fix(picker): Cluster A #86 — cell-BSP ray occlusion in WorldPicker
WorldPicker.Pick previously had no occlusion test — any entity along the click ray within maxDistance was a candidate, including ones behind walls. Adds the CellBspRayOccluder static helper that Möller-Trumbore-tests the click ray against every polygon in every currently-cached EnvCell BSP, returning the nearest wall-hit `t`. Both Pick overloads gate candidate selection by that wall-t (legacy ray-sphere via world-space `t`, screen-rect via camera-space clip.W depth — matching ScreenProjection.TryProjectSphereToScreenRect's convention). PhysicsDataCache exposes a new CellStructIds snapshot accessor so the caller can iterate without needing the private cache dictionary. CellPhysics.BSP/PhysicsPolygons/Vertices relaxed from required to nullable so test fixtures can construct a CellPhysics from Resolved alone without a real DAT BSP object. GameWindow snapshots the loaded cell physics on each Pick call and passes the occluder callback. Closes #86. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
27d7de11d8
commit
3764867566
6 changed files with 355 additions and 6 deletions
|
|
@ -9131,6 +9131,17 @@ public sealed class GameWindow : IDisposable
|
|||
var camera = _cameraController.Active;
|
||||
var viewport = new System.Numerics.Vector2((float)_window.Size.X, (float)_window.Size.Y);
|
||||
|
||||
// Indoor walking Phase 1 #86 (2026-05-19): snapshot the currently-
|
||||
// cached EnvCell physics so the picker can occlude entities behind
|
||||
// walls. Snapshot is per-pick (one click), iteration is bounded
|
||||
// by the streaming radius (~80 cells at radius 4).
|
||||
var loadedCellPhysics = new List<AcDream.Core.Physics.CellPhysics>();
|
||||
foreach (var cellId in _physicsDataCache.CellStructIds)
|
||||
{
|
||||
var cp = _physicsDataCache.GetCellStruct(cellId);
|
||||
if (cp is not null) loadedCellPhysics.Add(cp);
|
||||
}
|
||||
|
||||
var picked = AcDream.Core.Selection.WorldPicker.Pick(
|
||||
mouseX: _lastMouseX, mouseY: _lastMouseY,
|
||||
view: camera.View, projection: camera.Projection,
|
||||
|
|
@ -9153,7 +9164,11 @@ public sealed class GameWindow : IDisposable
|
|||
// Match the indicator's TriangleSize (8 px) so the click area
|
||||
// extends out to the bracket corners — what the user perceives
|
||||
// as "selectable extent."
|
||||
inflatePixels: 8f);
|
||||
inflatePixels: 8f,
|
||||
cellOccluder: loadedCellPhysics.Count > 0
|
||||
? (origin, direction) =>
|
||||
AcDream.Core.Selection.CellBspRayOccluder.NearestWallT(origin, direction, loadedCellPhysics)
|
||||
: null);
|
||||
|
||||
if (picked is uint guid)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -210,6 +210,15 @@ public sealed class PhysicsDataCache
|
|||
public int SetupCount => _setup.Count;
|
||||
public int CellStructCount => _cellStruct.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached
|
||||
/// EnvCell ids — used by <see cref="AcDream.Core.Selection.WorldPicker"/>
|
||||
/// to enumerate occluder candidates without exposing the underlying
|
||||
/// dictionary. Returns the live key-set; callers should snapshot the
|
||||
/// collection if they need stability across frames.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<uint> CellStructIds => (IReadOnlyCollection<uint>)_cellStruct.Keys;
|
||||
|
||||
/// <summary>
|
||||
/// Register a pre-built <see cref="GfxObjPhysics"/> directly.
|
||||
/// Intended for unit-test fixtures that construct synthetic BSP trees
|
||||
|
|
@ -285,9 +294,15 @@ public sealed class SetupPhysics
|
|||
/// </summary>
|
||||
public sealed class CellPhysics
|
||||
{
|
||||
public required PhysicsBSPTree BSP { get; init; }
|
||||
public required Dictionary<ushort, Polygon> PhysicsPolygons { get; init; }
|
||||
public required VertexArray Vertices { get; init; }
|
||||
/// <summary>
|
||||
/// The physics BSP tree for this cell. Nullable so that test fixtures
|
||||
/// can construct a <see cref="CellPhysics"/> from <see cref="Resolved"/>
|
||||
/// alone without needing a real DAT BSP object. Production code must
|
||||
/// null-check before traversal: <c>cell.BSP?.Root is not null</c>.
|
||||
/// </summary>
|
||||
public PhysicsBSPTree? BSP { get; init; }
|
||||
public Dictionary<ushort, Polygon>? PhysicsPolygons { get; init; }
|
||||
public VertexArray? Vertices { get; init; }
|
||||
public Matrix4x4 WorldTransform { get; init; }
|
||||
public Matrix4x4 InverseWorldTransform { get; init; }
|
||||
|
||||
|
|
|
|||
114
src/AcDream.Core/Selection/CellBspRayOccluder.cs
Normal file
114
src/AcDream.Core/Selection/CellBspRayOccluder.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Selection;
|
||||
|
||||
/// <summary>
|
||||
/// Indoor walking Phase 1 (2026-05-19). Pure ray-vs-cell-BSP-polygon
|
||||
/// occlusion test. Given a ray and a set of <see cref="CellPhysics"/>
|
||||
/// (currently-loaded EnvCells with resolved polygon planes), returns
|
||||
/// the nearest world-space <c>t</c> along the ray that hits any cell
|
||||
/// polygon — or <see cref="float.PositiveInfinity"/> if the ray clears
|
||||
/// all cells.
|
||||
///
|
||||
/// <para>
|
||||
/// Used by <see cref="WorldPicker.Pick"/> to filter entities that sit
|
||||
/// behind a wall from the camera's POV (issue #86). Möller-Trumbore
|
||||
/// ray-triangle intersection; one test per triangle. Cells are
|
||||
/// transformed via their <see cref="CellPhysics.InverseWorldTransform"/>
|
||||
/// so the ray runs in cell-local space and the resolved-polygon
|
||||
/// vertices don't need re-transformation per query.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// No BSP traversal — iterates every polygon in every cell. Cell count
|
||||
/// in a Holtburg-radius-4 streaming window is ~80 cells × ~50 polys
|
||||
/// each = ~4K triangles. Möller-Trumbore is ~40 ns per triangle on
|
||||
/// modern hardware; one <c>Pick</c> call is well under 1 ms.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CellBspRayOccluder
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the nearest positive <c>t</c> such that
|
||||
/// <c>origin + t * direction</c> intersects a polygon in any cell.
|
||||
/// Returns <see cref="float.PositiveInfinity"/> if no cell polygon
|
||||
/// is intersected.
|
||||
/// </summary>
|
||||
/// <param name="direction">Need not be normalized; returned <c>t</c>
|
||||
/// scales with direction length the same as a parametric ray.</param>
|
||||
public static float NearestWallT(
|
||||
Vector3 origin,
|
||||
Vector3 direction,
|
||||
IEnumerable<CellPhysics> loadedCells)
|
||||
{
|
||||
if (loadedCells is null) return float.PositiveInfinity;
|
||||
|
||||
float bestT = float.PositiveInfinity;
|
||||
foreach (var cell in loadedCells)
|
||||
{
|
||||
if (cell?.Resolved is null) continue;
|
||||
|
||||
// Bring the ray into cell-local space ONCE per cell.
|
||||
var localOrigin = Vector3.Transform(origin, cell.InverseWorldTransform);
|
||||
var localDirection = Vector3.TransformNormal(direction, cell.InverseWorldTransform);
|
||||
|
||||
foreach (var (_, poly) in cell.Resolved)
|
||||
{
|
||||
// Triangulate the (possibly polygonal) face into a fan.
|
||||
int n = poly.NumPoints;
|
||||
if (n < 3 || poly.Vertices is null || poly.Vertices.Length < n)
|
||||
continue;
|
||||
|
||||
for (int i = 1; i < n - 1; i++)
|
||||
{
|
||||
if (TryRayTriangle(
|
||||
localOrigin, localDirection,
|
||||
poly.Vertices[0], poly.Vertices[i], poly.Vertices[i + 1],
|
||||
out var t)
|
||||
&& t < bestT)
|
||||
{
|
||||
bestT = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestT;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Möller-Trumbore ray-triangle intersection. Returns true with
|
||||
/// <c>t</c> in <paramref name="t"/> if the ray hits the triangle
|
||||
/// at a positive distance.
|
||||
/// </summary>
|
||||
private static bool TryRayTriangle(
|
||||
Vector3 origin, Vector3 direction,
|
||||
Vector3 v0, Vector3 v1, Vector3 v2,
|
||||
out float t)
|
||||
{
|
||||
const float Epsilon = 1e-7f;
|
||||
|
||||
var edge1 = v1 - v0;
|
||||
var edge2 = v2 - v0;
|
||||
var pvec = Vector3.Cross(direction, edge2);
|
||||
float det = Vector3.Dot(edge1, pvec);
|
||||
|
||||
// No two-sided handling here — picker should be permissive so
|
||||
// a wall blocks regardless of which side the camera is on.
|
||||
if (det > -Epsilon && det < Epsilon) { t = 0f; return false; }
|
||||
float invDet = 1f / det;
|
||||
|
||||
var tvec = origin - v0;
|
||||
float u = Vector3.Dot(tvec, pvec) * invDet;
|
||||
if (u < 0f || u > 1f) { t = 0f; return false; }
|
||||
|
||||
var qvec = Vector3.Cross(tvec, edge1);
|
||||
float v = Vector3.Dot(direction, qvec) * invDet;
|
||||
if (v < 0f || u + v > 1f) { t = 0f; return false; }
|
||||
|
||||
t = Vector3.Dot(edge2, qvec) * invDet;
|
||||
return t > Epsilon;
|
||||
}
|
||||
}
|
||||
|
|
@ -91,13 +91,20 @@ public static class WorldPicker
|
|||
uint skipServerGuid,
|
||||
float maxDistance = 50f,
|
||||
Func<uint, float>? radiusForGuid = null,
|
||||
Func<uint, float>? verticalOffsetForGuid = null)
|
||||
Func<uint, float>? verticalOffsetForGuid = null,
|
||||
Func<Vector3, Vector3, float>? cellOccluder = null)
|
||||
{
|
||||
const float DefaultRadius = 1.0f;
|
||||
const float DefaultVerticalOffset = 0.9f;
|
||||
|
||||
if (direction.LengthSquared() < 1e-10f) return null;
|
||||
|
||||
// Indoor walking Phase 1 #86 (2026-05-19): if the caller provides
|
||||
// a cell-BSP occluder, query the nearest wall hit along the ray
|
||||
// ONCE; entities whose ray-t exceeds the wall-t sit behind a wall
|
||||
// and are skipped.
|
||||
float wallT = cellOccluder?.Invoke(origin, direction) ?? float.PositiveInfinity;
|
||||
|
||||
uint? bestGuid = null;
|
||||
float bestT = float.PositiveInfinity;
|
||||
foreach (var entity in candidates)
|
||||
|
|
@ -150,6 +157,7 @@ public static class WorldPicker
|
|||
if (t < 0f) t = -b + sqrtD; // origin inside sphere -> use far exit
|
||||
if (t < 0f) continue; // both roots negative -> sphere entirely behind ray
|
||||
if (t >= maxDistance) continue;
|
||||
if (t >= wallT) continue; // wall is between camera and entity (#86)
|
||||
if (t < bestT)
|
||||
{
|
||||
bestT = t;
|
||||
|
|
@ -207,11 +215,39 @@ public static class WorldPicker
|
|||
IEnumerable<WorldEntity> candidates,
|
||||
uint skipServerGuid,
|
||||
Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
|
||||
float inflatePixels = 8f)
|
||||
float inflatePixels = 8f,
|
||||
Func<Vector3, Vector3, float>? cellOccluder = null)
|
||||
{
|
||||
uint? bestGuid = null;
|
||||
float bestDepth = float.PositiveInfinity;
|
||||
|
||||
// Indoor walking Phase 1 #86 (2026-05-19): cell-BSP occlusion.
|
||||
// Build the click ray, query the nearest wall along it, convert
|
||||
// to the same camera-space depth metric (clip.W) that
|
||||
// ScreenProjection.TryProjectSphereToScreenRect returns per
|
||||
// candidate. Candidates with depth > wallDepth sit behind a wall.
|
||||
float wallDepth = float.PositiveInfinity;
|
||||
if (cellOccluder is not null)
|
||||
{
|
||||
var (rayOrigin, rayDir) = BuildRay(mouseX, mouseY, viewport.X, viewport.Y, view, projection);
|
||||
if (rayDir.LengthSquared() > 0f)
|
||||
{
|
||||
float wallT = cellOccluder(rayOrigin, rayDir);
|
||||
if (!float.IsPositiveInfinity(wallT))
|
||||
{
|
||||
var wallPoint = rayOrigin + rayDir * wallT;
|
||||
// ScreenProjection uses clip.W as its depth metric —
|
||||
// "camera-space depth" in the row-vector convention is
|
||||
// the W component of the homogeneous clip-space vector,
|
||||
// which equals the eye-space Z distance to the point.
|
||||
var viewProj = view * projection;
|
||||
var clip = Vector4.Transform(new Vector4(wallPoint, 1f), viewProj);
|
||||
if (clip.W > 0f)
|
||||
wallDepth = clip.W;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entity in candidates)
|
||||
{
|
||||
if (entity.ServerGuid == 0u) continue;
|
||||
|
|
@ -237,6 +273,8 @@ public static class WorldPicker
|
|||
if (mouseX < minX || mouseX > maxX) continue;
|
||||
if (mouseY < minY || mouseY > maxY) continue;
|
||||
|
||||
if (depth > wallDepth) continue; // wall is between camera and entity (#86)
|
||||
|
||||
if (depth < bestDepth)
|
||||
{
|
||||
bestDepth = depth;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue