V4f's content, landed as a SECOND arm per section 5.5.6: GL keeps its raw world
path through to V10 and the RHI world path ships on Vulkan. Every GL statement in
SkyRenderer is the one it always issued; the encoder arm lives in SkyRenderer.Rhi.cs
and runs only when there is no GL context.
What it produces. ACDREAM_RENDER_BACKEND=vulkan renders the sky: the dome
quadrants, the horizon band, the cloud sheet and the fog gradient, in the same
place and the same colours as the GL capture of the same scene (within a few
units on the channels sampled, which is the day-fraction drift between two
launches). Section 5.5.15's first V7 defect - "the sky is flat fog" - is closed.
Three things differ from the GL arm, each because Vulkan bakes what GL sets. The
per-submesh blend function becomes two PIPELINES, additive for sun/moon/stars and
straight alpha for everything else, because core Vulkan 1.3 does not make blend
dynamic. The SkyParams block becomes a ring slice taken per draw rather than one
buffer rewritten per draw, because a descriptor's contents are read at execution
time, not record time. And the pass is borrowed from IWorldPassScope, because the
frame's one backbuffer pass resolves and a second pass could not load what it
left.
The sky is the first Vulkan consumer of set 1 binding 4. Section 5.5.8 recorded
that UniformSkyParams was missing from the uniform set layout and V6i-2 added it;
until now nothing had ever bound it.
The stride bug, which is the fourth of its class this campaign. The first Vulkan
sky frame drew the dome as a field of blue-white noise. The RHI vertex layout
declared a 32-byte stride - position, normal, texcoord, exactly what sky.vert
reads - while AcDream.Core.Terrain.Vertex is 36 bytes: it carries a fourth
member, TerrainLayer, that no sky attribute names and that the GL arm never
described to a glVertexAttribPointer but did count, because it says
sizeof(Vertex). Nothing else in the frame looked wrong, no validation rule was
violated, and the offline pixel gate masks the sky band, so only a side-by-side
capture found it. SkyVertexLayoutTests now asserts the REQUIREMENT - the stride
is the uploaded record's footprint - rather than today's number.
The last interim handle table is gone. V4t retired the private
GlBindlessHandleTable in WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer
and ParticleRenderer and deliberately left the sky's, because the sky is the one
world path that mints its own resident handles from TextureCache's raw GL texture
names rather than interning someone else's. It now registers those handles
through V4t's RegisterWorldTextureHandle seam instead, which is the same
mechanical change the other four took, and the class and its tests are deleted
because nothing else ever used them.
TextureCache gains RegisterWorldSurface(surfaceId, repeat), the sky's RHI texture
source: the same DecodeFromDats the GL path uses, created through
IGpuDevice.CreateTexture and paired with a real sampler object rather than baked
into a bindless handle. Keyed by (surface, wrap) for the same reason the GL arm
keys its handles that way - a table entry is a combined image sampler, so the
dome sampled CLAMP_TO_EDGE and a scrolling cloud sheet sampled REPEAT are two
entries over one decoded texture.
Gates. Release build green. App tests 4,109 passed / 3 skipped - the 4,112
baseline less the six GlBindlessHandleTable tests that went with the class, plus
three vertex-layout tests. Strict GL offline pixel gate against 7ae796a1:
4.43e-05, 25 differing pixels of 563,200, inside the documented 9-31 band, with
maximumChannelDelta 48 in the same 46-52 range every control pair reports. GL
connected repeat gate at 3 runs: 3/3 RENDERED on the desktop witness and 3/3 on
the client capture. Seven-day-group before-and-after comparison on GL - the
method V6e used, because the pixel gate masks the sky band - matching in
gradient, cloud sheet, horizon band and fog on every group, including day group
2's salmon cloud band and day group 6's green band. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation
errors, zero warnings, a captured sky frame, graceful close.
Coverage gap, stated rather than assumed. The offline scene is a fixed outdoor
view at one time of day, so the sun, the moon and the rain cylinder are drawn by
neither arm's gate. They join the accumulated user-gate debt in plan section 5.1,
where V6e already filed them.
No divergence-register row: no retail-facing behaviour changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
291 lines
12 KiB
C#
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 = new(
|
|
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(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);
|
|
}
|
|
}
|