feat: multi-level parent-chain transform with depth-first pipeline
Rewrites M.update to traverse the rig depth-first, computing each
bone's world-transform from its parent's already-cascaded world-pose
plus its own rest-offset and animated angle. Replaces the previous
single-level direct-from-origin transform.
Per-frame per-bone steps inside the traversal:
1. Sample keyframe from any playing anim on the bone's track
2. Apply look-at (uses parent's world from cascade)
3. Run procedural callbacks (once per puppet at root visit)
4. Compute and cache world-transform from parent.world + own rest + own angle
bone_state[bid].world = {x, y, angle} is the cache; render reads it.
New public API:
- puppet.bone_world_transform(handle, bone_id) -> (wx, wy, wa)
- puppet.write_bone_test_only(handle, bone_id, angle_rad) for test setup
Look-target previously interpreted as world-coords with bone-world-pos
= puppet origin + bone.rest. New implementation computes bone-world-pos
from cascaded parent transform, then subtracts the parent's accumulated
world-angle to store the LOCAL angle (so subsequent cascade in step 3.d
produces the correct final world-angle for the look-at bone).
spawn now initializes bone_state[bid].world = {x, y, angle} cache.
This commit is contained in:
158
init.lua
158
init.lua
@@ -260,7 +260,10 @@ function M.spawn(rig, pos)
|
|||||||
-- Per-bone live state (current angle, current world-pos cache).
|
-- Per-bone live state (current angle, current world-pos cache).
|
||||||
local bone_state = {}
|
local bone_state = {}
|
||||||
for bid, b in pairs(rig.bones_by_id) do
|
for bid, b in pairs(rig.bones_by_id) do
|
||||||
bone_state[bid] = { angle = b.rest.angle, world_x = 0, world_y = 0 }
|
bone_state[bid] = {
|
||||||
|
angle = b.rest.angle,
|
||||||
|
world = { x = 0, y = 0, angle = b.rest.angle },
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
all_puppets[handle] = {
|
all_puppets[handle] = {
|
||||||
@@ -387,56 +390,155 @@ function M.write_bone(handle, bone_id, values)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- ====================================================================
|
-- ====================================================================
|
||||||
-- Update pipeline (per-frame)
|
-- World-transform query (public API; reads from cached bone_state.world)
|
||||||
|
-- ====================================================================
|
||||||
|
function M.bone_world_transform(handle, bone_id)
|
||||||
|
local p = all_puppets[handle]
|
||||||
|
if p == nil then
|
||||||
|
error("puppet.bone_world_transform: handle " .. tostring(handle) .. " not spawned")
|
||||||
|
end
|
||||||
|
local ws = p.bone_state[bone_id] and p.bone_state[bone_id].world
|
||||||
|
if ws == nil then
|
||||||
|
error("puppet.bone_world_transform: bone '" .. tostring(bone_id) .. "' has no cached world-transform")
|
||||||
|
end
|
||||||
|
return ws.x, ws.y, ws.angle
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ====================================================================
|
||||||
|
-- Test-only: direct bone-angle write bypassing track-conflict guard.
|
||||||
|
-- Not for production module use.
|
||||||
|
-- ====================================================================
|
||||||
|
function M.write_bone_test_only(handle, bone_id, angle_rad)
|
||||||
|
local p = all_puppets[handle]
|
||||||
|
if p == nil then
|
||||||
|
error("puppet.write_bone_test_only: handle " .. tostring(handle) .. " not spawned")
|
||||||
|
end
|
||||||
|
if p.bone_state[bone_id] == nil then
|
||||||
|
error("puppet.write_bone_test_only: bone '" .. tostring(bone_id) .. "' not in rig")
|
||||||
|
end
|
||||||
|
p.bone_state[bone_id].angle = angle_rad
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ====================================================================
|
||||||
|
-- Forward-declared recursive helper (forward decl required for child-recursion).
|
||||||
|
-- ====================================================================
|
||||||
|
local update_bone_recursive
|
||||||
|
|
||||||
|
-- ====================================================================
|
||||||
|
-- Update pipeline (per-frame, depth-first traversal).
|
||||||
|
--
|
||||||
|
-- For each puppet:
|
||||||
|
-- 1. Reset all bones to rest pose
|
||||||
|
-- 2. Advance keyframe-anim sample-times
|
||||||
|
-- 3. Depth-first traversal root -> children:
|
||||||
|
-- a. Sample keyframe for bone if on active track
|
||||||
|
-- b. Apply look-at if bone has look_at=true (uses parent's already-cascaded world)
|
||||||
|
-- c. Run procedural callbacks (once per puppet at root-visit)
|
||||||
|
-- d. Compute world-transform from parent.world + own rest + own angle
|
||||||
-- ====================================================================
|
-- ====================================================================
|
||||||
function M.update(dt)
|
function M.update(dt)
|
||||||
for handle, p in pairs(all_puppets) do
|
for handle, p in pairs(all_puppets) do
|
||||||
-- 1. Reset bones to rest pose.
|
-- 1. Reset to rest pose.
|
||||||
for bid, b in pairs(p.rig.bones_by_id) do
|
for bid, b in pairs(p.rig.bones_by_id) do
|
||||||
p.bone_state[bid].angle = b.rest.angle
|
p.bone_state[bid].angle = b.rest.angle
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 2. Apply look-at constraint (writes to bones marked look_at: true).
|
-- 2. Advance keyframe sample-times.
|
||||||
if p.look_target ~= nil then
|
|
||||||
for bid, b in pairs(p.rig.bones_by_id) do
|
|
||||||
if b.look_at then
|
|
||||||
-- Compute bone's world-pos by chaining parents.
|
|
||||||
-- For minimal v0.1: assume bone's parent is at puppet origin (torso).
|
|
||||||
-- Simplified: bone world-pos = puppet pos + bone.rest offset.
|
|
||||||
local bone_world_x = p.x + b.rest.x
|
|
||||||
local bone_world_y = p.y + b.rest.y
|
|
||||||
local dx = p.look_target.x - bone_world_x
|
|
||||||
local dy = p.look_target.y - bone_world_y
|
|
||||||
p.bone_state[bid].angle = math.atan(dy, dx)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- 3. Advance + apply keyframe animations.
|
|
||||||
for anim_id, play_state in pairs(p.playing) do
|
for anim_id, play_state in pairs(p.playing) do
|
||||||
local anim = p.animations[anim_id]
|
local anim = p.animations[anim_id]
|
||||||
play_state.t = play_state.t + dt * play_state.speed
|
play_state.t = play_state.t + dt * play_state.speed
|
||||||
if play_state.loop and play_state.t > anim.duration then
|
if play_state.loop and play_state.t > anim.duration then
|
||||||
play_state.t = play_state.t % anim.duration
|
play_state.t = play_state.t % anim.duration
|
||||||
end
|
end
|
||||||
local frame = M.sample_animation(anim, play_state.t)
|
end
|
||||||
for bid, bone_kf in pairs(frame) do
|
|
||||||
p.bone_state[bid].angle = bone_kf.angle
|
-- 3. Depth-first traversal: roots first, then children, recursively.
|
||||||
|
for _, b_src in ipairs(p.rig.bones) do
|
||||||
|
local b = p.rig.bones_by_id[b_src.id]
|
||||||
|
if b.parent == nil then
|
||||||
|
update_bone_recursive(p, b, dt)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 4. Apply procedural callbacks (via write_bone -- auto-validated).
|
-- ====================================================================
|
||||||
-- Use pcall per callback so that a track-conflict from one callback
|
-- Depth-first update of one bone, then its children.
|
||||||
-- removes only that offending procedural and re-raises, rather than
|
-- ====================================================================
|
||||||
-- leaving other puppets' callbacks un-executed on subsequent calls.
|
update_bone_recursive = function(p, b, dt)
|
||||||
|
local bid = b.id
|
||||||
|
|
||||||
|
-- 3.a Sample keyframe for this bone from any playing anim on its track.
|
||||||
|
local track_id = p.rig.bone_to_track[bid]
|
||||||
|
if track_id then
|
||||||
|
for anim_id, play_state in pairs(p.playing) do
|
||||||
|
local anim = p.animations[anim_id]
|
||||||
|
if anim and anim.track == track_id then
|
||||||
|
local frame = M.sample_animation(anim, play_state.t)
|
||||||
|
local bone_kf = frame[bid]
|
||||||
|
if bone_kf and bone_kf.angle ~= nil then
|
||||||
|
p.bone_state[bid].angle = bone_kf.angle -- already radians per sample_animation
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 3.b Apply look-at constraint if this bone is marked look_at.
|
||||||
|
if b.look_at and p.look_target ~= nil then
|
||||||
|
-- Compute this bone's world-pos from parent's world + own rest offset
|
||||||
|
-- (parent's world has been computed earlier in the traversal).
|
||||||
|
local px, py, pa
|
||||||
|
if b.parent then
|
||||||
|
local pw = p.bone_state[b.parent.id].world
|
||||||
|
px, py, pa = pw.x, pw.y, pw.angle
|
||||||
|
else
|
||||||
|
px, py, pa = p.x, p.y, 0
|
||||||
|
end
|
||||||
|
local cos_a, sin_a = math.cos(pa), math.sin(pa)
|
||||||
|
local lx, ly = b.rest.x, b.rest.y
|
||||||
|
local bone_world_x = px + lx * cos_a - ly * sin_a
|
||||||
|
local bone_world_y = py + lx * sin_a + ly * cos_a
|
||||||
|
-- Direction to target in world-space.
|
||||||
|
local dx = p.look_target.x - bone_world_x
|
||||||
|
local dy = p.look_target.y - bone_world_y
|
||||||
|
-- World-angle for look-at = atan2(dy, dx). Convert to local (subtract parent's accumulated).
|
||||||
|
local world_angle = math.atan(dy, dx)
|
||||||
|
p.bone_state[bid].angle = world_angle - pa
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 3.c Run procedural callbacks at root-visit (once per puppet, before world-transform compute).
|
||||||
|
if b.parent == nil then
|
||||||
for name, cb in pairs(p.procedural) do
|
for name, cb in pairs(p.procedural) do
|
||||||
local ok, err = pcall(cb, handle, dt)
|
local ok, err = pcall(cb, p.handle, dt)
|
||||||
if not ok then
|
if not ok then
|
||||||
p.procedural[name] = nil -- disarm offending callback to avoid repeat errors
|
p.procedural[name] = nil -- disarm offending callback to avoid repeat errors
|
||||||
error(err, 0) -- re-raise so caller (or test pcall) sees the conflict
|
error(err, 0) -- re-raise so caller (or test pcall) sees the conflict
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- 3.d Compute this bone's world-transform from parent + own.
|
||||||
|
local px, py, pa
|
||||||
|
if b.parent then
|
||||||
|
local pw = p.bone_state[b.parent.id].world
|
||||||
|
px, py, pa = pw.x, pw.y, pw.angle
|
||||||
|
else
|
||||||
|
px, py, pa = p.x, p.y, 0
|
||||||
|
end
|
||||||
|
local cos_a, sin_a = math.cos(pa), math.sin(pa)
|
||||||
|
local lx, ly = b.rest.x, b.rest.y
|
||||||
|
local wx = px + lx * cos_a - ly * sin_a
|
||||||
|
local wy = py + lx * sin_a + ly * cos_a
|
||||||
|
local wa = pa + p.bone_state[bid].angle
|
||||||
|
p.bone_state[bid].world = { x = wx, y = wy, angle = wa }
|
||||||
|
|
||||||
|
-- Recurse to children.
|
||||||
|
for _, child_src in ipairs(p.rig.bones) do
|
||||||
|
local child = p.rig.bones_by_id[child_src.id]
|
||||||
|
if child.parent == b then
|
||||||
|
update_bone_recursive(p, child, dt)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ====================================================================
|
-- ====================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user