Skip to content

fix: stop ASCII Doughnut tiling one torus five times across the wall (#182) - #236

Merged
BernardJen merged 1 commit into
mainfrom
fix/ascii-doughnut-tiling
Aug 18, 2026
Merged

fix: stop ASCII Doughnut tiling one torus five times across the wall (#182)#236
BernardJen merged 1 commit into
mainfrom
fix/ascii-doughnut-tiling

Conversation

@BernardJen

Copy link
Copy Markdown
Contributor

Closes #182.

The bug

The shader folded x with mod() into a 3.4-unit cell whenever the canvas was wider
than that, so a 5:1 wall got the same doughnut stamped five times at identical phase
— five copies tumbling in lockstep. That reads as a rendering fault rather than a design,
which is why this carried the bug label.

The fold is gone. The width is divided into slots and each slot gets its own solid:
shape, proportions, spin phase, scale, vertical offset and depth, all from a hash of the
slot index. Cost is unchanged — still one distance function per march step — because a
fragment only ever belongs to one slot. What changes is that no two slots look alike or
move together.

Slot width follows the solid, not the reverse

Worth recording because my first attempt got it backwards. I picked slot count first and
fitted solids into them, which produced five small solids adrift in a mostly black
frame — trading this issue's tiling complaint for its emptiness complaint.

A solid's half-extent is ~1.34 world units against a vertical half-span of 1.55, so
height caps its size: the frame is 1200px tall however wide the wall is. Sizing the
solid to the height first and then laying out as many slots as fit gives about nine
across the wall, nearly touching.

The rest of the issue

  • Cells 26px → 48px (~53px at 6000×1200): about 113 columns × 22 rows, close to
    donut.c's own 80×22. The characters are readable across a room — the entire point of
    ASCII art, and measured as specks before.
  • Perceptually even ramp with dithering. The donut.c ramp is ordered by eye, not by
    area, so a linear index terraced the shading into bands. Each glyph's ink coverage is
    now measured from the atlas the shader samples, and luminance is matched against
    real coverage; a 4×4 ordered dither picks between the two bracketing glyphs per cell.
  • Lighting: key + fill + specular, plus ambient occlusion from the distance
    function
    — which is what finally darkens the inside of the hole, where a plain Lambert
    reports full light.
  • Normals by central differences rather than the analytic torus gradient, so shading is
    correct for every shape.
  • Colour carries depth: near solids warm amber, far ones cooler green, instead of one
    flat red.
  • Brightness raised for ambient light.

A wrong diagnosis, recorded rather than buried

The rightmost solid looked clipped in a downscaled crop and I attributed it to an
off-by-one in the slot index. Measuring ink in the last 40px was identical with and
without the clamp
(0.01% of pixels, peak 0.298) — there was no clipping and the
diagnosis was wrong. The clamp stays as a one-instruction guard, and its comment says
plainly that it is defensive rather than a fix.

Acceptance criteria

  • No mod()-tiled repetition; the wide canvas is filled by a composition
  • Characters individually legible at wall viewing distance
  • Bright enough to read at 12% washout — peak luminance 0.910 vs 0.510 before
  • Ramp perceptually even, with dithering between steps
  • Improved lighting (specular, second light, occlusion in the hole)
  • Colour carries depth information
  • Variety across activations — two seeds shown below differ in palette, shapes and layout
  • Frame cost at 6000×1200 recorded: 120.2 fps, vsync-capped, vs main's 120.0
  • Before/after at 3000×600

Verification

  • Branched from main (76b8259).
  • npm run lint clean.
  • npm test — 407 passed.
  • npm run shadercheckok=150 fail=0; edge density 0.0095 against a 0.0124 baseline,
    a 23% drop inside the 35% tolerance, so no baseline change and the new staleness
    report stays empty.
  • Rendered at 3000×600 on seeds 555 and 909, at 6000×1200, and at 12% washout.

Not verified / honest limits

  • The dark end of the ramp still washes out. . and , are inherently dim and sparse;
    no amount of gain fixes that without destroying the shading the ramp exists to express.
    The bright end carries legibility, which is what the 0.910 peak measures.
  • There is still substantial black between solids. Nine round objects in a 5:1 frame cannot
    fill it without either stretching them or shrinking the glyphs, both of which the issue
    argues against.
  • Only two of the shape variants (torus, rounded box) appear in the captures; the
    octahedron branch is reachable by hash but was not observed in these two seeds.

…182)

THE BUG

The shader folded x with mod() into a 3.4-unit cell whenever the canvas was wider
than that, so a 5:1 wall got the same doughnut stamped five times at identical
phase -- five copies tumbling in lockstep. It read as a rendering fault, not a
design, which is why this carried the bug label.

The fold is gone. The width is divided into slots and each slot gets its OWN
solid: shape, proportions, spin phase, scale, vertical offset and depth, all from
a hash of the slot index. Cost is unchanged -- still one distance function per
march step -- because a fragment only ever belongs to one slot. What changes is
that no two slots look alike or move together.

SLOT WIDTH FOLLOWS THE SOLID, NOT THE REVERSE

The first attempt picked slots first and fitted solids into them, which produced
five small solids adrift in a mostly black frame -- trading the tiling complaint
for the emptiness complaint in the same issue. A solid's half-extent is ~1.34
world units against a vertical half-span of 1.55, so HEIGHT caps its size: the
frame is 1200px tall however wide the wall is. Sizing the solid to the height
first and then laying out as many slots as fit gives about nine across the wall,
nearly touching.

THE REST OF THE ISSUE

- Cells 26px -> 48px, so ~53px at 6000x1200: about 113 columns by 22 rows, close
  to donut.c's own 80x22. The characters are readable across a room, which is the
  entire point of ASCII art and was measured as specks before.
- Perceptually even ramp with dithering. The donut.c ramp is ordered by eye, not
  by area, so a linear index terraced the shading into bands. Each glyph's ink
  coverage is now MEASURED from the atlas the shader samples, and luminance is
  matched against real coverage; a 4x4 ordered dither picks between the two
  bracketing glyphs per cell, turning the residual step into noise the eye
  integrates.
- Key plus fill plus specular, and ambient occlusion from the distance function,
  which is what finally darkens the inside of the hole -- a plain Lambert reports
  full light there.
- Normals by central differences rather than the analytic torus gradient, so the
  shading is correct for every shape.
- Colour carries depth: near solids warm amber, far ones cooler green, instead of
  one flat red.
- Brightness raised for ambient light. Peak luminance under 12% washout measured
  0.910 against 0.510 before.

A WRONG DIAGNOSIS, RECORDED

The rightmost solid looked clipped in a downscaled crop and I attributed it to an
off-by-one in the slot index. Measuring ink in the last 40px was identical with
and without the clamp (0.01% of pixels, peak 0.298), so there was no clipping and
the diagnosis was wrong. The clamp is kept as a one-instruction guard, with the
comment saying plainly that it is defensive rather than a fix.

Frame cost at 6000x1200: 120.2 fps, vsync-capped, against main's 120.0 -- the
extra shading and the AO taps cost nothing measurable. shadercheck ok=150 fail=0;
edge density 0.0095 against a 0.0124 baseline, a 23% drop inside the 35%
tolerance, so no baseline change.
@BernardJen
BernardJen merged commit 697c54f into main Aug 18, 2026
1 check passed
@BernardJen
BernardJen deleted the fix/ascii-doughnut-tiling branch August 18, 2026 09:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quality: ASCII Doughnut screensaver — stop tiling the same torus five times

1 participant