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

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