V6i-2 cut IMeshPipelineDevice at the measured surface and proved the mesh
pipeline could be CONSTRUCTED without naming a backend. It said plainly what it
did not claim: "the mesh pipeline does not RUN on Vulkan. Its upload bodies are
still raw GL — GlobalMeshBuffer, the VAO/IBO construction, the layer transfers."
This moves them, and gives the interface its second implementation.
GlobalMeshBuffer takes GL?. The two backing stores were already IGpuBuffer
(V4b); what still needed a context was the vertex array and the attribute
pointers, which have no RHI verb because Vulkan bakes vertex input into the
pipeline. So a backend with none builds the stores and nothing else, publishes
0 for VAO/VBO/IBO, and publishes VertexStore/IndexStore — the same buffers,
named the way a pass encoder binds them. HasStores is the backend-neutral form
of the VAO != 0 readiness test the raw-GL draw paths make. Two bodies fork on
the context and nothing else does: InitBuffers skips the vertex array, and
CommitMigration skips the rebind — on the encoder arm the field swap IS the
atomic publication, because the next pass reads whatever the field then holds.
The store deletion likewise splits: GL keeps its immediate DeleteRetired,
because the arena's own flight gate has already proven no submitted frame can
reference the store, while the other arm has no second deferral to skip and
Dispose is its retirement-queued release.
ObjectMeshManager's RequireGl narrowed to the LEGACY per-mesh upload. Its three
call sites were one modern-path constructor argument and two bodies whose every
GL statement sits inside `if (!_useModernRendering)`. The constructor now hands
the arena the nullable context; the two bodies resolve one lazily inside the
legacy branch. That branch is unreachable in every shipping configuration —
missing bindless or draw-parameters throws at startup under the N.5 ship
amendment — so the accessor survives as the guard on dead code rather than as a
blocker, and it is deleted with that code.
VulkanMeshPipelineDevice is the second implementation, and it is four
properties and two no-ops. Two things about it are worth stating rather than
leaving to be inferred. HasBindless and HasOpenGL43 answer TRUE: their names are
GL-shaped because the seam was cut from a GL device, but what they gate is the
MODERN path — one shared arena, table texture indexing, multi-draw indirect —
which Vulkan supplies unconditionally and the capability gate rejects a device
for lacking, so answering false would disable the only path that exists.
HasPendingWork answers false because the GL device's queue exists to defer work
onto the thread holding the context, and Vulkan resource work is recorded into
the frame's command buffer or routed through the retirement queue.
WbMeshAdapter selects between them once, in the one place the mesh pipeline
still names a backend. The GL arm is unchanged, including the queue-drain
guarantee its construction rollback asserts.
So composition builds the mesh pipeline on BOTH arms, and NullWbMeshAdapter is
deleted — it existed for exactly the gap this closes, and the landblock spawn
ledger now registers against the real adapter. Streaming's publication into GPU
state stops being a no-op there: the Vulkan run below builds real render data,
including the [up-null] zero-vertex caching path.
Gates. Release build green. App tests 4,112 passed / 3 skipped, against a 4,109
baseline plus the three added here. Strict GL offline pixel gate against
579e0b7f: 4.44e-05 (25 differing pixels of 563,200), inside the documented 9-31
px control band and 22x under the 0.001 threshold. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader (VK_LOADER_DEBUG=layer
reports `Insert instance layer "VK_LAYER_KHRONOS_validation"`): zero validation
errors, zero warnings, a captured frame, and no [shutdown] diagnostic on either
stream.
What this does NOT claim: nothing draws the world on Vulkan yet. The three
world renderers' submission arms, the two pass executors, and the pass-structure
merge are the next commit's.
No divergence-register row: no retail-facing behaviour changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
436 lines
16 KiB
C#
436 lines
16 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]
|
|
[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;
|
|
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<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, 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);
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6i-2: the arm a backend with no GL context takes.
|
|
/// Returns the same stub atlas through the same lifetime owner, so the
|
|
/// composition assertions do not care which arm ran.
|
|
/// </summary>
|
|
public TerrainAtlas AcquireBackendNeutralTerrainAtlas(
|
|
IGameRenderResourceLifetime lifetime,
|
|
IGpuDevice device,
|
|
IDatReaderWriter dats) =>
|
|
lifetime.AcquireTerrainAtlas(() => Atlas);
|
|
|
|
/// <summary>
|
|
/// Recorded rather than run: the exercise needs a real
|
|
/// <see cref="IGpuDevice"/> to create images through. Its behaviour is
|
|
/// covered by <c>RhiWorldTextureArrayTests</c> and by the Vulkan
|
|
/// composition-host run — see plan §5.5.13.
|
|
/// </summary>
|
|
public void ExerciseBackendNeutralWorldTextures(
|
|
IGpuDevice device,
|
|
Action<string> log) =>
|
|
WorldTextureExerciseCount++;
|
|
|
|
public int WorldTextureExerciseCount { get; private set; }
|
|
|
|
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(
|
|
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,
|
|
BindlessSupport bindless,
|
|
Shader shader,
|
|
TerrainAtlas atlas,
|
|
IGpuDevice gpuDevice,
|
|
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 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.");
|
|
}
|
|
}
|