Skip to content

Animation blending and root motion#1837

Open
Sezzary wants to merge 361 commits intodevelopfrom
sezz/anim-blend
Open

Animation blending and root motion#1837
Sezzary wants to merge 361 commits intodevelopfrom
sezz/anim-blend

Conversation

@Sezzary
Copy link
Copy Markdown
Member

@Sezzary Sezzary commented Dec 13, 2025

Checklist

Links to issue(s) this pull request concerns (if applicable)

N/A

Description of pull request

Introduces the following new features:

  • Animation blending.
  • Root motion.

TODO:

  • Investigate crash when unholstering weapons.
  • Check if extra code is required for root motion at 60FPS.
  • Fix hair teleport.
  • Add more 2 or 3 frame blends for certain hardcoded animation dispatches.
  • Check if any more merge errors made it in.
  • Remove debug code when it's no longer needed.
  • Make TE write relevant data to the level.

Related TE PR: TombEngine/Tomb-Editor#1095

@Lwmte
Copy link
Copy Markdown
Collaborator

Lwmte commented Jan 6, 2026

@Sezzary FYI: GetLastFrame function crashes because it refers to INT_MAX frame number, and GetLastFrame has no bounds checks.

@Lwmte Lwmte added this to the Version 1.12 milestone Feb 3, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds animation blending and root motion support to TombEngine’s animation, item, and renderer pipelines, including new curve math utilities and updated level serialization for the extra animation data.

Changes:

  • Introduces per-animation blend settings (frame count + Bezier curve) and per-item runtime blend state used by the renderer.
  • Implements root motion extraction/counteraction and applies it across rendering transforms and various gameplay queries/bounds.
  • Replaces legacy “keyframe interpolation” access patterns with direct Frames[...] / GetFrame(...) usage and renames/reshapes related structs/constants.

Reviewed changes

Copilot reviewed 48 out of 49 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
TombEngine/TombEngine.vcxproj Adds BezierCurve2 sources to the build.
TombEngine/Specific/level.cpp Reads new animation data (blend curves, fixed motion curves, per-frame root position).
TombEngine/Scripting/Internal/TEN/Objects/Moveable/MoveableObject.h Extends Lua API to allow specifying blend frames when setting animation.
TombEngine/Scripting/Internal/TEN/Objects/Moveable/MoveableObject.cpp Wires scripting animation changes to new blending-aware SetAnimation.
TombEngine/Scripting/Internal/TEN/Objects/Lara/LaraObject.cpp Minor cleanup to TestLaraPosition call.
TombEngine/Renderer/Structures/RendererMirror.h Formatting/whitespace normalization.
TombEngine/Renderer/Structures/RendererItem.h Renames bone arrays and interpolation buffers to new constants/names.
TombEngine/Renderer/RendererLara.cpp Applies root motion counteraction to Lara world matrix and updates animation update calls for new frame/blend path.
TombEngine/Renderer/RendererHelper.cpp Updates animation evaluation to use FrameData + optional blend data.
TombEngine/Renderer/RendererFrame.cpp Applies root motion counteraction to renderer item transforms and updates interpolation buffer handling.
TombEngine/Renderer/RendererEnums.h Renames MAX_BONES/MAX_BONE_WEIGHTS to BONE_COUNT_MAX/BONE_WEIGHT_COUNT_MAX.
TombEngine/Renderer/RendererDrawMenu.cpp Updates preview animation path to use Frames rather than keyframe interpolation data.
TombEngine/Renderer/RendererDrawEffect.cpp Updates effect attachment/root offsets to new Frames/RootPosition fields and renamed bone buffers.
TombEngine/Renderer/RendererDraw.cpp Updates skinning transforms/bone loops to new bone constants and renamed buffers.
TombEngine/Renderer/Renderer.h Updates UpdateAnimation signature to new frame/blend types.
TombEngine/Renderer/ConstantBuffers/ItemBuffer.h Updates constant buffer arrays to use BONE_COUNT_MAX.
TombEngine/Objects/Utils/object_helper.cpp Uses new Frames container for “has > 1 frame” checks.
TombEngine/Objects/TR5/Entity/tr5_guard.cpp Updates bounds query to GetFrame.
TombEngine/Objects/TR4/Trap/tr4_teethspike.cpp Updates bounds query to GetFrame.
TombEngine/Objects/TR4/Trap/tr4_joby_spikes.cpp Refactors flag locals and switches player height calc to OBB-based approach.
TombEngine/Objects/TR2/Entity/tr2_mercenary.cpp Minor comment formatting for deterministic shooting rate logic.
TombEngine/Objects/Generic/Object/rope.cpp Updates root offset access to GetFrame(...).RootPosition.
TombEngine/Objects/Generic/Object/Pushable/PushableStates.cpp Updates last-frame bounds query helper.
TombEngine/Objects/Generic/Object/Pushable/PushableCollision.cpp Updates root offset access to GetFrame(...).RootPosition.
TombEngine/Objects/Generic/Object/BridgeObject.cpp Updates local AABB access to Frames[].LocalAabb.
TombEngine/Math/Objects/GameBoundingBox.cpp Switches to GetFrame and applies root-motion counteraction translation in deprecated ctor.
TombEngine/Math/Objects/BezierCurve2.h Adds new 2D Bezier curve utility type used for blending and fixed motion.
TombEngine/Math/Objects/BezierCurve2.cpp Implements Bezier curve presets + evaluation (GetY via Newton-Raphson).
TombEngine/Math/Math.h Exposes BezierCurve2 through the umbrella math header.
TombEngine/Game/pickup/pickup.cpp Updates plinth bounds queries to Frames[...] and leaves a commented-out debug return.
TombEngine/Game/people.cpp Updates target bounds queries to GetFrame.
TombEngine/Game/items.h Renames/reshapes ItemInfo sub-structs; adds blending state + helpers; pulls in renderer bone constants.
TombEngine/Game/items.cpp Implements blending state capture (SetAnimBlend/DisableAnimBlend) and updates OBB calculation.
TombEngine/Game/effects/tomb4fx.cpp Updates bounds query to GetFrame.
TombEngine/Game/effects/hair.cpp Updates bounds query to Frames[...] / GetFrame.
TombEngine/Game/control/box.cpp Updates various bounds/frame queries to Frames/GetFrame.
TombEngine/Game/collision/collide_room.cpp Updates collision AABB bounds query to GetFrame.
TombEngine/Game/collision/collide_item.cpp Updates collision bounds queries to GetFrame/Frames[...] and formatting.
TombEngine/Game/Lara/lara_initialise.cpp Renames EntityAnimationData to MoveableAnimData in backup globals/copy.
TombEngine/Game/Lara/lara_helpers.cpp Updates many SetAnimation calls to include blend parameters/curves.
TombEngine/Game/Lara/lara_flare.cpp Refactors flare object/anim assignment (currently introduces an AnimObjectID mismatch).
TombEngine/Game/Lara/lara_fire.cpp Updates bounds query to GetFrame.
TombEngine/Game/Lara/lara_collide.cpp Updates several SetAnimation calls to blend-aware overloads and tweaks comments.
TombEngine/Game/Lara/lara.h Adds default player animation blend frame count constant.
TombEngine/Game/Lara/lara.cpp Adds a debug block forcing root-motion flags on Lara animations; updates a fall transition to blend.
TombEngine/Game/Animation/Commands.h Renames command base type and “MoveOrigin” -> “MoveRoot”.
TombEngine/Game/Animation/Commands.cpp Updates move-root execution and uses OBB-based height for Lara room update.
TombEngine/Game/Animation/Animation.h Reworks animation data model (frames, dispatch ranges, curves, flags) and updates API signatures.
TombEngine/Game/Animation/Animation.cpp Implements fixed motion + root motion extraction/counteraction and blending integration into animation updates.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +88 to +96
{
auto interpData = GetKeyframeInterpolationData(frameNumber);
return ((interpData.Alpha <= 0.5f) ? interpData.Keyframe0 : interpData.Keyframe1);
const auto& rootOrient = Frames[frameNumber].BoneOrientations.front();
const auto& prevRootOrient = Frames[frameNumber - 1].BoneOrientations.front();
auto rootRot = rootOrient - prevRootOrient;

rot = EulerAngles(
(Flags & (int)AnimFlags::RootMotionRotationX) ? rootRot.x : ANGLE(0.0f),
(Flags & (int)AnimFlags::RootMotionRotationY) ? rootRot.y : ANGLE(0.0f),
(Flags & (int)AnimFlags::RootMotionRotationZ) ? rootRot.z : ANGLE(0.0f));
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

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

AnimData::GetRootMotion() computes rootRot via rootOrient - prevRootOrient and then treats the quaternion components as Euler angles. Quaternion subtraction is not a valid way to derive relative rotation and will yield incorrect/root-motion rotations. Compute a delta rotation (e.g., delta = rootOrient * Inverse(prevRootOrient)), convert that delta to EulerAngles, then mask axes via the flags.

Copilot uses AI. Check for mistakes.
Comment on lines +327 to +328
flareItem.Animation.AnimObjectID = flareItem.ObjectNumber;
flareItem.ObjectNumber = objectID;
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

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

CreateFlare() sets flareItem.Animation.AnimObjectID to the old flareItem.ObjectNumber and then overwrites ObjectNumber with objectID. This leaves AnimObjectID pointing at the wrong slot, which will break animation lookup for the flare/torch. Assign AnimObjectID to objectID (or set it after updating ObjectNumber).

Suggested change
flareItem.Animation.AnimObjectID = flareItem.ObjectNumber;
flareItem.ObjectNumber = objectID;
flareItem.ObjectNumber = objectID;
flareItem.Animation.AnimObjectID = objectID;

Copilot uses AI. Check for mistakes.
Comment on lines 437 to 440
// Load keyframes.
int frameCount = ReadCount();
anim.Keyframes.resize(frameCount);
for (auto& keyframe : anim.Keyframes)
anim.Frames.resize(ReadInt32());
for (auto& keyframe : anim.Frames)
{
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

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

LoadObjects() reads the frame count with ReadInt32() and uses it to resize anim.Frames without the bounds validation that ReadCount() provides. A malformed/outdated level can trigger huge allocations or OOM here; prefer ReadCount() (with an appropriate max) for consistency with other size reads in this loader.

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +56
auto rootMotionCounter = anim.GetRootMotionCounteraction(item->Animation.FrameNumber);

*this = anim.Frames[item->Animation.FrameNumber].BoundingBox;

Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

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

GameBoundingBox(const ItemInfo*) indexes anim.Frames[item->Animation.FrameNumber] directly with no clamping. If FrameNumber ever drifts out of range (e.g., due to bad data or state transitions), this will be an immediate OOB read. Consider using GetFrame(*item) (which clamps) or explicitly clamping FrameNumber against anim.Frames.size() before indexing.

Suggested change
auto rootMotionCounter = anim.GetRootMotionCounteraction(item->Animation.FrameNumber);
*this = anim.Frames[item->Animation.FrameNumber].BoundingBox;
// If the current animation has no frames, return empty bounds.
if (anim.Frames.empty())
{
*this = GameBoundingBox::Zero;
return;
}
int frameIndex = item->Animation.FrameNumber;
if (frameIndex < 0)
frameIndex = 0;
else if (frameIndex >= static_cast<int>(anim.Frames.size()))
frameIndex = static_cast<int>(anim.Frames.size()) - 1;
auto rootMotionCounter = anim.GetRootMotionCounteraction(frameIndex);
*this = anim.Frames[frameIndex].BoundingBox;

Copilot uses AI. Check for mistakes.
static auto defautBounds = GameBoundingBox(-MIN_BOUND, MIN_BOUND, 0, MIN_BOUND, -MIN_BOUND, MIN_BOUND);

return &defautBounds;
//return &GetFrame(g_Level.Items[itemNumber]).BoundingBox;
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

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

There is leftover commented-out debug code after the return in FindPlinth() (//return &GetFrame(...)). This is dead code and makes it harder to tell what the intended fallback behavior is. Please remove it (or replace with a proper TODO referencing an issue).

Suggested change
//return &GetFrame(g_Level.Items[itemNumber]).BoundingBox;

Copilot uses AI. Check for mistakes.
Comment on lines +145 to +152
// debug
auto& object = Objects[ID_LARA];
object.Animations[LA_RUN].Flags |= (int)AnimFlags::RootMotionTranslationZ;
object.Animations[LA_WALK].Flags |= (int)AnimFlags::RootMotionTranslationZ;
object.Animations[LA_TURN_180_START].Flags |= (int)AnimFlags::RootMotionRotationY;
object.Animations[LA_CROUCH_TURN_180_START].Flags |= (int)AnimFlags::RootMotionRotationY;
//object.Animations[LA_CRAWL_TURN_180_START].Flags |= (int)AnimFlags::RootMotionRotationY; // cant set??

Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

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

This block force-enables root-motion flags on Lara animations every frame and is marked // debug. It will affect normal gameplay behavior outside of debugging and is also listed as TODO in the PR description. Please remove it before merging, or gate it behind an explicit debug setting/cheat so it can't ship enabled unintentionally.

Suggested change
// debug
auto& object = Objects[ID_LARA];
object.Animations[LA_RUN].Flags |= (int)AnimFlags::RootMotionTranslationZ;
object.Animations[LA_WALK].Flags |= (int)AnimFlags::RootMotionTranslationZ;
object.Animations[LA_TURN_180_START].Flags |= (int)AnimFlags::RootMotionRotationY;
object.Animations[LA_CROUCH_TURN_180_START].Flags |= (int)AnimFlags::RootMotionRotationY;
//object.Animations[LA_CRAWL_TURN_180_START].Flags |= (int)AnimFlags::RootMotionRotationY; // cant set??

Copilot uses AI. Check for mistakes.
Comment on lines +79 to 84
if (frame.BoneOrientations.size() <= bone->Index)
{
TENLog(
"Attempted to animate object with ID " + GetObjectName((GAME_OBJECT_ID)rItem->ObjectID) +
"Attempted to animate object with ID " + GetObjectName((GAME_OBJECT_ID)rendererItem->ObjectID) +
" using incorrect animation data. Bad animations set for slot?",
LogLevel::Error);
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

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

UpdateAnimation() can be called with rendererItem == nullptr (e.g., menu/inventory preview), but the error log path unconditionally dereferences rendererItem->ObjectID, which will crash. Use rendererObject.Id (or pass an ID explicitly) when rendererItem is null, or guard the log message with a null check.

Copilot uses AI. Check for mistakes.
const auto& extents = anim.Frames[Animation.FrameNumber].LocalAabb.Extents;

// Create and return OBB.
return BoundingOrientedBox(Pose.Position.ToVector3() + offset, extents, Pose.Orientation.ToQuaternion());
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

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

ItemInfo::GetObb() builds the offset using (Pose.Orientation + rootMotionCounter.Rotation), but the returned BoundingOrientedBox orientation uses Pose.Orientation.ToQuaternion() (without the root-motion counteraction). This makes the OBB rotation inconsistent with the center offset and will skew collision/queries under root motion. Use the same effective orientation for the OBB that you used to compute rotMatrix (or remove the counter-rotation from the offset computation).

Suggested change
return BoundingOrientedBox(Pose.Position.ToVector3() + offset, extents, Pose.Orientation.ToQuaternion());
return BoundingOrientedBox(
Pose.Position.ToVector3() + offset,
extents,
(Pose.Orientation + rootMotionCounter.Rotation).ToQuaternion());

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +81
// Newton-Raphson iteration for approximate Y alpha.
float alpha = x / GetEnd().x;
for (int i = 0; i < ITERATION_COUNT_MAX; i++)
{
auto point = GetPoint(alpha);
auto derivative = GetDerivative(alpha);

float delta = (point.x - x) / derivative.x;
alpha -= delta;
if (abs(delta) <= TOLERANCE)
break;
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

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

BezierCurve2::GetY() does Newton-Raphson using delta = (point.x - x) / derivative.x without guarding against derivative.x being 0 (or extremely small). For curves with near-vertical tangents this can produce inf/NaN and propagate into blend/root-motion calculations. Add an epsilon check for derivative.x, clamp alpha each iteration, and/or fall back to a safer method (e.g., binary search) when the derivative is too small.

Copilot uses AI. Check for mistakes.
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.

4 participants