TerrainModernRenderer records through IGpuPassEncoder instead of calling GL directly. V4d-1 already converged its two matrix uniforms; this is the plumbing. What moved. The per-frame indirect command array became an IGpuFrame.AllocateRing slice, which retires the three-deep per-frame-slot indirect buffer pool outright. That pool existed so a second terrain draw within one frame - a retail outside view can issue several - could not overwrite an earlier draw's still-pending commands; the frame ring gives that structurally, because every allocation within a frame is distinct memory that lives until the frame retires. DynamicIndirectBufferCount now reports 0, which is the truth rather than a silent change. The vertex and index arena became an IGpuBuffer pair. AddLandblock's two BufferSubData calls are Upload, and EnsureCapacity's grow-and-copy is IGpuBuffer.CopyTo, still device-side so resident landblock meshes never round-trip through system memory. The global VAO is gone: the pipeline owns one shaped by the vertex layout, and the encoder re-issues attribute pointers on every BindVertexBuffer. Locations 2-5 use GpuVertexFormat.UByte4UInt, added atc7f5f251for exactly this. They are uvec4 in the shader and carry terrain-type, road and split-direction codes; UByte4Normalized would have delivered [0,1] floats to an integer input, which GL leaves undefined - garbage, not an approximation. uTextureIndexA/uTextureIndexB became GpuPushConstants.TextureIndexA/B. Slice V2b named those uniforms to match the pinned block, so this was the rename it was meant to be. uTexTiling moved from a loose uniform float[36] into a std140 block at GpuBindingModel.UniformTerrainTiling: at 144 bytes of payload it cannot ride in the 96-byte push-constant block, and no RHI verb sets a uniform array. std140 pads each element to 16 bytes so the block is 576, but the element type is unchanged, so uTexTiling[int(layer)] reads exactly as before. It is a long-lived uniform buffer uploaded on the first draw, preserving the upload-once property the linked-program uniform had. The imperative Enable(CullFace)/CullFace(Back)/FrontFace(Ccw) triple and the inherited depth state are baked into one pipeline. Depth compare is GL_LESS, not the contract's LessOrEqual default: the world frame runs under GL_LESS (RenderFrameGlStateController.RestoreFrameDefaults) and terrain never called glDepthFunc, so it inherited it. Baking LessOrEqual would change which of two coplanar retail surfaces wins - visible exactly where terrain meets roads and building footings, which is what the shader's zFightTerrainAdjust nudge is about. Blend off, alpha-to-coverage off, colour write on and depth write on come from the same frame default, each checked against what terrain observes rather than assumed. GL_MULTISAMPLE is untouched by pipeline binds, so MSAA does not leak away from the still-raw-GL sky and particles. Deliberately unmoved. The terrain clip UBO at binding 2 and the SceneLighting UBO at binding 1 stay raw global binds - ClipFrame owns one and the viewport and portal renderers read the other, and both are raw GL until V4h (campaign doc 5.3). The interim GlBindlessHandleTable stays, now held as an IGpuBuffer and bound through the encoder at binding 9; retiring it is V4t. glMemoryBarrier stays a raw call: it has no RHI verb and was already a no-op against client-side uploads. The trailing FrontFace(CW)/Disable(CullFace) restore stays so sky and particles see what they see today. TerrainAtlas is untouched - it belongs to V4t. Terrain has no GPU timer to port; its diagnostics use a CPU stopwatch. Three consequences worth naming rather than leaving to be discovered. The convenience constructor narrowed from public to internal, because IGpuDevice and ICurrentGpuFrameSource are internal RHI types and a public constructor cannot name them. The class stays public, no other member changed visibility, and every caller was already in this assembly - EnvCellRenderer's constructor is internal for the same reason. That is the only visibility change in the diff. Terrain no longer needs a Shader composed for it, since its pipeline compiles terrain_modern from the same sources with the same shared preamble. That removes the terrain-shader composition step, its publication, its lifetime field and the WorldRenderCompositionPoint member. Two data-driven test cases went with it: one InlineData row naming "terrain shader" as a publication to fail, and one case from the theory that enumerates every composition point. App tests therefore read 3,844 rather than the 3,846 baseline. No invariant lost coverage - both theories still exercise every remaining resource and point; the two cases were parameterisations over a step that no longer exists. The renderer's own GpuRetirementLedger is gone. Every resource it held retryable releases for is an IGpuBuffer or IGpuPipeline now, and their Dispose already routes the physical free through the device's retirement queue. Only the fallback clip UBO is still a raw GL name, so it is all the dispose ledger carries. The slot allocator's separate retryable publication path is untouched. Also dropped: a dead BindlessSupport field, assigned and never read. Gates. Release build green with TreatWarningsAsErrors. App tests 3,844 passed / 3 skipped over four consecutive runs. Offline pixel gate against0cb10597: 20 differing pixels of 563,200 (fraction 3.55e-05, 28x under the threshold), against a same-commit control at this commit of 26 - the change differs from its parent by LESS than the capture differs from itself, which is as close to proof of no systematic shift as this gate can give. Compared against all three V4d-1 captures the numbers are 20, 32 and 34, against a same-commit V4d-1 spread of 8, 27 and 28: the same distribution. The gate run's client log has zero exceptions and an empty stderr. Coverage gap, stated rather than assumed: the offline gate's scene is a fixed outdoor view. It exercises terrain heavily - terrain blending, road overlays and the water edge are most of the frame - but it does not cover terrain seen through a doorway clip region, which is the one terrain path with its own binding (the clip UBO at binding 2). That wants a user visual check. No divergence-register row: this slice changes no retail-facing behaviour. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
410 lines
15 KiB
C#
410 lines
15 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Runtime.CompilerServices;
|
|
using AcDream.App.Composition;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.App.Rendering.Residency;
|
|
using AcDream.App.World;
|
|
using AcDream.App.Tests.Rendering.Gpu;
|
|
using AcDream.Content;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Terrain;
|
|
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()
|
|
{
|
|
ResidencyBudgetOptions budgets =
|
|
ResidencyBudgetOptions.Default with
|
|
{
|
|
ObjectMeshGpuBytes = 321,
|
|
CompositePhysicalBytes = 654,
|
|
};
|
|
var fixture = new Fixture(budgets: budgets);
|
|
|
|
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.Same(budgets, result.Foundation.Residency.Budgets);
|
|
Assert.Same(budgets, fixture.Factory.MeshBudgets);
|
|
Assert.Same(budgets, fixture.Factory.TextureBudgets);
|
|
Assert.Same(result.Foundation.Residency, fixture.Factory.RegisteredResidency);
|
|
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]
|
|
// The "terrain shader" row went with Campaign V slice V4d: terrain's
|
|
// IGpuPipeline compiles terrain_modern itself, so there is no longer a
|
|
// terrain-shader publication step for a failure to be injected into. The
|
|
// invariant this theory pins is unchanged and still covered by every row
|
|
// below.
|
|
[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;
|
|
private readonly ResidencyBudgetOptions _budgets;
|
|
private readonly IGpuDevice _gpuDevice = new RecordingGpuDevice();
|
|
|
|
public Fixture(
|
|
bool hasFont = true,
|
|
WorldRenderCompositionPoint? failurePoint = null,
|
|
string? publicationFailure = null,
|
|
ResidencyBudgetOptions? budgets = null)
|
|
{
|
|
_failurePoint = failurePoint;
|
|
_budgets = budgets ?? ResidencyBudgetOptions.Default;
|
|
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,
|
|
_budgets,
|
|
0xA9B4FFFFu,
|
|
Path.Combine(Path.GetTempPath(), "acdream-tests"),
|
|
_ => { },
|
|
_gpuDevice,
|
|
new GpuDeviceFrameLifetime(_gpuDevice)),
|
|
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 ResidencyBudgetOptions? MeshBudgets { get; private set; }
|
|
public ResidencyBudgetOptions? TextureBudgets { get; private set; }
|
|
public ResidencyManager? RegisteredResidency { get; private set; }
|
|
|
|
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 SceneLightingUboBinding CreateSceneLighting(GL gl) =>
|
|
Resource<SceneLightingUboBinding>("scene lighting");
|
|
|
|
public DebugLineRenderer CreateDebugLines(
|
|
IGpuDevice device, ICurrentGpuFrameSource frameSource, string shadersDirectory) =>
|
|
Resource<DebugLineRenderer>("debug lines");
|
|
|
|
public byte[]? TryLoadDebugFont() => hasFont ? [1] : null;
|
|
|
|
public BitmapFont CreateDebugFont(IGpuDevice device, byte[] bytes) =>
|
|
Resource<BitmapFont>("debug font");
|
|
|
|
public TextRenderer CreateTextRenderer(
|
|
IGpuDevice device, ICurrentGpuFrameSource frameSource, string shadersDirectory) =>
|
|
Resource<TextRenderer>("text renderer");
|
|
|
|
public TerrainModernRenderer CreateTerrain(
|
|
GL gl,
|
|
IGpuDevice device,
|
|
ICurrentGpuFrameSource frameSource,
|
|
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,
|
|
IGpuDevice device,
|
|
IDatReaderWriter dats,
|
|
IPreparedAssetSource preparedAssets,
|
|
IGpuResourceRetirementQueue retirement,
|
|
ResidencyBudgetOptions budgets)
|
|
{
|
|
MeshBudgets = budgets;
|
|
return Resource<WbMeshAdapter>("WB mesh adapter");
|
|
}
|
|
|
|
public TextureCache CreateTextureCache(
|
|
GL gl,
|
|
IGpuDevice device,
|
|
IDatReaderWriter dats,
|
|
BindlessSupport bindless,
|
|
IGpuResourceRetirementQueue retirement,
|
|
string diagnosticsDirectory,
|
|
ResidencyBudgetOptions budgets)
|
|
{
|
|
TextureBudgets = budgets;
|
|
return Resource<TextureCache>("texture cache");
|
|
}
|
|
|
|
public void RegisterResidencySources(
|
|
ResidencyManager manager,
|
|
WbMeshAdapter meshes,
|
|
TextureCache textures,
|
|
IPreparedAssetSource preparedAssets,
|
|
IAnimationLoader animations,
|
|
AcDream.Core.Audio.DatSoundCache? audio)
|
|
{
|
|
RegisteredResidency = manager;
|
|
}
|
|
|
|
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 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.");
|
|
}
|
|
}
|