diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs
index 7dcabd57..fc4ad107 100644
--- a/src/AcDream.App/Composition/LivePresentationComposition.cs
+++ b/src/AcDream.App/Composition/LivePresentationComposition.cs
@@ -801,8 +801,14 @@ internal sealed class LivePresentationCompositionPhase
CompositionAcquisitionScope.CompositionAcquisitionLease<
PaperdollViewportRenderer>? paperdollLease = null;
PaperdollFramePresenter? paperdollPresenter = null;
- if (gl is not null
- && dispatcherLease.Resource is { } paperdollDispatcher
+ // Campaign V slice V6l: both retained-UI viewports exist on BOTH arms.
+ // The RHI arm needed two backend fixes first — a layered sampled view per
+ // render target, so a colour attachment can legally enter the
+ // sampler2DArray table, and sample-count pipeline variants in
+ // WbDrawDispatcher, because a world pipeline is built at the backbuffer's
+ // count and an offscreen target is single-sampled by contract (plan
+ // §5.5.16 defect 3).
+ if (dispatcherLease.Resource is { } paperdollDispatcher
&& interaction.RetainedUi?.Runtime.PaperdollViewportWidget is { } viewport
&& interaction.RetainedUi.Runtime.InventoryFrame is { } inventoryFrame)
{
@@ -810,6 +816,7 @@ internal sealed class LivePresentationCompositionPhase
"paperdoll viewport",
() => new PaperdollViewportRenderer(
gl,
+ worldPassScope,
host.GpuDevice,
host.GpuFrameLifetime,
paperdollDispatcher,
@@ -843,8 +850,7 @@ internal sealed class LivePresentationCompositionPhase
CompositionAcquisitionScope.CompositionAcquisitionLease<
CreatureAppraisalViewportRenderer>? creatureAppraisalLease = null;
CreatureAppraisalFramePresenter? creatureAppraisalPresenter = null;
- if (gl is not null
- && dispatcherLease.Resource is { } appraisalDispatcher
+ if (dispatcherLease.Resource is { } appraisalDispatcher
&& interaction.RetainedUi?.Runtime.CreatureAppraisalViewportWidget
is { } creatureViewport
&& interaction.RetainedUi.Runtime.ExaminationFrame
@@ -856,6 +862,7 @@ internal sealed class LivePresentationCompositionPhase
"creature appraisal viewport",
() => new CreatureAppraisalViewportRenderer(
gl,
+ worldPassScope,
host.GpuDevice,
host.GpuFrameLifetime,
appraisalDispatcher,
diff --git a/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs b/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs
index 83180620..824e585b 100644
--- a/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs
+++ b/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs
@@ -377,7 +377,8 @@ internal sealed class CreatureAppraisalViewportRenderer :
private readonly PrivateEntityViewportRenderer _renderer;
public CreatureAppraisalViewportRenderer(
- GL gl,
+ GL? gl,
+ IWorldPassScope? scope,
AcDream.App.Rendering.Gpu.IGpuDevice device,
ICurrentGpuFrameSource frames,
WbDrawDispatcher dispatcher,
@@ -387,6 +388,7 @@ internal sealed class CreatureAppraisalViewportRenderer :
{
_renderer = new PrivateEntityViewportRenderer(
gl,
+ scope,
device,
frames,
dispatcher,
@@ -398,6 +400,8 @@ internal sealed class CreatureAppraisalViewportRenderer :
"creature examination");
}
+ public bool TextureIsBottomUp => _renderer.TextureIsBottomUp;
+
public void SetCreature(
WorldEntity? creature,
Vector3 boundsMin,
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs
index 006e9302..a2f140cc 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs
@@ -245,7 +245,8 @@ internal sealed unsafe partial class VulkanGpuDevice
_uploads,
_flights,
_debugNames,
- description);
+ description,
+ DepthStencilFormat);
}
public GpuTextureSlot RegisterTexture(IGpuTexture texture, IGpuSampler sampler)
@@ -258,27 +259,16 @@ internal sealed unsafe partial class VulkanGpuDevice
if (sampler is not VulkanGpuSampler vulkanSampler)
throw new ArgumentException("The Vulkan backend can only register a Vulkan sampler.", nameof(sampler));
- // Campaign V slice V6k, the §5.5.7 re-check. A render-target image is
- // viewed as VK_IMAGE_VIEW_TYPE_2D because that is what an ATTACHMENT
- // needs; the texture table's descriptor array is declared
- // sampler2DArray, so writing that view into it is invalid usage rather
- // than a mismatch that samples oddly. §5.5.7 recorded that this "did not
- // fire" and asked that it be re-checked rather than accepted, and the
- // reason it still does not fire is that the one renderer with an
- // offscreen target — PrivateEntityViewportRenderer — is composed on GL
- // only. Making the precondition loud here is what stops that from being
- // discovered as a driver-level fault the first time it is composed;
- // serving it needs a SECOND, layered view per render target, which
- // belongs to the slice that gives the Vulkan arm a viewport.
- if (VulkanTextureFormatMapping.IsRenderTarget(vulkanTexture.Format))
- {
- throw new NotSupportedException(
- $"Render target '{vulkanTexture.Name}' cannot be registered into the texture table: "
- + "its attachment view is 2-D and the table's descriptor array is sampler2DArray. "
- + "A layered sampled view per render target is the fix (campaign plan §5.5.7).");
- }
-
- return TextureTable.Register(vulkanTexture.View, vulkanSampler.Handle);
+ // Campaign V slice V6k made this a loud refusal, and V6l is the slice
+ // that serves it. A render-target image is viewed as
+ // VK_IMAGE_VIEW_TYPE_2D because that is what an ATTACHMENT needs, while
+ // the table's descriptor array is declared sampler2DArray — so the
+ // attachment view is invalid usage here rather than a mismatch that
+ // samples oddly (plan §5.5.7). VulkanGpuTexture now creates a SECOND,
+ // layered view over the same image for exactly this, and every texture
+ // that is not an attachment has always had one; SampledView is that view
+ // in both cases, so the question disappears rather than being answered.
+ return TextureTable.Register(vulkanTexture.SampledView, vulkanSampler.Handle);
}
public void ReleaseTextureSlot(GpuTextureSlot slot)
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs
index 60f22c15..4f679a94 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs
@@ -12,6 +12,12 @@ namespace AcDream.App.Rendering.Gpu.Vk;
/// so it can be registered into the texture table and drawn by the retained UI
/// the moment its pass ends — which is the whole reason these exist rather than
/// rendering those views onto the backbuffer.
+///
+/// Slice V6l made both halves of that sentence true. The colour image now
+/// carries a second, LAYERED view for the table to sample (see
+/// ), and the depth image takes the
+/// device's own combined depth/stencil format so the world pipelines that draw
+/// into this target are not undefined against it.
///
internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget
{
@@ -26,7 +32,8 @@ internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget
VulkanUploadQueue uploads,
IGpuResourceRetirementQueue retirement,
VulkanDebugNames debugNames,
- in GpuRenderTargetDescription description)
+ in GpuRenderTargetDescription description,
+ Format deviceDepthStencilFormat)
{
ArgumentException.ThrowIfNullOrWhiteSpace(description.Name);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Width);
@@ -69,7 +76,13 @@ internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget
LayerCount: 1,
MipLevelCount: 1),
Math.Max(1, description.SampleCount),
- renderTarget: true);
+ renderTarget: true,
+ // Slice V6l: the DEVICE's combined depth/stencil format, not the
+ // contract enum's literal one. Every pipeline bakes one
+ // depth/stencil format under dynamic rendering and the same
+ // pipelines draw in both the backbuffer pass and this one, so a
+ // second format here would make one of the two undefined.
+ formatOverride: deviceDepthStencilFormat);
}
}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs
index 19f6b0a0..31264aae 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs
@@ -40,7 +40,10 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
VulkanDebugNames debugNames,
in GpuTextureDescription description,
int sampleCount = 1,
- bool renderTarget = false)
+ bool renderTarget = false,
+ // Fully qualified: in a parameter-default expression the simple name
+ // `Format` binds to this type's own GpuTextureFormat property first.
+ Format formatOverride = Silk.NET.Vulkan.Format.Undefined)
{
_vk = vk ?? throw new ArgumentNullException(nameof(vk));
_device = device;
@@ -61,7 +64,14 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
LayerCount = description.LayerCount;
MipLevelCount = description.MipLevelCount;
SampleCount = sampleCount;
- VkFormat = VulkanTextureFormatMapping.FormatOf(description.Format);
+ // Slice V6l: an offscreen target's DEPTH attachment takes the format the
+ // device already chose for the backbuffer, because a pipeline bakes one
+ // depth/stencil format and draws in both kinds of pass. The contract's
+ // Depth24Stencil8 means "a combined depth+stencil attachment"; which
+ // combined format that is belongs to the device that probed for it.
+ VkFormat = formatOverride != Silk.NET.Vulkan.Format.Undefined
+ ? formatOverride
+ : VulkanTextureFormatMapping.FormatOf(description.Format);
Aspect = VulkanTextureFormatMapping.AspectOf(description.Format);
bool depthStencil = VulkanTextureFormatMapping.IsDepthStencil(description.Format);
@@ -132,6 +142,28 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
_vk.CreateImageView(_device, &viewCreate, null, out ImageView view),
$"vkCreateImageView ('{description.Name}')");
View = view;
+ SampledView = view;
+
+ // Campaign V slice V6l: a colour render target needs TWO views.
+ //
+ // An ATTACHMENT view must be VK_IMAGE_VIEW_TYPE_2D, and the global
+ // texture table's descriptor array is declared sampler2DArray, so the
+ // attachment view cannot legally be registered into it — plan §5.5.7
+ // recorded that as invalid usage rather than a mismatch that samples
+ // oddly, and V6k made VulkanGpuDevice.RegisterTexture refuse it and
+ // name this fix. A second, LAYERED view over the same image is that
+ // fix: one image, one allocation, two ways of looking at it. Legal
+ // without any creation flag — a 2D_ARRAY view over an imageType-2D
+ // image with arrayLayers >= 1 is exactly what the spec permits.
+ if (renderTarget && !depthStencil)
+ {
+ viewCreate.ViewType = VulkanTextureFormatMapping.SampledViewTypeOf(description.Kind);
+ VulkanInterop.Check(
+ _vk.CreateImageView(_device, &viewCreate, null, out ImageView sampled),
+ $"vkCreateImageView ('{description.Name}', sampled)");
+ SampledView = sampled;
+ debugNames.NameImageView(sampled, $"{description.Name}-sampled-view");
+ }
}
catch
{
@@ -153,7 +185,17 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
internal int SampleCount { get; }
internal Image Image { get; }
+
+ /// The view a pass names as an attachment, and the only view a non-attachment has.
internal ImageView View { get; }
+
+ ///
+ /// The view the global texture table samples. Identical to
+ /// for every ordinary texture; a second, LAYERED view for a colour render
+ /// target, because an attachment view is 2-D and the table's descriptor array
+ /// is sampler2DArray (slice V6l, plan §5.5.7).
+ ///
+ internal ImageView SampledView { get; }
internal Format VkFormat { get; }
internal ImageAspectFlags Aspect { get; }
@@ -218,9 +260,12 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
Image image = Image;
ImageView view = View;
+ ImageView sampledView = SampledView;
VulkanAllocation allocation = _allocation;
_retirement.Retire(() =>
{
+ if (sampledView.Handle != view.Handle)
+ _vk.DestroyImageView(_device, sampledView, null);
_vk.DestroyImageView(_device, view, null);
_vk.DestroyImage(_device, image, null);
_allocator.Free(allocation);
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanWorldPassScope.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanWorldPassScope.cs
index 29bde055..7d20e4da 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanWorldPassScope.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanWorldPassScope.cs
@@ -52,7 +52,7 @@ internal sealed unsafe class VulkanWorldPassScope : IWorldPassScope
/// per frame, so a nested publication could only mean two phases believe they
/// own the frame.
///
- internal IDisposable Publish(IGpuPassEncoder encoder)
+ public IDisposable Publish(IGpuPassEncoder encoder)
{
ArgumentNullException.ThrowIfNull(encoder);
if (_encoder is not null)
diff --git a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs
index 6cf61973..5666bef4 100644
--- a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs
+++ b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs
@@ -18,7 +18,8 @@ public sealed class PaperdollViewportRenderer :
private readonly PrivateEntityViewportRenderer _renderer;
internal PaperdollViewportRenderer(
- GL gl,
+ GL? gl,
+ IWorldPassScope? scope,
AcDream.App.Rendering.Gpu.IGpuDevice device,
ICurrentGpuFrameSource frames,
WbDrawDispatcher dispatcher,
@@ -28,6 +29,7 @@ public sealed class PaperdollViewportRenderer :
{
_renderer = new PrivateEntityViewportRenderer(
gl,
+ scope,
device,
frames,
dispatcher,
@@ -39,6 +41,8 @@ public sealed class PaperdollViewportRenderer :
"paperdoll");
}
+ public bool TextureIsBottomUp => _renderer.TextureIsBottomUp;
+
public void SetDoll(WorldEntity? doll) => _renderer.SetEntity(doll);
public uint Render(int width, int height) =>
diff --git a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
index 34dbbcfd..7670d7aa 100644
--- a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
+++ b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
@@ -53,9 +53,30 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
{
private const uint PrivateLandblockId = 0u;
- private readonly GL _gl;
+ ///
+ /// The GL arm's context, or null on a backend that has none. Campaign V
+ /// slice V6l: both retained-UI viewports render on either arm now.
+ ///
+ private readonly GL? _glContext;
+
+ private GL RequireGl => _glContext
+ ?? throw new InvalidOperationException(
+ $"The {_diagnosticName} reached its GL arm on a backend with no GL context.");
+
private readonly IGpuDevice _device;
private readonly ICurrentGpuFrameSource _frames;
+
+ ///
+ /// The world pass scope, or null on the GL arm.
+ ///
+ /// Slice V6l. WbDrawDispatcher's RHI arm borrows its pass from
+ /// the scope rather than opening one, so a viewport that opens a pass of its
+ /// own has to publish it there for the duration of the draw. On the GL arm
+ /// the dispatcher records against whatever framebuffer is bound, which is
+ /// what the pass bound, so nothing is published.
+ ///
+ private readonly IWorldPassScope? _scope;
+
private readonly WbDrawDispatcher _dispatcher;
private readonly SceneLightingUboBinding _lightUbo;
private readonly FixedEntityTextureOwnerLease _textureOwnerLease;
@@ -75,7 +96,8 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
private SyntheticEntityMeshReferenceOwner? _meshReferences;
public PrivateEntityViewportRenderer(
- GL gl,
+ GL? gl,
+ IWorldPassScope? scope,
IGpuDevice device,
ICurrentGpuFrameSource frames,
WbDrawDispatcher dispatcher,
@@ -88,7 +110,15 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
{
if (renderId == 0u)
throw new ArgumentOutOfRangeException(nameof(renderId));
- _gl = gl ?? throw new ArgumentNullException(nameof(gl));
+ if (gl is null && scope is null)
+ {
+ throw new ArgumentNullException(
+ nameof(scope),
+ "A backend without a GL context must publish a world pass scope for the viewport to draw into.");
+ }
+
+ _glContext = gl;
+ _scope = scope;
_device = device ?? throw new ArgumentNullException(nameof(device));
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
@@ -105,6 +135,12 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
renderId);
}
+ ///
+ /// A GL framebuffer's colour texture samples bottom-up; a Vulkan render
+ /// target's does not. See .
+ ///
+ public bool TextureIsBottomUp => _glContext is not null;
+
public void SetEntity(WorldEntity? entity)
{
ReleaseRetiringMeshReferences();
@@ -186,11 +222,13 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
?? throw new InvalidOperationException(
$"The {_diagnosticName} requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
- using var scope = new GLStateScope(_gl);
// The pass's clear is not scissored on either backend, but GL's glClear
// is: a doorway slice earlier in the frame can have left the scissor on,
// and this target is not confined to it.
- _gl.Disable(EnableCap.ScissorTest);
+ using GLStateScope? glState = _glContext is { } scissorGl
+ ? new GLStateScope(scissorGl)
+ : null;
+ _glContext?.Disable(EnableCap.ScissorTest);
using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
{
@@ -210,14 +248,29 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
// GL's BeginPass deliberately does not touch viewport or scissor, and
// the renderer that draws inside this pass is still raw GL, so the
- // remaining state is set here exactly as it always was.
- _gl.Viewport(0, 0, (uint)width, (uint)height);
- _gl.Enable(EnableCap.DepthTest);
- _gl.DepthFunc(DepthFunction.Less);
- _gl.Enable(EnableCap.CullFace);
- _gl.CullFace(TriangleFace.Back);
- _gl.FrontFace(FrontFaceDirection.Ccw);
- _gl.Disable(EnableCap.Blend);
+ // remaining state is set here exactly as it always was. On the RHI arm
+ // every one of these is baked into the dispatcher's pipelines and the
+ // pass sets its own full-attachment viewport, so there is nothing to do.
+ if (_glContext is { } stateGl)
+ {
+ stateGl.Viewport(0, 0, (uint)width, (uint)height);
+ stateGl.Enable(EnableCap.DepthTest);
+ stateGl.DepthFunc(DepthFunction.Less);
+ stateGl.Enable(EnableCap.CullFace);
+ stateGl.CullFace(TriangleFace.Back);
+ stateGl.FrontFace(FrontFaceDirection.Ccw);
+ stateGl.Disable(EnableCap.Blend);
+ }
+
+ // Slice V6l: the dispatcher's RHI arm borrows its pass from the scope,
+ // so this pass has to BE the scope's for the span of the draw. Published
+ // after the world phase has closed its own, which is why it does not
+ // nest; the sections it resets are republished by UploadCreatureLight
+ // immediately below, which is the private lighting this view wants
+ // rather than the world's.
+ using IDisposable? publication = _glContext is null
+ ? _scope!.Publish(encoder)
+ : null;
UploadCreatureLight();
diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs
index 131244e9..a7a2c9b0 100644
--- a/src/AcDream.App/Rendering/TextureCache.cs
+++ b/src/AcDream.App/Rendering/TextureCache.cs
@@ -189,19 +189,44 @@ public sealed unsafe class TextureCache
}
else
{
- // Campaign V slice V6l: particles draw on both arms, so the standalone
- // particle cache exists on both. Everything about it that matters —
- // sharing equivalent surfaces between emitter owners, the bounded
- // unowned LRU, and retirement behind the frame-flight fence — is
+ // Campaign V slice V6l: both owner-scoped caches exist on both arms.
+ //
+ // Everything about them that matters — sharing equivalent surfaces
+ // between owners, the bounded unowned LRU, the metered upload budget,
+ // and retirement behind the frame-flight fence — is already
// backend-neutral; only how one entry is created and destroyed
- // differs, which is what the backend interface is for. The COMPOSITE
- // cache stays GL-only: it serves entity appearance, not particles,
- // and its port is not this slice's.
- _particleTextures = new StandaloneBindlessTextureCache(
- new ParticleRhiTextureBackend(this),
- retirementQueue,
- budgets.StandaloneUnownedBytes,
- budgets.StandaloneUnownedEntries);
+ // differs, which is exactly what the two backend interfaces are for.
+ // RhiCompositeTextureArrayBackend is V6i-2's, built and exercised at
+ // startup since that slice but with no production consumer until now;
+ // ParticleRhiTextureBackend is this slice's.
+ var resources = new ResourceCleanupGroup();
+ CompositeTextureArrayCache? composite = null;
+ StandaloneBindlessTextureCache? particles = null;
+ try
+ {
+ composite = new CompositeTextureArrayCache(
+ new RhiCompositeTextureArrayBackend(device),
+ retirementQueue,
+ budgets.CompositeUnownedBytes,
+ budgets.CompositePhysicalBytes);
+ resources.Add("composite texture cache", composite.Dispose);
+ particles = new StandaloneBindlessTextureCache(
+ new ParticleRhiTextureBackend(this),
+ retirementQueue,
+ budgets.StandaloneUnownedBytes,
+ budgets.StandaloneUnownedEntries);
+ resources.Add("particle texture cache", particles.Dispose);
+ resources.TransferAll();
+ }
+ catch (Exception constructionFailure)
+ {
+ resources.RollbackConstructionAndThrow(
+ "TextureCache construction failed and its child-cache prefix did not cleanly roll back.",
+ constructionFailure);
+ }
+
+ _compositeTextures = composite;
+ _particleTextures = particles;
}
}
@@ -836,11 +861,15 @@ public sealed unsafe class TextureCache
"WbDrawDispatcher requires the bindless-aware ctor overload (pass non-null BindlessSupport).");
}
- private CompositeTextureArrayCache EnsureCompositeTexturesAvailable()
- {
- EnsureBindlessAvailable();
- return _compositeTextures!;
- }
+ ///
+ /// Campaign V slice V6l: no longer gated on bindless. The composite cache is
+ /// constructed on both arms — V6i-2's RHI backend is what serves the one
+ /// without a GL context — so the only failure left is a cache that was never
+ /// built at all.
+ ///
+ private CompositeTextureArrayCache EnsureCompositeTexturesAvailable() =>
+ _compositeTextures ?? throw new InvalidOperationException(
+ "This TextureCache owns no composite texture array cache.");
///
/// Campaign V slice V6l: no longer gated on bindless. The particle cache is
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
index 66ade0a5..ef2e3fa7 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
@@ -31,11 +31,31 @@ 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;
+
+ ///
+ /// The five mesh pipelines at ONE sample count.
+ ///
+ /// Campaign V slice V6l: there are two of these. Vulkan requires a
+ /// pipeline's rasterizationSamples to equal the pass it draws in, and
+ /// this dispatcher draws in two passes with different counts — the
+ /// multisampled backbuffer world pass, and the single-sampled offscreen
+ /// paperdoll/appraisal target, which the contract fixes at one sample. Plan
+ /// §5.5.16 defect 3 named exactly this as the reason those viewports could
+ /// not exist on the Vulkan arm, and the answer is the same shape §5.5.8 gave
+ /// the depth-format problem: materialise both, select at bind time from what
+ /// the live pass actually is. Both are built at startup against the persisted
+ /// cache, so no frame ever compiles one.
+ ///
+ private sealed record MeshPipelineSet(
+ int SampleCount,
+ IGpuPipeline Opaque,
+ IGpuPipeline OpaqueAlphaToCoverage,
+ IGpuPipeline AlphaBlend,
+ IGpuPipeline AlphaAdditive,
+ IGpuPipeline AlphaInverse);
+
+ private MeshPipelineSet? _backbufferPipelines;
+ private MeshPipelineSet? _offscreenPipelines;
private const string OpaqueTimerScope = "wb-entities-opaque";
private const string TransparentTimerScope = "wb-entities-transparent";
@@ -101,18 +121,49 @@ public sealed unsafe partial class WbDrawDispatcher
_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);
+ try
+ {
+ _backbufferPipelines = CreateMeshPipelineSet(device, samples);
+ // One sample is what an IGpuRenderTarget is by contract, so a second
+ // set only exists when the backbuffer is multisampled.
+ _offscreenPipelines = samples == 1
+ ? _backbufferPipelines
+ : CreateMeshPipelineSet(device, 1);
+ }
+ catch
+ {
+ DisposeRhiResources();
+ throw;
+ }
}
+ private static MeshPipelineSet CreateMeshPipelineSet(IGpuDevice device, int samples)
+ {
+ string suffix = samples > 1 ? string.Empty : "-1x";
+ return new MeshPipelineSet(
+ samples,
+ CreateMeshPipeline(
+ device, $"wb-mesh-opaque{suffix}", GpuBlendMode.None, true, false, samples),
+ CreateMeshPipeline(
+ device, $"wb-mesh-opaque-a2c{suffix}", GpuBlendMode.None, true, true, samples),
+ CreateMeshPipeline(
+ device, $"wb-mesh-alpha{suffix}", GpuBlendMode.StraightAlpha, false, false, samples),
+ CreateMeshPipeline(
+ device, $"wb-mesh-additive{suffix}", GpuBlendMode.Additive, false, false, samples),
+ CreateMeshPipeline(
+ device, $"wb-mesh-inverse{suffix}", GpuBlendMode.InverseAlpha, false, false, samples));
+ }
+
+ ///
+ /// The pipeline set whose sample count matches the pass being recorded into.
+ /// Taken from the live pass rather than from the scope, because the offscreen
+ /// viewport borrows the scope with a pass of its own.
+ ///
+ private MeshPipelineSet PipelinesFor(IGpuPassEncoder encoder) =>
+ encoder.Pass.SampleCount > 1
+ ? _backbufferPipelines!
+ : _offscreenPipelines!;
+
///
/// The imperative Enable/Disable/BlendFunc/DepthMask brackets became
/// pipeline variants: opaque, opaque with alpha-to-coverage, and the three
@@ -182,9 +233,10 @@ public sealed unsafe partial class WbDrawDispatcher
// 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.
+ MeshPipelineSet pipelines = PipelinesFor(encoder);
BindPipelineWithMesh(
encoder,
- AlphaToCoverage ? _opaqueAlphaToCoveragePipeline! : _opaquePipeline!,
+ AlphaToCoverage ? pipelines.OpaqueAlphaToCoverage : pipelines.Opaque,
mesh);
encoder.SetPushConstants(in pushConstants);
@@ -246,7 +298,7 @@ public sealed unsafe partial class WbDrawDispatcher
// ── Phase 8: transparent pass ────────────────────────────────────────
if (_transparentDrawCount > 0)
{
- BindPipelineWithMesh(encoder, _alphaBlendPipeline!, mesh);
+ BindPipelineWithMesh(encoder, pipelines.AlphaBlend, 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.
@@ -319,7 +371,8 @@ public sealed unsafe partial class WbDrawDispatcher
ParamB = 0f,
};
- BindPipelineWithMesh(encoder, _alphaBlendPipeline!, mesh);
+ MeshPipelineSet pipelines = PipelinesFor(encoder);
+ BindPipelineWithMesh(encoder, pipelines.AlphaBlend, mesh);
encoder.SetPushConstants(in pushConstants);
BindSection(encoder, GpuBindingModel.StorageInstances, _alphaInstances);
BindSection(encoder, GpuBindingModel.StorageBatches, _alphaBatches);
@@ -348,7 +401,7 @@ public sealed unsafe partial class WbDrawDispatcher
// ApplyRetailBlend's three cases are three pipelines, including the
// inverse-alpha one GpuBlendMode.InverseAlpha was added for.
- BindPipelineWithMesh(encoder, PipelineForBlend(blend), mesh);
+ BindPipelineWithMesh(encoder, PipelineForBlend(pipelines, blend), mesh);
encoder.SetPushConstants(in pushConstants);
DrawIndirectRangeRhi(
encoder,
@@ -361,12 +414,13 @@ public sealed unsafe partial class WbDrawDispatcher
}
}
- private IGpuPipeline PipelineForBlend(TranslucencyKind blend) => blend switch
- {
- TranslucencyKind.Additive => _alphaAdditivePipeline!,
- TranslucencyKind.InvAlpha => _alphaInversePipeline!,
- _ => _alphaBlendPipeline!,
- };
+ private static IGpuPipeline PipelineForBlend(MeshPipelineSet pipelines, TranslucencyKind blend) =>
+ blend switch
+ {
+ TranslucencyKind.Additive => pipelines.AlphaAdditive,
+ TranslucencyKind.InvAlpha => pipelines.AlphaInverse,
+ _ => pipelines.AlphaBlend,
+ };
private void DrawIndirectRangeRhi(
IGpuPassEncoder encoder,
@@ -548,16 +602,26 @@ public sealed unsafe partial class WbDrawDispatcher
private void DisposeRhiResources()
{
- _opaquePipeline?.Dispose();
- _opaquePipeline = null;
- _opaqueAlphaToCoveragePipeline?.Dispose();
- _opaqueAlphaToCoveragePipeline = null;
- _alphaBlendPipeline?.Dispose();
- _alphaBlendPipeline = null;
- _alphaAdditivePipeline?.Dispose();
- _alphaAdditivePipeline = null;
- _alphaInversePipeline?.Dispose();
- _alphaInversePipeline = null;
+ MeshPipelineSet? backbuffer = _backbufferPipelines;
+ MeshPipelineSet? offscreen = _offscreenPipelines;
+ _backbufferPipelines = null;
+ _offscreenPipelines = null;
+ DisposeMeshPipelineSet(backbuffer);
+ // Reference-equal when the backbuffer is single-sampled, in which case
+ // there is one set and disposing it twice would be a double free.
+ if (!ReferenceEquals(offscreen, backbuffer))
+ DisposeMeshPipelineSet(offscreen);
+ }
+
+ private static void DisposeMeshPipelineSet(MeshPipelineSet? pipelines)
+ {
+ if (pipelines is null)
+ return;
+ pipelines.Opaque.Dispose();
+ pipelines.OpaqueAlphaToCoverage.Dispose();
+ pipelines.AlphaBlend.Dispose();
+ pipelines.AlphaAdditive.Dispose();
+ pipelines.AlphaInverse.Dispose();
}
private sealed class NullRhiTimerScope : IDisposable
diff --git a/src/AcDream.App/Rendering/WorldPassScope.cs b/src/AcDream.App/Rendering/WorldPassScope.cs
index 402a3e8a..559312ff 100644
--- a/src/AcDream.App/Rendering/WorldPassScope.cs
+++ b/src/AcDream.App/Rendering/WorldPassScope.cs
@@ -106,6 +106,24 @@ internal interface IWorldPassScope
/// The frame-global sections every renderer in this pass rebinds.
WorldFrameSections Sections { get; }
+
+ ///
+ /// Publishes as the pass every world renderer
+ /// borrows, for the duration of the returned scope.
+ ///
+ /// Campaign V slice V6l lifted this onto the interface for the
+ /// offscreen viewports. The world-scene phase is still the only publisher of
+ /// the frame's backbuffer pass, but the paperdoll and creature-appraisal
+ /// views open a pass of their OWN — a real render target, after the world
+ /// pass has closed — and then ask WbDrawDispatcher to draw one entity
+ /// into it. The dispatcher borrows its pass from here, so the viewport has to
+ /// be able to say which pass "here" means.
+ ///
+ /// Publications do not nest: the backend permits one open pass at a
+ /// time, so a nested publication could only mean two owners believe they hold
+ /// the frame.
+ ///
+ IDisposable Publish(IGpuPassEncoder encoder);
}
///
diff --git a/src/AcDream.App/UI/IUiViewportRenderer.cs b/src/AcDream.App/UI/IUiViewportRenderer.cs
index 13023078..94cd10b4 100644
--- a/src/AcDream.App/UI/IUiViewportRenderer.cs
+++ b/src/AcDream.App/UI/IUiViewportRenderer.cs
@@ -8,4 +8,19 @@ public interface IUiViewportRenderer
{
/// Render at (width,height); return the color-texture GL handle, or 0 if nothing rendered.
uint Render(int width, int height);
+
+ ///
+ /// Whether the produced texture's v=0 row is the BOTTOM of the rendered
+ /// image.
+ ///
+ /// Campaign V slice V6l. This is a property of the backend that made
+ /// the texture, not of the widget that draws it. A GL framebuffer's origin is
+ /// bottom-left, so its colour texture samples bottom-up and
+ /// has always flipped V to compensate. A Vulkan
+ /// image's origin is top-left and the backend's negative viewport height
+ /// stores the rendered image that way round, so the same flip would draw the
+ /// doll on its head — which is exactly what the first Vulkan capture did, and
+ /// what the comment on that line had predicted since V4a.
+ ///
+ bool TextureIsBottomUp { get; }
}
diff --git a/src/AcDream.App/UI/UiViewport.cs b/src/AcDream.App/UI/UiViewport.cs
index 90ec21ab..e48f4ccd 100644
--- a/src/AcDream.App/UI/UiViewport.cs
+++ b/src/AcDream.App/UI/UiViewport.cs
@@ -53,9 +53,18 @@ public sealed class UiViewport : UiElement
uint textureHandle = AcDream.App.Rendering.TextRenderer.ResolveExternalTextureSlot(TextureSlot);
if (textureHandle == 0) return;
// Local origin is already at this widget's Left/Top (PushTransform applied by DrawSelfAndChildren).
- // V is FLIPPED (v0=1, v1=0): the resolved texture is an off-screen FBO color texture, whose origin is
- // bottom-left (GL), while the UI sprite convention is top-left. Without the flip the doll renders
- // upside-down. (If the doll appears upside-down at the visual gate, this is the line to revisit.)
- ctx.DrawSprite(textureHandle, 0f, 0f, Width, Height, 0f, 1f, 1f, 0f, Vector4.One);
+ //
+ // V depends on the backend that produced the texture, and slice V6l is
+ // where that stopped being a constant. A GL off-screen FBO colour texture
+ // has a BOTTOM-LEFT origin while the UI sprite convention is top-left, so
+ // its V is flipped (v0=1, v1=0) — without that the doll renders
+ // upside-down, which is what the line this replaces had always said. A
+ // Vulkan render target has a top-left origin and needs no flip; applying
+ // one drew the doll on its head in the first Vulkan capture. The renderer
+ // that made the texture is the one that knows.
+ bool bottomUp = Renderer?.TextureIsBottomUp ?? true;
+ float v0 = bottomUp ? 1f : 0f;
+ float v1 = bottomUp ? 0f : 1f;
+ ctx.DrawSprite(textureHandle, 0f, 0f, Width, Height, 0f, v0, 1f, v1, Vector4.One);
}
}