code wiki / (root) / nx_softdyn.nx

nx_softdyn.nx source

↩ module page · 120 lines · 7072 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" 14 15const SD_Q8: i64 = 256 16const SD_G: i64 = 1024 17const SD_STRIDE: i64 = 6 18// stability envelope (enforced in sd_step): C must stay under one full per-tick velocity reversal, 19// K under omega*dt < 2. Values outside diverge -- so they are clamped, never honoured. 20const SD_CAPC: i64 = 1000 21const SD_CAPK: i64 = 3600 22 23func sd_abs(v: i64) -> i64 { if v < 0 { return 0 - v } return v } 24func sd_isqrt(v: i64) -> i64 { if v <= 0 { return 0 } var x: i64 = v; var y: i64 = (x+1)/2; while y < x { x = y; y = (x + v/x)/2 } return x } 25 26// allocate state for n points 27func sd_alloc(n: i64) -> *i64 { return sys_mmap(n*SD_STRIDE*8) as *i64 } 28 29// seat point i exactly at its anchor, at rest (no startup transient) 30func sd_seat(st: *i64, i: i64, ax: i64, ay: i64, az: i64) -> i64 { 31 let b: i64 = i*SD_STRIDE 32 st[b] = ax*SD_Q8; st[b+1] = ay*SD_Q8; st[b+2] = az*SD_Q8 33 st[b+3] = 0; st[b+4] = 0; st[b+5] = 0 34 return 0 35} 36 37// inject an impulse (velocity kick) -- RECOIL, a hit, a footfall. Units: model-units/tick * 256. 38func sd_impulse(st: *i64, i: i64, ix: i64, iy: i64, iz: i64) -> i64 { 39 let b: i64 = i*SD_STRIDE 40 st[b+3] = st[b+3] + ix; st[b+4] = st[b+4] + iy; st[b+5] = st[b+5] + iz 41 return 0 42} 43 44// ★TRUNCATION DEAD-ZONE SNAP (found 2026-08-03 by nx_softtissue_conserve_gate T1 + nx_cg_restprobe). 45// Both force terms are integer divisions by SD_G that TRUNCATE toward zero, so the damping term is 46// exactly 0 whenever |v|*C < SD_G and the spring term is exactly 0 whenever |e|*K < SD_G. Inside BOTH 47// dead zones the solver applies NO force at all and the point coasts ballistically forever: measured 48// as a perfect period-30 limit cycle at +-52 q8 (+-3.2 mm at 2.0 Hz) still running undecayed after 49// 200,000 ticks with a stationary anchor. A damped system that cannot reach its own rest state is a 50// perpetual-motion bug, not a rounding detail -- the tissue never stops moving on a standing character. 51// The remedy acts ONLY where the model already computes zero force, so it cannot alter any trajectory 52// that carries real force: it replaces "coast forever" with the one physically correct force-free 53// outcome, rest. Thresholds are DERIVED from K, C and SD_G (no magic numbers). Guarded on kk>0 and 54// cc>0 because with no spring there is no rest pose to snap to, and with no damping rest is genuinely 55// NOT an attractor -- an undamped oscillator must keep oscillating. 56func sd_deadsnap(st: *i64, b: i64, axis: i64, q: i64, kk: i64, cc: i64) -> i64 { 57 if kk <= 0 { return 0 } 58 if cc <= 0 { return 0 } 59 let e: i64 = sd_abs(st[b+axis] - q) 60 let v: i64 = sd_abs(st[b+3+axis]) 61 if e*kk < SD_G { if v*cc < SD_G { st[b+axis] = q; st[b+3+axis] = 0 } } 62 return 0 63} 64 65// one integration tick against anchor (ax,ay,az) in MODEL UNITS. maxd = displacement clamp (model units). 66func sd_step(st: *i64, i: i64, ax: i64, ay: i64, az: i64, K: i64, C: i64, maxd: i64) -> i64 { 67 let b: i64 = i*SD_STRIDE 68 let qx: i64 = ax*SD_Q8; let qy: i64 = ay*SD_Q8; let qz: i64 = az*SD_Q8 69 // ★STABILITY ENVELOPE, ENFORCED (found by nx_softdyn_gate T7, 2026-07-23): with semi-implicit Euler the 70 // damping term is a PER-TICK velocity multiplier -- C >= SD_G inverts and amplifies velocity (divergence), 71 // and K > SD_CAPK breaks omega*dt < 2. Clamping here makes an unstable config IMPOSSIBLE TO REQUEST rather 72 // than trusting every caller to know the envelope. 73 var kk: i64 = K; if kk > SD_CAPK { kk = SD_CAPK } 74 if kk < 0 { kk = 0 } 75 var cc: i64 = C; if cc > SD_CAPC { cc = SD_CAPC } 76 if cc < 0 { cc = 0 } 77 // spring toward anchor + viscous damping 78 let accx: i64 = ((qx - st[b])*kk)/SD_G - (st[b+3]*cc)/SD_G 79 let accy: i64 = ((qy - st[b+1])*kk)/SD_G - (st[b+4]*cc)/SD_G 80 let accz: i64 = ((qz - st[b+2])*kk)/SD_G - (st[b+5]*cc)/SD_G 81 st[b+3] = st[b+3] + accx; st[b+4] = st[b+4] + accy; st[b+5] = st[b+5] + accz 82 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] 83 // HARD CLAMP: tissue can never separate further than maxd from bone -- divergence impossible by construction 84 var dx: i64 = st[b] - qx; var dy: i64 = st[b+1] - qy; var dz: i64 = st[b+2] - qz 85 let lim: i64 = maxd*SD_Q8 86 let d2: i64 = dx*dx + dy*dy + dz*dz 87 if d2 > lim*lim { 88 var d: i64 = sd_isqrt(d2) 89 if d < 1 { d = 1 } 90 st[b] = qx + dx*lim/d; st[b+1] = qy + dy*lim/d; st[b+2] = qz + dz*lim/d 91 // kill the outward velocity component so it rests on the limit instead of grinding 92 st[b+3] = st[b+3]/2; st[b+4] = st[b+4]/2; st[b+5] = st[b+5]/2 93 } 94 sd_deadsnap(st, b, 0, qx, kk, cc) 95 sd_deadsnap(st, b, 1, qy, kk, cc) 96 sd_deadsnap(st, b, 2, qz, kk, cc) 97 return 0 98} 99 100// current displacement of point i from its anchor, in MODEL UNITS (the jiggle amplitude) 101func sd_disp(st: *i64, i: i64, ax: i64, ay: i64, az: i64) -> i64 { 102 let b: i64 = i*SD_STRIDE 103 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 104 return sd_isqrt(dx*dx + dy*dy + dz*dz)/SD_Q8 105} 106// signed displacement along one axis (for overshoot / sign-change detection), model units 107func sd_disp_y(st: *i64, i: i64, ay: i64) -> i64 { return (st[i*SD_STRIDE+1] - ay*SD_Q8)/SD_Q8 } 108// ★SUB-UNIT accessors: jiggle amplitude is often a FRACTION of a model unit -- measuring in truncated 109// integer units hides real oscillation (that bug made gate T2 read "no overshoot" when it was oscillating). 110func sd_disp_y_q8(st: *i64, i: i64, ay: i64) -> i64 { return st[i*SD_STRIDE+1] - ay*SD_Q8 } 111func sd_disp_q8(st: *i64, i: i64, ax: i64, ay: i64, az: i64) -> i64 { 112 let b: i64 = i*SD_STRIDE 113 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 114 return sd_isqrt(dx*dx + dy*dy + dz*dz) 115} 116// kinetic energy proxy (sum v^2) -- must DECAY for a damped system 117func sd_energy(st: *i64, i: i64) -> i64 { 118 let b: i64 = i*SD_STRIDE 119 return (st[b+3]*st[b+3] + st[b+4]*st[b+4] + st[b+5]*st[b+5])/SD_Q8 120}