/* ============================================================================ MASSFRONT — RIGID BODY PHYSICS ---------------------------------------------------------------------------- WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT This is a compact impulse-based rigid-body solver: real mass, a real inertia tensor, real angular velocity carried on a quaternion, and contacts resolved with normal + Coulomb-friction impulses applied AT THE CONTACT POINT — which is the whole reason a piece of rubble tumbles instead of sliding flat. It is NOT a general physics engine and it is not trying to be one. It runs for DESTRUCTION ONLY: debris thrown off a hit, the pieces a destroyed structure breaks into, and the shove an explosion gives to both. Everything else in this game deliberately stays parametric: * unit movement and pathing (flow field, thousands of agents) * projectiles (analytic arcs) * sparks / embers / smoke / fire (the type 0-6,8-10 particle ring) * the textured shatter shards (shX/shY in sim.js — billboards) * terrain deformation and water A full rigid-body sim per unit would cost more than the entire rest of the frame and would buy nothing the player can see. Impactful destruction is the goal; a physics engine is not. WHY A PURPOSE-BUILT SOLVER AND NOT RAPIER/WASM 1. The OTA path is TEXT ONLY. src/updater.js does `out[f.path]=new TextDecoder().decode(bytes)`, boot.js's validBundle() requires `typeof b.files[path]==='string'`, and runBundle() hands each one to `new Blob([src],{type:'text/javascript'})`. A .wasm cannot survive that round trip — TextDecoder mangles non-UTF-8 bytes irreversibly. Shipping wasm would mean base64 inside a .js (+33%, ~3.4 MB for rapier3d) listed in assets/data/manifest.json's `order`, which tools/publish-hf-release.ps1 gates byte-for-byte. 2. Instantiating wasm is ASYNCHRONOUS. Every system here self-initialises synchronously inside one global scope in manifest order, and main.js's init loop has no await point. There is nowhere for `await init()` to live without restructuring the boot contract. 3. The body count that matters is ~100-200 pieces of debris, not 10k. What Rapier is good at (broad phase, convex-convex, joints, islands) is not what this needs; what this needs is heightfield contact, which is 30 lines. The JS<->wasm transform marshalling per body per step on an Android WebView is a real cost that would eat the theoretical win. 4. APK size is actively being fought (tools/shrink-apk.ps1). 1.5 MB of binary for tumbling rubble is a bad trade. BUDGET (measured, see mfPhysStats().stepMs) perfScale <= 0.34 -> 40 bodies perfScale <= 0.50 -> 96 bodies (the target device sits here, 0.4125) otherwise -> 224 bodies Sleeping bodies cost approximately nothing: they are skipped by the integrator, the contact solver and the ground query alike. RENDERING This file does not own a shader and does not touch the renderer's files. Each body is drawn as a small CLUSTER of chunk instances pushed into an existing lit instanced stream (FX.wreck — solid pass, depth tested, already a CSM shadow caster; FX.rock and FX.crate are the fallbacks). The cluster's chunk positions are transformed by the body's FULL rotation matrix, so three-axis tumble is genuinely visible even though the instance stream carries only a yaw. That is a deliberate workaround: instance attribute 10 is taken by Material V2 surface state and the vertex stage only knows `aYaw` (src/engine/mesh.js). Attribute slots 11-15 are free; see the report for the one-attribute change that would let a single instance carry a full orientation. InstMesh accumulates until flush() and flush() zeroes n, so instances added before render() reaches its flush are drawn. mfPhysEmit() therefore runs once per FRAME (not per sim step — the accumulator in main.js can take zero steps in a frame, which would strobe the debris). ============================================================================ */ /* --------------------------------------------------------------------------- Pool. Structure-of-arrays: the step touches BZ/BVZ for every body and nothing else, so keeping them in separate typed arrays is what makes a sleeping field free. --------------------------------------------------------------------------- */ const MFPHYS_MAX = 256; // hard pool ceiling (allocation, not budget) const MFPHYS_CHUNKS = 6; // render chunks per body const MFPHYS_EVENT_MAX = 3; // one grouped debris layer = at most 3 readable slabs const MFPHYS_G = 290; // wu/s^2 — matches SH_G / DEBRIS_G exactly const MFPHYS_MAX_V = 650; // cosmetic rubble must never become a simulation projectile const MFPHYS_MAX_W = 32; // rad/s; above this rotation aliases at RTS scale const mfpX = new Float32Array(MFPHYS_MAX), mfpY = new Float32Array(MFPHYS_MAX), mfpZ = new Float32Array(MFPHYS_MAX); const mfpVX = new Float32Array(MFPHYS_MAX), mfpVY= new Float32Array(MFPHYS_MAX), mfpVZ= new Float32Array(MFPHYS_MAX); const mfpQX = new Float32Array(MFPHYS_MAX), mfpQY= new Float32Array(MFPHYS_MAX), mfpQZ= new Float32Array(MFPHYS_MAX), mfpQW=new Float32Array(MFPHYS_MAX); const mfpWX = new Float32Array(MFPHYS_MAX), mfpWY= new Float32Array(MFPHYS_MAX), mfpWZ= new Float32Array(MFPHYS_MAX); const mfpHX = new Float32Array(MFPHYS_MAX), mfpHY= new Float32Array(MFPHYS_MAX), mfpHZ= new Float32Array(MFPHYS_MAX); const mfpIM = new Float32Array(MFPHYS_MAX); // inverse mass const mfpIIX= new Float32Array(MFPHYS_MAX), mfpIIY=new Float32Array(MFPHYS_MAX), mfpIIZ=new Float32Array(MFPHYS_MAX); // inverse inertia, body diag const mfpLife=new Float32Array(MFPHYS_MAX), mfpTTL=new Float32Array(MFPHYS_MAX); const mfpRest=new Float32Array(MFPHYS_MAX), mfpFric=new Float32Array(MFPHYS_MAX); const mfpSleepT=new Float32Array(MFPHYS_MAX); const mfpR=new Uint8Array(MFPHYS_MAX), mfpG=new Uint8Array(MFPHYS_MAX), mfpB=new Uint8Array(MFPHYS_MAX); const mfpState=new Uint8Array(MFPHYS_MAX); // 0 free, 1 awake, 2 asleep const mfpNCh =new Uint8Array(MFPHYS_MAX); const mfpTrail=new Uint8Array(MFPHYS_MAX); // render-only velocity line; never a sim particle const mfpSeq =new Float64Array(MFPHYS_MAX); // spawn order, for eviction /* Local ground plane cache: g(x,y) ~= g0 + gx*(x-ax) + gy*(y-ay). terrainH() is nine rawH() taps; sampling it per corner per step would be the single most expensive thing in this file. Three taps per body per refresh, then every corner is two multiplies. */ const mfpG0=new Float32Array(MFPHYS_MAX), mfpGX=new Float32Array(MFPHYS_MAX), mfpGY=new Float32Array(MFPHYS_MAX); const mfpAX=new Float32Array(MFPHYS_MAX), mfpAY=new Float32Array(MFPHYS_MAX); const mfpGT=new Float32Array(MFPHYS_MAX); // seconds until the plane is re-sampled /* Chunk offsets in BODY space + chunk radius: ox,oy,oz,r */ const mfpCh=new Float32Array(MFPHYS_MAX*MFPHYS_CHUNKS*4); let mfpLive=0, mfpAwake=0, mfpSeqNext=1; let mfpStepMs=0, mfpEmitMs=0, mfpChunksDrawn=0; let mfpHooked=false, mfpEmittedFrame=-1, mfpFrame=0; let mfpEnabled=true; /* Physics is presentation-only, so it must not consume Math.random() and perturb gameplay's random stream. A private xorshift32 makes destruction reproducible for a map, event order and quality tier without adding any save/replay/network field. mfPhysClear() re-seeds it at match reset. */ let mfpSeedBase=0x6d2b79f5, mfpRandState=mfpSeedBase; const mfpAudit={ steps:0, spawns:0, retired:0, invalidRetires:0, evictions:0, budgetTrims:0, motionClamps:0, groundQueries:0, burstEvents:0, collapseEvents:0, blastEvents:0, impulseEvents:0, impulseHits:0, rigidPieces:0, attractEvents:0, attractHits:0, attractConsumed:0, attractClamps:0, attractWakeups:0, attractPeakAccel:0, groupClamps:0, maxGroup:0, maxLive:0, rngDraws:0, emitCalls:0, emittedChunks:0, velocityTrails:0, pausedEmitSkips:0, offscreenRetires:0, subpixelRetires:0, acceleratedLife:0 }; function mfPhysHashWord(h,v){ h^=v>>>0; return Math.imul(h,16777619)>>>0; } function mfPhysWorldSeed(){ let h=2166136261>>>0; const key=(typeof curMap==='string'&&curMap)?curMap:'massfront'; for(let i=0;i>>0; else{ const key=String(seed); s=2166136261>>>0; for(let i=0;i>>0)||0x6d2b79f5; mfpRandState=mfpSeedBase; return mfpSeedBase; } function mfPhysRand(){ let x=mfpRandState>>>0; x^=x<<13; x^=x>>>17; x^=x<<5; mfpRandState=(x>>>0)||0x6d2b79f5; mfpAudit.rngDraws++; return mfpRandState/4294967296; } function mfPhysResetAudit(){ for(const k in mfpAudit) mfpAudit[k]=0; } /* Sleep thresholds. Deliberately generous: rubble that keeps micro-jittering reads as broken, and every sleeping body is a body the solver skips. */ const MFPHYS_SLEEP_V=2.20, MFPHYS_SLEEP_W=0.90, MFPHYS_SLEEP_T=0.34; function mfPhysGround(x,y){ return (typeof terrainH==='function') ? terrainH(x,y) : 0; } /* How many bodies this device may keep alive at once. Read live rather than cached: perfScale moves with load and with the graphics preset. */ function mfPhysBudget(){ const ps=(typeof perfScale==='number'&&perfScale>0)?perfScale:1; if(ps<=0.34) return 40; if(ps<=0.50) return 96; return 224; } /* Camera-aware retirement is presentation policy only: it changes no damage, collision, salvage or gameplay state. One camBounds sample is shared by the complete step so the policy does not turn into a per-body layout query. */ let mfpViewClass=0,mfpViewPx=999; function mfPhysViewSample(i,B){ const x=mfpX[i],y=mfpY[i],r=Math.max(mfpHX[i],mfpHY[i],mfpHZ[i]); mfpViewClass=0;mfpViewPx=999; if(B&&Number.isFinite(B.x0)&&Number.isFinite(B.x1)&&Number.isFinite(B.y0)&&Number.isFinite(B.y1)){ const dx=xB.x1?x-B.x1:0; const dy=yB.y1?y-B.y1:0; const w=Math.max(1,B.x1-B.x0),h=Math.max(1,B.y1-B.y0); mfpViewClass=(dx<=60+r&&dy<=60+r)?0:(dx<=w*.42+120&&dy<=h*.42+120?1:2); const vh=(typeof innerHeight==='number'&&innerHeight>0)?innerHeight:720; mfpViewPx=r*2*vh/h; }else if(typeof orthoSpan==='number'&&orthoSpan>0){ const vh=(typeof innerHeight==='number'&&innerHeight>0)?innerHeight:720; mfpViewPx=r*2*vh/orthoSpan; } return mfpViewClass; } function mfPhysCamBounds(){ if(typeof camBounds!=='function')return null; try{return camBounds()||null;}catch(err){return null;} } function mfPhysScaledTTL(i,base){ const ps=Math.max(.2,Math.min(1,(typeof perfScale==='number'&&perfScale>0)?perfScale:1)); const pressure=Math.max(0,Math.min(1,mfpLive/Math.max(1,mfPhysBudget()))); const view=mfPhysViewSample(i,mfPhysCamBounds()); let scale=(.70+.30*ps)*(1-.36*pressure*pressure); if(view===1)scale*=.72;else if(view===2)scale*=.46; if(mfpViewPx<1.5)scale*=.72; return Math.max(1.35,base*scale); } function mfPhysSampleGround(i){ const x=mfpX[i], y=mfpY[i], d=6; const g0=mfPhysGround(x,y); mfpG0[i]=g0; mfpGX[i]=(mfPhysGround(x+d,y)-g0)/d; mfpGY[i]=(mfPhysGround(x,y+d)-g0)/d; mfpAX[i]=x; mfpAY[i]=y; mfpGT[i]=0.28+((i*0.0137)%0.22); // staggered so refreshes never bunch mfpAudit.groundQueries+=3; } function mfPhysPlaneAt(i,x,y){ return mfpG0[i]+mfpGX[i]*(x-mfpAX[i])+mfpGY[i]*(y-mfpAY[i]); } /* --------------------------------------------------------------------------- Allocation. When the pool is at budget the OLDEST body goes — sleeping ones first, because a settled chunk of rubble disappearing behind the player is far less noticeable than one vanishing mid-flight. --------------------------------------------------------------------------- */ function mfPhysAlloc(){ const budget=mfPhysBudget(); let free=-1; for(let i=0;i=0&&mfpLive=0){ mfpLive++; return free; } return -1; } mfpAudit.evictions++; return victim; } /* perfScale can fall after a dense scene is already alive. Allocation alone cannot enforce the new lower budget, so trim deterministically: oldest sleeping rubble first, then oldest airborne rubble. This runs only on a tier transition (normally one frame), not as a permanent O(n^2) cost. */ function mfPhysTrimToBudget(){ const budget=mfPhysBudget(); let removed=0; while(mfpLive>budget){ let victim=-1, best=Infinity, victimAsleep=false; for(let i=0;i0) mfpAwake--; mfpState[victim]=0; mfpLive--; removed++; } mfpAudit.budgetTrims+=removed; mfpAudit.retired+=removed; return removed; } /* --------------------------------------------------------------------------- PUBLIC: spawn one rigid body. o = {hx,hy,hz half extents (wu) vx,vy,vz launch velocity (wu/s) wx,wy,wz launch angular velocity (rad/s) r,g,b tint 0..255 mass, restitution, friction, ttl, chunks} Returns the body handle, or -1 when the budget refuses it. --------------------------------------------------------------------------- */ function mfPhysSpawn(x,y,z,o){ if(!mfpEnabled) return -1; o=o||{}; const i=mfPhysAlloc(); if(i<0) return -1; const hx=Math.max(0.35,o.hx||1.4), hy=Math.max(0.35,o.hy||1.4), hz=Math.max(0.30,o.hz||1.0); const m=Math.max(0.25,o.mass||(hx*hy*hz*2.4)); mfpX[i]=x; mfpY[i]=y; mfpZ[i]=z; mfpVX[i]=o.vx||0; mfpVY[i]=o.vy||0; mfpVZ[i]=o.vz||0; /* Random start orientation: a slab that always begins axis-aligned reads as a spawned prop, not as something that just came off a building. */ const a=mfPhysRand()*Math.PI*2, b=mfPhysRand()*Math.PI*2, c=mfPhysRand()*Math.PI*2; const ca=Math.cos(a*0.5), sa=Math.sin(a*0.5), cb=Math.cos(b*0.5), sb=Math.sin(b*0.5), cc=Math.cos(c*0.5), sc=Math.sin(c*0.5); mfpQW[i]=ca*cb*cc+sa*sb*sc; mfpQX[i]=sa*cb*cc-ca*sb*sc; mfpQY[i]=ca*sb*cc+sa*cb*sc; mfpQZ[i]=ca*cb*sc-sa*sb*cc; mfpWX[i]=o.wx!==undefined?o.wx:(mfPhysRand()*2-1)*7; mfpWY[i]=o.wy!==undefined?o.wy:(mfPhysRand()*2-1)*7; mfpWZ[i]=o.wz!==undefined?o.wz:(mfPhysRand()*2-1)*7; mfpHX[i]=hx; mfpHY[i]=hy; mfpHZ[i]=hz; mfpIM[i]=1/m; /* Solid box inertia about the centre of mass. Full extents are 2h, so I = m/12 * ((2a)^2 + (2b)^2) = m/3 * (a^2 + b^2). */ mfpIIX[i]=3/(m*(hy*hy+hz*hz)); mfpIIY[i]=3/(m*(hx*hx+hz*hz)); mfpIIZ[i]=3/(m*(hx*hx+hy*hy)); const baseTTL=o.ttl||18; mfpTTL[i]=mfPhysScaledTTL(i,baseTTL); mfpLife[i]=mfpTTL[i]; mfpRest[i]=o.restitution!==undefined?o.restitution:0.22; mfpFric[i]=o.friction!==undefined?o.friction:0.62; mfpSleepT[i]=0; mfpR[i]=o.r!==undefined?o.r:150; mfpG[i]=o.g!==undefined?o.g:142; mfpB[i]=o.b!==undefined?o.b:126; mfpTrail[i]=o.trail?1:0; mfpState[i]=1; mfpSeq[i]=mfpSeqNext++; mfPhysSampleGround(i); /* Chunk cluster. Laid along the body's LONGEST axis so a slab flipping end over end is unmistakable; a cube gets a compact clump. */ const want=Math.max(1,Math.min(MFPHYS_CHUNKS,o.chunks||(hx>2.2||hy>2.2?4:2))); mfpNCh[i]=want; const base=i*MFPHYS_CHUNKS*4; const longX=hx>=hy&&hx>=hz, longY=hy>=hx&&hy>=hz; for(let k=0;kmfpAudit.maxLive) mfpAudit.maxLive=mfpLive; mfPhysHook(); return i; } function mfPhysWake(i){ if(mfpState[i]===2){ mfpState[i]=1; mfpSleepT[i]=0; } } /* Rotate a body-space vector into world space by the body's quaternion. */ function mfPhysRot(i,vx,vy,vz,out){ const qx=mfpQX[i],qy=mfpQY[i],qz=mfpQZ[i],qw=mfpQW[i]; const tx=2*(qy*vz-qz*vy), ty=2*(qz*vx-qx*vz), tz=2*(qx*vy-qy*vx); out[0]=vx+qw*tx+(qy*tz-qz*ty); out[1]=vy+qw*ty+(qz*tx-qx*tz); out[2]=vz+qw*tz+(qx*ty-qy*tx); return out; } /* Inverse rotation — world into body space. */ function mfPhysUnrot(i,vx,vy,vz,out){ const qx=-mfpQX[i],qy=-mfpQY[i],qz=-mfpQZ[i],qw=mfpQW[i]; const tx=2*(qy*vz-qz*vy), ty=2*(qz*vx-qx*vz), tz=2*(qx*vy-qy*vx); out[0]=vx+qw*tx+(qy*tz-qz*ty); out[1]=vy+qw*ty+(qz*tx-qx*tz); out[2]=vz+qw*tz+(qx*ty-qy*tx); return out; } const _mfpA=[0,0,0], _mfpB=[0,0,0], _mfpC=[0,0,0]; /* World-space I^-1 * L, without ever forming the 3x3: rotate into body space, scale by the diagonal, rotate back. */ function mfPhysInvInertiaMul(i,lx,ly,lz,out){ mfPhysUnrot(i,lx,ly,lz,_mfpB); _mfpB[0]*=mfpIIX[i]; _mfpB[1]*=mfpIIY[i]; _mfpB[2]*=mfpIIZ[i]; return mfPhysRot(i,_mfpB[0],_mfpB[1],_mfpB[2],out); } /* Apply an impulse J at world offset r from the centre of mass. dv = J/m, dw = I^-1 (r x J). This is the single line that makes debris tumble: an off-centre hit produces torque, and nothing else in this game's FX has ever produced torque. */ function mfPhysApplyImpulse(i,jx,jy,jz,rx,ry,rz){ const im=mfpIM[i]; mfpVX[i]+=jx*im; mfpVY[i]+=jy*im; mfpVZ[i]+=jz*im; const tx=ry*jz-rz*jy, ty=rz*jx-rx*jz, tz=rx*jy-ry*jx; mfPhysInvInertiaMul(i,tx,ty,tz,_mfpC); mfpWX[i]+=_mfpC[0]; mfpWY[i]+=_mfpC[1]; mfpWZ[i]+=_mfpC[2]; /* Bound pathological chain blasts. Beyond these speeds a one-frame streak is all the player sees, while contact impulses and quaternion integration become needlessly unstable. Direction and momentum ratio are preserved. */ const v2=mfpVX[i]*mfpVX[i]+mfpVY[i]*mfpVY[i]+mfpVZ[i]*mfpVZ[i]; if(v2>MFPHYS_MAX_V*MFPHYS_MAX_V){ const s=MFPHYS_MAX_V/Math.sqrt(v2); mfpVX[i]*=s; mfpVY[i]*=s; mfpVZ[i]*=s; mfpAudit.motionClamps++; } const w2=mfpWX[i]*mfpWX[i]+mfpWY[i]*mfpWY[i]+mfpWZ[i]*mfpWZ[i]; if(w2>MFPHYS_MAX_W*MFPHYS_MAX_W){ const s=MFPHYS_MAX_W/Math.sqrt(w2); mfpWX[i]*=s; mfpWY[i]*=s; mfpWZ[i]*=s; mfpAudit.motionClamps++; } } /* --------------------------------------------------------------------------- THE STEP. Semi-implicit Euler for the integrator, sequential impulses for the contacts. Contacts are the eight box corners against the cached local ground plane; the plane's normal is the terrain's real slope, so a chunk thrown onto a hillside slides downhill and settles against the grade rather than balancing on a flat imaginary floor. --------------------------------------------------------------------------- */ function mfPhysStep(dt){ if(!mfpEnabled||mfpLive<=0){ mfpAwake=0; mfpStepMs=mfpStepMs*0.9; return; } if(!(dt>0)) return; if(dt>0.1) dt=0.1; // never integrate a stall mfPhysTrimToBudget(); if(mfpLive<=0){ mfpAwake=0; return; } const t0=performance.now(); let awake=0, live=0; const viewB=mfPhysCamBounds(); const ps=Math.max(.2,Math.min(1,(typeof perfScale==='number'&&perfScale>0)?perfScale:1)); const pressure=Math.max(0,Math.min(1,mfpLive/Math.max(1,mfPhysBudget()))); const pressureAge=1+(1-ps)*.42+pressure*.68; mfpAudit.steps++; for(let i=0;i0)mfpAudit.offscreenRetires++;if(subpixel)mfpAudit.subpixelRetires++; continue; } /* Sleeping bodies are skipped entirely, but the ground under them can still move — a crater opening beneath settled rubble has to relaunch it. One cheap re-sample per second, staggered. */ if(st===2){ mfpGT[i]-=dt; if(mfpGT[i]<=0){ const before=mfPhysPlaneAt(i,mfpX[i],mfpY[i]); mfPhysSampleGround(i); mfpGT[i]+=0.85; if(Math.abs(mfPhysPlaneAt(i,mfpX[i],mfpY[i])-before)>0.35) mfPhysWake(i); } continue; } awake++; /* ---- integrate ---- */ mfpVZ[i]-=MFPHYS_G*dt; const air=1-Math.min(0.45,0.42*dt); // air drag only; ground drag is friction mfpVX[i]*=air; mfpVY[i]*=air; mfpX[i]+=mfpVX[i]*dt; mfpY[i]+=mfpVY[i]*dt; mfpZ[i]+=mfpVZ[i]*dt; /* quaternion: q' = q + 0.5 * (w as pure quaternion) * q * dt */ const wx=mfpWX[i], wy=mfpWY[i], wz=mfpWZ[i]; let qx=mfpQX[i], qy=mfpQY[i], qz=mfpQZ[i], qw=mfpQW[i]; const h=dt*0.5; const nx=qx+h*( wx*qw + wy*qz - wz*qy); const ny=qy+h*(-wx*qz + wy*qw + wz*qx); const nz=qz+h*( wx*qy - wy*qx + wz*qw); const nw=qw+h*(-wx*qx - wy*qy - wz*qz); const ql=Math.sqrt(nx*nx+ny*ny+nz*nz+nw*nw)||1; qx=nx/ql; qy=ny/ql; qz=nz/ql; qw=nw/ql; mfpQX[i]=qx; mfpQY[i]=qy; mfpQZ[i]=qz; mfpQW[i]=qw; mfpWX[i]*=air; mfpWY[i]*=air; mfpWZ[i]*=air; /* ---- ground plane refresh ---- */ mfpGT[i]-=dt; const moved=Math.abs(mfpX[i]-mfpAX[i])+Math.abs(mfpY[i]-mfpAY[i]); if(mfpGT[i]<=0||moved>5) mfPhysSampleGround(i); /* ---- contacts: eight corners vs the local plane ---- */ const gx=mfpGX[i], gy=mfpGY[i]; let nlen=Math.sqrt(gx*gx+gy*gy+1); const Nx=-gx/nlen, Ny=-gy/nlen, Nz=1/nlen; const hx=mfpHX[i], hy=mfpHY[i], hz=mfpHZ[i]; const e=mfpRest[i], mu=mfpFric[i]; let deepest=0, support=0; for(let iter=0;iter<2;iter++){ for(let c=0;c<8;c++){ const cx=(c&1)?hx:-hx, cy=(c&2)?hy:-hy, cz=(c&4)?hz:-hz; mfPhysRot(i,cx,cy,cz,_mfpA); const rx=_mfpA[0], ry=_mfpA[1], rz=_mfpA[2]; const px=mfpX[i]+rx, py=mfpY[i]+ry, pz=mfpZ[i]+rz; const g=mfPhysPlaneAt(i,px,py); const pen=(g-pz)*Nz; // perpendicular depth /* SUPPORT is proximity, not penetration. Counting only penetrating corners meant the positional correction below lifted the body out of "contact" every other step, the count alternated, and the sleep timer could never run to completion — measured 0/9 asleep. */ if(iter===0&&pen>-0.45){ support++; if(pen>deepest) deepest=pen; } if(pen<=0) continue; /* relative velocity at the contact point */ const vpx=mfpVX[i]+(mfpWY[i]*rz-mfpWZ[i]*ry); const vpy=mfpVY[i]+(mfpWZ[i]*rx-mfpWX[i]*rz); const vpz=mfpVZ[i]+(mfpWX[i]*ry-mfpWY[i]*rx); const vn=vpx*Nx+vpy*Ny+vpz*Nz; if(vn>=0) continue; /* effective mass along the normal: 1/m + N . ((I^-1 (r x N)) x r) */ let ax=ry*Nz-rz*Ny, ay=rz*Nx-rx*Nz, az=rx*Ny-ry*Nx; mfPhysInvInertiaMul(i,ax,ay,az,_mfpC); const kx=_mfpC[1]*rz-_mfpC[2]*ry, ky=_mfpC[2]*rx-_mfpC[0]*rz, kz=_mfpC[0]*ry-_mfpC[1]*rx; const kn=mfpIM[i]+(kx*Nx+ky*Ny+kz*Nz); if(kn<=1e-6) continue; /* Restitution only above a threshold: applying it to a 0.2 wu/s micro-contact is what makes resting bodies buzz forever. */ const bounce=(-vn>60)?e:0; const jn=Math.max(0,-(1+bounce)*vn/kn); mfPhysApplyImpulse(i,Nx*jn,Ny*jn,Nz*jn,rx,ry,rz); /* Coulomb friction along the tangential slip direction. This is what stops a landed chunk from skating, and what converts a glancing landing into a roll. */ const vpx2=mfpVX[i]+(mfpWY[i]*rz-mfpWZ[i]*ry); const vpy2=mfpVY[i]+(mfpWZ[i]*rx-mfpWX[i]*rz); const vpz2=mfpVZ[i]+(mfpWX[i]*ry-mfpWY[i]*rx); const vn2=vpx2*Nx+vpy2*Ny+vpz2*Nz; let tx2=vpx2-vn2*Nx, ty2=vpy2-vn2*Ny, tz2=vpz2-vn2*Nz; const tl=Math.sqrt(tx2*tx2+ty2*ty2+tz2*tz2); if(tl>0.02){ tx2/=tl; ty2/=tl; tz2/=tl; let bx=ry*tz2-rz*ty2, by=rz*tx2-rx*tz2, bz=rx*ty2-ry*tx2; mfPhysInvInertiaMul(i,bx,by,bz,_mfpC); const fx=_mfpC[1]*rz-_mfpC[2]*ry, fy=_mfpC[2]*rx-_mfpC[0]*rz, fz=_mfpC[0]*ry-_mfpC[1]*rx; const kt=mfpIM[i]+(fx*tx2+fy*ty2+fz*tz2); if(kt>1e-6){ const jt=Math.min(mu*jn,tl/kt); mfPhysApplyImpulse(i,-tx2*jt,-ty2*jt,-tz2*jt,rx,ry,rz); } } } } /* Positional correction. Only the deepest corner, only the excess past a slop band, and only partially — a full correction per corner ejects the body off the ground and the whole pile pops. */ if(deepest>0.03) mfpZ[i]+=Math.min(1.2,(deepest-0.03)*0.55)/Math.max(0.35,Nz); /* ---- sleep ---- */ const sv=Math.abs(mfpVX[i])+Math.abs(mfpVY[i])+Math.abs(mfpVZ[i]); const sw=Math.abs(mfpWX[i])+Math.abs(mfpWY[i])+Math.abs(mfpWZ[i]); /* mfpSleepT counts CONTINUOUS GROUND SUPPORT, not "time spent already slow". The earlier version reset it whenever the body was still moving, so a slab sliding down a grade steeper than atan(friction), or one rocking on a corner, reset it forever: measured 41 of 96 bodies still awake after fourteen seconds. Rubble that never stops is not an option. Damping now ramps WITH the supported time — negligible during the landing and the first roll, decisive after a second — and a body that has been on the ground for three seconds is put down regardless. */ if(support>0) mfpSleepT[i]+=dt; else mfpSleepT[i]=0; if(support>0){ const ramp=Math.min(1,mfpSleepT[i]/1.1); const rd=1-Math.min(0.62,(0.9+5.4*ramp*ramp)*dt); mfpVX[i]*=rd; mfpVY[i]*=rd; mfpVZ[i]*=rd; mfpWX[i]*=rd; mfpWY[i]*=rd; mfpWZ[i]*=rd; } const slow=sv=1&&((slow&&mfpSleepT[i]>=MFPHYS_SLEEP_T)||mfpSleepT[i]>=3.0)){ mfpState[i]=2; mfpSleepT[i]=0; mfpVX[i]=mfpVY[i]=mfpVZ[i]=0; mfpWX[i]=mfpWY[i]=mfpWZ[i]=0; mfpGT[i]=0.85; awake--; } /* A body that has fallen through the world (map edge, a torn heightfield) is retired rather than integrated forever. */ if(mfpZ[i]<-400||!Number.isFinite(mfpX[i])||!Number.isFinite(mfpY[i])|| !Number.isFinite(mfpZ[i])||!Number.isFinite(mfpVX[i])|| !Number.isFinite(mfpVY[i])||!Number.isFinite(mfpVZ[i])){ mfpState[i]=0; live--; awake--; mfpAudit.retired++; mfpAudit.invalidRetires++; } } mfpLive=live; mfpAwake=awake; /* Exponential average — a single frame's number is noise on a WebView. */ mfpStepMs=mfpStepMs*0.86+(performance.now()-t0)*0.14; } /* --------------------------------------------------------------------------- PUBLIC: explosion impulse. Real momentum transfer, applied at an off-centre point so a blast SPINS what it shoves instead of sliding it. --------------------------------------------------------------------------- */ function mfPhysImpulse(x,y,z,radius,power){ if(!mfpEnabled||mfpLive<=0) return 0; mfpAudit.impulseEvents++; const r2=radius*radius; let hit=0; for(let i=0;ir2) continue; const d=Math.sqrt(d2)||0.001; const fall=1-d/radius; mfPhysWake(i); /* Upward bias: a ground blast lifts as much as it pushes, and rubble that only slides outward reads as a wind gust. */ const mass=1/Math.max(1e-5,mfpIM[i]); const mag=power*fall*fall*mass*0.85; const ux=dx/d, uy=dy/d, uz=Math.max(0.35,dz/d+0.55); const ul=Math.sqrt(ux*ux+uy*uy+uz*uz)||1; const off=Math.max(mfpHX[i],mfpHY[i],mfpHZ[i])*0.7; mfPhysApplyImpulse(i,ux/ul*mag,uy/ul*mag,uz/ul*mag, (mfPhysRand()*2-1)*off,(mfPhysRand()*2-1)*off,(mfPhysRand()*2-1)*off); hit++; } mfpAudit.impulseHits+=hit; return hit; } /* --------------------------------------------------------------------------- PUBLIC: bounded attraction field. This is deliberately an acceleration, not an outward-style impulse multiplied by body mass: every loose chunk should visibly fall into a singularity, while the authoritative unit mass resistance remains in sim.js. `tangent` adds an accretion-orbit component; `consumeRadius` retires cosmetic matter that crosses the horizon. --------------------------------------------------------------------------- */ function mfPhysAttract(x,y,z,radius,strength,dt,tangent,consumeRadius){ if(!mfpEnabled||mfpLive<=0||!Number.isFinite(x)||!Number.isFinite(y)|| !Number.isFinite(z)||!Number.isFinite(radius)||!Number.isFinite(strength)|| !Number.isFinite(dt)||!(dt>0)||!(radius>0)||!(strength>0)) return 0; mfpAudit.attractEvents++; /* The positional form is the shipped compatibility surface: attract(x,y,z,radius,strength,dt,orbit,consumeRadius) New callers may pass an options object in `orbit` without creating a second singularity API. All limits are presentation-only and deterministic. */ const o=tangent&&typeof tangent==='object'?tangent:null; let orbit=o?o.orbit:tangent; orbit=Number.isFinite(orbit)?Math.max(-1.5,Math.min(1.5,orbit)):0; let horizon=o?o.consumeRadius:consumeRadius; horizon=Number.isFinite(horizon)?Math.max(0,Math.min(radius*.85,horizon)):0; const maxConsume=Math.max(0,Math.min(16,Math.floor(o&&Number.isFinite(o.maxConsume)?o.maxConsume:6))); const maxAccel=Math.max(1,Math.min(2400,o&&Number.isFinite(o.maxAcceleration)?o.maxAcceleration:strength)); const maxSpeed=Math.max(8,Math.min(MFPHYS_MAX_V,o&&Number.isFinite(o.maxSpeed)?o.maxSpeed:MFPHYS_MAX_V)); const verticalScale=Math.max(0,Math.min(1.5,o&&Number.isFinite(o.verticalScale)?o.verticalScale:1)); const step=Math.min(dt,1/15); if(step!==dt||maxAccel!==strength) mfpAudit.attractClamps++; const r2=radius*radius; const horizon2=horizon*horizon; let hit=0, consumed=0; for(let i=0;ir2) continue; if(horizon>0&&d2<=horizon2&&consumedmaxSpeed*maxSpeed){ const s=maxSpeed/Math.sqrt(v2); mfpVX[i]*=s;mfpVY[i]*=s;mfpVZ[i]*=s; mfpAudit.motionClamps++;mfpAudit.attractClamps++; } mfpSleepT[i]=0; if(accel>mfpAudit.attractPeakAccel) mfpAudit.attractPeakAccel=accel; hit++; } mfpAudit.attractHits+=hit; mfpAudit.attractConsumed+=consumed; return hit+consumed; } /* --------------------------------------------------------------------------- PUBLIC: a burst of rigid debris. `size` is the source's world scale. --------------------------------------------------------------------------- */ function mfPhysBurst(x,y,z,size,o){ if(!mfpEnabled) return 0; o=o||{}; mfpAudit.burstEvents++; const ps=(typeof perfScale==='number'&&perfScale>0)?perfScale:1; const room=Math.max(0,mfPhysBudget()-mfpLive); const raw=Math.max(0,Math.round(o.count!==undefined?o.count:Math.max(1,2+size*0.30))); const requested=Math.min(MFPHYS_EVENT_MAX,raw); if(raw>requested) mfpAudit.groupClamps++; let n=requested>0?Math.max(1,Math.round(requested*Math.max(0.45,ps))):0; n=Math.min(n,room); if(n<=0) return 0; if(n>mfpAudit.maxGroup) mfpAudit.maxGroup=n; const sp=o.speed!==undefined?o.speed:(24+size*0.9); const up=o.up!==undefined?o.up:(70+size*1.5); const r=o.r!==undefined?o.r:150, g=o.g!==undefined?o.g:142, b=o.b!==undefined?o.b:126; let aim=0,aimed=false; if(o.direction&&o.direction.length>=2){ const dl=Math.hypot(o.direction[0],o.direction[1]); if(dl>.0001){aim=Math.atan2(o.direction[1]/dl,o.direction[0]/dl);aimed=true;} } const spread=o.spread!==undefined?Math.max(.05,o.spread):.62; let made=0; for(let k=0;k=0) made++; } mfpAudit.rigidPieces+=made; return made; } /* --------------------------------------------------------------------------- PUBLIC: STRUCTURE COLLAPSE. The current wreck path scales one uniform mesh to 22-38% for fourteen seconds (src/ui/render3d.js:1351). This breaks the same footprint into real 1-3 large slabs that fall, hit the ground, tip over the debris already on it and come to rest at whatever angle they land at. Pieces are biased OUTWARD and DOWNWARD from the structure's own volume, so the pile grows from the footprint instead of erupting from a point. --------------------------------------------------------------------------- */ function mfPhysCollapse(x,y,size,o){ if(!mfpEnabled) return 0; o=o||{}; mfpAudit.collapseEvents++; const ps=(typeof perfScale==='number'&&perfScale>0)?perfScale:1; const room=Math.max(0,mfPhysBudget()-mfpLive); const sz=Math.max(8,Math.min(size||24,72)); const raw=Math.max(0,Math.round(o.count!==undefined?o.count:3)); const requested=Math.min(MFPHYS_EVENT_MAX,raw); if(raw>requested) mfpAudit.groupClamps++; let n=requested>0?Math.max(1,Math.round(requested*Math.max(0.45,ps))):0; n=Math.min(room,n); if(n<=0) return 0; if(n>mfpAudit.maxGroup) mfpAudit.maxGroup=n; const g=mfPhysGround(x,y); const civic=!!o.civic; const r=o.r!==undefined?o.r:(civic?146:132), gg=o.g!==undefined?o.g:(civic?140:128), b=o.b!==undefined?o.b:(civic?128:118); let made=0; for(let k=0;k=0) made++; } mfpAudit.rigidPieces+=made; return made; } /* --------------------------------------------------------------------------- PUBLIC: one call for an explosion — shove what is already there, then throw new fragments. This is what sim.js's spawnExplosion hooks. --------------------------------------------------------------------------- */ function mfPhysBlast(x,y,size,o){ if(!mfpEnabled) return 0; o=o||{}; mfpAudit.blastEvents++; const g=mfPhysGround(x,y); const sz=Math.max(3,size||8); mfPhysImpulse(x,y,g+sz*0.30,sz*2.6,sz*0.36); /* Only hull-scale detonations mint bodies. Every rifle hit calling mfPhysBurst would evict a collapsing factory's rubble within a second — the budget is small on purpose and structure collapse has first claim. */ if(sz<12&&!(o.count>0)) return 0; return mfPhysBurst(x,y,g+sz*0.25,sz, Object.assign({count:Math.min(3,2+Math.round(sz*0.10))},o)); } /* --------------------------------------------------------------------------- RENDER EMIT. Runs once per FRAME. Pushes chunk instances into an existing lit instanced stream, which is flushed later in the same frame by render3d.js. Because the cluster's chunk positions come through the body's full rotation matrix, three-axis tumble is visible with a yaw-only instance stream. --------------------------------------------------------------------------- */ /* {mesh, halfW, cy}: halfW is the model's own half-width in model units and cy the height of its centre above its anchor, both from src/engine/models-world-data.js. The models are BASE-anchored, so without cy every chunk floats half its own height. */ let mfpDraw=null; function mfPhysDrawStream(){ if(typeof FX==='undefined'||!FX) return null; if(mfpDraw&&mfpDraw.mesh) return mfpDraw; /* Rigid debris is fractured material, not a miniature wreck token. The wreck mesh has rectangular panels and a pale cap, so clustered bodies read as tiny cubes around the blast. mdlShard is a closed asymmetric extrusion; multiple overlapping instances form a jagged slab while the solver still owns the body's full 3-axis tumble and terrain contact. */ if(FX.shard) mfpDraw={mesh:FX.shard, halfW:0.62, cy:0.0}; else if(FX.wreck) mfpDraw={mesh:FX.wreck, halfW:8.15, cy:4.0}; else if(FX.rock) mfpDraw={mesh:FX.rock, halfW:10.0, cy:3.6}; else if(FX.crate) mfpDraw={mesh:FX.crate, halfW:10.8, cy:10.0}; else return null; return mfpDraw; } function mfPhysEmit(){ mfpFrame++; mfpAudit.emitCalls++; /* Pausing freezes mfPhysStep, not presentation. Instanced streams are flushed every render, so the same frozen bodies must be re-submitted or every shard disappears the instant a capture/pause overlay opens. */ if(!mfpEnabled||mfpLive<=0){ mfpChunksDrawn=0; return 0; } const D=mfPhysDrawStream(); if(!D||!D.mesh||typeof D.mesh.add!=='function'){ mfpChunksDrawn=0; return 0; } const M=D.mesh; const t0=performance.now(); /* Cull to the camera. camBounds() is the same rectangle the weather and scenery loops use, so debris obeys the same visibility rule they do. */ let bx0=-Infinity,by0=-Infinity,bx1=Infinity,by1=Infinity; if(typeof camBounds==='function'){ try{ const B=camBounds(); if(B){ bx0=B.x0-60; by0=B.y0-60; bx1=B.x1+60; by1=B.y1+60; } }catch(err){} } let drawn=0; for(let i=0;ibx1||yby1) continue; /* Destruction is not intelligence. Enemy rubble can be spawned by combat outside allied vision, so the renderer must obey the same disclosure gate as every other battlefield effect. */ if(typeof fogPointVisible==='function'&&!fogPointVisible(x,y)) continue; /* Fade out over the last second rather than blinking off. */ const a=mfpLife[i]<1?Math.max(0,mfpLife[i])*255:255; if(a<=4) continue; /* One short velocity-aligned streak belongs to this rigid body. It is queued straight into the render stream and creates no particle/entity, so three bodies remain one bounded debris layer and paused renders cannot grow a pool. Only the fast, early ballistic phase receives it. */ const age=1-mfpLife[i]/Math.max(.001,mfpTTL[i]); const flightAge=Math.max(0,mfpTTL[i]-mfpLife[i]); const speed=Math.hypot(mfpVX[i],mfpVY[i],mfpVZ[i]); if(mfpTrail[i]&&mfpState[i]===1&&flightAge<.72&&speed>18&& typeof FX!=='undefined'&&FX.beam){ const lag=Math.min(.13,.050+flightAge*.11),tx=x-mfpVX[i]*lag,ty=y-mfpVY[i]*lag; const floor=mfPhysPlaneAt(i,tx,ty)+.35,tz=Math.max(floor,mfpZ[i]-mfpVZ[i]*lag); const fade=Math.max(0,1-flightAge/.72); const w=Math.max(.46,Math.min(1.40,Math.max(mfpHX[i],mfpHY[i],mfpHZ[i])*.16)); const tr=Math.max(146,mfpR[i]),tg=Math.max(104,mfpG[i]),tb=Math.max(68,mfpB[i]); /* Soft heated wake plus a narrow leading velocity line. Both are render-only stamps on this one body—no pool growth and no extra logical layer. The paired widths remain readable against fire and disappear before the first bounce. */ if(typeof addBeamRibbon==='function'&&typeof sprites!=='undefined'&&sprites.glow){ addBeamRibbon(sprites.glow,tx,tz,ty,x,mfpZ[i],y,w*1.55,tr,tg,tb,26+44*fade,150); addBeamRibbon(sprites.glow,tx,tz,ty,x,mfpZ[i],y,w*.48,255,218,164,54+72*fade,150); }else if(typeof addBeam3D==='function'){ addBeam3D(FX.beam,tx,tz,ty,x,mfpZ[i],y,w,tr,tg,tb,72+98*fade, {projectile:1,noMuzzle:true}); } mfpAudit.velocityTrails++; } /* Yaw for the instance stream: the heading of the body's own +X axis projected onto the ground plane. Positions carry pitch and roll. */ mfPhysRot(i,1,0,0,_mfpA); const yaw=Math.atan2(_mfpA[1],_mfpA[0]); const nch=mfpNCh[i], base=i*MFPHYS_CHUNKS*4; const r=mfpR[i], g=mfpG[i], b=mfpB[i]; for(let k=0;k>>0; for(let i=0;i>>0).toString(16)).slice(-8); } function mfPhysProbe(reset){ let live=0, finite=true; for(let i=0;i>>0, rngState:mfpRandState>>>0, steps:mfpAudit.steps, spawns:mfpAudit.spawns, retired:mfpAudit.retired, invalidRetires:mfpAudit.invalidRetires, evictions:mfpAudit.evictions, budgetTrims:mfpAudit.budgetTrims, motionClamps:mfpAudit.motionClamps, groundQueries:mfpAudit.groundQueries, burstEvents:mfpAudit.burstEvents, collapseEvents:mfpAudit.collapseEvents, blastEvents:mfpAudit.blastEvents, impulseEvents:mfpAudit.impulseEvents, impulseHits:mfpAudit.impulseHits, attractEvents:mfpAudit.attractEvents, attractHits:mfpAudit.attractHits, attractConsumed:mfpAudit.attractConsumed, attractClamps:mfpAudit.attractClamps, attractWakeups:mfpAudit.attractWakeups, attractPeakAccel:mfpAudit.attractPeakAccel, rigidPieces:mfpAudit.rigidPieces, groupClamps:mfpAudit.groupClamps, rngDraws:mfpAudit.rngDraws, emitCalls:mfpAudit.emitCalls, emittedChunks:mfpAudit.emittedChunks, velocityTrails:mfpAudit.velocityTrails, pausedEmitSkips:mfpAudit.pausedEmitSkips, offscreenRetires:mfpAudit.offscreenRetires,subpixelRetires:mfpAudit.subpixelRetires, acceleratedLife:mfpAudit.acceleratedLife }; if(reset) mfPhysResetAudit(); return out; } /* Read-only view for any system that wants to draw or query bodies itself — a renderer with a real orientation attribute, an audio impact layer, or a test harness. cb(handle, view) with view reused between calls. */ const _mfpView={i:0,x:0,y:0,z:0,vx:0,vy:0,vz:0,wx:0,wy:0,wz:0, qx:0,qy:0,qz:0,qw:1,hx:0,hy:0,hz:0,life:0,ttl:0,trail:false,asleep:false}; function mfPhysForEach(cb){ let n=0; for(let i=0;i