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/pillarselected byshapeIdprefix. - Building proportion table
_BUILDING_DEFSfor shapeIdsbuilding_1x1,building_2x1,building_1x2,building_2x3,building_sm,building_md. - Pillar proportion table
_PILLAR_DEFSfor shapeIdspillar_sm,pillar_md,pillar_lg. - Bake functions:
_bakeAsteroid,_bakeSkyscraper,_bakePillar. - Baked stamp store (
_bakedStampsWeakMap) holding{ canvas, silhouetteVerts, hullVerts, roofSplitY }per piece. - Mesh cache (
_meshCacheWeakMap) and collision cache (_collisionCacheWeakMap). - Scratch buffers
_projX/_projY/_projZ(Float32Array, grown on demand) and_rotScratch/_faceA/_faceB/_faceCVec3 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
../core—camerasingleton (usescamera.zoom)../camera—Camera.toS(x, y)for world-to-screen projection.../core/config—PERF_FLAGS(imported)../parallax/parallax-system—getActiveBiomeId();old_earthactivates the grounded skyscraper variant with a base plinth../palette/palette-system—resolvePaletteSlot(slot)via the local_paletteRgbhelper for slotsterrain_base,terrain_edge,shadow,midtone,bg_star,bg_haze. Asteroid face shading lerps base → midtone → edge by lighting intensity. Building face usesterrain_edge/terrain_base/midtonefor the vertical gradient,shadow(with ±6 per-channel delta) for the angled roof gradient, andbg_starfor window glow. Pillars usebg_starfor 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): readsshapeId,x,yto derive per-piece bake seed (x * 73856093 + y * 19349663) and for pillar light phase offsets.
PUSHES TO
- The shared 2D
CanvasRenderingContext2Dpassed by the caller — every draw function writes viadrawImage,fill,stroke, gradient fills,save/restore,globalCompositeOperation = 'lighter'(pillar lights only). - Mutates the
pieceargument ofupdateTerrainCollisionShape: writesworldVerts(length + per-vertex x/y, reused in place),normals(outward edge normals),boundingRadius,radius. For buildings/pillarsboundingRadiusis kept at the bake-timeworldR(so visual scale survives frame 2) whileradiusis 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,_collisionCacheget a new entry on first contact per piece.clearBakedSprites()replaces_bakedStampswith 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 —
tumbleSpeedis hardcoded to 0; thetimeparameter ondrawAsteroid3DandupdateTerrainCollisionShapeis unused (kept for API compatibility). Pillar light twinkle is the only time-driven effect, and it readstimefrom 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 —
convexHull2Dis 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 byshapeId). Draws sticker outline (white outer + black inner stroke at screen resolution) then the baked stamp. Skips whenscreenR < 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 BEFOREdrawAsteroid3Dfor 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 viadrawAsteroid3Dif 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 ratios1.618,2.71828,3.7,2.1,5.3, threshold0.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). ExportsASTEROID_BAKE_SIZEso the bridge knows the render radius.updateTerrainCollisionShape(piece, time, worldR, origX, origY)— bakes if needed, scales the unit-space silhouette to world space, writesworldVerts/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. UsessilhouetteVertsfor buildings/pillars (matches collision exactly) andhullVertsfor 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 byclearBakedSprites()on palette swap. Cost per frame is onedrawImageplus 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.
_traceCanvasSilhouettereads pixel alpha atALPHA_THRESHOLD = 10to 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_douglasPeuckerwith epsilonDP_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 * 19349663for stamps,x * 7919 + y * 104729for meshes). RNG ismakeRng(seed)— a linear congruential generator (s = s * 1103515245 + 12345 & 0x7fffffff).generateAsteroidMeshdeliberately 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.initialRotonly (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-facecolorSeedjitter weighted asymmetrically across RGB (1.00 / 0.55 / 0.25) so faces shift hue, not just brightness.AMBIENT = 0.15is 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 = 4margin per side for AA. All fixed pixel sizes inside building/pillar bakes scale byBS = BUILDING_BAKE_SIZE / BAKE_SIZEso 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/drawPillarBasesource-clip [splitY→ end] anddrawBuildingRoof/drawPillarTopsource-clip [0→splitY]. - Pillar lights. Baked at low alpha (0.25) so a faint baseline shows under every pillar. The bright twinkle in
drawPillarLightsis additive (globalCompositeOperation = 'lighter'), uses palettebg_starfor the halo and a desaturated-toward-white core (bg_star * 0.3 + 255 * 0.7), and skips lights whose composite wave is below0.65. Per-pillar phase comes frompiece.x * 0.0137 + piece.y * 0.0091(stable, no RNG). Top row gets a2.71828(e) phase offset so the two rows never fire in unison. - Collision cache restore is belt-and-suspenders.
updateTerrainCollisionShapekeepspiece.worldVertspointing 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 cachedboundingRstores the originalworldR(notmaxR) sodrawImagescaling 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_bakedStampswith a freshWeakMapdetaches 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.