[Feature] Add PhysicsQuery raycast facade with octree fallback - #1140
[Feature] Add PhysicsQuery raycast facade with octree fallback#1140miogds wants to merge 2 commits into
Conversation
PR D of the physics plugin readiness plan (discussion untoldengine#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).
|
@miogds Two things worth fixing, not blockers: Issue 1 — fallbackUnboundedDistance never actually triggers.
PhysicsRay's own default (Sources/UntoldEngine/Physics/PhysicsBackend.swift:244) is Issue 2 — octreeRaycast reimplements a broad/narrow-phase search that OctreeSystem already has. Minor doc gap: docs/Extensions/CreatingAPhysicsBackendPlugin.md's "Queries" section (line 125) doesn't say what thread raycast is safe to call from — worth a line, since the "Contracts to honor" section right above it (line 102) is explicit about frame-thread-only contracts for everything else. |
|
I'll do the changes. |
…ance cap - 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.
|
Done in 93700cd:
9/9 |
| rayDirection: direction, | ||
| maxDistance: maxDistance | ||
| ) { | ||
| if let currentBest = best, sortedDistance >= currentBest.distance { break } |
There was a problem hiding this comment.
Hey, I think there's a bug in this early exit. The comment says sortedDistance never undercuts the true distance — that's correct, but it actually works against us here. That makes it an upper bound, not a lower bound, and we need a lower bound to safely break out of a sorted scan early.
Here's the case that breaks: if the ray starts inside a big box, its sortedDistance is its exit distance (which can be huge), even though the true hit distance is 0. So it can end up sorted behind some small box further down the ray. Once that small box becomes the best hit, we break before ever looking at the big box — even though the big box is actually the correct nearest hit.
I tested it locally to be sure:
let bigBox = makeObstacle(
center: simd_float3(0, 0, -50),
halfExtents: simd_float3(50, 50, 100)
) // contains ray origin
let smallBox = makeObstacle(
center: simd_float3(0, 0, -50),
halfExtents: simd_float3(0.5, 0.5, 0.5)
) // further down the ray
let hit = PhysicsQuery.raycast(
PhysicsRay(
origin: .zero,
direction: simd_float3(0, 0, -1)
)
)
// expected: bigBox, distance 0
// got: smallBox, distance 49.5
testFallbackRayStartingInsideBoxHitsAtOrigin doesn't catch this because it only has one entity in the scene, so the early exit never kicks in.
Would you mind either dropping the early exit, or only applying it once we know the candidate is outside-origin, where sortedDistance actually equals the true distance?
There was a problem hiding this comment.
let me review this case.
Summary
PR D of the narrowed phase-1 plan from discussion #1116 — the last of the four (after #1123, #1129, #1139). Independent of #1139: it builds only on the merged interface and coordinator, so the two can review/merge in either order.
Sources/UntoldEngine/Physics/PhysicsQuery.swift— the single query facade for phase 1:PhysicsQuery.raycast(_:filter:). When the active backend reports the.raycastcapability, its answer is authoritative — hit or miss — so results come from real collider geometry. With no backend installed, or a backend without the capability, it answers from a documented best-effort fallback over the engine's octree of entity bounds: nearest AABB hit with the hit face's axis normal;excludedEntitiesalways honored;layerMasktested againstRigidBodyComponent.layeras a layer index with bodyless entities passing as layer 0; a ray starting inside a box reports distance 0 at the origin with the normal pointing back along the ray. Shapecast/overlap remain capability bits only, per the plan — declared today, exposed in phase 2.docs/Extensions/CreatingAPhysicsBackendPlugin.md(+ mkdocs nav) — the backend-author guide, closing the phase-1 acceptance criterion that a third party can write a backend without reading engine source: manifest/validation rules, the coordinator-driven per-substep lifecycle, threading and batch-transfer contracts, events, queries, and a pointer to the license policy in the contribution guidelines. (The short events paragraph describes the delivery added in [Feature] Deliver physics backend events to subscribers and USC scripts #1139; the backend-facing sink contract it documents is from [Feature] Add physics backend plugin interface #1123 and stands alone.)maxDistance, excluded entities, layer masking (body vs bodyless), inside-the-box rays, misses, and both routing directions — a capable backend is asked and trusted, an incapable one is never asked.ScenePickingSystemis untouched, per the plan. This completes phase 1: the Jolt backend can now be built as an external package against the public API alone.Test plan
swift test --filter PhysicsQueryTests— 9/9 passPhysicsCoordinatorTests+PhysicsBackendRegistryTests— 30/30 passUntoldEngineTestsmodule: 1093 tests, no new failures (only the known env-dependentExternalRenderExtensionPackageTests)