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>
316 lines
13 KiB
C#
316 lines
13 KiB
C#
using AcDream.App.Rendering.Wb;
|
|
using Silk.NET.OpenGL;
|
|
|
|
namespace AcDream.App.Rendering.Gpu.Gl;
|
|
|
|
/// <summary>
|
|
/// Records one pass's draw work. Binding calls translate almost mechanically
|
|
/// to GL (a storage/uniform binding is <c>glBindBufferRange</c>, an indexed
|
|
/// draw is <c>glDrawElementsInstancedBaseVertexBaseInstance</c>, and so on);
|
|
/// the two pieces of real logic are the render-state diff applied on
|
|
/// <see cref="BindPipeline"/> / the dynamic setters, and the "flush dirty
|
|
/// ring + texture-table bytes immediately before every draw" discipline
|
|
/// described on <see cref="GlGpuDevice"/>.
|
|
/// </summary>
|
|
internal sealed class GlGpuPassEncoder : IGpuPassEncoder
|
|
{
|
|
private readonly GlGpuDevice _device;
|
|
private readonly GlGpuFrame _frame;
|
|
private readonly GL _gl;
|
|
private readonly IGlAmbientStateApi _ambientApi;
|
|
private readonly GlAmbientCapabilityState _ambientOnEntry;
|
|
private bool _closed;
|
|
|
|
private GlGpuPipeline? _currentPipeline;
|
|
private GpuIndexType _currentIndexType = GpuIndexType.UInt16;
|
|
private uint _currentIndexBufferBaseOffset;
|
|
private GpuPushConstants? _currentPushConstants;
|
|
|
|
internal GlGpuPassEncoder(GlGpuDevice device, GlGpuFrame frame, GpuPassDescription pass)
|
|
{
|
|
_device = device;
|
|
_frame = frame;
|
|
_gl = device.Gl;
|
|
Pass = pass;
|
|
|
|
// Campaign V slice V4a (2026-07-27 revert postmortem, plan §7.1 rule 1):
|
|
// capture every ambient capability a bound pipeline can change, so
|
|
// Dispose can put it back. Every acdream renderer is still raw GL
|
|
// until V4c/V4d, so each one assumes whatever capability state the
|
|
// PREVIOUS renderer left behind is still there — GL_MULTISAMPLE and
|
|
// GL_SAMPLE_ALPHA_TO_COVERAGE in particular are set once per frame by
|
|
// quality settings and never re-asserted per draw. The first V4a
|
|
// attempt bound a pipeline that changed this state and never restored
|
|
// it, so the world drew without multisampling from the first UI frame
|
|
// on. Capturing here and restoring on Dispose keeps the GL backend's
|
|
// behaviour-preserving property true at this seam. Deleted at V4h
|
|
// once nothing raw-GL remains.
|
|
_ambientApi = new SilkGlAmbientStateApi(_gl);
|
|
_ambientOnEntry = GlAmbientCapabilityState.Capture(_ambientApi);
|
|
|
|
// Campaign V slice V6d. GL_MULTISAMPLE is the one piece of pass state
|
|
// with no representation in GpuPipelineDescription, and the pass's own
|
|
// SampleCount is the contract's answer for it: a single-sampled pass
|
|
// does not multisample. Until now the retained UI asserted that with a
|
|
// raw glDisable of its own — exactly the kind of state a
|
|
// backend-neutral renderer cannot own. Quality settings enable
|
|
// GL_MULTISAMPLE once per frame for the world, and if it leaks into the
|
|
// UI pass every glyph's soft alpha edge becomes dithered coverage
|
|
// instead of a clean alpha blend (the "fuzzy text" artifact). The
|
|
// ambient capture above puts it back on Dispose, so the raw-GL world
|
|
// renderers that follow are unaffected.
|
|
_ambientApi.SetCapability(EnableCap.Multisample, pass.SampleCount > 1);
|
|
}
|
|
|
|
public GpuPassDescription Pass { get; }
|
|
|
|
public void BindPipeline(IGpuPipeline pipeline)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(pipeline);
|
|
ThrowIfClosed();
|
|
var p = (GlGpuPipeline)pipeline;
|
|
_currentPipeline = p;
|
|
|
|
GpuPipelineDescription description = p.Description;
|
|
var desired = new GlRenderStateSnapshot(
|
|
p.GlProgram,
|
|
description.Blend,
|
|
description.Depth.Test,
|
|
description.Depth.Write,
|
|
description.Depth.Compare,
|
|
description.Cull,
|
|
description.FrontFace,
|
|
description.AlphaToCoverage,
|
|
description.ColorWrite,
|
|
description.StencilTest,
|
|
description.Stencil);
|
|
_device.ApplyRenderState(desired);
|
|
|
|
_gl.BindVertexArray(p.GlVertexArray);
|
|
GLHelpers.ThrowOnResourceError(_gl, $"bind pipeline '{description.Name}' VAO");
|
|
|
|
// Campaign V slice V6d: the device's texture table is bound with the
|
|
// pipeline, the GL analogue of the Vulkan backend binding descriptor
|
|
// set 2 on every draw. It has to happen here rather than once per frame
|
|
// because every raw-GL world renderer binds its OWN private handle
|
|
// table at this same binding before its own draws, with its own slot
|
|
// numbering; an RHI shader that read that instead would sample a
|
|
// plausible but entirely unrelated texture. Removed at V4h with the
|
|
// per-renderer tables.
|
|
_gl.BindBufferBase(
|
|
GLEnum.ShaderStorageBuffer,
|
|
GpuBindingModel.StorageTextureTable,
|
|
_device.TextureTableGlName);
|
|
GLHelpers.ThrowOnResourceError(_gl, $"bind pipeline '{description.Name}' texture table");
|
|
|
|
// Push constants "survive pipeline changes within a pass" per the
|
|
// IGpuPassEncoder contract. GL uniforms are per-program state, so the
|
|
// GL backend must explicitly re-apply the last value to the newly
|
|
// bound program to honour that — Vulkan gets this for free from a
|
|
// shared pipeline layout.
|
|
if (_currentPushConstants is { } constants)
|
|
_device.PushConstants.Apply(p.GlProgram, in constants);
|
|
}
|
|
|
|
public void BindStorageBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
|
|
{
|
|
ThrowIfClosed();
|
|
var b = RequireGlBuffer(buffer);
|
|
_gl.BindBufferRange(GLEnum.ShaderStorageBuffer, binding, b.GlName, (nint)offsetBytes, sizeBytes);
|
|
GLHelpers.ThrowOnResourceError(_gl, $"bind storage buffer '{buffer.Name}' at binding {binding}");
|
|
}
|
|
|
|
public void BindUniformBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
|
|
{
|
|
ThrowIfClosed();
|
|
var b = RequireGlBuffer(buffer);
|
|
_gl.BindBufferRange(GLEnum.UniformBuffer, binding, b.GlName, (nint)offsetBytes, sizeBytes);
|
|
GLHelpers.ThrowOnResourceError(_gl, $"bind uniform buffer '{buffer.Name}' at binding {binding}");
|
|
}
|
|
|
|
public unsafe void BindVertexBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes)
|
|
{
|
|
ThrowIfClosed();
|
|
if (_currentPipeline is not { } pipeline)
|
|
throw new InvalidOperationException("BindPipeline must be called before BindVertexBuffer.");
|
|
var b = RequireGlBuffer(buffer);
|
|
|
|
_gl.BindBuffer(GLEnum.ArrayBuffer, b.GlName);
|
|
GpuVertexLayout layout = pipeline.Description.VertexLayout;
|
|
// Slice V6l: only the attributes this binding actually supplies. GL has
|
|
// no binding indirection of its own — glVertexAttribPointer records the
|
|
// currently bound ARRAY_BUFFER per attribute — so the binding index is
|
|
// resolved here, by filtering, rather than by the driver.
|
|
uint stride = layout.StrideOf(binding);
|
|
foreach (GpuVertexAttribute attribute in layout.Attributes)
|
|
{
|
|
if (attribute.Binding != binding)
|
|
continue;
|
|
|
|
GlVertexAttributeShape shape = GlEnumMapping.VertexShapeOf(attribute.Format);
|
|
nint attributeOffset = (nint)(offsetBytes + attribute.OffsetBytes);
|
|
if (shape.Integer)
|
|
{
|
|
// An integer shader input (uvec4) must come through the I-form.
|
|
// Supplying it via glVertexAttribPointer leaves the value undefined.
|
|
_gl.VertexAttribIPointer(
|
|
attribute.Location,
|
|
shape.ComponentCount,
|
|
(VertexAttribIType)shape.Type,
|
|
stride,
|
|
(void*)attributeOffset);
|
|
}
|
|
else
|
|
{
|
|
_gl.VertexAttribPointer(
|
|
attribute.Location,
|
|
shape.ComponentCount,
|
|
shape.Type,
|
|
shape.Normalized,
|
|
stride,
|
|
(void*)attributeOffset);
|
|
}
|
|
}
|
|
GLHelpers.ThrowOnResourceError(_gl, $"bind vertex buffer '{buffer.Name}'");
|
|
_gl.BindBuffer(GLEnum.ArrayBuffer, 0);
|
|
}
|
|
|
|
public void BindIndexBuffer(IGpuBuffer buffer, uint offsetBytes, GpuIndexType indexType)
|
|
{
|
|
ThrowIfClosed();
|
|
var b = RequireGlBuffer(buffer);
|
|
_currentIndexType = indexType;
|
|
_currentIndexBufferBaseOffset = offsetBytes;
|
|
_gl.BindBuffer(GLEnum.ElementArrayBuffer, b.GlName);
|
|
GLHelpers.ThrowOnResourceError(_gl, $"bind index buffer '{buffer.Name}'");
|
|
}
|
|
|
|
public void SetPushConstants(in GpuPushConstants constants)
|
|
{
|
|
ThrowIfClosed();
|
|
_currentPushConstants = constants;
|
|
if (_currentPipeline is { } pipeline)
|
|
_device.PushConstants.Apply(pipeline.GlProgram, in constants);
|
|
}
|
|
|
|
public void SetViewport(int x, int y, int width, int height)
|
|
{
|
|
ThrowIfClosed();
|
|
_gl.Viewport(x, y, (uint)width, (uint)height);
|
|
}
|
|
|
|
public void SetScissor(int x, int y, int width, int height)
|
|
{
|
|
ThrowIfClosed();
|
|
_gl.Enable(EnableCap.ScissorTest);
|
|
_gl.Scissor(x, y, (uint)width, (uint)height);
|
|
}
|
|
|
|
public void SetCullMode(GpuCullMode cullMode)
|
|
{
|
|
ThrowIfClosed();
|
|
_device.ApplyRenderState(_device.CurrentRenderState with { Cull = cullMode });
|
|
}
|
|
|
|
public void SetFrontFace(GpuFrontFace frontFace)
|
|
{
|
|
ThrowIfClosed();
|
|
_device.ApplyRenderState(_device.CurrentRenderState with { FrontFace = frontFace });
|
|
}
|
|
|
|
public void SetDepthWrite(bool enabled)
|
|
{
|
|
ThrowIfClosed();
|
|
_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();
|
|
_device.FlushBeforeDraw(_frame.SlotIndex);
|
|
int indexSize = GlEnumMapping.IndexSizeBytesOf(_currentIndexType);
|
|
nint indexOffset = (nint)(_currentIndexBufferBaseOffset + firstIndex * (uint)indexSize);
|
|
_gl.DrawElementsInstancedBaseVertexBaseInstance(
|
|
GlEnumMapping.PrimitiveTypeOf(RequirePipeline().Description.Topology),
|
|
indexCount,
|
|
GlEnumMapping.DrawElementsTypeOf(_currentIndexType),
|
|
(void*)indexOffset,
|
|
instanceCount,
|
|
vertexOffset,
|
|
firstInstance);
|
|
GLHelpers.ThrowOnResourceError(_gl, "DrawIndexed");
|
|
}
|
|
|
|
public void Draw(uint vertexCount, uint instanceCount, uint firstVertex, uint firstInstance)
|
|
{
|
|
ThrowIfClosed();
|
|
_device.FlushBeforeDraw(_frame.SlotIndex);
|
|
_gl.DrawArraysInstancedBaseInstance(
|
|
(GLEnum)GlEnumMapping.PrimitiveTypeOf(RequirePipeline().Description.Topology),
|
|
(int)firstVertex,
|
|
vertexCount,
|
|
instanceCount,
|
|
firstInstance);
|
|
GLHelpers.ThrowOnResourceError(_gl, "Draw");
|
|
}
|
|
|
|
public unsafe void MultiDrawIndexedIndirect(IGpuBuffer commands, uint offsetBytes, uint drawCount, uint strideBytes)
|
|
{
|
|
ThrowIfClosed();
|
|
var indirect = RequireGlBuffer(commands);
|
|
_device.FlushBeforeDraw(_frame.SlotIndex);
|
|
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, indirect.GlName);
|
|
_gl.MultiDrawElementsIndirect(
|
|
GlEnumMapping.PrimitiveTypeOf(RequirePipeline().Description.Topology),
|
|
GlEnumMapping.DrawElementsTypeOf(_currentIndexType),
|
|
(void*)(nint)offsetBytes,
|
|
drawCount,
|
|
strideBytes);
|
|
GLHelpers.ThrowOnResourceError(_gl, "MultiDrawIndexedIndirect");
|
|
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
|
|
}
|
|
|
|
public IDisposable BeginTimerScope(string scopeName) => _device.TimerPool.BeginScope(scopeName);
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_closed)
|
|
return;
|
|
_closed = true;
|
|
// GL has no store-op work to do here: GpuStoreOp.Resolve was already
|
|
// rejected at BeginPass (V1 targets are single-sampled), and
|
|
// Store/DontCare need no explicit action — the framebuffer's contents
|
|
// simply persist until the next pass rebinds a target.
|
|
//
|
|
// Restore whatever capability state was ambient before this pass
|
|
// opened (see the constructor's comment) so a still-raw-GL renderer
|
|
// running immediately after this pass sees exactly what it would have
|
|
// seen had this pass never bound a pipeline.
|
|
_ambientOnEntry.Restore(_ambientApi);
|
|
_frame.ClosePass(this);
|
|
}
|
|
|
|
private GlGpuPipeline RequirePipeline() =>
|
|
_currentPipeline ?? throw new InvalidOperationException("BindPipeline must be called before drawing.");
|
|
|
|
private static GlGpuBuffer RequireGlBuffer(IGpuBuffer buffer)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(buffer);
|
|
if (buffer is not GlGpuBuffer glBuffer)
|
|
throw new ArgumentException("The GL backend can only bind GL buffers.", nameof(buffer));
|
|
return glBuffer;
|
|
}
|
|
|
|
private void ThrowIfClosed()
|
|
{
|
|
if (_closed)
|
|
throw new ObjectDisposedException(nameof(GlGpuPassEncoder));
|
|
}
|
|
}
|
|
|