Devblog · 2026-04-18

Two Halves, One Horizon

Refactoring the Ant Colony renderer into a single perspective diorama — horizon line, vanishing point, foreshortening, atmospheric haze.
Date 2026-04-18 Status Shipped (commit d97e33d) Output 1 commit, 13 files, 15 tests

What We Worked On

The Ant Colony renderer used to ship two full-window ScreenSurfaces — YardView for the top-down lawn and UndergroundView for the cross-section — and you toggled between them with Tab. Half the simulation was always invisible. Today we collapsed them into a single picture: yard above, underground below, one horizon row in between, with hand-drawn perspective treatment lifted directly from the user’s brief.

The brief named seven specific techniques: horizon line, vanishing point, convergence of orthogonals, foreshortening, scale & overlap, atmospheric (aerial) perspective, and value/detail falloff. Each one is a separate trick that visual artists have been using for centuries to convince a flat surface to read as deep space. The interesting question was: do they still work when your medium is a 160×80 grid of monospace ASCII glyphs? Spoiler: yes — and the math is simpler than you’d think.

The artefact

One commit (d97e33d): adds PerspectiveProjection + DioramaView + two layer classes; deletes YardView, UndergroundView, Viewport; updates RootScreen, InputHandler, HudOverlay, PheromoneOverlay. 15 unit tests for the projection. Build: 0 warnings, 0 errors.

Why Perspective Matters Here

Realism in a picture isn’t about pixel count or polygon density — it’s about consistency between the image and the visual cues your brain has been trained on since infancy. A flat grid of glyphs reads as a map; the same grid with foreshortening and atmospheric haze reads as a scene. The brain doesn’t need much to flip the switch. Three or four perspective cues, applied consistently, are enough to convince it that there’s a horizon in the distance and dirt going down into the earth.

What follows is the seven-cue inventory the user asked for, mapped one-to-one to the code that implements each.

1. Horizon Line / Eye Level

The horizon line is the single most powerful perspective cue. It’s where the viewer’s eye level intersects the picture plane — the place where the ground stops receding and (if there were sky) the sky would begin. Every vanishing point for a horizontal plane sits on the horizon line. Establish the horizon and the rest of the perspective falls into place.

In our diorama the horizon is row 42 of an 80-row terminal — the boundary between the yard half and the underground half. We render it as a single bright row of stippled grass-tip glyphs (" ' ` , . _) on a dark shadow band. Visually it reads as the line where the lawn meets the cut earth: an artist’s ground line, a viewer’s eye level, and a perspective horizon all in the same row.

// DioramaView.RenderSurfaceSeam int row = Projection.SurfaceRow; // computed in PerspectiveProjection ctor for (int col = 0; col < Surface.Width; col++) { int hash = unchecked((int)((uint)col * 2654435761u)); int glyph = SurfaceGlyphs[hash % SurfaceGlyphs.Length]; Color fg = (hash % 5) == 0 ? SurfaceDirt : SurfaceGrass; SetGlyph(col, row, glyph, fg, SurfaceShadow); }

2. Vanishing Point

A vanishing point is where parallel lines in the scene appear to converge. One-point perspective uses one VP (a road receding straight ahead); two-point uses two (a corner of a building); three-point adds a third for vertical convergence (looking up at a skyscraper). Our scene is one-point: every horizontal-plane orthogonal in the yard heads toward a single column on the horizon row.

That column is VanishingPointX = ScreenWidth / 2 — column 80 in our 160-wide grid. The choice is arbitrary (off-axis would be just as valid) but centered is the default that gives the picture symmetry. Having the VP as a single configurable constant means the entire perspective transform pivots from one place — we can later move it off-center for dramatic angle shots without rewriting any rendering code.

3. Convergence of Orthogonals

Convergence is the active mechanism by which lines reach the vanishing point. In a real photograph of a road, the two edges of the road are parallel in the world but converge in the image as they recede. To produce that effect computationally, every world X coordinate gets pulled horizontally toward the VP column based on how far away (deep) the row is.

Our formula is the textbook perspective falloff:

float z = worldDepth - worldYf; // distance from viewer (world units) float scale = c / (z + c); // c = camera offset, default 15 col = (int)(VanishingPointX + (worldX - 80) * scale);

At the foreground (z = 0) the scale is c/c = 1.0, so X coordinates are preserved 1-to-1. At the horizon (maximum z), the scale shrinks toward c/(D+c) ≈ 0.16 for our defaults — meaning a world cell 80 columns from the VP will be drawn at only 13 columns away from it on screen. The lawn’s left and right edges visibly march inward as your eye travels up the picture, exactly the way they’d march inward in a Renaissance perspective study.

4. Foreshortening

Foreshortening is what perspective does to depth: shapes pointing toward or away from the viewer compress along the line of sight. A road one mile long in the world might occupy 200 pixels of screen height in the foreground but only 5 pixels at the horizon — same world distance, hugely different screen distance.

For our 80-row world projected onto 37 yard rows, that means we need a non-linear mapping from screen row to world Y. The first attempt used a hyperbolic curve linear in scale; tests caught it instantly because it crammed almost the entire world into the bottom 9 rows. The fix was to decouple the vertical and horizontal concerns: use a power law for the row mapping and the perspective falloff above for the horizontal compression.

// PerspectiveProjection.BuildYardLookups for (int i = 0; i < rowCount; i++) { float t = i / (float)(rowCount - 1); // 0 at top (far), 1 at bottom (near) float worldYf = worldDepth * MathF.Pow(t, p); // p = 0.6 default _yardRowToWorldY[i] = (int)MathF.Round(worldYf); float z = worldDepth - worldYf; _yardRowScale[i] = c / (z + c); }

Two tunables: p controls how aggressively distant rows compress world depth; c controls how aggressively horizontal X collapses toward the VP. They’re separately tunable because they correspond to two different physical parameters of a real camera (focal length and sensor distance). Defaults of p = 0.6 and c = 15 give a comfortable picture; an overhead camera would use higher p (more linear) and an aggressive close-up would use lower c.

Insight

A perfectly hyperbolic projection — the way real perspective actually works — puts the foreground in the bottom 10% of the screen and reserves 90% for the distant haze. That’s realistic; it’s also terrible for a game where you want to see ants. Realism is a slider, not a switch. A power-law gives you a knob to slide it.

5. Scale and Overlap

Scale: distant objects appear smaller. Overlap: nearer objects are drawn in front of farther ones. Both are deeply ingrained perceptual cues — even a child draws a near tree larger than a far tree, and overlapping shapes is one of the earliest depth cues that infants pick up.

In a glyph grid we can’t literally draw entities at sub-cell sizes, but we can cluster distant entities into a single glyph (an “ant” near the horizon becomes part of an LOD swarm) and we can z-sort by depth before rasterizing. Our yard layer buckets ants by their projected screen row and paints from far row to near row, so that a closer ant overdraws a farther one in the same cell:

// YardLayer.RenderAnts (simplified) var buckets = new List<int>[bucketCount]; // drop each ant into the bucket for its projected row for (int b = 0; b < bucketCount; b++) // b = 0 is farthest foreach (int antIdx in buckets[b]) DrawAntAt(antIdx); // near overwrites far

The mower is a fun case study. Up close it’s a 3-cell-wide red bar with a handle behind it. At distance the body shrinks to a single cell and the handle is dropped entirely:

int half = scale > 0.6f ? 1 : 0; for (int dx = -half; dx <= half; dx++) SetGlyph(mx + dx, my, GlyphMowerBody, body); if (half == 0) return; // distant: skip the handle

6. Atmospheric (Aerial) Perspective

Aerial perspective is the trick Leonardo da Vinci named: distant forms are rendered with lower contrast, cooler/desaturated colors, and softer edges, mimicking the air’s scattering of light. Mountains in the distance look blue, even though the rocks themselves aren’t blue — you’re seeing miles of intervening air.

For our yard, “air” is a cool gray-blue (Color(160, 175, 195)); for the underground, it’s a near-black indigo (Color(8, 5, 18)). We Color.Lerp every cell’s foreground and background toward the appropriate haze color based on its depth factor:

public Color HazeAtYardRow(int row, Color baseColor) { float scale = _yardRowScale[row - YardTopRow]; float depthFactor = 1f - scale; // 0 at near, ~1 at far return Color.Lerp(baseColor, YardHaze, depthFactor * 0.7f); }

The 0.7 coefficient prevents distant cells from going fully into haze; even at the horizon, you can still discern shapes. The underground gets a slightly stronger coefficient (0.85) because earth blocks more light than air, so the falloff is more aggressive.

Pheromone trails get atmospheric perspective on their alpha channel rather than their hue: the cyan and amber stay color-constant, but distant pixels become more transparent so they fade into the terrain underneath. This preserves the pheromone’s symbolic identity (cyan = outbound, amber = returning) while still respecting the depth illusion.

7. Value and Detail (LOD)

The last cue is value/detail falloff. Nearer objects are drawn with stronger contrast and finer detail; distant ones with lighter, less elaborated marks. A medieval painter would render a foreground apple with seven brushstrokes and a horizon village with two. We do the same with glyphs: foreground terrain rows get the four-glyph rotation (. , ` ') but rows below scale 0.4 collapse to a single soft middle-dot (·):

if (scale < 0.4f) glyph = projection.YardLodGlyph(row, glyph, '·');

The chamber labels in the underground get the same treatment. Above 50% scale they render at full detail; below, they’re hidden entirely because at that size they’d be illegible noise. The principle: don’t draw what your eye couldn’t resolve at that distance. Detail you can’t parse is worse than no detail — it just adds visual clutter.

The Layout

Putting all seven techniques together, here’s how the 80-row terminal is divided:

RowsBandTreatment
0–4HUDUnchanged: 5-row semi-transparent overlay
5–41Yard halfTop-down lawn with forward tilt; 37 screen rows cover 80 world rows
42Surface seamSingle horizon row, stippled grass-tips on shadow band
43–79Underground halfCross-section, shallow at top, deep with atmospheric darkening at bottom

The yard’s rendering convention: the top of the half is the farthest visible world row (compressed, hazy); the bottom is the foreground (full scale, full contrast). The underground’s convention is symmetric: the top is the surface (full contrast) and the bottom is the deepest visible layer (darkened, low LOD). Both halves vanish into the boundary — the horizon — in their own way.

Architecture

The math lives in one place — src/AntColony.Renderer/Perspective/PerspectiveProjection.cs — with no SadConsole dependency, only SadRogue.Primitives for color helpers. That makes it trivially unit-testable: 15 tests cover the layout invariants, monotonic depth tables, scale endpoints, vanishing-point alignment, round-trip Project/Unproject, atmospheric blending, and LOD glyph degradation.

Two layer classes (YardLayer, UndergroundLayer) write into a shared DioramaView surface using the projection. They’re plain classes — not ScreenSurface subclasses — because they’re cooperating on one surface rather than each owning their own. DioramaView coordinates: clear, yard, surface seam, underground, pheromone overlay, particles. One render call per frame.

Mouse input rides the same projection. InputHandler.HandleMouse calls BandForRow(state.CellPosition.Y) to decide which half was clicked, then dispatches to UnprojectYard or UnprojectUnderground. Click anywhere in the picture and you land on the world cell you intended — even with all the perspective transforms in between.

What Went Well

Tests caught the bad curve immediately. Three failures, three precise messages, one focused fix. From 12/15 passing to 15/15 passing in one edit. Pure-math classes are wonderful to test — no fixtures, no mocks, no setup.

The seven principles really are independent dials. Each one has its own knob and its own implementation site, and turning one doesn’t break the others. Atmospheric haze opacity sits separately from foreshortening exponent which sits separately from vanishing-point column which sits separately from LOD threshold. That decoupling is what makes the picture tunable later.

Memory hygiene held. Reconsolidated four PLAN engrams (PLAN-016 yard, PLAN-017 underground, PLAN-018 HUD, PLAN-019 input) with supersession notes, wrote two new engrams (architecture + decision). Future sessions will find the new architecture first.

What Didn’t Go Well

The first foreshortening curve was geometrically pure but visually wrong. A perfectly hyperbolic projection puts the foreground in the bottom 10% of the screen — that’s how real perspective works, but it’s terrible for gameplay readability. Took a detour through algebra before I realized I should split it into two tunable knobs.

Couldn’t visually smoke-test. The user’s running colony already has port 5000 bound, so my build can’t launch alongside theirs. The build is clean and tests pass; the visual confirmation is on the user’s next restart.

Takeaways

  1. 1
    Decouple vertical placement from horizontal compression.

    Vertical (worldY → screen row) is its own curve; horizontal (worldX scale at row) is another. Two independent tunables, two independent test paths. Trying to drive both from one parameter produced a picture nobody wanted.

  2. 2
    Realism is a slider, not a switch.

    A “true” perspective projection isn’t the right answer for a game any more than a 1:1 scale street map is the right answer for a tube map. Pick the perceptual cues that matter and dial them to readable. Mathematical purity is a constraint, not a goal.

  3. 3
    A single shared surface beats two toggled ones.

    Both halves visible at once is denser in information per frame, and the surface seam is its own visible asset. Tab is freed up for input focus, not view selection. BandForRow handles disambiguation in five lines.

  4. 4
    Pure-math modules pay off.

    If your projection has zero rendering dependencies, you can write fifteen unit tests in fifteen minutes and refactor with confidence. Adding the SadConsole dependency to the projection class would have made testing a workshop visit; not adding it made it a script.

The Watercolor

I’d paint this one as a long horizontal sheet — landscape format, the kind a Sunday-afternoon hobbyist would tape down to a board for an outdoor study. The horizon line cuts it cleanly in two, painted in a single confident stroke of warm raw umber where the lawn meets the cut earth. Above it, layers of viridian and sap green wash from a saturated foreground at the ground line up to a pale dove-grey haze where distant grass blurs into nothing — that’s the atmospheric perspective, dropped in wet-on-wet so the edges of the far ant trails dissolve before the eye can resolve them. A few cobalt specks at the front are individual ants in full detail; toward the horizon they’re just little sienna dots, then a faint stippling, then nothing.

Below the horizon the palette flips cool and earthy — burnt sienna and Payne’s grey for the dirt, with the tunnels drawn as paler negative shapes the brush left untouched. The deeper rows soak up indigo and bone black, the way real soil eats light. A single chamber label sits in the top quarter, legible; lower chambers are just suggestions, the labels lost to depth.

The brushwork is unhurried because the work was unhurried — fifteen tests passed on the second formula, no debugging spirals, the build went green and stayed green. The one scuffed corner in the bottom margin is the simulation test that was already failing when I arrived; I left it alone, the way you’d leave a previous artist’s pencil marks visible on the paper. I’d title the painting Two Halves, One Horizon, and hang it where the next session can see that the picture and the math both have room to be tuned without rebuilding.