diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md index 65c005c3..fe17360a 100644 --- a/docs/plans/2026-07-27-vulkan-campaign.md +++ b/docs/plans/2026-07-27-vulkan-campaign.md @@ -574,6 +574,7 @@ tenth pair with no consumer at all; see the V6e report. | **V6g** | The four Vulkan validation defects §5.5.7 and its log left open: the dynamic-descriptor split (an architect decision, §5.5.8 item 1), per-pass depth-format pipeline variants, first-use backbuffer attachment layout transitions, and a backbuffer capture that no longer reads a presented swapchain image. Confined to `Gpu/Vk/`; the GL backend executes not one changed statement. | validation-clean bring-up run (0 errors / 0 warnings over 39,855 frames, against 7 VUIDs + 1 UNASSIGNED at the parent), App tests, GL offline pixel gate 4.08e-05 — its own same-commit control value | | **V6h** ✅ | **The Vulkan composition host**, specified by §5.5.9 and reported in §5.5.10. `ACDREAM_RENDER_BACKEND=vulkan` runs the real `GameWindow` composition — DAT load, streaming, camera, entity table, session, and the real retained `UiHost` through the RHI — with no world renderers. Three seams: the already-generic platform acquisition now publishes a `GameWindowGraphics`; `VulkanHostInputCameraCompositionFactory` is the host-phase fork; the frame root gains a Vulkan arm. `VulkanBringUpHost` is reduced to the capability-probe harness over the extracted `VulkanGraphicsContext`. | offline Vulkan launch reaching the real composition with the client's own UI captured, one validation-layer run at 0 errors / 0 warnings, converging ownership ledger, App tests 4,075/3, complete Release suite 9,138/5, GL offline pixel gate 1.78e-05 | +| **V6i** ✅ | **The world arm's prerequisites**, in two parts. **V6i-1** (`df6e2a79`, reported in §5.5.12) closed §5.5.8's one-binding-two-buffers hazard with one descriptor-set pair per renderer scope, derived from the descriptor state rather than declared, and measured the ordered remainder list the world arm still needed. **V6i-2** (reported in §5.5.13) took items 1, 2 and 6 of that list in three gated commits: **1** the `TerrainClip` descriptor-set fix plus set 1's missing bindings 2 and 4, proven by `spirv-dis` and now gated by a SPIR-V-reading contract test (`f7344758`); **2** world texture CREATION crosses to `IGpuTexture` — `IWorldTextureArray` over `TextureAtlasManager`/`ManagedGLTextureArray`, `TerrainAtlas`'s second construction path, and `ICompositeTextureArrayBackend`'s RHI arm — with the Vulkan arm exercised at startup (`c8d0f70b`); **3** `IMeshPipelineDevice` decouples `ObjectMeshManager`/`WbMeshAdapter` from `OpenGLGraphicsDevice`. **Items 3–5 — the submission arms, `RetailPViewPassExecutor`, and the pass-structure merge — are the next slice's.** | pixel gate per commit (1.60e-05, 3.20e-05, 3.02e-05 vs `0ca802cd`), App tests, 3/3 desktop-witness connected run at commits 2 and 3, one validation-layer Vulkan run per commit | | **V7** | GL-versus-Vulkan differential: `tools/run-backend-differential-gate.ps1`, strict paired-PNG compare, divergences fixed in the Vulkan backend only, then lifecycle + R6 soak natively on Vulkan, one validation-layer-clean run, one RenderDoc capture. **Milestone: parity.** | every differential checkpoint passes; both connected routes green on VK | | **V8** | Perf gate on the RX 9070 XT, uncapped, both backends, same route. | §2 acceptance table; parity is the floor | | **V9** | Linux + CI: X11/Wayland surfaces; a `linux-vulkan` job on lavapipe (probe accepts on a real 1.3 software device, a short real render under xvfb, forced-unsupported → exit 4, `.spv` freshness). Physical Linux GPU row deferred post-cutover, as for Slice L. | CI green including the new job | @@ -1609,6 +1610,134 @@ honest state and it is why the hazard was closed as its own commit: when the world arm lands and a frame first holds three renderers' buffers, a blank or corrupt result cannot be this defect. +#### 5.5.13 V6i-2 (2026-07-28): world texture creation reaches `IGpuTexture`, and the mesh pipeline stops naming a backend + +§5.5.12's ordered remainder list had six items. This slice is items 1, 2 and 6 — +the exercisable prerequisites the world arm cannot be written without — in three +separately gated commits. Items 3–5 are the next slice's and are unchanged. + +**1. The terrain clip block was in the wrong descriptor set, and so was the whole +uniform layout** (`f7344758`). `terrain_modern.vert` declared `TerrainClip` with +no `ACDREAM_UBO_SET`, so the Vulkan dialect put it at set 0 binding 2 — which set +0 declares as a STORAGE buffer. `spirv-dis` on the committed `.spv`, before and +after: + +``` +before %372 = OpVariable %_ptr_Uniform__struct_370 Uniform + OpDecorate %372 DescriptorSet 0 / Binding 2 +after OpDecorate %372 DescriptorSet 1 / Binding 2 +``` + +with `%_struct_370 = OpTypeStruct %int %_arr_v4float_uint_8` — the block's +`{ int; vec4[8]; }` — unchanged in both. The same commit closed §5.5.8's recorded +`UniformSkyParams` gap, because set 1's layout declared only bindings 1 and 3 and +was therefore missing BOTH. All four are now declared and dynamic, which is half +Vulkan's guaranteed `maxDescriptorSetUniformBuffersDynamic`. + +Membership AND order now come from one predicate — `IsDeclaredUniformBinding` — +that the layout, the descriptor writes and `vkCmdBindDescriptorSets`'s +dynamic-offset array are all derived from, the shape V6g gave set 0. The three had +been restated separately, which is how a fifth binding would have gone wrong the +same way. + +**Both gaps were found by hand, months apart, and neither could fail on the +shipping backend.** `VulkanShaderDescriptorContractTests` now reads every +committed `.spv` and asserts the partition instead: every uniform block at a +declared set-1 binding, every storage block inside set 0's range, every sampled +resource in the one texture table. Checked out against the pre-fix `.spv`, two of +its four tests fail — so it is a gate, not a description. + +**2. World texture CREATION crosses to `IGpuTexture`** (`c8d0f70b`), which is what +V4t explicitly deferred and §5.5.12 item 1 handed forward. `IWorldTextureArray` is +the seam, and **the slot is what crosses it**: `ObjectMeshManager` used to read +`BindlessWrapHandle`/`BindlessClampHandle` off the concrete GL array and intern +them itself, and a 64-bit `ARB_bindless_texture` handle has no Vulkan spelling. The +array now answers `ResolveSlot(wrapping)` — the GL arm makes the same idempotent +interning call one level down, the RHI arm returns a pair registered at +construction — and `ReleaseTextureSlots` replaces the snapshot dictionary the +manager kept, still running only once physical retirement completes. + +Which implementation exists is decided ONCE, by the factory composition builds. +Everything above the seam is written once: capacity policy, slot allocation, ref +counting, layer retirement, empty-atlas eviction, and the whole of +`ObjectMeshManager`'s atlas policy. + +Three deliberate differences, each because the backends genuinely differ. BC mip +chains are CPU-built through V6b's `BlockCompressionMipChain`, since Vulkan cannot +blit into a compressed image, while RGBA8 uses the device's blit. Filtering lives +in an immutable sampler rather than a texture parameter, so both address modes are +registered up front — the same reason the GL array holds two resident handles. And +**RGB8, A8 and Rgba32f are refused at creation with the reason named.** A8 is the +one worth recording: the GL array serves it by swizzling R into A, and a Vulkan +swizzle lives in the image VIEW, which the pinned `GpuTextureDescription` does not +describe. A silent substitution would render wrong and look like a shader bug. +Whoever draws world materials on Vulkan either meets a real A8 atlas and extends +the contract, or proves none exists. + +`TerrainAtlas` gained the second construction path V6i drafted and reverted, with +the decode factored out and shared so both arms read the same DATs in the same +order with the same resize-to-max policy. `ICompositeTextureArrayBackend` gained +its RHI arm, which is four small methods because that seam was already a seam. + +**The arm is EXERCISED, and that is the point.** The V6i draft was reverted +precisely because nothing exercised it, and §5.5.12 measured the same failure twice +over in the descriptor layouts. So the composition host now builds the real terrain +atlas through `IGpuDevice.CreateTexture` on the arm with no GL context, and creates +and releases one shared array of each format family plus one composite array at +startup. Creation only; nothing draws them. Releasing them in the same statement +covers what a retained bundle would not — that both slot pairs come back and the +images route through the retirement queue. + +**3. The mesh pipeline stops naming a backend** (§5.5.12 item 6). That item +measured the dependency and found it seven members wide out of a 760-line class: a +GL context, the retirement queue, the instance VBO, and two capability flags. +`IMeshPipelineDevice` is exactly that surface, `OpenGLGraphicsDevice` declares it, +and every member already existed — so the GL arm executes not one changed +statement. Two casts moved: the `GlGpuDevice` downcast left the constructor for the +one property that genuinely needs it (the raw-GL renderers' handle table), and the +atlas factory is selected by `IWorldTextureArrayFactory.For`. **`ObjectMeshManager` +now constructs against a device with no GL context at all**, which is what +`MeshPipelineDeviceSeamTests` proves; before this commit the constructor threw a +cast before running a statement, and that is why `NullWbMeshAdapter` exists. + +**What this does NOT claim.** The mesh pipeline does not RUN on Vulkan. Its upload +bodies are still raw GL — `GlobalMeshBuffer`, the VAO/IBO construction, the layer +transfers — and they now fail at the site that needs GL, naming the slice that owns +them, instead of failing at construction. `WbMeshAdapter` still creates an +`OpenGLGraphicsDevice` in its GL constructor, because there is no second +implementation to create yet. A reflection test pins the seam's member set so a +later slice cannot quietly widen it back out. + +**Gate results.** Strict GL offline pixel gate against `0ca802cd`: **1.60e-05**, +**3.20e-05** and **3.02e-05** at the three commits (9, 18 and 17 differing pixels +of 563,200), every one inside the documented 9–31 px control band and at least 31x +under the 0.001 threshold. GL connected `tools/run-repeat-connected-gate.ps1 +-Runs 3` at commits 2 and 3: **3/3 RENDERED** on the desktop witness and 3/3 on the +client capture, both times. One Vulkan composition-host run per commit with +`VK_LAYER_KHRONOS_validation` **proven inserted by the loader**: zero validation +errors, zero warnings, no `[shutdown]` diagnostic on either stream, captured frame. +App tests 4,109 / 3 skips — the 4,086 baseline plus 23. The Vulkan run at commit 2 +built `terrain-atlas` 512x512x33 with 10 mip levels, `terrain-alpha-atlas` +512x512x8 (4 corner, 1 side, 3 road), RGBA8 64x64x32 at slots 3/4 with 174,720 mip +bytes blitted, BC1 64x64x32 at slots 5/6 with 696 mip bytes encoded, and a +composite 32x32x8 at slot 7. + +**Two intermittent test failures, filed rather than attributed.** A whole-suite run +failed `Issue181WallPressEquilibriumTests` once; it passed alone and did not recur +in five further runs. Seven test classes mutate the same process-global +`CameraDiagnostics` switches with no xUnit collection isolation, and this slice +touches no camera, visibility or physics code. Separately, a run of the UNCHANGED +parent tree failed a zero-allocation test — `#250`'s documented class, which fired +on three different tests across these runs. + +**What the world arm still needs, and it is now exactly items 3–5.** §5.5.12 item +3's flat-versus-PView question is unchanged and still decides how much of the world +arm is one slice: the offline gate's scene has buildings, so +`WorldRenderFrame.ClipRoot` takes the PView path, and `RetailPViewPassExecutor` +(685 lines) is on the critical path to the first Vulkan Dereth PNG. Beyond that: +the three world renderers' submission arms, and the mesh pipeline's raw-GL upload +bodies this slice deliberately left in place. + ### 5.4 The null-target `BeginPass` divergence (V4c) — must be undone at V6 V4c had to stop GL's `BeginPass` from binding framebuffer 0 when a pass declares diff --git a/src/AcDream.App/Rendering/Wb/IMeshPipelineDevice.cs b/src/AcDream.App/Rendering/Wb/IMeshPipelineDevice.cs new file mode 100644 index 00000000..12a43144 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/IMeshPipelineDevice.cs @@ -0,0 +1,58 @@ +using AcDream.App.Rendering; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Campaign V slice V6i-2: everything the mesh pipeline actually needs from a +/// graphics device. +/// +/// Plan §5.5.10 recorded the blocker plainly: "WbMeshAdapter owns an +/// OpenGLGraphicsDevice, so it is not constructible on Vulkan" — which is +/// why NullWbMeshAdapter exists at all. §5.5.12 item 6 then MEASURED how +/// wide that dependency really is, and the answer is this: a GL context, the +/// retirement queue, the shared instance VBO, and two capability flags. Seven +/// members out of a 760-line class. +/// +/// So the coupling is expressed as an interface at exactly that surface +/// and declares it — every member already +/// existed, so the GL arm executes not one changed statement. What this buys is +/// that ObjectMeshManager and WbMeshAdapter no longer NAME a +/// backend, which is the prerequisite for the slice that gives them a second +/// implementation. +/// +/// What it does not yet buy, stated plainly. is +/// still a GL type, because the mesh pipeline's upload bodies are still raw GL — +/// GlobalMeshBuffer, the VAO/IBO construction, and the layer transfers all +/// speak it directly. Those bodies are the pass-structure work items 3–5 of +/// §5.5.12's remainder list own. This slice removes the TYPE-level blocker and +/// names the rest; it does not claim the mesh pipeline runs on Vulkan today, and +/// being nullable is what will make the remaining sites fail +/// loudly rather than silently when that arm is written. +/// +internal interface IMeshPipelineDevice : IDisposable +{ + /// + /// The GL context, or null on a backend that has none. Every reader is a + /// raw-GL upload body awaiting its own port. + /// + GL? Gl { get; } + + /// Frame-flight-gated release for everything the pipeline allocates. + IGpuResourceRetirementQueue ResourceRetirement { get; } + + /// The shared per-instance attribute buffer the legacy draw path binds. + uint InstanceVBO { get; } + + /// GL_ARB_bindless_texture. Half of the modern-path gate. + bool HasBindless { get; } + + /// GL 4.3 or better. The other half of the modern-path gate. + bool HasOpenGL43 { get; } + + /// True while deferred device work is still queued. + bool HasPendingWork { get; } + + /// Runs deferred device work. Called once per frame from the render thread. + void ProcessQueue(); +} diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index 5e6691ca..c649370e 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -116,7 +116,25 @@ namespace AcDream.App.Rendering.Wb /// public class ObjectMeshManager : IDisposable { - private readonly OpenGLGraphicsDevice _graphicsDevice; + /// + /// Campaign V slice V6i-2: the graphics device, no longer named by + /// backend. See for the measured + /// surface this class actually needs, and for what still has to move + /// before there is a second implementation of it. + /// + private readonly IMeshPipelineDevice _graphicsDevice; + + /// + /// The GL context this class's still-raw upload bodies write through. + /// Null only on a backend with none, where every one of those bodies is + /// a programming error rather than a runtime condition — the slice that + /// ports them owns deleting this accessor. + /// + private GL RequireGl() => + _graphicsDevice.Gl + ?? throw new InvalidOperationException( + "The mesh pipeline's upload bodies are still raw GL and this device has no " + + "context. Campaign V's world-draw slice owns porting them."); private readonly IPreparedAssetSource _preparedAssets; private readonly ILogger _logger; @@ -129,9 +147,21 @@ namespace AcDream.App.Rendering.Wb /// composition wire, because a batch's slot and the table that resolves /// it must come from the same device by construction. /// - private readonly AcDream.App.Rendering.Gpu.Gl.GlGpuDevice _worldTextureTable; + private readonly AcDream.App.Rendering.Gpu.IGpuDevice _gpuDevice; - internal AcDream.App.Rendering.Gpu.Gl.GlGpuDevice WorldTextureTable => _worldTextureTable; + /// + /// Campaign V slice V6i-2: the downcast moved here from the constructor. + /// Only the raw-GL world renderers reach this — the Vulkan backend binds + /// set 2 and never touches the handle table — so a Vulkan-composed mesh + /// pipeline can now be CONSTRUCTED, and only a caller that genuinely + /// needs a GL handle table fails, naming why. + /// + internal AcDream.App.Rendering.Gpu.Gl.GlGpuDevice WorldTextureTable => + _gpuDevice as AcDream.App.Rendering.Gpu.Gl.GlGpuDevice + ?? throw new InvalidOperationException( + "The GL bindless handle table was requested from a mesh pipeline composed against " + + $"the {_gpuDevice.Backend} backend. It is GL-only emulation of the Vulkan texture " + + "table and is deleted with the raw-GL world path."); /// /// Campaign V slice V6i-2: how a shared atlas's physical array is made. @@ -451,7 +481,7 @@ namespace AcDream.App.Rendering.Wb // already lives inside AcDream.App or its InternalsVisibleTo test // assemblies. internal ObjectMeshManager( - OpenGLGraphicsDevice graphicsDevice, + IMeshPipelineDevice graphicsDevice, AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice, IPreparedAssetSource preparedAssets, ILogger logger, @@ -461,17 +491,20 @@ namespace AcDream.App.Rendering.Wb _graphicsDevice = graphicsDevice ?? throw new ArgumentNullException(nameof(graphicsDevice)); ArgumentNullException.ThrowIfNull(gpuDevice); - // Slice V4t: this class only ever exists on GL — it takes an - // OpenGLGraphicsDevice — so the backend cast states that fact rather - // than narrowing anything. - _worldTextureTable = (AcDream.App.Rendering.Gpu.Gl.GlGpuDevice)gpuDevice; + _gpuDevice = gpuDevice; // Slice V6i-2: which physical array a shared atlas gets is decided // once, here. Everything below — capacity, slot allocation, ref // counting, layer retirement, eviction — is written against // IWorldTextureArray and does not branch on the backend. - _atlasArrays = new GlWorldTextureArrayFactory( + // + // Slice V4t downcast gpuDevice to GlGpuDevice HERE, which is what + // made a Vulkan-composed mesh pipeline throw before it had run a + // statement. The cast now lives on the one property that genuinely + // needs it — the raw-GL world renderers' handle table — so + // construction itself no longer names a backend. + _atlasArrays = IWorldTextureArrayFactory.For( graphicsDevice, - _worldTextureTable, + gpuDevice, logger ?? throw new ArgumentNullException(nameof(logger))); _preparedAssets = preparedAssets ?? throw new ArgumentNullException(nameof(preparedAssets)); @@ -489,7 +522,7 @@ namespace AcDream.App.Rendering.Wb if (_useModernRendering) { GlobalBuffer = new GlobalMeshBuffer( - _graphicsDevice.GL, + RequireGl(), gpuDevice, _graphicsDevice.ResourceRetirement); } @@ -1951,7 +1984,7 @@ namespace AcDream.App.Rendering.Wb { if (meshData.Vertices.Length == 0) return null; - var gl = _graphicsDevice.GL; + var gl = RequireGl(); uint vao = 0, vbo = 0; var modernIndexBatches = meshData.TextureBatches.Values .SelectMany(batches => batches) @@ -2398,7 +2431,7 @@ namespace AcDream.App.Rendering.Wb return null; var releases = new List<(string Name, Action Release)>(); - GL gl = _graphicsDevice.GL; + GL gl = RequireGl(); if (_useModernRendering) { if (data.GlobalAllocation is { } allocation) diff --git a/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs b/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs index 38bf7e1d..1e926865 100644 --- a/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs +++ b/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs @@ -20,7 +20,7 @@ namespace AcDream.App.Rendering.Wb { /// /// OpenGL graphics device /// - public unsafe class OpenGLGraphicsDevice : BaseGraphicsDevice { + public unsafe class OpenGLGraphicsDevice : BaseGraphicsDevice, IMeshPipelineDevice { private readonly ILogger _log; private readonly DebugRenderSettings _renderSettings; private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue _resourceRetirement; @@ -34,6 +34,20 @@ namespace AcDream.App.Rendering.Wb { internal bool HasPendingGLWork => !_glThreadQueue.IsEmpty || !_nextGlThreadQueue.IsEmpty; + // Campaign V slice V6i-2: IMeshPipelineDevice. Every member below already + // existed under a GL-specific name; these are aliases, not behaviour, so + // the shipping backend executes exactly the statements it executed + // before. See the interface for what the mesh pipeline actually needs + // and what still has to move before it has a second implementation. + GL? IMeshPipelineDevice.Gl => GL; + + AcDream.App.Rendering.IGpuResourceRetirementQueue IMeshPipelineDevice.ResourceRetirement => + _resourceRetirement; + + bool IMeshPipelineDevice.HasPendingWork => HasPendingGLWork; + + void IMeshPipelineDevice.ProcessQueue() => ProcessGLQueue(); + public void QueueGLAction(Action action) { _glThreadQueue.Enqueue(action); } diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index 7cddfa07..075b2ddd 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -47,7 +47,7 @@ public sealed class WbMeshAdapter internal const int MaximumReclaimedMeshesPerFrame = MaximumUploadsPerFrame; internal const long MaximumReclaimedMeshBytesPerFrame = 64L * 1024 * 1024; internal const int MaximumStaleDiscardsPerFrame = 64; - private readonly OpenGLGraphicsDevice? _graphicsDevice; + private readonly IMeshPipelineDevice? _graphicsDevice; private readonly ObjectMeshManager? _meshManager; private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement; private readonly IPreparedAssetSource? _ownedPreparedAssets; @@ -495,7 +495,7 @@ public sealed class WbMeshAdapter if (_disposed) return; ObjectMeshManager meshManager = _meshManager!; - _graphicsDevice!.ProcessGLQueue(); + _graphicsDevice!.ProcessQueue(); // #125: drain staged uploads; a FAILED upload (UploadMeshData returned // null from its catch) is re-staged for a LATER frame, not dropped. The // re-stages are collected and re-enqueued AFTER the loop — re-enqueuing @@ -759,8 +759,8 @@ public sealed class WbMeshAdapter if (_graphicsDevice is null) return; - _graphicsDevice.ProcessGLQueue(); - if (_graphicsDevice.HasPendingGLWork) + _graphicsDevice.ProcessQueue(); + if (_graphicsDevice.HasPendingWork) { throw new InvalidOperationException( $"OpenGL work remains pending after {operation}; retry adapter disposal to continue the exact stage."); diff --git a/src/AcDream.App/Rendering/Wb/WorldTextureArray.cs b/src/AcDream.App/Rendering/Wb/WorldTextureArray.cs index 846832ee..eae19197 100644 --- a/src/AcDream.App/Rendering/Wb/WorldTextureArray.cs +++ b/src/AcDream.App/Rendering/Wb/WorldTextureArray.cs @@ -101,6 +101,26 @@ internal interface IWorldTextureArray : IDisposable /// internal interface IWorldTextureArrayFactory { + /// + /// Campaign V slice V6i-2: picks the arm from what the composed devices + /// actually are. This is the ONE place the mesh pipeline's texture stack + /// branches on a backend, which is what lets everything above it — capacity + /// policy, slot allocation, ref counting, layer retirement, eviction — be + /// written once. + /// + internal static IWorldTextureArrayFactory For( + IMeshPipelineDevice graphicsDevice, + IGpuDevice gpuDevice, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(graphicsDevice); + ArgumentNullException.ThrowIfNull(gpuDevice); + ArgumentNullException.ThrowIfNull(logger); + return graphicsDevice is OpenGLGraphicsDevice gl && gpuDevice is GlGpuDevice table + ? new GlWorldTextureArrayFactory(gl, table, logger) + : new RhiWorldTextureArrayFactory(gpuDevice); + } + /// The retirement queue array layers and images are released through. IGpuResourceRetirementQueue Retirement { get; } diff --git a/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs new file mode 100644 index 00000000..6d2fda9b --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs @@ -0,0 +1,187 @@ +using System; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Wb; +using AcDream.App.Tests.Rendering.Gpu; +using System.Threading; +using AcDream.Content; +using Chorizite.Core.Render.Enums; +using Microsoft.Extensions.Logging.Abstractions; +using Silk.NET.OpenGL; + +namespace AcDream.App.Tests.Rendering.Wb; + +/// +/// Campaign V slice V6i-2: the mesh pipeline no longer names a backend. +/// +/// Plan §5.5.10 recorded the blocker as a fact about types — "WbMeshAdapter +/// owns an OpenGLGraphicsDevice, so it is not constructible on Vulkan" — which is +/// why NullWbMeshAdapter exists. §5.5.12 item 6 measured how wide the +/// dependency really is: a GL context, the retirement queue, the instance VBO, +/// and two capability flags. This suite proves the interface at that surface is +/// load-bearing rather than cosmetic, by building the object graph against a +/// device that has NO GL context at all. +/// +/// It deliberately proves construction and nothing more. The upload bodies +/// are still raw GL and the world renderers still bind a GL handle table; both +/// belong to the slice that draws Dereth on Vulkan. What matters here is that +/// each of those now fails at the site that needs GL, naming why, instead of +/// throwing a cast before the constructor has run a statement. +/// +public sealed class MeshPipelineDeviceSeamTests +{ + /// A device with the mesh pipeline's whole surface and no GL behind it. + private sealed class ContextFreeMeshPipelineDevice(IGpuResourceRetirementQueue retirement) + : IMeshPipelineDevice + { + public GL? Gl => null; + + public IGpuResourceRetirementQueue ResourceRetirement { get; } = retirement; + + public uint InstanceVBO => 0; + + public bool HasBindless => false; + + public bool HasOpenGL43 => false; + + public bool HasPendingWork => false; + + public int ProcessedQueues { get; private set; } + + public void ProcessQueue() => ProcessedQueues++; + + public void Dispose() + { + } + } + + private static ObjectMeshManager Build(RecordingGpuDevice device) => + new( + new ContextFreeMeshPipelineDevice(device.Retirement), + device, + new NullPreparedAssetSource(), + NullLogger.Instance); + + private sealed class NullPreparedAssetSource : IPreparedAssetSource + { + public PreparedAssetSourceStats Stats => default; + + public CacheStats DecodedTextureCacheStats => default; + + public PreparedAssetPresence Probe( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + PreparedAssetPresence.Missing; + + public PreparedAssetReadResult Read( + in PreparedAssetRequest request, + CancellationToken cancellationToken = default) => + PreparedAssetReadResult.Missing; + + public void Dispose() + { + } + } + + /// + /// The whole point. Before this slice the constructor downcast the RHI device + /// to GlGpuDevice, so this threw before running a statement. + /// + [Fact] + public void TheMeshPipelineConstructsAgainstADeviceWithNoGlContext() + { + using var device = new RecordingGpuDevice(); + using ObjectMeshManager manager = Build(device); + + Assert.False(manager.IsDisposed); + } + + /// + /// The GL handle table is the emulation the Vulkan backend replaces with set + /// 2, so asking a non-GL pipeline for it is a programming error — and it says + /// which backend it was composed against rather than reporting a cast. + /// + [Fact] + public void TheGlHandleTableIsRefusedByNameRatherThanCast() + { + using var device = new RecordingGpuDevice(); + using ObjectMeshManager manager = Build(device); + + InvalidOperationException failure = + Assert.Throws(() => manager.WorldTextureTable); + Assert.Contains("GL-only", failure.Message, StringComparison.Ordinal); + Assert.Contains(GpuBackendKind.Recording.ToString(), failure.Message, StringComparison.Ordinal); + } + + /// + /// The one branch the texture stack keeps: a GL pair yields the GL arm, and + /// anything else yields the RHI arm. Selection happens once, at construction. + /// + [Fact] + public void TheArrayFactorySelectsTheRhiArmWithoutAGlPair() + { + using var device = new RecordingGpuDevice(); + IWorldTextureArrayFactory arrays = IWorldTextureArrayFactory.For( + new ContextFreeMeshPipelineDevice(device.Retirement), + device, + NullLogger.Instance); + + Assert.IsType(arrays); + using IWorldTextureArray array = + arrays.CreateClampedArray(TextureFormat.RGBA8, 32, 32, 2); + Assert.IsType(array); + } + + /// + /// The seam's whole value is that it is NARROW — seven members measured out + /// of a 760-line class. A later slice that quietly widens it back out would + /// re-couple the mesh pipeline to a backend without any other gate noticing, + /// so the member set is pinned rather than described. + /// + [Fact] + public void TheDeviceSeamStaysAtTheMeasuredSurface() + { + string[] members = + [ + .. typeof(IMeshPipelineDevice) + .GetMembers() + // Property accessors are the same members under another name. + .Where(member => member is not System.Reflection.MethodInfo + { + IsSpecialName: true, + }) + .Select(member => member.Name) + .Order(StringComparer.Ordinal), + ]; + + Assert.Equal( + [ + "Gl", + "HasBindless", + "HasOpenGL43", + "HasPendingWork", + "InstanceVBO", + "ProcessQueue", + "ResourceRetirement", + ], + members); + } + + /// + /// Construction touched no GL object at all. The shared mesh arena is the + /// only one the constructor would build, and it is gated on the two + /// capability flags the interface carries — so a device reporting neither + /// leaves it absent rather than dereferencing a null context. + /// + [Fact] + public void ConstructionBuildsNoGlObject() + { + using var device = new RecordingGpuDevice(); + using ObjectMeshManager manager = Build(device); + + Assert.Null(manager.GlobalBuffer); + // Read-only policy queries still answer, which is what lets streaming + // residence accounting keep running on a backend with no world draws. + Assert.Equal((0, 0, 0), manager.GetPendingTextureUpdateStats()); + } +}