From 891811f53c348f70d01c666ed564d1db2e63f95f Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Tue, 11 Aug 2026 17:46:00 +0200 Subject: [PATCH 1/2] [Feature] Add PhysicsQuery raycast facade with octree fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR D of the physics plugin readiness plan (discussion #1116), completing phase 1: - PhysicsQuery.raycast(_:filter:) routes to the active backend when it reports the .raycast capability — a capable backend's answer, hit or miss, is authoritative. With no backend (or one without the capability) it falls back to a documented best-effort query against the octree of entity bounds: nearest AABB hit with face normal, excludedEntities always honored, layerMask tested against RigidBodyComponent.layer as a layer index (bodyless entities pass as layer 0), inside-the-box rays report distance 0 with the normal pointing back along the ray. - Shapecast/overlap stay capability-sketched only, per the plan. - docs/Extensions/CreatingAPhysicsBackendPlugin.md: the backend-author guide — manifest/validation, coordinator-driven lifecycle, threading and batch-transfer contracts, events, queries, and the license policy pointer — so a third party can write a backend without reading engine source. 9 new tests: fallback nearest-hit/surface data, direction normalization, maxDistance, excluded entities, layer masking, inside- box rays, miss, and both routing directions (capable backend authoritative, incapable backend never asked). --- .../UntoldEngine/Physics/PhysicsQuery.swift | 136 +++++++++++ .../UntoldEngineTests/PhysicsQueryTests.swift | 224 ++++++++++++++++++ .../CreatingAPhysicsBackendPlugin.md | 143 +++++++++++ mkdocs.yml | 1 + 4 files changed, 504 insertions(+) create mode 100644 Sources/UntoldEngine/Physics/PhysicsQuery.swift create mode 100644 Tests/UntoldEngineTests/PhysicsQueryTests.swift create mode 100644 docs/Extensions/CreatingAPhysicsBackendPlugin.md diff --git a/Sources/UntoldEngine/Physics/PhysicsQuery.swift b/Sources/UntoldEngine/Physics/PhysicsQuery.swift new file mode 100644 index 000000000..b27598bad --- /dev/null +++ b/Sources/UntoldEngine/Physics/PhysicsQuery.swift @@ -0,0 +1,136 @@ +// +// PhysicsQuery.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import simd + +/// Scene queries against the physics world. +/// +/// `raycast` routes to the active `PhysicsBackend` when it reports the +/// `.raycast` capability, so results come from real collider geometry. With no +/// backend installed — or a backend without the capability — it falls back to a +/// best-effort query against the engine's octree of entity bounds. +/// +/// Fallback semantics (documented contract, intentionally approximate): +/// - Hits are entity **AABBs**, not collider shapes; `position`/`normal` are on +/// the box surface (the normal is the hit face's axis direction). A ray that +/// starts inside a box reports the hit at the ray origin with `distance` 0 +/// and the normal pointing back along the ray. +/// - `filter.excludedEntities` is always honored. `filter.layerMask` is tested +/// against `RigidBodyComponent.layer` interpreted as a layer *index* (bit +/// `1 << layer` in the mask); entities without a `RigidBodyComponent` are +/// treated as layer 0, and layers ≥ 32 cannot be expressed in the mask and +/// always pass. +/// - Only entities registered with `OctreeSystem` (those with render bounds) +/// are considered. +/// +/// Shapecast and overlap queries are deliberately not exposed yet; the +/// capability bits exist so backends can declare them ahead of phase 2. +public enum PhysicsQuery { + /// Longest segment the octree fallback searches when the ray's + /// `maxDistance` is unbounded — comfortably beyond the octree's world + /// bounds, while keeping the query AABB finite. + static let fallbackUnboundedDistance: Float = 1.0e6 + + public static func raycast( + _ ray: PhysicsRay, + filter: PhysicsQueryFilter = PhysicsQueryFilter() + ) -> PhysicsRayHit? { + if let backend = PhysicsBackendRegistry.shared.activeBackend(), + backend.capabilities.contains(.raycast) + { + return backend.raycast(ray, filter: filter) + } + return octreeRaycast(ray, filter: filter) + } + + // MARK: - Octree fallback + + static func octreeRaycast( + _ ray: PhysicsRay, + filter: PhysicsQueryFilter + ) -> PhysicsRayHit? { + let directionLength = simd_length(ray.direction) + guard directionLength > .ulpOfOne else { return nil } + let direction = ray.direction / directionLength + + let maxDistance = ray.maxDistance.isFinite + ? ray.maxDistance + : fallbackUnboundedDistance + guard maxDistance > 0 else { return nil } + + // Gather candidates whose bounds overlap the ray segment's AABB + // (padded so axis-aligned rays produce a non-degenerate box). + let end = ray.origin + direction * maxDistance + let padding = simd_float3(repeating: 1.0e-3) + let segmentBounds = AABB( + min: simd_min(ray.origin, end) - padding, + max: simd_max(ray.origin, end) + padding + ) + + var best: PhysicsRayHit? + for entity in OctreeSystem.shared.query(range: segmentBounds) { + guard passesFilter(entity, filter), + let bounds = OctreeSystem.shared.getBounds(for: entity) + else { continue } + + var tmin: Float = 0 + guard rayIntersectsAABB( + rayOrigin: ray.origin, + rayDir: direction, + boxMin: bounds.min, + boxMax: bounds.max, + tmin: &tmin + ) else { continue } + + let distance = max(0.0, tmin) + guard distance <= maxDistance else { continue } + if let currentBest = best, currentBest.distance <= distance { continue } + + let position = ray.origin + direction * distance + let normal = distance > 0 + ? boxFaceNormal(at: position, bounds: bounds) + : -direction + best = PhysicsRayHit( + entity: entity, + position: position, + normal: normal, + distance: distance + ) + } + return best + } + + private static func passesFilter(_ entity: EntityID, _ filter: PhysicsQueryFilter) -> Bool { + if filter.excludedEntities.contains(entity) { return false } + guard filter.layerMask != .max else { return true } + + let layer = scene.get(component: RigidBodyComponent.self, for: entity)?.layer ?? 0 + guard layer < 32 else { return true } + return (filter.layerMask >> layer) & 1 != 0 + } + + /// Axis-aligned face normal of the box face nearest to a surface point. + private static func boxFaceNormal(at point: simd_float3, bounds: AABB) -> simd_float3 { + let halfExtents = simd_max((bounds.max - bounds.min) * 0.5, simd_float3(repeating: .ulpOfOne)) + let local = (point - bounds.center) / halfExtents + + var normal = simd_float3(local.x < 0 ? -1 : 1, 0, 0) + var strongest = abs(local.x) + if abs(local.y) > strongest { + strongest = abs(local.y) + normal = simd_float3(0, local.y < 0 ? -1 : 1, 0) + } + if abs(local.z) > strongest { + normal = simd_float3(0, 0, local.z < 0 ? -1 : 1) + } + return normal + } +} diff --git a/Tests/UntoldEngineTests/PhysicsQueryTests.swift b/Tests/UntoldEngineTests/PhysicsQueryTests.swift new file mode 100644 index 000000000..f46f7e4a5 --- /dev/null +++ b/Tests/UntoldEngineTests/PhysicsQueryTests.swift @@ -0,0 +1,224 @@ +// +// PhysicsQueryTests.swift +// UntoldEngineTests +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +private final class RaycastPhysicsBackend: PhysicsBackend, @unchecked Sendable { + let id: String + let capabilities: PhysicsCapabilities + + var cannedHit: PhysicsRayHit? + private(set) var raycastCallCount = 0 + + init(id: String = "com.example.raycastphysics", capabilities: PhysicsCapabilities) { + self.id = id + self.capabilities = capabilities + } + + func configure(_: PhysicsWorldConfiguration) {} + + func step(deltaTime _: Float) {} + + func raycast(_: PhysicsRay, filter _: PhysicsQueryFilter) -> PhysicsRayHit? { + raycastCallCount += 1 + return cannedHit + } +} + +private struct RaycastBackendPlugin: PhysicsBackendPlugin { + let manifest: PhysicsBackendPluginManifest + let backend: RaycastPhysicsBackend + + init(pluginID: String = "com.example.raycastphysics", capabilities: PhysicsCapabilities) { + manifest = PhysicsBackendPluginManifest( + id: pluginID, + displayName: "Raycast Physics", + version: PhysicsBackendVersion(major: 1, minor: 0, patch: 0), + requiredAPIVersion: .current + ) + backend = RaycastPhysicsBackend(id: pluginID, capabilities: capabilities) + } + + func makeBackend() -> any PhysicsBackend { + backend + } +} + +@MainActor +final class PhysicsQueryTests: XCTestCase { + override func setUp() async throws { + resetEngineTestState() + } + + override func tearDown() { + PhysicsBackendRegistry.shared.resetForTesting() + super.tearDown() + } + + /// Creates an entity with world-space AABB `center ± halfExtents`, + /// registered with the octree the same way renderable entities are. + private func makeObstacle( + center: simd_float3, + halfExtents: simd_float3 = simd_float3(repeating: 0.5) + ) -> EntityID { + let entityId = createEntity() + scene.get(component: LocalTransformComponent.self, for: entityId)?.boundingBox = + (min: -halfExtents, max: halfExtents) + if let worldTransform = scene.get(component: WorldTransformComponent.self, for: entityId) { + var space = matrix_identity_float4x4 + space.columns.3 = simd_float4(center.x, center.y, center.z, 1.0) + worldTransform.space = space + } + OctreeSystem.shared.registerEntity(entityId) + return entityId + } + + // MARK: - Octree fallback + + func testFallbackReturnsNearestHitWithSurfaceData() { + let near = makeObstacle(center: simd_float3(0.0, 0.0, -5.0)) + _ = makeObstacle(center: simd_float3(0.0, 0.0, -10.0)) + + let hit = PhysicsQuery.raycast( + PhysicsRay(origin: .zero, direction: simd_float3(0.0, 0.0, -1.0)) + ) + + XCTAssertEqual(hit?.entity, near) + XCTAssertEqual(hit?.distance ?? 0, 4.5, accuracy: 1.0e-4) + XCTAssertEqual(hit?.position.z ?? 0, -4.5, accuracy: 1.0e-4) + XCTAssertEqual(hit?.normal, simd_float3(0.0, 0.0, 1.0)) + } + + func testFallbackNormalizesDirectionAndReportsWorldDistance() { + _ = makeObstacle(center: simd_float3(0.0, 0.0, -5.0)) + + let hit = PhysicsQuery.raycast( + PhysicsRay(origin: .zero, direction: simd_float3(0.0, 0.0, -8.0)) + ) + + XCTAssertEqual(hit?.distance ?? 0, 4.5, accuracy: 1.0e-4) + } + + func testFallbackHonorsMaxDistance() { + _ = makeObstacle(center: simd_float3(0.0, 0.0, -5.0)) + + let hit = PhysicsQuery.raycast( + PhysicsRay(origin: .zero, direction: simd_float3(0.0, 0.0, -1.0), maxDistance: 3.0) + ) + + XCTAssertNil(hit) + } + + func testFallbackHonorsExcludedEntities() { + let near = makeObstacle(center: simd_float3(0.0, 0.0, -5.0)) + let far = makeObstacle(center: simd_float3(0.0, 0.0, -10.0)) + + let hit = PhysicsQuery.raycast( + PhysicsRay(origin: .zero, direction: simd_float3(0.0, 0.0, -1.0)), + filter: PhysicsQueryFilter(excludedEntities: [near]) + ) + + XCTAssertEqual(hit?.entity, far) + XCTAssertEqual(hit?.distance ?? 0, 9.5, accuracy: 1.0e-4) + } + + func testFallbackAppliesLayerMaskToBodiesAndPassesBodylessEntities() { + let near = makeObstacle(center: simd_float3(0.0, 0.0, -5.0)) + registerComponent(entityId: near, componentType: RigidBodyComponent.self) + scene.get(component: RigidBodyComponent.self, for: near)?.layer = 1 + let far = makeObstacle(center: simd_float3(0.0, 0.0, -10.0)) + + // Mask selects only layer 0: the layer-1 body is skipped, the bodyless + // far entity (treated as layer 0) is hit. + let maskedHit = PhysicsQuery.raycast( + PhysicsRay(origin: .zero, direction: simd_float3(0.0, 0.0, -1.0)), + filter: PhysicsQueryFilter(layerMask: 1 << 0) + ) + XCTAssertEqual(maskedHit?.entity, far) + + // The default all-layers mask hits the near body. + let openHit = PhysicsQuery.raycast( + PhysicsRay(origin: .zero, direction: simd_float3(0.0, 0.0, -1.0)) + ) + XCTAssertEqual(openHit?.entity, near) + } + + func testFallbackRayStartingInsideBoxHitsAtOrigin() { + let box = makeObstacle(center: simd_float3(0.0, 0.0, -0.2), halfExtents: simd_float3(repeating: 1.0)) + + let direction = simd_float3(0.0, 0.0, -1.0) + let hit = PhysicsQuery.raycast(PhysicsRay(origin: .zero, direction: direction)) + + XCTAssertEqual(hit?.entity, box) + XCTAssertEqual(hit?.distance, 0.0) + XCTAssertEqual(hit?.position, .zero) + XCTAssertEqual(hit?.normal, -direction) + } + + func testFallbackMissReturnsNil() { + _ = makeObstacle(center: simd_float3(0.0, 10.0, -5.0)) + + let hit = PhysicsQuery.raycast( + PhysicsRay(origin: .zero, direction: simd_float3(0.0, 0.0, -1.0)) + ) + + XCTAssertNil(hit) + } + + // MARK: - Backend routing + + func testBackendWithRaycastCapabilityIsAuthoritative() { + _ = makeObstacle(center: simd_float3(0.0, 0.0, -5.0)) + + let plugin = RaycastBackendPlugin(capabilities: [.raycast]) + XCTAssertEqual(PhysicsBackendRegistry.shared.install(plugin), .installed) + plugin.backend.cannedHit = PhysicsRayHit( + entity: 999, + position: simd_float3(0.0, 0.0, -2.0), + normal: simd_float3(0.0, 0.0, 1.0), + distance: 2.0 + ) + + let hit = PhysicsQuery.raycast( + PhysicsRay(origin: .zero, direction: simd_float3(0.0, 0.0, -1.0)) + ) + + XCTAssertEqual(plugin.backend.raycastCallCount, 1) + XCTAssertEqual(hit?.entity, 999, "A capable backend's answer wins over the octree") + + // A capable backend's miss is also authoritative — no octree fallback. + plugin.backend.cannedHit = nil + XCTAssertNil(PhysicsQuery.raycast( + PhysicsRay(origin: .zero, direction: simd_float3(0.0, 0.0, -1.0)) + )) + } + + func testBackendWithoutRaycastCapabilityFallsBackToOctree() { + let obstacle = makeObstacle(center: simd_float3(0.0, 0.0, -5.0)) + + let plugin = RaycastBackendPlugin(capabilities: []) + XCTAssertEqual(PhysicsBackendRegistry.shared.install(plugin), .installed) + plugin.backend.cannedHit = PhysicsRayHit( + entity: 999, + position: .zero, + normal: simd_float3(0.0, 1.0, 0.0), + distance: 1.0 + ) + + let hit = PhysicsQuery.raycast( + PhysicsRay(origin: .zero, direction: simd_float3(0.0, 0.0, -1.0)) + ) + + XCTAssertEqual(plugin.backend.raycastCallCount, 0, "Backends without .raycast are never asked") + XCTAssertEqual(hit?.entity, obstacle) + } +} diff --git a/docs/Extensions/CreatingAPhysicsBackendPlugin.md b/docs/Extensions/CreatingAPhysicsBackendPlugin.md new file mode 100644 index 000000000..3048b30ee --- /dev/null +++ b/docs/Extensions/CreatingAPhysicsBackendPlugin.md @@ -0,0 +1,143 @@ +# Creating a Physics Backend Plugin + +A physics backend replaces the engine's numerical simulation for entities that +opt in, while everything else — components, events, queries, the frame loop — +stays engine-owned. This guide covers everything needed to implement one in an +external Swift package, without reading engine source. The engine keeps zero +native dependencies: heavyweight physics libraries (Jolt, PhysX, …) live in +your plugin package, never in core. + +For the general plugin philosophy (namespacing, licensing, review expectations) +read [Plugin Authoring Guidelines](PluginAuthoringGuidelines.md) first — +notably: optional native libraries and binary frameworks belong in the plugin +repository, and dependency licenses should be MIT/BSD/Zlib/Apache-2.0. + +## The moving parts + +| Engine type | Role | +|---|---| +| `PhysicsBackend` | The protocol your simulation implements | +| `PhysicsBackendPlugin` + `PhysicsBackendPluginManifest` | Identity, versioning, validation | +| `PhysicsBackendRegistry` | Single-slot install/uninstall with rollback | +| `ColliderComponent`, `RigidBodyComponent` | Engine-owned ECS vocabulary — never define your own | +| `PhysicsCoordinator` | Engine-side driver; you never call it directly | +| `PhysicsEventSink` | Where your buffered events go each substep | +| `PhysicsQuery.raycast` | Routed to your backend when you report `.raycast` | + +## Minimal skeleton + +```swift +import UntoldEngine + +final class MyBackend: PhysicsBackend { + let id = "com.example.myphysics.backend" + let capabilities: PhysicsCapabilities = [.collisions, .raycast] + + func configure(_ config: PhysicsWorldConfiguration) { /* gravity, layers */ } + func didAddBody(entity: EntityID, descriptor: PhysicsBodyDescriptor) { /* create body */ } + func didRemoveBody(entity: EntityID) { /* destroy body */ } + func step(deltaTime: Float) { /* advance simulation one fixed substep */ } + func drainEvents(into sink: any PhysicsEventSink) { /* hand over buffered events */ } + func writeKinematicTargets(_ batch: PhysicsBodyWriteBatch) { /* engine → you */ } + func readActiveTransforms(into batch: PhysicsTransformReadBatch) -> Int { /* you → engine */ } + func raycast(_ ray: PhysicsRay, filter: PhysicsQueryFilter) -> PhysicsRayHit? { /* query */ } +} + +struct MyBackendPlugin: PhysicsBackendPlugin { + let manifest = PhysicsBackendPluginManifest( + id: "com.example.myphysics", + displayName: "My Physics", + version: PhysicsBackendVersion(major: 1, minor: 0, patch: 0), + requiredAPIVersion: .current + ) + + func makeBackend() -> any PhysicsBackend { MyBackend() } +} +``` + +Every method except `configure` and `step` has a default no-op implementation, +so a minimal backend implements only what it supports. Calls gated on a +capability you don't declare are defined no-ops. + +Install before creating the renderer: + +```swift +switch PhysicsBackendRegistry.shared.install(MyBackendPlugin()) { +case .installed, .replaced: break +case let .rejected(failure): print(failure) // validation errors, conflict, or lock +} +``` + +Validation requires a reverse-DNS namespaced plugin ID, a backend ID inside the +plugin's namespace, and an exact `requiredAPIVersion` match. One external +backend is active at a time: installing under the same ID replaces it, a +different ID while one is installed is rejected. The registry **locks on the +first simulated substep** — install/uninstall after that are rejected for the +rest of the run. + +## What the engine does with your backend + +Installing schedules the engine's `PhysicsCoordinator` (an `EngineExtension`) +into the fixed-timestep loop; uninstalling removes it. Once per fixed substep, +after the built-in integrator, the coordinator: + +1. **Diffs the body set.** Entities carrying `RigidBodyComponent` + + `ColliderComponent` + `LocalTransformComponent` are yours. New ones arrive + via `didAddBody` with a `PhysicsBodyDescriptor` snapshot (shape, mass, + layer/mask, gravity scale, initial pose and velocities); entities that lost + those components or were destroyed arrive via `didRemoveBody`. +2. **Writes kinematic targets** — one `PhysicsBodyWriteBatch` with parallel + entity/transform buffers for every kinematic body. +3. **Calls `step(deltaTime:)`.** +4. **Reads transforms back** — you fill the `PhysicsTransformReadBatch` with + your *active* bodies (sleeping bodies can be skipped) and return the count. + The engine applies them to dynamic bodies' `LocalTransformComponent` and + marks the scene graph dirty. +5. **Drains events** via `drainEvents(into:)`. + +The built-in integrator keeps running for legacy `PhysicsComponents`/ +`KineticComponent` entities regardless — both can coexist in one scene, and +your backend never sees them. + +## Contracts to honor + +- **Threading.** Every protocol method is called on the engine's frame thread. + Parallelize internally all you like, but callbacks from your worker threads + must never reach the engine: buffer events into fixed-capacity storage during + `step` and hand them over only in `drainEvents`. Report overflow through + `reportDroppedEvents(count:)` — never allocate or throw mid-step. +- **Batch-only transforms.** Transform exchange happens through the two batch + calls, one contiguous buffer per direction per substep. Never per-body calls. + The buffers are valid only for the duration of the call. +- **Units.** Metres/kilograms/seconds, Y-up, quaternion orientations — matching + `PhysicsWorldConfiguration`, whose `collisionLayerMatrix[layer]` is the bit + mask of layers that `layer` collides with (empty = everything collides). + +## Events + +Deliver `PhysicsContactEvent` (began/persisted/ended), +`PhysicsTriggerEvent` (entered/exited) and `PhysicsBodyActivationEvent` to the +sink during `drainEvents`. The engine fans them out to `PhysicsEvents` +subscribers and fires USC script events (`OnCollision`, `OnTriggerEnter`, +`OnTriggerExit`) — you only produce the events; delivery order is your +delivery order. + +## Queries + +Declare `.raycast` and `PhysicsQuery.raycast(_:filter:)` routes to your +`raycast` implementation — your answer (including a miss) is authoritative. +Without the capability, the engine answers from its octree of entity bounds +instead. Honor `PhysicsQueryFilter`: skip `excludedEntities`, and test +`layerMask` against each body's layer. Shapecast and overlap capability bits +exist but are not yet exposed through `PhysicsQuery`; declaring them today is +harmless and future-proof. + +## Testing without an engine run + +The engine's own suites (`PhysicsBackendRegistryTests`, +`PhysicsCoordinatorTests`, `PhysicsEventsTests`, `PhysicsQueryTests`) exercise +every seam above with mock backends — they double as reference implementations +for the protocol's expected behavior. A useful pattern for your package's +tests: install your plugin, drive `PhysicsCoordinator.shared.fixedUpdate` +manually, and assert on your backend's recorded calls, exactly as those suites +do. diff --git a/mkdocs.yml b/mkdocs.yml index bcd7af873..f5289e11c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -103,6 +103,7 @@ nav: - Plugin Authoring Guidelines: Extensions/PluginAuthoringGuidelines.md - Creating a Rendering Extension Plugin: Extensions/CreatingRenderingExtensionPlugin.md - Creating an Engine Extension Plugin: Extensions/CreatingAnEngineExtensionPlugin.md + - Creating a Physics Backend Plugin: Extensions/CreatingAPhysicsBackendPlugin.md - Contributing: - Guidelines: Contributor/ContributionGuidelines.md - Versioning: Contributor/versioning.md From 93700cd593db888918d5e96424e71302642c381a Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Thu, 13 Aug 2026 08:57:59 +0200 Subject: [PATCH 2/2] [Patch] Address review: octree ray query broad phase and real maxDistance cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The fallback now gathers candidates through OctreeSystem.query(rayOrigin:rayDirection:maxDistance:) — tree-pruned and distance-sorted — instead of an oversized segment AABB fed to the generic range query. The sorted broad-phase distance never undercuts the final hit distance (it equals it for outside-origin rays and upper-bounds the reported 0 for inside-origin rays), so the scan early-exits once it passes the best hit. The narrow phase still recomputes the entry distance, preserving the documented inside-the-box contract (hit at origin, distance 0). - maxDistance is capped with min(_, 1e6) instead of an .isFinite test: PhysicsRay's unbounded sentinel is .greatestFiniteMagnitude, which is finite, so the previous branch never fired. - Docs: the Queries section now states the thread contract — PhysicsQuery.raycast is frame-thread-only, like the backend method it routes to. --- .../UntoldEngine/Physics/PhysicsQuery.swift | 36 ++++++++++--------- .../CreatingAPhysicsBackendPlugin.md | 4 +++ 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/Sources/UntoldEngine/Physics/PhysicsQuery.swift b/Sources/UntoldEngine/Physics/PhysicsQuery.swift index b27598bad..da9d6203d 100644 --- a/Sources/UntoldEngine/Physics/PhysicsQuery.swift +++ b/Sources/UntoldEngine/Physics/PhysicsQuery.swift @@ -34,9 +34,10 @@ import simd /// Shapecast and overlap queries are deliberately not exposed yet; the /// capability bits exist so backends can declare them ahead of phase 2. public enum PhysicsQuery { - /// Longest segment the octree fallback searches when the ray's - /// `maxDistance` is unbounded — comfortably beyond the octree's world - /// bounds, while keeping the query AABB finite. + /// Longest segment the octree fallback searches. `PhysicsRay`'s + /// "unbounded" sentinel is `.greatestFiniteMagnitude` — which is finite — + /// so the cap is applied with `min`, not a finiteness test; 1e6 m is + /// comfortably beyond the octree's world bounds. static let fallbackUnboundedDistance: Float = 1.0e6 public static func raycast( @@ -61,26 +62,29 @@ public enum PhysicsQuery { guard directionLength > .ulpOfOne else { return nil } let direction = ray.direction / directionLength - let maxDistance = ray.maxDistance.isFinite - ? ray.maxDistance - : fallbackUnboundedDistance + let maxDistance = min(ray.maxDistance, fallbackUnboundedDistance) guard maxDistance > 0 else { return nil } - // Gather candidates whose bounds overlap the ray segment's AABB - // (padded so axis-aligned rays produce a non-degenerate box). - let end = ray.origin + direction * maxDistance - let padding = simd_float3(repeating: 1.0e-3) - let segmentBounds = AABB( - min: simd_min(ray.origin, end) - padding, - max: simd_max(ray.origin, end) + padding - ) - + // Tree-pruned broad phase: candidates arrive sorted by their ray-AABB + // distance. For a ray starting outside a box that distance equals the + // hit distance below; for a ray starting inside it is the exit + // distance, an upper bound of the reported 0 — so in both cases it + // never undercuts the final distance, and the scan can stop as soon + // as the sorted distance passes the best hit found. var best: PhysicsRayHit? - for entity in OctreeSystem.shared.query(range: segmentBounds) { + for (entity, sortedDistance) in OctreeSystem.shared.query( + rayOrigin: ray.origin, + rayDirection: direction, + maxDistance: maxDistance + ) { + if let currentBest = best, sortedDistance >= currentBest.distance { break } guard passesFilter(entity, filter), let bounds = OctreeSystem.shared.getBounds(for: entity) else { continue } + // Narrow phase recomputes the entry distance: the broad-phase + // value is the exit distance for inside-the-box origins, where + // the documented contract reports the hit at the origin instead. var tmin: Float = 0 guard rayIntersectsAABB( rayOrigin: ray.origin, diff --git a/docs/Extensions/CreatingAPhysicsBackendPlugin.md b/docs/Extensions/CreatingAPhysicsBackendPlugin.md index 3048b30ee..92e193586 100644 --- a/docs/Extensions/CreatingAPhysicsBackendPlugin.md +++ b/docs/Extensions/CreatingAPhysicsBackendPlugin.md @@ -126,6 +126,10 @@ delivery order. Declare `.raycast` and `PhysicsQuery.raycast(_:filter:)` routes to your `raycast` implementation — your answer (including a miss) is authoritative. +Like every other backend method, `raycast` is called on the frame thread, and +`PhysicsQuery.raycast` itself must only be called from there (game update +code, engine extensions, USC actions — not from your worker threads or +arbitrary dispatch queues). Without the capability, the engine answers from its octree of entity bounds instead. Honor `PhysicsQueryFilter`: skip `excludedEntities`, and test `layerMask` against each body's layer. Shapecast and overlap capability bits