Skip to content

[Feature] Add PhysicsQuery raycast facade with octree fallback - #1140

Open
miogds wants to merge 2 commits into
untoldengine:developfrom
miolabs:feature/physics_raycast-upstream
Open

[Feature] Add PhysicsQuery raycast facade with octree fallback#1140
miogds wants to merge 2 commits into
untoldengine:developfrom
miolabs:feature/physics_raycast-upstream

Conversation

@miogds

@miogds miogds commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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 .raycast capability, 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; excludedEntities always honored; layerMask tested against RigidBodyComponent.layer as 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.)
  • 9 unit tests: fallback nearest-hit with surface data, direction normalization, 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.

ScenePickingSystem is 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 pass
  • PhysicsCoordinatorTests + PhysicsBackendRegistryTests — 30/30 pass
  • Full UntoldEngineTests module: 1093 tests, no new failures (only the known env-dependent ExternalRenderExtensionPackageTests)
  • Pinned SwiftFormat 0.60.1 lint — clean

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
miogds requested a review from untoldengine as a code owner August 11, 2026 15:49
@untoldengine

Copy link
Copy Markdown
Owner

@miogds
Hey Javier — reviewed this one, really solid work overall. A few notes before I approve:

Two things worth fixing, not blockers:

Issue 1 — fallbackUnboundedDistance never actually triggers.
(Sources/UntoldEngine/Physics/PhysicsQuery.swift:64-66):

let maxDistance = ray.maxDistance.isFinite ? ray.maxDistance : fallbackUnboundedDistance // line 40: static let fallbackUnboundedDistance: Float = 1.0e6

PhysicsRay's own default (Sources/UntoldEngine/Physics/PhysicsBackend.swift:244) is maxDistance: Float = .greatestFiniteMagnitude, and Float.greatestFiniteMagnitude.isFinite == true — so the "unbounded" branch never fires for the sentinel every caller will actually hit by default. Harmless today (Float's range absorbs the huge segment box, no NaN/crash), but the 1e6 cap documented at line 40 ("comfortably beyond the octree's world bounds") never applies in practice — a diagonal ray with default maxDistance builds a segment box on the order of 1e38 instead of 1e6. Suggest min(ray.maxDistance, fallbackUnboundedDistance) instead of the .isFinite check.

Issue 2 — octreeRaycast reimplements a broad/narrow-phase search that OctreeSystem already has.
PhysicsQuery.swift:56-115. OctreeSystem.query(rayOrigin:rayDirection:maxDistance:) at Sources/UntoldEngine/Systems/OctreeSystem.swift:172-181 already does tree-pruned, per-node ray-AABB early-outs and returns results pre-sorted by distance. This PR instead builds an oversized bounding-box AABB (PhysicsQuery.swift:77-82), calls the generic query(range:) broad-phase (line 85), then narrow-phase-tests every candidate by hand (lines 85-113). For diagonal rays in busier scenes that's a meaningfully worse broad-phase pass than what's already sitting on the same class you're calling getBounds on (line 81). Not a 1:1 drop-in — the existing method doesn't return position/normal — but it'd simplify candidate-gathering and sidestep the maxDistance issue above for free.

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.

@miogds

miogds commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

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.
@miogds

miogds commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Done in 93700cd:

  • maxDistance cap: switched to min(ray.maxDistance, fallbackUnboundedDistance) — you're right that the .isFinite branch was dead, since PhysicsRay's sentinel is .greatestFiniteMagnitude.
  • Broad phase: candidate gathering now goes through OctreeSystem.query(rayOrigin:rayDirection:maxDistance:). One deliberate detail: its per-candidate distance is the exit distance for rays starting inside a box (rayAABBIntersectionDistance returns tMin >= 0 ? tMin : tMax), while the facade's documented contract reports that case at the origin with distance 0 — so the narrow phase still recomputes the entry distance. Since the sorted broad-phase distance never undercuts the final one, the scan early-exits as soon as it passes the best hit.
  • Docs: the Queries section now states PhysicsQuery.raycast (and the backend's raycast) are frame-thread-only, matching the contracts section above it.

9/9 PhysicsQueryTests green, lint clean.

rayDirection: direction,
maxDistance: maxDistance
) {
if let currentBest = best, sortedDistance >= currentBest.distance { break }

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let me review this case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants