code wiki / (root) / nx_softdyn.nx

nx_softdyn.nx source

↩ module page · 165 lines · 10715 B

1// nx_softdyn.nx -- LIB: SOVEREIGN SECONDARY DYNAMICS ("jiggle physics"), the general solver every animated 2// thing inherits. A driven ANCHOR (a bone from the rig, a weapon mount, a hair root) pulls a mass point through 3// a spring-damper; the mass LAGS under acceleration, OVERSHOOTS when the anchor stops, oscillates, and damps to 4// rest. The SAME solver serves: soft-tissue inertia on a moving body, firearm RECOIL (impulse), cloth and hair 5// sway, vehicle suspension. 100% integer + deterministic -> the same motion replays bit-identically. 6// 7// State stride 6 per point: [px,py,pz, vx,vy,vz] all Q8 (model-units * 256). 8// Semi-implicit Euler: a = K*(anchor-p)/1024 - C*v/1024 ; v += a ; p += v 9// K = stiffness per-1024 (how hard tissue is bound to bone), C = damping per-1024 (how fast wobble dies). 10// Underdamped (C < 2*sqrt(K)) => visible jiggle. Critically/over-damped => rigid, no wobble. 11// A hard displacement CLAMP makes divergence impossible BY CONSTRUCTION (tissue can never fly off the body). 12// license_tier: ORIGINAL 13import "nx_syscalls.nx" 14import "nx_vecmath.nx" 15 16const SD_Q8: i64 = 256 17const SD_G: i64 = 1024 18const SD_STRIDE: i64 = 6 19// stability envelope (enforced in sd_step): C must stay under one full per-tick velocity reversal, 20// K under omega*dt < 2. Values outside diverge -- so they are clamped, never honoured. 21const SD_CAPC: i64 = 1000 22const SD_CAPK: i64 = 3600 23 24func sd_abs(v: i64) -> i64 { if v < 0 { return 0 - v } return v } 25func sd_isqrt(v: i64) -> i64 { return vm_isqrt(v) } 26 27// STABILITY ENVELOPE as one callable each, so the per-axis path cannot drift from the scalar one. 28// Extracted from sd_step's own inline clamps (nx_softdyn_gate T7, 2026-07-23) -- same arithmetic, 29// one definition. A config outside the envelope stays IMPOSSIBLE TO REQUEST rather than trusted. 30func sd_clampk(K: i64) -> i64 { var k: i64 = K; if k > SD_CAPK { k = SD_CAPK } if k < 0 { k = 0 } return k } 31func sd_clampc(C: i64) -> i64 { var c: i64 = C; if c > SD_CAPC { c = SD_CAPC } if c < 0 { c = 0 } return c } 32 33// allocate state for n points 34func sd_alloc(n: i64) -> *i64 { return sys_mmap(n*SD_STRIDE*8) as *i64 } 35 36// seat point i exactly at its anchor, at rest (no startup transient) 37func sd_seat(st: *i64, i: i64, ax: i64, ay: i64, az: i64) -> i64 { 38 let b: i64 = i*SD_STRIDE 39 st[b] = ax*SD_Q8; st[b+1] = ay*SD_Q8; st[b+2] = az*SD_Q8 40 st[b+3] = 0; st[b+4] = 0; st[b+5] = 0 41 return 0 42} 43 44// inject an impulse (velocity kick) -- RECOIL, a hit, a footfall. Units: model-units/tick * 256. 45func sd_impulse(st: *i64, i: i64, ix: i64, iy: i64, iz: i64) -> i64 { 46 let b: i64 = i*SD_STRIDE 47 st[b+3] = st[b+3] + ix; st[b+4] = st[b+4] + iy; st[b+5] = st[b+5] + iz 48 return 0 49} 50 51// ★TRUNCATION DEAD-ZONE SNAP (found 2026-08-03 by nx_softtissue_conserve_gate T1 + nx_cg_restprobe). 52// Both force terms are integer divisions by SD_G that TRUNCATE toward zero, so the damping term is 53// exactly 0 whenever |v|*C < SD_G and the spring term is exactly 0 whenever |e|*K < SD_G. Inside BOTH 54// dead zones the solver applies NO force at all and the point coasts ballistically forever: measured 55// as a perfect period-30 limit cycle at +-52 q8 (+-3.2 mm at 2.0 Hz) still running undecayed after 56// 200,000 ticks with a stationary anchor. A damped system that cannot reach its own rest state is a 57// perpetual-motion bug, not a rounding detail -- the tissue never stops moving on a standing character. 58// The remedy acts ONLY where the model already computes zero force, so it cannot alter any trajectory 59// that carries real force: it replaces "coast forever" with the one physically correct force-free 60// outcome, rest. Thresholds are DERIVED from K, C and SD_G (no magic numbers). Guarded on kk>0 and 61// cc>0 because with no spring there is no rest pose to snap to, and with no damping rest is genuinely 62// NOT an attractor -- an undamped oscillator must keep oscillating. 63func sd_deadsnap(st: *i64, b: i64, axis: i64, q: i64, kk: i64, cc: i64) -> i64 { 64 if kk <= 0 { return 0 } 65 if cc <= 0 { return 0 } 66 let e: i64 = sd_abs(st[b+axis] - q) 67 let v: i64 = sd_abs(st[b+3+axis]) 68 if e*kk < SD_G { if v*cc < SD_G { st[b+axis] = q; st[b+3+axis] = 0 } } 69 return 0 70} 71 72// one integration tick against anchor (ax,ay,az) in MODEL UNITS. maxd = displacement clamp (model units). 73func sd_step(st: *i64, i: i64, ax: i64, ay: i64, az: i64, K: i64, C: i64, maxd: i64) -> i64 { 74 return sd_step_ax(st, i, ax, ay, az, K, K, K, K, C, C, C, C, maxd) 75} 76// ANISOTROPIC + SIGN-ASYMMETRIC step -- the ONE integrator. sd_step above is exactly this with 77// equal axes and a symmetric vertical pair, so every existing caller stays bit-identical BY 78// CONSTRUCTION rather than by promise. Real tissue is neither isotropic nor sign-symmetric, and 79// a single scalar K/C cannot express either. Both facts are CITED ROWS, never tuned here: 80// knowledge/gamefeel_oracle.conf tissue_aniso_ap_permil / tissue_aniso_ml_permil 81// (mills2025-ejss, CC-BY): vertical (SI) carries the largest excursion; anterior-posterior 82// and medio-lateral run ~610 / ~600 per-mille of it. One K for all three axes asserts 1000. 83// knowledge/gamefeel_oracle.conf tissue_k_up_n_per_m 70..78 vs tissue_k_down_n_per_m 620..700 84// (cai2018-j-biomech-67-137, model RMSE <= 2.6pct vs real running): a piecewise mass-spring 85// -damper roughly 9x STIFFER BELOW static equilibrium than above it. The conf also states 86// the implementation: ONE SIGN TEST ON DISPLACEMENT SELECTS THE BRANCH, exact in integers. 87func sd_step_ax(st: *i64, i: i64, ax: i64, ay: i64, az: i64, 88 kx: i64, kyu: i64, kyd: i64, kz: i64, 89 cx: i64, cyu: i64, cyd: i64, cz: i64, maxd: i64) -> i64 { 90 return sd_step_axq(st, i, ax*SD_Q8, ay*SD_Q8, az*SD_Q8, 91 kx, kyu, kyd, kz, cx, cyu, cyd, cz, maxd) 92} 93// SUB-UNIT ANCHOR ENTRY POINT. Identical arithmetic, but the anchor arrives ALREADY IN Q8 so a 94// caller that derives an anchor from another point's CURRENT position -- a HAIR CHAIN, where each 95// segment hangs off the one above it -- does not lose that position to model-unit truncation at 96// every hop. A chain built on the model-unit entry point quantises its own coupling away: the 97// parent's sub-unit motion is floored before the child ever sees it, so the tip of a long strand 98// reads as still while the root is visibly moving. sd_step_ax above is exactly this with the 99// multiply applied, so every existing caller stays ARITHMETICALLY identical BY CONSTRUCTION: the 100// old body's first act was `let qx = ax*SD_Q8`, and that multiply is now the argument expression. 101// THE BINARY IS NOT BYTE-IDENTICAL AND SAYING SO WOULD BE A FALSE CLAIM -- extracting a function 102// adds a symbol and a delegating frame. MEASURED across this exact edit: nx_softbind_gate went 103// 35,485 -> 35,915 B (+430), sha a5d872b0 -> 0adef123. A const-rename refactor rebuilds byte- 104// identical; a function extraction cannot, so the proof owed here is BEHAVIOURAL, not byte, and 105// it is nx_softbind_gate staying GREEN across the change. 106func sd_step_axq(st: *i64, i: i64, qx: i64, qy: i64, qz: i64, 107 kx: i64, kyu: i64, kyd: i64, kz: i64, 108 cx: i64, cyu: i64, cyd: i64, cz: i64, maxd: i64) -> i64 { 109 let b: i64 = i*SD_STRIDE 110 // ★STABILITY ENVELOPE, ENFORCED (found by nx_softdyn_gate T7, 2026-07-23): with semi-implicit Euler the 111 // damping term is a PER-TICK velocity multiplier -- C >= SD_G inverts and amplifies velocity (divergence), 112 // and K > SD_CAPK breaks omega*dt < 2. Clamping here makes an unstable config IMPOSSIBLE TO REQUEST rather 113 // than trusting every caller to know the envelope. 114 // THE SIGN TEST: the vertical branch is selected by where the tissue currently sits relative 115 // to its anchor. Below it (dy < 0) the suspensory ligament and skin carry tension -- the stiff 116 // row. This is the whole of the cai2018 piecewise model and it is exact in integers. 117 var ky: i64 = kyu 118 var cy: i64 = cyu 119 if st[b+1] - qy < 0 { ky = kyd; cy = cyd } 120 let kkx: i64 = sd_clampk(kx); let kky: i64 = sd_clampk(ky); let kkz: i64 = sd_clampk(kz) 121 let ccx: i64 = sd_clampc(cx); let ccy: i64 = sd_clampc(cy); let ccz: i64 = sd_clampc(cz) 122 // spring toward anchor + viscous damping, per axis 123 let accx: i64 = ((qx - st[b])*kkx)/SD_G - (st[b+3]*ccx)/SD_G 124 let accy: i64 = ((qy - st[b+1])*kky)/SD_G - (st[b+4]*ccy)/SD_G 125 let accz: i64 = ((qz - st[b+2])*kkz)/SD_G - (st[b+5]*ccz)/SD_G 126 st[b+3] = st[b+3] + accx; st[b+4] = st[b+4] + accy; st[b+5] = st[b+5] + accz 127 st[b] = st[b] + st[b+3]; st[b+1] = st[b+1] + st[b+4]; st[b+2] = st[b+2] + st[b+5] 128 // HARD CLAMP: tissue can never separate further than maxd from bone -- divergence impossible by construction 129 var dx: i64 = st[b] - qx; var dy: i64 = st[b+1] - qy; var dz: i64 = st[b+2] - qz 130 let lim: i64 = maxd*SD_Q8 131 let d2: i64 = dx*dx + dy*dy + dz*dz 132 if d2 > lim*lim { 133 var d: i64 = sd_isqrt(d2) 134 if d < 1 { d = 1 } 135 st[b] = qx + dx*lim/d; st[b+1] = qy + dy*lim/d; st[b+2] = qz + dz*lim/d 136 // kill the outward velocity component so it rests on the limit instead of grinding 137 st[b+3] = st[b+3]/2; st[b+4] = st[b+4]/2; st[b+5] = st[b+5]/2 138 } 139 sd_deadsnap(st, b, 0, qx, kkx, ccx) 140 sd_deadsnap(st, b, 1, qy, kky, ccy) 141 sd_deadsnap(st, b, 2, qz, kkz, ccz) 142 return 0 143} 144 145// current displacement of point i from its anchor, in MODEL UNITS (the jiggle amplitude) 146func sd_disp(st: *i64, i: i64, ax: i64, ay: i64, az: i64) -> i64 { 147 let b: i64 = i*SD_STRIDE 148 let dx: i64 = st[b] - ax*SD_Q8; let dy: i64 = st[b+1] - ay*SD_Q8; let dz: i64 = st[b+2] - az*SD_Q8 149 return sd_isqrt(dx*dx + dy*dy + dz*dz)/SD_Q8 150} 151// signed displacement along one axis (for overshoot / sign-change detection), model units 152func sd_disp_y(st: *i64, i: i64, ay: i64) -> i64 { return (st[i*SD_STRIDE+1] - ay*SD_Q8)/SD_Q8 } 153// ★SUB-UNIT accessors: jiggle amplitude is often a FRACTION of a model unit -- measuring in truncated 154// integer units hides real oscillation (that bug made gate T2 read "no overshoot" when it was oscillating). 155func sd_disp_y_q8(st: *i64, i: i64, ay: i64) -> i64 { return st[i*SD_STRIDE+1] - ay*SD_Q8 } 156func sd_disp_q8(st: *i64, i: i64, ax: i64, ay: i64, az: i64) -> i64 { 157 let b: i64 = i*SD_STRIDE 158 let dx: i64 = st[b] - ax*SD_Q8; let dy: i64 = st[b+1] - ay*SD_Q8; let dz: i64 = st[b+2] - az*SD_Q8 159 return sd_isqrt(dx*dx + dy*dy + dz*dz) 160} 161// kinetic energy proxy (sum v^2) -- must DECAY for a damped system 162func sd_energy(st: *i64, i: i64) -> i64 { 163 let b: i64 = i*SD_STRIDE 164 return (st[b+3]*st[b+3] + st[b+4]*st[b+4] + st[b+5]*st[b+5])/SD_Q8 165}