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