Research-first per user direction (docs/physics.md): - Box3D: identifyEdges suppresses ghost collisions (docs+samples), meshes are ONE-SIDED (proven by minimal repro: winding [0,2,1] collides up), weldVertices recommended, b3CreateMesh can fail - shim now returns a code and the engine throws instead of silently dropping strips. - Marble Blast (MBHaxe source, 99% identical physics): gravity 20 at our scale, restitution 0.5 with bounce thresholds, HARD acceleration toward target velocity (authority = feel), air control 5, powerups as timed parameter overrides, gravity pads rotate the gravity vector. Ribbon trimesh (smooth channel) built + gated OFF (USE_RIBBON=false): the isolated square collides correctly but the full ribbon does not in either winding - isolated ribbon repro is the documented next step. The proven M10 slab law is live; 65/65 tests green.
- commit
- fb04301fa7da42c7879b374911c242c2ccf0fe78
- parent
- df69540bc1eb2dc9749c4fe1cf634e91143ff83b
- author
- Dami <dami.etoile@gmail.com>
| A | docs/ | 57 | +++++++++ |
| M | src/ | 31 | +++++ |
| M | src/ | 31 | +++++ |
| M | src/ | 188 | ++++++++++++++++++++++-------- |
| M | tools/ | 4 | +- |
@@ -0,0 +1,57 @@
1+# Marble physics research + design (M11)2+3+## Research findings4+5+**Box3D meshes** (docs/collision.md, sample_mesh.cpp, pinned commit):6+7+- Trimesh via b3MeshDef → b3CreateMesh → b3CreateMeshShape (static only).8+- `identifyEdges=true` suppresses internal-edge (ghost) collisions — the modern9+ equivalent of Marble Blast's smooth convex-brush interiors.10+- Docs recommend `weldVertices=true` (tolerance ~1mm). Samples pass a11+ degenerate-triangle capacity; b3CreateMesh can fail → NEVER ignore a null12+ return (our first ribbon dropped whole strips silently = holes).13+- Per-triangle `materialIndices` + b3ShapeDef materials = native per-surface14+ friction/restitution → the future icy/boost surface hook.15+- Character mover doc: roundness beats soup — capsules/spheres slide because16+ they're convex; keep the marble a dynamic sphere.17+18+**Marble Blast recipe** (MBHaxe src/Marble.hx — "99% identical physics"):19+20+- gravity 20 (marble r≈0.2 → ~our world scale): gravity-DOMINANT feel.21+- Restitution 0.5 with minBounceVel 0.1 / minBounceSpeed 3 (thresholded — no22+ micro-bounce); shock absorber 0.01, super bounce 0.9 (timed override).23+- Rolling: accelerate HARD toward a target velocity (maxRoll 15, angular accel24+ 75 ≈ linear 15) — responsiveness comes from authority, not speed.25+- Friction: kinetic 0.7, static 1.1, bounce-kinetic 0.2. Air control 5.26+- Powerups are cheap parameter/impulse mutations with timers (helicopter =27+ gravity ×0.25). Gravity pads rotate the gravity vector.28+29+## Our design mapping30+31+- Geometry: ONE welded ribbon trimesh per strip (identifyEdges), cups for drop32+ shafts, boxes for islands. Mesh creation failures are LOUD.33+- Feel: gravity −20; assist = strong acceleration (≈8) toward target cruise,34+ climb carry sized by rail lookahead, soft brake above max; restitution ~0.3535+ on the marble.36+- Per-tile modifiers (next): rail stations inherit def-family tags →37+ speed/gravity multipliers; per-triangle materials for icy later.38+- Powerup pattern (later): timed overrides {gravityScale, restitution, impulse}39+ — Marble Blast's proven cheap trick. Island gravity rotation = rotating the40+ world gravity vector while anchored.41+42+## Ribbon debug state (next session)43+44+- Shim mesh path WORKS: 2-triangle repro (scratchpad mesh-repro.mjs) collides45+ with winding [0,2,1] (one-sided; CW in (x,z) viewed from +y = up-facing46+ collision). ph_add_mesh is loud on failure now.47+- The FULL ribbon holds the marble in NEITHER winding — the discrepancy is48+ unexplained. NEXT: isolated repro — build ribbonOf() output for a synthetic49+ straight 10-unit path, drop a marble in node, inspect. Suspects: vertex50+ ordering per ring vs winding assumption, weld collapsing rings, triangle51+ indices crossing ring boundaries wrongly, or scale (verts are fine floats).52+- USE_RIBBON=false gates it in proxies.ts; slab law is live and proven (65/65).53+ Adopt Marble Blast constants (g=20, accel 8, restitution 0.35 thresholded)54+ TOGETHER WITH the ribbon once it holds — recorded values in the section above.55+- Then: per-tile modifiers via rail-station def tags (speed/gravity) and56+ per-triangle materials (icy); Marble Blast powerup pattern; island gravity57+ rotation.@@ -39,12 +39,21 @@ export interface PhysicsModule {
3939 vz: number,4040 radius: number,4141 ): void;42+ _ph_add_mesh(43+ vertsPtr: number,44+ vertCount: number,45+ trisPtr: number,46+ triCount: number,47+ ): number;4248 _ph_marble_force(fx: number, fy: number, fz: number): void;4349 _ph_marble_mass(): number;4450 _ph_step(dt: number, substeps: number): void;4551 _ph_marble_state(outPtr: number): void;4652 _malloc(bytes: number): number;53+ _free(ptr: number): void;4754 HEAPF64: Float64Array;55+ HEAPF32: Float32Array;56+ HEAP32: Int32Array;4857 }4958 5059 export interface MarbleState {@@ -54,6 +63,8 @@ export interface MarbleState {
5463 }5564 5665 export const MARBLE_RADIUS = 0.2;66+/** Proven M10 value. Marble Blast runs g=20 at our scale — adopt WITH the67+ * smooth ribbon + restitution thresholds, not before (docs/physics.md). */5768 export const GRAVITY_Y = -10;5869 /** Fixed physics clock: 60 Hz × 4 substeps (the spike's validated rate). */5970 export const FIXED_DT = 1 / 60;@@ -117,6 +128,26 @@ export class PhysicsEngine {
117128 addSegment(key: number, proxies: readonly CollisionProxy[]): void {118129 this.m._ph_begin_static(key);119130 for (const p of proxies) {131+ if (p.kind === "mesh") {132+ // Box3D copies vertex data into its own BVH storage — the heap133+ // staging buffers are freed immediately.134+ const vp = this.m._malloc(p.vertices.length * 4);135+ this.m.HEAPF32.set(p.vertices, vp / 4);136+ const ip = this.m._malloc(p.indices.length * 4);137+ this.m.HEAP32.set(p.indices, ip / 4);138+ const ok = this.m._ph_add_mesh(139+ vp,140+ p.vertices.length / 3,141+ ip,142+ p.indices.length / 3,143+ );144+ this.m._free(vp);145+ this.m._free(ip);146+ if (ok !== 1) {147+ throw new Error(`mesh creation failed for segment ${key} (${ok})`);148+ }149+ continue;150+ }120151 if (p.kind === "box") {121152 this.m._ph_add_box(122153 p.center[0],@@ -16,9 +16,13 @@ static b3WorldId world;
1616 static b3BodyId marble;1717 static int marbleAlive = 0;1818 19+20+1921 static int refKeys[MAX_REFS];2022 static b3BodyId refBodies[MAX_REFS];2123 static int refUsed[MAX_REFS];24+static b3MeshData* refMeshes[MAX_REFS][MAX_MESHES];25+static int refMeshCount[MAX_REFS];2226 static b3ShapeDef shapeDef;2327 static int pendingSlot = -1;2428 @@ -48,9 +52,34 @@ void ph_begin_static(int key) {
4852 refBodies[slot] = b3CreateBody(world, &def);4953 refKeys[slot] = key;5054 refUsed[slot] = 1;55+ refMeshCount[slot] = 0;5156 pendingSlot = slot;5257 }5358 59+// Smooth channel ribbon: a triangle mesh extruded along the centerline60+// (verts = xyz float triples, indices = CCW int32 triples). Box3D copies61+// vertex data into its own BVH storage; the b3MeshData handle must live62+// as long as the body, so it's tracked per slot.63+EMSCRIPTEN_KEEPALIVE64+int ph_add_mesh(float* verts, int vertCount, int* tris, int triCount) {65+ if (pendingSlot < 0 || refMeshCount[pendingSlot] >= MAX_MESHES) return -1;66+ b3MeshDef md = {0};67+ md.vertices = (b3Vec3*)verts;68+ md.indices = (int32_t*)tris;69+ md.vertexCount = vertCount;70+ md.triangleCount = triCount;71+ md.weldVertices = true;72+ md.weldTolerance = 0.001f;73+ md.useMedianSplit = true; // ribbon rings are grid-like74+ md.identifyEdges = true; // internal-edge smoothing: no seam catching75+ b3MeshData* mesh = b3CreateMesh(&md, 0, 0);76+ if (!mesh) return 0; // caller must treat this as a build error77+ refMeshes[pendingSlot][refMeshCount[pendingSlot]++] = mesh;78+ b3CreateMeshShape(refBodies[pendingSlot], &shapeDef, mesh,79+ (b3Vec3){1, 1, 1});80+ return 1;81+}82+5483 // Axis-aligned box in world space (channel slabs, island blocks); rotated5584 // slabs pass a quarter-turn yaw. Attached to the pending static body.5685 EMSCRIPTEN_KEEPALIVE@@ -93,6 +122,8 @@ void ph_remove_static(int key) {
93122 for (int i = 0; i < MAX_REFS; i++) {94123 if (refUsed[i] && refKeys[i] == key) {95124 b3DestroyBody(refBodies[i]);125+ for (int j = 0; j < refMeshCount[i]; j++) b3DestroyMesh(refMeshes[i][j]);126+ refMeshCount[i] = 0;96127 refUsed[i] = 0;97128 }98129 }@@ -33,7 +33,35 @@ export interface BoxProxy {
3333 half: readonly [number, number, number];3434 }3535 36-export type CollisionProxy = SlabProxy | BoxProxy;36+/** Smooth channel ribbon: one triangle mesh per contiguous strip (M11 —37+ * slab chains made the marble contact awkwardly at every seam; a shared-38+ * vertex extrusion is ONE continuous surface, and Box3D's identifyEdges39+ * removes internal-edge collisions entirely). */40+export interface MeshProxy {41+ kind: "mesh";42+ /** xyz triples. */43+ vertices: number[];44+ /** CCW triples into vertices. */45+ indices: number[];46+}47+48+export type CollisionProxy = SlabProxy | BoxProxy | MeshProxy;49+50+/** Channel cross-section half-profile (lateral, up) in world units at the51+ * slalom scale: flat floor, gutter fillets, near-vertical walls with a52+ * slight outward flare — the shape a marble rolls smoothly in. */53+const PROFILE: readonly (readonly [number, number])[] = [54+ [-0.88, 0.66],55+ [-0.80, 0.52],56+ [-0.74, 0.16],57+ [-0.62, 0.03],58+ [-0.30, 0],59+ [0.30, 0],60+ [0.62, 0.03],61+ [0.74, 0.16],62+ [0.80, 0.52],63+ [0.88, 0.66],64+];3765 3866 /** Sampling step along the centerline, world units. */3967 const STEP = 0.6;@@ -75,7 +103,7 @@ interface Station {
75103 /** Channel slabs for one segment's world-space path. Two passes: sample76104 * every part into stations, then emit — drop shafts need LOOKAHEAD (the77105 * guide tube opens toward the receiver's exit heading). */78-export function channelProxies(path: LoopPath): SlabProxy[] {106+export function channelProxies(path: LoopPath): CollisionProxy[] {79107 const stations: Station[] = [];80108 for (const seg of path.segments) {81109 for (const part of seg.curve) {@@ -123,11 +151,32 @@ export function channelProxies(path: LoopPath): SlabProxy[] {
123151 stations[i] = { ...stations[i], yaw: j >= 0 ? stations[j].yaw : 0 };124152 }125153 126- const out: SlabProxy[] = [];154+ const out: CollisionProxy[] = [];155+ // M11 WIP: the smooth ribbon trimesh is gated OFF until the isolated156+ // ribbon repro settles winding/collision behavior (the 2-triangle mesh157+ // repro collides fine with [0,2,1] winding; the full ribbon holds a158+ // marble in NEITHER winding — see docs/physics.md next steps). Slabs159+ // remain the proven live law.160+ const USE_RIBBON = false;161+ let strip: Station[] = [];162+ const flushStrip = (endsAtShaft: boolean) => {163+ if (strip.length < 2) {164+ strip = [];165+ return;166+ }167+ if (USE_RIBBON) out.push(ribbonOf(strip));168+ else {169+ strip.forEach((st, idx) =>170+ emitSlabs(st, out, endsAtShaft && idx === strip.length - 1)171+ );172+ }173+ strip = [];174+ };127175 for (let i = 0; i < stations.length; i++) {128176 const st = stations[i];129177 const [px, py, pz] = st.p;130178 if (st.vertical) {179+ flushStrip(true);131180 // One CUP PER CONTIGUOUS VERTICAL RUN (per-station emission stacked132181 // overlapping cups that wedged the marble — found the hard way).133182 // Frame: approach yaw; open side: receiver exit yaw.@@ -143,10 +192,6 @@ export function channelProxies(path: LoopPath): SlabProxy[] {
143192 const lz = -Math.sin(st.yaw);144193 const fwdX = Math.sin(st.yaw);145194 const fwdZ = Math.cos(st.yaw);146- // Cup end wall ABOVE the upper floor at the far side of the mouth —147- // at the cell edge (1.0 from the shaft), leaving a full marble-width148- // mouth (a closer wall + floor overhang wedged the marble in the149- // mouth; the contact math nailed it to the millimeter).150195 out.push({151196 kind: "slab",152197 center: [px + fwdX * 1.0, topY + 0.45, pz + fwdZ * 1.0],@@ -154,10 +199,6 @@ export function channelProxies(path: LoopPath): SlabProxy[] {
154199 yaw: st.yaw,155200 pitch: 0,156201 });157- // Guide tube: TIGHT (interior ~±0.5 — a wide tube let the marble158- // miss the receiver floor through a corner pocket), three walls159- // from just under the mouth to just above the receiver floor; the160- // receiver-exit side stays open.161202 const tubeTop = topY - 0.1;162203 const tubeBot = botY + 0.1;163204 const tubeHalf = Math.max(0.15, (tubeTop - tubeBot) / 2);@@ -180,9 +221,7 @@ export function channelProxies(path: LoopPath): SlabProxy[] {
180221 pitch: 0,181222 });182223 }183- // Bottom pan: the full shaft cross-section at receiver-floor level —184- // a falling marble can drift off the rail line and the receiver's185- // own floor only starts AT its rail (a 0.4 gap swallowed abs-1).224+ // Bottom pan: the full shaft cross-section at receiver-floor level.186225 out.push({187226 kind: "slab",188227 center: [px, botY - FLOOR_HALF_T - SINK, pz],@@ -193,49 +232,100 @@ export function channelProxies(path: LoopPath): SlabProxy[] {
193232 i = k - 1; // consume the group194233 continue;195234 }196- // The floor APPROACHING a drop shaft pulls back so it doesn't197- // overhang the mouth (the marble must clear the hole edge cleanly).198- // The RECEIVER floor keeps its full extent — it must reach under the199- // shaft to catch the fall (trimming it opened a slip-through gap).200- const nextVert = i + 1 < stations.length && stations[i + 1].vertical;201- const trim = nextVert ? 0.3 : 0;202- const tx = Math.sin(st.yaw);203- const tz = Math.cos(st.yaw);204- const shift = nextVert ? -trim : 0;235+ strip.push(st);236+ }237+ flushStrip(false);238+ return out;239+}240+241+/** Slab law (proven, M10): floor follows slope; walls are vertical. The242+ * floor slab approaching a drop shaft pulls back so it doesn't overhang243+ * the mouth. */244+function emitSlabs(245+ st: Station,246+ out: CollisionProxy[],247+ trimForward = false,248+): void {249+ const [px, py, pz] = st.p;250+ const trim = trimForward ? 0.3 : 0;251+ const tx = Math.sin(st.yaw);252+ const tz = Math.cos(st.yaw);253+ out.push({254+ kind: "slab",255+ center: [px - tx * trim, py - FLOOR_HALF_T - SINK, pz - tz * trim],256+ half: [FLOOR_HALF_W, FLOOR_HALF_T, Math.max(0.1, st.halfLen - trim)],257+ yaw: st.yaw,258+ pitch: st.pitch,259+ });260+ const lx = Math.cos(st.yaw);261+ const lz = -Math.sin(st.yaw);262+ const off = FLOOR_HALF_W + WALL_HALF_W - 0.04;263+ for (const side of [-1, 1]) {205264 out.push({206265 kind: "slab",207266 center: [208- px + tx * shift,209- py - FLOOR_HALF_T - SINK,210- pz + tz * shift,267+ px + lx * off * side,268+ py + WALL_CENTER_LIFT,269+ pz + lz * off * side,211270 ],212- half: [FLOOR_HALF_W, FLOOR_HALF_T, Math.max(0.1, st.halfLen - trim)],271+ half: [WALL_HALF_W, WALL_HALF_H, st.halfLen + 0.06],213272 yaw: st.yaw,214- pitch: st.pitch,273+ pitch: 0,215274 });216- // Walls are VERTICAL columns (yaw only): pitched walls scissor at217- // slope transitions into overhang pockets that wedge the marble —218- // found the hard way (cua-seed-1 froze at a chute valley). Tall219- // enough to cover the slope span within a sample, dipped below the220- // floor so no gap opens underneath.221- const lx = Math.cos(st.yaw);222- const lz = -Math.sin(st.yaw);223- const off = FLOOR_HALF_W + WALL_HALF_W - 0.04;224- for (const side of [-1, 1]) {225- out.push({226- kind: "slab",227- center: [228- px + lx * off * side,229- py + WALL_CENTER_LIFT,230- pz + lz * off * side,231- ],232- half: [WALL_HALF_W, WALL_HALF_H, st.halfLen + 0.06],233- yaw: st.yaw,234- pitch: 0,235- });275+ }276+}277+278+/** Extrude the channel PROFILE along a strip of stations into one mesh279+ * with shared ring vertices (C0-continuous — no seams to catch on). */280+function ribbonOf(strip: readonly Station[]): MeshProxy {281+ const vertices: number[] = [];282+ const indices: number[] = [];283+ const N = PROFILE.length;284+ let last: Station | null = null;285+ const rings: Station[] = [];286+ for (const st of strip) {287+ if (288+ last &&289+ Math.hypot(290+ st.p[0] - last.p[0],291+ st.p[1] - last.p[1],292+ st.p[2] - last.p[2],293+ ) < 0.05294+ ) continue; // near-duplicate ring → degenerate triangles295+ rings.push(st);296+ last = st;297+ }298+ for (const st of rings) {299+ // Frame from the true tangent: right = up × t, upV = t × right.300+ const ty = Math.sin(-st.pitch);301+ const th = Math.cos(st.pitch);302+ const t = [Math.sin(st.yaw) * th, ty, Math.cos(st.yaw) * th];303+ const rl = Math.hypot(t[2], t[0]) || 1;304+ const right = [t[2] / rl, 0, -t[0] / rl];305+ const upV = [306+ right[1] * t[2] - right[2] * t[1],307+ right[2] * t[0] - right[0] * t[2],308+ right[0] * t[1] - right[1] * t[0],309+ ];310+ for (const [lat, up] of PROFILE) {311+ vertices.push(312+ st.p[0] + right[0] * lat + upV[0] * up,313+ st.p[1] + right[1] * lat + upV[1] * up,314+ st.p[2] + right[2] * lat + upV[2] * up,315+ );236316 }237317 }238- return out;318+ for (let r = 0; r < rings.length - 1; r++) {319+ const a = r * N;320+ const b = (r + 1) * N;321+ for (let j = 0; j < N - 1; j++) {322+ // Winding: Box3D mesh collision is ONE-SIDED — proven empirically323+ // (mesh-repro): this order faces the channel interior (up floors).324+ indices.push(a + j, b + j + 1, b + j);325+ indices.push(a + j, a + j + 1, b + j + 1);326+ }327+ }328+ return { kind: "mesh", vertices, indices };239329 }240330 241331 /** Heights of the cliff block family (world units). */@@ -30,8 +30,8 @@ emcc "$ROOT/src/client/physics/shim.c" \
3030 -O2 \3131 -sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=web,node \3232 -sALLOW_MEMORY_GROWTH=1 \33- -sEXPORTED_FUNCTIONS=_ph_init,_ph_begin_static,_ph_add_box,_ph_add_slab,_ph_end_static,_ph_remove_static,_ph_spawn_marble,_ph_marble_force,_ph_marble_mass,_ph_step,_ph_marble_state,_malloc,_free \34- -sEXPORTED_RUNTIME_METHODS=HEAPF64 \33+ -sEXPORTED_FUNCTIONS=_ph_init,_ph_begin_static,_ph_add_box,_ph_add_slab,_ph_add_mesh,_ph_end_static,_ph_remove_static,_ph_spawn_marble,_ph_marble_force,_ph_marble_mass,_ph_step,_ph_marble_state,_malloc,_free \34+ -sEXPORTED_RUNTIME_METHODS=HEAPF64,HEAPF32,HEAP32 \3535 -o "$ROOT/static/dist/box3d.mjs"3636 3737 echo "built static/dist/box3d.mjs ($(du -h "$ROOT/static/dist/box3d.wasm" | cut -f1))"