feat(selection): port retail polygon picking and vivid marker

Replace the projected Setup-sphere rectangle and independent physics-wall ray with retail's render-coupled picker: only visible server-object parts participate, each exact drawing sphere broad-phases the camera-eye ray, and first-in-DAT-order visual polygon hits globally outrank sphere fallbacks.

Replace the devtools-only procedural triangles with the retained gameplay VividTargetIndicator using retail client-enum surfaces 1..4, radar-blip colorization, Setup selection-sphere framing, and the exact eight-pixel viewport clamp.

Release build succeeds with zero warnings and all 5,886 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 21:32:51 +02:00
parent 0f82a08f0a
commit 146a963aeb
26 changed files with 1302 additions and 1340 deletions

View file

@ -1,114 +0,0 @@
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;
}
}

View file

@ -0,0 +1,32 @@
using System.Numerics;
namespace AcDream.Core.Selection;
/// <summary>
/// Immutable CPU geometry used by retail's world-selection pass. One instance
/// represents one GfxObj part: its exact drawing-BSP root sphere and its visual
/// polygons in DAT order.
/// </summary>
public sealed record RetailSelectionMesh(
Vector3 SphereCenter,
float SphereRadius,
IReadOnlyList<RetailSelectionPolygon> Polygons);
/// <summary>One visual polygon. Vertex order and one/two-sidedness are DAT-authored.</summary>
public sealed record RetailSelectionPolygon(
IReadOnlyList<Vector3> Vertices,
bool SingleSided);
/// <summary>One part which survived the normal world-render visibility traversal.</summary>
public readonly record struct RetailSelectionPart(
uint ServerGuid,
int PartIndex,
Matrix4x4 LocalToWorld,
RetailSelectionMesh Mesh);
/// <summary>Retail picker result, including which physics part supplied the hit.</summary>
public readonly record struct RetailSelectionHit(
uint ServerGuid,
int PartIndex,
double Distance,
bool PolygonHit);

View file

@ -0,0 +1,181 @@
using System.Numerics;
namespace AcDream.Core.Selection;
/// <summary>
/// Pure port of retail's render-coupled mouse selection accumulator.
/// <c>Render::GfxObjUnderSelectionRay @ 0x0054C740</c> broad-phases each
/// visible part against its drawing sphere, then scans visual polygons in DAT
/// order. Any polygon hit globally outranks every sphere-only fallback.
/// </summary>
public static class RetailWorldPicker
{
private const double RetailRayEpsilon = 0.0002;
public static RetailSelectionHit? Pick(
Vector3 worldOrigin,
Vector3 worldDirection,
IEnumerable<RetailSelectionPart> visibleParts,
uint skipServerGuid = 0u)
{
if (worldDirection.LengthSquared() < 1e-10f)
return null;
RetailSelectionHit? closestSphere = null;
RetailSelectionHit? closestPolygon = null;
foreach (var part in visibleParts)
{
if (part.ServerGuid == 0u || part.ServerGuid == skipServerGuid)
continue;
if (part.Mesh.SphereRadius <= 0f
|| !Matrix4x4.Invert(part.LocalToWorld, out var worldToLocal))
continue;
// Keep direction unnormalised after the affine inverse. With row-vector
// transforms this preserves the same ray parameter t in world metres even
// when the part carries scale (retail divides by gfxobj_scale likewise).
Vector3 localOrigin = Vector3.Transform(worldOrigin, worldToLocal);
Vector3 localDirection = Vector3.TransformNormal(worldDirection, worldToLocal);
if (!TryIntersectSphere(
localOrigin,
localDirection,
part.Mesh.SphereCenter,
part.Mesh.SphereRadius,
out double sphereT))
continue;
// Retail skips a part whose broad sphere starts beyond an already-found
// polygon, because that part cannot improve the global polygon winner.
if (closestPolygon is { } polygonWinner && sphereT > polygonWinner.Distance)
continue;
if (closestSphere is null || sphereT < closestSphere.Value.Distance)
closestSphere = new RetailSelectionHit(
part.ServerGuid, part.PartIndex, sphereT, PolygonHit: false);
// Retail stops at the FIRST hit polygon in this part's stored flat order.
foreach (var polygon in part.Mesh.Polygons)
{
if (!TryIntersectPolygon(localOrigin, localDirection, polygon, out double polygonT))
continue;
if (closestPolygon is null || polygonT < closestPolygon.Value.Distance)
closestPolygon = new RetailSelectionHit(
part.ServerGuid, part.PartIndex, polygonT, PolygonHit: true);
break;
}
}
return closestPolygon ?? closestSphere;
}
internal static bool TryIntersectSphere(
Vector3 origin,
Vector3 direction,
Vector3 center,
float radius,
out double distance)
{
// CSphere::sphere_intersects_ray @ 0x005377A0. Retail intentionally
// declines a broad-phase hit when the ray begins in or on the sphere.
// The render view-cone normally keeps selectable objects in front of
// the camera, so the routine does not separately reject a negative t.
distance = 0d;
Vector3 offset = origin - center;
double c = Vector3.Dot(offset, offset) - (double)radius * radius;
if (c <= 0d)
return false;
double a = Vector3.Dot(direction, direction);
if (a < RetailRayEpsilon)
return false;
double b = -Vector3.Dot(offset, direction);
double discriminant = b * b - c * a;
if (discriminant < 0d)
return false;
double root = Math.Sqrt(discriminant);
distance = b > root ? (b - root) / a : (b + root) / a;
return true;
}
internal static bool TryIntersectPolygon(
Vector3 origin,
Vector3 direction,
RetailSelectionPolygon polygon,
out double distance)
{
distance = 0d;
if (polygon.Vertices.Count < 3
|| !TryPlane(polygon.Vertices, out Vector3 normal, out float planeD))
return false;
double denominator = Vector3.Dot(direction, normal);
// CPolygon::polygon_hits_ray @ 0x005395E0: raw sides_type 0 is
// single-sided and rejects a ray travelling with the positive normal.
if (polygon.SingleSided && denominator > 0d)
return false;
if (Math.Abs(denominator) < RetailRayEpsilon)
return false;
distance = -(Vector3.Dot(origin, normal) + planeD) / denominator;
if (distance < 0d)
return false;
Vector3 point = origin + direction * (float)distance;
return PointInPolygon(point, polygon.Vertices, normal);
}
private static bool TryPlane(
IReadOnlyList<Vector3> vertices,
out Vector3 normal,
out float planeD)
{
// CPolygon::make_plane @ 0x005383D0 builds a triangle fan from
// vertex zero, sums the fan normals, normalizes once, then chooses d
// from the average signed distance of every vertex. DatReaderWriter
// exposes vertices rather than retail's derived Plane, so reconstruct
// that load-time result here.
Vector3 first = vertices[0];
Vector3 normalSum = Vector3.Zero;
for (int i = 1; i + 1 < vertices.Count; i++)
normalSum += Vector3.Cross(vertices[i] - first, vertices[i + 1] - first);
if (normalSum.LengthSquared() > 1e-12f)
{
normal = Vector3.Normalize(normalSum);
double averageDot = 0d;
foreach (Vector3 vertex in vertices)
averageDot += Vector3.Dot(normal, vertex);
planeD = (float)-(averageDot / vertices.Count);
return true;
}
normal = default;
planeD = 0f;
return false;
}
private static bool PointInPolygon(
Vector3 point,
IReadOnlyList<Vector3> vertices,
Vector3 normal)
{
// CPolygon::point_in_polygon @ 0x00538D90. Retail visual polygons are
// convex: the point must remain on the inward side of every ordered
// edge. Zero is accepted, so a click exactly on an edge still hits.
Vector3 previous = vertices[^1];
for (int i = 0; i < vertices.Count; i++)
{
Vector3 current = vertices[i];
Vector3 inward = Vector3.Cross(normal, current - previous);
if (Vector3.Dot(point - previous, inward) < 0f)
return false;
previous = current;
}
return true;
}
}

View file

@ -1,286 +1,54 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.Core.Selection;
/// <summary>
/// Mouse-to-entity picker. Pure static functions; no state, no DI.
/// <list type="bullet">
/// <item><see cref="BuildRay"/> turns a pixel + view/projection into a world-space ray.</item>
/// <item><see cref="Pick"/> ray-sphere intersects against entity candidates and returns the nearest hit's ServerGuid.</item>
/// </list>
/// Used by <c>GameWindow.OnInputAction</c> to wire SelectLeft / SelectDblLeft / UseSelected to <c>InteractRequests.BuildUse</c>.
/// Pure mouse-pixel to world-ray conversion shared by retail selection paths.
/// </summary>
public static class WorldPicker
{
/// <summary>
/// Unprojects a pixel coordinate to a world-space ray using the supplied
/// view + projection matrices (System.Numerics row-vector convention,
/// composed as view * projection — same as the rest of acdream's camera
/// pipeline; see GameWindow.cs:6445 FrustumPlanes.FromViewProjection).
/// Unprojects a pixel coordinate to a world-space ray using System.Numerics'
/// row-vector convention (<c>view * projection</c>).
/// </summary>
/// <returns>
/// (origin = world point on the near plane, direction = normalized
/// world-space ray direction). Returns (Vector3.Zero, Vector3.Zero)
/// if the view-projection composition is singular.
/// The camera viewpoint and normalized direction, or two zero vectors
/// when the camera transform is singular.
/// </returns>
public static (Vector3 Origin, Vector3 Direction) BuildRay(
float mouseX, float mouseY,
float viewportW, float viewportH,
Matrix4x4 view, Matrix4x4 projection)
float mouseX,
float mouseY,
float viewportW,
float viewportH,
Matrix4x4 view,
Matrix4x4 projection)
{
// Pixel -> NDC. y flipped: top-left pixel maps to ndc.y = +1.
float ndcX = (2f * mouseX) / viewportW - 1f;
float ndcY = 1f - (2f * mouseY) / viewportH;
var vp = view * projection;
if (!Matrix4x4.Invert(vp, out var invVp))
Matrix4x4 vp = view * projection;
if (!Matrix4x4.Invert(vp, out Matrix4x4 invVp)
|| !Matrix4x4.Invert(view, out Matrix4x4 invView))
return (Vector3.Zero, Vector3.Zero);
// Unproject near (ndc.z = -1) and far (ndc.z = +1) clip points.
var nearClip = new Vector4(ndcX, ndcY, -1f, 1f);
var farClip = new Vector4(ndcX, ndcY, +1f, 1f);
var n4 = Vector4.Transform(nearClip, invVp);
var f4 = Vector4.Transform(farClip, invVp);
if (n4.W == 0f || f4.W == 0f)
Vector4 nearClip = new(ndcX, ndcY, -1f, 1f);
Vector4 farClip = new(ndcX, ndcY, 1f, 1f);
Vector4 near = Vector4.Transform(nearClip, invVp);
Vector4 far = Vector4.Transform(farClip, invVp);
if (near.W == 0f || far.W == 0f)
return (Vector3.Zero, Vector3.Zero);
var nearWorld = new Vector3(n4.X, n4.Y, n4.Z) / n4.W;
var farWorld = new Vector3(f4.X, f4.Y, f4.Z) / f4.W;
var dir = farWorld - nearWorld;
if (dir.LengthSquared() < 1e-10f)
Vector3 nearWorld = new Vector3(near.X, near.Y, near.Z) / near.W;
Vector3 farWorld = new Vector3(far.X, far.Y, far.Z) / far.W;
Vector3 direction = farWorld - nearWorld;
if (direction.LengthSquared() < 1e-10f)
return (Vector3.Zero, Vector3.Zero);
return (nearWorld, Vector3.Normalize(dir));
}
/// <summary>
/// Ray-sphere intersection against each candidate's <see cref="WorldEntity.Position"/>
/// using a fixed 0.7 m sphere radius. Returns the <see cref="WorldEntity.ServerGuid"/>
/// of the closest hit within <paramref name="maxDistance"/>, or null on miss.
/// </summary>
/// <param name="direction">
/// World-space ray direction. <b>Must be normalized</b> — the geometric
/// ray-sphere formula simplifies <c>a = dot(direction, direction)</c> to
/// <c>1</c>; non-unit input produces an undocumented <c>t</c>-scale that
/// makes <c>maxDistance</c> compare against ray-parameter units instead
/// of world meters.
/// </param>
/// <remarks>
/// <para>
/// Entities with <c>ServerGuid == 0</c> (atlas-tier scenery, dat-hydrated
/// statics) are skipped — they have no server-side identity and can't be
/// the target of a Use packet. The player's own guid is skipped via
/// <paramref name="skipServerGuid"/>.
/// </para>
/// <para>
/// <b>Radius history (Issue #59).</b> Started at 5 m as a forgiving default;
/// in practice this over-picked massively — any cursor anywhere near an
/// NPC selected the NPC instead of a nearby item, and "click empty
/// ground to deselect" was nearly impossible. Tightened to 0.7 m on
/// 2026-05-15 to roughly match the actual hitbox radius of humanoids +
/// most items. A future refinement is per-itemType radius (smaller for
/// tapers, bigger for shop chests) or priority sorting (items beat
/// NPCs at equal hit-distance).
/// </para>
/// </remarks>
public static uint? Pick(
Vector3 origin, Vector3 direction,
IEnumerable<WorldEntity> candidates,
uint skipServerGuid,
float maxDistance = 50f,
Func<uint, float>? radiusForGuid = 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)
{
if (entity.ServerGuid == 0u) continue;
if (entity.ServerGuid == skipServerGuid) continue;
// Per-entity radius + vertical offset (caller-supplied).
//
// <para>
// <b>Vertical offset (2026-05-15).</b> WorldEntity.Position
// is at the entity's feet (Z=ground for a humanoid). User
// clicks usually land on chest/head (Z ≈ 11.8 m). With the
// sphere centred at feet, a chest click is 1.2 m of vertical
// distance from sphere centre — bigger than any reasonable
// body radius — so the ray misses. Lifting the sphere
// centre to mid-body fixes this: 0.9 m default for
// humanoids, smaller for items, larger for tall objects.
// </para>
//
// <para>
// <b>Radius (2026-05-15).</b> Bumped default 0.7 → 1.0 m to
// accommodate the new vertical-offset sphere placement
// (chest-height sphere centre + 1.0 m radius covers from
// shin to top-of-head for a 1.8 m humanoid).
// </para>
float r = radiusForGuid?.Invoke(entity.ServerGuid) ?? DefaultRadius;
float r2 = r * r;
float vz = verticalOffsetForGuid?.Invoke(entity.ServerGuid) ?? DefaultVerticalOffset;
var sphereCenter = new Vector3(
entity.Position.X,
entity.Position.Y,
entity.Position.Z + vz);
// Geometric ray-sphere: oc = origin - center, b = dot(oc, dir),
// c = |oc|^2 - r^2, discriminant = b^2 - c. If discriminant < 0
// the ray misses the sphere. Otherwise nearest intersection is
// t = -b - sqrt(discriminant).
var oc = origin - sphereCenter;
float b = Vector3.Dot(oc, direction);
float c = Vector3.Dot(oc, oc) - r2;
float d = b * b - c;
if (d < 0f) continue;
// Two intersection roots: t_near = -b - sqrt(d), t_far = -b + sqrt(d).
// If t_near < 0 the ray origin is INSIDE the sphere; fall through
// to t_far so the entity is still pickable at point-blank range.
float sqrtD = MathF.Sqrt(d);
float t = -b - sqrtD;
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;
bestGuid = entity.ServerGuid;
}
}
return bestGuid;
}
/// <summary>
/// 2026-05-16. Screen-space rect-hit-test picker overload. Each
/// candidate's world-space sphere (via <paramref name="sphereForEntity"/>)
/// projects to a screen-space rectangle through
/// <see cref="ScreenProjection.TryProjectSphereToScreenRect"/>. The
/// rect is inflated by <paramref name="inflatePixels"/> on every side
/// (matches the indicator's <c>TriangleSize</c> outer brackets) and
/// hit-tested against the mouse pixel. Among rects that contain the
/// mouse, the entity with the nearest camera-space depth wins.
///
/// <para>
/// Why screen-space instead of world-space ray-sphere: the indicator
/// draws a screen-space RECT. A world-space sphere projects to a
/// screen CIRCLE inscribed in that rect — leaving the four rect
/// corners as click dead zones. Per user feedback 2026-05-16, the
/// click area must match the visible indicator extent exactly. By
/// sharing the <see cref="ScreenProjection"/> helper with
/// <c>TargetIndicatorPanel</c>, the click rect and the drawn rect
/// cannot drift.
/// </para>
///
/// <para>
/// Resolver returning <c>null</c> skips the candidate (matches retail
/// "no Setup → not pickable" behavior). Entities with
/// <c>ServerGuid == 0</c> (atlas-tier scenery) and the player's own
/// guid are also skipped.
/// </para>
///
/// <para>
/// Stage A of the picker port. Stage B (polygon refine via
/// <c>CPolygon::polygon_hits_ray</c> 0x0054c889) remains deferred
/// per issue #71 — only needed if visual testing surfaces a Stage A
/// over-pick on entities whose visible mesh is well inside the
/// indicator rect.
/// </para>
/// </summary>
/// <param name="inflatePixels">Pixel inflate on each side of the
/// projected rect. Pass the indicator's <c>TriangleSize</c> (8 px)
/// so the click area extends to where the visible bracket corners
/// sit — the user perceives the inflated rect as the clickable area.</param>
public static uint? Pick(
float mouseX, float mouseY,
Matrix4x4 view,
Matrix4x4 projection,
Vector2 viewport,
IEnumerable<WorldEntity> candidates,
uint skipServerGuid,
Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
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;
if (entity.ServerGuid == skipServerGuid) continue;
var sphere = sphereForEntity(entity);
if (sphere is null) continue;
var (center, radius) = sphere.Value;
if (radius <= 0f) continue;
if (!ScreenProjection.TryProjectSphereToScreenRect(
center, radius, view, projection, viewport,
out var rMin, out var rMax, out var depth))
continue;
// Inflate by inflatePixels on each side — extend hit area to
// where the indicator brackets sit.
float minX = rMin.X - inflatePixels;
float minY = rMin.Y - inflatePixels;
float maxX = rMax.X + inflatePixels;
float maxY = rMax.Y + inflatePixels;
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;
bestGuid = entity.ServerGuid;
}
}
return bestGuid;
// Render::pick_ray @ 0x0054B610 stores a direction through the pixel;
// GfxObjUnderSelectionRay @ 0x0054C740 pairs it with Render::viewpoint,
// not the near-plane point. Inverse-view origin is that viewpoint in
// System.Numerics' row-vector convention.
Vector3 viewpoint = Vector3.Transform(Vector3.Zero, invView);
return (viewpoint, Vector3.Normalize(direction));
}
}