The requirement was the hard part
A browser-based 3D product simulation for manufacturers has three requirements that fight each other. It has to look like an offline render, because that is the standard the client's existing marketing images set. It has to run in a browser on a laptop. And changing an option has to feel instant.
The obvious approach is real time lighting: load the model, light the scene in the browser, let the user change materials live. I built that version first.
The real time version worked, and looked wrong
Technically it was fine. 3.79 million triangles at 60 frames per second. No stutter on the target hardware.
It looked like clay. Not slightly worse, categorically different. Flat, evenly lit, whites blown out, no sense that the object was sitting anywhere.
The reason is not a tuning problem. A single environment map has no direction in it. Image-based lighting in the browser engine I was using casts no shadow, so nothing grounds the object. There is no ambient occlusion, so no contact darkening where surfaces meet. The quality people read as "expensive render" is mostly global illumination: light bouncing off the walls of the room and arriving from somewhere specific. That is exactly what a real time approximation does not carry.
I costed the rescue: environment restricted to indirect only, nine area lights with shadow maps, screen space ambient occlusion, multiple reflection probes. One to two days, and it still would not match the reference. I stopped and kept the comparison build running on its own port, so the decision remains checkable rather than remembered.
What replaced it: separate the light from the colour
The insight is that in a product simulation the lighting never changes. The room, the lamps and the camera are fixed. Only the materials change. So the light can be computed once, offline, at full quality, and the option can be a cheap multiply on top of it.
screen = AgX_LUT( shaper( light_linear × albedo_option ) )
- Light: one Cycles diffuse bake, direct and indirect, colour switched off. Stored linear, packed to 8 bit through a log2 shaper across the range -10 to 6.5. Measured encoding error 0.0010. Baked once, ever.
- Albedo: a diffuse bake with colour only, 2K, per option. Three seconds each.
- Tone mapping: the renderer's own AgX transform baked into a 32 cubed Hald lookup table and applied in the browser. Measured error 0.0012.
The order of operations is not a detail
The renderer computes AgX applied to light multiplied by colour. It is tempting to precompute AgX of the light and then multiply the colour in, because that is one texture fetch cheaper.
That is wrong, and it is wrong by a measurable amount: 0.17 against the reference. Fitting a best-case single correction multiplier still left 0.094. Tone mapping is not linear, so it does not commute with the multiply. The shaper exists precisely so the light can stay linear until the last step.
What it bought
Cost of adding one material option, before and after the split. Measured 4 September 2026.
| Metric | Full re-bake | Light times albedo |
| Time per option | 90 seconds | 3 seconds |
| Payload per option | 4 MB | 0.6 MB |
| VRAM per option | 268 MB | 21 MB |
| Fidelity against Cycles reference | 0.151 | 0.156 |
Thirty times cheaper per option, and the fidelity number barely moved. The shipped simulation carries 9 axes and 28 options, 2.2 million triangles, five camera views, 60 frames per second, 100.7 MB total.
The honest limitation
A bake samples every texel along the surface normal. It computes each point as though the camera were always looking straight at it. That is fine for diffuse light, which does not care where you stand, and it is wrong for everything that does.
Reflection, specular highlights, Fresnel falloff, the sheen on velvet, clearcoat, refraction through glass: all of it gets computed once from the wrong direction and nailed into the texture. This is the entire reason a baked scene can read as "a very good texture" rather than a render, and it took me longer than it should have to name it, because the missing ingredient is not light. It is direction.
The fix is to stop baking one thing. Diffuse and indirect go into the atlas. Specular gets computed live in the browser from an environment map baked from the same scene, so the reflections at least come from the right room.
Glass needed its own measurement
Clear glass rendered through the engine's built-in transmission was sampling the wrong pixel entirely: it returned a bright grey of 0.79 where the dark space behind the glass measured 0.235. Screen space refraction was reaching for geometry that was not where it assumed. The tint was also being applied twice, once as colour and once as attenuation.
What replaced it is two layers: a multiply pass carrying the tint over the back buffer, and a black additive twin of the same mesh carrying Fresnel-weighted reflection from the environment map. Hero frame error against the reference went from 0.252 to 0.165.
The part worth stealing is not the technique, it is the measurement. Glass occupies three to five percent of the frame. A whole-frame difference metric never moved while the glass was visibly wrong, which is exactly how you end up shipping a bad parameter and calling it fixed. It had to be measured through a mask that isolated the glass.
What I would tell someone starting this
Build the comparison first. Not a screenshot, a running build on a separate port that renders the same scene the other way. Every argument about whether something looks right collapses into a number the moment you can put the two side by side, and the arguments that survive that are the ones worth having.