diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index de7332ef..06b361cb 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -721,10 +721,17 @@ atomic update/render frame-root slot, and a prepare-aware portal fallback and transfer slot. GL construction and state mutations now use checked commit boundaries with exact retry ownership for failed names, bindless residency, and texture-binding restoration. `GameWindow` is 3,689 raw lines / 162 fields / -37 methods. Checkpoints I–L remain active, with ordered production composition -next. The Checkpoint H production App build is warning-free, its 61 focused -ownership tests pass, the complete Release suite passes 7,606 tests / 5 skips, -and all three final corrected-diff reviews are clean. +37 methods at H. Checkpoint I.1–I.5 now provide the executable nine-phase +oracle, platform/host/content/settings phases, and the production world/render +phase. `WorldRenderCompositionPhase` owns Region/environment, the mandatory +modern-renderer foundation, immutable terrain-worker inputs, and WB/texture/ +sampler construction; every Phase-4 GL constructor has retryable prefix +ownership. `GameWindow` is 3,522 raw lines after I.5. Checkpoints I.6–L remain +active, with retained UI and live presentation composition next. The current +production App build is warning-free; the App gate passes 3,373 tests / 3 +intentional skips and the complete Release suite passes 7,745 tests / 5 +intentional skips. I.5's corrected-diff behavior, architecture, and +adversarial reviews are clean. ### 4.4 Exit criteria diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md index e117fb8b..01f67226 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md @@ -253,6 +253,27 @@ Small implementation commits may be used while the checkpoint is active, but each must build and preserve production startup. The final I commit closes the ledger only after the complete pipeline is live. +### Current implementation progress — 2026-07-22 + +- I.1–I.4 are committed: the executable pipeline/rollback oracle, platform + acquisition, host input/camera, content/effects/audio, and settings/devtools + phases are the production startup path. +- I.5 is complete. `WorldRenderCompositionPhase` now owns the ordered Region, + environment, mandatory bindless, terrain-atlas, shader, lighting, debug/HUD, + terrain, WB, texture, and sampler foundation. `GameWindow` is a write-only + publisher for this phase and no longer contains the Phase-4 construction + body. +- Every fallible GL constructor in the Phase-4 prefix now publishes each name + into retryable construction ownership before later GL work. Failure testing + covers every composition boundary, every disposable publication, partial HUD + construction, reverse cleanup, retry, and no replay. +- The I.5 tree is 3,522 raw `GameWindow.cs` lines. The App gate passes 3,373 + tests / 3 intentional skips; the complete Release suite passes 7,745 tests / + 5 intentional skips. Production App builds with zero warnings/errors; the + solution retains only the 17 warnings tracked by #228. +- I.6 (retained UI plus live-presentation composition) is next. Checkpoint I + remains active until I.9 closes the complete nine-phase pipeline. + ## 6. Automated acceptance ### Platform and pipeline diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs new file mode 100644 index 00000000..346360a5 --- /dev/null +++ b/src/AcDream.App/Composition/WorldRenderComposition.cs @@ -0,0 +1,534 @@ +using System.Collections.Concurrent; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using AcDream.App.World; +using AcDream.Content; +using AcDream.Core.Terrain; +using AcDream.UI.Abstractions.Settings; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using Microsoft.Extensions.Logging.Abstractions; +using Silk.NET.Input; +using Silk.NET.OpenGL; +using Shader = AcDream.App.Rendering.Shader; + +namespace AcDream.App.Composition; + +internal sealed record WorldRegionData(Region Region, float[] HeightTable); + +internal sealed record WorldTerrainBuildContext( + uint InitialCenterLandblockId, + int InitialCenterX, + int InitialCenterY, + float[] HeightTable, + TerrainBlendingContext Blending, + ConcurrentDictionary SurfaceCache); + +internal sealed record WorldRenderFoundation( + string ShadersDirectory, + BindlessSupport Bindless, + TerrainAtlas TerrainAtlas, + Shader TerrainShader, + SceneLightingUboBinding SceneLighting, + DebugLineRenderer DebugLines, + BitmapFont? DebugFont, + TextRenderer? TextRenderer, + TerrainModernRenderer Terrain, + Shader MeshShader, + WbMeshAdapter MeshAdapter, + TextureCache TextureCache, + SamplerCache Samplers); + +internal sealed record WorldRenderResult( + WorldTerrainBuildContext TerrainBuild, + WorldRenderFoundation Foundation); + +internal sealed record WorldRenderDependencies( + WorldEnvironmentController Environment, + IGameRenderResourceLifetime RenderResources, + IGpuResourceRetirementQueue ResourceRetirement, + uint InitialCenterLandblockId, + Action Log); + +internal interface IGameWindowWorldRenderPublication +{ + void PublishBindlessSupport(BindlessSupport value); + void PublishTerrainShader(Shader value); + void PublishSceneLighting(SceneLightingUboBinding value); + void PublishDebugLines(DebugLineRenderer value); + void PublishHudResources(BitmapFont font, TextRenderer text); + void PublishTerrain(TerrainModernRenderer value); + void PublishTerrainBuildState( + float[] heightTable, + TerrainBlendingContext blending, + ConcurrentDictionary surfaceCache); + void PublishMeshShader(Shader value); + void PublishWbMeshAdapter(WbMeshAdapter value); + void PublishTextureCache(TextureCache value); + void PublishSamplerCache(SamplerCache value); +} + +internal interface IWorldRenderCompositionFactory +{ + void InitializeGlState(GL gl); + WorldRegionData LoadRegion(IDatReaderWriter dats); + void InitializeEnvironment(WorldEnvironmentController environment, Region region); + BindlessSupport RequireBindless(GL gl, Action log); + TerrainAtlas AcquireTerrainAtlas( + IGameRenderResourceLifetime lifetime, + GL gl, + IDatReaderWriter dats, + BindlessSupport bindless); + void SetTerrainAnisotropic(TerrainAtlas atlas, int level); + Shader CreateTerrainShader(GL gl, string shadersDirectory); + SceneLightingUboBinding CreateSceneLighting(GL gl); + DebugLineRenderer CreateDebugLines(GL gl, string shadersDirectory); + byte[]? TryLoadDebugFont(); + BitmapFont CreateDebugFont(GL gl, byte[] bytes); + TextRenderer CreateTextRenderer(GL gl, string shadersDirectory); + TerrainModernRenderer CreateTerrain( + GL gl, + BindlessSupport bindless, + Shader shader, + TerrainAtlas atlas, + IGpuResourceRetirementQueue retirement); + WorldTerrainBuildContext CreateTerrainBuildContext( + uint initialCenterLandblockId, + float[] heightTable, + TerrainAtlas atlas); + Shader CreateMeshShader(GL gl, string shadersDirectory); + WbMeshAdapter CreateMeshAdapter( + GL gl, + IDatReaderWriter dats, + IGpuResourceRetirementQueue retirement); + TextureCache CreateTextureCache( + GL gl, + IDatReaderWriter dats, + BindlessSupport bindless, + IGpuResourceRetirementQueue retirement); + SamplerCache CreateSamplerCache(GL gl); + void Release(IDisposable resource); +} + +internal sealed class RetailWorldRenderCompositionFactory + : IWorldRenderCompositionFactory +{ + public void InitializeGlState(GL gl) + { + ArgumentNullException.ThrowIfNull(gl); + gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); + gl.Enable(EnableCap.DepthTest); + } + + public WorldRegionData LoadRegion(IDatReaderWriter dats) + { + ArgumentNullException.ThrowIfNull(dats); + Region region = dats.Get(0x13000000u) + ?? throw new InvalidOperationException( + "Region dat id 0x13000000 missing"); + float[]? heightTable = region.LandDefs.LandHeightTable; + if (heightTable is null || heightTable.Length < 256) + { + throw new InvalidOperationException( + "Region.LandDefs.LandHeightTable missing or truncated"); + } + return new WorldRegionData(region, heightTable); + } + + public void InitializeEnvironment( + WorldEnvironmentController environment, + Region region) + { + ArgumentNullException.ThrowIfNull(environment); + ArgumentNullException.ThrowIfNull(region); + environment.Initialize(region); + } + + public BindlessSupport RequireBindless(GL gl, Action log) + { + ArgumentNullException.ThrowIfNull(gl); + ArgumentNullException.ThrowIfNull(log); + if (BindlessSupport.TryCreate(gl, out BindlessSupport? bindless)) + { + if (bindless!.HasShaderDrawParameters(gl)) + { + log("[N.5] modern path capabilities present " + + "(bindless + ARB_shader_draw_parameters)"); + return bindless; + } + log("[N.5] GL_ARB_shader_draw_parameters not present — " + + "modern path not available"); + } + else + { + log("[N.5] GL_ARB_bindless_texture not present — " + + "modern path not available"); + } + + throw new NotSupportedException( + "acdream requires GL_ARB_bindless_texture + " + + "GL_ARB_shader_draw_parameters (GL 4.3+ with bindless support). " + + "Your GPU/driver does not expose these extensions. If this is " + + "unexpected, please file a bug report with your GPU vendor + " + + "driver version."); + } + + public TerrainAtlas AcquireTerrainAtlas( + IGameRenderResourceLifetime lifetime, + GL gl, + IDatReaderWriter dats, + BindlessSupport bindless) + { + ArgumentNullException.ThrowIfNull(lifetime); + return lifetime.AcquireTerrainAtlas( + () => TerrainAtlas.Build(gl, dats, bindless)); + } + + public void SetTerrainAnisotropic(TerrainAtlas atlas, int level) => + atlas.SetAnisotropic(level); + + public Shader CreateTerrainShader(GL gl, string shadersDirectory) => + new( + gl, + Path.Combine(shadersDirectory, "terrain_modern.vert"), + Path.Combine(shadersDirectory, "terrain_modern.frag")); + + public SceneLightingUboBinding CreateSceneLighting(GL gl) => new(gl); + + public DebugLineRenderer CreateDebugLines( + GL gl, + string shadersDirectory) => + new(gl, shadersDirectory); + + public byte[]? TryLoadDebugFont() => + BitmapFont.TryLoadSystemMonospaceFont(); + + public BitmapFont CreateDebugFont(GL gl, byte[] bytes) => + new(gl, bytes, pixelHeight: 15f, atlasSize: 512); + + public TextRenderer CreateTextRenderer( + GL gl, + string shadersDirectory) => + new(gl, shadersDirectory); + + public TerrainModernRenderer CreateTerrain( + GL gl, + BindlessSupport bindless, + Shader shader, + TerrainAtlas atlas, + IGpuResourceRetirementQueue retirement) => + new(gl, bindless, shader, atlas, retirement); + + public WorldTerrainBuildContext CreateTerrainBuildContext( + uint initialCenterLandblockId, + float[] heightTable, + TerrainAtlas atlas) + { + int centerX = (int)((initialCenterLandblockId >> 24) & 0xFFu); + int centerY = (int)((initialCenterLandblockId >> 16) & 0xFFu); + var layers = new Dictionary(atlas.TerrainTypeToLayer.Count); + foreach ((uint terrainType, uint layer) in atlas.TerrainTypeToLayer) + layers[terrainType] = (byte)layer; + + const uint RoadTypeEnumValue = 0x20; + byte roadLayer = layers.TryGetValue(RoadTypeEnumValue, out byte road) + ? road + : SurfaceInfo.None; + var blending = new TerrainBlendingContext( + TerrainTypeToLayer: layers, + RoadLayer: roadLayer, + CornerAlphaLayers: atlas.CornerAlphaLayers, + SideAlphaLayers: atlas.SideAlphaLayers, + RoadAlphaLayers: atlas.RoadAlphaLayers, + CornerAlphaTCodes: atlas.CornerAlphaTCodes, + SideAlphaTCodes: atlas.SideAlphaTCodes, + RoadAlphaRCodes: atlas.RoadAlphaRCodes); + return new WorldTerrainBuildContext( + initialCenterLandblockId, + centerX, + centerY, + heightTable, + blending, + new ConcurrentDictionary()); + } + + public Shader CreateMeshShader(GL gl, string shadersDirectory) => + new( + gl, + Path.Combine(shadersDirectory, "mesh_modern.vert"), + Path.Combine(shadersDirectory, "mesh_modern.frag")); + + public WbMeshAdapter CreateMeshAdapter( + GL gl, + IDatReaderWriter dats, + IGpuResourceRetirementQueue retirement) => + new( + gl, + dats, + NullLogger.Instance, + retirement); + + public TextureCache CreateTextureCache( + GL gl, + IDatReaderWriter dats, + BindlessSupport bindless, + IGpuResourceRetirementQueue retirement) => + new(gl, dats, bindless, retirement); + + public SamplerCache CreateSamplerCache(GL gl) => new(gl); + + public void Release(IDisposable resource) => resource.Dispose(); +} + +internal enum WorldRenderCompositionPoint +{ + GlStateInitialized, + RegionLoaded, + EnvironmentInitialized, + BindlessPublished, + TerrainAtlasAcquired, + TerrainShaderPublished, + SceneLightingPublished, + DebugLinesPublished, + DebugFontCreated, + TextRendererCreated, + HudResourcesPublished, + HudResourcesCompleted, + TerrainPublished, + TerrainBuildStatePublished, + MeshShaderPublished, + MeshAdapterPublished, + TextureCachePublished, + SamplerCachePublished, +} + +/// +/// Production Phase 4. It preserves the mandatory modern renderer and sole +/// DAT path while moving construction into an ordered, failure-injectable +/// ownership boundary. +/// +internal sealed class WorldRenderCompositionPhase + : IWorldRenderCompositionPhase< + GameWindowPlatformResult, + ContentEffectsAudioResult, + SettingsDevToolsResult, + WorldRenderResult> +{ + private readonly WorldRenderDependencies _dependencies; + private readonly IGameWindowWorldRenderPublication _publication; + private readonly IWorldRenderCompositionFactory _factory; + private readonly Action? _faultInjection; + + public WorldRenderCompositionPhase( + WorldRenderDependencies dependencies, + IGameWindowWorldRenderPublication publication, + IWorldRenderCompositionFactory? factory = null, + Action? faultInjection = null) + { + _dependencies = dependencies + ?? throw new ArgumentNullException(nameof(dependencies)); + _publication = publication + ?? throw new ArgumentNullException(nameof(publication)); + _factory = factory ?? new RetailWorldRenderCompositionFactory(); + _faultInjection = faultInjection; + } + + public WorldRenderResult Compose( + GameWindowPlatformResult platform, + ContentEffectsAudioResult content, + SettingsDevToolsResult settings) + { + ArgumentNullException.ThrowIfNull(platform); + ArgumentNullException.ThrowIfNull(content); + ArgumentNullException.ThrowIfNull(settings); + + var scope = new CompositionAcquisitionScope(); + try + { + GL gl = platform.Graphics; + _factory.InitializeGlState(gl); + Fault(WorldRenderCompositionPoint.GlStateInitialized); + + WorldRegionData region = _factory.LoadRegion(content.Dats); + Fault(WorldRenderCompositionPoint.RegionLoaded); + _factory.InitializeEnvironment(_dependencies.Environment, region.Region); + Fault(WorldRenderCompositionPoint.EnvironmentInitialized); + + BindlessSupport bindless = _factory.RequireBindless(gl, _dependencies.Log); + _publication.PublishBindlessSupport(bindless); + Fault(WorldRenderCompositionPoint.BindlessPublished); + + TerrainAtlas terrainAtlas = _factory.AcquireTerrainAtlas( + _dependencies.RenderResources, + gl, + content.Dats, + bindless); + _factory.SetTerrainAnisotropic( + terrainAtlas, + settings.ResolvedQuality.AnisotropicLevel); + Fault(WorldRenderCompositionPoint.TerrainAtlasAcquired); + + string shadersDirectory = Path.Combine( + AppContext.BaseDirectory, + "Rendering", + "Shaders"); + Shader terrainShader = AcquireAndPublish( + scope, + "terrain shader", + () => _factory.CreateTerrainShader(gl, shadersDirectory), + _publication.PublishTerrainShader, + WorldRenderCompositionPoint.TerrainShaderPublished); + SceneLightingUboBinding sceneLighting = AcquireAndPublish( + scope, + "scene lighting", + () => _factory.CreateSceneLighting(gl), + _publication.PublishSceneLighting, + WorldRenderCompositionPoint.SceneLightingPublished); + DebugLineRenderer debugLines = AcquireAndPublish( + scope, + "debug lines", + () => _factory.CreateDebugLines(gl, shadersDirectory), + _publication.PublishDebugLines, + WorldRenderCompositionPoint.DebugLinesPublished); + + (BitmapFont? debugFont, TextRenderer? textRenderer) = + ComposeOptionalHudResources(scope, gl, shadersDirectory); + + TerrainModernRenderer terrain = AcquireAndPublish( + scope, + "terrain renderer", + () => _factory.CreateTerrain( + gl, + bindless, + terrainShader, + terrainAtlas, + _dependencies.ResourceRetirement), + _publication.PublishTerrain, + WorldRenderCompositionPoint.TerrainPublished); + + WorldTerrainBuildContext terrainBuild = + _factory.CreateTerrainBuildContext( + _dependencies.InitialCenterLandblockId, + region.HeightTable, + terrainAtlas); + _publication.PublishTerrainBuildState( + terrainBuild.HeightTable, + terrainBuild.Blending, + terrainBuild.SurfaceCache); + Fault(WorldRenderCompositionPoint.TerrainBuildStatePublished); + + Shader meshShader = AcquireAndPublish( + scope, + "mesh shader", + () => _factory.CreateMeshShader(gl, shadersDirectory), + _publication.PublishMeshShader, + WorldRenderCompositionPoint.MeshShaderPublished); + _dependencies.Log("[N.5] mesh_modern shader loaded"); + WbMeshAdapter meshAdapter = AcquireAndPublish( + scope, + "WB mesh adapter", + () => _factory.CreateMeshAdapter( + gl, + content.Dats, + _dependencies.ResourceRetirement), + _publication.PublishWbMeshAdapter, + WorldRenderCompositionPoint.MeshAdapterPublished); + TextureCache textureCache = AcquireAndPublish( + scope, + "texture cache", + () => _factory.CreateTextureCache( + gl, + content.Dats, + bindless, + _dependencies.ResourceRetirement), + _publication.PublishTextureCache, + WorldRenderCompositionPoint.TextureCachePublished); + SamplerCache samplers = AcquireAndPublish( + scope, + "sampler cache", + () => _factory.CreateSamplerCache(gl), + _publication.PublishSamplerCache, + WorldRenderCompositionPoint.SamplerCachePublished); + + scope.Complete(); + _dependencies.Log( + "[N.4+N.5] WB foundation + modern path active — " + + "routing all content through ObjectMeshManager."); + return new WorldRenderResult( + terrainBuild, + new WorldRenderFoundation( + shadersDirectory, + bindless, + terrainAtlas, + terrainShader, + sceneLighting, + debugLines, + debugFont, + textRenderer, + terrain, + meshShader, + meshAdapter, + textureCache, + samplers)); + } + catch (Exception failure) + { + scope.RollbackAndThrow(failure); + throw new System.Diagnostics.UnreachableException(); + } + } + + private (BitmapFont? Font, TextRenderer? Text) ComposeOptionalHudResources( + CompositionAcquisitionScope scope, + GL gl, + string shadersDirectory) + { + byte[]? fontBytes = _factory.TryLoadDebugFont(); + if (fontBytes is null) + { + _dependencies.Log("world-hud font: no system monospace font found"); + Fault(WorldRenderCompositionPoint.HudResourcesCompleted); + return (null, null); + } + + var fontLease = scope.Acquire( + "world HUD font", + () => _factory.CreateDebugFont(gl, fontBytes), + _factory.Release); + BitmapFont font = fontLease.Resource; + Fault(WorldRenderCompositionPoint.DebugFontCreated); + var textLease = scope.Acquire( + "world HUD text renderer", + () => _factory.CreateTextRenderer(gl, shadersDirectory), + _factory.Release); + TextRenderer text = textLease.Resource; + Fault(WorldRenderCompositionPoint.TextRendererCreated); + + _publication.PublishHudResources(font, text); + fontLease.Transfer(); + textLease.Transfer(); + Fault(WorldRenderCompositionPoint.HudResourcesPublished); + _dependencies.Log( + $"world-hud font: loaded {fontBytes.Length / 1024}KB, " + + $"atlas {font.AtlasWidth}x{font.AtlasHeight}, " + + $"lineHeight={font.LineHeight:F1}px (reserved for D.6 HUD)"); + Fault(WorldRenderCompositionPoint.HudResourcesCompleted); + return (font, text); + } + + private T AcquireAndPublish( + CompositionAcquisitionScope scope, + string name, + Func factory, + Action publish, + WorldRenderCompositionPoint point) + where T : class, IDisposable + { + T value = scope.Acquire(name, factory, _factory.Release).Publish(publish); + Fault(point); + return value; + } + + private void Fault(WorldRenderCompositionPoint point) => + _faultInjection?.Invoke(point); +} diff --git a/src/AcDream.App/Rendering/BitmapFont.cs b/src/AcDream.App/Rendering/BitmapFont.cs index c3106bab..9306b4bd 100644 --- a/src/AcDream.App/Rendering/BitmapFont.cs +++ b/src/AcDream.App/Rendering/BitmapFont.cs @@ -38,6 +38,7 @@ public sealed unsafe class BitmapFont : IDisposable private readonly Glyph[] _glyphs; private readonly int _firstChar; private readonly int _numChars; + private readonly ResourceCleanupGroup _resources; public uint TextureId { get; } public float PixelHeight { get; } @@ -95,27 +96,65 @@ public sealed unsafe class BitmapFont : IDisposable adv: bc.xadvance); } - // Upload atlas as a single-channel GL texture (R8). - TextureId = _gl.GenTexture(); - _gl.BindTexture(TextureTarget.Texture2D, TextureId); - _gl.PixelStore(PixelStoreParameter.UnpackAlignment, 1); - fixed (byte* ptr = pixels) + // 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; + try { - _gl.TexImage2D(TextureTarget.Texture2D, 0, - (int)InternalFormat.R8, - (uint)AtlasWidth, (uint)AtlasHeight, 0, - PixelFormat.Red, PixelType.UnsignedByte, ptr); + 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", () => + { + 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.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); - _gl.PixelStore(PixelStoreParameter.UnpackAlignment, 4); // restore default - _gl.BindTexture(TextureTarget.Texture2D, 0); + catch (Exception constructionFailure) + { + resources.RollbackConstructionAndThrow( + "BitmapFont construction failed and its GL atlas did not cleanly roll back.", + constructionFailure); + } + + TextureId = texture; + _resources = resources; } public bool TryGetGlyph(char c, out Glyph g) @@ -144,7 +183,7 @@ public sealed unsafe class BitmapFont : IDisposable public void Dispose() { - _gl.DeleteTexture(TextureId); + _resources.RetryCleanup(); } /// diff --git a/src/AcDream.App/Rendering/DebugLineRenderer.cs b/src/AcDream.App/Rendering/DebugLineRenderer.cs index d86340e8..7f883a78 100644 --- a/src/AcDream.App/Rendering/DebugLineRenderer.cs +++ b/src/AcDream.App/Rendering/DebugLineRenderer.cs @@ -20,6 +20,7 @@ public sealed unsafe class DebugLineRenderer : IDisposable private readonly Shader _shader; private readonly uint _vao; private readonly uint _vbo; + private readonly ResourceCleanupGroup _resources; private readonly List _buffer = new(4096); private int _vertexCount; @@ -27,25 +28,67 @@ public sealed unsafe class DebugLineRenderer : IDisposable public DebugLineRenderer(GL gl, string shaderDir) { - _gl = gl; - _shader = new Shader(gl, - Path.Combine(shaderDir, "debug_line.vert"), - Path.Combine(shaderDir, "debug_line.frag")); + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + ArgumentException.ThrowIfNullOrWhiteSpace(shaderDir); + var resources = new ResourceCleanupGroup(); + Shader? shader = null; + uint vao = 0; + uint vbo = 0; + try + { + 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}")); - _vao = _gl.GenVertexArray(); - _vbo = _gl.GenBuffer(); + 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); + }); + } + catch (Exception constructionFailure) + { + resources.RollbackConstructionAndThrow( + "DebugLineRenderer construction failed and its GL prefix did not cleanly roll back.", + constructionFailure); + } - _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); + _resources = resources; + _shader = shader!; + _vao = vao; + _vbo = vbo; } /// Clear accumulated lines. Call at the start of each frame. @@ -165,8 +208,6 @@ public sealed unsafe class DebugLineRenderer : IDisposable public void Dispose() { - _gl.DeleteVertexArray(_vao); - _gl.DeleteBuffer(_vbo); - _shader.Dispose(); + _resources.RetryCleanup(); } } diff --git a/src/AcDream.App/Rendering/GameRenderResourceLifetime.cs b/src/AcDream.App/Rendering/GameRenderResourceLifetime.cs index e24575f5..71aeb182 100644 --- a/src/AcDream.App/Rendering/GameRenderResourceLifetime.cs +++ b/src/AcDream.App/Rendering/GameRenderResourceLifetime.cs @@ -1,10 +1,15 @@ namespace AcDream.App.Rendering; +internal interface IGameRenderResourceLifetime +{ + TerrainAtlas AcquireTerrainAtlas(Func factory); +} + /// /// Sole lifetime owner for render resources that are borrowed by, but not /// owned by, their renderers. /// -internal sealed class GameRenderResourceLifetime +internal sealed class GameRenderResourceLifetime : IGameRenderResourceLifetime { private readonly OwnedResourceSlot _terrainAtlas = new(); private readonly OwnedResourceSlot _skyShader = new(); diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3638bb18..19a62173 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1,6 +1,7 @@ using AcDream.Core.Plugins; using AcDream.App.Composition; using AcDream.App.Physics; +using AcDream.App.Rendering.Wb; using AcDream.App.Settings; using AcDream.App.World; using AcDream.Content; @@ -17,7 +18,8 @@ public sealed class GameWindow : IGameWindowPlatformPublication, IGameWindowHostInputCameraPublication, IGameWindowContentEffectsAudioPublication, - IGameWindowSettingsDevToolsPublication + IGameWindowSettingsDevToolsPublication, + IGameWindowWorldRenderPublication { private static double ClientTimerNow() => System.Diagnostics.Stopwatch.GetTimestamp() @@ -802,6 +804,84 @@ public sealed class GameWindow : _debugVm = value.Debug; } + void IGameWindowWorldRenderPublication.PublishBindlessSupport( + BindlessSupport value) => + PublishCompositionOwner( + ref _bindlessSupport, + value, + "bindless support"); + + void IGameWindowWorldRenderPublication.PublishTerrainShader(Shader value) => + PublishCompositionOwner( + ref _terrainModernShader, + value, + "terrain shader"); + + void IGameWindowWorldRenderPublication.PublishSceneLighting( + SceneLightingUboBinding value) => + PublishCompositionOwner( + ref _sceneLightingUbo, + value, + "scene lighting"); + + void IGameWindowWorldRenderPublication.PublishDebugLines( + DebugLineRenderer value) => + PublishCompositionOwner(ref _debugLines, value, "debug lines"); + + void IGameWindowWorldRenderPublication.PublishHudResources( + BitmapFont font, + TextRenderer text) + { + ArgumentNullException.ThrowIfNull(font); + ArgumentNullException.ThrowIfNull(text); + if (_debugFont is not null || _textRenderer is not null) + { + throw new InvalidOperationException( + "The GameWindow composition shell already owns world HUD resources."); + } + _debugFont = font; + _textRenderer = text; + } + + void IGameWindowWorldRenderPublication.PublishTerrain( + TerrainModernRenderer value) => + PublishCompositionOwner(ref _terrain, value, "terrain renderer"); + + void IGameWindowWorldRenderPublication.PublishTerrainBuildState( + float[] heightTable, + AcDream.Core.Terrain.TerrainBlendingContext blending, + System.Collections.Concurrent.ConcurrentDictionary< + uint, + AcDream.Core.Terrain.SurfaceInfo> surfaceCache) + { + ArgumentNullException.ThrowIfNull(heightTable); + ArgumentNullException.ThrowIfNull(blending); + ArgumentNullException.ThrowIfNull(surfaceCache); + if (_heightTable is not null || _blendCtx is not null || _surfaceCache is not null) + { + throw new InvalidOperationException( + "The GameWindow composition shell already owns terrain build state."); + } + _heightTable = heightTable; + _blendCtx = blending; + _surfaceCache = surfaceCache; + } + + void IGameWindowWorldRenderPublication.PublishMeshShader(Shader value) => + PublishCompositionOwner(ref _meshShader, value, "mesh shader"); + + void IGameWindowWorldRenderPublication.PublishWbMeshAdapter( + WbMeshAdapter value) => + PublishCompositionOwner(ref _wbMeshAdapter, value, "WB mesh adapter"); + + void IGameWindowWorldRenderPublication.PublishTextureCache( + TextureCache value) => + PublishCompositionOwner(ref _textureCache, value, "texture cache"); + + void IGameWindowWorldRenderPublication.PublishSamplerCache( + SamplerCache value) => + PublishCompositionOwner(ref _samplerCache, value, "sampler cache"); + private static void PublishCompositionOwner( ref T? destination, T value, @@ -932,168 +1012,22 @@ public sealed class GameWindow : Console.WriteLine), this).Compose(platform, hostInputCamera, contentEffectsAudio); - _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); - _gl.Enable(EnableCap.DepthTest); - - // Phase U.3: the 8 hardware clip planes (GL_CLIP_DISTANCE0..7) are NOT - // enabled here. Only the mesh_modern / terrain_modern vertex shaders write - // gl_ClipDistance[0..7]; the sky / particle / weather / UI / debug-line - // shaders write gl_Position but no gl_ClipDistance. Enabling a clip plane - // for a draw whose vertex shader doesn't write that plane's distance is - // UNDEFINED per the GL/GLSL spec (a driver may read the unwritten value as - // negative and clip the primitive away). So instead of a permanent global - // enable, OnRender brackets glEnable/glDisable(GL_CLIP_DISTANCE0..7) around - // ONLY the clip-writing world-geometry draws (terrain + entities, plus - // U.4's EnvCellRenderer.Render); everything else draws with clipping off. - - string shadersDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders"); - - // Phase N.5b: terrain_modern shader pair — bindless texture handles + - // glMultiDrawElementsIndirect dispatch path. The only terrain shader - // since Task 9 retired the legacy terrain.vert/.frag program. - _terrainModernShader = new Shader(_gl, - Path.Combine(shadersDir, "terrain_modern.vert"), - Path.Combine(shadersDir, "terrain_modern.frag")); - - // Phase G.1/G.2: shared scene-lighting UBO. Stays bound at - // binding=1 for the lifetime of the process — every shader that - // declares `layout(std140, binding = 1) uniform SceneLighting` - // reads from this without further intervention. - _sceneLightingUbo = new SceneLightingUboBinding(_gl); - - _debugLines = new DebugLineRenderer(_gl, shadersDir); - - // Phase I.2: load a system monospace font + TextRenderer for the - // future world-space HUD (D.6). The custom DebugOverlay is gone; - // the ImGui DebugPanel handles all dev surfaces now. These fields - // are reserved for future work — currently unused at the renderer - // level. Skips silently if no font is available. - var fontBytes = BitmapFont.TryLoadSystemMonospaceFont(); - if (fontBytes is not null) - { - _debugFont = new BitmapFont(_gl, fontBytes, pixelHeight: 15f, atlasSize: 512); - _textRenderer = new TextRenderer(_gl, shadersDir); - Console.WriteLine($"world-hud font: loaded {fontBytes.Length / 1024}KB, " + - $"atlas {_debugFont.AtlasWidth}x{_debugFont.AtlasHeight}, " + - $"lineHeight={_debugFont.LineHeight:F1}px (reserved for D.6 HUD)"); - } - else - { - Console.WriteLine("world-hud font: no system monospace font found"); - } - - uint centerLandblockId = 0xA9B4FFFFu; - Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}"); - - var region = contentEffectsAudio.Dats.Get( - 0x13000000u); - var heightTable = region?.LandDefs.LandHeightTable; - if (heightTable is null || heightTable.Length < 256) - throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated"); - - // Parse and install the DAT-backed sky, retail day-group state, and - // offline clock seed through their single environment owner. - _worldEnvironment.Initialize(region!); - - // N.5: detect ARB_bindless_texture + ARB_shader_draw_parameters BEFORE - // building the terrain atlas / renderer — both consume BindlessSupport - // (atlas via Texture2DArray bindless handles, renderer for SSBO uploads). - // The modern path (SSBO + glMultiDrawElementsIndirect + bindless textures) - // is mandatory as of Phase N.5 — missing extensions throw at startup with - // a clear error so users can file a real bug report rather than silently - // falling back to a half-working renderer. - if (AcDream.App.Rendering.Wb.BindlessSupport.TryCreate(_gl, out var bindless)) - { - if (bindless!.HasShaderDrawParameters(_gl)) - { - _bindlessSupport = bindless; - Console.WriteLine("[N.5] modern path capabilities present (bindless + ARB_shader_draw_parameters)"); - } - else - { - Console.WriteLine("[N.5] GL_ARB_shader_draw_parameters not present — modern path not available"); - } - } - else - { - Console.WriteLine("[N.5] GL_ARB_bindless_texture not present — modern path not available"); - } - - if (_bindlessSupport is null) - { - throw new NotSupportedException( - "acdream requires GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters " + - "(GL 4.3+ with bindless support). Your GPU/driver does not expose these extensions. " + - "If this is unexpected, please file a bug report with your GPU vendor + driver version."); - } - - // Build the terrain atlas once from the Region dat. Phase N.5b: the - // atlas exposes bindless handles for the modern terrain path, so - // BindlessSupport is threaded through. - var terrainAtlas = _renderResourceLifetime.AcquireTerrainAtlas( - () => AcDream.App.Rendering.TerrainAtlas.Build( - _gl, - contentEffectsAudio.Dats, - _bindlessSupport)); - // A.5 T22.5: apply anisotropic level from quality preset. Build() - // hard-codes 16x; override here to match the resolved quality so Low - // (4x) and Medium (8x) actually take effect. - terrainAtlas.SetAnisotropic(_runtimeSettings.ResolvedQuality.AnisotropicLevel); - - _terrain = new TerrainModernRenderer( - _gl, - _bindlessSupport, - _terrainModernShader!, - terrainAtlas, - _gpuFrameFlights!); - - int centerX = (int)((centerLandblockId >> 24) & 0xFFu); - int centerY = (int)((centerLandblockId >> 16) & 0xFFu); - - // Build blending context from the terrain atlas. The worker-side - // LandblockBuildFactory captures this immutable table for each build. - var terrainTypeToLayerBytes = new Dictionary(terrainAtlas.TerrainTypeToLayer.Count); - foreach (var kvp in terrainAtlas.TerrainTypeToLayer) - terrainTypeToLayerBytes[kvp.Key] = (byte)kvp.Value; - - const uint RoadTypeEnumValue = 0x20; // TerrainTextureType.RoadType - byte roadLayer = terrainTypeToLayerBytes.TryGetValue(RoadTypeEnumValue, out var rl) - ? rl - : AcDream.Core.Terrain.SurfaceInfo.None; - - _blendCtx = new AcDream.Core.Terrain.TerrainBlendingContext( - TerrainTypeToLayer: terrainTypeToLayerBytes, - RoadLayer: roadLayer, - CornerAlphaLayers: terrainAtlas.CornerAlphaLayers, - SideAlphaLayers: terrainAtlas.SideAlphaLayers, - RoadAlphaLayers: terrainAtlas.RoadAlphaLayers, - CornerAlphaTCodes: terrainAtlas.CornerAlphaTCodes, - SideAlphaTCodes: terrainAtlas.SideAlphaTCodes, - RoadAlphaRCodes: terrainAtlas.RoadAlphaRCodes); - - _heightTable = heightTable; - _surfaceCache = new System.Collections.Concurrent.ConcurrentDictionary(); - - // (Bindless detection moved above — must precede TerrainAtlas.Build / - // TerrainModernRenderer ctor so they can consume BindlessSupport.) - - // Mesh shader always loads (modern path is the only path). - _meshShader = new Shader(_gl, - Path.Combine(shadersDir, "mesh_modern.vert"), - Path.Combine(shadersDir, "mesh_modern.frag")); - Console.WriteLine("[N.5] mesh_modern shader loaded"); - - _textureCache = new TextureCache( - _gl, - contentEffectsAudio.Dats, - _bindlessSupport, - _gpuFrameFlights!); - // Two persistent GL sampler objects (Repeat + ClampToEdge) so - // the sky pass can pick wrap mode per submesh without mutating - // shared per-texture wrap state. See SamplerCache + the - // WorldBuilder reference at - // references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132. - _samplerCache = new SamplerCache(_gl); + const uint initialCenterLandblockId = 0xA9B4FFFFu; + WorldRenderResult worldRender = new WorldRenderCompositionPhase( + new WorldRenderDependencies( + _worldEnvironment, + _renderResourceLifetime, + _gpuFrameFlights!, + initialCenterLandblockId, + Console.WriteLine), + this).Compose(platform, contentEffectsAudio, settingsDevTools); + string shadersDir = worldRender.Foundation.ShadersDirectory; + TerrainAtlas terrainAtlas = worldRender.Foundation.TerrainAtlas; + int centerX = worldRender.TerrainBuild.InitialCenterX; + int centerY = worldRender.TerrainBuild.InitialCenterY; + Console.WriteLine( + $"loading world view centered on " + + $"0x{worldRender.TerrainBuild.InitialCenterLandblockId:X8}"); // Retail ClientCombatSystem attack-request owner. This exists independently // of the retained UI so keyboard combat keeps the same press/hold/release @@ -1456,22 +1390,6 @@ public sealed class GameWindow : } - // Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is - // mandatory as of N.5 ship amendment: WbMeshAdapter + WbDrawDispatcher - // always construct. - // Phase O (2026-05-21): WbMeshAdapter now consumes our DatCollection - // directly via DatCollectionAdapter. No second dat reader; index cache - // is shared with the rest of the client. - { - var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; - _wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter( - _gl, - contentEffectsAudio.Dats, - wbLogger, - _gpuFrameFlights!); - Console.WriteLine("[N.4+N.5] WB foundation + modern path active — routing all content through ObjectMeshManager."); - } - // Phase N.4 Task 12: construct LandblockSpawnAdapter under the feature flag // and rebuild _worldState so it threads the adapter in. _worldState starts // as an unadorned GpuWorldState (field initializer); here we replace it with @@ -1937,7 +1855,7 @@ public sealed class GameWindow : contentEffectsAudio.Dats, skyShader, _textureCache!, - _samplerCache); + _samplerCache!); // Phase G.1 particle renderer — renders rain / snow / spell auras // spawned into the shared ParticleSystem. Mode-1/no-degrade GfxObjs diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index c6b8fc19..370cf8ea 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -221,35 +221,46 @@ public sealed unsafe class ParticleRenderer : IDisposable handle => _particleGfxInfoByEmitter.Remove(handle), handle => _textures?.ReleaseParticleTextureOwner(handle), error => Console.Error.WriteLine($"[particles] {error}")); - _shader = new Shader(_gl, - System.IO.Path.Combine(shadersDir, "particle.vert"), - System.IO.Path.Combine(shadersDir, "particle.frag")); - if (_meshAdapter?.MeshManager?.GlobalBuffer is not null) - { - _meshShader = new Shader(_gl, - System.IO.Path.Combine(shadersDir, "particle_mesh.vert"), - System.IO.Path.Combine(shadersDir, "particle_mesh.frag")); - _meshTextureHandleLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureHandle"); - _meshTextureLayerLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureLayer"); - } - - float[] quadVerts = - { - -0.5f, -0.5f, 0f, 0f, - 0.5f, -0.5f, 1f, 0f, - 0.5f, 0.5f, 1f, 1f, - -0.5f, 0.5f, 0f, 1f, - }; - uint[] quadIdx = { 0, 1, 2, 0, 2, 3 }; - - uint quadVbo = 0; - uint quadEbo = 0; - uint uploadVao = 0; - bool vboAllocated = false; - bool eboAllocated = false; + var constructionResources = new ResourceCleanupGroup(); try { - quadVbo = TrackedGlResource.CreateBuffer(_gl, "creating particle quad VBO"); + _shader = new Shader(_gl, + System.IO.Path.Combine(shadersDir, "particle.vert"), + System.IO.Path.Combine(shadersDir, "particle.frag")); + constructionResources.Add("particle shader", _shader.Dispose); + if (_meshAdapter?.MeshManager?.GlobalBuffer is not null) + { + _meshShader = new Shader(_gl, + System.IO.Path.Combine(shadersDir, "particle_mesh.vert"), + System.IO.Path.Combine(shadersDir, "particle_mesh.frag")); + constructionResources.Add( + "particle mesh shader", + _meshShader.Dispose); + _meshTextureHandleLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureHandle"); + _meshTextureLayerLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureLayer"); + } + + float[] quadVerts = + { + -0.5f, -0.5f, 0f, 0f, + 0.5f, -0.5f, 1f, 0f, + 0.5f, 0.5f, 1f, 1f, + -0.5f, 0.5f, 0f, 1f, + }; + uint[] quadIdx = { 0, 1, 2, 0, 2, 3 }; + + bool vboAllocated = false; + bool eboAllocated = false; + uint quadVbo = TrackedGlResource.CreateBuffer( + _gl, + "creating particle quad VBO"); + RetryableGpuResourceRelease quadVboRelease = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + quadVbo, + () => vboAllocated ? quadVerts.Length * sizeof(float) : 0, + "rolling back particle quad VBO"); + constructionResources.Add("particle quad VBO", quadVboRelease.Run); fixed (void* p = quadVerts) { TrackedGlResource.AllocateBufferStorage( @@ -264,9 +275,31 @@ public sealed unsafe class ParticleRenderer : IDisposable } vboAllocated = true; - quadEbo = TrackedGlResource.CreateBuffer(_gl, "creating particle quad EBO"); - uploadVao = TrackedGlResource.CreateVertexArray(_gl, "creating particle upload VAO"); - _gl.BindVertexArray(uploadVao); + uint quadEbo = TrackedGlResource.CreateBuffer( + _gl, + "creating particle quad EBO"); + RetryableGpuResourceRelease quadEboRelease = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + quadEbo, + () => eboAllocated ? quadIdx.Length * sizeof(uint) : 0, + "rolling back particle quad EBO"); + constructionResources.Add("particle quad EBO", quadEboRelease.Run); + + uint uploadVao = TrackedGlResource.CreateVertexArray( + _gl, + "creating particle upload VAO"); + RetryableGpuResourceRelease uploadVaoRelease = + TrackedGlResource.CreateRetryableVertexArrayDeletion( + _gl, + uploadVao, + "rolling back particle upload VAO"); + constructionResources.Add("particle upload VAO", uploadVaoRelease.Run); + + GlResourceCommand.Execute( + _gl, + "bind particle upload VAO", + () => _gl.BindVertexArray(uploadVao)); fixed (void* p = quadIdx) { TrackedGlResource.AllocateBufferStorage( @@ -280,44 +313,26 @@ public sealed unsafe class ParticleRenderer : IDisposable "uploading particle quad EBO"); } eboAllocated = true; - _gl.BindVertexArray(0); - TrackedGlResource.DeleteVertexArray(_gl, uploadVao, "deleting particle upload VAO"); - uploadVao = 0; - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + GlResourceCommand.Execute(_gl, "finish particle static-buffer upload", () => + { + _gl.BindVertexArray(0); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + }); + uploadVaoRelease.Run(); + _quadVbo = quadVbo; _quadEbo = quadEbo; - } - catch (Exception creationFailure) - { - List? cleanupFailures = null; - void Attempt(Action action) - { - try { action(); } - catch (Exception ex) { (cleanupFailures ??= []).Add(ex); } - } - if (uploadVao != 0) - Attempt(() => TrackedGlResource.DeleteVertexArray(_gl, uploadVao, "rolling back particle upload VAO")); - if (quadEbo != 0) - Attempt(() => TrackedGlResource.DeleteBuffer( - _gl, - quadEbo, - eboAllocated ? quadIdx.Length * sizeof(uint) : 0, - "rolling back particle quad EBO")); - if (quadVbo != 0) - Attempt(() => TrackedGlResource.DeleteBuffer( - _gl, - quadVbo, - vboAllocated ? quadVerts.Length * sizeof(float) : 0, - "rolling back particle quad VBO")); - if (cleanupFailures is not null) - { - cleanupFailures.Insert(0, creationFailure); - throw new AggregateException("Particle static-buffer creation and rollback failed.", cleanupFailures); - } - throw; - } - _particles.EmitterDied += OnEmitterDied; + _particles.EmitterDied += OnEmitterDied; + constructionResources.TransferAll(); + } + catch (Exception constructionFailure) + { + constructionResources.RollbackConstructionAndThrow( + "ParticleRenderer construction failed and its shader prefix did not cleanly roll back.", + constructionFailure); + throw new System.Diagnostics.UnreachableException(); + } } /// diff --git a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs index fa93120e..8de3bdaa 100644 --- a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs +++ b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs @@ -88,6 +88,7 @@ void main() { } // depth-only: color writes are masked off by the caller state private readonly int _locForceFarZ; private readonly int _locDepthBias; private readonly int _locDepthBiasEyeCapN; + private readonly ResourceCleanupGroup _resources; private const int MaxFanVerts = 32; private readonly float[] _scratch = new float[MaxFanVerts * 3]; @@ -109,28 +110,52 @@ void main() { } // depth-only: color writes are masked off by the caller state public PortalDepthMaskRenderer(GL gl) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + var resources = new ResourceCleanupGroup(); + try + { + _program = ShaderProgramConstruction.Build( + new GlShaderProgramBuildApi(gl), + VertSrc, + FragSrc); + resources.Add( + "portal-depth program", + () => GlResourceCommand.DeleteProgram( + gl, + _program, + $"delete PortalDepthMask program {_program}")); - uint vs = Compile(ShaderType.VertexShader, VertSrc); - uint fs = Compile(ShaderType.FragmentShader, FragSrc); - _program = _gl.CreateProgram(); - _gl.AttachShader(_program, vs); - _gl.AttachShader(_program, fs); - _gl.LinkProgram(_program); - _gl.GetProgram(_program, ProgramPropertyARB.LinkStatus, out int linked); - if (linked == 0) - throw new InvalidOperationException($"PortalDepthMask link failed: {_gl.GetProgramInfoLog(_program)}"); - _gl.DeleteShader(vs); - _gl.DeleteShader(fs); + ( + _locViewProjection, + _locPlaneCount, + _locPlanes, + _locForceFarZ, + _locDepthBias, + _locDepthBiasEyeCapN) = GlResourceCommand.Execute( + _gl, + "resolve PortalDepthMask uniforms", + () => ( + _gl.GetUniformLocation(_program, "uViewProjection"), + _gl.GetUniformLocation(_program, "uPlaneCount"), + _gl.GetUniformLocation(_program, "uPlanes"), + _gl.GetUniformLocation(_program, "uForceFarZ"), + _gl.GetUniformLocation(_program, "uDepthBias"), + _gl.GetUniformLocation(_program, "uDepthBiasEyeCapN"))); - _locViewProjection = _gl.GetUniformLocation(_program, "uViewProjection"); - _locPlaneCount = _gl.GetUniformLocation(_program, "uPlaneCount"); - _locPlanes = _gl.GetUniformLocation(_program, "uPlanes"); - _locForceFarZ = _gl.GetUniformLocation(_program, "uForceFarZ"); - _locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias"); - _locDepthBiasEyeCapN = _gl.GetUniformLocation(_program, "uDepthBiasEyeCapN"); + for (int i = 0; i < _frameBuffers.Length; i++) + { + FrameBufferSet set = CreateFrameBufferSet(resources, i); + _frameBuffers[i] = set; + } + } + catch (Exception constructionFailure) + { + resources.RollbackConstructionAndThrow( + "PortalDepthMaskRenderer construction failed and its GL prefix did not cleanly roll back.", + constructionFailure); + throw new System.Diagnostics.UnreachableException(); + } - for (int i = 0; i < _frameBuffers.Length; i++) - _frameBuffers[i] = CreateFrameBufferSet(); + _resources = resources; } /// @@ -146,19 +171,34 @@ void main() { } // depth-only: color writes are masked off by the caller state _activeFrameBuffer.UsedVertices = 0; } - private FrameBufferSet CreateFrameBufferSet() + private FrameBufferSet CreateFrameBufferSet( + ResourceCleanupGroup resources, + int frameIndex) { - uint vao = 0; - uint vbo = 0; - try + uint vao = TrackedGlResource.CreateVertexArray( + _gl, + "PortalDepthMask frame VAO creation"); + RetryableGpuResourceRelease vaoRelease = + TrackedGlResource.CreateRetryableVertexArrayDeletion( + _gl, + vao, + "PortalDepthMask frame VAO disposal"); + resources.Add($"portal-depth frame {frameIndex} VAO", vaoRelease.Run); + + uint vbo = TrackedGlResource.CreateBuffer( + _gl, + "PortalDepthMask frame VBO creation"); + var set = new FrameBufferSet { Vao = vao, Vbo = vbo }; + RetryableGpuResourceRelease vboRelease = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + vbo, + () => set.CapacityBytes, + "PortalDepthMask frame VBO disposal"); + resources.Add($"portal-depth frame {frameIndex} VBO", vboRelease.Run); + + GlResourceCommand.Execute(_gl, "configure PortalDepthMask frame buffers", () => { - vao = TrackedGlResource.CreateVertexArray( - _gl, - "PortalDepthMask frame VAO creation"); - vbo = TrackedGlResource.CreateBuffer( - _gl, - "PortalDepthMask frame VBO creation"); - var set = new FrameBufferSet { Vao = vao, Vbo = vbo }; _gl.BindVertexArray(set.Vao); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.Vbo); unsafe @@ -168,29 +208,8 @@ void main() { } // depth-only: color writes are masked off by the caller state _gl.EnableVertexAttribArray(0); _gl.BindVertexArray(0); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); - return set; - } - catch - { - if (vbo != 0) - TrackedGlResource.DeleteBuffer( - _gl, vbo, 0, "PortalDepthMask frame VBO rollback"); - if (vao != 0) - TrackedGlResource.DeleteVertexArray( - _gl, vao, "PortalDepthMask frame VAO rollback"); - throw; - } - } - - private uint Compile(ShaderType type, string src) - { - uint s = _gl.CreateShader(type); - _gl.ShaderSource(s, src); - _gl.CompileShader(s); - _gl.GetShader(s, ShaderParameterName.CompileStatus, out int ok); - if (ok == 0) - throw new InvalidOperationException($"PortalDepthMask {type} compile failed: {_gl.GetShaderInfoLog(s)}"); - return s; + }); + return set; } /// @@ -387,18 +406,6 @@ void main() { } // depth-only: color writes are masked off by the caller state public void Dispose() { - _gl.DeleteProgram(_program); - foreach (FrameBufferSet set in _frameBuffers) - { - TrackedGlResource.DeleteVertexArray( - _gl, - set.Vao, - "PortalDepthMask frame VAO disposal"); - TrackedGlResource.DeleteBuffer( - _gl, - set.Vbo, - set.CapacityBytes, - "PortalDepthMask frame VBO disposal"); - } + _resources.RetryCleanup(); } } diff --git a/src/AcDream.App/Rendering/ResourceCleanupGroup.cs b/src/AcDream.App/Rendering/ResourceCleanupGroup.cs index fe8ba308..1a3b6c5d 100644 --- a/src/AcDream.App/Rendering/ResourceCleanupGroup.cs +++ b/src/AcDream.App/Rendering/ResourceCleanupGroup.cs @@ -1,5 +1,7 @@ namespace AcDream.App.Rendering; +using System.Runtime.ExceptionServices; + /// /// Reverse-order, all-attempted cleanup owner used while a composite resource /// is still under construction and after it becomes the aggregate owner. @@ -25,6 +27,15 @@ internal sealed class ResourceCleanupGroup : IRetryableResourceCleanup _entries.Add(new Entry(name, release)); } + public void TransferAll() + { + if (_running) + throw new InvalidOperationException( + "The resource cleanup group is currently releasing resources."); + foreach (Entry entry in _entries) + entry.Complete = true; + } + public void RetryCleanup() { if (_running || IsCleanupComplete) @@ -60,4 +71,26 @@ internal sealed class ResourceCleanupGroup : IRetryableResourceCleanup if (failures is not null) throw new AggregateException("Composite resource cleanup remains incomplete.", failures); } + + public void RollbackConstructionAndThrow( + string message, + Exception constructionFailure) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); + ArgumentNullException.ThrowIfNull(constructionFailure); + try + { + RetryCleanup(); + } + catch (Exception cleanupFailure) + { + throw new GlResourceConstructionException( + message, + this, + [constructionFailure, cleanupFailure]); + } + + ExceptionDispatchInfo.Capture(constructionFailure).Throw(); + throw new InvalidOperationException("Unreachable construction rollback path."); + } } diff --git a/src/AcDream.App/Rendering/SamplerCache.cs b/src/AcDream.App/Rendering/SamplerCache.cs index d65e7e85..91647bab 100644 --- a/src/AcDream.App/Rendering/SamplerCache.cs +++ b/src/AcDream.App/Rendering/SamplerCache.cs @@ -31,6 +31,7 @@ namespace AcDream.App.Rendering; public sealed class SamplerCache : IDisposable { private readonly GL _gl; + private readonly ResourceCleanupGroup _resources; /// Sampler with WrapS = WrapT = Repeat. The default for textures uploaded by . public uint Wrap { get; } @@ -41,23 +42,62 @@ public sealed class SamplerCache : IDisposable public SamplerCache(GL gl) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + var resources = new ResourceCleanupGroup(); + uint wrap = 0; + uint clamp = 0; + try + { + wrap = GlResourceCommand.CreateName( + _gl, + "repeat sampler", + _gl.GenSampler, + _gl.DeleteSampler); + uint ownedWrap = wrap; + resources.Add( + "repeat sampler", + () => GlResourceCommand.Execute( + _gl, + $"delete repeat sampler {ownedWrap}", + () => _gl.DeleteSampler(ownedWrap))); + Configure(wrap, TextureWrapMode.Repeat, "repeat sampler"); - Wrap = _gl.GenSampler(); - _gl.SamplerParameter(Wrap, SamplerParameterI.WrapS, (int)TextureWrapMode.Repeat); - _gl.SamplerParameter(Wrap, SamplerParameterI.WrapT, (int)TextureWrapMode.Repeat); - _gl.SamplerParameter(Wrap, SamplerParameterI.MinFilter, (int)TextureMinFilter.Linear); - _gl.SamplerParameter(Wrap, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear); + clamp = GlResourceCommand.CreateName( + _gl, + "clamp sampler", + _gl.GenSampler, + _gl.DeleteSampler); + uint ownedClamp = clamp; + resources.Add( + "clamp sampler", + () => GlResourceCommand.Execute( + _gl, + $"delete clamp sampler {ownedClamp}", + () => _gl.DeleteSampler(ownedClamp))); + Configure(clamp, TextureWrapMode.ClampToEdge, "clamp sampler"); + } + catch (Exception constructionFailure) + { + resources.RollbackConstructionAndThrow( + "SamplerCache construction failed and its sampler prefix did not cleanly roll back.", + constructionFailure); + } - Clamp = _gl.GenSampler(); - _gl.SamplerParameter(Clamp, SamplerParameterI.WrapS, (int)TextureWrapMode.ClampToEdge); - _gl.SamplerParameter(Clamp, SamplerParameterI.WrapT, (int)TextureWrapMode.ClampToEdge); - _gl.SamplerParameter(Clamp, SamplerParameterI.MinFilter, (int)TextureMinFilter.Linear); - _gl.SamplerParameter(Clamp, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear); + Wrap = wrap; + Clamp = clamp; + _resources = resources; } + private void Configure(uint sampler, TextureWrapMode wrap, string name) => + GlResourceCommand.Execute(_gl, $"configure {name}", () => + { + _gl.SamplerParameter(sampler, SamplerParameterI.WrapS, (int)wrap); + _gl.SamplerParameter(sampler, SamplerParameterI.WrapT, (int)wrap); + _gl.SamplerParameter(sampler, SamplerParameterI.MinFilter, (int)TextureMinFilter.Linear); + _gl.SamplerParameter(sampler, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear); + }); + public void Dispose() { - if (Wrap != 0) _gl.DeleteSampler(Wrap); - if (Clamp != 0) _gl.DeleteSampler(Clamp); + _resources.RetryCleanup(); } } diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.cs index 40ed41a5..2a4cfd5c 100644 --- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs @@ -144,42 +144,56 @@ public sealed unsafe class TerrainModernRenderer : IDisposable if (_uTexTilingLoc < 0) throw new InvalidOperationException("terrain_modern.frag is missing the required uTexTiling uniform."); + var constructionResources = new ResourceCleanupGroup(); try { _globalVao = TrackedGlResource.CreateVertexArray( _gl, "creating terrain global VAO"); + RetryableGpuResourceRelease globalVaoRelease = + TrackedGlResource.CreateRetryableVertexArrayDeletion( + _gl, + _globalVao, + "rolling back terrain global VAO"); + constructionResources.Add( + "terrain global VAO", + globalVaoRelease.Run); _globalVbo = TrackedGlResource.CreateBuffer( _gl, "creating terrain global vertex buffer"); + RetryableGpuResourceRelease globalVboRelease = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + _globalVbo, + () => _globalVboCapacityBytes, + "rolling back terrain global vertex buffer"); + constructionResources.Add( + "terrain global vertex buffer", + globalVboRelease.Run); _globalEbo = TrackedGlResource.CreateBuffer( _gl, "creating terrain global index buffer"); + RetryableGpuResourceRelease globalEboRelease = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + _globalEbo, + () => _globalEboCapacityBytes, + "rolling back terrain global index buffer"); + constructionResources.Add( + "terrain global index buffer", + globalEboRelease.Run); AllocateGpuBuffers(initialSlotCapacity); - ConfigureVao(_globalVao, _globalVbo, _globalEbo); + GlResourceCommand.Execute( + _gl, + "configure terrain global vertex array", + () => ConfigureVao(_globalVao, _globalVbo, _globalEbo)); + constructionResources.TransferAll(); } - catch + catch (Exception constructionFailure) { - TrackedGlResource.DeleteVertexArray( - _gl, - _globalVao, - "rolling back terrain global VAO"); - TrackedGlResource.DeleteBuffer( - _gl, - _globalVbo, - _globalVboCapacityBytes, - "rolling back terrain global vertex buffer"); - TrackedGlResource.DeleteBuffer( - _gl, - _globalEbo, - _globalEboCapacityBytes, - "rolling back terrain global index buffer"); - _globalVao = 0; - _globalVbo = 0; - _globalEbo = 0; - _globalVboCapacityBytes = 0; - _globalEboCapacityBytes = 0; - throw; + constructionResources.RollbackConstructionAndThrow( + "TerrainModernRenderer construction failed and its GL prefix did not cleanly roll back.", + constructionFailure); } } diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index 03ec5121..8e6eae6f 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -95,10 +95,31 @@ public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable ArgumentNullException.ThrowIfNull(retirementQueue); if (bindless is not null) { - _compositeTextures = new CompositeTextureArrayCache(gl, bindless, retirementQueue); - _particleTextures = new StandaloneBindlessTextureCache( - new ParticleTextureBackend(this), - retirementQueue); + var resources = new ResourceCleanupGroup(); + CompositeTextureArrayCache? composite = null; + StandaloneBindlessTextureCache? particles = null; + try + { + composite = new CompositeTextureArrayCache( + gl, + bindless, + retirementQueue); + resources.Add("composite texture cache", composite.Dispose); + particles = new StandaloneBindlessTextureCache( + new ParticleTextureBackend(this), + retirementQueue); + 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; } } diff --git a/src/AcDream.App/Rendering/Wb/GLSLShader.cs b/src/AcDream.App/Rendering/Wb/GLSLShader.cs index 85f569b9..f4eabecf 100644 --- a/src/AcDream.App/Rendering/Wb/GLSLShader.cs +++ b/src/AcDream.App/Rendering/Wb/GLSLShader.cs @@ -1,4 +1,5 @@ using Chorizite.Core.Render; +using AcDream.App.Rendering; using Microsoft.Extensions.Logging; using Silk.NET.OpenGL; using System; @@ -161,7 +162,7 @@ namespace AcDream.App.Rendering.Wb { if (string.IsNullOrWhiteSpace(vertShaderSource) || string.IsNullOrWhiteSpace(fragShaderSource)) { _log.LogError($"Shader {Name} has no source code!"); - return; + throw new InvalidOperationException($"Shader {Name} has no source code."); } if (_device.HasOpenGL43 && _device.HasBindless) { @@ -170,41 +171,47 @@ namespace AcDream.App.Rendering.Wb { fragShaderSource = fragShaderSource.Replace("#version 330 core", replacement); } - uint vertexShader = CompileShader(ShaderType.VertexShader, Name, vertShaderSource); - uint fragmentShader = CompileShader(ShaderType.FragmentShader, Name, fragShaderSource); + var resources = new ResourceCleanupGroup(); + uint prog = 0; + bool accountingPublished = false; + try { + prog = ShaderProgramConstruction.Build( + new GlShaderProgramBuildApi(GL), + vertShaderSource, + fragShaderSource); + uint ownedProgram = prog; + var unpublishedProgramRelease = new RetryableGpuResourceRelease( + () => GlResourceCommand.DeleteProgram( + GL, + ownedProgram, + $"delete unpublished WB shader program {ownedProgram}"), + () => { + if (accountingPublished) + { + GpuMemoryTracker.TrackResourceDeallocation( + GpuResourceType.Shader); + } + }); + resources.Add("WB shader program", unpublishedProgramRelease.Run); - var prog = GL.CreateProgram(); - GLHelpers.CheckErrors(GL, true); - GL.AttachShader(prog, vertexShader); - GLHelpers.CheckErrors(GL, true); - GL.AttachShader(prog, fragmentShader); - GLHelpers.CheckErrors(GL, true); - GL.LinkProgram(prog); - GLHelpers.CheckErrors(GL, true); + // Bind SceneData uniform block to point 0 if it exists. + GlResourceCommand.Execute(GL, $"configure shader {Name} SceneData binding", () => { + uint sceneDataIndex = GL.GetUniformBlockIndex(prog, "SceneData"); + if (sceneDataIndex != uint.MaxValue) + GL.UniformBlockBinding(prog, sceneDataIndex, 0); + }); - GL.GetProgram(prog, GLEnum.LinkStatus, out int success); - GLHelpers.CheckErrors(GL); - if (success != 1) { - var infoLog = GL.GetProgramInfoLog(prog); - _log.LogError($"Error: shader {Name} link failed: {infoLog}"); - GL.DeleteProgram(prog); - return; - } - else { - _log.LogTrace($"{(Program != 0 ? "Reloaded" : "Loaded")} shader: {Name}"); + GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Shader); + accountingPublished = true; + resources.TransferAll(); + } catch (Exception constructionFailure) { + _log.LogError(constructionFailure, "Failed to construct shader {ShaderName}", Name); + resources.RollbackConstructionAndThrow( + $"Shader {Name} construction failed and its GL program did not cleanly roll back.", + constructionFailure); } - // Bind SceneData uniform block to point 0 if it exists - var sceneDataIndex = GL.GetUniformBlockIndex(prog, "SceneData"); - if (sceneDataIndex != uint.MaxValue) { - GL.UniformBlockBinding(prog, sceneDataIndex, 0); - GLHelpers.CheckErrors(GL); - } - - GL.DeleteShader(vertexShader); - GLHelpers.CheckErrors(GL); - GL.DeleteShader(fragmentShader); - GLHelpers.CheckErrors(GL); + _log.LogTrace($"{(Program != 0 ? "Reloaded" : "Loaded")} shader: {Name}"); if (Program != 0) { Unload(); @@ -212,32 +219,12 @@ namespace AcDream.App.Rendering.Wb { _uniformLocations.Clear(); _uniformValues.Clear(); - GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Shader); Program = prog; ProgramId = prog; NeedsLoad = false; GLHelpers.CheckErrors(GL); } - private uint CompileShader(ShaderType shaderType, string name, string shaderSource) { - uint shader = GL.CreateShader(shaderType); - GLHelpers.CheckErrors(GL); - - GL.ShaderSource(shader, shaderSource); - GLHelpers.CheckErrors(GL); - GL.CompileShader(shader); - GLHelpers.CheckErrors(GL); - - GL.GetShader(shader, ShaderParameterName.CompileStatus, out int success); - GLHelpers.CheckErrors(GL); - if (success != 1) { - var infoLog = GL.GetShaderInfoLog(shader); - _log.LogError($"Error: {name}:{shaderType} compilation failed: {infoLog}"); - } - - return shader; - } - public override void Bind() { lock (_lock) { SetActive(); diff --git a/src/AcDream.App/Rendering/Wb/ManagedGLUniformBuffer.cs b/src/AcDream.App/Rendering/Wb/ManagedGLUniformBuffer.cs index da93db1a..ebcc56d2 100644 --- a/src/AcDream.App/Rendering/Wb/ManagedGLUniformBuffer.cs +++ b/src/AcDream.App/Rendering/Wb/ManagedGLUniformBuffer.cs @@ -28,30 +28,44 @@ namespace AcDream.App.Rendering.Wb { /// Buffer usage /// The size of the buffer, in bytes public unsafe ManagedGLUniformBuffer(OpenGLGraphicsDevice device, BufferUsage usage, int size) { - _device = device; + _device = device ?? throw new ArgumentNullException(nameof(device)); + ArgumentOutOfRangeException.ThrowIfLessThan(size, 1); Size = size; Usage = usage; - - // Generate the buffer - bufferId = GL.GenBuffer(); - if (bufferId == 0) { - throw new Exception("Failed to generate uniform buffer."); + var resources = new AcDream.App.Rendering.ResourceCleanupGroup(); + uint buffer = 0; + bool allocated = false; + try { + buffer = TrackedGlResource.CreateBuffer( + GL, + "creating managed uniform buffer"); + uint ownedBuffer = buffer; + RetryableGpuResourceRelease unpublishedBufferRelease = + TrackedGlResource.CreateRetryableBufferDeletion( + GL, + ownedBuffer, + () => allocated ? Size : 0, + "rolling back managed uniform buffer"); + resources.Add( + "managed uniform buffer", + unpublishedBufferRelease.Run); + TrackedGlResource.AllocateBufferStorage( + GL, + GLEnum.UniformBuffer, + buffer, + 0, + Size, + GLEnum.DynamicDraw, + "allocating managed uniform buffer"); + allocated = true; + resources.TransferAll(); + } catch (Exception constructionFailure) { + resources.RollbackConstructionAndThrow( + "ManagedGLUniformBuffer construction failed and its GL buffer did not cleanly roll back.", + constructionFailure); } - GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer); - GLHelpers.CheckErrors(GL); - // Allocate the buffer with the specified size - GL.BindBuffer(GLEnum.UniformBuffer, bufferId); - GLHelpers.CheckErrors(GL); - - GL.BufferData( - GLEnum.UniformBuffer, - (uint)Size, - (void*)0, // No initial data - GLEnum.DynamicDraw); - GLHelpers.CheckErrors(GL); - - GpuMemoryTracker.TrackAllocation(Size, GpuResourceType.Buffer); + bufferId = buffer; } /// @@ -139,5 +153,25 @@ namespace AcDream.App.Rendering.Wb { } }); } + + /// + /// Releases an unpublished constructor-owned buffer synchronously on + /// the GL thread. This is deliberately separate from ordinary queued + /// disposal so an enclosing constructor can prove rollback before it + /// propagates its failure. + /// + internal void DisposeImmediately() { + if (bufferId == 0) + return; + + RetryableGpuResourceRelease release = + TrackedGlResource.CreateRetryableBufferDeletion( + GL, + bufferId, + Size, + "rolling back unpublished managed uniform buffer"); + release.Run(); + bufferId = 0; + } } } diff --git a/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs b/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs index fbb65575..7b70774f 100644 --- a/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs +++ b/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs @@ -1,6 +1,7 @@ using Chorizite.Core.Render; using Chorizite.Core.Render.Enums; using Chorizite.Core.Render.Vertex; +using AcDream.App.Rendering; using Microsoft.Extensions.Logging; using Silk.NET.OpenGL; // IUniformBuffer is in Chorizite.Core.dll but under the Chorizite.OpenGLSDLBackend namespace @@ -149,38 +150,49 @@ namespace AcDream.App.Rendering.Wb { HasBindless = false; } - GL.GenBuffers(1, out uint instanceVbo); - InstanceVBO = instanceVbo; + var resources = new ResourceCleanupGroup(); + try { + InstanceVBO = CreateConstructionBuffer(resources, "WB instance buffer"); - // Query this immutable device limit once. Atlas construction can - // happen hundreds of times during portal streaming; repeating a - // driver GetFloat for every texture serialized the upload burst. - if (renderSettings.EnableAnisotropicFiltering) { - GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso); - MaxSupportedAnisotropy = Math.Max(0f, maxAniso); + // Query this immutable device limit once. Atlas construction can + // happen hundreds of times during portal streaming; repeating a + // driver GetFloat for every texture serialized the upload burst. + if (renderSettings.EnableAnisotropicFiltering) { + MaxSupportedAnisotropy = GlResourceCommand.Execute( + GL, + "query maximum texture anisotropy", + () => { + GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso); + return Math.Max(0f, maxAniso); + }); + } + + WrapSampler = CreateConstructionSampler( + resources, + TextureWrapMode.Repeat, + "WB repeat sampler"); + ClampSampler = CreateConstructionSampler( + resources, + TextureWrapMode.ClampToEdge, + "WB clamp sampler"); + + _sceneDataBuffer = new ManagedGLUniformBuffer( + this, + BufferUsage.Dynamic, + Marshal.SizeOf()); + ManagedGLUniformBuffer ownedSceneDataBuffer = _sceneDataBuffer; + resources.Add( + "WB scene-data uniform buffer", + ownedSceneDataBuffer.DisposeImmediately); + + InitializeSharedDebugResources(resources); + resources.TransferAll(); + } catch (Exception constructionFailure) { + resources.RollbackConstructionAndThrow( + "OpenGLGraphicsDevice construction failed and its GL prefix did not cleanly roll back.", + constructionFailure); } - // Create sampler objects for wrap vs clamp - WrapSampler = GL.GenSampler(); - GL.SamplerParameter(WrapSampler, SamplerParameterI.WrapS, (int)TextureWrapMode.Repeat); - GL.SamplerParameter(WrapSampler, SamplerParameterI.WrapT, (int)TextureWrapMode.Repeat); - GL.SamplerParameter(WrapSampler, SamplerParameterI.MinFilter, (int)TextureMinFilter.LinearMipmapLinear); - GL.SamplerParameter(WrapSampler, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear); - if (MaxSupportedAnisotropy > 0) - GL.SamplerParameter(WrapSampler, GLEnum.TextureMaxAnisotropy, MaxSupportedAnisotropy); - - ClampSampler = GL.GenSampler(); - GL.SamplerParameter(ClampSampler, SamplerParameterI.WrapS, (int)TextureWrapMode.ClampToEdge); - GL.SamplerParameter(ClampSampler, SamplerParameterI.WrapT, (int)TextureWrapMode.ClampToEdge); - GL.SamplerParameter(ClampSampler, SamplerParameterI.MinFilter, (int)TextureMinFilter.LinearMipmapLinear); - GL.SamplerParameter(ClampSampler, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear); - if (MaxSupportedAnisotropy > 0) - GL.SamplerParameter(ClampSampler, GLEnum.TextureMaxAnisotropy, MaxSupportedAnisotropy); - - _sceneDataBuffer = new ManagedGLUniformBuffer(this, BufferUsage.Dynamic, Marshal.SizeOf()); - - InitializeSharedDebugResources(); - // ParticleBatcher is constructed post-ctor by WbMeshAdapter (WbMeshAdapter.cs:78) // after the adapter has wired up all dependencies. The null! here is overridden // immediately after construction; it is not observable as null at runtime. @@ -196,7 +208,49 @@ namespace AcDream.App.Rendering.Wb { internal AcDream.App.Rendering.IGpuResourceRetirementQueue ResourceRetirement => _resourceRetirement; - private void InitializeSharedDebugResources() { + private uint CreateConstructionBuffer(ResourceCleanupGroup resources, string name) { + uint buffer = GlResourceCommand.CreateName(GL, name, GL.GenBuffer, GL.DeleteBuffer); + resources.Add( + name, + () => GlResourceCommand.DeleteBuffer( + GL, + buffer, + $"delete {name} {buffer}")); + return buffer; + } + + private uint CreateConstructionSampler( + ResourceCleanupGroup resources, + TextureWrapMode wrapMode, + string name) { + uint sampler = GlResourceCommand.CreateName(GL, name, GL.GenSampler, GL.DeleteSampler); + resources.Add( + name, + () => GlResourceCommand.Execute( + GL, + $"delete {name} {sampler}", + () => GL.DeleteSampler(sampler))); + GlResourceCommand.Execute(GL, $"configure {name}", () => { + GL.SamplerParameter(sampler, SamplerParameterI.WrapS, (int)wrapMode); + GL.SamplerParameter(sampler, SamplerParameterI.WrapT, (int)wrapMode); + GL.SamplerParameter( + sampler, + SamplerParameterI.MinFilter, + (int)TextureMinFilter.LinearMipmapLinear); + GL.SamplerParameter( + sampler, + SamplerParameterI.MagFilter, + (int)TextureMagFilter.Linear); + if (MaxSupportedAnisotropy > 0) + GL.SamplerParameter( + sampler, + GLEnum.TextureMaxAnisotropy, + MaxSupportedAnisotropy); + }); + return sampler; + } + + private void InitializeSharedDebugResources(ResourceCleanupGroup resources) { // Unit quad vertices for two triangles (0 to 1 for length, -0.5 to 0.5 for thickness) float[] quadVertices = { 0.0f, -0.5f, @@ -207,53 +261,74 @@ namespace AcDream.App.Rendering.Wb { 0.0f, 0.5f }; - GL.GenBuffers(1, out uint quadVbo); - SharedQuadVBO = quadVbo; - GL.BindBuffer(GLEnum.ArrayBuffer, SharedQuadVBO); - fixed (float* pQuad = quadVertices) { - GL.BufferData(GLEnum.ArrayBuffer, (nuint)(quadVertices.Length * sizeof(float)), pQuad, GLEnum.StaticDraw); - } + SharedQuadVBO = CreateConstructionBuffer(resources, "WB shared debug quad buffer"); + SharedDebugInstanceVBO = CreateConstructionBuffer( + resources, + "WB shared debug instance buffer"); + SharedDebugVAO = GlResourceCommand.CreateName( + GL, + "WB shared debug vertex array", + GL.GenVertexArray, + GL.DeleteVertexArray); + uint ownedDebugVao = SharedDebugVAO; + resources.Add( + "WB shared debug vertex array", + () => GlResourceCommand.DeleteVertexArray( + GL, + ownedDebugVao, + $"delete WB shared debug vertex array {ownedDebugVao}")); - GL.GenBuffers(1, out uint debugInstanceVbo); - SharedDebugInstanceVBO = debugInstanceVbo; - // Initial capacity for debug instances - GL.BindBuffer(GLEnum.ArrayBuffer, SharedDebugInstanceVBO); - GL.BufferData(GLEnum.ArrayBuffer, (nuint)(1024 * 44), (void*)0, GLEnum.StreamDraw); // 44 bytes is sizeof(LineInstance) + GlResourceCommand.Execute(GL, "configure WB shared debug resources", () => { + GL.BindBuffer(GLEnum.ArrayBuffer, SharedQuadVBO); + fixed (float* pQuad = quadVertices) { + GL.BufferData( + GLEnum.ArrayBuffer, + (nuint)(quadVertices.Length * sizeof(float)), + pQuad, + GLEnum.StaticDraw); + } - GL.GenVertexArrays(1, out uint debugVao); - SharedDebugVAO = debugVao; - GL.BindVertexArray(SharedDebugVAO); + // Initial capacity for debug instances. + GL.BindBuffer(GLEnum.ArrayBuffer, SharedDebugInstanceVBO); + GL.BufferData( + GLEnum.ArrayBuffer, + (nuint)(1024 * 44), + (void*)0, + GLEnum.StreamDraw); // 44 bytes is sizeof(LineInstance) - // Quad Pos attribute (location 0) - GL.BindBuffer(GLEnum.ArrayBuffer, SharedQuadVBO); - GL.EnableVertexAttribArray(0); - GL.VertexAttribPointer(0, 2, GLEnum.Float, false, 2 * sizeof(float), (void*)0); + GL.BindVertexArray(SharedDebugVAO); - // Instance attributes - GL.BindBuffer(GLEnum.ArrayBuffer, SharedDebugInstanceVBO); - uint lineInstanceSize = 44; // Marshal.SizeOf() - we'll hardcode or use a constant later + // Quad Pos attribute (location 0) + GL.BindBuffer(GLEnum.ArrayBuffer, SharedQuadVBO); + GL.EnableVertexAttribArray(0); + GL.VertexAttribPointer(0, 2, GLEnum.Float, false, 2 * sizeof(float), (void*)0); - // aStart (location 1) - GL.EnableVertexAttribArray(1); - GL.VertexAttribPointer(1, 3, GLEnum.Float, false, lineInstanceSize, (void*)0); - GL.VertexAttribDivisor(1, 1); + // Instance attributes + GL.BindBuffer(GLEnum.ArrayBuffer, SharedDebugInstanceVBO); + uint lineInstanceSize = 44; - // aEnd (location 2) - GL.EnableVertexAttribArray(2); - GL.VertexAttribPointer(2, 3, GLEnum.Float, false, lineInstanceSize, (void*)12); // OffsetOf End - GL.VertexAttribDivisor(2, 1); + // aStart (location 1) + GL.EnableVertexAttribArray(1); + GL.VertexAttribPointer(1, 3, GLEnum.Float, false, lineInstanceSize, (void*)0); + GL.VertexAttribDivisor(1, 1); - // aColor (location 3) - GL.EnableVertexAttribArray(3); - GL.VertexAttribPointer(3, 4, GLEnum.Float, false, lineInstanceSize, (void*)24); // OffsetOf Color - GL.VertexAttribDivisor(3, 1); + // aEnd (location 2) + GL.EnableVertexAttribArray(2); + GL.VertexAttribPointer(2, 3, GLEnum.Float, false, lineInstanceSize, (void*)12); + GL.VertexAttribDivisor(2, 1); - // aThickness (location 4) - GL.EnableVertexAttribArray(4); - GL.VertexAttribPointer(4, 1, GLEnum.Float, false, lineInstanceSize, (void*)40); // OffsetOf Thickness - GL.VertexAttribDivisor(4, 1); + // aColor (location 3) + GL.EnableVertexAttribArray(3); + GL.VertexAttribPointer(3, 4, GLEnum.Float, false, lineInstanceSize, (void*)24); + GL.VertexAttribDivisor(3, 1); - GL.BindVertexArray(0); + // aThickness (location 4) + GL.EnableVertexAttribArray(4); + GL.VertexAttribPointer(4, 1, GLEnum.Float, false, lineInstanceSize, (void*)40); + GL.VertexAttribDivisor(4, 1); + + GL.BindVertexArray(0); + }); } public void EnsureInstanceBufferCapacity(int count, int stride, bool forceOrphan = false) { diff --git a/src/AcDream.App/Rendering/Wb/ParticleBatcher.cs b/src/AcDream.App/Rendering/Wb/ParticleBatcher.cs index 0841ec64..3b1a6c9a 100644 --- a/src/AcDream.App/Rendering/Wb/ParticleBatcher.cs +++ b/src/AcDream.App/Rendering/Wb/ParticleBatcher.cs @@ -32,6 +32,7 @@ namespace AcDream.App.Rendering.Wb { private readonly uint _ibo; private readonly uint _instanceVbo; private readonly IShader _shader; + private readonly ResourceCleanupGroup _resources; private readonly ParticleInstance[] _instanceData = new ParticleInstance[MAX_PARTICLES_TOTAL]; private readonly List _allParticles = new(); private int _currentInstanceCount = 0; @@ -43,91 +44,108 @@ namespace AcDream.App.Rendering.Wb { private Vector3 _cameraRight; public ParticleBatcher(OpenGLGraphicsDevice graphicsDevice) { - _graphicsDevice = graphicsDevice; + _graphicsDevice = graphicsDevice ?? throw new ArgumentNullException(nameof(graphicsDevice)); var gl = _graphicsDevice.GL; + var resources = new ResourceCleanupGroup(); + IShader? shader = null; + uint vao = 0; + uint vbo = 0; + uint ibo = 0; + uint instanceVbo = 0; + try { + var vertSource = EmbeddedResourceReader.GetEmbeddedResource("Shaders.Particle.vert"); + var fragSource = EmbeddedResourceReader.GetEmbeddedResource("Shaders.Particle.frag"); + shader = _graphicsDevice.CreateShader("Particle", vertSource, fragSource); + if (shader is IDisposable disposableShader) + resources.Add("WB particle shader", disposableShader.Dispose); - var vertSource = EmbeddedResourceReader.GetEmbeddedResource("Shaders.Particle.vert"); - var fragSource = EmbeddedResourceReader.GetEmbeddedResource("Shaders.Particle.frag"); - _shader = _graphicsDevice.CreateShader("Particle", vertSource, fragSource); + // Create quad vertices - centered to match ACViewer expansion logic + float[] vertices = { + -0.5f, 0.0f, -0.5f, 0.0f, 1.0f, + 0.5f, 0.0f, -0.5f, 1.0f, 1.0f, + 0.5f, 0.0f, 0.5f, 1.0f, 0.0f, + -0.5f, 0.0f, 0.5f, 0.0f, 0.0f + }; + ushort[] indices = { 0, 1, 2, 2, 3, 0 }; - // Create quad vertices - centered to match ACViewer expansion logic - float[] vertices = { - // x, y, z, u, v - -0.5f, 0.0f, -0.5f, 0.0f, 1.0f, - 0.5f, 0.0f, -0.5f, 1.0f, 1.0f, - 0.5f, 0.0f, 0.5f, 1.0f, 0.0f, - -0.5f, 0.0f, 0.5f, 0.0f, 0.0f - }; + vao = CreateVertexArray(resources, gl, "WB particle VAO"); + vbo = CreateBuffer(resources, gl, "WB particle vertex buffer"); + ibo = CreateBuffer(resources, gl, "WB particle index buffer"); + instanceVbo = CreateBuffer(resources, gl, "WB particle instance buffer"); - ushort[] indices = { 0, 1, 2, 2, 3, 0 }; + GlResourceCommand.Execute(gl, "configure WB particle batcher", () => { + gl.BindVertexArray(vao); + gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo); + fixed (float* p = vertices) { + gl.BufferData(BufferTargetARB.ArrayBuffer, (uint)(vertices.Length * sizeof(float)), p, BufferUsageARB.StaticDraw); + } + gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ibo); + fixed (ushort* p = indices) { + gl.BufferData(BufferTargetARB.ElementArrayBuffer, (uint)(indices.Length * sizeof(ushort)), p, BufferUsageARB.StaticDraw); + } - _vao = gl.GenVertexArray(); - gl.BindVertexArray(_vao); + gl.EnableVertexAttribArray(0); + gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 5 * sizeof(float), (void*)0); + gl.EnableVertexAttribArray(1); + gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, 5 * sizeof(float), (void*)(3 * sizeof(float))); - _vbo = gl.GenBuffer(); - gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); - unsafe { - fixed (float* p = vertices) { - gl.BufferData(BufferTargetARB.ArrayBuffer, (uint)(vertices.Length * sizeof(float)), p, BufferUsageARB.StaticDraw); - } + gl.BindBuffer(BufferTargetARB.ArrayBuffer, instanceVbo); + gl.BufferData(BufferTargetARB.ArrayBuffer, (uint)(MAX_PARTICLES_TOTAL * Marshal.SizeOf()), (void*)0, BufferUsageARB.DynamicDraw); + uint stride = (uint)Marshal.SizeOf(); + gl.EnableVertexAttribArray(2); + gl.VertexAttribPointer(2, 3, VertexAttribPointerType.Float, false, stride, (void*)0); + gl.VertexAttribDivisor(2, 1); + gl.EnableVertexAttribArray(3); + gl.VertexAttribPointer(3, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float))); + gl.VertexAttribDivisor(3, 1); + gl.EnableVertexAttribArray(4); + gl.VertexAttribPointer(4, 1, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float))); + gl.VertexAttribDivisor(4, 1); + gl.EnableVertexAttribArray(5); + gl.VertexAttribPointer(5, 4, VertexAttribPointerType.Float, false, stride, (void*)(7 * sizeof(float))); + gl.VertexAttribDivisor(5, 1); + gl.EnableVertexAttribArray(6); + gl.VertexAttribPointer(6, 2, VertexAttribPointerType.Float, false, stride, (void*)(11 * sizeof(float))); + gl.VertexAttribDivisor(6, 1); + gl.EnableVertexAttribArray(7); + gl.VertexAttribPointer(7, 1, VertexAttribPointerType.Float, false, stride, (void*)(13 * sizeof(float))); + gl.VertexAttribDivisor(7, 1); + gl.BindVertexArray(0); + }); + + shader.Bind(); + shader.SetUniform("uTextureArray", 0); + shader.Unbind(); + } catch (Exception constructionFailure) { + resources.RollbackConstructionAndThrow( + "ParticleBatcher construction failed and its GL prefix did not cleanly roll back.", + constructionFailure); } - _ibo = gl.GenBuffer(); - gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _ibo); - unsafe { - fixed (ushort* p = indices) { - gl.BufferData(BufferTargetARB.ElementArrayBuffer, (uint)(indices.Length * sizeof(ushort)), p, BufferUsageARB.StaticDraw); - } - } + _resources = resources; + _shader = shader!; + _vao = vao; + _vbo = vbo; + _ibo = ibo; + _instanceVbo = instanceVbo; + } - // Quad attributes - gl.EnableVertexAttribArray(0); - gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 5 * sizeof(float), (void*)0); - gl.EnableVertexAttribArray(1); - gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, 5 * sizeof(float), (void*)(3 * sizeof(float))); + private static uint CreateBuffer( + ResourceCleanupGroup resources, + GL gl, + string name) { + uint buffer = GlResourceCommand.CreateName(gl, name, gl.GenBuffer, gl.DeleteBuffer); + resources.Add(name, () => GlResourceCommand.DeleteBuffer(gl, buffer, $"delete {name} {buffer}")); + return buffer; + } - // Instance attributes - _instanceVbo = gl.GenBuffer(); - gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo); - gl.BufferData(BufferTargetARB.ArrayBuffer, (uint)(MAX_PARTICLES_TOTAL * Marshal.SizeOf()), (void*)0, BufferUsageARB.DynamicDraw); - - uint stride = (uint)Marshal.SizeOf(); - - // iPosition - gl.EnableVertexAttribArray(2); - gl.VertexAttribPointer(2, 3, VertexAttribPointerType.Float, false, stride, (void*)0); - gl.VertexAttribDivisor(2, 1); - - // iScaleOpacityActive - gl.EnableVertexAttribArray(3); - gl.VertexAttribPointer(3, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float))); - gl.VertexAttribDivisor(3, 1); - - // iTextureIndex - gl.EnableVertexAttribArray(4); - gl.VertexAttribPointer(4, 1, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float))); - gl.VertexAttribDivisor(4, 1); - - // iRotation (Quaternion) - gl.EnableVertexAttribArray(5); - gl.VertexAttribPointer(5, 4, VertexAttribPointerType.Float, false, stride, (void*)(7 * sizeof(float))); - gl.VertexAttribDivisor(5, 1); - - // iSize - gl.EnableVertexAttribArray(6); - gl.VertexAttribPointer(6, 2, VertexAttribPointerType.Float, false, stride, (void*)(11 * sizeof(float))); - gl.VertexAttribDivisor(6, 1); - - // iIsBillboard - gl.EnableVertexAttribArray(7); - gl.VertexAttribPointer(7, 1, VertexAttribPointerType.Float, false, stride, (void*)(13 * sizeof(float))); - gl.VertexAttribDivisor(7, 1); - - gl.BindVertexArray(0); - - _shader.Bind(); - _shader.SetUniform("uTextureArray", 0); - _shader.Unbind(); + private static uint CreateVertexArray( + ResourceCleanupGroup resources, + GL gl, + string name) { + uint vao = GlResourceCommand.CreateName(gl, name, gl.GenVertexArray, gl.DeleteVertexArray); + resources.Add(name, () => GlResourceCommand.DeleteVertexArray(gl, vao, $"delete {name} {vao}")); + return vao; } public void Begin(Matrix4x4 viewProjection, Vector3 cameraUp, Vector3 cameraRight) { @@ -216,12 +234,7 @@ namespace AcDream.App.Rendering.Wb { } public void Dispose() { - var gl = _graphicsDevice.GL; - gl.DeleteVertexArray(_vao); - gl.DeleteBuffer(_vbo); - gl.DeleteBuffer(_instanceVbo); - gl.DeleteBuffer(_ibo); - (_shader as IDisposable)?.Dispose(); + _resources.RetryCleanup(); } } } diff --git a/src/AcDream.App/Rendering/Wb/TrackedGlResource.cs b/src/AcDream.App/Rendering/Wb/TrackedGlResource.cs index 14b43d3f..6a02e3fd 100644 --- a/src/AcDream.App/Rendering/Wb/TrackedGlResource.cs +++ b/src/AcDream.App/Rendering/Wb/TrackedGlResource.cs @@ -16,11 +16,22 @@ internal static unsafe class TrackedGlResource GL gl, uint buffer, long capacityBytes, + string context) => + CreateRetryableBufferDeletion( + gl, + buffer, + () => capacityBytes, + context); + + public static RetryableGpuResourceRelease CreateRetryableBufferDeletion( + GL gl, + uint buffer, + Func capacityBytes, string context) { if (buffer == 0) throw new ArgumentOutOfRangeException(nameof(buffer)); - ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes); + ArgumentNullException.ThrowIfNull(capacityBytes); return new RetryableGpuResourceRelease( () => GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"), () => @@ -34,8 +45,10 @@ internal static unsafe class TrackedGlResource }, () => { - if (capacityBytes != 0) - GpuMemoryTracker.TrackDeallocation(capacityBytes, GpuResourceType.Buffer); + long bytes = capacityBytes(); + ArgumentOutOfRangeException.ThrowIfNegative(bytes); + if (bytes != 0) + GpuMemoryTracker.TrackDeallocation(bytes, GpuResourceType.Buffer); }, () => GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer)); } diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index 6769d096..d2eb22b1 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -122,18 +122,48 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter _dats = dats; _resourceRetirement = resourceRetirement; - _graphicsDevice = new OpenGLGraphicsDevice( - gl, - logger, - new DebugRenderSettings(), - resourceRetirement); - _graphicsDevice.ParticleBatcher = new ParticleBatcher(_graphicsDevice); - // ConsoleErrorLogger surfaces WB's silently-caught exceptions - // (ObjectMeshManager.PrepareMeshData try/catch at line ~589). - _meshManager = new ObjectMeshManager( - _graphicsDevice, - dats, - new ConsoleErrorLogger()); + var resources = new AcDream.App.Rendering.ResourceCleanupGroup(); + OpenGLGraphicsDevice? graphicsDevice = null; + ObjectMeshManager? meshManager = null; + try + { + graphicsDevice = new OpenGLGraphicsDevice( + gl, + logger, + new DebugRenderSettings(), + resourceRetirement); + OpenGLGraphicsDevice ownedGraphicsDevice = graphicsDevice; + var graphicsDeviceRelease = new RetryableGpuResourceRelease( + ownedGraphicsDevice.Dispose, + () => + { + ownedGraphicsDevice.ProcessGLQueue(); + if (ownedGraphicsDevice.HasPendingGLWork) + { + throw new InvalidOperationException( + "WB graphics-device construction cleanup still has queued GL work."); + } + }); + resources.Add("WB graphics device", graphicsDeviceRelease.Run); + graphicsDevice.ParticleBatcher = new ParticleBatcher(graphicsDevice); + // ConsoleErrorLogger surfaces WB's silently-caught exceptions + // (ObjectMeshManager.PrepareMeshData try/catch at line ~589). + meshManager = new ObjectMeshManager( + graphicsDevice, + dats, + new ConsoleErrorLogger()); + resources.Add("WB object mesh manager", meshManager.Dispose); + resources.TransferAll(); + } + catch (Exception constructionFailure) + { + resources.RollbackConstructionAndThrow( + "WbMeshAdapter construction failed and its WB/GL prefix did not cleanly roll back.", + constructionFailure); + } + + _graphicsDevice = graphicsDevice; + _meshManager = meshManager; } /// diff --git a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs new file mode 100644 index 00000000..11e06dca --- /dev/null +++ b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs @@ -0,0 +1,361 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using AcDream.App.Composition; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using AcDream.App.World; +using AcDream.Content; +using AcDream.Core.Terrain; +using AcDream.UI.Abstractions.Settings; +using DatReaderWriter.DBObjs; +using Silk.NET.Input; +using Silk.NET.OpenGL; +using Shader = AcDream.App.Rendering.Shader; + +namespace AcDream.App.Tests.Composition; + +public sealed class WorldRenderCompositionTests +{ + [Fact] + public void SuccessPublishesExactModernFoundationInFrozenOrder() + { + var fixture = new Fixture(); + + WorldRenderResult result = fixture.Compose(); + + Assert.Equal(Enum.GetValues(), fixture.Points); + Assert.Same(fixture.Publication.Terrain, result.Foundation.Terrain); + Assert.Same(fixture.Publication.MeshAdapter, result.Foundation.MeshAdapter); + Assert.Same(fixture.Publication.TextureCache, result.Foundation.TextureCache); + Assert.Same(fixture.Lifetime.Atlas, result.Foundation.TerrainAtlas); + Assert.Equal(QualitySettings.From(QualityPreset.High).AnisotropicLevel, + fixture.Factory.AnisotropicLevel); + Assert.Equal(1, fixture.Lifetime.AcquireCalls); + Assert.Empty(fixture.Factory.Releases); + } + + [Fact] + public void MissingDebugFontSkipsOnlyTheOptionalHudPrefix() + { + var fixture = new Fixture(hasFont: false); + + WorldRenderResult result = fixture.Compose(); + + Assert.Null(result.Foundation.DebugFont); + Assert.Null(result.Foundation.TextRenderer); + Assert.DoesNotContain(WorldRenderCompositionPoint.DebugFontCreated, fixture.Points); + Assert.DoesNotContain(WorldRenderCompositionPoint.TextRendererCreated, fixture.Points); + Assert.DoesNotContain(WorldRenderCompositionPoint.HudResourcesPublished, fixture.Points); + Assert.Contains(WorldRenderCompositionPoint.HudResourcesCompleted, fixture.Points); + } + + [Theory] + [MemberData(nameof(FailurePoints))] + public void FaultAfterEachBoundaryStopsTheExactSuffix(int pointValue) + { + var point = (WorldRenderCompositionPoint)pointValue; + var fixture = new Fixture(failurePoint: point); + + Assert.Throws(fixture.Compose); + + Assert.Equal( + Enum.GetValues() + .TakeWhile(candidate => candidate <= point), + fixture.Points); + } + + public static TheoryData FailurePoints() + { + var data = new TheoryData(); + foreach (WorldRenderCompositionPoint point in + Enum.GetValues()) + { + data.Add((int)point); + } + return data; + } + + [Theory] + [InlineData("terrain shader", "terrain shader")] + [InlineData("scene lighting", "scene lighting")] + [InlineData("debug lines", "debug lines")] + [InlineData("HUD", "text renderer|debug font")] + [InlineData("terrain", "terrain")] + [InlineData("mesh shader", "mesh shader")] + [InlineData("WB mesh adapter", "WB mesh adapter")] + [InlineData("texture cache", "texture cache")] + [InlineData("sampler cache", "sampler cache")] + public void FailedPublicationRollsBackOnlyItsUnpublishedResourcePrefix( + string publication, + string expectedReleaseOrder) + { + var fixture = new Fixture(publicationFailure: publication); + + Assert.Throws(fixture.Compose); + + Assert.Equal( + expectedReleaseOrder.Split('|'), + fixture.Factory.Releases); + } + + [Fact] + public void PartialHudConstructionRollsBackFontWhenTextCreationFails() + { + var fixture = new Fixture( + failurePoint: WorldRenderCompositionPoint.DebugFontCreated); + + Assert.Throws(fixture.Compose); + + Assert.Equal(["debug font"], fixture.Factory.Releases); + } + + [Fact] + public void GameWindowUsesPhaseAndNoLongerBuildsWorldFoundationInline() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + Assert.Contains("new WorldRenderCompositionPhase(", source, + StringComparison.Ordinal); + Assert.DoesNotContain("BindlessSupport.TryCreate(_gl", source, + StringComparison.Ordinal); + Assert.DoesNotContain("_terrainModernShader = new Shader", source, + StringComparison.Ordinal); + Assert.DoesNotContain("_wbMeshAdapter = new", source, + StringComparison.Ordinal); + Assert.DoesNotContain("_textureCache = new TextureCache", source, + StringComparison.Ordinal); + } + + private sealed class Fixture + { + private readonly WorldRenderCompositionPoint? _failurePoint; + + public Fixture( + bool hasFont = true, + WorldRenderCompositionPoint? failurePoint = null, + string? publicationFailure = null) + { + _failurePoint = failurePoint; + Factory = new Factory(hasFont); + Publication = new Publication(publicationFailure); + Lifetime = new RenderLifetime(Factory.Atlas); + var content = (ContentEffectsAudioResult) + RuntimeHelpers.GetUninitializedObject(typeof(ContentEffectsAudioResult)); + Content = content; + } + + public Factory Factory { get; } + public Publication Publication { get; } + public RenderLifetime Lifetime { get; } + public List Points { get; } = []; + public ContentEffectsAudioResult Content { get; } + + public WorldRenderResult Compose() => + new WorldRenderCompositionPhase( + new WorldRenderDependencies( + new WorldEnvironmentController(), + Lifetime, + ImmediateGpuResourceRetirementQueue.Instance, + 0xA9B4FFFFu, + _ => { }), + Publication, + Factory, + point => + { + Points.Add(point); + if (point == _failurePoint) + throw new InvalidOperationException($"fault at {point}"); + }).Compose( + new GameWindowPlatformResult(null!, null!), + Content, + new SettingsDevToolsResult( + QualitySettings.From(QualityPreset.High), + null)); + } + + private sealed class RenderLifetime(TerrainAtlas atlas) + : IGameRenderResourceLifetime + { + public TerrainAtlas Atlas { get; } = atlas; + public int AcquireCalls { get; private set; } + + public TerrainAtlas AcquireTerrainAtlas(Func factory) + { + AcquireCalls++; + return Atlas; + } + } + + private sealed class Factory(bool hasFont) : IWorldRenderCompositionFactory + { + private readonly Dictionary _names = + new(ReferenceEqualityComparer.Instance); + + public TerrainAtlas Atlas { get; } = Stub(); + public int AnisotropicLevel { get; private set; } + public List Releases { get; } = []; + + public void InitializeGlState(GL gl) { } + + public WorldRegionData LoadRegion(IDatReaderWriter dats) => + new(Stub(), new float[256]); + + public void InitializeEnvironment( + WorldEnvironmentController environment, + Region region) { } + + public BindlessSupport RequireBindless(GL gl, Action log) => + Stub(); + + public TerrainAtlas AcquireTerrainAtlas( + IGameRenderResourceLifetime lifetime, + GL gl, + IDatReaderWriter dats, + BindlessSupport bindless) => + lifetime.AcquireTerrainAtlas(() => Atlas); + + public void SetTerrainAnisotropic(TerrainAtlas atlas, int level) => + AnisotropicLevel = level; + + public Shader CreateTerrainShader(GL gl, string shadersDirectory) => + Resource("terrain shader"); + + public SceneLightingUboBinding CreateSceneLighting(GL gl) => + Resource("scene lighting"); + + public DebugLineRenderer CreateDebugLines(GL gl, string shadersDirectory) => + Resource("debug lines"); + + public byte[]? TryLoadDebugFont() => hasFont ? [1] : null; + + public BitmapFont CreateDebugFont(GL gl, byte[] bytes) => + Resource("debug font"); + + public TextRenderer CreateTextRenderer(GL gl, string shadersDirectory) => + Resource("text renderer"); + + public TerrainModernRenderer CreateTerrain( + GL gl, + BindlessSupport bindless, + Shader shader, + TerrainAtlas atlas, + IGpuResourceRetirementQueue retirement) => + Resource("terrain"); + + public WorldTerrainBuildContext CreateTerrainBuildContext( + uint initialCenterLandblockId, + float[] heightTable, + TerrainAtlas atlas) => + new( + initialCenterLandblockId, + 0xA9, + 0xB4, + heightTable, + Stub(), + new ConcurrentDictionary()); + + public Shader CreateMeshShader(GL gl, string shadersDirectory) => + Resource("mesh shader"); + + public WbMeshAdapter CreateMeshAdapter( + GL gl, + IDatReaderWriter dats, + IGpuResourceRetirementQueue retirement) => + Resource("WB mesh adapter"); + + public TextureCache CreateTextureCache( + GL gl, + IDatReaderWriter dats, + BindlessSupport bindless, + IGpuResourceRetirementQueue retirement) => + Resource("texture cache"); + + public SamplerCache CreateSamplerCache(GL gl) => + Resource("sampler cache"); + + public void Release(IDisposable resource) + { + Releases.Add(_names[resource]); + } + + private T Resource(string name) + where T : class, IDisposable + { + T value = Stub(); + _names.Add(value, name); + return value; + } + } + + private sealed class Publication(string? failure) : IGameWindowWorldRenderPublication + { + public TerrainModernRenderer? Terrain { get; private set; } + public WbMeshAdapter? MeshAdapter { get; private set; } + public TextureCache? TextureCache { get; private set; } + + public void PublishBindlessSupport(BindlessSupport value) => + Fail("bindless"); + public void PublishTerrainShader(Shader value) => + Fail("terrain shader"); + public void PublishSceneLighting(SceneLightingUboBinding value) => + Fail("scene lighting"); + public void PublishDebugLines(DebugLineRenderer value) => + Fail("debug lines"); + public void PublishHudResources(BitmapFont font, TextRenderer text) => + Fail("HUD"); + + public void PublishTerrain(TerrainModernRenderer value) + { + Fail("terrain"); + Terrain = value; + } + + public void PublishTerrainBuildState( + float[] heightTable, + TerrainBlendingContext blending, + ConcurrentDictionary surfaceCache) => + Fail("terrain build state"); + public void PublishMeshShader(Shader value) => Fail("mesh shader"); + + public void PublishWbMeshAdapter(WbMeshAdapter value) + { + Fail("WB mesh adapter"); + MeshAdapter = value; + } + + public void PublishTextureCache(TextureCache value) + { + Fail("texture cache"); + TextureCache = value; + } + + public void PublishSamplerCache(SamplerCache value) => + Fail("sampler cache"); + + private void Fail(string point) + { + if (string.Equals(failure, point, StringComparison.Ordinal)) + throw new InvalidOperationException($"publication failed at {point}"); + } + } + + private static T Stub() where T : class => + (T)RuntimeHelpers.GetUninitializedObject(typeof(T)); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 1286bc70..e0199082 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -120,12 +120,30 @@ public sealed class GameWindowSlice8BoundaryTests string sessionFactory = MethodBody( "private AcDream.App.Net.LiveSessionEventRouter CreateLiveSessionEventRouter(", "private AcDream.App.Net.LiveInventorySessionBindings"); + string worldPhase = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "WorldRenderComposition.cs")); Assert.Contains( "private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment =", source, StringComparison.Ordinal); - Assert.Contains("_worldEnvironment.Initialize(region!);", load, StringComparison.Ordinal); + Assert.Contains( + "new WorldRenderDependencies(\n _worldEnvironment,", + load.Replace("\r\n", "\n", StringComparison.Ordinal), + StringComparison.Ordinal); + string worldCompose = Slice( + worldPhase, + "public WorldRenderResult Compose(", + "private (BitmapFont? Font, TextRenderer? Text) ComposeOptionalHudResources("); + AssertAppearsInOrder( + worldCompose, + "WorldRegionData region = _factory.LoadRegion(content.Dats);", + "_factory.InitializeEnvironment(_dependencies.Environment, region.Region);"); + Assert.Contains("environment.Initialize(region);", worldPhase, StringComparison.Ordinal); AssertAppearsInOrder( sessionFactory, "new AcDream.App.Net.LiveEnvironmentSessionSink(", @@ -278,7 +296,7 @@ public sealed class GameWindowSlice8BoundaryTests load, "new SettingsDevToolsCompositionPhase(", "this).Compose(platform, hostInputCamera, contentEffectsAudio);", - "TerrainAtlas.Build(", + "new WorldRenderCompositionPhase(", "_runtimeSettings.BindRuntimeTargets(", "_liveSessionHost.Start(_options)"); string settingsPhase = File.ReadAllText(Path.Combine( @@ -291,6 +309,17 @@ public sealed class GameWindowSlice8BoundaryTests settingsPhase, "_dependencies.Settings.ApplyStartup(_dependencies.StartupTarget);", "_dependencies.DevTools is { } optional"); + string worldPhase = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "WorldRenderComposition.cs")); + AssertAppearsInOrder( + worldPhase, + "TerrainAtlas.Build(gl, dats, bindless)", + "settings.ResolvedQuality.AnisotropicLevel", + "_factory.CreateTerrain("); AssertAppearsInOrder( shutdown, "_runtimeSettings.UnbindViewModel()", @@ -440,17 +469,28 @@ public sealed class GameWindowSlice8BoundaryTests "AcDream.App", "Rendering", "TerrainAtlas.cs")); + string worldPhase = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "WorldRenderComposition.cs")); AssertAppearsInOrder( load, - "_renderResourceLifetime.AcquireTerrainAtlas(", - "_terrain = new TerrainModernRenderer(", + "new WorldRenderCompositionPhase(", "_portalTunnelFallback.AcquirePrepared(", "static portalTunnel => portalTunnel.PrepareResources());", "_renderResourceLifetime.AcquireSkyShader(", "_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(", "_portalTunnelFallback.Transfer(", "_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);"); + AssertAppearsInOrder( + worldPhase, + "lifetime.AcquireTerrainAtlas(", + "TerrainAtlas.Build(gl, dats, bindless)", + "TerrainModernRenderer CreateTerrain(", + "TerrainModernRenderer terrain = AcquireAndPublish("); AssertAppearsInOrder( shutdown, "_frameGraphs.Withdraw();", diff --git a/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs b/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs index d52c1352..0efce3e4 100644 --- a/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs @@ -29,6 +29,107 @@ public sealed class ResourceCleanupGroupTests Assert.Equal(["last", "middle", "first", "middle"], calls); } + [Fact] + public void TransferAllLeavesPublishedResourcesUntouched() + { + int releaseCalls = 0; + var resources = new ResourceCleanupGroup(); + resources.Add("published", () => releaseCalls++); + + resources.TransferAll(); + resources.RetryCleanup(); + + Assert.True(resources.IsCleanupComplete); + Assert.Equal(0, releaseCalls); + Assert.Throws(() => + resources.Add("late", () => { })); + } + + [Fact] + public void FailedConstructionRollbackRetainsOnlyUnreleasedOwnership() + { + int releases = 0; + bool releaseFails = true; + var resources = new ResourceCleanupGroup(); + resources.Add("retryable", () => + { + releases++; + if (releaseFails) + throw new InvalidOperationException("release failed"); + }); + + GlResourceConstructionException failure = + Assert.Throws(() => + resources.RollbackConstructionAndThrow( + "construction failed", + new InvalidOperationException("original failure"))); + + Assert.False(failure.IsCleanupComplete); + Assert.Equal(1, releases); + releaseFails = false; + failure.RetryCleanup(); + failure.RetryCleanup(); + + Assert.True(failure.IsCleanupComplete); + Assert.Equal(2, releases); + } + + [Fact] + public void WorldRenderFoundationConstructorsPublishEveryOwnedPrefix() + { + string root = FindRepoRoot(); + string graphicsDevice = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Rendering", + "Wb", + "OpenGLGraphicsDevice.cs")); + string shader = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Rendering", + "Wb", + "GLSLShader.cs")); + string uniformBuffer = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Rendering", + "Wb", + "ManagedGLUniformBuffer.cs")); + + AssertAppearsInOrder( + graphicsDevice, + "var resources = new ResourceCleanupGroup();", + "InstanceVBO = CreateConstructionBuffer(resources", + "WrapSampler = CreateConstructionSampler(", + "ClampSampler = CreateConstructionSampler(", + "_sceneDataBuffer = new ManagedGLUniformBuffer(", + "resources.Add(", + "\"WB scene-data uniform buffer\"", + "InitializeSharedDebugResources(resources);", + "resources.TransferAll();", + "resources.RollbackConstructionAndThrow("); + AssertAppearsInOrder( + shader, + "ShaderProgramConstruction.Build(", + "resources.Add(\"WB shader program\"", + "configure shader {Name} SceneData binding", + "resources.TransferAll();", + "resources.RollbackConstructionAndThrow("); + AssertAppearsInOrder( + uniformBuffer, + "TrackedGlResource.CreateBuffer(", + "resources.Add(", + "TrackedGlResource.AllocateBufferStorage(", + "resources.TransferAll();", + "resources.RollbackConstructionAndThrow("); + Assert.Contains("internal void DisposeImmediately()", uniformBuffer, + StringComparison.Ordinal); + } + [Fact] public void TextRendererPublishesEveryConstructorResourceBeforeLaterGlWork() {