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..c089ad768 --- /dev/null +++ b/Sources/UntoldEngine/Animation/MotionMatching.swift @@ -0,0 +1,387 @@ +// +// 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? + + /// 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 + 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 } + + 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) + : 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, + anchorEntity: anchor, + 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, + anchorEntity: 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: anchorEntity) + + // 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/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/ECS/Components.swift b/Sources/UntoldEngine/ECS/Components.swift index 236f690bb..73aa71161 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..7d983d878 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 } @@ -346,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() } @@ -405,6 +418,69 @@ 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.anchorEntity = entityId + 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 + if animationComponent.motionMatching.anchorEntity == .invalid { + animationComponent.motionMatching.anchorEntity = entityId + } + } +} + +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 +494,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 +516,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..21bdeda30 --- /dev/null +++ b/Tests/UntoldEngineTests/AnimationMotionMatchingTests.swift @@ -0,0 +1,297 @@ +// +// 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)) + } +} + +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" + ) + } +} 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.