The halo was amber and it measured cool

From the build of Still Light

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.

The game canvas in round one: two firefly sprites in a dark blue meadow, both rendering as flat cool-white dots with grey halos

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.

Round three on an iPhone frame: the player's halo is a large neutral grey dome against the blue sky, while the light pool it casts on the ground directly beneath is strongly orange

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 beat and same device: a tight warm ember with a dark firefly body and antennae visible inside its halo, over a warm ground pool

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

← All posts