feat(world): port retail portal-space viewport

Replace the opaque teleport cover with the DAT-authored CreatureMode tunnel, exact retail camera/light/animation timing and easing, and animation-hook audio routing. Compose portal and fade passes below retained UI so gameplay windows and input remain active through F751 travel.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-15 21:59:23 +02:00
parent 0eab7497c1
commit eab23cbdd1
14 changed files with 983 additions and 45 deletions

View file

@ -0,0 +1,363 @@
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.App.UI;
using AcDream.Content.Vfx;
using AcDream.Core.Lighting;
using AcDream.Core.Meshing;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// The retail portal-space CreatureMode scene owned by
/// <c>gmSmartBoxUI</c> (<c>PostInit 0x004D6B60</c>,
/// <c>UseTime 0x004D6E30</c>). It is a synthetic DAT Setup animated at
/// 40 frames/second and drawn as a replacement 3-D viewport beneath the
/// retained gameplay UI.
/// </summary>
public sealed class PortalTunnelPresentation : IDisposable
{
public const uint SetupClientEnum = 0x10000001u;
public const uint AnimationClientEnum = 0x10000002u;
public const uint ClientEnumCategory = 7u;
private const uint SyntheticEntityId = 0xFFFF_FF01u;
private const uint SyntheticLandblockId = 0u;
private const float RotationDurationMin = 0.6f;
private const float RotationDurationMax = 1.8f;
private static readonly HashSet<uint> AnimatedIds = new() { SyntheticEntityId };
private readonly GL _gl;
private readonly WbDrawDispatcher _dispatcher;
private readonly SceneLightingUboBinding _lightUbo;
private readonly IWbMeshAdapter _meshAdapter;
private readonly Setup _setup;
private readonly uint _setupDid;
private readonly uint _animationDid;
private readonly CSequence _sequence;
private readonly PortalAnimationHookQueue _animationHooks;
private readonly WorldEntity _entity;
private readonly PortalTunnelCamera _camera = new();
private readonly Random _random;
private readonly Action<string>? _displayNotice;
private readonly uint[] _registeredGfxObjects;
private bool _visible;
private double _rotationElapsed;
private double _rotationDuration;
private float _rotationStartAngle;
private float _rotationEndAngle;
private float _rotationCurrentAngle;
private bool _disposed;
private PortalTunnelPresentation(
GL gl,
WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo,
IWbMeshAdapter meshAdapter,
Setup setup,
uint setupDid,
uint animationDid,
IAnimationLoader animationLoader,
IAnimationHookSink hookSink,
Random random,
Action<string>? displayNotice)
{
_gl = gl;
_dispatcher = dispatcher;
_lightUbo = lightUbo;
_meshAdapter = meshAdapter;
_setup = setup;
_setupDid = setupDid;
_animationDid = animationDid;
_sequence = new CSequence(animationLoader);
_animationHooks = new PortalAnimationHookQueue(hookSink, SyntheticEntityId);
_sequence.HookObj = _animationHooks;
_random = random;
_displayNotice = displayNotice;
_entity = new WorldEntity
{
Id = SyntheticEntityId,
SourceGfxObjOrSetupId = setupDid,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = SetupMesh.Flatten(setup),
};
_registeredGfxObjects = setup.Parts
.Select(part => (uint)part)
.Where(id => id != 0u)
.Distinct()
.ToArray();
foreach (uint gfxObjId in _registeredGfxObjects)
_meshAdapter.IncrementRefCount(gfxObjId);
}
/// <summary>
/// Resolve retail's enum-mapped portal Setup and animation through the
/// installed DATs. Missing assets fail closed with an actionable message;
/// no substitute tunnel is fabricated.
/// </summary>
public static PortalTunnelPresentation? TryCreate(
GL gl,
DatCollection dats,
IAnimationLoader animationLoader,
IAnimationHookSink hookSink,
WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo,
IWbMeshAdapter meshAdapter,
Action<string>? displayNotice = null,
Random? random = null)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(animationLoader);
ArgumentNullException.ThrowIfNull(hookSink);
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(lightUbo);
ArgumentNullException.ThrowIfNull(meshAdapter);
uint setupDid = RetailDataIdResolver.Resolve(dats, SetupClientEnum, ClientEnumCategory);
uint animationDid = RetailDataIdResolver.Resolve(dats, AnimationClientEnum, ClientEnumCategory);
Setup? setup = setupDid == 0u ? null : dats.Get<Setup>(setupDid);
Animation? animation = animationDid == 0u
? null
: animationLoader.LoadAnimation(animationDid);
if (setup is null || animation is null)
{
Console.Error.WriteLine(
"[portal-space] retail DAT assets unavailable: "
+ $"setup=0x{setupDid:X8} ({(setup is null ? "missing" : "ok")}), "
+ $"animation=0x{animationDid:X8} ({(animation is null ? "missing" : "ok")})");
return null;
}
return new PortalTunnelPresentation(
gl,
dispatcher,
lightUbo,
meshAdapter,
setup,
setupDid,
animationDid,
animationLoader,
hookSink,
random ?? Random.Shared,
displayNotice);
}
public bool IsVisible => _visible;
public int CurrentAnimationFrame => _sequence.GetCurrFrameNumber();
public uint SetupDid => _setupDid;
public uint AnimationDid => _animationDid;
/// <summary>
/// Retail <c>set_sequence_animation(anim, clear=1, low=1, fps=40)</c>
/// on the edge where portal space becomes visible.
/// </summary>
public void Enter()
{
ThrowIfDisposed();
_animationHooks.Clear();
_sequence.ClearAnimations();
_sequence.AppendAnimation(new AnimData
{
AnimId = (QualifiedDataId<Animation>)_animationDid,
LowFrame = 1,
HighFrame = -1,
Framerate = TeleportAnimSequencer.TunnelFramesPerSecond,
});
_rotationElapsed = 0.0;
_rotationDuration = 0.0;
_rotationStartAngle = 0f;
_rotationEndAngle = 0f;
_rotationCurrentAngle = 0f;
_camera.DirectionDegrees = 0f;
_visible = true;
RebuildPose();
}
/// <summary>Hide the CreatureMode viewport and clear its sequence.</summary>
public void Exit()
{
if (_disposed)
return;
_visible = false;
_animationHooks.Clear();
_sequence.ClearAnimations();
}
public void Tick(float dt)
{
if (!_visible || dt < 0f)
return;
_sequence.Update(dt, frame: null);
RebuildPose();
_animationHooks.Drain(Vector3.Zero);
TickRotation(dt);
}
/// <summary>
/// Replace the already-rendered world viewport with retail portal space.
/// The caller then draws the fade and retained UI above this pass.
/// </summary>
public void Draw(int width, int height, Matrix4x4 smartBoxProjection)
{
if (!_visible || width <= 0 || height <= 0 || _entity.MeshRefs.Count == 0)
return;
_camera.Aspect = width / (float)height;
_camera.UseSmartBoxFov(smartBoxProjection);
using var scope = new GLStateScope(_gl);
_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.Enable(EnableCap.DepthTest);
_gl.DepthFunc(DepthFunction.Less);
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
_gl.Disable(EnableCap.Blend);
UploadRetailLight();
// The dispatcher is shared with the world pass. Portal space is its
// own CreatureMode scene: it has no world-cell clip routing and no
// world point lights, only the distant light installed above.
_dispatcher.ClearClipRouting();
_dispatcher.SetSceneLights(null);
var entries = new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>, IReadOnlyDictionary<uint, WorldEntity>?)[]
{
(SyntheticLandblockId,
new Vector3(-16f, -16f, -16f),
new Vector3(16f, 16f, 16f),
new WorldEntity[] { _entity },
null),
};
_dispatcher.Draw(
_camera,
entries,
frustum: null,
neverCullLandblockId: SyntheticLandblockId,
visibleCellIds: null,
animatedEntityIds: AnimatedIds);
}
private void TickRotation(float dt)
{
_rotationElapsed += dt;
if (_rotationElapsed >= _rotationDuration)
{
_rotationCurrentAngle = _rotationEndAngle;
_rotationElapsed = 0.0;
_rotationDuration = NextDouble(RotationDurationMin, RotationDurationMax);
_rotationStartAngle = _rotationCurrentAngle;
_rotationEndAngle = (float)NextDouble(0.0, 360.0);
_displayNotice?.Invoke("In Portal Space - Please Wait...");
}
else
{
float t = _rotationDuration <= 0.0
? 1f
: (float)(_rotationElapsed / _rotationDuration);
float level = TeleportAnimSequencer.GetRetailAnimationLevel(t) / 1024f;
_rotationCurrentAngle = _rotationStartAngle
+ ((_rotationEndAngle - _rotationStartAngle) * level);
}
_camera.DirectionDegrees = _rotationCurrentAngle;
}
private double NextDouble(double min, double max) => min + (_random.NextDouble() * (max - min));
private void RebuildPose()
{
AnimationFrame? frame = _sequence.GetCurrAnimframe();
_entity.MeshRefs = SetupMesh.Flatten(_setup, frame);
}
private void UploadRetailLight()
{
Vector3 direction = Vector3.Normalize(new Vector3(0.3f, -1.9f, 0.65f));
_lightUbo.Upload(new SceneLightingUbo
{
Light0 = new UboLight
{
PosAndKind = Vector4.Zero,
DirAndRange = new Vector4(direction, 1e9f),
ColorAndIntensity = new Vector4(1f, 1f, 1f, 2f),
ConeAngleEtc = Vector4.Zero,
},
CellAmbient = new Vector4(0.3f, 0.3f, 0.3f, 1f),
FogParams = new Vector4(1e9f, 1e9f, 0f, 0f),
FogColor = Vector4.Zero,
CameraAndTime = new Vector4(PortalTunnelCamera.RetailEye, 0f),
});
}
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
/// <summary>
/// Retail stages animation hooks while advancing the sequence, then drains
/// them after the object's current pose has been committed. Portal space
/// has no live-world record, so it owns this small equivalent queue and
/// forwards its DAT-authored hooks through the shared hook router.
/// </summary>
private sealed class PortalAnimationHookQueue : IAnimHookQueue
{
private readonly IAnimationHookSink _sink;
private readonly uint _ownerId;
private readonly List<AnimationHook> _pending = new();
public PortalAnimationHookQueue(IAnimationHookSink sink, uint ownerId)
{
_sink = sink;
_ownerId = ownerId;
}
public void AddAnimHook(AnimationHook hook) => _pending.Add(hook);
public void AddAnimDoneHook()
{
// The tunnel animation is retail's cyclic tail, so AnimDone is
// not observable during the portal-space presentation.
}
public void Drain(Vector3 worldPosition)
{
for (int i = 0; i < _pending.Count; i++)
_sink.OnHook(_ownerId, worldPosition, _pending[i]);
_pending.Clear();
}
public void Clear() => _pending.Clear();
}
public void Dispose()
{
if (_disposed)
return;
Exit();
_animationHooks.Clear();
foreach (uint gfxObjId in _registeredGfxObjects)
_meshAdapter.DecrementRefCount(gfxObjId);
_disposed = true;
}
}