The Umbra
Inspired by Umbra, penumbra and antumbra on Wikipedia
Built with Pure WebGL2 · Fragment Shader · Canvas Text Rasterization · Golden-Angle Disc Sampling
Techniques Multi-sample soft shadow from extended disc light · Ray-occluder plane intersection · Golden-angle spiral sampling with per-block rotation · Dual-texture text cross-fade · Smooth penumbra color mapping
Direction What if you never saw the text itself — only the shadow it casts? The cursor becomes a light source. The word exists only as absence.
Result Text that is never drawn — only implied. 48 light samples per pixel compute physically-accurate soft shadows of invisible floating text, revealing LIGHT and then SHADOW as dark impressions on a sunlit surface
The Story
350 BCE. Aristotle watches Earth’s shadow cross the face of the Moon during a lunar eclipse. The edge of the shadow is curved. Always curved. No matter the angle, no matter the time of night. He concludes: the Earth must be a sphere, because only a sphere casts a circular shadow from every direction.
This is perhaps the oldest scientific deduction from shadow geometry. But shadow structure runs deeper than Aristotle knew. Any extended light source — a disc, a sphere, a fluorescent tube — creates not one shadow zone but three. The umbra is the region of complete occlusion: no part of the light source is visible. The penumbra is partial: some rays reach the surface, others are blocked. And the antumbra — the most counterintuitive — appears when the occluder is too small to fully cover the light source, creating a ring of light around a core of shadow.
The width of the penumbra depends on three things: the radius of the light source, the distance from light to occluder, and the distance from occluder to surface. The formula is elegant: penumbra_width = R_light * H_occ / (H_light - H_occ). A point light source (zero radius) produces zero penumbra — a perfectly sharp shadow. As the light source grows, the penumbra widens, and the umbra shrinks. At the limit, when the light source is infinitely large, every shadow is infinitely soft. This is why overcast days have no visible shadows — the entire sky becomes the light source.
The Take
The experience inverts the typical relationship between text and visibility. The word is never rendered on screen. Instead, it floats as an invisible occluder between a disc light source and a sunlit ground plane. What you see is the shadow — the dark impression where light cannot reach.
The cursor controls the light source position. Move it, and the shadow shifts: light rays from the disc trace through the occluder plane at different angles, and the penumbral edges soften or sharpen depending on the geometry. The shadow is not a texture or a pre-computed image. It is computed fresh every frame, for every pixel, from first principles.
The experience opens with the word LIGHT casting its shadow onto a warm cream surface. At the 25-second mark, the occluder cross-fades from LIGHT to SHADOW — the word “shadow” now casting its own shadow. The concept becomes recursive: the shadow of the word “shadow.”
If the viewer doesn’t interact, the light source drifts on a slow autonomous path — Lissajous curves that produce gentle shadow drift across the surface. Narrative text appears at timed intervals: Aristotle’s deduction, the three shadow zones, and a final line — “The shadow is the proof.”
On mobile, touch drives the light source with tighter shadow parameters (smaller disc radius, lower occluder) to preserve text legibility on the smaller viewport.
The Tech
Pure WebGL2 Fragment Shader
The entire experience runs in a single fragment shader on a fullscreen quad. There is no scene graph, no geometry, no draw calls beyond one gl_drawArrays(TRIANGLE_STRIP, 0, 4). The vertex shader is six lines — it just positions the quad corners. All computation happens in the fragment shader, which runs once per pixel per frame.
This is the simplest possible WebGL2 architecture: compile two shaders, link one program, upload one quad, set uniforms, draw. The complexity lives entirely in the per-pixel shadow computation.
Multi-Sample Disc Light Shadow
For each pixel on the ground plane, the shader computes how much light arrives from an extended disc source. The light is not a point — it has a physical radius (R_LIGHT = 0.12 world units on desktop, 0.06 on mobile). This means each pixel can be fully lit (seeing the entire disc), partially occluded (seeing part of the disc), or fully shadowed (seeing none of it).
The shader samples 48 points on the light disc. For each sample, it traces a ray from the ground pixel toward that point on the disc and checks whether the ray intersects the occluder plane:
vec2 L = L0 + offset * uRLight; // Sample point on disc
vec2 occPt = P + t * (L - P); // Ray-occluder intersection
vec2 tc = occPt / uOccSize + 0.5; // Convert to texture coords
float occ = texture(uOcc1, tc).r; // Sample occluder opacity
lit += 1.0 - occ; // Accumulate light contribution
The ray parameter t = H_occ / H_light is constant for all samples (both planes are horizontal), which simplifies the intersection to a single lerp between the ground point and the light sample point.
Golden-Angle Spiral Sampling
The 48 disc samples use a Vogel spiral — the golden-angle distribution that minimizes clustering on a disc:
float r = sqrt((fi + 0.5) / float(N_SAMPLES));
float theta = fi * GOLDEN_ANGLE + baseAngle;
vec2 offset = vec2(cos(theta), sin(theta)) * r;
The golden angle (2.39996… radians, or approximately 137.5 degrees) ensures each successive sample falls in the largest available gap on the disc. The sqrt radius mapping converts the uniform-in-angle distribution to uniform-in-area, preventing over-sampling at the disc center.
To prevent structured banding artifacts, each 2x2 pixel block receives a random base angle rotation. This is deliberately block-based rather than per-pixel — per-pixel randomization creates visible noise, while block-based rotation preserves local coherence while breaking long-range patterns.
Canvas Text Rasterization as Occluder
The occluder is not geometry — it is a texture. The words LIGHT and SHADOW are rasterized onto offscreen canvases at 400px font size using Anton (a heavy condensed sans-serif). White pixels on black background: white = opaque occluder, black = transparent to light.
Both words are rendered onto canvases of identical dimensions (computed from the wider word plus padding), ensuring they can cross-fade without shifting:
const maxW = Math.max(...words.map(w => tmpCtx.measureText(w).width));
const canW = Math.ceil(maxW + pad * 2);
const canH = Math.ceil(fontSize * 1.4 + pad * 2);
The textures are uploaded with UNPACK_FLIP_Y_WEBGL to match GL’s bottom-up coordinate system, and sampled with LINEAR filtering for smooth edges.
Shadow Color Mapping
The raw shadow value is a float from 0 (full occlusion) to 1 (full illumination). Rather than mapping this linearly to a grayscale gradient, the shader uses a three-tone palette:
| lit Range | Color Blend | Visual Effect |
|---|---|---|
| 0.0 – 0.4 | Dark brown → warm mid-brown | Umbra to inner penumbra |
| 0.4 – 1.0 | Warm mid-brown → cream | Outer penumbra to full light |
The warm mid-tone at vec3(0.48, 0.38, 0.32) prevents the penumbra from reading as flat gray. Real shadows on warm surfaces shift in hue through the penumbra due to subsurface scattering and ambient light contribution — the color mapping approximates this.
A subtle glow effect adds warmth near the light source center (exp(-dist * 4.0) * 0.025), and a vignette darkens the viewport edges. Paper grain at 0.6% intensity prevents the background from reading as perfectly digital.
Occluder Rise Animation
The occluder does not start at its final height. Over the first 3 seconds, it rises from near-surface (0.05) to its resting height (0.25 desktop, 0.18 mobile) with a smoothstep ease. This creates a dramatic opening: the shadow starts as a razor-sharp hard silhouette (occluder touching the surface = zero penumbra) and gradually softens as the occluder lifts away. The viewer watches physics happen in real time — the penumbra widens as the gap grows.
After settling, the occluder breathes: a slow sinusoidal oscillation of ±0.015 world units creates barely perceptible penumbra pulsing.
Responsive Mobile Adaptation
Mobile viewports (< 768px) receive three parameter adjustments:
- R_LIGHT: 0.12 → 0.06 (halved disc radius = tighter penumbra, preserving letter stroke legibility)
- H_OCC_BASE: 0.25 → 0.18 (lower occluder = less shadow magnification)
- occWorldW: Fixed at 0.42 instead of
aspect * 0.55(prevents the text from shrinking to unreadable size on portrait aspect ratios)
DPR is capped at 1.5 on mobile (vs 2.0 on desktop) to maintain 60fps with 48 texture samples per pixel.
This blog post was AI generated with Claude Code. Authored by Artificial Noodles.