From 376e2c357845a3e293538520ab474c7de94acb88 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 29 May 2026 19:01:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20Phase=20A8.F=20=E2=80=94=20Phys?= =?UTF-8?q?icsCameraCollisionProbe=20(swept-sphere=20eye=20via=20ResolveWi?= =?UTF-8?q?thTransition)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../Rendering/ICameraCollisionProbe.cs | 20 ++++++ .../Rendering/PhysicsCameraCollisionProbe.cs | 61 +++++++++++++++++++ .../PhysicsCameraCollisionProbeTests.cs | 43 +++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 src/AcDream.App/Rendering/ICameraCollisionProbe.cs create mode 100644 src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs create mode 100644 tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs diff --git a/src/AcDream.App/Rendering/ICameraCollisionProbe.cs b/src/AcDream.App/Rendering/ICameraCollisionProbe.cs new file mode 100644 index 0000000..52fad70 --- /dev/null +++ b/src/AcDream.App/Rendering/ICameraCollisionProbe.cs @@ -0,0 +1,20 @@ +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// +/// Sweeps a small sphere from the camera pivot (player head) toward the +/// desired eye and returns the stopped (non-penetrating) eye. The seam that +/// lets collide its eye without depending on +/// the physics engine directly (and stay unit-testable with a fake). +/// +public interface ICameraCollisionProbe +{ + /// + /// Roll a collision sphere from to + /// ; return the position it reaches without + /// penetrating geometry. Returns unchanged + /// when nothing blocks the path or when is 0. + /// + Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId); +} diff --git a/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs b/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs new file mode 100644 index 0000000..09ce5c1 --- /dev/null +++ b/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs @@ -0,0 +1,61 @@ +using System.Numerics; +using AcDream.Core.Physics; + +namespace AcDream.App.Rendering; + +/// +/// backed by the player's swept-sphere +/// engine. Ports retail's SmartBox::update_viewer (0x00453ce0): sweep +/// the 0.3 m viewer_sphere from the head-pivot to the desired eye via a +/// CTransition and use the stopped position. Reusing +/// collides against indoor +/// cell walls (FindEnvCollisions) AND outdoor/baked GfxObj shells +/// (FindObjCollisions) in one faithful path. +/// +public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe +{ + /// Retail viewer_sphere radius (acclient :93314). + public const float ViewerSphereRadius = 0.3f; + + private readonly PhysicsEngine _physics; + + public PhysicsCameraCollisionProbe(PhysicsEngine physics) => _physics = physics; + + public Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId) + { + // No starting cell → nothing to sweep against; keep the desired eye. + if (cellId == 0) return desiredEye; + + // SpherePath.InitPath puts sphere0's center at pathPos + (0,0,radius) + // (the player foot-capsule convention). Retail's viewer_sphere center is + // (0,0,0), so shift the path DOWN by the radius to make the SPHERE CENTER + // travel pivot→eye, then add it back to the swept stop position. + Vector3 begin = ToSpherePath(pivot, ViewerSphereRadius); + Vector3 end = ToSpherePath(desiredEye, ViewerSphereRadius); + + var r = _physics.ResolveWithTransition( + currentPos: begin, + targetPos: end, + cellId: cellId, + sphereRadius: ViewerSphereRadius, + sphereHeight: 0f, // single sphere (no head sphere) + stepUpHeight: 0f, // no step-up for a camera + stepDownHeight: 0f, // no step-down / ground snap + isOnGround: false, // no contact-plane / walkable semantics + body: null, // no cross-frame persistence + moverFlags: ObjectInfoState.None, // all targets collide; also keeps + // camera sweeps out of the #98 + // IsPlayer capture filter + movingEntityId: selfEntityId); // skip the player's own ShadowEntry + + return FromSpherePath(r.Position, ViewerSphereRadius); + } + + /// Eye/pivot point → InitPath path point (subtract the sphere-center offset). + internal static Vector3 ToSpherePath(Vector3 spherePoint, float radius) + => spherePoint - new Vector3(0f, 0f, radius); + + /// InitPath path point → eye point (add the sphere-center offset back). + internal static Vector3 FromSpherePath(Vector3 pathPoint, float radius) + => pathPoint + new Vector3(0f, 0f, radius); +} diff --git a/tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs b/tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs new file mode 100644 index 0000000..5cb7b0c --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs @@ -0,0 +1,43 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public class PhysicsCameraCollisionProbeTests +{ + // The probe must convert the desired eye path (where the SPHERE CENTER + // should travel) into the foot-capsule path InitPath expects (which offsets + // sphere0 up by radius), then invert it on the result. Verify the round trip. + [Fact] + public void SpherePathOffset_RoundTrips() + { + var p = new Vector3(10f, 20f, 30f); + const float r = 0.3f; + + var path = PhysicsCameraCollisionProbe.ToSpherePath(p, r); + Assert.Equal(p.Z - r, path.Z, 5); + Assert.Equal(p.X, path.X, 5); + Assert.Equal(p.Y, path.Y, 5); + + var back = PhysicsCameraCollisionProbe.FromSpherePath(path, r); + Assert.Equal(p.X, back.X, 5); + Assert.Equal(p.Y, back.Y, 5); + Assert.Equal(p.Z, back.Z, 5); + } + + // cellId == 0 means "no starting cell" — the probe must short-circuit and + // return the desired eye without touching the engine. + [Fact] + public void SweepEye_NoStartingCell_ReturnsDesiredEyeUnchanged() + { + var probe = new PhysicsCameraCollisionProbe(new PhysicsEngine()); + var pivot = new Vector3(0f, 0f, 1.5f); + var eye = new Vector3(-2f, 0f, 2.2f); + + var result = probe.SweepEye(pivot, eye, cellId: 0, selfEntityId: 0); + + Assert.Equal(eye, result); + } +}