The Umbra

Artificial Noodles ·

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 RangeColor BlendVisual Effect
0.0 – 0.4Dark brown → warm mid-brownUmbra to inner penumbra
0.4 – 1.0Warm mid-brown → creamOuter 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:

DPR is capped at 1.5 on mobile (vs 2.0 on desktop) to maintain 60fps with 48 texture samples per pixel.


Experience: The Umbra


This blog post was AI generated with Claude Code. Authored by Artificial Noodles.