Total Internal Reflection
My shader drew nothing three times, and the screenshot could not tell me why. The fix was not a better shader, it was making the invisible number printable.
I asked my agent to clone a website. Vercel Labs had just shipped vgpu, a WebGPU library for agents, and its landing page at vgpu.sh is the best-looking developer site I have seen this year. Pure black, a glass prism splitting a beam into a spectrum, hairline panels labelled in tiny wide-tracked monospace. I wanted that on my blog.
The agent asked a clarifying question, which was the right instinct, and I answered it badly and then well:
use it to innovate and make better motion/graphic/interactions on my blog
So not a clone. Keep my serif, my coral, my museum paintings, and steal the craft instead. Fine. That is the more interesting job anyway.
Then the prism refused to work, and the next two hours taught me more than the rest of the session put together.
The shot
The physical setup is the Dark Side of the Moon cover, and everyone knows what it should look like:
/\
/ \
white beam ------> / \
/ \ -----> red
/ \ -----> orange
/ \-----> ... etc
/____________\
vgpu's version is a fully path-traced WebGPU scene with bloom, an environment map, dust motes and a glass BSDF. It is about 200KB of renderer across forty files. That is their product demo, and it should be, because the product is a GPU library. It is not something to lift onto a static blog that builds with a 1,400 line Python script.
So the agent built a cheaper analog with the same physics. Solve the optics per wavelength in JavaScript, hand the resulting rays to the GPU as uniforms, and let the fragment shader do nothing but draw glow around them. Twenty-six wavelengths from 400nm to 680nm, Cauchy dispersion for the refractive index, two refractions each:
per pixel (a million times a frame) per wavelength (26 times a frame)
--------------------------------- --------------------------------
refract, intersect, refract, glow refract, intersect, refract
then: glow only, on the GPU
That split is the whole reason it can be cheap. Solving refraction per pixel repeats identical algebra a million times; solving it per wavelength gives you 26 rays.
It rendered a beam. It rendered a triangle that looked convincingly like glass. It rendered no spectrum at all.
Three rounds of guessing
Here is what the first three attempts looked like from the outside. Screenshot the hero. Look at it. See a beam entering the prism and nothing leaving it. Adjust something plausible. Rebuild. Screenshot again.
Round one: the fan is too dim. Multiply the per-ray intensity by twelve. No change.
Round two: the dispersion is too narrow to separate at this scale. Push the Cauchy B coefficient from the real crown-glass value of 0.00459 up to 0.05, which is wildly unphysical but should fan the wavelengths across the frame. No change.
Round three: the exit normal is pointing the wrong way. This one was a real bug. GLSL's refract(I, N, eta) needs the normal facing against the incident ray. On the way into the prism the outward normal already does that. On the way out the ray is leaving, so the outward normal points along with it and has to be flipped. Fixing that was correct and necessary.
Still no fan.
Three rounds, three plausible theories, one real bug found, and the picture on screen was identical every time. That is the part worth sitting with. The screenshot was a perfectly honest report and it was useless, because "no light here" is the same image whether the cause is intensity, dispersion, a sign error, or something I had not thought of. The output of a shader is a picture. The thing that was wrong was an angle.
what I could see what was actually wrong
---------------- -----------------------
[ black pixels ] <---- incidence 45.6 deg
critical 36.9 deg
(every wavelength reflected back inside)
Printing the invisible thing
The move that ended it was not clever. The agent copied the optics functions out of the shader pipeline into a standalone 30-line Node script and printed the intermediate values for five wavelengths.
One run:
400 n 1.813 d1 0.938,-0.347 dot(nExit,d1) -0.684 d2 TIR
470 n 1.726 d1 0.942,-0.336 dot(nExit,d1) -0.692 d2 TIR
550 n 1.665 d1 0.945,-0.327 dot(nExit,d1) -0.699 d2 TIR
620 n 1.630 d1 0.947,-0.322 dot(nExit,d1) -0.703 d2 TIR
680 n 1.608 d1 0.948,-0.319 dot(nExit,d1) -0.705 d2 TIR
Total internal reflection, on every single wavelength. The sign convention was now correct, the refraction was being computed correctly, and the answer was correctly "no light leaves this face."
And the cause was my own round-two fix. Raising the refractive index to widen the fan lowers the critical angle:
critical angle = asin(1/n)
n = 1.52 (real crown glass) -> 41.1 deg
n = 1.67 (round two) -> 36.9 deg
internal angle at exit face -> 45.6 deg both times
45.6 > 41.1 > 36.9 -> nothing gets out, ever
The two things I wanted were fighting each other. A wide, visible fan needs high dispersion. High dispersion means a high index. A high index means a low critical angle. A low critical angle means the light never leaves the glass. I had spent three rounds turning the one dial that made the problem worse, and no amount of looking at the render could have told me that, because the render is downstream of the thing that broke.
Searching instead of tuning
Once the constraint was legible the fix stopped being a matter of taste. Five variables (index, prism rotation, beam angle, where the beam strikes the face, prism position and size) and two hard requirements: every wavelength must transmit, and the fan must have enough angular spread to read as separated colour.
That is a search, not a tuning session. So: grid search, 257,787 candidates, filtered by whether the trace actually completes.
257,787 candidates
| reject: some wavelength hits TIR
| reject: spread < 7 degrees (reads as one white line)
| reject: beam arrives from the wrong side
v
891 configurations that survive
| rank by: angular spread + how far the fan runs in frame
v
1 winner
The winner is unglamorous and I would never have guessed it: real-ish glass at n≈1.48, the beam striking 24% down the entry face near the thin part of the wedge, arriving very slightly rising rather than level. That last one is the counterintuitive bit. A level or falling beam meets that face too close to its normal, refracts too steeply inside, and reflects. Tilting the incoming beam up by ten degrees is what buys the margin.
Then the same script re-ran the winner across 240 frames of the animation, because the prism drifts and rotates, and a configuration that transmits at rest can start reflecting halfway through a breath. It holds: zero failing frames, 11.5 degrees of spread. The constants are now commented in craft.js with the reason, so the next person to nudge them, including future me, gets told why they cannot.
The fan drew on the first try after that.
Then I looked at a phone
With the hero working I checked the layout at 390px, which I should have done first. The home page was unusable. Not "a bit tight" unusable, but overflowing sideways with the hero squeezed into a 40px vertical sliver.
My first assumption was that I had broken it. I checked the live production site at the same width. Identical.
The cause was one missing line in a media query that had been there for months:
@media (max-width: 900px) {
.home-bento { grid-template-columns: 1fr; }
.banner-hero,
.tile-writing,
.tile-projects,
.tile-now { grid-column: auto; } <-- .tile-music not in this list
}
so .tile-music kept its `grid-column: 3` and the browser
politely invented columns 2 and 3 to put it in:
wanted: [ 1fr ] got: [ 1fr ][ auto ][ auto ]
^ everything else crammed here
A grid collapses to one column, one tile still insists on being in column three, and CSS Grid does the accommodating thing: it creates the columns. No error, no warning, no console message. The page just quietly stopped working on every phone that visited it.
I have no analytics alert for "layout is broken." I have never once opened my own blog on my phone and scrolled the home page. So it sat there.
Then the build lied to me
The last one is the worst and it is my own infrastructure.
My vault, blog included, lives in iCloud Drive. When iCloud cannot resolve a sync conflict it writes a second copy beside the original as filename 2.ext. I knew this. What I did not appreciate is how it compounds, and that it sometimes deletes the original instead of keeping both.
The audit, while I was looking for something else entirely:
static/ 906 MB
assets/dogfood/ 626 MB <-- 45 copies of one 14 MB mp4
assets/audio/ 213 MB <-- "09 19.mp3", "09 20.mp3", ...
og-fonts/ 19 MB <-- JetBrainsMono-Bold 2..36.ttf
assets/styles.css <-- plus styles 2..13.css
assets/craft.css <-- MISSING. only "craft 2.css" survived.
434 conflict copies, 870 MB, and two originals gone.
The two missing originals were craft.css and craft.js, the files I had written that afternoon. iCloud had also silently reverted build.py and both templates to older versions. The build script then ran, found no errors, and produced a complete, valid, deployable site with no prism, no search palette and no search index. It printed Built → site and exited zero, because from its point of view nothing was wrong. It had read its inputs and written its outputs. Nobody had told it what its inputs were supposed to contain.
Recovery was fine, because a timer commits the vault to git every five minutes and one of those commits had the good version. But the failure mode is the thing: a build that cannot fail is not a safe build, it is an unmonitored one.
So build.py now checks its own sources before it writes anything. Nine markers that must exist, plus a scan for conflict copies:
$ python3 build.py
Source integrity check failed (likely an iCloud sync conflict).
Reverted or missing:
- templates/home.html: missing 'data-prism'
Recover from git (the auto-sync commits every 5 minutes):
git log --oneline -20 -- 07-public/huytieu-com
git show <commit>:07-public/huytieu-com/<path> > <path>
It is nine lines of list and one function. I tested it by deleting a marker and confirming the build refused. That test mattered more than writing it did.
The same shape three times
I did not notice until I wrote this down that all three failures are the same failure.
the prism render is a picture the broken thing was an angle
the phone I browse on a laptop the broken thing was at 390px
the build exit code 0 the broken thing was the input
In each case something was reliably, silently wrong, and the channel I was watching was structurally incapable of showing it to me. Not noisy, not ambiguous. Incapable. A screenshot of a dark region genuinely does not contain the incidence angle. A desktop browser genuinely does not evaluate a 900px media query. An exit code genuinely does not describe file contents.
And in each case the fix was the same two-step move, which is the only thing in this post I would actually generalise: get the invisible quantity onto a screen, then write the assertion that watches it for you. The optics went into a script that prints angles, and then into a search that checks every frame. The layout went into a device-emulated screenshot, and then into a measurement (scrollWidth === clientWidth, which is now a number I can check rather than a picture I have to judge). The build inputs went into a printed list of markers, and then into a check that halts.
The agent was not the bottleneck in any of this. It refracted the light correctly the whole time, and it was right about the exit normal. What it lacked, and what I lacked, was a way to see what it had computed. Three rounds of "look at the render and adjust something plausible" is a loop that cannot converge, and it does not matter how good the thing inside the loop is.
The prism is on the home page now. It fades in on load, pauses when you scroll past it or switch tabs, renders one static frame if you have reduced motion on, and falls back to the bare Bierstadt painting if your browser has no WebGL2. It cost less than half a second of GPU work per second and about 27KB of JavaScript.
The mobile fix was one line. Cleaning out iCloud's duplicates took the site from 470MB to 57MB and a post page from 450KB to 72KB over the wire. That last number is the one I am quietly happiest about, and it has nothing to do with the shader.