Skill v1.0.0
currentAutomated scan100/100version: "1.0.0" name: threejs-architecture description: Use when building an architectural or floor-plan visualisation in three.js or react-three-fiber: turning a building plan into 3D geometry, walls from centrelines, mitred junctions, procedural materials, sun and shadow studies, orbit and first-person navigation, or recording a WebGL canvas to video. Covers the conventions to settle before writing code and the failure modes that render silently wrong output. Not for general three.js work unrelated to buildings, and not for CAD or BIM file formats.
Architectural visualisation in three.js
Conventions and failure modes for turning a floor plan into a navigable 3D model. Settle everything in "Decide first" before writing code; most of the rest exists because getting one of those wrong is expensive to unpick later.
Decide first
Units. Pick one and use it everywhere, converting only at display time. Architectural drawings are dimensioned in feet and inches, so inches is the natural internal unit: every dimension on the sheet becomes an integer or a simple fraction, and no code ever converts. Do not mix.
Two coordinate spaces, and one conversion point. Plan space is what the drawing uses: (x, y) in inches from a grid origin, y increasing across the page. World space is three.js: x right, y up, z toward the viewer. The mapping is:
world = (plan.x - CX, height, plan.y - CZ)
where CX/CZ centre the model on the origin. Put that conversion in exactly one module. Every bug where something renders in the wrong place traces back to a second place that also does the conversion.
Heights are per storey. Store z as height above the storey's own floor level, and keep a LEVELS table. Otherwise every first-floor coordinate carries a magic offset.
The plan is data, not geometry. Author walls, openings and rooms as plain arrays in one file. Derive geometry, schedules, validation and any 2D output from it. Never measure something twice.
Walls
Author walls as centrelines with a thickness, not as boxes:
[x1, y1, x2, y2, thickness]
Rendering each wall as a box is the obvious first implementation and it is wrong in a way that only shows up on close inspection. Two boxes meeting at a corner leave a triangular notch of missing material, because neither extends into the other's width. A wall crossing another mid-run stops dead at its face.
The correct approach is a solver:
- Split every wall into half-edges and cluster near-coincident endpoints into
nodes. Keep the weld tolerance small; a generous tolerance will fuse two walls across a real gap. Single digits of inches.
- Detect crossings, not just shared endpoints. A partition landing in the
middle of an unbroken wall must create a node there, or the solver never sees the junction.
- At each node, sort the half-edges by angle and intersect each edge's left
offset line with the next edge's right offset line. That intersection is the mitre point.
- Nodes with three or more arms need a cap polygon over the node itself,
triangulated as a fan about the node centre. Without it a T-junction has a hole in the middle.
Openings sit on the wall centreline and get subtracted from whichever wall run they touch, matched by proximity rather than equality. Clamp an opening's jambs so they cannot intrude into a mitre zone; an opening an inch from a corner otherwise produces a self-intersecting sliver.
Geometry generation
Extrude plan polygons rather than using primitives. One prism(polygon, z0, z1) function covers walls, slabs, bands, copings and cladding. It also lets you apply consistent UVs, which primitives do not.
Use world-scale UVs. Map u to distance along the run and v to height, both divided by a constant (UV_SCALE, say 48 inches). A texture then repeats at the same physical size on a 3-foot panel and a 30-foot wall. BoxGeometry gives 0..1 UVs per face, which stretches every texture differently and looks wrong the moment two differently sized surfaces meet.
Merge aggressively. One merged geometry per material per storey, instead of one mesh per wall. Draw-call count is what costs you.
mergeGeometries returns null on mixed indexing
BufferGeometryUtils.mergeGeometries silently returns null when handed a mix of indexed and non-indexed geometry. It does not throw, and a null geometry renders as nothing. BoxGeometry and ExtrudeGeometry are indexed; hand-built BufferGeometry usually is not. Call .toNonIndexed() on everything before merging, and treat a whole material disappearing as a symptom of this.
Curves and diagonals
A plan extrusion cannot produce an arch or a diagonal brace, because it only varies in the horizontal plane. Build those flat in the plane of the face using THREE.Shape plus ExtrudeGeometry, then map them into place with a matrix that sends local (along, height, outward) to world. Set materials to DoubleSide and stop worrying about the handedness of that matrix.
Materials
Generate textures procedurally. Canvas 2D into THREE.CanvasTexture covers plaster, brickwork, floor finishes, grass and paving convincingly at architectural viewing distances. No assets to fetch or license, and a material becomes a function of its arguments. Memoise by argument.
t.wrapS = t.wrapT = THREE.RepeatWrapping;t.colorSpace = THREE.SRGBColorSpace; // omitting this washes everything outt.anisotropy = 8; // grazing angles are most of an interior
Thin geometry needs `side: DoubleSide`. Cladding panels, screens and railings are seen from both faces.
Scale cues matter more than material quality. A room with no skirting, cornice or architrave reads as the wrong size regardless of how good the textures are, because nothing in frame has a known dimension. Adding a 4.5 inch skirting band and a 3 inch cornice does more for perceived realism than any material work.
Lighting and daylight
One directional light for the sun, with an explicit shadow camera sized to the site:
<directionalLightcastShadowshadow-mapSize={[2048, 2048]}shadow-bias={-0.0004}shadow-normalBias={2} // in world units; scale to your unit choiceshadow-camera-left={-950} shadow-camera-right={950}shadow-camera-top={950} shadow-camera-bottom={-950}shadow-camera-near={100} shadow-camera-far={5600}/>
An oversized shadow camera wastes resolution and closes narrow gaps. Size it to the model.
Real solar position is cheap and worth it. A NOAA solar-position routine is about forty lines and takes latitude, longitude, day of year and hour. Map its compass azimuth through the plan's north bearing to get a direction in plan space. Derive north from the structural grid and then check it against the north arrow on the drawing.
Hidden storeys must still cast shadows. When the view shows one floor with the ceiling off, the floors above are not drawn, so sunlight falls on the ground floor as if the building had no upper storeys. Render the hidden floors as shadow-only meshes:
new THREE.MeshBasicMaterial({ colorWrite: false, depthWrite: false })
with castShadow on. Invisible in the colour pass, present in the shadow pass.
Shadow maps model occlusion, not bounce. There is no global illumination. A room lit only through a window will read as a cave, because in reality most of its light has bounced off the floor. Sampling the sky dome with several dim shadow-casting directionals gives geometry-aware daylight, so a space open to sky brightens and a deep room darkens, but it still adds no bounce. Keep an unoccluded ambient floor so interiors stay legible, and be explicit that this is not a lighting simulation. Real bounce needs a path tracer.
Navigation
Orbit for inspection, pointer lock for first person. Do not hand-roll mouse look; PointerLockControls applies raw pointer deltas to a Euler once per event, which is jitter-free, whereas per-frame lookAt re-aiming is not.
Run movement on a fixed timestep accumulator, not on frame delta:
acc += Math.min(delta, 0.25);while (acc >= STEP) { acc -= STEP; integrate(STEP); }
A dropped frame otherwise becomes a large positional jump, which reads as the camera teleporting through a wall.
A free-fly camera is worth adding alongside walk mode. Walk mode's constraints (eye height, collision, stair following) are what make it feel like a building; they are exactly wrong when someone wants to get above the roof.
Straight down is degenerate. A camera looking along (0, -1, 0) with an up vector of (0, 1, 0) has an undefined orientation. Either set up to (0, 0, -1) for a plan view, or tilt a few degrees off vertical.
Interpolate camera moves in spherical coordinates about the target, not by lerping positions. A linear lerp between two viewpoints cuts the chord and drives the camera through the building.
Performance
`frameloop="demand"` is the right default for a model that is usually static, and it has one sharp edge: changing the camera imperatively re-renders no React component, so nothing schedules a frame. A field-of-view slider, a camera preset or a scripted animation will all appear to do nothing. Call invalidate() after any imperative change, and switch to frameloop="always" for modes that animate continuously.
Do not write to a store every frame. One state write per frame re-renders every subscriber sixty times a second and lands DOM work inside the render loop. Write only when a value visibly changes.
Never write state during render. A store setter called inside useMemo schedules an additional render pass each time it fires. Harmless at one call per user action, a stutter when it fires per frame. Use an effect.
Cap device pixel ratio at about 1.5. A DPR of 2 quadruples shaded pixels.
Labels
Two options with a decisive difference:
drei's<Html>renders a DOM node positioned over the canvas. Crisp, easy
to style, and invisible to anything that captures the canvas.
- A sprite with a
CanvasTextureis real scene content, so it records, sorts
with the scene and works in screenshots.
Use <Html> for interactive UI, sprites for anything that must appear in an image or a video.
Recording
canvas.captureStream() plus MediaRecorder records the WebGL canvas directly, which beats screen capture: no cursor, no window chrome, exact size, steady frame rate. Two requirements:
- `preserveDrawingBuffer: true` must be set at context creation, or frames
read back empty.
- Only the canvas is captured. Overlays that are DOM will not be in the
file. Draw each frame into a second 2D canvas, composite the overlay on top, and record that canvas instead.
Ask for MP4 first; recent Chromium can mux video/mp4;codecs=avc1 directly, which is what most platforms accept. Fall back to WebM.
Validation
Check the plan data, not the picture. A render that looks right is not evidence, and the errors that matter are geometric.
Worth writing as standalone scripts over the data:
- every wall junction closes, with the residual hole area reported in real units
- every opening lands on some wall
- no slab is unsupported from below, allowing for intentional cantilevers
- no railing lies inside a wall
- no furniture sits in a door swing, on a stair, or off the slab
- room areas match the figured dimensions on the drawing, within a stated
tolerance, with intentional deviations recorded and explained
Overlay the original drawing. Put the scanned sheet on the floor plane at true scale under the model with an opacity control. This is the only check that catches a whole-plan error, because everything else validates the data against itself. Calibrate by hand with nudge controls; automatic registration by line detection or cross-correlation performs worse than a person dragging a slider for thirty seconds.
Checklist
- [ ] One unit, converted only at display time
- [ ] Plan and world space converted in exactly one module
- [ ] Walls authored as centrelines and mitred by a solver
- [ ] Openings on centrelines, clamped out of mitre zones
- [ ]
.toNonIndexed()before merging geometry - [ ] World-scale UVs,
SRGBColorSpaceon every texture - [ ] Skirting and cornice present, for scale
- [ ] Shadow camera sized to the model
- [ ] Hidden storeys cast shadows
- [ ]
invalidate()after imperative camera changes - [ ] Fixed-timestep movement
- [ ] Sprites, not
<Html>, for anything that must be captured - [ ]
preserveDrawingBufferif recording - [ ] Validation scripts over the data
- [ ] Drawing overlay for whole-plan verification