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' }