Run ·
Smoothing Starlock's glows
Starlock's cutscene draws soft glows on near-black space and hides their steps with a classic one-line noise; on this exact rendering path, which noise (or which fix to the path) leaves the least visible banding, and does the classic one break on low-precision phone GPUs?
- Outcome
- Confirmed
- Grows out of
- Starlock
- Field
- computer graphics (quantization and dithering)
In plain words
Starlock's cutscene is set in deep space: dark violet and warm glows fading into black. A phone screen can show only 256 steps of brightness for each color, and in the dark those steps are far apart, so a smooth fade can break into rings, like the lines on a contour map.
The usual cure is a sprinkle of noise, too fine to notice, that blurs the edges of the steps. We copied the game's drawing steps and measured the noise Starlock used against other well-known recipes. The old noise hid the rings well in a single glow, but not where glows overlap. One glow, the warm light behind the fleet, had no noise at all, and its rings were the ones we could actually see in the cutscene.
We changed the noise recipe, stopped switching the noise off at the faintest edge of each glow, and added noise to the warm light. In our tests the measured error fell three or four times for a single glow and five to sixteen times where glows overlap, and the rings left our crop of the real cutscene. Another model, starting fresh with its own code, checked it and agreed, and found a slip in ours that had made the gain look smaller. We ran this on a software stand-in for a phone's graphics chip, not a real phone, so a real phone may differ.
The question
A phone screen stores each color of each pixel in 8 bits: 256 levels. A smooth fade from bright to black has to be rounded to those levels, and where the fade is slow the rounding leaves wide flat bands with a visible edge between them. That is banding, and it shows most in dark, soft gradients, because the eye judges ratios: an equal step of light is a bigger change near black. Screens store sRGB codes to space their steps evenly to the eye; Starlock’s glows go through a buffer that stores linear light instead, where the dark steps are coarse.
The standard cure is dither: add a tiny random value, about half a level either way, just before rounding. A pixel whose true value sits between two levels then lands on either one at random, and the average over a small neighbourhood comes back to the true value. The edge of the band turns into fine grain. Try it on a glow like the game’s (a simplified stand-in, without the game’s smoothing filter, brightened so the steps show). It starts with no dither: the fade breaks into rings. Switch to the new noise and the rings turn into fine grain.
A simplified stand-in: the glow, the 8-bit rounding, the dither and a tone curve, without the painterly pass. Brighten only multiplies what is shown, to make the steps easy to see.
Without the game’s smoothing filter the old noise looks coarse and blotchy here. In the game the filter hides most of that grain. What told against the old noise there was a ring where two glows overlap (the cutoff, in the next section) and how unevenly it averages.
Starlock’s cutscene is nothing but soft glows on near-black space, so it is a fair test. This run asks: on the cutscene’s actual rendering path, which dither (or which fix to the path) leaves the least visible banding, and does the classic one-line dither the game used break on phone GPUs with low-precision arithmetic?
Where it comes from
Starlock is a phone game on this site. Its roughly 40 second pre-match cutscene is 3D (three.js r186, WebGL2). Its glows (the Rift’s corona, flashes, shock rings, beam halos) are quads with a shader that adds light into an 8-bit render target. That target holds linear light with no sRGB encoding. A painterly post pass (a generalised Kuwahara filter in 8 sectors; radius 5 on “full”, 2 on “lite”, 0 on “off” for slow devices) then tone-maps with ACES filmic at an exposure of 1.15, encodes sRGB and writes the 8-bit screen with no dither of its own. The game caps the pixel ratio at 2.
Two details make this path unforgiving. Near black, one level of the linear target is several levels on screen: linear code 1 comes out at about 2 on screen, 2 at 7, 3 at 13, 4 at 18, 5 at 23. Those steps of 5 to 6 levels are about 1.3 to 2.3 in L* (a perceptual lightness scale). Step through the levels to see it:
And the glows’ own dither was this line:
// before: cutscene3d.js
float dither() { return (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - .5) / 255.; }
// used as: dither() * step(.002, a * uOpacity) (off in each glow's faintest edge)
The same pattern went into every layer, and the Rift multiplied its dither by its fade. The warm pool of light behind the fleet is a different thing, a three.js Sprite with a 128-pixel canvas radial gradient, and three’s sprite shader has no dither at all.
The cutoff in that line, step(.002, ...), does more harm than it looks. Below
0.002, half a level of the target, a glow’s dither is off, so it rounds its
faint edge away. Here is a line through two faint glows, each added with its
own dither:
- what the glows add up to
- stored, dither on everywhere (the new shader), on average
- stored, dither off below half a level (the old shader's 0.002), on average
Look at about level 6 on each slope: the purple line drops a step where the other glow's faint tail is rounded away, and on screen that edge is a ring. (The little spike at the far right is real too: just below the cutoff the dither is already off, but the glow is still a hair over half a level, so it rounds up to a whole one.) Both pixel rows use the new noise; only the cutoff differs.
What’s known
- The sine hash is shader folklore with no source we could trace. It
circulates in
a 2011 OZone3D post
and The Book of Shaders. GPUs compute
sindifferently from each other (a gamedev.net write-up), and Dave Hoskins’ “Hash without Sine” (Shadertoy 4djSRW) exists because of it. Jarzynski and Olano, “Hash Functions for GPU Rendering” (JCGT, 2020), recommend integer hashes such as PCG. Arm has a post on float precision on mobile GPUs. - Interleaved gradient noise (IGN) is Jorge Jimenez’s, from
“Next Generation Post Processing in Call of Duty: Advanced Warfare”
(SIGGRAPH 2014):
fract(52.9829189 * fract(0.06711056 x + 0.00583715 y)). Three multiplies and two fracts, nosin. - Blue noise, noise with little low-frequency content, comes from Ulichney’s void-and-cluster method (SPIE 1913, 1993). Christoph Peters publishes free blue noise textures, and Wolfe et al. extend them over time in Spatiotemporal Blue Noise Masks (EGSR 2022).
- Triangular dither (the sum of two uniform noises) is the textbook choice for audio and images: Lipshitz, Wannamaker and Vanderkooy, “Quantization and Dither: A Theoretical Survey” (JAES, 1992); a short version is on Wikipedia.
- For games, Mikkel Gjøl’s “Banding in Games: A Noisy Rant” (Playdead, 2016) says: dither just before each quantization, in the space the values are stored in; use triangular noise; sRGB or higher-precision intermediates go a long way; dither every pass.
- Banding metrics exist for video: CAMBI (Netflix) and BBAND. We didn’t port them.
We found nothing that compares these dithers for soft glows in an 8-bit linear target followed by a smoothing filter. So the claim here is narrow: which known technique is best on this path, measured. Some sources we only saw as search snippets, because the run’s machine couldn’t open them: iryoku.com, blog.demofox.org, loopit.dk, jcgt.org (403), reedbeta.com, shadertoy.com, gamedeveloper.com, pixelmager.github.io, bartwronski.com, momentsingraphics.de and the Lipshitz PDF at hajim.rochester.edu.
What we tried
The next sections are the technical record. The pictures and Results carry the story, so skip ahead if you like.
Everything is in code/.
A copy of the path, checked against the real one
We wrote a harness (webgl.mjs.txt) that draws the
game’s own glow shader with the game’s own three.js and painterly pass in
headless Chromium, and reads the pixels back. The browser has no graphics card:
it draws with SwiftShader, a software GPU with 32-bit floats. Then a numpy
port of the path (pipeline.py) was held to it
(validate.py, out/validate.txt).
On the 8-bit target the port matches exactly. After the painterly pass it
matches exactly too, at pixel ratios 1 and 2, once one detail is right: a
filter tap that sits exactly on the edge between two of the pass’s eight
sectors goes to the higher one on this GPU (the fresh check below caught
that; before the fix, 96 to 100 percent of pixels matched, within one level).
Reading the sine hash back from the GPU
On a big phone screen (1290 by 2796) the sine hash is not the uniform noise it
looks like. We read it back from WebGL (probe.py) and
counted distinct values per 64 by 64 tile. Near the top-left origin (y = 0)
there are about 633. At y = 700 there are about 344, at y = 1400 to 2100 about
210, at y = 2700 about 117, and a diagonal lattice shows up. A correctly rounded
sin would give about 1050 everywhere. A model where sin is computed from
the fraction of a turn held in 32-bit float (we call it M2; it is the way GPUs
commonly reduce the angle) reproduces the counts: 625, 345, 207, 207, 115.
Here is that model running in your browser, next to the noise that replaced it:
Each patch is 64 pixels on a side, 600 pixels in from the left, at the chosen height on a 1290 × 2796 screen. Slide up and the sine hash's count recovers; slide down and it runs out of values and falls into a faint diagonal grid. Fewer distinct values means many pixels get the same nudge, so they round the same way. Even a correctly rounded sine gives it only about 1050 (counted at 16 bits): after multiplying by 43758 the 32-bit result is in the tens of thousands, where a float keeps only a few hundred fractions. The new noise's regular look is deliberate: neighbouring pixels always differ as much as they can, which is what a blur averages away best. The blurred row shows it: flat grey is good.
At half precision (mediump) things are worse: the hash’s argument overflows
on 74 percent of that screen (out/mediump.txt). But
three.js asks for highp and every WebGL2 device has it, so that is a worry
for fallback hardware only, not for this game.
The grid
experiment.py scores 23 candidate dithers and fixes on
5 dark test scenes (a single violet glow; a faint one; five overlapping glows;
five concentric; two rings), with the painterly pass at full, lite and off and
at pixel ratios 1 and 2. Each result is compared with the float ideal, the
picture with no 8-bit rounding anywhere. The candidates included the old
dither, the old one without its cutoff, one seeded per layer, one with an exact
sin, white noise, IGN with and without the cutoff, blue noise (a 64 by 64
void-and-cluster tile, bluenoise.py), triangular noise
(two noises added, and Gjøl’s one-noise remap), blue noise per CSS pixel, a
final dither on the screen write (blue noise, IGN, IGN with x and y swapped,
Roberts’ R2) and a half-float target.
Two numbers score each run. Band is the RMS low-frequency error in L* against the float ideal, after a blur of 2 cycles per degree at phone viewing distance. Steps is the number of contour pixels per megapixel, a count of where a flat band meets the next. The visibility scale is ours, not a published metric (see the limits below).
The stand-in near the top of the page lets you switch between these dithers on one glow or two.
A real before and after
beforeafter.py renders the glow and ring shaders in
real WebGL, exactly as written in the game before and after the change.
The real cutscene
We then drove the game’s own cutscene demo page in headless Chromium (390 by 844). The frames showed visible concentric rings in the warm pool behind the fleet, both before and after the shader change. That was the sprite: hiding the pool made them go. The sprite is the one glow with no dither at all, so the shader work had not touched it. This is the banding a visitor could see; the shader dither turned out to be a smaller, mostly sub-threshold effect.
Dead ends
- Blue noise per CSS pixel (one noise value per CSS pixel, so shared by its device pixels) did not help.
- Triangular dither did worse than expected. It adds more noise, and the painterly pass was already averaging.
- A plain IGN as the final dither on the screen write, on top of IGN in the layers, made contour lines of its own (1390 per megapixel on the single glow, against 148 with no final dither at all): IGN is laid out in diagonal stripes, and the two sets of stripes ran the same way. Turning the final one on its side, x and y swapped, fixed that (23). It tied blue noise and R2, so no texture is needed.
Results
Paint full (radius 5), pixel ratio 2. Each cell is band / steps. Lower is better. For scale: 1 in L* is roughly the smallest step people notice. Band is an average over the whole picture, so even .15 means faint rings in places; the rings a visitor could actually see were the warm pool’s (below).
| dither | glow | faint | stack | concentric | ring |
|---|---|---|---|---|---|
| none | .071 / 16960 | .122 / 19357 | .258 / 7590 | .249 / 12121 | .066 / 3241 |
| old (sin, cutoff) | .023 / 776 | .017 / 331 | .153 / 208 | .152 / 779 | .029 / 86 |
| old without the cutoff | .023 / 280 | .017 / 180 | .077 / 66 | .082 / 148 | .023 / 46 |
| IGN with the cutoff | .005 / 574 | .005 / 137 | .119 / 137 | .113 / 499 | .020 / 83 |
| IGN without the cutoff | .005 / 148 | .005 / 123 | .008 / 20 | .009 / 43 | .014 / 26 |
| blue noise | .006 / 160 | .007 / 66 | .012 / 29 | .011 / 74 | .013 / 29 |
| shipped (IGN, offset per glow, no cutoff, plus IGN on its side on the screen write) | .006 / 23 | .004 / 0 | .009 / 6 | .009 / 14 | .012 / 0 |
| half-float target + screen dither | .002 / 114 | .001 / 11 | .003 / 31 | .003 / 111 | .003 / 6 |
The full grid, with paint off as well, is out/summary.txt.
What the table says:
- Where glows overlap, both the cutoff and the hash matter. Each glow threw away the last half-step of its fade, and where that edge sits on top of another glow it shows as a ring. On our measure, taking the cutoff off the old dither alone roughly halves the error in the stack (.153 to .077), and IGN with the cutoff left on barely helps (.119). The fresh check’s own measure, which weighs broad shapes less, puts it the other way round: the hash change alone cuts more (38 percent) than the cutoff alone (7 to 13 percent). Which is the bigger single cause depends on the measure; only the two together give the big drop.
- The hash. Under the painterly pass, IGN and blue noise average back to the true level far better than the sine hash, and better than ideal white noise (white, no cutoff, seeded: stack .035).
- IGN and blue noise tie. Triangular noise does worse here.
- The screen-write dither mostly removes the last one-level contours (the steps column), not band error.
- A half-float target (16-bit floats instead of 8-bit linear) is lower still: 2 to 4 times below the shipped dither with the painterly pass on, and 15 to 90 times below it with the pass off. It would also lift a ceiling we noticed on the side: the 8-bit linear target limits the brightest white on screen to about 231 of 255, because it clips at 1.0 before the tone curve, which needs more than 1.0 to reach white.
With the painterly pass off
With radius 0 nothing but the half-float target gets below about 0.07 to 0.11
L* (old: .075, .080, .155, .103, .041; shipped: .076, .087, .074, .111, .049;
half-float .002 to .003). The reason, checked: the target stores linear light,
and the tone curve is convex near black. A pixel dithered between two levels
therefore looks brighter on average than the level it stands for. Predicting
that error from the curve alone gives .079, .091, .057, .066 and .048 against
the measured .076, .087, .074, .111 and .049
(floor.py, out/floor.txt). That
accounts for the single-glow scenes and most of the stacked ones. With the
pass off, the new dither is about the same on band error (slightly worse on
concentric and ring) but cuts the grain 3 to 4 times: the stack goes from .214
to .058 L*.
Real shaders, before and after
On the game’s own shaders in real WebGL (paint full, pixel ratio 2), the band
error falls from .143 to .009 on the stack, .145 to .009 on the concentric
scene and .015 to .004 on the faint glow. Contour pixels go from 257 to 9, from
1067 to 14 and from 568 to 0 (out/beforeafter.txt;
it needs a browser to rerun). That is about 16 times on the overlapping
glows and about 4 times on the single faint one.
The warm pool in the real cutscene
In the crop of the rift beat, the rings in the warm pool were 317 contour
pixels per megapixel before and 0 after
(poolsteps.py, out/poolsteps.txt).
Before
AfterThe footage at the top of the page is the same moment in motion: 3.2 seconds
of the rift beat, filmed a frame at a time from the game before the change and
after it (film.mjs.txt), side by side. Video
compression is exactly what adds or hides banding in dark gradients, so
footage.py checks the clip against the lossless frames
(out/footage.txt). After a 4-pixel blur, the
difference between the halves, the rings, survives with correlation 0.99 at
the same size. Played back in Chromium and compared screenshot by screenshot
(footage-browser.mjs.txt,
out/footage-browser.txt), the after half
matches its source with an RMS gap of 0.15 in L*, about a sixth of the rings’
own 0.87. (ffmpeg’s own decode, in out/footage.txt, reads 0.57: almost all of
it a uniform shift from ffmpeg’s colour conversion, which the browser doesn’t
make.) The encoding is not free. It flattens the old rings’ bands, so their
contour pixels rise from about 50 to about 2,300 a frame, though the rings stay
the same size. And it leaves faint flat patches in the after half, about 900
contour pixels a frame where the uncompressed frames have none, under 1% of its
pixels. Brightened, you can find them; at true brightness the
old rings are what shows.
Changes to the idea itself
The change is in Starlock in this PR
(public/ideas/starlock/cutscene3d.js and reveal3d.js). Its 206 unit tests
and its cutscene-shots browser check pass. The core of it is the dither
itself:
// after
const DITHER = `uniform float uSeed; float dither() { vec2 p = gl_FragCoord.xy + uSeed * vec2(37., 91.); return (fract(52.9829189 * fract(dot(p, vec2(.06711056, .00583715)))) - .5) / 255.; }`;
- Each glow material gets its own
uSeed(0 to 63), so overlapping layers don’t carry the same pattern. - The
* step(.002, ...)cutoff is gone from the glow, ring, beam-ribbon and Rift shaders. The Rift now adds its dither after the fade rather than multiplying it by the fade. - The warm pool sprite is dithered: its light is now written premultiplied
with IGN added on top, through
onBeforeCompileon itsSpriteMaterial(premultiplied because additive blending scales the colour by alpha, which would scale a dither down to nothing in the pool’s faint edge):
// reveal3d.js
m.onBeforeCompile = (s) => {
s.fragmentShader = s.fragmentShader.replace("#include <opaque_fragment>",
"gl_FragColor = vec4(outgoingLight * diffuseColor.a + (fract(52.9829189 * fract(dot(gl_FragCoord.xy + vec2(23., 61.), vec2(.06711056, .00583715)))) - .5) / 255., 1.);");
};
- The painterly pass now ends with a dither of its own on the screen write, with IGN on its side. It runs for the reveal too.
gl_FragColor.rgb += (fract(52.9829189 * fract(dot(gl_FragCoord.yx, vec2(.06711056, .00583715)))) - .5) / 255.;
An independent check
A fresh model, which had seen none of our reasoning, was given the claims,
the code and this notebook and asked to break them. It wrote its own copy of
the render path from the shaders, its own harness for real WebGL and two
measures of its own: an angle-averaged ring profile, and an error weighted by
a contrast-sensitivity curve that counts only broad shapes (under 4 cycles per
degree). Its checker is in code/independent/,
and its output is out/independent.txt.
- It agreed on the direction everywhere. Old dither against shipped, paint full, pixel ratio 2: glow 3.0 (sensitivity curve) and 3.1 (ring profile) times better; faint 4.0 and 2.6; stack 5.6; concentric 5.9 and 18.9; ring 1.5 and 4.1.
- It found a bug in our port. Taps on a sector’s edge went to the wrong sector, so our float ideal wasn’t quite what the GPU computes. Our own before-and-after had understated the gain (we had .144 to .015 and .146 to .020). We fixed it, and every number on this page is from the fixed port.
- It caught an overclaim. We had first written “5 to 17 times” for every scene but the ring. The single glows gain about 3 to 4 times, and how big the overlapping glows’ gain looks depends on the measure: 5 to 6 times on its sensitivity curve, about 16 on ours.
- It scoped the cutoff claim (see “What the table says” above). It also noted that the ring scene’s gain mostly vanishes if the ideal skips the painterly filter, so that scene’s number belongs to this filter.
- It confirmed the rest. It confirmed the hash readback counts (633 and 117 per tile; the M2 model 625 and 115) and the half-precision overflow (73.9 percent). It regenerated the pool crops byte for byte, and they show the same mean light before and after. Pure black stays black. Starlock’s tests and cutscene check pass, and the blending math is right.
- Small notes. The Rift and the beam ribbons have no seed of their own, so they share the first glow’s offset. Seeds wrap at 64. At half precision the seeded IGN coarsens too, though it never breaks the way the sine hash does.
Limits
- No real phone was in the loop, only SwiftShader.
- The visibility scale is ours: L* error after a blur set by viewing distance (a phone at 30 cm, pixel ratio 2, about 64 pixels per degree; a desktop 45). It is not a published metric.
- The scenes are stand-ins, plus a few real cutscene frames.
- What the sine hash does on phones is a model (M2) matched to SwiftShader. We did not measure it on Apple, Adreno or Mali GPUs.
Where things stand
For this rendering path the question is answered, with the limits above: the old sine-hash dither with its cutoff left measurable banding wherever glows overlap, and the warm pool had none at all. The change is shipped in this PR and measured on a software GPU, and a fresh model’s own checker agrees on every scene, with the factors scoped as in the summary.
What’s next
- A half-float render target (RGBA16F with 4x MSAA) at full and lite
paint, with a capability check and a fallback. It lowers the error 2 to 4
times more with the pass on, 15 to 90 times with it off, and lifts the 231
ceiling. It costs about twice the
memory and bandwidth on phones and needs
EXT_color_buffer_float, so measure it on real phones first. - Real-device captures (Apple, Adreno, Mali) of the hash field and of the pool, to turn the M2 model into a measurement.
- The other sprites are still undithered: the engine glows made by
glow()inship3d.js. The sky uses three’s own dither, also a sine hash, with the argument reduced mod π. - A temporal (per-frame) dither might hide the remaining grain with paint off.
Check it yourself
Everything this run used to reach its results, with the output each step should give: 30 files, exactly as the run left them.
Download the code .zip · 203 KBBrowse the files
It needs Python 3 with numpy, nothing else. Unzip it and run these from its folder. Each prints nothing when its output matches the one shipped with it:
- the main table
python3 experiment.py --summary | cmp - out/summary.txt - the paint-off floor
python3 floor.py | cmp - out/floor.txt - the hashes at half precision
python3 mediump.py | cmp - out/mediump.txt - the rings in the real cutscene
python3 poolsteps.py | cmp - out/poolsteps.txt - the fresh check's own measures
cd independent && python3 banding.py | cmp - ../out/independent.txt
Start with README.md inside: it says how to build and run each piece.