clone

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

Glass marbles + tunnel variants; yaw-only pursuit; distributed respawns

Dami committed 13c41fe
Glass (researched: transmission pipeline, Codrops glass/vortex-sphere,
fresnel fakes — summary in marbleGlass.ts): the kit marble's authored
'glass' shell submesh becomes real MeshPhysicalMaterial transmission
glass — phenotype shell tint expressed as Beer-Lambert attenuationColor
(transparent by physics, not color-multiply paint; ghost = long
attenuation, pearl = iridescence), identity tint + effects on the
opaque core the glass refracts. One PMREM RoomEnvironment on glass
materials only; transmissionResolutionScale 0.5. Instanced packs:
opaque core + additive fresnel shell (order-independent at any N).
Tunnel render variants, position-hashed: 20% alpha-glass arches
(non-transmission so marbles stay visible through them), 14% wire
lattice cages. Visual-only; deterministic per seed.

Autopilot: pursuit steers YAW ONLY — wishdir is the horizontal
direction to the lookahead, pitch comes from the ground-plane
projection (3D seeking aimed into the floor at ramp-up-then-down
crests: accelerate-toward-floor jitter; user report + unit test).

Respawns distribute across an N-scaled fill range (10+0.35N units, 70%
trailing/30% ahead, golden-ratio idx slots, lane+height stagger) —
a culled segment had respawned every straggler onto one station
(user report: 'liveliness is distribution, not a clump').

116 tests green; browser-verified: transparent glass marbles + wire
tunnel in frame, 400-pack river of rimmed glass beads, 0 errors.
commit
13c41feb8e1643ac7a326c31d2e3d17e866cdc09
parent
e55858697dd1029d3eb507d3adfa48b08231972b
author
Dami <dami.etoile@gmail.com>
6 files changed, +346 -20
Mprogress.md30+++++
Msrc/client/endless.ts111+++++++++++++++++---
Asrc/client/marbleGlass.ts170++++++++++++++++++++++++++++++
Msrc/client/physics/autopilot.ts20+++-
Msrc/client/scene.ts3+
Mtests/autopilot_test.ts32++++++
M progress.md +30 -0
@@ -1278,6 +1278,36 @@ emissive (boost-glow flameout RESTORES the ember baseline, not black),
12781278 ghost goes transparent. Instanced path folds phenotype into instance12791279 color (brighten/lift/darken approximations).12801280 1281+GLASS ROUND (user: shell too aggressive → research three.js glass;1282+also glass/wire TUNNEL variants; research report summarized in the1283+marbleGlass.ts header): the kit marble is AUTHORED as opaque core +1284+"glass"-named shell submesh — the phenotype had been color-multiplying1285+BOTH (the "aggressive" look). Now: identity tint + effects live on the1286+CORE only; the shell material becomes real MeshPhysicalMaterial1287+transmission glass tinted via Beer–Lambert attenuationColor (the1288+phenotype shell color; ghost = long attenuationDistance, pearl =1289+iridescence). ONE PMREM RoomEnvironment texture feeds material.envMap1290+on glass/gloss materials ONLY (scene.environment would relight the1291+void); renderer.transmissionResolutionScale = 0.5. Instanced packs:1292+opaque core (0.82r) + additive-fresnel shell InstancedMesh (order-1293+independent — no sorting artifacts at any N; three injects1294+instanceMatrix/instanceColor for ShaderMaterial on InstancedMesh —1295+declaring them again is a shader redefinition error). TUNNELS:1296+position-hashed render variants — 20% alpha-glass (NON-transmission:1297+a transmissive arch would erase transmissive marbles behind it — only1298+opaque objects land in the transmission buffer), 14% wireframe cage.1299+1300+AUTOPILOT FIXES (user reports): (a) crest ground-seeking — pursuit now1301+steers YAW ONLY (wishdir = horizontal direction to the lookahead,1302+pitch supplied by the local ground-plane projection; drop-shaft1303+fallback to tangent heading) — a 3D seek aimed into the floor on1304+ramp-up-then-down and jittered; unit test pins it. (b) respawn1305+distribution — a culled segment respawned every straggler onto one1306+station; respawnPeer now distributes across an N-scaled fill range1307+(10 + 0.35·N units, 70% trailing / 30% ahead of the camera subject,1308+clamped to built rails) with golden-ratio idx slots + lane/height1309+stagger. Liveliness is distribution, not a clump (user rule).1310+12811311 REMAINING / NEXT IDEAS: marble-marble collision-immunity window on12821312 respawn (shim filter bits — deferred from M15); rubber-banding via pilot12831313 skill params (never thrust — Melder); race HUD (leaderboard strip, name
M src/client/endless.ts +95 -16
@@ -52,6 +52,12 @@ import {
5252 wantJump,5353 } from "./physics/autopilot.ts";5454 import { racerIdentity } from "./peersCore.ts";55+import {56+ applyTunnelVariants,57+ fresnelShellMaterial,58+ glassShellMaterial,59+ marbleEnv,60+} from "./marbleGlass.ts";5561 import {5662 bumperProxies,5763 channelProxies,
@@ -370,8 +376,10 @@ class Session {
370376 /** Separation-partner scratch (reused per decision). */371377 private readonly othersScratch: Neighbor[] = [];372378 private stepCounter = 0;373- /** Instanced peer rendering (packs > INSTANCED_AT): one mesh. */379+ /** Instanced peer rendering (packs > INSTANCED_AT): opaque core mesh380+ * plus an additive fresnel glass shell mesh. */374381 private peerMesh: THREE.InstancedMesh | null = null;382+ private peerShellMesh: THREE.InstancedMesh | null = null;375383 private readonly peerColors: THREE.Color[] = [];376384 /** Camera/trail stand-in when the followed racer is instanced. */377385 private readonly instProxy = new THREE.Object3D();
@@ -466,6 +474,17 @@ class Session {
466474 const m = child as THREE.Mesh;467475 if (!m.isMesh) return;468476 const cloneOne = (mat: THREE.Material): THREE.Material => {477+ // Hero shell: clear real glass (neutral phenotype).478+ if (mat.name === "glass" && physics) {479+ const g = glassShellMaterial(ctx.renderer, {480+ meshId: MARBLE_PIECE,481+ tint: null,482+ shell: [1, 1, 1],483+ effect: { kind: "none", intensity: 0 },484+ });485+ heroMaterials.push(g);486+ return g;487+ }469488 const clone = mat.clone();470489 heroMaterials.push(clone);471490 return clone;
@@ -546,7 +565,9 @@ class Session {
546565 // one InstancedMesh beyond.547566 const instanced = this.realPeerCount > INSTANCED_AT;548567 if (instanced) {549- const geo = new THREE.SphereGeometry(MARBLE_RADIUS, 12, 8);568+ // Opaque colored core inside an additive fresnel glass rim (the569+ // instanced glass illusion — order-independent, sorts never).570+ const geo = new THREE.SphereGeometry(MARBLE_RADIUS * 0.82, 12, 8);550571 const mat = new THREE.MeshStandardMaterial({551572 roughness: 0.35,552573 metalness: 0.05,
@@ -555,6 +576,15 @@ class Session {
555576 this.peerMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);556577 this.peerMesh.frustumCulled = false;557578 ctx.scene.add(this.peerMesh);579+ const shellGeo = new THREE.SphereGeometry(MARBLE_RADIUS, 12, 8);580+ this.peerShellMesh = new THREE.InstancedMesh(581+ shellGeo,582+ fresnelShellMaterial(),583+ this.realPeerCount,584+ );585+ this.peerShellMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);586+ this.peerShellMesh.frustumCulled = false;587+ ctx.scene.add(this.peerShellMesh);558588 }559589 const span = Math.max(2, Math.min(rail.pts.length - 2, 120));560590 const LANES = [-0.26, 0, 0.26];
@@ -603,6 +633,15 @@ class Session {
603633 }604634 this.peerColors.push(color);605635 this.peerMesh!.setColorAt(i - 1, color);636+ // Shell rim tint: the phenotype shell (near-white = clear).637+ this.peerShellMesh!.setColorAt(638+ i - 1,639+ new THREE.Color(640+ identity.shell[0],641+ identity.shell[1],642+ identity.shell[2],643+ ),644+ );606645 }607646 this.racers.push({608647 idx: i,
@@ -632,6 +671,9 @@ class Session {
632671 if (this.peerMesh?.instanceColor) {633672 this.peerMesh.instanceColor.needsUpdate = true;634673 }674+ if (this.peerShellMesh?.instanceColor) {675+ this.peerShellMesh.instanceColor.needsUpdate = true;676+ }635677 }636678 637679 this.cameraProxy = {
@@ -781,6 +823,9 @@ class Session {
781823 const disposables = data.islands.map((isle) =>782824 buildIslandInstanced(isle, this.assets, group, missing)783825 );826+ // Tunnel render variants (glass / wire) — position-hashed, visual827+ // only, deterministic per seed.828+ disposables.push(applyTunnelVariants(group, this.ctx.renderer));784829 for (const id of missing) {785830 console.warn(`[endless] unknown pieceId "${id}" — skipped`);786831 }
@@ -1142,6 +1187,7 @@ class Session {
11421187 );11431188 this.instMatrix.compose(this.instPos, this.instQuat, this.instScale);11441189 this.peerMesh.setMatrixAt(r.idx - 1, this.instMatrix);1190+ this.peerShellMesh?.setMatrixAt(r.idx - 1, this.instMatrix);11451191 }11461192 const seg = this.built.get(r.nearest.seg);11471193 const rail = this.rails.get(r.nearest.seg);
@@ -1239,6 +1285,9 @@ class Session {
12391285 }12401286 }12411287 if (this.peerMesh) this.peerMesh.instanceMatrix.needsUpdate = true;1288+ if (this.peerShellMesh) {1289+ this.peerShellMesh.instanceMatrix.needsUpdate = true;1290+ }12421291 if (heroFell) {12431292 this.respawn();12441293 return;
@@ -1298,17 +1347,31 @@ class Session {
12981347 }12991348 }13001349 1301- /** Respawn a peer racer at the pack tail (never past the keep window's1302- * lower edge) — no checkpoint escalation, no death counting; the pack1303- * simply keeps itself together. */1350+ /** Respawn a peer racer DISTRIBUTED across the pack's fill range (user1351+ * rule: liveliness is distribution, not a clump — a culled segment once1352+ * respawned every straggler onto one station). The range grows with1353+ * the pack size, filling backward AND forward of the camera subject,1354+ * clamped inside the built rails; each racer owns a deterministic slot1355+ * (golden-ratio hash of its idx) plus a lane, so simultaneous respawns1356+ * land spread out. No checkpoint escalation, no death counting. */13041357 private respawnPeer(r: Racer): void {13051358 const engine = this.engine;13061359 if (!engine) return;1307- let tailS = Infinity;1308- for (const o of this.racers) if (o !== r) tailS = Math.min(tailS, o.s);1309- const lo = Math.min(...this.built.keys());1310- const loS = (this.built.get(lo)?.startS ?? 0) + 1;1311- const at = Math.max(loS, tailS - 1.5);1360+ let loS = Infinity;1361+ let hiS = -Infinity;1362+ for (const b of this.built.values()) {1363+ loS = Math.min(loS, b.startS);1364+ hiS = Math.max(hiS, b.startS + b.length);1365+ }1366+ const lo = loS + 2;1367+ const hi = hiS - 6;1368+ if (!(hi > lo)) return;1369+ // Fill range: ~10u for a duo, growing with the pack, capped by the1370+ // built window. 70% of it trails the camera subject, 30% runs ahead1371+ // (traffic to catch AND to be caught by).1372+ const span = Math.min(hi - lo, 10 + this.racers.length * 0.35);1373+ const h = (((r.idx * 2654435761) >>> 0) % 1024) / 1024;1374+ const at = Math.max(lo, Math.min(hi, this.marbleS + (h - 0.7) * span));13121375 const seg = this.segmentAt(at);13131376 const rail = seg ? this.rails.get(seg.index) : undefined;13141377 if (!seg || !rail) return;
@@ -1316,12 +1379,12 @@ class Session {
13161379 while (idx < rail.s.length - 1 && rail.s[idx] < at - seg.startS) idx++;13171380 const t = rail.tans[idx];13181381 const nl = Math.hypot(t[2], t[0]) || 1;1319- const lane = ((r.idx % 3) - 1) * 0.24; // big packs: don't pile the tail1382+ const lane = ((r.idx % 5) - 2) * 0.13;13201383 engine.spawnMarble(13211384 r.idx,13221385 [13231386 rail.pts[idx][0] + (t[2] / nl) * lane,1324- rail.pts[idx][1] + 0.4,1387+ rail.pts[idx][1] + 0.4 + (r.idx % 7) * 0.12,13251388 rail.pts[idx][2] + (-t[0] / nl) * lane,13261389 ],13271390 [t[0] * 2.2, 0, t[2] * 2.2],
@@ -1357,26 +1420,36 @@ class Session {
13571420 // base look: shell tints the color, the effect reshapes the13581421 // material (gloss/pearl/ember/ghost at its intensity).13591422 const tint = identity.tint;1360- const { shell, effect } = identity;1423+ const { effect } = identity;13611424 mesh.traverse((child: THREE.Object3D) => {13621425 const m = child as THREE.Mesh;13631426 if (!m.isMesh) return;13641427 const cloneOne = (mat: THREE.Material): THREE.Material => {1428+ // The kit marble is core + shell: the "glass" material IS the1429+ // shell — it becomes real transmission glass tinted by the1430+ // phenotype (Beer–Lambert), while identity tint + effects1431+ // stay on the opaque CORE the glass refracts.1432+ if (mat.name === "glass") {1433+ const g = glassShellMaterial(this.ctx.renderer, identity);1434+ materials.push(g);1435+ return g;1436+ }13651437 const clone = (mat as THREE.MeshStandardMaterial).clone();13661438 if (tint) {13671439 clone.color.setRGB(tint[0] / 255, tint[1] / 255, tint[2] / 255);13681440 }1369- clone.color.multiply(1370- new THREE.Color(shell[0], shell[1], shell[2]),1371- );13721441 const i = effect.intensity;13731442 if (effect.kind === "gloss") {13741443 clone.roughness *= 1 - 0.7 * i;13751444 clone.metalness = Math.min(1, clone.metalness + 0.35 * i);1445+ clone.envMap = marbleEnv(this.ctx.renderer);1446+ clone.envMapIntensity = 0.6;13761447 } else if (effect.kind === "pearl") {13771448 clone.roughness = 0.15;13781449 clone.metalness = 0.5 * i;13791450 clone.color.lerp(new THREE.Color(1, 1, 1), 0.25 * i);1451+ clone.envMap = marbleEnv(this.ctx.renderer);1452+ clone.envMapIntensity = 0.6;13801453 } else if (effect.kind === "ember") {13811454 clone.emissive = clone.color.clone().multiplyScalar(0.5 * i);13821455 } else if (effect.kind === "ghost") {
@@ -1574,6 +1647,12 @@ class Session {
15741647 (this.peerMesh.material as THREE.Material).dispose();15751648 this.peerMesh = null;15761649 }1650+ if (this.peerShellMesh) {1651+ this.ctx.scene.remove(this.peerShellMesh);1652+ this.peerShellMesh.geometry.dispose();1653+ (this.peerShellMesh.material as THREE.Material).dispose();1654+ this.peerShellMesh = null;1655+ }15771656 this.racers = [];15781657 // Session teardown is immediate — no exit waves on a seed change.15791658 for (const seg of [...this.built.values()]) {
A src/client/marbleGlass.ts +170 -0
@@ -0,0 +1,170 @@
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).19+ *20+ * Tunnels: glass tunnels use NON-transmission transparent glass — a21+ * transmissive arch would erase transmissive marbles behind it (only22+ * opaque objects land in the transmission buffer); alpha-blend keeps23+ * the pack visible through the arch. Wire tunnels are the wireframe24+ * lattice of the same arch mesh.25+ */26+import * as THREE from "three";27+import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";28+import type { RacerLook } from "./peersCore.ts";29+30+let env: THREE.Texture | null = null;31+32+/** One shared PMREM environment for every glass material (lazy). */33+export function marbleEnv(renderer: THREE.WebGLRenderer): THREE.Texture {34+ if (!env) {35+ const pmrem = new THREE.PMREMGenerator(renderer);36+ env = pmrem.fromScene(new RoomEnvironment()).texture;37+ pmrem.dispose();38+ }39+ return env;40+}41+42+/** Kit-marble shell glass: transmission with the phenotype SHELL as the43+ * absorption tint (white shell = clear glass); ghost marbles run a long44+ * attenuation distance (clearer), pearls carry iridescence. */45+export function glassShellMaterial(46+ renderer: THREE.WebGLRenderer,47+ look: RacerLook,48+): THREE.MeshPhysicalMaterial {49+ const ghost = look.effect.kind === "ghost";50+ return new THREE.MeshPhysicalMaterial({51+ color: 0xffffff,52+ transmission: 1,53+ transparent: false,54+ roughness: 0.04,55+ metalness: 0,56+ ior: 1.5,57+ thickness: 0.2,58+ attenuationColor: new THREE.Color(59+ look.shell[0],60+ look.shell[1],61+ look.shell[2],62+ ),63+ attenuationDistance: ghost ? 0.6 : 0.15,64+ clearcoat: 1,65+ clearcoatRoughness: 0.06,66+ envMap: marbleEnv(renderer),67+ envMapIntensity: 1.2,68+ iridescence: look.effect.kind === "pearl"69+ ? 0.35 + 0.5 * look.effect.intensity70+ : 0,71+ });72+}73+74+/** Additive fresnel shell for instanced packs: rim-only glass illusion,75+ * order-independent (no sorting artifacts at any N). Declares the76+ * instancing attributes itself (raw ShaderMaterial gets none for free);77+ * per-instance tint via instanceColor. */78+export function fresnelShellMaterial(): THREE.ShaderMaterial {79+ return new THREE.ShaderMaterial({80+ transparent: true,81+ blending: THREE.AdditiveBlending,82+ depthWrite: false,83+ vertexShader: `84+ // instanceMatrix/instanceColor are injected by three's prelude for85+ // materials on an InstancedMesh — declaring them again collides.86+ varying vec3 vN;87+ varying vec3 vW;88+ varying vec3 vTint;89+ void main() {90+ vTint = instanceColor;91+ vec4 wp = modelMatrix * instanceMatrix * vec4(position, 1.0);92+ vW = wp.xyz;93+ vN = normalize(mat3(modelMatrix) * mat3(instanceMatrix) * normal);94+ gl_Position = projectionMatrix * viewMatrix * wp;95+ }96+ `,97+ fragmentShader: `98+ varying vec3 vN;99+ varying vec3 vW;100+ varying vec3 vTint;101+ void main() {102+ vec3 V = normalize(cameraPosition - vW);103+ float f = pow(1.0 - abs(dot(normalize(vN), V)), 3.0);104+ vec3 c = vTint * f * 1.35 + vec3(0.55) * f * f;105+ gl_FragColor = vec4(c, 1.0);106+ }107+ `,108+ });109+}110+111+/** Deterministic per-placement hash (position is seed material — the112+ * same recipe as island color jitter). */113+function posHash(x: number, y: number, z: number): number {114+ let h = Math.imul(Math.round(x * 8) | 0, 374761393) ^115+ Math.imul(Math.round(y * 8) | 0, 668265263) ^116+ Math.imul(Math.round(z * 8) | 0, 2147483647);117+ h = Math.imul(h ^ (h >>> 13), 1274126177);118+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;119+}120+121+/** Tunnel render variants (user round): some arches draw as GLASS122+ * (alpha-blend translucent — the pack stays visible racing through),123+ * some as WIRE lattice. Position-hashed → deterministic per seed, no124+ * generation impact. Returns disposables for the segment. */125+export function applyTunnelVariants(126+ group: THREE.Object3D,127+ renderer: THREE.WebGLRenderer,128+): { meshes: THREE.InstancedMesh[]; dispose(): void } {129+ const cloned: THREE.Material[] = [];130+ group.traverse((o: THREE.Object3D) => {131+ const m = o as THREE.Mesh;132+ if (!m.isMesh || m.name !== "tunnel") return;133+ const p = m.getWorldPosition(new THREE.Vector3());134+ const h = posHash(p.x, p.y, p.z);135+ if (h < 0.2) {136+ // Glass arch: tinted alpha glass (non-transmission — see header).137+ const mat = new THREE.MeshPhysicalMaterial({138+ color: 0xbfe2f5,139+ transparent: true,140+ opacity: 0.32,141+ roughness: 0.08,142+ metalness: 0,143+ clearcoat: 1,144+ clearcoatRoughness: 0.1,145+ envMap: marbleEnv(renderer),146+ envMapIntensity: 1.0,147+ side: THREE.DoubleSide,148+ depthWrite: false,149+ });150+ cloned.push(mat);151+ m.material = mat;152+ } else if (h < 0.34) {153+ // Wire arch: the lattice cage.154+ const base = (Array.isArray(m.material)155+ ? m.material[0]156+ : m.material) as THREE.MeshStandardMaterial;157+ const mat = base.clone();158+ mat.wireframe = true;159+ mat.color.multiplyScalar(0.85);160+ cloned.push(mat);161+ m.material = mat;162+ }163+ });164+ return {165+ meshes: [],166+ dispose() {167+ for (const m of cloned) m.dispose();168+ },169+ };170+}
M src/client/physics/autopilot.ts +16 -4
M src/client/scene.ts +3 -0
M tests/autopilot_test.ts +32 -0
@@ -128,6 +128,38 @@ Deno.test("autopilot: airborne control is weak and horizontal", () => {
128128 assert(Math.hypot(...f) <= 5.01, "air authority must be ≤ AIR_ACCEL");129129 });130130 131+Deno.test("autopilot: crest lookahead never thrusts into the floor", () => {132+ // Ramp up then immediately down: the lookahead point sits BELOW the133+ // marble past the crest. Pursuit must steer yaw only — a 3D seek aimed134+ // the pilot into the crest face (accelerate-toward-floor jitter).135+ const ys: number[] = [];136+ for (let i = 0; i < 30; i++) {137+ ys.push(i < 10 ? i * 0.25 : Math.max(0, (20 - i) * 0.25));138+ }139+ const rail = railOf(ys);140+ // Mid-climb two stations before the crest, rolling up-slope at speed —141+ // the lookahead lands well down the far side.142+ const idx = 8;143+ const t = rail.tans[idx];144+ const pos: [number, number, number] = [145+ rail.pts[idx][0],146+ rail.pts[idx][1] + 0.1,147+ 0,148+ ];149+ const f = autopilotForce(rail, idx, pos, [t[0] * 3, t[1] * 3, 0], 1);150+ assert(f !== null, "climb must produce force");151+ // The force may press along the slope but never predominantly DOWN152+ // into the floor: its downward part must stay below its horizontal153+ // magnitude (a 3D seek gave a dominant −y here).154+ const horiz = Math.hypot(f[0], f[2]);155+ assert(156+ f[1] > -horiz,157+ `force aims into the floor: fy=${f[1].toFixed(2)} vs horiz=${158+ horiz.toFixed(2)159+ }`,160+ );161+});162+131163 Deno.test("boost economy: spend on triggers with headroom, recharge on restraint", () => {132164 const dt = 1 / 60;133165 // Full tank + headroom + a rival ahead → ignition.