A path-traced showroom in a TV browser

The website concept’s 3D tour was hard to drive in a live demo. Now AHA’s experience centre is path-traced offline into 22 renders, and the TV only turns the camera and crossfades the light.

27 Oct 2026 4 min readCase study · Showroom TV app

The problem

The website concept’s 3D tour of the experience centre was hard to drive in a live demo.

The solution

Path-trace every room offline, and let the TV only turn the camera and crossfade the light.

22renders, path-traced offline in about 2 hours
2048 → 3072 pxroom renders on the TV, after “pixelated”
60 fpsheld at the TV’s size, in a Chrome stand-in

AHA’s website concept had a 3D tour of the experience centre, but it was hard to drive in a live demo. In October it moved into the showroom app as a walk the presenter runs with the TV remote alone: the arrows go room to room, and nothing needs dragging.

The TV never traces a ray. The light is traced once, offline on my laptop, and the TV only shows the result.

Tracing the light offline

The centre is a three.js model: rooms, furniture, lights and each lighting state. A GPU path tracer renders it in headless Chrome, and an open-source denoiser cleans up each render on WebGPU. Three traps shaped the pipeline:

  • Anything over about 4.19 megapixels came out black. So each room is rendered as six cube faces and stitched into one 5120 × 2560 panorama.
  • The tracer and the denoiser needed different Chromes. On Chrome’s Direct3D 11 backend the traced surfaces came out blank, so the tracer runs on Vulkan, and WebGPU only works without that flag. Every face goes through two headless Chromes: one traces, one denoises.
  • Too few bounces left holes. Below 12 in total, about one pixel in six stayed black and the denoiser smeared the rest.

The full run is 22 renders: 17 room panoramas (each room in each of its lights) and 5 of the whole centre. Each cube face took 20 to 80 seconds to trace and 4 to 10 to denoise, and the lot took about two hours.

One shader, two renders

The TV never gets the 3D model. One WebGL context draws everything. For a room, the shader works out which way each pixel looks from the camera and finds that direction in the 360° render, so turning the camera is just a lookup per pixel.

A scene, such as theatre mode, is two renders of the same room in different light, mixed:

// which way does this pixel look? find that direction in the 360° render
vec3 d = normalize(F + v.x * T.x * R + v.y * T.y * U);
vec2 uv = vec2(atan(d.z, d.x) / (2.0 * PI) + 0.5, 1.0 - acos(d.y) / PI);
// same room, two lights: as m goes from 0 to 1, only the light changes
gl_FragColor = vec4(mix(texture2D(a, uv).rgb, texture2D(b, uv).rgb, m), 1.0);

The furniture and the camera are identical in both renders, so only the light moves.

Two rules keep it smooth. Handing a 5120 px render to my laptop’s GPU took 60 to 85 ms, a visible jump mid-move, so the next room is fetched while you look at this one and uploaded only while the picture is still. And WebGL1 draws a texture that isn’t a power of two as black unless its edges clamp, so every render is clamped.

“Basically pixelated”

Once it was live, the verdict on the TV was blunt: the renders looked “basically pixelated”. Its browser reports a 960 × 540 screen at 2× density, and the canvas was drawn at 960 × 540 and stretched, from 2048 px room renders. The whole centre’s 2400 px render was squeezed into about 760 pixels, so its thin lines broke up.

The fix: the TV now gets 3072 × 1536 renders and draws them at the screen’s real 1920 × 1080 while the picture rests. While it moves, it draws at half that each way, as smooth as before, and lays that frame over the canvas. A new render the same size as the old one reuses its GPU memory. All the renders a TV fetches come to about 2 MB.

The cost, measured in Chrome stand-ins rather than on the TV itself: on my laptop’s GPU at the TV’s screen size, still 60 frames a second, with the browser’s peak memory going from 830 to 883 MB. On a harsher stand-in, with graphics in software and the processor slowed four times, the walk fell from 57 to 39 fps. In software every pixel costs; a GPU draws the extra pass for next to nothing.

What I took from it

  1. Bake what you can. If a picture can be computed once, don’t ask a TV to compute it live.
  2. Change one thing at a time. Two renders that differ only in light crossfade cleanly.
  3. Upload on still frames. GPU work that stalls a frame belongs where nobody is watching.
  4. Check sharpness on the real screen. A canvas sized in CSS pixels quietly halves the resolution on a 2× TV.

I directed the work and Claude Code wrote much of the code. The heavy lifting is all done before the page loads.

More problems, more solutions.

All posts

Non-brand clicks are the SEO number that matters

+183% non-brand clicks

Five bugs a data audit caught before anyone made a decision on them

2.00× → 1.00× Meta lead count