This reverts ceec3bc4. Two independent reasons, either sufficient.
The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.
The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.
This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.
The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.
Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.
Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
427 lines
14 KiB
C#
427 lines
14 KiB
C#
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 AcDream.Content;
|
|
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;
|
|
|
|
// 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;
|
|
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 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 IDisposable? _displayNoticeLifetime;
|
|
private readonly SyntheticEntityMeshReferenceOwner _meshReferences;
|
|
|
|
private bool _visible;
|
|
private double _rotationElapsed;
|
|
private double _rotationDuration;
|
|
private float _rotationStartAngle;
|
|
private float _rotationEndAngle;
|
|
private float _rotationCurrentAngle;
|
|
private bool _waitCueVisible;
|
|
private bool _disposeRequested;
|
|
private bool _disposing;
|
|
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,
|
|
IDisposable? displayNoticeLifetime)
|
|
{
|
|
_gl = gl;
|
|
_dispatcher = dispatcher;
|
|
_lightUbo = lightUbo;
|
|
_setup = setup;
|
|
_setupDid = setupDid;
|
|
_animationDid = animationDid;
|
|
_sequence = new CSequence(animationLoader);
|
|
_animationHooks = new PortalAnimationHookQueue(hookSink, SyntheticEntityId);
|
|
_sequence.HookObj = _animationHooks;
|
|
_random = random;
|
|
_displayNotice = displayNotice;
|
|
_displayNoticeLifetime = displayNoticeLifetime;
|
|
|
|
_entity = new WorldEntity
|
|
{
|
|
Id = SyntheticEntityId,
|
|
SourceGfxObjOrSetupId = setupDid,
|
|
Position = Vector3.Zero,
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = SetupMesh.Flatten(setup),
|
|
};
|
|
|
|
_meshReferences = new SyntheticEntityMeshReferenceOwner(
|
|
meshAdapter,
|
|
setup.Parts.Select(part => (ulong)(uint)part));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve retail's enum-mapped portal Setup and animation through the
|
|
/// installed DATs. Retail dereferences this scene unconditionally; missing
|
|
/// required assets therefore fail startup with an actionable diagnostic.
|
|
/// No substitute tunnel is fabricated.
|
|
/// </summary>
|
|
public static PortalTunnelPresentation CreateRequired(
|
|
GL gl,
|
|
IDatReaderWriter dats,
|
|
IAnimationLoader animationLoader,
|
|
IAnimationHookSink hookSink,
|
|
WbDrawDispatcher dispatcher,
|
|
SceneLightingUboBinding lightUbo,
|
|
IWbMeshAdapter meshAdapter,
|
|
Action<string?>? displayNotice = null,
|
|
IDisposable? displayNoticeLifetime = 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);
|
|
|
|
EnsureRequiredAssets(
|
|
setupDid,
|
|
setup is not null,
|
|
animationDid,
|
|
animation is not null);
|
|
|
|
return new PortalTunnelPresentation(
|
|
gl,
|
|
dispatcher,
|
|
lightUbo,
|
|
meshAdapter,
|
|
setup!,
|
|
setupDid,
|
|
animationDid,
|
|
animationLoader,
|
|
hookSink,
|
|
random ?? Random.Shared,
|
|
displayNotice,
|
|
displayNoticeLifetime);
|
|
}
|
|
|
|
internal static void EnsureRequiredAssets(
|
|
uint setupDid,
|
|
bool setupLoaded,
|
|
uint animationDid,
|
|
bool animationLoaded)
|
|
{
|
|
if (setupLoaded && animationLoaded)
|
|
return;
|
|
|
|
throw new InvalidOperationException(
|
|
"[portal-space] required retail DAT assets unavailable: "
|
|
+ $"setup=0x{setupDid:X8} ({(setupLoaded ? "ok" : "missing")}), "
|
|
+ $"animation=0x{animationDid:X8} ({(animationLoaded ? "ok" : "missing")})");
|
|
}
|
|
|
|
public bool IsVisible => _visible;
|
|
public int CurrentAnimationFrame => _sequence.GetCurrFrameNumber();
|
|
public uint SetupDid => _setupDid;
|
|
public uint AnimationDid => _animationDid;
|
|
|
|
/// <summary>
|
|
/// Publishes the synthetic scene's mesh ownership after the presentation
|
|
/// itself has been stored by <c>GameWindow</c>. A partial acquisition stays
|
|
/// reachable and is resumed or released by the same lifetime owner.
|
|
/// </summary>
|
|
internal void PrepareResources()
|
|
{
|
|
ThrowIfDisposed();
|
|
_meshReferences.Acquire();
|
|
}
|
|
|
|
/// <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;
|
|
SetWaitCue(false);
|
|
_visible = true;
|
|
RebuildPose();
|
|
}
|
|
|
|
/// <summary>Hide the CreatureMode viewport and clear its sequence.</summary>
|
|
public void Exit()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_visible = false;
|
|
SetWaitCue(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);
|
|
}
|
|
|
|
public void SetWaitCue(bool visible)
|
|
{
|
|
if (_waitCueVisible == visible)
|
|
return;
|
|
|
|
_waitCueVisible = visible;
|
|
_displayNotice?.Invoke(
|
|
visible ? "In Portal Space - Please Wait..." : null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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)
|
|
{
|
|
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.ClearDepth(1.0);
|
|
_gl.DepthMask(true);
|
|
_gl.Clear(RetailViewportClearMask);
|
|
|
|
_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);
|
|
if (_waitCueVisible)
|
|
_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(_disposeRequested || _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 || _disposing)
|
|
return;
|
|
|
|
_disposeRequested = true;
|
|
_disposing = true;
|
|
try
|
|
{
|
|
_visible = false;
|
|
SetWaitCue(false);
|
|
_animationHooks.Clear();
|
|
_sequence.ClearAnimations();
|
|
_meshReferences.Dispose();
|
|
if (_meshReferences.IsDisposed)
|
|
{
|
|
_displayNoticeLifetime?.Dispose();
|
|
_displayNoticeLifetime = null;
|
|
_disposed = true;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_disposing = false;
|
|
}
|
|
}
|
|
}
|