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

@ -1,8 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Gl;
using Silk.NET.OpenGL;
using StbTrueTypeSharp; using StbTrueTypeSharp;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -14,11 +12,13 @@ namespace AcDream.App.Rendering;
/// ///
/// Campaign V slice V4a: the atlas is created and uploaded through /// Campaign V slice V4a: the atlas is created and uploaded through
/// <see cref="IGpuDevice.CreateTexture"/> instead of raw GL, and registered /// <see cref="IGpuDevice.CreateTexture"/> instead of raw GL, and registered
/// into the device's texture table. <see cref="TextureId"/> stays a raw GL /// into the device's texture table.
/// name — extracted from the created <see cref="IGpuTexture"/> — because its ///
/// only consumer (<see cref="TextRenderer"/>'s classic sprite/font texture-unit /// Campaign V slice V6d: <see cref="TextureId"/> is that registration's
/// binding path; see that class's remarks) is not itself slot-based this /// <see cref="UiTextureTableHandle"/> rather than the raw GL name it used to
/// slice. /// be. Its only consumer is <see cref="TextRenderer"/>, which now samples the
/// table instead of binding a texture unit, so the GL name has no remaining
/// reader — and on Vulkan there is no GL name to have.
/// ///
/// Only printable ASCII (32..127) is supported for the debug overlay. /// Only printable ASCII (32..127) is supported for the debug overlay.
/// </summary> /// </summary>
@ -49,6 +49,12 @@ public sealed unsafe class BitmapFont : IDisposable
private readonly int _numChars; private readonly int _numChars;
private readonly IGpuTexture _texture; private readonly IGpuTexture _texture;
/// <summary>
/// The atlas's <see cref="UiTextureTableHandle"/> — a one-based index into
/// the device's global texture table, not a GL texture name. The name is
/// unchanged so that the public shape of this class did not move for a
/// change of currency its only consumer makes invisible.
/// </summary>
public uint TextureId { get; } public uint TextureId { get; }
public float PixelHeight { get; } public float PixelHeight { get; }
public float LineHeight { get; } public float LineHeight { get; }
@ -118,34 +124,22 @@ public sealed unsafe class BitmapFont : IDisposable
Height: AtlasHeight, Height: AtlasHeight,
LayerCount: 1, LayerCount: 1,
MipLevelCount: 1)); MipLevelCount: 1));
GpuTextureSlot slot;
try try
{ {
fixed (byte* ptr = pixels) fixed (byte* ptr = pixels)
texture.Upload(0, 0, new ReadOnlySpan<byte>(ptr, AtlasWidth * AtlasHeight)); texture.Upload(0, 0, new ReadOnlySpan<byte>(ptr, AtlasWidth * AtlasHeight));
uint glNameForWrapFixup = ((GlGpuTexture)texture).GlName;
// The classic texture-unit binding path TextRenderer's font draws
// use (see its class remarks) samples this texture object
// directly rather than through a bound GL sampler object, so wrap
// addressing must live on the texture itself — GlGpuTexture's
// constructor only sets the filter, not wrap mode (GL 4.3
// defaults textures to GL_REPEAT). Retail's dat font glyph UVs
// never leave their own tight atlas sub-rect, but pin ClampToEdge
// explicitly to preserve the exact sampling behaviour the raw-GL
// constructor had. MUST happen BEFORE RegisterTexture below:
// ARB_bindless_texture forbids glTexParameter on a texture once
// its bindless handle has been made resident (GL_INVALID_OPERATION).
if (device is GlGpuDevice glDeviceForWrapFixup)
{
GL gl = glDeviceForWrapFixup.Gl;
gl.BindTexture(TextureTarget.Texture2D, glNameForWrapFixup);
gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge);
gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge);
gl.BindTexture(TextureTarget.Texture2D, 0);
}
// Clamped, so a glyph's edge texel cannot bleed in from the opposite
// side of the atlas. Retail's dat font glyph UVs never leave their
// own tight sub-rect, but the sampler states it rather than relying
// on that. Slice V6d: this sampler is now the only thing that
// decides how the atlas is filtered — the raw glTexParameter pass
// that used to sit here existed solely because the classic
// texture-unit path sampled the texture object directly, and it is
// gone with that path.
IGpuSampler sampler = device.CreateSampler(GpuSamplerDescription.WorldClamp); IGpuSampler sampler = device.CreateSampler(GpuSamplerDescription.WorldClamp);
device.RegisterTexture(texture, sampler); slot = device.RegisterTexture(texture, sampler);
} }
catch catch
{ {
@ -154,7 +148,7 @@ public sealed unsafe class BitmapFont : IDisposable
} }
_texture = texture; _texture = texture;
TextureId = ((GlGpuTexture)texture).GlName; TextureId = UiTextureTableHandle.FromSlot(slot);
} }
public bool TryGetGlyph(char c, out Glyph g) public bool TryGetGlyph(char c, out Glyph g)

View file

@ -1,8 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Gl;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -16,17 +14,22 @@ namespace AcDream.App.Rendering;
/// <see cref="IGpuDevice.CreatePipeline"/> (<see cref="GpuPrimitiveTopology.LineList"/> /// <see cref="IGpuDevice.CreatePipeline"/> (<see cref="GpuPrimitiveTopology.LineList"/>
/// topology, depth disabled to match this renderer's "visible through /// topology, depth disabled to match this renderer's "visible through
/// geometry" intent), and each Flush's vertex data comes from a per-frame /// geometry" intent), and each Flush's vertex data comes from a per-frame
/// ring allocation instead of the old single respecialized VBO. The shader's /// ring allocation instead of the old single respecialized VBO.
/// <c>uView</c>/<c>uProjection</c> pair does not fit the shared ///
/// <c>GpuPushConstants</c> block (one combined view-projection matrix), and /// <para>Campaign V slice V6d removed the last GL dependency. The shader's
/// <see cref="IGpuPassEncoder"/> has no verb for arbitrary named uniforms, so /// separate <c>uView</c>/<c>uProjection</c> pair was set directly against the
/// they are set directly against the pipeline's compiled program — the same /// compiled GL program because the pinned <see cref="GpuPushConstants"/> block
/// mechanical translation <see cref="TextRenderer"/> uses for its own /// carries one combined matrix and there is no verb for arbitrary named
/// shader-local uniforms. /// uniforms. That was never portable — Vulkan has no default uniform block at
/// all — so the shader converged on <c>uViewProjection</c> and
/// <see cref="Flush"/> multiplies the two matrices on the CPU. The product
/// therefore rounds once per frame instead of once per vertex; these lines are
/// diagnostic-only geometry that draws when collision wireframes are switched
/// on, so nothing user-visible depends on those bits.</para>
/// ///
/// Vertex format is (vec3 pos, vec3 color) = 24 bytes per vertex. /// Vertex format is (vec3 pos, vec3 color) = 24 bytes per vertex.
/// </summary> /// </summary>
public sealed unsafe class DebugLineRenderer : IDisposable public sealed class DebugLineRenderer : IDisposable
{ {
private const int FloatsPerVertex = 6; private const int FloatsPerVertex = 6;
private const int VertexStrideBytes = FloatsPerVertex * sizeof(float); private const int VertexStrideBytes = FloatsPerVertex * sizeof(float);
@ -38,11 +41,8 @@ public sealed unsafe class DebugLineRenderer : IDisposable
new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12), new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12),
]); ]);
private readonly GL _gl;
private readonly ICurrentGpuFrameSource _frameSource; private readonly ICurrentGpuFrameSource _frameSource;
private readonly IGpuPipeline _pipeline; private readonly IGpuPipeline _pipeline;
private readonly int _uViewLocation;
private readonly int _uProjectionLocation;
private readonly List<float> _buffer = new(4096); private readonly List<float> _buffer = new(4096);
private int _vertexCount; private int _vertexCount;
@ -55,13 +55,6 @@ public sealed unsafe class DebugLineRenderer : IDisposable
ArgumentNullException.ThrowIfNull(device); ArgumentNullException.ThrowIfNull(device);
_frameSource = frameSource ?? throw new ArgumentNullException(nameof(frameSource)); _frameSource = frameSource ?? throw new ArgumentNullException(nameof(frameSource));
ArgumentException.ThrowIfNullOrWhiteSpace(shaderDir); ArgumentException.ThrowIfNullOrWhiteSpace(shaderDir);
if (device is not GlGpuDevice glDevice)
{
throw new NotSupportedException(
"DebugLineRenderer's uView/uProjection uniforms (see the class remarks) are set " +
"directly against the GL program; it needs GlGpuDevice's raw GL handle.");
}
_gl = glDevice.Gl;
_pipeline = device.CreatePipeline(new GpuPipelineDescription _pipeline = device.CreatePipeline(new GpuPipelineDescription
{ {
@ -80,10 +73,6 @@ public sealed unsafe class DebugLineRenderer : IDisposable
ColorWrite = true, ColorWrite = true,
SampleCount = 1, SampleCount = 1,
}); });
uint program = ((GlGpuPipeline)_pipeline).GlProgram;
_uViewLocation = _gl.GetUniformLocation(program, "uView");
_uProjectionLocation = _gl.GetUniformLocation(program, "uProjection");
} }
/// <summary>Clear accumulated lines. Call at the start of each frame.</summary> /// <summary>Clear accumulated lines. Call at the start of each frame.</summary>
@ -186,8 +175,14 @@ public sealed unsafe class DebugLineRenderer : IDisposable
SampleCount = 1, SampleCount = 1,
}); });
encoder.BindPipeline(_pipeline); encoder.BindPipeline(_pipeline);
SetMatrix(_uViewLocation, view);
SetMatrix(_uProjectionLocation, projection); // The GLSL used to evaluate uProjection * uView per vertex. System.Numerics
// is row-vector convention and GL/Vulkan read the 16 floats as column-major,
// which transposes; so the CPU-side equivalent of that product is
// view * projection, in that order.
GpuPushConstants constants = GpuPushConstants.Default;
constants.ViewProjection = view * projection;
encoder.SetPushConstants(constants);
int byteCount = _buffer.Count * sizeof(float); int byteCount = _buffer.Count * sizeof(float);
GpuRingAllocation allocation = frame.AllocateRing(byteCount, GpuRingUsage.Vertex); GpuRingAllocation allocation = frame.AllocateRing(byteCount, GpuRingUsage.Vertex);
@ -196,13 +191,6 @@ public sealed unsafe class DebugLineRenderer : IDisposable
encoder.Draw((uint)_vertexCount, 1, 0, 0); encoder.Draw((uint)_vertexCount, 1, 0, 0);
} }
private void SetMatrix(int location, Matrix4x4 m)
{
if (location < 0)
return;
_gl.UniformMatrix4(location, 1, false, (float*)&m);
}
public void Dispose() public void Dispose()
{ {
_pipeline.Dispose(); _pipeline.Dispose();

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 GlGpuPushConstantBinder PushConstants => _pushConstants;
internal GlGpuTimerPool TimerPool => _timerPool; 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; } internal GlRenderStateSnapshot CurrentRenderState { get; private set; }
public IGpuBuffer CreateBuffer(in GpuBufferDescription description) public IGpuBuffer CreateBuffer(in GpuBufferDescription description)
@ -199,8 +211,15 @@ internal sealed class GlGpuDevice : IGpuDevice
ArgumentNullException.ThrowIfNull(description); ArgumentNullException.ThrowIfNull(description);
string vertexPath = Path.Combine(_shadersDirectory, $"{description.Shaders.Name}.vert"); string vertexPath = Path.Combine(_shadersDirectory, $"{description.Shaders.Name}.vert");
string fragmentPath = Path.Combine(_shadersDirectory, $"{description.Shaders.Name}.frag"); string fragmentPath = Path.Combine(_shadersDirectory, $"{description.Shaders.Name}.frag");
string vertexSource = File.ReadAllText(vertexPath); // Campaign V slice V6d: every RHI pipeline gets the shared preamble,
string fragmentSource = File.ReadAllText(fragmentPath); // 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); 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 GlGpuDevice _device;
private readonly GlGpuFrame _frame; private readonly GlGpuFrame _frame;
private readonly GL _gl; private readonly GL _gl;
private readonly IGlAmbientStateApi _ambientApi;
private readonly GlAmbientCapabilityState _ambientOnEntry; private readonly GlAmbientCapabilityState _ambientOnEntry;
private bool _closed; private bool _closed;
@ -44,7 +45,21 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder
// on. Capturing here and restoring on Dispose keeps the GL backend's // on. Capturing here and restoring on Dispose keeps the GL backend's
// behaviour-preserving property true at this seam. Deleted at V4h // behaviour-preserving property true at this seam. Deleted at V4h
// once nothing raw-GL remains. // 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; } public GpuPassDescription Pass { get; }
@ -72,6 +87,20 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder
_gl.BindVertexArray(p.GlVertexArray); _gl.BindVertexArray(p.GlVertexArray);
GLHelpers.ThrowOnResourceError(_gl, $"bind pipeline '{description.Name}' VAO"); 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 // Push constants "survive pipeline changes within a pass" per the
// IGpuPassEncoder contract. GL uniforms are per-program state, so the // IGpuPassEncoder contract. GL uniforms are per-program state, so the
// GL backend must explicitly re-apply the last value to the newly // 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 // opened (see the constructor's comment) so a still-raw-GL renderer
// running immediately after this pass sees exactly what it would have // running immediately after this pass sees exactly what it would have
// seen had this pass never bound a pipeline. // seen had this pass never bound a pipeline.
_ambientOnEntry.Restore(_gl); _ambientOnEntry.Restore(_ambientApi);
_frame.ClosePass(this); _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);
}
}

View file

@ -111,7 +111,13 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
{ {
SType = StructureType.ImageViewCreateInfo, SType = StructureType.ImageViewCreateInfo,
Image = image, Image = image,
ViewType = VulkanTextureFormatMapping.ViewTypeOf(description.Kind), // Slice V6d: a texture that is not an attachment exists to be
// sampled, and every sampled view has to be layered because the
// global table's descriptor type is sampler2DArray. Attachments
// keep the literal view type of their kind.
ViewType = renderTarget
? VulkanTextureFormatMapping.ViewTypeOf(description.Kind)
: VulkanTextureFormatMapping.SampledViewTypeOf(description.Kind),
Format = VkFormat, Format = VkFormat,
SubresourceRange = new ImageSubresourceRange SubresourceRange = new ImageSubresourceRange
{ {

View file

@ -126,6 +126,32 @@ internal static class VulkanTextureFormatMapping
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown texture kind."), _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown texture kind."),
}; };
/// <summary>
/// The view type for a texture that will be REGISTERED INTO THE GLOBAL
/// TABLE, which is always layered.
///
/// <para>The table is a single descriptor array and a descriptor array has
/// one element type: <c>sampler2DArray</c> (plan §4.4). A view whose type is
/// <see cref="ImageViewType.Type2D"/> cannot legally back such a descriptor,
/// so a plain 2-D texture is viewed as a one-layer array instead. The image
/// itself is unchanged — <c>arrayLayers</c> is already 1 — and a one-layer
/// array view over it costs nothing.</para>
///
/// <para>This is what lets a renderer create <see cref="GpuTextureKind.Texture2D"/>
/// and have it work on both backends: GL reconstructs a <c>sampler2D</c>
/// from the entry's bindless handle, Vulkan reads layer 0 of the array. The
/// <c>ACDREAM_SAMPLE_2D</c> macro is the shader-side half of the same
/// arrangement.</para>
///
/// <para>Attachment views keep <see cref="ViewTypeOf"/>: an attachment is not
/// a table entry, and its view type is answerable from the pass alone.</para>
/// </summary>
internal static ImageViewType SampledViewTypeOf(GpuTextureKind kind) => kind switch
{
GpuTextureKind.Texture2D or GpuTextureKind.Texture2DArray => ImageViewType.Type2DArray,
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown texture kind."),
};
internal static Filter FilterOf(GpuFilter filter) => filter switch internal static Filter FilterOf(GpuFilter filter) => filter switch
{ {
GpuFilter.Nearest => Filter.Nearest, GpuFilter.Nearest => Filter.Nearest,

View file

@ -51,8 +51,13 @@ public sealed class Shader : IDisposable
/// <c>#version</c> to be the very first statement in the source, so the /// <c>#version</c> to be the very first statement in the source, so the
/// preamble cannot simply be prepended — it has to land after that block, /// preamble cannot simply be prepended — it has to land after that block,
/// before the first real declaration. /// before the first real declaration.
///
/// Internal rather than private since slice V6d: <c>GlGpuDevice.CreatePipeline</c>
/// loads RHI shader pairs itself and has to splice the same preamble the
/// same way. Two implementations of "where does the preamble go" is exactly
/// the kind of drift that shows up as one shader silently missing a binding.
/// </summary> /// </summary>
private static string InjectPreamble(string source, string preamble) internal static string InjectPreamble(string source, string preamble)
{ {
int insertAt = 0; int insertAt = 0;
int lineStart = 0; int lineStart = 0;

View file

@ -39,3 +39,18 @@ layout(std430, binding = 9) readonly buffer TextureTableBuf {
// existing call site already follows that exact pattern and a function cannot // existing call site already follows that exact pattern and a function cannot
// return an opaque sampler type built from a runtime value in GLSL. // return an opaque sampler type built from a runtime value in GLSL.
#define ACDREAM_TEXTURE_HANDLE(idx) gTextureTable[idx] #define ACDREAM_TEXTURE_HANDLE(idx) gTextureTable[idx]
// Campaign V slice V6d: samples a table slot that holds a plain 2-D texture.
//
// The two backends disagree about what a 2-D table entry IS, and this macro is
// the one place that difference lives. Under GL a bindless handle carries its
// own texture type, so a GL_TEXTURE_2D entry is reconstructed as a sampler2D
// and read with a 2-component UV. Under Vulkan the table is one descriptor
// array whose element type is fixed at sampler2DArray, so the same entry is a
// one-layer array read at layer 0 (see tools/ShaderCompiler/VulkanGlslPreamble.cs).
//
// That asymmetry is deliberate and is what keeps the retained UI's textures
// exactly as they are on GL — including the paperdoll/appraisal FBO colour
// texture, which is an externally-owned GL_TEXTURE_2D registered by the §7.1
// transitional seam and cannot be made an array before V4g moves its renderer.
#define ACDREAM_SAMPLE_2D(idx, uv) texture(sampler2D(gTextureTable[idx]), uv)

View file

@ -2,12 +2,21 @@
layout(location = 0) in vec3 aPos; layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aColor; layout(location = 1) in vec3 aColor;
uniform mat4 uView; // Campaign V slice V6d: the separate uView/uProjection pair converged into the
uniform mat4 uProjection; // pinned GpuPushConstants block's single uViewProjection. Vulkan has no default
// uniform block, so a loose uniform cannot exist there at all — a shader either
// reads a field of the shared push-constant block or it cannot be expressed.
//
// The product now happens once on the CPU (DebugLineRenderer.Flush) instead of
// once per vertex, so the rounding differs in the last bits. That is why this
// convergence is measured on its own: these lines are diagnostic-only geometry,
// drawn only when collision wireframes are switched on, so the offline gate
// reads nothing but its own ambient noise.
uniform mat4 uViewProjection;
out vec3 vColor; out vec3 vColor;
void main() { void main() {
vColor = aColor; vColor = aColor;
gl_Position = uProjection * uView * vec4(aPos, 1.0); gl_Position = uViewProjection * vec4(aPos, 1.0);
} }

View file

@ -3,13 +3,12 @@
"shaders": [ "shaders": [
{ {
"name": "debug_line", "name": "debug_line",
"vulkanReady": false, "vulkanReady": true,
"stages": [ "stages": [
{ {
"stage": "vert", "stage": "vert",
"sourceSha256": "e6a535ed722a034482cfe09e15ac2308ecde2bb54bc7d303cb5874b5b347eb61", "sourceSha256": "069ef7c89eea80c68e28f44b9e065c5cd3cc9220e8ba63261d345377025c2ea1",
"compiled": false, "compiled": true
"message": "debug_line.vert:62: error: \u0027uProjection\u0027 : undeclared identifier"
}, },
{ {
"stage": "frag", "stage": "frag",
@ -26,13 +25,13 @@
"stage": "vert", "stage": "vert",
"sourceSha256": "c35f767ab07fa9df805f9e77f4851f517c153dd2ef2efa6d49d0c24b688e4f56", "sourceSha256": "c35f767ab07fa9df805f9e77f4851f517c153dd2ef2efa6d49d0c24b688e4f56",
"compiled": false, "compiled": false,
"message": "mesh.vert:70: error: \u0027uModel\u0027 : undeclared identifier" "message": "mesh.vert:71: error: \u0027uModel\u0027 : undeclared identifier"
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "4d6478543a9a903a3453581fa847e096aaecf01f38ebb2921572663bad8e24ea", "sourceSha256": "4d6478543a9a903a3453581fa847e096aaecf01f38ebb2921572663bad8e24ea",
"compiled": false, "compiled": false,
"message": "mesh.frag:157: error: \u0027uDiffuse\u0027 : undeclared identifier" "message": "mesh.frag:158: error: \u0027uDiffuse\u0027 : undeclared identifier"
} }
] ]
}, },
@ -44,13 +43,13 @@
"stage": "vert", "stage": "vert",
"sourceSha256": "1ec2f4af83e73102d87997244a35b69ad5e9ece4b1ad78e2b5ece4d58fab5530", "sourceSha256": "1ec2f4af83e73102d87997244a35b69ad5e9ece4b1ad78e2b5ece4d58fab5530",
"compiled": false, "compiled": false,
"message": "mesh_modern.vert:379: error: \u0027assign\u0027 : cannot convert from \u0027 global highp uint\u0027 to \u0027layout( location=4) flat out highp 2-component vector of uint\u0027" "message": "mesh_modern.vert:380: error: \u0027assign\u0027 : cannot convert from \u0027 global highp uint\u0027 to \u0027layout( location=4) flat out highp 2-component vector of uint\u0027"
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "3aea96cba6c2afc49caae7545f9e42603b6b17afa50b3254beca60f95af5d2f3", "sourceSha256": "3aea96cba6c2afc49caae7545f9e42603b6b17afa50b3254beca60f95af5d2f3",
"compiled": false, "compiled": false,
"message": "mesh_modern.frag:105: error: \u0027sampler2DArray\u0027 : sampler-constructor requires the extension GL_ARB_bindless_texture enabled" "message": "mesh_modern.frag:106: error: \u0027sampler2DArray\u0027 : sampler-constructor requires the extension GL_ARB_bindless_texture enabled"
} }
] ]
}, },
@ -62,13 +61,13 @@
"stage": "vert", "stage": "vert",
"sourceSha256": "6a6ebeaacba95e5e4e8a308ed7c4cd805b80f305650c1e9e03e2bdfc6c18f5e7", "sourceSha256": "6a6ebeaacba95e5e4e8a308ed7c4cd805b80f305650c1e9e03e2bdfc6c18f5e7",
"compiled": false, "compiled": false,
"message": "particle.vert:82: error: \u0027assign\u0027 : cannot convert from \u0027layout( location=6) in highp uint\u0027 to \u0027layout( location=2) flat out highp 2-component vector of uint\u0027" "message": "particle.vert:83: error: \u0027assign\u0027 : cannot convert from \u0027layout( location=6) in highp uint\u0027 to \u0027layout( location=2) flat out highp 2-component vector of uint\u0027"
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "3924ecbabf051349bc6a13e6cff370725a0832f8baf515decdb7e0394304006d", "sourceSha256": "3924ecbabf051349bc6a13e6cff370725a0832f8baf515decdb7e0394304006d",
"compiled": false, "compiled": false,
"message": "particle.frag:59: error: \u0027sampler2DArray\u0027 : sampler-constructor requires the extension GL_ARB_bindless_texture enabled" "message": "particle.frag:60: error: \u0027sampler2DArray\u0027 : sampler-constructor requires the extension GL_ARB_bindless_texture enabled"
} }
] ]
}, },
@ -85,7 +84,7 @@
"stage": "frag", "stage": "frag",
"sourceSha256": "0da368243e967388990f4f4b90e2304044af6187de45f70499a3e4ece8dfd5a8", "sourceSha256": "0da368243e967388990f4f4b90e2304044af6187de45f70499a3e4ece8dfd5a8",
"compiled": false, "compiled": false,
"message": "particle_mesh.frag:61: error: \u0027uTextureIndex\u0027 : undeclared identifier" "message": "particle_mesh.frag:62: error: \u0027uTextureIndex\u0027 : undeclared identifier"
} }
] ]
}, },
@ -97,13 +96,13 @@
"stage": "vert", "stage": "vert",
"sourceSha256": "d338e9b03686b7baf79d5121c5c8d0f24037979cc58f203957d7bd97b02b1cc2", "sourceSha256": "d338e9b03686b7baf79d5121c5c8d0f24037979cc58f203957d7bd97b02b1cc2",
"compiled": false, "compiled": false,
"message": "sky.vert:150: error: \u0027uUvScroll\u0027 : undeclared identifier" "message": "sky.vert:151: error: \u0027uUvScroll\u0027 : undeclared identifier"
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "8084af39f65ae399c73e3ca864376ef20ba8a1c495ee4774be6a82af3872c51c", "sourceSha256": "8084af39f65ae399c73e3ca864376ef20ba8a1c495ee4774be6a82af3872c51c",
"compiled": false, "compiled": false,
"message": "sky.frag:75: error: \u0027uDiffuse\u0027 : undeclared identifier" "message": "sky.frag:76: error: \u0027uDiffuse\u0027 : undeclared identifier"
} }
] ]
}, },
@ -115,31 +114,29 @@
"stage": "vert", "stage": "vert",
"sourceSha256": "4de580ce11b8d755d3558dc49bf7ebccec54d307595d91c38b5c5d552d645c7e", "sourceSha256": "4de580ce11b8d755d3558dc49bf7ebccec54d307595d91c38b5c5d552d645c7e",
"compiled": false, "compiled": false,
"message": "terrain_modern.vert:218: error: \u0027uProjection\u0027 : undeclared identifier" "message": "terrain_modern.vert:219: error: \u0027uProjection\u0027 : undeclared identifier"
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "6003b81df6da6cbea7f00310bd956348bc7b2525345dd490b0b6a3b6428340d9", "sourceSha256": "6003b81df6da6cbea7f00310bd956348bc7b2525345dd490b0b6a3b6428340d9",
"compiled": false, "compiled": false,
"message": "terrain_modern.frag:107: error: \u0027uTexTiling\u0027 : undeclared identifier" "message": "terrain_modern.frag:108: error: \u0027uTexTiling\u0027 : undeclared identifier"
} }
] ]
}, },
{ {
"name": "ui_text", "name": "ui_text",
"vulkanReady": false, "vulkanReady": true,
"stages": [ "stages": [
{ {
"stage": "vert", "stage": "vert",
"sourceSha256": "6c4b0cb8b05da648a5e335db6747cb239dd1fbf95333b658557f52b39eadf4a3", "sourceSha256": "4ddc52f18ea953b33e9263dc2703397f63f4b104bc45d67edc06531145bd9d53",
"compiled": false, "compiled": true
"message": "ui_text.vert:64: error: \u0027uScreenSize\u0027 : undeclared identifier"
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "7287a9f19530979b00de01a8f6c3865905ae3f72e5f219ce228b41915c58d60d", "sourceSha256": "43cb53aa8c933f7800142b72ea81bafc923fd7039fd460022d55543081d0aee5",
"compiled": false, "compiled": true
"message": "ui_text.frag:57: error: \u0027uUseTexture\u0027 : undeclared identifier"
} }
] ]
}, },

Binary file not shown.

Binary file not shown.

View file

@ -1,19 +1,45 @@
#version 430 core #version 430 core
#extension GL_ARB_bindless_texture : require
in vec2 vUv; in vec2 vUv;
in vec4 vColor; in vec4 vColor;
out vec4 FragColor; out vec4 FragColor;
uniform sampler2D uTex; // Campaign V slice V6d: the retained UI samples the device's global texture
uniform int uUseTexture; // table instead of whatever happened to be bound to texture unit 0. The old
// `uniform sampler2D uTex` plus `uniform int uUseTexture` pair could not exist
// under Vulkan — there is no default uniform block, and an opaque sampler
// cannot be a push constant — so both are expressed with the pinned block's two
// texture-table slots. There is no third field and none was needed: which slots
// are ASSIGNED is itself the mode.
//
// uTextureIndexB assigned -> single-channel coverage source (a font atlas):
// red is the glyph's alpha, and the colour's RGB
// is NOT multiplied by it.
// uTextureIndexA assigned -> RGBA colour source (dat chrome, icons, sprites),
// modulated by the vertex colour/tint.
// neither assigned -> a flat quad in the vertex colour. Untextured
// rects and DrawFill take this path; the old
// shader's uUseTexture==0 branch is unchanged.
//
// Exactly one of the two is ever assigned at a time, so the branch is uniform
// across a draw.
uniform uint uTextureIndexA;
uniform uint uTextureIndexB;
// GpuTextureSlot.Unassigned. It is a loud sentinel precisely so it can be
// tested for rather than silently resolving to slot 0 — the failure mode that
// produced the magenta 1x1 UI placeholder. Both branches below are guarded, so
// it never reaches a sampler.
const uint kUnassignedTextureSlot = 0xFFFFFFFFu;
void main() { void main() {
if (uUseTexture == 1) { if (uTextureIndexB != kUnassignedTextureSlot) {
// Font atlas is a single-channel R8 texture; red = coverage alpha. // Font atlas is a single-channel R8 texture; red = coverage alpha.
float coverage = texture(uTex, vUv).r; float coverage = ACDREAM_SAMPLE_2D(uTextureIndexB, vUv).r;
FragColor = vec4(vColor.rgb, vColor.a * coverage); FragColor = vec4(vColor.rgb, vColor.a * coverage);
} else if (uUseTexture == 2) { } else if (uTextureIndexA != kUnassignedTextureSlot) {
// RGBA dat sprite (decoded to RGBA8); modulate by tint/alpha. // RGBA dat sprite (decoded to RGBA8); modulate by tint/alpha.
FragColor = texture(uTex, vUv) * vColor; FragColor = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv) * vColor;
} else { } else {
FragColor = vColor; FragColor = vColor;
} }

View file

@ -3,16 +3,28 @@ layout(location = 0) in vec2 aPos; // screen pixels, origin top-left
layout(location = 1) in vec2 aUv; layout(location = 1) in vec2 aUv;
layout(location = 2) in vec4 aColor; layout(location = 2) in vec4 aColor;
uniform vec2 uScreenSize; // Campaign V slice V6d: uScreenSize moved into the pinned GpuPushConstants
// block's two spare scalars — uParamA is the framebuffer width in pixels,
// uParamB its height. Vulkan has no default uniform block, so a loose
// `uniform vec2` cannot exist there; the block's fields are the only uniforms
// a shader can read, and the two unclaimed scalars are exactly the right shape.
//
// The arithmetic below is byte-for-byte what it was: the same two divisions and
// the same NDC mapping, reading two floats instead of one vec2.
uniform float uParamA;
uniform float uParamB;
out vec2 vUv; out vec2 vUv;
out vec4 vColor; out vec4 vColor;
void main() { void main() {
// Convert pixel coords (origin top-left, +Y down) to NDC (origin center, +Y up). // Convert pixel coords (origin top-left, +Y down) to NDC (origin center, +Y up).
// This is GL-convention NDC on both backends: the Vulkan backend renders with
// a negative viewport height, so it consumes the same +Y-up clip space and no
// renderer performs a flip of its own.
vec2 ndc = vec2( vec2 ndc = vec2(
aPos.x / uScreenSize.x * 2.0 - 1.0, aPos.x / uParamA * 2.0 - 1.0,
1.0 - aPos.y / uScreenSize.y * 2.0); 1.0 - aPos.y / uParamB * 2.0);
gl_Position = vec4(ndc, 0.0, 1.0); gl_Position = vec4(ndc, 0.0, 1.0);
vUv = aUv; vUv = aUv;
vColor = aColor; vColor = aColor;

View file

@ -1,156 +0,0 @@
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
internal interface ITextRenderGlStateApi
{
bool IsEnabled(EnableCap capability);
int GetInteger(GetPName parameter);
bool GetBoolean(GetPName parameter);
void SetCapability(EnableCap capability, bool enabled);
void DepthMask(bool enabled);
void BlendFuncSeparate(
BlendingFactor sourceRgb,
BlendingFactor destinationRgb,
BlendingFactor sourceAlpha,
BlendingFactor destinationAlpha);
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 SilkTextRenderGlStateApi : ITextRenderGlStateApi
{
private readonly GL _gl;
public SilkTextRenderGlStateApi(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);
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 BlendFuncSeparate(
BlendingFactor sourceRgb,
BlendingFactor destinationRgb,
BlendingFactor sourceAlpha,
BlendingFactor destinationAlpha) =>
_gl.BlendFuncSeparate(
sourceRgb,
destinationRgb,
sourceAlpha,
destinationAlpha);
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>
/// Exact, focused state transaction for <see cref="TextRenderer.Flush"/>. It
/// captures every GL value that Flush or DrawLayer mutates, while avoiding the
/// dozens of unrelated synchronous reads made by the broad diagnostic scope.
/// </summary>
internal readonly struct TextRenderGlStateScope : IDisposable
{
private readonly ITextRenderGlStateApi _gl;
private readonly bool _depthTest;
private readonly bool _blend;
private readonly bool _cullFace;
private readonly bool _alphaToCoverage;
private readonly bool _multisample;
private readonly bool _depthWrite;
private readonly int _blendSourceRgb;
private readonly int _blendDestinationRgb;
private readonly int _blendSourceAlpha;
private readonly int _blendDestinationAlpha;
private readonly int _program;
private readonly int _vertexArray;
private readonly int _arrayBuffer;
private readonly int _activeTexture;
private readonly int _texture0Binding2D;
public TextRenderGlStateScope(ITextRenderGlStateApi gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_depthTest = gl.IsEnabled(EnableCap.DepthTest);
_blend = gl.IsEnabled(EnableCap.Blend);
_cullFace = gl.IsEnabled(EnableCap.CullFace);
_alphaToCoverage = gl.IsEnabled(EnableCap.SampleAlphaToCoverage);
_multisample = gl.IsEnabled(EnableCap.Multisample);
_depthWrite = gl.GetBoolean(GetPName.DepthWritemask);
_blendSourceRgb = gl.GetInteger(GetPName.BlendSrcRgb);
_blendDestinationRgb = gl.GetInteger(GetPName.BlendDstRgb);
_blendSourceAlpha = gl.GetInteger(GetPName.BlendSrcAlpha);
_blendDestinationAlpha = gl.GetInteger(GetPName.BlendDstAlpha);
_program = gl.GetInteger(GetPName.CurrentProgram);
_vertexArray = gl.GetInteger(GetPName.VertexArrayBinding);
_arrayBuffer = gl.GetInteger(GetPName.ArrayBufferBinding);
_activeTexture = gl.GetInteger(GetPName.ActiveTexture);
try
{
gl.ActiveTexture(TextureUnit.Texture0);
_texture0Binding2D = gl.GetInteger(GetPName.TextureBinding2D);
}
finally
{
gl.ActiveTexture((TextureUnit)_activeTexture);
}
}
public void Dispose()
{
_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.DepthMask(_depthWrite);
_gl.BlendFuncSeparate(
(BlendingFactor)_blendSourceRgb,
(BlendingFactor)_blendDestinationRgb,
(BlendingFactor)_blendSourceAlpha,
(BlendingFactor)_blendDestinationAlpha);
_gl.SetCapability(EnableCap.DepthTest, _depthTest);
_gl.SetCapability(EnableCap.Blend, _blend);
_gl.SetCapability(EnableCap.CullFace, _cullFace);
_gl.SetCapability(EnableCap.SampleAlphaToCoverage, _alphaToCoverage);
_gl.SetCapability(EnableCap.Multisample, _multisample);
}
}

View file

@ -3,8 +3,6 @@ using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Gl;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -16,32 +14,38 @@ namespace AcDream.App.Rendering;
/// ///
/// Campaign V slice V4a: the <c>ui_text</c> shader compiles through /// Campaign V slice V4a: the <c>ui_text</c> shader compiles through
/// <see cref="IGpuDevice.CreatePipeline"/> (one <see cref="IGpuPipeline"/>, /// <see cref="IGpuDevice.CreatePipeline"/> (one <see cref="IGpuPipeline"/>,
/// replacing the old hand-rolled <c>Shader</c> class), its three /// replacing the old hand-rolled <c>Shader</c> class) and its three
/// fence-buffered per-flight VBOs are gone in favour of a per-<see cref="IGpuFrame"/> /// fence-buffered per-flight VBOs are gone in favour of a per-<see cref="IGpuFrame"/>
/// ring allocation per draw bucket, and the 1×1 white fill texture is created /// ring allocation per draw bucket.
/// and uploaded through <see cref="IGpuDevice.CreateTexture"/> and registered
/// into the device's texture table.
/// ///
/// <see cref="IGpuPassEncoder"/> has no verb for classic texture-unit binding /// <para>Campaign V slice V6d finished the job: this class no longer touches
/// (every acdream RHI pass samples through the bindless texture table) but /// GL at all, and is the first production renderer that draws on either
/// <see cref="DrawSprite"/> receives an ARBITRARY externally-owned raw GL /// backend. Three things had to change for that.</para>
/// texture name from dozens of UI call sites that are not part of this slice ///
/// (icons, dat chrome, composited item art) — converting that whole surface /// <para><b>Textures.</b> V4a kept a classic <c>glActiveTexture</c>/<c>glBindTexture</c>
/// to slot-based sampling is out of scope here. So sprite/font texture /// path because <see cref="DrawSprite"/> receives an arbitrary texture from
/// binding stays classic (<c>glActiveTexture</c>/<c>glBindTexture</c>, /// dozens of widget call sites. Those textures are all registered into the
/// <c>ui_text.frag</c>'s <c>uTex</c> sampler unchanged) issued directly against /// device's global table by <c>TextureCache</c> already — the classic path was
/// the GL handle this class keeps for that reason, while the shader program /// only ever consuming the raw GL name that registration also produced. They
/// itself, its blend/depth/cull description, and every per-frame vertex /// now travel as <see cref="UiTextureTableHandle"/> values instead, and the
/// upload now go through the RHI. This mirrors the same GL-only escape hatch /// shader samples the table.</para>
/// the campaign's V2 note already documents for the interim bindless handle ///
/// table, and is retired only when a later slice moves ALL of TextRenderer's /// <para><b>Loose uniforms.</b> <c>uScreenSize</c> and <c>uUseTexture</c> moved
/// texture consumers onto registered slots. /// into the pinned <see cref="GpuPushConstants"/> block. Screen size is the
/// block's two spare scalars; the sampling mode is derived from which of the
/// two texture-table slots is assigned, so no new field was needed. See
/// <c>ui_text.frag</c> for the three cases.</para>
///
/// <para><b>GL capability state.</b> The pass no longer disables multisampling
/// by hand — <c>GlGpuPassEncoder</c> derives that from the pass's SampleCount
/// and restores it on close, which is where pass state belongs and which the
/// Vulkan backend gets from the pass description for free.</para>
/// ///
/// Uses per-bucket ring allocations flushed in up to three draw calls per /// Uses per-bucket ring allocations flushed in up to three draw calls per
/// layer, to avoid a per-vertex "use texture" flag. Rects are drawn first so /// layer, to avoid a per-vertex "use texture" flag. Rects are drawn first so
/// text sits on top of background panels. /// text sits on top of background panels.
/// </summary> /// </summary>
public sealed unsafe class TextRenderer : IDisposable public sealed class TextRenderer : IDisposable
{ {
private const int FloatsPerVertex = 8; // pos(2) + uv(2) + color(4) private const int FloatsPerVertex = 8; // pos(2) + uv(2) + color(4)
private const int VertexStrideBytes = FloatsPerVertex * sizeof(float); private const int VertexStrideBytes = FloatsPerVertex * sizeof(float);
@ -54,16 +58,8 @@ public sealed unsafe class TextRenderer : IDisposable
new GpuVertexAttribute(2, GpuVertexFormat.Float4, 16), new GpuVertexAttribute(2, GpuVertexFormat.Float4, 16),
]); ]);
private readonly IGpuDevice _device;
private readonly GlGpuDevice _glDevice;
private readonly ICurrentGpuFrameSource _frameSource; private readonly ICurrentGpuFrameSource _frameSource;
private readonly GL _gl;
private readonly ITextRenderGlStateApi _glState;
private readonly IGpuPipeline _pipeline; private readonly IGpuPipeline _pipeline;
private readonly IGpuTexture _whiteTexture;
private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket
private readonly int _uScreenSizeLocation;
private readonly int _uUseTextureLocation;
private sealed class SpriteSeg { public uint Texture; public readonly List<float> Verts = new(256); } private sealed class SpriteSeg { public uint Texture; public readonly List<float> Verts = new(256); }
@ -112,82 +108,28 @@ public sealed unsafe class TextRenderer : IDisposable
// change of their own. // change of their own.
internal TextRenderer(IGpuDevice device, ICurrentGpuFrameSource frameSource, string shaderDir) internal TextRenderer(IGpuDevice device, ICurrentGpuFrameSource frameSource, string shaderDir)
{ {
_device = device ?? throw new ArgumentNullException(nameof(device)); ArgumentNullException.ThrowIfNull(device);
_frameSource = frameSource ?? throw new ArgumentNullException(nameof(frameSource)); _frameSource = frameSource ?? throw new ArgumentNullException(nameof(frameSource));
if (device is not GlGpuDevice glDevice)
{
throw new NotSupportedException(
"TextRenderer's classic sprite/font texture-unit binding path (see the class " +
"remarks) is GL-only; it needs the raw GL handle GlGpuDevice exposes. Other " +
"backends are out of scope until a later slice removes that classic path.");
}
_glDevice = glDevice;
_gl = glDevice.Gl;
_glState = new SilkTextRenderGlStateApi(_gl);
ArgumentException.ThrowIfNullOrWhiteSpace(shaderDir); ArgumentException.ThrowIfNullOrWhiteSpace(shaderDir);
IGpuPipeline? pipeline = null; _pipeline = device.CreatePipeline(new GpuPipelineDescription
IGpuTexture? whiteTexture = null;
try
{ {
pipeline = device.CreatePipeline(new GpuPipelineDescription Name = "ui-text",
{ Shaders = new GpuShaderSet("ui_text"),
Name = "ui-text", VertexLayout = SpriteVertexLayout,
Shaders = new GpuShaderSet("ui_text"), Topology = GpuPrimitiveTopology.TriangleList,
VertexLayout = SpriteVertexLayout, Blend = GpuBlendMode.StraightAlpha,
Topology = GpuPrimitiveTopology.TriangleList, // The retained UI is a self-contained 2-D pass — depth is
Blend = GpuBlendMode.StraightAlpha, // irrelevant and the world pass's alpha-to-coverage state must not
// The retained UI is a self-contained 2-D pass — depth is // leak in (feedback_render_self_contained_gl_state). Multisampling
// irrelevant and the world pass's alpha-to-coverage state must // is the pass's business rather than the pipeline's and comes from
// not leak in (feedback_render_self_contained_gl_state). Flush // the SampleCount below; see GlGpuPassEncoder's constructor.
// below still asserts this by hand via raw GL calls (and Depth = GpuDepthState.Disabled,
// GL_MULTISAMPLE, which has no representation here), matching Cull = GpuCullMode.None,
// what TextRenderGlStateScope has always restored on exit. AlphaToCoverage = false,
Depth = GpuDepthState.Disabled, ColorWrite = true,
Cull = GpuCullMode.None, SampleCount = 1,
AlphaToCoverage = false, });
ColorWrite = true,
SampleCount = 1,
});
// 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE
// bucket (the shader multiplies texel×color → white×color = color). Lets a panel
// background draw UNDER its text in painter order, which DrawRect's separate
// bucket cannot (it always composites after all sprites).
whiteTexture = device.CreateTexture(new GpuTextureDescription(
"ui-text-white",
GpuTextureKind.Texture2D,
GpuTextureFormat.Rgba8Unorm,
Width: 1,
Height: 1,
LayerCount: 1,
MipLevelCount: 1));
whiteTexture.Upload(0, 0, [255, 255, 255, 255]);
IGpuSampler whiteSampler = device.CreateSampler(GpuSamplerDescription.UiNearest);
device.RegisterTexture(whiteTexture, whiteSampler);
}
catch
{
whiteTexture?.Dispose();
pipeline?.Dispose();
throw;
}
_pipeline = pipeline;
_whiteTexture = whiteTexture;
_whiteTex = ((GlGpuTexture)whiteTexture).GlName;
uint program = ((GlGpuPipeline)_pipeline).GlProgram;
_uScreenSizeLocation = _gl.GetUniformLocation(program, "uScreenSize");
_uUseTextureLocation = _gl.GetUniformLocation(program, "uUseTexture");
// uTex (the sampler unit) never changes — bind it once rather than on every Flush.
int texLocation = _gl.GetUniformLocation(program, "uTex");
if (texLocation >= 0)
{
_gl.UseProgram(program);
_gl.Uniform1(texLocation, 0);
_gl.UseProgram(0);
}
} }
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary> /// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
@ -218,9 +160,15 @@ public sealed unsafe class TextRenderer : IDisposable
/// when active), so it composites in painter order with sprites + dat-font text. Use /// when active), so it composites in painter order with sprites + dat-font text. Use
/// this — not <see cref="DrawRect"/> — for a panel BACKGROUND that text draws on top of: /// this — not <see cref="DrawRect"/> — for a panel BACKGROUND that text draws on top of:
/// DrawRect's bucket always flushes after all sprites, so a rect background would cover /// DrawRect's bucket always flushes after all sprites, so a rect background would cover
/// the text instead.</summary> /// the text instead.
///
/// <para>Slice V6d: this used to route through a 1×1 white texture, relying on
/// white × colour = colour. The shader now has an untextured branch that produces
/// the same value directly (multiplying by exactly 1.0 changes no bits), so the
/// white texture is gone and the fill is a sprite segment with no texture.</para>
/// </summary>
public void DrawFill(float x, float y, float w, float h, Vector4 color) public void DrawFill(float x, float y, float w, float h, Vector4 color)
=> DrawSprite(_whiteTex, x, y, w, h, 0f, 0f, 1f, 1f, color); => DrawSprite(UiTextureTableHandle.None, x, y, w, h, 0f, 0f, 1f, 1f, color);
/// <summary>Draw a 1-pixel-thick outline rect.</summary> /// <summary>Draw a 1-pixel-thick outline rect.</summary>
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f) public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
@ -318,8 +266,15 @@ public sealed unsafe class TextRenderer : IDisposable
/// <summary> /// <summary>
/// Draw a textured sprite quad in screen pixel space with an explicit /// Draw a textured sprite quad in screen pixel space with an explicit
/// source-UV rectangle (for 9-slice / atlas sub-regions). Batched per /// source-UV rectangle (for 9-slice / atlas sub-regions).
/// GL texture handle; flushed with uUseTexture=2 (RGBA modulate). ///
/// <paramref name="texture"/> is a <see cref="UiTextureTableHandle"/> — a
/// one-based index into the device's global texture table, which is what
/// <c>TextureCache</c> now hands out in place of the raw GL name it used to.
/// Segments batch per handle and draw in submission order.
/// <see cref="UiTextureTableHandle.None"/> draws the tint alone; every
/// widget guards against passing it, and <see cref="DrawFill"/> uses it
/// deliberately.
/// </summary> /// </summary>
public void DrawSprite(uint texture, float x, float y, float w, float h, public void DrawSprite(uint texture, float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 tint) float u0, float v0, float u1, float v1, Vector4 tint)
@ -331,14 +286,18 @@ public sealed unsafe class TextRenderer : IDisposable
} }
/// <summary> /// <summary>
/// Resolves a <see cref="GpuTextureSlot"/> produced by the paperdoll/appraisal /// Encodes a <see cref="GpuTextureSlot"/> produced by the paperdoll/appraisal
/// viewport transitional seam (<c>GlGpuDevice.RegisterExternalColorTexture</c>, /// viewport transitional seam (<c>GlGpuDevice.RegisterExternalColorTexture</c>,
/// campaign doc §7.1) back to the raw GL texture name <see cref="DrawSprite"/> /// campaign doc §7.1) as the handle <see cref="DrawSprite"/> takes. Returns
/// needs. Returns 0 (no texture) for an unassigned slot. GL-only; removed /// <see cref="UiTextureTableHandle.None"/> for an unassigned slot.
/// at V4g alongside the seam it resolves. ///
/// <para>Slice V6d: this used to resolve the slot back to a raw GL texture
/// name for the classic binding path. Now that the UI samples the table, the
/// externally-owned texture needs no special treatment at draw time at all —
/// registration already put it in the table, and this is a plain encode.</para>
/// </summary> /// </summary>
internal uint ResolveExternalTextureSlot(GpuTextureSlot slot) => internal static uint ResolveExternalTextureSlot(GpuTextureSlot slot) =>
_glDevice.TryResolveExternalColorTexture(slot, out uint name) ? name : 0; UiTextureTableHandle.FromSlot(slot);
/// <summary>Pick the sprite segment for <paramref name="texture"/>: extend the current /// <summary>Pick the sprite segment for <paramref name="texture"/>: extend the current
/// same-texture run, else reuse a pooled segment, else allocate. Submission order is /// same-texture run, else reuse a pooled segment, else allocate. Submission order is
@ -401,10 +360,11 @@ public sealed unsafe class TextRenderer : IDisposable
// Retained UI is a private render pass: an upload or draw failure must // Retained UI is a private render pass: an upload or draw failure must
// not leak its depth/cull/blend/MSAA state into a later recoverable // not leak its depth/cull/blend/MSAA state into a later recoverable
// frame. The focused scope restores from Flush's generated finally, // frame. Slice V6d: the encoder's own `using` is that guarantee — the
// including when either DrawLayer call throws. // GL backend captures every ambient capability a pipeline bind can
using var stateScope = new TextRenderGlStateScope(_glState); // change when the pass opens and restores it on close, including when
// either DrawLayer call throws. That replaced this renderer's private
// GL state scope, which restored a strict subset of the same values.
using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
{ {
Name = "ui-text", Name = "ui-text",
@ -422,21 +382,6 @@ public sealed unsafe class TextRenderer : IDisposable
SampleCount = 1, SampleCount = 1,
}); });
encoder.BindPipeline(_pipeline); encoder.BindPipeline(_pipeline);
_gl.Uniform2(_uScreenSizeLocation, _screenSize.X, _screenSize.Y);
// Establish the self-contained UI pass state.
// The world pass leaves alpha-to-coverage + multisample enabled (WbDrawDispatcher,
// QualitySettings MSAA). If they bleed into the UI pass, each glyph's soft alpha
// EDGE is converted to dithered MSAA coverage instead of a clean alpha blend —
// the "text not sharp / fuzzy" artifact. The UI composites with straight alpha
// blending and must own this state (feedback_render_self_contained_gl_state).
_gl.Disable(EnableCap.SampleAlphaToCoverage);
_gl.Disable(EnableCap.Multisample);
_gl.Disable(EnableCap.DepthTest);
_gl.Disable(EnableCap.CullFace);
_gl.DepthMask(false);
_gl.Enable(EnableCap.Blend);
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
// LAYERED compositing for the UI (background → fill → text): // LAYERED compositing for the UI (background → fill → text):
// 1. RGBA dat sprites — window chrome / panel backgrounds (behind) // 1. RGBA dat sprites — window chrome / panel backgrounds (behind)
@ -459,16 +404,14 @@ public sealed unsafe class TextRenderer : IDisposable
List<float> textBuf, int textVerts, BitmapFont? font, List<float> textBuf, int textVerts, BitmapFont? font,
IGpuFrame frame, IGpuPassEncoder encoder) IGpuFrame frame, IGpuPassEncoder encoder)
{ {
// 1. RGBA dat sprites — one draw call per distinct GL texture. // 1. RGBA dat sprites — one draw call per distinct texture-table slot.
if (segUsed > 0) if (segUsed > 0)
{ {
SetUseTexture(2);
_gl.ActiveTexture(TextureUnit.Texture0);
for (int i = 0; i < segUsed; i++) for (int i = 0; i < segUsed; i++)
{ {
var seg = spriteSegs[i]; var seg = spriteSegs[i];
if (seg.Verts.Count == 0) continue; if (seg.Verts.Count == 0) continue;
_gl.BindTexture(TextureTarget.Texture2D, seg.Texture); SetTextures(encoder, colorHandle: seg.Texture, coverageHandle: UiTextureTableHandle.None);
DrawRing(frame, encoder, seg.Verts); DrawRing(frame, encoder, seg.Verts);
} }
} }
@ -476,24 +419,33 @@ public sealed unsafe class TextRenderer : IDisposable
// 2. Untextured rects — widget fills on top of the chrome. // 2. Untextured rects — widget fills on top of the chrome.
if (rectVerts > 0) if (rectVerts > 0)
{ {
SetUseTexture(0); SetTextures(encoder, UiTextureTableHandle.None, UiTextureTableHandle.None);
DrawRing(frame, encoder, rectBuf); DrawRing(frame, encoder, rectBuf);
} }
// 3. Textured debug-font text glyphs on top. // 3. Textured debug-font text glyphs on top. The atlas is single-channel
// coverage, which is the coverage slot rather than the colour one.
if (textVerts > 0 && font is not null) if (textVerts > 0 && font is not null)
{ {
SetUseTexture(1); SetTextures(encoder, UiTextureTableHandle.None, coverageHandle: font.TextureId);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, font.TextureId);
DrawRing(frame, encoder, textBuf); DrawRing(frame, encoder, textBuf);
} }
} }
private void SetUseTexture(int mode) /// <summary>
/// Writes the shared push-constant block for one draw bucket: the screen
/// size the vertex stage maps pixels to NDC with, and the two texture-table
/// slots whose assignment selects the fragment stage's sampling mode.
/// At most one of the two handles is ever a real texture.
/// </summary>
private void SetTextures(IGpuPassEncoder encoder, uint colorHandle, uint coverageHandle)
{ {
if (_uUseTextureLocation >= 0) GpuPushConstants constants = GpuPushConstants.Default;
_gl.Uniform1(_uUseTextureLocation, mode); constants.ParamA = _screenSize.X;
constants.ParamB = _screenSize.Y;
constants.TextureIndexA = UiTextureTableHandle.ToSlot(colorHandle).Index;
constants.TextureIndexB = UiTextureTableHandle.ToSlot(coverageHandle).Index;
encoder.SetPushConstants(constants);
} }
/// <summary> /// <summary>
@ -513,9 +465,5 @@ public sealed unsafe class TextRenderer : IDisposable
encoder.Draw((uint)(buf.Count / FloatsPerVertex), 1, 0, 0); encoder.Draw((uint)(buf.Count / FloatsPerVertex), 1, 0, 0);
} }
public void Dispose() public void Dispose() => _pipeline.Dispose();
{
_whiteTexture.Dispose();
_pipeline.Dispose();
}
} }

View file

@ -250,13 +250,20 @@ public sealed unsafe class TextureCache
/// <c>DefaultPaletteId</c> (same starting palette <see cref="DecodeFromDats"/> /// <c>DefaultPaletteId</c> (same starting palette <see cref="DecodeFromDats"/>
/// uses); non-paletted formats have DefaultPaletteId==0 → palette null. Returns /// uses); non-paletted formats have DefaultPaletteId==0 → palette null. Returns
/// a 1x1 magenta handle on miss. /// a 1x1 magenta handle on miss.
///
/// <para>Campaign V slice V6d: the returned value is a
/// <see cref="UiTextureTableHandle"/> — a one-based index into the device's
/// global texture table — not a raw GL texture name. Every caller passes it
/// straight to <see cref="TextRenderer.DrawSprite"/>, which samples the
/// table; nothing reads it as a GL name, and on Vulkan there is no GL name.
/// Zero still means "no texture", which is what every widget guards on.</para>
/// </summary> /// </summary>
public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false) public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false)
{ {
if (_renderSurfaceGpuTextures.TryGetValue(renderSurfaceId, out GpuUiTextureEntry existing)) if (_renderSurfaceGpuTextures.TryGetValue(renderSurfaceId, out GpuUiTextureEntry existing))
{ {
width = existing.Width; height = existing.Height; width = existing.Width; height = existing.Height;
return existing.GlName; return UiTextureTableHandle.FromSlot(existing.Slot);
} }
DecodedTexture decoded; DecodedTexture decoded;
@ -280,7 +287,7 @@ public sealed unsafe class TextureCache
GpuUiTextureEntry entry = UploadUiTexture(decoded, nearest, $"ui-rendersurface-0x{renderSurfaceId:X8}"); GpuUiTextureEntry entry = UploadUiTexture(decoded, nearest, $"ui-rendersurface-0x{renderSurfaceId:X8}");
_renderSurfaceGpuTextures[renderSurfaceId] = entry; _renderSurfaceGpuTextures[renderSurfaceId] = entry;
width = decoded.Width; height = decoded.Height; width = decoded.Width; height = decoded.Height;
return entry.GlName; return UiTextureTableHandle.FromSlot(entry.Slot);
} }
/// <summary> /// <summary>
@ -288,13 +295,16 @@ public sealed unsafe class TextureCache
/// decoded UI sprite/atlas and registers it into the device's global /// decoded UI sprite/atlas and registers it into the device's global
/// texture table. Every UI-path texture uses REPEAT addressing (existing /// texture table. Every UI-path texture uses REPEAT addressing (existing
/// behaviour — panel fills and tiled chrome sample UVs greater than 1) and /// behaviour — panel fills and tiled chrome sample UVs greater than 1) and
/// a single mip level (UI sprites never mip). The classic texture-unit /// a single mip level (UI sprites never mip).
/// binding path <see cref="TextRenderer.DrawSprite"/> uses for these ///
/// (see that class's remarks) samples the texture object directly rather /// <para>Campaign V slice V6d: the sampler is now what filtering actually
/// than through a bound sampler object, so filtering must live on the /// comes from. Before this slice the draw bound the texture object directly,
/// texture itself — <c>GlGpuTexture</c>'s constructor always sets Linear, /// so filtering lived on the texture and <paramref name="nearest"/> was
/// which is wrong for <paramref name="nearest"/>-requested (pixel-exact) /// applied with a raw <c>glTexParameter</c> before the bindless handle was
/// sprites, so it is overridden here exactly as the old raw-GL path did. /// made resident. Sampling through the table means a bound sampler object
/// overrides those parameters, so a nearest-requested sprite has to be
/// registered with a nearest SAMPLER or every retail icon and dat-font
/// glyph would silently become bilinear.</para>
/// </summary> /// </summary>
private GpuUiTextureEntry UploadUiTexture(DecodedTexture decoded, bool nearest, string debugName) private GpuUiTextureEntry UploadUiTexture(DecodedTexture decoded, bool nearest, string debugName)
{ {
@ -312,19 +322,7 @@ public sealed unsafe class TextureCache
uint glName = ((GlGpuTexture)texture).GlName; uint glName = ((GlGpuTexture)texture).GlName;
TrackUploadedTexture(glName, decoded.Width, decoded.Height); TrackUploadedTexture(glName, decoded.Width, decoded.Height);
// MUST happen BEFORE RegisterTexture below: ARB_bindless_texture IGpuSampler sampler = _device.CreateSampler(nearest ? UiNearestRepeat : GpuSamplerDescription.WorldRepeat);
// forbids glTexParameter on a texture once its bindless handle has
// been made resident (GL_INVALID_OPERATION). See BitmapFont's
// constructor for the same fix and full explanation.
if (nearest)
{
_gl.BindTexture(TextureTarget.Texture2D, glName);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Nearest);
_gl.BindTexture(TextureTarget.Texture2D, 0);
}
IGpuSampler sampler = _device.CreateSampler(GpuSamplerDescription.WorldRepeat);
GpuTextureSlot slot = _device.RegisterTexture(texture, sampler); GpuTextureSlot slot = _device.RegisterTexture(texture, sampler);
return new GpuUiTextureEntry(texture, slot, glName, decoded.Width, decoded.Height); return new GpuUiTextureEntry(texture, slot, glName, decoded.Width, decoded.Height);
} }
@ -335,6 +333,19 @@ public sealed unsafe class TextureCache
} }
} }
/// <summary>
/// Point sampling with REPEAT addressing — pixel-exact retail UI art that is
/// still tiled by nine-slice chrome and meter tracks. Neither stock preset
/// fits: <c>UiNearest</c> clamps, <c>WorldRepeat</c> filters.
/// </summary>
private static readonly GpuSamplerDescription UiNearestRepeat = new(
GpuFilter.Nearest,
GpuFilter.Nearest,
GpuMipFilter.None,
GpuAddressMode.Repeat,
GpuAddressMode.Repeat,
MaxAnisotropy: 1f);
/// <summary> /// <summary>
/// Alpha-channel histogram for one decoded texture. Used to diagnose /// Alpha-channel histogram for one decoded texture. Used to diagnose
/// "why are clouds not transparent" — if cloud textures come out with /// "why are clouds not transparent" — if cloud textures come out with
@ -887,15 +898,20 @@ public sealed unsafe class TextureCache
/// <summary>Uploads a raw RGBA8 byte array as a Texture2D. Used by /// <summary>Uploads a raw RGBA8 byte array as a Texture2D. Used by
/// <see cref="AcDream.App.UI.IconComposer"/> to upload CPU-composited icon layers. /// <see cref="AcDream.App.UI.IconComposer"/> to upload CPU-composited icon layers.
/// The returned handle is tracked in <see cref="_adhocGpuTextures"/> and deleted by /// The texture is tracked in <see cref="_adhocGpuTextures"/> and deleted by
/// <see cref="Dispose"/>. Callers must NOT also store the handle in any of the /// <see cref="Dispose"/>. Callers must NOT also store the returned handle in any
/// keyed caches — that would cause a double-delete on Dispose.</summary> /// of the keyed caches — that would cause a double-delete on Dispose.
///
/// <para>Campaign V slice V6d: returns a <see cref="UiTextureTableHandle"/>
/// rather than a GL texture name, for the reason given on
/// <see cref="GetOrUploadRenderSurface"/>.</para>
/// </summary>
public uint UploadRgba8(byte[] rgba, int width, int height, bool nearest = false) public uint UploadRgba8(byte[] rgba, int width, int height, bool nearest = false)
{ {
GpuUiTextureEntry entry = UploadUiTexture( GpuUiTextureEntry entry = UploadUiTexture(
new DecodedTexture(rgba, width, height), nearest, "ui-adhoc-rgba8"); new DecodedTexture(rgba, width, height), nearest, "ui-adhoc-rgba8");
_adhocGpuTextures.Add(entry); _adhocGpuTextures.Add(entry);
return entry.GlName; return UiTextureTableHandle.FromSlot(entry.Slot);
} }
private uint UploadRgba8(DecodedTexture decoded, bool nearest = false) private uint UploadRgba8(DecodedTexture decoded, bool nearest = false)

View file

@ -0,0 +1,50 @@
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Rendering;
/// <summary>
/// Campaign V slice V6d: the encoding the retained UI passes textures around
/// with — a ONE-BASED index into the device's global texture table, where 0
/// means "no texture".
///
/// <para><b>Why an encoding rather than the slot itself.</b> Until this slice
/// the UI's currency was a raw GL texture name: <c>TextureCache</c> handed one
/// out, sixty-odd widget call sites carried it, and <c>TextRenderer.DrawSprite</c>
/// bound it to texture unit 0. That name means nothing on Vulkan, so the
/// currency has to become a <see cref="GpuTextureSlot"/>. But
/// <see cref="GpuTextureSlot"/> is internal to the pinned RHI contract while
/// <c>UiRenderContext.DrawSprite</c>, <c>TextureCache.GetOrUploadRenderSurface</c>
/// and a dozen widget properties are public, so the slot cannot itself travel
/// through those signatures without either publishing a contract type or
/// converting the whole retained-UI surface to internal. Both were out of scope
/// for this slice, and the second is explicitly forbidden by the campaign's
/// rule against visibility sweeps.</para>
///
/// <para><b>Why one-based.</b> The old currency already had the property that
/// zero means nothing — GL texture name 0 is "no texture" — and every widget in
/// the tree guards on it (<c>if (tex == 0) return;</c>). Slot 0 is a perfectly
/// valid table index, so handing out raw slot indices would turn every one of
/// those guards into a silent false negative. Shifting by one preserves the
/// guard exactly, needs no call-site change, and keeps the sentinel loud rather
/// than aliasing onto a real texture.</para>
///
/// <para>Retired when the retained UI's public surface can name a
/// <see cref="GpuTextureSlot"/> directly.</para>
/// </summary>
internal static class UiTextureTableHandle
{
/// <summary>No texture. What every widget's <c>tex == 0</c> guard tests for.</summary>
public const uint None = 0;
/// <summary>Encodes a registered slot. An unassigned slot encodes to <see cref="None"/>.</summary>
public static uint FromSlot(GpuTextureSlot slot) =>
slot.IsAssigned ? slot.Index + 1 : None;
/// <summary>
/// Decodes a handle. <see cref="None"/> decodes to
/// <see cref="GpuTextureSlot.Unassigned"/>, which the retained UI's shader
/// reads as "draw the vertex colour" rather than sampling anything.
/// </summary>
public static GpuTextureSlot ToSlot(uint handle) =>
handle == None ? GpuTextureSlot.Unassigned : new GpuTextureSlot(handle - 1);
}

View file

@ -50,7 +50,7 @@ public sealed class UiViewport : UiElement
protected override void OnDraw(UiRenderContext ctx) protected override void OnDraw(UiRenderContext ctx)
{ {
if (!Visible || !TextureSlot.IsAssigned) return; if (!Visible || !TextureSlot.IsAssigned) return;
uint textureHandle = ctx.TextRenderer.ResolveExternalTextureSlot(TextureSlot); uint textureHandle = AcDream.App.Rendering.TextRenderer.ResolveExternalTextureSlot(TextureSlot);
if (textureHandle == 0) return; if (textureHandle == 0) return;
// Local origin is already at this widget's Left/Top (PushTransform applied by DrawSelfAndChildren). // Local origin is already at this widget's Left/Top (PushTransform applied by DrawSelfAndChildren).
// V is FLIPPED (v0=1, v1=0): the resolved texture is an off-screen FBO color texture, whose origin is // V is FLIPPED (v0=1, v1=0): the resolved texture is an off-screen FBO color texture, whose origin is

View file

@ -108,10 +108,10 @@ public sealed class GlTextureOwnershipTests
// from raw GlResourceCommand calls to IGpuDevice.CreatePipeline/ // from raw GlResourceCommand calls to IGpuDevice.CreatePipeline/
// CreateTexture, whose own checked-commit construction // CreateTexture, whose own checked-commit construction
// (GlResourceCommand.CreateName / ShaderProgramConstruction.Build, // (GlResourceCommand.CreateName / ShaderProgramConstruction.Build,
// predating this slice) is what those two now delegate to. What // predating this slice) is what those now delegate to. Slice V6d then
// TextRenderer's OWN constructor still owns is the ordered // removed the white fill texture, so TextRenderer's constructor owns a
// pipeline-then-texture sequence and disposing whichever of the two // single resource and holds no GL name of any kind — every checked
// it already created if the other throws. // commit boundary it depends on lives behind the RHI.
string text = File.ReadAllText(Path.Combine( string text = File.ReadAllText(Path.Combine(
root, "src", "AcDream.App", "Rendering", "TextRenderer.cs")); root, "src", "AcDream.App", "Rendering", "TextRenderer.cs"));
string bindless = File.ReadAllText(Path.Combine( string bindless = File.ReadAllText(Path.Combine(
@ -124,14 +124,9 @@ public sealed class GlTextureOwnershipTests
Assert.Contains("GlResourceCommand.DeleteProgram", shaderPrograms, StringComparison.Ordinal); Assert.Contains("GlResourceCommand.DeleteProgram", shaderPrograms, StringComparison.Ordinal);
Assert.Contains("_anisotropyBindingMutation.Execute", terrain, StringComparison.Ordinal); Assert.Contains("_anisotropyBindingMutation.Execute", terrain, StringComparison.Ordinal);
Assert.Contains("GlResourceCommand.DeleteTexture", terrain, StringComparison.Ordinal); Assert.Contains("GlResourceCommand.DeleteTexture", terrain, StringComparison.Ordinal);
AssertAppearsInOrder( Assert.Contains("_pipeline = device.CreatePipeline(", text, StringComparison.Ordinal);
text, Assert.DoesNotContain("GlResourceCommand", text, StringComparison.Ordinal);
"pipeline = device.CreatePipeline(", Assert.DoesNotContain("GlName", text, StringComparison.Ordinal);
"whiteTexture = device.CreateTexture(",
"catch",
"whiteTexture?.Dispose();",
"pipeline?.Dispose();",
"throw;");
Assert.Contains("make bindless handle", bindless, StringComparison.Ordinal); Assert.Contains("make bindless handle", bindless, StringComparison.Ordinal);
Assert.Contains("GlResourceCommand.Execute", bindless, StringComparison.Ordinal); Assert.Contains("GlResourceCommand.Execute", bindless, StringComparison.Ordinal);
} }

View file

@ -131,18 +131,20 @@ public sealed class ResourceCleanupGroupTests
} }
/// <summary> /// <summary>
/// Campaign V slice V4a: TextRenderer's constructor no longer owns raw /// Campaign V slice V4a moved TextRenderer's constructor off raw
/// VAO/VBO/texture GL names through a ResourceCleanupGroup ledger — it /// VAO/VBO/texture GL names and onto two device-owned resources — a
/// creates exactly two device-owned resources (a pipeline, then the white /// pipeline and a 1x1 white fill texture — with a catch that disposed
/// fill texture) and, since a later failure could otherwise orphan an /// whichever already existed when the other threw.
/// already-created pipeline, disposes whichever of the two it already ///
/// created if the other throws. This replaces the pre-V4a assertion of /// Slice V6d removed the white texture: the shader gained an untextured
/// the same name, which pinned the old multi-resource raw-GL shape /// branch that produces what white-times-colour produced, so the fill needs
/// (Shader + three flight-indexed VAO/VBO pairs + a hand-rolled white /// no texture at all. That leaves exactly ONE owned resource, which is a
/// texture) that no longer exists. /// stronger property than correct rollback — with nothing to orphan there is
/// no partial-construction window to get wrong. This test pins that, so
/// re-growing a second resource without re-growing the rollback fails here.
/// </summary> /// </summary>
[Fact] [Fact]
public void TextRendererDisposesWhicheverConstructorResourceAlreadyExistsOnFailure() public void TextRendererConstructorOwnsExactlyOneDeviceResource()
{ {
string source = File.ReadAllText(Path.Combine( string source = File.ReadAllText(Path.Combine(
FindRepoRoot(), FindRepoRoot(),
@ -151,20 +153,27 @@ public sealed class ResourceCleanupGroupTests
"Rendering", "Rendering",
"TextRenderer.cs")); "TextRenderer.cs"));
AssertAppearsInOrder( Assert.Equal(1, CountOccurrences(source, "device.CreatePipeline("));
source, Assert.Equal(0, CountOccurrences(source, "device.CreateTexture("));
"IGpuPipeline? pipeline = null;", Assert.Equal(0, CountOccurrences(source, "device.CreateBuffer("));
"IGpuTexture? whiteTexture = null;", Assert.Equal(0, CountOccurrences(source, "device.CreateSampler("));
"try", Assert.Equal(0, CountOccurrences(source, "device.RegisterTexture("));
"pipeline = device.CreatePipeline(",
"whiteTexture = device.CreateTexture(", // And the one resource is released.
"device.RegisterTexture(whiteTexture, whiteSampler);", Assert.Contains("public void Dispose() => _pipeline.Dispose();", source, StringComparison.Ordinal);
"catch", }
"whiteTexture?.Dispose();",
"pipeline?.Dispose();", private static int CountOccurrences(string source, string needle)
"throw;", {
"_pipeline = pipeline;", int count = 0;
"_whiteTexture = whiteTexture;"); for (int i = source.IndexOf(needle, StringComparison.Ordinal);
i >= 0;
i = source.IndexOf(needle, i + needle.Length, StringComparison.Ordinal))
{
count++;
}
return count;
} }
[Fact] [Fact]

View file

@ -1,13 +1,22 @@
using System.Reflection; using System.Reflection;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu.Gl;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Rendering; namespace AcDream.App.Tests.Rendering;
/// <summary>
/// The retained UI's whole draw is one RHI pass, and a failure anywhere inside
/// it must not leave GL state behind for the raw-GL world renderers that run in
/// the next frame. Until Campaign V slice V6d, TextRenderer owned a private
/// state scope for that; V6d made the renderer backend-neutral and moved the
/// guarantee onto <see cref="GlAmbientCapabilityState"/>, which every RHI pass
/// gets. These tests follow it there.
/// </summary>
public sealed class TextRendererFailureSafetyTests public sealed class TextRendererFailureSafetyTests
{ {
[Fact] [Fact]
public void Flush_CompilesCompleteGlStateScopeAsFinallyAroundBothDrawLayers() public void Flush_CompilesThePassEncoderAsFinallyAroundBothDrawLayers()
{ {
MethodInfo flush = typeof(TextRenderer).GetMethod(nameof(TextRenderer.Flush))!; MethodInfo flush = typeof(TextRenderer).GetMethod(nameof(TextRenderer.Flush))!;
MethodBody body = flush.GetMethodBody()!; MethodBody body = flush.GetMethodBody()!;
@ -18,27 +27,36 @@ public sealed class TextRendererFailureSafetyTests
"Rendering", "Rendering",
"TextRenderer.cs")); "TextRenderer.cs"));
// The encoder's `using` is the finally: disposing it closes the pass,
// which is what restores the ambient capability state the pass changed.
Assert.Contains( Assert.Contains(
body.ExceptionHandlingClauses, body.ExceptionHandlingClauses,
clause => clause.Flags == ExceptionHandlingClauseOptions.Finally); clause => clause.Flags == ExceptionHandlingClauseOptions.Finally);
AssertAppearsInOrder( AssertAppearsInOrder(
source, source,
"using var stateScope = new TextRenderGlStateScope(_glState);", "using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription",
"_gl.Disable(EnableCap.Multisample);", "encoder.BindPipeline(_pipeline);",
"DrawLayer(_spriteSegs,", "DrawLayer(_spriteSegs,",
"DrawLayer(_overlaySpriteSegs,"); "DrawLayer(_overlaySpriteSegs,");
// And no raw GL of its own is left: the renderer draws on both backends.
Assert.DoesNotContain("Silk.NET.OpenGL", source, StringComparison.Ordinal);
Assert.DoesNotContain("_gl.", source, StringComparison.Ordinal);
} }
[Fact] [Fact]
public void FailedDraw_RestoresEveryGlValueMutatedByTheTextPass() public void FailedDraw_RestoresEveryGlValueMutatedByThePass()
{ {
var gl = new RecordingGlState var gl = new RecordingGlState
{ {
DepthWrite = false, DepthWrite = false,
DepthFuncValue = DepthFunction.Greater,
BlendSourceRgb = BlendingFactor.DstAlpha, BlendSourceRgb = BlendingFactor.DstAlpha,
BlendDestinationRgb = BlendingFactor.OneMinusDstAlpha, BlendDestinationRgb = BlendingFactor.OneMinusDstAlpha,
BlendSourceAlpha = BlendingFactor.One, BlendSourceAlpha = BlendingFactor.One,
BlendDestinationAlpha = BlendingFactor.Zero, BlendDestinationAlpha = BlendingFactor.Zero,
CullFaceMode = TriangleFace.Front,
FrontFaceDirection = FrontFaceDirection.CW,
Program = 17, Program = 17,
VertexArray = 23, VertexArray = 23,
ArrayBuffer = 31, ArrayBuffer = 31,
@ -48,31 +66,44 @@ public sealed class TextRendererFailureSafetyTests
gl.SetCapability(EnableCap.Blend, enabled: false); gl.SetCapability(EnableCap.Blend, enabled: false);
gl.SetCapability(EnableCap.CullFace, enabled: true); gl.SetCapability(EnableCap.CullFace, enabled: true);
gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: true); gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: true);
gl.SetCapability(EnableCap.Multisample, enabled: false); // Enabled on entry — the world's MSAA. The UI pass turns it off and the
// restore has to put it back, which is the exact dimension the V4a
// revert lost and which nothing covered before this test.
gl.SetCapability(EnableCap.Multisample, enabled: true);
gl.TextureBindings[TextureUnit.Texture0] = 41; gl.TextureBindings[TextureUnit.Texture0] = 41;
gl.TextureBindings[TextureUnit.Texture2] = 43; gl.TextureBindings[TextureUnit.Texture2] = 43;
StateSnapshot expected = gl.Capture(); StateSnapshot expected = gl.Capture();
Action failedDraw = () => Action failedDraw = () =>
{ {
using var stateScope = new TextRenderGlStateScope(gl); GlAmbientCapabilityState ambient = GlAmbientCapabilityState.Capture(gl);
gl.SetCapability(EnableCap.DepthTest, enabled: false); try
gl.SetCapability(EnableCap.Blend, enabled: true); {
gl.SetCapability(EnableCap.CullFace, enabled: false); gl.SetCapability(EnableCap.DepthTest, enabled: false);
gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: false); gl.SetCapability(EnableCap.Blend, enabled: true);
gl.SetCapability(EnableCap.Multisample, enabled: true); gl.SetCapability(EnableCap.CullFace, enabled: false);
gl.DepthMask(true); gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: false);
gl.BlendFuncSeparate( gl.SetCapability(EnableCap.Multisample, enabled: false);
BlendingFactor.SrcAlpha, gl.DepthMask(true);
BlendingFactor.OneMinusSrcAlpha, gl.DepthFunc(DepthFunction.Lequal);
BlendingFactor.SrcAlpha, gl.BlendFuncSeparate(
BlendingFactor.OneMinusSrcAlpha); BlendingFactor.SrcAlpha,
gl.UseProgram(101); BlendingFactor.OneMinusSrcAlpha,
gl.BindVertexArray(103); BlendingFactor.SrcAlpha,
gl.BindBuffer(BufferTargetARB.ArrayBuffer, 107); BlendingFactor.OneMinusSrcAlpha);
gl.ActiveTexture(TextureUnit.Texture0); gl.CullFace(TriangleFace.Back);
gl.BindTexture(TextureTarget.Texture2D, 109); gl.FrontFace(FrontFaceDirection.Ccw);
throw new InvalidOperationException("draw upload"); gl.UseProgram(101);
gl.BindVertexArray(103);
gl.BindBuffer(BufferTargetARB.ArrayBuffer, 107);
gl.ActiveTexture(TextureUnit.Texture0);
gl.BindTexture(TextureTarget.Texture2D, 109);
throw new InvalidOperationException("draw upload");
}
finally
{
ambient.Restore(gl);
}
}; };
Assert.Throws<InvalidOperationException>(failedDraw); Assert.Throws<InvalidOperationException>(failedDraw);
@ -87,10 +118,13 @@ public sealed class TextRendererFailureSafetyTests
bool AlphaToCoverage, bool AlphaToCoverage,
bool Multisample, bool Multisample,
bool DepthWrite, bool DepthWrite,
DepthFunction DepthFunc,
BlendingFactor BlendSourceRgb, BlendingFactor BlendSourceRgb,
BlendingFactor BlendDestinationRgb, BlendingFactor BlendDestinationRgb,
BlendingFactor BlendSourceAlpha, BlendingFactor BlendSourceAlpha,
BlendingFactor BlendDestinationAlpha, BlendingFactor BlendDestinationAlpha,
TriangleFace CullFaceMode,
FrontFaceDirection FrontFaceDirection,
uint Program, uint Program,
uint VertexArray, uint VertexArray,
uint ArrayBuffer, uint ArrayBuffer,
@ -98,15 +132,18 @@ public sealed class TextRendererFailureSafetyTests
uint Texture0, uint Texture0,
uint Texture2); uint Texture2);
private sealed class RecordingGlState : ITextRenderGlStateApi private sealed class RecordingGlState : IGlAmbientStateApi
{ {
private readonly Dictionary<EnableCap, bool> _capabilities = []; private readonly Dictionary<EnableCap, bool> _capabilities = [];
public bool DepthWrite { get; set; } public bool DepthWrite { get; set; }
public DepthFunction DepthFuncValue { get; set; }
public BlendingFactor BlendSourceRgb { get; set; } public BlendingFactor BlendSourceRgb { get; set; }
public BlendingFactor BlendDestinationRgb { get; set; } public BlendingFactor BlendDestinationRgb { get; set; }
public BlendingFactor BlendSourceAlpha { get; set; } public BlendingFactor BlendSourceAlpha { get; set; }
public BlendingFactor BlendDestinationAlpha { get; set; } public BlendingFactor BlendDestinationAlpha { get; set; }
public TriangleFace CullFaceMode { get; set; }
public FrontFaceDirection FrontFaceDirection { get; set; }
public uint Program { get; set; } public uint Program { get; set; }
public uint VertexArray { get; set; } public uint VertexArray { get; set; }
public uint ArrayBuffer { get; set; } public uint ArrayBuffer { get; set; }
@ -120,10 +157,13 @@ public sealed class TextRendererFailureSafetyTests
IsEnabled(EnableCap.SampleAlphaToCoverage), IsEnabled(EnableCap.SampleAlphaToCoverage),
IsEnabled(EnableCap.Multisample), IsEnabled(EnableCap.Multisample),
DepthWrite, DepthWrite,
DepthFuncValue,
BlendSourceRgb, BlendSourceRgb,
BlendDestinationRgb, BlendDestinationRgb,
BlendSourceAlpha, BlendSourceAlpha,
BlendDestinationAlpha, BlendDestinationAlpha,
CullFaceMode,
FrontFaceDirection,
Program, Program,
VertexArray, VertexArray,
ArrayBuffer, ArrayBuffer,
@ -140,6 +180,9 @@ public sealed class TextRendererFailureSafetyTests
GetPName.BlendDstRgb => (int)BlendDestinationRgb, GetPName.BlendDstRgb => (int)BlendDestinationRgb,
GetPName.BlendSrcAlpha => (int)BlendSourceAlpha, GetPName.BlendSrcAlpha => (int)BlendSourceAlpha,
GetPName.BlendDstAlpha => (int)BlendDestinationAlpha, GetPName.BlendDstAlpha => (int)BlendDestinationAlpha,
GetPName.DepthFunc => (int)DepthFuncValue,
GetPName.CullFaceMode => (int)CullFaceMode,
GetPName.FrontFace => (int)FrontFaceDirection,
GetPName.CurrentProgram => (int)Program, GetPName.CurrentProgram => (int)Program,
GetPName.VertexArrayBinding => (int)VertexArray, GetPName.VertexArrayBinding => (int)VertexArray,
GetPName.ArrayBufferBinding => (int)ArrayBuffer, GetPName.ArrayBufferBinding => (int)ArrayBuffer,
@ -159,6 +202,8 @@ public sealed class TextRendererFailureSafetyTests
public void DepthMask(bool enabled) => DepthWrite = enabled; public void DepthMask(bool enabled) => DepthWrite = enabled;
public void DepthFunc(DepthFunction function) => DepthFuncValue = function;
public void BlendFuncSeparate( public void BlendFuncSeparate(
BlendingFactor sourceRgb, BlendingFactor sourceRgb,
BlendingFactor destinationRgb, BlendingFactor destinationRgb,
@ -171,6 +216,10 @@ public sealed class TextRendererFailureSafetyTests
BlendDestinationAlpha = destinationAlpha; BlendDestinationAlpha = destinationAlpha;
} }
public void CullFace(TriangleFace face) => CullFaceMode = face;
public void FrontFace(FrontFaceDirection direction) => FrontFaceDirection = direction;
public void UseProgram(uint program) => Program = program; public void UseProgram(uint program) => Program = program;
public void BindVertexArray(uint vertexArray) => VertexArray = vertexArray; public void BindVertexArray(uint vertexArray) => VertexArray = vertexArray;

View file

@ -94,6 +94,13 @@ internal static class VulkanGlslPreamble
text.AppendLine("#undef ACDREAM_TEXTURE_HANDLE"); text.AppendLine("#undef ACDREAM_TEXTURE_HANDLE");
text.AppendLine("#define ACDREAM_TEXTURE_HANDLE(idx) (idx)"); text.AppendLine("#define ACDREAM_TEXTURE_HANDLE(idx) (idx)");
text.AppendLine("#define ACDREAM_TEXTURE(idx) uTextures[nonuniformEXT(uint(idx))]"); text.AppendLine("#define ACDREAM_TEXTURE(idx) uTextures[nonuniformEXT(uint(idx))]");
// Slice V6d: the 2-D read. GL reconstructs a sampler2D from the entry's
// bindless handle; Vulkan has one descriptor array whose element type is
// fixed at sampler2DArray, so a 2-D entry is a one-layer array read at
// layer 0. See common.glsl for the GL half and for why the UI's
// textures stay plain GL_TEXTURE_2D objects.
text.AppendLine(
"#define ACDREAM_SAMPLE_2D(idx, uv) texture(ACDREAM_TEXTURE(idx), vec3((uv), 0.0))");
text.AppendLine(); text.AppendLine();
text.AppendLine("// §3.4 push constants: one shared 96-byte block, so switching pipelines"); text.AppendLine("// §3.4 push constants: one shared 96-byte block, so switching pipelines");
text.AppendLine("// mid-pass invalidates neither descriptors nor constants."); text.AppendLine("// mid-pass invalidates neither descriptors nor constants.");