A Real-Time Clock Rendered with Signed Distance Functions

Open in full screen ↗  —  Move your mouse over the clock to interact with the cube.

Note for large screens: The shader uses bounding-sphere checks to skip full SDF evaluation for pixels that clearly miss all geometry, but on high-resolution or wide-screen displays the sheer number of fragment invocations still adds up and you may notice lower frame rates.

What are Signed Distance Functions?

A signed distance function (SDF) maps any point in space to the shortest distance between that point and the surface of a shape — positive outside, negative inside, zero exactly on the surface.

For example a hypersphere (circle in 2D, sphere in 3D, 4D sphere in 4D, etc.) with center CC and radius rr can be constructed from the following SDF:

ϕ(P)=PCr\phi(P) = \|P - C\| - r

That single scalar tells a ray-marcher exactly how far it can safely step without overshooting geometry, which is what makes SDFs so powerful for real-time rendering: complex shapes reduce to a handful of arithmetic operations in a GLSL fragment shader.

SDFs also compose elegantly — union, intersection, and subtraction are min, max, and negation respectively. I introduced the topic in more depth, including the erythrocyte model that motivated it, in Erythrocyte Modelling (Part 1).

The SDF Clock

The clock at sdf-clock.pedramramezani.de runs entirely inside a GLSL fragment shader mounted on a full-screen quad via Three.js. There is no canvas 2D drawing, no DOM manipulation for the digits — the current time is uploaded as a uniform and the shader figures out what to draw. Internally it is a full 3D ray-marched scene: a camera at a fixed Z position shoots one ray per pixel, and each ray steps forward by the SDF value until it hits a surface or exits the scene. Hours, minutes, and seconds are rendered as three pairs of digits stacked vertically in world space.

Uniforms

The JavaScript side passes the following uniforms each frame:

uniforms = {
    resolution: { value: new THREE.Vector4() },  // viewport size
    delta:      { value: 0.0 },                  // elapsed time (seconds)
    mouse:      { value: new THREE.Vector4() },  // cursor position
    hours:      { value: { tens: 0, ones: 0 } },
    minutes:    { value: { tens: 0, ones: 0 } },
    seconds:    { value: { tens: 0, ones: 0 } },
}

delta drives the cube's continuous rotation. mouse carries the cursor coordinates (the shader reads it as uniform vec2 mouse). The time fields are updated only when their value actually changes — hours only when the hour changes, etc. — avoiding unnecessary uniform uploads.

On the GLSL side the time data is received through a struct:

struct TwoDigitSegment {
    lowp int tens;
    lowp int ones;
};

uniform TwoDigitSegment hours;
uniform TwoDigitSegment minutes;
uniform TwoDigitSegment seconds;

Each tens/ones field is not the raw digit but a packed integer: one bit per segment of the seven-segment display. createActiveSegments(digit) maps a digit 0–9 to a 7-element boolean array, and packBooleansToInt folds that into a single int with bit ii set when segment ii is active:

function packBooleansToInt(booleans) {
    let packed = 0;
    for (let i = 0; i < booleans.length; i++) {
        if (booleans[i]) packed |= (1 << i);
    }
    return packed;
}

Inside the GLSL shader, a helper unpacks each bit to decide whether to draw that capsule:

bool getSegmentActive(lowp int segmentData, int index) {
    return (segmentData & (1 << index)) != 0;
}

This keeps all per-digit branching on the CPU side where it is cheap, and lets the shader loop over a fixed array of 7 segment endpoints.

Digits — Capsule SDFs

Each digit is a classic seven-segment display reproduced with seven capsule SDFs. A capsule is the union of a line segment and a uniform radius swept around it; its 3D SDF is:

float capsuleSDF(vec3 p, vec3 a, vec3 b, float r)
{
    vec3 pa = p - a, ba = b - a;
    float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
    return length(pa - ba * h) - r;
}

The segment endpoints a and b are looked up from a constant array of 14 vec2 positions (7 segments × 2 endpoints) and promoted to vec3 with z = 0. By looping over all seven and taking the min of the active ones, each digit is fully defined analytically.

Reference

Both primitives, along with many others, are catalogued in Inigo Quilez's indispensable reference 3D Signed Distance Functions. The project scaffold and shader pipeline are based on Andrew Colligan's SDF template.

Smooth Union and Organic Blending

Standard union — min(d1, d2) — produces a hard crease wherever two shapes meet. A small change replaces it with a smooth minimum based on a cubic polynomial:

float smin(float a, float b, float k)
{
    k *= 6.0;
    float h = max(k - abs(a - b), 0.0) / k;
    return min(a, b) - h*h*h * k * (1.0/6.0);
}

When the two distances are far apart relative to k the result collapses to a plain min. As the shapes draw close, the cubic term lifts the blend smoothly — no discontinuity in value or gradient. The parameter k controls the blend radius: with k near zero you get the crisp hard edge; as k grows the shapes start to melt into each other, giving the clock its organic, slightly liquid feel as the cube approaches the digits.

Mouse-Interactive Cube

A box SDF tracks the mouse cursor and rotates continuously:

float boxSDF(vec3 p, vec3 b)
{
    vec3 q = abs(p) - b;
    return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}

Before evaluating the SDF the sample point is rotated by the elapsed time around the (1,1,1) axis using a full 4×4 rotation matrix, so the cube spins in place while staying centred on the cursor. smin then blends the cube's distance with the clock's distance — so as you move the cube over a digit the two shapes melt into each other in real time. The clock keeps ticking underneath; the cube is purely cosmetic but makes the interaction feel tactile.