fix(world): match retail portal passage and exit warp
This commit is contained in:
parent
eab23cbdd1
commit
842bd89c16
11 changed files with 355 additions and 28 deletions
|
|
@ -609,10 +609,21 @@ and no world point lights for this pass. Chat, gameplay windows, toolbar,
|
|||
cursor, and input continue normally above it. The synthetic object never
|
||||
enters `LiveEntityRuntime`, collision, picking, radar, or server GUID state.
|
||||
|
||||
`TeleportViewPlaneController` is the App-layer projection adapter for retail
|
||||
`SmartBox::SetOverrideFovDistance` / `Render::set_vdst`. It captures the active
|
||||
game projection at teleport begin, then applies the same table-eased
|
||||
view-plane distance and near plane to the portal and world viewports through
|
||||
the four fade states. Portal camera direction is a roll around its local AC
|
||||
`+Y` forward axis, so the animated scene remains a passage rather than yawing
|
||||
away from it. Destination placement also calls the retail chase camera's
|
||||
`set_viewer(player, reset_sought=1)` equivalent; the normal damped/swept camera
|
||||
path then re-extends from the arriving player.
|
||||
|
||||
The destination residency gate supplies retail's `EndTeleportAnimation` edge
|
||||
once asynchronous streaming is ready. Player placement remains authoritative
|
||||
and separate from presentation; arrival does not reset the character animation
|
||||
sequence. See `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
|
||||
and separate from presentation; arrival resets camera viewer state but does
|
||||
not reset the character animation sequence. See
|
||||
`docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
|
||||
|
||||
## Roadmap Model
|
||||
|
||||
|
|
|
|||
|
|
@ -96,7 +96,6 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AD-35 | `MotionTableManager.PerformMovement`'s unhandled-type default case returns the named sentinel `0xFFFFFFFF` (`MotionTableManagerError.NotHandled`); retail's compiled code leaks the `CSequence*` pointer reinterpreted as the return code (BN-confirmed artifact, dead/unreachable — callers gate on type first) (R2-Q3, 2026-07-02) | `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`PerformMovement` default case) | No retail caller consults the return value for unhandled types (RawCommand/StopRawCommand/MoveTo\*/TurnTo\* route elsewhere); returning a stable non-zero sentinel preserves the only observable contract (non-zero = not success) without fabricating a pointer-shaped number | If a future port wires a caller that passes unhandled types AND branches on the exact return value, it would see `0xFFFFFFFF` where retail saw an arbitrary pointer — flag at that port | `PerformMovement` 0x0051c0b0 (`r2-motiontable-decomp.md` §11 default-case note) |
|
||||
| AD-36 | `IMotionDoneSink.MotionDone` consumed for CREATURE-class entities only: R3-W2 binds the seam to the entity's `MotionInterpreter.MotionDone` (player via `PlayerMovementController.Motion`, remotes via `RemoteMotion.Motion`, resolved at fire time); interp-less entities (statics that never receive a UM/UP and so never get a `RemoteMotion`) keep a diagnostic-recorder-only target — retail gives every CPhysicsObj a MovementManager/CMotionInterp (R2-Q4 seam, narrowed R3-W2, 2026-07-02; R5-V5 gave every `RemoteMotion`/player ONE literal `MovementManager` facade, so the residue is only the no-RemoteMotion class) | `src/AcDream.App/Rendering/GameWindow.cs` (TickAnimations MotionDoneTarget bind) | Motion for entities without a `RemoteMotion` (never UM/UP-touched) completes via the manager queue alone; nothing consumes their MotionDone until every sequencer-owning entity gets a host/`RemoteMotion` (doors DO have one since the R4-V5 door fix — first UM creates it) | An entity behavior depending on pending_motions bookkeeping in that no-RemoteMotion class (none known) would silently no-op | `CPhysicsObj::MotionDone` 0x0050fdb0; retire when every sequencer-owning entity constructs a `RemoteMotion`/host (post-M1.5 entity-class unification; R5-V5 closed the facade half) |
|
||||
| AD-37 | Camera rotation state is a forward VECTOR (nlerp + normalize; roll always 0, up = world Z); retail's sought carries a full Frame and slerps quaternions (`Frame::interpolate_rotation` shortest-path slerp with 2e-4 nlerp fallback). The dead-band compares forward-vector distance against the same 2e-4 epsilon retail applies per quaternion component | `src/AcDream.App/Rendering/RetailChaseCamera.cs` (`_dampedForward`, `ApplyConvergenceSnap`) | The chase camera never rolls (heading frames are Z-up by construction), so a forward vector spans the reachable rotation space; identified (not introduced) during the #180 UpdateCamera tail reading | If a future camera mode needs roll (death cam, cutscene) the vector state can't represent it; large-angle per-frame turns nlerp (chord) vs slerp (arc) — imperceptible at 0.45-stiffness step sizes | `Frame::interpolate_rotation` 0x00535390, `Frame::close_rotation` 0x00455d70; pseudocode doc 2026-07-06-camera-sought-position |
|
||||
| AD-38 | First camera frame seats sought = viewer = the full-length target eye (starts converged); retail hard resets both to the PLAYER's position (`set_viewer(pos, reset_sought=1)`) at login/teleport/cell-loss, so the retail camera visibly re-extends outward from the head over ~1 s | `src/AcDream.App/Rendering/RetailChaseCamera.cs` (`_initialised` branch) | acdream teleports don't reset the camera object today; the pivot-anchored sweep self-heals in one convergence, and the login zoom-out is cosmetic. Identified (not introduced) during the #180 reading | Login/teleport first frames show a fully-extended boom where retail shows a zoom-out; if a teleport lands the stale sought behind distant geometry the first sweeps clip until convergence (sub-second) | `SmartBox::set_viewer` 0x00452c40 (reset_sought=1 sites pc:92775, pc:92886); retire by wiring teleport → sought reset |
|
||||
| AD-39 | The `frames_stationary_fall` ladder + fsf≥3 UP-contact-plane manufacture runs AFTER acdream's fused LKCP-restore/contact-marking block, deriving retail's `_redo` as `cleanAdvance \|\| OnWalkable`; retail (ACE Transition.cs:1029-1061) interleaves the fsf block BETWEEN the LKCP-restore (sets `_redo`) and the contact-marking (reads the manufactured plane) (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ValidateTransition` fsf tail) | acdream deliberately fused ACE's separate LKCP-restore + contact-mark blocks (the L.2.3c/L.2.4/A6.P3 contact-retention divergences); running the ladder after them and re-marking grounding inside the manufacture branch is semantically equal (a grounded wall-slide is not a stuck-fall in either arrangement) without disturbing those hard-won fixes | If a future contact-retention change alters when OnWalkable is set relative to the ladder, `_redo` could misclassify a frame (grounded-jam mistaken for stuck-fall → spurious velocity zero, or vice-versa) — the fsf conformance tests pin the current arrangement | `CTransition::validate_transition` 0x0050aa70 pc:272625-656; ACE Transition.cs:1029-1061 |
|
||||
| AD-40 | The fsf `Stationary*` transient-bit encode (fsf→0x10/0x20/0x40) lives in the Core resolve writeback (`PhysicsEngine.ResolveWithTransition`), co-located with the fsf computation; retail encodes it in `handle_all_collisions` (pc:282737-758). Also: `PhysicsBody.CachedVelocity` is computed at the player chokepoint but not yet consumed — outbound wire velocity still uses the existing `get_state_velocity` path, not retail's cached_velocity source (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/PhysicsEngine.cs` (writeback); `src/AcDream.App/Input/PlayerMovementController.cs` (`CachedVelocity`) | Encoding in the writeback keeps the seed→ladder→writeback→seed round-trip self-contained in Core (testable without the App loop); the bit values + timing are identical to retail's (set after fsf is final, before the next resolve). CachedVelocity is faithful to carry now; routing the wire through it is a separate, unmeasured change | If a future consumer reads the Stationary* bits expecting retail's handle_all_collisions to have set them (it doesn't run in Core), the Core writeback is the source of truth; a wire-reporting change that assumes CachedVelocity is live would send the wrong velocity until it's wired | `handle_all_collisions` bit encode pc:282737-758; `get_velocity` 0x005113c0 (cached_velocity reader) |
|
||||
| AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses ONLY `handle_all_collisions` + `cached_velocity` on a no-move frame; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.App/Input/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) |
|
||||
|
|
|
|||
|
|
@ -593,7 +593,7 @@ Research: R9 + R12 + R13.
|
|||
- **✓ SHIPPED — G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. Full data + visual stack shipped: Region dat loader, keyframe interp, WeatherSystem with 5-kind PDF + transitions + storm flashes, WorldSession→WorldTimeService sync via ConnectRequest+TimeSync, SkyRenderer with sky-object arcs + UV scroll, rain/snow billboard renderer, F7/F10 debug cycle keys.
|
||||
- **✓ SHIPPED — G.2 — Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`. SceneLightingUbo std140 at binding=1 feeds terrain + mesh + mesh_instanced + sky shaders. LightingHookSink auto-registers Setup.Lights at entity stream-in, flips IsLit on SetLightHook, unregisters on landblock unload.
|
||||
- **Indoor portal-based cell tracking (follow-up to Indoor walking Phase 1 / issue #87).** Replace `PhysicsDataCache.TryFindContainingCell` AABB containment with retail's `CObjMaint::HandleObjectEnterCell` portal traversal. When the player crosses a cell portal boundary, `CellId` propagates through the `CEnvCell` portal connectivity graph. Prerequisite for wall collision from outside (#85) and the remaining #84 threshold symptom. PDB symbols and `acclient.h` `CCellStructure` refs are in place (see #87). **Unblocks G.3.**
|
||||
- **✓ SHIPPED — G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with post-transition `LoginComplete`, and the retail DAT-authored portal-space CreatureMode viewport (Setup `0x02000306`, animation `0x030005AC`, exact camera/light/timing/fades; visual gate pending 2026-07-15). Dungeons render, stream, teleport-in, collide, light, and their doors work — see the shipped-table rows (G.3, G.3a, #137 collision, A7.L1 lighting) below and the M1.5 section of `docs/plans/2026-05-12-milestones.md` for current gate status (one recorded end-to-end round-trip user gate outstanding; live residual = far-town teleport-OUT arrival cascade, #145-residual REOPENED). See `r09-dungeon-portal-space.md` and `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
|
||||
- **✓ SHIPPED — G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with post-transition `LoginComplete`, and the retail DAT-authored portal-space CreatureMode viewport (Setup `0x02000306`, animation `0x030005AC`, exact camera/light/timing/fades, forward-axis roll, projection warp, and destination viewer reset; corrective visual gate pending 2026-07-15). Dungeons render, stream, teleport-in, collide, light, and their doors work — see the shipped-table rows (G.3, G.3a, #137 collision, A7.L1 lighting) below and the M1.5 section of `docs/plans/2026-05-12-milestones.md` for current gate status (one recorded end-to-end round-trip user gate outstanding; live residual = far-town teleport-OUT arrival cascade, #145-residual REOPENED). See `r09-dungeon-portal-space.md` and `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
|
||||
|
||||
**Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight.
|
||||
|
||||
|
|
|
|||
|
|
@ -305,8 +305,9 @@ capture-harness-first).
|
|||
temporary black tunnel cover is retired. F751 transit now renders retail's
|
||||
client-enum-resolved DAT Setup `0x02000306` with animation `0x030005AC` at
|
||||
40 frames/s in a dedicated CreatureMode-equivalent viewport, using retail's
|
||||
camera, distant light, random eased yaw, frame-aligned exit window, and exact
|
||||
100-sample fade curve. The viewport and fades compose below retained UI, so
|
||||
camera, distant light, random eased forward-axis roll, frame-aligned exit
|
||||
window, exact 100-sample fade curve, view-plane/FOV warp, and destination
|
||||
viewer reset. The viewport and fades compose below retained UI, so
|
||||
chat, panels, toolbar, cursor, and input remain live throughout travel. See
|
||||
`docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ Named Sept-2013 retail symbols:
|
|||
- `gmSmartBoxUI::UseTime` `0x004D6E30`
|
||||
- `CreatureMode::CreatureMode` `0x00454390`
|
||||
- `CreatureMode::SetCameraDirection_Degrees` `0x00453760`
|
||||
- `Frame::rotate` `0x004525B0`
|
||||
- `CreatureMode::AddObject` `0x004556D0`
|
||||
- `UIGlobals::Init` `0x004EE470`
|
||||
- `UIGlobals::GetAnimLevel` `0x004EE540`
|
||||
|
|
@ -24,6 +25,9 @@ Named Sept-2013 retail symbols:
|
|||
- `SmartBox::PlayerPositionUpdated` `0x00453870`
|
||||
- `CPhysicsObj::teleport_hook` `0x00514ED0`
|
||||
- `CommandInterpreter::PlayerTeleported` `0x006B32B0`
|
||||
- `SmartBox::SetOverrideFovDistance` `0x00451BC0`
|
||||
- `SmartBox::GetOverrideFovDistance` `0x00451BE0`
|
||||
- `Render::set_vdst` `0x0054B240`
|
||||
|
||||
Constants were checked against static x86 disassembly. The Binary Ninja
|
||||
pseudocode loses several x87 operands in `UseTime`; disassembly is authoritative
|
||||
|
|
@ -93,7 +97,12 @@ sequence animations, plays `Sound_UI_ExitPortal`, and enters `WorldFadeIn`.
|
|||
|
||||
## Camera motion
|
||||
|
||||
The camera rotates around AC's vertical `+Z` axis. Each segment is independent:
|
||||
The camera rolls around its own AC `+Y` forward axis. It does not yaw around
|
||||
world `+Z`. `SetCameraDirection_Degrees` resets the frame to identity, converts
|
||||
the vector to radians, then passes `(0, angle, 0)` to `Frame::rotate`, whose
|
||||
input is an axis-angle rotation vector. The forward direction therefore stays
|
||||
down the portal passage while local `+Z` up rolls around it. Each segment is
|
||||
independent:
|
||||
|
||||
```text
|
||||
if now >= rotationStart + rotationDuration:
|
||||
|
|
@ -184,6 +193,39 @@ World/tunnel fades use `GetAnimLevel(elapsed / FadeTime)`. The fade belongs
|
|||
between the 3-D viewport and the retained UI. It must never cover or disable
|
||||
the retained UI.
|
||||
|
||||
## View-plane warp
|
||||
|
||||
Retail couples every fade state to a projection transition. On begin it
|
||||
captures the current SmartBox view-plane distance; for a perspective
|
||||
projection this is `cot(verticalFov / 2)`. The transition endpoint is the
|
||||
named constant `TRANSITION_VIEW_PLANE_DISTANCE = 0.001` at `0x007BD260`.
|
||||
|
||||
```text
|
||||
fadeLevel = GetAnimLevel(elapsed / FadeTime) / 1024
|
||||
|
||||
WorldFadeOut or TunnelFadeOut:
|
||||
currentDistance = gameDistance
|
||||
+ (0.001 - gameDistance) * fadeLevel
|
||||
|
||||
TunnelFadeIn or WorldFadeIn:
|
||||
currentDistance = 0.001
|
||||
+ (gameDistance - 0.001) * fadeLevel
|
||||
|
||||
SmartBox.SetOverrideFovDistance(true, currentDistance)
|
||||
```
|
||||
|
||||
`Render::set_vdst` turns the distance back into projection values:
|
||||
|
||||
```text
|
||||
verticalFov = 2 * atan(1 / currentDistance)
|
||||
znear = max(0.1, currentDistance * 0.25)
|
||||
```
|
||||
|
||||
At `0.001`, the view is nearly 180 degrees wide. Black covers the singular
|
||||
endpoint; as `WorldFadeIn` clears, the FOV eases back to the captured game
|
||||
projection. This is retail's characteristic destination-world warp, separate
|
||||
from both the alpha fade and chase-camera movement.
|
||||
|
||||
## Player placement boundary
|
||||
|
||||
```text
|
||||
|
|
@ -195,7 +237,7 @@ PlayerPositionUpdated(isTeleport=true):
|
|||
clear position-update/teleport bookkeeping
|
||||
player.teleport_hook()
|
||||
commandInterpreter.PlayerTeleported()
|
||||
move world viewer to player
|
||||
SmartBox.set_viewer(player.position, reset_sought=true)
|
||||
update CellManager position
|
||||
|
||||
player.teleport_hook():
|
||||
|
|
@ -212,6 +254,11 @@ PlayerTeleported():
|
|||
SendMovementEvent()
|
||||
```
|
||||
|
||||
The viewer reset copies the player's complete position into both `viewer` and
|
||||
`viewer_sought_position`. Subsequent ordinary camera updates extend the chase
|
||||
boom outward through the same damped and swept path used after camera
|
||||
collision. Reusing the source-world viewer is not retail behavior.
|
||||
|
||||
The retail teleport hook does not clear the character's animation sequence.
|
||||
The arrival presentation is instead hidden by the portal scene and its final
|
||||
world fade while the normal motion/update stream settles. Therefore acdream
|
||||
|
|
@ -227,6 +274,11 @@ must not add an arrival-only animation reset.
|
|||
- During tunnel states the portal scene replaces world pixels before retained
|
||||
UI draws. The black fade also draws before retained UI. Input dispatch and
|
||||
`RetailUiRuntime.Tick` continue unchanged.
|
||||
- The projection owner captures the active camera's M22 view-plane distance at
|
||||
teleport begin and applies `Render::set_vdst` semantics to both the tunnel
|
||||
and world viewport during the four fade states.
|
||||
- Destination placement resets the retail chase camera's published and sought
|
||||
positions to the player before the normal update path resumes.
|
||||
- Destination residency remains acdream's asynchronous adaptation. It supplies
|
||||
the same `EndTeleportAnimation` edge retail receives when its blocking cell
|
||||
load completes; it does not alter presentation ordering.
|
||||
|
|
|
|||
|
|
@ -6922,6 +6922,7 @@ public sealed class GameWindow : IDisposable
|
|||
// fires Place (materialize) and FireLoginComplete (regain control + ack the server).
|
||||
// Replaces the old TeleportArrivalController hold/place machine.
|
||||
private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new();
|
||||
private readonly TeleportViewPlaneController _teleportViewPlane = new();
|
||||
private bool _teleportInProgress;
|
||||
private System.Numerics.Vector3 _pendingTeleportPos;
|
||||
private uint _pendingTeleportCell;
|
||||
|
|
@ -7022,11 +7023,12 @@ public sealed class GameWindow : IDisposable
|
|||
_playerController.Yaw = AcQuaternionToYaw(_pendingTeleportRot);
|
||||
|
||||
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
|
||||
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
|
||||
playerVelocity: System.Numerics.Vector3.Zero,
|
||||
isOnGround: true,
|
||||
contactPlaneNormal: System.Numerics.Vector3.UnitZ,
|
||||
dt: 1f / 60f);
|
||||
// SmartBox::PlayerPositionUpdated (0x00453870) calls
|
||||
// set_viewer(player_pos, reset_sought=1): the viewer and sought
|
||||
// position both restart at the player, then the ordinary per-frame
|
||||
// camera path re-extends the boom. Updating directly from the stale
|
||||
// source-world viewer made the destination bend across the reveal.
|
||||
_retailChaseCamera?.ResetViewerToPlayer(snappedPos, _playerController.Yaw);
|
||||
|
||||
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
||||
"PLACED", resolved.CellId, $"forced={forced}");
|
||||
|
|
@ -7055,6 +7057,8 @@ public sealed class GameWindow : IDisposable
|
|||
_teleportInProgress = true;
|
||||
_teleportHoldSeconds = 0f;
|
||||
_teleportForced = false;
|
||||
_teleportViewPlane.Begin(
|
||||
_cameraController?.Active.Projection ?? System.Numerics.Matrix4x4.Identity);
|
||||
_teleportAnim.Begin(AcDream.Core.World.TeleportEntryKind.Portal);
|
||||
Console.WriteLine($"live: teleport started (seq={sequence})");
|
||||
}
|
||||
|
|
@ -8900,6 +8904,7 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0;
|
||||
var (snap, evts) = _teleportAnim.Tick((float)dt, ready, tunnelFrame);
|
||||
_teleportViewPlane.Update(snap);
|
||||
// The fade is a viewport transition below retained UI. If the
|
||||
// installed DATs are corrupt and the retail portal scene could
|
||||
// not be built, retain an opaque world cover during transit.
|
||||
|
|
@ -8934,6 +8939,7 @@ public sealed class GameWindow : IDisposable
|
|||
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
|
||||
_teleportInProgress = false;
|
||||
_pendingTeleportCell = 0u;
|
||||
_teleportViewPlane.Reset();
|
||||
_teleportFadeAlpha = 0f;
|
||||
break;
|
||||
default:
|
||||
|
|
@ -9570,7 +9576,8 @@ public sealed class GameWindow : IDisposable
|
|||
if (_cameraController is not null)
|
||||
{
|
||||
var camera = _cameraController.Active;
|
||||
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * camera.Projection);
|
||||
var worldProjection = _teleportViewPlane.Apply(camera.Projection);
|
||||
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection);
|
||||
|
||||
// Extract camera world position from the inverse of the view
|
||||
// matrix — needed by the scene-lighting UBO (for fog distance)
|
||||
|
|
@ -9799,7 +9806,7 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
// Phase A8: update EnvCellRenderer's frustum. The per-frame shell snapshot
|
||||
// is prepared after the portal-visible cell filter is known.
|
||||
var envCellViewProj = camera.View * camera.Projection;
|
||||
var envCellViewProj = camera.View * worldProjection;
|
||||
_envCellFrustum?.Update(envCellViewProj);
|
||||
|
||||
// MP-Alloc: reuse _animatedIdsScratch instead of `new`ing a
|
||||
|
|
@ -10422,7 +10429,7 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
_debugDrawLogOnce++;
|
||||
}
|
||||
_debugLines.Flush(camera.View, camera.Projection);
|
||||
_debugLines.Flush(camera.View, worldProjection);
|
||||
}
|
||||
|
||||
// Count visible vs total for the perf overlay.
|
||||
|
|
@ -10484,10 +10491,12 @@ public sealed class GameWindow : IDisposable
|
|||
// CreatureMode portal-space viewport. This replacement scene and
|
||||
// its black transition draw BEFORE retained UI, so chat, windows,
|
||||
// cursor, and toolbar remain visible and interactive in transit.
|
||||
var portalProjection = _teleportViewPlane.Apply(
|
||||
_cameraController!.Active.Projection);
|
||||
_portalTunnel?.Draw(
|
||||
_window!.Size.X,
|
||||
_window.Size.Y,
|
||||
_cameraController!.Active.Projection);
|
||||
portalProjection);
|
||||
_fadeOverlay?.Draw(_teleportFadeAlpha);
|
||||
|
||||
// Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ namespace AcDream.App.Rendering;
|
|||
/// Retail portal-space camera from <c>gmSmartBoxUI::PostInit</c>
|
||||
/// (<c>0x004D6D8A</c>) and <c>CreatureMode::SetCameraDirection_Degrees</c>
|
||||
/// (<c>0x00453760</c>). Identity looks along AC +Y with +Z up; the animated
|
||||
/// angle rotates that view around +Z.
|
||||
/// angle rolls that view around its own +Y forward axis.
|
||||
/// </summary>
|
||||
public sealed class PortalTunnelCamera : ICamera
|
||||
{
|
||||
|
|
@ -20,8 +20,9 @@ public sealed class PortalTunnelCamera : ICamera
|
|||
|
||||
/// <summary>
|
||||
/// Retail <c>CreatureMode::UseSmartboxFOV</c> reads the active SmartBox
|
||||
/// projection each draw. Recover its vertical field of view from that
|
||||
/// perspective matrix while retaining this private scene's near/far planes.
|
||||
/// projection each draw. Recover its vertical field of view and near
|
||||
/// plane from that perspective matrix while retaining this private
|
||||
/// scene's bounded far plane.
|
||||
/// </summary>
|
||||
public void UseSmartBoxFov(Matrix4x4 smartBoxProjection)
|
||||
{
|
||||
|
|
@ -32,6 +33,15 @@ public sealed class PortalTunnelCamera : ICamera
|
|||
float fov = 2f * MathF.Atan(1f / verticalScale);
|
||||
if (float.IsFinite(fov) && fov > 0f && fov < MathF.PI)
|
||||
FovRadians = fov;
|
||||
|
||||
// System.Numerics perspective matrices encode near as M43/M33.
|
||||
// During teleport fades this carries Render::set_vdst's
|
||||
// max(0.1, viewPlaneDistance * 0.25) result.
|
||||
float near = smartBoxProjection.M33 != 0f
|
||||
? smartBoxProjection.M43 / smartBoxProjection.M33
|
||||
: float.NaN;
|
||||
if (float.IsFinite(near) && near > 0f && near < Far)
|
||||
Near = near;
|
||||
}
|
||||
|
||||
public Matrix4x4 View
|
||||
|
|
@ -39,9 +49,17 @@ public sealed class PortalTunnelCamera : ICamera
|
|||
get
|
||||
{
|
||||
float radians = DirectionDegrees * (MathF.PI / 180f);
|
||||
Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, radians);
|
||||
Vector3 forward = Vector3.Transform(Vector3.UnitY, rotation);
|
||||
return Matrix4x4.CreateLookAt(RetailEye, RetailEye + forward, Vector3.UnitZ);
|
||||
// CreatureMode::SetCameraDirection_Degrees (0x00453760) resets
|
||||
// the Frame to identity, converts (0, angle, 0) to radians, and
|
||||
// passes that rotation vector to Frame::rotate (0x004525B0).
|
||||
// Frame::rotate interprets the vector as axis * angle, so this is
|
||||
// a roll around AC +Y -- the identity camera's FORWARD axis --
|
||||
// not a yaw around world +Z. The passage therefore stays ahead
|
||||
// while the authored tunnel rolls around the viewer.
|
||||
Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, radians);
|
||||
Vector3 forward = Vector3.UnitY;
|
||||
Vector3 up = Vector3.Transform(Vector3.UnitZ, rotation);
|
||||
return Matrix4x4.CreateLookAt(RetailEye, RetailEye + forward, up);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -247,6 +247,32 @@ public sealed class RetailChaseCamera : ICamera
|
|||
PlayerTranslucency = ComputeTranslucency(d);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>SmartBox::set_viewer(playerPosition, reset_sought: 1)</c>
|
||||
/// (<c>0x00452C40</c>), called by
|
||||
/// <c>SmartBox::PlayerPositionUpdated</c> after a teleport
|
||||
/// (<c>0x00453870</c>). Both the published viewer and its sought
|
||||
/// position restart at the player; subsequent camera updates then
|
||||
/// re-extend the boom through the ordinary damped/swept path.
|
||||
/// </summary>
|
||||
public void ResetViewerToPlayer(Vector3 playerPosition, float playerYaw)
|
||||
{
|
||||
Vector3 playerForward = new(MathF.Cos(playerYaw), MathF.Sin(playerYaw), 0f);
|
||||
|
||||
_publishedEye = playerPosition;
|
||||
_soughtEye = playerPosition;
|
||||
_dampedForward = playerForward;
|
||||
_initialised = true;
|
||||
|
||||
Position = playerPosition;
|
||||
ViewerCellId = 0u; // retail set_viewer clears viewer_cell at the teleport boundary
|
||||
View = Matrix4x4.CreateLookAt(
|
||||
playerPosition,
|
||||
playerPosition + playerForward,
|
||||
Vector3.UnitZ);
|
||||
PlayerTranslucency = 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adjust the camera distance (zoom) by a delta, clamped to
|
||||
/// <see cref="DistanceMin"/>..<see cref="DistanceMax"/>. Mirrors
|
||||
|
|
|
|||
98
src/AcDream.App/Rendering/TeleportViewPlaneController.cs
Normal file
98
src/AcDream.App/Rendering/TeleportViewPlaneController.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Ports retail's teleport projection transition. <c>gmSmartBoxUI::UseTime</c>
|
||||
/// (<c>0x004D6E30</c>) eases SmartBox's view-plane distance between the
|
||||
/// active game's value and <c>TRANSITION_VIEW_PLANE_DISTANCE = 0.001</c>.
|
||||
/// <c>Render::set_vdst</c> (<c>0x0054B240</c>) converts that distance back to
|
||||
/// FOV and adjusts the near plane. This is the wide projection warp visible
|
||||
/// while the destination world emerges from black.
|
||||
/// </summary>
|
||||
public sealed class TeleportViewPlaneController
|
||||
{
|
||||
public const float TransitionViewPlaneDistance = 0.001f;
|
||||
|
||||
private float _gameViewPlaneDistance = 1f;
|
||||
|
||||
public bool Enabled { get; private set; }
|
||||
public float CurrentViewPlaneDistance { get; private set; } = 1f;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>BeginTeleportAnimation</c> captures
|
||||
/// <c>SmartBox::GetOverrideFovDistance</c> once when starting from Off.
|
||||
/// For a standard perspective matrix, M22 is exactly
|
||||
/// <c>cot(verticalFov / 2)</c>, retail's view-plane-distance value.
|
||||
/// </summary>
|
||||
public void Begin(Matrix4x4 gameProjection)
|
||||
{
|
||||
float distance = gameProjection.M22;
|
||||
if (!float.IsFinite(distance) || distance <= 0f)
|
||||
distance = 1f;
|
||||
|
||||
_gameViewPlaneDistance = distance;
|
||||
CurrentViewPlaneDistance = distance;
|
||||
Enabled = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply the four retail fade-state branches. Their projection blend is
|
||||
/// numerically the same table-driven level as the black fade: normal to
|
||||
/// 0.001 on fade-out, and 0.001 back to normal on fade-in.
|
||||
/// </summary>
|
||||
public void Update(TeleportAnimSnapshot snapshot)
|
||||
{
|
||||
Enabled = snapshot.State is TeleportAnimState.WorldFadeOut
|
||||
or TeleportAnimState.TunnelFadeIn
|
||||
or TeleportAnimState.TunnelFadeOut
|
||||
or TeleportAnimState.WorldFadeIn;
|
||||
|
||||
CurrentViewPlaneDistance = Enabled
|
||||
? Lerp(_gameViewPlaneDistance, TransitionViewPlaneDistance, snapshot.FadeAlpha)
|
||||
: _gameViewPlaneDistance;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Enabled = false;
|
||||
CurrentViewPlaneDistance = _gameViewPlaneDistance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuild a perspective projection with retail's active view-plane
|
||||
/// distance while preserving the source projection's aspect and far
|
||||
/// plane. <c>Render::set_vdst</c> uses
|
||||
/// <c>FOV = 2 * atan(1 / distance)</c> and
|
||||
/// <c>znear = max(0.1, distance * 0.25)</c>.
|
||||
/// </summary>
|
||||
public Matrix4x4 Apply(Matrix4x4 baseProjection)
|
||||
{
|
||||
if (!Enabled)
|
||||
return baseProjection;
|
||||
|
||||
float aspect = baseProjection.M11 != 0f
|
||||
? baseProjection.M22 / baseProjection.M11
|
||||
: 1f;
|
||||
float far = baseProjection.M33 != -1f
|
||||
? baseProjection.M43 / (baseProjection.M33 + 1f)
|
||||
: 5000f;
|
||||
|
||||
if (!float.IsFinite(aspect) || aspect <= 0f)
|
||||
aspect = 1f;
|
||||
if (!float.IsFinite(far) || far <= 0.1f)
|
||||
far = 5000f;
|
||||
|
||||
float distance = MathF.Max(CurrentViewPlaneDistance, TransitionViewPlaneDistance);
|
||||
float fov = 2f * MathF.Atan(1f / distance);
|
||||
float near = MathF.Max(0.1f, distance * 0.25f);
|
||||
if (near >= far)
|
||||
near = MathF.Min(0.1f, far * 0.5f);
|
||||
|
||||
return Matrix4x4.CreatePerspectiveFieldOfView(fov, aspect, near, far);
|
||||
}
|
||||
|
||||
private static float Lerp(float from, float to, float amount) =>
|
||||
from + (to - from) * Math.Clamp(amount, 0f, 1f);
|
||||
}
|
||||
|
|
@ -54,7 +54,7 @@ public sealed class PortalTunnelAssetTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void PortalTunnelCamera_UsesRetailEyeAndRotatesAroundAcUpAxis()
|
||||
public void PortalTunnelCamera_RollsAroundForwardAxisWithoutLeavingPassage()
|
||||
{
|
||||
var camera = new PortalTunnelCamera
|
||||
{
|
||||
|
|
@ -68,30 +68,103 @@ public sealed class PortalTunnelAssetTests
|
|||
|
||||
camera.DirectionDegrees = 90f;
|
||||
Assert.True(System.Numerics.Matrix4x4.Invert(camera.View, out var rotatedWorld));
|
||||
System.Numerics.Vector3 forward = System.Numerics.Vector3.Normalize(
|
||||
System.Numerics.Vector3.TransformNormal(-System.Numerics.Vector3.UnitZ, rotatedWorld));
|
||||
System.Numerics.Vector3 up = System.Numerics.Vector3.Normalize(
|
||||
System.Numerics.Vector3.TransformNormal(System.Numerics.Vector3.UnitY, rotatedWorld));
|
||||
Assert.Equal(0f, up.X, precision: 5);
|
||||
|
||||
// Frame::rotate(identity, (0, pi/2, 0)): local +Y remains the
|
||||
// camera's forward direction; local +Z rolls onto global +X.
|
||||
Assert.Equal(0f, forward.X, precision: 5);
|
||||
Assert.Equal(1f, forward.Y, precision: 5);
|
||||
Assert.Equal(0f, forward.Z, precision: 5);
|
||||
Assert.Equal(1f, up.X, precision: 5);
|
||||
Assert.Equal(0f, up.Y, precision: 5);
|
||||
Assert.Equal(1f, up.Z, precision: 5);
|
||||
Assert.Equal(0f, up.Z, precision: 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TeleportViewPlane_FadeOutPortsRetailDistanceFovAndNearPlane()
|
||||
{
|
||||
var baseProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI / 3f,
|
||||
16f / 9f,
|
||||
0.1f,
|
||||
5000f);
|
||||
var controller = new TeleportViewPlaneController();
|
||||
controller.Begin(baseProjection);
|
||||
|
||||
controller.Update(new TeleportAnimSnapshot(
|
||||
TeleportAnimState.TunnelFadeOut,
|
||||
FadeAlpha: 0.5f,
|
||||
ShowTunnel: true,
|
||||
ShowPleaseWait: false));
|
||||
System.Numerics.Matrix4x4 projection = controller.Apply(baseProjection);
|
||||
|
||||
float gameDistance = baseProjection.M22;
|
||||
float expectedDistance = gameDistance
|
||||
+ (TeleportViewPlaneController.TransitionViewPlaneDistance - gameDistance) * 0.5f;
|
||||
float expectedNear = MathF.Max(0.1f, expectedDistance * 0.25f);
|
||||
float actualNear = projection.M43 / projection.M33;
|
||||
|
||||
Assert.True(controller.Enabled);
|
||||
Assert.Equal(expectedDistance, controller.CurrentViewPlaneDistance, precision: 5);
|
||||
Assert.Equal(expectedDistance, projection.M22, precision: 5);
|
||||
Assert.Equal(expectedNear, actualNear, precision: 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TeleportViewPlane_WorldFadeInStartsAtRetailWideProjectionAndRestoresGameProjection()
|
||||
{
|
||||
var baseProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI / 3f,
|
||||
16f / 9f,
|
||||
0.1f,
|
||||
5000f);
|
||||
var controller = new TeleportViewPlaneController();
|
||||
controller.Begin(baseProjection);
|
||||
|
||||
controller.Update(new TeleportAnimSnapshot(
|
||||
TeleportAnimState.WorldFadeIn,
|
||||
FadeAlpha: 1f,
|
||||
ShowTunnel: false,
|
||||
ShowPleaseWait: false));
|
||||
System.Numerics.Matrix4x4 wideProjection = controller.Apply(baseProjection);
|
||||
|
||||
Assert.Equal(
|
||||
TeleportViewPlaneController.TransitionViewPlaneDistance,
|
||||
wideProjection.M22,
|
||||
precision: 5);
|
||||
Assert.Equal(0.1f, wideProjection.M43 / wideProjection.M33, precision: 5);
|
||||
|
||||
controller.Update(new TeleportAnimSnapshot(
|
||||
TeleportAnimState.Off,
|
||||
FadeAlpha: 0f,
|
||||
ShowTunnel: false,
|
||||
ShowPleaseWait: false));
|
||||
|
||||
Assert.False(controller.Enabled);
|
||||
Assert.Equal(baseProjection, controller.Apply(baseProjection));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(45f)]
|
||||
[InlineData(60f)]
|
||||
[InlineData(90f)]
|
||||
public void PortalTunnelCamera_UsesActiveSmartBoxFieldOfView(float degrees)
|
||||
public void PortalTunnelCamera_UsesActiveSmartBoxFieldOfViewAndNearPlane(float degrees)
|
||||
{
|
||||
float expected = degrees * (MathF.PI / 180f);
|
||||
var smartBoxProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
expected,
|
||||
16f / 9f,
|
||||
0.1f,
|
||||
0.35f,
|
||||
5000f);
|
||||
var camera = new PortalTunnelCamera();
|
||||
|
||||
camera.UseSmartBoxFov(smartBoxProjection);
|
||||
|
||||
Assert.Equal(expected, camera.FovRadians, precision: 5);
|
||||
Assert.Equal(0.35f, camera.Near, precision: 5);
|
||||
}
|
||||
|
||||
private static string? ResolveDatDir()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,46 @@ namespace AcDream.App.Tests.Rendering;
|
|||
|
||||
public class RetailChaseCameraTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResetViewerToPlayer_TeleportReseedsViewerAndReextendsFromPlayer()
|
||||
{
|
||||
bool savedColl = CameraDiagnostics.CollideCamera;
|
||||
float savedT = CameraDiagnostics.TranslationStiffness;
|
||||
float savedR = CameraDiagnostics.RotationStiffness;
|
||||
try
|
||||
{
|
||||
CameraDiagnostics.CollideCamera = false;
|
||||
CameraDiagnostics.TranslationStiffness = 0.45f;
|
||||
CameraDiagnostics.RotationStiffness = 0.45f;
|
||||
|
||||
var camera = new RetailChaseCamera();
|
||||
camera.Update(
|
||||
Vector3.Zero, 0f, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f,
|
||||
cellId: 0x100u);
|
||||
|
||||
var destination = new Vector3(200f, -300f, 12f);
|
||||
float yaw = MathF.PI / 2f;
|
||||
camera.ResetViewerToPlayer(destination, yaw);
|
||||
|
||||
Assert.Equal(destination, camera.Position);
|
||||
Assert.Equal(0u, camera.ViewerCellId);
|
||||
|
||||
camera.Update(
|
||||
destination, yaw, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f,
|
||||
cellId: 0x12340100u);
|
||||
|
||||
float firstStep = Vector3.Distance(camera.Position, destination);
|
||||
Assert.InRange(firstStep, 0.001f, 0.3f);
|
||||
Assert.Equal(0x12340100u, camera.ViewerCellId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CameraDiagnostics.CollideCamera = savedColl;
|
||||
CameraDiagnostics.TranslationStiffness = savedT;
|
||||
CameraDiagnostics.RotationStiffness = savedR;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Heading source ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue