clone

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

main
marbles / src / client / marbleGlass.ts
marbleGlass.ts 12.3K
1/**2 * Glass toolkit (user round) — the marble's outer shell as REAL glass,3 * and glass/wire render variants for tunnel arches.4 *5 * Research-backed (Codrops glass/vortex-sphere; three.js transmission6 * pipeline): kit marbles keep their authored two-part construction —7 * opaque colored core inside a shell — and the shell's material becomes8 * MeshPhysicalMaterial transmission glass, TINTED VIA BEER–LAMBERT9 * ABSORPTION (attenuationColor), never by multiplying the base color10 * (that read as paint, not glass — "too aggressive"). The opaque core11 * is what the shell refracts. Big instanced packs get an additive12 * fresnel shell instead (order-independent — 10k unsorted transparent13 * instances cannot artifact on a dark sky).14 *15 * Environment: glass is reflection + refraction; the void has neither,16 * so ONE PMREM RoomEnvironment texture feeds material.envMap on glass17 * materials only — scene.environment stays unset (it would relight the18 * whole dark world). That environment is BRIGHTER THAN THE WORLD, so19 * marble shells keep exactly one reflection layer at half intensity —20 * never a clearcoat stacked over the glass's own Fresnel (see21 * glassShellMaterial for the measurement).22 *23 * Tunnels: glass tunnels use NON-transmission transparent glass — a24 * transmissive arch would erase transmissive marbles behind it (only25 * opaque objects land in the transmission buffer); alpha-blend keeps26 * the pack visible through the arch. Wire tunnels are the wireframe27 * lattice of the same arch mesh.28 */29import * as THREE from "three";30import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";31import type { RacerLook } from "./peersCore.ts";3233let env: THREE.Texture | null = null;3435/** One shared PMREM environment for every glass material (lazy). */36export function marbleEnv(renderer: THREE.WebGLRenderer): THREE.Texture {37 if (!env) {38 const pmrem = new THREE.PMREMGenerator(renderer);39 env = pmrem.fromScene(new RoomEnvironment()).texture;40 pmrem.dispose();41 }42 return env;43}4445/** Kit-marble shell glass: transmission with the phenotype SHELL as the46 * absorption tint (white shell = clear glass); ghost marbles run a long47 * attenuation distance (clearer), pearls carry iridescence. */48export function glassShellMaterial(49 renderer: THREE.WebGLRenderer,50 look: RacerLook,51): THREE.MeshPhysicalMaterial {52 const ghost = look.effect.kind === "ghost";53 return new THREE.MeshPhysicalMaterial({54 color: 0xffffff,55 transmission: 1,56 transparent: false,57 roughness: 0.04,58 metalness: 0,59 // Thin SHELL, not solid glass: thickness is a world-space volume60 // depth — at 0.2 (the marble's full diameter) refraction displaced61 // the core's virtual image visibly off-center (user report: "centers62 // offset while physics is true-centered"). ~0.03 reads as a skin of63 // glass over a solid core, image stays concentric.64 ior: 1.45,65 thickness: 0.03,66 attenuationColor: new THREE.Color(67 look.shell[0],68 look.shell[1],69 look.shell[2],70 ),71 attenuationDistance: ghost ? 0.6 : 0.15,72 // ONE reflection layer, at half strength (user: "the outer shell gets a73 // blue tint at some locations or angles"). Transmission ADDS specular on74 // top of the refracted core rather than blending with it, so a hot75 // environment washes the core out. RoomEnvironment's PMREM is an indoor76 // room with emissive panels well above 1.0, and `clearcoat: 1` stacked a77 // SECOND full Fresnel layer over the glass's own — together ~8% of a very78 // bright env sprayed neutral light over the whole sphere, which the zone's79 // mood lighting then tinted (periwinkle under the lavender moods). Measured80 // on a red-cored marble: the wash lifted G/B from ~4 to ~47. Dropping the81 // redundant clearcoat and halving the intensity cuts it to ~a fifth, and82 // core saturation at race distance rises ~40%.83 envMap: marbleEnv(renderer),84 envMapIntensity: 0.5,85 iridescence: look.effect.kind === "pearl"86 ? 0.35 + 0.5 * look.effect.intensity87 : 0,88 });89}9091/** Kit marbles are NOT authored core+shell (measured 2026-08-09): the92 * plain variants are an EMPTY glass sphere and the center variants hide93 * only a flat decal disc at the shell's mid-plane — nothing solid to94 * refract, so at race distance every marble read as a washed-out bubble95 * (user report). This injects the missing core: an opaque sphere at96 * 0.78 of the shell, added to the kit mesh BEFORE the material pass so97 * identity tint / effects / boost glow all apply through the same98 * pipeline. The core anchors to the MEASURED shell bounds — the99 * marble-center-* piece roots carry a +0.2 node translation the plain100 * variants lack (second user report: hardcoding any center left those101 * cores hanging at the bottom of the shell). */102export function addMarbleCore(mesh: THREE.Object3D): void {103 mesh.updateMatrixWorld(true);104 const box = new THREE.Box3().setFromObject(mesh);105 if (box.isEmpty()) return;106 const center = box.getCenter(new THREE.Vector3());107 const size = box.getSize(new THREE.Vector3());108 mesh.worldToLocal(center);109 // Shell radius in the mesh's LOCAL frame (setFromObject measured in110 // parent space — undo the mesh's own scale).111 const radius = Math.max(size.x, size.y, size.z) / 2 /112 (Math.abs(mesh.scale.x) || 1);113 const geo = new THREE.SphereGeometry(radius * 0.78, 20, 14);114 const mat = new THREE.MeshStandardMaterial({115 color: 0xdfe6ee,116 roughness: 0.35,117 metalness: 0.05,118 });119 mat.name = "core";120 const m = new THREE.Mesh(geo, mat);121 m.name = "core";122 // Unlike the cloned kit shell, this geometry/material pair is created123 // per marble and must be released on same-page regeneration.124 m.userData.marbleOwnedVisual = true;125 m.position.copy(center);126 mesh.add(m);127}128129/** Dispose only the per-marble geometry/material injected above. Kit shell130 * clones share their loaded assets and must remain alive for later seeds. */131export function disposeMarbleOwnedVisuals(root: THREE.Object3D): void {132 root.traverse((child: THREE.Object3D) => {133 const mesh = child as THREE.Mesh;134 if (!mesh.isMesh || !mesh.userData.marbleOwnedVisual) return;135 mesh.geometry.dispose();136 const materials = Array.isArray(mesh.material)137 ? mesh.material138 : [mesh.material];139 for (const material of materials) material.dispose();140 });141}142143/** Additive fresnel shell for instanced packs: rim-only glass illusion,144 * order-independent (no sorting artifacts at any N). Declares the145 * instancing attributes itself (raw ShaderMaterial gets none for free);146 * per-instance tint via instanceColor. */147export function fresnelShellMaterial(): THREE.ShaderMaterial {148 return new THREE.ShaderMaterial({149 transparent: true,150 blending: THREE.AdditiveBlending,151 depthWrite: false,152 vertexShader: `153 // instanceMatrix/instanceColor are injected by three's prelude for154 // materials on an InstancedMesh — declaring them again collides.155 varying vec3 vN;156 varying vec3 vW;157 varying vec3 vTint;158 void main() {159 vTint = instanceColor;160 vec4 wp = modelMatrix * instanceMatrix * vec4(position, 1.0);161 vW = wp.xyz;162 vN = normalize(mat3(modelMatrix) * mat3(instanceMatrix) * normal);163 gl_Position = projectionMatrix * viewMatrix * wp;164 }165 `,166 fragmentShader: `167 varying vec3 vN;168 varying vec3 vW;169 varying vec3 vTint;170 void main() {171 vec3 V = normalize(cameraPosition - vW);172 float f = pow(1.0 - abs(dot(normalize(vN), V)), 3.5);173 vec3 c = vTint * f * 0.85 + vec3(0.22) * f * f;174 gl_FragColor = vec4(c, 1.0);175 }176 `,177 });178}179180let chainlink: THREE.DataTexture | null = null;181182/** Procedural chainlink lattice (alpha cutout): a diamond weave, not the183 * model's triangulation (user report — `wireframe` rendered the literal184 * mesh edges). Shared by every wire arch. */185function chainlinkTexture(): THREE.DataTexture {186 if (chainlink) return chainlink;187 const S = 64;188 const P = 16; // weave period189 const W = 2.2; // strand half-width190 const data = new Uint8Array(S * S * 4);191 for (let y = 0; y < S; y++) {192 for (let x = 0; x < S; x++) {193 const a = Math.abs(((x + y) % P) - P / 2) < W;194 const b = Math.abs(((x - y + S) % P) - P / 2) < W;195 const on = a || b ? 255 : 0;196 const i = (y * S + x) * 4;197 data[i] = on;198 data[i + 1] = on;199 data[i + 2] = on;200 data[i + 3] = 255;201 }202 }203 chainlink = new THREE.DataTexture(data, S, S);204 chainlink.wrapS = THREE.RepeatWrapping;205 chainlink.wrapT = THREE.RepeatWrapping;206 chainlink.magFilter = THREE.LinearFilter;207 chainlink.minFilter = THREE.LinearMipmapLinearFilter;208 chainlink.generateMipmaps = true;209 chainlink.needsUpdate = true;210 return chainlink;211}212213/** Deterministic per-placement hash (position is seed material — the214 * same recipe as island color jitter). */215function posHash(x: number, y: number, z: number): number {216 let h = Math.imul(Math.round(x * 8) | 0, 374761393) ^217 Math.imul(Math.round(y * 8) | 0, 668265263) ^218 Math.imul(Math.round(z * 8) | 0, 2147483647);219 h = Math.imul(h ^ (h >>> 13), 1274126177);220 return ((h ^ (h >>> 16)) >>> 0) / 4294967296;221}222223/** Tunnel render variants (user round): some arches draw as GLASS224 * (alpha-blend translucent — the pack stays visible racing through),225 * some as WIRE lattice. Position-hashed → deterministic per seed, no226 * generation impact. Returns disposables for the segment. */227export function applyTunnelVariants(228 group: THREE.Object3D,229 renderer: THREE.WebGLRenderer,230): {231 meshes: THREE.InstancedMesh[];232 cameraFadeMeshes: THREE.Mesh[];233 dispose(): void;234} {235 const cloned: THREE.Material[] = [];236 const clonedGeos: THREE.BufferGeometry[] = [];237 const cameraFadeMeshes: THREE.Mesh[] = [];238 group.traverse((o: THREE.Object3D) => {239 const m = o as THREE.Mesh;240 if (!m.isMesh || m.name !== "tunnel") return;241 const p = m.getWorldPosition(new THREE.Vector3());242 const h = posHash(p.x, p.y, p.z);243 if (h < 0.2) {244 // Glass arch: tinted alpha glass (non-transmission — see header).245 const mat = new THREE.MeshPhysicalMaterial({246 color: 0xbfe2f5,247 transparent: true,248 opacity: 0.32,249 roughness: 0.08,250 metalness: 0,251 clearcoat: 1,252 clearcoatRoughness: 0.1,253 envMap: marbleEnv(renderer),254 envMapIntensity: 1.0,255 side: THREE.DoubleSide,256 depthWrite: false,257 // The arch flange lies on the track rim — push the glass back so258 // the opaque rim wins the contact line cleanly.259 polygonOffset: true,260 polygonOffsetFactor: 1,261 polygonOffsetUnits: 1,262 });263 cloned.push(mat);264 m.material = mat;265 m.geometry.computeBoundingBox();266 m.userData.cameraBaseOpacity = mat.opacity;267 cameraFadeMeshes.push(m);268 } else if (h < 0.34) {269 // Wire arch: a chainlink CAGE — alpha-cutout diamond weave over270 // projected UVs (the kit's atlas UVs can't tile a repeat; the271 // arch curves in x/y and runs along z, so (x+y, z) projects a272 // continuous wrap). Geometry is cloned — the kit shares it.273 const geo = m.geometry.clone();274 const pos = geo.getAttribute("position");275 const uv = new Float32Array(pos.count * 2);276 for (let i = 0; i < pos.count; i++) {277 uv[i * 2] = (pos.getX(i) + pos.getY(i)) * 3.0;278 uv[i * 2 + 1] = pos.getZ(i) * 3.0;279 }280 geo.setAttribute("uv", new THREE.BufferAttribute(uv, 2));281 m.geometry = geo;282 clonedGeos.push(geo);283 const base = (Array.isArray(m.material)284 ? m.material[0]285 : m.material) as THREE.MeshStandardMaterial;286 const mat = base.clone();287 mat.map = null;288 mat.alphaMap = chainlinkTexture();289 mat.alphaTest = 0.5;290 mat.side = THREE.DoubleSide;291 mat.color.multiplyScalar(0.85);292 // Lattice on the rim surface: pull the weave forward.293 mat.polygonOffset = true;294 mat.polygonOffsetFactor = -1;295 mat.polygonOffsetUnits = -1;296 mat.transparent = true;297 mat.depthWrite = false;298 cloned.push(mat);299 m.material = mat;300 m.geometry.computeBoundingBox();301 m.userData.cameraBaseOpacity = 1;302 cameraFadeMeshes.push(m);303 }304 });305 return {306 meshes: [],307 cameraFadeMeshes,308 dispose() {309 for (const m of cloned) m.dispose();310 for (const g of clonedGeos) g.dispose();311 },312 };313}