Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
269 lines
12 KiB
C#
269 lines
12 KiB
C#
using System.Collections.Concurrent;
|
|
using AcDream.Content;
|
|
using DatReaderWriter;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Silk.NET.OpenGL;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>
|
|
/// The subset of the production render stack that the UI Studio needs:
|
|
/// GL + dats + UiHost + the WB mesh pipeline. Constructed from the same
|
|
/// classes and the same order as <see cref="GameWindow.OnLoad"/>, minus
|
|
/// terrain / sky / physics / streaming.
|
|
/// </summary>
|
|
public sealed record RenderStack(
|
|
GL Gl,
|
|
IDatReaderWriter Dats,
|
|
string ShaderDir,
|
|
Wb.BindlessSupport Bindless,
|
|
TextureCache TextureCache,
|
|
Shader MeshShader,
|
|
Wb.WbMeshAdapter MeshAdapter,
|
|
Wb.EntitySpawnAdapter EntitySpawnAdapter,
|
|
Wb.WbDrawDispatcher DrawDispatcher,
|
|
SceneLightingUboBinding LightingUbo,
|
|
AcDream.App.UI.UiHost UiHost,
|
|
AcDream.App.UI.UiDatFont? VitalsDatFont,
|
|
AcDream.App.UI.UiDatFont? LargeDatFont) : System.IDisposable
|
|
{
|
|
internal GpuFrameFlightController FrameFlights { get; init; } = null!;
|
|
private ResourceShutdownTransaction? _shutdown;
|
|
|
|
internal void BeginFrame()
|
|
{
|
|
FrameFlights.BeginFrame();
|
|
UiHost.TextRenderer.BeginFrame(FrameFlights.CurrentSlot);
|
|
}
|
|
|
|
internal void EndFrame() => FrameFlights.EndFrame();
|
|
|
|
/// <summary>Dispose the GL pieces this stack OWNS (everything created in
|
|
/// <see cref="RenderBootstrap.Create"/>). <see cref="Dats"/> + <see cref="Gl"/> are caller-owned
|
|
/// and NOT disposed here. Called once at studio teardown.</summary>
|
|
public void Dispose()
|
|
{
|
|
_shutdown ??= new ResourceShutdownTransaction(
|
|
new ResourceShutdownStage("submitted GPU work",
|
|
[
|
|
new("frame flight drain", FrameFlights.WaitForSubmittedWork),
|
|
]),
|
|
new ResourceShutdownStage("draw frontend",
|
|
[
|
|
new("draw dispatcher", DrawDispatcher.Dispose),
|
|
]),
|
|
new ResourceShutdownStage("mesh adapter",
|
|
[
|
|
new("mesh adapter", MeshAdapter.Dispose),
|
|
]),
|
|
new ResourceShutdownStage("remaining render stack",
|
|
[
|
|
new("texture cache", TextureCache.Dispose),
|
|
new("mesh shader", MeshShader.Dispose),
|
|
new("lighting UBO", LightingUbo.Dispose),
|
|
new("UI host", UiHost.Dispose),
|
|
]),
|
|
new ResourceShutdownStage("frame flight owner",
|
|
[
|
|
new("frame flights", FrameFlights.Dispose),
|
|
]));
|
|
_shutdown.CompleteOrThrow();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a sprite id (0x06xxxxxx) to a (GL handle, width, height) triple.
|
|
/// Copied verbatim from GameWindow's ResolveChrome closure — it calls
|
|
/// TextureCache.GetOrUploadRenderSurface(id, out w, out h).
|
|
/// </summary>
|
|
public (uint handle, int width, int height) ResolveChrome(uint spriteId)
|
|
{
|
|
uint t = TextureCache.GetOrUploadRenderSurface(spriteId, out int w, out int h);
|
|
return (t, w, h);
|
|
}
|
|
|
|
// ── Font cache (per-stack, keyed by FontDid) ─────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Cache of loaded dat fonts keyed by FontDid (0x40000000-range).
|
|
/// Populated lazily by <see cref="ResolveDatFont"/>. Thread-safe for
|
|
/// concurrent reads from the studio render loop; writes happen only
|
|
/// during the first load of each distinct FontDid.
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<uint, AcDream.App.UI.UiDatFont?> _fontCache = new();
|
|
|
|
/// <summary>
|
|
/// Lazily load and cache a dat font by its FontDid. Returns null (and
|
|
/// caches null) when the Font DBObj is absent or has no foreground surface —
|
|
/// callers fall back to the global font in that case.
|
|
///
|
|
/// <para>Pre-seeds <see cref="VitalsDatFont"/> (0x40000000) and
|
|
/// <see cref="LargeDatFont"/> (0x40000001) from the already-loaded instances
|
|
/// to avoid a redundant upload on those two ids.</para>
|
|
/// </summary>
|
|
public AcDream.App.UI.UiDatFont? ResolveDatFont(uint fontDid)
|
|
{
|
|
return _fontCache.GetOrAdd(fontDid, id =>
|
|
AcDream.App.UI.UiDatFont.Load(Dats, TextureCache, id));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pre-seeds the font cache from the two already-loaded font instances
|
|
/// (VitalsDatFont = 0x40000000, LargeDatFont = 0x40000001) so that
|
|
/// <see cref="ResolveDatFont"/> returns them without a redundant GL upload.
|
|
/// Called once by <see cref="RenderBootstrap.Create"/> after the stack is
|
|
/// fully constructed.
|
|
/// </summary>
|
|
internal void SeedFontCache()
|
|
{
|
|
if (VitalsDatFont is not null)
|
|
_fontCache.TryAdd(AcDream.App.UI.UiDatFont.DefaultFontId, VitalsDatFont);
|
|
if (LargeDatFont is not null)
|
|
_fontCache.TryAdd(0x40000001u, LargeDatFont);
|
|
}
|
|
}
|
|
|
|
/// <summary>Options for <see cref="RenderBootstrap.Create"/>.</summary>
|
|
public sealed record RenderBootstrapOptions(
|
|
AcDream.UI.Abstractions.Settings.QualitySettings Quality);
|
|
|
|
/// <summary>
|
|
/// Constructs the UI Studio's render stack from the production classes,
|
|
/// in the same order as <see cref="GameWindow.OnLoad"/>.
|
|
/// </summary>
|
|
public static class RenderBootstrap
|
|
{
|
|
/// <summary>
|
|
/// Build the studio's render stack. Throws <see cref="NotSupportedException"/>
|
|
/// (same message as GameWindow) if GL_ARB_bindless_texture or
|
|
/// GL_ARB_shader_draw_parameters are absent — the modern path is mandatory.
|
|
/// </summary>
|
|
public static RenderStack Create(
|
|
GL gl,
|
|
IDatReaderWriter dats,
|
|
RenderBootstrapOptions opts)
|
|
{
|
|
// --- Bindless detection (GameWindow ~1701-1723) ---
|
|
if (!Wb.BindlessSupport.TryCreate(gl, out var bindless)
|
|
|| bindless is null
|
|
|| !bindless.HasShaderDrawParameters(gl))
|
|
{
|
|
throw new NotSupportedException(
|
|
"acdream requires GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters " +
|
|
"(GL 4.3+ with bindless support). Your GPU/driver does not expose these extensions. " +
|
|
"If this is unexpected, please file a bug report with your GPU vendor + driver version.");
|
|
}
|
|
|
|
// --- Shared infra (GameWindow ~1198, ~1211) ---
|
|
string shaderDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders");
|
|
var lightingUbo = new SceneLightingUboBinding(gl);
|
|
|
|
// --- Mesh shader (GameWindow ~1769-1771) ---
|
|
var meshShader = new Shader(gl,
|
|
Path.Combine(shaderDir, "mesh_modern.vert"),
|
|
Path.Combine(shaderDir, "mesh_modern.frag"));
|
|
|
|
// --- TextureCache (GameWindow ~1774) ---
|
|
var frameFlights = new GpuFrameFlightController(gl);
|
|
var textureCache = new TextureCache(gl, dats, bindless, frameFlights);
|
|
|
|
// --- AnimLoader (GameWindow ~1240) ---
|
|
var animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(dats);
|
|
|
|
// --- WbMeshAdapter (GameWindow ~2286-2287) ---
|
|
var wbLogger = NullLogger<Wb.WbMeshAdapter>.Instance;
|
|
var meshAdapter = new Wb.WbMeshAdapter(gl, dats, wbLogger, frameFlights);
|
|
|
|
// --- SequencerFactory (GameWindow ~2306-2334) ---
|
|
var capturedDats = dats;
|
|
var capturedAnimLoader = animLoader;
|
|
AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e)
|
|
{
|
|
if (capturedDats is not null && capturedAnimLoader is not null)
|
|
{
|
|
var setup = capturedDats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
|
|
if (setup is not null)
|
|
{
|
|
uint mtableId = (uint)setup.DefaultMotionTable;
|
|
if (mtableId != 0)
|
|
{
|
|
var mtable = capturedDats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
|
|
if (mtable is not null)
|
|
return new AcDream.Core.Physics.AnimationSequencer(
|
|
setup, mtable, capturedAnimLoader);
|
|
}
|
|
// Setup exists but no motion table — no-op sequencer.
|
|
return new AcDream.Core.Physics.AnimationSequencer(
|
|
setup,
|
|
new DatReaderWriter.DBObjs.MotionTable(),
|
|
capturedAnimLoader);
|
|
}
|
|
}
|
|
// Complete fallback: empty setup + empty motion table + null loader.
|
|
return new AcDream.Core.Physics.AnimationSequencer(
|
|
new DatReaderWriter.DBObjs.Setup(),
|
|
new DatReaderWriter.DBObjs.MotionTable(),
|
|
new NullAnimLoader());
|
|
}
|
|
|
|
// --- EntitySpawnAdapter (GameWindow ~2335-2336) ---
|
|
var entitySpawnAdapter = new Wb.EntitySpawnAdapter(
|
|
textureCache, SequencerFactory, meshAdapter);
|
|
|
|
// --- EntityClassificationCache (GameWindow ~217 — field initializer, new()) ---
|
|
var classificationCache = new Wb.EntityClassificationCache();
|
|
|
|
// --- TranslucencyFadeManager (GameWindow — field initializer, new()) ---
|
|
var translucencyFades = new AcDream.Core.Rendering.TranslucencyFadeManager();
|
|
|
|
// --- WbDrawDispatcher (GameWindow ~2377-2381) ---
|
|
var drawDispatcher = new Wb.WbDrawDispatcher(
|
|
gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter,
|
|
bindless, classificationCache, translucencyFades);
|
|
drawDispatcher.AlphaToCoverage = opts.Quality.AlphaToCoverage;
|
|
|
|
// --- Vitals dat font (GameWindow ~1820-1822) ---
|
|
var vitalsDatFont = AcDream.App.UI.UiDatFont.Load(dats, textureCache);
|
|
|
|
// --- Larger retail font (0x40000001, MaxCharHeight=18) for attribute row text.
|
|
// The default font (0x40000000, 16px) renders the row names too small; the 18px
|
|
// variant (confirmed in client_portal.dat 2026-06-26) matches the retail character
|
|
// window list more closely (≈ icon height ≈ 24px target, 18px is best available).
|
|
var largeDatFont = AcDream.App.UI.UiDatFont.Load(dats, textureCache, 0x40000001u);
|
|
|
|
// --- UiHost (GameWindow ~1790); pass null for debugFont (only used as
|
|
// a fallback BitmapFont for the world-space HUD — not needed for the
|
|
// UI Studio, and BitmapFont requires a system font byte array) ---
|
|
var uiHost = new AcDream.App.UI.UiHost(gl, shaderDir, defaultFont: null);
|
|
|
|
var stack = new RenderStack(
|
|
Gl: gl,
|
|
Dats: dats,
|
|
ShaderDir: shaderDir,
|
|
Bindless: bindless,
|
|
TextureCache: textureCache,
|
|
MeshShader: meshShader,
|
|
MeshAdapter: meshAdapter,
|
|
EntitySpawnAdapter: entitySpawnAdapter,
|
|
DrawDispatcher: drawDispatcher,
|
|
LightingUbo: lightingUbo,
|
|
UiHost: uiHost,
|
|
VitalsDatFont: vitalsDatFont,
|
|
LargeDatFont: largeDatFont)
|
|
{
|
|
FrameFlights = frameFlights,
|
|
};
|
|
|
|
// Pre-seed the font cache with the two already-uploaded atlas instances
|
|
// so ResolveDatFont(0x40000000) and ResolveDatFont(0x40000001) hit the cache
|
|
// rather than re-uploading the same GL texture a second time.
|
|
stack.SeedFontCache();
|
|
|
|
return stack;
|
|
}
|
|
|
|
// NullAnimLoader mirrors GameWindow's private NullAnimLoader (GameWindow ~13327-13330).
|
|
private sealed class NullAnimLoader : AcDream.Core.Physics.IAnimationLoader
|
|
{
|
|
public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null;
|
|
}
|
|
}
|