diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs
index 04255832..fb10392c 100644
--- a/src/AcDream.App/Composition/LivePresentationComposition.cs
+++ b/src/AcDream.App/Composition/LivePresentationComposition.cs
@@ -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",
diff --git a/src/AcDream.App/Rendering/GlBindlessHandleTable.cs b/src/AcDream.App/Rendering/GlBindlessHandleTable.cs
deleted file mode 100644
index 1b6b34b0..00000000
--- a/src/AcDream.App/Rendering/GlBindlessHandleTable.cs
+++ /dev/null
@@ -1,66 +0,0 @@
-namespace AcDream.App.Rendering;
-
-///
-/// 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 (GpuBindingModel.StorageTextureTable) — the
-/// GL-only emulation of Vulkan's global sampled-texture descriptor array
-/// (GpuBindingModel.TextureTableSet). That indirection is what makes
-/// the CPU-side batch data backend-neutral.
-///
-/// Owns no GL resource. The owning renderer uploads to
-/// its own storage buffer whenever is set — mirroring how
-/// it already uploads its other per-frame/per-registration SSBOs — and clears
-/// the flag with . Never releases a slot: entries
-/// accumulate for the renderer's lifetime, exactly like the world atlas and
-/// composite-texture caches it draws handles from.
-///
-/// Campaign V slice V4t retired every instance but one. The world
-/// texture stack now produces GpuTextureSlot directly, so
-/// WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer and
-/// ParticleRenderer share GlGpuDevice's single retirement-gated table
-/// instead of interning handles themselves — see that class's
-/// RegisterWorldTextureHandle and the campaign doc's §5.5.11.
-///
-/// The remaining owner is SkyRenderer, whose textures are minted
-/// from TextureCache.GetOrUpload'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.
-///
-internal sealed class GlBindlessHandleTable
-{
- private readonly Dictionary _slotByHandle = new();
- private ulong[] _handles = new ulong[64];
- private int _count;
-
- /// True after a call registers a handle not seen before.
- public bool Dirty { get; private set; }
-
- /// Live prefix of assigned handles, in slot order (array index == slot).
- public ReadOnlySpan Handles => _handles.AsSpan(0, _count);
-
- /// Returns the stable slot for a handle, registering it on first use.
- 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;
- }
-
- /// Call once the owning renderer has uploaded to its SSBO.
- public void MarkFlushed() => Dirty = false;
-}
diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs
new file mode 100644
index 00000000..427bb143
--- /dev/null
+++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs
@@ -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;
+
+///
+/// Campaign V slice V6k: the sky's RHI submission arm.
+///
+/// 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.
+///
+/// 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 SkyParams 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
+/// , 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. Plan §5.5.8
+/// recorded that UniformSkyParams was missing from the uniform set layout
+/// and V6i-2 added it; nothing had ever bound it until now.
+///
+public sealed unsafe partial class SkyRenderer
+{
+ private readonly IGpuDevice? _device;
+ private readonly ICurrentGpuFrameSource? _frames;
+ private readonly IWorldPassScope? _scope;
+ private IGpuPipeline? _alphaPipeline;
+ private IGpuPipeline? _additivePipeline;
+
+ ///
+ /// 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.
+ ///
+ private readonly Dictionary<(uint SurfaceId, bool Repeat), GpuTextureSlot>
+ _slotBySurfaceAndWrap = new();
+
+ ///
+ /// The sky vertex layout, taken from itself.
+ ///
+ /// The stride is sizeof(Vertex) — 36 bytes, not the 32 the world
+ /// mesh uses. carries a fourth member,
+ /// TerrainLayer, which sky.vert does not declare and the GL arm
+ /// never described to a glVertexAttribPointer; 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 sizeof(Vertex) and so does this.
+ ///
+ 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)));
+
+ ///
+ /// The RHI arm's constructor. No GL context, no Shader, no
+ /// SamplerCache and no BindlessSupport: the two pipelines
+ /// compile sky from the committed SPIR-V, and the wrap mode is which
+ /// sampler the slot was registered with.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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,
+ });
+
+ ///
+ /// 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.
+ ///
+ private SubMeshGpu UploadSubMeshRhi(GfxObjSubMesh sm)
+ {
+ IGpuDevice device = _device!;
+ ReadOnlySpan vertexBytes = MemoryMarshal.AsBytes(sm.Vertices);
+ ReadOnlySpan indexBytes = MemoryMarshal.AsBytes(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,
+ };
+ }
+
+ ///
+ /// The (surface, wrap) table slot on a backend with no GL texture name. The
+ /// decode is TextureCache's, so the pixels are the same ones the GL arm
+ /// samples; what differs is that the texture is created through
+ /// and paired with a real sampler
+ /// object rather than a bindless handle.
+ ///
+ 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;
+ }
+
+ ///
+ /// Records one submesh into the borrowed world pass.
+ ///
+ /// Order matters for the same reason it does in terrain's arm:
+ /// BindPipeline 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).
+ ///
+ 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? failures = null;
+ void Attempt(Action action)
+ {
+ try { action(); }
+ catch (Exception error) { (failures ??= []).Add(error); }
+ }
+
+ foreach (List 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);
+ }
+}
diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
index 136a0d46..a86ca48a 100644
--- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
+++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
@@ -43,35 +43,45 @@ namespace AcDream.App.Rendering.Sky;
/// measured clockwise from north.
///
///
-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;
+
+ ///
+ /// 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 GlBindlessHandleTable in the tree.
+ ///
+ 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");
}
///
@@ -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);
+ }
}
///
@@ -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.
///
- 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;
}
///
@@ -552,51 +584,31 @@ public sealed unsafe class SkyRenderer : IDisposable
///
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);
}
}
///
- /// 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
+ /// .
/// Mirrors ParticleRenderer.FlushAndBindTextureTable, 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.
///
private void FlushAndBindTextureTable()
{
- if (_textureTable.Dirty)
- {
- ReadOnlySpan 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);
}
///
@@ -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;
- }
}
///
@@ -955,6 +973,14 @@ public sealed unsafe class SkyRenderer : IDisposable
public uint Vao;
public uint Vbo;
public uint Ebo;
+ ///
+ /// 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 .
+ ///
+ public AcDream.App.Rendering.Gpu.IGpuBuffer? VertexBuffer;
+ public AcDream.App.Rendering.Gpu.IGpuBuffer? IndexBuffer;
public int IndexCount;
public uint SurfaceId;
///
diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs
index 844c00ac..038bf9cd 100644
--- a/src/AcDream.App/Rendering/TextureCache.cs
+++ b/src/AcDream.App/Rendering/TextureCache.cs
@@ -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.
///
+ ///
+ /// Campaign V slice V6k: one world Surface as a device texture-table slot,
+ /// sampled with the wrap mode the caller needs.
+ ///
+ /// 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
+ /// the GL path uses, so the pixels are
+ /// identical; what differs is that the image is created through
+ /// and paired with a real sampler
+ /// object rather than baked into a 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 even when they name one decoded texture.
+ ///
+ 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)
diff --git a/tests/AcDream.App.Tests/Rendering/GlBindlessHandleTableTests.cs b/tests/AcDream.App.Tests/Rendering/GlBindlessHandleTableTests.cs
deleted file mode 100644
index e5b5170f..00000000
--- a/tests/AcDream.App.Tests/Rendering/GlBindlessHandleTableTests.cs
+++ /dev/null
@@ -1,105 +0,0 @@
-using AcDream.App.Rendering;
-using Xunit;
-
-namespace AcDream.App.Tests.Rendering;
-
-///
-/// Campaign V slice V2 (2026-07-27): pure-CPU proof of
-/// '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.
-///
-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]);
- }
-}
diff --git a/tests/AcDream.App.Tests/Rendering/SkyVertexLayoutTests.cs b/tests/AcDream.App.Tests/Rendering/SkyVertexLayoutTests.cs
new file mode 100644
index 00000000..38c0b424
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/SkyVertexLayoutTests.cs
@@ -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;
+
+///
+/// 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.
+///
+/// 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 sky.vert reads — while
+/// is 36 bytes: it carries a fourth member,
+/// TerrainLayer, that no sky attribute names. The GL arm has always said
+/// sizeof(Vertex) and was unaffected, and nothing else in the frame looked
+/// wrong, so only a side-by-side capture caught it.
+///
+/// The assertion is the REQUIREMENT — "the stride is the record's
+/// footprint" — rather than the number, so adding or removing a member of
+/// keeps it honest instead of pinning today's answer.
+///
+public class SkyVertexLayoutTests
+{
+ [Fact]
+ public void SkyVertexLayout_StrideMatchesTheUploadedRecord()
+ {
+ Assert.Equal(
+ (uint)Unsafe.SizeOf(),
+ 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);
+ });
+ }
+
+ ///
+ /// 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.
+ ///
+ [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.");
+ }
+ }
+}