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."); + } + } +}