The Pinwheel at the Pole

Published:

← Back to Blog


This is the third stop on our tour of curved-surface rendering artifacts, after the missing depth buffer and the wrap seam. With those two fixed, the sphere mode had one glitch left, and it lived at the poles: a pinwheel of compressed color bands converging on a single point, shimmering with moiré as the camera moved. Unlike the depth bug (a pipeline mistake) and the seam (a mathematical inevitability we softened), the pinch is a projection choice — and cartographers worked out the better choice more than four centuries ago.

BEFORE
Sphere with color bands pinching into a pinwheel at the pole
AFTER
Sphere with smooth, pinch-free texture at the pole
The same frame (0:53) before and after the fix — the simulation content is identical; only the mapping changed. Left: every vertical color band crowds into the top pole, folding into a feathered pinwheel with a hard part-line. Right: the bands taper and flow smoothly through the pole; it's just a point on a continuous surface.

The Symptom

ShoggothOx wraps its live 160×100 simulation buffer around the sphere with plain latitude–longitude UV mapping: u is longitude, v is latitude. Every horizontal row of the texture becomes a latitude ring around the sphere (a horizontal circle parallel to the equator, not a pole-to-pole band) — the equator gets a big ring, and rings near the poles get smaller and smaller until, at the pole itself, an entire row of 160 texels is crushed into a single point.

The texture content doesn't know that. Its columns keep varying all the way up, so near the pole you get 160 columns' worth of horizontal detail packed into a circle a few pixels across: a starburst of radial spokes, aliasing into moiré when it moves. Map readers know this defect from the other direction — on an equirectangular map of Earth, Antarctica smears into a continent-wide ribbon along the bottom edge. We're doing the inverse: instead of stretching the globe onto a rectangle, we're squeezing a rectangle onto a globe, so the smear becomes a pinch.

Quantifying the Squeeze

Let's put numbers on it. At colatitude φ (angle from the pole), a latitude ring has circumference 2πr sin φ, but the old mapping assigns every ring the same span of texture columns. The horizontal texel density on the surface therefore grows like 1 / sin φ — and diverges at the poles. No amount of mesh tessellation or filtering fixes that; the mapping itself demands infinite spatial sampling frequency at two points.

Latitude Ring Circumference & Texel Density Divergence
1. Sphere Geometry: Ring Circumference C(φ) Pole (φ = 0) C = 2πr (Equator) C = 2πr sin φ φ 2. Texel Density Divergence ρ(φ) Horizontal Density ρ(φ) ∝ 1 / sin φ ρ → ∞ Texel Density ρ φ 0° (Pole) 45° 90° (Equator) Divergence at Pole! Infinite spatial sampling frequency

The Fix: a Sinusoidal Projection

The cure is to stop giving every ring the full texture width. Scale the u span of each ring by the ring's own radius factor, sin φ, and the divergence cancels exactly: texel density becomes constant per unit of arc, at every latitude. Cartographers call this the sinusoidal projection (also Sanson–Flamsteed, in use since the 1570s).

In cartography, a sinusoidal map draws the 3D globe onto a 2D page within a lens-shaped outline. In 3D GPU rendering, we apply this mapping in reverse: the 3D sphere mesh queries UV coordinates from our flat 2D rectangular simulation buffer. Because each latitude ring's UV span scales with sin φ, the sphere surface queries a lens-shaped footprint out of the 2D rectangular buffer.

Crucially, 100% of the 3D sphere is fully textured — no physical gaps exist on the sphere. Because a high-latitude ring has a smaller physical circumference in 3D space, sampling a shorter UV interval [1 − sin φ, 1 + sin φ] still covers the full 360° circumference of that ring at the exact same spatial texel density as the equator. The 4 outer corner regions of the 2D rectangular simulation buffer simply remain unread by the 3D sphere mesh.

It is an equal-area projection: the Jacobian determinant of our texture-to-sphere map works out to a constant, so a blob in the simulation buffer covers the same amount of sphere surface whether it lands at the equator or near a pole.

before: every ring, full width pole ring (tiny) ← whole row 160 columns → 1 point = pinch after: width ∝ ring radius 100% of 360° ring covered (uniform density) buffer corner (unused) the 2D texture buffer [0,2]; horizontal lines are latitude rings
What each latitude ring samples from the (mirrored) 2D texture buffer. Left: Equirectangular mapping forces every ring to stretch across 100% of the buffer width, over-sampling high-latitude rings. Right: Each ring queries a UV span proportional to sin φ, which covers 100% of that ring's 360° sphere circumference at uniform texel density. The 3D sphere surface is completely textured; only the 4 outer corner regions of the 2D rectangular texture buffer remain unread.

One Line, Two Constraints

The implementation is a one-line change per mesh (our generating functions for our 3d surfaces: spheres, cones, etc.), but the line has to respect the seam fix we already shipped. Closed directions — periodic mesh axes where traveling continuously loops back onto itself, such as traveling 360° around a longitude circle — traverse the mirrored coordinate span u ∈ [0, 2]. The seam wrap is invisible because MirrorRepeat renders the texture symmetric about the central mirror axis u = 1. So when we shrink a latitude ring's UV span near the poles, we must shrink it symmetrically toward the mirror center:

Closed Direction Traversal & Mirror Seam on 3D Sphere
North Pole (φ = 0°) South Pole (φ = 180°) Seam Meridian (u = 0.0 ≡ 2.0) Mirror Fold Meridian (u = 1.0) Front Loop: u ∈ [0, 1] (Forward) Back Loop: u ∈ [1, 2] (Mirrored) Traveling 360° around a latitude ring continuously loops u ∈ [0, 2]
Symmetric UV Shrinking Centered at Mirror Axis (u = 1.0)
Mirrored Texture Buffer Span u ∈ [0, 2] Forward Texture [0, 1] Mirrored Texture [1, 2] Mirror Center Axis u = 1.0 Equator (φ = 90°): Full Span u ∈ [0, 2] Mid-Lat (φ = 45°): [1 - sin φ, 1 + sin φ] Pole (φ = 0°): Collapses to single point u = 1.0 u = 0.0 u = 2.0
// crates/render/src/mesh.rs, make_sphere()
verts.push(Vertex {
    pos: [sin_p * cos_t * 0.9, cos_p * 0.9, sin_p * sin_t * 0.9],
    uv: [1.0 + (uu * 2.0 - 1.0) * sin_p, vv],   // was [uu * 2.0, vv]
});

Why the center and not, say, the left edge? Seam continuity. The two ends of a 360° mesh loop (the longitude parameters uu = 0 and uu = 1) land on the exact same physical line on the sphere, so they must sample the same texel. (Note the distinction: uu = 0 ≡ 1 corresponds to 0° ≡ 360° at the seam line, whereas uu = 0.5 corresponds to 180° on the opposite side of the sphere where texture coordinate u = 1 sits at the mirror fold). With the span [1 − s, 1 + s] centered on the fold, the mirror function sends both ends to the same place: mirror(1−s) = mirror(1+s) =  1−s. The reflection symmetry that killed the seam is exactly the symmetry that lets us shrink the span freely — the two fixes compose instead of fighting. Three properties fall out:

The double cones got the same one-liner — a cone's apex is the same degeneracy as a sphere's pole, with 1 − t playing the role of sin φ:

// make_cones(): apex pinch, same cure
// Previous UV: [uu * 2.0, tt]
uv: [1.0 + (uu * 2.0 - 1.0) * (1.0 - tt), tt],

The torus needs nothing: it has no degenerate rings (every point of a torus looks locally like every other), which is why it never showed the artifact. And because the whole fix lives in the vertex UVs, the shader, samplers, and simulation are untouched — the GPU interpolates the new coordinates exactly as it did the old ones.

The Trade-Off: the Corners Go Dark

Equal-area projections pay for their honesty somewhere, and ours pays in coverage. The lens-shaped footprint in the diagram covers 4/π of the mirrored span — about 64% — so roughly a third of the buffer (the regions near the seam edges at high latitudes; the "corners" of the map) is never sampled by the sphere at all.

Does skipping the four corners of the 2D texture buffer introduce a discontinuity or tear on the 3D sphere? No — the surface remains perfectly smooth and continuous everywhere. In graphics, a visual discontinuity occurs when adjacent points on a surface jump across non-adjacent texture coordinates. Here, vertex UVs are governed by smooth, continuous mathematical functions: u(φ, θ) = 1.0 + (uu · 2.0 − 1.0) · sin φ across the entire mesh. As latitude moves continuously from equator to pole, the sampled UV interval narrows seamlessly toward the center line u = 1. Neighboring vertices on the 3D sphere always sample neighboring texels in the 2D buffer. The corner texels in memory simply lie outside the domain of this continuous mapping — unread locations that no vertex on the 3D sphere ever queries.

There's also shear: away from the central meridian, latitude rings slide horizontally relative to each other, so vertical features in the buffer lean as they approach the poles — the same italic tilt you see in continents at the edge of a sinusoidal world map. That shear is visible well away from the poles, so the sphere's whole character shifts: where the old mapping showed rigid vertical stripes at every latitude, the new one bends them into sweeping curves as they climb. Compare the before/after pairs above — it's a genuinely different (we'd say better) look, not just a patched pole.

For a music visualizer, both are cheap. The buffer's content is a boiling, palette-cycled simulation with no privileged corner worth preserving, and the previous alternative was not "show the corners" but "shred them into a moiré pinwheel." As with the mirror symmetry, what remains reads as intentional: the bands now taper gracefully into the pole the way segments taper on a beach ball — a shape the eye already trusts.

Verification

Same protocol as the previous two fixes, thanks to the deterministic render pipeline: re-render the reference track with the effect playlist held fixed (cargo xtask video "8bit Dungeon Boss" --reuse-playlist), then extract the same timestamps from both videos. The simulation content matches frame for frame — the only thing that changed is where each texel lands on the sphere. The before/after pairs in this post are those frames.

BEFORE
Sphere pole with high-frequency converging stripes
AFTER
Sphere pole with smoothly tapering bands
A second pair from the same sphere segment (0:49). Left: fine stripes crowd and alias as they converge on both poles. Right: the same stripes taper like beach-ball gores and close cleanly around the pole (the "eye" at the lower left).

That leaves two small items on the curvature-rendering list: a one-texel out-of-bounds fetch at the far edge of the height-field mesh, and a palette lookup that should use nearest-neighbor filtering instead of blending unrelated palette entries. Both get the same treatment: fix, re-render, compare.