Skip to content

Rebuilding lightning from a 5-shader frame study

The Dark Skies bolt used to be a line with noise on it. What five Shadertoy lightning shaders taught me one frame at a time, and the depth tricks nobody sees.

So the lightning in Dark Skies used to be a line with noise on it. Two anchor points picked off the cloud lattice, a straight span between them, a folded noise term to put kinks in the span, and a red glow around the whole thing. (Dark Skies is the volumetric cloud scene from the live rig, the one on /visuals where the camera flies through a cloud band toward a low red sun.) It looked fine in a still. In motion it read as a crack in the screen, a thing sitting on the glass in front of the scene instead of a thing happening in the weather, and once I saw it that way I couldn’t unsee it.

The first fixes were the obvious ones, make the crack more crack-like. On the evening of July 11th the bolt went from a flat screen-space line to a 3D polyline with 9 vertices, then 13 vertices with hotter kinks, then a 17-vertex zigzag with a hard strobe, a second fork and a triple restrike… each pass looked pretty exciting in the stills and each one still read as a crack when it moved. More vertices was not the axis, I sort of knew that by the third pass but it took the fourth to admit it.

So I stopped writing lightning and started watching it. Five lightning shaders off Shadertoy, each one pulled into a little local harness where I could render any single frame at any time (a renderAt(t) call, plus a uniform to force a strike on instead of waiting for its random gate to open), and then stepped through frame by frame, relating what moved to the line of code that made it move. Stepping is the important part. Watching a shader run at 60fps tells you it looks good, stepping through it one frame at a time tells you why, and the why is the only part you can carry into a completely different scene. Also, do not try to predict from JavaScript when a shader’s strike gate will fire, a float64 copy of the shader’s hash drifts from the GPU’s float32 version and you will sit there waiting on gates that never open. That is what the force uniform was for.

Basically the grammar was the same in all five once you looked slowly.

My polyline was a chain of straight segments with kinks at the joints, and every one of the references was a single continuous curve that wanders off the straight line at every scale at once, big slow S-curves with fast crinkle riding on top. Which is just fbm (layered noise, each layer finer and fainter than the last) used as a sideways offset instead of as a texture. The channel is now 4 octaves of noise pushed off the straight span: the first octave puts about 3 bumps along the length, each octave after that is 2.6× finer and 0.55× fainter, and the total wander is capped at about 16% of the span’s on-screen length so a bolt still reads as going from this cloud to that one.

It also writhes. The references keep the channel alive and moving for the whole flash, and the movement is not uniform, the big shape holds while the small stuff sputters. So each octave gets its own clock, the coarsest drifts at a quarter speed and the finest at 1.6×, which means over the life of one strike the S-curve barely moves while the crinkle re-rolls constantly. Then the whole shape re-rolls on the next strike off a fresh seed, because no two bolts share a path. On top of that the brightness strobes between 45% and 100% off a noise that re-rolls about 95 times a second, so the channel sputters. Two forks come off the main channel at 55% and 30% of the way along, on opposite sides, at roughly 38% and 29% of the trunk’s peak brightness, and their crinkle runs on two faster clocks (1.3× and 2.2× the trunk’s, both in each fork) so the branches flicker faster than the trunk does.

The core is a 1/d falloff. That one I would never have guessed from watching at speed. The old bolt’s brightness was exp(-distance × 150), a soft beam profile, and the references all use something shaped like 1/(1 + distance × k), which spikes way harder at the filament and then falls off with a long fat tail. Roughly, for the thin setting: dead on the filament the two are about the same (5 vs 4), a few pixels out the beam is still at 1.9 while 1/d has dropped to 1.6, and at four times that distance the beam is down to 0.2 and 1/d is still at 0.5. One formula gives you the razor core and the big halo both, which is why it was worth swapping for. Plus a broad pedestal underneath (a wide exp falloff at 12% brightness) so the strike floods its neighborhood, which is the area flash that makes the whole sky lift for a frame.

The references all do the same thing with the flash, the strike is over almost before it registers. Instant pop, about 30ms to peak, a decay with a 260ms time constant, then two dimmer restrikes at 55% and 30% of the pop, the first one 130 to 220ms after it (rolled per bolt off its seed) and the second at 2.2× that delay. Do the arithmetic and the visible life is about a second, and if you count the tail nobody can see it is a bit under two before the envelope drops below 0.4% and the slot is freed for the next strike. That stutter of two fading restrikes is most of what makes it read as electricity.

The shipped Dark Skies loop, the same file the /visuals grid plays. The full 16:9 cut is in the lightbox over on /visuals.

Now, the scene is a raymarch through a cloud band (the volumetric core is a port of Shane’s Cloud Flight, XtlfDn on Shadertoy, with a lattice cut layered on top so the field reads as separate clouds), and the strike endpoints are anchored inside actual clouds. A little union-find over the cloud lattice fuses neighbouring puffs into clusters by screen overlap and depth, and a bolt leaves one cluster’s centroid and lands in another’s. The source is biased toward near, prominent masses, the target has to be at least a quarter of the screen away from it and preferably on a different depth plane, so strikes travel through the scape instead of across it. Earlier versions anchored on single lattice nodes, which are sub-puffs of what your eye calls a cloud, and the bolts left from nowhere.

The old bolt handled clouds with one number: multiply the whole thing by one minus 0.7 times the cloud alpha at that pixel, front or back, didn’t matter. Now the march records, per pixel, the depth at which the cloud in front went solid, and any part of the channel behind that depth gets buried while the part in front stays at full brightness (the first version dimmed the front part a little for cloud that was actually behind it, and washed out the whole bolt). There were two traps in that one line. First, “solid” was originally coverage above 0.5, which only ever fired on clouds that were 86%+ opaque, so a back-cloud bolt drew straight over a visibly solid fore-cloud and it took a while to see why. It is 0.18 now, about 51% alpha.

I think the best way to explain the second trap is with an example. Say the strike is anchored in a cloud whose center sits 12 units out, and the march on that pixel hits solid cloud at 11. The anchor is inside that cloud on purpose (bolts come out of clouds, that is the point), so with a strict occluder every sample of the channel from 11 outward is buried and the whole strike is born dead. With 1.5 units of grace past the solid depth the channel registers from 11 to 12.5 before the cloud closes over it, which is enough to see it leave. I have no physical justification for 1.5, it is the number where it stopped looking wrong.

Two more things I’d rather not have learned the hard way. Shadertoy references are composed on black, and the first straight port of the 1/d core (gain 1.6) was completely invisible over the red inferno this scene runs on, it took 5.0 to saturate through the tonemap, so about 3×, and the rule is calibrate against the live backdrop and never against the reference. And a headless test page without a video recording attached throttles its animation frames, so the frame time spikes and any envelope-driven effect (a 260ms flash, say) is dead by the time the screenshot lands… which is how a working bolt got reported as broken twice before the probes moved to a recording context.

Anyways. The clouds and the lightning are one fragment shader, value noise all the way down, no textures, and it went from crack to weather in one evening with the frame study sitting in the middle of it, which I figure was the part that actually mattered. The code that evening was typed by an agent with me driving, every look pass and every number in it is mine, the 1.5 included. Sorry, this got long, it turns out there is more in one bolt than I expected when I started writing it down. I still haven’t done the projector pass, everything above was judged off captured clips and my monitor, and there is a decent chance the grace window or the 5.0 gain moves once it is on a wall… The black speckles the thickest deck throws at high density with the red grade turned down are also still unexplained (they are masked at the shipped grade, which is the only reason nobody sees them), three trials of a march overstep fix measured as doing nothing, so if anyone has seen a raymarch throw single-pixel holes that move every frame, I would really like to hear about it.