PURPOSE

Procedural low-poly 3D terrain system that bakes asteroids, skyscrapers, and obsidian pillars to offscreen canvases once per piece, then renders each frame as a single drawImage. Collision polygons are derived by pixel-tracing the baked visual boundary (Moore-neighborhood contour walk + Douglas-Peucker simplification), so the collision shape matches the rendered sprite exactly — no convex hull approximation, no 3D projection drift, no scale mismatch. Buildings and pillars get z-split rendering helpers so the face/base draws below ships and the roof/top draws above. All terrain is static (tumble killed; tumbleSpeed=0).

OWNS

  • 3D mesh types: Vec3, Face, AsteroidMesh.
  • Procedural mesh generator: generateSphere, generateAsteroidMesh (UV sphere with ellipsoidal stretch in random orientation + gentle surface bump).
  • Shape category dispatch (getShapeCategory): asteroid / building / pillar selected by shapeId prefix.
  • Building proportion table _BUILDING_DEFS for shapeIds building_1x1, building_2x1, building_1x2, building_2x3, building_sm, building_md.
  • Pillar proportion table _PILLAR_DEFS for shapeIds pillar_sm, pillar_md, pillar_lg.
  • Bake functions: _bakeAsteroid, _bakeSkyscraper, _bakePillar.
  • Baked stamp store (_bakedStamps WeakMap) holding { canvas, silhouetteVerts, hullVerts, roofSplitY } per piece.
  • Mesh cache (_meshCache WeakMap) and collision cache (_collisionCache WeakMap).
  • Scratch buffers _projX/_projY/_projZ (Float32Array, grown on demand) and _rotScratch/_faceA/_faceB/_faceC Vec3 buffers for allocation-free face rotation.
  • Pixel-trace silhouette pipeline: _traceCanvasSilhouette, _douglasPeucker, _dpSimplify.
  • Building/pillar collision geometry: _buildingRoofVerts, _pillarCollisionVerts.
  • Z-split math: _computeRoofSplitY, _computePillarSplits.
  • Convex hull builder convexHull2D (Andrew’s monotone chain) used for the screen-resolution sticker outline.
  • Lighting + bake constants: LIGHT_X/Y/Z, AMBIENT, COLOR_VARIATION, BAKE_SIZE = 256, BUILDING_BAKE_SIZE = 1024, PILLAR_BAKE_SIZE = 1024, BAKE_PAD = 4, OUTLINE_WHITE = 8, OUTLINE_BLACK = 4, ALPHA_THRESHOLD = 10, DP_EPSILON = 2.0.
  • Drop-shadow tunables: ASTEROID_SHADOW_ALPHA, SHADOW_RX_MUL, SHADOW_RY_MUL, SHADOW_Y_OFFSET_MUL.
  • Exports: drawAsteroid3D, drawAsteroidShadow, drawBuildingFace, drawBuildingRoof, drawPillarBase, drawPillarTop, drawPillarLights, isBuildingTerrain, isPillarTerrain, getAsteroidStamp, ASTEROID_BAKE_SIZE, updateTerrainCollisionShape, clearBakedSprites, drawBlocker, drawTerrainSolidFill, OUTLINE_WHITE, OUTLINE_BLACK.

READS FROM

  • ../corecamera singleton (uses camera.zoom).
  • ./cameraCamera.toS(x, y) for world-to-screen projection.
  • ../core/configPERF_FLAGS (imported).
  • ./parallax/parallax-systemgetActiveBiomeId(); old_earth activates the grounded skyscraper variant with a base plinth.
  • ./palette/palette-systemresolvePaletteSlot(slot) via the local _paletteRgb helper for slots terrain_base, terrain_edge, shadow, midtone, bg_star, bg_haze. Asteroid face shading lerps base → midtone → edge by lighting intensity. Building face uses terrain_edge/terrain_base/midtone for the vertical gradient, shadow (with ±6 per-channel delta) for the angled roof gradient, and bg_star for window glow. Pillars use bg_star for both the baked indicator-light dots and the live twinkle halo+core; pure black + white/black outline for the collision rect; pure black for the bottom fade gradient.
  • Piece object via (piece as any): reads shapeId, x, y to derive per-piece bake seed (x * 73856093 + y * 19349663) and for pillar light phase offsets.

PUSHES TO

  • The shared 2D CanvasRenderingContext2D passed by the caller — every draw function writes via drawImage, fill, stroke, gradient fills, save/restore, globalCompositeOperation = 'lighter' (pillar lights only).
  • Mutates the piece argument of updateTerrainCollisionShape: writes worldVerts (length + per-vertex x/y, reused in place), normals (outward edge normals), boundingRadius, radius. For buildings/pillars boundingRadius is kept at the bake-time worldR (so visual scale survives frame 2) while radius is tightened to the roof/collision band max-radius for broad-phase. Pieces without a valid silhouette get a 12-vertex circle fallback.
  • Internal caches: _bakedStamps, _meshCache, _collisionCache get a new entry on first contact per piece. clearBakedSprites() replaces _bakedStamps with a fresh WeakMap (palette swap requires this so existing pieces re-bake; mesh/collision caches are palette-independent and stay).

DOES NOT

  • Does not own piece spawning, ECS storage, world generation, or LOD/culling — pieces are handed in by the renderer/bridge.
  • Does not run rotation/animation on terrain at runtime — tumbleSpeed is hardcoded to 0; the time parameter on drawAsteroid3D and updateTerrainCollisionShape is unused (kept for API compatibility). Pillar light twinkle is the only time-driven effect, and it reads time from the caller, not a frame clock owned here.
  • Does not allocate Vec3s per face during bake — uses module-scope scratch buffers (_faceA/B/C, _rotScratch) and Float32Array scratch (_projX/Y/Z).
  • Does not compute convex-hull collision — convexHull2D is only for the visible sticker outline. Collision is the pixel-traced silhouette.
  • Does not draw window-pipe details on terrain buildings (Nate 2026-04-22: those moved to parallax silhouettes).
  • Does not include the grounded building base plinth in collision verts — only the rooftop blocks pathing.
  • Does not emit telemetry, events, or logs.

Signals

None. The module is pure pull (caller-driven draws + collision updates). The only outward effects are pixels on the supplied canvas context and mutation of caller-owned piece objects.

Entry points

  • drawAsteroid3D(ctx, x, y, radius, worldVerts | undefined, piece, time = 0) — unified entry that lazily bakes the sprite on first call (asteroid / building / pillar dispatched by shapeId). Draws sticker outline (white outer + black inner stroke at screen resolution) then the baked stamp. Skips when screenR < 2. Integer-rounds destination coords to avoid sub-pixel AA on mobile.
  • drawAsteroidShadow(ctx, x, y, radius, piece) — flattened radial-gradient ellipse below the body (alpha 0.55 peak, RX×1.10, RY×0.38, Y offset ×0.50). Must be called BEFORE drawAsteroid3D for the same piece so the sprite covers its own shadow. Early-returns on buildings/pillars (detected via !baked.hullVerts).
  • drawBuildingFace(ctx, x, y, radius, piece) / drawBuildingRoof(ctx, x, y, radius, piece) — source-rect-clipped split rendering of one baked building canvas; face below ships, roof above. Both lazy-bake via drawAsteroid3D if needed.
  • drawPillarBase(ctx, x, y, radius, piece) / drawPillarTop(ctx, x, y, radius, piece) / drawPillarLights(ctx, x, y, radius, piece, time) — three-zone pillar render (base = collision rect + bottom fade below player; top = solid rect above player; lights = additive bloom dots with layered sine waves at irrational frequency ratios 1.618, 2.71828, 3.7, 2.1, 5.3, threshold 0.65).
  • isBuildingTerrain(piece) / isPillarTerrain(piece) — predicates used by the bridge for draw-path dispatch.
  • getAsteroidStamp(piece, x, y) : HTMLCanvasElement | null — lazy-bake + return the baked canvas (bridge uses it to downscale and patch the WebGL atlas). Exports ASTEROID_BAKE_SIZE so the bridge knows the render radius.
  • updateTerrainCollisionShape(piece, time, worldR, origX, origY) — bakes if needed, scales the unit-space silhouette to world space, writes worldVerts/normals/boundingRadius/radius, caches the result. Falls back to a 12-vertex circle when tracing yields fewer than 3 verts.
  • clearBakedSprites() — drops all baked canvases (replaces the WeakMap); called when the active palette swaps so the next frame re-bakes with the new colors.
  • drawBlocker(ctx, x, y, piece) — solid black polygon with white + black outline, used by the level builder when sticker rendering is off.
  • drawTerrainSolidFill(ctx, x, y, piece) — black silhouette pass for the sticker merged-outline buffer. Uses silhouetteVerts for buildings/pillars (matches collision exactly) and hullVerts for asteroids. Returns silently if the piece has not been baked yet — it will appear next frame after the normal draw bakes it.

Pattern notes

  • Bake-once / draw-many. The bake path runs once per piece on first draw (_bakedStamps.set) and is invalidated as a group by clearBakedSprites() on palette swap. Cost per frame is one drawImage plus an optional screen-resolution stroke for the asteroid sticker outline.
  • Outlines drawn at screen resolution, not baked. Asteroid silhouette is stroked from the convex hull (hullVerts) at draw time so line width stays constant regardless of zoom. Building/pillar roof outlines are baked because they’re rectangles. OUTLINE_WHITE = 8, OUTLINE_BLACK = 4 (screen pixels); white drawn first, black drawn on top of white, then stamp covers the inner halves — no triple-stripe artifact.
  • Collision IS the visual. _traceCanvasSilhouette reads pixel alpha at ALPHA_THRESHOLD = 10 to find the visible boundary, walks it with an 8-connected Pavlidis-variant Moore-neighborhood trace (capped at 4000 pixels), subsamples to ≤200 points, converts to unit space, then runs _douglasPeucker with epsilon DP_EPSILON / renderR ≈ 2 bake-px (~0.4 screen-px). Winding is forced CCW via signed-area check. Buildings and pillars skip tracing — they use exact rectangle geometry from _buildingRoofVerts / _pillarCollisionVerts.
  • Deterministic per-piece seed. Mesh seed and building/pillar bake seed are derived from piece position with the standard spatial-hash prime triple (x * 73856093 + y * 19349663 for stamps, x * 7919 + y * 104729 for meshes). RNG is makeRng(seed) — a linear congruential generator (s = s * 1103515245 + 12345 & 0x7fffffff). generateAsteroidMesh deliberately consumes three RNG values after killing tumble to keep the seed sequence stable with worlds generated before the rotation removal.
  • 3D math. Vertices are projected with a 2D rotation by mesh.initialRot only (Z spin) — no perspective; back-faces culled by 2D signed-edge test. Per-face surface normal computed in rotated 3D space and dotted with the fixed light (-0.5, -0.5, 0.7071) (≈ 45° azimuth / 45° elevation); shading lerps through three palette slots (base → midtone → edge) with per-face colorSeed jitter weighted asymmetrically across RGB (1.00 / 0.55 / 0.25) so faces shift hue, not just brightness. AMBIENT = 0.15 is the floor.
  • Bake resolutions are tier-locked. Asteroids: BAKE_SIZE = 256. Buildings: BUILDING_BAKE_SIZE = 1024 (4× pixel budget for crisp sprites at half world footprint). Pillars: PILLAR_BAKE_SIZE = 1024. BAKE_PAD = 4 margin per side for AA. All fixed pixel sizes inside building/pillar bakes scale by BS = BUILDING_BAKE_SIZE / BAKE_SIZE so the unit-space layout stays identical to the asteroid baseline.
  • Building variants. Sunrise-City variant fades face alpha to 0 at the bottom (“dissolving into the void” skyline). Grounded variant (active when getActiveBiomeId() === 'old_earth') holds face alpha at 1 and adds a decorative plinth that extends past the roof width — the plinth is NOT in _buildingRoofVerts, so only the rooftop blocks pathing (Nate 2026-04-22).
  • Z-split rendering. Buildings split at _computeRoofSplitY (roof bottom + outline bleed). Pillars split at _computePillarSplits (bottom of solid collision rect + outline bleed). Both helpers return a Y in baked-canvas pixels; drawBuildingFace/drawPillarBase source-clip [splitY → end] and drawBuildingRoof/drawPillarTop source-clip [0splitY].
  • Pillar lights. Baked at low alpha (0.25) so a faint baseline shows under every pillar. The bright twinkle in drawPillarLights is additive (globalCompositeOperation = 'lighter'), uses palette bg_star for the halo and a desaturated-toward-white core (bg_star * 0.3 + 255 * 0.7), and skips lights whose composite wave is below 0.65. Per-pillar phase comes from piece.x * 0.0137 + piece.y * 0.0091 (stable, no RNG). Top row gets a 2.71828 (e) phase offset so the two rows never fire in unison.
  • Collision cache restore is belt-and-suspenders. updateTerrainCollisionShape keeps piece.worldVerts pointing at the cached array on the fast path, but if a caller has swapped it out, the function copies the cached values back in place rather than reassigning the array. For buildings/pillars the cached boundingR stores the original worldR (not maxR) so drawImage scaling survives a cache hit on frame 2+.
  • Palette invalidation. clearBakedSprites() is the only invalidator. Mesh shape and collision polygon are palette-independent and intentionally NOT cleared on palette swap. Replacing _bakedStamps with a fresh WeakMap detaches every entry in one step — necessary because terrain pieces stay alive during a mission, so the old WeakMap would keep them pinned.
  • Mobile sub-pixel handling. All blit destinations are integer-rounded (isx = sc.x | 0, isy = sc.y | 0) to prevent canvas sub-pixel resampling on mobile.