diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 0319914f..60b96b4e 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -263,8 +263,6 @@ internal sealed class FrameRootCompositionPhase live.DrawDispatcher, live.EnvCellRenderer, live.PortalDepthMask, - foundation.TextRenderer, - interaction.RetainedUi?.Host.TextRenderer, live.ClipFrame, foundation.Terrain, foundation.SceneLighting), @@ -509,7 +507,7 @@ internal sealed class FrameRootCompositionPhase : (IRenderFramePostDiagnosticsPhase?)lifecycleAutomation ?? NullRenderFramePostDiagnosticsPhase.Instance; var renderFrame = new RenderFrameOrchestrator( - host.GpuFrameFlights, + host.GpuFrameLifetime, new FrameProfilerGpuMeasurement(d.FrameProfiler, d.Gl), framePreparation, worldSceneRenderer, diff --git a/src/AcDream.App/Composition/HostInputCameraComposition.cs b/src/AcDream.App/Composition/HostInputCameraComposition.cs index 52e0592b..756d09d5 100644 --- a/src/AcDream.App/Composition/HostInputCameraComposition.cs +++ b/src/AcDream.App/Composition/HostInputCameraComposition.cs @@ -12,6 +12,7 @@ internal interface IGameWindowHostInputCameraPublication { void PublishGpuFrameFlights(GpuFrameFlightController value); void PublishGpuDevice(IGpuDevice value); + void PublishGpuFrameLifetime(GpuDeviceFrameLifetime value); void PublishKeyboardSource(SilkKeyboardSource value); void PublishMouseSource(SilkMouseSource value); void PublishMouseLookCursor(IMouseLookCursor value); @@ -23,6 +24,7 @@ internal interface IGameWindowHostInputCameraPublication internal sealed record HostInputCameraResult( GpuFrameFlightController GpuFrameFlights, IGpuDevice GpuDevice, + GpuDeviceFrameLifetime GpuFrameLifetime, WorldRenderDiagnostics WorldRenderDiagnostics, SilkKeyboardSource? KeyboardSource, SilkMouseSource? MouseSource, @@ -245,6 +247,17 @@ internal sealed class HostInputCameraCompositionPhase : _publication.PublishGpuDevice); Fault(HostInputCameraCompositionPoint.GpuDevicePublished); + // Campaign V slice V4a: drives IGpuDevice.BeginFrame()/IGpuFrame.End() + // once per rendered frame, additively over the existing + // GpuFrameFlightController-driven fence/slot bracket (see the class + // comment) — RenderFrameOrchestrator's IRenderFrameLifetime is wired + // to THIS wrapper instead of gpuFrames directly at FrameRootComposition, + // and the IGpuFrame it exposes is what TextRenderer/DebugLineRenderer + // reach through ICurrentGpuFrameSource. Owns no disposable resource of + // its own — gpuDevice's own scope.Acquire entry above disposes it. + var gpuFrameLifetime = new GpuDeviceFrameLifetime(gpuDevice); + _publication.PublishGpuFrameLifetime(gpuFrameLifetime); + WorldRenderDiagnostics diagnostics = _factory.CreateWorldRenderDiagnostics( gl, @@ -345,6 +358,7 @@ internal sealed class HostInputCameraCompositionPhase : return new HostInputCameraResult( gpuFrames, gpuDevice, + gpuFrameLifetime, diagnostics, keyboard, mouse, diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 7f189414..0dc10c38 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -60,7 +60,9 @@ internal sealed record InteractionRetainedUiDependencies( VitalsVM? ExistingVitals, Action? Toast, Func ClientTime, - Action Log) + Action Log, + AcDream.App.Rendering.Gpu.IGpuDevice GpuDevice, + ICurrentGpuFrameSource GpuFrameSource) { public RuntimeActionState Actions => Runtime.ActionOwner; @@ -389,7 +391,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory d.Character.LocalPlayer); UiHost host = lease.AcquireHost( () => new UiHost( - d.Gl, + d.GpuDevice, + d.GpuFrameSource, d.ShadersDirectory, d.DebugFont, d.HostQuiescence)); diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 0e3cfe73..26ffa74e 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -795,7 +795,8 @@ internal sealed class LivePresentationCompositionPhase paperdollLease.Resource, new RetailPaperdollFrameView( viewport, - new PaperdollInventoryVisibility(inventoryFrame)), + new PaperdollInventoryVisibility(inventoryFrame), + host.GpuDevice), new RetailPaperdollDollFactory( new LivePaperdollEntityLookup(liveEntities), d.PlayerIdentity, @@ -842,7 +843,8 @@ internal sealed class LivePresentationCompositionPhase new RetailCreatureAppraisalFrameView( creatureViewport, examinationFrame, - appraisalController), + appraisalController, + host.GpuDevice), new RetailCreatureAppraisalCloneFactory( new LiveCreatureAppraisalEntityLookup(liveEntities))); } diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs index bdbc10f6..afeda836 100644 --- a/src/AcDream.App/Composition/WorldRenderComposition.cs +++ b/src/AcDream.App/Composition/WorldRenderComposition.cs @@ -55,7 +55,9 @@ internal sealed record WorldRenderDependencies( ResidencyBudgetOptions ResidencyBudgets, uint InitialCenterLandblockId, string DiagnosticsDirectory, - Action Log); + Action Log, + AcDream.App.Rendering.Gpu.IGpuDevice GpuDevice, + AcDream.App.Rendering.ICurrentGpuFrameSource GpuFrameSource); internal interface IGameWindowWorldRenderPublication { @@ -89,10 +91,16 @@ internal interface IWorldRenderCompositionFactory void SetTerrainAnisotropic(TerrainAtlas atlas, int level); Shader CreateTerrainShader(GL gl, string shadersDirectory); SceneLightingUboBinding CreateSceneLighting(GL gl); - DebugLineRenderer CreateDebugLines(GL gl, string shadersDirectory); + DebugLineRenderer CreateDebugLines( + AcDream.App.Rendering.Gpu.IGpuDevice device, + ICurrentGpuFrameSource frameSource, + string shadersDirectory); byte[]? TryLoadDebugFont(); - BitmapFont CreateDebugFont(GL gl, byte[] bytes); - TextRenderer CreateTextRenderer(GL gl, string shadersDirectory); + BitmapFont CreateDebugFont(AcDream.App.Rendering.Gpu.IGpuDevice device, byte[] bytes); + TextRenderer CreateTextRenderer( + AcDream.App.Rendering.Gpu.IGpuDevice device, + ICurrentGpuFrameSource frameSource, + string shadersDirectory); TerrainModernRenderer CreateTerrain( GL gl, BindlessSupport bindless, @@ -112,6 +120,7 @@ internal interface IWorldRenderCompositionFactory ResidencyBudgetOptions budgets); TextureCache CreateTextureCache( GL gl, + AcDream.App.Rendering.Gpu.IGpuDevice device, IDatReaderWriter dats, BindlessSupport bindless, IGpuResourceRetirementQueue retirement, @@ -215,20 +224,22 @@ internal sealed class RetailWorldRenderCompositionFactory public SceneLightingUboBinding CreateSceneLighting(GL gl) => new(gl); public DebugLineRenderer CreateDebugLines( - GL gl, + AcDream.App.Rendering.Gpu.IGpuDevice device, + ICurrentGpuFrameSource frameSource, string shadersDirectory) => - new(gl, shadersDirectory); + new(device, frameSource, shadersDirectory); public byte[]? TryLoadDebugFont() => BitmapFont.TryLoadSystemMonospaceFont(); - public BitmapFont CreateDebugFont(GL gl, byte[] bytes) => - new(gl, bytes, pixelHeight: 15f, atlasSize: 512); + public BitmapFont CreateDebugFont(AcDream.App.Rendering.Gpu.IGpuDevice device, byte[] bytes) => + new(device, bytes, pixelHeight: 15f, atlasSize: 512); public TextRenderer CreateTextRenderer( - GL gl, + AcDream.App.Rendering.Gpu.IGpuDevice device, + ICurrentGpuFrameSource frameSource, string shadersDirectory) => - new(gl, shadersDirectory); + new(device, frameSource, shadersDirectory); public TerrainModernRenderer CreateTerrain( GL gl, @@ -294,6 +305,7 @@ internal sealed class RetailWorldRenderCompositionFactory public TextureCache CreateTextureCache( GL gl, + AcDream.App.Rendering.Gpu.IGpuDevice device, IDatReaderWriter dats, BindlessSupport bindless, IGpuResourceRetirementQueue retirement, @@ -301,6 +313,7 @@ internal sealed class RetailWorldRenderCompositionFactory ResidencyBudgetOptions budgets) => new( gl, + device, dats, bindless, retirement, @@ -482,12 +495,15 @@ internal sealed class WorldRenderCompositionPhase DebugLineRenderer debugLines = AcquireAndPublish( scope, "debug lines", - () => _factory.CreateDebugLines(gl, shadersDirectory), + () => _factory.CreateDebugLines( + _dependencies.GpuDevice, + _dependencies.GpuFrameSource, + shadersDirectory), _publication.PublishDebugLines, WorldRenderCompositionPoint.DebugLinesPublished); (BitmapFont? debugFont, TextRenderer? textRenderer) = - ComposeOptionalHudResources(scope, gl, shadersDirectory); + ComposeOptionalHudResources(scope, shadersDirectory); TerrainModernRenderer terrain = AcquireAndPublish( scope, @@ -535,6 +551,7 @@ internal sealed class WorldRenderCompositionPhase "texture cache", () => _factory.CreateTextureCache( gl, + _dependencies.GpuDevice, content.Dats, bindless, _dependencies.ResourceRetirement, @@ -587,7 +604,6 @@ internal sealed class WorldRenderCompositionPhase private (BitmapFont? Font, TextRenderer? Text) ComposeOptionalHudResources( CompositionAcquisitionScope scope, - GL gl, string shadersDirectory) { byte[]? fontBytes = _factory.TryLoadDebugFont(); @@ -600,13 +616,16 @@ internal sealed class WorldRenderCompositionPhase var fontLease = scope.Acquire( "world HUD font", - () => _factory.CreateDebugFont(gl, fontBytes), + () => _factory.CreateDebugFont(_dependencies.GpuDevice, fontBytes), _factory.Release); BitmapFont font = fontLease.Resource; Fault(WorldRenderCompositionPoint.DebugFontCreated); var textLease = scope.Acquire( "world HUD text renderer", - () => _factory.CreateTextRenderer(gl, shadersDirectory), + () => _factory.CreateTextRenderer( + _dependencies.GpuDevice, + _dependencies.GpuFrameSource, + shadersDirectory), _factory.Release); TextRenderer text = textLease.Resource; Fault(WorldRenderCompositionPoint.TextRendererCreated); diff --git a/src/AcDream.App/Rendering/BitmapFont.cs b/src/AcDream.App/Rendering/BitmapFont.cs index 9306b4bd..87067356 100644 --- a/src/AcDream.App/Rendering/BitmapFont.cs +++ b/src/AcDream.App/Rendering/BitmapFont.cs @@ -1,5 +1,7 @@ using System; using System.IO; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Gl; using Silk.NET.OpenGL; using StbTrueTypeSharp; @@ -7,9 +9,17 @@ namespace AcDream.App.Rendering; /// /// A pixel-font atlas rasterized from a TTF at load time using stb_truetype. -/// Glyphs are packed into a single-channel (R8) GL texture. Call +/// Glyphs are packed into a single-channel (R8) atlas. Call /// to resolve an ASCII codepoint to UV + metrics. /// +/// Campaign V slice V4a: the atlas is created and uploaded through +/// instead of raw GL, and registered +/// into the device's texture table. stays a raw GL +/// name — extracted from the created — because its +/// only consumer ('s classic sprite/font texture-unit +/// binding path; see that class's remarks) is not itself slot-based this +/// slice. +/// /// Only printable ASCII (32..127) is supported for the debug overlay. /// public sealed unsafe class BitmapFont : IDisposable @@ -34,11 +44,10 @@ public sealed unsafe class BitmapFont : IDisposable } } - private readonly GL _gl; private readonly Glyph[] _glyphs; private readonly int _firstChar; private readonly int _numChars; - private readonly ResourceCleanupGroup _resources; + private readonly IGpuTexture _texture; public uint TextureId { get; } public float PixelHeight { get; } @@ -47,10 +56,14 @@ public sealed unsafe class BitmapFont : IDisposable public int AtlasWidth { get; } public int AtlasHeight { get; } - public BitmapFont(GL gl, byte[] ttfBytes, float pixelHeight, + // internal, not public: IGpuDevice is an internal type (the pinned RHI + // contract). BitmapFont stays public — only construction is restricted — + // so existing public members that hold or return a BitmapFont need no + // visibility change of their own. + internal BitmapFont(IGpuDevice device, byte[] ttfBytes, float pixelHeight, int atlasSize = 512, int firstChar = 32, int numChars = 96) { - _gl = gl; + ArgumentNullException.ThrowIfNull(device); PixelHeight = pixelHeight; AtlasWidth = atlasSize; AtlasHeight = atlasSize; @@ -96,65 +109,52 @@ public sealed unsafe class BitmapFont : IDisposable adv: bc.xadvance); } - // Upload atlas as a single-channel GL texture (R8). Publish the GL - // name into the construction ledger before any later upload/state - // command can fail. - var resources = new ResourceCleanupGroup(); - uint texture = 0; + // Upload atlas as a single-channel texture (R8) through the device. + IGpuTexture texture = device.CreateTexture(new GpuTextureDescription( + "bitmap-font-atlas", + GpuTextureKind.Texture2D, + GpuTextureFormat.R8Unorm, + Width: AtlasWidth, + Height: AtlasHeight, + LayerCount: 1, + MipLevelCount: 1)); try { - texture = GlResourceCommand.CreateTexture(_gl, "BitmapFont atlas"); - uint ownedTexture = texture; - resources.Add( - "bitmap-font atlas", - () => GlResourceCommand.DeleteTexture( - _gl, - ownedTexture, - $"delete BitmapFont atlas {ownedTexture}")); - _gl.GetInteger(GetPName.TextureBinding2D, out int previousTexture); - _gl.GetInteger(GetPName.UnpackAlignment, out int previousAlignment); - GlResourceCommand.Execute(_gl, "initialize BitmapFont atlas", () => + fixed (byte* ptr = pixels) + texture.Upload(0, 0, new ReadOnlySpan(ptr, AtlasWidth * AtlasHeight)); + uint glNameForWrapFixup = ((GlGpuTexture)texture).GlName; + + // The classic texture-unit binding path TextRenderer's font draws + // use (see its class remarks) samples this texture object + // directly rather than through a bound GL sampler object, so wrap + // addressing must live on the texture itself — GlGpuTexture's + // constructor only sets the filter, not wrap mode (GL 4.3 + // defaults textures to GL_REPEAT). Retail's dat font glyph UVs + // never leave their own tight atlas sub-rect, but pin ClampToEdge + // explicitly to preserve the exact sampling behaviour the raw-GL + // constructor had. MUST happen BEFORE RegisterTexture below: + // ARB_bindless_texture forbids glTexParameter on a texture once + // its bindless handle has been made resident (GL_INVALID_OPERATION). + if (device is GlGpuDevice glDeviceForWrapFixup) { - try - { - _gl.BindTexture(TextureTarget.Texture2D, texture); - _gl.PixelStore(PixelStoreParameter.UnpackAlignment, 1); - fixed (byte* ptr = pixels) - { - _gl.TexImage2D(TextureTarget.Texture2D, 0, - (int)InternalFormat.R8, - (uint)AtlasWidth, (uint)AtlasHeight, 0, - PixelFormat.Red, PixelType.UnsignedByte, ptr); - } - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, - (int)TextureMinFilter.Linear); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, - (int)TextureMagFilter.Linear); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, - (int)TextureWrapMode.ClampToEdge); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, - (int)TextureWrapMode.ClampToEdge); - } - finally - { - _gl.PixelStore( - PixelStoreParameter.UnpackAlignment, - previousAlignment); - _gl.BindTexture( - TextureTarget.Texture2D, - unchecked((uint)previousTexture)); - } - }); + GL gl = glDeviceForWrapFixup.Gl; + gl.BindTexture(TextureTarget.Texture2D, glNameForWrapFixup); + gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge); + gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge); + gl.BindTexture(TextureTarget.Texture2D, 0); + } + + IGpuSampler sampler = device.CreateSampler(GpuSamplerDescription.WorldClamp); + device.RegisterTexture(texture, sampler); } - catch (Exception constructionFailure) + catch { - resources.RollbackConstructionAndThrow( - "BitmapFont construction failed and its GL atlas did not cleanly roll back.", - constructionFailure); + texture.Dispose(); + throw; } - TextureId = texture; - _resources = resources; + _texture = texture; + TextureId = ((GlGpuTexture)texture).GlName; } public bool TryGetGlyph(char c, out Glyph g) @@ -183,7 +183,7 @@ public sealed unsafe class BitmapFont : IDisposable public void Dispose() { - _resources.RetryCleanup(); + _texture.Dispose(); } /// diff --git a/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs b/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs index 1cecd9ef..8e5ed5fa 100644 --- a/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs +++ b/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs @@ -1,4 +1,6 @@ using System.Numerics; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Gl; using AcDream.App.Rendering.Wb; using AcDream.App.UI; using AcDream.App.UI.Layout; @@ -114,15 +116,23 @@ internal sealed class RetailCreatureAppraisalFrameView : private readonly UiViewport _viewport; private readonly UiElement _windowFrame; private readonly AppraisalUiController _controller; + private readonly GlGpuDevice _gpuDevice; public RetailCreatureAppraisalFrameView( UiViewport viewport, UiElement windowFrame, - AppraisalUiController controller) + AppraisalUiController controller, + IGpuDevice gpuDevice) { _viewport = viewport ?? throw new ArgumentNullException(nameof(viewport)); _windowFrame = windowFrame ?? throw new ArgumentNullException(nameof(windowFrame)); _controller = controller ?? throw new ArgumentNullException(nameof(controller)); + // The paperdoll/appraisal viewport texture escape hatch (campaign doc + // §7.1) is GL-only; see GlGpuDevice.RegisterExternalColorTexture. + _gpuDevice = gpuDevice as GlGpuDevice + ?? throw new ArgumentException( + "RetailCreatureAppraisalFrameView's viewport-texture registration is GL-only.", + nameof(gpuDevice)); } public bool TryGetVisibleTarget( @@ -149,7 +159,7 @@ internal sealed class RetailCreatureAppraisalFrameView : } public void SetTextureHandle(uint textureHandle) => - _viewport.TextureHandle = textureHandle; + _viewport.TextureSlot = _gpuDevice.RegisterExternalColorTexture(textureHandle); private static bool IsEffectivelyVisible(UiElement element) { diff --git a/src/AcDream.App/Rendering/DebugLineRenderer.cs b/src/AcDream.App/Rendering/DebugLineRenderer.cs index 7f883a78..2206d609 100644 --- a/src/AcDream.App/Rendering/DebugLineRenderer.cs +++ b/src/AcDream.App/Rendering/DebugLineRenderer.cs @@ -1,94 +1,89 @@ using System.Collections.Generic; using System.Numerics; -using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Gl; using Silk.NET.OpenGL; namespace AcDream.App.Rendering; /// -/// Minimal GL debug line renderer for visualizing collision shapes, -/// bounding boxes, and other debug geometry. Collect lines each frame -/// via / , then call +/// Minimal line renderer for visualizing collision shapes, bounding boxes, +/// and other debug geometry. Collect lines each frame via +/// / , then call /// to upload + draw them. /// -/// Uses a single shared VBO that's respecialized each frame. Vertex -/// format is (vec3 pos, vec3 color) = 24 bytes per vertex. +/// Campaign V slice V4a: the debug_line shader compiles through +/// ( +/// topology, depth disabled to match this renderer's "visible through +/// geometry" intent), and each Flush's vertex data comes from a per-frame +/// ring allocation instead of the old single respecialized VBO. The shader's +/// uView/uProjection pair does not fit the shared +/// GpuPushConstants block (one combined view-projection matrix), and +/// has no verb for arbitrary named uniforms, so +/// they are set directly against the pipeline's compiled program — the same +/// mechanical translation uses for its own +/// shader-local uniforms. +/// +/// Vertex format is (vec3 pos, vec3 color) = 24 bytes per vertex. /// public sealed unsafe class DebugLineRenderer : IDisposable { + private const int FloatsPerVertex = 6; + private const int VertexStrideBytes = FloatsPerVertex * sizeof(float); + + private static readonly GpuVertexLayout VertexLayout = new( + StrideBytes: VertexStrideBytes, + [ + new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0), + new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12), + ]); + private readonly GL _gl; - private readonly Shader _shader; - private readonly uint _vao; - private readonly uint _vbo; - private readonly ResourceCleanupGroup _resources; + private readonly ICurrentGpuFrameSource _frameSource; + private readonly IGpuPipeline _pipeline; + private readonly int _uViewLocation; + private readonly int _uProjectionLocation; private readonly List _buffer = new(4096); private int _vertexCount; - private int _capacityBytes; - public DebugLineRenderer(GL gl, string shaderDir) + // internal, not public: IGpuDevice/ICurrentGpuFrameSource are internal + // types (the pinned RHI contract). DebugLineRenderer stays public — only + // construction is restricted. + internal DebugLineRenderer(IGpuDevice device, ICurrentGpuFrameSource frameSource, string shaderDir) { - _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + ArgumentNullException.ThrowIfNull(device); + _frameSource = frameSource ?? throw new ArgumentNullException(nameof(frameSource)); ArgumentException.ThrowIfNullOrWhiteSpace(shaderDir); - var resources = new ResourceCleanupGroup(); - Shader? shader = null; - uint vao = 0; - uint vbo = 0; - try + if (device is not GlGpuDevice glDevice) { - shader = new Shader(gl, - Path.Combine(shaderDir, "debug_line.vert"), - Path.Combine(shaderDir, "debug_line.frag")); - resources.Add("debug-line shader", shader.Dispose); - vao = GlResourceCommand.CreateName( - gl, - "debug-line VAO", - gl.GenVertexArray, - gl.DeleteVertexArray); - uint ownedVao = vao; - resources.Add( - "debug-line VAO", - () => GlResourceCommand.DeleteVertexArray( - gl, - ownedVao, - $"delete debug-line VAO {ownedVao}")); - vbo = GlResourceCommand.CreateName( - gl, - "debug-line VBO", - gl.GenBuffer, - gl.DeleteBuffer); - uint ownedVbo = vbo; - resources.Add( - "debug-line VBO", - () => GlResourceCommand.DeleteBuffer( - gl, - ownedVbo, - $"delete debug-line VBO {ownedVbo}")); - - GlResourceCommand.Execute(gl, "configure debug-line vertex state", () => - { - gl.BindVertexArray(vao); - gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo); - // 24-byte stride: vec3 pos + vec3 color - gl.EnableVertexAttribArray(0); - gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 6 * sizeof(float), (void*)0); - gl.EnableVertexAttribArray(1); - gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, 6 * sizeof(float), (void*)(3 * sizeof(float))); - gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); - gl.BindVertexArray(0); - }); + throw new NotSupportedException( + "DebugLineRenderer's uView/uProjection uniforms (see the class remarks) are set " + + "directly against the GL program; it needs GlGpuDevice's raw GL handle."); } - catch (Exception constructionFailure) + _gl = glDevice.Gl; + + _pipeline = device.CreatePipeline(new GpuPipelineDescription { - resources.RollbackConstructionAndThrow( - "DebugLineRenderer construction failed and its GL prefix did not cleanly roll back.", - constructionFailure); - } + Name = "debug-line", + Shaders = new GpuShaderSet("debug_line"), + VertexLayout = VertexLayout, + Topology = GpuPrimitiveTopology.LineList, + Blend = GpuBlendMode.None, + // Retail debug lines draw through geometry (the old Flush disabled + // depth testing for the draw and restored whatever was ambient + // before it — GlGpuPassEncoder now does that restore generically; + // see its class comment). + Depth = GpuDepthState.Disabled, + Cull = GpuCullMode.None, + AlphaToCoverage = false, + ColorWrite = true, + SampleCount = 1, + }); - _resources = resources; - _shader = shader!; - _vao = vao; - _vbo = vbo; + uint program = ((GlGpuPipeline)_pipeline).GlProgram; + _uViewLocation = _gl.GetUniformLocation(program, "uView"); + _uProjectionLocation = _gl.GetUniformLocation(program, "uProjection"); } /// Clear accumulated lines. Call at the start of each frame. @@ -174,40 +169,42 @@ public sealed unsafe class DebugLineRenderer : IDisposable { if (_vertexCount == 0) return; - _shader.Use(); - _shader.SetMatrix4("uView", view); - _shader.SetMatrix4("uProjection", projection); + IGpuFrame frame = _frameSource.CurrentFrame + ?? throw new InvalidOperationException( + "DebugLineRenderer.Flush requires an open IGpuFrame (see GpuDeviceFrameLifetime) — " + + "the host must drive IGpuDevice.BeginFrame() before rendering debug lines."); - _gl.BindVertexArray(_vao); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); - - int neededBytes = _buffer.Count * sizeof(float); - if (neededBytes > _capacityBytes) + using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription { - fixed (float* ptr = CollectionsMarshal.AsSpan(_buffer)) - _gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)neededBytes, ptr, BufferUsageARB.DynamicDraw); - _capacityBytes = neededBytes; - } - else - { - fixed (float* ptr = CollectionsMarshal.AsSpan(_buffer)) - _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)neededBytes, ptr); - } + Name = "debug-line", + Color = new GpuColorAttachment( + Target: null, + Load: GpuLoadOp.Load, + Store: GpuStoreOp.Store, + ClearColor: default), + Depth = null, + SampleCount = 1, + }); + encoder.BindPipeline(_pipeline); + SetMatrix(_uViewLocation, view); + SetMatrix(_uProjectionLocation, projection); - // Depth test on so lines get occluded by geometry (but we want them - // visible through geometry — disable depth test so everything shows). - bool wasDepthEnabled = _gl.IsEnabled(EnableCap.DepthTest); - _gl.Disable(EnableCap.DepthTest); + int byteCount = _buffer.Count * sizeof(float); + GpuRingAllocation allocation = frame.AllocateRing(byteCount, GpuRingUsage.Vertex); + System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_buffer).CopyTo(allocation.AsSpan()); + encoder.BindVertexBuffer(allocation.Buffer, allocation.OffsetBytes); + encoder.Draw((uint)_vertexCount, 1, 0, 0); + } - _gl.DrawArrays(PrimitiveType.Lines, 0, (uint)_vertexCount); - - if (wasDepthEnabled) _gl.Enable(EnableCap.DepthTest); - - _gl.BindVertexArray(0); + private void SetMatrix(int location, Matrix4x4 m) + { + if (location < 0) + return; + _gl.UniformMatrix4(location, 1, false, (float*)&m); } public void Dispose() { - _resources.RetryCleanup(); + _pipeline.Dispose(); } } diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a638ba48..e16fa047 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -116,6 +116,7 @@ public sealed class GameWindow : private IDisposable? _frameGraphPublication; private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; private IGpuDevice? _gpuDevice; + private GpuDeviceFrameLifetime? _gpuFrameLifetime; private readonly AcDream.App.Rendering.GameFrameGraphSlot _frameGraphs = new(); private readonly AcDream.App.Rendering.GameRenderResourceLifetime _renderResourceLifetime = new(); @@ -748,6 +749,10 @@ public sealed class GameWindow : IGpuDevice value) => PublishCompositionOwner(ref _gpuDevice, value, "GPU device (RHI)"); + void IGameWindowHostInputCameraPublication.PublishGpuFrameLifetime( + GpuDeviceFrameLifetime value) => + PublishCompositionOwner(ref _gpuFrameLifetime, value, "GPU frame lifetime"); + void IGameWindowHostInputCameraPublication.PublishKeyboardSource( AcDream.App.Input.SilkKeyboardSource value) => PublishCompositionOwner(ref _kbSource, value, "keyboard source"); @@ -1306,7 +1311,9 @@ public sealed class GameWindow : _options.ResidencyBudgets, initialCenterLandblockId, _applicationPaths.DiagnosticsDirectory, - Console.WriteLine), + Console.WriteLine, + _gpuDevice!, + _gpuFrameLifetime!), this).Compose(platformResult, contentEffectsAudio, settingsDevTools); Console.WriteLine( $"loading world view centered on " + @@ -1351,7 +1358,9 @@ public sealed class GameWindow : settingsDevTools.DevTools?.Vitals, compositionToast, ClientTimerNow, - Console.WriteLine), + Console.WriteLine, + hostInputCamera.GpuDevice, + hostInputCamera.GpuFrameLifetime), _retailUiLease, this).Compose( platformResult, diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs index b416b597..dd329df8 100644 --- a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs @@ -313,9 +313,7 @@ internal sealed class GlGpuDevice : IGpuDevice // Force the write masks on before clearing, regardless of what // the previous pass's last draw left them at (e.g. depth-write // disabled mid-translucent-pass) — glClear silently no-ops for a - // buffer whose mask is off. GlRenderStateCache.Reset() afterward - // stops the cache from believing this forced state is the - // baseline the next BindPipeline should diff against. + // buffer whose mask is off. ClearBufferMask mask = 0; if (clearsColor) { @@ -333,8 +331,72 @@ internal sealed class GlGpuDevice : IGpuDevice } _gl.Clear(mask); GLHelpers.ThrowOnResourceError(_gl, $"clear pass '{description.Name}'"); - _renderState.Reset(); } + + // Campaign V slice V4a (2026-07-27 revert postmortem, plan §7.1 rule 2): + // reset unconditionally on EVERY pass, not only a clearing one. While + // raw-GL renderers coexist with RHI-ported ones (through V4h), a + // raw-GL renderer can run between two RHI passes within the same frame + // and change GL program/blend/depth/cull state the cache never + // observes. A reset gated on "this pass cleared" left the cache + // trusting a stale belief in that case, so a later BindPipeline + // skipped re-issuing glUseProgram and the following push-constant + // upload threw GL_INVALID_OPERATION against whatever program was + // actually bound — exactly the failure the first V4a attempt hit. + // Resetting on every BeginPass costs one redundant state application + // on the pass's first bind and is removed at V4h once nothing raw-GL + // remains. + _renderState.Reset(); + } + + // ── V4a pre-approved transitional seam (campaign doc §7.1, final paragraph) ── + // + // The paperdoll and creature-appraisal viewport textures are produced by + // PaperdollViewportRenderer / PrivateEntityViewportRenderer, both still raw + // GL until V4g. UiViewport (ported this slice) needs a GpuTextureSlot for + // whatever texture they hand it so it can draw through the same seam every + // other ported UI texture uses, without those renderers themselves porting + // early. This registers an EXTERNALLY-OWNED GL texture name into the + // device's texture table without taking ownership of its GL lifetime: this + // device never deletes it, and the owning renderer keeps recreating it on + // resize exactly as before. Idempotent by GL name so calling this every + // frame with the same still-live texture does not churn the table. + // + // Deleted at V4g, when PaperdollViewportRenderer / PrivateEntityViewportRenderer + // port onto IGpuDevice and can call RegisterTexture directly instead. + private readonly Dictionary _externalColorTextureSlotsByGlName = new(); + private readonly Dictionary _externalColorTextureGlNamesBySlot = new(); + + internal GpuTextureSlot RegisterExternalColorTexture(uint glTextureName) + { + ThrowIfDisposed(); + if (glTextureName == 0) + return GpuTextureSlot.Unassigned; + if (_externalColorTextureSlotsByGlName.TryGetValue(glTextureName, out GpuTextureSlot existing)) + return existing; + + ulong handle = _bindless.GetResidentHandle(glTextureName); + uint slotIndex = _textureSlotAllocator.Allocate(); + WriteHandle(slotIndex, handle); + var slot = new GpuTextureSlot(slotIndex); + _externalColorTextureSlotsByGlName[glTextureName] = slot; + _externalColorTextureGlNamesBySlot[slotIndex] = glTextureName; + return slot; + } + + /// + /// Resolves a slot produced by + /// back to its raw GL texture name, for the still-classic texture-unit + /// binding draw path (TextRenderer.DrawSprite(uint texture, ...)). + /// + internal bool TryResolveExternalColorTexture(GpuTextureSlot slot, out uint glTextureName) + { + if (!slot.IsAssigned) + { + glTextureName = 0; + return false; + } + return _externalColorTextureGlNamesBySlot.TryGetValue(slot.Index, out glTextureName); } internal void ApplyRenderState(GlRenderStateSnapshot desired) diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs index f3ac77c8..50c7cd0a 100644 --- a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs @@ -17,6 +17,7 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder private readonly GlGpuDevice _device; private readonly GlGpuFrame _frame; private readonly GL _gl; + private readonly GlAmbientCapabilityState _ambientOnEntry; private bool _closed; private GlGpuPipeline? _currentPipeline; @@ -30,6 +31,20 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder _frame = frame; _gl = device.Gl; Pass = pass; + + // Campaign V slice V4a (2026-07-27 revert postmortem, plan §7.1 rule 1): + // capture every ambient capability a bound pipeline can change, so + // Dispose can put it back. Every acdream renderer is still raw GL + // until V4c/V4d, so each one assumes whatever capability state the + // PREVIOUS renderer left behind is still there — GL_MULTISAMPLE and + // GL_SAMPLE_ALPHA_TO_COVERAGE in particular are set once per frame by + // quality settings and never re-asserted per draw. The first V4a + // attempt bound a pipeline that changed this state and never restored + // it, so the world drew without multisampling from the first UI frame + // on. Capturing here and restoring on Dispose keeps the GL backend's + // behaviour-preserving property true at this seam. Deleted at V4h + // once nothing raw-GL remains. + _ambientOnEntry = GlAmbientCapabilityState.Capture(_gl); } public GpuPassDescription Pass { get; } @@ -213,6 +228,12 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder // rejected at BeginPass (V1 targets are single-sampled), and // Store/DontCare need no explicit action — the framebuffer's contents // simply persist until the next pass rebinds a target. + // + // Restore whatever capability state was ambient before this pass + // opened (see the constructor's comment) so a still-raw-GL renderer + // running immediately after this pass sees exactly what it would have + // seen had this pass never bound a pipeline. + _ambientOnEntry.Restore(_gl); _frame.ClosePass(this); } @@ -233,3 +254,126 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder throw new ObjectDisposedException(nameof(GlGpuPassEncoder)); } } + +/// +/// Every ambient GL capability/binding a bind (or +/// a dynamic setter) can change, captured by raw query and restored by raw +/// call — the same field list TextRenderGlStateScope already restores +/// around TextRenderer.Flush, generalized to every +/// so a renderer with no scope of its own +/// (DebugLineRenderer today) does not leak state forward into the next +/// raw-GL renderer either. Transitional for as long as raw-GL renderers +/// coexist with RHI-ported ones; deleted at V4h. +/// +internal readonly struct GlAmbientCapabilityState +{ + private readonly int _program; + private readonly int _vertexArray; + private readonly int _arrayBuffer; + private readonly int _activeTexture; + private readonly int _texture0Binding2D; + private readonly bool _depthTest; + private readonly bool _depthWrite; + private readonly int _depthFunc; + private readonly bool _blend; + private readonly int _blendSourceRgb; + private readonly int _blendDestinationRgb; + private readonly int _blendSourceAlpha; + private readonly int _blendDestinationAlpha; + private readonly bool _cullFace; + private readonly int _cullFaceMode; + private readonly int _frontFace; + private readonly bool _alphaToCoverage; + private readonly bool _multisample; + + private GlAmbientCapabilityState( + int program, int vertexArray, int arrayBuffer, int activeTexture, int texture0Binding2D, + bool depthTest, bool depthWrite, int depthFunc, + bool blend, int blendSourceRgb, int blendDestinationRgb, int blendSourceAlpha, int blendDestinationAlpha, + bool cullFace, int cullFaceMode, int frontFace, + bool alphaToCoverage, bool multisample) + { + _program = program; + _vertexArray = vertexArray; + _arrayBuffer = arrayBuffer; + _activeTexture = activeTexture; + _texture0Binding2D = texture0Binding2D; + _depthTest = depthTest; + _depthWrite = depthWrite; + _depthFunc = depthFunc; + _blend = blend; + _blendSourceRgb = blendSourceRgb; + _blendDestinationRgb = blendDestinationRgb; + _blendSourceAlpha = blendSourceAlpha; + _blendDestinationAlpha = blendDestinationAlpha; + _cullFace = cullFace; + _cullFaceMode = cullFaceMode; + _frontFace = frontFace; + _alphaToCoverage = alphaToCoverage; + _multisample = multisample; + } + + internal static GlAmbientCapabilityState Capture(GL gl) + { + gl.GetInteger(GetPName.CurrentProgram, out int program); + gl.GetInteger(GetPName.VertexArrayBinding, out int vertexArray); + gl.GetInteger(GetPName.ArrayBufferBinding, out int arrayBuffer); + gl.GetInteger(GetPName.ActiveTexture, out int activeTexture); + int texture0Binding2D; + gl.ActiveTexture(TextureUnit.Texture0); + gl.GetInteger(GetPName.TextureBinding2D, out texture0Binding2D); + gl.ActiveTexture((TextureUnit)activeTexture); + + gl.GetInteger(GetPName.DepthFunc, out int depthFunc); + gl.GetInteger(GetPName.BlendSrcRgb, out int blendSourceRgb); + gl.GetInteger(GetPName.BlendDstRgb, out int blendDestinationRgb); + gl.GetInteger(GetPName.BlendSrcAlpha, out int blendSourceAlpha); + gl.GetInteger(GetPName.BlendDstAlpha, out int blendDestinationAlpha); + gl.GetInteger(GetPName.CullFaceMode, out int cullFaceMode); + gl.GetInteger(GetPName.FrontFace, out int frontFace); + + return new GlAmbientCapabilityState( + program, vertexArray, arrayBuffer, activeTexture, texture0Binding2D, + gl.IsEnabled(EnableCap.DepthTest), gl.GetBoolean(GetPName.DepthWritemask), depthFunc, + gl.IsEnabled(EnableCap.Blend), blendSourceRgb, blendDestinationRgb, blendSourceAlpha, blendDestinationAlpha, + gl.IsEnabled(EnableCap.CullFace), cullFaceMode, frontFace, + gl.IsEnabled(EnableCap.SampleAlphaToCoverage), gl.IsEnabled(EnableCap.Multisample)); + } + + internal void Restore(GL gl) + { + gl.UseProgram((uint)_program); + gl.BindVertexArray((uint)_vertexArray); + gl.BindBuffer(BufferTargetARB.ArrayBuffer, (uint)_arrayBuffer); + + gl.ActiveTexture(TextureUnit.Texture0); + gl.BindTexture(TextureTarget.Texture2D, (uint)_texture0Binding2D); + gl.ActiveTexture((TextureUnit)_activeTexture); + + SetCapability(gl, EnableCap.DepthTest, _depthTest); + gl.DepthMask(_depthWrite); + gl.DepthFunc((DepthFunction)_depthFunc); + + SetCapability(gl, EnableCap.Blend, _blend); + gl.BlendFuncSeparate( + (BlendingFactor)_blendSourceRgb, + (BlendingFactor)_blendDestinationRgb, + (BlendingFactor)_blendSourceAlpha, + (BlendingFactor)_blendDestinationAlpha); + + SetCapability(gl, EnableCap.CullFace, _cullFace); + gl.CullFace((TriangleFace)_cullFaceMode); + gl.FrontFace((FrontFaceDirection)_frontFace); + + SetCapability(gl, EnableCap.SampleAlphaToCoverage, _alphaToCoverage); + SetCapability(gl, EnableCap.Multisample, _multisample); + } + + private static void SetCapability(GL gl, EnableCap capability, bool enabled) + { + if (enabled) + gl.Enable(capability); + else + gl.Disable(capability); + } +} diff --git a/src/AcDream.App/Rendering/GpuDeviceFrameLifetime.cs b/src/AcDream.App/Rendering/GpuDeviceFrameLifetime.cs new file mode 100644 index 00000000..736d044b --- /dev/null +++ b/src/AcDream.App/Rendering/GpuDeviceFrameLifetime.cs @@ -0,0 +1,50 @@ +using AcDream.App.Rendering.Gpu; + +namespace AcDream.App.Rendering; + +/// +/// Campaign V slice V4a: exposes whichever is +/// currently open, so renderers ported onto the RHI (, +/// today) can reach it without a stored +/// window/delegate back-reference. Non-null exactly between a +/// and its matching +/// . +/// +internal interface ICurrentGpuFrameSource +{ + IGpuFrame? CurrentFrame { get; } +} + +/// +/// Drives / once +/// per rendered frame, additively over the existing +/// -driven fence/slot bracket: +/// 's own BeginFrame already calls straight +/// through to the frame-flight controller it was constructed with (see its +/// class comment), so routing the production +/// 's +/// through the device instead of the controller directly changes nothing about +/// the existing fence-wait/slot-rotation contract — it only additionally +/// yields the that ported renderers need, which +/// alone could not supply. +/// No clears move, no framebuffer binding changes, and the frame graph's +/// phase order is untouched. +/// +internal sealed class GpuDeviceFrameLifetime : IRenderFrameLifetime, ICurrentGpuFrameSource +{ + private readonly IGpuDevice _device; + + public GpuDeviceFrameLifetime(IGpuDevice device) => + _device = device ?? throw new ArgumentNullException(nameof(device)); + + public IGpuFrame? CurrentFrame { get; private set; } + + public void BeginFrame() => CurrentFrame = _device.BeginFrame(); + + public void EndFrame() + { + IGpuFrame? frame = CurrentFrame; + CurrentFrame = null; + frame?.End(); + } +} diff --git a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs index 33883803..ec0efd0f 100644 --- a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs +++ b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs @@ -1,5 +1,7 @@ using System.Numerics; using AcDream.App.Input; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Gl; using AcDream.App.UI; using AcDream.App.World; using AcDream.Content; @@ -191,13 +193,21 @@ internal sealed class RetailPaperdollFrameView : IPaperdollFrameView { private readonly UiViewport _viewport; private readonly IPaperdollInventoryVisibility _inventory; + private readonly GlGpuDevice _gpuDevice; public RetailPaperdollFrameView( UiViewport viewport, - IPaperdollInventoryVisibility inventory) + IPaperdollInventoryVisibility inventory, + IGpuDevice gpuDevice) { _viewport = viewport ?? throw new ArgumentNullException(nameof(viewport)); _inventory = inventory ?? throw new ArgumentNullException(nameof(inventory)); + // The paperdoll/appraisal viewport texture escape hatch (campaign doc + // §7.1) is GL-only; see GlGpuDevice.RegisterExternalColorTexture. + _gpuDevice = gpuDevice as GlGpuDevice + ?? throw new ArgumentException( + "RetailPaperdollFrameView's viewport-texture registration is GL-only.", + nameof(gpuDevice)); } public bool TryGetVisibleSize(out int width, out int height) @@ -215,7 +225,7 @@ internal sealed class RetailPaperdollFrameView : IPaperdollFrameView } public void SetTextureHandle(uint textureHandle) => - _viewport.TextureHandle = textureHandle; + _viewport.TextureSlot = _gpuDevice.RegisterExternalColorTexture(textureHandle); } /// Narrow visibility adapter for the paperdoll's inventory host. diff --git a/src/AcDream.App/Rendering/RenderBootstrap.cs b/src/AcDream.App/Rendering/RenderBootstrap.cs index 1b3949b2..76ee41da 100644 --- a/src/AcDream.App/Rendering/RenderBootstrap.cs +++ b/src/AcDream.App/Rendering/RenderBootstrap.cs @@ -28,15 +28,26 @@ public sealed record RenderStack( AcDream.App.UI.UiDatFont? LargeDatFont) : System.IDisposable { internal GpuFrameFlightController FrameFlights { get; init; } = null!; + + /// + /// Campaign V slice V4a: the studio's own RHI device (mirrors + /// 's production one — the studio + /// composes its own render stack independently of GameWindow). + /// + internal AcDream.App.Rendering.Gpu.IGpuDevice GpuDevice { get; init; } = null!; + + /// + /// Drives /IGpuFrame.End + /// once per / pair and + /// exposes the open frame to 's . + /// + internal GpuDeviceFrameLifetime FrameLifetime { get; init; } = null!; + private ResourceShutdownTransaction? _shutdown; - internal void BeginFrame() - { - FrameFlights.BeginFrame(); - UiHost.TextRenderer.BeginFrame(FrameFlights.CurrentSlot); - } + internal void BeginFrame() => FrameLifetime.BeginFrame(); - internal void EndFrame() => FrameFlights.EndFrame(); + internal void EndFrame() => FrameLifetime.EndFrame(); /// Dispose the GL pieces this stack OWNS (everything created in /// ). + are caller-owned @@ -62,6 +73,10 @@ public sealed record RenderStack( new("mesh shader", MeshShader.Dispose), new("lighting UBO", LightingUbo.Dispose), new("UI host", UiHost.Dispose), + // GpuDevice's own Dispose routes every resource release through + // FrameFlights as its retirement queue, so it must be disposed + // before FrameFlights below (see GlGpuDevice.Dispose's comment). + new("GPU device (RHI)", GpuDevice.Dispose), ]), new ResourceShutdownStage("frame flight owner", [ @@ -165,8 +180,14 @@ public static class RenderBootstrap // --- TextureCache (GameWindow ~1774) --- var frameFlights = new GpuFrameFlightController(gl); + // Campaign V slice V4a: the studio composes its own RHI device + // independently of GameWindow/HostInputCameraComposition, mirroring + // that composition's construction (gl + frame flights + shaders dir). + Gpu.IGpuDevice gpuDevice = new Gpu.Gl.GlGpuDevice(gl, frameFlights, shaderDir); + var gpuFrameLifetime = new GpuDeviceFrameLifetime(gpuDevice); var textureCache = new TextureCache( gl, + gpuDevice, dats, bindless, frameFlights, @@ -243,7 +264,7 @@ public static class RenderBootstrap // --- UiHost (GameWindow ~1790); pass null for debugFont (only used as // a fallback BitmapFont for the world-space HUD — not needed for the // UI Studio, and BitmapFont requires a system font byte array) --- - var uiHost = new AcDream.App.UI.UiHost(gl, shaderDir, defaultFont: null); + var uiHost = new AcDream.App.UI.UiHost(gpuDevice, gpuFrameLifetime, shaderDir, defaultFont: null); var stack = new RenderStack( Gl: gl, @@ -261,6 +282,8 @@ public static class RenderBootstrap LargeDatFont: largeDatFont) { FrameFlights = frameFlights, + GpuDevice = gpuDevice, + FrameLifetime = gpuFrameLifetime, }; // Pre-seed the font cache with the two already-uploaded atlas instances diff --git a/src/AcDream.App/Rendering/RenderFrameResourceController.cs b/src/AcDream.App/Rendering/RenderFrameResourceController.cs index 03d4792d..ef75b9d3 100644 --- a/src/AcDream.App/Rendering/RenderFrameResourceController.cs +++ b/src/AcDream.App/Rendering/RenderFrameResourceController.cs @@ -88,8 +88,6 @@ internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResour private readonly WbDrawDispatcher? _dispatcher; private readonly EnvCellRenderer? _environmentCells; private readonly PortalDepthMaskRenderer? _portalDepth; - private readonly TextRenderer? _worldText; - private readonly TextRenderer? _uiText; private readonly ClipFrame? _clip; private readonly TerrainModernRenderer? _terrain; private readonly SceneLightingUboBinding? _lighting; @@ -98,8 +96,6 @@ internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResour WbDrawDispatcher? dispatcher, EnvCellRenderer? environmentCells, PortalDepthMaskRenderer? portalDepth, - TextRenderer? worldText, - TextRenderer? uiText, ClipFrame? clip, TerrainModernRenderer? terrain, SceneLightingUboBinding? lighting) @@ -108,8 +104,6 @@ internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResour _dispatcher = dispatcher; _environmentCells = environmentCells; _portalDepth = portalDepth; - _worldText = worldText; - _uiText = uiText; _clip = clip; _terrain = terrain; _lighting = lighting; @@ -122,8 +116,10 @@ internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResour _dispatcher?.BeginFrame(gpuSlot); _environmentCells?.BeginFrame(gpuSlot); _portalDepth?.BeginFrame(gpuSlot); - _worldText?.BeginFrame(gpuSlot); - _uiText?.BeginFrame(gpuSlot); + // The world-HUD and retained-UI TextRenderers no longer take a + // per-slot begin: Campaign V slice V4a ported them onto the shared + // IGpuFrame ring (see GpuDeviceFrameLifetime), which is reset once + // per frame by IGpuDevice.BeginFrame() rather than per renderer. _clip?.BeginFrame(gpuSlot); _terrain?.BeginFrame(gpuSlot); _lighting?.BeginFrame(gpuSlot); diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index 18f8862b..20e329d4 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -1,10 +1,9 @@ using System; using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Numerics; using System.Runtime.InteropServices; -using AcDream.App.Rendering.Wb; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Gl; using Silk.NET.OpenGL; namespace AcDream.App.Rendering; @@ -15,53 +14,82 @@ namespace AcDream.App.Rendering; /// at the start of a HUD pass, queue geometry via /// / , then . /// -/// Uses two internal vertex buffers (text and rect) flushed in two draw calls -/// to avoid a per-vertex "use texture" flag. Rects are drawn first so text -/// sits on top of background panels. +/// Campaign V slice V4a: the ui_text shader compiles through +/// (one , +/// replacing the old hand-rolled Shader class), its three +/// fence-buffered per-flight VBOs are gone in favour of a per- +/// ring allocation per draw bucket, and the 1×1 white fill texture is created +/// and uploaded through and registered +/// into the device's texture table. +/// +/// has no verb for classic texture-unit binding +/// (every acdream RHI pass samples through the bindless texture table) but +/// receives an ARBITRARY externally-owned raw GL +/// texture name from dozens of UI call sites that are not part of this slice +/// (icons, dat chrome, composited item art) — converting that whole surface +/// to slot-based sampling is out of scope here. So sprite/font texture +/// binding stays classic (glActiveTexture/glBindTexture, +/// ui_text.frag's uTex sampler unchanged) issued directly against +/// the GL handle this class keeps for that reason, while the shader program +/// itself, its blend/depth/cull description, and every per-frame vertex +/// upload now go through the RHI. This mirrors the same GL-only escape hatch +/// the campaign's V2 note already documents for the interim bindless handle +/// table, and is retired only when a later slice moves ALL of TextRenderer's +/// texture consumers onto registered slots. +/// +/// Uses per-bucket ring allocations flushed in up to three draw calls per +/// layer, to avoid a per-vertex "use texture" flag. Rects are drawn first so +/// text sits on top of background panels. /// public sealed unsafe class TextRenderer : IDisposable { private const int FloatsPerVertex = 8; // pos(2) + uv(2) + color(4) + private const int VertexStrideBytes = FloatsPerVertex * sizeof(float); + private static readonly GpuVertexLayout SpriteVertexLayout = new( + StrideBytes: VertexStrideBytes, + [ + new GpuVertexAttribute(0, GpuVertexFormat.Float2, 0), + new GpuVertexAttribute(1, GpuVertexFormat.Float2, 8), + new GpuVertexAttribute(2, GpuVertexFormat.Float4, 16), + ]); + + private readonly IGpuDevice _device; + private readonly GlGpuDevice _glDevice; + private readonly ICurrentGpuFrameSource _frameSource; private readonly GL _gl; private readonly ITextRenderGlStateApi _glState; - private readonly Shader _shader; - private readonly ResourceCleanupGroup _resources; - private uint _vao; - private uint _vbo; + private readonly IGpuPipeline _pipeline; + private readonly IGpuTexture _whiteTexture; private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket - private int _vboCapacityBytes; + private readonly int _uScreenSizeLocation; + private readonly int _uUseTextureLocation; - private sealed class FrameBufferSet - { - public uint Vao; - public uint Vbo; - public int CapacityBytes; - public int UsedBytes; - } + private sealed class SpriteSeg { public uint Texture; public readonly List Verts = new(256); } - private readonly FrameBufferSet[] _frameBuffers; - private FrameBufferSet? _activeFrameBuffer; - - internal long DynamicBufferCapacityBytes => - _frameBuffers.Sum(set => (long)set.CapacityBytes); - - private readonly List _textBuf = new(8192); - private readonly List _rectBuf = new(1024); // Submission-ordered sprite segments: consecutive DrawSprite calls with the // SAME texture batch into one segment; a texture change starts a new segment. // Drawing segments in submission order preserves painter z-order for // sprite-on-sprite UI. (The old per-texture dictionary drew a REUSED texture // at its FIRST-insertion point, so later bar sprites covered glyphs emitted // earlier via the shared dat-font atlas — the stamina/mana numbers vanished.) - private sealed class SpriteSeg { public uint Texture; public readonly List Verts = new(256); } - + private readonly List _textBuf = new(8192); + private readonly List _rectBuf = new(1024); private readonly List _spriteSegs = new(); private int _segUsed; private int _textVerts; private int _rectVerts; private Vector2 _screenSize; + /// + /// No longer meaningful post-V4a: per-frame vertex data comes from the + /// device's shared per-flight ring rather than a VBO this class owns. Kept + /// (returning 0) so 's telemetry + /// read still compiles; the dynamic-buffer dimension it reported is now a + /// device-wide, not a per-renderer, concern. + /// + internal long DynamicBufferCapacityBytes => 0; + // Overlay layer — a parallel set of buckets drawn AFTER the normal sprite/rect/text // buckets, so open popups/menus composite on top of EVERYTHING, including translucent // rect panel backgrounds (which otherwise always win because rects flush after @@ -77,142 +105,89 @@ public sealed unsafe class TextRenderer : IDisposable /// of all normal-layer geometry). Set by the UI root around the popup/overlay pass. public bool OverlayMode { get; set; } - public TextRenderer(GL gl, string shaderDir) + // internal, not public: IGpuDevice/ICurrentGpuFrameSource are internal + // types (the pinned RHI contract). The TextRenderer TYPE stays public — + // only construction is restricted — so existing public members that hold + // or return a TextRenderer (e.g. UiHost.TextRenderer) need no visibility + // change of their own. + internal TextRenderer(IGpuDevice device, ICurrentGpuFrameSource frameSource, string shaderDir) { - _gl = gl; - _glState = new SilkTextRenderGlStateApi(gl); - var resources = new ResourceCleanupGroup(); - Shader? shader = null; - var frameBuffers = new FrameBufferSet[3]; - uint whiteTexture = 0; + _device = device ?? throw new ArgumentNullException(nameof(device)); + _frameSource = frameSource ?? throw new ArgumentNullException(nameof(frameSource)); + if (device is not GlGpuDevice glDevice) + { + throw new NotSupportedException( + "TextRenderer's classic sprite/font texture-unit binding path (see the class " + + "remarks) is GL-only; it needs the raw GL handle GlGpuDevice exposes. Other " + + "backends are out of scope until a later slice removes that classic path."); + } + _glDevice = glDevice; + _gl = glDevice.Gl; + _glState = new SilkTextRenderGlStateApi(_gl); + ArgumentException.ThrowIfNullOrWhiteSpace(shaderDir); + + IGpuPipeline? pipeline = null; + IGpuTexture? whiteTexture = null; try { - shader = new Shader(gl, - Path.Combine(shaderDir, "ui_text.vert"), - Path.Combine(shaderDir, "ui_text.frag")); - resources.Add("text shader", shader.Dispose); - - for (int i = 0; i < frameBuffers.Length; i++) - frameBuffers[i] = CreateFrameBufferSet(resources); + pipeline = device.CreatePipeline(new GpuPipelineDescription + { + Name = "ui-text", + Shaders = new GpuShaderSet("ui_text"), + VertexLayout = SpriteVertexLayout, + Topology = GpuPrimitiveTopology.TriangleList, + Blend = GpuBlendMode.StraightAlpha, + // The retained UI is a self-contained 2-D pass — depth is + // irrelevant and the world pass's alpha-to-coverage state must + // not leak in (feedback_render_self_contained_gl_state). Flush + // below still asserts this by hand via raw GL calls (and + // GL_MULTISAMPLE, which has no representation here), matching + // what TextRenderGlStateScope has always restored on exit. + Depth = GpuDepthState.Disabled, + Cull = GpuCullMode.None, + AlphaToCoverage = false, + ColorWrite = true, + SampleCount = 1, + }); // 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE // bucket (the shader multiplies texel×color → white×color = color). Lets a panel // background draw UNDER its text in painter order, which DrawRect's separate // bucket cannot (it always composites after all sprites). - whiteTexture = GlResourceCommand.CreateTexture( - _gl, - "TextRenderer white texture"); - uint ownedWhiteTexture = whiteTexture; - resources.Add( - "white texture", - () => GlResourceCommand.DeleteTexture( - _gl, - ownedWhiteTexture, - $"delete TextRenderer white texture {ownedWhiteTexture}")); - GlResourceCommand.Execute( - _gl, - "initialize TextRenderer white texture", - () => - { - _gl.BindTexture(TextureTarget.Texture2D, whiteTexture); - Span whitePixel = stackalloc byte[] { 255, 255, 255, 255 }; - fixed (byte* wp = whitePixel) - _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0, - PixelFormat.Rgba, PixelType.UnsignedByte, wp); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest); - _gl.BindTexture(TextureTarget.Texture2D, 0); - }); + whiteTexture = device.CreateTexture(new GpuTextureDescription( + "ui-text-white", + GpuTextureKind.Texture2D, + GpuTextureFormat.Rgba8Unorm, + Width: 1, + Height: 1, + LayerCount: 1, + MipLevelCount: 1)); + whiteTexture.Upload(0, 0, [255, 255, 255, 255]); + IGpuSampler whiteSampler = device.CreateSampler(GpuSamplerDescription.UiNearest); + device.RegisterTexture(whiteTexture, whiteSampler); } - catch (Exception constructionFailure) + catch { - try - { - resources.RetryCleanup(); - } - catch (Exception cleanupFailure) - { - throw new GlResourceConstructionException( - "TextRenderer construction failed and its published GL resources did not cleanly roll back.", - resources, - [constructionFailure, cleanupFailure]); - } - + whiteTexture?.Dispose(); + pipeline?.Dispose(); throw; } - _resources = resources; - _shader = shader; - _frameBuffers = frameBuffers; - _whiteTex = whiteTexture; - } + _pipeline = pipeline; + _whiteTexture = whiteTexture; + _whiteTex = ((GlGpuTexture)whiteTexture).GlName; - /// - /// Selects the GPU-fenced frame slot and resets its append cursor. Every - /// UI segment rendered during the frame receives a distinct byte range; - /// later text or sprite batches cannot overwrite an earlier in-flight draw. - /// - public void BeginFrame(int frameSlot) - { - if ((uint)frameSlot >= (uint)_frameBuffers.Length) - throw new ArgumentOutOfRangeException(nameof(frameSlot)); - - FrameBufferSet set = _frameBuffers[frameSlot]; - set.UsedBytes = 0; - _activeFrameBuffer = set; - _vao = set.Vao; - _vbo = set.Vbo; - _vboCapacityBytes = set.CapacityBytes; - } - - private FrameBufferSet CreateFrameBufferSet(ResourceCleanupGroup resources) - { - uint vao = TrackedGlResource.CreateVertexArray( - _gl, - "TextRenderer frame VAO creation"); - RetryableGpuResourceRelease vaoRelease = - TrackedGlResource.CreateRetryableVertexArrayDeletion( - _gl, - vao, - "TextRenderer frame VAO disposal"); - resources.Add("frame VAO", vaoRelease.Run); - var set = new FrameBufferSet { Vao = vao }; - - uint vbo = TrackedGlResource.CreateBuffer( - _gl, - "TextRenderer frame VBO creation"); - set.Vbo = vbo; - RetryableGpuResourceRelease? vboRelease = null; - resources.Add( - "frame VBO", - () => - { - vboRelease ??= TrackedGlResource.CreateRetryableBufferDeletion( - _gl, - vbo, - set.CapacityBytes, - "TextRenderer frame VBO disposal"); - vboRelease.Run(); - }); - - GlResourceCommand.Execute( - _gl, - "initialize TextRenderer frame VAO and VBO", - () => - { - _gl.BindVertexArray(set.Vao); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.Vbo); - uint stride = FloatsPerVertex * sizeof(float); - _gl.EnableVertexAttribArray(0); - _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0); - _gl.EnableVertexAttribArray(1); - _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float))); - _gl.EnableVertexAttribArray(2); - _gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float))); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); - _gl.BindVertexArray(0); - }); - return set; + uint program = ((GlGpuPipeline)_pipeline).GlProgram; + _uScreenSizeLocation = _gl.GetUniformLocation(program, "uScreenSize"); + _uUseTextureLocation = _gl.GetUniformLocation(program, "uUseTexture"); + // uTex (the sampler unit) never changes — bind it once rather than on every Flush. + int texLocation = _gl.GetUniformLocation(program, "uTex"); + if (texLocation >= 0) + { + _gl.UseProgram(program); + _gl.Uniform1(texLocation, 0); + _gl.UseProgram(0); + } } /// Begin a HUD pass. Call once per frame before any Draw* calls. @@ -355,6 +330,16 @@ public sealed unsafe class TextRenderer : IDisposable AppendQuad(seg.Verts, x, y, w, h, u0, v0, u1, v1, tint); } + /// + /// Resolves a produced by the paperdoll/appraisal + /// viewport transitional seam (GlGpuDevice.RegisterExternalColorTexture, + /// campaign doc §7.1) back to the raw GL texture name + /// needs. Returns 0 (no texture) for an unassigned slot. GL-only; removed + /// at V4g alongside the seam it resolves. + /// + internal uint ResolveExternalTextureSlot(GpuTextureSlot slot) => + _glDevice.TryResolveExternalColorTexture(slot, out uint name) ? name : 0; + /// Pick the sprite segment for : extend the current /// same-texture run, else reuse a pooled segment, else allocate. Submission order is /// preserved (painter z-order for sprite-on-sprite UI). @@ -409,17 +394,35 @@ public sealed unsafe class TextRenderer : IDisposable bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0; if (!anyNormal && !anyOverlay) return; + IGpuFrame frame = _frameSource.CurrentFrame + ?? throw new InvalidOperationException( + "TextRenderer.Flush requires an open IGpuFrame (see GpuDeviceFrameLifetime) — " + + "the host must drive IGpuDevice.BeginFrame() before rendering the retained UI."); + // Retained UI is a private render pass: an upload or draw failure must // not leak its depth/cull/blend/MSAA state into a later recoverable // frame. The focused scope restores from Flush's generated finally, // including when either DrawLayer call throws. using var stateScope = new TextRenderGlStateScope(_glState); - _shader.Use(); - _shader.SetVec2("uScreenSize", _screenSize); - - _gl.BindVertexArray(_vao); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); + using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription + { + Name = "ui-text", + // GL has no framebuffer-implicit "pass" of its own; this slice's + // transitional shape (campaign doc §4's GpuPassDescription remarks) + // opens a Load/Store pass against the backbuffer so clears and + // framebuffer management stay owned by the frame spine, exactly as + // today, while this renderer records through the encoder. + Color = new GpuColorAttachment( + Target: null, + Load: GpuLoadOp.Load, + Store: GpuStoreOp.Store, + ClearColor: default), + Depth = null, + SampleCount = 1, + }); + encoder.BindPipeline(_pipeline); + _gl.Uniform2(_uScreenSizeLocation, _screenSize.X, _screenSize.Y); // Establish the self-contained UI pass state. // The world pass leaves alpha-to-coverage + multisample enabled (WbDrawDispatcher, @@ -443,9 +446,8 @@ public sealed unsafe class TextRenderer : IDisposable // so sprite-on-sprite z is preserved. Buckets 2 (rects) + 3 (debug text) // composite on top, in that order. The OVERLAY layer repeats all three // AFTER the normal layer, so open popups beat even the rect backgrounds. - DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font); - DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font); - + DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font, frame, encoder); + DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font, frame, encoder); } /// Draw one compositing layer: sprites (submission order, one call per @@ -454,79 +456,66 @@ public sealed unsafe class TextRenderer : IDisposable private void DrawLayer( List spriteSegs, int segUsed, List rectBuf, int rectVerts, - List textBuf, int textVerts, BitmapFont? font) + List textBuf, int textVerts, BitmapFont? font, + IGpuFrame frame, IGpuPassEncoder encoder) { // 1. RGBA dat sprites — one draw call per distinct GL texture. if (segUsed > 0) { - _shader.SetInt("uUseTexture", 2); + SetUseTexture(2); _gl.ActiveTexture(TextureUnit.Texture0); - _shader.SetInt("uTex", 0); for (int i = 0; i < segUsed; i++) { var seg = spriteSegs[i]; if (seg.Verts.Count == 0) continue; _gl.BindTexture(TextureTarget.Texture2D, seg.Texture); - int firstVertex = UploadBuffer(seg.Verts); - _gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (uint)(seg.Verts.Count / FloatsPerVertex)); + DrawRing(frame, encoder, seg.Verts); } } // 2. Untextured rects — widget fills on top of the chrome. if (rectVerts > 0) { - _shader.SetInt("uUseTexture", 0); - int firstVertex = UploadBuffer(rectBuf); - _gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (uint)rectVerts); + SetUseTexture(0); + DrawRing(frame, encoder, rectBuf); } // 3. Textured debug-font text glyphs on top. if (textVerts > 0 && font is not null) { - _shader.SetInt("uUseTexture", 1); + SetUseTexture(1); _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2D, font.TextureId); - _shader.SetInt("uTex", 0); - int firstVertex = UploadBuffer(textBuf); - _gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (uint)textVerts); + DrawRing(frame, encoder, textBuf); } } - private int UploadBuffer(List buf) + private void SetUseTexture(int mode) { - int bytes = buf.Count * sizeof(float); - if (bytes == 0) return 0; - FrameBufferSet set = _activeFrameBuffer - ?? throw new InvalidOperationException("BeginFrame must be called before rendering text."); - int byteOffset = set.UsedBytes; - int requiredBytes = checked(byteOffset + bytes); + if (_uUseTextureLocation >= 0) + _gl.Uniform1(_uUseTextureLocation, mode); + } - if (requiredBytes > _vboCapacityBytes) - { - int newCapacity = DynamicBufferCapacity.Grow( - _vboCapacityBytes, - requiredBytes); - TrackedGlResource.AllocateBufferStorage( - _gl, - GLEnum.ArrayBuffer, - _vbo, - _vboCapacityBytes, - newCapacity, - GLEnum.DynamicDraw, - "TextRenderer frame VBO growth"); - _vboCapacityBytes = newCapacity; - } - - fixed (float* p = CollectionsMarshal.AsSpan(buf)) - _gl.BufferSubData(BufferTargetARB.ArrayBuffer, (nint)byteOffset, (nuint)bytes, p); - - set.UsedBytes = requiredBytes; - set.CapacityBytes = _vboCapacityBytes; - return byteOffset / (FloatsPerVertex * sizeof(float)); + /// + /// Allocates a ring range from the current frame, copies + /// into it, and issues one non-indexed draw. Replaces the old growable + /// per-flight VBO + BufferSubData pattern: every UI vertex upload is + /// now the frame's shared ring, reset once per frame by + /// . + /// + private static void DrawRing(IGpuFrame frame, IGpuPassEncoder encoder, List buf) + { + if (buf.Count == 0) + return; + GpuRingAllocation allocation = frame.AllocateRing(buf.Count * sizeof(float), GpuRingUsage.Vertex); + CollectionsMarshal.AsSpan(buf).CopyTo(allocation.AsSpan()); + encoder.BindVertexBuffer(allocation.Buffer, allocation.OffsetBytes); + encoder.Draw((uint)(buf.Count / FloatsPerVertex), 1, 0, 0); } public void Dispose() { - _resources.RetryCleanup(); + _whiteTexture.Dispose(); + _pipeline.Dispose(); } } diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index c4bfb1fd..48da8081 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -2,6 +2,8 @@ using AcDream.Core.Textures; using AcDream.Core.World; using AcDream.Content; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Gl; using DatReaderWriter; using DatReaderWriter.DBObjs; using Silk.NET.OpenGL; @@ -17,6 +19,7 @@ public sealed unsafe class TextureCache IDisposable { private readonly GL _gl; + private readonly IGpuDevice _device; private readonly IDatReaderWriter _dats; private readonly string _diagnosticsDirectory; // Handle and decoded dimensions are one atomic cache entry. Keeping them @@ -28,17 +31,29 @@ public sealed unsafe class TextureCache _decodedDimensionsByTexture = new(); private uint _magentaHandle; + /// + /// Campaign V slice V4a: one registered plus its + /// device texture-table , decoded pixel size, + /// and the raw GL name 's classic + /// texture-unit binding path still needs (see that class's remarks). + /// + private readonly record struct GpuUiTextureEntry( + IGpuTexture Texture, + GpuTextureSlot Slot, + uint GlName, + int Width, + int Height); + // Direct-RenderSurface caches for UI sprites: 0x06xxxxxx RenderSurface ids // decoded directly (Portal/HighRes → DecodeRenderSurface), bypassing the // Surface→SurfaceTexture chain that GetOrUpload uses for world materials. - private readonly Dictionary _handlesByRenderSurfaceId = new(); - private readonly Dictionary _rsSizeById = new(); + private readonly Dictionary _renderSurfaceGpuTextures = new(); - // Ad-hoc handles produced by the public UploadRgba8(byte[],int,int,bool) wrapper + // Ad-hoc textures produced by the public UploadRgba8(byte[],int,int,bool) wrapper // (used by IconComposer for composited item icons). These are NOT stored in any // of the keyed caches above, so Dispose must sweep this list to avoid leaking - // GL texture objects until process exit. - private readonly List _adhocHandles = new(); + // GPU texture objects/slots until process exit. + private readonly List _adhocGpuTextures = new(); private readonly Wb.BindlessSupport? _bindless; private readonly CompositeTextureArrayCache? _compositeTextures; @@ -86,9 +101,14 @@ public sealed unsafe class TextureCache private int _dumpFrameCounter; private bool _surfaceHistogramAlreadyDumped; - public TextureCache(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null) + // internal, not public: IGpuDevice is an internal type (the pinned RHI + // contract), and this convenience overload has no real caller today (both + // production construction sites already target the internal overload + // below) — kept internal rather than deleted to preserve its shape. + internal TextureCache(GL gl, IGpuDevice device, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null) : this( gl, + device, dats, bindless, ImmediateGpuResourceRetirementQueue.Instance, @@ -101,6 +121,7 @@ public sealed unsafe class TextureCache internal TextureCache( GL gl, + IGpuDevice device, IDatReaderWriter dats, Wb.BindlessSupport? bindless, IGpuResourceRetirementQueue retirementQueue, @@ -109,6 +130,7 @@ public sealed unsafe class TextureCache { budgets ??= ResidencyBudgetOptions.Default; _gl = gl; + _device = device ?? throw new ArgumentNullException(nameof(device)); _dats = dats; _bindless = bindless; ArgumentException.ThrowIfNullOrWhiteSpace(diagnosticsDirectory); @@ -231,11 +253,10 @@ public sealed unsafe class TextureCache /// public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false) { - if (_handlesByRenderSurfaceId.TryGetValue(renderSurfaceId, out var existing) - && _rsSizeById.TryGetValue(renderSurfaceId, out var sz)) + if (_renderSurfaceGpuTextures.TryGetValue(renderSurfaceId, out GpuUiTextureEntry existing)) { - width = sz.w; height = sz.h; - return existing; + width = existing.Width; height = existing.Height; + return existing.GlName; } DecodedTexture decoded; @@ -256,11 +277,62 @@ public sealed unsafe class TextureCache decoded = DecodedTexture.Magenta; } - uint h = UploadRgba8(decoded, nearest); - _handlesByRenderSurfaceId[renderSurfaceId] = h; - _rsSizeById[renderSurfaceId] = (decoded.Width, decoded.Height); + GpuUiTextureEntry entry = UploadUiTexture(decoded, nearest, $"ui-rendersurface-0x{renderSurfaceId:X8}"); + _renderSurfaceGpuTextures[renderSurfaceId] = entry; width = decoded.Width; height = decoded.Height; - return h; + return entry.GlName; + } + + /// + /// Campaign V slice V4a: creates an for one + /// decoded UI sprite/atlas and registers it into the device's global + /// texture table. Every UI-path texture uses REPEAT addressing (existing + /// behaviour — panel fills and tiled chrome sample UVs greater than 1) and + /// a single mip level (UI sprites never mip). The classic texture-unit + /// binding path uses for these + /// (see that class's remarks) samples the texture object directly rather + /// than through a bound sampler object, so filtering must live on the + /// texture itself — GlGpuTexture's constructor always sets Linear, + /// which is wrong for -requested (pixel-exact) + /// sprites, so it is overridden here exactly as the old raw-GL path did. + /// + private GpuUiTextureEntry UploadUiTexture(DecodedTexture decoded, bool nearest, string debugName) + { + IGpuTexture texture = _device.CreateTexture(new GpuTextureDescription( + debugName, + GpuTextureKind.Texture2D, + GpuTextureFormat.Rgba8Unorm, + Width: decoded.Width, + Height: decoded.Height, + LayerCount: 1, + MipLevelCount: 1)); + try + { + texture.Upload(0, 0, decoded.Rgba8); + uint glName = ((GlGpuTexture)texture).GlName; + TrackUploadedTexture(glName, decoded.Width, decoded.Height); + + // MUST happen BEFORE RegisterTexture below: ARB_bindless_texture + // forbids glTexParameter on a texture once its bindless handle has + // been made resident (GL_INVALID_OPERATION). See BitmapFont's + // constructor for the same fix and full explanation. + if (nearest) + { + _gl.BindTexture(TextureTarget.Texture2D, glName); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Nearest); + _gl.BindTexture(TextureTarget.Texture2D, 0); + } + + IGpuSampler sampler = _device.CreateSampler(GpuSamplerDescription.WorldRepeat); + GpuTextureSlot slot = _device.RegisterTexture(texture, sampler); + return new GpuUiTextureEntry(texture, slot, glName, decoded.Width, decoded.Height); + } + catch + { + texture.Dispose(); + throw; + } } /// @@ -815,14 +887,15 @@ public sealed unsafe class TextureCache /// Uploads a raw RGBA8 byte array as a Texture2D. Used by /// to upload CPU-composited icon layers. - /// The returned handle is tracked in and deleted by + /// The returned handle is tracked in and deleted by /// . Callers must NOT also store the handle in any of the /// keyed caches — that would cause a double-delete on Dispose. public uint UploadRgba8(byte[] rgba, int width, int height, bool nearest = false) { - uint h = UploadRgba8(new DecodedTexture(rgba, width, height), nearest); - _adhocHandles.Add(h); - return h; + GpuUiTextureEntry entry = UploadUiTexture( + new DecodedTexture(rgba, width, height), nearest, "ui-adhoc-rgba8"); + _adhocGpuTextures.Add(entry); + return entry.GlName; } private uint UploadRgba8(DecodedTexture decoded, bool nearest = false) @@ -932,7 +1005,18 @@ public sealed unsafe class TextureCache { _gl.DeleteTexture(name); Wb.GLHelpers.ThrowOnResourceError(_gl, $"deleting uploaded texture {name}"); + UntrackUploadedTexture(name); + } + /// + /// Memory-tracking bookkeeping only, without a raw GL delete — used for + /// the Campaign V slice V4a UI-path entries, + /// whose GL name is released by through + /// the device's own retirement queue rather than by + /// . + /// + private void UntrackUploadedTexture(uint name) + { if (_uploadMetadata.Remove(name, out var metadata)) { long bytes = checked((long)metadata.Width * metadata.Height * 4L); @@ -962,16 +1046,26 @@ public sealed unsafe class TextureCache _magentaHandle = 0; } - // RenderSurface (UI sprite) handles — pre-existing gap: this dict was populated - // by GetOrUploadRenderSurface but was not swept here before this fix. - foreach (var h in _handlesByRenderSurfaceId.Values) - DeleteUploadedTexture(h); - _handlesByRenderSurfaceId.Clear(); + // RenderSurface (UI sprite) textures — Campaign V slice V4a: each + // entry's IGpuTexture.Dispose() releases the underlying GL name + // through the device's own retirement queue, so only the memory- + // tracking bookkeeping and the registered slot need releasing here. + foreach (GpuUiTextureEntry entry in _renderSurfaceGpuTextures.Values) + { + entry.Texture.Dispose(); + _device.ReleaseTextureSlot(entry.Slot); + UntrackUploadedTexture(entry.GlName); + } + _renderSurfaceGpuTextures.Clear(); - // Ad-hoc handles from the public UploadRgba8(byte[],int,int,bool) wrapper + // Ad-hoc textures from the public UploadRgba8(byte[],int,int,bool) wrapper // (IconComposer composited icons). Not stored in any keyed cache. - foreach (var h in _adhocHandles) - DeleteUploadedTexture(h); - _adhocHandles.Clear(); + foreach (GpuUiTextureEntry entry in _adhocGpuTextures) + { + entry.Texture.Dispose(); + _device.ReleaseTextureSlot(entry.Slot); + UntrackUploadedTexture(entry.GlName); + } + _adhocGpuTextures.Clear(); } } diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs index 6fb21eb6..d2b0043b 100644 --- a/src/AcDream.App/UI/UiHost.cs +++ b/src/AcDream.App/UI/UiHost.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; using Silk.NET.Input; using Silk.NET.OpenGL; @@ -58,19 +59,27 @@ public sealed class UiHost : System.IDisposable internal bool IsDisposalComplete => _disposed; - public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null) - : this(gl, shaderDir, defaultFont, new HostQuiescenceGate()) + // internal, not public: IGpuDevice/ICurrentGpuFrameSource are internal + // types (the pinned RHI contract). UiHost stays public — only + // construction is restricted. + internal UiHost( + IGpuDevice device, + ICurrentGpuFrameSource frameSource, + string shaderDir, + BitmapFont? defaultFont = null) + : this(device, frameSource, shaderDir, defaultFont, new HostQuiescenceGate()) { } internal UiHost( - GL gl, + IGpuDevice device, + ICurrentGpuFrameSource frameSource, string shaderDir, BitmapFont? defaultFont, HostQuiescenceGate quiescence) { _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); - TextRenderer = new TextRenderer(gl, shaderDir); + TextRenderer = new TextRenderer(device, frameSource, shaderDir); DefaultFont = defaultFont; } diff --git a/src/AcDream.App/UI/UiViewport.cs b/src/AcDream.App/UI/UiViewport.cs index f5a50323..3f479906 100644 --- a/src/AcDream.App/UI/UiViewport.cs +++ b/src/AcDream.App/UI/UiViewport.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.App.Rendering.Gpu; namespace AcDream.App.UI; @@ -34,16 +35,27 @@ public sealed class UiViewport : UiElement /// Renderer that produces the off-screen texture. Set by GameWindow wiring (later task). public IUiViewportRenderer? Renderer { get; set; } - /// Last GL color-texture handle produced by the pre-UI hook. 0 = nothing to blit. - public uint TextureHandle { get; set; } + /// + /// Campaign V slice V4a: the off-screen FBO colour texture produced by the + /// pre-UI hook, registered into the device's texture table by the + /// pre-approved paperdoll/appraisal transitional seam + /// (GlGpuDevice.RegisterExternalColorTexture, campaign doc §7.1) — + /// its owning renderer (PrivateEntityViewportRenderer) stays raw GL + /// until V4g. = nothing to blit. + /// internal, not public: is an internal type + /// (the pinned RHI contract); UiViewport stays public. + /// + internal GpuTextureSlot TextureSlot { get; set; } = GpuTextureSlot.Unassigned; protected override void OnDraw(UiRenderContext ctx) { - if (!Visible || TextureHandle == 0) return; + if (!Visible || !TextureSlot.IsAssigned) return; + uint textureHandle = ctx.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): TextureHandle is an off-screen FBO color texture, whose origin is + // 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); + ctx.DrawSprite(textureHandle, 0f, 0f, Width, Height, 0f, 1f, 1f, 0f, Vector4.One); } } diff --git a/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs b/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs index 7b081a55..63727c77 100644 --- a/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs @@ -159,6 +159,7 @@ public sealed class HostInputCameraCompositionTests { public GpuFrameFlightController? GpuFrames { get; private set; } public IGpuDevice? GpuDevice { get; private set; } + public GpuDeviceFrameLifetime? GpuFrameLifetime { get; private set; } public SilkKeyboardSource? Keyboard { get; private set; } public SilkMouseSource? Mouse { get; private set; } public IMouseLookCursor? Cursor { get; private set; } @@ -172,6 +173,9 @@ public sealed class HostInputCameraCompositionTests public void PublishGpuDevice(IGpuDevice value) => GpuDevice = PublishOnce(GpuDevice, value); + public void PublishGpuFrameLifetime(GpuDeviceFrameLifetime value) => + GpuFrameLifetime = PublishOnce(GpuFrameLifetime, value); + public void PublishKeyboardSource(SilkKeyboardSource value) => Keyboard = PublishOnce(Keyboard, value); diff --git a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs index 9ea7893e..f6855583 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs @@ -215,7 +215,9 @@ public sealed class InteractionRetainedUiCompositionTests ExistingVitals: null, Toast: null, ClientTime: static () => 0d, - Log: static _ => { }); + Log: static _ => { }, + GpuDevice: null!, + GpuFrameSource: null!); } public InteractionRetainedUiDependencies Dependencies { get; } diff --git a/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs b/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs index d9d3db6c..a4bfedc1 100644 --- a/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs @@ -218,6 +218,7 @@ public sealed class SettingsDevToolsCompositionTests null!, null!, null!, + null!, null, null, null, diff --git a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs index 4a1aab29..75aa08bc 100644 --- a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs @@ -2,9 +2,11 @@ using System.Collections.Concurrent; using System.Runtime.CompilerServices; using AcDream.App.Composition; using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Residency; using AcDream.App.World; +using AcDream.App.Tests.Rendering.Gpu; using AcDream.Content; using AcDream.Core.Physics; using AcDream.Core.Terrain; @@ -147,6 +149,7 @@ public sealed class WorldRenderCompositionTests { private readonly WorldRenderCompositionPoint? _failurePoint; private readonly ResidencyBudgetOptions _budgets; + private readonly IGpuDevice _gpuDevice = new RecordingGpuDevice(); public Fixture( bool hasFont = true, @@ -179,7 +182,9 @@ public sealed class WorldRenderCompositionTests _budgets, 0xA9B4FFFFu, Path.Combine(Path.GetTempPath(), "acdream-tests"), - _ => { }), + _ => { }, + _gpuDevice, + new GpuDeviceFrameLifetime(_gpuDevice)), Publication, Factory, point => @@ -248,15 +253,17 @@ public sealed class WorldRenderCompositionTests public SceneLightingUboBinding CreateSceneLighting(GL gl) => Resource("scene lighting"); - public DebugLineRenderer CreateDebugLines(GL gl, string shadersDirectory) => + public DebugLineRenderer CreateDebugLines( + IGpuDevice device, ICurrentGpuFrameSource frameSource, string shadersDirectory) => Resource("debug lines"); public byte[]? TryLoadDebugFont() => hasFont ? [1] : null; - public BitmapFont CreateDebugFont(GL gl, byte[] bytes) => + public BitmapFont CreateDebugFont(IGpuDevice device, byte[] bytes) => Resource("debug font"); - public TextRenderer CreateTextRenderer(GL gl, string shadersDirectory) => + public TextRenderer CreateTextRenderer( + IGpuDevice device, ICurrentGpuFrameSource frameSource, string shadersDirectory) => Resource("text renderer"); public TerrainModernRenderer CreateTerrain( @@ -295,6 +302,7 @@ public sealed class WorldRenderCompositionTests public TextureCache CreateTextureCache( GL gl, + IGpuDevice device, IDatReaderWriter dats, BindlessSupport bindless, IGpuResourceRetirementQueue retirement, diff --git a/tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs b/tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs index 2edc666f..4a7f532b 100644 --- a/tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs @@ -104,6 +104,14 @@ public sealed class GlTextureOwnershipTests root, "src", "AcDream.App", "Rendering", "ShaderProgramConstruction.cs")); string terrain = File.ReadAllText(Path.Combine( root, "src", "AcDream.App", "Rendering", "TerrainAtlas.cs")); + // Campaign V slice V4a: TextRenderer's shader/texture creation moved + // from raw GlResourceCommand calls to IGpuDevice.CreatePipeline/ + // CreateTexture, whose own checked-commit construction + // (GlResourceCommand.CreateName / ShaderProgramConstruction.Build, + // predating this slice) is what those two now delegate to. What + // TextRenderer's OWN constructor still owns is the ordered + // pipeline-then-texture sequence and disposing whichever of the two + // it already created if the other throws. string text = File.ReadAllText(Path.Combine( root, "src", "AcDream.App", "Rendering", "TextRenderer.cs")); string bindless = File.ReadAllText(Path.Combine( @@ -116,12 +124,30 @@ public sealed class GlTextureOwnershipTests Assert.Contains("GlResourceCommand.DeleteProgram", shaderPrograms, StringComparison.Ordinal); Assert.Contains("_anisotropyBindingMutation.Execute", terrain, StringComparison.Ordinal); Assert.Contains("GlResourceCommand.DeleteTexture", terrain, StringComparison.Ordinal); - Assert.Contains("GlResourceCommand.CreateTexture", text, StringComparison.Ordinal); - Assert.Contains("GlResourceCommand.Execute", text, StringComparison.Ordinal); + AssertAppearsInOrder( + text, + "pipeline = device.CreatePipeline(", + "whiteTexture = device.CreateTexture(", + "catch", + "whiteTexture?.Dispose();", + "pipeline?.Dispose();", + "throw;"); Assert.Contains("make bindless handle", bindless, StringComparison.Ordinal); Assert.Contains("GlResourceCommand.Execute", bindless, StringComparison.Ordinal); } + private static void AssertAppearsInOrder(string source, params string[] needles) + { + int cursor = -1; + foreach (string needle in needles) + { + int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal); + Assert.True(next >= 0, $"Missing expected source fragment: {needle}"); + Assert.True(next > cursor, $"Out-of-order source fragment: {needle}"); + cursor = next; + } + } + [Fact] public void SecondBindlessAcquireFailureRollsBackFirstHandle() { diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs index 4ac71907..34c60eb3 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs @@ -49,6 +49,11 @@ public sealed class RenderFrameResourceControllerTests { string source = ResourceSource(); + // Campaign V slice V4a: the world-HUD and retained-UI TextRenderers no + // longer take a per-slot begin call — they now read the shared + // IGpuFrame ring reset once per frame by IGpuDevice.BeginFrame() (see + // GpuDeviceFrameLifetime), so _worldText/_uiText are gone from this + // sequence. AssertAppearsInOrder( source, "_textures?.BeginCompositeTextureFrame();", @@ -56,8 +61,6 @@ public sealed class RenderFrameResourceControllerTests "_dispatcher?.BeginFrame(gpuSlot);", "_environmentCells?.BeginFrame(gpuSlot);", "_portalDepth?.BeginFrame(gpuSlot);", - "_worldText?.BeginFrame(gpuSlot);", - "_uiText?.BeginFrame(gpuSlot);", "_clip?.BeginFrame(gpuSlot);", "_terrain?.BeginFrame(gpuSlot);", "_lighting?.BeginFrame(gpuSlot);"); diff --git a/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs b/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs index 0efce3e4..e5fbdd2c 100644 --- a/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs @@ -130,8 +130,19 @@ public sealed class ResourceCleanupGroupTests StringComparison.Ordinal); } + /// + /// Campaign V slice V4a: TextRenderer's constructor no longer owns raw + /// VAO/VBO/texture GL names through a ResourceCleanupGroup ledger — it + /// creates exactly two device-owned resources (a pipeline, then the white + /// fill texture) and, since a later failure could otherwise orphan an + /// already-created pipeline, disposes whichever of the two it already + /// created if the other throws. This replaces the pre-V4a assertion of + /// the same name, which pinned the old multi-resource raw-GL shape + /// (Shader + three flight-indexed VAO/VBO pairs + a hand-rolled white + /// texture) that no longer exists. + /// [Fact] - public void TextRendererPublishesEveryConstructorResourceBeforeLaterGlWork() + public void TextRendererDisposesWhicheverConstructorResourceAlreadyExistsOnFailure() { string source = File.ReadAllText(Path.Combine( FindRepoRoot(), @@ -142,27 +153,18 @@ public sealed class ResourceCleanupGroupTests AssertAppearsInOrder( source, - "shader = new Shader(gl,", - "resources.Add(\"text shader\", shader.Dispose);", - "frameBuffers[i] = CreateFrameBufferSet(resources);", - "whiteTexture = GlResourceCommand.CreateTexture(", - "resources.Add(", - "\"white texture\"", - "GlResourceCommand.Execute(", - "initialize TextRenderer white texture", - "resources.RetryCleanup();", - "_resources = resources;"); - AssertAppearsInOrder( - source, - "private FrameBufferSet CreateFrameBufferSet(ResourceCleanupGroup resources)", - "TrackedGlResource.CreateVertexArray(", - "resources.Add(\"frame VAO\", vaoRelease.Run);", - "TrackedGlResource.CreateBuffer(", - "resources.Add(", - "\"frame VBO\""); - Assert.Contains("_resources.RetryCleanup();", source, StringComparison.Ordinal); - Assert.DoesNotContain("private FrameBufferSet CreateFrameBufferSet()", source, - StringComparison.Ordinal); + "IGpuPipeline? pipeline = null;", + "IGpuTexture? whiteTexture = null;", + "try", + "pipeline = device.CreatePipeline(", + "whiteTexture = device.CreateTexture(", + "device.RegisterTexture(whiteTexture, whiteSampler);", + "catch", + "whiteTexture?.Dispose();", + "pipeline?.Dispose();", + "throw;", + "_pipeline = pipeline;", + "_whiteTexture = whiteTexture;"); } [Fact]