shisaku ~/devlog
$ auth github

Author sign in

Sign in with GitHub to comment on devlog entries.

Continue with GitHub
← all posts

Devlog #6 — One Pixel Wide, At Any Size

A shader fidelity pass, and the one theorem that made it cheap

I had been avoiding the shaders for weeks.

Not because they were hard to write — they were, honestly, kind of fun to write. The problem was that they looked fine on my screen and bad in the game. A lightning bolt that was crisp in isolation turned into a flickering, stair-stepped wire once it was small and moving across the arena. The ward circle, which I was quietly proud of, had a rim that crawled and shimmered like a badly-compressed GIF whenever the camera drifted. Someone watching a clip would not have said “the math is wrong.” They would have said “it looks janky,” and they would have been right, and I would not have had a good answer for why.

This is a devlog about finding the why, and about a single fact from calculus that turned out to fix most of it — without making anything slower. If anything, it made the cheap fixes possible because it was cheap.

I want to be upfront: none of this is novel. The technique I lean on here is old, it is in textbooks, and graphics people have used it for decades. I just hadn’t internalized why it works until I was forced to, and writing it down is how I make sure I actually understand it rather than just cargo-culting a fwidth() call I saw on Shadertoy.


The symptom: edges that don’t know how big they are

Here is the lightning bolt — lance.frag — more or less as it was. I have trimmed it to the part that matters:


// shaders/lance.frag (before)

float dist = segment_distance(uv, a, b);     // distance from this pixel to the bolt line

float core = 1.0 - smoothstep(0.010, 0.030, dist);

float glow = 1.0 - smoothstep(0.018, 0.095, dist);

Read that core line out loud and you can hear the bug. “The bolt core is fully bright until dist reaches 0.010, then fades to nothing by dist = 0.030.” Those two numbers — 0.010 and 0.030 — are constants in UV space. They describe a band that is a fixed fraction of the effect quad, no matter how many actual screen pixels that quad covers.

So when the effect quad is large on screen, 0.0100.030 of UV spans many pixels, and the edge is a soft, pleasant gradient. When the quad is small — a bolt zipping between two distant enemies — that same 0.0100.030 might span less than one pixel. The transition can’t happen across a fraction of a pixel, so it snaps. Move the bolt a little and a different set of pixels snap. That is the shimmer. That is the jank.

I had been “fixing” this for weeks by nudging the constants. Make the band wider and it stops shimmering at small sizes — but now it’s a fat blurry rope at large sizes. Make it narrower and it’s crisp up close and a dashed mess far away. There is no constant that is right at all sizes, because the problem isn’t the value, it’s that the value is constant at all.


The theorem: the screen-space derivative is the pixel size

Here is the fact I should have respected sooner.

A fragment shader runs on every pixel, but the GPU doesn’t run them one at a time — it runs them in 2×2 quads. Because it has the neighbours right there, it can hand you, for free, the rate of change of any value across one pixel step in screen space. In GLSL that’s dFdx and dFdy, and the convenient combination is:


fwidth(v) = abs(dFdx(v)) + abs(dFdy(v))

fwidth(v) answers exactly one question: “how much does v change from this pixel to the next one over?”

Now connect it to our problem. We have dist, the distance from the current pixel to the bolt. fwidth(dist) is, by definition, approximately how much dist changes per pixel — which is the same as saying how wide one pixel is, measured in the units of dist. It’s the first-order Taylor term: over one pixel step h, dist changes by roughly ∇dist · h, and fwidth is the absolute-value approximation of the magnitude of that gradient projected onto the pixel grid.

That’s the whole theorem, and it is genuinely a theorem and not a hack: if you feather an edge over a window of width fwidth(dist) centred on the boundary, you are computing the analytic coverage of that edge within the pixel — the fraction of the pixel that falls on the lit side. That is precisely what anti-aliasing is. A 16× supersampler approximates this coverage by taking 16 point samples and averaging. The derivative gives you the answer in closed form, with one sample, because the edge is locally a straight line and the area of a half-covered pixel is something we can just compute.

So the fix writes itself:


// shaders/lance.frag (after)

float aa_band(float dist, float w) {

#ifdef GL_OES_standard_derivatives

    float aa = fwidth(dist);   // one pixel, in dist-units, at THIS size

#else

    float aa = 0.0025;         // graceful fallback if derivatives are absent

#endif

    return 1.0 - smoothstep(w - aa, w + aa, dist);

}



// ...

float core = aa_band(dist, 0.018);

float glow = exp(-dist * dist * 520.0);   // smooth glow, no hard rim

The band is now w ± fwidth(dist) — half a pixel of feather on each side of the edge, whatever the current size of the effect. Tiny bolt far away: one pixel of softening. Big bolt up close: one pixel of softening. The edge is always exactly as soft as it needs to be and no softer. The shimmer is gone because the transition is always given exactly one pixel to happen in, so moving the bolt can never compress it into nothing.

The ward circle got the same treatment, just phrased as a signed distance to a ring band so the feather sits on both sides of the stroke:


// shaders/ward.frag (after)

float aa_ring(float r, float center, float width) {

    float signed_d = abs(r - center) - width;   // <0 inside the stroke, >0 outside

#ifdef GL_OES_standard_derivatives

    float aa = fwidth(signed_d);

#else

    float aa = 0.004;

#endif

    return 1.0 - smoothstep(-aa, aa, signed_d);

}

The abs(r - center) - width is just the signed distance to a circular stripe. Feather it over one pixel, done. The rim that used to crawl now holds still whether the ward is a coin or fills the screen.


Where it bites: step() is the enemy

The worst offender was the snare effect. It had this:


// shaders/snare.frag (before)

float net = step(0.72, hash(floor((p + 1.0) * 8.0 + u_seed * 31.0))) * 0.18;

step() is a cliff. There is no transition at all — a pixel is either on one side or the other. There is no width to feather, no gradient to measure; fwidth of a step output is mostly zero with occasional spikes. You cannot anti-alias a cliff after the fact. The only fix is to not build a cliff in the first place. So the binary hash-confetti became smooth value noise with a soft threshold:


// shaders/snare.frag (after)

float net = smoothstep(0.55, 0.85, vnoise((p + 1.0) * 8.0 + u_seed * 31.0)) * 0.22;

vnoise is bilinearly-interpolated value noise — continuous, so it has a gradient everywhere, so the soft smoothstep threshold actually has something to bite into. It reads as a soft web instead of hard-edged static.

The snare also taught me a smaller, sneakier lesson. The spiral strands were measured like this:


float spiral = fract(angle / 6.28318 * 3.0 - r * 2.6 + u_time * 0.8 + u_seed);

float strand = 1.0 - smoothstep(0.035, 0.10, abs(spiral - 0.5));

fract() wraps from 1 back to 0. Right at that wrap, abs(spiral - 0.5) has a discontinuity — a seam where the strand abruptly breaks. The honest fix is to measure distance on the circle the value actually lives on, not on the number line:


float band_d = abs(spiral - 0.5);

band_d = min(band_d, 1.0 - band_d);   // toroidal distance — wraps cleanly at 0/1

float strand = aa_band(band_d, 0.06);

min(d, 1 - d) is the distance on a unit circle instead of a line segment. The seam vanishes. This isn’t a fidelity-vs-speed thing at all — it’s just a correctness bug I’d been seeing as “a bit janky” and never diagnosed. A lot of “janky” is undiagnosed bugs wearing a costume.


The part where I argue with myself: fidelity vs. optimization

The brief I gave myself was “increase visual fidelity,” and the reflexive way to do that is to throw samples at the problem. Render the effects to a 2× target and downsample. Take nine taps for a soft glow. Supersample the whole scene. All of these work. All of them cost real milliseconds, on every frame, on every machine — including the modest laptops I actually want this game to run on.

The derivative approach is the opposite trade. It is one extra fwidth per edge — a couple of ALU ops the hardware computes essentially for free, because it already has the 2×2 quad in flight. It does not add a pass. It does not add a sample. It produces a result that, for a locally-straight edge, is exactly what an infinite supersampler would converge to. So this was the rare case where “more beautiful” and “cheaper” pointed in the same direction, and I want to be careful not to oversell that as wisdom. It’s not wisdom. It’s the specific geometry of this specific problem: thin lines and rings, where the edge is the whole image and the edge is locally straight. The theorem is tailored to that case.

Where it stops being free is exactly where the assumption breaks. fwidth is a first-order approximation — it assumes the function is locally linear across the pixel. At a sharp corner, where two edges meet inside one pixel, the linear model is wrong and you get a tiny artifact. Under extreme minification, when a whole spiral arm collapses into a sub-pixel speck, no single-sample coverage estimate can represent “there are three strands in here” — that genuinely needs more samples or a mip chain, and the derivative trick will alias. I did not solve those cases. I decided they don’t happen in this game: the effects are short-lived, mid-screen, and never shrink to a speck. That decision is the actual engineering. The math was the easy part; knowing the exact boundary where the cheap thing stays honest is the part worth writing down.

There’s also a small honesty tax I paid. fwidth lives behind the GL_OES_standard_derivatives extension on the GLSL ES 1.00 profile this project targets. It’s near-universal, but “near” is not “always,” so every helper has a fallback:


#ifdef GL_OES_standard_derivatives

    float aa = fwidth(dist);

#else

    float aa = 0.0025;   // a fixed feather: not size-correct, but never broken

#endif

If the derivative isn’t there, you fall back to exactly the old behaviour — a fixed band. Worse, but not broken. That’s the same graceful-degradation reflex from the data loaders, just in GLSL: assume the environment will sometimes be poorer than yours, and make the poor case dull instead of fatal.


The bug I found while I was in there

While re-reading the manifest I noticed the burst shader had no uniforms declared:


*# Assets/Data/shaders.toml (before)*

[[shader]]

id = "burst"

vert = "shaders/burst.vert"

frag = "shaders/burst.frag"

*# ...and nothing else.*

But the runtime sets six uniforms on every ability material, burst included:


*// src/runtime/mod.rs*

material.set_uniform("u_progress", progress);

material.set_uniform("u_color", */* ... */*);

*// ...four more*

Because the material was created from the manifest’s (empty) uniform list, those names had no registered target. The sets quietly went nowhere. Burst had been rendering with un-driven uniforms the whole time — no progress, no tint — and I had filed that under “shaders look janky” along with everything else. It wasn’t a fidelity problem. It was a wiring problem I’d misread as an art problem. Declaring the six uniforms in the manifest fixed it, and it’s a good reminder that “looks wrong” is a symptom with many possible diseases, and guessing the disease from the symptom is how you spend three weeks nudging smoothstep constants.


What shipped

Six fragment shaders moved from fixed-threshold edges to derivative-based anti-aliased edges: lance, chain, ward, snare, burst, and the orb spark (its hard if (r > 1.0) discard disc clip is now a one-pixel feathered mask). The high-frequency sin “flicker” that read as a strobe became a pair of detuned sines that beat into a shimmer. The burst uniform contract got fixed in the manifest. All fourteen shaders still compile and load on the GPU — I checked the startup log rather than trusting myself, because the whole point of this entry is that I trust my eyes less than I used to.

No new render pass. No new samples. A couple of derivative instructions the hardware was already paying for.


My daughter has graduated from falling over to running directly at things she shouldn’t. The other night she was standing too close to the television, nose almost on the glass, and from there of course it looks like nothing — just enormous fuzzy blocks of colour. She backed up, found the spot where the picture resolved, and sat down. She’d found the distance where the image was correct.

That’s all anti-aliasing is, really. The image is only ever right at some distances. The work is making it right at all of them — and the cheapest way to do that, it turns out, is to ask each pixel how big it is and trust the answer.

$ comments

Reader notes

0 notes

No notes yet.

If you're reading this as a developer: this devlog is built in the open.

If you're reading this as a modder: the direction is source-visible, inspectable systems.