acdream/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs
Erik 3066460370 diag(render): camera-collision indoor non-engagement — RED test + diagnosis
Root cause (b): ShadowObjectRegistry.GetNearbyObjects (line 480) returns early
when primaryCellId is an indoor cell, skipping the outdoor radial sweep that
contains the landblock-baked cottage exterior-shell GfxObj. The issue-#98 fix
that prevents the player's head sphere from being capped by the cottage floor
also prevents the IsViewer camera sweep from finding the exterior building shell.
Result: camera passes through exterior walls unimpeded, driving the residual
transparent-walls symptom after the U.4c flap fix.

Evidence: live capture shows eyeInRoot=n ~90% of frames, eye-player distance
3.43m (full chase, no pull-in). RED test deterministically reproduces: synthetic
indoor cell (0xA9B40175) + exterior GfxObj registered at cellScope=0; probe
SweepEye returns pulledIn=0.0000m (full eye distance Y=5.0, wall at Y=4.0).

Fix design: exempt IsViewer from the indoor-primary early-return gate in
GetNearbyObjects — retail's find_obj_collisions (named-retail :308918) has no
indoor/outdoor cell gate; the acdream fix is correct only for IsPlayer.

Apparatus committed:
- tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs (RED test)
- docs/research/2026-05-31-camera-collision-indoor-diagnosis.md (findings + design)
- PhysicsCameraCollisionProbe.cs [flap-sweep] diagnostic retained (U.4c spike)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:02:37 +02:00

93 lines
5.3 KiB
C#

using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.App.Rendering;
/// <summary>
/// <see cref="ICameraCollisionProbe"/> backed by the player's swept-sphere
/// engine. Ports retail's <c>SmartBox::update_viewer</c> (0x00453ce0): sweep
/// the 0.3 m <c>viewer_sphere</c> from the head-pivot to the desired eye via a
/// <c>CTransition</c> and use the stopped position. Reusing
/// <see cref="PhysicsEngine.ResolveWithTransition"/> collides against indoor
/// cell walls (<c>FindEnvCollisions</c>) AND outdoor/baked GfxObj shells
/// (<c>FindObjCollisions</c>) in one faithful path.
/// </summary>
public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
{
/// <summary>Retail <c>viewer_sphere</c> radius (acclient :93314).</summary>
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
// Retail SmartBox::update_viewer calls init_object(player, 0x5c) =
// IsViewer | PathClipped | FreeRotate | PerfectClip (acclient
// pseudo-C :92864; enum TransitionTypes.cs:24-33). PathClipped makes
// the sweep HARD-STOP at first contact (TransitionTypes.cs:811) — the
// spring-arm pull-in, not the player's edge-slide. IsViewer lets the
// eye pass through creatures, colliding only with world geometry
// (CollisionExemption.cs:83-85). FreeRotate/PerfectClip are no-ops in
// acdream today but set to match retail's exact value. NOT IsPlayer
// (0x100), so camera sweeps stay out of the #98 capture filter.
moverFlags: ObjectInfoState.IsViewer | ObjectInfoState.PathClipped
| ObjectInfoState.FreeRotate | ObjectInfoState.PerfectClip,
movingEntityId: selfEntityId); // skip the player's own ShadowEntry
Vector3 eye = FromSpherePath(r.Position, ViewerSphereRadius);
// Phase U.4c spike apparatus (THROWAWAY — strip with ACDREAM_PROBE_FLAP).
// The post-fix [flap-cam] capture shows the eye flying to full chase distance
// (eyeInRoot=n ~90%) in cells like 0xA9B40174/0175 — i.e. this sweep is not
// stopping it. This line answers WHY, the fork that picks the primary residual
// fix: pulledIn≈0 with resolved=Y bsp=ok ⇒ the sweep ran but found NOTHING in
// that cell (space genuinely open, or wall geometry the per-cell sweep can't
// reach → clip-robustness is primary); resolved=n / bsp=nobsp/noroot ⇒ collision
// can't even run there (cell/BSP not loaded → camera-collision reliability is
// primary); pulledIn large ⇒ collision IS engaging (eye leaving is then expected
// through an opening). Paired per-frame with the builder's [flap]/[flap-cam].
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
{
var cp = _physics.DataCache?.GetCellStruct(cellId);
string bsp = cp?.BSP is null ? "nobsp" : (cp.BSP.Root is null ? "noroot" : "ok");
float desiredBack = Vector3.Distance(pivot, desiredEye);
float eyeBack = Vector3.Distance(pivot, eye);
System.Console.WriteLine(
$"[flap-sweep] cell=0x{cellId:X8} resolved={(cp is not null ? "Y" : "n")} bsp={bsp} " +
$"desiredBack={desiredBack:F2} eyeBack={eyeBack:F2} pulledIn={desiredBack - eyeBack:F2} " +
$"collNormValid={r.CollisionNormalValid}");
}
return eye;
}
/// <summary>Eye/pivot point → InitPath path point (subtract the sphere-center offset).</summary>
internal static Vector3 ToSpherePath(Vector3 spherePoint, float radius)
=> spherePoint - new Vector3(0f, 0f, radius);
/// <summary>InitPath path point → eye point (add the sphere-center offset back).</summary>
internal static Vector3 FromSpherePath(Vector3 pathPoint, float radius)
=> pathPoint + new Vector3(0f, 0f, radius);
}