refactor(app): compose world rendering startup

Move Region/environment, mandatory modern rendering, terrain, WB, texture, and sampler construction behind the typed Phase-4 composition boundary. Give every fallible GL constructor prefix retryable ownership so partial startup failure cannot leak or replay resource deletion while preserving the accepted render path and DAT inputs.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 16:46:05 +02:00
parent cd7b519f78
commit 6a2fe98cc4
22 changed files with 1983 additions and 634 deletions

View file

@ -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 IL 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.1I.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.6L 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

View file

@ -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.1I.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

View file

@ -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<uint, SurfaceInfo> 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<string> 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<uint, SurfaceInfo> 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<string> 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<Region>(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<string> 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<uint, byte>(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<uint, SurfaceInfo>());
}
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<WbMeshAdapter>.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,
}
/// <summary>
/// Production Phase 4. It preserves the mandatory modern renderer and sole
/// DAT path while moving construction into an ordered, failure-injectable
/// ownership boundary.
/// </summary>
internal sealed class WorldRenderCompositionPhase
: IWorldRenderCompositionPhase<
GameWindowPlatformResult<GL, IInputContext>,
ContentEffectsAudioResult,
SettingsDevToolsResult,
WorldRenderResult>
{
private readonly WorldRenderDependencies _dependencies;
private readonly IGameWindowWorldRenderPublication _publication;
private readonly IWorldRenderCompositionFactory _factory;
private readonly Action<WorldRenderCompositionPoint>? _faultInjection;
public WorldRenderCompositionPhase(
WorldRenderDependencies dependencies,
IGameWindowWorldRenderPublication publication,
IWorldRenderCompositionFactory? factory = null,
Action<WorldRenderCompositionPoint>? 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<GL, IInputContext> 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<T>(
CompositionAcquisitionScope scope,
string name,
Func<T> factory,
Action<T> 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);
}

View file

@ -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();
}
/// <summary>

View file

@ -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<float> _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;
}
/// <summary>Clear accumulated lines. Call at the start of each frame.</summary>
@ -165,8 +208,6 @@ public sealed unsafe class DebugLineRenderer : IDisposable
public void Dispose()
{
_gl.DeleteVertexArray(_vao);
_gl.DeleteBuffer(_vbo);
_shader.Dispose();
_resources.RetryCleanup();
}
}

View file

@ -1,10 +1,15 @@
namespace AcDream.App.Rendering;
internal interface IGameRenderResourceLifetime
{
TerrainAtlas AcquireTerrainAtlas(Func<TerrainAtlas> factory);
}
/// <summary>
/// Sole lifetime owner for render resources that are borrowed by, but not
/// owned by, their renderers.
/// </summary>
internal sealed class GameRenderResourceLifetime
internal sealed class GameRenderResourceLifetime : IGameRenderResourceLifetime
{
private readonly OwnedResourceSlot<TerrainAtlas> _terrainAtlas = new();
private readonly OwnedResourceSlot<Shader> _skyShader = new();

View file

@ -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<GL, IInputContext>,
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<T>(
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<DatReaderWriter.DBObjs.Region>(
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<uint, byte>(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<uint, AcDream.Core.Terrain.SurfaceInfo>();
// (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<AcDream.App.Rendering.Wb.WbMeshAdapter>.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

View file

@ -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<Exception>? 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();
}
}
/// <summary>

View file

@ -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;
}
/// <summary>
@ -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;
}
/// <summary>
@ -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();
}
}

View file

@ -1,5 +1,7 @@
namespace AcDream.App.Rendering;
using System.Runtime.ExceptionServices;
/// <summary>
/// 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.");
}
}

View file

@ -31,6 +31,7 @@ namespace AcDream.App.Rendering;
public sealed class SamplerCache : IDisposable
{
private readonly GL _gl;
private readonly ResourceCleanupGroup _resources;
/// <summary>Sampler with WrapS = WrapT = Repeat. The default for textures uploaded by <see cref="TextureCache"/>.</summary>
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();
}
}

View file

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

View file

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

View file

@ -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();

View file

@ -28,30 +28,44 @@ namespace AcDream.App.Rendering.Wb {
/// <param name="usage">Buffer usage</param>
/// <param name="size">The size of the buffer, in bytes</param>
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;
}
/// <inheritdoc />
@ -139,5 +153,25 @@ namespace AcDream.App.Rendering.Wb {
}
});
}
/// <summary>
/// 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.
/// </summary>
internal void DisposeImmediately() {
if (bufferId == 0)
return;
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
GL,
bufferId,
Size,
"rolling back unpublished managed uniform buffer");
release.Run();
bufferId = 0;
}
}
}

View file

@ -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<SceneData>());
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<SceneData>());
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<LineInstance>() - 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) {

View file

@ -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<ParticleRenderData> _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<ParticleInstance>()), (void*)0, BufferUsageARB.DynamicDraw);
uint stride = (uint)Marshal.SizeOf<ParticleInstance>();
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<ParticleInstance>()), (void*)0, BufferUsageARB.DynamicDraw);
uint stride = (uint)Marshal.SizeOf<ParticleInstance>();
// 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();
}
}
}

View file

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

View file

@ -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<ObjectMeshManager>());
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<ObjectMeshManager>());
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;
}
/// <summary>

View file

@ -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<WorldRenderCompositionPoint>(), 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<InvalidOperationException>(fixture.Compose);
Assert.Equal(
Enum.GetValues<WorldRenderCompositionPoint>()
.TakeWhile(candidate => candidate <= point),
fixture.Points);
}
public static TheoryData<int> FailurePoints()
{
var data = new TheoryData<int>();
foreach (WorldRenderCompositionPoint point in
Enum.GetValues<WorldRenderCompositionPoint>())
{
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<InvalidOperationException>(fixture.Compose);
Assert.Equal(
expectedReleaseOrder.Split('|'),
fixture.Factory.Releases);
}
[Fact]
public void PartialHudConstructionRollsBackFontWhenTextCreationFails()
{
var fixture = new Fixture(
failurePoint: WorldRenderCompositionPoint.DebugFontCreated);
Assert.Throws<InvalidOperationException>(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<WorldRenderCompositionPoint> 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<GL, IInputContext>(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<TerrainAtlas> factory)
{
AcquireCalls++;
return Atlas;
}
}
private sealed class Factory(bool hasFont) : IWorldRenderCompositionFactory
{
private readonly Dictionary<IDisposable, string> _names =
new(ReferenceEqualityComparer.Instance);
public TerrainAtlas Atlas { get; } = Stub<TerrainAtlas>();
public int AnisotropicLevel { get; private set; }
public List<string> Releases { get; } = [];
public void InitializeGlState(GL gl) { }
public WorldRegionData LoadRegion(IDatReaderWriter dats) =>
new(Stub<Region>(), new float[256]);
public void InitializeEnvironment(
WorldEnvironmentController environment,
Region region) { }
public BindlessSupport RequireBindless(GL gl, Action<string> log) =>
Stub<BindlessSupport>();
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<Shader>("terrain shader");
public SceneLightingUboBinding CreateSceneLighting(GL gl) =>
Resource<SceneLightingUboBinding>("scene lighting");
public DebugLineRenderer CreateDebugLines(GL gl, string shadersDirectory) =>
Resource<DebugLineRenderer>("debug lines");
public byte[]? TryLoadDebugFont() => hasFont ? [1] : null;
public BitmapFont CreateDebugFont(GL gl, byte[] bytes) =>
Resource<BitmapFont>("debug font");
public TextRenderer CreateTextRenderer(GL gl, string shadersDirectory) =>
Resource<TextRenderer>("text renderer");
public TerrainModernRenderer CreateTerrain(
GL gl,
BindlessSupport bindless,
Shader shader,
TerrainAtlas atlas,
IGpuResourceRetirementQueue retirement) =>
Resource<TerrainModernRenderer>("terrain");
public WorldTerrainBuildContext CreateTerrainBuildContext(
uint initialCenterLandblockId,
float[] heightTable,
TerrainAtlas atlas) =>
new(
initialCenterLandblockId,
0xA9,
0xB4,
heightTable,
Stub<TerrainBlendingContext>(),
new ConcurrentDictionary<uint, SurfaceInfo>());
public Shader CreateMeshShader(GL gl, string shadersDirectory) =>
Resource<Shader>("mesh shader");
public WbMeshAdapter CreateMeshAdapter(
GL gl,
IDatReaderWriter dats,
IGpuResourceRetirementQueue retirement) =>
Resource<WbMeshAdapter>("WB mesh adapter");
public TextureCache CreateTextureCache(
GL gl,
IDatReaderWriter dats,
BindlessSupport bindless,
IGpuResourceRetirementQueue retirement) =>
Resource<TextureCache>("texture cache");
public SamplerCache CreateSamplerCache(GL gl) =>
Resource<SamplerCache>("sampler cache");
public void Release(IDisposable resource)
{
Releases.Add(_names[resource]);
}
private T Resource<T>(string name)
where T : class, IDisposable
{
T value = Stub<T>();
_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<uint, SurfaceInfo> 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<T>() 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.");
}
}

View file

@ -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();",

View file

@ -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<InvalidOperationException>(() =>
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<GlResourceConstructionException>(() =>
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()
{