<VoxelTerrain> + <VoxelStructure> — blocky voxel terrain renderer (hive spec)
Render a
VoxelGrid(a flat integer height-per-cell grid) as a Minecraft-style blocky terrain — instanced cubes, height-banded color, orbit-controllable — with an optional procedural structure (a simple house silhouette) resting on top. Plug-and-play, importable into personal-site, publicly showcased at/components/voxel-terrain(not gated — seedocs/components/content-engine.mdfor the one gated page that also uses this component).
Design discussion (RESOLVED architecture — read first):
.pHive/epics/minecraft-content-engine/docs/design-discussion.md — this doc documents
those decisions, it does not re-derive them.
Why (operator intent — honor this)
The operator's own framing (CLAUDE.md's 2026-08-07 priority order, position 3 of 4):
"landscape to minecraft so that I can take the 3d model and the landscape and put a
sample house or structure in place for the content engine where I let people see the
engineering, the minecraft of it, the flight docs, etc." <VoxelTerrain> is the
"minecraft of it" half of that; docs/components/content-engine.md covers the page that
composes it alongside the engineering/flight-log panels.
This is a standalone 3D scene, not a map overlay — unlike the land-overlay epic's
GeoAnchoredModel/createModelLayer (raw three.js drawn into MapLibre's own WebGL
context and render loop, see docs/components/land-overlay.md), <VoxelTerrain> reuses
<Model3D>'s r3f/drei stack (<Canvas> + <Bounds> auto-framing + <OrbitControls>,
see components/Model3D/Model3D.tsx) because there's no map to composite into here —
just a mesh in its own canvas, the same shape of problem <Model3D> already solved.
The data type — VoxelGrid (lib/voxel-types.ts)
export interface VoxelGrid {
slug: string; // stable slug identifying the source property, e.g. "2806-prado"
title: string; // human-readable title
size: number; // grid is size x size cells
heights: number[]; // flat row-major array of integer block heights; length === size*size
}
heights[row * size + col] is the block height (a small positive integer, e.g. 1-8) at
that cell. This is data shape only — deliberately no color/material/texture field,
mirroring lib/layer-types.ts's LayerDef staying free of rendering detail: coloring is
<VoxelTerrain>'s own concern (see "Height-band coloring" below), not baked into the
data. heights.length === size * size is enforced by convention (the type can't express
it structurally) and checked at runtime by
public/minecraft-samples/2806-prado/heightmap.json's own test —
public/minecraft-samples/2806-prado/heightmap.test.ts — via a hand-written
assertVoxelGrid runtime type-guard, not just a tsc cast, since a JSON import has no
literal-type narrowing to check the actual committed values against.
Heightmap data prep — offline script, real terrain, tuned by observation
public/minecraft-samples/2806-prado/heightmap.json is derived from real data already in
this repo: public/layer-viewer-samples/2806-prado/hillshade.tif, the uint8
synthetic-elevation COG <LayerViewer>'s sample data already established as public-safe.
Updated 2026-08-08: hillshade.tif was reprojected from EPSG:32621 to EPSG:3857 to
fix a @geomatico/maplibre-cog-protocol bounds bug (see
docs/components/land-overlay.md's "Fixed" section) — its pixel dimensions shifted from
512×512 to 549×522 and its value range from 78–216 to 0–213 as a result of bilinear
resampling. heightmap.json was regenerated from the reprojected file using the same
derivation described below, so it stays self-consistent with the committed hillshade.tif.
Updated again, 2026-08-08 (layerviewer-sample-dataset-overhaul): hillshade.tif was
regenerated a second time — as part of a full replacement of the <LayerViewer> sample
dataset at a new real location (see docs/components/layer-viewer.md's "Sample data
provenance" section) — and is now 1268×701 (a portrait aspect matching the new sample
ortho's own real extent, not square-ish like the two prior versions). heightmap.json was
regenerated again in lockstep from this file using the same average-pool + quantize
approach. This exposed a real bug in heightmap.test.ts's independent re-derivation
script: it computed a single pooling block size from arr.shape[0] (rows) alone and
applied it to both axes, which silently assumed a square-ish source grid; against this
portrait-aspect file the reshape failed outright (701 columns can't cover a
32 × (1268 // 32) = 1248-wide block). Fixed by computing block_h/block_w separately,
matching how the JSON is actually derived — see that file's own comment.
This is deliberately not a client-side
GeoTIFF-decoding dependency — a one-time offline Python/rasterio script (matching the
pattern already used to generate the hillshade COG itself) does the conversion; only its
JSON output is committed, per this repo's established throwaway-script convention (the
script itself was intentionally not committed — see
.pHive/epics/minecraft-content-engine/stories/minecraft-types-and-heightmap.yaml's
do_not).
The conversion, concretely:
- Average-pool the 512×512 pixel grid down to a coarser N×N grid — each output cell is the mean of the corresponding block of source pixels (e.g. at 32×32, each cell averages a 16×16 pixel block).
- Quantize each cell's averaged value from the source's
[78, 216]pixel range into a small integer height band,[1, 8]— exactly how Minecraft-style heightmap terrain generation works. - Write
public/minecraft-samples/2806-prado/heightmap.jsonconforming toVoxelGrid,slug: "2806-prado",titlereflecting that it's derived sample terrain (not real photogrammetry — 2806 Prado has no real DSM yet, per CLAUDE.md's Phase-0 blocker).
Grid size — 32×32, tuned by lag-1 spatial autocorrelation, not guessed. The story
explicitly called for a live-tune-and-document decision (the same precedent as
land-overlay's model-scale search), not an arbitrary pick. Candidate sizes 8/16/24/32/48/
64/128 were compared by computing each candidate grid's lag-1 spatial autocorrelation
— the Pearson correlation between the grid and itself shifted by one column, i.e. "how
similar is a cell to its immediate horizontal neighbor" (the same statistic
heightmap.test.ts's "adjacent cells are spatially correlated" test checks against the
committed grid, threshold > 0.5). Real terrain has high spatial autocorrelation
(neighboring cells share similar elevation); pixel-level sensor noise does not. Measured:
0.536 at size 8 → 0.856 at 32 → 0.925 at 64. Higher isn't automatically better here —
16 was visibly too coarse (the hillshade's real ridge/basin structure blurred to mush at
that resolution), while 64 mostly just re-exposed the source pixel data's own noise floor
as macro-scale "terrain" rather than adding genuine additional structure, at 4× the voxel
count (and therefore 4× the draw burden — see "Instancing" below). 32 landed as the real
terrain-vs-mush/noise sweet spot — high enough autocorrelation to read as coherent
terrain, low enough resolution to stay a recognizably blocky Minecraft aesthetic rather
than a smooth heightfield.
Verification, not just visual judgment. heightmap.test.ts (kept, not throwaway — it
re-runs under npm test and catches drift if the sample data or VoxelGrid's shape ever
changes) checks, against the actual committed heightmap.json:
- The shape type-checks as a
VoxelGridandheights.length === size * size === 1024. - Real variation exists: at least 4 distinct height bands, standard deviation
> 0.5(not a flat plateau). - Adjacent-cell (lag-1) spatial correlation
> 0.5(terrain-like, not random per-cell noise). - Independent re-derivation: if
python3/rasterioare available on the test machine, the test re-reads the actualhillshade.tif, re-pools it to 32×32 in Python, and asserts the result correlates> 0.95with the committed JSON values — proof the committed grid genuinely reflects the real source pixel gradient, not just "any" variation that happens to look plausible. Skipped (not failed) ifpython3isn't onPATH, matching the layer-viewer sample data's ownrio-cogeo-availability skip pattern.
The components — <VoxelTerrain> / <VoxelStructure> (components/VoxelTerrain/)
import { VoxelTerrain, VoxelStructure } from "@/components/VoxelTerrain";
import type { VoxelGrid } from "@/lib/voxel-types";
import sampleGrid from "@/public/minecraft-samples/2806-prado/heightmap.json";
const grid = sampleGrid as VoxelGrid;
<VoxelTerrain grid={grid}>
<VoxelStructure grid={grid} gridX={16} gridZ={16} />
</VoxelTerrain>
export interface VoxelTerrainProps {
grid: VoxelGrid;
/** Rendered inside the SAME <Canvas>/<Bounds> as the terrain — the slot
* <VoxelStructure> (or any other r3f content) composes into. */
children?: ReactNode;
className?: string;
}
export interface VoxelStructureProps {
/** The SAME grid passed to the enclosing <VoxelTerrain> — used to look up the
* terrain height at (gridX, gridZ) so the house rests on top of it, not floating
* above or sunk into it. */
grid: VoxelGrid;
gridX: number; // grid column (0-indexed) the house is centered on
gridZ: number; // grid row (0-indexed) the house is centered on
width?: number; // footprint width in blocks (X axis). default 5
depth?: number; // footprint depth in blocks (Z axis). default 5
wallHeight?: number; // wall height in blocks, before the roof starts. default 3
}
<VoxelTerrain> owns the <Canvas>: a two-light rig (ambient + directional, same
"glTF/PBR materials render solid black with zero lights" lesson land-overlay's showcase
story learned live, applied here from the start rather than rediscovered), an initial
camera position roughly proportional to grid.size (so the pre-<Bounds>-fit frame isn't
wildly off), and <Bounds fit clip observe margin={1.2}> wrapping both the terrain and
children — the same auto-framing convention <Model3D> established, fitting the camera
to the rendered content's actual bounding box on mount and re-fitting if it changes.
<OrbitControls makeDefault /> registers as the r3f store's active controls so <Bounds>'s
fit animation and orbit controls cooperate instead of fighting, again mirroring
<Model3D>.
<VoxelStructure> is not a standalone component — it's an r3f element with no
<Canvas> of its own, meant to render as a child of <VoxelTerrain> so it shares that
Canvas's WebGL context, camera, and <Bounds> auto-framing (mounting it outside a
<VoxelTerrain> throws — r3f elements like <instancedMesh> only exist inside a Canvas's
render tree). It requires the same grid prop as the enclosing <VoxelTerrain> — not
just gridX/gridZ — specifically so it can look up the terrain's own height at that
cell (heightAt in voxel-geometry.ts) and stack its walls starting exactly there; this
is what guarantees the house sits on the terrain surface rather than floating above or
sinking into it, using the identical gridToWorldX/gridToWorldZ/level-to-Y math
<VoxelTerrain> uses for its own ground blocks, not an independently-guessed offset.
The house shape (buildHouseBlocks in voxel-geometry.ts): a hollow-perimeter box for
walls (width × depth footprint, wallHeight blocks tall — hollow, not solid, so it
reads as walls rather than a filled block), topped by a stepped pyramid roof that shrinks
by one cube per side per level until it caps out (or the footprint can't shrink further).
Procedural cubes only, deliberately not a reused glTF (unlike land-overlay's reused
sample duck) — the design discussion records this as a genuine visual/technical fit call,
not just "avoid a new asset": a smooth-shaded organic model sitting on a blocky voxel
terrain would look like an import mistake, while a procedural block-house reuses the exact
same cube-instancing primitive the terrain itself needs, and is the only option that's
visually consistent with the blocky aesthetic by construction. No Minecraft assets or
textures are used or needed anywhere in this component family — it's an original blocky
look, not an emulator.
Height-band coloring
heightBandColor(level) in voxel-geometry.ts colors a block by its stacked level
within its column (1 = the ground layer, increasing upward) — not by the column's total
height. A small, fixed 3-band palette: grass-green (levels 1–3) → dirt-brown (4–5) →
stone-grey (6+). This is what gives tall columns a layered look (valleys/low ground read
green, peaks read grey, matching how real terrain actually looks) instead of every block
in a tall column being uniformly colored by that column's max height.
Instancing — the performance requirement, verified live
A 32×32 grid with height-stacking is several thousand cubes (the real sample: 4,000+
terrain blocks), plus ~80 more for the house structure. Rendering one THREE.Mesh per
cube at that count is not a style choice to avoid — it's a genuine performance floor the
design discussion flagged explicitly ("a real requirement, not a preference... this should
be verified live, not just assumed correct because the code compiles"). Both
<VoxelTerrain> and <VoxelStructure> render through the same shared primitive,
VoxelInstances (an internal, non-exported function in VoxelTerrain.tsx, deliberately
reused by both rather than each component inventing its own instancing path): it wraps
drei's <Instances>/<Instance> — a thin API over a single THREE.InstancedMesh — around
a flat list of {position, color} placements (voxel-geometry.ts's buildTerrainBlocks/
buildHouseBlocks), producing one draw call per block list, not one per cube.
How this was actually verified (not just "the code calls <Instances>, so it must be
fine"): live, against the real running dev-preview route, by monkey-patching
WebGLRenderingContext.prototype.draw* (the actual GL draw-call entry points) and
counting invocations for a full scene render. Result: 4 instanced draw calls total, 0
plain per-cube draw calls, for a scene with 4,000+ terrain blocks plus ~80 house blocks
— direct evidence the <Instances> wrapper is genuinely producing instanced GL calls, not
silently falling back to a mesh-per-cube path if drei's API were ever misused. The same
live pass also confirmed: real height variation visibly renders as green/brown/grey bands,
the house sits on the terrain surface (not floating or embedded), drag/scroll genuinely
rotate/zoom the view (the rendered frame changed materially in response), and there's no
multi-second freeze on mount (~445ms to the first real frame, requestAnimationFrame kept
firing afterward) — the story's live jank-check proxy. This numeric-verification-over-
code-inspection approach is the same discipline the land-overlay epic established for its
own hardest-to-get-wrong rendering work (see docs/components/land-overlay.md's "numeric
verification approach").
The pure, unit-tested core vs. the WebGL-dependent shell
voxel-geometry.ts deliberately holds every piece of logic that doesn't need a live
Canvas/WebGL context — gridToWorldX/gridToWorldZ (grid-cell → world-space centering),
heightAt (grid-index lookup, throws on an out-of-bounds cell rather than silently
clamping — a mis-specified structure position is a bug worth surfacing loudly), the
height-band color mapping, and both block-list builders — split out of
VoxelTerrain.tsx/VoxelStructure.tsx specifically so this math is unit-testable without
a browser, the same "extract pure/testable logic out of a 'use client' r3f component"
precedent components/Model3D/Model3D.tsx's toErrorMessage extraction and
lib/maplibre-model-layer.ts's pure-matrix-helpers set. voxel-geometry.test.ts covers
this with 14 ordinary Vitest unit tests — no WebGL, no r3f, no jsdom canvas shimming
needed. What's not unit-tested this way — whether <Instances> actually issues instanced
GL draw calls, whether the rendered pixels are visually correct — is exactly the live,
Playwright-adjacent verification described above; jsdom has no WebGL, so npm test's
coverage of the rendering shell stops at "the right props/children are composed," by
construction, not by oversight.
Usage
"use client";
import dynamic from "next/dynamic";
import type { VoxelGrid } from "@/lib/voxel-types";
import sampleGrid from "@/public/minecraft-samples/2806-prado/heightmap.json";
// Heavy client-only WebGL viewer (@react-three/fiber's <Canvas>) — CLAUDE.md's
// "every heavy viewer = next/dynamic({ssr:false})" convention, same as every
// other viewer in this stack.
const VoxelTerrainDemo = dynamic(() => import("./VoxelTerrainDemo"), { ssr: false });
// VoxelTerrainDemo.tsx:
import { VoxelStructure, VoxelTerrain } from "@/components/VoxelTerrain";
const grid = sampleGrid as VoxelGrid;
export default function VoxelTerrainDemo() {
return (
<VoxelTerrain grid={grid} className="h-full w-full">
<VoxelStructure grid={grid} gridX={16} gridZ={16} />
</VoxelTerrain>
);
}
VoxelInstances (the shared cube-instancing primitive) is deliberately not exported
from components/VoxelTerrain/index.ts — it's an internal rendering detail, same as
Model3D.tsx's toErrorMessage staying un-re-exported. Only VoxelTerrain/
VoxelTerrainProps and VoxelStructure/VoxelStructureProps are public. Both components
are copy-portable into a standalone consumer like personal-site — everything they
transitively import is scoped to components/VoxelTerrain/ + lib/voxel-types.ts, no
app/ or gating dependency, same precedent as components/Model3D/index.ts and
components/LayerViewer/index.ts.
The public showcase page lives at /components/voxel-terrain
(app/(showcase)/components/voxel-terrain/page.tsx), public/ungated, following the
standing showcase-site pattern (see .pHive/CONTEXT.md). The one gated consumer is
/properties/[slug]/engine — see docs/components/content-engine.md.
Phase fit
- This epic:
<VoxelTerrain>+<VoxelStructure>— instanced-mesh blocky terrain renderer, reusing<Model3D>'s r3f/drei stack (not land-overlay's harder MapLibre custom-layer approach, since there's no map to composite into for a standalone voxel scene). - Deferred, not this epic's scope: per-property real DSM-derived terrain (today, the
same sample 2806-prado grid is shared across every slug on the gated content-engine
page — see
docs/components/content-engine.md's note on this). Once real per-property WebODM/DSM output exists, terrain would need the same per-slug real-vs-fallback resolution pattern the engineering/flight-log content already has. - Next (per the operator's 2026-08-07 priority order): the telemetry-driven video
overlay epic is queued after this one; it does not extend
<VoxelTerrain>orVoxelGrid— seedocs/components/content-engine.md's note onFlightLogEntry's scope for the same anti-speculation precedent applied to that epic's own future type.
Real data update (2806 Prado, real georeferenced-fix story)
The "Deferred, not this epic's scope" note above is now resolved for 2806-prado
specifically: public/minecraft-samples/2806-prado/heightmap.json is no longer derived
from a synthetic hillshade — it's block-pooled (32×32, mean per cell) and quantized
directly from the operator's real, ODM-reconstructed DSM (dsm.tif, real elevation in
meters, not a shading proxy). The DSM raster itself isn't duplicated into this repo as a
second committed copy (it's a real 2.8MB geospatial file and hillshade.tif already
carries its committable derived form in public/layer-viewer-samples/2806-prado/) — see
public/minecraft-samples/2806-prado/heightmap.test.ts's own comment for why its previous
hillshade-correlation cross-check was removed rather than pointed at a duplicate file: that
check no longer applies to elevation-derived heights anyway (hillshade encodes local
slope+aspect shading, not absolute elevation — no longer the right thing to compare
against). The statistical validations (range, real variation, spatial correlation) remain
real, meaningful checks independent of source raster.
Every OTHER slug on the gated content-engine page still shares this same sample grid — the per-slug real-vs-fallback resolution pattern this note used to flag as fully deferred is now proven for one real property, not yet generalized to arbitrary slugs.
v2 update
heightmap.json was regenerated from v2's DSM (the reconstruction that added an oblique
orbit pass alongside the nadir grid — see docs/components/layer-viewer.md's own "v2
update" section for the full nadir-occlusion explanation). Same block-pooling/quantization
method as before, just against a real DSM with better-resolved tree-canopy elevation —
terrain features under tall trees are less likely to be artificially flattened by the
smeared v1 reconstruction's height estimate.