clone

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

Four presentation fixes from live user reports

Dami committed dd7b658
- Marble cores TRULY concentric: the marble-center-* piece roots carry
  a +0.2 node translation the plain variants lack — any hardcoded core
  height left those cores hanging at the shell bottom. addMarbleCore
  now anchors to the MEASURED shell bounds (bbox center + radius in the
  mesh's local frame), correct for every variant.
- Camera 'snaps to a default angle, floats back': the follow-cam up
  target could flip to world-up for a frame (missing rail mid-cull /
  mid-respawn). camUp now keeps its last known value when the rail is
  missing and is eased on the supplier side too (6/s) on top of the
  camera's own easing — one-frame flips can no longer slam the roll.
- Twist chute is the TRACK's U, not a paper ribbon: closed section ring
  (collision profile as the blue interior + outer shell offset by the
  wall thickness + flat rim tops + triangulated end caps), extruded
  along the framed stations; outer wears the chapter's pipe color (the
  chapter the line entered the twist with), interior the kit channel
  blue. Two material groups, one mesh.
- Wire tunnels are CHAINLINK, not the model's triangulation: procedural
  diamond-weave alpha-cutout texture over projected UVs ((x+y, z) wraps
  the arch continuously — kit atlas UVs can't tile), cloned geometry
  per wire arch, double-sided, alphaTest 0.5 (no sorting).

Verified in-browser: concentric tinted cores at race distance, thick
yellow/blue corkscrew on #meow + green/blue on #biome-3, chainlink
cages on #meow. 154 tests, check clean.
commit
dd7b658d490f6e95243d6fd8d8f03590d0dc87f1
parent
7992ac40cd86ead1a37c2f1597d08497e3d3c434
author
Dami <dami.etoile@gmail.com>
3 files changed, +221 -61
Msrc/client/endless.ts187+++++++++++++++++++++++-------
Msrc/client/marble.ts4+-
Msrc/client/marbleGlass.ts91++++++++++++--
M src/client/endless.ts +142 -45
@@ -59,10 +59,10 @@ import {
5959 import { racerIdentity } from "./peersCore.ts";6060 import { rngFamily } from "../gen/rng.ts";6161 import {62+ addMarbleCore,6263 applyTunnelVariants,6364 fresnelShellMaterial,6465 glassShellMaterial,65- marbleCoreMesh,6666 marbleEnv,6767 } from "./marbleGlass.ts";6868 import type { Placement } from "../shared/types.ts";
@@ -505,8 +505,10 @@ class Session {
505505 pos: [0, 0, 0] as [number, number, number],506506 bravery: 1,507507 }));508- /** Local up of the camera subject (follow-cam rolls with the frame). */508+ /** Local up of the camera subject (follow-cam rolls with the frame);509+ * target + eased value — one-frame frame flips must never slam it. */509510 private readonly camUp: [number, number, number] = [0, 1, 0];511+ private readonly camUpTarget: [number, number, number] = [0, 1, 0];510512 /** Active diamond branch per segment — respawns alternate it, so a511513 * fall's retry may ride the OTHER branch (respawn variety). */512514 private readonly branchOf = new Map<number, "main" | "alt">();
@@ -601,7 +603,7 @@ class Session {
601603 // refracts (hero: soft white) so the marble reads SOLID, not as a602604 // washed-out bubble (user report). Added before the material pass603605 // so boost glow reaches it.604- mesh.add(marbleCoreMesh());606+ addMarbleCore(mesh);605607 // Own materials (boost glow writes emissive; shared kit materials606608 // would light every piece using them).607609 mesh.traverse((child: THREE.Object3D) => {
@@ -1165,7 +1167,22 @@ class Session {
11651167 // what you ride is what you hit. Violet-charged like gravity11661168 // zones (the set-piece vocabulary already reads that way).11671169 if (fr?.twist) {1168- const mesh = this.buildTwistChute(rail, fr);1170+ // Outer shell wears the chapter the line ENTERED the twist with1171+ // (the nearest pre-twist placement's chapter).1172+ let chapter: number | undefined;1173+ let bestT = -Infinity;1174+ for (const p of data.placements) {1175+ const t = twistParam(fr, p.pos);1176+ if (t < 0.5 && t > bestT && p.chapter !== undefined) {1177+ bestT = t;1178+ chapter = p.chapter;1179+ }1180+ }1181+ const mesh = this.buildTwistChute(1182+ rail,1183+ fr,1184+ chapter !== undefined ? this.chapterColor(chapter) : null,1185+ );11691186 if (mesh) {11701187 this.root.add(mesh);11711188 disposables.push({
@@ -1173,7 +1190,7 @@ class Session {
11731190 dispose: () => {11741191 this.root.remove(mesh);11751192 mesh.geometry.dispose();1176- (mesh.material as THREE.Material).dispose();1193+ for (const m of mesh.material as THREE.Material[]) m.dispose();11771194 },11781195 } as unknown as InstancedBuild);11791196 }
@@ -1228,35 +1245,75 @@ class Session {
12281245 return seg;12291246 }12301247 1231- /** The reorient run's chute: the channel cross-section (the collision1232- * ribbon's own profile) extruded along the rail's FRAMED stations —1233- * each ring rolls a little further, so the chute corkscrews from the1234- * entry frame into the exit frame. What you ride is what you hit. */1235- private buildTwistChute(rail: Rail, fr: SegmentFrame): THREE.Mesh | null {1248+ /** The reorient run's chute: the channel cross-section extruded along1249+ * the rail's FRAMED stations — each ring rolls a little further, so1250+ * the chute corkscrews from the entry frame into the exit frame. NOT1251+ * a paper ribbon (user report): the section is the track's closed1252+ * U — the collision profile as the blue interior, an offset outer1253+ * shell + rim tops + end caps in the chapter's pipe color, exactly1254+ * the bent version of the kit channel look. */1255+ private buildTwistChute(1256+ rail: Rail,1257+ fr: SegmentFrame,1258+ shellRgb: readonly [number, number, number] | null,1259+ ): THREE.Mesh | null {12361260 const tw = fr.twist!;12371261 const frames = rail.frames;12381262 if (!Array.isArray(frames)) return null;1239- const ring: number[] = [];1263+ const rows: number[] = [];12401264 for (let i = 0; i < rail.pts.length; i++) {12411265 const t = twistParam(fr, rail.pts[i]);12421266 if (t < -0.6 || t > tw.len + 0.6) continue;1243- ring.push(i);1267+ rows.push(i);12441268 }1245- if (ring.length < 2) return null;1269+ if (rows.length < 2) return null;1270+1271+ // Closed section ring: inner U (the collision profile, left→right)1272+ // + outer U offset outward by the wall thickness (right→left). The1273+ // ring closes across the two wall tips — the flat rim tops.12461274 const N = CHANNEL_PROFILE.length;1247- const positions = new Float32Array(ring.length * N * 3);1275+ const T = 0.14;1276+ const inner = CHANNEL_PROFILE;1277+ const normals: [number, number][] = inner.map((_, i) => {1278+ const a = inner[Math.max(0, i - 1)];1279+ const b = inner[Math.min(N - 1, i + 1)];1280+ const dx = b[0] - a[0];1281+ const dy = b[1] - a[1];1282+ const l = Math.hypot(dx, dy) || 1;1283+ // Perp of the local direction, oriented AWAY from the channel1284+ // interior (reference point on the centerline above the floor).1285+ let nx = dy / l;1286+ let ny = -dx / l;1287+ if (nx * (inner[i][0] - 0) + ny * (inner[i][1] - 0.5) < 0) {1288+ nx = -nx;1289+ ny = -ny;1290+ }1291+ return [nx, ny];1292+ });1293+ const ring: [number, number][] = [1294+ ...inner.map((p) => [p[0], p[1]] as [number, number]),1295+ ...inner1296+ .map((p, i): [number, number] => [1297+ p[0] + normals[i][0] * T,1298+ p[1] + normals[i][1] * T,1299+ ])1300+ .reverse(),1301+ ];1302+ const R = ring.length; // 2N1303+1304+ const positions = new Float32Array((rows.length + 2) * R * 3);12481305 const scratch: [number, number, number] = [0, 0, 0];12491306 let vi = 0;1250- for (const i of ring) {1307+ const writeRow = (i: number) => {12511308 const p = rail.pts[i];12521309 const tan = rail.tans[i];1253- // The run is straight and flat in plan space — the lateral axis is1254- // the horizontal perp of the tangent, up is +y.1310+ // The run is straight and flat in plan space — lateral is the1311+ // horizontal perp of the tangent, up is +y.12551312 const nl = Math.hypot(tan[0], tan[2]) || 1;12561313 const ux = tan[2] / nl;12571314 const uz = -tan[0] / nl;12581315 const f = frames[i] ?? fr.entry;1259- for (const [lat, up] of CHANNEL_PROFILE) {1316+ for (const [lat, up] of ring) {12601317 scratch[0] = p[0] + ux * lat;12611318 scratch[1] = p[1] + up;12621319 scratch[2] = p[2] + uz * lat;
@@ -1265,32 +1322,57 @@ class Session {
12651322 positions[vi++] = w[1];12661323 positions[vi++] = w[2];12671324 }1268- }1269- const indices: number[] = [];1270- for (let r = 0; r < ring.length - 1; r++) {1271- for (let c = 0; c < N - 1; c++) {1272- const a = r * N + c;1273- const b = a + 1;1274- const c2 = a + N;1275- const d = c2 + 1;1276- indices.push(a, c2, b, b, c2, d);1325+ };1326+ for (const i of rows) writeRow(i);1327+ // Cap rows: duplicated first/last rings so cap shading stays crisp.1328+ writeRow(rows[0]);1329+ writeRow(rows[rows.length - 1]);1330+1331+ const innerIdx: number[] = [];1332+ const shellIdx: number[] = [];1333+ for (let r = 0; r < rows.length - 1; r++) {1334+ for (let c = 0; c < R; c++) {1335+ const c1 = (c + 1) % R;1336+ const a = r * R + c;1337+ const b = r * R + c1;1338+ const a2 = (r + 1) * R + c;1339+ const b2 = (r + 1) * R + c1;1340+ const into = c < N - 1 ? innerIdx : shellIdx;1341+ into.push(a, a2, b, b, a2, b2);12771342 }12781343 }1344+ // End caps: triangulate the (simple, non-convex) section polygon.1345+ const shape = ring.map(([x, y]) => new THREE.Vector2(x, y));1346+ const tris = THREE.ShapeUtils.triangulateShape(shape, []);1347+ const capA = rows.length * R;1348+ const capB = capA + R;1349+ for (const [a, b, c] of tris) {1350+ shellIdx.push(capA + a, capA + c, capA + b); // entry cap faces back1351+ shellIdx.push(capB + a, capB + b, capB + c);1352+ }1353+12791354 const geo = new THREE.BufferGeometry();12801355 geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));1281- geo.setIndex(indices);1356+ geo.setIndex([...innerIdx, ...shellIdx]);1357+ geo.addGroup(0, innerIdx.length, 0);1358+ geo.addGroup(innerIdx.length, shellIdx.length, 1);12821359 geo.computeVertexNormals();1283- const mat = new THREE.MeshStandardMaterial({1284- color: new THREE.Color(1285- GRAVITY_PIPE[0] / 255,1286- GRAVITY_PIPE[1] / 255,1287- GRAVITY_PIPE[2] / 255,1288- ),1360+ // Kit look: fixed channel-interior blue; outer shell in the chapter1361+ // pipe color (stock kit plastic when the chapter kept the classic).1362+ const shell = shellRgb ?? [136, 150, 166];1363+ const matInner = new THREE.MeshStandardMaterial({1364+ color: new THREE.Color(58 / 255, 106 / 255, 200 / 255),1365+ roughness: 0.6,1366+ metalness: 0.05,1367+ side: THREE.DoubleSide,1368+ });1369+ const matShell = new THREE.MeshStandardMaterial({1370+ color: new THREE.Color(shell[0] / 255, shell[1] / 255, shell[2] / 255),12891371 roughness: 0.55,1290- metalness: 0.1,1372+ metalness: 0.05,12911373 side: THREE.DoubleSide,12921374 });1293- const mesh = new THREE.Mesh(geo, mat);1375+ const mesh = new THREE.Mesh(geo, [matInner, matShell]);12941376 mesh.name = "twist-chute";12951377 return mesh;12961378 }
@@ -1481,7 +1563,9 @@ class Session {
14811563 r.lod === "rider" && keep &&14821564 Math.abs(r.s - subjS) < LOD_PROMOTE_S14831565 ) this.promoteRacer(r);1484- else if (r.lod === "rider" && (r.idx === 0 || r.idx === this.followedIdx)) {1566+ else if (1567+ r.lod === "rider" && (r.idx === 0 || r.idx === this.followedIdx)1568+ ) {14851569 this.promoteRacer(r);14861570 }14871571 }
@@ -1985,15 +2069,28 @@ class Session {
19852069 }19862070 this.followedIdx = followed.idx;19872071 // Follow-cam local up (M16): the lens rolls with the subject's1988- // gravity frame (camera.ts eases it; identity frames keep world +y).2072+ // gravity frame. Eased HERE as well as in camera.ts, and a missing2073+ // rail (mid-cull, mid-respawn) keeps the last known up — a one-frame2074+ // flip to world-up read as "snap to a default angle, float back"2075+ // (user report).19892076 const fRail = this.rails.get(followed.nearest.seg);1990- const fFrame = fRail ? railFrameAt(fRail, followed.nearest.idx) : null;1991- if (fFrame) frameUp(fFrame, this.camUp);1992- else {1993- this.camUp[0] = 0;1994- this.camUp[1] = 1;1995- this.camUp[2] = 0;2077+ if (fRail) {2078+ const fFrame = railFrameAt(fRail, followed.nearest.idx);2079+ if (fFrame) frameUp(fFrame, this.camUpTarget);2080+ else {2081+ this.camUpTarget[0] = 0;2082+ this.camUpTarget[1] = 1;2083+ this.camUpTarget[2] = 0;2084+ }19962085 }2086+ const ku = 1 - Math.exp(-6 * Math.max(dt, 1e-4));2087+ this.camUp[0] += (this.camUpTarget[0] - this.camUp[0]) * ku;2088+ this.camUp[1] += (this.camUpTarget[1] - this.camUp[1]) * ku;2089+ this.camUp[2] += (this.camUpTarget[2] - this.camUp[2]) * ku;2090+ const ul = Math.hypot(this.camUp[0], this.camUp[1], this.camUp[2]) || 1;2091+ this.camUp[0] /= ul;2092+ this.camUp[1] /= ul;2093+ this.camUp[2] /= ul;19972094 this.speed = followed.speed;19982095 // The WORLD anchors to the HERO: streaming, culling, milestones and19992096 // respawn fill all key on marbleS = hero progress (ratcheted in the
@@ -2163,7 +2260,7 @@ class Session {
21632260 // Empty kit shell → inject the opaque core (see marbleCoreMesh);21642261 // the traverse below tints it and applies the phenotype effect21652262 // like any other core material.2166- mesh.add(marbleCoreMesh());2263+ addMarbleCore(mesh);21672264 // Always clone materials (boost glow needs its own emissive even21682265 // on untinted marbles). The phenotype layers MULTIPLY over the21692266 // base look: shell tints the color, the effect reshapes the
M src/client/marble.ts +2 -2
M src/client/marbleGlass.ts +77 -14
@@ -78,15 +78,27 @@ export function glassShellMaterial(
7878 7979 /** Kit marbles are NOT authored core+shell (measured 2026-08-09): the8080 * plain variants are an EMPTY glass sphere and the center variants hide81- * only a flat decal disc at the base — nothing solid to refract, so at82- * race distance every marble read as a washed-out bubble (user report).83- * This injects the missing core: an opaque sphere sized like the84- * instanced pack's (0.79 of the shell), added to the kit mesh BEFORE the85- * material pass so identity tint / effects / boost glow all apply to it86- * through the same pipeline. Kit-mesh space: shell radius ≈0.19787- * centered at y 0.2. */88-export function marbleCoreMesh(): THREE.Mesh {89- const geo = new THREE.SphereGeometry(0.155, 20, 14);81+ * only a flat decal disc at the shell's mid-plane — nothing solid to82+ * refract, so at race distance every marble read as a washed-out bubble83+ * (user report). This injects the missing core: an opaque sphere at84+ * 0.78 of the shell, added to the kit mesh BEFORE the material pass so85+ * identity tint / effects / boost glow all apply through the same86+ * pipeline. The core anchors to the MEASURED shell bounds — the87+ * marble-center-* piece roots carry a +0.2 node translation the plain88+ * variants lack (second user report: hardcoding any center left those89+ * cores hanging at the bottom of the shell). */90+export function addMarbleCore(mesh: THREE.Object3D): void {91+ mesh.updateMatrixWorld(true);92+ const box = new THREE.Box3().setFromObject(mesh);93+ if (box.isEmpty()) return;94+ const center = box.getCenter(new THREE.Vector3());95+ const size = box.getSize(new THREE.Vector3());96+ mesh.worldToLocal(center);97+ // Shell radius in the mesh's LOCAL frame (setFromObject measured in98+ // parent space — undo the mesh's own scale).99+ const radius = Math.max(size.x, size.y, size.z) / 2 /100+ (Math.abs(mesh.scale.x) || 1);101+ const geo = new THREE.SphereGeometry(radius * 0.78, 20, 14);90102 const mat = new THREE.MeshStandardMaterial({91103 color: 0xdfe6ee,92104 roughness: 0.35,
@@ -95,8 +107,8 @@ export function marbleCoreMesh(): THREE.Mesh {
95107 mat.name = "core";96108 const m = new THREE.Mesh(geo, mat);97109 m.name = "core";98- m.position.y = 0.2;99- return m;110+ m.position.copy(center);111+ mesh.add(m);100112 }101113 102114 /** Additive fresnel shell for instanced packs: rim-only glass illusion,
@@ -136,6 +148,39 @@ export function fresnelShellMaterial(): THREE.ShaderMaterial {
136148 });137149 }138150 151+let chainlink: THREE.DataTexture | null = null;152+153+/** Procedural chainlink lattice (alpha cutout): a diamond weave, not the154+ * model's triangulation (user report — `wireframe` rendered the literal155+ * mesh edges). Shared by every wire arch. */156+function chainlinkTexture(): THREE.DataTexture {157+ if (chainlink) return chainlink;158+ const S = 64;159+ const P = 16; // weave period160+ const W = 2.2; // strand half-width161+ const data = new Uint8Array(S * S * 4);162+ for (let y = 0; y < S; y++) {163+ for (let x = 0; x < S; x++) {164+ const a = Math.abs(((x + y) % P) - P / 2) < W;165+ const b = Math.abs(((x - y + S) % P) - P / 2) < W;166+ const on = a || b ? 255 : 0;167+ const i = (y * S + x) * 4;168+ data[i] = on;169+ data[i + 1] = on;170+ data[i + 2] = on;171+ data[i + 3] = 255;172+ }173+ }174+ chainlink = new THREE.DataTexture(data, S, S);175+ chainlink.wrapS = THREE.RepeatWrapping;176+ chainlink.wrapT = THREE.RepeatWrapping;177+ chainlink.magFilter = THREE.LinearFilter;178+ chainlink.minFilter = THREE.LinearMipmapLinearFilter;179+ chainlink.generateMipmaps = true;180+ chainlink.needsUpdate = true;181+ return chainlink;182+}183+139184 /** Deterministic per-placement hash (position is seed material — the140185 * same recipe as island color jitter). */141186 function posHash(x: number, y: number, z: number): number {
@@ -155,6 +200,7 @@ export function applyTunnelVariants(
155200 renderer: THREE.WebGLRenderer,156201 ): { meshes: THREE.InstancedMesh[]; dispose(): void } {157202 const cloned: THREE.Material[] = [];203+ const clonedGeos: THREE.BufferGeometry[] = [];158204 group.traverse((o: THREE.Object3D) => {159205 const m = o as THREE.Mesh;160206 if (!m.isMesh || m.name !== "tunnel") return;
@@ -183,14 +229,30 @@ export function applyTunnelVariants(
183229 cloned.push(mat);184230 m.material = mat;185231 } else if (h < 0.34) {186- // Wire arch: the lattice cage.232+ // Wire arch: a chainlink CAGE — alpha-cutout diamond weave over233+ // projected UVs (the kit's atlas UVs can't tile a repeat; the234+ // arch curves in x/y and runs along z, so (x+y, z) projects a235+ // continuous wrap). Geometry is cloned — the kit shares it.236+ const geo = m.geometry.clone();237+ const pos = geo.getAttribute("position");238+ const uv = new Float32Array(pos.count * 2);239+ for (let i = 0; i < pos.count; i++) {240+ uv[i * 2] = (pos.getX(i) + pos.getY(i)) * 3.0;241+ uv[i * 2 + 1] = pos.getZ(i) * 3.0;242+ }243+ geo.setAttribute("uv", new THREE.BufferAttribute(uv, 2));244+ m.geometry = geo;245+ clonedGeos.push(geo);187246 const base = (Array.isArray(m.material)188247 ? m.material[0]189248 : m.material) as THREE.MeshStandardMaterial;190249 const mat = base.clone();191- mat.wireframe = true;250+ mat.map = null;251+ mat.alphaMap = chainlinkTexture();252+ mat.alphaTest = 0.5;253+ mat.side = THREE.DoubleSide;192254 mat.color.multiplyScalar(0.85);193- // Lattice edges on the rim surface: pull the wires forward.255+ // Lattice on the rim surface: pull the weave forward.194256 mat.polygonOffset = true;195257 mat.polygonOffsetFactor = -1;196258 mat.polygonOffsetUnits = -1;
@@ -202,6 +264,7 @@ export function applyTunnelVariants(
202264 meshes: [],203265 dispose() {204266 for (const m of cloned) m.dispose();267+ for (const g of clonedGeos) g.dispose();205268 },206269 };207270 }