Skip to content

perf(animation): reuse persistent scratch buffers in AnimationGraph/AnimationStateMachine#581

Merged
drsnuggles8 merged 1 commit into
masterfrom
feature/skinning-frame-arena
Jul 7, 2026
Merged

perf(animation): reuse persistent scratch buffers in AnimationGraph/AnimationStateMachine#581
drsnuggles8 merged 1 commit into
masterfrom
feature/skinning-frame-arena

Conversation

@drsnuggles8

@drsnuggles8 drsnuggles8 commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #445.

  • Renderer3D::DrawAnimatedMesh/DrawAnimatedMeshParallel (the issue's headline example) already write bone matrices into FrameDataBuffer via offset+count (AllocateBoneMatrices/WriteBoneMatrices) — in place since commit 789c48f2, predating this issue. No changes needed there.
  • The remaining per-frame heap churn was one layer up: AnimationGraph::Update() built a fresh std::vector<BoneTransform> inside its per-layer loop every tick, and AnimationStateMachine::Update()'s cross-fade path did the same for every ticked frame of a transition. Both are now persistent per-instance scratch members, resize()d in place instead of reallocated.
  • Verified safe: every downstream writer (BlendTree::FillBindPose/Evaluate*/BlendBoneTransforms, AnimationStateMachine::Update) fully overwrites [0, boneCount); each entity owns its own AnimationGraph instance (Scene.cpp Clone()s one per entity); the AnimationGraph/AnimationGraphSystem gameplay step is not .Parallelizable().
  • Added a stale-scratch guard (targetTransforms.clear()) for the one case where a reused buffer could otherwise carry over a previous transition's target pose.
  • Documented the reusable pattern in docs/agent-rules/per-frame-scratch-reuse.md.

Test plan

  • Added 2 regression tests in AnimationStateMachineTest.cpp targeting the reuse-specific hazard (stale data bleeding across transitions/layers/ticks): SequentialCrossFadesDoNotLeakScratchBetweenTransitions, MultiLayerGraphUpdateReusesScratchAcrossLayersAndTicksWithoutBleed.
  • Full OloEngine-Tests suite: 4184 passed, 5 skipped (env-gated), 0 failed.
  • Visually verified against the animated fox.olo scene in the editor — clean frame, no shader/GL errors, DrawAnimatedMesh still logs correct bone-matrix offsets.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added guidance for reusing per-frame scratch buffers to reduce repeated allocations in animation updates.
    • Improved animation evaluation to keep temporary pose data around between frames for faster steady-state performance.
  • Bug Fixes
    • Fixed stale pose data from leaking between animation transitions and layered updates.
    • Added regression coverage to verify cross-fades and multi-layer graph updates remain correct across ticks.

…nimationStateMachine (#445)

Renderer3D::DrawAnimatedMesh already writes bone matrices into FrameDataBuffer
via offset+count, but AnimationGraph::Update and AnimationStateMachine::Update
still built fresh std::vector<BoneTransform> scratch (per-layer, per cross-fade
tick) as function locals every frame. Promote them to persistent per-instance
members, resized in place instead of reallocated, since every downstream
writer fully overwrites its buffer and each entity owns its own graph instance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

AnimationGraph and AnimationStateMachine now reuse persistent per-instance scratch vectors for bone transforms instead of allocating local vectors each tick. New regression tests verify no stale data leaks across cross-fade transitions and multi-layer graph updates. A companion documentation page and CLAUDE.md entry describe the reuse rule.

Changes

Per-frame scratch buffer reuse

Layer / File(s) Summary
AnimationGraph scratch buffers
OloEngine/src/OloEngine/Animation/AnimationGraph.h, AnimationGraph.cpp
Adds m_ScratchAccumulated and m_ScratchLayer member vectors and rebinds Update's accumulated and per-layer transform locals to them.
AnimationStateMachine scratch buffers
OloEngine/src/OloEngine/Animation/AnimationStateMachine.h, AnimationStateMachine.cpp
Adds m_ScratchCurrentTransforms and m_ScratchTargetTransforms members and reuses them in the transition cross-fade path, clearing target scratch before evaluation.
Regression tests for scratch reuse
OloEngine/tests/AnimationStateMachineTest.cpp
Adds headers and two tests verifying sequential cross-fades and multi-layer graph updates do not leak stale scratch data across transitions, layers, or ticks.
Documentation
docs/agent-rules/per-frame-scratch-reuse.md, CLAUDE.md
Adds a new agent-rules doc describing the scratch-reuse allocation trap, safety conditions, and related tests, linked from CLAUDE.md's companion guides.

Related Issues: #445

Suggested Labels: performance, animation, tests, documentation

Suggested Reviewers: drsnuggles8

🥁 A tick, a fade, a vector reused,

No fresh heap grabbed, no memory bruised.

Bones blend and settle, tests stand their ground,

Scratch buffers dance without a leak found.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR reduces allocations in animation hot paths, but it does not implement the issue’s renderer frame-buffer offset+count path. Update animated-mesh submission to write bone matrices into FrameDataBuffer and pass offset+count, then verify allocation reductions.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main animation scratch-buffer reuse change.
Out of Scope Changes check ✅ Passed The added docs, tests, and internal scratch-buffer changes all support the stated animation performance goal.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

sonarqubecloud Bot commented Jul 7, 2026

Copy link
Copy Markdown

@drsnuggles8

Copy link
Copy Markdown
Owner Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
OloEngine/src/OloEngine/Animation/AnimationStateMachine.cpp (1)

64-68: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Same stale-scratch hazard as flagged in AnimationGraph.cpp — see that comment for the full analysis and fix.

These bare resize(boneCount) calls (with no element writes) are safe only as long as the output buffer starts empty each call. Since AnimationGraph::Update() now binds this parameter to the persistent m_ScratchLayer (reused across layers/ticks), hitting any of these branches will silently surface stale data from a previous evaluation instead of a fresh/identity pose.

Also applies to: 74-78, 135-138

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@OloEngine/src/OloEngine/Animation/AnimationStateMachine.cpp` around lines 64
- 68, The early-return paths in AnimationStateMachine::Update leave
outBoneTransforms only resized and not rewritten, which can leak stale pose data
when the buffer is reused. Update the branches in AnimationStateMachine::Update
that currently only call resize(boneCount) so they explicitly fill every
BoneTransform with a fresh default/identity value before returning. Apply the
same fix to the other matching branches noted in this method so the persistent
scratch buffer always receives fully initialized output.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@OloEngine/src/OloEngine/Animation/AnimationGraph.cpp`:
- Around line 61-62: The scratch layer is being reused without being fully
reinitialized, so stale bone transforms can survive when the layer is only
resized. Update the `AnimationGraph::Update` path that prepares `m_ScratchLayer`
before `layer.StateMachine->Update(...)` so it always overwrites the full pose,
using `BlendTree::FillBindPose(...)` or an equivalent full initialization
instead of relying on `resize(boneCount)`. Make sure the early-return handling
in `AnimationStateMachine` also leaves `m_ScratchLayer` in a valid bind-pose
state before `ApplyLayerTransforms()` consumes it.

In `@OloEngine/tests/AnimationStateMachineTest.cpp`:
- Around line 400-482: Add a regression test for the missing-state fallback in
AnimationStateMachineTest: after a tick has populated the scratch buffers with a
non-identity pose, remove the currently active state with RemoveState() (or
force a transition target to be removed) and update again. Use
AnimationStateMachine, RemoveState, Start, and Update to verify the fallback
path clears or resets m_ScratchLayer and m_ScratchCurrentTransforms instead of
reusing stale pose data, and assert the resulting BoneTransform output is reset
rather than leaking the previous state.

---

Duplicate comments:
In `@OloEngine/src/OloEngine/Animation/AnimationStateMachine.cpp`:
- Around line 64-68: The early-return paths in AnimationStateMachine::Update
leave outBoneTransforms only resized and not rewritten, which can leak stale
pose data when the buffer is reused. Update the branches in
AnimationStateMachine::Update that currently only call resize(boneCount) so they
explicitly fill every BoneTransform with a fresh default/identity value before
returning. Apply the same fix to the other matching branches noted in this
method so the persistent scratch buffer always receives fully initialized
output.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9da1f409-ab68-499e-b2fe-6a52a85c69e3

📥 Commits

Reviewing files that changed from the base of the PR and between a8a1379 and 5021665.

📒 Files selected for processing (7)
  • CLAUDE.md
  • OloEngine/src/OloEngine/Animation/AnimationGraph.cpp
  • OloEngine/src/OloEngine/Animation/AnimationGraph.h
  • OloEngine/src/OloEngine/Animation/AnimationStateMachine.cpp
  • OloEngine/src/OloEngine/Animation/AnimationStateMachine.h
  • OloEngine/tests/AnimationStateMachineTest.cpp
  • docs/agent-rules/per-frame-scratch-reuse.md

Comment on lines +61 to 62
std::vector<BoneTransform>& layerTransforms = m_ScratchLayer;
layer.StateMachine->Update(dt, Parameters, boneCount, ctx, layerTransforms);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm BoneTransform's default value and Evaluate()'s write pattern.
rg -n -A6 'struct BoneTransform' OloEngine/src
rg -n -A20 'void AnimationState::Evaluate' OloEngine/src/OloEngine/Animation
rg -n -A15 'FillBindPose' OloEngine/src/OloEngine/Animation

Repository: drsnuggles8/OloEngineBase

Length of output: 18991


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the state machine update path and the graph blending path in context.
ast-grep outline OloEngine/src/OloEngine/Animation/AnimationStateMachine.cpp --view expanded || true
ast-grep outline OloEngine/src/OloEngine/Animation/AnimationGraph.cpp --view expanded || true

printf '\n--- AnimationStateMachine.cpp ---\n'
sed -n '1,260p' OloEngine/src/OloEngine/Animation/AnimationStateMachine.cpp

printf '\n--- AnimationGraph.cpp ---\n'
sed -n '1,220p' OloEngine/src/OloEngine/Animation/AnimationGraph.cpp

Repository: drsnuggles8/OloEngineBase

Length of output: 18711


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the state machine implementation and any helper paths that write outputs.
rg -n -A20 -B10 'outBoneTransforms\.resize|FillBindPose|Evaluate\(|Update\(' OloEngine/src/OloEngine/Animation/AnimationStateMachine.cpp OloEngine/src/OloEngine/Animation/AnimationState.cpp OloEngine/src/OloEngine/Animation/BlendTree.cpp

Repository: drsnuggles8/OloEngineBase

Length of output: 39173


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '284,410p' OloEngine/src/OloEngine/Animation/AnimationGraph.cpp

Repository: drsnuggles8/OloEngineBase

Length of output: 3445


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check how layer outputs are consumed so the neutral fallback is clear.
sed -n '284,410p' OloEngine/src/OloEngine/Animation/AnimationGraph.cpp

Repository: drsnuggles8/OloEngineBase

Length of output: 3445


Initialize the scratch pose on the early-return paths in AnimationStateMachine.cpp
m_ScratchLayer is reused, so resize(boneCount) preserves prior layer data whenever the vector is already large enough. A missing/removed state can then feed stale transforms into ApplyLayerTransforms(). Use a full overwrite here (BlendTree::FillBindPose(...) or equivalent) instead of bare resize().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@OloEngine/src/OloEngine/Animation/AnimationGraph.cpp` around lines 61 - 62,
The scratch layer is being reused without being fully reinitialized, so stale
bone transforms can survive when the layer is only resized. Update the
`AnimationGraph::Update` path that prepares `m_ScratchLayer` before
`layer.StateMachine->Update(...)` so it always overwrites the full pose, using
`BlendTree::FillBindPose(...)` or an equivalent full initialization instead of
relying on `resize(boneCount)`. Make sure the early-return handling in
`AnimationStateMachine` also leaves `m_ScratchLayer` in a valid bind-pose state
before `ApplyLayerTransforms()` consumes it.

Comment on lines +400 to +482
TEST(AnimationStateMachineTest, SequentialCrossFadesDoNotLeakScratchBetweenTransitions)
{
// Idle(x=0) -> Walk(x=10) -> Run(x=20): each clip is distinguishable so a
// stale carry-over from the FIRST transition's scratch would show up as a
// wrong blended value in the SECOND transition.
auto idleClip = CreateTestClip("Idle", 1.0f, glm::vec3(0.0f), glm::vec3(0.0f));
auto walkClip = CreateTestClip("Walk", 1.0f, glm::vec3(10.0f, 0.0f, 0.0f), glm::vec3(10.0f, 0.0f, 0.0f));
auto runClip = CreateTestClip("Run", 1.0f, glm::vec3(20.0f, 0.0f, 0.0f), glm::vec3(20.0f, 0.0f, 0.0f));

AnimationStateMachine sm;

AnimationState idleState;
idleState.Name = "Idle";
idleState.Clip = idleClip;
sm.AddState(idleState);

AnimationState walkState;
walkState.Name = "Walk";
walkState.Clip = walkClip;
sm.AddState(walkState);

AnimationState runState;
runState.Name = "Run";
runState.Clip = runClip;
sm.AddState(runState);

sm.SetDefaultState("Idle");

AnimationTransition idleToWalk;
idleToWalk.SourceState = "Idle";
idleToWalk.DestinationState = "Walk";
idleToWalk.BlendDuration = 0.2f;
TransitionCondition walkCond;
walkCond.ParameterName = "Speed";
walkCond.Op = TransitionCondition::Comparison::Equal;
walkCond.FloatThreshold = 1.0f;
idleToWalk.Conditions.push_back(walkCond);
sm.AddTransition(idleToWalk);

AnimationTransition walkToRun;
walkToRun.SourceState = "Walk";
walkToRun.DestinationState = "Run";
walkToRun.BlendDuration = 0.2f;
TransitionCondition runCond;
runCond.ParameterName = "Speed";
runCond.Op = TransitionCondition::Comparison::Equal;
runCond.FloatThreshold = 2.0f;
walkToRun.Conditions.push_back(runCond);
sm.AddTransition(walkToRun);

AnimationParameterSet params;
params.DefineFloat("Speed", 0.0f);
sm.Start(params);

std::vector<BoneTransform> bones;

// First transition: Idle -> Walk. Complete it fully.
params.SetFloat("Speed", 1.0f);
sm.Update(0.01f, params, 1, s_Bone0Ctx, bones);
ASSERT_TRUE(sm.IsInTransition());
sm.Update(0.3f, params, 1, s_Bone0Ctx, bones);
ASSERT_EQ(sm.GetCurrentStateName(), "Walk");
ASSERT_FALSE(sm.IsInTransition());
EXPECT_NEAR(bones[0].Translation.x, 10.0f, 1e-3f) << "settled Walk pose is wrong before the second transition";

// Second transition: Walk -> Run, reusing the same scratch buffers the
// first transition used.
params.SetFloat("Speed", 2.0f);
sm.Update(0.01f, params, 1, s_Bone0Ctx, bones);
ASSERT_TRUE(sm.IsInTransition());

// Mid-blend: result must be strictly between Walk (10) and Run (20) — a
// stale Idle (0) leaking in from the first transition's scratch would
// pull this below 10 or otherwise off the Walk/Run blend line.
sm.Update(0.1f, params, 1, s_Bone0Ctx, bones);
EXPECT_GT(bones[0].Translation.x, 10.0f) << "second transition's blend was polluted by stale scratch data";
EXPECT_LT(bones[0].Translation.x, 20.0f) << "second transition's blend was polluted by stale scratch data";

sm.Update(0.3f, params, 1, s_Bone0Ctx, bones);
EXPECT_EQ(sm.GetCurrentStateName(), "Run");
EXPECT_FALSE(sm.IsInTransition());
EXPECT_NEAR(bones[0].Translation.x, 20.0f, 1e-3f) << "settled Run pose is wrong after the second transition";
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Good coverage for the happy path; add a case for the missing-state fallback.

Both new tests validate reuse correctness only when states/transitions resolve successfully. Given the stale-scratch issue flagged in AnimationStateMachine.cpp (bare resize() on the m_CurrentState.empty() / !currentState fallback paths), consider adding a regression test that calls RemoveState() on the currently-active state mid-playback (or targets a transition to a since-removed state) after a prior tick has populated m_ScratchLayer/m_ScratchCurrentTransforms with non-identity values, then asserts the output is reset (not stale) rather than leaking the prior pose.

Want me to draft this test once the underlying resize()assign() fix lands?

Also applies to: 489-571

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@OloEngine/tests/AnimationStateMachineTest.cpp` around lines 400 - 482, Add a
regression test for the missing-state fallback in AnimationStateMachineTest:
after a tick has populated the scratch buffers with a non-identity pose, remove
the currently active state with RemoveState() (or force a transition target to
be removed) and update again. Use AnimationStateMachine, RemoveState, Start, and
Update to verify the fallback path clears or resets m_ScratchLayer and
m_ScratchCurrentTransforms instead of reusing stale pose data, and assert the
resulting BoneTransform output is reset rather than leaking the previous state.

@drsnuggles8 drsnuggles8 merged commit 8db90a4 into master Jul 7, 2026
10 checks passed
@drsnuggles8 drsnuggles8 deleted the feature/skinning-frame-arena branch July 7, 2026 08:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Perf/DOD: stop heap-allocating per-frame skinning scratch — write into the renderer frame allocator (offset+count)

1 participant