Overview
My GitHub profile README opens with the word AVI extruded into a 3D slab, rasterized to ASCII characters, wiping in left to right and then rocking gently on its vertical axis so you can see it has depth. It sits beside a self-typing ASCII portrait, with a live contribution graph underneath.
There is no JavaScript involved, because there cannot be. This post is about the constraint that forces, the rendering pipeline that satisfies it, and the three bugs that ate most of the build time — all of which were geometry problems wearing a rendering-bug costume.
The whole thing is one 38 KB SVG. A Python script
draws the word with a bold TTF, extrudes the glyph mask into a 3D
surface, renders twenty rotated frames to a character grid, and emits
them as an ASCII flipbook driven by SMIL. Source:
scripts/make_wordmark_svg.py.
The constraint that shapes everything
A GitHub README renders Markdown, and images inside it go through
GitHub's image proxy and land in an <img> tag. An SVG
loaded via <img> runs in
restricted mode: no scripts, no external resources, no
interaction. But it does run
SMIL
— the declarative animation elements baked into the SVG spec.
So every animation option collapses to one: whatever moves has to be described declaratively, ahead of time. You cannot rotate a 3D object per frame. You can only pre-render every frame you will ever show and cross-fade between them. The wordmark is a flipbook.
That sounds expensive and mostly isn't, because ASCII compresses beautifully. Twenty frames of a 50×25 character grid is about 25,000 characters of text before gzip. The whole animated file is smaller than a small PNG.
The pipeline
Five stages, all in one Python script:
- Draw the word with a bold TTF and threshold it to a binary mask.
- Extrude that mask into a surface voxel shell — front cap, back cap, and side walls — each point carrying a surface normal.
- For each frame, rotate the shell and perspective-project it.
- Splat the projected points into a character grid with a z-buffer, picking each character by Lambert shading of its normal.
- Emit the frames as SVG text rows, cycled by a SMIL animation.
Stage 1 — The glyph mask
Pillow renders the text; anything above 50% coverage becomes
True. The one non-obvious part is drawing
glyph by glyph rather than as a string, so letter
spacing can be widened. That matters more than it sounds — see the first
bug below.
Stage 2 — Extruding a surface shell
A solid extrusion would be wasteful: you only ever see the surface. So the script emits three sets of points, each with an outward normal.
The caps are every filled pixel of the mask, placed at
z = 0 and z = depth, with normals pointing
straight at and away from the camera. The side walls
are the boundary pixels — filled pixels with at least one empty
4-neighbour — repeated at intervals through the depth, with a normal
derived from which neighbours are empty:
A pixel on a letter's left edge gets a normal of
(-1, 0, 0), one on the top gets (0, -1, 0),
and a corner gets the normalized diagonal. That is the entire geometry
step. No mesh library, no triangles — just points and normals, which is
all a point-splatting rasterizer needs.
Stage 3 — Rotate and project
Standard stuff: a Y rotation for the turn, a small X tilt so the top faces stay visible, a perspective divide, and a back-face cull that keeps only normals pointing toward the camera.
Stage 4 — Splatting to characters
Each surviving point gets a screen cell and a brightness. Brightness is plain Lambert shading against a fixed light, plus depth fog so the far end of the word dims. The brightness indexes into a density ramp:
The z-buffer is one line, because painter's algorithm is free when you can sort: order the points far to near and let NumPy's fancy assignment overwrite. The nearest point wins by construction.
Light placement is a real design decision, not a detail.
My first attempt lit the word from the upper left, which is the
conventional choice and completely wrong here. Side lighting makes the
extruded walls brighter than the letter faces, so the glyphs
dissolve into a mess of edge highlights. Keying the light close to the
view axis — (-0.15, -0.45, -1.0) — keeps faces solid and
dense while the walls fall away to a dimmer character. That contrast
gap is the 3D read.
Stage 5 — The SMIL flipbook
Each frame becomes a <g> of <text>
rows, hidden by default. One discrete opacity animation per group gives
each frame its own slice of the loop:
calcMode="discrete" is what makes this work — it snaps
between values instead of interpolating, so you get a hard cut rather
than a cross-fade. Each frame's keyTimes window is
[i/n, (i+1)/n], and every group shares the same
dur, so they tile the loop exactly with no gaps.
The intro wipe is separate and simpler: an animated
clipPath rectangle growing from zero width, with a
translucent bar riding its edge. It freezes at full width, hides itself,
and hands over to the flipbook.
Three bugs that were actually geometry
The pipeline above came together in an afternoon. These three took longer, and each one looked like a rendering fault while being nothing of the sort.
1. The word rendered as one solid slab
The first version spelled out AVI VASHISHTA across the full width. It came out as an unbroken brick with faint notches.
The arithmetic: thirteen characters across an 860px README column, at a readable ASCII cell size, is about 12 grid columns per letter. The gaps between letters — and the counters inside them — worked out to one or two columns. But rotating the slab offsets the side walls sideways by roughly four columns. The walls were wider than the gaps they were supposed to sit next to, so they filled every hole in the word.
Three fixes, in order of how much they helped. Drop the surname, so three letters get the whole width and ~25 columns each. Add letter tracking, which grows the gaps faster than it grows the strokes. Reduce the extrusion depth. The lesson generalizes to any ASCII art: your effective resolution is grid cells, not pixels, and every feature you want to survive needs to be at least a couple of cells wide.
2. Streaks across the letter faces
At larger sizes the letter faces came out mottled — wall-shaded pixels scattered through what should have been flat fields.
Classic z-fighting. The front cap sits at z = 0, and the
side walls start at z = 0. Points from both landed
in the same cell at exactly the same depth, and which one won came down
to array ordering. The fix is one number:
Push the cap 0.6 mask-pixels proud of where the walls begin and it wins every tie. The walls still show past the silhouette, where they belong, because the yaw shifts them clear of the cap's footprint. Correct occlusion, one constant.
3. The bottom row frayed at the ends of the swing
This one was reported by a screenshot: at one point in the animation the last letter looked truncated and the bottom row trailed off into two stray characters. It only happened at the extremes of the rock, which is exactly the sort of intermittent thing that reads as a rendering fault.
Two independent causes, stacked:
- The camera was too close. 3.4 units away from a word 1.0 unit wide. At the end of the swing the far letter was 0.7 units further from the lens than the near one, so it projected 20% smaller. The letter wasn't clipped — it was genuinely shorter than its neighbours.
- The tilt slanted the baseline. An X tilt rotates points around the screen-horizontal axis, so once the word has yawed, its near end sits lower on screen than its far end. At 11° of tilt that's nearly two rows of drift across the word — enough that the final row caught only the lowest corner and rendered as a two-character sliver.
Both are corrected by backing off. Camera to 6.0 units with a longer focal length, which flattens the projection toward isometric and keeps all three letters the same size. Tilt down to 4° and the swing down to ±11°, which keeps the baseline near level. Nothing was lost: the extruded side walls were always the thing selling the depth, and the aggressive perspective had only been contributing artifacts.
The pattern in all three: nothing was wrong with the renderer. The geometry was faithfully reproducing a scene whose parameters were bad for the medium. When ASCII output looks broken, check the scene before you check the rasterizer — a character grid is an extremely low-resolution display, and it punishes settings that would be invisible at pixel resolution.
Picking the typeface
I started with Impact, on the reasonable-sounding theory that a heavier face gives the shading ramp more area to work with. It looked wrong and I couldn't immediately say why.
The reason is that Impact's strokes are enormous relative to its counters. At ASCII resolution those counters shrink below one grid cell and vanish, so the A becomes a solid triangle with a notch and the whole word reads as blobs. What you actually want is even stroke weight — consistent shading across every part of a letter — and open counters that survive downsampling.
Futura Bold turned out to be the pick. Even strokes, geometric construction, and a pointed apex on the A that gives the extrusion a real edge to wrap around — where Impact's flat-topped A gives it nothing. Worth noting that most sans faces make the I a plain rectangle; if that bothers you, a slab serif like Rockwell or a monospace like Space Mono gives it actual letterform.
Fitting it to the page
Two details that make the finished thing look deliberate rather than approximate.
Render at display size. The panel is 486px wide natively and shown at 490px in the README, so characters render essentially 1:1 at 9px. Rendering large and letting the browser scale down softens the glyphs into mush — the entire point of ASCII art is that you can see the characters.
Let the panel derive its own height, then pad it. The
script computes the row count from the union bounding box of every frame,
so the terminal window hugs the art with no dead space. A
ROW_MARGIN setting then pads it back out — five blank rows
put the wordmark at 387px tall, within 5px of the ASCII portrait beside
it. Two terminal windows at matching heights read as a pair; two at
different heights read as an accident.
Recap
-
GitHub runs SMIL inside
<img>but never JavaScript, so anything that moves must be pre-rendered and declared. For 3D, that means a flipbook. - Extrude a glyph mask into a surface point cloud with normals, project it, and splat it into a character grid with a z-buffer. Roughly 200 lines of NumPy — no mesh library needed.
-
calcMode="discrete"opacity animations, one per frame, tile the loop with hard cuts. - Key the light near the view axis. Side lighting makes the extrusion out-shine the faces and destroys legibility.
- Think in grid cells, not pixels. Counters, gaps and letter spacing all need to survive at a couple of cells wide, which limits how many characters you can fit before the word turns into a slab.
- Bias the front cap in front of where the side walls start, or the two z-fight and streak the faces.
- Pull the camera back. Strong perspective on a wide wordmark shrinks the far letter and frays the bottom row; the extrusion carries the depth on its own.
The generator lives in
the profile repo
as scripts/make_wordmark_svg.py. Text, font, tilt, frame
count, extrusion depth, panel size and yaw range are all flags or
environment variables, and it ships three motion modes —
rock (the gentle oscillation), once (a single
full turn that then freezes), and spin (a continuous
turntable).
If you want the rest of the README — the self-typing ASCII portrait and the live contribution heatmap — that's covered in the earlier post on building the profile.