diff --git a/.agents/skills/adaengine-development/references/architecture.md b/.agents/skills/adaengine-development/references/architecture.md index 5d4f2f30a..692976de9 100644 --- a/.agents/skills/adaengine-development/references/architecture.md +++ b/.agents/skills/adaengine-development/references/architecture.md @@ -37,7 +37,7 @@ The root `AdaEngine` product is the high-level facade. Prefer implementing behav ### Tools and applications - `Editor/`: its own SwiftPM package with executable `AdaEditor`, tests, resources, package tooling, local root-AdaEngine dependency, and `project.yml` for a macOS app wrapper generated with XcodeGen. -- `Demos/`: real examples and stress/benchmark scenes. Use these to understand current API style and for manual/runtime validation, but do not treat a demo as the implementation layer. +- `AdaExamples/Examples/Technical/` (separate repository): real examples and stress/benchmark scenes. The engine checkout keeps `Demos/` ignored for local work only. Use these to understand current API style and for manual/runtime validation, but do not treat a demo as the implementation layer. - `Plugins/`: SwiftPM build/command plugins for WebGPU/Tint, web export, shader transpilation, and texture atlas generation. - `Tests/Tests`: focused tests aligned with root modules. `Editor/Tests/AdaEditorTests` belongs to the separate editor package. diff --git a/Demos/A2UIForm/A2UIFormDemo.swift b/Demos/A2UIForm/A2UIFormDemo.swift deleted file mode 100644 index e2f21d687..000000000 --- a/Demos/A2UIForm/A2UIFormDemo.swift +++ /dev/null @@ -1,199 +0,0 @@ -import AdaA2UI -import AdaEngine -@_spi(Internal) import AdaPlatform -import Foundation -import Observation -#if os(macOS) -import AppKit -#endif - -@main -struct A2UIFormDemo: App { - var body: some AppScene { - WindowGroup { FormDemoView() } - .window(with: UIWindow.Configuration(title: "AdaUI · A2UI Forms", frame: Rect(x: 160, y: 140, width: 760, height: 820))) - } -} - -@MainActor @Observable -private final class FormDemoModel { - let client = A2UIClient() - var status = "Waiting for the agent stream…" - var lastAction = "Submit the form to inspect its action payload." - var started = false - var lastEvent: A2UIClientEvent? - - init() { - client.onEvent = { [weak self] event in - guard let self else { return } - if case let .object(envelope) = event.envelope, envelope["action"] != nil { - self.lastEvent = event - self.lastAction = (try? String(decoding: JSONEncoder().encode(event.envelope), as: UTF8.self)) ?? "Action received" - demoLog("A2UI action: \(self.lastAction)") - } else { - self.status = self.client.lastError?.message ?? "Invalid message rejected" - } - } - } - - func start() async { - guard !started else { return } - started = true - do { - guard let url = Bundle.module.url(forResource: "form", withExtension: "jsonl", subdirectory: "Fixtures") else { - throw A2UIValidationError(message: "Demo fixture missing.") - } - let data = try Data(contentsOf: url) - var decoder = A2UIJSONLDecoder() - // Byte chunks exercise the same framing path a network adapter would use. - for offset in stride(from: 0, to: data.count, by: 53) { - let chunk = data.subdata(in: offset.. some View { - PlainTextFieldStyle()._body(configuration: configuration) - .padding(.horizontal, 8) - .background(Color(red: 0.16, green: 0.19, blue: 0.24)) - .border(Color(red: 0.34, green: 0.39, blue: 0.47), lineWidth: 1) - } -} - -private struct FormDemoButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .fontSize(14) - .padding(9) - .background(configuration.isPressed ? Color(red: 0.25, green: 0.38, blue: 0.55) : Color(red: 0.18, green: 0.26, blue: 0.37)) - } -} diff --git a/Demos/A2UIForm/Fixtures/form.jsonl b/Demos/A2UIForm/Fixtures/form.jsonl deleted file mode 100644 index dcba20a88..000000000 --- a/Demos/A2UIForm/Fixtures/form.jsonl +++ /dev/null @@ -1,5 +0,0 @@ -{"version":"v0.9.1","createSurface":{"surfaceId":"form","catalogId":"https://adaengine.org/a2ui/catalogs/forms/v1","sendDataModel":true}} -{"version":"v0.9.1","updateComponents":{"surfaceId":"form","components":[{"id":"root","component":"Column","spacing":16,"children":["heading","name","copy","enabled","role","health-label","health","submit"]},{"id":"heading","component":"Text","text":"Create an NPC profile"}]}} -{"version":"v0.9.1","updateComponents":{"surfaceId":"form","components":[{"id":"name","component":"TextField","placeholder":"NPC name","text":{"path":"/npc/name"},"checks":[{"condition":{"call":"required","args":{"value":{"path":"/npc/name"}}},"message":"NPC name is required."}]},{"id":"copy","component":"Text","text":{"path":"/npc/name"}},{"id":"enabled","component":"Toggle","label":"Enable dialog","value":{"path":"/npc/dialogEnabled"}}]}} -{"version":"v0.9.1","updateDataModel":{"surfaceId":"form","value":{"npc":{"name":"Guide","dialogEnabled":true,"role":"guide","health":50}}}} -{"version":"v0.9.1","updateComponents":{"surfaceId":"form","components":[{"id":"submit","component":"Button","text":"Create profile","action":{"event":{"name":"create_npc","context":{"name":{"path":"/npc/name"},"dialogEnabled":{"path":"/npc/dialogEnabled"},"role":{"path":"/npc/role"},"health":{"path":"/npc/health"}}}},"checks":[{"condition":{"call":"required","args":{"value":{"path":"/npc/name"}}},"message":"NPC name is required."},{"condition":{"call":"range","args":{"value":{"path":"/npc/health"},"min":1,"max":100}},"message":"Health must be between 1 and 100."}]},{"id":"role","component":"ChoicePicker","label":"Role","value":{"path":"/npc/role"},"options":[{"label":"Guide","value":"guide"},{"label":"Merchant","value":"merchant"},{"label":"Guard","value":"guard"}]},{"id":"health-label","component":"Text","text":"Health"},{"id":"health","component":"Slider","value":{"path":"/npc/health"},"min":1,"max":100,"step":1,"checks":[{"condition":{"call":"range","args":{"value":{"path":"/npc/health"},"min":1,"max":100}},"message":"Health must be between 1 and 100."}]}]}} diff --git a/Demos/A2UIForm/README.md b/Demos/A2UIForm/README.md deleted file mode 100644 index 231d8feb7..000000000 --- a/Demos/A2UIForm/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# A2UI form demo - -See [the integration guide](../../Documentation/A2UI.md). - -The NPC form includes a single-choice role picker and stepped health slider. -Required/range checks show inline messages and block invalid submission. Native -verification clears the name, checks rejection, corrects it, chooses a role, -changes health, submits, and exports controls plus validation into `.ui`. diff --git a/Demos/A2UIForm/script/build_and_run.sh b/Demos/A2UIForm/script/build_and_run.sh deleted file mode 100755 index 89248c792..000000000 --- a/Demos/A2UIForm/script/build_and_run.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -MODE="${1:-run}" -DEMO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -REPO_ROOT="$(cd "$DEMO_ROOT/../.." && pwd)" -BUILD_PATH="${ADAENGINE_A2UI_BUILD_PATH:-/private/tmp/adaengine-a2ui-build}" -mkdir -p "$BUILD_PATH" -BUILD_PATH="$(cd "$BUILD_PATH" && pwd -P)" -APP="$DEMO_ROOT/dist/A2UIFormDemo.app" -export ADAENGINE_DISABLE_SWAN=1 -export CLANG_MODULE_CACHE_PATH="$BUILD_PATH/clang-cache" -export SWIFTPM_MODULECACHE_OVERRIDE="$BUILD_PATH/swift-cache" -case "$MODE" in run|--verify|--debug|--logs|--telemetry) ;; *) echo "usage: $0 [run|--verify|--debug|--logs|--telemetry]" >&2; exit 2 ;; esac -for demo_pid in $(pgrep -f "$APP/Contents/MacOS/A2UIFormDemo" || true); do - demo_command="$(ps -p "$demo_pid" -o command= || true)" - if [[ "$demo_command" == "$APP/Contents/MacOS/A2UIFormDemo" || "$demo_command" == "$APP/Contents/MacOS/A2UIFormDemo "* ]]; then - kill "$demo_pid" - fi -done -mkdir -p "$DEMO_ROOT/dist" -if ! swift build --package-path "$REPO_ROOT" --scratch-path "$BUILD_PATH" --disable-sandbox --product A2UIFormDemo > "$DEMO_ROOT/dist/build.log" 2>&1; then - tail -60 "$DEMO_ROOT/dist/build.log" - exit 1 -fi -BIN="$(swift build --package-path "$REPO_ROOT" --scratch-path "$BUILD_PATH" --disable-sandbox --show-bin-path)" -mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" -cp "$BIN/A2UIFormDemo" "$APP/Contents/MacOS/" -for resource in "$BIN"/*.bundle; do - [[ -d "$resource" ]] && cp -R "$resource" "$APP/" -done -cat > "$APP/Contents/Info.plist" <<'PLIST' - - - -CFBundleExecutableA2UIFormDemo -CFBundleIdentifierorg.adaengine.demo.a2ui-form -CFBundleNameAdaUI A2UI Forms -CFBundlePackageTypeAPPL -LSMinimumSystemVersion15.0 -NSPrincipalClassNSApplication - -PLIST -: > "$DEMO_ROOT/dist/runtime.log" -if [[ "$MODE" == --debug ]]; then - /usr/bin/lldb -- "$APP/Contents/MacOS/A2UIFormDemo" - exit -fi -OPEN_ARGS=(-n "$APP" --stdout "$DEMO_ROOT/dist/runtime.log" --stderr "$DEMO_ROOT/dist/runtime.log" --args) -if [[ "$MODE" == --verify ]]; then OPEN_ARGS+=(--verify --proof-directory "$DEMO_ROOT/dist/proof"); fi -/usr/bin/open "${OPEN_ARGS[@]}" -case "$MODE" in - --verify) - for attempt in {1..30}; do - if rg -q 'A2UI demo verification PASS' "$DEMO_ROOT/dist/runtime.log"; then - cat "$DEMO_ROOT/dist/runtime.log" - exit 0 - fi - if rg -q 'verification FAIL|Fatal error' "$DEMO_ROOT/dist/runtime.log"; then - tail -40 "$DEMO_ROOT/dist/runtime.log" - exit 1 - fi - sleep 1 - done - tail -40 "$DEMO_ROOT/dist/runtime.log" - echo 'A2UI verification timed out' >&2 - exit 1 - ;; - --logs|--telemetry) /usr/bin/log stream --info --style compact --predicate 'process == "A2UIFormDemo"' ;; -esac diff --git a/Demos/AndroidDemo/AndroidDemo.swift b/Demos/AndroidDemo/AndroidDemo.swift deleted file mode 100644 index baaa09748..000000000 --- a/Demos/AndroidDemo/AndroidDemo.swift +++ /dev/null @@ -1,39 +0,0 @@ -#if os(Android) - import AdaEngine - import AdaPlatform - import Foundation - - @_cdecl("ada_android_start") - public func androidDemoStart() { - AndroidRuntime.start { AndroidDemo() } - } - - struct AndroidDemo: App { - var body: some AppScene { - WindowGroup( - content: { AndroidDemoView() }, - assetBundle: AndroidResourceBundle.bundle(named: "AdaEngine_AndroidDemo") - ) - } - } - - private struct AndroidDemoView: View { - @State private var taps = 0 - var body: some View { - VStack(spacing: 24) { - Text("AdaEngine on Android").foregroundColor(.white) - Text("Native Swift • Android Surface • Touch").foregroundColor(.white) - Text("Taps: \(taps)").foregroundColor(.white) - Button(action: { - taps += 1 - AndroidRuntime.log("AndroidDemo taps=\(taps)") - }) { - Text("Tap +1").foregroundColor(.white) - } - } - } - } - -#else - public enum AndroidDemoUnsupportedHost {} -#endif diff --git a/Demos/AndroidDemo/Assets/fixture.txt b/Demos/AndroidDemo/Assets/fixture.txt deleted file mode 100644 index 4331b72ff..000000000 --- a/Demos/AndroidDemo/Assets/fixture.txt +++ /dev/null @@ -1 +0,0 @@ -AdaEngine Android packaged resource diff --git a/Demos/AndroidDemo/README.md b/Demos/AndroidDemo/README.md deleted file mode 100644 index 9952c8607..000000000 --- a/Demos/AndroidDemo/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# AndroidDemo - -Native Swift AdaUI demo for the Android host. Build and run from the repository -root with `python3 Tools/Android/android.py run`. See `Tools/Android/README.md` -for matching toolchain/SDK installation and emulator/device selection. diff --git a/Demos/LinuxSmoke/LinuxSmoke.swift b/Demos/LinuxSmoke/LinuxSmoke.swift deleted file mode 100644 index d32df8ad8..000000000 --- a/Demos/LinuxSmoke/LinuxSmoke.swift +++ /dev/null @@ -1,49 +0,0 @@ -import AdaEngine -@_spi(Internal) import AdaPlatform -import Foundation - -@main -struct LinuxSmoke: App { - var body: some AppScene { - WindowGroup { - LinuxSmokeView() - } - .window( - with: UIWindow.Configuration( - title: "AdaEngine Linux", - frame: Rect(x: 80, y: 80, width: 800, height: 600), - minimumSize: Size(width: 480, height: 360) - ) - ) - } -} - -private struct LinuxSmokeView: View { - @State private var clicks = 0 - @State private var text = "Hello, Linux!" - - var body: some View { - VStack(spacing: 24) { - Text("AdaEngine on Linux") - .font(.system(size: 36)) - Text("Resize the window, type a message and press the button.") - TextField("Message", text: $text) - .frame(width: 360, height: 44) - Button("Clicks: \(clicks)") { - clicks += 1 - smokeLog("LINUX_SMOKE_CLICK \(clicks) text=\(text)") - } - Text(text) - } - .padding(32) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.white) - .task { - smokeLog("LINUX_SMOKE_READY backend=\(RenderEngine.shared.type)") - } - } -} - -private func smokeLog(_ message: String) { - FileHandle.standardOutput.write(Data((message + "\n").utf8)) -} diff --git a/Demos/MovieMaker/MovieMakerDemo.swift b/Demos/MovieMaker/MovieMakerDemo.swift deleted file mode 100644 index 789b1df72..000000000 --- a/Demos/MovieMaker/MovieMakerDemo.swift +++ /dev/null @@ -1,75 +0,0 @@ -import AdaEngine -import Math - -/// An asset-free take that exercises the same renderer and mixer used by games. -@main -struct MovieMakerDemo { - @MainActor - static func main() async throws { - let configuration = try MovieRecordingConfiguration.commandLine() ?? MovieRecordingConfiguration( - outputURL: URL(fileURLWithPath: "Movies/demo.png"), - width: 640, - height: 360, - frameCount: 180 - ) - let maker = MovieMaker() - let scene = MovieDemoScene(configuration: configuration) - defer { scene.music?.stop() } - let recording = try await maker.record(configuration: configuration, make: { app in - scene.configure(app) - }, update: { world, frame in - try scene.update(world: world, frame: frame) - }, progress: { count in - if count % configuration.framesPerSecond == 0 { print("Recorded \(count) frames") } - }) - print("Saved \(recording.frameCount) frames (\(recording.duration) seconds) to \(configuration.outputURL.deletingLastPathComponent().path)") - } -} - -@MainActor -private final class MovieDemoScene { - let configuration: MovieRecordingConfiguration - var sprites: [Entity] = [] - var music: AudioPlaybackController? - - init(configuration: MovieRecordingConfiguration) { self.configuration = configuration } - - func configure(_ app: AppWorlds) { - app.addPlugin(TransformPlugin()) - .addPlugin(RenderWorldPlugin()) - .addPlugin(CameraPlugin()) - .addPlugin(VisibilityPlugin()) - .addPlugin(Core2DPlugin()) - .addPlugin(SpritePlugin()) - .addPlugin(AudioPlugin()) - var camera = Camera() - camera.backgroundColor = Color(red: 0.025, green: 0.035, blue: 0.08) - app.spawn("TrailerCamera", bundle: Camera2D(camera: camera)) - } - - func update(world: World, frame: MovieFrame) throws { - if sprites.isEmpty { - for index in 0..<9 { - let hue = Float(index) / 8 - let color = Color(red: 0.25 + hue * 0.7, green: 0.85 - hue * 0.4, blue: 1 - hue * 0.35) - sprites.append(world.spawn("Light \(index)") { - Sprite(tintColor: color, size: [32, 32]) - Transform() - }) - } - var spec = SoundSpec() - spec.peak = 0.15 - spec.voices = [SoundVoice(wave: .sine, frequency: 220, duration: 1)] - let resource = try AudioResource.create(from: WAVWriter().encode(SoundSynth.render(spec))) - music = AudioServer.shared.prepareAudio(resource).setLoop(true) - music?.play() - } - let time = Float(frame.time) - for (index, entity) in sprites.enumerated() { - let phase = time * 1.4 + Float(index) * .pi * 2 / 9 - let x = Math.cos(phase) * Float(configuration.width) * 0.32 - let y = Math.sin(phase * 2) * Float(configuration.height) * 0.28 - entity.components += Transform(rotation: Quat(axis: [0, 0, 1], angle: phase), position: [x, y, 0]) - } - } -} diff --git a/Demos/Navigation/NavigationDemoApp.swift b/Demos/Navigation/NavigationDemoApp.swift deleted file mode 100644 index 716abf0e7..000000000 --- a/Demos/Navigation/NavigationDemoApp.swift +++ /dev/null @@ -1,201 +0,0 @@ -import AdaEngine -import Foundation -import Observation - -@MainActor -@Observable -final class NavigationDemoSession: Resource { - var status = "Готовим площадку…" - var showNavigation = true - var rebakeRequested = false - var nextGoalRequested = false - var resetRequested = false - var patrolRequested = false - var script: AdaScriptPlugin? - var triangles: [Vector3] = [] - var robot: Entity.ID? - var goalIndex = 0 - var wasMouseDown = false - - func setStatus(_ value: String) { - if status != value { status = value } - } -} - -struct NavigationDemoApp: App { - var body: some AppScene { - WindowGroup { NavigationDemoView() } - .window(with: UIWindow.Configuration(title: "AdaEngine · Navigation playground", frame: Rect(x: 80, y: 60, width: 1100, height: 800))) - } -} - -private struct NavigationDemoView: View { - @State private var session = NavigationDemoSession() - - var body: some View { - VStack(spacing: 12) { - Text("Проложи путь для робота").font(.system(size: 24)) - Text("Нажми на пол — робот найдёт дорогу вокруг препятствий.") - HStack(spacing: 18) { - Button("Другая цель") { session.nextGoalRequested = true } - Button(session.showNavigation ? "Скрыть сетку" : "Показать сетку") { session.showNavigation.toggle() } - Button("В начало") { session.resetRequested = true } - Button("Запечь заново") { session.rebakeRequested = true } - Button("Патруль AdaScript") { session.patrolRequested = true } - } - Text(session.status) - SceneView(useSceneCameras: true, make: { app in - configureNavigationDemo(&app, session: session) - }, updateContent: { _, _ in }) - .frameCaptureLabel("NavigationDemo") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .padding(16) - .background(Color(0.06, 0.08, 0.13, 1)) - .foregroundColor(.white) - } -} - -@MainActor -func configureNavigationDemo(_ app: inout AppWorlds, session: NavigationDemoSession) { - app.addPlugin(TransformPlugin()).addPlugin(InputPlugin()).addPlugin(RenderWorldPlugin()) - .addPlugin(CameraPlugin()).addPlugin(AssetsPlugin()).addPlugin(VisibilityPlugin()) - .addPlugin(ScenePlugin()).addPlugin(Core3DPlugin(includes2D: false)).addPlugin(UpscalePlugin()) - .addPlugin(Physics3DPlugin()).addPlugin(Navigation3DPlugin()).addPlugin(NavigationDemoPlugin()) - app.insertResource(session) -} - -/// SceneView installs its schedulers after make(); register systems when plugins are built. -private struct NavigationDemoPlugin: Plugin { - func setup(in app: AppWorlds) { - app.addSystem(NavigationDemoStartupSystem.self, on: .startup) - app.addSystem(NavigationDemoControlsSystem.self, on: .update) - app.addSystem(NavigationDemoOverlaySystem.self, on: .postUpdate) - do { - app.main.getResource(NavigationDemoSession.self)?.script = try NavigationDemoScript.install(in: app) - } catch { - app.main.getResource(NavigationDemoSession.self)?.setStatus("Ошибка AdaScript: \(error)") - } - if CommandLine.arguments.contains("--capture") { - app.getSubworldBuilder(by: .renderWorld)?.getResource(RenderGraphDiagnostics.self)?.configure(isEnabled: true) - } - } -} - -@System -@MainActor -func NavigationDemoStartup(_ context: WorldUpdateContext, _ session: Res) async { - let robot = NavigationDemoFixture.installPhysics(in: context.world) - session.wrappedValue.robot = robot.id - robot.components[NavigationDemoPatrolComponent.self] = NavigationDemoPatrolComponent() - robot.components[NavigationTarget3DComponent.self] = NavigationTarget3DComponent() - NavigationDemoFixture.installGraphics(in: context.world, robot: robot) - do { - let mesh = try await NavigationMesh(data: NavigationDemoFixture.bakedAsset()) - context.world.insertResource(NavigationMap3D(mesh: mesh)) - session.wrappedValue.triangles = try await mesh.debugTriangles() - } catch { - session.wrappedValue.status = "Не удалось построить сетку: \(error)" - } -} - -@PlainSystem(dependencies: [.before(NavigationPathRequest3DSystem.self)]) -struct NavigationDemoControlsSystem { - @Res private var session - @Res private var input - @Query private var cameras - // Reset/picking use the robot's stable ID; declare those component writes to the scheduler. - @Query, Ref, Ref, Ref, Ref> - private var robotAccess - - init(world _: World) {} - - @MainActor - func update(context: UpdateContext) async { - let state = session - _ = robotAccess - guard let id = state.robot, let robot = context.world.getEntityByID(id) else { return } - if state.resetRequested { - robot.components[NavigationDemoPatrolComponent.self] = NavigationDemoPatrolComponent(enabled: false) - state.resetRequested = false - robot.components[Transform.self] = Transform(position: NavigationDemoFixture.start) - var controller = robot.components[CharacterController3DComponent.self] ?? CharacterController3DComponent() - controller.resetMotion = true - robot.components[CharacterController3DComponent.self] = controller - // Invalidate the pending request even when returning to the same goal. - robot.components[NavigationAgent3DComponent.self] = NavigationAgent3DComponent() - } - if state.nextGoalRequested { - robot.components[NavigationDemoPatrolComponent.self] = NavigationDemoPatrolComponent(enabled: false) - state.nextGoalRequested = false - state.goalIndex = (state.goalIndex + 1) % NavigationDemoFixture.goals.count - robot.components[NavigationTarget3DComponent.self] = NavigationTarget3DComponent(destination: NavigationDemoFixture.goals[state.goalIndex]) - } - let down = input.isMouseButtonPressed(.left) - if down && !state.wasMouseDown, let (camera, transform) = cameras.first, - let ray = camera.viewportToWorld( - cameraGlobalTransform: transform.matrix, - point: Vector2(input.getMousePosition().x, camera.logicalViewport.rect.size.height - input.getMousePosition().y) - ), - abs(ray.direction.y) > 0.0001 { - let distance = -ray.origin.y / ray.direction.y - let point = ray.origin + ray.direction * distance - if distance > 0, abs(point.x) < 10, abs(point.z) < 7 { - robot.components[NavigationDemoPatrolComponent.self] = NavigationDemoPatrolComponent(enabled: false) - robot.components[NavigationTarget3DComponent.self] = NavigationTarget3DComponent(destination: [point.x, 0, point.z]) - } - } - state.wasMouseDown = down - if state.patrolRequested { - state.patrolRequested = false - robot.components[NavigationDemoPatrolComponent.self] = NavigationDemoPatrolComponent() - robot.components[NavigationTarget3DComponent.self] = NavigationTarget3DComponent() - } - if state.rebakeRequested { - state.rebakeRequested = false - state.status = "Запекаем площадку…" - do { - let mesh = try await NavigationMesh(data: NavigationDemoFixture.bakedAsset()) - context.world.insertResource(NavigationMap3D(mesh: mesh)) - state.triangles = try await mesh.debugTriangles() - } catch { - state.status = "Ошибка запекания: \(error)" - } - } - } -} - -@System -@MainActor -func NavigationDemoOverlay( - _ session: Res, - _ agents: Query, - _ draw: Res -) { - let state = session.wrappedValue - if state.showNavigation { - let triangles = state.triangles - for offset in stride(from: 0, to: triangles.count, by: 3) { - draw.wrappedValue.polyline(Array(triangles[offset..<(offset + 3)]).map { $0 + Vector3(0, 0.06, 0) }, closed: true, color: Color(0.15, 0.85, 0.7, 1)) - } - } - agents.forEach { agent, target, transform in - if let path = agent.follower.path { - draw.wrappedValue.polyline(path.points.map { $0 + Vector3(0, 0.16, 0) }, color: .yellow, depthTested: false) - } - if let destination = target.destination { - draw.wrappedValue.sphere(center: destination + Vector3(0, 0.2, 0), radius: 0.3, color: agent.lastError == nil ? .yellow : .red) - } - draw.wrappedValue.sphere(center: transform.position + Vector3(0, 0.06, 0), radius: 0.4, color: .mint) - if let error = agent.lastError { - state.setStatus(error == .targetOutsideMesh || error == .unreachable ? "Здесь нет прохода. Выбери другую точку." : "Ошибка маршрута: \(error)") - } else { - switch agent.follower.state { - case .following: state.setStatus("Идём к цели · поворотов: \(max(0, (agent.follower.path?.points.count ?? 0) - 2))") - case .arrived: state.setStatus("Пришли! Выбери следующую цель.") - case .blocked: state.setStatus("Проход занят — ищем путь снова…") - default: break - } - } - } -} diff --git a/Demos/Navigation/NavigationDemoFixture.swift b/Demos/Navigation/NavigationDemoFixture.swift deleted file mode 100644 index 9af5141fe..000000000 --- a/Demos/Navigation/NavigationDemoFixture.swift +++ /dev/null @@ -1,115 +0,0 @@ -import AdaEngine -import AdaNavigationBaking -import Foundation - -/// The same geometry drives the bake, physical colliders, and visible meshes. -@MainActor -enum NavigationDemoFixture { - struct Obstacle { - let center: Vector3 - let size: Vector3 - } - - static let start = Vector3(-8, 0, -4) - static let goals: [Vector3] = [[8, 0, 4], [-8, 0, 5], [8, 0, -5], [0, 0, 5]] - static let obstacles = [ - Obstacle(center: [0, 1.5, 0], size: [4, 3, 4]), - Obstacle(center: [-5.5, 1.1, 0], size: [1.4, 2.2, 6]), - Obstacle(center: [5, 1.1, 2.5], size: [2, 2.2, 3]) - ] - - static func bakedAsset() async throws -> NavigationMeshData { - var vertices: [Vector3] = [[-10, 0, -7], [-10, 0, 7], [10, 0, 7], [10, 0, -7]] - var indices: [UInt32] = [0, 1, 2, 0, 2, 3] - for box in obstacles { - let min = box.center - box.size / 2 - let max = box.center + box.size / 2 - let corners: [Vector3] = [ - [min.x, min.y, min.z], [min.x, min.y, max.z], [max.x, min.y, max.z], [max.x, min.y, min.z], - [min.x, max.y, min.z], [min.x, max.y, max.z], [max.x, max.y, max.z], [max.x, max.y, min.z] - ] - let offset = UInt32(vertices.count) - vertices += corners - // Outward winding: ceiling on the bottom, walkable top, and blocking walls. - indices += [0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, - 0, 1, 5, 0, 5, 4, 3, 7, 6, 3, 6, 2, - 0, 4, 7, 0, 7, 3, 1, 2, 6, 1, 6, 5].map { offset + $0 } - } - let baked = try await NavigationMeshBaker.bake(vertices: vertices, indices: indices) - let directory = FileManager.default.temporaryDirectory.appendingPathComponent("AdaEngine-NavigationDemo", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let url = directory.appendingPathComponent("playground.navmesh") - try baked.encoded().write(to: url, options: .atomic) - // Exercise the production file decoder before installing the map. - return try NavigationMeshData(encoded: Data(contentsOf: url)) - } - - @discardableResult - static func installPhysics(in world: World) -> Entity { - func box(_ name: String, center: Vector3, size: Vector3) { - world.spawn(name) { - Transform(position: center) - PhysicsBody3DComponent(shapes: [.generateBox(width: size.x, height: size.y, depth: size.z)], mode: .static) - } - } - box("Floor", center: [0, -0.3, 0], size: [20, 0.6, 14]) - for (index, obstacle) in obstacles.enumerated() { box("Obstacle \(index)", center: obstacle.center, size: obstacle.size) } - return world.spawn("Navigation robot") { - Transform(position: start) - CharacterController3DComponent() - NavigationAgent3DComponent() - NavigationTarget3DComponent(destination: goals[0]) - } - } - - static func installGraphics(in world: World, robot: Entity) { - let device = unsafe RenderEngine.shared.renderDevice - func material(_ color: Vector4) -> PBRMaterial { - let result = PBRMaterial() - result.baseColorFactor = color - result.metallicFactor = 0 - result.roughnessFactor = 0.75 - return result - } - let cube = Mesh.generateCube(renderDevice: device) - for entity in world.getEntities() where entity.components[PhysicsBody3DComponent.self] != nil { - let size = entity.name == "Floor" ? Vector3(20, 0.6, 14) - : obstacles[Int(entity.name.split(separator: " ").last ?? "0") ?? 0].size - var transform = entity.components[Transform.self] ?? Transform() - transform.scale = size - entity.components[Transform.self] = transform - entity.components[Mesh3DComponent.self] = Mesh3DComponent( - mesh: cube, materials: [material(entity.name == "Floor" ? [0.18, 0.24, 0.31, 1] : [0.55, 0.48, 0.8, 1])] - ) - } - let sphere = Mesh.generateSphere(radius: 1, segments: 24, rings: 16, renderDevice: device) - func part(_ name: String, at position: Vector3, scale: Vector3, color: Vector4) { - let entity = world.spawn(name) { - Transform(scale: scale, position: position) - Mesh3DComponent(mesh: sphere, materials: [material(color)]) - } - robot.addChild(entity) - } - part("Body", at: [0, 0.7, 0], scale: [0.36, 0.55, 0.32], color: [0.05, 0.8, 0.85, 1]) - part("Head", at: [0, 1.5, 0], scale: [0.32, 0.32, 0.32], color: [1, 0.8, 0.2, 1]) - for x: Float in [-0.12, 0.12] { - part("Eye", at: [x, 1.55, -0.28], scale: [0.055, 0.075, 0.045], color: [0.03, 0.05, 0.1, 1]) - } - var camera = Camera() - camera.backgroundColor = Color(0.035, 0.055, 0.09, 1) - let eye = Vector3(12, 16, -18) - let forward = (-eye).normalized - let right = Vector3.up.cross(forward).normalized - let up = forward.cross(right) - let cameraMatrix = Transform3D(columns: [Vector4(right, 0), Vector4(up, 0), Vector4(forward, 0), Vector4(eye, 1)]) - world.spawn("Playground camera", bundle: Camera3D( - camera: camera, - perspectiveProjection: PerspectiveProjection(fieldOfView: .degrees(45)), - transform: Transform(matrix: cameraMatrix) - )) - world.spawn("Sun") { - Transform(rotation: Quat(axis: .right, angle: .pi / 3)) - DirectionalLightComponent(intensity: 3, castShadows: true) - } - } -} diff --git a/Demos/Navigation/NavigationDemoMain.swift b/Demos/Navigation/NavigationDemoMain.swift deleted file mode 100644 index e8d1fccf3..000000000 --- a/Demos/Navigation/NavigationDemoMain.swift +++ /dev/null @@ -1,148 +0,0 @@ -@_spi(Internal) import AdaApp -import AdaEngine -import Foundation - -@main -@MainActor -struct NavigationDemoMain { - static func main() async throws { - if CommandLine.arguments.contains("--validate-script") { - try await validateScript() - } else if CommandLine.arguments.contains("--validate") { - try await validate() - } else if let index = CommandLine.arguments.firstIndex(of: "--capture"), CommandLine.arguments.indices.contains(index + 1) { - try await capture(to: CommandLine.arguments[index + 1]) - } else { - try await AppRuntime.run(NavigationDemoApp()) - } - } - - private static func validateScript() async throws { - let app = AppWorlds(main: World()) - app.addPlugin(MainSchedulerPlugin()).addPlugin(Physics3DPlugin()).addPlugin(Navigation3DPlugin()) - try await app.build() - let script = try NavigationDemoScript.install(in: app) - let robot = NavigationDemoFixture.installPhysics(in: app.main) - robot.components[NavigationDemoPatrolComponent.self] = NavigationDemoPatrolComponent() - robot.components[NavigationTarget3DComponent.self] = NavigationTarget3DComponent() - app.insertResource(NavigationMap3D(mesh: try await NavigationMesh(data: NavigationDemoFixture.bakedAsset()))) - app.main.insertResource(FixedTime(deltaTime: 1.0 / 60)) - await app.main.runScheduler(.physicsSync) - for _ in 0..<4800 { - await app.main.runScheduler(.update) - await app.main.runScheduler(.physicsStep) - await app.main.runScheduler(.physicsWriteback) - if robot.components[NavigationDemoPatrolComponent.self]?.completedGoals == 4 { break } - } - guard robot.components[NavigationDemoPatrolComponent.self]?.completedGoals == 4, script.diagnostics.isEmpty else { - throw DemoFailure.didNotArrive - } - print("PASS: NavigationRobot.ada completed four goals through the navigation and physics systems") - } - - /// Runs the demo's actual bake/file load/target requests/physical movement without a GPU. - private static func validate() async throws { - let app = AppWorlds(main: World()) - app.addPlugin(MainSchedulerPlugin()).addPlugin(Physics3DPlugin()).addPlugin(Navigation3DPlugin()) - try await app.build() - let robot = NavigationDemoFixture.installPhysics(in: app.main) - let mesh = try await NavigationMesh(data: NavigationDemoFixture.bakedAsset()) - app.insertResource(NavigationMap3D(mesh: mesh)) - app.main.insertResource(FixedTime(deltaTime: 1.0 / 60)) - await app.main.runScheduler(.physicsSync) - for goal in NavigationDemoFixture.goals { - robot.components[NavigationTarget3DComponent.self] = NavigationTarget3DComponent(destination: goal) - for _ in 0..<1200 { - await app.main.runScheduler(.update) - await app.main.runScheduler(.physicsStep) - await app.main.runScheduler(.physicsWriteback) - if robot.components[NavigationAgent3DComponent.self]?.follower.state == .arrived { break } - } - guard robot.components[NavigationAgent3DComponent.self]?.follower.state == .arrived else { - throw DemoFailure.didNotArrive - } - print("PASS: reached \(goal)") - } - print("PASS: saved/loaded \(mesh.data.polygonCount) polygons; four goals reached through the physics controller") - } - - private static func capture(to path: String) async throws { - weak var capturedApp: AppWorlds? - let runtime = OffscreenSceneRuntime(label: "NavigationDemo", size: [1100, 760], useSceneCameras: true) { app in - configureNavigationDemo(&app, session: NavigationDemoSession()) - capturedApp = app - } - try await runtime.start(timeout: .seconds(30)) - var completed = false - defer { - if !completed, let app = capturedApp { - let status = app.main.getResource(NavigationDemoSession.self)?.status ?? "missing" - report("Capture diagnostics: frame=\(runtime.frameSource.frameSequence), cameras=\(runtime.frameSource.activeCameraNames)") - report("Entities=\(app.main.getEntities().count), status=\(status)") - if let render = app.getSubworldBuilder(by: .renderWorld) { - report("Render entities: \(render.main.getEntities().map(\.name))") - if let records = render.getResource(RenderGraphDiagnostics.self)?.recentFrames(limit: 1), - let data = try? JSONEncoder().encode(records) { report(String(decoding: data, as: UTF8.self)) } - } - } - runtime.stop() - } - var frame = try await runtime.frameSource.captureFrame(after: 0, timeout: .seconds(30)) - try write(frame.image, to: path) - for _ in 0..<20 { - frame = try await runtime.frameSource.captureFrame(after: frame.sequence, timeout: .seconds(10)) - } - try write(frame.image, to: path) - guard let world = runtime.frameSource.world, - let cameraEntity = world.getEntities().first(where: { $0.components[Camera.self] != nil }), - let camera = cameraEntity.components[Camera.self], - let transform = cameraEntity.components[GlobalTransform.self], - let session = world.getResource(NavigationDemoSession.self), - let robotID = session.robot, let robot = world.getEntityByID(robotID) else { throw DemoFailure.sceneUnavailable } - let probe = Vector3(7.5, 0, 4.5) - let clip = camera.projection.makeClipView() * (transform.matrix.inverse * Vector4(probe, 1)) - guard clip.w.isFinite, clip.w > 0 else { throw DemoFailure.sceneUnavailable } - let projected = clip.xyz / clip.w - guard projected.x.isFinite, projected.y.isFinite, projected.z >= 0, projected.z <= 1 else { - throw DemoFailure.sceneUnavailable - } - let size = camera.logicalViewport.rect.size - let pointer = Vector2((projected.x + 1) * size.width / 2, (1 - projected.y) * size.height / 2) - let window = world.getResource(PrimaryWindowId.self)?.windowId ?? RID() - runtime.frameSource.movePointer(to: pointer) - runtime.frameSource.send(MouseEvent(window: window, button: .left, mousePosition: pointer, phase: .began, modifierKeys: [], time: 1)) - for _ in 0..<10 { - frame = try await runtime.frameSource.captureFrame(after: frame.sequence, timeout: .seconds(10)) - if let selected = robot.components[NavigationTarget3DComponent.self]?.destination, (selected - probe).length < 0.02 { break } - } - runtime.frameSource.send(MouseEvent(window: window, button: .left, mousePosition: pointer, phase: .ended, modifierKeys: [], time: 2)) - for _ in 0..<8 { - frame = try await runtime.frameSource.captureFrame(after: frame.sequence, timeout: .seconds(10)) - } - guard let selected = robot.components[NavigationTarget3DComponent.self]?.destination, - (selected - probe).length < 0.02 else { throw DemoFailure.pickingFailed } - print("PASS: viewport click selected \(selected)") - try write(frame.image, to: path) - completed = true - print("PASS: captured real GPU frame \(frame.sequence) to \(path)") - } - - private static func write(_ image: Image, to path: String) throws { - var bytes = Array(image.data) - if image.format == .bgra8 || image.format == .bgra8_sRGB { - for index in stride(from: 0, to: bytes.count, by: 4) { bytes.swapAt(index, index + 2) } - } - let png = try PNGWriter().encode(width: image.width, height: image.height, rgba: bytes) - try png.write(to: URL(fileURLWithPath: path), options: .atomic) - } - - private static func report(_ message: String) { - FileHandle.standardError.write(Data((message + "\n").utf8)) - } -} - -private enum DemoFailure: Error { - case didNotArrive - case sceneUnavailable - case pickingFailed -} diff --git a/Demos/Navigation/NavigationDemoScript.swift b/Demos/Navigation/NavigationDemoScript.swift deleted file mode 100644 index 45d947990..000000000 --- a/Demos/Navigation/NavigationDemoScript.swift +++ /dev/null @@ -1,24 +0,0 @@ -import AdaEngine -import Foundation - -@Component -struct NavigationDemoPatrolComponent { - var enabled: Bool = true - var completedGoals: Int = 0 -} - -@MainActor -enum NavigationDemoScript { - static func install(in app: AppWorlds) throws -> AdaScriptPlugin { - RuntimeTypeRegistry.registerComponent( - NavigationDemoPatrolComponent.self, names: ["NavigationDemoPatrolComponent"], - makeDefault: { NavigationDemoPatrolComponent() } - ) - guard let file = Bundle.module.url(forResource: "NavigationRobot", withExtension: "ada") else { - throw CocoaError(.fileNoSuchFile) - } - let script = try AdaScriptPlugin(source: String(contentsOf: file, encoding: .utf8), name: "NavigationRobot") - script.setup(in: app) - return script - } -} diff --git a/Demos/Navigation/NavigationRobot.ada b/Demos/Navigation/NavigationRobot.ada deleted file mode 100644 index 5e48891c4..000000000 --- a/Demos/Navigation/NavigationRobot.ada +++ /dev/null @@ -1,20 +0,0 @@ -// Navigation runs in the engine. This script chooses goals and reads the agent's state. -@system(id: "navigation.demo.patrol") -class RobotPatrol { - @query(NavigationAgent3DComponent, NavigationTarget3DComponent, NavigationDemoPatrolComponent) var robots; - var goals = [[8.0, 0.0, 4.0], [-8.0, 0.0, 5.0], [8.0, 0.0, -5.0], [0.0, 0.0, 5.0]]; - - func update(context) { - for (var row in robots) { - if (!row.navigationDemoPatrolComponent.enabled) { continue; } - if (row.navigationAgent3DComponent.hasArrived) { - row.navigationDemoPatrolComponent.completedGoals = row.navigationDemoPatrolComponent.completedGoals + 1; - row.navigationTarget3DComponent.destination = null; - } - if (row.navigationTarget3DComponent.destination == null) { - var next = row.navigationDemoPatrolComponent.completedGoals % 4; - row.navigationTarget3DComponent.destination = goals[next]; - } - } - } -} diff --git a/Demos/Physics2D/.codex/environments/environment.toml b/Demos/Physics2D/.codex/environments/environment.toml deleted file mode 100644 index 339cbc4fb..000000000 --- a/Demos/Physics2D/.codex/environments/environment.toml +++ /dev/null @@ -1,8 +0,0 @@ -version = 1 -name = "Box2D playground" -[setup] -script = "" -[[actions]] -name = "Run" -icon = "run" -command = "./script/build_and_run.sh" diff --git a/Demos/Physics2D/.gitignore b/Demos/Physics2D/.gitignore deleted file mode 100644 index 178135c2b..000000000 --- a/Demos/Physics2D/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/dist/ diff --git a/Demos/Physics2D/Physics2DAdvancedChecks.swift b/Demos/Physics2D/Physics2DAdvancedChecks.swift deleted file mode 100644 index 1a045f0f2..000000000 --- a/Demos/Physics2D/Physics2DAdvancedChecks.swift +++ /dev/null @@ -1,81 +0,0 @@ -import AdaEngine - -extension Physics2DDemoMain { - static func validateAdvanced(_ app: AppWorlds, _ session: PhysicsDemoSession) async throws { - let state = session.advanced - guard state.pointCount > 0, let air = state.air, let projectile = state.projectile else { throw Failure.advanced } - state.windMode = 1; state.dirty = true - await advance(app, steps: 30) - let speed = air.components[PhysicsBody2DComponent.self]?.linearVelocity.x ?? 0 - guard speed > 0.2 else { throw Failure.advanced } - state.windMode = 2; state.dirty = true - await advance(app, steps: 20) - guard (air.components[PhysicsBody2DComponent.self]?.linearVelocity.x ?? 0) < speed else { throw Failure.advanced } - state.locked = true; state.dirty = true - await advance(app, steps: 2) - guard abs(air.components[PhysicsBody2DComponent.self]?.linearVelocity.x ?? 1) < 0.01 else { throw Failure.advanced } - state.locked = false; state.damping = true; state.softSolver = true - state.reverseBelt = true; state.rolling = false; state.lowGrip = true; state.dirty = true - await advance(app, steps: 2) - guard app.main.physicsWorld2D?.solverSettings.restitutionIterations == 8, - state.belt?.components[Collision2DComponent.self]?.material.surfaceSpeed == -1.8, - state.circle?.components[PhysicsBody2DComponent.self]?.material.mixing.friction == .minimum else { throw Failure.advanced } - air.components[PhysicsBody2DComponent.self]?.setAwake(true) - state.sleepRequested = true - await advance(app, steps: 1) - guard air.components[PhysicsBody2DComponent.self]?.isAwake == false else { throw Failure.advanced } - state.sleepRequested = true; state.bullet = false; state.dirty = true; state.refireRequested = true - await advance(app, steps: 30) - guard (projectile.components[PhysicsBody2DComponent.self]?.worldCenter.x ?? -1) > 0 else { throw Failure.advanced } - state.bullet = true; state.dirty = true; state.refireRequested = true - await advance(app, steps: 30) - guard (projectile.components[PhysicsBody2DComponent.self]?.worldCenter.x ?? 1) < 0, state.hitPoints > 0 else { throw Failure.advanced } - report("PASS: advanced · wind / drag / locks / sleep / materials / CCD / contacts / solver") - } - - static func verifyAdvanced(to directory: String, tap: (String) throws -> Void) async throws { - try tap("physics-demo.advanced") - let source = try await waitForSource("Physics2D Advanced") - guard let session = source.world?.getResource(PhysicsDemoSession.self), let air = session.advanced.air else { throw Failure.advanced } - let state = session.advanced - try tap("physics-demo.advanced-wind") - _ = try await nextFrames(source, count: 30) - let speed = air.components[PhysicsBody2DComponent.self]?.linearVelocity.x ?? 0 - guard speed > 0.2 else { throw Failure.advanced } - try tap("physics-demo.advanced-wind") - _ = try await nextFrames(source, count: 20) - guard (air.components[PhysicsBody2DComponent.self]?.linearVelocity.x ?? 0) < speed else { throw Failure.advanced } - try tap("physics-demo.advanced-lock") - _ = try await nextFrames(source) - guard air.components[PhysicsBody2DComponent.self]?.motion.lockTranslationX == true, - abs(air.components[PhysicsBody2DComponent.self]?.linearVelocity.x ?? 1) < 0.01 else { throw Failure.advanced } - try tap("physics-demo.advanced-lock") - _ = try await nextFrames(source) - if air.components[PhysicsBody2DComponent.self]?.isAwake == false { try tap("physics-demo.advanced-sleep"); _ = try await nextFrames(source) } - try tap("physics-demo.advanced-sleep") - _ = try await nextFrames(source) - guard air.components[PhysicsBody2DComponent.self]?.isAwake == false else { throw Failure.advanced } - try tap("physics-demo.advanced-sleep") - try tap("physics-demo.advanced-damping") - try tap("physics-demo.advanced-belt") - try tap("physics-demo.advanced-rolling") - try tap("physics-demo.advanced-grip") - try tap("physics-demo.advanced-solver") - _ = try await nextFrames(source) - guard source.world?.physicsWorld2D?.solverSettings.contactHertz == 8, - air.components[PhysicsBody2DComponent.self]?.dynamics.linearDamping == 2, - state.circle?.components[PhysicsBody2DComponent.self]?.material.rollingResistance == 0, - state.belt?.components[Collision2DComponent.self]?.material.surfaceSpeed == -1.8 else { throw Failure.advanced } - try tap("physics-demo.advanced-bullet") - _ = try await nextFrames(source, count: 30) - guard (state.projectile?.components[PhysicsBody2DComponent.self]?.worldCenter.x ?? -1) > 0 else { throw Failure.advanced } - try tap("physics-demo.advanced-bullet") - let frame = try await nextFrames(source, count: 30) - guard (state.projectile?.components[PhysicsBody2DComponent.self]?.worldCenter.x ?? 1) < 0, - state.pointCount > 0, state.hitPoints > 0 else { throw Failure.advanced } - try write(frame.image, to: directory + "/advanced-physics.png") - try tap("physics-demo.advanced-reset") - _ = try await nextFrames(source) - report("PASS: native advanced · wind / drag / locks / sleep / materials / CCD / contact points / solver controls") - } -} diff --git a/Demos/Physics2D/Physics2DAdvancedDemo.swift b/Demos/Physics2D/Physics2DAdvancedDemo.swift deleted file mode 100644 index ba90de14e..000000000 --- a/Demos/Physics2D/Physics2DAdvancedDemo.swift +++ /dev/null @@ -1,202 +0,0 @@ -import AdaEngine -import Foundation -import Math -import Observation - -@MainActor -@Observable -final class PhysicsAdvancedState { - var windMode = 0 - var reverseBelt = false - var rolling = true - var lowGrip = false - var damping = false - var bullet = true - var locked = false - var softSolver = false - var dirty = true - var sleepRequested = false - var resetRequested = false - var refireRequested = false - var belt: Entity? - var box: Entity? - var circle: Entity? - var air: Entity? - var projectile: Entity? - var markers: [Entity] = [] - var defaults: PhysicsSolver2DSettings? - var pointCount = 0 - var hitPoints = 0 - var status = "Waiting for contacts" -} - -@MainActor -func setupAdvanced(in world: World, session: PhysicsDemoSession) throws { - let state = session.advanced - state.defaults = world.physicsWorld2D?.solverSettings - world.insertResource(PhysicsDebugOptions([.showPhysicsShapes])) - let beltMaterial = try PhysicsMaterial.generate2D(surfaceSpeed: 1.8) - state.belt = world.spawn("Conveyor") { - Collision2DComponent(shapes: [.generateBox(width: 17, height: 0.4)], material: beltMaterial) - Sprite(tintColor: Color(0.24, 0.35, 0.5, 1), size: [17, 0.4]) - Transform(position: [0, -3.3, 0]) - } - for x: Float in [-8.5, 8.5] { - world.spawn("Boundary") { Collision2DComponent(shapes: [.generateBox(width: 0.25, height: 8)]); Transform(position: [x, 0, 0]) } - } - state.box = world.spawn("Conveyor box") { - PhysicsBody2DComponent(shapes: [.generateBox(width: 0.8, height: 0.8)], mass: 1) - Sprite(tintColor: Color(1, 0.65, 0.25, 1), size: [0.8, 0.8]) - Transform(position: [-3, -2.7, 0]) - } - var circle = PhysicsBody2DComponent(shapes: [.generateCircle(radius: 1.2)], mass: 1) - circle.debugColor = Color(0.3, 1, 0.5, 1) - circle.linearVelocity = [2, 0]; circle.angularVelocity = -2 / 0.6 - state.circle = world.spawn("Rolling circle") { circle; Transform(position: [-6, -2.5, 0]) } - let capsule = try Shape2DResource.generateCapsule(from: [0, -0.35], to: [0, 0.35], radius: 0.25) - var air = PhysicsBody2DComponent(shapes: [capsule], mass: 2) - air.gravityScale = 0; air.debugColor = Color(0.2, 0.85, 1, 1) - state.air = world.spawn("Wind capsule") { air; PhysicsWind2DComponent(.init(drag: 0)); Transform(position: [0, 0.7, 0]) } - world.spawn("Thin kinematic CCD target") { - PhysicsBody2DComponent(shapes: [.generateBox(width: 0.04, height: 1.5)], mode: .kinematic) - Sprite(tintColor: .white, size: [0.04, 1.5]) - Transform(position: [0, 2.5, 0]) - } - var projectile = PhysicsBody2DComponent(shapes: [.generateBox(width: 0.2, height: 0.2)], mass: 1) - projectile.gravityScale = 0; projectile.fixedRotation = true - state.projectile = world.spawn("CCD box") { - projectile - Sprite(tintColor: Color(1, 0.3, 0.3, 1), size: [0.2, 0.2]) - Transform(position: [-6, 2.5, 0]) - } - state.refireRequested = true - for _ in 0..<4 { - state.markers.append(world.spawn("Contact point") { Sprite(tintColor: Color(1, 0.2, 0.55, 1), size: [0.1, 0.1]); Transform(position: [0, 50, 3]) }) - } -} - -@MainActor -func controlAdvanced(in world: World, session: PhysicsDemoSession) { - let state = session.advanced - do { - if state.dirty { - if let belt = state.belt, belt.components[Collision2DComponent.self]?.material.surfaceSpeed != (state.reverseBelt ? -1.8 : 1.8) { - belt.components[Collision2DComponent.self]?.material = try .generate2D(surfaceSpeed: state.reverseBelt ? -1.8 : 1.8) - } - var mixing = PhysicsMaterialMixing() - mixing.friction = state.lowGrip ? .minimum : .maximum; mixing.priority = 10 - let friction: Float = state.lowGrip ? 0.05 : 0.8 - let rolling: Float = state.rolling ? 0.2 : 0 - if let circle = state.circle, let current = circle.components[PhysicsBody2DComponent.self]?.material, - current.friction != friction || current.rollingResistance != rolling || current.mixing != mixing { - circle.components[PhysicsBody2DComponent.self]?.material = try .generate2D(friction: friction, rollingResistance: rolling, mixing: mixing) - } - var dynamics = PhysicsBody2DDynamics() - dynamics.linearDamping = state.damping ? 2 : 0; dynamics.angularDamping = state.damping ? 3 : 0 - try state.air?.components[PhysicsBody2DComponent.self]?.configureDynamics(dynamics) - var motion = PhysicsBody2DMotion() - motion.lockTranslationX = state.locked; motion.lockTranslationY = state.locked; motion.lockRotation = state.locked - try state.air?.components[PhysicsBody2DComponent.self]?.configureMotion(motion) - state.air?.components[PhysicsWind2DComponent.self]?.settings = .init(velocity: state.windMode == 1 ? [6, 0] : .zero, drag: state.windMode == 0 ? 0 : 1) - if state.windMode == 1 { state.air?.components[PhysicsBody2DComponent.self]?.setAwake(true) } - dynamics = .init(); dynamics.isBullet = state.bullet; dynamics.safetyFactor = 0.01 - try state.projectile?.components[PhysicsBody2DComponent.self]?.configureDynamics(dynamics) - if var solver = state.defaults { - if state.softSolver { - solver.contactHertz = 8; solver.contactDampingRatio = 1; solver.contactPushSpeed = 1 - solver.restitutionIterations = 8; solver.isRestitutionPropagationEnabled = true - } - try world.physicsWorld2D?.configureSolver(solver) - } - state.dirty = false - } - if state.resetRequested { - state.box?.components[PhysicsBody2DComponent.self]?.setPosition([-3, -2.7]) - state.box?.components[PhysicsBody2DComponent.self]?.clearForces() - state.circle?.components[PhysicsBody2DComponent.self]?.setPosition([-6, -2.5]) - state.circle?.components[PhysicsBody2DComponent.self]?.linearVelocity = [2, 0] - state.circle?.components[PhysicsBody2DComponent.self]?.angularVelocity = -2 / 0.6 - state.air?.components[PhysicsBody2DComponent.self]?.setPosition([0, 0.7]) - state.air?.components[PhysicsBody2DComponent.self]?.clearForces() - state.air?.components[PhysicsBody2DComponent.self]?.setAwake(true) - state.refireRequested = true; state.resetRequested = false - } - if state.refireRequested { - state.projectile?.components[PhysicsBody2DComponent.self]?.setPosition([-6, 2.5]) - state.projectile?.components[PhysicsBody2DComponent.self]?.linearVelocity = [50, 0] - state.refireRequested = false - } - if state.sleepRequested { - if let entity = state.air, let body = entity.components[PhysicsBody2DComponent.self] { entity.components[PhysicsBody2DComponent.self]?.setAwake(!body.isAwake) } - state.sleepRequested = false - } - } catch { state.status = "Control failed: \(error)" } -} - -@MainActor -func frameAdvanced(in world: World, session: PhysicsDemoSession) { - let state = session.advanced - guard let physics = world.physicsWorld2D, let box = state.box else { - return - } - let contacts = physics.contacts(for: box) - let points = contacts.flatMap { $0.manifold.points } - state.pointCount = points.count - let impulse = points.reduce(Float(0)) { $0 + $1.totalNormalImpulse } - let separation = points.first?.separation ?? 0 - let identifier = contacts.first.map { String(UInt(bitPattern: $0.id.hashValue), radix: 16).suffix(6) } ?? "none" - let velocity = state.air?.components[PhysicsBody2DComponent.self]?.linearVelocity.x ?? 0 - let awake = state.air?.components[PhysicsBody2DComponent.self]?.isAwake == true - state.status = String( - format: "Contact #%@ · %d points · separation %.3f · impulse %.3f · wind vx %.2f · %@ · hit points %d", - String(identifier), - points.count, - separation, - impulse, - velocity, - awake ? "awake" : "sleeping", - state.hitPoints - ) - for (index, marker) in state.markers.enumerated() { - marker.components[Transform.self]?.position = index < points.count ? Vector3(points[index].point, 3) : [0, 50, 3] - } -} - -struct PhysicsAdvancedDemoPane: View { - let session: PhysicsDemoSession - var body: some View { - let state = session.advanced - VStack(spacing: 8) { - HStack(spacing: 14) { - Button(["Wind: off", "Wind: airflow", "Wind: still-air drag"][state.windMode]) { state.windMode = (state.windMode + 1) % 3; state.dirty = true } - .accessibilityIdentifier("physics-demo.advanced-wind") - Button(state.reverseBelt ? "Belt: left" : "Belt: right") { state.reverseBelt.toggle(); state.dirty = true } - .accessibilityIdentifier("physics-demo.advanced-belt") - Button(state.rolling ? "Rolling resistance: on" : "Rolling resistance: off") { state.rolling.toggle(); state.dirty = true } - .accessibilityIdentifier("physics-demo.advanced-rolling") - Button(state.lowGrip ? "Mixing: minimum grip" : "Mixing: maximum grip") { state.lowGrip.toggle(); state.dirty = true } - .accessibilityIdentifier("physics-demo.advanced-grip") - } - HStack(spacing: 14) { - Button(state.damping ? "Damping: high" : "Damping: off") { state.damping.toggle(); state.dirty = true } - .accessibilityIdentifier("physics-demo.advanced-damping") - Button(state.bullet ? "CCD bullet: on" : "CCD bullet: off") { state.bullet.toggle(); state.dirty = true; state.refireRequested = true } - .accessibilityIdentifier("physics-demo.advanced-bullet") - Button(state.locked ? "Axes: locked" : "Axes: free") { state.locked.toggle(); state.dirty = true } - .accessibilityIdentifier("physics-demo.advanced-lock") - Button("Sleep / wake capsule") { state.sleepRequested = true } - .accessibilityIdentifier("physics-demo.advanced-sleep") - Button(state.softSolver ? "Solver: soft / 8 restitution" : "Solver: default") { state.softSolver.toggle(); state.dirty = true } - .accessibilityIdentifier("physics-demo.advanced-solver") - Button("Reset") { state.resetRequested = true } - .accessibilityIdentifier("physics-demo.advanced-reset") - } - Text("Green: rolling + grip mixing · Cyan: wind + damping · Red: fast box vs thin kinematic wall · Pink: contact points") - Text(state.status).font(.system(size: 12)) - SceneView(useSceneCameras: true, make: { configurePhysicsDemo(&$0, session: session) }, updateContent: { _, _ in }) - .frameCaptureLabel("Physics2D Advanced").id(PhysicsDemoScene.advanced) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} diff --git a/Demos/Physics2D/Physics2DCharactersDemo.swift b/Demos/Physics2D/Physics2DCharactersDemo.swift deleted file mode 100644 index ce8fff37d..000000000 --- a/Demos/Physics2D/Physics2DCharactersDemo.swift +++ /dev/null @@ -1,93 +0,0 @@ -import AdaEngine -import Math - -@MainActor -func setupCharacters(in world: World, session: PhysicsDemoSession) throws { - world.insertResource(PhysicsDebugOptions.showPhysicsShapes) - world.spawn("Floor") { - Sprite(tintColor: Color(0.25, 0.35, 0.5, 1), size: [18, 0.4]) - Collision2DComponent(shapes: [.generateBox(width: 18, height: 0.4)]) - Transform(position: [0, -2.2, 0]) - } - let ramp = try Shape2DResource.generateChain(points: [[7, -1.4], [5, -2], [3, -2]]) - world.spawn("Ramp") { Collision2DComponent(shapes: [ramp]); Transform() } - world.spawn("Right wall") { Collision2DComponent(shapes: [.generateBox(width: 0.2, height: 4)]); Transform(position: [7.7, -0.2, 0]) } - session.sensor = world.spawn("Sensor") { - Sprite(tintColor: Color(0.1, 0.6, 0.4, 0.35), size: [0.5, 2]) - Collision2DComponent(shapes: [.generateBox(width: 0.5, height: 2)], mode: .trigger) - Transform(position: [-4, -1, -1]) - } - var controller = CharacterController2DComponent() - controller.mode = session.characterMode - session.character = world.spawn("Character") { controller; Transform(position: [-5, 0, 1]) } - session.crate = world.spawn("Light crate") { - Sprite(tintColor: Color(1, 0.57, 0.2, 1), size: [0.8, 0.8]) - PhysicsBody2DComponent(shapes: [.generateBox(width: 0.8, height: 0.8)], massProperties: .init(mass: 0.2, inertia: [0, 0, 0.2])) - Transform(position: [-1, -1.6, 0]) - } - var platform = PhysicsBody2DComponent(shapes: [.generateBox(width: 3, height: 0.4)], mass: 1, mode: .kinematic) - platform.linearVelocity = [0.8, 0] - session.platform = world.spawn("Moving platform") { - Sprite(tintColor: Color(0.25, 0.6, 1, 1), size: [3, 0.4]) - platform - Transform(position: [2, -1.3, 0]) - } -} - -@MainActor -func controlCharacter(_ session: PhysicsDemoSession) { - guard let character = session.character else { - return - } - character.components[CharacterController2DComponent.self]?.mode = session.characterMode - character.components[CharacterController2DComponent.self]?.desiredVelocity = [2.5 * session.moveDirection, 0] - if session.jumpRequest { - session.jumpRequest = false - character.components[CharacterController2DComponent.self]?.jumpRequested = true - } - if session.resetRequested { - session.resetRequested = false; session.moveDirection = 0 - character.components[Transform.self]?.position = [-5, 0, 1] - character.components[CharacterController2DComponent.self]?.desiredVelocity = .zero - character.components[CharacterController2DComponent.self]?.resetMotion = true - session.crate?.components[PhysicsBody2DComponent.self]?.setPosition([-1, -1.6], angle: .radians(0)) - session.crate?.components[PhysicsBody2DComponent.self]?.linearVelocity = .zero - session.crate?.components[PhysicsBody2DComponent.self]?.angularVelocity = 0 - } - let x = session.platform?.components[Transform.self]?.position.x ?? 2 - if x > 3.2 { session.platformDirection = -1 } - if x < 1.2 { session.platformDirection = 1 } - session.platform?.components[PhysicsBody2DComponent.self]?.linearVelocity = [0.8 * session.platformDirection, 0] -} - -@MainActor -func updateCharacterStatus(_ session: PhysicsDemoSession) { - guard let controller = session.character?.components[CharacterController2DComponent.self] else { - return - } - let velocity = Vector2((controller.velocity.x * 10).rounded() / 10, (controller.velocity.y * 10).rounded() / 10) - session.rayStatus = "\(controller.mode.rawValue) · \(controller.isGrounded ? "grounded" : "airborne") · velocity \(velocity.x), \(velocity.y)" -} - -struct PhysicsCharactersDemoPane: View { - let session: PhysicsDemoSession - var body: some View { - VStack(spacing: 12) { - HStack(spacing: 14) { - Button("Left") { session.moveDirection = -1 }.accessibilityIdentifier("physics-demo.left") - Button("Stop") { session.moveDirection = 0 }.accessibilityIdentifier("physics-demo.stop") - Button("Right") { session.moveDirection = 1 }.accessibilityIdentifier("physics-demo.right") - Button("Jump") { session.jumpRequest = true }.accessibilityIdentifier("physics-demo.jump") - Button(session.characterMode == .query ? "Mode: Query" : "Mode: Dynamic") { session.characterMode = session.characterMode == .query ? .dynamic : .query } - .accessibilityIdentifier("physics-demo.character-mode") - Button("Reset") { session.resetRequested = true }.accessibilityIdentifier("physics-demo.character-reset") - } - Text("Move and jump onto the moving platform. Dynamic mode pushes the orange crate; query mode is virtual.") - Text(session.rayStatus) - SceneView(useSceneCameras: true, make: { configurePhysicsDemo(&$0, session: session) }, updateContent: { _, _ in }) - .frameCaptureLabel("Physics2D Characters").id(PhysicsDemoScene.characters) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} diff --git a/Demos/Physics2D/Physics2DDemoMain.swift b/Demos/Physics2D/Physics2DDemoMain.swift deleted file mode 100644 index d7497f53c..000000000 --- a/Demos/Physics2D/Physics2DDemoMain.swift +++ /dev/null @@ -1,364 +0,0 @@ -@_spi(Internal) import AdaApp -import AdaEngine -@_spi(Internal) import AdaPlatform -import Foundation - -@main -@MainActor -struct Physics2DDemoMain { - static func main() async throws { - if CommandLine.arguments.contains("--validate") { - try await validate() - return - } - if CommandLine.arguments.contains("--verify") { - Task { @MainActor in - do { - let path = argument(after: "--proof-directory") ?? "/tmp/ada-physics2d-proof" - try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) - try await verifyWindow(to: path) - report("Physics2DDemo verification PASS") - } catch { - report("Physics2DDemo verification FAIL: \(error)") - } - } - } - try await AppRuntime.run(Physics2DDemoApp()) - } - - private static func argument(after flag: String) -> String? { - guard let index = CommandLine.arguments.firstIndex(of: flag), CommandLine.arguments.indices.contains(index + 1) else { - return nil - } - return CommandLine.arguments[index + 1] - } - - private static func validate() async throws { - for scene in PhysicsDemoScene.allCases { - let session = PhysicsDemoSession(scene: scene, rendering: false) - session.scan = false - let app = AppWorlds(main: World()) - app.addPlugin(MainSchedulerPlugin()).addPlugin(TransformPlugin()) - .addPlugin(Physics2DPlugin(gravity: scene.gravity)) - .addPlugin(PhysicsDemoPlugin(session: session)) - try await app.build() - app.main.insertResource(FixedTime(deltaTime: 1.0 / 60)) - await app.main.runScheduler(.startup) - await advance(app, steps: 240) - if scene == .largeWorld { - try await validateLargeWorld(app, session) - } else if scene == .advanced { - try await validateAdvanced(app, session) - } else if scene == .mechanisms { - guard session.connectedJoints == 9 else { throw Failure.mechanisms } - session.reverseRequested = true - await advance(app, steps: 30) - guard (session.targets[4].components[PhysicsBody2DComponent.self]?.angularVelocity ?? 0) < -1 else { throw Failure.mechanisms } - session.breakRequested = true - await advance(app, steps: 5) - guard session.jointBreaks > 0, session.connectedJoints == 8 else { throw Failure.mechanisms } - session.resetRequested = true - await advance(app, steps: 5) - guard session.connectedJoints == 9 else { throw Failure.mechanisms } - report("PASS: mechanisms · nine joint kinds / live reverse / break / repair") - } else if scene == .characters { - guard session.character?.components[CharacterController2DComponent.self]?.isGrounded == true else { throw Failure.character } - session.characterMode = .dynamic - await advance(app, steps: 90) - session.moveDirection = 1 - await advance(app, steps: 150) - session.moveDirection = 0 - await advance(app, steps: 30) - guard (session.crate?.components[Transform.self]?.position.x ?? -1) > -0.8 else { throw Failure.character } - session.jumpRequest = true - await advance(app, steps: 1) - guard session.character?.components[CharacterController2DComponent.self]?.isGrounded == false else { throw Failure.character } - report("PASS: characters · query ground / dynamic push / jump") - } else if scene == .queries { - guard session.queryEntities.count == 4 else { throw Failure.queries } - session.querySensors = false - await app.main.runScheduler(.postUpdate) - guard session.queryEntities.count == 3 else { throw Failure.queries } - session.probeMode = .overlap - await app.main.runScheduler(.postUpdate) - guard session.queryEntities.count == 1 else { throw Failure.queries } - session.probeMode = .area - await app.main.runScheduler(.postUpdate) - guard session.queryEntities.count == 3 else { throw Failure.queries } - report("PASS: geometry/query scene · cast / sensor exclusion / exact overlap / area") - } else if scene == .raycast { - guard session.lastHits.count == 3, session.lastHits[0].distance < session.lastHits[1].distance else { throw Failure.raycast } - session.allHits = false - await app.main.runScheduler(.postUpdate) - guard session.lastHits.count == 1 else { throw Failure.raycast } - session.allHits = true - session.blueOnly = true - await app.main.runScheduler(.postUpdate) - guard session.lastHits.count == 2 else { throw Failure.raycast } - report("PASS: raycast scene · 3 ordered hits / nearest / 2 blue-layer hits") - } else { - try requireEvents(session) - report("PASS: event scene · contacts=\(session.contactBegins)/\(session.contactEnds), sensors=\(session.sensorBegins)/\(session.sensorEnds), impacts=\(session.impacts)") - } - } - } - - static func advance(_ app: AppWorlds, steps: Int) async { - for _ in 0.. SceneViewFrameSource { - for _ in 0..<100 { - if let source = SceneViewFrameRegistry.latest(label: label), source.frameSequence > 2, source !== previous { - return source - } - try await Task.sleep(for: .milliseconds(100)) - } - report("No ready source for \(label); labels: \(SceneViewFrameRegistry.sources.map(\.label))") - throw Failure.sceneUnavailable - } - - static func nextFrames(_ source: SceneViewFrameSource, count: Int = 5) async throws -> SceneViewCapturedFrame { - var frame = try await source.captureFrame(after: source.frameSequence, timeout: .seconds(10)) - for _ in 1.. 0 && events.contactBegins > 0 && events.contactEnds > 0 && events.impacts > 0 { break } - } - try requireEvents(events) - let eventFrame = try await nextFrames(eventSource) - try write(eventFrame.image, to: directory + "/events.png") - let initialImpacts = events.impacts - try tap("physics-demo.drop") - var observedDrop = false - for _ in 0..<20 { - _ = try await nextFrames(eventSource, count: 1) - if (events.falling?.components[Transform.self]?.position.y ?? 0) > 2 { - observedDrop = true - break - } - } - guard observedDrop else { throw Failure.events } - for _ in 0..<150 { - _ = try await nextFrames(eventSource, count: 1) - if events.impacts > initialImpacts { break } - } - guard events.impacts > initialImpacts else { throw Failure.events } - var verification: [String: Any] = [ - "passed": true, "backend": String(describing: unsafe RenderEngine.shared.type), "raycastAll": 3, "raycastNearest": 1, "raycastBlueMask": 2, - "contactBegins": events.contactBegins, "contactEnds": events.contactEnds, - "sensorBegins": events.sensorBegins, "sensorEnds": events.sensorEnds, "impacts": events.impacts, - "lastImpactSpeed": events.lastImpactSpeed, "sceneReentry": true, "dropButton": true, "rayFrame": rayFrame.sequence, "eventFrame": eventFrame.sequence, - ] - try tap("physics-demo.raycasts") - let rebuiltRay = try await waitForSource("Physics2D Raycasts", excluding: raySource) - _ = try await nextFrames(rebuiltRay) - guard let rebuilt = rebuiltRay.world?.getResource(PhysicsDemoSession.self), - rebuilt.lastHits.count == 3, rebuilt.markers.count == 3 else { throw Failure.raycast } - try tap("physics-demo.events") - _ = try await waitForSource("Physics2D Events", excluding: eventSource) - report("PASS: native events · sensor entry/exit, contact begin/end, impact, re-drop, scene re-entry") - try tap("physics-demo.queries") - let queriesSource = try await waitForSource("Physics2D Geometry & queries") - guard let queries = queriesSource.world?.getResource(PhysicsDemoSession.self) else { throw Failure.sceneUnavailable } - try tap("physics-demo.query-scan") - _ = try await nextFrames(queriesSource) - guard queries.queryEntities.count == 4 else { throw Failure.queries } - try tap("physics-demo.query-sensors") - _ = try await nextFrames(queriesSource) - guard queries.queryEntities.count == 3 else { throw Failure.queries } - try tap("physics-demo.query-mask") - _ = try await nextFrames(queriesSource) - guard queries.queryEntities.count == 2 else { throw Failure.queries } - try tap("physics-demo.query-order") - _ = try await nextFrames(queriesSource) - guard queries.queryEntities.count == 1 else { throw Failure.queries } - try tap("physics-demo.query-order") - try tap("physics-demo.query-mask") - try tap("physics-demo.query-sensors") - let geometryFrame = try await nextFrames(queriesSource) - report("Geometry debug \(queries.debugDrawState), options=\(queriesSource.world?.getResource(PhysicsDebugOptions.self)?.rawValue ?? 0)") - try write(geometryFrame.image, to: directory + "/geometry-cast.png") - try tap("physics-demo.query-kind") - _ = try await nextFrames(queriesSource) - guard queries.queryEntities.count == 1 else { throw Failure.queries } - try tap("physics-demo.query-kind") - let areaFrame = try await nextFrames(queriesSource) - guard queries.queryEntities.count == 4 else { throw Failure.queries } - try write(areaFrame.image, to: directory + "/geometry-area.png") - try tap("physics-demo.query-kind") - try tap("physics-demo.raycasts") - _ = try await waitForSource("Physics2D Raycasts", excluding: rebuiltRay) - try tap("physics-demo.queries") - let rebuiltQueries = try await waitForSource("Physics2D Geometry & queries", excluding: queriesSource) - _ = try await nextFrames(rebuiltQueries) - guard queries.queryEntities.count == 4, queries.targets.count == 8 else { throw Failure.queries } - verification["querySceneReentry"] = true - verification["geometryQueries"] = true - report("PASS: native geometry · convex cast / overlap / area / masks / sensors / nearest") - try tap("physics-demo.mechanisms") - let mechanismSource = try await waitForSource("Physics2D Mechanisms") - guard let mechanisms = mechanismSource.world?.getResource(PhysicsDemoSession.self), mechanisms.connectedJoints == 9 else { throw Failure.mechanisms } - try tap("physics-demo.reverse") - _ = try await nextFrames(mechanismSource, count: 30) - guard mechanisms.driveDirection < 0, (mechanisms.targets[4].components[PhysicsBody2DComponent.self]?.angularVelocity ?? 0) < -1 else { throw Failure.mechanisms } - let mechanismFrame = try await nextFrames(mechanismSource) - try write(mechanismFrame.image, to: directory + "/mechanisms.png") - try tap("physics-demo.break") - for _ in 0..<30 { - _ = try await nextFrames(mechanismSource, count: 1) - if mechanisms.jointBreaks > 0 { break } - } - guard mechanisms.jointBreaks > 0, mechanisms.connectedJoints == 8 else { throw Failure.mechanisms } - try tap("physics-demo.repair") - _ = try await nextFrames(mechanismSource) - guard mechanisms.connectedJoints == 9 else { throw Failure.mechanisms } - verification["mechanisms"] = true - verification["jointBreaks"] = mechanisms.jointBreaks - report("PASS: native mechanisms · nine constraints / reverse / break / repair") - try tap("physics-demo.characters") - let characterSource = try await waitForSource("Physics2D Characters") - guard let characters = characterSource.world?.getResource(PhysicsDemoSession.self) else { throw Failure.character } - try await waitForGround(characterSource, characters) - guard !hasCharacterCollider(characterSource, characters) else { throw Failure.character } - let startX = characters.character?.components[Transform.self]?.position.x ?? -5 - try tap("physics-demo.right") - _ = try await nextFrames(characterSource, count: 25) - guard (characters.character?.components[Transform.self]?.position.x ?? -5) > startX + 0.2 else { throw Failure.character } - try tap("physics-demo.stop") - _ = try await nextFrames(characterSource, count: 15) - try tap("physics-demo.jump") - try await waitForJump(characterSource, characters) - let queryFrame = try await nextFrames(characterSource, count: 10) - try write(queryFrame.image, to: directory + "/character-query.png") - try await waitForGround(characterSource, characters) - try tap("physics-demo.character-mode") - _ = try await nextFrames(characterSource) - guard characters.characterMode == .dynamic, hasCharacterCollider(characterSource, characters) else { throw Failure.character } - try tap("physics-demo.right") - _ = try await nextFrames(characterSource, count: 120) - try tap("physics-demo.stop") - _ = try await nextFrames(characterSource, count: 15) - guard (characters.crate?.components[Transform.self]?.position.x ?? -1) > -0.8 else { throw Failure.character } - let dynamicFrame = try await nextFrames(characterSource) - try write(dynamicFrame.image, to: directory + "/character-dynamic.png") - try tap("physics-demo.jump") - try await waitForJump(characterSource, characters) - try await waitForGround(characterSource, characters) - try tap("physics-demo.character-mode") - _ = try await nextFrames(characterSource) - guard !hasCharacterCollider(characterSource, characters) else { throw Failure.character } - let oldPlayer = characters.character - try tap("physics-demo.mechanisms") - _ = try await waitForSource("Physics2D Mechanisms", excluding: mechanismSource) - try tap("physics-demo.characters") - let rebuiltCharacter = try await waitForSource("Physics2D Characters", excluding: characterSource) - try await waitForGround(rebuiltCharacter, characters) - guard characters.character !== oldPlayer else { throw Failure.character } - verification["characterModes"] = ["query", "dynamic"] - verification["dynamicPush"] = true - verification["characterJumpBothModes"] = true - verification["characterColliderCleanup"] = true - verification["characterSceneReentry"] = true - report("PASS: native characters · move / stop / both jumps / physical push / mode cleanup / re-entry") - try await verifyAdvanced(to: directory, tap: tap) - verification["advancedPhysics"] = true - try await verifyLargeWorld(to: directory, tap: tap) - verification["largeWorldPrecision"] = true - verification["largeWorldMillimeterNudge"] = true - verification["largeWorldOriginShift"] = true - verification["largeWorldCharacters"] = true - verification["largeWorldSceneReentry"] = true - try JSONSerialization.data(withJSONObject: verification, options: [.prettyPrinted, .sortedKeys]) - .write(to: URL(fileURLWithPath: directory + "/verification.json"), options: .atomic) - } - - static func waitForGround(_ source: SceneViewFrameSource, _ session: PhysicsDemoSession) async throws { - for _ in 0..<150 { - if session.character?.components[CharacterController2DComponent.self]?.isGrounded == true { - return - } - _ = try await nextFrames(source, count: 1) - } - throw Failure.character - } - - static func waitForJump(_ source: SceneViewFrameSource, _ session: PhysicsDemoSession) async throws { - for _ in 0..<30 { - _ = try await nextFrames(source, count: 1) - if session.character?.components[CharacterController2DComponent.self]?.isGrounded == false { - return - } - } - throw Failure.character - } - - static func hasCharacterCollider(_ source: SceneViewFrameSource, _ session: PhysicsDemoSession) -> Bool { - guard let player = session.character, let bottom = player.components[Transform.self]?.position.xy, let physics = source.world?.physicsWorld2D else { - return false - } - return physics.overlap(at: bottom + [0, 0.9]).contains { $0.entity === player } - } - - private static func requireEvents(_ session: PhysicsDemoSession) throws { - guard session.contactBegins > 0, session.contactEnds > 0, session.sensorBegins > 0, - session.sensorEnds > 0, session.impacts > 0, session.lastImpactSpeed > 0 else { throw Failure.events } - } - - static func write(_ image: Image, to path: String) throws { - guard image.width > 0, image.height > 0, !image.data.isEmpty else { throw Failure.emptyFrame } - var bytes = Array(image.data) - if image.format == .bgra8 || image.format == .bgra8_sRGB { - for index in stride(from: 0, to: bytes.count, by: 4) { bytes.swapAt(index, index + 2) } - } - var colors = Set() - for offset in stride(from: 0, to: bytes.count - 3, by: 64) { - colors.insert(UInt32(bytes[offset]) << 16 | UInt32(bytes[offset + 1]) << 8 | UInt32(bytes[offset + 2])) - } - let png = try PNGWriter().encode(width: image.width, height: image.height, rgba: bytes) - try png.write(to: URL(fileURLWithPath: path), options: .atomic) - guard image.width > 200, image.height > 200, colors.count > 3 else { report("Frame \(path) contains \(colors.count) sampled colors"); throw Failure.emptyFrame } - } - - static func report(_ message: String) { - FileHandle.standardOutput.write(Data((message + "\n").utf8)) - } - - enum Failure: Error { - case raycast, events, queries, mechanisms, character, advanced, largeWorld, sceneUnavailable, emptyFrame - } -} diff --git a/Demos/Physics2D/Physics2DDemoScenes.swift b/Demos/Physics2D/Physics2DDemoScenes.swift deleted file mode 100644 index f902f0dca..000000000 --- a/Demos/Physics2D/Physics2DDemoScenes.swift +++ /dev/null @@ -1,302 +0,0 @@ -import AdaEngine -import Math -import Observation - -/// The same scenes run in the interactive window, offscreen capture and deterministic validation. -enum PhysicsDemoScene: String, CaseIterable { - case raycast, events, queries, mechanisms, characters, advanced, largeWorld - var gravity: Vector2 { self == .raycast || self == .queries ? .zero : [0, -9.81] } -} - -@MainActor -@Observable -final class PhysicsDemoSession: Resource { - let scene: PhysicsDemoScene - let rendering: Bool - var largeWorld = PhysicsLargeWorldState() - var advanced = PhysicsAdvancedState() - var mechanismOwners: [Entity] = [] - var mechanismStarts: [Vector2] = [] - var reverseRequested = false - var breakRequested = false - var driveDirection: Float = 1 - var moverDirection: Float = 1 - var connectedJoints = 0 - var jointBreaks = 0 - var character: Entity? - var crate: Entity? - var platform: Entity? - var characterMode = CharacterController2DMode.query - var moveDirection: Float = 0 - var jumpRequest = false - var platformDirection: Float = 1 - var debugDrawState = "extract pending" - var probeMode = PhysicsProbeMode.cast - var querySensors = true - var queryShape: Shape2DResource? - var queryEntities: [Entity] = [] - var queryPoints: [Vector2] = [] - var probeY: Float = 1.6 - var probeContact: Vector2? - var allHits = true - var blueOnly = false - var scan = true - var elapsed: Float = 0 - var resetRequested = false - var rayStatus = "Waiting for physics sync" - var contactBegins = 0 - var contactEnds = 0 - var sensorBegins = 0 - var sensorEnds = 0 - var impacts = 0 - var lastImpactSpeed: Float = 0 - var sensorOccupied = false - var targets: [Entity] = [] - var markers: [Entity] = [] - var beam: Entity? - var sensor: Entity? - var visitor: Entity? - var falling: Entity? - var lastHits: [Raycast2DHit] = [] - - func resetScene() { - largeWorld = PhysicsLargeWorldState() - advanced = PhysicsAdvancedState() - elapsed = 0 - mechanismOwners.removeAll(); mechanismStarts.removeAll() - reverseRequested = false; breakRequested = false; driveDirection = 1; moverDirection = 1 - connectedJoints = 0; jointBreaks = 0 - character = nil; crate = nil; platform = nil; moveDirection = 0; jumpRequest = false; platformDirection = 1 - resetRequested = false - rayStatus = "Waiting for physics sync" - contactBegins = 0 - contactEnds = 0 - sensorBegins = 0 - sensorEnds = 0 - impacts = 0 - lastImpactSpeed = 0 - sensorOccupied = false - targets.removeAll() - markers.removeAll() - beam = nil - sensor = nil - visitor = nil - falling = nil - lastHits.removeAll() - queryEntities.removeAll(); queryPoints.removeAll(); queryShape = nil; probeContact = nil - } - - init(scene: PhysicsDemoScene, rendering: Bool = true) { - self.scene = scene - self.rendering = rendering - } -} - -@MainActor -func configurePhysicsDemo(_ app: inout AppWorlds, session: PhysicsDemoSession) { - app.addPlugin(TransformPlugin()).addPlugin(InputPlugin()).addPlugin(RenderWorldPlugin()) - .addPlugin(CameraPlugin()).addPlugin(AssetsPlugin()).addPlugin(VisibilityPlugin()) - .addPlugin(SpritePlugin()).addPlugin(Core2DPlugin()).addPlugin(UpscalePlugin()) - .addPlugin(Physics2DPlugin(gravity: session.scene.gravity)) - .addPlugin(PhysicsDemoPlugin(session: session)) -} - -struct PhysicsDemoPlugin: Plugin { - let session: PhysicsDemoSession - - func setup(in app: AppWorlds) { - app.insertResource(session) - .addSystem(PhysicsDemoStartupSystem.self, on: .startup) - .addSystem(PhysicsDemoControlsSystem.self, on: .fixedPreUpdate) - .addSystem(PhysicsDemoFrameSystem.self, on: .postUpdate) - app.getSubworldBuilder(by: .renderWorld)?.addSystem(PhysicsDemoProbeExtractSystem.self, on: .extract) - } -} - -@System -@MainActor -func PhysicsDemoStartup(_ context: WorldUpdateContext, _ state: Res) { - let session = state.wrappedValue - session.resetScene() - let world = context.world - if session.rendering { - var camera = Camera() - camera.backgroundColor = Color(0.035, 0.055, 0.10, 1) - let entity = world.spawn("Physics camera", bundle: Camera2D(camera: camera, orthographicProjection: OrthographicProjection(scale: 48))) - if session.scene == .largeWorld { entity.components[PhysicsPosition2DComponent.self] = .init(session.largeWorld.base) } - } - if session.scene == .largeWorld { - do { try setupLargeWorld(in: world, session: session) } catch { session.rayStatus = "Large-world setup failed: \(error)" } - return - } - if session.scene == .advanced { - do { try setupAdvanced(in: world, session: session) } catch { session.rayStatus = "Advanced setup failed: \(error)" } - return - } - if session.scene == .mechanisms || session.scene == .characters { - do { - if session.scene == .mechanisms { try setupMechanisms(in: world, session: session) } else { try setupCharacters(in: world, session: session) } - } catch { session.rayStatus = "Setup failed: \(error)" } - return - } - if session.scene == .queries { - do { try setupGeometryQueries(in: world, session: session) } catch { session.rayStatus = "Geometry failed: \(error)" } - return - } - if session.scene == .raycast { - for (index, x) in [Float(-3), 0, 3].enumerated() { - let color: Color = index == 1 ? Color(1, 0.57, 0.20, 1) : Color(0.25, 0.55, 1, 1) - let entity = world.spawn("Target \(index + 1)") { - Sprite(tintColor: color, size: [0.8, 3]) - Collision2DComponent( - shapes: [.generateBox(width: 0.8, height: 3)], - filter: CollisionFilter(categoryBitMask: CollisionGroup(rawValue: index == 1 ? 2 : 1)) - ) - Transform(position: [x, 0, 0]) - } - session.targets.append(entity) - } - session.beam = world.spawn("Ray") { - Sprite(tintColor: Color(0.3, 1, 0.8, 1), size: [12, 0.035]) - Transform(position: [0, 0, 1]) - } - for _ in 0..<3 { - session.markers.append(world.spawn("Hit marker") { - Sprite(tintColor: .white, size: [0.16, 0.16]) - Transform(position: [0, 50, 2]) - }) - } - } else { - world.spawn("Floor") { - Sprite(tintColor: Color(0.3, 0.4, 0.55, 1), size: [14, 0.5]) - Collision2DComponent(shapes: [.generateBox(width: 14, height: 0.5)]) - Transform(position: [0, -2, 0]) - } - session.sensor = world.spawn("Sensor") { - Sprite(tintColor: Color(0.15, 0.6, 0.4, 0.45), size: [3, 3]) - Collision2DComponent(shapes: [.generateBox(width: 3, height: 3)], mode: .trigger) - Transform(position: [-2, 0.5, -1]) - } - var visitorBody = PhysicsBody2DComponent(shapes: [.generateBox(width: 0.5, height: 0.5)], mass: 1) - visitorBody.gravityScale = 0 - visitorBody.linearVelocity = [2.5, 0] - session.visitor = world.spawn("Sensor visitor") { - Sprite(tintColor: Color(0.3, 0.75, 1, 1), size: [0.5, 0.5]) - visitorBody - Transform(position: [-5, 0.5, 1]) - } - session.falling = world.spawn("Bouncing box") { - Sprite(tintColor: Color(1, 0.57, 0.2, 1), size: [0.8, 0.8]) - PhysicsBody2DComponent( - shapes: [.generateBox(width: 0.8, height: 0.8)], - mass: 1, - material: .generate(friction: 0.4, restitution: 0.65, density: 1) - ) - Transform(position: [3, 3, 1]) - } - } -} - -@System -@MainActor -func PhysicsDemoControls(_ context: WorldUpdateContext, _ state: Res, _ time: Res) { - let session = state.wrappedValue - session.elapsed += Float(time.deltaTime) - if session.scene == .largeWorld { - controlLargeWorld(in: context.world, session: session) - return - } - if session.scene == .advanced { - controlAdvanced(in: context.world, session: session) - return - } - if session.scene == .mechanisms { - controlMechanisms(session) - return - } - if session.scene == .characters { - controlCharacter(session) - return - } - guard session.scene == .events else { - return - } - if let visitor = session.visitor, (visitor.components[Transform.self]?.position.x ?? 0) > 1.5 { - visitor.components[PhysicsBody2DComponent.self]?.setPosition([-5, 0.5]) - visitor.components[PhysicsBody2DComponent.self]?.linearVelocity = [2.5, 0] - } - if session.resetRequested { - session.resetRequested = false - session.falling?.components[PhysicsBody2DComponent.self]?.setPosition([3, 3]) - session.falling?.components[PhysicsBody2DComponent.self]?.linearVelocity = [0, -0.1] - } -} - -@System -@MainActor -func PhysicsDemoFrame( - _ context: WorldUpdateContext, - _ state: Res, - _ begins: Events, - _ ends: Events, - _ hits: Events, - _ jointEvents: Events -) { - let session = state.wrappedValue - for event in begins { - if event.isSensor { session.sensorBegins += 1; session.sensorOccupied = true } else { session.contactBegins += 1 } - } - for event in ends { - if event.isSensor { session.sensorEnds += 1; session.sensorOccupied = false } else { session.contactEnds += 1 } - } - for _ in jointEvents { session.jointBreaks += 1 } - for event in hits { - session.impacts += 1; session.lastImpactSpeed = event.approachSpeed - if session.scene == .advanced { session.advanced.hitPoints = event.contact?.manifold.points.count ?? 0 } - } - session.sensor?.components[Sprite.self]?.tintColor = session.sensorOccupied ? Color(0.2, 1, 0.5, 0.8) : Color(0.15, 0.6, 0.4, 0.45) - if session.scene == .largeWorld { - frameLargeWorld(in: context.world, session: session) - return - } - if session.scene == .advanced { - frameAdvanced(in: context.world, session: session) - return - } - if session.scene == .mechanisms { - session.connectedJoints = session.mechanismOwners.filter { $0.components[PhysicsJoint2DComponent.self]?.isConnected == true }.count - session.rayStatus = "\(session.connectedJoints) / 9 connected · \(session.jointBreaks) break events · filter box passes through its block" - return - } - if session.scene == .characters { - updateCharacterStatus(session) - return - } - if session.scene == .queries { - updateGeometryQueries(in: context.world, session: session) - return - } - guard session.scene == .raycast, let physics = context.world.physicsWorld2D else { - return - } - let start = Vector2(-6, 0) - let end = Vector2(6, session.scan ? Math.sin(session.elapsed * 0.7) * 3 : 0) - let hits = physics.raycast( - from: start, - to: end, - query: session.allHits ? .all : .first, - mask: session.blueOnly ? .default : .all - ) - session.lastHits = hits - let description = hits.map { "\($0.entity.name): \((($0.distance * 100).rounded()) / 100) m" }.joined(separator: " · ") - session.rayStatus = hits.isEmpty ? "No hits" : description - let translation = end - start - session.beam?.components[Sprite.self]?.size = Size(width: translation.squaredLength.squareRoot(), height: 0.035) - session.beam?.components[Transform.self] = Transform( - rotation: Quat(axis: [0, 0, 1], angle: Math.atan2(translation.y, translation.x)), - position: Vector3((start + end) / 2, 1) - ) - for (index, marker) in session.markers.enumerated() { - marker.components[Transform.self]?.position = index < hits.count ? Vector3(hits[index].point, 2) : [0, 50, 2] - } -} diff --git a/Demos/Physics2D/Physics2DDemoView.swift b/Demos/Physics2D/Physics2DDemoView.swift deleted file mode 100644 index 849a59b73..000000000 --- a/Demos/Physics2D/Physics2DDemoView.swift +++ /dev/null @@ -1,70 +0,0 @@ -import AdaEngine -import Observation - -struct Physics2DDemoApp: App { - var body: some AppScene { - WindowGroup { Physics2DDemoView() } - .window(with: UIWindow.Configuration(title: "AdaEngine · Box2D playground", frame: Rect(x: 100, y: 100, width: 1000, height: 700))) - } -} - -private struct Physics2DDemoView: View { - @State private var scene = PhysicsDemoScene.raycast - @State private var raycast = PhysicsDemoSession(scene: .raycast) - @State private var events = PhysicsDemoSession(scene: .events) - @State private var queries = PhysicsDemoSession(scene: .queries) - @State private var mechanisms = PhysicsDemoSession(scene: .mechanisms) - @State private var characters = PhysicsDemoSession(scene: .characters) - @State private var advanced = PhysicsDemoSession(scene: .advanced) - @State private var largeWorld = PhysicsDemoSession(scene: .largeWorld) - - var body: some View { - VStack(spacing: 12) { - Text("Box2D playground").font(.system(size: 26)) - HStack(spacing: 20) { - Button("Raycasts") { scene = .raycast }.accessibilityIdentifier("physics-demo.raycasts") - Button("Collisions & sensors") { scene = .events }.accessibilityIdentifier("physics-demo.events") - Button("Geometry & queries") { scene = .queries }.accessibilityIdentifier("physics-demo.queries") - Button("Mechanisms") { scene = .mechanisms }.accessibilityIdentifier("physics-demo.mechanisms") - Button("Characters") { scene = .characters }.accessibilityIdentifier("physics-demo.characters") - Button("Advanced") { scene = .advanced }.accessibilityIdentifier("physics-demo.advanced") - Button("Large worlds") { scene = .largeWorld }.accessibilityIdentifier("physics-demo.large-world") - } - if scene == .raycast { - HStack(spacing: 18) { - Button(raycast.allHits ? "All hits" : "Nearest hit") { raycast.allHits.toggle() }.accessibilityIdentifier("physics-demo.mode") - Button(raycast.blueOnly ? "Blue targets only" : "All layers") { raycast.blueOnly.toggle() }.accessibilityIdentifier("physics-demo.mask") - Button(raycast.scan ? "Hold horizontal ray" : "Animate ray") { raycast.scan.toggle() }.accessibilityIdentifier("physics-demo.scan") - } - Text("White markers show hits in distance order. Toggle the orange layer or nearest hit.") - Text(raycast.rayStatus) - SceneView(useSceneCameras: true, make: { configurePhysicsDemo(&$0, session: raycast) }, updateContent: { _, _ in }) - .frameCaptureLabel("Physics2D Raycasts") - .id(PhysicsDemoScene.raycast) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if scene == .largeWorld { - PhysicsLargeWorldDemoPane(session: largeWorld) - } else if scene == .advanced { - PhysicsAdvancedDemoPane(session: advanced) - } else if scene == .mechanisms { - PhysicsMechanismsDemoPane(session: mechanisms) - } else if scene == .characters { - PhysicsCharactersDemoPane(session: characters) - } else if scene == .queries { - PhysicsQueriesDemoPane(session: queries) - } else { - Button("Drop box again") { events.resetRequested = true }.accessibilityIdentifier("physics-demo.drop") - Text("Blue box crosses the green sensor freely. Orange box bounces on the floor.") - Text("Contacts: \(events.contactBegins) begin / \(events.contactEnds) end · Impacts: \(events.impacts)") - Text("Sensor: \(events.sensorBegins) enter / \(events.sensorEnds) exit · \(events.sensorOccupied ? "occupied" : "empty")") - SceneView(useSceneCameras: true, make: { configurePhysicsDemo(&$0, session: events) }, updateContent: { _, _ in }) - .frameCaptureLabel("Physics2D Events") - .id(PhysicsDemoScene.events) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - .padding(18) - .background(Color(0.035, 0.055, 0.10, 1)) - .foregroundColor(.white) - } -} diff --git a/Demos/Physics2D/Physics2DLargeWorldChecks.swift b/Demos/Physics2D/Physics2DLargeWorldChecks.swift deleted file mode 100644 index b08bb88a6..000000000 --- a/Demos/Physics2D/Physics2DLargeWorldChecks.swift +++ /dev/null @@ -1,86 +0,0 @@ -import AdaEngine - -extension Physics2DDemoMain { - static func validateLargeWorld(_ app: AppWorlds, _ session: PhysicsDemoSession) async throws { - let state = session.largeWorld - guard let physics = app.main.physicsWorld2D, let player = session.character, let hit = state.hit, - state.pointCount > 0, state.hinge?.components[PhysicsJoint2DComponent.self]?.isConnected == true else { throw Failure.largeWorld } - let before = hit.x - state.nudgeRequested = true - await advance(app, steps: 2) - guard let nudged = state.hit, nudged.x - before > 0.0009, nudged.x - before < 0.0011 else { throw Failure.largeWorld } - let contact = state.contactID - state.shifted = true - await advance(app, steps: 2) - guard physics.coordinateOrigin == state.base.offset(by: [1000, -500]), state.hit == nudged, - state.contactID == contact, player.components[CharacterController2DComponent.self]?.isGrounded == true else { throw Failure.largeWorld } - let start = player.components[PhysicsPosition2DComponent.self]?.position.x ?? 0 - state.direction = 1 - await advance(app, steps: 30) - state.direction = 0 - await advance(app, steps: 15) - guard (player.components[PhysicsPosition2DComponent.self]?.position.x ?? 0) > start + 0.1 else { throw Failure.largeWorld } - state.jumpRequested = true - await advance(app, steps: 1) - guard player.components[CharacterController2DComponent.self]?.isGrounded == false else { throw Failure.largeWorld } - await advance(app, steps: 90) - state.mode = .dynamic - await advance(app, steps: 60) - guard player.components[CharacterController2DComponent.self]?.isGrounded == true else { throw Failure.largeWorld } - state.jumpRequested = true - await advance(app, steps: 1) - guard player.components[CharacterController2DComponent.self]?.isGrounded == false else { throw Failure.largeWorld } - report("PASS: large worlds · exact queries / 1 mm nudge / origin shift / stable contacts and hinge / both character modes") - } - - static func verifyLargeWorld(to directory: String, tap: (String) throws -> Void) async throws { - let oldAdvanced = SceneViewFrameRegistry.latest(label: "Physics2D Advanced") - try tap("physics-demo.large-world") - let source = try await waitForSource("Physics2D Large worlds") - guard let session = source.world?.getResource(PhysicsDemoSession.self), let physics = source.world?.physicsWorld2D, - let player = session.character else { throw Failure.largeWorld } - let state = session.largeWorld - try await waitForGround(source, session) - guard let hit = state.hit, state.pointCount > 0 else { throw Failure.largeWorld } - let contact = state.contactID - try tap("physics-demo.large-nudge") - _ = try await nextFrames(source) - guard let nudged = state.hit, nudged.x - hit.x > 0.0009, nudged.x - hit.x < 0.0011 else { throw Failure.largeWorld } - let before = try await nextFrames(source) - try write(before.image, to: directory + "/large-world-before.png") - try tap("physics-demo.large-origin") - let shifted = try await nextFrames(source) - guard physics.coordinateOrigin == state.base.offset(by: [1000, -500]), state.hit == nudged, - state.contactID == contact, state.hinge?.components[PhysicsJoint2DComponent.self]?.isConnected == true else { throw Failure.largeWorld } - try write(shifted.image, to: directory + "/large-world-shifted.png") - let start = player.components[PhysicsPosition2DComponent.self]?.position.x ?? 0 - try tap("physics-demo.large-right") - _ = try await nextFrames(source, count: 30) - try tap("physics-demo.large-stop") - _ = try await nextFrames(source, count: 15) - guard (player.components[PhysicsPosition2DComponent.self]?.position.x ?? 0) > start + 0.1 else { throw Failure.largeWorld } - try tap("physics-demo.large-jump") - try await waitForJump(source, session) - try await waitForGround(source, session) - try tap("physics-demo.large-mode") - _ = try await nextFrames(source) - try await waitForGround(source, session) - guard hasCharacterCollider(source, session) else { throw Failure.largeWorld } - let dynamic = try await nextFrames(source) - try write(dynamic.image, to: directory + "/large-world-dynamic.png") - try tap("physics-demo.large-jump") - try await waitForJump(source, session) - try await waitForGround(source, session) - try tap("physics-demo.large-mode") - _ = try await nextFrames(source) - guard !hasCharacterCollider(source, session) else { throw Failure.largeWorld } - try tap("physics-demo.advanced") - _ = try await waitForSource("Physics2D Advanced", excluding: oldAdvanced) - try tap("physics-demo.large-world") - let rebuilt = try await waitForSource("Physics2D Large worlds", excluding: source) - guard let fresh = rebuilt.world?.getResource(PhysicsDemoSession.self), fresh.character !== player, - let freshPhysics = rebuilt.world?.physicsWorld2D, freshPhysics.coordinateOrigin == state.base else { throw Failure.largeWorld } - if let contact { guard !freshPhysics.isValid(contact) else { throw Failure.largeWorld } } - report("PASS: native large worlds · exact query / millimeter nudge / camera origin shift / contacts / both character jumps / re-entry") - } -} diff --git a/Demos/Physics2D/Physics2DLargeWorldDemo.swift b/Demos/Physics2D/Physics2DLargeWorldDemo.swift deleted file mode 100644 index 1bc754149..000000000 --- a/Demos/Physics2D/Physics2DLargeWorldDemo.swift +++ /dev/null @@ -1,152 +0,0 @@ -import AdaEngine -import Foundation -import Math -import Observation - -@MainActor -@Observable -final class PhysicsLargeWorldState { - let base = PhysicsPosition2D(x: 1_000_000_000.125, y: -999_999_999.75) - var shifted = false - var nudgeRequested = false - var mode = CharacterController2DMode.query - var direction: Float = 0 - var jumpRequested = false - var target: Entity? - var crate: Entity? - var hinge: Entity? - var marker: Entity? - var hit: PhysicsPosition2D? - var distance: Float = 0 - var probeStart = Vector2.zero - var probeEnd = Vector2.zero - var hitLocal: Vector2? - var contactID: Contact2DID? - var pointCount = 0 - var status = "Waiting for precise queries" -} - -@MainActor -func setupLargeWorld(in world: World, session: PhysicsDemoSession) throws { - let state = session.largeWorld - guard let physics = world.physicsWorld2D, physics.setCoordinateOrigin(state.base) else { - return - } - world.insertResource(PhysicsDebugOptions([.showPhysicsShapes, .showJoints])) - world.spawn("Global floor") { - Collision2DComponent(shapes: [.generateBox(width: 16, height: 0.4)]) - Sprite(tintColor: Color(0.25, 0.35, 0.5, 1), size: [16, 0.4]) - Transform() - PhysicsPosition2DComponent(state.base.offset(by: [0, -2.5])) - } - for x: Float in [-8, 8] { - world.spawn("Global boundary") { - Collision2DComponent(shapes: [.generateBox(width: 0.2, height: 4)]) - Transform() - PhysicsPosition2DComponent(state.base.offset(by: [x, -0.3])) - } - } - state.target = world.spawn("Millimeter query target") { - Collision2DComponent(shapes: [.generateBox(width: 0.4, height: 1.2)], filter: CollisionFilter(categoryBitMask: CollisionGroup(rawValue: 8))) - Sprite(tintColor: Color(0.2, 0.65, 1, 1), size: [0.4, 1.2]) - Transform() - PhysicsPosition2DComponent(state.base.offset(by: [3, 0.8])) - } - state.crate = world.spawn("Global contact box") { - PhysicsBody2DComponent(shapes: [.generateBox(width: 0.8, height: 0.8)], massProperties: .init(mass: 1, inertia: [0, 0, 1])) - Sprite(tintColor: Color(1, 0.6, 0.2, 1), size: [0.8, 0.8]) - Transform() - PhysicsPosition2DComponent(state.base.offset(by: [-1, -1.9])) - } - var bob = PhysicsBody2DComponent(shapes: [.generateCircle(radius: 0.8)], massProperties: .init(mass: 1, inertia: [0, 0, 0.2])) - bob.debugColor = Color(0.5, 1, 0.65, 1); bob.angularVelocity = 1 - let pendulum = world.spawn("Global hinge bob") { bob; Transform(); PhysicsPosition2DComponent(state.base.offset(by: [-3, 0.3])) } - let descriptor = try PhysicsJoint2DDescriptor.revolute(entity: pendulum, globalAnchor: state.base.offset(by: [-3, 2])) - state.hinge = world.spawn("Exact global pivot") { PhysicsJoint2DComponent(joint: descriptor) } - let controller = CharacterController2DComponent(radius: 0.3, height: 1.5) - session.character = world.spawn("Global character") { controller; Transform(); PhysicsPosition2DComponent(state.base.offset(by: [1, 0])) } - state.marker = world.spawn("Precise hit marker") { - Sprite(tintColor: Color(1, 0.85, 0.2, 1), size: [0.12, 0.12]) - Transform(position: [0, 0, 2]) - PhysicsPosition2DComponent(state.base.offset(by: [3, 0.8])) - } -} - -@MainActor -func controlLargeWorld(in world: World, session: PhysicsDemoSession) { - let state = session.largeWorld - let origin = state.shifted ? state.base.offset(by: [1000, -500]) : state.base - if world.physicsWorld2D?.coordinateOrigin != origin { world.physicsWorld2D?.setCoordinateOrigin(origin) } - if state.nudgeRequested, let target = state.target, let position = target.components[PhysicsPosition2DComponent.self]?.position { - target.components[PhysicsPosition2DComponent.self]?.position = .init(x: position.x + 0.001, y: position.y) - state.nudgeRequested = false - } - if let player = session.character { - if player.components[CharacterController2DComponent.self]?.mode != state.mode { - player.components[CharacterController2DComponent.self]?.mode = state.mode - player.components[CharacterController2DComponent.self]?.resetMotion = true - } - player.components[CharacterController2DComponent.self]?.desiredVelocity = [state.direction * 2, 0] - if state.jumpRequested { player.components[CharacterController2DComponent.self]?.jumpRequested = true; state.jumpRequested = false } - } -} - -@MainActor -func frameLargeWorld(in world: World, session: PhysicsDemoSession) { - let state = session.largeWorld - guard let physics = world.physicsWorld2D, let target = state.target, - let targetPosition = target.components[PhysicsPosition2DComponent.self]?.position else { - return - } - let start = state.base.offset(by: [-6, 0.8]) - let hit = physics.raycast(from: start, translation: [12, 0], query: .first, mask: CollisionGroup(rawValue: 8)).first - state.hit = hit?.point; state.distance = hit?.distance ?? 0 - state.probeStart = start.displacement(from: physics.coordinateOrigin) - state.probeEnd = start.offset(by: [12, 0]).displacement(from: physics.coordinateOrigin) - state.hitLocal = hit?.point.displacement(from: physics.coordinateOrigin) - if let marker = state.marker { - let position = hit?.point ?? state.base.offset(by: [0, 50]) - marker.components[PhysicsPosition2DComponent.self]?.position = position - marker.components[Transform.self]?.position = Vector3(position.displacement(from: physics.coordinateOrigin), 2) - } - let contact = state.crate.flatMap { physics.contacts(for: $0).first } - state.contactID = contact?.id; state.pointCount = contact?.manifold.points.count ?? 0 - let delta = targetPosition.x - state.base.x - 3 - let grounded = session.character?.components[CharacterController2DComponent.self]?.isGrounded == true - state.status = String( - format: "Global hit x %.6f · travel %.3f · target delta %.6f · %d contact points · %@ %@", - hit?.point.x ?? 0, - state.distance, - delta, - state.pointCount, - state.mode.rawValue, - grounded ? "grounded" : "airborne" - ) -} - -struct PhysicsLargeWorldDemoPane: View { - let session: PhysicsDemoSession - var body: some View { - let state = session.largeWorld - VStack(spacing: 10) { - HStack(spacing: 14) { - Button("Left") { state.direction = -1 }.accessibilityIdentifier("physics-demo.large-left") - Button("Stop") { state.direction = 0 }.accessibilityIdentifier("physics-demo.large-stop") - Button("Right") { state.direction = 1 }.accessibilityIdentifier("physics-demo.large-right") - Button("Jump") { state.jumpRequested = true }.accessibilityIdentifier("physics-demo.large-jump") - Button(state.mode == .query ? "Mode: Query" : "Mode: Dynamic") { state.mode = state.mode == .query ? .dynamic : .query } - .accessibilityIdentifier("physics-demo.large-mode") - Button(state.shifted ? "Restore render origin" : "Shift render origin 1 km") { state.shifted.toggle() } - .accessibilityIdentifier("physics-demo.large-origin") - Button("Nudge target 1 mm") { state.nudgeRequested = true }.accessibilityIdentifier("physics-demo.large-nudge") - } - Text("Exact physics at 1,000,000,000 units · Camera and tagged roots share the render frame · Yellow probe targets the blue pillar") - .font(.system(size: 12)) - Text(state.status).font(.system(size: 12)) - SceneView(useSceneCameras: true, make: { configurePhysicsDemo(&$0, session: session) }, updateContent: { _, _ in }) - .frameCaptureLabel("Physics2D Large worlds").id(PhysicsDemoScene.largeWorld) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} diff --git a/Demos/Physics2D/Physics2DMechanismsDemo.swift b/Demos/Physics2D/Physics2DMechanismsDemo.swift deleted file mode 100644 index 122199d98..000000000 --- a/Demos/Physics2D/Physics2DMechanismsDemo.swift +++ /dev/null @@ -1,132 +0,0 @@ -import AdaEngine -import Math - -@MainActor -func demoHinge(_ direction: Float) -> RevoluteJoint2DSettings { - .init(frameB: .init(position: [-0.7, 1]), limits: .init(lower: -0.7, upper: 0.7), motor: .init(speed: direction, maximumEffort: 20)) -} -@MainActor -func demoSlider(_ direction: Float) -> PrismaticJoint2DSettings { - .init(spring: .init(hertz: 2), limits: .init(lower: -0.6, upper: 0.6), motor: .init(speed: direction, maximumEffort: 80)) -} -@MainActor -func demoWheel(_ direction: Float) -> WheelJoint2DSettings { - .init(frameA: .init(angle: .pi / 2), limits: .init(lower: -0.8, upper: 0.25), motor: .init(speed: 3 * direction, maximumEffort: 5)) -} -@MainActor -func demoMotor(_ direction: Float) -> MotorJoint2DSettings { - .init(frameA: .init(position: [0.7, 0]), angularVelocity: direction, maximumVelocityForce: 0, maximumVelocityTorque: 3, linearSpring: .init(hertz: 2)) -} - -@MainActor -func setupMechanisms(in world: World, session: PhysicsDemoSession) throws { - world.insertResource(PhysicsDebugOptions([.showPhysicsShapes, .showJoints])) - world.physicsWorld2D?.isSleepEnabled = false - let positions: [(Vector2, Vector2)] = [ - ([-6, 2.6], [-5, 1.6]), ([0, 2.6], [0.7, 1.6]), ([5, 2.2], [5, 2.2]), - ([-6, 0.1], [-5, 0.1]), ([0, 0.1], [0, -0.4]), ([5, 0.1], [5, 0.1]), - ([-6, -2.2], [-6.7, -2.2]), ([0, -2.2], [0, -2.2]), ([5, -2.6], [5, -1.8]) - ] - for (index, pair) in positions.enumerated() { - let anchorShape = index == 6 ? Shape2DResource.generateBox(width: 1.2, height: 0.8) : .generateCircle(radius: 0.18) - var anchorBody = PhysicsBody2DComponent(shapes: [anchorShape], mass: 1, mode: .static) - anchorBody.debugColor = Color(0.65, 0.7, 0.8, 1) - let a = world.spawn("Anchor \(index)") { anchorBody; Transform(position: Vector3(pair.0, 0)) } - let shape: Shape2DResource - if index == 4 { - shape = .generateCircle(radius: 0.8) - } else if index == 8 { - shape = try .generateCapsule(from: [0, -0.25], to: [0, 0.25], radius: 0.18) - } else { - shape = .generateBox(width: 0.45, height: 0.45) - } - var body = PhysicsBody2DComponent(shapes: [shape], massProperties: .init(mass: 1, inertia: [0, 0, 0.1])) - body.debugColor = index == 0 ? Color(1, 0.57, 0.2, 1) : Color(0.3, 0.7, 1, 1) - if index == 6 { body.gravityScale = 0; body.linearVelocity = [0.5, 0] } - if index == 3 { body.linearVelocity = [0, 1]; body.angularVelocity = 1 } - let b = world.spawn("Mechanism body \(index)") { body; Transform(position: Vector3(pair.1, 0)) } - let descriptor: PhysicsJoint2DDescriptor - switch index { - case 0: descriptor = try PhysicsJoint2DDescriptor.rope(entityA: a, entityB: b).withBreakSettings(.init(forceThreshold: 40)) - case 1: descriptor = try .revolute(entityA: a, entityB: b, settings: demoHinge(1)) - case 2: descriptor = try .prismatic(entityA: a, entityB: b, settings: demoSlider(1)) - case 3: - descriptor = try .weld( - entityA: a, - entityB: b, - settings: .init(frameA: .init(position: [1, 0]), linearSpring: .init(hertz: 2, dampingRatio: 0.3), angularSpring: .init(hertz: 2)) - ) - case 4: descriptor = try .wheel(entityA: a, entityB: b, settings: demoWheel(1)) - case 5: descriptor = try .motor(entityA: a, entityB: b, settings: demoMotor(1)) - case 6: descriptor = try .filter(entityA: a, entityB: b) - case 7: - descriptor = try .mover(entityA: a, entityB: b, settings: .init(linearVelocity: [1, 0], maximumVelocityForce: [20, 20], collideConnected: false)) - default: descriptor = try .pogo(entityA: a, entityB: b, settings: .init(restLength: 0.8, maximumCompressionForce: 100)) - } - session.mechanismOwners.append(world.spawn("Joint \(index)") { PhysicsJoint2DComponent(joint: descriptor) }) - session.targets.append(b) - session.mechanismStarts.append(pair.1) - } -} - -@MainActor -func controlMechanisms(_ session: PhysicsDemoSession) { - guard session.targets.count == 9, session.mechanismOwners.count == 9 else { - return - } - if session.resetRequested { - session.resetRequested = false - for (index, entity) in session.targets.enumerated() { - entity.components[PhysicsBody2DComponent.self]?.setPosition(session.mechanismStarts[index], angle: .radians(0)) - entity.components[PhysicsBody2DComponent.self]?.linearVelocity = index == 6 ? [0.5, 0] : .zero - entity.components[PhysicsBody2DComponent.self]?.angularVelocity = 0 - session.mechanismOwners[index].components[PhysicsJoint2DComponent.self]?.reconnect() - } - return - } - if session.breakRequested { - session.breakRequested = false - session.targets[0].components[PhysicsBody2DComponent.self]?.applyForceToCenter([0, -5000], wake: true) - } - do { - var retuneMover = false - if session.reverseRequested { - session.reverseRequested = false; session.driveDirection *= -1; session.moverDirection *= -1 - try session.mechanismOwners[1].components[PhysicsJoint2DComponent.self]?.updateSettings(demoHinge(session.driveDirection)) - try session.mechanismOwners[2].components[PhysicsJoint2DComponent.self]?.updateSettings(demoSlider(session.driveDirection)) - try session.mechanismOwners[4].components[PhysicsJoint2DComponent.self]?.updateSettings(demoWheel(session.driveDirection)) - try session.mechanismOwners[5].components[PhysicsJoint2DComponent.self]?.updateSettings(demoMotor(session.driveDirection)) - retuneMover = true - } - let x = session.targets[7].components[Transform.self]?.position.x ?? 0 - if (x > 0.8 && session.moverDirection > 0) || (x < -0.8 && session.moverDirection < 0) { session.moverDirection *= -1; retuneMover = true } - if retuneMover { - try session.mechanismOwners[7].components[PhysicsJoint2DComponent.self]?.updateSettings( - MoverJoint2DSettings(linearVelocity: [session.moverDirection, 0], maximumVelocityForce: [20, 20], collideConnected: false) - ) - } - } catch { session.rayStatus = "Tuning failed: \(error)" } - let filter = session.targets[6] - let x = filter.components[Transform.self]?.position.x ?? -6 - let velocity = filter.components[PhysicsBody2DComponent.self]?.linearVelocity.x ?? 0.5 - if (x > -5.2 && velocity > 0) || (x < -6.8 && velocity < 0) { filter.components[PhysicsBody2DComponent.self]?.linearVelocity = [-velocity, 0] } -} - -struct PhysicsMechanismsDemoPane: View { - let session: PhysicsDemoSession - var body: some View { - VStack(spacing: 12) { - HStack(spacing: 18) { - Button("Reverse motors") { session.reverseRequested = true }.accessibilityIdentifier("physics-demo.reverse") - Button("Break rope") { session.breakRequested = true }.accessibilityIdentifier("physics-demo.break") - Button("Repair all") { session.resetRequested = true }.accessibilityIdentifier("physics-demo.repair") - } - Text("Top: rope / hinge / slider · middle: weld / wheel / motor · bottom: filter / mover / pogo") - Text(session.rayStatus) - SceneView(useSceneCameras: true, make: { configurePhysicsDemo(&$0, session: session) }, updateContent: { _, _ in }) - .frameCaptureLabel("Physics2D Mechanisms").id(PhysicsDemoScene.mechanisms) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} diff --git a/Demos/Physics2D/Physics2DQueriesDemo.swift b/Demos/Physics2D/Physics2DQueriesDemo.swift deleted file mode 100644 index b277210d1..000000000 --- a/Demos/Physics2D/Physics2DQueriesDemo.swift +++ /dev/null @@ -1,135 +0,0 @@ -import AdaEngine -import Math - -@MainActor -enum PhysicsProbeMode: String, CaseIterable { - case cast = "Shape cast", overlap = "Capsule overlap", area = "Area candidates" - var next: Self { self == .cast ? .overlap : self == .overlap ? .area : .cast } -} - -@MainActor -func setupGeometryQueries(in world: World, session: PhysicsDemoSession) throws { - world.insertResource(PhysicsDebugOptions.showPhysicsShapes) - func add(_ name: String, _ shape: Shape2DResource, at position: Vector2 = .zero, layer: UInt64 = 1, sensor: Bool = false) { - var collision = Collision2DComponent(shapes: [shape], mode: sensor ? .trigger : .default, filter: .init(categoryBitMask: CollisionGroup(rawValue: layer))) - collision.debugColor = layer == 2 ? Color(1, 0.57, 0.2, 1) : sensor ? Color(0.2, 0.9, 0.5, 1) : Color(0.3, 0.65, 1, 1) - session.targets.append(world.spawn(name) { collision; Transform(position: Vector3(position, 0)) }) - } - add("Rounded box", try .generateRoundedBox(width: 1.8, height: 1.2, radius: 0.25), at: [-4, 1.6], layer: 2) - add("Capsule", try .generateCapsule(from: [0, -0.6], to: [0, 0.6], radius: 0.45), at: [0, 1.6]) - add("Sensor", .generateBox(width: 0.6, height: 2), at: [-2, 1.6], sensor: true) - add("Segment", try .generateSegment(from: [-0.8, -0.6], to: [0.8, 0.6]), at: [3.7, 1.6]) - add("Rounded triangle", try .generateRoundedPolygon(vertices: [[-0.6, -0.5], [0.6, -0.5], [0, 0.6]], radius: 0.15), at: [1.5, -0.6], layer: 2) - add("Open chain", try .generateChain(points: [[6, -1.6], [3, -2], [-1, -1.3], [-6, -2]])) - add("Loop", try .generateChain(points: [[-0.7, -0.5], [0.7, -0.5], [0.7, 0.5], [-0.7, 0.5]], isLoop: true), at: [5, -0.5]) - add("Loose chain segment", try .generateChainSegment(from: [1, 0], to: [-1, 0], ghostStart: [2, 0], ghostEnd: [-2, 0]), at: [-4, -0.6]) - session.queryShape = try .generateCapsule(from: [0, -0.25], to: [0, 0.25], radius: 0.2) -} - -@MainActor -func updateGeometryQueries(in world: World, session: PhysicsDemoSession) { - guard let physics = world.physicsWorld2D, let shape = session.queryShape else { - return - } - session.probeY = 1.6 + (session.scan ? Math.sin(session.elapsed * 0.7) : 0) - session.probeContact = nil - do { - let mask: CollisionGroup = session.blueOnly ? .default : .all - switch session.probeMode { - case .cast: - let hits = try physics.shapeCast( - shape, - from: Transform(position: [-7, session.probeY, 0]), - to: [7, session.probeY], - query: session.allHits ? .all : .first, - mask: mask, - includeSensors: session.querySensors - ) - session.queryEntities = hits.map(\.entity) - session.queryPoints = hits.map(\.point) - session.probeContact = hits.first.map { [-7 + $0.distance, session.probeY] } - case .overlap: - session.queryEntities = try physics.overlap(shape, at: Transform(position: [0, session.probeY, 0]), mask: mask, includeSensors: session.querySensors).map(\.entity) - session.queryPoints = session.queryEntities.compactMap { $0.components[Transform.self]?.position.xy } - case .area: - session.queryEntities = physics.overlap(in: Rect(x: -6, y: 0.5, width: 12, height: 2.2), mask: mask, includeSensors: session.querySensors).map(\.entity) - session.queryPoints = session.queryEntities.compactMap { $0.components[Transform.self]?.position.xy } - } - session.rayStatus = "\(session.probeMode.rawValue) · \(session.queryEntities.count) hits: " + session.queryEntities.map(\.name).joined(separator: " · ") - } catch { - session.queryEntities.removeAll(); session.queryPoints.removeAll() - session.rayStatus = "Query failed: \(error)" - } -} - -@System(dependencies: [.after(ExtractPhysicsDebugSystem.self)]) -@MainActor -func PhysicsDemoProbeExtract( - _ state: Extract>, - _ shapes: ResMut, - _ data: Res, - _ sorted: Res?> -) { - let session = state.wrappedValue.wrappedValue - if session.scene == .largeWorld { - let state = session.largeWorld - shapes.wrappedValue.addLine(from: state.probeStart, to: state.probeEnd, color: Color(1, 0.8, 0.2, 1)) - } - if session.scene == .characters || session.scene == .largeWorld { - if let character = session.character, let controller = character.components[CharacterController2DComponent.self], - controller.mode == .query, let bottom = character.components[Transform.self]?.position.xy { - shapes.wrappedValue.addCapsule( - from: bottom + [0, controller.settings.radius], - to: bottom + [0, controller.settings.height - controller.settings.radius], - radius: controller.settings.radius, - color: .white - ) - } - return - } - guard session.scene == .queries else { - return - } - if session.probeMode == .area { - let corners: [Vector2] = [[-6, 0.5], [6, 0.5], [6, 2.7], [-6, 2.7]] - for index in 0..<4 { shapes.wrappedValue.addLine(from: corners[index], to: corners[(index + 1) % 4], color: .white) } - } else { - let x: Float = session.probeMode == .cast ? -7 : 0 - shapes.wrappedValue.addCapsule(from: [x, session.probeY - 0.25], to: [x, session.probeY + 0.25], radius: 0.2, color: .white) - if session.probeMode == .cast { - shapes.wrappedValue.addLine(from: [-7, session.probeY], to: [7, session.probeY], color: Color(0.5, 0.6, 0.7, 1)) - if let contact = session.probeContact { - shapes.wrappedValue.addCapsule(from: contact - [0, 0.25], to: contact + [0, 0.25], radius: 0.2, color: Color(1, 0.85, 0.3, 1)) - } - } - } - session.debugDrawState = "lines=\(shapes.lines.count), vertices=\(data.wrappedValue?.lineVertexBuffer.count ?? -1), items=\(sorted.wrappedValue?.items.items.count ?? -1)" - for point in session.queryPoints { - shapes.wrappedValue.addLine(from: point - [0.08, 0], to: point + [0.08, 0], color: .white) - shapes.wrappedValue.addLine(from: point - [0, 0.08], to: point + [0, 0.08], color: .white) - } -} - -struct PhysicsQueriesDemoPane: View { - let session: PhysicsDemoSession - var body: some View { - VStack(spacing: 12) { - HStack(spacing: 14) { - Button(session.probeMode.rawValue) { session.probeMode = session.probeMode.next }.accessibilityIdentifier("physics-demo.query-kind") - Button(session.querySensors ? "Include sensors" : "Solids only") { session.querySensors.toggle() }.accessibilityIdentifier("physics-demo.query-sensors") - Button(session.blueOnly ? "Layer 1 only" : "All layers") { session.blueOnly.toggle() }.accessibilityIdentifier("physics-demo.query-mask") - Button(session.scan ? "Hold probe" : "Animate probe") { session.scan.toggle() }.accessibilityIdentifier("physics-demo.query-scan") - if session.probeMode == .cast { - Button(session.allHits ? "All hits" : "Nearest hit") { session.allHits.toggle() }.accessibilityIdentifier("physics-demo.query-order") - } - } - Text("White probe queries colored colliders. Area mode shows broad-phase AABB candidates.") - Text(session.rayStatus) - SceneView(useSceneCameras: true, make: { configurePhysicsDemo(&$0, session: session) }, updateContent: { _, _ in }) - .frameCaptureLabel("Physics2D Geometry & queries") - .id(PhysicsDemoScene.queries) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} diff --git a/Demos/Physics2D/README.md b/Demos/Physics2D/README.md deleted file mode 100644 index 93c09f13e..000000000 --- a/Demos/Physics2D/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# Box2D playground - -Five interactive AdaEngine scenes, using the same physics systems as games: - -- **Raycasts:** a scanning ray, distance-ordered white hit markers, nearest/all - selection and a blue-only collision mask. The orange block is on another layer. -- **Collisions & sensors:** an orange box bounces on a floor while a blue box - crosses a green sensor without a physical response. Live counters distinguish - solid contacts, sensor enter/exit and impact events delivered through ECS. - “Drop box again” repeats the impact. - -- **Geometry & queries:** capsule, rounded box/polygon, segment, open/closed chains - and a loose chain segment. A white capsule sweeps or overlaps the colored - colliders; area mode draws broad-phase candidates. Toggle sensor inclusion, - layer mask, nearest/all and animation. The outlines follow native geometry. - -- **Mechanisms:** a 3x3 grid of rope, hinge, slider, weld, suspension wheel, - pose/velocity motor, pair filter, mover and pogo rigs. Reverse live motors, - overload the rope to emit a break event, and repair the connections. Pogo is - experimental in upstream Box2D 3.2. -- **Characters:** left/stop/right, jump, reset and query/dynamic mode controls. - The query capsule is virtual; the native dynamic capsule pushes the orange - crate. A moving platform and ramp exercise support and slope behavior. - -On macOS run `./Demos/Physics2D/script/build_and_run.sh`. Use `--verify` to launch -all scenes, exercise query/character/motor controls, check live event counters and -save real GPU frames plus `verification.json` in `Demos/Physics2D/dist/proof/`. - -Verification also checks both jumps, physical crate movement, native collider -cleanup after a mode switch, repair, and scene re-entry. GPU frames include -`geometry-cast.png`, `geometry-area.png`, `mechanisms.png`, -`character-query.png`, and `character-dynamic.png`. - -Deterministic simulation without a window: - -```sh -swift run --scratch-path /tmp/adaengine-box2d-3.2-build Physics2DDemo --validate -``` - -## Query and event semantics - -`raycast(from:to:)` casts a finite segment and returns distance-sorted hits. -`.first` means nearest. Invalid/zero-length input and misses return an empty -array. Initial overlaps are ignored. `raycast(from: Ray, maxDistance:)` uses -its origin and normalized XY direction; the default range is 10,000 world units. -Masks select shape categories; the native Box2D query filter also applies. - -`CollisionEvents.Began` / `.Ended` include `isSensor`; for sensors `entityA` -is the sensor and the impulse is zero. `.Hit` contains the impact point, normal, -approach speed and the contact's total normal impulse for the current step. -Native shape pairs produce separate events, including compound bodies. -End events whose entities/shapes have already been destroyed are skipped. -A fast visitor swept through a sensor emits begin in that step and end in the next. - -Subscribe through `world.subscribe(to:)` for immediate delivery. `Events` -receives the same events through the world-local ECS buffer on the next frame. -The physics plugin installs the event lifecycle if it has not already been set up. - -The Advanced tab exercises live body settings, native CCD, conveyors, rolling and material mixing, wind/still-air drag, contact point snapshots and solver presets. `--validate` and native `--verify` include these controls; the latter writes `proof/advanced-physics.png` from the actual scene camera. - -The Large worlds tab demonstrates exact Double physics at 1e9, a one-millimeter target nudge, camera/render-origin shifts, stable contacts/global hinges and both character modes. Native `--verify` captures before/after origin frames and the dynamic character, and checks scene re-entry without stale contact handles. diff --git a/Demos/Physics2D/script/build_and_run.sh b/Demos/Physics2D/script/build_and_run.sh deleted file mode 100755 index 7815ab117..000000000 --- a/Demos/Physics2D/script/build_and_run.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -MODE="${1:-run}" -DEMO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -REPO_ROOT="$(cd "$DEMO_ROOT/../.." && pwd)" -BUILD_PATH="${ADAENGINE_PHYSICS2D_BUILD_PATH:-/tmp/adaengine-box2d-3.2-build}" -mkdir -p "$BUILD_PATH" -APP="$DEMO_ROOT/dist/Physics2DDemo.app" -export ADAENGINE_DISABLE_SWAN=1 -export ADAENGINE_SKIP_SWIFTLINT=1 -case "$MODE" in run|--verify|--debug|--logs|--telemetry) ;; *) echo "usage: $0 [run|--verify|--debug|--logs|--telemetry]" >&2; exit 2 ;; esac -for demo_pid in $(pgrep -f "$APP/Contents/MacOS/Physics2DDemo" || true); do - demo_command="$(ps -p "$demo_pid" -o command= || true)" - if [[ "$demo_command" == "$APP/Contents/MacOS/Physics2DDemo" || "$demo_command" == "$APP/Contents/MacOS/Physics2DDemo "* ]]; then - kill "$demo_pid" - fi -done -mkdir -p "$DEMO_ROOT/dist" -if ! swift build --package-path "$REPO_ROOT" --scratch-path "$BUILD_PATH" --disable-sandbox --product Physics2DDemo > "$DEMO_ROOT/dist/build.log" 2>&1; then - tail -60 "$DEMO_ROOT/dist/build.log" - exit 1 -fi -BIN="$(swift build --package-path "$REPO_ROOT" --scratch-path "$BUILD_PATH" --disable-sandbox --show-bin-path)" -mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" -cp "$BIN/Physics2DDemo" "$APP/Contents/MacOS/" -for resource in "$BIN"/*.bundle; do - [[ -d "$resource" ]] && cp -R "$resource" "$APP/" -done -cat > "$APP/Contents/Info.plist" <<'PLIST' - - - -CFBundleExecutablePhysics2DDemo -CFBundleIdentifierorg.adaengine.demo.physics2d -CFBundleNameAdaEngine Box2D playground -CFBundlePackageTypeAPPL -LSMinimumSystemVersion15.0 -NSPrincipalClassNSApplication - -PLIST -: > "$DEMO_ROOT/dist/runtime.log" -rm -f "$DEMO_ROOT/dist/proof/verification.json" -if [[ "$MODE" == --debug ]]; then - /usr/bin/lldb -- "$APP/Contents/MacOS/Physics2DDemo" - exit -fi -OPEN_ARGS=(-n "$APP" --stdout "$DEMO_ROOT/dist/runtime.log" --stderr "$DEMO_ROOT/dist/runtime.log" --args) -if [[ "$MODE" == --verify ]]; then OPEN_ARGS+=(--verify --proof-directory "$DEMO_ROOT/dist/proof"); fi -/usr/bin/open "${OPEN_ARGS[@]}" -case "$MODE" in - --verify) - for attempt in {1..60}; do - if rg -q 'Physics2DDemo verification PASS' "$DEMO_ROOT/dist/runtime.log"; then - cat "$DEMO_ROOT/dist/runtime.log" - exit 0 - fi - if rg -q 'verification FAIL|Fatal error|BOX2D ASSERTION' "$DEMO_ROOT/dist/runtime.log"; then - tail -40 "$DEMO_ROOT/dist/runtime.log" - exit 1 - fi - sleep 1 - done - tail -40 "$DEMO_ROOT/dist/runtime.log" - echo 'Physics2DDemo verification timed out' >&2 - exit 1 - ;; - --logs|--telemetry) /usr/bin/log stream --info --style compact --predicate 'process == "Physics2DDemo"' ;; -esac diff --git a/Demos/SpineNative/Package.resolved b/Demos/SpineNative/Package.resolved deleted file mode 100644 index 833c88964..000000000 --- a/Demos/SpineNative/Package.resolved +++ /dev/null @@ -1,150 +0,0 @@ -{ - "originHash" : "666c6e07655e3b6d48cd4f5fa7c3184f6951b995cbbdccf534d1b53696f76b50", - "pins" : [ - { - "identity" : "javascriptkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftwasm/JavaScriptKit.git", - "state" : { - "revision" : "67f3be5cc54fa8134cbccafd9c1a3026dd6b5750", - "version" : "0.60.0" - } - }, - { - "identity" : "swan", - "kind" : "remoteSourceControl", - "location" : "https://github.com/adobe/swan", - "state" : { - "revision" : "1eb45a22c79439b50e9cde774454450e50147149", - "version" : "0.0.8" - } - }, - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", - "state" : { - "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", - "version" : "1.8.2" - } - }, - { - "identity" : "swift-atomics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics", - "state" : { - "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", - "version" : "1.3.1" - } - }, - { - "identity" : "swift-cmark", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-cmark.git", - "state" : { - "revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a", - "version" : "0.9.0" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections", - "state" : { - "revision" : "35a1edcb8ebd2bfe251d587f0b69e62af28f508c", - "version" : "1.7.3" - } - }, - { - "identity" : "swift-distributed-tracing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-distributed-tracing", - "state" : { - "revision" : "297b5b9c82a5631e3199ce3ec0993a3cfb0fbb84", - "version" : "1.7.2" - } - }, - { - "identity" : "swift-format", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-format.git", - "state" : { - "revision" : "62eaad2822b865407b8cde56c36386c00800f7ec", - "version" : "602.0.0" - } - }, - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "4038b6a4f74a1426f47eb1dd5b4bb5628a15d597", - "version" : "1.16.1" - } - }, - { - "identity" : "swift-markdown", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-markdown.git", - "state" : { - "revision" : "25cb61d3482054b09ae76ca4f281b1bfe7fe5a43", - "version" : "0.9.0" - } - }, - { - "identity" : "swift-service-context", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-service-context", - "state" : { - "revision" : "853ba3d9ce78851b9cc20108e8bd4f13f7fa546c", - "version" : "1.5.1" - } - }, - { - "identity" : "swift-subprocess", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-subprocess.git", - "state" : { - "branch" : "0.2.1", - "revision" : "44922dfe46380cd354ca4b0208e717a3e92b13dd" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax", - "state" : { - "revision" : "4799286537280063c85a32f09884cfbca301b1a1", - "version" : "602.0.0" - } - }, - { - "identity" : "swift-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system", - "state" : { - "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", - "version" : "1.8.1" - } - }, - { - "identity" : "yams", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SpectralDragon/Yams.git", - "state" : { - "branch" : "fb676da", - "revision" : "fb676da5b91f677ab8e8d06b7fcde45b25181531" - } - }, - { - "identity" : "zlib", - "kind" : "remoteSourceControl", - "location" : "https://github.com/the-swift-collective/zlib.git", - "state" : { - "revision" : "f1d153b90420f9fcc6ef916cd67ea96f0e68d137", - "version" : "1.3.2" - } - } - ], - "version" : 3 -} \ No newline at end of file diff --git a/Demos/SpineNative/Package.swift b/Demos/SpineNative/Package.swift deleted file mode 100644 index dda2673ba..000000000 --- a/Demos/SpineNative/Package.swift +++ /dev/null @@ -1,27 +0,0 @@ -// swift-tools-version: 6.2 -import Foundation -import PackageDescription - -let mcpPath = ProcessInfo.processInfo.environment["ADA_MCP_LOCAL_PATH"] -let package = Package( - name: "SpineNative", - platforms: [.macOS(.v15)], - dependencies: [ - .package(name: "AdaEngine", path: "../.."), - mcpPath.map { .package(name: "AdaMCP", path: $0) } - ?? .package(url: "https://github.com/AdaEngine/AdaMCP.git", revision: "v0.2.0"), - ], - targets: [ - .executableTarget( - name: "SpineNative", - dependencies: [ - .product(name: "AdaEngine", package: "AdaEngine"), - .product(name: "AdaSpine", package: "AdaEngine"), - .product(name: "AdaMCPPlugin", package: "AdaMCP"), - ], - path: ".", - exclude: ["README.md", "capture.py"], - sources: ["SpineNativeApp.swift"] - ), - ] -) diff --git a/Demos/SpineNative/README.md b/Demos/SpineNative/README.md deleted file mode 100644 index f0c8829e8..000000000 --- a/Demos/SpineNative/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Native Spine demo - -This separate Swift package runs `SpinePlugin` in an AdaEngine native window. -It uses the licensed example assets beside `../SpinePreview/Assets`. - -Run from the engine checkout: - -``` -swift run --package-path Demos/SpineNative SpineNative -``` - -On Windows use the native toolchain/dependencies prepared by -`script/build_studio_windows.ps1`. Set `TINT_EXECUTABLE` to the verified Tint -binary and `ADA_MCP_LOCAL_PATH` to the Windows AdaMCP package prepared by -`script/ensure_windows_mcp.py`. `ADAENGINE_PACKAGE_PATH` must point to this -engine checkout, and `ADAENGINE_GRAVITY_PACKAGE_PATH` can select the prepared -Windows Gravity package. Do not enable `ADAENGINE_HEADLESS`. - -Windows capture requires the AdaMCP compatibility package with asynchronous -GPU readback; unpatched AdaMCP 0.2.0 uses the unsupported synchronous texture -accessor. The compatible MCP holds exclusive world access during capture, since a -native frame must not replace the target while its readback is pending. - -AdaMCP listens at `http://127.0.0.1:2511/mcp` so Studio can keep port 2510. -Discovery is disabled because the endpoint is explicit. -Use `spine.pose` (`animation`, `time`, `playing`) followed by -`render.capture_screenshot` for repeatable captures from the actual engine GPU -output. `runtime.resume` resumes the engine after capture pauses it. - -With the app running, capture three poses and optionally a walk sequence: - -``` -python Demos/SpineNative/capture.py --frames 30 -``` - -The capture report includes backend/render-graph metadata, camera, native -dimensions, original GPU capture paths and exact pose times. - -Validated on Windows with NVIDIA RTX 4070 / D3D12: native 1000×760 PNGs for -walk, run and jump, followed by 32-frame and 90-frame capture sequences. These -use the actual Spine render systems, GPU textures and AdaMCP capture service. diff --git a/Demos/SpineNative/SpineNativeApp.swift b/Demos/SpineNative/SpineNativeApp.swift deleted file mode 100644 index 0e573700f..000000000 --- a/Demos/SpineNative/SpineNativeApp.swift +++ /dev/null @@ -1,141 +0,0 @@ -import AdaEngine -import AdaMCPCore -import AdaMCPPlugin -import AdaSpine -import Foundation -import MCP - -/// Runs the production Spine render systems in a native window, with AdaMCP GPU capture. -@main -struct SpineNativeApp: App { - var body: some AppScene { - DefaultAppWindow() - .addPlugins(SpinePlugin(), SpineDemoPlugin()) - .window(with: UIWindow.Configuration( - title: "AdaEngine · Spine 4.2 · native GPU", - frame: Rect(x: 100, y: 80, width: 1000, height: 760) - )) - } -} - -private struct SpineDemoPlugin: Plugin { - func setup(in app: borrowing AppWorlds) { - app.addSystem(StartupSpineDemoSystem.self, on: .startup) - let worlds = copy app - app.addPlugin(MCPPlugin(configuration: .init( - port: 2511, - serverName: "AdaEngine Spine native demo", - additionalTools: { - [Tool( - name: "spine.pose", - description: "Select a Spine animation and exact time, optionally continuing playback.", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "animation": .object(["type": "string"]), - "time": .object(["type": "number", "minimum": 0]), - "playing": .object(["type": "boolean"]), - ]), - "required": ["animation"], - ]) - )] - }, - additionalToolHandler: { name, arguments in - guard name == "spine.pose" else { return nil } - do { - let message = try await worlds.withWorldAccess { - try selectSpinePose(in: worlds.main, arguments: arguments) - } - return .init(content: [.text(text: message, annotations: nil, _meta: nil)]) - } catch { - return .init(content: [.text(text: String(describing: error), annotations: nil, _meta: nil)], isError: true) - } - }, - enableDiscovery: false - ))) - } -} - -@MainActor -private func selectSpinePose(in world: World, arguments: [String: Value]) throws -> String { - guard case let .string(clip)? = arguments["animation"], - let entity = world.getEntities().first(where: { $0.components[SpineAnimation.self] != nil }), - var animation = entity.components[SpineAnimation.self] else { - throw SpineError.invalidData("Spine character is not ready.") - } - let time: Float - switch arguments["time"] { - case let .double(value): time = Float(value) - case let .int(value): time = Float(value) - default: time = 0 - } - guard time.isFinite, time >= 0, animation.player.animationNames.contains(clip) else { - throw SpineError.invalidData("Select an existing animation and a finite, non-negative time.") - } - animation.player.resetToSetupPose() - try animation.player.play(clip, repeats: clip != "jump", transitionDuration: 0) - animation.player.advance(by: time) - animation.isPaused = arguments["playing"] != .bool(true) - entity.components[SpineAnimation.self] = animation - let vertices = animation.player.snapshot().flatMap(\.vertices) - if let minX = vertices.map(\.x).min(), let maxX = vertices.map(\.x).max(), - let minY = vertices.map(\.y).min(), let maxY = vertices.map(\.y).max(), - let owner = entity.components[Transform.self], - let cameraEntity = world.getEntities().first(where: { $0.components[Camera.self] != nil }), - var camera = cameraEntity.components[Camera.self] { - camera.projection = .custom(SpineDemoProjection(center: [ - owner.position.x + (minX + maxX) / 2, owner.position.y + (minY + maxY) / 2, - ])) - cameraEntity.components[Camera.self] = camera - } - return "\(clip) at \(time)s; bones=\(animation.player.boneSnapshot().count)" -} - -@System -@MainActor -func StartupSpineDemo(_ context: WorldUpdateContext) async { - do { - let demoDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - let assets = demoDirectory.appendingPathComponent("../SpinePreview/Assets").standardizedFileURL - let image = try await AssetsManager.load(Image.self, at: assets.appendingPathComponent("spineboy.png").path) - guard let sourceImage = image.asset else { throw SpineError.invalidData("Atlas image is not loaded.") } - let texture = AssetHandle(Texture2D(image: sourceImage, samplerDescription: .init(minFilter: .linear, magFilter: .linear))) - let skeleton = try SpineSkeleton( - skeleton: Data(contentsOf: assets.appendingPathComponent("spineboy-pro.skel")), - atlas: String(contentsOf: assets.appendingPathComponent("spineboy.atlas"), encoding: .utf8), - textures: [texture], - format: .binary - ) - let animation = try SpineAnimation(skeleton: skeleton) - try animation.player.play("walk", transitionDuration: 0) - context.world.spawn("Spineboy") { - animation - Transform(position: [-50, -280, 0]) - NoFrustumCulling() - } - var camera = Camera() - camera.projection = .custom(SpineDemoProjection()) - camera.backgroundColor = Color(0.035, 0.055, 0.10, 1) - context.world.spawn("Spine Camera") { - camera - Transform() - GlobalViewUniform() - VisibleEntities() - Visibility.visible - CameraRenderGraph(subgraphLabel: .main2D, inputSlot: Main2DRenderNode.InputNode.view) - } - print("[SpineNative] Ready: native renderer; AdaMCP http://127.0.0.1:2511/mcp") - } catch { - print("[SpineNative] Failed to load character: \(error)") - } -} - -private struct SpineDemoProjection: CameraProjection { - var center: Vector2 = [15, 60] - var near: Float { -1 } - var far: Float { 1000 } - func makeClipView() -> Transform3D { - Transform3D(scale: [2 / 1000, 2 / 760, 0.001]) * Transform3D(translation: [-center.x, -center.y, 0]) - } - mutating func updateView(width: Float, height: Float) {} -} diff --git a/Demos/SpineNative/capture.py b/Demos/SpineNative/capture.py deleted file mode 100644 index c96f4fbe6..000000000 --- a/Demos/SpineNative/capture.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Capture native engine pixels through AdaMCP, with no browser renderer.""" -import argparse -import json -from pathlib import Path -import shutil -from time import sleep -import urllib.request - - -class MCP: - def __init__(self, endpoint): - self.endpoint = endpoint - self.session = None - self.sequence = 0 - self.request("initialize", { - "protocolVersion": "2024-11-05", "capabilities": {}, - "clientInfo": {"name": "SpineNative capture", "version": "1.0"}, - }) - - def request(self, method, params): - self.sequence += 1 - headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} - if self.session: - headers["Mcp-Session-Id"] = self.session - request = urllib.request.Request(self.endpoint, headers=headers, data=json.dumps({ - "jsonrpc": "2.0", "id": self.sequence, "method": method, "params": params, - }).encode()) - with urllib.request.urlopen(request, timeout=60) as response: - self.session = response.headers.get("Mcp-Session-Id", self.session) - body = response.read().decode() - if body.startswith("event:") or body.startswith("data:"): - body = next(line[5:].strip() for line in body.splitlines() if line.startswith("data:")) - reply = json.loads(body) - if "error" in reply: - raise RuntimeError(reply["error"]) - return reply["result"] - - def tool(self, name, **arguments): - result = self.request("tools/call", {"name": name, "arguments": arguments}) - if result.get("isError"): - raise RuntimeError(result) - text = next(item["text"] for item in result["content"] if item["type"] == "text") - try: - return json.loads(text) - except json.JSONDecodeError: - return text - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("output", type=Path) - parser.add_argument("--endpoint", default="http://127.0.0.1:2511/mcp") - parser.add_argument("--frames", type=int, default=0, help="Also capture this many walk frames at 20 fps.") - args = parser.parse_args() - args.output.mkdir(parents=True, exist_ok=True) - client = MCP(args.endpoint) - report = {"renderGraph": client.tool("render_graph.get"), "captures": []} - cameras = [camera for camera in report["renderGraph"]["cameras"] if camera["isActive"]] - if len(cameras) != 1: - raise RuntimeError("This demo expects exactly one active render camera.") - camera_id = cameras[0]["entityId"] - client.tool("runtime.pause", reason="Repeatable native Spine capture") - poses = [("walk", 0.3), ("run", 0.2), ("jump", 0.35)] - poses += [("walk", index / 20) for index in range(args.frames)] - try: - for index, (animation, time) in enumerate(poses): - client.tool("spine.pose", animation=animation, time=time, playing=False) - # Transform propagation and camera preparation run in successive phases. - client.tool("runtime.step_frame", frames=2) - capture = client.tool("render.capture_screenshot", cameraEntityId=camera_id) - if capture["width"] <= 1 or capture["height"] <= 1: - raise RuntimeError(f"Empty native capture: {capture}") - name = f"{animation}.png" if index < 3 else f"frame-{index - 3:03d}.png" - shutil.copy2(capture["path"], args.output / name) - report["captures"].append(dict(capture, animation=animation, time=time, savedAs=name)) - print(f"{name}: {capture['width']}x{capture['height']}", flush=True) - sleep(0.15) - finally: - (args.output / "capture-report.json").write_text(json.dumps(report, indent=2), encoding="utf-8") - client.tool("spine.pose", animation="walk", time=0, playing=True) - client.tool("runtime.resume") - - -if __name__ == "__main__": - main() diff --git a/Demos/SpinePreview/Assets/SpineRuntime-LICENSE.txt b/Demos/SpinePreview/Assets/SpineRuntime-LICENSE.txt deleted file mode 100644 index 517ff9fbf..000000000 --- a/Demos/SpinePreview/Assets/SpineRuntime-LICENSE.txt +++ /dev/null @@ -1,26 +0,0 @@ -Spine Runtimes License Agreement -Last updated April 5, 2025. Replaces all prior versions. - -Copyright (c) 2013-2025, Esoteric Software LLC - -Integration of the Spine Runtimes into software or otherwise creating -derivative works of the Spine Runtimes is permitted under the terms and -conditions of Section 2 of the Spine Editor License Agreement: -https://esotericsoftware.com/spine-editor-license - -Otherwise, it is permitted to integrate the Spine Runtimes into software -or otherwise create derivative works of the Spine Runtimes (collectively, -"Products"), provided that each user of the Products must obtain their own -Spine Editor license and redistribution of the Products in any form must -include this license and copyright notice. - -THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY -EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY -DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, -BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF -THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Demos/SpinePreview/Assets/UPSTREAM.md b/Demos/SpinePreview/Assets/UPSTREAM.md deleted file mode 100644 index 432fdd348..000000000 --- a/Demos/SpinePreview/Assets/UPSTREAM.md +++ /dev/null @@ -1,5 +0,0 @@ -Spineboy binary skeleton, atlas and image are unmodified official Spine 4.2 example assets. - -https://github.com/EsotericSoftware/spine-runtimes/tree/e7dc1435fa4a0083ab431f1b28e083c14a1f5c68/examples/spineboy/export - -Copyright Esoteric Software LLC. See SpineRuntime-LICENSE.txt. diff --git a/Demos/SpinePreview/Assets/preview.html b/Demos/SpinePreview/Assets/preview.html deleted file mode 100644 index ab2726d44..000000000 --- a/Demos/SpinePreview/Assets/preview.html +++ /dev/null @@ -1,75 +0,0 @@ - - - - - -AdaEngine · Spine 2D - - - -
-
AdaEngine / Spine 4.2

Персонаж оживает

Ходьба, бег и прыжок — с настоящим движением костей и деформацией сетки.

Кадры из Swift runtime
-
-
Ходьба
Кости персонажа
-
-
0.00 / 2.00 s
-
-
Spineboy · официальный пример Esoteric Software
Рассчитано AdaSpineRuntime → Spine C 4.2 → вершины и кости
Браузерный WebGL-просмотр кадров
Не является снимком нативного GPU-рендера AdaEngine
-
- - - - diff --git a/Demos/SpinePreview/Assets/spineboy-pro.skel b/Demos/SpinePreview/Assets/spineboy-pro.skel deleted file mode 100644 index 09e564b7e..000000000 Binary files a/Demos/SpinePreview/Assets/spineboy-pro.skel and /dev/null differ diff --git a/Demos/SpinePreview/Assets/spineboy.atlas b/Demos/SpinePreview/Assets/spineboy.atlas deleted file mode 100644 index eca542b71..000000000 --- a/Demos/SpinePreview/Assets/spineboy.atlas +++ /dev/null @@ -1,94 +0,0 @@ -spineboy.png - size: 1024, 256 - filter: Linear, Linear - scale: 0.5 -crosshair - bounds: 352, 7, 45, 45 -eye-indifferent - bounds: 862, 105, 47, 45 -eye-surprised - bounds: 505, 79, 47, 45 -front-bracer - bounds: 826, 66, 29, 40 -front-fist-closed - bounds: 786, 65, 38, 41 -front-fist-open - bounds: 710, 51, 43, 44 - rotate: 90 -front-foot - bounds: 210, 6, 63, 35 -front-shin - bounds: 665, 128, 41, 92 - rotate: 90 -front-thigh - bounds: 2, 2, 23, 56 - rotate: 90 -front-upper-arm - bounds: 250, 205, 23, 49 -goggles - bounds: 665, 171, 131, 83 -gun - bounds: 798, 152, 105, 102 -head - bounds: 2, 27, 136, 149 -hoverboard-board - bounds: 2, 178, 246, 76 -hoverboard-thruster - bounds: 722, 96, 30, 32 - rotate: 90 -hoverglow-small - bounds: 275, 81, 137, 38 -mouth-grind - bounds: 614, 97, 47, 30 -mouth-oooo - bounds: 612, 65, 47, 30 -mouth-smile - bounds: 661, 64, 47, 30 -muzzle-glow - bounds: 382, 54, 25, 25 -muzzle-ring - bounds: 275, 54, 25, 105 - rotate: 90 -muzzle01 - bounds: 911, 95, 67, 40 - rotate: 90 -muzzle02 - bounds: 792, 108, 68, 42 -muzzle03 - bounds: 956, 171, 83, 53 - rotate: 90 -muzzle04 - bounds: 275, 7, 75, 45 -muzzle05 - bounds: 140, 3, 68, 38 -neck - bounds: 250, 182, 18, 21 -portal-bg - bounds: 140, 43, 133, 133 -portal-flare1 - bounds: 554, 65, 56, 30 -portal-flare2 - bounds: 759, 112, 57, 31 - rotate: 90 -portal-flare3 - bounds: 554, 97, 58, 30 -portal-shade - bounds: 275, 121, 133, 133 -portal-streaks1 - bounds: 410, 126, 126, 128 -portal-streaks2 - bounds: 538, 129, 125, 125 -rear-bracer - bounds: 857, 67, 28, 36 -rear-foot - bounds: 663, 96, 57, 30 -rear-shin - bounds: 414, 86, 38, 89 - rotate: 90 -rear-thigh - bounds: 756, 63, 28, 47 -rear-upper-arm - bounds: 60, 5, 20, 44 - rotate: 90 -torso - bounds: 905, 164, 49, 90 diff --git a/Demos/SpinePreview/Assets/spineboy.png b/Demos/SpinePreview/Assets/spineboy.png deleted file mode 100644 index 0ea9737f3..000000000 Binary files a/Demos/SpinePreview/Assets/spineboy.png and /dev/null differ diff --git a/Demos/SpinePreview/README.md b/Demos/SpinePreview/README.md deleted file mode 100644 index 10e1056b7..000000000 --- a/Demos/SpinePreview/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Spine runtime visual preview - -Export 240 real poses of the official Spineboy example with the new -`AdaSpineRuntime`, including skinned/clipped geometry and bone endpoints: - -```powershell -$env:ADAENGINE_HEADLESS = '1' -swift run SpinePreview C:/path/to/spine-preview -python -m http.server 8769 --bind 127.0.0.1 --directory C:/path/to/spine-preview -``` - -Open `http://127.0.0.1:8769`. Select walk, run or jump; show the skeleton, pause, -change playback speed or scrub individual frames. - -The Swift executable samples the production player. The browser only draws the -exported triangles and bone endpoints; it does not implement or imitate Spine -animation. This is a portable WebGL preview, **not proof of AdaEngine's native GPU -draw pass**. The production headless integration tests cover extraction and GPU -buffer preparation separately. See `Documentation/Spine2D.md` for engine usage. - -The example character, skeleton and atlas are official Esoteric Software assets -from the pinned upstream commit. Source and license notices are included in the -exported preview directory. No generated animation data is checked into the repo. diff --git a/Demos/SpinePreview/SpinePreview.swift b/Demos/SpinePreview/SpinePreview.swift deleted file mode 100644 index fee1d4d46..000000000 --- a/Demos/SpinePreview/SpinePreview.swift +++ /dev/null @@ -1,80 +0,0 @@ -import AdaSpineRuntime -import Foundation - -/// Exports actual runtime poses for a portable visual preview, without substituting animation in JavaScript. -@main -struct SpinePreview { - struct Draw: Encodable { - let page: Int - let blend: Int - let pma: Bool - let vertices: [[Float]] - let indices: [UInt32] - } - struct Frame: Encodable { - let draws: [Draw] - let bones: [[Float]] - } - struct Clip: Encodable { - let name: String - let fps: Int - let frames: [Frame] - } - struct Preview: Encodable { - let source = "AdaSpineRuntime / official Spine C 4.2" - let clips: [Clip] - } - - static func main() throws { - let arguments = CommandLine.arguments - guard arguments.count == 2 else { - print("Usage: swift run SpinePreview ") - return - } - let output = URL(fileURLWithPath: arguments[1], isDirectory: true) - try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) - guard let assets = Bundle.module.resourceURL?.appendingPathComponent("Assets") else { - throw SpineError.invalidData("Preview assets are missing.") - } - let skeleton = try Data(contentsOf: assets.appendingPathComponent("spineboy-pro.skel")) - let atlas = try String(contentsOf: assets.appendingPathComponent("spineboy.atlas"), encoding: .utf8) - let player = try SpineAnimationPlayer(skeleton: skeleton, atlas: atlas, format: .binary) - print("Animations: \(player.animationNames.joined(separator: ", "))") - let fps = 30 - var clips: [Clip] = [] - for name in ["idle", "walk", "run", "jump"] where player.animationNames.contains(name) { - player.resetToSetupPose() - try player.play(name, repeats: name != "jump", transitionDuration: 0) - var frames: [Frame] = [] - for index in 0..<60 { - if index > 0 { player.advance(by: 1 / Float(fps)) } - frames.append(Frame( - draws: player.snapshot().map { draw in - Draw(page: draw.pageIndex, blend: draw.blendMode.rawValue, pma: draw.premultipliedAlpha, - vertices: draw.vertices.map { vertex in - [round(vertex.x), round(vertex.y), round(vertex.u), round(vertex.v), - round(vertex.red), round(vertex.green), round(vertex.blue), round(vertex.alpha), - round(vertex.darkRed), round(vertex.darkGreen), round(vertex.darkBlue), round(vertex.darkAlpha)] - }, indices: draw.indices) - }, - bones: player.boneSnapshot().filter(\.isActive).map { [round($0.x), round($0.y), round($0.endX), round($0.endY)] } - )) - } - clips.append(Clip(name: name, fps: fps, frames: frames)) - } - let data = try JSONEncoder().encode(Preview(clips: clips)) - try data.write(to: output.appendingPathComponent("poses.json")) - var script = Data("window.SPINE_PREVIEW_DATA=".utf8) - script.append(data) - script.append(Data(";".utf8)) - try script.write(to: output.appendingPathComponent("poses.js")) - for name in ["spineboy.png", "preview.html", "SpineRuntime-LICENSE.txt", "UPSTREAM.md"] { - let content = try Data(contentsOf: assets.appendingPathComponent(name)) - let destination = name == "preview.html" ? "index.html" : name - try content.write(to: output.appendingPathComponent(destination)) - } - print("Exported \(clips.count) animations / \(clips.reduce(0) { $0 + $1.frames.count }) runtime frames to \(output.path)") - } - - private static func round(_ value: Float) -> Float { (value * 100_000).rounded() / 100_000 } -} diff --git a/Demos/Sponza/Camera/SponzaCameraMotion.swift b/Demos/Sponza/Camera/SponzaCameraMotion.swift deleted file mode 100644 index 348016a3c..000000000 --- a/Demos/Sponza/Camera/SponzaCameraMotion.swift +++ /dev/null @@ -1,30 +0,0 @@ -import AdaTransform -import Foundation - -/// Repeatable six-second gallery controls, evaluated from wall time rather than frame count. -public enum SponzaCameraMotion: String, CaseIterable, Sendable { - case stationary - case translation - case rotation - - public func pose(from origin: Transform, seconds: Double) -> Transform { - guard seconds.isFinite else { return origin } - let phase = seconds.remainder(dividingBy: 6) * .pi / 3 - var pose = origin - switch self { - case .stationary: - break - case .translation: - // The upper gallery runs along world X. Keep height, heading and corridor Z fixed. - pose.position.x += Float(1.5 * (1 - cos(phase))) - case .rotation: - var camera = SponzaFlyCamera() - camera.setPose(origin) - pose.rotation = SponzaFlyCamera.rotation( - yaw: camera.yaw + Float(phase), - pitch: camera.pitch + Float(0.2 * sin(2 * phase)) - ) - } - return pose - } -} diff --git a/Demos/Sponza/Camera/SponzaFlyCamera.swift b/Demos/Sponza/Camera/SponzaFlyCamera.swift deleted file mode 100644 index 2d225fb37..000000000 --- a/Demos/Sponza/Camera/SponzaFlyCamera.swift +++ /dev/null @@ -1,48 +0,0 @@ -import AdaTransform -import Foundation -import Math - -/// +Z forward, yaw about world Y, then pitch about the camera's local X. No roll. -public struct SponzaFlyCamera { - public private(set) var transform = Transform() - public private(set) var yaw: Float = 0 - public private(set) var pitch: Float = 0 - - public init() {} - - public mutating func setPose(_ pose: Transform) { - transform = pose - let forward = pose.matrix.z - yaw = Math.atan2(forward.x, forward.z) - pitch = -asin(max(-1, min(1, forward.y))) - } - - public static func rotation(yaw: Float, pitch: Float) -> Quat { - let horizontal = Quat(axis: [0, 1, 0], angle: yaw) - let vertical = Quat(axis: [1, 0, 0], angle: pitch) - // Hamilton product Y * X; spelling out the axes avoids legacy Euler conventions. - return Quat(x: horizontal.w * vertical.x, y: horizontal.y * vertical.w, - z: -horizontal.y * vertical.x, w: horizontal.w * vertical.w).normalized - } - - public static func looking(from position: Vector3, at target: Vector3) -> Transform { - let direction = (target - position).normalized - return Transform(rotation: rotation(yaw: Math.atan2(direction.x, direction.z), pitch: -asin(max(-1, min(1, direction.y)))), position: position) - } - - public mutating func look(delta: Vector2) { - guard delta.x.isFinite, delta.y.isFinite, delta != .zero else { return } - yaw = (yaw + delta.x * 0.003).remainder(dividingBy: 2 * .pi) - pitch = max(-1.55, min(1.55, pitch + delta.y * 0.003)) - transform.rotation = Self.rotation(yaw: yaw, pitch: pitch) - } - - public mutating func move(local: Vector3, seconds: Double, fast: Bool) { - guard seconds.isFinite, seconds > 0 else { return } - let basis = transform.matrix - var direction = Vector3(basis.x.x, basis.x.y, basis.x.z) * local.x - + Vector3(0, local.y, 0) + Vector3(basis.z.x, basis.z.y, basis.z.z) * local.z - if direction.length > 1 { direction = direction.normalized } - transform.position += direction * Float(min(seconds, 0.05)) * (fast ? 10 : 3) - } -} diff --git a/Demos/Sponza/Camera/SponzaLightMotion.swift b/Demos/Sponza/Camera/SponzaLightMotion.swift deleted file mode 100644 index 0c42b4add..000000000 --- a/Demos/Sponza/Camera/SponzaLightMotion.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation -import Math - -/// A repeatable twelve-second flight through the upper gallery. -public enum SponzaLightMotion { - public static func position(seconds: Double) -> Vector3 { - let phase: Double = (seconds.isFinite ? seconds : 0).remainder(dividingBy: 12) * .pi / 6 - let x = Float(8 * Foundation.sin(phase)) - let y = Float(7.4 + 0.6 * Foundation.sin(2 * phase)) - let z = Float(5.35 + 0.5 * Foundation.cos(phase)) - return Vector3(x, y, z) - } -} diff --git a/Demos/Sponza/Package.resolved b/Demos/Sponza/Package.resolved deleted file mode 100644 index eab0eb973..000000000 --- a/Demos/Sponza/Package.resolved +++ /dev/null @@ -1,140 +0,0 @@ -{ - "originHash" : "995820e9e58c7f70d39b8638eda0773f78b4a426aff725a5bcf3802f46790aab", - "pins" : [ - { - "identity" : "swan", - "kind" : "remoteSourceControl", - "location" : "https://github.com/AdaEngine/swan", - "state" : { - "revision" : "790df787281e9cf1c2358a11bb42b2ec2df01029" - } - }, - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", - "state" : { - "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", - "version" : "1.8.2" - } - }, - { - "identity" : "swift-atomics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics", - "state" : { - "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", - "version" : "1.3.1" - } - }, - { - "identity" : "swift-cmark", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-cmark.git", - "state" : { - "revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a", - "version" : "0.9.0" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections", - "state" : { - "revision" : "f96b909e1a9e469620bc5037c1e24e00153f376f", - "version" : "1.7.5" - } - }, - { - "identity" : "swift-distributed-tracing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-distributed-tracing", - "state" : { - "revision" : "789663680a3a4767e698be46bc154a3bc5c64fef", - "version" : "1.7.3" - } - }, - { - "identity" : "swift-format", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-format.git", - "state" : { - "revision" : "d54c5be7afba3e5f52ae29e2371e444a3c2a49c1", - "version" : "603.0.0" - } - }, - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log", - "state" : { - "revision" : "c9c6bb9d275ec0d95c3ad16391f3f045dc623104", - "version" : "1.16.2" - } - }, - { - "identity" : "swift-markdown", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-markdown.git", - "state" : { - "revision" : "25cb61d3482054b09ae76ca4f281b1bfe7fe5a43", - "version" : "0.9.0" - } - }, - { - "identity" : "swift-service-context", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-service-context", - "state" : { - "revision" : "853ba3d9ce78851b9cc20108e8bd4f13f7fa546c", - "version" : "1.5.1" - } - }, - { - "identity" : "swift-subprocess", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-subprocess.git", - "state" : { - "branch" : "0.2.1", - "revision" : "44922dfe46380cd354ca4b0208e717a3e92b13dd" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax.git", - "state" : { - "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", - "version" : "603.0.2" - } - }, - { - "identity" : "swift-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system", - "state" : { - "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", - "version" : "1.8.1" - } - }, - { - "identity" : "yams", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SpectralDragon/Yams.git", - "state" : { - "branch" : "fb676da", - "revision" : "fb676da5b91f677ab8e8d06b7fcde45b25181531" - } - }, - { - "identity" : "zlib", - "kind" : "remoteSourceControl", - "location" : "https://github.com/the-swift-collective/zlib.git", - "state" : { - "revision" : "f1d153b90420f9fcc6ef916cd67ea96f0e68d137", - "version" : "1.3.2" - } - } - ], - "version" : 3 -} \ No newline at end of file diff --git a/Demos/Sponza/Package.swift b/Demos/Sponza/Package.swift deleted file mode 100644 index 1d599754f..000000000 --- a/Demos/Sponza/Package.swift +++ /dev/null @@ -1,19 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -let package = Package( - name: "SponzaDemo", - platforms: [.macOS(.v15)], - dependencies: [.package(name: "AdaEngine", path: "../..")], - targets: [ - .executableTarget( - name: "SponzaDemo", - dependencies: ["SponzaCamera", .product(name: "AdaEngine", package: "AdaEngine"), .product(name: "Math", package: "AdaEngine")], - path: ".", - exclude: ["README.md", "Tests", "Camera"], - swiftSettings: [.enableUpcomingFeature("MemberImportVisibility"), .strictMemorySafety()] - ), - .target(name: "SponzaCamera", dependencies: [.product(name: "AdaTransform", package: "AdaEngine"), .product(name: "Math", package: "AdaEngine")], path: "Camera"), - .testTarget(name: "SponzaDemoTests", dependencies: ["SponzaCamera", .product(name: "AdaTransform", package: "AdaEngine")], path: "Tests") - ] -) diff --git a/Demos/Sponza/README.md b/Demos/Sponza/README.md deleted file mode 100644 index 7aed4eb57..000000000 --- a/Demos/Sponza/README.md +++ /dev/null @@ -1,178 +0,0 @@ -# Sponza windowed demo - -Fetch the external dataset first with `python script/fetch_benchmark_scene.py` from the engine root. -The original assets and licenses stay in `.build-codex/benchmark-assets/intel-sponza`. - -Run `swift run --package-path Demos/Sponza -c release SponzaDemo`. -`ADAENGINE_SPONZA_ASSETS` can override the prepared assets directory. -On Windows, use the same Swift/Dawn/Tint configuration as the native renderer benchmark. - -WASD moves, Q/E changes world height, hold the right mouse button to look, and Shift increases speed. -Escape releases mouse capture until the right button is released. Yaw uses world Y and pitch uses local X; -there is no roll. The toolbar selects atrium, corridor, gallery or outside and toggles Hi-Z. -There is no collision controller. PBR textures, three shadow cascades and +3 EV match the benchmark profile. - -Set `ADAENGINE_SPONZA_PROFILE_OUTPUT` to an absolute JSON filename to measure actual SceneView publication -intervals without image readback. After loading, the profiler visits atrium, gallery and outside with three -alternating pairs of shadow-on/off measurements (one second warmup, two seconds measured per case). -Keep window size constant during the entire run; discard a case if its size changes. Camera input is disabled -while profiling. The profiler restores shadows and the outside viewpoint when it finishes. -Graph records contain CPU encoding times, not GPU timestamp measurements. Use the opt-in rendering tests -for the reproducible meshlet benchmark, and the window profile to investigate interactive frame pacing. - -`ADAENGINE_WEBGPU_LEGACY_COMPLETION=1` selects the old native callback polling path for diagnostic comparisons. -Leave it unset during normal use. - -For moving-camera measurements, also set `ADAENGINE_SPONZA_PROFILE_MOTION=1`. -This compares the stationary gallery pose, a 0–3 metre translation along gallery X, and a full 360-degree -turn with a small pitch sweep. Motion follows a six-second wall-clock cycle through the same ECS camera -component write path as interactive movement. Each case has one second warmup and six seconds measured; -three alternating shadow-on/off pairs are recorded for each motion. `motion` and `cameraUpdates` identify -what actually moved. Use `stationary`, `translation` or `rotation` instead of `1` to run only that motion. -The scripted motion does not emulate native mouse events or an arbitrary user flight path. - -## Lighting and path tracing - -The window now has three explicit presets: full 8.81M-triangle raster geometry, raster lighting LOD, -and path tracing on the same lighting LOD. The default interactive preset is full-scene raster with the 12 local lights. -`ADAENGINE_SPONZA_RENDER_PRESET=fullRaster|lodRaster|pathTracing` selects startup mode. -The light button cycles 0 / 4 / 12 local lights; the sun stays enabled. -Four warm gallery spots cast shadows; eight warm/cool point fill lights do not. -The demo now defaults to 2048 pixels per sun cascade and 1024 per local-light face/tile. -Sun-shadow coverage also increases from 30 to 80 metres with closer cascade splits (lambda 0.85). -The shadow button switches back to the earlier 1024/512, 30-metre setting. Other scenes retain their defaults. -Raster local-shadow budget remains four lights; the renderer selects nearby shadow-casting sources, -including the moving test light, within that budget. PT shadow rays do not use this raster atlas budget. -The separate flying-light button enables a shadow-casting point light on a twelve-second gallery orbit. -It starts enabled for interactive testing, independently of the 0 / 4 / 12 stationary lights. -Turn it off to inspect static PT accumulation; moving light intentionally resets that accumulation. - -The portable shader-BVH tracer supports at most 131072 triangles. Prepare the distinct lighting LOD first: -`python script/prepare_sponza_path_tracing.py --gltfpack `. -The current preparation yields 122286 source triangles, preserving image references and keeping the originals. -The tool records output hashes and its executable hash in `assets/lighting-pt-receipt.json`. -Pinned tool source: https://github.com/zeux/meshoptimizer/releases/tag/v1.3 ; Windows archive SHA-256: -`f6e9c09d66af23da3da71b86f0652d2413e81c734e0aecd1cd3d0e6e6e8645c0`. -This is a strongly simplified geometry preset; UVs and normals participate in attribute-aware simplification. -The relative geometric error is limited to 1%, with a larger triangle allocation for architecture than ivy. -Preparation rejects a result that loses an entire source material group. The former 100% error profile -discarded the floor and wall groups and must be regenerated. This LOD does not reproduce full-scene geometric detail. - -PT uses 1 sample per pixel, 2 interactions and up to 128 accumulated static frames. -Without DLSS the quality button selects 1/2 width and height or the faster 1/4 preview. -With DLSS it selects Quality (2/3 input scale) or Performance (1/2); PT traces at that input size, -without a second internal resolution reduction. DLSS Super Resolution is enabled by default when available. -Its separate status line reports actual encoded frames and fallback frames. Unsupported devices retain -the ordinary rendering path and report that DLSS is unavailable. -Camera/light changes reset accumulation. It is software BVH traversal, not hardware RTX. -The demo uses 512-pixel PT material tiles (the engine default remains 128); raster still samples the -original textures. The comparison is a renderer workload comparison, not an equal-image-quality benchmark. -The status line reports actual encoded geometry, lights, ray-image size or a tracing failure. - -For the lighting benchmark set `ADAENGINE_SPONZA_PROFILE_LIGHTING=1` along with the profile output, -`ADAENGINE_SPONZA_RENDER_PRESET=lodRaster`, and optionally `ADAENGINE_SPONZA_PROFILE_MOTION=rotation`. -It measures the LOD in raster and path tracing with 0, 4 and 12 local lights, three rounds of six seconds -per case after one second warmup. Cold BVH/atlas construction is outside measurement and has a 180-second -readiness deadline. PT cases require actual encoded-frame progress, expected light count and no failure; -a fallback image does not count as a path-tracing result. Profiling disables DLSS and the flying light -to preserve the explicit stationary-light cases. Full-scene legacy profiles retain 0 local lights. - -For Windows DLSS builds, prepare and stage the matching native NGX runtime using -[Documentation/DLSS.md](../../Documentation/DLSS.md). This demo uses Super Resolution, not Frame Generation -or Ray Reconstruction. DLSS cannot recover geometry removed during asset preparation. - -Native camera history is retained while offscreen render targets are being prepared; removal of the actual -camera releases that history. GPU regression coverage exercises this lifecycle. During movement, this -experimental tracer resets accumulation; it has no temporal reprojection or production denoiser. -The current static PT image still has substantial visual defects even after accumulation and is not a -production-quality reference. See the accepted workload measurements and remaining limits in -[the lighting report](../../Documentation/Performance/SponzaLightingPerformance-2026-11-03.md). - -## Meshlets and cluster map - -Prepare clusters without simplifying the source scene: - -```text -python script/build_meshlet_tool.py -python script/prepare_meshlets.py /main_sponza/benchmark-pbr.gltf --builder native -python script/prepare_meshlets.py /pkg_b_ivy/benchmark-pbr.gltf --builder native -``` - -Keep each `.gltf.meshlets` file beside its source. Repeat preparation for `lighting-pt.gltf` to use -the smaller raster LOD. Missing or invalid sidecars retain ordinary geometry. The builder pins -meshoptimizer 1.3; no offline C++ dependency enters the runtime. See [Tools/Meshlets](../../Tools/Meshlets/README.md). - -The meshlet button cycles **disabled → cpuReference → gpu**. The engine default stays disabled; -this diagnostic demo explicitly selects GPU mode. `ADAENGINE_SPONZA_MESHLETS` overrides the startup mode. -GPU v1 conservatively culls prepared static opaque singleton draws by frustum and compacts their indices, -issuing one indirect draw per original coarse draw. Existing instancing, MASK/BLEND, deformed/graph/parallax -surfaces, force-visible meshes, unknown draw passes and unsupported devices retain the original path. -Custom passes must explicitly adopt `MeshletGeometryDrawPass` to promise static vertex coverage and consume -the main-pass geometry override. Hi-Z uses the existing -legacy path; combined meshlet Hi-Z/cone culling is not enabled in this version. -Fully contained parts bypass classification/index copying and use their complete original draw; `целиком` -reports this fast path separately from unsupported fallbacks. Partial parts use workgroup-parallel prefix -scan and three batched compute passes per view, preserving source primitive order. - -The separate **Карта мешлетов** button displays stable colors from actual local cluster IDs. Gray means -ordinary fallback geometry, not a missing surface. The map selects full-scene raster, disables DLSS for -sharp diagnostic colors and preserves the source silhouette. Its cached diagnostic vertex streams are -bounded to 768 MiB and created only when requested. Exact partition display uses one draw per eligible part; -adaptive LOD display coalesces the selected node ranges. Neither path issues one draw per cluster. -this diagnostic allocation/draw path is excluded from performance measurements and bypasses unused GPU -visibility compaction. Unsupported or over-budget -parts remain gray. Set `ADAENGINE_SPONZA_MESHLET_MAP=1` to start in this view. - -These meshlets retain the full source triangles and are separate from the simplified PT lighting LOD. -The raster visibility processor does not accelerate the software path tracer's BVH. - -`ADAENGINE_SPONZA_PROFILE_MESHLETS=1` with an absolute `ADAENGINE_SPONZA_PROFILE_OUTPUT` runs -disabled/CPU/GPU comparisons in gallery rotation and a stationary outside view: three alternating rounds, -one second warmup and four seconds measured per case. It fixes full geometry, disables DLSS, local/flying -lights, Hi-Z and the map, and leaves sun shadows enabled. The run first warms the outside overview for -three seconds and a complete gallery turn for six seconds. CPU-known cluster counts, fully visible and -fallback triangles are separate from GPU-submitted index counts, which are read back only after each -measured interval. SceneView publication -and CPU graph times are not GPU timestamps. The run ends in the full-scene meshlet map. - -The current Windows/RTX 4070 gallery benchmark does **not** meet the acceleration gate: about 103 FPS -in GPU mode versus 123 FPS in disabled mode at 1890×843 without local lights or DLSS. Keep the mode -explicit while investigating this cost. Correctness, raw measurements and remaining platform limits: -[implementation report](../../Documentation/Performance/MeshletsImplementation-2026-11-03.md). - -## Adaptive meshlet LOD - -The original visibility-only meshlets do not simplify geometry. Prepare the additional hierarchy to let -groups of clusters use coarser parents with distance: - -```text -python script/build_meshlet_tool.py -python script/prepare_meshlet_lod.py /main_sponza/benchmark-pbr.gltf -python script/prepare_meshlet_lod.py /pkg_b_ivy/benchmark-pbr.gltf -``` - -Keep `.gltf.meshletlod` beside the source model; `.gltf.meshlets` remains the exact v1 partition. -The builder preserves source vertex identities and exact group boundary edges; it does not cross materials. -Missing/stale/corrupt hierarchy retains the original geometry or v1 culling. Unreducible groups remain exact -forest roots instead of inventing parent levels. The prepared full architecture has L0–L4, Ivy L0–L1. - -The **LOD** button cycles Off / 1 / 2 / 4 / 8 output pixels of allowed approximation error; the demo starts at 2. -`ADAENGINE_SPONZA_MESHLET_LOD=0|1|2|4|8` sets startup quality. Engine settings default to zero, and the -meshlet `disabled` mode still uses original geometry. Different source regions choose levels independently. -In the map, actual selected parents replace their children: L1 is tinted cyan, L2 orange, L3 pink, L4 purple; -L0 retains the original cluster-ID colors. The status reports selected levels/triangles for the diagnostic -cut. Ordinary GPU mode reports active LOD items; it does not synchronously read GPU triangle counts. - -At the coarsest complete forest, opaque architecture changes from 3,738,878 to 2,172,290 triangles, -Ivy from 5,065,558 to 4,455,592. These are offline lower bounds, not expected counts at every camera position. -Shadow/contact passes retain full source geometry. Temporal motion guides use independent immutable indices -and the same LOD cut, including previous object transforms; they do not borrow a completed GPU frame's buffer. - -`ADAENGINE_SPONZA_PROFILE_MESHLET_LOD=1` compares GPU LOD Off / 2 / 8 with the same two camera paths and -three alternating rounds. It fixes the new 2048/1024 shadow quality with 80-metre sun coverage, disables DLSS, local/flying lights, -Hi-Z and the map during timing, and records the explicit LOD/shadow settings. Profiling fixes the SceneView -to 1120×400 logical points so maximizing the native window cannot change the measured resolution. -Historical profiles keep -LOD off and 1024/512 shadows. `ADAENGINE_SPONZA_SHADOW_QUALITY=baseline` selects old startup shadows. - -[LOD, shadows and final validation](../../Documentation/Performance/MeshletLODAndShadows-2026-11-03.md). diff --git a/Demos/Sponza/SponzaDemo.swift b/Demos/Sponza/SponzaDemo.swift deleted file mode 100644 index 28386928a..000000000 --- a/Demos/Sponza/SponzaDemo.swift +++ /dev/null @@ -1,370 +0,0 @@ -import AdaEngine -import Foundation -import Math -import Observation -import SponzaCamera - -struct CameraRoutes: Decodable { - struct Pose: Decodable { - var position: [Float] - var rotation: [Float] - - var transform: Transform { - Transform(rotation: Quat(x: rotation[0], y: rotation[1], z: rotation[2], w: rotation[3]), position: [position[0], position[1], position[2]]) - } - } - struct Route: Decodable { var id: String; var start: Pose } - var routes: [Route] -} - -@MainActor -@Observable -final class SponzaSession: Resource { - var status = "Загружаем Sponza + Ivy…" - var hiZ = false - var requestedView: Int? - var camera: Entity? - var poses: [CameraRoutes.Pose] = [] - var preset = SponzaRenderPreset.fullRaster - var lightCount = 12 - var requestedPreset: SponzaRenderPreset? - var requestedLightCount: Int? - var pathHighQuality = true - var requestedQuality: Bool? - var dlssAvailable = false - var dlssEnabled = true - var requestedDLSS: Bool? - var dlssStatus = "Проверяем DLSS…" - var flyingLightEnabled = true - var meshletMode = MeshletVisibilityMode3D.gpu - var meshletMapEnabled = false - var requestedMeshletMode: MeshletVisibilityMode3D? - var requestedMeshletMap: Bool? - var meshletLODError: Float = 2 - var requestedMeshletLODError: Float? - var highResolutionShadows = true - var requestedShadowQuality: Bool? - var meshletStatus = "Загружаем подготовленные мешлеты…" - @ObservationIgnored var meshletStatistics: Render3DMeshletStatistics? - @ObservationIgnored var flightSeconds = 0.0 - @ObservationIgnored var temporalStatistics: Render3DTemporalStatistics? - var rendererStatus = "Готовим освещение…" - @ObservationIgnored var fullGeometry: Entity? - @ObservationIgnored var lodGeometry: Entity? - @ObservationIgnored var sun: Entity? - @ObservationIgnored var lighting = SponzaLighting() - @ObservationIgnored var pathStatistics: Render3DPathTracingStatistics? - @ObservationIgnored var nextStatusUpdate = ContinuousClock.now - var isLoaded = false - @ObservationIgnored let warmupFrames = SponzaWarmupFrames() - @ObservationIgnored var flight = SponzaFlyCamera() - @ObservationIgnored var cancelledLook = false - @ObservationIgnored var profiler: SponzaDemoProfiler? - var loadTask: Task? - @ObservationIgnored weak var runtime: AppWorlds? - - func move(to index: Int) { - guard poses.indices.contains(index), let camera else { return } - let transform = poses[index].transform - camera.components[Transform.self] = transform - camera.components[GlobalTransform.self] = GlobalTransform(matrix: transform.matrix) - flight.setPose(transform) - } -} - -@main -struct SponzaDemo: App { - var body: some AppScene { - WindowGroup { SponzaView() } - .window(with: UIWindow.Configuration(title: "Sponza + Ivy · AdaEngine", frame: Rect(x: 0, y: 0, - width: ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_STUTTER"] == "1" ? 2560 : 1280, - height: ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_STUTTER"] == "1" ? 1400 : 800))) - } -} - -private struct SponzaView: View { - @State private var session = SponzaSession() - - var body: some View { - VStack(spacing: 8) { - HStack(spacing: 16) { - Button("Атриум") { session.requestedView = 0 } - Button("Коридор") { session.requestedView = 1 } - Button("Галерея") { session.requestedView = 2 } - Button("Снаружи") { session.requestedView = 3 } - Button(session.hiZ ? "Hi-Z: вкл." : "Hi-Z: выкл.") { session.hiZ.toggle() } - } - .disabled(!session.isLoaded) - Text(session.status) - HStack(spacing: 16) { - Button("Полная сцена · растр") { session.requestedPreset = .fullRaster } - Button("PT LOD · растр") { session.requestedPreset = .lodRaster } - Button("PT LOD · path tracing") { session.requestedPreset = .pathTracing } - Button("Локальный свет: \(session.lightCount)") { session.requestedLightCount = session.lightCount == 0 ? 4 : session.lightCount == 4 ? 12 : 0 } - Button(session.meshletMapEnabled ? "Карта: 1:1" : session.dlssEnabled && session.dlssAvailable - ? (session.pathHighQuality ? "DLSS: Quality" : "DLSS: Performance") - : (session.pathHighQuality ? "PT: качество ½" : "PT: предпросмотр ¼")) { session.requestedQuality = !session.pathHighQuality } - } - .disabled(!session.isLoaded) - HStack(spacing: 16) { - Button(session.dlssEnabled && session.dlssAvailable && !session.meshletMapEnabled ? "DLSS: вкл." : "DLSS: выкл.") { session.requestedDLSS = !session.dlssEnabled } - Button(session.flyingLightEnabled ? "Летающий свет: вкл." : "Летающий свет: выкл.") { session.flyingLightEnabled.toggle() } - Button(session.highResolutionShadows ? "Тени: высокие · 80 м" : "Тени: базовые · 30 м") { session.requestedShadowQuality = !session.highResolutionShadows } - SponzaDLSSStatus(session: session) - } - .disabled(!session.isLoaded) - SponzaRendererStatus(session: session) - HStack(spacing: 16) { - Button("Мешлеты: \(session.meshletMode.rawValue)") { - session.requestedMeshletMode = session.meshletMode == .disabled ? .cpuReference : session.meshletMode == .cpuReference ? .gpu : .disabled - } - Button(session.meshletMapEnabled ? "Карта мешлетов: вкл." : "Карта мешлетов: выкл.") { session.requestedMeshletMap = !session.meshletMapEnabled } - Button(session.meshletLODError > 0 ? "LOD: \(Int(session.meshletLODError)) px" : "LOD: выкл.") { - session.requestedMeshletLODError = session.meshletLODError == 0 ? 1 : session.meshletLODError >= 8 ? 0 : session.meshletLODError * 2 - } - } - .disabled(!session.isLoaded) - SponzaMeshletStatus(session: session) - if session.meshletMapEnabled && session.meshletLODError > 0 { - Text("L0 — исходные кластеры · L1 — голубой · L2 — оранжевый · L3 — розовый · L4 — фиолетовый") - } - Text("WASD — движение · Q/E — вниз/вверх · правая кнопка мыши — осмотр · Shift — быстрее") - SceneView(useSceneCameras: true, make: { app in - app.addPlugin(TransformPlugin()).addPlugin(InputPlugin()).addPlugin(RenderWorldPlugin()) - .addPlugin(CameraPlugin()).addPlugin(AssetsPlugin()).addPlugin(VisibilityPlugin()) - .addPlugin(ScenePlugin()).addPlugin(Core3DPlugin(pathTracingPresentation: true)).addPlugin(UpscalePlugin()).addPlugin(SponzaPlugin()) - app.insertResource(session) - session.runtime = app - }, updateContent: { _, _ in }, onFrameRendered: { texture in - session.warmupFrames.didPublish() - session.profiler?.didPresent(width: texture.width, height: texture.height) - }) - .frameCaptureLabel("SponzaDemo") - // Keep measured GPU work independent of native-window resizing/maximization. - .frame(width: session.profiler?.isRunning == true && session.profiler?.usesFixedViewport == true ? 1120 : nil, - height: session.profiler?.isRunning == true && session.profiler?.usesFixedViewport == true ? 400 : nil) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .padding(10) - .background(Color(0.06, 0.07, 0.09, 1)) - .foregroundColor(.white) - } -} - -private struct SponzaPlugin: Plugin { - func setup(in app: AppWorlds) { - app.addSystem(SponzaStartupSystem.self, on: .startup) - app.addSystem(SponzaControlsSystem.self, on: .preUpdate) - } -} - -@System -@MainActor -func SponzaStartup(_ context: WorldUpdateContext, _ resource: Res) { - let session = resource.wrappedValue - guard let app = session.runtime else { return } - // Keep the window rendering and accepting input while the asset actor decodes the 4K textures. - // The session owns this task for the lifetime of this single-window demo; it is not detached. - session.loadTask = Task { @MainActor in - do { - let repository = ProcessInfo.processInfo.environment["ADAENGINE_SHADER_SOURCE_ROOT"].map { URL(fileURLWithPath: $0) } - ?? URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() - let root = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_ASSETS"].map { URL(fileURLWithPath: $0, isDirectory: true) } - ?? repository.appendingPathComponent(".build-codex/benchmark-assets/intel-sponza/assets") - let routes = try JSONDecoder().decode(CameraRoutes.self, from: Data(contentsOf: repository.appendingPathComponent("Tools/3DAssets/BenchmarkScenes/intel-sponza-routes.json"))) - guard routes.routes.count == 3, routes.routes.allSatisfy({ $0.start.position.count == 3 && $0.start.rotation.count == 4 }) else { - throw CocoaError(.fileReadCorruptFile) - } - session.poses = routes.routes.map(\.start) - let outside = SponzaFlyCamera.looking(from: [-32, 25, -35], at: [0, 4, 0]) - session.poses.append(CameraRoutes.Pose(position: [outside.position.x, outside.position.y, outside.position.z], - rotation: [outside.rotation.x, outside.rotation.y, outside.rotation.z, outside.rotation.w])) - var environment = Environment3D(antiAliasing: .none) - environment.skybox.isEnabled = false - environment.shadows.isEnabled = true - environment.shadowCascadeCount = 3 - environment.shadows.resolution = 2048 - environment.ambientOcclusion.isEnabled = false - environment.screenSpaceReflection.isEnabled = false - environment.exposureEV = 3 - environment.gpuOcclusionEnabled = session.hiZ - let environmentRoot = try await app.withWorldAccess { - session.camera = app.main.spawn("Camera", bundle: Camera3D( - camera: Camera(), perspectiveProjection: PerspectiveProjection(near: 0.05, far: 200, fieldOfView: .degrees(60)), - transform: routes.routes[0].start.transform, environment: environment - )) - session.move(to: 0) - session.camera?.components[MouseCaptureComponent.self] = MouseCaptureComponent() - let lighting = app.main.spawn("Lighting") { Transform() } - let sun = app.main.spawn("Sun") { - DirectionalLightComponent(intensity: 3, castShadows: true) - Transform(rotation: Quat.euler([-0.4, 0.5, 0])) - } - lighting.addChild(sun) - session.sun = sun - session.lighting.spawn(in: app.main, parent: lighting) - let root = app.main.spawn("Environment") { Transform() } - session.fullGeometry = app.main.spawn("Full Sponza + Ivy") { Transform(); Visibility.hidden } - session.lodGeometry = app.main.spawn("PT lighting LOD") { Transform(); Visibility.hidden } - if let full = session.fullGeometry { root.addChild(full) } - if let lod = session.lodGeometry { root.addChild(lod) } - return root - } - _ = environmentRoot - for (filename, isLOD) in [("benchmark-pbr.gltf", false), ("lighting-pt.gltf", true)] { - for (folder, label) in [("main_sponza", "Sponza"), ("pkg_b_ivy", "Ivy")] { - try Task.checkCancellation() - session.status = "Загружаем \(label)\(isLOD ? " · PT LOD" : "")…" - let file = root.appendingPathComponent(folder).appendingPathComponent(filename) - guard let model = try await AssetsManager.load(ModelAsset3D.self, at: file.path).asset else { throw CocoaError(.fileReadCorruptFile) } - try await app.withWorldAccess { - let parent = isLOD ? session.lodGeometry : session.fullGeometry - parent?.addChild(model.instantiate(in: app.main)) - } - } - } - try await app.withWorldAccess { - session.pathStatistics = app.getSubworldBuilder(by: .renderWorld)?.getResource(Render3DPathTracingStatistics.self) - session.temporalStatistics = app.getSubworldBuilder(by: .renderWorld)?.getResource(Render3DTemporalStatistics.self) - session.meshletStatistics = app.getSubworldBuilder(by: .renderWorld)?.getResource(Render3DMeshletStatistics.self) - session.meshletMode = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_MESHLETS"].flatMap(MeshletVisibilityMode3D.init(rawValue:)) ?? .gpu - session.meshletMapEnabled = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_MESHLET_MAP"] == "1" - if let value = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_MESHLET_LOD"].flatMap(Float.init), value.isFinite { - session.meshletLODError = min(max(value, 0), 8) - } - session.highResolutionShadows = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_SHADOW_QUALITY"] != "baseline" - session.dlssAvailable = app.getSubworldBuilder(by: .renderWorld)?.getResource(RenderDeviceHandler.self)?.renderDevice.supportsTemporalUpscaling ?? false - let requested = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_RENDER_PRESET"].flatMap(SponzaRenderPreset.init(rawValue:)) ?? .fullRaster - session.apply(preset: requested, lights: 12) - session.move(to: 2) - } - try await session.warmupRaster(app: app) - session.isLoaded = true - session.status = "Sponza + Ivy · полная сцена / отдельный PT LOD" - FileHandle.standardOutput.write(Data("SPONZA_DEMO_READY\n".utf8)) - if let output = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_OUTPUT"] { - let profiler = SponzaDemoProfiler(output: URL(fileURLWithPath: output)) - session.profiler = profiler - try await profiler.run(session: session, app: app) - } - } catch { - session.status = session.isLoaded ? "Замер остановлен; подробности в журнале" : "Не удалось завершить загрузку: \(error)" - FileHandle.standardError.write(Data("SPONZA_DEMO_ERROR \(error)\n".utf8)) - } - } -} - -private struct SponzaRendererStatus: View { - let session: SponzaSession - - var body: some View { Text(session.rendererStatus) } -} - -private struct SponzaDLSSStatus: View { - let session: SponzaSession - var body: some View { Text(session.dlssStatus) } -} - -private struct SponzaMeshletStatus: View { - let session: SponzaSession - var body: some View { Text(session.meshletStatus) } -} - -@PlainSystem(dependencies: [.after(InputEventParseSystem.self), .before(CameraSystem.self)]) -struct SponzaControlsSystem { - @Res private var session - @Res private var input - @Res private var time - // Declare writes performed through the session's camera ID. - @Query, Ref, Ref, Ref, Ref> private var cameraAccess - @Query, Ref, PointLightComponent> private var lightAccess - @Query> private var visibilityAccess - @Query> private var sunAccess - - init(world _: World) {} - - @MainActor - func update(context _: UpdateContext) { - _ = cameraAccess - _ = visibilityAccess - _ = lightAccess - _ = sunAccess - guard session.isLoaded, let camera = session.camera, - let transform = camera.components[Transform.self] else { return } - session.refreshPathStatus() - if let profiler = session.profiler, profiler.isRunning { - if let next = profiler.cameraPose(), next != transform { - camera.components[Transform.self] = next - camera.components[GlobalTransform.self] = GlobalTransform(matrix: next.matrix) - profiler.didMoveCamera() - } - return - } - if session.isLoaded, session.requestedPreset != nil || session.requestedLightCount != nil || session.requestedQuality != nil - || session.requestedDLSS != nil || session.requestedMeshletMode != nil || session.requestedMeshletMap != nil - || session.requestedMeshletLODError != nil || session.requestedShadowQuality != nil { - if let quality = session.requestedQuality { session.pathHighQuality = quality } - if let dlss = session.requestedDLSS { session.dlssEnabled = dlss } - if let quality = session.requestedShadowQuality { session.highResolutionShadows = quality } - if let error = session.requestedMeshletLODError { - session.meshletLODError = error - session.hiZ = false - if error > 0 && session.meshletMode == .disabled { session.meshletMode = .gpu } - if session.preset == .pathTracing { session.requestedPreset = .fullRaster } - } - if session.requestedPreset == .pathTracing { session.meshletMapEnabled = false } - if let mode = session.requestedMeshletMode { - session.meshletMode = mode - session.hiZ = false - if session.preset == .pathTracing { session.requestedPreset = .fullRaster } - } - if let map = session.requestedMeshletMap { - session.meshletMapEnabled = map - if map { session.hiZ = false; session.requestedPreset = .fullRaster } - } - session.apply(preset: session.requestedPreset ?? session.preset, lights: session.requestedLightCount ?? session.lightCount) - session.requestedPreset = nil - session.requestedLightCount = nil - session.requestedQuality = nil - session.requestedDLSS = nil - session.requestedMeshletMode = nil - session.requestedMeshletMap = nil - session.requestedMeshletLODError = nil - session.requestedShadowQuality = nil - } - session.flightSeconds += max(0, Double(time.deltaTime)) - session.lighting.updateFlight(enabled: session.flyingLightEnabled, seconds: session.flightSeconds) - if let index = session.requestedView { - session.move(to: index) - session.requestedView = nil - } - let rightDown = input.isMouseButtonPressed(.right) - if !rightDown { session.cancelledLook = false } - if input.isKeyPressed(.escape) { session.cancelledLook = true } - if var capture = camera.components[MouseCaptureComponent.self] { - let requested = rightDown && !session.cancelledLook - if requested && capture.isCaptured { session.flight.look(delta: input.getMouseDelta()) } - if capture.isRequested != requested { - capture.isRequested = requested - camera.components[MouseCaptureComponent.self] = capture - } - } - var movement = Vector3.zero - if input.isKeyPressed(.w) { movement.z += 1 } - if input.isKeyPressed(.s) { movement.z -= 1 } - if input.isKeyPressed(.d) { movement.x += 1 } - if input.isKeyPressed(.a) { movement.x -= 1 } - if input.isKeyPressed(.e) { movement.y += 1 } - if input.isKeyPressed(.q) { movement.y -= 1 } - session.flight.move(local: movement, seconds: Double(time.deltaTime), fast: input.isKeyPressed(.shift)) - let next = session.flight.transform - if next != transform { - camera.components[Transform.self] = next - camera.components[GlobalTransform.self] = GlobalTransform(matrix: next.matrix) - } - if var environment = camera.components[Environment3D.self], environment.gpuOcclusionEnabled != session.hiZ { - environment.gpuOcclusionEnabled = session.hiZ - camera.components[Environment3D.self] = environment - } - } -} diff --git a/Demos/Sponza/SponzaDemoProfiler.swift b/Demos/Sponza/SponzaDemoProfiler.swift deleted file mode 100644 index 9ea0fdea2..000000000 --- a/Demos/Sponza/SponzaDemoProfiler.swift +++ /dev/null @@ -1,357 +0,0 @@ -import AdaEngine -import Foundation -import SponzaCamera - -/// Opt-in measurements of actual SceneView publication, with no image readback in the interval. -@MainActor -final class SponzaDemoProfiler { - struct Measurement: Codable { - var view: String - var shadows: Bool - var round: Int - var width: Int - var height: Int - var publishedIntervalsMilliseconds: [Double] - var graphRecords: [RenderGraphFrameRecord] - var motion: String? - var cameraUpdates: Int? - var preset: String? - var localLightCount: Int? - var lightStatistics: [String: Render3DLightStatistics.Snapshot]? - var pathTracing: [String: PathTracing3DStatus]? - var pathFramesBefore: [String: Int]? - var pathSettings: PathTracing3D? - var meshlets: [String: Meshlet3DCount]? - var meshletGPUIndexCount: Int? - var meshletLODPixelError: Float? - var shadowCascadeResolution: Int? - var localShadowResolution: Int? - var shadowDistance: Float? - } - - let output: URL - let usesFixedViewport = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_STUTTER"] != "1" - private(set) var isRunning = false - var recording = false - var last: ContinuousClock.Instant? - var intervals: [Double] = [] - var size = [0, 0] - var sizeChanged = false - var motion = SponzaCameraMotion.stationary - var motionOrigin = Transform() - var motionStart: ContinuousClock.Instant? - var cameraUpdates = 0 - - init(output: URL) { self.output = output } - - func cameraPose() -> Transform? { - guard motion != .stationary, let motionStart else { return nil } - let elapsed = motionStart.duration(to: .now).components - let seconds = Double(elapsed.seconds) + Double(elapsed.attoseconds) / 1e18 - return motion.pose(from: motionOrigin, seconds: seconds) - } - - func didMoveCamera() { - if recording { cameraUpdates += 1 } - } - - func didPresent(width: Int, height: Int) { - guard recording else { return } - if size[0] > 0, size != [width, height] { sizeChanged = true } - let now = ContinuousClock.now - if let last { - PerformanceStallProbe.end("SceneView.PublishGap", since: last, count: width * height) - let elapsed = last.duration(to: now).components - intervals.append(Double(elapsed.seconds) * 1000 + Double(elapsed.attoseconds) / 1e15) - } - last = now - size = [width, height] - } - - func run(session: SponzaSession, app: AppWorlds) async throws { - guard let render = app.getSubworldBuilder(by: .renderWorld), let diagnostics = render.getResource(RenderGraphDiagnostics.self) else { return } - isRunning = true - defer { isRunning = false; recording = false; motionStart = nil } - if !usesFixedViewport { - try await runStutter(session: session, app: app, diagnostics: diagnostics) - return - } - // Keep historical profiles comparable; the new LOD profile explicitly records its quality settings. - session.meshletLODError = 0 - session.highResolutionShadows = false - if ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_MESHLETS"] == "1" - || ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_MESHLET_LOD"] == "1" { - try await runMeshlets(session: session, app: app, diagnostics: diagnostics) - return - } - session.meshletMode = .disabled - session.meshletMapEnabled = false - if let lighting = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_LIGHTING"], - ["1", "lodRaster", "pathTracing"].contains(lighting) { - try await runLighting(session: session, app: app, diagnostics: diagnostics) - return - } - try await app.withWorldAccess { - session.dlssEnabled = false - session.flyingLightEnabled = false - session.apply(preset: .fullRaster, lights: 0) - } - var measurements: [Measurement] = [] - let requestedMotion = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_MOTION"] - let selectedMotion = requestedMotion.flatMap(SponzaCameraMotion.init(rawValue:)) - let motionRun = requestedMotion == "1" || selectedMotion != nil - let motions = selectedMotion.map { [$0] } ?? SponzaCameraMotion.allCases - let scenarios = motionRun ? motions.map { (2, $0) } : [0, 2, 3].map { ($0, .stationary) } - for (view, mode) in scenarios { - for round in 0..<3 { - for shadows in round.isMultiple(of: 2) ? [true, false] : [false, true] { - try await app.withWorldAccess { - session.move(to: view) - motion = mode - motionOrigin = session.flight.transform - motionStart = .now - if let camera = session.camera, var environment = camera.components[Environment3D.self] { - environment.shadows.isEnabled = shadows - camera.components[Environment3D.self] = environment - } - diagnostics.configure(isEnabled: true, capacity: 64) - diagnostics.clear() - } - session.status = "Измеряем сцену…" - try await Task.sleep(for: .seconds(1)) - intervals.removeAll(keepingCapacity: true) - intervals.reserveCapacity(1024) - last = nil - cameraUpdates = 0 - recording = true - try await Task.sleep(for: .seconds(motionRun ? 6 : 2)) - recording = false - let records = try await app.withWorldAccess { diagnostics.records } - let name = ["atrium", "corridor", "gallery", "outside"][view] - measurements.append(Measurement(view: name, shadows: shadows, round: round, - width: size[0], height: size[1], publishedIntervalsMilliseconds: intervals, graphRecords: records, - motion: mode.rawValue, cameraUpdates: cameraUpdates, preset: session.preset.rawValue, - localLightCount: session.lightCount)) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) - try encoder.encode(measurements).write(to: output, options: .atomic) - } - } - } - try await app.withWorldAccess { - diagnostics.configure(isEnabled: false) - if let camera = session.camera, var environment = camera.components[Environment3D.self] { - environment.shadows.isEnabled = true - camera.components[Environment3D.self] = environment - } - session.move(to: 3) - } - session.status = "Sponza + Ivy · полная сцена / отдельный PT LOD" - FileHandle.standardOutput.write(Data("SPONZA_PROFILE_READY \(output.path)\n".utf8)) - } - - private func runLighting(session: SponzaSession, app: AppWorlds, diagnostics: RenderGraphDiagnostics) async throws { - let render = app.getSubworldBuilder(by: .renderWorld) - guard let tracing = render?.getResource(Render3DPathTracingStatistics.self), - let lights = render?.getResource(Render3DLightStatistics.self) else { throw CocoaError(.featureUnsupported) } - let selected = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_MOTION"].flatMap(SponzaCameraMotion.init(rawValue:)) ?? .rotation - session.pathHighQuality = false - session.dlssEnabled = false - session.flyingLightEnabled = false - var measurements: [Measurement] = [] - // Group modes so the cold CPU BVH/texture atlas is built once, outside measured intervals. - let requested = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_LIGHTING"] - let presets = requested.flatMap(SponzaRenderPreset.init(rawValue:)).map { [$0] } ?? [.lodRaster, .pathTracing] - for preset in presets { - for round in 0..<3 { - for count in round.isMultiple(of: 2) ? [0, 4, 12] : [12, 4, 0] { - motionStart = nil - try await app.withWorldAccess { - session.move(to: 2) - session.apply(preset: preset, lights: count) - } - if preset == .pathTracing { - let deadline = ContinuousClock.now + .seconds(180) - while true { - let states = tracing.snapshots.values - if let failure = states.compactMap(\.failure).first { - throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: failure]) - } - if !states.isEmpty && states.allSatisfy({ $0.encodedFrames > 0 && $0.triangles > 0 && $0.lights == count + 1 }) { break } - guard ContinuousClock.now < deadline else { throw CocoaError(.executableRuntimeMismatch) } - try await Task.sleep(for: .milliseconds(50)) - } - } - try await app.withWorldAccess { - motion = selected - motionOrigin = session.flight.transform - motionStart = .now - diagnostics.configure(isEnabled: true, capacity: 64) - diagnostics.clear() - } - session.status = "Замер света: \(preset.rawValue) · \(count) источников" - try await Task.sleep(for: .seconds(1)) - let before = tracing.snapshots.mapValues(\.encodedFrames) - intervals.removeAll(keepingCapacity: true) - last = nil - cameraUpdates = 0 - recording = true - try await Task.sleep(for: .seconds(6)) - recording = false - let states = tracing.snapshots - guard !intervals.isEmpty else { throw CocoaError(.fileReadCorruptFile) } - if preset == .pathTracing { - guard !states.isEmpty && states.allSatisfy({ key, value in - value.failure == nil && value.encodedFrames > (before[key] ?? 0) && value.lights == count + 1 - }) else { - struct Failure: Encodable { - var before: [String: Int] - var after: [String: PathTracing3DStatus] - } - let data = try JSONEncoder().encode(Failure(before: before, after: states)) - try data.write(to: output.appendingPathExtension("failure.json"), options: .atomic) - let reason = "PT frame progress validation failed: \(String(decoding: data, as: UTF8.self))" - throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: reason]) - } - } - let records = try await app.withWorldAccess { diagnostics.records } - measurements.append(Measurement(view: "gallery", shadows: true, round: round, - width: size[0], height: size[1], publishedIntervalsMilliseconds: intervals, graphRecords: records, - motion: selected.rawValue, cameraUpdates: cameraUpdates, preset: preset.rawValue, localLightCount: count, - lightStatistics: lights.snapshots, pathTracing: states, pathFramesBefore: before, - pathSettings: session.camera?.components[Environment3D.self]?.pathTracing)) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) - try encoder.encode(measurements).write(to: output, options: .atomic) - } - } - } - motionStart = nil - try await app.withWorldAccess { - diagnostics.configure(isEnabled: false) - session.pathHighQuality = true - session.dlssEnabled = true - session.apply(preset: .pathTracing, lights: 12) - session.move(to: 2) - } - session.status = "Sponza + Ivy · полная сцена / отдельный PT LOD" - FileHandle.standardOutput.write(Data("SPONZA_PROFILE_READY \(output.path)\n".utf8)) - } - - private func runMeshlets(session: SponzaSession, app: AppWorlds, diagnostics: RenderGraphDiagnostics) async throws { - let render = app.getSubworldBuilder(by: .renderWorld) - guard let statistics = render?.getResource(Render3DMeshletStatistics.self) else { throw CocoaError(.featureUnsupported) } - session.dlssEnabled = false - session.meshletMapEnabled = false - session.flyingLightEnabled = false - session.hiZ = false - let lodRun = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_MESHLET_LOD"] == "1" - session.highResolutionShadows = lodRun - var measurements: [Measurement] = [] - var measuredSize: [Int]? - // The outside overview primes all eligible geometry uploads and material variants before timing. - try await app.withWorldAccess { - session.meshletMode = .gpu - session.meshletLODError = lodRun ? 2 : 0 - session.apply(preset: .fullRaster, lights: 0) - session.move(to: 3) - } - session.status = "Прогрев полной геометрии и GPU мешлетов…" - try await Task.sleep(for: .seconds(3)) - try await app.withWorldAccess { - session.move(to: 2) - motion = .rotation - motionOrigin = session.flight.transform - motionStart = .now - } - // A complete turn warms the partially clipped parts that actually enter the compute path. - try await Task.sleep(for: .seconds(6)) - for view in [2, 3] { - for round in 0..<3 { - let cases: [(mode: MeshletVisibilityMode3D, error: Float)] = lodRun - ? [(.gpu, 0), (.gpu, 2), (.gpu, 8)] : [(.disabled, 0), (.cpuReference, 0), (.gpu, 0)] - for sample in round.isMultiple(of: 2) ? cases : Array(cases.reversed()) { - let mode = sample.mode - try await app.withWorldAccess { - session.meshletMode = mode - session.meshletLODError = sample.error - session.apply(preset: .fullRaster, lights: 0) - session.move(to: view) - motion = view == 2 ? .rotation : .stationary - motionOrigin = session.flight.transform - motionStart = .now - diagnostics.configure(isEnabled: true, capacity: 32) - diagnostics.clear() - } - session.status = "Замер мешлетов · \(mode.rawValue) · LOD \(Int(sample.error)) px · \(view == 2 ? "галерея" : "снаружи") · \(round + 1)/3" - try await Task.sleep(for: .seconds(1)) - intervals.removeAll(keepingCapacity: true) - last = nil - cameraUpdates = 0 - size = [0, 0] - sizeChanged = false - recording = true - try await Task.sleep(for: .seconds(4)) - recording = false - guard !intervals.isEmpty, !sizeChanged, measuredSize == nil || measuredSize == size else { - throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "Benchmark requires a constant render size and published frames"]) - } - measuredSize = size - // Freeze frame updates only after timing, so statistics and arguments describe one completed frame. - let captured = try await app.withExclusiveWorldAccess { - let snapshot = statistics.snapshots - let actualGPUOrFullyVisible = snapshot.values.contains { - $0.gpuDraws > 0 || ($0.candidateMeshlets > 0 && $0.fullyVisibleItems > 0 && $0.fullyVisibleItems == $0.eligibleItems - && $0.fallbackReasons["processorUnavailableOrBudget"] == nil) - } - if mode == .gpu && !actualGPUOrFullyVisible { - throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "No actual GPU meshlet draws in benchmark"]) - } - if lodRun && sample.error > 0 && !snapshot.values.contains(where: { $0.lodGPUItems > 0 }) { - throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "No actual adaptive LOD draws in benchmark"]) - } - var submittedIndices: Int? - // Explicit benchmark readback AFTER the measured interval; never in ordinary frames. - if mode == .gpu, let camera = session.camera, - let output = render?.getResource(Meshlet3DState.self)?.output(for: camera.id) { - let bytes = try await output.arguments.readData() - var total = 0 - for draw in 0...stride - guard offset + 4 <= bytes.count else { throw CocoaError(.fileReadCorruptFile) } - let word = UInt32(bytes[offset]) | UInt32(bytes[offset + 1]) << 8 | UInt32(bytes[offset + 2]) << 16 | UInt32(bytes[offset + 3]) << 24 - total += Int(word) - } - submittedIndices = total - } - return (snapshot, submittedIndices, diagnostics.records) - } - measurements.append(Measurement(view: view == 2 ? "gallery" : "outside", shadows: true, round: round, - width: size[0], height: size[1], publishedIntervalsMilliseconds: intervals, graphRecords: captured.2, - motion: motion.rawValue, cameraUpdates: cameraUpdates, preset: session.preset.rawValue, localLightCount: 0, - meshlets: captured.0, meshletGPUIndexCount: captured.1, meshletLODPixelError: sample.error, - shadowCascadeResolution: session.highResolutionShadows ? 2048 : 1024, - localShadowResolution: session.highResolutionShadows ? 1024 : 512, - shadowDistance: session.highResolutionShadows ? 80 : 30)) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) - try encoder.encode(measurements).write(to: output, options: .atomic) - } - } - } - motionStart = nil - try await app.withWorldAccess { - diagnostics.configure(isEnabled: false) - session.meshletMode = .gpu - session.meshletLODError = lodRun ? 2 : 0 - session.meshletMapEnabled = true - session.apply(preset: .fullRaster, lights: 0) - session.move(to: 2) - } - session.status = "Sponza + Ivy · мешлеты\(lodRun ? " + LOD" : "") · полный raster asset" - FileHandle.standardOutput.write(Data("SPONZA_PROFILE_READY \(output.path)\n".utf8)) - } -} diff --git a/Demos/Sponza/SponzaLighting.swift b/Demos/Sponza/SponzaLighting.swift deleted file mode 100644 index a9f0ba814..000000000 --- a/Demos/Sponza/SponzaLighting.swift +++ /dev/null @@ -1,134 +0,0 @@ -import AdaEngine -import Foundation -import Math -import SponzaCamera - -enum SponzaRenderPreset: String, CaseIterable { - case fullRaster - case lodRaster - case pathTracing - - var usesLOD: Bool { self != .fullRaster } -} - -@MainActor -struct SponzaLighting { - private var lamps: [Entity] = [] - private var flyingLamp: Entity? - - mutating func spawn(in world: World, parent: Entity) { - let spots = world.spawn("Gallery spotlights") { Transform() } - let points = world.spawn("Gallery fill lights") { Transform() } - let moving = world.spawn("Flying test light") { Transform() } - parent.addChild(spots) - parent.addChild(points) - parent.addChild(moving) - let lamp = world.spawn("Moving point light") { - PointLightComponent(radiance: [0.85, 0.93, 1], intensity: 25, castShadows: true, range: 10) - Transform(position: SponzaLightMotion.position(seconds: 0)) - } - moving.addChild(lamp) - flyingLamp = lamp - for (index, x) in [Float(-9), -3, 3, 9].enumerated() { - let position = Vector3(x, 8.4, 5.35) - let lamp = world.spawn("Spot \(index + 1)") { - SpotLightComponent(radiance: [1, 0.92, 0.82], intensity: 8, castShadows: true, - range: 9, innerConeAngle: 25, outerConeAngle: 42) - SponzaFlyCamera.looking(from: position, at: [x, 6.4, 5.35]) - } - spots.addChild(lamp) - lamps.append(lamp) - } - for z in [Float(-5.35), 5.35] { - for (index, x) in [Float(-9), -3, 3, 9].enumerated() { - let lamp = world.spawn("Fill \(lamps.count - 3)") { - PointLightComponent(radiance: index.isMultiple(of: 2) ? [1, 0.9, 0.8] : [0.8, 0.9, 1], - intensity: 2, castShadows: false, range: 8) - Transform(position: [x, 7.6, z]) - } - points.addChild(lamp) - lamps.append(lamp) - } - } - } - - func setCount(_ count: Int) { - for (index, lamp) in lamps.enumerated() { - lamp.components[Visibility.self] = index < count ? .visible : .hidden - } - } - - func updateFlight(enabled: Bool, seconds: Double) { - guard let flyingLamp else { return } - let visibility: Visibility = enabled ? .visible : .hidden - if flyingLamp.components[Visibility.self] != visibility { flyingLamp.components[Visibility.self] = visibility } - guard enabled else { return } - let pose = Transform(position: SponzaLightMotion.position(seconds: seconds)) - flyingLamp.components[Transform.self] = pose - flyingLamp.components[GlobalTransform.self] = GlobalTransform(matrix: pose.matrix) - } -} - -extension SponzaSession { - func apply(preset: SponzaRenderPreset, lights: Int) { - fullGeometry?.components[Visibility.self] = preset.usesLOD ? .hidden : .visible - lodGeometry?.components[Visibility.self] = preset.usesLOD ? .visible : .hidden - lighting.setCount(lights) - lighting.updateFlight(enabled: flyingLightEnabled, seconds: flightSeconds) - if let sun, var light = sun.components[DirectionalLightComponent.self] { - light.shadowDistance = highResolutionShadows ? 80 : 30 - sun.components[DirectionalLightComponent.self] = light - } - if let camera, var component = camera.components[Camera.self] { - component.temporalUpscaling = dlssEnabled && dlssAvailable && !meshletMapEnabled - ? .init(renderScale: pathHighQuality ? 2.0 / 3.0 : 0.5, method: .automatic) : nil - camera.components[Camera.self] = component - } - if let camera, var environment = camera.components[Environment3D.self] { - environment.pathTracing = PathTracing3D(isEnabled: preset == .pathTracing, - samplesPerPixel: 1, maximumBounces: 2, - resolutionScale: dlssEnabled && dlssAvailable ? 1 : (pathHighQuality ? 0.5 : 0.25), historyLimit: 128) - environment.pathTracing.textureTileSize = 512 - environment.debugView = meshletMapEnabled ? .meshlets : .none - environment.meshVisibility.meshlets = preset == .pathTracing ? .disabled : meshletMode - environment.meshVisibility.meshletLODPixelError = meshletLODError - environment.shadows.isEnabled = true - environment.shadows.resolution = highResolutionShadows ? 2048 : 1024 - environment.shadows.splitLambda = highResolutionShadows ? 0.85 : 0.65 - environment.localShadows = LocalShadowSettings3D(isEnabled: true, maximumLights: 4, resolution: highResolutionShadows ? 1024 : 512) - camera.components[Environment3D.self] = environment - } - self.preset = preset - self.lightCount = lights - rendererStatus = preset == .pathTracing ? "Path tracing · 1 spp · 2 отскока · подготовка BVH…" - : "\(preset.usesLOD ? "PT LOD" : "Полная геометрия") · растр · \(lights) локальных источников" - } - - func refreshPathStatus() { - guard ContinuousClock.now >= nextStatusUpdate else { return } - nextStatusUpdate = .now + .seconds(1) - if preset == .pathTracing && !meshletMapEnabled { - meshletStatus = "Мешлеты — растровый путь; PT использует свой BVH" - } else if let status = meshletStatistics?.snapshots.values.first { - let levels = status.selectedLODLevels.keys.sorted().map { "L\($0): \(status.selectedLODLevels[$0] ?? 0)" }.joined(separator: " / ") - let lod = meshletLODError > 0 - ? (meshletMapEnabled ? "\n\(levels.isEmpty ? "LOD: нет данных" : levels) · \(status.lodSelectedTriangles) треуг." - : " · GPU LOD: \(status.lodGPUItems)") : "" - meshletStatus = meshletMapEnabled - ? "Кластеры \(status.candidateMeshlets) · серый = без карты (\(status.fallbackItems))\(lod)" - : "Кластеры \(status.candidateMeshlets) · GPU \(status.gpuDraws) · целиком \(status.fullyVisibleItems) · fallback \(status.fallbackItems)\(lod)" - } - if meshletMapEnabled { dlssStatus = "DLSS: отключён для карты мешлетов" } - else if dlssEnabled && dlssAvailable, let status = temporalStatistics?.snapshots.values.first { - dlssStatus = "DLSS · \(status.inputWidth)×\(status.inputHeight) → \(status.outputWidth)×\(status.outputHeight)" - + " · кадры \(status.encodedFrames) · fallback \(status.fallbackFrames)" - } else { dlssStatus = dlssAvailable ? "DLSS: выкл." : "DLSS недоступен на текущем устройстве/сборке" } - guard preset == .pathTracing, let status = pathStatistics?.snapshots.values.first else { return } - if let failure = status.failure { - rendererStatus = "Path tracing недоступен: \(failure)" - } else { - rendererStatus = "Path tracing · \(status.triangles) треуг. · \(status.lights) источников · \(status.width)×\(status.height)" - + " · кадр \(status.encodedFrames) · накопление \(status.accumulatedFrames)" - } - } -} diff --git a/Demos/Sponza/SponzaStutterProfiler.swift b/Demos/Sponza/SponzaStutterProfiler.swift deleted file mode 100644 index 9ca17ab9d..000000000 --- a/Demos/Sponza/SponzaStutterProfiler.swift +++ /dev/null @@ -1,72 +0,0 @@ -import AdaEngine -import Foundation -import SponzaCamera - -extension SponzaDemoProfiler { - struct StutterPhase: Codable { - var name: String - var input: String - var width: Int - var height: Int - var sizeChanged: Bool - var publishedIntervalsMilliseconds: [Double] - var graphRecords: [RenderGraphFrameRecord] - var meshlets: [String: Meshlet3DCount] - } - - func runStutter(session: SponzaSession, app: AppWorlds, diagnostics: RenderGraphDiagnostics) async throws { - let render = app.getSubworldBuilder(by: .renderWorld) - let statistics = render?.getResource(Render3DMeshletStatistics.self) - var captured: [StutterPhase] = [] - session.status = "Sponza + Ivy · запись задержек кадров" - let cases: [(String, SponzaCameraMotion, Double)] = [ - ("stationary", .stationary, 6), ("rotation-first", .rotation, 12), ("rotation-repeat", .rotation, 12), - ("translation", .translation, 12), ("LOD-off", .rotation, 6), ("LOD-on", .rotation, 6), - ("DLSS-off", .rotation, 6), ("DLSS-on", .rotation, 6), - ("shadows-baseline", .stationary, 4), ("shadows-high", .stationary, 4), - ("lights-off", .stationary, 4), ("lights-on", .stationary, 4) - ] - for (name, movement, seconds) in cases { - try await app.withWorldAccess { - motionStart = nil - session.meshletMode = .gpu - session.meshletLODError = name == "LOD-off" ? 0 : 2 - session.meshletMapEnabled = false - session.flyingLightEnabled = false - session.dlssEnabled = name != "DLSS-off" - session.highResolutionShadows = name != "shadows-baseline" - session.apply(preset: .fullRaster, lights: name == "lights-off" ? 0 : 12) - session.move(to: 2) - motion = movement - motionOrigin = session.flight.transform - motionStart = .now - diagnostics.configure(isEnabled: true, capacity: 4096) - diagnostics.clear() - } - intervals.removeAll(keepingCapacity: true) - last = nil - size = [0, 0] - sizeChanged = false - cameraUpdates = 0 - PerformanceStallProbe.mark("begin:\(name)") - recording = true - try await Task.sleep(for: .seconds(seconds)) - recording = false - PerformanceStallProbe.mark("end:\(name)") - let data = try await app.withExclusiveWorldAccess { (diagnostics.records, statistics?.snapshots ?? [:]) } - captured.append(.init(name: name, input: movement.rawValue, width: size[0], height: size[1], sizeChanged: sizeChanged, - publishedIntervalsMilliseconds: intervals, graphRecords: data.0, meshlets: data.1)) - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) - try encoder.encode(captured).write(to: output, options: .atomic) - } - try await app.withWorldAccess { - diagnostics.configure(isEnabled: false) - motionStart = nil - session.move(to: 2) - } - session.status = "Sponza + Ivy · запись задержек завершена" - print("SPONZA_STUTTER_READY \(output.path)") - } -} diff --git a/Demos/Sponza/SponzaWarmup.swift b/Demos/Sponza/SponzaWarmup.swift deleted file mode 100644 index 2999ef09f..000000000 --- a/Demos/Sponza/SponzaWarmup.swift +++ /dev/null @@ -1,111 +0,0 @@ -import AdaEngine -import Foundation - -enum SponzaWarmupError: Error, CustomStringConvertible { - case alreadyWaiting - case timedOut(String) - - var description: String { - switch self { - case .alreadyWaiting: "Прогрев уже ожидает кадры" - case .timedOut(let stage): "Не получены готовые кадры прогрева: \(stage)" - } - } -} - -/// A demo-local barrier driven by actual SceneView publications, not elapsed warmup time. -@MainActor -final class SponzaWarmupFrames { - private struct Waiter { - let id: UUID - let target: Int - let condition: @MainActor () -> Bool - let continuation: CheckedContinuation - let deadline: Task - } - private var publications = 0 - private var waiter: Waiter? - - func didPublish() { - publications += 1 - guard let waiter, publications >= waiter.target, waiter.condition() else { return } - finish(waiter.id) - } - - func wait(stage: String, frames: Int = 6, timeout: Duration = .seconds(60), - condition: @escaping @MainActor () -> Bool = { true }) async throws { - guard waiter == nil else { throw SponzaWarmupError.alreadyWaiting } - let id = UUID() - try Task.checkCancellation() - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let deadline = Task { @MainActor [weak self] in - do { try await Task.sleep(for: timeout) } catch { return } - self?.finish(id, error: SponzaWarmupError.timedOut(stage)) - } - waiter = Waiter(id: id, target: publications + frames, condition: condition, continuation: continuation, deadline: deadline) - } - } onCancel: { - Task { @MainActor [weak self] in self?.finish(id, error: CancellationError()) } - } - } - - private func finish(_ id: UUID, error: (any Error)? = nil) { - guard let pending = waiter, pending.id == id else { return } - waiter = nil - pending.deadline.cancel() - if let error { pending.continuation.resume(throwing: error) } else { pending.continuation.resume() } - } -} - -extension SponzaSession { - /// Render both depth-format pipeline variants while controls remain in the loading state. - func warmupRaster(app: AppWorlds) async throws { - let selectedPreset = preset - let selectedDLSS = dlssEnabled - let selectedMap = meshletMapEnabled - let selectedMeshlets = meshletMode - let selectedLODError = meshletLODError - let selectedFlight = flyingLightEnabled - let selectedLights = lightCount - @MainActor func restoreSelection() { - dlssEnabled = selectedDLSS - meshletMapEnabled = selectedMap - meshletMode = selectedMeshlets - meshletLODError = selectedLODError - flyingLightEnabled = selectedFlight - apply(preset: selectedPreset, lights: selectedLights) - } - do { - let variants = dlssAvailable ? [false, true] : [false] - for temporal in variants { - try Task.checkCancellation() - let stage = temporal ? "растр · DLSS вкл." : "растр · DLSS выкл." - status = "Прогреваем \(stage)…" - try await app.withWorldAccess { - dlssEnabled = temporal - meshletMapEnabled = false - // Prime the exact leaf stream as well as the selected hierarchy below. Otherwise - // turning LOD off first uploads Ivy's large v1 stream during camera movement. - meshletMode = selectedMeshlets == .gpu ? .gpu : .disabled - meshletLODError = 0 - flyingLightEnabled = false - apply(preset: .fullRaster, lights: selectedLights) - } - // Six publications exceed the three in-flight slots. The DLSS stage additionally - // requires encoded temporal frames; an empty/fallback-only image cannot prove warmup. - try await warmupFrames.wait(stage: stage) { [weak self] in - guard let snapshots = self?.temporalStatistics?.snapshots else { return true } - return temporal ? snapshots.values.contains { $0.encodedFrames >= 3 && $0.fallbackFrames == 0 } : snapshots.isEmpty - } - FileHandle.standardOutput.write(Data("SPONZA_WARMUP_READY \(temporal ? "temporal" : "native")\n".utf8)) - } - status = "Готовим выбранный режим…" - try await app.withWorldAccess { restoreSelection() } - try await warmupFrames.wait(stage: "выбранный режим") - } catch { - try? await app.withWorldAccess { restoreSelection() } - throw error - } - } -} diff --git a/Demos/Sponza/Tests/SponzaCameraTests.swift b/Demos/Sponza/Tests/SponzaCameraTests.swift deleted file mode 100644 index 0ef7dbdbb..000000000 --- a/Demos/Sponza/Tests/SponzaCameraTests.swift +++ /dev/null @@ -1,66 +0,0 @@ -import AdaTransform -import Testing -@testable import SponzaCamera - -@Suite("Sponza fly camera") -struct SponzaCameraTests { - @Test func yawTurnsHorizontallyAndPitchVerticallyWithoutRoll() { - let yaw = Transform(rotation: SponzaFlyCamera.rotation(yaw: .pi / 2, pitch: 0)).matrix - #expect(abs(yaw.z.x - 1) < 0.00001 && abs(yaw.z.y) < 0.00001 && abs(yaw.z.z) < 0.00001) - let pitch = Transform(rotation: SponzaFlyCamera.rotation(yaw: 0, pitch: .pi / 4)).matrix - #expect(pitch.z.y < -0.7 && pitch.z.z > 0.7 && abs(pitch.z.x) < 0.00001) - let mixed = Transform(rotation: SponzaFlyCamera.rotation(yaw: 1.2, pitch: 0.6)).matrix - #expect(abs(mixed.x.y) < 0.00001, "Camera right stays horizontal; there is no bank") - } - - @Test func resetsPreservePoseAndMouseMotionDoesNotJumpAxes() { - let pose = SponzaFlyCamera.looking(from: [-32, 25, -35], at: [0, 4, 0]) - var camera = SponzaFlyCamera() - camera.setPose(pose) - camera.look(delta: .zero) - #expect(camera.transform == pose) - camera.look(delta: [1, 0]) - #expect(abs(camera.transform.matrix.z.y - pose.matrix.z.y) < 0.00001) - camera.look(delta: [0, 10000]) - #expect(camera.pitch <= 1.55) - } - - @Test func movementHasConstantDiagonalSpeedAndUsesCameraForward() { - var straight = SponzaFlyCamera(), diagonal = SponzaFlyCamera() - straight.move(local: [0, 0, 1], seconds: 0.05, fast: false) - diagonal.move(local: [1, 0, 1], seconds: 0.05, fast: false) - #expect(abs(straight.transform.position.length - diagonal.transform.position.length) < 0.00001) - var turned = SponzaFlyCamera() - turned.setPose(Transform(rotation: SponzaFlyCamera.rotation(yaw: .pi / 2, pitch: 0))) - turned.move(local: [0, 0, 1], seconds: 0.05, fast: false) - #expect(abs(turned.transform.position.x - 0.15) < 0.00001 && abs(turned.transform.position.z) < 0.00001) - } - - @Test func galleryMotionIsPeriodicAndKeepsTranslationInsideTheCorridor() { - let origin = SponzaFlyCamera.looking(from: [-9.594356, 6.921101, 5.35278], at: [0, 6.921101, 5.35278]) - for mode in SponzaCameraMotion.allCases { - #expect(mode.pose(from: origin, seconds: 0) == origin) - #expect(mode.pose(from: origin, seconds: 6) == origin) - #expect(mode.pose(from: origin, seconds: .nan) == origin) - } - let midpoint = SponzaCameraMotion.translation.pose(from: origin, seconds: 3) - #expect(abs(midpoint.position.x - origin.position.x - 3) < 0.00001) - #expect(midpoint.position.y == origin.position.y && midpoint.position.z == origin.position.z) - #expect(midpoint.rotation == origin.rotation) - let turned = SponzaCameraMotion.rotation.pose(from: origin, seconds: 1.5) - #expect(turned.position == origin.position) - #expect(abs(turned.matrix.x.y) < 0.00001) - #expect(turned.rotation != origin.rotation) - } - - @Test func flyingLightStaysInGalleryAndCompletesItsOrbit() { - let start = SponzaLightMotion.position(seconds: 0) - #expect((SponzaLightMotion.position(seconds: 12) - start).length < 0.0001) - #expect(SponzaLightMotion.position(seconds: .nan) == start) - #expect((SponzaLightMotion.position(seconds: 3) - start).length > 8) - for step in 0...120 { - let p = SponzaLightMotion.position(seconds: Double(step) / 10) - #expect(abs(p.x) <= 8 && p.y >= 6.8 && p.y <= 8 && p.z >= 4.85 && p.z <= 5.85) - } - } -} diff --git a/Demos/SpriteInstancingValidation/README.md b/Demos/SpriteInstancingValidation/README.md deleted file mode 100644 index d6e263f06..000000000 --- a/Demos/SpriteInstancingValidation/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Sprite instancing validation - -Separate QA app using production SpriteRenderSystem / SpriteDrawPass / shaders. -The same 640x360 GPU fixture covers stretch/fit/fill, sliced/tiled atlas regions, -flips, reflected/rotated transforms, anchors, alpha and ordered texture changes. -It compares readback pixels for five expanded and five instanced frames, with a -1200-sprite contiguous run. A PASS requires identical nonempty pixels and active -instancing. It reports backend, quads and draw count; no backend fallback counts as proof. - -iOS, from this demo directory: - -```sh -xcodegen generate --spec project.yml -``` - -Build and launch the scheme with `ADAENGINE_DISABLE_SWAN=1` in the host environment. -On iOS the visible PASS also writes `Documents/sprite-validation.json` in the QA -app's container, allowing a physical device result to be retrieved without relying -on launch success alone. - -Browser, from the engine root: - -```sh -python3 script/ensure_tint.py -swift package --allow-writing-to-package-directory export-web \ - --product SpriteInstancingValidation --output dist/SpriteInstancingValidation \ - --scratch-path /tmp/ada-sprite-web-validation \ - --swift-sdk swift-6.3.2-RELEASE_wasm --debug -``` - -Use compatible Swan/Dawn; local development overrides are described in -[Tools/Tint](../../Tools/Tint/README.md). Serve the output over HTTP and inspect the -console `[SpriteValidation] PASS`. The canvas presents the validated GPU readback -image with a green border; failures log FAIL and show a red background. Known color -probes reject a blank frame even when both rendering modes produce equal bytes. - -Recorded proof: macOS native WebGPU, actual WASM WebGPU, iPhone 17 Pro Simulator, -and physical iPad8,1 / iPadOS 26.5. Rendering correctness is verified; this app -provides no browser/mobile performance benchmark or Editor UI validation. diff --git a/Demos/SpriteInstancingValidation/SpriteValidationApp.swift b/Demos/SpriteInstancingValidation/SpriteValidationApp.swift deleted file mode 100644 index 5ac1fc6c4..000000000 --- a/Demos/SpriteInstancingValidation/SpriteValidationApp.swift +++ /dev/null @@ -1,152 +0,0 @@ -import AdaApp -import AdaCorePipelines -import AdaECS -import AdaRender -import AdaSprite -import AdaText -import AdaTransform -import AdaUtils -import Foundation -import Math - -#if os(iOS) - import SwiftUI - import UIKit - - /// Separate QA application; no validation hooks are installed in Studio. - @main - struct SpriteValidationApp: SwiftUI.App { - var body: some SwiftUI.Scene { WindowGroup { ValidationView() } } - } - - private struct ValidationView: SwiftUI.View { - @State private var status = "Running Metal A/B readback…" - @State private var image: UIImage? - @State private var passed = false - var body: some SwiftUI.View { - VStack(spacing: 20) { - Text("Sprite instancing").font(.title) - Text(status).foregroundStyle(passed ? .green : .primary).accessibilityIdentifier("validation-status") - if let image { SwiftUI.Image(uiImage: image).resizable().interpolation(.none).aspectRatio(contentMode: .fit) } - } - .padding() - .task { - do { - unsafe RenderEngine.configurations.preferredBackend = .metal - let host = AppWorlds(main: World()) - host.addPlugin(RenderWorldPlugin()) - try await host.build() - let result = try await SpriteValidationFixture.run() - let bytes = result.image.data as CFData - guard let provider = CGDataProvider(data: bytes), - let cgImage = CGImage( - width: 640, - height: 360, - bitsPerComponent: 8, - bitsPerPixel: 32, - bytesPerRow: 640 * 4, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: result.image.format == .bgra8 - ? [.byteOrder32Little, CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)] - : [.byteOrder32Big, CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)], - provider: provider, - decode: nil, - shouldInterpolate: false, - intent: .defaultIntent - ) - else { return } - let report: [String: Any] = [ - "passed": true, "backend": String(describing: result.backend), "frames": 10, - "quads": result.quads, "draws": result.draws, "pixels": "identical", "controlPixels": "passed", - ] - let documents = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) - .write(to: documents.appendingPathComponent("sprite-validation.json"), options: .atomic) - image = UIImage(cgImage: cgImage) - status = "PASS · Metal · identical pixels\n10 frames · \(result.quads) quads · \(result.draws) draws" - passed = true - } catch { - status = "FAIL: \(error)" - print("[SpriteValidation] FAIL: \(error)") - } - } - } - } -#else - import AdaAssets - import AdaInput - import AdaPlatform - import AdaUI - - @main - struct SpriteValidationApp: AdaApp.App { - var body: some AppScene { - EmptyWindow().transformAppWorlds { world in - world.insertPlugin(ValidationPlugin(), after: MainSchedulerPlugin.self) - } - .window(with: UIWindow.Configuration(title: "Sprite instancing validation", frame: Rect(x: 80, y: 80, width: 960, height: 540))) - } - } - - private struct ValidationPlugin: Plugin { - func setup(in app: AppWorlds) { - app.addPlugin(TransformPlugin()) - app.addPlugin(AppPlatformPlugin()) - app.addPlugin(InputPlugin()) - app.addPlugin(RenderWorldPlugin()) - app.addPlugin(CameraPlugin()) - app.addPlugin(AssetsPlugin()) - app.addPlugin(VisibilityPlugin()) - app.addPlugin(SpritePlugin()) - app.addPlugin(Mesh2DPlugin()) - app.addPlugin(TextPlugin()) - app.addPlugin(WindowPlugin()) - app.addPlugin(Core2DPlugin()) - app.addPlugin(UpscalePlugin()) - app.addPlugin(UIPlugin()) - app.addSystem(StartupSpriteValidationSystem.self, on: .startup) - } - } - - @System - @MainActor - func StartupSpriteValidation(_ context: WorldUpdateContext) async { - do { - let result = try await SpriteValidationFixture.run() - context.world.spawn { - Sprite(texture: Texture2D(image: result.image), size: [640, 360]) - Transform() - } - var camera = Camera() - camera.projection = .custom(ValidationProjection()) - camera.backgroundColor = .green - context.world.spawn { - camera - Transform() - GlobalViewUniform() - VisibleEntities() - Visibility.visible - CameraRenderGraph(subgraphLabel: .main2D, inputSlot: Main2DRenderNode.InputNode.view) - } - } catch { - print("[SpriteValidation] FAIL: \(error)") - var camera = Camera() - camera.backgroundColor = .red - context.world.spawn { - camera - Transform() - GlobalViewUniform() - VisibleEntities() - Visibility.visible - CameraRenderGraph(subgraphLabel: .main2D, inputSlot: Main2DRenderNode.InputNode.view) - } - } - } - - private struct ValidationProjection: CameraProjection { - var near: Float { -1 } - var far: Float { 1000 } - func makeClipView() -> Transform3D { Transform3D(scale: [Float(2) / 680, Float(2) / 400, 0.001]) } - mutating func updateView(width: Float, height: Float) {} - } -#endif diff --git a/Demos/SpriteInstancingValidation/SpriteValidationFixture.swift b/Demos/SpriteInstancingValidation/SpriteValidationFixture.swift deleted file mode 100644 index bf3050b0e..000000000 --- a/Demos/SpriteInstancingValidation/SpriteValidationFixture.swift +++ /dev/null @@ -1,154 +0,0 @@ -import AdaCorePipelines -import AdaECS -@_spi(Internal) import AdaRender -import AdaSprite -import AdaTransform -import AdaUtils -import Foundation -import Math - -/// Shared GPU readback fixture for native/mobile Metal and browser WebGPU. -@MainActor -enum SpriteValidationFixture { - struct Result { - let image: Image - let backend: RenderBackendType - let draws: Int - let quads: Int - } - - enum Failure: Error { - case unsupportedBackend, missingResource, missingImage, unequalPixels, emptyImage, missingInstancing - } - - static func run() async throws -> Result { - let device = unsafe RenderEngine.shared.renderDevice - guard device.supportsInstancedVertexInputs else { throw Failure.unsupportedBackend } - Camera.registerComponent() - let world = World() - world.insertResource(SpriteDrawPass()) - world.insertResource(SpriteBatches()) - world.insertResource(AdditionalExtractedSprites()) - world.insertResource(RenderDeviceHandler(renderDevice: device)) - world.insertResource(RenderPipelines(configurator: SpriteRenderPipeline())) - world.addSystem(SpriteRenderSystem.self, on: .update) - let view = world.spawn { Camera() } - let target = RenderTexture(size: [640, 360], scaleFactor: 1, format: .bgra8) - var pipelines = RenderPipelines(configurator: SpriteRenderPipeline()) - let pipeline = pipelines.pipeline(device: device) - var sprites: [Entity.ID: ExtractedSprite] = [:] - var items: [Transparent2DRenderItem] = [] - var atlasImage = Image(width: 32, height: 16, color: .green) - atlasImage.samplerDescription.magFilter = .nearest - atlasImage.samplerDescription.minFilter = .nearest - for y in 0..<16 { - for x in 0..<32 where x < 4 || x >= 28 || y < 4 || y >= 12 { - atlasImage.setPixel(in: [Float(x), Float(y)], color: y < 8 ? (x < 16 ? .red : .yellow) : (x < 16 ? .blue : .white)) - } - } - let atlas = TextureAtlas(from: atlasImage, size: [32, 16]) - guard let slice = atlas.textureSlice(in: RectInt(x: 0, y: 0, width: 32, height: 16)) else { throw Failure.missingImage } - let alphaTexture = Texture2D(image: Image(width: 2, height: 1, data: Data([255, 255, 255, 0, 255, 255, 255, 128]))) - func add( - texture: Texture2D, - position: Vector3, - size: Size, - mode: SpriteImageMode = .stretch, - anchor: SpriteAnchor = .center, - flipX: Bool = false, - flipY: Bool = false, - color: Color = .white, - scale: Vector3 = [1, 1, 1], - rotation: Quat = .identity - ) { - let id = sprites.count - let transform = Transform(rotation: rotation, scale: scale, position: position) - sprites[id] = ExtractedSprite( - entityId: id, - texture: texture, - size: size, - flipX: flipX, - flipY: flipY, - tintColor: color, - transform: transform, - worldTransform: transform.matrix, - anchor: anchor, - imageMode: mode - ) - items.append(.init(entity: id, drawPass: SpriteDrawPass(), renderPipeline: pipeline, sortKey: Float(id), batchRange: 0..<0)) - } - let modes: [SpriteImageMode] = [.stretch, .fit, .fill, .sliced(SpriteSliceBorder(4)), .sliced(SpriteSliceBorder(4)), .tiled()] - for (index, mode) in modes.enumerated() { - add( - texture: slice, - position: [Float(index) * 96 - 240, 112, 0], - size: [72, 72], - mode: mode, - flipX: index == 4, - flipY: index == 5 - ) - } - add(texture: .whiteTexture, position: [-280, 8, 0], size: [40, 40], anchor: .bottomLeft, color: .red) - add(texture: slice, position: [-170, 28, 0], size: [96, 48], scale: [-1, 1, 1], rotation: Quat(axis: [0, 0, 1], angle: 0.2)) - add(texture: .whiteTexture, position: [-32, 28, 0], size: [72, 48], color: .blue) - add(texture: alphaTexture, position: [-32, 28, 0], size: [72, 48]) - add(texture: .whiteTexture, position: [64, 28, 0], size: [72, 48], color: .red) - add(texture: alphaTexture, position: [64, 28, 0], size: [72, 48]) - // A long contiguous run exercises instance offsets and buffer reuse across frames. - for index in 0..<1200 { - add( - texture: .whiteTexture, - position: [Float(index % 120) * 5 - 298, Float(index / 120) * 5 - 120, 0], - size: [3, 3], - color: index % 2 == 0 ? .green : .yellow - ) - } - world.insertResource(ExtractedSprites(sprites: SparseSet(sprites))) - var reference: Data? - var resultImage: Image? - var draws = 0 - var quads = 0 - for mode in [SpriteRenderingMode.batched, .automatic] { - var data = SpriteDrawData.defaultValue - data.renderingMode = mode - world.insertResource(data) - for _ in 0..<5 { - world.insertResource(SortedRenderItems(items: RenderItems(items: items))) - await world.runScheduler(.update) - guard let prepared = world.getResource(SpriteDrawData.self), - let sorted = world.getResource(SortedRenderItems.self) - else { throw Failure.missingResource } - if mode == .automatic, !prepared.vertexBuffer.isEmpty { throw Failure.missingInstancing } - draws = sorted.items.items.filter { $0.batchRange?.isEmpty == false }.count - quads = world.getResource(SpriteBatches.self)?.batches.values.reduce(0) { $0 + $1.range.count } ?? 0 - let command = device.createCommandQueue().makeCommandBuffer() - let encoder = command.beginRenderPass( - RenderPassDescriptor(colorAttachments: [ - .init(texture: target, operation: OperationDescriptor(loadAction: .clear, storeAction: .store), clearColor: .black) - ]) - ) - let projection = Transform3D(scale: [Float(2) / 640, Float(2) / 360, 0.001]) - encoder.setVertexBuffer(GlobalViewUniform(projectionMatrix: projection, viewProjectionMatrix: projection), slot: GlobalBufferIndex.viewUniform) - encoder.setViewport(Rect(x: 0, y: 0, width: 640, height: 360)) - try sorted.items.render(with: encoder, world: world, view: view) - encoder.endRenderPass() - await withCheckedContinuation { (continuation: CheckedContinuation) in - command.addCompletedHandler { continuation.resume() } - command.commit() - } - guard let image = try await device.readImage(from: target) else { throw Failure.missingImage } - if let reference, image.data != reference { throw Failure.unequalPixels } - reference = image.data - resultImage = image - } - } - guard let image = resultImage else { throw Failure.missingImage } - guard image.getPixel(x: 50, y: 150) == .red, image.getPixel(x: 260, y: 150) == .blue, - image.getPixel(x: 356, y: 150) == .red, image.getPixel(x: 22, y: 300) == .green, - quads >= 1200, draws > 1 - else { throw Failure.emptyImage } - let backend = unsafe RenderEngine.shared.type - print("[SpriteValidation] PASS backend=\(backend) pixels=identical frames=10 quads=\(quads) draws=\(draws) instancing=true") - return Result(image: image, backend: backend, draws: draws, quads: quads) - } -} diff --git a/Demos/SpriteInstancingValidation/project.yml b/Demos/SpriteInstancingValidation/project.yml deleted file mode 100644 index 8da0860f8..000000000 --- a/Demos/SpriteInstancingValidation/project.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: SpriteInstancingValidation -options: - deploymentTarget: - iOS: '18.0' -packages: - AdaEngine: - path: ../.. -settings: - base: - SWIFT_VERSION: '6.2' - DEVELOPMENT_TEAM: 8PYCRS3EA3 - CODE_SIGN_STYLE: Automatic -targets: - SpriteInstancingValidation: - type: application - platform: iOS - sources: - - SpriteValidationApp.swift - - SpriteValidationFixture.swift - dependencies: - - package: AdaEngine - product: AdaApp - - package: AdaEngine - product: AdaCorePipelines - - package: AdaEngine - product: AdaECS - - package: AdaEngine - product: AdaRender - - package: AdaEngine - product: AdaSprite - - package: AdaEngine - product: AdaTransform - - package: AdaEngine - product: AdaUtils - - package: AdaEngine - product: Math - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: org.adaengine.SpriteInstancingValidation - GENERATE_INFOPLIST_FILE: YES - INFOPLIST_KEY_UILaunchScreen_Generation: YES - TARGETED_DEVICE_FAMILY: '1,2' - scheme: - testTargets: [] diff --git a/Documentation/A2UI.md b/Documentation/A2UI.md index 9811b53fd..c98d01ff6 100644 --- a/Documentation/A2UI.md +++ b/Documentation/A2UI.md @@ -6,7 +6,7 @@ no dependency on Compose, an LLM SDK, an agent framework, or a networking client ## Run the first experiment -From this worktree on macOS: +From this worktree on macOS: Set `ADAEXAMPLES_PATH` to your AdaExamples checkout. ```sh ./script/build_and_run.sh @@ -16,7 +16,7 @@ From this worktree on macOS: The Run action launches **A2UIFormDemo** as an application bundle. The script uses `/private/tmp/adaengine-a2ui-build` by default; set `ADAENGINE_A2UI_BUILD_PATH` to choose another task-owned cache. Logs and proof artifacts live in -`Demos/A2UIForm/dist/`. `--debug`, `--logs`, and `--telemetry` are also supported. +`Examples/Technical/A2UIForm/dist/`. `--debug`, `--logs`, and `--telemetry` are also supported. The demo streams a local agent fixture in arbitrary byte chunks, progressively renders an NPC form, and binds its name field and toggle to a surface model. diff --git a/Documentation/Linux.md b/Documentation/Linux.md index 267881bb3..cbb8d70a4 100644 --- a/Documentation/Linux.md +++ b/Documentation/Linux.md @@ -48,7 +48,8 @@ python3 script/prepare_swan_linux.py "$HOME/adaengine-linux-tools/swan-source" " export ADAENGINE_SWAN_PACKAGE_PATH="$HOME/adaengine-linux-tools/swan" export ADAENGINE_TINT_CACHE="$HOME/adaengine-linux-tools/tint" export TINT_EXECUTABLE="$(python3 script/ensure_tint.py)" -swift run --scratch-path "$ADAENGINE_LINUX_SCRATCH" -j 6 LinuxSmoke +# ADAEXAMPLES_PATH points to a clone of https://github.com/AdaEngine/AdaExamples +swift run --package-path "$ADAEXAMPLES_PATH/Examples/Technical/LinuxSmoke" --scratch-path "$ADAENGINE_LINUX_SCRATCH" -j 6 LinuxSmoke ``` The checkout used for this port is Swan commit diff --git a/Documentation/Performance/SponzaLightingPerformance-2026-11-03.md b/Documentation/Performance/SponzaLightingPerformance-2026-11-03.md index 9cef98b77..9a320e31f 100644 --- a/Documentation/Performance/SponzaLightingPerformance-2026-11-03.md +++ b/Documentation/Performance/SponzaLightingPerformance-2026-11-03.md @@ -105,7 +105,7 @@ the final benchmark. Earlier history-test logs predate the complete backpressure regression and are retained only as diagnostic history. Reproduce asset preparation and demo profiling with the instructions in -[Demos/Sponza/README.md](../../Demos/Sponza/README.md). Set +[Examples/Technical/Sponza/README.md](https://github.com/AdaEngine/AdaExamples/tree/main/Examples/Technical/Sponza/README.md). Set `ADAENGINE_SPONZA_PROFILE_LIGHTING=1`, `ADAENGINE_SPONZA_PROFILE_MOTION=rotation`, `ADAENGINE_SPONZA_RENDER_PRESET=lodRaster` and an absolute diff --git a/Documentation/Physics2D.md b/Documentation/Physics2D.md index e9c435429..b5a8a2def 100644 --- a/Documentation/Physics2D.md +++ b/Documentation/Physics2D.md @@ -242,7 +242,7 @@ platforms follow their velocity instead of receiving gravity. ### Runnable coverage -`Demos/Physics2D` contains ray/event, geometry/query, nine-mechanism and character +`Examples/Technical/Physics2D` contains ray/event, geometry/query, nine-mechanism and character playgrounds. Its `--validate` mode runs the same scene setup deterministically; `script/build_and_run.sh --verify` stages a native macOS app, exercises controls, checks live state/re-entry and saves real Metal GPU frames and verification JSON. diff --git a/Documentation/Physics2DAdvanced.md b/Documentation/Physics2DAdvanced.md index 30ff8efad..375da51fb 100644 --- a/Documentation/Physics2DAdvanced.md +++ b/Documentation/Physics2DAdvanced.md @@ -127,9 +127,9 @@ Both character modes, exact global revolute anchors, native debug geometry and o ## Runnable acceptance -`Demos/Physics2D` includes an Advanced tab with conveyors/rolling/mixing, wind/drag/damping, axis and sleep controls, a bullet crossing a thin kinematic target, contact point markers and solver presets. The controls use only public runtime APIs. +`Examples/Technical/Physics2D` includes an Advanced tab with conveyors/rolling/mixing, wind/drag/damping, axis and sleep controls, a bullet crossing a thin kinematic target, contact point markers and solver presets. The controls use only public runtime APIs. -From the engine worktree, run `Demos/Physics2D/script/build_and_run.sh --verify` to build/stage/open the native app and generate actual scene GPU frames plus `dist/proof/verification.json`. The executable's `--validate` mode runs deterministic simulation checks without opening a window. Generated bundles and proof files are ignored. +From an AdaExamples checkout, run `Examples/Technical/Physics2D/script/build_and_run.sh --verify` to build/stage/open the native app and generate actual scene GPU frames plus `dist/proof/verification.json`. The executable's `--validate` mode runs deterministic simulation checks without opening a window. Generated bundles and proof files are ignored. The Large worlds tab simulates at global coordinates near 1e9. Its one-millimeter target nudge changes an actual precise ray hit; shifting the render origin keeps the camera view, contact identity and exact hinge intact. Movement/jump and mode controls exercise both virtual and dynamic characters there. Native proof includes `large-world-before.png`, `large-world-shifted.png` and `large-world-dynamic.png`, plus scene re-entry and handle-isolation checks. diff --git a/Documentation/Spine2D.md b/Documentation/Spine2D.md index 51aed45f9..65310337b 100644 --- a/Documentation/Spine2D.md +++ b/Documentation/Spine2D.md @@ -91,11 +91,11 @@ separate render smoke test. ## Visual preview -[SpineNative](../Demos/SpineNative/README.md) runs the production render systems +[SpineNative](https://github.com/AdaEngine/AdaExamples/tree/main/Examples/Technical/SpineNative/README.md) runs the production render systems in a native engine window. Its AdaMCP endpoint exposes exact pose selection and the engine's GPU screenshot tools. -[SpinePreview](../Demos/SpinePreview/README.md) exports the official Spineboy's +[SpinePreview](https://github.com/AdaEngine/AdaExamples/tree/main/Examples/Technical/SpinePreview/README.md) exports the official Spineboy's walk, run, jump and idle poses through the production Swift player. Its portable browser viewer displays the textured triangles, bone overlay and frame scrubber. This preview makes animation behavior visible without requiring a native GPU backend. diff --git a/Documentation/SpriteInstancing.md b/Documentation/SpriteInstancing.md index 2fdabf106..ab63b3962 100644 --- a/Documentation/SpriteInstancing.md +++ b/Documentation/SpriteInstancing.md @@ -81,7 +81,7 @@ paths, including alpha picking, padded texture readback and tile occlusion. The developer command also verifies host-tool discovery, real fresh GLSL → SPIR-V → Tint WGSL compilation and WGSL binding reflection: six tests in four suites pass. -The separate `Demos/SpriteInstancingValidation` app exercises the same production +The separate `Examples/Technical/SpriteInstancingValidation` app exercises the same production SpriteRenderSystem / SpriteDrawPass / shaders on all three validated runtimes: | Runtime | Backend | Result | diff --git a/Editor/Sources/AdaEditor/Assets/Documentation/catalog.json b/Editor/Sources/AdaEditor/Assets/Documentation/catalog.json index b86f6aa8d..53c4261bb 100644 --- a/Editor/Sources/AdaEditor/Assets/Documentation/catalog.json +++ b/Editor/Sources/AdaEditor/Assets/Documentation/catalog.json @@ -3115,13 +3115,18 @@ }, { "kind": "text", - "text": "Run the included animated sprite and audio example from the engine root:", + "text": "Clone AdaExamples and run its animated sprite and audio example:", "level": 0, - "links": [] + "links": [ + { + "title": "AdaExamples", + "destination": "https://github.com/AdaEngine/AdaExamples" + } + ] }, { "kind": "code", - "text": "swift run MovieMakerDemo --write-movie Movies/demo.png --fixed-fps 60 --resolution 1280x720 --quit-after 180", + "text": "swift run --package-path Examples/Technical/MovieMaker MovieMakerDemo --write-movie Movies/demo.png --fixed-fps 60 --resolution 1280x720 --quit-after 180", "language": "sh", "links": [] }, diff --git a/Editor/scripts/stage-build-sdk.py b/Editor/scripts/stage-build-sdk.py index c2cba1b41..71963c196 100644 --- a/Editor/scripts/stage-build-sdk.py +++ b/Editor/scripts/stage-build-sdk.py @@ -45,7 +45,7 @@ def stage(engine, compiler, destination, swan=None): staged = Path(temporary) / "BuildSDK" staged.mkdir() copy_inputs(engine, staged / "AdaEngine", ["Package.swift", "Package.resolved", ".swiftlint.yml", "LICENSE", "NOTICE", - "Sources", "Plugins", "Tools", "Tests", "Demos", "Assets", "script"]) + "Sources", "Plugins", "Tools", "Tests", "Assets", "script"]) copy_inputs(compiler, staged / "AdaScript", ["Package.swift", "Package.resolved", "LICENSE", "Makefile", "src", "binding", "tools", "Tests", "examples", "gravity", "gravity.exe"]) host = staged / "AdaScript" / ("gravity.exe" if os.name == "nt" else "gravity") diff --git a/Editor/scripts/test_stage_build_sdk.py b/Editor/scripts/test_stage_build_sdk.py index 3f2105cc5..c847432b9 100644 --- a/Editor/scripts/test_stage_build_sdk.py +++ b/Editor/scripts/test_stage_build_sdk.py @@ -44,7 +44,7 @@ def test_source_sdk_is_self_contained_and_omits_private_build_state(self): root = Path(temporary) engine = root / "engine" compiler = root / "compiler" - for base, files in [(engine, ["Package.swift", "LICENSE", "Sources/Runtime.swift", "Sources/.build/private", ".git/config", "script/ensure_tint.py"]), + for base, files in [(engine, ["Package.swift", "LICENSE", "Sources/Runtime.swift", "Sources/.build/private", ".git/config", "script/ensure_tint.py", "Demos/Example/binary.glb"]), (compiler, ["Package.swift", "LICENSE", "src/shared/header.h", "tools/aot_build.py", "binding/Runtime.swift"])]: for name in files: path = base / name @@ -61,6 +61,7 @@ def test_source_sdk_is_self_contained_and_omits_private_build_state(self): self.assertTrue((destination / "AdaScript/gravity").is_file()) self.assertTrue((destination / "AdaScript/LICENSE").is_file()) self.assertFalse((destination / "AdaEngine/.git").exists()) + self.assertFalse((destination / "AdaEngine/Demos").exists()) self.assertFalse((destination / "AdaEngine/Sources/.build").exists()) self.assertFalse((engine / "sdk.json").exists()) # A second stage replaces the complete SDK without accumulating nested copies. @@ -69,15 +70,24 @@ def test_source_sdk_is_self_contained_and_omits_private_build_state(self): def test_android_sdk_keeps_dawn_static_library_after_relocation(self): with tempfile.TemporaryDirectory() as temporary: - root=Path(temporary); engine=root/"engine"; compiler=root/"compiler"; swan=root/"swan" + root=Path(temporary) + engine=root/"engine" + compiler=root/"compiler" + swan=root/"swan" for base, names in [(engine,["Package.swift","Tools/Android/android.py"]),(compiler,["Package.swift","tools/aot_build.py"]), (swan,["Package.swift","Sources/WebGPU/WebGPU.swift","Dawn/dist/android.artifactbundle/info.json","Dawn/dist/android.artifactbundle/arm64/libwebgpu_dawn.a"])]: for name in names: - path=base/name;path.parent.mkdir(parents=True,exist_ok=True);path.write_text("fixture") - host=compiler/"gravity";host.write_text("#!/bin/sh\nexit 0\n");host.chmod(0o755) + path=base/name + path.parent.mkdir(parents=True,exist_ok=True) + path.write_text("fixture") + host=compiler/"gravity" + host.write_text("#!/bin/sh\nexit 0\n") + host.chmod(0o755) destination=root/"Studio.app/Contents/Resources/BuildSDK" sdk.stage(engine,compiler,destination,swan) - moved=root/"Relocated/BuildSDK";moved.parent.mkdir();destination.rename(moved) + moved=root/"Relocated/BuildSDK" + moved.parent.mkdir() + destination.rename(moved) self.assertTrue(json.loads((moved/"sdk.json").read_text())["android"]) self.assertEqual((moved/"Swan/Dawn/dist/android.artifactbundle/arm64/libwebgpu_dawn.a").read_text(),"fixture") self.assertTrue((moved/"AdaEngine/Tools/Android/android.py").is_file()) diff --git a/Package.swift b/Package.swift index 49c3d26e9..9eddb864e 100644 --- a/Package.swift +++ b/Package.swift @@ -70,12 +70,13 @@ extension String { let applePlatforms: [Platform] = [.iOS, .macOS, .tvOS, .watchOS, .visionOS] var products: [Product] = [ - .executable(name: "MovieMakerDemo", targets: ["MovieMakerDemo"]), - .executable(name: "NavigationDemo", targets: ["NavigationDemo"]), - .executable(name: "Physics2DDemo", targets: ["Physics2DDemo"]), + // Public runtime modules used by independently packaged examples. + .library(name: "AdaAssets", targets: ["AdaAssets"]), + .library(name: "AdaPlatform", targets: ["AdaPlatform"]), + .library(name: "AdaInput", targets: ["AdaInput"]), + .library(name: "AdaSpineRuntime", targets: ["AdaSpineRuntime"]), .library(name: "AdaNavigation", targets: ["AdaNavigation"]), .library(name: "AdaNavigationBaking", targets: ["AdaNavigationBaking"]), - .executable(name: "SpinePreview", targets: ["SpinePreview"]), .library(name: "AdaSpine", targets: ["AdaSpine"]), .library(name: "CAndroid", targets: ["CAndroid"]), .executable(name: "AdaWebPlayer", targets: ["AdaWebPlayer"]), @@ -83,7 +84,6 @@ var products: [Product] = [ .executable(name: "AdaScriptTest", targets: ["AdaScriptTest"]), .library(name: "AdaUIDescription", targets: ["AdaUIDescription"]), .library(name: "AdaA2UI", targets: ["AdaA2UI"]), - .executable(name: "A2UIFormDemo", targets: ["A2UIFormDemo"]), .library( name: "AdaEngine", targets: ["AdaEngine"] @@ -172,7 +172,6 @@ var products: [Product] = [ ]) ] -products.append(.library(name: "AndroidDemo", type: .dynamic, targets: ["AndroidDemo"])) // MARK: - Targets @@ -344,26 +343,6 @@ let adaEngineMacros: Target = .macro( // MARK: Other Targets var targets: [Target] = [ - .executableTarget( - name: "MovieMakerDemo", - dependencies: ["AdaEngine"], - path: "Demos/MovieMaker", - swiftSettings: swiftSettings - ), - .executableTarget( - name: "Physics2DDemo", - dependencies: ["AdaEngine"], - path: "Demos/Physics2D", - exclude: ["README.md", "script", "dist", ".codex"], - swiftSettings: swiftSettings - ), - .executableTarget( - name: "NavigationDemo", - dependencies: ["AdaEngine", "AdaNavigationBaking"], - path: "Demos/Navigation", - resources: [.copy("NavigationRobot.ada")], - swiftSettings: swiftSettings - ), .target( name: "Detour", path: "Sources/RecastNavigation/Detour", @@ -381,14 +360,6 @@ var targets: [Target] = [ .target(name: "AdaNavigation", dependencies: ["CNavigation", "Math"], swiftSettings: swiftSettings), .target(name: "AdaNavigationBaking", dependencies: ["AdaNavigation", "CNavigationBaking", "Math"], swiftSettings: swiftSettings), .testTarget(name: "AdaNavigationTests", dependencies: ["AdaNavigation", "AdaNavigationBaking", "Math"], swiftSettings: swiftSettings), - .executableTarget( - name: "SpinePreview", - dependencies: ["AdaSpineRuntime"], - path: "Demos/SpinePreview", - exclude: ["README.md"], - resources: [.copy("Assets")], - swiftSettings: swiftSettings - ), .target( name: "SpineRuntimeC", exclude: ["LICENSE", "UPSTREAM.md"], @@ -411,14 +382,6 @@ var targets: [Target] = [ .linkedLibrary("log", .when(platforms: [.android])), ] ), - .target( - name: "AndroidDemo", - dependencies: ["AdaEngine", "CAndroid"], - path: "Demos/AndroidDemo", - exclude: ["README.md"], - resources: [.copy("Assets")], - swiftSettings: swiftSettings - ), adaEngineTarget, adaEngineEmbeddable, adaEngineMacros, @@ -647,17 +610,6 @@ var targets: [Target] = [ resources: [.process("Resources")], swiftSettings: swiftSettings ), - .executableTarget( - name: "A2UIFormDemo", - dependencies: ["AdaA2UI", "AdaEngine", "AdaPlatform"], - path: "Demos/A2UIForm", - exclude: ["README.md", "script"] + ( - FileManager.default.fileExists(atPath: URL(fileURLWithPath: #filePath).deletingLastPathComponent().appendingPathComponent("Demos/A2UIForm/dist").path) - ? ["dist"] : [] - ), - resources: [.copy("Fixtures")], - swiftSettings: swiftSettings - ), .adaTarget( name: "AdaUI", dependencies: [ @@ -942,9 +894,7 @@ if isWGPUEnabled { // MARK: Extra #if os(Linux) -products.append(.executable(name: "LinuxSmoke", targets: ["LinuxSmoke"])) targets += [ - .executableTarget(name: "LinuxSmoke", dependencies: ["AdaEngine", "AdaPlatform"], path: "Demos/LinuxSmoke", swiftSettings: swiftSettings), .testTarget(name: "AdaPlatformTests", dependencies: ["AdaPlatform", "AdaECS", "AdaInput", "AdaRender", "AdaUI", "AdaUtils", "Math", "X11"], swiftSettings: swiftSettings), ] #endif @@ -1438,7 +1388,7 @@ targets += [ .testTarget( name: "AdaTransformTests", dependencies: [ - "AdaECS", + "AdaECS", "AdaTransform", "Math" ], @@ -1523,17 +1473,6 @@ targets += [ //targets.append(contentsOf: swiftLintTargets) #endif -// Portable GPU readback QA app; kept outside the engine and Studio runtime. -products.append(.executable(name: "SpriteInstancingValidation", targets: ["SpriteInstancingValidation"])) -targets.append(.executableTarget( - name: "SpriteInstancingValidation", - dependencies: ["AdaApp", "AdaAssets", "AdaCorePipelines", "AdaECS", "AdaInput", "AdaPlatform", "AdaRender", "AdaSprite", "AdaText", "AdaTransform", "AdaUI", "AdaUtils", "Math"], - path: "Demos/SpriteInstancingValidation", - exclude: ["project.yml", "README.md", "SpriteInstancingValidation.xcodeproj"], - swiftSettings: swiftSettings, - linkerSettings: wasmExecutableLinkerSettings -)) - if let config = dlssBuildConfiguration, let includes = config["includePaths"] as? [String], let libraries = config["libraries"] as? [String] { let includeFlags = includes.flatMap { ["-I", $0] } diff --git a/Sources/AdaEngine/AdaEngine.docc/MovieMaker.md b/Sources/AdaEngine/AdaEngine.docc/MovieMaker.md index b581bf8b6..9e029874d 100644 --- a/Sources/AdaEngine/AdaEngine.docc/MovieMaker.md +++ b/Sources/AdaEngine/AdaEngine.docc/MovieMaker.md @@ -103,10 +103,10 @@ It returns `nil` when `--write-movie` is absent, so a game's entry point can cho between its normal launch and a recording. Parsing alone does not change app startup; pass the configuration to `MovieMaker.record` with your game's setup. -Run the included animated sprite and audio example from the engine root: +Clone [AdaExamples](https://github.com/AdaEngine/AdaExamples) and run its animated sprite and audio example: ```sh -swift run MovieMakerDemo --write-movie Movies/demo.png --fixed-fps 60 --resolution 1280x720 --quit-after 180 +swift run --package-path Examples/Technical/MovieMaker MovieMakerDemo --write-movie Movies/demo.png --fixed-fps 60 --resolution 1280x720 --quit-after 180 ``` Additional flags are `--movie-warmup N` and `--movie-no-audio`. `--quit-after N` diff --git a/Sources/AdaNavigation/AdaNavigation.docc/AdaNavigation.md b/Sources/AdaNavigation/AdaNavigation.docc/AdaNavigation.md index 419a298b7..b0b8aa928 100644 --- a/Sources/AdaNavigation/AdaNavigation.docc/AdaNavigation.md +++ b/Sources/AdaNavigation/AdaNavigation.docc/AdaNavigation.md @@ -149,7 +149,9 @@ controller on the character as usual. The host still owns mesh baking, loading a ## Playground demo -Run `swift run NavigationDemo` for a 3D playground with a robot and obstacles. +Set `ADAEXAMPLES_PATH` to a clone of [AdaExamples](https://github.com/AdaEngine/AdaExamples). + +Run `swift run --package-path "$ADAEXAMPLES_PATH/Examples/Technical/Navigation" NavigationDemo` for a 3D playground with a robot and obstacles. On Windows, use `./script/run_navigation_demo_windows.ps1`; it selects the checked-in AdaScript runtime and prepares the shader compiler automatically. Click the floor to choose a destination. Buttons cycle goals, toggle the mesh, @@ -161,7 +163,7 @@ floor switches to manual goals; the **Патруль AdaScript** button resumes `NavigationDemo --validate` runs the same fixture, file round trip, automatic requests and physics movement without creating a renderer. To build without GPU dependencies on Windows, use `$env:ADAENGINE_HEADLESS='1'` before -`swift run NavigationDemo --validate`. Clear that setting for the interactive demo. +`swift run --package-path "$ADAEXAMPLES_PATH/Examples/Technical/Navigation" NavigationDemo --validate`. Clear that setting for the interactive demo. `NavigationDemo --capture /absolute/path/playground.png` renders offscreen using the real GPU backend, checks viewport mouse picking, and saves the resulting frame. diff --git a/Tools/Android/README.md b/Tools/Android/README.md index 6c8a623e8..d60fa7c17 100644 --- a/Tools/Android/README.md +++ b/Tools/Android/README.md @@ -1,5 +1,7 @@ # Native Android apps +The Android demo is maintained in [AdaExamples](https://github.com/AdaEngine/AdaExamples). Set `ADAEXAMPLES_PATH` to that checkout when using the commands below. + AdaEngine can build a native Swift shared library, package it in a debug-signed APK, install it, and launch it on an authorized device or emulator. The host uses Android NativeActivity, native WebGPU (Swan + Dawn/Vulkan), Choreographer, and Swift Concurrency. @@ -64,16 +66,16 @@ From the AdaEngine repository root: ```sh python3 Tools/Android/android.py doctor python3 Tools/Android/android.py devices -python3 Tools/Android/android.py build -python3 Tools/Android/android.py run --serial emulator-5554 +python3 Tools/Android/android.py build --package "$ADAEXAMPLES_PATH/Examples/Technical/AndroidDemo" +python3 Tools/Android/android.py run --package "$ADAEXAMPLES_PATH/Examples/Technical/AndroidDemo" --serial emulator-5554 # Convenience wrapper; optionally reads task-local .build-android/local-env.sh -Tools/Android/run_demo.sh --serial emulator-5554 +Tools/Android/run_demo.sh --package "$ADAEXAMPLES_PATH/Examples/Technical/AndroidDemo" --serial emulator-5554 ``` `run` builds, installs, and starts the app. To reuse an APK: ```sh -python3 Tools/Android/android.py run \ +python3 Tools/Android/android.py run --package "$ADAEXAMPLES_PATH/Examples/Technical/AndroidDemo" \ --apk .build-android/apk/AndroidDemo-debug.apk --serial emulator-5554 ``` @@ -126,7 +128,7 @@ python3 /path/to/AdaEngine/Tools/Android/android.py run \ For the app target's own SwiftPM resources, pass `AndroidResourceBundle.bundle(named: "PackageName_TargetName")` as `assetBundle` -to `WindowGroup` or `DefaultAppWindow` on Android. See `Demos/AndroidDemo`. +to `WindowGroup` or `DefaultAppWindow` on Android. See [AndroidDemo](https://github.com/AdaEngine/AdaExamples/tree/main/Examples/Technical/AndroidDemo). The packaging tool extracts bundles from the APK into app-private storage before engine bootstrap; engine shaders/fonts use the same path. Writable data and caches also stay inside the app's private files directory. diff --git a/Tools/Android/android.py b/Tools/Android/android.py index ec8c49c92..c0a57391a 100644 --- a/Tools/Android/android.py +++ b/Tools/Android/android.py @@ -154,7 +154,8 @@ def compile_java(args, output, sdk, build_tools, env): dex=output/"dex" # Both directories are task-owned, under the selected APK output directory. for directory in (classes, dex): - if directory.exists(): shutil.rmtree(directory) + if directory.exists(): + shutil.rmtree(directory) directory.mkdir(parents=True) javac=Path(env["JAVA_HOME"])/"bin/javac" if env.get("JAVA_HOME") else Path(shutil.which("javac") or "javac") android_jar=tool(sdk/"platforms"/f"android-{args.target_sdk}"/"android.jar") @@ -322,7 +323,7 @@ def launch(args): def parser(): parser=argparse.ArgumentParser(description=__doc__) parser.add_argument("command",choices=["doctor","build","run","devices"]) - parser.add_argument("--package",default=str(ROOT)) + parser.add_argument("--package", help="Swift package to build; AndroidDemo lives in AdaExamples/Examples/Technical/AndroidDemo") parser.add_argument("--product",default="AndroidDemo") parser.add_argument("--application-id",default="org.adaengine.android.demo") parser.add_argument("--label",default="AdaEngine Android") @@ -348,11 +349,17 @@ def parser(): return parser def main(): - args=parser().parse_args() + cli=parser() + args=cli.parse_args() + if args.command in ("build", "run") and not args.apk and not args.package: + cli.error("--package is required to build; pass the AndroidDemo package in AdaExamples/Examples/Technical/AndroidDemo") try: - if args.command=="build": build(args) - elif args.command=="run": launch(args) - elif args.command=="devices": run([tool(sdk_path(args)/"platform-tools/adb"),"devices","-l"]) + if args.command=="build": + build(args) + elif args.command=="run": + launch(args) + elif args.command=="devices": + run([tool(sdk_path(args)/"platform-tools/adb"),"devices","-l"]) else: validate_options(args) env=build_env() @@ -361,7 +368,8 @@ def main(): print("Swift SDK:",swift_sdk_root(args)[1]) print("NDK:",ndk_host(ndk_path(args))) sdk=sdk_path(args) - for name in ["aapt2","zipalign","apksigner"]: print("Tool:",tool(sdk/"build-tools"/args.build_tools/name)) + for name in ["aapt2","zipalign","apksigner"]: + print("Tool:",tool(sdk/"build-tools"/args.build_tools/name)) print("Android platform:",tool(sdk/"platforms"/f"android-{args.target_sdk}"/"android.jar")) run([tool(sdk/"platform-tools/adb"),"devices","-l"]) except (AndroidError,OSError) as error: diff --git a/Tools/Android/tests/test_android.py b/Tools/Android/tests/test_android.py index 8f0fd1c75..77fc0b27a 100644 --- a/Tools/Android/tests/test_android.py +++ b/Tools/Android/tests/test_android.py @@ -1,5 +1,7 @@ import importlib.util from pathlib import Path +import subprocess +import sys import tempfile import unittest import xml.etree.ElementTree as ET @@ -10,17 +12,28 @@ spec.loader.exec_module(android) class AndroidPackagingTests(unittest.TestCase): + def test_build_requires_explicit_external_package(self): + for command in ("build", "run"): + result = subprocess.run([sys.executable, str(Path(android.__file__)), command], capture_output=True, text=True) + self.assertEqual(result.returncode, 2) + self.assertIn("--package is required", result.stderr) + def test_apk_preserves_every_manifest_asset_and_utf8_filename(self): with tempfile.TemporaryDirectory() as temporary: - root=Path(temporary); binary=root/"bin"; staging=root/"staging" - bundle=binary/"Game.resources"/"Assets"; bundle.mkdir(parents=True) + root=Path(temporary) + binary=root/"bin" + staging=root/"staging" + bundle=binary/"Game.resources"/"Assets" + bundle.mkdir(parents=True) (bundle/".gitkeep").touch() (bundle/"Текстура 🌈.png").write_bytes(bytes(range(256))) android.stage_resources(binary,staging/"assets") - library=staging/"lib/arm64-v8a/libGame.so"; library.parent.mkdir(parents=True) + library=staging/"lib/arm64-v8a/libGame.so" + library.parent.mkdir(parents=True) library.write_bytes(b"native runtime") apk=root/"unsigned.apk" - with zipfile.ZipFile(apk,"w") as archive: archive.writestr("AndroidManifest.xml",b"manifest") + with zipfile.ZipFile(apk,"w") as archive: + archive.writestr("AndroidManifest.xml",b"manifest") android.append_payload(apk,staging) with zipfile.ZipFile(apk) as archive: self.assertIsNone(archive.testzip()) @@ -33,12 +46,16 @@ def test_apk_preserves_every_manifest_asset_and_utf8_filename(self): def test_java_host_dex_and_utf8_resources_share_the_apk(self): with tempfile.TemporaryDirectory() as temporary: - root=Path(temporary); staging=root/"staging" + root=Path(temporary) + staging=root/"staging" asset=staging/"assets/bundles/Studio.resources/Привет.txt" - asset.parent.mkdir(parents=True); asset.write_bytes("🙂".encode("utf-8")) - dex=root/"classes.dex"; dex.write_bytes(b"dex\n035\0host") + asset.parent.mkdir(parents=True) + asset.write_bytes("🙂".encode("utf-8")) + dex=root/"classes.dex" + dex.write_bytes(b"dex\n035\0host") apk=root/"unsigned.apk" - with zipfile.ZipFile(apk,"w") as archive: archive.writestr("AndroidManifest.xml",b"manifest") + with zipfile.ZipFile(apk,"w") as archive: + archive.writestr("AndroidManifest.xml",b"manifest") android.append_payload(apk,staging,[dex]) with zipfile.ZipFile(apk) as archive: self.assertIsNone(archive.testzip()) @@ -47,8 +64,11 @@ def test_java_host_dex_and_utf8_resources_share_the_apk(self): def test_preserves_nested_resource_manifest_and_binary_font(self): with tempfile.TemporaryDirectory() as temporary: - root=Path(temporary); binary=root/"bin"; assets=root/"assets" - bundle=binary/"Game_GameNative.resources"/"Assets"; bundle.mkdir(parents=True) + root=Path(temporary) + binary=root/"bin" + assets=root/"assets" + bundle=binary/"Game_GameNative.resources"/"Assets" + bundle.mkdir(parents=True) (bundle/"manifest.txt").write_text("game manifest") (bundle/"font.ttf").write_bytes(bytes(range(256))) android.stage_resources(binary,assets) @@ -59,25 +79,33 @@ def test_preserves_nested_resource_manifest_and_binary_font(self): def test_second_abi_does_not_leave_files_deleted_from_bundle(self): with tempfile.TemporaryDirectory() as temporary: - root=Path(temporary); binary=root/"bin"; assets=root/"assets" - bundle=binary/"Game_GameNative.resources"; bundle.mkdir(parents=True) + root=Path(temporary) + binary=root/"bin" + assets=root/"assets" + bundle=binary/"Game_GameNative.resources" + bundle.mkdir(parents=True) (bundle/"old.txt").write_text("old") android.stage_resources(binary,assets) - (bundle/"old.txt").unlink(); (bundle/"new.txt").write_text("new") + (bundle/"old.txt").unlink() + (bundle/"new.txt").write_text("new") android.stage_resources(binary,assets) self.assertFalse((assets/"bundles/Game_GameNative.resources/old.txt").exists()) self.assertEqual((assets/"bundles/manifest.txt").read_text(),"Game_GameNative.resources/new.txt\n") def test_refuses_apk_without_resources(self): with tempfile.TemporaryDirectory() as temporary: - root=Path(temporary); (root/"bin").mkdir() - with self.assertRaises(android.AndroidError): android.stage_resources(root/"bin",root/"assets") + root=Path(temporary) + (root/"bin").mkdir() + with self.assertRaises(android.AndroidError): + android.stage_resources(root/"bin",root/"assets") def test_native_activity_manifest_has_exported_entry_and_no_dex_requirement(self): with tempfile.TemporaryDirectory() as temporary: args=android.parser().parse_args(["build","--label",'Game & "test"']) - path=Path(temporary)/"AndroidManifest.xml"; android.write_manifest(path,args) - manifest=ET.parse(path).getroot(); app=manifest.find("application") + path=Path(temporary)/"AndroidManifest.xml" + android.write_manifest(path,args) + manifest=ET.parse(path).getroot() + app=manifest.find("application") self.assertEqual(app.attrib[android.ANDROID+"hasCode"],"false") self.assertEqual(app.attrib[android.ANDROID+"label"],'Game & "test"') activity=app.find("activity") @@ -86,7 +114,8 @@ def test_native_activity_manifest_has_exported_entry_and_no_dex_requirement(self def test_rejects_incompatible_api_level_before_building(self): args=android.parser().parse_args(["build","--min-sdk","28"]) - with self.assertRaises(android.AndroidError): android.validate_options(args) + with self.assertRaises(android.AndroidError): + android.validate_options(args) def test_studio_manifest_enables_dex_and_excludes_keystore_backup(self): with tempfile.TemporaryDirectory() as temporary: @@ -117,13 +146,15 @@ def test_custom_activity_requires_qualified_class_name(self): def test_browser_callback_returns_to_the_existing_activity(self): with tempfile.TemporaryDirectory() as temporary: - source=Path(temporary)/"StudioActivity.java"; source.write_text("class StudioActivity {}") + source=Path(temporary)/"StudioActivity.java" + source.write_text("class StudioActivity {}") args=android.parser().parse_args([ "build", "--activity", "org.adaengine.studio.StudioActivity", "--java-source", str(source), "--callback-url", "adaeditor://cloud/callback" ]) android.validate_options(args) - path=Path(temporary)/"AndroidManifest.xml"; android.write_manifest(path,args) + path=Path(temporary)/"AndroidManifest.xml" + android.write_manifest(path,args) activity=ET.parse(path).getroot().find("application/activity") self.assertEqual(activity.attrib[android.ANDROID+"launchMode"],"singleTask") link=activity.findall("intent-filter")[1] @@ -137,7 +168,8 @@ def test_callback_without_a_java_host_is_rejected(self): with self.assertRaisesRegex(android.AndroidError,"Java host"): android.validate_options(args) -if __name__=="__main__": unittest.main() +if __name__=="__main__": + unittest.main() class AndroidWebGPUSetupTests(unittest.TestCase): def test_requires_explicit_android_dawn_setup(self): @@ -148,8 +180,10 @@ def test_requires_explicit_android_dawn_setup(self): def test_rejects_an_unbuilt_abi_before_swift_build(self): import json with tempfile.TemporaryDirectory() as temporary: - root=Path(temporary); (root/"Package.swift").write_text("// fixture") - bundle=root/"android.artifactbundle"; bundle.mkdir() + root=Path(temporary) + (root/"Package.swift").write_text("// fixture") + bundle=root/"android.artifactbundle" + bundle.mkdir() (bundle/"lib.a").write_bytes(b"library") (bundle/"info.json").write_text(json.dumps({"artifacts":{"dawn":{"variants":[ {"path":"lib.a","supportedTriples":["aarch64-unknown-linux-android29"]} @@ -164,7 +198,9 @@ def test_rejects_an_unbuilt_abi_before_swift_build(self): class AndroidSDKSelectionTests(unittest.TestCase): def test_export_staging_does_not_change_sdk_import_paths(self): with tempfile.TemporaryDirectory() as temporary: - root=Path(temporary); bundle=root/"sdk.artifactbundle/swift-android"; bundle.mkdir(parents=True) + root=Path(temporary) + bundle=root/"sdk.artifactbundle/swift-android" + bundle.mkdir(parents=True) first=android.parser().parse_args(["build","--scratch",str(root/"cache"),"--output",str(root/"stage-one")]) second=android.parser().parse_args(["build","--scratch",str(root/"cache"),"--output",str(root/"stage-two")]) self.assertEqual(android.select_swift_sdks(first,bundle),android.select_swift_sdks(second,bundle)) diff --git a/Tools/Tint/README.md b/Tools/Tint/README.md index 65b2c233e..0bd7b612f 100644 --- a/Tools/Tint/README.md +++ b/Tools/Tint/README.md @@ -45,4 +45,4 @@ GPU validation build cache. For direct WASM builds set both both for its nested build automatically. Browser/mobile runtime checks use the separate -[SpriteInstancingValidation](../../Demos/SpriteInstancingValidation/README.md) app. +[SpriteInstancingValidation](https://github.com/AdaEngine/AdaExamples/tree/main/Examples/Technical/SpriteInstancingValidation/README.md) app. diff --git a/script/build_and_run.sh b/script/build_and_run.sh index b1603620e..18550ab37 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash set -euo pipefail -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -exec "$REPO_ROOT/Demos/A2UIForm/script/build_and_run.sh" "$@" +: "${ADAEXAMPLES_PATH:?Set ADAEXAMPLES_PATH to an AdaExamples checkout}" +exec "$ADAEXAMPLES_PATH/Examples/Technical/A2UIForm/script/build_and_run.sh" "$@" diff --git a/script/run_navigation_demo_windows.ps1 b/script/run_navigation_demo_windows.ps1 index 4d504f090..553033581 100644 --- a/script/run_navigation_demo_windows.ps1 +++ b/script/run_navigation_demo_windows.ps1 @@ -1,40 +1,14 @@ -# Run the navigation playground with the same Windows AdaScript dependency used by Studio. +# Forward to the independently packaged navigation example. [CmdletBinding()] -param( - [switch]$ValidateScript, - [string]$CapturePath, - [string]$ScratchPath -) - +param([switch]$ValidateScript, [string]$CapturePath, [string]$ScratchPath, [string]$ExamplesRoot = $env:ADAEXAMPLES_PATH) $ErrorActionPreference = 'Stop' -$demoRoot = Split-Path -Parent $PSScriptRoot -$demoEnvironment = @{} -$demoVariables = @('ADAENGINE_HEADLESS', 'ADAENGINE_DISABLE_SWAN', 'ADAENGINE_GRAVITY_PACKAGE_PATH', 'TINT_EXECUTABLE') -foreach ($name in $demoVariables) { $demoEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') } +if (-not $ExamplesRoot) { throw 'Set ADAEXAMPLES_PATH or pass -ExamplesRoot for the AdaExamples checkout.' } +$launcher = Join-Path $ExamplesRoot 'Examples/Technical/Navigation/script/run_windows.ps1' +if (-not (Test-Path -LiteralPath $launcher -PathType Leaf)) { throw "Navigation example not found: $launcher" } +$previousEngineRoot = $env:ADAENGINE_ROOT try { - if (-not $env:ADAENGINE_GRAVITY_PACKAGE_PATH) { - $demoGravity = & python (Join-Path $PSScriptRoot 'ensure_windows_gravity.py') - if ($LASTEXITCODE -ne 0) { throw 'Windows AdaScript dependency preparation failed.' } - $env:ADAENGINE_GRAVITY_PACKAGE_PATH = ($demoGravity | Select-Object -Last 1).Trim() - } - $env:ADAENGINE_HEADLESS = if ($ValidateScript) { '1' } else { '0' } - if (-not $ValidateScript) { - $env:ADAENGINE_DISABLE_SWAN = '0' - if (-not $env:TINT_EXECUTABLE) { - $demoTint = & python (Join-Path $PSScriptRoot 'ensure_tint.py') --jobs 2 - if ($LASTEXITCODE -ne 0) { throw 'Tint preparation failed.' } - $env:TINT_EXECUTABLE = ($demoTint | Select-Object -Last 1).Trim() - } - } - $demoArguments = @('run', '--package-path', $demoRoot) - if ($ScratchPath) { $demoArguments += @('--scratch-path', $ScratchPath) } - $demoArguments += 'NavigationDemo' - if ($ValidateScript) { $demoArguments += '--validate-script' } - elseif ($CapturePath) { - $demoArguments += @('--capture', $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($CapturePath)) - } - & swift @demoArguments - if ($LASTEXITCODE -ne 0) { throw "Navigation demo failed with exit code $LASTEXITCODE." } + $env:ADAENGINE_ROOT = Split-Path -Parent $PSScriptRoot + & $launcher -ValidateScript:$ValidateScript -CapturePath $CapturePath -ScratchPath $ScratchPath } finally { - foreach ($name in $demoVariables) { [Environment]::SetEnvironmentVariable($name, $demoEnvironment[$name], 'Process') } + $env:ADAENGINE_ROOT = $previousEngineRoot } diff --git a/script/webgpu_validation_package.py b/script/webgpu_validation_package.py index 5ef52175f..2e6da0c50 100644 --- a/script/webgpu_validation_package.py +++ b/script/webgpu_validation_package.py @@ -2,6 +2,7 @@ """Stage focused production test sources without building unrelated demo/editor targets.""" import argparse import json +import os from pathlib import Path import shutil @@ -9,7 +10,14 @@ parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("destination", type=Path) parser.add_argument("--include-sponza-demo", action="store_true", help="Build the Sponza demo and camera tests with the same renderer objects") +parser.add_argument("--examples-root", type=Path, default=os.environ.get("ADAEXAMPLES_PATH"), help="AdaExamples checkout for optional Sponza validation") args = parser.parse_args() +if args.include_sponza_demo: + if args.examples_root is None: + parser.error("--include-sponza-demo requires --examples-root or ADAEXAMPLES_PATH pointing to AdaExamples") + sponza_source = args.examples_root.resolve() / "Examples/Technical/Sponza" + if not (sponza_source / "SponzaDemo.swift").is_file(): + parser.error("The examples checkout does not contain Examples/Technical/Sponza") destination = args.destination.resolve() sources = destination / "Tests/RenderingValidationTests" sources.mkdir(parents=True, exist_ok=True) @@ -109,20 +117,21 @@ dependencies = ',\n'.join('.product(name: ' + json.dumps(module) + ', package: "AdaEngine")' for module in modules) extra_targets = "" if args.include_sponza_demo: - demo = destination / "Demos/Sponza" - camera = destination / "Demos/SponzaCamera" + demo = destination / "Examples/Sponza" + camera = destination / "Examples/SponzaCamera" demo.mkdir(parents=True, exist_ok=True) camera.mkdir(parents=True, exist_ok=True) for name in ["SponzaDemo.swift", "SponzaDemoProfiler.swift", "SponzaLighting.swift", "SponzaStutterProfiler.swift", "SponzaWarmup.swift"]: - shutil.copy2(root / "Demos/Sponza" / name, demo / name) - for source in (root / "Demos/Sponza/Camera").glob("*.swift"): + shutil.copy2(sponza_source / name, demo / name) + shutil.copytree(sponza_source / "Assets", demo / "Assets", dirs_exist_ok=True) + for source in (sponza_source / "Camera").glob("*.swift"): shutil.copy2(source, camera / source.name) - shutil.copy2(root / "Demos/Sponza/Tests/SponzaCameraTests.swift", sources / "SponzaCameraTests.swift") + shutil.copy2(sponza_source / "Tests/SponzaCameraTests.swift", sources / "SponzaCameraTests.swift") dependencies += ',\n"SponzaCamera"' extra_targets = ''', .target(name: "SponzaCamera", dependencies: [ - .product(name: "AdaTransform", package: "AdaEngine"), .product(name: "Math", package: "AdaEngine")], path: "Demos/SponzaCamera"), + .product(name: "AdaTransform", package: "AdaEngine"), .product(name: "Math", package: "AdaEngine")], path: "Examples/SponzaCamera"), .executableTarget(name: "SponzaDemo", dependencies: ["SponzaCamera", .product(name: "AdaEngine", package: "AdaEngine"), - .product(name: "Math", package: "AdaEngine")], path: "Demos/Sponza")''' + .product(name: "Math", package: "AdaEngine")], path: "Examples/Sponza", resources: [.copy("Assets")])''' (destination / "Package.swift").write_text('''// swift-tools-version: 6.2 import PackageDescription let package = Package(name: "RenderingValidation", platforms: [.macOS(.v15)], dependencies: [