feat(render): Vulkan campaign V11 step 2 — delete the OpenGL backend
Vulkan is the sole, user-signed-off backend (V10 landed) and step 1 already removed ImGui/Studio/DevTools. This step deletes the GL rendering backend itself: every Gpu/Gl/** implementation, the Wb ManagedGL*/GLHelpers/GLSLShader/GLStateScope/RenderStateCache/ BindlessSupport family, Shader/ShaderProgramConstruction/SamplerCache, RenderBootstrap, and RenderFrameGlStateController. GameWindow.cs's Run()/CreateGraphics()/CreateBackbufferReader()/ OnLoad() collapse to their Vulkan-only arm; GameWindowGraphics loses its OpenGlGameWindowGraphics subclass. RuntimeOptions.RenderBackend and RenderBackendKind (incl. the Gl member of GpuBackendKind) are gone — there is nothing left to select between. The five world-draw dual-arm renderers (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer, ParticleRenderer, SkyRenderer) and the composition roots (WorldRenderComposition, HostInputCameraComposition, LivePresentationComposition, FrameRootComposition) collapse to their RHI-only arm. GL-only diagnostic properties with a live external reader (DynamicBufferCount and friends) simplify to a documented `=> 0`/no-op rather than disappearing, since the reader is out of this commit's scope. A few GL-flavored mechanisms turned out to be backend-neutral once isolated: GlConstructionCleanupLedger is renamed ResourceConstructionCleanupLedger (exception-chain walking has nothing to do with GL), and GlfwNativePlatformProbe moved out of the otherwise GL-only GraphicalCapabilityRecord.cs into GraphicalWindowBackendSelection.cs before the rest of that file was deleted. Test files with no surviving subject are deleted outright (GraphicalCapabilityRequirementsTests, ShaderProgramConstructionTests, PortalDepthShaderParityTests, TextureCacheBindlessTests, TextRendererFailureSafetyTests, ClipFrameUploadTests, every Gpu/Gl/*Tests, GlTextureOwnershipTests, RenderFrameGlStateControllerTests); others get their dead GL-only members trimmed while their live assertions stay (ClipFrameLayoutTests' MeshClipSsboBinding check now reads GpuBindingModel.StorageClipRegions, the same binding index under its new backend-neutral name; GpuResourceRetirementTransactionTests drops its OpenGLGraphicsDevice-subclassing test double and the two GL queue tests it existed for). EnvCellRendererTests' construction helper now builds a real ObjectMeshManager via VulkanMeshPipelineDevice instead of passing null through a null-forgiving operator, since the RHI constructor never tolerated a null mesh manager and the old GL constructor (which did) is gone. Deferred to the next two steps, deliberately not touched here: the Silk.NET.OpenGL/.Extensions.ARB package references, IMeshPipelineDevice.Gl (WbMeshAdapter's GL? threading stays in place), Chorizite.Core's stale csproj comment (the package itself is still load-bearing — TextureFormat and friends are used well beyond the deleted ManagedGLUniformBuffer), and the CI/gate scripts. Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors. Tests: full-solution `dotnet test` green across every project (App.Tests 3937/3940 + 3 skips, Core.Tests 3296/3298 + 2 skips, all others 100%); the 2 App.Tests names that flake under full-suite parallel execution (#250-family, documented pre-existing) pass in isolation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b70b9832ff
commit
8a7a0837e1
121 changed files with 1243 additions and 19840 deletions
|
|
@ -1,4 +1,4 @@
|
|||
using System.Reflection;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using AcDream.App.Audio;
|
||||
using AcDream.App.Composition;
|
||||
|
|
@ -19,7 +19,6 @@ using AcDream.Runtime.Gameplay;
|
|||
using DatReaderWriter.DBObjs;
|
||||
using Silk.NET.Input;
|
||||
using Silk.NET.OpenAL;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Tests.Composition;
|
||||
|
||||
|
|
@ -253,7 +252,7 @@ public sealed class ContentEffectsAudioCompositionTests
|
|||
Poses = new EntityEffectPoseRegistry();
|
||||
Factory = new Factory();
|
||||
Publication = new Publication();
|
||||
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, null!);
|
||||
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.Instance, null!);
|
||||
Host = (HostInputCameraResult)RuntimeHelpers.GetUninitializedObject(
|
||||
typeof(HostInputCameraResult));
|
||||
Dependencies = new ContentEffectsAudioDependencies(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using System.Numerics;
|
||||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using AcDream.App.Composition;
|
||||
using AcDream.App.Input;
|
||||
|
|
@ -8,7 +8,6 @@ using AcDream.App.Tests.Rendering.Gpu;
|
|||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
using Silk.NET.Maths;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Tests.Composition;
|
||||
|
||||
|
|
@ -95,7 +94,7 @@ public sealed class HostInputCameraCompositionTests
|
|||
IKeyboard keyboard = DispatchProxy.Create<IKeyboard, NullDeviceProxy>();
|
||||
IMouse mouse = DispatchProxy.Create<IMouse, NullDeviceProxy>();
|
||||
Input = new InputContext(keyboard, mouse);
|
||||
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, Input);
|
||||
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.Instance, Input);
|
||||
ViewportAspect = new ViewportAspectState();
|
||||
Framebuffer = new FramebufferResizeController(ViewportAspect);
|
||||
Capture = new CaptureSource();
|
||||
|
|
|
|||
|
|
@ -1,61 +1,30 @@
|
|||
using AcDream.App;
|
||||
using AcDream.App.Composition;
|
||||
using Silk.NET.Core.Contexts;
|
||||
using Silk.NET.OpenGL;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
|
||||
namespace AcDream.App.Tests.Composition;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6h: the graphics handle composition tests hand to a phase.
|
||||
/// The graphics handle composition tests hand to a phase.
|
||||
///
|
||||
/// <para>The phases now select their backend arm from
|
||||
/// <see cref="GameWindowGraphics"/> rather than from a bare <c>GL</c> reference,
|
||||
/// so a test that means "compose the OpenGL arm" has to say so. The GL instance
|
||||
/// is never called: every composition test supplies a stub factory that ignores
|
||||
/// its context argument, and the loader below would fault if anything did — which
|
||||
/// is the point. It is a token identifying the arm, not a driver.</para>
|
||||
/// <para>Campaign V slice V11 deleted the raw-GL arm — <c>GameWindowGraphics</c>
|
||||
/// no longer has a <c>Backend</c> or <c>Gl</c> member to select between, so this
|
||||
/// is now a single Vulkan-shaped token: a real <see cref="VulkanWorldPassScope"/>
|
||||
/// (composition phases require one, and it needs no live surface — just a
|
||||
/// sample count), no live <see cref="VulkanGraphicsContext"/> because tests never
|
||||
/// dereference it.</para>
|
||||
/// </summary>
|
||||
internal sealed class TestGameWindowGraphics : GameWindowGraphics
|
||||
{
|
||||
private readonly GL? _gl;
|
||||
public static TestGameWindowGraphics Instance { get; } = new();
|
||||
|
||||
private TestGameWindowGraphics(RenderBackendKind backend, GL? gl)
|
||||
private TestGameWindowGraphics()
|
||||
{
|
||||
Backend = backend;
|
||||
_gl = gl;
|
||||
}
|
||||
|
||||
/// <summary>Selects the OpenGL arm, with a context token no test dereferences.</summary>
|
||||
public static TestGameWindowGraphics OpenGl { get; } =
|
||||
new(RenderBackendKind.Gl, new GL(new UnusableNativeContext()));
|
||||
|
||||
/// <summary>Selects the Vulkan arm: no GL context exists.</summary>
|
||||
public static TestGameWindowGraphics Vulkan { get; } =
|
||||
new(RenderBackendKind.Vulkan, null);
|
||||
|
||||
public override RenderBackendKind Backend { get; }
|
||||
|
||||
public override GL? Gl => _gl;
|
||||
public override IWorldPassScope? WorldPassScope { get; } = new VulkanWorldPassScope(sampleCount: 1);
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class UnusableNativeContext : INativeContext
|
||||
{
|
||||
public nint GetProcAddress(string proc, int? slot = null) =>
|
||||
throw new InvalidOperationException(
|
||||
$"A composition test called GL entry point '{proc}'. " +
|
||||
"Test graphics are a backend token, not a driver.");
|
||||
|
||||
public bool TryGetProcAddress(string proc, out nint addr, int? slot = null)
|
||||
{
|
||||
addr = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
using AcDream.App.Composition;
|
||||
using AcDream.App.Rendering;
|
||||
|
|
@ -14,7 +14,6 @@ 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;
|
||||
|
||||
|
|
@ -90,15 +89,12 @@ public sealed class WorldRenderCompositionTests
|
|||
}
|
||||
|
||||
[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)
|
||||
|
|
@ -193,7 +189,7 @@ public sealed class WorldRenderCompositionTests
|
|||
if (point == _failurePoint)
|
||||
throw new InvalidOperationException($"fault at {point}");
|
||||
}).Compose(
|
||||
new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, null!),
|
||||
new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.Instance, null!),
|
||||
Content,
|
||||
new SettingsDevToolsResult(
|
||||
QualitySettings.From(QualityPreset.High)));
|
||||
|
|
@ -224,8 +220,6 @@ public sealed class WorldRenderCompositionTests
|
|||
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]);
|
||||
|
||||
|
|
@ -233,16 +227,6 @@ public sealed class WorldRenderCompositionTests
|
|||
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
|
||||
|
|
@ -258,7 +242,7 @@ public sealed class WorldRenderCompositionTests
|
|||
/// 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.
|
||||
/// composition-host run  see plan §5.5.13.
|
||||
/// </summary>
|
||||
public void ExerciseBackendNeutralWorldTextures(
|
||||
IGpuDevice device,
|
||||
|
|
@ -270,12 +254,6 @@ public sealed class WorldRenderCompositionTests
|
|||
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 SceneLightingUboBinding CreateBackendNeutralSceneLighting(
|
||||
ICurrentGpuFrameSource frameSource,
|
||||
IWorldPassScope scope) =>
|
||||
|
|
@ -294,15 +272,6 @@ public sealed class WorldRenderCompositionTests
|
|||
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 TerrainModernRenderer CreateBackendNeutralTerrain(
|
||||
IGpuDevice gpuDevice,
|
||||
ICurrentGpuFrameSource frameSource,
|
||||
|
|
@ -323,9 +292,6 @@ public sealed class WorldRenderCompositionTests
|
|||
Stub<TerrainBlendingContext>(),
|
||||
new ConcurrentDictionary<uint, SurfaceInfo>());
|
||||
|
||||
public Shader CreateMeshShader(GL gl, string shadersDirectory) =>
|
||||
Resource<Shader>("mesh shader");
|
||||
|
||||
public WbMeshAdapter CreateMeshAdapter(
|
||||
GL? gl,
|
||||
IGpuDevice device,
|
||||
|
|
@ -339,10 +305,8 @@ public sealed class WorldRenderCompositionTests
|
|||
}
|
||||
|
||||
public TextureCache CreateTextureCache(
|
||||
GL gl,
|
||||
IGpuDevice device,
|
||||
IDatReaderWriter dats,
|
||||
BindlessSupport bindless,
|
||||
IGpuResourceRetirementQueue retirement,
|
||||
string diagnosticsDirectory,
|
||||
ResidencyBudgetOptions budgets)
|
||||
|
|
@ -362,9 +326,6 @@ public sealed class WorldRenderCompositionTests
|
|||
RegisteredResidency = manager;
|
||||
}
|
||||
|
||||
public SamplerCache CreateSamplerCache(GL gl) =>
|
||||
Resource<SamplerCache>("sampler cache");
|
||||
|
||||
public void Release(IDisposable resource)
|
||||
{
|
||||
Releases.Add(_names[resource]);
|
||||
|
|
@ -385,10 +346,6 @@ public sealed class WorldRenderCompositionTests
|
|||
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) =>
|
||||
|
|
@ -407,7 +364,6 @@ public sealed class WorldRenderCompositionTests
|
|||
TerrainBlendingContext blending,
|
||||
ConcurrentDictionary<uint, SurfaceInfo> surfaceCache) =>
|
||||
Fail("terrain build state");
|
||||
public void PublishMeshShader(Shader value) => Fail("mesh shader");
|
||||
|
||||
public void PublishWbMeshAdapter(WbMeshAdapter value)
|
||||
{
|
||||
|
|
@ -421,9 +377,6 @@ public sealed class WorldRenderCompositionTests
|
|||
TextureCache = value;
|
||||
}
|
||||
|
||||
public void PublishSamplerCache(SamplerCache value) =>
|
||||
Fail("sampler cache");
|
||||
|
||||
private void Fail(string point)
|
||||
{
|
||||
if (string.Equals(failure, point, StringComparison.Ordinal))
|
||||
|
|
|
|||
|
|
@ -1,261 +0,0 @@
|
|||
using System.Text.Json;
|
||||
using AcDream.App.Platform;
|
||||
|
||||
namespace AcDream.App.Tests.Platform;
|
||||
|
||||
public sealed class GraphicalCapabilityRequirementsTests
|
||||
{
|
||||
[Fact]
|
||||
public void SupportedModernContextPasses()
|
||||
{
|
||||
GraphicalCapabilityRecord capabilities = CreateSupported();
|
||||
|
||||
Assert.Empty(GraphicalCapabilityRequirements.Evaluate(capabilities));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("bindless")]
|
||||
[InlineData("draw-parameters")]
|
||||
[InlineData("mdi")]
|
||||
[InlineData("ssbo")]
|
||||
[InlineData("timer")]
|
||||
[InlineData("depth")]
|
||||
[InlineData("stencil")]
|
||||
[InlineData("srgb")]
|
||||
[InlineData("keyboard")]
|
||||
[InlineData("mouse")]
|
||||
public void MissingMandatoryCapabilityIsRejected(string missing)
|
||||
{
|
||||
GraphicalCapabilityRecord capabilities = CreateSupported();
|
||||
capabilities = missing switch
|
||||
{
|
||||
"bindless" => capabilities with
|
||||
{
|
||||
HasBindlessTexture = false,
|
||||
},
|
||||
"draw-parameters" => capabilities with
|
||||
{
|
||||
HasShaderDrawParameters = false,
|
||||
},
|
||||
"mdi" => capabilities with
|
||||
{
|
||||
HasMultiDrawIndirect = false,
|
||||
},
|
||||
"ssbo" => capabilities with
|
||||
{
|
||||
HasShaderStorageBuffer = false,
|
||||
},
|
||||
"timer" => capabilities with
|
||||
{
|
||||
HasTimerQuery = false,
|
||||
},
|
||||
"depth" => capabilities with
|
||||
{
|
||||
Framebuffer = capabilities.Framebuffer with
|
||||
{
|
||||
DepthBits = 16,
|
||||
},
|
||||
},
|
||||
"stencil" => capabilities with
|
||||
{
|
||||
Framebuffer = capabilities.Framebuffer with
|
||||
{
|
||||
StencilBits = 0,
|
||||
},
|
||||
},
|
||||
"srgb" => capabilities with
|
||||
{
|
||||
Framebuffer = capabilities.Framebuffer with
|
||||
{
|
||||
FramebufferSrgbApi = false,
|
||||
},
|
||||
},
|
||||
"keyboard" => capabilities with
|
||||
{
|
||||
Input = capabilities.Input with
|
||||
{
|
||||
KeyboardCount = 0,
|
||||
},
|
||||
},
|
||||
"mouse" => capabilities with
|
||||
{
|
||||
Input = capabilities.Input with
|
||||
{
|
||||
MouseCount = 0,
|
||||
},
|
||||
},
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(missing)),
|
||||
};
|
||||
|
||||
Assert.NotEmpty(GraphicalCapabilityRequirements.Evaluate(capabilities));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdvertisedBufferStorageRequiresSuccessfulPersistentProbe()
|
||||
{
|
||||
GraphicalCapabilityRecord capabilities = CreateSupported() with
|
||||
{
|
||||
FunctionProbe = CreateSupported().FunctionProbe with
|
||||
{
|
||||
PersistentBufferStorage = false,
|
||||
},
|
||||
};
|
||||
|
||||
Assert.Contains(
|
||||
GraphicalCapabilityRequirements.Evaluate(capabilities),
|
||||
failure => failure.Contains(
|
||||
"persistent mapping",
|
||||
StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingOptionalBufferStorageDoesNotRequireProbe()
|
||||
{
|
||||
GraphicalCapabilityRecord capabilities = CreateSupported() with
|
||||
{
|
||||
HasBufferStorage = false,
|
||||
FunctionProbe = CreateSupported().FunctionProbe with
|
||||
{
|
||||
PersistentBufferStorage = null,
|
||||
},
|
||||
};
|
||||
|
||||
Assert.Empty(GraphicalCapabilityRequirements.Evaluate(capabilities));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsupportedMessageNamesDriverProtocolFailureAndReport()
|
||||
{
|
||||
GraphicalCapabilityRecord capabilities = CreateSupported() with
|
||||
{
|
||||
SupportFailures = ["GL_ARB_bindless_texture is required."],
|
||||
};
|
||||
|
||||
string message = GraphicalCapabilityGuard.FormatUnsupportedMessage(
|
||||
capabilities,
|
||||
"capabilities.json");
|
||||
|
||||
Assert.Contains("Mesa", message);
|
||||
Assert.Contains("RadeonSI", message);
|
||||
Assert.Contains("Wayland", message);
|
||||
Assert.Contains("GL_ARB_bindless_texture", message);
|
||||
Assert.Contains(
|
||||
Path.GetFullPath("capabilities.json"),
|
||||
message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportWriterAtomicallyOverwritesJson()
|
||||
{
|
||||
string directory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-capability-tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
string path = Path.Combine(directory, "capabilities.json");
|
||||
try
|
||||
{
|
||||
GraphicalCapabilityReportWriter.Write(path, CreateSupported());
|
||||
GraphicalCapabilityReportWriter.Write(
|
||||
path,
|
||||
CreateSupported() with
|
||||
{
|
||||
GlRenderer = "second renderer",
|
||||
});
|
||||
|
||||
using JsonDocument report = JsonDocument.Parse(
|
||||
File.ReadAllText(path));
|
||||
Assert.Equal(
|
||||
"second renderer",
|
||||
report.RootElement
|
||||
.GetProperty(nameof(GraphicalCapabilityRecord.GlRenderer))
|
||||
.GetString());
|
||||
Assert.Equal(
|
||||
"Wayland",
|
||||
report.RootElement
|
||||
.GetProperty(nameof(
|
||||
GraphicalCapabilityRecord.ActiveDisplayProtocol))
|
||||
.GetString());
|
||||
Assert.False(File.Exists(path + ".tmp"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static GraphicalCapabilityRecord CreateSupported() => new(
|
||||
DateTimeOffset.UnixEpoch,
|
||||
"linux-x64",
|
||||
GraphicalHostOperatingSystem.Linux,
|
||||
GraphicalDisplayProtocol.Wayland,
|
||||
GraphicalDisplayProtocol.Wayland,
|
||||
"test",
|
||||
"Silk.NET.Windowing.Glfw",
|
||||
"3.4.0",
|
||||
"Mesa",
|
||||
"RadeonSI",
|
||||
"4.6",
|
||||
"4.60",
|
||||
4,
|
||||
6,
|
||||
1,
|
||||
1,
|
||||
HasBindlessTexture: true,
|
||||
HasShaderDrawParameters: true,
|
||||
HasMultiDrawIndirect: true,
|
||||
HasShaderStorageBuffer: true,
|
||||
HasBufferStorage: true,
|
||||
HasTimerQuery: true,
|
||||
MaximumShaderStorageBufferBindings: 16,
|
||||
MaximumUniformBufferBindings: 72,
|
||||
MaximumTextureSize: 16_384,
|
||||
MaximumArrayTextureLayers: 2_048,
|
||||
MaximumCombinedTextureImageUnits: 192,
|
||||
new GraphicalFramebufferCapabilities(
|
||||
8,
|
||||
8,
|
||||
8,
|
||||
8,
|
||||
24,
|
||||
8,
|
||||
1,
|
||||
4,
|
||||
FramebufferSrgbApi: true),
|
||||
new GraphicalInputCapabilities(
|
||||
KeyboardCount: 1,
|
||||
MouseCount: 1,
|
||||
GamepadCount: 0,
|
||||
JoystickCount: 0),
|
||||
new GraphicalWindowCapabilities(
|
||||
1280,
|
||||
720,
|
||||
2560,
|
||||
1440,
|
||||
"test monitor",
|
||||
144,
|
||||
VSync: false),
|
||||
new GraphicalAudioCapabilities(
|
||||
Requested: false,
|
||||
Available: false,
|
||||
PlaybackSubmitted: false,
|
||||
DisposalComplete: true,
|
||||
Backend: "not requested"),
|
||||
new GraphicalSmokeLifecycleCapabilities(
|
||||
OwnedWindowCount: 1,
|
||||
OwnedGlApiCount: 1,
|
||||
OwnedInputContextCount: 1,
|
||||
OwnedAudioEngineCount: 0,
|
||||
ShutdownComplete: false),
|
||||
[],
|
||||
new GraphicalFunctionProbeResult(
|
||||
BindlessTexture: true,
|
||||
ShaderDrawParameters: true,
|
||||
MultiDrawIndirect: true,
|
||||
ShaderStorageBuffer: true,
|
||||
TimerQuery: true,
|
||||
SrgbFramebuffer: true,
|
||||
PersistentBufferStorage: true,
|
||||
Failures: []),
|
||||
SupportFailures: []);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
|
@ -41,7 +42,11 @@ public class ClipFrameLayoutTests
|
|||
Assert.Equal(8, ClipFrame.MaxPlanes);
|
||||
Assert.Equal(144, ClipFrame.TerrainUboBytes);
|
||||
// Binding contract: mesh clip regions on SSBO binding=2, terrain on UBO binding=2.
|
||||
Assert.Equal(2u, ClipFrame.MeshClipSsboBinding);
|
||||
// The mesh side's binding index moved off ClipFrame at Campaign V slice
|
||||
// V11 — the RHI arm addresses it through GpuBindingModel.StorageClipRegions
|
||||
// instead of a raw GL binding constant (see ClipFrame's BeginFrame doc
|
||||
// comment); the terrain UBO binding is still genuinely shared, so it stays.
|
||||
Assert.Equal(2u, GpuBindingModel.StorageClipRegions);
|
||||
Assert.Equal(2u, ClipFrame.TerrainClipUboBinding);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class ClipFrameUploadTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1, 144)]
|
||||
[InlineData(16, 144)]
|
||||
[InlineData(64, 192)]
|
||||
[InlineData(256, 256)]
|
||||
[InlineData(512, 512)]
|
||||
public void TerrainArena_RecordStride_RespectsDriverAlignment(
|
||||
int alignment,
|
||||
int expectedStride)
|
||||
{
|
||||
Assert.Equal(expectedStride, ClipFrameArenaLayout.RecordStride(alignment));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TerrainArena_AssignsEverySliceAUniqueOrderedRange()
|
||||
{
|
||||
const int stride = 256;
|
||||
Assert.Equal(0, ClipFrameArenaLayout.RecordOffset(0, stride));
|
||||
Assert.Equal(256, ClipFrameArenaLayout.RecordOffset(1, stride));
|
||||
Assert.Equal(512, ClipFrameArenaLayout.RecordOffset(2, stride));
|
||||
Assert.Equal(1280, ClipFrameArenaLayout.RequiredBytes(5, stride));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UploadState_RegionsOnce_AndTerrainRangesInSubmissionOrder()
|
||||
{
|
||||
var state = new ClipFrameUploadState();
|
||||
state.BeginFrame();
|
||||
state.ValidateRegionsNotUploaded();
|
||||
state.MarkRegionsUploaded();
|
||||
Assert.True(state.RegionsUploaded);
|
||||
Assert.Throws<InvalidOperationException>(state.ValidateRegionsNotUploaded);
|
||||
|
||||
state.ValidateTerrainReservation(4);
|
||||
state.CommitTerrainReservation(4);
|
||||
Assert.Equal(new[] { 0, 1, 2, 3 }, new[]
|
||||
{
|
||||
state.NextTerrainRecord(),
|
||||
state.NextTerrainRecord(),
|
||||
state.NextTerrainRecord(),
|
||||
state.NextTerrainRecord(),
|
||||
});
|
||||
Assert.Equal(4, state.TerrainUploaded);
|
||||
Assert.Throws<InvalidOperationException>(() => state.NextTerrainRecord());
|
||||
|
||||
state.BeginFrame();
|
||||
Assert.False(state.RegionsUploaded);
|
||||
Assert.Equal(0, state.TerrainReserved);
|
||||
Assert.Equal(0, state.TerrainUploaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResourceRing_RetainsAtMostOneResourcePerFencedFrameSlot()
|
||||
{
|
||||
var ring = new ClipFrameResourceRing<object>(ClipFrame.FrameSlotCount);
|
||||
for (int slot = 0; slot < ClipFrame.FrameSlotCount; slot++)
|
||||
ring.Set(slot, new object());
|
||||
|
||||
// Pathological slice counts reuse ranges in the frame slot's one arena;
|
||||
// they cannot add another GL object to the ring.
|
||||
for (int slice = 0; slice < 10_000; slice++)
|
||||
Assert.True(ring.TryGet(slice % ClipFrame.FrameSlotCount, out _));
|
||||
|
||||
Assert.Equal(ClipFrame.FrameSlotCount, ring.Count);
|
||||
Assert.Throws<InvalidOperationException>(() => ring.Set(0, new object()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapacityPolicy_ShrinksOneOffPathologicalPeakAfterHysteresis()
|
||||
{
|
||||
var policy = new ClipBufferCapacityPolicy();
|
||||
int peak = policy.SelectCapacity(0, 1_000_000);
|
||||
Assert.True(peak >= 1_000_000);
|
||||
|
||||
int first = policy.SelectCapacity(peak, 4_096);
|
||||
int second = policy.SelectCapacity(first, 4_096);
|
||||
int third = policy.SelectCapacity(second, 4_096);
|
||||
|
||||
Assert.Equal(peak, first);
|
||||
Assert.Equal(peak, second);
|
||||
Assert.Equal(4_096, third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapacityPolicy_OrdinaryDemandJitterCancelsPendingShrink()
|
||||
{
|
||||
var policy = new ClipBufferCapacityPolicy();
|
||||
int capacity = policy.SelectCapacity(0, 65_536);
|
||||
capacity = policy.SelectCapacity(capacity, 4_096);
|
||||
capacity = policy.SelectCapacity(capacity, 20_000); // above 25% utilization
|
||||
capacity = policy.SelectCapacity(capacity, 4_096);
|
||||
capacity = policy.SelectCapacity(capacity, 4_096);
|
||||
|
||||
Assert.Equal(65_536, capacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapacityTransaction_PublishesOnlySuccessfulResize()
|
||||
{
|
||||
int capacity = 4_096;
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
ClipBufferCapacityTransaction.Resize(
|
||||
ref capacity,
|
||||
8_192,
|
||||
(_, _) => throw new InvalidOperationException("BufferData failed")));
|
||||
Assert.Equal(4_096, capacity);
|
||||
|
||||
ClipBufferCapacityTransaction.Resize(
|
||||
ref capacity,
|
||||
8_192,
|
||||
(previous, next) =>
|
||||
{
|
||||
Assert.Equal(4_096, previous);
|
||||
Assert.Equal(8_192, next);
|
||||
});
|
||||
Assert.Equal(8_192, capacity);
|
||||
|
||||
// A later stage failure must not roll accounting back to the old store.
|
||||
Action laterFailure = () => throw new InvalidOperationException("later bind failed");
|
||||
Assert.Throws<InvalidOperationException>(laterFailure);
|
||||
Assert.Equal(8_192, capacity);
|
||||
}
|
||||
}
|
||||
|
|
@ -138,7 +138,7 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
"new ResourceShutdownStage(\"render frontends\"",
|
||||
"Hard(\"portal tunnel\"",
|
||||
"Hard(\"paperdoll viewport\"",
|
||||
"new ResourceShutdownStage(\"OpenGL context\"");
|
||||
"new ResourceShutdownStage(\"graphics API context\"");
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||
|
|
@ -155,7 +155,7 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
"new ResourceShutdownStage(\"render frontends\"",
|
||||
"new ResourceShutdownStage(\"input context\"",
|
||||
"platform.Input?.Dispose()",
|
||||
"new ResourceShutdownStage(\"OpenGL context\"");
|
||||
"new ResourceShutdownStage(\"graphics API context\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"RuntimeSettingsSnapshot startup = _runtimeSettings.Startup",
|
||||
"_displayFramePacing.InitializeStartup(startup.Display.VSync)",
|
||||
"VSync = startupPacing.UseVSync",
|
||||
"Samples = startup.Quality.MsaaSamples",
|
||||
// Campaign V slice V11: the raw-GL "Samples = ..." window option
|
||||
// is gone — Vulkan takes MSAA as an RHI attachment property, not
|
||||
// a window attribute. _startupQuality carries it forward instead.
|
||||
"_startupQuality = startup.Quality;",
|
||||
"Window.Create(options)",
|
||||
"_displayFramePacing.BindSurface(",
|
||||
"_windowCallbacks = SilkWindowCallbackBinding.Create(",
|
||||
|
|
@ -332,7 +335,13 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
run,
|
||||
"RuntimeSettingsSnapshot startup = _runtimeSettings.Startup",
|
||||
"_displayFramePacing.InitializeStartup(startup.Display.VSync)",
|
||||
"Samples = startup.Quality.MsaaSamples",
|
||||
// Campaign V slice V11: Vulkan needs a client-API-less window and
|
||||
// takes neither MSAA nor the stencil bit count as a window
|
||||
// attribute (both are RHI attachment properties instead), so the
|
||||
// raw-GL "Samples = ..." window option this used to assert is
|
||||
// gone. _startupQuality carries MsaaSamples forward instead, into
|
||||
// CreateGraphics' VulkanGraphicsContext.Acquire call.
|
||||
"_startupQuality = startup.Quality;",
|
||||
"Window.Create(options)");
|
||||
AssertAppearsInOrder(
|
||||
load,
|
||||
|
|
@ -372,9 +381,9 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"WorldRenderComposition.cs"));
|
||||
AssertAppearsInOrder(
|
||||
worldPhase,
|
||||
"TerrainAtlas.Build(gl, dats, bindless)",
|
||||
"TerrainAtlas.BuildBackendNeutral(device, dats)",
|
||||
"settings.ResolvedQuality.AnisotropicLevel",
|
||||
"_factory.CreateTerrain(");
|
||||
"_factory.CreateBackendNeutralTerrain(");
|
||||
AssertAppearsInOrder(
|
||||
shutdown,
|
||||
"Soft(\"settings view model\", () => ingress.Settings.UnbindViewModel())",
|
||||
|
|
@ -471,7 +480,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"new ResourceShutdownStage(\"frame flight owner\"",
|
||||
"new ResourceShutdownStage(\"content mappings\"",
|
||||
"new ResourceShutdownStage(\"input context\"",
|
||||
"new ResourceShutdownStage(\"OpenGL context\"",
|
||||
"new ResourceShutdownStage(\"graphics API context\"",
|
||||
];
|
||||
AssertAppearsInOrder(manifest, stages);
|
||||
Assert.Equal(stages.Length, CountOccurrences(manifest, "new ResourceShutdownStage("));
|
||||
|
|
@ -563,7 +572,6 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
livePhase,
|
||||
"d.PortalTunnelFallback.AcquirePrepared(",
|
||||
"static tunnel => tunnel.PrepareResources());",
|
||||
"d.RenderResourceLifetime.AcquireSkyShader(",
|
||||
"new SkyRenderer(");
|
||||
AssertAppearsInOrder(
|
||||
load,
|
||||
|
|
@ -584,13 +592,14 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
AssertAppearsInOrder(
|
||||
worldPhase,
|
||||
"lifetime.AcquireTerrainAtlas(",
|
||||
"TerrainAtlas.Build(gl, dats, bindless)",
|
||||
"TerrainModernRenderer CreateTerrain(",
|
||||
"TerrainAtlas.BuildBackendNeutral(device, dats)",
|
||||
"TerrainModernRenderer CreateBackendNeutralTerrain(",
|
||||
// Campaign V slice V6j: terrain is composed on both arms, so its
|
||||
// acquisition is unconditional. The boundary this test pins — that
|
||||
// the atlas is acquired, then the factory names the renderer, then
|
||||
// the renderer is acquired AND published in one step — is unchanged.
|
||||
"TerrainModernRenderer? terrain = AcquireAndPublish(");
|
||||
// The raw-GL arm (CreateTerrain) was deleted at slice V11.
|
||||
"TerrainModernRenderer terrain = AcquireAndPublish(");
|
||||
AssertAppearsInOrder(
|
||||
shutdown,
|
||||
"frame.FrameGraphPublication?.Dispose()",
|
||||
|
|
@ -600,7 +609,6 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"render.PortalTunnelFallback.ReleaseFallback();",
|
||||
"render.Sky?.Dispose()",
|
||||
"render.Terrain?.Dispose()",
|
||||
"render.DedicatedResources.ReleaseSkyShader",
|
||||
"render.DedicatedResources.ReleaseTerrainAtlas",
|
||||
"render.ConstructionCleanup.Dispose",
|
||||
"platform.Graphics?.Dispose()");
|
||||
|
|
@ -620,11 +628,11 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_window.Run();",
|
||||
"_glConstructionCleanup.RetainFrom(failure);",
|
||||
"_constructionCleanup.RetainFrom(failure);",
|
||||
"private GameWindowShutdownRoots CaptureShutdownRoots()",
|
||||
"_glConstructionCleanup)");
|
||||
"_constructionCleanup)");
|
||||
Assert.Contains(
|
||||
"Hard(\"GL construction ledger\", render.ConstructionCleanup.Dispose)",
|
||||
"Hard(\"resource construction ledger\", render.ConstructionCleanup.Dispose)",
|
||||
shutdown,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,417 +0,0 @@
|
|||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class GlTextureOwnershipTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConstructionRollbackDeletesAllNamesInReverseOrder()
|
||||
{
|
||||
var api = new FakeTextureNameApi();
|
||||
var transaction = new GlTextureConstructionTransaction(api);
|
||||
|
||||
Assert.Equal(1u, transaction.Allocate());
|
||||
Assert.Equal(2u, transaction.Allocate());
|
||||
Assert.Equal(3u, transaction.Allocate());
|
||||
|
||||
transaction.Rollback();
|
||||
transaction.Rollback();
|
||||
|
||||
Assert.Equal([3u, 2u, 1u], api.DeleteAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructionRollbackAttemptsEveryNameAndReportsFailures()
|
||||
{
|
||||
var api = new FakeTextureNameApi { FailingDelete = 2 };
|
||||
var transaction = new GlTextureConstructionTransaction(api);
|
||||
_ = transaction.Allocate();
|
||||
_ = transaction.Allocate();
|
||||
_ = transaction.Allocate();
|
||||
|
||||
AggregateException failure = Assert.Throws<AggregateException>(transaction.Rollback);
|
||||
|
||||
Assert.Single(failure.InnerExceptions);
|
||||
Assert.Equal([3u, 2u, 1u], api.DeleteAttempts);
|
||||
Assert.Equal([3u, 1u], api.Deleted);
|
||||
|
||||
api.FailingDelete = null;
|
||||
transaction.RetryCleanup();
|
||||
|
||||
Assert.True(transaction.IsCleanupComplete);
|
||||
Assert.Equal([3u, 2u, 1u, 2u], api.DeleteAttempts);
|
||||
Assert.Equal([3u, 1u, 2u], api.Deleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommittedNamesAreNeverDeletedByConstructionTransaction()
|
||||
{
|
||||
var api = new FakeTextureNameApi();
|
||||
var transaction = new GlTextureConstructionTransaction(api);
|
||||
_ = transaction.Allocate();
|
||||
_ = transaction.Allocate();
|
||||
|
||||
transaction.Commit();
|
||||
transaction.Rollback();
|
||||
|
||||
Assert.Empty(api.DeleteAttempts);
|
||||
Assert.Throws<InvalidOperationException>(() => transaction.Allocate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackedTextureUploadFailureLeavesEveryActualBranchNameInTransaction()
|
||||
{
|
||||
var api = new FakeTextureNameApi();
|
||||
var transaction = new GlTextureConstructionTransaction(api);
|
||||
uint first = TrackedTextureConstruction.Create(transaction, _ => { });
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
TrackedTextureConstruction.Create(
|
||||
transaction,
|
||||
_ => throw new InvalidOperationException("upload failed")));
|
||||
transaction.Rollback();
|
||||
|
||||
Assert.Equal(1u, first);
|
||||
Assert.Equal([2u, 1u], api.DeleteAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TerrainAtlasRoutesEveryTerrainAlphaAndFallbackUploadThroughTracker()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"TerrainAtlas.cs"));
|
||||
|
||||
Assert.Equal(5, CountOccurrences(source, "TrackedTextureConstruction.Create("));
|
||||
Assert.Equal(
|
||||
5,
|
||||
System.Text.RegularExpressions.Regex.Matches(
|
||||
source,
|
||||
"TrackedTextureConstruction\\.Create\\(\\s*textures,\\s*gl,\\s*\\\"").Count);
|
||||
Assert.DoesNotContain("textures.Allocate()", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionGlResourcePathsUseAlwaysOnCheckedCommitBoundaries()
|
||||
{
|
||||
string root = FindRepoRoot();
|
||||
string textureNames = File.ReadAllText(Path.Combine(
|
||||
root, "src", "AcDream.App", "Rendering", "GlTextureConstructionTransaction.cs"));
|
||||
string shaderPrograms = File.ReadAllText(Path.Combine(
|
||||
root, "src", "AcDream.App", "Rendering", "ShaderProgramConstruction.cs"));
|
||||
string terrain = File.ReadAllText(Path.Combine(
|
||||
root, "src", "AcDream.App", "Rendering", "TerrainAtlas.cs"));
|
||||
// Campaign V slice V4a: TextRenderer's shader/texture creation moved
|
||||
// from raw GlResourceCommand calls to IGpuDevice.CreatePipeline/
|
||||
// CreateTexture, whose own checked-commit construction
|
||||
// (GlResourceCommand.CreateName / ShaderProgramConstruction.Build,
|
||||
// predating this slice) is what those now delegate to. Slice V6d then
|
||||
// removed the white fill texture, so TextRenderer's constructor owns a
|
||||
// single resource and holds no GL name of any kind — every checked
|
||||
// commit boundary it depends on lives behind the RHI.
|
||||
string text = File.ReadAllText(Path.Combine(
|
||||
root, "src", "AcDream.App", "Rendering", "TextRenderer.cs"));
|
||||
string bindless = File.ReadAllText(Path.Combine(
|
||||
root, "src", "AcDream.App", "Rendering", "Wb", "BindlessSupport.cs"));
|
||||
|
||||
Assert.Contains("GlResourceCommand.CreateTexture", textureNames, StringComparison.Ordinal);
|
||||
Assert.Contains("GlResourceCommand.DeleteTexture", textureNames, StringComparison.Ordinal);
|
||||
Assert.Contains("GlResourceCommand.CreateName", shaderPrograms, StringComparison.Ordinal);
|
||||
Assert.Contains("GlResourceCommand.DeleteShader", shaderPrograms, StringComparison.Ordinal);
|
||||
Assert.Contains("GlResourceCommand.DeleteProgram", shaderPrograms, StringComparison.Ordinal);
|
||||
Assert.Contains("_anisotropyBindingMutation.Execute", terrain, StringComparison.Ordinal);
|
||||
Assert.Contains("GlResourceCommand.DeleteTexture", terrain, StringComparison.Ordinal);
|
||||
Assert.Contains("_pipeline = device.CreatePipeline(", text, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("GlResourceCommand", text, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("GlName", text, StringComparison.Ordinal);
|
||||
Assert.Contains("make bindless handle", bindless, StringComparison.Ordinal);
|
||||
Assert.Contains("GlResourceCommand.Execute", bindless, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void AssertAppearsInOrder(string source, params string[] needles)
|
||||
{
|
||||
int cursor = -1;
|
||||
foreach (string needle in needles)
|
||||
{
|
||||
int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal);
|
||||
Assert.True(next >= 0, $"Missing expected source fragment: {needle}");
|
||||
Assert.True(next > cursor, $"Out-of-order source fragment: {needle}");
|
||||
cursor = next;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecondBindlessAcquireFailureRollsBackFirstHandle()
|
||||
{
|
||||
var residency = new FakeResidency { FailingAcquireTexture = 20 };
|
||||
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => pair.Acquire());
|
||||
|
||||
Assert.False(pair.IsFullyResident);
|
||||
Assert.Equal([10u, 20u], residency.AcquireAttempts);
|
||||
Assert.Equal([1010ul], residency.ReleaseAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedPrefixRollbackRemainsOwnedAndRetryDoesNotReacquireIt()
|
||||
{
|
||||
var residency = new FakeResidency
|
||||
{
|
||||
FailingAcquireTexture = 20,
|
||||
FailingReleaseHandle = 1010,
|
||||
};
|
||||
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
|
||||
|
||||
Assert.Throws<AggregateException>(() => pair.Acquire());
|
||||
residency.FailingAcquireTexture = null;
|
||||
residency.FailingReleaseHandle = null;
|
||||
|
||||
Assert.Equal((1010ul, 1020ul), pair.Acquire());
|
||||
|
||||
Assert.True(pair.IsFullyResident);
|
||||
Assert.Equal([10u, 20u, 20u], residency.AcquireAttempts);
|
||||
Assert.Equal([1010ul], residency.ReleaseAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindlessReleaseAttemptsBothAndRetriesOnlyPendingHandle()
|
||||
{
|
||||
var residency = new FakeResidency();
|
||||
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
|
||||
_ = pair.Acquire();
|
||||
residency.FailingReleaseHandle = 1010;
|
||||
|
||||
Assert.Throws<AggregateException>(pair.Release);
|
||||
Assert.False(pair.IsFullyResident);
|
||||
Assert.True(pair.HasAnyResident);
|
||||
Assert.Equal([1010ul, 1020ul], residency.ReleaseAttempts);
|
||||
|
||||
residency.FailingReleaseHandle = null;
|
||||
pair.Release();
|
||||
pair.Release();
|
||||
|
||||
Assert.False(pair.HasAnyResident);
|
||||
Assert.Equal([1010ul, 1020ul, 1010ul], residency.ReleaseAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MutationFailureStillRestoresThePreviouslyResidentPair()
|
||||
{
|
||||
var residency = new FakeResidency();
|
||||
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
|
||||
var guard = new BindlessTextureMutationGuard(pair);
|
||||
_ = pair.Acquire();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
guard.Execute(() => throw new InvalidOperationException("mutation failed")));
|
||||
|
||||
Assert.True(pair.IsFullyResident);
|
||||
Assert.False(guard.RestoreRequired);
|
||||
Assert.Equal([10u, 20u, 10u, 20u], residency.AcquireAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedReacquireKeepsRestoreIntentUntilALaterMutationRetry()
|
||||
{
|
||||
var residency = new FakeResidency();
|
||||
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
|
||||
var guard = new BindlessTextureMutationGuard(pair);
|
||||
_ = pair.Acquire();
|
||||
residency.FailingAcquireTexture = 20;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => guard.Execute(() => { }));
|
||||
Assert.True(guard.RestoreRequired);
|
||||
Assert.False(pair.HasAnyResident);
|
||||
|
||||
residency.FailingAcquireTexture = null;
|
||||
guard.Execute(() => { });
|
||||
|
||||
Assert.False(guard.RestoreRequired);
|
||||
Assert.True(pair.IsFullyResident);
|
||||
Assert.Equal([10u, 20u, 10u, 20u, 10u, 20u], residency.AcquireAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartialReleaseFailurePreventsMutationAndRestoresPairBeforeThrowing()
|
||||
{
|
||||
var residency = new FakeResidency();
|
||||
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
|
||||
var guard = new BindlessTextureMutationGuard(pair);
|
||||
_ = pair.Acquire();
|
||||
residency.FailingReleaseHandle = 1010;
|
||||
int mutations = 0;
|
||||
|
||||
Assert.Throws<AggregateException>(() => guard.Execute(() => mutations++));
|
||||
|
||||
Assert.Equal(0, mutations);
|
||||
Assert.True(pair.IsFullyResident);
|
||||
Assert.False(guard.RestoreRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedTextureMutationRestoresTheExactPriorBinding()
|
||||
{
|
||||
var bindings = new List<uint>();
|
||||
var owner = new RestoredTextureBindingMutation();
|
||||
|
||||
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(() =>
|
||||
owner.Execute(
|
||||
() => 77,
|
||||
bindings.Add,
|
||||
42,
|
||||
() => throw new InvalidOperationException("mutation failed")));
|
||||
|
||||
Assert.Equal("mutation failed", failure.Message);
|
||||
Assert.Equal([42u, 77u], bindings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextureMutationReportsBothMutationAndBindingRestoreFailure()
|
||||
{
|
||||
int bindCalls = 0;
|
||||
var owner = new RestoredTextureBindingMutation();
|
||||
|
||||
AggregateException failure = Assert.Throws<AggregateException>(() =>
|
||||
owner.Execute(
|
||||
() => 77,
|
||||
_ =>
|
||||
{
|
||||
bindCalls++;
|
||||
if (bindCalls == 2)
|
||||
throw new InvalidOperationException("restore failed");
|
||||
},
|
||||
42,
|
||||
() => throw new InvalidOperationException("mutation failed")));
|
||||
|
||||
Assert.Equal(2, failure.InnerExceptions.Count);
|
||||
Assert.Equal(2, bindCalls);
|
||||
Assert.True(owner.HasPendingRestore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedBindingRestoreRetriesTheOriginalBindingBeforeAnotherMutation()
|
||||
{
|
||||
var owner = new RestoredTextureBindingMutation();
|
||||
var bindings = new List<uint>();
|
||||
int restoreFailures = 1;
|
||||
int mutations = 0;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
owner.Execute(
|
||||
() => 77,
|
||||
binding =>
|
||||
{
|
||||
bindings.Add(binding);
|
||||
if (binding == 77 && restoreFailures-- > 0)
|
||||
throw new InvalidOperationException("restore failed");
|
||||
},
|
||||
42,
|
||||
() => mutations++));
|
||||
|
||||
Assert.True(owner.HasPendingRestore);
|
||||
owner.Execute(
|
||||
() => 77,
|
||||
bindings.Add,
|
||||
42,
|
||||
() => mutations++);
|
||||
|
||||
Assert.False(owner.HasPendingRestore);
|
||||
Assert.Equal([42u, 77u, 77u, 42u, 77u], bindings);
|
||||
Assert.Equal(2, mutations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructionCleanupLedgerRetainsNestedFailureUntilRetryCompletes()
|
||||
{
|
||||
var api = new FakeTextureNameApi { FailingDelete = 1 };
|
||||
var transaction = new GlTextureConstructionTransaction(api);
|
||||
_ = transaction.Allocate();
|
||||
AggregateException cleanupFailure = Assert.Throws<AggregateException>(
|
||||
transaction.Rollback);
|
||||
var constructionFailure = new GlResourceConstructionException(
|
||||
"synthetic construction failure",
|
||||
transaction,
|
||||
[new InvalidOperationException("build failed"), cleanupFailure]);
|
||||
var ledger = new GlConstructionCleanupLedger();
|
||||
|
||||
Assert.True(ledger.RetainFrom(constructionFailure));
|
||||
Assert.Throws<AggregateException>(ledger.Dispose);
|
||||
Assert.False(ledger.IsComplete);
|
||||
|
||||
api.FailingDelete = null;
|
||||
ledger.Dispose();
|
||||
|
||||
Assert.True(ledger.IsComplete);
|
||||
Assert.True(constructionFailure.IsCleanupComplete);
|
||||
}
|
||||
|
||||
private sealed class FakeTextureNameApi : IGlTextureNameApi
|
||||
{
|
||||
private uint _nextName = 1;
|
||||
|
||||
public uint? FailingDelete { get; set; }
|
||||
public List<uint> DeleteAttempts { get; } = [];
|
||||
public List<uint> Deleted { get; } = [];
|
||||
|
||||
public uint GenTexture() => _nextName++;
|
||||
|
||||
public void DeleteTexture(uint texture)
|
||||
{
|
||||
DeleteAttempts.Add(texture);
|
||||
if (FailingDelete == texture)
|
||||
throw new InvalidOperationException("delete failed");
|
||||
Deleted.Add(texture);
|
||||
}
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string source, string value)
|
||||
{
|
||||
int count = 0;
|
||||
int cursor = 0;
|
||||
while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
cursor += value.Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
private sealed class FakeResidency
|
||||
{
|
||||
public uint? FailingAcquireTexture { get; set; }
|
||||
public ulong? FailingReleaseHandle { get; set; }
|
||||
public List<uint> AcquireAttempts { get; } = [];
|
||||
public List<ulong> ReleaseAttempts { get; } = [];
|
||||
|
||||
public ulong Acquire(uint texture)
|
||||
{
|
||||
AcquireAttempts.Add(texture);
|
||||
if (FailingAcquireTexture == texture)
|
||||
throw new InvalidOperationException("acquire failed");
|
||||
return 1000ul + texture;
|
||||
}
|
||||
|
||||
public void Release(ulong handle)
|
||||
{
|
||||
ReleaseAttempts.Add(handle);
|
||||
if (FailingReleaseHandle == handle)
|
||||
throw new InvalidOperationException("release failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
using AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// The texture table's flush is mapped with GL_MAP_UNSYNCHRONIZED_BIT and
|
||||
/// GL_MAP_INVALIDATE_RANGE_BIT, so a run this tracker reports must contain
|
||||
/// nothing but slots that were actually written. A run that swallowed a clean
|
||||
/// slot in between would let the driver discard a live bindless handle while a
|
||||
/// submitted draw was reading it — invisible in any pixel gate, and exactly the
|
||||
/// class of fault the ring rewrite exists to remove. These tests pin that.
|
||||
/// </summary>
|
||||
public sealed class GlDirtySlotRunsTests
|
||||
{
|
||||
[Fact]
|
||||
public void NothingMarkedYieldsNoRuns()
|
||||
{
|
||||
var runs = new GlDirtySlotRuns(16);
|
||||
|
||||
Assert.False(runs.HasDirtySlots);
|
||||
Assert.False(runs.TryTakeNextRun(out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneMarkedSlotIsOneRunOfOne()
|
||||
{
|
||||
var runs = new GlDirtySlotRuns(16);
|
||||
runs.Mark(7);
|
||||
|
||||
Assert.True(runs.HasDirtySlots);
|
||||
Assert.True(runs.TryTakeNextRun(out uint first, out uint count));
|
||||
Assert.Equal(7u, first);
|
||||
Assert.Equal(1u, count);
|
||||
Assert.False(runs.TryTakeNextRun(out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsecutiveSlotsMergeIntoOneRun()
|
||||
{
|
||||
var runs = new GlDirtySlotRuns(16);
|
||||
runs.Mark(4);
|
||||
runs.Mark(5);
|
||||
runs.Mark(6);
|
||||
|
||||
Assert.True(runs.TryTakeNextRun(out uint first, out uint count));
|
||||
Assert.Equal(4u, first);
|
||||
Assert.Equal(3u, count);
|
||||
Assert.False(runs.TryTakeNextRun(out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AGapBetweenMarkedSlotsSplitsTheRuns()
|
||||
{
|
||||
var runs = new GlDirtySlotRuns(64);
|
||||
runs.Mark(5);
|
||||
runs.Mark(50);
|
||||
|
||||
Assert.True(runs.TryTakeNextRun(out uint firstStart, out uint firstCount));
|
||||
Assert.Equal(5u, firstStart);
|
||||
Assert.Equal(1u, firstCount);
|
||||
|
||||
Assert.True(runs.TryTakeNextRun(out uint secondStart, out uint secondCount));
|
||||
Assert.Equal(50u, secondStart);
|
||||
Assert.Equal(1u, secondCount);
|
||||
|
||||
Assert.False(runs.TryTakeNextRun(out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunsComeBackInAscendingSlotOrderRegardlessOfMarkOrder()
|
||||
{
|
||||
var runs = new GlDirtySlotRuns(64);
|
||||
runs.Mark(40);
|
||||
runs.Mark(1);
|
||||
runs.Mark(41);
|
||||
runs.Mark(20);
|
||||
runs.Mark(0);
|
||||
|
||||
Assert.True(runs.TryTakeNextRun(out uint start, out uint count));
|
||||
Assert.Equal(0u, start);
|
||||
Assert.Equal(2u, count);
|
||||
|
||||
Assert.True(runs.TryTakeNextRun(out start, out count));
|
||||
Assert.Equal(20u, start);
|
||||
Assert.Equal(1u, count);
|
||||
|
||||
Assert.True(runs.TryTakeNextRun(out start, out count));
|
||||
Assert.Equal(40u, start);
|
||||
Assert.Equal(2u, count);
|
||||
|
||||
Assert.False(runs.TryTakeNextRun(out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkingTheSameSlotTwiceStillYieldsOneRun()
|
||||
{
|
||||
var runs = new GlDirtySlotRuns(16);
|
||||
runs.Mark(3);
|
||||
runs.Mark(3);
|
||||
|
||||
Assert.True(runs.TryTakeNextRun(out uint start, out uint count));
|
||||
Assert.Equal(3u, start);
|
||||
Assert.Equal(1u, count);
|
||||
Assert.False(runs.TryTakeNextRun(out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrainingLeavesTheTrackerCleanForTheNextFlush()
|
||||
{
|
||||
var runs = new GlDirtySlotRuns(16);
|
||||
runs.Mark(2);
|
||||
runs.Mark(9);
|
||||
while (runs.TryTakeNextRun(out _, out _))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.False(runs.HasDirtySlots);
|
||||
|
||||
runs.Mark(11);
|
||||
Assert.True(runs.TryTakeNextRun(out uint start, out uint count));
|
||||
Assert.Equal(11u, start);
|
||||
Assert.Equal(1u, count);
|
||||
Assert.False(runs.TryTakeNextRun(out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlotsMarkedAfterAPartialDrainAreStillReported()
|
||||
{
|
||||
var runs = new GlDirtySlotRuns(32);
|
||||
runs.Mark(4);
|
||||
runs.Mark(20);
|
||||
|
||||
Assert.True(runs.TryTakeNextRun(out uint start, out uint count));
|
||||
Assert.Equal(4u, start);
|
||||
Assert.Equal(1u, count);
|
||||
|
||||
// A draw between two flushes can register another texture.
|
||||
runs.Mark(21);
|
||||
|
||||
Assert.True(runs.TryTakeNextRun(out start, out count));
|
||||
Assert.Equal(20u, start);
|
||||
Assert.Equal(2u, count);
|
||||
Assert.False(runs.TryTakeNextRun(out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheLastSlotInTheTableIsAddressable()
|
||||
{
|
||||
var runs = new GlDirtySlotRuns(8);
|
||||
runs.Mark(7);
|
||||
|
||||
Assert.True(runs.TryTakeNextRun(out uint start, out uint count));
|
||||
Assert.Equal(7u, start);
|
||||
Assert.Equal(1u, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkingBeyondCapacityThrows()
|
||||
{
|
||||
var runs = new GlDirtySlotRuns(8);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => runs.Mark(8));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroCapacityIsRejected()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GlDirtySlotRuns(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The whole point, stated as one property: every slot a run covers was
|
||||
/// marked, and every marked slot is covered exactly once.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EveryReportedSlotWasMarkedAndEveryMarkedSlotIsReportedOnce()
|
||||
{
|
||||
const int Capacity = 200;
|
||||
var random = new Random(20260727);
|
||||
var expected = new HashSet<uint>();
|
||||
var runs = new GlDirtySlotRuns(Capacity);
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
uint slot = (uint)random.Next(Capacity);
|
||||
expected.Add(slot);
|
||||
runs.Mark(slot);
|
||||
}
|
||||
|
||||
var reported = new List<uint>();
|
||||
while (runs.TryTakeNextRun(out uint start, out uint count))
|
||||
{
|
||||
for (uint slot = start; slot < start + count; slot++)
|
||||
reported.Add(slot);
|
||||
}
|
||||
|
||||
Assert.Equal(reported.Count, reported.Distinct().Count());
|
||||
Assert.Equal(expected.OrderBy(slot => slot), reported);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Gpu.Gl;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
|
||||
|
||||
public sealed class GlEnumMappingTests
|
||||
{
|
||||
[Fact]
|
||||
public void VertexShapesMatchTheDeclaredComponentCounts()
|
||||
{
|
||||
Assert.Equal(new GlVertexAttributeShape(1, VertexAttribPointerType.Float, false), GlEnumMapping.VertexShapeOf(GpuVertexFormat.Float1));
|
||||
Assert.Equal(new GlVertexAttributeShape(2, VertexAttribPointerType.Float, false), GlEnumMapping.VertexShapeOf(GpuVertexFormat.Float2));
|
||||
Assert.Equal(new GlVertexAttributeShape(3, VertexAttribPointerType.Float, false), GlEnumMapping.VertexShapeOf(GpuVertexFormat.Float3));
|
||||
Assert.Equal(new GlVertexAttributeShape(4, VertexAttribPointerType.Float, false), GlEnumMapping.VertexShapeOf(GpuVertexFormat.Float4));
|
||||
Assert.Equal(new GlVertexAttributeShape(4, VertexAttribPointerType.UnsignedByte, true), GlEnumMapping.VertexShapeOf(GpuVertexFormat.UByte4Normalized));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndexTypesMapToTheirGlSizesAndEnums()
|
||||
{
|
||||
Assert.Equal(DrawElementsType.UnsignedShort, GlEnumMapping.DrawElementsTypeOf(GpuIndexType.UInt16));
|
||||
Assert.Equal(2, GlEnumMapping.IndexSizeBytesOf(GpuIndexType.UInt16));
|
||||
Assert.Equal(DrawElementsType.UnsignedInt, GlEnumMapping.DrawElementsTypeOf(GpuIndexType.UInt32));
|
||||
Assert.Equal(4, GlEnumMapping.IndexSizeBytesOf(GpuIndexType.UInt32));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlendFactorsMatchTheDocumentedFormulas()
|
||||
{
|
||||
(BlendingFactor source, BlendingFactor destination) straight = GlEnumMapping.BlendFactorsOf(GpuBlendMode.StraightAlpha);
|
||||
Assert.Equal((BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha), straight);
|
||||
|
||||
(BlendingFactor source, BlendingFactor destination) additive = GlEnumMapping.BlendFactorsOf(GpuBlendMode.Additive);
|
||||
Assert.Equal((BlendingFactor.SrcAlpha, BlendingFactor.One), additive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlendNoneHasNoGlFactors()
|
||||
{
|
||||
Assert.Throws<NotSupportedException>(() => GlEnumMapping.BlendFactorsOf(GpuBlendMode.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CullNoneHasNoGlCullFaceMode()
|
||||
{
|
||||
Assert.Throws<NotSupportedException>(() => GlEnumMapping.CullFaceModeOf(GpuCullMode.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DepthCompareOpsMapOneToOne()
|
||||
{
|
||||
Assert.Equal(DepthFunction.Never, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Never));
|
||||
Assert.Equal(DepthFunction.Less, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Less));
|
||||
Assert.Equal(DepthFunction.Lequal, GlEnumMapping.DepthFunctionOf(GpuCompareOp.LessOrEqual));
|
||||
Assert.Equal(DepthFunction.Equal, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Equal));
|
||||
Assert.Equal(DepthFunction.Greater, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Greater));
|
||||
Assert.Equal(DepthFunction.Gequal, GlEnumMapping.DepthFunctionOf(GpuCompareOp.GreaterOrEqual));
|
||||
Assert.Equal(DepthFunction.Always, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Always));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterCombinationsProduceTheExpectedMinFilter()
|
||||
{
|
||||
Assert.Equal(TextureMinFilter.Nearest, GlEnumMapping.MinFilterOf(GpuFilter.Nearest, GpuMipFilter.None));
|
||||
Assert.Equal(TextureMinFilter.Linear, GlEnumMapping.MinFilterOf(GpuFilter.Linear, GpuMipFilter.None));
|
||||
Assert.Equal(TextureMinFilter.LinearMipmapLinear, GlEnumMapping.MinFilterOf(GpuFilter.Linear, GpuMipFilter.Linear));
|
||||
Assert.Equal(TextureMinFilter.NearestMipmapNearest, GlEnumMapping.MinFilterOf(GpuFilter.Nearest, GpuMipFilter.Nearest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryNonNoneEnumValueResolvesForFrontFaceAndWrapMode()
|
||||
{
|
||||
Assert.Equal(FrontFaceDirection.Ccw, GlEnumMapping.FrontFaceDirectionOf(GpuFrontFace.CounterClockwise));
|
||||
Assert.Equal(FrontFaceDirection.CW, GlEnumMapping.FrontFaceDirectionOf(GpuFrontFace.Clockwise));
|
||||
Assert.Equal(TextureWrapMode.Repeat, GlEnumMapping.WrapModeOf(GpuAddressMode.Repeat));
|
||||
Assert.Equal(TextureWrapMode.ClampToEdge, GlEnumMapping.WrapModeOf(GpuAddressMode.ClampToEdge));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Gpu.Gl;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
|
||||
|
||||
public sealed class GlGpuTextureFormatMappingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Rgba8AndItsRenderTargetVariantShareTheSameGlShape()
|
||||
{
|
||||
GlTextureFormatInfo plain = GlGpuTextureFormatMapping.Resolve(GpuTextureFormat.Rgba8Unorm);
|
||||
GlTextureFormatInfo target = GlGpuTextureFormatMapping.Resolve(GpuTextureFormat.Rgba8UnormRenderTarget);
|
||||
|
||||
Assert.Equal(plain, target);
|
||||
Assert.Equal(SizedInternalFormat.Rgba8, plain.SizedInternalFormat);
|
||||
Assert.Equal(PixelFormat.Rgba, plain.UploadPixelFormat);
|
||||
Assert.Equal(PixelType.UnsignedByte, plain.UploadPixelType);
|
||||
Assert.False(plain.IsCompressed);
|
||||
Assert.Equal(4, plain.LayerByteCount(1, 1));
|
||||
Assert.Equal(4 * 16 * 16, plain.LayerByteCount(16, 16));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void R8IsOneByteUncompressed()
|
||||
{
|
||||
GlTextureFormatInfo info = GlGpuTextureFormatMapping.Resolve(GpuTextureFormat.R8Unorm);
|
||||
Assert.Equal(SizedInternalFormat.R8, info.SizedInternalFormat);
|
||||
Assert.False(info.IsCompressed);
|
||||
Assert.Equal(1, info.LayerByteCount(1, 1));
|
||||
Assert.Equal(64, info.LayerByteCount(8, 8));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bc1IsCompressedFourByFourEightByteBlocks() =>
|
||||
AssertBcFormat(GpuTextureFormat.Bc1Unorm, expectedBlockBytes: 8);
|
||||
|
||||
[Fact]
|
||||
public void Bc2IsCompressedFourByFourSixteenByteBlocks() =>
|
||||
AssertBcFormat(GpuTextureFormat.Bc2Unorm, expectedBlockBytes: 16);
|
||||
|
||||
[Fact]
|
||||
public void Bc3IsCompressedFourByFourSixteenByteBlocks() =>
|
||||
AssertBcFormat(GpuTextureFormat.Bc3Unorm, expectedBlockBytes: 16);
|
||||
|
||||
private static void AssertBcFormat(GpuTextureFormat format, int expectedBlockBytes)
|
||||
{
|
||||
GlTextureFormatInfo info = GlGpuTextureFormatMapping.Resolve(format);
|
||||
Assert.True(info.IsCompressed);
|
||||
Assert.Equal(4, info.BlockDimension);
|
||||
Assert.Equal(expectedBlockBytes, info.BlockOrTexelBytes);
|
||||
|
||||
// A single 4x4 block for a 4x4 texture.
|
||||
Assert.Equal(expectedBlockBytes, info.LayerByteCount(4, 4));
|
||||
// Partial blocks round up: a 5x5 texture needs a 2x2 block grid.
|
||||
Assert.Equal(expectedBlockBytes * 4, info.LayerByteCount(5, 5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Depth24Stencil8IsAttachmentShapedNotCompressed()
|
||||
{
|
||||
GlTextureFormatInfo info = GlGpuTextureFormatMapping.Resolve(GpuTextureFormat.Depth24Stencil8);
|
||||
Assert.Equal(SizedInternalFormat.Depth24Stencil8, info.SizedInternalFormat);
|
||||
Assert.Equal(PixelFormat.DepthStencil, info.UploadPixelFormat);
|
||||
Assert.Equal(PixelType.UnsignedInt248, info.UploadPixelType);
|
||||
Assert.False(info.IsCompressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryGpuTextureFormatValueResolvesWithoutThrowing()
|
||||
{
|
||||
foreach (GpuTextureFormat format in Enum.GetValues<GpuTextureFormat>())
|
||||
GlGpuTextureFormatMapping.Resolve(format);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
using AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
|
||||
|
||||
public sealed class GlGpuTimerPoolTests
|
||||
{
|
||||
[Fact]
|
||||
public void UnsupportedPoolReturnsNoOpScopesAndNeverResolves()
|
||||
{
|
||||
var pool = new GlGpuTimerPool(new FakeTimerQueryApi(), isSupported: false);
|
||||
|
||||
using (pool.BeginScope("world"))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.False(pool.TryResolve("world", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopeNamesGetIndependentDoubleBufferedQueries()
|
||||
{
|
||||
var api = new FakeTimerQueryApi();
|
||||
var pool = new GlGpuTimerPool(api, isSupported: true);
|
||||
|
||||
using (pool.BeginScope("world"))
|
||||
{
|
||||
}
|
||||
using (pool.BeginScope("ui"))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.Equal(4, api.CreatedQueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestingAScopeBeforeDisposingThePreviousOneThrows()
|
||||
{
|
||||
var pool = new GlGpuTimerPool(new FakeTimerQueryApi(), isSupported: true);
|
||||
pool.BeginScope("world");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => pool.BeginScope("ui"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AResolvedResultIsPromotedTheNextTimeTheSameScopeBegins()
|
||||
{
|
||||
var api = new FakeTimerQueryApi();
|
||||
var pool = new GlGpuTimerPool(api, isSupported: true);
|
||||
|
||||
using (pool.BeginScope("world"))
|
||||
{
|
||||
}
|
||||
// No result available yet — the first slot hasn't been revisited.
|
||||
Assert.False(pool.TryResolve("world", out _));
|
||||
|
||||
api.MakeNextResultReady(milliseconds: 1.5);
|
||||
using (pool.BeginScope("world"))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.True(pool.TryResolve("world", out double milliseconds));
|
||||
Assert.Equal(1.5, milliseconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeQueriesDeletesEveryCreatedQuery()
|
||||
{
|
||||
var api = new FakeTimerQueryApi();
|
||||
var pool = new GlGpuTimerPool(api, isSupported: true);
|
||||
using (pool.BeginScope("world"))
|
||||
{
|
||||
}
|
||||
|
||||
pool.DisposeQueries();
|
||||
|
||||
Assert.Equal(2, api.DeletedQueryCount);
|
||||
}
|
||||
|
||||
private sealed class FakeTimerQueryApi : IGlTimerQueryApi
|
||||
{
|
||||
private uint _nextQuery = 1;
|
||||
private bool _nextResultReady;
|
||||
private double _nextResultMilliseconds;
|
||||
|
||||
public int CreatedQueryCount { get; private set; }
|
||||
public int DeletedQueryCount { get; private set; }
|
||||
|
||||
public uint CreateQuery()
|
||||
{
|
||||
CreatedQueryCount++;
|
||||
return _nextQuery++;
|
||||
}
|
||||
|
||||
public void DeleteQuery(uint query) => DeletedQueryCount++;
|
||||
|
||||
public void Begin(uint query)
|
||||
{
|
||||
}
|
||||
|
||||
public void End()
|
||||
{
|
||||
}
|
||||
|
||||
public void MakeNextResultReady(double milliseconds)
|
||||
{
|
||||
_nextResultReady = true;
|
||||
_nextResultMilliseconds = milliseconds;
|
||||
}
|
||||
|
||||
public bool TryGetResult(uint query, out double milliseconds)
|
||||
{
|
||||
if (_nextResultReady)
|
||||
{
|
||||
milliseconds = _nextResultMilliseconds;
|
||||
_nextResultReady = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
milliseconds = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
using AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
|
||||
|
||||
public sealed class GlPushConstantUniformNamesTests
|
||||
{
|
||||
[Fact]
|
||||
public void EveryDocumentedFieldMapsToItsDocumentedUniformName()
|
||||
{
|
||||
Assert.Equal("uViewProjection", GlPushConstantUniformNames.ByFieldName["ViewProjection"]);
|
||||
Assert.Equal("uDrawIDOffset", GlPushConstantUniformNames.ByFieldName["DrawIdOffset"]);
|
||||
Assert.Equal("uLightingMode", GlPushConstantUniformNames.ByFieldName["LightingMode"]);
|
||||
Assert.Equal("uRenderPass", GlPushConstantUniformNames.ByFieldName["RenderPass"]);
|
||||
Assert.Equal("uLightDebug", GlPushConstantUniformNames.ByFieldName["LightDebug"]);
|
||||
Assert.Equal("uTextureIndexA", GlPushConstantUniformNames.ByFieldName["TextureIndexA"]);
|
||||
Assert.Equal("uTextureIndexB", GlPushConstantUniformNames.ByFieldName["TextureIndexB"]);
|
||||
Assert.Equal("uParamA", GlPushConstantUniformNames.ByFieldName["ParamA"]);
|
||||
Assert.Equal("uParamB", GlPushConstantUniformNames.ByFieldName["ParamB"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TableCoversEveryFieldOnTheSharedStructWithNoStaleEntries()
|
||||
{
|
||||
// This is the drift guard: if a later slice adds/removes/renames a
|
||||
// GpuPushConstants field without updating the mapping table, this
|
||||
// test fails instead of the GL backend silently skipping a uniform.
|
||||
GlPushConstantUniformNames.AssertMapsEveryField();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TableHasExactlyNineEntries()
|
||||
{
|
||||
Assert.Equal(9, GlPushConstantUniformNames.ByFieldName.Count);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
|
||||
|
||||
public sealed class GlRenderStateCacheTests
|
||||
{
|
||||
private static GlRenderStateSnapshot Default(uint program = 1) => new(
|
||||
program,
|
||||
GpuBlendMode.None,
|
||||
DepthTest: true,
|
||||
DepthWrite: true,
|
||||
GpuCompareOp.LessOrEqual,
|
||||
GpuCullMode.Back,
|
||||
GpuFrontFace.CounterClockwise,
|
||||
AlphaToCoverage: false,
|
||||
ColorWrite: true,
|
||||
// Slice V6l: the stencil dimension. Off is what every pipeline in the
|
||||
// tree but the portal depth mask asks for.
|
||||
StencilTest: false,
|
||||
GpuStencilState.Default);
|
||||
|
||||
[Fact]
|
||||
public void FirstApplyReportsEveryDimensionChanged()
|
||||
{
|
||||
var cache = new GlRenderStateCache();
|
||||
GlRenderStateChanges changes = cache.Apply(Default());
|
||||
Assert.Equal(GlRenderStateChanges.All, changes);
|
||||
Assert.True(changes.AnyChange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReapplyingTheIdenticalSnapshotReportsNoChanges()
|
||||
{
|
||||
var cache = new GlRenderStateCache();
|
||||
GlRenderStateSnapshot snapshot = Default();
|
||||
cache.Apply(snapshot);
|
||||
|
||||
GlRenderStateChanges changes = cache.Apply(snapshot);
|
||||
|
||||
Assert.False(changes.AnyChange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangingOnlyCullModeReportsOnlyThatDimension()
|
||||
{
|
||||
var cache = new GlRenderStateCache();
|
||||
cache.Apply(Default());
|
||||
|
||||
GlRenderStateChanges changes = cache.Apply(Default() with { Cull = GpuCullMode.None });
|
||||
|
||||
Assert.True(changes.Cull);
|
||||
Assert.True(changes.AnyChange);
|
||||
Assert.False(changes.Program);
|
||||
Assert.False(changes.Blend);
|
||||
Assert.False(changes.DepthTest);
|
||||
Assert.False(changes.DepthWrite);
|
||||
Assert.False(changes.DepthCompare);
|
||||
Assert.False(changes.FrontFace);
|
||||
Assert.False(changes.AlphaToCoverage);
|
||||
Assert.False(changes.ColorWrite);
|
||||
Assert.False(changes.StencilTest);
|
||||
Assert.False(changes.Stencil);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangingOnlyTheStencilValuesReportsOnlyThatDimension()
|
||||
{
|
||||
// Slice V6l. #117's portal punch changes compare, ops, reference and
|
||||
// masks between its mark pass and its punch pass while the test stays
|
||||
// enabled, so the two stencil dimensions have to move independently.
|
||||
var cache = new GlRenderStateCache();
|
||||
cache.Apply(Default() with { StencilTest = true });
|
||||
|
||||
GlRenderStateChanges changes = cache.Apply(Default() with
|
||||
{
|
||||
StencilTest = true,
|
||||
Stencil = GpuStencilState.Default with
|
||||
{
|
||||
Compare = GpuCompareOp.Equal,
|
||||
Pass = GpuStencilOp.Zero,
|
||||
Reference = 1,
|
||||
},
|
||||
});
|
||||
|
||||
Assert.True(changes.Stencil);
|
||||
Assert.False(changes.StencilTest);
|
||||
Assert.False(changes.DepthTest);
|
||||
Assert.False(changes.ColorWrite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangingTheProgramReportsProgramChangedEvenWhenEveryOtherFieldMatches()
|
||||
{
|
||||
var cache = new GlRenderStateCache();
|
||||
cache.Apply(Default(program: 1));
|
||||
|
||||
GlRenderStateChanges changes = cache.Apply(Default(program: 2));
|
||||
|
||||
Assert.True(changes.Program);
|
||||
Assert.False(changes.Blend);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetForcesTheNextApplyToReportEveryDimensionAgain()
|
||||
{
|
||||
var cache = new GlRenderStateCache();
|
||||
GlRenderStateSnapshot snapshot = Default();
|
||||
cache.Apply(snapshot);
|
||||
|
||||
cache.Reset();
|
||||
GlRenderStateChanges changes = cache.Apply(snapshot);
|
||||
|
||||
Assert.Equal(GlRenderStateChanges.All, changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleDimensionsChangingAreAllReported()
|
||||
{
|
||||
var cache = new GlRenderStateCache();
|
||||
cache.Apply(Default());
|
||||
|
||||
GlRenderStateChanges changes = cache.Apply(Default() with
|
||||
{
|
||||
Blend = GpuBlendMode.StraightAlpha,
|
||||
DepthWrite = false,
|
||||
FrontFace = GpuFrontFace.Clockwise,
|
||||
});
|
||||
|
||||
Assert.True(changes.Blend);
|
||||
Assert.True(changes.DepthWrite);
|
||||
Assert.True(changes.FrontFace);
|
||||
Assert.False(changes.Cull);
|
||||
Assert.False(changes.DepthTest);
|
||||
Assert.False(changes.DepthCompare);
|
||||
Assert.False(changes.AlphaToCoverage);
|
||||
Assert.False(changes.ColorWrite);
|
||||
Assert.False(changes.Program);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
using AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
|
||||
|
||||
public sealed class GlRingBufferStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void FirstAllocationStartsAtZero()
|
||||
{
|
||||
var state = new GlRingBufferState(1024);
|
||||
uint offset = state.Allocate(64, alignmentBytes: 16);
|
||||
Assert.Equal(0u, offset);
|
||||
Assert.Equal(64u, state.AllocatedBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubsequentAllocationsAlignUpToTheRequestedBoundary()
|
||||
{
|
||||
var state = new GlRingBufferState(1024);
|
||||
state.Allocate(12, alignmentBytes: 4);
|
||||
uint second = state.Allocate(64, alignmentBytes: 256);
|
||||
Assert.Equal(0u, second % 256);
|
||||
Assert.True(second >= 12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllocationsMergeIntoOneDirtyRange()
|
||||
{
|
||||
var state = new GlRingBufferState(1024);
|
||||
state.Allocate(16, alignmentBytes: 4);
|
||||
state.Allocate(32, alignmentBytes: 4);
|
||||
|
||||
(int start, int length) = state.TakeDirtyRange();
|
||||
Assert.Equal(0, start);
|
||||
Assert.Equal(48, length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TakingTheDirtyRangeClearsItUntilTheNextWrite()
|
||||
{
|
||||
var state = new GlRingBufferState(1024);
|
||||
state.Allocate(16, alignmentBytes: 4);
|
||||
state.TakeDirtyRange();
|
||||
|
||||
(int start, int length) = state.TakeDirtyRange();
|
||||
Assert.Equal(0, start);
|
||||
Assert.Equal(0, length);
|
||||
Assert.False(state.HasDirtyBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WritesAfterAFlushExtendANewDirtyRangeWithoutRewindingTheCursor()
|
||||
{
|
||||
var state = new GlRingBufferState(1024);
|
||||
state.Allocate(16, alignmentBytes: 4);
|
||||
state.TakeDirtyRange();
|
||||
|
||||
uint secondOffset = state.Allocate(8, alignmentBytes: 4);
|
||||
Assert.Equal(16u, secondOffset);
|
||||
|
||||
(int start, int length) = state.TakeDirtyRange();
|
||||
Assert.Equal(16, start);
|
||||
Assert.Equal(8, length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The precondition for issuing the ring's writes with
|
||||
/// GL_MAP_UNSYNCHRONIZED_BIT: within one frame, no flush may cover a byte an
|
||||
/// earlier flush already handed to the GPU. This walks a frame's worth of
|
||||
/// mixed-alignment allocations and asserts the ranges are disjoint and
|
||||
/// ascending.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SuccessiveDirtyRangesWithinAFrameNeverOverlap()
|
||||
{
|
||||
var state = new GlRingBufferState(64 * 1024);
|
||||
int previousEnd = 0;
|
||||
|
||||
foreach (int size in new[] { 100, 4, 1024, 36, 7, 512, 3 })
|
||||
{
|
||||
state.Allocate(size, alignmentBytes: 256);
|
||||
state.Allocate(size, alignmentBytes: 4);
|
||||
|
||||
(int start, int length) = state.TakeDirtyRange();
|
||||
Assert.True(
|
||||
start >= previousEnd,
|
||||
$"flush [{start}, {start + length}) reaches below the already-flushed {previousEnd} bytes");
|
||||
previousEnd = start + length;
|
||||
Assert.Equal(previousEnd, state.FlushedEndBytes);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AWriteBelowTheFlushedHighWaterMarkIsRefused()
|
||||
{
|
||||
var state = new GlRingBufferState(1024);
|
||||
state.Allocate(128, alignmentBytes: 4);
|
||||
state.TakeDirtyRange();
|
||||
|
||||
Assert.Equal(128, state.FlushedEndBytes);
|
||||
InvalidOperationException error = Assert.Throws<InvalidOperationException>(
|
||||
() => state.MarkDirty(64, 96));
|
||||
Assert.Contains("GL_MAP_UNSYNCHRONIZED_BIT", error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlushedHighWaterMarkOnlyAdvancesWhenSomethingWasFlushed()
|
||||
{
|
||||
var state = new GlRingBufferState(1024);
|
||||
state.Allocate(48, alignmentBytes: 4);
|
||||
state.TakeDirtyRange();
|
||||
Assert.Equal(48, state.FlushedEndBytes);
|
||||
|
||||
// A draw with nothing newly written must not move the mark, or the next
|
||||
// allocation's guard would compare against a number no flush produced.
|
||||
state.TakeDirtyRange();
|
||||
Assert.Equal(48, state.FlushedEndBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetClearsTheFlushedHighWaterMarkSoTheSlotIsWritableAgain()
|
||||
{
|
||||
var state = new GlRingBufferState(1024);
|
||||
state.Allocate(256, alignmentBytes: 4);
|
||||
state.TakeDirtyRange();
|
||||
Assert.Equal(256, state.FlushedEndBytes);
|
||||
|
||||
// BeginFrame has waited on this slot's fence by the time Reset runs, so
|
||||
// the whole slot is writable from zero again.
|
||||
state.Reset();
|
||||
|
||||
Assert.Equal(0, state.FlushedEndBytes);
|
||||
Assert.Equal(0u, state.Allocate(64, alignmentBytes: 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetRewindsTheCursorAndClearsDirtyState()
|
||||
{
|
||||
var state = new GlRingBufferState(1024);
|
||||
state.Allocate(64, alignmentBytes: 4);
|
||||
|
||||
state.Reset();
|
||||
|
||||
Assert.Equal(0u, state.AllocatedBytes);
|
||||
Assert.False(state.HasDirtyBytes);
|
||||
Assert.Equal(0u, state.Allocate(4, alignmentBytes: 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverCapacityRequestThrowsRatherThanTruncating()
|
||||
{
|
||||
var state = new GlRingBufferState(64);
|
||||
Assert.Throws<InvalidOperationException>(() => state.Allocate(128, alignmentBytes: 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlignmentPushingPastCapacityAlsoThrows()
|
||||
{
|
||||
var state = new GlRingBufferState(64);
|
||||
state.Allocate(60, alignmentBytes: 4);
|
||||
Assert.Throws<InvalidOperationException>(() => state.Allocate(8, alignmentBytes: 4));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u, 256u, 0u)]
|
||||
[InlineData(1u, 256u, 256u)]
|
||||
[InlineData(255u, 256u, 256u)]
|
||||
[InlineData(256u, 256u, 256u)]
|
||||
[InlineData(10u, 1u, 10u)]
|
||||
public void AlignUpMatchesStandardAlignmentArithmetic(uint value, uint alignment, uint expected)
|
||||
{
|
||||
Assert.Equal(expected, GlRingBufferState.AlignUp(value, alignment));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroByteAllocationDoesNotMarkAnythingDirty()
|
||||
{
|
||||
var state = new GlRingBufferState(1024);
|
||||
state.Allocate(0, alignmentBytes: 4);
|
||||
Assert.False(state.HasDirtyBytes);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
using AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
|
||||
|
||||
public sealed class GlTextureSlotAllocatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void AllocationsBumpSequentiallyFromZero()
|
||||
{
|
||||
var allocator = new GlTextureSlotAllocator(capacity: 4);
|
||||
Assert.Equal(0u, allocator.Allocate());
|
||||
Assert.Equal(1u, allocator.Allocate());
|
||||
Assert.Equal(2u, allocator.Allocate());
|
||||
Assert.Equal(3, allocator.LiveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExhaustingCapacityThrows()
|
||||
{
|
||||
var allocator = new GlTextureSlotAllocator(capacity: 2);
|
||||
allocator.Allocate();
|
||||
allocator.Allocate();
|
||||
Assert.Throws<InvalidOperationException>(() => allocator.Allocate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReleasedSlotsAreReusedBeforeBumpingFurther()
|
||||
{
|
||||
var allocator = new GlTextureSlotAllocator(capacity: 4);
|
||||
uint first = allocator.Allocate();
|
||||
allocator.Allocate();
|
||||
|
||||
allocator.Release(first);
|
||||
Assert.Equal(1, allocator.LiveCount);
|
||||
|
||||
uint reused = allocator.Allocate();
|
||||
Assert.Equal(first, reused);
|
||||
Assert.Equal(2, allocator.LiveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReleasingAnUnallocatedSlotThrows()
|
||||
{
|
||||
var allocator = new GlTextureSlotAllocator(capacity: 4);
|
||||
allocator.Allocate();
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => allocator.Release(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreeingThenExhaustingTheBumpRangeStillThrowsPastCapacity()
|
||||
{
|
||||
var allocator = new GlTextureSlotAllocator(capacity: 2);
|
||||
uint first = allocator.Allocate();
|
||||
allocator.Allocate();
|
||||
allocator.Release(first);
|
||||
allocator.Allocate();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => allocator.Allocate());
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ using AcDream.App.Rendering;
|
|||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
|
|
@ -192,40 +191,6 @@ public sealed class GpuResourceRetirementTransactionTests
|
|||
Assert.Equal(2, queue.Actions.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlQueue_PersistentNextPassRetryDoesNotStarveOrdinaryWork()
|
||||
{
|
||||
var device = new QueueOnlyGraphicsDevice();
|
||||
int retryCalls = 0;
|
||||
int ordinaryCalls = 0;
|
||||
Action<GL>? retry = null;
|
||||
retry = _ =>
|
||||
{
|
||||
retryCalls++;
|
||||
device.QueueGLActionForNextPass(retry!);
|
||||
};
|
||||
device.QueueGLActionForNextPass(retry);
|
||||
device.QueueGLAction(_ => ordinaryCalls++);
|
||||
|
||||
device.ProcessGLQueue();
|
||||
|
||||
Assert.Equal(1, retryCalls);
|
||||
Assert.Equal(1, ordinaryCalls);
|
||||
Assert.True(device.HasPendingGLWork);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlQueue_ReportsPendingWorkUntilBothGenerationsDrain()
|
||||
{
|
||||
var device = new QueueOnlyGraphicsDevice();
|
||||
device.QueueGLAction(_ => { });
|
||||
|
||||
Assert.True(device.HasPendingGLWork);
|
||||
device.ProcessGLQueue();
|
||||
|
||||
Assert.False(device.HasPendingGLWork);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V4b: the mesh arena's staged store is an
|
||||
/// <see cref="IGpuBuffer"/> rather than a raw GL name, so the abort ticket
|
||||
|
|
@ -309,46 +274,6 @@ public sealed class GpuResourceRetirementTransactionTests
|
|||
Assert.Equal(1, accountingCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalMeshVaoAccounting_CreateAndRetryableDeleteBalanceExactlyOnce()
|
||||
{
|
||||
int baseline = GpuMemoryTracker.VaoCount;
|
||||
bool allocationOutstanding = true;
|
||||
GlobalMeshVaoAccounting.TrackAllocation();
|
||||
try
|
||||
{
|
||||
Assert.Equal(baseline + 1, GpuMemoryTracker.VaoCount);
|
||||
var release = new RetryableGpuResourceRelease(
|
||||
() => { },
|
||||
() =>
|
||||
{
|
||||
GlobalMeshVaoAccounting.TrackDeallocation();
|
||||
allocationOutstanding = false;
|
||||
});
|
||||
|
||||
release.Run();
|
||||
release.Run();
|
||||
|
||||
Assert.Equal(baseline, GpuMemoryTracker.VaoCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (allocationOutstanding)
|
||||
GlobalMeshVaoAccounting.TrackDeallocation();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalMeshVaoAccounting_InitializationRollbackReturnsToBaseline()
|
||||
{
|
||||
int baseline = GpuMemoryTracker.VaoCount;
|
||||
GlobalMeshVaoAccounting.TrackAllocation();
|
||||
|
||||
GlobalMeshVaoAccounting.TrackDeallocation();
|
||||
|
||||
Assert.Equal(baseline, GpuMemoryTracker.VaoCount);
|
||||
}
|
||||
|
||||
private sealed class FailBeforeAcceptQueue : IGpuResourceRetirementQueue
|
||||
{
|
||||
private bool _fail = true;
|
||||
|
|
@ -378,11 +303,4 @@ public sealed class GpuResourceRetirementTransactionTests
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class QueueOnlyGraphicsDevice : OpenGLGraphicsDevice
|
||||
{
|
||||
public QueueOnlyGraphicsDevice()
|
||||
: base()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6l: the portal depth mask is the one renderer whose two arms
|
||||
/// do NOT share a shader source, and this is the tripwire that stops them
|
||||
/// drifting.
|
||||
///
|
||||
/// <para>Every other RHI arm in the campaign compiles the same GLSL file the GL
|
||||
/// arm does. This one cannot: its clip planes would have to travel in the
|
||||
/// <c>TerrainClip</c> uniform block at binding 2, and on GL that binding is held
|
||||
/// globally by <c>ClipFrame</c> for terrain — a portal draw that rebound it would
|
||||
/// leave every later terrain draw in the frame reading the wrong region. So the
|
||||
/// GL arm keeps its inline program, the RHI arm compiles
|
||||
/// <c>Rendering/Shaders/portal_depth.vert</c>, and the numbers that decide where
|
||||
/// depth lands are asserted to appear in both.</para>
|
||||
///
|
||||
/// <para>Deleted with the GL arm at V11, when the inline string goes.</para>
|
||||
/// </summary>
|
||||
public class PortalDepthShaderParityTests
|
||||
{
|
||||
private static string PortalDepthVertexSource() =>
|
||||
File.ReadAllText(Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Rendering",
|
||||
"Shaders",
|
||||
"portal_depth.vert"));
|
||||
|
||||
[Fact]
|
||||
public void BothArmsPunchWithRetailsFarZConstant()
|
||||
{
|
||||
// 0.99999988 is retail's own value at the tail of
|
||||
// D3DPolyRender::DrawPortalPolyInternal (0x0059bc90). It decides where
|
||||
// the punched depth lands, so the two arms agreeing on it is the whole
|
||||
// point of this file.
|
||||
const string FarZ = "0.99999988";
|
||||
Assert.Contains(FarZ, PortalDepthMaskRenderer.VertSrc, StringComparison.Ordinal);
|
||||
Assert.Contains(FarZ, PortalDepthVertexSource(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothArmsApplyTheSameEyeCappedMarkBias()
|
||||
{
|
||||
// #129's capped bias: min(bias, cap / max(w*w, 1e-6)), then z -= bias*w.
|
||||
// Spelled with different identifier names on the two arms — the RHI arm
|
||||
// reads the shared push block's uParamA/uParamB — so the assertion is on
|
||||
// the SHAPE that decides the result rather than on the text.
|
||||
string rhi = PortalDepthVertexSource();
|
||||
Assert.Contains("max(clipPos.w * clipPos.w, 1e-6)", PortalDepthMaskRenderer.VertSrc, StringComparison.Ordinal);
|
||||
Assert.Contains("max(clipPos.w * clipPos.w, 1e-6)", rhi, StringComparison.Ordinal);
|
||||
Assert.Contains("clipPos.z -= biasNdc * clipPos.w", PortalDepthMaskRenderer.VertSrc, StringComparison.Ordinal);
|
||||
Assert.Contains("clipPos.z -= biasNdc * clipPos.w", rhi, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothArmsClipAgainstEightHalfPlanes()
|
||||
{
|
||||
// The plane budget is ClipFrame.MaxPlanes and GL's guaranteed
|
||||
// GL_MAX_CLIP_DISTANCES; a shader that looped to a different number
|
||||
// would clip a differently-shaped region than the one the CPU published.
|
||||
Assert.Equal(8, ClipFrame.MaxPlanes);
|
||||
Assert.Contains("for (int i = 0; i < 8; i++)", PortalDepthMaskRenderer.VertSrc, StringComparison.Ordinal);
|
||||
Assert.Contains("for (int i = 0; i < 8; i++)", PortalDepthVertexSource(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheRhiArmReadsTheClipPlanesFromTheSharedTerrainClipBlock()
|
||||
{
|
||||
string rhi = PortalDepthVertexSource();
|
||||
Assert.Contains("uniform TerrainClip", rhi, StringComparison.Ordinal);
|
||||
Assert.Contains("binding = 2", rhi, StringComparison.Ordinal);
|
||||
// And the block is the one ClipFrame already packs for terrain and sky:
|
||||
// an int count padded to 16 bytes, then eight vec4 planes.
|
||||
Assert.Equal(144, ClipFrame.TerrainUboBytes);
|
||||
Assert.Equal(2u, ClipFrame.TerrainClipUboBinding);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,17 +13,6 @@ namespace AcDream.App.Tests.Rendering;
|
|||
|
||||
public sealed class PortalTunnelAssetTests
|
||||
{
|
||||
[Fact]
|
||||
public void PortalViewport_PreservesColorWhileClearingDepthLikeRetail()
|
||||
{
|
||||
Assert.Equal(
|
||||
Silk.NET.OpenGL.ClearBufferMask.DepthBufferBit,
|
||||
PortalTunnelPresentation.RetailViewportClearMask);
|
||||
Assert.False(
|
||||
(PortalTunnelPresentation.RetailViewportClearMask
|
||||
& Silk.NET.OpenGL.ClearBufferMask.ColorBufferBit) != 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6m. The RHI arm cannot inherit a bound framebuffer, so
|
||||
/// its pass re-establishes the colour the frame already cleared. That is
|
||||
|
|
@ -40,19 +29,18 @@ public sealed class PortalTunnelAssetTests
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6m. A backend with no GL context draws portal space
|
||||
/// Campaign V slice V6m (the raw-GL arm deleted at V11): portal space draws
|
||||
/// through a pass of its own, so it must be given somewhere to open one.
|
||||
/// Composition failing loudly here beats a null dereference at the first
|
||||
/// portal transit, which is minutes into a connected run.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PortalSpace_BackendWithoutGlRequiresAPassScopeAndFrameSource()
|
||||
public void PortalSpace_RequiresAPassScope()
|
||||
{
|
||||
var error = Assert.Throws<ArgumentNullException>(() =>
|
||||
PortalTunnelPresentation.CreateRequired(
|
||||
gl: null,
|
||||
scope: null,
|
||||
frames: null,
|
||||
scope: null!,
|
||||
frames: null!,
|
||||
dats: null!,
|
||||
animationLoader: null!,
|
||||
hookSink: null!,
|
||||
|
|
@ -61,7 +49,6 @@ public sealed class PortalTunnelAssetTests
|
|||
meshAdapter: null!));
|
||||
|
||||
Assert.Equal("scope", error.ParamName);
|
||||
Assert.Contains("world pass scope", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
using AcDream.App.Rendering;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class RenderFrameGlStateControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void RestoreFrameDefaults_ReestablishesTheCompleteFrameGlobalContract()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var state = new RenderFrameGlStateController(api);
|
||||
|
||||
state.RestoreFrameDefaults();
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"cap:ScissorTest:False",
|
||||
"cap:StencilTest:False",
|
||||
"cap:Blend:False",
|
||||
"cap:SampleAlphaToMaskSgis:False",
|
||||
"cap:ClipDistance0:False",
|
||||
"cap:ClipDistance1:False",
|
||||
"cap:ClipDistance2:False",
|
||||
"cap:ClipDistance3:False",
|
||||
"cap:ClipDistance4:False",
|
||||
"cap:ClipDistance5:False",
|
||||
"cap:ClipDistance6:False",
|
||||
"cap:ClipDistance7:False",
|
||||
"color-mask:True:True:True:True",
|
||||
"stencil-mask:255",
|
||||
"depth-mask:True",
|
||||
"depth-func:Less",
|
||||
"cap:DepthTest:True",
|
||||
"cap:CullFace:False",
|
||||
"cull:Back",
|
||||
"front:CW",
|
||||
"program:0",
|
||||
"vao:0",
|
||||
"buffer:ArrayBuffer:0",
|
||||
],
|
||||
api.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortalDepthMask_SuccessExitRestoresTheSameCullOffConvention()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"PortalDepthMaskRenderer.cs"));
|
||||
int restore = source.IndexOf(
|
||||
"// ---- restore the frame-global convention ----",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
Assert.True(restore >= 0, "Missing portal-depth state restore block.");
|
||||
string restoreBlock = source[restore..];
|
||||
Assert.Contains("_gl.Disable(EnableCap.CullFace);", restoreBlock);
|
||||
Assert.DoesNotContain("_gl.Enable(EnableCap.CullFace);", restoreBlock);
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
private sealed class RecordingApi : IRenderFrameGlStateApi
|
||||
{
|
||||
public List<string> Calls { get; } = [];
|
||||
|
||||
public void SetCapability(EnableCap capability, bool enabled) =>
|
||||
Calls.Add($"cap:{capability}:{enabled}");
|
||||
|
||||
public void ColorMask(bool red, bool green, bool blue, bool alpha) =>
|
||||
Calls.Add($"color-mask:{red}:{green}:{blue}:{alpha}");
|
||||
|
||||
public void StencilMask(uint mask) => Calls.Add($"stencil-mask:{mask}");
|
||||
|
||||
public void DepthMask(bool enabled) => Calls.Add($"depth-mask:{enabled}");
|
||||
|
||||
public void DepthFunc(DepthFunction function) =>
|
||||
Calls.Add($"depth-func:{function}");
|
||||
|
||||
public void CullFace(TriangleFace face) => Calls.Add($"cull:{face}");
|
||||
|
||||
public void FrontFace(FrontFaceDirection direction) =>
|
||||
Calls.Add($"front:{direction}");
|
||||
|
||||
public void UseProgram(uint program) => Calls.Add($"program:{program}");
|
||||
|
||||
public void BindVertexArray(uint vertexArray) => Calls.Add($"vao:{vertexArray}");
|
||||
|
||||
public void BindBuffer(BufferTargetARB target, uint buffer) =>
|
||||
Calls.Add($"buffer:{target}:{buffer}");
|
||||
}
|
||||
}
|
||||
|
|
@ -58,8 +58,8 @@ public sealed class ResourceCleanupGroupTests
|
|||
throw new InvalidOperationException("release failed");
|
||||
});
|
||||
|
||||
GlResourceConstructionException failure =
|
||||
Assert.Throws<GlResourceConstructionException>(() =>
|
||||
ResourceConstructionException failure =
|
||||
Assert.Throws<ResourceConstructionException>(() =>
|
||||
resources.RollbackConstructionAndThrow(
|
||||
"construction failed",
|
||||
new InvalidOperationException("original failure")));
|
||||
|
|
@ -74,62 +74,6 @@ public sealed class ResourceCleanupGroupTests
|
|||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V4a moved TextRenderer's constructor off raw
|
||||
/// VAO/VBO/texture GL names and onto two device-owned resources — a
|
||||
|
|
@ -176,49 +120,6 @@ public sealed class ResourceCleanupGroupTests
|
|||
return count;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnedNameIsRetainedWhenPostconditionAndFirstRollbackFail()
|
||||
{
|
||||
bool deleteFails = true;
|
||||
int deleteCalls = 0;
|
||||
|
||||
GlResourceConstructionException failure =
|
||||
Assert.Throws<GlResourceConstructionException>(() =>
|
||||
GlResourceCommand.CreateNameCore(
|
||||
"synthetic buffer",
|
||||
static () => { },
|
||||
() => 42,
|
||||
() => throw new InvalidOperationException("postcondition failed"),
|
||||
name =>
|
||||
{
|
||||
Assert.Equal(42u, name);
|
||||
deleteCalls++;
|
||||
if (deleteFails)
|
||||
throw new InvalidOperationException("delete failed");
|
||||
}));
|
||||
|
||||
Assert.False(failure.IsCleanupComplete);
|
||||
Assert.Equal(1, deleteCalls);
|
||||
|
||||
deleteFails = false;
|
||||
failure.RetryCleanup();
|
||||
failure.RetryCleanup();
|
||||
|
||||
Assert.True(failure.IsCleanupComplete);
|
||||
Assert.Equal(2, deleteCalls);
|
||||
}
|
||||
|
||||
private static void AssertAppearsInOrder(string source, params string[] values)
|
||||
{
|
||||
int cursor = -1;
|
||||
foreach (string value in values)
|
||||
{
|
||||
int next = source.IndexOf(value, cursor + 1, StringComparison.Ordinal);
|
||||
Assert.True(next > cursor, $"Missing or out-of-order source fragment: {value}");
|
||||
cursor = next;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
|
|
|
|||
|
|
@ -1,197 +0,0 @@
|
|||
using AcDream.App.Rendering;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class ShaderProgramConstructionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ShaderSource:VertexShader", 1, 0)]
|
||||
[InlineData("CompileShader:VertexShader", 1, 0)]
|
||||
[InlineData("CreateShader:FragmentShader", 1, 0)]
|
||||
[InlineData("ShaderSource:FragmentShader", 2, 0)]
|
||||
[InlineData("CompileShader:FragmentShader", 2, 0)]
|
||||
[InlineData("CreateProgram", 2, 0)]
|
||||
[InlineData("AttachShader:VertexShader", 2, 1)]
|
||||
[InlineData("AttachShader:FragmentShader", 2, 1)]
|
||||
[InlineData("LinkProgram", 2, 1)]
|
||||
public void ThrowAfterEachAllocatedNameRollsBackEveryPublishedName(
|
||||
string failingOperation,
|
||||
int expectedShaderDeletes,
|
||||
int expectedProgramDeletes)
|
||||
{
|
||||
var api = new FakeShaderApi { ThrowOn = failingOperation };
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
ShaderProgramConstruction.Build(api, "vertex", "fragment"));
|
||||
|
||||
Assert.Equal(expectedShaderDeletes, api.DeletedShaders.Count);
|
||||
Assert.Equal(expectedProgramDeletes, api.DeletedPrograms.Count);
|
||||
Assert.Equal(api.CreatedShaders, api.DeletedShaders.Order().ToArray());
|
||||
Assert.Equal(api.CreatedPrograms, api.DeletedPrograms.Order().ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompileFailurePreservesOriginalFailureWhenRollbackIsClean()
|
||||
{
|
||||
var api = new FakeShaderApi { VertexCompiles = false };
|
||||
|
||||
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(() =>
|
||||
ShaderProgramConstruction.Build(api, "vertex", "fragment"));
|
||||
|
||||
Assert.Contains("VertexShader compile failed", failure.Message);
|
||||
Assert.Equal([1u], api.DeletedShaders);
|
||||
Assert.Empty(api.CreatedPrograms);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LinkFailureDetachesAndDeletesBothShadersAndProgram()
|
||||
{
|
||||
var api = new FakeShaderApi { ProgramLinks = false };
|
||||
|
||||
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(() =>
|
||||
ShaderProgramConstruction.Build(api, "vertex", "fragment"));
|
||||
|
||||
Assert.Contains("program link failed", failure.Message);
|
||||
Assert.Equal(["DetachShader:VertexShader", "DetachShader:FragmentShader"], api.DetachCalls);
|
||||
Assert.Equal([1u, 2u], api.DeletedShaders);
|
||||
Assert.Equal([3u], api.DeletedPrograms);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessfulBuildCommitsOnlyProgramAndReleasesTemporaryShaders()
|
||||
{
|
||||
var api = new FakeShaderApi();
|
||||
|
||||
uint program = ShaderProgramConstruction.Build(api, "vertex", "fragment");
|
||||
|
||||
Assert.Equal(3u, program);
|
||||
Assert.Equal([1u, 2u], api.DeletedShaders);
|
||||
Assert.Empty(api.DeletedPrograms);
|
||||
Assert.Equal(["DetachShader:VertexShader", "DetachShader:FragmentShader"], api.DetachCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CleanupFailureStillAttemptsEveryOtherReleaseAndProgramRollback()
|
||||
{
|
||||
var api = new FakeShaderApi { ThrowOn = "DetachShader:VertexShader" };
|
||||
|
||||
AggregateException failure = Assert.Throws<AggregateException>(() =>
|
||||
ShaderProgramConstruction.Build(api, "vertex", "fragment"));
|
||||
|
||||
Assert.Single(failure.InnerExceptions);
|
||||
Assert.Equal(["DetachShader:VertexShader", "DetachShader:FragmentShader"], api.DetachCalls);
|
||||
Assert.Equal([1u, 2u], api.DeletedShaders);
|
||||
Assert.Equal([3u], api.DeletedPrograms);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructionAndRollbackFailuresAreReportedTogether()
|
||||
{
|
||||
var api = new FakeShaderApi
|
||||
{
|
||||
ProgramLinks = false,
|
||||
ThrowOn = "DeleteShader:VertexShader",
|
||||
};
|
||||
|
||||
GlResourceConstructionException failure = Assert.Throws<GlResourceConstructionException>(() =>
|
||||
ShaderProgramConstruction.Build(api, "vertex", "fragment"));
|
||||
|
||||
Assert.Equal(2, failure.InnerExceptions.Count);
|
||||
Assert.Contains("program link failed", failure.InnerExceptions[0].Message);
|
||||
Assert.Equal([1u, 2u], api.DeleteShaderAttempts);
|
||||
Assert.Equal([2u], api.DeletedShaders);
|
||||
Assert.Equal([3u], api.DeletedPrograms);
|
||||
|
||||
api.ThrowOn = null;
|
||||
failure.RetryCleanup();
|
||||
|
||||
Assert.True(failure.IsCleanupComplete);
|
||||
Assert.Equal([2u, 1u], api.DeletedShaders);
|
||||
Assert.Equal([1u, 2u, 1u], api.DeleteShaderAttempts);
|
||||
}
|
||||
|
||||
private sealed class FakeShaderApi : IShaderProgramBuildApi
|
||||
{
|
||||
private readonly Dictionary<uint, ShaderType> _shaderTypes = [];
|
||||
private uint _nextName = 1;
|
||||
|
||||
public string? ThrowOn { get; set; }
|
||||
public bool VertexCompiles { get; init; } = true;
|
||||
public bool FragmentCompiles { get; init; } = true;
|
||||
public bool ProgramLinks { get; init; } = true;
|
||||
public List<uint> CreatedShaders { get; } = [];
|
||||
public List<uint> CreatedPrograms { get; } = [];
|
||||
public List<uint> DeleteShaderAttempts { get; } = [];
|
||||
public List<uint> DeletedShaders { get; } = [];
|
||||
public List<uint> DeletedPrograms { get; } = [];
|
||||
public List<string> DetachCalls { get; } = [];
|
||||
|
||||
public uint CreateShader(ShaderType type)
|
||||
{
|
||||
Fail($"CreateShader:{type}");
|
||||
uint name = _nextName++;
|
||||
CreatedShaders.Add(name);
|
||||
_shaderTypes.Add(name, type);
|
||||
return name;
|
||||
}
|
||||
|
||||
public void ShaderSource(uint shader, string source) =>
|
||||
Fail($"ShaderSource:{TypeOf(shader)}");
|
||||
|
||||
public void CompileShader(uint shader) =>
|
||||
Fail($"CompileShader:{TypeOf(shader)}");
|
||||
|
||||
public int GetShaderCompileStatus(uint shader) => TypeOf(shader) switch
|
||||
{
|
||||
ShaderType.VertexShader => VertexCompiles ? 1 : 0,
|
||||
ShaderType.FragmentShader => FragmentCompiles ? 1 : 0,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
public string GetShaderInfoLog(uint shader) => $"bad {TypeOf(shader)}";
|
||||
|
||||
public uint CreateProgram()
|
||||
{
|
||||
Fail("CreateProgram");
|
||||
uint name = _nextName++;
|
||||
CreatedPrograms.Add(name);
|
||||
return name;
|
||||
}
|
||||
|
||||
public void AttachShader(uint program, uint shader) =>
|
||||
Fail($"AttachShader:{TypeOf(shader)}");
|
||||
|
||||
public void LinkProgram(uint program) => Fail("LinkProgram");
|
||||
public int GetProgramLinkStatus(uint program) => ProgramLinks ? 1 : 0;
|
||||
public string GetProgramInfoLog(uint program) => "bad link";
|
||||
|
||||
public void DetachShader(uint program, uint shader)
|
||||
{
|
||||
string operation = $"DetachShader:{TypeOf(shader)}";
|
||||
DetachCalls.Add(operation);
|
||||
Fail(operation);
|
||||
}
|
||||
|
||||
public void DeleteShader(uint shader)
|
||||
{
|
||||
DeleteShaderAttempts.Add(shader);
|
||||
Fail($"DeleteShader:{TypeOf(shader)}");
|
||||
DeletedShaders.Add(shader);
|
||||
}
|
||||
|
||||
public void DeleteProgram(uint program)
|
||||
{
|
||||
Fail("DeleteProgram");
|
||||
DeletedPrograms.Add(program);
|
||||
}
|
||||
|
||||
private ShaderType TypeOf(uint shader) => _shaderTypes[shader];
|
||||
|
||||
private void Fail(string operation)
|
||||
{
|
||||
if (ThrowOn == operation)
|
||||
throw new InvalidOperationException(operation + " failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,337 +0,0 @@
|
|||
using System.Reflection;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu.Gl;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// The retained UI's whole draw is one RHI pass, and a failure anywhere inside
|
||||
/// it must not leave GL state behind for the raw-GL world renderers that run in
|
||||
/// the next frame. Until Campaign V slice V6d, TextRenderer owned a private
|
||||
/// state scope for that; V6d made the renderer backend-neutral and moved the
|
||||
/// guarantee onto <see cref="GlAmbientCapabilityState"/>, which every RHI pass
|
||||
/// gets. These tests follow it there.
|
||||
/// </summary>
|
||||
public sealed class TextRendererFailureSafetyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Flush_CompilesThePassEncoderAsFinallyAroundBothDrawLayers()
|
||||
{
|
||||
MethodInfo flush = typeof(TextRenderer).GetMethod(nameof(TextRenderer.Flush))!;
|
||||
MethodBody body = flush.GetMethodBody()!;
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"TextRenderer.cs"));
|
||||
|
||||
// The encoder's `using` is the finally: disposing it closes the pass,
|
||||
// which is what restores the ambient capability state the pass changed.
|
||||
Assert.Contains(
|
||||
body.ExceptionHandlingClauses,
|
||||
clause => clause.Flags == ExceptionHandlingClauseOptions.Finally);
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription",
|
||||
"encoder.BindPipeline(_pipeline);",
|
||||
"DrawLayer(_spriteSegs,",
|
||||
"DrawLayer(_overlaySpriteSegs,");
|
||||
|
||||
// And no raw GL of its own is left: the renderer draws on both backends.
|
||||
Assert.DoesNotContain("Silk.NET.OpenGL", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_gl.", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedDraw_RestoresEveryGlValueMutatedByThePass()
|
||||
{
|
||||
var gl = new RecordingGlState
|
||||
{
|
||||
DepthWrite = false,
|
||||
DepthFuncValue = DepthFunction.Greater,
|
||||
BlendSourceRgb = BlendingFactor.DstAlpha,
|
||||
BlendDestinationRgb = BlendingFactor.OneMinusDstAlpha,
|
||||
BlendSourceAlpha = BlendingFactor.One,
|
||||
BlendDestinationAlpha = BlendingFactor.Zero,
|
||||
CullFaceMode = TriangleFace.Front,
|
||||
FrontFaceDirection = FrontFaceDirection.CW,
|
||||
Program = 17,
|
||||
VertexArray = 23,
|
||||
ArrayBuffer = 31,
|
||||
ActiveUnit = TextureUnit.Texture2,
|
||||
};
|
||||
gl.SetCapability(EnableCap.DepthTest, enabled: true);
|
||||
gl.SetCapability(EnableCap.Blend, enabled: false);
|
||||
gl.SetCapability(EnableCap.CullFace, enabled: true);
|
||||
gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: true);
|
||||
// Enabled on entry — the world's MSAA. The UI pass turns it off and the
|
||||
// restore has to put it back, which is the exact dimension the V4a
|
||||
// revert lost and which nothing covered before this test.
|
||||
gl.SetCapability(EnableCap.Multisample, enabled: true);
|
||||
gl.TextureBindings[TextureUnit.Texture0] = 41;
|
||||
gl.TextureBindings[TextureUnit.Texture2] = 43;
|
||||
StateSnapshot expected = gl.Capture();
|
||||
|
||||
Action failedDraw = () =>
|
||||
{
|
||||
GlAmbientCapabilityState ambient = GlAmbientCapabilityState.Capture(gl);
|
||||
try
|
||||
{
|
||||
gl.SetCapability(EnableCap.DepthTest, enabled: false);
|
||||
gl.SetCapability(EnableCap.Blend, enabled: true);
|
||||
gl.SetCapability(EnableCap.CullFace, enabled: false);
|
||||
gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: false);
|
||||
gl.SetCapability(EnableCap.Multisample, enabled: false);
|
||||
gl.DepthMask(true);
|
||||
gl.DepthFunc(DepthFunction.Lequal);
|
||||
gl.BlendFuncSeparate(
|
||||
BlendingFactor.SrcAlpha,
|
||||
BlendingFactor.OneMinusSrcAlpha,
|
||||
BlendingFactor.SrcAlpha,
|
||||
BlendingFactor.OneMinusSrcAlpha);
|
||||
gl.CullFace(TriangleFace.Back);
|
||||
gl.FrontFace(FrontFaceDirection.Ccw);
|
||||
gl.UseProgram(101);
|
||||
gl.BindVertexArray(103);
|
||||
gl.BindBuffer(BufferTargetARB.ArrayBuffer, 107);
|
||||
gl.ActiveTexture(TextureUnit.Texture0);
|
||||
gl.BindTexture(TextureTarget.Texture2D, 109);
|
||||
throw new InvalidOperationException("draw upload");
|
||||
}
|
||||
finally
|
||||
{
|
||||
ambient.Restore(gl);
|
||||
}
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(failedDraw);
|
||||
|
||||
Assert.Equal(expected, gl.Capture());
|
||||
}
|
||||
|
||||
private readonly record struct StateSnapshot(
|
||||
bool DepthTest,
|
||||
bool Blend,
|
||||
bool Cull,
|
||||
bool AlphaToCoverage,
|
||||
bool Multisample,
|
||||
bool DepthWrite,
|
||||
DepthFunction DepthFunc,
|
||||
BlendingFactor BlendSourceRgb,
|
||||
BlendingFactor BlendDestinationRgb,
|
||||
BlendingFactor BlendSourceAlpha,
|
||||
BlendingFactor BlendDestinationAlpha,
|
||||
TriangleFace CullFaceMode,
|
||||
FrontFaceDirection FrontFaceDirection,
|
||||
uint Program,
|
||||
uint VertexArray,
|
||||
uint ArrayBuffer,
|
||||
TextureUnit ActiveUnit,
|
||||
uint Texture0,
|
||||
uint Texture2,
|
||||
bool StencilTest,
|
||||
StencilFunction StencilFunc,
|
||||
int StencilReference,
|
||||
uint StencilValueMask,
|
||||
uint StencilWriteMask,
|
||||
Silk.NET.OpenGL.StencilOp StencilFail,
|
||||
Silk.NET.OpenGL.StencilOp StencilDepthFail,
|
||||
Silk.NET.OpenGL.StencilOp StencilPass,
|
||||
bool ColorMaskRed,
|
||||
bool ColorMaskGreen,
|
||||
bool ColorMaskBlue,
|
||||
bool ColorMaskAlpha);
|
||||
|
||||
private sealed class RecordingGlState : IGlAmbientStateApi
|
||||
{
|
||||
private readonly Dictionary<EnableCap, bool> _capabilities = [];
|
||||
|
||||
public bool DepthWrite { get; set; }
|
||||
public DepthFunction DepthFuncValue { get; set; }
|
||||
public BlendingFactor BlendSourceRgb { get; set; }
|
||||
public BlendingFactor BlendDestinationRgb { get; set; }
|
||||
public BlendingFactor BlendSourceAlpha { get; set; }
|
||||
public BlendingFactor BlendDestinationAlpha { get; set; }
|
||||
public TriangleFace CullFaceMode { get; set; }
|
||||
public FrontFaceDirection FrontFaceDirection { get; set; }
|
||||
public uint Program { get; set; }
|
||||
public uint VertexArray { get; set; }
|
||||
public uint ArrayBuffer { get; set; }
|
||||
public TextureUnit ActiveUnit { get; set; }
|
||||
public Dictionary<TextureUnit, uint> TextureBindings { get; } = [];
|
||||
|
||||
// Slice V6l added the stencil and colour-mask dimensions to the ambient
|
||||
// save/restore, for the portal depth mask's sake. GL's own defaults.
|
||||
public StencilFunction StencilFuncValue { get; set; } = StencilFunction.Always;
|
||||
public int StencilReference { get; set; }
|
||||
public uint StencilValueMask { get; set; } = 0xFFFFFFFFu;
|
||||
public uint StencilWriteMaskValue { get; set; } = 0xFFFFFFFFu;
|
||||
public Silk.NET.OpenGL.StencilOp StencilFailOp { get; set; } = Silk.NET.OpenGL.StencilOp.Keep;
|
||||
public Silk.NET.OpenGL.StencilOp StencilDepthFailOp { get; set; } = Silk.NET.OpenGL.StencilOp.Keep;
|
||||
public Silk.NET.OpenGL.StencilOp StencilPassOp { get; set; } = Silk.NET.OpenGL.StencilOp.Keep;
|
||||
public bool[] ColorMaskValue { get; } = [true, true, true, true];
|
||||
|
||||
public StateSnapshot Capture() => new(
|
||||
IsEnabled(EnableCap.DepthTest),
|
||||
IsEnabled(EnableCap.Blend),
|
||||
IsEnabled(EnableCap.CullFace),
|
||||
IsEnabled(EnableCap.SampleAlphaToCoverage),
|
||||
IsEnabled(EnableCap.Multisample),
|
||||
DepthWrite,
|
||||
DepthFuncValue,
|
||||
BlendSourceRgb,
|
||||
BlendDestinationRgb,
|
||||
BlendSourceAlpha,
|
||||
BlendDestinationAlpha,
|
||||
CullFaceMode,
|
||||
FrontFaceDirection,
|
||||
Program,
|
||||
VertexArray,
|
||||
ArrayBuffer,
|
||||
ActiveUnit,
|
||||
TextureBindings.GetValueOrDefault(TextureUnit.Texture0),
|
||||
TextureBindings.GetValueOrDefault(TextureUnit.Texture2),
|
||||
IsEnabled(EnableCap.StencilTest),
|
||||
StencilFuncValue,
|
||||
StencilReference,
|
||||
StencilValueMask,
|
||||
StencilWriteMaskValue,
|
||||
StencilFailOp,
|
||||
StencilDepthFailOp,
|
||||
StencilPassOp,
|
||||
ColorMaskValue[0],
|
||||
ColorMaskValue[1],
|
||||
ColorMaskValue[2],
|
||||
ColorMaskValue[3]);
|
||||
|
||||
public bool IsEnabled(EnableCap capability) =>
|
||||
_capabilities.GetValueOrDefault(capability);
|
||||
|
||||
public int GetInteger(GetPName parameter) => parameter switch
|
||||
{
|
||||
GetPName.BlendSrcRgb => (int)BlendSourceRgb,
|
||||
GetPName.BlendDstRgb => (int)BlendDestinationRgb,
|
||||
GetPName.BlendSrcAlpha => (int)BlendSourceAlpha,
|
||||
GetPName.BlendDstAlpha => (int)BlendDestinationAlpha,
|
||||
GetPName.DepthFunc => (int)DepthFuncValue,
|
||||
GetPName.CullFaceMode => (int)CullFaceMode,
|
||||
GetPName.FrontFace => (int)FrontFaceDirection,
|
||||
GetPName.CurrentProgram => (int)Program,
|
||||
GetPName.VertexArrayBinding => (int)VertexArray,
|
||||
GetPName.ArrayBufferBinding => (int)ArrayBuffer,
|
||||
GetPName.ActiveTexture => (int)ActiveUnit,
|
||||
GetPName.TextureBinding2D =>
|
||||
(int)TextureBindings.GetValueOrDefault(ActiveUnit),
|
||||
GetPName.StencilFunc => (int)StencilFuncValue,
|
||||
GetPName.StencilRef => StencilReference,
|
||||
GetPName.StencilValueMask => (int)StencilValueMask,
|
||||
GetPName.StencilWritemask => (int)StencilWriteMaskValue,
|
||||
GetPName.StencilFail => (int)StencilFailOp,
|
||||
GetPName.StencilPassDepthFail => (int)StencilDepthFailOp,
|
||||
GetPName.StencilPassDepthPass => (int)StencilPassOp,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(parameter)),
|
||||
};
|
||||
|
||||
public bool[] GetColorMask() => (bool[])ColorMaskValue.Clone();
|
||||
|
||||
public void StencilFunc(StencilFunction function, int reference, uint mask)
|
||||
{
|
||||
StencilFuncValue = function;
|
||||
StencilReference = reference;
|
||||
StencilValueMask = mask;
|
||||
}
|
||||
|
||||
public void StencilOp(
|
||||
Silk.NET.OpenGL.StencilOp fail,
|
||||
Silk.NET.OpenGL.StencilOp depthFail,
|
||||
Silk.NET.OpenGL.StencilOp pass)
|
||||
{
|
||||
StencilFailOp = fail;
|
||||
StencilDepthFailOp = depthFail;
|
||||
StencilPassOp = pass;
|
||||
}
|
||||
|
||||
public void StencilMask(uint mask) => StencilWriteMaskValue = mask;
|
||||
|
||||
public void ColorMask(bool red, bool green, bool blue, bool alpha)
|
||||
{
|
||||
ColorMaskValue[0] = red;
|
||||
ColorMaskValue[1] = green;
|
||||
ColorMaskValue[2] = blue;
|
||||
ColorMaskValue[3] = alpha;
|
||||
}
|
||||
|
||||
public bool GetBoolean(GetPName parameter) =>
|
||||
parameter == GetPName.DepthWritemask
|
||||
? DepthWrite
|
||||
: throw new ArgumentOutOfRangeException(nameof(parameter));
|
||||
|
||||
public void SetCapability(EnableCap capability, bool enabled) =>
|
||||
_capabilities[capability] = enabled;
|
||||
|
||||
public void DepthMask(bool enabled) => DepthWrite = enabled;
|
||||
|
||||
public void DepthFunc(DepthFunction function) => DepthFuncValue = function;
|
||||
|
||||
public void BlendFuncSeparate(
|
||||
BlendingFactor sourceRgb,
|
||||
BlendingFactor destinationRgb,
|
||||
BlendingFactor sourceAlpha,
|
||||
BlendingFactor destinationAlpha)
|
||||
{
|
||||
BlendSourceRgb = sourceRgb;
|
||||
BlendDestinationRgb = destinationRgb;
|
||||
BlendSourceAlpha = sourceAlpha;
|
||||
BlendDestinationAlpha = destinationAlpha;
|
||||
}
|
||||
|
||||
public void CullFace(TriangleFace face) => CullFaceMode = face;
|
||||
|
||||
public void FrontFace(FrontFaceDirection direction) => FrontFaceDirection = direction;
|
||||
|
||||
public void UseProgram(uint program) => Program = program;
|
||||
|
||||
public void BindVertexArray(uint vertexArray) => VertexArray = vertexArray;
|
||||
|
||||
public void BindBuffer(BufferTargetARB target, uint buffer)
|
||||
{
|
||||
Assert.Equal(BufferTargetARB.ArrayBuffer, target);
|
||||
ArrayBuffer = buffer;
|
||||
}
|
||||
|
||||
public void ActiveTexture(TextureUnit unit) => ActiveUnit = unit;
|
||||
|
||||
public void BindTexture(TextureTarget target, uint texture)
|
||||
{
|
||||
Assert.Equal(TextureTarget.Texture2D, target);
|
||||
TextureBindings[ActiveUnit] = texture;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertAppearsInOrder(string source, params string[] needles)
|
||||
{
|
||||
int cursor = -1;
|
||||
foreach (string needle in needles)
|
||||
{
|
||||
int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal);
|
||||
Assert.True(next >= 0, $"Missing expected source fragment: {needle}");
|
||||
Assert.True(next > cursor, $"Out-of-order source fragment: {needle}");
|
||||
cursor = next;
|
||||
}
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,78 @@
|
|||
// Tests for EnvCellRenderer (Phase A8, 2026-05-28).
|
||||
// These cover the pure data-handling portions of EnvCellRenderer.
|
||||
// The GL-dependent Render() and RenderModernMDIInternal() paths require a
|
||||
// GL context and are visual-verified at the render frame (Task 10).
|
||||
// The draw-recording Render() and RenderModernMDIInternal() paths require a
|
||||
// live GPU device and are visual-verified at the render frame (Task 10).
|
||||
//
|
||||
// Campaign V slice V11: the raw-GL constructor (which stored a possibly-null
|
||||
// GL reference and did no other work) was deleted along with the GL arm. The
|
||||
// sole remaining constructor builds three real pipelines against IGpuDevice,
|
||||
// so these "no GL calls" tests now construct through the RHI arm with the
|
||||
// same lightweight, no-hardware-required fakes the composition tests use:
|
||||
// RecordingGpuDevice (records but does no driver work), GpuDeviceFrameLifetime
|
||||
// (never began, so CurrentFrame stays null — fine, since these tests never
|
||||
// draw), and VulkanWorldPassScope (needs only a sample count, no live surface).
|
||||
// meshManager can no longer be null either — the RHI constructor throws on a
|
||||
// null ObjectMeshManager, so tests that don't care about mesh-manager
|
||||
// behavior get a real one built the same way MeshPipelineDeviceSeamTests
|
||||
// does: VulkanMeshPipelineDevice (the production Vulkan seam implementation)
|
||||
// over the same RecordingGpuDevice, plus a no-op IPreparedAssetSource.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using AcDream.Content;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
public class EnvCellRendererTests
|
||||
{
|
||||
private sealed class NullPreparedAssetSource : IPreparedAssetSource
|
||||
{
|
||||
public PreparedAssetSourceStats Stats => default;
|
||||
|
||||
public CacheStats DecodedTextureCacheStats => default;
|
||||
|
||||
public PreparedAssetPresence Probe(
|
||||
AcDream.Content.Pak.PakAssetType type,
|
||||
uint sourceFileId) =>
|
||||
PreparedAssetPresence.Missing;
|
||||
|
||||
public PreparedAssetReadResult Read(
|
||||
in PreparedAssetRequest request,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
PreparedAssetReadResult.Missing;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectMeshManager CreateMeshManager(RecordingGpuDevice device) =>
|
||||
new(
|
||||
new VulkanMeshPipelineDevice(device.Retirement),
|
||||
device,
|
||||
new NullPreparedAssetSource(),
|
||||
NullLogger<ObjectMeshManager>.Instance);
|
||||
|
||||
private static EnvCellRenderer CreateRenderer(ObjectMeshManager? meshManager = null)
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
return new EnvCellRenderer(
|
||||
device,
|
||||
new GpuDeviceFrameLifetime(device),
|
||||
new VulkanWorldPassScope(sampleCount: 1),
|
||||
meshManager ?? CreateMeshManager(device),
|
||||
new WbFrustum());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderedMdiRanges_CoalesceAdjacentCellsWithIdenticalState()
|
||||
{
|
||||
|
|
@ -57,14 +117,14 @@ public class EnvCellRendererTests
|
|||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GetEnvCellGeomId — verbatim port of WB EnvCellRenderManager.cs:94-103
|
||||
// GetEnvCellGeomId — verbatim port of WB EnvCellRenderManager.cs:94-103
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetEnvCellGeomId_DedupBitSet()
|
||||
{
|
||||
var id = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List<ushort> { 1, 2, 3 });
|
||||
// Bit 33 (0x2_0000_0000) must be set — distinguishes dedup geom from per-cell ids.
|
||||
// Bit 33 (0x2_0000_0000) must be set — distinguishes dedup geom from per-cell ids.
|
||||
Assert.NotEqual(0UL, id & 0x2_0000_0000UL);
|
||||
}
|
||||
|
||||
|
|
@ -102,40 +162,40 @@ public class EnvCellRendererTests
|
|||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Constructor — pure data, no GL
|
||||
// Constructor — pure data, no GL
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void NewRenderer_NeedsPrepareIsTrue()
|
||||
{
|
||||
// GL and meshManager are null — only valid for pure-data tests (no
|
||||
// GL and meshManager are null — only valid for pure-data tests (no
|
||||
// Initialize() is called, so no GL calls are made).
|
||||
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
var r = CreateRenderer();
|
||||
Assert.True(r.NeedsPrepare);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewRenderer_NotDisposed()
|
||||
{
|
||||
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
var r = CreateRenderer();
|
||||
Assert.False(r.IsDisposed);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RemoveLandblock — pure data path
|
||||
// RemoveLandblock — pure data path
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RemoveLandblock_NonExistent_DoesNotThrow()
|
||||
{
|
||||
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
var r = CreateRenderer();
|
||||
// Should silently no-op.
|
||||
r.RemoveLandblock(0xA9B40000u);
|
||||
Assert.True(r.NeedsPrepare);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GetEnvCellGeomId — additional edge cases
|
||||
// GetEnvCellGeomId — additional edge cases
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
|
|
@ -157,7 +217,7 @@ public class EnvCellRendererTests
|
|||
Assert.NotEqual(a, b);
|
||||
}
|
||||
|
||||
// (Render() requires a GL context — visual-verified in Task 10.)
|
||||
// (Render() requires a GL context — visual-verified in Task 10.)
|
||||
|
||||
[Fact]
|
||||
public void GpuInstanceUpload_UsesMeshModernMat4Stride()
|
||||
|
|
@ -212,13 +272,13 @@ public class EnvCellRendererTests
|
|||
{
|
||||
// The bug: WB's GetPooledList clears the list before returning so
|
||||
// the merge phase pattern `gfxDict[k] = list; list.AddRange(...)`
|
||||
// populates fresh data. The original port omitted Clear() — each
|
||||
// populates fresh data. The original port omitted Clear() — each
|
||||
// frame's lists grew unbounded with stale data layered on top.
|
||||
//
|
||||
// Reflection-based test that drives the private GetPooledList +
|
||||
// _poolIndex/_listPool fields. If a future refactor removes the
|
||||
// Clear() call, this test fails.
|
||||
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
var r = CreateRenderer();
|
||||
|
||||
var type = typeof(EnvCellRenderer);
|
||||
var getPooledListMethod = type.GetMethod("GetPooledList",
|
||||
|
|
@ -228,16 +288,16 @@ public class EnvCellRendererTests
|
|||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
Assert.NotNull(poolIndexField);
|
||||
|
||||
// First call — creates _listPool[0], _poolIndex 0 → 1.
|
||||
// First call — creates _listPool[0], _poolIndex 0 → 1.
|
||||
var first = (List<InstanceData>)getPooledListMethod!.Invoke(r, null)!;
|
||||
first.Add(new InstanceData());
|
||||
first.Add(new InstanceData());
|
||||
Assert.Equal(2, first.Count);
|
||||
|
||||
// Reset cursor to 0 — simulates the start of the next prepare cycle.
|
||||
// Reset cursor to 0 — simulates the start of the next prepare cycle.
|
||||
poolIndexField!.SetValue(r, 0);
|
||||
|
||||
// Second call — returns _listPool[0] (same as first). With the fix
|
||||
// Second call — returns _listPool[0] (same as first). With the fix
|
||||
// it should be cleared. Without the fix the list still has 2 items.
|
||||
var second = (List<InstanceData>)getPooledListMethod.Invoke(r, null)!;
|
||||
Assert.Same(first, second); // reuses the same instance
|
||||
|
|
@ -249,7 +309,7 @@ public class EnvCellRendererTests
|
|||
{
|
||||
// Sanity check for the fresh-list branch. _poolIndex past _listPool.Count
|
||||
// should produce a brand-new empty list and grow the pool.
|
||||
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
var r = CreateRenderer();
|
||||
|
||||
var type = typeof(EnvCellRenderer);
|
||||
var getPooledListMethod = type.GetMethod("GetPooledList",
|
||||
|
|
@ -264,8 +324,8 @@ public class EnvCellRendererTests
|
|||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Prepare gate (2026-07-24) — pure camera-tolerance half.
|
||||
// The tolerance must swallow the ~36 µm eye rest jitter (RetailPViewRenderer
|
||||
// Prepare gate (2026-07-24) — pure camera-tolerance half.
|
||||
// The tolerance must swallow the ~36 µm eye rest jitter (RetailPViewRenderer
|
||||
// R-A2 note) but never survive real camera motion.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
|
|
@ -288,7 +348,7 @@ public class EnvCellRendererTests
|
|||
[Fact]
|
||||
public void CameraApproximatelyEqual_RestJitter_True()
|
||||
{
|
||||
// The ~36 µm eye rest jitter must NOT dirty the gate.
|
||||
// The ~36 µm eye rest jitter must NOT dirty the gate.
|
||||
var eye = new Vector3(120.34f, 87.91f, 10.2f);
|
||||
var jittered = eye + new Vector3(36e-6f, -36e-6f, 36e-6f);
|
||||
var a = ViewProjectionFor(eye, Vector3.UnitX);
|
||||
|
|
@ -299,7 +359,7 @@ public class EnvCellRendererTests
|
|||
[Fact]
|
||||
public void CameraApproximatelyEqual_SmallRealRotation_False()
|
||||
{
|
||||
// 0.05° of yaw — far below one frame of real mouse motion — must dirty.
|
||||
// 0.05° of yaw — far below one frame of real mouse motion — must dirty.
|
||||
var eye = new Vector3(120.34f, 87.91f, 10.2f);
|
||||
float yaw = 0.05f * MathF.PI / 180f;
|
||||
var a = ViewProjectionFor(eye, Vector3.UnitX);
|
||||
|
|
@ -322,7 +382,7 @@ public class EnvCellRendererTests
|
|||
public void CameraApproximatelyEqual_GlobalScaleCoordinates_TranslationStillDirties()
|
||||
{
|
||||
// AC world coordinates reach ~5e4. A relative tolerance applied to the
|
||||
// matrix translation row would mask sub-meter motion at that scale —
|
||||
// matrix translation row would mask sub-meter motion at that scale —
|
||||
// the eye epsilon is absolute precisely so this case stays sharp.
|
||||
var eye = new Vector3(40120.34f, 45087.91f, 110.2f);
|
||||
var moved = eye + new Vector3(0.07f, 0f, 0f); // one walking frame
|
||||
|
|
@ -338,7 +398,7 @@ public class EnvCellRendererTests
|
|||
[Fact]
|
||||
public void NewRenderer_SnapshotGenerationStartsAtZero()
|
||||
{
|
||||
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
var r = CreateRenderer();
|
||||
Assert.Equal(0, r.SnapshotGeneration);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,23 +100,6 @@ public sealed class MeshPipelineDeviceSeamTests
|
|||
Assert.False(manager.IsDisposed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The GL handle table is the emulation the Vulkan backend replaces with set
|
||||
/// 2, so asking a non-GL pipeline for it is a programming error — and it says
|
||||
/// which backend it was composed against rather than reporting a cast.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheGlHandleTableIsRefusedByNameRatherThanCast()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using ObjectMeshManager manager = Build(device);
|
||||
|
||||
InvalidOperationException failure =
|
||||
Assert.Throws<InvalidOperationException>(() => manager.WorldTextureTable);
|
||||
Assert.Contains("GL-only", failure.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(GpuBackendKind.Recording.ToString(), failure.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one branch the texture stack keeps: a GL pair yields the GL arm, and
|
||||
/// anything else yields the RHI arm. Selection happens once, at construction.
|
||||
|
|
@ -203,9 +186,6 @@ public sealed class MeshPipelineDeviceSeamTests
|
|||
using ObjectMeshManager manager = Build(device, modernPath: true);
|
||||
|
||||
GlobalMeshBuffer arena = Assert.IsType<GlobalMeshBuffer>(manager.GlobalBuffer);
|
||||
Assert.Equal(0u, arena.VAO);
|
||||
Assert.Equal(0u, arena.VBO);
|
||||
Assert.Equal(0u, arena.IBO);
|
||||
Assert.True(arena.HasStores);
|
||||
Assert.NotNull(arena.VertexStore);
|
||||
Assert.NotNull(arena.IndexStore);
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ public sealed class TextureAtlasCapacityTests
|
|||
[Fact]
|
||||
public void DirectUploadAcceptsExactRgbaPayload()
|
||||
{
|
||||
ManagedGLTextureArray.ValidateUploadPayload(
|
||||
RhiWorldTextureArray.ValidateUploadPayload(
|
||||
TextureFormat.RGBA8, 2, 2, 16, PixelFormat.Rgba, PixelType.UnsignedByte);
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ public sealed class TextureAtlasCapacityTests
|
|||
public void DirectUploadRejectsNonExactPayloadLength(int bytes)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
ManagedGLTextureArray.ValidateUploadPayload(
|
||||
RhiWorldTextureArray.ValidateUploadPayload(
|
||||
TextureFormat.RGBA8, 2, 2, bytes, PixelFormat.Rgba, PixelType.UnsignedByte));
|
||||
}
|
||||
|
||||
|
|
@ -50,27 +50,27 @@ public sealed class TextureAtlasCapacityTests
|
|||
public void DirectUploadRejectsMismatchedTransferTuple()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
ManagedGLTextureArray.ValidateUploadPayload(
|
||||
RhiWorldTextureArray.ValidateUploadPayload(
|
||||
TextureFormat.RGBA8, 2, 2, 16, PixelFormat.Rgb, PixelType.UnsignedByte));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
ManagedGLTextureArray.ValidateUploadPayload(
|
||||
RhiWorldTextureArray.ValidateUploadPayload(
|
||||
TextureFormat.RGBA8, 2, 2, 16, PixelFormat.Rgba, PixelType.Float));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompressedUploadRejectsUncompressedDescriptorOverride()
|
||||
{
|
||||
int bytes = ManagedGLTextureArray.CalculateExpectedDataSize(TextureFormat.DXT1, 4, 4);
|
||||
int bytes = RhiWorldTextureArray.CalculateExpectedDataSize(TextureFormat.DXT1, 4, 4);
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
ManagedGLTextureArray.ValidateUploadPayload(
|
||||
RhiWorldTextureArray.ValidateUploadPayload(
|
||||
TextureFormat.DXT1, 4, 4, bytes, PixelFormat.Rgba, PixelType.UnsignedByte));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RgbPayloadUsesTightlyPackedRows()
|
||||
{
|
||||
Assert.Equal(18, ManagedGLTextureArray.CalculateExpectedDataSize(TextureFormat.RGB8, 3, 2));
|
||||
ManagedGLTextureArray.ValidateUploadPayload(
|
||||
Assert.Equal(18, RhiWorldTextureArray.CalculateExpectedDataSize(TextureFormat.RGB8, 3, 2));
|
||||
RhiWorldTextureArray.ValidateUploadPayload(
|
||||
TextureFormat.RGB8, 3, 2, 18, PixelFormat.Rgb, PixelType.UnsignedByte);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -475,75 +475,6 @@ public sealed class RuntimeOptionsTests
|
|||
Assert.Throws<ArgumentNullException>(() => RuntimeOptions.Parse(AnyDatDir, null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V10 flipped the default: Vulkan is the shipping backend
|
||||
/// and an unset variable must select it. Awaiting the user's cutover
|
||||
/// sign-off; the one-line rollback restores GL here and in
|
||||
/// <c>RuntimeOptions.ParseRenderBackend</c> together.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RenderBackend_DefaultsToVulkan()
|
||||
{
|
||||
Assert.Equal(
|
||||
RenderBackendKind.Vulkan,
|
||||
RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).RenderBackend);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("vulkan")]
|
||||
[InlineData("Vulkan")]
|
||||
[InlineData("VULKAN")]
|
||||
public void RenderBackend_SelectsVulkanCaseInsensitively(string value)
|
||||
{
|
||||
Assert.Equal(
|
||||
RenderBackendKind.Vulkan,
|
||||
RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The V10 escape hatch, and the whole of it. Both spellings are honoured
|
||||
/// because a near-miss here strands the operator on the backend they asked
|
||||
/// to leave.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("gl")]
|
||||
[InlineData("GL")]
|
||||
[InlineData("Gl")]
|
||||
[InlineData("opengl")]
|
||||
[InlineData("OpenGL")]
|
||||
public void RenderBackend_SelectsGlOnlyForTheEscapeHatchTokens(string value)
|
||||
{
|
||||
Assert.Equal(
|
||||
RenderBackendKind.Gl,
|
||||
RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The mirror image of the pre-V10 rule. A typo used to have to land on GL
|
||||
/// because Vulkan was dark; it now has to land on Vulkan because GL is the
|
||||
/// backend slice V11 deletes, and a misspelled fallback that silently works
|
||||
/// is how a process ends up pinned to it.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("vulcan")]
|
||||
[InlineData("vk")]
|
||||
[InlineData(" vulkan")]
|
||||
[InlineData(" gl")]
|
||||
[InlineData("ogl")]
|
||||
public void RenderBackend_AnythingElseStaysOnVulkan(string value)
|
||||
{
|
||||
Assert.Equal(
|
||||
RenderBackendKind.Vulkan,
|
||||
RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VulkanDeviceOverride_IsNullWhenUnsetOrEmpty()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using DatReaderWriter;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight unit tests for <see cref="TextureCache"/>'s bindless path.
|
||||
/// We can't construct a real TextureCache in a headless test (it requires a
|
||||
/// live GL context), so this file documents contracts that future engineers
|
||||
/// should preserve. Real bindless integration is verified at Task 14's
|
||||
/// visual gate.
|
||||
/// </summary>
|
||||
public sealed class TextureCacheBindlessTests
|
||||
{
|
||||
[Fact]
|
||||
public void Contract_BindlessMethodsThrowWithoutBindlessSupport()
|
||||
{
|
||||
// The actual throw lives in TextureCache.EnsureBindlessAvailable
|
||||
// and is reached only via GL-bound Bindless* method calls. The
|
||||
// contract is: if the dispatcher (which requires bindless) ever
|
||||
// gets a TextureCache constructed without BindlessSupport, it
|
||||
// should fail-fast with InvalidOperationException — NOT silently
|
||||
// route a draw to handle 0 (which would produce a non-resident
|
||||
// GPU fault).
|
||||
//
|
||||
// This test is a marker. Future engineers: do not weaken
|
||||
// EnsureBindlessAvailable to swallow the missing dependency.
|
||||
Assert.True(true, "Contract documented in TextureCache.EnsureBindlessAvailable");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue