fix(runtime): align portal and movement presentation
Port retail portal viewport projection and reveal behavior, preserve outbound combat style, drive remote and local grounded movement from authored CSequence root frames, and reuse the local prepared pose so animation hooks advance once. User-verified portal, observer movement, combat stance, and short-tap locomotion gates. Release build passed with 5,767 tests and five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
e95f55f25b
commit
124e046976
30 changed files with 1362 additions and 348 deletions
|
|
@ -303,6 +303,15 @@ public sealed class GameWindow : IDisposable
|
|||
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
|
||||
public AcDream.Core.Physics.AnimationSequencer? Sequencer;
|
||||
|
||||
// The local player's CPartArray is advanced from
|
||||
// PlayerMovementController before its 30 Hz physics integration so
|
||||
// the exact authored root delta enters the collision sweep. Retain
|
||||
// that same advance's part pose for TickAnimations; advancing the
|
||||
// sequencer a second time would double both animation time and hooks.
|
||||
public IReadOnlyList<AcDream.Core.Physics.PartTransform>? PreparedSequenceFrames;
|
||||
public bool SequenceAdvancedBeforeAnimationPass;
|
||||
public readonly DatReaderWriter.Types.Frame RootMotionScratch = new();
|
||||
|
||||
/// <summary>
|
||||
/// MP-Alloc (2026-07-05): reusable per-entity MeshRefs buffer. Every
|
||||
/// animated entity (including idle NPCs on a breathe cycle) used to
|
||||
|
|
@ -422,22 +431,18 @@ public sealed class GameWindow : IDisposable
|
|||
_remoteLastMove = new();
|
||||
|
||||
/// <summary>
|
||||
/// Per-remote-entity dead-reckoning state for smoothing between server
|
||||
/// UpdatePosition broadcasts. Without this, remote characters teleport
|
||||
/// every ~100–200 ms when the server pushes a new position (the retail
|
||||
/// client hides the gap by integrating <c>CMotionInterp</c>-surfaced
|
||||
/// velocity forward each tick — see chunk_00520000.c
|
||||
/// <c>apply_current_movement</c> L7132-L7189 and holtburger's
|
||||
/// <c>spatial/physics.rs::project_pose_by_velocity</c>).
|
||||
/// Per-remote-entity movement state for smoothing between server
|
||||
/// UpdatePosition broadcasts. Retail queues each received position and
|
||||
/// advances the body toward the queue head through
|
||||
/// <c>InterpolationManager::adjust_offset</c> every object tick.
|
||||
///
|
||||
/// <para>
|
||||
/// Each entry records the last authoritative server position + time + a
|
||||
/// measured velocity inferred from the delta between consecutive
|
||||
/// UpdatePositions. The client's per-tick integrator uses the
|
||||
/// sequencer's <c>CurrentVelocity</c> (rotated into world space by the
|
||||
/// entity's orientation) as the primary source and falls back to the
|
||||
/// inferred velocity when the motion table doesn't carry one (e.g. NPC
|
||||
/// motion tables with HasVelocity=0).
|
||||
/// Each entry owns the retail interpolation queue, MovementManager,
|
||||
/// PhysicsBody, latest authoritative position, and the velocity estimate
|
||||
/// used only to select animation cycles for NPCs that receive no motion
|
||||
/// messages. Body translation consumes queue catch-up plus the literal
|
||||
/// root-motion Frame emitted by CSequence; it never guesses movement from
|
||||
/// a Walk/Run command or from packet cadence.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private readonly LiveEntityRemoteMotionRuntimeView<RemoteMotion> _remoteDeadReckon;
|
||||
|
|
@ -684,13 +689,10 @@ public sealed class GameWindow : IDisposable
|
|||
/// <summary>
|
||||
/// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): the
|
||||
/// previous UpdatePosition's world position + timestamp. The per-tick
|
||||
/// path computes <c>(serverPos - prevServerPos) / dt</c> and compares
|
||||
/// it to the sequencer's <c>CurrentVelocity</c>. The ratio tells us
|
||||
/// whether the local-prediction speed (animation root motion) is
|
||||
/// outrunning the server's actual broadcast pace, which would cause
|
||||
/// the InterpolationManager queue to walk back the body each UP and
|
||||
/// produce visible 1-Hz blips. Read in TickAnimations and throttled
|
||||
/// to one log line per remote per ~2 seconds.
|
||||
/// path compares the server's broadcast pace with the literal root
|
||||
/// displacement emitted by <c>CSequence::update</c>. The old inferred
|
||||
/// walk/run velocity was the source of the periodic overshoot this
|
||||
/// diagnostic was introduced to find.
|
||||
/// </summary>
|
||||
public System.Numerics.Vector3 PrevServerPos;
|
||||
public double PrevServerPosTime;
|
||||
|
|
@ -713,12 +715,11 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
public double LastPartsDiagLogTime;
|
||||
/// <summary>
|
||||
/// Diagnostic-only: max |sequencer.CurrentVelocity| observed across
|
||||
/// all per-tick samples since the last UpdatePosition arrival. The
|
||||
/// next UP compares this against (LastServerPos - PrevServerPos) /
|
||||
/// dtServer to compute the overshoot ratio. Reset on each UP.
|
||||
/// Diagnostic-only: maximum scaled CSequence root-motion speed
|
||||
/// observed since the last UpdatePosition arrival. The next UP
|
||||
/// compares it with the server's observed pace. Reset on each UP.
|
||||
/// </summary>
|
||||
public float MaxSeqSpeedSinceLastUP;
|
||||
public float MaxRootMotionSpeedSinceLastUP;
|
||||
|
||||
public RemoteMotion(AcDream.Core.Physics.PhysicsBody? sharedBody = null)
|
||||
{
|
||||
|
|
@ -6476,8 +6477,8 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
// Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous
|
||||
// server-pos snapshot forward AND print the per-UP comparison
|
||||
// between the max sequencer speed observed since last UP and
|
||||
// the actual server broadcast pace. Both sides are now sampled
|
||||
// between the max literal CSequence root-motion speed observed
|
||||
// since the last UP and the actual server broadcast pace. Both are sampled
|
||||
// over the same window so the ratio reflects real overshoot.
|
||||
{
|
||||
double nowSecDiag = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
|
|
@ -6489,17 +6490,17 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
var serverDelta = worldPos - rmState.LastServerPos;
|
||||
float serverSpeed = (float)(serverDelta.Length() / dtServer);
|
||||
float seqSpeed = rmState.MaxSeqSpeedSinceLastUP;
|
||||
if (serverSpeed > 0.1f || seqSpeed > 0.1f)
|
||||
float rootMotionSpeed = rmState.MaxRootMotionSpeedSinceLastUP;
|
||||
if (serverSpeed > 0.1f || rootMotionSpeed > 0.1f)
|
||||
{
|
||||
System.Console.WriteLine(
|
||||
$"[VEL_DIAG] guid={update.Guid:X8} maxSeqSpeed={seqSpeed:F3} m/s "
|
||||
$"[VEL_DIAG] guid={update.Guid:X8} maxRootMotionSpeed={rootMotionSpeed:F3} m/s "
|
||||
+ $"serverSpeed={serverSpeed:F3} m/s dtServer={dtServer:F3}s "
|
||||
+ $"ratio={(serverSpeed > 1e-3f ? seqSpeed / serverSpeed : 0f):F3}");
|
||||
+ $"ratio={(serverSpeed > 1e-3f ? rootMotionSpeed / serverSpeed : 0f):F3}");
|
||||
}
|
||||
}
|
||||
}
|
||||
rmState.MaxSeqSpeedSinceLastUP = 0f;
|
||||
rmState.MaxRootMotionSpeedSinceLastUP = 0f;
|
||||
rmState.PrevServerPos = rmState.LastServerPos;
|
||||
rmState.PrevServerPosTime = rmState.LastServerPosTime;
|
||||
rmState.LastServerPos = worldPos;
|
||||
|
|
@ -9485,6 +9486,11 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
_frameProfiler.FrameBoundary(_gl!);
|
||||
|
||||
// gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the
|
||||
// normal SmartBox viewport and UIElement_Viewport's portal
|
||||
// CreatureMode; it does not redraw the world behind portal space.
|
||||
bool portalViewportVisible = _portalTunnel?.IsVisible == true;
|
||||
|
||||
// Phase G.1: set the clear color from the current sky's fog
|
||||
// tint so the horizon band continues naturally past the
|
||||
// rendered geometry. Fog blends to this color at max distance
|
||||
|
|
@ -9500,11 +9506,22 @@ public sealed class GameWindow : IDisposable
|
|||
// since keyframes may pre-multiply DirBright and produce over-1
|
||||
// values that some drivers interpret as "bright clamp" (pink/green
|
||||
// frames).
|
||||
_gl!.ClearColor(
|
||||
System.Math.Clamp(fogColor.X, 0f, 1f),
|
||||
System.Math.Clamp(fogColor.Y, 0f, 1f),
|
||||
System.Math.Clamp(fogColor.Z, 0f, 1f),
|
||||
1f);
|
||||
if (portalViewportVisible)
|
||||
{
|
||||
// SceneTool::BeginScene @ 0x0043DAD0 begins every retail frame
|
||||
// with RenderDevice.Clear(7, black, 1). The portal viewport later
|
||||
// clears depth only, preserving this frame's black target—not a
|
||||
// previous swap-chain image or the hidden destination world.
|
||||
_gl!.ClearColor(0f, 0f, 0f, 1f);
|
||||
}
|
||||
else
|
||||
{
|
||||
_gl!.ClearColor(
|
||||
System.Math.Clamp(fogColor.X, 0f, 1f),
|
||||
System.Math.Clamp(fogColor.Y, 0f, 1f),
|
||||
System.Math.Clamp(fogColor.Z, 0f, 1f),
|
||||
1f);
|
||||
}
|
||||
|
||||
// §4 outdoor full-world flap (2026-06-10): the depth clear DEPENDS on the depth
|
||||
// write mask — glClear(GL_DEPTH_BUFFER_BIT) is silently gated by glDepthMask.
|
||||
|
|
@ -9514,7 +9531,7 @@ public sealed class GameWindow : IDisposable
|
|||
// feedback_render_self_contained_gl_state: the clear site asserts the state it
|
||||
// depends on rather than inheriting it. The [gl-state] tripwire still detects
|
||||
// any future leak.
|
||||
_gl.DepthMask(true);
|
||||
_gl!.DepthMask(true);
|
||||
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit);
|
||||
|
||||
// WB GameScene.cs:830-843 establishes CW as the frame-global
|
||||
|
|
@ -9582,7 +9599,7 @@ public sealed class GameWindow : IDisposable
|
|||
int visibleLandblocks = 0;
|
||||
int totalLandblocks = 0;
|
||||
|
||||
if (_cameraController is not null)
|
||||
if (_cameraController is not null && !portalViewportVisible)
|
||||
{
|
||||
var activeCamera = _cameraController.Active;
|
||||
var camera = _teleportViewPlane.ApplyTo(activeCamera);
|
||||
|
|
@ -10492,7 +10509,8 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
// Retail gmSmartBoxUI swaps the world SmartBox viewport for a
|
||||
// CreatureMode portal-space viewport. This replacement scene and
|
||||
// CreatureMode portal-space viewport. The world block above is skipped
|
||||
// while this replacement scene is visible. This scene and
|
||||
// its projection transition draws BEFORE retained UI, so chat, windows,
|
||||
// cursor, and toolbar remain visible and interactive in transit.
|
||||
var portalProjection = _teleportViewPlane.Apply(
|
||||
|
|
@ -10736,6 +10754,31 @@ public sealed class GameWindow : IDisposable
|
|||
/// entities (no AnimatedEntity record) are untouched. The static
|
||||
/// renderer reads the new MeshRefs on the next Draw call.
|
||||
/// </summary>
|
||||
private System.Numerics.Vector3 AdvanceLocalPlayerAnimationRoot(float dt)
|
||||
{
|
||||
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
|
||||
|| !_animatedEntities.TryGetValue(entity.Id, out var ae)
|
||||
|| ae.Sequencer is not { } sequencer
|
||||
|| _liveEntities?.ShouldAdvanceRootRuntime(_playerServerGuid) == false
|
||||
|| _liveEntities?.IsHidden(_playerServerGuid) == true)
|
||||
{
|
||||
return System.Numerics.Vector3.Zero;
|
||||
}
|
||||
|
||||
EnsureMotionDoneBinding(_playerServerGuid, ae);
|
||||
|
||||
DatReaderWriter.Types.Frame rootFrame = ae.RootMotionScratch;
|
||||
rootFrame.Origin = System.Numerics.Vector3.Zero;
|
||||
rootFrame.Orientation = System.Numerics.Quaternion.Identity;
|
||||
ae.PreparedSequenceFrames = sequencer.Advance(dt, rootFrame);
|
||||
ae.SequenceAdvancedBeforeAnimationPass = true;
|
||||
|
||||
// TickAnimations will publish this advance's final part transforms
|
||||
// before the deferred hook frame is drained.
|
||||
_animationHookFrames?.Capture(ae.Entity.Id, sequencer);
|
||||
return rootFrame.Origin;
|
||||
}
|
||||
|
||||
private void TickAnimations(float dt)
|
||||
{
|
||||
// Retail has NO stop-detection heuristic — it relies on the
|
||||
|
|
@ -10752,6 +10795,12 @@ public sealed class GameWindow : IDisposable
|
|||
foreach (var kv in _animatedEntities)
|
||||
{
|
||||
var ae = kv.Value;
|
||||
bool sequenceAdvancedBeforeAnimationPass =
|
||||
ae.SequenceAdvancedBeforeAnimationPass;
|
||||
IReadOnlyList<AcDream.Core.Physics.PartTransform>? preparedSequenceFrames =
|
||||
ae.PreparedSequenceFrames;
|
||||
ae.SequenceAdvancedBeforeAnimationPass = false;
|
||||
ae.PreparedSequenceFrames = null;
|
||||
|
||||
// The server guid is carried on the entity itself
|
||||
// (WorldEntity.ServerGuid, set == the _entitiesByServerGuid key at
|
||||
|
|
@ -10790,21 +10839,19 @@ public sealed class GameWindow : IDisposable
|
|||
bool hidden = serverGuid != 0
|
||||
&& _liveEntities?.IsHidden(serverGuid) == true;
|
||||
|
||||
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
|
||||
// The server broadcasts UpdatePosition at ~5-10Hz for distant
|
||||
// entities; without integration, remote chars jitter-hop between
|
||||
// samples. Each tick we advance entity.Position by the
|
||||
// sequencer's current velocity (rotated into world space by the
|
||||
// entity's facing) — matching the retail client's
|
||||
// apply_current_movement (chunk_00520000.c L7132-L7189) and
|
||||
// holtburger's project_pose_by_velocity.
|
||||
//
|
||||
// The cap on predict-distance from the last server pos prevents
|
||||
// runaway when the sequencer's velocity and the server's reality
|
||||
// disagree (e.g. server is rubber-banding the entity). Retail
|
||||
// uses a similar clamp at PhysicsObj::IsInterpolationComplete.
|
||||
// CPhysicsObj::UpdatePositionInternal @ 0x00512C30 advances the
|
||||
// CPartArray FIRST into a local root-motion Frame, then lets the
|
||||
// PositionManager replace that Frame with interpolation catch-up.
|
||||
// Advance remote sequences here so the physics owner consumes the
|
||||
// literal CSequence delta; reconstructing a velocity from the
|
||||
// interpreted Walk/Run command made bodies run past the queue head
|
||||
// and snap backward on the next server position.
|
||||
IReadOnlyList<AcDream.Core.Physics.PartTransform>? seqFrames =
|
||||
sequenceAdvancedBeforeAnimationPass ? preparedSequenceFrames : null;
|
||||
bool sequencerAdvancedForObject = sequenceAdvancedBeforeAnimationPass;
|
||||
if (!hidden
|
||||
&& ae.Sequencer is not null
|
||||
&& !sequencerAdvancedForObject
|
||||
&& serverGuid != 0
|
||||
&& serverGuid != _playerServerGuid
|
||||
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm)
|
||||
|
|
@ -10822,11 +10869,32 @@ public sealed class GameWindow : IDisposable
|
|||
// TickAnimations advance.
|
||||
&& rm.LastServerPosTime > 0)
|
||||
{
|
||||
_remotePhysicsUpdater.Tick(rm, ae, dt, _liveCenterX, _liveCenterY);
|
||||
var remoteRootMotionFrame = new DatReaderWriter.Types.Frame
|
||||
{
|
||||
Origin = System.Numerics.Vector3.Zero,
|
||||
Orientation = System.Numerics.Quaternion.Identity,
|
||||
};
|
||||
seqFrames = ae.Sequencer.Advance(dt, remoteRootMotionFrame);
|
||||
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
|
||||
sequencerAdvancedForObject = true;
|
||||
if (dt > 0f)
|
||||
{
|
||||
float rootMotionSpeed = remoteRootMotionFrame.Origin.Length()
|
||||
* ae.Scale / dt;
|
||||
rm.MaxRootMotionSpeedSinceLastUP = MathF.Max(
|
||||
rm.MaxRootMotionSpeedSinceLastUP,
|
||||
rootMotionSpeed);
|
||||
}
|
||||
_remotePhysicsUpdater.Tick(
|
||||
rm,
|
||||
ae,
|
||||
dt,
|
||||
remoteRootMotionFrame.Origin,
|
||||
_liveCenterX,
|
||||
_liveCenterY);
|
||||
}
|
||||
|
||||
// ── Get per-part (origin, orientation) from either sequencer or legacy ──
|
||||
IReadOnlyList<AcDream.Core.Physics.PartTransform>? seqFrames = null;
|
||||
if (ae.Sequencer is not null)
|
||||
{
|
||||
// Per-tick sequencer-state diag: prove whether the sequencer
|
||||
|
|
@ -10869,18 +10937,21 @@ public sealed class GameWindow : IDisposable
|
|||
rmDiag.LastSeqStateLogTime = nowSec;
|
||||
}
|
||||
}
|
||||
seqFrames = hidden
|
||||
? ae.Sequencer.SampleCurrentPose()
|
||||
: ae.Sequencer.Advance(dt);
|
||||
if (!sequencerAdvancedForObject)
|
||||
{
|
||||
seqFrames = hidden
|
||||
? ae.Sequencer.SampleCurrentPose()
|
||||
: ae.Sequencer.Advance(dt);
|
||||
|
||||
// Capture hooks now, but deliver them only after every final
|
||||
// part transform and equipped-child root has been published.
|
||||
// This matches CPhysicsObj::UpdateObjectInternal followed by
|
||||
// CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect
|
||||
// reads this frame's pose, never the previous frame's pose.
|
||||
// AnimationDone/UseTime remain paired with the deferred drain.
|
||||
if (!hidden)
|
||||
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
|
||||
// Capture hooks now, but deliver them only after every final
|
||||
// part transform and equipped-child root has been published.
|
||||
// This matches CPhysicsObj::UpdateObjectInternal followed by
|
||||
// CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect
|
||||
// reads this frame's pose, never the previous frame's pose.
|
||||
// AnimationDone/UseTime remain paired with the deferred drain.
|
||||
if (!hidden)
|
||||
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -14231,6 +14302,8 @@ public sealed class GameWindow : IDisposable
|
|||
&& playerAE.Sequencer is { } playerSeq)
|
||||
{
|
||||
_playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity);
|
||||
_playerController.ObjectScale = playerAE.Scale;
|
||||
_playerController.AttachAnimationRootMotionSource(AdvanceLocalPlayerAnimationRoot);
|
||||
// R3-W4: bind the player interp's retail seams to the player
|
||||
// sequencer — LeaveGround/HitGround strip transition links here
|
||||
// (the K-fix18 replacement).
|
||||
|
|
|
|||
|
|
@ -15,14 +15,18 @@ public sealed class PortalTunnelCamera : ICamera
|
|||
public float DirectionDegrees { get; set; }
|
||||
public float FovRadians { get; set; } = MathF.PI / 4f;
|
||||
public float Near { get; set; } = 0.1f;
|
||||
public float Far { get; set; } = 50f;
|
||||
// Render::zfar defaults to 4000 in retail (0x0081EC88). CreatureMode::Render
|
||||
// does not install a private far plane: PrimD3DRender::SetFOVInternal
|
||||
// (0x0059AB40) rebuilds the portal projection with that shared value.
|
||||
public float Far { get; set; } = 4000f;
|
||||
public float Aspect { get; set; } = 1f;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CreatureMode::UseSmartboxFOV</c> reads the active SmartBox
|
||||
/// 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.
|
||||
/// and far planes from that perspective matrix. Retail's CreatureMode
|
||||
/// changes the FOV through the SmartBox path while retaining the renderer's
|
||||
/// shared near/far clip range.
|
||||
/// </summary>
|
||||
public void UseSmartBoxFov(Matrix4x4 smartBoxProjection)
|
||||
{
|
||||
|
|
@ -40,6 +44,11 @@ public sealed class PortalTunnelCamera : ICamera
|
|||
float near = smartBoxProjection.M33 != 0f
|
||||
? smartBoxProjection.M43 / smartBoxProjection.M33
|
||||
: float.NaN;
|
||||
float far = smartBoxProjection.M33 != -1f
|
||||
? smartBoxProjection.M43 / (smartBoxProjection.M33 + 1f)
|
||||
: float.NaN;
|
||||
if (float.IsFinite(far) && far > 0f)
|
||||
Far = far;
|
||||
if (float.IsFinite(near) && near > 0f && near < Far)
|
||||
Near = near;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@ public sealed class PortalTunnelPresentation : IDisposable
|
|||
public const uint AnimationClientEnum = 0x10000002u;
|
||||
public const uint ClientEnumCategory = 7u;
|
||||
|
||||
// UIViewportObject::DrawContent @ 0x006950A5 calls
|
||||
// RenderDeviceD3D::Clear(4, RGBAColor_Black, 1). Clear @ 0x0059FD30
|
||||
// maps retail flag 4 to D3DCLEAR_ZBUFFER only: portal space deliberately
|
||||
// preserves the black color target established by SceneTool::BeginScene's
|
||||
// whole-frame Clear(7) while replacing the hidden world viewport.
|
||||
internal const ClearBufferMask RetailViewportClearMask = ClearBufferMask.DepthBufferBit;
|
||||
|
||||
private const uint SyntheticEntityId = 0xFFFF_FF01u;
|
||||
private const uint SyntheticLandblockId = 0u;
|
||||
private const float RotationDurationMin = 0.6f;
|
||||
|
|
@ -222,8 +229,9 @@ public sealed class PortalTunnelPresentation : IDisposable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace the already-rendered world viewport with retail portal space.
|
||||
/// The caller then draws retained UI above this pass.
|
||||
/// Draw retail portal space into the active viewport. The caller suppresses
|
||||
/// the normal world viewport while this scene is visible, then draws the
|
||||
/// retained UI above it.
|
||||
/// </summary>
|
||||
public void Draw(int width, int height, Matrix4x4 smartBoxProjection)
|
||||
{
|
||||
|
|
@ -236,10 +244,9 @@ public sealed class PortalTunnelPresentation : IDisposable
|
|||
|
||||
_gl.Viewport(0, 0, (uint)width, (uint)height);
|
||||
_gl.Disable(EnableCap.ScissorTest);
|
||||
_gl.ClearColor(0f, 0f, 0f, 1f);
|
||||
_gl.ClearDepth(1.0);
|
||||
_gl.DepthMask(true);
|
||||
_gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
||||
_gl.Clear(RetailViewportClearMask);
|
||||
|
||||
_gl.Enable(EnableCap.DepthTest);
|
||||
_gl.DepthFunc(DepthFunction.Less);
|
||||
|
|
|
|||
52
src/AcDream.App/Rendering/Sky/SkyProjection.cs
Normal file
52
src/AcDream.App/Rendering/Sky/SkyProjection.cs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering.Sky;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the temporary sky projection used by retail <c>GameSky::Draw</c>
|
||||
/// (<c>0x00506FF0</c>): preserve the active viewport's field of view and
|
||||
/// projection handedness, changing only its near/far depth mapping.
|
||||
/// </summary>
|
||||
internal static class SkyProjection
|
||||
{
|
||||
public static Matrix4x4 WithDepthRange(
|
||||
in Matrix4x4 activeProjection,
|
||||
float near,
|
||||
float far)
|
||||
{
|
||||
if (!float.IsFinite(near) || !float.IsFinite(far)
|
||||
|| near <= 0f || far <= near)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(far),
|
||||
"Sky depth range must be finite with 0 < near < far.");
|
||||
}
|
||||
|
||||
var result = activeProjection;
|
||||
|
||||
// System.Numerics perspective matrices use M34=-1 (right-handed).
|
||||
// Keep the source matrix's X/Y scale and offsets verbatim so a
|
||||
// teleport projection remains pixel-aligned with terrain. The
|
||||
// positive branch also preserves a left-handed source should one be
|
||||
// supplied by a future backend.
|
||||
if (activeProjection.M34 < 0f)
|
||||
{
|
||||
result.M33 = far / (near - far);
|
||||
result.M43 = near * far / (near - far);
|
||||
}
|
||||
else if (activeProjection.M34 > 0f)
|
||||
{
|
||||
result.M33 = far / (far - near);
|
||||
result.M43 = -near * far / (far - near);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Sky projection must be perspective (M34 cannot be zero).",
|
||||
nameof(activeProjection));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -162,13 +162,13 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
{
|
||||
if (group is null || group.SkyObjects.Count == 0) return;
|
||||
|
||||
// Build a sky projection with a huge far plane so 1e6m-distant
|
||||
// celestial meshes don't clip. The FOV is cargo-culted from the
|
||||
// camera's projection — see WorldBuilder's implementation.
|
||||
float fovY = MathF.PI / 3f; // 60° — matches FlyCamera/ChaseCamera
|
||||
float aspect = camera.Aspect;
|
||||
if (aspect <= 0f) aspect = 16f / 9f;
|
||||
var skyProj = Matrix4x4.CreatePerspectiveFieldOfView(fovY, aspect, Near, Far);
|
||||
// Keep sky meshes inside their authored depth range without
|
||||
// replacing the active viewport's horizontal/vertical projection.
|
||||
// Retail installs SmartBox's active projection before LScape::draw
|
||||
// (SmartBox::RenderNormalMode @ 0x00453AA0), then GameSky::Draw
|
||||
// (@ 0x00506FF0) changes ONLY zfar to 4x for the sky draw. Preserve
|
||||
// that FOV here, including the near-180-degree teleport transition.
|
||||
var skyProj = SkyProjection.WithDepthRange(camera.Projection, Near, Far);
|
||||
|
||||
// View with translation zeroed — keeps the sky at camera origin
|
||||
// regardless of camera position in the world.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue