wip(0.S2): migrate - a var nothing writes becomes a let; a state is keyed by its module, directory or program, so every program's plan agrees; paths normalized; program states named SceneDemoState / scene_demo_st; the LSP reads state, mut and entry/handler parameters

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 15:31:34 +03:00
parent 3eda72e8c2
commit 5ffe50ed02
463 changed files with 72045 additions and 67421 deletions

View file

@ -16,47 +16,37 @@ export property Clip {
vals: floats
}
export state AnimState {
clips: []Clip = null
positions: bool = false
q_a: floats = null
q_b: floats = null
q_cur: floats = null
parts: []floats = null
rot_s: floats = null
pos_s: floats = null
hit_s: words = null
cap: int = 0
}
var clips: []Clip = null
# Keyed translations are off unless asked for: baking keys location on every bone, and a skeleton
# does not translate its joints, so applying them turns rounding into a pose.
var positions: bool = false
function clips_init(anim_st: mut AnimState) -> void {
if anim_st.clips == null { anim_st.clips = new []Clip }
function clips_init() -> void {
if clips == null { clips = new []Clip }
}
# the clip of that name, or null; names are per file (per body, per species), never shared
export function anim_find(anim_st: mut AnimState, name: string) -> Clip {
clips_init(anim_st)
for i in 0 .. len(anim_st.clips) { if anim_st.clips[i].name == name { return anim_st.clips[i] } }
export function anim_find(name: string) -> Clip {
clips_init()
for i in 0 .. len(clips) { if clips[i].name == name { return clips[i] } }
return null
}
export function anim_count(anim_st: mut AnimState) -> int {
clips_init(anim_st)
return len(anim_st.clips)
export function anim_count() -> int {
clips_init()
return len(clips)
}
# a clip made outside a glTF (a test, a generated clip); a name already held is refused
export function anim_add(anim_st: mut AnimState, c: Clip) -> bool {
if c == null or anim_find(anim_st, c.name) != null { return false }
push(anim_st.clips, c)
export function anim_add(c: Clip) -> bool {
if c == null or anim_find(c.name) != null { return false }
push(clips, c)
return true
}
export function anim_clear(anim_st: mut AnimState) -> void { anim_st.clips = new []Clip }
export function anim_set_positions(anim_st: mut AnimState, on: bool) -> void { anim_st.positions = on }
export function anim_positions(anim_st: AnimState) -> bool { return anim_st.positions }
export function anim_clear() -> void { clips = new []Clip }
export function anim_set_positions(on: bool) -> void { positions = on }
export function anim_positions() -> bool { return positions }
# Which key pair straddles time t, by halving rather than walking: a grazing clip is three
# hundred keys long and a valley can have dozens of animals posed in a frame.

View file

@ -1,13 +1,16 @@
# mix.ludic - sample one clip at a time into per-node buffers, mixed in at a weight. `rot` is 4 per
# node, `pos` 3 per node, and `hit` says which nodes have received a value (bit 0 a rotation, bit 1
# a translation), so a node the clips never mention stays at rest
var q_a: floats = null
var q_b: floats = null
var q_cur: floats = null
export function anim_mix(anim_st: mut AnimState, c: Clip, sk: Skin, t: float, w: float, rot: floats, pos: floats, hit: words) -> void {
export function anim_mix(c: Clip, sk: Skin, t: float, w: float, rot: floats, pos: floats, hit: words) -> void {
if c == null or sk == null { return }
if anim_st.q_a == null {
anim_st.q_a = floats(4)
anim_st.q_b = floats(4)
anim_st.q_cur = floats(4)
if q_a == null {
q_a = floats(4)
q_b = floats(4)
q_cur = floats(4)
}
var tt = t
if c.dur > 0.0 { tt = t % c.dur }
@ -24,27 +27,27 @@ export function anim_mix(anim_st: mut AnimState, c: Clip, sk: Skin, t: float, w:
let span = c.times[o + i1] - c.times[o + i0]
if span > 0.000001 { f = Math.clamp((tt - c.times[o + i0]) / span, 0.0, 1.0) }
}
if c.cpath[ch] == AC_ROT { mix_rot(anim_st, c, ch, node, i0, i1, f, w, rot, hit) } else { mix_pos(c, ch, node, i0, i1, f, w, pos, hit) }
if c.cpath[ch] == AC_ROT { mix_rot(c, ch, node, i0, i1, f, w, rot, hit) } else { mix_pos(c, ch, node, i0, i1, f, w, pos, hit) }
}
}
# Across clips the first contributor lands and the rest are nlerped onto it by their share, which
# is what makes a cross-fade rather than a jump. Test the ROTATION bit, not the whole word: glTF
# writes a node's translation before its rotation, and `hit == 0` then skipped every rotation.
function mix_rot(anim_st: mut AnimState, c: Clip, ch: int, node: int, i0: int, i1: int, f: float, w: float, rot: floats, hit: words) -> void {
function mix_rot(c: Clip, ch: int, node: int, i0: int, i1: int, f: float, w: float, rot: floats, hit: words) -> void {
let vo = c.cvoff[ch]
for e in 0 .. 4 {
anim_st.q_a[e] = c.vals[vo + i0 * 4 + e]
anim_st.q_b[e] = c.vals[vo + i1 * 4 + e]
q_a[e] = c.vals[vo + i0 * 4 + e]
q_b[e] = c.vals[vo + i1 * 4 + e]
}
q_nlerp(anim_st.q_a, anim_st.q_a, anim_st.q_b, f)
q_nlerp(q_a, q_a, q_b, f)
if (hit[node] & 1) == 0 {
q_store(rot, node, anim_st.q_a)
q_store(rot, node, q_a)
hit[node] = hit[node] | 1
} else {
q_load(anim_st.q_cur, rot, node)
q_nlerp(anim_st.q_cur, anim_st.q_cur, anim_st.q_a, w)
q_store(rot, node, anim_st.q_cur)
q_load(q_cur, rot, node)
q_nlerp(q_cur, q_cur, q_a, w)
q_store(rot, node, q_cur)
}
}

View file

@ -1,23 +1,28 @@
# pose.ludic - the sampled LOCAL transforms turned into the model-frame deltas `skin_pose` wants.
# skin_pose folds a delta D in as local = (G_p^-1 . D . G_p) . R_rest, and a channel gives the
# absolute local L, so D = G_p . (L . R_rest^-1) . G_p^-1 (a root: D = L . R_rest^-1)
var parts: []floats = null
var rot_s: floats = null
var pos_s: floats = null
var hit_s: words = null
var cap: int = 0
function part(anim_st: mut AnimState, i: int) -> floats {
if anim_st.parts == null {
anim_st.parts = new []floats
for k in 0 .. 8 { push(anim_st.parts, floats(4)) }
function part(i: int) -> floats {
if parts == null {
parts = new []floats
for k in 0 .. 8 { push(parts, floats(4)) }
}
return anim_st.parts[i]
return parts[i]
}
export function anim_to_pose(anim_st: mut AnimState, sk: Skin, rot: floats, pos: floats, hit: words) -> void {
let l = part(anim_st, 0)
let rinv = part(anim_st, 1)
let gp = part(anim_st, 2)
let gpi = part(anim_st, 3)
let tmp = part(anim_st, 4)
let d = part(anim_st, 5)
let v = part(anim_st, 6)
export function anim_to_pose(sk: Skin, rot: floats, pos: floats, hit: words) -> void {
let l = part(0)
let rinv = part(1)
let gp = part(2)
let gpi = part(3)
let tmp = part(4)
let d = part(5)
let v = part(6)
for i in 0 .. sk.n_nodes {
let p = sk.par[i]
if (hit[i] & 1) != 0 {
@ -34,7 +39,7 @@ export function anim_to_pose(anim_st: mut AnimState, sk: Skin, rot: floats, pos:
q_normalize(d)
skin_set_quat(sk, i, d)
}
if (hit[i] & 2) != 0 and anim_st.positions {
if (hit[i] & 2) != 0 and positions {
let dx = pos[i * 3] - sk.rest_t[i * 3]
let dy = pos[i * 3 + 1] - sk.rest_t[i * 3 + 1]
let dz = pos[i * 3 + 2] - sk.rest_t[i * 3 + 2]
@ -49,25 +54,25 @@ export function anim_to_pose(anim_st: mut AnimState, sk: Skin, rot: floats, pos:
}
# The scratch is made ONCE and grown, never allocated per call: a call is per actor per frame.
function scratch(anim_st: mut AnimState, n: int) -> void {
if anim_st.cap >= n { return }
anim_st.rot_s = floats(n * 4)
anim_st.pos_s = floats(n * 3)
anim_st.hit_s = words(n)
anim_st.cap = n
function scratch(n: int) -> void {
if cap >= n { return }
rot_s = floats(n * 4)
pos_s = floats(n * 3)
hit_s = words(n)
cap = n
}
# Play `a` at time `ta`, cross-faded toward `b` at `tb` by `blend` (0 is all a, 1 is all b), and
# fold it into the joint matrices. `b` may be null. False when there is no skin or no clip.
export function anim_play(anim_st: mut AnimState, sk: Skin, a: Clip, ta: float, b: Clip, tb: float, blend: float) -> bool {
export function anim_play(sk: Skin, a: Clip, ta: float, b: Clip, tb: float, blend: float) -> bool {
if sk == null or a == null { return false }
let n = sk.n_nodes
scratch(anim_st, n)
for i in 0 .. n { anim_st.hit_s[i] = 0 }
scratch(n)
for i in 0 .. n { hit_s[i] = 0 }
skin_reset(sk)
anim_mix(anim_st, a, sk, ta, 1.0, anim_st.rot_s, anim_st.pos_s, anim_st.hit_s)
if b != null and blend > 0.001 { anim_mix(anim_st, b, sk, tb, blend, anim_st.rot_s, anim_st.pos_s, anim_st.hit_s) }
anim_to_pose(anim_st, sk, anim_st.rot_s, anim_st.pos_s, anim_st.hit_s)
anim_mix(a, sk, ta, 1.0, rot_s, pos_s, hit_s)
if b != null and blend > 0.001 { anim_mix(b, sk, tb, blend, rot_s, pos_s, hit_s) }
anim_to_pose(sk, rot_s, pos_s, hit_s)
skin_pose(sk)
return true
}

View file

@ -3,31 +3,31 @@
# Call it straight after a load and before the next one: `gltf_doc` and `gltf_bin` point at
# whichever file was read last, so a clip that is not taken now cannot be taken later.
export function anim_read_doc(anim_st: mut AnimState, render3d_st: mut Render3dState, dir: string) -> int {
clips_init(anim_st)
if render3d_st.gltf_doc == null { return 0 }
if value_has(render3d_st.gltf_doc, "animations") == 0 { return 0 }
export function anim_read_doc(dir: string) -> int {
clips_init()
if gltf_doc == null { return 0 }
if value_has(gltf_doc, "animations") == 0 { return 0 }
# REOPEN THE .bin. `gltf_load` closes it before it returns, and an accessor read through a closed
# handle returns a buffer of the right size full of zeros: right names, right counts, no motion.
let buffers = value_get(render3d_st.gltf_doc, "buffers")
let buffers = value_get(gltf_doc, "buffers")
if value_count(buffers) == 0 { return 0 }
let bin_uri = value_as_str(value_get(value_at(buffers, 0), "uri"))
render3d_st.gltf_bin = file_open(dir + "/" + bin_uri, "rb")
if render3d_st.gltf_bin == null {
gltf_bin = file_open(dir + "/" + bin_uri, "rb")
if gltf_bin == null {
print(`anim: cannot reopen {dir}/{bin_uri}`)
return 0
}
let arr = value_get(render3d_st.gltf_doc, "animations")
let arr = value_get(gltf_doc, "animations")
var got = 0
for ai in 0 .. value_count(arr) {
let c = read_one(anim_st, render3d_st, value_at(arr, ai))
let c = read_one(value_at(arr, ai))
if c != null {
push(anim_st.clips, c)
push(clips, c)
got += 1
}
}
file_close(render3d_st.gltf_bin)
render3d_st.gltf_bin = null
file_close(gltf_bin)
gltf_bin = null
return got
}
@ -39,10 +39,10 @@ function kept_path(ch: Val) -> string {
return path
}
function read_one(anim_st: mut AnimState, render3d_st: mut Render3dState, a: Val) -> Clip {
function read_one(a: Val) -> Clip {
var nm = ""
if value_has(a, "name") != 0 { nm = value_as_str(value_get(a, "name")) }
if anim_find(anim_st, nm) != null { return null } # a second rig's file, same clip
if anim_find(nm) != null { return null } # a second rig's file, same clip
let chans = value_get(a, "channels")
let samps = value_get(a, "samplers")
let nc = value_count(chans)
@ -55,7 +55,7 @@ function read_one(anim_st: mut AnimState, render3d_st: mut Render3dState, a: Val
let path = kept_path(ch)
if path == "" { continue }
let sp = value_at(samps, value_as_int(value_get(ch, "sampler")))
let cnt = gltf_accessor_count(render3d_st, value_as_int(value_get(sp, "input")))
let cnt = gltf_accessor_count(value_as_int(value_get(sp, "input")))
n_keep += 1
n_t += cnt
if path == "rotation" { n_v += cnt * 4 } else { n_v += cnt * 3 }
@ -81,14 +81,14 @@ function read_one(anim_st: mut AnimState, render3d_st: mut Render3dState, a: Val
let sp = value_at(samps, value_as_int(value_get(ch, "sampler")))
c.cnode[k] = value_as_int(value_get(value_get(ch, "target"), "node"))
if path == "rotation" { c.cpath[k] = AC_ROT } else { c.cpath[k] = AC_POS }
let tb = gltf_accessor_floats(render3d_st, value_as_int(value_get(sp, "input")))
let nk = render3d_st.gltf_count
let tb = gltf_accessor_floats(value_as_int(value_get(sp, "input")))
let nk = gltf_count
c.ckeys[k] = nk
c.coff[k] = at
for i in 0 .. nk { c.times[at + i] = tb[i] }
if nk > 0 and c.times[at + nk - 1] > c.dur { c.dur = c.times[at + nk - 1] }
at += nk
let vb = gltf_accessor_floats(render3d_st, value_as_int(value_get(sp, "output")))
let vb = gltf_accessor_floats(value_as_int(value_get(sp, "output")))
var comps = 3
if c.cpath[k] == AC_ROT { comps = 4 }
c.cvoff[k] = av

View file

@ -9,9 +9,9 @@ program AnimTest {
function links_runtime() -> bool { return Input.key_pressed(0) }
# a root and one child 1 m up, no inverse bind matrices, one skin of both
function skin(render3d_st: mut Render3dState) -> Skin {
render3d_st.gltf_doc = Json.parse("{\"nodes\": [{\"name\": \"root\", \"children\": [1]}, {\"name\": \"bone\", \"translation\": [0, 1, 0]}], \"skins\": [{\"joints\": [0, 1]}]}")
return skin_load(render3d_st, 0)
function skin() -> Skin {
gltf_doc = Json.parse("{\"nodes\": [{\"name\": \"root\", \"children\": [1]}, {\"name\": \"bone\", \"translation\": [0, 1, 0]}], \"skins\": [{\"joints\": [0, 1]}]}")
return skin_load(0)
}
# one channel on node 1: identity at 0, a quarter turn about Z at 1 s
@ -44,14 +44,14 @@ program AnimTest {
return q[2]
}
test "a clip is found by name, once" (anim_st: mut AnimState) {
anim_clear(anim_st)
expect(anim_add(anim_st, turn("hiker_m_walk")))
expect(not anim_add(anim_st, turn("hiker_m_walk")))
expect(anim_add(anim_st, turn("hiker_f_walk")))
expect_eq(anim_count(anim_st), 2)
expect(anim_find(anim_st, "hiker_f_walk") != null)
expect(anim_find(anim_st, "bear_walk") == null)
test "a clip is found by name, once" {
anim_clear()
expect(anim_add(turn("hiker_m_walk")))
expect(not anim_add(turn("hiker_m_walk")))
expect(anim_add(turn("hiker_f_walk")))
expect_eq(anim_count(), 2)
expect(anim_find("hiker_f_walk") != null)
expect(anim_find("bear_walk") == null)
}
test "a key pair is found by halving" {
@ -61,56 +61,56 @@ program AnimTest {
expect_eq(anim_key_at(c, 0, -1.0), 0)
}
test "playing samples between keys, and wraps on the duration" (anim_st: mut AnimState, render3d_st: mut Render3dState) {
let sk = skin(render3d_st)
test "playing samples between keys, and wraps on the duration" {
let sk = skin()
let c = turn("t")
expect(anim_play(anim_st, sk, c, 0.0, null, 0.0, 0.0))
expect(anim_play(sk, c, 0.0, null, 0.0, 0.0))
expect_near(pose_z(sk), 0.0, 0.001)
anim_play(anim_st, sk, c, 1.0 - 0.0001, null, 0.0, 0.0)
anim_play(sk, c, 1.0 - 0.0001, null, 0.0, 0.0)
expect_near(pose_z(sk), HALF, 0.01)
anim_play(anim_st, sk, c, 0.5, null, 0.0, 0.0)
anim_play(sk, c, 0.5, null, 0.0, 0.0)
let mid = pose_z(sk)
anim_play(anim_st, sk, c, 1.5, null, 0.0, 0.0)
anim_play(sk, c, 1.5, null, 0.0, 0.0)
expect_near(pose_z(sk), mid, 0.001)
expect(mid > 0.3 and mid < 0.45)
}
test "a cross-fade lands between the two clips" (anim_st: mut AnimState, render3d_st: mut Render3dState) {
let sk = skin(render3d_st)
test "a cross-fade lands between the two clips" {
let sk = skin()
let a = turn("a")
let b = turn("b")
anim_play(anim_st, sk, a, 0.0, b, 0.999, 0.5)
anim_play(sk, a, 0.0, b, 0.999, 0.5)
let z = pose_z(sk)
expect(z > 0.3 and z < 0.45)
anim_play(anim_st, sk, a, 0.0, b, 0.999, 0.0)
anim_play(sk, a, 0.0, b, 0.999, 0.0)
expect_near(pose_z(sk), 0.0, 0.001)
}
test "a bone is found in the skin's own joints, not the file's first of that name" (render3d_st: mut Render3dState) {
render3d_st.gltf_doc = Json.parse("{\"nodes\": [{\"name\": \"bone\"}, {\"name\": \"root\", \"children\": [2]}, {\"name\": \"bone\"}], \"skins\": [{\"joints\": [1, 2]}]}")
let sk = skin_load(render3d_st, 0)
test "a bone is found in the skin's own joints, not the file's first of that name" {
gltf_doc = Json.parse("{\"nodes\": [{\"name\": \"bone\"}, {\"name\": \"root\", \"children\": [2]}, {\"name\": \"bone\"}], \"skins\": [{\"joints\": [1, 2]}]}")
let sk = skin_load(0)
expect_eq(skin_joint(sk, "bone"), 2)
expect_eq(skin_joint_quiet(sk, "wing_l"), -1)
expect_eq(skin_joint_quiet(null, "bone"), -1)
}
test "no skin or no clip plays nothing" (anim_st: mut AnimState, render3d_st: mut Render3dState) {
expect(not anim_play(anim_st, null, turn("t"), 0.0, null, 0.0, 0.0))
expect(not anim_play(anim_st, skin(render3d_st), null, 0.0, null, 0.0, 0.0))
test "no skin or no clip plays nothing" {
expect(not anim_play(null, turn("t"), 0.0, null, 0.0, 0.0))
expect(not anim_play(skin(), null, 0.0, null, 0.0, 0.0))
}
test "keyed translations are ignored until asked for" (anim_st: mut AnimState, render3d_st: mut Render3dState) {
let sk = skin(render3d_st)
test "keyed translations are ignored until asked for" {
let sk = skin()
let c = turn("t")
c.cpath[0] = AC_POS
c.vals[0] = 0.0
c.vals[1] = 3.0
c.vals[3] = 0.0
c.vals[4] = 3.0
anim_play(anim_st, sk, c, 0.0, null, 0.0, 0.0)
anim_play(sk, c, 0.0, null, 0.0, 0.0)
expect_near(sk.pose_t[4], 0.0, 0.0001)
anim_set_positions(anim_st, true)
anim_play(anim_st, sk, c, 0.0, null, 0.0, 0.0)
anim_set_positions(true)
anim_play(sk, c, 0.0, null, 0.0, 0.0)
expect_near(sk.pose_t[4], 2.0, 0.0001)
}
@ -122,8 +122,8 @@ program AnimTest {
b[at + 3] = (v >> 24) & 255
}
test "clips are read out of a glTF's .bin, and a late first key is measured" (anim_st: mut AnimState, render3d_st: mut Render3dState) {
anim_clear(anim_st)
test "clips are read out of a glTF's .bin, and a late first key is measured" {
anim_clear()
let dir = Os.temp_dir() + "/ludic-anim-test"
Fs.mkdir(dir)
let b = buffer(40)
@ -133,10 +133,10 @@ program AnimTest {
put_f(b, 8 + 24, HALF)
put_f(b, 8 + 28, HALF)
expect(Fs.write_bytes(dir + "/t.bin", b, 40))
render3d_st.gltf_doc = Json.parse("{\"buffers\": [{\"uri\": \"t.bin\"}], \"bufferViews\": [{\"byteOffset\": 0}, {\"byteOffset\": 8}], \"accessors\": [{\"bufferView\": 0, \"count\": 2, \"type\": \"SCALAR\"}, {\"bufferView\": 1, \"count\": 2, \"type\": \"VEC4\"}], \"animations\": [{\"name\": \"bear_walk\", \"samplers\": [{\"input\": 0, \"output\": 1}], \"channels\": [{\"sampler\": 0, \"target\": {\"node\": 1, \"path\": \"rotation\"}}, {\"sampler\": 0, \"target\": {\"node\": 1, \"path\": \"scale\"}}]}]}")
expect_eq(anim_read_doc(anim_st, render3d_st, dir), 1)
expect_eq(anim_read_doc(anim_st, render3d_st, dir), 0)
let c = anim_find(anim_st, "bear_walk")
gltf_doc = Json.parse("{\"buffers\": [{\"uri\": \"t.bin\"}], \"bufferViews\": [{\"byteOffset\": 0}, {\"byteOffset\": 8}], \"accessors\": [{\"bufferView\": 0, \"count\": 2, \"type\": \"SCALAR\"}, {\"bufferView\": 1, \"count\": 2, \"type\": \"VEC4\"}], \"animations\": [{\"name\": \"bear_walk\", \"samplers\": [{\"input\": 0, \"output\": 1}], \"channels\": [{\"sampler\": 0, \"target\": {\"node\": 1, \"path\": \"rotation\"}}, {\"sampler\": 0, \"target\": {\"node\": 1, \"path\": \"scale\"}}]}]}")
expect_eq(anim_read_doc(dir), 1)
expect_eq(anim_read_doc(dir), 0)
let c = anim_find("bear_walk")
expect(c != null)
expect_eq(c.n, 1)
expect_near(c.dur, 1.0, 0.0001)