Programming - magic of new world
⌄Six face sweeps
The cube is not a triangle mesh. Each of its 6 faces is swept directly as a dense point cloud — a 99×99 grid of interior samples per face.
A face is defined by its outward unit normal (sx, sy, sz) — one coordinate is ±1, the rest 0. The same normal pins the face in space: the matching coordinate is fixed at ±0.5 while the other two sweep:
faces: (±1,0,0) (0,±1,0) (0,0,±1) // e.g. the sx=+1 face, u,v ∈ [1..99]: ox = +0.5 // pinned by the normal oy = u/100 - 0.5 // ∈ (-0.5, +0.5) oz = v/100 - 0.5
The cube is centred at the origin with side length 1, so every sample lands in [−0.5, +0.5]³ and carries its face's normal along for lighting (panel 05).
Surfaces of revolution
The coffee cup is built from parametric surfaces, not a mesh. Each is swept as a dense point cloud — every sample carries a position and an outward normal.
Two coaxial cylinders form the hollow wall (angle θ, height y) — note the opposite normals:
// outer wall -- normal points OUT P = (Ro·cosθ, y, Ro·sinθ), N = ( cosθ, 0, sinθ) // inner wall -- normal points IN P = (Ri·cosθ, y, Ri·sinθ), N = (-cosθ, 0, -sinθ)
A closed disk seals the base (normal down) so it is a cup, not a tube; an interior floor + top rim finish the bowl:
bottom: P=(r·cosθ, hb, r·sinθ), N=(0,-1,0) floor : P=(r·cosθ, hb+t, r·sinθ), N=(0,+1,0)
The handle is half a torus on the +x side (sweep φ ∈ [−90°,90°]) — see panel 05.
var FACES = [[-1,0,0],[1,0,0],[0,-1,0],
[0,1,0],[0,0,-1],[0,0,1]];
for (var f=0; f<6; f++) {
var sx=FACES[f][0], sy=FACES[f][1], sz=FACES[f][2];
for (var u=1; u<=99; u++) {
for (var v=1; v<=99; v++) {
// pinned coord = ±0.5, the other two sweep u/v
var ox = sx ? sx*0.5 : u/100-0.5, …
// → rotation & transform: panels 02–03
}
}
}// surfaces of revolution → point cloud (pos + normal)
for (th=0; th<TAU; th+=dth) {
c=cos(th); s=sin(th);
for (y=hb; y<=ht; y+=dy) {
add(Ro*c, y, Ro*s, c,0, s); // outer wall, N out
add(Ri*c, y, Ri*s, -c,0,-s); // inner wall, N in
}
}
add(r*c, hb, r*s, 0,-1,0); // closed bottom
// + interior floor, top rim, half-torus handle
// → rotation & transform: panels 02–03Elementary 3D rotations
Any 3D rotation is a linear map — a 3×3 orthogonal matrix that preserves lengths and angles.
Rx(a) — around X axis
┌ 1 0 0 ┐ │ 0 cos(a) -sin(a) │ └ 0 sin(a) cos(a) ┘
Ry(a) — around Y axis
┌ cos(a) 0 sin(a) ┐ │ 0 1 0 │ └ -sin(a) 0 cos(a) ┘
Rz(a) — around Z axis
┌ cos(a) -sin(a) 0 ┐ │ sin(a) cos(a) 0 │ └ 0 0 1 ┘
The cube applies Ry(θ)·Rz(45°)·Rx(45°) — matrices act right-to-left: tilt 45° about X, tilt 45° about Z, then the continuous Y-spin.The cup applies Ry(θ)·Rx(−45°) — no Rz, so its axis stays a fixed 45° from vertical while it spins (adding Rz would tip it to 60° and make it precess). 45° bakes in as √2/2 ≈ 0.7071.
var ct=Math.cos(A), st=Math.sin(A); // pre-compute each frame for (var f=0; f<6; f++) { // 6 faces for (var u=1; u<=99; u++) { for (var v=1; v<=99; v++) { … ox/oy/oz from the face sweep … // → combined transform: panel 03 } } }
var ct=Math.cos(A), st=Math.sin(A); // pre-compute each frame for (var s=0; s<n; s++) { // every cup sample … ox/oy/oz, nx/ny/nz from the point cloud … // → combined transform: panel 03 }
Step 1 — P = Rz(45°)·Rx(45°)
Multiply the two fixed tilts first. Row i of P is (row i of Rz) dotted with each column of Rx. With c = cos45° = sin45° = 1/√2:
┌ c -c 0 ┐ ┌ 1 0 0 ┐
│ c c 0 │ · │ 0 c -c │
└ 0 0 1 ┘ └ 0 c c ┘
row1 = [c·1+0+0, 0-c·c+0, 0+c·c+0]
= [ 1/√2 -1/2 1/2 ]
row2 = [c·1+0+0, 0+c·c+0, 0-c·c+0]
= [ 1/√2 1/2 -1/2 ]
row3 = [ 0+0+0, 0+c+0, 0+c+0 ]
= [ 0 1/√2 1/√2 ]
Step 2 — M = Ry(θ)·P
Now the spin: each row of Ry dotted with the columns of P (ct = cosθ, st = sinθ):
┌ ct 0 st ┐ ┌ 1/√2 -1/2 1/2 ┐
│ 0 1 0 │ · │ 1/√2 1/2 -1/2 │
└ -st 0 ct ┘ └ 0 1/√2 1/√2 ┘
row1 = [ct/√2+0, -ct/2+st/√2, ct/2+st/√2]
= [ ct/√2 (√2·st-ct)/2 (ct+√2·st)/2 ]
row2 = P row2 (Ry row 2 is [0 1 0])
= [ 1/√2 1/2 -1/2 ]
row3 = [-st/√2+0, st/2+ct/√2, -st/2+ct/√2]
= [ -st/√2 (st+√2·ct)/2 (√2·ct-st)/2 ]
Row 2 contains no θ — the spin axis is Y, so a point's height never changes as the cube turns.
Step 3 — M · (ox,oy,oz), row by row
Each output is one row dotted with the point. Factoring out /2 and writing √2 as 1.414 gives exactly the code:
x0 = row1·v = (1.414·st·(oy+oz) + ct·(-oy+oz+1.414·ox)) / 2 y0 = row2·v = (1.414·ox + oy - oz) / 2 z0 = row3·v = (1.414·ct·(oy+oz) - st·(-oy+oz+1.414·ox)) / 2 + 1.5 ← camera offset, z only
Face normals (sx,sy,sz) ride the same rows with no +1.5 — directions rotate, never translate. Only rows 2–3 are evaluated for them (→ panel 05).
Step 1 — M = Ry(θ)·Rx(−45°)
One fixed tilt, so a single multiply builds M. Row i of Ry is dotted with each column of Rx(−45°). With ct = cosθ, st = sinθ and cos(−45°) = 1/√2, sin(−45°) = −1/√2:
┌ ct 0 st ┐ ┌ 1 0 0 ┐
│ 0 1 0 │ · │ 0 1/√2 1/√2 │
└ -st 0 ct ┘ └ 0 -1/√2 1/√2 ┘
row1 = [ct·1+0, 0-st/√2, 0+st/√2]
= [ ct -st/√2 st/√2 ]
row2 = [ 0 1/√2 1/√2 ] (copies Rx row 2)
row3 = [-st·1+0, 0-ct/√2, 0+ct/√2]
= [ -st -ct/√2 ct/√2 ]
No Rz — the cup's axis stays a fixed 45° from vertical while it spins; row 2 has no θ, so heights never wobble.
Step 2 — M · (ox,oy,oz), row by row
Each output is one row dotted with the point (0.7071 ≈ 1/√2):
x0 = row1·v = ox·ct + 0.7071·(oz-oy)·st y0 = row2·v = 0.7071·(oy+oz) z0 = row3·v = -ox·st + 0.7071·(oz-oy)·ct + 1.5 ← camera offset, z only
Step 3 — the normal rides rows 2–3
Directions rotate but never translate (no +1.5), and the light has no x-component, so only two rows are ever computed:
ny1 = row2·N = 0.7071·(ny+nz) nz1 = row3·N = -nx·st + 0.7071·(nz-ny)·ct
var ct=Math.cos(A), st=Math.sin(A); for (each face f) { for (each u,v) { … ox/oy/oz from the face sweep … var x0=(1.414*st*(oy+oz)+ct*(-oy+oz+1.414*ox))/2, y0=(1.414*ox+oy-oz)/2, z0=(1.414*ct*(oy+oz)-st*(-oy+oz+1.414*ox))/2+1.5, y1=(1.414*sx+sy-sz)/2, // row2·N z1=(1.414*ct*(sy+sz)-st*(-sy+sz+1.414*sx))/2; // row3·N // → projection: panel 04 }}
var ct=Math.cos(A), st=Math.sin(A); for (var s=0; s<n; s++) { // every cup sample … ox/oy/oz, nx/ny/nz from the point cloud … var x0 = ox*ct + 0.7071*(oz-oy)*st, y0 = 0.7071*(oy+oz), z0 = -ox*st + 0.7071*(oz-oy)*ct + 1.5, ny1 = 0.7071*(ny+nz), // row2·N nz1 = -nx*st + 0.7071*(nz-ny)*ct; // row3·N // → projection: panel 04 }
Orthographic screen map
After rotation, (x0, y0, z0) lands on the character grid by plain scaling — no divide by z. The hero solid is drawn orthographically, so depth never shrinks it:
px = centerCol + scaleX · x0 py = centerRow + scaleY · y0 // +y0 draws downward
px = centerCol + scaleX · x0 py = centerRow - scaleY · y0 // minus: +y0 is up on screen
(The navbar's mini solid does divide by z — 30·x0/z0 — a true pinhole projection in miniature.)
Depth buffer — z's only job
Multiple surface points can hit the same cell. The z-buffer keeps only the closest (largest D = 1/z0); the +1.5 camera offset keeps z0 > 0 so D stays finite and ordered:
D = 1 / z0 // bigger D = closer
if (D > zbuf[o]) {
zbuf[o] = D
buf[o] = char // overwrite what's behind
}
Scale parameters
scaleX ≈ COLS×0.22 fills ~half the viewport. scaleY = scaleX/2 corrects the 2:1 aspect ratio of monospace chars.
… ct/st, face sweep, x0..z1 … var px = 0|(centerCol + scaleX*x0), py = 0|(centerRow + scaleY*y0), // ortho: no ÷z D = 1/z0, o = px + stride*py; if (D > zbuf[o]) { zbuf[o] = D; // → per-face luminance N: panel 05 } }}
… ct/st, sample loop, x0..z0, ny1/nz1 … var px = 0|(centerCol + scaleX*x0), py = 0|(centerRow - scaleY*y0), // minus: +y0 up D = 1/z0, o = px + stride*py; if (D > zbuf[o]) { zbuf[o] = D; // → per-sample luminance N: panel 05 } }
Step 1 — pick the face normal
Each of the six face sweeps carries a fixed (sx, sy, sz) — one of the six unit axis vectors, the outward normal of the face being swept (panel 01).
Consequence: every sample on the same face shares the same normal. The shading we compute below is therefore per-face, not per-pixel — this is exactly why each face appears as one ASCII character at a time.
Step 2 — rotate the normal with M
Apply the rotation rows from Panel 03 to (sx, sy, sz) — same coefficients, but with the +1.5 translate omitted. Translation moves points; it must never move directions. The rotated normal is N̂ = (x1, y1, z1) — and since the light has no x-component, the code never computes x1 at all.
Step 3 — project onto the light
The cube's fixed light direction is L̂ = (0, 2, 1) / √5 — mostly from above, leaning into the screen. Since both N̂ and L̂ are unit vectors, their dot product is exactly the cosine of the angle between them — the textbook Lambertian shading term:
|L| = √(0² + 2² + 1²) = √5 ≈ 2.236
L̂ = (0, 2/√5, 1/√5)
luminance = N̂ · L̂
= x1·0 + y1·(2/√5) + z1·(1/√5)
= (2·y1 + z1) / 2.236 ← matches code
Step 4 — clamp; the z-buffer does the hiding
If luminance ≤ 0 the face is tilted away from the light. The cube does not skip it — the value clamps to 0, and after panel 06's inverted ramp that face fills with the densest glyph @. Hidden surfaces are removed only by the 1/z depth buffer.
Step 1 — the normal varies smoothly
Every cup sample stores its own outward normal. On the walls it is radial — N = (±cosθ, 0, ±sinθ) (outer “+”, inner “−”); on the handle it sweeps the torus — N = (cosψ·cosφ, cosψ·sinφ, sinψ).
Consequence: unlike the cube, the normal changes at every sample — so shading is a continuous gradient, not one flat value per face.
Step 2 — rotate the normal with M
Apply the cup's rotation Ry(θ)·Rx(−45°) from Panel 03 to the surface normal — identical coefficients, +1.5 translate omitted (directions never translate). The rotated normal is N̂ = (x1, y1, z1).
Step 3 — project onto the light, scale by AO
The cup's fixed light is L̂ = (0, 1, −2) / √5 — from above, toward the viewer (the cube uses a different light: (0,2,1)/√5). Each sample also carries a baked ambient-occlusion factor — inner wall 0.30–0.60, floor 0.35, rim 1.25 — darkening the cavity and popping the lip:
luminance = (N̂ · L̂) · AO
= ((ny1 - 2·nz1) / 2.236) · AO
if (luminance > 1) luminance = 1 // rim boost can overshoot
Step 4 — depth buffer resolves the hollow
Outer wall, inner wall and floor can hit the same pixel; the 1/z depth buffer keeps the nearest, so you see into the cup through the rim while the closed bottom blocks any see-through.
… face sweep: y1/z1, px/py/D/o ready … // per-face normal: (sx,sy,sz) constant over each face var lum = (2*y1 + z1) / 2.236; // L̂ = (0,2,1)/√5 if (D > zbuf[o]) { // solid: keep nearest zbuf[o] = D; // → 12-glyph INVERTED ramp: panel 06 } }}
… per-sample pos + normal ready … // normal varies per sample -> smooth gradient var lum = ((ny1 - 2*nz1) / 2.236) * AO[s]; if (lum > 1) lum = 1; // rim boost overshoots if (D > zbuf[o]) { // depth buffer = hollow cup zbuf[o] = D; // → 70-step ramp + round: panel 06 } }
Step 1 — a 12-glyph ink ramp
The cube uses the classic 12-character “donut” ramp, ordered sparsest ink → densest:
RAMP_CUBE = ".,-~:;!=#&%@" // 12 glyphs, sparse → dense
Step 2 — INVERTED index: lit → sparse
The index is flipped — high luminance maps to the start of the ramp. Faces catching the light dissolve into faint dots; faces turning away fill with heavy ink:
last = RAMP_CUBE.length - 1 // 11 i = last - Math.round(max(lum, 0) · last) char = RAMP_CUBE[ min(last, i) ] lum = 1 (lit) → i = 0 → '.' sparsest lum = 0 (away) → i = 11 → '@' densest
Step 3 — flat faces, coarse steps: the style
A cube face is flat — one normal, one luminance, one glyph for the whole face. 12 coarse levels + the inverted map give the dark theme its engraved, retro pulse as faces snap between shades. A clamped lum ≤ 0 face renders solid @ — never a hole — and the z-buffer alone hides back faces.
Step 1 — a 70-glyph ink ramp
Order 70 characters from sparsest ink (dim) to densest (bright). In a monospace font each glyph fills a different fraction of its cell (. ≈ 5% → $ ≈ 80%) and the eye reads ink density as brightness:
RAMP = ".'`^\",:;Il!i><~+-?][}{1)(|\/
tfjrxnuvczXYUJCLQ0OZmwqpdbkhao
*#MW&8%B@$" // one 70-glyph string
Step 2 — direct map, rounded
Scale luminance [0,1] across the ramp and round to the nearest level (not floor), then clamp:
i = Math.round(lum · (RAMP.length - 1)) char = RAMP[ min(RAMP.length - 1, max(0, i)) ]
Rounding removes the half-step bias of a 0 | x floor. Curved normals + the AO factor vary luminance continuously, and 70 levels resolve it into a true gradient around the cup's curve.
Step 3 — keep the solid filled
When luminance ≤ 0 a surface faces away from the light; we draw the dimmest glyph . instead of a hole, and the depth buffer keeps the nearest surface — so the silhouette stays solid.
… face sweep; y1/z1, D, o ready … var lum = (2*y1 + z1)/2.236, last = RAMP_CUBE.length-1; if (D > zbuf[o]) { zbuf[o] = D; var i = last - Math.round((lum > 0 ? lum : 0) * last); buf[o] = RAMP_CUBE.charAt(i > last ? last : i); // inverted } } pretag.textContent = buf.join(''); // render frame to DOM
… sample loop; ny1/nz1, D, o ready … var lum = ((ny1 - 2*nz1)/2.236) * AO[s]; if (lum > 1) lum = 1; if (D > zbuf[o]) { zbuf[o] = D; var i = Math.round((lum > 0 ? lum : 0) * last); buf[o] = RAMP.charAt(i > last ? last : i); // 70 levels } } pretag.textContent = buf.join(''); // render frame to DOM
Get in touch
Quant · Python · Crypto · Open to collab
Built with pure HTML · Canvas · Vanilla JS