fix(puppet): v0.3.2 — persistent look_at_state across frames

Previously the look-at-rate-limit read p.bone_state[bid].rot as 'current'
and advanced from it. But bone_state.rot is reset-to-rest + keyframe-
sampled at frame start, so 'current' was always near rest (~0) and the
rate-limit only advanced one frame's worth (~7deg) per frame. Visual
result: head/body rotation appeared capped at ~10deg.

Fix: persistent p.look_at_state[bid] table carries angle across frames.
Rate-limit operates on look_at_state.angle (not bone_state.rot). After
rate-limit + clamp, value is written to bone_state.rot.

Init: look_at_state[bid].angle = b.rest.rot on first encounter, so the
first frame advances from rest pose toward target (matches substrate
LookAtState init convention).
This commit is contained in:
Axel Meyer
2026-05-18 20:13:22 +02:00
parent b8b8bf1a76
commit a64055ca2a
2 changed files with 23 additions and 16 deletions

View File

@@ -1,4 +1,4 @@
-- lib-core.puppet v0.3.1
-- lib-core.puppet v0.3.2
-- Skeletal animation: skeleton + bones + tracks + rest + keyframe
-- + procedural + look-at constraint. Multi-channel animation (rot/pos/scl)
-- + cascaded world-transform with scale propagation.
@@ -420,6 +420,7 @@ function M.spawn(rig, pos)
bone_animated = {},
last_move_time = 0,
look_target = nil,
look_at_state = {}, -- {[bone_id] = { angle = X }} persistent rate-limit state
animations = {}, -- {[anim_id] = built_anim}
playing = {}, -- {[anim_id] = { t = 0, loop, speed }}
procedural = {}, -- {[name] = callback_fn}
@@ -894,6 +895,7 @@ update_bone_recursive = function(p, b, dt)
-- 3.b Apply look-at constraint if this bone is marked look_at (scale-aware).
if b.look_at and p.look_target ~= nil then
-- Compute bone-world-pos (scale-aware).
local px, py, pa, psx, psy
if b.parent then
local pw = p.bone_state[b.parent.id].world
@@ -907,34 +909,39 @@ update_bone_recursive = function(p, b, dt)
local bone_world_y = py + (sin_a * lx * psx + cos_a * ly * psy)
local dx = p.look_target.x - bone_world_x
local dy = p.look_target.y - bone_world_y
-- A.1: Substrate convention (puppet.c:748): sprite top extends in -Y direction at rot=0.
-- atan(dx, -dy) means: rot=0 when target is straight up (dy<0), rot=pi/2 when target is to the right.
-- Sprite-top convention: rot=0 means sprite top points up (-Y direction).
-- atan(dx, -dy): rot=0 when target is straight up, rot=pi/2 when target is to the right.
local world_angle = math.atan(dx, -dy)
-- A.4: Apply look_at_speed (rate-limited tracking) + rot_min/rot_max clamp (local frame).
local target_local = world_angle - pa
local current = p.bone_state[bid].rot
local next_value
-- Persistent state for rate-limit (carries angle across frames).
local state = p.look_at_state[bid]
if state == nil then
-- Initialize to bone's rest rotation so first frame advances from rest toward target.
state = { angle = b.rest.rot or 0 }
p.look_at_state[bid] = state
end
if b.look_at_speed then
-- Rate-limited rotation: allow up to look_at_speed * dt per frame.
local delta = target_local - current
-- Normalize delta to [-pi, pi] for shortest-path rotation.
-- Rate-limit using persistent state (not current frame's bone_state.rot).
local delta = target_local - state.angle
while delta > math.pi do delta = delta - 2 * math.pi end
while delta < -math.pi do delta = delta + 2 * math.pi end
local max_step = b.look_at_speed * dt
if delta > max_step then delta = max_step end
if delta < -max_step then delta = -max_step end
next_value = current + delta
state.angle = state.angle + delta
else
next_value = target_local
-- Instant snap (no speed limit).
state.angle = target_local
end
-- Apply rot_min/rot_max clamp (local-frame radians).
if b.rest_rot_min and next_value < b.rest_rot_min then next_value = b.rest_rot_min end
if b.rest_rot_max and next_value > b.rest_rot_max then next_value = b.rest_rot_max end
if b.rest_rot_min and state.angle < b.rest_rot_min then state.angle = b.rest_rot_min end
if b.rest_rot_max and state.angle > b.rest_rot_max then state.angle = b.rest_rot_max end
p.bone_state[bid].rot = next_value
-- Write into bone_state (overrides keyframe-sampled rot).
p.bone_state[bid].rot = state.angle
end
-- 3.c Run procedural callbacks at root-visit (once per puppet, before world-transform compute).