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 CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos) { // update_viewer: player->cell == 0 → set_viewer(player_pos, 1), viewer_cell = null // (acclient_2013_pseudo_c.txt:92775). No cell to sweep against → snap to the player. if (cellId == 0) return new CameraSweepResult(playerPos, 0u); // === Start cell (pc:92824-92844) === // Indoor (objcell_id >= 0x100): seat the sweep's start cell at the head-PIVOT via // CPhysicsObj::AdjustPosition (pc:92832) — the head can sit in a different cell than // the feet (the cellar lip: feet in the low connector, head up at floor level). On // failure retail falls back to player->cell. Outdoor: cell = player->cell (no AdjustPosition). uint startCell = cellId; if ((cellId & 0xFFFFu) >= 0x0100u) { var (pivotCell, found) = _physics.AdjustPosition(cellId, pivot); if (found) startCell = pivotCell; } // === Sweep the viewer_sphere pivot → sought-eye from the start cell (pc:92860-92868) === // 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. Vector3 begin = ToSpherePath(pivot, ViewerSphereRadius); Vector3 end = ToSpherePath(desiredEye, ViewerSphereRadius); var r = _physics.ResolveWithTransition( currentPos: begin, targetPos: end, cellId: startCell, 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); // [flap-sweep] camera-collision probe (ACDREAM_PROBE_FLAP), paired with the // builder's [flap]/[flap-cam]. start = the pivot-seated start cell (vs cell = the // player feet cell); ok = the sweep found a valid position (find_valid_position != 0). if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) { var cp = _physics.DataCache?.GetCellStruct(startCell); 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} start=0x{startCell:X8} ok={r.Ok} resolved={(cp is not null ? "Y" : "n")} bsp={bsp} " + $"desiredBack={desiredBack:F2} eyeBack={eyeBack:F2} pulledIn={desiredBack - eyeBack:F2} " + $"in=({desiredEye.X:F6},{desiredEye.Y:F6},{desiredEye.Z:F6}) out=({eye.X:F6},{eye.Y:F6},{eye.Z:F6}) " + $"viewerCell=0x{r.CellId:X8} collNormValid={r.CollisionNormalValid}"); } // success: set_viewer(curr_pos, 0); viewer_cell = sphere_path.curr_cell (pc:92870-92871). // Graph-tracked, no AABB/grace. if (r.Ok) return new CameraSweepResult(eye, r.CellId); // === Fallback 1 (pc:92878-92883): AdjustPosition at the sought eye === // The sweep found no valid position; try to seat the eye at its own cell. // (Seed with the player cell — acdream's camera doesn't track the sought-eye's // cell separately; the eye is near the player so its stab-list is the right one.) var (eyeCell, eyeFound) = _physics.AdjustPosition(cellId, desiredEye); if (eyeFound) return new CameraSweepResult(desiredEye, eyeCell); // === Fallback 2 (pc:92886-92887): set_viewer(player_pos), viewer_cell = null === return new CameraSweepResult(playerPos, 0u); } /// 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); }