acdream/src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs
Erik b1ad1d481b feat(render): Campaign V slice V6l commit 1 - particles draw on Vulkan
Contract amendment 1 of three, and V4e's content behind it. Plan section 5.5.16
recorded that both particle pipelines draw with per-instance VERTEX attributes
and that the pinned contract could express instanced DRAWING but not instanced
vertex INPUT: one stride, no divisor, one buffer at VertexInputRate.VERTEX. That
is what stopped V4e. This takes the reviewed option (i) - a second vertex
binding with a per-instance rate.

The amendment. GpuVertexLayout grows a per-binding notion (binding index,
stride, input rate) and GpuVertexAttribute names the binding it is fed from,
defaulting to 0; IGpuPassEncoder.BindVertexBuffer takes a binding index. Every
layout written before this slice keeps its exact meaning through
GpuVertexLayout.Interleaved, which is one vertex-rate binding 0 - and
GpuContractTests asserts that as a requirement rather than trusting it. Both
backends carry the rate natively and at no cost: VK_VERTEX_INPUT_RATE_INSTANCE
on the pipeline, glVertexAttribDivisor recorded once into the pipeline's VAO
where it survives every later attribute rebind.

GpuVertexFormat.UInt1 comes with it, and is necessary to it: particle.vert
declares `layout(location = 6) in uint aTextureIndex` and the amendment's whole
premise is that no shader is edited. Same kind-distinction UByte4UInt was added
for at V4d - GL needs glVertexAttribIPointer, Vulkan needs R32_UINT, and the
float path would reinterpret the value's bits rather than approximate them.

Options (ii) and (iii) were rejected on the record: all ten storage bindings are
spoken for and reusing binding 0 would have the GL particle draw clobber
WbDrawDispatcher's instance array mid-frame (section 5.5.8's hazard in its GL
form); CPU-expanding instances is 5x billboard bandwidth and does not scale to
mesh particles at all.

The arm. ParticleRenderer.Rhi.cs is a SECOND arm per section 5.5.6, not a
replacement - every GL statement in the sibling file is the one it always
issued. Five pipelines replace the imperative glBlendFunc switch (two billboard
blends, three mesh blends) because core Vulkan 1.3 does not make blend dynamic.
The per-flight VAO/VBO pool disappears because every ring allocation inside a
frame is already distinct memory that lives until the frame retires. The
binding-9 table is not bound at all - the device owns the table and the encoder
binds set 2. The pass is BORROWED from IWorldPassScope. Depth tests but does not
write, compare is Less and alpha-to-coverage is off, which is the ambient GL
state particles have always drawn under rather than a choice. Everything above
the submission seam - emitter iteration, retail distance ordering, the
deferred-alpha handoff, billboard axis construction, blend resolution - is the
same CPU code on both arms.

The first Vulkan particle frame threw rather than drew, which is the second
defect of the compiles-clean class this slice found by running:
TextureCache.AcquireParticleTexture is bindless-only, so the standalone particle
texture cache did not exist on a backend without GL. It exists on both arms now.
Everything about it that matters - sharing equivalent surfaces between emitter
owners, the bounded unowned LRU, retirement behind the frame-flight fence - is
already backend-neutral; only how one entry is created and destroyed differs,
which is what IStandaloneBindlessTextureBackend is for. The RHI arm creates the
image through IGpuDevice.CreateTexture with a real sampler and releases the
table slot before the image, which is the GL arm's order and for the same
reason. The composite cache stays GL-only: it serves entity appearance, not
particles.

The durability fix V6k earned. That slice found the sky declaring a 32-byte
stride against a 36-byte AcDream.Core.Terrain.Vertex - the record carries a
TerrainLayer no sky attribute names - and noted that every .Rhi.cs arm restates
a CPU record's footprint from memory while only sky had a test.
RhiVertexLayoutStrideTests is that test for the rest: world mesh, terrain, sky,
retained-UI sprite, debug line, and both particle bindings, each asserted
against the record or the producer's own float count, plus two sweeps over all
seven for attributes that reach past their stride or name an undeclared binding.
Four private layouts became internal to be assertable; nothing else about them
moved.

Gates. Release build green. App tests 4,121/3 skips (4,109 baseline plus three
contract tests and nine layout tests); complete Release suite 9,184/5. Strict GL
offline pixel gate against 08ffe141: 3.20e-05, 18 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 that still draws terrain,
blending, roads, water, statics, scenery, sky and the complete retained UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:20:59 +02:00

291 lines
12 KiB
C#

using System.Collections.Immutable;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.Core.Meshing;
using AcDream.Core.Terrain;
using AcDream.Content;
using DatReaderWriter;
namespace AcDream.App.Rendering.Sky;
/// <summary>
/// Campaign V slice V6k: the sky's RHI submission arm.
///
/// <para>This is V4f's content, landed as a SECOND arm rather than a replacement,
/// for the reason §5.5.6 gave: NVIDIA rendered the V4c binary 10/10 where AMD's
/// GL stack did not, so GL keeps its raw world path through to V10 as a
/// documented fork confined to the submission seam and the RHI world path ships
/// on Vulkan. Every GL statement in the sibling file is the one it always issued;
/// everything here runs only when there is no GL context.</para>
///
/// <para>Three things differ from the GL arm, each because Vulkan bakes what GL
/// sets. The per-submesh blend function is two PIPELINES — additive for
/// sun/moon/stars, straight alpha for the dome and cloud sheets — because core
/// Vulkan 1.3 does not make blend dynamic. The <c>SkyParams</c> block is a ring
/// slice taken per draw rather than one buffer rewritten per draw, because a ring
/// allocation is distinct memory that lives until the frame retires and a
/// rewritten buffer is read at EXECUTION time, not record time (plan §5.5.8's
/// second recorded gap). And the pass is BORROWED from
/// <see cref="IWorldPassScope"/>, because the frame's one backbuffer pass
/// resolves and a second pass could not load what it left.</para>
///
/// <para>The sky is the first Vulkan consumer of set 1 binding 4. Plan §5.5.8
/// recorded that <c>UniformSkyParams</c> was missing from the uniform set layout
/// and V6i-2 added it; nothing had ever bound it until now.</para>
/// </summary>
public sealed unsafe partial class SkyRenderer
{
private readonly IGpuDevice? _device;
private readonly ICurrentGpuFrameSource? _frames;
private readonly IWorldPassScope? _scope;
private IGpuPipeline? _alphaPipeline;
private IGpuPipeline? _additivePipeline;
/// <summary>
/// Slot cache for the RHI arm, keyed the same way the GL arm keys its
/// bindless handles: one entry per (surface, wrap mode) pair, because a
/// Vulkan table entry is a combined image sampler and the dome sampled
/// CLAMP_TO_EDGE is a different entry from a cloud sheet sampled REPEAT.
/// </summary>
private readonly Dictionary<(uint SurfaceId, bool Repeat), GpuTextureSlot>
_slotBySurfaceAndWrap = new();
/// <summary>
/// The sky vertex layout, taken from <see cref="Vertex"/> itself.
///
/// <para>The stride is <c>sizeof(Vertex)</c> — 36 bytes, not the 32 the world
/// mesh uses. <see cref="Vertex"/> carries a fourth member,
/// <c>TerrainLayer</c>, which <c>sky.vert</c> does not declare and the GL arm
/// never described to a <c>glVertexAttribPointer</c>; it is still part of the
/// record's footprint, and getting the stride wrong scatters the dome's
/// vertices into noise while leaving the frame otherwise plausible. The GL arm
/// says <c>sizeof(Vertex)</c> and so does this.</para>
/// </summary>
internal static readonly GpuVertexLayout SkyVertexLayout = GpuVertexLayout.Interleaved(
strideBytes: (uint)sizeof(Vertex),
ImmutableArray.Create(
new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0),
new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12),
new GpuVertexAttribute(2, GpuVertexFormat.Float2, 24)));
/// <summary>
/// The RHI arm's constructor. No GL context, no <c>Shader</c>, no
/// <c>SamplerCache</c> and no <c>BindlessSupport</c>: the two pipelines
/// compile <c>sky</c> from the committed SPIR-V, and the wrap mode is which
/// sampler the slot was registered with.
/// </summary>
internal SkyRenderer(
IGpuDevice device,
ICurrentGpuFrameSource frames,
IWorldPassScope scope,
IDatReaderWriter dats,
TextureCache textures)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
_scope = scope ?? throw new ArgumentNullException(nameof(scope));
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_textures = textures ?? throw new ArgumentNullException(nameof(textures));
_alphaPipeline = CreateSkyPipeline("sky-alpha", GpuBlendMode.StraightAlpha);
try
{
_additivePipeline = CreateSkyPipeline("sky-additive", GpuBlendMode.Additive);
}
catch
{
_alphaPipeline.Dispose();
_alphaPipeline = null;
throw;
}
}
/// <summary>
/// One sky pipeline. Depth is off in both directions and culling is off, which
/// is exactly the GL arm's state bracket — the sky must never occlude scene
/// geometry and its meshes are drawn from both sides.
/// </summary>
private IGpuPipeline CreateSkyPipeline(string name, GpuBlendMode blend) =>
_device!.CreatePipeline(new GpuPipelineDescription
{
Name = name,
Shaders = new GpuShaderSet("sky"),
VertexLayout = SkyVertexLayout,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = blend,
Depth = GpuDepthState.Disabled,
Cull = GpuCullMode.None,
FrontFace = GpuFrontFace.CounterClockwise,
AlphaToCoverage = false,
ColorWrite = true,
SampleCount = _scope!.SampleCount,
});
/// <summary>
/// Uploads one submesh into its own device-local vertex/index buffer pair.
/// Sky meshes are built once per GfxObj and never move, so this is the same
/// "upload once, draw many frames" shape the GL arm's static-draw VBOs have.
/// </summary>
private SubMeshGpu UploadSubMeshRhi(GfxObjSubMesh sm)
{
IGpuDevice device = _device!;
ReadOnlySpan<byte> vertexBytes = MemoryMarshal.AsBytes<Vertex>(sm.Vertices);
ReadOnlySpan<byte> indexBytes = MemoryMarshal.AsBytes<uint>(sm.Indices);
IGpuBuffer vertices = device.CreateBuffer(new GpuBufferDescription(
$"sky-vertices-0x{sm.SurfaceId:X8}",
Math.Max(vertexBytes.Length, 32),
GpuBufferUsage.Vertex | GpuBufferUsage.TransferDestination,
GpuMemoryResidency.DeviceLocal));
IGpuBuffer indices;
try
{
indices = device.CreateBuffer(new GpuBufferDescription(
$"sky-indices-0x{sm.SurfaceId:X8}",
Math.Max(indexBytes.Length, 4),
GpuBufferUsage.Index | GpuBufferUsage.TransferDestination,
GpuMemoryResidency.DeviceLocal));
}
catch
{
vertices.Dispose();
throw;
}
try
{
if (!vertexBytes.IsEmpty)
vertices.Upload(0, vertexBytes);
if (!indexBytes.IsEmpty)
indices.Upload(0, indexBytes);
}
catch
{
indices.Dispose();
vertices.Dispose();
throw;
}
return new SubMeshGpu
{
VertexBuffer = vertices,
IndexBuffer = indices,
IndexCount = sm.Indices.Length,
SurfaceId = sm.SurfaceId,
IsAdditive = sm.Translucency == TranslucencyKind.Additive,
SurfLuminosity = sm.Luminosity,
SurfDiffuse = sm.Diffuse,
NeedsUvRepeat = sm.NeedsUvRepeat,
SurfOpacity = sm.SurfOpacity,
DisableFog = sm.DisableFog,
};
}
/// <summary>
/// The (surface, wrap) table slot on a backend with no GL texture name. The
/// decode is <c>TextureCache</c>'s, so the pixels are the same ones the GL arm
/// samples; what differs is that the texture is created through
/// <see cref="IGpuDevice.CreateTexture"/> and paired with a real sampler
/// object rather than a bindless handle.
/// </summary>
private uint RhiTextureTableSlot(uint surfaceId, bool repeat)
{
var key = (surfaceId, repeat);
if (!_slotBySurfaceAndWrap.TryGetValue(key, out GpuTextureSlot slot))
{
slot = _textures.RegisterWorldSurface(surfaceId, repeat);
_slotBySurfaceAndWrap.Add(key, slot);
}
return slot.Index;
}
/// <summary>
/// Records one submesh into the borrowed world pass.
///
/// <para>Order matters for the same reason it does in terrain's arm:
/// <c>BindPipeline</c> re-issues the pipeline's own fixed state, and the
/// frame-global sections are bound AFTER this renderer's own binds because
/// those binds are what select the descriptor scope the sections must land in
/// (plan §5.5.14 item 2).</para>
/// </summary>
private void DrawSubMeshRhi(SubMeshGpu sub, uint textureSlot)
{
if (sub.IndexCount == 0 || sub.VertexBuffer is null || sub.IndexBuffer is null)
return;
IWorldPassScope scope = _scope!;
IGpuPassEncoder encoder = scope.RequireEncoder();
IGpuFrame frame = _frames!.CurrentFrame
?? throw new InvalidOperationException(
"SkyRenderer requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
encoder.BindPipeline(sub.IsAdditive ? _additivePipeline! : _alphaPipeline!);
var pushConstants = new GpuPushConstants
{
// uViewProjection is unread by sky.vert — the sky carries its own
// camera-anchored view and dome projection in SkyParams — so the
// block's only live member here is the texture slot.
ViewProjection = System.Numerics.Matrix4x4.Identity,
DrawIdOffset = 0,
LightingMode = 0,
RenderPass = 0,
LightDebug = 0,
TextureIndexA = textureSlot,
TextureIndexB = 0,
ParamA = 0f,
ParamB = 0f,
};
encoder.SetPushConstants(in pushConstants);
encoder.BindVertexBuffer(0, sub.VertexBuffer, 0);
encoder.BindIndexBuffer(sub.IndexBuffer, 0, GpuIndexType.UInt32);
GpuRingAllocation parameters = frame.AllocateRing(
SkyParams.SizeInBytes,
GpuRingUsage.Uniform);
MemoryMarshal.Write(parameters.Data, in _params);
encoder.BindUniformBuffer(
GpuBindingModel.UniformSkyParams,
parameters.Buffer,
parameters.OffsetBytes,
SkyParams.SizeInBytes);
WorldFrameSectionBinding.BindSceneLighting(encoder, scope.Sections, frame);
WorldFrameSectionBinding.BindTerrainClip(encoder, scope.Sections, frame);
encoder.DrawIndexed((uint)sub.IndexCount, 1, 0, 0, 0);
}
private void DisposeRhi()
{
List<Exception>? failures = null;
void Attempt(Action action)
{
try { action(); }
catch (Exception error) { (failures ??= []).Add(error); }
}
foreach (List<SubMeshGpu> subs in _gpuByGfxObj.Values)
{
foreach (SubMeshGpu sub in subs)
{
Attempt(() => sub.VertexBuffer?.Dispose());
Attempt(() => sub.IndexBuffer?.Dispose());
}
}
_gpuByGfxObj.Clear();
// The textures themselves belong to TextureCache, which outlives this
// renderer and releases both the image and its table slot itself; the
// slot cache here is a lookup, not an ownership record.
_slotBySurfaceAndWrap.Clear();
Attempt(() => _alphaPipeline?.Dispose());
_alphaPipeline = null;
Attempt(() => _additivePipeline?.Dispose());
_additivePipeline = null;
if (failures is not null)
throw new AggregateException("The sky renderer's RHI resources did not fully release.", failures);
}
}