diff --git a/src/AcDream.App/Rendering/BitmapFont.cs b/src/AcDream.App/Rendering/BitmapFont.cs index 87067356..3f123b3d 100644 --- a/src/AcDream.App/Rendering/BitmapFont.cs +++ b/src/AcDream.App/Rendering/BitmapFont.cs @@ -1,8 +1,6 @@ using System; using System.IO; using AcDream.App.Rendering.Gpu; -using AcDream.App.Rendering.Gpu.Gl; -using Silk.NET.OpenGL; using StbTrueTypeSharp; namespace AcDream.App.Rendering; @@ -14,11 +12,13 @@ namespace AcDream.App.Rendering; /// /// Campaign V slice V4a: the atlas is created and uploaded through /// instead of raw GL, and registered -/// into the device's texture table. stays a raw GL -/// name — extracted from the created — because its -/// only consumer ('s classic sprite/font texture-unit -/// binding path; see that class's remarks) is not itself slot-based this -/// slice. +/// into the device's texture table. +/// +/// Campaign V slice V6d: is that registration's +/// rather than the raw GL name it used to +/// be. Its only consumer is , 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. /// @@ -49,6 +49,12 @@ public sealed unsafe class BitmapFont : IDisposable private readonly int _numChars; private readonly IGpuTexture _texture; + /// + /// The atlas's — 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. + /// public uint TextureId { get; } public float PixelHeight { get; } public float LineHeight { get; } @@ -118,34 +124,22 @@ public sealed unsafe class BitmapFont : IDisposable Height: AtlasHeight, LayerCount: 1, MipLevelCount: 1)); + GpuTextureSlot slot; try { fixed (byte* ptr = pixels) texture.Upload(0, 0, new ReadOnlySpan(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); - device.RegisterTexture(texture, sampler); + slot = device.RegisterTexture(texture, sampler); } catch { @@ -154,7 +148,7 @@ public sealed unsafe class BitmapFont : IDisposable } _texture = texture; - TextureId = ((GlGpuTexture)texture).GlName; + TextureId = UiTextureTableHandle.FromSlot(slot); } public bool TryGetGlyph(char c, out Glyph g) diff --git a/src/AcDream.App/Rendering/DebugLineRenderer.cs b/src/AcDream.App/Rendering/DebugLineRenderer.cs index 2206d609..1bce691c 100644 --- a/src/AcDream.App/Rendering/DebugLineRenderer.cs +++ b/src/AcDream.App/Rendering/DebugLineRenderer.cs @@ -1,8 +1,6 @@ using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering.Gpu; -using AcDream.App.Rendering.Gpu.Gl; -using Silk.NET.OpenGL; namespace AcDream.App.Rendering; @@ -16,17 +14,22 @@ namespace AcDream.App.Rendering; /// ( /// topology, depth disabled to match this renderer's "visible through /// 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 -/// uView/uProjection pair does not fit the shared -/// GpuPushConstants block (one combined view-projection matrix), and -/// has no verb for arbitrary named uniforms, so -/// they are set directly against the pipeline's compiled program — the same -/// mechanical translation uses for its own -/// shader-local uniforms. +/// ring allocation instead of the old single respecialized VBO. +/// +/// Campaign V slice V6d removed the last GL dependency. The shader's +/// separate uView/uProjection pair was set directly against the +/// compiled GL program because the pinned block +/// carries one combined matrix and there is 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 +/// 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. /// /// Vertex format is (vec3 pos, vec3 color) = 24 bytes per vertex. /// -public sealed unsafe class DebugLineRenderer : IDisposable +public sealed class DebugLineRenderer : IDisposable { private const int FloatsPerVertex = 6; private const int VertexStrideBytes = FloatsPerVertex * sizeof(float); @@ -38,11 +41,8 @@ public sealed unsafe class DebugLineRenderer : IDisposable new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12), ]); - private readonly GL _gl; private readonly ICurrentGpuFrameSource _frameSource; private readonly IGpuPipeline _pipeline; - private readonly int _uViewLocation; - private readonly int _uProjectionLocation; private readonly List _buffer = new(4096); private int _vertexCount; @@ -55,13 +55,6 @@ public sealed unsafe class DebugLineRenderer : IDisposable ArgumentNullException.ThrowIfNull(device); _frameSource = frameSource ?? throw new ArgumentNullException(nameof(frameSource)); 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 { @@ -80,10 +73,6 @@ public sealed unsafe class DebugLineRenderer : IDisposable ColorWrite = true, SampleCount = 1, }); - - uint program = ((GlGpuPipeline)_pipeline).GlProgram; - _uViewLocation = _gl.GetUniformLocation(program, "uView"); - _uProjectionLocation = _gl.GetUniformLocation(program, "uProjection"); } /// Clear accumulated lines. Call at the start of each frame. @@ -186,8 +175,14 @@ public sealed unsafe class DebugLineRenderer : IDisposable SampleCount = 1, }); 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); GpuRingAllocation allocation = frame.AllocateRing(byteCount, GpuRingUsage.Vertex); @@ -196,13 +191,6 @@ public sealed unsafe class DebugLineRenderer : IDisposable 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() { _pipeline.Dispose(); diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlAmbientCapabilityState.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlAmbientCapabilityState.cs new file mode 100644 index 00000000..fcd6b70f --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlAmbientCapabilityState.cs @@ -0,0 +1,226 @@ +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Gpu.Gl; + +/// +/// The narrow slice of GL that 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 ITextRenderGlStateApi, which +/// TextRenderer 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. +/// +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); +} + +/// +/// Every ambient GL capability/binding a 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. +/// +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); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs index 3935e742..6213d73e 100644 --- a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs @@ -168,6 +168,18 @@ internal sealed class GlGpuDevice : IGpuDevice internal GlGpuPushConstantBinder PushConstants => _pushConstants; internal GlGpuTimerPool TimerPool => _timerPool; + /// + /// GL name of the buffer emulating the global texture table + /// (). Slice V6d: + /// 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. + /// + internal uint TextureTableGlName => _textureTableBuffer.GlName; + internal GlRenderStateSnapshot CurrentRenderState { get; private set; } public IGpuBuffer CreateBuffer(in GpuBufferDescription description) @@ -199,8 +211,15 @@ internal sealed class GlGpuDevice : IGpuDevice ArgumentNullException.ThrowIfNull(description); string vertexPath = Path.Combine(_shadersDirectory, $"{description.Shaders.Name}.vert"); string fragmentPath = Path.Combine(_shadersDirectory, $"{description.Shaders.Name}.frag"); - string vertexSource = File.ReadAllText(vertexPath); - string fragmentSource = File.ReadAllText(fragmentPath); + // Campaign V slice V6d: every RHI pipeline gets the shared preamble, + // unconditionally. The Vulkan backend injects its own preamble into + // every shader it compiles, so making the GL side selective would mean + // one source file compiling against two different sets of definitions + // depending on which pipeline happened to ask for it. A shader that + // reads nothing from the preamble simply carries an unused declaration. + string common = File.ReadAllText(Path.Combine(_shadersDirectory, "common.glsl")); + string vertexSource = Shader.InjectPreamble(File.ReadAllText(vertexPath), common); + string fragmentSource = Shader.InjectPreamble(File.ReadAllText(fragmentPath), common); return new GlGpuPipeline(_gl, Retirement, description, vertexSource, fragmentSource); } diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs index 09e33cd1..cd9fe32a 100644 --- a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs @@ -17,6 +17,7 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder private readonly GlGpuDevice _device; private readonly GlGpuFrame _frame; private readonly GL _gl; + private readonly IGlAmbientStateApi _ambientApi; private readonly GlAmbientCapabilityState _ambientOnEntry; private bool _closed; @@ -44,7 +45,21 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder // on. Capturing here and restoring on Dispose keeps the GL backend's // behaviour-preserving property true at this seam. Deleted at V4h // once nothing raw-GL remains. - _ambientOnEntry = GlAmbientCapabilityState.Capture(_gl); + _ambientApi = new SilkGlAmbientStateApi(_gl); + _ambientOnEntry = GlAmbientCapabilityState.Capture(_ambientApi); + + // Campaign V slice V6d. GL_MULTISAMPLE is the one piece of pass state + // with no representation in GpuPipelineDescription, and the pass's own + // SampleCount is the contract's answer for it: a single-sampled pass + // does not multisample. Until now the retained UI asserted that with a + // raw glDisable of its own — exactly the kind of state a + // backend-neutral renderer cannot own. Quality settings enable + // GL_MULTISAMPLE once per frame for the world, and if it leaks into the + // UI pass every glyph's soft alpha edge becomes dithered coverage + // instead of a clean alpha blend (the "fuzzy text" artifact). The + // ambient capture above puts it back on Dispose, so the raw-GL world + // renderers that follow are unaffected. + _ambientApi.SetCapability(EnableCap.Multisample, pass.SampleCount > 1); } public GpuPassDescription Pass { get; } @@ -72,6 +87,20 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder _gl.BindVertexArray(p.GlVertexArray); GLHelpers.ThrowOnResourceError(_gl, $"bind pipeline '{description.Name}' VAO"); + // Campaign V slice V6d: the device's texture table is bound with the + // pipeline, the GL analogue of the Vulkan backend binding descriptor + // set 2 on every draw. It has to happen here rather than once per frame + // because every raw-GL world renderer binds its OWN private handle + // table at this same binding before its own draws, with its own slot + // numbering; an RHI shader that read that instead would sample a + // plausible but entirely unrelated texture. Removed at V4h with the + // per-renderer tables. + _gl.BindBufferBase( + GLEnum.ShaderStorageBuffer, + GpuBindingModel.StorageTextureTable, + _device.TextureTableGlName); + GLHelpers.ThrowOnResourceError(_gl, $"bind pipeline '{description.Name}' texture table"); + // Push constants "survive pipeline changes within a pass" per the // IGpuPassEncoder contract. GL uniforms are per-program state, so the // GL backend must explicitly re-apply the last value to the newly @@ -247,7 +276,7 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder // opened (see the constructor's comment) so a still-raw-GL renderer // running immediately after this pass sees exactly what it would have // seen had this pass never bound a pipeline. - _ambientOnEntry.Restore(_gl); + _ambientOnEntry.Restore(_ambientApi); _frame.ClosePass(this); } @@ -269,125 +298,3 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder } } -/// -/// Every ambient GL capability/binding a bind (or -/// a dynamic setter) can change, captured by raw query and restored by raw -/// call — the same field list TextRenderGlStateScope already restores -/// around TextRenderer.Flush, generalized to every -/// so a renderer with no scope of its own -/// (DebugLineRenderer 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. -/// -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); - } -} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs index 8b88c48d..19f6b0a0 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs @@ -111,7 +111,13 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture { SType = StructureType.ImageViewCreateInfo, 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, SubresourceRange = new ImageSubresourceRange { diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs index 0b31dca9..3d84dda6 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs @@ -126,6 +126,32 @@ internal static class VulkanTextureFormatMapping _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown texture kind."), }; + /// + /// The view type for a texture that will be REGISTERED INTO THE GLOBAL + /// TABLE, which is always layered. + /// + /// The table is a single descriptor array and a descriptor array has + /// one element type: sampler2DArray (plan §4.4). A view whose type is + /// 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 — arrayLayers is already 1 — and a one-layer + /// array view over it costs nothing. + /// + /// This is what lets a renderer create + /// and have it work on both backends: GL reconstructs a sampler2D + /// from the entry's bindless handle, Vulkan reads layer 0 of the array. The + /// ACDREAM_SAMPLE_2D macro is the shader-side half of the same + /// arrangement. + /// + /// Attachment views keep : an attachment is not + /// a table entry, and its view type is answerable from the pass alone. + /// + 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 { GpuFilter.Nearest => Filter.Nearest, diff --git a/src/AcDream.App/Rendering/Shader.cs b/src/AcDream.App/Rendering/Shader.cs index b74960ae..2d3be01c 100644 --- a/src/AcDream.App/Rendering/Shader.cs +++ b/src/AcDream.App/Rendering/Shader.cs @@ -51,8 +51,13 @@ public sealed class Shader : IDisposable /// #version to be the very first statement in the source, so the /// preamble cannot simply be prepended — it has to land after that block, /// before the first real declaration. + /// + /// Internal rather than private since slice V6d: GlGpuDevice.CreatePipeline + /// 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. /// - private static string InjectPreamble(string source, string preamble) + internal static string InjectPreamble(string source, string preamble) { int insertAt = 0; int lineStart = 0; diff --git a/src/AcDream.App/Rendering/Shaders/common.glsl b/src/AcDream.App/Rendering/Shaders/common.glsl index f0234359..21c0873a 100644 --- a/src/AcDream.App/Rendering/Shaders/common.glsl +++ b/src/AcDream.App/Rendering/Shaders/common.glsl @@ -39,3 +39,18 @@ layout(std430, binding = 9) readonly buffer TextureTableBuf { // existing call site already follows that exact pattern and a function cannot // return an opaque sampler type built from a runtime value in GLSL. #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) diff --git a/src/AcDream.App/Rendering/Shaders/debug_line.vert b/src/AcDream.App/Rendering/Shaders/debug_line.vert index f6340133..145bcd18 100644 --- a/src/AcDream.App/Rendering/Shaders/debug_line.vert +++ b/src/AcDream.App/Rendering/Shaders/debug_line.vert @@ -2,12 +2,21 @@ layout(location = 0) in vec3 aPos; layout(location = 1) in vec3 aColor; -uniform mat4 uView; -uniform mat4 uProjection; +// Campaign V slice V6d: the separate uView/uProjection pair converged into the +// 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; void main() { vColor = aColor; - gl_Position = uProjection * uView * vec4(aPos, 1.0); + gl_Position = uViewProjection * vec4(aPos, 1.0); } diff --git a/src/AcDream.App/Rendering/Shaders/spv/debug_line.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/debug_line.frag.spv new file mode 100644 index 00000000..7d27c512 Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/debug_line.frag.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/debug_line.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/debug_line.vert.spv new file mode 100644 index 00000000..82c2f533 Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/debug_line.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index 0da87a4d..a57f56cb 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -3,13 +3,12 @@ "shaders": [ { "name": "debug_line", - "vulkanReady": false, + "vulkanReady": true, "stages": [ { "stage": "vert", - "sourceSha256": "e6a535ed722a034482cfe09e15ac2308ecde2bb54bc7d303cb5874b5b347eb61", - "compiled": false, - "message": "debug_line.vert:62: error: \u0027uProjection\u0027 : undeclared identifier" + "sourceSha256": "069ef7c89eea80c68e28f44b9e065c5cd3cc9220e8ba63261d345377025c2ea1", + "compiled": true }, { "stage": "frag", @@ -26,13 +25,13 @@ "stage": "vert", "sourceSha256": "c35f767ab07fa9df805f9e77f4851f517c153dd2ef2efa6d49d0c24b688e4f56", "compiled": false, - "message": "mesh.vert:70: error: \u0027uModel\u0027 : undeclared identifier" + "message": "mesh.vert:71: error: \u0027uModel\u0027 : undeclared identifier" }, { "stage": "frag", "sourceSha256": "4d6478543a9a903a3453581fa847e096aaecf01f38ebb2921572663bad8e24ea", "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", "sourceSha256": "1ec2f4af83e73102d87997244a35b69ad5e9ece4b1ad78e2b5ece4d58fab5530", "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", "sourceSha256": "3aea96cba6c2afc49caae7545f9e42603b6b17afa50b3254beca60f95af5d2f3", "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", "sourceSha256": "6a6ebeaacba95e5e4e8a308ed7c4cd805b80f305650c1e9e03e2bdfc6c18f5e7", "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", "sourceSha256": "3924ecbabf051349bc6a13e6cff370725a0832f8baf515decdb7e0394304006d", "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", "sourceSha256": "0da368243e967388990f4f4b90e2304044af6187de45f70499a3e4ece8dfd5a8", "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", "sourceSha256": "d338e9b03686b7baf79d5121c5c8d0f24037979cc58f203957d7bd97b02b1cc2", "compiled": false, - "message": "sky.vert:150: error: \u0027uUvScroll\u0027 : undeclared identifier" + "message": "sky.vert:151: error: \u0027uUvScroll\u0027 : undeclared identifier" }, { "stage": "frag", "sourceSha256": "8084af39f65ae399c73e3ca864376ef20ba8a1c495ee4774be6a82af3872c51c", "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", "sourceSha256": "4de580ce11b8d755d3558dc49bf7ebccec54d307595d91c38b5c5d552d645c7e", "compiled": false, - "message": "terrain_modern.vert:218: error: \u0027uProjection\u0027 : undeclared identifier" + "message": "terrain_modern.vert:219: error: \u0027uProjection\u0027 : undeclared identifier" }, { "stage": "frag", "sourceSha256": "6003b81df6da6cbea7f00310bd956348bc7b2525345dd490b0b6a3b6428340d9", "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", - "vulkanReady": false, + "vulkanReady": true, "stages": [ { "stage": "vert", - "sourceSha256": "6c4b0cb8b05da648a5e335db6747cb239dd1fbf95333b658557f52b39eadf4a3", - "compiled": false, - "message": "ui_text.vert:64: error: \u0027uScreenSize\u0027 : undeclared identifier" + "sourceSha256": "4ddc52f18ea953b33e9263dc2703397f63f4b104bc45d67edc06531145bd9d53", + "compiled": true }, { "stage": "frag", - "sourceSha256": "7287a9f19530979b00de01a8f6c3865905ae3f72e5f219ce228b41915c58d60d", - "compiled": false, - "message": "ui_text.frag:57: error: \u0027uUseTexture\u0027 : undeclared identifier" + "sourceSha256": "43cb53aa8c933f7800142b72ea81bafc923fd7039fd460022d55543081d0aee5", + "compiled": true } ] }, diff --git a/src/AcDream.App/Rendering/Shaders/spv/ui_text.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/ui_text.frag.spv new file mode 100644 index 00000000..9c632c3e Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/ui_text.frag.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/ui_text.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/ui_text.vert.spv new file mode 100644 index 00000000..5d5b87a4 Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/ui_text.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/ui_text.frag b/src/AcDream.App/Rendering/Shaders/ui_text.frag index 75c9cd3d..f0454e34 100644 --- a/src/AcDream.App/Rendering/Shaders/ui_text.frag +++ b/src/AcDream.App/Rendering/Shaders/ui_text.frag @@ -1,19 +1,45 @@ #version 430 core +#extension GL_ARB_bindless_texture : require in vec2 vUv; in vec4 vColor; out vec4 FragColor; -uniform sampler2D uTex; -uniform int uUseTexture; +// Campaign V slice V6d: the retained UI samples the device's global texture +// 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() { - if (uUseTexture == 1) { + if (uTextureIndexB != kUnassignedTextureSlot) { // 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); - } else if (uUseTexture == 2) { + } else if (uTextureIndexA != kUnassignedTextureSlot) { // RGBA dat sprite (decoded to RGBA8); modulate by tint/alpha. - FragColor = texture(uTex, vUv) * vColor; + FragColor = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv) * vColor; } else { FragColor = vColor; } diff --git a/src/AcDream.App/Rendering/Shaders/ui_text.vert b/src/AcDream.App/Rendering/Shaders/ui_text.vert index 0cc6c932..30d0cf71 100644 --- a/src/AcDream.App/Rendering/Shaders/ui_text.vert +++ b/src/AcDream.App/Rendering/Shaders/ui_text.vert @@ -3,16 +3,28 @@ layout(location = 0) in vec2 aPos; // screen pixels, origin top-left layout(location = 1) in vec2 aUv; 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 vec4 vColor; void main() { // 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( - aPos.x / uScreenSize.x * 2.0 - 1.0, - 1.0 - aPos.y / uScreenSize.y * 2.0); + aPos.x / uParamA * 2.0 - 1.0, + 1.0 - aPos.y / uParamB * 2.0); gl_Position = vec4(ndc, 0.0, 1.0); vUv = aUv; vColor = aColor; diff --git a/src/AcDream.App/Rendering/TextRenderGlStateScope.cs b/src/AcDream.App/Rendering/TextRenderGlStateScope.cs deleted file mode 100644 index b65123b7..00000000 --- a/src/AcDream.App/Rendering/TextRenderGlStateScope.cs +++ /dev/null @@ -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); -} - -/// -/// Exact, focused state transaction for . It -/// captures every GL value that Flush or DrawLayer mutates, while avoiding the -/// dozens of unrelated synchronous reads made by the broad diagnostic scope. -/// -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); - } -} diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index 20e329d4..406ca5f3 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -3,8 +3,6 @@ using System.Collections.Generic; using System.Numerics; using System.Runtime.InteropServices; using AcDream.App.Rendering.Gpu; -using AcDream.App.Rendering.Gpu.Gl; -using Silk.NET.OpenGL; namespace AcDream.App.Rendering; @@ -16,32 +14,38 @@ namespace AcDream.App.Rendering; /// /// Campaign V slice V4a: the ui_text shader compiles through /// (one , -/// replacing the old hand-rolled Shader class), its three +/// replacing the old hand-rolled Shader class) and its three /// fence-buffered per-flight VBOs are gone in favour of a per- -/// ring allocation per draw bucket, and the 1×1 white fill texture is created -/// and uploaded through and registered -/// into the device's texture table. +/// ring allocation per draw bucket. /// -/// has no verb for classic texture-unit binding -/// (every acdream RHI pass samples through the bindless texture table) but -/// receives an ARBITRARY externally-owned raw GL -/// 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 -/// to slot-based sampling is out of scope here. So sprite/font texture -/// binding stays classic (glActiveTexture/glBindTexture, -/// ui_text.frag's uTex sampler unchanged) issued directly against -/// the GL handle this class keeps for that reason, while the shader program -/// itself, its blend/depth/cull description, and every per-frame vertex -/// upload now go through the RHI. This mirrors the same GL-only escape hatch -/// 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 -/// texture consumers onto registered slots. +/// Campaign V slice V6d finished the job: this class no longer touches +/// GL at all, and is the first production renderer that draws on either +/// backend. Three things had to change for that. +/// +/// Textures. V4a kept a classic glActiveTexture/glBindTexture +/// path because receives an arbitrary texture from +/// dozens of widget call sites. Those textures are all registered into the +/// device's global table by TextureCache already — the classic path was +/// only ever consuming the raw GL name that registration also produced. They +/// now travel as values instead, and the +/// shader samples the table. +/// +/// Loose uniforms. uScreenSize and uUseTexture moved +/// into the pinned 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 +/// ui_text.frag for the three cases. +/// +/// GL capability state. The pass no longer disables multisampling +/// by hand — GlGpuPassEncoder 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. /// /// 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 /// text sits on top of background panels. /// -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 VertexStrideBytes = FloatsPerVertex * sizeof(float); @@ -54,16 +58,8 @@ public sealed unsafe class TextRenderer : IDisposable new GpuVertexAttribute(2, GpuVertexFormat.Float4, 16), ]); - private readonly IGpuDevice _device; - private readonly GlGpuDevice _glDevice; private readonly ICurrentGpuFrameSource _frameSource; - private readonly GL _gl; - private readonly ITextRenderGlStateApi _glState; 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 Verts = new(256); } @@ -112,82 +108,28 @@ public sealed unsafe class TextRenderer : IDisposable // change of their own. 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)); - 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); - IGpuPipeline? pipeline = null; - IGpuTexture? whiteTexture = null; - try + _pipeline = device.CreatePipeline(new GpuPipelineDescription { - pipeline = device.CreatePipeline(new GpuPipelineDescription - { - Name = "ui-text", - Shaders = new GpuShaderSet("ui_text"), - VertexLayout = SpriteVertexLayout, - Topology = GpuPrimitiveTopology.TriangleList, - Blend = GpuBlendMode.StraightAlpha, - // The retained UI is a self-contained 2-D pass — depth is - // irrelevant and the world pass's alpha-to-coverage state must - // not leak in (feedback_render_self_contained_gl_state). Flush - // below still asserts this by hand via raw GL calls (and - // GL_MULTISAMPLE, which has no representation here), matching - // what TextRenderGlStateScope has always restored on exit. - Depth = GpuDepthState.Disabled, - Cull = GpuCullMode.None, - 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); - } + Name = "ui-text", + Shaders = new GpuShaderSet("ui_text"), + VertexLayout = SpriteVertexLayout, + Topology = GpuPrimitiveTopology.TriangleList, + Blend = GpuBlendMode.StraightAlpha, + // The retained UI is a self-contained 2-D pass — depth is + // irrelevant and the world pass's alpha-to-coverage state must not + // leak in (feedback_render_self_contained_gl_state). Multisampling + // is the pass's business rather than the pipeline's and comes from + // the SampleCount below; see GlGpuPassEncoder's constructor. + Depth = GpuDepthState.Disabled, + Cull = GpuCullMode.None, + AlphaToCoverage = false, + ColorWrite = true, + SampleCount = 1, + }); } /// Begin a HUD pass. Call once per frame before any Draw* calls. @@ -218,9 +160,15 @@ public sealed unsafe class TextRenderer : IDisposable /// when active), so it composites in painter order with sprites + dat-font text. Use /// this — not — for a panel BACKGROUND that text draws on top of: /// DrawRect's bucket always flushes after all sprites, so a rect background would cover - /// the text instead. + /// the text instead. + /// + /// 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. + /// 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); /// Draw a 1-pixel-thick outline rect. 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 /// /// Draw a textured sprite quad in screen pixel space with an explicit - /// source-UV rectangle (for 9-slice / atlas sub-regions). Batched per - /// GL texture handle; flushed with uUseTexture=2 (RGBA modulate). + /// source-UV rectangle (for 9-slice / atlas sub-regions). + /// + /// is a — a + /// one-based index into the device's global texture table, which is what + /// TextureCache now hands out in place of the raw GL name it used to. + /// Segments batch per handle and draw in submission order. + /// draws the tint alone; every + /// widget guards against passing it, and uses it + /// deliberately. /// public void DrawSprite(uint texture, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 tint) @@ -331,14 +286,18 @@ public sealed unsafe class TextRenderer : IDisposable } /// - /// Resolves a produced by the paperdoll/appraisal + /// Encodes a produced by the paperdoll/appraisal /// viewport transitional seam (GlGpuDevice.RegisterExternalColorTexture, - /// campaign doc §7.1) back to the raw GL texture name - /// needs. Returns 0 (no texture) for an unassigned slot. GL-only; removed - /// at V4g alongside the seam it resolves. + /// campaign doc §7.1) as the handle takes. Returns + /// for an unassigned slot. + /// + /// 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. /// - internal uint ResolveExternalTextureSlot(GpuTextureSlot slot) => - _glDevice.TryResolveExternalColorTexture(slot, out uint name) ? name : 0; + internal static uint ResolveExternalTextureSlot(GpuTextureSlot slot) => + UiTextureTableHandle.FromSlot(slot); /// Pick the sprite segment for : extend the current /// 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 // not leak its depth/cull/blend/MSAA state into a later recoverable - // frame. The focused scope restores from Flush's generated finally, - // including when either DrawLayer call throws. - using var stateScope = new TextRenderGlStateScope(_glState); - + // frame. Slice V6d: the encoder's own `using` is that guarantee — the + // GL backend captures every ambient capability a pipeline bind can + // 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 { Name = "ui-text", @@ -422,21 +382,6 @@ public sealed unsafe class TextRenderer : IDisposable SampleCount = 1, }); 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): // 1. RGBA dat sprites — window chrome / panel backgrounds (behind) @@ -459,16 +404,14 @@ public sealed unsafe class TextRenderer : IDisposable List textBuf, int textVerts, BitmapFont? font, 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) { - SetUseTexture(2); - _gl.ActiveTexture(TextureUnit.Texture0); for (int i = 0; i < segUsed; i++) { var seg = spriteSegs[i]; 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); } } @@ -476,24 +419,33 @@ public sealed unsafe class TextRenderer : IDisposable // 2. Untextured rects — widget fills on top of the chrome. if (rectVerts > 0) { - SetUseTexture(0); + SetTextures(encoder, UiTextureTableHandle.None, UiTextureTableHandle.None); 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) { - SetUseTexture(1); - _gl.ActiveTexture(TextureUnit.Texture0); - _gl.BindTexture(TextureTarget.Texture2D, font.TextureId); + SetTextures(encoder, UiTextureTableHandle.None, coverageHandle: font.TextureId); DrawRing(frame, encoder, textBuf); } } - private void SetUseTexture(int mode) + /// + /// 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. + /// + private void SetTextures(IGpuPassEncoder encoder, uint colorHandle, uint coverageHandle) { - if (_uUseTextureLocation >= 0) - _gl.Uniform1(_uUseTextureLocation, mode); + GpuPushConstants constants = GpuPushConstants.Default; + constants.ParamA = _screenSize.X; + constants.ParamB = _screenSize.Y; + constants.TextureIndexA = UiTextureTableHandle.ToSlot(colorHandle).Index; + constants.TextureIndexB = UiTextureTableHandle.ToSlot(coverageHandle).Index; + encoder.SetPushConstants(constants); } /// @@ -513,9 +465,5 @@ public sealed unsafe class TextRenderer : IDisposable encoder.Draw((uint)(buf.Count / FloatsPerVertex), 1, 0, 0); } - public void Dispose() - { - _whiteTexture.Dispose(); - _pipeline.Dispose(); - } + public void Dispose() => _pipeline.Dispose(); } diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index 48da8081..e6c86be0 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -250,13 +250,20 @@ public sealed unsafe class TextureCache /// DefaultPaletteId (same starting palette /// uses); non-paletted formats have DefaultPaletteId==0 → palette null. Returns /// a 1x1 magenta handle on miss. + /// + /// Campaign V slice V6d: the returned value is a + /// — a one-based index into the device's + /// global texture table — not a raw GL texture name. Every caller passes it + /// straight to , 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. /// public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false) { if (_renderSurfaceGpuTextures.TryGetValue(renderSurfaceId, out GpuUiTextureEntry existing)) { width = existing.Width; height = existing.Height; - return existing.GlName; + return UiTextureTableHandle.FromSlot(existing.Slot); } DecodedTexture decoded; @@ -280,7 +287,7 @@ public sealed unsafe class TextureCache GpuUiTextureEntry entry = UploadUiTexture(decoded, nearest, $"ui-rendersurface-0x{renderSurfaceId:X8}"); _renderSurfaceGpuTextures[renderSurfaceId] = entry; width = decoded.Width; height = decoded.Height; - return entry.GlName; + return UiTextureTableHandle.FromSlot(entry.Slot); } /// @@ -288,13 +295,16 @@ public sealed unsafe class TextureCache /// decoded UI sprite/atlas and registers it into the device's global /// texture table. Every UI-path texture uses REPEAT addressing (existing /// behaviour — panel fills and tiled chrome sample UVs greater than 1) and - /// a single mip level (UI sprites never mip). The classic texture-unit - /// binding path uses for these - /// (see that class's remarks) samples the texture object directly rather - /// than through a bound sampler object, so filtering must live on the - /// texture itself — GlGpuTexture's constructor always sets Linear, - /// which is wrong for -requested (pixel-exact) - /// sprites, so it is overridden here exactly as the old raw-GL path did. + /// a single mip level (UI sprites never mip). + /// + /// Campaign V slice V6d: the sampler is now what filtering actually + /// comes from. Before this slice the draw bound the texture object directly, + /// so filtering lived on the texture and was + /// applied with a raw glTexParameter before the bindless handle was + /// 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. /// private GpuUiTextureEntry UploadUiTexture(DecodedTexture decoded, bool nearest, string debugName) { @@ -312,19 +322,7 @@ public sealed unsafe class TextureCache uint glName = ((GlGpuTexture)texture).GlName; TrackUploadedTexture(glName, decoded.Width, decoded.Height); - // MUST happen BEFORE RegisterTexture below: ARB_bindless_texture - // 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); + IGpuSampler sampler = _device.CreateSampler(nearest ? UiNearestRepeat : GpuSamplerDescription.WorldRepeat); GpuTextureSlot slot = _device.RegisterTexture(texture, sampler); return new GpuUiTextureEntry(texture, slot, glName, decoded.Width, decoded.Height); } @@ -335,6 +333,19 @@ public sealed unsafe class TextureCache } } + /// + /// 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: UiNearest clamps, WorldRepeat filters. + /// + private static readonly GpuSamplerDescription UiNearestRepeat = new( + GpuFilter.Nearest, + GpuFilter.Nearest, + GpuMipFilter.None, + GpuAddressMode.Repeat, + GpuAddressMode.Repeat, + MaxAnisotropy: 1f); + /// /// Alpha-channel histogram for one decoded texture. Used to diagnose /// "why are clouds not transparent" — if cloud textures come out with @@ -887,15 +898,20 @@ public sealed unsafe class TextureCache /// Uploads a raw RGBA8 byte array as a Texture2D. Used by /// to upload CPU-composited icon layers. - /// The returned handle is tracked in and deleted by - /// . Callers must NOT also store the handle in any of the - /// keyed caches — that would cause a double-delete on Dispose. + /// The texture is tracked in and deleted by + /// . Callers must NOT also store the returned handle in any + /// of the keyed caches — that would cause a double-delete on Dispose. + /// + /// Campaign V slice V6d: returns a + /// rather than a GL texture name, for the reason given on + /// . + /// public uint UploadRgba8(byte[] rgba, int width, int height, bool nearest = false) { GpuUiTextureEntry entry = UploadUiTexture( new DecodedTexture(rgba, width, height), nearest, "ui-adhoc-rgba8"); _adhocGpuTextures.Add(entry); - return entry.GlName; + return UiTextureTableHandle.FromSlot(entry.Slot); } private uint UploadRgba8(DecodedTexture decoded, bool nearest = false) diff --git a/src/AcDream.App/Rendering/UiTextureTableHandle.cs b/src/AcDream.App/Rendering/UiTextureTableHandle.cs new file mode 100644 index 00000000..edc71abf --- /dev/null +++ b/src/AcDream.App/Rendering/UiTextureTableHandle.cs @@ -0,0 +1,50 @@ +using AcDream.App.Rendering.Gpu; + +namespace AcDream.App.Rendering; + +/// +/// 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". +/// +/// Why an encoding rather than the slot itself. Until this slice +/// the UI's currency was a raw GL texture name: TextureCache handed one +/// out, sixty-odd widget call sites carried it, and TextRenderer.DrawSprite +/// bound it to texture unit 0. That name means nothing on Vulkan, so the +/// currency has to become a . But +/// is internal to the pinned RHI contract while +/// UiRenderContext.DrawSprite, TextureCache.GetOrUploadRenderSurface +/// 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. +/// +/// Why one-based. 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 (if (tex == 0) return;). 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. +/// +/// Retired when the retained UI's public surface can name a +/// directly. +/// +internal static class UiTextureTableHandle +{ + /// No texture. What every widget's tex == 0 guard tests for. + public const uint None = 0; + + /// Encodes a registered slot. An unassigned slot encodes to . + public static uint FromSlot(GpuTextureSlot slot) => + slot.IsAssigned ? slot.Index + 1 : None; + + /// + /// Decodes a handle. decodes to + /// , which the retained UI's shader + /// reads as "draw the vertex colour" rather than sampling anything. + /// + public static GpuTextureSlot ToSlot(uint handle) => + handle == None ? GpuTextureSlot.Unassigned : new GpuTextureSlot(handle - 1); +} diff --git a/src/AcDream.App/UI/UiViewport.cs b/src/AcDream.App/UI/UiViewport.cs index 3f479906..90ec21ab 100644 --- a/src/AcDream.App/UI/UiViewport.cs +++ b/src/AcDream.App/UI/UiViewport.cs @@ -50,7 +50,7 @@ public sealed class UiViewport : UiElement protected override void OnDraw(UiRenderContext ctx) { if (!Visible || !TextureSlot.IsAssigned) return; - uint textureHandle = ctx.TextRenderer.ResolveExternalTextureSlot(TextureSlot); + uint textureHandle = AcDream.App.Rendering.TextRenderer.ResolveExternalTextureSlot(TextureSlot); if (textureHandle == 0) return; // 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 diff --git a/tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs b/tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs index 4a7f532b..51f506af 100644 --- a/tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs @@ -108,10 +108,10 @@ public sealed class GlTextureOwnershipTests // from raw GlResourceCommand calls to IGpuDevice.CreatePipeline/ // CreateTexture, whose own checked-commit construction // (GlResourceCommand.CreateName / ShaderProgramConstruction.Build, - // predating this slice) is what those two now delegate to. What - // TextRenderer's OWN constructor still owns is the ordered - // pipeline-then-texture sequence and disposing whichever of the two - // it already created if the other throws. + // predating this slice) is what those now delegate to. Slice V6d then + // removed the white fill texture, so TextRenderer's constructor owns a + // single resource and holds no GL name of any kind — every checked + // commit boundary it depends on lives behind the RHI. string text = File.ReadAllText(Path.Combine( root, "src", "AcDream.App", "Rendering", "TextRenderer.cs")); string bindless = File.ReadAllText(Path.Combine( @@ -124,14 +124,9 @@ public sealed class GlTextureOwnershipTests Assert.Contains("GlResourceCommand.DeleteProgram", shaderPrograms, StringComparison.Ordinal); Assert.Contains("_anisotropyBindingMutation.Execute", terrain, StringComparison.Ordinal); Assert.Contains("GlResourceCommand.DeleteTexture", terrain, StringComparison.Ordinal); - AssertAppearsInOrder( - text, - "pipeline = device.CreatePipeline(", - "whiteTexture = device.CreateTexture(", - "catch", - "whiteTexture?.Dispose();", - "pipeline?.Dispose();", - "throw;"); + Assert.Contains("_pipeline = device.CreatePipeline(", text, StringComparison.Ordinal); + Assert.DoesNotContain("GlResourceCommand", text, StringComparison.Ordinal); + Assert.DoesNotContain("GlName", text, StringComparison.Ordinal); Assert.Contains("make bindless handle", bindless, StringComparison.Ordinal); Assert.Contains("GlResourceCommand.Execute", bindless, StringComparison.Ordinal); } diff --git a/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs b/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs index e5fbdd2c..dfe2fa26 100644 --- a/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs @@ -131,18 +131,20 @@ public sealed class ResourceCleanupGroupTests } /// - /// Campaign V slice V4a: TextRenderer's constructor no longer owns raw - /// VAO/VBO/texture GL names through a ResourceCleanupGroup ledger — it - /// creates exactly two device-owned resources (a pipeline, then the white - /// fill texture) and, since a later failure could otherwise orphan an - /// already-created pipeline, disposes whichever of the two it already - /// created if the other throws. This replaces the pre-V4a assertion of - /// the same name, which pinned the old multi-resource raw-GL shape - /// (Shader + three flight-indexed VAO/VBO pairs + a hand-rolled white - /// texture) that no longer exists. + /// Campaign V slice V4a moved TextRenderer's constructor off raw + /// VAO/VBO/texture GL names and onto two device-owned resources — a + /// pipeline and a 1x1 white fill texture — with a catch that disposed + /// whichever already existed when the other threw. + /// + /// Slice V6d removed the white texture: the shader gained an untextured + /// branch that produces what white-times-colour produced, so the fill needs + /// no texture at all. That leaves exactly ONE owned resource, which is a + /// 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. /// [Fact] - public void TextRendererDisposesWhicheverConstructorResourceAlreadyExistsOnFailure() + public void TextRendererConstructorOwnsExactlyOneDeviceResource() { string source = File.ReadAllText(Path.Combine( FindRepoRoot(), @@ -151,20 +153,27 @@ public sealed class ResourceCleanupGroupTests "Rendering", "TextRenderer.cs")); - AssertAppearsInOrder( - source, - "IGpuPipeline? pipeline = null;", - "IGpuTexture? whiteTexture = null;", - "try", - "pipeline = device.CreatePipeline(", - "whiteTexture = device.CreateTexture(", - "device.RegisterTexture(whiteTexture, whiteSampler);", - "catch", - "whiteTexture?.Dispose();", - "pipeline?.Dispose();", - "throw;", - "_pipeline = pipeline;", - "_whiteTexture = whiteTexture;"); + Assert.Equal(1, CountOccurrences(source, "device.CreatePipeline(")); + Assert.Equal(0, CountOccurrences(source, "device.CreateTexture(")); + Assert.Equal(0, CountOccurrences(source, "device.CreateBuffer(")); + Assert.Equal(0, CountOccurrences(source, "device.CreateSampler(")); + Assert.Equal(0, CountOccurrences(source, "device.RegisterTexture(")); + + // And the one resource is released. + Assert.Contains("public void Dispose() => _pipeline.Dispose();", source, StringComparison.Ordinal); + } + + private static int CountOccurrences(string source, string needle) + { + int count = 0; + for (int i = source.IndexOf(needle, StringComparison.Ordinal); + i >= 0; + i = source.IndexOf(needle, i + needle.Length, StringComparison.Ordinal)) + { + count++; + } + + return count; } [Fact] diff --git a/tests/AcDream.App.Tests/Rendering/TextRendererFailureSafetyTests.cs b/tests/AcDream.App.Tests/Rendering/TextRendererFailureSafetyTests.cs index 77154717..c4fc61cf 100644 --- a/tests/AcDream.App.Tests/Rendering/TextRendererFailureSafetyTests.cs +++ b/tests/AcDream.App.Tests/Rendering/TextRendererFailureSafetyTests.cs @@ -1,13 +1,22 @@ using System.Reflection; using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu.Gl; using Silk.NET.OpenGL; namespace AcDream.App.Tests.Rendering; +/// +/// 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 , which every RHI pass +/// gets. These tests follow it there. +/// public sealed class TextRendererFailureSafetyTests { [Fact] - public void Flush_CompilesCompleteGlStateScopeAsFinallyAroundBothDrawLayers() + public void Flush_CompilesThePassEncoderAsFinallyAroundBothDrawLayers() { MethodInfo flush = typeof(TextRenderer).GetMethod(nameof(TextRenderer.Flush))!; MethodBody body = flush.GetMethodBody()!; @@ -18,27 +27,36 @@ public sealed class TextRendererFailureSafetyTests "Rendering", "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( body.ExceptionHandlingClauses, clause => clause.Flags == ExceptionHandlingClauseOptions.Finally); AssertAppearsInOrder( source, - "using var stateScope = new TextRenderGlStateScope(_glState);", - "_gl.Disable(EnableCap.Multisample);", + "using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription", + "encoder.BindPipeline(_pipeline);", "DrawLayer(_spriteSegs,", "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] - public void FailedDraw_RestoresEveryGlValueMutatedByTheTextPass() + public void FailedDraw_RestoresEveryGlValueMutatedByThePass() { var gl = new RecordingGlState { DepthWrite = false, + DepthFuncValue = DepthFunction.Greater, BlendSourceRgb = BlendingFactor.DstAlpha, BlendDestinationRgb = BlendingFactor.OneMinusDstAlpha, BlendSourceAlpha = BlendingFactor.One, BlendDestinationAlpha = BlendingFactor.Zero, + CullFaceMode = TriangleFace.Front, + FrontFaceDirection = FrontFaceDirection.CW, Program = 17, VertexArray = 23, ArrayBuffer = 31, @@ -48,31 +66,44 @@ public sealed class TextRendererFailureSafetyTests gl.SetCapability(EnableCap.Blend, enabled: false); gl.SetCapability(EnableCap.CullFace, 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.Texture2] = 43; StateSnapshot expected = gl.Capture(); Action failedDraw = () => { - using var stateScope = new TextRenderGlStateScope(gl); - gl.SetCapability(EnableCap.DepthTest, enabled: false); - gl.SetCapability(EnableCap.Blend, enabled: true); - gl.SetCapability(EnableCap.CullFace, enabled: false); - gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: false); - gl.SetCapability(EnableCap.Multisample, enabled: true); - gl.DepthMask(true); - gl.BlendFuncSeparate( - BlendingFactor.SrcAlpha, - BlendingFactor.OneMinusSrcAlpha, - BlendingFactor.SrcAlpha, - BlendingFactor.OneMinusSrcAlpha); - 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"); + GlAmbientCapabilityState ambient = GlAmbientCapabilityState.Capture(gl); + try + { + gl.SetCapability(EnableCap.DepthTest, enabled: false); + gl.SetCapability(EnableCap.Blend, enabled: true); + gl.SetCapability(EnableCap.CullFace, enabled: false); + gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: false); + gl.SetCapability(EnableCap.Multisample, enabled: false); + gl.DepthMask(true); + gl.DepthFunc(DepthFunction.Lequal); + gl.BlendFuncSeparate( + BlendingFactor.SrcAlpha, + BlendingFactor.OneMinusSrcAlpha, + BlendingFactor.SrcAlpha, + BlendingFactor.OneMinusSrcAlpha); + gl.CullFace(TriangleFace.Back); + gl.FrontFace(FrontFaceDirection.Ccw); + 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(failedDraw); @@ -87,10 +118,13 @@ public sealed class TextRendererFailureSafetyTests bool AlphaToCoverage, bool Multisample, bool DepthWrite, + DepthFunction DepthFunc, BlendingFactor BlendSourceRgb, BlendingFactor BlendDestinationRgb, BlendingFactor BlendSourceAlpha, BlendingFactor BlendDestinationAlpha, + TriangleFace CullFaceMode, + FrontFaceDirection FrontFaceDirection, uint Program, uint VertexArray, uint ArrayBuffer, @@ -98,15 +132,18 @@ public sealed class TextRendererFailureSafetyTests uint Texture0, uint Texture2); - private sealed class RecordingGlState : ITextRenderGlStateApi + private sealed class RecordingGlState : IGlAmbientStateApi { private readonly Dictionary _capabilities = []; public bool DepthWrite { get; set; } + public DepthFunction DepthFuncValue { get; set; } public BlendingFactor BlendSourceRgb { get; set; } public BlendingFactor BlendDestinationRgb { get; set; } public BlendingFactor BlendSourceAlpha { get; set; } public BlendingFactor BlendDestinationAlpha { get; set; } + public TriangleFace CullFaceMode { get; set; } + public FrontFaceDirection FrontFaceDirection { get; set; } public uint Program { get; set; } public uint VertexArray { get; set; } public uint ArrayBuffer { get; set; } @@ -120,10 +157,13 @@ public sealed class TextRendererFailureSafetyTests IsEnabled(EnableCap.SampleAlphaToCoverage), IsEnabled(EnableCap.Multisample), DepthWrite, + DepthFuncValue, BlendSourceRgb, BlendDestinationRgb, BlendSourceAlpha, BlendDestinationAlpha, + CullFaceMode, + FrontFaceDirection, Program, VertexArray, ArrayBuffer, @@ -140,6 +180,9 @@ public sealed class TextRendererFailureSafetyTests GetPName.BlendDstRgb => (int)BlendDestinationRgb, GetPName.BlendSrcAlpha => (int)BlendSourceAlpha, GetPName.BlendDstAlpha => (int)BlendDestinationAlpha, + GetPName.DepthFunc => (int)DepthFuncValue, + GetPName.CullFaceMode => (int)CullFaceMode, + GetPName.FrontFace => (int)FrontFaceDirection, GetPName.CurrentProgram => (int)Program, GetPName.VertexArrayBinding => (int)VertexArray, GetPName.ArrayBufferBinding => (int)ArrayBuffer, @@ -159,6 +202,8 @@ public sealed class TextRendererFailureSafetyTests public void DepthMask(bool enabled) => DepthWrite = enabled; + public void DepthFunc(DepthFunction function) => DepthFuncValue = function; + public void BlendFuncSeparate( BlendingFactor sourceRgb, BlendingFactor destinationRgb, @@ -171,6 +216,10 @@ public sealed class TextRendererFailureSafetyTests 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 BindVertexArray(uint vertexArray) => VertexArray = vertexArray; diff --git a/tools/ShaderCompiler/VulkanGlslPreamble.cs b/tools/ShaderCompiler/VulkanGlslPreamble.cs index c611e40b..08f9fc9b 100644 --- a/tools/ShaderCompiler/VulkanGlslPreamble.cs +++ b/tools/ShaderCompiler/VulkanGlslPreamble.cs @@ -94,6 +94,13 @@ internal static class VulkanGlslPreamble text.AppendLine("#undef ACDREAM_TEXTURE_HANDLE"); text.AppendLine("#define ACDREAM_TEXTURE_HANDLE(idx) (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("// §3.4 push constants: one shared 96-byte block, so switching pipelines"); text.AppendLine("// mid-pass invalidates neither descriptors nor constants.");