Graphics / Three.js / Shaders / Interaction design / Debugging · 4 min read
A spotlight without the rings
A small visual defect in this site turned into a useful distinction: lighting the background and revealing its geometry are different operations.
Muhammad Musa
The detail that broke the illusion
The background of this site used a cursor spotlight to reveal a constellation. The interaction was meant to feel quiet: a dark surface, a small warm pointer, and a little structure emerging as I moved. Broad blue-black rings around the pointer made the surface feel like a stack of effects instead.
The distinction mattered more than the size of the change. I still wanted the spotlight. I did not want the empty background to become a visibly shaded disk. Removing the interaction entirely would have hidden the symptom without preserving the design.
Reveal the marks, leave the surface alone
The earlier implementation placed a veil over the scene. A later version used a CSS mask to fade the rendered constellation. I moved the reveal into the point and line materials, where it could change the opacity of the geometry without painting a separate gradient across otherwise empty pixels.
The material compares the current fragment position with the pointer, divides by the spotlight radius, and uses the resulting distance to calculate a falloff. Points and line fragments receive that opacity adjustment. A pixel where the scene drew nothing remains the original matte surface.
distanceFromPointer = distance(fragment, pointer) / radius
geometryOpacity *= reveal(distanceFromPointer)
// Empty background pixels receive no extra tinted layer.Coordinates are part of the visual design
Pointer events arrive in browser coordinates. The shader works in drawing-buffer pixels, with a different vertical origin. The implementation accounts for the renderer’s pixel ratio and flips the pointer’s vertical coordinate before sending it to the material. A correct equation in the wrong coordinate system still produces the wrong interaction.
I also kept the pointer easing dependent on elapsed time rather than assuming every display draws the same number of frames. That lets the spotlight follow smoothly without making the intended response depend entirely on refresh rate. It is a small piece of engineering in service of a very simple visual promise.
Fixing the rings did not settle the brightness
After the rendering change, the live background could still feel too dim. Brightness outside the spotlight and the amount of fading while the pointer rested were separate controls. I adjusted those without changing the palette. That was a useful reminder that a technically correct fix does not automatically match the visual intention.
For the next design, the requirement became more specific: a fully matte surface, with the hidden geometry visible only under the cursor. That changes the ambient visibility target, but keeps the same separation between the surface and the thing being revealed.
Verify the delivered page
I checked the production build and the assets served by the public URL, then inspected the actual page. An up-to-date deployment and the right visual result answer different questions; both need checking. A user describing a translucency difference is giving useful evidence even when the deployment contains the expected commit.
The part worth reusing is not a particular shader trick. It is the decision to give each visual layer one job, then test the interaction in the place people will experience it. A quiet effect stops feeling quiet as soon as its implementation becomes the most visible thing about it.