feat(render): put the retained UI and debug lines on both backends

Campaign V slice V6d, commit 2 of 3. TextRenderer and DebugLineRenderer were the only two renderers speaking the RHI, and both refused any device that was not a GlGpuDevice. They now refuse nothing: this is the first production rendering acdream can do on Vulkan.

Three things had to go.

The loose uniforms. debug_line declared uView and uProjection separately and DebugLineRenderer set them straight against the compiled GL program, because the pinned push-constant block carries one combined matrix and IGpuPassEncoder has no verb for arbitrary named uniforms. That was never portable — Vulkan has no default uniform block at all — so the shader converged on uViewProjection and Flush multiplies on the CPU. System.Numerics is row-vector convention while GLSL reads the floats column-major, which transposes, so the CPU equivalent of the old per-vertex uProjection * uView is view * projection. The product now rounds once per frame rather than once per vertex; these lines only draw when collision wireframes are switched on, so the offline gate sees nothing of it. ui_text's uScreenSize became the block's two spare scalars, uParamA and uParamB, with the same two divisions and the same NDC mapping around them.

The sampling mode. uUseTexture selected between font coverage, RGBA modulate and flat colour, and no field of the 96-byte block means that. It did not need one: which of the two texture-table slots is assigned IS the mode. uTextureIndexB assigned means a single-channel coverage source, uTextureIndexA assigned means an RGBA colour source, neither assigned means the vertex colour alone. GpuTextureSlot.Unassigned is already a loud sentinel for exactly this kind of question, and both branches guard so it never reaches a sampler. That also retired the 1x1 white fill texture: DrawFill routed solid quads through the sprite bucket relying on white times colour, and the untextured branch produces the same value with no texture at all. Multiplying by 1.0 changes no bits, and the gate agrees.

The texture binding. The classic glActiveTexture/glBindTexture path survived V4a because DrawSprite takes an arbitrary texture from sixty-odd widget call sites. But TextureCache had already registered every one of those into the device's table — the classic path was consuming the raw GL name that registration also produced. The UI's currency is now UiTextureTableHandle, a one-based table index whose zero is the same "no texture" every widget already guards on; a raw slot index would have turned all of those guards into silent false negatives, since slot 0 is perfectly valid. One-based rather than the slot itself because GpuTextureSlot is internal to the pinned contract while UiRenderContext.DrawSprite, TextureCache.GetOrUploadRenderSurface and a dozen widget properties are public, and neither publishing a contract type nor converting the retained UI to internal belongs in this slice.

Two consequences worth stating. The two backends disagree about what a 2-D table entry is — GL reconstructs a sampler2D from the bindless handle, Vulkan reads layer 0 of its sampler2DArray descriptor array — and ACDREAM_SAMPLE_2D is the one place that lives. Keeping GL on sampler2D is what leaves the UI's textures exactly as they are, including the paperdoll/appraisal FBO colour texture, which is an externally-owned GL_TEXTURE_2D from the §7.1 transitional seam and cannot become an array before V4g. On the Vulkan side, sampled views are now always layered, which also removes a latent invalid usage V6c shipped: it registered a Type2D offscreen view into a descriptor array whose element type is sampler2DArray.

And one real fix. Sampling through the table means a bound sampler object overrides the texture's own parameters. Nearest-requested UI art used to get its point filtering from a glTexParameter applied before the bindless handle went resident, so registering it with the stock WorldRepeat sampler would have made every retail icon and dat-font glyph silently bilinear. Those now register with a nearest-and-repeat sampler.

Supporting moves: GlGpuDevice.CreatePipeline splices common.glsl the same way Shader does, since an RHI shader that reads the table needs the table declared; GlGpuPassEncoder binds the device's table with the pipeline, which is the GL analogue of Vulkan binding descriptor set 2 per draw, and has to be per-bind because every raw-GL world renderer puts its own privately-numbered table at that binding; and the encoder derives GL_MULTISAMPLE from the pass's SampleCount, which is where the retained UI's hand-rolled glDisable belonged all along. TextRenderGlStateScope is deleted — the encoder's ambient capture restored a strict superset of it — and its failure-safety test follows the guarantee to GlAmbientCapabilityState, which gains a fakeable seam and, with it, the multisample-dimension coverage #249 recorded as missing.

App tests 4,057 passed / 3 skipped, unchanged from commit 1. Offline pixel gate against 871c406b: differing fraction 2.31e-05, 13 pixels of 563,200 compared — below the documented 15-23 pixel same-commit noise band, on a change that redraws every pixel of the retained UI through a different sampling path. The capture was inspected: vitals, spell bar, radar, toolbar icons and slot digits, chat window and Send button all present and correctly placed. Both new .spv pairs compile; the manifest records ui_text and debug_line as Vulkan-ready, leaving six pairs blocked on the world-renderer slices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 08:51:32 +02:00
parent 871c406b99
commit f6f58a12db
26 changed files with 763 additions and 615 deletions

View file

@ -0,0 +1,226 @@
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// The narrow slice of GL that <see cref="GlAmbientCapabilityState"/> reads and
/// writes. It exists so the save/restore transaction can be exercised without a
/// GL context: the property that matters is "every value this pass can change
/// is put back, including when a draw throws", and that is a property of the
/// bookkeeping, not of the driver.
///
/// Slice V6d generalized this from <c>ITextRenderGlStateApi</c>, which
/// <c>TextRenderer</c> owned privately and which restored a strict subset of the
/// same values. That renderer no longer touches GL at all, and the guarantee now
/// lives in one place for every RHI pass.
/// </summary>
internal interface IGlAmbientStateApi
{
bool IsEnabled(EnableCap capability);
int GetInteger(GetPName parameter);
bool GetBoolean(GetPName parameter);
void SetCapability(EnableCap capability, bool enabled);
void DepthMask(bool enabled);
void DepthFunc(DepthFunction function);
void BlendFuncSeparate(
BlendingFactor sourceRgb,
BlendingFactor destinationRgb,
BlendingFactor sourceAlpha,
BlendingFactor destinationAlpha);
void CullFace(TriangleFace face);
void FrontFace(FrontFaceDirection direction);
void UseProgram(uint program);
void BindVertexArray(uint vertexArray);
void BindBuffer(BufferTargetARB target, uint buffer);
void ActiveTexture(TextureUnit unit);
void BindTexture(TextureTarget target, uint texture);
}
internal sealed class SilkGlAmbientStateApi : IGlAmbientStateApi
{
private readonly GL _gl;
public SilkGlAmbientStateApi(GL gl) => _gl = gl ?? throw new ArgumentNullException(nameof(gl));
public bool IsEnabled(EnableCap capability) => _gl.IsEnabled(capability);
public int GetInteger(GetPName parameter)
{
_gl.GetInteger(parameter, out int value);
return value;
}
public bool GetBoolean(GetPName parameter) => _gl.GetBoolean(parameter);
public void SetCapability(EnableCap capability, bool enabled)
{
if (enabled)
_gl.Enable(capability);
else
_gl.Disable(capability);
}
public void DepthMask(bool enabled) => _gl.DepthMask(enabled);
public void DepthFunc(DepthFunction function) => _gl.DepthFunc(function);
public void BlendFuncSeparate(
BlendingFactor sourceRgb,
BlendingFactor destinationRgb,
BlendingFactor sourceAlpha,
BlendingFactor destinationAlpha) =>
_gl.BlendFuncSeparate(sourceRgb, destinationRgb, sourceAlpha, destinationAlpha);
public void CullFace(TriangleFace face) => _gl.CullFace(face);
public void FrontFace(FrontFaceDirection direction) => _gl.FrontFace(direction);
public void UseProgram(uint program) => _gl.UseProgram(program);
public void BindVertexArray(uint vertexArray) => _gl.BindVertexArray(vertexArray);
public void BindBuffer(BufferTargetARB target, uint buffer) => _gl.BindBuffer(target, buffer);
public void ActiveTexture(TextureUnit unit) => _gl.ActiveTexture(unit);
public void BindTexture(TextureTarget target, uint texture) => _gl.BindTexture(target, texture);
}
/// <summary>
/// Every ambient GL capability/binding a <see cref="GlGpuPipeline"/> bind (or a
/// dynamic setter, or the pass's own sample count) can change, captured by raw
/// query and restored by raw call.
///
/// Transitional for as long as raw-GL renderers coexist with RHI-ported ones:
/// each raw-GL renderer assumes whatever state the previous one left behind is
/// still there, so a pass that changes state and does not put it back is
/// invisible until the world silhouette changes. That is precisely how the first
/// V4a attempt lost multisampling (plan §7.1 rule 1). Deleted at V4h.
/// </summary>
internal readonly struct GlAmbientCapabilityState
{
private readonly int _program;
private readonly int _vertexArray;
private readonly int _arrayBuffer;
private readonly int _activeTexture;
private readonly int _texture0Binding2D;
private readonly bool _depthTest;
private readonly bool _depthWrite;
private readonly int _depthFunc;
private readonly bool _blend;
private readonly int _blendSourceRgb;
private readonly int _blendDestinationRgb;
private readonly int _blendSourceAlpha;
private readonly int _blendDestinationAlpha;
private readonly bool _cullFace;
private readonly int _cullFaceMode;
private readonly int _frontFace;
private readonly bool _alphaToCoverage;
private readonly bool _multisample;
private GlAmbientCapabilityState(
int program, int vertexArray, int arrayBuffer, int activeTexture, int texture0Binding2D,
bool depthTest, bool depthWrite, int depthFunc,
bool blend, int blendSourceRgb, int blendDestinationRgb, int blendSourceAlpha, int blendDestinationAlpha,
bool cullFace, int cullFaceMode, int frontFace,
bool alphaToCoverage, bool multisample)
{
_program = program;
_vertexArray = vertexArray;
_arrayBuffer = arrayBuffer;
_activeTexture = activeTexture;
_texture0Binding2D = texture0Binding2D;
_depthTest = depthTest;
_depthWrite = depthWrite;
_depthFunc = depthFunc;
_blend = blend;
_blendSourceRgb = blendSourceRgb;
_blendDestinationRgb = blendDestinationRgb;
_blendSourceAlpha = blendSourceAlpha;
_blendDestinationAlpha = blendDestinationAlpha;
_cullFace = cullFace;
_cullFaceMode = cullFaceMode;
_frontFace = frontFace;
_alphaToCoverage = alphaToCoverage;
_multisample = multisample;
}
internal static GlAmbientCapabilityState Capture(IGlAmbientStateApi gl)
{
ArgumentNullException.ThrowIfNull(gl);
int program = gl.GetInteger(GetPName.CurrentProgram);
int vertexArray = gl.GetInteger(GetPName.VertexArrayBinding);
int arrayBuffer = gl.GetInteger(GetPName.ArrayBufferBinding);
int activeTexture = gl.GetInteger(GetPName.ActiveTexture);
int texture0Binding2D;
try
{
gl.ActiveTexture(TextureUnit.Texture0);
texture0Binding2D = gl.GetInteger(GetPName.TextureBinding2D);
}
finally
{
gl.ActiveTexture((TextureUnit)activeTexture);
}
return new GlAmbientCapabilityState(
program, vertexArray, arrayBuffer, activeTexture, texture0Binding2D,
gl.IsEnabled(EnableCap.DepthTest),
gl.GetBoolean(GetPName.DepthWritemask),
gl.GetInteger(GetPName.DepthFunc),
gl.IsEnabled(EnableCap.Blend),
gl.GetInteger(GetPName.BlendSrcRgb),
gl.GetInteger(GetPName.BlendDstRgb),
gl.GetInteger(GetPName.BlendSrcAlpha),
gl.GetInteger(GetPName.BlendDstAlpha),
gl.IsEnabled(EnableCap.CullFace),
gl.GetInteger(GetPName.CullFaceMode),
gl.GetInteger(GetPName.FrontFace),
gl.IsEnabled(EnableCap.SampleAlphaToCoverage),
gl.IsEnabled(EnableCap.Multisample));
}
internal void Restore(IGlAmbientStateApi gl)
{
ArgumentNullException.ThrowIfNull(gl);
gl.UseProgram((uint)_program);
gl.BindVertexArray((uint)_vertexArray);
gl.BindBuffer(BufferTargetARB.ArrayBuffer, (uint)_arrayBuffer);
gl.ActiveTexture(TextureUnit.Texture0);
gl.BindTexture(TextureTarget.Texture2D, (uint)_texture0Binding2D);
gl.ActiveTexture((TextureUnit)_activeTexture);
gl.SetCapability(EnableCap.DepthTest, _depthTest);
gl.DepthMask(_depthWrite);
gl.DepthFunc((DepthFunction)_depthFunc);
gl.SetCapability(EnableCap.Blend, _blend);
gl.BlendFuncSeparate(
(BlendingFactor)_blendSourceRgb,
(BlendingFactor)_blendDestinationRgb,
(BlendingFactor)_blendSourceAlpha,
(BlendingFactor)_blendDestinationAlpha);
gl.SetCapability(EnableCap.CullFace, _cullFace);
gl.CullFace((TriangleFace)_cullFaceMode);
gl.FrontFace((FrontFaceDirection)_frontFace);
gl.SetCapability(EnableCap.SampleAlphaToCoverage, _alphaToCoverage);
gl.SetCapability(EnableCap.Multisample, _multisample);
}
}

View file

@ -168,6 +168,18 @@ internal sealed class GlGpuDevice : IGpuDevice
internal GlGpuPushConstantBinder PushConstants => _pushConstants;
internal GlGpuTimerPool TimerPool => _timerPool;
/// <summary>
/// GL name of the buffer emulating the global texture table
/// (<see cref="GpuBindingModel.StorageTextureTable"/>). Slice V6d:
/// <see cref="GlGpuPassEncoder.BindPipeline"/> binds it on every pipeline
/// bind, which is the GL analogue of the Vulkan backend binding descriptor
/// set 2 on every draw. Without it an RHI shader that samples the table
/// would read whichever raw-GL renderer's private handle table was left at
/// binding 9 — a different slot numbering entirely, which is the loudest
/// possible way to sample the wrong texture.
/// </summary>
internal uint TextureTableGlName => _textureTableBuffer.GlName;
internal GlRenderStateSnapshot CurrentRenderState { get; private set; }
public IGpuBuffer CreateBuffer(in GpuBufferDescription description)
@ -199,8 +211,15 @@ internal sealed class GlGpuDevice : IGpuDevice
ArgumentNullException.ThrowIfNull(description);
string vertexPath = Path.Combine(_shadersDirectory, $"{description.Shaders.Name}.vert");
string fragmentPath = Path.Combine(_shadersDirectory, $"{description.Shaders.Name}.frag");
string vertexSource = File.ReadAllText(vertexPath);
string fragmentSource = File.ReadAllText(fragmentPath);
// Campaign V slice V6d: every RHI pipeline gets the shared preamble,
// unconditionally. The Vulkan backend injects its own preamble into
// every shader it compiles, so making the GL side selective would mean
// one source file compiling against two different sets of definitions
// depending on which pipeline happened to ask for it. A shader that
// reads nothing from the preamble simply carries an unused declaration.
string common = File.ReadAllText(Path.Combine(_shadersDirectory, "common.glsl"));
string vertexSource = Shader.InjectPreamble(File.ReadAllText(vertexPath), common);
string fragmentSource = Shader.InjectPreamble(File.ReadAllText(fragmentPath), common);
return new GlGpuPipeline(_gl, Retirement, description, vertexSource, fragmentSource);
}

View file

@ -17,6 +17,7 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder
private readonly GlGpuDevice _device;
private readonly GlGpuFrame _frame;
private readonly GL _gl;
private readonly IGlAmbientStateApi _ambientApi;
private readonly GlAmbientCapabilityState _ambientOnEntry;
private bool _closed;
@ -44,7 +45,21 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder
// on. Capturing here and restoring on Dispose keeps the GL backend's
// behaviour-preserving property true at this seam. Deleted at V4h
// once nothing raw-GL remains.
_ambientOnEntry = GlAmbientCapabilityState.Capture(_gl);
_ambientApi = new SilkGlAmbientStateApi(_gl);
_ambientOnEntry = GlAmbientCapabilityState.Capture(_ambientApi);
// Campaign V slice V6d. GL_MULTISAMPLE is the one piece of pass state
// with no representation in GpuPipelineDescription, and the pass's own
// SampleCount is the contract's answer for it: a single-sampled pass
// does not multisample. Until now the retained UI asserted that with a
// raw glDisable of its own — exactly the kind of state a
// backend-neutral renderer cannot own. Quality settings enable
// GL_MULTISAMPLE once per frame for the world, and if it leaks into the
// UI pass every glyph's soft alpha edge becomes dithered coverage
// instead of a clean alpha blend (the "fuzzy text" artifact). The
// ambient capture above puts it back on Dispose, so the raw-GL world
// renderers that follow are unaffected.
_ambientApi.SetCapability(EnableCap.Multisample, pass.SampleCount > 1);
}
public GpuPassDescription Pass { get; }
@ -72,6 +87,20 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder
_gl.BindVertexArray(p.GlVertexArray);
GLHelpers.ThrowOnResourceError(_gl, $"bind pipeline '{description.Name}' VAO");
// Campaign V slice V6d: the device's texture table is bound with the
// pipeline, the GL analogue of the Vulkan backend binding descriptor
// set 2 on every draw. It has to happen here rather than once per frame
// because every raw-GL world renderer binds its OWN private handle
// table at this same binding before its own draws, with its own slot
// numbering; an RHI shader that read that instead would sample a
// plausible but entirely unrelated texture. Removed at V4h with the
// per-renderer tables.
_gl.BindBufferBase(
GLEnum.ShaderStorageBuffer,
GpuBindingModel.StorageTextureTable,
_device.TextureTableGlName);
GLHelpers.ThrowOnResourceError(_gl, $"bind pipeline '{description.Name}' texture table");
// Push constants "survive pipeline changes within a pass" per the
// IGpuPassEncoder contract. GL uniforms are per-program state, so the
// GL backend must explicitly re-apply the last value to the newly
@ -247,7 +276,7 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder
// opened (see the constructor's comment) so a still-raw-GL renderer
// running immediately after this pass sees exactly what it would have
// seen had this pass never bound a pipeline.
_ambientOnEntry.Restore(_gl);
_ambientOnEntry.Restore(_ambientApi);
_frame.ClosePass(this);
}
@ -269,125 +298,3 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder
}
}
/// <summary>
/// Every ambient GL capability/binding a <see cref="GlGpuPipeline"/> bind (or
/// a dynamic setter) can change, captured by raw query and restored by raw
/// call — the same field list <c>TextRenderGlStateScope</c> already restores
/// around <c>TextRenderer.Flush</c>, generalized to every
/// <see cref="GlGpuPassEncoder"/> so a renderer with no scope of its own
/// (<c>DebugLineRenderer</c> today) does not leak state forward into the next
/// raw-GL renderer either. Transitional for as long as raw-GL renderers
/// coexist with RHI-ported ones; deleted at V4h.
/// </summary>
internal readonly struct GlAmbientCapabilityState
{
private readonly int _program;
private readonly int _vertexArray;
private readonly int _arrayBuffer;
private readonly int _activeTexture;
private readonly int _texture0Binding2D;
private readonly bool _depthTest;
private readonly bool _depthWrite;
private readonly int _depthFunc;
private readonly bool _blend;
private readonly int _blendSourceRgb;
private readonly int _blendDestinationRgb;
private readonly int _blendSourceAlpha;
private readonly int _blendDestinationAlpha;
private readonly bool _cullFace;
private readonly int _cullFaceMode;
private readonly int _frontFace;
private readonly bool _alphaToCoverage;
private readonly bool _multisample;
private GlAmbientCapabilityState(
int program, int vertexArray, int arrayBuffer, int activeTexture, int texture0Binding2D,
bool depthTest, bool depthWrite, int depthFunc,
bool blend, int blendSourceRgb, int blendDestinationRgb, int blendSourceAlpha, int blendDestinationAlpha,
bool cullFace, int cullFaceMode, int frontFace,
bool alphaToCoverage, bool multisample)
{
_program = program;
_vertexArray = vertexArray;
_arrayBuffer = arrayBuffer;
_activeTexture = activeTexture;
_texture0Binding2D = texture0Binding2D;
_depthTest = depthTest;
_depthWrite = depthWrite;
_depthFunc = depthFunc;
_blend = blend;
_blendSourceRgb = blendSourceRgb;
_blendDestinationRgb = blendDestinationRgb;
_blendSourceAlpha = blendSourceAlpha;
_blendDestinationAlpha = blendDestinationAlpha;
_cullFace = cullFace;
_cullFaceMode = cullFaceMode;
_frontFace = frontFace;
_alphaToCoverage = alphaToCoverage;
_multisample = multisample;
}
internal static GlAmbientCapabilityState Capture(GL gl)
{
gl.GetInteger(GetPName.CurrentProgram, out int program);
gl.GetInteger(GetPName.VertexArrayBinding, out int vertexArray);
gl.GetInteger(GetPName.ArrayBufferBinding, out int arrayBuffer);
gl.GetInteger(GetPName.ActiveTexture, out int activeTexture);
int texture0Binding2D;
gl.ActiveTexture(TextureUnit.Texture0);
gl.GetInteger(GetPName.TextureBinding2D, out texture0Binding2D);
gl.ActiveTexture((TextureUnit)activeTexture);
gl.GetInteger(GetPName.DepthFunc, out int depthFunc);
gl.GetInteger(GetPName.BlendSrcRgb, out int blendSourceRgb);
gl.GetInteger(GetPName.BlendDstRgb, out int blendDestinationRgb);
gl.GetInteger(GetPName.BlendSrcAlpha, out int blendSourceAlpha);
gl.GetInteger(GetPName.BlendDstAlpha, out int blendDestinationAlpha);
gl.GetInteger(GetPName.CullFaceMode, out int cullFaceMode);
gl.GetInteger(GetPName.FrontFace, out int frontFace);
return new GlAmbientCapabilityState(
program, vertexArray, arrayBuffer, activeTexture, texture0Binding2D,
gl.IsEnabled(EnableCap.DepthTest), gl.GetBoolean(GetPName.DepthWritemask), depthFunc,
gl.IsEnabled(EnableCap.Blend), blendSourceRgb, blendDestinationRgb, blendSourceAlpha, blendDestinationAlpha,
gl.IsEnabled(EnableCap.CullFace), cullFaceMode, frontFace,
gl.IsEnabled(EnableCap.SampleAlphaToCoverage), gl.IsEnabled(EnableCap.Multisample));
}
internal void Restore(GL gl)
{
gl.UseProgram((uint)_program);
gl.BindVertexArray((uint)_vertexArray);
gl.BindBuffer(BufferTargetARB.ArrayBuffer, (uint)_arrayBuffer);
gl.ActiveTexture(TextureUnit.Texture0);
gl.BindTexture(TextureTarget.Texture2D, (uint)_texture0Binding2D);
gl.ActiveTexture((TextureUnit)_activeTexture);
SetCapability(gl, EnableCap.DepthTest, _depthTest);
gl.DepthMask(_depthWrite);
gl.DepthFunc((DepthFunction)_depthFunc);
SetCapability(gl, EnableCap.Blend, _blend);
gl.BlendFuncSeparate(
(BlendingFactor)_blendSourceRgb,
(BlendingFactor)_blendDestinationRgb,
(BlendingFactor)_blendSourceAlpha,
(BlendingFactor)_blendDestinationAlpha);
SetCapability(gl, EnableCap.CullFace, _cullFace);
gl.CullFace((TriangleFace)_cullFaceMode);
gl.FrontFace((FrontFaceDirection)_frontFace);
SetCapability(gl, EnableCap.SampleAlphaToCoverage, _alphaToCoverage);
SetCapability(gl, EnableCap.Multisample, _multisample);
}
private static void SetCapability(GL gl, EnableCap capability, bool enabled)
{
if (enabled)
gl.Enable(capability);
else
gl.Disable(capability);
}
}