From f84eef32563efbea169d282d29bb99fb69861c9d Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Jul 2026 15:46:54 +0200 Subject: [PATCH] feat(render): Campaign V slice V6j commit 2 - Dereth draws on Vulkan The three world renderers' submission arms, both pass executors, and the composition that reaches them. This is the unit three predecessors stopped at. What it produces. ACDREAM_RENDER_BACKEND=vulkan on the offline scene renders terrain with blended textures and road overlays, the water edge, static world meshes, procedural scenery, and the complete retained UI - the same frame the GL pixel gate captures, from the same camera, minus the sky. artifacts/v6j-vk2. The shape, and why it is not V4c's. Section 5.5.6 chose option (B) after NVIDIA rendered the V4c binary 10/10 where AMD's GL stack did not: 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. So V4c's and V4d-2's content returns as a SECOND arm rather than a replacement. The GL arm issues the same GL statements in the same order against the same objects; the encoder arm lives in three .Rhi.cs partials and is entered by one branch per submission site. Three differences from V4c, each because the tree moved under it. There is no binding-9 texture table - V4t put the slot on the device and Vulkan binds set 2, so the arm that used to intern bindless handles simply has nothing to do. The pipelines carry the device's sample count rather than 1, because Vulkan requires rasterizationSamples to match the pass and alpha-to-coverage is a no-op at one sample. And no renderer opens a pass. That last one is structural, not tidiness. Under MSAA the frame's one backbuffer pass resolves into the swapchain image and stores DONT_CARE into the multisampled scratch, so a second pass declaring Load would load undefined contents; the backend also permits one open pass per frame. VulkanWorldScenePhase therefore opens the pass, publishes the encoder on VulkanWorldPassScope for exactly the span of the inner WorldSceneRenderer, and every renderer borrows it. Three sections are frame-global on GL and cannot be on Vulkan: the SceneLighting UBO, the per-cell clip regions, and the terrain clip block. GL binds each to a global binding point and every consumer inherits it. Vulkan binds a descriptor set per draw, and a renderer's own binds are what select the scope those sections must land in - so their writers PUBLISH into WorldFrameSections and each renderer binds them inside the pass, after its own binds. SceneLightingUboBinding's per-flight-slot buffer pool disappears with it: a ring allocation is already distinct memory that lives until the frame retires, which is the property the pool existed to provide. Both pass executors became backend-neutral rather than gaining twins. Everything they do is delegation to a renderer except four concerns - the clip-frame publication, the doorway scissor, gl_ClipDistance enablement, and retail's interior depth clear - so those four move behind IWorldPassSurface and retail's ordering, which is what these classes are actually for, is written once. The GL implementation issues the statements the executors used to issue inline. Clip distances are no-ops on the Vulkan arm, and that is safe rather than a divergence: Vulkan activates every element the shader declares, and all three world vertex shaders already write 1.0 into every slot past the active count. The interior depth clear becomes vkCmdClearAttachments, reached through the scope so the pinned contract stays frozen and the backend-only verb stays in the backend. The hook for it was already committed at V6i-3 with a cref to a type that did not exist yet; it exists now. The collision-wireframe DebugLineRenderer is composed as null on the Vulkan arm. DrawAndPublish flushes it INSIDE the world phase and it opens its own pass, which the one-pass rule forbids. The toggle is DevTools-only and DevTools is not composed there, so nothing is lost - composing it would throw on the first wireframe frame rather than silently misdraw. Two seams widened rather than invented. GameWindowGraphics answers whether the backend has a world-pass seam, because the three composition phases that need it already borrow that handle and "does this backend work that way" is what the type exists to answer. And MeshSourceReady replaces the anyVao != 0 gate with the same question in backend-neutral form - V6i-3 published HasStores for exactly this - so the predicate evaluates identically on GL. What is NOT here, and is expected. Sky and weather are still raw GL (V4f), so the Vulkan frame's sky is the atmosphere fog clear. Particles (V4e), the paperdoll and appraisal viewports and the portal depth mask (V4g) likewise. The executors already accepted all of them as absent. Gates. Release build green. App tests 4,112 passed / 3 skipped, the unchanged baseline; complete Release suite 9,175 / 5. Strict GL offline pixel gate against 847f14ae: 5.50e-05, 31 differing pixels of 563,200, inside the documented 9-31 band and 18x under the threshold. Characterised rather than accepted, because 31 is the band's top: cross-commit pairs measured 21, 29 and 31 while same-commit controls measured 12 and 20, and maximumChannelDelta is 46-52 in every comparison INCLUDING the pure controls - so the few large-delta pixels are a property of the capture, and a cross-commit pair at 21 against a same-commit pair at 20 is not what a systematic shift looks like. GL connected repeat gate at 3 runs: 3/3 RENDERED on the desktop witness and 3/3 on the client capture. One offline Vulkan run with VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation errors, zero warnings, a captured world frame, and a graceful close. Coverage gap, stated rather than assumed. The offline scene is a fixed outdoor view, so EnvCellRenderer's Vulkan arm draws nothing in it - dungeon interiors are half of this slice and are unproven by anything automated, exactly as they were for V4c. The deferred-alpha path and the doorway scissor are likewise untouched by this scene. They join the accumulated user-gate debt in plan section 5.1. No divergence-register row: no retail-facing behaviour changes. Co-Authored-By: Claude Fable 5 --- .../Composition/FrameRootComposition.cs | 90 ++- .../Composition/GameWindowGraphics.cs | 27 +- .../LivePresentationComposition.cs | 45 +- .../Composition/WorldRenderComposition.cs | 76 ++- src/AcDream.App/Rendering/ClipFrame.cs | 21 +- .../Gpu/Vk/VulkanCompositionFramePhases.cs | 23 +- .../Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs | 16 + .../Rendering/Gpu/Vk/VulkanWorldPassScope.cs | 121 ++++ .../Rendering/RetailPViewPassExecutor.cs | 80 +-- .../Rendering/SceneLightingUboBinding.cs | 74 ++- .../Rendering/TerrainModernRenderer.Rhi.cs | 332 ++++++++++ .../Rendering/TerrainModernRenderer.cs | 103 +++- .../Rendering/Wb/EnvCellRenderer.Rhi.cs | 326 ++++++++++ .../Rendering/Wb/EnvCellRenderer.cs | 114 ++-- .../Rendering/Wb/WbDrawDispatcher.Rhi.cs | 570 ++++++++++++++++++ .../Rendering/Wb/WbDrawDispatcher.cs | 136 +++-- src/AcDream.App/Rendering/WorldPassScope.cs | 198 ++++++ src/AcDream.App/Rendering/WorldPassSurface.cs | 319 ++++++++++ .../Rendering/WorldScenePassExecutor.cs | 46 +- .../WorldRenderCompositionTests.cs | 13 + .../GameWindowSlice8BoundaryTests.cs | 6 +- tools/run-offline-vulkan-capture.ps1 | 94 +++ 22 files changed, 2566 insertions(+), 264 deletions(-) create mode 100644 src/AcDream.App/Rendering/Gpu/Vk/VulkanWorldPassScope.cs create mode 100644 src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs create mode 100644 src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs create mode 100644 src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs create mode 100644 src/AcDream.App/Rendering/WorldPassScope.cs create mode 100644 src/AcDream.App/Rendering/WorldPassSurface.cs create mode 100644 tools/run-offline-vulkan-capture.ps1 diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 4eba21ac..95d5feb6 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -307,11 +307,7 @@ internal sealed class FrameRootCompositionPhase content.ParticleSink, d.EffectPoses, live.EntityEffects); - IWorldSceneFramePhase worldSceneRenderer = - new AcDream.App.Rendering.Gpu.Vk.VulkanWorldScenePhase( - host.GpuFrameLifetime, - vulkanClear, - () => d.Graphics.Vulkan?.SampleCount ?? 1); + IWorldSceneFramePhase? worldSceneRenderer = null; CurrentRenderSceneOracle? currentRenderSceneOracle = interaction.RetainedUi?.Screenshots is not null && d.Options.AutomationArtifactDirectory is not null @@ -327,16 +323,39 @@ internal sealed class FrameRootCompositionPhase acknowledgeDirty: false) : null; RenderScenePViewFrameProductController? renderFrameProduct = null; - if (gl is not null) { - // The GL world scene, unchanged. Campaign V slice V6h wrapped it in - // this one condition and changed nothing inside it: every renderer - // it composes exists on GL and none of them exists on Vulkan, so the - // Vulkan arm keeps the VulkanWorldScenePhase assigned above. + // Campaign V slice V6j: the world scene is composed on BOTH arms. + // Every renderer below now exists on Vulkan too; what forks is one + // pass surface, one state restorer, one GL-state reader, and the + // three renderers that stay raw GL until their own slices — sky, + // particles and the portal depth mask, which the executors already + // accept as absent. WorldRenderDiagnostics worldRenderDiagnostics = host.WorldRenderDiagnostics - ?? throw new InvalidOperationException( - "The GL world scene requires the GL state tripwire."); + ?? new WorldRenderDiagnostics( + NullRenderGlStateReader.Instance, + d.RenderDiagnosticLog); + IRenderFrameGlState worldFrameGlState = + (IRenderFrameGlState?)renderFrameGlState + ?? NullRenderFrameGlState.Instance; + IWorldPassScope? worldPassScope = d.Graphics.WorldPassScope; + var worldFramebufferSource = + new SilkRetailPViewFramebufferSource(d.Window); + IWorldPassSurface worldPassSurface = gl is not null + ? new GlWorldPassSurface( + gl, + live.ClipFrame, + worldFramebufferSource, + live.DrawDispatcher!, + live.EnvCellRenderer!, + foundation.Terrain) + : new RhiWorldPassSurface( + worldPassScope + ?? throw new InvalidOperationException( + "A backend without a GL context must publish a world pass scope."), + host.GpuFrameLifetime, + live.ClipFrame, + worldFramebufferSource); var worldFrameEnvironment = new RuntimeWorldFrameEnvironmentPreparation( d.Options, @@ -391,17 +410,16 @@ internal sealed class FrameRootCompositionPhase d.RenderDiagnosticLog); var retailPViewCells = new RetailPViewCellSource(d.CellVisibility); var retailPViewPassExecutor = new RetailPViewPassExecutor( - gl, - renderFrameGlState!, - new SilkRetailPViewFramebufferSource(d.Window), + worldPassSurface, + worldFrameGlState, live.ClipFrame, - foundation.Terrain!, + foundation.Terrain, live.EnvCellRenderer!, live.DrawDispatcher!, - live.SkyRenderer!, + live.SkyRenderer, content.ParticleSystem, - live.ParticleRenderer!, - live.PortalDepthMask!, + live.ParticleRenderer, + live.PortalDepthMask, d.RetailAlphaQueue, worldRenderDiagnostics, terrainDrawDiagnostics); @@ -412,23 +430,30 @@ internal sealed class FrameRootCompositionPhase d.PhysicsEngine, d.CellVisibility), d.WorldSceneDebugState, - foundation.DebugLines, + // Campaign V slice V6j: no debug-line renderer on the Vulkan arm. + // DrawAndPublish flushes it INSIDE the world phase and that + // renderer opens its own pass, which the frame's one backbuffer + // pass forbids. The collision-wireframe toggle is DevTools-only + // and DevTools is not composed there, so nothing is lost — + // composing it would throw on the first wireframe frame rather + // than silently misdraw (plan §5.5.14 item 7). + gl is not null ? foundation.DebugLines : null, d.PhysicsEngine, d.PlayerMode, d.PlayerController, d.DebugVmRenderFacts, settings.DevTools is not null); var worldScenePasses = new WorldScenePassExecutor( - gl, - renderFrameGlState!, + worldPassSurface, + worldFrameGlState, live.ClipFrame, live.DrawDispatcher!, live.EnvCellRenderer!, - foundation.Terrain!, + foundation.Terrain, terrainDrawDiagnostics, - live.SkyRenderer!, + live.SkyRenderer, content.ParticleSystem, - live.ParticleRenderer!); + live.ParticleRenderer); renderFrameProduct = live.RenderSceneShadow is not null ? new RenderScenePViewFrameProductController( @@ -462,6 +487,21 @@ internal sealed class FrameRootCompositionPhase d.RenderRange, worldSceneDiagnostics, live.WorldAvailability); + if (gl is null) + { + // On Vulkan the world renderer runs INSIDE the frame's one + // backbuffer pass, which this phase opens, publishes on the + // scope, and closes. + worldSceneRenderer = + new AcDream.App.Rendering.Gpu.Vk.VulkanWorldScenePhase( + host.GpuFrameLifetime, + vulkanClear, + () => d.Graphics.Vulkan?.SampleCount ?? 1, + (d.Graphics as VulkanGameWindowGraphics)?.WorldPassScopeCore + ?? throw new InvalidOperationException( + "The Vulkan world phase requires the Vulkan graphics handle."), + worldSceneRenderer); + } } Fault(FrameRootCompositionPoint.WorldRendererCreated); bindings = new FrameRootRuntimeBindings(); diff --git a/src/AcDream.App/Composition/GameWindowGraphics.cs b/src/AcDream.App/Composition/GameWindowGraphics.cs index d85ac34f..6070adb2 100644 --- a/src/AcDream.App/Composition/GameWindowGraphics.cs +++ b/src/AcDream.App/Composition/GameWindowGraphics.cs @@ -33,6 +33,19 @@ internal abstract class GameWindowGraphics : IDisposable /// The live Vulkan context, or null on any other backend. public virtual VulkanGraphicsContext? Vulkan => null; + /// + /// Campaign V slice V6j: the backend's world-pass seam, or null where the + /// world renderers open their own passes. + /// + /// It lives here because the three composition phases that need it — + /// world render, live presentation and the frame root — already borrow this + /// handle, and because whether a backend HAS such a seam is exactly the kind + /// of thing this type exists to answer. GL returns null: its renderers + /// submit raw, and the frame spine still owns framebuffer management until + /// V4h. + /// + public virtual AcDream.App.Rendering.IWorldPassScope? WorldPassScope => null; + /// /// The GL context, or a failure naming the caller. Used where the call site /// has already established that the GL arm is running, so a null would be a @@ -67,8 +80,14 @@ internal sealed class OpenGlGameWindowGraphics : GameWindowGraphics /// internal sealed class VulkanGameWindowGraphics : GameWindowGraphics { - public VulkanGameWindowGraphics(VulkanGraphicsContext context) => + public VulkanGameWindowGraphics(VulkanGraphicsContext context) + { Context = context ?? throw new ArgumentNullException(nameof(context)); + // The sample count is fixed at device creation, and every world pipeline + // must be created against it, so the scope is built here rather than at + // the first frame. + WorldPassScopeCore = new VulkanWorldPassScope(context.SampleCount); + } public VulkanGraphicsContext Context { get; } @@ -76,5 +95,11 @@ internal sealed class VulkanGameWindowGraphics : GameWindowGraphics public override VulkanGraphicsContext? Vulkan => Context; + /// The concrete scope, for the phase that publishes the encoder on it. + public VulkanWorldPassScope WorldPassScopeCore { get; } + + public override AcDream.App.Rendering.IWorldPassScope? WorldPassScope => + WorldPassScopeCore; + public override void Dispose() => Context.Dispose(); } diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 13167c41..04255832 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -685,11 +685,14 @@ internal sealed class LivePresentationCompositionPhase GL? gl = d.Graphics.Gl; var selectionScene = new RetailSelectionScene( new RetailSelectionGeometryCache(content.Dats, d.DatLock)); - var dispatcherLease = scope.AcquireOptional( + // Campaign V slice V6j: the world dispatcher exists on BOTH arms. The GL + // arm is unchanged; the RHI arm records into the pass the world scene + // phase publishes on this scope. + IWorldPassScope? worldPassScope = d.Graphics.WorldPassScope; + var dispatcherLease = scope.Acquire( "WB draw dispatcher", - () => gl is null - ? null - : new WbDrawDispatcher( + () => gl is not null + ? new WbDrawDispatcher( gl, foundation.MeshShader!, foundation.TextureCache, @@ -700,6 +703,20 @@ internal sealed class LivePresentationCompositionPhase d.TranslucencyFades, selectionScene, d.RetailAlphaQueue, + alphaScratchBudgets.DispatcherBytes) + : new WbDrawDispatcher( + host.GpuDevice, + host.GpuFrameLifetime, + worldPassScope + ?? throw new InvalidOperationException( + "A backend without a GL context must publish a world pass scope."), + foundation.TextureCache, + foundation.MeshAdapter!, + entitySpawnAdapter, + d.ClassificationCache, + d.TranslucencyFades, + selectionScene, + d.RetailAlphaQueue, alphaScratchBudgets.DispatcherBytes), static value => value.Dispose()); var selectionQuery = new WorldSelectionQuery( @@ -869,16 +886,26 @@ internal sealed class LivePresentationCompositionPhase Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated); var envCellFrustum = new WbFrustum(); - var envCellLease = scope.AcquireOptional( + var envCellLease = scope.Acquire( "environment-cell renderer", - () => gl is null - ? null - : new EnvCellRenderer( + () => gl is not null + ? new EnvCellRenderer( gl, foundation.MeshAdapter!.MeshManager!, + envCellFrustum) + : new EnvCellRenderer( + host.GpuDevice, + host.GpuFrameLifetime, + worldPassScope + ?? throw new InvalidOperationException( + "A backend without a GL context must publish a world pass scope."), + foundation.MeshAdapter!.MeshManager!, envCellFrustum), static value => value.Dispose()); - envCellLease.Resource?.Initialize(foundation.MeshShader!); + // The RHI arm's three pipelines ARE its program, built at construction, + // so only the GL arm has a second initialisation step. + if (foundation.MeshShader is { } envCellShader) + envCellLease.Resource.Initialize(envCellShader); Fault(LivePresentationCompositionPoint.EnvironmentCellsCreated); // The streaming pipeline itself is backend-neutral and runs on both diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs index 4745fcf4..7b72ce07 100644 --- a/src/AcDream.App/Composition/WorldRenderComposition.cs +++ b/src/AcDream.App/Composition/WorldRenderComposition.cs @@ -122,6 +122,14 @@ internal interface IWorldRenderCompositionFactory void SetTerrainAnisotropic(TerrainAtlas atlas, int level); Shader CreateTerrainShader(GL gl, string shadersDirectory); SceneLightingUboBinding CreateSceneLighting(GL gl); + /// + /// Campaign V slice V6j: scene lighting on a backend with no global uniform + /// binding point. It publishes a ring section on the world pass scope and + /// each renderer binds it inside the pass. + /// + SceneLightingUboBinding CreateBackendNeutralSceneLighting( + ICurrentGpuFrameSource frameSource, + IWorldPassScope scope); DebugLineRenderer CreateDebugLines( AcDream.App.Rendering.Gpu.IGpuDevice device, ICurrentGpuFrameSource frameSource, @@ -139,6 +147,17 @@ internal interface IWorldRenderCompositionFactory TerrainAtlas atlas, AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice, IGpuResourceRetirementQueue retirement); + /// + /// Campaign V slice V6j: terrain's RHI arm. No GL context, no linked + /// program — the pipeline compiles terrain_modern from the committed + /// SPIR-V and records into the world pass the scope publishes. + /// + TerrainModernRenderer CreateBackendNeutralTerrain( + AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice, + ICurrentGpuFrameSource frameSource, + IWorldPassScope scope, + TerrainAtlas atlas, + IGpuResourceRetirementQueue retirement); /// /// The built terrain atlas, or null on a backend that has none. The atlas is /// where the blending layer/T-code tables come from, so a null one yields an @@ -279,6 +298,11 @@ internal sealed class RetailWorldRenderCompositionFactory public SceneLightingUboBinding CreateSceneLighting(GL gl) => new(gl); + public SceneLightingUboBinding CreateBackendNeutralSceneLighting( + ICurrentGpuFrameSource frameSource, + IWorldPassScope scope) => + new(frameSource, scope.Sections); + public DebugLineRenderer CreateDebugLines( AcDream.App.Rendering.Gpu.IGpuDevice device, ICurrentGpuFrameSource frameSource, @@ -316,6 +340,14 @@ internal sealed class RetailWorldRenderCompositionFactory (AcDream.App.Rendering.Gpu.Gl.GlGpuDevice)gpuDevice, retirement); + public TerrainModernRenderer CreateBackendNeutralTerrain( + AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice, + ICurrentGpuFrameSource frameSource, + IWorldPassScope scope, + TerrainAtlas atlas, + IGpuResourceRetirementQueue retirement) => + new(gpuDevice, frameSource, scope, atlas, retirement); + public WorldTerrainBuildContext CreateTerrainBuildContext( uint initialCenterLandblockId, float[] heightTable, @@ -610,11 +642,20 @@ internal sealed class WorldRenderCompositionPhase () => _factory.CreateTerrainShader(gl!, shadersDirectory), _publication.PublishTerrainShader, WorldRenderCompositionPoint.TerrainShaderPublished); - SceneLightingUboBinding? sceneLighting = AcquireAndPublishIf( - gl is not null, + // Campaign V slice V6j: scene lighting exists on BOTH arms — the + // world renderers read it on both, and on the RHI arm it publishes a + // ring section instead of holding a global binding point. + IWorldPassScope? worldPassScope = platform.Graphics.WorldPassScope; + SceneLightingUboBinding? sceneLighting = AcquireAndPublish( scope, "scene lighting", - () => _factory.CreateSceneLighting(gl!), + () => gl is not null + ? _factory.CreateSceneLighting(gl) + : _factory.CreateBackendNeutralSceneLighting( + _dependencies.GpuFrameSource, + worldPassScope + ?? throw new InvalidOperationException( + "A backend without a GL context must publish a world pass scope.")), _publication.PublishSceneLighting, WorldRenderCompositionPoint.SceneLightingPublished); DebugLineRenderer debugLines = AcquireAndPublish( @@ -630,17 +671,28 @@ internal sealed class WorldRenderCompositionPhase (BitmapFont? debugFont, TextRenderer? textRenderer) = ComposeOptionalHudResources(scope, shadersDirectory); - TerrainModernRenderer? terrain = AcquireAndPublishIf( - gl is not null, + // Campaign V slice V6j: terrain exists on BOTH arms. The GL arm is + // unchanged; the RHI arm records into the world pass and reads the + // same backend-neutral atlas built above. + TerrainModernRenderer? terrain = AcquireAndPublish( scope, "terrain renderer", - () => _factory.CreateTerrain( - gl!, - bindless!, - terrainShader!, - terrainAtlas!, - _dependencies.GpuDevice, - _dependencies.ResourceRetirement), + () => gl is not null + ? _factory.CreateTerrain( + gl, + bindless!, + terrainShader!, + terrainAtlas!, + _dependencies.GpuDevice, + _dependencies.ResourceRetirement) + : _factory.CreateBackendNeutralTerrain( + _dependencies.GpuDevice, + _dependencies.GpuFrameSource, + worldPassScope + ?? throw new InvalidOperationException( + "A backend without a GL context must publish a world pass scope."), + terrainAtlas!, + _dependencies.ResourceRetirement), _publication.PublishTerrain, WorldRenderCompositionPoint.TerrainPublished); diff --git a/src/AcDream.App/Rendering/ClipFrame.cs b/src/AcDream.App/Rendering/ClipFrame.cs index 76174b22..cf65065a 100644 --- a/src/AcDream.App/Rendering/ClipFrame.cs +++ b/src/AcDream.App/Rendering/ClipFrame.cs @@ -568,14 +568,31 @@ public sealed class ClipFrame : IDisposable WriteUInt(dst, offset, bits); } + // ---- Packed bytes -------------------------------------------------------- + + /// + /// The packed std430 region table for slots 0..-1. + /// + /// Campaign V slice V6j: the GL arm hands these to + /// glBufferSubData against a renderer-owned SSBO; the RHI arm copies + /// them straight into a frame ring slice and publishes the range. Same bytes, + /// two destinations — which is why the packing above is backend-neutral and + /// stays where it is. + /// + internal ReadOnlySpan RegionBytes => + _regionBytes.AsSpan(0, _slotCount * CellClipStrideBytes); + + /// The packed std140 terrain-clip block. See . + internal ReadOnlySpan TerrainBytes => _terrainBytes; + // ---- Test seams ---------------------------------------------------------- /// Test seam: the packed std430 region bytes (slot 0..SlotCount-1). /// Read-only snapshot used by ClipFrameLayoutTests to assert the byte layout. - internal ReadOnlySpan RegionBytesForTest => _regionBytes.AsSpan(0, _slotCount * CellClipStrideBytes); + internal ReadOnlySpan RegionBytesForTest => RegionBytes; /// Test seam: the packed std140 terrain UBO bytes. - internal ReadOnlySpan TerrainBytesForTest => _terrainBytes; + internal ReadOnlySpan TerrainBytesForTest => TerrainBytes; } /// A single std140 terrain-clip record within a frame-slot UBO arena. diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs index ce0fa9d1..dd76b31f 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs @@ -94,24 +94,33 @@ internal sealed class VulkanRenderFrameClearPhase : IRenderFrameClearPhase /// UI's own pass is single-sampled and targets the swapchain image directly, so /// it composites over the resolved result exactly as before. /// -/// The phase still reports default — zero visible, zero total, -/// world not drawn — because nothing draws into the pass yet. That remains the -/// honest answer and is what the lifecycle artifacts record. +/// Slice V6j made it bracket the real world renderer. The pass is +/// opened here and PUBLISHED on for exactly +/// the span of the inner WorldSceneRenderer, which is backend-neutral and +/// unchanged. Every world renderer and both pass executors borrow that encoder +/// instead of opening a pass of their own — see +/// for why that is structural here rather than an economy. /// internal sealed class VulkanWorldScenePhase : IWorldSceneFramePhase { private readonly ICurrentGpuFrameSource _frames; private readonly VulkanBackbufferClearState _clear; private readonly Func _sampleCount; + private readonly VulkanWorldPassScope _scope; + private readonly IWorldSceneFramePhase _world; public VulkanWorldScenePhase( ICurrentGpuFrameSource frames, VulkanBackbufferClearState clear, - Func sampleCount) + Func sampleCount, + VulkanWorldPassScope scope, + IWorldSceneFramePhase world) { _frames = frames ?? throw new ArgumentNullException(nameof(frames)); _clear = clear ?? throw new ArgumentNullException(nameof(clear)); _sampleCount = sampleCount ?? throw new ArgumentNullException(nameof(sampleCount)); + _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + _world = world ?? throw new ArgumentNullException(nameof(world)); } public WorldRenderFrameOutcome Render(RenderFrameInput input) @@ -137,8 +146,10 @@ internal sealed class VulkanWorldScenePhase : IWorldSceneFramePhase SampleCount = samples, }); - _ = encoder; - return default; + // The clear happens as the pass's load op whether or not the world draws, + // so an empty world still produces the atmosphere fog frame V6h captured. + using IDisposable publication = _scope.Publish(encoder); + return _world.Render(input); } } diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs index 4117d681..9521453a 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs @@ -28,9 +28,24 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder private readonly VulkanGpuFrame _frame; private readonly CommandBuffer _commands; private readonly VulkanFrameBindings _bindings; + private readonly uint _attachmentWidth; private readonly uint _attachmentHeight; private readonly bool _hasDepthAttachment; + /// + /// Extent of the attachments vkCmdBeginRendering was handed. Campaign V + /// slice V6j: needs it for the scissor + /// rectangle it restores and for the interior depth clear's rect, and neither + /// is expressible on the pinned contract. + /// + internal int AttachmentWidth => (int)_attachmentWidth; + + /// Height counterpart of . + internal int AttachmentHeight => (int)_attachmentHeight; + + /// Whether the live pass actually carries a depth attachment to clear. + internal bool HasDepthAttachment => _hasDepthAttachment; + private VulkanGpuPipeline? _pipeline; private bool _closed; @@ -48,6 +63,7 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder _frame = frame; _commands = commands; _bindings = bindings; + _attachmentWidth = attachmentWidth; _attachmentHeight = attachmentHeight; // Slice V6g: what vkCmdBeginRendering was actually handed, not what the // description asked for. A backbuffer pass that requests depth before diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanWorldPassScope.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanWorldPassScope.cs new file mode 100644 index 00000000..29bde055 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanWorldPassScope.cs @@ -0,0 +1,121 @@ +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6j: the Vulkan arm of . +/// +/// opens the frame's one backbuffer +/// pass, publishes the encoder here, and calls the real +/// WorldSceneRenderer inside the publication. Every world renderer and +/// both pass executors borrow the encoder from here instead of opening a pass — +/// see for why that is structural on this +/// backend rather than an economy. +/// +/// The scope owns nothing. It borrows the encoder for exactly the span of +/// one world phase and forgets it on exit, so a renderer reached outside that +/// span fails loudly rather than recording into a closed command buffer. +/// +internal sealed unsafe class VulkanWorldPassScope : IWorldPassScope +{ + private readonly Publication _publication; + private IGpuPassEncoder? _encoder; + + internal VulkanWorldPassScope(int sampleCount) + { + if (sampleCount < 1) + throw new ArgumentOutOfRangeException(nameof(sampleCount)); + SampleCount = sampleCount; + _publication = new Publication(this); + } + + public int SampleCount { get; } + + public WorldFrameSections Sections { get; } = new(); + + public IGpuPassEncoder? CurrentEncoder => _encoder; + + public int AttachmentWidth => + _encoder is VulkanGpuPassEncoder vulkan ? vulkan.AttachmentWidth : 0; + + public int AttachmentHeight => + _encoder is VulkanGpuPassEncoder vulkan ? vulkan.AttachmentHeight : 0; + + public IGpuPassEncoder RequireEncoder() => + _encoder ?? throw new InvalidOperationException( + "The Vulkan world renderers record into the pass VulkanWorldScenePhase opens; " + + "no pass is open. The phase must bracket every world draw."); + + /// + /// Publishes for the duration of the returned + /// scope. Re-entry is a composition error: the backend permits one open pass + /// per frame, so a nested publication could only mean two phases believe they + /// own the frame. + /// + internal IDisposable Publish(IGpuPassEncoder encoder) + { + ArgumentNullException.ThrowIfNull(encoder); + if (_encoder is not null) + { + throw new InvalidOperationException( + "A Vulkan world pass is already published; passes do not nest."); + } + + _encoder = encoder; + Sections.Reset(); + return _publication; + } + + /// + /// Records vkCmdClearAttachments for the depth aspect over the whole + /// attachment — retail's interior depth clear, which has no RHI verb because + /// every other clear in the design is a pass load-op. + /// + /// Silently does nothing when the live pass carries no depth + /// attachment: vkCmdClearAttachments naming an absent aspect is + /// undefined, and a depth-less world pass has nothing to clear anyway. + /// + public void ClearInteriorDepth() + { + if (RequireEncoder() is not VulkanGpuPassEncoder encoder) + { + throw new InvalidOperationException( + "The Vulkan world pass scope was published with a non-Vulkan encoder."); + } + + if (!encoder.HasDepthAttachment) + return; + + var attachment = new ClearAttachment + { + AspectMask = ImageAspectFlags.DepthBit, + ColorAttachment = 0, + ClearValue = new ClearValue + { + DepthStencil = new ClearDepthStencilValue(depth: 1f, stencil: 0), + }, + }; + var rect = new ClearRect + { + Rect = new Rect2D( + new Offset2D(0, 0), + new Extent2D((uint)encoder.AttachmentWidth, (uint)encoder.AttachmentHeight)), + BaseArrayLayer = 0, + LayerCount = 1, + }; + encoder.ClearAttachments(1, &attachment, 1, &rect); + } + + private sealed class Publication : IDisposable + { + private readonly VulkanWorldPassScope _owner; + + internal Publication(VulkanWorldPassScope owner) => _owner = owner; + + public void Dispose() + { + _owner._encoder = null; + _owner.Sections.Reset(); + } + } +} diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index 992c3212..0a9966fc 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -110,14 +110,18 @@ internal interface IRenderFrameEntityPassExecutor void AbortEntityFrame(); } +/// +/// Campaign V slice V6j: backend-neutral. The order it implements is retail's and +/// is written once; the four places it touches graphics state directly are owned +/// by . +/// internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor, IRenderFrameEntityPassExecutor, IOutdoorSceneParticleOwnerSource { - private readonly GL _gl; + private readonly IWorldPassSurface _surface; private readonly IRenderFrameGlState _frameGlState; - private readonly IRetailPViewFramebufferSource _framebuffer; private readonly ClipFrame _clipFrame; private readonly TerrainModernRenderer? _terrain; private readonly EnvCellRenderer _envCells; @@ -140,9 +144,8 @@ internal sealed class RetailPViewPassExecutor : _particleClassifications.Outdoor; public RetailPViewPassExecutor( - GL gl, + IWorldPassSurface surface, IRenderFrameGlState frameGlState, - IRetailPViewFramebufferSource framebuffer, ClipFrame clipFrame, TerrainModernRenderer? terrain, EnvCellRenderer envCells, @@ -155,11 +158,9 @@ internal sealed class RetailPViewPassExecutor : WorldRenderDiagnostics diagnostics, TerrainDrawDiagnosticsController terrainDiagnostics) { - _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _surface = surface ?? throw new ArgumentNullException(nameof(surface)); _frameGlState = frameGlState ?? throw new ArgumentNullException(nameof(frameGlState)); - _framebuffer = framebuffer - ?? throw new ArgumentNullException(nameof(framebuffer)); _clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame)); _terrain = terrain; _envCells = envCells ?? throw new ArgumentNullException(nameof(envCells)); @@ -230,22 +231,11 @@ internal sealed class RetailPViewPassExecutor : ClipFrameAssembly reuseAssembly) => ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly); - public void PrepareClipFrame(int terrainUploadCount) - { - // Allocate every terrain record before issuing the first draw. BufferData - // must not replace the arena while an earlier slice can reference it. - _clipFrame.ReserveTerrainUploads(_gl, terrainUploadCount); - _clipFrame.UploadRegions(_gl); - _entities.SetClipRegionSsbo(_clipFrame.RegionSsbo); - _envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo); - UploadTerrainClip(); - } + public void PrepareClipFrame(int terrainUploadCount) => + _surface.PrepareClipFrame(terrainUploadCount); - public void SetTerrainClip(ReadOnlySpan planes) - { - _clipFrame.SetTerrainClip(planes); - UploadTerrainClip(); - } + public void SetTerrainClip(ReadOnlySpan planes) => + _surface.SetTerrainClip(planes); public void ClearClipRouting() => _entities.ClearClipRouting(); @@ -355,7 +345,7 @@ internal sealed class RetailPViewPassExecutor : scissor, slice.NdcAabb); - _clipFrame.BindTerrainClip(_gl); + _surface.BindTerrainClip(); EnableClipDistances(); if (frame.RenderSky) { @@ -417,7 +407,7 @@ internal sealed class RetailPViewPassExecutor : } if (scissor) - _gl.Disable(EnableCap.ScissorTest); + _surface.EndScissor(); DisableClipDistances(); } @@ -427,7 +417,7 @@ internal sealed class RetailPViewPassExecutor : { ClipViewSlice slice = context.Slice; bool scissor = BeginDoorwayScissor(slice.NdcAabb); - _clipFrame.BindTerrainClip(_gl); + _surface.BindTerrainClip(); DisableClipDistances(); if (context.EntityDraw is RenderFrameEntityDrawRequest request) @@ -502,16 +492,11 @@ internal sealed class RetailPViewPassExecutor : } if (scissor) - _gl.Disable(EnableCap.ScissorTest); + _surface.EndScissor(); DisableClipDistances(); } - public void ClearInteriorDepth() - { - _gl.Disable(EnableCap.ScissorTest); - _gl.DepthMask(true); - _gl.Clear(ClearBufferMask.DepthBufferBit); - } + public void ClearInteriorDepth() => _surface.ClearInteriorDepth(); public void DrawExitPortalMask( RetailPViewFrameInput frame, @@ -603,12 +588,6 @@ internal sealed class RetailPViewPassExecutor : frame.CameraView, frame.CameraCellResolution); - private void UploadTerrainClip() - { - TerrainClipBufferBinding binding = _clipFrame.UploadTerrainClip(_gl); - _terrain?.SetClipUbo(binding); - } - private void DrawPortalDepthWrite( RetailPViewCellSliceContext context, RetailPViewFrameInput frame, @@ -659,27 +638,10 @@ internal sealed class RetailPViewPassExecutor : } } - private bool BeginDoorwayScissor(Vector4 ndcAabb) - { - RetailPViewFramebufferSize framebuffer = _framebuffer.Capture(); - var box = NdcScissorRect.ToPixels( - ndcAabb, - framebuffer.Width, - framebuffer.Height); - _gl.Enable(EnableCap.ScissorTest); - _gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height); - return true; - } + private bool BeginDoorwayScissor(Vector4 ndcAabb) => + _surface.BeginScissor(ndcAabb); - private void EnableClipDistances() - { - for (int index = 0; index < ClipFrame.MaxPlanes; index++) - _gl.Enable(EnableCap.ClipDistance0 + index); - } + private void EnableClipDistances() => _surface.EnableClipDistances(); - private void DisableClipDistances() - { - for (int index = 0; index < ClipFrame.MaxPlanes; index++) - _gl.Disable(EnableCap.ClipDistance0 + index); - } + private void DisableClipDistances() => _surface.DisableClipDistances(); } diff --git a/src/AcDream.App/Rendering/SceneLightingUboBinding.cs b/src/AcDream.App/Rendering/SceneLightingUboBinding.cs index 72b02277..57e20d1e 100644 --- a/src/AcDream.App/Rendering/SceneLightingUboBinding.cs +++ b/src/AcDream.App/Rendering/SceneLightingUboBinding.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Wb; using AcDream.Core.Lighting; using Silk.NET.OpenGL; @@ -23,7 +24,9 @@ namespace AcDream.App.Rendering; /// public sealed unsafe class SceneLightingUboBinding : IDisposable { - private readonly GL _gl; + private readonly GL? _gl; + private readonly ICurrentGpuFrameSource? _frames; + private readonly WorldFrameSections? _sections; private uint _ubo; private readonly List[] _buffersByFrame = [[], [], []]; private int _frameSlot; @@ -38,6 +41,29 @@ public sealed unsafe class SceneLightingUboBinding : IDisposable _gl = gl ?? throw new ArgumentNullException(nameof(gl)); } + /// + /// Campaign V slice V6j: the RHI arm. + /// + /// GL keeps the block bound at a global uniform binding point and every + /// shader inherits it. Vulkan has no such point — a descriptor set is bound + /// per draw and the renderer's own binds select the scope this block has to + /// land in — so the upload becomes a frame ring slice PUBLISHED on + /// , and each world renderer + /// binds it inside the pass after its own binds (plan §5.5.14 item 2). + /// + /// The per-flight-slot buffer pool disappears with it: every allocation + /// within a frame is already distinct memory that lives until the frame + /// retires, which is the property the pool existed to provide when the world, + /// portal-space and paperdoll draws each upload different lighting. + /// + internal SceneLightingUboBinding( + ICurrentGpuFrameSource frames, + WorldFrameSections sections) + { + _frames = frames ?? throw new ArgumentNullException(nameof(frames)); + _sections = sections ?? throw new ArgumentNullException(nameof(sections)); + } + /// /// Resets the lighting-submission cursor for a GPU-fenced frame slot. /// World, portal-space, and paperdoll draws can each upload different @@ -57,6 +83,8 @@ public sealed unsafe class SceneLightingUboBinding : IDisposable { if (!_frameStarted) throw new InvalidOperationException("BeginFrame must be called before uploading scene lighting."); + if (_gl is null) + throw new InvalidOperationException("The RHI arm publishes a ring section rather than a buffer."); List buffers = _buffersByFrame[_frameSlot]; if (_bufferCursor == buffers.Count) @@ -96,6 +124,12 @@ public sealed unsafe class SceneLightingUboBinding : IDisposable /// public void Upload(SceneLightingUbo data) { + if (_gl is null) + { + PublishSection(data); + return; + } + ActivateNextBuffer(); _gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo); _gl.BufferSubData(BufferTargetARB.UniformBuffer, @@ -105,20 +139,42 @@ public sealed unsafe class SceneLightingUboBinding : IDisposable _gl.BindBuffer(BufferTargetARB.UniformBuffer, 0); } + private void PublishSection(SceneLightingUbo data) + { + if (!_frameStarted) + throw new InvalidOperationException("BeginFrame must be called before uploading scene lighting."); + + IGpuFrame frame = _frames!.CurrentFrame + ?? throw new InvalidOperationException( + "Scene lighting requires an open IGpuFrame (see GpuDeviceFrameLifetime)."); + GpuRingAllocation allocation = frame.AllocateRing( + SceneLightingUbo.SizeInBytes, + GpuRingUsage.Uniform); + new ReadOnlySpan(&data, SceneLightingUbo.SizeInBytes) + .CopyTo(allocation.Data); + _sections!.SceneLighting = new GpuBufferSection( + allocation.Buffer, + allocation.OffsetBytes, + (uint)SceneLightingUbo.SizeInBytes); + } + public void Dispose() { if (_disposed) return; - foreach (List buffers in _buffersByFrame) + if (_gl is not null) { - foreach (uint buffer in buffers) + foreach (List buffers in _buffersByFrame) { - TrackedGlResource.DeleteBuffer( - _gl, - buffer, - SceneLightingUbo.SizeInBytes, - "SceneLighting frame UBO disposal"); + foreach (uint buffer in buffers) + { + TrackedGlResource.DeleteBuffer( + _gl, + buffer, + SceneLightingUbo.SizeInBytes, + "SceneLighting frame UBO disposal"); + } + buffers.Clear(); } - buffers.Clear(); } _disposed = true; } diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs new file mode 100644 index 00000000..ed06b37c --- /dev/null +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs @@ -0,0 +1,332 @@ +using System.Collections.Immutable; +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Wb; +using AcDream.Core.Terrain; + +namespace AcDream.App.Rendering; + +/// +/// Campaign V slice V6j: terrain's RHI submission arm. +/// +/// This is V4d-2's content, re-landed as a SECOND arm rather than as a +/// replacement. §5.5.6 selected option (B) after NVIDIA rendered the V4c binary +/// 10/10 and AMD's GL stack did not: GL keeps its raw world path through to V10 +/// as a documented, scoped fork confined to the submission seam, and the RHI +/// world path ships on Vulkan. So every GL statement in the sibling file is +/// untouched, and everything here runs only when there is no GL context. +/// +/// Three things differ from V4d-2, each because the tree moved under it. +/// The texture slots come from TerrainAtlas's device table (V4t) rather +/// than a per-renderer bindless table, so there is no binding-9 table to bind at +/// all — the Vulkan texture table is set 2 and the encoder binds it. The tiling +/// block is the shared TerrainTextureTilingTable constants (V6f-2) rather +/// than locals. And the pass is BORROWED from +/// rather than opened, because the frame's one backbuffer pass resolves and a +/// second pass could not load what it left. +/// +public sealed unsafe partial class TerrainModernRenderer +{ + /// + /// Terrain's vertex layout: the same 40-byte record ConfigureVao + /// describes with glVertexAttribPointer/glVertexAttribIPointer. + /// + /// Locations 2–5 are , not + /// UByte4Normalized. They are uvec4 in the shader and carry + /// terrain-type, road and split-direction codes; normalising them would not + /// be an approximation, it would be garbage. + /// + private static readonly GpuVertexLayout TerrainVertexLayout = new( + StrideBytes: VertexSize, + ImmutableArray.Create( + new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0), + new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12), + new GpuVertexAttribute(2, GpuVertexFormat.UByte4UInt, 24), + new GpuVertexAttribute(3, GpuVertexFormat.UByte4UInt, 28), + new GpuVertexAttribute(4, GpuVertexFormat.UByte4UInt, 32), + new GpuVertexAttribute(5, GpuVertexFormat.UByte4UInt, 36))); + + private readonly IGpuDevice? _device; + private readonly ICurrentGpuFrameSource? _frames; + private readonly IWorldPassScope? _scope; + private IGpuPipeline? _pipeline; + private IGpuBuffer? _vertexStore; + private IGpuBuffer? _indexStore; + private IGpuBuffer? _tilingBuffer; + + /// + /// The RHI arm's constructor. No GL context, no Shader, no + /// BindlessSupport: the pipeline compiles terrain_modern from + /// the committed SPIR-V and the atlas's slots index the device's one table. + /// + internal TerrainModernRenderer( + IGpuDevice device, + ICurrentGpuFrameSource frames, + IWorldPassScope scope, + TerrainAtlas atlas, + IGpuResourceRetirementQueue resourceRetirement, + int initialSlotCapacity = 64) + { + _device = device ?? throw new ArgumentNullException(nameof(device)); + _frames = frames ?? throw new ArgumentNullException(nameof(frames)); + _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + _atlas = atlas ?? throw new ArgumentNullException(nameof(atlas)); + ArgumentNullException.ThrowIfNull(resourceRetirement); + _retirementLedger = new GpuRetirementLedger(resourceRetirement); + _alloc = new GpuRetiredTerrainSlotAllocator(initialSlotCapacity, resourceRetirement); + _slots = new SlotData?[initialSlotCapacity]; + + _pipeline = device.CreatePipeline(new GpuPipelineDescription + { + Name = "terrain", + Shaders = new GpuShaderSet("terrain_modern"), + VertexLayout = TerrainVertexLayout, + Topology = GpuPrimitiveTopology.TriangleList, + Blend = GpuBlendMode.None, + // GL_LESS, not the contract's LessOrEqual default: the world frame + // runs under GL_LESS and terrain never called glDepthFunc, so it + // inherited it. LessOrEqual would change which of two coplanar retail + // surfaces wins — visible exactly where terrain meets roads and + // building footings, which is what zFightTerrainAdjust is about. + Depth = new GpuDepthState(Test: true, Write: true, GpuCompareOp.Less), + // #108-residual: retail terrain is SINGLE-SIDED. See the GL arm's + // Draw for the full reasoning; this bakes the same triple. + Cull = GpuCullMode.Back, + FrontFace = GpuFrontFace.CounterClockwise, + AlphaToCoverage = false, + ColorWrite = true, + SampleCount = scope.SampleCount, + }); + AllocateRhiBuffers(initialSlotCapacity); + } + + private void AllocateRhiBuffers(int capacitySlots) + { + long vertexBytes = checked((long)capacitySlots * VertsPerLandblock * VertexSize); + long indexBytes = checked((long)capacitySlots * IndicesPerLandblock * IndexSize); + IGpuDevice device = RequireDevice(); + _vertexStore = device.CreateBuffer(new GpuBufferDescription( + "terrain-vertices", + vertexBytes, + GpuBufferUsage.Vertex + | GpuBufferUsage.TransferSource + | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.DeviceLocal)); + _globalVboCapacityBytes = vertexBytes; + _indexStore = device.CreateBuffer(new GpuBufferDescription( + "terrain-indices", + indexBytes, + GpuBufferUsage.Index + | GpuBufferUsage.TransferSource + | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.DeviceLocal)); + _globalEboCapacityBytes = indexBytes; + } + + /// + /// Grow-and-copy, device-side. keeps resident + /// landblock meshes from round-tripping through system memory, exactly as the + /// GL arm's glCopyBufferSubData does. + /// + private void EnsureRhiCapacity(int newCapacitySlots) + { + if (newCapacitySlots <= _alloc.Capacity) + return; + + long vertexBytes = checked((long)newCapacitySlots * VertsPerLandblock * VertexSize); + long indexBytes = checked((long)newCapacitySlots * IndicesPerLandblock * IndexSize); + IGpuDevice device = RequireDevice(); + IGpuBuffer oldVertices = RequireVertexStore(); + IGpuBuffer oldIndices = RequireIndexStore(); + + IGpuBuffer newVertices = device.CreateBuffer(new GpuBufferDescription( + "terrain-vertices", + vertexBytes, + GpuBufferUsage.Vertex + | GpuBufferUsage.TransferSource + | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.DeviceLocal)); + IGpuBuffer newIndices; + try + { + newIndices = device.CreateBuffer(new GpuBufferDescription( + "terrain-indices", + indexBytes, + GpuBufferUsage.Index + | GpuBufferUsage.TransferSource + | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.DeviceLocal)); + } + catch + { + newVertices.Dispose(); + throw; + } + + oldVertices.CopyTo(newVertices, 0, 0, _globalVboCapacityBytes); + oldIndices.CopyTo(newIndices, 0, 0, _globalEboCapacityBytes); + + _vertexStore = newVertices; + _indexStore = newIndices; + _globalVboCapacityBytes = vertexBytes; + _globalEboCapacityBytes = indexBytes; + + // Dispose routes the physical free through the device's retirement queue, + // so the old arena outlives every frame that can still reference it. + oldVertices.Dispose(); + oldIndices.Dispose(); + + var grownSlots = new SlotData?[newCapacitySlots]; + Array.Copy(_slots, grownSlots, _slots.Length); + _slots = grownSlots; + _alloc.GrowTo(newCapacitySlots); + } + + private void UploadRhiLandblock( + int slot, + TerrainVertex[] bakedVerts, + uint[] bakedIndices) + { + RequireVertexStore().Upload( + (long)slot * VertsPerLandblock * VertexSize, + MemoryMarshal.AsBytes(bakedVerts)); + RequireIndexStore().Upload( + (long)slot * IndicesPerLandblock * IndexSize, + MemoryMarshal.AsBytes(bakedIndices)); + } + + /// + /// Records terrain's multi-draw into the borrowed world pass. + /// + /// Order matters twice. BindPipeline re-issues the pipeline's own + /// cull/front-face/depth-write defaults, so anything dynamic has to come + /// after it. And the frame-global sections — SceneLighting and the terrain + /// clip block — are bound HERE, after this renderer's own binds, because its + /// own binds are what select the descriptor scope those sections must land in + /// (plan §5.5.14 item 2). + /// + private void DrawRhi(Matrix4x4 viewProjection, int drawCount) + { + IWorldPassScope scope = _scope!; + IGpuPassEncoder encoder = scope.RequireEncoder(); + IGpuFrame frame = _frames!.CurrentFrame + ?? throw new InvalidOperationException( + "TerrainModernRenderer requires an open IGpuFrame (see GpuDeviceFrameLifetime)."); + + // V6i-2's backend-neutral atlas registers both slots at construction, so + // there is no per-draw acquire-and-reregister step and no binding-9 table + // to flush — the Vulkan texture table is set 2 and the encoder binds it. + (GpuTextureSlot terrainSlot, GpuTextureSlot alphaSlot) = _atlas.TextureSlots; + + var pushConstants = new GpuPushConstants + { + ViewProjection = viewProjection, + DrawIdOffset = 0, + LightingMode = 0, + RenderPass = 0, + LightDebug = 0, + TextureIndexA = terrainSlot.Index, + TextureIndexB = alphaSlot.Index, + ParamA = 0f, + ParamB = 0f, + }; + + encoder.BindPipeline(_pipeline!); + encoder.SetPushConstants(in pushConstants); + encoder.BindVertexBuffer(RequireVertexStore(), 0); + encoder.BindIndexBuffer(RequireIndexStore(), 0, GpuIndexType.UInt32); + BindTilingTable(encoder); + WorldFrameSectionBinding.BindSceneLighting(encoder, scope.Sections, frame); + WorldFrameSectionBinding.BindTerrainClip(encoder, scope.Sections, frame); + + GpuRingAllocation commands = frame.AllocateRing( + drawCount * sizeof(DrawElementsIndirectCommand), + GpuRingUsage.Indirect); + MemoryMarshal.AsBytes(_deicScratch.AsSpan(0, drawCount)) + .CopyTo(commands.Data); + encoder.MultiDrawIndexedIndirect( + commands.Buffer, + commands.OffsetBytes, + (uint)drawCount, + (uint)sizeof(DrawElementsIndirectCommand)); + } + + /// + /// Binds the immutable 36-entry tiling table. Long-lived and written once, so + /// its range never moves — which also keeps it out of the descriptor-scope + /// key's moving parts. + /// + private void BindTilingTable(IGpuPassEncoder encoder) + { + if (_tilingBuffer is null) + { + if (_atlas.TilingByLayer.Count != TerrainTextureTilingTable.LayerCapacity) + { + throw new InvalidOperationException( + $"Terrain tiling table has {_atlas.TilingByLayer.Count} entries; " + + $"expected {TerrainTextureTilingTable.LayerCapacity}."); + } + + Span block = stackalloc byte[TerrainTextureTilingTable.UniformBufferBytes]; + block.Clear(); + for (int i = 0; i < TerrainTextureTilingTable.LayerCapacity; i++) + { + BitConverter.TryWriteBytes( + block[(i * TerrainTextureTilingTable.UniformElementStrideBytes)..], + _atlas.TilingByLayer[i]); + } + + IGpuBuffer buffer = RequireDevice().CreateBuffer(new GpuBufferDescription( + "terrain-tiling", + TerrainTextureTilingTable.UniformBufferBytes, + GpuBufferUsage.Uniform | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.DeviceLocal)); + try + { + buffer.Upload(0, block); + } + catch + { + buffer.Dispose(); + throw; + } + _tilingBuffer = buffer; + _textureTilingUploaded = true; + } + + encoder.BindUniformBuffer( + GpuBindingModel.UniformTerrainTiling, + _tilingBuffer, + 0, + TerrainTextureTilingTable.UniformBufferBytes); + } + + private IGpuDevice RequireDevice() => + _device ?? throw new InvalidOperationException( + "TerrainModernRenderer's RHI arm was reached without an IGpuDevice."); + + private IGpuBuffer RequireVertexStore() => + _vertexStore ?? throw new InvalidOperationException( + "The terrain vertex arena has not been created."); + + private IGpuBuffer RequireIndexStore() => + _indexStore ?? throw new InvalidOperationException( + "The terrain index arena has not been created."); + + private void DisposeRhi() + { + _pipeline?.Dispose(); + _pipeline = null; + _tilingBuffer?.Dispose(); + _tilingBuffer = null; + _vertexStore?.Dispose(); + _vertexStore = null; + _indexStore?.Dispose(); + _indexStore = null; + _globalVboCapacityBytes = 0; + _globalEboCapacityBytes = 0; + _dynamicFrameStarted = false; + _disposed = true; + } +} diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.cs index 4d710233..e28c3a2f 100644 --- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs @@ -17,7 +17,7 @@ namespace AcDream.App.Rendering; /// Total ~6-8 GL calls per frame for terrain regardless of visible /// landblock count. /// -public sealed unsafe class TerrainModernRenderer : IDisposable +public sealed unsafe partial class TerrainModernRenderer : IDisposable { // VertsPerLandblock MUST stay divisible by 6 — terrain_modern.vert uses // `gl_VertexID % 6` to pick the cell-corner index (BL/BR/TR/TL), and @@ -32,9 +32,12 @@ public sealed unsafe class TerrainModernRenderer : IDisposable private const int IndexSize = sizeof(uint); private const float LandblockSize = LandblockMesh.LandblockSize; // 192 - private readonly GL _gl; - private readonly BindlessSupport _bindless; - private readonly Shader _shader; + // Campaign V slice V6j: null on the RHI arm, where every statement below that + // reaches one of these is forked into TerrainModernRenderer.Rhi.cs. The GL arm + // executes exactly what it did before. + private readonly GL? _gl; + private readonly BindlessSupport? _bindless; + private readonly Shader? _shader; private readonly TerrainAtlas _atlas; /// A.5 T22.5: exposes the terrain atlas so callers can update @@ -264,7 +267,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable if (_dynamicBufferCursor == frameBuffers.Count) { uint buffer = TrackedGlResource.CreateBuffer( - _gl, + _gl!, $"creating terrain indirect buffer for frame slot {_dynamicFrameSlot}"); try { @@ -273,7 +276,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable catch { TrackedGlResource.DeleteBuffer( - _gl, + _gl!, buffer, 0, "rolling back terrain indirect buffer"); @@ -363,6 +366,12 @@ public sealed unsafe class TerrainModernRenderer : IDisposable nint vboByteOffset = (nint)(slot * VertsPerLandblock * VertexSize); nint eboByteOffset = (nint)(slot * IndicesPerLandblock * IndexSize); + if (_gl is null) + { + UploadRhiLandblock(slot, bakedVerts, bakedIndices); + } + else + { fixed (TerrainVertex* p = bakedVerts) { TrackedGlResource.UpdateBufferSubData( @@ -386,6 +395,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable p, $"uploading terrain indices for 0x{landblockId:X8}"); } + } _slots[slot] = new SlotData { @@ -461,24 +471,23 @@ public sealed unsafe class TerrainModernRenderer : IDisposable ndcClipAabb); } if (_visibleSlots.Count == 0) return; - ActivateNextIndirectBuffer(); - // Build DEIC array. - if (_deicScratch.Length < _visibleSlots.Count) - _deicScratch = new DrawElementsIndirectCommand[Math.Max(_visibleSlots.Count, 64)]; - for (int i = 0; i < _visibleSlots.Count; i++) + // Campaign V slice V6j: the command array is built the same way on both + // arms; only where it lands differs. The RHI arm writes it into a frame + // ring slice, which retires the per-frame-slot indirect buffer pool + // structurally — every allocation within a frame is already distinct + // memory that outlives the draw recorded against it. + BuildIndirectCommands(); + if (_gl is null) { - var data = _slots[_visibleSlots[i]]!; - _deicScratch[i] = new DrawElementsIndirectCommand - { - Count = (uint)data.IndexCount, - InstanceCount = 1u, - FirstIndex = data.FirstIndex, - BaseVertex = 0, // baked into indices on upload - BaseInstance = 0, - }; + if (!_dynamicFrameStarted) + throw new InvalidOperationException("BeginFrame must be called before drawing terrain."); + DrawRhi(viewProjection, _visibleSlots.Count); + return; } + ActivateNextIndirectBuffer(); + // Grow indirect buffer if needed. if (_visibleSlots.Count > _indirectCapacity) { @@ -515,7 +524,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable // renderers in the unified pipeline. Retail's LScape::update_viewpoint // pre-positions terrain to the outdoor landcell, but acdream uses the // unified camera matrix everywhere, so no separate viewpoint divergence can occur. - _shader.Use(); + _shader!.Use(); UploadTextureTilingOnce(); // Campaign V slice V6f-2: bind the tiling UBO every draw, not once. GL's // uniform-buffer binding points are global and shared with the sky's @@ -582,11 +591,38 @@ public sealed unsafe class TerrainModernRenderer : IDisposable _gl.Disable(EnableCap.CullFace); } + /// + /// Builds this frame's DrawElementsIndirectCommand array from the + /// visible slot list. Pure CPU, identical on both arms. + /// + private void BuildIndirectCommands() + { + if (_deicScratch.Length < _visibleSlots.Count) + _deicScratch = new DrawElementsIndirectCommand[Math.Max(_visibleSlots.Count, 64)]; + for (int i = 0; i < _visibleSlots.Count; i++) + { + var data = _slots[_visibleSlots[i]]!; + _deicScratch[i] = new DrawElementsIndirectCommand + { + Count = (uint)data.IndexCount, + InstanceCount = 1u, + FirstIndex = data.FirstIndex, + BaseVertex = 0, // baked into indices on upload + BaseInstance = 0, + }; + } + } + public void Dispose() { if (_disposed) return; _retirementLedger.RetryPendingPublications(); + if (_gl is null) + { + DisposeRhi(); + return; + } if (_disposeResources is null) { @@ -727,7 +763,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable fixed (byte* p = block) { TrackedGlResource.UpdateBufferSubData( - _gl, + _gl!, BufferTargetARB.UniformBuffer, _tilingUbo, 0, @@ -764,7 +800,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable { GlGpuDevice device = GpuDevice; device.FlushTextureTable(); - _gl.BindBufferBase( + _gl!.BindBufferBase( GLEnum.ShaderStorageBuffer, GpuBindingModel.StorageTextureTable, device.TextureTableGlName); @@ -781,7 +817,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable { if (_sharedClipBinding.IsValid) { - _sharedClipBinding.Bind(_gl); + _sharedClipBinding.Bind(_gl!); return; } @@ -790,12 +826,12 @@ public sealed unsafe class TerrainModernRenderer : IDisposable var zero = stackalloc byte[ClipFrame.TerrainUboBytes]; for (int i = 0; i < ClipFrame.TerrainUboBytes; i++) zero[i] = 0; uint fallback = TrackedGlResource.CreateBuffer( - _gl, + _gl!, "creating terrain fallback clip UBO"); try { TrackedGlResource.AllocateBufferStorage( - _gl, + _gl!, BufferTargetARB.UniformBuffer, fallback, 0, @@ -808,14 +844,14 @@ public sealed unsafe class TerrainModernRenderer : IDisposable catch { TrackedGlResource.DeleteBuffer( - _gl, + _gl!, fallback, 0, "rolling back terrain fallback clip UBO"); throw; } } - _gl.BindBufferBase(BufferTargetARB.UniformBuffer, + _gl!.BindBufferBase(BufferTargetARB.UniformBuffer, ClipFrame.TerrainClipUboBinding, _fallbackClipUbo); } @@ -825,7 +861,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable long eboBytes = checked((long)capacitySlots * IndicesPerLandblock * IndexSize); TrackedGlResource.AllocateBufferStorage( - _gl, + _gl!, BufferTargetARB.ArrayBuffer, _globalVbo, _globalVboCapacityBytes, @@ -835,7 +871,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable _globalVboCapacityBytes = vboBytes; TrackedGlResource.AllocateBufferStorage( - _gl, + _gl!, BufferTargetARB.ElementArrayBuffer, _globalEbo, _globalEboCapacityBytes, @@ -847,7 +883,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable private void ConfigureVao(uint vao, uint vbo, uint ebo) { - _gl.BindVertexArray(vao); + _gl!.BindVertexArray(vao); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo); _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo); @@ -1002,6 +1038,11 @@ public sealed unsafe class TerrainModernRenderer : IDisposable { if (newCapacity <= _alloc.Capacity) return; + if (_gl is null) + { + EnsureRhiCapacity(newCapacity); + return; + } var grownSlots = new SlotData?[newCapacity]; Array.Copy(_slots, grownSlots, _slots.Length); diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs new file mode 100644 index 00000000..ab9f22f9 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs @@ -0,0 +1,326 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; +using AcDream.Core.Lighting; +using DatReaderWriter.Enums; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Campaign V slice V6j: the dungeon-shell renderer's RHI submission arm. +/// +/// V4c's content, re-landed as a second arm rather than a replacement — +/// see 's RHI file for +/// why the fork exists and where it is confined. +/// +/// Two structural differences from V4c. It records into the pass +/// VulkanWorldScenePhase opened rather than opening +/// "envcell-shells" of its own, because the frame's one backbuffer pass +/// resolves. And there is no binding-9 texture table: V4t moved the slot onto +/// the device, and on Vulkan that table is set 2, which the encoder binds. +/// +public sealed unsafe partial class EnvCellRenderer +{ + private readonly IGpuDevice? _device; + private readonly ICurrentGpuFrameSource? _frames; + private readonly IWorldPassScope? _scope; + private IGpuPipeline? _opaquePipeline; + private IGpuPipeline? _alphaPipeline; + private IGpuPipeline? _additivePipeline; + + /// + /// The RHI arm's constructor. It also completes Initialize's job: the + /// three pipelines ARE this renderer's program, so there is no second step + /// and no Shader to hand in. + /// + internal EnvCellRenderer( + IGpuDevice device, + ICurrentGpuFrameSource frames, + IWorldPassScope scope, + ObjectMeshManager meshManager, + WbFrustum frustum) + { + _device = device ?? throw new ArgumentNullException(nameof(device)); + _frames = frames ?? throw new ArgumentNullException(nameof(frames)); + _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + _meshManager = meshManager ?? throw new ArgumentNullException(nameof(meshManager)); + _frustum = frustum ?? throw new ArgumentNullException(nameof(frustum)); + + _opaquePipeline = CreateShellPipeline( + device, "envcell-opaque", GpuBlendMode.None, depthWrite: true, scope.SampleCount); + _alphaPipeline = CreateShellPipeline( + device, "envcell-alpha", GpuBlendMode.StraightAlpha, depthWrite: false, scope.SampleCount); + _additivePipeline = CreateShellPipeline( + device, "envcell-additive", GpuBlendMode.Additive, depthWrite: false, scope.SampleCount); + _initialized = true; + } + + /// + /// One pipeline per blend state the shell pass uses. Everything else is + /// shared: mesh_modern, the 32-byte world-mesh vertex, triangle lists, + /// back-face culling with clockwise front faces. + /// + /// Depth compare is Less, not the contract's LessOrEqual + /// default. The world frame runs under GL_LESS and this renderer never + /// called glDepthFunc, so it inherited it; baking LessOrEqual + /// would change which of two coplanar retail surfaces wins. + /// + private static IGpuPipeline CreateShellPipeline( + IGpuDevice device, + string name, + GpuBlendMode blend, + bool depthWrite, + int sampleCount) => + device.CreatePipeline(new GpuPipelineDescription + { + Name = name, + Shaders = new GpuShaderSet("mesh_modern"), + VertexLayout = GpuVertexLayout.WorldMesh, + Topology = GpuPrimitiveTopology.TriangleList, + Blend = blend, + Depth = new GpuDepthState(Test: true, Write: depthWrite, GpuCompareOp.Less), + Cull = GpuCullMode.Back, + FrontFace = GpuFrontFace.Clockwise, + AlphaToCoverage = false, + ColorWrite = true, + SampleCount = sampleCount, + }); + + /// + /// Writes this pass's sections into the frame ring and records the same + /// per-group multi-draw runs the GL arm issues, in the same order. + /// + private void SubmitRhi( + List allInstances, + WbRenderPass renderPass, + int totalDraws, + int uniqueInstanceCount) + { + IWorldPassScope scope = _scope!; + IGpuPassEncoder encoder = scope.RequireEncoder(); + IGpuFrame frame = _frames!.CurrentFrame + ?? throw new InvalidOperationException( + "EnvCellRenderer requires an open IGpuFrame (see GpuDeviceFrameLifetime)."); + GlobalMeshBuffer mesh = _meshManager.GlobalBuffer + ?? throw new InvalidOperationException("The shared mesh arena is not published."); + + if (_gpuInstanceTransforms.Length < uniqueInstanceCount) + { + Array.Resize( + ref _gpuInstanceTransforms, + Math.Max(_gpuInstanceTransforms.Length * 2, uniqueInstanceCount)); + } + for (int i = 0; i < uniqueInstanceCount; i++) + _gpuInstanceTransforms[i] = allInstances[i].Transform; + + // Phase U.4: per-instance clip slots, laid out parallel to the transforms + // so instanceClipSlot[BaseInstance + gl_InstanceID] tracks Instances[]. + if (_clipSlotData.Length < uniqueInstanceCount) + _clipSlotData = new uint[Math.Max(_clipSlotData.Length * 2, uniqueInstanceCount)]; + if (_cellIdToSlot is null + || AcDream.Core.Rendering.RenderingDiagnostics.ClipDebugNoShellTrim) + { + Array.Clear(_clipSlotData, 0, uniqueInstanceCount); + } + else + { + for (int i = 0; i < uniqueInstanceCount; i++) + { + _clipSlotData[i] = + _cellIdToSlot.TryGetValue(allInstances[i].CellId, out int slot) + ? (uint)slot + : 0u; + } + } + + // A7 Fix D (D-2): per-instance 8-int light set, keyed on the cell each + // shell instance belongs to. + int lightStride = LightManager.MaxLightsPerObject; + if (_lightSetData.Length < uniqueInstanceCount * lightStride) + { + _lightSetData = new int[Math.Max( + _lightSetData.Length * 2, + uniqueInstanceCount * lightStride)]; + } + for (int i = 0; i < uniqueInstanceCount; i++) + { + int[] cellSet = GetCellLightSet(allInstances[i].CellId); + Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride); + } + + if (renderPass == WbRenderPass.Opaque + && AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled) + { + EmitSeamDrawProbe(_renderDrawCalls, allInstances, _seamProbeFilter); + } + + int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); + int globalLightUploadCount = lightCount > 0 ? lightCount : 1; + + var pushConstants = new GpuPushConstants + { + ViewProjection = _lastViewProjection, + DrawIdOffset = 0, + // A7 Fix D D-3/D-4: EnvCell bake — wrap points, no sun. + LightingMode = 1, + RenderPass = (int)renderPass, + LightDebug = AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode, + TextureIndexA = 0, + TextureIndexB = 0, + ParamA = 0f, + ParamB = 0f, + }; + + // Bind the pass's base pipeline first so the ring binds land on a live + // program; the per-range switches below rebind the mesh with it. + IGpuPipeline basePipeline = renderPass == WbRenderPass.Transparent + ? _alphaPipeline! + : _opaquePipeline!; + BindPipelineWithMesh(encoder, basePipeline, mesh); + encoder.SetPushConstants(in pushConstants); + + BindRingSection( + encoder, frame, GpuBindingModel.StorageInstances, + _gpuInstanceTransforms.AsSpan(0, uniqueInstanceCount)); + BindRingSection( + encoder, frame, GpuBindingModel.StorageBatches, + _modernBatches.AsSpan(0, totalDraws)); + BindRingSection( + encoder, frame, GpuBindingModel.StorageClipSlots, + _clipSlotData.AsSpan(0, uniqueInstanceCount)); + BindRingSection( + encoder, frame, GpuBindingModel.StorageGlobalLights, + _globalLightData.AsSpan( + 0, + globalLightUploadCount * GlobalLightPacker.FloatsPerLight)); + BindRingSection( + encoder, frame, GpuBindingModel.StorageInstanceLightSets, + _lightSetData.AsSpan(0, uniqueInstanceCount * lightStride)); + + // The frame-global sections, bound after this renderer's own binds + // because those binds are what select the descriptor scope. + AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions( + encoder, scope.Sections, frame); + AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting( + encoder, scope.Sections, frame); + + GpuRingAllocation commands = frame.AllocateRing( + totalDraws * sizeof(DrawElementsIndirectCommand), + GpuRingUsage.Indirect); + MemoryMarshal.AsBytes(_commands.AsSpan(0, totalDraws)).CopyTo(commands.Data); + IGpuBuffer commandBuffer = commands.Buffer; + uint commandBase = commands.OffsetBytes; + + for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++) + { + MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex]; + int groupIndex = drawRange.GroupIndex; + var cullMode = (CullMode)(groupIndex % 4); + // Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock + // uniformly, but the room surfaces need to be visible from inside. + // Render cell polys double-sided, exactly as the GL arm does. + if (cullMode == CullMode.Landblock) cullMode = CullMode.None; + + bool isAdditive = groupIndex >= 4; + if (renderPass == WbRenderPass.Transparent) + { + // Blend state is the pipeline's; switching variants mid-pass has + // to re-establish the mesh, which is vertex-array state. + BindPipelineWithMesh( + encoder, + isAdditive ? _additivePipeline! : _alphaPipeline!, + mesh); + } + + // Must follow the pipeline bind: BindPipeline re-issues the + // pipeline's own cull/front-face/depth-write defaults. + SetCullMode(encoder, cullMode); + + pushConstants.RenderPass = isAdditive + ? (int)renderPass | 0x100 + : (int)renderPass; + pushConstants.DrawIdOffset = drawRange.FirstCommand; + encoder.SetPushConstants(in pushConstants); + encoder.MultiDrawIndexedIndirect( + commandBuffer, + commandBase + (uint)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)), + (uint)drawRange.CommandCount, + (uint)sizeof(DrawElementsIndirectCommand)); + } + } + + private void BindPipelineWithMesh( + IGpuPassEncoder encoder, + IGpuPipeline pipeline, + GlobalMeshBuffer mesh) + { + encoder.BindPipeline(pipeline); + encoder.BindVertexBuffer( + mesh.VertexStore ?? throw new InvalidOperationException( + "The shared mesh arena has no vertex store."), + 0); + encoder.BindIndexBuffer( + mesh.IndexStore ?? throw new InvalidOperationException( + "The shared mesh arena has no index store."), + 0, + GpuIndexType.UInt16); + } + + /// + /// WB BaseObjectRenderManager.cs:850-866 applies CullMode per MDI + /// group; WB GameScene.cs:843 sets FrontFace(CW) globally. Both are + /// dynamic state in core Vulkan 1.3, so they stay per-run calls. + /// + private static void SetCullMode(IGpuPassEncoder encoder, CullMode mode) + { + encoder.SetFrontFace(GpuFrontFace.Clockwise); + switch (mode) + { + case CullMode.None: + encoder.SetCullMode(GpuCullMode.None); + break; + case CullMode.Clockwise: + encoder.SetCullMode(GpuCullMode.Front); + break; + case CullMode.CounterClockwise: + case CullMode.Landblock: + encoder.SetCullMode(GpuCullMode.Back); + break; + } + } + + /// + /// Reserves this frame's ring, copies into it, and binds the slice. A + /// logically empty section still reserves one element so the bound range is + /// never zero-length — the "bind at least one element so the shader never + /// reads an unbound SSBO" rule the light buffers already stated. + /// + private static void BindRingSection( + IGpuPassEncoder encoder, + IGpuFrame frame, + uint binding, + ReadOnlySpan data) + where T : unmanaged + { + int elementBytes = sizeof(T); + int byteCount = Math.Max(data.Length * elementBytes, elementBytes); + GpuRingAllocation allocation = frame.AllocateRing(byteCount, GpuRingUsage.Storage); + if (!data.IsEmpty) + data.CopyTo(allocation.AsSpan()); + encoder.BindStorageBuffer( + binding, + allocation.Buffer, + allocation.OffsetBytes, + (uint)byteCount); + } + + private void DisposeRhiResources() + { + _opaquePipeline?.Dispose(); + _opaquePipeline = null; + _alphaPipeline?.Dispose(); + _alphaPipeline = null; + _additivePipeline?.Dispose(); + _additivePipeline = null; + } +} diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 91a341a7..f268f899 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -29,12 +29,16 @@ using Silk.NET.OpenGL; namespace AcDream.App.Rendering.Wb; -public sealed unsafe class EnvCellRenderer : +public sealed unsafe partial class EnvCellRenderer : IDisposable, IEnvCellLandblockPublisher { private readonly object _publicationOwner = new(); - private readonly GL _gl; + + // Campaign V slice V6j: null on the RHI arm. Every GL statement below is + // reached only when this is non-null; the encoder arm lives in + // EnvCellRenderer.Rhi.cs and the GL arm is unchanged. + private readonly GL? _gl; private readonly ObjectMeshManager _meshManager; private readonly WbFrustum _frustum; @@ -972,13 +976,17 @@ public sealed unsafe class EnvCellRenderer : IReadOnlyList? orderedCellIds) { // WB EnvCellRenderManager.cs:400: - if (!_initialized || _shader is null || _shader.Program == 0) return; + if (!_initialized) return; + // Campaign V slice V6j: the RHI arm has no linked program to check — the + // pipeline it draws with was built at construction and the same readiness + // question is answered by _initialized alone. + if (_gl is not null && (_shader is null || _shader.Program == 0)) return; lock (_renderLock) { var snapshot = _activeSnapshot; // WB EnvCellRenderManager.cs:403-404: - _shader.Use(); + _shader?.Use(); // FIX 2026-05-28 (pool aliasing root cause): mirror WB // EnvCellRenderManager.cs:405 — restore the pool cursor to the // high-water mark Prepare's merge phase reached, so any @@ -1010,11 +1018,11 @@ public sealed unsafe class EnvCellRenderer : _currentCullMode = null; // WB EnvCellRenderManager.cs:406-409: uniform state setup. - _shader.SetInt("uRenderPass", (int)renderPass); - _shader.SetInt("uFilterByCell", 0); - _shader.SetInt("uLightingMode", 1); // A7 Fix D D-3/D-4: EnvCell bake (wrap points, no sun) + _shader?.SetInt("uRenderPass", (int)renderPass); + _shader?.SetInt("uFilterByCell", 0); + _shader?.SetInt("uLightingMode", 1); // A7 Fix D D-3/D-4: EnvCell bake (wrap points, no sun) // #176 stripe-hunt isolation (ACDREAM_LIGHT_DEBUG) — throwaway diagnostic. - _shader.SetInt("uLightDebug", AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode); + _shader?.SetInt("uLightDebug", AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode); // Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when // moving"): upload uViewProjection HERE rather than inheriting it from @@ -1024,7 +1032,7 @@ public sealed unsafe class EnvCellRenderer : // gl_Position against this frame's clip planes → pose-dependent clipping, // worst while moving. Same self-contained-GL-state precedent as the // 2026-05-28 cull-state cache fix above. - _shader.SetMatrix4("uViewProjection", _lastViewProjection); + _shader?.SetMatrix4("uViewProjection", _lastViewProjection); List allInstances = _renderInstances; List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls = @@ -1148,9 +1156,9 @@ public sealed unsafe class EnvCellRenderer : // WB EnvCellRenderManager.cs:486-510: selection/hover highlights — DROPPED (no editor state). // WB EnvCellRenderManager.cs:506-509: cleanup. - _shader.SetVec4("uHighlightColor", new System.Numerics.Vector4(0, 0, 0, 0)); - _shader.SetInt("uRenderPass", (int)renderPass); - _gl.BindVertexArray(0); + _shader?.SetVec4("uHighlightColor", new System.Numerics.Vector4(0, 0, 0, 0)); + _shader?.SetInt("uRenderPass", (int)renderPass); + _gl?.BindVertexArray(0); _currentVao = 0; // No cull restore at exit, matching WB's manager pattern: the @@ -1307,12 +1315,12 @@ public sealed unsafe class EnvCellRenderer : var set = new DynamicBufferSet(); try { - set.MdiCommandBuffer = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell MDI buffer"); - set.ModernInstanceBuffer = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell instance SSBO"); - set.ModernBatchBuffer = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell batch SSBO"); - set.ClipSlotBuffer = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell clip-slot SSBO"); - set.GlobalLightsSsbo = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell global-light SSBO"); - set.InstanceLightSetSsbo = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell light-set SSBO"); + set.MdiCommandBuffer = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell MDI buffer"); + set.ModernInstanceBuffer = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell instance SSBO"); + set.ModernBatchBuffer = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell batch SSBO"); + set.ClipSlotBuffer = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell clip-slot SSBO"); + set.GlobalLightsSsbo = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell global-light SSBO"); + set.InstanceLightSetSsbo = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell light-set SSBO"); return set; } catch (Exception creationFailure) @@ -1356,7 +1364,7 @@ public sealed unsafe class EnvCellRenderer : List? failures = null; void Attempt(uint buffer, long bytes, string name) { - try { TrackedGlResource.DeleteBuffer(_gl, buffer, bytes, $"deleting {name}"); } + try { TrackedGlResource.DeleteBuffer(_gl!, buffer, bytes, $"deleting {name}"); } catch (Exception ex) { (failures ??= []).Add(ex); } } @@ -1403,7 +1411,7 @@ public sealed unsafe class EnvCellRenderer : } private void RenderModernMDIInternal( - AcDream.App.Rendering.Shader shader, + AcDream.App.Rendering.Shader? shader, List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls, List allInstances, IReadOnlyList drawCallRanges, @@ -1419,12 +1427,22 @@ public sealed unsafe class EnvCellRenderer : // Without the global VAO nothing can draw, and returning AFTER the pass state // was established leaked it (same early-out shape as the totalDraws==0 leak — // see the comment on the state-establish block below). + // Campaign V slice V6j: the RHI arm has no vertex array — the pipeline + // owns one shaped by GpuVertexLayout.WorldMesh — so its readiness test is + // the backend-neutral HasStores the arena publishes (V6i-3). var globalVao = _meshManager.GlobalBuffer?.VAO ?? 0u; - if (globalVao == 0) return; + if (_gl is not null) + { + if (globalVao == 0) return; + } + else if (_meshManager.GlobalBuffer is not { HasStores: true }) + { + return; + } // WB BaseObjectRenderManager.cs:715-716: - shader.Use(); - shader.SetInt("uFilterByCell", 0); + shader?.Use(); + shader?.SetInt("uFilterByCell", 0); // WB BaseObjectRenderManager.cs:718-740: count the pass-filtered batches. // A normal render has one range. The ordered transparent-shell path has @@ -1460,6 +1478,13 @@ public sealed unsafe class EnvCellRenderer : // WB BaseObjectRenderManager.cs:743: if (totalDraws == 0) return; + int uniqueInstanceCount = allInstances.Count; + // Campaign V slice V6j: the encoder arm owns no buffer pool and no + // imperative state bracket. Every per-frame section is a ring slice, so + // there is nothing to activate or grow, and blend plus depth-write are + // baked into the three shell pipelines rather than set here. + if (_gl is not null) + { ActivateNextDynamicBufferSet(); // Phase U.4 ROOT-CAUSE FIX (cell-shell "transparent walls / only bluish @@ -1517,7 +1542,6 @@ public sealed unsafe class EnvCellRenderer : _modernBatchCapacity = grownBatchCapacity; } - int uniqueInstanceCount = allInstances.Count; if (uniqueInstanceCount > _modernInstanceCapacity) { int grownInstanceCapacity = Math.Max(_modernInstanceCapacity * 2, uniqueInstanceCount); @@ -1568,6 +1592,7 @@ public sealed unsafe class EnvCellRenderer : $"growing EnvCell light-set SSBO to {grownLightSetCapacity} instances"); _instLightSetCapacity = grownLightSetCapacity; } + } // WB BaseObjectRenderManager.cs:761-762: grow scratch arrays. if (_commands.Length < totalDraws) @@ -1659,6 +1684,12 @@ public sealed unsafe class EnvCellRenderer : } } + if (_gl is null) + { + SubmitRhi(allInstances, renderPass, totalDraws, uniqueInstanceCount); + return; + } + // WB BaseObjectRenderManager.cs:784-805 upload. Retain capacity and // update the active prefix so portal frames cannot enqueue an unbounded // chain of retired driver allocations. @@ -1810,15 +1841,15 @@ public sealed unsafe class EnvCellRenderer : if (isAdditive) { _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); - shader.SetInt("uRenderPass", (int)renderPass | 0x100); + shader!.SetInt("uRenderPass", (int)renderPass | 0x100); } else { _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); - shader.SetInt("uRenderPass", (int)renderPass); + shader!.SetInt("uRenderPass", (int)renderPass); } - shader.SetInt("uDrawIDOffset", drawRange.FirstCommand); + shader!.SetInt("uDrawIDOffset", drawRange.FirstCommand); _gl.MultiDrawElementsIndirect( PrimitiveType.Triangles, DrawElementsType.UnsignedShort, @@ -1834,7 +1865,7 @@ public sealed unsafe class EnvCellRenderer : _gl.DepthMask(true); // WB BaseObjectRenderManager.cs:845-847: - shader.SetInt("uDrawIDOffset", 0); + shader!.SetInt("uDrawIDOffset", 0); _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); } @@ -1974,15 +2005,15 @@ public sealed unsafe class EnvCellRenderer : switch (mode) { case CullMode.None: - _gl.Disable(EnableCap.CullFace); + _gl!.Disable(EnableCap.CullFace); break; case CullMode.Clockwise: - _gl.Enable(EnableCap.CullFace); + _gl!.Enable(EnableCap.CullFace); _gl.CullFace(TriangleFace.Front); break; case CullMode.CounterClockwise: case CullMode.Landblock: - _gl.Enable(EnableCap.CullFace); + _gl!.Enable(EnableCap.CullFace); _gl.CullFace(TriangleFace.Back); break; } @@ -2005,7 +2036,7 @@ public sealed unsafe class EnvCellRenderer : { AcDream.App.Rendering.Gpu.Gl.GlGpuDevice device = WorldTextureTable; device.FlushTextureTable(); - _gl.BindBufferBase( + _gl!.BindBufferBase( GLEnum.ShaderStorageBuffer, AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable, device.TextureTableGlName); @@ -2025,19 +2056,19 @@ public sealed unsafe class EnvCellRenderer : { if (_sharedClipRegionSsbo != 0) { - _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, + _gl!.BindBufferBase(GLEnum.ShaderStorageBuffer, AcDream.App.Rendering.ClipFrame.MeshClipSsboBinding, _sharedClipRegionSsbo); return; } if (_fallbackClipRegionSsbo == 0) { - uint fallback = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell fallback clip SSBO"); + uint fallback = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell fallback clip SSBO"); bool allocated = false; try { TrackedGlResource.AllocateBufferStorage( - _gl, + _gl!, GLEnum.ShaderStorageBuffer, fallback, 0, @@ -2050,7 +2081,7 @@ public sealed unsafe class EnvCellRenderer : zero.Clear(); fixed (byte* p = zero) { - _gl.BufferSubData( + _gl!.BufferSubData( GLEnum.ShaderStorageBuffer, 0, (nuint)zero.Length, @@ -2062,14 +2093,14 @@ public sealed unsafe class EnvCellRenderer : catch { TrackedGlResource.DeleteBuffer( - _gl, + _gl!, fallback, allocated ? AcDream.App.Rendering.ClipFrame.CellClipStrideBytes : 0, "rolling back EnvCell fallback clip SSBO"); throw; } } - _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, + _gl!.BindBufferBase(GLEnum.ShaderStorageBuffer, AcDream.App.Rendering.ClipFrame.MeshClipSsboBinding, _fallbackClipRegionSsbo); } @@ -2123,6 +2154,11 @@ public sealed unsafe class EnvCellRenderer : { ("prepare-scratch", _prepareScratch.Dispose), }; + // Campaign V slice V6j: the encoder arm owns no GL names — its + // pipelines route their physical free through the device's + // retirement queue — so the ledger below holds only the scratch. + if (_gl is null) + DisposeRhiResources(); for (int frame = 0; frame < _dynamicBufferSetsByFrame.Length; frame++) { @@ -2226,7 +2262,7 @@ public sealed unsafe class EnvCellRenderer : return; RetryableGpuResourceRelease release = TrackedGlResource.CreateRetryableBufferDeletion( - _gl, + _gl!, buffer, capacityBytes, context); diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs new file mode 100644 index 00000000..10f2a0d0 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs @@ -0,0 +1,570 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Residency; +using AcDream.App.Rendering.Selection; +using AcDream.Core.Lighting; +using AcDream.Core.Meshing; +using AcDream.Core.Rendering; +using DatReaderWriter.Enums; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Campaign V slice V6j: the world entity dispatcher's RHI submission arm. +/// +/// V4c's content, re-landed as a second arm rather than a replacement — +/// §5.5.6 selected that shape after NVIDIA rendered the V4c binary 10/10 and +/// AMD's GL stack did not. Every GL statement in the sibling file is untouched; +/// everything here runs only when there is no GL context. +/// +/// Three differences from V4c, each because the tree moved under it. There +/// is no binding-9 texture table — V4t put the slot on the device and Vulkan +/// binds set 2. The pass is BORROWED from rather +/// than opened, because the frame's one backbuffer pass resolves. And the +/// pipelines carry the device's sample count, because Vulkan requires a +/// pipeline's rasterizationSamples to match the pass and +/// alpha-to-coverage does nothing at one sample. +/// +public sealed unsafe partial class WbDrawDispatcher +{ + private readonly IGpuDevice? _device; + private readonly ICurrentGpuFrameSource? _frames; + private readonly IWorldPassScope? _scope; + private IGpuPipeline? _opaquePipeline; + private IGpuPipeline? _opaqueAlphaToCoveragePipeline; + private IGpuPipeline? _alphaBlendPipeline; + private IGpuPipeline? _alphaAdditivePipeline; + private IGpuPipeline? _alphaInversePipeline; + + private const string OpaqueTimerScope = "wb-entities-opaque"; + private const string TransparentTimerScope = "wb-entities-transparent"; + + /// + /// A ring slice reduced to the three values a later bind needs. The prepared + /// alpha payload is written once and bound many times, so the allocation's + /// ref struct lifetime is escaped through these ordinary values. + /// + private readonly record struct RhiSection( + IGpuBuffer? Buffer, + uint OffsetBytes, + uint SizeBytes); + + private RhiSection _alphaInstances; + private RhiSection _alphaBatches; + private RhiSection _alphaClipSlots; + private RhiSection _alphaGlobalLights; + private RhiSection _alphaLightSets; + private RhiSection _alphaIndoor; + private RhiSection _alphaOpacity; + private RhiSection _alphaSelectionLighting; + private RhiSection _alphaCommands; + + /// + /// The RHI arm's constructor. No GL context, no Shader, no + /// BindlessSupport: the five pipelines compile mesh_modern from + /// the committed SPIR-V, and batch data already carries the device's own + /// GpuTextureSlot (V4t) rather than a bindless handle. + /// + internal WbDrawDispatcher( + IGpuDevice device, + ICurrentGpuFrameSource frames, + IWorldPassScope scope, + TextureCache textures, + WbMeshAdapter meshAdapter, + EntitySpawnAdapter entitySpawnAdapter, + EntityClassificationCache classificationCache, + AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades, + IRetailSelectionRenderSink? selectionSink = null, + RetailAlphaQueue? alphaQueue = null, + long? alphaScratchBudgetBytes = null) + { + _device = device ?? throw new ArgumentNullException(nameof(device)); + _frames = frames ?? throw new ArgumentNullException(nameof(frames)); + _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + _textures = textures ?? throw new ArgumentNullException(nameof(textures)); + _meshAdapter = meshAdapter ?? throw new ArgumentNullException(nameof(meshAdapter)); + _entitySpawnAdapter = entitySpawnAdapter + ?? throw new ArgumentNullException(nameof(entitySpawnAdapter)); + _cache = classificationCache + ?? throw new ArgumentNullException(nameof(classificationCache)); + _translucencyFades = translucencyFades + ?? throw new ArgumentNullException(nameof(translucencyFades)); + _selectionSink = selectionSink; + _selectionLighting = selectionSink as IRetailSelectionLightingSource; + _alphaQueue = alphaQueue; + _alphaSource = new AlphaDrawSource(this); + long scratchBudget = alphaScratchBudgetBytes + ?? AlphaScratchBudgetProfile.Create( + ResidencyBudgetOptions.Default.AlphaScratchBytes) + .DispatcherBytes; + _alphaScratchPolicy = new RetainedScratchCapacityPolicy(scratchBudget); + + int samples = scope.SampleCount; + _opaquePipeline = CreateMeshPipeline( + device, "wb-mesh-opaque", GpuBlendMode.None, true, false, samples); + _opaqueAlphaToCoveragePipeline = CreateMeshPipeline( + device, "wb-mesh-opaque-a2c", GpuBlendMode.None, true, true, samples); + _alphaBlendPipeline = CreateMeshPipeline( + device, "wb-mesh-alpha", GpuBlendMode.StraightAlpha, false, false, samples); + _alphaAdditivePipeline = CreateMeshPipeline( + device, "wb-mesh-additive", GpuBlendMode.Additive, false, false, samples); + _alphaInversePipeline = CreateMeshPipeline( + device, "wb-mesh-inverse", GpuBlendMode.InverseAlpha, false, false, samples); + } + + /// + /// The imperative Enable/Disable/BlendFunc/DepthMask brackets became + /// pipeline variants: opaque, opaque with alpha-to-coverage, and the three + /// retail blends. Cull mode and front face stay dynamic per MDI run, exactly + /// where ApplyCullMode sets them, because core Vulkan 1.3 makes those + /// dynamic and blend and alpha-to-coverage not. + /// + /// Depth compare is Less, not the contract's + /// LessOrEqual default: the world frame runs under GL_LESS and + /// this renderer never called glDepthFunc, so it inherited it. Baking + /// LessOrEqual would change which of two coplanar retail surfaces + /// wins. + /// + private static IGpuPipeline CreateMeshPipeline( + IGpuDevice device, + string name, + GpuBlendMode blend, + bool depthWrite, + bool alphaToCoverage, + int sampleCount) => + device.CreatePipeline(new GpuPipelineDescription + { + Name = name, + Shaders = new GpuShaderSet("mesh_modern"), + VertexLayout = GpuVertexLayout.WorldMesh, + Topology = GpuPrimitiveTopology.TriangleList, + Blend = blend, + Depth = new GpuDepthState(Test: true, Write: depthWrite, GpuCompareOp.Less), + Cull = GpuCullMode.Back, + FrontFace = GpuFrontFace.Clockwise, + AlphaToCoverage = alphaToCoverage, + ColorWrite = true, + SampleCount = sampleCount, + }); + + /// + /// Records the opaque and transparent multi-draws into the borrowed world + /// pass. Phases 1–4 above are untouched — the bucketing, the sorts, the + /// indirect-command array and every retail fidelity decision are the same + /// CPU code on both arms; only where the bytes land differs. + /// + private void SubmitRhi( + Matrix4x4 viewProjection, + int immediateInstances, + int totalDraws, + bool diag) + { + IWorldPassScope scope = _scope!; + IGpuPassEncoder encoder = scope.RequireEncoder(); + IGpuFrame frame = RequireRhiFrame(); + GlobalMeshBuffer mesh = _meshAdapter.MeshManager?.GlobalBuffer + ?? throw new InvalidOperationException("The shared mesh arena is not published."); + + var pushConstants = new GpuPushConstants + { + ViewProjection = viewProjection, + DrawIdOffset = 0, + LightingMode = 0, + RenderPass = 0, + LightDebug = RenderingDiagnostics.LightDebugMode, + TextureIndexA = 0, + TextureIndexB = 0, + ParamA = 0f, + ParamB = 0f, + }; + + // Bind the opaque variant first so the ring binds land on a live program; + // the transparent bracket rebinds its own variant, and push constants + // survive that switch per the encoder contract. + BindPipelineWithMesh( + encoder, + AlphaToCoverage ? _opaqueAlphaToCoveragePipeline! : _opaquePipeline!, + mesh); + encoder.SetPushConstants(in pushConstants); + + BindRingSection( + encoder, frame, GpuBindingModel.StorageInstances, + _instanceData.AsSpan(0, immediateInstances * 16)); + BindRingSection( + encoder, frame, GpuBindingModel.StorageBatches, + _batchData.AsSpan(0, totalDraws)); + BindRingSection( + encoder, frame, GpuBindingModel.StorageClipSlots, + _clipSlotData.AsSpan(0, immediateInstances)); + BindGlobalLightsRhi(encoder, frame); + BindRingSection( + encoder, frame, GpuBindingModel.StorageInstanceLightSets, + _lightSetData.AsSpan(0, immediateInstances * LightManager.MaxLightsPerObject)); + BindRingSection( + encoder, frame, GpuBindingModel.StorageInstanceIndoor, + _indoorData.AsSpan(0, immediateInstances)); + BindRingSection( + encoder, frame, GpuBindingModel.StorageInstanceAlpha, + _alphaData.AsSpan(0, immediateInstances)); + BindRingSection( + encoder, frame, GpuBindingModel.StorageInstanceSelectionLighting, + _selectionLightingData.AsSpan(0, immediateInstances)); + + AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions( + encoder, scope.Sections, frame); + AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting( + encoder, scope.Sections, frame); + + GpuRingAllocation commands = frame.AllocateRing( + totalDraws * DrawCommandStride, + GpuRingUsage.Indirect); + MemoryMarshal.AsBytes(_indirectCommands.AsSpan(0, totalDraws)) + .CopyTo(commands.Data); + IGpuBuffer commandBuffer = commands.Buffer; + uint commandBase = commands.OffsetBytes; + + // ── Phase 7: opaque pass ───────────────────────────────────────────── + if (_opaqueDrawCount > 0) + { + // Blend-off, depth-write-on and A.5 T20's alpha-to-coverage all come + // from the pipeline rather than an imperative bracket. Issue #52's + // per-pass batch offset is unchanged: the opaque section of Batches[] + // starts at index 0, and Vulkan's gl_DrawID resets per + // vkCmdDrawIndexedIndirect exactly as GL's does. + pushConstants.RenderPass = 0; + pushConstants.DrawIdOffset = 0; + encoder.SetPushConstants(in pushConstants); + using (BeginRhiTimer(encoder, diag, OpaqueTimerScope)) + { + DrawIndirectRangeRhi( + encoder, ref pushConstants, commandBuffer, commandBase, + 0, _opaqueDrawCount); + } + } + + // ── Phase 8: transparent pass ──────────────────────────────────────── + if (_transparentDrawCount > 0) + { + BindPipelineWithMesh(encoder, _alphaBlendPipeline!, mesh); + // Issue #52 again: the transparent section starts at _opaqueDrawCount. + // Without the offset each transparent draw reads the OPAQUE section + // and the lifestone crystal's texture flickers. + pushConstants.RenderPass = 1; + pushConstants.DrawIdOffset = _opaqueDrawCount; + encoder.SetPushConstants(in pushConstants); + using (BeginRhiTimer(encoder, diag, TransparentTimerScope)) + { + DrawIndirectRangeRhi( + encoder, ref pushConstants, commandBuffer, commandBase, + _opaqueDrawCount, _transparentDrawCount); + } + } + + SampleRhiTimers(diag); + } + + /// + /// Writes the prepared deferred-alpha payload into the frame ring once. The + /// sections survive as ordinary values so every later + /// DrawPreparedAlphaBatch binds the same bytes without recopying. + /// + private void PrepareRhiAlphaSections(int count) + { + IGpuFrame frame = RequireRhiFrame(); + _alphaInstances = WriteRingSection(frame, _instanceData.AsSpan(0, count * 16)); + _alphaBatches = WriteRingSection(frame, _batchData.AsSpan(0, count)); + _alphaClipSlots = WriteRingSection(frame, _clipSlotData.AsSpan(0, count)); + int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); + int uploadCount = lightCount > 0 ? lightCount : 1; + _alphaGlobalLights = WriteRingSection( + frame, + _globalLightData.AsSpan(0, uploadCount * GlobalLightPacker.FloatsPerLight)); + _alphaLightSets = WriteRingSection( + frame, + _lightSetData.AsSpan(0, count * LightManager.MaxLightsPerObject)); + _alphaIndoor = WriteRingSection(frame, _indoorData.AsSpan(0, count)); + _alphaOpacity = WriteRingSection(frame, _alphaData.AsSpan(0, count)); + _alphaSelectionLighting = WriteRingSection( + frame, + _selectionLightingData.AsSpan(0, count)); + _alphaCommands = WriteRingSection( + frame, + _indirectCommands.AsSpan(0, count), + GpuRingUsage.Indirect); + } + + private void DrawPreparedAlphaBatchRhi( + GlobalMeshBuffer mesh, + int firstPreparedDraw, + int drawCount) + { + if (_alphaCommands.Buffer is null) + return; + + IWorldPassScope scope = _scope!; + IGpuPassEncoder encoder = scope.RequireEncoder(); + IGpuFrame frame = RequireRhiFrame(); + + var pushConstants = new GpuPushConstants + { + ViewProjection = _deferredAlphaViewProjection, + DrawIdOffset = 0, + LightingMode = 0, + RenderPass = 1, + LightDebug = RenderingDiagnostics.LightDebugMode, + TextureIndexA = 0, + TextureIndexB = 0, + ParamA = 0f, + ParamB = 0f, + }; + + BindPipelineWithMesh(encoder, _alphaBlendPipeline!, mesh); + encoder.SetPushConstants(in pushConstants); + BindSection(encoder, GpuBindingModel.StorageInstances, _alphaInstances); + BindSection(encoder, GpuBindingModel.StorageBatches, _alphaBatches); + BindSection(encoder, GpuBindingModel.StorageClipSlots, _alphaClipSlots); + BindSection(encoder, GpuBindingModel.StorageGlobalLights, _alphaGlobalLights); + BindSection(encoder, GpuBindingModel.StorageInstanceLightSets, _alphaLightSets); + BindSection(encoder, GpuBindingModel.StorageInstanceIndoor, _alphaIndoor); + BindSection(encoder, GpuBindingModel.StorageInstanceAlpha, _alphaOpacity); + BindSection( + encoder, + GpuBindingModel.StorageInstanceSelectionLighting, + _alphaSelectionLighting); + AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions( + encoder, scope.Sections, frame); + AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting( + encoder, scope.Sections, frame); + + int runStart = firstPreparedDraw; + int preparedEnd = firstPreparedDraw + drawCount; + while (runStart < preparedEnd) + { + TranslucencyKind blend = _deferredAlphaKinds[runStart]; + int runEnd = runStart + 1; + while (runEnd < preparedEnd && _deferredAlphaKinds[runEnd] == blend) + runEnd++; + + // ApplyRetailBlend's three cases are three pipelines, including the + // inverse-alpha one GpuBlendMode.InverseAlpha was added for. + BindPipelineWithMesh(encoder, PipelineForBlend(blend), mesh); + encoder.SetPushConstants(in pushConstants); + DrawIndirectRangeRhi( + encoder, + ref pushConstants, + _alphaCommands.Buffer!, + _alphaCommands.OffsetBytes, + runStart, + runEnd - runStart); + runStart = runEnd; + } + } + + private IGpuPipeline PipelineForBlend(TranslucencyKind blend) => blend switch + { + TranslucencyKind.Additive => _alphaAdditivePipeline!, + TranslucencyKind.InvAlpha => _alphaInversePipeline!, + _ => _alphaBlendPipeline!, + }; + + private void DrawIndirectRangeRhi( + IGpuPassEncoder encoder, + ref GpuPushConstants pushConstants, + IGpuBuffer commandBuffer, + uint commandBaseOffsetBytes, + int startCommand, + int commandCount) + { + int end = startCommand + commandCount; + int command = startCommand; + while (command < end) + { + CullMode cullMode = _drawCullModes[command]; + ApplyCullModeRhi(encoder, cullMode); + + int runCount = 1; + while (command + runCount < end && _drawCullModes[command + runCount] == cullMode) + runCount++; + + // Each multi-draw-indirect call restarts gl_DrawID at 0, so a run + // that begins partway into the batch array must carry its absolute + // command index or it reads BatchData[0] again (issue #52). + pushConstants.DrawIdOffset = command; + encoder.SetPushConstants(in pushConstants); + encoder.MultiDrawIndexedIndirect( + commandBuffer, + commandBaseOffsetBytes + (uint)(command * DrawCommandStride), + (uint)runCount, + (uint)DrawCommandStride); + + command += runCount; + } + } + + /// + /// WB BaseObjectRenderManager.cs:850-866 applies CullMode per MDI + /// group and WB GameScene.cs:843 sets FrontFace(CW) globally. Both are + /// dynamic state in core Vulkan 1.3, and both must be re-issued after every + /// BindPipeline, which restores the pipeline's own defaults. + /// + private static void ApplyCullModeRhi(IGpuPassEncoder encoder, CullMode mode) + { + encoder.SetFrontFace(GpuFrontFace.Clockwise); + switch (mode) + { + case CullMode.None: + encoder.SetCullMode(GpuCullMode.None); + break; + case CullMode.Clockwise: + encoder.SetCullMode(GpuCullMode.Front); + break; + case CullMode.CounterClockwise: + case CullMode.Landblock: + encoder.SetCullMode(GpuCullMode.Back); + break; + } + } + + /// + /// Binds a pipeline and immediately re-establishes the mesh source. Every + /// pipeline owns its own vertex array, and vertex attribute pointers plus the + /// index binding are vertex-array state, so a pipeline switch inside a pass + /// silently drops them while storage bindings survive. + /// + private static void BindPipelineWithMesh( + IGpuPassEncoder encoder, + IGpuPipeline pipeline, + GlobalMeshBuffer mesh) + { + encoder.BindPipeline(pipeline); + encoder.BindVertexBuffer( + mesh.VertexStore ?? throw new InvalidOperationException( + "The shared mesh arena has no vertex store."), + 0); + encoder.BindIndexBuffer( + mesh.IndexStore ?? throw new InvalidOperationException( + "The shared mesh arena has no index store."), + 0, + GpuIndexType.UInt16); + } + + private void BindGlobalLightsRhi(IGpuPassEncoder encoder, IGpuFrame frame) + { + int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); + int uploadCount = lightCount > 0 ? lightCount : 1; + BindRingSection( + encoder, + frame, + GpuBindingModel.StorageGlobalLights, + _globalLightData.AsSpan(0, uploadCount * GlobalLightPacker.FloatsPerLight)); + } + + private static void BindRingSection( + IGpuPassEncoder encoder, + IGpuFrame frame, + uint binding, + ReadOnlySpan data) + where T : unmanaged => + BindSection(encoder, binding, WriteRingSection(frame, data)); + + private static void BindSection( + IGpuPassEncoder encoder, + uint binding, + in RhiSection section) + { + if (section.Buffer is null) + return; + encoder.BindStorageBuffer( + binding, + section.Buffer, + section.OffsetBytes, + section.SizeBytes); + } + + private static RhiSection WriteRingSection( + IGpuFrame frame, + ReadOnlySpan data, + GpuRingUsage usage = GpuRingUsage.Storage) + where T : unmanaged + { + int elementBytes = sizeof(T); + int byteCount = Math.Max(data.Length * elementBytes, elementBytes); + GpuRingAllocation allocation = frame.AllocateRing(byteCount, usage); + if (!data.IsEmpty) + data.CopyTo(allocation.AsSpan()); + return new RhiSection(allocation.Buffer, allocation.OffsetBytes, (uint)byteCount); + } + + private IGpuFrame RequireRhiFrame() + { + // The same precondition ActivateNextDynamicBufferSet enforces on GL: a + // draw that has not been bracketed by BeginFrame has no slot to write to. + if (!_dynamicFrameStarted) + throw new InvalidOperationException("BeginFrame must be called before drawing world entities."); + + return _frames!.CurrentFrame + ?? throw new InvalidOperationException( + "WbDrawDispatcher requires an open IGpuFrame (see GpuDeviceFrameLifetime)."); + } + + private static IDisposable BeginRhiTimer( + IGpuPassEncoder encoder, + bool diag, + string scopeName) => + diag ? encoder.BeginTimerScope(scopeName) : NullRhiTimerScope.Instance; + + /// + /// The [WB-DIAG] median/p95 window still measures opaque + transparent GPU + /// time; the sample now comes from the device's timer pool — the most recent + /// retired result — rather than a hand-rolled 3-deep query ring read at N-3. + /// A sample can therefore repeat when the GPU has not finished a newer query, + /// where the old code dropped it. Diagnostic-only. + /// + private void SampleRhiTimers(bool diag) + { + if (!diag || _device is null) + return; + + double totalMs = 0; + bool any = false; + if (_device.Timers.TryResolve(OpaqueTimerScope, out double opaqueMs)) + { + totalMs += opaqueMs; + any = true; + } + if (_device.Timers.TryResolve(TransparentTimerScope, out double transparentMs)) + { + totalMs += transparentMs; + any = true; + } + if (!any) + return; + + _gpuSamples[_gpuSampleCursor] = (long)(totalMs * 1000.0); + _gpuSampleCursor = (_gpuSampleCursor + 1) % _gpuSamples.Length; + } + + private void DisposeRhiResources() + { + _opaquePipeline?.Dispose(); + _opaquePipeline = null; + _opaqueAlphaToCoveragePipeline?.Dispose(); + _opaqueAlphaToCoveragePipeline = null; + _alphaBlendPipeline?.Dispose(); + _alphaBlendPipeline = null; + _alphaAdditivePipeline?.Dispose(); + _alphaAdditivePipeline = null; + _alphaInversePipeline?.Dispose(); + _alphaInversePipeline = null; + } + + private sealed class NullRhiTimerScope : IDisposable + { + internal static NullRhiTimerScope Instance { get; } = new(); + + public void Dispose() + { + } + } +} diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 5ed6ec0a..54ebdfbd 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -86,8 +86,8 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable All, } - private readonly GL _gl; - private readonly Shader _shader; + private readonly GL? _gl; + private readonly Shader? _shader; private readonly TextureCache _textures; private readonly WbMeshAdapter _meshAdapter; private readonly EntitySpawnAdapter _entitySpawnAdapter; @@ -98,7 +98,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable private readonly RetainedScratchCapacityPolicy _alphaScratchPolicy; private int _scratchPeakUnits; - private readonly BindlessSupport _bindless; + private readonly BindlessSupport? _bindless; private ICurrentRenderDispatcherObserver? _currentRenderSceneObserver; public readonly record struct DrawStats( @@ -2056,18 +2056,33 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable observeCurrentPath: true); } + /// + /// Whether there is a mesh source to draw from. + /// + /// Campaign V slice V6j: on GL that question is "did we find a vertex + /// array", which answers. The encoder arm has no + /// vertex array — the pipeline owns one shaped by + /// GpuVertexLayout.WorldMesh — so it asks the arena the + /// backend-neutral form of the same question, which V6i-3 published as + /// HasStores. + /// + private bool MeshSourceReady(uint anyVao) => + _gl is not null + ? anyVao != 0 + : _meshAdapter.MeshManager?.GlobalBuffer is { HasStores: true }; + private bool BeginEntityDispatch( ICamera camera, out Matrix4x4 viewProjection, out Vector3 cameraWorldPosition) { - _shader.Use(); + _shader?.Use(); _selectionLighting?.TickLighting(); _indoorProbeFrameCounter++; viewProjection = camera.View * camera.Projection; - _shader.SetMatrix4("uViewProjection", viewProjection); - _shader.SetInt("uLightingMode", 0); - _shader.SetInt( + _shader?.SetMatrix4("uViewProjection", viewProjection); + _shader?.SetInt("uLightingMode", 0); + _shader?.SetInt( "uLightDebug", RenderingDiagnostics.LightDebugMode); _missRequested.Clear(); @@ -2076,7 +2091,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal); - if (diagnosticsEnabled && !_gpuQueriesInitialized) + if (diagnosticsEnabled && _gl is not null && !_gpuQueriesInitialized) { for (int index = 0; index < GpuQueryRingDepth; index++) { @@ -2104,8 +2119,8 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable bool diag, bool observeCurrentPath) { - // Nothing visible — skip the GL pass entirely. - if (anyVao == 0) + // Nothing visible — skip the pass entirely. + if (!MeshSourceReady(anyVao)) { LastDrawStats = new DrawStats(set, entitiesWalked, tupleCount, 0, 0, 0, 0, 0, 0); ObserveClassifiedDispatcherSubmission(observeCurrentPath, @@ -2262,6 +2277,26 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable deferTransparent, camPos); + // Campaign V slice V6j: on the encoder arm every per-frame upload below + // is a frame ring slice bound through the borrowed world pass, which + // retires the buffer-set pool structurally. See WbDrawDispatcher.Rhi.cs. + if (_gl is null) + { + SubmitRhi(vp, immediateInstances, totalDraws, diag); + _cpuStopwatch.Stop(); + if (diag) + { + long rhiCpuUs = _cpuStopwatch.ElapsedTicks * 1_000_000L + / System.Diagnostics.Stopwatch.Frequency; + _cpuSamples[_cpuSampleCursor] = rhiCpuUs; + _cpuSampleCursor = (_cpuSampleCursor + 1) % _cpuSamples.Length; + _drawsIssued += _opaqueDrawCount + _transparentDrawCount; + _instancesIssued += totalInstances; + MaybeFlushDiag(); + } + return; + } + // ── Phase 5: upload four buffers ──────────────────────────────────── ActivateNextDynamicBufferSet(); fixed (float* ip = _instanceData) @@ -2404,7 +2439,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable // A.5 T22.5: gated by AlphaToCoverage property so Low/Medium presets // (no MSAA) skip the unnecessary GL state change. if (AlphaToCoverage) _gl.Enable(EnableCap.SampleAlphaToCoverage); - _shader.SetInt("uRenderPass", 0); + _shader!.SetInt("uRenderPass", 0); // Phase Post-A.5 (ISSUE #52, 2026-05-10): opaque section of // Batches[] starts at index 0. See uDrawIDOffset comment in // mesh_modern.vert for why this is needed. @@ -2432,7 +2467,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable // OPAQUE section — and the lifestone crystal's apparent texture // flickers to whatever opaque batch sorted first that frame. See // uDrawIDOffset comment in mesh_modern.vert. - _shader.SetInt("uDrawIDOffset", _opaqueDrawCount); + _shader!.SetInt("uDrawIDOffset", _opaqueDrawCount); // Closed-shell translucent meshes still need culling, but the // cull side must come from each dat batch just like the opaque // section. BuildIndirectArrays preserves CullMode in _drawCullModes. @@ -2965,7 +3000,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable return; GlobalMeshBuffer? global = _meshAdapter.MeshManager?.GlobalBuffer; - if (global is null || global.VAO == 0) + if (global is null || !MeshSourceReady(global.VAO)) return; int count = tokens.Length; @@ -3004,6 +3039,15 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable // One upload per source per sorted alpha scope. RetailAlphaQueue later // draws contiguous ranges from this immutable prepared payload; it must // never overwrite these buffers for every short mesh/particle run. + if (_gl is null) + { + // A ring allocation cannot outlive its frame as a ref struct, but its + // buffer, offset and size can be stored — so the payload is written + // once here and bound many times below without recopying. + PrepareRhiAlphaSections(count); + return; + } + ActivateNextDynamicBufferSet(); UploadDeferredAlphaBuffers(count); PersistActiveDynamicBufferCapacities(); @@ -3018,10 +3062,16 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable throw new ArgumentOutOfRangeException(nameof(firstPreparedDraw)); GlobalMeshBuffer? global = _meshAdapter.MeshManager?.GlobalBuffer; - if (global is null || global.VAO == 0) + if (global is null || !MeshSourceReady(global.VAO)) return; - _shader.Use(); + if (_gl is null) + { + DrawPreparedAlphaBatchRhi(global, firstPreparedDraw, drawCount); + return; + } + + _shader!.Use(); _shader.SetMatrix4("uViewProjection", _deferredAlphaViewProjection); _shader.SetInt("uLightingMode", 0); _shader.SetInt("uLightDebug", AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode); @@ -3193,7 +3243,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable private void ApplyRetailBlend(TranslucencyKind blend) { - _gl.BlendFunc( + _gl!.BlendFunc( blend == TranslucencyKind.InvAlpha ? BlendingFactor.OneMinusSrcAlpha : BlendingFactor.SrcAlpha, @@ -3252,8 +3302,8 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable // into CullMode runs, the shader must receive the absolute command // index for this run or it will read BatchData[0] again and bind // the wrong texture for later runs. - _shader.SetInt("uDrawIDOffset", command); - _gl.MultiDrawElementsIndirect( + _shader!.SetInt("uDrawIDOffset", command); + _gl!.MultiDrawElementsIndirect( PrimitiveType.Triangles, DrawElementsType.UnsignedShort, (void*)(command * DrawCommandStride), @@ -3270,7 +3320,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable // WB GameScene.cs:843 sets FrontFace(CW) globally; SetCullMode then // only chooses front/back culling. Keep the same convention here so // splitting MDI commands by CullMode cannot resurrect stale CCW state. - _gl.FrontFace(FrontFaceDirection.CW); + _gl!.FrontFace(FrontFaceDirection.CW); switch (mode) { case CullMode.None: @@ -3324,16 +3374,16 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable var set = new DynamicBufferSet(); try { - set.InstanceSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity instance SSBO"); - set.BatchSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity batch SSBO"); - set.IndirectBuffer = TrackedGlResource.CreateBuffer(_gl, "creating entity indirect buffer"); - set.ClipSlotSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity clip-slot SSBO"); - set.GlobalLightsSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity global-light SSBO"); - set.InstanceLightSetSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity light-set SSBO"); - set.InstanceIndoorSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity indoor SSBO"); - set.InstanceAlphaSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity alpha SSBO"); + set.InstanceSsbo = TrackedGlResource.CreateBuffer(_gl!, "creating entity instance SSBO"); + set.BatchSsbo = TrackedGlResource.CreateBuffer(_gl!, "creating entity batch SSBO"); + set.IndirectBuffer = TrackedGlResource.CreateBuffer(_gl!, "creating entity indirect buffer"); + set.ClipSlotSsbo = TrackedGlResource.CreateBuffer(_gl!, "creating entity clip-slot SSBO"); + set.GlobalLightsSsbo = TrackedGlResource.CreateBuffer(_gl!, "creating entity global-light SSBO"); + set.InstanceLightSetSsbo = TrackedGlResource.CreateBuffer(_gl!, "creating entity light-set SSBO"); + set.InstanceIndoorSsbo = TrackedGlResource.CreateBuffer(_gl!, "creating entity indoor SSBO"); + set.InstanceAlphaSsbo = TrackedGlResource.CreateBuffer(_gl!, "creating entity alpha SSBO"); set.InstanceSelectionLightingSsbo = TrackedGlResource.CreateBuffer( - _gl, + _gl!, "creating entity selection-lighting SSBO"); return set; } @@ -3356,7 +3406,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable List? failures = null; void Attempt(uint buffer, int bytes, string name) { - try { TrackedGlResource.DeleteBuffer(_gl, buffer, bytes, $"deleting {name}"); } + try { TrackedGlResource.DeleteBuffer(_gl!, buffer, bytes, $"deleting {name}"); } catch (Exception ex) { (failures ??= []).Add(ex); } } @@ -3405,7 +3455,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable ref capacityBytes, data, byteCount); - _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo); + _gl!.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo); } private unsafe void UploadDynamicBuffer( @@ -3418,7 +3468,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable if (byteCount < 0) throw new ArgumentOutOfRangeException(nameof(byteCount)); - _gl.BindBuffer(target, buffer); + _gl!.BindBuffer(target, buffer); // A render bucket can legitimately contain zero batches (for example the outdoor dynamic // bucket immediately after auto-entry). Keep the buffer bound for the corresponding SSBO // binding, but there is no active prefix to allocate or upload and no draw can read it. @@ -3474,7 +3524,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable { AcDream.App.Rendering.Gpu.Gl.GlGpuDevice device = WorldTextureTable; device.FlushTextureTable(); - _gl.BindBufferBase( + _gl!.BindBufferBase( BufferTargetARB.ShaderStorageBuffer, AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable, device.TextureTableGlName); @@ -3491,14 +3541,14 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable { if (_sharedClipRegionSsbo != 0) { - _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, + _gl!.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, ClipFrame.MeshClipSsboBinding, _sharedClipRegionSsbo); return; } if (_fallbackClipRegionSsbo == 0) { - _fallbackClipRegionSsbo = _gl.GenBuffer(); + _fallbackClipRegionSsbo = _gl!.GenBuffer(); // One CellClip slot, all zeros: count 0 ⇒ shader passes every plane. var zero = stackalloc byte[ClipFrame.CellClipStrideBytes]; for (int i = 0; i < ClipFrame.CellClipStrideBytes; i++) zero[i] = 0; @@ -3506,7 +3556,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable _gl.BufferData(BufferTargetARB.ShaderStorageBuffer, (nuint)ClipFrame.CellClipStrideBytes, zero, BufferUsageARB.DynamicDraw); } - _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, + _gl!.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, ClipFrame.MeshClipSsboBinding, _fallbackClipRegionSsbo); } @@ -4105,7 +4155,13 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable if (_disposeResources is null) { var releases = new List<(string Name, Action Release)>(); - BuildDisposeReleases(releases); + // Campaign V slice V6j: the encoder arm owns no GL names. Its + // pipelines route their physical free through the device's + // retirement queue, so the ledger below is empty there. + if (_gl is null) + DisposeRhiResources(); + else + BuildDisposeReleases(releases); _disposeResources = new RetryableResourceReleaseLedger(releases); } @@ -4146,7 +4202,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable _fallbackClipRegionSsbo, "fallback-clip-region", "deleting entity fallback clip SSBO", - _gl.DeleteBuffer); + _gl!.DeleteBuffer); if (!_gpuQueriesInitialized) return; @@ -4208,7 +4264,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable return; RetryableGpuResourceRelease release = TrackedGlResource.CreateRetryableBufferDeletion( - _gl, + _gl!, buffer, capacityBytes, context); @@ -4225,11 +4281,11 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable if (resource == 0) return; var release = new RetryableGpuResourceRelease( - () => GLHelpers.ThrowOnResourceError(_gl, $"{context} (precondition)"), + () => GLHelpers.ThrowOnResourceError(_gl!, $"{context} (precondition)"), () => { delete(resource); - GLHelpers.ThrowOnResourceError(_gl, context); + GLHelpers.ThrowOnResourceError(_gl!, context); }); releases.Add((name, release.Run)); } diff --git a/src/AcDream.App/Rendering/WorldPassScope.cs b/src/AcDream.App/Rendering/WorldPassScope.cs new file mode 100644 index 00000000..402a3e8a --- /dev/null +++ b/src/AcDream.App/Rendering/WorldPassScope.cs @@ -0,0 +1,198 @@ +using AcDream.App.Rendering.Gpu; +using AcDream.Core.Lighting; + +namespace AcDream.App.Rendering; + +/// +/// A buffer range reduced to the three values a bind needs. +/// +/// is a ref struct — deliberately, +/// so nothing can outlive the frame's memory — but the buffer reference plus its +/// offset and size are ordinary values and stay valid for as long as that memory +/// does. That is what lets one writer publish a section and several renderers +/// bind it later in the same frame without recopying. +/// +internal readonly record struct GpuBufferSection( + IGpuBuffer? Buffer, + uint OffsetBytes, + uint SizeBytes) +{ + public bool IsValid => Buffer is not null && SizeBytes > 0; +} + +/// +/// Campaign V slice V6j: the three sections GL binds frame-globally and Vulkan +/// cannot. +/// +/// On GL the SceneLighting UBO (set 1 binding 1), the per-cell clip regions +/// (set 0 binding 2) and the terrain clip block (set 1 binding 2) are each bound +/// once, to a global binding point, and every consumer inherits them. Vulkan has +/// no global binding points: a descriptor set is bound per draw, and a renderer +/// that binds its own buffers selects the descriptor scope those sections have to +/// land in (plan §5.5.14 item 2). So the writers PUBLISH here and each renderer +/// binds them inside the pass, after its own binds. +/// +/// Borrowed for the frame that publishes it — the sections are ring slices +/// and die when the frame retires. +/// +internal sealed class WorldFrameSections +{ + /// Set 1 binding 1 — SceneLighting. + public GpuBufferSection SceneLighting { get; set; } + + /// Set 0 binding 2 — the per-cell CellClip table. + public GpuBufferSection ClipRegions { get; set; } + + /// Set 1 binding 2 — TerrainClip. + public GpuBufferSection TerrainClip { get; set; } + + public void Reset() + { + SceneLighting = default; + ClipRegions = default; + TerrainClip = default; + } +} + +/// +/// Campaign V slice V6j: the one render pass every world renderer records into, +/// borrowed rather than opened. +/// +/// Why they cannot each open one. Under MSAA the frame's backbuffer +/// pass renders into a multisampled scratch image and RESOLVES it into the +/// swapchain image, storing DONT_CARE into the scratch — so a second pass +/// declaring Load would load undefined contents and lose everything the +/// first drew. The Vulkan backend also permits only one open pass per frame. +/// V4c's shape, where WbDrawDispatcher and EnvCellRenderer each +/// bracketed their own Load/Store pass, is therefore not available +/// on this backend (plan §5.5.12 item 5, §5.5.14 item 1). +/// +/// So the world-scene phase opens the pass, publishes the encoder here for +/// the duration of WorldSceneRenderer, and every renderer borrows it. +/// +internal interface IWorldPassScope +{ + /// + /// Sample count of the pass, and therefore of every pipeline recorded into + /// it. Vulkan requires a pipeline's rasterizationSamples to match the + /// pass, and alpha-to-coverage does nothing at one sample — which is why + /// V4c's blanket SampleCount = 1 is not portable (plan §5.5.14 item 6). + /// + int SampleCount { get; } + + /// The open encoder, or null outside the world phase. + IGpuPassEncoder? CurrentEncoder { get; } + + /// The open encoder, or a composition error if the phase is not bracketing. + IGpuPassEncoder RequireEncoder(); + + /// Colour-attachment width in pixels, for scissor and clear rectangles. + int AttachmentWidth { get; } + + /// Colour-attachment height in pixels. + int AttachmentHeight { get; } + + /// + /// Retail's interior depth clear, scoped to the live pass. + /// + /// RetailPViewPassExecutor issues glClear(GL_DEPTH_BUFFER_BIT) + /// between the landscape slice and the interior cells. Splitting the pass to + /// get a depth load-op is exactly what the resolve forbids, so the backend + /// records vkCmdClearAttachments instead — reached through here, so the + /// pinned contract stays frozen and the backend-only verb stays inside the + /// backend (plan §5.5.14 item 3). + /// + void ClearInteriorDepth(); + + /// The frame-global sections every renderer in this pass rebinds. + WorldFrameSections Sections { get; } +} + +/// +/// Campaign V slice V6j: binds the frame-global sections inside a renderer's own +/// draw, which is the only place they can go on Vulkan. +/// +/// Each helper falls back to a zeroed ring slice when nothing published the +/// section. That is the same rule the GL arm already states for its own bindings +/// — bind at least one element so the shader never reads an unbound buffer — +/// applied to the three sections whose publisher runs outside the renderer. +/// +internal static class WorldFrameSectionBinding +{ + internal static void BindSceneLighting( + IGpuPassEncoder encoder, + WorldFrameSections sections, + IGpuFrame frame) + { + GpuBufferSection section = sections.SceneLighting; + if (!section.IsValid) + { + section = Zeroed( + frame, + SceneLightingUbo.SizeInBytes, + GpuRingUsage.Uniform); + } + + encoder.BindUniformBuffer( + (uint)SceneLightingUbo.BindingPoint, + section.Buffer!, + section.OffsetBytes, + section.SizeBytes); + } + + internal static void BindClipRegions( + IGpuPassEncoder encoder, + WorldFrameSections sections, + IGpuFrame frame) + { + GpuBufferSection section = sections.ClipRegions; + if (!section.IsValid) + { + // Slot 0 zeroed is retail's "no clip": count 0 means ungated. + section = Zeroed( + frame, + ClipFrame.CellClipStrideBytes, + GpuRingUsage.Storage); + } + + encoder.BindStorageBuffer( + GpuBindingModel.StorageClipRegions, + section.Buffer!, + section.OffsetBytes, + section.SizeBytes); + } + + internal static void BindTerrainClip( + IGpuPassEncoder encoder, + WorldFrameSections sections, + IGpuFrame frame) + { + GpuBufferSection section = sections.TerrainClip; + if (!section.IsValid) + { + section = Zeroed( + frame, + ClipFrame.TerrainUboBytes, + GpuRingUsage.Uniform); + } + + encoder.BindUniformBuffer( + ClipFrame.TerrainClipUboBinding, + section.Buffer!, + section.OffsetBytes, + section.SizeBytes); + } + + private static GpuBufferSection Zeroed( + IGpuFrame frame, + int byteCount, + GpuRingUsage usage) + { + GpuRingAllocation allocation = frame.AllocateRing(byteCount, usage); + allocation.Data.Clear(); + return new GpuBufferSection( + allocation.Buffer, + allocation.OffsetBytes, + (uint)byteCount); + } +} diff --git a/src/AcDream.App/Rendering/WorldPassSurface.cs b/src/AcDream.App/Rendering/WorldPassSurface.cs new file mode 100644 index 00000000..b4aee130 --- /dev/null +++ b/src/AcDream.App/Rendering/WorldPassSurface.cs @@ -0,0 +1,319 @@ +using System.Numerics; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Wb; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// Campaign V slice V6j: the small graphics surface the two world pass executors +/// touch directly, expressed once so their ordering logic — which is retail's, +/// and heavily tested — is written once for both backends. +/// +/// Everything else those executors do is delegation to a renderer. What is +/// left is exactly this: the clip-frame publication, the doorway scissor, +/// gl_ClipDistance enablement, and retail's interior depth clear. Four +/// concerns, each of which genuinely differs between GL and Vulkan, and none of +/// which is expressible on the pinned RHI contract. +/// +internal interface IWorldPassSurface +{ + /// + /// Publishes this frame's per-cell clip-region table and terrain clip block, + /// and routes both to the renderers that read them. + /// + /// is how many distinct terrain + /// clip blocks the frame will issue. GL reserves that many arena records + /// before the first draw, because reallocating the arena while an earlier + /// slice can still reference it is the hazard the reservation exists for. The + /// RHI arm ignores it: a ring allocation is distinct memory by construction + /// and lives until the frame retires. + /// + void PrepareClipFrame(int terrainUploadCount); + + /// Replaces the terrain clip planes and republishes the block. + void SetTerrainClip(ReadOnlySpan planes); + + /// + /// Re-asserts the terrain clip block at its binding. + /// + /// GL needs this because binding points are global and the sky and mesh + /// shaders read the same uniform binding between two terrain slices. On the + /// RHI arm every consumer binds the published section inside the pass, so + /// there is no ambient binding to re-assert and this is a no-op. + /// + void BindTerrainClip(); + + /// + /// Enables every gl_ClipDistance slot. + /// + /// Vulkan activates every element the shader declares and has no + /// enable, so this is a no-op there — and that is safe rather than a + /// divergence: all three world vertex shaders already write 1.0 + /// ("keep everything") into every slot past the active count, so a frame with + /// no clip planes clips nothing on either backend (plan §5.5.14 item 4). + /// + void EnableClipDistances(); + + /// Disables every gl_ClipDistance slot. See . + void DisableClipDistances(); + + /// + /// Scissors to a doorway slice's screen-space bounding box. Returns whether a + /// scissor is now active, so the caller can pair it with . + /// + bool BeginScissor(Vector4 ndcAabb); + + /// Restores the full drawable rectangle. + void EndScissor(); + + /// + /// Retail's interior depth clear, between the landscape slice and the + /// interior cells (PView::DrawCells @ 0x005A4840). + /// + void ClearInteriorDepth(); +} + +/// +/// The GL arm. Every statement below is the one the executor used to issue +/// inline, in the same order, against the same objects. +/// +internal sealed class GlWorldPassSurface : IWorldPassSurface +{ + private readonly GL _gl; + private readonly ClipFrame _clipFrame; + private readonly IRetailPViewFramebufferSource _framebuffer; + private readonly WbDrawDispatcher _entities; + private readonly EnvCellRenderer _envCells; + private readonly TerrainModernRenderer? _terrain; + + public GlWorldPassSurface( + GL gl, + ClipFrame clipFrame, + IRetailPViewFramebufferSource framebuffer, + WbDrawDispatcher entities, + EnvCellRenderer envCells, + TerrainModernRenderer? terrain) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame)); + _framebuffer = framebuffer ?? throw new ArgumentNullException(nameof(framebuffer)); + _entities = entities ?? throw new ArgumentNullException(nameof(entities)); + _envCells = envCells ?? throw new ArgumentNullException(nameof(envCells)); + _terrain = terrain; + } + + public void PrepareClipFrame(int terrainUploadCount) + { + // Allocate every terrain record before issuing the first draw. BufferData + // must not replace the arena while an earlier slice can reference it. + _clipFrame.ReserveTerrainUploads(_gl, terrainUploadCount); + _clipFrame.UploadRegions(_gl); + _entities.SetClipRegionSsbo(_clipFrame.RegionSsbo); + _envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo); + UploadTerrainClip(); + } + + public void SetTerrainClip(ReadOnlySpan planes) + { + _clipFrame.SetTerrainClip(planes); + UploadTerrainClip(); + } + + public void BindTerrainClip() => _clipFrame.BindTerrainClip(_gl); + + public void EnableClipDistances() + { + for (int index = 0; index < ClipFrame.MaxPlanes; index++) + _gl.Enable(EnableCap.ClipDistance0 + index); + } + + public void DisableClipDistances() + { + for (int index = 0; index < ClipFrame.MaxPlanes; index++) + _gl.Disable(EnableCap.ClipDistance0 + index); + } + + public bool BeginScissor(Vector4 ndcAabb) + { + RetailPViewFramebufferSize framebuffer = _framebuffer.Capture(); + var box = NdcScissorRect.ToPixels( + ndcAabb, + framebuffer.Width, + framebuffer.Height); + _gl.Enable(EnableCap.ScissorTest); + _gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height); + return true; + } + + public void EndScissor() => _gl.Disable(EnableCap.ScissorTest); + + public void ClearInteriorDepth() + { + _gl.Disable(EnableCap.ScissorTest); + _gl.DepthMask(true); + _gl.Clear(ClearBufferMask.DepthBufferBit); + } + + private void UploadTerrainClip() + { + TerrainClipBufferBinding binding = _clipFrame.UploadTerrainClip(_gl); + _terrain?.SetClipUbo(binding); + } +} + +/// +/// The RHI arm. The clip tables become ring sections published on the world pass +/// scope, the scissor becomes dynamic state on the borrowed encoder, and the +/// depth clear becomes a scoped vkCmdClearAttachments. +/// +internal sealed class RhiWorldPassSurface : IWorldPassSurface +{ + private readonly IWorldPassScope _scope; + private readonly ICurrentGpuFrameSource _frames; + private readonly ClipFrame _clipFrame; + private readonly IRetailPViewFramebufferSource _framebuffer; + + public RhiWorldPassSurface( + IWorldPassScope scope, + ICurrentGpuFrameSource frames, + ClipFrame clipFrame, + IRetailPViewFramebufferSource framebuffer) + { + _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + _frames = frames ?? throw new ArgumentNullException(nameof(frames)); + _clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame)); + _framebuffer = framebuffer ?? throw new ArgumentNullException(nameof(framebuffer)); + } + + public void PrepareClipFrame(int terrainUploadCount) + { + // The reservation count has no RHI counterpart: each publication below + // takes its own ring slice, which is distinct memory that outlives every + // draw recorded against it in this frame. + _ = terrainUploadCount; + _scope.Sections.ClipRegions = Publish( + _clipFrame.RegionBytes, + GpuRingUsage.Storage); + PublishTerrainClip(); + } + + public void SetTerrainClip(ReadOnlySpan planes) + { + _clipFrame.SetTerrainClip(planes); + PublishTerrainClip(); + } + + /// No-op: there is no ambient binding to re-assert. See the interface. + public void BindTerrainClip() + { + } + + /// No-op: Vulkan activates every declared clip distance. See the interface. + public void EnableClipDistances() + { + } + + /// No-op: Vulkan activates every declared clip distance. See the interface. + public void DisableClipDistances() + { + } + + public bool BeginScissor(Vector4 ndcAabb) + { + RetailPViewFramebufferSize framebuffer = _framebuffer.Capture(); + var box = NdcScissorRect.ToPixels( + ndcAabb, + framebuffer.Width, + framebuffer.Height); + // GL convention on the way in; the backend performs its own Y flip. + _scope.RequireEncoder().SetScissor(box.X, box.Y, box.Width, box.Height); + return true; + } + + public void EndScissor() => + _scope.RequireEncoder().SetScissor( + 0, + 0, + _scope.AttachmentWidth, + _scope.AttachmentHeight); + + public void ClearInteriorDepth() + { + // The GL arm drops the scissor before clearing so the clear covers the + // whole target; vkCmdClearAttachments takes its own rectangle and is not + // scissored, so the same coverage comes for free — but the scissor still + // has to come off, because the interior cells drawn after it are not + // confined to the doorway slice that was active. + EndScissor(); + _scope.ClearInteriorDepth(); + } + + private void PublishTerrainClip() => + _scope.Sections.TerrainClip = Publish( + _clipFrame.TerrainBytes, + GpuRingUsage.Uniform); + + private GpuBufferSection Publish(ReadOnlySpan data, GpuRingUsage usage) + { + IGpuFrame frame = _frames.CurrentFrame + ?? throw new InvalidOperationException( + "The world clip frame requires an open IGpuFrame (see GpuDeviceFrameLifetime)."); + // A logically empty table still reserves one slot so the bound range is + // never zero-length — the same rule the light buffers already state. + int byteCount = Math.Max(data.Length, ClipFrame.CellClipStrideBytes); + GpuRingAllocation allocation = frame.AllocateRing(byteCount, usage); + allocation.Data.Clear(); + if (!data.IsEmpty) + data.CopyTo(allocation.Data); + return new GpuBufferSection( + allocation.Buffer, + allocation.OffsetBytes, + (uint)byteCount); + } +} + +/// +/// Campaign V slice V6j: the frame-default restore on a backend with no ambient +/// state to restore. +/// +/// Both executors call +/// when aborting a failed frame. On Vulkan every piece of state that call +/// re-establishes is either baked into a pipeline or set per draw, so there is +/// nothing to put back and saying so is more honest than composing a GL +/// controller that would have no context to talk to. +/// +internal sealed class NullRenderFrameGlState : IRenderFrameGlState +{ + public static NullRenderFrameGlState Instance { get; } = new(); + + private NullRenderFrameGlState() + { + } + + public void RestoreFrameDefaults() + { + } +} + +/// +/// Campaign V slice V6j: the GL state reader on a backend with no GL state. +/// +/// WorldRenderDiagnostics reads live GL state for explicitly enabled +/// probes only. Every snapshot below is the truthful answer for a Vulkan frame — +/// there is no ambient capability state to sample — which keeps every other +/// diagnostic the class emits (render signature, PView input, out-stage routing, +/// phantom objects) working unchanged on both backends. +/// +internal sealed class NullRenderGlStateReader : IRenderGlStateReader +{ + public static NullRenderGlStateReader Instance { get; } = new(); + + private NullRenderGlStateReader() + { + } + + public RenderGlStateSnapshot CaptureState() => default; + + public RenderGlScissorSnapshot CaptureScissor() => default; +} diff --git a/src/AcDream.App/Rendering/WorldScenePassExecutor.cs b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs index 081cd5e9..af85081e 100644 --- a/src/AcDream.App/Rendering/WorldScenePassExecutor.cs +++ b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs @@ -3,7 +3,6 @@ using AcDream.App.Rendering.Wb; using AcDream.Core.Rendering; using AcDream.Core.Vfx; using AcDream.Core.World; -using Silk.NET.OpenGL; namespace AcDream.App.Rendering; @@ -51,13 +50,18 @@ internal interface IWorldScenePassExecutor } /// -/// Concrete GL leaf for the flat-world safety path and the post-world particle +/// Concrete leaf for the flat-world safety path and the post-world particle /// and weather passes. Retail PView frames remain owned by /// and . +/// +/// Campaign V slice V6j: backend-neutral. Everything it does is either +/// delegation to a renderer or one of the four concerns +/// owns, so one implementation serves both +/// backends and the retail ordering is written once. /// internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor { - private readonly GL _gl; + private readonly IWorldPassSurface _surface; private readonly IRenderFrameGlState _frameGlState; private readonly ClipFrame _clipFrame; private readonly WbDrawDispatcher _entities; @@ -71,7 +75,7 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor private readonly HashSet _noExcludedParticleOwners = []; public WorldScenePassExecutor( - GL gl, + IWorldPassSurface surface, IRenderFrameGlState frameGlState, ClipFrame clipFrame, WbDrawDispatcher entities, @@ -82,7 +86,7 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor ParticleSystem? particles, ParticleRenderer? particleRenderer) { - _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _surface = surface ?? throw new ArgumentNullException(nameof(surface)); _frameGlState = frameGlState ?? throw new ArgumentNullException(nameof(frameGlState)); _clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame)); @@ -107,15 +111,7 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor _environmentCells.SetClipRouting(null); } - public void PrepareFlatWorldClip() - { - _clipFrame.ReserveTerrainUploads(_gl, 1); - _clipFrame.UploadRegions(_gl); - TerrainClipBufferBinding terrainBinding = _clipFrame.UploadTerrainClip(_gl); - _entities.SetClipRegionSsbo(_clipFrame.RegionSsbo); - _environmentCells.SetClipRegionSsbo(_clipFrame.RegionSsbo); - _terrain?.SetClipUbo(terrainBinding); - } + public void PrepareFlatWorldClip() => _surface.PrepareClipFrame(1); public void DrawFlatSky( in WorldCameraFrame camera, @@ -123,8 +119,8 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor DayGroupData? activeDayGroup, float dayFraction) { - _clipFrame.BindTerrainClip(_gl); - EnableClipDistances(); + _surface.BindTerrainClip(); + _surface.EnableClipDistances(); Exception? drawFailure = null; try { @@ -169,7 +165,7 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor in WorldCameraFrame camera, uint? playerLandblockId) { - EnableClipDistances(); + _surface.EnableClipDistances(); _terrainDiagnostics.Begin(); _terrain?.Draw( camera.Camera, @@ -245,8 +241,8 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor DayGroupData? activeDayGroup, float dayFraction) { - _clipFrame.BindTerrainClip(_gl); - EnableClipDistances(); + _surface.BindTerrainClip(); + _surface.EnableClipDistances(); Exception? drawFailure = null; try { @@ -287,11 +283,7 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor } } - public void DisableClipDistances() - { - for (int index = 0; index < ClipFrame.MaxPlanes; index++) - _gl.Disable(EnableCap.ClipDistance0 + index); - } + public void DisableClipDistances() => _surface.DisableClipDistances(); public void AbortFrame() { @@ -318,12 +310,6 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor } } - private void EnableClipDistances() - { - for (int index = 0; index < ClipFrame.MaxPlanes; index++) - _gl.Enable(EnableCap.ClipDistance0 + index); - } - private static string AppendSignature(string current, string value) => current == "none" ? value : current + "+" + value; } diff --git a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs index 0383966d..30b3ba58 100644 --- a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs @@ -277,6 +277,11 @@ public sealed class WorldRenderCompositionTests public SceneLightingUboBinding CreateSceneLighting(GL gl) => Resource("scene lighting"); + public SceneLightingUboBinding CreateBackendNeutralSceneLighting( + ICurrentGpuFrameSource frameSource, + IWorldPassScope scope) => + Resource("scene lighting"); + public DebugLineRenderer CreateDebugLines( IGpuDevice device, ICurrentGpuFrameSource frameSource, string shadersDirectory) => Resource("debug lines"); @@ -299,6 +304,14 @@ public sealed class WorldRenderCompositionTests IGpuResourceRetirementQueue retirement) => Resource("terrain"); + public TerrainModernRenderer CreateBackendNeutralTerrain( + IGpuDevice gpuDevice, + ICurrentGpuFrameSource frameSource, + IWorldPassScope scope, + TerrainAtlas atlas, + IGpuResourceRetirementQueue retirement) => + Resource("terrain"); + public WorldTerrainBuildContext CreateTerrainBuildContext( uint initialCenterLandblockId, float[] heightTable, diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index a7b60087..bc949b59 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -597,7 +597,11 @@ public sealed class GameWindowSlice8BoundaryTests "lifetime.AcquireTerrainAtlas(", "TerrainAtlas.Build(gl, dats, bindless)", "TerrainModernRenderer CreateTerrain(", - "TerrainModernRenderer? terrain = AcquireAndPublishIf("); + // Campaign V slice V6j: terrain is composed on both arms, so its + // acquisition is unconditional. The boundary this test pins — that + // the atlas is acquired, then the factory names the renderer, then + // the renderer is acquired AND published in one step — is unchanged. + "TerrainModernRenderer? terrain = AcquireAndPublish("); AssertAppearsInOrder( shutdown, "frame.FrameGraphPublication?.Dispose()", diff --git a/tools/run-offline-vulkan-capture.ps1 b/tools/run-offline-vulkan-capture.ps1 new file mode 100644 index 00000000..4702a388 --- /dev/null +++ b/tools/run-offline-vulkan-capture.ps1 @@ -0,0 +1,94 @@ +<# +.SYNOPSIS + Campaign V: one offline Vulkan capture with the validation layer proven loaded. + +.DESCRIPTION + The same deterministic offline scene the GL pixel gate captures, rendered on + ACDREAM_RENDER_BACKEND=vulkan. VK_LOADER_DEBUG=layer makes the loader print + the layer it inserts, so "validation was on" is evidence rather than a claim. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Out, + [int]$WarmupMs = 12000, + [int]$DayGroup = 0, + [switch]$SkipBuild +) + +$ErrorActionPreference = 'Stop' +$repo = Split-Path -Parent $PSScriptRoot +$exe = Join-Path $repo 'src\AcDream.App\bin\Release\net10.0\AcDream.App.exe' + +function Write-Step($message) { Write-Host "[vk-capture] $message" } + +if (-not $SkipBuild) { + & dotnet build (Join-Path $repo 'AcDream.slnx') -c Release --nologo -v q | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Release build failed with exit code $LASTEXITCODE." } +} +if (-not (Test-Path $exe)) { throw "Client not found at $exe." } + +if (Test-Path $Out) { Remove-Item -Recurse -Force $Out } +New-Item -ItemType Directory -Force -Path $Out | Out-Null + +$probe = Join-Path $Out 'vk.probe.txt' +Set-Content -Encoding utf8 -Path $probe -Value @" +sleep $WarmupMs +screenshot vk-offline 30000 +sleep 500 +"@ + +$log = Join-Path $Out 'client.log' +$previousLive = $env:ACDREAM_LIVE +Remove-Item Env:\ACDREAM_LIVE -ErrorAction SilentlyContinue +$env:ACDREAM_DAT_DIR = Join-Path $env:USERPROFILE "Documents\Asheron's Call" +$env:ACDREAM_NO_AUDIO = '1' +$env:ACDREAM_RETAIL_UI = '1' +$env:ACDREAM_DAY_GROUP = "$DayGroup" +$env:ACDREAM_UI_PROBE_SCRIPT = $probe +$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $Out +$env:ACDREAM_RENDER_BACKEND = 'vulkan' +$env:VK_INSTANCE_LAYERS = 'VK_LAYER_KHRONOS_validation' +$env:VK_LOADER_DEBUG = 'layer' + +Write-Step "launching Vulkan offline client (warmup ${WarmupMs}ms, day group $DayGroup)" +$proc = Start-Process -FilePath $exe -RedirectStandardOutput $log ` + -RedirectStandardError "$log.err" -PassThru -WindowStyle Minimized + +try { + $shots = Join-Path $Out 'screenshots' + $deadline = (Get-Date).AddMilliseconds($WarmupMs + 60000) + $captured = $false + while ((Get-Date) -lt $deadline) { + if ((Test-Path $shots) -and (Get-ChildItem $shots -Filter *.png -ErrorAction SilentlyContinue)) { + $captured = $true + break + } + if ($proc.HasExited) { break } + Start-Sleep -Milliseconds 1000 + } + if ($captured) { Start-Sleep -Milliseconds 1500 } + else { Write-Step 'no screenshot captured before the deadline' } +} +finally { + $app = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue + if ($app) { + $app.CloseMainWindow() | Out-Null + if (-not $app.WaitForExit(15000)) { + Write-Step 'WM_CLOSE timed out; forcing' + $app | Stop-Process -Force + } + } + Remove-Item Env:\ACDREAM_RENDER_BACKEND -ErrorAction SilentlyContinue + Remove-Item Env:\VK_INSTANCE_LAYERS -ErrorAction SilentlyContinue + Remove-Item Env:\VK_LOADER_DEBUG -ErrorAction SilentlyContinue + if ($previousLive) { $env:ACDREAM_LIVE = $previousLive } +} + +Write-Step "exit code $($proc.ExitCode)" +Write-Step 'layer insertion evidence:' +Select-String -Path $log, "$log.err" -Pattern 'Insert instance layer' -ErrorAction SilentlyContinue | + Select-Object -First 3 | ForEach-Object { Write-Host " $($_.Line)" } +Write-Step 'validation output:' +$vuids = Select-String -Path $log, "$log.err" -Pattern 'VUID-|UNASSIGNED-|Validation Error|Validation Warning' -ErrorAction SilentlyContinue +if ($vuids) { $vuids | Select-Object -First 20 | ForEach-Object { Write-Host " $($_.Line)" } } +else { Write-Host ' none' }