The short version
  • A back yard assembles itself as you scroll — 1,288 parts across 15 build stages, drawn live in the browser rather than played as a video.
  • One parametric spec is the single source of truth. It feeds a real-time WebGL scene and, via an export, a Blender/Cycles render of the identical model. Neither renderer defines geometry, so the two can never drift apart.
  • The spec cites the Alberta Building Code inline — guard heights, footing depth, joist spacing — so the model is correct by construction for an audience that includes people who build decks for a living.
  • Every part carries {stage, order, from}. That metadata, not keyframing, is what turns a model into a build sequence. Nobody animated 119 deck boards.
  • The export ships 401 pre-computed camera positions rather than the camera algorithm, which removes an entire category of "why don't these two match" bugs.
  • Cycles costs ~36 seconds a frame; WebGL costs ~16 milliseconds. So iterate in WebGL and render the hero in Cycles — the cheap renderer is the authoring environment.
  • With CAD as the source instead of a hand-written spec, the geometry pipeline is solved tooling. Assembly order is the part CAD can't give you — and it's the whole difference between a 3D model and a story.

Scroll down a page and a back yard builds itself. Piers go in the ground first, then posts, then the beam and the joists, then boards run across the frame and a picture-frame border closes the edge. A fence rises along the property line. Concrete gets formed, poured, finished, and the forms come away again. Beds go in, then planting, then sod.

Nothing about that is a video. It's a real-time 3D scene, assembled from 1,288 individual parts, and the scroll position is the only input. It runs on the site we built for a Calgary deck and fence builder. The same model, exported, rebuilds itself inside Blender for the photoreal hero shot — because it is not a second model, it's the same one.

This is how it's built, what it cost, and where the approach goes when the source of truth stops being a hand-written spec and starts being the client's own CAD.

The shipped homepage journey. Scroll position is the only input, and every frame is drawn live in the browser — this is not a video playing.
1,288
parts in the model
15
build stages, five trades
401
baked camera samples
2,250×
faster per frame than Cycles

The rule that makes the whole thing work

There's one architectural decision here, and everything else follows from it.

One spec. Two renderers. No way for them to disagree. deck-spec.js every dimension in mm building code cited inline deck-build.js 1,288 discrete parts each tagged stage/order/from scene.js → the live WebGL scene scroll drives assembly, real time free to change, interactive this is where iteration happens parts.json → build_deck.py Blender / Cycles rebuilds the same yard ~36s per frame, photoreal used for the hero still only The export carries 401 camera samples, so Blender replays the move instead of reimplementing it and drifting.
One parametric specification feeds a parts builder; two renderers consume the parts. Neither renderer defines geometry, so they cannot disagree about what is being built.

A parametric specification defines the yard. A builder turns that specification into discrete parts. Two renderers consume those parts: a real-time WebGL scene for the site, and a Blender/Cycles scene for photoreal stills.

The renderers never define geometry. They can't — they have no dimensions of their own to work from. Which means the two can never disagree about what's being built, no matter how much either one changes.

The tempting alternative is what most studios do: model it once in Blender for the pretty renders, then rebuild a simplified version in Three.js for the web. That works exactly until the first change request. Move the stair, widen the deck, and now you're doing it twice and hoping they still match. Two weeks later they don't, and nobody knows which one is right.

The spec is a building code document that happens to be executable

Here's the top of the specification file. Read the comments rather than the numbers:

// src/deck-spec.js
/**
 * Code basis (Calgary / Alberta Building Code, verified 2026-08-04):
 *   - Building permit required above 600mm to deck surface. This deck is 850mm.
 *   - Guard height 900mm where surface is 600mm–1800mm above grade.
 *     (1070mm only at >=1800mm.)
 *   - Maximum guard opening 100mm — satisfied by glass infill panels.
 *   - Footings min 250mm diameter, bearing below Calgary frost depth of ~1200mm.
 *   - Joists at 406mm (16") on centre — also AZEK's maximum for perpendicular
 *     board runs. A 45-degree board run would require 305mm (12").
 *   - Stair rise 125–200mm, run min 255mm. Ours: 170mm rise / 280mm run.
 */

Every one of those numbers was checked against the actual code before a single triangle was drawn, and each constant in the file carries its justification next to it:

export const RAILING = {
  height: 900,          // ABC: 900mm for 600–1800mm above grade
  postSize: 51,         // 2" black aluminium
  postSpacingMax: 1830,
  glassThickness: 12,
  glassInset: 30,       // reveal between glass edge and post
};

export const FOOTING = {
  diameter: 250,        // 10" min
  depthBelowGrade: 1200,// below Calgary frost
  baseHeight: 90,       // black standoff post base — keeps end grain off concrete
};

This matters more than it looks, and the reason is commercial rather than technical.

The audience for a deck builder's website includes people who build decks. A contractor looks at a rendering the way a copy editor looks at a paragraph — the errors surface before anything else does. A guard at the wrong height, joists spaced too far apart for the board run, a post base sitting timber directly on concrete, a guard running straight across a stair opening where code requires it to stop. Any one of those turns your beautiful animation into evidence that you don't know what you're selling.

Encoding the code basis in the spec means the model is correct by construction. There is no separate review step where somebody eyeballs the render and hopes. If the guard is 900mm in the file, it's 900mm in the WebGL scene and 900mm in the Blender render, and it is 900mm because a line in a comment says why.

Two frames from the build journey showing stage captions naming the beam type and joist spacing
The code basis doesn't stay in the source. Each stage surfaces the detail that justifies it — standoff bases so end grain never sits in water, joists at 406mm on centre in galvanized hangers.

The transferable idea: when you model something a professional audience will scrutinise, put the standard in the source and cite it inline. Every industry has its version — clearances, tolerances, load ratings, accessibility dimensions. That comment block is the difference between a visualisation and a claim you can defend.

Parts, not a model

The second decision is that nothing here is a "3D model of a deck." It's a pile of parts that know things about themselves.

// src/deck-build.js
/**
 * Every part carries { stage, order, from } so the scroll timeline can bring it
 * in independently. Nothing here is decorative: if it exists in the model it
 * exists on a real deck, and it sits where code says it sits.
 */
const add = (mesh, stage, order, from, exit) => {
  mesh.userData = { ...(mesh.userData||{}), stage, order, from, trade: TRADE };
  mesh.userData.finalPos = mesh.position.clone();
  if (exit) mesh.userData.exit = exit;   // { stage, order?, to }
  group.add(mesh);
  parts.push(mesh);
  return mesh;
};

Four pieces of metadata do all the work:

  • stage — which phase of the build it belongs to, out of fifteen.
  • order — where inside that stage it lands, so 119 deck boards arrive in sequence rather than all at once.
  • from — where it flies in from, so parts arrive from a direction that makes physical sense.
  • trade — deck, fence, concrete, landscape or furnishing. The decks service page filters to deck so a visitor reading about decks doesn't watch fence post-holes appear.
What 1,288 parts are actually made of one yard: deck, fence, concrete, planting, furniture fence 457 landscape 325 deck 210 concrete 206 furnish 90 and by primitive: 1129 boxes 114 spheres 45 cylinders A deck is boxes. That is the whole reason this runs on a phone.
Every part in the exported model, grouped by trade and by primitive. 1,129 of the 1,288 parts are boxes.

That last number is the one worth sitting with. Of 1,288 parts, 1,129 are boxes. A deck is boards and lumber and posts, which is to say a deck is boxes — and a scene made almost entirely of the same primitive is dramatically cheaper to build, to instance and to draw than one made of imported meshes. The performance budget was won at the modelling stage, before a line of render code existed.

The fifteen stages

Stage Trade Parts Scroll span
site ground 0.00 – 0.06
piers fence, deck 34 0.04 – 0.12
fence-posts fence 46 0.12 – 0.18
fence-rails fence 38 0.17 – 0.24
fence-boards fence 350 0.23 – 0.30
deck-frame deck 26 0.29 – 0.38
deck-joists deck 54 0.36 – 0.46
deck-boards deck 119 0.45 – 0.58
concrete-base concrete 65 0.57 – 0.65
concrete-pour concrete 127 0.64 – 0.71
concrete-finish concrete 14 0.70 – 0.77
land-beds landscape 56 0.76 – 0.82
land-planting landscape 202 0.81 – 0.89
land-sod landscape 67 0.88 – 0.95
reveal furnishing 90 0.94 – 1.00

Two things in that table are deliberate. The spans overlap — piers begin at 0.04 while the site stage is still finishing at 0.06 — because on a real site trades overlap, and hard cuts between phases read as a slideshow. And the part counts are wildly uneven: 350 fence boards against 14 concrete-finish parts. Even distribution would have been easier to write and completely wrong, because a fence genuinely is mostly boards.

The fence build runway on the Lamacraft fences page
The same engine filtered to the fence trade. A visitor reading about fences never sees deck joists arrive.

Scroll position drives assembly

With parts tagged, the timeline becomes arithmetic rather than animation. Each stage owns a slice of scroll; each part owns a slot inside its stage.

// src/scene.js
const PART_WINDOW = 0.45; // how much of a stage a single part takes to land

function setProgress(p) {
  const t = clamp01(p);
  for (const mesh of parts_) {
    const { _s, _e, _from, order, finalPos } = mesh.userData;
    const sp = clamp01((t - _s) / Math.max(1e-6, _e - _s));   // progress in stage
    const startAt = order * (1 - PART_WINDOW);
    const local = clamp01((sp - startAt) / PART_WINDOW);
    if (local <= 0) { mesh.visible = false; continue; }
    // ... interpolate from `_from` to `finalPos`, ease, done
  }
}

PART_WINDOW at 0.45 is the entire feel of the thing. Each part takes 45% of its stage to travel, which means parts overlap heavily — while board sixty is still landing, boards sixty-one through seventy are already on their way. Set it to 1.0 and every part in a stage moves in unison, which reads as a shrink-wrapped group rather than a crew working. Set it near zero and parts snap in one at a time, which is both slow and mechanical.

There's no keyframing anywhere. Nobody animated 119 deck boards. The boards animate themselves because each one knows its own position in the sequence, which is why adding a level or widening the deck needs no animation work at all — new parts arrive with the same rules.

Temporary parts

The detail that convinced the client the model understood construction is small and slightly pedantic.

Concrete needs forms. Forms are there while the pour cures, and then they come off. An animation that leaves the formwork standing forever is wrong in a way every trades person notices instantly.

// forms come off once the slab has cured
const STRIP_FORM = { stage: 'concrete-finish', order: 0.15, to: [0, 900, 0] };

Any part can carry an exit: a stage at which it leaves, and a direction to leave in. The forms rise away during the concrete-finish stage. Shoring behaves the same way.

It's a handful of lines. It's also the difference between a sequence that shows a building process and one that shows objects appearing.

Four frames from the Blender rebuild showing piers, posts, joists and boards
Four frames from the Blender rebuild, driven by the same exported parts file as the live scene. Piers and standoff bases, posts, joists at 406mm on centre, then boards.

The camera problem, and a solution worth stealing

Both renderers need to move the camera along the same path. The obvious approach — define the path in the WebGL scene, then reimplement the same interpolation in Python — is the obvious approach and it's wrong. Two implementations of a curve will diverge, and you'll be comparing renders trying to work out why the Blender shot sits four degrees off.

So the export doesn't ship the path. It ships the answer:

// src/scene.js — exportParts()
// Sample the camera path densely so Blender replays the identical move
// instead of reimplementing the interpolation and drifting from it.
const SAMPLES = 401;
const cameraSamples = Array.from({ length: SAMPLES }, (_, i) => {
  const c = cameraAt(i / (SAMPLES - 1));
  return { pos: orbitToPos(c), focus: c.focus };
});

401 pre-computed camera positions, baked at export. Blender reads them and moves between them. There is no second implementation of anything, so there is nothing to drift.

The same principle covers the easing. PART_WINDOW = 0.45 in the JavaScript has a twin in the Python:

# blender/build_deck.py
EASE_WINDOW = 0.45          # matches PART_WINDOW in scene.js

That's the one genuine duplication in the system, and it's commented as such so the next person knows the two must move together. Everything else crosses the boundary as data.

The generalisable rule: when two systems must agree, ship computed values across the boundary, not the algorithm that computes them. Sampling is cheap; two implementations of the same maths is a bug with a delay fuse.

What each renderer is actually for

The reason to keep both comes down to one measured number.

Blender/Cycles renders this scene at roughly 36 seconds per frame at 1600px and 128 samples. Eight stills is about six minutes. A 120-frame animation is over an hour, per attempt, before you've seen whether the camera move works.

The WebGL scene renders it at 60fps, for free, while you drag a slider.

Real-time WebGL Blender / Cycles
Time per frame ~16ms ~36 seconds at 1600px / 128 samples
Eight stills instant about six minutes
120-frame animation instant over an hour, per attempt
Change a dimension reload re-export, re-render
Shows the client three options in a call one option, tomorrow
Best at iteration, interaction, shipping light transport, final hero

So the division of labour is decided by arithmetic, not preference. Iterate in WebGL — that's where the spec lives, where a dimension change is instant, and where the client can be shown three options in a call. Render in Cycles only once, for the hero still, where light transport genuinely beats real-time shading.

This is the part clients find counterintuitive: the "cheap" real-time version is the authoring environment, and the "expensive" photoreal version is a downstream export. Most 3D pipelines have that relationship backwards, and pay for it in every revision round.

Making it survive a phone

Real-time 3D on the web is easy to demo and hard to ship, and almost all of the difficulty is mobile.

The specific problem here: the camera is composed for a 16:9 landscape frame. Put that same fixed vertical field of view on a portrait phone canvas and you get a narrow vertical column through the middle of the yard — the deck is off-frame, and the visitor sees a fence post and some grass.

// src/scene.js — resize()
// On a portrait canvas the fixed vertical FOV would slice the yard to a narrow
// column. Widen the vertical FOV so the frame keeps (cover x) the horizontal
// extent the 16:9 camera was composed for.
if (opts.portraitFit && camera.aspect < 1) {
  const baseHalfH = Math.atan(Math.tan(degToRad(34) / 2) * (16 / 9));
  const cover = opts.portraitCover ?? 0.95;
  const v = 2 * Math.atan(Math.tan(baseHalfH) * cover / camera.aspect);
  camera.fov = Math.min(70, radToDeg(v));
} else {
  camera.fov = 34;
}

Rather than recomposing every camera for portrait, the field of view is derived from the aspect ratio so the frame keeps the horizontal coverage the shot was designed around. The 70-degree cap stops it turning into a fish-eye on very narrow screens.

Alongside that, the things that decide whether a 3D section is usable rather than merely impressive:

  • Pause offscreen. An IntersectionObserver stops rendering when the canvas isn't in view. A WebGL canvas quietly drawing at 60fps below the fold is a battery complaint waiting to happen.
  • Shorten the scroll. The desktop journey runs long by design. On a phone the same distance is exhausting, so the sections are compressed and a skip control is offered.
  • Respect reduced motion. A scroll-driven build sequence is exactly the sort of thing that makes some people ill. There has to be a still frame path.
  • Budget the download. Because this is generated geometry rather than imported meshes, there is no model to download at all — the scene is built in the browser from a specification measured in kilobytes.

That last point is the quiet advantage of the parametric approach and it only exists because we didn't start from a mesh.

Where this goes: CAD as the source of truth

Everything above was hand-specified, because the client had no drawings — the deck in the animation is a designed one. That's the norm for a residential builder and it's why the spec file exists.

But plenty of businesses have the drawings already. Manufacturers have SolidWorks assemblies. Builders and architects have Revit and IFC models. Fabricators have STEP files. In those cases the hand-written spec is replaced by something far richer, and the interesting work moves.

Where this goes next: CAD as the source of truth the spec stops being hand-written and starts being the drawing 1 CAD / BIM Revit, IFC, Rhino, SolidWorks, SketchUp 2 Tessellate NURBS and solids become triangles 3 Reduce decimate, weld, instance repeats 4 Compress glTF + Draco or meshopt, KTX2 textures 5 Stage group by trade and assembly order 6 Ship scroll timeline, same as this build The part that matters is step 5. CAD already knows what every part IS. It does not know what order a crew installs them in — and that ordering is the entire difference between a 3D model and a story. Everything left of step 5 is a solved tooling problem. Step 5 is the design work.
The route from a CAD or BIM model to a web-ready scroll journey. Steps 1–4 are tooling. Step 5 is the part that needs a foreman.

Steps 1 through 4 are a solved tooling problem. CAD geometry is usually NURBS or solid bodies and has to be tessellated into triangles; the result is far denser than the web needs, so it gets decimated, welded and instanced; then it's compressed into glTF with Draco or meshopt geometry compression and KTX2 textures. There is mature tooling for every one of those stages, and the output is a file a browser can stream.

Source What it is What it carries beyond shape Route to the web
IFC Open BIM exchange Full property sets — type, material, fire rating, system IFC → glTF with property preservation
Revit BIM authoring Families, parameters, phasing, schedules Export IFC or glTF; phasing is the gift here
SolidWorks / STEP Solid modelling Assembly tree, part numbers, mates Tessellate → decimate → glTF
Rhino / Grasshopper NURBS + parametric The parametric definition itself Direct glTF export; Grasshopper can drive variants
SketchUp Lightweight modelling Groups, components, layers glTF export; usually needs cleanup

The column that matters is the third one. Geometry is the easy part — every one of those formats can be turned into triangles a browser will draw. What separates a useful pipeline from a pretty one is whether the meaning survives: that this component is a specific part number, that these walls belong to phase two, that this assembly has a variant.

The interesting part is what CAD carries that a mesh doesn't. A Revit or IFC model isn't a shape — it's a database of objects that know what they are. This wall is a wall, of this assembly type, with this fire rating. This fastener is that part number. Those property sets survive into glTF as extras if you preserve them deliberately, and once they do, the model can be interrogated rather than merely displayed: click a component and get its specification, filter by system, price a variant.

And here is the honest part, which is step 5. CAD tells you what every part is. It does not tell you what order a crew installs them in. Assembly order is not a property of the drawing — it's knowledge that lives with the people who do the work, and capturing it is a conversation with a foreman, not an import setting. Our fifteen stages came from how a yard actually gets built: you can't pour concrete before the piers, you don't sod before the beds, forms come off after the cure.

That sequencing is the entire difference between a 3D model and a story. Everything left of it is tooling. It's also, conveniently, the part a client can't get from a software vendor, which is what makes it worth paying an agency for.

What it's actually good for

Worth being clear-eyed: a scroll-driven build sequence is not right for most websites. It's right when the process is the product.

It's also not always the right way to put 3D on a page. For a jewellery client we did the opposite — 360 pre-rendered frames swapped as an image source rather than a live scene, because the subject was one object that never changed and photoreal fidelity mattered more than interactivity. Pre-rendered frames win when the thing is fixed and beautiful. Real-time geometry wins when the thing is assembled, because the assembly is the point and a frame sequence can't be filtered, re-ordered or reconfigured.

It earns its place when what you sell is complicated enough that customers don't understand what they're buying — a build, an installation, a fabricated assembly. When the sequence itself is the differentiator, because a competitor showing a finished photo can't demonstrate that they know how it goes together. When the audience includes specifiers who will judge you on whether the details are right. And when it replaces a page of text that nobody reads with something that answers "what will actually happen at my house" in fifteen seconds of scrolling.

It doesn't earn its place on a service where the process is uninteresting or the buyer doesn't care. Sometimes the answer is a good photograph.

Where CAD-driven versions get genuinely interesting is configuration. Once the geometry is parametric — as ours is, and as any CAD assembly already is — the visitor can change it. Pick a decking colour, a guard style, a size, and watch the same build sequence reassemble with those choices. That's a configurator with a build story attached, and it's a materially different thing from a spinning product viewer, because it shows the customer their thing being made.

The same engine on the decks service page, filtered to a single trade. A visitor reading about decks never sees fence boards arrive.

If you only remember four things

One source of truth, two renderers. The moment geometry is defined in more than one place, the versions start drifting and you're maintaining a disagreement.

Ship computed values, not algorithms, across a boundary. 401 camera samples exported at build time removed an entire category of "why don't these match" bugs.

Metadata is what turns a model into a sequence. {stage, order, from} on every part is what makes the thing tell a story, and it's four fields.

The standard belongs in the source. A comment citing the building code next to the constant it justifies is the difference between a render and a claim you can defend in front of someone who does the work.

If you sell something that gets built, installed, or assembled — and especially if you already have the CAD for it — we should talk. The hard part isn't the graphics. It's knowing what order it goes together in, and that's a conversation, not a file format.