Building a high-fidelity 3D realtime rendered background scene for Hezo

2026-09-16

(13 minute read)

I recently attended a fantastic design workshop by Meng To and it got me thinking about how I could jazz up the hezo.ai homepage with something that was really eye-catching and yet elegant. I wanted something that would convey the same information as the current page and yet visually lead the reader towards the call-to-action.

The new homepage is a Three.js scene running on WebGPU. You start in front of a temple gate, and scrolling walks you through it, along a stone causeway over a lake, and up a mountain to a second temple. It has a day mode and a night mode. Nothing ships as an asset, no model files and no texture images. It is all generated in the browser from code.

It took four models and about two weeks: Higgsfield for the reference video, Fable 5 for the mockup, GPT-6 for the build, Opus 5 for the optimisation. Each one was handed the artifact the previous one produced, not a description of it.

The thing I did not expect is how one-sided the prompt log turned out to be. Prompts that described what I wanted mostly produced confident, plausible, wrong output. The prompts that moved the project along took a choice away from the model instead.

1 - Start with a video, because prose cannot pin down a camera

The first artifact was not code or a design. It was a clip generated in Higgsfield, and I started as broadly as I could:

come up with several landing page concept for https://hezo.ai as a video. we want to emphasize:

  • collaborating with large fleets of ai agents to create great things that you couldn't otherwise do by yourself
  • it sits above your existing ai providers and harnesses (codex, claude code, etc) and supercharges the workflow and your life

explore themes around:

  • achieveing great things (e.g climbing mountain, launching into space et al, etc)
  • a sense of smooth, stress-free living and being in flow
  • doing amazing things

That gave me three directions (summit, launch, flow) and an 8 second hero with a person in it. Perfectly good video. Wrong artifact, because I had asked for a film and what the page needed was a scroll.

The prompt that changed the project named the eventual runtime. A week later:

ok, so the way i want it to be is that in the hero splash we can see the mountain range in the distance and then as we scroll the page we are moving up the mountain. get rid of the person character.

redo the higgsfield animation with this in mind. note that we will be using three.js to render this in realtime in the browser so the animation can take that into account.

That second sentence turned a film into a spec. From there every render was constrained to one continuous camera on a single spline, with no cuts, no character and no audio, because those are the only things that survive being scrubbed backwards by a scroll wheel.

Then I dictated the actual scene, in one long run-on paragraph, which is worth reproducing because it looks nothing like a prompt-engineering example:

So what I want is to do a... let's start the video out looking at the front entrance of a grand temple based on the reference image. So the idea is that we look at this temple and we can see that some vegetation outside it and the temple has some little birds fluttering in front of it. And as we scroll down the page, we walk in through the entrance of the temple, but we can already see in the distance, in the hallway, through the hallway, there is a walkway that leads away from the temple, from its back, in the outside. [...] So that means at the very beginning, the temple we're looking at isn't really a temple, it's just a gate, but a very elaborate gate styled like a temple.

Buried in the middle of that is the entire architectural brief: "it isn't really a temple, it's just a gate". Everything downstream, including a 7,000 line Three.js scene, hangs off the gate being a passage rather than a destination. I would not have got there by writing a tidier prompt, because I only worked it out while talking.

[Figure: the final daytime reference video]

2 - Video models invent geometry, and describing the problem does not fix it

The ascent took many attempts, and they failed the same way each time: the temple or the staircase would materialise mid-shot instead of being there from the start.

I described the problem more precisely on each pass.

ok, almost there, but the ending should be closer to the other temple entrance. and instead of the stairway appearing out of nowhere the camera should just quickly move up the existing walkway at speed to get to the temple at the top.

That produced a clip where "fast" became the entire motion, so I tried again.

no this is worse. what needs to happen is that as the animation progress the camera goes through the gate and then accelerates up the mountain pathway until it slows down as it approaches the temple at the top. ease-in-out.

The timing improved. The popping did not.

again it needs to show the viewer moving up the linear walkway at pace and not have the final template and/or walkway suddenly appear out of nothing.

Three rounds of better description got nowhere, because the cause was structural. Interpolating between a distant start frame and a close end frame leaves a large unconstrained middle, and the model fills that middle by inventing whatever gets it from one end to the other.

What finally worked was removing the middle. Generate three keyframes first (gate, mid-climb, entrance), so that the walkway and the temple are visibly present in the midpoint frame before any motion is rendered, then animate against those. Stairs cannot pop into existence in a frame where they already exist.

Night mode came later, from the same approved motion, and needed its own round of specifics, because "night" on its own just produces generic darkness:

the nightmode is too dark - we need a moon in the sky, we need more aply placed torces and lanterns that don't look generic, we need fireflies. do the animation.

the lanterns should be positioned on the walkway wall and not floating in the air. also we should have chinese latnerns hanging in the gate celing and in the temple doorway

[Figure: the night-mode reference video]

3 - The mockup answers a question the video cannot

A 12 second clip shows you what the scene looks like. It tells you nothing about where the headline goes, when the nav appears, or what happens to text legibility when it sits on top of a moving render.

So the video went to Fable 5 in Claude Code, along with the existing site:

my plan is to redo the hezo homepage such that the background is a three.js rendering of this video animation whereby as you scroll down the page it transitions through the animation, but all as a 3d render.

but first i want you to figure out the best way of combing the current website content with such a rendering. and note that the rendering will need to be able to day and night mode, to match the theme switcher.

show me some mock visuals, with various options and choices.

The obvious prompt here is "build me this homepage". Asking for options against the existing content first, before committing to anything, is what made the next two weeks cheap.

I picked a layout and set two constraints that never changed afterwards:

animations are all code, not assets. this has to be procedurally rendered.

and then, once I saw the first frames, the rule that ended up governing the whole page design:

basically every time we need to show text that's not in a panel we must blur the background or atleast enough of it under the text so that the text is legible.

Then about eighteen rounds of redo visuals, which is the unglamorous middle of any design process: station panels alternating along the causeway instead of sitting in a grid, the section title timed to appear just as you clear the gate, birds cut back to the gate only, mouse movement turning the camera slightly the opposite way, red lanterns with the Hezo logo hanging from the gate ceiling.

The one I would single out:

hmm the glass panel backing for the hero content looks ugly - come up with alternative options for me that don't block out the bg.

Rejecting the fix and restating the constraint beats specifying a replacement. I had no idea what the answer was. It turned out to be a horizontal occlusion band, which I would never have asked for, and which is still on the page.

All of this produced a published artifact: a ten-viewport walk with every scroll range, blur strength and panel animation already decided. That artifact, rather than a description of it, is what went to the next model.

[Figure: the mockup artifact, or a contact sheet of frames from it]

4 - Hand over both artifacts, and say which one governs what

The build prompt to GPT-6 shows why keeping the video and the mockup separate helped:

look at the reference video attached. i want to recreate the fidelity and scene from this video exactly using three.js and webgpu renderer as the new homepage for https://hezo.ai

i have created a mockup version of the new homepage (see [artifact link]) with the desired visual elements. so all i need you to do is redo the background from this mockup with a high-fidelity version of what's in the reference video.

[...] the visual flow should be exactly as in the mockup. it's just that the bg rendering should be high-fidelity and matching what's in the reference video in terms of detail, vegentation, water, etc.

self-verify the final against the reference and your plan and keep iterating until you get it. use subagents to split up the work, and use adversarial subagents to verify each others' work.

The mockup governs timing and layout, the video governs fidelity. Two references with separate jurisdictions, so there was never a question about which one wins.

The adversarial review instruction earned its place immediately. It caught gaps in the mountain seams, an abrupt island shoreline, paving that overlapped and hid the middle of the entrance stairs, and German text overlapping on short phone screens. I would not have spotted any of those in a screenshot.

The line about not sandbagging mattered more than I expected it to:

note that the scene must be procedurally rendered and must match the fidelity in the video in terms of fine detail. don't do the bare minimum you think, do the maximum possible in terms of matching the fine detail, but obviously we want 60 fps.

The first pass cleared 60fps and looked wrong: foliage as large faceted masses, distant cliffs with no natural detail. A sample partway through ran above 120fps at 1280x800, on one desktop, and that measured headroom is what paid for what came next: folded leaves near the camera, finer cliff erosion, carved timber relief. Without the instruction to spend it, a scene at twice the target frame rate looks finished.

The interaction work arrived as one-liners over the following day, each of them a small independent thing a finished scene can absorb without redesign:

additionally, when the user clicks/taps the vegetation it should animate slightly as if it has been touched.

the vegetation click detection needs improving. let's change it from click to mouseover (on mobile it can still be tap)

when we click the hanging lanterns they should gently swing with real physics motion

5 - "Don't code yet"

By this point the scene was right and the page took far too long to show anything. My first prompt about it was the ordinary one, and it got ordinary results:

also, we need to make sure it loads well esp. on slow connections, ui shouldn't freeze up whilst rendering is taking place, and can we load in the elements progressively (front to back)?

The second one is the best prompt in the project:

don't code yet.

just figure out if there is a way to speed up the rendering from page load start time (simulate with a slow 4g connection like you get in chrome devtools)

Those two opening words prevent the most expensive failure mode there is. A model asked to make something faster will produce plausible micro-optimisations straight away, and you will spend an afternoon reviewing changes that move nothing. Asked to investigate against a named network condition, with code explicitly forbidden, it comes back with a list you can approve or reject.

Then:

ok do all of these, and fix the webgpu shader failure as well

What that pass produced, measured in Chrome against its Slow 4G preset:

beforeafter
first 3D preview (cold, Slow 4G)11.1s6.8s
all loading stages complete32.3s23.5s
scene worker starts downloading7.2s1.5s
app bundle, compressed283 KB30 KB

Most of the bundle drop comes from loading only the active language rather than every translation. No main-thread task exceeded 50ms in that run.

Two bugs surfaced here that only appear in a production build. Gatsby's build transform rewrites a custom shader node class in a way that conflicts with Three's native class. And some devices allow smaller uniform buffers than Three assumes for medium instance batches, which meant moving those batches over to instanced attributes.

The last prompt is the one that usually gets skipped:

remove docs/ folder and remove all unnecessary test files, we just want the new website homepage stuff and essential files. update agents.md with any necessary rules to ensure we don't mess up the performance / architrecture decisions in the rendering going forward.

129 tests became 46, covering worker startup, shader compatibility, geometry reuse and language delivery. They are local regression checks rather than CI, which is a gap I still have not closed.

6 - The part no screenshot could have caught

The pull requests that followed, between 13 and 15 September, fixed things that no amount of looking at the page would have surfaced.

A reader's phone browser died on the homepage. Redmi Note 14 Pro+, Snapdragon 7s Gen 3, Adreno 710. Not a shader fault, an out-of-memory kill, because a phone tab shares its budget with the operating system.

No capability probe would have caught that device. Chrome on Android clamps navigator.deviceMemory to 8 and reports hardwareConcurrency of 8, which are the numbers a high-end desktop reports. Safari omits deviceMemory altogether. There is no browser signal that says this one will die.

The bug underneath was worse than the crash. Whether a device counted as mobile was being worked out inside the worker, from the CSS viewport width the page had sent, and a worker has neither matchMedia nor screen. A phone held sideways reads about 988 px, so it was being handed the heaviest build there is. The decision moved to the page thread, where sceneTier() reads screen.width and screen.height, whose smaller edge is the same number in both orientations, plus (pointer: coarse).

That produced a table, SCENE_TIERS, with one row per build, replacing six scattered mobile ? a : b expressions of which only one named a tier. The phone row is not a smaller version of the tablet row. At pixel ratio 1 a phone draws about 390x844 device pixels, so 95,000 distant canopy cards and 3,000 individual roof tiles are all sub-pixel, and the cuts follow what survives that resolution rather than scaling every number down evenly. The passes a row declines outright (shadow map, ambient occlusion, lake reflection, bloom) are the larger half of the saving, because they are the difference between drawing the scene three times a frame through a post chain and drawing it once.

The sentinel that protects readers now reads no device signal at all. An out-of-memory kill runs no callback and sends no event, so the build writes a record to localStorage before it starts and clears it on ready and on pagehide. A load that still finds one skips the scene for 30 days. The gate runs before first paint, ahead of the worker warm-up, so a gated device never starts the 1.02 MB download. A device is refused for having died, never for what it is.

Then the wrong turn, which is the part worth reading.

The phone row first shipped forestSpacing: 2.6. That knob scales the distant planting rows in both axes, so it is a fourth power on tree count rather than a linear trim: 23,164 trees against the tablet's 92,374. The canopy stopped closing, sky showed between the cards, and the karst towers behind the headline read as lace.

Chasing it turned up a real latent bug. The distant canopy material sampled the crown texture's macro luminance at a hard-coded mip level 4, correct for the 512 px map every existing row shipped, and 16x16 rather than the intended 32x32 on the first row asking for 256. That was found, explained convincingly, and pushed as the fix.

It moves 0.4% of the pixels and it was not the cause. The cause was the spacing. At 1.8 the phone gets 47,989 trees and the canopy closes.

The mip change is worth keeping on its own merits, since a knob a shader reads ought to be a knob the shader follows. But it had been verified as a mechanism without being verified as producing the pixels, which is the same shape of failure as the video model filling an unconstrained middle: something plausible that fits, offered confidently, standing in for the thing that is actually true.

The lesson went into AGENTS.md, because it generalises. generateMountain is pure geometry and runs under bun with no renderer and no canvas, so a population sweep across every candidate spacing takes seconds where a headless render takes six minutes. The tree counts would have pointed straight at the spacing.

7 - What the good prompts have in common

  • "We will be using three.js to render this in realtime" removed cuts, audio and characters.
  • Three locked keyframes removed the unconstrained middle where geometry got invented.
  • "Animations are all code, not assets" removed an entire asset pipeline.
  • "The visual flow should be exactly as in the mockup" removed the model's discretion over timing.
  • "Don't code yet" removed the option to start fixing before measuring.

Every prompt that failed was a more precise description of a symptom. Three rounds of explaining that the staircase should not appear out of nowhere changed nothing, because the model was not misunderstanding me. It was doing the only thing available to it, given how the render had been set up.

The mip level was the same thing arriving from the other direction. Nobody asked for a wrong answer there. The question was left open enough that a plausible one fitted, and the way out was to close it by counting trees rather than by asking more carefully.


If I had to compress the whole thing to one sentence: when a model keeps getting it wrong, change what it is still free to decide, not how clearly you described what you wanted.

Need help shipping your product?

Let's talk about your project and see how I can help.

./book_call.sh