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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 17:36:45 +02:00
parent b1ad1d481b
commit eced67d038
25 changed files with 1052 additions and 24 deletions

View file

@ -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;

View file

@ -22,6 +22,9 @@ internal interface IGlAmbientStateApi
bool GetBoolean(GetPName parameter);
/// <summary>The four colour-mask channels, in RGBA order.</summary>
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);
}
}

View file

@ -39,6 +39,28 @@ internal static class GlEnumMapping
_ => throw new NotSupportedException($"No GL vertex attribute shape for {format}."),
};
/// <summary>Slice V6l: the stencil comparison, which shares GL's depth-function enum values.</summary>
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}."),
};
/// <summary>Slice V6l: what a stencil outcome does to the stored value.</summary>
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,

View file

@ -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");
}

View file

@ -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();

View file

@ -15,7 +15,9 @@ internal readonly record struct GlRenderStateSnapshot(
GpuCullMode Cull,
GpuFrontFace FrontFace,
bool AlphaToCoverage,
bool ColorWrite);
bool ColorWrite,
bool StencilTest,
GpuStencilState Stencil);
/// <summary>Which GL state calls are needed to move from the previous snapshot to the new one.</summary>
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;
/// <summary>Every dimension reported changed — used for the first apply after a reset.</summary>
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);
}
/// <summary>
@ -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);
}
/// <summary>Discards the cached baseline — the next <see cref="Apply"/> reports every dimension changed.</summary>

View file

@ -152,6 +152,29 @@ internal enum GpuCullMode
Front,
}
/// <summary>
/// What a stencil test does to the stencil buffer at one of its three outcomes.
///
/// <para>Added at slice V6l with the stencil dimension. These three are exactly
/// what <c>PortalDepthMaskRenderer</c>'s two-pass punch (#117) uses — mark with
/// <c>Replace</c>, gate on <c>Equal</c>, self-clean with <c>Zero</c> — 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
/// <c>PortalDepthMaskRenderer</c>.</para>
/// </summary>
internal enum GpuStencilOp
{
/// <summary>Leave the stored value alone.</summary>
Keep,
/// <summary>Store zero.</summary>
Zero,
/// <summary>Store the reference value.</summary>
Replace,
}
/// <summary>
/// Triangle winding treated as front-facing. Both backends receive the SAME value
/// from renderers; the Vulkan backend inverts it internally because it renders

View file

@ -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);
}
/// <summary>
/// The per-draw half of stencil state: the comparison, what happens at each of
/// its three outcomes, and the reference and masks it uses.
///
/// <para>Added at slice V6l. Core Vulkan 1.3 makes ALL of these dynamic
/// (<c>VK_DYNAMIC_STATE_STENCIL_OP</c>, <c>_COMPARE_MASK</c>, <c>_WRITE_MASK</c>,
/// <c>_REFERENCE</c>), 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 <see cref="IGpuPassEncoder.SetStencil"/> 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
/// <see cref="GpuPipelineDescription.StencilTest"/>, because that is also the
/// attachment intent.</para>
///
/// <para>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.</para>
/// </summary>
internal readonly record struct GpuStencilState(
GpuCompareOp Compare,
GpuStencilOp Fail,
GpuStencilOp DepthFail,
GpuStencilOp Pass,
uint Reference,
uint CompareMask,
uint WriteMask)
{
/// <summary>GL's and Vulkan's own defaults: always pass, never write.</summary>
public static GpuStencilState Default { get; } = new(
GpuCompareOp.Always,
GpuStencilOp.Keep,
GpuStencilOp.Keep,
GpuStencilOp.Keep,
Reference: 0,
CompareMask: 0xFF,
WriteMask: 0xFF);
}
/// <summary>
/// Everything a draw needs beyond its buffers: the shader pair and all fixed
/// state. Vulkan bakes this into one <c>VkPipeline</c> at startup, which is why
@ -237,6 +275,32 @@ internal sealed record GpuPipelineDescription
/// <summary>Whether the pipeline writes colour at all. False for depth/stencil-only prepasses.</summary>
public bool ColorWrite { get; init; } = true;
/// <summary>
/// Whether this pipeline uses the stencil aspect at all.
///
/// <para>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 <c>PortalDepthMaskRenderer</c> stayed raw GL
/// and V4g's "stencil/depth-mask pipelines" row could not be written (plan
/// §5.5.16 defect 2).</para>
///
/// <para>This is the ENABLE and the attachment intent together, which is why
/// it is baked while everything in <see cref="Stencil"/> is dynamic. Vulkan
/// makes <c>stencilTestEnable</c> 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.
/// </para>
/// </summary>
public bool StencilTest { get; init; }
/// <summary>
/// Default stencil compare/op/reference/mask, re-established by
/// <c>BindPipeline</c> and overridable per draw through
/// <see cref="IGpuPassEncoder.SetStencil"/>. Ignored entirely when
/// <see cref="StencilTest"/> is false.
/// </summary>
public GpuStencilState Stencil { get; init; } = GpuStencilState.Default;
/// <summary>
/// Format of the colour attachment this pipeline renders into.
///

View file

@ -63,6 +63,18 @@ internal interface IGpuPassEncoder : IDisposable
/// <summary>Dynamic depth-write override — how the translucent pass stops occluding later draws.</summary>
void SetDepthWrite(bool enabled);
/// <summary>
/// Dynamic stencil override: compare, the three outcome ops, reference and
/// both masks. Meaningful only inside a pipeline whose
/// <see cref="GpuPipelineDescription.StencilTest"/> is set.
///
/// <para>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.</para>
/// </summary>
void SetStencil(in GpuStencilState stencil);
/// <summary>Draws indexed geometry directly, without an indirect buffer.</summary>
void DrawIndexed(uint indexCount, uint instanceCount, uint firstIndex, int vertexOffset, uint firstInstance);

View file

@ -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);
}
/// <summary>

View file

@ -234,6 +234,28 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
_device.Api.CmdSetDepthWriteEnable(_commands, enabled);
}
/// <summary>
/// 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 <see cref="GpuStencilState"/> for why a two-sided distinction would
/// have no consumer.
/// </summary>
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,

View file

@ -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,
};

View file

@ -159,6 +159,15 @@ internal static class VulkanViewportMapping
_ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown vertex format."),
};
/// <summary>Slice V6l: what a stencil outcome does to the stored value.</summary>
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."),
};
/// <summary>The blend factors each retail translucency mode composites with.</summary>
internal static (BlendFactor Source, BlendFactor Destination) BlendFactorsOf(GpuBlendMode blend) => blend switch
{

View file

@ -0,0 +1,304 @@
using System.Collections.Immutable;
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Rendering;
/// <summary>
/// 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.
///
/// <para>Plan §5.5.16 defect 2: this renderer's two-pass punch (#117) is built
/// on <c>glStencilFunc</c>/<c>glStencilOp</c>/<c>glStencilMask</c> and the pinned
/// <see cref="GpuPipelineDescription"/> 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
/// (<see cref="GpuPipelineDescription.StencilTest"/>) and the per-draw compare,
/// ops, reference and masks on <see cref="IGpuPassEncoder.SetStencil"/>, because
/// core Vulkan 1.3 makes exactly that split dynamic.</para>
///
/// <para><b>Three pipelines, not one.</b> Depth COMPARE is not dynamic in the
/// contract (only depth write is), and the punch's two passes differ in it —
/// mark tests <c>LEQUAL</c> and writes no depth, punch tests <c>ALWAYS</c> and
/// writes. The seal is a third: <c>ALWAYS</c> + write, with no stencil at all.
/// All three write no colour, which is what retail's "COLOR-INVISIBLE triangle
/// fan" means.</para>
///
/// <para><b>Two other differences from the GL arm.</b> The fan is expanded to a
/// triangle LIST on the CPU, because <see cref="GpuPrimitiveTopology"/> 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 <c>TerrainClip</c> uniform block at binding 2 rather than as a loose
/// <c>vec4[8]</c> array, because Vulkan GLSL has no default uniform block — the
/// block already has precisely this shape and is already read by
/// <c>terrain_modern.vert</c> and <c>sky.vert</c>.</para>
/// </summary>
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;
/// <summary>One position per vertex — the only attribute <c>portal_depth.vert</c> reads.</summary>
internal static GpuVertexLayout PortalVertexLayout { get; } = GpuVertexLayout.Interleaved(
strideBytes: 3 * sizeof(float),
ImmutableArray.Create(
new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0)));
/// <summary>
/// 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.
/// </summary>
private const uint PunchStencilReference = 1;
/// <summary>
/// The RHI arm's constructor. No GL context and no inline program: the three
/// pipelines compile <c>portal_depth</c> from the committed SPIR-V, and the
/// per-frame fan vertices come from the frame ring.
/// </summary>
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<Vector3> worldVerts,
in Matrix4x4 viewProjection,
ReadOnlySpan<Vector4> 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<float> positions = vertices.AsSpan<float>();
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<Vector4> clipPlanes = MemoryMarshal.Cast<byte, Vector4>(
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<float> destination, int offset, Vector3 position)
{
destination[offset] = position.X;
destination[offset + 1] = position.Y;
destination[offset + 2] = position.Z;
}
private void DisposeRhiResources()
{
List<Exception>? 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);
}
}
}

View file

@ -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.</para>
/// </summary>
public sealed class PortalDepthMaskRenderer : IDisposable
public sealed partial class PortalDepthMaskRenderer : IDisposable
{
private const string VertSrc = @"#version 430 core
/// <summary>
/// The GL arm's inline program. Campaign V slice V6l added a SECOND arm that
/// compiles <c>Rendering/Shaders/portal_depth.{vert,frag}</c> — the same body,
/// with its loose uniforms rehomed onto the shared push block and the clip
/// planes onto the <c>TerrainClip</c> 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
/// <c>ClipFrame</c> binds there globally on GL. The two are kept in step by
/// <c>PortalDepthShaderParityTests</c>, and this one is deleted at V11.
/// </summary>
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();
}
}

View file

@ -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() { }

View file

@ -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;
}

View file

@ -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,

View file

@ -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]

View file

@ -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<GpuStencilOp>().Length);
Assert.Contains(GpuStencilOp.Keep, Enum.GetValues<GpuStencilOp>());
Assert.Contains(GpuStencilOp.Zero, Enum.GetValues<GpuStencilOp>());
Assert.Contains(GpuStencilOp.Replace, Enum.GetValues<GpuStencilOp>());
}
[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()
{

View file

@ -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) =>

View file

@ -0,0 +1,79 @@
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// 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.
///
/// <para>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
/// <c>TerrainClip</c> uniform block at binding 2, and on GL that binding is held
/// globally by <c>ClipFrame</c> 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
/// <c>Rendering/Shaders/portal_depth.vert</c>, and the numbers that decide where
/// depth lands are asserted to appear in both.</para>
///
/// <para>Deleted with the GL arm at V11, when the inline string goes.</para>
/// </summary>
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);
}
}

View file

@ -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<TextureUnit, uint> 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