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}