From 5b3d72a90c197e3325209ea8608fb4f6b719c282 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Jul 2026 14:09:48 +0200 Subject: [PATCH] =?UTF-8?q?refactor(render):=20Campaign=20V=20slice=20V6i-?= =?UTF-8?q?2=20commit=203=20=E2=80=94=20the=20mesh=20pipeline=20stops=20na?= =?UTF-8?q?ming=20a=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §5.5.10 recorded the blocker as a fact about types: "WbMeshAdapter owns an OpenGLGraphicsDevice, so it is not constructible on Vulkan until slice V4t" — which is the entire reason NullWbMeshAdapter exists. §5.5.12 item 6 then measured how wide that dependency really is, and the answer is seven members out of a 760-line class: a GL context, the retirement queue, the shared instance VBO, and two capability flags. IMeshPipelineDevice is exactly that surface. OpenGLGraphicsDevice declares it and every member already existed under a GL-specific name, so the shipping backend executes not one changed statement — these are aliases, not behaviour. Two casts moved, and they are what actually blocked construction: - ObjectMeshManager downcast IGpuDevice to GlGpuDevice in its CONSTRUCTOR, so a Vulkan-composed pipeline threw before running a statement. V4t put it there because the class registered bindless handles itself; commit 2 moved that into the array, leaving the field a pass-through for the raw-GL renderers' handle table. The cast now lives on that one property and names the backend it was composed against instead of reporting a failed cast. - The atlas array factory is selected by IWorldTextureArrayFactory.For, which is the one place the texture stack branches on a backend. MeshPipelineDeviceSeamTests proves the decoupling rather than describing it: it builds ObjectMeshManager against a device whose Gl is null, asserts it constructs, asserts construction built no GL object, asserts the handle table refuses by name, and asserts the factory picks the RHI arm. A reflection test pins the seam's member set so a later slice cannot quietly widen it back out — the whole value here is that it is narrow. 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 through one RequireGl() accessor that names the slice that owns porting them, instead of failing at construction. WbMeshAdapter still creates an OpenGLGraphicsDevice in its GL constructor, because there is no second implementation to create yet. Those bodies are items 3–5 of §5.5.12's remainder list, along with RetailPViewPassExecutor and the three world renderers' submission arms. §5.5.13 reports the whole of V6i-2 and the slice table gains its V6i row. Gates: Release build; App tests 4,109 / 3 skips (the 4,086 baseline plus 23 across the three commits); complete Release suite 9,172 / 5; strict GL offline pixel gate vs 0ca802cd 1.60e-05 (9 px of 563,200 — the low end of the documented 9–31 px control band, and fewer than a same-commit control has measured); GL connected tools/run-repeat-connected-gate.ps1 -Runs 3 at 3/3 RENDERED on the desktop witness and 3/3 on the client capture; one Vulkan composition-host run with VK_LAYER_KHRONOS_validation proven inserted by the loader at zero errors, zero warnings, no [shutdown] diagnostic, and a captured frame. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-07-27-vulkan-campaign.md | 129 ++++++++++++ .../Rendering/Wb/IMeshPipelineDevice.cs | 58 ++++++ .../Rendering/Wb/ObjectMeshManager.cs | 59 ++++-- .../Rendering/Wb/OpenGLGraphicsDevice.cs | 16 +- src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 8 +- .../Rendering/Wb/WorldTextureArray.cs | 20 ++ .../Wb/MeshPipelineDeviceSeamTests.cs | 187 ++++++++++++++++++ 7 files changed, 459 insertions(+), 18 deletions(-) create mode 100644 src/AcDream.App/Rendering/Wb/IMeshPipelineDevice.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs 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()); + } +}