From 794fbcbae30a6e3cc24a71fafe8a6fe7756fe5d7 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sat, 25 Jul 2026 23:27:32 +0200 Subject: [PATCH 1/2] [Feature] Add motion matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Animates characters with no animation state machine: gameplay states a goal (desired velocity/facing, typically the steering output) and the controller periodically searches a motion database for the frame that best matches the current pose and the predicted future trajectory, then jumps there through an inertialized transition. Root motion moves the entity; clips are the vocabulary, the search is the state machine. - MotionDatabase: clips resampled at a fixed rate (default 30 Hz) into 27-dim character-space feature vectors — foot positions/velocities, hip velocity, future root trajectory positions and facings at 0.33/0.66/1.0 s (loop-wrapped with the clip's per-loop root displacement). Per-dimension normalization, per-group weights. Built lazily at runtime from the entity's loaded clips; a persisted format is deferred until Learned Motion Matching needs it. - Brute-force nearest-neighbour search with early-out, seeded with the currently playing frame's cost discounted 10% — hysteresis so only meaningfully better frames cause a jump. Winners near the natural playback position are skipped entirely. - Query velocities are world-space finite differences rotated into the character frame, matching how the database measures them from clip root motion (a character-relative difference would zero out travel and bias every query toward idle). - Trajectory prediction: first-order lag of the simulated velocity toward the goal (predictionHalflife), integrated analytically. - beginAnimationTransition generalized to arbitrary target times so jumps land mid-clip through the standard transition path, with root motion re-baselined. - Public API: setMotionMatching(entityId:descriptor:), setMotionMatchingEnabled, isMotionMatchingEnabled, setMotionMatchingGoal(entityId:desiredVelocity:desiredFacing:). Docs: docs/API/UsingMotionMatching.md --- .../Animation/MotionDatabase.swift | 332 +++++++++++++++ .../Animation/MotionMatching.swift | 377 ++++++++++++++++++ Sources/UntoldEngine/ECS/Components.swift | 2 + .../Systems/AnimationSystem.swift | 86 +++- .../AnimationMotionMatchingTests.swift | 254 ++++++++++++ docs/API/UsingMotionMatching.md | 100 +++++ 6 files changed, 1144 insertions(+), 7 deletions(-) create mode 100644 Sources/UntoldEngine/Animation/MotionDatabase.swift create mode 100644 Sources/UntoldEngine/Animation/MotionMatching.swift create mode 100644 Tests/UntoldEngineTests/AnimationMotionMatchingTests.swift create mode 100644 docs/API/UsingMotionMatching.md diff --git a/Sources/UntoldEngine/Animation/MotionDatabase.swift b/Sources/UntoldEngine/Animation/MotionDatabase.swift new file mode 100644 index 000000000..fbd4bf682 --- /dev/null +++ b/Sources/UntoldEngine/Animation/MotionDatabase.swift @@ -0,0 +1,332 @@ +// +// MotionDatabase.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import simd + +// Motion matching database: every animation clip resampled at a fixed rate +// into (clip, time) frames, each with a feature vector describing what the +// character is doing at that instant — where its feet are and how fast +// they move, how fast the hips travel, and where the root will be shortly. +// At runtime a query built from the current pose and the AI's desired +// trajectory finds the nearest frame by brute force; playback jumps there +// through an inertialized transition. +// +// Features live in character space: the root joint's horizontal position +// and yaw at the frame define the frame of reference, so the same walk +// matches regardless of where in the world it was authored. Future values +// past a clip's end wrap with the clip's per-loop root displacement/yaw +// (clips are assumed to loop, keys spanning the full duration). +// +// The database is built at load time from clips already loaded on the +// entity — a few minutes of animation resamples in milliseconds. A +// persisted binary format is deferred until database sizes justify it. +// See docs/Architecture/animationPoseLayer.md. + +/// Feature group weights: how much each aspect matters in the distance. +public struct MotionMatchingWeights { + public var footPosition: Float + public var footVelocity: Float + public var hipVelocity: Float + public var trajectoryPosition: Float + public var trajectoryDirection: Float + + public init( + footPosition: Float = 0.75, + footVelocity: Float = 1.0, + hipVelocity: Float = 1.0, + trajectoryPosition: Float = 1.0, + trajectoryDirection: Float = 1.25 + ) { + self.footPosition = footPosition + self.footVelocity = footVelocity + self.hipVelocity = hipVelocity + self.trajectoryPosition = trajectoryPosition + self.trajectoryDirection = trajectoryDirection + } +} + +/// Layout of one feature vector. Order (character space): +/// left foot pos (3), right foot pos (3), left foot vel (3), +/// right foot vel (3), hip vel (3), trajectory positions x/z at each +/// horizon (2 each), trajectory facing x/z at each horizon (2 each). +enum MotionFeatureLayout { + static let trajectoryHorizons: [Float] = [0.33, 0.66, 1.0] + static let poseDimensions = 15 + static var trajectoryDimensions: Int { + trajectoryHorizons.count * 4 + } + + static var dimensions: Int { + poseDimensions + trajectoryDimensions + } + + static func groupWeight(forDimension d: Int, weights: MotionMatchingWeights) -> Float { + switch d { + case 0 ..< 6: return weights.footPosition + case 6 ..< 12: return weights.footVelocity + case 12 ..< 15: return weights.hipVelocity + default: + let t = d - poseDimensions + return t % 4 < 2 ? weights.trajectoryPosition : weights.trajectoryDirection + } + } +} + +final class MotionDatabase { + struct Frame { + let clipIndex: Int + let time: Float + } + + /// Clips referenced by frames, with their compiled forms. + let clips: [AnimationClip] + let compiledClips: [CompiledAnimationClip] + + let frames: [Frame] + /// First frame index of each clip's contiguous run in `frames`. + let clipFrameOffsets: [Int] + let dimensions = MotionFeatureLayout.dimensions + let sampleInterval: Float + + /// Feature vectors, flattened, pre-scaled by `scales` so the search is + /// a plain squared distance. + private let features: [Float] + + /// Per-dimension scale = groupWeight / stdDev. + let scales: [Float] + + /// Joint indices the features are built from. + let rootJointIndex: Int + let leftFootIndex: Int + let rightFootIndex: Int + + init?( + clips: [AnimationClip], + compiledClips: [CompiledAnimationClip], + skeleton: Skeleton, + leftFootPath: String, + rightFootPath: String, + sampleRate: Float, + weights: MotionMatchingWeights + ) { + guard clips.count == compiledClips.count, clips.isEmpty == false, sampleRate > 0 else { return nil } + guard let leftFoot = skeleton.jointPaths.firstIndex(of: leftFootPath), + let rightFoot = skeleton.jointPaths.firstIndex(of: rightFootPath), + let root = skeleton.parentIndices.firstIndex(where: { $0 == nil }) + else { return nil } + + self.clips = clips + self.compiledClips = compiledClips + rootJointIndex = root + leftFootIndex = leftFoot + rightFootIndex = rightFoot + sampleInterval = 1.0 / sampleRate + + var frames: [Frame] = [] + var clipFrameOffsets: [Int] = [] + var rawFeatures: [Float] = [] + + var sampler = ClipSampler() + var pose = PoseBuffer() + var positions: [simd_float3] = [] + var rotations: [simd_quatf] = [] + + /// Samples the root's model-space translation and yaw at an + /// unwrapped time, correcting whole loops with the clip's per-loop + /// displacement so future trajectory values never snap backward. + func rootSample( + clip: AnimationClip, compiled: CompiledAnimationClip, + at time: Float, sampler: inout ClipSampler, pose: inout PoseBuffer + ) -> (position: simd_float3, yaw: Float) { + let duration = max(clip.duration, 1e-4) + let loops = floor(time / duration) + let wrapped = time - loops * duration + sampler.sample(compiled, time: wrapped, duration: clip.duration, speed: clip.speed, into: &pose) + let position = pose.translations[root] + compiled.rootTranslationPerLoop * loops + let yaw = yawTwist(pose.rotations[root]).yaw + compiled.rootYawPerLoop * loops + return (position, yaw) + } + + for (clipIndex, clip) in clips.enumerated() { + clipFrameOffsets.append(frames.count) + let compiled = compiledClips[clipIndex] + guard compiled.jointCount == skeleton.jointPaths.count else { continue } + let duration = clip.duration + guard duration > 0 else { continue } + + let frameCount = max(1, Int((duration / sampleInterval).rounded())) + for frameIndex in 0 ..< frameCount { + let time = Float(frameIndex) * sampleInterval + let dt = sampleInterval + + // Current frame pose in model space. + sampler.sample(compiled, time: time, duration: duration, speed: clip.speed, into: &pose) + computeForwardKinematics( + pose: pose, parentIndices: skeleton.parentIndices, + positions: &positions, rotations: &rotations + ) + let rootPosition = positions[root] + let rootYaw = yawTwist(rotations[root]).yaw + let inverseYaw = simd_quatf(angle: -rootYaw, axis: simd_float3(0, 1, 0)) + let rootHorizontal = simd_float3(rootPosition.x, 0, rootPosition.z) + + func toCharacterSpace(_ p: simd_float3) -> simd_float3 { + inverseYaw.act(p - rootHorizontal) + } + + let leftFootCS = toCharacterSpace(positions[leftFoot]) + let rightFootCS = toCharacterSpace(positions[rightFoot]) + + // Next-sample pose for velocities (feet and hips), with the + // loop-wrap correction on the root. + var nextSampler = sampler + let nextRoot = rootSample(clip: clip, compiled: compiled, at: time + dt, sampler: &nextSampler, pose: &pose) + computeForwardKinematics( + pose: pose, parentIndices: skeleton.parentIndices, + positions: &positions, rotations: &rotations + ) + // The wrapped sample's positions need the same loop shift as the root. + let loopShift = nextRoot.position - positions[root] + let leftFootVelocity = (toCharacterSpace(positions[leftFoot] + loopShift) - leftFootCS) / dt + let rightFootVelocity = (toCharacterSpace(positions[rightFoot] + loopShift) - rightFootCS) / dt + let hipVelocity = inverseYaw.act(nextRoot.position - rootPosition) / dt + + var vector: [Float] = [] + vector.reserveCapacity(MotionFeatureLayout.dimensions) + for value in [leftFootCS, rightFootCS, leftFootVelocity, rightFootVelocity, hipVelocity] { + vector.append(value.x) + vector.append(value.y) + vector.append(value.z) + } + + for horizon in MotionFeatureLayout.trajectoryHorizons { + var futureSampler = sampler + let future = rootSample(clip: clip, compiled: compiled, at: time + horizon, sampler: &futureSampler, pose: &pose) + let relative = inverseYaw.act(future.position - rootHorizontal) + vector.append(relative.x) + vector.append(relative.z) + let yawDelta = future.yaw - rootYaw + vector.append(sin(yawDelta)) + vector.append(cos(yawDelta)) + } + + frames.append(Frame(clipIndex: clipIndex, time: time)) + rawFeatures.append(contentsOf: vector) + } + } + + guard frames.isEmpty == false else { return nil } + self.frames = frames + self.clipFrameOffsets = clipFrameOffsets + + // Per-dimension standard deviation for normalization; degenerate + // dimensions (constant across the database) get scale from weight + // alone so they cannot blow up the distance. + let dims = MotionFeatureLayout.dimensions + let count = frames.count + var scales = [Float](repeating: 1, count: dims) + for d in 0 ..< dims { + var mean: Float = 0 + for f in 0 ..< count { + mean += rawFeatures[f * dims + d] + } + mean /= Float(count) + var variance: Float = 0 + for f in 0 ..< count { + let delta = rawFeatures[f * dims + d] - mean + variance += delta * delta + } + variance /= Float(count) + let std = sqrt(variance) + let weight = MotionFeatureLayout.groupWeight(forDimension: d, weights: weights) + scales[d] = std > 1e-5 ? weight / std : weight + } + self.scales = scales + + var scaled = rawFeatures + for f in 0 ..< count { + for d in 0 ..< dims { + scaled[f * dims + d] *= scales[d] + } + } + features = scaled + } + + /// Nearest stored frame index for a (clip, wrapped time) position, or + /// nil when the clip is not part of the database. + func frameIndex(ofClip clip: AnimationClip, time: Float) -> Int? { + guard let clipIndex = clips.firstIndex(where: { $0 === clip }) else { return nil } + let start = clipFrameOffsets[clipIndex] + let end = clipIndex + 1 < clipFrameOffsets.count ? clipFrameOffsets[clipIndex + 1] : frames.count + guard start < end else { return nil } + let offset = Int((time / sampleInterval).rounded()) + return min(max(start + offset, start), end - 1) + } + + /// A candidate must beat the currently playing frame's cost by this + /// factor to justify a jump — hysteresis against equal-cost and + /// noise-level "improvements" that would otherwise cause pointless + /// phase jumps every search. + private static let switchMargin: Float = 0.9 + + /// Brute-force nearest neighbour. `query` is a raw (unscaled) feature + /// vector; returns the best frame index. When `preferredIndex` is + /// given (the frame playback is currently at), the search is seeded + /// with its discounted cost, so only meaningfully better frames win. + func search(query: [Float], preferredIndex: Int? = nil) -> Int? { + guard query.count == dimensions, frames.isEmpty == false else { return nil } + + var scaledQuery = query + for d in 0 ..< dimensions { + scaledQuery[d] *= scales[d] + } + + var bestIndex = 0 + var bestCost = Float.greatestFiniteMagnitude + if let preferredIndex, preferredIndex >= 0, preferredIndex < frames.count { + var cost: Float = 0 + let base = preferredIndex * dimensions + for d in 0 ..< dimensions { + let delta = features[base + d] - scaledQuery[d] + cost += delta * delta + } + bestIndex = preferredIndex + bestCost = cost * Self.switchMargin + } + features.withUnsafeBufferPointer { buffer in + for f in 0 ..< frames.count { + var cost: Float = 0 + let base = f * dimensions + for d in 0 ..< dimensions { + let delta = buffer[base + d] - scaledQuery[d] + cost += delta * delta + if cost >= bestCost { + break + } + } + if cost < bestCost { + bestCost = cost + bestIndex = f + } + } + } + return bestIndex + } + + /// Raw (unscaled) feature vector of a stored frame — used by tests and + /// for building continuity-biased queries. + func rawFeatures(at frameIndex: Int) -> [Float] { + let base = frameIndex * dimensions + return (0 ..< dimensions).map { d in + scales[d] > 0 ? features[base + d] / scales[d] : features[base + d] + } + } +} diff --git a/Sources/UntoldEngine/Animation/MotionMatching.swift b/Sources/UntoldEngine/Animation/MotionMatching.swift new file mode 100644 index 000000000..abac2ee94 --- /dev/null +++ b/Sources/UntoldEngine/Animation/MotionMatching.swift @@ -0,0 +1,377 @@ +// +// MotionMatching.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import simd + +// Motion matching controller: instead of a hand-authored state machine +// choosing clips, gameplay states a *goal* (desired velocity and facing — +// typically straight from the steering system) and the controller +// periodically searches the motion database for the frame that best +// matches the current pose and the predicted future trajectory, then jumps +// there through an inertialized transition. Root motion should be enabled +// on the entity: the clips' own travel is what moves the character. +// See docs/Architecture/animationPoseLayer.md. + +/// Configuration for motion matching on one entity. +public struct MotionMatchingDescriptor { + /// Clip names to include in the database; empty means every clip + /// loaded on the entity. + public var clipNames: [String] + + /// Feet joints for pose features. + public var leftFootPath: String + public var rightFootPath: String + + /// Database resample rate in frames per second. + public var sampleRate: Float + + /// How often the database is searched, in seconds. + public var searchInterval: Float + + /// Halflife of the inertialized transition used for jumps. + public var transitionHalflife: Float + + /// Halflife of the simulated velocity's approach to the desired + /// velocity — lower is more responsive, higher is smoother. + public var predictionHalflife: Float + + public var weights: MotionMatchingWeights + + public init( + leftFootPath: String, + rightFootPath: String, + clipNames: [String] = [], + sampleRate: Float = 30, + searchInterval: Float = 0.1, + transitionHalflife: Float = 0.1, + predictionHalflife: Float = 0.25, + weights: MotionMatchingWeights = MotionMatchingWeights() + ) { + self.leftFootPath = leftFootPath + self.rightFootPath = rightFootPath + self.clipNames = clipNames + self.sampleRate = sampleRate + self.searchInterval = searchInterval + self.transitionHalflife = transitionHalflife + self.predictionHalflife = predictionHalflife + self.weights = weights + } +} + +/// Per-entity motion matching state. +struct MotionMatchingState { + var isEnabled = false + var descriptor: MotionMatchingDescriptor? + var database: MotionDatabase? + + /// World-space goal, set by gameplay every frame (or whenever it + /// changes). + var desiredVelocity = simd_float3.zero + var desiredFacing: simd_float3? + + /// First-order-lag simulated velocity in character space; drives the + /// trajectory prediction. + var simulatedVelocity = simd_float3.zero + + var searchClock: Float = 0 + + /// Query history for finite-difference features. + var hasHistory = false + var previousLeftFootWorld = simd_float3.zero + var previousRightFootWorld = simd_float3.zero + var previousWorldPosition = simd_float3.zero + var historyElapsed: Float = 0 + + /// FK scratch. + var fkPositions: [simd_float3] = [] + var fkRotations: [simd_quatf] = [] + var query: [Float] = [] + + mutating func reset() { + database = nil + simulatedVelocity = .zero + searchClock = 0 + hasHistory = false + historyElapsed = 0 + } +} + +// MARK: - Per-frame update + +/// Runs one motion matching step for an entity: advances the simulated +/// velocity, and on the search cadence builds a query from the current +/// pose + predicted trajectory, searches the database, and jumps when a +/// better frame is found. Called before the frame's pose sampling, so a +/// jump takes effect the same frame. +func updateMotionMatching( + entityId: EntityID, + animationComponent: AnimationComponent, + skeleton: Skeleton, + deltaTime: Float +) { + guard animationComponent.motionMatching.isEnabled, + let descriptor = animationComponent.motionMatching.descriptor + else { return } + + if animationComponent.motionMatching.database == nil { + buildMotionDatabase(animationComponent: animationComponent, skeleton: skeleton, descriptor: descriptor) + // Force a search on the first update so the entity starts playing. + animationComponent.motionMatching.searchClock = descriptor.searchInterval + } + guard let database = animationComponent.motionMatching.database else { return } + + // Entity's character frame: its world yaw (the pose root is grounded + // when root motion is on, so the entity transform carries heading). + let entityRotation = getRotationQuaternion(entityId: entityId) + let entityYaw = yawTwist( + simd_length_squared(entityRotation.vector) < 1e-8 + ? simd_quatf(ix: 0, iy: 0, iz: 0, r: 1) + : entityRotation + ).yaw + let inverseEntityYaw = simd_quatf(angle: -entityYaw, axis: simd_float3(0, 1, 0)) + + // Advance the simulated velocity toward the goal (first-order lag). + let desiredVelocityCS = inverseEntityYaw.act(animationComponent.motionMatching.desiredVelocity) + let lambda = 0.693_147_18 / max(descriptor.predictionHalflife, 1e-3) + let approach = 1 - exp(-lambda * deltaTime) + animationComponent.motionMatching.simulatedVelocity += + (desiredVelocityCS - animationComponent.motionMatching.simulatedVelocity) * approach + + animationComponent.motionMatching.searchClock += deltaTime + animationComponent.motionMatching.historyElapsed += deltaTime + guard animationComponent.motionMatching.searchClock >= descriptor.searchInterval else { return } + animationComponent.motionMatching.searchClock = 0 + + // Nothing playing yet: hard-start on the first database frame; the + // next search will course-correct with a real query. + guard animationComponent.currentAnimation != nil, animationComponent.hasSampledPose else { + let frame = database.frames[0] + motionMatchingJump( + entityId: entityId, + animationComponent: animationComponent, + skeleton: skeleton, + database: database, + frameIndex: 0, + halflife: 0 + ) + _ = frame + return + } + + // Seed the search with where playback currently is, so equal-cost + // frames never cause a jump. + var preferredIndex: Int? + if let current = animationComponent.currentAnimation { + let wrapped = fmod(animationComponent.currentTime, max(current.duration, 1e-4)) + preferredIndex = database.frameIndex(ofClip: current, time: wrapped) + } + + guard let query = buildMotionMatchingQuery( + entityId: entityId, + animationComponent: animationComponent, + skeleton: skeleton, + database: database, + descriptor: descriptor, + inverseEntityYaw: inverseEntityYaw + ), let best = database.search(query: query, preferredIndex: preferredIndex) else { return } + + let frame = database.frames[best] + let clip = database.clips[frame.clipIndex] + + // Continuity: when the winner is (near) where playback would naturally + // be anyway, keep playing instead of re-transitioning every search. + if let current = animationComponent.currentAnimation, current === clip { + let duration = max(clip.duration, 1e-4) + let wrapped = fmod(animationComponent.currentTime, duration) + var difference = abs(wrapped - frame.time) + difference = min(difference, duration - difference) + if difference < database.sampleInterval * 2 { + return + } + } + + motionMatchingJump( + entityId: entityId, + animationComponent: animationComponent, + skeleton: skeleton, + database: database, + frameIndex: best, + halflife: descriptor.transitionHalflife + ) +} + +// MARK: - Query construction + +private func buildMotionMatchingQuery( + entityId: EntityID, + animationComponent: AnimationComponent, + skeleton: Skeleton, + database: MotionDatabase, + descriptor: MotionMatchingDescriptor, + inverseEntityYaw: simd_quatf +) -> [Float]? { + let pose = animationComponent.localPose + guard pose.jointCount == skeleton.jointPaths.count else { return nil } + + animationComponent.motionMatching.refreshForwardKinematics(pose: pose, parentIndices: skeleton.parentIndices) + let positions = animationComponent.motionMatching.fkPositions + let rotations = animationComponent.motionMatching.fkRotations + + // Character frame from the pose root (identity when root motion has + // grounded the pose — this also covers the ungrounded case). + let root = database.rootJointIndex + let rootYaw = yawTwist(rotations[root]).yaw + let inverseRootYaw = simd_quatf(angle: -rootYaw, axis: simd_float3(0, 1, 0)) + let rootHorizontal = simd_float3(positions[root].x, 0, positions[root].z) + + let leftFoot = inverseRootYaw.act(positions[database.leftFootIndex] - rootHorizontal) + let rightFoot = inverseRootYaw.act(positions[database.rightFootIndex] - rootHorizontal) + let worldPosition = getPosition(entityId: entityId) + + // Velocities are world-space finite differences rotated into the + // character frame — the entity's own travel is part of a foot's + // velocity, matching how the database measures it from clip root + // motion. + let worldMatrix = scene.get(component: WorldTransformComponent.self, for: entityId)?.space ?? .identity + func toWorld(_ p: simd_float3) -> simd_float3 { + let w = worldMatrix * simd_float4(p, 1) + return simd_float3(w.x, w.y, w.z) + } + let leftFootWorld = toWorld(positions[database.leftFootIndex]) + let rightFootWorld = toWorld(positions[database.rightFootIndex]) + + let elapsed = animationComponent.motionMatching.historyElapsed + var leftVelocity = simd_float3.zero + var rightVelocity = simd_float3.zero + var hipVelocity = simd_float3.zero + if animationComponent.motionMatching.hasHistory, elapsed > 1e-4 { + leftVelocity = inverseEntityYaw.act( + (leftFootWorld - animationComponent.motionMatching.previousLeftFootWorld) / elapsed + ) + rightVelocity = inverseEntityYaw.act( + (rightFootWorld - animationComponent.motionMatching.previousRightFootWorld) / elapsed + ) + hipVelocity = inverseEntityYaw.act( + (worldPosition - animationComponent.motionMatching.previousWorldPosition) / elapsed + ) + } + + animationComponent.motionMatching.previousLeftFootWorld = leftFootWorld + animationComponent.motionMatching.previousRightFootWorld = rightFootWorld + animationComponent.motionMatching.previousWorldPosition = worldPosition + animationComponent.motionMatching.hasHistory = true + animationComponent.motionMatching.historyElapsed = 0 + + var query: [Float] = [] + query.reserveCapacity(database.dimensions) + for value in [leftFoot, rightFoot, leftVelocity, rightVelocity, hipVelocity] { + query.append(value.x) + query.append(value.y) + query.append(value.z) + } + + // Predicted trajectory: integrate the first-order lag of the simulated + // velocity toward the desired velocity, in character space. Facing + // approaches the desired facing with the same time constant. + let velocity = animationComponent.motionMatching.simulatedVelocity + let desiredVelocityCS = inverseEntityYaw.act(animationComponent.motionMatching.desiredVelocity) + let lambda = 0.693_147_18 / max(descriptor.predictionHalflife, 1e-3) + + var desiredYawDelta: Float = 0 + if let facing = animationComponent.motionMatching.desiredFacing, + simd_length_squared(simd_float3(facing.x, 0, facing.z)) > 1e-8 + { + let facingCS = inverseEntityYaw.act(simd_float3(facing.x, 0, facing.z)) + desiredYawDelta = atan2(facingCS.x, facingCS.z) + } else if simd_length_squared(simd_float3(desiredVelocityCS.x, 0, desiredVelocityCS.z)) > 1e-6 { + desiredYawDelta = atan2(desiredVelocityCS.x, desiredVelocityCS.z) + } + + for horizon in MotionFeatureLayout.trajectoryHorizons { + let decay = (1 - exp(-lambda * horizon)) / lambda + let position = desiredVelocityCS * horizon + (velocity - desiredVelocityCS) * decay + query.append(position.x) + query.append(position.z) + let yawAtHorizon = desiredYawDelta * (1 - exp(-lambda * horizon)) + query.append(sin(yawAtHorizon)) + query.append(cos(yawAtHorizon)) + } + + return query +} + +// MARK: - Database build and jumps + +private func buildMotionDatabase( + animationComponent: AnimationComponent, + skeleton: Skeleton, + descriptor: MotionMatchingDescriptor +) { + let names = descriptor.clipNames.isEmpty + ? animationComponent.animationClips.keys.sorted() + : descriptor.clipNames + + var clips: [AnimationClip] = [] + var compiled: [CompiledAnimationClip] = [] + for name in names { + guard let clip = animationComponent.animationClips[name] else { continue } + clips.append(clip) + compiled.append(animationComponent.compiledClip(for: clip, skeleton: skeleton)) + } + + animationComponent.motionMatching.database = MotionDatabase( + clips: clips, + compiledClips: compiled, + skeleton: skeleton, + leftFootPath: descriptor.leftFootPath, + rightFootPath: descriptor.rightFootPath, + sampleRate: descriptor.sampleRate, + weights: descriptor.weights + ) +} + +/// Jumps playback to a database frame through an inertialized transition +/// (or a hard cut when `halflife` is zero), re-baselining root motion. +func motionMatchingJump( + entityId: EntityID, + animationComponent: AnimationComponent, + skeleton _: Skeleton, + database: MotionDatabase, + frameIndex: Int, + halflife: Float +) { + let frame = database.frames[frameIndex] + let clip = database.clips[frame.clipIndex] + + beginAnimationTransition( + entityId: entityId, + animationComponent: animationComponent, + to: clip, + halflife: halflife, + targetTime: frame.time + ) + animationComponent.currentAnimation = clip + animationComponent.currentTime = frame.time + animationComponent.rootMotion.resetHistory() +} + +extension MotionMatchingState { + /// Single-access FK refresh (see the exclusivity note on + /// `FootIKState.refreshForwardKinematics`). + mutating func refreshForwardKinematics(pose: PoseBuffer, parentIndices: [Int?]) { + computeForwardKinematics( + pose: pose, + parentIndices: parentIndices, + positions: &fkPositions, + rotations: &fkRotations + ) + } +} diff --git a/Sources/UntoldEngine/ECS/Components.swift b/Sources/UntoldEngine/ECS/Components.swift index 36ccfa692..b78a302c8 100644 --- a/Sources/UntoldEngine/ECS/Components.swift +++ b/Sources/UntoldEngine/ECS/Components.swift @@ -205,6 +205,7 @@ public class AnimationComponent: Component { var transition = PoseTransition() var rootMotion = RootMotionState() var footIK = FootIKState() + var motionMatching = MotionMatchingState() public required init() {} @@ -222,6 +223,7 @@ public class AnimationComponent: Component { transition = PoseTransition() rootMotion = RootMotionState() footIK = FootIKState() + motionMatching = MotionMatchingState() } func getAllAnimationClips() -> [String] { diff --git a/Sources/UntoldEngine/Systems/AnimationSystem.swift b/Sources/UntoldEngine/Systems/AnimationSystem.swift index 29002ba31..5e4c507df 100644 --- a/Sources/UntoldEngine/Systems/AnimationSystem.swift +++ b/Sources/UntoldEngine/Systems/AnimationSystem.swift @@ -9,6 +9,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import Foundation +import simd public final class AnimationSystem: @unchecked Sendable { /// Thread-safe shared instance @@ -165,6 +166,17 @@ private func updateAnimationSystem(deltaTime: Float) { continue } + // Motion matching may switch the clip/time before this frame's + // pose is sampled. + if animationComponent.motionMatching.isEnabled { + updateMotionMatching( + entityId: entity, + animationComponent: animationComponent, + skeleton: skeletonComponent.skeleton, + deltaTime: deltaTime + ) + } + animationComponent.currentTime += deltaTime * animationComponent.playbackSpeed guard let animationClip = animationComponent.currentAnimation else { continue } @@ -405,6 +417,65 @@ public func setFootIKGroundQuery(entityId: EntityID, query: FootIKGroundQuery?) } } +/// Configures motion matching for the entity (or its descendants that +/// carry an `AnimationComponent`). The motion database is built lazily on +/// the first enabled update, from the clips loaded on the entity. Enable +/// root motion as well — the clips' own travel is what moves the entity. +public func setMotionMatching(entityId: EntityID, descriptor: MotionMatchingDescriptor) { + let animationComponents = animationComponentsForEntityOrDescendants(entityId: entityId) + guard animationComponents.isEmpty == false else { + handleError(.noAnimationComponent, entityId) + return + } + + for (_, animationComponent) in animationComponents { + animationComponent.motionMatching.descriptor = descriptor + animationComponent.motionMatching.reset() + } +} + +public func setMotionMatchingEnabled(entityId: EntityID, enabled: Bool) { + let animationComponents = animationComponentsForEntityOrDescendants(entityId: entityId) + guard animationComponents.isEmpty == false else { + handleError(.noAnimationComponent, entityId) + return + } + + for (_, animationComponent) in animationComponents { + animationComponent.motionMatching.isEnabled = enabled + } +} + +public func isMotionMatchingEnabled(entityId: EntityID) -> Bool { + let targetEntityId = resolveEntityWithAnimationComponent(entityId: entityId) ?? entityId + guard let animationComponent = scene.get(component: AnimationComponent.self, for: targetEntityId) else { + handleError(.noAnimationComponent, entityId) + return false + } + + return animationComponent.motionMatching.isEnabled +} + +/// States the world-space goal motion matching should steer toward — +/// typically the steering system's desired velocity. `desiredFacing` nil +/// faces along the desired velocity. +public func setMotionMatchingGoal( + entityId: EntityID, + desiredVelocity: simd_float3, + desiredFacing: simd_float3? = nil +) { + let animationComponents = animationComponentsForEntityOrDescendants(entityId: entityId) + guard animationComponents.isEmpty == false else { + handleError(.noAnimationComponent, entityId) + return + } + + for (_, animationComponent) in animationComponents { + animationComponent.motionMatching.desiredVelocity = desiredVelocity + animationComponent.motionMatching.desiredFacing = desiredFacing + } +} + public func isRootMotionEnabled(entityId: EntityID) -> Bool { let targetEntityId = resolveEntityWithAnimationComponent(entityId: entityId) ?? entityId guard let animationComponent = scene.get(component: AnimationComponent.self, for: targetEntityId) else { @@ -418,11 +489,12 @@ public func isRootMotionEnabled(entityId: EntityID) -> Bool { /// Captures inertialization offsets for a clip switch. Falls back to a hard /// cut (no transition) when there is nothing to blend from: no clip playing, /// no pose displayed yet, no skeleton, or a zero halflife. -private func beginAnimationTransition( +func beginAnimationTransition( entityId: EntityID, animationComponent: AnimationComponent, to clip: AnimationClip, - halflife: Float + halflife: Float, + targetTime: Float = 0 ) { guard halflife > 0, animationComponent.currentAnimation != nil, @@ -439,20 +511,20 @@ private func beginAnimationTransition( return } - // Sample the incoming clip at its start and one small step later to - // estimate its initial velocity. The component sampler rebinds to the - // new clip here, which it would do on the next frame anyway. + // Sample the incoming clip at its target time and one small step + // later to estimate its initial velocity. The component sampler + // rebinds to the new clip here, which it would do next frame anyway. let velocityStep: Float = 1.0 / 60.0 animationComponent.sampler.sample( compiledClip, - time: 0, + time: targetTime, duration: clip.duration, speed: clip.speed, into: &animationComponent.transition.scratchTarget ) animationComponent.sampler.sample( compiledClip, - time: velocityStep, + time: targetTime + velocityStep, duration: clip.duration, speed: clip.speed, into: &animationComponent.transition.scratchTargetNext diff --git a/Tests/UntoldEngineTests/AnimationMotionMatchingTests.swift b/Tests/UntoldEngineTests/AnimationMotionMatchingTests.swift new file mode 100644 index 000000000..0531bc3c5 --- /dev/null +++ b/Tests/UntoldEngineTests/AnimationMotionMatchingTests.swift @@ -0,0 +1,254 @@ +// +// AnimationMotionMatchingTests.swift +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +@MainActor +final class AnimationMotionMatchingTests: XCTestCase { + var entityId: EntityID! + + private let deltaTime: Float = 1.0 / 90.0 + + // Skeleton: root plus two feet hanging off it at ±x. + private let jointPaths = ["root", "root/foot_l", "root/foot_r"] + private let parentIndices: [Int?] = [nil, 0, 0] + + override func setUp() async throws { + resetEngineTestState() + + entityId = createEntity() + registerComponent(entityId: entityId, componentType: SkeletonComponent.self) + registerComponent(entityId: entityId, componentType: AnimationComponent.self) + registerComponent(entityId: entityId, componentType: RenderComponent.self) + registerComponent(entityId: entityId, componentType: ScenegraphComponent.self) + registerComponent(entityId: entityId, componentType: LocalTransformComponent.self) + registerComponent(entityId: entityId, componentType: WorldTransformComponent.self) + + let locals = [ + simd_float4x4(translation: simd_float3(0, 0.9, 0)), + simd_float4x4(translation: simd_float3(-0.1, -0.9, 0)), + simd_float4x4(translation: simd_float3(0.1, -0.9, 0)), + ] + let binds = [ + simd_float4x4(translation: simd_float3(0, 0.9, 0)), + simd_float4x4(translation: simd_float3(-0.1, 0, 0)), + simd_float4x4(translation: simd_float3(0.1, 0, 0)), + ] + let runtimeSkeleton = RuntimeSkeleton( + jointPaths: jointPaths, + parentIndices: parentIndices, + bindTransforms: binds, + restTransforms: locals + ) + scene.get(component: SkeletonComponent.self, for: entityId)?.skeleton = + Skeleton(runtimeSkeleton: runtimeSkeleton) + + let animationComponent = scene.get(component: AnimationComponent.self, for: entityId)! + animationComponent.animationClips["walk"] = makeClip(name: "walk", speed: 1.0) + animationComponent.animationClips["idle"] = makeClip(name: "idle", speed: 0.0) + + setRootMotionEnabled(entityId: entityId, enabled: true) + // The synthetic clips have constant foot velocities (no gait), so + // foot velocity is down-weighted to keep the goal decisive. + setMotionMatching(entityId: entityId, descriptor: MotionMatchingDescriptor( + leftFootPath: "root/foot_l", + rightFootPath: "root/foot_r", + weights: MotionMatchingWeights(footVelocity: 0.5) + )) + } + + override func tearDown() async throws { + destroyEntity(entityId: entityId) + } + + /// Locomotion clip whose root travels along +z at `speed` m/s over a + /// 2 s loop (speed 0 = idle). Feet are unanimated and ride along. + private func makeClip(name: String, speed: Float) -> AnimationClip { + let rootChannel = RuntimeAnimationChannel( + jointPath: "root", + translations: [ + .init(time: 0.0, value: simd_float3(0, 0.9, 0)), + .init(time: 1.0, value: simd_float3(0, 0.9, speed)), + .init(time: 2.0, value: simd_float3(0, 0.9, speed * 2)), + ], + rotations: [ + .init(time: 0.0, value: SIMD4(0, 0, 0, 1)), + .init(time: 2.0, value: SIMD4(0, 0, 0, 1)), + ] + ) + return AnimationClip(runtimeClip: RuntimeAnimationClip(name: name, duration: 2.0, channels: [rootChannel])) + } + + private var animationComponent: AnimationComponent { + scene.get(component: AnimationComponent.self, for: entityId)! + } + + private func buildDatabase() -> MotionDatabase? { + let skeleton = scene.get(component: SkeletonComponent.self, for: entityId)!.skeleton! + let clips = [animationComponent.animationClips["walk"]!, animationComponent.animationClips["idle"]!] + let compiled = clips.map { animationComponent.compiledClip(for: $0, skeleton: skeleton) } + return MotionDatabase( + clips: clips, + compiledClips: compiled, + skeleton: skeleton, + leftFootPath: "root/foot_l", + rightFootPath: "root/foot_r", + sampleRate: 30, + weights: MotionMatchingWeights() + ) + } + + private func run(seconds: Float, goal: simd_float3) { + setMotionMatchingGoal(entityId: entityId, desiredVelocity: goal) + var time: Float = 0 + while time < seconds { + AnimationSystem.shared.update(deltaTime) + time += deltaTime + } + } + + // MARK: - Database construction + + func testDatabaseFrameCountAndLayout() throws { + let database = try XCTUnwrap(buildDatabase()) + + // Two 2 s clips at 30 Hz. + XCTAssertEqual(database.frames.count, 120) + XCTAssertEqual(database.dimensions, 27) + XCTAssertEqual(database.frames.filter { $0.clipIndex == 0 }.count, 60) + } + + func testWalkFramesEncodeTravelFeatures() throws { + let database = try XCTUnwrap(buildDatabase()) + + // A mid-clip walk frame: hip velocity ≈ (0, 0, 1) m/s in character + // space, and the 1 s trajectory sample ≈ 1 m ahead. + let index = try XCTUnwrap(database.frames.firstIndex { $0.clipIndex == 0 && abs($0.time - 0.5) < 1e-3 }) + let features = database.rawFeatures(at: index) + + XCTAssertEqual(features[12], 0, accuracy: 1e-3, "hip velocity x") + XCTAssertEqual(features[14], 1.0, accuracy: 1e-2, "hip velocity z") + + // Trajectory horizon entries: (x, z, sin, cos) per horizon. + XCTAssertEqual(features[15 + 1], 0.33, accuracy: 2e-2, "0.33 s trajectory z") + XCTAssertEqual(features[15 + 9], 1.0, accuracy: 2e-2, "1.0 s trajectory z") + XCTAssertEqual(features[15 + 3], 1.0, accuracy: 1e-3, "facing cos stays forward") + } + + func testIdleFramesEncodeStillness() throws { + let database = try XCTUnwrap(buildDatabase()) + + let index = try XCTUnwrap(database.frames.firstIndex { $0.clipIndex == 1 && abs($0.time - 0.5) < 1e-3 }) + let features = database.rawFeatures(at: index) + + XCTAssertEqual(features[14], 0, accuracy: 1e-3, "idle hip velocity z") + XCTAssertEqual(features[15 + 9], 0, accuracy: 1e-3, "idle 1.0 s trajectory z") + } + + func testTrajectoryWrapsAcrossLoopWithoutSnap() throws { + let database = try XCTUnwrap(buildDatabase()) + + // A walk frame near the clip end: its 1 s trajectory crosses the + // loop wrap and must still be ≈ 1 m ahead, not negative. + let index = try XCTUnwrap(database.frames.firstIndex { $0.clipIndex == 0 && abs($0.time - 1.8) < 1e-2 }) + let features = database.rawFeatures(at: index) + XCTAssertEqual(features[15 + 9], 1.0, accuracy: 5e-2, "trajectory must wrap with per-loop displacement") + } + + // MARK: - Search + + func testSearchFindsExactStoredFrame() throws { + let database = try XCTUnwrap(buildDatabase()) + + // Query with a stored frame's own features: that frame (or one + // with identical features) must win. + let index = 30 + let query = database.rawFeatures(at: index) + let best = try XCTUnwrap(database.search(query: query)) + + let expected = database.rawFeatures(at: index) + let found = database.rawFeatures(at: best) + for d in 0 ..< database.dimensions { + XCTAssertEqual(found[d], expected[d], accuracy: 1e-3, "dimension \(d)") + } + } + + func testSearchSeparatesWalkFromIdleByGoal() throws { + let database = try XCTUnwrap(buildDatabase()) + + // Walk-like query (features of a walk frame) must land in the walk + // clip; idle-like in the idle clip. + let walkIndex = try XCTUnwrap(database.frames.firstIndex { $0.clipIndex == 0 && abs($0.time - 1.0) < 1e-3 }) + let idleIndex = try XCTUnwrap(database.frames.firstIndex { $0.clipIndex == 1 && abs($0.time - 1.0) < 1e-3 }) + + let bestWalk = try XCTUnwrap(database.search(query: database.rawFeatures(at: walkIndex))) + let bestIdle = try XCTUnwrap(database.search(query: database.rawFeatures(at: idleIndex))) + + XCTAssertEqual(database.frames[bestWalk].clipIndex, 0) + XCTAssertEqual(database.frames[bestIdle].clipIndex, 1) + } + + // MARK: - End to end: goal-driven clip selection + + func testForwardGoalSelectsWalkAndMovesEntity() { + setMotionMatchingEnabled(entityId: entityId, enabled: true) + + run(seconds: 1.5, goal: simd_float3(0, 0, 1)) + + XCTAssertEqual(animationComponent.currentAnimation?.name, "walk") + XCTAssertGreaterThan( + getLocalPosition(entityId: entityId).z, 0.3, + "Walk clip's root motion must move the entity toward the goal" + ) + } + + func testZeroGoalSettlesOnIdle() { + setMotionMatchingEnabled(entityId: entityId, enabled: true) + + run(seconds: 1.5, goal: simd_float3(0, 0, 1)) + XCTAssertEqual(animationComponent.currentAnimation?.name, "walk") + + run(seconds: 2.5, goal: .zero) + XCTAssertEqual(animationComponent.currentAnimation?.name, "idle") + + let position = getLocalPosition(entityId: entityId).z + AnimationSystem.shared.update(deltaTime) + XCTAssertEqual( + getLocalPosition(entityId: entityId).z, position, accuracy: 1e-4, + "Idle must stop the entity" + ) + } + + func testContinuityKeepsPlaybackMonotonicUnderConstantGoal() { + setMotionMatchingEnabled(entityId: entityId, enabled: true) + run(seconds: 1.0, goal: simd_float3(0, 0, 1)) + + // Under a constant, matched goal, playback should advance without + // re-jumping every search (currentTime never rewinds noticeably). + var previousTime = animationComponent.currentTime + var time: Float = 0 + while time < 1.0 { + AnimationSystem.shared.update(deltaTime) + let current = animationComponent.currentTime + XCTAssertGreaterThan(current, previousTime - 0.25, "Playback rewound more than a search step at t=\(time)") + previousTime = current + time += deltaTime + } + } + + func testDisabledByDefault() { + // Descriptor set in setUp, but not enabled: nothing should play. + run(seconds: 0.5, goal: simd_float3(0, 0, 1)) + XCTAssertNil(animationComponent.currentAnimation) + XCTAssertFalse(isMotionMatchingEnabled(entityId: entityId)) + } +} diff --git a/docs/API/UsingMotionMatching.md b/docs/API/UsingMotionMatching.md new file mode 100644 index 000000000..b8571a264 --- /dev/null +++ b/docs/API/UsingMotionMatching.md @@ -0,0 +1,100 @@ +# Motion Matching + +## Introduction + +Motion matching animates a character with **no animation state machine**. +Instead of authoring transitions between clips, gameplay states a *goal* — +a desired velocity and facing, typically straight from the steering +system — and every tenth of a second the engine searches all loaded +animation frames for the one that best matches what the character is +currently doing *and* where it should be heading, then eases playback to +that frame with an inertialized transition. The clips' own root motion +moves the character. + +## Why Use It + +- **No state machine to author or maintain.** Adding a clip to the entity + adds it to the vocabulary; the search decides when it's used. +- **Natural movement.** The character is always playing real captured + motion; transitions happen at the frames where poses genuinely match. +- **AI-friendly.** "Walk toward the player through the environment" is one + `setMotionMatchingGoal` call per frame with the steering output. + +## Step-by-Step Implementation + +1. Load the entity's clips as usual and **enable root motion** — motion + matching relies on the clips' travel to move the entity: + +```swift +setEntityAnimations(entityId: zombie, filename: "locomotion", withExtension: "untold", name: "locomotion") +setRootMotionEnabled(entityId: zombie, enabled: true) +``` + +2. Describe the character and build the database (built lazily on the + first enabled frame, from the loaded clips — a few minutes of animation + resamples in milliseconds): + +```swift +setMotionMatching(entityId: zombie, descriptor: MotionMatchingDescriptor( + leftFootPath: "root/hips/thigh_l/calf_l/foot_l", + rightFootPath: "root/hips/thigh_r/calf_r/foot_r" +)) +setMotionMatchingEnabled(entityId: zombie, enabled: true) +``` + +3. Feed it a goal every frame from your AI: + +```swift +// e.g. inside the game update, from the steering system: +let toPlayer = getPosition(entityId: player) - getPosition(entityId: zombie) +let desired = simd_normalize(simd_float3(toPlayer.x, 0, toPlayer.z)) * zombieSpeed +setMotionMatchingGoal(entityId: zombie, desiredVelocity: desired) +``` + +That's the whole integration — no `changeAnimation` calls, no states. + +## What Happens Behind the Scenes + +1. **Database build:** every clip is resampled at `sampleRate` (default + 30 Hz). Each frame stores a 27-dimensional feature vector in character + space: foot positions and velocities, hip velocity, and the root's + future positions and facings at 0.33/0.66/1.0 s (loop-wrapped with the + clip's per-loop displacement). Features are normalized per dimension + and weighted per group (`MotionMatchingWeights`). +2. **Query:** on each search (default every 0.1 s) the current pose's foot + features are measured, velocities as world-space finite differences + rotated into the character frame, and the future trajectory is + *predicted* by easing the current simulated velocity toward the goal + with `predictionHalflife`. +3. **Search:** brute force over every frame — a few thousand frames is a + few hundred microseconds. The currently playing frame's cost is + discounted 10% as hysteresis, so only meaningfully better frames cause + a jump, and a winner near the natural playback position is skipped + entirely. +4. **Jump:** playback switches clip and time through the same + inertialized transition `changeAnimation` uses, and root motion + re-baselines — no pops, no teleports. + +## Tips and Best Practices + +- **Coverage beats quantity:** the search can only pick frames that + exist. For locomotion you want starts, stops, turns, and speed + variations; a single straight walk loop will turn by sliding. +- **Weights are the tuning surface.** Raise `trajectoryPosition`/ + `trajectoryDirection` for responsiveness to the goal; raise the foot + weights for pose fidelity (less foot sliding at transitions). +- `searchInterval` trades responsiveness for cost; 0.1 s is a good + default. Databases of a few thousand frames need no acceleration + structure. +- Combine with **foot IK** for terrain and **animation policy** + (`.forceOff`) as a distance LOD lever — freeze far characters and stop + calling `setMotionMatchingGoal` for them. +- Clips are assumed to loop with keys spanning their full duration + (standard for locomotion loops). + +## Running the Feature + +1. Load a character with at least an idle and a walk whose root travels. +2. Enable root motion + motion matching with both foot paths. +3. In game mode, drive the goal from input or AI and watch the character + pick clips by itself — zero the goal and it settles into idle. From db25b660228a8aeda155246279baa0124511c12c Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Mon, 27 Jul 2026 00:53:28 +0200 Subject: [PATCH 2/2] [Patch] Anchor root motion and motion matching to the API entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hierarchical assets loaded via setEntityMeshAsync carry their AnimationComponent on a skinned scenegraph child while the game holds and steers the asset root. Root motion applied its transform deltas to the component's entity, and motion matching read heading and world position from it — so the child drifted inside the asset along its never-rotated local frame while the root the game turned stayed put: characters marched off in a fixed world direction ignoring their goal, and the goal-to-character-space conversion churned the clip selection. Both systems now anchor to the entity the public API was called on (setRootMotionEnabled / setMotionMatching), which is the gameplay handle by the engine's own hierarchical-resolution convention. Flat entities behave exactly as before (anchor == component entity). Regression test drives a parent root with the components on a child. --- .../Animation/MotionMatching.swift | 18 ++++++-- .../UntoldEngine/Animation/RootMotion.swift | 18 ++++++-- .../Systems/AnimationSystem.swift | 5 +++ .../AnimationMotionMatchingTests.swift | 43 +++++++++++++++++++ 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/Sources/UntoldEngine/Animation/MotionMatching.swift b/Sources/UntoldEngine/Animation/MotionMatching.swift index abac2ee94..c089ad768 100644 --- a/Sources/UntoldEngine/Animation/MotionMatching.swift +++ b/Sources/UntoldEngine/Animation/MotionMatching.swift @@ -72,6 +72,10 @@ struct MotionMatchingState { var descriptor: MotionMatchingDescriptor? var database: MotionDatabase? + /// The gameplay handle whose transform expresses the character's world + /// position and heading (see RootMotionState.anchorEntity). + var anchorEntity: EntityID = .invalid + /// World-space goal, set by gameplay every frame (or whenever it /// changes). var desiredVelocity = simd_float3.zero @@ -128,9 +132,13 @@ func updateMotionMatching( } guard let database = animationComponent.motionMatching.database else { return } - // Entity's character frame: its world yaw (the pose root is grounded - // when root motion is on, so the entity transform carries heading). - let entityRotation = getRotationQuaternion(entityId: entityId) + let anchor = animationComponent.motionMatching.anchorEntity == .invalid + ? entityId + : animationComponent.motionMatching.anchorEntity + + // Character frame: the gameplay handle's world yaw (the pose root is + // grounded when root motion is on, so that transform carries heading). + let entityRotation = getRotationQuaternion(entityId: anchor) let entityYaw = yawTwist( simd_length_squared(entityRotation.vector) < 1e-8 ? simd_quatf(ix: 0, iy: 0, iz: 0, r: 1) @@ -176,6 +184,7 @@ func updateMotionMatching( guard let query = buildMotionMatchingQuery( entityId: entityId, + anchorEntity: anchor, animationComponent: animationComponent, skeleton: skeleton, database: database, @@ -212,6 +221,7 @@ func updateMotionMatching( private func buildMotionMatchingQuery( entityId: EntityID, + anchorEntity: EntityID, animationComponent: AnimationComponent, skeleton: Skeleton, database: MotionDatabase, @@ -234,7 +244,7 @@ private func buildMotionMatchingQuery( let leftFoot = inverseRootYaw.act(positions[database.leftFootIndex] - rootHorizontal) let rightFoot = inverseRootYaw.act(positions[database.rightFootIndex] - rootHorizontal) - let worldPosition = getPosition(entityId: entityId) + let worldPosition = getPosition(entityId: anchorEntity) // Velocities are world-space finite differences rotated into the // character frame — the entity's own travel is part of a foot's diff --git a/Sources/UntoldEngine/Animation/RootMotion.swift b/Sources/UntoldEngine/Animation/RootMotion.swift index 68e9eb0ce..ac4b711a6 100644 --- a/Sources/UntoldEngine/Animation/RootMotion.swift +++ b/Sources/UntoldEngine/Animation/RootMotion.swift @@ -29,6 +29,12 @@ import simd struct RootMotionState { var isEnabled = false + /// Entity whose transform receives the extracted deltas — the entity + /// the public API was called on (the gameplay handle). Hierarchical + /// assets keep their AnimationComponent on a skinned descendant, but + /// games move the asset root. + var anchorEntity: EntityID = .invalid + /// Optional joint-path override; by default the skeleton's first /// parentless joint drives root motion. var rootJointPath: String? @@ -146,6 +152,10 @@ func applyRootMotion( let translationTime = wrappedChannelTime(channelTime, lastKeyTime: channel.translationTimes.last) let rotationTime = wrappedChannelTime(channelTime, lastKeyTime: channel.rotationTimes.last) + let motionEntity = animationComponent.rootMotion.anchorEntity == .invalid + ? entityId + : animationComponent.rootMotion.anchorEntity + if animationComponent.rootMotion.hasPreviousSample { var delta = translation - animationComponent.rootMotion.previousTranslation if translationTime < animationComponent.rootMotion.previousTranslationTime { @@ -159,21 +169,21 @@ func applyRootMotion( yawDelta = wrapAngle(yawDelta) let horizontal = simd_float3(delta.x, 0, delta.z) - if scene.get(component: LocalTransformComponent.self, for: entityId) != nil { + if scene.get(component: LocalTransformComponent.self, for: motionEntity) != nil { // LocalTransformComponent's default rotation is the zero // quaternion (simd_quatf()), which rotates every vector to zero // — treat it as identity so deltas survive on never-rotated // entities. - var entityRotation = getRotationQuaternion(entityId: entityId) + var entityRotation = getRotationQuaternion(entityId: motionEntity) if simd_length_squared(entityRotation.vector) < 1e-8 { entityRotation = simd_quatf(ix: 0, iy: 0, iz: 0, r: 1) } if simd_length_squared(horizontal) > 0 { - translateBy(entityId: entityId, position: entityRotation.act(horizontal)) + translateBy(entityId: motionEntity, position: entityRotation.act(horizontal)) } if yawDelta != 0 { let yawRotation = simd_quatf(angle: yawDelta, axis: simd_float3(0, 1, 0)) - rotateTo(entityId: entityId, rotation: simd_normalize(entityRotation * yawRotation)) + rotateTo(entityId: motionEntity, rotation: simd_normalize(entityRotation * yawRotation)) } } } diff --git a/Sources/UntoldEngine/Systems/AnimationSystem.swift b/Sources/UntoldEngine/Systems/AnimationSystem.swift index 5e4c507df..7d983d878 100644 --- a/Sources/UntoldEngine/Systems/AnimationSystem.swift +++ b/Sources/UntoldEngine/Systems/AnimationSystem.swift @@ -358,6 +358,7 @@ public func setRootMotionEnabled(entityId: EntityID, enabled: Bool, rootJointPat for (_, animationComponent) in animationComponents { animationComponent.rootMotion.isEnabled = enabled animationComponent.rootMotion.rootJointPath = rootJointPath + animationComponent.rootMotion.anchorEntity = entityId animationComponent.rootMotion.resolvedRootIndex = nil animationComponent.rootMotion.resetHistory() } @@ -430,6 +431,7 @@ public func setMotionMatching(entityId: EntityID, descriptor: MotionMatchingDesc for (_, animationComponent) in animationComponents { animationComponent.motionMatching.descriptor = descriptor + animationComponent.motionMatching.anchorEntity = entityId animationComponent.motionMatching.reset() } } @@ -443,6 +445,9 @@ public func setMotionMatchingEnabled(entityId: EntityID, enabled: Bool) { for (_, animationComponent) in animationComponents { animationComponent.motionMatching.isEnabled = enabled + if animationComponent.motionMatching.anchorEntity == .invalid { + animationComponent.motionMatching.anchorEntity = entityId + } } } diff --git a/Tests/UntoldEngineTests/AnimationMotionMatchingTests.swift b/Tests/UntoldEngineTests/AnimationMotionMatchingTests.swift index 0531bc3c5..21bdeda30 100644 --- a/Tests/UntoldEngineTests/AnimationMotionMatchingTests.swift +++ b/Tests/UntoldEngineTests/AnimationMotionMatchingTests.swift @@ -252,3 +252,46 @@ final class AnimationMotionMatchingTests: XCTestCase { XCTAssertFalse(isMotionMatchingEnabled(entityId: entityId)) } } + +extension AnimationMotionMatchingTests { + /// Hierarchical assets (setEntityMeshAsync) carry their + /// AnimationComponent on a scenegraph child while the game drives the + /// root. Root motion deltas and the character frame must anchor to the + /// entity the public API was called on, not the component's entity. + @MainActor + func testHierarchicalAssetAnchorsMotionToAPIEntity() { + let root = createEntity() + registerComponent(entityId: root, componentType: LocalTransformComponent.self) + registerComponent(entityId: root, componentType: WorldTransformComponent.self) + registerComponent(entityId: root, componentType: ScenegraphComponent.self) + defer { destroyEntity(entityId: root) } + + // Reparent the fixture entity (which carries all the components) + // under the root, then call every API on the root — like a game. + setParent(childId: entityId, parentId: root) + + setRootMotionEnabled(entityId: root, enabled: true) + setMotionMatching(entityId: root, descriptor: MotionMatchingDescriptor( + leftFootPath: "root/foot_l", + rightFootPath: "root/foot_r", + weights: MotionMatchingWeights(footVelocity: 0.5) + )) + setMotionMatchingEnabled(entityId: root, enabled: true) + setMotionMatchingGoal(entityId: root, desiredVelocity: simd_float3(0, 0, 1)) + + var time: Float = 0 + while time < 1.5 { + AnimationSystem.shared.update(deltaTime) + time += deltaTime + } + + XCTAssertGreaterThan( + getLocalPosition(entityId: root).z, 0.3, + "Root motion must move the API entity (the gameplay handle)" + ) + XCTAssertEqual( + simd_length(getLocalPosition(entityId: entityId)), 0, accuracy: 1e-4, + "The component's child entity must not drift inside the asset" + ) + } +}