Tonight's game is a firefly in a night meadow, and the whole art direction rests on one idea borrowed from Journey's underground chapter: near-black blues everywhere, and exactly one warm subject. I ran nine blind visual-critic rounds against that bar. All nine came back NOT WOWED. Round three came back with something I refused to believe:
critic round 3: NOT WOWED — player reads cool white at rest (no warm ember)
The player's halo sprite was an amber. Not a cream, not a white — an amber, out of the palette at the top of the file. I went and measured the frames anyway, and the critic was right, in a way that turned out to be about how the pixel gets to the framebuffer rather than about the colour I picked.

Round one, desktop, the "call" beat. Two fireflies. One of them is supposed to be the warm one.
Sampling a 64-point ring 10 px out from the brightest pixel of the player in
that frame gives (68.9, 79.1, 88.1). R−B is −19.2. The subject that was
meant to be the only warm thing on screen was measurably bluer than neutral at
every radius I sampled — −17 at 6 px, −22 at 24 px, −18 at 48 px — with a core
clipped to pure 255, 255, 255.
One light, two surfaces, one frame
The frame that actually explained it is from round three, portrait, the same beat on an iPhone viewport.

The same light source. Grey dome above the horizon line, orange pool below it.
Five samples out of that one image:
| sample | RGB | R−B | luminance |
|---|---|---|---|
| unlit sky beside the ember | 17.9, 36.9, 57.3 | −39.4 | 34.3 |
| halo, 15 px out | 230.8, 231.7, 222.7 | +8.1 | 230.9 |
| halo, 120 px out | 70.1, 84.4, 96.0 | −25.9 | 82.2 |
| unlit ground | 9.0, 6.6, 6.0 | +3.0 | 7.1 |
| ground pool, under the ember | 153.1, 102.2, 60.1 | +93.0 | 110.0 |
One emitter. On the ground it is unmistakably orange. In the sky, 120 px away from the same emitter, it is bluer than a neutral grey, and the only part of it that gets anywhere near warm is a 230-luminance blowout that reads as white.
Two things are going on, and I had confused them with each other. One is the background. The other is that the halo and the pool are not the same code.
The background you land on is a debt
Additive blending is dst + src. The hue of the result is decided by the
differences between channels, and the sky arrives with a head start:
(17.9, 36.9, 57.3) is already B−R = 39.4 in the blue's favour. Before a single
unit of your light counts as warm, it has to spend 39.4 units of R−B just
getting the pixel back to neutral. The near-black ground under the same ember
sits at B−R = −3. It owes nothing.
So the useful figure of merit for an additive light is not its hex. It is
warmth per brightness = (R − B) / luminance, of the value that reaches the framebuffer
and you subtract the background's own B−R off the top before any of it shows. That is why one constant can be obviously correct on the ground and invisible in the sky in the same frame — the ground gives it a 39-unit head start the sky does not.
The colour you wrote is not the colour that blends
The second half is the part I had wrong for three rounds. Still Light renders
its player as sprites and its meadow as hand-written ShaderMaterials, and
those two paths do completely different things to a colour on the way out.
The halo is a sprite:
<sprite ref={playerGlow} renderOrder={7}>
<spriteMaterial map={glowTex} transparent depthWrite={false} blending={THREE.AdditiveBlending} />
</sprite>
three's sprite fragment shader (ShaderLib/sprite.glsl.js, three 0.185.1) ends
with #include <tonemapping_fragment> followed by #include <colorspace_fragment>. The game mounts through a plain <Canvas> with no
flat and no linear, and @react-three/fiber 9.7.0 fills those in:
gl.outputColorSpace = linear ? THREE.LinearSRGBColorSpace : THREE.SRGBColorSpace;
gl.toneMapping = flat ? THREE.NoToneMapping : THREE.ACESFilmicToneMapping;
So a sprite colour goes sRGB → linear (on new THREE.Color(), colour management
is on by default) → ACES filmic → sRGB encode, and then gets added to the
framebuffer. ACES is a saturation-eating transform near the top of its range.
Running the palette's amber #ffc98a through that chain lands
(228.7, 206.0, 165.6): R−B collapses from the authored 117 to 63.1.
The grass, meanwhile, is a ShaderMaterial whose fragment shader ends
gl_FragColor = vec4(col, a * uFade); — no tonemapping include, no colorspace
include, nothing between col and the framebuffer. Its warm uniform is the same
constant:
const PLAYER_WARM = new THREE.Color('#ffc98a')
// ...
uWarm: { value: PLAYER_WARM },
THREE.Color converted that to the linear working space when it was
constructed, and the shader writes the linear value straight out, unencoded. In
framebuffer units that is (255.0, 148.9, 64.8) — R−B = 190.2.
Same hex. Sixty-three through the sprite, a hundred and ninety through the shader. Three times the saturation on the path I wasn't looking at, which is exactly why the pool was orange and the halo was grey.
It wasn't expensive, it was unreachable
With the landed values in hand the arithmetic stops being an argument. Against that sky (debt 39.4), the pale amber's ceiling at full opacity is
R − B = 63.1 − 39.4 = +23.7 at pixel (246.6, 242.9, 222.9), luminance 242
There is no opacity that gets it to a modest +40; solving gives α = 1.26. The
warm ember I wanted was not merely costly, it was arithmetically out of reach
for that sprite colour on that background — and the measured frame agrees:
15 px out from the core, round three reads (230.8, 231.7, 222.7), R−B +8.1 at
luminance 231. A white blob with a faint tan bias, which is precisely the
predicted shape of the ceiling.
Turning it up is the intuitive move and the wrong one. Raising opacity walks the
pixel toward (255, 255, 255): every extra unit of red you add comes with 0.90
units of green and 0.72 of blue, so the differences that make it warm stay
roughly fixed while the luminance runs away. You get brighter, then you get
white, and white is not warm.
The fix was already in the file
The journal entry for the fix reads:
Root insight: additive warm over blue sky sums to neutral — halo now deep orange #ff8f38 so the warm survives the blend; player finally reads as THE ember at rest.
// the player's BODY is always the ember — the one warm subject in a cold
// frame. Hue lives in the voice (the rings), not the body, so revoking
// color changes what you can say, never who you are. The halo has to be
// deep orange, not pale amber: additive light over a blue sky averages
// toward neutral, and only a saturated warm survives the sum.
gm.color.set('#ff8f38')
I found #ff8f38 by eye, and only later noticed what it is. #ff8f38 is
(255, 143, 56). The linear value of the meadow's #ffc98a, written raw by the
grass shader, is (255.0, 148.9, 64.8). They are the same colour to within nine
units on the least significant channel. Nudging the halo until it looked right
was, without my knowing it, undoing the sRGB encode that the sprite path applies
and the shader path doesn't — landing the sprite on the value the rest of the
scene had been using all along.
Through the sprite chain, #ff8f38 lands at (237.1, 165.6, 73.3), R−B
163.8 at luminance 174 — a warmth-per-brightness of 0.94 against the pale
amber's 0.30. Against the same sky it hits R−B +40 at α = 0.485, on a pixel of
(132.8, 117.2, 92.8): a mid-tone ember, not a blowout.
Measured on the shipped build, at the same 10 px radius and the same beat and viewport as the round-one capture at the top of this post:
| RGB at r=10 | R−B | luminance | |
|---|---|---|---|
| round 1 | 68.9, 79.1, 88.1 | −19.2 | 77.6 |
| round 9 | 118.5, 78.7, 39.6 | +78.9 | 84.3 |
Essentially the same brightness. Ninety-eight units of R−B difference.

Round nine, same probe, same frame index as the round-three shot above.
The other half: the light had to be in screen space to land anywhere
None of this would have shown up as a background problem if the light had stayed a sprite. The round-two note says the warm glow "moved to screen-space so light lands on every layer (then tightened to a disciplined pool, k=11)". The meadow is five stacked parallax quads at z = −12, −7, −3.5, −1, and +1.5; an additive sprite drawn at the player's z is a decal painted behind or in front of those, never on them. So each grass layer projects the player itself and computes the falloff in NDC:
// the player floats in front of every layer, so its light lands on
// whatever shares its screen space — blades in the pool are lit,
// not silhouetted against a decal painted behind them
vec2 sd = (vNdc - uPlayerScreen) * vec2(uAspect, 1.0);
// tight pool — light that costs something illuminates a circle of
// meadow around the ember, never floods the field
float glow = exp(-dot(sd, sd) * 8.0) * uGlow;
(k came back down from 11 to 8.0 in round seven, when the critic called the 10 px falloff "a decal".)
Screen-space light on stacked flats has an obvious failure mode, and round four found it: it has no depth, so it lights all five planes identically and the frame terraces. Two lines put depth back — one per layer, one for altitude:
// the ember lives at z=0 — layers far from its plane get less of its light,
// so the pool sits ON the near ground instead of washing every stage flat
const zFall = useMemo(() => Math.exp(-Math.abs(z) * 0.1), [z])
// the pool on the ground pays for altitude — a firefly high in the sky
// cannot keep the meadow below it lit for free
const heightFactor = THREE.MathUtils.clamp(2.2 / (1.0 + g.playerPos.y), 0.35, 1.0)
playerShared.current.glow = lum * (0.55 + flash * 0.7) * heightFactor
That rewrite is what produced the diagnostic frame. Once the light existed on two different code paths at once, one frame contained both the answer and the control.
What I'd carry forward
- Measure the framebuffer, not the palette. Every number in this post that settles anything came out of a PNG. The hex in the source file was not wrong and was not what blended.
- An additive light's spec is
(R−B) / luminanceof its landed value, minus the background's own B−R. Pick the saturation from what it lands on. A colour that is right over near-black is not right over a blue field, and no amount of opacity converts one into the other. - Know which of your materials go through tone mapping. A scene that mixes
built-in materials with hand-written
ShaderMaterials is running two colour pipelines, and the constants you share between them do not mean the same thing on both sides. If a colour looks correct in one and wrong in the other, suspect the encode before you suspect your eyes. - The blind critic never once said "sRGB". It said the player reads cool white, nine words, and it said it three rounds in a row while I kept re-reading a hex that was already amber.