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 I took from Journey's underground chapter: near-black blues everywhere, and exactly one warm thing on screen. I built it, put a screenshot next to a Journey still, and lost. Then I did that eight more times.

On the third pass I finally wrote down what was wrong instead of what I wanted to be wrong. At rest, the player reads cool white. There is no ember.

I did not believe it. The halo sprite is an amber, #ffc98a, straight off the palette at the top of the file. Not a cream, not a white. So I went and measured the frames, and the frames agreed with the complaint. The colour I picked was amber. The colour that reached the screen was not.

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

First pass, 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 meant to be the only warm thing on screen measured bluer than neutral at every radius I tried: −17 at 6 px, −22 at 24 px, −18 at 48 px, around a core clipped to pure 255, 255, 255.

One light, two surfaces, one frame

The frame that broke it open came off an iPhone viewport, same beat.

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 that 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 were going on and I had them tangled together. One is the background. The other is that the halo and the pool are not the same code.

The background a light lands 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 blue's favour. Before a single unit of the ember's 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 the background's own B−R comes off the top before any of it shows. That is how 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 withholds.

The colour I wrote is not the colour that blends

The second half is the part I had wrong for three passes. 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 it 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 arrives 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. The pixel gets brighter, then it gets white, and white is not warm.

The fix was already in the file

What finally landed: additive warm over a blue sky sums toward neutral, so the halo has to be deep orange rather than pale amber. Only a saturated warm survives the blend.

// 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 capture at the top of this post:

RGB at r=10 R−B luminance
before 68.9, 79.1, 88.1 −19.2 77.6
shipped 118.5, 78.7, 39.6 +78.9 84.3

Essentially the same brightness. Ninety-eight units of R−B difference.

The shipped build, 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

The shipped build, same probe, same frame index as the iPhone 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. Early on I moved the warm glow into screen space so it lands on every layer, then tightened it into a disciplined pool. 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 started at 11 and came back down to 8.0 later, once the falloff got tight enough that the pool stopped reading as light and started reading as a decal.

Screen-space light on stacked flats has an obvious failure mode, and I walked into 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