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); } }