diff --git a/docs/dev/plans/2026-09-12-traversal-reference-parity.md b/docs/dev/plans/2026-09-12-traversal-reference-parity.md new file mode 100644 index 00000000..a4e3c228 --- /dev/null +++ b/docs/dev/plans/2026-09-12-traversal-reference-parity.md @@ -0,0 +1,473 @@ +# Traversal reference parity + +## 2026-09-13 unified air matching and contact continuity + +- Ground, takeoff, fall and landing now render through the same schema-specific MotionMatcher. The auxiliary air player only tracks phase timing for the non-reference fallback; contact curves and generic inertia sample the pose source that is actually rendered. This removes the previous split where the Chooser state remained OnGround while a separate air clip was displayed. +- Movement-mode transitions retain the exact reference blend durations: jump 0.15 s, landing 0.20 s, ordinary ground/air changes 0.50 s. Same-schema landing reselection and cross-schema activation no longer replace an already queued movement-mode transition in the same fixed update. +- Mantle hand contact now preserves an authored right-hand plant when present. Pelvis reach limiting uses the posed shoulder-elbow-wrist chain length used by two-bone IK, avoiding the retargeted bind-length overestimate that allowed a 9.135 cm vertical fingertip displacement. +- `build/reference-selection-exact-mode-blends` passes the complete selection/render/contact suite, including explicit jump and landing duration assertions, local-pose handoff continuity, 12 fingertip contact cases, 24 consecutive traversals, Hurdle/Vault, moving support and high-platform falling. `build/reference-air-integrated` passes all 12 stand/walk/run/sprint and light/heavy air families. `build/climbing-motion-matching-unified` passes the complete input/collision/gravity/traversal regression. +- Render audit confirmed that the three grounded obstacle heights selecting the same `Mantle_1_0` family is source-consistent: the imported Chooser uses that family for OnGround obstacle heights 0-150 cm and reserves the low/medium/high catch families for InAir entries. No height-specific override was added. +- Generic BlendStack inertia now samples incoming matcher history in animation time using the active 0.85-1.15 play rate. At the first rendered frame after selection, current and previous target samples therefore bracket the actual selected instant instead of using an unscaled simulation-step offset. `build/reference-selection-playrate-inertia` passes the complete suite and its explicit 1.15x sample-time assertion. +- OffsetRootBone translation/yaw extraction, OrientationWarping root direction and contact/Phase curve derivatives now use the same play-rate-scaled animation step. Matcher curve switches inherit the external BlendStack duration (including 0.15 s jump and 0.20 s landing) instead of the intentionally disabled internal matcher blend time. `build/reference-selection-unified-timebase-2` passes the full suite and explicit external-curve-duration assertion. +- Steering root-angular-rate continuity now also uses the external BlendStack duration. The cached schema matcher's zero internal blend can no longer collapse angular velocity smoothing at a clip/schema switch. `build/reference-selection-unified-blend-owner` passes the full suite and asserts the jump root-rate window remains 0.15 s. +- `build/reference-selection-playrate-root-proof` adds real-runtime numeric coverage for root extraction across a loop boundary: a 1.15x matcher advances a 2 m/s source by 0.046 m over a 20 ms simulation step. The complete suite remains passing, proving the scaled sampler did not regress action selection, contacts or traversal exits. + +Objective: fix all six identified differences against the local 游戏动画示例2 reference, in order. This is active work; a passing traversal smoke test is not completion of the objective. + +1. Collision/display authority: never restore rejected capsule displacement into the rendered root. Keep explicitly authored floor/pelvis adaptation separate. Verify a second obstacle constrains both capsule and display, plus normal contact and release regression. +2. Conditional montage exit: import and evaluate Force Blend Out, With Movement Input and If Falling within notify windows; preserve duration and blend profile. Verify stationary completion, input arriving during a window, falling release, and cancellation. Version exported data; reject unsupported conditions without partial publication. +3. Continuous transitions: implement reference-relevant inertia, blend-stack interruption and bounded root offset/rotation behavior, with per-bone profiles. Verify position/velocity continuity across repeated switches and traversal release, not just endpoint poses. +4. Foot contact: source contact curves, planting/unplanting, ground tracing, pelvis compensation and leg IK during ordinary locomotion and traversal. Verify raised surfaces, edges, turns and moving support without foot sliding/penetration; explicit unsupported configurations. +5. Root-motion warp: implement source modifier translation/rotation semantics and moving target transforms, replacing ad hoc endpoint interpolation. Verify different heights, widths, approach angles, reachable contact and safe rejection when geometry cannot fit. +6. Schema-specific matching: implement the referenced schema channels/weights/horizons/normalization and traversal entry matching, replacing common-ground-only features and script phase filtering. Verify database and query encoders agree, schema routing, deterministic selection, representative rendered motion and performance. + +## Evidence and current status + +- Step 1 implementation changes `reference_root_motion.nut` to retain only pre-collision floor lowering; horizontal or vertical rejected collision displacement is never added back. Added real-engine blocker regression in `verify_reference_selection.py`; verified by `build/traversal-six-step1-final/result.json` (PASS): the second obstacle actually constrained motion while the displayed root could not restore it. The same run passed three contact heights, 24 repeated traversals, Hurdle/Vault landing, moving support, high-platform movement and camera checks. Engine-owned contact screenshot inspected. +- Step 2 fresh UE commandlet exports are in ignored `build/ue-reference/BP_NotifyState_MontageBlendOut{,_Default}.t3d`. Default condition is NewEnumerator0 (Force Blend Out), default duration 0.2 s, profile FastFeet_InstantRoot. Montage overrides include With Movement Input and 0.3 s. Received_NotifyTick calls Montage_StopWithBlendSettings and GetBlendProfileByName; do not replace these with an unconditional deadline. +- Step 2 data layer now emits selection schema 2 with `exits` records, resolves class defaults, preserves long source windows, and rejects unsupported conditions before publishing the montage. Runtime loader validates schema 2. All 34 montages regenerated; 8 converter tests pass. Runtime now evaluates notify conditions, releases native control at blend start and plays a separate advancing tail. Imported TimeFactor profiles apply per-bone durations; the actual root/feet early-completion check passed. `build/traversal-six-step2-profile/result.json` passes the full regression, with additional live EXIT_PROFILE_RUNTIME_PASS. Natural montage auto blend-out now resolves the exported AnimMontage CDO and per-asset overrides, uses remaining play time when trigger=-1, and uses the configured duration for an explicit trigger. Source Linear and HermiteCubic curves are honored. `build/traversal-six-step2-natural/result.json` passes, with natural-exit frame inspected; converter suite now 14 passing tests. +- Fresh `SK_UEFN_Mannequin.t3d` contains FastFeet_InstantRoot: TimeFactor, root explicit entry has omitted BlendScale (=0 per UE BlendProfile.h), legs and foot IK entries 0.75, unspecified bones default 1. Profiles are now compiled from actual entries and applied to live tail playback. Converter suite: 10 tests passed. +- AC_TraversalLogic EventGraph confirms OnBlendOut clears DoingTraversalAction, restores IgnoreComponentWhenMoving(false), then SetMovementMode_CMC. OnCompleted is unlinked. Therefore runtime control is handed back at blend start while the visual montage tail blends out, not after waiting out the fade. +- Historical checkpoint: steps 3–6 were pending here; later entries below record subsequent implementation and remaining limitations. + +Native public API, persistent data and lifecycle changes must follow the four architecture specifications in AGENTS.md. Preserve a single motion authority, checked Result contracts, injected fixed time, generation-checked support handles, versioned atomic import and source-only architecture gate plus fixtures. Do not claim completion from existing tests that do not cover these requirements. + +- UE local source `Engine/Source/Runtime/Engine/Private/Animation/AnimMontage.cpp` constructor defaults: BlendOut 0.25 s, BlendOutTriggerTime -1, bEnableAutoBlendOut true. Stationary montage should naturally begin blending before sequence end, not use the generic frozen transition at the last frame. Export AnimMontage CDO to make default resolution data-driven; preserve any per-asset overrides. Implemented and verified. The fresh CDO actually specifies HermiteCubic, unlike the earlier assumption of Linear; the notify Blueprint explicitly uses HermiteCubic too. The 34 assets all enable auto blend-out; unsupported indefinite-hold assets are rejected instead of silently auto-ending. + +- Next: step 3. Existing ControlPose is a continuously tracking second-order/spring/PD driver with quaternion-component normalization; do not mistake it for finite-duration pose inertialization or duplicate its continuous-control role. MotionMatcher still snapshots fromPose_ and blends it with matchedPose_. Existing FootIKSolver has contact smoothing, planting and pelvis compensation and should be evaluated for step 4 reuse. Final step-2 architecture gate and its 9 fixtures were launched in sessions 12421 and 67740; inspect their logs before starting a duplicate run. + +- Step 3 native matcher now uses an owned finite-duration pose offset instead of frozen-source crossfade. Translation/scale offsets and quaternion log-space rotation offsets decay with a quintic curve; prior actual output and incoming clip history seed offset velocity, including interrupted transitions. Incoming clips continue sampling. This is an internal animation implementation, with no new public operation, persistent schema, cross-module relationship, or discarded Result. All MotionMatcher header consumers rebuilt in the build log. `build/traversal-inertia-tests.log`: 18 matching tests pass, including three new inertia/real matcher integration cases. Step-2 fixture process finished: 9/9 PASS. Current architecture source gate passes (`build/traversal-inertia-architecture.log`). Real scene regression is running in `build/traversal-inertia-runtime`; do not yet claim step 3 complete. Script action handoffs still use frozen transitionPose; per-bone inertia and bounded root translation/rotation policies remain pending, along with steps 4-6. + +- Step 3 matcher runtime regression completed: `build/traversal-inertia-runtime/result.json` PASS, including 24 consecutive traversals, conditional/natural montage exits, high-platform falling, moving support and rejected-displacement authority. Engine-owned Dense-run-left and high-platform-falling frames inspected; these frames verify rendering/representative pose, not full temporal coordination. Module dependency gate passes. The interactive engine is left running by the verifier. Next implement inertia for script action/air handoffs and reference bounded root policies; current matcher change alone does not complete step 3. + +- Step 3 handoff implementation: added native `AnimInertializer` with copied pose history, atomic checked begin/evaluate, quaternion-log inertia and per-bone time factors. The example's `pose_transition.nut` queues actual outgoing render history for action/air/bank changes and samples incoming clip history at the same instant. All old `transitionPose` frozen crossfade call sites now queue inertia; root factor zero preserves collision authority. Authored montage exit tails remain separately advancing. Public API uses Pimpl and owns inputs, explicit simulation elapsed time, no callbacks, no cross-domain handles/ownership or persistence changes. Player bindings were moved out of the >1000-line Animation.cpp before adding borrowed getClip access. Native test suite: 21 matching/inertializer cases pass, including atomic rejection, factors, replay and interrupted rotation/velocity. Strict binding, test discovery and module dependency gates pass. Source architecture gate passes; fixture suite and real-engine regression are in progress in `build/traversal-handoff-*`. New runtime regression asserts actual rendered pose continuity through air/landing interruption. Remaining step-3 work includes authored entry transition policy and bounded root offset/rotation behavior; do not mark all six complete. + +- Handoff runtime result is now PASS (`build/traversal-handoff-runtime/result.json`), including the new rendered-pose interruption check and all 24 repeated traversals. Architecture fixtures completed 9/9 in 122.996 s. Inspected engine-owned traversal-1-contact and conditional-mantle-exit frames: the exit is rendered correctly, but the low mantle still visibly penetrates the platform with the lower leg. This is unresolved step-4/5 contact/path geometry, not evidence that all visual issues are fixed. Current live example PID is recorded in the runtime output folder (29312 at completion); verify path before stopping. Continue step 3 authored entry policy/bounded root rotation and translation next, then actual foot-contact and warp geometry corrections. + +- Step 3 authored entry now follows freshly inspected AnimMontage_Default: BlendModeIn Standard, BlendTime .25, HermiteCubic, no profile. Of 34 source montages, 33 inherit this curve and one overrides Linear. Compiler selection schema 3 imports entry mode/duration/curve/profile, rejects unsupported modes/curves before publishing, and includes entry-only profiles. Runtime requires schema 3; older generated assets must be regenerated. All 34 private montage records regenerated; 17 converter tests pass. +- Standard entry is distinct from generic inertia: outgoing source animation continues sampling and its existing blended residual/velocity is preserved with native inertia; it is then blended into the moving incoming animation with the authored curve and per-bone time factors. Root bone remains target-owned. Reset/exit explicitly cancel long-duration transitions rather than relying on time=1. `build/traversal-entry-runtime/result.json` passes the full regression (24 repeated traversals, platforms, exits, collision authority). Added ENTRY_BLEND_PROBE to the real verifier and executed it against the same live engine (`build/traversal-entry-probe.log`, runtime STANDARD_ENTRY_RUNTIME_PASS), proving outgoing playback advances and both curves and .75-foot profile match expected motion. Remaining step 3: bounded root offset/rotation policies; steps 4-6 remain incomplete. Entry source residual preserves interruption continuity but is not a literal unbounded UE blend-stack graph. + +- Next root policy evidence (freshly inspected local exports and UE 5.8 source): `build/root-policy-inspection.log` plus `build/reference-Get_OffsetRoot{TranslationHalfLife,RotationMode,TranslationMode}.json`. Translation mode is Release for DefaultSlot montage, air or stopped; Interpolate while grounded moving. Half-life function overrides the node default .2: stopped .1, moving .3 seconds. Rotation mode Release with DefaultSlot active, otherwise Accumulate; rotation half-life .1, max translation error .30 m. UE `AnimNode_OffsetRootBone.h` defaults max rotation error -1 (unbounded), clamp-to-velocity flags false and collision testing Disabled; do not invent a source angular clamp. Runtime source at Engine/Plugins/Animation/AnimationWarping/Source/Runtime/Private/BoneControllers/AnimNode_OffsetRootBone.cpp: Accumulate/Interpolate consume root delta and do not counter capsule delta; Release counters capsule motion and dampens residual. Ground projection precedes damping and max-distance clamp. Existing collision authority must remain enforced when implementing this adaptation. Current live entry-verification engine PID 36508 (verify path before stopping). Entry rendered frame inspected; unresolved low-ledge body/leg geometry remains step 4/5. + +- Entry change final gates: architecture source contract PASS, fixtures 9/9 (194.281 s), strict bindings and module dependency gates PASS. Converter tests 17/17 and live Standard-entry probe PASS. No deliberate architecture exception. Root policy implementation is still next, not completed by the source inspection above. + +- Step 3 translation offset implemented in `reference_root_offset.nut`, composing existing native clip root extraction and read-only capsule shape casts with fixed-time planar residual state. Grounded moving uses Interpolate (.3 s), idle/air/slot uses Release (.1 s when idle), max .30 m. Cast queries leave mover state untouched and reject unsupported visual positions at ground edges. Pure damping and live integration probes verify half-life, timestep partition, distance cap, actual accumulated movement, idle release and root-loop boundary extraction. Full `build/traversal-root-offset-runtime/result.json` PASS (24 traversals, exits, moving/high platforms, collision authority), expanded probe passed live (`build/traversal-root-offset-probe.log`). Root rotation remains deliberately unimplemented until Steering, because source Accumulate without Steering would prevent ordinary directional clips from following target heading. No public API/persistence/module boundary changed this step. + +- Translation-offset source architecture gate PASS; inspected engine-owned stop/high-platform-falling frames. Six-second real runtime profile (`build/traversal-root-offset-runtime/performance.txt`): 60.19 FPS, fixedUpdate 1.921 ms, root offset update 0.193 ms average (362 calls). Live engine PID 14116 at validation; verify path before stopping. No native/public API or persistent-format edit this step; prior entry change architecture fixtures remain 9/9 and unchanged. Rotation/Steering must be implemented next; translation does not complete step 3 or the six-item goal. + +- Step 3 rotation/Steering regression is now PASS in `build/traversal-steering-complete/result.json`: authored yaw consumption, predictive steering, Accumulate/Release, and angular-rate continuity at matcher switches. Includes 24 repeated traversals, exit/contact authority checks and left-direction assertion. Actual TurnInPlace graph compares OrientationIntent with RootTransform, not the capsule heading; implementation follows the linked pins. This remains a bounded approximation to the complete UE per-clip Blend Stack, not full graph equivalence. The latest low-mantle contact frame still visibly penetrates the obstacle with a lower leg; step 4/5 remain necessary. + +- Step 4 source data work: added read-only `ue_export_contact_curves.py`, verified against the actual reference project via UE commandlet (exit 0). `build/ue-reference/contact-samples.json` contains 2,008 sequence records: 1,342 paired contact curves, 666 explicitly absent. It is a 78,232,177-byte intermediate evaluation capture at 240 Hz plus authored key times, **not a runtime payload**. Compact encoding and sampling validation must precede runtime integration. Comparing UE evaluation with linear interpolation of previously archived keys found maximum contact-weight error 0.16126 (Relaxed Walk Turn 180 R), proving old key-only interpolation changes contact timing. Audit: `build/ue-reference/contact-samples-audit.json`. +- New extractor contract test passes: source evaluation is retained (including off-grid key time), absence is explicit, changed source and nonfinite curve values preserve the previously published artifact. Schema `eve.unreal-contact-samples/1` is versioned, with source hash checks and atomic replacement; sampled interpolation is documented as approximate between samples. No source UE packages are saved. +- Step 4 implementation constraint: existing FootIKSolver stores lock contacts in model space despite the world-lock method description, and releases only by contact weight. Do not simply enable it for parity: world/support-relative anchoring, distance/angle unplant/replant, blended contact signals and ground/ball orientation need explicit handling. Source FootPlacement also has a linked Get_FootPlacementPlantSettings input, so inspect its dynamic overrides before treating serialized node fields as final settings. Runtime Foot Placement is not yet implemented. +- Step 4 compact curve compiler now produces `eve.animation-contact-curves/1` (6,230,199 bytes, 2,008 records) from the UE evaluation capture. Verified all 3,103,981 playable samples against compressed linear segments: maximum error 0.000989151, below the 0.001 contract (`build/ue-reference/contact-reduction-audit.json`). UE source curves often include pre/post-roll keys; these are validated, with runtime curves trimmed at explicitly evaluated sequence endpoints rather than lengthening the animation. Two focused tests cover sample fidelity, off-grid keys, playback extent, explicit absence, unknown fields, source changes and atomic failure. No runtime contact consumer has been connected yet. +- Contact extraction/reduction source architecture gate PASS; architecture fixtures 9/9 PASS (183.655 s), diff whitespace check PASS. Persistent intermediate format/atomic publication rules apply; no native public API, cross-module lifecycle, or ECS change and no deliberate exception. Fresh `build/reference-Get_FootPlacementPlantSettings.json` shows dynamic choice between PlantSettings_Default and PlantSettings_Stops, driven by Stops database tag (or Stop blend-stack tag for experimental state machine). Export actual Blueprint class defaults next; do not substitute static FootPlacement node values. Then connect compact curves to blended animation playback and support-relative foot placement. +- Actual Blueprint CDO read succeeded through UE (`build/foot-defaults.log`). Default/Stops both use SpeedThreshold=1 cm/s on remapped `(1-contact)*100`, DistanceToGround=10 cm, PivotAroundBall, UnplantAngle=60 degrees, ReplantAngleRatio=.2, UnalignmentSpeedThreshold=100. Default UnplantRadius=20 cm / ReplantRadiusRatio=.2; Stops radius=40 cm / ratio=.75. MaxExtensionRatio=.5, MinExtensionRatio=.2, AnkleTwistReduction=.75, reconstruct-velocity and adjust-heel-before-planting false. The serialized node defaults do not override this connected input. +- Contact channel runtime integration now loads source-addressed schema `eve.animation-contact-runtime/1`, checks all 1,879 reference clips, samples active player or matcher, advances Standard entry/exit curves and preserves scalar value/velocity through generic switches. Looping, missing channels and held terminal time have focused real-engine assertions in CONTACT_CURVE_PROBE. A first probe exposed wrapper identity differences from AnimPlayer.getClip; mapping now uses explicitly unique clip names, with duplicate rejection. Three Python extractor/reducer/binding tests pass. Full real-engine verification is `build/traversal-contact-bound` (in progress at this entry). World/support-relative planting and leg/pelvis solving are still not connected. +- Performance limitation to resolve before final delivery: the 6.2 MB Squirrel curve table adds substantial traced startup cost (first-present 32.690 s in `build/traversal-contact-probe/runtime.log`). Replace it with compact native loading rather than claiming the script data path meets the user's startup requirement. Do not disable tracing globally as a workaround. The intermediate representation is private generated data and must not become a second authoritative curve source. +- Contact binding verification completed: `build/traversal-contact-bound/result.json` PASS; runtime log contains REFERENCE_CONTACT_CURVE_RUNTIME_PASS for looping, missing channel, live player binding, continuous handoff, settling and held terminal sample. All 24 traversals and platform/exit regressions also pass; engine-owned walk frame inspected. Six-second real profile: 60.2056 FPS, contact channel update 0.046 ms average, fixedUpdate 2.037 ms (362 calls). Startup remains 32.852 s and must be repaired with native data loading before calling the work complete. Architecture source gate and diff whitespace check PASS. Next implement native compact curve loading, then source-derived support-relative Foot Placement; current channel output alone does not fix limb penetration. +- Contact binding architecture fixtures completed with exit 0 (9 tests); `build/traversal-contact-binding-fixtures.log`. Persistent runtime schema has checked source binding and atomic compiler publication; no native public API or cross-domain ownership change and no deliberate architecture exception. +- Native curve loading replaces the script table: owning Pimpl `AnimCurveLibrary`, checked atomic load and optional-valued sampling, strict EVFC/1 format (`eve.animation-curves/1`). It retains no clip/skeleton/filesystem pointers and takes explicit sample time. Binding reads filesystem bytes only during synchronous load. Compiler verifies source hashes/coverage, emits Float32 keys and rejects time collisions; runtime preserves absence and uniquely maps player clip names to stable source paths. Old private `contact-curves.nut` removed; README documents migration and required producer commands. +- Native verification: 2 CTest cases pass, including positive/negative/large-time wrap, clamp, absent-vs-unknown source, every truncation, invalid versions/UTF-8/duration/key order/endpoints, source/channel duplicates and resource counts, with prior data preserved on all rejections. Four Python tests pass. Binary artifact is 1,632,305 bytes; all 3,103,981 playable source samples checked against packed interpolation with maximum error 0.000989151 (`build/ue-reference/contact-binary-audit.json`). Source architecture, strict bindings, module dependencies, test discovery, diff checks PASS; architecture fixtures 9/9 (116.943 s). Public Result/ownership/time/persistence requirements apply with no deliberate exception. +- `build/traversal-contact-native-final/result.json` PASS, including native contact loop/absence/player-handoff/held-time probe and all 24 traversal/platform/exit regressions. First failed native run exposed unsupported `double` Squirrel argument conversion; adapter now takes script Float32 and passes it to native double-time evaluation. Successful run: contact load .285 s; total first-present 17.184 s; reference selection initialization 7.370 s including .999 s database bake. This is not proof total startup is resolved; earlier attribution of all startup delay to the curve script was too broad. Six-second real profile: 60.1962 FPS, native curve script adapter .127 ms/frame, fixedUpdate 2.130 ms (362 calls). Next source-relative planting/leg/pelvis integration remains required; low-mantle penetration is still unresolved. + +- Step 4 now connects blended contact curves to ball-pivot planting, source Default/Stops distance and angle hysteresis, ground queries, support-relative anchors, pelvis lowering and analytic leg IK. Fresh UE CDO interpolation settings override the static node: release stiffness 100 by default, 250 for Stops. Airborne traversal returns toward authored feet/pelvis instead of pulling the body toward the floor beneath it. Transition history is captured before foot processing, preventing double IK on interruption; zero-time pose evaluation does not advance spring history. +- `build/traversal-foot-support-link/result.json` PASS: full real-engine regression including 24 traversals, high-platform support/exit, collision authority, actual rendered handoff continuity, planted ball stability and moving-support anchors. Support links combine world identity with a fresh ray query ID, retain no Body3D pointer, and clear before world replacement. World3D assigns monotonically increasing query IDs. Probes cover body destruction, foot-link destruction and world mismatch; this is a course-local observed link, not a general physics handle API. Fixed time is injected; no collision displacement is restored by IK. +- Analytic two-bone solving replaces the old two-pass CCD implementation and removes the example's 24 repeated solves. `build/foot-ik-regression.log`: 8 tests PASS, including reachable target precision, unreachable clamping, zero weight, degenerate limbs and a rotated uniformly scaled parent. Architecture source gate PASS (`build/foot-placement-final-architecture.log`); architecture fixtures 9/9 PASS (`build/foot-placement-fixtures.log`). Existing public signature retained; ownership/time/Result contracts for the surrounding additions remain required, with no deliberate exception. +- Remaining step 4 gaps: floor-normal/ankle alignment, floor/angular interpolation and the complete source pelvis extension/compression/rebalance solve. Current pelvis solve is downward-only. Low-mantle lower-leg penetration remains visible in inspected engine-owned contact frames. Step 5 still uses endpoint correction and vertical-only moving-target following, not source SkewWarp translation/rotation semantics. Step 6 still routes custom schemas through common default features. Passing current probes does not establish full graph parity or resolve these visible defects. +- Follow-up step 4 replaces downward-only pelvis selection with source-derived desired/max/min limb ranges, heel-lift allowance, horizontal reach limit, .3 horizontal rebalance and combined two-leg height selection. The source node overrides MaxOffset=250 cm and stiffness=100; inherited HeelLiftRatio=.5 and MaxOffsetHorizontal=10 cm are preserved. Compression uses the authored IK-root plane distance (minimum ankle/ball), not physical floor clearance. Metre-space bias is .0005 m, corresponding to source .05 cm. Explicit fixed-time spring state resets with the course. No new public API, persistence or cross-domain owner is introduced. +- `build/traversal-pelvis-source-plane/result.json` PASS after the plane correction, including synthetic raised/lowered-support range checks and the full real-engine traversal/platform/handoff suite. Engine-owned low-mantle contact frame inspected: lower-leg penetration remains. Remaining foot work includes full ground/ankle alignment, interpolated plane, and post-pelvis heel/extension correction; this range solve does not complete step 4. The current vertical sphere intersection assumes the ordinary hip-above-foot arrangement; extreme above-hip targets still need explicit source parity coverage. +- Above-hip intersection handling now follows FMath::SphereDistToLine's nearest-to-line-origin choice (TraceSettings.EndOffset=100 cm), including tangent/no-intersection height and equal-distance downward selection. The earlier always-upper intersection was wrong for targets above the query origin. New above-hip and tangent real-script checks pass in `build/traversal-pelvis-above-hip/result.json`. +- Ground alignment no longer forces the ankle to a fixed .08 m above the floor: it preserves the authored ankle-to-IK-plane vertical distance. Plant eligibility uses the minimum authored ankle/ball plane distance, matching CalcTargetPlantPlaneDistance. `build/traversal-authored-ankle/result.json` PASS with horizontal/tilted plane-distance probes and full traversal/platform/lock/handoff regression; engine-owned walk frame inspected. Source architecture gate PASS (`build/foot-authored-height-architecture.log`). Changes are private example algorithms with explicit fixed time and existing owners; no public API/schema change or deliberate architecture exception. +- Further source comparison: UpdateLegInputPose uses IKIndex as the placement input and reconstructs the ball relative to that IK transform, then LegIK drives FK. The example still starts from FK foot/ball. Measure actual imported IK/FK deviation and transfer this target path before claiming equivalent FootPlacement; do not assume both tracks coincide. Ground normal/rotation/plane interpolation and post-pelvis heel correction also remain pending, as do steps 5 and 6. +- IK/FK audit completed in the live engine: 15,032 samples (four times per compatible clip, both feet), maximum positional difference .0911492 m in M_ragdoll_poses_rolling_0 at normalized time .25, left foot (`build/traversal-authored-ankle/runtime.log`, IK_FK_AUDIT). This corpus maximum is not a claim that ordinary walking differs by that amount. FootPlacement now uses ik_foot_l/r as input, reconstructs the ball using the FK foot-to-ball relative rotation/translation, and drives the FK leg with the resulting target. Physical limb lengths remain measured from the FK chain. +- `build/traversal-ik-target/result.json` PASS, including rotated IK/FK ball mapping, planted ball stability (120 samples, maximum error 4.95338e-7 m), rendered handoff continuity and the full 24-traversal/platform suite. Engine-owned moving-platform contact frame inspected. Architecture source gate PASS (`build/foot-ik-target-architecture.log`). No public API, persistence or ownership change; no deliberate exception. Source ground-plane orientation/interpolation and post-pelvis correction remain next; the six-item objective is still incomplete. +- Step 4 ground alignment now retains ray-hit point/normal, projects ankle height along the approach direction, and transfers source IK-plane orientation to the actual plant plane. New private `reference_foot_alignment.nut` implements the source plane delta, swing/twist separation, and .75 ankle twist reduction while retaining authored pitch on flat terrain. Alignment is weighted by contact-derived alignment alpha. No native/public API or lifetime/schema change. +- `build/traversal-foot-normal/result.json` PASS: full traversal/platform/handoff regression plus pitch, roll reduction and flat-pose preservation probes. Expanded FOOT_PLACEMENT_PROBE also ran against the same live engine after completion (`build/foot-normal-live-probe.json`, ok), checking a real 20-degree rotated physics box's returned normal. Inspected engine-owned stop frame; this is flat-terrain visual evidence, not a sloped walking sequence. Source architecture gate PASS (`build/foot-normal-architecture.log`), diff check PASS. No deliberate exception. Plant-plane interpolation, temporal angular release and post-pelvis heel/extension correction still need implementation and sloped moving-character visual validation. Steps 5/6 remain incomplete. +- Grounded plant-plane smoothing now preserves the previous plane and last aligned foot point, chooses the closer prior vertical intersection, consumes component vertical displacement only to close the gap, and uses source floor stiffness 1000 / angular stiffness 450 with critical damping. Height lag is bounded by the node's .20 m penetration allowance; this is interpolation policy, not a capsule collision override. Normal rotation uses a model-independent world rotation-vector spring. Per-foot state is owned/reset with the existing placement state; explicit dt, no public API or persistence change. +- `build/traversal-floor-smoothing/result.json` PASS, including discontinuous height/normal changes, zero-time preservation, settling, real slope ray, and the full traversal/platform/handoff suite. Engine-owned high-platform-falling frame inspected. Source architecture gate completed successfully (`build/floor-smoothing-architecture.log`). Remaining source differences include interpolating toward the virtual IK plane during air, angular unplant release, post-pelvis heel/extension correction and moving-character slope visual evidence. This grounded interpolation step alone does not establish full FootPlacement parity; six-item objective remains active. +- Air now routes foot alignment through the same persistent plane spring, targeting the authored IK-root position with world-up normal. Actual physics support stays separate: the virtual plane cannot plant a foot or retain a support link. Air interpolation does not consume capsule vertical movement or apply the real-ground penetration clamp. Zero-weight feet still update their last evaluated foot point for subsequent plane interpolation. The pelvis continues returning to zero offset in air; complete source post-pelvis correction remains pending. +- `build/traversal-air-plane/result.json` PASS, including air plane lag/settling, no ground-only compensation on a one-metre rise, all prior foot/plane probes and the full traversal/platform/exit regression. Engine-owned high-platform hand-contact frame inspected; hand support remains visually present. Architecture source gate PASS (`build/air-plane-architecture.log`), no public API/schema/lifecycle change or deliberate exception. Remaining step 4: angular release, heel/extension/compression finalization, rotating/sloped support visual verification. Source performs a trace even in air before replacing the plane; current adapter intentionally queries physical support only while grounded, so this is still a documented parity difference requiring review, not full equivalence. Steps 5 and 6 remain incomplete. +- Source-derived post-pelvis finalization is now connected before analytic leg IK: calculate maximum extension from the authored pose, gate heel lift on planted/reachable or recently unplanted state, pull by at most the foot length times alignment alpha, rotate toward the preserved toe direction, clamp remaining extension, correct plant-plane penetration with authored penetration allowance, then enforce minimum vertical compression. Reachability and time-since-unalignment are per-foot resettable state. The source heel-roll approximation itself does not exactly preserve toe position after translation; this adapter follows that calculation rather than claiming an exact toe pivot. +- `build/traversal-foot-finalize/result.json` PASS, including independent heel lift/reach and above-hip compression probes, locked ball stability, actual rendered transition continuity, 24 traversals and platforms. Source architecture gate PASS (`build/foot-finalize-architecture.log`), diff check PASS. No public API/persistence/owner change and no deliberate exception. Inspected low-mantle contact frame still shows lower-leg penetration; finalization does not repair the root path. Remaining step 4 work includes angular unplant release and sloped/rotating support visual coverage; step 5 SkewWarp and step 6 schema matching remain necessary. +- Angular-release source audit resolves an earlier overbroad pending item: GetFootPivotAroundBallWS takes the current ball rotation and reconstructs the foot from the current BallToFoot transform. For this source lock mode, the unaligned rotation equals the current input rotation, so the stored rotational offset is identity; adding extra ankle damping would alter authored motion. This does not implement the unused LockRotation mode, which is not selected by either imported plant policy. +- Fixed a real release discrepancy: previously the whole world-space ankle target was spring-smoothed. Source ProcessFootAlignment resets velocity while planted, stores the planar root-relative lock offset, and springs that offset to identity on release. Runtime now follows this structure; authored swing advances immediately while the residual offset decays, and planted vertical alignment preserves the animated ankle height. Reset owns/clears the new offset state, with explicit dt and no API/schema/lifetime change. +- `build/traversal-unplant-offset/result.json` PASS for the complete runtime suite. Expanded FOOT_PLACEMENT_PROBE ran on the completed live engine (`build/unplant-offset-live-probe.json`, ok): with zero lock offset and an artificially stale target .25 m away, the target follows the current authored foot immediately. Engine-owned walk frame inspected. Moving/sloped support visual coverage and remaining trace/curve fidelity must still be audited before step 4 is complete; low-mantle penetration and steps 5/6 remain unresolved. +- Added repeatable live `scripts/verify_reference_sloped_support.py` (existing MCP port, output directory); it saves/restores update/camera hooks and platform rotation even on failure, captures standing/rotating/walking frames and records responses. Real slope testing found an actual controller defect: raw centre-ray floor snapping embeds the capsule sphere in slopes, and the constant grounded -.5 m/s downward sweep projects into downhill sliding. First rotating test lost the platform (151 grounded frames, only 211 foot support samples, final z=12.7396). Correcting sphere support height alone still drifted .1003 m on an idle 10-degree slope. +- Controller now uses the tangent support height hitY + radius*(1/normalY-1), rejects non-walkable normals, and checks distance to that adjusted height. Grounded motion no longer injects a downward sweep; existing final support probing handles adhesion/edge release. Capsule position remains authoritative. No native/public API, schema or owner change and no deliberate exception. +- `build/traversal-slope-stable/result.json` PASS, preserving full traversal/platform/edge/falling regressions. `build/sloped-stable-proof/responses.json` and runtime SLOPE_* markers prove: 180/180 static support frames with idle drift under .05 m, 180/180 rotating support frames, 720 foot samples with minimum bone-to-plane clearance .0271699 m, and .616666 m sloped walking displacement. Engine-owned close walking frame inspected. These tests cover 10-degree standing and slowly varying approximately 5–15-degree pitch, not arbitrary platform angular speeds. Source architecture gate PASS (`build/slope-stable-architecture.log`), diff check PASS. Low-mantle penetration remains a root-warp issue; source trace/curve edge cases and steps 5/6 are still pending. +- Step 5 source audit: 72 SkewWarp windows across exported montages; 21 disable rotation, 11 override rotation time multiplier to .8, all override IgnoreZAxis=false, 70 override added-translation easing to Linear. Two residual `interaction` bone names have provider None and must not enable bone targeting. The existing compiler drops these flags and unconditionally consumes nonempty bone names, so runtime endpoint correction cannot be considered source-equivalent. +- Exporter now includes RootMotionModifier_SkewWarp class defaults. Fresh UE commandlet exit 0 (`build/skew-default-export.log`) produced `build/ue-reference/RootMotionModifier_SkewWarp_Default.t3d`: default provider None, translation/rotation true, IgnoreZAxis true, feet-location true, easing HermiteCubic, Default/Slerp rotation, time multiplier1, speed/rate clamps0. CDO delegate fields contain unsupported `(null).None` syntax: read only needed configuration fields, as for AnimMontage defaults, rather than broadening unrelated parsing. +- Added pure `unreal_warp_settings.py` configuration resolver; three focused tests pass for inherited defaults, overrides, unused bone names, invalid/unsupported policy rejection and input preservation. All 72 actual windows resolve successfully (`build/ue-reference/warp-settings-audit.json`). This resolver is staged and is not yet wired into selection serialization or runtime; no new persistent schema or runtime behavior is claimed. Next integrate a versioned window contract and the actual SkewWarp step, including translation-free windows, rotation and dynamic targets. Step 4 trace/curve boundary parity remains to audit; do not mark the overall goal complete. +- Step 5 configuration resolver is now wired into montage compilation. Schema `eve.animation-selection/4` flattens complete window configuration into each window record, hashes the modifier CDO source, resolves the explicitly referenced modifier instead of taking the first nested object, rejects invalid windows before publishing a montage, and clears unused bone names when provider=None. Runtime checks window fields/flags/provider/policy and rejects schema3; regenerate private selection files. All 34 montages/72 windows regenerated, retaining 21 disabled-rotation windows and 11 .8 time multipliers. +- 21 converter tests PASS, including selected-child resolution, inherited easing, unused bone names and no partial publication for malformed time windows. `build/traversal-warp-schema4/result.json` PASS for full real-engine regression after target-provider correction; low-mantle contact frame inspected and still penetrates. Configuration is now available/validated, but the old root solver still does not consume the full SkewWarp policy; this remains explicitly incomplete. Source contract gate PASS (`build/warp-schema4-architecture.log`); fixture suite is tracked in `build/warp-schema4-fixtures.log`. Persistent format rules apply: explicit version, rejected unknown window fields, atomic runtime .nut replacement and source hashes; no deliberate exception. +- Schema4 architecture fixtures completed: 9/9 PASS in 157.634 s (`build/warp-schema4-fixtures.log`). Next replace root endpoint interpolation with source animated/no-translation branches and actual rotation/target transform processing; neither schema migration nor these tests completes that implementation. +- Step 5 window translation now integrates authored deltas against remaining source displacement and remaining target displacement. The scale/shear is expressed as longitudinal redirection plus preserved transverse delta, with the source perpendicular-target branch. Frames split at window boundaries; same-time queries return cached results and backward simulation time is rejected. Translation-free windows use their imported Linear/HermiteCubic easing. Dynamic target height is evaluated during the window. Removed obsolete from/to smooth-correction state; capsule collision still owns applied motion. +- `build/traversal-skew-translation/result.json` PASS for full runtime regression. Expanded live probe passed (`build/skew-translation-live-probe.json`): transverse-motion preservation, endpoint reach and perpendicular target branch. Source contract gate PASS (`build/skew-translation-architecture.log`), unchanged architecture fixtures remain schema4 9/9. No native/public API or persistent schema change; explicit monotonic simulation time and private traversal-owned state. Low-mantle contact frame inspected and still penetrates. Remaining: full bone-to-root target transform, root/target rotation, full moving target transforms, speed caps/feet-location variants, feedback from collision-resolved position, and removing the legacy post-contact Mantle endWarp correction. Current passing tests do not cover these pending semantics. No deliberate architecture exception. +- Direct low-mantle diagnosis changed the next action: root and attach sampled rotations are both identity at the contact pose, so rotation is not the cause of this particular penetration. Actual FrontLedge target was z=4.97, .33 m inside the physical front face z=4.64; hanging calf was z=4.80226 below the .62 m top. Native candidate.topPoint is an inset support ray, not the edge. Source SetWarpTargets uses FrontLedgeLocation plus .5 cm vertical clearance. Reference geometry now combines candidate.frontPoint horizontal coordinates with confirmed top height + .005 m. Top inset remains separately available for native landing-distance calculations. +- `build/traversal-front-edge/result.json` PASS for full runtime regression after correcting FrontLedge. The engine-owned low-mantle contact frame now visibly keeps the hanging lower leg outside the platform. Added a below-top knee/front-plane assertion to the existing contact checks; the expanded three-height probe passed live (24 knee/hand samples, `build/front-edge-knee-probe.json`, FRONT_EDGE_KNEE_PASS). This resolves the repeatedly observed low-mantle contact penetration, not every possible traversal pose/geometry. Source architecture gate PASS (`build/front-edge-architecture.log`), no public API/schema/ownership change or deliberate exception. Full target transform/rotation, dynamic targets, post-contact warp and schema matching remain required. +- Step 5 collision feedback: UE SkewWarp reads OwnerAdapter current visual root for every extraction. Runtime now separates the cached requested point from the next extraction origin; after native collision resolution, the origin uses actual capsule feet plus only the explicit pre-collision floor lowering. Same-time diagnostic reads remain stable. Full `build/traversal-warp-feedback/result.json` PASS, including the expanded live blocker assertion that the next extraction origin equals the resolved visual root, 24 repeated traversals, three-height contact checks, moving support and falling. Source architecture gate and diff whitespace check PASS. Private state only, explicit simulation time, no API/schema/ownership change or deliberate exception. Legacy post-contact endWarp still overrides the integrated path and must be removed as part of the remaining full target-transform/rotation work; this correction completes feedback for the integrated windows, not all six requirements. +- Step 5 target/rotation integration: source `CalculateRootTransformRelativeToWarpPointAtTime` and `WarpRotation` inspected directly. Bone providers now compose inverse bone transform with root transform and the world ledge transform; provider None targets root directly. Front-face normal determines target yaw. Each extraction rotates source translation through inverse source root orientation and current traversal orientation, consumes authored quaternion delta, and applies Default/Slerp remaining-error correction with imported rotation flag/time multiplier. Full quaternion state drives visual root residual while capsule heading consumes yaw. No new public API, persistent format or lifecycle owner; explicit step time and no deliberate architecture exception. +- `build/traversal-warp-rotation/result.json` PASS for full existing regression. Added WARP_ROTATION_PROBE to the verifier and executed it against the completed live engine (`build/warp-rotation-angle-probe.json`, ok): quarter-turn timing, noncommuting quaternion endpoint, and six real traversals at +/-15 degrees across three heights verify target rotation, wrist contact and exit. Engine-owned `angled-contact.png` inspected. BackLedge/BackFloor graph-derived target creation/orientation still needs full audit; moving target full rigid transforms, post-contact endWarp removal, speed policies and schema-specific matching remain incomplete. The source architecture check is tracked in `build/warp-rotation-architecture.log`; do not infer full parity from these bounded tests. +- Step 5 moving-window targets now store root target position and rotation relative to the course-owned moving body, and resolve both against its current rigid transform at every extraction. This replaces height-only target following inside warp windows. No new ownership/handle/API/schema: the action remains course-owned and reset before course teardown. Legacy post-contact endWarp still follows height only and is explicitly pending removal; BackFloor ownership/target-creation parity also remains pending. +- `build/traversal-rigid-target/result.json` PASS for full regression including six angled traversals. Added DYNAMIC_WARP_PROBE, then ran it on the live engine (`build/rigid-target-live-probe.json`, ok; REFERENCE_DYNAMIC_WARP_PASS frames=20): moving platform translates up to 12 cm sideways/8 cm forward while yawing up to 10 degrees; target rotation and wrist-in-platform coordinates verified at first window end. The first probe omitted Standard-entry transition time and failed visually; corrected the harness to execute world, action, events, root offset and transition timing in normal order, preserving contact bounds. Engine-owned `rigid-contact.png` inspected. Architecture source gate PASS (`build/rigid-target-architecture.log`), diff check PASS. This is bounded yaw/translation coverage, not arbitrary pitch/roll or whole-action moving-contact fidelity. No deliberate architecture exception. Next remove post-contact interpolation with source BackLedge/BackFloor geometry, then finish curve boundary and schema-specific matching work. +- Step 5 authored tail: removed the entire legacy Mantle endWarp position interpolation and its separate height-follow addition. Window splitting now naturally passes subsequent authored translation/rotation through the same incremental extraction, seeded by collision-resolved root. Removed unused fitTime/landingTarget/platformY state. UE ProcessRootMotion explicitly extracts ExtraRootMotion after EndTime; no source basis existed for the removed landing-point blend. +- `build/traversal-authored-tail/result.json` PASS, including six angled traversals, rigid moving-target contact, repeated traversal/contact, moving support, high-platform falling and conditional/natural exits. Added AUTHORED_TAIL_PROBE and ran live (`build/authored-tail-live-probe.json`, ok): at three heights the post-last-window request equals the rotated source delta from the actual origin. Engine-owned contact and conditional-exit frames inspected. No public API/schema/owner change, no architecture exception. Source gate tracked in `build/authored-tail-architecture.log`. Remaining step 5: source BackLedge/BackFloor graph geometry/ownership, zero-translation exact policy and speed settings; step 4 trace/curve boundaries and step 6 schema-specific matching remain incomplete. +- Step 5 source SetWarpTargets graph decoded with node/pin links (`build/warp-target-pin-map.txt`). FrontLedge alone connects MakeRotFromX(-FrontLedgeNormal); BackLedge/BackFloor rotation pins are unconnected identity. BackFloor horizontal target is BackLedgeLocation + BackLedgeNormal * abs(Distance_From_Ledge at first BackLedge window end minus that at first BackFloor window end), with height taken from detected BackFloorLocation. Targets are conditionally removed when action/window conditions fail. Current fixed-distance BackFloor geometry is therefore still incomplete and curve sampling/import must follow; do not describe it as source equivalent. +- Corrected BackLedge/BackFloor initial rotation to identity. Floor query now records whether the floor is the moving support independently of the obstacle; only targets actually on that support attach to its transform. This is the example's moving-platform adaptation; source graph uses location/rotation targets, not component-bound targets. Full `build/traversal-target-ownership/result.json` PASS. Added TARGET_POLICY_PROBE and ran live (`build/target-ownership-live-probe.json`): independent floor stays fixed as obstacle translates/rotates, identity rotation retained, moving-floor attachment exists. No API/schema/new lifetime owner or deliberate architecture exception. Gate tracked in `build/target-ownership-architecture.log`. Next implement the actual distance-curve-derived BackFloor and conditional targets, then remaining step 4/6 work. +- Step 5 distance data evidence: executed UE AnimationWarpingLibrary.GetCurveValueFromAnimation on actual montages at each first BackLedge/BackFloor window end. This API resolves the montage segment and converts track time before curve evaluation. New reusable `tools/unreal-uasset-converter/ue_export_warp_distances.py` exports `eve.unreal-warp-distance-samples/1`, selection hash, montage/sequence hashes, sample times and explicit curve presence; absent curves retain UE's zero OutValue. Atomic replacement preserves old output on malformed/nonfinite data. Two focused tests PASS. +- Real production exporter commandlet completed exit 0 (`build/warp-distance-production-export.log`): `build/ue-reference/warp-distance-samples.json` has 34 montage records, 21 evaluated samples, 22,650 bytes. Thirteen BackFloor distances range 55.6969147 to 118.0687943 cm after subtracting first BackLedge curve value (zero when no BackLedge window). Confirmed E_TraversalActionType: NewEnumerator1=Vault, NewEnumerator2=Hurdle; source creates BackFloor only for Hurdle, BackLedge for Vault/Hurdle. These samples are staged, NOT consumed by runtime yet. Next version the selection contract, verify hashes/window times during compilation, implement source conditional target creation and distance-derived BackFloor. Architecture/fixtures tracked in `build/warp-distance-architecture.log` and `build/warp-distance-fixtures.log`; no public API/lifecycle change or deliberate exception. +- Step 5 distance integration: selection schema5 adds per-montage warpDistances in metres, retaining sampled time/presence. Pure attach_warp_distances validates exact montage coverage, source montage/sequence hashes against imported catalogue, first-window times, presence and finite values before returning new records. Runtime rejects schema4 and validates distance target/time coverage. All34 montages regenerated from real UE samples. Exporter accepts schema4/5 for bootstrap/migration. README documents regeneration. One initial restricted write to the shared generated-asset directory failed; authorized elevated compiler run succeeded without changing source UE packages. +- Runtime Hurdle BackFloor horizontal coordinates now use BackLedge + back-face normal * abs(first BackLedge distance or zero - first BackFloor distance), retaining queried floor height. Source action conditions enforced: BackFloor only Hurdle; BackLedge Vault/Hurdle. Back-face normal captured before subsequent floor ray overwrites query results. Full `build/traversal-distance-schema5/result.json` PASS; engine-owned hurdle-landed frame inspected. Latest runtime coverage validation executed live and rejects altered first-window time (`build/distance-schema5-validation-probe.json`, ok). 25 export/compile tests PASS; prior architecture fixtures9PASS191.188s, current source gate `build/distance-schema5-architecture.log` PASS. Persistent-format version/unknown fields/source provenance/atomic runtime replacement rules apply, no deliberate exception. Remaining: zero-translation/speed policy fidelity, foot trace/curve boundary parity, and schema-specific matching; full six-item objective remains active. +- Step 5 zero-translation branch now uses the eased step magnitude directed toward the current target, matching UE's no-authored-translation branch; a reached target returns zero instead of being pulled back to the original interpolation line. Added authored-speed ratio limiting for translated window segments, with 3D and planar variants (planar preserves vertical delta). These are private pure helpers with explicit input/time, no API/schema/lifecycle change or deliberate exception. +- `build/traversal-warp-policies/result.json` PASS for full real-engine regression. Added focused helper cases to the verifier, then executed live (`build/warp-policies-live-probe.json`, ok): off-line actor direction and eased length, reached target, 3D clamp, planar clamp preserving vertical movement, disabled clamp. Engine-owned moving-platform contact frame inspected. Current source gate tracked in `build/warp-policies-architecture.log`. Actual imported windows use ratio0, so enabled-cap evidence is the focused helper checks; boundary frames spanning EndTime still need source comparison because UE clamps window+ExtraRootMotion together whereas this runtime splits intervals. Do not mark step5/all6 complete yet. Remaining step4 trace/curve boundaries and step6 schema-specific matching are unchanged. +- Step 4 exit curve lifetime fixed: an advancing montage exit tail now contributes curve presence for its actual blend duration, rather than losing it when the shorter generic inertia duration expires. Synthetic runtime coverage extends CONTACT_CURVE_PROBE with a .6 s outgoing curve tail into a clip with no curves: presence and actual blended value verified after .4 s, and full removal after completion. `build/traversal-contact-tail/result.json` PASS for the entire real-engine suite with this expanded probe; conditional exit frame inspected. No API/schema/lifecycle change or deliberate exception; source gate tracked in `build/contact-tail-architecture.log`. +- Missing-contact source audit resolved: actual SandboxCharacter_CMC_ABP RemapCurves expression is contact_l=(1-contact_l)*100 (and right). AnimNode_RemapCurves evaluates missing SourceCurve.Get(name) as zero and writes the finite expression result, so absent contact becomes present speed100 before FootPlacement. Hence current no-contact alignment0/unplanted behavior is correct for this graph; do NOT replace it with FootPlacement's isolated missing-speed fallback1/alignment1. Remaining foot parity: sphere/complex tracing, air physical-plane handling and exact pelvis behavior. Step6 schema matching and step5 cross-window enabled-cap parity still pending. +- Step4 foot tracing now uses existing World3D.castSphere, source default radius .05 m and vertical sweep from +.75 to -1.0 m, rather than the old +.20/- .50 centre ray. Reads surface impact point/normal and retains walkability rejection. Source T3D overrides only MaxGroundPenetration, so trace extents/radius come from UE FootPlacement header defaults. No new native API/schema/lifecycle or deliberate exception; existing physics sphere casts support mesh/heightfield shapes, though source separate simple/complex channel blending remains to audit. +- `build/traversal-foot-sweep/result.json` PASS for full real runtime regression. Added flat-height/edge probe and ran live (`build/foot-sweep-edge-probe.json`, ok): surface height equals box top (not sphere centre); sphere finds walkable edge 2 cm beyond box where centre ray misses. Repeated sloped support test (`build/foot-sweep-slopes/responses.json`): static180/180, rotating180/180,720samples,minclearance .0268307 m, walking .616666 m; close walking frame inspected. Architecture source gate PASS (`build/foot-sweep-architecture.log`), diff check PASS. Remaining step4: air physical-plane/virtual-plane and pelvis semantics; step5 boundary-cap policies and step6 schema-specific matching still pending. +- Step4 air physical-plane handling: source UpdatePlantingPlaneInterpolation always traces, then replaces desired plane with IK-root virtual plane in air; its interpolation penetration bound still uses the physical impact when found. Runtime now also queries actual ankle/ball support in air, passes it separately to floor smoothing, and bounds the smoothed height to physical plane minus .20 m. Ground/plant eligibility remains disabled in air, so detecting a nearby ledge does not itself lock a foot. No public API/schema/lifecycle change or deliberate exception. +- `build/traversal-air-contact-plane/result.json` PASS for full real-engine regression, including three-height contacts, moving targets and high-platform falling. Expanded pure plane probe passed live (`build/air-contact-plane-live-probe.json`): no-ground virtual-plane convergence, actual surface penetration bound while airborne, and recovery when that surface disappears. Engine-owned high-platform-falling frame inspected. Current source gate tracked in `build/air-contact-plane-architecture.log`. Air pelvis semantics, missing-hit floor fallback/complex channels, step5 enabled-cap frame boundaries and step6 schema matching remain incomplete. +- Air pelvis continuation is an UNVERIFIED candidate: removed blanket !grounded pelvisTarget/planar zeroing after confirming bDisablePelvisOffsetInAir only gates actor-motion compensation in UE UpdateCharacterData. `build/air-pelvis-verifier.log` FAILED low mantle contact (wrist y=.710 z=4.650 exceeded existing bound); do not relax assertion or report this candidate fixed. Source gate passed (`build/air-pelvis-architecture.log`), runtime remains failing. Latest verifier process81400 completed/failed; it kills its owned engine on failure. Earlier kept-running engines may remain: before next launch enumerate by exact workspace eve.exe path and stop those, preserving other worktree binaries. +- Further direct source evidence changes the next fix: SolvePelvis uses full AlignedFootTransformRS. AlignPlantToGround always writes corrected transform, without AlignmentAlpha weighting. AlignmentAlpha appears in planting/time-since-unaligned and heel-lift behavior, not a generic ground-alignment blend. Current reference_foot_placement preblends target with align, fraction-blends rotation, and uses s.weight for IK, so ground-alignment and contact/lock weights are conflated. Inspect FinalizeFootAlignment output/DisableLeg flow and separate these weights before finishing air pelvis. This also revises earlier missing-curve conclusion: absent remapped contact indeed becomes speed100/unplanted, but that does NOT by itself prove whole ground alignment must be zero. Need exact output-stage source audit. Keep all six goals active; no architecture exception claimed and no passing runtime evidence for current candidate. +- Continued full-alignment candidate: confirmed FinalizeFootAlignment only blends by DisableLeg (source default NAME_None), not AlignmentAlpha. Runtime no longer fraction-blends aligned rotation, preblends pelvis targets, or attenuates final leg IK by contact weight; contact weight still drives heel correction and unplant timing. This is NOT validated: `build/full-foot-alignment-verifier.log` fails six-angle contact probe (hand y=.7212 z=4.64399). Preserve existing assertions. No final fix claimed. +- Stopped all eve processes whose executable exactly matches this worktree before the run; preserved other worktree binaries. Failed verifier94518 exited and killed its engine. Started one diagnostic engine PID36000, MCP57450, stdout `build/foot-alignment-diagnose.log`, stderr `build/foot-alignment-diagnose-error.log`; it remains running. Live wrapper temporarily recorded referencePelvisRange arguments/results then restored function/update and reset course. PELVIS_DEBUG shows low mantle pelvis offset .06239 at time.3833, .10204 at .4667, .10114 at .55; hand correspondingly .72876/.77648/.77977. Actual capsule feet remain at ground0 through .55 due floor adaptation. Need resolve reference-plane/physical-floor/root-lowering interaction rather than hide with air-zeroing or loosen bounds. +- Source graph ordering confirmed with pins: Slot_0 -> OffsetRootBone_0 -> RemapCurves_0 -> LocalToComponentSpace_0 -> FootPlacement_0 -> LegIK_1 -> ComponentToLocalSpace_2 -> PoseSearchHistoryCollector_0 -> Root_0. No post-foot hand IK in this path; do not invent graph parity. Also source Bones.LimbLength and FootLength are cached from reference skeleton, whereas runtime recomputes from current pose; audit/fix that next alongside plane/root adaptation. Entire six-item objective remains active; latest runtime candidate is failing. + +- Root-space plane transport now carries the previous plant plane and last aligned foot through the input root's rigid transform; the world/support plant anchor remains independently owned. Reference limb/foot lengths are cached from bind pose. Expanded FOOT_PLACEMENT_PROBE verifies translation, quarter-turn rotation, unchanged world anchor, and repeated-evaluation idempotence. Fresh engine diagnostic plus all FOOT_PLACEMENT_PROBE checks PASS (`build/root-plane-transport-proof.log`, DIAGNOSTIC_DONE); this is narrow evidence, not full traversal success. No public API/schema/lifecycle change or architecture exception. +- Full `build/root-plane-verifier.log` remains FAILED low contact y=.713 z=4.648. New authoritative per-frame `build/root-plane-diagnosis/runtime.log` identifies the remaining mechanism: visualRootLowering reaches -.375 m, authored IK-root follows it, but the physical penetration bound holds plant plane at -.20 m. At montage .40 s pelvis offset=.04294 m, hand=.71304 m; at .50 s pelvis=.09484 m, hand=.77218 m. Plane transport fixes coordinate drift but cannot remove this physical-plane clamp. Actual exported node overrides MaxGroundPenetration=20 cm, confirmed; do not change it to hide this conflict. +- Source PSS_Traversal audit confirms current entry scoring is incomplete: source includes attach position AND heading (weight15 each), left-foot relative right-foot position/velocity, individual vertical foot velocities (weight.3), and past trajectory. Runtime uses attach position plus foot/pelvis position errors, with no heading/velocity/normalization. Its query still uses inset topPoint while execution uses front edge. Resolve actual Get_InteractionTransform graph and source selection semantics before choosing a workaround to low-ledge root lowering. Also current keepTakeoffFeetAboveGround solves FK before full FootPlacement solves from unchanged IK references; inspect this duplicate correction while fixing pipeline. All six goals remain active; no final visual success claimed. + +- Corrected traversal search position to frontPoint XZ + confirmed top Y (no .005 execution clearance), replacing inset topPoint XZ. Direct pin trace: AC_TraversalLogic.TryTraversalAction.K2Node_CallFunction_17 Set_InteractionTransform <- CallFunction_31 MakeTransform; Location <- BreakStruct_0.FrontLedgeLocation, Rotation <- CallFunction_3 MakeRotFromZ(FrontLedgeNormal). PSC_Traversal_Pos/Head both consume that interaction transform. Thus the heading query must not be copied from execution MakeRotFromX(-normal). +- Fresh `build/front-query-hands/runtime.log` selects AM_M_Neutral_Traversal_Mantle_1_0_stand_F_Rfoot, start .1. Both wrists recorded: time .46667 left .85805/right .75463; time .56667 left1.21455/right.75264, pelvis .08205. Real diagnostic and FOOT_PLACEMENT_PROBE complete (`build/front-query-hands.log` DIAGNOSTIC_DONE), but contact remains wrong; do not report full regression success. Selection changed because source position now differs from the old inset query; missing source heading/relative feet/velocity/normalization must be implemented rather than reverting a source-correct query to favor old assertions. No API/schema/lifecycle change, no deliberate exception; git diff --check passed. Diagnostic process21641 terminated normally, no kept-running engine from these diagnostics. + +- Added traversal attach heading feature using source HeadingAxis::X and weight15. Fresh read-only UE commandlet loaded the actual PSS_Traversal and exported effective inherited properties (`build/traversal-effective-schema.json`, `build/effective-schema-export.log`, exit0). This confirms PSC_Traversal_Head_C HeadingAxis.X, None stripping, zero offsets, weight15. Source GLTFCoreUtilities::ConvertVector maps UE(X,Y,Z) -> (X,Z,Y); ConvertRotation=(-qx,-qz,-qy,qw). Source TRotationMatrix::MakeFromZ uses Up cross Normal and the near-vertical fallback. Runtime helper accounts for handedness and transforms query into character heading space; candidate uses root-relative attach quaternion X axis. +- `build/traversal-heading-proof.log` DIAGNOSTIC_DONE, fresh engine run including newly added FOOT_PLACEMENT_PROBE heading checks: joint quarter-turn invariance, front-face expected axis, vertical fallback, degenerate normal rejection. Existing foot probe also passes. Low-lane diagnostic still selects Rfoot/start.1 and contact/pelvis numbers are unchanged; heading addition alone does not fix low mantle and full traversal remains unproven/failing. Do not claim matching parity: current cost lacks source group normalization, relative-foot position/velocities, vertical velocities and past trajectory. Raw feature weights remain intermediate until that normalization is implemented. No API/schema/cross-domain lifecycle edit or deliberate architecture exception; diff check passed. UE source assets not saved. + +- Replaced ad-hoc independent foot/pelvis position penalties with PSS_Traversal's left-foot-relative-to-right-foot position channel, weight1. New referenceTraversalRelativePosition subtracts the origin bone and unrotates by root quaternion, matching UE FAssetIndexer::GetSamplePositionInternal / FSearchContext::GetSamplePositionInternal (non-root origin case). No pelvis position channel exists in this schema. Fresh `build/traversal-relative-feet-proof.log` DIAGNOSTIC_DONE includes expanded FOOT_PLACEMENT_PROBE: whole-pose translation/root-yaw invariance and sensitivity to actual foot displacement. Diagnostic low lane still chooses Rfoot/start.1 and wrist/pelvis offsets unchanged; this is not full contact success. +- Velocity source audit: UE FAssetIndexer::GetSampleVelocity uses backward difference at FiniteDelta=1/60 (PoseSearchDefines.h); when bUseCharacterSpaceVelocities=true it shifts both sample and origin time. Existing referenceState.poseHistory already owns prior rendered pose for matcher query (copied before matcher.update each locomotion step). Before adding a second history, assess reuse and ensure it does not stay stale through traversal/air exits. Current step lacks velocity channels, past trajectory and normalization. No public API/schema/cross-domain lifetime changes or exceptions this turn. All six remain active; no full regression pass claimed. + +- Added PSS_Traversal character-space velocity channels: left relative right (all3,weight1), left/root vertical and right/root vertical (weight.3 each, UE Z->runtimeY). Candidate samples use source finite backward delta1/60s, previous clip sample at nonnegative time; query uses existing referenceState.poseHistory. Root-relative vector helper is shared with position channel. +- Moved poseHistory advancement from locomotion-only matching into positive-dt applyCharacterPose before overwriting renderPose. This preserves a single history owner and captures the preceding rendered pose during traversal too; dt0 does not advance it. Existing reset/bank-switch invalidation remains. No new persistent schema/public API/cross-domain lifecycle or exception. +- `build/traversal-velocity-history-proof.log` DIAGNOSTIC_DONE (fresh engine): added checks verify .1m/1-60s displacement has 6m/s magnitude, missing history yields zero velocity, preceding rendered pose retained for six active traversal frames, dt0 history unchanged; existing FOOT_PLACEMENT_PROBE checks also passed. Source contract gate PASS `build/traversal-velocity-architecture.log`. +- Full `build/traversal-velocity-regression.log` FAILED at earlier six-angle traversal hand probe (.792024,4.49198); this occurs before FOOT_PLACEMENT_PROBE so the dedicated run above is its evidence. Diagnostic selects Rfoot/start0 now, compared with prior start.1; low contact remains wrong. Full task is not completed. Next source normalization evidence: PoseSearchDerivedData.cpp FMeanDeviationCalculator groups CanBeNormalizedWith channels, computes shared vector centroid then mean Euclidean distance (not per-axis standard deviation), and uses1 for deviation<=.1 UE units. Need apply true source groups/index population, plus past trajectory, before claiming traversal matching parity. Active verification handles73844/71110/16801 all terminal; diagnostics own engines terminated normally. + +- Corrected active schema provenance: CHT_TraversalMontages_CMC's PSD_Mantles_0/PSD_Hurdles_0/PSD_Vaults_0 use PSS_Traversal_Chooser, not PSS_Traversal. Exported active leaf feature layout is the same six pose channels plus trajectory, but use the actual Chooser schema for future effective-property export. PoseSearch columns retain per-row ranges and each embedded DB indexes its full enabled table before runtime filtering. +- Added reference_traversal_features.nut: transient per-referenceBank/per-table feature cache contains all compiled montage rows/ranges, sampled features in UE centimetres, and independent channel mean deviations. Source CanBeNormalizedWith requires cardinality/class and matching group or label; the six current pose channels do not share groups (only left vertical explicitly names FeetVelZ). FMeanDeviationCalculator uses mean Euclidean distance to common centroid, fallback1 below/equal.1 source units. Cost applies squared inverse deviation and per-component source weights normalized by96.6 for these six channels. Trajectory channels remain missing; full schema weight sum must be extended with them. This is not complete matching parity. +- selectReferenceTraversal now searches cached eligible rows, rather than resampling and accumulating unnormalized errors every request. Internal chooser results retain table identity. Query attach position is additionally transformed by the actual rendered root, matching source query frame; no serialized format/public API change. Bank owns cache; replacing bank discards it; reset-course can reuse immutable sampled data. No cross-domain lifecycle change or deliberate exception. +- `build/traversal-normalization-proof.log` DIAGNOSTIC_DONE: fresh engine includes Euclidean-centroid/deviation, translation-invariance, small-deviation fallback, squared normalized weight formula, all-table-row coverage and same-cache reuse checks plus existing foot/history probes. Source gate PASS `build/traversal-normalization-architecture.log`; diff check PASS. Full `build/traversal-normalization-regression.log` FAILED six-angle contact (.792046,4.49198). Low diagnostic still selects Rfoot/start.1, right hand ~.75 for .62 ledge. No success claimed for contact. All run handles52206/96041/3216/10810 are terminal, verifier killed its own engine. +- Next: source past trajectory channels/index sample-grid/range population need completion. Also existing contact verifier hardcodes left hand, while new source selection picks Rfoot variants; inspect source hand-to-attach/contact timing before interpreting left-hand checks as universal. Do not loosen contact bounds to pass; right-hand low-lane data already shows a remaining real offset. Preserve six-item goal and remaining root/pelvis contact work. + +- Added both active PSS_Traversal_Chooser trajectory channels: root position at -.4 s (planar,weight.5) and current root velocity (planar,weight1,bUseCharacterSpaceVelocities=false). Cached feature groups now eight, source per-dimension weight sum99.6. Query rendered-root history records after FootPlacement every positive simulation dt, seeds once at dt0, bounded ~.5 s; unlike existing capsule positionHistory, it includes root offset and active traversal. Selection state owns copied points/time and resets with course. Same-time external reposition reseeds instead of fabricating velocity across teleport. Source pose-history non-extrapolating boundary behavior is retained by holding first/last sample. +- New reference_pose_history.nut samples montage root before time0 using source first1/30s rigid root delta repeated/inverted plus fractional interval. Root-space trajectory velocity uses current root frame (does not cancel character motion). Foot derivative prior sample is now clamped to montage start+offset rather than sequence time0. No native/public API/persistent schema/cross-domain owner change or exception. +- `build/traversal-trajectory-proof.log` DIAGNOSTIC_DONE: interpolation/edge clamp, cm and cm/s units, rotating inverse root extrapolation (two full intervals + fractional), actual root capture through active traversal, dt0 idempotence, teleport invalidation, prior foot/normalization checks. Architecture source PASS `build/traversal-trajectory-architecture.log`. Full `build/traversal-trajectory-regression.log` FAILED six-angle hand (.792046,4.49198); current real contact remains unresolved. +- Further source fix: FSearchIndexAsset constructor uses ceil(rangeMin*30) and floor(rangeMax*30), so sampling is global-grid anchored. Removed ad hoc montage.blendOut-.1 cutoff; use actual row range bounded by montage duration. `build/traversal-sample-grid-proof.log` DIAGNOSTIC_DONE verifies .88-.91 maps only sample27 and0-.08 maps0..2, plus all trajectory tests. Low lane still Rfoot/start.066667; wrist heights remain wrong. Diff check PASS. All run handles9795/42104/57577/3963/95202 terminal and owned engines stopped. Full architecture source gate preceded the final pure sampling-grid edit; no refactor/API change in that edit. +- Still pending: exact schema/column import (currently active eight-channel layout is coded from source), notify-based sample exclusions/database pruning or normalization-set scope, pose reselect history, and non-traversal per-Schema matching. More urgently verify actual support hand/timing for selected Rfoot montage and root/pelvis conflict without weakening bounds. All six objective items remain active; eight channels implemented is not proof of original visual coordination or completed contact fixes. + + +### Contact audit and moving-support correction + +- Fresh source/pre-FootPlacement/final-pose diagnostics (`build/contact-fingers/runtime.log`) resolve the earlier left-wrist failures: the selected Rfoot montage first reaches its attach target at .44961 s, then plants the right fingers. The left hand is authored to lift. First-window attach arrival and later physical finger contact are separate assertions; fixed-left-wrist assertions were invalid for this montage. The verifier now checks both actual fingers against their pre-foot authored pose and the physical surface, requiring consecutive eligible contact frames so a no-contact run cannot pass. +- A real low-ledge error remained: at .58333 s, right middle fingertip pre-foot Y=.63353 on a .62 m ledge became final Y=.72128 because floor adaptation raised the pelvis. New private `reference_hand_contact.nut` limits pelvis displacement by the supporting arm's bind-pose reach and restores the authored wrist transform after FootPlacement. It uses current pre-foot fingertips, traced obstacle identity, a short weight transition and actual FrontLedge release; it never pins the lifted hand or changes capsule/root authority. This is an explicit adaptation for this example's collision/floor correction, NOT a node found in the source UE graph. +- Coupled pelvis/arm diagnostic (`build/hand-reach-pelvis/runtime.log`): at .58333 s final fingertip Y=.63855; at .60 s final Y=.63444 versus authored .63385. Arm-only IK was insufficient because the raised shoulder put the target outside arm reach; the coupled constraint resolves that cause. +- `build/hand-contact-twelve-proof.log` PASS: three static heights and moving support, each at -15/0/+15 degrees; physical fingertip clearance <=.035 m, authored position deviation <=.025 m, non-vacuous contact coverage, dt0 re-evaluation idempotence and release/reset. Moving cases explicitly seed support pose/time; resetCourse intentionally resets the character, not the world animation. The previous first-moving-case entry failure came from inherited platform phase, not from finger IK. +- Full `build/hand-contact-final-regression.log` FAILED moving-platform attach retention after passing ordinary movement, jump/slide, three traversals, repeated traversal, collision, Hurdle and Vault. Diagnostic `build/moving-contact-phases/runtime.log` confirms a real missing transport: at .45 s attach error is zero, then -.03399 m vertically at .60 s while support rises. Updating the target alone cannot carry a held source root with zero extracted displacement. This diagnostic only completed phase0; its next phase was outside the entry probe and is not evidence for four phases. +- Added support-frame transport after first FrontLedge contact until final FrontLedge release. Copied previous support transform transports the collision-resolved extraction origin and rotation; the resulting motion request still passes through advanceWarped's capsule sweep. Positive-time evaluation updates the support snapshot; same-time cached evaluation cannot accumulate movement. Existing traversal reset/world teardown drops this private state; no Body pointer or second capsule-position authority is introduced. This extends the example's moving-support behavior and is not claimed to reproduce a specific source Blueprint node. +- `build/moving-frame-contact-proof.log` PASS for all twelve actual hand cases and the existing foot/history/normalization probes after support transport. Full regression, including a new eight-frame translated/rotated held-contact test, is running in `build/support-frame-final-regression`; do not claim complete pass until terminal result. Source architecture gate passed before support transport (`build/hand-contact-final-architecture.log`); rerun after final edits. No public API, persistent schema, module boundary or deliberate architecture exception in these script changes. + +### Remaining matching differences confirmed in exported reference assets + +- Current ordinary matcher still uses a fixed 30-component encoder and five horizons (-.05,0,.35,.7,1 s), regardless of selected database schema (`MotionFeatureInternal.h`, `reference_selection.nut`). Correction after effective-property audit: PSS_Default has full XYZ foot velocities (ComponentStripping=None), despite the FeetVelZ normalization label. The existing native 30-component default layout is consistent with these axes; only the traversal schema strips horizontal foot velocity. Do not shrink the default layout based on that label. +- Actual `PSS_Jump` trajectory samples are -.2,0,.2,.5 s, with different position/velocity/direction flags. `PSS_Relaxed_Jump` uses -.2,0,.25,.5,1 s. Reusing the ground horizon/vector layout cannot reproduce these searches or their landing preference. +- Actual `PSS_Relaxed_Loops` samples -.125,0,.33,.8,1.35 s and includes two Phase curve channels (one past sample), while current matching has no Phase channel. `PSS_Idle` includes a Pose subchannel; `PSS_Stop` changes current trajectory flags and omits the default 1 s sample. These are directly observed differences in local T3D exports, not assumptions about clip quantity. +- Traversal now has the active eight-channel source layout and mean-deviation normalization, but the layout is still coded from source. Exact data-driven schema import, common-schema/normalization-set scope, notify exclusions, reselection history and ordinary-schema encoders remain unfinished. Root-offset/orientation and pelvis actor-motion policies also retain known approximations. All six objective items remain active; these contact fixes alone do not establish original visual coordination. + +- Final fresh `build/support-frame-final-regression/result.json` PASS for the complete verifier, including twelve finger-contact cases, translated/rotated held contact, dt0 support idempotence, 24 consecutive traversals, Hurdle/Vault, moving contact/release, high-platform movement/falling and collision/render authority. `build/support-frame-final-architecture.log` PASS after support transport. These are bounded regression checks, not proof of complete six-item source parity. +- Final engine-owned close side frame inspected: `build/hand-contact-close-final/contact-side.png` at montage .60 s shows the supporting hand at the low ledge; HUD partly overlaps the upper body, but the contact is unobstructed. Camera override used resolveCourseCamera because eve_render rewrites Camera3D each frame. Earlier direct-camera screenshot was a distant view and was not accepted as close-contact evidence. Diagnostic exited normally and stopped its owned engine. All current verification sessions are terminal; no engine was intentionally left open. + + +### Effective schema extraction (step 6 continues) + +- Previous turn was progress: private hand/support fixes and full55-check runtime evidence. This turn preserves that verified runtime and advances the authoritative data needed for schema-specific indexing rather than guessing a new feature layout. +- Added standalone `tools/unreal-uasset-converter/ue_export_pose_schemas.py`. Reads all PSS assets using actual UE reflected properties; full paths preserve hierarchy/identity, required fields fail explicitly, primitive/enum/bone values are typed, nonfinite values fail. Group children retain order; Pose and Trajectory retain ordered authored samples because this installed editor does not reflect Pose.SubChannels despite its presence in the local source header. Eight source channel families include custom Blueprint subclasses; class paths remain explicit for later query-override routing. +- Output `eve.unreal-pose-schemas/1` is UE-centimetre data with source-package SHA256, unknown-field rejection, explicit re-export migration and atomic publication after complete success. No source package saves, retained UE object pointers, native API, module/lifecycle changes or architecture exception. This is channel configuration, not complete database/filter/normalization/Blueprint behavior. +- Fresh UE commandlet `build/pose-schema-export-final.log` exited0 and produced `build/ue-reference/pose-schemas.json` with34 schemas. Confirms PSS_Default foot velocities ComponentStripping=NONE; active PSS_Traversal_Chooser uses STRIP_XY. Prior suggestion to shrink default XYZ features was wrong and no such code change was made. +- Also confirmed existing MotionMatcher::search already replaces pose query components with eligible continuing pose values while retaining character trajectory. Do not reimplement this or call it wholly missing. Source CanUseContinuingPoseValues checks valid continuing result and identical schema; precise cross-schema handling remains for the actual multi-schema implementation. +- Attempted integration into old ue_export_selection failed BEFORE schema extraction because the source now contains duplicate short-name PSD_Dense_Stand_Walk_Stops assets (`build/pose-schema-integrated-export.log`, exit1). Preserved the existing ambiguity rejection and removed the attempted integration; the new standalone schema command uses full paths and successfully reads all34. Do not claim the old flat T3D export pipeline passed or bypass the ambiguity by overwriting files. Future full-database regeneration must resolve package-path identity explicitly. +- Five isolated exporter tests PASS: effective axes vs group label, repeated-name hierarchy, authored Pose/Trajectory samples without transient children, missing/nonfinite fields, and injected publish failure preserving old output/no temp leakage. Architecture source gate PASS (`build/pose-schema-architecture.log`); all9 architecture fixtures PASS (`build/pose-schema-architecture-fixtures.log`, 140.892s). The source gate preceded the final source-hash/doc-only additions; isolated exporter tests passed again after those changes. Runtime changes were not made this turn; previous55-check engine result remains the last runtime evidence. Runtime schema compilation/routing, Phase extraction, distinct trajectory encoders and normalization-set scope remain unfinished; all six objectives stay active. + + +### Schema compilation and traversal consumption (selection v6) + +- Previous goal turn was progress (34 effective source snapshots and tested atomic export). Current turn adds `scripts/unreal_pose_schemas.py` and connects it to the real selection compiler. Source Pose/Trajectory Finalize expansion order is preserved: position, two heading vectors for rotation, then velocity; trajectory position/velocity/normalized-velocity/heading. UE axes map to engine XYZ explicitly, sample weights multiply their parent weights, zero-weight channels retain dimensionality, and query/normalization/Blueprint identity is retained. Unsupported roles, computed bone-phase flags, sampling attributes, group subclasses and unknown custom query implementations reject rather than silently becoming default features. +- The 34 source files represent33 resolved schemas: PSS_Relaxed_Trans redirects to PSS_Relaxed_StandTurn. Snapshot v1 conflated the requested and resolved identity. `eve.unreal-pose-schemas/2` records source_path plus resolved object path and hashes both packages; consumers reject v1 and require re-export. Source and resolved packages are checked for modification around extraction. Fresh `build/pose-schema-v2-export.log` exited0. No source package was saved. Current compiler keeps request identity and resolved identity separate and hashes each canonical layout deterministically. +- Source compilation resolves all16 distinct schema paths referenced by the106 imported databases plus the actual embedded traversal schema (17 layouts). Representative dimensions: Default30, Idle34, Jump39, Relaxed_Loops25, Stop30, active Traversal_Chooser18. This is data, not yet proof of ordinary-schema runtime encoding. Some source trajectory parent weights are large; preserve those effective values rather than flattening all groups to1. +- `build_unreal_selection.py` now reads the schema from each PoseSearchColumn row's embedded database, records tableSchemas, and emits selection schema6 with compiled layouts. It requires the effective snapshot and hashes it. All21 tables,106 databases and34 montages regenerated successfully. The runtime rejects selection5 and unknown fields; regeneration is the explicit migration. It validates layouts before publishing the replacement bank. Existing selection.json is a companion report; selection.nut is the runtime's self-contained authoritative import. +- New `reference_feature_schema.nut` resolves the existing eight traversal encoder slots by semantic channel identity, independently of serialized order. Weights, total dimensional weight, schema sample rate, past-root query time and explicit normalization groups now come from imported data. Unsupported traversal pose offsets, origins, axes or extra channels reject before bank publication. Bank-owned immutable configuration determines retained root-history duration; reset continues to invalidate copied history. No native API/module/cross-domain lifecycle change or deliberate architecture exception. +- Traversal indexing and query now use the imported sampling grid/historical offset; scoring uses imported weights. Normalization pools explicit same-class/cardinality groups and retains distinct semantic labels for the implemented traversal channels. This does NOT yet establish cross-database NormalizationSet/common-schema pooling or support arbitrary pose schema channels in the ordinary native matcher. +- First runtime attempt failed initialization because an absolute1e-5 weight-sum check was too strict for float32 accumulation of large source group weights; the later drainEvents error was downstream of that failed initialization. The validator now uses1e-6 relative numeric tolerance and includes the schema path in mismatch diagnostics. `build/schema-driven-traversal-proof2.log` PASS for actual contact and existing pose/trajectory probes. +- Added eight isolated compiler tests (trajectory flags/order/weights, two-axis pose rotation, zero-weight dimensions, redirects and duplicate names, deterministic hashing, input immutability, unknown-field/missing-schema/invalid-value rejection). Together with existing selection and exporter tests:30 PASS. Expanded real-engine FOOT_PLACEMENT_PROBE changes imported weight, sample rate and normalization group to prove runtime consumption, rather than only testing the original constants. +- Full `build/schema-selection-v6-regression/result.json` PASS55 checks, including those new parameter checks, twelve hand contacts,24 consecutive traversals, moving/rotated support, conditional exits, high-platform movement/falling and collision/render authority. Engine-owned static and moving contact screenshots inspected; these are distant representative frames, not new close-up finger evidence. Verification engine exited normally and was stopped. Architecture source gate PASS (`build/schema-selection-v6-architecture.log`) and all9 fixtures PASS (`build/schema-selection-v6-fixtures.log`,147.942s); both handles terminal. Compiled selection coverage also verifies every database and all3 embedded traversal tables resolve to an imported layout. +- Remaining step6: replace the ordinary fixed native30-component layout with actual per-schema indexing/query routing (especially Idle34, Jump39 and Phase-curve schemas), import/evaluate Phase curves, and establish source normalization-set, notify-exclusion and reselection-history semantics. Do not count compiled-but-unused ordinary layouts as completion. Remaining six-item audit also includes known continuous blend/orientation and pelvis actor-motion approximations; keep the original goal active. + + +### Native variable vector layout: first runtime consumer + +- Previous comparison turn revalidated 68 source/resolved schema hashes against the actual reference Content files, all matching, and confirmed the ordinary runtime still used the fixed layout. This turn builds and connects the pending native vector implementation; the six-item objective remains active. +- Added typed Position/Velocity/Heading channels with ordered axes, explicit query policy, sample time, weights and normalization groups. Database owns its copied immutable layout; matcher owns its copied normalized query. Invalid setters preserve prior state, mixed physical/normalized velocity pools reject, and basic query APIs reject variable-layout databases. Pose offsets remain zero-only; curves are not yet supported. Owner-thread/lifetime/no-callback and captured desired-yaw contracts are documented. No persistent-format/module/lifecycle change or deliberate architecture exception. +- Root sampling supports arbitrary past/future trajectory times, loop rigid-cycle accumulation and nonloop extrapolation. Sample grid uses imported rate, includes nonloop grid endpoints, and preserves the old incremental grid for legacy layouts. Normalization and query encoding share vector helpers; actual per-schema normalization-set scope is still missing. +- First compile exposed private AnimClip sampling access from a free helper; made sampler a private nested database implementation rather than widening public access. Explicitly touched 36 native consumer TUs for changed class layouts. Configure/build eve and unit_test passed (build/native-schema-build2.log). +- Example reference_selection now configures its bank from imported PSS_Default through setFeatureLayout and submits timestamped setFeatureQuery values. This replaces its fixed native encoder, but ALL ordinary chooser databases still share Default. Per-schema routing, Phase and schema-specific query prediction remain required; do not count this as their completion. +- build/native-schema-tests.log PASS20 existing/new motion tests. Added a third boundary test and rebuilt unit_test; build/native-schema-boundaries.log PASS3 schema tests, covering copied layout/query, invalid replacement, velocity discrimination, 20 Hz grids, arbitrary -.2/+1.35 s trajectory selection across loop/nonloop boundaries, missing/duplicate query times and mixed-unit normalization rejection. Full-quaternion query invariance and rotating-cycle cases still need dedicated coverage. +- Fresh build/native-schema-runtime/result.json PASS55 real-engine checks, including twelve hand contacts,24 repeated traversals, moving platforms, high-platform movement/falling and collision/render authority. Actual startup reports reference bank1879 clips,106 databases,bake1.235s. Engine-owned walk and low-mantle contact frames inspected; distant frames are not new close-up or complete coordination evidence. Runtime verifier terminated normally and stopped its engine. +- Binding strict check, module dependency check, test manifest and diff check passed. Architecture source gate passed with PYTHONUTF8=1 (build/native-schema-architecture.log); initial non-UTF8 run failed while decoding git diff, not on a contract finding. All9 architecture fixtures passed in153.361s (build/native-schema-fixtures.log); handle31847 terminal. + + +### Schema-driven query times and evaluated Phase source data + +- Previous goal turn was progress: native variable vectors compiled, Default consumer connected, PASS55 runtime. This turn removes the remaining five-time query list and four-frame movement-history assumption. Bank owns sorted distinct trajectory times read from its imported layout; actual capsule positions, velocities and yaw now have injected simulation timestamps and retained history bounded by imported schema offsets. Query interpolates position/velocity and shortest-arc yaw, extrapolates only before the initial history sample, and returns copied values. Positive horizons reuse acceleration/ballistic prediction at the actual requested time. Reset/bank switch clears movement history and its clock. No new API/schema/module/lifecycle change in runtime scripts. +- Expanded real-engine probe asserts the bank uses native variable channels, verifies actual Jump -.2/0/.2/.5 and Relaxed_Loops final1.35 horizons, history interpolation, cold extrapolation, yaw wrap and no mutable aliasing. Fresh build/schema-query-times-runtime/result.json PASS55 full runtime checks; engine-owned sustained-Relaxed-walk frame inspected. Verifier84779 exited0 and stopped its owned engine. Per-schema bank routing is still pending, so current actual bank uses Default times; other imported layouts are covered by the extraction probe, not claimed as active matching. +- Direct UE5.8 Runtime/Private/PoseSearchFeatureChannel_Curve.cpp audit: source Curve uses one scalar, copies continuing values when eligible, otherwise samples pose-history curves at the channel offset. Phase must not be recast as a custom circular angle. Imported animation metadata contains97 Phase-bearing sequences. +- Refactored existing UE contact exporter evaluation into an internal fixed-channel helper, preserving old contact format/CLI. New ue_export_pose_curves.py uses the same UE evaluator for Phase at240Hz plus all authored key times/endpoints. New intermediate eve.unreal-pose-curve-samples/1 records all sequences including absence, unknown-field reject and source hashes; checks source hash before and after evaluation, atomically publishes, and never saves source packages. Existing contact tests retain nonfinite/atomic failure coverage; added actual wrapper test for nonlinear evaluator values and mid-export source mutation preserving old output. Five Python tests pass (build/pose-curve-export-tests.log). +- Actual reference UE commandlet49018 exited0 (build/pose-curves-ue-export.log), producing build/ue-reference/pose-curve-samples.json:2008 entries,97 Phase curves,4220406 JSON bytes. This is unreduced intermediate source data, not runtime payload. Phase reduction/EVFC compilation, native scalar channel indexing/query and evaluated curve-history consumption remain required. Do not mark Phase or six-item objective complete. +- Source architecture gate passed before exporter addition (build/schema-query-times-architecture.log); final source gate after exporter changes also PASS (build/schema-query-times-architecture-final.log), handle32004 terminal. All9 fixtures passed in182.881s (build/schema-query-times-fixtures.log), handle63752 terminal. No deliberate architecture exception; no native layout/API change this turn. Diff check passed. + + +### Phase runtime binary compilation + +- Previous goal turn was progress: schema-driven prediction/history and UE Phase extraction. Current turn adds scripts/build_unreal_pose_curves.py, using a shared explicit-schema/channel reduction helper extracted from the existing contact compiler. Contact input/output versions and accepted channel names stay unchanged. Pose compiler rejects wrong source schema, unexpected channels/fields, stale hashes and incomplete source coverage, then emits the existing generic EVFC/1 format. Publication failure preserves the previous output and removes its temporary file. No new native API, cross-domain lifecycle or module dependency; no deliberate architecture exception. +- Actual source artifact compiled to examples/climbing-motion-matching/assets/ue-local/selection-v1/pose-curves.evc:197802 bytes for2008 source records,97 present Phase curves. build/pose-curves-error-report.json verifies111545 playable UE sample values against decoded Float32 output:1068 retained keys,max error0.0008114851. The .001 reduction bound applies at the evaluated samples, not analytically between240Hz samples. Source post-roll is validated but playback ends at the actual sequence duration. +- Eight Python tests PASS (build/phase-compiler-tests.log): nonlinear source evaluation, signed Phase/error/absence/input immutability, incompatible schemas/channels, stale/incomplete source rejection, Float32 collision rejection and failed atomic publication. Existing contact nonfinite/absence/publication coverage retained. +- Runtime FOOT_PLACEMENT_PROBE now loads this artifact with the native AnimCurveLibrary and compares a real Phase sample at.5s to UE value0.5264031887. Fresh build/phase-library-runtime/result.json PASS55 complete scenario checks; engine-owned moving-platform contact frame inspected (distant view). Verifier74943 exited0 and stopped its owned engine. This proves native curve loading/evaluation only; Phase still does not participate in motion feature scoring. +- Direct source audit confirms Curve IndexAsset uses sampleTime+channelOffset and scalar curve value. Query uses continuing feature values when eligible; otherwise requests evaluated pose-history curves, defaulting to0 when absent. Actual compiled schemas with Phase are PSS_Default_Mover and PSS_Relaxed_Loops, both current and-.0333s with USE_CONTINUING_POSE. Next required work is native scalar channel indexing/query plus curve history and per-schema bank routing; all six objectives remain active. +- Architecture source gate PASS (build/phase-compiler-architecture.log), handle71420 terminal. All38 related Python tests PASS (build/phase-compiler-python-regression.log). Fixture handle85778 subsequently exited0: all9 cases PASS in231.435s (build/phase-compiler-fixtures.log). Diff check passed. + + +### Native scalar curve feature indexing and queries + +- Previous goal turn was progress: reduced Phase EVFC compilation and native evaluator probe. Current turn adds MotionFeatureKind::Curve, named scalar channels with arbitrary bounded time offsets, and copied MotionFeatureCurveSample queries. Curve axes must be scalar1/sourcePose; vector channels reject nonempty curve names. Normalization treats curves as dimensionless signed scalars; continuing query policy uses the existing eligible-playhead feature substitution. Missing authored channels in known sources evaluate to0; missing query channel/time rejects rather than guessing. +- MotionDatabase::setFeatureCurves(bytes,sources) atomically owns a decoded EVFC snapshot plus copied source names keyed by already-added clip identity. Rejects invalid/incomplete/missing/conflicting sources and post-bake changes. Bake validates required curve provider/source presence before frame mutation. The existing AnimCurveLibrary const implementation has no mutable sampling state; contract now explicitly permits concurrent const sampling while the owner prevents load/destruction, supporting database bake workers. Public API ownership/thread/no-callback/failure contracts documented; no new persistent format, module boundary, ECS state or deliberate architecture exception. +- Script loadFeatureCurves bridges filesystem bytes to the checked native API. setFeatureLayout accepts curve names as field13; setFeatureQuery now accepts a fifth curve-array argument [name,seconds,value], and the example's sole ordinary caller supplies[] until per-schema routing/curve history is connected. Imported channel mapper now converts scalar channels directly. No false claim that ordinary Default matching uses Phase. +- Explicitly touched all affected native consumers for header layout/signature changes. Native eve/unit_test build PASS (build/native-phase-build.log). Full motion tests PASS22 (build/native-phase-tests.log); after adding continuing-policy test, rebuilt unit_test and all5 schema tests PASS (build/native-phase-policy-tests.log). Scalar tests prove positive/negative/absent Phase chooses different clips with identical bone poses, invalid replacement preserves the query/data, copied binary lifetime, negative-time nonloop sampling and actual Character vs Continuing policy behavior. +- First runtime build/native-phase-runtime completed PASS55, but a mid-run mapper edit failed hot reload due existing characterSkins.part serialization limitation. Started fresh verifier92000 at build/native-phase-imported-runtime; its probe uses the actual imported Relaxed_Loops Phase definition, EVFC source data, native curve index and explicit query, expecting matching cost0 at.5s. Do not use the first run as proof of the latest mapper. Fresh verifier92000 exited0 with full55-check PASS (build/native-phase-imported-runtime/result.json), including imported scalar index/query cost agreement. Engine-owned high-platform-falling frame inspected; verifier stopped its owned engine. +- Strict bindings, module dependency, test manifest and diff checks PASS. Source architecture gate before final mapper/test edits PASS (build/native-phase-architecture.log), handle14071 terminal. Final source gate11248 PASS (build/native-phase-architecture-final.log); fixture6694 PASS9 in211.154s (build/native-phase-fixtures.log); both handles terminal. Remaining: evaluated curve-history blend/offset queries, actual per-schema banks and routing, broader root quaternion/normalization-set/notify fidelity, plus the other six-item audit gaps. Objective remains active. + + +### Evaluated Phase and pose-curve history + +- Previous goal turn was progress: native scalar indexing/query, matching discrimination and imported-channel proof. This turn extends the existing contact-curve evaluator to a third signed Phase channel, sharing current/outgoing clip selection, inertial value/derivative transition, Standard entry blend and advancing montage exit-tail evaluation. It does not invent a second independent Phase blend state. FootPlacement continues reading indices0/1; Phase owns index2. Both EVFC libraries and full source coverage validate before publication; reset restores all three channels. +- Selection state owns copied {time,value} Phase history recorded after actual curve evaluation on the same injected pose clock as rendered-root history. Imported layout retention bounds storage; zero-dt reevaluation is idempotent. referencePoseCurveQuery extracts distinct Phase/time requests from the layout and samples evaluated history, with cold0, linear interpolation and endpoint clamping. Actual UE PoseSearchHistory.cpp LerpEntries/GetCurveValueAtTime source confirms default non-extrapolated linear clamped sampling. Unknown named curves reject explicitly. No new native API, persistent format, module or cross-domain lifecycle, and no deliberate architecture exception. +- Ordinary setFeatureQuery now requests layout-driven curve queries instead of a hardcoded empty array. Current bank.queryLayout is still Default, so actual ordinary searches still have no curve channels; Relaxed schema routing is not yet complete. History/evaluation executes on real animation frames and is tested with explicit imported Relaxed_Loops curve requests. +- CONTACT_CURVE_PROBE fixture now includes signed Phase +1/-1. Expanded checks verify source-active evaluation, retained +1 at a switch to raw -1, history query using blended value, zero-dt history idempotence, eventual negative-value convergence and a .6s outgoing Phase exit tail into an absent-curve clip. Existing foot/contact tests retained and test-injected libraries are restored. +- Fresh build/phase-history-runtime/result.json full55-check PASS, including all new Phase checks plus repeated traversal, moving-platform contact and high-platform falling. Verifier82356 exited0 and stopped its engine; no script edits were made during this run. Engine-owned conditional-mantle-exit frame inspected. Architecture source gate60236 PASS (build/phase-history-architecture.log); fixture15934 PASS9 in171.262s (build/phase-history-fixtures.log), handle terminal. Diff check PASS. Keep full six-item goal active; next major step is actual schema-specific banks/routing, then normalization-set/notify fidelity and other remaining coordination gaps. + +- Next routing sizing audit from current selection.json:106 DBs reference16 schemas,1069 distinct source clips,1192 total distinct clips across schema groups (Default608, Default_Mover22, Idle4, Jump60, Relaxed_Idle4, Relaxed_Jump59, Relaxed_Loops35, Relaxed_Pivots132, Relaxed_RunSpins30, Relaxed_Slide8, Relaxed_SlideExit12, Relaxed_StandTurn8, Relaxed_Starts74, Relaxed_Stops54, Relaxed_WalkSpins30, Stop52). Build actual schema membership rather than replicating1879 clips across every layout; maintain explicit local-to-source/global identity for selection/traversal consumers. No such routing change has been made yet. + + +### Ordinary per-schema routing connected + +- Previous turn was progress: evaluated Phase history with full55 regression. Current reference_schema_banks.nut builds all16 imported ordinary layouts from actual member sources (1192 distinct clip memberships across1069 unique sources), while retaining the master1879-source catalogue for traversal/contact data. Index-local source maps prevent matching IDs from leaking into master lookups. Candidate schemas derive from current Chooser results; scoring uses each native layout and its timestamps/Phase history, with a fresh matcher for inactive schemas so stale playheads cannot masquerade as continuing poses. Equal scores prefer the current eligible schema. +- Candidate-range updates occur on selection changes or fresh inactive matchers, rather than rebuilding native frame masks every frame. Query trajectories cover the union of selected schema times. Query poses are copied before adding component position/rotation and actual resolved velocity so world-space pose velocities include character travel without altering the rendered pose. Schema switches queue a continuous transition and update the active matcher/database/clip array centrally. Ordinary matching no longer invokes appendReferenceAirRanges; old helper is now unused and remains to remove during cleanup. +- First actual Jump build rejected a mixed physical/normalized velocity normalization group. This exposed an INCORRECT earlier defensive rejection. Direct UE PoseSearchFeatureChannel::CanBeNormalizedWith checks class/cardinality/explicit group, not bNormalize; the real Jump schema uses such a pool. Removed rejection and added explicit MotionFeatureLayout.normalizationLengthScale (default1 for metres,100 for imported centimetres). Physical vector channels scale before pooling/query normalization; headings, normalized velocities and scalar curves do not. Degenerate physical thresholds scale consistently. This preserves generic API units and actual source grouping instead of pretending mixed units cannot occur. Earlier log statements/tests claiming such groups should reject are superseded. +- Updated script setFeatureLayout signature(rate,channels,lengthScale), both callers, docs and bindings. C++ aggregate default remains metre-based. Native build PASS; all6 schema tests PASS including new mixed-pool source-scale/index-query agreement (build/per-schema-native-tests.log). Public scale ownership/value/validation contracts apply; no new persistent format/module/cross-domain lifecycle or deliberate architecture exception. +- build/per-schema-runtime failed init on the now-corrected mixed-pool rejection. build/per-schema-runtime2 built all16 and reached high-platform checks, then the verifier itself used an obsolete global clip index. Fixed assertion mapping through referenceMatchedSource and strengthened selected-schema/dimension assertions. Subsequent build/per-schema-runtime-final passed high-platform movement/falling but caught a REAL montage-exit regression: schema-switch pose blending ran after per-bone exit blending and modified feet that should already be released. +- Added explicit transitionBeforeExit for schema switches, reset on generic queue/cancel. Main pose pipeline now performs schema-switch blending before the montage exit layer, retaining per-bone exit authority. No assertion threshold was relaxed; failure diagnostics now include bone/dot. Full fresh build/per-schema-exit-order/result.json PASS55, including all original contact/traversal/high-platform/exit checks and actual selected-schema assertions. Verifier32052 exited0 and stopped its owned engine. +- Measured successful schema startup: all16 built (Default30,Idle34,Jump39,Relaxed_Loops25 etc), overall reference selection load8.194s,bake1.449s in build/per-schema-runtime2/runtime.log. This is build timing, not a measured frame-rate claim. Phase is now part of ordinary searches when those schemas are selected, no longer only an isolated probe. Cross-schema NormalizationSet/common-schema scope, notify exclusions/reselection-history and full orientation/blend/pelvis parity remain unfinished; keep all six goals active. +- Architecture source gate PASS before exit-order fix and again after it (build/per-schema-exit-architecture.log), final handle99146 terminal. All9 architecture fixtures PASS220.151s (build/per-schema-fixtures.log), handle79424 terminal. Strict bindings/module dependencies/test manifest/diff checks PASS. Pending cleanup includes unused old air filter, preview-to-reference query-facing reset, additional direct world-space/angular query tests and measured multi-schema frame cost. + +- Engine-owned sustained-Relaxed-walk and high-platform-falling frames from per-schema-exit-order inspected after the complete pass. These are representative frames, not proof of full-body coordination at all turn/obstacle angles. + +### Source BlockTransition entry protection + +- Direct UE 5.8 source comparison showed that `PoseSearchAssetIndexer` stores `BlockTransition` independently from database exclusion: protected poses remain valid continuation, while search filters reject them as new destinations. `FAnimationAssetSampler` evaluates notifies through a centered `1/120 s` interval. The previous runtime only implemented `PoseSearchExcludeFromDatabase`, so motion matching could jump into protected middles of stops, turns, landings and other authored sequences. +- Added a source-hash-verified `eve.unreal-transition-notifies/1` export and selection schema 7. The exporter read the user-supplied reference project without saving assets: 2008 AnimSequences, 663 sequences containing 671 BlockTransition windows. UE Python 5.8 exposes effective trigger time and duration but not `EndTriggerTimeOffset`; the artifact declares `EffectiveTriggerPlusDuration`, and the compiler expands both ends by half the source extraction interval. Invalid, stale, duplicate, incomplete or unknown-field input rejects before publication. +- `MotionSearchRange` now owns copied transition-block intervals. `setCandidateRanges` validates and atomically builds a separate per-frame block mask, unioning blocks across duplicate ranges. Search applies it only to new candidates; eligible continuation crosses a block normally, while an ineligible current clip still exits. Inclusive-start/exclusive-end boundaries are deliberate, and an expanded end beyond clip duration protects the terminal sample. Script ranges now require six values and selection-v7 supplies the actual blocks. +- Native build PASS. All 27 `animation.motion*` tests PASS, including fresh-entry rejection, protected continuation, ordinary membership authority, duplicate-range union, exact boundaries, copied inputs, all-blocked recovery and atomic invalid-input failure. All 34 selection/layout/schema Python tests PASS. Regenerated selection contains 106 databases, 1246 sample ranges and 610 ranges carrying real block windows. +- Fresh `build/transition-block-runtime/result.json` PASS all 55 engine scenarios, including starts/stops/turns/jumps/lands, repeated traversal, montage exits, moving/high platforms, collision authority and falling. Engine-owned traversal contact, high-platform falling and conditional exit frames were inspected. Strict bindings, module dependency, test manifest and diff checks PASS. This completes the first newly identified parity gap, not the overall six-item objective; per-pose cost notifies, continuing cost override/history, shared NormalizationSet and full orientation/contact coordination remain active. +- Public API/format rules apply: owned copies, bounded validation, checked `Result`, owner-thread/no-callback contract, schema bump with reject policy and explicit regeneration. No new module boundary, ECS lifecycle or deliberate architecture exception. + +### Source pose and continuing cost fidelity + +- UE source confirms two independent costs. Every indexed pose starts with its database `BaseCostBias`, a `PoseSearchModifyCost` state replaces that value, and looping bias is then added. Continuing evaluation separately starts from the result database's `ContinuingPoseCostBias`; `PoseSearchOverrideContinuingPoseCostBias` replaces it at the current sampled pose. Both remain additive to feature dissimilarity. The previous runtime carried only base/looping candidate bias and a global 10% switch guard, so it could neither rank authored local segments nor retain stops, turns, jumps and lands with their database-specific policy. +- Extended the read-only source snapshot to `eve.unreal-pose-search-notifies/2`, preserving BlockTransition plus finite local candidate and continuing cost values in original event order. Current reference evidence is 2008 sequences, 671 block windows, 22 candidate-cost overrides and 63 continuing-cost overrides; authored values span -10 through -0.01. Selection schema 8 now retains database continuing bias (14 distinct values among 106 databases) and the two source override lists. Source hashes, complete coverage, time basis, fields, cardinality, ordering and finite values validate before publication. +- `MotionSearchRange` now copies separate candidate and continuing defaults/overrides. During atomic frame selection, the last active override replaces its default, matching UE notify iteration. If one clip/frame is supplied by multiple selected databases, the continuing bias stays paired with the range having the lowest candidate cost rather than independently taking an impossible combination. Search adds continuing cost only to the eligible live playhead; new candidates receive candidate cost only. Looping bias is folded only into candidate defaults/overrides in the example. +- Native rebuild PASS. All 30 `animation.motion*` tests PASS, including local override order/end boundary, database continuing retention, local continuing override switching, duplicate-range cost pairing, invalid-input atomicity and all prior BlockTransition/schema/curve contracts. The 21 focused compiler tests PASS. Regenerated production selection contains 1246 candidate ranges; 21 selected ranges carry candidate overrides and 88 carry continuing overrides because source clips can belong to multiple databases. +- Fresh `build/pose-cost-runtime/result.json` PASS all 55 production scenarios. Stop, pivot and landing engine-owned frames were inspected after the pass; no missing render/collision regression appeared. This completes the second newly identified scoring gap, not the overall six-item objective. Shared NormalizationSet, pose-reselection history/full search threshold fidelity, orientation warping and unified contact coordination remain active. +- Public API/persistent format rules apply: owned bounded intervals, checked atomic `Result`, owner-thread/no-callback lifetime contract, reject-policy schema migration and explicit regeneration. No module boundary, ECS lifecycle or deliberate architecture exception. + +## 2026-09-12 UE NormalizationSet parity + +- Exported four PoseSearch normalization sets from the reference project without saving source assets. +- Selection schema 9 preserves the three sets referenced by the 106 selected databases and 23 additional databases used only as normalization input. +- Runtime banks are keyed by `(schema, normalizationSet)`. `PSS_Stop` now has distinct Dense and Extreme Sparse statistics. +- Native variable-layout databases accept ordered inclusive normalization ranges; duplicate entries preserve UE dataset multiplicity. +- Evidence: 77 focused Python tests, 32 native motion tests, and all 55 real-engine reference scenarios pass. Engine screenshots were inspected for traversal contact and high-platform fall/landing. +- Measured runtime: `load=17.137s`, `bake=10.404s`; offline compiled feature/statistics caching remains necessary. +- Remaining visible gap: the high-platform descent can select a descending interval from `M_Neutral_Jump_F_Start_Walk_*`, leaving the torso too upright despite vertical-phase filtering. + +### UE pose-reselection and strict-cost switching + +- Direct UE 5.8 source confirms the ordinary MotionMatching node defaults: PoseJumpThresholdTime=(0,0), PoseReselectHistory=.3s and SearchThrottleTime=0. The search compares candidates directly with continuing cost; the prior EVEngine 10 percent improvement gate had no source equivalent. +- Reference schema matchers now use zero-frame jump exclusion and .3s reselect history. Native matchers retain an owned, bounded frame-age history, update it only from positive simulation steps, keep continuation eligible and reject invalid configuration atomically. Recent lookup is limited to current candidate frames. +- Removed the artificial 10 percent switch margin. Tests prove a strict .05 cost improvement switches, recent poses cannot be jumped back to, continuation remains valid and expiry restores eligibility. +- Native build and all34 motion tests PASS. Fresh build/pose-history-runtime/result.json PASS55; high-platform descent remains visually too upright and still selects M_Neutral_Jump_F_Start_Walk_Rfoot_0 at .15s, so this search-policy correction does not claim that separate issue fixed. +- Public Result/ownership/thread requirements apply; no persistence, module, ECS or cross-domain lifecycle change and no deliberate exception. +# 2026-09-12 update: orientation warping parity + +- Added the locomotion BlendStack OrientationWarping stage after pose blending and before OffsetRootBone/contact solvers. +- Matched the authored Sandbox graph values: graph-driven locomotion direction, five spine bones, `ik_foot_root`, both IK feet, `DistributedBoneOrientationAlpha=0.5`, `LocomotionAngleDeltaThreshold=135`, and `RotationInterpSpeed=8`. +- Root motion direction sampling now joins the end and beginning displacement of looping clips instead of treating the loop boundary as a large backwards step. +- `build/orientation-warp-runtime-final/result.json` passed the 55-scenario real-engine suite, including explicit threshold/interpolation/bone-chain assertions. Representative strafe and high-platform images were inspected; orientation warping improves locomotion turning, while the still-upright falling pose remains an air-schema/state issue. + +# 2026-09-12 update: airborne phase and dynamic range parity + +- Connected the existing airborne range builder to every selected Jump schema. It was previously dead code, so root-trajectory phase filtering never constrained the native matcher. +- Descending ranges now require both authored foot contact curves to be released. This rejects stationary, planted jump anticipation without animation-name rules. +- Rebuild airborne ranges every simulation step because vertical velocity and time-to-land change continuously. The old path only replaced ranges when the Chooser database list changed. +- Fixed native continuation eligibility: a live playhead must be represented by a nearby frame in the replacement ranges. A distant valid interval in the same clip can no longer retain an invalid phase. +- Added `animation.motionMatching.replacementRangesInvalidateDistantSameClipContinuation`; all 35 focused motion tests pass. +- `build/air-phase-final/result.json` passes all 55 real-engine scenarios. The high-platform capture now selects authored walk-off animation `M_Neutral_Jump_F_Off_Walk_Rfoot_0 @ 1.15`, inside its active `[1.067,1.300]` range, instead of planted `M_Neutral_Jump_F_Start_Walk_Rfoot_0 @ 0.15`. + +# 2026-09-13 final reference-parity closure + +- The final hand-contact pass now uses the UE-authored FrontLedge window as authority. It solves the wrist from a world-space middle_03 fingertip target on the actual obstacle top; Mantle retains its authored left-hand support while Vault/Hurdle constrain both hands. The contact response is 8 ms, source poses remain immutable, and constraints release with the traversal window. +- Foot Placement is followed by the takeoff-ground constraint before hand IK, so pelvis adaptation cannot reintroduce foot penetration while satisfying the ledge contact. +- The verifier now distinguishes reference InAir/landing state from the KayKit irState, accepts the intentional native cancelled terminal produced by conditional montage exit, compares modifier movement against a walk baseline, and verifies supporting fingertips rather than requiring the wrist origin to cross the ledge. +- A persistent baked-index cache prototype was measured and removed: 17/17 cache hits still spent 11-14 seconds rebuilding in-memory frame structures and worsened total startup. The shipped path keeps deterministic eight-worker parallel bake and creates no large save files. +- Final evidence: uild/reference-parity-final/result.json passes the complete 55-scenario real-engine suite. Representative engine-owned low/high hand-support and landing frames were inspected. nimation.motionMatching|motionSchema native tests pass 35/35; UE Python tests pass 77/77; strict bindings, binding-gap metadata, module manifest, dependency graph, test manifest and diff checks pass; architecture source gate and all 9 fixtures pass. +- Applicable architecture rules: public Result/ownership/thread contracts, copied matcher inputs, persistent selection schema versioning/reject policy, single capsule authority, and module boundaries. No deliberate exception. + +# 2026-09-13 runtime sequence parity follow-up + +- Runtime hitch audit found two independent synchronous costs: candidate-range publication scanned every frame in the schema bank, and script-level schema search was followed by MotionMatcher's own timed search. MotionDatabase now records contiguous per-clip frame offsets, and the reference layer owns search scheduling. All 36 animation.motionMatching/motionSchema tests and the climbing candidate performance test pass. +- Airborne sequencing now uses the imported CMC families without a transition-frame database scan: Stand/Walk/Run/Sprint Start, Walk/Run Off, Fall loop, and Stand/Walk/Run Light/Heavy Land. Lfoot/Rfoot is selected from the live pose at takeoff and again immediately before ground contact. Ground OrientationWarping is cleared and disabled during the airborne sequence. +- `scripts/verify_reference_air_sequence.py` drives the real engine and passes all 12 sequence families. The full climbing verifier passes traversal, 24-cycle reentry, gravity, contact landing and deck collision with the new sequence path. +- Ground sequence tracing now runs outside the obstacle lanes and records every selected source for Walk/Run/Sprint start-loop-stop, forward-to-back pivot, strafe and slide. It exposed repeated Lfoot/Rfoot slide-exit reselection; unchanged Sliding context now searches once on entry and plays the selected authored range through. Start/Stop/Pivot/Transition one-shots likewise retain their selected imported range while their movement condition remains valid, but opposite input or movement/stop changes may interrupt. `build/reference-sequence-trace-4/runtime.log` confirms the slide sequence is now `KneesOut_trans_FootOut -> FootOut_Out_Moving_Run -> Sprint_to_Run` with no alternating-foot churn, and the 12 airborne families remain passing. +## 2026-09-13 locomotion transition parity + +- Reconstructed `SandboxCharacter_CMC_ABP.Get_MMBlendTime` from the exported + reference graph: ordinary locomotion uses 0.50 s, upward jump entry uses + 0.15 s, landing from InAir uses 0.20 s, and other airborne changes use + 0.50 s. The example now applies those values instead of a global 0.14 s. +- Reconstructed `Get_MMInterruptMode`: core MovementMode/MovementState changes + interrupt the selected database; a gait-only change interrupts while Moving, + but idle Stop playback remains `DoNotInterrupt`. Non-looping Stop clips now + hold until movement resumes rather than chaining Sprint/Run/Walk stops. +- Pivot classification now compares current velocity direction with requested + input acceleration, matching the reference predicate. Collision state is + calculated from the current capsule move, with a minimum effective-speed + threshold so a reversal's zero crossing is not mistaken for a wall. +- `build/reference-sequence-pivot-final/runtime.log` records the verified + reversal `Run_Loop_F -> Run_Pivot_F_B_Rfoot -> Run_Start_B_Lfoot -> + Run_Loop_B`. `verify_reference_air_sequence.py` now asserts that a reverse + trace visits a Pivot clip and that each Stop trace selects only one clip. + The same run passed all Stand/Walk/Run/Sprint starts, Off_Walk/Off_Run, and + Stand/Walk/Run light/heavy landing families. + +## 2026-09-13 pose-history feedback and terminal-stop parity + +- Split pose history by its actual graph owner. Pose Search now reads the raw + Motion Matching/BlendStack output, before OrientationWarping, root offsets + and limb IK; traversal feature extraction retains the preceding fully + rendered pose so authored support motion remains measurable. This prevents + procedural corrections from feeding back into the next search and causing + progressively incompatible, twisted pose selection. +- The native matcher normally searches when a non-looping clip reaches its + end. Reference Stop playback now suppresses that terminal search while idle, + implementing the graph's `DoNotInterrupt` result. Fresh trace initially + exposed `Walk_Stop_F_Lfoot -> Walk_Stop_F_Rfoot` at frame 86; after the fix, + Walk, Run and Sprint each retain one Stop source until movement resumes. +- The imported corpus count is 2,008: 1,879 UEFN Mannequin character clips and + 129 Echo/Manny/Quinn/MetaHuman rig-pose or correction assets. Reference + selection loads exactly the 1,879 character clips. The HUD now distinguishes + the reference character pool and active schema bank from the total imported + preview corpus instead of implying that all skeleton-support assets are + ordinary match candidates. +- Fresh evidence: `build/reference-stop-terminal-fix/result.json` passes all 12 + air families and its ground trace records the authored start/loop/single-stop, + pivot, strafe and slide sequences. `build/reference-stop-terminal-regression/result.json` + passes the complete 55-check real-engine suite, including 24 consecutive + traversals, contacts, moving/high platforms and montage exits. The resulting + interactive current-worktree engine remains running for manual review. + +## 2026-09-13 Foot Placement alpha and pose continuity + +- A new per-bone continuity trace separated the raw matcher/BlendStack pose + from the final postprocessed pose across turns, jump/landing and traversal. + It found a deterministic final-pose-only right-leg failure during ordinary + forward running: thigh 78.68 degrees, calf 151.46 degrees and foot 63.99 + degrees in one frame while the right Foot Placement alpha was exactly zero. +- The Foot Placement implementation calculated the authored interpolation + alpha but called the two-bone solve with a hard-coded weight of one. It now + passes the actual foot alpha, so an unaligned swing leg retains its authored + pose and partial alignment blends the entire leg solve. +- Repeating the identical 810-frame diagnostic produced zero local bone + rotation changes above 60 degrees. The earlier eight events included the + final-only running knee flip and a raw landing transition; both are now + below the diagnostic threshold after the existing transition smoothing. + +## 2026-09-13 Default Mover and Relaxed sequence parity + +- Direct UE 5.8 inspection of `DefaultLevel`, `GM_Sandbox` and the spawned pawn + confirms the project runs `SandboxCharacter_Mover` with + `SandboxCharacter_Mover_ABP`. That graph binds + `CHT_PoseSearchDatabases_Relaxed` and the Mover traversal table; the previous + example defaulted to the CMC Dense/Neutral path. +- The focused read-only exporter now imports all 34 Mover traversal montages. + Generated selection and warp-distance data use the Relaxed montage sources. + The global export still rejects the reference project's duplicate + `PSD_Dense_Stand_Walk_Stops` short name instead of silently choosing one. +- Runtime defaults now match the project settings: Relaxed selection and + strafe style enabled. Mover's one-frame empty bank handoff holds the outgoing + pose, contact curves and orientation source while publishing the authored + BlendStack duration; pose, curve and root-offset consumers no longer index + an invalid matcher result. +- `build/reference-mover-regression-final-7/result.json` passes all 55 real + engine scenarios. The final default-mode continuity trace reports zero local + bone rotation jumps above 60 degrees, and 28 focused import/compiler tests + pass. The exact air-family verifier also passes Stand, Walk, Run, Sprint, + Off_Walk, Off_Run and all light/heavy landing families. The verified + current-worktree engine remains open for manual review. + +## 2026-09-13 direct Mover input and reversal parity + +- A real PIE capture now injects the reference project's Enhanced Input keys + and records 330 frames of component-space bones and capsule velocity. It + proves bare W is Run at 3.75 m/s, Ctrl+W is Walk at 1.65 m/s, and Shift+W is + Sprint at 5.85 m/s. The example controls and HUD now use those exact roles. +- Ground velocity now retains a jerk-limited acceleration state. Against the + same idle/forward/reverse/stop trace, velocity RMSE is 0.12 m/s after + accounting for the reference PIE input-event latency. The reverse branch is + constrained to authored strafe Pivot samples, preventing a 180-degree Turn + from winning by raw cost and removing the observed 24.7 cm one-frame foot + displacement. +- Foot Placement now blends its whole-leg solve weight across plant-state + changes. Unplanted legs are no longer snapped into a fully weighted IK solve, + which previously produced deterministic 90-110 degree calf flips after + traversal and during direction changes. +- Fresh evidence: `build/reference-mover-regression-sequence-final/result.json` + passes all 55 real-engine scenarios; `build/reference-sequence-pose-continuity-6/runtime.log` + reports zero final or raw local-bone changes above 60 degrees; and + `build/reference-air-sequence-final` passes all 12 takeoff/fall/landing + families. The latest current-worktree engine remains running for review. + +## 2026-09-13 measured DefaultLevel hurdle parity + +- A PIE environment inventory identified the reproducible Y=-900 hurdle lane: + a 20 cm approach deck, a 50 cm-deep obstacle rising to 120 cm, and a pawn + running on the deck's side boundary. The EV course now preserves those + relative dimensions and the ledge interaction centre's 30 cm inward clamp. +- With W held and traversal requested at frame 142, both runtimes select + `M_Relaxed_Traversal_Hurdle_1_0_stand_F_V2_Rfoot` at entry time zero and + leave it after frame 202. Capsule-trajectory RMSE fell from 0.353 m to + 0.070 m; at frame 172 the reference/EV vertical displacements are + 1.002/1.003 m, and final forward displacements are 1.713/1.702 m. +- The imported hurdle montage already contains the reference limb pose. The + generic low-ledge foot clamp and approximate hand-contact solve displaced + the final hands by as much as 0.51 m. Hurdles now retain the authored pose; + left/right hand RMSE against PIE fell to 0.088/0.032 m and pelvis RMSE to + 0.066 m. Mantles and other supported traversal actions retain their contact + corrections. +- `scripts/compare_reference_runtime_sequences.py` records this evidence from + paired UE/EV captures. The air-family verifier remains green and the final + pose-continuity trace reports no local-bone jump above 60 degrees. The full + selection verifier is being updated for the reconstructed course extents. diff --git a/docs/usr/modules/animation.md b/docs/usr/modules/animation.md index cacf959c..09f34fe2 100644 --- a/docs/usr/modules/animation.md +++ b/docs/usr/modules/animation.md @@ -223,6 +223,26 @@ mm.update(dt); local pose = mm.getPose(); ``` +原地动画必须在 `bake()` 前用 `clip.applyPlanarRootMotion(rootBone, vx, vz)` +补入标定的移动速度;恒定的单关键帧 root 轨道也会补齐终点。数据库对循环的平面 +位移累计整周期行程,避免把回到起点误当作反向速度。`setDesiredVelocity` 输入 +世界 XZ 速度,`setDesiredYaw` 为绕 Y 轴的弧度,正向是 `(sin(yaw), cos(yaw))`。 + +匹配器以当前播放时刻为连续候选;新候选须改善至少 10% 的代价才切换,避免反复 +重启交叉淡入。`setIgnoreRadius` 保留当前时刻邻域内的连续播放,也覆盖循环接缝。 +`setPlayRateRange(minimum, maximum)` 返回 `{ok,message}`,配置 Motion Matching +播放速率的闭区间。可变特征布局会按 UE Pose Search 的规则,累加查询与选中姿势中 +所有未归一化轨迹速度通道的长度,以二者比值作为播放速率并夹到该区间;搜索节流仍按 +模拟时间推进。`getPlayRate()` 返回当前实际速率,默认区间为 `[1,1]`。参数必须满足 +有限的 `0 < minimum <= maximum <= 10`;失败时保留原区间和当前速率。 + +`setPoseReselectHistory(seconds)` 复制 UE PoseSearch 的短期姿势历史:正时间步会记录 +当前最接近的烘焙姿势,在指定时间内禁止把它重新选为跳转目标,但不会阻止当前姿势继续播放。 +`getPoseReselectHistory()` 返回当前期限。零会清空并关闭历史;脚本返回的 `{ok,message}` 必须检查。候选只需严格优于继续播放成本即可切换, +不会再额外施加非源自 UE 的百分比改善门槛。 +若物理由角色控制器负责,先复制 `getPose()`,再从渲染副本移除平面 root 位移; +不要修改匹配器持有的姿态。参考 `examples/climbing-motion-matching`。 + ## 基本用法(控制论程序动画) 二阶动力学把目标当成输入 `x`,输出 `y` 满足: @@ -431,7 +451,7 @@ Root-motion 位移会补偿 loop 末尾到开头的跳变;旋转返回单位 - `AnimLayerMixer`:由 `newLayerMixer()` 创建;`setBasePlayer()` / `getBasePlayer()` 设置基础动画,`addLayer(name, player, mask, mode)` 添加 `override` 或 `additive` 层,其中 Additive 以骨架 bind pose 为参考姿势。另有 `removeLayer()`、`setLayerWeight()`、`setLayerEnabled()`、`getLayerCount()`、`getLayerName()`、`update()`、`getPose()`。层事件通过 `getEventCount()`、`getEventLayer()`、`getEventName()`、`getEventPayload()`、`clearEvents()` 汇总。 - `AnimStateMachine`:`addState()`、`setEntry()`、`addTransition()`、`addFloatCondition()`、`addBoolCondition()`、`addTriggerCondition()`、`setExitTime()`、`setFloat()`、`setBool()`、`setTrigger()`、`getPose()`、`update()` - `MotionDatabase`:`addFeatureBone()`、`addFeatureBoneByName()`、`addClip()`、`bake()`、`getFrameCount()`、`getFeatureSize()` -- `MotionMatcher`:`setDesiredVelocity()`、`setDesiredYaw()`、`setSearchInterval()`、`setBlendTime()`、`search()`、`update()`、`getPose()`、`getMatchedClipIndex()` +- `MotionMatcher`:`setDesiredVelocity()`、`setDesiredYaw()`、`setSearchInterval()`、`setBlendTime()`、`setPlayRateRange()`、`getPlayRate()`、`search()`、`update()`、`getPose()`、`getMatchedClipIndex()` - `ControlAnim`:`setFrequency()`、`getFrequency()`、`setDamping()`、`getDamping()`、`setResponse()`、`getResponse()`、`setIntegrator()`、`getIntegrator()`、`set()`、`setTarget()`、`setTargetVelocity()`、`impulse()`、`has()`、`get()`、`getVelocity()`、`getTarget()`、`clear()`、`remove()`、`getPropertyCount()`、`getPropertyName()`、`update()` - `ControlPose`:`setFrequency()`、`getFrequency()`、`setDamping()`、`getDamping()`、`setResponse()`、`getResponse()`、`setIntegrator()`、`getIntegrator()`、`setBoneWeight()`、`getBoneWeight()`、`setTargetPose()`、`snapToTarget()`、`getPose()`、`getTargetPose()`、`update()` - `AnimTrail`:`setCapacity()`、`getCapacity()`、`setDuration()`、`getDuration()`、`setMinDistance()`、`getMinDistance()`、`setWidth()`、`getWidth()`、`setColor()`、`getColor*()`、`setFade()`、`getFade()`、`setStyle()`、`getStyle()`、`setDrawScale()`、`getDrawScale*()`、`setDrawOffset()`、`getDrawOffset*()`、`addPoint()`、`addPoint3()`、`sampleBone()`、`sampleBoneOffset()`、`clear()`、`update()`、`getPointCount()`、`getPoint*()`、`getPointAge()`、`getPointAlpha()`、`draw()` @@ -446,6 +466,7 @@ Root-motion 位移会补偿 loop 末尾到开头的跳变;旋转返回单位 - 3D clip 轨道使用二分查找采样,长动画不会随单轨关键帧数线性退化;Motion Database 应离线/加载时 bake,不要逐帧重建。 - 远处角色可用 `AnimPlayer.setUpdateRate(hz)` 降低姿态求值频率(例如 15 Hz);播放器会累积时间,达到间隔后一次推进,设为 `0` 恢复逐帧求值。姿态、Graph 中间结果以及 CPU 蒙皮矩阵均复用内部缓存,稳定播放不产生逐帧容器分配。 - Graph、状态机或 Motion Matching 求值后,可对返回的 `AnimPose` 调用 `aimBone`(骨骼本地 +Z 朝向目标)或 `solveTwoBoneIK` 做世界空间后处理;两者都接受 `0..1` 权重并会更新 world pose。 +- `solveTwoBoneIK` 使用解析双骨求解,保持当前弯曲方向,并将不可达目标限制到肢体长度范围;不需要反复调用来逼近目标。精度测试覆盖刚性链和带旋转、均匀缩放的父节点,不保证非均匀缩放链具有相同的目标精度。 - GPU 蒙皮 shader 可调用 `AnimSkin.updateMatrixPalette(pose)` 后按骨骼读取 `getMatrixPaletteElement(bone, 0..15)` 上传调色板;顶点关节/权重由 `getVertexBone` / `getVertexWeight` 提供。CPU 路径复用同一调色板缓存。 - 内建 GPU 蒙皮路径只需在 Mesh 创建后调用一次 `skin.bindGpuMesh(gfx, mesh)`,之后每帧在 `pose.computeWorld(skeleton)` 后调用 `skin.updateGpuMesh(mesh, pose)`;顶点保持 Bind Pose,Vulkan/WebGPU 顶点着色器读取四关节权重和最多 128 个骨骼矩阵完成变形,前向、阴影与 GBuffer 路径共享同一调色板。 - 离线导入后可调用 `clip.compress(positionError, rotationErrorDegrees, scaleError)`,以逐轨道曲线误差为上限删除冗余关键帧;首尾关键帧、clip 属性和 gameplay notify 均保留。 @@ -459,3 +480,233 @@ Root-motion 位移会补偿 loop 末尾到开头的跳变;旋转返回单位 **源码:** [`src/modules/animation/`](../../../src/modules/animation/) **相关测试:** 在 [`test/`](../../../test/) 中搜索 `animation`。 + + +### Compact runtime animation tracks + +`eve.AnimClip()` constructs a script-owned clip. Keep its instance alive while a +player, graph or motion database borrows it. `clip.loadBinary(path, skeleton)` +returns `{ok, message}`; check `ok` before using the new data. The synchronous +owner-thread decoder borrows the skeleton during loading, copies all tracks and +releases file bytes after decoding. Failure preserves the existing clip. Native +consumers use `loadAnimationTracks(clip, bytes, skeleton) -> Result`. + +For multiple clips, `eve.loadAnimationTrackBatch(clips, paths, skeleton, workers)` +returns `{ok, message, workers}`. The arrays must have the same length (at most 64) +and distinct script-owned clip instances. Zero workers chooses up to eight hardware +threads; one forces serial decoding, and values outside 0..8 are rejected. The +binding reads files on the owner thread, then native workers decode independent +candidates. All destinations remain unchanged if any input fails. Retain the +instances while their players/databases borrow them. The batch is synchronous: +no worker accesses the VM or filesystem, and every worker joins before return or +exception. The native canonical API is `loadAnimationTrackBatch(inputs, skeleton, +workers) -> Result`; inputs borrow destinations and immutable encoded bytes +for this call only. Total input is bounded to 256 MiB; the version-1 codec and its +validation rules are unchanged. + +`MotionDatabase.bake()` computes clips in parallel for banks of at least 16 clips, +using at most eight native threads. Its borrowed clips and skeleton must remain +immutable throughout the synchronous call. Frame IDs, sample times, and floating +point normalization order remain identical to serial baking. Worker exceptions +are joined and propagated; an interrupted bake is not exposed as searchable. + +The `eve.animation-tracks/1` wire format is little-endian: `EVAC`, uint32 version +(1), uint32 track count, Float32 duration/sample rate, uint32 loop flag (0 or 1), +then a UTF-8 clip name. Strings have a uint32 byte length. Each track has a bone +name followed by three channels (position, rotation, scale). Each channel starts +with uint32 key count, then Float32 time and 3/4/3 Float32 components per key. +Times must be strictly increasing and within the duration; all values must be +finite and quaternions nonzero. Bone names must map uniquely to the supplied +skeleton. Unknown versions, flags, trailing bytes, malformed UTF-8 and truncation +are rejected. Files are limited to 128 MiB and channels to one million keys. +There is no implicit version migration; regenerate from the source library. + +The offline corpus builder removes only exactly constant channels' redundant +keys, preserves varying Float32 samples and represents STEP boundaries with +adjacent Float32 times. It does not encode UE montages, event graphs or curves; +those remain in the import library's native archives and metadata. Binary import +uses temporary candidate ownership and publishes through `AnimClip::adopt` only +on success, without retaining input pointers or creating a mesh resource. + +### Authored candidate selection + +`MotionMatcher::setCandidateRanges` accepts clip indices, start/end seconds, +cost biases, disable-reselection flags, transition blocks and authored cost overrides. +Squirrel ranges contain exactly `[clip,start,end,bias,disableReselection,blocks, +continuingBias,costOverrides,continuingCostOverrides]`, +where `blocks` contains `[start,end]` pairs (an empty array permits all entries). +Transition blocks use inclusive start/exclusive end without endpoint tolerance; +they are unioned across the clip's ranges and prevent new matches while allowing +eligible continuation. A protected terminal sample needs an end beyond clip duration. +Each cost override is `[start,end,bias]`: it replaces the candidate or continuing +default inside that interval, and the last active override wins. Candidate cost +includes an authored looping bias before entering this API; continuing cost does +not. Duplicate ranges retain the continuing cost paired with the lowest candidate +cost at that frame. Nonfinite, negative, empty/reversed intervals or more than +100000 total metadata intervals reject +the entire update. Candidate count includes protected continuation frames. +It atomically replaces the searchable +frame set and preserves database frame identities. Empty/invalid selections are +rejected without changing the previous set. The borrowed database must remain +immutable while its matcher is used. Candidate restrictions apply to continuation +as well as new searches, allowing a Chooser to switch state without retaining an +ineligible animation. + +`setQueryPose` copies a pose with matching bone count; `setTrajectory` copies three +finite world displacements and yaw angles at 0.33, 0.66 and 1.0 seconds. All three +native APIs return checked Results, run synchronously on the owner thread outside +search/advance, retain no input references and invoke no callbacks. Squirrel +candidate/trajectory setters return `{ok,message}` (candidate selection also +returns `count`); the pose setter throws a script diagnostic on rejection. + + +### Locomotion feature layout + +Before baking, `MotionDatabase::setLocomotionFeatures(leftFoot, rightFoot, pelvis)` +selects a 30-dimensional layout. Its three distinct bone indices replace the basic +feature-bone list. The pelvis heading axis is local +Z in Y-up imported assets. +Invalid configuration leaves the previous layout intact; changes after baking are +rejected. Keep the caller-owned skeleton, clips and database alive and immutable +through matching. Existing databases retain the basic layout. + +Trajectory channels: current planar velocity; position at -0.05 seconds; current +heading; position/heading at +0.35; position/velocity/heading at +0.7; and 3D velocity +direction at +1 second. Pose channels: left-foot position relative to right foot, +both feet's velocities relative to their own time-sampled root, and planar pelvis +heading. Weights follow the reference PSS_Default: past position 0.3, feet velocities +0.3 each, pelvis heading 0.1, final velocity direction 1.5, other channels 1.0. +Group multipliers are normalized together before candidate biases are applied. +Velocity direction clamps at 0.01 m/s, matching the source's 1 cm/s threshold. +Vector channels use mean Euclidean deviation from their centroid; feet velocities +share one normalization group. For imported UE data, +`setFeatureNormalizationRanges([[clip,start,end], ...])` selects the inclusive +layout-rate samples used for statistics before `bake()`. Ordered duplicate and +overlapping ranges contribute repeatedly, which preserves database-entry +multiplicity in a UE `NormalizationSet`. The call returns +`{ok,message,count}` and copies its input atomically. + +`MotionMatcher::setLocomotionQuery(current, previous, elapsedSeconds, samples)` copies +both local poses and exactly five trajectory samples at -0.05, 0, 0.35, 0.7 and +1 seconds. Each sample contains world displacement XYZ, world velocity XYZ in m/s, +and yaw in radians. The positive simulation interval must be at most one second. +Nonfinite values, mismatched poses, wrong counts or use with a basic database fail +atomically. This owner-thread call runs outside search/advance, retains no input +references and invokes no callbacks. A cold start may pass the same pose twice; +action handoffs should provide actual adjacent poses. Eligible continuing poses +supply pose-channel query values during playback; trajectory always uses character +prediction. A locomotion database requires this query API before search; the basic +pose/trajectory setters reject it. Both new Squirrel setters return `{ok,message}` +and must be checked. Native setters return `[[nodiscard]] Result`. + +Baking accumulates translation and yaw across looping root trajectories and +extrapolates endpoint root velocity outside nonlooping clips. Pose samples clamp +at nonlooping endpoints. These runtime objects are not serialized; rebuilding a +database also rebuilds statistics. Determinism is within floating-point tolerance +for identical assets and ordered simulation inputs; no wall clock or RNG is used. + + +Nonlooping matcher playheads clamp at clip duration. Reaching the end forces a +search even before the normal search interval expires. Disable-reselection also +excludes the exhausted current asset from fresh candidates; it cannot pin playback +to its final frame. When no other candidate exists, a one-shot-only database holds +its terminal pose until the candidate set changes. This applies to both layouts. + + +### Finite-duration pose inertia + +`eve.AnimInertializer()` creates an independent object owned by Squirrel GC. It owns +its samples and retains no clip, skeleton or pose pointers. It complements +`ControlPose`: inertia ends exactly at its deadline, while ControlPose continuously +tracks a changing target. + +`begin(source, previousSource, target, previousTarget, historySeconds, durationSeconds, boneTimeFactors)` +returns `{ok, message}`. All four poses must have the same bone order and local +coordinate space. Supply actual outgoing output history when interrupting a blend, +and incoming samples at the same two instants. History must be finite in (0, 1] +seconds; duration must be finite in [0, 10] seconds. Factors are empty for uniform +duration or one [0, 1] value per bone: zero snaps, 0.75 finishes in 75% of the time. +Inputs are copied atomically; invalid input leaves the prior transition intact. + +`evaluate(target, elapsedSeconds)` returns `{ok, message}` and samples the transition +at absolute nonnegative simulation time since begin. Target is the currently +advancing incoming pose, not the initial pose. Call `copyPose(destination)` to copy +the evaluated output. At or after each bone's deadline it equals the incoming pose. +Translation/scale and quaternion log-space rotation offsets preserve sampled source +velocity and decay to zero. Repeated evaluation at the same time/target is deterministic +within floating-point tolerance. Calls are owner-thread only and have no callbacks. +Native users access `AnimInertializer::pose()` as a borrowed const reference whose +contents change after successful begin/evaluate and whose lifetime ends with the owner. + +The climbing example queues this transition for action, air and animation-bank +handoffs, using fixed-step pose history. Root travel is stripped before blending and +its duration factor is zero, preserving capsule collision authority. Authored montage +exit tails retain their separate advancing playback and source blend profiles. + +`AnimPlayer.getClip()` borrows the selected clip (null before selection); retain the owning clip while sampling it. `MotionDatabase.hasLocomotionFeatures()` reports whether the database uses the configured locomotion feature layout. + +### Variable motion feature layouts + +`MotionDatabase.setFeatureLayout(sampleRate, channels, normalizationLengthScale)` copies an ordered vector +layout before baking and returns `{ok,message}`. Each channel is +`[kind,source,query,bone,origin,axesMask,headingAxis,sampleTime,weight,characterSpaceVelocity,normalizeVelocity,normalizationGroup]`. +Kinds are `position`, `velocity`, `heading`, `curve`; sources are `pose`, `trajectory`; +query policies are `USE_CHARACTER_POSE`, `USE_CONTINUING_POSE`. The X/Y/Z mask +uses bits 1/2/4. Heading axes are 0/1/2. Values use metres, seconds and radians. +Pose sample times currently require zero; trajectory offsets cover -10..10 seconds. +Trajectory channels require character queries and world-space velocity. +Equal nonempty normalization groups pool channels of equal kind and cardinality; +normalized and physical velocities may share an explicitly authored pool. +`normalizationLengthScale` scales metre-based positions and physical velocities +before pooling; normalized velocities, headings and curves remain dimensionless. +Use 100 for source configurations authored in centimetres, or 1 for metres +(the C++ layout default). Empty groups remain independent. Zero-weight channels +retain their dimensions. Query inputs remain metres/seconds regardless of this scale. + +`MotionMatcher.setFeatureQuery(current, previous, elapsedSeconds, samples, curves)` copies +and normalizes the query atomically, returning `{ok,message}`. Each trajectory +sample is `[seconds,x,y,z,vx,vy,vz,yaw]`, where position is world-axis displacement +from the current root. Provide every distinct configured trajectory time exactly +once. Call `setDesiredYaw` first: the query captures that orientation immediately. +Invalid input, including normalization overflow, preserves the previous query. +Basic and fixed-locomotion query setters cannot configure this layout. Pass an empty +curve array for vector-only schemas; otherwise each curve sample is +`[name,seconds,value]` and must cover every configured curve/time pair. + +Scalar channels use kind `curve`, source `pose`, axesMask=1 and a thirteenth +channel field containing the curve name. Scalar sample offsets may range from +-10 to +10 seconds. After adding all clips and configuring the layout, call +`MotionDatabase.loadFeatureCurves(path, sources)` before baking. It loads EVFC/1 +data and copies source names in exact clip order, returning `{ok,message}` with +atomic failure. C++ uses `setFeatureCurves(bytes, sources)` with borrowed spans +and an owning decoded snapshot. Missing source records reject; missing channels +in a known source evaluate to zero. Scalar values retain their sign and use the +same normalization and continuing-query rules as other feature channels. + +These APIs are owner-thread operations without callbacks or reentrancy. The +matcher borrows its skeleton and database; retain both unchanged until it is +destroyed. Layout/query inputs are copied and need not outlive their setter. +`hasFeatureLayout()` reports this mode. A baked variable-layout database rejects +layout, root and clip-list changes. Native equivalents use the owning typed +values in `MotionFeatureLayout.h` and checked `Result` returns. The climbing +example records evaluated Phase history alongside contact curves and routes +ordinary searches to the imported schema indexes. + +### Scalar animation curve libraries + +`eve.AnimCurveLibrary()` owns scalar curve tracks independently of skeletons and +players. `loadBinary(path)` returns `{ok,message}` and atomically replaces the +library from `eve.animation-curves/1` (EVFC/1); failures preserve previous data. +`contains(source)` and `getSourceCount()` inspect its source records. +`sample(source,channel,seconds,loop)` returns `{ok,message,present,value}`. An +absent channel succeeds with `present=false`; an unknown source or nonfinite +time is an error. Sampling linearly interpolates, clamps non-looping playback, +and wraps positive or negative time for looping playback. + +Native `AnimCurveLibrary::load(span)` copies all decoded data and +retains no input pointers. `sample` returns `Result>`; all +operations are owner-thread only, without callbacks. Repeated samples use +explicit simulation time and are deterministic within Float32 rounding. +Unknown versions, trailing data, invalid names, nonfinite/unsorted keys and +resource limits are rejected. Regenerate older contact script tables using +`scripts/build_unreal_contact_curves.py --library ...`; the binary layout and +limits are documented in `tools/unreal-uasset-converter/README.md`. diff --git a/docs/usr/modules/climbing.md b/docs/usr/modules/climbing.md index 68057466..3e25034e 100644 --- a/docs/usr/modules/climbing.md +++ b/docs/usr/modules/climbing.md @@ -213,3 +213,24 @@ Graphics adapter 可以选择颜色与绘制方式;`climbing` 本身不反向 execution 创建之前返回错误。 - action 的 `eventMetadata` 随 started/contact/land/terminal 事件一同进入 owning 队列和 snapshot;事件消费者不必再反查可能已热重载的 definition。 + + +## 已变形的根运动输入 + +`ClimbingMotionInput::rootMotionPolicy` 默认 `ApplyActionWarp`,维持原有轨迹与 +warp 行为。动画适配层已经按接触窗口完成变形时,可选择 `PreserveSuppliedDelta`, +并设置 `hasRootMotion`:runtime 消费本帧提供的位移,不再叠加程序轨迹或要求它 +收敛到程序轨迹终点;实际运动仍受胶囊碰撞及残差预算约束。 + +`obstacleCollision` 默认 `Collide`。`IgnoreTraversedShape` 只对当前 advance 查询 +排除 execution 保存且经代际验证的障碍 shape,保留角色自身过滤和所有其他 shape +的碰撞。它不修改世界过滤器或持久碰撞状态。无根运动、无效枚举或过期 shape +在推进 tick 前失败。上述值由调用方拥有,在世界的仿真线程同步消费,不保存指针、 +不调用回调,不新增持久格式;时间来自注入的 tick/dt。 + +Squirrel 对应入口为 +`runtime.advanceWarped(world, tick, dt, dx, dy, dz, facingX, facingZ, ignoreObstacle)`, +返回与 `advance` 相同的结构化结果。游戏必须使用返回的实际位置,并在动作结束后 +恢复普通移动与地面检测。此入口只负责运动消费,动画窗口与骨骼约束属于适配层。 + +`runtime.probeMode(world, feetX, feetY, feetZ, facingX, facingZ, speed, verticalSpeed, grounded)` evaluates a traversal candidate for the supplied movement state without starting execution; inspect `ok` and diagnostics before consuming `value`. diff --git a/src/modules/animation/AnimClip.cpp b/src/modules/animation/AnimClip.cpp index 679a6a5b..35103e5b 100644 --- a/src/modules/animation/AnimClip.cpp +++ b/src/modules/animation/AnimClip.cpp @@ -46,7 +46,8 @@ void AnimClip::addPositionKey(int boneIndex, float time, float x, float y, float ensureBone(boneIndex); auto& keys = tracks_[static_cast(boneIndex)].positions; keys.push_back({time, x, y, z}); - std::sort(keys.begin(), keys.end(), [](const Vec3Key& a, const Vec3Key& b) { return a.t < b.t; }); + if (keys.size() > 1 && keys[keys.size() - 2].t >= time) + std::sort(keys.begin(), keys.end(), [](const Vec3Key& a, const Vec3Key& b) { return a.t < b.t; }); if (time > duration_) duration_ = time; } @@ -66,7 +67,8 @@ void AnimClip::addRotationKey(int boneIndex, float time, float x, float y, float k.w = tmp.qw; auto& keys = tracks_[static_cast(boneIndex)].rotations; keys.push_back(k); - std::sort(keys.begin(), keys.end(), [](const QuatKey& a, const QuatKey& b) { return a.t < b.t; }); + if (keys.size() > 1 && keys[keys.size() - 2].t >= time) + std::sort(keys.begin(), keys.end(), [](const QuatKey& a, const QuatKey& b) { return a.t < b.t; }); if (time > duration_) duration_ = time; } @@ -75,7 +77,8 @@ void AnimClip::addScaleKey(int boneIndex, float time, float x, float y, float z) ensureBone(boneIndex); auto& keys = tracks_[static_cast(boneIndex)].scales; keys.push_back({time, x, y, z}); - std::sort(keys.begin(), keys.end(), [](const Vec3Key& a, const Vec3Key& b) { return a.t < b.t; }); + if (keys.size() > 1 && keys[keys.size() - 2].t >= time) + std::sort(keys.begin(), keys.end(), [](const Vec3Key& a, const Vec3Key& b) { return a.t < b.t; }); if (time > duration_) duration_ = time; } @@ -445,6 +448,19 @@ void AnimClip::applyPlanarRootMotion(int boneIndex, float speedX, float speedZ) keys.push_back({duration_, speedX * duration_, 0.f, speedZ * duration_}); return; } + // Importers commonly reduce a constant in-place root track to one key at + // t=0. Preserve its endpoint values before adding travel, otherwise every + // added displacement is zero and the clip remains stationary. + if (keys.front().t > 0.f) { + auto start = keys.front(); + start.t = 0.f; + keys.insert(keys.begin(), start); + } + if (keys.back().t < duration_) { + auto end = keys.back(); + end.t = duration_; + keys.push_back(end); + } for (auto& k : keys) { k.x += speedX * k.t; k.z += speedZ * k.t; @@ -477,8 +493,8 @@ void AnimClip::sampleVec3(const std::vector& keys, float time, float& x z = keys.back().z; return; } - const auto upper = - std::upper_bound(keys.begin(), keys.end(), time, [](float value, const Vec3Key& key) { return value < key.t; }); + const auto upper = std::upper_bound(keys.data(), keys.data() + keys.size(), time, + [](float value, const Vec3Key& key) { return value < key.t; }); const auto& b = *upper; const auto& a = *(upper - 1); const float den = b.t - a.t; @@ -507,8 +523,8 @@ void AnimClip::sampleQuat(const std::vector& keys, float time, float& x w = keys.back().w; return; } - const auto upper = - std::upper_bound(keys.begin(), keys.end(), time, [](float value, const QuatKey& key) { return value < key.t; }); + const auto upper = std::upper_bound(keys.data(), keys.data() + keys.size(), time, + [](float value, const QuatKey& key) { return value < key.t; }); const auto& b = *upper; const auto& a = *(upper - 1); const float den = b.t - a.t; diff --git a/src/modules/animation/AnimClip.h b/src/modules/animation/AnimClip.h index 993b7a21..02858be9 100644 --- a/src/modules/animation/AnimClip.h +++ b/src/modules/animation/AnimClip.h @@ -236,6 +236,8 @@ public: private: friend class AnimPlayer; + friend class MotionDatabase; + friend class AnimClipBinaryAccess; struct Vec3Key { float t = 0.f, x = 0.f, y = 0.f, z = 0.f; }; diff --git a/src/modules/animation/AnimClipBinary.cpp b/src/modules/animation/AnimClipBinary.cpp new file mode 100644 index 00000000..037727a2 --- /dev/null +++ b/src/modules/animation/AnimClipBinary.cpp @@ -0,0 +1,164 @@ +#include "animation/AnimClipBinary.h" +#include +#include +#include +#include +#include +#include +#include +#include "animation/AnimClip.h" +#include "animation/AnimParallelInternal.h" +#include "animation/AnimSkeleton.h" +#include "common/Utf8Validation.h" + +namespace eve::animation { +class AnimClipBinaryAccess { +public: + static void resize(AnimClip& clip, int bone, int kind, std::size_t count) { + clip.ensureBone(bone); + auto& track = clip.tracks_[bone]; + if (kind == 0) + track.positions.resize(count); + else if (kind == 1) + track.rotations.resize(count); + else + track.scales.resize(count); + } + static void write(AnimClip& clip, int bone, int kind, std::size_t key, float time, float x, float y, float z, + float w) { + auto& track = clip.tracks_[bone]; + if (kind == 0) + track.positions[key] = {time, x, y, z}; + else if (kind == 2) + track.scales[key] = {time, x, y, z}; + else { + TransformTRS rotation; + rotation.qx = x; + rotation.qy = y; + rotation.qz = z; + rotation.qw = w; + rotation.normalizeRotation(); + track.rotations[key] = {time, rotation.qx, rotation.qy, rotation.qz, rotation.qw}; + } + } +}; +namespace { +class Reader { +public: + explicit Reader(std::span input) : data(input) {} + std::uint32_t integer() { + require(4); + std::uint32_t value = 0; + if constexpr (std::endian::native == std::endian::little) { + std::memcpy(&value, data.data() + offset, 4); + offset += 4; + } else { + for (int i = 0; i < 4; ++i) value |= std::to_integer(data[offset++]) << (i * 8); + } + return value; + } + float number() { + const float value = std::bit_cast(integer()); + if (!std::isfinite(value)) throw std::runtime_error("nonfinite track value"); + return value; + } + std::string string() { + const auto length = integer(); + if (length == 0 || length > 4096) throw std::runtime_error("invalid track name length"); + require(length); + std::string value(reinterpret_cast(data.data() + offset), length); + offset += length; + if (!eve::isValidUtf8(value, eve::Utf8NullPolicy::Reject)) throw std::runtime_error("invalid UTF-8 name"); + return value; + } + void require(std::size_t count) const { + if (count > data.size() - offset) throw std::runtime_error("truncated animation tracks"); + } + bool finished() const { return offset == data.size(); } + +private: + std::span data; + std::size_t offset = 0; +}; +} // namespace + +eve::Result loadAnimationTrackBatch(std::span inputs, const AnimSkeleton& skeleton, + int workerCount) { + try { + if (workerCount < 0 || workerCount > 8 || inputs.size() > 64) + throw std::runtime_error("invalid animation batch size or worker count"); + std::size_t total = 0; + for (std::size_t i = 0; i < inputs.size(); ++i) { + if (inputs[i].bytes.size() > 256 * 1024 * 1024 - total) + throw std::runtime_error("animation batch exceeds 256 MiB limit"); + total += inputs[i].bytes.size(); + for (std::size_t j = 0; j < i; ++j) + if (&inputs[i].destination.get() == &inputs[j].destination.get()) + throw std::runtime_error("duplicate animation batch destination"); + } + std::vector candidates(inputs.size()); + const int workers = detail::parallelAnimationItems(inputs.size(), workerCount, [&](std::size_t i) { + auto loaded = loadAnimationTracks(candidates[i], inputs[i].bytes, skeleton); + if (!loaded.ok()) throw std::runtime_error(loaded.status().describe()); + }); + for (std::size_t i = 0; i < inputs.size(); ++i) inputs[i].destination.get().adopt(candidates[i]); + return eve::Result::success(workers, eve::Status::success(eve::StatusCode::Applied)); + } catch (const std::exception& error) { + return eve::Result::failure(eve::Diagnostic::error(eve::DiagnosticCode::InvalidArgument, error.what(), + "animation-track-batch", {}, "animation")); + } +} + +eve::Result loadAnimationTracks(AnimClip& destination, std::span bytes, + const AnimSkeleton& skeleton) { + try { + if (bytes.size() > 128 * 1024 * 1024) throw std::runtime_error("animation track file exceeds 128 MiB limit"); + Reader reader(bytes); + if (reader.integer() != 0x43415645 || reader.integer() != 1) + throw std::runtime_error("unsupported animation track schema"); + const auto count = reader.integer(); + if (count > static_cast(skeleton.getBoneCount())) throw std::runtime_error("too many bone tracks"); + const float duration = reader.number(), rate = reader.number(); + const auto flags = reader.integer(); + if (duration < 0 || rate <= 0 || flags > 1) throw std::runtime_error("invalid animation settings"); + AnimClip candidate(reader.string()); + candidate.setDuration(duration); + candidate.setSampleRate(rate); + candidate.setLoop(flags != 0); + std::vector seen(skeleton.getBoneCount(), false); + for (unsigned track = 0; track < count; ++track) { + const int bone = skeleton.findBone(reader.string()); + if (bone < 0 || seen[bone]) throw std::runtime_error("unknown or duplicate bone track"); + seen[bone] = true; + for (int kind = 0; kind < 3; ++kind) { + const auto keys = reader.integer(); + const int components = kind == 1 ? 4 : 3; + if (keys > 1000000) throw std::runtime_error("too many animation keys"); + reader.require(static_cast(keys) * (components + 1) * 4); + AnimClipBinaryAccess::resize(candidate, bone, kind, keys); + float previous = -1.f; + for (unsigned key = 0; key < keys; ++key) { + const float time = reader.number(); + if (time < 0 || time > duration || time <= previous) + throw std::runtime_error("invalid key time order"); + previous = time; + const float x = reader.number(), y = reader.number(), z = reader.number(); + float w = 1.f; + if (kind == 1) { + w = reader.number(); + const float norm = x * x + y * y + z * z + w * w; + if (!std::isfinite(norm) || norm < 1e-12f) throw std::runtime_error("invalid key quaternion"); + } + AnimClipBinaryAccess::write(candidate, bone, kind, key, time, x, y, z, w); + } + } + } + if (!reader.finished()) throw std::runtime_error("unknown trailing animation data"); + destination.adopt(candidate); + return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); + } catch (const std::exception& error) { + return eve::Result::failure(eve::Diagnostic::error(eve::DiagnosticCode::InvalidArgument, error.what(), + "animation-tracks", {}, "animation")); + } +} +} // namespace eve::animation diff --git a/src/modules/animation/AnimClipBinary.h b/src/modules/animation/AnimClipBinary.h new file mode 100644 index 00000000..2df0073b --- /dev/null +++ b/src/modules/animation/AnimClipBinary.h @@ -0,0 +1,38 @@ +#pragma once +#include +#include +#include +#include "common/Result.h" + +namespace eve::animation { +class AnimClip; +class AnimSkeleton; +/** @brief Atomically replace a clip from the little-endian eve.animation-tracks/1 format (EVAC, version 1). + * @param destination Borrowed existing clip; unchanged on rejection, owns decoded tracks on success. + * @param bytes Borrowed complete encoded bytes, valid for this synchronous call only; never retained. + * @param skeleton Borrowed bone-name mapping, valid for this call; never retained. + * @return Applied or a diagnostic for invalid version, bounds, names, times, or values. + * @thread Owner animation thread, outside sampling; no callbacks or reentrancy. + * @details Unknown versions/flags/trailing bytes are rejected. No implicit migration. Float32 tracks + * retain source sampling precision; the offline encoder only removes exactly constant keys. + */ +[[nodiscard]] eve::Result loadAnimationTracks(AnimClip& destination, std::span bytes, + const AnimSkeleton& skeleton); + +/** @brief One synchronous borrowed batch input; destination and bytes must outlive the call. */ +struct AnimationTrackInput { + std::reference_wrapper destination; + std::span bytes; +}; +/** @brief Decode independent clips in parallel, then publish all or none in input order. + * @param inputs Borrowed unique destinations and immutable bytes, at most 64 clips and 256 MiB total. + * @param skeleton Borrowed immutable skeleton, used only until all workers join. + * @param workerCount Zero selects up to eight hardware threads; 1 forces serial; valid range 0..8. + * @return Applied with actual worker count, or diagnostic leaving every destination unchanged. + * @thread Synchronous owner-thread entry, outside sampling/mutation. Workers touch only private + * candidates and immutable inputs. No VM, filesystem, callbacks, retained jobs, or reentrancy. + * @details Uses the same version-1 codec and exact sampling as loadAnimationTracks. + */ +[[nodiscard]] eve::Result loadAnimationTrackBatch(std::span inputs, + const AnimSkeleton& skeleton, int workerCount = 0); +} // namespace eve::animation diff --git a/src/modules/animation/AnimCurveLibrary.cpp b/src/modules/animation/AnimCurveLibrary.cpp new file mode 100644 index 00000000..38c16531 --- /dev/null +++ b/src/modules/animation/AnimCurveLibrary.cpp @@ -0,0 +1,135 @@ +#include "animation/AnimCurveLibrary.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include "common/Utf8Validation.h" + +namespace eve::animation { +namespace { +struct Key { + float time, value; +}; +struct Clip { + float duration; + std::map, std::less<>> channels; +}; +class Reader { +public: + explicit Reader(std::span data) : bytes(data) {} + std::uint32_t integer() { + require(4); + std::uint32_t result = 0; + for (unsigned i = 0; i < 4; ++i) result |= std::to_integer(bytes[offset++]) << (8 * i); + return result; + } + float number() { + float value = std::bit_cast(integer()); + if (!std::isfinite(value)) throw std::runtime_error("nonfinite animation curve value"); + return value; + } + std::string string() { + auto count = integer(); + if (count == 0 || count > 4096) throw std::runtime_error("invalid animation curve name length"); + require(count); + std::string value(reinterpret_cast(bytes.data() + offset), count); + offset += count; + if (!eve::isValidUtf8(value, eve::Utf8NullPolicy::Reject)) + throw std::runtime_error("invalid animation curve UTF-8"); + return value; + } + void require(std::size_t count) const { + if (count > bytes.size() - offset) throw std::runtime_error("truncated animation curves"); + } + bool finished() const { return offset == bytes.size(); } + +private: + std::span bytes; + std::size_t offset = 0; +}; +eve::Diagnostic invalid(std::string message) { + return eve::Diagnostic::error(eve::DiagnosticCode::InvalidArgument, std::move(message), "animationCurves", {}, + "animation"); +} +} // namespace +struct AnimCurveLibrary::Impl { + std::map> clips; +}; +AnimCurveLibrary::AnimCurveLibrary() : impl_(std::make_unique()) {} +AnimCurveLibrary::~AnimCurveLibrary() = default; +eve::Result AnimCurveLibrary::load(std::span bytes) { + try { + if (bytes.size() > 64 * 1024 * 1024) throw std::runtime_error("animation curves exceed 64 MiB"); + Reader reader(bytes); + if (reader.integer() != 0x43465645 || reader.integer() != 1) + throw std::runtime_error("unsupported animation curves format"); + auto count = reader.integer(); + if (count > 100000) throw std::runtime_error("too many animation curve sources"); + Impl next; + for (std::uint32_t i = 0; i < count; ++i) { + auto source = reader.string(); + Clip clip; + clip.duration = reader.number(); + auto channelCount = reader.integer(); + if (channelCount > 64 || clip.duration < 0 || (channelCount && clip.duration <= 0)) + throw std::runtime_error("invalid animation curve duration or channel count"); + for (std::uint32_t j = 0; j < channelCount; ++j) { + auto name = reader.string(); + auto keyCount = reader.integer(); + if (keyCount < 2 || keyCount > 1000000) throw std::runtime_error("invalid animation curve key count"); + reader.require(std::size_t(keyCount) * 8); + std::vector keys; + keys.reserve(keyCount); + for (std::uint32_t k = 0; k < keyCount; ++k) { + Key key{reader.number(), reader.number()}; + if (key.time < 0 || key.time > clip.duration || (!keys.empty() && key.time <= keys.back().time)) + throw std::runtime_error("unordered animation curve time"); + keys.push_back(key); + } + if (keys.front().time != 0 || keys.back().time != clip.duration) + throw std::runtime_error("animation curve endpoints do not span duration"); + if (!clip.channels.emplace(name, std::move(keys)).second) + throw std::runtime_error("duplicate animation curve channel"); + } + if (!next.clips.emplace(source, std::move(clip)).second) + throw std::runtime_error("duplicate animation curve source"); + } + if (!reader.finished()) throw std::runtime_error("trailing animation curve bytes"); + impl_->clips.swap(next.clips); + return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); + } catch (const std::exception& error) { + return eve::Result::failure(invalid(error.what())); + } +} +bool AnimCurveLibrary::contains(std::string_view source) const { return impl_->clips.contains(source); } +std::size_t AnimCurveLibrary::size() const noexcept { return impl_->clips.size(); } +eve::Result> AnimCurveLibrary::sample(std::string_view source, std::string_view channel, + double seconds, bool loop) const { + using Result = eve::Result>; + if (!std::isfinite(seconds)) return Result::failure(invalid("nonfinite animation curve time")); + auto found = impl_->clips.find(source); + if (found == impl_->clips.end()) + return Result::failure(eve::Diagnostic::error(eve::DiagnosticCode::NotFound, "animation curve source not found", + "source", {}, "animation")); + const auto& clip = found->second; + auto curve = clip.channels.find(channel); + if (curve == clip.channels.end()) return Result::success(std::optional{}); + if (loop) { + seconds = std::fmod(seconds, double(clip.duration)); + if (seconds < 0) seconds += clip.duration; + } + const auto& keys = curve->second; + if (seconds <= 0) return Result::success(keys.front().value); + if (seconds >= clip.duration) return Result::success(keys.back().value); + auto end = std::upper_bound(keys.begin(), keys.end(), seconds, + [](double time, const Key& key) { return time < key.time; }); + const auto& a = *(end - 1); + const auto& b = *end; + double alpha = (seconds - a.time) / (double(b.time) - a.time); + return Result::success(float(double(a.value) * (1 - alpha) + double(b.value) * alpha)); +} +} // namespace eve::animation diff --git a/src/modules/animation/AnimCurveLibrary.h b/src/modules/animation/AnimCurveLibrary.h new file mode 100644 index 00000000..fd6103a2 --- /dev/null +++ b/src/modules/animation/AnimCurveLibrary.h @@ -0,0 +1,50 @@ +#pragma once +#include +#include +#include +#include +#include +#include "common/Result.h" + +namespace eve::animation { +/** @brief Owned scalar animation curves indexed by stable source and channel names. + * @details Contains no skeleton, clip, filesystem or script references. Mutations + * are owner-thread only, with no callbacks or reentrancy. Concurrent const sampling + * is allowed while the owner guarantees no load/destruction. Samples use injected time. + */ +class AnimCurveLibrary { +public: + /** @brief Construct an empty library with independent lifetime. */ + AnimCurveLibrary(); + /** @brief Destroy owned curve data without accessing external objects. */ + ~AnimCurveLibrary(); + AnimCurveLibrary(const AnimCurveLibrary&) = delete; + AnimCurveLibrary& operator=(const AnimCurveLibrary&) = delete; + /** @brief Atomically replace data from little-endian eve.animation-curves/1 bytes. + * @param bytes Borrowed for this synchronous call only; decoded values are owned. + * @return Applied, or InvalidArgument preserving all previous data. Rejects unknown + * versions, trailing bytes, invalid UTF-8, duplicates, nonfinite/unsorted keys and + * resource limits. Empty channels are absent; endpoints must span clip duration. + */ + [[nodiscard]] eve::Result load(std::span bytes); + /** @brief Query whether a source record exists, including records without channels. */ + [[nodiscard]] bool contains(std::string_view source) const; + /** @brief Return the number of source records currently owned. */ + [[nodiscard]] std::size_t size() const noexcept; + /** @brief Evaluate one scalar channel with linear interpolation. + * @param source Borrowed stable source name; never retained. + * @param channel Borrowed channel name; never retained. + * @param seconds Finite simulation time; looping wraps negative time too, otherwise clamps. + * @param loop Whether to wrap at the source duration. No external clip state is read. + * @return Owning optional value (empty for an absent channel), NotFound for an + * unknown source, or InvalidArgument for nonfinite time. No state is mutated; + * repeat calls are deterministic within float rounding tolerance. + */ + [[nodiscard]] eve::Result> sample(std::string_view source, std::string_view channel, + double seconds, bool loop) const; + +private: + struct Impl; + std::unique_ptr impl_; +}; +} // namespace eve::animation diff --git a/src/modules/animation/AnimInertializer.cpp b/src/modules/animation/AnimInertializer.cpp new file mode 100644 index 00000000..5d3badca --- /dev/null +++ b/src/modules/animation/AnimInertializer.cpp @@ -0,0 +1,68 @@ +#include "animation/AnimInertializer.h" +#include +#include "animation/PoseInertiaInternal.h" + +namespace eve::animation { +struct AnimInertializer::Impl { + detail::PoseInertia inertia; + AnimPose output; + std::vector factors; +}; +namespace { +eve::Result invalid(const char* message) { + return eve::Result::failure( + eve::Diagnostic::error(eve::DiagnosticCode::InvalidArgument, message, "poseTransition", {}, "animation")); +} +bool finitePose(const AnimPose& pose, int count) { + if (count <= 0 || pose.getBoneCount() != count) return false; + for (int i = 0; i < count; ++i) { + const auto& t = pose.local(i); + for (float v : {t.px, t.py, t.pz, t.qx, t.qy, t.qz, t.qw, t.sx, t.sy, t.sz}) + if (!std::isfinite(v)) return false; + const double norm = double(t.qx) * t.qx + double(t.qy) * t.qy + double(t.qz) * t.qz + double(t.qw) * t.qw; + if (norm < 1e-12 || norm > 1e12) return false; + } + return true; +} +} // namespace +AnimInertializer::AnimInertializer() : impl_(std::make_unique()) {} +AnimInertializer::~AnimInertializer() = default; + +eve::Result AnimInertializer::begin(const AnimPose& source, const AnimPose& previousSource, + const AnimPose& target, const AnimPose& previousTarget, float historySeconds, + float durationSeconds, std::span boneTimeFactors) { + const int count = source.getBoneCount(); + if (!std::isfinite(historySeconds) || historySeconds <= 0.f || historySeconds > 1.f || + !std::isfinite(durationSeconds) || durationSeconds < 0.f || durationSeconds > 10.f || + !finitePose(source, count) || !finitePose(previousSource, count) || !finitePose(target, count) || + !finitePose(previousTarget, count)) + return invalid("transition requires matching finite poses and valid history/duration"); + if (!boneTimeFactors.empty() && boneTimeFactors.size() != static_cast(count)) + return invalid("time factors must be empty or match the bone count"); + for (float factor : boneTimeFactors) + if (!std::isfinite(factor) || factor < 0.f || factor > 1.f) + return invalid("bone time factors must be finite and between zero and one"); + auto next = std::make_unique(); + next->factors.assign(boneTimeFactors.begin(), boneTimeFactors.end()); + next->inertia.remember(previousSource, historySeconds); + next->inertia.begin(source, target, previousTarget, durationSeconds); + for (const auto& values : next->inertia.velocities) + for (float v : values) + if (!std::isfinite(v)) return invalid("transition history overflows velocity range"); + next->inertia.apply(target, 0.f, next->output, next->factors); + if (!finitePose(next->output, count)) return invalid("transition output is not finite"); + impl_.swap(next); + return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); +} + +eve::Result AnimInertializer::evaluate(const AnimPose& target, float elapsedSeconds) { + if (!std::isfinite(elapsedSeconds) || elapsedSeconds < 0.f || !finitePose(target, impl_->output.getBoneCount())) + return invalid("evaluation requires an initialized transition, matching finite target and elapsed time"); + AnimPose next; + impl_->inertia.apply(target, elapsedSeconds, next, impl_->factors); + if (!finitePose(next, target.getBoneCount())) return invalid("transition output is not finite"); + impl_->output = std::move(next); + return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); +} +const AnimPose& AnimInertializer::pose() const noexcept { return impl_->output; } +} // namespace eve::animation diff --git a/src/modules/animation/AnimInertializer.h b/src/modules/animation/AnimInertializer.h new file mode 100644 index 00000000..965f5ff3 --- /dev/null +++ b/src/modules/animation/AnimInertializer.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include "common/Result.h" + +namespace eve::animation { +class AnimPose; + +/** @brief Finite-duration pose transition relative to a continuously sampled target. + * @details Owns all history and output; retains no skeleton, clip or input pose pointers. + * Local TRS inputs must share bone order and coordinate space. Unlike ControlPose, + * this converges exactly at the requested deadline and does not continually lag its target. + * @thread Owner thread only; no callbacks or reentrancy. Explicit elapsed simulation + * time makes repeated evaluation deterministic within floating-point tolerance. + */ +class AnimInertializer { +public: + /** @brief Construct an empty, independently owned transition. */ + AnimInertializer(); + /** @brief Release owned samples; no external objects are accessed. */ + ~AnimInertializer(); + AnimInertializer(const AnimInertializer&) = delete; + AnimInertializer& operator=(const AnimInertializer&) = delete; + + /** @brief Atomically begin or interrupt a transition using actual outgoing pose history. + * @param source Current outgoing local pose, borrowed only for this call. + * @param previousSource Outgoing pose one historySeconds earlier, copied on success. + * @param target Incoming pose at transition start, copied on success. + * @param previousTarget Incoming pose one historySeconds earlier, copied on success. + * @param historySeconds Positive finite history interval, at most one second. + * @param durationSeconds Finite nonnegative transition duration, at most ten seconds; zero snaps. + * @param boneTimeFactors Empty for uniform duration, otherwise one finite [0,1] factor per bone; + * copied. Zero snaps that bone immediately, one uses the full duration. + * @return Applied or InvalidArgument leaving prior transition/output unchanged. + */ + [[nodiscard]] eve::Result begin(const AnimPose& source, const AnimPose& previousSource, + const AnimPose& target, const AnimPose& previousTarget, float historySeconds, + float durationSeconds, std::span boneTimeFactors = {}); + + /** @brief Evaluate the moving target at an absolute elapsed simulation time since begin. + * @param target Borrowed local pose at elapsedSeconds; copied, never retained. + * @param elapsedSeconds Finite nonnegative time; evaluations may be replayed or partitioned freely. + * @return Applied or InvalidArgument without changing output. Requires a successful begin. + * @details At/after the deadline output equals target. No input pose is modified. + */ + [[nodiscard]] eve::Result evaluate(const AnimPose& target, float elapsedSeconds); + + /** @brief Borrow read-only output, valid until destruction; contents change after successful begin/evaluate. + * Empty before the first successful begin. Copy it to retain a historical sample. + */ + [[nodiscard]] const AnimPose& pose() const noexcept; + +private: + struct Impl; + std::unique_ptr impl_; +}; +} // namespace eve::animation diff --git a/src/modules/animation/AnimParallelInternal.h b/src/modules/animation/AnimParallelInternal.h new file mode 100644 index 00000000..714c436b --- /dev/null +++ b/src/modules/animation/AnimParallelInternal.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace eve::animation::detail { +// Synchronous, bounded fork/join for disjoint native animation work. Nothing +// survives the call, including when thread creation or an item throws. +template +int parallelAnimationItems(std::size_t count, int requested, Body&& body) { + if (count == 0) return 0; + const auto limit = requested > 0 ? static_cast(requested) : 8u; + const auto workers = std::min({count, static_cast(limit), + static_cast(std::max(1u, std::thread::hardware_concurrency()))}); + std::vector errors(count); + std::atomic next{0}; + auto run = [&] { + for (;;) { + const auto index = next.fetch_add(1, std::memory_order_relaxed); + if (index >= count) return; + try { + body(index); + } catch (...) { + errors[index] = std::current_exception(); + } + } + }; + { + std::vector threads; + threads.reserve(workers - 1); + for (std::size_t i = 1; i < workers; ++i) threads.emplace_back(run); + run(); + } + // Report the first failing input, independent of completion order. + for (const auto& error : errors) + if (error) std::rethrow_exception(error); + return static_cast(workers); +} +} // namespace eve::animation::detail diff --git a/src/modules/animation/AnimPlayer.h b/src/modules/animation/AnimPlayer.h index 804c54d2..394b6d78 100644 --- a/src/modules/animation/AnimPlayer.h +++ b/src/modules/animation/AnimPlayer.h @@ -46,6 +46,11 @@ public: bool isPlaying() const { return playing_ && clip_ != nullptr; } bool isPaused() const { return paused_; } + /** @brief Borrow the selected clip, or null if none is selected. + * The caller owns the clip and must keep it alive during playback and this borrow. + * Playback changes can replace the selected clip; no ownership is transferred. + * @thread Owner thread only, outside advance; no callbacks or reentrancy. + */ AnimClip* getClip() const { return clip_; } AnimPose* getPose(); /** @brief Select the bone whose per-frame motion is extracted (default 0). */ diff --git a/src/modules/animation/AnimPose.cpp b/src/modules/animation/AnimPose.cpp index a07a92fe..59e71179 100644 --- a/src/modules/animation/AnimPose.cpp +++ b/src/modules/animation/AnimPose.cpp @@ -1,5 +1,7 @@ #include "animation/AnimPose.h" +#include #include "animation/AnimSkeleton.h" +#include "animation/AnimTransformInternal.h" #include "common/Exception.h" @@ -68,35 +70,6 @@ void blendLocalRotation(TransformTRS& local, const Quat& target, float weight) { local.qx, local.qy, local.qz, local.qw); } -TransformTRS mulTRS(const TransformTRS& parent, const TransformTRS& local) { - // world = parent * local (TRS, scale ignored in rotation path for FK positions) - TransformTRS out; - // Rotate local translation by parent rotation, then add parent translation * scales. - const float qx = parent.qx, qy = parent.qy, qz = parent.qz, qw = parent.qw; - const float lx = local.px * parent.sx; - const float ly = local.py * parent.sy; - const float lz = local.pz * parent.sz; - // q * v - const float ix = qw * lx + qy * lz - qz * ly; - const float iy = qw * ly + qz * lx - qx * lz; - const float iz = qw * lz + qx * ly - qy * lx; - const float iw = -qx * lx - qy * ly - qz * lz; - out.px = parent.px + (ix * qw + iw * -qx + iy * -qz - iz * -qy); - out.py = parent.py + (iy * qw + iw * -qy + iz * -qx - ix * -qz); - out.pz = parent.pz + (iz * qw + iw * -qz + ix * -qy - iy * -qx); - - // Quaternion multiply parent * local - out.qw = parent.qw * local.qw - parent.qx * local.qx - parent.qy * local.qy - parent.qz * local.qz; - out.qx = parent.qw * local.qx + parent.qx * local.qw + parent.qy * local.qz - parent.qz * local.qy; - out.qy = parent.qw * local.qy - parent.qx * local.qz + parent.qy * local.qw + parent.qz * local.qx; - out.qz = parent.qw * local.qz + parent.qx * local.qy - parent.qy * local.qx + parent.qz * local.qw; - out.normalizeRotation(); - - out.sx = parent.sx * local.sx; - out.sy = parent.sy * local.sy; - out.sz = parent.sz * local.sz; - return out; -} } // namespace @@ -239,7 +212,7 @@ void AnimPose::computeWorld(const AnimSkeleton* skeleton) { worlds_[static_cast(i)] = locals_[static_cast(i)]; } else { worlds_[static_cast(i)] = - mulTRS(worlds_[static_cast(parent)], locals_[static_cast(i)]); + detail::mulTRS(worlds_[static_cast(parent)], locals_[static_cast(i)]); } } } @@ -264,28 +237,102 @@ bool AnimPose::aimBone(const AnimSkeleton* skeleton, int boneIndex, float target bool AnimPose::solveTwoBoneIK(const AnimSkeleton* skeleton, int rootBone, int midBone, int tipBone, float targetX, float targetY, float targetZ, float weight) { if (!skeleton) throw Exception("AnimPose.solveTwoBoneIK: skeleton is null"); + requireBone(midBone); + computeWorld(skeleton); + const auto pole = world(midBone); + return solveTwoBoneIKPoleImpl(skeleton, rootBone, midBone, tipBone, targetX, targetY, targetZ, pole.px, pole.py, + pole.pz, weight); +} + +bool AnimPose::solveTwoBoneIKPoleImpl(const AnimSkeleton* skeleton, int rootBone, int midBone, int tipBone, + float targetX, float targetY, float targetZ, float poleX, float poleY, + float poleZ, float weight) { + if (!skeleton) throw Exception("AnimPose.solveTwoBoneIK: skeleton is null"); requireBone(rootBone); requireBone(midBone); requireBone(tipBone); if (skeleton->getParent(midBone) != rootBone || skeleton->getParent(tipBone) != midBone) return false; - bool changed = false; - for (int pass = 0; pass < 2; ++pass) { - for (int joint : {midBone, rootBone}) { - computeWorld(skeleton); - const auto& j = world(joint); - const auto& t = world(tipBone); - Quat delta; - if (!fromTo(t.px - j.px, t.py - j.py, t.pz - j.pz, targetX - j.px, targetY - j.py, targetZ - j.pz, delta)) { - continue; - } - const Quat desiredWorld = quatMul(delta, {j.qx, j.qy, j.qz, j.qw}); - blendLocalRotation(local(joint), worldToLocalRotation(skeleton, this, joint, desiredWorld), weight); - changed = true; - } - } + if (!std::isfinite(targetX) || !std::isfinite(targetY) || !std::isfinite(targetZ) || !std::isfinite(poleX) || + !std::isfinite(poleY) || !std::isfinite(poleZ) || !std::isfinite(weight)) + throw Exception("AnimPose.solveTwoBoneIK: nonfinite target or weight"); computeWorld(skeleton); - return changed; + using Vector = std::array; + auto position = [](const TransformTRS& t) -> Vector { return {t.px, t.py, t.pz}; }; + auto sub = [](Vector a, Vector b) -> Vector { return {a[0] - b[0], a[1] - b[1], a[2] - b[2]}; }; + auto dot = [](Vector a, Vector b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }; + auto cross = [](Vector a, Vector b) -> Vector { + return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]}; + }; + const Vector a = position(world(rootBone)), b = position(world(midBone)), c = position(world(tipBone)); + const Vector ab = sub(b, a), bc = sub(c, b); + const double upper = std::sqrt(dot(ab, ab)), lower = std::sqrt(dot(bc, bc)); + if (upper < 1e-7 || lower < 1e-7) return false; + Vector direction = sub(Vector{targetX, targetY, targetZ}, a); + double distance = std::sqrt(dot(direction, direction)); + if (distance < 1e-7) { + direction = sub(c, a); + distance = std::sqrt(dot(direction, direction)); + } + if (distance < 1e-7) { + direction = ab; + distance = upper; + } + for (auto& value : direction) value /= distance; + const double requested = + std::sqrt(dot(sub(Vector{targetX, targetY, targetZ}, a), sub(Vector{targetX, targetY, targetZ}, a))); + const double reach = std::clamp(requested, std::max(std::abs(upper - lower), 1e-7), upper + lower); + const double along = (upper * upper - lower * lower + reach * reach) / (2 * reach); + const double height = std::sqrt(std::max(upper * upper - along * along, 0.0)); + Vector pole = sub(Vector{poleX, poleY, poleZ}, a); + const double projected = dot(pole, direction); + for (int i = 0; i < 3; ++i) pole[i] -= projected * direction[i]; + double poleLength = std::sqrt(dot(pole, pole)); + if (poleLength < 1e-7) { + pole = cross(cross(ab, bc), direction); + poleLength = std::sqrt(dot(pole, pole)); + } + if (poleLength < 1e-7) { + // A fully straight chain has no bend plane. Choose the least parallel + // axis deterministically; subsequent calls retain the resulting plane. + int axis = 0; + for (int i = 1; i < 3; ++i) + if (std::abs(direction[i]) < std::abs(direction[axis])) axis = i; + pole = {0, 0, 0}; + pole[axis] = 1; + const double projection = dot(pole, direction); + for (int i = 0; i < 3; ++i) pole[i] -= projection * direction[i]; + poleLength = std::sqrt(dot(pole, pole)); + } + Vector knee{}, tip{}; + for (int i = 0; i < 3; ++i) { + knee[i] = a[i] + direction[i] * along + pole[i] * height / poleLength; + tip[i] = a[i] + direction[i] * reach; + } + const auto originalRoot = local(rootBone), originalMid = local(midBone); + Quat delta; + if (!fromTo(float(ab[0]), float(ab[1]), float(ab[2]), float(knee[0] - a[0]), float(knee[1] - a[1]), + float(knee[2] - a[2]), delta)) + return false; + const auto root = world(rootBone); + const Quat rootRotation = + worldToLocalRotation(skeleton, this, rootBone, quatMul(delta, {root.qx, root.qy, root.qz, root.qw})); + blendLocalRotation(local(rootBone), rootRotation, 1.f); + computeWorld(skeleton); + const auto mid = world(midBone), end = world(tipBone); + if (fromTo(end.px - mid.px, end.py - mid.py, end.pz - mid.pz, float(tip[0] - mid.px), float(tip[1] - mid.py), + float(tip[2] - mid.pz), delta)) { + const Quat midRotation = + worldToLocalRotation(skeleton, this, midBone, quatMul(delta, {mid.qx, mid.qy, mid.qz, mid.qw})); + blendLocalRotation(local(midBone), midRotation, 1.f); + } + const auto solvedRoot = local(rootBone), solvedMid = local(midBone); + local(rootBone) = originalRoot; + local(midBone) = originalMid; + blendLocalRotation(local(rootBone), {solvedRoot.qx, solvedRoot.qy, solvedRoot.qz, solvedRoot.qw}, weight); + blendLocalRotation(local(midBone), {solvedMid.qx, solvedMid.qy, solvedMid.qz, solvedMid.qw}, weight); + computeWorld(skeleton); + return true; } float AnimPose::getWorldPositionX(int boneIndex) const { diff --git a/src/modules/animation/AnimPose.h b/src/modules/animation/AnimPose.h index 30942271..6ba9a120 100644 --- a/src/modules/animation/AnimPose.h +++ b/src/modules/animation/AnimPose.h @@ -67,7 +67,11 @@ public: float weight = 1.f); /** - * @brief Solve a root-mid-tip chain toward a world-space target using CCD. + * @brief Analytically solve a root-mid-tip chain toward a world-space target. + * @details Preserves segment lengths and the current bend plane, with a deterministic + * plane for a straight chain. Unreachable targets clamp to the chain's reach; + * full-weight reachable targets are exact up to float rounding for rigid/uniformly + * scaled chains. Blends solved local rotations once, then updates world transforms. * @param skeleton Skeleton * defining the hierarchy. * @param rootBone Root joint index. @@ -80,7 +84,8 @@ public: * @param targetZ World-space target Z. * @param weight Blend weight in [0, 1]. * @return - * False for an invalid chain or degenerate target, otherwise true. + * False for an invalid chain or zero-length segment, otherwise true. + * Nonfinite targets/weights throw before changing local transforms. */ bool solveTwoBoneIK(const AnimSkeleton* skeleton, int rootBone, int midBone, int tipBone, float targetX, float targetY, float targetZ, float weight = 1.f); @@ -107,6 +112,8 @@ public: private: void requireBone(int boneIndex) const; + bool solveTwoBoneIKPoleImpl(const AnimSkeleton* skeleton, int rootBone, int midBone, int tipBone, float targetX, + float targetY, float targetZ, float poleX, float poleY, float poleZ, float weight); std::vector locals_; std::vector worlds_; diff --git a/src/modules/animation/AnimTransformInternal.h b/src/modules/animation/AnimTransformInternal.h new file mode 100644 index 00000000..d5ed5852 --- /dev/null +++ b/src/modules/animation/AnimTransformInternal.h @@ -0,0 +1,37 @@ +#pragma once + +#include "animation/AnimMath.h" + +namespace eve::animation::detail { + +inline TransformTRS mulTRS(const TransformTRS& parent, const TransformTRS& local) { + // world = parent * local (TRS, scale ignored in rotation path for FK positions) + TransformTRS out; + // Rotate local translation by parent rotation, then add parent translation * scales. + const float qx = parent.qx, qy = parent.qy, qz = parent.qz, qw = parent.qw; + const float lx = local.px * parent.sx; + const float ly = local.py * parent.sy; + const float lz = local.pz * parent.sz; + // q * v + const float ix = qw * lx + qy * lz - qz * ly; + const float iy = qw * ly + qz * lx - qx * lz; + const float iz = qw * lz + qx * ly - qy * lx; + const float iw = -qx * lx - qy * ly - qz * lz; + out.px = parent.px + (ix * qw + iw * -qx + iy * -qz - iz * -qy); + out.py = parent.py + (iy * qw + iw * -qy + iz * -qx - ix * -qz); + out.pz = parent.pz + (iz * qw + iw * -qz + ix * -qy - iy * -qx); + + // Quaternion multiply parent * local + out.qw = parent.qw * local.qw - parent.qx * local.qx - parent.qy * local.qy - parent.qz * local.qz; + out.qx = parent.qw * local.qx + parent.qx * local.qw + parent.qy * local.qz - parent.qz * local.qy; + out.qy = parent.qw * local.qy - parent.qx * local.qz + parent.qy * local.qw + parent.qz * local.qx; + out.qz = parent.qw * local.qz + parent.qx * local.qy - parent.qy * local.qx + parent.qz * local.qw; + out.normalizeRotation(); + + out.sx = parent.sx * local.sx; + out.sy = parent.sy * local.sy; + out.sz = parent.sz * local.sz; + return out; +} + +} // namespace eve::animation::detail diff --git a/src/modules/animation/Animation.cpp b/src/modules/animation/Animation.cpp index b351ea68..e6166bc4 100644 --- a/src/modules/animation/Animation.cpp +++ b/src/modules/animation/Animation.cpp @@ -767,36 +767,7 @@ void Animation::expose(ssq::Table &table) { lattice.addFunc("getDeformedPositions", &AnimLattice::getDeformedPositions); lattice.addFunc("getDeformedNormals", &AnimLattice::getDeformedNormals); - auto player = table.addClass( - "AnimPlayer", std::function([]() -> AnimPlayer * { return nullptr; }), - true); - player.addFunc("play", &AnimPlayer::play); - player.addFunc("crossFade", &AnimPlayer::crossFade); - player.addFunc("stop", &AnimPlayer::stop); - player.addFunc("pause", &AnimPlayer::pause); - player.addFunc("resume", &AnimPlayer::resume); - player.addFunc("setSpeed", &AnimPlayer::setSpeed); - player.addFunc("getSpeed", &AnimPlayer::getSpeed); - player.addFunc("setTime", &AnimPlayer::setTime); - player.addFunc("getTime", &AnimPlayer::getTime); - player.addFunc("setLoop", &AnimPlayer::setLoop); - player.addFunc("getLoop", &AnimPlayer::getLoop); - player.addFunc("isPlaying", &AnimPlayer::isPlaying); - player.addFunc("isPaused", &AnimPlayer::isPaused); - player.addFunc("getPose", &AnimPlayer::getPose); - player.addFunc("setRootMotionBone", &AnimPlayer::setRootMotionBone); - player.addFunc("getRootMotionBone", &AnimPlayer::getRootMotionBone); - player.addFunc("getRootMotionX", &AnimPlayer::getRootMotionX); - player.addFunc("getRootMotionY", &AnimPlayer::getRootMotionY); - player.addFunc("getRootMotionZ", &AnimPlayer::getRootMotionZ); - player.addFunc("getRootMotionRotationX", &AnimPlayer::getRootMotionRotationX); - player.addFunc("getRootMotionRotationY", &AnimPlayer::getRootMotionRotationY); - player.addFunc("getRootMotionRotationZ", &AnimPlayer::getRootMotionRotationZ); - player.addFunc("getRootMotionRotationW", &AnimPlayer::getRootMotionRotationW); - player.addFunc("consumeEvent", &AnimPlayer::consumeEvent); - player.addFunc("setUpdateRate", &AnimPlayer::setUpdateRate); - player.addFunc("getUpdateRate", &AnimPlayer::getUpdateRate); - player.addFunc("update", &AnimPlayer::update); + exposeAnimPlayerBindings(table); auto graph = table.addClass( "AnimGraph", std::function([]() -> AnimGraph * { return nullptr; }), true); @@ -823,11 +794,6 @@ void Animation::expose(ssq::Table &table) { graph.addFunc("getPose", &AnimGraph::getPose); graph.addFunc("update", &AnimGraph::update); - player.addFunc("getEventCount", &AnimPlayer::getEventCount); - player.addFunc("getEventName", &AnimPlayer::getEventName); - player.addFunc("getEventPayload", &AnimPlayer::getEventPayload); - player.addFunc("clearEvents", &AnimPlayer::clearEvents); - auto mask = table.addClass( "AnimBoneMask", std::function([]() -> AnimBoneMask* { return nullptr; }), true); mask.addFunc("setAll", &AnimBoneMask::setAll); @@ -880,52 +846,7 @@ void Animation::expose(ssq::Table &table) { sm.addFunc("isBlending", &AnimStateMachine::isBlending); sm.addFunc("update", &AnimStateMachine::update); - auto db = table.addClass( - "MotionDatabase", - std::function([]() -> MotionDatabase * { return nullptr; }), true); - db.addFunc("addFeatureBone", &MotionDatabase::addFeatureBone); - db.addFunc("addFeatureBoneByName", &MotionDatabase::addFeatureBoneByName); - db.addFunc("setRootBone", &MotionDatabase::setRootBone); - db.addFunc("getRootBone", &MotionDatabase::getRootBone); - db.addFunc("setRootBoneByName", &MotionDatabase::setRootBoneByName); - db.addFunc("addClip", &MotionDatabase::addClip); - db.addFunc("getClipCount", &MotionDatabase::getClipCount); - db.addFunc("bake", &MotionDatabase::bake); - db.addFunc("isBaked", &MotionDatabase::isBaked); - db.addFunc("getFrameCount", &MotionDatabase::getFrameCount); - db.addFunc("getFeatureSize", &MotionDatabase::getFeatureSize); - db.addFunc("getFrameTime", &MotionDatabase::getFrameTime); - db.addFunc("getFrameClipIndex", &MotionDatabase::getFrameClipIndex); - db.addFunc("getFeatureBoneCount", &MotionDatabase::getFeatureBoneCount); - db.addFunc("getFeatureBone", &MotionDatabase::getFeatureBone); - - auto mm = table.addClass( - "MotionMatcher", - std::function([]() -> MotionMatcher * { return nullptr; }), true); - mm.addFunc("setDesiredVelocity", &MotionMatcher::setDesiredVelocity); - mm.addFunc("getDesiredVelocityX", &MotionMatcher::getDesiredVelocityX); - mm.addFunc("getDesiredVelocityZ", &MotionMatcher::getDesiredVelocityZ); - mm.addFunc("setDesiredYaw", &MotionMatcher::setDesiredYaw); - mm.addFunc("getDesiredYaw", &MotionMatcher::getDesiredYaw); - mm.addFunc("setSearchInterval", &MotionMatcher::setSearchInterval); - mm.addFunc("getSearchInterval", &MotionMatcher::getSearchInterval); - mm.addFunc("setBlendTime", &MotionMatcher::setBlendTime); - mm.addFunc("getBlendTime", &MotionMatcher::getBlendTime); - mm.addFunc("setTrajectoryWeight", &MotionMatcher::setTrajectoryWeight); - mm.addFunc("getTrajectoryWeight", &MotionMatcher::getTrajectoryWeight); - mm.addFunc("setPoseWeight", &MotionMatcher::setPoseWeight); - mm.addFunc("getPoseWeight", &MotionMatcher::getPoseWeight); - mm.addFunc("setVelocityWeight", &MotionMatcher::setVelocityWeight); - mm.addFunc("getVelocityWeight", &MotionMatcher::getVelocityWeight); - mm.addFunc("setIgnoreRadius", &MotionMatcher::setIgnoreRadius); - mm.addFunc("getIgnoreRadius", &MotionMatcher::getIgnoreRadius); - mm.addFunc("getMatchedFrame", &MotionMatcher::getMatchedFrame); - mm.addFunc("getMatchedClipIndex", &MotionMatcher::getMatchedClipIndex); - mm.addFunc("getMatchedTime", &MotionMatcher::getMatchedTime); - mm.addFunc("getLastSearchCost", &MotionMatcher::getLastSearchCost); - mm.addFunc("getPose", &MotionMatcher::getPose); - mm.addFunc("search", &MotionMatcher::search); - mm.addFunc("update", &MotionMatcher::update); + exposeMotionMatcherBindings(table); auto ca = table.addClass( "ControlAnim", std::function([]() -> ControlAnim * { return nullptr; }), diff --git a/src/modules/animation/AnimationBindings.h b/src/modules/animation/AnimationBindings.h index 61c54cd2..069c35ab 100644 --- a/src/modules/animation/AnimationBindings.h +++ b/src/modules/animation/AnimationBindings.h @@ -8,5 +8,14 @@ namespace eve::animation { /** @brief Register AnimClip's authoring and playback-neutral Squirrel surface. */ void exposeAnimClipBindings(ssq::Table& table); +/** @brief Register the owned scalar animation curve library and checked loader. */ +void exposeAnimCurveLibraryBindings(ssq::Table& table); +/** @brief Register the synchronous MotionMatcher Squirrel surface. */ +void exposeMotionMatcherBindings(ssq::Table& table); +/** @brief Register the owning AnimInertializer Squirrel surface. */ +void exposeAnimInertializerBindings(ssq::Table& table); + +/** @brief Register playback, sampling and event inspection for AnimPlayer. */ +void exposeAnimPlayerBindings(ssq::Table& table); } // namespace eve::animation diff --git a/src/modules/animation/AnimationClipBindings.cpp b/src/modules/animation/AnimationClipBindings.cpp index ebb12209..42b06948 100644 --- a/src/modules/animation/AnimationClipBindings.cpp +++ b/src/modules/animation/AnimationClipBindings.cpp @@ -1,13 +1,53 @@ #include "animation/AnimationBindings.h" #include "animation/AnimClip.h" +#include "animation/AnimClipBinary.h" +#include "animation/AnimSkeleton.h" +#include "filesystem/FileData.h" +#include "filesystem/Filesystem.h" #include #include +#include namespace eve::animation { void exposeAnimClipBindings(ssq::Table& table) { + exposeAnimCurveLibraryBindings(table); + table.addFunc("loadAnimationTrackBatch", [vm = table.getHandle()](ssq::Array clips, ssq::Array paths, + AnimSkeleton* skeleton, int workers) { + ssq::Table result(vm); + try { + if (!skeleton || clips.size() != paths.size() || clips.size() > 64) + throw std::runtime_error("invalid animation batch arguments"); + auto* fs = eve::ModuleManager::getInstance("Filesystem"); + if (!fs) throw std::runtime_error("filesystem unavailable"); + std::vector> buffers; + std::vector inputs; + std::size_t total = 0; + for (std::size_t i = 0; i < clips.size(); ++i) { + auto* destination = clips.get(i); + if (!destination) throw std::runtime_error("missing animation batch destination"); + auto* raw = fs->read(paths.get(i)); + if (!raw) throw std::runtime_error("animation track file unavailable"); + eve::ref data(raw); + if (data->getSize() > 256 * 1024 * 1024 - total) + throw std::runtime_error("animation batch exceeds 256 MiB limit"); + total += data->getSize(); + inputs.push_back({*destination, {static_cast(data->getData()), data->getSize()}}); + buffers.push_back(data); + } + auto loaded = loadAnimationTrackBatch(inputs, *skeleton, workers); + result.set("ok", loaded.ok()); + result.set("message", loaded.status().describe()); + result.set("workers", loaded.ok() ? loaded.value() : 0); + } catch (const std::exception& error) { + result.set("ok", false); + result.set("message", std::string(error.what())); + result.set("workers", 0); + } + return result; + }); auto retarget = table.addClass( "AnimRetargetProfile", std::function([]() { return new AnimRetargetProfile(); }), true); retarget.addFunc("addBoneMapping", &AnimRetargetProfile::addBoneMapping); @@ -26,8 +66,26 @@ void exposeAnimClipBindings(ssq::Table& table) { retarget.addFunc("getUnmatchedBoneCount", &AnimRetargetProfile::getUnmatchedBoneCount); retarget.addFunc("getUnmatchedTargetBone", &AnimRetargetProfile::getUnmatchedTargetBone); - auto clip = table.addClass( - "AnimClip", std::function([]() -> AnimClip* { return nullptr; }), true); + auto clip = table.addClass("AnimClip", std::function([]() { return new AnimClip(); }), true); + clip.addFunc("loadBinary", [vm = table.getHandle()](AnimClip* self, std::string path, AnimSkeleton* skeleton) { + ssq::Table result(vm); + try { + if (!self || !skeleton) throw std::runtime_error("missing clip or skeleton"); + auto* fs = eve::ModuleManager::getInstance("Filesystem"); + if (!fs) throw std::runtime_error("filesystem unavailable"); + auto* raw = fs->read(path); + if (!raw) throw std::runtime_error("animation track file unavailable"); + eve::ref data(raw); + auto loaded = loadAnimationTracks(*self, {static_cast(data->getData()), data->getSize()}, + *skeleton); + result.set("ok", loaded.ok()); + result.set("message", loaded.status().describe()); + } catch (const std::exception& error) { + result.set("ok", false); + result.set("message", std::string(error.what())); + } + return result; + }); clip.addFunc("setName", &AnimClip::setName); clip.addFunc("getName", &AnimClip::getName); clip.addFunc("setDuration", &AnimClip::setDuration); diff --git a/src/modules/animation/AnimationCurveBindings.cpp b/src/modules/animation/AnimationCurveBindings.cpp new file mode 100644 index 00000000..295d2fa1 --- /dev/null +++ b/src/modules/animation/AnimationCurveBindings.cpp @@ -0,0 +1,43 @@ +#include +#include +#include +#include "animation/AnimCurveLibrary.h" +#include "animation/AnimationBindings.h" +#include "filesystem/FileData.h" +#include "filesystem/Filesystem.h" + +namespace eve::animation { +void exposeAnimCurveLibraryBindings(ssq::Table& table) { + auto cls = table.addClass( + "AnimCurveLibrary", std::function([] { return new AnimCurveLibrary(); }), true); + cls.addFunc("loadBinary", [vm = table.getHandle()](AnimCurveLibrary* self, std::string path) { + ssq::Table result(vm); + try { + auto* fs = eve::ModuleManager::getInstance("Filesystem"); + if (!fs) throw std::runtime_error("filesystem unavailable"); + auto* raw = fs->read(path); + if (!raw) throw std::runtime_error("animation curve file unavailable"); + eve::ref data(raw); + auto loaded = self->load({static_cast(data->getData()), data->getSize()}); + result.set("ok", loaded.ok()); + result.set("message", loaded.status().describe()); + } catch (const std::exception& error) { + result.set("ok", false); + result.set("message", std::string(error.what())); + } + return result; + }); + cls.addFunc("contains", [](AnimCurveLibrary* self, std::string source) { return self->contains(source); }); + cls.addFunc("getSourceCount", [](AnimCurveLibrary* self) { return static_cast(self->size()); }); + cls.addFunc("sample", [vm = table.getHandle()](AnimCurveLibrary* self, std::string source, std::string channel, + float time, bool loop) { + auto sampled = self->sample(source, channel, time, loop); + ssq::Table result(vm); + result.set("ok", sampled.ok()); + result.set("message", sampled.status().describe()); + result.set("present", sampled.ok() && sampled.value().has_value()); + result.set("value", sampled.ok() ? sampled.value().value_or(0.f) : 0.f); + return result; + }); +} +} // namespace eve::animation diff --git a/src/modules/animation/AnimationInertiaBindings.cpp b/src/modules/animation/AnimationInertiaBindings.cpp new file mode 100644 index 00000000..05586fda --- /dev/null +++ b/src/modules/animation/AnimationInertiaBindings.cpp @@ -0,0 +1,50 @@ +#include +#include +#include +#include +#include "animation/AnimInertializer.h" +#include "animation/AnimPose.h" +#include "animation/AnimationBindings.h" + +namespace eve::animation { +void exposeAnimInertializerBindings(ssq::Table& table) { + auto cls = table.addClass( + "AnimInertializer", std::function([] { return new AnimInertializer(); }), true); + cls.addFunc("begin", [vm = table.getHandle()](AnimInertializer* self, AnimPose* source, AnimPose* previousSource, + AnimPose* target, AnimPose* previousTarget, float history, + float duration, ssq::Array factors) { + ssq::Table result(vm); + try { + if (!source || !previousSource || !target || !previousTarget) + throw std::runtime_error("transition requires four poses"); + if (factors.size() != 0 && factors.size() != static_cast(source->getBoneCount())) + throw std::runtime_error("time factors must match bone count"); + std::vector values; + for (std::size_t i = 0; i < factors.size(); ++i) values.push_back(factors.get(i)); + auto applied = self->begin(*source, *previousSource, *target, *previousTarget, history, duration, values); + result.set("ok", applied.ok()); + result.set("message", applied.status().describe()); + } catch (const std::exception& e) { + result.set("ok", false); + result.set("message", std::string(e.what())); + } + return result; + }); + cls.addFunc("evaluate", [vm = table.getHandle()](AnimInertializer* self, AnimPose* target, float elapsed) { + ssq::Table result(vm); + if (!target) { + result.set("ok", false); + result.set("message", std::string("target pose is required")); + return result; + } + auto applied = self->evaluate(*target, elapsed); + result.set("ok", applied.ok()); + result.set("message", applied.status().describe()); + return result; + }); + cls.addFunc("copyPose", [](AnimInertializer* self, AnimPose* destination) { + if (!destination) throw std::runtime_error("output pose is required"); + destination->copyFrom(&self->pose()); + }); +} +} // namespace eve::animation diff --git a/src/modules/animation/AnimationMotionBindings.cpp b/src/modules/animation/AnimationMotionBindings.cpp new file mode 100644 index 00000000..67fbf8e6 --- /dev/null +++ b/src/modules/animation/AnimationMotionBindings.cpp @@ -0,0 +1,263 @@ +#include +#include +#include +#include "animation/AnimationBindings.h" +#include "animation/MotionDatabase.h" +#include "animation/MotionMatcher.h" +#include "animation/MotionFeatureLayout.h" +#include "filesystem/FileData.h" +#include "filesystem/Filesystem.h" + +namespace eve::animation { +void exposeMotionMatcherBindings(ssq::Table& table) { + exposeAnimInertializerBindings(table); + auto db = table.addClass( + "MotionDatabase", std::function([]() -> MotionDatabase* { return nullptr; }), true); + db.addFunc("addFeatureBone", &MotionDatabase::addFeatureBone); + db.addFunc("addFeatureBoneByName", &MotionDatabase::addFeatureBoneByName); + db.addFunc("setRootBone", &MotionDatabase::setRootBone); + db.addFunc("getRootBone", &MotionDatabase::getRootBone); + db.addFunc("setRootBoneByName", &MotionDatabase::setRootBoneByName); + db.addFunc("addClip", &MotionDatabase::addClip); + db.addFunc("getClipCount", &MotionDatabase::getClipCount); + db.addFunc("bake", &MotionDatabase::bake); + db.addFunc("isBaked", &MotionDatabase::isBaked); + db.addFunc("getFrameCount", &MotionDatabase::getFrameCount); + db.addFunc("getFeatureSize", &MotionDatabase::getFeatureSize); + db.addFunc("getFrameTime", &MotionDatabase::getFrameTime); + db.addFunc("getFrameClipIndex", &MotionDatabase::getFrameClipIndex); + db.addFunc("getFeatureBoneCount", &MotionDatabase::getFeatureBoneCount); + db.addFunc("getFeatureBone", &MotionDatabase::getFeatureBone); + db.addFunc("setLocomotionFeatures", + [vm = table.getHandle()](MotionDatabase* self, int left, int right, int pelvis) { + auto configured = self->setLocomotionFeatures(left, right, pelvis); + ssq::Table result(vm); + result.set("ok", configured.ok()); + result.set("message", configured.status().describe()); + return result; + }); + db.addFunc("hasLocomotionFeatures", &MotionDatabase::hasLocomotionFeatures); + db.addFunc("hasFeatureLayout", &MotionDatabase::hasFeatureLayout); + db.addFunc("loadFeatureCurves", [vm = table.getHandle()](MotionDatabase* self, std::string path, ssq::Array sources) { + ssq::Table result(vm); + try { + if (sources.size() != static_cast(self->getClipCount())) + throw std::runtime_error("one curve source per database clip is required"); + std::vector names; + for (std::size_t i = 0; i < sources.size(); ++i) names.push_back(sources.get(i)); + auto* fs = eve::ModuleManager::getInstance("Filesystem"); + if (!fs) throw std::runtime_error("filesystem unavailable"); + auto* raw = fs->read(path); + if (!raw) throw std::runtime_error("feature curve file unavailable"); + eve::ref data(raw); + auto loaded = self->setFeatureCurves({static_cast(data->getData()), data->getSize()}, names); + result.set("ok", loaded.ok()); result.set("message", loaded.status().describe()); + } catch (const std::exception& e) { result.set("ok", false); result.set("message", std::string(e.what())); } + return result; + }); + db.addFunc("setFeatureLayout", [vm = table.getHandle()](MotionDatabase* self, int rate, ssq::Array entries, float lengthScale) { + ssq::Table result(vm); + try { + if (entries.size() == 0 || entries.size() > 256) throw std::runtime_error("feature layout requires 1..256 channels"); + MotionFeatureLayout layout; layout.sampleRate = rate;layout.normalizationLengthScale = lengthScale; + for (std::size_t i = 0; i < entries.size(); ++i) { + auto item = entries.get(i); + if (item.size() != 12 && item.size() != 13) throw std::runtime_error("feature channel requires twelve fields plus an optional curve name"); + MotionFeatureChannel c; + const auto kind = item.get(0), source = item.get(1), query = item.get(2); + if (kind == "position") c.kind = MotionFeatureKind::Position; + else if (kind == "velocity") c.kind = MotionFeatureKind::Velocity; + else if (kind == "heading") c.kind = MotionFeatureKind::Heading; + else if (kind == "curve") c.kind = MotionFeatureKind::Curve; + else throw std::runtime_error("unsupported feature operation"); + if (source == "pose") c.source = MotionFeatureSource::Pose; + else if (source == "trajectory") c.source = MotionFeatureSource::Trajectory; + else throw std::runtime_error("unsupported feature source"); + if (query == "USE_CHARACTER_POSE") c.query = MotionFeatureQuery::Character; + else if (query == "USE_CONTINUING_POSE") c.query = MotionFeatureQuery::Continuing; + else throw std::runtime_error("unsupported feature query policy"); + c.bone = item.get(3); c.origin = item.get(4); + const int axes = item.get(5); + if (axes < 1 || axes > 7) throw std::runtime_error("feature axes must be a nonempty XYZ mask"); + c.axes = static_cast(axes); c.headingAxis = item.get(6); + c.sampleTime = item.get(7); c.weight = item.get(8); + c.characterSpaceVelocity = item.get(9); c.normalizeVelocity = item.get(10); + c.normalizationGroup = item.get(11); + if (item.size() == 13) c.curve = item.get(12); + layout.channels.push_back(std::move(c)); + } + auto configured = self->setFeatureLayout(layout); + result.set("ok", configured.ok()); result.set("message", configured.status().describe()); + } catch (const std::exception& e) { result.set("ok", false); result.set("message", std::string(e.what())); } + return result; + }); + db.addFunc("setFeatureNormalizationRanges", [vm = table.getHandle()](MotionDatabase* self, ssq::Array entries) { + ssq::Table result(vm); + try { + if (entries.size() > 100000) throw std::runtime_error("too many normalization ranges"); + std::vector ranges; + for (std::size_t i = 0; i < entries.size(); ++i) { + auto item = entries.get(i); + if (item.size() != 3) throw std::runtime_error("normalization range requires clip,start,end"); + ranges.push_back({item.get(0), item.get(1), item.get(2)}); + } + auto configured = self->setFeatureNormalizationRanges(ranges); + result.set("ok", configured.ok()); + result.set("message", configured.status().describe()); + result.set("count", configured.ok() ? configured.value() : 0); + } catch (const std::exception& e) { + result.set("ok", false); result.set("message", std::string(e.what())); result.set("count", 0); + } + return result; + }); + auto mm = table.addClass( + "MotionMatcher", std::function([]() -> MotionMatcher* { return nullptr; }), true); + mm.addFunc("setDesiredVelocity", &MotionMatcher::setDesiredVelocity); + mm.addFunc("getDesiredVelocityX", &MotionMatcher::getDesiredVelocityX); + mm.addFunc("getDesiredVelocityZ", &MotionMatcher::getDesiredVelocityZ); + mm.addFunc("setDesiredYaw", &MotionMatcher::setDesiredYaw); + mm.addFunc("getDesiredYaw", &MotionMatcher::getDesiredYaw); + mm.addFunc("setSearchInterval", &MotionMatcher::setSearchInterval); + mm.addFunc("getSearchInterval", &MotionMatcher::getSearchInterval); + mm.addFunc("setBlendTime", &MotionMatcher::setBlendTime); + mm.addFunc("getBlendTime", &MotionMatcher::getBlendTime); + mm.addFunc("setPlayRateRange", [vm = table.getHandle()](MotionMatcher* self, float minimum, float maximum) { + auto configured = self->setPlayRateRange(minimum, maximum); + ssq::Table result(vm); result.set("ok", configured.ok()); result.set("message", configured.status().describe()); return result; + }); + mm.addFunc("getPlayRateMinimum", &MotionMatcher::getPlayRateMinimum); + mm.addFunc("getPlayRateMaximum", &MotionMatcher::getPlayRateMaximum); + mm.addFunc("getPlayRate", &MotionMatcher::getPlayRate); + mm.addFunc("setTrajectoryWeight", &MotionMatcher::setTrajectoryWeight); + mm.addFunc("getTrajectoryWeight", &MotionMatcher::getTrajectoryWeight); + mm.addFunc("setPoseWeight", &MotionMatcher::setPoseWeight); + mm.addFunc("getPoseWeight", &MotionMatcher::getPoseWeight); + mm.addFunc("setVelocityWeight", &MotionMatcher::setVelocityWeight); + mm.addFunc("getVelocityWeight", &MotionMatcher::getVelocityWeight); + mm.addFunc("setIgnoreRadius", &MotionMatcher::setIgnoreRadius); + mm.addFunc("getIgnoreRadius", &MotionMatcher::getIgnoreRadius); + mm.addFunc("setPoseReselectHistory", [vm = table.getHandle()](MotionMatcher* self, float seconds) { + auto configured = self->setPoseReselectHistory(seconds); + ssq::Table result(vm); result.set("ok", configured.ok()); result.set("message", configured.status().describe()); return result; + }); + mm.addFunc("getPoseReselectHistory", &MotionMatcher::getPoseReselectHistory); + mm.addFunc("getMatchedFrame", &MotionMatcher::getMatchedFrame); + mm.addFunc("getMatchedClipIndex", &MotionMatcher::getMatchedClipIndex); + mm.addFunc("getMatchedTime", &MotionMatcher::getMatchedTime); + mm.addFunc("getLastSearchCost", &MotionMatcher::getLastSearchCost); + mm.addFunc("getPose", &MotionMatcher::getPose); + mm.addFunc("search", &MotionMatcher::search); + mm.addFunc("update", &MotionMatcher::update); + mm.addFunc("setFeatureQuery", [vm = table.getHandle()](MotionMatcher* self, AnimPose* current, + AnimPose* previous, float dt, ssq::Array entries, ssq::Array curveEntries) { + ssq::Table result(vm); + try { + if (!current || !previous || entries.size() > 256) throw std::runtime_error("feature query requires two poses and bounded trajectory samples"); + std::vector samples; + for (std::size_t i = 0; i < entries.size(); ++i) { + auto item = entries.get(i); + if (item.size() != 8) throw std::runtime_error("feature sample requires time,x,y,z,vx,vy,vz,yaw"); + samples.push_back({item.get(0),item.get(1),item.get(2),item.get(3), + item.get(4),item.get(5),item.get(6),item.get(7)}); + } + if (curveEntries.size() > 256) throw std::runtime_error("curve query requires bounded samples"); + std::vector curves; + for (std::size_t i = 0; i < curveEntries.size(); ++i) { + auto item = curveEntries.get(i); + if (item.size() != 3) throw std::runtime_error("curve sample requires name,time,value"); + curves.push_back({item.get(0), item.get(1), item.get(2)}); + } + auto configured = self->setFeatureQuery(*current, *previous, dt, samples, curves); + result.set("ok", configured.ok()); result.set("message", configured.status().describe()); + } catch (const std::exception& e) { result.set("ok", false); result.set("message", std::string(e.what())); } + return result; + }); + mm.addFunc("setLocomotionQuery", [vm = table.getHandle()](MotionMatcher* self, AnimPose* current, + AnimPose* previous, float dt, ssq::Array entries) { + ssq::Table result(vm); + try { + if (!current || !previous || entries.size() != 5) + throw std::runtime_error("locomotion query requires two poses and five trajectory samples"); + std::array samples; + for (std::size_t i = 0; i < 5; ++i) { + auto item = entries.get(i); + if (item.size() != 7) throw std::runtime_error("locomotion sample requires x,y,z,vx,vy,vz,yaw"); + samples[i] = {item.get(0), item.get(1), item.get(2), item.get(3), + item.get(4), item.get(5), item.get(6)}; + } + auto configured = self->setLocomotionQuery(*current, *previous, dt, samples); + result.set("ok", configured.ok()); + result.set("message", configured.status().describe()); + } catch (const std::exception& e) { + result.set("ok", false); + result.set("message", std::string(e.what())); + } + return result; + }); + mm.addFunc("setCandidateRanges", [vm = table.getHandle()](MotionMatcher* self, ssq::Array entries) { + ssq::Table result(vm); + try { + if (entries.size() > 100000) throw std::runtime_error("too many candidate ranges"); + std::vector ranges; + for (std::size_t i = 0; i < entries.size(); ++i) { + auto item = entries.get(i); + if (item.size() != 9) throw std::runtime_error("candidate range requires clip,start,end,bias,disableReselection,transitionBlocks,continuingBias,costOverrides,continuingCostOverrides"); + MotionSearchRange range{item.get(0), item.get(1), item.get(2), + item.get(3), item.get(4)}; + auto blocks = item.get(5); + if (blocks.size() > 100000) throw std::runtime_error("too many transition blocks"); + for (std::size_t j = 0; j < blocks.size(); ++j) { + auto block = blocks.get(j); + if (block.size() != 2) throw std::runtime_error("transition block requires start,end"); + range.transitionBlocks.push_back({block.get(0), block.get(1)}); + } + range.continuingCostBias = item.get(6); + for (std::size_t field : {std::size_t{7}, std::size_t{8}}) { + auto values = item.get(field); + if (values.size() > 100000) throw std::runtime_error("too many cost override intervals"); + auto& destination = field == 7 ? range.costOverrides : range.continuingCostOverrides; + for (std::size_t j = 0; j < values.size(); ++j) { + auto value = values.get(j); + if (value.size() != 3) throw std::runtime_error("cost override requires start,end,bias"); + destination.push_back({value.get(0), value.get(1), value.get(2)}); + } + } + ranges.push_back(std::move(range)); + } + auto selected = self->setCandidateRanges(ranges); + result.set("ok", selected.ok()); + result.set("message", selected.status().describe()); + result.set("count", selected.ok() ? selected.value() : 0); + } catch (const std::exception& e) { + result.set("ok", false); + result.set("message", std::string(e.what())); + result.set("count", 0); + } + return result; + }); + mm.addFunc("setQueryPose", [](MotionMatcher* self, AnimPose* pose) { + if (!pose) throw std::runtime_error("query pose is required"); + auto configured = self->setQueryPose(*pose); + if (!configured) throw std::runtime_error(configured.status().describe()); + }); + mm.addFunc("setTrajectory", [vm = table.getHandle()](MotionMatcher* self, ssq::Array entries) { + ssq::Table result(vm); + try { + if (entries.size() != 3) throw std::runtime_error("trajectory requires three samples"); + std::array samples; + for (std::size_t i = 0; i < 3; ++i) { + auto item = entries.get(i); + if (item.size() != 3) throw std::runtime_error("trajectory sample requires x,z,yaw"); + samples[i] = {item.get(0), item.get(1), item.get(2)}; + } + auto configured = self->setTrajectory(samples); + result.set("ok", configured.ok()); + result.set("message", configured.status().describe()); + } catch (const std::exception& e) { + result.set("ok", false); + result.set("message", std::string(e.what())); + } + return result; + }); +} +} // namespace eve::animation diff --git a/src/modules/animation/AnimationPlayerBindings.cpp b/src/modules/animation/AnimationPlayerBindings.cpp new file mode 100644 index 00000000..6300061c --- /dev/null +++ b/src/modules/animation/AnimationPlayerBindings.cpp @@ -0,0 +1,45 @@ +#include +#include +#include "animation/AnimClip.h" +#include "animation/AnimPlayer.h" +#include "animation/AnimationBindings.h" + +namespace eve::animation { +void exposeAnimPlayerBindings(ssq::Table& table) { + auto player = table.addClass( + "AnimPlayer", std::function([]() -> AnimPlayer* { return nullptr; }), true); + player.addFunc("play", &AnimPlayer::play); + player.addFunc("crossFade", &AnimPlayer::crossFade); + player.addFunc("stop", &AnimPlayer::stop); + player.addFunc("pause", &AnimPlayer::pause); + player.addFunc("resume", &AnimPlayer::resume); + player.addFunc("setSpeed", &AnimPlayer::setSpeed); + player.addFunc("getSpeed", &AnimPlayer::getSpeed); + player.addFunc("setTime", &AnimPlayer::setTime); + player.addFunc("getTime", &AnimPlayer::getTime); + player.addFunc("setLoop", &AnimPlayer::setLoop); + player.addFunc("getLoop", &AnimPlayer::getLoop); + player.addFunc("isPlaying", &AnimPlayer::isPlaying); + player.addFunc("isPaused", &AnimPlayer::isPaused); + player.addFunc("getPose", &AnimPlayer::getPose); + player.addFunc("getClip", &AnimPlayer::getClip); + player.addFunc("setRootMotionBone", &AnimPlayer::setRootMotionBone); + player.addFunc("getRootMotionBone", &AnimPlayer::getRootMotionBone); + player.addFunc("getRootMotionX", &AnimPlayer::getRootMotionX); + player.addFunc("getRootMotionY", &AnimPlayer::getRootMotionY); + player.addFunc("getRootMotionZ", &AnimPlayer::getRootMotionZ); + player.addFunc("getRootMotionRotationX", &AnimPlayer::getRootMotionRotationX); + player.addFunc("getRootMotionRotationY", &AnimPlayer::getRootMotionRotationY); + player.addFunc("getRootMotionRotationZ", &AnimPlayer::getRootMotionRotationZ); + player.addFunc("getRootMotionRotationW", &AnimPlayer::getRootMotionRotationW); + player.addFunc("consumeEvent", &AnimPlayer::consumeEvent); + player.addFunc("setUpdateRate", &AnimPlayer::setUpdateRate); + player.addFunc("getUpdateRate", &AnimPlayer::getUpdateRate); + player.addFunc("update", &AnimPlayer::update); + + player.addFunc("getEventCount", &AnimPlayer::getEventCount); + player.addFunc("getEventName", &AnimPlayer::getEventName); + player.addFunc("getEventPayload", &AnimPlayer::getEventPayload); + player.addFunc("clearEvents", &AnimPlayer::clearEvents); +} +} // namespace eve::animation diff --git a/src/modules/animation/MotionDatabase.cpp b/src/modules/animation/MotionDatabase.cpp index 70ee436e..74f9995b 100644 --- a/src/modules/animation/MotionDatabase.cpp +++ b/src/modules/animation/MotionDatabase.cpp @@ -1,6 +1,9 @@ #include "animation/MotionDatabase.h" #include "animation/AnimClip.h" +#include "animation/AnimParallelInternal.h" #include "animation/AnimSkeleton.h" +#include "animation/AnimTransformInternal.h" +#include "animation/MotionSchemaInternal.h" #include "common/Exception.h" @@ -15,6 +18,7 @@ MotionDatabase::MotionDatabase(AnimSkeleton* skeleton) : skeleton_(skeleton) { } void MotionDatabase::addFeatureBone(int boneIndex) { + if (locomotionFeatures_ || schema_) throw Exception("MotionDatabase: configured layout owns its feature bones"); if (boneIndex < 0 || boneIndex >= skeleton_->getBoneCount()) { throw Exception("MotionDatabase.addFeatureBone: invalid bone %d", boneIndex); } @@ -32,6 +36,7 @@ void MotionDatabase::addFeatureBoneByName(const std::string& name) { } void MotionDatabase::setRootBone(int boneIndex) { + if (schema_ && baked_) throw Exception("MotionDatabase: baked feature layout is immutable"); if (boneIndex < 0 || boneIndex >= skeleton_->getBoneCount()) { throw Exception("MotionDatabase.setRootBone: invalid bone %d", boneIndex); } @@ -46,6 +51,7 @@ void MotionDatabase::setRootBoneByName(const std::string& name) { } void MotionDatabase::addClip(AnimClip* clip) { + if (schema_ && baked_) throw Exception("MotionDatabase: baked feature layout is immutable"); if (!clip) throw Exception("MotionDatabase.addClip: clip is null"); clips_.push_back(clip); baked_ = false; @@ -60,7 +66,7 @@ AnimClip* MotionDatabase::getClip(int clipIndex) const { void MotionDatabase::computeFeatureSize() { // vel(2) + trajPos*3(6) + trajFacing(2) + bones*3 - featureSize_ = 2 + 6 + 2 + static_cast(featureBones_.size()) * 3; + featureSize_ = schema_ ? schema_->dimension : (locomotionFeatures_ ? 30 : 2 + 6 + 2 + static_cast(featureBones_.size()) * 3); } float MotionDatabase::yawFromQuat(float /*x*/, float y, float /*z*/, float w) { @@ -77,23 +83,50 @@ int MotionDatabase::getFeatureBone(int index) const { void MotionDatabase::extractFeature(AnimClip* clip, float time, float dtSample, std::vector& out, float& rootX, float& rootZ, float& rootYaw, float& velX, float& velZ) const { + if (schema_) { + extractSchemaFeature(clip, time, out, rootX, rootZ, rootYaw, velX, velZ); + return; + } + if (locomotionFeatures_) { + extractLocomotionFeature(clip, time, out, rootX, rootZ, rootYaw, velX, velZ); + return; + } out.assign(static_cast(featureSize_), 0.f); - clip->sample(time, &scratchPose_, skeleton_); - scratchPose_.computeWorld(skeleton_); - + // Trajectory horizons need only the root's ancestor chain; pose features + // need only their own chains. Sampling every unrelated finger/face bone at + // all six horizons makes full-corpus baking unnecessarily expensive. + auto worldBone = [&](auto&& self, int bone, float sampleTime) -> TransformTRS { + const auto local = clip->sampleBone(bone, sampleTime, skeleton_->bindLocal(bone)); + const int parent = skeleton_->getParent(bone); + return parent < 0 ? local : detail::mulTRS(self(self, parent, sampleTime), local); + }; + auto worldAt = [&](int bone, float sampleTime, bool wrap = true) { + const float t = clampf(wrap ? clip->wrapTime(sampleTime) : sampleTime, 0.f, clip->getDuration()); + return worldBone(worldBone, bone, t); + }; const int root = rootBone_; - rootX = scratchPose_.getWorldPositionX(root); - rootZ = scratchPose_.getWorldPositionZ(root); - rootYaw = yawFromQuat(scratchPose_.getWorldRotationX(root), scratchPose_.getWorldRotationY(root), - scratchPose_.getWorldRotationZ(root), scratchPose_.getWorldRotationW(root)); + const auto current = worldAt(root, time); + rootX = current.px; + rootZ = current.pz; + rootYaw = yawFromQuat(current.qx, current.qy, current.qz, current.qw); + + // Sampling a looping pose wraps its root back to the origin. Trajectories + // must instead accumulate the displacement of every completed cycle. + float cycleX = 0.f, cycleZ = 0.f; + const float duration = clip->getDuration(); + if (clip->getLoop() && duration > 1e-8f) { + const auto start = worldAt(root, 0.f, false); + const auto end = worldAt(root, duration, false); + cycleX = end.px - start.px; + cycleZ = end.pz - start.pz; + } + auto cyclesAt = [&](float t) { return clip->getLoop() && duration > 1e-8f ? std::floor(t / duration) : 0.f; }; // Velocity from nearby sample. const float t1 = time + std::max(dtSample, 1e-3f); - AnimPose next; - clip->sample(t1, &next, skeleton_); - next.computeWorld(skeleton_); - const float nX = next.getWorldPositionX(root); - const float nZ = next.getWorldPositionZ(root); + const auto next = worldAt(root, t1); + const float nX = next.px + cyclesAt(t1) * cycleX; + const float nZ = next.pz + cyclesAt(t1) * cycleZ; const float dtt = std::max(dtSample, 1e-3f); velX = (nX - rootX) / dtt; velZ = (nZ - rootZ) / dtt; @@ -104,30 +137,26 @@ void MotionDatabase::extractFeature(AnimClip* clip, float time, float dtSample, auto toLocal = [&](float wx, float wz, float& lx, float& lz) { const float dx = wx - rootX; const float dz = wz - rootZ; - lx = dx * cs + dz * sn; - lz = -dx * sn + dz * cs; + lx = dx * cs - dz * sn; + lz = dx * sn + dz * cs; }; - out[0] = velX * cs + velZ * sn; - out[1] = -velX * sn + velZ * cs; + out[0] = velX * cs - velZ * sn; + out[1] = velX * sn + velZ * cs; const float horizons[3] = {0.33f, 0.66f, 1.0f}; for (int h = 0; h < 3; ++h) { - AnimPose fut; - clip->sample(time + horizons[h], &fut, skeleton_); - fut.computeWorld(skeleton_); + const auto fut = worldAt(root, time + horizons[h]); float lx, lz; - toLocal(fut.getWorldPositionX(root), fut.getWorldPositionZ(root), lx, lz); + const float cycles = cyclesAt(time + horizons[h]); + toLocal(fut.px + cycles * cycleX, fut.pz + cycles * cycleZ, lx, lz); out[2 + h * 2] = lx; out[2 + h * 2 + 1] = lz; } { - AnimPose fut; - clip->sample(time + 1.0f, &fut, skeleton_); - fut.computeWorld(skeleton_); - const float fyaw = yawFromQuat(fut.getWorldRotationX(root), fut.getWorldRotationY(root), - fut.getWorldRotationZ(root), fut.getWorldRotationW(root)); + const auto fut = worldAt(root, time + 1.0f); + const float fyaw = yawFromQuat(fut.qx, fut.qy, fut.qz, fut.qw); float fx, fz; yawToForward(fyaw - rootYaw, fx, fz); out[8] = fx; @@ -137,8 +166,9 @@ void MotionDatabase::extractFeature(AnimClip* clip, float time, float dtSample, int base = 10; for (int bone : featureBones_) { float lx, lz; - const float wy = scratchPose_.getWorldPositionY(bone); - toLocal(scratchPose_.getWorldPositionX(bone), scratchPose_.getWorldPositionZ(bone), lx, lz); + const auto feature = worldAt(bone, time); + const float wy = feature.py; + toLocal(feature.px, feature.pz, lx, lz); out[static_cast(base)] = lx; out[static_cast(base + 1)] = wy; out[static_cast(base + 2)] = lz; @@ -147,18 +177,27 @@ void MotionDatabase::extractFeature(AnimClip* clip, float time, float dtSample, } void MotionDatabase::bake() { + if (schema_) for (const auto& channel : schema_->layout.channels) { + if (channel.kind != MotionFeatureKind::Curve) continue; + if (!schema_->curves) throw Exception("MotionDatabase: scalar curve data must be configured before bake"); + for (const auto* clip : clips_) if (!schema_->curveSources.contains(clip)) + throw Exception("MotionDatabase: added clip has no scalar curve source"); + } if (clips_.empty()) throw Exception("MotionDatabase.bake: no clips"); - if (featureBones_.empty()) { + if (featureBones_.empty() && !schema_) { // Default: all bones except root. for (int i = 1; i < skeleton_->getBoneCount(); ++i) addFeatureBone(i); if (featureBones_.empty() && skeleton_->getBoneCount() > 0) addFeatureBone(0); } computeFeatureSize(); - frames_.clear(); + baked_ = false; + std::vector> clipFrames(clips_.size()); - for (int ci = 0; ci < getClipCount(); ++ci) { + detail::parallelAnimationItems(clips_.size(), clips_.size() < 16 ? 1 : 0, [&](std::size_t index) { + const int ci = static_cast(index); + auto& output = clipFrames[index]; AnimClip* clip = clips_[static_cast(ci)]; - const float rate = clip->getSampleRate() > 0.f ? clip->getSampleRate() : 30.f; + const float rate = schema_ ? static_cast(schema_->layout.sampleRate) : (clip->getSampleRate() > 0.f ? clip->getSampleRate() : 30.f); const float dt = 1.f / rate; const float dur = clip->getDuration(); if (dur <= 0.f) { @@ -166,16 +205,43 @@ void MotionDatabase::bake() { f.clipIndex = ci; f.time = 0.f; extractFeature(clip, 0.f, dt, f.feature, f.rootX, f.rootZ, f.rootYaw, f.velX, f.velZ); - frames_.push_back(std::move(f)); - continue; + if (schema_) f.trajectorySpeed = schemaTrajectorySpeed(f.feature); + output.push_back(std::move(f)); + return; } - for (float t = 0.f; t < dur - 1e-5f; t += dt) { + const int schemaLast = schema_ ? static_cast(std::floor(dur * rate)) : 0; + float t = 0.f; + for (int sample = 0; ; ++sample, t = schema_ ? sample / rate : t + dt) { + if (schema_ ? (sample > schemaLast || (clip->getLoop() && t >= dur - 1e-5f)) : t >= dur - 1e-5f) break; Frame f; f.clipIndex = ci; f.time = t; extractFeature(clip, t, dt, f.feature, f.rootX, f.rootZ, f.rootYaw, f.velX, f.velZ); - frames_.push_back(std::move(f)); + if (schema_) f.trajectorySpeed = schemaTrajectorySpeed(f.feature); + output.push_back(std::move(f)); } + }); + // Preserve frame IDs and floating-point reduction order exactly. + std::size_t total = 0; + for (const auto& output : clipFrames) total += output.size(); + frames_.clear(); + frames_.reserve(total); + clipFrameOffsets_.clear(); + clipFrameOffsets_.reserve(clipFrames.size() + 1); + for (auto& output : clipFrames) { + clipFrameOffsets_.push_back(static_cast(frames_.size())); + for (auto& frame : output) frames_.push_back(std::move(frame)); + } + clipFrameOffsets_.push_back(static_cast(frames_.size())); + if (schema_) { + normalizeSchemaFeatures(); + baked_ = true; + return; + } + if (locomotionFeatures_) { + normalizeLocomotionFeatures(); + baked_ = true; + return; } featureMean_.assign(static_cast(featureSize_), 0.f); featureInvStd_.assign(static_cast(featureSize_), 0.f); diff --git a/src/modules/animation/MotionDatabase.h b/src/modules/animation/MotionDatabase.h index 4f8ddb4e..bb2cf84b 100644 --- a/src/modules/animation/MotionDatabase.h +++ b/src/modules/animation/MotionDatabase.h @@ -1,28 +1,44 @@ #pragma once #include "animation/AnimPose.h" +#include "common/Result.h" #include +#include #include +#include +#include namespace eve::animation { class AnimClip; class AnimSkeleton; +class MotionMatcher; +struct MotionFeatureLayout; +namespace detail { struct MotionSchemaState; } + +/** @brief Copied clip-time interval contributing once to feature normalization; endpoints inclusive. */ +struct MotionNormalizationRange { + int clipIndex = 0; + float start = 0.f; + float end = 0.f; +}; /** * @brief Baked motion-matching feature database from one or more AnimClips. * Feature layout per frame: * [0..1] root velocity xz * [2..7] trajectory pos xz at +0.33/+0.66/+1.0s (character space) - * [8..10] trajectory facing dir xz + yaw sin/cos packed as (fx,fz) for +1.0s - * [11..] selected joint world positions (xyz each) + * [8..9] trajectory facing dir xz at +1.0s + * [10..] selected joint world positions (xyz each) + * The optional locomotion layout has 19 trajectory and 11 pose dimensions; + * see setLocomotionFeatures(). Existing databases retain the basic layout. * Script type: `MotionDatabase`. */ class MotionDatabase { public: explicit MotionDatabase(AnimSkeleton* skeleton); - ~MotionDatabase() = default; + ~MotionDatabase(); MotionDatabase(const MotionDatabase&) = delete; MotionDatabase& operator=(const MotionDatabase&) = delete; @@ -33,6 +49,59 @@ public: void addFeatureBone(int boneIndex); void addFeatureBoneByName(const std::string& name); + /** @brief Configure the locomotion layout before baking or creating matchers. + * @param leftFoot Left foot bone index. + * @param rightFoot Right foot bone index. + * @param pelvis Pelvis bone index; local +Z is its heading axis (Y-up assets). + * @return Applied, or InvalidArgument without changing the database. + * @details Uses relative feet position, character-space feet velocities, + * pelvis heading, and trajectory at -0.05, 0, 0.35, 0.7, 1 seconds. + * Selects channel-wise mean-deviation normalization with a shared feet-velocity group. + * Bone IDs must be distinct and valid. Reconfiguration after bake is rejected. + * Borrowed skeleton/clips remain caller-owned and immutable during bake and playback. + * @thread Owner thread; no callbacks, reentrancy or retained input pointers. + */ + [[nodiscard]] eve::Result setLocomotionFeatures(int leftFoot, int rightFoot, int pelvis); + /** @brief Whether this database uses the 30-dimensional locomotion layout. */ + bool hasLocomotionFeatures() const { return locomotionFeatures_; } + + /** @brief Atomically configure an owning variable feature layout before bake. + * @param layout Borrowed only for this call; all channels and strings are copied. + * @return Applied, or InvalidArgument preserving the previous configuration. + * @details Rejects invalid bones, axes, weights, times and unsupported pose + * offsets. Skeleton and clips remain borrowed, immutable and caller-owned + * throughout bake and matching. Configure before constructing any matcher. + * Reconfiguration after bake is rejected. No callbacks or retained input pointers. + * @thread Owner thread, outside bake/search; not reentrant. + */ + [[nodiscard]] eve::Result setFeatureLayout(const MotionFeatureLayout& layout); + /** @brief Whether this database uses an explicitly configured variable layout. */ + bool hasFeatureLayout() const; + /** @brief Atomically copy scalar source curves for the configured layout. + * @param bytes Borrowed EVFC/1 bytes for this synchronous call only; decoded data is owned. + * @param sources Borrowed source names in exact database clip order; names are copied. + * @return Applied, or InvalidArgument preserving the previous curve data. + * @details Call after adding all clips and configuring the layout, before bake. + * Every clip must resolve to a source record; absent channels evaluate to zero. + * Clips and skeleton remain caller-owned. No callbacks or retained input pointers. + * @thread Owner thread, outside bake/search; not reentrant. + */ + [[nodiscard]] eve::Result setFeatureCurves(std::span bytes, + std::span sources); + /** @brief Atomically select weighted source ranges used to normalize a variable feature layout. + * @param ranges Borrowed only for this call and copied in order. Duplicate and overlapping + * ranges deliberately contribute again, matching multiple source database entries. + * @return Number of sampled feature rows that will contribute, or InvalidArgument while + * preserving the previous configuration. + * @details Requires a configured unbaked variable layout and existing clips. Each finite + * nonnegative inclusive interval must address a clip and contain at least one layout-rate + * sample. Empty input, more than 100000 ranges or more than 100 million contributing rows + * reject. Omit this call to normalize over every baked frame once. Inputs are owned after copy; + * the borrowed clips and skeleton remain caller-owned and immutable through bake/matching. + * @thread Owner thread before bake; synchronous, no callbacks or reentrancy. + */ + [[nodiscard]] eve::Result setFeatureNormalizationRanges(std::span ranges); + /** * @brief Bone used for trajectory / velocity features (default 0). * Mixamo clips typically want hips (`mixamorig:Hips`). @@ -44,7 +113,14 @@ public: void addClip(AnimClip* clip); int getClipCount() const { return static_cast(clips_.size()); } - /** @brief Bake all clips into searchable frames. Call after addClip / feature bones. */ + /** @brief Bake all clips into searchable frames. Call after addClip / feature bones. + * @details Uses up to + * eight native threads for 16 or more clips; frame and normalization + * order match serial baking exactly. + * Borrowed skeleton and clips must remain immutable + * until this synchronous owner-thread call returns. No + * callbacks or retained jobs. + */ void bake(); bool isBaked() const { return baked_; } @@ -70,11 +146,23 @@ public: // Cached root for trajectory reconstruction helpers. float rootX = 0.f, rootZ = 0.f, rootYaw = 0.f; float velX = 0.f, velZ = 0.f; + // Sum of unnormalized trajectory-velocity channel magnitudes, retained + // before feature normalization for UE-compatible play-rate estimation. + float trajectorySpeed = 0.f; }; const Frame& frameAt(int index) const; private: + struct SchemaSampler; + friend class MotionMatcher; + float schemaTrajectorySpeed(std::span feature) const; + void extractSchemaFeature(AnimClip* clip, float time, std::vector& out, float& rootX, + float& rootZ, float& rootYaw, float& velX, float& velZ) const; + void normalizeSchemaFeatures(); + void extractLocomotionFeature(AnimClip* clip, float time, std::vector& out, float& rootX, float& rootZ, + float& rootYaw, float& velX, float& velZ) const; + void normalizeLocomotionFeatures(); void requireBaked() const; void computeFeatureSize(); void extractFeature(AnimClip* clip, float time, float dtSample, std::vector& out, float& rootX, float& rootZ, @@ -85,12 +173,17 @@ private: std::vector clips_; std::vector featureBones_; std::vector frames_; + // Contiguous baked-frame span for each clip; size is clips_.size() + 1. + std::vector clipFrameOffsets_; int featureSize_ = 0; int rootBone_ = 0; bool baked_ = false; + bool locomotionFeatures_ = false; std::vector featureMean_; std::vector featureInvStd_; mutable AnimPose scratchPose_; + std::unique_ptr schema_; + std::vector normalizationRanges_; }; } // namespace eve::animation diff --git a/src/modules/animation/MotionDatabaseLocomotion.cpp b/src/modules/animation/MotionDatabaseLocomotion.cpp new file mode 100644 index 00000000..5eb1574d --- /dev/null +++ b/src/modules/animation/MotionDatabaseLocomotion.cpp @@ -0,0 +1,169 @@ +#include +#include +#include "animation/AnimClip.h" +#include "animation/AnimSkeleton.h" +#include "animation/AnimTransformInternal.h" +#include "animation/MotionDatabase.h" +#include "animation/MotionFeatureInternal.h" +#include "animation/MotionSchemaInternal.h" + +namespace eve::animation { +eve::Result MotionDatabase::setLocomotionFeatures(int leftFoot, int rightFoot, int pelvis) { + const int count = skeleton_->getBoneCount(); + if (baked_ || leftFoot < 0 || rightFoot < 0 || pelvis < 0 || leftFoot >= count || rightFoot >= count || + pelvis >= count || leftFoot == rightFoot || leftFoot == pelvis || rightFoot == pelvis) + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, "locomotion features require three distinct valid bones before bake", + "featureBones", {}, "animation")); + std::vector bones{leftFoot, rightFoot, pelvis}; + schema_.reset(); + featureBones_.swap(bones); + locomotionFeatures_ = true; + computeFeatureSize(); + return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); +} + +void MotionDatabase::extractLocomotionFeature(AnimClip* clip, float time, std::vector& out, float& rootX, + float& rootZ, float& rootYaw, float& velX, float& velZ) const { + out.assign(detail::locomotionFeatureCount, 0.f); + const float duration = clip->getDuration(); + const float delta = 1.f / 60.f; + auto world = [&](auto&& self, int bone, float t) -> TransformTRS { + const auto local = clip->sampleBone(bone, t, skeleton_->bindLocal(bone)); + const int parent = skeleton_->getParent(bone); + return parent < 0 ? local : detail::mulTRS(self(self, parent, t), local); + }; + auto poseAt = [&](int bone, float t) { return world(world, bone, clampf(clip->wrapTime(t), 0.f, duration)); }; + // Accumulate complete root transforms at loop seams; turning cycles need + // rotation composition, not repeated addition of an unrotated displacement. + const auto start = world(world, rootBone_, 0.f), end = world(world, rootBone_, duration); + const float startYaw = detail::featureYaw(start); + const float cycleYaw = std::remainder(detail::featureYaw(end) - startYaw, 6.28318530718f); + auto rootAt = [&](float t) { + auto root = poseAt(rootBone_, t); + if (clip->getLoop() && duration > 1e-6f) { + const int cycles = static_cast(std::floor(t / duration)); + float x = root.px - start.px, z = root.pz - start.pz; + const float dx = end.px - start.px, dz = end.pz - start.pz; + const float cs = std::cos(cycleYaw), sn = std::sin(cycleYaw); + for (int i = 0; i < std::abs(cycles); ++i) { + if (cycles > 0) { + const float nextX = cs * x + sn * z + dx; + z = -sn * x + cs * z + dz; + x = nextX; + } else { + x -= dx; + z -= dz; + const float nextX = cs * x - sn * z; + z = sn * x + cs * z; + x = nextX; + } + } + root.px = start.px + x; + root.pz = start.pz + z; + root.py += cycles * (end.py - start.py); + const float angle = detail::featureYaw(root) + cycles * cycleYaw; + root.qx = root.qz = 0.f; + root.qy = std::sin(angle * .5f); + root.qw = std::cos(angle * .5f); + } else if (duration > 1e-6f && (t < 0.f || t > duration)) { + // Root trajectory extrapolation preserves velocity outside short + // starts/stops, instead of inventing a stationary future endpoint. + const float dt = std::min(delta, duration); + const auto a = world(world, rootBone_, t < 0.f ? 0.f : duration - dt); + const auto b = world(world, rootBone_, t < 0.f ? dt : duration); + const float extension = (t < 0.f ? t : t - duration) / dt; + root.px += (b.px - a.px) * extension; + root.py += (b.py - a.py) * extension; + root.pz += (b.pz - a.pz) * extension; + const float angle = + detail::featureYaw(root) + + std::remainder(detail::featureYaw(b) - detail::featureYaw(a), 6.28318530718f) * extension; + root.qx = root.qz = 0.f; + root.qy = std::sin(angle * .5f); + root.qw = std::cos(angle * .5f); + } + return root; + }; + struct Sample { + float x, y, z, vx, vy, vz, yaw; + }; + std::array samples{}; + const auto current = rootAt(time); + rootX = current.px; + rootZ = current.pz; + rootYaw = detail::featureYaw(current); + for (int i = 0; i < 5; ++i) { + const float t = time + detail::locomotionHorizons[i]; + const auto r = rootAt(t), previous = rootAt(t - delta); + samples[i] = {r.px - current.px, + r.py - current.py, + r.pz - current.pz, + (r.px - previous.px) / delta, + (r.py - previous.py) / delta, + (r.pz - previous.pz) / delta, + detail::featureYaw(r)}; + } + velX = samples[1].vx; + velZ = samples[1].vz; + detail::encodeLocomotionTrajectory(std::span(out), samples, rootYaw); + std::array now{}, past{}; + now[0] = poseAt(rootBone_, time); + past[0] = poseAt(rootBone_, time - delta); + for (int i = 0; i < 3; ++i) { + now[i + 1] = poseAt(featureBones_[i], time); + past[i + 1] = poseAt(featureBones_[i], time - delta); + } + detail::encodeLocomotionPose(out, now, past, delta); +} + +void MotionDatabase::normalizeLocomotionFeatures() { + featureMean_.assign(detail::locomotionFeatureCount, 0.f); + featureInvStd_.assign(detail::locomotionFeatureCount, 1.f); + // Separate channels use vector mean deviation (isotropic XZ/XYZ); the two + // feet share a centroid and scale, as the authored FeetVelZ group specifies. + const std::array, 12> channels{{{0, 2, 1}, + {2, 2, 1}, + {4, 2, 1}, + {6, 2, 1}, + {8, 2, 1}, + {10, 2, 1}, + {12, 2, 1}, + {14, 2, 1}, + {16, 3, 1}, + {19, 3, 1}, + {22, 3, 2}, + {28, 2, 1}}}; + for (const auto& channel : channels) { + const int base = channel[0], dimensions = channel[1], copies = channel[2]; + std::array mean{}; + const double count = static_cast(frames_.size()) * copies; + for (const auto& frame : frames_) + for (int copy = 0; copy < copies; ++copy) + for (int axis = 0; axis < dimensions; ++axis) + mean[axis] += frame.feature[base + copy * dimensions + axis]; + for (double& value : mean) value /= count; + double deviation = 0; + for (const auto& frame : frames_) + for (int copy = 0; copy < copies; ++copy) { + double square = 0; + for (int axis = 0; axis < dimensions; ++axis) { + const double d = frame.feature[base + copy * dimensions + axis] - mean[axis]; + square += d * d; + } + deviation += std::sqrt(square); + } + deviation /= count; + const bool unitVector = base == 4 || base == 8 || base == 14 || base == 16 || base == 28; + const double minimum = unitVector ? 0.1 : 0.001; // UE 0.1 cm, converted to metres. + const float inverse = static_cast(deviation > minimum ? 1.0 / deviation : (unitVector ? 1.0 : 100.0)); + for (int copy = 0; copy < copies; ++copy) + for (int axis = 0; axis < dimensions; ++axis) { + const int i = base + copy * dimensions + axis; + featureMean_[i] = static_cast(mean[axis]); + featureInvStd_[i] = inverse; + } + } + for (auto& frame : frames_) normalizeFeature(frame.feature); +} +} // namespace eve::animation diff --git a/src/modules/animation/MotionDatabaseSchema.cpp b/src/modules/animation/MotionDatabaseSchema.cpp new file mode 100644 index 00000000..6bfaa9b2 --- /dev/null +++ b/src/modules/animation/MotionDatabaseSchema.cpp @@ -0,0 +1,283 @@ +#include +#include +#include +#include +#include +#include "animation/AnimClip.h" +#include "animation/AnimSkeleton.h" +#include "animation/MotionDatabase.h" +#include "animation/MotionSchemaInternal.h" +#include "common/Exception.h" + +namespace eve::animation { +MotionDatabase::~MotionDatabase() = default; +bool MotionDatabase::hasFeatureLayout() const { return schema_ != nullptr; } + +float MotionDatabase::schemaTrajectorySpeed(std::span feature) const { + if (!schema_ || feature.size() != static_cast(schema_->dimension)) return 0.f; + float total = 0.f; + for (std::size_t i = 0; i < schema_->layout.channels.size(); ++i) { + const auto& channel = schema_->layout.channels[i]; + if (channel.source != MotionFeatureSource::Trajectory || channel.kind != MotionFeatureKind::Velocity || + channel.normalizeVelocity) + continue; + const int count = std::popcount(static_cast(channel.axes)); + float squared = 0.f; + for (int component = 0; component < count; ++component) { + const float value = feature[schema_->offsets[i] + static_cast(component)]; + squared += value * value; + } + total += std::sqrt(squared); + } + return total; +} + +eve::Result MotionDatabase::setFeatureLayout(const MotionFeatureLayout& layout) { + auto fail = [](const char* message) { + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, message, "featureLayout", {}, "animation")); + }; + if (baked_ || layout.sampleRate < 1 || layout.sampleRate > 240 || layout.channels.empty() || layout.channels.size() > 256 || + !std::isfinite(layout.normalizationLengthScale) || layout.normalizationLengthScale <= 0.f || layout.normalizationLengthScale > 1000000.f) + return fail("feature layout requires an unbaked database, 1..240 Hz and 1..256 channels"); + auto next = std::make_unique(); + next->layout = layout; + double totalWeight = 0; + for (const auto& c : layout.channels) { + if (c.kind > MotionFeatureKind::Curve || c.source > MotionFeatureSource::Trajectory || + c.query > MotionFeatureQuery::Continuing || c.axes == 0 || c.axes > 7 || + c.headingAxis < 0 || c.headingAxis > 2 || !std::isfinite(c.weight) || c.weight < 0.f || + c.weight > 1000000.f || !std::isfinite(c.sampleTime) || std::abs(c.sampleTime) > 10.f || + c.normalizationGroup.size() > 4096) + return fail("invalid feature channel operation, axes, weight, time or normalization group"); + if (c.kind == MotionFeatureKind::Curve && (c.source != MotionFeatureSource::Pose || c.axes != 1 || + c.curve.empty() || c.curve.size() > 4096 || c.curve.find('\0') != std::string::npos)) + return fail("curve channels require a named scalar pose source"); + if (c.kind != MotionFeatureKind::Curve && !c.curve.empty()) + return fail("vector channels cannot specify a scalar curve"); + if (c.kind != MotionFeatureKind::Curve && c.source == MotionFeatureSource::Pose && (c.sampleTime != 0.f || c.bone < 0 || c.origin < 0 || + c.bone >= skeleton_->getBoneCount() || c.origin >= skeleton_->getBoneCount())) + return fail("pose channels require valid bones and zero sample offset"); + if (c.source == MotionFeatureSource::Trajectory && (c.query != MotionFeatureQuery::Character || + (c.kind == MotionFeatureKind::Velocity && c.characterSpaceVelocity))) + return fail("trajectory channels require character queries and world-space velocity"); + const int count = std::popcount(static_cast(c.axes)); + if (c.normalizeVelocity && c.kind != MotionFeatureKind::Velocity) + return fail("velocity normalization requires a velocity channel"); + next->offsets.push_back(next->dimension); + next->dimension += count; + totalWeight += c.weight * count; + const int group = c.source == MotionFeatureSource::Pose ? 0 : (c.kind == MotionFeatureKind::Velocity ? 1 : 2); + next->weightSums[group] += c.weight * count; + for (int i = 0; i < count; ++i) { + next->weights.push_back(c.weight); next->sources.push_back(c.source); + next->kinds.push_back(c.kind); next->queries.push_back(c.query); + } + } + if (next->dimension > 1024 || totalWeight <= 0 || !std::isfinite(totalWeight)) + return fail("feature layout requires bounded dimensions and positive finite total weight"); + schema_ = std::move(next); + locomotionFeatures_ = false; + featureBones_.clear(); + computeFeatureSize(); + return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); +} + +eve::Result MotionDatabase::setFeatureCurves(std::span bytes, + std::span sources) { + auto fail = [](const char* message) { + return eve::Result::failure(eve::Diagnostic::error(eve::DiagnosticCode::InvalidArgument, + message, "featureCurves", {}, "animation")); + }; + if (!schema_ || baked_ || clips_.empty() || sources.size() != clips_.size()) + return fail("feature curves require a configured unbaked database and one source per clip"); + auto library = std::make_unique(); + auto loaded = library->load(bytes); + if (!loaded.ok()) return fail("invalid feature curve binary"); + std::map names; + for (std::size_t i = 0; i < sources.size(); ++i) { + if (!library->contains(sources[i])) return fail("feature curve source is missing"); + auto [it, inserted] = names.emplace(clips_[i], sources[i]); + if (!inserted && it->second != sources[i]) return fail("one clip cannot have conflicting curve sources"); + } + schema_->curves = std::move(library); + schema_->curveSources = std::move(names); + return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); +} + +eve::Result MotionDatabase::setFeatureNormalizationRanges(std::span ranges) { + auto fail = [](const char* message) { + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, message, "featureNormalizationRanges", {}, "animation")); + }; + if (!schema_ || baked_ || clips_.empty() || ranges.empty() || ranges.size() > 100000) + return fail("normalization ranges require a configured unbaked variable layout, clips and bounded nonempty input"); + std::uint64_t sampleCount = 0; + const float rate = static_cast(schema_->layout.sampleRate); + for (const auto& range : ranges) { + if (range.clipIndex < 0 || range.clipIndex >= static_cast(clips_.size()) || + !std::isfinite(range.start) || !std::isfinite(range.end) || range.start < 0.f || + range.end < range.start || range.end > clips_[range.clipIndex]->getDuration() + .001f) + return fail("invalid normalization clip or time interval"); + const auto* clip = clips_[range.clipIndex]; + int last = static_cast(std::floor(clip->getDuration() * rate)); + if (clip->getLoop() && static_cast(last) / rate >= clip->getDuration() - 1e-5f) --last; + const int first = std::max(0, static_cast(std::ceil((range.start - 1e-5f) * rate))); + const int end = std::min(last, static_cast(std::floor((range.end + 1e-5f) * rate))); + if (first > end) return fail("normalization interval contains no layout-rate sample"); + sampleCount += static_cast(end - first + 1); + if (sampleCount > 100000000) return fail("normalization sample count exceeds the configured bound"); + } + normalizationRanges_.assign(ranges.begin(), ranges.end()); + return eve::Result::success(static_cast(sampleCount), + eve::Status::success(eve::StatusCode::Applied)); +} + +namespace { +TransformTRS inverseRigid(TransformTRS value) { + value.qx = -value.qx; value.qy = -value.qy; value.qz = -value.qz; + const auto p = detail::schemaRotate({-value.px, -value.py, -value.pz}, value); + value.px = p[0]; value.py = p[1]; value.pz = p[2]; + value.sx = value.sy = value.sz = 1.f; + return value; +} +TransformTRS rigidPower(TransformTRS delta, float count) { + if (count < 0.f) { delta = inverseRigid(delta); count = -count; } + auto whole = static_cast(std::floor(count)); + const float fraction = count - whole; + TransformTRS result, power = delta; + while (whole) { + if (whole & 1) result = detail::mulTRS(result, power); + power = detail::mulTRS(power, power); whole >>= 1; + } + TransformTRS partial; + partial.px = delta.px * fraction; partial.py = delta.py * fraction; partial.pz = delta.pz * fraction; + slerpQuat(0.f, 0.f, 0.f, 1.f, delta.qx, delta.qy, delta.qz, delta.qw, fraction, + partial.qx, partial.qy, partial.qz, partial.qw); + return detail::mulTRS(result, partial); +} +} // namespace +struct MotionDatabase::SchemaSampler { + const AnimSkeleton& skeleton; + const AnimClip& clip; + int rootBone; + TransformTRS raw(int bone, float time) const { + const auto local = clip.sampleBone(bone, time, skeleton.bindLocal(bone)); + const int parent = skeleton.getParent(bone); + return parent < 0 ? local : detail::mulTRS(raw(parent, time), local); + } + TransformTRS root(float time) const { + const float duration = clip.getDuration(); + if (duration <= 0.f) return raw(rootBone, 0.f); + if (clip.getLoop()) { + const float cycles = std::floor(time / duration); + const auto start = raw(rootBone, 0.f), end = raw(rootBone, duration); + const auto delta = detail::mulTRS(inverseRigid(start), end); + const auto local = detail::mulTRS(inverseRigid(start), raw(rootBone, clip.wrapTime(time))); + return detail::mulTRS(detail::mulTRS(start, rigidPower(delta, cycles)), local); + } + if (time >= 0.f && time <= duration) return raw(rootBone, time); + const float step = std::min(1.f / 30.f, duration); + const auto a = raw(rootBone, time < 0.f ? 0.f : duration - step); + const auto b = raw(rootBone, time < 0.f ? step : duration); + const auto delta = detail::mulTRS(inverseRigid(a), b); + return detail::mulTRS(time < 0.f ? a : b, rigidPower(delta, (time < 0.f ? time : time - duration) / step)); + } + TransformTRS bone(int index, float time) const { + const float sample = clip.getLoop() ? clip.wrapTime(time) : clampf(time, 0.f, clip.getDuration()); + return detail::mulTRS(root(time), detail::mulTRS(inverseRigid(raw(rootBone, sample)), raw(index, sample))); + } +}; + +void MotionDatabase::extractSchemaFeature(AnimClip* clip, float time, std::vector& out, float& rootX, + float& rootZ, float& rootYaw, float& velX, float& velZ) const { + constexpr float dt = 1.f / 60.f; + const SchemaSampler sampler{*skeleton_, *clip, rootBone_}; + const auto root = sampler.root(time), previousRoot = sampler.root(time - dt); + rootX = root.px; rootZ = root.pz; + rootYaw = std::atan2(2.f * (root.qw * root.qy + root.qx * root.qz), 1.f - 2.f * (root.qx * root.qx + root.qy * root.qy)); + velX = (root.px - previousRoot.px) / dt; velZ = (root.pz - previousRoot.pz) / dt; + out.assign(schema_->dimension, 0.f); + for (std::size_t i = 0; i < schema_->layout.channels.size(); ++i) { + const auto& c = schema_->layout.channels[i]; + std::array value{}; + if (c.kind == MotionFeatureKind::Curve) { + auto sampled = schema_->curves->sample(schema_->curveSources.at(clip), c.curve, + time + c.sampleTime, clip->getLoop()); + if (!sampled.ok()) throw std::runtime_error("configured motion curve cannot be sampled"); + value[0] = sampled.value().value_or(0.f); + } else if (c.source == MotionFeatureSource::Trajectory) { + const auto sample = sampler.root(time + c.sampleTime); + if (c.kind == MotionFeatureKind::Position) value = detail::schemaDifference(sample, root, root); + else if (c.kind == MotionFeatureKind::Heading) value = detail::schemaHeading(sample, root, c.headingAxis); + else { + value = detail::schemaDifference(sample, sampler.root(time + c.sampleTime - dt), root); + for (float& v : value) v /= dt; + } + } else { + const auto bone = sampler.bone(c.bone, time), origin = sampler.bone(c.origin, time); + if (c.kind == MotionFeatureKind::Position) value = detail::schemaDifference(bone, origin, root); + else if (c.kind == MotionFeatureKind::Heading) value = detail::schemaHeading(bone, root, c.headingAxis); + else if (c.characterSpaceVelocity) { + value = detail::schemaDifference(bone, origin, root); + const auto past = detail::schemaDifference(sampler.bone(c.bone, time - dt), sampler.bone(c.origin, time - dt), previousRoot); + for (int axis = 0; axis < 3; ++axis) value[axis] = (value[axis] - past[axis]) / dt; + } else { + value = detail::schemaDifference(bone, sampler.bone(c.bone, time - dt), root); + for (float& v : value) v /= dt; + } + } + detail::schemaEncode(std::span(out).subspan(schema_->offsets[i]), c, value, schema_->layout.normalizationLengthScale); + } +} + +void MotionDatabase::normalizeSchemaFeatures() { + featureMean_.assign(schema_->dimension, 0.f); featureInvStd_.assign(schema_->dimension, 1.f); + using Key = std::tuple; + std::map> groups; + for (std::size_t i = 0; i < schema_->layout.channels.size(); ++i) { + const auto& c = schema_->layout.channels[i]; + groups[{c.kind, std::popcount(static_cast(c.axes)), c.normalizationGroup, + c.normalizationGroup.empty() ? static_cast(i) : -1}].push_back(static_cast(i)); + } + std::vector samples; + if (normalizationRanges_.empty()) { + samples.reserve(frames_.size()); + for (const auto& frame : frames_) samples.push_back(&frame); + } else { + std::vector> perClip(clips_.size()); + for (const auto& frame : frames_) perClip[frame.clipIndex].push_back(&frame); + for (const auto& range : normalizationRanges_) + for (const auto* frame : perClip[range.clipIndex]) + if (frame->time + 1e-5f >= range.start && frame->time - 1e-5f <= range.end) + samples.push_back(frame); + } + if (samples.empty()) throw Exception("MotionDatabase: normalization ranges contain no baked samples"); + for (const auto& [key, channels] : groups) { + const int count = std::get<1>(key); + const double size = static_cast(samples.size()) * channels.size(); + std::array mean{}; + for (const auto* frame : samples) for (int channel : channels) + for (int axis = 0; axis < count; ++axis) mean[axis] += frame->feature[schema_->offsets[channel] + axis]; + for (double& v : mean) v /= size; + double deviation = 0; + for (const auto* frame : samples) for (int channel : channels) { + double square = 0; + for (int axis = 0; axis < count; ++axis) { + const double delta = frame->feature[schema_->offsets[channel] + axis] - mean[axis]; square += delta * delta; + } + deviation += std::sqrt(square); + } + deviation /= size; + const auto& first = schema_->layout.channels[channels[0]]; + const bool unit = first.kind == MotionFeatureKind::Heading || first.kind == MotionFeatureKind::Curve || first.normalizeVelocity; + const float scale = schema_->layout.normalizationLengthScale; + const float inverse = static_cast(deviation > (unit ? .1 : .001 * scale) ? 1.0 / deviation : (unit ? 1.0 : 100.0 / scale)); + for (int channel : channels) for (int axis = 0; axis < count; ++axis) { + featureMean_[schema_->offsets[channel] + axis] = static_cast(mean[axis]); + featureInvStd_[schema_->offsets[channel] + axis] = inverse; + } + } + for (auto& frame : frames_) normalizeFeature(frame.feature); +} +} // namespace eve::animation diff --git a/src/modules/animation/MotionFeatureInternal.h b/src/modules/animation/MotionFeatureInternal.h new file mode 100644 index 00000000..18ee7450 --- /dev/null +++ b/src/modules/animation/MotionFeatureInternal.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include "animation/AnimMath.h" + +namespace eve::animation::detail { +inline constexpr int locomotionPoseOffset = 19; +inline constexpr int locomotionFeatureCount = 30; +inline constexpr std::array locomotionHorizons{-0.05f, 0.f, 0.35f, 0.7f, 1.f}; + +inline float featureYaw(const TransformTRS& t) { + return std::atan2(2.f * (t.qw * t.qy + t.qx * t.qz), 1.f - 2.f * (t.qx * t.qx + t.qy * t.qy)); +} +inline std::array featureLocal(float x, float y, float z, float yaw) { + const float cs = std::cos(yaw), sn = std::sin(yaw); + return {x * cs - z * sn, y, x * sn + z * cs}; +} +inline std::array featurePosition(const TransformTRS& bone, const TransformTRS& root) { + return featureLocal(bone.px - root.px, bone.py - root.py, bone.pz - root.pz, featureYaw(root)); +} +// Shared by database indexing and live pose-history queries. Each sample removes +// its own root transform before differencing: capsule travel is not foot swing. +inline void encodeLocomotionPose(std::span out, const std::array& now, + const std::array& past, float interval) { + const auto left = featurePosition(now[1], now[0]), right = featurePosition(now[2], now[0]); + for (int axis = 0; axis < 3; ++axis) out[19 + axis] = left[axis] - right[axis]; + for (int foot = 1; foot <= 2; ++foot) { + const auto current = featurePosition(now[foot], now[0]), previous = featurePosition(past[foot], past[0]); + for (int axis = 0; axis < 3; ++axis) + out[22 + (foot - 1) * 3 + axis] = (current[axis] - previous[axis]) / interval; + } + // UE's GLTFExporter maps local UE Y to glTF Z. Keep the projected vector's + // length (a tilted pelvis must not be renormalized after stripping vertical). + const auto& q = now[3]; + const auto heading = featureLocal(2.f * (q.qx * q.qz + q.qw * q.qy), 0.f, 1.f - 2.f * (q.qx * q.qx + q.qy * q.qy), + featureYaw(now[0])); + out[28] = heading[0]; + out[29] = heading[2]; +} +template +inline void encodeLocomotionTrajectory(std::span out, const std::array& samples, float yaw) { + auto position = [&](int sample, int offset) { + const auto p = featureLocal(samples[sample].x, samples[sample].y, samples[sample].z, yaw); + out[offset] = p[0]; + out[offset + 1] = p[2]; + }; + auto velocity = [&](int sample, int offset) { + const auto v = featureLocal(samples[sample].vx, samples[sample].vy, samples[sample].vz, yaw); + out[offset] = v[0]; + out[offset + 1] = v[2]; + }; + auto heading = [&](int sample, int offset) { + yawToForward(samples[sample].yaw - yaw, out[offset], out[offset + 1]); + }; + velocity(1, 0); + position(0, 2); + heading(1, 4); + position(2, 6); + heading(2, 8); + position(3, 10); + velocity(3, 12); + heading(3, 14); + auto v = featureLocal(samples[4].vx, samples[4].vy, samples[4].vz, yaw); + // Reference bNormalize clamps at 1 cm/s; these assets and queries use metres. + const float divisor = std::max(0.01f, std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2])); + for (int axis = 0; axis < 3; ++axis) out[16 + axis] = v[axis] / divisor; +} +inline float locomotionWeight(int index) { + if (index == 2 || index == 3 || (index >= 22 && index < 28)) return 0.3f; + if (index >= 16 && index < 19) return 1.5f; + if (index >= 28) return 0.1f; + return 1.f; +} +} // namespace eve::animation::detail diff --git a/src/modules/animation/MotionFeatureLayout.h b/src/modules/animation/MotionFeatureLayout.h new file mode 100644 index 00000000..8b2cbf79 --- /dev/null +++ b/src/modules/animation/MotionFeatureLayout.h @@ -0,0 +1,77 @@ +#pragma once +#include +#include +#include + +namespace eve::animation { +/** @brief Vector or scalar operation encoded by a motion-search channel. */ +enum class MotionFeatureKind : std::uint8_t { Position, Velocity, Heading, Curve }; +/** @brief Source of a channel's samples. */ +enum class MotionFeatureSource : std::uint8_t { Pose, Trajectory }; +/** @brief Query data policy while a valid continuing pose exists. */ +enum class MotionFeatureQuery : std::uint8_t { Character, Continuing }; + +/** @brief Owning configuration for one ordered motion feature channel. + * @details Bone IDs belong to the database skeleton. Trajectory channels sample + * the configured root; pose channels sample bone relative to origin. Bit 0/1/2 + * in axes includes X/Y/Z, in that order. Values use metres and seconds. Velocity + * normalization clamps the full vector at 0.01 m/s before component stripping. + * Empty normalizationGroup keeps this channel independent; equal nonempty groups + * pool channels with the same operation and cardinality. Query policy applies to + * pose channels; trajectories always use character prediction. Curve channels + * use source Pose, axes=1 and a nonempty curve name, with scalar values and an + * arbitrary sample offset in the supported range. Vector channels leave curve empty. + */ +struct MotionFeatureChannel { + MotionFeatureKind kind = MotionFeatureKind::Position; + MotionFeatureSource source = MotionFeatureSource::Pose; + MotionFeatureQuery query = MotionFeatureQuery::Continuing; + int bone = 0, origin = 0; + std::uint8_t axes = 7; + int headingAxis = 0; + float sampleTime = 0.f; + float weight = 1.f; + bool characterSpaceVelocity = true; + bool normalizeVelocity = false; + std::string normalizationGroup; + std::string curve; +}; + +/** @brief Owning ordered feature layout; copied atomically by a database. + * @details Configuration is owner-thread only, with no callbacks or external + * references. Sample rate is 1..240 Hz. Up to 256 channels and 1024 dimensions. + * This runtime value is not a serialized format. Vector pose offsets require + * zero; trajectory and scalar curve offsets range from -10 to +10 seconds. + */ +struct MotionFeatureLayout { + int sampleRate = 30; + std::vector channels; + /** @brief Scale applied to metre-based position/velocity features before pooling; + * dimensionless headings, curves and normalized velocities are unchanged. + * Use 100 for source configurations authored in centimetres; default is metres. + */ + float normalizationLengthScale = 1.f; +}; + +/** @brief Copied trajectory sample at an explicit offset from the current frame. + * @details Position is world-axis displacement from the current root in metres; + * velocity is world metres/second, yaw is world radians. Queries provide one + * sample for every distinct trajectory time required by their layout. + */ +struct MotionFeatureTrajectorySample { + float seconds = 0.f; + float x = 0.f, y = 0.f, z = 0.f; + float vx = 0.f, vy = 0.f, vz = 0.f; + float yaw = 0.f; +}; + +/** @brief Copied evaluated scalar curve query at a schema-relative time. + * @details A missing authored curve is supplied explicitly as zero. Values are + * scalars, not angles; negative values retain their sign. No references retained. + */ +struct MotionFeatureCurveSample { + std::string curve; + float seconds = 0.f; + float value = 0.f; +}; +} // namespace eve::animation diff --git a/src/modules/animation/MotionMatcher.cpp b/src/modules/animation/MotionMatcher.cpp index c687b450..f0f5f1bd 100644 --- a/src/modules/animation/MotionMatcher.cpp +++ b/src/modules/animation/MotionMatcher.cpp @@ -4,9 +4,13 @@ #include "animation/AnimSkeleton.h" #include "animation/AnimationTime.h" #include "animation/MotionDatabase.h" +#include "animation/MotionFeatureInternal.h" +#include "animation/MotionSchemaInternal.h" +#include "animation/PoseInertiaInternal.h" #include "common/Exception.h" +#include #include #include @@ -20,11 +24,13 @@ MotionMatcher::MotionMatcher(AnimSkeleton* skeleton, MotionDatabase* database) throw Exception("MotionMatcher: database skeleton mismatch"); } pose_.resize(skeleton_->getBoneCount()); - fromPose_.resize(skeleton_->getBoneCount()); + inertia_ = std::make_unique(); matchedPose_.resize(skeleton_->getBoneCount()); skeleton_->applyBindPose(&pose_); } +MotionMatcher::~MotionMatcher() = default; + void MotionMatcher::setDesiredVelocity(float x, float z) { desiredVelX_ = x; desiredVelZ_ = z; @@ -62,6 +68,36 @@ void MotionMatcher::setIgnoreRadius(int frames) { ignoreRadius_ = frames; } +eve::Result MotionMatcher::setPlayRateRange(float minimum, float maximum) { + if (!std::isfinite(minimum) || !std::isfinite(maximum) || minimum <= 0.f || maximum < minimum || maximum > 10.f) + return eve::Result::failure(eve::Diagnostic::error(eve::DiagnosticCode::InvalidArgument, + "play rate range requires finite 0 < minimum <= maximum <= 10", + "playRateRange", {}, "animation")); + playRateMin_ = minimum; + playRateMax_ = maximum; + playRate_ = std::clamp(playRate_, minimum, maximum); + return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); +} + +void MotionMatcher::updatePlayRate(int frame) { + float estimated = 1.f; + if (database_->schema_ && frame >= 0) { + const float poseSpeed = database_->frameAt(frame).trajectorySpeed; + if (poseSpeed > 1e-4f) estimated = queryTrajectorySpeed_ / poseSpeed; + } + playRate_ = std::clamp(estimated, playRateMin_, playRateMax_); +} + +eve::Result MotionMatcher::setPoseReselectHistory(float seconds) { + if (!std::isfinite(seconds) || seconds < 0.f || seconds > 10.f) + return eve::Result::failure(eve::Diagnostic::error(eve::DiagnosticCode::InvalidArgument, + "pose reselect history must be finite and between zero and ten seconds", + "poseReselectHistory", {}, "animation")); + poseReselectHistory_ = seconds; + if (seconds == 0.f) poseHistory_.clear(); + return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); +} + int MotionMatcher::getMatchedClipIndex() const { if (matchedFrame_ < 0 || !database_->isBaked()) return -1; return database_->getFrameClipIndex(matchedFrame_); @@ -70,31 +106,40 @@ int MotionMatcher::getMatchedClipIndex() const { AnimPose* MotionMatcher::getPose() { return &pose_; } void MotionMatcher::buildQuery(std::vector& query) const { + if (database_->hasFeatureLayout()) { buildSchemaQuery(query); return; } if (!database_->isBaked()) throw Exception("MotionMatcher: database not baked"); + if (database_->hasLocomotionFeatures()) { + buildLocomotionQuery(query); + return; + } const int n = database_->getFeatureSize(); query.assign(static_cast(n), 0.f); // Character-space desired velocity (assume current facing = desiredYaw for query). const float cs = std::cos(desiredYaw_); const float sn = std::sin(desiredYaw_); - query[0] = desiredVelX_ * cs + desiredVelZ_ * sn; - query[1] = -desiredVelX_ * sn + desiredVelZ_ * cs; + query[0] = desiredVelX_ * cs - desiredVelZ_ * sn; + query[1] = desiredVelX_ * sn + desiredVelZ_ * cs; // Desired trajectory: integrate constant velocity for 0.33/0.66/1.0s in char space. const float horizons[3] = {0.33f, 0.66f, 1.0f}; for (int h = 0; h < 3; ++h) { query[static_cast(2 + h * 2)] = query[0] * horizons[h]; query[static_cast(2 + h * 2 + 1)] = query[1] * horizons[h]; + if (hasTrajectory_) { + query[static_cast(2 + h * 2)] = trajectory_[h].x * cs - trajectory_[h].z * sn; + query[static_cast(3 + h * 2)] = trajectory_[h].x * sn + trajectory_[h].z * cs; + } } // Facing at +1s: same as desired (relative facing 0 → forward +Z in char space). float fx, fz; - yawToForward(0.f, fx, fz); + yawToForward(hasTrajectory_ ? trajectory_[2].yaw - desiredYaw_ : 0.f, fx, fz); query[8] = fx; query[9] = fz; // Pose features from current pose (character-relative). AnimPose cur; - cur.copyFrom(&pose_); + cur.copyFrom(hasQueryPose_ ? &queryPose_ : (playing_ ? &matchedPose_ : &pose_)); cur.computeWorld(skeleton_); const int root = database_->getRootBone(); const float rootX = cur.getWorldPositionX(root); @@ -111,8 +156,8 @@ void MotionMatcher::buildQuery(std::vector& query) const { const int b = database_->getFeatureBone(i); const float dx = cur.getWorldPositionX(b) - rootX; const float dz = cur.getWorldPositionZ(b) - rootZ; - const float lx = dx * c2 + dz * s2; - const float lz = -dx * s2 + dz * c2; + const float lx = dx * c2 - dz * s2; + const float lz = dx * s2 + dz * c2; query[static_cast(base)] = lx; query[static_cast(base + 1)] = cur.getWorldPositionY(b); query[static_cast(base + 2)] = lz; @@ -124,10 +169,22 @@ void MotionMatcher::buildQuery(std::vector& query) const { float MotionMatcher::cost(const std::vector& query, const std::vector& cand, float upperBound) const { const int n = static_cast(query.size()); float sum = 0.f; + const auto* schema = database_->schema_.get(); + const float schemaWeight = schema ? schema->weightSums[0] * poseWeight_ + + schema->weightSums[1] * velWeight_ + schema->weightSums[2] * trajWeight_ : 0.f; for (int i = 0; i < n; ++i) { const float d = query[static_cast(i)] - cand[static_cast(i)]; float w = poseWeight_; - if (i < 2) + if (schema) { + w = schema->sources[i] == MotionFeatureSource::Pose ? poseWeight_ : + (schema->kinds[i] == MotionFeatureKind::Velocity ? velWeight_ : trajWeight_); + w *= schema->weights[i] / std::max(1e-8f, schemaWeight); + } else if (database_->hasLocomotionFeatures()) { + w = i < detail::locomotionPoseOffset ? trajWeight_ : poseWeight_; + if (i < 2 || (i >= 12 && i < 14) || (i >= 16 && i < 19)) w = velWeight_; + w *= detail::locomotionWeight(i); + w /= std::max(1e-8f, 8.5f * velWeight_ + 10.6f * trajWeight_ + 5.f * poseWeight_); + } else if (i < 2) w = velWeight_; else if (i < 10) w = trajWeight_; @@ -156,43 +213,105 @@ void MotionMatcher::search() { std::vector query; buildQuery(query); + // Compare against the live playhead, not the original entry frame. + // Equal-cost searches must keep advancing instead of restarting a blend. + int continuation = -1; + float nearest = std::numeric_limits::infinity(); + const int currentClip = getMatchedClipIndex(); + const auto* currentAsset = currentClip >= 0 ? database_->getClip(currentClip) : nullptr; + const bool ended = currentAsset && !currentAsset->getLoop() && matchedTime_ >= currentAsset->getDuration(); + if (playing_ && currentClip >= 0 && !ended) { + const auto* clip = database_->getClip(currentClip); + const float time = clip->wrapTime(matchedTime_); + const int count = filtered_ ? static_cast(candidateFrames_.size()) : database_->getFrameCount(); + for (int candidate = 0; candidate < count; ++candidate) { + const int i = filtered_ ? candidateFrames_[candidate] : candidate; + if (database_->getFrameClipIndex(i) != currentClip) continue; + float distance = std::abs(database_->getFrameTime(i) - time); + if (clip->getLoop()) distance = std::min(distance, clip->getDuration() - distance); + if (distance < nearest) { + nearest = distance; + continuation = i; + } + } + // Candidate ranges are a live search context (for example a Chooser + // changing from grounded to airborne). A different valid interval in + // the same clip must not make an out-of-range playhead eligible as a + // continuing pose. At the bake rate, a live time is at most half a + // sample from its representing frame. + if (filtered_ && continuation >= 0) { + const float rate = currentAsset->getSampleRate() > 0.f ? currentAsset->getSampleRate() : 30.f; + if (nearest > .5f / rate + 1e-5f) continuation = -1; + } + } float bestCost = std::numeric_limits::infinity(); - int best = 0; - for (int i = 0; i < database_->getFrameCount(); ++i) { - if (matchedFrame_ >= 0 && std::abs(i - matchedFrame_) <= ignoreRadius_) { - // Still allow if different clip continues — but prefer diversity slightly. - // Skip only exact-neighborhood of current match when already playing. - if (playing_ && database_->getFrameClipIndex(i) == getMatchedClipIndex()) { - continue; - } + if (continuation >= 0) { + auto feature = database_->frameAt(continuation).feature; + // Continuing has no pose discontinuity. Use the evaluated playhead + // pose rather than its nearest baked sample (which is quantized). + if (database_->schema_) { + for (std::size_t i = 0; i < query.size(); ++i) + if (database_->schema_->queries[i] == MotionFeatureQuery::Continuing) query[i] = feature[i]; + } else if (database_->hasLocomotionFeatures()) { + // Authored pose channels use UseContinuingPose; trajectory channels + // still use the character's actual/predicted movement. + std::copy(feature.begin() + detail::locomotionPoseOffset, feature.end(), + query.begin() + detail::locomotionPoseOffset); + } else + std::copy(query.begin() + 10, query.end(), feature.begin() + 10); + bestCost = cost(query, feature, bestCost); + if (filtered_) bestCost += candidateBias_[continuation] + candidateContinuingBias_[continuation]; + } + int best = continuation; + float switchCost = bestCost; + const int searchCount = filtered_ ? static_cast(candidateFrames_.size()) : database_->getFrameCount(); + for (int candidate = 0; candidate < searchCount; ++candidate) { + const int i = filtered_ ? candidateFrames_[candidate] : candidate; + // Authored protected phases can be played through, but never jumped into. + if (filtered_ && transitionBlocked_[i]) continue; + if (std::any_of(poseHistory_.begin(), poseHistory_.end(), + [i](const auto& item) { return item.first == i; })) continue; + // DisableReselection belongs to the current asset, including its end. + // An expired one-shot cannot become its own fresh candidate again. + if (playing_ && filtered_ && disableReselection_[currentClip] && database_->getFrameClipIndex(i) == currentClip) + continue; + if (continuation >= 0 && database_->getFrameClipIndex(i) == currentClip) { + if (filtered_ && disableReselection_[currentClip]) continue; + const auto* clip = database_->getClip(currentClip); + float distance = std::abs(database_->getFrameTime(i) - clip->wrapTime(matchedTime_)); + if (clip->getLoop()) distance = std::min(distance, clip->getDuration() - distance); + const float rate = clip->getSampleRate() > 0.f ? clip->getSampleRate() : 30.f; + if (distance <= static_cast(ignoreRadius_) / rate + 1e-5f) continue; } - const auto& f = database_->frameAt(i); - const float c = cost(query, f.feature, bestCost); - if (c < bestCost) { + const float bias = filtered_ ? candidateBias_[i] : 0.f; + const float c = cost(query, database_->frameAt(i).feature, switchCost - bias) + bias; + if (c + 1e-5f < switchCost) { bestCost = c; - best = i; + switchCost = c; + best = i; } } - - // If everything skipped, fall back to exhaustive. - if (!std::isfinite(bestCost)) { - for (int i = 0; i < database_->getFrameCount(); ++i) { - const auto& f = database_->frameAt(i); - const float c = cost(query, f.feature, bestCost); - if (c < bestCost) { - bestCost = c; - best = i; - } - } + if (best == continuation && continuation >= 0) { + matchedFrame_ = continuation; + lastCost_ = bestCost; + updatePlayRate(continuation); + return; } + // A database containing only an exhausted, non-reselectable one-shot holds + // its terminal pose. It remains searchable after the caller changes candidates. + if (best < 0) return; lastCost_ = bestCost; - if (best != matchedFrame_) { - fromPose_.copyFrom(&pose_); + updatePlayRate(best); + if (best != matchedFrame_ || ended) { matchedFrame_ = best; matchedTime_ = database_->getFrameTime(best); sampleMatched(&matchedPose_); if (blendTime_ > 1e-6f && playing_) { + AnimPose previousTarget; + database_->getClip(getMatchedClipIndex()) + ->sample(matchedTime_ - inertia_->historyInterval * playRate_, &previousTarget, skeleton_); + inertia_->begin(pose_, matchedPose_, previousTarget, blendTime_); blending_ = true; blendElapsed_ = 0.f; } else { @@ -212,29 +331,57 @@ void MotionMatcher::updateUnchecked(float dt) { searchTimer_ = 0.f; } else { searchTimer_ += dt; - if (searchTimer_ >= searchInterval_) { + const auto* clip = matchedFrame_ >= 0 ? database_->getClip(getMatchedClipIndex()) : nullptr; + const bool ended = clip && !clip->getLoop() && matchedTime_ >= clip->getDuration(); + if (searchTimer_ >= searchInterval_ || ended) { searchTimer_ = 0.f; search(); } } + inertia_->remember(pose_, dt); if (matchedFrame_ >= 0) { - matchedTime_ += dt; + matchedTime_ += dt * playRate_; + const auto* clip = database_->getClip(getMatchedClipIndex()); + if (!clip->getLoop()) matchedTime_ = std::min(matchedTime_, clip->getDuration()); sampleMatched(&matchedPose_); } if (blending_) { blendElapsed_ += dt; - float t = blendTime_ > 1e-8f ? blendElapsed_ / blendTime_ : 1.f; - if (t >= 1.f) { + if (blendElapsed_ >= inertia_->duration) { blending_ = false; pose_.copyFrom(&matchedPose_); } else { - pose_.blendFrom(&fromPose_, &matchedPose_, t); + inertia_->apply(matchedPose_, blendElapsed_, pose_); } } else if (matchedFrame_ >= 0) { pose_.copyFrom(&matchedPose_); } + updatePoseHistory(dt); +} + +void MotionMatcher::updatePoseHistory(float dt) { + if (poseReselectHistory_ <= 0.f || dt <= 0.f || matchedFrame_ < 0) return; + for (auto& item : poseHistory_) item.second += dt; + std::erase_if(poseHistory_, [&](const auto& item) { return item.second > poseReselectHistory_; }); + const int clipIndex = getMatchedClipIndex(); + const auto* clip = database_->getClip(clipIndex); + const float time = clip->wrapTime(matchedTime_); + int nearestFrame = -1; float nearest = std::numeric_limits::infinity(); + const int count = filtered_ ? static_cast(candidateFrames_.size()) : database_->getFrameCount(); + for (int candidate = 0; candidate < count; ++candidate) { + const int i = filtered_ ? candidateFrames_[candidate] : candidate; + if (database_->getFrameClipIndex(i) != clipIndex) continue; + float distance = std::abs(database_->getFrameTime(i) - time); + if (clip->getLoop()) distance = std::min(distance, clip->getDuration() - distance); + if (distance < nearest) {nearest = distance; nearestFrame = i;} + } + if (nearestFrame < 0) return; + auto found = std::find_if(poseHistory_.begin(), poseHistory_.end(), + [nearestFrame](const auto& item) { return item.first == nearestFrame; }); + if (found == poseHistory_.end()) poseHistory_.push_back({nearestFrame, 0.f}); + else found->second = 0.f; } eve::Result MotionMatcher::advance(const eve::SimulationStep& step) { diff --git a/src/modules/animation/MotionMatcher.h b/src/modules/animation/MotionMatcher.h index 1a7b5808..01805dd5 100644 --- a/src/modules/animation/MotionMatcher.h +++ b/src/modules/animation/MotionMatcher.h @@ -3,22 +3,68 @@ #include "animation/AnimPose.h" #include "common/Time.h" +#include +#include +#include #include namespace eve::animation { +namespace detail { +struct PoseInertia; +} class AnimSkeleton; class MotionDatabase; +struct MotionFeatureTrajectorySample; +struct MotionFeatureCurveSample; + +/** @brief Copied clip-time interval forbidding new matches, with inclusive start and exclusive end. */ +struct MotionTransitionBlock { + float start = 0.f; + float end = 0.f; +}; +/** @brief Copied clip-time interval overriding a matching cost, with inclusive start and exclusive end. */ +struct MotionCostOverride { + float start = 0.f; + float end = 0.f; + float costBias = 0.f; +}; +/** @brief Owning candidate interval in seconds in a database clip; endpoints inclusive. */ +struct MotionSearchRange { + int clipIndex = 0; + float start = 0.f; + float end = 0.f; + float costBias = 0.f; + bool disableReselection = false; + /** @brief Owned transition blocks, unioned across all ranges of this clip; continuation remains eligible. */ + std::vector transitionBlocks; + /** @brief Default extra cost for continuing a pose selected through this range. */ + float continuingCostBias = 0.f; + /** @brief Owned absolute candidate-cost overrides, evaluated in order so the last active interval wins. */ + std::vector costOverrides; + /** @brief Owned continuing-cost overrides, evaluated in order so the last active interval wins. */ + std::vector continuingCostOverrides; +}; +/** @brief Predicted world-space displacement and facing at a fixed future horizon. */ +struct MotionTrajectorySample { + float x = 0.f, z = 0.f, yaw = 0.f; +}; +/** @brief Copied world displacement, velocity (metres/second), and yaw for a locomotion horizon. */ +struct MotionLocomotionSample { + float x = 0.f, y = 0.f, z = 0.f; + float vx = 0.f, vy = 0.f, vz = 0.f; + float yaw = 0.f; +}; /** * @brief Runtime motion matching player: builds a query feature from current pose + - * desired trajectory, searches MotionDatabase, cross-fades to best match. + * desired trajectory, searches MotionDatabase, inertializes into the moving best match. * Script type: `MotionMatcher`. */ class MotionMatcher { public: MotionMatcher(AnimSkeleton* skeleton, MotionDatabase* database); - ~MotionMatcher() = default; + ~MotionMatcher(); MotionMatcher(const MotionMatcher&) = delete; MotionMatcher& operator=(const MotionMatcher&) = delete; @@ -39,6 +85,20 @@ public: float getSearchInterval() const { return searchInterval_; } void setBlendTime(float seconds); float getBlendTime() const { return blendTime_; } + /** + * @brief Configure the inclusive playback-rate interval used to reconcile query and selected trajectory speed. + * @param minimum Finite positive lower rate. + * @param maximum Finite upper rate greater than or equal to minimum; both values are at most ten. + * @return Applied, or InvalidArgument without changing the prior interval or current rate. + * @details Variable-layout databases sum every unnormalized trajectory velocity channel, matching UE Pose Search. + * Other layouts use rate one clamped to this interval. Configuration and playback are deterministic for the same + * simulation/query sequence. The matcher owns all values and retains no caller memory. + * @thread Owner simulation thread outside advance/search; not reentrant. + */ + [[nodiscard]] eve::Result setPlayRateRange(float minimum, float maximum); + float getPlayRateMinimum() const { return playRateMin_; } + float getPlayRateMaximum() const { return playRateMax_; } + float getPlayRate() const { return playRate_; } void setTrajectoryWeight(float w); float getTrajectoryWeight() const { return trajWeight_; } void setPoseWeight(float w); @@ -46,9 +106,18 @@ public: void setVelocityWeight(float w); float getVelocityWeight() const { return velWeight_; } - /** @brief Skip rematching the same frame / nearby frames to reduce jitter. */ + /** @brief Keep continuous playback within this frame radius of the live playhead, including loop seams. */ void setIgnoreRadius(int frames); int getIgnoreRadius() const { return ignoreRadius_; } + /** @brief Set how long an already selected baked pose is excluded from new candidates. + * @param seconds Finite duration in [0,10]. Zero clears and disables history. + * @return Applied, or InvalidArgument preserving the previous duration and history. + * @details The continuing pose remains eligible. History is owned by this matcher and + * advances only through positive simulation steps. No callbacks or retained inputs. + * @thread Owner thread outside search/advance; not reentrant. + */ + [[nodiscard]] eve::Result setPoseReselectHistory(float seconds); + float getPoseReselectHistory() const { return poseReselectHistory_; } int getMatchedFrame() const { return matchedFrame_; } int getMatchedClipIndex() const; @@ -57,6 +126,95 @@ public: AnimPose* getPose(); + /** @brief Atomically replace searchable intervals, retaining original database frame IDs. + * @param ranges + * Borrowed for this synchronous call only; copied into an owning frame selection. + * @return Applied candidate + * count, or a diagnostic leaving the old selection unchanged. + * @details Empty, nonfinite, invalid clip/time + * ranges and ranges containing no frames are rejected. + * A fresh matcher searches all frames until configured. + * Transition blocks only reject new matches, not eligible continuation. Their + * finite nonnegative intervals must have start < end; an end beyond clip + * duration can protect the terminal sample. At most 100000 blocks are accepted. + * The returned count includes blocked frames eligible for continuation. + * Continuation is retained only while the live playhead is represented by + * a frame in the replacement ranges; another interval of the same clip + * cannot retain an out-of-range playhead. + * Candidate and continuing cost overrides use the same interval semantics; + * the last active override in a range replaces its corresponding default. + * Duplicate ranges retain the continuing cost paired with the lowest + * candidate cost for that sampled frame. + * Database must stay immutable thereafter. + * @thread Owner thread outside advance/search; no callbacks, + * reentrancy, or retained input pointers. + */ + [[nodiscard]] eve::Result setCandidateRanges(std::span ranges); + /** @brief Copy the character's current local pose for the next search, including action handoffs. + * @param + * pose Borrowed pose with matching bone count; never retained by reference. + * @return Applied or a diagnostic + * leaving the previous query unchanged. + * @thread Owner thread outside advance/search; no callbacks or + * reentrancy. + */ + [[nodiscard]] eve::Result setQueryPose(const AnimPose& pose); + /** @brief Set predictions at 0.33, 0.66, and 1.0 seconds instead of constant-velocity prediction. + * @param + * samples Borrowed exactly three finite world displacements and world yaw angles, copied here. + * @return + * Applied or diagnostic without changing existing predictions. + * @thread Owner thread outside advance/search; + * no callbacks, reentrancy, or retained input pointers. + */ + [[nodiscard]] eve::Result setTrajectory(std::span samples); + + /** @brief Atomically copy pose history and trajectory for a locomotion database. + * @param current Current + * local pose, matching this skeleton's bone count. + * @param previous Previous local pose; a copy of current is + * valid at a cold start. + * @param elapsedSeconds Positive finite simulation interval between poses, at most + * one second. + * @param samples Exactly five finite world displacements/velocities/yaws at -0.05, 0, 0.35, 0.7, + * 1 seconds. + * @return Applied, or a diagnostic leaving every query input unchanged. + * @details Pose + * samples supply cold-start/action-handoff features. Eligible continuing poses + * supply pose features during + * playback; trajectory always comes from character prediction. + * All inputs are copied; database and skeleton + * are borrowed and must outlive this matcher. + * Deterministic for the same ordered simulation samples within + * floating-point tolerance. + * @thread Owner thread outside search/advance; no callbacks or reentrancy. + */ + [[nodiscard]] eve::Result setLocomotionQuery(const AnimPose& current, const AnimPose& previous, + float elapsedSeconds, + std::span samples); + + /** @brief Atomically copy a query for the database's variable feature layout. + * @param current Current local pose, borrowed for this call. + * @param previous Previous local pose, borrowed for this call; may equal current at cold start. + * @param elapsedSeconds Finite positive simulation interval, at most one second. + * @param samples Borrowed trajectory samples with unique times covering every + * configured trajectory channel; all values must be finite. Copied, not retained. + * @param curves Borrowed evaluated scalar values covering each configured curve/time, + * copied during this call. Missing authored curves must be supplied as zero. + * @return Applied, or InvalidArgument preserving the complete previous query. + * @details Values use metres/seconds. Trajectories are encoded using the desired + * yaw at this call; setDesiredYaw must precede this setter each frame. + * Pose channels use the same encoder as + * database baking; continuing-policy channels use a valid continuing frame + * during search. The caller-owned skeleton/database must outlive this matcher + * and remain immutable. No callbacks or reentrancy. + * @thread Owner thread, outside search/advance. + */ + [[nodiscard]] eve::Result setFeatureQuery(const AnimPose& current, const AnimPose& previous, + float elapsedSeconds, + std::span samples, + std::span curves = {}); + /** @brief Force an immediate search (also called periodically from update). */ void search(); @@ -66,6 +224,8 @@ public: void update(float dt); private: + void buildSchemaQuery(std::vector& query) const; + void buildLocomotionQuery(std::vector& query) const; void buildQuery(std::vector& query) const; float cost(const std::vector& query, const std::vector& cand, float upperBound) const; void sampleMatched(AnimPose* out) const; @@ -73,7 +233,7 @@ private: AnimSkeleton* skeleton_ = nullptr; MotionDatabase* database_ = nullptr; AnimPose pose_; - AnimPose fromPose_; + std::unique_ptr inertia_; AnimPose matchedPose_; float desiredVelX_ = 0.f; @@ -85,11 +245,17 @@ private: float blendTime_ = 0.15f; float blendElapsed_ = 0.f; bool blending_ = false; + float playRateMin_ = 1.f; + float playRateMax_ = 1.f; + float playRate_ = 1.f; + float queryTrajectorySpeed_ = 0.f; float trajWeight_ = 1.f; float poseWeight_ = 1.f; float velWeight_ = 1.f; int ignoreRadius_ = 2; + float poseReselectHistory_ = 0.f; + std::vector> poseHistory_; int matchedFrame_ = -1; float matchedTime_ = 0.f; @@ -98,7 +264,26 @@ private: eve::SimulationTick lastTick_ = eve::SimulationTick::zero(); bool hasLastTick_ = false; + std::vector candidateFrames_; + std::vector candidateBias_; + std::vector candidateContinuingBias_; + std::vector candidateMask_; + std::vector transitionBlocked_; + std::vector disableReselection_; + bool filtered_ = false; + AnimPose queryPose_; + bool hasQueryPose_ = false; + std::array trajectory_{}; + bool hasTrajectory_ = false; + AnimPose previousQueryPose_; + float queryPoseInterval_ = 0.f; + std::array locomotionTrajectory_{}; + bool hasLocomotionQuery_ = false; + std::vector schemaQuery_; + void updateUnchecked(float dt); + void updatePoseHistory(float dt); + void updatePlayRate(int frame); }; } // namespace eve::animation diff --git a/src/modules/animation/MotionMatcherLocomotion.cpp b/src/modules/animation/MotionMatcherLocomotion.cpp new file mode 100644 index 00000000..d2f83d0b --- /dev/null +++ b/src/modules/animation/MotionMatcherLocomotion.cpp @@ -0,0 +1,64 @@ +#include +#include +#include "animation/MotionDatabase.h" +#include "animation/MotionFeatureInternal.h" +#include "animation/MotionMatcher.h" +#include "common/Exception.h" + +namespace eve::animation { +eve::Result MotionMatcher::setLocomotionQuery(const AnimPose& current, const AnimPose& previous, + float elapsedSeconds, + std::span samples) { + auto finitePose = [](const AnimPose& pose) { + for (int i = 0; i < pose.getBoneCount(); ++i) { + const auto& t = pose.local(i); + for (float v : {t.px, t.py, t.pz, t.qx, t.qy, t.qz, t.qw, t.sx, t.sy, t.sz}) + if (!std::isfinite(v)) return false; + } + return true; + }; + if (!database_->hasLocomotionFeatures() || current.getBoneCount() != pose_.getBoneCount() || + previous.getBoneCount() != pose_.getBoneCount() || !std::isfinite(elapsedSeconds) || elapsedSeconds <= 0.f || + elapsedSeconds > 1.f || samples.size() != 5 || !finitePose(current) || !finitePose(previous) || + std::any_of(samples.begin(), samples.end(), [](const auto& s) { + for (float v : {s.x, s.y, s.z, s.vx, s.vy, s.vz, s.yaw}) + if (!std::isfinite(v)) return true; + return false; + })) + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, + "locomotion query requires the locomotion layout, finite matching poses, five samples, and dt in (0,1]", + "locomotionQuery", {}, "animation")); + AnimPose nextCurrent, nextPrevious; + nextCurrent.copyFrom(¤t); + nextPrevious.copyFrom(&previous); + queryPose_ = std::move(nextCurrent); + previousQueryPose_ = std::move(nextPrevious); + std::copy(samples.begin(), samples.end(), locomotionTrajectory_.begin()); + queryPoseInterval_ = elapsedSeconds; + hasLocomotionQuery_ = true; + hasQueryPose_ = true; + return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); +} + +void MotionMatcher::buildLocomotionQuery(std::vector& query) const { + if (!hasLocomotionQuery_) throw Exception("MotionMatcher: configure a locomotion query before searching"); + query.assign(detail::locomotionFeatureCount, 0.f); + detail::encodeLocomotionTrajectory(std::span(query), locomotionTrajectory_, desiredYaw_); + AnimPose current, previous; + current.copyFrom(&queryPose_); + previous.copyFrom(&previousQueryPose_); + current.computeWorld(skeleton_); + previous.computeWorld(skeleton_); + std::array now{}, past{}; + now[0] = current.world(database_->getRootBone()); + past[0] = previous.world(database_->getRootBone()); + for (int i = 0; i < 3; ++i) { + const int bone = database_->getFeatureBone(i); + now[i + 1] = current.world(bone); + past[i + 1] = previous.world(bone); + } + detail::encodeLocomotionPose(query, now, past, queryPoseInterval_); + database_->normalizeFeature(query); +} +} // namespace eve::animation diff --git a/src/modules/animation/MotionMatcherSchema.cpp b/src/modules/animation/MotionMatcherSchema.cpp new file mode 100644 index 00000000..af04e12d --- /dev/null +++ b/src/modules/animation/MotionMatcherSchema.cpp @@ -0,0 +1,107 @@ +#include +#include "animation/AnimSkeleton.h" +#include "animation/MotionDatabase.h" +#include "animation/MotionMatcher.h" +#include "animation/MotionSchemaInternal.h" +#include "common/Exception.h" + +namespace eve::animation { +eve::Result MotionMatcher::setFeatureQuery(const AnimPose& current, const AnimPose& previous, + float dt, std::span samples, + std::span curves) { + auto fail = [](const char* message) { + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, message, "featureQuery", {}, "animation")); + }; + if (!database_->schema_ || !database_->isBaked() || !std::isfinite(dt) || dt <= 0.f || dt > 1.f || + current.getBoneCount() != skeleton_->getBoneCount() || previous.getBoneCount() != skeleton_->getBoneCount() || samples.size() > 256 || curves.size() > 256) + return fail("feature query requires a baked variable layout, matching poses and finite dt in (0,1]"); + for (const auto* pose : {¤t, &previous}) for (int i = 0; i < pose->getBoneCount(); ++i) { + const auto& t = pose->local(i); + for (float v : {t.px,t.py,t.pz,t.qx,t.qy,t.qz,t.qw,t.sx,t.sy,t.sz}) + if (!std::isfinite(v)) return fail("feature query poses must be finite"); + } + for (std::size_t i = 0; i < samples.size(); ++i) { + const auto& s = samples[i]; + for (float v : {s.seconds,s.x,s.y,s.z,s.vx,s.vy,s.vz,s.yaw}) + if (!std::isfinite(v)) return fail("trajectory samples must be finite"); + for (std::size_t j = 0; j < i; ++j) + if (std::abs(s.seconds-samples[j].seconds) < 1e-6f) return fail("trajectory sample times must be unique"); + } + const auto& schema = *database_->schema_; + for (std::size_t i = 0; i < curves.size(); ++i) { + const auto& c = curves[i]; + if (c.curve.empty() || c.curve.size() > 4096 || !std::isfinite(c.seconds) || !std::isfinite(c.value)) + return fail("curve queries require bounded names and finite times/values"); + for (std::size_t j = 0; j < i; ++j) + if (c.curve == curves[j].curve && std::abs(c.seconds-curves[j].seconds) < 1e-6f) + return fail("curve query names and times must be unique"); + } + std::vector scalarValues(schema.layout.channels.size(), 0.f); + for (std::size_t i = 0; i < schema.layout.channels.size(); ++i) { + const auto& c = schema.layout.channels[i]; + if (c.kind != MotionFeatureKind::Curve) continue; + auto sample = std::find_if(curves.begin(), curves.end(), [&](const auto& s) { + return s.curve == c.curve && std::abs(s.seconds-c.sampleTime) < 1e-6f; + }); + if (sample == curves.end()) return fail("curve query does not cover a configured channel/time"); + scalarValues[i] = sample->value; + } + std::vector selected; + for (const auto& c : schema.layout.channels) { + const MotionFeatureTrajectorySample* sample = nullptr; + if (c.source == MotionFeatureSource::Trajectory) { + for (const auto& s : samples) if (std::abs(s.seconds-c.sampleTime) < 1e-6f) { sample = &s; break; } + if (!sample) return fail("trajectory query does not cover a configured sample time"); + } + selected.push_back(sample); + } + AnimPose now, past; + now.copyFrom(¤t); past.copyFrom(&previous); + now.computeWorld(skeleton_); past.computeWorld(skeleton_); + const auto& root = now.world(database_->getRootBone()); + const auto& previousRoot = past.world(database_->getRootBone()); + TransformTRS character; + character.qy = std::sin(desiredYaw_ * .5f); character.qw = std::cos(desiredYaw_ * .5f); + std::vector query(schema.dimension, 0.f); + for (std::size_t i = 0; i < schema.layout.channels.size(); ++i) { + const auto& c = schema.layout.channels[i]; + std::array value{}; + if (c.kind == MotionFeatureKind::Curve) value[0] = scalarValues[i]; + else if (const auto* sample = selected[i]) { + if (c.kind == MotionFeatureKind::Position) value = detail::schemaRotate({sample->x,sample->y,sample->z}, character, true); + else if (c.kind == MotionFeatureKind::Velocity) value = detail::schemaRotate({sample->vx,sample->vy,sample->vz}, character, true); + else { + TransformTRS rotation; + rotation.qy = std::sin(sample->yaw * .5f); rotation.qw = std::cos(sample->yaw * .5f); + value = detail::schemaHeading(rotation, character, c.headingAxis); + } + } else { + const auto& bone = now.world(c.bone); + if (c.kind == MotionFeatureKind::Position) value = detail::schemaDifference(bone, now.world(c.origin), root); + else if (c.kind == MotionFeatureKind::Heading) value = detail::schemaHeading(bone, root, c.headingAxis); + else if (c.characterSpaceVelocity) { + value = detail::schemaDifference(bone, now.world(c.origin), root); + const auto old = detail::schemaDifference(past.world(c.bone), past.world(c.origin), previousRoot); + for (int axis = 0; axis < 3; ++axis) value[axis] = (value[axis] - old[axis]) / dt; + } else { + value = detail::schemaDifference(bone, past.world(c.bone), root); + for (float& v : value) v /= dt; + } + } + detail::schemaEncode(std::span(query).subspan(schema.offsets[i]), c, value, schema.layout.normalizationLengthScale); + } + for (float v : query) if (!std::isfinite(v)) return fail("encoded feature query exceeds finite range"); + const float queryTrajectorySpeed = database_->schemaTrajectorySpeed(query); + database_->normalizeFeature(query); + for (float v : query) if (!std::isfinite(v)) return fail("normalized feature query exceeds finite range"); + schemaQuery_.swap(query); + queryTrajectorySpeed_ = queryTrajectorySpeed; + return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); +} + +void MotionMatcher::buildSchemaQuery(std::vector& query) const { + if (schemaQuery_.empty()) throw Exception("MotionMatcher: configure a feature query before searching"); + query = schemaQuery_; +} +} // namespace eve::animation diff --git a/src/modules/animation/MotionMatcherSelection.cpp b/src/modules/animation/MotionMatcherSelection.cpp new file mode 100644 index 00000000..1ff426c0 --- /dev/null +++ b/src/modules/animation/MotionMatcherSelection.cpp @@ -0,0 +1,106 @@ +#include +#include +#include +#include "animation/AnimClip.h" +#include "animation/MotionDatabase.h" +#include "animation/MotionMatcher.h" + +namespace eve::animation { +eve::Result MotionMatcher::setCandidateRanges(std::span ranges) { + auto fail = [](const char* message) { + return eve::Result::failure( + eve::Diagnostic::error(eve::DiagnosticCode::InvalidArgument, message, "candidateRanges", {}, "animation")); + }; + if (!database_->isBaked() || ranges.empty() || ranges.size() > 100000) + return fail("candidate selection requires a baked database and bounded nonempty ranges"); + std::vector> perClip(database_->getClipCount()); + std::size_t intervalCount = 0; + for (const auto& r : ranges) { + if (r.clipIndex < 0 || r.clipIndex >= database_->getClipCount() || !std::isfinite(r.start) || + !std::isfinite(r.end) || !std::isfinite(r.costBias) || r.start < 0.f || r.end < r.start || + r.end > database_->getClip(r.clipIndex)->getDuration() + 0.001f) + return fail("invalid candidate clip, time interval, or cost bias"); + intervalCount += r.transitionBlocks.size() + r.costOverrides.size() + r.continuingCostOverrides.size(); + if (intervalCount > 100000) return fail("too many candidate metadata intervals"); + for (const auto& block : r.transitionBlocks) + if (!std::isfinite(block.start) || !std::isfinite(block.end) || block.start < 0.f || + block.end <= block.start) + return fail("invalid transition block interval"); + if (!std::isfinite(r.continuingCostBias)) return fail("invalid continuing cost bias"); + for (const auto* overrides : {&r.costOverrides, &r.continuingCostOverrides}) + for (const auto& cost : *overrides) + if (!std::isfinite(cost.start) || !std::isfinite(cost.end) || !std::isfinite(cost.costBias) || + cost.start < 0.f || cost.end <= cost.start) + return fail("invalid cost override interval"); + perClip[r.clipIndex].push_back(r); + } + const int count = database_->getFrameCount(); + std::vector selected; + std::vector bias(count, 0.f); + std::vector continuingBias(count, 0.f); + std::vector mask(count, 0), noReselect(database_->getClipCount(), 0); + std::vector blocked(count, 0); + for (int clipIndex = 0; clipIndex < database_->getClipCount(); ++clipIndex) { + const auto& clipRanges = perClip[clipIndex]; + if (clipRanges.empty()) continue; + const int begin = database_->clipFrameOffsets_[static_cast(clipIndex)]; + const int end = database_->clipFrameOffsets_[static_cast(clipIndex + 1)]; + for (int i = begin; i < end; ++i) { + const auto& frame = database_->frameAt(i); + float best = std::numeric_limits::infinity(); + for (const auto& r : clipRanges) { + for (const auto& block : r.transitionBlocks) + if (frame.time >= block.start && frame.time < block.end) blocked[i] = 1; + if (frame.time + 1e-5f < r.start || frame.time - 1e-5f > r.end) continue; + float rangeBias = r.costBias; + for (const auto& cost : r.costOverrides) + if (frame.time >= cost.start && frame.time < cost.end) rangeBias = cost.costBias; + float rangeContinuingBias = r.continuingCostBias; + for (const auto& cost : r.continuingCostOverrides) + if (frame.time >= cost.start && frame.time < cost.end) rangeContinuingBias = cost.costBias; + if (rangeBias < best) { + best = rangeBias; + continuingBias[i] = rangeContinuingBias; + } + if (r.disableReselection) noReselect[frame.clipIndex] = 1; + } + if (!std::isfinite(best)) continue; + selected.push_back(i); + mask[i] = 1; + bias[i] = best; + } + } + if (selected.empty()) return fail("candidate selection contains no sampled frames"); + candidateFrames_.swap(selected); + candidateBias_.swap(bias); + candidateContinuingBias_.swap(continuingBias); + candidateMask_.swap(mask); + transitionBlocked_.swap(blocked); + disableReselection_.swap(noReselect); + filtered_ = true; + return eve::Result::success(static_cast(candidateFrames_.size()), + eve::Status::success(eve::StatusCode::Applied)); +} + +eve::Result MotionMatcher::setQueryPose(const AnimPose& pose) { + if (database_->hasLocomotionFeatures() || database_->hasFeatureLayout() || pose.getBoneCount() != pose_.getBoneCount()) + return eve::Result::failure(eve::Diagnostic::error(eve::DiagnosticCode::InvalidArgument, + "basic query pose requires the basic layout and matching bone count", + "queryPose", {}, "animation")); + queryPose_.copyFrom(&pose); + hasQueryPose_ = true; + return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); +} + +eve::Result MotionMatcher::setTrajectory(std::span samples) { + if (database_->hasLocomotionFeatures() || database_->hasFeatureLayout() || samples.size() != 3 || std::any_of(samples.begin(), samples.end(), [](const auto& s) { + return !std::isfinite(s.x) || !std::isfinite(s.z) || !std::isfinite(s.yaw); + })) + return eve::Result::failure(eve::Diagnostic::error(eve::DiagnosticCode::InvalidArgument, + "trajectory requires three finite samples", + "trajectory", {}, "animation")); + std::copy(samples.begin(), samples.end(), trajectory_.begin()); + hasTrajectory_ = true; + return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); +} +} // namespace eve::animation diff --git a/src/modules/animation/MotionSchemaInternal.h b/src/modules/animation/MotionSchemaInternal.h new file mode 100644 index 00000000..7e313675 --- /dev/null +++ b/src/modules/animation/MotionSchemaInternal.h @@ -0,0 +1,49 @@ +#pragma once +#include +#include +#include +#include +#include "animation/AnimCurveLibrary.h" +#include "animation/AnimTransformInternal.h" +#include "animation/MotionFeatureLayout.h" + +namespace eve::animation { class AnimClip; } +namespace eve::animation::detail { +struct MotionSchemaState { + MotionFeatureLayout layout; + std::vector offsets; + std::vector weights; + std::vector sources; + std::vector kinds; + std::vector queries; + std::array weightSums{}; + int dimension = 0; + std::unique_ptr curves; + std::map curveSources; +}; +inline std::array schemaRotate(const std::array& p, const TransformTRS& q, bool inverse = false) { + TransformTRS r = q, v; + r.px = r.py = r.pz = 0.f; + r.sx = r.sy = r.sz = 1.f; + if (inverse) { r.qx = -r.qx; r.qy = -r.qy; r.qz = -r.qz; } + v.px = p[0]; v.py = p[1]; v.pz = p[2]; + const auto result = mulTRS(r, v); + return {result.px, result.py, result.pz}; +} +inline std::array schemaDifference(const TransformTRS& a, const TransformTRS& b, const TransformTRS& root) { + return schemaRotate({a.px - b.px, a.py - b.py, a.pz - b.pz}, root, true); +} +inline void schemaEncode(std::span output, const MotionFeatureChannel& channel, std::array value, float lengthScale) { + if (channel.kind == MotionFeatureKind::Velocity && channel.normalizeVelocity) { + const float divisor = std::max(.01f, std::sqrt(value[0]*value[0] + value[1]*value[1] + value[2]*value[2])); + for (float& component : value) component /= divisor; + } else if (channel.kind == MotionFeatureKind::Position || channel.kind == MotionFeatureKind::Velocity) + for (float& component : value) component *= lengthScale; + int index = 0; + for (int axis = 0; axis < 3; ++axis) if (channel.axes & (1 << axis)) output[index++] = value[axis]; +} +inline std::array schemaHeading(const TransformTRS& bone, const TransformTRS& root, int axis) { + std::array direction{}; direction[axis] = 1.f; + return schemaRotate(schemaRotate(direction, bone), root, true); +} +} // namespace eve::animation::detail diff --git a/src/modules/animation/PoseInertiaInternal.h b/src/modules/animation/PoseInertiaInternal.h new file mode 100644 index 00000000..7e7cb030 --- /dev/null +++ b/src/modules/animation/PoseInertiaInternal.h @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#include +#include +#include "animation/AnimPose.h" + +namespace eve::animation::detail { +// Finite-duration offsets relative to a moving target. Inputs are copied; +// simulation elapsed time is supplied by the owner, never wall-clock time. +struct PoseInertia { + using Channels = std::array; + std::vector offsets, velocities; + AnimPose previousOutput; + float historyInterval = 0.f; + float duration = 0.f; + + static Channels difference(const TransformTRS& source, const TransformTRS& target) { + auto a = source, b = target; + a.normalizeRotation(); + b.normalizeRotation(); + // log(source * inverse(target)), with hemisphere-independent shortest arc. + float x = -a.qw * b.qx + a.qx * b.qw - a.qy * b.qz + a.qz * b.qy; + float y = -a.qw * b.qy + a.qx * b.qz + a.qy * b.qw - a.qz * b.qx; + float z = -a.qw * b.qz - a.qx * b.qy + a.qy * b.qx + a.qz * b.qw; + float w = a.qw * b.qw + a.qx * b.qx + a.qy * b.qy + a.qz * b.qz; + if (w < 0.f) { + x = -x; + y = -y; + z = -z; + w = -w; + } + float length = std::sqrt(x * x + y * y + z * z); + float factor = length > 1e-8f ? 2.f * std::atan2(length, w) / length : 2.f; + return {a.px - b.px, a.py - b.py, a.pz - b.pz, x * factor, y * factor, + z * factor, a.sx - b.sx, a.sy - b.sy, a.sz - b.sz}; + } + + void begin(const AnimPose& source, const AnimPose& target, const AnimPose& previousTarget, float seconds) { + duration = seconds; + offsets.resize(source.getBoneCount()); + velocities.resize(source.getBoneCount()); + bool history = historyInterval > 1e-6f && previousOutput.getBoneCount() == source.getBoneCount(); + for (int bone = 0; bone < source.getBoneCount(); ++bone) { + offsets[bone] = difference(source.local(bone), target.local(bone)); + velocities[bone].fill(0.f); + if (!history) continue; + auto previous = difference(previousOutput.local(bone), previousTarget.local(bone)); + // At the pi branch cut, use the equivalent log closest to the current offset. + float dot = 0.f, length = 0.f; + for (int c = 3; c < 6; ++c) { + dot += previous[c] * offsets[bone][c]; + length += previous[c] * previous[c]; + } + length = std::sqrt(length); + if (dot < 0.f && length > 1.57079632679f) { + float factor = (length - 6.28318530718f) / length; + for (int c = 3; c < 6; ++c) previous[c] *= factor; + } + for (int c = 0; c < 9; ++c) velocities[bone][c] = (offsets[bone][c] - previous[c]) / historyInterval; + } + } + + void remember(const AnimPose& output, float dt) { + if (dt <= 0.f) return; + previousOutput.copyFrom(&output); + historyInterval = dt; + } + + void apply(const AnimPose& target, float elapsed, AnimPose& output, std::span factors = {}) const { + output.copyFrom(&target); + if (duration <= 0.f || elapsed >= duration) return; + for (int bone = 0; bone < target.getBoneCount(); ++bone) { + const float boneDuration = duration * (factors.empty() ? 1.f : factors[bone]); + if (boneDuration <= 0.f || elapsed >= boneDuration) continue; + float u = std::clamp(elapsed / boneDuration, 0.f, 1.f), u2 = u * u, u3 = u2 * u, u4 = u3 * u, u5 = u4 * u; + float position = 1.f - 10.f * u3 + 15.f * u4 - 6.f * u5; + float velocity = boneDuration * (u - 6.f * u3 + 8.f * u4 - 3.f * u5); + Channels value{}; + for (int c = 0; c < 9; ++c) value[c] = offsets[bone][c] * position + velocities[bone][c] * velocity; + auto& result = output.local(bone); + result.px += value[0]; + result.py += value[1]; + result.pz += value[2]; + result.sx += value[6]; + result.sy += value[7]; + result.sz += value[8]; + float angle = std::sqrt(value[3] * value[3] + value[4] * value[4] + value[5] * value[5]); + float factor = angle > 1e-8f ? std::sin(angle * .5f) / angle : .5f; + float x = value[3] * factor, y = value[4] * factor, z = value[5] * factor, w = std::cos(angle * .5f); + auto q = result; + result.qx = w * q.qx + x * q.qw + y * q.qz - z * q.qy; + result.qy = w * q.qy - x * q.qz + y * q.qw + z * q.qx; + result.qz = w * q.qz + x * q.qy - y * q.qx + z * q.qw; + result.qw = w * q.qw - x * q.qx - y * q.qy - z * q.qz; + result.normalizeRotation(); + } + } +}; +} // namespace eve::animation::detail diff --git a/src/modules/climbing/Climbing.cpp b/src/modules/climbing/Climbing.cpp index 86e17794..6743ef71 100644 --- a/src/modules/climbing/Climbing.cpp +++ b/src/modules/climbing/Climbing.cpp @@ -1,6 +1,7 @@ #include "climbing/Climbing.h" #include "climbing/ClimbingCodec.h" +#include "climbing/ClimbingRuntimeInternal.h" #include "climbing/ClimbingTrajectory.h" #include "animation/AnimClip.h" @@ -16,279 +17,8 @@ #include namespace eve::climbing { -namespace { +using namespace runtime_detail; -constexpr float epsilon = 1e-5f; -constexpr std::size_t maxDebugEntries = 64; - -class RuntimeTelemetryScope { -public: - RuntimeTelemetryScope(ClimbingTelemetryBuffer& buffer, ClimbingRuntimeCounters& counters, - eve::SimulationTick tick) noexcept - : buffer_(buffer), counters_(counters), tick_(tick), start_(std::chrono::steady_clock::now()) {} - - ~RuntimeTelemetryScope() { - const auto elapsed = std::chrono::steady_clock::now() - start_; - buffer_.record({tick_, static_cast( - std::chrono::duration_cast(elapsed).count()), - counters_}); - } - -private: - ClimbingTelemetryBuffer& buffer_; - ClimbingRuntimeCounters& counters_; - eve::SimulationTick tick_; - std::chrono::steady_clock::time_point start_; -}; - -template -void boundedDebugPush(std::vector& values, T value) { - if (values.size() < maxDebugEntries) values.push_back(std::move(value)); -} - -template -eve::Result climbingFailure(eve::DiagnosticCode code, std::string message, std::string path = {}) { - return eve::Result::failure(eve::Diagnostic::error(code, std::move(message), std::move(path), {}, "climbing")); -} - -bool finite(float value) { return std::isfinite(value); } - -bool finite(Vec3 value) { return finite(value.x) && finite(value.y) && finite(value.z); } - -float lengthSquared(Vec3 value) { return value.x * value.x + value.y * value.y + value.z * value.z; } -float length(Vec3 value) { return std::sqrt(lengthSquared(value)); } - -Vec3 operator+(Vec3 lhs, Vec3 rhs) { return {lhs.x + rhs.x, lhs.y + rhs.y, lhs.z + rhs.z}; } -Vec3 operator-(Vec3 lhs, Vec3 rhs) { return {lhs.x - rhs.x, lhs.y - rhs.y, lhs.z - rhs.z}; } -Vec3 operator*(Vec3 value, float scale) { return {value.x * scale, value.y * scale, value.z * scale}; } - -Vec3 normalizedHorizontal(Vec3 value) { - value.y = 0.f; - const float length = std::sqrt(lengthSquared(value)); - return length > epsilon ? value * (1.f / length) : Vec3{}; -} - -Vec3 clampMagnitude(Vec3 value, float maximum) { - const float magnitude = length(value); - return magnitude > maximum && magnitude > epsilon ? value * (maximum / magnitude) : value; -} - -struct WarpChannels { - bool horizontal = false; - bool vertical = false; - bool facing = false; -}; - -WarpChannels activeWarpChannels(const ClimbingActionDefinition& action, float normalizedTime) { - if (action.warpWindows.empty()) return {true, true, true}; - for (const ClimbingWarpWindow& window : action.warpWindows) - if (normalizedTime + epsilon >= window.start && normalizedTime <= window.end + epsilon) - return {window.horizontal, window.vertical, window.facing}; - return {}; -} - -bool activeBranchWindow(const ClimbingActionDefinition& action, float normalizedTime) { - return std::any_of(action.branchWindows.begin(), action.branchWindows.end(), [&](const auto& window) { - return normalizedTime + epsilon >= window.start && normalizedTime <= window.end + epsilon; - }); -} - -const std::string* activeBranchComboTag(const ClimbingActionDefinition& action, float normalizedTime) { - const auto found = std::find_if(action.branchWindows.begin(), action.branchWindows.end(), - [&](const auto& window) { - return normalizedTime + epsilon >= window.start && - normalizedTime <= window.end + epsilon; - }); - return found == action.branchWindows.end() ? nullptr : &found->comboTag; -} - -float activeContactWeight(const ClimbingActionDefinition& action, ClimbingContactTarget target, - float normalizedTime) { - float weight = 0.f; - for (const ClimbingContactConstraint& constraint : action.contactConstraints) { - if (constraint.target == target && normalizedTime + epsilon >= constraint.start && - normalizedTime <= constraint.end + epsilon) - weight = std::max(weight, constraint.maxWeight); - } - return weight; -} - -Vec3 terminalVelocityFor(const ClimbingActionDefinition& action, Vec3 actualDelta, float inverseDelta) { - Vec3 velocity = actualDelta * inverseDelta; - switch (action.landingPolicy) { - case ClimbingLandingPolicy::PreserveMomentum: break; - case ClimbingLandingPolicy::MatchGround: velocity.y = 0.f; break; - case ClimbingLandingPolicy::Stop: velocity = {}; break; - } - switch (action.terminalVelocityPolicy) { - case ClimbingTerminalVelocityPolicy::Preserve: break; - case ClimbingTerminalVelocityPolicy::ClampDownward: velocity.y = std::min(velocity.y, 0.f); break; - case ClimbingTerminalVelocityPolicy::Zero: velocity.y = 0.f; break; - } - return velocity; -} - -float finalWarpWindowEnd(const ClimbingActionDefinition& action) { - return action.warpWindows.empty() ? 1.f : action.warpWindows.back().end; -} - -float signedHorizontalAngle(Vec3 from, Vec3 to) { - from = normalizedHorizontal(from); - to = normalizedHorizontal(to); - if (lengthSquared(from) <= epsilon || lengthSquared(to) <= epsilon) return 0.f; - const float crossY = from.z * to.x - from.x * to.z; - const float dot = std::clamp(from.x * to.x + from.z * to.z, -1.f, 1.f); - return std::atan2(crossY, dot); -} - -bool isActivePhase(ClimbingPhase phase) { - return phase != ClimbingPhase::Idle && phase != ClimbingPhase::Completed && phase != ClimbingPhase::Cancelled && - phase != ClimbingPhase::Failed; -} - -bool isRuntimeProbeKind(ClimbingActionKind kind) { - return kind == ClimbingActionKind::Vault || kind == ClimbingActionKind::Mantle || - kind == ClimbingActionKind::LedgeGrab || kind == ClimbingActionKind::ClimbUp || - kind == ClimbingActionKind::WallRun || kind == ClimbingActionKind::Slide; -} - -bool isObstacleProbeKind(ClimbingActionKind kind) { - return kind == ClimbingActionKind::Vault || kind == ClimbingActionKind::Mantle || - kind == ClimbingActionKind::LedgeGrab || kind == ClimbingActionKind::ClimbUp; -} - -bool endsAtAnchorHang(ClimbingActionKind kind) { - return kind == ClimbingActionKind::LedgeGrab || kind == ClimbingActionKind::Shimmy || - kind == ClimbingActionKind::CornerInner || kind == ClimbingActionKind::CornerOuter || - kind == ClimbingActionKind::LedgeJump || kind == ClimbingActionKind::ClimbDown || - kind == ClimbingActionKind::LadderMount || kind == ClimbingActionKind::LadderClimb || - kind == ClimbingActionKind::BeamBalance || kind == ClimbingActionKind::PoleSwing || - kind == ClimbingActionKind::BarSwing; -} - -std::int64_t quantizeMillimeters(float value) { - const double scaled = std::round(static_cast(value) * 1000.0); - return static_cast(std::clamp(scaled, static_cast(std::numeric_limits::min()), - static_cast(std::numeric_limits::max()))); -} - -const ClimbingActionDefinition* findAction(const ClimbingProfile& profile, std::string_view id) { - const auto found = std::find_if(profile.actions.begin(), profile.actions.end(), - [id](const auto& action) { return action.id == id; }); - return found == profile.actions.end() ? nullptr : &*found; -} - -bool candidateLess(const ClimbingCandidate& lhs, const ClimbingCandidate& rhs) { - if (lhs.score != rhs.score) return lhs.score < rhs.score; - if (lhs.actionId != rhs.actionId) return lhs.actionId < rhs.actionId; - if (lhs.obstacleBodyId != rhs.obstacleBodyId) return lhs.obstacleBodyId < rhs.obstacleBodyId; - return lhs.obstacleShapeId < rhs.obstacleShapeId; -} - -bool containsAnyTag(const std::vector& actionTags, const std::vector& policyTags) { - return std::any_of(actionTags.begin(), actionTags.end(), [&](const std::string& tag) { - return std::find(policyTags.begin(), policyTags.end(), tag) != policyTags.end(); - }); -} - -bool actionEnabledForPose(const ClimbingProfile& profile, const ClimbingActionDefinition& action, - const ClimbingPose& pose) { - if (!profile.defaultActionIds.empty() && - std::find(profile.defaultActionIds.begin(), profile.defaultActionIds.end(), action.id) == - profile.defaultActionIds.end()) - return false; - if (!profile.allowedActionTags.empty() && !containsAnyTag(action.tags, profile.allowedActionTags)) return false; - if (containsAnyTag(action.tags, profile.deniedActionTags)) return false; - const auto required = pose.grounded ? ClimbingSourceMode::Grounded : ClimbingSourceMode::Airborne; - return (static_cast(action.sourceModes) & static_cast(required)) != 0; -} - -bool probeRecipeMatchesKind(const ClimbingActionDefinition& action) { - switch (action.probeRecipe) { - case ClimbingProbeRecipe::Automatic: return true; - case ClimbingProbeRecipe::Obstacle: - return action.kind == ClimbingActionKind::Vault || action.kind == ClimbingActionKind::Mantle; - case ClimbingProbeRecipe::Ledge: - return action.kind == ClimbingActionKind::LedgeGrab || action.kind == ClimbingActionKind::ClimbUp; - case ClimbingProbeRecipe::Wall: return action.kind == ClimbingActionKind::WallRun; - case ClimbingProbeRecipe::Ground: return action.kind == ClimbingActionKind::Slide; - case ClimbingProbeRecipe::AnchorGraph: return !isRuntimeProbeKind(action.kind); - } - return false; -} - -bool parseTagSelector(std::string_view selector, std::string_view prefix, int& value) { - if (!selector.starts_with(prefix)) return false; - const std::string_view digits = selector.substr(prefix.size()); - if (digits.empty()) return false; - const char* begin = digits.data(); - const char* end = begin + digits.size(); - const auto parsed = std::from_chars(begin, end, value); - return parsed.ec == std::errc{} && parsed.ptr == end; -} - -bool supportSelectorsMatch(const ClimbingActionDefinition& action, int shapeTag, int materialId) { - for (const std::string& selector : action.requiredSupportTags) { - int expected = 0; - if (parseTagSelector(selector, "shape:", expected)) { - if (shapeTag != expected) return false; - } else if (parseTagSelector(selector, "material:", expected)) { - if (materialId != expected) return false; - } else { - return false; - } - } - return true; -} - -std::int64_t weightedMillimeters(float value, std::int32_t weight) { - return quantizeMillimeters(value) * static_cast(weight); -} - -std::int64_t selectionCost(const ClimbingProfile& profile, const ClimbingActionDefinition& action, - const ClimbingPose& pose, Vec3 targetDirection, Vec3 targetDelta, - float heightError, float distance, std::int64_t stableTieBreak = 0) { - const Vec3 forward = normalizedHorizontal(pose.forward); - targetDirection = normalizedHorizontal(targetDirection); - if (lengthSquared(targetDirection) <= epsilon) targetDirection = forward; - - Vec3 intentDirection = normalizedHorizontal(pose.inputMode == ClimbingInputMode::Precision - ? pose.lookIntent - : pose.moveIntent); - if (lengthSquared(intentDirection) <= epsilon) intentDirection = forward; - - const float directionDot = std::clamp(forward.x * targetDirection.x + forward.z * targetDirection.z, - -1.f, 1.f); - const float intentDot = std::clamp(intentDirection.x * targetDirection.x + - intentDirection.z * targetDirection.z, - -1.f, 1.f); - const bool precision = pose.inputMode == ClimbingInputMode::Precision; - const float assistScale = 1.f - profile.autoAssistStrength * (precision ? 0.15f : 0.5f); - const float directionModeScale = precision ? 1.5f : 0.75f; - const float speedModeScale = precision ? 0.5f : 1.5f; - const float translationModeScale = precision ? 1.25f : 0.75f; - const float rotationModeScale = precision ? 1.5f : 0.75f; - const float intentModeScale = precision ? 1.5f : 0.5f; - const float horizontalDelta = std::sqrt(targetDelta.x * targetDelta.x + targetDelta.z * targetDelta.z); - const float warpTranslation = std::sqrt(horizontalDelta * horizontalDelta + targetDelta.y * targetDelta.y); - const float warpRotation = std::acos(directionDot); - const float intentMismatch = 1.f - intentDot; - - return static_cast(action.selectionBias) * 1000000ll + - weightedMillimeters((1.f - directionDot) * assistScale * directionModeScale, - profile.scoreWeights.direction) + - weightedMillimeters(std::fabs(pose.speed - action.minSpeed) * speedModeScale, - profile.scoreWeights.approachSpeed) + - weightedMillimeters(heightError, profile.scoreWeights.height) + - weightedMillimeters(distance, profile.scoreWeights.distance) + - weightedMillimeters(warpTranslation * translationModeScale, - profile.scoreWeights.warpTranslation) + - weightedMillimeters(warpRotation * rotationModeScale, profile.scoreWeights.warpRotation) + - weightedMillimeters(intentMismatch * intentModeScale, profile.scoreWeights.intentMismatch) + - stableTieBreak; -} - -} // namespace void ClimbingCandidateSet::consider(ClimbingCandidate candidate) { std::string actionId; @@ -311,7 +41,7 @@ void ClimbingCandidateSet::considerWithActionId(ClimbingCandidate candidate, std store(values_[size_++]); return; } - auto worst = std::max_element(values_.begin(), values_.end(), candidateLess); + auto worst = std::max_element(values_.begin(), values_.end(), isCandidateLess); const bool better = candidate.score != worst->score ? candidate.score < worst->score : actionId != worst->actionId @@ -323,7 +53,7 @@ void ClimbingCandidateSet::considerWithActionId(ClimbingCandidate candidate, std } void ClimbingCandidateSet::sortAndLimit(std::size_t limit) { - std::sort(values_.begin(), values_.begin() + static_cast(size_), candidateLess); + std::sort(values_.begin(), values_.begin() + static_cast(size_), isCandidateLess); size_ = std::min(size_, std::min(limit, Capacity)); } @@ -453,8 +183,8 @@ eve::Result ClimbingRuntime::probeInto(physics::World3D& world, const Clim if (!world.isValid()) return climbingFailure(eve::DiagnosticCode::StaleHandle, "physics world is no longer valid", "world"); - if (!finite(pose.feet) || !finite(pose.forward) || !finite(pose.speed) || pose.speed < 0.f || - !finite(pose.verticalSpeed) || !finite(pose.moveIntent) || !finite(pose.lookIntent)) + if (!isFinite(pose.feet) || !isFinite(pose.forward) || !isFinite(pose.speed) || pose.speed < 0.f || + !isFinite(pose.verticalSpeed) || !isFinite(pose.moveIntent) || !isFinite(pose.lookIntent)) return climbingFailure( eve::DiagnosticCode::InvalidArgument, "pose values must be finite and speed non-negative", "pose"); const Vec3 forward = normalizedHorizontal(pose.forward); @@ -518,8 +248,8 @@ eve::Result ClimbingRuntime::probeInto(physics::World3D& world, const Clim }; const bool hasSlide = std::any_of(profile_.actions.begin(), profile_.actions.end(), [&](const auto& action) { - return action.kind == ClimbingActionKind::Slide && actionEnabledForPose(profile_, action, pose) && - probeRecipeMatchesKind(action); + return action.kind == ClimbingActionKind::Slide && isActionEnabledForPose(profile_, action, pose) && + isProbeRecipeMatchingKind(action); }); if (hasSlide && pose.grounded) { const Vec3 groundStart{pose.feet.x, pose.feet.y + 0.25f, pose.feet.z}; @@ -534,8 +264,8 @@ eve::Result ClimbingRuntime::probeInto(physics::World3D& world, const Clim groundStart, groundEnd}); if (ground.hit && broadPhaseContains(ground.shape) && ground.normalY >= profile_.minTopNormalY) { for (const auto& action : profile_.actions) { - if (action.kind != ClimbingActionKind::Slide || !actionEnabledForPose(profile_, action, pose) || - !probeRecipeMatchesKind(action)) + if (action.kind != ClimbingActionKind::Slide || !isActionEnabledForPose(profile_, action, pose) || + !isProbeRecipeMatchingKind(action)) continue; if (pose.speed + epsilon < action.minSpeed) { rejectCandidate(); @@ -543,7 +273,7 @@ eve::Result ClimbingRuntime::probeInto(physics::World3D& world, const Clim boundedDebugPush(lastEvidence_, ClimbingCandidateEvidence{action.id, "below_min_speed"}); continue; } - if (!supportSelectorsMatch(action, ground.shapeTag, ground.materialId)) { + if (!isSupportSelectorMatch(action, ground.shapeTag, ground.materialId)) { rejectCandidate(); if (debugCapture_ == ClimbingDebugCapture::Enabled) boundedDebugPush(lastEvidence_, ClimbingCandidateEvidence{action.id, "support_tag_rejected"}); @@ -597,8 +327,8 @@ eve::Result ClimbingRuntime::probeInto(physics::World3D& world, const Clim } const bool hasWallRun = std::any_of(profile_.actions.begin(), profile_.actions.end(), [&](const auto& action) { - return action.kind == ClimbingActionKind::WallRun && actionEnabledForPose(profile_, action, pose) && - probeRecipeMatchesKind(action); + return action.kind == ClimbingActionKind::WallRun && isActionEnabledForPose(profile_, action, pose) && + isProbeRecipeMatchingKind(action); }); if (hasWallRun) { const Vec3 chest{pose.feet.x, pose.feet.y + profile_.capsuleHeight * 0.55f, pose.feet.z}; @@ -619,10 +349,10 @@ eve::Result ClimbingRuntime::probeInto(physics::World3D& world, const Clim Vec3 tangent{normal.z, 0.f, -normal.x}; if (tangent.x * forward.x + tangent.z * forward.z < 0.f) tangent = tangent * -1.f; for (const auto& action : profile_.actions) { - if (action.kind != ClimbingActionKind::WallRun || !actionEnabledForPose(profile_, action, pose) || - !probeRecipeMatchesKind(action) || pose.speed + epsilon < action.minSpeed) + if (action.kind != ClimbingActionKind::WallRun || !isActionEnabledForPose(profile_, action, pose) || + !isProbeRecipeMatchingKind(action) || pose.speed + epsilon < action.minSpeed) continue; - if (!supportSelectorsMatch(action, hit.shapeTag, hit.materialId)) { + if (!isSupportSelectorMatch(action, hit.shapeTag, hit.materialId)) { rejectCandidate(); continue; } @@ -676,10 +406,11 @@ eve::Result ClimbingRuntime::probeInto(physics::World3D& world, const Clim } } - const bool hasObstacleProbe = std::any_of(profile_.actions.begin(), profile_.actions.end(), [&](const auto& action) { - return isObstacleProbeKind(action.kind) && actionEnabledForPose(profile_, action, pose) && - probeRecipeMatchesKind(action); - }); + const bool hasObstacleProbe = + std::any_of(profile_.actions.begin(), profile_.actions.end(), [&](const auto& action) { + return isObstacleProbeKind(action.kind) && isActionEnabledForPose(profile_, action, pose) && + isProbeRecipeMatchingKind(action); + }); if (!hasObstacleProbe) return finalizeCandidates(); // A single chest-height ray skips low vault obstacles entirely. The obstacle recipe starts just above the // capsule's lower hemisphere so it can see every valid obstacle above the profile's physical skin; top and @@ -723,8 +454,8 @@ eve::Result ClimbingRuntime::probeInto(physics::World3D& world, const Clim float depthProbeDistance = 0.f; bool needsDepthEvidence = false; for (const ClimbingActionDefinition& action : profile_.actions) { - if (!isObstacleProbeKind(action.kind) || !actionEnabledForPose(profile_, action, pose) || - !probeRecipeMatchesKind(action)) + if (!isObstacleProbeKind(action.kind) || !isActionEnabledForPose(profile_, action, pose) || + !isProbeRecipeMatchingKind(action)) continue; if (action.minDepth > baseDepth + epsilon || action.maxDepth < 999.f || action.maxCurvature < 999.f) { needsDepthEvidence = true; @@ -760,13 +491,13 @@ eve::Result ClimbingRuntime::probeInto(physics::World3D& world, const Clim } for (const ClimbingActionDefinition& action : profile_.actions) { - if (!actionEnabledForPose(profile_, action, pose)) { + if (!isActionEnabledForPose(profile_, action, pose)) { rejectCandidate(); if (debugCapture_ == ClimbingDebugCapture::Enabled) boundedDebugPush(lastEvidence_, ClimbingCandidateEvidence{action.id, "definition_policy_rejected"}); continue; } - if (!probeRecipeMatchesKind(action)) { + if (!isProbeRecipeMatchingKind(action)) { rejectCandidate(); if (debugCapture_ == ClimbingDebugCapture::Enabled) boundedDebugPush(lastEvidence_, ClimbingCandidateEvidence{action.id, "probe_recipe_rejected"}); @@ -787,7 +518,7 @@ eve::Result ClimbingRuntime::probeInto(physics::World3D& world, const Clim } if (measuredDepth + epsilon < action.minDepth || measuredDepth - epsilon > action.maxDepth || topSlope - epsilon > action.maxSlopeRadians || measuredCurvature - epsilon > action.maxCurvature || - !supportSelectorsMatch(action, top.shapeTag, top.materialId)) { + !isSupportSelectorMatch(action, top.shapeTag, top.materialId)) { rejectCandidate(); if (debugCapture_ == ClimbingDebugCapture::Enabled) boundedDebugPush(lastEvidence_, ClimbingCandidateEvidence{action.id, "support_geometry_rejected"}); @@ -912,464 +643,6 @@ eve::Result ClimbingRuntime::probeInto(physics::World3D& world, const Clim return finalizeCandidates(); } -eve::Result ClimbingRuntime::advance(physics::World3D& world, eve::SimulationStep step) { - return advance(world, step, {}); -} - -eve::Result ClimbingRuntime::advance(physics::World3D& world, eve::SimulationStep step, - const ClimbingMotionInput& motion) { - lastQueryCount_ = 0; - lastCounters_ = {}; - lastCounters_.workload = ClimbingWorkload::Active; - lastCounters_.queryBudget = ClimbingQueryBudgets::Active; - RuntimeTelemetryScope telemetryScope(telemetry_, lastCounters_, step.tick); - if (!isActivePhase(phase_) || !execution_) - return climbingFailure(eve::DiagnosticCode::PreconditionViolation, - "no climbing execution is active", "runtime.phase"); - Execution& execution = *execution_; - lastCounters_.selectedCost = execution.candidate.score; - if (world.runtimeHandle() != execution.candidate.world) - return climbingFailure(eve::DiagnosticCode::StaleHandle, - "execution belongs to another or stale physics world", "world"); - if (step.tick <= execution.lastTick) - return climbingFailure(eve::DiagnosticCode::Conflict, - "simulation tick must increase exactly once per update", "step.tick"); - if (step.delta.nanoseconds() <= 0) - return climbingFailure(eve::DiagnosticCode::InvalidArgument, - "simulation delta must be positive", "step.delta"); - if (!finite(motion.rootTranslation) || !finite(motion.facing) || !finite(motion.pelvisOffset) || - !finite(motion.rootYawRadians) || length(motion.pelvisOffset) > profile_.maxPelvisDeviation + epsilon) - return climbingFailure( - eve::DiagnosticCode::InvalidArgument, - "authored motion must be finite and pelvis deviation must remain inside the profile limit", "motion"); - auto eventCapacity = requireEventCapacity(4, step.tick); - if (!eventCapacity) return eve::Result::failure(eventCapacity.status()); - - const bool graphBound = execution.anchorGraph.isValid() && !execution.anchorReservation.id.isZero(); - float graphPointSpeed = -1.f; - if (graphBound) { - auto graph = Climbing::resolveAnchorGraph(execution.anchorGraph); - auto resolved = graph.isBound() - ? graph->resolveNodeKinematics(world, execution.anchorNode) - : climbingFailure( - eve::DiagnosticCode::StaleHandle, "anchor graph instance handle is stale", - "execution.anchorGraph"); - auto reservation = graph.isBound() - ? graph->validateReservation(execution.anchorReservation) - : climbingFailure(eve::DiagnosticCode::StaleHandle, - "anchor graph instance handle is stale", - "execution.anchorGraph"); - if (!resolved || !reservation) { - enqueueEvent( - {ClimbingEventKind::Cancelled, execution.candidate.actionId, step.tick, execution.executionId}); - phase_ = ClimbingPhase::Cancelled; - terminalCode_ = "climbing.anchor.stale"; - if (graph.isBound() && reservation) { - auto released = graph->release(execution.anchorReservation); - released.ignore("stale graph anchor resolution releases live occupancy"); - } - execution_.reset(); - return climbingFailure(eve::DiagnosticCode::StaleHandle, - "graph anchor or occupancy reservation is stale", - "execution.anchor"); - } - execution.candidate.obstacleBody = resolved.value().body; - execution.candidate.topPoint = resolved.value().position; - execution.candidate.frontPoint = resolved.value().position; - execution.candidate.surfaceNormal = resolved.value().normal; - execution.candidate.surfaceTangent = resolved.value().tangent; - execution.candidate.leftHandAnchor = resolved.value().leftHandSocket; - execution.candidate.rightHandAnchor = resolved.value().rightHandSocket; - if (execution.action.kind == ClimbingActionKind::LadderDismount || - execution.action.kind == ClimbingActionKind::BeamBalance) { - execution.candidate.landingFeet = resolved.value().position; - } else { - execution.candidate.landingFeet = - resolved.value().position + - resolved.value().normal * - (profile_.capsuleRadius + profile_.skin + execution.action.hangBodyOffset); - execution.candidate.landingFeet.y = - resolved.value().position.y - execution.action.hangFeetBelowLedge; - } - graphPointSpeed = length(resolved.value().pointVelocity); - } - - physics::Body3D* obstacle = world.findBody(execution.candidate.obstacleBody); - if (!obstacle) { - enqueueEvent({ClimbingEventKind::Cancelled, execution.candidate.actionId, step.tick, execution.executionId}); - phase_ = ClimbingPhase::Cancelled; - terminalCode_ = "climbing.anchor.stale"; - if (graphBound) { - auto released = releaseAnchorReservation(); - released.ignore("destroyed anchor body releases graph occupancy"); - } - execution_.reset(); - return climbingFailure(eve::DiagnosticCode::StaleHandle, "climbing target handle is stale", - "execution.candidate.obstacleBody"); - } - const float platformSpeed = graphPointSpeed >= 0.f - ? graphPointSpeed - : std::sqrt(obstacle->getLinearVelocityX() * obstacle->getLinearVelocityX() + - obstacle->getLinearVelocityY() * obstacle->getLinearVelocityY() + - obstacle->getLinearVelocityZ() * obstacle->getLinearVelocityZ()); - if (platformSpeed > profile_.maxPlatformSpeed + epsilon) { - enqueueEvent({ClimbingEventKind::Cancelled, execution.candidate.actionId, step.tick, execution.executionId}); - phase_ = ClimbingPhase::Cancelled; - terminalCode_ = "climbing.anchor.platform_speed"; - if (graphBound) { - auto released = releaseAnchorReservation(); - released.ignore("unsafe anchor platform speed releases graph occupancy"); - } - execution_.reset(); - return climbingFailure(eve::DiagnosticCode::PreconditionViolation, - "climbing target exceeded the platform speed limit", - "execution.candidate.obstacleBody"); - } - if (!graphBound) { - auto worldTop = obstacle->localToWorldPointOwned(execution.candidate.bodyLocalTop.x, - execution.candidate.bodyLocalTop.y, - execution.candidate.bodyLocalTop.z); - if (!worldTop) return eve::Result::failure(worldTop.status()); - auto worldLanding = obstacle->localToWorldPointOwned( - execution.candidate.bodyLocalLanding.x, execution.candidate.bodyLocalLanding.y, - execution.candidate.bodyLocalLanding.z); - if (!worldLanding) return eve::Result::failure(worldLanding.status()); - execution.candidate.topPoint = {worldTop.value().x, worldTop.value().y, worldTop.value().z}; - if (execution.candidate.kind == ClimbingActionKind::ClimbUp) { - execution.candidate.landingFeet = - execution.candidate.topPoint - execution.candidate.surfaceNormal * execution.action.landingForward; - } else { - execution.candidate.landingFeet = {worldLanding.value().x, worldLanding.value().y, - worldLanding.value().z}; - } - execution.candidate.leftHandAnchor = - execution.candidate.topPoint - execution.candidate.surfaceTangent * (execution.action.handSpacing * 0.5f); - execution.candidate.rightHandAnchor = - execution.candidate.topPoint + execution.candidate.surfaceTangent * (execution.action.handSpacing * 0.5f); - } - - physics::QueryFilter3D filter = profile_.queryFilter; - filter.ignoredBodyId = execution.candidate.ignoredBodyId; - if (phase_ == ClimbingPhase::Hanging || phase_ == ClimbingPhase::Balanced || - phase_ == ClimbingPhase::Swinging) { - const float lowerY = execution.currentFeet.y + profile_.capsuleRadius + profile_.skin; - const float upperY = execution.currentFeet.y + profile_.capsuleHeight - profile_.capsuleRadius + profile_.skin; - auto overlap = - world.queryCapsuleOwned(execution.currentFeet.x, lowerY, execution.currentFeet.z, execution.currentFeet.x, - upperY, execution.currentFeet.z, profile_.capsuleRadius, filter); - if (!overlap) return eve::Result::failure(overlap.status()); - ++lastQueryCount_; - lastCounters_.queryCount = lastQueryCount_; - if (overlap.value().bodyCount != 0) { - enqueueEvent({ClimbingEventKind::Failed, execution.candidate.actionId, step.tick, execution.executionId}); - phase_ = ClimbingPhase::Failed; - terminalCode_ = "climbing.candidate.clearance_blocked"; - if (graphBound) { - auto released = releaseAnchorReservation(); - released.ignore("blocked hanging clearance releases graph occupancy"); - } - execution_.reset(); - return climbingFailure(eve::DiagnosticCode::PreconditionViolation, - "hanging capsule clearance became blocked", "execution.clearance"); - } - execution.lastTick = step.tick; - ClimbingAdvance output{phase_, - execution.candidate.actionId, - execution.currentFeet, - {}, - {}, - {}, - 1.f, - false, - false, - execution.candidate.support}; - output.leftHandAnchor = execution.candidate.leftHandAnchor; - output.rightHandAnchor = execution.candidate.rightHandAnchor; - const bool handHold = phase_ != ClimbingPhase::Balanced; - const bool authoredContacts = !execution.action.contactConstraints.empty(); - output.leftHandWeight = authoredContacts - ? activeContactWeight(execution.action, ClimbingContactTarget::LeftHand, 1.f) - : (handHold ? 1.f : 0.f); - output.rightHandWeight = authoredContacts - ? activeContactWeight(execution.action, ClimbingContactTarget::RightHand, 1.f) - : (handHold ? 1.f : 0.f); - output.leftFootWeight = activeContactWeight(execution.action, ClimbingContactTarget::LeftFoot, 1.f); - output.rightFootWeight = activeContactWeight(execution.action, ClimbingContactTarget::RightFoot, 1.f); - output.pelvisWeight = activeContactWeight(execution.action, ClimbingContactTarget::Pelvis, 1.f); - output.contactWeight = std::max({output.leftHandWeight, output.rightHandWeight, output.leftFootWeight, - output.rightFootWeight, output.pelvisWeight}); - output.executionId = execution.executionId; - output.compactCollisionActive = execution.compactCollisionActive; - output.branchWindowOpen = execution.branchWindowOpen; - output.cameraCueProfile = profile_.cameraCueProfile; - output.cameraCue = execution.action.cameraCue; - output.animationClipId = execution.action.animation.clipId; - output.animationGraphNodeId = execution.action.animation.graphNodeId; - output.animationMirrored = execution.action.animation.mirrored; - return eve::Result::success(std::move(output)); - } - - if (phase_ == ClimbingPhase::Dropping) { - const float dt = static_cast(step.delta.seconds()); - execution.velocity.x += world.getGravityX() * dt; - execution.velocity.y += world.getGravityY() * dt; - execution.velocity.z += world.getGravityZ() * dt; - const Vec3 desired = execution.velocity * dt; - const float lowerY = execution.currentFeet.y + profile_.capsuleRadius; - const float upperY = execution.currentFeet.y + profile_.capsuleHeight - profile_.capsuleRadius; - auto moved = world.moveCapsuleOwned(execution.currentFeet.x, lowerY, execution.currentFeet.z, - execution.currentFeet.x, upperY, execution.currentFeet.z, - profile_.capsuleRadius, desired.x, desired.y, desired.z, filter); - if (!moved) return eve::Result::failure(moved.status()); - ++lastQueryCount_; - lastCounters_.queryCount = lastQueryCount_; - const physics::CapsuleMove3D movement = std::move(moved).takeValue(); - lastCounters_.moverIterations = static_cast(std::max(0, movement.iterations)); - const Vec3 actual{movement.deltaX, movement.deltaY, movement.deltaZ}; - lastCounters_.warpResidual = desired - actual; - execution.currentFeet = execution.currentFeet + actual; - execution.lastTick = step.tick; - ClimbingAdvance output{ClimbingPhase::Dropping, - execution.candidate.actionId, - execution.currentFeet, - desired, - actual, - desired - actual, - 1.f, - movement.constrained, - movement.grounded, - HangSupport::None}; - output.executionId = execution.executionId; - output.cameraCueProfile = profile_.cameraCueProfile; - output.cameraCue = execution.action.cameraCue; - output.animationClipId = execution.action.animation.clipId; - output.animationGraphNodeId = execution.action.animation.graphNodeId; - output.animationMirrored = execution.action.animation.mirrored; - if (movement.grounded) { - phase_ = ClimbingPhase::Completed; - terminalCode_ = "climbing.completed"; - output.phase = phase_; - enqueueEvent({ClimbingEventKind::Landed, output.actionId, step.tick, execution.executionId}); - enqueueEvent({ClimbingEventKind::Completed, output.actionId, step.tick, execution.executionId}); - output.terminalVelocity = terminalVelocityFor(execution.action, actual, 1.f / dt); - output.hasTerminalVelocity = true; - execution_.reset(); - } - return eve::Result::success(std::move(output)); - } - - auto elapsed = execution.elapsed.tryAdd(step.delta); - if (!elapsed) return eve::Result::failure(elapsed.status()); - execution.elapsed = std::move(elapsed).takeValue(); - execution.lastTick = step.tick; - const double durationSeconds = execution.duration.seconds(); - const float t = static_cast(std::clamp(execution.elapsed.seconds() / durationSeconds, 0.0, 1.0)); - const Vec3 planned = - detail::trajectoryPoint(execution.startFeet, execution.candidate, execution.action, profile_, t); - const Vec3 proceduralDelta = planned - execution.currentFeet; - Vec3 baseDelta = proceduralDelta; - if (motion.hasRootMotion) { - const float authoredLength = length(motion.rootTranslation); - const float targetLength = length(proceduralDelta); - const float scale = authoredLength > epsilon - ? std::clamp(targetLength / authoredLength, execution.action.rootMotionScaleMin, - execution.action.rootMotionScaleMax) - : execution.action.rootMotionScaleMin; - baseDelta = motion.rootTranslation * scale; - } - - const WarpChannels channels = activeWarpChannels(execution.action, t); - const Vec3 warpError = planned - (execution.currentFeet + baseDelta); - Vec3 appliedWarp; - if (channels.horizontal) { - Vec3 horizontal{warpError.x, 0.f, warpError.z}; - const float remaining = std::max(0.f, execution.action.horizontalWarpBudget - execution.horizontalWarpUsed); - horizontal = clampMagnitude(horizontal, std::min(execution.action.maxTranslationWarpPerTick, remaining)); - appliedWarp.x = horizontal.x; - appliedWarp.z = horizontal.z; - } - if (channels.vertical) { - const float remaining = std::max(0.f, execution.action.verticalWarpBudget - execution.verticalWarpUsed); - appliedWarp.y = std::clamp(warpError.y, -std::min(execution.action.maxTranslationWarpPerTick, remaining), - std::min(execution.action.maxTranslationWarpPerTick, remaining)); - } - const float remainingTotalWarp = - std::max(0.f, profile_.maxTotalWarpBudget - execution.horizontalWarpUsed - execution.verticalWarpUsed); - appliedWarp = clampMagnitude(appliedWarp, std::min(execution.action.maxTranslationWarpPerTick, remainingTotalWarp)); - execution.horizontalWarpUsed += length({appliedWarp.x, 0.f, appliedWarp.z}); - execution.verticalWarpUsed += std::fabs(appliedWarp.y); - const Vec3 desired = baseDelta + appliedWarp; - - float desiredYawDelta = motion.rootYawRadians; - if (channels.facing) { - const Vec3 targetFacing = execution.candidate.surfaceNormal * -1.f; - const float yawError = signedHorizontalAngle(motion.facing, targetFacing) - motion.rootYawRadians; - const float remaining = std::max(0.f, execution.action.facingWarpBudgetRadians - execution.facingWarpUsed); - const float correction = std::clamp(yawError, -std::min(execution.action.maxYawWarpRadiansPerTick, remaining), - std::min(execution.action.maxYawWarpRadiansPerTick, remaining)); - execution.facingWarpUsed += std::fabs(correction); - desiredYawDelta += correction; - } - const float authoredLeftHand = activeContactWeight(execution.action, ClimbingContactTarget::LeftHand, t); - const float authoredRightHand = activeContactWeight(execution.action, ClimbingContactTarget::RightHand, t); - bool leftContact = authoredLeftHand > epsilon; - bool rightContact = authoredRightHand > epsilon; - bool landContact = false; - bool compactRequested = false; - bool compactForTick = execution.compactCollisionActive; - bool branchForTick = execution.action.branchWindows.empty() ? execution.branchWindowOpen - : activeBranchWindow(execution.action, t); - for (ClimbingNotifyKind notify : motion.notifies) { - switch (notify) { - case ClimbingNotifyKind::ContactLeftHand: leftContact = true; break; - case ClimbingNotifyKind::ContactRightHand: rightContact = true; break; - case ClimbingNotifyKind::CollisionCompact: - compactRequested = true; - compactForTick = true; - break; - case ClimbingNotifyKind::BranchOpen: branchForTick = true; break; - case ClimbingNotifyKind::BranchClose: branchForTick = false; break; - case ClimbingNotifyKind::Land: - landContact = true; - compactForTick = false; - branchForTick = false; - break; - } - } - const float collisionHeight = compactForTick ? profile_.compactCapsuleHeight : profile_.capsuleHeight; - const float lowerY = execution.currentFeet.y + profile_.capsuleRadius; - const float upperY = execution.currentFeet.y + collisionHeight - profile_.capsuleRadius; - auto moved = world.moveCapsuleOwned(execution.currentFeet.x, lowerY, execution.currentFeet.z, - execution.currentFeet.x, upperY, execution.currentFeet.z, - profile_.capsuleRadius, desired.x, desired.y, desired.z, filter); - if (!moved) return eve::Result::failure(moved.status()); - ++lastQueryCount_; - lastCounters_.queryCount = lastQueryCount_; - const physics::CapsuleMove3D movement = std::move(moved).takeValue(); - lastCounters_.moverIterations = static_cast(std::max(0, movement.iterations)); - const Vec3 actual{movement.deltaX, movement.deltaY, movement.deltaZ}; - execution.currentFeet = execution.currentFeet + actual; - execution.lastPlannedFeet = planned; - const Vec3 residual = desired - actual; - lastCounters_.warpResidual = residual; - execution.accumulatedResidual = execution.accumulatedResidual + residual; - execution.compactCollisionActive = compactForTick; - execution.branchWindowOpen = branchForTick; - if (debugCapture_ == ClimbingDebugCapture::Enabled) - boundedDebugPush(motionEvidence_, ClimbingMotionEvidence{step.tick, planned, execution.currentFeet, residual, - collisionHeight, movement.constrained}); - ClimbingAdvance output{ClimbingPhase::Climbing, - execution.candidate.actionId, - execution.currentFeet, - desired, - actual, - residual, - t, - movement.constrained, - movement.grounded, - execution.candidate.support}; - output.executionId = execution.executionId; - output.appliedWarp = appliedWarp; - output.desiredYawDelta = desiredYawDelta; - output.cameraCueProfile = profile_.cameraCueProfile; - output.cameraCue = execution.action.cameraCue; - output.animationClipId = execution.action.animation.clipId; - output.animationGraphNodeId = execution.action.animation.graphNodeId; - output.animationMirrored = execution.action.animation.mirrored; - output.leftHandAnchor = execution.candidate.leftHandAnchor; - output.rightHandAnchor = execution.candidate.rightHandAnchor; - if (leftContact && !execution.leftContactEmitted) { - execution.leftContactEmitted = true; - enqueueEvent({ClimbingEventKind::ContactLeftHand, output.actionId, step.tick, execution.executionId}); - } - if (rightContact && !execution.rightContactEmitted) { - execution.rightContactEmitted = true; - enqueueEvent({ClimbingEventKind::ContactRightHand, output.actionId, step.tick, execution.executionId}); - } - if (landContact) execution.landContactReleased = true; - if (execution.action.contactConstraints.empty()) { - output.leftHandWeight = execution.leftContactEmitted && !execution.landContactReleased ? 1.f : 0.f; - output.rightHandWeight = execution.rightContactEmitted && !execution.landContactReleased ? 1.f : 0.f; - } else if (!execution.landContactReleased) { - output.leftHandWeight = authoredLeftHand; - output.rightHandWeight = authoredRightHand; - output.leftFootWeight = activeContactWeight(execution.action, ClimbingContactTarget::LeftFoot, t); - output.rightFootWeight = activeContactWeight(execution.action, ClimbingContactTarget::RightFoot, t); - output.pelvisWeight = activeContactWeight(execution.action, ClimbingContactTarget::Pelvis, t); - } - output.contactWeight = std::max({output.leftHandWeight, output.rightHandWeight, output.leftFootWeight, - output.rightFootWeight, output.pelvisWeight}); - output.compactCollisionRequested = compactRequested; - output.compactCollisionActive = execution.compactCollisionActive; - output.branchWindowOpen = execution.branchWindowOpen; - if (const std::string* comboTag = activeBranchComboTag(execution.action, t)) - output.branchComboTag = *comboTag; - if (t < 0.1f) - phase_ = ClimbingPhase::Aligning; - else if (t < 0.2f) - phase_ = ClimbingPhase::Launching; - else if (t < 0.8f) - phase_ = ClimbingPhase::Climbing; - else if (t < 0.95f) - phase_ = ClimbingPhase::Landing; - else - phase_ = ClimbingPhase::Recovering; - output.phase = phase_; - const Vec3 remainingTargetError = planned - execution.currentFeet; - const bool authoredWarpMissed = motion.hasRootMotion && t + epsilon >= finalWarpWindowEnd(execution.action) && - length(remainingTargetError) > profile_.maxWarpResidual + epsilon; - if (length(execution.accumulatedResidual) > profile_.maxWarpResidual + epsilon || authoredWarpMissed) { - phase_ = ClimbingPhase::Failed; - terminalCode_ = "climbing.warp.budget_exceeded"; - output.phase = phase_; - enqueueEvent({ClimbingEventKind::Failed, output.actionId, step.tick, execution.executionId}); - if (graphBound) { - auto released = releaseAnchorReservation(); - released.ignore("failed anchor motion releases graph occupancy"); - } - execution_.reset(); - } else if (t >= 1.f - epsilon) { - if (graphBound && endsAtAnchorHang(execution.candidate.kind)) { - if (execution.candidate.kind == ClimbingActionKind::BeamBalance) - phase_ = ClimbingPhase::Balanced; - else if (execution.candidate.kind == ClimbingActionKind::PoleSwing || - execution.candidate.kind == ClimbingActionKind::BarSwing) - phase_ = ClimbingPhase::Swinging; - else - phase_ = ClimbingPhase::Hanging; - execution.branchWindowOpen = true; - output.branchWindowOpen = true; - enqueueEvent({execution.candidate.kind == ClimbingActionKind::LedgeGrab - ? ClimbingEventKind::Hanging - : ClimbingEventKind::AnchorReached, - output.actionId, step.tick, execution.executionId}); - } else if (execution.candidate.kind == ClimbingActionKind::LedgeGrab) { - phase_ = ClimbingPhase::Hanging; - execution.branchWindowOpen = true; - output.branchWindowOpen = true; - enqueueEvent({ClimbingEventKind::Hanging, output.actionId, step.tick, execution.executionId}); - } else { - phase_ = ClimbingPhase::Completed; - terminalCode_ = "climbing.completed"; - if (execution.candidate.kind != ClimbingActionKind::WallRun) - enqueueEvent({ClimbingEventKind::Landed, output.actionId, step.tick, execution.executionId}); - enqueueEvent({ClimbingEventKind::Completed, output.actionId, step.tick, execution.executionId}); - if (graphBound) { - auto released = releaseAnchorReservation(); - released.ignore("completed graph dismount releases occupancy"); - } - } - output.phase = phase_; - if (phase_ == ClimbingPhase::Completed) { - output.terminalVelocity = terminalVelocityFor(execution.action, actual, - static_cast(1.0 / step.delta.seconds())); - output.hasTerminalVelocity = true; - execution_.reset(); - } - } - return eve::Result::success(std::move(output)); -} - eve::Result ClimbingRuntime::drop(eve::SimulationTick tick) { if (phase_ != ClimbingPhase::Hanging || !execution_) return climbingFailure(eve::DiagnosticCode::PreconditionViolation, "drop requires a hanging execution", diff --git a/src/modules/climbing/Climbing.h b/src/modules/climbing/Climbing.h index 319d9c17..06d342cb 100644 --- a/src/modules/climbing/Climbing.h +++ b/src/modules/climbing/Climbing.h @@ -509,7 +509,24 @@ struct ClimbingEvent { std::vector metadata; }; -/** @brief Per-tick authored animation motion supplied before climbing warp and collision. */ +/** @brief Whether animation deltas still require the runtime's procedural target warp. */ +enum class ClimbingRootMotionPolicy { ApplyActionWarp, PreserveSuppliedDelta }; +/** @brief Per-call capsule collision policy; never changes persistent world collision state. */ +enum class ClimbingObstacleCollision { Collide, IgnoreTraversedShape }; + +/** @brief Per-tick authored animation motion supplied before climbing warp and collision. + * @details Owning values + * consumed synchronously on the runtime/world simulation thread. + * Prewarped deltas use PreserveSuppliedDelta to avoid + * applying a second procedural warp. + * IgnoreTraversedShape excludes only the execution's generation-validated + * obstacle shape, + * retaining the character's existing body filter and collisions with all other shapes. + * Both + * policies require hasRootMotion; invalid inputs fail before execution advances. + * No pointers, callbacks, retained + * collision overrides, serialization or wall-clock input. + */ struct ClimbingMotionInput { Vec3 rootTranslation; Vec3 facing{0.f, 0.f, 1.f}; @@ -518,6 +535,8 @@ struct ClimbingMotionInput { bool hasRootMotion = false; /** @brief Owning semantic notifies crossed by the animation player during this exact simulation step. */ std::vector notifies; + ClimbingRootMotionPolicy rootMotionPolicy = ClimbingRootMotionPolicy::ApplyActionWarp; + ClimbingObstacleCollision obstacleCollision = ClimbingObstacleCollision::Collide; }; /** @brief Owning output of one execution tick. */ diff --git a/src/modules/climbing/ClimbingBindingInternal.h b/src/modules/climbing/ClimbingBindingInternal.h new file mode 100644 index 00000000..9ec16de0 --- /dev/null +++ b/src/modules/climbing/ClimbingBindingInternal.h @@ -0,0 +1,16 @@ +#pragma once +#include +#include "climbing/Climbing.h" +namespace eve::climbing { +struct ScriptClimbingRuntime { + explicit ScriptClimbingRuntime(ClimbingRuntimeHandleRef value) : reference(value) {} + ~ScriptClimbingRuntime() noexcept { + Climbing::release(reference).ignore("script climbing runtime proxy destruction"); + } + ClimbingRuntimeHandleRef reference; +}; + +// Internal bindings share the owned runtime proxy and one result projection. +eve::Value projectClimbingAdvance(ClimbingAdvance value); +void exposeClimbingMotionBindings(ssq::Class& runtime, HSQUIRRELVM vm); +} // namespace eve::climbing diff --git a/src/modules/climbing/ClimbingBindings.cpp b/src/modules/climbing/ClimbingBindings.cpp index ea725b7c..b380e631 100644 --- a/src/modules/climbing/ClimbingBindings.cpp +++ b/src/modules/climbing/ClimbingBindings.cpp @@ -1,4 +1,5 @@ #include "climbing/Climbing.h" +#include "climbing/ClimbingBindingInternal.h" #include "animation/AnimClip.h" #include "common/SquirrelBinding.h" @@ -41,6 +42,7 @@ eve::Value candidateValue(ClimbingCandidate candidate) { {"bodyLocalTop", vecValue(candidate.bodyLocalTop)}, {"bodyLocalLanding", vecValue(candidate.bodyLocalLanding)}, {"obstacleHeight", candidate.obstacleHeight}, + {"obstacleDepth", candidate.obstacleDepth}, {"score", candidate.score}, {"kind", static_cast(candidate.kind)}, {"support", static_cast(candidate.support)}, @@ -396,13 +398,6 @@ ssq::Table makeOwnedProxy(HSQUIRRELVM vm, eve::Result&& reference, Release& return result; } -struct ScriptClimbingRuntime { - explicit ScriptClimbingRuntime(ClimbingRuntimeHandleRef value) : reference(value) {} - ~ScriptClimbingRuntime() noexcept { - Climbing::release(reference).ignore("script climbing runtime proxy destruction"); - } - ClimbingRuntimeHandleRef reference; -}; struct ScriptClimbingAnchorGraph { explicit ScriptClimbingAnchorGraph(ClimbingAnchorGraphHandleRef value) : reference(value) {} @@ -414,6 +409,8 @@ struct ScriptClimbingAnchorGraph { } // namespace +eve::Value projectClimbingAdvance(ClimbingAdvance value) { return advanceValue(std::move(value)); } + Module_IMPL(Climbing, new Climbing()); void Climbing::expose(ssq::Table& table) { @@ -640,6 +637,34 @@ void Climbing::expose(ssq::Table& table) { : bindingFailure(eve::DiagnosticCode::StaleHandle, "climbing runtime handle is stale", "runtime")); }); + runtime.addFunc("probeMode", [vm](ScriptClimbingRuntime* value, physics::World3D* world, float x, float y, float z, + float forwardX, float forwardZ, float speed, float verticalSpeed, bool grounded) { + if (!value || !world) + return eve::script::projectResult( + vm, + bindingFailure(eve::DiagnosticCode::InvalidArgument, + "runtime and world are required", "probeMode"), + candidateValue); + auto resolved = Climbing::resolve(value->reference); + if (!resolved.isBound()) + return eve::script::projectResult( + vm, + bindingFailure(eve::DiagnosticCode::StaleHandle, "climbing runtime handle is stale", + "runtime"), + candidateValue); + auto candidates = + resolved->probe(*world, {{x, y, z}, {forwardX, 0.f, forwardZ}, speed, -1, verticalSpeed, grounded}); + if (!candidates.ok()) + return eve::script::projectResult(vm, eve::Result::failure(candidates.status()), + candidateValue); + if (candidates.value().empty()) + return eve::script::projectResult(vm, + bindingFailure(eve::DiagnosticCode::NotFound, + "no traversable obstacle", "probeMode"), + candidateValue); + return eve::script::projectResult(vm, eve::Result::success(candidates.value()[0]), + candidateValue); + }); runtime.addFunc("tryBegin", [vm](ScriptClimbingRuntime* value, physics::World3D* world, float x, float y, float z, float forwardX, float forwardZ, float speed, int ignoredBodyId, std::int64_t tick) { @@ -756,27 +781,7 @@ void Climbing::expose(ssq::Table& table) { "climbing runtime handle is stale", "runtime"), anchorNodeValue); }); - runtime.addFunc( - "advance", [vm](ScriptClimbingRuntime* value, physics::World3D* world, std::int64_t tick, float deltaSeconds) { - if (!value || !world || tick < 0) - return eve::script::projectResult( - vm, bindingFailure(eve::DiagnosticCode::InvalidArgument, - "runtime, world, and non-negative tick are required", "advance"), - advanceValue); - auto resolved = Climbing::resolve(value->reference); - if (!resolved.isBound()) - return eve::script::projectResult( - vm, bindingFailure(eve::DiagnosticCode::StaleHandle, - "climbing runtime handle is stale", "runtime"), - advanceValue); - auto duration = eve::Duration::fromSeconds(deltaSeconds); - if (!duration) return eve::script::projectStatusResult(vm, duration.status(), false, false); - return eve::script::projectResult( - vm, - resolved->advance( - *world, {eve::SimulationTick(static_cast(tick)), std::move(duration).takeValue()}), - advanceValue); - }); + exposeClimbingMotionBindings(runtime, vm); runtime.addFunc("cancel", [vm](ScriptClimbingRuntime* value, const std::string& reason, std::int64_t tick) { if (!value || tick < 0) return eve::script::projectResult( diff --git a/src/modules/climbing/ClimbingExecution.cpp b/src/modules/climbing/ClimbingExecution.cpp new file mode 100644 index 00000000..045460ee --- /dev/null +++ b/src/modules/climbing/ClimbingExecution.cpp @@ -0,0 +1,471 @@ +#include "climbing/Climbing.h" +#include "climbing/ClimbingRuntimeInternal.h" +#include "climbing/ClimbingTrajectory.h" +#include "physics/Body3D.h" +#include "physics/World3D.h" +namespace eve::climbing { +using namespace runtime_detail; +eve::Result ClimbingRuntime::advance(physics::World3D& world, eve::SimulationStep step) { + return advance(world, step, {}); +} + +eve::Result ClimbingRuntime::advance(physics::World3D& world, eve::SimulationStep step, + const ClimbingMotionInput& motion) { + lastQueryCount_ = 0; + lastCounters_ = {}; + lastCounters_.workload = ClimbingWorkload::Active; + lastCounters_.queryBudget = ClimbingQueryBudgets::Active; + RuntimeTelemetryScope telemetryScope(telemetry_, lastCounters_, step.tick); + if (!isActivePhase(phase_) || !execution_) + return climbingFailure(eve::DiagnosticCode::PreconditionViolation, + "no climbing execution is active", "runtime.phase"); + Execution& execution = *execution_; + lastCounters_.selectedCost = execution.candidate.score; + if (world.runtimeHandle() != execution.candidate.world) + return climbingFailure(eve::DiagnosticCode::StaleHandle, + "execution belongs to another or stale physics world", "world"); + if (step.tick <= execution.lastTick) + return climbingFailure(eve::DiagnosticCode::Conflict, + "simulation tick must increase exactly once per update", "step.tick"); + if (step.delta.nanoseconds() <= 0) + return climbingFailure(eve::DiagnosticCode::InvalidArgument, + "simulation delta must be positive", "step.delta"); + if ((motion.rootMotionPolicy != ClimbingRootMotionPolicy::ApplyActionWarp && + motion.rootMotionPolicy != ClimbingRootMotionPolicy::PreserveSuppliedDelta) || + (motion.obstacleCollision != ClimbingObstacleCollision::Collide && + motion.obstacleCollision != ClimbingObstacleCollision::IgnoreTraversedShape) || + (!motion.hasRootMotion && (motion.rootMotionPolicy != ClimbingRootMotionPolicy::ApplyActionWarp || + motion.obstacleCollision != ClimbingObstacleCollision::Collide))) + return climbingFailure(eve::DiagnosticCode::InvalidArgument, + "motion policies require valid enum values and authored root motion", + "motion.policy"); + if (!isFinite(motion.rootTranslation) || !isFinite(motion.facing) || !isFinite(motion.pelvisOffset) || + !isFinite(motion.rootYawRadians) || length(motion.pelvisOffset) > profile_.maxPelvisDeviation + epsilon) + return climbingFailure( + eve::DiagnosticCode::InvalidArgument, + "authored motion must be finite and pelvis deviation must remain inside the profile limit", "motion"); + if (motion.obstacleCollision == ClimbingObstacleCollision::IgnoreTraversedShape && + !world.findShape(execution.candidate.obstacleShape)) + return climbingFailure(eve::DiagnosticCode::StaleHandle, + "traversed shape is stale; collision exclusion was not applied", + "motion.obstacleCollision"); + auto eventCapacity = requireEventCapacity(4, step.tick); + if (!eventCapacity) return eve::Result::failure(eventCapacity.status()); + + const bool graphBound = execution.anchorGraph.isValid() && !execution.anchorReservation.id.isZero(); + float graphPointSpeed = -1.f; + if (graphBound) { + auto graph = Climbing::resolveAnchorGraph(execution.anchorGraph); + auto resolved = graph.isBound() ? graph->resolveNodeKinematics(world, execution.anchorNode) + : climbingFailure( + eve::DiagnosticCode::StaleHandle, "anchor graph instance handle is stale", + "execution.anchorGraph"); + auto reservation = + graph.isBound() ? graph->validateReservation(execution.anchorReservation) + : climbingFailure(eve::DiagnosticCode::StaleHandle, + "anchor graph instance handle is stale", "execution.anchorGraph"); + if (!resolved || !reservation) { + enqueueEvent( + {ClimbingEventKind::Cancelled, execution.candidate.actionId, step.tick, execution.executionId}); + phase_ = ClimbingPhase::Cancelled; + terminalCode_ = "climbing.anchor.stale"; + if (graph.isBound() && reservation) { + auto released = graph->release(execution.anchorReservation); + released.ignore("stale graph anchor resolution releases live occupancy"); + } + execution_.reset(); + return climbingFailure( + eve::DiagnosticCode::StaleHandle, "graph anchor or occupancy reservation is stale", "execution.anchor"); + } + execution.candidate.obstacleBody = resolved.value().body; + execution.candidate.topPoint = resolved.value().position; + execution.candidate.frontPoint = resolved.value().position; + execution.candidate.surfaceNormal = resolved.value().normal; + execution.candidate.surfaceTangent = resolved.value().tangent; + execution.candidate.leftHandAnchor = resolved.value().leftHandSocket; + execution.candidate.rightHandAnchor = resolved.value().rightHandSocket; + if (execution.action.kind == ClimbingActionKind::LadderDismount || + execution.action.kind == ClimbingActionKind::BeamBalance) { + execution.candidate.landingFeet = resolved.value().position; + } else { + execution.candidate.landingFeet = + resolved.value().position + + resolved.value().normal * (profile_.capsuleRadius + profile_.skin + execution.action.hangBodyOffset); + execution.candidate.landingFeet.y = resolved.value().position.y - execution.action.hangFeetBelowLedge; + } + graphPointSpeed = length(resolved.value().pointVelocity); + } + + physics::Body3D* obstacle = world.findBody(execution.candidate.obstacleBody); + if (!obstacle) { + enqueueEvent({ClimbingEventKind::Cancelled, execution.candidate.actionId, step.tick, execution.executionId}); + phase_ = ClimbingPhase::Cancelled; + terminalCode_ = "climbing.anchor.stale"; + if (graphBound) { + auto released = releaseAnchorReservation(); + released.ignore("destroyed anchor body releases graph occupancy"); + } + execution_.reset(); + return climbingFailure(eve::DiagnosticCode::StaleHandle, "climbing target handle is stale", + "execution.candidate.obstacleBody"); + } + const float platformSpeed = graphPointSpeed >= 0.f + ? graphPointSpeed + : std::sqrt(obstacle->getLinearVelocityX() * obstacle->getLinearVelocityX() + + obstacle->getLinearVelocityY() * obstacle->getLinearVelocityY() + + obstacle->getLinearVelocityZ() * obstacle->getLinearVelocityZ()); + if (platformSpeed > profile_.maxPlatformSpeed + epsilon) { + enqueueEvent({ClimbingEventKind::Cancelled, execution.candidate.actionId, step.tick, execution.executionId}); + phase_ = ClimbingPhase::Cancelled; + terminalCode_ = "climbing.anchor.platform_speed"; + if (graphBound) { + auto released = releaseAnchorReservation(); + released.ignore("unsafe anchor platform speed releases graph occupancy"); + } + execution_.reset(); + return climbingFailure(eve::DiagnosticCode::PreconditionViolation, + "climbing target exceeded the platform speed limit", + "execution.candidate.obstacleBody"); + } + if (!graphBound) { + auto worldTop = obstacle->localToWorldPointOwned( + execution.candidate.bodyLocalTop.x, execution.candidate.bodyLocalTop.y, execution.candidate.bodyLocalTop.z); + if (!worldTop) return eve::Result::failure(worldTop.status()); + auto worldLanding = obstacle->localToWorldPointOwned(execution.candidate.bodyLocalLanding.x, + execution.candidate.bodyLocalLanding.y, + execution.candidate.bodyLocalLanding.z); + if (!worldLanding) return eve::Result::failure(worldLanding.status()); + execution.candidate.topPoint = {worldTop.value().x, worldTop.value().y, worldTop.value().z}; + if (execution.candidate.kind == ClimbingActionKind::ClimbUp) { + execution.candidate.landingFeet = + execution.candidate.topPoint - execution.candidate.surfaceNormal * execution.action.landingForward; + } else { + execution.candidate.landingFeet = {worldLanding.value().x, worldLanding.value().y, worldLanding.value().z}; + } + execution.candidate.leftHandAnchor = + execution.candidate.topPoint - execution.candidate.surfaceTangent * (execution.action.handSpacing * 0.5f); + execution.candidate.rightHandAnchor = + execution.candidate.topPoint + execution.candidate.surfaceTangent * (execution.action.handSpacing * 0.5f); + } + + physics::QueryFilter3D filter = profile_.queryFilter; + filter.ignoredBodyId = execution.candidate.ignoredBodyId; + if (motion.obstacleCollision == ClimbingObstacleCollision::IgnoreTraversedShape) + filter.ignoredShapeId = execution.candidate.obstacleShapeId; + if (phase_ == ClimbingPhase::Hanging || phase_ == ClimbingPhase::Balanced || phase_ == ClimbingPhase::Swinging) { + const float lowerY = execution.currentFeet.y + profile_.capsuleRadius + profile_.skin; + const float upperY = execution.currentFeet.y + profile_.capsuleHeight - profile_.capsuleRadius + profile_.skin; + auto overlap = + world.queryCapsuleOwned(execution.currentFeet.x, lowerY, execution.currentFeet.z, execution.currentFeet.x, + upperY, execution.currentFeet.z, profile_.capsuleRadius, filter); + if (!overlap) return eve::Result::failure(overlap.status()); + ++lastQueryCount_; + lastCounters_.queryCount = lastQueryCount_; + if (overlap.value().bodyCount != 0) { + enqueueEvent({ClimbingEventKind::Failed, execution.candidate.actionId, step.tick, execution.executionId}); + phase_ = ClimbingPhase::Failed; + terminalCode_ = "climbing.candidate.clearance_blocked"; + if (graphBound) { + auto released = releaseAnchorReservation(); + released.ignore("blocked hanging clearance releases graph occupancy"); + } + execution_.reset(); + return climbingFailure(eve::DiagnosticCode::PreconditionViolation, + "hanging capsule clearance became blocked", "execution.clearance"); + } + execution.lastTick = step.tick; + ClimbingAdvance output{phase_, execution.candidate.actionId, execution.currentFeet, {}, {}, {}, 1.f, false, + false, execution.candidate.support}; + output.leftHandAnchor = execution.candidate.leftHandAnchor; + output.rightHandAnchor = execution.candidate.rightHandAnchor; + const bool handHold = phase_ != ClimbingPhase::Balanced; + const bool authoredContacts = !execution.action.contactConstraints.empty(); + output.leftHandWeight = authoredContacts + ? activeContactWeight(execution.action, ClimbingContactTarget::LeftHand, 1.f) + : (handHold ? 1.f : 0.f); + output.rightHandWeight = authoredContacts + ? activeContactWeight(execution.action, ClimbingContactTarget::RightHand, 1.f) + : (handHold ? 1.f : 0.f); + output.leftFootWeight = activeContactWeight(execution.action, ClimbingContactTarget::LeftFoot, 1.f); + output.rightFootWeight = activeContactWeight(execution.action, ClimbingContactTarget::RightFoot, 1.f); + output.pelvisWeight = activeContactWeight(execution.action, ClimbingContactTarget::Pelvis, 1.f); + output.contactWeight = std::max({output.leftHandWeight, output.rightHandWeight, output.leftFootWeight, + output.rightFootWeight, output.pelvisWeight}); + output.executionId = execution.executionId; + output.compactCollisionActive = execution.compactCollisionActive; + output.branchWindowOpen = execution.branchWindowOpen; + output.cameraCueProfile = profile_.cameraCueProfile; + output.cameraCue = execution.action.cameraCue; + output.animationClipId = execution.action.animation.clipId; + output.animationGraphNodeId = execution.action.animation.graphNodeId; + output.animationMirrored = execution.action.animation.mirrored; + return eve::Result::success(std::move(output)); + } + + if (phase_ == ClimbingPhase::Dropping) { + const float dt = static_cast(step.delta.seconds()); + execution.velocity.x += world.getGravityX() * dt; + execution.velocity.y += world.getGravityY() * dt; + execution.velocity.z += world.getGravityZ() * dt; + const Vec3 desired = execution.velocity * dt; + const float lowerY = execution.currentFeet.y + profile_.capsuleRadius; + const float upperY = execution.currentFeet.y + profile_.capsuleHeight - profile_.capsuleRadius; + auto moved = world.moveCapsuleOwned(execution.currentFeet.x, lowerY, execution.currentFeet.z, + execution.currentFeet.x, upperY, execution.currentFeet.z, + profile_.capsuleRadius, desired.x, desired.y, desired.z, filter); + if (!moved) return eve::Result::failure(moved.status()); + ++lastQueryCount_; + lastCounters_.queryCount = lastQueryCount_; + const physics::CapsuleMove3D movement = std::move(moved).takeValue(); + lastCounters_.moverIterations = static_cast(std::max(0, movement.iterations)); + const Vec3 actual{movement.deltaX, movement.deltaY, movement.deltaZ}; + lastCounters_.warpResidual = desired - actual; + execution.currentFeet = execution.currentFeet + actual; + execution.lastTick = step.tick; + ClimbingAdvance output{ClimbingPhase::Dropping, + execution.candidate.actionId, + execution.currentFeet, + desired, + actual, + desired - actual, + 1.f, + movement.constrained, + movement.grounded, + HangSupport::None}; + output.executionId = execution.executionId; + output.cameraCueProfile = profile_.cameraCueProfile; + output.cameraCue = execution.action.cameraCue; + output.animationClipId = execution.action.animation.clipId; + output.animationGraphNodeId = execution.action.animation.graphNodeId; + output.animationMirrored = execution.action.animation.mirrored; + if (movement.grounded) { + phase_ = ClimbingPhase::Completed; + terminalCode_ = "climbing.completed"; + output.phase = phase_; + enqueueEvent({ClimbingEventKind::Landed, output.actionId, step.tick, execution.executionId}); + enqueueEvent({ClimbingEventKind::Completed, output.actionId, step.tick, execution.executionId}); + output.terminalVelocity = terminalVelocityFor(execution.action, actual, 1.f / dt); + output.hasTerminalVelocity = true; + execution_.reset(); + } + return eve::Result::success(std::move(output)); + } + + auto elapsed = execution.elapsed.tryAdd(step.delta); + if (!elapsed) return eve::Result::failure(elapsed.status()); + execution.elapsed = std::move(elapsed).takeValue(); + execution.lastTick = step.tick; + const double durationSeconds = execution.duration.seconds(); + const float t = static_cast(std::clamp(execution.elapsed.seconds() / durationSeconds, 0.0, 1.0)); + const Vec3 planned = + detail::trajectoryPoint(execution.startFeet, execution.candidate, execution.action, profile_, t); + const Vec3 proceduralDelta = planned - execution.currentFeet; + Vec3 baseDelta = proceduralDelta; + if (motion.hasRootMotion && motion.rootMotionPolicy == ClimbingRootMotionPolicy::PreserveSuppliedDelta) { + baseDelta = motion.rootTranslation; + } else if (motion.hasRootMotion) { + const float authoredLength = length(motion.rootTranslation); + const float targetLength = length(proceduralDelta); + const float scale = authoredLength > epsilon + ? std::clamp(targetLength / authoredLength, execution.action.rootMotionScaleMin, + execution.action.rootMotionScaleMax) + : execution.action.rootMotionScaleMin; + baseDelta = motion.rootTranslation * scale; + } + + const WarpChannels channels = motion.rootMotionPolicy == ClimbingRootMotionPolicy::PreserveSuppliedDelta + ? WarpChannels{} + : activeWarpChannels(execution.action, t); + const Vec3 warpError = planned - (execution.currentFeet + baseDelta); + Vec3 appliedWarp; + if (channels.horizontal) { + Vec3 horizontal{warpError.x, 0.f, warpError.z}; + const float remaining = std::max(0.f, execution.action.horizontalWarpBudget - execution.horizontalWarpUsed); + horizontal = clampMagnitude(horizontal, std::min(execution.action.maxTranslationWarpPerTick, remaining)); + appliedWarp.x = horizontal.x; + appliedWarp.z = horizontal.z; + } + if (channels.vertical) { + const float remaining = std::max(0.f, execution.action.verticalWarpBudget - execution.verticalWarpUsed); + appliedWarp.y = std::clamp(warpError.y, -std::min(execution.action.maxTranslationWarpPerTick, remaining), + std::min(execution.action.maxTranslationWarpPerTick, remaining)); + } + const float remainingTotalWarp = + std::max(0.f, profile_.maxTotalWarpBudget - execution.horizontalWarpUsed - execution.verticalWarpUsed); + appliedWarp = clampMagnitude(appliedWarp, std::min(execution.action.maxTranslationWarpPerTick, remainingTotalWarp)); + execution.horizontalWarpUsed += length({appliedWarp.x, 0.f, appliedWarp.z}); + execution.verticalWarpUsed += std::fabs(appliedWarp.y); + const Vec3 desired = baseDelta + appliedWarp; + + float desiredYawDelta = motion.rootYawRadians; + if (channels.facing) { + const Vec3 targetFacing = execution.candidate.surfaceNormal * -1.f; + const float yawError = signedHorizontalAngle(motion.facing, targetFacing) - motion.rootYawRadians; + const float remaining = std::max(0.f, execution.action.facingWarpBudgetRadians - execution.facingWarpUsed); + const float correction = std::clamp(yawError, -std::min(execution.action.maxYawWarpRadiansPerTick, remaining), + std::min(execution.action.maxYawWarpRadiansPerTick, remaining)); + execution.facingWarpUsed += std::fabs(correction); + desiredYawDelta += correction; + } + const float authoredLeftHand = activeContactWeight(execution.action, ClimbingContactTarget::LeftHand, t); + const float authoredRightHand = activeContactWeight(execution.action, ClimbingContactTarget::RightHand, t); + bool leftContact = authoredLeftHand > epsilon; + bool rightContact = authoredRightHand > epsilon; + bool landContact = false; + bool compactRequested = false; + bool compactForTick = execution.compactCollisionActive; + bool branchForTick = + execution.action.branchWindows.empty() ? execution.branchWindowOpen : activeBranchWindow(execution.action, t); + for (ClimbingNotifyKind notify : motion.notifies) { + switch (notify) { + case ClimbingNotifyKind::ContactLeftHand: leftContact = true; break; + case ClimbingNotifyKind::ContactRightHand: rightContact = true; break; + case ClimbingNotifyKind::CollisionCompact: + compactRequested = true; + compactForTick = true; + break; + case ClimbingNotifyKind::BranchOpen: branchForTick = true; break; + case ClimbingNotifyKind::BranchClose: branchForTick = false; break; + case ClimbingNotifyKind::Land: + landContact = true; + compactForTick = false; + branchForTick = false; + break; + } + } + const float collisionHeight = compactForTick ? profile_.compactCapsuleHeight : profile_.capsuleHeight; + const float lowerY = execution.currentFeet.y + profile_.capsuleRadius; + const float upperY = execution.currentFeet.y + collisionHeight - profile_.capsuleRadius; + auto moved = world.moveCapsuleOwned(execution.currentFeet.x, lowerY, execution.currentFeet.z, + execution.currentFeet.x, upperY, execution.currentFeet.z, + profile_.capsuleRadius, desired.x, desired.y, desired.z, filter); + if (!moved) return eve::Result::failure(moved.status()); + ++lastQueryCount_; + lastCounters_.queryCount = lastQueryCount_; + const physics::CapsuleMove3D movement = std::move(moved).takeValue(); + lastCounters_.moverIterations = static_cast(std::max(0, movement.iterations)); + const Vec3 actual{movement.deltaX, movement.deltaY, movement.deltaZ}; + execution.currentFeet = execution.currentFeet + actual; + execution.lastPlannedFeet = planned; + const Vec3 residual = desired - actual; + lastCounters_.warpResidual = residual; + execution.accumulatedResidual = execution.accumulatedResidual + residual; + execution.compactCollisionActive = compactForTick; + execution.branchWindowOpen = branchForTick; + if (debugCapture_ == ClimbingDebugCapture::Enabled) + boundedDebugPush(motionEvidence_, ClimbingMotionEvidence{step.tick, planned, execution.currentFeet, residual, + collisionHeight, movement.constrained}); + ClimbingAdvance output{ClimbingPhase::Climbing, + execution.candidate.actionId, + execution.currentFeet, + desired, + actual, + residual, + t, + movement.constrained, + movement.grounded, + execution.candidate.support}; + output.executionId = execution.executionId; + output.appliedWarp = appliedWarp; + output.desiredYawDelta = desiredYawDelta; + output.cameraCueProfile = profile_.cameraCueProfile; + output.cameraCue = execution.action.cameraCue; + output.animationClipId = execution.action.animation.clipId; + output.animationGraphNodeId = execution.action.animation.graphNodeId; + output.animationMirrored = execution.action.animation.mirrored; + output.leftHandAnchor = execution.candidate.leftHandAnchor; + output.rightHandAnchor = execution.candidate.rightHandAnchor; + if (leftContact && !execution.leftContactEmitted) { + execution.leftContactEmitted = true; + enqueueEvent({ClimbingEventKind::ContactLeftHand, output.actionId, step.tick, execution.executionId}); + } + if (rightContact && !execution.rightContactEmitted) { + execution.rightContactEmitted = true; + enqueueEvent({ClimbingEventKind::ContactRightHand, output.actionId, step.tick, execution.executionId}); + } + if (landContact) execution.landContactReleased = true; + if (execution.action.contactConstraints.empty()) { + output.leftHandWeight = execution.leftContactEmitted && !execution.landContactReleased ? 1.f : 0.f; + output.rightHandWeight = execution.rightContactEmitted && !execution.landContactReleased ? 1.f : 0.f; + } else if (!execution.landContactReleased) { + output.leftHandWeight = authoredLeftHand; + output.rightHandWeight = authoredRightHand; + output.leftFootWeight = activeContactWeight(execution.action, ClimbingContactTarget::LeftFoot, t); + output.rightFootWeight = activeContactWeight(execution.action, ClimbingContactTarget::RightFoot, t); + output.pelvisWeight = activeContactWeight(execution.action, ClimbingContactTarget::Pelvis, t); + } + output.contactWeight = std::max({output.leftHandWeight, output.rightHandWeight, output.leftFootWeight, + output.rightFootWeight, output.pelvisWeight}); + output.compactCollisionRequested = compactRequested; + output.compactCollisionActive = execution.compactCollisionActive; + output.branchWindowOpen = execution.branchWindowOpen; + if (const std::string* comboTag = activeBranchComboTag(execution.action, t)) output.branchComboTag = *comboTag; + if (t < 0.1f) + phase_ = ClimbingPhase::Aligning; + else if (t < 0.2f) + phase_ = ClimbingPhase::Launching; + else if (t < 0.8f) + phase_ = ClimbingPhase::Climbing; + else if (t < 0.95f) + phase_ = ClimbingPhase::Landing; + else + phase_ = ClimbingPhase::Recovering; + output.phase = phase_; + const Vec3 remainingTargetError = planned - execution.currentFeet; + const bool authoredWarpMissed = motion.hasRootMotion && + motion.rootMotionPolicy == ClimbingRootMotionPolicy::ApplyActionWarp && + t + epsilon >= finalWarpWindowEnd(execution.action) && + length(remainingTargetError) > profile_.maxWarpResidual + epsilon; + if (length(execution.accumulatedResidual) > profile_.maxWarpResidual + epsilon || authoredWarpMissed) { + phase_ = ClimbingPhase::Failed; + terminalCode_ = "climbing.warp.budget_exceeded"; + output.phase = phase_; + enqueueEvent({ClimbingEventKind::Failed, output.actionId, step.tick, execution.executionId}); + if (graphBound) { + auto released = releaseAnchorReservation(); + released.ignore("failed anchor motion releases graph occupancy"); + } + execution_.reset(); + } else if (t >= 1.f - epsilon) { + if (graphBound && isAnchorHangEnd(execution.candidate.kind)) { + if (execution.candidate.kind == ClimbingActionKind::BeamBalance) + phase_ = ClimbingPhase::Balanced; + else if (execution.candidate.kind == ClimbingActionKind::PoleSwing || + execution.candidate.kind == ClimbingActionKind::BarSwing) + phase_ = ClimbingPhase::Swinging; + else + phase_ = ClimbingPhase::Hanging; + execution.branchWindowOpen = true; + output.branchWindowOpen = true; + enqueueEvent({execution.candidate.kind == ClimbingActionKind::LedgeGrab ? ClimbingEventKind::Hanging + : ClimbingEventKind::AnchorReached, + output.actionId, step.tick, execution.executionId}); + } else if (execution.candidate.kind == ClimbingActionKind::LedgeGrab) { + phase_ = ClimbingPhase::Hanging; + execution.branchWindowOpen = true; + output.branchWindowOpen = true; + enqueueEvent({ClimbingEventKind::Hanging, output.actionId, step.tick, execution.executionId}); + } else { + phase_ = ClimbingPhase::Completed; + terminalCode_ = "climbing.completed"; + if (execution.candidate.kind != ClimbingActionKind::WallRun) + enqueueEvent({ClimbingEventKind::Landed, output.actionId, step.tick, execution.executionId}); + enqueueEvent({ClimbingEventKind::Completed, output.actionId, step.tick, execution.executionId}); + if (graphBound) { + auto released = releaseAnchorReservation(); + released.ignore("completed graph dismount releases occupancy"); + } + } + output.phase = phase_; + if (phase_ == ClimbingPhase::Completed) { + output.terminalVelocity = + terminalVelocityFor(execution.action, actual, static_cast(1.0 / step.delta.seconds())); + output.hasTerminalVelocity = true; + execution_.reset(); + } + } + return eve::Result::success(std::move(output)); +} + + +} // namespace eve::climbing diff --git a/src/modules/climbing/ClimbingMotionBindings.cpp b/src/modules/climbing/ClimbingMotionBindings.cpp new file mode 100644 index 00000000..f8e77604 --- /dev/null +++ b/src/modules/climbing/ClimbingMotionBindings.cpp @@ -0,0 +1,69 @@ +#include "climbing/ClimbingBindingInternal.h" +#include "common/SquirrelBinding.h" +#include "physics/World3D.h" +namespace eve::climbing { +namespace { +template +eve::Result bindingFailure(eve::DiagnosticCode code, std::string message, std::string path = {}) { + return eve::Result::failure( + eve::Diagnostic::error(code, std::move(message), std::move(path), {}, "climbing.binding")); +} +} // namespace +void exposeClimbingMotionBindings(ssq::Class& runtime, HSQUIRRELVM vm) { + runtime.addFunc( + "advance", [vm](ScriptClimbingRuntime* value, physics::World3D* world, std::int64_t tick, float deltaSeconds) { + if (!value || !world || tick < 0) + return eve::script::projectResult( + vm, + bindingFailure(eve::DiagnosticCode::InvalidArgument, + "runtime, world, and non-negative tick are required", "advance"), + projectClimbingAdvance); + auto resolved = Climbing::resolve(value->reference); + if (!resolved.isBound()) + return eve::script::projectResult( + vm, + bindingFailure(eve::DiagnosticCode::StaleHandle, + "climbing runtime handle is stale", "runtime"), + projectClimbingAdvance); + auto duration = eve::Duration::fromSeconds(deltaSeconds); + if (!duration) return eve::script::projectStatusResult(vm, duration.status(), false, false); + return eve::script::projectResult( + vm, + resolved->advance( + *world, {eve::SimulationTick(static_cast(tick)), std::move(duration).takeValue()}), + projectClimbingAdvance); + }); + runtime.addFunc("advanceWarped", [vm](ScriptClimbingRuntime* value, physics::World3D* world, std::int64_t tick, + float dt, float dx, float dy, float dz, float fx, float fz, + bool ignoreObstacle) { + if (!value || !world || tick < 0) + return eve::script::projectResult( + vm, + bindingFailure(eve::DiagnosticCode::InvalidArgument, + "runtime, world, and nonnegative tick required", "advanceWarped"), + projectClimbingAdvance); + auto resolved = Climbing::resolve(value->reference); + if (!resolved.isBound()) + return eve::script::projectResult( + vm, + bindingFailure(eve::DiagnosticCode::StaleHandle, "climbing runtime handle is stale", + "runtime"), + projectClimbingAdvance); + auto duration = eve::Duration::fromSeconds(dt); + if (!duration) return eve::script::projectStatusResult(vm, duration.status(), false, false); + ClimbingMotionInput motion; + motion.rootTranslation = {dx, dy, dz}; + motion.facing = {fx, 0.f, fz}; + motion.hasRootMotion = true; + motion.rootMotionPolicy = ClimbingRootMotionPolicy::PreserveSuppliedDelta; + motion.obstacleCollision = + ignoreObstacle ? ClimbingObstacleCollision::IgnoreTraversedShape : ClimbingObstacleCollision::Collide; + return eve::script::projectResult( + vm, + resolved->advance(*world, + {eve::SimulationTick(static_cast(tick)), std::move(duration).takeValue()}, + motion), + projectClimbingAdvance); + }); +} +} // namespace eve::climbing diff --git a/src/modules/climbing/ClimbingRuntimeInternal.h b/src/modules/climbing/ClimbingRuntimeInternal.h new file mode 100644 index 00000000..313ada3b --- /dev/null +++ b/src/modules/climbing/ClimbingRuntimeInternal.h @@ -0,0 +1,279 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include "climbing/Climbing.h" +namespace eve::climbing::runtime_detail { + + +constexpr float epsilon = 1e-5f; +constexpr std::size_t maxDebugEntries = 64; + +class RuntimeTelemetryScope { +public: + RuntimeTelemetryScope(ClimbingTelemetryBuffer& buffer, ClimbingRuntimeCounters& counters, + eve::SimulationTick tick) noexcept + : buffer_(buffer), counters_(counters), tick_(tick), start_(std::chrono::steady_clock::now()) {} + + ~RuntimeTelemetryScope() { + const auto elapsed = std::chrono::steady_clock::now() - start_; + buffer_.record( + {tick_, static_cast(std::chrono::duration_cast(elapsed).count()), + counters_}); + } + +private: + ClimbingTelemetryBuffer& buffer_; + ClimbingRuntimeCounters& counters_; + eve::SimulationTick tick_; + std::chrono::steady_clock::time_point start_; +}; + +template +void boundedDebugPush(std::vector& values, T value) { + if (values.size() < maxDebugEntries) values.push_back(std::move(value)); +} + +template +eve::Result climbingFailure(eve::DiagnosticCode code, std::string message, std::string path = {}) { + return eve::Result::failure(eve::Diagnostic::error(code, std::move(message), std::move(path), {}, "climbing")); +} + +inline bool isFinite(float value) { return std::isfinite(value); } + +inline bool isFinite(Vec3 value) { return isFinite(value.x) && isFinite(value.y) && isFinite(value.z); } + +inline float lengthSquared(Vec3 value) { return value.x * value.x + value.y * value.y + value.z * value.z; } +inline float length(Vec3 value) { return std::sqrt(lengthSquared(value)); } + +inline Vec3 operator+(Vec3 lhs, Vec3 rhs) { return {lhs.x + rhs.x, lhs.y + rhs.y, lhs.z + rhs.z}; } +inline Vec3 operator-(Vec3 lhs, Vec3 rhs) { return {lhs.x - rhs.x, lhs.y - rhs.y, lhs.z - rhs.z}; } +inline Vec3 operator*(Vec3 value, float scale) { return {value.x * scale, value.y * scale, value.z * scale}; } + +inline Vec3 normalizedHorizontal(Vec3 value) { + value.y = 0.f; + const float length = std::sqrt(lengthSquared(value)); + return length > epsilon ? value * (1.f / length) : Vec3{}; +} + +inline Vec3 clampMagnitude(Vec3 value, float maximum) { + const float magnitude = length(value); + return magnitude > maximum && magnitude > epsilon ? value * (maximum / magnitude) : value; +} + +struct WarpChannels { + bool horizontal = false; + bool vertical = false; + bool facing = false; +}; + +inline WarpChannels activeWarpChannels(const ClimbingActionDefinition& action, float normalizedTime) { + if (action.warpWindows.empty()) return {true, true, true}; + for (const ClimbingWarpWindow& window : action.warpWindows) + if (normalizedTime + epsilon >= window.start && normalizedTime <= window.end + epsilon) + return {window.horizontal, window.vertical, window.facing}; + return {}; +} + +inline bool activeBranchWindow(const ClimbingActionDefinition& action, float normalizedTime) { + return std::any_of(action.branchWindows.begin(), action.branchWindows.end(), [&](const auto& window) { + return normalizedTime + epsilon >= window.start && normalizedTime <= window.end + epsilon; + }); +} + +/** @brief Optional combo tag. @borrowed From action; lifetime ends when the caller-owned action changes or is + * destroyed. */ +inline const std::string* activeBranchComboTag(const ClimbingActionDefinition& action, float normalizedTime) { + const auto found = std::find_if(action.branchWindows.begin(), action.branchWindows.end(), [&](const auto& window) { + return normalizedTime + epsilon >= window.start && normalizedTime <= window.end + epsilon; + }); + return found == action.branchWindows.end() ? nullptr : &found->comboTag; +} + +inline float activeContactWeight(const ClimbingActionDefinition& action, ClimbingContactTarget target, + float normalizedTime) { + float weight = 0.f; + for (const ClimbingContactConstraint& constraint : action.contactConstraints) { + if (constraint.target == target && normalizedTime + epsilon >= constraint.start && + normalizedTime <= constraint.end + epsilon) + weight = std::max(weight, constraint.maxWeight); + } + return weight; +} + +inline Vec3 terminalVelocityFor(const ClimbingActionDefinition& action, Vec3 actualDelta, float inverseDelta) { + Vec3 velocity = actualDelta * inverseDelta; + switch (action.landingPolicy) { + case ClimbingLandingPolicy::PreserveMomentum: break; + case ClimbingLandingPolicy::MatchGround: velocity.y = 0.f; break; + case ClimbingLandingPolicy::Stop: velocity = {}; break; + } + switch (action.terminalVelocityPolicy) { + case ClimbingTerminalVelocityPolicy::Preserve: break; + case ClimbingTerminalVelocityPolicy::ClampDownward: velocity.y = std::min(velocity.y, 0.f); break; + case ClimbingTerminalVelocityPolicy::Zero: velocity.y = 0.f; break; + } + return velocity; +} + +inline float finalWarpWindowEnd(const ClimbingActionDefinition& action) { + return action.warpWindows.empty() ? 1.f : action.warpWindows.back().end; +} + +inline float signedHorizontalAngle(Vec3 from, Vec3 to) { + from = normalizedHorizontal(from); + to = normalizedHorizontal(to); + if (lengthSquared(from) <= epsilon || lengthSquared(to) <= epsilon) return 0.f; + const float crossY = from.z * to.x - from.x * to.z; + const float dot = std::clamp(from.x * to.x + from.z * to.z, -1.f, 1.f); + return std::atan2(crossY, dot); +} + +inline bool isActivePhase(ClimbingPhase phase) { + return phase != ClimbingPhase::Idle && phase != ClimbingPhase::Completed && phase != ClimbingPhase::Cancelled && + phase != ClimbingPhase::Failed; +} + +inline bool isRuntimeProbeKind(ClimbingActionKind kind) { + return kind == ClimbingActionKind::Vault || kind == ClimbingActionKind::Mantle || + kind == ClimbingActionKind::LedgeGrab || kind == ClimbingActionKind::ClimbUp || + kind == ClimbingActionKind::WallRun || kind == ClimbingActionKind::Slide; +} + +inline bool isObstacleProbeKind(ClimbingActionKind kind) { + return kind == ClimbingActionKind::Vault || kind == ClimbingActionKind::Mantle || + kind == ClimbingActionKind::LedgeGrab || kind == ClimbingActionKind::ClimbUp; +} + +inline bool isAnchorHangEnd(ClimbingActionKind kind) { + return kind == ClimbingActionKind::LedgeGrab || kind == ClimbingActionKind::Shimmy || + kind == ClimbingActionKind::CornerInner || kind == ClimbingActionKind::CornerOuter || + kind == ClimbingActionKind::LedgeJump || kind == ClimbingActionKind::ClimbDown || + kind == ClimbingActionKind::LadderMount || kind == ClimbingActionKind::LadderClimb || + kind == ClimbingActionKind::BeamBalance || kind == ClimbingActionKind::PoleSwing || + kind == ClimbingActionKind::BarSwing; +} + +inline std::int64_t quantizeMillimeters(float value) { + const double scaled = std::round(static_cast(value) * 1000.0); + return static_cast(std::clamp(scaled, static_cast(std::numeric_limits::min()), + static_cast(std::numeric_limits::max()))); +} + +/** @brief Optional action. @borrowed From profile; lifetime ends when the caller-owned profile changes or is destroyed. + */ +inline const ClimbingActionDefinition* findAction(const ClimbingProfile& profile, std::string_view id) { + const auto found = std::find_if(profile.actions.begin(), profile.actions.end(), + [id](const auto& action) { return action.id == id; }); + return found == profile.actions.end() ? nullptr : &*found; +} + +inline bool isCandidateLess(const ClimbingCandidate& lhs, const ClimbingCandidate& rhs) { + if (lhs.score != rhs.score) return lhs.score < rhs.score; + if (lhs.actionId != rhs.actionId) return lhs.actionId < rhs.actionId; + if (lhs.obstacleBodyId != rhs.obstacleBodyId) return lhs.obstacleBodyId < rhs.obstacleBodyId; + return lhs.obstacleShapeId < rhs.obstacleShapeId; +} + +inline bool containsAnyTag(const std::vector& actionTags, const std::vector& policyTags) { + return std::any_of(actionTags.begin(), actionTags.end(), [&](const std::string& tag) { + return std::find(policyTags.begin(), policyTags.end(), tag) != policyTags.end(); + }); +} + +inline bool isActionEnabledForPose(const ClimbingProfile& profile, const ClimbingActionDefinition& action, + const ClimbingPose& pose) { + if (!profile.defaultActionIds.empty() && std::find(profile.defaultActionIds.begin(), profile.defaultActionIds.end(), + action.id) == profile.defaultActionIds.end()) + return false; + if (!profile.allowedActionTags.empty() && !containsAnyTag(action.tags, profile.allowedActionTags)) return false; + if (containsAnyTag(action.tags, profile.deniedActionTags)) return false; + const auto required = pose.grounded ? ClimbingSourceMode::Grounded : ClimbingSourceMode::Airborne; + return (static_cast(action.sourceModes) & static_cast(required)) != 0; +} + +inline bool isProbeRecipeMatchingKind(const ClimbingActionDefinition& action) { + switch (action.probeRecipe) { + case ClimbingProbeRecipe::Automatic: return true; + case ClimbingProbeRecipe::Obstacle: + return action.kind == ClimbingActionKind::Vault || action.kind == ClimbingActionKind::Mantle; + case ClimbingProbeRecipe::Ledge: + return action.kind == ClimbingActionKind::LedgeGrab || action.kind == ClimbingActionKind::ClimbUp; + case ClimbingProbeRecipe::Wall: return action.kind == ClimbingActionKind::WallRun; + case ClimbingProbeRecipe::Ground: return action.kind == ClimbingActionKind::Slide; + case ClimbingProbeRecipe::AnchorGraph: return !isRuntimeProbeKind(action.kind); + } + return false; +} + +inline std::optional parseTagSelector(std::string_view selector, std::string_view prefix) { + if (!selector.starts_with(prefix)) return std::nullopt; + const std::string_view digits = selector.substr(prefix.size()); + if (digits.empty()) return std::nullopt; + int value = 0; + const auto parsed = std::from_chars(digits.data(), digits.data() + digits.size(), value); + if (parsed.ec != std::errc{} || parsed.ptr != digits.data() + digits.size()) return std::nullopt; + return value; +} + +inline bool isSupportSelectorMatch(const ClimbingActionDefinition& action, int shapeTag, int materialId) { + for (const std::string& selector : action.requiredSupportTags) { + if (const auto expected = parseTagSelector(selector, "shape:")) { + if (shapeTag != *expected) return false; + } else if (const auto expected = parseTagSelector(selector, "material:")) { + if (materialId != *expected) return false; + } else { + return false; + } + } + return true; +} + +inline std::int64_t weightedMillimeters(float value, std::int32_t weight) { + return quantizeMillimeters(value) * static_cast(weight); +} + +inline std::int64_t selectionCost(const ClimbingProfile& profile, const ClimbingActionDefinition& action, + const ClimbingPose& pose, Vec3 targetDirection, Vec3 targetDelta, float heightError, + float distance, std::int64_t stableTieBreak = 0) { + const Vec3 forward = normalizedHorizontal(pose.forward); + targetDirection = normalizedHorizontal(targetDirection); + if (lengthSquared(targetDirection) <= epsilon) targetDirection = forward; + + Vec3 intentDirection = + normalizedHorizontal(pose.inputMode == ClimbingInputMode::Precision ? pose.lookIntent : pose.moveIntent); + if (lengthSquared(intentDirection) <= epsilon) intentDirection = forward; + + const float directionDot = std::clamp(forward.x * targetDirection.x + forward.z * targetDirection.z, -1.f, 1.f); + const float intentDot = + std::clamp(intentDirection.x * targetDirection.x + intentDirection.z * targetDirection.z, -1.f, 1.f); + const bool precision = pose.inputMode == ClimbingInputMode::Precision; + const float assistScale = 1.f - profile.autoAssistStrength * (precision ? 0.15f : 0.5f); + const float directionModeScale = precision ? 1.5f : 0.75f; + const float speedModeScale = precision ? 0.5f : 1.5f; + const float translationModeScale = precision ? 1.25f : 0.75f; + const float rotationModeScale = precision ? 1.5f : 0.75f; + const float intentModeScale = precision ? 1.5f : 0.5f; + const float horizontalDelta = std::sqrt(targetDelta.x * targetDelta.x + targetDelta.z * targetDelta.z); + const float warpTranslation = std::sqrt(horizontalDelta * horizontalDelta + targetDelta.y * targetDelta.y); + const float warpRotation = std::acos(directionDot); + const float intentMismatch = 1.f - intentDot; + + return static_cast(action.selectionBias) * 1000000ll + + weightedMillimeters((1.f - directionDot) * assistScale * directionModeScale, + profile.scoreWeights.direction) + + weightedMillimeters(std::fabs(pose.speed - action.minSpeed) * speedModeScale, + profile.scoreWeights.approachSpeed) + + weightedMillimeters(heightError, profile.scoreWeights.height) + + weightedMillimeters(distance, profile.scoreWeights.distance) + + weightedMillimeters(warpTranslation * translationModeScale, profile.scoreWeights.warpTranslation) + + weightedMillimeters(warpRotation * rotationModeScale, profile.scoreWeights.warpRotation) + + weightedMillimeters(intentMismatch * intentModeScale, profile.scoreWeights.intentMismatch) + stableTieBreak; +} + + +} // namespace eve::climbing::runtime_detail diff --git a/src/modules/climbing/ClimbingTrajectory.h b/src/modules/climbing/ClimbingTrajectory.h index d9222c24..f18b1906 100644 --- a/src/modules/climbing/ClimbingTrajectory.h +++ b/src/modules/climbing/ClimbingTrajectory.h @@ -18,7 +18,9 @@ inline Vec3 interpolate(Vec3 start, Vec3 end, float amount) noexcept { /** * Internal deterministic path shared by candidate validation and authoritative execution. - * Mantles rise clear of the lip before crossing it; vault and ledge approaches retain an arc. + * Mantles and wall-contact vaults rise clear of the lip before crossing it. + * Vaults with takeoff clearance and ledge + * approaches retain an arc. */ inline Vec3 trajectoryPoint(Vec3 start, const ClimbingCandidate& candidate, const ClimbingActionDefinition& action, const ClimbingProfileDefinition& profile, @@ -27,8 +29,13 @@ inline Vec3 trajectoryPoint(Vec3 start, const ClimbingCandidate& candidate, const bool surfaceFollow = action.trajectory == ClimbingTrajectoryKind::SurfaceFollow || action.trajectory == ClimbingTrajectoryKind::AnchorToAnchor; const bool ballistic = action.trajectory == ClimbingTrajectoryKind::BallisticArc; + const float frontX = start.x - candidate.frontPoint.x; + const float frontZ = start.z - candidate.frontPoint.z; + const float contactDistance = profile.capsuleRadius + 2.f * profile.skin; + const bool contactVault = action.kind == ClimbingActionKind::Vault && + frontX * frontX + frontZ * frontZ <= contactDistance * contactDistance; if (!ballistic && !surfaceFollow && - (action.kind == ClimbingActionKind::Mantle || action.kind == ClimbingActionKind::ClimbUp)) { + (action.kind == ClimbingActionKind::Mantle || action.kind == ClimbingActionKind::ClimbUp || contactVault)) { constexpr float riseEnd = 0.4f; constexpr float crossEnd = 0.85f; const float clearance = std::max({start.y, candidate.topPoint.y + profile.skin, diff --git a/test/animation_binary.cpp b/test/animation_binary.cpp new file mode 100644 index 00000000..0ffb39bf --- /dev/null +++ b/test/animation_binary.cpp @@ -0,0 +1,180 @@ +#include +#include +#include +#include +#include +#include "animation/AnimClip.h" +#include "animation/AnimClipBinary.h" +#include "animation/AnimParallelInternal.h" +#include "animation/AnimPose.h" +#include "animation/AnimSkeleton.h" +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" +using namespace eve::animation; +namespace { +using Bytes = std::vector; +void integer(Bytes& b, std::uint32_t v) { + for (int i = 0; i < 4; ++i) b.push_back(static_cast((v >> (8 * i)) & 255)); +} +void number(Bytes& b, float v) { integer(b, std::bit_cast(v)); } +void string(Bytes& b, const std::string& v) { + integer(b, static_cast(v.size())); + for (auto c : v) b.push_back(static_cast(c)); +} +Bytes fixture() { + Bytes b; + integer(b, 0x43415645); + integer(b, 1); + integer(b, 1); + number(b, 2.f); + number(b, 30.f); + integer(b, 1); + string(b, "test"); + string(b, "root"); + integer(b, 2); + for (float t : {0.f, 2.f}) { + number(b, t); + number(b, 0.f); + number(b, 0.f); + number(b, 3.f * t); + } + integer(b, 1); + number(b, 0.f); + number(b, 0.f); + number(b, 0.f); + number(b, 0.f); + number(b, 1.f); + integer(b, 1); + number(b, 0.f); + number(b, 1.f); + number(b, 1.f); + number(b, 1.f); + return b; +} +} // namespace +TEST_CASE("animation.binary.parallelBatchMatchesSerialAndRejectsAtomically") { + AnimSkeleton sk; + sk.addBone("root"); + const auto bytes = fixture(); + for (int workers : {1, 2, 8}) { + std::vector clips(32); + std::vector inputs; + for (auto& clip : clips) inputs.push_back({clip, bytes}); + auto loaded = loadAnimationTrackBatch(inputs, sk, workers); + REQUIRE(loaded.ok()); + CHECK(loaded.value() >= 1); + CHECK(loaded.value() <= workers); + for (auto& clip : clips) { + AnimPose pose; + clip.sample(0.5f, &pose, &sk); + CHECK(pose.getLocalPositionZ(0) == 1.5f); + clip.setName("unchanged"); + } + inputs[17].bytes = std::span(bytes).first(12); + auto rejected = loadAnimationTrackBatch(inputs, sk, workers); + CHECK(!rejected.ok()); + for (auto& clip : clips) CHECK(clip.getName() == "unchanged"); + inputs[17].bytes = bytes; + inputs[31].destination = clips[0]; + auto duplicate = loadAnimationTrackBatch(inputs, sk, workers); + CHECK(!duplicate.ok()); + for (auto& clip : clips) CHECK(clip.getName() == "unchanged"); + } + auto empty = loadAnimationTrackBatch({}, sk); + REQUIRE(empty.ok()); + CHECK(empty.value() == 0); + auto invalid = loadAnimationTrackBatch({}, sk, -1); + CHECK(!invalid.ok()); +} +TEST_CASE("animation.binary.parallelWorkersJoinBeforeFailureReturns") { + std::vector visited(64, 0); + bool caught = false; + try { + eve::animation::detail::parallelAnimationItems(visited.size(), 8, [&](std::size_t i) { + visited[i] = 1; + if (i == 3 || i == 30) throw std::runtime_error(std::to_string(i)); + }); + } catch (const std::runtime_error& error) { + caught = true; + CHECK(std::string(error.what()) == "3"); + } + CHECK(caught); + for (int item : visited) CHECK(item == 1); +} +TEST_CASE("animation.binary.loadsTracksAndPreservesSampling") { + AnimSkeleton sk; + sk.addBone("root"); + sk.addBone("unanimated", 0); + sk.setBindPosition(1, 0.f, 1.f, 0.f); + AnimClip clip("old"); + auto loaded = loadAnimationTracks(clip, fixture(), sk); + REQUIRE(loaded.ok()); + CHECK(clip.getName() == "test"); + CHECK(clip.getLoop()); + CHECK(clip.getDuration() == 2.f); + AnimPose p; + clip.sample(0.5f, &p, &sk); + p.computeWorld(&sk); + CHECK(std::abs(p.getWorldPositionZ(1) - 1.5f) < 1e-6f); + CHECK(p.getWorldPositionY(1) == 1.f); + clip.sample(2.5f, &p, &sk); + CHECK(std::abs(p.getLocalPositionZ(0) - 1.5f) < 1e-6f); +} +TEST_CASE("animation.binary.rejectsEveryTruncationAtomically") { + AnimSkeleton sk; + sk.addBone("root"); + AnimClip clip("unchanged"); + clip.setDuration(4.f); + const auto bytes = fixture(); + for (std::size_t size = 0; size < bytes.size(); ++size) { + auto r = loadAnimationTracks(clip, {bytes.data(), size}, sk); + CHECK(!r.ok()); + CHECK(clip.getName() == "unchanged"); + CHECK(clip.getDuration() == 4.f); + } +} +TEST_CASE("animation.binary.rejectsVersionTrailingNamesFlagsAndNonfinite") { + AnimSkeleton sk; + sk.addBone("root"); + AnimClip clip("old"); + for (int bad : {0, 1, 2, 3, 4, 5, 6, 7}) { + auto bytes = fixture(); + if (bad == 0) bytes[4] = std::byte{2}; + if (bad == 1) bytes.push_back(std::byte{0}); + if (bad == 2) bytes[36] = std::byte{'X'}; + if (bad == 3) bytes[20] = std::byte{2}; + if (bad == 4) { + Bytes nan; + number(nan, std::numeric_limits::quiet_NaN()); + for (int i = 0; i < 4; ++i) bytes[12 + i] = nan[i]; + } + if (bad == 5) bytes[36] = std::byte{0xc0}; + if (bad == 6) + for (int i = 0; i < 4; ++i) bytes[60 + i] = std::byte{0}; + if (bad == 7) + for (int i = 0; i < 4; ++i) bytes[96 + i] = std::byte{0}; + auto r = loadAnimationTracks(clip, bytes, sk); + CHECK(!r.ok()); + CHECK(clip.getName() == "old"); + } +} +TEST_CASE("animation.binary.stepBoundaryAtAdjacentFloatTimes") { + AnimClip clip("step"); + clip.setLoop(false); + AnimSkeleton sk; + sk.addBone("root"); + const float before = std::nextafter(1.f, 0.f); + clip.addPositionKey(0, 0.f, 0.f, 0.f, 0.f); + clip.addPositionKey(0, before, 0.f, 0.f, 0.f); + clip.addPositionKey(0, 1.f, 0.f, 0.f, 2.f); + clip.addPositionKey(0, 2.f, 0.f, 0.f, 2.f); + AnimPose p; + for (float t : {0.3f, before}) { + clip.sample(t, &p, &sk); + CHECK(p.getLocalPositionZ(0) == 0.f); + } + for (float t : {1.f, std::nextafter(1.f, 2.f), 1.8f}) { + clip.sample(t, &p, &sk); + CHECK(p.getLocalPositionZ(0) == 2.f); + } +} diff --git a/test/animation_curves.cpp b/test/animation_curves.cpp new file mode 100644 index 00000000..effb2221 --- /dev/null +++ b/test/animation_curves.cpp @@ -0,0 +1,117 @@ +#include +#include +#include +#include +#include +#include +#include "animation/AnimCurveLibrary.h" +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" +namespace { +using Bytes = std::vector; +void integer(Bytes& out, std::uint32_t value) { + for (int i = 0; i < 4; ++i) out.push_back(std::byte((value >> (8 * i)) & 255)); +} +void number(Bytes& out, float value) { integer(out, std::bit_cast(value)); } +void string(Bytes& out, const std::string& value) { + integer(out, static_cast(value.size())); + for (char c : value) out.push_back(std::byte(c)); +} +Bytes fixture() { + Bytes out; + integer(out, 0x43465645); + integer(out, 1); + integer(out, 2); + string(out, "walk"); + number(out, 1); + integer(out, 1); + string(out, "contact_l"); + integer(out, 3); + for (auto key : {std::pair{0.f, 0.f}, std::pair{.25f, 1.f}, std::pair{1.f, 0.f}}) { + number(out, key.first); + number(out, key.second); + } + string(out, "absent"); + number(out, 0); + integer(out, 0); + return out; +} +} // namespace +TEST_CASE("animation.curves.samplingWrapsClampsAndDistinguishesAbsence") { + eve::animation::AnimCurveLibrary curves; + auto loaded = curves.load(fixture()); + REQUIRE(loaded.ok()); + CHECK(curves.size() == 2); + for (double t : {.125, 1.125, -.875, 1000000.125}) { + auto sample = curves.sample("walk", "contact_l", t, true); + REQUIRE(sample.ok()); + REQUIRE(sample.value().has_value()); + CHECK(std::abs(*sample.value() - .5f) < 1e-6f); + } + for (double t : {-1., 2.}) { + auto sample = curves.sample("walk", "contact_l", t, false); + REQUIRE(sample.ok()); + CHECK(*sample.value() == 0); + } + auto absent = curves.sample("walk", "contact_r", 0, false); + REQUIRE(absent.ok()); + CHECK(!absent.value()); + auto empty = curves.sample("absent", "contact_l", 0, true); + REQUIRE(empty.ok()); + CHECK(!empty.value()); + auto missing = curves.sample("missing", "contact_l", 0, false); + CHECK(!missing.ok()); + auto invalid = curves.sample("walk", "contact_l", std::numeric_limits::infinity(), true); + CHECK(!invalid.ok()); +} +TEST_CASE("animation.curves.rejectsMalformedInputAtomically") { + auto bytes = fixture(); + eve::animation::AnimCurveLibrary curves; + auto loaded = curves.load(bytes); + REQUIRE(loaded.ok()); + for (std::size_t size = 0; size < bytes.size(); ++size) { + auto rejected = curves.load(std::span(bytes).first(size)); + CHECK(!rejected.ok()); + CHECK(curves.size() == 2); + } + std::vector cases; + auto version = bytes; + version[4] = std::byte(2); + cases.push_back(version); + auto trailing = bytes; + trailing.push_back(std::byte(0)); + cases.push_back(trailing); + auto nan = bytes; + // Header(12), source length/name(8), duration at offset 20. + for (int i = 0; i < 4; ++i) nan[20 + i] = std::byte((0x7fc00000u >> (i * 8)) & 255); + cases.push_back(nan); + auto duplicate = bytes; + // Duplicate a complete source record by appending the first record instead of the absent record. + duplicate.resize(bytes.size() - 18); // absent: length/name(10), duration/count(8). + duplicate.insert(duplicate.end(), bytes.begin() + 12, bytes.end() - 18); + cases.push_back(duplicate); + auto changedInteger = [&](std::size_t offset, std::uint32_t value) { + auto changed = bytes; + for (int i = 0; i < 4; ++i) changed[offset + i] = std::byte((value >> (i * 8)) & 255); + cases.push_back(changed); + }; + changedInteger(8, 100001); // source limit + changedInteger(24, 65); // channel limit + changedInteger(41, 1000001); // key limit + changedInteger(53, 0); // duplicate key time + changedInteger(61, std::bit_cast(.9f)); // missing duration endpoint + auto invalidName = bytes; + invalidName[16] = std::byte(0xff); + cases.push_back(invalidName); + auto duplicateChannel = bytes; + duplicateChannel[24] = std::byte(2); + duplicateChannel.insert(duplicateChannel.begin() + 69, bytes.begin() + 28, bytes.begin() + 69); + cases.push_back(duplicateChannel); + for (const auto& invalid : cases) { + auto rejected = curves.load(invalid); + CHECK(!rejected.ok()); + auto sample = curves.sample("walk", "contact_l", .25, false); + REQUIRE(sample.ok()); + CHECK(*sample.value() == 1); + } +} diff --git a/test/animation_inertializer.cpp b/test/animation_inertializer.cpp new file mode 100644 index 00000000..20e21998 --- /dev/null +++ b/test/animation_inertializer.cpp @@ -0,0 +1,84 @@ +#include +#include +#include +#include "animation/AnimInertializer.h" +#include "animation/AnimPose.h" +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +using namespace eve::animation; + +TEST_CASE("animation.inertializer.ownsHistoryAndHonorsBoneDurations") { + AnimInertializer inertia; + AnimPose source(3), previous(3), target(3), priorTarget(3); + for (int i = 0; i < 3; ++i) source.setLocalPosition(i, 2.f, 0.f, 0.f); + previous.copyFrom(&source); + std::array factors{0.f, .75f, 1.f}; + REQUIRE(inertia.begin(source, previous, target, priorTarget, .01f, .2f, factors).ok()); + CHECK(inertia.pose().getLocalPositionX(0) == 0.f); + CHECK(inertia.pose().getLocalPositionX(1) == 2.f); + source.setLocalPosition(1, 100.f, 0.f, 0.f); + factors[1] = 1.f; + REQUIRE(inertia.evaluate(target, .16f).ok()); + CHECK(inertia.pose().getLocalPositionX(1) == 0.f); + CHECK(inertia.pose().getLocalPositionX(2) > 0.f); + REQUIRE(inertia.evaluate(target, .2f).ok()); + CHECK(inertia.pose().getLocalPositionX(2) == 0.f); + // Evaluating absolute time again has no integration/order dependence. + REQUIRE(inertia.evaluate(target, .16f).ok()); + float atSixteen = inertia.pose().getLocalPositionX(2); + REQUIRE(inertia.evaluate(target, .03f).ok()); + REQUIRE(inertia.evaluate(target, .16f).ok()); + CHECK(inertia.pose().getLocalPositionX(2) == atSixteen); +} + +TEST_CASE("animation.inertializer.invalidInputsLeaveTransitionAndOutputUnchanged") { + AnimInertializer inertia; + AnimPose source(1), previous(1), target(1), priorTarget(1), wrong(2); + CHECK(!inertia.evaluate(target, 0.f).ok()); + source.setLocalPosition(0, 2.f, 0.f, 0.f); + previous.copyFrom(&source); + REQUIRE(inertia.begin(source, previous, target, priorTarget, .01f, .2f).ok()); + const float nan = std::numeric_limits::quiet_NaN(); + CHECK(!inertia.begin(source, previous, wrong, priorTarget, .01f, .2f).ok()); + CHECK(!inertia.begin(source, previous, target, priorTarget, 0.f, .2f).ok()); + CHECK(!inertia.begin(source, previous, target, priorTarget, .01f, nan).ok()); + std::array factors{-1.f}; + CHECK(!inertia.begin(source, previous, target, priorTarget, .01f, .2f, factors).ok()); + CHECK(!inertia.evaluate(target, -1.f).ok()); + CHECK(!inertia.evaluate(wrong, 0.f).ok()); + target.setLocalPosition(0, nan, 0.f, 0.f); + CHECK(!inertia.evaluate(target, .1f).ok()); + CHECK(inertia.pose().getLocalPositionX(0) == 2.f); + target.setLocalPosition(0, 0.f, 0.f, 0.f); + REQUIRE(inertia.evaluate(target, .2f).ok()); + CHECK(inertia.pose().getLocalPositionX(0) == 0.f); +} + +TEST_CASE("animation.inertializer.interruptionPreservesMovingRotationAndVelocity") { + AnimInertializer inertia; + AnimPose source(1), previous(1), target(1), priorTarget(1); + source.setLocalPosition(0, 2.f, 0.f, 0.f); + previous.setLocalPosition(0, 1.97f, 0.f, 0.f); + source.setLocalRotation(0, 0.f, std::sin(.4f), 0.f, std::cos(.4f)); + previous.setLocalRotation(0, 0.f, std::sin(.39f), 0.f, std::cos(.39f)); + target.setLocalRotation(0, 0.f, std::sin(.1f), 0.f, std::cos(.1f)); + priorTarget.setLocalRotation(0, 0.f, std::sin(.095f), 0.f, std::cos(.095f)); + REQUIRE(inertia.begin(source, previous, target, priorTarget, .01f, .2f).ok()); + target.setLocalRotation(0, 0.f, std::sin(.10005f), 0.f, std::cos(.10005f)); + REQUIRE(inertia.evaluate(target, .0001f).ok()); + auto q = inertia.pose().local(0); + CHECK(std::abs((2.f * std::atan2(q.qy, q.qw) - .8f) / .0001f - 2.f) < .02f); + CHECK(std::abs((q.px - 2.f) / .0001f - 3.f) < .02f); + REQUIRE(inertia.evaluate(target, .08f).ok()); + previous.copyFrom(&inertia.pose()); + REQUIRE(inertia.evaluate(target, .09f).ok()); + source.copyFrom(&inertia.pose()); + float expectedVelocity = (source.local(0).px - previous.local(0).px) / .01f; + target.setLocalPosition(0, -3.f, 0.f, 0.f); + priorTarget.copyFrom(&target); + REQUIRE(inertia.begin(source, previous, target, priorTarget, .01f, .15f).ok()); + CHECK(std::abs(inertia.pose().local(0).px - source.local(0).px) < 1e-5f); + REQUIRE(inertia.evaluate(target, .0001f).ok()); + CHECK(std::abs((inertia.pose().local(0).px - source.local(0).px) / .0001f - expectedVelocity) < .03f); +} diff --git a/test/animation_motion_continuity.cpp b/test/animation_motion_continuity.cpp new file mode 100644 index 00000000..d33fa686 --- /dev/null +++ b/test/animation_motion_continuity.cpp @@ -0,0 +1,320 @@ +#include "animation/AnimClip.h" +#include "animation/PoseInertiaInternal.h" +#include "animation/AnimSkeleton.h" +#include "animation/MotionDatabase.h" +#include "animation/MotionMatcher.h" +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include +#include + +using namespace eve::animation; + +namespace { +void locomotion(AnimClip& clip, float speed) { + clip.setDuration(0.8f); + clip.setLoop(true); + clip.addPositionKey(0, 0.f, 0.f, 0.f, 0.f); + clip.addPositionKey(0, 0.8f, 0.f, 0.f, speed * 0.8f); + clip.addPositionKey(1, 0.f, 0.f, 0.1f, -0.2f); + clip.addPositionKey(1, 0.4f, 0.f, 0.3f, 0.2f); + clip.addPositionKey(1, 0.8f, 0.f, 0.1f, -0.2f); +} +} // namespace + +TEST_CASE("animation.motionMatching.candidateIntervalsAreAtomicAndConstrainContinuation") { + AnimSkeleton skeleton; + skeleton.addBone("root"); + skeleton.addBone("foot", 0); + AnimClip idle("idle"), walk("walk"); + locomotion(idle, 0.f); + locomotion(walk, 2.f); + MotionDatabase db(&skeleton); + db.addFeatureBone(1); + db.addClip(&idle); + db.addClip(&walk); + db.bake(); + MotionMatcher matcher(&skeleton, &db); + std::vector first{{1, 0.2f, 0.5f, -0.2f, true}}; + auto selected = matcher.setCandidateRanges(first); + REQUIRE(selected.ok()); + REQUIRE(selected.value() > 0); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == 1); + CHECK(matcher.getMatchedTime() >= 0.19999f); + CHECK(matcher.getMatchedTime() <= 0.50001f); + for (auto bad : std::vector{ + {9, 0.f, 0.5f}, {0, 0.6f, 0.2f}, {0, 0.f, 9.f}, {0, 0.f, 0.1f, std::numeric_limits::quiet_NaN()}}) { + auto failed = matcher.setCandidateRanges(std::span(&bad, 1)); + CHECK(!failed.ok()); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == 1); + } + auto empty = matcher.setCandidateRanges({}); + CHECK(!empty.ok()); + std::vector second{{0, 0.f, 0.1f}}; + auto changed = matcher.setCandidateRanges(second); + REQUIRE(changed.ok()); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == 0); + CHECK(matcher.getMatchedTime() <= 0.10001f); +} + +TEST_CASE("animation.motionMatching.externalPoseAndTrajectoryInputsAreCopied") { + AnimSkeleton skeleton; + skeleton.addBone("root"); + skeleton.addBone("foot", 0); + AnimClip left("left"), right("right"); + for (auto* clip : {&left, &right}) { + clip->setDuration(1.f); + clip->setLoop(true); + } + left.addPositionKey(1, 0.f, -1.f, 0.f, 0.f); + right.addPositionKey(1, 0.f, 1.f, 0.f, 0.f); + MotionDatabase db(&skeleton); + db.addFeatureBone(1); + db.addClip(&left); + db.addClip(&right); + db.bake(); + MotionMatcher matcher(&skeleton, &db); + matcher.setPoseWeight(1.f); + AnimPose input(2); + input.setLocalPosition(1, 1.f, 0.f, 0.f); + ASSERT(matcher.setQueryPose(input).ok()); + AnimPose wrongSkeleton(1); + ASSERT(!matcher.setQueryPose(wrongSkeleton).ok()); + input.setLocalPosition(1, -1.f, 0.f, 0.f); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == 1); + std::array trajectory{}; + auto configured = matcher.setTrajectory(trajectory); + CHECK(configured.ok()); + trajectory[1].yaw = std::numeric_limits::infinity(); + auto invalid = matcher.setTrajectory(trajectory); + CHECK(!invalid.ok()); + auto shortInput = matcher.setTrajectory(std::span(trajectory).first(2)); + CHECK(!shortInput.ok()); +} + +TEST_CASE("animation.motionMatching.constantRootTrackReceivesPlanarTravel") { + AnimSkeleton skeleton; + skeleton.addBone("root"); + AnimClip clip("in-place"); + clip.setDuration(1.f); + clip.addPositionKey(0, 0.f, 0.f, 0.2f, 0.f); + clip.applyPlanarRootMotion(0, 0.f, 1.7f); + AnimPose pose; + clip.sample(0.5f, &pose, &skeleton); + CHECK(std::abs(pose.getLocalPositionZ(0) - 0.85f) < 0.0001f); + CHECK(std::abs(pose.getLocalPositionY(0) - 0.2f) < 0.0001f); +} + +TEST_CASE("animation.motionMatching.loopFeaturesPreserveForwardTravel") { + AnimSkeleton skeleton; + skeleton.addBone("root"); + skeleton.addBone("foot", 0); + AnimClip walk("walk"); + locomotion(walk, 1.7f); + MotionDatabase database(&skeleton); + database.addFeatureBone(1); + database.addClip(&walk); + database.bake(); + for (int i = 0; i < database.getFrameCount(); ++i) { + CHECK(std::abs(database.frameAt(i).velZ - 1.7f) < 0.001f); + // Constant velocity means every phase has identical trajectory features, + // even when a one-second horizon crosses more than one 0.8s cycle. + for (int f = 2; f < 8; ++f) CHECK(std::abs(database.frameAt(i).feature[f]) < 0.01f); + } +} + +TEST_CASE("animation.motionMatching.continuesCyclesAndChangesSpeedAndFacing") { + AnimSkeleton skeleton; + skeleton.addBone("root"); + skeleton.addBone("foot", 0); + AnimClip idle("idle"), walk("walk"), run("run"); + locomotion(idle, 0.f); + locomotion(walk, 1.7f); + locomotion(run, 4.2f); + MotionDatabase database(&skeleton); + database.addFeatureBone(1); + database.addClip(&idle); + database.addClip(&walk); + database.addClip(&run); + database.bake(); + MotionMatcher matcher(&skeleton, &database); + matcher.setSearchInterval(0.08f); + matcher.setBlendTime(0.14f); + matcher.setVelocityWeight(2.f); + matcher.setPoseWeight(0.15f); + for (int clip = 0; clip < 3; ++clip) { + const float speed = clip == 0 ? 0.f : (clip == 1 ? 1.7f : 4.2f); + for (int direction = 0; direction < 4; ++direction) { + const float yaw = direction * 1.57079632679f; + matcher.setDesiredYaw(yaw); + matcher.setDesiredVelocity(std::sin(yaw) * speed, std::cos(yaw) * speed); + matcher.search(); + REQUIRE(matcher.getMatchedClipIndex() == clip); + float time = matcher.getMatchedTime(); + for (int frame = 0; frame < 180; ++frame) { + matcher.update(1.f / 60.f); + CHECK(matcher.getMatchedClipIndex() == clip); + CHECK(std::abs(matcher.getMatchedTime() - time - 1.f / 60.f) < 0.0001f); + time = matcher.getMatchedTime(); + } + } + } + matcher.setDesiredVelocity(0.f, 0.f); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == 0); +} + + +TEST_CASE("animation.motionMatching.sparseFeaturesMatchFullPose") { + AnimSkeleton skeleton; + skeleton.addBone("ancestor"); + skeleton.addBone("root", 0); + skeleton.addBone("foot", 1); + skeleton.addBone("tip", 2); + skeleton.addBone("unrelated", 0); + skeleton.setBindScale(0, 1.2f, 0.9f, 1.4f); + skeleton.setBindRotation(0, 0.f, std::sin(0.15f), 0.f, std::cos(0.15f)); + skeleton.setBindPosition(2, 0.2f, 0.4f, -0.3f); + skeleton.setBindPosition(3, 0.1f, 0.2f, 0.3f); + AnimClip clip("hierarchy"); + clip.setDuration(0.6f); + clip.setSampleRate(10.f); + clip.addPositionKey(0, 0.f, 0.f, 0.f, 0.f); + clip.addPositionKey(0, 0.6f, 0.2f, 0.1f, 0.3f); + clip.addPositionKey(1, 0.f, 0.f, 0.f, 0.f); + clip.addPositionKey(1, 0.6f, 0.3f, 0.2f, 0.9f); + clip.addRotationKey(1, 0.f, 0.f, 0.f, 0.f, 1.f); + clip.addRotationKey(1, 0.6f, 0.f, std::sin(0.2f), 0.f, std::cos(0.2f)); + AnimPose pose; + auto world = [&](int bone, float time) { + clip.sample(time, &pose, &skeleton); + pose.computeWorld(&skeleton); + return pose.world(bone); + }; + auto yaw = [](const TransformTRS& t) { return std::atan2(2.f * t.qw * t.qy, 1.f - 2.f * t.qy * t.qy); }; + for (int copies : {1, 16}) + for (bool loop : {false, true}) { + clip.setLoop(false); + const auto start = world(1, 0.f), end = world(1, clip.getDuration()); + clip.setLoop(loop); + MotionDatabase db(&skeleton); + db.setRootBone(1); + db.addFeatureBone(3); + for (int copy = 0; copy < copies; ++copy) db.addClip(&clip); + db.bake(); + for (int i = 0; i < db.getFrameCount(); ++i) { + const auto& frame = db.frameAt(i); + const float time = frame.time; + const auto root = world(1, time); + const float angle = yaw(root), cs = std::cos(angle), sn = std::sin(angle); + auto future = [&](float t) { + auto value = world(1, t); + const float cycles = loop ? std::floor(t / clip.getDuration()) : 0.f; + value.px += cycles * (end.px - start.px); + value.pz += cycles * (end.pz - start.pz); + return value; + }; + const auto next = future(time + 0.1f); + const float vx = (next.px - root.px) / 0.1f; + const float vz = (next.pz - root.pz) / 0.1f; + CHECK(std::abs(frame.rootX - root.px) < 0.00001f); + CHECK(std::abs(frame.rootZ - root.pz) < 0.00001f); + CHECK(std::abs(frame.velX - vx) < 0.00001f); + CHECK(std::abs(frame.velZ - vz) < 0.00001f); + std::vector expected(13); + expected[0] = vx * cs - vz * sn; + expected[1] = vx * sn + vz * cs; + int offset = 2; + for (float horizon : {0.33f, 0.66f, 1.f}) { + const auto f = future(time + horizon); + const float dx = f.px - root.px, dz = f.pz - root.pz; + expected[offset++] = dx * cs - dz * sn; + expected[offset++] = dx * sn + dz * cs; + } + yawToForward(yaw(world(1, time + 1.f)) - angle, expected[8], expected[9]); + const auto foot = world(3, time); + expected[10] = (foot.px - root.px) * cs - (foot.pz - root.pz) * sn; + expected[11] = foot.py; + expected[12] = (foot.px - root.px) * sn + (foot.pz - root.pz) * cs; + db.normalizeFeature(expected); + for (int f = 0; f < 13; ++f) CHECK(std::abs(expected[f] - frame.feature[f]) < 0.001f); + } + } +} + +TEST_CASE("animation.motionMatching.inertiaPreservesVelocityAndInterruptedPose") { + detail::PoseInertia inertia; + AnimPose source(1), previous(1), target(1), previousTarget(1), output(1); + source.setLocalPosition(0, 2.f, 0.f, 0.f); + previous.setLocalPosition(0, 1.97f, 0.f, 0.f); + target.setLocalPosition(0, 7.f, 0.f, 0.f); + previousTarget.setLocalPosition(0, 6.99f, 0.f, 0.f); + inertia.remember(previous, .01f); + inertia.begin(source, target, previousTarget, .3f); + inertia.apply(target, 0.f, output); + CHECK(std::abs(output.getLocalPositionX(0)-2.f)<1e-5f); + target.setLocalPosition(0,7.0001f,0.f,0.f); + inertia.apply(target,.0001f,output); + CHECK(std::abs((output.getLocalPositionX(0)-2.f)/.0001f-3.f)<.02f); + inertia.apply(target,.1f,output); + AnimPose interrupted(1); interrupted.copyFrom(&output); + inertia.remember(source,.1f); + target.setLocalPosition(0,-4.f,0.f,0.f); + previousTarget.copyFrom(&target); + inertia.begin(interrupted,target,previousTarget,.2f); + inertia.apply(target,0.f,output); + CHECK(std::abs(output.getLocalPositionX(0)-interrupted.getLocalPositionX(0))<1e-5f); + inertia.apply(target,.2f,output); + CHECK(output.getLocalPositionX(0)==-4.f); +} + +TEST_CASE("animation.motionMatching.inertiaQuaternionHemisphereAndConvergence") { + detail::PoseInertia inertia; + AnimPose source(1), target(1), previousTarget(1), output(1); + source.setLocalRotation(0,0.f,std::sin(.8f),0.f,std::cos(.8f)); + target.setLocalRotation(0,0.f,0.f,0.f,-1.f); + previousTarget.copyFrom(&target); + inertia.begin(source,target,previousTarget,.2f); + inertia.apply(target,0.f,output); + CHECK(std::abs(std::abs(output.local(0).qy)-std::sin(.8f))<1e-5f); + for(int i=0;i<=20;++i) { + inertia.apply(target,i*.01f,output); + auto q=output.local(0); + CHECK(std::abs(q.qx*q.qx+q.qy*q.qy+q.qz*q.qz+q.qw*q.qw-1.f)<1e-5f); + } + CHECK(std::abs(output.local(0).qy)<1e-5f); + inertia.begin(source,target,previousTarget,0.f); + inertia.apply(target,0.f,output); + CHECK(output.local(0).qw==-1.f); +} + +TEST_CASE("animation.motionMatching.inertiaRunsThroughMatcherSearchAndAdvance") { + AnimSkeleton skeleton; + skeleton.addBone("root"); skeleton.addBone("foot",0); + AnimClip first("first"), second("second"); + for(auto* clip : {&first,&second}) {clip->setDuration(2.f);clip->setLoop(false);} + first.addPositionKey(1,0.f,0.f,0.f,0.f); + first.addPositionKey(1,2.f,6.f,0.f,0.f); + second.addPositionKey(1,0.f,7.f,0.f,0.f); + second.addPositionKey(1,2.f,9.f,0.f,0.f); + MotionDatabase db(&skeleton); db.addFeatureBone(1);db.addClip(&first);db.addClip(&second);db.bake(); + MotionMatcher matcher(&skeleton,&db);matcher.setSearchInterval(10.f);matcher.setBlendTime(.3f); + std::vector candidates{{0,0.f,1.f}}; + REQUIRE(matcher.setCandidateRanges(candidates).ok());matcher.search(); + matcher.update(.01f);matcher.update(.01f); + float before=matcher.getPose()->getLocalPositionX(1); + candidates[0].clipIndex=1; + REQUIRE(matcher.setCandidateRanges(candidates).ok());matcher.search(); + CHECK(std::abs(matcher.getPose()->getLocalPositionX(1)-before)<1e-5f); + matcher.update(.0001f); + CHECK(std::abs((matcher.getPose()->getLocalPositionX(1)-before)/.0001f-3.f)<.02f); + matcher.update(.3f); + AnimPose expected; + second.sample(matcher.getMatchedTime(),&expected,&skeleton); + CHECK(std::abs(matcher.getPose()->getLocalPositionX(1)-expected.getLocalPositionX(1))<1e-5f); +} diff --git a/test/animation_motion_curve_features.cpp b/test/animation_motion_curve_features.cpp new file mode 100644 index 00000000..127c6377 --- /dev/null +++ b/test/animation_motion_curve_features.cpp @@ -0,0 +1,105 @@ +#include +#include +#include +#include +#include "animation/AnimClip.h" +#include "animation/AnimSkeleton.h" +#include "animation/MotionDatabase.h" +#include "animation/MotionFeatureLayout.h" +#include "animation/MotionMatcher.h" +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +using namespace eve::animation; +namespace { +std::vector curveBytes() { + std::vector out; + auto integer = [&](std::uint32_t value) { + for (int i = 0; i < 4; ++i) out.push_back(std::byte((value >> (8 * i)) & 255)); + }; + auto number = [&](float value) { integer(std::bit_cast(value)); }; + auto string = [&](std::string_view value) { + integer(static_cast(value.size())); + for (char c : value) out.push_back(std::byte(c)); + }; + integer(0x43465645); integer(1); integer(3); + for (const auto name : {"positive", "negative", "absent"}) { + string(name); number(1.f); + const bool absent = std::string_view(name) == "absent"; + integer(absent ? 0 : 1); + if (absent) continue; + string("Phase"); integer(2); + const float value = std::string_view(name) == "positive" ? 1.f : -1.f; + number(0.f); number(value); number(1.f); number(value); + } + return out; +} +} + +TEST_CASE("animation.motionSchema.curveFeaturesChangeSelectionWithIdenticalBonePoses") { + AnimSkeleton skeleton; + skeleton.addBone("root"); + AnimClip positive("positive"), negative("negative"), absent("absent"); + for (auto* clip : {&positive, &negative, &absent}) clip->setDuration(1.f); + MotionFeatureChannel phase; + phase.kind = MotionFeatureKind::Curve; + phase.query = MotionFeatureQuery::Character; + phase.axes = 1; + phase.curve = "Phase"; + phase.sampleTime = -.0333f; + MotionDatabase db(&skeleton); + REQUIRE(db.setFeatureLayout({30, {phase}}).ok()); + db.addClip(&positive); db.addClip(&negative); db.addClip(&absent); + auto bytes = curveBytes(); + const std::array sources{"positive", "negative", "absent"}; + REQUIRE(db.setFeatureCurves(bytes, sources).ok()); + CHECK(!db.setFeatureCurves(std::span(bytes).first(3), sources).ok()); + CHECK(!db.setFeatureCurves(bytes, std::span(sources).first(2)).ok()); + bytes.clear(); + db.bake(); + CHECK(db.getFeatureSize() == 1); + CHECK(!db.setFeatureCurves(curveBytes(), sources).ok()); + for (int selected = 0; selected < 3; ++selected) { + MotionMatcher matcher(&skeleton, &db); + const std::array ranges{{{0, 0.f, 0.f}, {1, 0.f, 0.f}, {2, 0.f, 0.f}}}; + REQUIRE(matcher.setCandidateRanges(ranges).ok()); + AnimPose pose(1); + std::array query{{{"Phase", -.0333f, selected == 0 ? 1.f : selected == 1 ? -1.f : 0.f}}}; + REQUIRE(matcher.setFeatureQuery(pose, pose, 1.f / 60.f, {}, query).ok()); + CHECK(!matcher.setFeatureQuery(pose, pose, 1.f / 60.f, {}).ok()); + query[0].value = std::numeric_limits::infinity(); + CHECK(!matcher.setFeatureQuery(pose, pose, 1.f / 60.f, {}, query).ok()); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == selected); + } +} + +TEST_CASE("animation.motionSchema.curveContinuingPolicyUsesEligiblePlayhead") { + AnimSkeleton skeleton; + skeleton.addBone("root"); + AnimClip positive("positive"), negative("negative"); + positive.setDuration(1.f); negative.setDuration(1.f); + for (auto policy : {MotionFeatureQuery::Character, MotionFeatureQuery::Continuing}) { + MotionFeatureChannel phase; + phase.kind = MotionFeatureKind::Curve; + phase.axes = 1; + phase.curve = "Phase"; + phase.query = policy; + MotionDatabase db(&skeleton); + REQUIRE(db.setFeatureLayout({30, {phase}}).ok()); + db.addClip(&positive); db.addClip(&negative); + const std::array sources{"positive", "negative"}; + REQUIRE(db.setFeatureCurves(curveBytes(), sources).ok()); + db.bake(); + MotionMatcher matcher(&skeleton, &db); + AnimPose pose(1); + std::array query{{{"Phase", 0.f, 1.f}}}; + REQUIRE(matcher.setFeatureQuery(pose, pose, 1.f / 60.f, {}, query).ok()); + matcher.search(); + REQUIRE(matcher.getMatchedClipIndex() == 0); + query[0].value = -1.f; + REQUIRE(matcher.setFeatureQuery(pose, pose, 1.f / 60.f, {}, query).ok()); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == (policy == MotionFeatureQuery::Continuing ? 0 : 1)); + } +} diff --git a/test/animation_motion_features.cpp b/test/animation_motion_features.cpp new file mode 100644 index 00000000..91b60ce8 --- /dev/null +++ b/test/animation_motion_features.cpp @@ -0,0 +1,174 @@ +#include +#include +#include +#include "animation/AnimClip.h" +#include "animation/AnimSkeleton.h" +#include "animation/MotionDatabase.h" +#include "animation/MotionMatcher.h" +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +using namespace eve::animation; +namespace { +void bones(AnimSkeleton& skeleton) { + skeleton.addBone("root"); + skeleton.addBone("left", 0); + skeleton.addBone("right", 0); + skeleton.addBone("pelvis", 0); +} +void swing(AnimClip& clip, float direction) { + clip.setDuration(1.f); + clip.setSampleRate(60.f); + clip.addPositionKey(1, 0.f, 0.f, 0.f, -0.5f * direction); + clip.addPositionKey(1, 1.f, 0.f, 0.f, 0.5f * direction); +} +} // namespace + +TEST_CASE("animation.motionMatching.exhaustedOneShotHandsOffAndDoesNotReselect") { + AnimSkeleton skeleton; + bones(skeleton); + AnimClip start("start"), loop("loop"); + start.setDuration(.2f); + loop.setDuration(.4f); + loop.setLoop(true); + MotionDatabase db(&skeleton); + REQUIRE(db.setLocomotionFeatures(1, 2, 3).ok()); + db.addClip(&start); + db.addClip(&loop); + db.bake(); + MotionMatcher matcher(&skeleton, &db); + // An arbitrarily long search interval must not keep an expired one-shot. + matcher.setSearchInterval(10.f); + const std::array ranges{{{0, 0, .2f, -1.f, true}, {1, 0, .4f, 0.f, false}}}; + REQUIRE(matcher.setCandidateRanges(ranges).ok()); + AnimPose pose(4); + std::array samples{}; + REQUIRE(matcher.setLocomotionQuery(pose, pose, 1.f / 60.f, samples).ok()); + matcher.search(); + REQUIRE(matcher.getMatchedClipIndex() == 0); + for (int i = 0; i < 15; ++i) { + matcher.update(1.f / 60.f); + if (matcher.getMatchedClipIndex() == 0) CHECK(matcher.getMatchedTime() <= start.getDuration()); + } + CHECK(matcher.getMatchedClipIndex() == 1); + CHECK(matcher.getMatchedTime() > 0.f); + + MotionMatcher only(&skeleton, &db); + REQUIRE(only.setCandidateRanges(std::span(ranges).first(1)).ok()); + REQUIRE(only.setLocomotionQuery(pose, pose, 1.f / 60.f, samples).ok()); + for (int i = 0; i < 60; ++i) only.update(1.f / 60.f); + CHECK(only.getMatchedClipIndex() == 0); + CHECK(only.getMatchedTime() == start.getDuration()); + REQUIRE(only.setCandidateRanges(std::span(ranges).last(1)).ok()); + only.update(1.f / 60.f); + CHECK(only.getMatchedClipIndex() == 1); +} + +TEST_CASE("animation.motionMatching.locomotionSeparatesEqualPositionsByFootVelocity") { + AnimSkeleton skeleton; + bones(skeleton); + AnimClip forward("swing-forward"), backward("swing-backward"); + swing(forward, 1.f); + swing(backward, -1.f); + MotionDatabase db(&skeleton); + REQUIRE(db.setLocomotionFeatures(1, 2, 3).ok()); + db.addClip(&forward); + db.addClip(&backward); + db.bake(); + CHECK(db.getFeatureSize() == 30); + for (int direction : {-1, 1}) { + MotionMatcher matcher(&skeleton, &db); + const std::array ranges{{{0, .5f, .5f}, {1, .5f, .5f}}}; + REQUIRE(matcher.setCandidateRanges(ranges).ok()); + AnimPose current(4), previous(4); + previous.setLocalPosition(1, 0.f, 0.f, -direction / 60.f); + std::array samples{}; + REQUIRE(matcher.setLocomotionQuery(current, previous, 1.f / 60.f, samples).ok()); + // Mutating the caller's pose cannot alter the stored query. + previous.setLocalPosition(1, 0.f, 0.f, 100.f); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == (direction == 1 ? 0 : 1)); + } +} + +TEST_CASE("animation.motionMatching.locomotionSeparatesIntermediateFacing") { + AnimSkeleton skeleton; + bones(skeleton); + AnimClip left("turn-left-return"), right("turn-right-return"); + for (auto* clip : {&left, &right}) { + const float angle = clip == &left ? .6f : -.6f; + clip->setDuration(1.1f); + clip->addRotationKey(0, 0.f, 0.f, 0.f, 0.f, 1.f); + clip->addRotationKey(0, .35f, 0.f, std::sin(angle * .5f), 0.f, std::cos(angle * .5f)); + clip->addRotationKey(0, .7f, 0.f, 0.f, 0.f, 1.f); + clip->addRotationKey(0, 1.1f, 0.f, 0.f, 0.f, 1.f); + } + MotionDatabase db(&skeleton); + REQUIRE(db.setLocomotionFeatures(1, 2, 3).ok()); + db.addClip(&left); + db.addClip(&right); + db.bake(); + for (int direction : {-1, 1}) { + MotionMatcher matcher(&skeleton, &db); + const std::array ranges{{{0, 0, 0}, {1, 0, 0}}}; + REQUIRE(matcher.setCandidateRanges(ranges).ok()); + AnimPose pose(4); + std::array samples{}; + samples[2].yaw = direction * .6f; + REQUIRE(matcher.setLocomotionQuery(pose, pose, 1.f / 60.f, samples).ok()); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == (direction == 1 ? 0 : 1)); + } +} + +TEST_CASE("animation.motionMatching.locomotionConfigurationRejectsPartialInputs") { + AnimSkeleton skeleton; + bones(skeleton); + MotionDatabase db(&skeleton); + REQUIRE(!db.setLocomotionFeatures(1, 1, 3).ok()); + CHECK(!db.hasLocomotionFeatures()); + REQUIRE(db.setLocomotionFeatures(1, 2, 3).ok()); + AnimClip clip("still"); + clip.setDuration(1.f); + db.addClip(&clip); + db.bake(); + REQUIRE(!db.setLocomotionFeatures(2, 1, 3).ok()); + CHECK(db.getFeatureBone(0) == 1); + MotionMatcher matcher(&skeleton, &db); + CHECK_THROWS((matcher.search(), false)); + AnimPose pose(4), wrong(3); + std::array samples{}; + REQUIRE(matcher.setLocomotionQuery(pose, pose, 1.f / 60.f, samples).ok()); + REQUIRE(!matcher.setLocomotionQuery(wrong, pose, 1.f / 60.f, samples).ok()); + REQUIRE(!matcher.setLocomotionQuery(pose, pose, 0.f, samples).ok()); + REQUIRE(!matcher.setLocomotionQuery(pose, pose, 1.f / 60.f, std::span(samples).first(4)).ok()); + samples[3].yaw = std::numeric_limits::infinity(); + REQUIRE(!matcher.setLocomotionQuery(pose, pose, 1.f / 60.f, samples).ok()); + samples[3].yaw = 0.f; + pose.setLocalPosition(1, std::numeric_limits::quiet_NaN(), 0.f, 0.f); + REQUIRE(!matcher.setLocomotionQuery(pose, pose, 1.f / 60.f, samples).ok()); + matcher.search(); + CHECK(std::isfinite(matcher.getLastSearchCost())); +} + +TEST_CASE("animation.motionMatching.locomotionRootTravelDoesNotBecomeFootSwing") { + AnimSkeleton skeleton; + bones(skeleton); + AnimClip clip("root-travel"); + clip.setDuration(.8f); + clip.setLoop(true); + clip.addPositionKey(0, 0.f, 0.f, 0.f, 0.f); + clip.addPositionKey(0, .8f, 0.f, 0.f, 1.6f); + skeleton.setBindPosition(1, .1f, .05f, 0.f); + skeleton.setBindPosition(2, -.1f, .05f, 0.f); + MotionDatabase db(&skeleton); + REQUIRE(db.setLocomotionFeatures(1, 2, 3).ok()); + db.addClip(&clip); + db.bake(); + for (int i = 0; i < db.getFrameCount(); ++i) { + const auto& f = db.frameAt(i); + CHECK(std::abs(f.velZ - 2.f) < .0001f); + for (int axis = 22; axis < 28; ++axis) CHECK(std::abs(f.feature[axis]) < .001f); + for (int axis : {0, 1, 2, 3, 6, 7, 10, 11, 12, 13}) CHECK(std::abs(f.feature[axis]) < .001f); + } +} diff --git a/test/animation_motion_normalization.cpp b/test/animation_motion_normalization.cpp new file mode 100644 index 00000000..aa5f8b69 --- /dev/null +++ b/test/animation_motion_normalization.cpp @@ -0,0 +1,81 @@ +#include "animation/AnimClip.h" +#include "animation/AnimSkeleton.h" +#include "animation/MotionDatabase.h" +#include "animation/MotionFeatureLayout.h" +#include "zeroerr/unittest.h" + +#include +#include + +using namespace eve::animation; + +namespace { +MotionFeatureLayout positionLayout() { + MotionFeatureLayout layout; + layout.sampleRate = 10; + MotionFeatureChannel channel; + channel.kind = MotionFeatureKind::Position; + channel.source = MotionFeatureSource::Pose; + channel.query = MotionFeatureQuery::Character; + channel.bone = 1; + channel.origin = 0; + channel.axes = 1; + channel.weight = 1.f; + layout.channels.push_back(channel); + return layout; +} + +struct NormalizationFixture { + AnimSkeleton skeleton; + AnimClip low{"low"}, high{"high"}; + NormalizationFixture() { + skeleton.addBone("root"); skeleton.addBone("sample", 0); + for (auto* clip : {&low, &high}) { clip->setDuration(1.f); clip->setLoop(false); } + low.addPositionKey(1, 0.f, 0.f, 0.f, 0.f); + high.addPositionKey(1, 0.f, 10.f, 0.f, 0.f); + } + void add(MotionDatabase& database) { database.addClip(&low); database.addClip(&high); } +}; +} + +TEST_CASE("animation.motionSchema.normalizationRangesPreserveMultiplicityAndSamplingIntervals") { + NormalizationFixture fixture; + MotionDatabase database(&fixture.skeleton); + fixture.add(database); + REQUIRE(database.setFeatureLayout(positionLayout()).ok()); + std::vector ranges{{0, 0.f, 1.f}, {0, 0.f, 1.f}, {1, 0.f, 1.f}}; + auto configured = database.setFeatureNormalizationRanges(ranges); + REQUIRE(configured.ok()); + CHECK(configured.value() == 33); + ranges.clear(); + database.bake(); + float high = 0.f; + database.getFeature(11, &high, 1); + CHECK(std::abs(high - 1.5f) < 1e-5f); + + MotionDatabase ordinary(&fixture.skeleton); + fixture.add(ordinary); + REQUIRE(ordinary.setFeatureLayout(positionLayout()).ok()); + ordinary.bake(); + ordinary.getFeature(11, &high, 1); + CHECK(std::abs(high - 1.f) < 1e-5f); +} + +TEST_CASE("animation.motionSchema.normalizationRangeValidationIsAtomic") { + NormalizationFixture fixture; + MotionDatabase database(&fixture.skeleton); + fixture.add(database); + REQUIRE(database.setFeatureLayout(positionLayout()).ok()); + std::vector valid{{0, 0.f, 0.f}}; + REQUIRE(database.setFeatureNormalizationRanges(valid).ok()); + for (const auto& invalid : std::vector{ + {-1, 0.f, 1.f}, {2, 0.f, 1.f}, {0, -.1f, 1.f}, {0, .5f, .4f}, + {0, 0.f, 2.f}, {0, 0.f, std::numeric_limits::quiet_NaN()}}) { + REQUIRE(!database.setFeatureNormalizationRanges(std::span(&invalid, 1)).ok()); + } + database.bake(); + float high = 0.f; + database.getFeature(11, &high, 1); + // The retained one-sample low range has zero deviation and therefore unit scale. + CHECK(std::abs(high - 10.f) < 1e-5f); +} diff --git a/test/animation_motion_schema.cpp b/test/animation_motion_schema.cpp new file mode 100644 index 00000000..529b4af3 --- /dev/null +++ b/test/animation_motion_schema.cpp @@ -0,0 +1,163 @@ +#include +#include +#include "animation/AnimClip.h" +#include "animation/AnimSkeleton.h" +#include "animation/MotionDatabase.h" +#include "animation/MotionFeatureLayout.h" +#include "animation/MotionMatcher.h" +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +using namespace eve::animation; + +TEST_CASE("animation.motionSchema.velocitySelectionCopiesQueryAndRejectsInvalidReplacement") { + AnimSkeleton skeleton; + skeleton.addBone("root"); + skeleton.addBone("foot", 0); + AnimClip forward("forward"), backward("backward"); + for (auto* clip : {&forward, &backward}) { + const float direction = clip == &forward ? 1.f : -1.f; + clip->setDuration(1.f); + clip->addPositionKey(1, 0.f, 0.f, 0.f, -.5f * direction); + clip->addPositionKey(1, 1.f, 0.f, 0.f, .5f * direction); + } + MotionFeatureChannel channel; + channel.kind = MotionFeatureKind::Velocity; + channel.query = MotionFeatureQuery::Character; + channel.bone = 1; + channel.axes = 4; + MotionFeatureLayout layout{20, {channel}}; + MotionDatabase db(&skeleton); + REQUIRE(db.setFeatureLayout(layout).ok()); + layout.channels[0].axes = 7; + CHECK(db.getFeatureSize() == 1); + db.addClip(&forward); + db.addClip(&backward); + db.bake(); + CHECK(db.getFrameCount() == 42); + CHECK(!db.setFeatureLayout(layout).ok()); + for (int direction : {-1, 1}) { + MotionMatcher matcher(&skeleton, &db); + const std::array ranges{{{0, .5f, .5f}, {1, .5f, .5f}}}; + REQUIRE(matcher.setCandidateRanges(ranges).ok()); + AnimPose current(2), previous(2); + previous.setLocalPosition(1, 0.f, 0.f, -direction / 60.f); + REQUIRE(matcher.setFeatureQuery(current, previous, 1.f / 60.f, {}).ok()); + previous.setLocalPosition(1, 0.f, 0.f, 100.f); + CHECK(!matcher.setFeatureQuery(current, previous, 0.f, {}).ok()); + CHECK(!matcher.setQueryPose(current).ok()); + const std::array basic{}; + CHECK(!matcher.setTrajectory(basic).ok()); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == (direction == 1 ? 0 : 1)); + } +} + +TEST_CASE("animation.motionSchema.layoutFailurePreservesConfiguration") { + AnimSkeleton skeleton; + skeleton.addBone("root"); + MotionDatabase db(&skeleton); + MotionFeatureChannel channel; + channel.kind = MotionFeatureKind::Velocity; + channel.normalizationGroup = "speed"; + MotionFeatureLayout layout{30, {channel}}; + REQUIRE(db.setFeatureLayout(layout).ok()); + layout.channels.push_back(channel); + layout.channels.back().normalizeVelocity = true; + layout.channels.back().axes = 0; + CHECK(!db.setFeatureLayout(layout).ok()); + CHECK(db.getFeatureSize() == 3); + layout.channels.pop_back(); + layout.channels[0].weight = std::numeric_limits::quiet_NaN(); + CHECK(!db.setFeatureLayout(layout).ok()); + CHECK(db.getFeatureSize() == 3); + CHECK(db.hasFeatureLayout()); +} + +TEST_CASE("animation.motionSchema.mixedVelocityPoolPreservesAuthoredLengthScale") { + AnimSkeleton skeleton;skeleton.addBone("root"); + AnimClip clip("travel");clip.setDuration(1.f); + clip.addPositionKey(0,0.f,0.f,0.f,0.f);clip.addPositionKey(0,1.f,2.f,0.f,0.f); + MotionFeatureChannel raw; + raw.kind=MotionFeatureKind::Velocity;raw.axes=1;raw.characterSpaceVelocity=false; + raw.normalizationGroup="speed";raw.query=MotionFeatureQuery::Character; + auto normalized=raw;normalized.normalizeVelocity=true; + MotionDatabase db(&skeleton); + REQUIRE(db.setFeatureLayout({30,{raw,normalized},100.f}).ok()); + db.addClip(&clip);db.bake(); + const auto& feature=db.frameAt(15).feature; + CHECK(std::abs(feature[0]-1.f)<.0001f); + CHECK(std::abs(feature[1]+1.f)<.0001f); + MotionMatcher matcher(&skeleton,&db); + AnimPose current(1),previous(1);current.setLocalPosition(0,1.f,0.f,0.f); + previous.setLocalPosition(0,1.f-2.f/60.f,0.f,0.f); + REQUIRE(matcher.setFeatureQuery(current,previous,1.f/60.f,{}).ok()); + matcher.search();CHECK(matcher.getLastSearchCost()<.000001f); +} + +TEST_CASE("animation.motionSchema.playRateMatchesUnnormalizedTrajectorySpeedRatio") { + AnimSkeleton skeleton;skeleton.addBone("root"); + AnimClip clip("travel");clip.setDuration(2.f); + clip.addPositionKey(0,0.f,0.f,0.f,0.f);clip.addPositionKey(0,2.f,2.f,0.f,0.f); + MotionFeatureChannel velocity; + velocity.kind=MotionFeatureKind::Velocity;velocity.source=MotionFeatureSource::Trajectory; + velocity.query=MotionFeatureQuery::Character;velocity.axes=1;velocity.sampleTime=0.f; + velocity.characterSpaceVelocity=false;velocity.normalizeVelocity=false; + MotionDatabase db(&skeleton);REQUIRE(db.setFeatureLayout({30,{velocity},100.f}).ok()); + db.addClip(&clip);db.bake(); + MotionMatcher matcher(&skeleton,&db); + const std::array ranges{{MotionSearchRange{0,0.f,0.f}}}; + REQUIRE(matcher.setCandidateRanges(ranges).ok()); + REQUIRE(matcher.setPlayRateRange(.85f,1.15f).ok()); + const float oldMin=matcher.getPlayRateMinimum(),oldMax=matcher.getPlayRateMaximum(); + CHECK(!matcher.setPlayRateRange(1.2f,.8f).ok()); + CHECK(matcher.getPlayRateMinimum()==oldMin);CHECK(matcher.getPlayRateMaximum()==oldMax); + AnimPose pose(1);std::array samples{};samples[0].vx=2.f; + REQUIRE(matcher.setFeatureQuery(pose,pose,1.f/60.f,samples).ok()); + matcher.search();CHECK(std::abs(matcher.getPlayRate()-1.15f)<.0001f); + matcher.update(.5f);CHECK(std::abs(matcher.getMatchedTime()-.575f)<.0001f); +} + +TEST_CASE("animation.motionSchema.arbitraryTrajectoryTimesSelectTravelAcrossClipBoundaries") { + AnimSkeleton skeleton; + skeleton.addBone("root"); + for (bool loop : {false, true}) { + AnimClip forward("forward"), backward("backward"); + for (auto* clip : {&forward, &backward}) { + clip->setDuration(1.f); + clip->setLoop(loop); + clip->addPositionKey(0, 0.f, 0.f, 0.f, 0.f); + clip->addPositionKey(0, 1.f, clip == &forward ? 2.f : -2.f, 0.f, 0.f); + } + MotionFeatureChannel past; + past.source = MotionFeatureSource::Trajectory; + past.query = MotionFeatureQuery::Character; + past.axes = 1; + past.sampleTime = -.2f; + MotionFeatureChannel future = past; + future.sampleTime = 1.35f; + MotionDatabase db(&skeleton); + REQUIRE(db.setFeatureLayout({20, {past, future}}).ok()); + db.addClip(&forward); + db.addClip(&backward); + db.bake(); + CHECK(db.getFrameCount() == (loop ? 40 : 42)); + for (int direction : {-1, 1}) { + MotionMatcher matcher(&skeleton, &db); + const std::array ranges{{{0, 0.f, 0.f}, {1, 0.f, 0.f}}}; + REQUIRE(matcher.setCandidateRanges(ranges).ok()); + AnimPose pose(1); + std::array samples{}; + samples[0].seconds = -.2f; + samples[0].x = -.4f * direction; + samples[1].seconds = 1.35f; + samples[1].x = 2.7f * direction; + REQUIRE(matcher.setFeatureQuery(pose, pose, 1.f / 60.f, samples).ok()); + CHECK(!matcher.setFeatureQuery(pose, pose, 1.f / 60.f, std::span(samples).first(1)).ok()); + samples[1].seconds = samples[0].seconds; + CHECK(!matcher.setFeatureQuery(pose, pose, 1.f / 60.f, samples).ok()); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == (direction == 1 ? 0 : 1)); + } + } +} diff --git a/test/animation_motion_transition_blocks.cpp b/test/animation_motion_transition_blocks.cpp new file mode 100644 index 00000000..ca0afb03 --- /dev/null +++ b/test/animation_motion_transition_blocks.cpp @@ -0,0 +1,230 @@ +#include "animation/AnimClip.h" +#include "animation/AnimSkeleton.h" +#include "animation/MotionDatabase.h" +#include "animation/MotionMatcher.h" +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include +#include + +using namespace eve::animation; + +namespace { +struct BlockFixture { + AnimSkeleton skeleton; + AnimClip first{"first"}, second{"second"}; + MotionDatabase database{&skeleton}; + BlockFixture() { + skeleton.addBone("root"); + for (auto* clip : {&first, &second}) { + clip->setDuration(1.f); + clip->setLoop(false); + clip->addPositionKey(0, 0.f, 0.f, 0.f, 0.f); + database.addClip(clip); + } + database.bake(); + } +}; +} + +TEST_CASE("animation.motionMatching.transitionBlocksPreventEntryButPreserveContinuation") { + BlockFixture fixture; + MotionMatcher continuing(&fixture.skeleton, &fixture.database); + continuing.setSearchInterval(10.f); + std::vector seed{{0, 0.f, 0.f}}; + REQUIRE(continuing.setCandidateRanges(seed).ok()); + continuing.search(); + continuing.update(.3f); + const float time = continuing.getMatchedTime(); + REQUIRE(time > .1f); + std::vector ranges{{0, 0.f, 1.f, -100.f, false, {{.1f, .8f}}}, + {1, 0.f, 1.f, 0.f}}; + REQUIRE(continuing.setCandidateRanges(ranges).ok()); + continuing.search(); + CHECK(continuing.getMatchedClipIndex() == 0); + CHECK(std::abs(continuing.getMatchedTime() - time) < 1e-6f); + + // A fresh search would strongly prefer clip 0's bias, but its selected + // middle section is protected. This does not remove it from continuation. + ranges[0].start = .2f; + ranges[0].end = .7f; + MotionMatcher fresh(&fixture.skeleton, &fixture.database); + REQUIRE(fresh.setCandidateRanges(ranges).ok()); + fresh.search(); + CHECK(fresh.getMatchedClipIndex() == 1); + REQUIRE(continuing.setCandidateRanges(ranges).ok()); + continuing.search(); + CHECK(continuing.getMatchedClipIndex() == 0); + + // Ordinary membership still applies even to a protected ongoing pose. + REQUIRE(continuing.setCandidateRanges(std::span(ranges).last(1)).ok()); + continuing.search(); + CHECK(continuing.getMatchedClipIndex() == 1); +} + +TEST_CASE("animation.motionMatching.transitionBlockBoundariesAreCopiedAndUnioned") { + BlockFixture fixture; + const float start = fixture.database.getFrameTime(3); + const float end = fixture.database.getFrameTime(4); + REQUIRE(end > start); + std::vector ranges{{0, start, start, -100.f, false, {{start, end}}}, + {0, start, start, -200.f}, + {1, 0.f, 0.f}}; + MotionMatcher matcher(&fixture.skeleton, &fixture.database); + REQUIRE(matcher.setCandidateRanges(ranges).ok()); + ranges[0].transitionBlocks.clear(); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == 1); + + // Exact end is eligible; exact start was blocked, including an overlapping + // duplicate candidate range with a better score and no block of its own. + ranges = {{0, end, end, -100.f, false, {{start, end}}}}; + REQUIRE(matcher.setCandidateRanges(ranges).ok()); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == 0); + CHECK(matcher.getMatchedTime() == end); +} + +TEST_CASE("animation.motionMatching.transitionBlockFailureIsAtomicAndAllBlockedCanRecover") { + BlockFixture fixture; + MotionMatcher matcher(&fixture.skeleton, &fixture.database); + std::vector blocked{{0, 0.f, 1.f, 0.f, false, {{0.f, 2.f}}}}; + REQUIRE(matcher.setCandidateRanges(blocked).ok()); + matcher.search(); + CHECK(matcher.getMatchedFrame() == -1); + for (const auto& invalid : {MotionTransitionBlock{-1.f, 1.f}, {1.f, 1.f}, {2.f, 1.f}, + {0.f, std::numeric_limits::infinity()}, + {std::numeric_limits::quiet_NaN(), 1.f}}) { + std::vector attempted{{1, 0.f, 1.f, 0.f, false, {invalid}}}; + REQUIRE(!matcher.setCandidateRanges(attempted).ok()); + matcher.search(); + CHECK(matcher.getMatchedFrame() == -1); + } + std::vector recovered{{1, 0.f, 1.f}}; + REQUIRE(matcher.setCandidateRanges(recovered).ok()); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == 1); +} + +TEST_CASE("animation.motionMatching.poseReselectHistoryExpiresAndKeepsContinuationEligible") { + BlockFixture fixture; + MotionMatcher matcher(&fixture.skeleton, &fixture.database); + matcher.setSearchInterval(10.f); + REQUIRE(matcher.setPoseReselectHistory(.3f).ok()); + REQUIRE(!matcher.setPoseReselectHistory(std::numeric_limits::quiet_NaN()).ok()); + CHECK(std::abs(matcher.getPoseReselectHistory() - .3f) < 1e-6f); + std::vector seed{{0, 0.f, 0.f}}; + REQUIRE(matcher.setCandidateRanges(seed).ok()); + matcher.update(.1f); + + std::vector ranges{{0, .1f, .1f, -100.f, false, {}, 200.f}, + {1, 0.f, 1.f, 0.f}}; + REQUIRE(matcher.setCandidateRanges(ranges).ok()); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == 1); + + matcher.update(.31f); + ranges[1].continuingCostBias = 200.f; + REQUIRE(matcher.setCandidateRanges(ranges).ok()); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == 0); +} + +TEST_CASE("animation.motionMatching.anyStrictCostImprovementCanSwitch") { + BlockFixture fixture; + MotionMatcher matcher(&fixture.skeleton, &fixture.database); + matcher.setSearchInterval(10.f); + std::vector seed{{0, 0.f, 0.f}}; + REQUIRE(matcher.setCandidateRanges(seed).ok()); + matcher.search(); + std::vector ranges{{0, 0.f, 1.f, 0.f, false, {}, 1.f}, + {1, 0.f, 1.f, .95f}}; + REQUIRE(matcher.setCandidateRanges(ranges).ok()); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == 1); +} + +TEST_CASE("animation.motionMatching.replacementRangesInvalidateDistantSameClipContinuation") { + BlockFixture fixture; + MotionMatcher matcher(&fixture.skeleton, &fixture.database); + matcher.setSearchInterval(10.f); + std::vector seed{{0, 0.f, 0.f}}; + REQUIRE(matcher.setCandidateRanges(seed).ok()); + matcher.search(); + matcher.update(.3f); + REQUIRE(matcher.getMatchedClipIndex() == 0); + REQUIRE(matcher.getMatchedTime() < .5f); + + // The same asset is still present, but only at a distant phase. It cannot + // masquerade as continuation of the current out-of-range playhead. + std::vector ranges{{0, .8f, 1.f, 10.f}, {1, 0.f, 1.f, 0.f}}; + REQUIRE(matcher.setCandidateRanges(ranges).ok()); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == 1); +} + +TEST_CASE("animation.motionMatching.authoredCandidateCostOverrideUsesLastActiveWindow") { + BlockFixture fixture; + MotionMatcher matcher(&fixture.skeleton, &fixture.database); + const float time = fixture.database.getFrameTime(3); + std::vector ranges{ + {0, time, time, -10.f, false, {}, 0.f, {{0.f, 1.f, -2.f}, {time, time + .01f, 4.f}}}, + {1, time, time, 0.f}}; + REQUIRE(matcher.setCandidateRanges(ranges).ok()); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == 1); + + // The override end is exclusive, so its base -10 cost wins exactly there. + const float end = fixture.database.getFrameTime(4); + ranges[0].start = end; + ranges[0].end = end; + ranges[0].costOverrides = {{0.f, end, 4.f}}; + ranges[1].start = end; + ranges[1].end = end; + MotionMatcher boundary(&fixture.skeleton, &fixture.database); + REQUIRE(boundary.setCandidateRanges(ranges).ok()); + boundary.search(); + CHECK(boundary.getMatchedClipIndex() == 0); +} + +TEST_CASE("animation.motionMatching.continuingCostBiasAndOverrideControlRetention") { + BlockFixture fixture; + MotionMatcher matcher(&fixture.skeleton, &fixture.database); + matcher.setSearchInterval(10.f); + std::vector seed{{0, 0.f, 0.f}}; + REQUIRE(matcher.setCandidateRanges(seed).ok()); + matcher.search(); + matcher.update(.3f); + REQUIRE(matcher.getMatchedClipIndex() == 0); + + std::vector ranges{ + {0, 0.f, 1.f, 0.f, false, {}, -5.f}, + {1, 0.f, 1.f, -1.f}}; + REQUIRE(matcher.setCandidateRanges(ranges).ok()); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == 0); + + // The source notify replaces the database continuing bias at this pose. + ranges[0].continuingCostOverrides = {{.2f, .8f, 5.f}}; + REQUIRE(matcher.setCandidateRanges(ranges).ok()); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == 1); +} + +TEST_CASE("animation.motionMatching.duplicateRangeRetainsCostPairFromWinningCandidate") { + BlockFixture fixture; + MotionMatcher matcher(&fixture.skeleton, &fixture.database); + matcher.setSearchInterval(10.f); + std::vector seed{{0, 0.f, 0.f}}; + REQUIRE(matcher.setCandidateRanges(seed).ok()); + matcher.search(); + matcher.update(.3f); + std::vector ranges{ + {0, 0.f, 1.f, -2.f, false, {}, 10.f}, + {0, 0.f, 1.f, -1.f, false, {}, -10.f}, + {1, 0.f, 1.f, 0.f}}; + REQUIRE(matcher.setCandidateRanges(ranges).ok()); + matcher.search(); + CHECK(matcher.getMatchedClipIndex() == 1); +} diff --git a/test/animation_two_bone_precision.cpp b/test/animation_two_bone_precision.cpp new file mode 100644 index 00000000..421679df --- /dev/null +++ b/test/animation_two_bone_precision.cpp @@ -0,0 +1,69 @@ +#include +#include +#include "animation/AnimPose.h" +#include "animation/AnimSkeleton.h" +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" +using namespace eve::animation; +TEST_CASE("animation.pose.twoBoneIkRespectsRotatedScaledParent") { + AnimSkeleton skeleton; + skeleton.addBone("parent"); + skeleton.addBone("hip", 0); + skeleton.addBone("knee", 1); + skeleton.addBone("ankle", 2); + skeleton.setBindPosition(0, .5f, .25f, -.3f); + skeleton.setBindRotation(0, 0.f, .70710678f, 0.f, .70710678f); + skeleton.setBindScale(0, 1.5f, 1.5f, 1.5f); + skeleton.setBindPosition(1, 0.f, .1f, 0.f); + skeleton.setBindPosition(2, 0.f, 1.f, 0.f); + skeleton.setBindPosition(3, 0.f, 1.f, 0.f); + AnimPose pose; + skeleton.applyBindPose(&pose); + REQUIRE(pose.solveTwoBoneIK(&skeleton, 1, 2, 3, .8f, 2.1f, .1f, 1.f)); + CHECK(std::abs(pose.world(3).px - .8f) < .0003f); + CHECK(std::abs(pose.world(3).py - 2.1f) < .0003f); + CHECK(std::abs(pose.world(3).pz - .1f) < .0003f); +} +TEST_CASE("animation.pose.twoBoneIkReachesTargetsWithoutIteration") { + AnimSkeleton skeleton; + skeleton.addBone("hip"); + skeleton.addBone("knee", 0); + skeleton.addBone("ankle", 1); + skeleton.setBindPosition(1, 0, 1, 0); + skeleton.setBindPosition(2, 0, 1, 0); + AnimPose pose; + for (auto target : {std::array{.2f, 1.4f, .1f}, std::array{0.f, 1.99f, 0.f}, std::array{0.f, .01f, 0.f}, + std::array{-.3f, -1.5f, .2f}}) { + skeleton.applyBindPose(&pose); + REQUIRE(pose.solveTwoBoneIK(&skeleton, 0, 1, 2, target[0], target[1], target[2], 1)); + float error = 0; + const auto& p = pose.world(2); + error = + std::sqrt(std::pow(p.px - target[0], 2) + std::pow(p.py - target[1], 2) + std::pow(p.pz - target[2], 2)); + CHECK(error < .0003f); + const auto before = pose.world(1); + REQUIRE(pose.solveTwoBoneIK(&skeleton, 0, 1, 2, target[0], target[1], target[2], 1)); + const auto after = pose.world(1); + CHECK(std::abs(before.px - after.px) < .0003f); + CHECK(std::abs(before.py - after.py) < .0003f); + CHECK(std::abs(before.pz - after.pz) < .0003f); + } +} +TEST_CASE("animation.pose.twoBoneIkClampsReachAndKeepsZeroWeightPose") { + AnimSkeleton skeleton; + skeleton.addBone("hip"); + skeleton.addBone("knee", 0); + skeleton.addBone("ankle", 1); + skeleton.setBindPosition(1, 0, 1, 0); + skeleton.setBindPosition(2, 0, 1, 0); + AnimPose pose; + skeleton.applyBindPose(&pose); + REQUIRE(pose.solveTwoBoneIK(&skeleton, 0, 1, 2, 1, 0, 1, 0)); + CHECK(std::abs(pose.world(2).py - 2) < 1e-6f); + REQUIRE(pose.solveTwoBoneIK(&skeleton, 0, 1, 2, 0, 3, 0, 1)); + CHECK(std::abs(pose.world(2).py - 2) < 1e-6f); + skeleton.setBindPosition(1, 0, 0, 0); + skeleton.applyBindPose(&pose); + CHECK(!pose.solveTwoBoneIK(&skeleton, 0, 1, 2, 1, 0, 1, 1)); + CHECK(std::abs(pose.world(2).py - 1) < 1e-6f); +} diff --git a/test/climbing_authored_motion.cpp b/test/climbing_authored_motion.cpp new file mode 100644 index 00000000..d997d9aa --- /dev/null +++ b/test/climbing_authored_motion.cpp @@ -0,0 +1,65 @@ +#include +#include +#include "climbing/Climbing.h" +#include "physics/Body3D.h" +#include "physics/Physics.h" +#include "physics/World3D.h" +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" +using namespace eve::climbing; +namespace { +eve::Duration seconds(double value) { + auto result = eve::Duration::fromSeconds(value); + REQUIRE(result.ok()); + return std::move(result).takeValue(); +} +struct Fixture { + std::unique_ptr world{eve::physics::Physics::create()->newWorld3D(0, 0, 0, false)}; + ClimbingRuntime runtime; + Fixture() { + world->newBody("static", 0, .5f, 1)->newBoxShape(2, 1, .5f); + ClimbingActionDefinition action{"mantle", .4f, 1.2f, 0, seconds(1), .6f, .7f, 0}; + REQUIRE(runtime.upsertAction(action).ok()); + REQUIRE(runtime.tryBegin(*world, {{0, 0, 0}, {0, 0, 1}, 1, -1}, eve::SimulationTick(1)).ok()); + } +}; +} // namespace +TEST_CASE("climbing.prewarpedMotionPreservesTimingAndRejectsInvalidPoliciesAtomically") { + Fixture f; + ClimbingMotionInput input; + input.rootMotionPolicy = ClimbingRootMotionPolicy::PreserveSuppliedDelta; + auto rejected = f.runtime.advance(*f.world, {eve::SimulationTick(2), seconds(.1)}, input); + CHECK(!rejected.ok()); + input.hasRootMotion = true; + input.rootTranslation = {0, .02f, .03f}; + auto advanced = f.runtime.advance(*f.world, {eve::SimulationTick(2), seconds(.1)}, input); + REQUIRE(advanced.ok()); + CHECK(std::abs(advanced.value().feet.y - .02f) < .0001f); + CHECK(std::abs(advanced.value().feet.z - .03f) < .0001f); + CHECK(advanced.value().appliedWarp.y == 0.f); + input.obstacleCollision = static_cast(99); + CHECK(!f.runtime.advance(*f.world, {eve::SimulationTick(3), seconds(.1)}, input).ok()); + input.obstacleCollision = ClimbingObstacleCollision::Collide; + REQUIRE(f.runtime.advance(*f.world, {eve::SimulationTick(3), seconds(.1)}, input).ok()); +} +TEST_CASE("climbing.prewarpedCollisionExcludesOnlyTraversedShapeForOneCall") { + Fixture f; + ClimbingMotionInput input; + input.hasRootMotion = true; + input.rootMotionPolicy = ClimbingRootMotionPolicy::PreserveSuppliedDelta; + input.obstacleCollision = ClimbingObstacleCollision::IgnoreTraversedShape; + input.rootTranslation = {0, 0, .9f}; + auto crossed = f.runtime.advance(*f.world, {eve::SimulationTick(2), seconds(.1)}, input); + REQUIRE(crossed.ok()); + CHECK(std::abs(crossed.value().feet.z - .9f) < .001f); + // A separate query still collides with the original obstacle. + auto normal = f.world->moveCapsuleOwned(0, .3f, 0, 0, 1.5f, 0, .3f, 0, 0, .9f, {}); + REQUIRE(normal.ok()); + CHECK(normal.value().deltaZ < .6f); + f.world->newBody("static", 0, .7f, 1.8f)->newBoxShape(2, 1.4f, .1f); + input.rootTranslation = {0, 0, 1.f}; + auto blocked = f.runtime.advance(*f.world, {eve::SimulationTick(3), seconds(.1)}, input); + REQUIRE(blocked.ok()); + CHECK(blocked.value().constrained); + CHECK(blocked.value().feet.z < 1.6f); +}