WebGL2 ยท float readback ยท four stages

The grade

A scene with three emitters, one of them at 7.2, through a bloom and a colour grade. Three switches, each one a real default from a real library. Every value below is read out of a 32-bit float render target at the pixel marked on the canvas, so when a number goes negative you can watch the next stage turn it into NaN.

buffer bloom blend after grade
probe pixel
RGBA16F ยท add ยท clamped the safe configuration ยทยทยท
stagevalue at the probe pixel (r, g, b)what happened
01 ยท scene + bloompress readComposite of the render target and the blurred bright pass.
02 ยท tone mappress readReinhard, c / (1 + c). Anything already clipped stays clipped.
03 ยท gradepress readBrightness and contrast, the same curve the library ships.
04 ยท sRGB encodepress readpow(c, 1/2.4). This is where a negative number stops being a number.
05 ยท on your screenpress readThe 8-bit pixel read straight back off the canvas.
Negative anywhere?press read

A negative in a float buffer is legal and silent. It only bites at the encode.

What the encode returnedpress read

Undefined behaviour, not an error. NaN is one possible answer, not the answer.

Black now looksnot yet reproduced

Read off your own canvas, because this is a driver answer and not a spec one.

Peak in the bufferยทยทยท

The brightest value the render target is holding. 1.0 means it clipped.

What this is not. This is not three.js and it is not the postprocessing library. It is a hand-written chain that reproduces the three defaults under discussion, so the behaviour can be measured instead of quoted. The tone map here is Reinhard because it is two characters of shader and the argument does not depend on which curve you use. The blend, the contrast curve and the encode are transcribed to match what those libraries actually do.
01 ยท the buffer

An 8-bit render target throws the highlight away before anything can tone map it

A composer allocates an unsigned byte target unless told otherwise. That is a sane default for a chain that only does colour correction, and a silent disaster for one that does bloom, because the write clamps. An emitter at 7.2 is stored as 1.0. Not compressed, not rolled off: replaced. The tone map that was supposed to bring it down gracefully now receives a flat 1.0 and has nothing to work with, and the bright pass finds nothing above threshold, so the bloom quietly disappears too.

Switch the buffer above and watch the peak in the buffer figure. On RGBA16F it reads well over 1. On 8-bit it reads exactly 1.0, on a scene that has not changed. The picture goes flat and slightly dull, which is the sort of wrong that sends you to look at your emissive materials, your exposure, and your asset pipeline, in that order, for a day.

// The default. Fine for a colour-correction chain, wrong for bloom.
new EffectComposer(renderer)

// What an HDR chain needs. One property.
new EffectComposer(renderer, { frameBufferType: THREE.HalfFloatType })
02 ยท the blend

Screen blend is well behaved inside 0 to 1, and inverts the moment you leave it

Screen is 1 - (1-a)(1-b). Inside the unit range both terms are positive and smaller than one, the product shrinks, and the result climbs gently toward white. That is why it is the default: for ordinary imagery it never blows out.

Now put a scene value of 2.5 and a bloom value of 1.4 through it. 1-2.5 is -1.5. 1-1.4 is -0.4. Their product is +0.6, positive, because two negatives multiplied, and the result is 1 - 0.6 = 0.4. The pixel got darker when you added light to it. Push the values further apart and it goes below zero, which hands landmine three a negative to work with. This is not an edge case you have to construct. It is the ordinary condition of a hot emissive with bloom on it, which is the only reason anyone enables bloom.

Set the buffer to RGBA16F, the blend to screen, and probe the hot emitter. Then flip to add and probe again. Add is a + b, monotonic everywhere, no special cases.

03 ยท the encode

Black, plus a little contrast, is a negative number, and no two drivers agree what to do with it

The contrast curve every library ships is a pivot around 0.5: (c - 0.5) / (1 - contrast) + 0.5 for positive contrast. Put black through it at contrast 0.12 and you get (0 - 0.5) / 0.88 + 0.5, which is -0.0682. That is arithmetic, not a bug, and in a float buffer it is stored happily and truthfully.

Then the chain encodes to sRGB, which for the non-linear part is pow(c, 1/2.4). The exponent is 0.41666. A negative base raised to a fractional exponent has no real answer, so the result is undefined, and undefined is the important word. Nothing throws, nothing logs, and the draw call succeeds.

The account I took this from says the pixel goes white, on the reasoning that pow() returns NaN and NaN reads as white. That is one possible outcome and it is not the one this machine produces. Press break the black, which sets contrast to 0.12, turns off the clamp and probes the pure black patch, and read the last two rows of the chain. Whatever your driver does, the page names it rather than assuming it.

On the machine this was built on, pow() returns the magnitude. The grade produces โˆ’0.0682, the encode returns 0.3266, which is exactly |โˆ’0.0682| ^ 0.41666, and the pure black patch renders as a mid grey. No NaN is ever created. That is worse than the white pixel, not better: a white pixel is a bug report, and a black that has quietly become grey across the whole frame is "the render looks a bit washed out", which is a note somebody leaves in review and nobody actions. There is no special value to test for, because the number is finite and plausible.

Which is the real lesson underneath all three of these. You cannot catch undefined behaviour downstream. Testing the output for NaN would find nothing here. The only place the problem is visible is where it is created, one line after the grade, while the value is still recognisably a colour that has gone below zero.

// The grade, faithfully. Legal, correct, and it returns a negative.
c = (c - 0.5) / (1.0 - contrast) + 0.5;   // contrast 0.12, c 0.0 -> -0.0682

// The encode, two passes later, with no memory of where c came from.
c = pow(c, vec3(1.0 / 2.4));              // undefined. Observed answers:
                                          //   NaN      (commonly quoted)
                                          //   0.3266   (= |x|^0.41666, this machine)
                                          //   0.0      (drivers that flush)

// The fix. One line, immediately after the grade, not at the end.
// Clamping the final output only hides whichever answer you got.
c = max(c, vec3(0.0));
01

Clamp at the grade, not at the end

A clamp on the final output hides whichever answer your driver gave, which is not the same as never asking. Clamp where the negative is created, while you still know it is a colour that went below zero.

02

All three look like content

Flat highlights, a dark halo, a white pixel. Every one of them sends you to the asset first. None of them produce a warning, a log line, or a failed draw call.

03

A float buffer keeps your mistakes

Moving to 16F fixes landmine one and enables landmines two and three, because negatives and values above one now survive instead of being clamped away by the write. The upgrade is correct and it widens the blast radius.

04 ยท receipt

Verify it yourself

> measured on this machine, this visit
[WEBGL] detecting
[FLOAT] EXT_color_buffer_float: detecting
[CHAIN] scene โ†’ bright pass (threshold 1.0) โ†’ 2ร— separable blur โ†’ composite โ†’ tone map โ†’ grade โ†’ sRGB encode
[PROBE] not run yet, it is behind the read button
[ENCODE] no negative has reached the encode yet
[PEAK] measuring
[NETWORK] 0 outbound requests since load, measured by PerformanceObserver
[ASSETS] 0 CDNs ยท 0 webfonts ยท 0 analytics ยท single HTML file ยท works offline
[LIMIT] Stages 01 to 04 are read from an RGBA32F target, so they are the real float values. Stage 05 is the 8-bit canvas.
[LIMIT] pow() with a negative base is undefined, not defined-as-NaN. A driver may return anything, including a finite plausible number, which is what this one does.
[LIMIT] The peak figure samples a 64ร—64 grid, not every pixel, so it can miss a single very small emitter.
Back to the Labs โ†’