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 camera = _cameraController.Active;
|
||||||
var viewport = new System.Numerics.Vector2((float)_window.Size.X, (float)_window.Size.Y);
|
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(
|
var picked = AcDream.Core.Selection.WorldPicker.Pick(
|
||||||
mouseX: _lastMouseX, mouseY: _lastMouseY,
|
mouseX: _lastMouseX, mouseY: _lastMouseY,
|
||||||
view: camera.View, projection: camera.Projection,
|
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
|
// Match the indicator's TriangleSize (8 px) so the click area
|
||||||
// extends out to the bracket corners — what the user perceives
|
// extends out to the bracket corners — what the user perceives
|
||||||
// as "selectable extent."
|
// 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)
|
if (picked is uint guid)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,15 @@ public sealed class PhysicsDataCache
|
||||||
public int SetupCount => _setup.Count;
|
public int SetupCount => _setup.Count;
|
||||||
public int CellStructCount => _cellStruct.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>
|
/// <summary>
|
||||||
/// Register a pre-built <see cref="GfxObjPhysics"/> directly.
|
/// Register a pre-built <see cref="GfxObjPhysics"/> directly.
|
||||||
/// Intended for unit-test fixtures that construct synthetic BSP trees
|
/// Intended for unit-test fixtures that construct synthetic BSP trees
|
||||||
|
|
@ -285,9 +294,15 @@ public sealed class SetupPhysics
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CellPhysics
|
public sealed class CellPhysics
|
||||||
{
|
{
|
||||||
public required PhysicsBSPTree BSP { get; init; }
|
/// <summary>
|
||||||
public required Dictionary<ushort, Polygon> PhysicsPolygons { get; init; }
|
/// The physics BSP tree for this cell. Nullable so that test fixtures
|
||||||
public required VertexArray Vertices { get; init; }
|
/// 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 WorldTransform { get; init; }
|
||||||
public Matrix4x4 InverseWorldTransform { 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,
|
uint skipServerGuid,
|
||||||
float maxDistance = 50f,
|
float maxDistance = 50f,
|
||||||
Func<uint, float>? radiusForGuid = null,
|
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 DefaultRadius = 1.0f;
|
||||||
const float DefaultVerticalOffset = 0.9f;
|
const float DefaultVerticalOffset = 0.9f;
|
||||||
|
|
||||||
if (direction.LengthSquared() < 1e-10f) return null;
|
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;
|
uint? bestGuid = null;
|
||||||
float bestT = float.PositiveInfinity;
|
float bestT = float.PositiveInfinity;
|
||||||
foreach (var entity in candidates)
|
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) t = -b + sqrtD; // origin inside sphere -> use far exit
|
||||||
if (t < 0f) continue; // both roots negative -> sphere entirely behind ray
|
if (t < 0f) continue; // both roots negative -> sphere entirely behind ray
|
||||||
if (t >= maxDistance) continue;
|
if (t >= maxDistance) continue;
|
||||||
|
if (t >= wallT) continue; // wall is between camera and entity (#86)
|
||||||
if (t < bestT)
|
if (t < bestT)
|
||||||
{
|
{
|
||||||
bestT = t;
|
bestT = t;
|
||||||
|
|
@ -207,11 +215,39 @@ public static class WorldPicker
|
||||||
IEnumerable<WorldEntity> candidates,
|
IEnumerable<WorldEntity> candidates,
|
||||||
uint skipServerGuid,
|
uint skipServerGuid,
|
||||||
Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
|
Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
|
||||||
float inflatePixels = 8f)
|
float inflatePixels = 8f,
|
||||||
|
Func<Vector3, Vector3, float>? cellOccluder = null)
|
||||||
{
|
{
|
||||||
uint? bestGuid = null;
|
uint? bestGuid = null;
|
||||||
float bestDepth = float.PositiveInfinity;
|
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)
|
foreach (var entity in candidates)
|
||||||
{
|
{
|
||||||
if (entity.ServerGuid == 0u) continue;
|
if (entity.ServerGuid == 0u) continue;
|
||||||
|
|
@ -237,6 +273,8 @@ public static class WorldPicker
|
||||||
if (mouseX < minX || mouseX > maxX) continue;
|
if (mouseX < minX || mouseX > maxX) continue;
|
||||||
if (mouseY < minY || mouseY > maxY) continue;
|
if (mouseY < minY || mouseY > maxY) continue;
|
||||||
|
|
||||||
|
if (depth > wallDepth) continue; // wall is between camera and entity (#86)
|
||||||
|
|
||||||
if (depth < bestDepth)
|
if (depth < bestDepth)
|
||||||
{
|
{
|
||||||
bestDepth = depth;
|
bestDepth = depth;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
|
using DatReaderWriter.Enums;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Selection;
|
||||||
|
|
||||||
|
public class CellBspRayOccluderTests
|
||||||
|
{
|
||||||
|
// Build a CellPhysics with a single triangular poly at world-Y=10.
|
||||||
|
// Triangle vertices in local space, world transform = identity.
|
||||||
|
// Uses the Resolved-only constructor path (BSP = null is allowed after Phase 1 relaxation).
|
||||||
|
private static CellPhysics MakeWallCell()
|
||||||
|
{
|
||||||
|
var verts = new[]
|
||||||
|
{
|
||||||
|
new Vector3(-5, 10, 0),
|
||||||
|
new Vector3( 5, 10, 0),
|
||||||
|
new Vector3( 0, 10, 5),
|
||||||
|
};
|
||||||
|
var poly = new ResolvedPolygon
|
||||||
|
{
|
||||||
|
Vertices = verts,
|
||||||
|
Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f),
|
||||||
|
NumPoints = 3,
|
||||||
|
SidesType = CullMode.None,
|
||||||
|
};
|
||||||
|
return new CellPhysics
|
||||||
|
{
|
||||||
|
BSP = null, // Occluder doesn't use BSP — direct poly iteration.
|
||||||
|
Resolved = new() { [0] = poly },
|
||||||
|
WorldTransform = Matrix4x4.Identity,
|
||||||
|
InverseWorldTransform = Matrix4x4.Identity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NearestWallT_RayHitsTriangle_ReturnsHitDistance()
|
||||||
|
{
|
||||||
|
var cell = MakeWallCell();
|
||||||
|
var origin = new Vector3(0, 0, 1);
|
||||||
|
var direction = Vector3.UnitY; // travels +Y toward the wall at Y=10
|
||||||
|
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
|
||||||
|
Assert.True(t > 9.9f && t < 10.1f, $"expected ~10, got {t}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NearestWallT_RayMisses_ReturnsPositiveInfinity()
|
||||||
|
{
|
||||||
|
var cell = MakeWallCell();
|
||||||
|
var origin = new Vector3(0, 0, 1);
|
||||||
|
var direction = -Vector3.UnitY; // travels AWAY from the wall
|
||||||
|
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
|
||||||
|
Assert.True(float.IsPositiveInfinity(t), $"expected +inf, got {t}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NearestWallT_EmptyCellList_ReturnsPositiveInfinity()
|
||||||
|
{
|
||||||
|
var origin = Vector3.Zero;
|
||||||
|
var direction = Vector3.UnitY;
|
||||||
|
float t = CellBspRayOccluder.NearestWallT(origin, direction, System.Array.Empty<CellPhysics>());
|
||||||
|
Assert.True(float.IsPositiveInfinity(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NearestWallT_TwoCells_ReturnsNearer()
|
||||||
|
{
|
||||||
|
var nearCell = MakeWallCell(); // wall at Y=10
|
||||||
|
var farCell = MakeWallCell();
|
||||||
|
// Move farCell's transform to push it to Y=20.
|
||||||
|
farCell = new CellPhysics
|
||||||
|
{
|
||||||
|
BSP = null,
|
||||||
|
Resolved = nearCell.Resolved,
|
||||||
|
WorldTransform = Matrix4x4.CreateTranslation(0, 10, 0),
|
||||||
|
InverseWorldTransform = Matrix4x4.CreateTranslation(0, -10, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
var origin = new Vector3(0, 0, 1);
|
||||||
|
var direction = Vector3.UnitY;
|
||||||
|
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { farCell, nearCell });
|
||||||
|
Assert.True(t < 11f, $"expected near-cell hit ~10, got {t}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
using DatReaderWriter.Enums;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Selection;
|
||||||
|
|
||||||
|
public class WorldPickerCellOcclusionTests
|
||||||
|
{
|
||||||
|
private static CellPhysics MakeWallAtY10()
|
||||||
|
{
|
||||||
|
// A quad wall at Y=10 spanning X=-5..5, Z=-5..5 (local space = world space
|
||||||
|
// because WorldTransform = Identity). The occluder triangulates it as a fan:
|
||||||
|
// tri0 = [0,1,2], tri1 = [0,2,3]. A ray travelling +Y from Y=0 hits it at t≈10.
|
||||||
|
var verts = new[]
|
||||||
|
{
|
||||||
|
new Vector3(-5, 10, -5),
|
||||||
|
new Vector3( 5, 10, -5),
|
||||||
|
new Vector3( 5, 10, 5),
|
||||||
|
new Vector3(-5, 10, 5),
|
||||||
|
};
|
||||||
|
var poly = new ResolvedPolygon
|
||||||
|
{
|
||||||
|
Vertices = verts,
|
||||||
|
Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f),
|
||||||
|
NumPoints = 4,
|
||||||
|
SidesType = CullMode.None,
|
||||||
|
};
|
||||||
|
return new CellPhysics
|
||||||
|
{
|
||||||
|
BSP = null,
|
||||||
|
Resolved = new() { [0] = poly },
|
||||||
|
WorldTransform = Matrix4x4.Identity,
|
||||||
|
InverseWorldTransform = Matrix4x4.Identity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorldEntity MakeEntity(uint guid, Vector3 pos) => new()
|
||||||
|
{
|
||||||
|
Id = guid,
|
||||||
|
ServerGuid = guid,
|
||||||
|
SourceGfxObjOrSetupId = 0,
|
||||||
|
Position = pos,
|
||||||
|
Rotation = Quaternion.Identity,
|
||||||
|
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pick_RaySphere_EntityBehindWall_OccludedByCellBsp()
|
||||||
|
{
|
||||||
|
var wall = MakeWallAtY10();
|
||||||
|
var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0)); // entity at Y=20, wall at Y=10
|
||||||
|
|
||||||
|
var result = WorldPicker.Pick(
|
||||||
|
origin: Vector3.Zero,
|
||||||
|
direction: Vector3.UnitY,
|
||||||
|
candidates: new[] { entity },
|
||||||
|
skipServerGuid: 0u,
|
||||||
|
cellOccluder: (origin, direction) =>
|
||||||
|
CellBspRayOccluder.NearestWallT(origin, direction, new[] { wall }));
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pick_RaySphere_NoWall_HitsEntity()
|
||||||
|
{
|
||||||
|
var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0));
|
||||||
|
|
||||||
|
var result = WorldPicker.Pick(
|
||||||
|
origin: Vector3.Zero,
|
||||||
|
direction: Vector3.UnitY,
|
||||||
|
candidates: new[] { entity },
|
||||||
|
skipServerGuid: 0u,
|
||||||
|
cellOccluder: null); // null occluder = no occlusion
|
||||||
|
|
||||||
|
Assert.Equal(0xABCDu, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue