acdream/src/AcDream.App/Rendering/RenderBootstrap.cs
Erik 935f4dc3d9 feat(render): V6e — move the world mesh's texture lookup to where Vulkan can express it
Campaign V slice V6e, first of three. mesh_modern is the shader every world
static, every piece of scenery and every EnvCell surface draws through, and it
was one of the four production pairs the SPIR-V toolchain still refused.

The blocker was a varying. Since V2 the vertex stage looked a batch's table slot
up in the binding=9 handle table and forwarded the resulting 64-bit
GL_ARB_bindless_texture handle to the fragment stage as a `flat uvec2`. That
works on GL because a bindless handle is just a number a shader may carry
anywhere. It cannot work on Vulkan at all: the equivalent object is a descriptor
in set 2, and a descriptor is not a value a stage can hand to another stage. So
what travels between the stages is now the SLOT — a `flat uint` — and the
fragment stage does the lookup at the point of sampling.

That relocation needs one shared idea, because the two backends disagree about
what the lookup IS. `ACDREAM_SAMPLE_ARRAY(slot, uvw)` asks the dialect-neutral
question — "sample table slot N" — and expands to
`texture(sampler2DArray(gTextureTable[slot]), uvw)` under GL and to
`texture(uTextures[nonuniformEXT(slot)], uvw)` under Vulkan. It is deliberately
a SAMPLING macro rather than a sampler-returning one: `nonuniformEXT` belongs on
the indexing expression itself, and binding the result to a local
`sampler2DArray` first is exactly where an implementation is free to drop it.
That is the same shape V6d already used for the retained UI's 2-D reads, and it
now covers the array reads the world path needs.

`ACDREAM_TEXTURE_NONE` lands alongside it, unused here and used by the next
commit. GL can ask "does this slot hold a texture" of the payload, because an
unregistered slot holds the null handle; Vulkan cannot, because set 2 is opaque
and reading an unwritten element of a partially-bound array is undefined rather
than zero. The sentinel moves that answer into the index, where both dialects
test it identically.

On GL nothing about the sampled result changes — the same slot resolves to the
same handle to the same texel. The SSBO read simply happens one stage later,
and `flat` keeps it one scalar load per primitive rather than per fragment.

Also: RenderBootstrap has been loading mesh_modern without common.glsl since V2,
which cannot have linked — `ACDREAM_UBO_SET` sits inside a layout qualifier
there. The UI Studio path is the only caller. One argument, same pair, same way
WorldRenderComposition has always loaded it.

Gates: Release build clean; App tests 4,057 passed / 3 skipped (baseline);
offline pixel gate against 95f8c25f differing fraction 3.37e-05 (~19 px of
563,200), inside the documented 15–23 px same-commit noise band and ~30x under
the 0.001 threshold. mesh_modern is the shader that gate covers most heavily,
so this is the strongest automated evidence any V6e commit gets.

Manifest: 4/9 pairs compile (debug_line, mesh_modern, ui_text, vk_probe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:21:22 +02:00

310 lines
14 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!;
/// <summary>
/// Campaign V slice V4a: the studio's own RHI device (mirrors
/// <see cref="HostInputCameraComposition"/>'s production one — the studio
/// composes its own render stack independently of GameWindow).
/// </summary>
internal AcDream.App.Rendering.Gpu.IGpuDevice GpuDevice { get; init; } = null!;
/// <summary>
/// Drives <see cref="AcDream.App.Rendering.Gpu.IGpuDevice.BeginFrame"/>/<c>IGpuFrame.End</c>
/// once per <see cref="BeginFrame"/>/<see cref="EndFrame"/> pair and
/// exposes the open frame to <see cref="UiHost"/>'s <see cref="TextRenderer"/>.
/// </summary>
internal GpuDeviceFrameLifetime FrameLifetime { get; init; } = null!;
private ResourceShutdownTransaction? _shutdown;
internal void BeginFrame() => FrameLifetime.BeginFrame();
internal void EndFrame() => FrameLifetime.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),
// GpuDevice's own Dispose routes every resource release through
// FrameFlights as its retirement queue, so it must be disposed
// before FrameFlights below (see GlGpuDevice.Dispose's comment).
new("GPU device (RHI)", GpuDevice.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,
string DiagnosticsDirectory);
/// <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) ---
// Campaign V slice V6e: mesh_modern has needed common.glsl since V2 —
// ACDREAM_UBO_SET appears in a layout qualifier and ACDREAM_SAMPLE_ARRAY
// at the sample site, and without the preamble both are undeclared
// identifiers, so this program has failed to link on the Studio path
// since that slice. The world composition (WorldRenderComposition) has
// always passed true; this is the same pair loaded the same way.
var meshShader = new Shader(gl,
Path.Combine(shaderDir, "mesh_modern.vert"),
Path.Combine(shaderDir, "mesh_modern.frag"),
includeCommonPreamble: true);
// --- TextureCache (GameWindow ~1774) ---
var frameFlights = new GpuFrameFlightController(gl);
// Campaign V slice V4a: the studio composes its own RHI device
// independently of GameWindow/HostInputCameraComposition, mirroring
// that composition's construction (gl + frame flights + shaders dir).
Gpu.IGpuDevice gpuDevice = new Gpu.Gl.GlGpuDevice(gl, frameFlights, shaderDir);
var gpuFrameLifetime = new GpuDeviceFrameLifetime(gpuDevice);
var textureCache = new TextureCache(
gl,
gpuDevice,
dats,
bindless,
frameFlights,
opts.DiagnosticsDirectory);
// --- AnimLoader (GameWindow ~1240) ---
var animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(dats);
// --- WbMeshAdapter (GameWindow ~2286-2287) ---
var wbLogger = NullLogger<Wb.WbMeshAdapter>.Instance;
var meshAdapter = Wb.WbMeshAdapter.CreateWithLiveDatPreparedAssets(
gl,
gpuDevice,
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(gpuDevice, gpuFrameLifetime, 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,
GpuDevice = gpuDevice,
FrameLifetime = gpuFrameLifetime,
};
// 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;
}
}