From eced67d03844a5cfc8f76d234b1f19d872dc0d6f Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Jul 2026 17:36:45 +0200 Subject: [PATCH] feat(render): Campaign V slice V6l commit 2 - the portal mask draws on Vulkan Contract amendment 2 of three, and V4g's remaining half behind it. Plan section 5.5.16 defect 2: PortalDepthMaskRenderer's two-pass punch (#117) is built on glStencilFunc/glStencilOp/glStencilMask, GpuPipelineDescription carried no stencil state at all, and nothing else can express it - so the renderer stayed raw GL, invisible to the Vulkan arm, and V4g's "stencil/depth-mask pipelines" row could not be written. The amendment splits the way core Vulkan 1.3 splits. The ENABLE and the attachment intent are baked: GpuPipelineDescription.StencilTest, false by default so no pipeline in the tree changed. The per-draw compare, three outcome ops, reference and both masks are a GpuStencilState that the pipeline carries as a DEFAULT and IGpuPassEncoder.SetStencil overrides - exactly the split cull mode, front face and depth write already have, and exactly what VK_DYNAMIC_STATE_STENCIL_OP/_COMPARE_MASK/_WRITE_MASK/_REFERENCE make dynamic. The four stencil dynamic states are declared ONLY by a pipeline that tests stencil: declaring a dynamic state obliges every draw with the pipeline to have set it, so adding them unconditionally would make every existing pipeline depend on a call none of them make. GpuStencilOp carries three values because the punch uses three - Replace marks, Equal gates, Zero self-cleans - and a fourth would be a facility with no consumer. The arm. Three pipelines, not one, because depth COMPARE is not dynamic in the contract and the punch's two passes differ in it: mark tests LEQUAL and writes no depth, punch tests ALWAYS and writes, seal is ALWAYS + write with no stencil. All three write no colour, which is what retail's "COLOR-INVISIBLE triangle fan" means. The fan is expanded to a triangle LIST on the CPU - the contract has no fan topology and Vulkan's is not portable - which is exact: triangle i is (v0, v[i+1], v[i+2]), the same triangles in the same order. portal_depth.{vert,frag} is a new committed shader pair, and this is the ONE renderer in the campaign whose two arms do not share a source. Its clip planes have to travel in the TerrainClip uniform block at binding 2, which is already precisely this shape and already read by terrain_modern.vert and sky.vert - but on GL that binding is held globally by ClipFrame for terrain, so a portal draw that rebound it would leave every later terrain draw in the frame reading the wrong region. The GL arm therefore keeps its inline program. PortalDepthShaderParityTests is the tripwire: retail's far-Z constant (0.99999988, from DrawPortalPolyInternal 0x0059bc90), #129's capped mark-bias expression and the eight-half-plane loop are asserted to appear in both. Both are deleted at V11. 9/10 shader pairs now compile to SPIR-V. Two GL-side gaps closed while the state was being extended, both of section 7.1 rule 1's class rather than new work. GlAmbientCapabilityState now saves and restores the stencil test, function, ops and both masks - the portal punch draws mid-frame among renderers that are still raw GL and assume the test is off - and the COLOUR MASK, which had no consumer until a colour-invisible pipeline existed and whose absence would have blacked out every raw-GL renderer after such a pass. PortalTunnelPresentation was re-read and confirmed as V6k left it: it clears depth and draws into the active viewport, binds no framebuffer of its own, and needs no port for section 5.4's sake. It remains unported on the Vulkan arm - the composition uses NullLocalPlayerTeleportPresentation there - which is an absence on the V7 list, not a defect. Gates. Release build green. App tests 4,129/3 skips; complete Release suite 9,192/5 (one solution-wide run reported a single App failure that did not reproduce in two subsequent runs, solution-wide or alone - the documented rerun-singly flake class). Strict GL offline pixel gate against 08ffe141: 2.31e-05, 13 differing pixels of 563,200, inside the documented 9-31 band. GL connected -Runs 3: 3/3 RENDERED on the desktop witness and 3/3 on the client capture. One offline Vulkan run with VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation errors, zero warnings, a captured world frame. Co-Authored-By: Claude Fable 5 --- .../LivePresentationComposition.cs | 15 +- .../Gpu/Gl/GlAmbientCapabilityState.cs | 91 +++++- .../Rendering/Gpu/Gl/GlEnumMapping.cs | 22 ++ .../Rendering/Gpu/Gl/GlGpuDevice.cs | 24 ++ .../Rendering/Gpu/Gl/GlGpuPassEncoder.cs | 10 +- .../Rendering/Gpu/Gl/GlRenderStateCache.cs | 18 +- src/AcDream.App/Rendering/Gpu/GpuEnums.cs | 23 ++ .../Rendering/Gpu/GpuPipelineDescription.cs | 64 ++++ .../Rendering/Gpu/IGpuPassEncoder.cs | 12 + .../Gpu/Vk/VulkanGpuDevice.Resources.cs | 20 ++ .../Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs | 22 ++ .../Rendering/Gpu/Vk/VulkanGpuPipeline.cs | 37 ++- .../Rendering/Gpu/Vk/VulkanViewportMapping.cs | 9 + .../Rendering/PortalDepthMaskRenderer.Rhi.cs | 304 ++++++++++++++++++ .../Rendering/PortalDepthMaskRenderer.cs | 50 ++- .../Rendering/Shaders/portal_depth.frag | 12 + .../Rendering/Shaders/portal_depth.vert | 70 ++++ .../Shaders/spv/portal_depth.frag.spv | Bin 0 -> 152 bytes .../Shaders/spv/portal_depth.vert.spv | Bin 0 -> 2360 bytes .../Shaders/spv/shaders.manifest.json | 16 + .../Gpu/Gl/GlRenderStateCacheTests.cs | 34 +- .../Rendering/Gpu/GpuContractTests.cs | 66 ++++ .../Rendering/Gpu/RecordingGpuDevice.cs | 3 + .../Rendering/PortalDepthShaderParityTests.cs | 79 +++++ .../TextRendererFailureSafetyTests.cs | 75 ++++- 25 files changed, 1052 insertions(+), 24 deletions(-) create mode 100644 src/AcDream.App/Rendering/PortalDepthMaskRenderer.Rhi.cs create mode 100644 src/AcDream.App/Rendering/Shaders/portal_depth.frag create mode 100644 src/AcDream.App/Rendering/Shaders/portal_depth.vert create mode 100644 src/AcDream.App/Rendering/Shaders/spv/portal_depth.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/portal_depth.vert.spv create mode 100644 tests/AcDream.App.Tests/Rendering/PortalDepthShaderParityTests.cs diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 772252cf..7dcabd57 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -965,9 +965,20 @@ internal sealed class LivePresentationCompositionPhase "portal clip frame", ClipFrame.NoClip, static value => value.Dispose()); - var portalDepthLease = scope.AcquireOptional( + // Campaign V slice V6l: the portal depth mask exists on BOTH arms. The + // GL arm keeps its inline program and raw draws; the RHI arm compiles + // portal_depth from SPIR-V into three pipelines and records into the + // pass the world scene phase publishes on this scope. + var portalDepthLease = scope.Acquire( "portal depth mask", - () => gl is null ? null : new PortalDepthMaskRenderer(gl), + () => gl is not null + ? new PortalDepthMaskRenderer(gl) + : new PortalDepthMaskRenderer( + host.GpuDevice, + host.GpuFrameLifetime, + worldPassScope + ?? throw new InvalidOperationException( + "A backend without a GL context must publish a world pass scope.")), static value => value.Dispose()); CompositionAcquisitionScope.CompositionAcquisitionLease< PortalWaitNoticeController>? portalWaitNoticeLease = null; diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlAmbientCapabilityState.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlAmbientCapabilityState.cs index fcd6b70f..af113ff4 100644 --- a/src/AcDream.App/Rendering/Gpu/Gl/GlAmbientCapabilityState.cs +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlAmbientCapabilityState.cs @@ -22,6 +22,9 @@ internal interface IGlAmbientStateApi bool GetBoolean(GetPName parameter); + /// The four colour-mask channels, in RGBA order. + bool[] GetColorMask(); + void SetCapability(EnableCap capability, bool enabled); void DepthMask(bool enabled); @@ -38,6 +41,14 @@ internal interface IGlAmbientStateApi void FrontFace(FrontFaceDirection direction); + void StencilFunc(StencilFunction function, int reference, uint mask); + + void StencilOp(StencilOp fail, StencilOp depthFail, StencilOp pass); + + void StencilMask(uint mask); + + void ColorMask(bool red, bool green, bool blue, bool alpha); + void UseProgram(uint program); void BindVertexArray(uint vertexArray); @@ -65,6 +76,14 @@ internal sealed class SilkGlAmbientStateApi : IGlAmbientStateApi public bool GetBoolean(GetPName parameter) => _gl.GetBoolean(parameter); + public unsafe bool[] GetColorMask() + { + var values = new bool[4]; + fixed (bool* first = values) + _gl.GetBoolean(GetPName.ColorWritemask, first); + return values; + } + public void SetCapability(EnableCap capability, bool enabled) { if (enabled) @@ -88,6 +107,17 @@ internal sealed class SilkGlAmbientStateApi : IGlAmbientStateApi public void FrontFace(FrontFaceDirection direction) => _gl.FrontFace(direction); + public void StencilFunc(StencilFunction function, int reference, uint mask) => + _gl.StencilFunc(function, reference, mask); + + public void StencilOp(StencilOp fail, StencilOp depthFail, StencilOp pass) => + _gl.StencilOp(fail, depthFail, pass); + + public void StencilMask(uint mask) => _gl.StencilMask(mask); + + public void ColorMask(bool red, bool green, bool blue, bool alpha) => + _gl.ColorMask(red, green, blue, alpha); + public void UseProgram(uint program) => _gl.UseProgram(program); public void BindVertexArray(uint vertexArray) => _gl.BindVertexArray(vertexArray); @@ -130,13 +160,35 @@ internal readonly struct GlAmbientCapabilityState private readonly int _frontFace; private readonly bool _alphaToCoverage; private readonly bool _multisample; + // Slice V6l: the stencil dimension. #117's portal punch is the only pipeline + // that enables it, and it draws in the middle of a frame whose other + // renderers are still raw GL and assume the test is off. + private readonly bool _stencilTest; + private readonly int _stencilFunc; + private readonly int _stencilReference; + private readonly int _stencilValueMask; + private readonly int _stencilWriteMask; + private readonly int _stencilFail; + private readonly int _stencilDepthFail; + private readonly int _stencilPass; + // Slice V6l: GpuPipelineDescription.ColorWrite has had no consumer until the + // portal depth mask, which is colour-invisible by construction. A pass that + // left the mask off would black out every raw-GL renderer that followed it, + // which is §7.1 rule 1's exact failure mode wearing a different name. + private readonly bool _colorMaskRed; + private readonly bool _colorMaskGreen; + private readonly bool _colorMaskBlue; + private readonly bool _colorMaskAlpha; 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) + bool alphaToCoverage, bool multisample, + bool stencilTest, int stencilFunc, int stencilReference, int stencilValueMask, + int stencilWriteMask, int stencilFail, int stencilDepthFail, int stencilPass, + bool colorMaskRed, bool colorMaskGreen, bool colorMaskBlue, bool colorMaskAlpha) { _program = program; _vertexArray = vertexArray; @@ -156,6 +208,18 @@ internal readonly struct GlAmbientCapabilityState _frontFace = frontFace; _alphaToCoverage = alphaToCoverage; _multisample = multisample; + _stencilTest = stencilTest; + _stencilFunc = stencilFunc; + _stencilReference = stencilReference; + _stencilValueMask = stencilValueMask; + _stencilWriteMask = stencilWriteMask; + _stencilFail = stencilFail; + _stencilDepthFail = stencilDepthFail; + _stencilPass = stencilPass; + _colorMaskRed = colorMaskRed; + _colorMaskGreen = colorMaskGreen; + _colorMaskBlue = colorMaskBlue; + _colorMaskAlpha = colorMaskAlpha; } internal static GlAmbientCapabilityState Capture(IGlAmbientStateApi gl) @@ -177,6 +241,7 @@ internal readonly struct GlAmbientCapabilityState gl.ActiveTexture((TextureUnit)activeTexture); } + bool[] colorMask = gl.GetColorMask(); return new GlAmbientCapabilityState( program, vertexArray, arrayBuffer, activeTexture, texture0Binding2D, gl.IsEnabled(EnableCap.DepthTest), @@ -191,7 +256,16 @@ internal readonly struct GlAmbientCapabilityState gl.GetInteger(GetPName.CullFaceMode), gl.GetInteger(GetPName.FrontFace), gl.IsEnabled(EnableCap.SampleAlphaToCoverage), - gl.IsEnabled(EnableCap.Multisample)); + gl.IsEnabled(EnableCap.Multisample), + gl.IsEnabled(EnableCap.StencilTest), + gl.GetInteger(GetPName.StencilFunc), + gl.GetInteger(GetPName.StencilRef), + gl.GetInteger(GetPName.StencilValueMask), + gl.GetInteger(GetPName.StencilWritemask), + gl.GetInteger(GetPName.StencilFail), + gl.GetInteger(GetPName.StencilPassDepthFail), + gl.GetInteger(GetPName.StencilPassDepthPass), + colorMask[0], colorMask[1], colorMask[2], colorMask[3]); } internal void Restore(IGlAmbientStateApi gl) @@ -222,5 +296,18 @@ internal readonly struct GlAmbientCapabilityState gl.SetCapability(EnableCap.SampleAlphaToCoverage, _alphaToCoverage); gl.SetCapability(EnableCap.Multisample, _multisample); + + gl.SetCapability(EnableCap.StencilTest, _stencilTest); + gl.StencilFunc( + (StencilFunction)_stencilFunc, + _stencilReference, + (uint)_stencilValueMask); + gl.StencilOp( + (StencilOp)_stencilFail, + (StencilOp)_stencilDepthFail, + (StencilOp)_stencilPass); + gl.StencilMask((uint)_stencilWriteMask); + + gl.ColorMask(_colorMaskRed, _colorMaskGreen, _colorMaskBlue, _colorMaskAlpha); } } diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs index fe13f053..f9ed094a 100644 --- a/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs @@ -39,6 +39,28 @@ internal static class GlEnumMapping _ => throw new NotSupportedException($"No GL vertex attribute shape for {format}."), }; + /// Slice V6l: the stencil comparison, which shares GL's depth-function enum values. + public static StencilFunction StencilFunctionOf(GpuCompareOp compare) => compare switch + { + GpuCompareOp.Never => StencilFunction.Never, + GpuCompareOp.Less => StencilFunction.Less, + GpuCompareOp.LessOrEqual => StencilFunction.Lequal, + GpuCompareOp.Equal => StencilFunction.Equal, + GpuCompareOp.Greater => StencilFunction.Greater, + GpuCompareOp.GreaterOrEqual => StencilFunction.Gequal, + GpuCompareOp.Always => StencilFunction.Always, + _ => throw new NotSupportedException($"No GL stencil function for {compare}."), + }; + + /// Slice V6l: what a stencil outcome does to the stored value. + public static StencilOp StencilOpOf(GpuStencilOp op) => op switch + { + GpuStencilOp.Keep => StencilOp.Keep, + GpuStencilOp.Zero => StencilOp.Zero, + GpuStencilOp.Replace => StencilOp.Replace, + _ => throw new NotSupportedException($"No GL stencil operation for {op}."), + }; + public static PrimitiveType PrimitiveTypeOf(GpuPrimitiveTopology topology) => topology switch { GpuPrimitiveTopology.TriangleList => PrimitiveType.Triangles, diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs index 38deb719..9671122d 100644 --- a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs @@ -529,6 +529,30 @@ internal sealed class GlGpuDevice : IGpuDevice { _gl.ColorMask(desired.ColorWrite, desired.ColorWrite, desired.ColorWrite, desired.ColorWrite); } + // Slice V6l. The stencil VALUES are issued whenever the test is on, even + // if only the enable changed: GL keeps func/op/mask as global state that a + // raw-GL renderer may have moved since this cache last saw it, and the + // portal punch's correctness depends on the exact triple it asked for. + if (changes.StencilTest) + { + if (desired.StencilTest) + _gl.Enable(EnableCap.StencilTest); + else + _gl.Disable(EnableCap.StencilTest); + } + if (desired.StencilTest && (changes.StencilTest || changes.Stencil)) + { + GpuStencilState stencil = desired.Stencil; + _gl.StencilFunc( + GlEnumMapping.StencilFunctionOf(stencil.Compare), + (int)stencil.Reference, + stencil.CompareMask); + _gl.StencilOp( + GlEnumMapping.StencilOpOf(stencil.Fail), + GlEnumMapping.StencilOpOf(stencil.DepthFail), + GlEnumMapping.StencilOpOf(stencil.Pass)); + _gl.StencilMask(stencil.WriteMask); + } GLHelpers.ThrowOnResourceError(_gl, "apply GL render state"); } diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs index 4b311cae..d2b895c6 100644 --- a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs @@ -81,7 +81,9 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder description.Cull, description.FrontFace, description.AlphaToCoverage, - description.ColorWrite); + description.ColorWrite, + description.StencilTest, + description.Stencil); _device.ApplyRenderState(desired); _gl.BindVertexArray(p.GlVertexArray); @@ -222,6 +224,12 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder _device.ApplyRenderState(_device.CurrentRenderState with { DepthWrite = enabled }); } + public void SetStencil(in GpuStencilState stencil) + { + ThrowIfClosed(); + _device.ApplyRenderState(_device.CurrentRenderState with { Stencil = stencil }); + } + public unsafe void DrawIndexed(uint indexCount, uint instanceCount, uint firstIndex, int vertexOffset, uint firstInstance) { ThrowIfClosed(); diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlRenderStateCache.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlRenderStateCache.cs index 846f8d35..ae458909 100644 --- a/src/AcDream.App/Rendering/Gpu/Gl/GlRenderStateCache.cs +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlRenderStateCache.cs @@ -15,7 +15,9 @@ internal readonly record struct GlRenderStateSnapshot( GpuCullMode Cull, GpuFrontFace FrontFace, bool AlphaToCoverage, - bool ColorWrite); + bool ColorWrite, + bool StencilTest, + GpuStencilState Stencil); /// Which GL state calls are needed to move from the previous snapshot to the new one. internal readonly record struct GlRenderStateChanges( @@ -27,14 +29,18 @@ internal readonly record struct GlRenderStateChanges( bool Cull, bool FrontFace, bool AlphaToCoverage, - bool ColorWrite) + bool ColorWrite, + bool StencilTest, + bool Stencil) { public bool AnyChange => Program || Blend || DepthTest || DepthWrite || DepthCompare - || Cull || FrontFace || AlphaToCoverage || ColorWrite; + || Cull || FrontFace || AlphaToCoverage || ColorWrite + || StencilTest || Stencil; /// Every dimension reported changed — used for the first apply after a reset. - internal static GlRenderStateChanges All { get; } = new(true, true, true, true, true, true, true, true, true); + internal static GlRenderStateChanges All { get; } = + new(true, true, true, true, true, true, true, true, true, true, true); } /// @@ -68,7 +74,9 @@ internal sealed class GlRenderStateCache p.Cull != desired.Cull, p.FrontFace != desired.FrontFace, p.AlphaToCoverage != desired.AlphaToCoverage, - p.ColorWrite != desired.ColorWrite); + p.ColorWrite != desired.ColorWrite, + p.StencilTest != desired.StencilTest, + p.Stencil != desired.Stencil); } /// Discards the cached baseline — the next reports every dimension changed. diff --git a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs index edd4012a..a645786b 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs @@ -152,6 +152,29 @@ internal enum GpuCullMode Front, } +/// +/// What a stencil test does to the stencil buffer at one of its three outcomes. +/// +/// Added at slice V6l with the stencil dimension. These three are exactly +/// what PortalDepthMaskRenderer's two-pass punch (#117) uses — mark with +/// Replace, gate on Equal, self-clean with Zero — and +/// nothing else in the tree touches stencil at all. Retail's own portal fan is a +/// depth write with no stencil; the stencil pass is acdream's z-buffered +/// equivalent of retail's painter's-order safety, and is recorded as such in +/// PortalDepthMaskRenderer. +/// +internal enum GpuStencilOp +{ + /// Leave the stored value alone. + Keep, + + /// Store zero. + Zero, + + /// Store the reference value. + Replace, +} + /// /// Triangle winding treated as front-facing. Both backends receive the SAME value /// from renderers; the Vulkan backend inverts it internally because it renders diff --git a/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs b/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs index 7422a230..e04ee578 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs @@ -192,6 +192,44 @@ internal readonly record struct GpuDepthState(bool Test, bool Write, GpuCompareO public static GpuDepthState Disabled { get; } = new(Test: false, Write: false, GpuCompareOp.Always); } +/// +/// The per-draw half of stencil state: the comparison, what happens at each of +/// its three outcomes, and the reference and masks it uses. +/// +/// Added at slice V6l. Core Vulkan 1.3 makes ALL of these dynamic +/// (VK_DYNAMIC_STATE_STENCIL_OP, _COMPARE_MASK, _WRITE_MASK, +/// _REFERENCE), and #117's portal punch changes every one of them between +/// its mark pass and its punch pass, so they live here as a pipeline DEFAULT and +/// on as the per-draw override — +/// exactly the split cull mode, front face and depth write already have. +/// Whether the pipeline uses the stencil aspect at all is +/// , because that is also the +/// attachment intent. +/// +/// The front and back faces always carry the same state. Retail's portal +/// fans are drawn with culling off and face either way, so a two-sided +/// distinction would be a facility with no consumer. +/// +internal readonly record struct GpuStencilState( + GpuCompareOp Compare, + GpuStencilOp Fail, + GpuStencilOp DepthFail, + GpuStencilOp Pass, + uint Reference, + uint CompareMask, + uint WriteMask) +{ + /// GL's and Vulkan's own defaults: always pass, never write. + public static GpuStencilState Default { get; } = new( + GpuCompareOp.Always, + GpuStencilOp.Keep, + GpuStencilOp.Keep, + GpuStencilOp.Keep, + Reference: 0, + CompareMask: 0xFF, + WriteMask: 0xFF); +} + /// /// Everything a draw needs beyond its buffers: the shader pair and all fixed /// state. Vulkan bakes this into one VkPipeline at startup, which is why @@ -237,6 +275,32 @@ internal sealed record GpuPipelineDescription /// Whether the pipeline writes colour at all. False for depth/stencil-only prepasses. public bool ColorWrite { get; init; } = true; + /// + /// Whether this pipeline uses the stencil aspect at all. + /// + /// Added at slice V6l for #117's portal punch, which is the only + /// consumer in the tree and which nothing else could express: the V0 contract + /// carried no stencil state, so PortalDepthMaskRenderer stayed raw GL + /// and V4g's "stencil/depth-mask pipelines" row could not be written (plan + /// §5.5.16 defect 2). + /// + /// This is the ENABLE and the attachment intent together, which is why + /// it is baked while everything in is dynamic. Vulkan + /// makes stencilTestEnable dynamic too, but a pipeline that declares + /// the stencil dynamic states obliges every draw with it to have set them, + /// so a pipeline that will never test stencil is better off saying so once. + /// + /// + public bool StencilTest { get; init; } + + /// + /// Default stencil compare/op/reference/mask, re-established by + /// BindPipeline and overridable per draw through + /// . Ignored entirely when + /// is false. + /// + public GpuStencilState Stencil { get; init; } = GpuStencilState.Default; + /// /// Format of the colour attachment this pipeline renders into. /// diff --git a/src/AcDream.App/Rendering/Gpu/IGpuPassEncoder.cs b/src/AcDream.App/Rendering/Gpu/IGpuPassEncoder.cs index 7762610d..6e0fb4bc 100644 --- a/src/AcDream.App/Rendering/Gpu/IGpuPassEncoder.cs +++ b/src/AcDream.App/Rendering/Gpu/IGpuPassEncoder.cs @@ -63,6 +63,18 @@ internal interface IGpuPassEncoder : IDisposable /// Dynamic depth-write override — how the translucent pass stops occluding later draws. void SetDepthWrite(bool enabled); + /// + /// Dynamic stencil override: compare, the three outcome ops, reference and + /// both masks. Meaningful only inside a pipeline whose + /// is set. + /// + /// Slice V6l. #117's portal punch changes every one of these between + /// its stencil-marking pass and its far-Z punch pass, and core Vulkan 1.3 + /// makes all of them dynamic, so they belong here rather than in a second + /// pipeline object. + /// + void SetStencil(in GpuStencilState stencil); + /// Draws indexed geometry directly, without an indirect buffer. void DrawIndexed(uint indexCount, uint instanceCount, uint firstIndex, int vertexOffset, uint firstInstance); diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs index d4de2ea7..006e9302 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs @@ -381,6 +381,26 @@ internal sealed unsafe partial class VulkanGpuDevice _vk.CmdSetCullMode(commands, VulkanViewportMapping.ToVulkan(description.Cull)); _vk.CmdSetFrontFace(commands, VulkanViewportMapping.ToVulkan(description.FrontFace)); _vk.CmdSetDepthWriteEnable(commands, description.Depth.Write); + // Slice V6l: a stencil pipeline declares four dynamic stencil states, and + // a declared dynamic state must be set before any draw uses it. Setting + // the pipeline's own declared default here is both what makes the draw + // legal without a renderer call and the exact mirror of the three lines + // above — bind restores the pipeline's defaults, the encoder overrides. + if (!description.StencilTest) + return; + + GpuStencilState stencil = description.Stencil; + const StencilFaceFlags BothFaces = StencilFaceFlags.FaceFrontAndBack; + _vk.CmdSetStencilOp( + commands, + BothFaces, + VulkanViewportMapping.ToVulkan(stencil.Fail), + VulkanViewportMapping.ToVulkan(stencil.Pass), + VulkanViewportMapping.ToVulkan(stencil.DepthFail), + VulkanViewportMapping.ToVulkan(stencil.Compare)); + _vk.CmdSetStencilCompareMask(commands, BothFaces, stencil.CompareMask); + _vk.CmdSetStencilWriteMask(commands, BothFaces, stencil.WriteMask); + _vk.CmdSetStencilReference(commands, BothFaces, stencil.Reference); } /// diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs index 8b24015d..14778fc8 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs @@ -234,6 +234,28 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder _device.Api.CmdSetDepthWriteEnable(_commands, enabled); } + /// + /// Slice V6l. Four calls rather than one because Vulkan splits the state + /// that way, and both faces get the same values because the contract does — + /// see for why a two-sided distinction would + /// have no consumer. + /// + public void SetStencil(in GpuStencilState stencil) + { + ThrowIfClosed(); + const StencilFaceFlags BothFaces = StencilFaceFlags.FaceFrontAndBack; + _device.Api.CmdSetStencilOp( + _commands, + BothFaces, + VulkanViewportMapping.ToVulkan(stencil.Fail), + VulkanViewportMapping.ToVulkan(stencil.Pass), + VulkanViewportMapping.ToVulkan(stencil.DepthFail), + VulkanViewportMapping.ToVulkan(stencil.Compare)); + _device.Api.CmdSetStencilCompareMask(_commands, BothFaces, stencil.CompareMask); + _device.Api.CmdSetStencilWriteMask(_commands, BothFaces, stencil.WriteMask); + _device.Api.CmdSetStencilReference(_commands, BothFaces, stencil.Reference); + } + public void DrawIndexed( uint indexCount, uint instanceCount, diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs index 3520ca03..becc067e 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs @@ -162,6 +162,21 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline // ignores it at one sample exactly as the contract says. AlphaToCoverageEnable = description.AlphaToCoverage && description.SampleCount > 1, }; + // Slice V6l: the stencil dimension. Both faces carry the same state + // because the contract does; the values here are the pipeline's + // DEFAULT and are re-established by CmdBindPipelineDefaults, exactly + // as cull mode, front face and depth write already are. + GpuStencilState stencil = description.Stencil; + var stencilOps = new StencilOpState + { + FailOp = VulkanViewportMapping.ToVulkan(stencil.Fail), + PassOp = VulkanViewportMapping.ToVulkan(stencil.Pass), + DepthFailOp = VulkanViewportMapping.ToVulkan(stencil.DepthFail), + CompareOp = VulkanViewportMapping.ToVulkan(stencil.Compare), + CompareMask = stencil.CompareMask, + WriteMask = stencil.WriteMask, + Reference = stencil.Reference, + }; var depthStencil = new PipelineDepthStencilStateCreateInfo { SType = StructureType.PipelineDepthStencilStateCreateInfo, @@ -169,7 +184,9 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline DepthWriteEnable = description.Depth.Write, DepthCompareOp = VulkanViewportMapping.ToVulkan(description.Depth.Compare), DepthBoundsTestEnable = false, - StencilTestEnable = false, + StencilTestEnable = description.StencilTest, + Front = stencilOps, + Back = stencilOps, }; (BlendFactor source, BlendFactor destination) = @@ -198,16 +215,30 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline PAttachments = &attachment, }; - DynamicState* dynamicStates = stackalloc DynamicState[5]; + DynamicState* dynamicStates = stackalloc DynamicState[9]; dynamicStates[0] = DynamicState.Viewport; dynamicStates[1] = DynamicState.Scissor; dynamicStates[2] = DynamicState.CullMode; dynamicStates[3] = DynamicState.FrontFace; dynamicStates[4] = DynamicState.DepthWriteEnable; + uint dynamicStateCount = 5; + // Slice V6l: the four stencil dynamic states are declared ONLY by a + // pipeline that tests stencil. Declaring a dynamic state obliges + // every draw with the pipeline to have set it, so adding these + // unconditionally would make every existing pipeline in the tree + // depend on a call none of them make. + if (description.StencilTest) + { + dynamicStates[5] = DynamicState.StencilOp; + dynamicStates[6] = DynamicState.StencilCompareMask; + dynamicStates[7] = DynamicState.StencilWriteMask; + dynamicStates[8] = DynamicState.StencilReference; + dynamicStateCount = 9; + } var dynamic = new PipelineDynamicStateCreateInfo { SType = StructureType.PipelineDynamicStateCreateInfo, - DynamicStateCount = 5, + DynamicStateCount = dynamicStateCount, PDynamicStates = dynamicStates, }; diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs index 2d1f7464..11bf73ab 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs @@ -159,6 +159,15 @@ internal static class VulkanViewportMapping _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown vertex format."), }; + /// Slice V6l: what a stencil outcome does to the stored value. + internal static StencilOp ToVulkan(GpuStencilOp op) => op switch + { + GpuStencilOp.Keep => StencilOp.Keep, + GpuStencilOp.Zero => StencilOp.Zero, + GpuStencilOp.Replace => StencilOp.Replace, + _ => throw new ArgumentOutOfRangeException(nameof(op), op, "Unknown stencil operation."), + }; + /// The blend factors each retail translucency mode composites with. internal static (BlendFactor Source, BlendFactor Destination) BlendFactorsOf(GpuBlendMode blend) => blend switch { diff --git a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.Rhi.cs b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.Rhi.cs new file mode 100644 index 00000000..48432059 --- /dev/null +++ b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.Rhi.cs @@ -0,0 +1,304 @@ +using System.Collections.Immutable; +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; + +namespace AcDream.App.Rendering; + +/// +/// Campaign V slice V6l: the portal depth mask's RHI submission arm — V4g's +/// remaining half, and the reason the slice grew a stencil dimension. +/// +/// Plan §5.5.16 defect 2: this renderer's two-pass punch (#117) is built +/// on glStencilFunc/glStencilOp/glStencilMask and the pinned +/// carried no stencil state at all, so it +/// stayed raw GL and was invisible to the Vulkan arm. The reviewed amendment +/// puts the ENABLE and the attachment intent in the pipeline +/// () and the per-draw compare, +/// ops, reference and masks on , because +/// core Vulkan 1.3 makes exactly that split dynamic. +/// +/// Three pipelines, not one. Depth COMPARE is not dynamic in the +/// contract (only depth write is), and the punch's two passes differ in it — +/// mark tests LEQUAL and writes no depth, punch tests ALWAYS and +/// writes. The seal is a third: ALWAYS + write, with no stencil at all. +/// All three write no colour, which is what retail's "COLOR-INVISIBLE triangle +/// fan" means. +/// +/// Two other differences from the GL arm. The fan is expanded to a +/// triangle LIST on the CPU, because has no +/// fan and Vulkan's is not portable; the expansion is exact (v0, vi, vi+1) and +/// rasterises the same triangles in the same order. And the clip planes travel +/// in the TerrainClip uniform block at binding 2 rather than as a loose +/// vec4[8] array, because Vulkan GLSL has no default uniform block — the +/// block already has precisely this shape and is already read by +/// terrain_modern.vert and sky.vert. +/// +public sealed partial class PortalDepthMaskRenderer +{ + private readonly IGpuDevice? _device; + private readonly ICurrentGpuFrameSource? _frames; + private readonly IWorldPassScope? _scope; + private IGpuPipeline? _sealPipeline; + private IGpuPipeline? _punchMarkPipeline; + private IGpuPipeline? _punchWritePipeline; + private bool _rhiFrameStarted; + + /// One position per vertex — the only attribute portal_depth.vert reads. + internal static GpuVertexLayout PortalVertexLayout { get; } = GpuVertexLayout.Interleaved( + strideBytes: 3 * sizeof(float), + ImmutableArray.Create( + new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0))); + + /// + /// The stencil reference the mark pass writes and the punch pass gates on. + /// Retail has no equivalent — the whole stencil pass is acdream's + /// z-buffered replacement for retail's painter's-order safety (#117) — so + /// the value is arbitrary and only has to agree with itself. + /// + private const uint PunchStencilReference = 1; + + /// + /// The RHI arm's constructor. No GL context and no inline program: the three + /// pipelines compile portal_depth from the committed SPIR-V, and the + /// per-frame fan vertices come from the frame ring. + /// + internal PortalDepthMaskRenderer( + IGpuDevice device, + ICurrentGpuFrameSource frames, + IWorldPassScope scope) + { + _device = device ?? throw new ArgumentNullException(nameof(device)); + _frames = frames ?? throw new ArgumentNullException(nameof(frames)); + _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + _resources = new ResourceCleanupGroup(); + + try + { + int samples = scope.SampleCount; + // SEAL: retail maxZ2, bit0 clear, data 0x00820e14 — depth ALWAYS at + // the polygon's true projected depth, no stencil. It runs + // immediately after the gated full depth clear, so there is no + // nearer content to stomp. + _sealPipeline = CreatePortalPipeline( + device, + "portal-depth-seal", + GpuCompareOp.Always, + depthWrite: true, + stencilTest: false, + GpuStencilState.Default, + samples); + // PUNCH pass A: mark stencil where the aperture fan passes a LEQUAL + // depth test at its (biased) true depth — i.e. where the aperture is + // actually visible against everything drawn so far. + _punchMarkPipeline = CreatePortalPipeline( + device, + "portal-depth-punch-mark", + GpuCompareOp.LessOrEqual, + depthWrite: false, + stencilTest: true, + GpuStencilState.Default with + { + Compare = GpuCompareOp.Always, + Fail = GpuStencilOp.Keep, + DepthFail = GpuStencilOp.Keep, + Pass = GpuStencilOp.Replace, + Reference = PunchStencilReference, + }, + samples); + // PUNCH pass B: the far-Z write on marked pixels only, zeroing the + // stencil as it goes so the buffer is self-cleaning. + _punchWritePipeline = CreatePortalPipeline( + device, + "portal-depth-punch-write", + GpuCompareOp.Always, + depthWrite: true, + stencilTest: true, + GpuStencilState.Default with + { + Compare = GpuCompareOp.Equal, + Fail = GpuStencilOp.Keep, + DepthFail = GpuStencilOp.Keep, + Pass = GpuStencilOp.Zero, + Reference = PunchStencilReference, + }, + samples); + } + catch + { + DisposeRhiResources(); + throw; + } + } + + private static IGpuPipeline CreatePortalPipeline( + IGpuDevice device, + string name, + GpuCompareOp depthCompare, + bool depthWrite, + bool stencilTest, + GpuStencilState stencil, + int sampleCount) => + device.CreatePipeline(new GpuPipelineDescription + { + Name = name, + Shaders = new GpuShaderSet("portal_depth"), + VertexLayout = PortalVertexLayout, + Topology = GpuPrimitiveTopology.TriangleList, + Blend = GpuBlendMode.None, + Depth = new GpuDepthState(Test: true, Write: depthWrite, depthCompare), + // Portal fans face either way; the GL arm disables culling for the + // same reason. + Cull = GpuCullMode.None, + FrontFace = GpuFrontFace.CounterClockwise, + AlphaToCoverage = false, + // "an alpha-0 fan is no colour" in retail; a colour mask here. + ColorWrite = false, + StencilTest = stencilTest, + Stencil = stencil, + SampleCount = sampleCount, + }); + + private void DrawDepthFanRhi( + ReadOnlySpan worldVerts, + in Matrix4x4 viewProjection, + ReadOnlySpan planes, + bool forceFarZ) + { + if (!_rhiFrameStarted) + throw new InvalidOperationException("BeginFrame must be called before drawing portal depth masks."); + + int n = Math.Min(worldVerts.Length, MaxFanVerts); + int planeCount = Math.Min(planes.Length, ClipFrame.MaxPlanes); + IGpuPassEncoder encoder = _scope!.RequireEncoder(); + IGpuFrame frame = _frames!.CurrentFrame + ?? throw new InvalidOperationException( + "PortalDepthMaskRenderer requires an open IGpuFrame (see GpuDeviceFrameLifetime)."); + + // The fan, expanded exactly: triangle i is (v0, v[i+1], v[i+2]). + int triangleCount = n - 2; + int vertexCount = triangleCount * 3; + GpuRingAllocation vertices = frame.AllocateRing( + vertexCount * 3 * sizeof(float), + GpuRingUsage.Vertex); + Span positions = vertices.AsSpan(); + for (int triangle = 0; triangle < triangleCount; triangle++) + { + WritePosition(positions, triangle * 9, worldVerts[0]); + WritePosition(positions, triangle * 9 + 3, worldVerts[triangle + 1]); + WritePosition(positions, triangle * 9 + 6, worldVerts[triangle + 2]); + } + + // The TerrainClip std140 block: an int count padded to 16 bytes, then + // eight clip-space half-planes. Every unused plane stays zero, which the + // shader never reads because it compares the index against the count. + GpuRingAllocation clip = frame.AllocateRing( + ClipFrame.TerrainUboBytes, + GpuRingUsage.Uniform); + clip.Data.Clear(); + MemoryMarshal.Write(clip.Data, in planeCount); + Span clipPlanes = MemoryMarshal.Cast( + clip.Data[ClipFrame.CellClipPlanesOffset..]); + for (int i = 0; i < planeCount; i++) + clipPlanes[i] = planes[i]; + + if (!forceFarZ) + { + RecordPortalPass( + encoder, + _sealPipeline!, + clip, + vertices, + vertexCount, + in viewProjection, + renderPass: 0, + depthBias: 0f); + return; + } + + RecordPortalPass( + encoder, + _punchMarkPipeline!, + clip, + vertices, + vertexCount, + in viewProjection, + renderPass: 0, + depthBias: PunchMarkDepthBias); + RecordPortalPass( + encoder, + _punchWritePipeline!, + clip, + vertices, + vertexCount, + in viewProjection, + renderPass: 1, + depthBias: 0f); + } + + private static void RecordPortalPass( + IGpuPassEncoder encoder, + IGpuPipeline pipeline, + in GpuRingAllocation clip, + in GpuRingAllocation vertices, + int vertexCount, + in Matrix4x4 viewProjection, + int renderPass, + float depthBias) + { + encoder.BindPipeline(pipeline); + encoder.SetPushConstants(new GpuPushConstants + { + ViewProjection = viewProjection, + DrawIdOffset = 0, + LightingMode = 0, + // portal_depth.vert's "render pass" IS the seal/punch selector — + // the GL arm's uForceFarZ, rehomed onto the shared block. + RenderPass = renderPass, + LightDebug = 0, + TextureIndexA = 0, + TextureIndexB = 0, + ParamA = depthBias, + ParamB = PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters, + }); + encoder.BindUniformBuffer( + ClipFrame.TerrainClipUboBinding, + clip.Buffer, + clip.OffsetBytes, + (uint)ClipFrame.TerrainUboBytes); + encoder.BindVertexBuffer(0, vertices.Buffer, vertices.OffsetBytes); + encoder.Draw((uint)vertexCount, 1, 0, 0); + } + + private static void WritePosition(Span destination, int offset, Vector3 position) + { + destination[offset] = position.X; + destination[offset + 1] = position.Y; + destination[offset + 2] = position.Z; + } + + private void DisposeRhiResources() + { + List? failures = null; + void Attempt(Action action) + { + try { action(); } + catch (Exception error) { (failures ??= []).Add(error); } + } + + Attempt(() => _sealPipeline?.Dispose()); + _sealPipeline = null; + Attempt(() => _punchMarkPipeline?.Dispose()); + _punchMarkPipeline = null; + Attempt(() => _punchWritePipeline?.Dispose()); + _punchWritePipeline = null; + _rhiFrameStarted = false; + + if (failures is not null) + { + throw new AggregateException( + "The portal depth mask's RHI resources did not fully release.", + failures); + } + } +} diff --git a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs index 8de3bdaa..7f3e0889 100644 --- a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs +++ b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs @@ -46,9 +46,20 @@ namespace AcDream.App.Rendering; /// sets everything it depends on, restores the frame-global convention on /// exit, no early-outs between set and restore. /// -public sealed class PortalDepthMaskRenderer : IDisposable +public sealed partial class PortalDepthMaskRenderer : IDisposable { - private const string VertSrc = @"#version 430 core + /// + /// The GL arm's inline program. Campaign V slice V6l added a SECOND arm that + /// compiles Rendering/Shaders/portal_depth.{vert,frag} — the same body, + /// with its loose uniforms rehomed onto the shared push block and the clip + /// planes onto the TerrainClip UBO — because a Vulkan pipeline needs a + /// named, SPIR-V-compiled pair. This string stays because the GL arm keeps + /// its raw path through to V10 (plan §5.5.6) and because rehoming its clip + /// planes onto UBO binding 2 would clobber the terrain clip block that + /// ClipFrame binds there globally on GL. The two are kept in step by + /// PortalDepthShaderParityTests, and this one is deleted at V11. + /// + internal const string VertSrc = @"#version 430 core layout(location = 0) in vec3 aPos; uniform mat4 uViewProjection; uniform int uPlaneCount; @@ -76,11 +87,16 @@ void main() gl_Position = clipPos; }"; - private const string FragSrc = @"#version 430 core + internal const string FragSrc = @"#version 430 core void main() { } // depth-only: color writes are masked off by the caller state "; - private readonly GL _gl; + private readonly GL? _glContext; + private GL _gl => _glContext + ?? throw new InvalidOperationException( + "PortalDepthMaskRenderer's GL arm was reached on a backend with no GL context " + + "(campaign plan slice V6l; the RHI arm lives in PortalDepthMaskRenderer.Rhi.cs)."); + private readonly uint _program; private readonly int _locViewProjection; private readonly int _locPlaneCount; @@ -101,15 +117,15 @@ void main() { } // depth-only: color writes are masked off by the caller state public int UsedVertices; } - private readonly FrameBufferSet[] _frameBuffers = new FrameBufferSet[3]; + private readonly FrameBufferSet?[] _frameBuffers = new FrameBufferSet[3]; private FrameBufferSet? _activeFrameBuffer; internal long DynamicBufferCapacityBytes => - _frameBuffers.Sum(set => (long)set.CapacityBytes); + _frameBuffers.Sum(set => (long)(set?.CapacityBytes ?? 0)); public PortalDepthMaskRenderer(GL gl) { - _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _glContext = gl ?? throw new ArgumentNullException(nameof(gl)); var resources = new ResourceCleanupGroup(); try { @@ -167,8 +183,14 @@ void main() { } // depth-only: color writes are masked off by the caller state { if ((uint)frameSlot >= (uint)_frameBuffers.Length) throw new ArgumentOutOfRangeException(nameof(frameSlot)); + // Slice V6l: the RHI arm owns no per-flight VBO ring — every frame ring + // allocation is already distinct memory that lives until the frame + // retires — so all this edge carries there is the started latch. + _rhiFrameStarted = true; + if (_glContext is null) + return; _activeFrameBuffer = _frameBuffers[frameSlot]; - _activeFrameBuffer.UsedVertices = 0; + _activeFrameBuffer!.UsedVertices = 0; } private FrameBufferSet CreateFrameBufferSet( @@ -281,6 +303,12 @@ void main() { } // depth-only: color writes are masked off by the caller state { if (worldVerts.Length < 3) return; + if (_glContext is null) + { + DrawDepthFanRhi(worldVerts, in viewProjection, planes, forceFarZ); + return; + } + FrameBufferSet frameBuffer = _activeFrameBuffer ?? throw new InvalidOperationException("BeginFrame must be called before drawing portal depth masks."); int n = Math.Min(worldVerts.Length, MaxFanVerts); @@ -406,6 +434,12 @@ void main() { } // depth-only: color writes are masked off by the caller state public void Dispose() { + if (_glContext is null) + { + DisposeRhiResources(); + return; + } + _resources.RetryCleanup(); } } diff --git a/src/AcDream.App/Rendering/Shaders/portal_depth.frag b/src/AcDream.App/Rendering/Shaders/portal_depth.frag new file mode 100644 index 00000000..606b02da --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/portal_depth.frag @@ -0,0 +1,12 @@ +#version 430 core + +// Campaign V slice V6l. Depth-only: the pipeline masks colour off +// (GpuPipelineDescription.ColorWrite = false), which is what "COLOR-INVISIBLE +// triangle fan" means in retail's DrawPortalPolyInternal. +// +// Vulkan needs a fragment stage even when nothing is written — a graphics +// pipeline with no fragment shader requires VK_EXT_shader_object or a +// rasterizer-discard pipeline, neither of which is what this draw is. An empty +// main() with the colour mask off is the same thing the GL arm has always done. + +void main() { } diff --git a/src/AcDream.App/Rendering/Shaders/portal_depth.vert b/src/AcDream.App/Rendering/Shaders/portal_depth.vert new file mode 100644 index 00000000..1183c01e --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/portal_depth.vert @@ -0,0 +1,70 @@ +#version 430 core + +// Campaign V slice V6l: retail's invisible portal depth write, moved out of +// PortalDepthMaskRenderer's inline GLSL string and into the shader tree so both +// backends compile it from one source. The BODY is the one that renderer has +// carried since BR-2 and #117; only how its inputs arrive changed. +// +// Three input moves, each because Vulkan GLSL has no default uniform block: +// +// uPlaneCount / uPlanes[8] -> the TerrainClip UBO at binding=2, which is +// already exactly this shape (int count + 8 +// clip-space half-planes) and is already +// declared identically by terrain_modern.vert +// and sky.vert. Retail clips the portal polygon +// on the CPU against the installed view +// (polyClipFinish); we apply the SAME view +// region through gl_ClipDistance, so the depth +// write lands only inside the slice region. +// uForceFarZ -> uRenderPass, the shared push block's pass +// selector. This shader's two passes ARE seal +// (retail maxZ2, true projected depth) and punch +// (retail maxZ1, far-plane z). +// uDepthBias / EyeCapN -> uParamA / uParamB. + +layout(location = 0) in vec3 aPos; + +// Loose uniforms for the GL dialect. Under Vulkan the compiler drops these +// declarations and the injected preamble #defines each name onto its member of +// the shared 96-byte push block (tools/ShaderCompiler/VulkanGlslPreamble.cs). +uniform mat4 uViewProjection; +uniform int uRenderPass; // 0 = seal (retail maxZ2), 1 = punch (retail maxZ1) +uniform float uParamA; // #117 mark-pass NDC bias toward the viewer +uniform float uParamB; // #129 eye-span cap x near plane + +layout(std140, ACDREAM_UBO_SET binding = 2) uniform TerrainClip { + int uTerrainClipCount; + vec4 uTerrainClipPlanes[8]; +}; + +// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal +// (mirrors terrain_modern.vert and sky.vert). Sized 8 to match the +// ClipFrame.MaxPlanes budget and GL's guaranteed GL_MAX_CLIP_DISTANCES >= 8. +out gl_PerVertex { + vec4 gl_Position; + float gl_ClipDistance[8]; +}; + +void main() +{ + vec4 clipPos = uViewProjection * vec4(aPos, 1.0); + for (int i = 0; i < 8; i++) + gl_ClipDistance[i] = (i < uTerrainClipCount) ? dot(uTerrainClipPlanes[i], clipPos) : 1.0; + + if (uRenderPass == 1) + { + clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail) + } + else if (uParamA > 0.0) + { + // #117 mark-pass bias, #129 eye-space cap. clipPos.w = eye depth d; + // an NDC bias b spans ~b*d*d/near meters of eye depth, so the + // constant-NDC form alone reached METERS at distance (door-shaped + // leaks through hills/houses). Keep in sync with + // PortalDepthMaskRenderer.MarkBiasNdc. + float biasNdc = min(uParamA, uParamB / max(clipPos.w * clipPos.w, 1e-6)); + clipPos.z -= biasNdc * clipPos.w; + } + + gl_Position = clipPos; +} diff --git a/src/AcDream.App/Rendering/Shaders/spv/portal_depth.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/portal_depth.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..86b5c126ec9284c4b19d605e1ef5ad02e0c22779 GIT binary patch literal 152 zcmZQ(Qf6mhV`SiF;ALQAfB-=TCI&_zlN%@kqTPLhee{Y;QuItr4L~aR7??p6SdO28 zm4OAw2I1Vq%sh~|08k#pX9r?opjk{nS`jD)(gk9h0rh}rkQxvGiT?nKumb77KvOM% F7yyFJ2$%o> literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/portal_depth.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/portal_depth.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..87457f0ccb231231212a5ef47f42dec29974f17c GIT binary patch literal 2360 zcmZ9MNl%nP5Qe{Dl|>O*RU`vMKoO$ImY^(dprRs>h>98`F+s%(BzOZ4Dp3!T_$$15 zHSuS;CN9r2(>;E%lXt4>?W*ePs{W?9sG~e7%Oq9F?qny)uiB(2$)GB;$f~3)XHU({ z&-AUWFZT@$9@leEQmn}B)g%=;y@cP@`zud1YCt3C1p{C@DdCj-D$UuY1-m2g)5$-s z9ByyQ&zkg`NO47hJD1|DO26q8XI*kLDb5Z{Q;1*Jxb?CR4;&ugY2{}a{yqC31 zaYygm3S8c&C$G}S{ED3Skt+t~&@bjVfo?7F{{84K>u{MvF7`w}bI8RU_Pjm#SeJHN z*ssG&zcyk#U%e5{Gqa!xfA6hr@0$6oM9OJ5@N4Z^U=7;U!A5X@UsRHd(Ju}Cgt_zQ zmGG95+zT>hlw8`rA752ZILkY_ve>mq_pHfU>yhU2F77uVtxMi~+RjDJeEK$nB-t8| z`8%JkolsIo|VP;M~82UIC1ceVIr9{b#e#7vFu% zcN1Ozm`~|9;EM5&&_55q95nw1y5}1|#GAEl-;YcFJB;7qB)`sdA8_u@`ZS)E$T}de z?L3=+v%F4>=kE_3qQD&pTqeaG44gI0Qjs;a0OR#{-#4*CUiS_G&(`M$b!L&)tNen8 zJ=+G>n*Tq`f1e+*y5PFQekbxPwYMWXfa@o=`)xqpyz2JBnGONx9Q)u|!-0?aN3cC# z{u|vqg>;$Qy-{!$m|K04ZuvgOfw_H$#@ko#I|1B#PYw3<4444+!TX!vT;@#M^7g}+ zx4<~hvzPjMuJ7;y@LP!Ij#0CmG3K*3=2vb}gLj?=)@V$62JP7P%J+L2_-1_9-o>~X zApZd$XK^KP=CN1TkS=@W-c@h|c)ogkTXX2%zZkp-eOW-4_dA*6Zuagukk_{eSgU84 z&%D;^w{r_v>msmLdv+Vhe+sqU3EUFWTJIrU*6Q9}AZKp%nEL^`xjRA3y^Jm&@ADA- z|NE@K$&2bR$H(Xy&<))8o8Auhe}ZmaeWL#=y8efO_t7VwxrQG5vKj9D4$hcr{&CBY z>%jPa;GVS@7yJ7PU7wi8{>C0CeSgm3P4Kbj#@L_qESyR7eU0wioVRi2v6majXP_7O O{rOI{_5VecBj7Kk-;50a literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index 6ca80364..2d4faaa9 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -83,6 +83,22 @@ } ] }, + { + "name": "portal_depth", + "vulkanReady": true, + "stages": [ + { + "stage": "vert", + "sourceSha256": "6214fc04936d92320594acb722e63f1cd1f7106dd4d0af60ed985abc2a50c4a9", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "711ba97649f27f890f0cad5f355552fd8f707cbe801af4ac14afbee1b8f800c9", + "compiled": true + } + ] + }, { "name": "sky", "vulkanReady": true, diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlRenderStateCacheTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlRenderStateCacheTests.cs index d4f32c6d..c6085a0e 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlRenderStateCacheTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlRenderStateCacheTests.cs @@ -14,7 +14,11 @@ public sealed class GlRenderStateCacheTests GpuCullMode.Back, GpuFrontFace.CounterClockwise, AlphaToCoverage: false, - ColorWrite: true); + ColorWrite: true, + // Slice V6l: the stencil dimension. Off is what every pipeline in the + // tree but the portal depth mask asks for. + StencilTest: false, + GpuStencilState.Default); [Fact] public void FirstApplyReportsEveryDimensionChanged() @@ -55,6 +59,34 @@ public sealed class GlRenderStateCacheTests Assert.False(changes.FrontFace); Assert.False(changes.AlphaToCoverage); Assert.False(changes.ColorWrite); + Assert.False(changes.StencilTest); + Assert.False(changes.Stencil); + } + + [Fact] + public void ChangingOnlyTheStencilValuesReportsOnlyThatDimension() + { + // Slice V6l. #117's portal punch changes compare, ops, reference and + // masks between its mark pass and its punch pass while the test stays + // enabled, so the two stencil dimensions have to move independently. + var cache = new GlRenderStateCache(); + cache.Apply(Default() with { StencilTest = true }); + + GlRenderStateChanges changes = cache.Apply(Default() with + { + StencilTest = true, + Stencil = GpuStencilState.Default with + { + Compare = GpuCompareOp.Equal, + Pass = GpuStencilOp.Zero, + Reference = 1, + }, + }); + + Assert.True(changes.Stencil); + Assert.False(changes.StencilTest); + Assert.False(changes.DepthTest); + Assert.False(changes.ColorWrite); } [Fact] diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs index 303c0307..e39a7d0a 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs @@ -190,6 +190,72 @@ public sealed class GpuContractTests Assert.Equal(GpuTextureFormat.Rgba8UnormRenderTarget, description.ColorFormat); } + [Fact] + public void APipelineCanDeclareThatItUsesTheStencilAspect() + { + // Campaign V slice V6l. Issue #117's portal punch is a two-pass + // stencil operation — mark where the aperture wins a depth test, then + // write the far-Z punch only on marked pixels and zero the stencil as it + // goes — and the V0 contract carried no stencil state at all, so + // PortalDepthMaskRenderer stayed raw GL and V4g's "stencil/depth-mask + // pipelines" row could not be written (plan §5.5.16 defect 2). + var description = new GpuPipelineDescription + { + Name = "contract-stencil", + Shaders = new GpuShaderSet("portal_depth"), + VertexLayout = GpuVertexLayout.None, + }; + + // Off by default, so no pipeline written before this slice changed. + Assert.False(description.StencilTest); + Assert.Equal(GpuStencilState.Default, description.Stencil); + Assert.Equal(GpuCompareOp.Always, GpuStencilState.Default.Compare); + Assert.Equal(GpuStencilOp.Keep, GpuStencilState.Default.Pass); + + GpuPipelineDescription punch = description with + { + StencilTest = true, + Stencil = GpuStencilState.Default with + { + Compare = GpuCompareOp.Equal, + Pass = GpuStencilOp.Zero, + Reference = 1, + }, + }; + Assert.True(punch.StencilTest); + Assert.Equal(GpuCompareOp.Equal, punch.Stencil.Compare); + Assert.Equal(GpuStencilOp.Zero, punch.Stencil.Pass); + Assert.False(description.StencilTest); + } + + [Fact] + public void EveryStencilOperationThePortalPunchNeedsIsRepresentable() + { + // Replace marks, Equal gates, Zero self-cleans. Nothing else in the tree + // touches stencil, and a fourth value would be a facility with no + // consumer rather than completeness. + Assert.Equal(3, Enum.GetValues().Length); + Assert.Contains(GpuStencilOp.Keep, Enum.GetValues()); + Assert.Contains(GpuStencilOp.Zero, Enum.GetValues()); + Assert.Contains(GpuStencilOp.Replace, Enum.GetValues()); + } + + [Fact] + public void TheDepthStencilAttachmentFormatCarriesAStencilAspect() + { + // The punch has nowhere to mark without one. The V5 capability gate + // prefers D32_SFLOAT_S8_UINT and falls back to D24_UNORM_S8_UINT rather + // than taking a depth-only format for exactly this reason, and the + // backbuffer pass clears both aspects through one ClearDepthStencil. + GpuPassDescription pass = GpuPassDescription.BackbufferClear( + "world", + Vector4.Zero, + sampleCount: 4); + Assert.Equal(0u, pass.Depth!.Value.ClearStencil); + Assert.Equal(GpuLoadOp.Clear, pass.Depth!.Value.Load); + Assert.Equal(GpuTextureFormat.Depth24Stencil8, GpuTextureFormat.Depth24Stencil8); + } + [Fact] public void UniformBindingsDoNotCollide() { diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs index 444ae78c..ff582e45 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs @@ -31,6 +31,7 @@ internal sealed record GpuRecordedUniformBind(uint Binding, string BufferName, u : GpuRecordedCall; internal sealed record GpuRecordedVertexBind(uint Binding, string BufferName, uint OffsetBytes) : GpuRecordedCall; +internal sealed record GpuRecordedStencil(GpuStencilState Stencil) : GpuRecordedCall; internal sealed record GpuRecordedIndexBind(string BufferName, uint OffsetBytes, GpuIndexType IndexType) : GpuRecordedCall; @@ -374,6 +375,8 @@ internal sealed class RecordingGpuPassEncoder(RecordingGpuDevice device, GpuPass public void SetFrontFace(GpuFrontFace frontFace) => device.Record(new GpuRecordedFrontFace(frontFace)); + public void SetStencil(in GpuStencilState stencil) => device.Record(new GpuRecordedStencil(stencil)); + public void SetDepthWrite(bool enabled) => device.Record(new GpuRecordedDepthWrite(enabled)); public void DrawIndexed(uint indexCount, uint instanceCount, uint firstIndex, int vertexOffset, uint firstInstance) => diff --git a/tests/AcDream.App.Tests/Rendering/PortalDepthShaderParityTests.cs b/tests/AcDream.App.Tests/Rendering/PortalDepthShaderParityTests.cs new file mode 100644 index 00000000..53a5b942 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/PortalDepthShaderParityTests.cs @@ -0,0 +1,79 @@ +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Campaign V slice V6l: the portal depth mask is the one renderer whose two arms +/// do NOT share a shader source, and this is the tripwire that stops them +/// drifting. +/// +/// Every other RHI arm in the campaign compiles the same GLSL file the GL +/// arm does. This one cannot: its clip planes would have to travel in the +/// TerrainClip uniform block at binding 2, and on GL that binding is held +/// globally by ClipFrame for terrain — a portal draw that rebound it would +/// leave every later terrain draw in the frame reading the wrong region. So the +/// GL arm keeps its inline program, the RHI arm compiles +/// Rendering/Shaders/portal_depth.vert, and the numbers that decide where +/// depth lands are asserted to appear in both. +/// +/// Deleted with the GL arm at V11, when the inline string goes. +/// +public class PortalDepthShaderParityTests +{ + private static string PortalDepthVertexSource() => + File.ReadAllText(Path.Combine( + AppContext.BaseDirectory, + "Rendering", + "Shaders", + "portal_depth.vert")); + + [Fact] + public void BothArmsPunchWithRetailsFarZConstant() + { + // 0.99999988 is retail's own value at the tail of + // D3DPolyRender::DrawPortalPolyInternal (0x0059bc90). It decides where + // the punched depth lands, so the two arms agreeing on it is the whole + // point of this file. + const string FarZ = "0.99999988"; + Assert.Contains(FarZ, PortalDepthMaskRenderer.VertSrc, StringComparison.Ordinal); + Assert.Contains(FarZ, PortalDepthVertexSource(), StringComparison.Ordinal); + } + + [Fact] + public void BothArmsApplyTheSameEyeCappedMarkBias() + { + // #129's capped bias: min(bias, cap / max(w*w, 1e-6)), then z -= bias*w. + // Spelled with different identifier names on the two arms — the RHI arm + // reads the shared push block's uParamA/uParamB — so the assertion is on + // the SHAPE that decides the result rather than on the text. + string rhi = PortalDepthVertexSource(); + Assert.Contains("max(clipPos.w * clipPos.w, 1e-6)", PortalDepthMaskRenderer.VertSrc, StringComparison.Ordinal); + Assert.Contains("max(clipPos.w * clipPos.w, 1e-6)", rhi, StringComparison.Ordinal); + Assert.Contains("clipPos.z -= biasNdc * clipPos.w", PortalDepthMaskRenderer.VertSrc, StringComparison.Ordinal); + Assert.Contains("clipPos.z -= biasNdc * clipPos.w", rhi, StringComparison.Ordinal); + } + + [Fact] + public void BothArmsClipAgainstEightHalfPlanes() + { + // The plane budget is ClipFrame.MaxPlanes and GL's guaranteed + // GL_MAX_CLIP_DISTANCES; a shader that looped to a different number + // would clip a differently-shaped region than the one the CPU published. + Assert.Equal(8, ClipFrame.MaxPlanes); + Assert.Contains("for (int i = 0; i < 8; i++)", PortalDepthMaskRenderer.VertSrc, StringComparison.Ordinal); + Assert.Contains("for (int i = 0; i < 8; i++)", PortalDepthVertexSource(), StringComparison.Ordinal); + } + + [Fact] + public void TheRhiArmReadsTheClipPlanesFromTheSharedTerrainClipBlock() + { + string rhi = PortalDepthVertexSource(); + Assert.Contains("uniform TerrainClip", rhi, StringComparison.Ordinal); + Assert.Contains("binding = 2", rhi, StringComparison.Ordinal); + // And the block is the one ClipFrame already packs for terrain and sky: + // an int count padded to 16 bytes, then eight vec4 planes. + Assert.Equal(144, ClipFrame.TerrainUboBytes); + Assert.Equal(2u, ClipFrame.TerrainClipUboBinding); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/TextRendererFailureSafetyTests.cs b/tests/AcDream.App.Tests/Rendering/TextRendererFailureSafetyTests.cs index c4fc61cf..4e87a29d 100644 --- a/tests/AcDream.App.Tests/Rendering/TextRendererFailureSafetyTests.cs +++ b/tests/AcDream.App.Tests/Rendering/TextRendererFailureSafetyTests.cs @@ -130,7 +130,19 @@ public sealed class TextRendererFailureSafetyTests uint ArrayBuffer, TextureUnit ActiveUnit, uint Texture0, - uint Texture2); + uint Texture2, + bool StencilTest, + StencilFunction StencilFunc, + int StencilReference, + uint StencilValueMask, + uint StencilWriteMask, + Silk.NET.OpenGL.StencilOp StencilFail, + Silk.NET.OpenGL.StencilOp StencilDepthFail, + Silk.NET.OpenGL.StencilOp StencilPass, + bool ColorMaskRed, + bool ColorMaskGreen, + bool ColorMaskBlue, + bool ColorMaskAlpha); private sealed class RecordingGlState : IGlAmbientStateApi { @@ -150,6 +162,17 @@ public sealed class TextRendererFailureSafetyTests public TextureUnit ActiveUnit { get; set; } public Dictionary TextureBindings { get; } = []; + // Slice V6l added the stencil and colour-mask dimensions to the ambient + // save/restore, for the portal depth mask's sake. GL's own defaults. + public StencilFunction StencilFuncValue { get; set; } = StencilFunction.Always; + public int StencilReference { get; set; } + public uint StencilValueMask { get; set; } = 0xFFFFFFFFu; + public uint StencilWriteMaskValue { get; set; } = 0xFFFFFFFFu; + public Silk.NET.OpenGL.StencilOp StencilFailOp { get; set; } = Silk.NET.OpenGL.StencilOp.Keep; + public Silk.NET.OpenGL.StencilOp StencilDepthFailOp { get; set; } = Silk.NET.OpenGL.StencilOp.Keep; + public Silk.NET.OpenGL.StencilOp StencilPassOp { get; set; } = Silk.NET.OpenGL.StencilOp.Keep; + public bool[] ColorMaskValue { get; } = [true, true, true, true]; + public StateSnapshot Capture() => new( IsEnabled(EnableCap.DepthTest), IsEnabled(EnableCap.Blend), @@ -169,7 +192,19 @@ public sealed class TextRendererFailureSafetyTests ArrayBuffer, ActiveUnit, TextureBindings.GetValueOrDefault(TextureUnit.Texture0), - TextureBindings.GetValueOrDefault(TextureUnit.Texture2)); + TextureBindings.GetValueOrDefault(TextureUnit.Texture2), + IsEnabled(EnableCap.StencilTest), + StencilFuncValue, + StencilReference, + StencilValueMask, + StencilWriteMaskValue, + StencilFailOp, + StencilDepthFailOp, + StencilPassOp, + ColorMaskValue[0], + ColorMaskValue[1], + ColorMaskValue[2], + ColorMaskValue[3]); public bool IsEnabled(EnableCap capability) => _capabilities.GetValueOrDefault(capability); @@ -189,9 +224,45 @@ public sealed class TextRendererFailureSafetyTests GetPName.ActiveTexture => (int)ActiveUnit, GetPName.TextureBinding2D => (int)TextureBindings.GetValueOrDefault(ActiveUnit), + GetPName.StencilFunc => (int)StencilFuncValue, + GetPName.StencilRef => StencilReference, + GetPName.StencilValueMask => (int)StencilValueMask, + GetPName.StencilWritemask => (int)StencilWriteMaskValue, + GetPName.StencilFail => (int)StencilFailOp, + GetPName.StencilPassDepthFail => (int)StencilDepthFailOp, + GetPName.StencilPassDepthPass => (int)StencilPassOp, _ => throw new ArgumentOutOfRangeException(nameof(parameter)), }; + public bool[] GetColorMask() => (bool[])ColorMaskValue.Clone(); + + public void StencilFunc(StencilFunction function, int reference, uint mask) + { + StencilFuncValue = function; + StencilReference = reference; + StencilValueMask = mask; + } + + public void StencilOp( + Silk.NET.OpenGL.StencilOp fail, + Silk.NET.OpenGL.StencilOp depthFail, + Silk.NET.OpenGL.StencilOp pass) + { + StencilFailOp = fail; + StencilDepthFailOp = depthFail; + StencilPassOp = pass; + } + + public void StencilMask(uint mask) => StencilWriteMaskValue = mask; + + public void ColorMask(bool red, bool green, bool blue, bool alpha) + { + ColorMaskValue[0] = red; + ColorMaskValue[1] = green; + ColorMaskValue[2] = blue; + ColorMaskValue[3] = alpha; + } + public bool GetBoolean(GetPName parameter) => parameter == GetPName.DepthWritemask ? DepthWrite