From 2e8b8b91ade43aa97ea999576473bcaa96cbfa50 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Jul 2026 18:05:24 +0200 Subject: [PATCH] feat(render): Campaign V slice V6l commit 3 - the offscreen viewports draw on Vulkan Amendment 3 of three: the paperdoll and creature-appraisal views render on the Vulkan arm. Plan section 5.5.16 defect 3 named two backend fixes as the precondition; both are here, and running it found two more the note could not have known about. Fix 1: a layered sampled view per render target. An ATTACHMENT view must be VK_IMAGE_VIEW_TYPE_2D and the global texture table's descriptor array is sampler2DArray, so the attachment view cannot legally be registered into it - section 5.5.7 recorded that as invalid usage rather than a mismatch that samples oddly, and V6k made RegisterTexture refuse it loudly and name this fix. VulkanGpuTexture now creates a SECOND, layered view over the same image for a colour render target: one image, one allocation, two ways of looking at it, legal without any creation flag. SampledView is what the table registers for every texture, so the question disappears rather than being answered. Fix 2: sample-count pipeline variants for WbDrawDispatcher. 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 target, which the contract fixes at one sample. Its five pipelines became a MeshPipelineSet with two instances, selected at bind time from the live pass rather than from the scope, which is the same shape section 5.5.8 gave the depth-format problem. When the backbuffer is single-sampled the two sets are one object, so nothing is built twice and nothing is freed twice. The offscreen target's DEPTH attachment also had to take the device's own combined depth/stencil format rather than the contract enum's literal D24_UNORM_S8_UINT: a pipeline bakes one depth/stencil format under dynamic rendering and the same pipelines draw in both passes, so a second format would make one of the two undefined. Fix 3, which running it found: entity APPEARANCE composites were still bindless-only, so no entity with a palette override could be drawn on the Vulkan arm at all - the doll being one, and every creature and player besides. The backend that serves it has existed since V6i-2 and had no production consumer; it has one now. TextureCache builds the composite cache on both arms, and EnsureCompositeTexturesAvailable stops asking about bindless. Nothing about the cache itself changed: the sharing, the bounded unowned LRU, the metered upload budget and the retirement fence were already backend-neutral. Fix 4, which the first successful capture found: the doll rendered upside down. UiViewport has flipped V since V4a because a GL framebuffer's origin is bottom-left, so its colour texture samples bottom-up. 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 stands the doll on its head. That is a property of the backend that made the texture, not of the widget that draws it, so IUiViewportRenderer answers TextureIsBottomUp and UiViewport asks. The line this replaces had predicted exactly this failure since it was written. The seam. WbDrawDispatcher's RHI arm borrows its pass from IWorldPassScope rather than opening one, so a viewport that opens a pass of its own has to publish it there for the span of the draw. Publish is on the interface now for that. It does not nest: the world phase has closed its own pass by the time private presentation runs, which is where these viewports have always drawn. Gates. Release build green. App tests 4,129/3 skips; complete Release suite 9,192/5 (one solution-wide run reported a single App failure that did not reproduce in the App suite alone or in a second solution-wide run - the documented rerun-singly flake class; the failing test name was not surfaced by the runner and is not carried forward as a claim). Strict GL offline pixel gate against 08ffe141: 3.55e-05, 20 differing pixels of 563,200, inside the documented 9-31 band. GL connected -Runs 3: 3/3 RENDERED on the desktop witness and 3/3 on the client capture. One offline Vulkan run with VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation errors, zero warnings. And the two captures the offline gate cannot reach, both connected and both inspected. The Vulkan paperdoll (artifacts/v6l-vk-paperdoll3) renders the doll upright, in armour, at the right scale, over a transparent background, and is indistinguishable from the same capture on GL taken minutes later (artifacts/v6l-gl-paperdoll) - which is also the no-regression check for the V change. Particles (artifacts/v6l-vk-poi versus artifacts/v6l-gl-poi, cropped 4x at artifacts/crop-vk-glow.png and crop-gl-glow.png): Holtburg's forge plume and its field of glint sprites draw in the same places with the same alpha compositing on both backends, the puffs differing only in phase because two launches cannot agree on an emitter's age. Co-Authored-By: Claude Fable 5 --- .../LivePresentationComposition.cs | 15 +- .../CreatureAppraisalPresentation.cs | 6 +- .../Gpu/Vk/VulkanGpuDevice.Resources.cs | 34 ++--- .../Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs | 17 ++- .../Rendering/Gpu/Vk/VulkanGpuTexture.cs | 49 ++++++- .../Rendering/Gpu/Vk/VulkanWorldPassScope.cs | 2 +- .../Rendering/PaperdollViewportRenderer.cs | 6 +- .../PrivateEntityViewportRenderer.cs | 79 +++++++++-- src/AcDream.App/Rendering/TextureCache.cs | 63 +++++--- .../Rendering/Wb/WbDrawDispatcher.Rhi.cs | 134 +++++++++++++----- src/AcDream.App/Rendering/WorldPassScope.cs | 18 +++ src/AcDream.App/UI/IUiViewportRenderer.cs | 15 ++ src/AcDream.App/UI/UiViewport.cs | 17 ++- 13 files changed, 353 insertions(+), 102 deletions(-) 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); } }