clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

main
marbles / src / client / islandRender.ts
islandRender.ts 11.6K
1/**2 * Instanced island rendering with PER-INSTANCE color variation.3 *4 * Blanket biome tints read as a filter; real kits vary per part. Every5 * island placement (cliff blocks, cap tiles, vegetation) becomes an6 * instance in an InstancedMesh per (piece, submesh), and every instance7 * gets its own color via setColorAt (multiplied over the kit texture):8 *9 * - blocks: geological STRATA (each course below the top drifts darker and10 * cooler), per-block hue/value jitter keyed by a position hash, MOSS11 * accents on the top course (forest/meadow), RUST mottling (barren).12 * - caps: hue/value spread around the grass tone + a subtle (x+z) parity13 * alternation so meadows read as tended fields.14 * - vegetation: each ISLAND draws a foliage hue lean (some islands run15 * autumn-warm, some deep green), each tree jitters around it. Foliage16 * materials are detected by green dominance; trunks/rocks get value17 * jitter only.18 *19 * All variation is deterministic from world position + island id — pure20 * presentation, no RNG streams touched. One draw call per (piece, submesh,21 * island) instead of one per placement.22 *23 * The spawn/exit waves animate instances individually: each InstancedMesh24 * carries `userData.animInstances` (base matrices + world positions) that25 * streamAnim.ts consumes.26 */27import * as THREE from "three";28import type { Placement } from "../shared/types.ts";29import type { Assets } from "./assets.ts";30import type { Island } from "../gen/endless/islands.ts";3132export interface InstancedBuild {33 meshes: THREE.InstancedMesh[];34 /** Dispose per-build GPU state (instance buffers, cloned materials). */35 dispose(): void;36}3738/** Per-instance animation data consumed by streamAnim. */39export interface AnimInstances {40 base: THREE.Matrix4[];41 worldPos: THREE.Vector3[];42}4344interface SubMesh {45 geometry: THREE.BufferGeometry;46 material: THREE.Material;47 local: THREE.Matrix4;48 foliage: boolean;49}5051const tmpM = new THREE.Matrix4();52const tmpQ = new THREE.Quaternion();53const tmpS = new THREE.Vector3();54const tmpP = new THREE.Vector3();55const tmpC = new THREE.Color();5657/** Deterministic hash of a world position (+salt) → [0, 1). */58function hash01(x: number, y: number, z: number, salt: number): number {59 let h = Math.imul((x * 4 + 1024) | 0, 374761393) ^60 Math.imul((y * 4 + 2048) | 0, 668265263) ^61 Math.imul((z * 4 + 4096) | 0, 2246822519) ^62 Math.imul(salt | 0, 973913579);63 h = Math.imul(h ^ (h >>> 13), 3266489917);64 return ((h ^ (h >>> 16)) >>> 0) / 4294967296;65}6667/** Submeshes of a template with their root-relative transforms. */68function subMeshesOf(template: THREE.Object3D): SubMesh[] {69 const out: SubMesh[] = [];70 const walk = (node: THREE.Object3D, parent: THREE.Matrix4) => {71 node.updateMatrix();72 const local = node === template73 ? new THREE.Matrix4()74 : new THREE.Matrix4().multiplyMatrices(parent, node.matrix);75 const mesh = node as THREE.Mesh;76 if (mesh.isMesh) {77 const mat = mesh.material as THREE.MeshStandardMaterial;78 const foliage = Boolean(79 mat.color && mat.color.g > mat.color.r * 1.15 &&80 mat.color.g > mat.color.b * 1.15,81 );82 out.push({83 geometry: mesh.geometry,84 material: mat,85 local,86 foliage,87 });88 }89 for (const child of node.children) walk(child, local);90 };91 walk(template, new THREE.Matrix4());92 return out;93}9495function clamp(v: number, lo: number, hi: number): number {96 return Math.min(hi, Math.max(lo, v));97}9899/** Color for a mass block: strata + jitter + biome accents (multiplied100 * over the cliff texture, so values hover near white). */101function blockColor(102 biome: string,103 depthBelowTop: number,104 x: number,105 y: number,106 z: number,107): THREE.Color {108 if (biome === "abstract") {109 // Dev-map blocking: saturated orange masses with grey courses — the110 // TF2-orange homage. Parity + hash decide which blocks go grey.111 const grey = hash01(x, y, z, 97) < 0.28;112 if (grey) {113 return tmpC.setHSL(0.08, 0.04, 0.72 + (hash01(x, y, z, 101) - 0.5) * 0.1)114 .clone();115 }116 return tmpC.setHSL(117 0.066 + (hash01(x, y, z, 103) - 0.5) * 0.015,118 0.85,119 0.55 + (hash01(x, y, z, 107) - 0.5) * 0.08,120 ).clone();121 }122 const band = Math.max(0, Math.floor(depthBelowTop));123 let h = 0.075 + band * 0.012 + (hash01(x, y, z, 11) - 0.5) * 0.05;124 let s = 0.06 + band * 0.02;125 let l = 0.88 - band * 0.055 + (hash01(x, y, z, 23) - 0.5) * 0.12;126127 const accent = hash01(x, y, z, 37);128 if ((biome === "forest" || biome === "meadow") && depthBelowTop <= 1.5) {129 // Moss creeps over the top course.130 if (accent < 0.22) {131 h = 0.3 + (accent - 0.11) * 0.3;132 s = 0.3;133 l = clamp(l + 0.02, 0.6, 0.95);134 }135 } else if (biome === "barren" && accent < 0.3) {136 // Rust mottling.137 h = 0.055 + accent * 0.05;138 s = 0.28;139 l = clamp(l - 0.04, 0.5, 0.95);140 } else if (biome === "winter") {141 // Cold desaturated strata; snow creeps over the top course.142 h = 0.58;143 s = clamp(s * 0.5, 0, 0.1);144 l = clamp(l + 0.03, 0.6, 0.98);145 if (depthBelowTop <= 1.5 && accent < 0.35) {146 s = 0.08;147 l = 0.96;148 }149 } else if (biome === "spooky") {150 // Graveyard gloom: darker, cooler, drained.151 h = 0.62;152 s = clamp(s * 0.6, 0, 0.12);153 l = clamp(l - 0.12, 0.42, 0.85);154 } else if (biome === "tropical") {155 // Sun-warmed sandstone strata.156 h = 0.1 + (hash01(x, y, z, 113) - 0.5) * 0.02;157 s = clamp(0.16 + band * 0.02, 0, 0.3);158 l = clamp(l + 0.02, 0.6, 0.98);159 } else if (biome === "autumn" && accent < 0.4) {160 // Soft ochre mottling.161 h = 0.07 + accent * 0.04;162 s = 0.22;163 l = clamp(l - 0.02, 0.55, 0.95);164 }165 return tmpC.setHSL(h, clamp(s, 0, 0.6), clamp(l, 0.5, 0.98)).clone();166}167168/** Color for a cap tile: grass spread + field parity. Themed biomes169 * reuse the nature 1×1 ground tile and repaint it here (M18a): winter170 * caps read as snow, tropical caps as sand. */171function capColor(biome: string, x: number, z: number): THREE.Color {172 const parity = (Math.abs(x + z) | 0) % 2 === 0 ? 0.025 : -0.025;173 if (biome === "winter") {174 const l = 0.955 + (hash01(x, 0, z, 47) - 0.5) * 0.04 + parity;175 return tmpC.setHSL(0.58, 0.07, clamp(l, 0.88, 0.99)).clone();176 }177 if (biome === "tropical") {178 const l = 0.86 + (hash01(x, 0, z, 47) - 0.5) * 0.1 + parity;179 return tmpC.setHSL(180 0.115 + (hash01(x, 0, z, 41) - 0.5) * 0.02,181 0.34,182 clamp(l, 0.7, 0.95),183 ).clone();184 }185 const warm = biome === "meadow" ? 0.035 : 0;186 const h = 0.29 + warm + (hash01(x, 0, z, 41) - 0.5) * 0.07;187 const s = 0.16 + hash01(x, 0, z, 43) * 0.18;188 let l = 0.88 + (hash01(x, 0, z, 47) - 0.5) * 0.12;189 l += parity;190 return tmpC.setHSL(h, s, clamp(l, 0.55, 0.98)).clone();191}192193/** Color for vegetation submeshes: island-wide foliage lean + per-plant194 * jitter on foliage; value-only jitter elsewhere. */195function decorColor(196 islandLean: number,197 foliage: boolean,198 x: number,199 y: number,200 z: number,201): THREE.Color {202 if (foliage) {203 const h = 0.3 + islandLean + (hash01(x, y, z, 53) - 0.5) * 0.06;204 const s = 0.25 + hash01(x, y, z, 59) * 0.2;205 const l = 0.85 + (hash01(x, y, z, 61) - 0.5) * 0.16;206 // Autumn lean pushes hue below green — clamp into a leaf-plausible arc.207 return tmpC.setHSL(clamp(h, 0.06, 0.42), s, clamp(l, 0.55, 0.98)).clone();208 }209 const l = 0.9 + (hash01(x, y, z, 67) - 0.5) * 0.14;210 return tmpC.setHSL(0.08, 0.05, clamp(l, 0.6, 0.98)).clone();211}212213/**214 * Build one island's placements as instanced meshes with per-instance215 * colors, appended directly to `group` (flat — the spawn/exit waves treat216 * each InstancedMesh as a bundle of individually-animated instances).217 */218export function buildIslandInstanced(219 island: Island,220 assets: Assets,221 group: THREE.Group,222 missing: Set<string>,223): InstancedBuild {224 // Bucket placements by pieceId.225 const byPiece = new Map<string, Placement[]>();226 for (const p of island.placements) {227 const list = byPiece.get(p.pieceId) ?? [];228 list.push(p);229 byPiece.set(p.pieceId, list);230 }231232 // Island-wide foliage lean: most islands stay green, a few run warm233 // (autumn) or cool. Deterministic per island id. Themed biomes (M18a)234 // pin the lean so their vegetation reads in-theme (snow pines never235 // turn orange; autumn islands always do).236 const leanDraw = hash01(island.id, 1, island.id * 7, 71);237 const BIOME_LEAN: Record<string, number> = {238 winter: 0.05,239 spooky: 0.0,240 tropical: 0.03,241 autumn: -0.14 - (leanDraw - 0.5) * 0.06,242 };243 const islandLean = BIOME_LEAN[island.biome] ?? (leanDraw < 0.6244 ? (leanDraw - 0.3) * 0.05 // subtle green spread245 : leanDraw < 0.85246 ? -0.12 - (leanDraw - 0.6) * 0.3 // autumn arc (toward orange)247 : 0.06); // cool teal-leaning248249 const meshes: THREE.InstancedMesh[] = [];250 const cloned: THREE.Material[] = [];251252 for (const [pieceId, placements] of byPiece) {253 const template = assets.templates.get(pieceId);254 if (!template) {255 missing.add(pieceId);256 continue;257 }258 const isBlock = pieceId.includes("cliff_block");259 // Block mass families (Platformer voxels): occluders like cliff260 // masses, but their tops/sides are PRE-TEXTURED — value jitter only,261 // never a hue tint (a green multiply over snow reads mossy).262 const isMassBlock = pieceId.startsWith("platformer-kit:block-");263 const isCap = !isBlock && !isMassBlock && pieceId.includes("ground_");264 for (const sub of subMeshesOf(template)) {265 const mat = sub.material.clone();266 // Cap tiles lie EXACTLY coplanar with the cliff-block tops beneath267 // them — polygon offset pulls the cap toward the camera so the268 // pair never z-fights (the distance shimmer on island tops).269 if (isCap) {270 mat.polygonOffset = true;271 mat.polygonOffsetFactor = -1;272 mat.polygonOffsetUnits = -1;273 }274 cloned.push(mat);275 const imesh = new THREE.InstancedMesh(276 sub.geometry,277 mat,278 placements.length,279 );280 imesh.castShadow = true;281 imesh.receiveShadow = true;282 const anim: AnimInstances = { base: [], worldPos: [] };283284 placements.forEach((p, i) => {285 const s = p.scale ?? 1;286 tmpP.set(p.pos[0], p.pos[1], p.pos[2]);287 tmpQ.setFromAxisAngle(288 new THREE.Vector3(0, 1, 0),289 p.rotation * Math.PI / 2,290 );291 tmpS.set(s, s, s);292 tmpM.compose(tmpP, tmpQ, tmpS);293 tmpM.multiply(sub.local);294 imesh.setMatrixAt(i, tmpM);295 anim.base.push(tmpM.clone());296 anim.worldPos.push(tmpP.clone());297298 const color = isBlock299 ? blockColor(300 island.biome,301 island.topY - p.pos[1],302 p.pos[0],303 p.pos[1],304 p.pos[2],305 )306 : isMassBlock307 ? tmpC.setHSL(308 0,309 0,310 clamp(0.92 + (hash01(p.pos[0], p.pos[1], p.pos[2], 131) - 0.5) *311 0.12, 0.8, 1),312 ).clone()313 : isCap314 ? capColor(island.biome, p.pos[0], p.pos[2])315 : decorColor(islandLean, sub.foliage, p.pos[0], p.pos[1], p.pos[2]);316 imesh.setColorAt(i, color);317 });318 imesh.instanceMatrix.needsUpdate = true;319 if (imesh.instanceColor) imesh.instanceColor.needsUpdate = true;320 imesh.computeBoundingSphere();321 imesh.userData.animInstances = anim;322 // Camera occluders are the MASSES only (cliff blocks + caps) —323 // pulling the boom in for a tree frond crossing the sightline324 // read as a camera jump (user report).325 imesh.userData.occluder = isBlock || isMassBlock || isCap;326 group.add(imesh);327 meshes.push(imesh);328 }329 }330331 return {332 meshes,333 dispose() {334 for (const m of meshes) m.dispose(); // instance buffers only335 for (const m of cloned) m.dispose();336 },337 };338}