feat(render): Campaign V slice V6k commit 1 - the sky draws on Vulkan
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>
This commit is contained in:
parent
7ae796a110
commit
22aa2edc65
7 changed files with 618 additions and 293 deletions
|
|
@ -1040,19 +1040,33 @@ internal sealed class LivePresentationCompositionPhase
|
|||
"sky shader lifetime",
|
||||
skyShader,
|
||||
_ => d.RenderResourceLifetime.ReleaseSkyShader());
|
||||
var skyLease = scope.AcquireOptional(
|
||||
// Campaign V slice V6k: the sky exists on BOTH arms. The GL arm is
|
||||
// unchanged except for where its table slots come from; the RHI arm
|
||||
// compiles the sky pair from SPIR-V and records into the pass the world
|
||||
// scene phase publishes on this scope.
|
||||
var skyLease = scope.Acquire(
|
||||
"sky renderer",
|
||||
() => gl is null || skyShader is null
|
||||
? null
|
||||
: new SkyRenderer(
|
||||
() => gl is not null
|
||||
? new SkyRenderer(
|
||||
gl,
|
||||
content.Dats,
|
||||
skyShader,
|
||||
skyShader!,
|
||||
foundation.TextureCache,
|
||||
foundation.Samplers!,
|
||||
// Slice V6e: the sky samples through the binding=9 handle table,
|
||||
// Slice V6e: the sky samples through the shared texture table,
|
||||
// so it needs the same bindless entry point the world path uses.
|
||||
foundation.Bindless!),
|
||||
foundation.Bindless!,
|
||||
// Slice V6k: and V4t's world-handle seam on the device, which
|
||||
// retired the last per-renderer GlBindlessHandleTable.
|
||||
(AcDream.App.Rendering.Gpu.Gl.GlGpuDevice)host.GpuDevice)
|
||||
: new SkyRenderer(
|
||||
host.GpuDevice,
|
||||
host.GpuFrameLifetime,
|
||||
worldPassScope
|
||||
?? throw new InvalidOperationException(
|
||||
"A backend without a GL context must publish a world pass scope."),
|
||||
content.Dats,
|
||||
foundation.TextureCache),
|
||||
static value => value.Dispose());
|
||||
var particleLease = scope.AcquireOptional(
|
||||
"particle renderer",
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V2 (docs/plans/2026-07-27-vulkan-campaign.md §3.4, §5.2):
|
||||
/// pure bookkeeping that assigns a stable small-integer "slot" to each distinct
|
||||
/// GL_ARB_bindless_texture handle a renderer submits. A batch/pass no longer
|
||||
/// carries the 64-bit handle directly into its GPU-visible struct; it carries
|
||||
/// this slot index instead, and the shader looks the handle back up from a
|
||||
/// binding=9 storage buffer (<c>GpuBindingModel.StorageTextureTable</c>) — the
|
||||
/// GL-only emulation of Vulkan's global sampled-texture descriptor array
|
||||
/// (<c>GpuBindingModel.TextureTableSet</c>). That indirection is what makes
|
||||
/// the CPU-side batch data backend-neutral.
|
||||
///
|
||||
/// Owns no GL resource. The owning renderer uploads <see cref="Handles"/> to
|
||||
/// its own storage buffer whenever <see cref="Dirty"/> is set — mirroring how
|
||||
/// it already uploads its other per-frame/per-registration SSBOs — and clears
|
||||
/// the flag with <see cref="MarkFlushed"/>. Never releases a slot: entries
|
||||
/// accumulate for the renderer's lifetime, exactly like the world atlas and
|
||||
/// composite-texture caches it draws handles from.
|
||||
///
|
||||
/// <b>Campaign V slice V4t retired every instance but one.</b> The world
|
||||
/// texture stack now produces <c>GpuTextureSlot</c> directly, so
|
||||
/// WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer and
|
||||
/// ParticleRenderer share <c>GlGpuDevice</c>'s single retirement-gated table
|
||||
/// instead of interning handles themselves — see that class's
|
||||
/// <c>RegisterWorldTextureHandle</c> and the campaign doc's §5.5.11.
|
||||
///
|
||||
/// <b>The remaining owner is <c>SkyRenderer</c></b>, whose textures are minted
|
||||
/// from <c>TextureCache.GetOrUpload</c>'s raw GL texture names — the one world
|
||||
/// path V4t did not retype — so it is the sole consumer interning handles it
|
||||
/// produced itself. Slice V4f retires that table, and this class with it.
|
||||
/// Nothing requires index agreement between the sky's table and the device's:
|
||||
/// each rebinds its own buffer to binding=9 immediately before its own draw
|
||||
/// call, so the same handle may legitimately hold different slots in the two.
|
||||
/// </summary>
|
||||
internal sealed class GlBindlessHandleTable
|
||||
{
|
||||
private readonly Dictionary<ulong, uint> _slotByHandle = new();
|
||||
private ulong[] _handles = new ulong[64];
|
||||
private int _count;
|
||||
|
||||
/// <summary>True after a <see cref="GetOrAdd"/> call registers a handle not seen before.</summary>
|
||||
public bool Dirty { get; private set; }
|
||||
|
||||
/// <summary>Live prefix of assigned handles, in slot order (array index == slot).</summary>
|
||||
public ReadOnlySpan<ulong> Handles => _handles.AsSpan(0, _count);
|
||||
|
||||
/// <summary>Returns the stable slot for a handle, registering it on first use.</summary>
|
||||
public uint GetOrAdd(ulong bindlessHandle)
|
||||
{
|
||||
if (_slotByHandle.TryGetValue(bindlessHandle, out uint slot))
|
||||
return slot;
|
||||
|
||||
if (_count == _handles.Length)
|
||||
Array.Resize(ref _handles, _handles.Length * 2);
|
||||
slot = (uint)_count;
|
||||
_handles[_count] = bindlessHandle;
|
||||
_count++;
|
||||
_slotByHandle.Add(bindlessHandle, slot);
|
||||
Dirty = true;
|
||||
return slot;
|
||||
}
|
||||
|
||||
/// <summary>Call once the owning renderer has uploaded <see cref="Handles"/> to its SSBO.</summary>
|
||||
public void MarkFlushed() => Dirty = false;
|
||||
}
|
||||
291
src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs
Normal file
291
src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -43,35 +43,45 @@ namespace AcDream.App.Rendering.Sky;
|
|||
/// measured clockwise from north.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed unsafe class SkyRenderer : IDisposable
|
||||
public sealed unsafe partial class SkyRenderer : IDisposable
|
||||
{
|
||||
private readonly GL _gl;
|
||||
private readonly GL? _gl;
|
||||
private readonly IDatReaderWriter _dats;
|
||||
private readonly Shader _shader;
|
||||
private readonly Shader? _shader;
|
||||
private readonly TextureCache _textures;
|
||||
private readonly SamplerCache _samplers;
|
||||
private readonly SamplerCache? _samplers;
|
||||
|
||||
// Campaign V slice V6e. Three GL objects replace what used to be a run of
|
||||
// Campaign V slice V6e. Two GL objects replace what used to be a run of
|
||||
// glUniform* calls and a texture/sampler binding on unit 0:
|
||||
//
|
||||
// _paramsUbo — the std140 SkyParams block both stages declare.
|
||||
// _textureTableSsbo — the binding=9 handle table (the GL-only emulation
|
||||
// of Vulkan's set 2), holding one entry per
|
||||
// (texture, wrap-mode) pair the sky samples.
|
||||
// _uTextureIndexALoc — the push-constant-named uniform carrying the slot.
|
||||
//
|
||||
// The wrap mode is what makes the pairing necessary. A bindless handle
|
||||
// BAKES its sampler state, so the per-submesh Repeat-vs-ClampToEdge choice
|
||||
// that used to be a glBindSampler call has to be a different handle — which
|
||||
// is also exactly how the Vulkan table works, where an entry is a combined
|
||||
// image sampler. ManagedGLTextureArray has interned wrap/clamp handle pairs
|
||||
// the same way since the world path went bindless.
|
||||
private readonly Wb.BindlessSupport _bindless;
|
||||
private readonly GlBindlessHandleTable _textureTable = new();
|
||||
// The wrap mode is what makes the (texture, wrap) pairing necessary. A
|
||||
// bindless handle BAKES its sampler state, so the per-submesh
|
||||
// Repeat-vs-ClampToEdge choice that used to be a glBindSampler call has to be
|
||||
// a different handle — which is also exactly how the Vulkan table works,
|
||||
// where an entry is a combined image sampler. ManagedGLTextureArray has
|
||||
// interned wrap/clamp handle pairs the same way since the world path went
|
||||
// bindless.
|
||||
//
|
||||
// Campaign V slice V6k retired the interim per-renderer GlBindlessHandleTable
|
||||
// — the last one in the tree — for the device's own retirement-gated table,
|
||||
// exactly as V4t did for the other four world renderers. The handles are
|
||||
// still minted here, because the sky is the one world path whose textures it
|
||||
// produces itself; only the slot allocator moved.
|
||||
private readonly Wb.BindlessSupport? _bindless;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6k: the GL arm's slot allocator. The sky mints its own
|
||||
/// resident handles — it is the one world path that does — so it registers
|
||||
/// them into the device's table through V4t's world-handle seam instead of
|
||||
/// keeping the last private <c>GlBindlessHandleTable</c> in the tree.
|
||||
/// </summary>
|
||||
private readonly AcDream.App.Rendering.Gpu.Gl.GlGpuDevice? _glDevice;
|
||||
|
||||
private readonly Dictionary<(uint Texture, bool Repeat), ulong> _handleByTextureAndWrap = new();
|
||||
private uint _paramsUbo;
|
||||
private uint _textureTableSsbo;
|
||||
private int _textureTableSsboCapacityBytes;
|
||||
private int _uTextureIndexALoc = -1;
|
||||
private SkyParams _params;
|
||||
|
||||
|
|
@ -87,13 +97,14 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
public float Near { get; set; } = 0.1f;
|
||||
public float Far { get; set; } = 1_000_000f;
|
||||
|
||||
public SkyRenderer(
|
||||
internal SkyRenderer(
|
||||
GL gl,
|
||||
IDatReaderWriter dats,
|
||||
Shader shader,
|
||||
TextureCache textures,
|
||||
SamplerCache samplers,
|
||||
Wb.BindlessSupport bindless)
|
||||
Wb.BindlessSupport bindless,
|
||||
AcDream.App.Rendering.Gpu.Gl.GlGpuDevice device)
|
||||
{
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
||||
|
|
@ -101,6 +112,7 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
_textures = textures ?? throw new ArgumentNullException(nameof(textures));
|
||||
_samplers = samplers ?? throw new ArgumentNullException(nameof(samplers));
|
||||
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
|
||||
_glDevice = device ?? throw new ArgumentNullException(nameof(device));
|
||||
|
||||
_uTextureIndexALoc = _gl.GetUniformLocation(_shader.Program, "uTextureIndexA");
|
||||
|
||||
|
|
@ -117,9 +129,6 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
BufferUsageARB.DynamicDraw,
|
||||
"allocating sky params UBO");
|
||||
_gl.BindBuffer(BufferTargetARB.UniformBuffer, 0);
|
||||
|
||||
_textureTableSsbo = Wb.TrackedGlResource.CreateBuffer(
|
||||
_gl, "sky texture-table SSBO creation");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -228,7 +237,7 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
skyView.M42 = 0f;
|
||||
skyView.M43 = 0f;
|
||||
|
||||
_shader.Use();
|
||||
_shader?.Use();
|
||||
// Campaign V slice V6e: the values below used to be individual
|
||||
// glUniform* calls. They now populate the std140 SkyParams block, which
|
||||
// is uploaded once per submesh right before its draw — the same cadence
|
||||
|
|
@ -246,29 +255,33 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
_params.SunDir =
|
||||
AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(keyframe);
|
||||
|
||||
_gl.BindBufferBase(
|
||||
BufferTargetARB.UniformBuffer,
|
||||
AcDream.App.Rendering.Gpu.GpuBindingModel.UniformSkyParams,
|
||||
_paramsUbo);
|
||||
bool wasCullFace = false;
|
||||
if (_gl is { } gl)
|
||||
{
|
||||
gl.BindBufferBase(
|
||||
BufferTargetARB.UniformBuffer,
|
||||
AcDream.App.Rendering.Gpu.GpuBindingModel.UniformSkyParams,
|
||||
_paramsUbo);
|
||||
|
||||
// Save + override GL state.
|
||||
_gl.DepthMask(false);
|
||||
_gl.Disable(EnableCap.DepthTest);
|
||||
// Save + disable CullFace for the sky pass; restore at the end.
|
||||
// Mirrors TextRenderer.cs's save/restore pattern. Without this the
|
||||
// sky pass left CullFace disabled regardless of its prior state,
|
||||
// which is benign today (the global convention in this codebase is
|
||||
// off and subsequent renderers manage their own CullFace) but
|
||||
// would break the moment any future caller assumes back-face
|
||||
// culling stays on across the sky pass.
|
||||
bool wasCullFace = _gl.IsEnabled(EnableCap.CullFace);
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
_gl.Enable(EnableCap.Blend);
|
||||
// Default blend — overridden per-submesh inside the inner loop.
|
||||
// Additive surfaces (sun/moon/stars via SurfaceType.Additive =
|
||||
// 0x10000) get GL_SRC_ALPHA / GL_ONE; alpha-blended (clouds, dome
|
||||
// with Alpha flag) get GL_SRC_ALPHA / GL_ONE_MINUS_SRC_ALPHA.
|
||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
// Save + override GL state.
|
||||
gl.DepthMask(false);
|
||||
gl.Disable(EnableCap.DepthTest);
|
||||
// Save + disable CullFace for the sky pass; restore at the end.
|
||||
// Mirrors TextRenderer.cs's save/restore pattern. Without this the
|
||||
// sky pass left CullFace disabled regardless of its prior state,
|
||||
// which is benign today (the global convention in this codebase is
|
||||
// off and subsequent renderers manage their own CullFace) but
|
||||
// would break the moment any future caller assumes back-face
|
||||
// culling stays on across the sky pass.
|
||||
wasCullFace = gl.IsEnabled(EnableCap.CullFace);
|
||||
gl.Disable(EnableCap.CullFace);
|
||||
gl.Enable(EnableCap.Blend);
|
||||
// Default blend — overridden per-submesh inside the inner loop.
|
||||
// Additive surfaces (sun/moon/stars via SurfaceType.Additive =
|
||||
// 0x10000) get GL_SRC_ALPHA / GL_ONE; alpha-blended (clouds, dome
|
||||
// with Alpha flag) get GL_SRC_ALPHA / GL_ONE_MINUS_SRC_ALPHA.
|
||||
gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
}
|
||||
|
||||
// Look up the keyframe's override list so we can apply
|
||||
// SkyObjReplace (r12 §2.3): per-keyframe GfxObj swaps + rotation
|
||||
|
|
@ -389,11 +402,16 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
// SrcAlpha/InvSrcAlpha for a no-op blend at alpha=1).
|
||||
// See FUN_00508010 (chunk_00500000.c:7535) for the retail
|
||||
// pattern — retail routes sky meshes through the normal
|
||||
// mesh pipeline where Surface flags dictate state.
|
||||
if (sub.IsAdditive)
|
||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
|
||||
else
|
||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
// mesh pipeline where Surface flags dictate state. On the RHI
|
||||
// arm the same two blend functions are two pipelines, because
|
||||
// Vulkan bakes blend rather than making it dynamic.
|
||||
if (_gl is { } blendGl)
|
||||
{
|
||||
if (sub.IsAdditive)
|
||||
blendGl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
|
||||
else
|
||||
blendGl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
}
|
||||
|
||||
// Emissive source picks the surface's authored Luminosity by
|
||||
// default; the per-keyframe replace data can OVERRIDE
|
||||
|
|
@ -445,8 +463,6 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
// 0x08000023 remains unfogged and keeps the pink detail.
|
||||
_params.ApplyFog = sub.DisableFog ? 0f : 1f;
|
||||
|
||||
uint tex = _textures.GetOrUpload(sub.SurfaceId);
|
||||
|
||||
// Sky meshes need per-object wrap mode driven by the
|
||||
// mesh's authored UV range, not by TexVelocity:
|
||||
// * The outer dome (0x010015EE/F0/F1/F2) authors UVs
|
||||
|
|
@ -486,19 +502,28 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
bool needsRepeat = sub.NeedsUvRepeat
|
||||
|| obj.TexVelocityX != 0f
|
||||
|| obj.TexVelocityY != 0f;
|
||||
_gl.ProgramUniform1(
|
||||
_shader.Program,
|
||||
_uTextureIndexALoc,
|
||||
TextureTableSlot(tex, needsRepeat));
|
||||
uint slot = TextureTableSlot(sub.SurfaceId, needsRepeat);
|
||||
|
||||
UploadParams();
|
||||
FlushAndBindTextureTable();
|
||||
if (_gl is { } drawGl)
|
||||
{
|
||||
drawGl.ProgramUniform1(
|
||||
_shader!.Program,
|
||||
_uTextureIndexALoc,
|
||||
slot);
|
||||
|
||||
_gl.BindVertexArray(sub.Vao);
|
||||
_gl.DrawElements(PrimitiveType.Triangles,
|
||||
(uint)sub.IndexCount,
|
||||
DrawElementsType.UnsignedInt,
|
||||
(void*)0);
|
||||
UploadParams();
|
||||
FlushAndBindTextureTable();
|
||||
|
||||
drawGl.BindVertexArray(sub.Vao);
|
||||
drawGl.DrawElements(PrimitiveType.Triangles,
|
||||
(uint)sub.IndexCount,
|
||||
DrawElementsType.UnsignedInt,
|
||||
(void*)0);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawSubMeshRhi(sub, slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -510,11 +535,14 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
// nothing left to leak onto the next renderer's unit 0. That unbind was
|
||||
// load-bearing precisely because the binding was global state; a table
|
||||
// slot is not.
|
||||
_gl.Disable(EnableCap.Blend);
|
||||
_gl.DepthMask(true);
|
||||
_gl.Enable(EnableCap.DepthTest);
|
||||
if (wasCullFace) _gl.Enable(EnableCap.CullFace);
|
||||
_gl.BindVertexArray(0);
|
||||
if (_gl is { } restoreGl)
|
||||
{
|
||||
restoreGl.Disable(EnableCap.Blend);
|
||||
restoreGl.DepthMask(true);
|
||||
restoreGl.Enable(EnableCap.DepthTest);
|
||||
if (wasCullFace) restoreGl.Enable(EnableCap.CullFace);
|
||||
restoreGl.BindVertexArray(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -531,18 +559,22 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
/// handle table in the codebase — the sky's texture set is a fixed handful
|
||||
/// per day group and does not churn.</para>
|
||||
/// </summary>
|
||||
private uint TextureTableSlot(uint textureName, bool repeat)
|
||||
private uint TextureTableSlot(uint surfaceId, bool repeat)
|
||||
{
|
||||
if (_gl is null)
|
||||
return RhiTextureTableSlot(surfaceId, repeat);
|
||||
|
||||
uint textureName = _textures.GetOrUpload(surfaceId);
|
||||
var key = (textureName, repeat);
|
||||
if (!_handleByTextureAndWrap.TryGetValue(key, out ulong handle))
|
||||
{
|
||||
handle = _bindless.GetResidentHandle(
|
||||
handle = _bindless!.GetResidentHandle(
|
||||
textureName,
|
||||
repeat ? _samplers.Wrap : _samplers.Clamp);
|
||||
repeat ? _samplers!.Wrap : _samplers!.Clamp);
|
||||
_handleByTextureAndWrap.Add(key, handle);
|
||||
}
|
||||
|
||||
return _textureTable.GetOrAdd(handle);
|
||||
return _glDevice!.RegisterWorldTextureHandle(handle).Index;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -552,51 +584,31 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
/// </summary>
|
||||
private void UploadParams()
|
||||
{
|
||||
GL gl = _gl!;
|
||||
fixed (void* p = &_params)
|
||||
{
|
||||
_gl.BindBuffer(BufferTargetARB.UniformBuffer, _paramsUbo);
|
||||
_gl.BufferSubData(
|
||||
gl.BindBuffer(BufferTargetARB.UniformBuffer, _paramsUbo);
|
||||
gl.BufferSubData(
|
||||
BufferTargetARB.UniformBuffer, 0, (nuint)SkyParams.SizeInBytes, p);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads newly interned handles to the binding=9 table and (re)binds it.
|
||||
/// Campaign V slice V6k: drains the device texture table's dirty runs and
|
||||
/// (re)binds it at
|
||||
/// <see cref="AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable"/>.
|
||||
/// Mirrors <c>ParticleRenderer.FlushAndBindTextureTable</c>, including its
|
||||
/// reason for running per draw rather than per pass: a submesh partway
|
||||
/// through the sky stack can be the first to ask for a wrap mode.
|
||||
/// </summary>
|
||||
private void FlushAndBindTextureTable()
|
||||
{
|
||||
if (_textureTable.Dirty)
|
||||
{
|
||||
ReadOnlySpan<ulong> handles = _textureTable.Handles;
|
||||
int byteCount = handles.Length * sizeof(ulong);
|
||||
fixed (ulong* p = handles)
|
||||
{
|
||||
_gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, _textureTableSsbo);
|
||||
if (_textureTableSsboCapacityBytes < byteCount)
|
||||
{
|
||||
int grown = DynamicBufferCapacity.Grow(_textureTableSsboCapacityBytes, byteCount);
|
||||
Wb.TrackedGlResource.AllocateBufferStorage(
|
||||
_gl,
|
||||
BufferTargetARB.ShaderStorageBuffer,
|
||||
_textureTableSsbo,
|
||||
_textureTableSsboCapacityBytes,
|
||||
grown,
|
||||
BufferUsageARB.DynamicDraw,
|
||||
"growing sky texture-table SSBO");
|
||||
_textureTableSsboCapacityBytes = grown;
|
||||
}
|
||||
_gl.BufferSubData(BufferTargetARB.ShaderStorageBuffer, 0, (nuint)byteCount, p);
|
||||
}
|
||||
_textureTable.MarkFlushed();
|
||||
}
|
||||
|
||||
_gl.BindBufferBase(
|
||||
AcDream.App.Rendering.Gpu.Gl.GlGpuDevice device = _glDevice!;
|
||||
device.FlushTextureTable();
|
||||
_gl!.BindBufferBase(
|
||||
BufferTargetARB.ShaderStorageBuffer,
|
||||
AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable,
|
||||
_textureTableSsbo);
|
||||
device.TextureTableGlName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -826,6 +838,9 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
|
||||
private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm)
|
||||
{
|
||||
if (_gl is null)
|
||||
return UploadSubMeshRhi(sm);
|
||||
|
||||
uint vao = _gl.GenVertexArray();
|
||||
_gl.BindVertexArray(vao);
|
||||
|
||||
|
|
@ -882,6 +897,12 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_gl is null)
|
||||
{
|
||||
DisposeRhi();
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var subs in _gpuByGfxObj.Values)
|
||||
{
|
||||
foreach (var sub in subs)
|
||||
|
|
@ -896,8 +917,16 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
// Campaign V slice V6e. Residency first: a handle made resident pins
|
||||
// its texture's GPU address, and the textures themselves belong to
|
||||
// TextureCache, which outlives this renderer and disposes them itself.
|
||||
//
|
||||
// Slice V6k: the device's table entry is retired alongside it, in the
|
||||
// order V4t established — the table entry goes first, because the slot
|
||||
// is only recycled once the frames that could still read it retire,
|
||||
// whereas the handle stops being valid the instant it is non-resident.
|
||||
foreach (ulong handle in _handleByTextureAndWrap.Values)
|
||||
_bindless.MakeNonResident(handle);
|
||||
{
|
||||
_glDevice!.ReleaseWorldTextureHandle(handle);
|
||||
_bindless!.MakeNonResident(handle);
|
||||
}
|
||||
_handleByTextureAndWrap.Clear();
|
||||
|
||||
if (_paramsUbo != 0)
|
||||
|
|
@ -906,17 +935,6 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
_gl, _paramsUbo, SkyParams.SizeInBytes, "sky params UBO disposal");
|
||||
_paramsUbo = 0;
|
||||
}
|
||||
|
||||
if (_textureTableSsbo != 0)
|
||||
{
|
||||
Wb.TrackedGlResource.DeleteBuffer(
|
||||
_gl,
|
||||
_textureTableSsbo,
|
||||
_textureTableSsboCapacityBytes,
|
||||
"sky texture-table SSBO disposal");
|
||||
_textureTableSsbo = 0;
|
||||
_textureTableSsboCapacityBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -955,6 +973,14 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
public uint Vao;
|
||||
public uint Vbo;
|
||||
public uint Ebo;
|
||||
/// <summary>
|
||||
/// Campaign V slice V6k: the RHI arm's vertex source. The sky's meshes are
|
||||
/// built once per GfxObj and never change, so each submesh owns a
|
||||
/// device-local buffer pair rather than taking a ring slice per frame.
|
||||
/// Null on the GL arm, which uses <see cref="Vao"/>.
|
||||
/// </summary>
|
||||
public AcDream.App.Rendering.Gpu.IGpuBuffer? VertexBuffer;
|
||||
public AcDream.App.Rendering.Gpu.IGpuBuffer? IndexBuffer;
|
||||
public int IndexCount;
|
||||
public uint SurfaceId;
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -350,6 +350,78 @@ public sealed unsafe class TextureCache
|
|||
/// registered with a nearest SAMPLER or every retail icon and dat-font
|
||||
/// glyph would silently become bilinear.</para>
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Campaign V slice V6k: one world Surface as a device texture-table slot,
|
||||
/// sampled with the wrap mode the caller needs.
|
||||
///
|
||||
/// <para>The sky's RHI arm is the only consumer, and it exists because that
|
||||
/// arm has no GL texture name to intern a bindless handle from — a Vulkan
|
||||
/// draw cannot sample a GL handle. The decode is the same
|
||||
/// <see cref="DecodeFromDats"/> the GL path uses, so the pixels are
|
||||
/// identical; what differs is that the image is created through
|
||||
/// <see cref="IGpuDevice.CreateTexture"/> and paired with a real sampler
|
||||
/// object rather than baked into a handle.</para>
|
||||
///
|
||||
/// <para>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 even when they name one decoded texture.</para>
|
||||
/// </summary>
|
||||
internal GpuTextureSlot RegisterWorldSurface(uint surfaceId, bool repeat)
|
||||
{
|
||||
var key = (surfaceId, repeat);
|
||||
if (_worldSurfaceGpuTextures.TryGetValue(key, out GpuUiTextureEntry existing))
|
||||
return existing.Slot;
|
||||
|
||||
DecodedTexture decoded = DecodeFromDats(
|
||||
surfaceId,
|
||||
origTextureOverride: null,
|
||||
paletteOverride: null);
|
||||
GpuUiTextureEntry entry = UploadWorldSurfaceTexture(
|
||||
decoded,
|
||||
repeat,
|
||||
$"world-surface-0x{surfaceId:X8}{(repeat ? "-repeat" : "-clamp")}");
|
||||
_worldSurfaceGpuTextures[key] = entry;
|
||||
return entry.Slot;
|
||||
}
|
||||
|
||||
private readonly Dictionary<(uint SurfaceId, bool Repeat), GpuUiTextureEntry>
|
||||
_worldSurfaceGpuTextures = new();
|
||||
|
||||
private GpuUiTextureEntry UploadWorldSurfaceTexture(
|
||||
DecodedTexture decoded,
|
||||
bool repeat,
|
||||
string debugName)
|
||||
{
|
||||
IGpuTexture texture = _device.CreateTexture(new GpuTextureDescription(
|
||||
debugName,
|
||||
GpuTextureKind.Texture2D,
|
||||
GpuTextureFormat.Rgba8Unorm,
|
||||
Width: decoded.Width,
|
||||
Height: decoded.Height,
|
||||
LayerCount: 1,
|
||||
MipLevelCount: 1));
|
||||
try
|
||||
{
|
||||
texture.Upload(0, 0, decoded.Rgba8);
|
||||
uint glName = UploadAccountingName(texture);
|
||||
TrackUploadedTexture(glName, decoded.Width, decoded.Height);
|
||||
|
||||
// Linear/linear with a single level — the filtering
|
||||
// TextureCache's own GL uploads have always used for sky surfaces,
|
||||
// and the wrap mode SamplerCache's two objects express on GL.
|
||||
IGpuSampler sampler = _device.CreateSampler(
|
||||
repeat ? GpuSamplerDescription.WorldRepeat : GpuSamplerDescription.WorldClamp);
|
||||
GpuTextureSlot slot = _device.RegisterTexture(texture, sampler);
|
||||
return new GpuUiTextureEntry(texture, slot, glName, decoded.Width, decoded.Height);
|
||||
}
|
||||
catch
|
||||
{
|
||||
texture.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private GpuUiTextureEntry UploadUiTexture(DecodedTexture decoded, bool nearest, string debugName)
|
||||
{
|
||||
IGpuTexture texture = _device.CreateTexture(new GpuTextureDescription(
|
||||
|
|
@ -1150,6 +1222,17 @@ public sealed unsafe class TextureCache
|
|||
}
|
||||
_renderSurfaceGpuTextures.Clear();
|
||||
|
||||
// Campaign V slice V6k: world Surface textures created through the RHI
|
||||
// for the sky's backend-neutral arm. Same ownership shape as the UI
|
||||
// entries above — the device retires the image, this releases the slot.
|
||||
foreach (GpuUiTextureEntry entry in _worldSurfaceGpuTextures.Values)
|
||||
{
|
||||
entry.Texture.Dispose();
|
||||
_device.ReleaseTextureSlot(entry.Slot);
|
||||
UntrackUploadedTexture(entry.GlName);
|
||||
}
|
||||
_worldSurfaceGpuTextures.Clear();
|
||||
|
||||
// Ad-hoc textures from the public UploadRgba8(byte[],int,int,bool) wrapper
|
||||
// (IconComposer composited icons). Not stored in any keyed cache.
|
||||
foreach (GpuUiTextureEntry entry in _adhocGpuTextures)
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V2 (2026-07-27): pure-CPU proof of
|
||||
/// <see cref="GlBindlessHandleTable"/>'s bookkeeping — the handle→slot
|
||||
/// allocator each Campaign V-touched renderer (WbDrawDispatcher,
|
||||
/// EnvCellRenderer, TerrainModernRenderer, ParticleRenderer) owns to back its
|
||||
/// own binding=9 GL texture table.
|
||||
/// </summary>
|
||||
public class GlBindlessHandleTableTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetOrAdd_FirstHandle_AssignsSlotZero_AndMarksDirty()
|
||||
{
|
||||
var table = new GlBindlessHandleTable();
|
||||
|
||||
uint slot = table.GetOrAdd(0xDEADBEEFu);
|
||||
|
||||
Assert.Equal(0u, slot);
|
||||
Assert.True(table.Dirty);
|
||||
Assert.Equal(new ulong[] { 0xDEADBEEFu }, table.Handles.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrAdd_SameHandleTwice_ReturnsSameSlot_AndDoesNotDuplicate()
|
||||
{
|
||||
var table = new GlBindlessHandleTable();
|
||||
|
||||
uint first = table.GetOrAdd(111ul);
|
||||
table.MarkFlushed();
|
||||
uint second = table.GetOrAdd(111ul);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.False(table.Dirty); // no NEW handle was registered
|
||||
Assert.Single(table.Handles.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrAdd_DistinctHandles_AssignStableIncreasingSlots()
|
||||
{
|
||||
var table = new GlBindlessHandleTable();
|
||||
|
||||
uint a = table.GetOrAdd(1ul);
|
||||
uint b = table.GetOrAdd(2ul);
|
||||
uint c = table.GetOrAdd(3ul);
|
||||
// Re-querying an already-registered handle must not shift anyone else's slot.
|
||||
uint aAgain = table.GetOrAdd(1ul);
|
||||
|
||||
Assert.Equal(0u, a);
|
||||
Assert.Equal(1u, b);
|
||||
Assert.Equal(2u, c);
|
||||
Assert.Equal(a, aAgain);
|
||||
Assert.Equal(new ulong[] { 1ul, 2ul, 3ul }, table.Handles.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroHandle_IsRegisteredLikeAnyOther_NotSpecialCased()
|
||||
{
|
||||
// The pre-V2 behaviour let a batch/pass carry a literal zero bindless
|
||||
// handle through to the shader unchanged (an existing "no texture"
|
||||
// edge case some batches hit). V2 must reproduce that bit-for-bit: a
|
||||
// zero handle gets a real slot whose table entry is uvec2(0,0) — the
|
||||
// same value the shader would have received directly before V2.
|
||||
var table = new GlBindlessHandleTable();
|
||||
|
||||
uint slot = table.GetOrAdd(0ul);
|
||||
|
||||
Assert.Equal(0u, table.Handles[(int)slot]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkFlushed_ClearsDirty_UntilNextNewHandle()
|
||||
{
|
||||
var table = new GlBindlessHandleTable();
|
||||
table.GetOrAdd(42ul);
|
||||
Assert.True(table.Dirty);
|
||||
|
||||
table.MarkFlushed();
|
||||
Assert.False(table.Dirty);
|
||||
|
||||
table.GetOrAdd(42ul); // already known — must NOT re-dirty
|
||||
Assert.False(table.Dirty);
|
||||
|
||||
table.GetOrAdd(43ul); // genuinely new — must re-dirty
|
||||
Assert.True(table.Dirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrAdd_GrowsPastInitialCapacity_WithoutLosingEarlierSlots()
|
||||
{
|
||||
var table = new GlBindlessHandleTable();
|
||||
const int count = 200; // exceeds the 64-entry initial backing array
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
uint slot = table.GetOrAdd((ulong)i + 1000ul);
|
||||
Assert.Equal((uint)i, slot);
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
Assert.Equal((ulong)i + 1000ul, table.Handles[i]);
|
||||
}
|
||||
}
|
||||
82
tests/AcDream.App.Tests/Rendering/SkyVertexLayoutTests.cs
Normal file
82
tests/AcDream.App.Tests/Rendering/SkyVertexLayoutTests.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Sky;
|
||||
using AcDream.Core.Terrain;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6k: the sky's RHI vertex layout must describe the CPU record
|
||||
/// the GL arm uploads, not a record that merely looks like it.
|
||||
///
|
||||
/// <para>The slice's first Vulkan sky frame drew the dome as a field of noise
|
||||
/// because the layout declared a 32-byte stride — position + normal + texcoord,
|
||||
/// which is exactly what <c>sky.vert</c> reads — while
|
||||
/// <see cref="Vertex"/> is 36 bytes: it carries a fourth member,
|
||||
/// <c>TerrainLayer</c>, that no sky attribute names. The GL arm has always said
|
||||
/// <c>sizeof(Vertex)</c> and was unaffected, and nothing else in the frame looked
|
||||
/// wrong, so only a side-by-side capture caught it.</para>
|
||||
///
|
||||
/// <para>The assertion is the REQUIREMENT — "the stride is the record's
|
||||
/// footprint" — rather than the number, so adding or removing a member of
|
||||
/// <see cref="Vertex"/> keeps it honest instead of pinning today's answer.</para>
|
||||
/// </summary>
|
||||
public class SkyVertexLayoutTests
|
||||
{
|
||||
[Fact]
|
||||
public void SkyVertexLayout_StrideMatchesTheUploadedRecord()
|
||||
{
|
||||
Assert.Equal(
|
||||
(uint)Unsafe.SizeOf<Vertex>(),
|
||||
SkyRenderer.SkyVertexLayout.StrideBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkyVertexLayout_DeclaresTheThreeAttributesTheShaderReads()
|
||||
{
|
||||
Assert.Collection(
|
||||
SkyRenderer.SkyVertexLayout.Attributes,
|
||||
position =>
|
||||
{
|
||||
Assert.Equal(0u, position.Location);
|
||||
Assert.Equal(GpuVertexFormat.Float3, position.Format);
|
||||
Assert.Equal(0u, position.OffsetBytes);
|
||||
},
|
||||
normal =>
|
||||
{
|
||||
Assert.Equal(1u, normal.Location);
|
||||
Assert.Equal(GpuVertexFormat.Float3, normal.Format);
|
||||
Assert.Equal(12u, normal.OffsetBytes);
|
||||
},
|
||||
texCoord =>
|
||||
{
|
||||
Assert.Equal(2u, texCoord.Location);
|
||||
Assert.Equal(GpuVertexFormat.Float2, texCoord.Format);
|
||||
Assert.Equal(24u, texCoord.OffsetBytes);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every attribute has to fit inside the stride it is read with. A layout that
|
||||
/// reaches past its own stride is the same defect wearing the other face.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SkyVertexLayout_EveryAttributeFitsInsideTheStride()
|
||||
{
|
||||
foreach (GpuVertexAttribute attribute in SkyRenderer.SkyVertexLayout.Attributes)
|
||||
{
|
||||
uint size = attribute.Format switch
|
||||
{
|
||||
GpuVertexFormat.Float1 => 4u,
|
||||
GpuVertexFormat.Float2 => 8u,
|
||||
GpuVertexFormat.Float3 => 12u,
|
||||
GpuVertexFormat.Float4 => 16u,
|
||||
_ => 4u,
|
||||
};
|
||||
Assert.True(
|
||||
attribute.OffsetBytes + size <= SkyRenderer.SkyVertexLayout.StrideBytes,
|
||||
$"Attribute at location {attribute.Location} reaches past the vertex stride.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue