feat(B.4b): WorldPicker.BuildRay — mouse-to-world ray unprojection
New AcDream.Core.Selection.WorldPicker static helper. BuildRay unprojects pixel (mouseX, mouseY) through a view+projection matrix pair into a world-space (origin, direction) ray. Used by GameWindow.OnInputAction to drive entity picking on click. Pure math, no state, no DI. Composes view*projection (System.Numerics row-vector convention, matching the rest of acdream's camera path — see GameWindow.cs:6445 FrustumPlanes.FromViewProjection). 2 xUnit tests cover center-of-viewport (forward ray) and right-of-center (positive-X deflection). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
179e441d11
commit
f0b3bd9aa2
2 changed files with 109 additions and 0 deletions
55
src/AcDream.Core/Selection/WorldPicker.cs
Normal file
55
src/AcDream.Core/Selection/WorldPicker.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
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>.
|
||||
/// </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).
|
||||
/// </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.
|
||||
/// </returns>
|
||||
public static (Vector3 Origin, Vector3 Direction) BuildRay(
|
||||
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))
|
||||
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)
|
||||
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)
|
||||
return (Vector3.Zero, Vector3.Zero);
|
||||
return (nearWorld, Vector3.Normalize(dir));
|
||||
}
|
||||
}
|
||||
54
tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
Normal file
54
tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Selection;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Selection;
|
||||
|
||||
public class WorldPickerTests
|
||||
{
|
||||
private const float Epsilon = 0.01f;
|
||||
|
||||
private static (Matrix4x4 View, Matrix4x4 Projection) MakeIdentityCamera()
|
||||
{
|
||||
var view = Matrix4x4.Identity;
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
fieldOfView: MathF.PI / 3f,
|
||||
aspectRatio: 16f / 9f,
|
||||
nearPlaneDistance: 0.1f,
|
||||
farPlaneDistance: 100f);
|
||||
return (view, proj);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRay_CenterOfViewport_ReturnsForwardRay()
|
||||
{
|
||||
var (view, proj) = MakeIdentityCamera();
|
||||
const float vpW = 1920f, vpH = 1080f;
|
||||
|
||||
var (_, direction) = WorldPicker.BuildRay(
|
||||
mouseX: vpW / 2f, mouseY: vpH / 2f,
|
||||
viewportW: vpW, viewportH: vpH,
|
||||
view, proj);
|
||||
|
||||
// Right-handed perspective + identity view -> camera looks down -Z.
|
||||
// Center pixel ray = (0, 0, -1) within float epsilon.
|
||||
Assert.True(MathF.Abs(direction.X) < Epsilon, $"direction.X = {direction.X}");
|
||||
Assert.True(MathF.Abs(direction.Y) < Epsilon, $"direction.Y = {direction.Y}");
|
||||
Assert.True(direction.Z < -0.99f, $"direction.Z = {direction.Z}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRay_OffsetMouseRight_DeflectsRayPositiveX()
|
||||
{
|
||||
var (view, proj) = MakeIdentityCamera();
|
||||
const float vpW = 1920f, vpH = 1080f;
|
||||
|
||||
var (_, direction) = WorldPicker.BuildRay(
|
||||
mouseX: vpW * 0.75f, mouseY: vpH / 2f,
|
||||
viewportW: vpW, viewportH: vpH,
|
||||
view, proj);
|
||||
|
||||
Assert.True(direction.X > 0.1f, $"direction.X = {direction.X} (expected > 0.1)");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue