From a64055ca2a9de49abe66791e9d5908653e2217db Mon Sep 17 00:00:00 2001 From: Axel Meyer Date: Mon, 18 May 2026 20:13:22 +0200 Subject: [PATCH] =?UTF-8?q?fix(puppet):=20v0.3.2=20=E2=80=94=20persistent?= =?UTF-8?q?=20look=5Fat=5Fstate=20across=20frames?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- init.lua | 37 ++++++++++++++++++++++--------------- manifest.lib | 2 +- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/init.lua b/init.lua index 50b667d..044995f 100644 --- a/init.lua +++ b/init.lua @@ -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). diff --git a/manifest.lib b/manifest.lib index 524dbcd..8f796ed 100644 --- a/manifest.lib +++ b/manifest.lib @@ -1,6 +1,6 @@ { "id": "lib-core.puppet", - "version": "0.3.1", + "version": "0.3.2", "api_min": "0.1", "deps": [] }