diff --git a/CoolZombie/Package.resolved b/CoolZombie/Package.resolved new file mode 100644 index 0000000..67a91c3 --- /dev/null +++ b/CoolZombie/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "d72e7cf6d19345d751ca8fb3cb19bfead0932d752f2bd6710e1e5be893dc6b90", + "pins" : [ + { + "identity" : "untoldengine", + "kind" : "remoteSourceControl", + "location" : "https://github.com/miolabs/UntoldEngine.git", + "state" : { + "branch" : "develop", + "revision" : "1edf2c18ac19aa4332e0ca3401b9c04335247528" + } + } + ], + "version" : 3 +} diff --git a/CoolZombie/Package.swift b/CoolZombie/Package.swift new file mode 100644 index 0000000..ff19b13 --- /dev/null +++ b/CoolZombie/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 6.0 +// CoolZombie — AI character locomotion demo for Untold Engine. +// +// 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 PackageDescription + +let package = Package( + name: "CoolZombie", + platforms: [ + .macOS(.v14), + .iOS(.v17), + .visionOS(.v2), + ], + dependencies: [ + .package(url: "https://github.com/miolabs/UntoldEngine.git", branch: "develop"), + ], + targets: [ + .executableTarget( + name: "CoolZombie", + dependencies: [ + .product(name: "UntoldEngine", package: "UntoldEngine"), + ], + resources: [ + .copy("Resources/Models"), + .copy("Resources/Animations"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + ] +) diff --git a/CoolZombie/README.md b/CoolZombie/README.md new file mode 100644 index 0000000..bb624a3 --- /dev/null +++ b/CoolZombie/README.md @@ -0,0 +1,35 @@ +# CoolZombie + +AI character locomotion demo for [Untold Engine](https://github.com/untoldengine/UntoldEngine) — **no animation state machine**. + +A wandering target orbits the arena and the character chases it. Every frame the AI states a *goal* (desired velocity and facing, straight from steering); the engine's animation stack does the rest: + +- **Motion matching** searches the loaded clips for the frame that best matches the current pose and predicted trajectory — nobody calls `changeAnimation`. +- **Inertialized transitions** smooth every clip jump. +- **Root motion** moves the entity: the clips' own travel is authoritative, so there is no foot sliding from mismatched speeds. +- **Foot IK** plants the feet on the ground. + +Far from the target the character runs, closing in it walks, arrived it idles — all emergent from one `setMotionMatchingGoal` call per frame. + +## Run + +```bash +swift run CoolZombie +``` + +macOS 14+. The package pins the engine to the `feature/animation_motion_matching` branch until the animation stack merges into `develop`. + +## Assets + +Placeholder assets while real motion data lands: + +- `redplayer` rig + `idle` clip from the engine's test resources. +- `run_forward` / `walk_forward` are generated from the engine's in-place `running` clip by injecting linear root travel (2.35 m/s and 0.94 m/s) — real traveling locomotion for the motion database, synthetic until mocap replaces it. + +The demo is written so assets swap without code changes: drop in new `.untold` clips (e.g. retargeted [ChingMu MotionDecode](https://huggingface.co/datasets/CMRobot/MotionDecode) captures), list them in `configureZombieAnimation`, and the motion database rebuilds from whatever is loaded. + +Planned motion data attribution: *Motion data: ChingMu MotionDecode Data Openness Program* (non-commercial use with attribution, per its access terms). + +## License + +MPL-2.0, matching the engine. This demo is non-commercial. diff --git a/CoolZombie/Sources/CoolZombie/AppDelegate.swift b/CoolZombie/Sources/CoolZombie/AppDelegate.swift new file mode 100644 index 0000000..85fc12a --- /dev/null +++ b/CoolZombie/Sources/CoolZombie/AppDelegate.swift @@ -0,0 +1,128 @@ +// +// AppDelegate.swift +// CoolZombie +// +// 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/. + +#if os(macOS) + import AppKit + import SwiftUI + import UntoldEngine + + @MainActor + final class AppDelegate: NSObject, NSApplicationDelegate { + private enum Constants { + static let windowSize = NSSize(width: 1280, height: 720) + static let minimumWindowSize = NSSize(width: 800, height: 600) + } + + private var window: NSWindow! + private var renderer: UntoldRenderer! + private var zombieScene: ZombieScene! + + func applicationDidFinishLaunching(_: Notification) { + setupWindow() + setupRendererAndScene() + presentSceneView() + } + + func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { + true + } + + private func setupWindow() { + window = NSWindow( + contentRect: NSRect(origin: .zero, size: Constants.windowSize), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "CoolZombie — Motion Matching Demo" + window.minSize = Constants.minimumWindowSize + window.center() + } + + private func setupRendererAndScene() { + guard let renderer = UntoldRenderer.create() else { + print("Failed to initialize UntoldRenderer.") + NSApp.terminate(nil) + return + } + + self.renderer = renderer + zombieScene = ZombieScene() + + renderer.setupCallbacks( + gameUpdate: { [weak self] deltaTime in + self?.zombieScene.update(deltaTime: deltaTime) + }, + handleInput: {} + ) + } + + private func presentSceneView() { + guard let renderer else { return } + + let hostingView = NSHostingView(rootView: CoolZombieView(renderer: renderer)) + window.contentView = hostingView + window.makeKeyAndOrderFront(nil) + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } + } + + private struct CoolZombieView: View { + let renderer: UntoldRenderer + @State private var isPlaying = false + + var body: some View { + ZStack { + SceneView(renderer: renderer) + + if isPlaying { + // Minimal overlay while running so recordings stay clean. + VStack { + Spacer() + HStack { + Spacer() + playPauseButton(systemName: "pause.fill") + .padding(16) + } + } + } else { + VStack(spacing: 16) { + Text("CoolZombie") + .font(.title.bold()) + Text("Motion matching: the character picks clips by itself — no state machine.") + .font(.caption) + .foregroundStyle(.secondary) + playPauseButton(systemName: "play.fill") + } + .padding(28) + .background(.black.opacity(0.55)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .foregroundStyle(.white) + } + } + } + + private func playPauseButton(systemName: String) -> some View { + Button { + isPlaying.toggle() + gameMode = isPlaying + } label: { + Image(systemName: systemName) + .font(.title2) + .frame(width: 44, height: 44) + .background(Circle().fill(.black.opacity(0.45))) + .foregroundStyle(.white.opacity(0.9)) + } + .buttonStyle(.plain) + .keyboardShortcut(.space, modifiers: []) + } + } +#endif diff --git a/CoolZombie/Sources/CoolZombie/Resources/Animations/idle/idle.untold b/CoolZombie/Sources/CoolZombie/Resources/Animations/idle/idle.untold new file mode 100644 index 0000000..0a93067 --- /dev/null +++ b/CoolZombie/Sources/CoolZombie/Resources/Animations/idle/idle.untold @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:322b34145c1ea4fd1f2291cc49fd19779a2ddfcb539590f164e6a9d39f8c8ea7 +size 328408 diff --git a/CoolZombie/Sources/CoolZombie/Resources/Animations/run_forward/run_forward.untold b/CoolZombie/Sources/CoolZombie/Resources/Animations/run_forward/run_forward.untold new file mode 100644 index 0000000..b9abe90 --- /dev/null +++ b/CoolZombie/Sources/CoolZombie/Resources/Animations/run_forward/run_forward.untold @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c082565ff26822301bbf24d6ea524e8ac903467d84eb10a4b802ab25aab75bd +size 43288 diff --git a/CoolZombie/Sources/CoolZombie/Resources/Animations/walk_forward/walk_forward.untold b/CoolZombie/Sources/CoolZombie/Resources/Animations/walk_forward/walk_forward.untold new file mode 100644 index 0000000..3cb47cc --- /dev/null +++ b/CoolZombie/Sources/CoolZombie/Resources/Animations/walk_forward/walk_forward.untold @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34c1c59679bbf503761e1e0a6cf68baf36ee7b42e2ff562b245fd90b8b53e71d +size 43288 diff --git a/CoolZombie/Sources/CoolZombie/Resources/Models/redplayer/Textures/soccer-player-0.png b/CoolZombie/Sources/CoolZombie/Resources/Models/redplayer/Textures/soccer-player-0.png new file mode 100644 index 0000000..9e40bda Binary files /dev/null and b/CoolZombie/Sources/CoolZombie/Resources/Models/redplayer/Textures/soccer-player-0.png differ diff --git a/CoolZombie/Sources/CoolZombie/Resources/Models/redplayer/redplayer.untold b/CoolZombie/Sources/CoolZombie/Resources/Models/redplayer/redplayer.untold new file mode 100644 index 0000000..e77574b --- /dev/null +++ b/CoolZombie/Sources/CoolZombie/Resources/Models/redplayer/redplayer.untold @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bfdf6658b5a2d78e8cbfc8531b7f4df545ad0b973d008a625a8adfe01b52b506 +size 72752 diff --git a/CoolZombie/Sources/CoolZombie/ZombieScene.swift b/CoolZombie/Sources/CoolZombie/ZombieScene.swift new file mode 100644 index 0000000..57533f3 --- /dev/null +++ b/CoolZombie/Sources/CoolZombie/ZombieScene.swift @@ -0,0 +1,228 @@ +// +// ZombieScene.swift +// CoolZombie +// +// 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/. + +#if os(macOS) + import Foundation + import simd + import UntoldEngine + + /// AI character locomotion with no animation state machine: a wandering + /// target orbits the arena, and the character chases it. Every frame the + /// AI states a *goal* (desired velocity + facing); motion matching picks + /// the animation frames, root motion moves the character, foot IK plants + /// the feet. Nobody ever calls changeAnimation. + @MainActor + final class ZombieScene { + // MARK: Rig constants (redplayer skeleton) + + private enum Rig { + static let leftFoot = "/Hips/LeftUpperLeg/LeftLowerLeg/LeftFoot" + static let rightFoot = "/Hips/RightUpperLeg/RightLowerLeg/RightFoot" + static let leftLeg = (hip: "/Hips/LeftUpperLeg", knee: "/Hips/LeftUpperLeg/LeftLowerLeg") + static let rightLeg = (hip: "/Hips/RightUpperLeg", knee: "/Hips/RightUpperLeg/RightLowerLeg") + } + + /// Effective travel speeds baked into the generated clips + /// (root displacement per loop / loop duration). + private enum Locomotion { + static let runSpeed: Float = 2.35 + static let walkSpeed: Float = 0.94 + static let runDistance: Float = 4.0 + static let stopDistance: Float = 1.2 + } + + // MARK: Entities + + private var zombie: EntityID = .invalid + private var target: EntityID = .invalid + private var zombieReady = false + + private var targetAngle: Float = 0 + + init() { + configureEngine() + makeCamera() + makeSun() + makeGround() + makeTarget() + loadZombie() + } + + // MARK: - Setup + + private func configureEngine() { + // Starts paused; the play button (or Space) flips gameMode so a + // screen recording can be armed on a clean still frame. + gameMode = false + setSceneReady(false) + if let resources = Bundle.module.resourceURL { + setEngine(.assetBasePath(resources)) + } + setRendering(.postProcessing(.enabled)) + setRendering(.antiAliasing(.fxaa)) + setRendering(.environment(.ibl(true))) + setRendering(.environment(.visible(false))) + } + + private func makeCamera() { + let camera = createEntity() + setEntityName(entityId: camera, name: "Main Camera") + createGameCamera(entityId: camera) + cameraLookAt( + entityId: camera, + eye: simd_float3(0, 4.5, -7.5), + target: simd_float3(0, 0.8, 0), + up: simd_float3(0, 1, 0) + ) + setCamera(.active(camera)) + } + + private func makeSun() { + let sun = createEntity() + setEntityName(entityId: sun, name: "Sun") + createDirLight(entityId: sun) + rotateTo(entityId: sun, angle: -50.0, axis: simd_float3(1, 0, 0)) + setLight(entityId: sun, .color(simd_float3(1.0, 0.94, 0.86))) + setLight(entityId: sun, .intensity(1.5)) + setLight(entityId: sun, .directional(.active)) + } + + private func makeGround() { + let ground = createEntity() + setEntityName(entityId: ground, name: "Ground") + let plane = BasicPrimitives.createPlane(width: 24, depth: 24) + setEntityMeshDirect(entityId: ground, meshes: plane, assetName: "ground") + } + + private func makeTarget() { + target = createEntity() + setEntityName(entityId: target, name: "Target") + let marker = BasicPrimitives.createCube(extent: 0.125) + setEntityMeshDirect(entityId: target, meshes: marker, assetName: "target") + translateTo(entityId: target, position: simd_float3(3.5, 0.15, 0)) + } + + private func loadZombie() { + zombie = createEntity() + setEntityName(entityId: zombie, name: "Zombie") + setEntityMeshAsync(entityId: zombie, filename: "redplayer", withExtension: "untold") { [weak self] success in + guard let self, success else { + print("CoolZombie: failed to load redplayer mesh") + setSceneReady(true) + return + } + configureZombieAnimation() + setSceneReady(true) + } + } + + private func configureZombieAnimation() { + setEntityAnimations(entityId: zombie, filename: "idle", withExtension: "untold", name: "idle") + setEntityAnimations(entityId: zombie, filename: "walk_forward", withExtension: "untold", name: "walk_forward") + setEntityAnimations(entityId: zombie, filename: "run_forward", withExtension: "untold", name: "run_forward") + + // The clips' own travel moves the entity. + setRootMotionEnabled(entityId: zombie, enabled: true) + + // Feet planted on the (flat) arena; swap the query for a terrain + // heightfield when the demo gains real ground. + setFootIKChains(entityId: zombie, chains: [ + FootIKChainDescriptor(hipPath: Rig.leftLeg.hip, kneePath: Rig.leftLeg.knee, anklePath: Rig.leftFoot), + FootIKChainDescriptor(hipPath: Rig.rightLeg.hip, kneePath: Rig.rightLeg.knee, anklePath: Rig.rightFoot), + ]) + setFootIKGroundQuery(entityId: zombie) { _ in + FootIKGroundSample(height: 0) + } + setFootIKEnabled(entityId: zombie, enabled: true) + + // Motion matching: the loaded clips are the whole vocabulary. + setMotionMatching(entityId: zombie, descriptor: MotionMatchingDescriptor( + leftFootPath: Rig.leftFoot, + rightFootPath: Rig.rightFoot + )) + setMotionMatchingEnabled(entityId: zombie, enabled: true) + + zombieReady = true + } + + // MARK: - Per-frame update + + func update(deltaTime: Float) { + guard gameMode else { return } + + moveTarget(deltaTime: deltaTime) + + guard zombieReady else { return } + + // The AI: chase the target. Far away → run, closing in → walk, + // arrived → stand. The goal is the only animation input. + let zombiePosition = getPosition(entityId: zombie) + let targetPosition = getPosition(entityId: target) + var toTarget = targetPosition - zombiePosition + toTarget.y = 0 + let distance = simd_length(toTarget) + + var desiredVelocity = simd_float3.zero + var desiredFacing: simd_float3? + if distance > Locomotion.stopDistance { + let direction = toTarget / distance + let speed = distance > Locomotion.runDistance ? Locomotion.runSpeed : Locomotion.walkSpeed + desiredVelocity = direction * speed + desiredFacing = direction + } + + setMotionMatchingGoal( + entityId: zombie, + desiredVelocity: desiredVelocity, + desiredFacing: desiredFacing + ) + + // The generated clips travel without turning, so steer the + // heading directly toward the goal; turning clips replace this + // once real mocap lands. + if distance > Locomotion.stopDistance { + turnToward(direction: toTarget / distance, deltaTime: deltaTime) + } + } + + /// Rotates the character's yaw toward `direction` along the shortest + /// arc, capped at `turnSpeed` rad/s. Explicit yaw-only control: the + /// engine's alignOrientation mixes orientation-matrix columns, which + /// degenerates for large heading changes (a target passing behind + /// the character), and it expects physics components this entity + /// doesn't carry. + private func turnToward(direction: simd_float3, deltaTime: Float) { + let turnSpeed: Float = 3.0 // rad/s + + let forward = getForwardAxisVector(entityId: zombie) + let currentYaw = atan2f(forward.x, forward.z) + let targetYaw = atan2f(direction.x, direction.z) + + // Shortest signed arc, wrapped to (-π, π]. + var delta = fmodf(targetYaw - currentYaw + .pi, 2 * .pi) + if delta < 0 { delta += 2 * .pi } + delta -= .pi + + let maxStep = turnSpeed * deltaTime + let step = max(-maxStep, min(maxStep, delta)) + rotateTo( + entityId: zombie, + rotation: simd_quatf(angle: currentYaw + step, axis: simd_float3(0, 1, 0)) + ) + } + + private func moveTarget(deltaTime: Float) { + targetAngle += deltaTime * 0.35 + let radius: Float = 3.5 + sinf(targetAngle * 0.7) * 1.5 + let position = simd_float3(cosf(targetAngle) * radius, 0.15, sinf(targetAngle) * radius) + translateTo(entityId: target, position: position) + } + } +#endif diff --git a/CoolZombie/Sources/CoolZombie/main.swift b/CoolZombie/Sources/CoolZombie/main.swift new file mode 100644 index 0000000..583e4b4 --- /dev/null +++ b/CoolZombie/Sources/CoolZombie/main.swift @@ -0,0 +1,18 @@ +// +// main.swift +// CoolZombie +// +// 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/. + +#if os(macOS) + import AppKit + + let app = NSApplication.shared + let delegate = AppDelegate() + app.delegate = delegate + app.run() +#endif