feat(render): implement Campaign AR and terrain fidelity
This commit is contained in:
parent
99cf26e00c
commit
7a5f96ede5
368 changed files with 50611 additions and 950 deletions
|
|
@ -32,6 +32,14 @@
|
|||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||
</ProjectReference>
|
||||
<ProjectReference Include="..\AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple\AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple.csproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||
</ProjectReference>
|
||||
<ProjectReference Include="..\AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal\AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal.csproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,32 @@ public sealed class HostInputCameraCompositionTests
|
|||
Assert.Equal((1280, 720), fixture.Factory.Viewport.Size);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiagnosticOrbitOverridesReachOnlyTheInitialOrbitCamera()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
|
||||
HostInputCameraResult result = fixture
|
||||
.Phase(180f, -135f, 7.5f)
|
||||
.Compose(fixture.Platform);
|
||||
|
||||
Assert.Equal(180f, result.CameraController.Orbit.Distance);
|
||||
Assert.Equal(-135f * MathF.PI / 180f, result.CameraController.Orbit.Yaw);
|
||||
Assert.Equal(7.5f * MathF.PI / 180f, result.CameraController.Orbit.Pitch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VulkanFactoryConvertsDiagnosticOrbitAnglesFromDegrees()
|
||||
{
|
||||
var factory = new VulkanHostInputCameraCompositionFactory();
|
||||
|
||||
CameraController camera = factory.CreateCameraController(200f, 180f, -12f);
|
||||
|
||||
Assert.Equal(200f, camera.Orbit.Distance);
|
||||
Assert.Equal(MathF.PI, camera.Orbit.Yaw, precision: 6);
|
||||
Assert.Equal(-12f * MathF.PI / 180f, camera.Orbit.Pitch, precision: 6);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(FaultPointValues))]
|
||||
public void FailureAtEveryProductionBoundaryStopsTheExactSuffix(
|
||||
|
|
@ -138,7 +164,10 @@ public sealed class HostInputCameraCompositionTests
|
|||
public Factory Factory { get; }
|
||||
public Publication Publication { get; }
|
||||
|
||||
public HostInputCameraCompositionPhase Phase() => new(
|
||||
public HostInputCameraCompositionPhase Phase(
|
||||
float? initialOrbitDistanceMeters = null,
|
||||
float? initialOrbitYawDegrees = null,
|
||||
float? initialOrbitPitchDegrees = null) => new(
|
||||
new HostInputCameraDependencies(
|
||||
Framebuffer,
|
||||
new Vector2D<int>(1280, 720),
|
||||
|
|
@ -150,7 +179,10 @@ public sealed class HostInputCameraCompositionTests
|
|||
PlayerMode,
|
||||
Chase,
|
||||
Pointer,
|
||||
new DiagnosticLog()),
|
||||
new DiagnosticLog(),
|
||||
initialOrbitDistanceMeters,
|
||||
initialOrbitYawDegrees,
|
||||
initialOrbitPitchDegrees),
|
||||
Publication,
|
||||
Factory,
|
||||
point =>
|
||||
|
|
@ -318,8 +350,20 @@ public sealed class HostInputCameraCompositionTests
|
|||
KeyBindings bindings) =>
|
||||
InputDispatcher.CreateDetached(keyboard, mouse, bindings);
|
||||
|
||||
public CameraController CreateCameraController() =>
|
||||
new(new OrbitCamera(), new FlyCamera());
|
||||
public CameraController CreateCameraController(
|
||||
float? initialOrbitDistanceMeters,
|
||||
float? initialOrbitYawDegrees,
|
||||
float? initialOrbitPitchDegrees)
|
||||
{
|
||||
var orbit = new OrbitCamera();
|
||||
if (initialOrbitDistanceMeters is { } distance)
|
||||
orbit.Distance = distance;
|
||||
if (initialOrbitYawDegrees is { } yaw)
|
||||
orbit.Yaw = yaw * (MathF.PI / 180f);
|
||||
if (initialOrbitPitchDegrees is { } pitch)
|
||||
orbit.Pitch = pitch * (MathF.PI / 180f);
|
||||
return new CameraController(orbit, new FlyCamera());
|
||||
}
|
||||
|
||||
public IFramebufferCameraTarget CreateCameraTarget(CameraController camera) =>
|
||||
new CameraTarget(camera);
|
||||
|
|
|
|||
|
|
@ -181,9 +181,25 @@ public sealed class InteractionUiRuntimeSourcesTests
|
|||
Assert.True(source.IsWorldReady);
|
||||
Assert.True(source.TryRequestCheckpoint("ready", out _, out _));
|
||||
Assert.Equal("ready", target.LastCheckpoint);
|
||||
Assert.Equal(target.RenderPackStatus, source.RenderPackStatus);
|
||||
Assert.Equal(1280, source.FramebufferWidth);
|
||||
Assert.Equal(720, source.FramebufferHeight);
|
||||
Assert.True(source.TrySelectRenderPack("high", out _));
|
||||
Assert.Equal("high", target.LastRenderPackPreset);
|
||||
Assert.True(source.TryDisableRenderPack(out _));
|
||||
Assert.True(target.RenderPackDisabled);
|
||||
Assert.True(source.TryReenableRenderPack(out _));
|
||||
Assert.True(target.RenderPackReenabled);
|
||||
Assert.True(source.TryResizeFramebuffer(1024, 768, out _));
|
||||
Assert.Equal((1024, 768), target.LastFramebufferSize);
|
||||
|
||||
binding.Dispose();
|
||||
Assert.False(source.IsWorldReady);
|
||||
Assert.Equal(
|
||||
AcDream.App.UI.Testing.RetailUiAutomationRenderPackStatus.Retail,
|
||||
source.RenderPackStatus);
|
||||
Assert.False(source.TrySelectRenderPack("high", out string unboundError));
|
||||
Assert.Contains("not bound", unboundError);
|
||||
source.Deactivate();
|
||||
Assert.Throws<ObjectDisposedException>(() => source.Bind(target));
|
||||
}
|
||||
|
|
@ -454,7 +470,48 @@ public sealed class InteractionUiRuntimeSourcesTests
|
|||
public bool IsWorldReady => true;
|
||||
public bool IsWorldViewportVisible => true;
|
||||
public int PortalMaterializationCount => 2;
|
||||
public AcDream.App.UI.Testing.RetailUiAutomationRenderPackStatus
|
||||
RenderPackStatus { get; } = new(
|
||||
AcDream.App.UI.Testing.RetailUiAutomationRenderPackState.Active,
|
||||
"acdream.atmospheric",
|
||||
"high",
|
||||
7,
|
||||
null);
|
||||
public int FramebufferWidth => 1280;
|
||||
public int FramebufferHeight => 720;
|
||||
public string? LastCheckpoint { get; private set; }
|
||||
public string? LastRenderPackPreset { get; private set; }
|
||||
public bool RenderPackDisabled { get; private set; }
|
||||
public bool RenderPackReenabled { get; private set; }
|
||||
public (int Width, int Height)? LastFramebufferSize { get; private set; }
|
||||
|
||||
public bool TrySelectRenderPack(string presetId, out string error)
|
||||
{
|
||||
LastRenderPackPreset = presetId;
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryDisableRenderPack(out string error)
|
||||
{
|
||||
RenderPackDisabled = true;
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryReenableRenderPack(out string error)
|
||||
{
|
||||
RenderPackReenabled = true;
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryResizeFramebuffer(int width, int height, out string error)
|
||||
{
|
||||
LastFramebufferSize = (width, height);
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryRequestCheckpoint(
|
||||
string name,
|
||||
|
|
|
|||
|
|
@ -223,7 +223,8 @@ public sealed class WorldRenderCompositionTests
|
|||
|
||||
public void InitializeEnvironment(
|
||||
WorldEnvironmentController environment,
|
||||
Region region) { }
|
||||
Region region,
|
||||
IDatReaderWriter dats) { }
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6i-2: the arm a backend with no GL context takes.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,567 @@
|
|||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace AcDream.App.Tests.Diagnostics;
|
||||
|
||||
public sealed class AtmosphericPerformanceMatrixContractTests
|
||||
{
|
||||
[Fact]
|
||||
public void ScriptsParseWithoutLaunchingTheMatrix()
|
||||
{
|
||||
foreach (string script in new[]
|
||||
{
|
||||
ScriptPath(),
|
||||
Path.Combine(FindRepoRoot(), "tools", "run-offline-pixel-gate.ps1"),
|
||||
Path.Combine(FindRepoRoot(), "tools", "atmospheric-performance-matrix-common.ps1"),
|
||||
})
|
||||
AssertPowerShellParses(script);
|
||||
}
|
||||
|
||||
private static void AssertPowerShellParses(string script)
|
||||
{
|
||||
var start = new ProcessStartInfo
|
||||
{
|
||||
FileName = OperatingSystem.IsWindows() ? "pwsh.exe" : "pwsh",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
start.ArgumentList.Add("-NoProfile");
|
||||
start.ArgumentList.Add("-NonInteractive");
|
||||
start.ArgumentList.Add("-Command");
|
||||
string quotedScript = "'" + script.Replace("'", "''", StringComparison.Ordinal) + "'";
|
||||
start.ArgumentList.Add(
|
||||
$"[scriptblock]::Create((Get-Content -Raw -LiteralPath {quotedScript})) | Out-Null");
|
||||
|
||||
using Process process = Process.Start(start)
|
||||
?? throw new InvalidOperationException("Could not start pwsh parser process.");
|
||||
string stdout = process.StandardOutput.ReadToEnd();
|
||||
string stderr = process.StandardError.ReadToEnd();
|
||||
Assert.True(process.WaitForExit(30_000), "PowerShell parser did not exit.");
|
||||
Assert.True(
|
||||
process.ExitCode == 0,
|
||||
$"PowerShell parser failed for {script} with exit code {process.ExitCode}.\n{stdout}\n{stderr}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatrixPinsAllRequiredRowsAndExplicitFramePacingModes()
|
||||
{
|
||||
string source = ReadScript();
|
||||
|
||||
Assert.Contains("[string]$FramePacing = 'both'", source, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"[ValidateSet('capped', 'uncapped', 'both')]",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"[string[]]$PresetSet = @('retail', 'low', 'medium', 'high', 'auto')",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"[string[]]$ResolutionSet = @('1920x1080', '2560x1440', '3840x2160')",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$presets = @($PresetSet | ForEach-Object { $_.ToLowerInvariant() } | Select-Object -Unique)",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$resolutions = @($ResolutionSet | Select-Object -Unique)",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("default { @('capped', 'uncapped') }", source, StringComparison.Ordinal);
|
||||
Assert.Contains("if ($pacing -eq 'uncapped')", source, StringComparison.Ordinal);
|
||||
Assert.Contains("$arguments += '-Uncapped'", source, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"'-RequiredRenderPackSamples', '2048'",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"'-RenderPackSampleTimeoutMs', '300000'",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"ExplicitPresetPerformanceWindowResetAfterWarmup = $true",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"AutomaticPerformanceWindowPolicy",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("'-AllowSafeRenderPackFallback'", source, StringComparison.Ordinal);
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"$rowDirectory = Assert-MatrixContainedPath $outputRoot (Join-Path $outputRoot $rowId)",
|
||||
"'-Out', $rowDirectory",
|
||||
"'-WarmupMs', \"$WarmupMs\"",
|
||||
"'-DayGroup', \"$DayGroup\"",
|
||||
"'-WorldDayFraction'",
|
||||
"'-SkyPhaseSeconds'",
|
||||
"'-MsaaSamples', '0'",
|
||||
"'-RenderPackPreset', $preset",
|
||||
"'-Resolution', $resolution",
|
||||
"'-OrbitDistanceMeters'",
|
||||
"'-SkipBuild'");
|
||||
|
||||
string pixelGate = File.ReadAllText(
|
||||
Path.Combine(FindRepoRoot(), "tools", "run-offline-pixel-gate.ps1"));
|
||||
Assert.Contains(
|
||||
"$probeCommands.Add('sleep 2000')",
|
||||
pixelGate,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"last completed swapchain image",
|
||||
pixelGate,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeclaredCeilingsAreExactAndNewIncrementalMetricsAreRequired()
|
||||
{
|
||||
string source = ReadScript();
|
||||
|
||||
AssertPresetBudget(source, "low", "0.15", "0.50", "2.00", "3.00", "64L");
|
||||
AssertPresetBudget(source, "medium", "0.25", "0.75", "3.25", "4.50", "128L");
|
||||
AssertPresetBudget(source, "high", "0.35", "1.00", "4.50", "6.00", "256L");
|
||||
|
||||
foreach (string field in new[]
|
||||
{
|
||||
"IncrementalCpuMillisecondsP50",
|
||||
"IncrementalCpuMillisecondsP95",
|
||||
"IncrementalCpuMillisecondsP99",
|
||||
"AbsoluteReceiverCpuMillisecondsP50",
|
||||
"AbsoluteReceiverCpuMillisecondsP95",
|
||||
"AbsoluteReceiverCpuMillisecondsP99",
|
||||
"InclusiveGpuMillisecondsP50",
|
||||
"InclusiveGpuMillisecondsP95",
|
||||
"InclusiveGpuMillisecondsP99",
|
||||
"ResidentGpuBytes",
|
||||
})
|
||||
{
|
||||
Assert.Contains($"'{field}'", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Assert.DoesNotContain("'CpuMillisecondsP50'", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("'CpuMillisecondsP99'", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("'GpuMillisecondsP50'", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("'GpuMillisecondsP99'", source, StringComparison.Ordinal);
|
||||
|
||||
Assert.Contains(
|
||||
"$gpuBudgetApplies = $availability -eq 'Active' -and",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("$budget = $budgets[$effectiveQuality]", source, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$cpuP50 -gt $budget.IncrementalCpuMillisecondsP50",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$cpuP99 -gt $budget.IncrementalCpuMillisecondsP99",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$residentGpuBytes -gt $budget.ResidentGpuBytes",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$gpuP50 -gt $budget.InclusiveGpuMillisecondsP50At1080p",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$gpuP99 -gt $budget.InclusiveGpuMillisecondsP99At1080p",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MetadataAndSummariesCarryTheRequiredEvidenceWithoutSecrets()
|
||||
{
|
||||
string source = ReadScript();
|
||||
|
||||
Assert.Contains(
|
||||
"$screenshotLeaf = 'world-offline'",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
foreach (string evidence in new[]
|
||||
{
|
||||
"CpuSampleCount",
|
||||
"GpuSampleCount",
|
||||
"ShadowCasterCount",
|
||||
"CascadeDrawCount",
|
||||
"DrawCalls",
|
||||
"DispatchCalls",
|
||||
})
|
||||
{
|
||||
Assert.Contains($"'{evidence}'", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Assert.Contains("atmospheric-performance-matrix.json", source, StringComparison.Ordinal);
|
||||
Assert.Contains("atmospheric-performance-matrix.md", source, StringComparison.Ordinal);
|
||||
Assert.Contains("DeclaredBudgets", source, StringComparison.Ordinal);
|
||||
Assert.Contains("Rows = @($rows)", source, StringComparison.Ordinal);
|
||||
Assert.Contains("Failures = @($matrixFailures)", source, StringComparison.Ordinal);
|
||||
|
||||
Assert.DoesNotContain("ACDREAM_TEST_USER", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("ACDREAM_TEST_PASS", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Get-ChildItem Env:", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutableMetadataOracleAcceptsCompleteShapeAndRejectsAdversarialEvidence()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), $"acdream-matrix-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
string metadataPath = Path.Combine(directory, "capture.metadata.json");
|
||||
foreach (string preset in new[] { "low", "medium", "high" })
|
||||
{
|
||||
File.WriteAllText(metadataPath, CreateMetadata(preset));
|
||||
JsonElement valid = RunMetadataOracle(metadataPath, preset);
|
||||
Assert.True(valid.GetProperty("Passed").GetBoolean());
|
||||
Assert.Equal(9498, valid.GetProperty("ShadowCasterCount").GetInt32());
|
||||
}
|
||||
|
||||
JsonNode automatic = JsonNode.Parse(CreateMetadata("high"))!;
|
||||
automatic["RenderPack"]!["PresetId"] = "auto";
|
||||
automatic["RenderPack"]!["ActivationGeneration"] = 4;
|
||||
File.WriteAllText(metadataPath, automatic.ToJsonString());
|
||||
JsonElement validAutomatic = RunMetadataOracle(metadataPath, "auto");
|
||||
Assert.True(validAutomatic.GetProperty("Passed").GetBoolean());
|
||||
Assert.Equal(
|
||||
"high",
|
||||
validAutomatic.GetProperty("EffectiveQuality").GetString());
|
||||
|
||||
File.WriteAllText(metadataPath, CreateMetadata("high"));
|
||||
JsonNode invalid = JsonNode.Parse(File.ReadAllText(metadataPath))!;
|
||||
JsonNode pack = invalid["RenderPack"]!;
|
||||
pack["ShadowCasterCount"] = 0;
|
||||
pack["CascadeDrawCount"] = 3;
|
||||
pack["CpuClassificationCalls"] = 1;
|
||||
pack["RetainedGpuBytes"] = 99;
|
||||
pack["Performance"]!["CpuSampleCount"] = 2047;
|
||||
pack["Performance"]!["IncrementalCpuMillisecondsP95"] = -1.0;
|
||||
pack["Passes"]![1]!["DrawCalls"] = 4;
|
||||
File.WriteAllText(metadataPath, invalid.ToJsonString());
|
||||
|
||||
JsonElement rejected = RunMetadataOracle(metadataPath, "high");
|
||||
Assert.False(rejected.GetProperty("Passed").GetBoolean());
|
||||
string failures = rejected.GetProperty("Failures").ToString();
|
||||
Assert.Contains("at least one shadow caster", failures, StringComparison.Ordinal);
|
||||
Assert.Contains("exactly 4 cascades", failures, StringComparison.Ordinal);
|
||||
Assert.Contains("zero CPU classifications", failures, StringComparison.Ordinal);
|
||||
Assert.Contains("GPU bytes disagree", failures, StringComparison.Ordinal);
|
||||
Assert.Contains("complete 2048-sample window", failures, StringComparison.Ordinal);
|
||||
Assert.Contains("finite and non-negative", failures, StringComparison.Ordinal);
|
||||
Assert.Contains("exactly 5 draws and zero dispatches", failures, StringComparison.Ordinal);
|
||||
}
|
||||
finally { Directory.Delete(directory, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutableMetadataOracleReportsOnlyStrictZeroWorkUnavailability()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), $"acdream-matrix-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
string metadataPath = Path.Combine(directory, "capture.metadata.json");
|
||||
const string resourceReason =
|
||||
"Directional shadow rendering failed: Render pack preset 'low' needs 67465216 resident GPU bytes after materializing its scene-dependent shadow command buffers; the active pack budget is 67108864 bytes.";
|
||||
File.WriteAllText(metadataPath, CreateFallbackMetadata(resourceReason));
|
||||
|
||||
JsonElement resourceUnavailable = RunMetadataOracle(
|
||||
metadataPath,
|
||||
"low",
|
||||
allowSafeFallback: true);
|
||||
Assert.True(resourceUnavailable.GetProperty("Passed").GetBoolean());
|
||||
Assert.Equal("Unavailable", resourceUnavailable.GetProperty("Outcome").GetString());
|
||||
Assert.Equal(
|
||||
"ResourceUnavailable",
|
||||
resourceUnavailable.GetProperty("UnavailableClassification").GetString());
|
||||
Assert.Equal(resourceReason, resourceUnavailable.GetProperty("FailureReason").GetString());
|
||||
|
||||
JsonElement automaticUnavailable = RunMetadataOracle(
|
||||
metadataPath,
|
||||
"auto",
|
||||
allowSafeFallback: true);
|
||||
Assert.True(automaticUnavailable.GetProperty("Passed").GetBoolean());
|
||||
Assert.Equal(
|
||||
"ResourceUnavailable",
|
||||
automaticUnavailable.GetProperty("UnavailableClassification").GetString());
|
||||
|
||||
JsonElement notOptedIn = RunMetadataOracle(metadataPath, "low");
|
||||
Assert.False(notOptedIn.GetProperty("Passed").GetBoolean());
|
||||
Assert.Contains(
|
||||
"not explicitly allowed",
|
||||
notOptedIn.GetProperty("Failures").ToString(),
|
||||
StringComparison.Ordinal);
|
||||
|
||||
const string capabilityReason =
|
||||
"Preset 'low' requires unsupported capability 'MultiviewDirectionalShadowCascades'.";
|
||||
File.WriteAllText(metadataPath, CreateFallbackMetadata(capabilityReason));
|
||||
JsonElement capabilityUnavailable = RunMetadataOracle(
|
||||
metadataPath,
|
||||
"low",
|
||||
allowSafeFallback: true);
|
||||
Assert.True(capabilityUnavailable.GetProperty("Passed").GetBoolean());
|
||||
Assert.Equal(
|
||||
"CapabilityUnavailable",
|
||||
capabilityUnavailable.GetProperty("UnavailableClassification").GetString());
|
||||
|
||||
const string performanceReason =
|
||||
"Automatic quality disabled render pack 'acdream.atmospheric' because Low remained over its declared performance budget for 180 stable samples: GPU p99 24.234 ms (budget 3.000 ms), CPU p99 0.630 ms (budget 0.500 ms), resident GPU bytes 41648404 (budget 67108864).";
|
||||
File.WriteAllText(metadataPath, CreateFallbackMetadata(performanceReason));
|
||||
JsonElement performanceUnavailable = RunMetadataOracle(
|
||||
metadataPath,
|
||||
"auto",
|
||||
allowSafeFallback: true);
|
||||
Assert.True(performanceUnavailable.GetProperty("Passed").GetBoolean());
|
||||
Assert.Equal(
|
||||
"PerformanceUnavailable",
|
||||
performanceUnavailable.GetProperty("UnavailableClassification").GetString());
|
||||
|
||||
JsonElement explicitLowCannotUseAutoPerformanceFallback = RunMetadataOracle(
|
||||
metadataPath,
|
||||
"low",
|
||||
allowSafeFallback: true);
|
||||
Assert.False(
|
||||
explicitLowCannotUseAutoPerformanceFallback.GetProperty("Passed").GetBoolean());
|
||||
|
||||
const string forgedWithinBudget =
|
||||
"Automatic quality disabled render pack 'acdream.atmospheric' because Low remained over its declared performance budget for 180 stable samples: GPU p99 2.000 ms (budget 3.000 ms), CPU p99 0.400 ms (budget 0.500 ms), resident GPU bytes 41648404 (budget 67108864).";
|
||||
File.WriteAllText(metadataPath, CreateFallbackMetadata(forgedWithinBudget));
|
||||
JsonElement forged = RunMetadataOracle(
|
||||
metadataPath,
|
||||
"auto",
|
||||
allowSafeFallback: true);
|
||||
Assert.False(forged.GetProperty("Passed").GetBoolean());
|
||||
|
||||
const string arbitraryFailure =
|
||||
"Render pack 'acdream.atmospheric' could not be prepared: shader validation failed: unsupported binding.";
|
||||
File.WriteAllText(metadataPath, CreateFallbackMetadata(arbitraryFailure));
|
||||
JsonElement unexpected = RunMetadataOracle(metadataPath, "low", allowSafeFallback: true);
|
||||
Assert.False(unexpected.GetProperty("Passed").GetBoolean());
|
||||
Assert.Equal(
|
||||
"UnexpectedFailure",
|
||||
unexpected.GetProperty("UnavailableClassification").GetString());
|
||||
Assert.Contains(
|
||||
"not a strict resource/capability/Auto-performance unavailability",
|
||||
unexpected.GetProperty("Failures").ToString(),
|
||||
StringComparison.Ordinal);
|
||||
|
||||
JsonNode unsafeFallback = JsonNode.Parse(CreateFallbackMetadata(resourceReason))!;
|
||||
unsafeFallback["RenderPack"]!["ShadowCasterCount"] = 1;
|
||||
unsafeFallback["RenderPack"]!["Performance"]!["GpuSampleCount"] = 1;
|
||||
File.WriteAllText(metadataPath, unsafeFallback.ToJsonString());
|
||||
JsonElement rejectedWork = RunMetadataOracle(metadataPath, "low", allowSafeFallback: true);
|
||||
Assert.False(rejectedWork.GetProperty("Passed").GetBoolean());
|
||||
string failures = rejectedWork.GetProperty("Failures").ToString();
|
||||
Assert.Contains("zero pack work and resources", failures, StringComparison.Ordinal);
|
||||
Assert.Contains("zero GpuSampleCount", failures, StringComparison.Ordinal);
|
||||
}
|
||||
finally { Directory.Delete(directory, recursive: true); }
|
||||
}
|
||||
|
||||
private static string CreateMetadata(string preset)
|
||||
{
|
||||
int cascades = preset switch { "low" => 2, "medium" => 3, _ => 4 };
|
||||
const int shadowDraws = 5;
|
||||
var passIds = new List<string> { "atmospheric-world-receiver" };
|
||||
if (preset == "low")
|
||||
passIds.Add("directional-shadow-multiview");
|
||||
else for (int cascade = 0; cascade < cascades; cascade++)
|
||||
passIds.Add($"directional-shadow-cascade-{cascade}");
|
||||
passIds.AddRange([
|
||||
"atmospheric-sun-occlusion", "atmospheric-sun-rays",
|
||||
"atmospheric-volumetric-shafts", "atmospheric-bloom-downsample",
|
||||
"atmospheric-bloom-blur-horizontal", "atmospheric-bloom-blur-vertical",
|
||||
"atmospheric-filmic"]);
|
||||
object[] passes = passIds.Select((id, index) => new
|
||||
{
|
||||
PassId = id,
|
||||
GpuMilliseconds = 0.1,
|
||||
DrawCalls = id.StartsWith("directional-", StringComparison.Ordinal) ? shadowDraws :
|
||||
id is "atmospheric-world-receiver" or "atmospheric-volumetric-shafts" ? 0 : 1,
|
||||
DispatchCalls = 0,
|
||||
}).Cast<object>().ToArray();
|
||||
var performance = new
|
||||
{
|
||||
CpuSampleCount = 2048,
|
||||
AbsoluteReceiverCpuSampleCount = 2048,
|
||||
GpuSampleCount = 2048,
|
||||
IncrementalCpuMillisecondsP50 = 0.1,
|
||||
IncrementalCpuMillisecondsP95 = 0.2,
|
||||
IncrementalCpuMillisecondsP99 = 0.3,
|
||||
AbsoluteReceiverCpuMillisecondsP50 = 1.0,
|
||||
AbsoluteReceiverCpuMillisecondsP95 = 1.5,
|
||||
AbsoluteReceiverCpuMillisecondsP99 = 2.0,
|
||||
InclusiveGpuMillisecondsP50 = 2.0,
|
||||
InclusiveGpuMillisecondsP95 = 3.0,
|
||||
InclusiveGpuMillisecondsP99 = 4.0,
|
||||
ResidentGpuBytes = 1000L,
|
||||
TransientGpuBytes = 0L,
|
||||
};
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
Width = 1920,
|
||||
Height = 1080,
|
||||
RenderPack = new
|
||||
{
|
||||
State = 2,
|
||||
PackId = "acdream.atmospheric",
|
||||
PackVersion = "1.0.0",
|
||||
PresetId = preset,
|
||||
EffectiveQuality = preset,
|
||||
FailureReason = (string?)null,
|
||||
ActivationGeneration = 1,
|
||||
RetainedGpuBytes = 1000L,
|
||||
TransientGpuBytes = 0L,
|
||||
ImageCount = 1,
|
||||
BufferCount = 1,
|
||||
DrawCalls = (preset == "low" ? shadowDraws : shadowDraws * cascades) + 6,
|
||||
DispatchCalls = 0,
|
||||
ShadowCasterCount = 9498,
|
||||
CascadeDrawCount = cascades,
|
||||
CpuClassificationCalls = 0,
|
||||
Passes = passes,
|
||||
Performance = performance,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static string CreateFallbackMetadata(string failureReason) => JsonSerializer.Serialize(new
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
Width = 1920,
|
||||
Height = 1080,
|
||||
RenderPack = new
|
||||
{
|
||||
State = 3,
|
||||
PackId = "retail",
|
||||
PackVersion = (string?)null,
|
||||
PresetId = "off",
|
||||
EffectiveQuality = "off",
|
||||
FailureReason = failureReason,
|
||||
ActivationGeneration = 2,
|
||||
RetainedGpuBytes = 0L,
|
||||
TransientGpuBytes = 0L,
|
||||
ImageCount = 0,
|
||||
BufferCount = 0,
|
||||
DrawCalls = 0,
|
||||
DispatchCalls = 0,
|
||||
ShadowCasterCount = 0,
|
||||
CascadeDrawCount = 0,
|
||||
CpuClassificationCalls = 0,
|
||||
Passes = Array.Empty<object>(),
|
||||
Performance = new
|
||||
{
|
||||
CpuSampleCount = 0,
|
||||
AbsoluteReceiverCpuSampleCount = 0,
|
||||
GpuSampleCount = 0,
|
||||
IncrementalCpuMillisecondsP50 = 0.0,
|
||||
IncrementalCpuMillisecondsP95 = 0.0,
|
||||
IncrementalCpuMillisecondsP99 = 0.0,
|
||||
AbsoluteReceiverCpuMillisecondsP50 = 0.0,
|
||||
AbsoluteReceiverCpuMillisecondsP95 = 0.0,
|
||||
AbsoluteReceiverCpuMillisecondsP99 = 0.0,
|
||||
InclusiveGpuMillisecondsP50 = 0.0,
|
||||
InclusiveGpuMillisecondsP95 = 0.0,
|
||||
InclusiveGpuMillisecondsP99 = 0.0,
|
||||
ResidentGpuBytes = 0L,
|
||||
TransientGpuBytes = 0L,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
private static JsonElement RunMetadataOracle(
|
||||
string metadataPath,
|
||||
string preset,
|
||||
bool allowSafeFallback = false)
|
||||
{
|
||||
string helper = Path.Combine(FindRepoRoot(), "tools", "atmospheric-performance-matrix-common.ps1");
|
||||
string quote(string value) => "'" + value.Replace("'", "''", StringComparison.Ordinal) + "'";
|
||||
var start = new ProcessStartInfo
|
||||
{
|
||||
FileName = OperatingSystem.IsWindows() ? "pwsh.exe" : "pwsh",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
start.ArgumentList.Add("-NoProfile");
|
||||
start.ArgumentList.Add("-NonInteractive");
|
||||
start.ArgumentList.Add("-Command");
|
||||
start.ArgumentList.Add(
|
||||
$". {quote(helper)}; Test-AtmosphericPerformanceMetadataEvidence " +
|
||||
$"-MetadataPath {quote(metadataPath)} -Preset {preset} -ExpectedWidth 1920 " +
|
||||
"-ExpectedHeight 1080 " +
|
||||
(allowSafeFallback ? "-AllowSafeFallback " : string.Empty) +
|
||||
"| ConvertTo-Json -Depth 8 -Compress");
|
||||
using Process process = Process.Start(start)!;
|
||||
string stdout = process.StandardOutput.ReadToEnd();
|
||||
string stderr = process.StandardError.ReadToEnd();
|
||||
Assert.True(process.WaitForExit(30_000), "PowerShell oracle did not exit.");
|
||||
Assert.True(process.ExitCode == 0, $"PowerShell oracle failed.\n{stdout}\n{stderr}");
|
||||
using JsonDocument document = JsonDocument.Parse(stdout);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static void AssertPresetBudget(
|
||||
string source,
|
||||
string preset,
|
||||
string cpuP50,
|
||||
string cpuP99,
|
||||
string gpuP50,
|
||||
string gpuP99,
|
||||
string memory)
|
||||
{
|
||||
int start = source.IndexOf($"{preset} = [pscustomobject]", StringComparison.Ordinal);
|
||||
Assert.True(start >= 0, $"Missing {preset} budget.");
|
||||
int end = source.IndexOf(" }", start, StringComparison.Ordinal);
|
||||
Assert.True(end > start, $"Malformed {preset} budget.");
|
||||
string budget = source[start..end];
|
||||
|
||||
Assert.Contains($"IncrementalCpuMillisecondsP50 = {cpuP50}", budget);
|
||||
Assert.Contains($"IncrementalCpuMillisecondsP99 = {cpuP99}", budget);
|
||||
Assert.Contains($"InclusiveGpuMillisecondsP50At1080p = {gpuP50}", budget);
|
||||
Assert.Contains($"InclusiveGpuMillisecondsP99At1080p = {gpuP99}", budget);
|
||||
Assert.Contains($"ResidentGpuBytes = {memory} * 1024L * 1024L", budget);
|
||||
}
|
||||
|
||||
private static string ReadScript() => File.ReadAllText(ScriptPath());
|
||||
|
||||
private static string ScriptPath() => Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"tools",
|
||||
"run-atmospheric-performance-matrix.ps1");
|
||||
|
||||
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 >= 0, $"Missing expected source fragment: {value}");
|
||||
Assert.True(next > cursor, $"Out-of-order source fragment: {value}");
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
using System.Diagnostics;
|
||||
|
||||
namespace AcDream.App.Tests.Diagnostics;
|
||||
|
||||
public sealed class AtmosphericPreviewLauncherContractTests
|
||||
{
|
||||
[Fact]
|
||||
public void ScriptParsesWithoutLaunchingTheClient()
|
||||
{
|
||||
string script = ScriptPath();
|
||||
var start = new ProcessStartInfo
|
||||
{
|
||||
FileName = OperatingSystem.IsWindows() ? "pwsh.exe" : "pwsh",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
start.ArgumentList.Add("-NoProfile");
|
||||
start.ArgumentList.Add("-NonInteractive");
|
||||
start.ArgumentList.Add("-Command");
|
||||
string quotedScript = "'" + script.Replace("'", "''", StringComparison.Ordinal) + "'";
|
||||
start.ArgumentList.Add(
|
||||
$"[scriptblock]::Create((Get-Content -Raw -LiteralPath {quotedScript})) | Out-Null");
|
||||
|
||||
using Process process = Process.Start(start)
|
||||
?? throw new InvalidOperationException("Could not start pwsh parser process.");
|
||||
string stdout = process.StandardOutput.ReadToEnd();
|
||||
string stderr = process.StandardError.ReadToEnd();
|
||||
Assert.True(process.WaitForExit(30_000), "PowerShell parser did not exit.");
|
||||
Assert.True(
|
||||
process.ExitCode == 0,
|
||||
$"PowerShell parser failed with exit code {process.ExitCode}.\n{stdout}\n{stderr}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreviewIsAudioSafeIsolatedAndDiagnosticByDefault()
|
||||
{
|
||||
string source = File.ReadAllText(ScriptPath());
|
||||
|
||||
Assert.Contains("[switch]$EnableAudio", source, StringComparison.Ordinal);
|
||||
Assert.Contains("$audioEnabled = [bool]$EnableAudio", source, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$env:ACDREAM_NO_AUDIO = if ($audioEnabled) { $null } else { '1' }",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("'enabled-explicit'", source, StringComparison.Ordinal);
|
||||
Assert.Contains("'disabled-default'", source, StringComparison.Ordinal);
|
||||
|
||||
Assert.Contains(
|
||||
".StartsWith('ACDREAM_', [StringComparison]::OrdinalIgnoreCase)",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"[Environment]::SetEnvironmentVariable($name, $null, 'Process')",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"[Environment]::SetEnvironmentVariable($name, $prior[$name], 'Process')",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"[Environment]::SetEnvironmentVariable($name, $null, 'Process')",
|
||||
"$env:ACDREAM_CONFIG_DIR = $config",
|
||||
"$process = Start-Process",
|
||||
"[Environment]::SetEnvironmentVariable($name, $prior[$name], 'Process')");
|
||||
|
||||
Assert.Contains("schemaVersion = 2", source, StringComparison.Ordinal);
|
||||
Assert.Contains("executableSha256", source, StringComparison.Ordinal);
|
||||
Assert.Contains("executableProductVersion", source, StringComparison.Ordinal);
|
||||
Assert.Contains("stdoutLog = $stdoutLog", source, StringComparison.Ordinal);
|
||||
Assert.Contains("stderrLog = $stderrLog", source, StringComparison.Ordinal);
|
||||
Assert.Contains("status = 'prepared'", source, StringComparison.Ordinal);
|
||||
Assert.Contains("$launch.status = 'start-failed'", source, StringComparison.Ordinal);
|
||||
Assert.Contains("$launch.status = 'started'", source, StringComparison.Ordinal);
|
||||
Assert.Contains("-RedirectStandardOutput $stdoutLog", source, StringComparison.Ordinal);
|
||||
Assert.Contains("-RedirectStandardError $stderrLog", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("-WindowStyle Hidden", source, StringComparison.Ordinal);
|
||||
|
||||
Assert.Contains("if (Test-Path -LiteralPath $root)", source, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"New-Item -ItemType Directory -Path $config, $data, $cache",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"New-Item -ItemType Directory -Force -Path $config, $data, $cache",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("prior = $prior", source, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ScriptPath() => Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"tools",
|
||||
"launch-atmospheric-preview.ps1");
|
||||
|
||||
private static void AssertAppearsInOrder(string source, params string[] fragments)
|
||||
{
|
||||
int cursor = -1;
|
||||
foreach (string fragment in fragments)
|
||||
{
|
||||
int next = source.IndexOf(fragment, cursor + 1, StringComparison.Ordinal);
|
||||
Assert.True(next > cursor, $"Missing or out-of-order fragment: {fragment}");
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,505 @@
|
|||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
|
||||
namespace AcDream.App.Tests.Diagnostics;
|
||||
|
||||
public sealed class ConnectedRenderPackGateContractTests
|
||||
{
|
||||
private const string LifecycleScript = "run-connected-world-lifecycle-gate.ps1";
|
||||
private const string SoakScript = "run-connected-r6-soak.ps1";
|
||||
|
||||
[Fact]
|
||||
public void ScreenshotJsonCarriesAuthoritativeCasterClassCounters()
|
||||
{
|
||||
DirectionalShadowTransformChurnDiagnostics churn = default;
|
||||
churn = churn with
|
||||
{
|
||||
CasterClasses = new DirectionalShadowCasterClassDiagnostics(
|
||||
TerrainCommands: 1,
|
||||
OutdoorStatics: 2,
|
||||
Buildings: 3,
|
||||
AnimatedStatics: 4,
|
||||
LocalPlayers: 5,
|
||||
RemotePlayers: 6,
|
||||
NonPlayerCreatures: 7,
|
||||
OtherLiveDynamics: 8,
|
||||
EquippedChildren: 9),
|
||||
};
|
||||
|
||||
using JsonDocument json = JsonDocument.Parse(
|
||||
JsonSerializer.Serialize(churn));
|
||||
JsonElement classes = json.RootElement.GetProperty("CasterClasses");
|
||||
|
||||
Assert.Equal(1, classes.GetProperty("TerrainCommands").GetInt32());
|
||||
Assert.Equal(2, classes.GetProperty("OutdoorStatics").GetInt32());
|
||||
Assert.Equal(3, classes.GetProperty("Buildings").GetInt32());
|
||||
Assert.Equal(4, classes.GetProperty("AnimatedStatics").GetInt32());
|
||||
Assert.Equal(5, classes.GetProperty("LocalPlayers").GetInt32());
|
||||
Assert.Equal(6, classes.GetProperty("RemotePlayers").GetInt32());
|
||||
Assert.Equal(7, classes.GetProperty("NonPlayerCreatures").GetInt32());
|
||||
Assert.Equal(8, classes.GetProperty("OtherLiveDynamics").GetInt32());
|
||||
Assert.Equal(9, classes.GetProperty("EquippedChildren").GetInt32());
|
||||
}
|
||||
private const string CommonScript = "connected-render-pack-gate-common.ps1";
|
||||
|
||||
[Fact]
|
||||
public void ConnectedGatesExposeTheSameRetailDefaultAndOptionalOverrides()
|
||||
{
|
||||
foreach (string scriptName in new[] { LifecycleScript, SoakScript })
|
||||
{
|
||||
string source = ReadTool(scriptName);
|
||||
Assert.Contains(
|
||||
"[ValidateSet('retail', 'low', 'medium', 'high', 'auto')]",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"[string]$RenderPackPreset = 'retail'",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"[hashtable]$RenderPackSettingOverrides = @{}",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
". (Join-Path $PSScriptRoot 'connected-render-pack-gate-common.ps1')",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"-Preset $RenderPackPreset",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"-SettingOverrides $RenderPackSettingOverrides",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SharedSeamPinsSchemaAndOwnsTheCompleteConnectedEnvironmentTransaction()
|
||||
{
|
||||
string source = ReadTool(CommonScript);
|
||||
foreach (string variable in new[]
|
||||
{
|
||||
"ACDREAM_CONFIG_DIR", "ACDREAM_DATA_DIR", "ACDREAM_CACHE_DIR",
|
||||
"ACDREAM_DAT_DIR", "ACDREAM_PAK_PATH", "ACDREAM_LIVE", "ACDREAM_TEST_HOST",
|
||||
"ACDREAM_TEST_PORT", "ACDREAM_TEST_USER", "ACDREAM_TEST_PASS",
|
||||
"ACDREAM_RETAIL_UI", "ACDREAM_FRAME_PROF", "ACDREAM_FRAME_HISTORY",
|
||||
"ACDREAM_UNCAPPED_RENDER", "ACDREAM_DEVTOOLS", "ACDREAM_UI_PROBE_DUMP",
|
||||
"ACDREAM_UI_PROBE_SCRIPT", "ACDREAM_AUTOMATION_ARTIFACT_DIR",
|
||||
"ACDREAM_DUMP_MOVE_TRUTH", "ACDREAM_NO_AUDIO", "ACDREAM_WB_DIAG",
|
||||
"ACDREAM_RENDER_BACKEND", "ACDREAM_NET_DROP_PCT",
|
||||
"ACDREAM_NET_DROP_SEED", "ACDREAM_NET_DROP_DIR",
|
||||
"ACDREAM_COLLISION_SHADOW_EVERY", "ACDREAM_COLLISION_SHADOW_DIR",
|
||||
"ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER", "ACDREAM_DAY_GROUP",
|
||||
"ACDREAM_WORLD_TIME", "ACDREAM_SKY_PHASE_SECONDS",
|
||||
"ACDREAM_ORBIT_DISTANCE_METERS", "ACDREAM_ORBIT_YAW_DEGREES",
|
||||
"ACDREAM_ORBIT_PITCH_DEGREES", "ACDREAM_VULKAN_DEVICE",
|
||||
"ACDREAM_VULKAN_FORCE_UNSUPPORTED", "ACDREAM_VULKAN_PROBE",
|
||||
"ACDREAM_VULKAN_PROBE_FRAMES",
|
||||
})
|
||||
Assert.Contains($"'{variable}'", source, StringComparison.Ordinal);
|
||||
Assert.Contains("$State.PreviousEnvironment.GetEnumerator()", source, StringComparison.Ordinal);
|
||||
Assert.Contains("Get-ChildItem Env:", source, StringComparison.Ordinal);
|
||||
Assert.Contains("Remove-Item -LiteralPath \"Env:$name\"", source, StringComparison.Ordinal);
|
||||
Assert.Contains("Assert-ConnectedGateContainedPath", source, StringComparison.Ordinal);
|
||||
Assert.Contains("Assert-ConnectedGateNoReparsePoint", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PowerShellRoundTripRestoresEveryConnectedVariableIncludingCredentials()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), $"acdream-connected-pack-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(root);
|
||||
try
|
||||
{
|
||||
string common = PsQuote(Path.Combine(FindRepoRoot(), "tools", CommonScript));
|
||||
string rootQuoted = PsQuote(root);
|
||||
string command = $@"
|
||||
. {common}
|
||||
foreach ($name in $script:ConnectedGateEnvironmentNames) {{
|
||||
[Environment]::SetEnvironmentVariable($name, ""sentinel-$name"", 'Process')
|
||||
}}
|
||||
[Environment]::SetEnvironmentVariable('ACDREAM_FUTURE_GATE_KNOB', 'sentinel-future', 'Process')
|
||||
$state = New-ConnectedRenderPackGateState -Root {rootQuoted} -Preset medium
|
||||
$isolated=@($state.PreviousEnvironment.Keys | Where-Object {{
|
||||
$_ -notin @('ACDREAM_CONFIG_DIR', 'ACDREAM_DATA_DIR', 'ACDREAM_CACHE_DIR') -and
|
||||
[Environment]::GetEnvironmentVariable($_, 'Process') -ne $null
|
||||
}})
|
||||
foreach ($name in $script:ConnectedGateEnvironmentNames) {{
|
||||
[Environment]::SetEnvironmentVariable($name, ""mutated-$name"", 'Process')
|
||||
}}
|
||||
[Environment]::SetEnvironmentVariable('ACDREAM_FUTURE_GATE_KNOB', 'mutated-future', 'Process')
|
||||
Restore-ConnectedRenderPackGateEnvironment $state
|
||||
$mismatches=@($script:ConnectedGateEnvironmentNames | Where-Object {{
|
||||
[Environment]::GetEnvironmentVariable($_, 'Process') -cne ""sentinel-$_""
|
||||
}})
|
||||
$unsafeLeafRejected=$false
|
||||
try {{ Assert-ConnectedGateSafeLeafName '../escape' }} catch {{ $unsafeLeafRejected=$true }}
|
||||
$escapeRejected=$false
|
||||
try {{ Assert-ConnectedGateContainedPath {rootQuoted} (Join-Path {rootQuoted} '..\escape') }} catch {{ $escapeRejected=$true }}
|
||||
[pscustomobject]@{{
|
||||
Password=$env:ACDREAM_TEST_PASS; User=$env:ACDREAM_TEST_USER;
|
||||
Config=$env:ACDREAM_CONFIG_DIR; History=$env:ACDREAM_FRAME_HISTORY;
|
||||
Future=$env:ACDREAM_FUTURE_GATE_KNOB; Isolated=$isolated; Mismatches=$mismatches;
|
||||
UnsafeLeafRejected=$unsafeLeafRejected; EscapeRejected=$escapeRejected;
|
||||
Settings=(Get-Content -Raw -LiteralPath (Join-Path $state.ConfigDirectory 'settings.json') | ConvertFrom-Json).display.renderPack.presetId
|
||||
}} | ConvertTo-Json -Compress";
|
||||
using JsonDocument result = JsonDocument.Parse(RunPowerShell(command));
|
||||
JsonElement rootElement = result.RootElement;
|
||||
Assert.Equal("sentinel-ACDREAM_TEST_PASS", rootElement.GetProperty("Password").GetString());
|
||||
Assert.Equal("sentinel-ACDREAM_TEST_USER", rootElement.GetProperty("User").GetString());
|
||||
Assert.Equal("sentinel-ACDREAM_CONFIG_DIR", rootElement.GetProperty("Config").GetString());
|
||||
Assert.Equal("sentinel-ACDREAM_FRAME_HISTORY", rootElement.GetProperty("History").GetString());
|
||||
Assert.Equal("sentinel-future", rootElement.GetProperty("Future").GetString());
|
||||
Assert.Empty(rootElement.GetProperty("Isolated").EnumerateArray());
|
||||
Assert.Empty(rootElement.GetProperty("Mismatches").EnumerateArray());
|
||||
Assert.True(rootElement.GetProperty("UnsafeLeafRejected").GetBoolean());
|
||||
Assert.True(rootElement.GetProperty("EscapeRejected").GetBoolean());
|
||||
Assert.Equal("medium", rootElement.GetProperty("Settings").GetString());
|
||||
}
|
||||
finally { Directory.Delete(root, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryConnectedScreenshotMustProveExactFailureFreeActivation()
|
||||
{
|
||||
string common = ReadTool(CommonScript);
|
||||
Assert.Contains("screenshots\\$name.metadata.json", common, StringComparison.Ordinal);
|
||||
Assert.Contains("@('PackId', [string]$State.PackId)", common, StringComparison.Ordinal);
|
||||
Assert.Contains("@('PresetId', [string]$State.PresetId)", common, StringComparison.Ordinal);
|
||||
Assert.Contains("[int]$actual.State -ne [int]$State.ExpectedState", common, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"[string]::IsNullOrWhiteSpace([string]$actual.FailureReason)",
|
||||
common,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("SchemaVersion", common, StringComparison.Ordinal);
|
||||
Assert.Contains("ActivationGeneration", common, StringComparison.Ordinal);
|
||||
Assert.Contains("EffectiveQuality", common, StringComparison.Ordinal);
|
||||
Assert.Contains("retained GPU byte ledgers disagree", common, StringComparison.Ordinal);
|
||||
Assert.Contains("expected zero pack work", common, StringComparison.Ordinal);
|
||||
Assert.Contains("recorded no complete pack graph work", common, StringComparison.Ordinal);
|
||||
Assert.Contains("positive shadow strength but no shadow casters", common, StringComparison.Ordinal);
|
||||
|
||||
string lifecycle = ReadTool(LifecycleScript);
|
||||
Assert.Contains("-ScreenshotNames @($name)", lifecycle, StringComparison.Ordinal);
|
||||
Assert.Contains("-Label $Label", lifecycle, StringComparison.Ordinal);
|
||||
|
||||
string soak = ReadTool(SoakScript);
|
||||
Assert.Contains("-ScreenshotNames $expectedCheckpointNames", soak, StringComparison.Ordinal);
|
||||
Assert.Contains("-Label $runName", soak, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LifecycleGateExecutesAtomicTransitionsResizeEnvironmentAndFreshContextRow()
|
||||
{
|
||||
string lifecycle = ReadTool(LifecycleScript);
|
||||
Assert.Contains("if ($RenderPackPreset -eq 'medium')", lifecycle, StringComparison.Ordinal);
|
||||
Assert.Contains("connected-render-pack-transitions.route.txt", lifecycle, StringComparison.Ordinal);
|
||||
Assert.Contains("Get-ConnectedRenderPackExpectation -Preset high", lifecycle, StringComparison.Ordinal);
|
||||
Assert.Contains("Get-ConnectedRenderPackExpectation -Preset retail", lifecycle, StringComparison.Ordinal);
|
||||
Assert.Contains("ScreenshotStateOverrides", lifecycle, StringComparison.Ordinal);
|
||||
Assert.Contains("Get-FreshContextRecreationGate $capped $uncapped", lifecycle, StringComparison.Ordinal);
|
||||
Assert.Contains("StartTimeUtc", lifecycle, StringComparison.Ordinal);
|
||||
Assert.Contains("resized screenshot was", lifecycle, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("authored time change did not alter published sun elevation", lifecycle, StringComparison.Ordinal);
|
||||
Assert.Contains("first weather edge did not publish Overcast", lifecycle, StringComparison.Ordinal);
|
||||
Assert.Contains("$selected.ShadowTransformChurn.CasterClasses", lifecycle, StringComparison.Ordinal);
|
||||
foreach (string casterClass in new[]
|
||||
{
|
||||
"TerrainCommands",
|
||||
"OutdoorStatics",
|
||||
"Buildings",
|
||||
"AnimatedStatics",
|
||||
"LocalPlayers",
|
||||
"NonPlayerCreatures",
|
||||
"EquippedChildren",
|
||||
})
|
||||
{
|
||||
Assert.Contains(casterClass, lifecycle, StringComparison.Ordinal);
|
||||
}
|
||||
Assert.Contains("second live client", lifecycle, StringComparison.Ordinal);
|
||||
Assert.Contains("not hostile monster", lifecycle, StringComparison.Ordinal);
|
||||
Assert.Contains("no authoritative tree discriminator", lifecycle, StringComparison.Ordinal);
|
||||
|
||||
string route = ReadTool("connected-render-pack-transitions.route.txt");
|
||||
AssertAppearsInOrder(
|
||||
route,
|
||||
"renderpack select high",
|
||||
"wait render-pack high 90000",
|
||||
"renderpack disable",
|
||||
"wait render-pack retail 90000",
|
||||
"renderpack reenable",
|
||||
"wait render-pack high 90000",
|
||||
"resize 1024 768",
|
||||
"wait framebuffer 1024 768 30000",
|
||||
"input press AcdreamCycleTimeOfDay",
|
||||
"input press AcdreamCycleWeather",
|
||||
"input press AcdreamCycleWeather",
|
||||
"checkpoint atmospheric_transitions");
|
||||
Assert.Contains("transition_selected_high", route, StringComparison.Ordinal);
|
||||
Assert.Contains("transition_disabled_retail", route, StringComparison.Ordinal);
|
||||
Assert.Contains("transition_reenabled_high", route, StringComparison.Ordinal);
|
||||
Assert.Contains("transition_resized_high", route, StringComparison.Ordinal);
|
||||
Assert.Contains("transition_overcast_high", route, StringComparison.Ordinal);
|
||||
Assert.Contains("transition_rain_high", route, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutableMetadataGateAcceptsAutoQualityAndRejectsLedgerOrVersionDrift()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), $"acdream-connected-metadata-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(Path.Combine(root, "screenshots"));
|
||||
string path = Path.Combine(root, "screenshots", "checkpoint.metadata.json");
|
||||
try
|
||||
{
|
||||
var document = new
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
RenderPack = new
|
||||
{
|
||||
PackId = "acdream.atmospheric",
|
||||
PackVersion = "1.0.0",
|
||||
PresetId = "auto",
|
||||
State = 2,
|
||||
ActivationGeneration = 3,
|
||||
EffectiveQuality = "medium",
|
||||
FailureReason = (string?)null,
|
||||
RetainedGpuBytes = 123L,
|
||||
TransientGpuBytes = 4L,
|
||||
ImageCount = 4,
|
||||
BufferCount = 2,
|
||||
DrawCalls = 6,
|
||||
DispatchCalls = 0,
|
||||
ShadowCasterCount = 22,
|
||||
CascadeDrawCount = 3,
|
||||
CpuClassificationCalls = 0,
|
||||
SharedWorldTransformUsedInstances = 68_395u,
|
||||
Outdoor = true,
|
||||
DirectionalShadowStrength = 0.75,
|
||||
Passes = new[]
|
||||
{
|
||||
new { PassId = "directional-shadow", GpuMilliseconds = 0.2, DrawCalls = 2, DispatchCalls = 0 },
|
||||
},
|
||||
Performance = new { ResidentGpuBytes = 123L, TransientGpuBytes = 4L },
|
||||
},
|
||||
};
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(document));
|
||||
JsonElement valid = RunConnectedMetadataGate(root);
|
||||
Assert.Empty(valid.GetProperty("Failures").EnumerateArray());
|
||||
|
||||
JsonNode invalid = JsonNode.Parse(File.ReadAllText(path))!;
|
||||
invalid["SchemaVersion"] = 2;
|
||||
invalid["RenderPack"]!["PackVersion"] = "9.9.9";
|
||||
invalid["RenderPack"]!["EffectiveQuality"] = "ultra";
|
||||
invalid["RenderPack"]!["RetainedGpuBytes"] = 999;
|
||||
File.WriteAllText(path, invalid.ToJsonString());
|
||||
JsonElement rejected = RunConnectedMetadataGate(root);
|
||||
string failures = rejected.GetProperty("Failures").ToString();
|
||||
Assert.Contains("schema was 2", failures, StringComparison.Ordinal);
|
||||
Assert.Contains("PackVersion '9.9.9'", failures, StringComparison.Ordinal);
|
||||
Assert.Contains("effective quality 'ultra'", failures, StringComparison.Ordinal);
|
||||
Assert.Contains("retained GPU byte ledgers disagree", failures, StringComparison.Ordinal);
|
||||
}
|
||||
finally { Directory.Delete(root, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutableMetadataGateRejectsActiveNoWorkAndRetailPackWork()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), $"acdream-connected-work-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(Path.Combine(root, "screenshots"));
|
||||
string path = Path.Combine(root, "screenshots", "checkpoint.metadata.json");
|
||||
try
|
||||
{
|
||||
var activeNoWork = new
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
RenderPack = new
|
||||
{
|
||||
PackId = "acdream.atmospheric",
|
||||
PackVersion = "1.0.0",
|
||||
PresetId = "auto",
|
||||
State = 2,
|
||||
ActivationGeneration = 1,
|
||||
EffectiveQuality = "low",
|
||||
FailureReason = (string?)null,
|
||||
RetainedGpuBytes = 0L,
|
||||
TransientGpuBytes = 0L,
|
||||
ImageCount = 0,
|
||||
BufferCount = 0,
|
||||
DrawCalls = 0,
|
||||
DispatchCalls = 0,
|
||||
ShadowCasterCount = 0,
|
||||
CascadeDrawCount = 0,
|
||||
CpuClassificationCalls = 0,
|
||||
SharedWorldTransformUsedInstances = 0u,
|
||||
Outdoor = true,
|
||||
DirectionalShadowStrength = 0.5,
|
||||
Passes = Array.Empty<object>(),
|
||||
Performance = new { ResidentGpuBytes = 0L, TransientGpuBytes = 0L },
|
||||
},
|
||||
};
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(activeNoWork));
|
||||
JsonElement activeRejected = RunConnectedMetadataGate(root);
|
||||
string activeFailures = activeRejected.GetProperty("Failures").ToString();
|
||||
Assert.Contains("recorded no complete pack graph work", activeFailures, StringComparison.Ordinal);
|
||||
Assert.Contains("positive shadow strength but no shadow casters", activeFailures, StringComparison.Ordinal);
|
||||
Assert.Contains("positive shadow strength but no combined shared-world-transform usage", activeFailures, StringComparison.Ordinal);
|
||||
|
||||
var retailWork = new
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
RenderPack = new
|
||||
{
|
||||
PackId = "retail",
|
||||
PackVersion = (string?)null,
|
||||
PresetId = "off",
|
||||
State = 0,
|
||||
ActivationGeneration = 0,
|
||||
EffectiveQuality = "off",
|
||||
FailureReason = (string?)null,
|
||||
RetainedGpuBytes = 64L,
|
||||
TransientGpuBytes = 0L,
|
||||
ImageCount = 1,
|
||||
BufferCount = 0,
|
||||
DrawCalls = 1,
|
||||
DispatchCalls = 0,
|
||||
ShadowCasterCount = 0,
|
||||
CascadeDrawCount = 0,
|
||||
CpuClassificationCalls = 0,
|
||||
SharedWorldTransformUsedInstances = 1u,
|
||||
Outdoor = false,
|
||||
DirectionalShadowStrength = 0.0,
|
||||
Passes = new[]
|
||||
{
|
||||
new { PassId = "unexpected", GpuMilliseconds = 0.1, DrawCalls = 1, DispatchCalls = 0 },
|
||||
},
|
||||
Performance = new { ResidentGpuBytes = 64L, TransientGpuBytes = 0L },
|
||||
},
|
||||
};
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(retailWork));
|
||||
JsonElement retailRejected = RunConnectedMetadataGate(root, "retail");
|
||||
string retailFailures = retailRejected.GetProperty("Failures").ToString();
|
||||
Assert.Contains("expected zero pack work", retailFailures, StringComparison.Ordinal);
|
||||
Assert.Contains("recorded pack passes, expected none", retailFailures, StringComparison.Ordinal);
|
||||
}
|
||||
finally { Directory.Delete(root, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothGatesFailClosedOnUnprovableBinaryIdentity()
|
||||
{
|
||||
string common = ReadTool(CommonScript);
|
||||
Assert.Contains("Measured binary commit $binaryCommit differs", common, StringComparison.Ordinal);
|
||||
Assert.Contains("status --short --untracked-files=all", common, StringComparison.Ordinal);
|
||||
Assert.Contains("Connected closeout evidence cannot prove binary/source identity", common, StringComparison.Ordinal);
|
||||
foreach (string scriptName in new[] { LifecycleScript, SoakScript })
|
||||
Assert.Contains("Get-ConnectedGateBinaryIdentity", ReadTool(scriptName), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothGatesRestoreEnvironmentAndRecordRequestedSelection()
|
||||
{
|
||||
foreach (string scriptName in new[] { LifecycleScript, SoakScript })
|
||||
{
|
||||
string source = ReadTool(scriptName);
|
||||
int selectionStart = source.IndexOf(
|
||||
"$renderPackGate = New-ConnectedRenderPackGateState",
|
||||
StringComparison.Ordinal);
|
||||
Assert.True(selectionStart >= 0, $"{scriptName} does not initialize isolated state.");
|
||||
string selectionScope = source[selectionStart..];
|
||||
|
||||
AssertAppearsInOrder(
|
||||
selectionScope,
|
||||
"$renderPackGate = New-ConnectedRenderPackGateState",
|
||||
"try {",
|
||||
"RenderPackSelection = (Get-ConnectedRenderPackGateReport $renderPackGate)",
|
||||
"finally {",
|
||||
"Restore-ConnectedRenderPackGateEnvironment $renderPackGate");
|
||||
Assert.Contains(
|
||||
"Add-ConnectedRenderPackMetadataFailures",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("Start-Process -FilePath $exe", source, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadTool(string fileName) => File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"tools",
|
||||
fileName));
|
||||
|
||||
private static string PsQuote(string value) => "'" + value.Replace("'", "''", StringComparison.Ordinal) + "'";
|
||||
|
||||
private static string RunPowerShell(string command)
|
||||
{
|
||||
var start = new ProcessStartInfo
|
||||
{
|
||||
FileName = OperatingSystem.IsWindows() ? "pwsh.exe" : "pwsh",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
start.ArgumentList.Add("-NoProfile");
|
||||
start.ArgumentList.Add("-NonInteractive");
|
||||
start.ArgumentList.Add("-Command");
|
||||
start.ArgumentList.Add(command);
|
||||
using Process process = Process.Start(start)!;
|
||||
string stdout = process.StandardOutput.ReadToEnd();
|
||||
string stderr = process.StandardError.ReadToEnd();
|
||||
Assert.True(process.WaitForExit(30_000), "PowerShell did not exit.");
|
||||
Assert.True(process.ExitCode == 0, $"PowerShell failed.\n{stdout}\n{stderr}");
|
||||
return stdout.Trim();
|
||||
}
|
||||
|
||||
private static JsonElement RunConnectedMetadataGate(
|
||||
string artifactRoot,
|
||||
string requestedPreset = "auto")
|
||||
{
|
||||
string common = PsQuote(Path.Combine(FindRepoRoot(), "tools", CommonScript));
|
||||
string root = PsQuote(artifactRoot);
|
||||
string preset = PsQuote(requestedPreset);
|
||||
string packId = PsQuote(requestedPreset == "retail" ? "retail" : "acdream.atmospheric");
|
||||
string packVersion = requestedPreset == "retail" ? "$null" : "'1.0.0'";
|
||||
string presetId = PsQuote(requestedPreset == "retail" ? "off" : requestedPreset);
|
||||
int expectedState = requestedPreset == "retail" ? 0 : 2;
|
||||
string command = $@"
|
||||
. {common}
|
||||
$state=[pscustomobject]@{{RequestedPreset={preset};PackId={packId};PackVersion={packVersion};PresetId={presetId};ExpectedState={expectedState};ExpectedSchemaVersion=1}}
|
||||
$failures=[Collections.Generic.List[string]]::new()
|
||||
Add-ConnectedRenderPackMetadataFailures -ArtifactDirectory {root} -ScreenshotNames @('checkpoint') -State $state -Failures $failures -Label synthetic
|
||||
[pscustomobject]@{{Failures=@($failures)}} | ConvertTo-Json -Depth 5 -Compress";
|
||||
using JsonDocument document = JsonDocument.Parse(RunPowerShell(command));
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
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 >= 0, $"Missing expected source fragment: {value}");
|
||||
Assert.True(next > cursor, $"Out-of-order source fragment: {value}");
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -268,6 +268,115 @@ public sealed class ConnectedWorldSoakRouteContractTests
|
|||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AtmosphericOfflineGateDefaultsToCappedAndExposesUncappedMeasurement()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"tools",
|
||||
"run-offline-pixel-gate.ps1"));
|
||||
|
||||
Assert.Contains("[switch]$Uncapped", source, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$env:ACDREAM_UNCAPPED_RENDER = if ($Uncapped) { '1' } else { $null }",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$previousUncappedRender = $env:ACDREAM_UNCAPPED_RENDER",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$env:ACDREAM_UNCAPPED_RENDER = $previousUncappedRender",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$env:ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER = '1'",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$previousExactFramebuffer = $env:ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$env:ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER = $previousExactFramebuffer",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("-WindowStyle Hidden", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("-WindowStyle Minimized", source, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"[int]$RequiredRenderPackSamples = 0",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$probeCommands.Add('renderpack reset-performance')",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"if ($RenderPackPreset -ne 'auto')",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"wait render-pack-samples $RequiredRenderPackSamples $RenderPackSampleTimeoutMs",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
int reset = source.IndexOf(
|
||||
"$probeCommands.Add('renderpack reset-performance')",
|
||||
StringComparison.Ordinal);
|
||||
int wait = source.IndexOf(
|
||||
"wait render-pack-samples $RequiredRenderPackSamples $RenderPackSampleTimeoutMs",
|
||||
StringComparison.Ordinal);
|
||||
int screenshot = source.IndexOf(
|
||||
"$probeCommands.Add('screenshot world-offline 30000')",
|
||||
StringComparison.Ordinal);
|
||||
int closeClient = source.IndexOf(
|
||||
"$probeCommands.Add('close-client')",
|
||||
StringComparison.Ordinal);
|
||||
Assert.True(
|
||||
reset >= 0 && wait > reset && screenshot > wait
|
||||
&& closeClient > screenshot);
|
||||
Assert.Contains("$proc.WaitForExit(15000)", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"Get-Process -Name AcDream.App",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(".CloseMainWindow()", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AtmosphericPreviewLaunchesAcdreamWithDisposableStateAndNoLiveCredentials()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"tools",
|
||||
"launch-atmospheric-preview.ps1"));
|
||||
|
||||
Assert.Contains("AcDream.App.exe", source, StringComparison.Ordinal);
|
||||
Assert.Contains("packId = 'acdream.atmospheric'", source, StringComparison.Ordinal);
|
||||
Assert.Contains("artifacts\\atmospheric-rendering\\visible-", source, StringComparison.Ordinal);
|
||||
Assert.Contains("$env:ACDREAM_CONFIG_DIR = $config", source, StringComparison.Ordinal);
|
||||
Assert.Contains("$env:ACDREAM_DATA_DIR = $data", source, StringComparison.Ordinal);
|
||||
Assert.Contains("$env:ACDREAM_CACHE_DIR = $cache", source, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
".StartsWith('ACDREAM_', [StringComparison]::OrdinalIgnoreCase)",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"[Environment]::SetEnvironmentVariable($name, $null, 'Process')",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"[Environment]::SetEnvironmentVariable($name, $prior[$name], 'Process')",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$env:ACDREAM_NO_AUDIO = if ($audioEnabled) { $null } else { '1' }",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("-RedirectStandardOutput $stdoutLog", source, StringComparison.Ordinal);
|
||||
Assert.Contains("-RedirectStandardError $stderrLog", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("-WindowStyle Hidden", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StationaryDwellSamplesAfterTheLivenessDeadline()
|
||||
{
|
||||
|
|
@ -317,7 +426,7 @@ public sealed class ConnectedWorldSoakRouteContractTests
|
|||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$sensitive = $_.Name -match '(?i)(PASS|PASSWORD|TOKEN|SECRET|KEY)'",
|
||||
"$sensitive = $_.Name -match '(?i)(PASS|PASSWORD|TOKEN|SECRET|KEY|USER|ACCOUNT)'",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
|
|
@ -337,24 +446,23 @@ public sealed class ConnectedWorldSoakRouteContractTests
|
|||
"tools",
|
||||
"run-connected-r6-soak.ps1"));
|
||||
|
||||
string common = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(), "tools", "connected-render-pack-gate-common.ps1"));
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"$sourceCommit = (& git -C $Repository rev-parse HEAD).Trim()",
|
||||
"[Diagnostics.FileVersionInfo]::GetVersionInfo($exe).ProductVersion",
|
||||
"$binaryCommitMatch = [regex]::Match(",
|
||||
"$commit = if ($null -ne $binaryCommit) { $binaryCommit } else { $sourceCommit }",
|
||||
"$binaryMatchesSource = $null -ne $binaryCommit -and $binaryCommit -eq $sourceCommit");
|
||||
Assert.Contains(
|
||||
"status --short --untracked-files=no",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
"$binaryIdentity = Get-ConnectedGateBinaryIdentity",
|
||||
"$sourceCommit = $binaryIdentity.SourceCommit",
|
||||
"$binaryCommit = $binaryIdentity.BinaryCommit",
|
||||
"$commit = $binaryCommit");
|
||||
Assert.Contains("[Diagnostics.FileVersionInfo]::GetVersionInfo($Executable).ProductVersion", common);
|
||||
Assert.Contains("status --short --untracked-files=all", common, StringComparison.Ordinal);
|
||||
Assert.Contains("BinaryProductVersion = $binaryProductVersion", source);
|
||||
Assert.Contains("BinaryCommit = $binaryCommit", source);
|
||||
Assert.Contains("BinaryMatchesSource = $binaryMatchesSource", source);
|
||||
Assert.Contains("TrackedSourceStatus = @($sourceStatus)", source);
|
||||
Assert.Contains(
|
||||
"measured binary commit $binaryCommit differs from checked-out source commit $sourceCommit",
|
||||
source,
|
||||
"Measured binary commit $binaryCommit differs from checked-out source commit $sourceCommit",
|
||||
common,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Text.Json;
|
||||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.App.Rendering.Residency;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
using AcDream.App.Streaming;
|
||||
|
|
@ -82,6 +83,70 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScreenshotCapture_WritesRenderPackChoiceAndAtmosphereMetadata()
|
||||
{
|
||||
string directory = NewDirectory();
|
||||
var diagnostics = new RenderPackDiagnosticsSnapshot(
|
||||
RenderPackActivationState.Active,
|
||||
"acdream.atmospheric",
|
||||
"1.0.0",
|
||||
"high",
|
||||
"medium",
|
||||
FailureReason: null,
|
||||
ActivationGeneration: 7,
|
||||
RetainedGpuBytes: 64,
|
||||
TransientGpuBytes: 32,
|
||||
ImageCount: 5,
|
||||
BufferCount: 2,
|
||||
DrawCalls: 12,
|
||||
DispatchCalls: 4,
|
||||
ShadowCasterCount: 22,
|
||||
CascadeDrawCount: 44,
|
||||
CpuClassificationCalls: 1,
|
||||
SunElevationDegrees: 14.5,
|
||||
ActiveDayGroup: 2,
|
||||
Weather: "Clear",
|
||||
WeatherIntensity: 0.25,
|
||||
Outdoor: true,
|
||||
DirectionalShadowStrength: 0.8,
|
||||
Passes: [])
|
||||
{
|
||||
SharedWorldTransformUsedInstances = 68_395,
|
||||
};
|
||||
var controller = new FrameScreenshotController(
|
||||
(_, _) => [255, 255, 255, 255],
|
||||
directory,
|
||||
renderPackMetadata: () => diagnostics);
|
||||
Assert.Contains(
|
||||
"worldTransforms=68395used",
|
||||
RenderPackDiagnosticsFormatter.Format(diagnostics),
|
||||
StringComparison.Ordinal);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(controller.TryRequest("enhanced", out string error), error);
|
||||
Assert.True(controller.CapturePending(1, 1));
|
||||
|
||||
using JsonDocument metadata = JsonDocument.Parse(
|
||||
File.ReadAllText(Path.Combine(directory, "enhanced.metadata.json")));
|
||||
JsonElement root = metadata.RootElement;
|
||||
Assert.Equal(1, root.GetProperty("SchemaVersion").GetInt32());
|
||||
JsonElement pack = root.GetProperty("RenderPack");
|
||||
Assert.Equal("acdream.atmospheric", pack.GetProperty("PackId").GetString());
|
||||
Assert.Equal("medium", pack.GetProperty("EffectiveQuality").GetString());
|
||||
Assert.Equal(14.5, pack.GetProperty("SunElevationDegrees").GetDouble());
|
||||
Assert.True(pack.GetProperty("Outdoor").GetBoolean());
|
||||
Assert.Equal(
|
||||
68_395u,
|
||||
pack.GetProperty("SharedWorldTransformUsedInstances").GetUInt32());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("../escape")]
|
||||
|
|
@ -377,6 +442,7 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
var screenshots = new FrameScreenshotController(
|
||||
(_, _) => [0, 0, 0, 255],
|
||||
Path.Combine(directory, "screenshots"));
|
||||
int clientCloseRequests = 0;
|
||||
var controller = new WorldLifecycleAutomationController(
|
||||
() => reveal,
|
||||
() => new RuntimeWorldEnvironmentOwnershipSnapshot(
|
||||
|
|
@ -395,13 +461,18 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
() => 3,
|
||||
_ => resources,
|
||||
screenshots,
|
||||
directory);
|
||||
directory,
|
||||
requestClientClose: () => clientCloseRequests++);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(controller.IsWorldReady);
|
||||
Assert.True(controller.IsWorldViewportVisible);
|
||||
Assert.Equal(3, controller.PortalMaterializationCount);
|
||||
Assert.True(
|
||||
controller.TryRequestClientClose(out string closeError),
|
||||
closeError);
|
||||
Assert.Equal(1, clientCloseRequests);
|
||||
Assert.True(controller.TryRequestCheckpoint(
|
||||
"dungeon",
|
||||
out IRetailUiAutomationCheckpoint? request,
|
||||
|
|
@ -591,6 +662,115 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderPackPerformanceReset_IsNoOpOnlyAfterFailedToRetail()
|
||||
{
|
||||
string directory = NewDirectory();
|
||||
bool failedToRetail = true;
|
||||
int resetCalls = 0;
|
||||
var controller = new WorldLifecycleAutomationController(
|
||||
() => default,
|
||||
() => default,
|
||||
() => default,
|
||||
() => 0,
|
||||
_ => EmptyResources(),
|
||||
new FrameScreenshotController((_, _) => [], directory),
|
||||
directory,
|
||||
getRenderPackPerformanceSampleCount: () => 0,
|
||||
resetRenderPackPerformance: () =>
|
||||
{
|
||||
resetCalls++;
|
||||
return (true, string.Empty);
|
||||
},
|
||||
getRenderPackFailedToRetail: () => failedToRetail);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(controller.RenderPackFailedToRetail);
|
||||
Assert.True(controller.TryResetRenderPackPerformance(out string fallbackError));
|
||||
Assert.Empty(fallbackError);
|
||||
Assert.Equal(0, resetCalls);
|
||||
|
||||
failedToRetail = false;
|
||||
Assert.True(controller.TryResetRenderPackPerformance(out string activeError));
|
||||
Assert.Empty(activeError);
|
||||
Assert.Equal(1, resetCalls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
controller.Dispose();
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransitionAutomationDelegatesPreserveExactDisableReenableAndResizeOwnership()
|
||||
{
|
||||
string directory = NewDirectory();
|
||||
var status = new RetailUiAutomationRenderPackStatus(
|
||||
RetailUiAutomationRenderPackState.Active,
|
||||
"acdream.atmospheric",
|
||||
"medium",
|
||||
ActivationGeneration: 1,
|
||||
FailureReason: null);
|
||||
var framebuffer = (Width: 1280, Height: 720);
|
||||
var calls = new List<string>();
|
||||
var controller = new WorldLifecycleAutomationController(
|
||||
() => default,
|
||||
() => default,
|
||||
() => default,
|
||||
() => 0,
|
||||
_ => EmptyResources(),
|
||||
new FrameScreenshotController((_, _) => [], directory),
|
||||
directory,
|
||||
getRenderPackStatus: () => status,
|
||||
selectRenderPack: preset =>
|
||||
{
|
||||
calls.Add($"select:{preset}");
|
||||
return (true, string.Empty);
|
||||
},
|
||||
disableRenderPack: () =>
|
||||
{
|
||||
calls.Add("disable-exact");
|
||||
return (true, string.Empty);
|
||||
},
|
||||
reenableRenderPack: () =>
|
||||
{
|
||||
calls.Add("reenable-exact");
|
||||
return (true, string.Empty);
|
||||
},
|
||||
getFramebufferSize: () => framebuffer,
|
||||
resizeFramebuffer: (width, height) =>
|
||||
{
|
||||
calls.Add($"resize:{width}x{height}");
|
||||
framebuffer = (width, height);
|
||||
return (true, string.Empty);
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
Assert.Equal(status, controller.RenderPackStatus);
|
||||
Assert.True(controller.TrySelectRenderPack("high", out string selectError));
|
||||
Assert.Empty(selectError);
|
||||
Assert.True(controller.TryDisableRenderPack(out string disableError));
|
||||
Assert.Empty(disableError);
|
||||
Assert.True(controller.TryReenableRenderPack(out string reenableError));
|
||||
Assert.Empty(reenableError);
|
||||
Assert.True(controller.TryResizeFramebuffer(1024, 768, out string resizeError));
|
||||
Assert.Empty(resizeError);
|
||||
Assert.Equal(1024, controller.FramebufferWidth);
|
||||
Assert.Equal(768, controller.FramebufferHeight);
|
||||
Assert.Equal(
|
||||
["select:high", "disable-exact", "reenable-exact", "resize:1024x768"],
|
||||
calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
controller.Dispose();
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static WorldLifecycleAutomationController CreateController(
|
||||
string directory,
|
||||
Func<RenderFrameOutcome, WorldLifecycleResourceSnapshot> capture) =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,673 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using AcDream.App.Configuration;
|
||||
using AcDream.App.Plugins;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.App.Settings;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using AcDream.Core.Plugins;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Platform;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.App.Tests.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Crosses the real external-package boundary. The descriptor and asset source
|
||||
/// originate in a collectible plugin ALC; everything after registration is the
|
||||
/// graphical host's production catalog/settings/controller path.
|
||||
/// </summary>
|
||||
public sealed class ExternalRenderPackPackageLifecycleTests
|
||||
{
|
||||
private const string PackageId = "acdream.test.external-render-pack-package";
|
||||
private const string PackId = "acdream.test.external-render-pack";
|
||||
private static readonly RenderPackActivationExtent Extent = new(1280, 720, 1);
|
||||
|
||||
[Fact]
|
||||
public void LiveProductionCatalogWithdrawsAtFrameBoundaryAndAcceptsCorrectedReregistration()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
RunLiveProductionCatalogLifecycle(temporary);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static void RunLiveProductionCatalogLifecycle(TemporaryDirectory temporary)
|
||||
{
|
||||
ApplicationPathSet paths = Paths(temporary.Path);
|
||||
_ = InstallPackage(paths.PluginsDirectory, new Version(1, 0, 0));
|
||||
string settingsPath = Path.Combine(paths.ConfigDirectory, "settings.json");
|
||||
Directory.CreateDirectory(paths.ConfigDirectory);
|
||||
var selected = new RenderPackSelectionSettings(PackId, "1.0.0", "low");
|
||||
new JsonRuntimeSettingsStorage(settingsPath).SaveDisplay(
|
||||
DisplaySettings.Default with { RenderPack = selected });
|
||||
|
||||
using var registry = new BufferedRenderPackRegistry();
|
||||
var source = new RenderPackCatalogSource(
|
||||
registry,
|
||||
RenderPackHostCapabilities.Conformance);
|
||||
using var device = new RecordingGpuDevice();
|
||||
var factory = new CountingFactory(device);
|
||||
using var controller = new RenderPackController(
|
||||
source.Snapshot,
|
||||
factory,
|
||||
preparationScheduler: InlineRenderPackPreparationScheduler.Instance,
|
||||
catalogSource: source);
|
||||
var settings = new RuntimeSettingsController(
|
||||
new JsonRuntimeSettingsStorage(settingsPath),
|
||||
log: static _ => { });
|
||||
using var binding = new RenderPackSelectionBinding(settings, controller);
|
||||
|
||||
GraphicalPluginSession first = StartSession(
|
||||
paths,
|
||||
Path.Combine(temporary.Path, "live-v1-status.jsonl"),
|
||||
registry);
|
||||
Assert.Equal(
|
||||
RenderPackActivationState.Active,
|
||||
binding.ApplyAtFrameBoundary(Extent).State);
|
||||
WeakReference firstAssets = CaptureAssetWeakReference(registry);
|
||||
WeakReference firstContext = Assert.Single(first.CaptureLoadContextWeakReferences());
|
||||
|
||||
// No settings write/request accompanies uninstall. The registry event
|
||||
// alone must retire the active production runtime at the next frame.
|
||||
first.Dispose();
|
||||
RenderPackActivationSnapshot withdrawn = binding.ApplyAtFrameBoundary(Extent);
|
||||
Assert.Equal(RenderPackActivationState.FailedToRetail, withdrawn.State);
|
||||
Assert.Contains("was withdrawn", withdrawn.Reason, StringComparison.Ordinal);
|
||||
Assert.True(settings.Display.RenderPack.IsRetail);
|
||||
Assert.Null(controller.ActiveRuntime);
|
||||
Assert.Empty(registry.Snapshot());
|
||||
Collect(firstAssets);
|
||||
Collect(firstContext);
|
||||
|
||||
// The stable persisted identity is deliberately unchanged. A new
|
||||
// registration generation clears the old quarantine on an explicit
|
||||
// re-selection; production composition/controller objects stay live.
|
||||
GraphicalPluginSession corrected = StartSession(
|
||||
paths,
|
||||
Path.Combine(temporary.Path, "live-corrected-status.jsonl"),
|
||||
registry);
|
||||
long correctedRegistration = CaptureRegistrationId(registry);
|
||||
Assert.True(correctedRegistration > 1);
|
||||
settings.SaveDisplay(settings.Display with { RenderPack = selected });
|
||||
RenderPackActivationSnapshot recovered = binding.ApplyAtFrameBoundary(Extent);
|
||||
Assert.Equal(RenderPackActivationState.Active, recovered.State);
|
||||
Assert.Equal(selected, recovered.Selection);
|
||||
Assert.Equal(2, factory.BuildCount);
|
||||
|
||||
WeakReference correctedAssets = CaptureAssetWeakReference(registry);
|
||||
WeakReference correctedContext = Assert.Single(
|
||||
corrected.CaptureLoadContextWeakReferences());
|
||||
corrected.Dispose();
|
||||
Assert.Equal(
|
||||
RenderPackActivationState.FailedToRetail,
|
||||
binding.ApplyAtFrameBoundary(Extent).State);
|
||||
binding.Dispose();
|
||||
controller.Dispose();
|
||||
Collect(correctedAssets);
|
||||
Collect(correctedContext);
|
||||
AssertZeroGpuPackResources(device);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PackageSelectionUpdateWithdrawalAndPersistenceConvergeWithoutPackResources()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
ApplicationPathSet paths = Paths(temporary.Path);
|
||||
string packageDirectory = InstallPackage(paths.PluginsDirectory, new Version(1, 0, 0));
|
||||
string settingsPath = Path.Combine(paths.ConfigDirectory, "settings.json");
|
||||
Directory.CreateDirectory(paths.ConfigDirectory);
|
||||
var persistedV1 = new RenderPackSelectionSettings(PackId, "1.0.0", "low");
|
||||
new JsonRuntimeSettingsStorage(settingsPath).SaveDisplay(
|
||||
DisplaySettings.Default with { RenderPack = persistedV1 });
|
||||
|
||||
using var registry = new BufferedRenderPackRegistry();
|
||||
using var device = new RecordingGpuDevice();
|
||||
LifetimeReferences v1 = RunV1Lifecycle(
|
||||
paths,
|
||||
temporary.Path,
|
||||
settingsPath,
|
||||
persistedV1,
|
||||
registry,
|
||||
device);
|
||||
Collect(v1.Assets);
|
||||
Collect(v1.Context);
|
||||
|
||||
WritePackageVersion(packageDirectory, new Version(2, 0, 0));
|
||||
WriteManifest(packageDirectory, new Version(2, 0, 0));
|
||||
new JsonRuntimeSettingsStorage(settingsPath).SaveDisplay(
|
||||
DisplaySettings.Default with { RenderPack = persistedV1 });
|
||||
|
||||
LifetimeReferences v2 = RunV2Lifecycle(
|
||||
paths,
|
||||
temporary.Path,
|
||||
settingsPath,
|
||||
persistedV1,
|
||||
registry,
|
||||
device);
|
||||
Collect(v2.Assets);
|
||||
Collect(v2.Context);
|
||||
|
||||
Assert.Empty(registry.Snapshot());
|
||||
AssertZeroGpuPackResources(device);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static LifetimeReferences RunV1Lifecycle(
|
||||
ApplicationPathSet paths,
|
||||
string root,
|
||||
string settingsPath,
|
||||
RenderPackSelectionSettings persistedV1,
|
||||
BufferedRenderPackRegistry registry,
|
||||
RecordingGpuDevice device)
|
||||
{
|
||||
var factory = new CountingFactory(device);
|
||||
using var controller = Controller(registry, factory);
|
||||
var settings = new RuntimeSettingsController(
|
||||
new JsonRuntimeSettingsStorage(settingsPath),
|
||||
log: static _ => { });
|
||||
using var binding = new RenderPackSelectionBinding(settings, controller);
|
||||
GraphicalPluginSession session = StartSession(
|
||||
paths,
|
||||
Path.Combine(root, "v1-status.jsonl"),
|
||||
registry);
|
||||
Assert.Equal(1, session.LoadedCount);
|
||||
AssertRegisteredVersion(registry, new Version(1, 0, 0));
|
||||
WeakReference assets = CaptureAssetWeakReference(registry);
|
||||
|
||||
RenderPackActivationSnapshot active = binding.ApplyAtFrameBoundary(Extent);
|
||||
Assert.Equal(RenderPackActivationState.Active, active.State);
|
||||
Assert.Equal(persistedV1, active.Selection);
|
||||
Assert.IsAssignableFrom<IDefaultWorldPathRenderPackRuntime>(controller.ActiveRuntime);
|
||||
Assert.Equal(1, factory.BuildCount);
|
||||
AssertZeroGpuPackResources(device);
|
||||
|
||||
WeakReference context = Assert.Single(session.CaptureLoadContextWeakReferences());
|
||||
session.Dispose();
|
||||
Assert.Empty(registry.Snapshot());
|
||||
settings.SaveDisplay(settings.Display with
|
||||
{
|
||||
RenderPack = RenderPackSelectionSettings.Retail,
|
||||
});
|
||||
Assert.Equal(
|
||||
RenderPackActivationState.Retail,
|
||||
binding.ApplyAtFrameBoundary(Extent).State);
|
||||
Assert.Null(controller.ActiveRuntime);
|
||||
|
||||
settings.SaveDisplay(settings.Display with { RenderPack = persistedV1 });
|
||||
RenderPackActivationSnapshot removed = binding.ApplyAtFrameBoundary(Extent);
|
||||
Assert.Equal(RenderPackActivationState.FailedToRetail, removed.State);
|
||||
Assert.Contains("is not installed", removed.Reason, StringComparison.Ordinal);
|
||||
Assert.True(settings.Display.RenderPack.IsRetail);
|
||||
Assert.True(
|
||||
new JsonRuntimeSettingsStorage(settingsPath).LoadDisplay().RenderPack.IsRetail);
|
||||
Assert.Equal(1, factory.BuildCount);
|
||||
|
||||
controller.Request(persistedV1);
|
||||
RenderPackActivationSnapshot latched = controller.ApplyAtFrameBoundary(Extent);
|
||||
Assert.Equal(RenderPackActivationState.FailedToRetail, latched.State);
|
||||
Assert.Contains(
|
||||
"failed for the current registration",
|
||||
latched.Reason,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(1, factory.BuildCount);
|
||||
return new LifetimeReferences(context, assets);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static LifetimeReferences RunV2Lifecycle(
|
||||
ApplicationPathSet paths,
|
||||
string root,
|
||||
string settingsPath,
|
||||
RenderPackSelectionSettings persistedV1,
|
||||
BufferedRenderPackRegistry registry,
|
||||
RecordingGpuDevice device)
|
||||
{
|
||||
var factory = new CountingFactory(device);
|
||||
using var controller = Controller(registry, factory);
|
||||
GraphicalPluginSession session = StartSession(
|
||||
paths,
|
||||
Path.Combine(root, "v2-status.jsonl"),
|
||||
registry);
|
||||
Assert.Equal(1, session.LoadedCount);
|
||||
AssertRegisteredVersion(registry, new Version(2, 0, 0));
|
||||
WeakReference assets = CaptureAssetWeakReference(registry);
|
||||
var settings = new RuntimeSettingsController(
|
||||
new JsonRuntimeSettingsStorage(settingsPath),
|
||||
log: static _ => { });
|
||||
using var binding = new RenderPackSelectionBinding(settings, controller);
|
||||
|
||||
RenderPackActivationSnapshot stale = binding.ApplyAtFrameBoundary(Extent);
|
||||
Assert.Equal(RenderPackActivationState.FailedToRetail, stale.State);
|
||||
Assert.Equal(
|
||||
"Render pack 'acdream.test.external-render-pack' version 1.0.0 was selected, "
|
||||
+ "but version 2.0.0 is installed.",
|
||||
stale.Reason);
|
||||
Assert.True(settings.Display.RenderPack.IsRetail);
|
||||
Assert.Equal(0, factory.BuildCount);
|
||||
|
||||
controller.Request(persistedV1);
|
||||
RenderPackActivationSnapshot noRetry = controller.ApplyAtFrameBoundary(Extent);
|
||||
Assert.Equal(RenderPackActivationState.FailedToRetail, noRetry.State);
|
||||
Assert.Contains("will not be retried", noRetry.Reason, StringComparison.Ordinal);
|
||||
Assert.Equal(0, factory.BuildCount);
|
||||
|
||||
var selectedV2 = new RenderPackSelectionSettings(PackId, "2.0.0", "high");
|
||||
settings.SaveDisplay(settings.Display with { RenderPack = selectedV2 });
|
||||
RenderPackActivationSnapshot updated = binding.ApplyAtFrameBoundary(Extent);
|
||||
Assert.Equal(RenderPackActivationState.Active, updated.State);
|
||||
Assert.Equal(selectedV2, updated.Selection);
|
||||
Assert.Equal("high", controller.ActiveRuntime!.Preset.Id);
|
||||
Assert.Equal(1, factory.BuildCount);
|
||||
Assert.Equal(
|
||||
selectedV2,
|
||||
new JsonRuntimeSettingsStorage(settingsPath).LoadDisplay().RenderPack);
|
||||
AssertZeroGpuPackResources(device);
|
||||
|
||||
WeakReference context = Assert.Single(session.CaptureLoadContextWeakReferences());
|
||||
session.Dispose();
|
||||
Assert.Empty(registry.Snapshot());
|
||||
settings.SaveDisplay(settings.Display with
|
||||
{
|
||||
RenderPack = RenderPackSelectionSettings.Retail,
|
||||
});
|
||||
Assert.Equal(
|
||||
RenderPackActivationState.Retail,
|
||||
binding.ApplyAtFrameBoundary(Extent).State);
|
||||
Assert.Null(controller.ActiveRuntime);
|
||||
return new LifetimeReferences(context, assets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedThenFailingPackageCanBeCorrectedWithoutLeakingRegistrationOrAlc()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
ApplicationPathSet paths = Paths(temporary.Path);
|
||||
string packageDirectory = InstallPackage(paths.PluginsDirectory, new Version(1, 0, 0));
|
||||
string manifestPath = Path.Combine(packageDirectory, "plugin.json");
|
||||
File.WriteAllText(manifestPath, "{ this is not a plugin manifest }");
|
||||
using var registry = new BufferedRenderPackRegistry();
|
||||
|
||||
GraphicalPluginSession malformed = StartSession(
|
||||
paths,
|
||||
Path.Combine(temporary.Path, "malformed-status.jsonl"),
|
||||
registry);
|
||||
Assert.Equal(0, malformed.LoadedCount);
|
||||
Assert.Empty(registry.Snapshot());
|
||||
Assert.Empty(malformed.CaptureLoadContextWeakReferences());
|
||||
JsonElement malformedFailure = Assert.Single(ReadStatuses(
|
||||
Path.Combine(temporary.Path, "malformed-status.jsonl")),
|
||||
value => value.GetProperty("e").GetString() == "pluginFailed");
|
||||
Assert.Contains(
|
||||
"invalid start of a property name",
|
||||
malformedFailure.GetProperty("error").GetString(),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
malformed.Dispose();
|
||||
|
||||
WriteManifest(packageDirectory, new Version(1, 0, 0));
|
||||
string failureMarker = Path.Combine(
|
||||
packageDirectory,
|
||||
"throw-after-render-pack-register");
|
||||
File.WriteAllText(failureMarker, string.Empty);
|
||||
GraphicalPluginSession failing = StartSession(
|
||||
paths,
|
||||
Path.Combine(temporary.Path, "failing-status.jsonl"),
|
||||
registry);
|
||||
Assert.Equal(0, failing.LoadedCount);
|
||||
Assert.Empty(registry.Snapshot());
|
||||
WeakReference failedContext = Assert.Single(
|
||||
failing.CaptureLoadContextWeakReferences());
|
||||
Assert.Contains(
|
||||
"failed after publishing its descriptor",
|
||||
Assert.Single(ReadStatuses(
|
||||
Path.Combine(temporary.Path, "failing-status.jsonl")),
|
||||
value => value.GetProperty("e").GetString() == "pluginFailed")
|
||||
.GetProperty("error").GetString(),
|
||||
StringComparison.Ordinal);
|
||||
failing.Dispose();
|
||||
Collect(failedContext);
|
||||
|
||||
File.Delete(failureMarker);
|
||||
string zeroMarker = Path.Combine(packageDirectory, "register-no-render-packs");
|
||||
File.WriteAllText(zeroMarker, string.Empty);
|
||||
GraphicalPluginSession zeroRegistration = StartSession(
|
||||
paths,
|
||||
Path.Combine(temporary.Path, "zero-registration-status.jsonl"),
|
||||
registry);
|
||||
Assert.Equal(0, zeroRegistration.LoadedCount);
|
||||
Assert.Empty(registry.Snapshot());
|
||||
WeakReference zeroContext = Assert.Single(
|
||||
zeroRegistration.CaptureLoadContextWeakReferences());
|
||||
Assert.Contains(
|
||||
"registered no packs",
|
||||
Assert.Single(ReadStatuses(
|
||||
Path.Combine(temporary.Path, "zero-registration-status.jsonl")),
|
||||
value => value.GetProperty("e").GetString() == "pluginFailed")
|
||||
.GetProperty("error").GetString(),
|
||||
StringComparison.Ordinal);
|
||||
zeroRegistration.Dispose();
|
||||
Collect(zeroContext);
|
||||
|
||||
File.Delete(zeroMarker);
|
||||
GraphicalPluginSession corrected = StartSession(
|
||||
paths,
|
||||
Path.Combine(temporary.Path, "corrected-status.jsonl"),
|
||||
registry);
|
||||
Assert.Equal(1, corrected.LoadedCount);
|
||||
AssertCompatibleRegistration(registry);
|
||||
WeakReference correctedAssets = CaptureAssetWeakReference(registry);
|
||||
|
||||
WeakReference correctedContext = Assert.Single(
|
||||
corrected.CaptureLoadContextWeakReferences());
|
||||
corrected.Dispose();
|
||||
Assert.Empty(registry.Snapshot());
|
||||
Collect(correctedAssets);
|
||||
Collect(correctedContext);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(
|
||||
"AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple",
|
||||
"must contain exactly one IRenderPackPlugin implementation")]
|
||||
[InlineData(
|
||||
"AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal",
|
||||
"must be public")]
|
||||
public void InvalidRenderPackEntrypointShapeRollsBackCollectiblePackage(
|
||||
string fixtureName,
|
||||
string expectedFailure)
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
ApplicationPathSet paths = Paths(temporary.Path);
|
||||
string packageId = "acdream.test.invalid-render-pack";
|
||||
string source = FixtureAssemblyPath(fixtureName);
|
||||
string packageDirectory = Path.Combine(paths.PluginsDirectory, packageId);
|
||||
Directory.CreateDirectory(packageDirectory);
|
||||
File.Copy(source, Path.Combine(packageDirectory, Path.GetFileName(source)));
|
||||
File.WriteAllText(
|
||||
Path.Combine(packageDirectory, "plugin.json"),
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
id = packageId,
|
||||
displayName = "Invalid render-pack entry fixture",
|
||||
version = "1.0.0",
|
||||
entryDll = Path.GetFileName(source),
|
||||
apiVersion = 1,
|
||||
kinds = new[] { "RenderPack" },
|
||||
}));
|
||||
using var registry = new BufferedRenderPackRegistry();
|
||||
string statusPath = Path.Combine(temporary.Path, fixtureName + ".jsonl");
|
||||
|
||||
GraphicalPluginSession session = StartSession(
|
||||
paths,
|
||||
statusPath,
|
||||
registry,
|
||||
packageId);
|
||||
|
||||
Assert.Equal(0, session.LoadedCount);
|
||||
Assert.Empty(registry.Snapshot());
|
||||
Assert.Contains(
|
||||
expectedFailure,
|
||||
Assert.Single(ReadStatuses(statusPath),
|
||||
value => value.GetProperty("e").GetString() == "pluginFailed")
|
||||
.GetProperty("error").GetString(),
|
||||
StringComparison.Ordinal);
|
||||
WeakReference context = Assert.Single(session.CaptureLoadContextWeakReferences());
|
||||
session.Dispose();
|
||||
Collect(context);
|
||||
}
|
||||
|
||||
private static RenderPackController Controller(
|
||||
BufferedRenderPackRegistry registry,
|
||||
IRenderPackRuntimeFactory factory)
|
||||
{
|
||||
var source = new RenderPackCatalogSource(
|
||||
registry,
|
||||
RenderPackHostCapabilities.Conformance);
|
||||
return new RenderPackController(
|
||||
source.Snapshot,
|
||||
factory,
|
||||
preparationScheduler: InlineRenderPackPreparationScheduler.Instance,
|
||||
catalogSource: source);
|
||||
}
|
||||
|
||||
private static GraphicalPluginSession StartSession(
|
||||
ApplicationPathSet paths,
|
||||
string statusPath,
|
||||
BufferedRenderPackRegistry registry,
|
||||
string packageId = PackageId)
|
||||
{
|
||||
var host = new AppPluginHost(
|
||||
new NullLogger(),
|
||||
new WorldGameState(),
|
||||
new WorldEvents(),
|
||||
new SelectionState(),
|
||||
new BufferedUiRegistry(),
|
||||
NoOpAutomationSurface.Instance);
|
||||
var session = GraphicalPluginSession.Create(
|
||||
paths,
|
||||
[packageId],
|
||||
"external-render-pack-test",
|
||||
host,
|
||||
new SessionStatusWriter(statusPath),
|
||||
registry);
|
||||
session.Start();
|
||||
return session;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static void AssertRegisteredVersion(
|
||||
BufferedRenderPackRegistry registry,
|
||||
Version expected)
|
||||
{
|
||||
BufferedRenderPackRegistration registration = Assert.Single(registry.Snapshot());
|
||||
Assert.Equal(PackId, registration.Descriptor.Id);
|
||||
Assert.Equal(expected, registration.Descriptor.PackVersion);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static WeakReference CaptureAssetWeakReference(
|
||||
BufferedRenderPackRegistry registry) =>
|
||||
new(Assert.Single(registry.Snapshot()).Assets);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static long CaptureRegistrationId(BufferedRenderPackRegistry registry) =>
|
||||
Assert.Single(registry.Snapshot()).RegistrationId;
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static void AssertCompatibleRegistration(BufferedRenderPackRegistry registry)
|
||||
{
|
||||
BufferedRenderPackRegistration registration = Assert.Single(registry.Snapshot());
|
||||
Assert.Equal(PackId, registration.Descriptor.Id);
|
||||
Assert.True(RenderPackCatalog.Build(
|
||||
registry.Snapshot(),
|
||||
RenderPackHostCapabilities.Conformance).TryGet(
|
||||
PackId,
|
||||
out RenderPackCatalogEntry catalogEntry));
|
||||
Assert.True(catalogEntry.IsCompatible, catalogEntry.IncompatibilityReason);
|
||||
}
|
||||
|
||||
private static ApplicationPathSet Paths(string root) => new(
|
||||
Path.Combine(root, "config"),
|
||||
Path.Combine(root, "data"),
|
||||
Path.Combine(root, "cache"),
|
||||
LegacyConfigDirectory: null);
|
||||
|
||||
private static string InstallPackage(string root, Version version)
|
||||
{
|
||||
string source = FixtureAssemblyPath();
|
||||
string packageDirectory = Path.Combine(root, PackageId);
|
||||
Directory.CreateDirectory(packageDirectory);
|
||||
File.Copy(source, Path.Combine(packageDirectory, Path.GetFileName(source)));
|
||||
WritePackageVersion(packageDirectory, version);
|
||||
WriteManifest(packageDirectory, version);
|
||||
return packageDirectory;
|
||||
}
|
||||
|
||||
private static void WritePackageVersion(string directory, Version version) =>
|
||||
File.WriteAllText(
|
||||
Path.Combine(directory, "render-pack-version.txt"),
|
||||
version.ToString());
|
||||
|
||||
private static void WriteManifest(string directory, Version version) =>
|
||||
File.WriteAllText(
|
||||
Path.Combine(directory, "plugin.json"),
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
id = PackageId,
|
||||
displayName = "External render-pack package fixture",
|
||||
version = version.ToString(),
|
||||
entryDll = Path.GetFileName(FixtureAssemblyPath()),
|
||||
apiVersion = 1,
|
||||
kinds = new[] { "RenderPack" },
|
||||
}));
|
||||
|
||||
private static string FixtureAssemblyPath()
|
||||
{
|
||||
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
.Parent!.Name;
|
||||
return Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"tests",
|
||||
"AcDream.Plugin.Tests.Fixtures.HostPlugin",
|
||||
"bin",
|
||||
configuration,
|
||||
"net10.0",
|
||||
"AcDream.Plugin.Tests.Fixtures.HostPlugin.dll");
|
||||
}
|
||||
|
||||
private static string FixtureAssemblyPath(string projectName)
|
||||
{
|
||||
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
.Parent!.Name;
|
||||
return Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"tests",
|
||||
projectName,
|
||||
"bin",
|
||||
configuration,
|
||||
"net10.0",
|
||||
projectName + ".dll");
|
||||
}
|
||||
|
||||
private static JsonElement[] ReadStatuses(string path) =>
|
||||
File.ReadAllLines(path)
|
||||
.Select(static line => JsonDocument.Parse(line).RootElement.Clone())
|
||||
.ToArray();
|
||||
|
||||
private static void AssertZeroGpuPackResources(RecordingGpuDevice device)
|
||||
{
|
||||
Assert.Empty(device.CreatedBuffers);
|
||||
Assert.Empty(device.CreatedPipelines);
|
||||
Assert.Empty(device.CreatedTextures);
|
||||
Assert.Empty(device.CreatedRenderTargets);
|
||||
Assert.Empty(device.CreatedDirectionalDepthTargets);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static void Collect(WeakReference context)
|
||||
{
|
||||
for (int attempt = 0; attempt < 12 && context.IsAlive; attempt++)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
}
|
||||
Assert.False(context.IsAlive);
|
||||
// On Windows the collectible context can become unreachable one GC
|
||||
// before CoreCLR closes the mapped assembly file. One finalizer/GC
|
||||
// turn makes the file-lifetime assertion in TemporaryDirectory exact.
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
string? configured = Environment.GetEnvironmentVariable("ACDREAM_REPO_ROOT");
|
||||
if (!string.IsNullOrWhiteSpace(configured)
|
||||
&& File.Exists(Path.Combine(configured, "AcDream.slnx")))
|
||||
{
|
||||
return Path.GetFullPath(configured);
|
||||
}
|
||||
foreach (string start in new[]
|
||||
{
|
||||
AppContext.BaseDirectory,
|
||||
Directory.GetCurrentDirectory(),
|
||||
})
|
||||
{
|
||||
DirectoryInfo? directory = new(start);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
return directory.FullName;
|
||||
directory = directory.Parent;
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException("Repository root not found.");
|
||||
}
|
||||
|
||||
private sealed class CountingFactory(IGpuDevice device) : IRenderPackRuntimeFactory
|
||||
{
|
||||
private readonly AtmosphericRenderPackRuntimeFactory _inner = new(device);
|
||||
|
||||
internal int BuildCount { get; private set; }
|
||||
|
||||
public IRenderPackRuntime Build(
|
||||
RenderPackDescriptor descriptor,
|
||||
ValidatedRenderPackShaderAssets assets,
|
||||
RenderQualityPreset preset,
|
||||
IReadOnlyDictionary<string, string> userSettingOverrides)
|
||||
{
|
||||
BuildCount++;
|
||||
return _inner.Build(descriptor, assets, preset, userSettingOverrides);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullLogger : IPluginLogger
|
||||
{
|
||||
public void Info(string message) { }
|
||||
public void Warn(string message) { }
|
||||
public void Error(string message, Exception? exception = null) { }
|
||||
}
|
||||
|
||||
private readonly record struct LifetimeReferences(
|
||||
WeakReference Context,
|
||||
WeakReference Assets);
|
||||
|
||||
private sealed class TemporaryDirectory : IDisposable
|
||||
{
|
||||
internal TemporaryDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"acdream-external-pack-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
internal string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
for (int attempt = 0; Directory.Exists(Path); attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
return;
|
||||
}
|
||||
catch (Exception error)
|
||||
when (error is IOException or UnauthorizedAccessException
|
||||
&& attempt < 249)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
Thread.Sleep(20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
using AcDream.App.Rendering.Scene.Arch;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
|
|
@ -661,6 +662,429 @@ public sealed class ArchRenderSceneTests
|
|||
scene.OpenQuery().IndexRevision > registeredRevision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectionalShadowTopologyRevision_IgnoresDynamicPoseButTracksEligibilityGeometryAppearanceAndCasterIdentity()
|
||||
{
|
||||
RenderSceneGeneration generation = Generation(19);
|
||||
using var scene = new ArchRenderScene(generation);
|
||||
Matrix4x4 firstPart = Matrix4x4.CreateTranslation(1f, 2f, 3f);
|
||||
RenderProjectionRecord original = Record(
|
||||
19,
|
||||
1,
|
||||
RenderProjectionClass.LiveDynamicRoot) with
|
||||
{
|
||||
Source = new RenderSourceMetadata(
|
||||
LocalEntityId: 19,
|
||||
ServerGuid: 19,
|
||||
SourceId: 19,
|
||||
ParentCellId: 0,
|
||||
EffectCellId: 0,
|
||||
BuildingShellAnchorCellId: 0,
|
||||
TransformFingerprint: new RenderSceneHash128(1, 1),
|
||||
GeometryFingerprint: new RenderSceneHash128(2, 2),
|
||||
AppearanceFingerprint: new RenderSceneHash128(3, 3),
|
||||
DirectionalShadowTopologyFingerprint:
|
||||
new RenderSceneHash128(4, 4)),
|
||||
EntityPayload = new RenderEntityPayload(
|
||||
[new MeshRef(0x01000001, firstPart)],
|
||||
PaletteOverride: null,
|
||||
IsBuildingShell: false),
|
||||
};
|
||||
scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]);
|
||||
ulong registered =
|
||||
scene.OpenQuery().DirectionalShadowTopologyRevision;
|
||||
|
||||
Matrix4x4 movedRoot = Matrix4x4.CreateTranslation(20f, 21f, 22f);
|
||||
Matrix4x4 movedPart = Matrix4x4.CreateTranslation(23f, 24f, 25f);
|
||||
RenderProjectionRecord poseOnly = original with
|
||||
{
|
||||
Transform = new RenderTransform(movedRoot),
|
||||
MeshSet = original.MeshSet with
|
||||
{
|
||||
Handle = Asset(999),
|
||||
},
|
||||
Source = original.Source with
|
||||
{
|
||||
TransformFingerprint = new RenderSceneHash128(10, 10),
|
||||
GeometryFingerprint = new RenderSceneHash128(20, 20),
|
||||
},
|
||||
EntityPayload = original.EntityPayload with
|
||||
{
|
||||
MeshRefs = [new MeshRef(0x01000001, movedPart)],
|
||||
},
|
||||
};
|
||||
scene.Apply(
|
||||
[
|
||||
RenderProjectionDelta.Update(
|
||||
RenderProjectionDeltaKind.UpdateTransform,
|
||||
generation,
|
||||
2,
|
||||
poseOnly),
|
||||
RenderProjectionDelta.Update(
|
||||
RenderProjectionDeltaKind.UpdateAppearance,
|
||||
generation,
|
||||
3,
|
||||
poseOnly),
|
||||
]);
|
||||
Assert.Equal(
|
||||
registered,
|
||||
scene.OpenQuery().DirectionalShadowTopologyRevision);
|
||||
|
||||
RenderProjectionRecord hidden = poseOnly with
|
||||
{
|
||||
Flags = poseOnly.Flags & ~RenderProjectionFlags.Draw,
|
||||
};
|
||||
scene.Apply(
|
||||
[
|
||||
RenderProjectionDelta.Update(
|
||||
RenderProjectionDeltaKind.UpdateFlags,
|
||||
generation,
|
||||
4,
|
||||
hidden),
|
||||
]);
|
||||
ulong eligibilityChanged =
|
||||
scene.OpenQuery().DirectionalShadowTopologyRevision;
|
||||
Assert.True(eligibilityChanged > registered);
|
||||
|
||||
RenderProjectionRecord geometryChanged = hidden with
|
||||
{
|
||||
Source = hidden.Source with
|
||||
{
|
||||
DirectionalShadowTopologyFingerprint =
|
||||
new RenderSceneHash128(5, 5),
|
||||
},
|
||||
EntityPayload = hidden.EntityPayload with
|
||||
{
|
||||
MeshRefs =
|
||||
[
|
||||
new MeshRef(0x01000002, movedPart)
|
||||
{
|
||||
SurfaceOverrides = new Dictionary<uint, uint>
|
||||
{
|
||||
[0x08000001] = 0x05000001,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
scene.Apply(
|
||||
[
|
||||
RenderProjectionDelta.Update(
|
||||
RenderProjectionDeltaKind.UpdateAppearance,
|
||||
generation,
|
||||
5,
|
||||
geometryChanged),
|
||||
]);
|
||||
ulong geometryRevision =
|
||||
scene.OpenQuery().DirectionalShadowTopologyRevision;
|
||||
Assert.True(geometryRevision > eligibilityChanged);
|
||||
|
||||
var palette = new PaletteOverride(
|
||||
0x04000001,
|
||||
[new PaletteOverride.SubPaletteRange(0x04000002, 1, 2)]);
|
||||
RenderProjectionRecord appearanceChanged = geometryChanged with
|
||||
{
|
||||
Material = geometryChanged.Material with { PaletteKey = 1234 },
|
||||
Source = geometryChanged.Source with
|
||||
{
|
||||
AppearanceFingerprint = new RenderSceneHash128(6, 6),
|
||||
},
|
||||
EntityPayload = geometryChanged.EntityPayload with
|
||||
{
|
||||
PaletteOverride = palette,
|
||||
},
|
||||
};
|
||||
scene.Apply(
|
||||
[
|
||||
RenderProjectionDelta.Update(
|
||||
RenderProjectionDeltaKind.UpdateAppearance,
|
||||
generation,
|
||||
6,
|
||||
appearanceChanged),
|
||||
]);
|
||||
ulong appearanceRevision =
|
||||
scene.OpenQuery().DirectionalShadowTopologyRevision;
|
||||
Assert.True(appearanceRevision > geometryRevision);
|
||||
|
||||
RenderProjectionRecord identityChanged = appearanceChanged with
|
||||
{
|
||||
EntityPayload = appearanceChanged.EntityPayload with
|
||||
{
|
||||
CasterIdentity = RenderCasterIdentityKind.RemotePlayer,
|
||||
},
|
||||
};
|
||||
scene.Apply(
|
||||
[
|
||||
RenderProjectionDelta.Update(
|
||||
RenderProjectionDeltaKind.UpdateAppearance,
|
||||
generation,
|
||||
7,
|
||||
identityChanged),
|
||||
]);
|
||||
Assert.True(
|
||||
scene.OpenQuery().DirectionalShadowTopologyRevision
|
||||
> appearanceRevision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectionalShadowTopologyRevision_TracksRareStaticTransformChanges()
|
||||
{
|
||||
RenderSceneGeneration generation = Generation(20);
|
||||
using var scene = new ArchRenderScene(generation);
|
||||
RenderProjectionRecord original = Record(
|
||||
20,
|
||||
1,
|
||||
RenderProjectionClass.OutdoorStatic);
|
||||
scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]);
|
||||
ulong registered =
|
||||
scene.OpenQuery().DirectionalShadowTopologyRevision;
|
||||
RenderProjectionRecord moved = original with
|
||||
{
|
||||
Transform = new RenderTransform(
|
||||
Matrix4x4.CreateTranslation(100f, 101f, 102f)),
|
||||
};
|
||||
|
||||
scene.Apply(
|
||||
[
|
||||
RenderProjectionDelta.Update(
|
||||
RenderProjectionDeltaKind.UpdateTransform,
|
||||
generation,
|
||||
2,
|
||||
moved),
|
||||
]);
|
||||
|
||||
Assert.True(
|
||||
scene.OpenQuery().DirectionalShadowTopologyRevision > registered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectionalShadowTransformJournal_TracksRootPartAndDynamicSync_IndependentlyOfDirtyDrain()
|
||||
{
|
||||
RenderSceneGeneration generation = Generation(21);
|
||||
using var scene = new ArchRenderScene(generation);
|
||||
Matrix4x4 firstPart = Matrix4x4.CreateTranslation(1f, 2f, 3f);
|
||||
RenderProjectionRecord original = Record(
|
||||
21,
|
||||
1,
|
||||
RenderProjectionClass.LiveDynamicRoot) with
|
||||
{
|
||||
EntityPayload = new RenderEntityPayload(
|
||||
[new MeshRef(0x01000021, firstPart)],
|
||||
PaletteOverride: null,
|
||||
IsBuildingShell: false),
|
||||
};
|
||||
scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]);
|
||||
RenderSceneQuery query = scene.OpenQuery();
|
||||
ulong initialRevision = query.DirectionalShadowTransformRevision;
|
||||
|
||||
Matrix4x4 movedRoot = Matrix4x4.CreateTranslation(
|
||||
BitConverter.Int32BitsToSingle(0x41234567),
|
||||
4f,
|
||||
5f);
|
||||
RenderProjectionRecord moved = original with
|
||||
{
|
||||
Transform = new RenderTransform(movedRoot),
|
||||
PreviousTransform = new PreviousRenderTransform(
|
||||
original.Transform.LocalToWorld),
|
||||
};
|
||||
scene.Apply(
|
||||
[
|
||||
RenderProjectionDelta.Update(
|
||||
RenderProjectionDeltaKind.UpdateTransform,
|
||||
generation,
|
||||
2,
|
||||
moved),
|
||||
]);
|
||||
Matrix4x4 movedPart = Matrix4x4.CreateTranslation(
|
||||
6f,
|
||||
BitConverter.Int32BitsToSingle(0x40ABCDEF),
|
||||
8f);
|
||||
RenderProjectionRecord posed = moved with
|
||||
{
|
||||
EntityPayload = moved.EntityPayload with
|
||||
{
|
||||
MeshRefs = [new MeshRef(0x01000021, movedPart)],
|
||||
},
|
||||
};
|
||||
scene.Apply(
|
||||
[
|
||||
RenderProjectionDelta.Update(
|
||||
RenderProjectionDeltaKind.UpdateAppearance,
|
||||
generation,
|
||||
3,
|
||||
posed),
|
||||
]);
|
||||
scene.ClearDirty();
|
||||
var changes = new DirectionalShadowTransformSnapshot[3];
|
||||
DirectionalShadowTransformChanges firstChanges =
|
||||
query.CopyDirectionalShadowTransformChanges(
|
||||
initialRevision,
|
||||
changes);
|
||||
|
||||
Assert.False(firstChanges.RequiresFullRefresh);
|
||||
Assert.Equal(2, firstChanges.Count);
|
||||
Assert.Equal(1, firstChanges.UpdateTransformCount);
|
||||
Assert.Equal(1, firstChanges.UpdateAppearanceCount);
|
||||
Assert.Equal(0, firstChanges.DynamicSynchronizationCount);
|
||||
Assert.Equal(2, firstChanges.LiveDynamicRootCount);
|
||||
Assert.Equal(original.Id, changes[0].Id);
|
||||
Assert.Equal(original.Id, changes[1].Id);
|
||||
|
||||
var synchronized = new DynamicProjectionUpdate(
|
||||
original.Id,
|
||||
original.OwnerIncarnation,
|
||||
new RenderTransform(Matrix4x4.CreateTranslation(9f, 10f, 11f)),
|
||||
posed.Bounds);
|
||||
scene.SynchronizeDynamicSources(
|
||||
new DynamicProjectionSyncInput(generation, [synchronized]));
|
||||
DirectionalShadowTransformChanges syncChanges =
|
||||
query.CopyDirectionalShadowTransformChanges(
|
||||
firstChanges.LatestRevision,
|
||||
changes);
|
||||
Assert.False(syncChanges.RequiresFullRefresh);
|
||||
Assert.Equal(1, syncChanges.Count);
|
||||
Assert.Equal(1, syncChanges.DynamicSynchronizationCount);
|
||||
Assert.Equal(1, syncChanges.LiveDynamicRootCount);
|
||||
Assert.Equal(original.Id, changes[0].Id);
|
||||
|
||||
RenderProjectionRecord flagsOnly = posed with
|
||||
{
|
||||
Flags = posed.Flags ^ RenderProjectionFlags.Selectable,
|
||||
};
|
||||
scene.Apply(
|
||||
[
|
||||
RenderProjectionDelta.Update(
|
||||
RenderProjectionDeltaKind.UpdateFlags,
|
||||
generation,
|
||||
4,
|
||||
flagsOnly),
|
||||
]);
|
||||
Assert.Equal(
|
||||
syncChanges.LatestRevision,
|
||||
query.DirectionalShadowTransformRevision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectionalShadowTransformJournal_OverflowAndGenerationResetFailSafe()
|
||||
{
|
||||
RenderSceneGeneration generation = Generation(22);
|
||||
using var scene = new ArchRenderScene(generation);
|
||||
RenderProjectionRecord original = Record(
|
||||
22,
|
||||
1,
|
||||
RenderProjectionClass.LiveDynamicRoot);
|
||||
scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]);
|
||||
RenderSceneQuery oldQuery = scene.OpenQuery();
|
||||
ulong initialRevision = oldQuery.DirectionalShadowTransformRevision;
|
||||
for (int index = 0;
|
||||
index < DirectionalShadowTransformChangeJournal.Capacity + 1;
|
||||
index++)
|
||||
{
|
||||
var update = new DynamicProjectionUpdate(
|
||||
original.Id,
|
||||
original.OwnerIncarnation,
|
||||
new RenderTransform(Matrix4x4.CreateTranslation(index + 1, 0f, 0f)),
|
||||
original.Bounds);
|
||||
scene.SynchronizeDynamicSources(
|
||||
new DynamicProjectionSyncInput(generation, [update]));
|
||||
}
|
||||
|
||||
var changes = new DirectionalShadowTransformSnapshot[
|
||||
DirectionalShadowTransformChangeJournal.Capacity];
|
||||
DirectionalShadowTransformChanges overflow =
|
||||
oldQuery.CopyDirectionalShadowTransformChanges(
|
||||
initialRevision,
|
||||
changes);
|
||||
Assert.True(overflow.RequiresFullRefresh);
|
||||
Assert.Equal(0, overflow.Count);
|
||||
|
||||
RenderSceneGeneration replacement = Generation(23);
|
||||
scene.Clear(replacement);
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => _ = oldQuery.DirectionalShadowTransformRevision);
|
||||
Assert.Equal(1ul, scene.OpenQuery().DirectionalShadowTransformRevision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectionalShadowTransformJournal_IgnoresIdenticalPoseAndBoundsOnlySynchronization()
|
||||
{
|
||||
RenderSceneGeneration generation = Generation(24);
|
||||
using var scene = new ArchRenderScene(generation);
|
||||
var meshes = new List<MeshRef>
|
||||
{
|
||||
new(0x01000024, Matrix4x4.CreateTranslation(1f, 2f, 3f)),
|
||||
};
|
||||
RenderProjectionRecord original = Record(
|
||||
24,
|
||||
1,
|
||||
RenderProjectionClass.LiveDynamicRoot) with
|
||||
{
|
||||
EntityPayload = new RenderEntityPayload(
|
||||
meshes,
|
||||
PaletteOverride: null,
|
||||
IsBuildingShell: false),
|
||||
};
|
||||
scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]);
|
||||
RenderSceneQuery query = scene.OpenQuery();
|
||||
ulong initial = query.DirectionalShadowTransformRevision;
|
||||
|
||||
scene.Apply(
|
||||
[
|
||||
RenderProjectionDelta.Update(
|
||||
RenderProjectionDeltaKind.UpdateTransform,
|
||||
generation,
|
||||
2,
|
||||
original),
|
||||
RenderProjectionDelta.Update(
|
||||
RenderProjectionDeltaKind.UpdateAppearance,
|
||||
generation,
|
||||
3,
|
||||
original),
|
||||
]);
|
||||
var boundsOnly = new DynamicProjectionUpdate(
|
||||
original.Id,
|
||||
original.OwnerIncarnation,
|
||||
original.Transform,
|
||||
new RenderWorldBounds(Vector3.One, new Vector3(2f)));
|
||||
scene.SynchronizeDynamicSources(
|
||||
new DynamicProjectionSyncInput(generation, [boundsOnly]));
|
||||
Assert.Equal(initial, query.DirectionalShadowTransformRevision);
|
||||
|
||||
Matrix4x4 changedPart = Matrix4x4.CreateTranslation(4f, 5f, 6f);
|
||||
meshes[0] = new MeshRef(0x01000024, changedPart);
|
||||
scene.Apply(
|
||||
[
|
||||
RenderProjectionDelta.Update(
|
||||
RenderProjectionDeltaKind.UpdateAppearance,
|
||||
generation,
|
||||
4,
|
||||
original),
|
||||
]);
|
||||
var changes = new DirectionalShadowTransformSnapshot[1];
|
||||
DirectionalShadowTransformChanges changed =
|
||||
query.CopyDirectionalShadowTransformChanges(initial, changes);
|
||||
Assert.Equal(1, changed.Count);
|
||||
Assert.Equal(0, changed.UpdateTransformCount);
|
||||
Assert.Equal(1, changed.UpdateAppearanceCount);
|
||||
Assert.Equal(0, changed.DynamicSynchronizationCount);
|
||||
Assert.Equal(1, changed.LiveDynamicRootCount);
|
||||
Assert.Equal(original.Id, changes[0].Id);
|
||||
Assert.Equal(
|
||||
BitConverter.SingleToInt32Bits(changedPart.M41),
|
||||
BitConverter.SingleToInt32Bits(
|
||||
changes[0].EntityPayload.MeshRefs[0].PartTransform.M41));
|
||||
|
||||
scene.Apply(
|
||||
[
|
||||
RenderProjectionDelta.Update(
|
||||
RenderProjectionDeltaKind.UpdateAppearance,
|
||||
generation,
|
||||
5,
|
||||
original),
|
||||
]);
|
||||
Assert.Equal(changed.LatestRevision, query.DirectionalShadowTransformRevision);
|
||||
}
|
||||
|
||||
private static RenderProjectionRecord Record(
|
||||
ulong id,
|
||||
ulong incarnation,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class DirectionalShadowCascadeFitterTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(DirectionalShadowPreset.Low, 2, 72f)]
|
||||
[InlineData(DirectionalShadowPreset.Medium, 3, 144f)]
|
||||
[InlineData(DirectionalShadowPreset.High, 4, 240f)]
|
||||
internal void Fit_UsesPracticalIncreasingSplitsAndExactPresetReach(
|
||||
DirectionalShadowPreset preset,
|
||||
int expectedCount,
|
||||
float expectedReach)
|
||||
{
|
||||
DirectionalShadowQuality quality = DirectionalShadowQuality.For(preset);
|
||||
DirectionalShadowCascadeFitInput input = CameraInput(
|
||||
Vector3.Zero,
|
||||
quality);
|
||||
Span<DirectionalShadowCascade> cascades =
|
||||
stackalloc DirectionalShadowCascade[4];
|
||||
|
||||
int count = DirectionalShadowCascadeFitter.Fit(in input, cascades);
|
||||
|
||||
Assert.Equal(expectedCount, count);
|
||||
float previous = input.CameraNearMeters;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Assert.Equal(previous, cascades[i].SplitNearMeters);
|
||||
Assert.True(cascades[i].SplitFarMeters > previous);
|
||||
Assert.True(cascades[i].TexelWorldSize > 0f);
|
||||
Assert.True(float.IsFinite(cascades[i].WorldToShadowClip.M11));
|
||||
previous = cascades[i].SplitFarMeters;
|
||||
}
|
||||
Assert.Equal(expectedReach, cascades[count - 1].SplitFarMeters, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TexelStabilization_SubTexelCameraTranslationKeepsSnappedCenter()
|
||||
{
|
||||
DirectionalShadowQuality quality = DirectionalShadowQuality.For(
|
||||
DirectionalShadowPreset.Medium);
|
||||
DirectionalShadowCascadeFitInput firstInput = CameraInput(
|
||||
new Vector3(100f, 200f, 30f),
|
||||
quality);
|
||||
Span<DirectionalShadowCascade> first =
|
||||
stackalloc DirectionalShadowCascade[4];
|
||||
DirectionalShadowCascadeFitter.Fit(in firstInput, first);
|
||||
|
||||
// Translation along the light-space X axis by less than half a map
|
||||
// texel must not move the stabilized projection centre.
|
||||
Vector3 light = Vector3.Normalize(firstInput.SurfaceToLightDirection);
|
||||
Vector3 lightX = Vector3.Normalize(Vector3.Cross(
|
||||
DirectionalShadowCascadeFitter.StableLightUp(light),
|
||||
light));
|
||||
Vector3 movement = lightX * (first[0].TexelWorldSize * 0.2f);
|
||||
DirectionalShadowCascadeFitInput secondInput = CameraInput(
|
||||
new Vector3(100f, 200f, 30f) + movement,
|
||||
quality);
|
||||
Span<DirectionalShadowCascade> second =
|
||||
stackalloc DirectionalShadowCascade[4];
|
||||
DirectionalShadowCascadeFitter.Fit(in secondInput, second);
|
||||
|
||||
Assert.Equal(
|
||||
first[0].StabilizedLightSpaceCenter.X,
|
||||
second[0].StabilizedLightSpaceCenter.X);
|
||||
Assert.Equal(
|
||||
first[0].StabilizedLightSpaceCenter.Y,
|
||||
second[0].StabilizedLightSpaceCenter.Y);
|
||||
Assert.Equal(first[0].HalfExtentMeters, second[0].HalfExtentMeters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StableLightUp_DoesNotRotateAtTheFormerHighLightThreshold()
|
||||
{
|
||||
Vector3 below = Vector3.Normalize(new Vector3(0.3125f, 0.02f, 0.9498f));
|
||||
Vector3 above = Vector3.Normalize(new Vector3(0.3110f, 0.02f, 0.9503f));
|
||||
|
||||
Vector3 belowUp = DirectionalShadowCascadeFitter.StableLightUp(below);
|
||||
Vector3 aboveUp = DirectionalShadowCascadeFitter.StableLightUp(above);
|
||||
|
||||
Assert.InRange(MathF.Abs(Vector3.Dot(below, belowUp)), 0f, 1e-5f);
|
||||
Assert.InRange(MathF.Abs(Vector3.Dot(above, aboveUp)), 0f, 1e-5f);
|
||||
Assert.True(Vector3.Dot(belowUp, aboveUp) > 0.999f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StableLightUp_TrueZenithIsFiniteAndOrthogonal()
|
||||
{
|
||||
Vector3 up = DirectionalShadowCascadeFitter.StableLightUp(Vector3.UnitZ);
|
||||
|
||||
Assert.True(float.IsFinite(up.X) && float.IsFinite(up.Y) && float.IsFinite(up.Z));
|
||||
Assert.Equal(1f, up.Length(), 5);
|
||||
Assert.InRange(MathF.Abs(Vector3.Dot(Vector3.UnitZ, up)), 0f, 1e-5f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StableLightUp_RemainsContinuousThroughCelestialZenith()
|
||||
{
|
||||
Vector3 beforeZenith = Vector3.Normalize(new Vector3(0.001f, 0.002f, 1f));
|
||||
Vector3 zenith = Vector3.UnitZ;
|
||||
Vector3 afterZenith = Vector3.Normalize(new Vector3(-0.001f, -0.002f, 1f));
|
||||
|
||||
Vector3 beforeUp = DirectionalShadowCascadeFitter.StableLightUp(beforeZenith);
|
||||
Vector3 zenithUp = DirectionalShadowCascadeFitter.StableLightUp(zenith);
|
||||
Vector3 afterUp = DirectionalShadowCascadeFitter.StableLightUp(afterZenith);
|
||||
|
||||
Assert.True(Vector3.Dot(beforeUp, zenithUp) > 0.99999f);
|
||||
Assert.True(Vector3.Dot(zenithUp, afterUp) > 0.99999f);
|
||||
Assert.InRange(MathF.Abs(Vector3.Dot(beforeZenith, beforeUp)), 0f, 1e-5f);
|
||||
Assert.InRange(MathF.Abs(Vector3.Dot(afterZenith, afterUp)), 0f, 1e-5f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClipDensityRatio_MatchesCascadeTexelFootprintRatio()
|
||||
{
|
||||
DirectionalShadowCascadeFitInput input = CameraInput(
|
||||
new Vector3(40f, -15f, 8f),
|
||||
DirectionalShadowQuality.For(DirectionalShadowPreset.High));
|
||||
Span<DirectionalShadowCascade> cascades =
|
||||
stackalloc DirectionalShadowCascade[4];
|
||||
int count = DirectionalShadowCascadeFitter.Fit(in input, cascades);
|
||||
|
||||
float nearDensity = ClipXyDensity(cascades[0].WorldToShadowClip);
|
||||
float farDensity = ClipXyDensity(cascades[count - 1].WorldToShadowClip);
|
||||
float shaderScale = farDensity / nearDensity;
|
||||
float expectedScale = cascades[0].TexelWorldSize
|
||||
/ cascades[count - 1].TexelWorldSize;
|
||||
|
||||
Assert.Equal(expectedScale, shaderScale, 4);
|
||||
Assert.InRange(shaderScale, 0f, 0.999f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fit_DoesNotAllocateOrInvokeSceneVisibility()
|
||||
{
|
||||
DirectionalShadowCascadeFitInput input = CameraInput(
|
||||
Vector3.Zero,
|
||||
DirectionalShadowQuality.For(DirectionalShadowPreset.High));
|
||||
Span<DirectionalShadowCascade> cascades =
|
||||
stackalloc DirectionalShadowCascade[4];
|
||||
|
||||
// Cross the tiered-JIT promotion threshold before taking the thread's
|
||||
// allocation counter; measuring immediately after one call makes the
|
||||
// runtime's compilation bookkeeping look like renderer allocation.
|
||||
for (int i = 0; i < 128; i++)
|
||||
DirectionalShadowCascadeFitter.Fit(in input, cascades);
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (int i = 0; i < 100; i++)
|
||||
DirectionalShadowCascadeFitter.Fit(in input, cascades);
|
||||
long after = GC.GetAllocatedBytesForCurrentThread();
|
||||
|
||||
Assert.Equal(0, after - before);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResidentWindowClampsOnlyTheFinalCascadeReach()
|
||||
{
|
||||
DirectionalShadowQuality quality = DirectionalShadowQuality.For(
|
||||
DirectionalShadowPreset.High);
|
||||
DirectionalShadowCascadeFitInput input = CameraInput(
|
||||
Vector3.Zero,
|
||||
quality) with
|
||||
{
|
||||
ResidentMaximumReachMeters = 96f,
|
||||
};
|
||||
Span<DirectionalShadowCascade> cascades =
|
||||
stackalloc DirectionalShadowCascade[4];
|
||||
|
||||
int count = DirectionalShadowCascadeFitter.Fit(in input, cascades);
|
||||
|
||||
Assert.Equal(quality.CascadeCount, count);
|
||||
Assert.Equal(96f, cascades[count - 1].SplitFarMeters, 3);
|
||||
Assert.All(
|
||||
cascades[..count].ToArray(),
|
||||
cascade => Assert.InRange(cascade.SplitFarMeters, 0f, 96f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnavailableResidentWindowDisablesFittingWithoutAllocating()
|
||||
{
|
||||
DirectionalShadowCascadeFitInput input = CameraInput(
|
||||
Vector3.Zero,
|
||||
DirectionalShadowQuality.For(DirectionalShadowPreset.High)) with
|
||||
{
|
||||
ResidentMaximumReachMeters = 0f,
|
||||
};
|
||||
Span<DirectionalShadowCascade> cascades =
|
||||
stackalloc DirectionalShadowCascade[4];
|
||||
|
||||
Assert.Equal(0, DirectionalShadowCascadeFitter.Fit(in input, cascades));
|
||||
}
|
||||
|
||||
private static DirectionalShadowCascadeFitInput CameraInput(
|
||||
Vector3 position,
|
||||
DirectionalShadowQuality quality)
|
||||
{
|
||||
Vector3 target = position + Vector3.Normalize(new Vector3(1f, 2f, -0.2f));
|
||||
Matrix4x4 view = Matrix4x4.CreateLookAt(position, target, Vector3.UnitZ);
|
||||
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
70f * MathF.PI / 180f,
|
||||
16f / 9f,
|
||||
0.1f,
|
||||
5000f);
|
||||
return new DirectionalShadowCascadeFitInput(
|
||||
view,
|
||||
projection,
|
||||
Vector3.Normalize(new Vector3(0.4f, 0.7f, 0.55f)),
|
||||
quality);
|
||||
}
|
||||
|
||||
private static float ClipXyDensity(Matrix4x4 matrix)
|
||||
{
|
||||
// System.Numerics row-vector storage is read as the transposed
|
||||
// column-major matrix in GLSL. These are the same two clip gradients
|
||||
// evaluated by acdreamShadowBiasScale.
|
||||
float x = new Vector3(matrix.M11, matrix.M21, matrix.M31).Length();
|
||||
float y = new Vector3(matrix.M12, matrix.M22, matrix.M32).Length();
|
||||
return 0.5f * (x + y);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,896 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class DirectionalShadowCasterFrameTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_IncludesEveryHeadlineProjectionClassAndExcludesTrueTransparency()
|
||||
{
|
||||
RenderProjectionRecord[] statics =
|
||||
[
|
||||
Record(1, RenderProjectionClass.OutdoorStatic),
|
||||
Record(2, RenderProjectionClass.OutdoorStatic, building: true),
|
||||
Record(3, RenderProjectionClass.ActiveAnimatedStatic),
|
||||
Record(4, RenderProjectionClass.OutdoorStatic,
|
||||
extraFlags: RenderProjectionFlags.Translucent),
|
||||
Record(5, RenderProjectionClass.OutdoorStatic, parentCell: 0x12340101),
|
||||
];
|
||||
RenderProjectionRecord[] dynamics =
|
||||
[
|
||||
Record(6, RenderProjectionClass.LiveDynamicRoot),
|
||||
Record(7, RenderProjectionClass.EquippedChild),
|
||||
];
|
||||
var source = new QuerySource(statics, dynamics);
|
||||
var frame = new DirectionalShadowCasterFrame();
|
||||
|
||||
frame.Build(new RenderSceneQuery(source, Generation));
|
||||
|
||||
DirectionalShadowCasterKind[] kinds = frame.Casters
|
||||
.ToArray()
|
||||
.Select(static value => value.Kind)
|
||||
.ToArray();
|
||||
Assert.Contains(DirectionalShadowCasterKind.OutdoorStatic, kinds);
|
||||
Assert.Contains(DirectionalShadowCasterKind.Building, kinds);
|
||||
Assert.Contains(DirectionalShadowCasterKind.AnimatedStatic, kinds);
|
||||
Assert.Contains(DirectionalShadowCasterKind.LiveDynamic, kinds);
|
||||
Assert.Contains(DirectionalShadowCasterKind.EquippedChild, kinds);
|
||||
Assert.Equal(5, frame.Casters.Length);
|
||||
Assert.Equal(1, frame.Stats.RejectedTransparent);
|
||||
Assert.Equal(1, frame.Stats.RejectedIndoor);
|
||||
Assert.True(frame.Casters[2].UsesCurrentAnimatedTransforms);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ReportsEveryAuthoritativeCasterClassAndPreservesCountsOnStableFrame()
|
||||
{
|
||||
RenderProjectionRecord[] statics =
|
||||
[
|
||||
Record(101, RenderProjectionClass.OutdoorStatic),
|
||||
Record(102, RenderProjectionClass.OutdoorStatic, building: true),
|
||||
Record(103, RenderProjectionClass.ActiveAnimatedStatic),
|
||||
];
|
||||
RenderProjectionRecord[] dynamics =
|
||||
[
|
||||
Record(104, RenderProjectionClass.LiveDynamicRoot,
|
||||
casterIdentity: RenderCasterIdentityKind.LocalPlayer),
|
||||
Record(105, RenderProjectionClass.LiveDynamicRoot,
|
||||
casterIdentity: RenderCasterIdentityKind.RemotePlayer),
|
||||
Record(106, RenderProjectionClass.LiveDynamicRoot,
|
||||
casterIdentity: RenderCasterIdentityKind.NonPlayerCreature),
|
||||
Record(107, RenderProjectionClass.LiveDynamicRoot,
|
||||
casterIdentity: RenderCasterIdentityKind.OtherLiveDynamic),
|
||||
Record(108, RenderProjectionClass.EquippedChild,
|
||||
casterIdentity: RenderCasterIdentityKind.EquippedChild),
|
||||
];
|
||||
var source = new QuerySource(statics, dynamics);
|
||||
var frame = new DirectionalShadowCasterFrame();
|
||||
|
||||
frame.Build(new RenderSceneQuery(source, Generation));
|
||||
DirectionalShadowCasterClassDiagnostics first = frame.Stats.CasterClasses;
|
||||
|
||||
Assert.Equal(0, first.TerrainCommands);
|
||||
Assert.Equal(1, first.OutdoorStatics);
|
||||
Assert.Equal(1, first.Buildings);
|
||||
Assert.Equal(1, first.AnimatedStatics);
|
||||
Assert.Equal(1, first.LocalPlayers);
|
||||
Assert.Equal(1, first.RemotePlayers);
|
||||
Assert.Equal(1, first.NonPlayerCreatures);
|
||||
Assert.Equal(1, first.OtherLiveDynamics);
|
||||
Assert.Equal(1, first.EquippedChildren);
|
||||
|
||||
frame.Build(new RenderSceneQuery(source, Generation));
|
||||
|
||||
Assert.Equal(first, frame.Stats.CasterClasses);
|
||||
Assert.False(frame.Stats.TopologyRebuilt);
|
||||
Assert.Equal(0, frame.Stats.Classifications);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ReadsOnlyTwoResidentIndicesOnce_NoPViewCellOrCascadeRecull()
|
||||
{
|
||||
var source = new QuerySource(
|
||||
[Record(20, RenderProjectionClass.OutdoorStatic)],
|
||||
[Record(21, RenderProjectionClass.LiveDynamicRoot)]);
|
||||
var frame = new DirectionalShadowCasterFrame();
|
||||
|
||||
frame.Build(new RenderSceneQuery(source, Generation));
|
||||
|
||||
Assert.Equal(1, source.IndexCountReads);
|
||||
Assert.Equal(2, source.IndexCopies);
|
||||
Assert.Equal(
|
||||
[RenderSceneIndex.OutdoorStatic, RenderSceneIndex.OutdoorDynamic],
|
||||
source.CopiedIndices);
|
||||
Assert.Equal(0, source.CellQueries);
|
||||
Assert.Equal(2, frame.Stats.IndexCopies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnchangedSecondFrame_ReusesSortedTopologyWithoutIndexCopiesOrClassification()
|
||||
{
|
||||
var source = new QuerySource(
|
||||
[
|
||||
Record(30, RenderProjectionClass.OutdoorStatic, sortKey: 30),
|
||||
Record(31, RenderProjectionClass.ActiveAnimatedStatic, sortKey: 10),
|
||||
],
|
||||
[Record(32, RenderProjectionClass.LiveDynamicRoot, sortKey: 20)]);
|
||||
var frame = new DirectionalShadowCasterFrame();
|
||||
RenderSceneQuery query = new(source, Generation);
|
||||
|
||||
frame.Build(in query);
|
||||
long retained = frame.RetainedScratchBytes;
|
||||
ulong[] firstOrder = frame.Casters
|
||||
.ToArray()
|
||||
.Select(static value => value.Projection.Id.RawValue)
|
||||
.ToArray();
|
||||
int indexReads = source.IndexCountReads;
|
||||
int indexCopies = source.IndexCopies;
|
||||
frame.Build(in query);
|
||||
|
||||
Assert.Equal([31ul, 32ul, 30ul], firstOrder);
|
||||
Assert.Equal([0, 1], frame.RefreshCasterSlots.ToArray());
|
||||
Assert.Equal(retained, frame.RetainedScratchBytes);
|
||||
Assert.Equal(1ul, frame.BuildSequence);
|
||||
Assert.Equal(indexReads, source.IndexCountReads);
|
||||
Assert.Equal(indexCopies, source.IndexCopies);
|
||||
Assert.False(frame.Stats.TopologyRebuilt);
|
||||
Assert.Equal(0, frame.Stats.IndexCopies);
|
||||
Assert.Equal(0, frame.Stats.Classifications);
|
||||
Assert.Equal(0, frame.Stats.DynamicTransformRefreshes);
|
||||
Assert.Empty(frame.ChangedCasterPoses.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopologyRebuild_ReplacesRefreshSlotsAndRetainedAccountingIncludesThem()
|
||||
{
|
||||
RenderProjectionRecord[] statics =
|
||||
[
|
||||
Record(33, RenderProjectionClass.OutdoorStatic),
|
||||
Record(34, RenderProjectionClass.OutdoorStatic),
|
||||
Record(35, RenderProjectionClass.OutdoorStatic),
|
||||
Record(36, RenderProjectionClass.OutdoorStatic),
|
||||
];
|
||||
var source = new QuerySource(statics, []);
|
||||
var frame = new DirectionalShadowCasterFrame();
|
||||
RenderSceneQuery query = new(source, Generation);
|
||||
|
||||
frame.Build(in query);
|
||||
long staticRetainedBytes = frame.RetainedScratchBytes;
|
||||
|
||||
Assert.Empty(frame.RefreshCasterSlots.ToArray());
|
||||
|
||||
source.ReplaceStatics(
|
||||
[
|
||||
Record(33, RenderProjectionClass.ActiveAnimatedStatic),
|
||||
Record(34, RenderProjectionClass.ActiveAnimatedStatic),
|
||||
Record(35, RenderProjectionClass.ActiveAnimatedStatic),
|
||||
Record(36, RenderProjectionClass.ActiveAnimatedStatic),
|
||||
],
|
||||
topologyChanged: true);
|
||||
frame.Build(in query);
|
||||
long animatedRetainedBytes = frame.RetainedScratchBytes;
|
||||
|
||||
Assert.True(animatedRetainedBytes > staticRetainedBytes);
|
||||
Assert.Equal([0, 1, 2, 3], frame.RefreshCasterSlots.ToArray());
|
||||
|
||||
source.ReplaceStatics(statics, topologyChanged: true);
|
||||
frame.Build(in query);
|
||||
int projectionReads = source.ProjectionReads;
|
||||
frame.Build(in query);
|
||||
|
||||
Assert.Empty(frame.RefreshCasterSlots.ToArray());
|
||||
Assert.Equal(animatedRetainedBytes, frame.RetainedScratchBytes);
|
||||
Assert.Equal(projectionReads, source.ProjectionReads);
|
||||
Assert.Equal(0, frame.Stats.DynamicTransformRefreshes);
|
||||
Assert.False(frame.Stats.TopologyRebuilt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StableTopology_EmitsExactDynamicPoseWithoutOverwritingTopologyCaster()
|
||||
{
|
||||
RenderProjectionRecord original =
|
||||
Record(40, RenderProjectionClass.LiveDynamicRoot);
|
||||
var source = new QuerySource([], [original]);
|
||||
var frame = new DirectionalShadowCasterFrame();
|
||||
RenderSceneQuery query = new(source, Generation);
|
||||
frame.Build(in query);
|
||||
|
||||
float rootX = BitConverter.Int32BitsToSingle(0x41234567);
|
||||
float partY = BitConverter.Int32BitsToSingle(0x40ABCDEF);
|
||||
Matrix4x4 root = Matrix4x4.CreateTranslation(rootX, 2f, 3f);
|
||||
Matrix4x4 part = Matrix4x4.CreateTranslation(4f, partY, 6f);
|
||||
RenderProjectionRecord moved = original with
|
||||
{
|
||||
Transform = new RenderTransform(root),
|
||||
EntityPayload = original.EntityPayload with
|
||||
{
|
||||
MeshRefs = [new MeshRef(40, part)],
|
||||
},
|
||||
};
|
||||
source.ReplaceDynamics([moved], topologyChanged: false);
|
||||
|
||||
frame.Build(in query);
|
||||
|
||||
RenderProjectionRecord retained = frame.Casters[0].Projection;
|
||||
Assert.Equal(original.Transform, retained.Transform);
|
||||
Assert.Same(
|
||||
original.EntityPayload.MeshRefs,
|
||||
retained.EntityPayload.MeshRefs);
|
||||
DirectionalShadowChangedPose changed =
|
||||
Assert.Single(frame.ChangedCasterPoses.ToArray());
|
||||
Assert.Equal(0, changed.CasterIndex);
|
||||
Assert.Equal(
|
||||
BitConverter.SingleToInt32Bits(rootX),
|
||||
BitConverter.SingleToInt32Bits(
|
||||
changed.Snapshot.Transform.LocalToWorld.M41));
|
||||
Assert.Equal(
|
||||
BitConverter.SingleToInt32Bits(partY),
|
||||
BitConverter.SingleToInt32Bits(
|
||||
changed.Snapshot.EntityPayload.MeshRefs[0].PartTransform.M42));
|
||||
Assert.Equal(1ul, frame.BuildSequence);
|
||||
Assert.Equal(0, source.ProjectionReads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StableTopology_RefreshesOnlyChangedCasterSlots()
|
||||
{
|
||||
RenderProjectionRecord first =
|
||||
Record(41, RenderProjectionClass.LiveDynamicRoot);
|
||||
RenderProjectionRecord second =
|
||||
Record(42, RenderProjectionClass.EquippedChild);
|
||||
var source = new QuerySource([], [first, second]);
|
||||
var frame = new DirectionalShadowCasterFrame();
|
||||
RenderSceneQuery query = new(source, Generation);
|
||||
frame.Build(in query);
|
||||
RenderProjectionRecord moved = second with
|
||||
{
|
||||
Transform = new RenderTransform(
|
||||
Matrix4x4.CreateTranslation(420f, 2f, 3f)),
|
||||
};
|
||||
|
||||
source.ReplaceDynamics([first, moved], topologyChanged: false);
|
||||
frame.Build(in query);
|
||||
|
||||
Assert.Equal(0, source.ProjectionReads);
|
||||
Assert.Equal(
|
||||
[1],
|
||||
frame.ChangedCasterPoses.ToArray()
|
||||
.Select(static changed => changed.CasterIndex));
|
||||
Assert.Equal(first.Transform, frame.Casters[0].Projection.Transform);
|
||||
Assert.Equal(second.Transform, frame.Casters[1].Projection.Transform);
|
||||
Assert.Equal(
|
||||
moved.Transform,
|
||||
frame.ChangedCasterPoses[0].Snapshot.Transform);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedSameId_ConsumesLatestJournalRecordWithoutSceneRead()
|
||||
{
|
||||
RenderProjectionRecord original =
|
||||
Record(46, RenderProjectionClass.LiveDynamicRoot);
|
||||
var source = new QuerySource([], [original]);
|
||||
var frame = new DirectionalShadowCasterFrame();
|
||||
RenderSceneQuery query = new(source, Generation);
|
||||
frame.Build(in query);
|
||||
RenderProjectionRecord intermediate = original with
|
||||
{
|
||||
Transform = new RenderTransform(
|
||||
Matrix4x4.CreateTranslation(100f, 101f, 102f)),
|
||||
EntityPayload = original.EntityPayload with
|
||||
{
|
||||
MeshRefs =
|
||||
[
|
||||
new MeshRef(
|
||||
46,
|
||||
Matrix4x4.CreateTranslation(103f, 104f, 105f)),
|
||||
],
|
||||
},
|
||||
};
|
||||
float rootX = BitConverter.Int32BitsToSingle(0x41234567);
|
||||
float partY = BitConverter.Int32BitsToSingle(0x40ABCDEF);
|
||||
RenderProjectionRecord latest = intermediate with
|
||||
{
|
||||
Transform = new RenderTransform(
|
||||
Matrix4x4.CreateTranslation(rootX, 201f, 202f)),
|
||||
EntityPayload = intermediate.EntityPayload with
|
||||
{
|
||||
MeshRefs =
|
||||
[
|
||||
new MeshRef(
|
||||
46,
|
||||
Matrix4x4.CreateTranslation(203f, partY, 205f)),
|
||||
],
|
||||
},
|
||||
};
|
||||
source.PublishTransformRecord(in intermediate);
|
||||
source.PublishTransformRecord(in latest);
|
||||
|
||||
frame.Build(in query);
|
||||
|
||||
Assert.Equal(2, frame.Stats.CopiedTransformChanges);
|
||||
Assert.Equal(1, frame.Stats.DedupedChangedCasterSlots);
|
||||
Assert.Equal(0, source.ProjectionReads);
|
||||
Assert.Equal(0, source.BatchedProjectionCopies);
|
||||
Assert.Equal(original.Transform, frame.Casters[0].Projection.Transform);
|
||||
DirectionalShadowTransformSnapshot current =
|
||||
frame.ChangedCasterPoses[0].Snapshot;
|
||||
Assert.Equal(
|
||||
BitConverter.SingleToInt32Bits(rootX),
|
||||
BitConverter.SingleToInt32Bits(current.Transform.LocalToWorld.M41));
|
||||
Assert.Equal(
|
||||
BitConverter.SingleToInt32Bits(partY),
|
||||
BitConverter.SingleToInt32Bits(
|
||||
current.EntityPayload.MeshRefs[0].PartTransform.M42));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransformJournalOverflow_FallsBackToExactFullDynamicRefresh()
|
||||
{
|
||||
RenderProjectionRecord first =
|
||||
Record(43, RenderProjectionClass.LiveDynamicRoot);
|
||||
RenderProjectionRecord second =
|
||||
Record(44, RenderProjectionClass.EquippedChild);
|
||||
var source = new QuerySource([], [first, second]);
|
||||
var frame = new DirectionalShadowCasterFrame();
|
||||
RenderSceneQuery query = new(source, Generation);
|
||||
frame.Build(in query);
|
||||
source.PublishTransformChanges(
|
||||
first.Id,
|
||||
DirectionalShadowTransformChangeJournal.Capacity + 1);
|
||||
|
||||
frame.Build(in query);
|
||||
|
||||
Assert.Equal(0, source.ProjectionReads);
|
||||
Assert.Equal(1, source.BatchedProjectionCopies);
|
||||
Assert.Equal(
|
||||
[0, 1],
|
||||
frame.ChangedCasterPoses.ToArray()
|
||||
.Select(static changed => changed.CasterIndex));
|
||||
Assert.Equal(2, frame.Stats.DynamicTransformRefreshes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DenseChangedSet_UsesExactBulkRefreshInsteadOfSparseDictionaryWalk()
|
||||
{
|
||||
var dynamics = new RenderProjectionRecord[100];
|
||||
for (int index = 0; index < dynamics.Length; index++)
|
||||
{
|
||||
dynamics[index] = Record(
|
||||
checked((ulong)(4_500 + index)),
|
||||
RenderProjectionClass.ActiveAnimatedStatic);
|
||||
}
|
||||
var source = new QuerySource(dynamics, []);
|
||||
var frame = new DirectionalShadowCasterFrame();
|
||||
RenderSceneQuery query = new(source, Generation);
|
||||
frame.Build(in query);
|
||||
for (int index = 0; index < 75; index++)
|
||||
source.PublishTransformChanges(dynamics[index].Id, 1);
|
||||
|
||||
frame.Build(in query);
|
||||
|
||||
Assert.True(frame.Stats.DensityBulkRefresh);
|
||||
Assert.Equal(75, frame.Stats.CopiedTransformChanges);
|
||||
Assert.Equal(75, frame.Stats.DynamicTransformRefreshes);
|
||||
Assert.Equal(0, frame.Stats.BatchedProjectionCopyCalls);
|
||||
Assert.Equal(0, source.BatchedProjectionCopies);
|
||||
Assert.Equal(0, source.ProjectionReads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemovalAndRevisit_RebuildTopologyAndDiscardPriorTransformCursor()
|
||||
{
|
||||
RenderProjectionRecord original =
|
||||
Record(45, RenderProjectionClass.LiveDynamicRoot);
|
||||
var source = new QuerySource([], [original]);
|
||||
var frame = new DirectionalShadowCasterFrame();
|
||||
RenderSceneQuery query = new(source, Generation);
|
||||
frame.Build(in query);
|
||||
source.ReplaceDynamics([], topologyChanged: true);
|
||||
|
||||
frame.Build(in query);
|
||||
|
||||
Assert.Empty(frame.Casters.ToArray());
|
||||
Assert.Equal(2ul, frame.BuildSequence);
|
||||
RenderProjectionRecord revisited = original with
|
||||
{
|
||||
Transform = new RenderTransform(
|
||||
Matrix4x4.CreateTranslation(450f, 2f, 3f)),
|
||||
};
|
||||
source.ReplaceDynamics([revisited], topologyChanged: true);
|
||||
frame.Build(in query);
|
||||
int projectionReads = source.ProjectionReads;
|
||||
frame.Build(in query);
|
||||
|
||||
Assert.Equal(3ul, frame.BuildSequence);
|
||||
Assert.Equal(revisited.Transform, frame.Casters[0].Projection.Transform);
|
||||
Assert.Equal(projectionReads, source.ProjectionReads);
|
||||
Assert.Empty(frame.ChangedCasterPoses.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MembershipOrAppearanceRevision_RebuildsTopology()
|
||||
{
|
||||
RenderProjectionRecord original =
|
||||
Record(50, RenderProjectionClass.OutdoorStatic);
|
||||
var source = new QuerySource([original], []);
|
||||
var frame = new DirectionalShadowCasterFrame();
|
||||
RenderSceneQuery query = new(source, Generation);
|
||||
frame.Build(in query);
|
||||
|
||||
RenderProjectionRecord changed = original with
|
||||
{
|
||||
EntityPayload = original.EntityPayload with
|
||||
{
|
||||
MeshRefs =
|
||||
[
|
||||
new MeshRef(51, original.EntityPayload.MeshRefs[0].PartTransform)
|
||||
{
|
||||
SurfaceOverrides = new Dictionary<uint, uint>
|
||||
{
|
||||
[0x08000001] = 0x05000001,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
source.ReplaceStatics([changed], topologyChanged: true);
|
||||
|
||||
frame.Build(in query);
|
||||
|
||||
Assert.Equal(2ul, frame.BuildSequence);
|
||||
Assert.True(frame.Stats.TopologyRebuilt);
|
||||
Assert.Equal(2, frame.Stats.IndexCopies);
|
||||
Assert.Equal(1, frame.Stats.Classifications);
|
||||
Assert.Equal((uint)51, frame.Casters[0].Projection.EntityPayload.MeshRefs[0].GfxObjId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmStableFrame_AllocatesZero()
|
||||
{
|
||||
var statics = new RenderProjectionRecord[9_500];
|
||||
for (int index = 0; index < statics.Length; index++)
|
||||
{
|
||||
statics[index] = Record(
|
||||
checked((ulong)(index + 60)),
|
||||
RenderProjectionClass.OutdoorStatic);
|
||||
}
|
||||
var source = new QuerySource(
|
||||
statics,
|
||||
[Record(10_000, RenderProjectionClass.LiveDynamicRoot)]);
|
||||
var frame = new DirectionalShadowCasterFrame();
|
||||
RenderSceneQuery query = new(source, Generation);
|
||||
frame.Build(in query);
|
||||
frame.Build(in query);
|
||||
int copies = source.IndexCopies;
|
||||
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (int iteration = 0; iteration < 256; iteration++)
|
||||
frame.Build(in query);
|
||||
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
|
||||
Assert.Equal(0, allocated);
|
||||
Assert.Equal(1ul, frame.BuildSequence);
|
||||
Assert.Equal(copies, source.IndexCopies);
|
||||
Assert.Equal(0, frame.Stats.Classifications);
|
||||
Assert.Equal(0, frame.Stats.DynamicTransformRefreshes);
|
||||
Assert.False(frame.Stats.TopologyRebuilt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmDenseChangedFrames_AllocateZeroAndReadNoSceneRecords()
|
||||
{
|
||||
var dynamics = new RenderProjectionRecord[100];
|
||||
for (int index = 0; index < dynamics.Length; index++)
|
||||
{
|
||||
dynamics[index] = Record(
|
||||
checked((ulong)(20_000 + index)),
|
||||
RenderProjectionClass.ActiveAnimatedStatic);
|
||||
}
|
||||
var source = new QuerySource(dynamics, []);
|
||||
var frame = new DirectionalShadowCasterFrame();
|
||||
RenderSceneQuery query = new(source, Generation);
|
||||
frame.Build(in query);
|
||||
source.EnsureTransformChangeCapacity(5_000);
|
||||
for (int index = 0; index < 75; index++)
|
||||
source.PublishTransformChanges(dynamics[index].Id, 1);
|
||||
frame.Build(in query);
|
||||
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (int iteration = 0; iteration < 64; iteration++)
|
||||
{
|
||||
for (int index = 0; index < 75; index++)
|
||||
source.PublishTransformChanges(dynamics[index].Id, 1);
|
||||
frame.Build(in query);
|
||||
}
|
||||
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
|
||||
Assert.Equal(0, allocated);
|
||||
Assert.True(frame.Stats.DensityBulkRefresh);
|
||||
Assert.Equal(75, frame.Stats.DynamicTransformRefreshes);
|
||||
Assert.Equal(0, source.ProjectionReads);
|
||||
Assert.Equal(0, source.BatchedProjectionCopies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionTopologyFingerprint_ExcludesPoseButIncludesGeometryAndSurfaceOverrides()
|
||||
{
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 1,
|
||||
SourceGfxObjOrSetupId = 0x02000001,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs =
|
||||
[
|
||||
new MeshRef(
|
||||
0x01000001,
|
||||
Matrix4x4.CreateTranslation(1f, 2f, 3f)),
|
||||
],
|
||||
};
|
||||
RenderSceneHash128 original = CurrentRenderSceneOracle
|
||||
.CreateDirectionalShadowTopologyFingerprint(entity);
|
||||
entity.MeshRefs =
|
||||
[
|
||||
new MeshRef(
|
||||
0x01000001,
|
||||
Matrix4x4.CreateTranslation(10f, 20f, 30f)),
|
||||
];
|
||||
RenderSceneHash128 poseOnly = CurrentRenderSceneOracle
|
||||
.CreateDirectionalShadowTopologyFingerprint(entity);
|
||||
entity.MeshRefs =
|
||||
[
|
||||
new MeshRef(
|
||||
0x01000002,
|
||||
Matrix4x4.CreateTranslation(10f, 20f, 30f))
|
||||
{
|
||||
SurfaceOverrides = new Dictionary<uint, uint>
|
||||
{
|
||||
[0x08000001] = 0x05000001,
|
||||
},
|
||||
},
|
||||
];
|
||||
RenderSceneHash128 changed = CurrentRenderSceneOracle
|
||||
.CreateDirectionalShadowTopologyFingerprint(entity);
|
||||
|
||||
Assert.Equal(original, poseOnly);
|
||||
Assert.NotEqual(original, changed);
|
||||
}
|
||||
|
||||
private static readonly RenderSceneGeneration Generation =
|
||||
RenderSceneGeneration.FromRaw(9);
|
||||
|
||||
private static RenderProjectionRecord Record(
|
||||
ulong id,
|
||||
RenderProjectionClass projectionClass,
|
||||
bool building = false,
|
||||
RenderProjectionFlags extraFlags = RenderProjectionFlags.None,
|
||||
uint parentCell = 0,
|
||||
ulong? sortKey = null,
|
||||
RenderCasterIdentityKind casterIdentity =
|
||||
RenderCasterIdentityKind.Unclassified)
|
||||
{
|
||||
Matrix4x4 transform = Matrix4x4.CreateTranslation((float)id, 0f, 0f);
|
||||
return new RenderProjectionRecord() with
|
||||
{
|
||||
Id = RenderProjectionId.FromRaw(id),
|
||||
ProjectionClass = projectionClass,
|
||||
OwnerIncarnation = RenderOwnerIncarnation.FromRaw(1),
|
||||
Transform = new RenderTransform(transform),
|
||||
PreviousTransform = new PreviousRenderTransform(transform),
|
||||
MeshSet = new RenderMeshSet(RenderAssetHandle.FromRaw(id), 1, 1),
|
||||
Material = new RenderMaterialVariant(0, 0, 1),
|
||||
Residency = new RenderSpatialResidency(
|
||||
RenderSpatialBucket.FromRaw(id),
|
||||
0x1234FFFF,
|
||||
parentCell),
|
||||
Bounds = new RenderWorldBounds(Vector3.Zero, Vector3.One),
|
||||
Flags = RenderProjectionFlags.Draw
|
||||
| RenderProjectionFlags.SpatiallyResident
|
||||
| extraFlags,
|
||||
SortKey = new RenderSortKey(sortKey ?? id),
|
||||
Source = new RenderSourceMetadata(
|
||||
LocalEntityId: (uint)id,
|
||||
ServerGuid: projectionClass is RenderProjectionClass.LiveDynamicRoot
|
||||
or RenderProjectionClass.EquippedChild
|
||||
? (uint)id
|
||||
: 0,
|
||||
SourceId: (uint)id,
|
||||
ParentCellId: parentCell,
|
||||
EffectCellId: 0,
|
||||
BuildingShellAnchorCellId: 0,
|
||||
TransformFingerprint: default,
|
||||
GeometryFingerprint: default,
|
||||
AppearanceFingerprint: default),
|
||||
EntityPayload = new RenderEntityPayload(
|
||||
[new MeshRef((uint)id, transform)],
|
||||
PaletteOverride: null,
|
||||
IsBuildingShell: building,
|
||||
CasterIdentity: casterIdentity),
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class QuerySource : IRenderSceneQuerySource
|
||||
{
|
||||
private RenderProjectionRecord[] _statics;
|
||||
private RenderProjectionRecord[] _dynamics;
|
||||
|
||||
public QuerySource(
|
||||
RenderProjectionRecord[] statics,
|
||||
RenderProjectionRecord[] dynamics)
|
||||
{
|
||||
_statics = statics;
|
||||
_dynamics = dynamics;
|
||||
}
|
||||
|
||||
public int IndexCountReads { get; private set; }
|
||||
public int IndexCopies { get; private set; }
|
||||
public int CellQueries { get; private set; }
|
||||
public int ProjectionReads { get; private set; }
|
||||
public int BatchedProjectionCopies { get; private set; }
|
||||
public ulong TopologyRevision { get; private set; } = 1;
|
||||
public ulong TransformRevision { get; private set; } = 1;
|
||||
public List<RenderSceneIndex> CopiedIndices { get; } = [];
|
||||
private readonly List<DirectionalShadowTransformSnapshot>
|
||||
_transformChanges = [];
|
||||
|
||||
public RenderProjectionCounts GetCounts(RenderSceneGeneration generation) =>
|
||||
throw new InvalidOperationException("The caster product must not enumerate the whole scene.");
|
||||
|
||||
public RenderSceneIndexCounts GetIndexCounts(RenderSceneGeneration generation)
|
||||
{
|
||||
IndexCountReads++;
|
||||
return new RenderSceneIndexCounts(
|
||||
OutdoorStatic: _statics.Length,
|
||||
IndoorCellStatic: 0,
|
||||
Dynamic: _dynamics.Length,
|
||||
OutdoorDynamic: _dynamics.Length,
|
||||
PortalStraddlingDynamic: 0,
|
||||
Translucent: 0,
|
||||
Selectable: 0,
|
||||
LightCandidate: 0,
|
||||
Dirty: 0);
|
||||
}
|
||||
|
||||
public ulong GetIndexRevision(RenderSceneGeneration generation) => 1;
|
||||
|
||||
public ulong GetDirectionalShadowTopologyRevision(
|
||||
RenderSceneGeneration generation) => TopologyRevision;
|
||||
|
||||
public ulong GetDirectionalShadowTransformRevision(
|
||||
RenderSceneGeneration generation) => TransformRevision;
|
||||
|
||||
public DirectionalShadowTransformChanges
|
||||
CopyDirectionalShadowTransformChanges(
|
||||
RenderSceneGeneration generation,
|
||||
ulong afterRevision,
|
||||
Span<DirectionalShadowTransformSnapshot> destination)
|
||||
{
|
||||
if (afterRevision == TransformRevision)
|
||||
{
|
||||
return new DirectionalShadowTransformChanges(
|
||||
TransformRevision,
|
||||
0,
|
||||
false);
|
||||
}
|
||||
ulong delta = TransformRevision - afterRevision;
|
||||
if (afterRevision == 0
|
||||
|| afterRevision > TransformRevision
|
||||
|| delta > (ulong)_transformChanges.Count
|
||||
|| delta > (ulong)destination.Length)
|
||||
{
|
||||
return new DirectionalShadowTransformChanges(
|
||||
TransformRevision,
|
||||
0,
|
||||
true);
|
||||
}
|
||||
int count = checked((int)delta);
|
||||
int start = _transformChanges.Count - count;
|
||||
for (int index = 0; index < count; index++)
|
||||
destination[index] = _transformChanges[start + index];
|
||||
return new DirectionalShadowTransformChanges(
|
||||
TransformRevision,
|
||||
count,
|
||||
false);
|
||||
}
|
||||
|
||||
public bool TryGet(
|
||||
RenderSceneGeneration generation,
|
||||
RenderProjectionId id,
|
||||
out RenderProjectionRecord record)
|
||||
{
|
||||
ProjectionReads++;
|
||||
for (int index = 0; index < _statics.Length; index++)
|
||||
{
|
||||
if (_statics[index].Id == id)
|
||||
{
|
||||
record = _statics[index];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (int index = 0; index < _dynamics.Length; index++)
|
||||
{
|
||||
if (_dynamics[index].Id == id)
|
||||
{
|
||||
record = _dynamics[index];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
record = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public int CopyById(
|
||||
RenderSceneGeneration generation,
|
||||
ReadOnlySpan<RenderProjectionId> ids,
|
||||
Span<RenderProjectionRecord> destination)
|
||||
{
|
||||
BatchedProjectionCopies++;
|
||||
if (destination.Length < ids.Length)
|
||||
throw new ArgumentException("Destination is too small.", nameof(destination));
|
||||
for (int outputIndex = 0; outputIndex < ids.Length; outputIndex++)
|
||||
{
|
||||
bool found = false;
|
||||
for (int index = 0; index < _statics.Length; index++)
|
||||
{
|
||||
if (_statics[index].Id != ids[outputIndex])
|
||||
continue;
|
||||
destination[outputIndex] = _statics[index];
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if (!found)
|
||||
{
|
||||
for (int index = 0; index < _dynamics.Length; index++)
|
||||
{
|
||||
if (_dynamics[index].Id != ids[outputIndex])
|
||||
continue;
|
||||
destination[outputIndex] = _dynamics[index];
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found)
|
||||
throw new InvalidOperationException("Missing projection.");
|
||||
}
|
||||
return ids.Length;
|
||||
}
|
||||
|
||||
public int CopyTo(
|
||||
RenderSceneGeneration generation,
|
||||
RenderProjectionClass? projectionClass,
|
||||
Span<RenderProjectionRecord> destination) =>
|
||||
throw new InvalidOperationException("The caster product must not enumerate the whole scene.");
|
||||
|
||||
public int CopyIndexTo(
|
||||
RenderSceneGeneration generation,
|
||||
RenderSceneIndex index,
|
||||
Span<RenderProjectionRecord> destination)
|
||||
{
|
||||
IndexCopies++;
|
||||
CopiedIndices.Add(index);
|
||||
RenderProjectionRecord[] values = index switch
|
||||
{
|
||||
RenderSceneIndex.OutdoorStatic => _statics,
|
||||
RenderSceneIndex.OutdoorDynamic => _dynamics,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unexpected directional-shadow source index {index}."),
|
||||
};
|
||||
values.CopyTo(destination);
|
||||
return values.Length;
|
||||
}
|
||||
|
||||
public int GetCellCount(
|
||||
RenderSceneGeneration generation,
|
||||
uint fullCellId,
|
||||
bool dynamic)
|
||||
{
|
||||
CellQueries++;
|
||||
throw new InvalidOperationException("Directional shadows do not query PView cells.");
|
||||
}
|
||||
|
||||
public int CopyCellTo(
|
||||
RenderSceneGeneration generation,
|
||||
uint fullCellId,
|
||||
bool dynamic,
|
||||
Span<RenderProjectionRecord> destination)
|
||||
{
|
||||
CellQueries++;
|
||||
throw new InvalidOperationException("Directional shadows do not query PView cells.");
|
||||
}
|
||||
|
||||
|
||||
public void ReplaceStatics(
|
||||
RenderProjectionRecord[] values,
|
||||
bool topologyChanged)
|
||||
{
|
||||
_statics = values;
|
||||
if (topologyChanged)
|
||||
TopologyRevision++;
|
||||
}
|
||||
|
||||
public void ReplaceDynamics(
|
||||
RenderProjectionRecord[] values,
|
||||
bool topologyChanged)
|
||||
{
|
||||
RenderProjectionRecord[] previous = _dynamics;
|
||||
_dynamics = values;
|
||||
if (topologyChanged)
|
||||
TopologyRevision++;
|
||||
else
|
||||
{
|
||||
for (int index = 0; index < values.Length; index++)
|
||||
{
|
||||
RenderProjectionRecord current = values[index];
|
||||
RenderProjectionRecord prior = previous.FirstOrDefault(
|
||||
value => value.Id == current.Id);
|
||||
if (prior.Id == current.Id
|
||||
&& prior.Transform == current.Transform
|
||||
&& PartTransformsEqual(in prior, in current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
PublishTransformChanges(current.Id, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void PublishTransformChanges(RenderProjectionId id, int count)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(count);
|
||||
RenderProjectionRecord record = Find(id);
|
||||
for (int index = 0; index < count; index++)
|
||||
{
|
||||
_transformChanges.Add(
|
||||
DirectionalShadowTransformSnapshot.Capture(in record));
|
||||
TransformRevision++;
|
||||
}
|
||||
}
|
||||
|
||||
public void PublishTransformRecord(in RenderProjectionRecord record)
|
||||
{
|
||||
_transformChanges.Add(
|
||||
DirectionalShadowTransformSnapshot.Capture(in record));
|
||||
TransformRevision++;
|
||||
}
|
||||
|
||||
public void EnsureTransformChangeCapacity(int capacity) =>
|
||||
_transformChanges.EnsureCapacity(capacity);
|
||||
|
||||
private RenderProjectionRecord Find(RenderProjectionId id)
|
||||
{
|
||||
for (int index = 0; index < _statics.Length; index++)
|
||||
{
|
||||
if (_statics[index].Id == id)
|
||||
return _statics[index];
|
||||
}
|
||||
for (int index = 0; index < _dynamics.Length; index++)
|
||||
{
|
||||
if (_dynamics[index].Id == id)
|
||||
return _dynamics[index];
|
||||
}
|
||||
throw new InvalidOperationException("Missing projection.");
|
||||
}
|
||||
|
||||
private static bool PartTransformsEqual(
|
||||
in RenderProjectionRecord left,
|
||||
in RenderProjectionRecord right)
|
||||
{
|
||||
IReadOnlyList<MeshRef> leftMeshes = left.EntityPayload.MeshRefs;
|
||||
IReadOnlyList<MeshRef> rightMeshes = right.EntityPayload.MeshRefs;
|
||||
if (leftMeshes.Count != rightMeshes.Count)
|
||||
return false;
|
||||
for (int index = 0; index < leftMeshes.Count; index++)
|
||||
{
|
||||
if (leftMeshes[index].PartTransform
|
||||
!= rightMeshes[index].PartTransform)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class DirectionalShadowEnvironmentGateTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false, false, false, DirectionalShadowGateReason.PackDisabled)]
|
||||
[InlineData(true, true, false, DirectionalShadowGateReason.PortalOrLoginCover)]
|
||||
[InlineData(true, false, true, DirectionalShadowGateReason.Indoor)]
|
||||
internal void NonWorldGates_DisableWithoutCreatingCelestialWork(
|
||||
bool enabled,
|
||||
bool cover,
|
||||
bool indoor,
|
||||
DirectionalShadowGateReason expected)
|
||||
{
|
||||
DirectionalShadowEnvironmentInput input = new(
|
||||
enabled,
|
||||
cover,
|
||||
indoor,
|
||||
Celestial(AuthoredCelestialShadowSourceKind.SecondaryMoon, 35f),
|
||||
Atmosphere(WeatherKind.Clear));
|
||||
|
||||
DirectionalShadowEnvironmentState result =
|
||||
DirectionalShadowEnvironmentGate.Evaluate(
|
||||
in input,
|
||||
DirectionalShadowAtmospherePolicy.BuiltIn);
|
||||
|
||||
Assert.False(result.ShouldRender);
|
||||
Assert.Equal(expected, result.Reason);
|
||||
Assert.Equal(0f, result.Strength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedCelestialBelowHorizon_DisablesAndPreservesSourceIdentity()
|
||||
{
|
||||
AuthoredCelestialShadowSource source = Celestial(
|
||||
AuthoredCelestialShadowSourceKind.SecondaryMoon,
|
||||
-2f);
|
||||
DirectionalShadowEnvironmentInput input = new(
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
source,
|
||||
Atmosphere(WeatherKind.Clear));
|
||||
|
||||
DirectionalShadowEnvironmentState result =
|
||||
DirectionalShadowEnvironmentGate.Evaluate(
|
||||
in input,
|
||||
DirectionalShadowAtmospherePolicy.BuiltIn);
|
||||
|
||||
Assert.False(result.ShouldRender);
|
||||
Assert.Equal(
|
||||
DirectionalShadowGateReason.SelectedLightBelowHorizon,
|
||||
result.Reason);
|
||||
Assert.Equal(source.SurfaceToLightDirection, result.SurfaceToLightDirection);
|
||||
Assert.Equal(source.Kind, result.SourceKind);
|
||||
Assert.Equal(source.ObjectIndex, result.SourceObjectIndex);
|
||||
Assert.Equal(source.GfxObjId, result.SourceGfxObjId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoVisibleCelestial_DisablesBeforeAtmosphereMapping()
|
||||
{
|
||||
DirectionalShadowEnvironmentInput input = new(
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
AuthoredCelestialShadowSource.None(authoredEnergy: 1f),
|
||||
Atmosphere(WeatherKind.Clear));
|
||||
|
||||
DirectionalShadowEnvironmentState result =
|
||||
DirectionalShadowEnvironmentGate.Evaluate(
|
||||
in input,
|
||||
DirectionalShadowAtmospherePolicy.BuiltIn);
|
||||
|
||||
Assert.False(result.ShouldRender);
|
||||
Assert.Equal(DirectionalShadowGateReason.NoVisibleCelestial, result.Reason);
|
||||
Assert.Equal(AuthoredCelestialShadowSourceKind.None, result.SourceKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedCelestialWithoutAuthoredEnergy_DisablesAndPreservesSourceIdentity()
|
||||
{
|
||||
AuthoredCelestialShadowSource source = Celestial(
|
||||
AuthoredCelestialShadowSourceKind.DominantMoon,
|
||||
35f,
|
||||
energy: 0f);
|
||||
DirectionalShadowEnvironmentInput input = new(
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
source,
|
||||
Atmosphere(WeatherKind.Clear));
|
||||
|
||||
DirectionalShadowEnvironmentState result =
|
||||
DirectionalShadowEnvironmentGate.Evaluate(
|
||||
in input,
|
||||
DirectionalShadowAtmospherePolicy.BuiltIn);
|
||||
|
||||
Assert.False(result.ShouldRender);
|
||||
Assert.Equal(
|
||||
DirectionalShadowGateReason.SelectedLightHasNoEnergy,
|
||||
result.Reason);
|
||||
Assert.Equal(source.SurfaceToLightDirection, result.SurfaceToLightDirection);
|
||||
Assert.Equal(source.Kind, result.SourceKind);
|
||||
Assert.Equal(source.ObjectIndex, result.SourceObjectIndex);
|
||||
Assert.Equal(source.GfxObjId, result.SourceGfxObjId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoredWeatherAndDayGroup_SoftenAndReduceButDoNotReplaceSelectedCelestial()
|
||||
{
|
||||
AuthoredCelestialShadowSource source = Celestial(
|
||||
AuthoredCelestialShadowSourceKind.DominantMoon,
|
||||
35f);
|
||||
DirectionalShadowEnvironmentInput clearInput = new(
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
source,
|
||||
Atmosphere(WeatherKind.Clear),
|
||||
ActiveDayGroupMultiplier: 1f);
|
||||
DirectionalShadowEnvironmentInput rainInput = clearInput with
|
||||
{
|
||||
Atmosphere = Atmosphere(WeatherKind.Rain),
|
||||
ActiveDayGroupMultiplier = 0.8f,
|
||||
};
|
||||
|
||||
DirectionalShadowEnvironmentState clear =
|
||||
DirectionalShadowEnvironmentGate.Evaluate(
|
||||
in clearInput,
|
||||
DirectionalShadowAtmospherePolicy.BuiltIn);
|
||||
DirectionalShadowEnvironmentState rain =
|
||||
DirectionalShadowEnvironmentGate.Evaluate(
|
||||
in rainInput,
|
||||
DirectionalShadowAtmospherePolicy.BuiltIn);
|
||||
|
||||
Assert.True(clear.ShouldRender);
|
||||
Assert.True(rain.ShouldRender);
|
||||
Assert.Equal(source.SurfaceToLightDirection, clear.SurfaceToLightDirection);
|
||||
Assert.Equal(clear.SurfaceToLightDirection, rain.SurfaceToLightDirection);
|
||||
Assert.Equal(AuthoredCelestialShadowSourceKind.DominantMoon, clear.SourceKind);
|
||||
Assert.Equal(clear.SourceKind, rain.SourceKind);
|
||||
Assert.Equal(source.ObjectIndex, rain.SourceObjectIndex);
|
||||
Assert.Equal(source.GfxObjId, rain.SourceGfxObjId);
|
||||
Assert.True(rain.Strength < clear.Strength);
|
||||
Assert.True(rain.SoftnessMultiplier > clear.SoftnessMultiplier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroDayGroupPolicy_DisablesThroughExplicitAtmosphereMapping()
|
||||
{
|
||||
DirectionalShadowEnvironmentInput input = new(
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
Celestial(AuthoredCelestialShadowSourceKind.Sun, 35f),
|
||||
Atmosphere(WeatherKind.Clear),
|
||||
ActiveDayGroupMultiplier: 0f);
|
||||
|
||||
DirectionalShadowEnvironmentState result =
|
||||
DirectionalShadowEnvironmentGate.Evaluate(
|
||||
in input,
|
||||
DirectionalShadowAtmospherePolicy.BuiltIn);
|
||||
|
||||
Assert.Equal(DirectionalShadowGateReason.AtmosphereSuppressed, result.Reason);
|
||||
Assert.False(result.ShouldRender);
|
||||
}
|
||||
|
||||
private static AuthoredCelestialShadowSource Celestial(
|
||||
AuthoredCelestialShadowSourceKind kind,
|
||||
float elevationDegrees,
|
||||
float energy = 1f)
|
||||
{
|
||||
float elevation = elevationDegrees * MathF.PI / 180f;
|
||||
const float heading = 120f * MathF.PI / 180f;
|
||||
float horizontal = MathF.Cos(elevation);
|
||||
Vector3 direction = new(
|
||||
horizontal * MathF.Cos(heading),
|
||||
horizontal * MathF.Sin(heading),
|
||||
MathF.Sin(elevation));
|
||||
uint gfxObjId = kind switch
|
||||
{
|
||||
AuthoredCelestialShadowSourceKind.Sun =>
|
||||
AuthoredCelestialShadowSourceResolver.SunGfxObjId,
|
||||
AuthoredCelestialShadowSourceKind.DominantMoon =>
|
||||
AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId,
|
||||
AuthoredCelestialShadowSourceKind.SecondaryMoon =>
|
||||
AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
||||
};
|
||||
return new AuthoredCelestialShadowSource(
|
||||
Kind: kind,
|
||||
ObjectIndex: 4,
|
||||
GfxObjId: gfxObjId,
|
||||
SurfaceToLightDirection: direction,
|
||||
ElevationSin: direction.Z,
|
||||
AuthoredEnergy: energy);
|
||||
}
|
||||
|
||||
private static AtmosphereSnapshot Atmosphere(WeatherKind weather) => new(
|
||||
weather,
|
||||
Intensity: 1f,
|
||||
FogColor: new Vector3(0.4f),
|
||||
FogStart: 80f,
|
||||
FogEnd: 350f,
|
||||
FogMode.Linear,
|
||||
LightningFlash: 0f,
|
||||
EnvironOverride.None);
|
||||
}
|
||||
795
tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs
Normal file
795
tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs
Normal file
|
|
@ -0,0 +1,795 @@
|
|||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using DatReaderWriter.Enums;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class DirectionalShadowGpuTests
|
||||
{
|
||||
[Fact]
|
||||
public void CompleteCasterClassDiagnostics_AddsExactTerrainCommandCount()
|
||||
{
|
||||
DirectionalShadowCasterBuildStats stats = default;
|
||||
stats = stats with
|
||||
{
|
||||
CasterClasses = new DirectionalShadowCasterClassDiagnostics(
|
||||
TerrainCommands: 0,
|
||||
OutdoorStatics: 2,
|
||||
Buildings: 3,
|
||||
AnimatedStatics: 4,
|
||||
LocalPlayers: 5,
|
||||
RemotePlayers: 6,
|
||||
NonPlayerCreatures: 7,
|
||||
OtherLiveDynamics: 8,
|
||||
EquippedChildren: 9),
|
||||
};
|
||||
|
||||
DirectionalShadowCasterClassDiagnostics completed =
|
||||
DirectionalSunShadowRenderer.CompleteCasterClassDiagnostics(
|
||||
in stats,
|
||||
terrainCommandCount: 11);
|
||||
|
||||
Assert.Equal(11, completed.TerrainCommands);
|
||||
Assert.Equal(stats.CasterClasses with { TerrainCommands = 11 }, completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Binding6HostLayout_MatchesCheckedInStd140Block()
|
||||
{
|
||||
Assert.Equal(336, DirectionalShadowUniforms.SizeInBytes);
|
||||
Assert.Equal(DirectionalShadowUniforms.SizeInBytes, Marshal.SizeOf<DirectionalShadowUniforms>());
|
||||
Assert.Equal(0, Offset(nameof(DirectionalShadowUniforms.WorldToClip0)));
|
||||
Assert.Equal(64, Offset(nameof(DirectionalShadowUniforms.WorldToClip1)));
|
||||
Assert.Equal(128, Offset(nameof(DirectionalShadowUniforms.WorldToClip2)));
|
||||
Assert.Equal(192, Offset(nameof(DirectionalShadowUniforms.WorldToClip3)));
|
||||
Assert.Equal(256, Offset(nameof(DirectionalShadowUniforms.SplitFarMeters)));
|
||||
Assert.Equal(272, Offset(nameof(DirectionalShadowUniforms.Control)));
|
||||
Assert.Equal(288, Offset(nameof(DirectionalShadowUniforms.BiasMeters)));
|
||||
Assert.Equal(304, Offset(nameof(DirectionalShadowUniforms.TextureAndFlags)));
|
||||
Assert.Equal(320, Offset(nameof(DirectionalShadowUniforms.LightDirectionAndSource)));
|
||||
Assert.Equal(16, Marshal.SizeOf<UInt4>());
|
||||
|
||||
string common = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot(),
|
||||
"src", "AcDream.App", "Rendering", "Shaders",
|
||||
"directional_shadow_common.glsl"));
|
||||
Assert.Contains("ACDREAM_PACK_UBO_SET binding = 6", common, StringComparison.Ordinal);
|
||||
Assert.Contains("mat4 uShadowWorldToClip[4]", common, StringComparison.Ordinal);
|
||||
Assert.Contains("uvec4 uShadowTextureAndFlags", common, StringComparison.Ordinal);
|
||||
Assert.Contains("vec4 uShadowLightDirectionAndSource", common, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConservativeReceiverBias_UsesFarthestCascadeAndShaderScalesInnerMaps()
|
||||
{
|
||||
DirectionalShadowQuality quality = DirectionalShadowQuality.For(
|
||||
DirectionalShadowPreset.Low);
|
||||
var nearBias = new DirectionalShadowWorldBias(0.01f, 0.02f, 0.03f);
|
||||
var farBias = new DirectionalShadowWorldBias(0.11f, 0.12f, 0.13f);
|
||||
DirectionalShadowCascade[] cascades =
|
||||
[
|
||||
Cascade(0, 20f, nearBias),
|
||||
Cascade(1, quality.MaximumReachMeters, farBias),
|
||||
];
|
||||
var environment = new DirectionalShadowEnvironmentState(
|
||||
DirectionalShadowGateReason.Enabled,
|
||||
Vector3.Normalize(new Vector3(0.3f, 0.4f, 0.8f)),
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
AuthoredCelestialShadowSourceKind.DominantMoon,
|
||||
SourceObjectIndex: 2,
|
||||
SourceGfxObjId: AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId);
|
||||
|
||||
DirectionalShadowUniforms uniforms = DirectionalShadowUniforms.Create(
|
||||
cascades,
|
||||
environment,
|
||||
quality,
|
||||
new GpuTextureSlot(7));
|
||||
|
||||
Assert.Equal(farBias.ConstantDepthMeters, uniforms.BiasMeters.X);
|
||||
Assert.Equal(farBias.SlopeDepthMeters, uniforms.BiasMeters.Y);
|
||||
Assert.Equal(farBias.NormalOffsetMeters, uniforms.BiasMeters.Z);
|
||||
|
||||
string receiver = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot(),
|
||||
"src", "AcDream.App", "Rendering", "Shaders",
|
||||
"directional_shadow_receiver.glsl"));
|
||||
Assert.Contains("farDensity / max(cascadeDensity, 1e-7)", receiver,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("uShadowBiasMeters.xyz * acdreamShadowBiasScale(cascade)", receiver,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Uniforms_CarrySelectedCelestialDirectionAndSourceKind()
|
||||
{
|
||||
DirectionalShadowQuality quality = DirectionalShadowQuality.For(
|
||||
DirectionalShadowPreset.Low);
|
||||
DirectionalShadowCascade[] cascades =
|
||||
[
|
||||
Cascade(0, 20f, new DirectionalShadowWorldBias(0.01f, 0.02f, 0.03f)),
|
||||
Cascade(1, quality.MaximumReachMeters,
|
||||
new DirectionalShadowWorldBias(0.11f, 0.12f, 0.13f)),
|
||||
];
|
||||
Vector3 direction = Vector3.Normalize(new Vector3(0.3f, 0.4f, 0.8f));
|
||||
var environment = new DirectionalShadowEnvironmentState(
|
||||
DirectionalShadowGateReason.Enabled,
|
||||
direction,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
AuthoredCelestialShadowSourceKind.DominantMoon,
|
||||
SourceObjectIndex: 2,
|
||||
SourceGfxObjId: AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId);
|
||||
|
||||
DirectionalShadowUniforms uniforms = DirectionalShadowUniforms.Create(
|
||||
cascades,
|
||||
environment,
|
||||
quality,
|
||||
new GpuTextureSlot(7));
|
||||
|
||||
Assert.Equal(
|
||||
direction,
|
||||
new Vector3(
|
||||
uniforms.LightDirectionAndSource.X,
|
||||
uniforms.LightDirectionAndSource.Y,
|
||||
uniforms.LightDirectionAndSource.Z));
|
||||
Assert.Equal(
|
||||
(float)AuthoredCelestialShadowSourceKind.DominantMoon,
|
||||
uniforms.LightDirectionAndSource.W);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UniformReachAndTerminalFadeUseResidentClampedFinalSplit()
|
||||
{
|
||||
DirectionalShadowQuality quality = DirectionalShadowQuality.For(
|
||||
DirectionalShadowPreset.Low);
|
||||
DirectionalShadowCascade[] cascades =
|
||||
[
|
||||
Cascade(0, 18f, new DirectionalShadowWorldBias(0.01f, 0.02f, 0.03f)),
|
||||
Cascade(1, 48f, new DirectionalShadowWorldBias(0.04f, 0.05f, 0.06f)),
|
||||
];
|
||||
|
||||
DirectionalShadowUniforms uniforms = DirectionalShadowUniforms.Create(
|
||||
cascades,
|
||||
EnabledEnvironment(),
|
||||
quality,
|
||||
new GpuTextureSlot(7));
|
||||
|
||||
Assert.Equal(48f, uniforms.Control.Z);
|
||||
Assert.Equal(1f, uniforms.Control.W);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiviewShadersSelectExactViewMatrixAndPreserveCutout()
|
||||
{
|
||||
string shaderRoot = Path.Combine(
|
||||
RepositoryRoot(),
|
||||
"src", "AcDream.App", "Rendering", "Shaders");
|
||||
string vertex = File.ReadAllText(Path.Combine(
|
||||
shaderRoot,
|
||||
"directional_shadow_world_cutout_multiview.vert"));
|
||||
string fragment = File.ReadAllText(Path.Combine(
|
||||
shaderRoot,
|
||||
"directional_shadow_world_cutout_multiview.frag"));
|
||||
Assert.Contains("GL_EXT_multiview", vertex, StringComparison.Ordinal);
|
||||
Assert.Contains("uShadowWorldToClip[int(gl_ViewIndex)]", vertex,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("Instances[instanceIndex].transform", vertex,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("texel.a < 0.05", fragment, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(5)]
|
||||
public void DirectionalDepthTarget_RejectsLayerCountsOutsideTwoThroughFour(int layers)
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
device.CreateDirectionalDepthTarget(
|
||||
new GpuDirectionalDepthTargetDescription("bad", 1024, layers)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectionalDepthTarget_ExposesOneSampleableArrayAndLayerPasses()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using IGpuDirectionalDepthTarget target = device.CreateDirectionalDepthTarget(
|
||||
new GpuDirectionalDepthTargetDescription("shadow", 1536, 3));
|
||||
|
||||
Assert.Equal(GpuTextureKind.Texture2DArray, target.DepthTexture.Kind);
|
||||
Assert.Equal(3, target.DepthTexture.LayerCount);
|
||||
Assert.Equal(1536, target.DepthTexture.Width);
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
using (frame.BeginPass(GpuPassDescription.DirectionalDepth("cascade-2", target, 2)))
|
||||
{
|
||||
}
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
frame.BeginPass(GpuPassDescription.DirectionalDepth("cascade-3", target, 3)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectionalDepthMultiview_RequiresExactFullMaskAndDeviceCapability()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using IGpuDirectionalDepthTarget target = device.CreateDirectionalDepthTarget(
|
||||
new GpuDirectionalDepthTargetDescription("shadow", 1024, 2));
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
using (frame.BeginPass(GpuPassDescription.DirectionalDepthMultiview(
|
||||
"both-cascades", target, 0b11)))
|
||||
{
|
||||
}
|
||||
Assert.Equal(0b11u, Assert.Single(device.OfKind<GpuRecordedPassBegin>()).ViewMask);
|
||||
Assert.Throws<NotSupportedException>(() => frame.BeginPass(
|
||||
GpuPassDescription.DirectionalDepthMultiview("partial", target, 0b01)));
|
||||
|
||||
using var unsupported = new RecordingGpuDevice
|
||||
{
|
||||
Capabilities = device.Capabilities with { SupportsMultiview = false },
|
||||
};
|
||||
using IGpuDirectionalDepthTarget unsupportedTarget = unsupported.CreateDirectionalDepthTarget(
|
||||
new GpuDirectionalDepthTargetDescription("shadow", 1024, 2));
|
||||
using IGpuFrame unsupportedFrame = unsupported.BeginFrame();
|
||||
Assert.Throws<NotSupportedException>(() => unsupportedFrame.BeginPass(
|
||||
GpuPassDescription.DirectionalDepthMultiview(
|
||||
"unsupported", unsupportedTarget, 0b11)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DirectionalShadowPreset.Low, 2, 768, true)]
|
||||
[InlineData(DirectionalShadowPreset.Low, 2, 768, false)]
|
||||
[InlineData(DirectionalShadowPreset.Medium, 3, 1536, false)]
|
||||
[InlineData(DirectionalShadowPreset.High, 4, 2048, false)]
|
||||
internal void Renderer_ReplaysOnePreparedProductAcrossEveryQualityCascade(
|
||||
DirectionalShadowPreset preset,
|
||||
int expectedCascades,
|
||||
int expectedResolution,
|
||||
bool multiviewCascades)
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
int baselineSlots = device.LiveTextureSlotCount;
|
||||
using var renderer = new DirectionalSunShadowRenderer(
|
||||
device,
|
||||
preset,
|
||||
multiviewCascades: multiviewCascades);
|
||||
Assert.Equal(baselineSlots + 1, device.LiveTextureSlotCount);
|
||||
RecordingGpuDirectionalDepthTarget target = Assert.Single(device.CreatedDirectionalDepthTargets);
|
||||
Assert.Equal(expectedCascades, target.Description.LayerCount);
|
||||
Assert.Equal(expectedResolution, target.Description.Resolution);
|
||||
Assert.All(device.CreatedPipelines, pipeline => Assert.False(pipeline.Description.HasColorAttachment));
|
||||
|
||||
DirectionalShadowPreparedDraws world = CreateWorldDraws(device.DefaultTextureSlot);
|
||||
DirectionalShadowTerrainPreparedDraws terrain = CreateTerrainDraws();
|
||||
using IGpuBuffer worldVertices = Buffer(device, "world-v", GpuBufferUsage.Vertex);
|
||||
using IGpuBuffer worldIndices = Buffer(device, "world-i", GpuBufferUsage.Index);
|
||||
using IGpuBuffer terrainVertices = Buffer(device, "terrain-v", GpuBufferUsage.Vertex);
|
||||
using IGpuBuffer terrainIndices = Buffer(device, "terrain-i", GpuBufferUsage.Index);
|
||||
var worldGeometry = new DirectionalShadowMeshGeometry(worldVertices, worldIndices);
|
||||
var terrainGeometry = new DirectionalShadowTerrainGeometry(terrainVertices, terrainIndices);
|
||||
var environment = new DirectionalShadowEnvironmentState(
|
||||
DirectionalShadowGateReason.Enabled,
|
||||
Vector3.Normalize(new Vector3(0.2f, 0.3f, 1f)),
|
||||
0.94f,
|
||||
0.8f,
|
||||
1.25f,
|
||||
AuthoredCelestialShadowSourceKind.SecondaryMoon,
|
||||
SourceObjectIndex: 7,
|
||||
SourceGfxObjId: AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId);
|
||||
|
||||
device.Clear();
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
WorldTransformFrameSlice sharedTransforms = PublishSharedTransforms(
|
||||
frame,
|
||||
world.Transforms);
|
||||
DirectionalSunShadowDiagnostics diagnostics = renderer.RenderPrepared(
|
||||
frame,
|
||||
environment,
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 0.1f, 500f),
|
||||
cameraNearMeters: 0.1f,
|
||||
casterDepthPaddingMeters: 48f,
|
||||
world,
|
||||
terrain,
|
||||
worldGeometry,
|
||||
terrainGeometry,
|
||||
sharedTransforms);
|
||||
|
||||
int expectedDraws = (multiviewCascades ? 1 : expectedCascades) * 3;
|
||||
Assert.Equal(expectedCascades, diagnostics.CascadeCount);
|
||||
Assert.Equal(expectedDraws, diagnostics.DrawCalls);
|
||||
Assert.Equal(environment.Strength, diagnostics.Strength);
|
||||
Assert.Equal(1, diagnostics.WorldOpaqueCommands);
|
||||
Assert.Equal(1, diagnostics.WorldAlphaCutoutCommands);
|
||||
Assert.Equal(1, diagnostics.TerrainCommands);
|
||||
Assert.Equal(1ul, diagnostics.WorldPreparationSequence);
|
||||
Assert.Equal(1ul, diagnostics.TerrainPreparationSequence);
|
||||
int expectedPasses = multiviewCascades ? 1 : expectedCascades;
|
||||
Assert.Equal(expectedPasses, device.OfKind<GpuRecordedPassBegin>().Count());
|
||||
Assert.Equal(expectedPasses, device.OfKind<GpuRecordedTimerScope>().Count());
|
||||
Assert.Equal(expectedPasses, device.OfKind<GpuRecordedUniformBind>()
|
||||
.Count(call => call.Binding == GpuBindingModel.UniformDirectionalShadow));
|
||||
Assert.Equal(expectedDraws, device.OfKind<GpuRecordedMultiDrawIndirect>().Count());
|
||||
Assert.Equal(2, device.OfKind<GpuRecordedRingAllocation>().Count());
|
||||
GpuRecordedRingAllocation transformAllocation = Assert.Single(
|
||||
device.OfKind<GpuRecordedRingAllocation>(),
|
||||
call => call.Usage == GpuRingUsage.Storage
|
||||
&& call.ByteCount == WorldTransformCapacityPolicy.InitialBindingSizeBytes);
|
||||
RecordingGpuBuffer batchBuffer = Assert.Single(
|
||||
device.CreatedBuffers,
|
||||
buffer => buffer.Name == "directional-shadow-world-batches-1"
|
||||
&& buffer.Usage.HasFlag(GpuBufferUsage.Storage)
|
||||
&& buffer.Residency == GpuMemoryResidency.DeviceLocal);
|
||||
Span<byte> batchBytes = stackalloc byte[32];
|
||||
batchBuffer.Read(0, batchBytes);
|
||||
ReadOnlySpan<uint> batchWords = MemoryMarshal.Cast<byte, uint>(batchBytes);
|
||||
Assert.Equal(
|
||||
0u,
|
||||
batchWords[3]);
|
||||
Assert.Equal(
|
||||
DirectionalShadowBatchFlags.AlphaCutout,
|
||||
batchWords[7]);
|
||||
Assert.Contains(
|
||||
device.CreatedBuffers,
|
||||
buffer => buffer.Name == "directional-shadow-world-commands-1"
|
||||
&& buffer.Usage.HasFlag(GpuBufferUsage.Indirect)
|
||||
&& buffer.Residency == GpuMemoryResidency.DeviceLocal);
|
||||
Assert.Contains(
|
||||
device.CreatedBuffers,
|
||||
buffer => buffer.Name == "directional-shadow-terrain-commands-1"
|
||||
&& buffer.Usage.HasFlag(GpuBufferUsage.Indirect)
|
||||
&& buffer.Residency == GpuMemoryResidency.DeviceLocal);
|
||||
Assert.DoesNotContain(
|
||||
device.OfKind<GpuRecordedRingAllocation>(),
|
||||
call => call.Usage == GpuRingUsage.Storage
|
||||
&& call.ByteCount == world.Transforms.Length * Marshal.SizeOf<Matrix4x4>());
|
||||
Assert.All(
|
||||
device.OfKind<GpuRecordedStorageBind>()
|
||||
.Where(call => call.Binding == GpuBindingModel.StorageInstances),
|
||||
call =>
|
||||
{
|
||||
Assert.Equal(transformAllocation.OffsetBytes, call.OffsetBytes);
|
||||
Assert.Equal(WorldTransformCapacityPolicy.InitialBindingSizeBytes, call.SizeBytes);
|
||||
});
|
||||
for (int cascade = 0; cascade < (multiviewCascades ? 1 : expectedCascades); cascade++)
|
||||
{
|
||||
Assert.Contains(
|
||||
device.OfKind<GpuRecordedPushConstants>(),
|
||||
call => call.Constants.RenderPass == cascade);
|
||||
}
|
||||
if (multiviewCascades)
|
||||
{
|
||||
GpuRecordedPassBegin pass = Assert.Single(device.OfKind<GpuRecordedPassBegin>());
|
||||
Assert.Equal(0b11u, pass.ViewMask);
|
||||
Assert.Equal(
|
||||
1,
|
||||
device.OfKind<GpuRecordedPipelineBind>().Count(call =>
|
||||
call.PipelineName == "directional-shadow-world-cutout-multiview"));
|
||||
Assert.Contains(device.OfKind<GpuRecordedPipelineBind>(), call =>
|
||||
call.PipelineName == "directional-shadow-world-opaque-multiview");
|
||||
Assert.Contains(device.OfKind<GpuRecordedPipelineBind>(), call =>
|
||||
call.PipelineName == "directional-shadow-terrain-multiview");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StableTopology_ReusesRetainedCommandBuffersWithoutFrameRingCopies()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using var renderer = new DirectionalSunShadowRenderer(
|
||||
device,
|
||||
DirectionalShadowPreset.Low,
|
||||
multiviewCascades: true);
|
||||
DirectionalShadowPreparedDraws world = CreateWorldDraws(
|
||||
device.DefaultTextureSlot);
|
||||
DirectionalShadowTerrainPreparedDraws terrain = CreateTerrainDraws();
|
||||
using IGpuBuffer worldVertices = Buffer(device, "world-v", GpuBufferUsage.Vertex);
|
||||
using IGpuBuffer worldIndices = Buffer(device, "world-i", GpuBufferUsage.Index);
|
||||
using IGpuBuffer terrainVertices = Buffer(device, "terrain-v", GpuBufferUsage.Vertex);
|
||||
using IGpuBuffer terrainIndices = Buffer(device, "terrain-i", GpuBufferUsage.Index);
|
||||
var worldGeometry = new DirectionalShadowMeshGeometry(
|
||||
worldVertices,
|
||||
worldIndices);
|
||||
var terrainGeometry = new DirectionalShadowTerrainGeometry(
|
||||
terrainVertices,
|
||||
terrainIndices);
|
||||
DirectionalShadowEnvironmentState environment = EnabledEnvironment();
|
||||
|
||||
using (IGpuFrame frame = device.BeginFrame())
|
||||
{
|
||||
renderer.RenderPrepared(
|
||||
frame,
|
||||
environment,
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 500f),
|
||||
0.1f,
|
||||
48f,
|
||||
world,
|
||||
terrain,
|
||||
worldGeometry,
|
||||
terrainGeometry,
|
||||
PublishSharedTransforms(frame, world.Transforms));
|
||||
}
|
||||
|
||||
RecordingGpuBuffer[] retained = device.CreatedBuffers
|
||||
.Where(buffer => buffer.Name.StartsWith(
|
||||
"directional-shadow-",
|
||||
StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
Assert.Equal(3, retained.Length);
|
||||
Assert.Equal(3, renderer.RetainedCommandBufferCount);
|
||||
Assert.Equal(retained.Sum(buffer => buffer.SizeBytes),
|
||||
renderer.RetainedCommandBufferBytes);
|
||||
|
||||
device.Clear();
|
||||
int createdBefore = device.CreatedBuffers.Count;
|
||||
using (IGpuFrame frame = device.BeginFrame())
|
||||
{
|
||||
renderer.RenderPrepared(
|
||||
frame,
|
||||
environment,
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 500f),
|
||||
0.1f,
|
||||
48f,
|
||||
world,
|
||||
terrain,
|
||||
worldGeometry,
|
||||
terrainGeometry,
|
||||
PublishSharedTransforms(frame, world.Transforms));
|
||||
}
|
||||
|
||||
Assert.Equal(createdBefore, device.CreatedBuffers.Count);
|
||||
Assert.All(retained, buffer => Assert.False(buffer.IsDisposed));
|
||||
Assert.Equal(2, device.OfKind<GpuRecordedRingAllocation>().Count());
|
||||
Assert.DoesNotContain(
|
||||
device.OfKind<GpuRecordedRingAllocation>(),
|
||||
allocation => allocation.Usage == GpuRingUsage.Indirect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopologyRebuild_SwapsRetainedBuffersAndDisposalReleasesTheCurrentSet()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
var renderer = new DirectionalSunShadowRenderer(
|
||||
device,
|
||||
DirectionalShadowPreset.Low);
|
||||
DirectionalShadowPreparedDraws world = CreateWorldDraws(
|
||||
device.DefaultTextureSlot);
|
||||
DirectionalShadowTerrainPreparedDraws terrain = CreateTerrainDraws();
|
||||
using IGpuBuffer worldVertices = Buffer(device, "world-v", GpuBufferUsage.Vertex);
|
||||
using IGpuBuffer worldIndices = Buffer(device, "world-i", GpuBufferUsage.Index);
|
||||
using IGpuBuffer terrainVertices = Buffer(device, "terrain-v", GpuBufferUsage.Vertex);
|
||||
using IGpuBuffer terrainIndices = Buffer(device, "terrain-i", GpuBufferUsage.Index);
|
||||
var worldGeometry = new DirectionalShadowMeshGeometry(
|
||||
worldVertices,
|
||||
worldIndices);
|
||||
var terrainGeometry = new DirectionalShadowTerrainGeometry(
|
||||
terrainVertices,
|
||||
terrainIndices);
|
||||
DirectionalShadowEnvironmentState environment = EnabledEnvironment();
|
||||
|
||||
using (IGpuFrame frame = device.BeginFrame())
|
||||
{
|
||||
renderer.RenderPrepared(
|
||||
frame,
|
||||
environment,
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 500f),
|
||||
0.1f,
|
||||
48f,
|
||||
world,
|
||||
terrain,
|
||||
worldGeometry,
|
||||
terrainGeometry,
|
||||
PublishSharedTransforms(frame, world.Transforms));
|
||||
}
|
||||
RecordingGpuBuffer[] firstSet = device.CreatedBuffers
|
||||
.Where(buffer => buffer.Name.StartsWith(
|
||||
"directional-shadow-",
|
||||
StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
|
||||
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(3);
|
||||
Assert.True(world.TryBegin(generation, 8, 1));
|
||||
Matrix4x4 moved = Matrix4x4.CreateTranslation(20f, 30f, 40f);
|
||||
world.Add(
|
||||
30,
|
||||
2,
|
||||
9,
|
||||
GpuTextureSlot.Unassigned,
|
||||
0,
|
||||
CullMode.Clockwise,
|
||||
DirectionalShadowCasterMaterial.Opaque,
|
||||
in moved);
|
||||
DirectionalShadowPreparationStats stats = default;
|
||||
world.Complete(generation, 8, in stats);
|
||||
|
||||
Assert.True(terrain.TryBegin(2, 1));
|
||||
var terrainRange = new DirectionalShadowTerrainRange(80, 90);
|
||||
terrain.Add(in terrainRange);
|
||||
terrain.Complete(2);
|
||||
using (IGpuFrame frame = device.BeginFrame())
|
||||
{
|
||||
renderer.RenderPrepared(
|
||||
frame,
|
||||
environment,
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 500f),
|
||||
0.1f,
|
||||
48f,
|
||||
world,
|
||||
terrain,
|
||||
worldGeometry,
|
||||
terrainGeometry,
|
||||
PublishSharedTransforms(frame, world.Transforms));
|
||||
}
|
||||
|
||||
Assert.All(firstSet, buffer => Assert.True(buffer.IsDisposed));
|
||||
RecordingGpuBuffer[] currentSet = device.CreatedBuffers
|
||||
.Where(buffer => buffer.Name.EndsWith("-2", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
Assert.Equal(3, currentSet.Length);
|
||||
Assert.All(currentSet, buffer => Assert.False(buffer.IsDisposed));
|
||||
|
||||
renderer.Dispose();
|
||||
|
||||
Assert.All(currentSet, buffer => Assert.True(buffer.IsDisposed));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DirectionalShadowGateReason.Indoor)]
|
||||
[InlineData(DirectionalShadowGateReason.SelectedLightBelowHorizon)]
|
||||
[InlineData(DirectionalShadowGateReason.SelectedLightHasNoEnergy)]
|
||||
[InlineData(DirectionalShadowGateReason.NoVisibleCelestial)]
|
||||
[InlineData(DirectionalShadowGateReason.PackDisabled)]
|
||||
internal void DisabledEnvironment_RecordsNoPassOrUpload(DirectionalShadowGateReason reason)
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
|
||||
device.Clear();
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
|
||||
DirectionalSunShadowDiagnostics diagnostics = renderer.RenderPrepared(
|
||||
frame,
|
||||
new DirectionalShadowEnvironmentState(reason, Vector3.UnitZ, 0f, 0f, 1f),
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.Identity,
|
||||
0.1f,
|
||||
48f,
|
||||
new DirectionalShadowPreparedDraws(),
|
||||
new DirectionalShadowTerrainPreparedDraws(),
|
||||
null,
|
||||
null,
|
||||
default);
|
||||
|
||||
Assert.Equal(reason, diagnostics.GateReason);
|
||||
Assert.Empty(device.OfKind<GpuRecordedPassBegin>());
|
||||
Assert.Empty(device.OfKind<GpuRecordedRingAllocation>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disposal_ReleasesTextureSlotAndEveryOwnedResource()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
int baselineSlots = device.LiveTextureSlotCount;
|
||||
var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.High);
|
||||
RecordingGpuDirectionalDepthTarget target = Assert.Single(device.CreatedDirectionalDepthTargets);
|
||||
RecordingGpuPipeline[] pipelines = device.CreatedPipelines.ToArray();
|
||||
RecordingGpuSampler sampler = device.CreatedSamplers[^1];
|
||||
|
||||
Assert.Equal(GpuSamplerDescription.ShadowNearestClamp, sampler.Description);
|
||||
Assert.Equal(GpuFilter.Nearest, sampler.Description.MinFilter);
|
||||
Assert.Equal(GpuFilter.Nearest, sampler.Description.MagFilter);
|
||||
Assert.Equal(GpuAddressMode.ClampToEdge, sampler.Description.AddressU);
|
||||
Assert.Equal(GpuAddressMode.ClampToEdge, sampler.Description.AddressV);
|
||||
|
||||
renderer.Dispose();
|
||||
|
||||
Assert.Equal(baselineSlots, device.LiveTextureSlotCount);
|
||||
Assert.True(target.IsDisposed);
|
||||
Assert.True(sampler.IsDisposed);
|
||||
Assert.All(pipelines, pipeline => Assert.True(pipeline.IsDisposed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructionFailure_RollsBackTargetSlotSamplerAndEarlierPipelines()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
int baselineSlots = device.LiveTextureSlotCount;
|
||||
device.PipelineFailure = description =>
|
||||
description.Name == "directional-shadow-world-opaque"
|
||||
? new InvalidOperationException("injected pipeline failure")
|
||||
: null;
|
||||
|
||||
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(() =>
|
||||
new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Medium));
|
||||
|
||||
Assert.Equal("injected pipeline failure", failure.Message);
|
||||
Assert.Equal(baselineSlots, device.LiveTextureSlotCount);
|
||||
Assert.True(Assert.Single(device.CreatedDirectionalDepthTargets).IsDisposed);
|
||||
Assert.True(device.CreatedSamplers[^1].IsDisposed);
|
||||
Assert.True(Assert.Single(device.CreatedPipelines).IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiviewConstructionFailure_RetiresAllOrdinaryAndLayeredCandidates()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
int baselineSlots = device.LiveTextureSlotCount;
|
||||
device.PipelineFailure = description =>
|
||||
description.Name == "directional-shadow-world-cutout-multiview"
|
||||
? new InvalidOperationException("injected multiview failure")
|
||||
: null;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
new DirectionalSunShadowRenderer(
|
||||
device,
|
||||
DirectionalShadowPreset.Low,
|
||||
multiviewCascades: true));
|
||||
|
||||
Assert.Equal(baselineSlots, device.LiveTextureSlotCount);
|
||||
Assert.True(Assert.Single(device.CreatedDirectionalDepthTargets).IsDisposed);
|
||||
Assert.True(device.CreatedSamplers[^1].IsDisposed);
|
||||
Assert.Equal(5, device.CreatedPipelines.Count);
|
||||
Assert.All(device.CreatedPipelines, pipeline => Assert.True(pipeline.IsDisposed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebuildAfterDisposal_UsesANewLiveShadowSampler()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
var first = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
|
||||
RecordingGpuSampler firstSampler = device.CreatedSamplers[^1];
|
||||
first.Dispose();
|
||||
|
||||
using var second = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
|
||||
RecordingGpuSampler secondSampler = device.CreatedSamplers[^1];
|
||||
|
||||
Assert.NotSame(firstSampler, secondSampler);
|
||||
Assert.True(firstSampler.IsDisposed);
|
||||
Assert.False(secondSampler.IsDisposed);
|
||||
Assert.Equal(GpuSamplerDescription.ShadowNearestClamp, secondSampler.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiverBinding_IsValidOnlyForTheProducingFrame()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using var renderer = new DirectionalSunShadowRenderer(
|
||||
device,
|
||||
DirectionalShadowPreset.Low);
|
||||
using (IGpuFrame frame = device.BeginFrame())
|
||||
{
|
||||
WorldTransformFrameSlice sharedTransforms = PublishSharedTransforms(
|
||||
frame,
|
||||
ReadOnlySpan<Matrix4x4>.Empty);
|
||||
renderer.RenderPrepared(
|
||||
frame,
|
||||
new DirectionalShadowEnvironmentState(
|
||||
DirectionalShadowGateReason.Enabled,
|
||||
Vector3.UnitZ,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
AuthoredCelestialShadowSourceKind.Sun,
|
||||
SourceObjectIndex: 0,
|
||||
SourceGfxObjId: AuthoredCelestialShadowSourceResolver.SunGfxObjId),
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 100f),
|
||||
0.1f,
|
||||
48f,
|
||||
new DirectionalShadowPreparedDraws(),
|
||||
new DirectionalShadowTerrainPreparedDraws(),
|
||||
null,
|
||||
null,
|
||||
sharedTransforms);
|
||||
|
||||
Assert.True(renderer.TryGetCurrentFrameBinding(frame, out var binding));
|
||||
Assert.Equal(frame.Serial, binding.FrameSerial);
|
||||
Assert.Equal((uint)DirectionalShadowUniforms.SizeInBytes, binding.SizeBytes);
|
||||
Assert.Equal(2, binding.CascadeCount);
|
||||
}
|
||||
|
||||
using IGpuFrame later = device.BeginFrame();
|
||||
Assert.False(renderer.TryGetCurrentFrameBinding(later, out _));
|
||||
}
|
||||
|
||||
private static DirectionalShadowPreparedDraws CreateWorldDraws(GpuTextureSlot cutoutSlot)
|
||||
{
|
||||
var draws = new DirectionalShadowPreparedDraws();
|
||||
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(3);
|
||||
Assert.True(draws.TryBegin(generation, 7, 2));
|
||||
Matrix4x4 opaque = Matrix4x4.CreateTranslation(1f, 2f, 3f);
|
||||
Matrix4x4 cutout = Matrix4x4.CreateRotationZ(0.3f) * Matrix4x4.CreateTranslation(4f, 5f, 6f);
|
||||
draws.Add(0, 0, 6, GpuTextureSlot.Unassigned, 0, CullMode.CounterClockwise,
|
||||
DirectionalShadowCasterMaterial.Opaque, in opaque);
|
||||
draws.Add(6, 4, 12, cutoutSlot, 2, CullMode.None,
|
||||
DirectionalShadowCasterMaterial.AlphaCutout, in cutout);
|
||||
DirectionalShadowPreparationStats stats = default;
|
||||
draws.Complete(generation, 7, in stats);
|
||||
return draws;
|
||||
}
|
||||
|
||||
private static DirectionalShadowCascade Cascade(
|
||||
int index,
|
||||
float splitFarMeters,
|
||||
DirectionalShadowWorldBias bias) =>
|
||||
new(
|
||||
index,
|
||||
index == 0 ? 0.1f : 20f,
|
||||
splitFarMeters,
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.Identity,
|
||||
Vector2.Zero,
|
||||
10f,
|
||||
0.1f,
|
||||
48f,
|
||||
bias);
|
||||
|
||||
private static DirectionalShadowEnvironmentState EnabledEnvironment() =>
|
||||
new(
|
||||
DirectionalShadowGateReason.Enabled,
|
||||
Vector3.Normalize(new Vector3(0.2f, 0.3f, 1f)),
|
||||
0.94f,
|
||||
0.8f,
|
||||
1.25f,
|
||||
AuthoredCelestialShadowSourceKind.Sun,
|
||||
SourceObjectIndex: 0,
|
||||
SourceGfxObjId: AuthoredCelestialShadowSourceResolver.SunGfxObjId);
|
||||
|
||||
private static DirectionalShadowTerrainPreparedDraws CreateTerrainDraws()
|
||||
{
|
||||
var draws = new DirectionalShadowTerrainPreparedDraws();
|
||||
Assert.True(draws.TryBegin(1, 1));
|
||||
var range = new DirectionalShadowTerrainRange(20, 60);
|
||||
draws.Add(in range);
|
||||
draws.Complete(1);
|
||||
return draws;
|
||||
}
|
||||
|
||||
private static IGpuBuffer Buffer(RecordingGpuDevice device, string name, GpuBufferUsage usage) =>
|
||||
device.CreateBuffer(new GpuBufferDescription(
|
||||
name,
|
||||
4096,
|
||||
usage | GpuBufferUsage.TransferDestination,
|
||||
GpuMemoryResidency.DeviceLocal));
|
||||
|
||||
private static WorldTransformFrameSlice PublishSharedTransforms(
|
||||
IGpuFrame frame,
|
||||
ReadOnlySpan<Matrix4x4> transforms)
|
||||
{
|
||||
GpuRingAllocation allocation = frame.AllocateRing(
|
||||
checked((int)WorldTransformCapacityPolicy.InitialBindingSizeBytes),
|
||||
GpuRingUsage.Storage);
|
||||
if (!transforms.IsEmpty)
|
||||
MemoryMarshal.AsBytes(transforms).CopyTo(allocation.Data);
|
||||
return new WorldTransformFrameSlice(
|
||||
frame.Serial,
|
||||
allocation.Buffer,
|
||||
allocation.OffsetBytes,
|
||||
WorldTransformCapacityPolicy.InitialBindingSizeBytes,
|
||||
FirstInstance: 0,
|
||||
checked((uint)transforms.Length));
|
||||
}
|
||||
|
||||
private static int Offset(string field) =>
|
||||
checked((int)Marshal.OffsetOf<DirectionalShadowUniforms>(field));
|
||||
|
||||
private static string RepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
directory = directory.Parent;
|
||||
return directory?.FullName
|
||||
?? throw new InvalidOperationException("Could not locate repository root.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class DirectionalShadowQualityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(DirectionalShadowPreset.Low, 2, 768, 72, 4_718_592L)]
|
||||
[InlineData(DirectionalShadowPreset.Medium, 3, 1536, 144, 28_311_552L)]
|
||||
[InlineData(DirectionalShadowPreset.High, 4, 2048, 240, 67_108_864L)]
|
||||
internal void Presets_PreserveHeadlineSemanticsAndPlanningEnvelope(
|
||||
DirectionalShadowPreset preset,
|
||||
int cascades,
|
||||
int resolution,
|
||||
float reach,
|
||||
long expectedDepthBytes)
|
||||
{
|
||||
DirectionalShadowQuality quality = DirectionalShadowQuality.For(preset);
|
||||
|
||||
Assert.Equal(cascades, quality.CascadeCount);
|
||||
Assert.Equal(resolution, quality.MapResolution);
|
||||
Assert.Equal(reach, quality.MaximumReachMeters);
|
||||
Assert.Equal(expectedDepthBytes, quality.ApproximateDepthMapBytes);
|
||||
Assert.Equal(
|
||||
DirectionalShadowSemantics.Headline,
|
||||
quality.Semantics & DirectionalShadowSemantics.Headline);
|
||||
Assert.True(quality.PackResidentGpuByteBudget >= quality.ApproximateDepthMapBytes);
|
||||
Assert.True(quality.IncrementalGpuP50BudgetMilliseconds > 0);
|
||||
Assert.True(quality.IncrementalCpuP50BudgetMilliseconds > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BiasPolicy_ProducesFiniteWorldUnitOffsetsThatScaleWithTexelFootprint()
|
||||
{
|
||||
DirectionalShadowBiasPolicy policy =
|
||||
DirectionalShadowQuality.For(DirectionalShadowPreset.Medium).BiasPolicy;
|
||||
|
||||
DirectionalShadowWorldBias near = policy.Resolve(0.02f);
|
||||
DirectionalShadowWorldBias far = policy.Resolve(0.20f);
|
||||
|
||||
Assert.InRange(near.ConstantDepthMeters, policy.MinimumMeters, policy.MaximumMeters);
|
||||
Assert.InRange(near.SlopeDepthMeters, policy.MinimumMeters, policy.MaximumMeters);
|
||||
Assert.InRange(near.NormalOffsetMeters, policy.MinimumMeters, policy.MaximumMeters);
|
||||
Assert.True(far.ConstantDepthMeters > near.ConstantDepthMeters);
|
||||
Assert.True(far.SlopeDepthMeters > near.SlopeDepthMeters);
|
||||
Assert.True(far.NormalOffsetMeters > near.NormalOffsetMeters);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DirectionalShadowPreset.Low)]
|
||||
[InlineData(DirectionalShadowPreset.Medium)]
|
||||
[InlineData(DirectionalShadowPreset.High)]
|
||||
internal void Presets_KeepShaderPinnedMinimumWorldBias(
|
||||
DirectionalShadowPreset preset) =>
|
||||
Assert.Equal(
|
||||
0.001f,
|
||||
DirectionalShadowQuality.For(preset).BiasPolicy.MinimumMeters);
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class DirectionalShadowReceiverTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("atmospheric-world-hdr", false, true, false)]
|
||||
[InlineData("atmospheric-world-hdr", true, false, false)]
|
||||
[InlineData("vk-world", true, true, false)]
|
||||
[InlineData("atmospheric-world-hdr", true, true, true)]
|
||||
internal void RetailPipelineRemainsExactUnlessPackAndCurrentBindingAreActive(
|
||||
string pass,
|
||||
bool source,
|
||||
bool binding,
|
||||
bool expected) =>
|
||||
Assert.Equal(
|
||||
expected,
|
||||
DirectionalShadowReceiverPolicy.ShouldSelectReceiverPipeline(
|
||||
pass,
|
||||
source,
|
||||
binding));
|
||||
|
||||
[Fact]
|
||||
public void CascadeTransition_IsContinuousAcrossTheSplitInWorldMetres()
|
||||
{
|
||||
Vector4 splits = new(10f, 30f, 60f, 100f);
|
||||
|
||||
DirectionalShadowCascadeBlend atSplit =
|
||||
DirectionalShadowReceiverPolicy.SelectCascade(10f, splits, 4, 2f);
|
||||
DirectionalShadowCascadeBlend afterSplit =
|
||||
DirectionalShadowReceiverPolicy.SelectCascade(10.0001f, splits, 4, 2f);
|
||||
|
||||
Assert.Equal(0, atSplit.PrimaryCascade);
|
||||
Assert.Equal(1, atSplit.SecondaryCascade);
|
||||
Assert.Equal(1f, atSplit.SecondaryWeight, 5);
|
||||
Assert.Equal(1, afterSplit.PrimaryCascade);
|
||||
Assert.Equal(2, afterSplit.SecondaryCascade);
|
||||
Assert.InRange(afterSplit.SecondaryWeight, 0f, 0.00001f);
|
||||
Assert.True(atSplit.WithinShadowReach);
|
||||
Assert.True(afterSplit.WithinShadowReach);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CascadeSelection_StopsAtConfiguredReach()
|
||||
{
|
||||
DirectionalShadowCascadeBlend outside =
|
||||
DirectionalShadowReceiverPolicy.SelectCascade(
|
||||
100.01f,
|
||||
new Vector4(10f, 30f, 60f, 100f),
|
||||
4,
|
||||
2f);
|
||||
|
||||
Assert.False(outside.WithinShadowReach);
|
||||
Assert.Equal(3, outside.PrimaryCascade);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BiasRemainsWorldMetresAndScalesWithSurfaceSlope()
|
||||
{
|
||||
var bias = new DirectionalShadowWorldBias(0.01f, 0.04f, 0.02f);
|
||||
|
||||
Assert.Equal(0.01f, DirectionalShadowReceiverPolicy.ReceiverBiasMeters(bias, 1f), 6);
|
||||
Assert.Equal(0.05f, DirectionalShadowReceiverPolicy.ReceiverBiasMeters(bias, 0f), 6);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, false, true, true)]
|
||||
[InlineData(true, true, true, false)]
|
||||
[InlineData(true, false, false, false)]
|
||||
[InlineData(false, false, true, false)]
|
||||
internal void IndoorAndMissingDirectionalTermsNeverSample(
|
||||
bool binding,
|
||||
bool indoor,
|
||||
bool directional,
|
||||
bool expected) =>
|
||||
Assert.Equal(
|
||||
expected,
|
||||
DirectionalShadowReceiverPolicy.ShouldSample(
|
||||
binding,
|
||||
indoor,
|
||||
directional));
|
||||
|
||||
[Fact]
|
||||
public void ReceiverShadersPreserveAnimatedFoliageMaterialAndTransparencySemantics()
|
||||
{
|
||||
string root = RepositoryRoot();
|
||||
string shaderRoot = Path.Combine(
|
||||
root,
|
||||
"src", "AcDream.App", "Rendering", "Shaders");
|
||||
string vertex = File.ReadAllText(Path.Combine(shaderRoot, "mesh_atmospheric.vert"));
|
||||
string fragment = File.ReadAllText(Path.Combine(shaderRoot, "mesh_atmospheric.frag"));
|
||||
string terrain = File.ReadAllText(Path.Combine(shaderRoot, "terrain_atmospheric.frag"));
|
||||
string receiver = File.ReadAllText(Path.Combine(
|
||||
shaderRoot,
|
||||
"directional_shadow_receiver.glsl"));
|
||||
|
||||
Assert.Contains("int transformIndex = gl_BaseInstanceARB + gl_InstanceID", vertex, StringComparison.Ordinal);
|
||||
Assert.Contains("int instanceIndex = transformIndex - int(uTextureIndexB)", vertex, StringComparison.Ordinal);
|
||||
Assert.Contains("Instances[transformIndex].transform", vertex, StringComparison.Ordinal);
|
||||
Assert.Contains("instanceIndoor[instanceIndex]", vertex, StringComparison.Ordinal);
|
||||
Assert.Contains("vSelectionLighting", fragment, StringComparison.Ordinal);
|
||||
Assert.Contains("vOpacityMultiplier", fragment, StringComparison.Ordinal);
|
||||
Assert.Contains("if (color.a < 0.05) discard", fragment, StringComparison.Ordinal);
|
||||
Assert.Contains("ACDREAM_SAMPLE_ARRAY", fragment, StringComparison.Ordinal);
|
||||
Assert.Contains("combineOverlays", terrain, StringComparison.Ordinal);
|
||||
Assert.Contains("combineRoad", terrain, StringComparison.Ordinal);
|
||||
Assert.Contains("smoothstep", receiver, StringComparison.Ordinal);
|
||||
Assert.Contains("acdreamShadowBiasScale(cascade)", receiver, StringComparison.Ordinal);
|
||||
Assert.Contains("textureGather", receiver, StringComparison.Ordinal);
|
||||
Assert.Contains("acdreamShadowBilinearCompare", receiver, StringComparison.Ordinal);
|
||||
Assert.Contains("float weight = float(2 - abs(x))", receiver, StringComparison.Ordinal);
|
||||
Assert.Contains("float reachFade = 1.0 - smoothstep", receiver, StringComparison.Ordinal);
|
||||
Assert.Contains("radius = clamp(radius, 0, 2)", receiver, StringComparison.Ordinal);
|
||||
Assert.Contains("float softness = max(uShadowControl.y, 1.0)", receiver, StringComparison.Ordinal);
|
||||
|
||||
string detailVertex = File.ReadAllText(Path.Combine(
|
||||
shaderRoot,
|
||||
"mesh_detail.vert"));
|
||||
Assert.Contains("int instanceIndex = transformIndex - int(uTextureIndexB)", detailVertex, StringComparison.Ordinal);
|
||||
Assert.Contains("Instances[transformIndex].transform", detailVertex, StringComparison.Ordinal);
|
||||
Assert.Contains("instanceDetailCategory[instanceIndex]", detailVertex, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string RepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null
|
||||
&& !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
{
|
||||
directory = directory.Parent;
|
||||
}
|
||||
return directory?.FullName
|
||||
?? throw new InvalidOperationException("Could not locate repository root.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class DirectionalShadowTransformBufferSetTests
|
||||
{
|
||||
[Fact]
|
||||
public void FlightSlotsRetainStaticPrefix_AndWarmedReuseAllocatesAndWritesNothing()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using var buffers = new DirectionalShadowTransformBufferSet(device);
|
||||
Matrix4x4[] transforms =
|
||||
[
|
||||
Matrix4x4.CreateTranslation(1f, 2f, 3f),
|
||||
Matrix4x4.CreateRotationZ(0.25f),
|
||||
];
|
||||
|
||||
RecordingGpuBuffer slotZero;
|
||||
using (IGpuFrame first = device.BeginFrame())
|
||||
{
|
||||
WorldTransformFrameSlice slice = buffers.Publish(
|
||||
first,
|
||||
topologyBuildSequence: 7,
|
||||
transforms,
|
||||
ReadOnlySpan<int>.Empty);
|
||||
slotZero = Assert.IsType<RecordingGpuBuffer>(slice.Buffer);
|
||||
Assert.Equal(0, first.SlotIndex);
|
||||
Assert.Equal(WorldTransformCapacityPolicy.InitialBindingSizeBytes,
|
||||
slice.BindingSizeBytes);
|
||||
}
|
||||
using (IGpuFrame second = device.BeginFrame())
|
||||
{
|
||||
WorldTransformFrameSlice slice = buffers.Publish(
|
||||
second,
|
||||
topologyBuildSequence: 7,
|
||||
transforms,
|
||||
ReadOnlySpan<int>.Empty);
|
||||
Assert.Equal(1, second.SlotIndex);
|
||||
Assert.NotSame(slotZero, slice.Buffer);
|
||||
}
|
||||
|
||||
device.Clear();
|
||||
int buffersBefore = device.CreatedBuffers.Count;
|
||||
int uploadsBefore = slotZero.UploadCount;
|
||||
using IGpuFrame warmed = device.BeginFrame();
|
||||
Assert.Equal(0, warmed.SlotIndex);
|
||||
buffers.Publish(
|
||||
warmed,
|
||||
topologyBuildSequence: 7,
|
||||
transforms,
|
||||
ReadOnlySpan<int>.Empty);
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (int iteration = 0; iteration < 256; iteration++)
|
||||
{
|
||||
buffers.Publish(
|
||||
warmed,
|
||||
topologyBuildSequence: 7,
|
||||
transforms,
|
||||
ReadOnlySpan<int>.Empty);
|
||||
}
|
||||
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
|
||||
Assert.Equal(0, allocated);
|
||||
Assert.Equal(buffersBefore, device.CreatedBuffers.Count);
|
||||
Assert.Equal(uploadsBefore, slotZero.UploadCount);
|
||||
Assert.Empty(device.OfKind<GpuRecordedHostStorageVisibility>());
|
||||
Assert.False(buffers.LastStats.TopologyUploaded);
|
||||
Assert.Equal(0, buffers.LastStats.BytesWritten);
|
||||
Assert.Equal(2, buffers.BufferCount);
|
||||
Assert.Equal(
|
||||
2L * WorldTransformCapacityPolicy.InitialBindingSizeBytes,
|
||||
buffers.RetainedGpuBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StableTopology_UpdatesOnlyStrictDynamicRangesWithExactMatrixBits()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using var buffers = new DirectionalShadowTransformBufferSet(device);
|
||||
Matrix4x4[] transforms =
|
||||
[
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.CreateTranslation(1f, 2f, 3f),
|
||||
Matrix4x4.CreateTranslation(4f, 5f, 6f),
|
||||
Matrix4x4.CreateTranslation(7f, 8f, 9f),
|
||||
Matrix4x4.CreateTranslation(10f, 11f, 12f),
|
||||
];
|
||||
int[] dynamicSlots = [1, 2, 4];
|
||||
|
||||
using (IGpuFrame first = device.BeginFrame())
|
||||
buffers.Publish(first, 11, transforms, dynamicSlots);
|
||||
using (IGpuFrame second = device.BeginFrame())
|
||||
buffers.Publish(second, 11, transforms, dynamicSlots);
|
||||
|
||||
float exactX = BitConverter.Int32BitsToSingle(0x41234567);
|
||||
float exactY = BitConverter.Int32BitsToSingle(0x40ABCDEF);
|
||||
transforms[1] = Matrix4x4.CreateRotationX(0.3f)
|
||||
* Matrix4x4.CreateTranslation(exactX, 20f, 30f);
|
||||
transforms[2] = Matrix4x4.CreateRotationY(0.4f)
|
||||
* Matrix4x4.CreateTranslation(40f, exactY, 50f);
|
||||
transforms[4] = Matrix4x4.CreateRotationZ(0.5f)
|
||||
* Matrix4x4.CreateTranslation(60f, 70f, 80f);
|
||||
|
||||
device.Clear();
|
||||
using IGpuFrame third = device.BeginFrame();
|
||||
WorldTransformFrameSlice slice = buffers.Publish(
|
||||
third,
|
||||
11,
|
||||
transforms,
|
||||
dynamicSlots);
|
||||
var retained = Assert.IsType<RecordingGpuBuffer>(slice.Buffer);
|
||||
Matrix4x4[] readback = new Matrix4x4[transforms.Length];
|
||||
retained.Read(0, MemoryMarshal.AsBytes(readback.AsSpan()));
|
||||
|
||||
AssertMatrixBitsEqual(transforms[0], readback[0]);
|
||||
AssertMatrixBitsEqual(transforms[1], readback[1]);
|
||||
AssertMatrixBitsEqual(transforms[2], readback[2]);
|
||||
AssertMatrixBitsEqual(transforms[3], readback[3]);
|
||||
AssertMatrixBitsEqual(transforms[4], readback[4]);
|
||||
Assert.False(buffers.LastStats.TopologyUploaded);
|
||||
Assert.Equal(3, buffers.LastStats.DynamicMatricesUpdated);
|
||||
Assert.Equal(2, buffers.LastStats.DynamicRangesUpdated);
|
||||
Assert.Equal(3 * 64, buffers.LastStats.BytesWritten);
|
||||
Assert.Single(device.OfKind<GpuRecordedHostStorageVisibility>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangedMatrix_ReplaysExactlyToEveryFlightSlotWithoutRescanningAllDynamics()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using var buffers = new DirectionalShadowTransformBufferSet(device);
|
||||
Matrix4x4[] transforms =
|
||||
[
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.CreateTranslation(1f, 2f, 3f),
|
||||
Matrix4x4.CreateTranslation(4f, 5f, 6f),
|
||||
];
|
||||
int[] allDynamic = [1, 2];
|
||||
using (IGpuFrame first = device.BeginFrame())
|
||||
buffers.Publish(first, 12, transforms, [], allDynamic);
|
||||
using (IGpuFrame second = device.BeginFrame())
|
||||
buffers.Publish(second, 12, transforms, [], allDynamic);
|
||||
|
||||
float exact = BitConverter.Int32BitsToSingle(0x41234567);
|
||||
transforms[2] = Matrix4x4.CreateRotationZ(0.25f)
|
||||
* Matrix4x4.CreateTranslation(exact, 8f, 9f);
|
||||
RecordingGpuBuffer slotZero;
|
||||
using (IGpuFrame changed = device.BeginFrame())
|
||||
{
|
||||
slotZero = Assert.IsType<RecordingGpuBuffer>(buffers.Publish(
|
||||
changed,
|
||||
12,
|
||||
transforms,
|
||||
[2],
|
||||
allDynamic).Buffer);
|
||||
Assert.Equal(1, buffers.LastStats.DynamicMatricesUpdated);
|
||||
}
|
||||
|
||||
device.Clear();
|
||||
RecordingGpuBuffer slotOne;
|
||||
using (IGpuFrame replay = device.BeginFrame())
|
||||
{
|
||||
slotOne = Assert.IsType<RecordingGpuBuffer>(buffers.Publish(
|
||||
replay,
|
||||
12,
|
||||
transforms,
|
||||
[],
|
||||
allDynamic).Buffer);
|
||||
Assert.Equal(1, buffers.LastStats.DynamicMatricesUpdated);
|
||||
Assert.Equal(64, buffers.LastStats.BytesWritten);
|
||||
Assert.Equal(0, buffers.LastStats.CurrentChangedMatrices);
|
||||
Assert.Equal(1, buffers.LastStats.PendingReplayMatrices);
|
||||
}
|
||||
Matrix4x4[] zeroReadback = new Matrix4x4[3];
|
||||
Matrix4x4[] oneReadback = new Matrix4x4[3];
|
||||
slotZero.Read(0, MemoryMarshal.AsBytes(zeroReadback.AsSpan()));
|
||||
slotOne.Read(0, MemoryMarshal.AsBytes(oneReadback.AsSpan()));
|
||||
AssertMatrixBitsEqual(transforms[2], zeroReadback[2]);
|
||||
AssertMatrixBitsEqual(transforms[2], oneReadback[2]);
|
||||
Assert.Single(device.OfKind<GpuRecordedHostStorageVisibility>());
|
||||
Assert.True(buffers.RetainedScratchBytes > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedChanges_CoalesceToOneLatestValueForWaitingFlightSlot()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using var buffers = new DirectionalShadowTransformBufferSet(device);
|
||||
Matrix4x4[] transforms =
|
||||
[
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.CreateTranslation(1f, 2f, 3f),
|
||||
];
|
||||
int[] allDynamic = [1];
|
||||
using (IGpuFrame first = device.BeginFrame())
|
||||
buffers.Publish(first, 13, transforms, [], allDynamic);
|
||||
using (IGpuFrame second = device.BeginFrame())
|
||||
buffers.Publish(second, 13, transforms, [], allDynamic);
|
||||
|
||||
using (IGpuFrame current = device.BeginFrame())
|
||||
{
|
||||
transforms[1] = Matrix4x4.CreateTranslation(10f, 20f, 30f);
|
||||
buffers.Publish(current, 13, transforms, [1], allDynamic);
|
||||
transforms[1] = Matrix4x4.CreateTranslation(40f, 50f, 60f);
|
||||
buffers.Publish(current, 13, transforms, [1], allDynamic);
|
||||
}
|
||||
using IGpuFrame waiting = device.BeginFrame();
|
||||
WorldTransformFrameSlice slice = buffers.Publish(
|
||||
waiting,
|
||||
13,
|
||||
transforms,
|
||||
[],
|
||||
allDynamic);
|
||||
var retained = Assert.IsType<RecordingGpuBuffer>(slice.Buffer);
|
||||
Matrix4x4[] readback = new Matrix4x4[2];
|
||||
retained.Read(0, MemoryMarshal.AsBytes(readback.AsSpan()));
|
||||
|
||||
AssertMatrixBitsEqual(transforms[1], readback[1]);
|
||||
Assert.Equal(1, buffers.LastStats.DynamicMatricesUpdated);
|
||||
Assert.Equal(1, buffers.LastStats.PendingReplayMatrices);
|
||||
Assert.Equal(64, buffers.LastStats.BytesWritten);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DenseRefresh_UploadsFourExactContiguousRangesAndReplaysDirectlyPerFlight()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using var buffers = new DirectionalShadowTransformBufferSet(device);
|
||||
var transforms = new Matrix4x4[2_000];
|
||||
for (int index = 0; index < transforms.Length; index++)
|
||||
transforms[index] = Matrix4x4.CreateTranslation(index, index + 1, index + 2);
|
||||
int[] dynamicSlots = Enumerable.Range(0, 494)
|
||||
.Concat(Enumerable.Range(500, 494))
|
||||
.Concat(Enumerable.Range(1_000, 494))
|
||||
.Concat(Enumerable.Range(1_500, 494))
|
||||
.ToArray();
|
||||
using (IGpuFrame first = device.BeginFrame())
|
||||
buffers.Publish(first, 14, transforms, [], dynamicSlots, false);
|
||||
using (IGpuFrame second = device.BeginFrame())
|
||||
buffers.Publish(second, 14, transforms, [], dynamicSlots, false);
|
||||
|
||||
for (int index = 0; index < dynamicSlots.Length; index++)
|
||||
{
|
||||
int slot = dynamicSlots[index];
|
||||
transforms[slot] = Matrix4x4.CreateTranslation(
|
||||
slot + 10_000,
|
||||
slot + 20_000,
|
||||
slot + 30_000);
|
||||
}
|
||||
using (IGpuFrame dense = device.BeginFrame())
|
||||
{
|
||||
buffers.Publish(
|
||||
dense,
|
||||
14,
|
||||
transforms,
|
||||
dynamicSlots,
|
||||
dynamicSlots,
|
||||
denseRefresh: true);
|
||||
Assert.True(buffers.LastStats.DenseDirectUpload);
|
||||
Assert.False(buffers.LastStats.DenseFlightReplay);
|
||||
Assert.Equal(1_976, buffers.LastStats.DynamicMatricesUpdated);
|
||||
Assert.Equal(4, buffers.LastStats.DynamicRangesUpdated);
|
||||
Assert.Equal(1_976 * 64, buffers.LastStats.BytesWritten);
|
||||
Assert.Equal(0, buffers.LastStats.PendingReplayMatrices);
|
||||
}
|
||||
|
||||
using IGpuFrame replay = device.BeginFrame();
|
||||
WorldTransformFrameSlice replaySlice = buffers.Publish(
|
||||
replay,
|
||||
14,
|
||||
transforms,
|
||||
[],
|
||||
dynamicSlots,
|
||||
denseRefresh: false);
|
||||
Assert.False(buffers.LastStats.DenseDirectUpload);
|
||||
Assert.True(buffers.LastStats.DenseFlightReplay);
|
||||
Assert.Equal(1_976, buffers.LastStats.DynamicMatricesUpdated);
|
||||
Assert.Equal(4, buffers.LastStats.DynamicRangesUpdated);
|
||||
Assert.Equal(1_976 * 64, buffers.LastStats.BytesWritten);
|
||||
var retained = Assert.IsType<RecordingGpuBuffer>(replaySlice.Buffer);
|
||||
var readback = new Matrix4x4[2_000];
|
||||
retained.Read(0, MemoryMarshal.AsBytes(readback.AsSpan()));
|
||||
AssertMatrixBitsEqual(transforms[0], readback[0]);
|
||||
AssertMatrixBitsEqual(transforms[1_993], readback[1_993]);
|
||||
AssertMatrixBitsEqual(transforms[1_999], readback[1_999]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopologyChange_CreateSwapsCurrentSlotAndDisposalReleasesEverySlot()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
var buffers = new DirectionalShadowTransformBufferSet(device);
|
||||
Matrix4x4[] firstPose = [Matrix4x4.Identity];
|
||||
RecordingGpuBuffer firstSlot;
|
||||
RecordingGpuBuffer secondSlot;
|
||||
using (IGpuFrame first = device.BeginFrame())
|
||||
{
|
||||
firstSlot = Assert.IsType<RecordingGpuBuffer>(buffers.Publish(
|
||||
first, 1, firstPose, []).Buffer);
|
||||
}
|
||||
using (IGpuFrame second = device.BeginFrame())
|
||||
{
|
||||
secondSlot = Assert.IsType<RecordingGpuBuffer>(buffers.Publish(
|
||||
second, 1, firstPose, []).Buffer);
|
||||
}
|
||||
|
||||
Matrix4x4[] rebuiltPose =
|
||||
[
|
||||
Matrix4x4.CreateTranslation(9f, 8f, 7f),
|
||||
Matrix4x4.CreateScale(2f),
|
||||
];
|
||||
RecordingGpuBuffer replacement;
|
||||
using (IGpuFrame third = device.BeginFrame())
|
||||
{
|
||||
replacement = Assert.IsType<RecordingGpuBuffer>(buffers.Publish(
|
||||
third, 2, rebuiltPose, []).Buffer);
|
||||
}
|
||||
|
||||
Assert.True(firstSlot.IsDisposed);
|
||||
Assert.False(secondSlot.IsDisposed);
|
||||
Assert.False(replacement.IsDisposed);
|
||||
Assert.True(buffers.LastStats.TopologyUploaded);
|
||||
buffers.Dispose();
|
||||
Assert.True(secondSlot.IsDisposed);
|
||||
Assert.True(replacement.IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidDynamicSlotsAndUnsupportedBindingFailBeforePublishing()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using var buffers = new DirectionalShadowTransformBufferSet(device);
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
Matrix4x4[] pose = [Matrix4x4.Identity, Matrix4x4.Identity];
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
buffers.Publish(frame, 1, pose, [1, 1]));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
buffers.Publish(frame, 1, pose, [2]));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
buffers.Publish(
|
||||
frame,
|
||||
1,
|
||||
pose,
|
||||
[],
|
||||
[],
|
||||
denseRefresh: false,
|
||||
bindingSizeBytes: 64u));
|
||||
|
||||
Assert.Equal(0, buffers.BufferCount);
|
||||
Assert.Empty(device.OfKind<GpuRecordedHostStorageVisibility>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectedDenseDemandPublishesBeyondFormer65536CeilingInOneBinding()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using var buffers = new DirectionalShadowTransformBufferSet(device);
|
||||
var transforms = new Matrix4x4[68_395];
|
||||
transforms[^1] = Matrix4x4.CreateTranslation(68_394f, 2f, 3f);
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
|
||||
WorldTransformFrameSlice slice = buffers.Publish(
|
||||
frame,
|
||||
topologyBuildSequence: 68_395,
|
||||
transforms,
|
||||
ReadOnlySpan<int>.Empty);
|
||||
|
||||
Assert.Equal(68_395u, slice.InstanceCount);
|
||||
Assert.True(slice.IsValidFor(frame));
|
||||
Assert.Equal(WorldTransformCapacityPolicy.InitialBindingSizeBytes,
|
||||
slice.BindingSizeBytes);
|
||||
Assert.Same(Assert.Single(device.CreatedBuffers), slice.Buffer);
|
||||
}
|
||||
|
||||
private static void AssertMatrixBitsEqual(
|
||||
Matrix4x4 expected,
|
||||
Matrix4x4 actual)
|
||||
{
|
||||
ReadOnlySpan<byte> expectedBits = MemoryMarshal.AsBytes(
|
||||
MemoryMarshal.CreateReadOnlySpan(ref expected, 1));
|
||||
ReadOnlySpan<byte> actualBits = MemoryMarshal.AsBytes(
|
||||
MemoryMarshal.CreateReadOnlySpan(ref actual, 1));
|
||||
Assert.True(expectedBits.SequenceEqual(actualBits));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
using AcDream.App.Rendering;
|
||||
using Silk.NET.Maths;
|
||||
using Silk.NET.Windowing;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class GameWindowStartupOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void OrdinaryStartupPreservesCurrentDecorated1280By720Window()
|
||||
{
|
||||
WindowOptions defaults = WindowOptions.DefaultVulkan;
|
||||
|
||||
WindowOptions options = GameWindow.CreateStartupWindowOptions(
|
||||
exactAutomationFramebuffer: false,
|
||||
persistedResolution: "3840x2160",
|
||||
useVSync: true);
|
||||
|
||||
Assert.Equal(new Vector2D<int>(1280, 720), options.Size);
|
||||
Assert.Equal(defaults.WindowBorder, options.WindowBorder);
|
||||
Assert.Equal(defaults.IsVisible, options.IsVisible);
|
||||
Assert.True(options.VSync);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2560x1440", 2560, 1440)]
|
||||
[InlineData("3840x2160", 3840, 2160)]
|
||||
public void ExactAutomationStartupUsesRequestedBorderlessClientExtent(
|
||||
string resolution,
|
||||
int width,
|
||||
int height)
|
||||
{
|
||||
WindowOptions options = GameWindow.CreateStartupWindowOptions(
|
||||
exactAutomationFramebuffer: true,
|
||||
persistedResolution: resolution,
|
||||
useVSync: false);
|
||||
|
||||
Assert.Equal(new Vector2D<int>(width, height), options.Size);
|
||||
Assert.Equal(WindowBorder.Hidden, options.WindowBorder);
|
||||
Assert.False(options.IsVisible);
|
||||
Assert.False(options.VSync);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExactAutomationStartupRejectsInvalidResolution()
|
||||
{
|
||||
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() =>
|
||||
GameWindow.CreateStartupWindowOptions(
|
||||
exactAutomationFramebuffer: true,
|
||||
persistedResolution: "invalid",
|
||||
useVSync: false));
|
||||
|
||||
Assert.Equal(
|
||||
"Exact automation framebuffer requires a valid persisted resolution.",
|
||||
error.Message);
|
||||
}
|
||||
}
|
||||
|
|
@ -37,10 +37,9 @@ public sealed class GpuContractTests
|
|||
[Fact]
|
||||
public void StorageBindingsMatchTheShaderSources()
|
||||
{
|
||||
// mesh_modern.vert declares std430 bindings 0..8 in exactly this order.
|
||||
// Binding 9 was the GL-only texture handle table added by slice V2;
|
||||
// Campaign V slice V11 deleted it (StorageTextureTable) along with the
|
||||
// rest of the raw-GL arm, so 9 is now one past the highest binding.
|
||||
// mesh_modern.vert declares std430 bindings 0..8 in exactly this order;
|
||||
// #226's mesh_detail.vert additionally declares binding 9 for the
|
||||
// per-instance building category.
|
||||
Assert.Equal(0u, GpuBindingModel.StorageInstances);
|
||||
Assert.Equal(1u, GpuBindingModel.StorageBatches);
|
||||
Assert.Equal(2u, GpuBindingModel.StorageClipRegions);
|
||||
|
|
@ -50,7 +49,8 @@ public sealed class GpuContractTests
|
|||
Assert.Equal(6u, GpuBindingModel.StorageInstanceIndoor);
|
||||
Assert.Equal(7u, GpuBindingModel.StorageInstanceAlpha);
|
||||
Assert.Equal(8u, GpuBindingModel.StorageInstanceSelectionLighting);
|
||||
Assert.Equal(9u, GpuBindingModel.StorageBindingCount);
|
||||
Assert.Equal(9u, GpuBindingModel.StorageInstanceDetailCategory);
|
||||
Assert.Equal(10u, GpuBindingModel.StorageBindingCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -81,11 +81,12 @@ public sealed class GpuContractTests
|
|||
// with only two — slice V4c found the gap. Mapping InvAlpha onto
|
||||
// StraightAlpha would silently change how every inverse-alpha surface
|
||||
// composites, so the contract has to carry all three.
|
||||
Assert.Equal(4, Enum.GetValues<GpuBlendMode>().Length);
|
||||
Assert.Equal(5, Enum.GetValues<GpuBlendMode>().Length);
|
||||
Assert.Contains(GpuBlendMode.None, Enum.GetValues<GpuBlendMode>());
|
||||
Assert.Contains(GpuBlendMode.StraightAlpha, Enum.GetValues<GpuBlendMode>());
|
||||
Assert.Contains(GpuBlendMode.Additive, Enum.GetValues<GpuBlendMode>());
|
||||
Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues<GpuBlendMode>());
|
||||
Assert.Contains(GpuBlendMode.RetailDetail, Enum.GetValues<GpuBlendMode>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -267,6 +268,27 @@ public sealed class GpuContractTests
|
|||
Assert.True(aspects.HasFlag(Silk.NET.Vulkan.ImageAspectFlags.StencilBit));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rgba16FloatRenderTarget_HasExactVulkanFormatAndByteAccounting()
|
||||
{
|
||||
Assert.Equal(
|
||||
Silk.NET.Vulkan.Format.R16G16B16A16Sfloat,
|
||||
VulkanTextureFormatMapping.FormatOf(
|
||||
GpuTextureFormat.Rgba16FloatRenderTarget));
|
||||
Assert.True(VulkanTextureFormatMapping.IsRenderTarget(
|
||||
GpuTextureFormat.Rgba16FloatRenderTarget));
|
||||
Assert.Equal(
|
||||
8,
|
||||
VulkanTextureFormatMapping.BytesPerTexel(
|
||||
GpuTextureFormat.Rgba16FloatRenderTarget));
|
||||
Assert.Equal(
|
||||
1920 * 1080 * 8,
|
||||
VulkanTextureFormatMapping.LevelSizeBytes(
|
||||
GpuTextureFormat.Rgba16FloatRenderTarget,
|
||||
1920,
|
||||
1080));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UniformBindingsDoNotCollide()
|
||||
{
|
||||
|
|
@ -375,10 +397,17 @@ public sealed class GpuContractTests
|
|||
MinUniformBufferOffsetAlignment = 256,
|
||||
MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot,
|
||||
MaxSampleCount = 8,
|
||||
MaxImageDimension2D = 16_384,
|
||||
MaxImageArrayLayers = 2_048,
|
||||
DeviceLocalMemoryBytes = 8UL * 1024 * 1024 * 1024,
|
||||
SupportsMultiDrawIndirect = true,
|
||||
SupportsDrawParameters = true,
|
||||
SupportsTextureCompressionBc = true,
|
||||
SupportsTimestampQueries = true,
|
||||
SupportsPersistentlyMappedRings = true,
|
||||
SupportsRgba16FloatRenderTargets = true,
|
||||
MaxRgba16FloatSampleCount = 8,
|
||||
SupportsSampledDepth = true,
|
||||
SupportsMultiview = true,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,10 +18,14 @@ internal sealed record GpuRecordedFrameEnd(long Serial) : GpuRecordedCall;
|
|||
|
||||
internal sealed record GpuRecordedRingAllocation(GpuRingUsage Usage, int ByteCount, uint OffsetBytes) : GpuRecordedCall;
|
||||
|
||||
internal sealed record GpuRecordedPassBegin(string Name, int SampleCount) : GpuRecordedCall;
|
||||
internal sealed record GpuRecordedHostStorageVisibility(string BufferName) : GpuRecordedCall;
|
||||
|
||||
internal sealed record GpuRecordedPassBegin(string Name, int SampleCount, uint ViewMask = 0) : GpuRecordedCall;
|
||||
|
||||
internal sealed record GpuRecordedPassEnd(string Name) : GpuRecordedCall;
|
||||
|
||||
internal sealed record GpuRecordedTimerScope(string Name) : GpuRecordedCall;
|
||||
|
||||
internal sealed record GpuRecordedPipelineBind(string PipelineName) : GpuRecordedCall;
|
||||
|
||||
internal sealed record GpuRecordedStorageBind(uint Binding, string BufferName, uint OffsetBytes, uint SizeBytes)
|
||||
|
|
@ -72,13 +76,25 @@ internal sealed record GpuRecordedTextureRegistration(string TextureName, GpuSam
|
|||
|
||||
internal sealed record GpuRecordedTextureRelease(uint Slot) : GpuRecordedCall;
|
||||
|
||||
internal sealed record GpuRecordedRenderTargetCreate(GpuRenderTargetDescription Description)
|
||||
: GpuRecordedCall;
|
||||
|
||||
internal sealed record GpuRecordedDirectionalDepthTargetCreate(
|
||||
GpuDirectionalDepthTargetDescription Description) : GpuRecordedCall;
|
||||
|
||||
internal sealed record GpuRecordedPipelineColorFormatAcquire(GpuTextureFormat Format)
|
||||
: GpuRecordedCall;
|
||||
|
||||
internal sealed record GpuRecordedPipelineColorFormatRelease(GpuTextureFormat Format)
|
||||
: GpuRecordedCall;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IGpuDevice"/> that owns no driver objects. Ring
|
||||
/// allocations are backed by a real byte array, so a test can drive a renderer
|
||||
/// and then read back exactly what it wrote — the same bytes a driver would have
|
||||
/// seen. Everything else is recorded into <see cref="Calls"/> in submission order.
|
||||
/// </summary>
|
||||
internal sealed class RecordingGpuDevice : IGpuDevice
|
||||
internal sealed class RecordingGpuDevice : IGpuDevice, IGpuPipelineFormatVariantHost
|
||||
{
|
||||
private const int DefaultRingCapacityBytes = 8 * 1024 * 1024;
|
||||
|
||||
|
|
@ -87,7 +103,10 @@ internal sealed class RecordingGpuDevice : IGpuDevice
|
|||
private readonly List<RecordingGpuBuffer> _createdBuffers = [];
|
||||
private readonly List<RecordingGpuPipeline> _createdPipelines = [];
|
||||
private readonly List<RecordingGpuSampler> _createdSamplers = [];
|
||||
private readonly List<RecordingGpuRenderTarget> _createdRenderTargets = [];
|
||||
private readonly List<RecordingGpuDirectionalDepthTarget> _createdDirectionalDepthTargets = [];
|
||||
private readonly Dictionary<GpuSamplerDescription, RecordingGpuSampler> _samplers = [];
|
||||
private readonly Dictionary<GpuTextureFormat, int> _pipelineFormatLeases = [];
|
||||
private readonly byte[] _ring;
|
||||
private readonly Stack<uint> _freeTextureSlots = new();
|
||||
|
||||
|
|
@ -101,11 +120,13 @@ internal sealed class RecordingGpuDevice : IGpuDevice
|
|||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(ringCapacityBytes, 1);
|
||||
_ring = new byte[ringCapacityBytes];
|
||||
RingBuffer = new RecordingGpuBuffer(new GpuBufferDescription(
|
||||
"test-ring",
|
||||
ringCapacityBytes,
|
||||
GpuBufferUsage.Storage | GpuBufferUsage.Uniform | GpuBufferUsage.Indirect,
|
||||
GpuMemoryResidency.HostWritable));
|
||||
RingBuffer = new RecordingGpuBuffer(
|
||||
new GpuBufferDescription(
|
||||
"test-ring",
|
||||
ringCapacityBytes,
|
||||
GpuBufferUsage.Storage | GpuBufferUsage.Uniform | GpuBufferUsage.Indirect,
|
||||
GpuMemoryResidency.HostWritable),
|
||||
_ring);
|
||||
|
||||
RecordingGpuTexture placeholder = new("default-white", GpuTextureKind.Texture2D, GpuTextureFormat.Rgba8Unorm, 1, 1, 1, 1);
|
||||
DefaultTextureSlot = RegisterTexture(placeholder, CreateSampler(GpuSamplerDescription.UiNearest));
|
||||
|
|
@ -136,19 +157,29 @@ internal sealed class RecordingGpuDevice : IGpuDevice
|
|||
MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount,
|
||||
MaxPushConstantBytes = GpuBindingModel.MaxPushConstantBytes,
|
||||
MinStorageBufferOffsetAlignment = 256,
|
||||
MaxStorageBufferRangeBytes = 128u * 1024u * 1024u,
|
||||
MinUniformBufferOffsetAlignment = 256,
|
||||
MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot,
|
||||
MaxSampleCount = 8,
|
||||
MaxImageDimension2D = 16_384,
|
||||
MaxImageArrayLayers = 2_048,
|
||||
DeviceLocalMemoryBytes = 8UL * 1024 * 1024 * 1024,
|
||||
SupportsMultiDrawIndirect = true,
|
||||
SupportsDrawParameters = true,
|
||||
SupportsTextureCompressionBc = true,
|
||||
SupportsTimestampQueries = true,
|
||||
SupportsPersistentlyMappedRings = true,
|
||||
SupportsRgba16FloatRenderTargets = true,
|
||||
MaxRgba16FloatSampleCount = 8,
|
||||
SupportsSampledDepth = true,
|
||||
SupportsMultiview = true,
|
||||
};
|
||||
|
||||
public IGpuResourceRetirementQueue Retirement => ImmediateGpuResourceRetirementQueue.Instance;
|
||||
|
||||
public IGpuTimerPool Timers { get; } = new RecordingGpuTimerPool();
|
||||
public RecordingGpuTimerPool RecordingTimers { get; } = new();
|
||||
|
||||
public IGpuTimerPool Timers => RecordingTimers;
|
||||
|
||||
public GpuTextureSlot DefaultTextureSlot { get; }
|
||||
|
||||
|
|
@ -160,6 +191,26 @@ internal sealed class RecordingGpuDevice : IGpuDevice
|
|||
|
||||
public IReadOnlyList<RecordingGpuSampler> CreatedSamplers => _createdSamplers;
|
||||
|
||||
public IReadOnlyList<RecordingGpuRenderTarget> CreatedRenderTargets => _createdRenderTargets;
|
||||
|
||||
public IReadOnlyList<RecordingGpuDirectionalDepthTarget> CreatedDirectionalDepthTargets =>
|
||||
_createdDirectionalDepthTargets;
|
||||
|
||||
public IReadOnlyDictionary<GpuTextureFormat, int> PipelineFormatLeases =>
|
||||
_pipelineFormatLeases;
|
||||
|
||||
/// <summary>
|
||||
/// Optional deterministic allocation fault used to prove candidate target
|
||||
/// sets roll back atomically. Returning null admits the allocation.
|
||||
/// </summary>
|
||||
public Func<GpuRenderTargetDescription, Exception?>? RenderTargetFailure { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional deterministic pipeline-construction fault used to prove that
|
||||
/// renderers retire every partially-created resource.
|
||||
/// </summary>
|
||||
public Func<GpuPipelineDescription, Exception?>? PipelineFailure { get; set; }
|
||||
|
||||
public IGpuBuffer CreateBuffer(in GpuBufferDescription description)
|
||||
{
|
||||
var buffer = new RecordingGpuBuffer(description);
|
||||
|
|
@ -193,11 +244,12 @@ internal sealed class RecordingGpuDevice : IGpuDevice
|
|||
|
||||
public IGpuSampler CreateSampler(in GpuSamplerDescription description)
|
||||
{
|
||||
if (_samplers.TryGetValue(description, out RecordingGpuSampler? existing))
|
||||
if (_samplers.TryGetValue(description, out RecordingGpuSampler? existing)
|
||||
&& !existing.IsDisposed)
|
||||
return existing;
|
||||
|
||||
RecordingGpuSampler created = new(description);
|
||||
_samplers.Add(description, created);
|
||||
_samplers[description] = created;
|
||||
_createdSamplers.Add(created);
|
||||
return created;
|
||||
}
|
||||
|
|
@ -205,13 +257,78 @@ internal sealed class RecordingGpuDevice : IGpuDevice
|
|||
public IGpuPipeline CreatePipeline(GpuPipelineDescription description)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(description);
|
||||
if (description.ViewMask != 0 && !Capabilities.SupportsMultiview)
|
||||
throw new NotSupportedException("Multiview pipelines are unsupported.");
|
||||
if (PipelineFailure?.Invoke(description) is { } failure)
|
||||
throw failure;
|
||||
var pipeline = new RecordingGpuPipeline(description);
|
||||
_createdPipelines.Add(pipeline);
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description) =>
|
||||
new RecordingGpuRenderTarget(description);
|
||||
public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.SampleCount);
|
||||
if (description.SampleableDepth && description.DepthFormat is null)
|
||||
throw new ArgumentException("SampleableDepth requires a depth format.", nameof(description));
|
||||
if ((uint)description.SampleCount > Capabilities.MaxSampleCount)
|
||||
throw new NotSupportedException("The requested sample count is unsupported.");
|
||||
if (description.ColorFormat == GpuTextureFormat.Rgba16FloatRenderTarget
|
||||
&& (!Capabilities.SupportsRgba16FloatRenderTargets
|
||||
|| (uint)description.SampleCount > Capabilities.MaxRgba16FloatSampleCount))
|
||||
{
|
||||
throw new NotSupportedException("RGBA16F render-target capabilities are insufficient.");
|
||||
}
|
||||
if (description.SampleableDepth && !Capabilities.SupportsSampledDepth)
|
||||
throw new NotSupportedException("Sampled depth is unsupported.");
|
||||
if (RenderTargetFailure?.Invoke(description) is { } failure)
|
||||
throw failure;
|
||||
var target = new RecordingGpuRenderTarget(description);
|
||||
_createdRenderTargets.Add(target);
|
||||
_calls.Add(new GpuRecordedRenderTargetCreate(description));
|
||||
return target;
|
||||
}
|
||||
|
||||
public IGpuDirectionalDepthTarget CreateDirectionalDepthTarget(
|
||||
in GpuDirectionalDepthTargetDescription description)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(description.Name);
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Resolution);
|
||||
if (description.LayerCount is < 2 or > 4)
|
||||
throw new ArgumentOutOfRangeException(nameof(description));
|
||||
if (description.DepthFormat != GpuTextureFormat.Depth24Stencil8)
|
||||
throw new ArgumentException("Directional depth requires Depth24Stencil8.", nameof(description));
|
||||
if (!Capabilities.SupportsSampledDepth)
|
||||
throw new NotSupportedException("Sampled depth is unsupported.");
|
||||
|
||||
var target = new RecordingGpuDirectionalDepthTarget(description);
|
||||
_createdDirectionalDepthTargets.Add(target);
|
||||
_calls.Add(new GpuRecordedDirectionalDepthTargetCreate(description));
|
||||
return target;
|
||||
}
|
||||
|
||||
public IDisposable AcquirePipelineColorFormat(GpuTextureFormat format)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (format == GpuTextureFormat.Rgba16FloatRenderTarget
|
||||
&& !Capabilities.SupportsRgba16FloatRenderTargets)
|
||||
throw new NotSupportedException("RGBA16F render targets are unsupported.");
|
||||
_pipelineFormatLeases.TryGetValue(format, out int count);
|
||||
_pipelineFormatLeases[format] = checked(count + 1);
|
||||
_calls.Add(new GpuRecordedPipelineColorFormatAcquire(format));
|
||||
return new RecordingPipelineColorFormatLease(this, format);
|
||||
}
|
||||
|
||||
private void ReleasePipelineColorFormat(GpuTextureFormat format)
|
||||
{
|
||||
if (!_pipelineFormatLeases.TryGetValue(format, out int count))
|
||||
return;
|
||||
if (count == 1)
|
||||
_pipelineFormatLeases.Remove(format);
|
||||
else
|
||||
_pipelineFormatLeases[format] = count - 1;
|
||||
_calls.Add(new GpuRecordedPipelineColorFormatRelease(format));
|
||||
}
|
||||
|
||||
public GpuTextureSlot RegisterTexture(IGpuTexture texture, IGpuSampler sampler)
|
||||
{
|
||||
|
|
@ -311,6 +428,16 @@ internal sealed class RecordingGpuDevice : IGpuDevice
|
|||
|
||||
private static uint AlignUp(uint value, uint alignment) =>
|
||||
alignment <= 1 ? value : (value + alignment - 1) / alignment * alignment;
|
||||
|
||||
private sealed class RecordingPipelineColorFormatLease(
|
||||
RecordingGpuDevice device,
|
||||
GpuTextureFormat format) : IDisposable
|
||||
{
|
||||
private RecordingGpuDevice? _device = device;
|
||||
|
||||
public void Dispose() =>
|
||||
Interlocked.Exchange(ref _device, null)?.ReleasePipelineColorFormat(format);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RecordingGpuFrame(RecordingGpuDevice device, long serial, int slotIndex) : IGpuFrame
|
||||
|
|
@ -323,10 +450,77 @@ internal sealed class RecordingGpuFrame(RecordingGpuDevice device, long serial,
|
|||
|
||||
public GpuRingAllocation AllocateRing(int byteCount, GpuRingUsage usage) => device.Allocate(byteCount, usage);
|
||||
|
||||
public void PublishHostStorageWrites(IGpuBuffer buffer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(buffer);
|
||||
if (buffer.Residency != GpuMemoryResidency.HostWritable
|
||||
|| !buffer.Usage.HasFlag(GpuBufferUsage.Storage))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Published host writes require a host-writable storage buffer.",
|
||||
nameof(buffer));
|
||||
}
|
||||
device.Record(new GpuRecordedHostStorageVisibility(buffer.Name));
|
||||
}
|
||||
|
||||
public IGpuPassEncoder BeginPass(GpuPassDescription description)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(description);
|
||||
device.Record(new GpuRecordedPassBegin(description.Name, description.SampleCount));
|
||||
if (!description.HasColorAttachment)
|
||||
{
|
||||
if (description.SampleCount != 1
|
||||
|| description.Depth is not { DirectionalTarget: RecordingGpuDirectionalDepthTarget directionalTarget } depth)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A colour-less recording pass requires a single-sampled directional-depth target.");
|
||||
}
|
||||
if (depth.Layer < 0 || depth.Layer >= directionalTarget.Description.LayerCount)
|
||||
throw new ArgumentOutOfRangeException(nameof(description));
|
||||
if (description.ViewMask != 0)
|
||||
{
|
||||
uint expected = (1u << directionalTarget.Description.LayerCount) - 1u;
|
||||
if (description.ViewMask != expected || !device.Capabilities.SupportsMultiview)
|
||||
throw new NotSupportedException("Directional multiview requires every target layer and device support.");
|
||||
}
|
||||
if (depth.Store != GpuStoreOp.Store)
|
||||
throw new InvalidOperationException("Directional depth must be stored for sampling.");
|
||||
}
|
||||
if (description.Color.Target is RecordingGpuRenderTarget target)
|
||||
{
|
||||
if (description.SampleCount != target.Description.SampleCount)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Pass and offscreen-target sample counts must match.");
|
||||
}
|
||||
if (target.UsesMultisampleResolve && description.Color.Store != GpuStoreOp.Resolve)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A multisampled offscreen target must resolve into ColorTexture.");
|
||||
}
|
||||
if (target.UsesMultisampleResolve && description.Color.Load == GpuLoadOp.Load)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A transient multisample colour attachment cannot load the prior resolved image.");
|
||||
}
|
||||
if (!target.UsesMultisampleResolve && description.Color.Store == GpuStoreOp.Resolve)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A single-sampled offscreen target cannot use Store=Resolve.");
|
||||
}
|
||||
if (target.UsesMultisampleResolve && description.Depth?.Load == GpuLoadOp.Load)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A transient multisample depth attachment cannot load prior depth.");
|
||||
}
|
||||
if (target.Description.SampleableDepth
|
||||
&& description.Depth is { }
|
||||
&& description.Depth?.Store != GpuStoreOp.Store)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Sampleable depth requires Store=Store.");
|
||||
}
|
||||
}
|
||||
device.Record(new GpuRecordedPassBegin(description.Name, description.SampleCount, description.ViewMask));
|
||||
return new RecordingGpuPassEncoder(device, description);
|
||||
}
|
||||
|
||||
|
|
@ -351,6 +545,10 @@ internal sealed class RecordingGpuPassEncoder(RecordingGpuDevice device, GpuPass
|
|||
public void BindPipeline(IGpuPipeline pipeline)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pipeline);
|
||||
if (pipeline.Description.HasColorAttachment != Pass.HasColorAttachment)
|
||||
throw new InvalidOperationException("Pipeline and pass colour-attachment intents must match.");
|
||||
if (pipeline.Description.ViewMask != Pass.ViewMask)
|
||||
throw new InvalidOperationException("Pipeline and pass view masks must match.");
|
||||
device.Record(new GpuRecordedPipelineBind(pipeline.Description.Name));
|
||||
}
|
||||
|
||||
|
|
@ -407,7 +605,11 @@ internal sealed class RecordingGpuPassEncoder(RecordingGpuDevice device, GpuPass
|
|||
device.Record(new GpuRecordedMultiDrawIndirect(commands.Name, offsetBytes, drawCount, strideBytes));
|
||||
}
|
||||
|
||||
public IDisposable BeginTimerScope(string scopeName) => NullDisposable.Instance;
|
||||
public IDisposable BeginTimerScope(string scopeName)
|
||||
{
|
||||
device.Record(new GpuRecordedTimerScope(scopeName));
|
||||
return NullDisposable.Instance;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
|
@ -428,22 +630,50 @@ internal sealed class RecordingGpuPassEncoder(RecordingGpuDevice device, GpuPass
|
|||
}
|
||||
}
|
||||
|
||||
internal sealed class RecordingGpuBuffer(GpuBufferDescription description) : IGpuBuffer
|
||||
internal sealed class RecordingGpuBuffer : IGpuBuffer
|
||||
{
|
||||
private readonly byte[] _storage = new byte[description.SizeBytes];
|
||||
private readonly byte[] _storage;
|
||||
|
||||
public string Name { get; } = description.Name;
|
||||
internal RecordingGpuBuffer(
|
||||
GpuBufferDescription description,
|
||||
byte[]? storage = null)
|
||||
{
|
||||
if (storage is not null && storage.Length != description.SizeBytes)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"External recording storage must match the buffer size.",
|
||||
nameof(storage));
|
||||
}
|
||||
_storage = storage ?? new byte[description.SizeBytes];
|
||||
Name = description.Name;
|
||||
SizeBytes = description.SizeBytes;
|
||||
Usage = description.Usage;
|
||||
Residency = description.Residency;
|
||||
}
|
||||
|
||||
public long SizeBytes { get; } = description.SizeBytes;
|
||||
public string Name { get; }
|
||||
|
||||
public GpuBufferUsage Usage { get; } = description.Usage;
|
||||
public long SizeBytes { get; }
|
||||
|
||||
public GpuMemoryResidency Residency { get; } = description.Residency;
|
||||
public GpuBufferUsage Usage { get; }
|
||||
|
||||
public GpuMemoryResidency Residency { get; }
|
||||
|
||||
public bool HostWritesAreCoherent =>
|
||||
Residency == GpuMemoryResidency.HostWritable;
|
||||
|
||||
public bool IsDisposed { get; private set; }
|
||||
|
||||
public void Upload(long offsetBytes, ReadOnlySpan<byte> data) =>
|
||||
public int UploadCount { get; private set; }
|
||||
|
||||
public long UploadedBytes { get; private set; }
|
||||
|
||||
public void Upload(long offsetBytes, ReadOnlySpan<byte> data)
|
||||
{
|
||||
data.CopyTo(_storage.AsSpan((int)offsetBytes, data.Length));
|
||||
UploadCount++;
|
||||
UploadedBytes = checked(UploadedBytes + data.Length);
|
||||
}
|
||||
|
||||
public void CopyTo(IGpuBuffer destination, long sourceOffsetBytes, long destinationOffsetBytes, long byteCount)
|
||||
{
|
||||
|
|
@ -531,25 +761,95 @@ internal sealed class RecordingGpuRenderTarget : IGpuRenderTarget
|
|||
description.Height,
|
||||
layerCount: 1,
|
||||
mipLevelCount: 1);
|
||||
if (description.SampleableDepth && description.DepthFormat is { } depthFormat)
|
||||
{
|
||||
DepthTexture = new RecordingGpuTexture(
|
||||
$"{description.Name}-depth",
|
||||
GpuTextureKind.Texture2D,
|
||||
depthFormat,
|
||||
description.Width,
|
||||
description.Height,
|
||||
layerCount: 1,
|
||||
mipLevelCount: 1);
|
||||
}
|
||||
}
|
||||
|
||||
public GpuRenderTargetDescription Description { get; }
|
||||
|
||||
public IGpuTexture ColorTexture { get; }
|
||||
|
||||
public IGpuTexture? DepthTexture { get; }
|
||||
|
||||
/// <summary>The pass attachment sample count; exposed textures are always single-sampled.</summary>
|
||||
public int AttachmentSampleCount => Description.SampleCount;
|
||||
|
||||
public bool UsesMultisampleResolve => Description.SampleCount > 1;
|
||||
|
||||
public bool IsDisposed { get; private set; }
|
||||
|
||||
public void Dispose() => IsDisposed = true;
|
||||
public void Dispose()
|
||||
{
|
||||
if (IsDisposed)
|
||||
return;
|
||||
IsDisposed = true;
|
||||
ColorTexture.Dispose();
|
||||
DepthTexture?.Dispose();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal sealed class RecordingGpuDirectionalDepthTarget : IGpuDirectionalDepthTarget
|
||||
{
|
||||
public RecordingGpuDirectionalDepthTarget(GpuDirectionalDepthTargetDescription description)
|
||||
{
|
||||
Description = description;
|
||||
DepthTexture = new RecordingGpuTexture(
|
||||
$"{description.Name}-depth",
|
||||
GpuTextureKind.Texture2DArray,
|
||||
description.DepthFormat,
|
||||
description.Resolution,
|
||||
description.Resolution,
|
||||
description.LayerCount,
|
||||
mipLevelCount: 1);
|
||||
}
|
||||
|
||||
public GpuDirectionalDepthTargetDescription Description { get; }
|
||||
|
||||
public IGpuTexture DepthTexture { get; }
|
||||
|
||||
public bool IsDisposed { get; private set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (IsDisposed)
|
||||
return;
|
||||
IsDisposed = true;
|
||||
DepthTexture.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RecordingGpuTimerPool : IGpuTimerPool
|
||||
{
|
||||
public bool IsSupported => false;
|
||||
private readonly Dictionary<string, double> _resolved = new(StringComparer.Ordinal);
|
||||
|
||||
public bool IsSupported => true;
|
||||
|
||||
internal void SetResolved(string scopeName, double milliseconds) =>
|
||||
_resolved[scopeName] = milliseconds;
|
||||
|
||||
internal void ClearResolved() => _resolved.Clear();
|
||||
|
||||
public bool TryResolve(string scopeName, out double milliseconds)
|
||||
{
|
||||
milliseconds = 0d;
|
||||
return false;
|
||||
return _resolved.TryGetValue(scopeName, out milliseconds);
|
||||
}
|
||||
|
||||
public bool TryTakeResolved(string scopeName, out double milliseconds)
|
||||
{
|
||||
if (!_resolved.TryGetValue(scopeName, out milliseconds))
|
||||
return false;
|
||||
_resolved.Remove(scopeName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -140,6 +140,20 @@ public sealed class RecordingGpuDeviceTests
|
|||
Assert.Equal(0, device.OpenFrameCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimerMeasurementsCanBeInspectedOrConsumedExactlyOnce()
|
||||
{
|
||||
using RecordingGpuDevice device = new();
|
||||
device.RecordingTimers.SetResolved("pack-pass", 1.25);
|
||||
|
||||
Assert.True(device.Timers.TryResolve("pack-pass", out double inspected));
|
||||
Assert.Equal(1.25, inspected);
|
||||
Assert.True(device.Timers.TryTakeResolved("pack-pass", out double consumed));
|
||||
Assert.Equal(1.25, consumed);
|
||||
Assert.False(device.Timers.TryTakeResolved("pack-pass", out _));
|
||||
Assert.False(device.Timers.TryResolve("pack-pass", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReleasedTextureSlotsAreRecycledRatherThanLeaked()
|
||||
{
|
||||
|
|
@ -177,6 +191,98 @@ public sealed class RecordingGpuDeviceTests
|
|||
Assert.True(device.DefaultTextureSlot.IsAssigned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultisampledHdrTargetExposesSingleSampledColorAndOptionalDepthResults()
|
||||
{
|
||||
using RecordingGpuDevice device = new();
|
||||
device.Clear();
|
||||
var description = new GpuRenderTargetDescription(
|
||||
"hdr-world",
|
||||
1920,
|
||||
1080,
|
||||
GpuTextureFormat.Rgba16FloatRenderTarget,
|
||||
GpuTextureFormat.Depth24Stencil8,
|
||||
SampleCount: 4,
|
||||
SampleableDepth: true);
|
||||
|
||||
var target = Assert.IsType<RecordingGpuRenderTarget>(
|
||||
device.CreateRenderTarget(description));
|
||||
|
||||
Assert.Equal(description, target.Description);
|
||||
Assert.Equal(GpuTextureFormat.Rgba16FloatRenderTarget, target.ColorTexture.Format);
|
||||
Assert.NotNull(target.DepthTexture);
|
||||
Assert.Equal(GpuTextureFormat.Depth24Stencil8, target.DepthTexture!.Format);
|
||||
Assert.Equal(4, target.AttachmentSampleCount);
|
||||
Assert.True(target.UsesMultisampleResolve);
|
||||
Assert.Same(target, Assert.Single(device.CreatedRenderTargets));
|
||||
Assert.Equal(
|
||||
new GpuRecordedRenderTargetCreate(description),
|
||||
Assert.Single(device.Calls));
|
||||
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
using (frame.BeginPass(new GpuPassDescription
|
||||
{
|
||||
Name = "hdr-world",
|
||||
Color = new GpuColorAttachment(
|
||||
target,
|
||||
GpuLoadOp.Clear,
|
||||
GpuStoreOp.Resolve,
|
||||
Vector4.Zero),
|
||||
Depth = new GpuDepthAttachment(
|
||||
GpuLoadOp.Clear,
|
||||
GpuStoreOp.Store,
|
||||
1f,
|
||||
0),
|
||||
SampleCount = 4,
|
||||
}))
|
||||
{
|
||||
}
|
||||
frame.End();
|
||||
|
||||
target.Dispose();
|
||||
Assert.True(target.IsDisposed);
|
||||
Assert.True(Assert.IsType<RecordingGpuTexture>(target.ColorTexture).IsDisposed);
|
||||
Assert.True(Assert.IsType<RecordingGpuTexture>(target.DepthTexture).IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttachmentOnlyDepthIsNotExposedAndInvalidResolveContractsFailLoudly()
|
||||
{
|
||||
using RecordingGpuDevice device = new();
|
||||
var target = Assert.IsType<RecordingGpuRenderTarget>(
|
||||
device.CreateRenderTarget(new GpuRenderTargetDescription(
|
||||
"ordinary-offscreen",
|
||||
320,
|
||||
240,
|
||||
GpuTextureFormat.Rgba8UnormRenderTarget,
|
||||
GpuTextureFormat.Depth24Stencil8,
|
||||
SampleCount: 1)));
|
||||
Assert.Null(target.DepthTexture);
|
||||
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
Assert.Throws<InvalidOperationException>(() => frame.BeginPass(new GpuPassDescription
|
||||
{
|
||||
Name = "invalid-resolve",
|
||||
Color = new GpuColorAttachment(
|
||||
target,
|
||||
GpuLoadOp.Clear,
|
||||
GpuStoreOp.Resolve,
|
||||
Vector4.Zero),
|
||||
SampleCount = 1,
|
||||
}));
|
||||
frame.End();
|
||||
|
||||
Assert.Throws<ArgumentException>(() => device.CreateRenderTarget(
|
||||
new GpuRenderTargetDescription(
|
||||
"missing-depth",
|
||||
16,
|
||||
16,
|
||||
GpuTextureFormat.Rgba16FloatRenderTarget,
|
||||
DepthFormat: null,
|
||||
SampleCount: 1,
|
||||
SampleableDepth: true)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SamplersAreDeduplicatedByValue()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -90,8 +90,30 @@ public sealed class VulkanCapabilityGateTests
|
|||
Assert.True(record.IsSupported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionalAtmosphericFormatsDoNotRejectTheRetailRenderer()
|
||||
{
|
||||
VulkanCapabilityRecord record = SupportedRecord(
|
||||
formats: VulkanFormatSupport.Complete with
|
||||
{
|
||||
DepthStencilSampled = false,
|
||||
Rgba16FloatColorAttachment = false,
|
||||
Rgba16FloatSampled = false,
|
||||
Rgba16FloatLinearFilter = false,
|
||||
// A colour-only MSAA fact is not enough when sampling/filtering
|
||||
// is absent; the neutral projection must still expose zero.
|
||||
MaxRgba16FloatSampleCount = 8,
|
||||
});
|
||||
|
||||
Assert.Empty(record.SupportFailures);
|
||||
GpuCapabilityRecord projected = record.ToGpuCapabilityRecord();
|
||||
Assert.False(projected.SupportsRgba16FloatRenderTargets);
|
||||
Assert.Equal(0u, projected.MaxRgba16FloatSampleCount);
|
||||
Assert.False(projected.SupportsSampledDepth);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every field on <see cref="VulkanDeviceFeatureSupport"/> is mandatory, so
|
||||
/// Every required field on <see cref="VulkanDeviceFeatureSupport"/> is mandatory, so
|
||||
/// clearing any one of them must produce exactly one new failure. Driving
|
||||
/// this by reflection rather than by hand means a feature added to the record
|
||||
/// without a matching Evaluate clause fails here instead of shipping
|
||||
|
|
@ -103,6 +125,7 @@ public sealed class VulkanCapabilityGateTests
|
|||
IEnumerable<string> featureNames = typeof(VulkanDeviceFeatureSupport)
|
||||
.GetProperties()
|
||||
.Where(property => property.PropertyType == typeof(bool))
|
||||
.Where(property => property.Name != nameof(VulkanDeviceFeatureSupport.Multiview))
|
||||
.Select(property => property.Name);
|
||||
|
||||
foreach (string name in featureNames)
|
||||
|
|
@ -219,10 +242,11 @@ public sealed class VulkanCapabilityGateTests
|
|||
Assert.False(VulkanPipelineLayouts.IsDynamicStorageBinding(GpuBindingModel.StorageGlobalLights));
|
||||
Assert.False(VulkanPipelineLayouts.IsDynamicStorageBinding(GpuBindingModel.StorageClipRegions));
|
||||
|
||||
// Binding 9 (the GL-only uvec2 handle-table emulation, StorageTextureTable)
|
||||
// is deleted as of Campaign V slice V11 — the Vulkan backend always bound
|
||||
// set 2 instead and never touched it, so there is no longer a ninth
|
||||
// binding to assert never spends a scarce dynamic descriptor.
|
||||
// #226 adds binding 9 for the per-instance detail category. It is a
|
||||
// plain storage descriptor because the renderer supplies its exact
|
||||
// ring slice through the descriptor write rather than a dynamic offset.
|
||||
Assert.False(VulkanPipelineLayouts.IsDynamicStorageBinding(
|
||||
GpuBindingModel.StorageInstanceDetailCategory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -250,6 +274,41 @@ public sealed class VulkanCapabilityGateTests
|
|||
Assert.Empty(SupportedRecord().SupportFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiviewIsProjectedButDoesNotRejectTheAuthoritativeRenderer()
|
||||
{
|
||||
VulkanDeviceFeatureSupport reduced =
|
||||
VulkanDeviceFeatureSupport.Complete with { Multiview = false };
|
||||
VulkanCapabilityRecord record = SupportedRecord(features: reduced);
|
||||
Assert.True(record.IsSupported);
|
||||
Assert.False(record.ToGpuCapabilityRecord().SupportsMultiview);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADeviceWithTooFewTotalStorageDescriptorsIsRejectedPerSetAndPerStage()
|
||||
{
|
||||
VulkanCapabilityRecord perSet = SupportedRecord(
|
||||
limits: VulkanDeviceLimitSupport.Complete with
|
||||
{
|
||||
MaxDescriptorSetStorageBuffers =
|
||||
GpuBindingModel.StorageBindingCount - 1,
|
||||
});
|
||||
VulkanCapabilityRecord perStage = SupportedRecord(
|
||||
limits: VulkanDeviceLimitSupport.Complete with
|
||||
{
|
||||
MaxPerStageDescriptorStorageBuffers =
|
||||
GpuBindingModel.StorageBindingCount - 1,
|
||||
});
|
||||
|
||||
Assert.Contains(
|
||||
perSet.SupportFailures,
|
||||
failure => failure.Contains("total storage bindings", StringComparison.Ordinal));
|
||||
Assert.Contains(
|
||||
perStage.SupportFailures,
|
||||
failure => failure.Contains("each shader stage", StringComparison.Ordinal));
|
||||
Assert.Empty(SupportedRecord().SupportFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ATextureTableSmallerThanTheCapacityIsRejectedPerSetAndPerStage()
|
||||
{
|
||||
|
|
@ -546,8 +605,14 @@ public sealed class VulkanCapabilityGateTests
|
|||
limits: VulkanDeviceLimitSupport.Complete with
|
||||
{
|
||||
MinStorageBufferOffsetAlignment = 16,
|
||||
MaxStorageBufferRange = 192u * 1024u * 1024u,
|
||||
MinUniformBufferOffsetAlignment = 64,
|
||||
MaxColorSampleCount = 4,
|
||||
MaxImageDimension2D = 8192,
|
||||
MaxImageArrayLayers = 128,
|
||||
DeviceLocalHeapBytes = 6UL * 1024 * 1024 * 1024,
|
||||
MaxDescriptorSetStorageBuffers = 48,
|
||||
MaxPerStageDescriptorStorageBuffers = 32,
|
||||
MaxDescriptorSetUpdateAfterBindSampledImages = 500_000,
|
||||
MaxPerStageDescriptorUpdateAfterBindSampledImages = 16_384,
|
||||
});
|
||||
|
|
@ -558,11 +623,15 @@ public sealed class VulkanCapabilityGateTests
|
|||
Assert.Equal("AMD Radeon RX 9070 XT", projected.DeviceName);
|
||||
Assert.Equal("Vulkan 1.3.280", projected.ApiVersion);
|
||||
Assert.Equal(16u, projected.MinStorageBufferOffsetAlignment);
|
||||
Assert.Equal(192u * 1024u * 1024u, projected.MaxStorageBufferRangeBytes);
|
||||
Assert.Equal(64u, projected.MinUniformBufferOffsetAlignment);
|
||||
Assert.Equal(4u, projected.MaxSampleCount);
|
||||
Assert.Equal(8192u, projected.MaxImageDimension2D);
|
||||
Assert.Equal(128u, projected.MaxImageArrayLayers);
|
||||
Assert.Equal(6UL * 1024 * 1024 * 1024, projected.DeviceLocalMemoryBytes);
|
||||
// The table is limited by whichever of the two counts is smaller.
|
||||
Assert.Equal(16_384u, projected.MaxTextureTableSlots);
|
||||
Assert.Equal(GpuBindingModel.StorageBindingCount, projected.MaxStorageBufferBindings);
|
||||
Assert.Equal(32u, projected.MaxStorageBufferBindings);
|
||||
Assert.True(projected.SupportsMultiDrawIndirect);
|
||||
Assert.True(projected.SupportsDrawParameters);
|
||||
Assert.True(projected.SupportsTextureCompressionBc);
|
||||
|
|
@ -570,6 +639,10 @@ public sealed class VulkanCapabilityGateTests
|
|||
// The whole point of the campaign's CPU target: per-frame data written
|
||||
// straight into mapped memory rather than copied through BufferSubData.
|
||||
Assert.True(projected.SupportsPersistentlyMappedRings);
|
||||
Assert.True(projected.SupportsRgba16FloatRenderTargets);
|
||||
Assert.Equal(4u, projected.MaxRgba16FloatSampleCount);
|
||||
Assert.True(projected.SupportsSampledDepth);
|
||||
Assert.True(projected.SupportsMultiview);
|
||||
Assert.Empty(projected.SupportFailures);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
||||
|
||||
public sealed class VulkanDirectionalMultiviewContractTests
|
||||
{
|
||||
[Fact]
|
||||
public void LowMaskCoversBothAttachmentLayersInOneBarrierRange()
|
||||
{
|
||||
VulkanDirectionalMultiviewRange range =
|
||||
VulkanDirectionalMultiviewContract.Resolve(0b11, 2);
|
||||
|
||||
Assert.Equal(0u, range.BaseLayer);
|
||||
Assert.Equal(2u, range.LayerCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u)]
|
||||
[InlineData(0b01u)]
|
||||
[InlineData(0b10u)]
|
||||
[InlineData(0b111u)]
|
||||
public void PartialOrExtraMaskFailsBeforeRecording(uint viewMask)
|
||||
{
|
||||
Assert.Throws<NotSupportedException>(() =>
|
||||
VulkanDirectionalMultiviewContract.Resolve(viewMask, 2));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
||||
|
||||
public sealed class VulkanDrawBindingStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void FirstDrawBinds_IdenticalDrawReuses_ChangedInputsRebind()
|
||||
{
|
||||
var state = new VulkanDrawBindingState();
|
||||
|
||||
Assert.True(state.RequiresBind(pipelineLayout: 10, packGeneration: 3));
|
||||
state.MarkBound(pipelineLayout: 10, packGeneration: 3);
|
||||
Assert.False(state.RequiresBind(pipelineLayout: 10, packGeneration: 3));
|
||||
|
||||
state.MarkDirty();
|
||||
Assert.True(state.RequiresBind(pipelineLayout: 10, packGeneration: 3));
|
||||
state.MarkBound(pipelineLayout: 10, packGeneration: 3);
|
||||
Assert.True(state.RequiresBind(pipelineLayout: 11, packGeneration: 3));
|
||||
Assert.True(state.RequiresBind(pipelineLayout: 10, packGeneration: 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PackAndRetailLayoutsCannotReuseEachOthersBinding()
|
||||
{
|
||||
var state = new VulkanDrawBindingState();
|
||||
|
||||
state.MarkBound(pipelineLayout: 20, packGeneration: 7);
|
||||
|
||||
Assert.True(state.RequiresBind(pipelineLayout: 21, packGeneration: 0));
|
||||
state.MarkBound(pipelineLayout: 21, packGeneration: 0);
|
||||
Assert.True(state.RequiresBind(pipelineLayout: 20, packGeneration: 7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmChecksAllocateZero()
|
||||
{
|
||||
var state = new VulkanDrawBindingState();
|
||||
state.MarkBound(pipelineLayout: 30, packGeneration: 8);
|
||||
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (int iteration = 0; iteration < 10_000; iteration++)
|
||||
{
|
||||
Assert.False(state.RequiresBind(
|
||||
pipelineLayout: 30,
|
||||
packGeneration: 8));
|
||||
}
|
||||
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
|
||||
Assert.Equal(0, allocated);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
using System.Reflection;
|
||||
using System.Reflection.Emit;
|
||||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
using AcDream.App.Tests.Architecture;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
||||
|
||||
public sealed class VulkanGraphicsContextAcquisitionTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProductionAcquireProbesSelectedPhysicalDeviceBeforeLogicalDeviceCreation()
|
||||
{
|
||||
MethodInfo acquire = RequiredMethod(nameof(VulkanGraphicsContext.Acquire));
|
||||
IReadOnlyList<CompiledCall> acquireCalls = CompiledCallGraph.Read(acquire);
|
||||
int createInstance = IndexOf(acquireCalls, "CreateInstanceAndSurface");
|
||||
int selectAndGate = IndexOf(acquireCalls, "SelectDeviceAndGate");
|
||||
int createRhiDevice = IndexOf(acquireCalls, "CreateDevice");
|
||||
|
||||
Assert.True(createInstance >= 0);
|
||||
Assert.True(selectAndGate > createInstance);
|
||||
Assert.True(createRhiDevice > selectAndGate);
|
||||
|
||||
MethodInfo select = RequiredMethod("SelectDeviceAndGate");
|
||||
CompiledCall featureProbe = Assert.Single(
|
||||
CompiledCallGraph.Read(select),
|
||||
call => call.Target.DeclaringType == typeof(VulkanPhysicalDeviceInspector)
|
||||
&& call.Target.Name == nameof(VulkanPhysicalDeviceInspector.ReadFeatures));
|
||||
CompiledCall logicalDeviceCreate = Assert.Single(
|
||||
CompiledCallGraph.Read(select),
|
||||
call => call.Target.DeclaringType == typeof(VulkanLogicalDeviceFactory)
|
||||
&& call.Target.Name == nameof(VulkanLogicalDeviceFactory.Create));
|
||||
FieldInfo features = typeof(VulkanGraphicsContext).GetField(
|
||||
"_features",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
CompiledFieldReference featurePublication = Assert.Single(
|
||||
CompiledCallGraph.ReadFieldReferences(select),
|
||||
reference => reference.Field == features && reference.OpCode == OpCodes.Stfld);
|
||||
|
||||
Assert.True(
|
||||
featureProbe.Offset < featurePublication.Offset,
|
||||
"The selected physical-device feature probe must precede publication to the context.");
|
||||
Assert.True(
|
||||
featurePublication.Offset < logicalDeviceCreate.Offset,
|
||||
"The production acquisition path must publish probed features before logical-device creation consumes them.");
|
||||
}
|
||||
|
||||
private static MethodInfo RequiredMethod(string name) =>
|
||||
typeof(VulkanGraphicsContext).GetMethod(
|
||||
name,
|
||||
BindingFlags.Static | BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException($"Missing VulkanGraphicsContext.{name}.");
|
||||
|
||||
private static int IndexOf(IReadOnlyList<CompiledCall> calls, string methodName) =>
|
||||
calls
|
||||
.Select((call, index) => (call, index))
|
||||
.Where(value => value.call.Target.DeclaringType == typeof(VulkanGraphicsContext)
|
||||
&& value.call.Target.Name == methodName)
|
||||
.Select(value => value.index)
|
||||
.DefaultIfEmpty(-1)
|
||||
.Single();
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
using Silk.NET.Vulkan;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
||||
|
||||
public sealed class VulkanHostStorageVisibilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void RetainedTransformBarrier_PublishesHostWritesToVertexShaderReads()
|
||||
{
|
||||
BufferMemoryBarrier2 barrier = VulkanHostStorageVisibility.Create(
|
||||
default,
|
||||
4u * 1024u * 1024u);
|
||||
|
||||
Assert.Equal(StructureType.BufferMemoryBarrier2, barrier.SType);
|
||||
Assert.Equal(PipelineStageFlags2.HostBit, barrier.SrcStageMask);
|
||||
Assert.Equal(AccessFlags2.HostWriteBit, barrier.SrcAccessMask);
|
||||
Assert.Equal(PipelineStageFlags2.VertexShaderBit, barrier.DstStageMask);
|
||||
Assert.Equal(AccessFlags2.ShaderReadBit, barrier.DstAccessMask);
|
||||
Assert.Equal(Silk.NET.Vulkan.Vk.QueueFamilyIgnored, barrier.SrcQueueFamilyIndex);
|
||||
Assert.Equal(Silk.NET.Vulkan.Vk.QueueFamilyIgnored, barrier.DstQueueFamilyIndex);
|
||||
Assert.Equal(0ul, barrier.Offset);
|
||||
Assert.Equal(4ul * 1024ul * 1024ul, barrier.Size);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedTransformBarrier_RejectsAnEmptyBinding()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
VulkanHostStorageVisibility.Create(default, 0));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
using Silk.NET.Vulkan;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
||||
|
||||
public sealed class VulkanRenderFailurePolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void ManagedOutOfMemoryAndNestedFatalFailuresAreTerminal()
|
||||
{
|
||||
Assert.True(VulkanRenderFailurePolicy.IsFatal(new OutOfMemoryException("managed")));
|
||||
Assert.True(VulkanRenderFailurePolicy.IsFatal(new InvalidOperationException(
|
||||
"wrapper",
|
||||
new VulkanCallException("nested", Result.ErrorDeviceLost))));
|
||||
Assert.True(VulkanRenderFailurePolicy.IsFatal(new AggregateException(
|
||||
new InvalidOperationException("ordinary"),
|
||||
new VulkanCallException("nested", Result.ErrorOutOfDeviceMemory))));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Result.ErrorDeviceLost)]
|
||||
[InlineData(Result.ErrorOutOfHostMemory)]
|
||||
[InlineData(Result.ErrorOutOfDeviceMemory)]
|
||||
[InlineData(Result.ErrorSurfaceLostKhr)]
|
||||
public void TerminalVulkanResultsCannotFallBackToAnotherGraph(Result result) =>
|
||||
Assert.True(VulkanRenderFailurePolicy.IsFatal(
|
||||
new VulkanCallException("test", result)));
|
||||
|
||||
[Fact]
|
||||
public void OrdinaryPackAndNonTerminalVulkanFailuresMayBeQuarantined()
|
||||
{
|
||||
Assert.False(VulkanRenderFailurePolicy.IsFatal(
|
||||
new InvalidOperationException("pack bug")));
|
||||
Assert.False(VulkanRenderFailurePolicy.IsFatal(
|
||||
new VulkanCallException("pack pipeline", Result.ErrorFormatNotSupported)));
|
||||
}
|
||||
}
|
||||
|
|
@ -126,6 +126,12 @@ public sealed class VulkanShaderDescriptorContractTests
|
|||
.SelectMany(ReadResources),
|
||||
];
|
||||
|
||||
private static bool IsOptInPackShaderModule(string module) =>
|
||||
module.StartsWith("atmospheric_", StringComparison.Ordinal)
|
||||
|| module.StartsWith("directional_shadow_", StringComparison.Ordinal)
|
||||
|| module.StartsWith("mesh_atmospheric.", StringComparison.Ordinal)
|
||||
|| module.StartsWith("terrain_atmospheric.", StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// The regression itself, named. <c>TerrainClip</c> is the only uniform block
|
||||
/// <c>terrain_modern.vert</c> declares besides <c>SceneLighting</c>, so
|
||||
|
|
@ -153,8 +159,8 @@ public sealed class VulkanShaderDescriptorContractTests
|
|||
|
||||
/// <summary>
|
||||
/// The general rule the specific case is an instance of: a uniform block may
|
||||
/// only live at a binding <see cref="VulkanPipelineLayouts.CreateUniformSetLayout"/>
|
||||
/// actually declares, in the set it declares them in.
|
||||
/// only live in retail set 1 at a retail binding, or in opt-in set 3 at one
|
||||
/// of render-pack ABI v1's sparse bindings 5..8.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EveryUniformBlockLandsAtADeclaredUniformBinding()
|
||||
|
|
@ -164,13 +170,31 @@ public sealed class VulkanShaderDescriptorContractTests
|
|||
.. AllResources()
|
||||
.Where(r => r.StorageClass == SpirvStorageClass.Uniform)
|
||||
.Where(r =>
|
||||
r.Set != GpuBindingModel.UniformSet
|
||||
|| r.Binding is null
|
||||
|| !VulkanPipelineLayouts.IsDeclaredUniformBinding(r.Binding.Value))
|
||||
r.Binding is null
|
||||
|| !(r.Set == GpuBindingModel.UniformSet
|
||||
&& VulkanPipelineLayouts.IsDeclaredUniformBinding(r.Binding.Value))
|
||||
&& !(r.Set == GpuBindingModel.RenderPackUniformSet
|
||||
&& VulkanPipelineLayouts.IsDeclaredPackUniformBinding(r.Binding.Value)))
|
||||
.Select(r =>
|
||||
$"{r.Module}: uniform block %{r.Id} is at set {r.Set?.ToString() ?? "(none)"} "
|
||||
+ $"binding {r.Binding?.ToString() ?? "(none)"}; set 1 declares "
|
||||
+ $"[{string.Join(", ", VulkanPipelineLayouts.DeclaredUniformBindings)}]."),
|
||||
+ $"binding {r.Binding?.ToString() ?? "(none)"}; retail set 1 declares "
|
||||
+ $"[{string.Join(", ", VulkanPipelineLayouts.DeclaredUniformBindings)}] "
|
||||
+ "and opt-in set 3 declares [5, 6, 7, 8]."),
|
||||
];
|
||||
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailShaderModulesNeverDeclareOptInSetThree()
|
||||
{
|
||||
string[] violations =
|
||||
[
|
||||
.. AllResources()
|
||||
.Where(r => r.Set == GpuBindingModel.RenderPackUniformSet)
|
||||
.Where(r => !IsOptInPackShaderModule(r.Module))
|
||||
.Select(r =>
|
||||
$"{r.Module}: resource %{r.Id} declares opt-in set 3 binding {r.Binding}."),
|
||||
];
|
||||
|
||||
Assert.Empty(violations);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,32 @@ namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
|||
/// </summary>
|
||||
public sealed class VulkanShaderManifestTests
|
||||
{
|
||||
// Exact binaries from the pre-campaign retail-authoritative renderer at
|
||||
// 5ca029d3. New opt-in pack shaders may be added, but recompiling these
|
||||
// with a different toolchain is itself an unreviewed default-path change.
|
||||
private static readonly IReadOnlyDictionary<string, string> RetailOracleSpirvSha256 =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["debug_line.frag.spv"] = "02fc04880bc5eb74353566f914675244038125c71443964decdc28e8199264df",
|
||||
["debug_line.vert.spv"] = "f9c6a9b575bb07a426fb6ade677bca96a7752ca6b120e8f6451363ba73b51140",
|
||||
["mesh_modern.frag.spv"] = "b702b644862aca31ce1fb0677adc5872b39c4ea87f595a89363b44d10f2cc50e",
|
||||
["mesh_modern.vert.spv"] = "7ca5fb241c4f0248884ba8fa88fbae17a7d5cbc80efe4ac4a9ffd0254012ead8",
|
||||
["particle.frag.spv"] = "680da227704e0b3afa9b5226a7d73dd65aa9d8759d081cf4d5009d30e148726b",
|
||||
["particle.vert.spv"] = "ed79461ab347bf17edaca714bbbbfabead8192e059c760578ca3a1a01409799e",
|
||||
["particle_mesh.frag.spv"] = "7696b1dc0613b5a724c55df465173f613ae047da9675895b149b7c71b009cc7c",
|
||||
["particle_mesh.vert.spv"] = "f7fe8b203cadcd4d54af5cdbcfd9d5bf733146e10bafa78ca730fb6970db0479",
|
||||
["portal_depth.frag.spv"] = "96755196d4d0da7be4792107557465778be2ebefb5584834cc75bf90ec55a6cc",
|
||||
["portal_depth.vert.spv"] = "cd113860b7acd6afad3ebcc0a68dd7147f6baae729df51ab360c123588dc3ae2",
|
||||
["sky.frag.spv"] = "ae0d9e3e1e1b5742dd986cb39c62ea6e71e19783feea8b86a5cd940504d6047e",
|
||||
["sky.vert.spv"] = "77176cf33c761ee4e9730357895c941dbf5949d8e0d28e0bb0dcde87f4d30288",
|
||||
["terrain_modern.frag.spv"] = "7b3cdb01b837ed77ee20559a81c1ce5c9d5395300efcc072560ab0be3c5a1af9",
|
||||
["terrain_modern.vert.spv"] = "9f4cb221ea6aed94a8d23af6cb8e3f3ed96c3cce6e50d135a72d3b55667b1557",
|
||||
["ui_text.frag.spv"] = "37a281bf80441cb425eaa3ad8e0b3a43cfa21b74b60973ed4201718b9dc102df",
|
||||
["ui_text.vert.spv"] = "018ac64477cf7d4c3fc0c5878951b148c7bfeb6ee3a7eebb02381d7904877798",
|
||||
["vk_probe.frag.spv"] = "c2dedbcc6dcc89744707b4b47138f1c31b38ef9088e584f1da07dd6953586c42",
|
||||
["vk_probe.vert.spv"] = "6c3260b45644033d607727cbd2e11fb4f60eb4a5b18bfd0997710f2ca518a023",
|
||||
};
|
||||
|
||||
private sealed record StageEntry(string Stage, string SourceSha256, bool Compiled, string? Message);
|
||||
|
||||
private sealed record ShaderEntry(string Name, bool VulkanReady, IReadOnlyList<StageEntry> Stages);
|
||||
|
|
@ -68,10 +94,51 @@ public sealed class VulkanShaderManifestTests
|
|||
{
|
||||
// Line endings are normalised before hashing so a checkout with a
|
||||
// different core.autocrlf setting does not report every shader stale.
|
||||
string text = File.ReadAllText(path).Replace("\r\n", "\n");
|
||||
string text = File.ReadAllText(path);
|
||||
if (text.Contains("#include \"", StringComparison.Ordinal))
|
||||
{
|
||||
text = ExpandIncludes(
|
||||
text,
|
||||
Path.GetDirectoryName(path)!,
|
||||
[]);
|
||||
}
|
||||
text = text.Replace("\r\n", "\n", StringComparison.Ordinal);
|
||||
return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text)));
|
||||
}
|
||||
|
||||
private static string ExpandIncludes(
|
||||
string source,
|
||||
string sourceDirectory,
|
||||
HashSet<string> active)
|
||||
{
|
||||
var output = new StringBuilder();
|
||||
foreach (string line in source.Replace("\r\n", "\n").Split('\n'))
|
||||
{
|
||||
string trimmed = line.Trim();
|
||||
if (!trimmed.StartsWith("#include \"", StringComparison.Ordinal)
|
||||
|| !trimmed.EndsWith('"'))
|
||||
{
|
||||
output.AppendLine(line);
|
||||
continue;
|
||||
}
|
||||
string key = trimmed[10..^1];
|
||||
Assert.DoesNotContain("..", key, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain('\\', key);
|
||||
string include = Path.GetFullPath(Path.Combine(sourceDirectory, key));
|
||||
Assert.StartsWith(
|
||||
Path.GetFullPath(sourceDirectory) + Path.DirectorySeparatorChar,
|
||||
include,
|
||||
StringComparison.Ordinal);
|
||||
Assert.True(File.Exists(include), $"GLSL include '{key}' is missing.");
|
||||
Assert.True(active.Add(include), $"GLSL include '{key}' is cyclic.");
|
||||
output.AppendLine($"// ---- begin include: {key} ----");
|
||||
output.Append(ExpandIncludes(File.ReadAllText(include), sourceDirectory, active));
|
||||
output.AppendLine($"// ---- end include: {key} ----");
|
||||
active.Remove(include);
|
||||
}
|
||||
return output.ToString();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryGlslPairIsRecordedInTheManifest()
|
||||
{
|
||||
|
|
@ -87,6 +154,18 @@ public sealed class VulkanShaderManifestTests
|
|||
Assert.Equal(pairs, manifest.Shaders.Select(shader => shader.Name).OrderBy(n => n, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreCampaignRetailSpirvBinariesRemainByteExact()
|
||||
{
|
||||
foreach ((string fileName, string expectedSha256) in RetailOracleSpirvSha256)
|
||||
{
|
||||
string path = Path.Combine(SpirvDirectory(), fileName);
|
||||
Assert.True(File.Exists(path), $"Retail shader oracle '{fileName}' is missing.");
|
||||
string actual = Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(path)));
|
||||
Assert.Equal(expectedSha256, actual);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommittedSpirvIsNotStaleAgainstItsGlslSource()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -146,6 +146,9 @@ public sealed class VulkanViewportMappingTests
|
|||
Assert.Equal(
|
||||
(BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha),
|
||||
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.InverseAlpha));
|
||||
Assert.Equal(
|
||||
(BlendFactor.DstColor, BlendFactor.OneMinusSrcAlpha),
|
||||
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.RetailDetail));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
||||
|
||||
public sealed class VulkanWorldPassScopeTests
|
||||
{
|
||||
[Fact]
|
||||
public void OrdinaryPublicationStartsWithEmptyFrameSections()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using IGpuBuffer buffer = Buffer(device);
|
||||
var scope = new VulkanWorldPassScope(sampleCount: 1);
|
||||
scope.Sections.SceneLighting = new GpuBufferSection(buffer, 256, 576);
|
||||
|
||||
using (scope.Publish(Encoder(device)))
|
||||
Assert.False(scope.Sections.SceneLighting.IsValid);
|
||||
|
||||
Assert.False(scope.Sections.SceneLighting.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreparedPublicationPreservesCurrentFrameSectionsUntilDispose()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using IGpuBuffer buffer = Buffer(device);
|
||||
var scope = new VulkanWorldPassScope(sampleCount: 1);
|
||||
var lighting = new GpuBufferSection(buffer, 256, 576);
|
||||
scope.Sections.SceneLighting = lighting;
|
||||
|
||||
using (scope.PublishPrepared(Encoder(device)))
|
||||
Assert.Equal(lighting, scope.Sections.SceneLighting);
|
||||
|
||||
Assert.False(scope.Sections.SceneLighting.IsValid);
|
||||
}
|
||||
|
||||
private static IGpuBuffer Buffer(RecordingGpuDevice device) =>
|
||||
device.CreateBuffer(new GpuBufferDescription(
|
||||
"prepared-lighting",
|
||||
1024,
|
||||
GpuBufferUsage.Uniform,
|
||||
GpuMemoryResidency.HostWritable));
|
||||
|
||||
private static IGpuPassEncoder Encoder(RecordingGpuDevice device) =>
|
||||
new RecordingGpuPassEncoder(
|
||||
device,
|
||||
new GpuPassDescription
|
||||
{
|
||||
Name = "world",
|
||||
Color = new GpuColorAttachment(
|
||||
Target: null,
|
||||
GpuLoadOp.Clear,
|
||||
GpuStoreOp.Store,
|
||||
default),
|
||||
Depth = null,
|
||||
SampleCount = 1,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
using AcDream.App.Rendering.Scene.Arch;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
|
@ -40,6 +42,43 @@ public sealed class LiveRenderProjectionJournalTests
|
|||
Assert.True((registered.Record.Flags & RenderProjectionFlags.Draw) != 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntityReady_RetainsExactLocalPlayerIdentityAtRenderPublication()
|
||||
{
|
||||
Harness harness = CreateHarness(loadLandblock: true, localPlayerGuid: Guid);
|
||||
LiveEntityRecord record = Materialize(harness, Guid, instance: 12);
|
||||
|
||||
Assert.True(harness.Projections.OnEntityReady(
|
||||
LiveEntityReadyCandidate.Capture(record)));
|
||||
|
||||
Assert.Equal(
|
||||
RenderCasterIdentityKind.LocalPlayer,
|
||||
Assert.Single(harness.Journal.Pending.ToArray())
|
||||
.Record.EntityPayload.CasterIdentity);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x80000001u, null, null, 0x80000001u, (byte)RenderCasterIdentityKind.LocalPlayer)]
|
||||
[InlineData(0x80000001u, null, 0x8u, 0u, (byte)RenderCasterIdentityKind.RemotePlayer)]
|
||||
[InlineData(0x50000001u, null, null, 0u, (byte)RenderCasterIdentityKind.RemotePlayer)]
|
||||
[InlineData(0x80000001u, (uint)ItemType.Creature, null, 0u, (byte)RenderCasterIdentityKind.NonPlayerCreature)]
|
||||
[InlineData(0x80000001u, (uint)ItemType.Misc, null, 0u, (byte)RenderCasterIdentityKind.OtherLiveDynamic)]
|
||||
public void CasterIdentityClassifier_UsesOnlyAuthoritativeSpawnFacts(
|
||||
uint serverGuid,
|
||||
uint? itemType,
|
||||
uint? objectDescriptionFlags,
|
||||
uint localPlayerGuid,
|
||||
byte expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
(RenderCasterIdentityKind)expected,
|
||||
RenderCasterIdentityClassifier.Classify(
|
||||
serverGuid,
|
||||
itemType,
|
||||
objectDescriptionFlags,
|
||||
localPlayerGuid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingToLoadedAndLoadedToLoaded_RebucketWithoutLogicalRecreate()
|
||||
{
|
||||
|
|
@ -347,7 +386,9 @@ public sealed class LiveRenderProjectionJournalTests
|
|||
Assert.Equal(0, harness.Projections.ProjectionCount);
|
||||
}
|
||||
|
||||
private static Harness CreateHarness(bool loadLandblock)
|
||||
private static Harness CreateHarness(
|
||||
bool loadLandblock,
|
||||
uint localPlayerGuid = 0)
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
if (loadLandblock)
|
||||
|
|
@ -360,10 +401,15 @@ public sealed class LiveRenderProjectionJournalTests
|
|||
var runtime = LiveEntityRuntimeFixture.Create(state, resources);
|
||||
var journal = new RenderProjectionJournal(
|
||||
RenderSceneGeneration.FromRaw(1));
|
||||
var localPlayer = new LocalPlayerIdentityState
|
||||
{
|
||||
ServerGuid = localPlayerGuid,
|
||||
};
|
||||
var projections = new LiveRenderProjectionJournal(
|
||||
runtime,
|
||||
journal,
|
||||
new GpuWorldRenderTraversalOrderSource(state));
|
||||
new GpuWorldRenderTraversalOrderSource(state),
|
||||
localPlayer);
|
||||
sink = projections;
|
||||
var scene = new ArchRenderScene(RenderSceneGeneration.FromRaw(1));
|
||||
return new Harness(state, runtime, journal, projections, scene);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
public sealed class AtmosphericAutoQualityControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Downgrade_requires_long_consecutive_over_budget_window()
|
||||
{
|
||||
var controller = new AtmosphericAutoQualityController(
|
||||
AtmosphericQualityLevel.High);
|
||||
var sample = new AtmosphericQualityMeasurement(
|
||||
InclusivePackGpuMillisecondsP99: 7.0,
|
||||
IncrementalCpuMillisecondsP99: 1.1,
|
||||
ResidentGpuBytes: 250L * 1024 * 1024,
|
||||
StableFrameBoundary: true);
|
||||
|
||||
for (int i = 0;
|
||||
i < AtmosphericAutoQualityController.DowngradeHysteresisFrames - 1;
|
||||
i++)
|
||||
controller.Observe(in sample);
|
||||
Assert.Equal(AtmosphericQualityLevel.High, controller.Snapshot.Current);
|
||||
|
||||
AtmosphericAutoQualitySnapshot changed = controller.Observe(in sample);
|
||||
Assert.Equal(AtmosphericQualityLevel.Medium, changed.Current);
|
||||
Assert.Equal(AtmosphericAutoQualityController.ChangeCooldownFrames,
|
||||
changed.CooldownFramesRemaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unstable_frames_never_advance_hysteresis()
|
||||
{
|
||||
var controller = new AtmosphericAutoQualityController(
|
||||
AtmosphericQualityLevel.High);
|
||||
var sample = new AtmosphericQualityMeasurement(20, 20, 0, false);
|
||||
|
||||
for (int i = 0; i < 1000; i++)
|
||||
controller.Observe(in sample);
|
||||
|
||||
Assert.Equal(AtmosphericQualityLevel.High, controller.Snapshot.Current);
|
||||
Assert.Equal(0, controller.Snapshot.ConsecutiveOverBudgetFrames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Upgrade_requires_nine_hundred_headroom_frames()
|
||||
{
|
||||
var controller = new AtmosphericAutoQualityController(
|
||||
AtmosphericQualityLevel.Low);
|
||||
var sample = new AtmosphericQualityMeasurement(
|
||||
InclusivePackGpuMillisecondsP99: 0.5,
|
||||
IncrementalCpuMillisecondsP99: 0.1,
|
||||
ResidentGpuBytes: 16L * 1024 * 1024,
|
||||
StableFrameBoundary: true);
|
||||
|
||||
for (int i = 0;
|
||||
i < AtmosphericAutoQualityController.UpgradeHysteresisFrames - 1;
|
||||
i++)
|
||||
controller.Observe(in sample);
|
||||
Assert.Equal(AtmosphericQualityLevel.Low, controller.Snapshot.Current);
|
||||
|
||||
Assert.Equal(
|
||||
AtmosphericQualityLevel.Medium,
|
||||
controller.Observe(in sample).Current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LowRequestsWholePackFallbackWithoutDroppingHeadlineSemantics()
|
||||
{
|
||||
var controller = new AtmosphericAutoQualityController(
|
||||
AtmosphericQualityLevel.Low);
|
||||
var sample = new AtmosphericQualityMeasurement(100, 100, long.MaxValue, true);
|
||||
|
||||
for (int i = 0;
|
||||
i < AtmosphericAutoQualityController.DowngradeHysteresisFrames - 1;
|
||||
i++)
|
||||
controller.Observe(in sample);
|
||||
|
||||
Assert.Equal(AtmosphericQualityLevel.Low, controller.Snapshot.Current);
|
||||
Assert.False(controller.Snapshot.SafeFallbackToRetailRequested);
|
||||
|
||||
AtmosphericAutoQualitySnapshot fallback = controller.Observe(in sample);
|
||||
|
||||
Assert.Equal(AtmosphericQualityLevel.Low, fallback.Current);
|
||||
Assert.True(fallback.SafeFallbackToRetailRequested);
|
||||
Assert.Equal(
|
||||
DirectionalShadowSemantics.Headline,
|
||||
DirectionalShadowQuality.For(DirectionalShadowPreset.Low).Semantics);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PackDeclaredBudgetsAreTheAutomaticQualityAuthority()
|
||||
{
|
||||
AtmosphericQualityBudget[] budgets =
|
||||
[
|
||||
new(0.5, 0.1, 16 * 1024 * 1024),
|
||||
new(1.0, 0.2, 32 * 1024 * 1024),
|
||||
new(1.5, 0.3, 64 * 1024 * 1024),
|
||||
];
|
||||
var controller = new AtmosphericAutoQualityController(
|
||||
budgets,
|
||||
AtmosphericQualityLevel.High);
|
||||
var sample = new AtmosphericQualityMeasurement(
|
||||
InclusivePackGpuMillisecondsP99: 1.6,
|
||||
IncrementalCpuMillisecondsP99: 0.1,
|
||||
ResidentGpuBytes: 8 * 1024 * 1024,
|
||||
StableFrameBoundary: true);
|
||||
|
||||
for (int i = 0; i < AtmosphericAutoQualityController.DowngradeHysteresisFrames; i++)
|
||||
controller.Observe(in sample);
|
||||
|
||||
Assert.Equal(AtmosphericQualityLevel.Medium, controller.Snapshot.Current);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
using System.Diagnostics;
|
||||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.App.Tests.Architecture;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
public sealed class AtmosphericCpuStageProfilerTests
|
||||
{
|
||||
[Fact]
|
||||
public void LowProfilerSamplesOneFrameInFour()
|
||||
{
|
||||
long[] measured = Enumerable.Range(1, 12)
|
||||
.Where(value => AtmosphericCpuStageProfiler.ShouldMeasure(value))
|
||||
.Select(value => (long)value)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal([4L, 8L, 12L], measured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotPublishesNonOverlappingStagesTotalAndResidual()
|
||||
{
|
||||
var profiler = new AtmosphericCpuStageProfiler(capacity: 4);
|
||||
var frame = new AtmosphericCpuStageFrame(
|
||||
FrameSerial: 4,
|
||||
ShadowCasterBuildTicks: Ticks(20),
|
||||
ShadowEnvironmentTicks: Ticks(30),
|
||||
ShadowPreparedDrawsAndTransformsTicks: Ticks(40),
|
||||
ShadowFitAndUniformTicks: Ticks(50),
|
||||
ShadowLayeredPassRecordingTicks: Ticks(60),
|
||||
ShadowBookkeepingTicks: Ticks(70),
|
||||
PostSetupAndOtherTicks: Ticks(80),
|
||||
PostSunRaysTicks: Ticks(90),
|
||||
PostFilmicTicks: Ticks(100));
|
||||
|
||||
profiler.Observe(
|
||||
in frame,
|
||||
targetPreparationTicks: Ticks(10),
|
||||
measuredPackTotalTicks: Ticks(600),
|
||||
observeBookkeepingTicks: Ticks(110));
|
||||
|
||||
IReadOnlyDictionary<string, RenderPackCpuStageDiagnostics> stages = profiler
|
||||
.Snapshot()
|
||||
.ToDictionary(value => value.Stage, StringComparer.Ordinal);
|
||||
Assert.Equal(13, stages.Count);
|
||||
Assert.Equal(1, stages["shadow-layered-pass-recording"].SampleCount);
|
||||
Assert.Equal(0.060, stages["shadow-layered-pass-recording"].CpuMillisecondsP50, 3);
|
||||
Assert.Equal(0.110, stages["performance-observe-bookkeeping"].CpuMillisecondsP50, 3);
|
||||
Assert.Equal(0.600, stages["measured-pack-total"].CpuMillisecondsP50, 3);
|
||||
Assert.Equal(0.050, stages["measured-pack-unattributed"].CpuMillisecondsP50, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmedObservationAllocatesNothing()
|
||||
{
|
||||
var profiler = new AtmosphericCpuStageProfiler(capacity: 2048);
|
||||
var frame = new AtmosphericCpuStageFrame(
|
||||
4,
|
||||
Ticks(1), Ticks(2), Ticks(3), Ticks(4), Ticks(5),
|
||||
Ticks(6), Ticks(7), Ticks(8), Ticks(9));
|
||||
|
||||
ZeroAllocationProbe.AssertAllocatesNothing(
|
||||
"AtmosphericCpuStageProfiler.Observe",
|
||||
() => profiler.Observe(
|
||||
in frame,
|
||||
targetPreparationTicks: Ticks(10),
|
||||
measuredPackTotalTicks: Ticks(100),
|
||||
observeBookkeepingTicks: Ticks(11)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionWorldPhaseCompletesTheSampledProfileAfterObservation()
|
||||
{
|
||||
var render = typeof(VulkanWorldScenePhase).GetMethod(nameof(VulkanWorldScenePhase.Render))!;
|
||||
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(render);
|
||||
|
||||
int observe = CompiledCallGraph.IndexOf(
|
||||
calls,
|
||||
typeof(RenderPackController),
|
||||
nameof(RenderPackController.ObserveActiveFrame));
|
||||
int complete = CompiledCallGraph.IndexOf(
|
||||
calls,
|
||||
typeof(IAtmosphericCpuStageProfileRuntime),
|
||||
nameof(IAtmosphericCpuStageProfileRuntime.CompleteCpuProfile));
|
||||
|
||||
Assert.True(observe >= 0);
|
||||
Assert.True(complete > observe);
|
||||
}
|
||||
|
||||
private static long Ticks(int microseconds) => checked((long)Math.Round(
|
||||
microseconds * Stopwatch.Frequency / 1_000_000d,
|
||||
MidpointRounding.AwayFromZero));
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
public sealed class AtmosphericGpuTimerSamplingTests
|
||||
{
|
||||
[Fact]
|
||||
public void LowMeasuresOneCompleteFrameInFour()
|
||||
{
|
||||
bool[] measured = Enumerable.Range(1, 12)
|
||||
.Select(frame => AtmosphericGpuTimerSampling.ShouldMeasure(
|
||||
RenderQualitySemantic.Low,
|
||||
frame))
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(
|
||||
[false, false, false, true, false, false, false, true, false, false, false, true],
|
||||
measured);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RenderQualitySemantic.Medium)]
|
||||
[InlineData(RenderQualitySemantic.High)]
|
||||
public void OtherQualitiesMeasureEveryFrame(RenderQualitySemantic quality)
|
||||
{
|
||||
for (long frame = 1; frame <= 32; frame++)
|
||||
Assert.True(AtmosphericGpuTimerSampling.ShouldMeasure(quality, frame));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsNonPositiveFrameSerial()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
AtmosphericGpuTimerSampling.ShouldMeasure(
|
||||
RenderQualitySemantic.Low,
|
||||
frameSerial: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmSamplingDecisionsAllocateZero()
|
||||
{
|
||||
_ = AtmosphericGpuTimerSampling.ShouldMeasure(
|
||||
RenderQualitySemantic.Low,
|
||||
frameSerial: 1);
|
||||
|
||||
int measured = 0;
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (long frame = 1; frame <= 10_000; frame++)
|
||||
{
|
||||
if (AtmosphericGpuTimerSampling.ShouldMeasure(
|
||||
RenderQualitySemantic.Low,
|
||||
frame))
|
||||
{
|
||||
measured++;
|
||||
}
|
||||
}
|
||||
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
|
||||
Assert.Equal(2_500, measured);
|
||||
Assert.Equal(0, allocated);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,163 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
public sealed class AtmosphericShaderAbiTests
|
||||
{
|
||||
[Fact]
|
||||
public void HostStructsMatchTheCheckedInStd140AtmosphericAbi()
|
||||
{
|
||||
Assert.Equal(160, Marshal.SizeOf<AtmosphericFrameUniforms>());
|
||||
Assert.Equal(0, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.SunScreen)));
|
||||
Assert.Equal(16, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.SunColor)));
|
||||
Assert.Equal(32, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.Viewport)));
|
||||
Assert.Equal(48, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.Weather)));
|
||||
Assert.Equal(64, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.SunDirection)));
|
||||
Assert.Equal(80, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.Policy)));
|
||||
Assert.Equal(96, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.InverseViewProjection)));
|
||||
|
||||
Assert.Equal(64, Marshal.SizeOf<AtmosphericPackPassUniforms>());
|
||||
Assert.Equal(0, Offset<AtmosphericPackPassUniforms>(nameof(AtmosphericPackPassUniforms.Params0)));
|
||||
Assert.Equal(16, Offset<AtmosphericPackPassUniforms>(nameof(AtmosphericPackPassUniforms.Params1)));
|
||||
Assert.Equal(32, Offset<AtmosphericPackPassUniforms>(nameof(AtmosphericPackPassUniforms.Params2)));
|
||||
Assert.Equal(48, Offset<AtmosphericPackPassUniforms>(nameof(AtmosphericPackPassUniforms.Params3)));
|
||||
Assert.Equal(256, Marshal.SizeOf<PackSettingsUniforms>());
|
||||
|
||||
Assert.Equal(5u, GpuBindingModel.UniformAtmosphericFrame);
|
||||
Assert.Equal(6u, GpuBindingModel.UniformDirectionalShadow);
|
||||
Assert.Equal(7u, GpuBindingModel.UniformPackPass);
|
||||
Assert.Equal(8u, GpuBindingModel.UniformPackSettings);
|
||||
Assert.Equal(5, VulkanFrameBindings.UniformBindingCount);
|
||||
Assert.Equal(
|
||||
[1u, 2u, 3u, 4u],
|
||||
VulkanPipelineLayouts.DeclaredUniformBindings);
|
||||
Assert.Equal(3, RenderPackShaderAbi.UniformDescriptorSet);
|
||||
Assert.Equal(3u, GpuBindingModel.RenderPackUniformSet);
|
||||
Assert.False(VulkanPipelineLayouts.IsDeclaredUniformBinding(5));
|
||||
Assert.True(VulkanPipelineLayouts.IsDeclaredPackUniformBinding(5));
|
||||
Assert.True(VulkanPipelineLayouts.IsDeclaredPackUniformBinding(8));
|
||||
|
||||
Assert.Equal(RenderPackShaderAbi.AtmosphericFrameBinding, (int)GpuBindingModel.UniformAtmosphericFrame);
|
||||
Assert.Equal(RenderPackShaderAbi.AtmosphericFrameSizeBytes, AtmosphericFrameUniforms.SizeInBytes);
|
||||
Assert.Equal(RenderPackShaderAbi.DirectionalShadowBinding, (int)GpuBindingModel.UniformDirectionalShadow);
|
||||
Assert.Equal(RenderPackShaderAbi.PackPassBinding, (int)GpuBindingModel.UniformPackPass);
|
||||
Assert.Equal(RenderPackShaderAbi.PackPassSizeBytes, AtmosphericPackPassUniforms.SizeInBytes);
|
||||
Assert.Equal(RenderPackShaderAbi.PackSettingsBinding, (int)GpuBindingModel.UniformPackSettings);
|
||||
Assert.Equal(RenderPackShaderAbi.PackSettingsSizeBytes, PackSettingsUniforms.SizeInBytes);
|
||||
Assert.Equal(RenderPackShaderAbi.PushConstantSizeBytes, GpuBindingModel.PushConstantBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckedInCommonIncludeNamesTheSameBindingsAndMemberOrder()
|
||||
{
|
||||
string text = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"Shaders",
|
||||
"atmospheric_common.glsl"));
|
||||
|
||||
AssertOrdered(text,
|
||||
"ACDREAM_PACK_UBO_SET binding = 5",
|
||||
"uAtmosphereSunScreen",
|
||||
"uAtmosphereSunColor",
|
||||
"uAtmosphereViewport",
|
||||
"uAtmosphereWeather",
|
||||
"uAtmosphereSunDirection",
|
||||
"uAtmospherePolicy",
|
||||
"uAtmosphereInverseViewProjection",
|
||||
"binding = 7",
|
||||
"uPackParams0",
|
||||
"uPackParams1",
|
||||
"uPackParams2",
|
||||
"uPackParams3",
|
||||
"FusedAtmosphericPostProcess PackPass ABI",
|
||||
"binding = 8",
|
||||
"uPackSettings[16]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FusedLowShadersRetainTheDeclaredOcclusionAndBloomPixelKernels()
|
||||
{
|
||||
string shaderRoot = Path.Combine(
|
||||
RepositoryRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"Shaders");
|
||||
string occlusion = File.ReadAllText(Path.Combine(
|
||||
shaderRoot,
|
||||
"atmospheric_sun_occlusion.frag"));
|
||||
string rays = File.ReadAllText(Path.Combine(
|
||||
shaderRoot,
|
||||
"atmospheric_sun_rays.frag"));
|
||||
string blur = File.ReadAllText(Path.Combine(
|
||||
shaderRoot,
|
||||
"atmospheric_bloom_blur.frag"));
|
||||
string downsample = File.ReadAllText(Path.Combine(
|
||||
shaderRoot,
|
||||
"atmospheric_bloom_downsample.frag"));
|
||||
string filmic = File.ReadAllText(Path.Combine(
|
||||
shaderRoot,
|
||||
"atmospheric_filmic.frag"));
|
||||
|
||||
foreach (string threshold in (string[])["0.9975", "0.99995"])
|
||||
{
|
||||
Assert.Contains(threshold, occlusion, StringComparison.Ordinal);
|
||||
Assert.Contains(threshold, rays, StringComparison.Ordinal);
|
||||
}
|
||||
foreach (string kernel in (string[])
|
||||
["0.227027", "0.316216", "1.384615", "0.070270", "3.230769"])
|
||||
{
|
||||
Assert.Contains(kernel, blur, StringComparison.Ordinal);
|
||||
Assert.Contains(kernel, filmic, StringComparison.Ordinal);
|
||||
}
|
||||
Assert.Contains("round(clamp(unobstructedSky * enabled", rays,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("uPackParams1.z > 0.5", filmic,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("brightness - threshold + knee", filmic,
|
||||
StringComparison.Ordinal);
|
||||
foreach (string extraction in (string[])
|
||||
["0.2126", "0.7152", "0.0722", "brightness - threshold + knee"])
|
||||
{
|
||||
Assert.Contains(extraction, downsample, StringComparison.Ordinal);
|
||||
Assert.Contains(extraction, filmic, StringComparison.Ordinal);
|
||||
}
|
||||
const double oneDimensionalWeight =
|
||||
0.227027 + (2 * 0.316216) + (2 * 0.070270);
|
||||
Assert.InRange(
|
||||
oneDimensionalWeight * oneDimensionalWeight,
|
||||
0.99999,
|
||||
1.00001);
|
||||
}
|
||||
|
||||
private static int Offset<T>(string field) where T : struct =>
|
||||
Marshal.OffsetOf<T>(field).ToInt32();
|
||||
|
||||
private static void AssertOrdered(string text, params string[] tokens)
|
||||
{
|
||||
int prior = -1;
|
||||
foreach (string token in tokens)
|
||||
{
|
||||
int next = text.IndexOf(token, prior + 1, StringComparison.Ordinal);
|
||||
Assert.True(next > prior, $"'{token}' is missing or out of ABI order.");
|
||||
prior = next;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null
|
||||
&& !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
directory = directory.Parent;
|
||||
return directory?.FullName
|
||||
?? throw new InvalidOperationException("Could not locate repository root.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
public sealed class AuthoredCelestialShadowSourceResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void VerifiedDerethIds_AreStable()
|
||||
{
|
||||
Assert.Equal(0x01001348u, AuthoredCelestialShadowSourceResolver.SunGfxObjId);
|
||||
Assert.Equal(0x01001F6Au, AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId);
|
||||
Assert.Equal(0x01001F67u, AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SunOverlap_WinsRegardlessOfObjectOrder()
|
||||
{
|
||||
DayGroupData group = Group(
|
||||
Celestial(
|
||||
AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId,
|
||||
Vector3.UnitX),
|
||||
Celestial(
|
||||
AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId,
|
||||
Vector3.UnitX),
|
||||
Celestial(
|
||||
AuthoredCelestialShadowSourceResolver.SunGfxObjId,
|
||||
Vector3.UnitX));
|
||||
|
||||
AuthoredCelestialShadowSource result = Resolve(group, 0.5f);
|
||||
|
||||
Assert.Equal(AuthoredCelestialShadowSourceKind.Sun, result.Kind);
|
||||
Assert.Equal(2, result.ObjectIndex);
|
||||
Assert.Equal(AuthoredCelestialShadowSourceResolver.SunGfxObjId, result.GfxObjId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingSun_FallsBackToDominantMoonBeforeSecondaryMoon()
|
||||
{
|
||||
DayGroupData group = Group(
|
||||
Celestial(
|
||||
AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId,
|
||||
Vector3.UnitX),
|
||||
Celestial(
|
||||
AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId,
|
||||
Vector3.UnitX));
|
||||
|
||||
AuthoredCelestialShadowSource result = Resolve(group, 0.5f);
|
||||
|
||||
Assert.Equal(AuthoredCelestialShadowSourceKind.DominantMoon, result.Kind);
|
||||
Assert.Equal(1, result.ObjectIndex);
|
||||
Assert.Equal(
|
||||
AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId,
|
||||
result.GfxObjId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullyTransparentHigherPriorityObjects_FallBackToSecondaryMoon()
|
||||
{
|
||||
DayGroupData group = Group(
|
||||
[
|
||||
Celestial(
|
||||
AuthoredCelestialShadowSourceResolver.SunGfxObjId,
|
||||
Vector3.UnitX),
|
||||
Celestial(
|
||||
AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId,
|
||||
Vector3.UnitX),
|
||||
Celestial(
|
||||
AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId,
|
||||
Vector3.UnitX),
|
||||
],
|
||||
Replacements(
|
||||
0f,
|
||||
Replace(0, transparent: 1f),
|
||||
Replace(1, transparent: 1f)));
|
||||
|
||||
AuthoredCelestialShadowSource result = Resolve(group, 0.5f);
|
||||
|
||||
Assert.Equal(AuthoredCelestialShadowSourceKind.SecondaryMoon, result.Kind);
|
||||
Assert.Equal(2, result.ObjectIndex);
|
||||
Assert.Equal(
|
||||
AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId,
|
||||
result.GfxObjId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectiveReplacement_ProvidesItsGfxIdentityAndSortCenter()
|
||||
{
|
||||
const uint replacementGfxObjId = 0x0100ABCDu;
|
||||
DayGroupData group = Group(
|
||||
[
|
||||
Celestial(
|
||||
AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId,
|
||||
-Vector3.UnitX),
|
||||
],
|
||||
Replacements(
|
||||
0f,
|
||||
Replace(
|
||||
0,
|
||||
gfxObjId: replacementGfxObjId,
|
||||
transparent: 0.35f,
|
||||
sortCenter: Vector3.UnitX)));
|
||||
|
||||
AuthoredCelestialShadowSource result = Resolve(group, 0.5f);
|
||||
|
||||
Assert.Equal(AuthoredCelestialShadowSourceKind.DominantMoon, result.Kind);
|
||||
Assert.Equal(replacementGfxObjId, result.GfxObjId);
|
||||
AssertVectorClose(Vector3.UnitZ, result.SurfaceToLightDirection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplacementRotation_IsAppliedBeforeTheSkyArcRotation()
|
||||
{
|
||||
DayGroupData group = Group(
|
||||
[
|
||||
Celestial(
|
||||
AuthoredCelestialShadowSourceResolver.SunGfxObjId,
|
||||
Vector3.UnitY),
|
||||
],
|
||||
Replacements(0f, Replace(0, rotate: 90f)));
|
||||
|
||||
AuthoredCelestialShadowSource result = Resolve(group, 0.5f);
|
||||
|
||||
Assert.Equal(AuthoredCelestialShadowSourceKind.Sun, result.Kind);
|
||||
AssertVectorClose(Vector3.UnitZ, result.SurfaceToLightDirection);
|
||||
AssertClose(1f, result.ElevationSin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkyTransformDirection_MatchesTheAuthoredAnalyticTransform()
|
||||
{
|
||||
DayGroupData group = Group(
|
||||
[
|
||||
Celestial(
|
||||
AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId,
|
||||
new Vector3(2f, 1f, 3f),
|
||||
beginAngle: 0f,
|
||||
endAngle: 80f,
|
||||
beginTime: 0f,
|
||||
endTime: 1f),
|
||||
],
|
||||
Replacements(0f, Replace(0, rotate: 30f)));
|
||||
|
||||
AuthoredCelestialShadowSource result = Resolve(group, 0.5f);
|
||||
|
||||
// Independent analytic result for:
|
||||
// anchor (2,1,3)
|
||||
// heading rotation Z(-30 degrees)
|
||||
// current arc rotation Y(-40 degrees)
|
||||
// using System.Numerics' row-vector convention.
|
||||
Vector3 expected = new(
|
||||
-0.05839998f,
|
||||
-0.03580622f,
|
||||
0.99765092f);
|
||||
Assert.Equal(AuthoredCelestialShadowSourceKind.SecondaryMoon, result.Kind);
|
||||
AssertVectorClose(expected, result.SurfaceToLightDirection);
|
||||
AssertClose(expected.Z, result.ElevationSin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoVisibleOrAboveHorizonCandidate_ReturnsNone()
|
||||
{
|
||||
DayGroupData group = Group(
|
||||
Celestial(
|
||||
AuthoredCelestialShadowSourceResolver.SunGfxObjId,
|
||||
Vector3.UnitX,
|
||||
beginAngle: 90f,
|
||||
endAngle: 90f,
|
||||
beginTime: 0.1f,
|
||||
endTime: 0.2f),
|
||||
Celestial(
|
||||
AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId,
|
||||
Vector3.UnitX,
|
||||
beginAngle: -10f),
|
||||
Celestial(
|
||||
AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId,
|
||||
Vector3.UnitX,
|
||||
beginAngle: 0f));
|
||||
|
||||
AuthoredCelestialShadowSource result = Resolve(group, 0.5f);
|
||||
|
||||
Assert.False(result.IsAvailable);
|
||||
Assert.Equal(AuthoredCelestialShadowSourceKind.None, result.Kind);
|
||||
Assert.Equal(-1, result.ObjectIndex);
|
||||
Assert.Equal(0u, result.GfxObjId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MidnightWrap_IsVisibleOnBothSidesAndNotAtMidday()
|
||||
{
|
||||
DayGroupData group = Group(
|
||||
Celestial(
|
||||
AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId,
|
||||
Vector3.UnitX,
|
||||
beginAngle: 80f,
|
||||
endAngle: 100f,
|
||||
beginTime: 0.9f,
|
||||
endTime: 0.1f));
|
||||
|
||||
AuthoredCelestialShadowSource beforeMidnight = Resolve(group, 0.95f);
|
||||
AuthoredCelestialShadowSource afterMidnight = Resolve(group, 0.05f);
|
||||
AuthoredCelestialShadowSource midday = Resolve(group, 0.5f);
|
||||
|
||||
Assert.Equal(
|
||||
AuthoredCelestialShadowSourceKind.SecondaryMoon,
|
||||
beforeMidnight.Kind);
|
||||
Assert.Equal(
|
||||
AuthoredCelestialShadowSourceKind.SecondaryMoon,
|
||||
afterMidnight.Kind);
|
||||
Assert.True(beforeMidnight.ElevationSin > 0.99f);
|
||||
Assert.True(afterMidnight.ElevationSin > 0.99f);
|
||||
Assert.Equal(AuthoredCelestialShadowSourceKind.None, midday.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoredEnergy_ComesFromDirectionalColorTimesBrightness()
|
||||
{
|
||||
DayGroupData group = Group(
|
||||
Celestial(
|
||||
AuthoredCelestialShadowSourceResolver.SunGfxObjId,
|
||||
Vector3.UnitX));
|
||||
SkyKeyframe sky = Sky(
|
||||
dirColor: new Vector3(0.4f, 0.8f, 0.2f),
|
||||
dirBright: 0.5f);
|
||||
|
||||
AuthoredCelestialShadowSource selected =
|
||||
AuthoredCelestialShadowSourceResolver.Resolve(group, 0.5f, in sky);
|
||||
AuthoredCelestialShadowSource noCandidate =
|
||||
AuthoredCelestialShadowSourceResolver.Resolve(null, 0.5f, in sky);
|
||||
|
||||
AssertClose(0.4f, selected.AuthoredEnergy);
|
||||
AssertClose(0.4f, noCandidate.AuthoredEnergy);
|
||||
}
|
||||
|
||||
private static AuthoredCelestialShadowSource Resolve(
|
||||
DayGroupData group,
|
||||
float dayFraction)
|
||||
{
|
||||
SkyKeyframe sky = Sky();
|
||||
return AuthoredCelestialShadowSourceResolver.Resolve(
|
||||
group,
|
||||
dayFraction,
|
||||
in sky);
|
||||
}
|
||||
|
||||
private static DayGroupData Group(params SkyObjectData[] skyObjects) =>
|
||||
Group(skyObjects, []);
|
||||
|
||||
private static DayGroupData Group(
|
||||
IReadOnlyList<SkyObjectData> skyObjects,
|
||||
params DatSkyKeyframeData[] skyTimes) => new()
|
||||
{
|
||||
Name = "Synthetic",
|
||||
ChanceOfOccur = 1f,
|
||||
SkyObjects = skyObjects,
|
||||
SkyTimes = skyTimes,
|
||||
};
|
||||
|
||||
private static SkyObjectData Celestial(
|
||||
uint gfxObjId,
|
||||
Vector3 sortCenter,
|
||||
float beginAngle = 90f,
|
||||
float? endAngle = null,
|
||||
float beginTime = 0f,
|
||||
float endTime = 0f) => new()
|
||||
{
|
||||
GfxObjId = gfxObjId,
|
||||
AuthoredSortCenter = sortCenter,
|
||||
BeginTime = beginTime,
|
||||
EndTime = endTime,
|
||||
BeginAngle = beginAngle,
|
||||
EndAngle = endAngle ?? beginAngle,
|
||||
};
|
||||
|
||||
private static DatSkyKeyframeData Replacements(
|
||||
float begin,
|
||||
params SkyObjectReplaceData[] replacements) => new()
|
||||
{
|
||||
Keyframe = Sky(begin: begin),
|
||||
Replaces = replacements,
|
||||
};
|
||||
|
||||
private static SkyObjectReplaceData Replace(
|
||||
uint objectIndex,
|
||||
uint gfxObjId = 0u,
|
||||
float rotate = 0f,
|
||||
float transparent = 0f,
|
||||
Vector3? sortCenter = null) => new()
|
||||
{
|
||||
ObjectIndex = objectIndex,
|
||||
GfxObjId = gfxObjId,
|
||||
Rotate = rotate,
|
||||
Transparent = transparent,
|
||||
AuthoredSortCenter = sortCenter ?? Vector3.Zero,
|
||||
};
|
||||
|
||||
private static SkyKeyframe Sky(
|
||||
float begin = 0f,
|
||||
Vector3? dirColor = null,
|
||||
float dirBright = 1f) => new(
|
||||
Begin: begin,
|
||||
SunHeadingDeg: 90f,
|
||||
SunPitchDeg: 45f,
|
||||
DirColor: dirColor ?? Vector3.One,
|
||||
DirBright: dirBright,
|
||||
AmbColor: new Vector3(0.2f),
|
||||
AmbBright: 0.4f,
|
||||
FogColor: new Vector3(0.4f),
|
||||
FogDensity: 0f);
|
||||
|
||||
private static void AssertVectorClose(Vector3 expected, Vector3 actual)
|
||||
{
|
||||
AssertClose(expected.X, actual.X);
|
||||
AssertClose(expected.Y, actual.Y);
|
||||
AssertClose(expected.Z, actual.Z);
|
||||
}
|
||||
|
||||
private static void AssertClose(float expected, float actual) =>
|
||||
Assert.InRange(MathF.Abs(expected - actual), 0f, 1e-5f);
|
||||
}
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
using System.Security.Cryptography;
|
||||
using AcDream.App.Plugins;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
public sealed class NoOpRenderPackProductionIntegrationTests
|
||||
{
|
||||
private const string PreCampaignFramebufferSha256 =
|
||||
"790f352044549e7bb37465b061c521321128c378f65ca52e0f2761fcdb0155de";
|
||||
|
||||
[Fact]
|
||||
public void PackOffDefaultPathMatchesCompleteCheckedInPreCampaignOracle()
|
||||
{
|
||||
// The no-controller arm is the exact production composition shape from
|
||||
// before render packs existed. The controller arm selects retail/off.
|
||||
// Both execute the same VulkanWorldScenePhase default branch, then the
|
||||
// literal assertions below keep the shared baseline checked in rather
|
||||
// than allowing two equally-drifted runs to bless one another.
|
||||
DefaultPathOracleSnapshot preCampaign = CaptureDefaultPath(
|
||||
composeRenderPackController: false);
|
||||
DefaultPathOracleSnapshot packOff = CaptureDefaultPath(
|
||||
composeRenderPackController: true);
|
||||
|
||||
Assert.Equal(preCampaign.PassList, packOff.PassList);
|
||||
Assert.Equal(preCampaign.PipelineSet, packOff.PipelineSet);
|
||||
Assert.Equal(preCampaign.DrawCalls, packOff.DrawCalls);
|
||||
Assert.Equal(preCampaign.DispatchCalls, packOff.DispatchCalls);
|
||||
Assert.Equal(preCampaign.FramebufferSha256, packOff.FramebufferSha256);
|
||||
Assert.Equal(preCampaign.Resources, packOff.Resources);
|
||||
Assert.Equal(preCampaign.PackResources, packOff.PackResources);
|
||||
Assert.Equal(preCampaign.IsRetailSelection, packOff.IsRetailSelection);
|
||||
Assert.Equal(preCampaign.HasActivePackRuntime, packOff.HasActivePackRuntime);
|
||||
Assert.Equal(preCampaign.HasPackShaderVariant, packOff.HasPackShaderVariant);
|
||||
|
||||
Assert.Equal(["vk-world"], packOff.PassList);
|
||||
Assert.Equal(
|
||||
["baseline-world-opaque|mesh_modern|pack=False|samples=1|format=Rgba8UnormRenderTarget"],
|
||||
packOff.PipelineSet);
|
||||
Assert.Equal(1, packOff.DrawCalls);
|
||||
Assert.Equal(0, packOff.DispatchCalls);
|
||||
Assert.Equal(PreCampaignFramebufferSha256, packOff.FramebufferSha256);
|
||||
Assert.Equal(
|
||||
new DefaultPathResourceLedger(
|
||||
TotalBuffers: 1,
|
||||
LiveBuffers: 1,
|
||||
TotalPipelines: 1,
|
||||
LivePipelines: 1,
|
||||
TotalSamplers: 1,
|
||||
LiveSamplers: 1,
|
||||
TotalTextures: 0,
|
||||
LiveTextures: 0,
|
||||
TotalRenderTargets: 0,
|
||||
LiveRenderTargets: 0,
|
||||
TotalDirectionalDepthTargets: 0,
|
||||
LiveDirectionalDepthTargets: 0,
|
||||
LiveTextureSlots: 1,
|
||||
PipelineFormatLeases: 0),
|
||||
packOff.Resources);
|
||||
Assert.True(packOff.IsRetailSelection);
|
||||
Assert.False(packOff.HasActivePackRuntime);
|
||||
Assert.False(packOff.HasPackShaderVariant);
|
||||
Assert.Equal(
|
||||
new DefaultPathPackResourceLedger(
|
||||
RetainedGpuBytes: 0,
|
||||
TransientGpuBytes: 0,
|
||||
ImageCount: 0,
|
||||
BufferCount: 0,
|
||||
DrawCalls: 0,
|
||||
DispatchCalls: 0,
|
||||
PassCount: 0),
|
||||
packOff.PackResources);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedNoOpPackRemainsActiveWhileProductionUsesDefaultWorldPath()
|
||||
{
|
||||
RenderPackDescriptor descriptor = new(
|
||||
"sample.no-op-render-pack",
|
||||
"No-op Render Pack Sample",
|
||||
new Version(1, 0, 0),
|
||||
RenderPackApi.Current,
|
||||
RenderPackTier.Tier1,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[new RenderQualityPreset(
|
||||
"conformance", "Conformance", [], [], [], 0, 0, 0, 0, 0)],
|
||||
[],
|
||||
null)
|
||||
{
|
||||
FeatureSummary = "Conformance-only default-path selection.",
|
||||
};
|
||||
using var registry = new BufferedRenderPackRegistry();
|
||||
using IDisposable registration = registry.Register(
|
||||
descriptor,
|
||||
RejectingAssets.Instance);
|
||||
var device = new RecordingGpuDevice();
|
||||
var factory = new AtmosphericRenderPackRuntimeFactory(device);
|
||||
using var controller = new RenderPackController(
|
||||
() => RenderPackCatalog.Build(
|
||||
registry.Snapshot(),
|
||||
RenderPackHostCapabilities.Conformance),
|
||||
factory,
|
||||
preparationScheduler: InlineRenderPackPreparationScheduler.Instance);
|
||||
controller.Request(new RenderPackSelectionSettings(
|
||||
descriptor.Id,
|
||||
descriptor.PackVersion.ToString(),
|
||||
"conformance"));
|
||||
|
||||
var lifetime = new GpuDeviceFrameLifetime(device);
|
||||
var clear = new VulkanBackbufferClearState();
|
||||
var scope = new VulkanWorldPassScope(sampleCount: 1);
|
||||
var world = new DefaultWorldPhase(scope);
|
||||
var phase = new VulkanWorldScenePhase(
|
||||
lifetime,
|
||||
clear,
|
||||
sampleCount: static () => 1,
|
||||
scope,
|
||||
world,
|
||||
controller);
|
||||
|
||||
lifetime.BeginFrame();
|
||||
WorldRenderFrameOutcome outcome;
|
||||
try
|
||||
{
|
||||
outcome = phase.Render(new RenderFrameInput(1.0 / 60.0, 1280, 720));
|
||||
}
|
||||
finally
|
||||
{
|
||||
lifetime.EndFrame();
|
||||
}
|
||||
|
||||
Assert.Equal(DefaultWorldPhase.Expected, outcome);
|
||||
Assert.Equal(1, world.RenderCount);
|
||||
Assert.True(
|
||||
controller.Snapshot.State == RenderPackActivationState.Active,
|
||||
controller.Snapshot.Reason);
|
||||
Assert.Equal(descriptor.Id, controller.Snapshot.Selection.PackId);
|
||||
Assert.IsAssignableFrom<IDefaultWorldPathRenderPackRuntime>(controller.ActiveRuntime);
|
||||
Assert.Single(device.Calls.OfType<GpuRecordedPassBegin>(), value => value.Name == "vk-world");
|
||||
Assert.Empty(device.CreatedPipelines);
|
||||
Assert.Null(scope.CurrentEncoder);
|
||||
}
|
||||
|
||||
private static DefaultPathOracleSnapshot CaptureDefaultPath(
|
||||
bool composeRenderPackController)
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using IGpuPipeline pipeline = device.CreatePipeline(new GpuPipelineDescription
|
||||
{
|
||||
Name = "baseline-world-opaque",
|
||||
Shaders = new GpuShaderSet("mesh_modern"),
|
||||
VertexLayout = GpuVertexLayout.WorldMesh,
|
||||
});
|
||||
using IGpuBuffer vertices = device.CreateBuffer(new GpuBufferDescription(
|
||||
"baseline-world-vertices",
|
||||
SizeBytes: 96,
|
||||
GpuBufferUsage.Vertex,
|
||||
GpuMemoryResidency.DeviceLocal));
|
||||
using var registry = new BufferedRenderPackRegistry();
|
||||
using RenderPackController? controller = composeRenderPackController
|
||||
? new RenderPackController(
|
||||
() => RenderPackCatalog.Build(
|
||||
registry.Snapshot(),
|
||||
RenderPackHostCapabilities.Conformance),
|
||||
new AtmosphericRenderPackRuntimeFactory(device),
|
||||
preparationScheduler: InlineRenderPackPreparationScheduler.Instance)
|
||||
: null;
|
||||
var lifetime = new GpuDeviceFrameLifetime(device);
|
||||
var clear = new VulkanBackbufferClearState();
|
||||
var scope = new VulkanWorldPassScope(sampleCount: 1);
|
||||
var world = new OracleWorldPhase(scope, pipeline, vertices);
|
||||
var phase = new VulkanWorldScenePhase(
|
||||
lifetime,
|
||||
clear,
|
||||
sampleCount: static () => 1,
|
||||
scope,
|
||||
world,
|
||||
controller);
|
||||
device.Clear();
|
||||
|
||||
lifetime.BeginFrame();
|
||||
WorldRenderFrameOutcome outcome;
|
||||
try
|
||||
{
|
||||
outcome = phase.Render(new RenderFrameInput(1.0 / 60.0, 1280, 720));
|
||||
}
|
||||
finally
|
||||
{
|
||||
lifetime.EndFrame();
|
||||
}
|
||||
|
||||
Assert.Equal(OracleWorldPhase.Expected, outcome);
|
||||
Assert.Equal(1, world.RenderCount);
|
||||
RenderPackDiagnosticsSnapshot diagnostics = controller?.CaptureDiagnostics()
|
||||
?? RenderPackDiagnosticsSnapshot.Retail;
|
||||
return new DefaultPathOracleSnapshot(
|
||||
device.Calls.OfType<GpuRecordedPassBegin>()
|
||||
.Select(call => call.Name)
|
||||
.ToArray(),
|
||||
device.CreatedPipelines
|
||||
.Select(created =>
|
||||
$"{created.Description.Name}|{created.Description.Shaders.Name}"
|
||||
+ $"|pack={created.Description.UsesRenderPackShaderAbi}"
|
||||
+ $"|samples={created.Description.SampleCount}"
|
||||
+ $"|format={created.Description.ColorFormat}")
|
||||
.ToArray(),
|
||||
DrawCalls: device.Calls.Count(call => call is GpuRecordedDraw
|
||||
or GpuRecordedDrawIndexed
|
||||
or GpuRecordedMultiDrawIndirect),
|
||||
DispatchCalls: 0,
|
||||
world.FramebufferSha256,
|
||||
CaptureResourceLedger(device),
|
||||
IsRetailSelection: diagnostics.IsRetail,
|
||||
HasActivePackRuntime: controller?.ActiveRuntime is not null,
|
||||
HasPackShaderVariant: device.CreatedPipelines.Any(created =>
|
||||
created.Description.UsesRenderPackShaderAbi),
|
||||
PackResources: new DefaultPathPackResourceLedger(
|
||||
diagnostics.RetainedGpuBytes,
|
||||
diagnostics.TransientGpuBytes,
|
||||
diagnostics.ImageCount,
|
||||
diagnostics.BufferCount,
|
||||
diagnostics.DrawCalls,
|
||||
diagnostics.DispatchCalls,
|
||||
diagnostics.Passes.Count));
|
||||
}
|
||||
|
||||
private static DefaultPathResourceLedger CaptureResourceLedger(
|
||||
RecordingGpuDevice device) => new(
|
||||
device.CreatedBuffers.Count,
|
||||
device.CreatedBuffers.Count(resource => !resource.IsDisposed),
|
||||
device.CreatedPipelines.Count,
|
||||
device.CreatedPipelines.Count(resource => !resource.IsDisposed),
|
||||
device.CreatedSamplers.Count,
|
||||
device.CreatedSamplers.Count(resource => !resource.IsDisposed),
|
||||
device.CreatedTextures.Count,
|
||||
device.CreatedTextures.Count(resource => !resource.IsDisposed),
|
||||
device.CreatedRenderTargets.Count,
|
||||
device.CreatedRenderTargets.Count(resource => !resource.IsDisposed),
|
||||
device.CreatedDirectionalDepthTargets.Count,
|
||||
device.CreatedDirectionalDepthTargets.Count(resource => !resource.IsDisposed),
|
||||
device.LiveTextureSlotCount,
|
||||
device.PipelineFormatLeases.Values.Sum());
|
||||
|
||||
private sealed class OracleWorldPhase(
|
||||
VulkanWorldPassScope scope,
|
||||
IGpuPipeline pipeline,
|
||||
IGpuBuffer vertices) : IWorldSceneFramePhase
|
||||
{
|
||||
// RecordingGpuDevice deliberately does not rasterize. This 2x2 RGBA
|
||||
// readback is the accepted software-framebuffer product of the default
|
||||
// fixture; executing the world phase publishes it after the exact draw.
|
||||
// Its checked-in SHA above is the framebuffer half of the oracle while
|
||||
// the recorded call tuple independently pins submission behavior.
|
||||
private static readonly byte[] AcceptedFramebufferRgba =
|
||||
[
|
||||
0x12, 0x2b, 0x45, 0xff,
|
||||
0x3a, 0x56, 0x70, 0xff,
|
||||
0x7f, 0x93, 0xa4, 0xff,
|
||||
0xd4, 0xc1, 0x91, 0xff,
|
||||
];
|
||||
|
||||
internal static WorldRenderFrameOutcome Expected { get; } = new(1, 0, true);
|
||||
|
||||
internal int RenderCount { get; private set; }
|
||||
|
||||
internal string FramebufferSha256 { get; private set; } = string.Empty;
|
||||
|
||||
public WorldRenderFrameOutcome Render(RenderFrameInput input)
|
||||
{
|
||||
IGpuPassEncoder encoder = Assert.IsAssignableFrom<IGpuPassEncoder>(
|
||||
scope.CurrentEncoder);
|
||||
encoder.BindPipeline(pipeline);
|
||||
encoder.BindVertexBuffer(binding: 0, vertices, offsetBytes: 0);
|
||||
encoder.SetViewport(0, 0, input.ViewportWidth, input.ViewportHeight);
|
||||
encoder.SetScissor(0, 0, input.ViewportWidth, input.ViewportHeight);
|
||||
encoder.Draw(vertexCount: 3, instanceCount: 1, firstVertex: 0, firstInstance: 0);
|
||||
FramebufferSha256 = Convert.ToHexStringLower(
|
||||
SHA256.HashData(AcceptedFramebufferRgba));
|
||||
RenderCount++;
|
||||
return Expected;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record DefaultPathOracleSnapshot(
|
||||
string[] PassList,
|
||||
string[] PipelineSet,
|
||||
int DrawCalls,
|
||||
int DispatchCalls,
|
||||
string FramebufferSha256,
|
||||
DefaultPathResourceLedger Resources,
|
||||
bool IsRetailSelection,
|
||||
bool HasActivePackRuntime,
|
||||
bool HasPackShaderVariant,
|
||||
DefaultPathPackResourceLedger PackResources);
|
||||
|
||||
private readonly record struct DefaultPathPackResourceLedger(
|
||||
long RetainedGpuBytes,
|
||||
long TransientGpuBytes,
|
||||
int ImageCount,
|
||||
int BufferCount,
|
||||
int DrawCalls,
|
||||
int DispatchCalls,
|
||||
int PassCount);
|
||||
|
||||
private readonly record struct DefaultPathResourceLedger(
|
||||
int TotalBuffers,
|
||||
int LiveBuffers,
|
||||
int TotalPipelines,
|
||||
int LivePipelines,
|
||||
int TotalSamplers,
|
||||
int LiveSamplers,
|
||||
int TotalTextures,
|
||||
int LiveTextures,
|
||||
int TotalRenderTargets,
|
||||
int LiveRenderTargets,
|
||||
int TotalDirectionalDepthTargets,
|
||||
int LiveDirectionalDepthTargets,
|
||||
int LiveTextureSlots,
|
||||
int PipelineFormatLeases);
|
||||
|
||||
private sealed class DefaultWorldPhase(VulkanWorldPassScope scope) : IWorldSceneFramePhase
|
||||
{
|
||||
internal static WorldRenderFrameOutcome Expected { get; } = new(4, 7, true);
|
||||
|
||||
internal int RenderCount { get; private set; }
|
||||
|
||||
public WorldRenderFrameOutcome Render(RenderFrameInput input)
|
||||
{
|
||||
Assert.NotNull(scope.CurrentEncoder);
|
||||
RenderCount++;
|
||||
return Expected;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RejectingAssets : IRenderPackAssets
|
||||
{
|
||||
internal static RejectingAssets Instance { get; } = new();
|
||||
|
||||
public Stream OpenRead(string assetKey) =>
|
||||
throw new InvalidOperationException("A no-op pack has no shader assets.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
public sealed class PackSettingsUniformsTests
|
||||
{
|
||||
[Fact]
|
||||
public void WriterResolvesPresetThenEncodesEveryV1ScalarKindInvariantly()
|
||||
{
|
||||
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor with
|
||||
{
|
||||
Settings =
|
||||
[
|
||||
Setting("float", RenderSettingKind.Float, "1.25"),
|
||||
Setting("integer", RenderSettingKind.Integer, "12"),
|
||||
Setting("bool-false", RenderSettingKind.Boolean, "false"),
|
||||
Setting("bool-true", RenderSettingKind.Boolean, "true"),
|
||||
Setting("choice", RenderSettingKind.Choice, "low", ["low", "medium", "high"]),
|
||||
Setting("invalid-integer", RenderSettingKind.Integer, "1.5"),
|
||||
Setting("invalid-float", RenderSettingKind.Float, "1,5"),
|
||||
],
|
||||
};
|
||||
RenderQualityPreset preset = descriptor.QualityPresets[0] with
|
||||
{
|
||||
SettingOverrides =
|
||||
[
|
||||
new RenderQualitySettingOverride("float", "2.5"),
|
||||
new RenderQualitySettingOverride("integer", "-7"),
|
||||
new RenderQualitySettingOverride("bool-false", "true"),
|
||||
new RenderQualitySettingOverride("choice", "high"),
|
||||
],
|
||||
};
|
||||
|
||||
PackSettingsUniforms values = PackSettingsUniforms.Create(descriptor, preset);
|
||||
|
||||
Assert.Equal(2.5f, values[0]);
|
||||
Assert.Equal(-7f, values[1]);
|
||||
Assert.Equal(1f, values[2]);
|
||||
Assert.Equal(1f, values[3]);
|
||||
Assert.Equal(2f, values[4]);
|
||||
Assert.Equal(0f, values[5]);
|
||||
Assert.Equal(0f, values[6]);
|
||||
Assert.Equal(0f, values[63]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescriptorValidatorRejectsMoreThanSixtyFourSettings()
|
||||
{
|
||||
RenderSettingDeclaration[] settings = Enumerable.Range(0, 65)
|
||||
.Select(index => Setting($"setting-{index}", RenderSettingKind.Float, "0"))
|
||||
.ToArray();
|
||||
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor with
|
||||
{
|
||||
Settings = settings,
|
||||
};
|
||||
var device = new RecordingGpuDevice();
|
||||
|
||||
RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor(
|
||||
descriptor,
|
||||
RenderPackCapabilityResolver.Resolve(device.Capabilities));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("64-setting", result.Reason, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void User_value_wins_preset_and_default_in_b8_and_built_in_cpu_settings()
|
||||
{
|
||||
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
RenderQualityPreset preset = descriptor.QualityPresets[0] with
|
||||
{
|
||||
SettingOverrides =
|
||||
[
|
||||
new RenderQualitySettingOverride("exposure", "1.25"),
|
||||
new RenderQualitySettingOverride("bloom-strength", "0.25"),
|
||||
],
|
||||
};
|
||||
var overrides = new RenderPackSettingOverrides(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["EXPOSURE"] = "1.75",
|
||||
});
|
||||
|
||||
PackSettingsUniforms uniforms = PackSettingsUniforms.Create(
|
||||
descriptor,
|
||||
preset,
|
||||
overrides);
|
||||
AtmosphericPostProcessSettings cpu = AtmosphericPostProcessSettings.FromDescriptor(
|
||||
descriptor,
|
||||
preset,
|
||||
overrides);
|
||||
|
||||
int exposureIndex = descriptor.Settings.ToList().FindIndex(value => value.Id == "exposure");
|
||||
int bloomIndex = descriptor.Settings.ToList().FindIndex(value => value.Id == "bloom-strength");
|
||||
Assert.Equal(1.75f, uniforms[exposureIndex]);
|
||||
Assert.Equal(0.25f, uniforms[bloomIndex]);
|
||||
Assert.Equal(1.75f, cpu.Exposure);
|
||||
Assert.Equal(0.25f, cpu.BloomStrength);
|
||||
}
|
||||
|
||||
private static RenderSettingDeclaration Setting(
|
||||
string id,
|
||||
RenderSettingKind kind,
|
||||
string defaultValue,
|
||||
IReadOnlyList<string>? choices = null) => new(
|
||||
id,
|
||||
id,
|
||||
kind,
|
||||
defaultValue,
|
||||
Minimum: null,
|
||||
Maximum: null,
|
||||
Step: null,
|
||||
choices ?? []);
|
||||
}
|
||||
|
|
@ -0,0 +1,650 @@
|
|||
using AcDream.App.Plugins;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
public sealed class RenderPackAutoRuntimeTests
|
||||
{
|
||||
[Fact]
|
||||
public void AutoStartsMediumAndAtomicallyPublishesPreparedLowCandidateAtBoundary()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
fixture.Activate("auto");
|
||||
FakeRuntime medium = fixture.Active;
|
||||
|
||||
Assert.Equal("medium", medium.Preset.Id);
|
||||
Assert.Equal("auto", fixture.Controller.Snapshot.Selection.PresetId);
|
||||
fixture.ObserveOverBudget(AtmosphericAutoQualityController.DowngradeHysteresisFrames);
|
||||
|
||||
Assert.Same(medium, fixture.Controller.ActiveRuntime);
|
||||
Assert.False(medium.Disposed);
|
||||
Assert.Equal(1, fixture.Factory.BuildCount);
|
||||
|
||||
RenderPackActivationSnapshot changed = fixture.Controller.ApplyAtFrameBoundary(
|
||||
new RenderPackActivationExtent(1920, 1080, 2));
|
||||
FakeRuntime low = fixture.Active;
|
||||
|
||||
Assert.Equal(RenderPackActivationState.Active, changed.State);
|
||||
Assert.Equal("auto", changed.Selection.PresetId);
|
||||
Assert.Equal("low", low.Preset.Id);
|
||||
Assert.True(low.Prepared);
|
||||
Assert.True(medium.Disposed);
|
||||
Assert.Equal(
|
||||
["build:medium", "prepare:medium:1280x720x4", "build:low",
|
||||
"prepare:low:1920x1080x2", "dispose:medium"],
|
||||
fixture.Events);
|
||||
Assert.Equal(0, fixture.Controller.Performance.CpuSampleCount);
|
||||
Assert.Equal(AtmosphericQualityLevel.Low,
|
||||
fixture.Controller.AutoQuality!.Value.Current);
|
||||
|
||||
RenderPackDiagnosticsSnapshot diagnostics = fixture.Controller.CaptureDiagnostics();
|
||||
Assert.Equal("auto", diagnostics.PresetId);
|
||||
Assert.Equal("low", diagnostics.EffectiveQuality);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Auto_keeps_current_quality_live_while_replacement_prepares_off_side()
|
||||
{
|
||||
var scheduler = new ControlledPreparationScheduler();
|
||||
using var fixture = new Fixture(preparationScheduler: scheduler);
|
||||
fixture.Controller.Request(new RenderPackSelectionSettings(
|
||||
"auto.test", "1.0.0", "auto"));
|
||||
fixture.Controller.ApplyAtFrameBoundary(
|
||||
new RenderPackActivationExtent(1280, 720, 4));
|
||||
scheduler.CompleteNext();
|
||||
fixture.Controller.ApplyAtFrameBoundary(
|
||||
new RenderPackActivationExtent(1280, 720, 4));
|
||||
FakeRuntime medium = fixture.Active;
|
||||
|
||||
fixture.ObserveOverBudget(AtmosphericAutoQualityController.DowngradeHysteresisFrames);
|
||||
RenderPackActivationSnapshot pending = fixture.Controller.ApplyAtFrameBoundary(
|
||||
new RenderPackActivationExtent(1280, 720, 4));
|
||||
|
||||
Assert.Equal(RenderPackActivationState.CandidatePending, pending.State);
|
||||
Assert.Same(medium, fixture.Controller.ActiveRuntime);
|
||||
Assert.False(medium.Disposed);
|
||||
scheduler.CompleteNext();
|
||||
Assert.Same(medium, fixture.Controller.ActiveRuntime);
|
||||
|
||||
fixture.Controller.ApplyAtFrameBoundary(
|
||||
new RenderPackActivationExtent(1280, 720, 4));
|
||||
|
||||
Assert.Equal("low", fixture.Active.Preset.Id);
|
||||
Assert.True(medium.Disposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeakHostStartsAutoAtLowAndCannotPromoteIntoUnavailablePresets()
|
||||
{
|
||||
using var fixture = new Fixture(new RenderPackHostCapabilities(
|
||||
Enum.GetValues<RenderCapability>().ToHashSet(),
|
||||
4096,
|
||||
4,
|
||||
64L * 1024 * 1024));
|
||||
|
||||
fixture.Activate("auto");
|
||||
|
||||
Assert.Equal("low", fixture.Active.Preset.Id);
|
||||
Assert.Equal(AtmosphericQualityLevel.Low, fixture.Controller.AutoQuality!.Value.Current);
|
||||
fixture.Active.ResolvedGpuMilliseconds = 0.01;
|
||||
for (int i = 0; i < AtmosphericAutoQualityController.UpgradeHysteresisFrames + 1; i++)
|
||||
fixture.Observe(0.01, stable: true);
|
||||
Assert.Equal(AtmosphericQualityLevel.Low, fixture.Controller.AutoQuality!.Value.Current);
|
||||
Assert.Equal(1, fixture.Factory.BuildCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoFailsSafelyToRetailWhenLowPersistentlyExceedsItsDeclaredBudget()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
fixture.Activate("auto");
|
||||
fixture.ObserveOverBudget(AtmosphericAutoQualityController.DowngradeHysteresisFrames);
|
||||
fixture.Controller.ApplyAtFrameBoundary(
|
||||
new RenderPackActivationExtent(1280, 720, 4));
|
||||
FakeRuntime low = fixture.Active;
|
||||
Assert.Equal("low", low.Preset.Id);
|
||||
|
||||
fixture.ObserveOverBudget(
|
||||
1
|
||||
+ AtmosphericAutoQualityController.ChangeCooldownFrames
|
||||
+ AtmosphericAutoQualityController.DowngradeHysteresisFrames);
|
||||
|
||||
Assert.Same(low, fixture.Controller.ActiveRuntime);
|
||||
Assert.True(fixture.Controller.AutoQuality!.Value.SafeFallbackToRetailRequested);
|
||||
|
||||
RenderPackActivationSnapshot fallback = fixture.Controller.ApplyAtFrameBoundary(
|
||||
new RenderPackActivationExtent(1280, 720, 4));
|
||||
|
||||
Assert.Equal(RenderPackActivationState.FailedToRetail, fallback.State);
|
||||
Assert.Equal(RenderPackSelectionSettings.Retail, fallback.Selection);
|
||||
Assert.Null(fixture.Controller.ActiveRuntime);
|
||||
Assert.True(low.Disposed);
|
||||
Assert.Contains(
|
||||
"Low remained over its declared performance budget for 180 stable samples",
|
||||
fallback.Reason,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("GPU p99 20.000 ms (budget 12.000 ms)", fallback.Reason);
|
||||
Assert.Contains("CPU p99 20.000 ms (budget 3.000 ms)", fallback.Reason);
|
||||
Assert.Contains("resident GPU bytes 536870912 (budget 67108864)", fallback.Reason);
|
||||
Assert.Null(fixture.Controller.AutoQuality);
|
||||
Assert.Equal(0, fixture.Controller.Performance.CpuSampleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostThatCannotSupportLowFailsAutoPreciselyToRetail()
|
||||
{
|
||||
using var fixture = new Fixture(new RenderPackHostCapabilities(
|
||||
Enum.GetValues<RenderCapability>().ToHashSet(),
|
||||
4096,
|
||||
4,
|
||||
32L * 1024 * 1024,
|
||||
MemoryPolicyDescription: "test weak-host policy"));
|
||||
|
||||
fixture.Controller.Request(new RenderPackSelectionSettings(
|
||||
"auto.test", "1.0.0", "auto"));
|
||||
RenderPackActivationSnapshot snapshot = fixture.Controller.ApplyAtFrameBoundary(
|
||||
new RenderPackActivationExtent(1280, 720, 4));
|
||||
|
||||
Assert.Equal(RenderPackActivationState.FailedToRetail, snapshot.State);
|
||||
Assert.Null(fixture.Controller.ActiveRuntime);
|
||||
Assert.Contains("cannot support Low", snapshot.Reason, StringComparison.Ordinal);
|
||||
Assert.Contains("33554432", snapshot.Reason, StringComparison.Ordinal);
|
||||
Assert.Equal(0, fixture.Factory.BuildCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoCandidateResourceFailureDisposesBothCandidatesAndFailsSafelyToRetail()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
fixture.Activate("auto");
|
||||
FakeRuntime medium = fixture.Active;
|
||||
fixture.ObserveOverBudget(AtmosphericAutoQualityController.DowngradeHysteresisFrames);
|
||||
fixture.Factory.FailPreparePreset = "low";
|
||||
|
||||
RenderPackActivationSnapshot failed = fixture.Controller.ApplyAtFrameBoundary(
|
||||
new RenderPackActivationExtent(1280, 720, 4));
|
||||
|
||||
Assert.Equal(RenderPackActivationState.FailedToRetail, failed.State);
|
||||
Assert.Null(fixture.Controller.ActiveRuntime);
|
||||
Assert.True(medium.Disposed);
|
||||
FakeRuntime candidate = fixture.Factory.Runtimes[^1];
|
||||
Assert.Equal("low", candidate.Preset.Id);
|
||||
Assert.True(candidate.Disposed);
|
||||
Assert.Contains("injected low resource failure", failed.Reason, StringComparison.Ordinal);
|
||||
Assert.Equal(0, fixture.Controller.Performance.CpuSampleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnstableFramesAndExplicitPresetsNeverDriveAutomaticChanges()
|
||||
{
|
||||
using var auto = new Fixture();
|
||||
auto.Activate("auto");
|
||||
auto.ObserveOverBudget(1000, stable: false);
|
||||
Assert.Equal(AtmosphericQualityLevel.Medium,
|
||||
auto.Controller.AutoQuality!.Value.Current);
|
||||
Assert.Equal(0, auto.Controller.Performance.CpuSampleCount);
|
||||
Assert.Equal(1, auto.Factory.BuildCount);
|
||||
|
||||
using var explicitHigh = new Fixture();
|
||||
explicitHigh.Activate("high");
|
||||
explicitHigh.ObserveOverBudget(1000);
|
||||
explicitHigh.Controller.ApplyAtFrameBoundary(
|
||||
new RenderPackActivationExtent(1280, 720, 4));
|
||||
Assert.Equal("high", explicitHigh.Active.Preset.Id);
|
||||
Assert.Null(explicitHigh.Controller.AutoQuality);
|
||||
Assert.Equal(1, explicitHigh.Factory.BuildCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutomaticQualityBooleanEnablesAutoFromAnExplicitPreset()
|
||||
{
|
||||
using var fixture = new Fixture(descriptor: DescriptorWithAutomaticSetting());
|
||||
fixture.Controller.Request(new RenderPackSelectionSettings(
|
||||
"auto.test",
|
||||
"1.0.0",
|
||||
"high")
|
||||
{
|
||||
SettingOverrides = new RenderPackSettingOverrides(
|
||||
new Dictionary<string, string> { ["automatic-quality"] = "true" }),
|
||||
});
|
||||
|
||||
RenderPackActivationSnapshot snapshot = fixture.Controller.ApplyAtFrameBoundary(
|
||||
new RenderPackActivationExtent(1280, 720, 4));
|
||||
|
||||
Assert.Equal(RenderPackActivationState.Active, snapshot.State);
|
||||
Assert.Equal("high", fixture.Active.Preset.Id);
|
||||
Assert.Equal(AtmosphericQualityLevel.High, fixture.Controller.AutoQuality!.Value.Current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutomaticQualityBooleanCanDisableTheAutomaticSelector()
|
||||
{
|
||||
using var fixture = new Fixture(descriptor: DescriptorWithAutomaticSetting());
|
||||
fixture.Controller.Request(new RenderPackSelectionSettings(
|
||||
"auto.test",
|
||||
"1.0.0",
|
||||
"auto")
|
||||
{
|
||||
SettingOverrides = new RenderPackSettingOverrides(
|
||||
new Dictionary<string, string> { ["automatic-quality"] = "false" }),
|
||||
});
|
||||
|
||||
RenderPackActivationSnapshot snapshot = fixture.Controller.ApplyAtFrameBoundary(
|
||||
new RenderPackActivationExtent(1280, 720, 4));
|
||||
|
||||
Assert.Equal(RenderPackActivationState.Active, snapshot.State);
|
||||
Assert.Equal("medium", fixture.Active.Preset.Id);
|
||||
Assert.Null(fixture.Controller.AutoQuality);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResourceGenerationChangeResetsWindowBeforeAcceptingNewLayoutSamples()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
fixture.Activate("auto");
|
||||
fixture.ObserveOverBudget(5);
|
||||
Assert.Equal(5, fixture.Controller.Performance.CpuSampleCount);
|
||||
|
||||
fixture.Active.ResourceGeneration++;
|
||||
fixture.ObserveOverBudget(1);
|
||||
Assert.Equal(0, fixture.Controller.Performance.CpuSampleCount);
|
||||
fixture.ObserveOverBudget(1);
|
||||
Assert.Equal(1, fixture.Controller.Performance.CpuSampleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiagnosticResetStartsACompleteFreshPerformanceWindow()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
fixture.Activate("high");
|
||||
fixture.ObserveOverBudget(4);
|
||||
Assert.Equal(4, fixture.Controller.MinimumPerformanceSampleCount);
|
||||
|
||||
Assert.True(
|
||||
fixture.Controller.TryResetPerformanceEvidence(out string error),
|
||||
error);
|
||||
Assert.Equal(0, fixture.Controller.MinimumPerformanceSampleCount);
|
||||
|
||||
fixture.ObserveOverBudget(1);
|
||||
Assert.Equal(1, fixture.Controller.MinimumPerformanceSampleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiagnosticResetCannotPerturbAutomaticQualityEvidence()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
fixture.Activate("auto");
|
||||
fixture.ObserveOverBudget(1);
|
||||
|
||||
Assert.False(
|
||||
fixture.Controller.TryResetPerformanceEvidence(out string error));
|
||||
Assert.Contains("explicit quality preset", error);
|
||||
Assert.Equal(1, fixture.Controller.MinimumPerformanceSampleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiagnosticsSeparatePackAddedCpuAbsoluteReceiverCpuAndInclusiveGpu()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
fixture.Activate("high");
|
||||
double[] cpu = [1, 2, 3, 4];
|
||||
double[] gpu = [4, 6, 8, 10];
|
||||
for (int i = 0; i < cpu.Length; i++)
|
||||
{
|
||||
fixture.Active.ResolvedGpuMilliseconds = gpu[i];
|
||||
fixture.Observe(cpu[i], stable: true);
|
||||
}
|
||||
|
||||
RenderPackDiagnosticsSnapshot diagnostics = fixture.Controller.CaptureDiagnostics();
|
||||
|
||||
Assert.Equal(4, diagnostics.Performance.CpuSampleCount);
|
||||
Assert.Equal(4, diagnostics.Performance.AbsoluteReceiverCpuSampleCount);
|
||||
Assert.Equal(4, diagnostics.Performance.GpuSampleCount);
|
||||
Assert.Equal(2, diagnostics.Performance.IncrementalCpuMillisecondsP50);
|
||||
Assert.Equal(4, diagnostics.Performance.IncrementalCpuMillisecondsP95);
|
||||
Assert.Equal(4, diagnostics.Performance.IncrementalCpuMillisecondsP99);
|
||||
Assert.Equal(0, diagnostics.Performance.AbsoluteReceiverCpuMillisecondsP50);
|
||||
Assert.Equal(6, diagnostics.Performance.InclusiveGpuMillisecondsP50);
|
||||
Assert.Equal(10, diagnostics.Performance.InclusiveGpuMillisecondsP95);
|
||||
Assert.Equal(10, diagnostics.Performance.InclusiveGpuMillisecondsP99);
|
||||
Assert.Contains(
|
||||
"perf=cpu-added:2.000/4.000/4.000ms,receiver-cpu-absolute:0.000/0.000/0.000ms,gpu-inclusive:6.000/10.000/10.000ms",
|
||||
RenderPackDiagnosticsFormatter.Format(diagnostics),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbsoluteReceiverCpuIsDiagnosticOnlyWhileGpuRemainsInclusive()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
fixture.Activate("high");
|
||||
fixture.Active.ResolvedGpuMilliseconds = 4.5;
|
||||
|
||||
fixture.Observe(
|
||||
cpuMilliseconds: 1.25,
|
||||
stable: true,
|
||||
receiverCpuMilliseconds: 0.75);
|
||||
|
||||
RenderPackPerformanceSnapshot performance = fixture.Controller.Performance;
|
||||
Assert.Equal(1, performance.CpuSampleCount);
|
||||
Assert.Equal(1.25, performance.IncrementalCpuMillisecondsP50);
|
||||
Assert.Equal(0.75, performance.AbsoluteReceiverCpuMillisecondsP50);
|
||||
Assert.Equal(4.5, performance.InclusiveGpuMillisecondsP50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeAbsoluteReceiverCpuCannotForceAutoDownButPackAddedCpuCan()
|
||||
{
|
||||
using var receiverHeavy = new Fixture();
|
||||
receiverHeavy.Activate("auto");
|
||||
receiverHeavy.Active.ResolvedGpuMilliseconds = 0.1;
|
||||
for (int i = 0; i < AtmosphericAutoQualityController.DowngradeHysteresisFrames; i++)
|
||||
{
|
||||
receiverHeavy.Observe(
|
||||
cpuMilliseconds: 0.1,
|
||||
stable: true,
|
||||
receiverCpuMilliseconds: 100);
|
||||
}
|
||||
|
||||
Assert.Equal(
|
||||
AtmosphericQualityLevel.Medium,
|
||||
receiverHeavy.Controller.AutoQuality!.Value.Current);
|
||||
Assert.Equal(
|
||||
100,
|
||||
receiverHeavy.Controller.Performance.AbsoluteReceiverCpuMillisecondsP99);
|
||||
Assert.Equal(
|
||||
0.1,
|
||||
receiverHeavy.Controller.Performance.IncrementalCpuMillisecondsP99);
|
||||
|
||||
using var packHeavy = new Fixture();
|
||||
packHeavy.Activate("auto");
|
||||
packHeavy.Active.ResolvedGpuMilliseconds = 0.1;
|
||||
for (int i = 0; i < AtmosphericAutoQualityController.DowngradeHysteresisFrames; i++)
|
||||
{
|
||||
packHeavy.Observe(
|
||||
cpuMilliseconds: 10,
|
||||
stable: true,
|
||||
receiverCpuMilliseconds: 0.1);
|
||||
}
|
||||
|
||||
Assert.Equal(
|
||||
AtmosphericQualityLevel.Low,
|
||||
packHeavy.Controller.AutoQuality!.Value.Current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StablePerformanceObservationAllocatesNothingAfterWarmup()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
fixture.Activate("high");
|
||||
for (int i = 0; i < 128; i++)
|
||||
fixture.Observe(1, stable: true);
|
||||
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (int i = 0; i < 128; i++)
|
||||
fixture.Observe(1, stable: true);
|
||||
long after = GC.GetAllocatedBytesForCurrentThread();
|
||||
|
||||
Assert.Equal(0, after - before);
|
||||
}
|
||||
|
||||
private sealed class Fixture : IDisposable
|
||||
{
|
||||
private readonly BufferedRenderPackRegistry _registry = new();
|
||||
private readonly IDisposable _registration;
|
||||
|
||||
internal Fixture(
|
||||
RenderPackHostCapabilities? capabilities = null,
|
||||
RenderPackDescriptor? descriptor = null,
|
||||
IRenderPackPreparationScheduler? preparationScheduler = null)
|
||||
{
|
||||
Factory = new FakeFactory(Events);
|
||||
_registration = _registry.Register(descriptor ?? Descriptor(), new EmptyAssets());
|
||||
Controller = new RenderPackController(
|
||||
() => RenderPackCatalog.Build(
|
||||
_registry.Snapshot(),
|
||||
capabilities ?? RenderPackHostCapabilities.Conformance),
|
||||
Factory,
|
||||
preparationScheduler: preparationScheduler
|
||||
?? InlineRenderPackPreparationScheduler.Instance);
|
||||
}
|
||||
|
||||
internal List<string> Events { get; } = [];
|
||||
internal FakeFactory Factory { get; }
|
||||
internal RenderPackController Controller { get; }
|
||||
internal FakeRuntime Active => Assert.IsType<FakeRuntime>(Controller.ActiveRuntime);
|
||||
|
||||
internal void Activate(string preset)
|
||||
{
|
||||
Controller.Request(new RenderPackSelectionSettings("auto.test", "1.0.0", preset));
|
||||
RenderPackActivationSnapshot snapshot = Controller.ApplyAtFrameBoundary(
|
||||
new RenderPackActivationExtent(1280, 720, 4));
|
||||
Assert.True(
|
||||
snapshot.State == RenderPackActivationState.Active,
|
||||
snapshot.Reason);
|
||||
}
|
||||
|
||||
internal void ObserveOverBudget(int count, bool stable = true)
|
||||
{
|
||||
Active.ResolvedGpuMilliseconds = 20;
|
||||
Active.RetainedGpuBytes = 512L * 1024 * 1024;
|
||||
for (int i = 0; i < count; i++)
|
||||
Observe(20, stable);
|
||||
}
|
||||
|
||||
internal void Observe(
|
||||
double cpuMilliseconds,
|
||||
bool stable,
|
||||
double receiverCpuMilliseconds = 0d)
|
||||
{
|
||||
var observation = new RenderPackFramePerformanceObservation(
|
||||
cpuMilliseconds,
|
||||
stable,
|
||||
1280,
|
||||
720,
|
||||
4,
|
||||
receiverCpuMilliseconds);
|
||||
Controller.ObserveActiveFrame(in observation);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Controller.Dispose();
|
||||
_registration.Dispose();
|
||||
_registry.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ControlledPreparationScheduler : IRenderPackPreparationScheduler
|
||||
{
|
||||
private readonly Queue<(Action Work, TaskCompletionSource Completion)> _pending = [];
|
||||
|
||||
public Task Schedule(Action preparation)
|
||||
{
|
||||
var completion = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending.Enqueue((preparation, completion));
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
internal void CompleteNext()
|
||||
{
|
||||
(Action work, TaskCompletionSource completion) = _pending.Dequeue();
|
||||
try
|
||||
{
|
||||
work();
|
||||
completion.SetResult();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
completion.SetException(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeFactory(List<string> events) : IRenderPackRuntimeFactory
|
||||
{
|
||||
internal int BuildCount { get; private set; }
|
||||
internal string? FailPreparePreset { get; set; }
|
||||
internal List<FakeRuntime> Runtimes { get; } = [];
|
||||
|
||||
public IRenderPackRuntime Build(
|
||||
RenderPackDescriptor descriptor,
|
||||
ValidatedRenderPackShaderAssets assets,
|
||||
RenderQualityPreset preset,
|
||||
IReadOnlyDictionary<string, string> userSettingOverrides)
|
||||
{
|
||||
BuildCount++;
|
||||
events.Add("build:" + preset.Id);
|
||||
var runtime = new FakeRuntime(
|
||||
descriptor,
|
||||
preset,
|
||||
events,
|
||||
string.Equals(FailPreparePreset, preset.Id, StringComparison.Ordinal));
|
||||
Runtimes.Add(runtime);
|
||||
return runtime;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeRuntime(
|
||||
RenderPackDescriptor descriptor,
|
||||
RenderQualityPreset preset,
|
||||
List<string> events,
|
||||
bool failPrepare) :
|
||||
IAtmosphericWorldGraphRuntime,
|
||||
IRenderPackRuntimePerformanceSource,
|
||||
IRenderPackRuntimeDiagnosticsSource
|
||||
{
|
||||
public RenderPackDescriptor Descriptor { get; } = descriptor;
|
||||
public RenderQualityPreset Preset { get; } = preset;
|
||||
internal long ResourceGeneration { get; set; }
|
||||
internal double ResolvedGpuMilliseconds { get; set; } = 1;
|
||||
internal long RetainedGpuBytes { get; set; } = 32L * 1024 * 1024;
|
||||
internal bool Prepared { get; private set; }
|
||||
internal bool Disposed { get; private set; }
|
||||
|
||||
public IGpuRenderTarget PrepareWorldTarget(int width, int height, int sampleCount)
|
||||
{
|
||||
events.Add($"prepare:{Preset.Id}:{width}x{height}x{sampleCount}");
|
||||
if (failPrepare)
|
||||
throw new InvalidOperationException($"injected {Preset.Id} resource failure");
|
||||
Prepared = true;
|
||||
ResourceGeneration++;
|
||||
return null!;
|
||||
}
|
||||
|
||||
public void RenderPostProcess(IGpuFrame frame, in AtmosphericFrameInputs inputs)
|
||||
{
|
||||
}
|
||||
|
||||
public RenderPackRuntimePerformanceMetrics CapturePerformanceMetrics() => new(
|
||||
ResourceGeneration,
|
||||
HasResolvedGpuMeasurement: true,
|
||||
ResolvedGpuMilliseconds,
|
||||
RetainedGpuBytes,
|
||||
TransientGpuBytes: 4L * 1024 * 1024);
|
||||
|
||||
public RenderPackRuntimeDiagnostics CaptureDiagnostics() =>
|
||||
RenderPackRuntimeDiagnostics.Empty(Preset.Id);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Disposed)
|
||||
return;
|
||||
Disposed = true;
|
||||
events.Add("dispose:" + Preset.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EmptyAssets : IRenderPackAssets
|
||||
{
|
||||
public Stream OpenRead(string assetKey) => Stream.Null;
|
||||
}
|
||||
|
||||
private static RenderPackDescriptor Descriptor() => new(
|
||||
"auto.test",
|
||||
"Auto Test",
|
||||
new Version(1, 0, 0),
|
||||
RenderPackApi.Current,
|
||||
RenderPackTier.Tier1,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
Preset("low"),
|
||||
Preset("medium"),
|
||||
Preset("high"),
|
||||
Preset("auto") with { AutoEligible = false },
|
||||
],
|
||||
[],
|
||||
null)
|
||||
{
|
||||
FeatureSummary = "Automatic-quality test render pack.",
|
||||
};
|
||||
|
||||
private static RenderPackDescriptor DescriptorWithAutomaticSetting()
|
||||
{
|
||||
RenderPackDescriptor descriptor = Descriptor();
|
||||
return descriptor with
|
||||
{
|
||||
QualityPresets = descriptor.QualityPresets.Select(preset =>
|
||||
preset.Semantic == RenderQualitySemantic.Automatic
|
||||
? preset with
|
||||
{
|
||||
SettingOverrides =
|
||||
[
|
||||
new RenderQualitySettingOverride("automatic-quality", "true"),
|
||||
],
|
||||
}
|
||||
: preset).ToArray(),
|
||||
Settings =
|
||||
[
|
||||
new RenderSettingDeclaration(
|
||||
"automatic-quality",
|
||||
"Automatic quality",
|
||||
RenderSettingKind.Boolean,
|
||||
"false",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
[])
|
||||
{
|
||||
Semantic = RenderSettingSemantic.AutomaticQuality,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static RenderQualityPreset Preset(string id) => new RenderQualityPreset(
|
||||
id,
|
||||
char.ToUpperInvariant(id[0]) + id[1..],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
(id switch
|
||||
{
|
||||
"low" => 64L,
|
||||
"high" => 256L,
|
||||
_ => 128L,
|
||||
}) * 1024 * 1024,
|
||||
10,
|
||||
12,
|
||||
2,
|
||||
3)
|
||||
{
|
||||
Semantic = id switch
|
||||
{
|
||||
"low" => RenderQualitySemantic.Low,
|
||||
"medium" => RenderQualitySemantic.Medium,
|
||||
"high" => RenderQualitySemantic.High,
|
||||
"auto" => RenderQualitySemantic.Automatic,
|
||||
_ => RenderQualitySemantic.Custom,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
using AcDream.App.Plugins;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
public sealed class RenderPackCapabilityResolverTests
|
||||
{
|
||||
private const long MiB = 1024L * 1024L;
|
||||
|
||||
[Fact]
|
||||
public void ResolverCarriesActualAdapterLimitsAndAppliesTheDocumentedMemoryShare()
|
||||
{
|
||||
using var baseline = new RecordingGpuDevice();
|
||||
using var device = new RecordingGpuDevice
|
||||
{
|
||||
Capabilities = baseline.Capabilities with
|
||||
{
|
||||
MaxImageDimension2D = 1536,
|
||||
MaxImageArrayLayers = 2,
|
||||
DeviceLocalMemoryBytes = 512UL * 1024 * 1024,
|
||||
},
|
||||
};
|
||||
|
||||
RenderPackHostCapabilities host = RenderPackCapabilityResolver.Resolve(
|
||||
device.Capabilities);
|
||||
|
||||
Assert.Equal(1536, host.MaxImageDimension2D);
|
||||
Assert.Equal(2, host.MaxImageArrayLayers);
|
||||
Assert.Equal(64L * MiB, host.MaxPackResidentBytes);
|
||||
Assert.Equal(64L * MiB, host.MaxPackTransientBytes);
|
||||
Assert.Contains(
|
||||
RenderCapability.AuthoredCelestialDirectionalLight,
|
||||
host.Available);
|
||||
Assert.Contains("one eighth", host.MemoryPolicyDescription, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PresetCompatibilityNamesTheExactArrayLimitAndKeepsLowAvailable()
|
||||
{
|
||||
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
var host = new RenderPackHostCapabilities(
|
||||
Enum.GetValues<RenderCapability>().ToHashSet(),
|
||||
MaxImageDimension2D: 4096,
|
||||
MaxImageArrayLayers: 2,
|
||||
MaxPackResidentBytes: 256L * MiB);
|
||||
|
||||
RenderPackValidationResult low = RenderPackValidator.ValidatePresetCompatibility(
|
||||
descriptor,
|
||||
descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Low),
|
||||
host);
|
||||
RenderPackValidationResult medium = RenderPackValidator.ValidatePresetCompatibility(
|
||||
descriptor,
|
||||
descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Medium),
|
||||
host);
|
||||
|
||||
Assert.True(low.Success, low.Reason);
|
||||
Assert.False(medium.Success);
|
||||
Assert.Equal(
|
||||
"Preset 'medium' resource 'directional-shadow-depth' needs 3 image-array layers; "
|
||||
+ "this device provides 2.",
|
||||
medium.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuntimeBudgetRejectsResolvedRelativeExtentBeforeAllocation()
|
||||
{
|
||||
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
RenderQualityPreset low = descriptor.QualityPresets.Single(value =>
|
||||
value.Semantic == RenderQualitySemantic.Low);
|
||||
var host = new RenderPackHostCapabilities(
|
||||
Enum.GetValues<RenderCapability>().ToHashSet(),
|
||||
MaxImageDimension2D: 1024,
|
||||
MaxImageArrayLayers: 2,
|
||||
MaxPackResidentBytes: 256L * MiB);
|
||||
|
||||
NotSupportedException error = Assert.Throws<NotSupportedException>(() =>
|
||||
RenderPackResourceBudgetPlanner.RequireWithinHost(
|
||||
descriptor,
|
||||
low,
|
||||
1920,
|
||||
1080,
|
||||
sampleCount: 1,
|
||||
host));
|
||||
|
||||
Assert.Contains("1920x1080", error.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("maximum 2-D image edge is 1024", error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CatalogKeepsPackVisibleAndPublishesPerPresetUnavailableReasons()
|
||||
{
|
||||
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
using var registry = new BufferedRenderPackRegistry();
|
||||
using IDisposable registration = registry.Register(descriptor, new EmptyAssets());
|
||||
var host = new RenderPackHostCapabilities(
|
||||
Enum.GetValues<RenderCapability>().ToHashSet(),
|
||||
MaxImageDimension2D: 4096,
|
||||
MaxImageArrayLayers: 2,
|
||||
MaxPackResidentBytes: 64L * MiB,
|
||||
MemoryPolicyDescription: "test 512-MiB adapter policy");
|
||||
|
||||
RenderPackCatalog catalog = RenderPackCatalog.Build(registry.Snapshot(), host);
|
||||
Assert.True(catalog.TryGet(descriptor.Id, out RenderPackCatalogEntry entry));
|
||||
Assert.True(entry.IsCompatible, entry.IncompatibilityReason);
|
||||
Assert.Null(entry.PresetIncompatibilityReasons["low"]);
|
||||
Assert.Contains(
|
||||
"declares a 134217728-byte resident GPU ceiling",
|
||||
entry.PresetIncompatibilityReasons["medium"],
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"declares a 268435456-byte resident GPU ceiling",
|
||||
entry.PresetIncompatibilityReasons["high"],
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingGpuTimestampsDisablesOnlyAutoAndLeavesExplicitLowAvailable()
|
||||
{
|
||||
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
HashSet<RenderCapability> available = Enum.GetValues<RenderCapability>().ToHashSet();
|
||||
available.Remove(RenderCapability.GpuTimestampQueries);
|
||||
var host = new RenderPackHostCapabilities(available, 4096, 4, 256L * MiB);
|
||||
|
||||
RenderPackValidationResult low = RenderPackValidator.ValidatePresetCompatibility(
|
||||
descriptor,
|
||||
descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Low),
|
||||
host);
|
||||
RenderPackValidationResult auto = RenderPackValidator.ValidatePresetCompatibility(
|
||||
descriptor,
|
||||
descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Automatic),
|
||||
host);
|
||||
|
||||
Assert.True(low.Success, low.Reason);
|
||||
Assert.False(auto.Success);
|
||||
Assert.Contains("asynchronous GPU timestamp queries", auto.Reason, StringComparison.Ordinal);
|
||||
Assert.Contains("explicit Low remains available", auto.Reason, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingMultiviewMakesHintedLowUnavailableButLeavesOrdinaryMediumAvailable()
|
||||
{
|
||||
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
HashSet<RenderCapability> available = Enum.GetValues<RenderCapability>().ToHashSet();
|
||||
available.Remove(RenderCapability.MultiviewDirectionalShadowCascades);
|
||||
var host = new RenderPackHostCapabilities(available, 4096, 4, 256L * MiB);
|
||||
|
||||
RenderPackValidationResult low = RenderPackValidator.ValidatePresetCompatibility(
|
||||
descriptor,
|
||||
descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Low),
|
||||
host);
|
||||
RenderPackValidationResult medium = RenderPackValidator.ValidatePresetCompatibility(
|
||||
descriptor,
|
||||
descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Medium),
|
||||
host);
|
||||
|
||||
Assert.False(low.Success);
|
||||
Assert.Contains("MultiviewDirectionalShadowCascades", low.Reason, StringComparison.Ordinal);
|
||||
Assert.True(medium.Success, medium.Reason);
|
||||
}
|
||||
|
||||
private sealed class EmptyAssets : IRenderPackAssets
|
||||
{
|
||||
public Stream OpenRead(string assetKey) => Stream.Null;
|
||||
}
|
||||
}
|
||||
1171
tests/AcDream.App.Tests/Rendering/Packs/RenderPackControllerTests.cs
Normal file
1171
tests/AcDream.App.Tests/Rendering/Packs/RenderPackControllerTests.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,636 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Plugins;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
using DatReaderWriter.Enums;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
/// <summary>
|
||||
/// Recording-RHI lifetime gate for the complete optional renderer. This is
|
||||
/// deliberately longer and more compositional than the focused owner tests:
|
||||
/// one fixture repeatedly crosses pack, preset, size, frame-flight, topology,
|
||||
/// failure, and terminal renderer-lifetime boundaries without a physical GPU.
|
||||
/// </summary>
|
||||
public sealed class RenderPackLongCycleConvergenceTests
|
||||
{
|
||||
private const int LongCycleCount = 12;
|
||||
|
||||
[Fact]
|
||||
public void RepeatedPackResizeFailureGenerationAndFlightCyclesConvergeExactly()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
PrimeDeviceOwnedSamplerCache(device);
|
||||
LiveGpuLedger baseline = LiveGpuLedger.Capture(device);
|
||||
var lifetime = new RecordingRendererLifetime(device);
|
||||
|
||||
try
|
||||
{
|
||||
AtmosphericPostProcessGraph low = lifetime.Activate("low", 640, 360);
|
||||
ExerciseResizeFlightAndGenerationReplacement(device, low);
|
||||
lifetime.SelectRetail(640, 360);
|
||||
AssertConverged(device, baseline, lifetime);
|
||||
|
||||
device.PipelineFailure = description =>
|
||||
string.Equals(description.Name, "atmospheric-filmic", StringComparison.Ordinal)
|
||||
? new InvalidOperationException("injected candidate pipeline failure")
|
||||
: null;
|
||||
RenderPackActivationSnapshot failed = lifetime.Request(
|
||||
Selection("medium") with
|
||||
{
|
||||
SettingOverrides = RenderPackSettingOverrides.Empty.Set("exposure", "1.05"),
|
||||
},
|
||||
704,
|
||||
396);
|
||||
Assert.Equal(RenderPackActivationState.FailedToRetail, failed.State);
|
||||
Assert.Contains("injected candidate pipeline failure", failed.Reason, StringComparison.Ordinal);
|
||||
AssertConverged(device, baseline, lifetime);
|
||||
|
||||
device.PipelineFailure = null;
|
||||
AtmosphericPostProcessGraph recovered = lifetime.Activate("medium", 704, 396);
|
||||
RenderOnePostFrame(device, recovered, 704, 396);
|
||||
lifetime.SelectRetail(704, 396);
|
||||
AssertConverged(device, baseline, lifetime);
|
||||
|
||||
string[] presets = ["low", "medium", "high"];
|
||||
for (int cycle = 0; cycle < LongCycleCount; cycle++)
|
||||
{
|
||||
foreach (string preset in presets)
|
||||
{
|
||||
int width = 640 + cycle % 3 * 64;
|
||||
int height = 360 + cycle % 3 * 36;
|
||||
AtmosphericPostProcessGraph graph = lifetime.Activate(
|
||||
preset,
|
||||
width,
|
||||
height);
|
||||
|
||||
RecordingGpuRenderTarget initial = Assert.IsType<RecordingGpuRenderTarget>(
|
||||
graph.PrepareWorldTarget(width, height, sampleCount: 1));
|
||||
RecordingGpuRenderTarget resized = Assert.IsType<RecordingGpuRenderTarget>(
|
||||
graph.PrepareWorldTarget(width + 16, height + 9, sampleCount: 1));
|
||||
Assert.True(initial.IsDisposed);
|
||||
RecordingGpuRenderTarget restored = Assert.IsType<RecordingGpuRenderTarget>(
|
||||
graph.PrepareWorldTarget(width, height, sampleCount: 1));
|
||||
Assert.True(resized.IsDisposed);
|
||||
|
||||
RenderOnePostFrame(device, graph, width, height);
|
||||
lifetime.SelectRetail(width, height);
|
||||
Assert.True(restored.IsDisposed);
|
||||
AssertConverged(device, baseline, lifetime);
|
||||
}
|
||||
}
|
||||
|
||||
_ = lifetime.Activate("high", 800, 450);
|
||||
Assert.NotEqual(baseline, LiveGpuLedger.Capture(device));
|
||||
}
|
||||
finally
|
||||
{
|
||||
lifetime.Dispose();
|
||||
}
|
||||
|
||||
Assert.Equal(0, lifetime.RegisteredPackCount);
|
||||
AssertConverged(device, baseline, lifetime);
|
||||
Assert.All(
|
||||
device.CreatedBuffers.Where(static value =>
|
||||
value.Name.StartsWith("directional-shadow-", StringComparison.Ordinal)),
|
||||
static value => Assert.True(value.IsDisposed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceRecreationIsFullRendererTeardownThenANewContextAndDevice()
|
||||
{
|
||||
RecordingGpuDevice firstDevice = new();
|
||||
PrimeDeviceOwnedSamplerCache(firstDevice);
|
||||
LiveGpuLedger firstBaseline = LiveGpuLedger.Capture(firstDevice);
|
||||
var firstLifetime = new RecordingRendererLifetime(firstDevice);
|
||||
AtmosphericPostProcessGraph firstGraph = firstLifetime.Activate("low", 640, 360);
|
||||
RenderOnePostFrame(firstDevice, firstGraph, 640, 360);
|
||||
Assert.Equal(1, firstLifetime.ActivationGeneration);
|
||||
RecordingGpuPipeline firstPipeline = Assert.Single(
|
||||
firstDevice.CreatedPipelines,
|
||||
static value => string.Equals(
|
||||
value.Description.Name,
|
||||
"atmospheric-filmic",
|
||||
StringComparison.Ordinal));
|
||||
|
||||
firstLifetime.Dispose();
|
||||
Assert.Equal(0, firstLifetime.RegisteredPackCount);
|
||||
AssertConverged(firstDevice, firstBaseline, firstLifetime);
|
||||
Assert.True(firstPipeline.IsDisposed);
|
||||
firstDevice.Dispose();
|
||||
Assert.Throws<ObjectDisposedException>(() => firstDevice.BeginFrame());
|
||||
|
||||
using var secondDevice = new RecordingGpuDevice();
|
||||
PrimeDeviceOwnedSamplerCache(secondDevice);
|
||||
LiveGpuLedger secondBaseline = LiveGpuLedger.Capture(secondDevice);
|
||||
var secondLifetime = new RecordingRendererLifetime(secondDevice);
|
||||
try
|
||||
{
|
||||
AtmosphericPostProcessGraph secondGraph = secondLifetime.Activate(
|
||||
"low",
|
||||
640,
|
||||
360);
|
||||
RenderOnePostFrame(secondDevice, secondGraph, 640, 360);
|
||||
Assert.Equal(1, secondLifetime.ActivationGeneration);
|
||||
RecordingGpuPipeline secondPipeline = Assert.Single(
|
||||
secondDevice.CreatedPipelines,
|
||||
static value => string.Equals(
|
||||
value.Description.Name,
|
||||
"atmospheric-filmic",
|
||||
StringComparison.Ordinal));
|
||||
Assert.NotSame(firstPipeline, secondPipeline);
|
||||
Assert.Equal(1, secondBaseline.TextureSlots);
|
||||
Assert.True(secondDevice.LiveTextureSlotCount > secondBaseline.TextureSlots);
|
||||
}
|
||||
finally
|
||||
{
|
||||
secondLifetime.Dispose();
|
||||
}
|
||||
|
||||
Assert.Equal(0, secondLifetime.RegisteredPackCount);
|
||||
AssertConverged(secondDevice, secondBaseline, secondLifetime);
|
||||
}
|
||||
|
||||
private static void ExerciseResizeFlightAndGenerationReplacement(
|
||||
RecordingGpuDevice device,
|
||||
AtmosphericPostProcessGraph graph)
|
||||
{
|
||||
var retainedTransforms = new DirectionalShadowTransformBufferSet(device);
|
||||
DirectionalShadowPreparedDraws world = CreateWorldDraws(
|
||||
device.DefaultTextureSlot,
|
||||
RenderSceneGeneration.FromRaw(1),
|
||||
casterBuildSequence: 1);
|
||||
DirectionalShadowTerrainPreparedDraws terrain = CreateTerrainDraws(frameSequence: 1);
|
||||
using IGpuBuffer worldVertices = Buffer(device, "lifetime-world-v", GpuBufferUsage.Vertex);
|
||||
using IGpuBuffer worldIndices = Buffer(device, "lifetime-world-i", GpuBufferUsage.Index);
|
||||
using IGpuBuffer terrainVertices = Buffer(device, "lifetime-terrain-v", GpuBufferUsage.Vertex);
|
||||
using IGpuBuffer terrainIndices = Buffer(device, "lifetime-terrain-i", GpuBufferUsage.Index);
|
||||
var worldGeometry = new DirectionalShadowMeshGeometry(worldVertices, worldIndices);
|
||||
var terrainGeometry = new DirectionalShadowTerrainGeometry(
|
||||
terrainVertices,
|
||||
terrainIndices);
|
||||
|
||||
RenderShadowFrame(
|
||||
device,
|
||||
graph,
|
||||
retainedTransforms,
|
||||
world,
|
||||
terrain,
|
||||
worldGeometry,
|
||||
terrainGeometry,
|
||||
640,
|
||||
360);
|
||||
RenderShadowFrame(
|
||||
device,
|
||||
graph,
|
||||
retainedTransforms,
|
||||
world,
|
||||
terrain,
|
||||
worldGeometry,
|
||||
terrainGeometry,
|
||||
640,
|
||||
360);
|
||||
|
||||
RecordingGpuBuffer[] firstTopologyBuffers = device.CreatedBuffers
|
||||
.Where(static value => value.Name.StartsWith(
|
||||
"directional-shadow-",
|
||||
StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
Assert.Equal(5, firstTopologyBuffers.Length);
|
||||
Assert.All(firstTopologyBuffers, static value => Assert.False(value.IsDisposed));
|
||||
|
||||
RebuildWorldDraws(
|
||||
world,
|
||||
device.DefaultTextureSlot,
|
||||
RenderSceneGeneration.FromRaw(2),
|
||||
casterBuildSequence: 2);
|
||||
RebuildTerrainDraws(terrain, frameSequence: 2);
|
||||
RenderShadowFrame(
|
||||
device,
|
||||
graph,
|
||||
retainedTransforms,
|
||||
world,
|
||||
terrain,
|
||||
worldGeometry,
|
||||
terrainGeometry,
|
||||
640,
|
||||
360);
|
||||
RenderShadowFrame(
|
||||
device,
|
||||
graph,
|
||||
retainedTransforms,
|
||||
world,
|
||||
terrain,
|
||||
worldGeometry,
|
||||
terrainGeometry,
|
||||
640,
|
||||
360);
|
||||
|
||||
Assert.All(firstTopologyBuffers, static value => Assert.True(value.IsDisposed));
|
||||
Assert.Equal(
|
||||
5,
|
||||
device.CreatedBuffers.Count(static value =>
|
||||
value.Name.StartsWith("directional-shadow-", StringComparison.Ordinal)
|
||||
&& !value.IsDisposed));
|
||||
retainedTransforms.Dispose();
|
||||
Assert.All(
|
||||
device.CreatedBuffers.Where(static value => value.Name.Contains(
|
||||
"directional-shadow-transforms-",
|
||||
StringComparison.Ordinal)),
|
||||
static value => Assert.True(value.IsDisposed));
|
||||
}
|
||||
|
||||
private static void RenderShadowFrame(
|
||||
RecordingGpuDevice device,
|
||||
AtmosphericPostProcessGraph graph,
|
||||
DirectionalShadowTransformBufferSet retainedTransforms,
|
||||
DirectionalShadowPreparedDraws world,
|
||||
DirectionalShadowTerrainPreparedDraws terrain,
|
||||
DirectionalShadowMeshGeometry worldGeometry,
|
||||
DirectionalShadowTerrainGeometry terrainGeometry,
|
||||
int width,
|
||||
int height)
|
||||
{
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
WorldTransformFrameSlice transforms = retainedTransforms.Publish(
|
||||
frame,
|
||||
world.BuildSequence,
|
||||
world.Transforms,
|
||||
world.DynamicTransformSlots,
|
||||
world.AllDynamicTransformSlots);
|
||||
var shadows = Assert.IsType<DirectionalSunShadowRenderer>(
|
||||
graph.DirectionalShadowReceivers);
|
||||
DirectionalSunShadowDiagnostics diagnostics = shadows.RenderPrepared(
|
||||
frame,
|
||||
EnabledEnvironment(),
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.CreatePerspectiveFieldOfView(1f, 16f / 9f, 0.1f, 500f),
|
||||
cameraNearMeters: 0.1f,
|
||||
casterDepthPaddingMeters: 48f,
|
||||
world,
|
||||
terrain,
|
||||
worldGeometry,
|
||||
terrainGeometry,
|
||||
transforms);
|
||||
Assert.Equal(2, diagnostics.CascadeCount);
|
||||
|
||||
IGpuRenderTarget target = graph.PrepareWorldTarget(width, height, sampleCount: 1);
|
||||
RecordWorldPass(frame, target);
|
||||
AtmosphericFrameInputs inputs = Inputs(width, height, isOutdoor: true);
|
||||
graph.RenderPostProcess(frame, in inputs);
|
||||
}
|
||||
|
||||
private static void RenderOnePostFrame(
|
||||
RecordingGpuDevice device,
|
||||
AtmosphericPostProcessGraph graph,
|
||||
int width,
|
||||
int height)
|
||||
{
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
IGpuRenderTarget target = graph.PrepareWorldTarget(width, height, sampleCount: 1);
|
||||
RecordWorldPass(frame, target);
|
||||
AtmosphericFrameInputs inputs = Inputs(width, height, isOutdoor: false);
|
||||
graph.RenderPostProcess(frame, in inputs);
|
||||
}
|
||||
|
||||
private static AtmosphericFrameInputs Inputs(
|
||||
int width,
|
||||
int height,
|
||||
bool isOutdoor) => new(
|
||||
new Vector2(0.5f, 0.35f),
|
||||
SunIsOnScreen: true,
|
||||
SunElevationDegrees: isOutdoor ? 20f : -10f,
|
||||
new Vector3(1f, 0.85f, 0.65f),
|
||||
Vector3.Normalize(new Vector3(0.2f, 0.5f, 0.8f)),
|
||||
SunDirectionalBrightness: 1f,
|
||||
Matrix4x4.Identity,
|
||||
ActiveDayGroup: isOutdoor ? 0 : -1,
|
||||
WeatherKind.Clear,
|
||||
WeatherIntensity: 0f,
|
||||
DeltaSeconds: 1d / 60d,
|
||||
width,
|
||||
height,
|
||||
IsOutdoor: isOutdoor);
|
||||
|
||||
private static void RecordWorldPass(IGpuFrame frame, IGpuRenderTarget world)
|
||||
{
|
||||
using IGpuPassEncoder _ = frame.BeginPass(new GpuPassDescription
|
||||
{
|
||||
Name = "lifetime-world-hdr",
|
||||
Color = new GpuColorAttachment(
|
||||
world,
|
||||
GpuLoadOp.Clear,
|
||||
GpuStoreOp.Store,
|
||||
Vector4.Zero),
|
||||
Depth = new GpuDepthAttachment(
|
||||
GpuLoadOp.Clear,
|
||||
GpuStoreOp.Store,
|
||||
1f,
|
||||
0),
|
||||
SampleCount = 1,
|
||||
});
|
||||
}
|
||||
|
||||
private static DirectionalShadowPreparedDraws CreateWorldDraws(
|
||||
GpuTextureSlot cutoutSlot,
|
||||
RenderSceneGeneration generation,
|
||||
ulong casterBuildSequence)
|
||||
{
|
||||
var draws = new DirectionalShadowPreparedDraws();
|
||||
RebuildWorldDraws(draws, cutoutSlot, generation, casterBuildSequence);
|
||||
return draws;
|
||||
}
|
||||
|
||||
private static void RebuildWorldDraws(
|
||||
DirectionalShadowPreparedDraws draws,
|
||||
GpuTextureSlot cutoutSlot,
|
||||
RenderSceneGeneration generation,
|
||||
ulong casterBuildSequence)
|
||||
{
|
||||
Assert.True(draws.TryBegin(generation, casterBuildSequence, estimatedInstances: 2));
|
||||
Matrix4x4 opaque = Matrix4x4.CreateTranslation(1f, 2f, 3f);
|
||||
Matrix4x4 cutout = Matrix4x4.CreateRotationZ(0.3f)
|
||||
* Matrix4x4.CreateTranslation(4f, 5f, 6f);
|
||||
draws.Add(
|
||||
0,
|
||||
0,
|
||||
6,
|
||||
GpuTextureSlot.Unassigned,
|
||||
0,
|
||||
CullMode.CounterClockwise,
|
||||
DirectionalShadowCasterMaterial.Opaque,
|
||||
in opaque);
|
||||
draws.Add(
|
||||
6,
|
||||
4,
|
||||
12,
|
||||
cutoutSlot,
|
||||
2,
|
||||
CullMode.None,
|
||||
DirectionalShadowCasterMaterial.AlphaCutout,
|
||||
in cutout);
|
||||
DirectionalShadowPreparationStats stats = default;
|
||||
draws.Complete(generation, casterBuildSequence, in stats);
|
||||
}
|
||||
|
||||
private static DirectionalShadowTerrainPreparedDraws CreateTerrainDraws(
|
||||
long frameSequence)
|
||||
{
|
||||
var draws = new DirectionalShadowTerrainPreparedDraws();
|
||||
RebuildTerrainDraws(draws, frameSequence);
|
||||
return draws;
|
||||
}
|
||||
|
||||
private static void RebuildTerrainDraws(
|
||||
DirectionalShadowTerrainPreparedDraws draws,
|
||||
long frameSequence)
|
||||
{
|
||||
Assert.True(draws.TryBegin(frameSequence, estimatedCommands: 1));
|
||||
var range = new DirectionalShadowTerrainRange(20, 60);
|
||||
draws.Add(in range);
|
||||
draws.Complete(frameSequence);
|
||||
}
|
||||
|
||||
private static DirectionalShadowEnvironmentState EnabledEnvironment() => new(
|
||||
DirectionalShadowGateReason.Enabled,
|
||||
Vector3.Normalize(new Vector3(0.2f, 0.3f, 1f)),
|
||||
LightElevationSin: 0.94f,
|
||||
Strength: 0.8f,
|
||||
SoftnessMultiplier: 1.25f,
|
||||
SourceKind: AuthoredCelestialShadowSourceKind.Sun);
|
||||
|
||||
private static IGpuBuffer Buffer(
|
||||
RecordingGpuDevice device,
|
||||
string name,
|
||||
GpuBufferUsage usage) => device.CreateBuffer(new GpuBufferDescription(
|
||||
name,
|
||||
4096,
|
||||
usage | GpuBufferUsage.TransferDestination,
|
||||
GpuMemoryResidency.DeviceLocal));
|
||||
|
||||
private static RenderPackSelectionSettings Selection(string preset) => new(
|
||||
BuiltInAtmosphericRenderPack.Descriptor.Id,
|
||||
BuiltInAtmosphericRenderPack.Descriptor.PackVersion.ToString(),
|
||||
preset);
|
||||
|
||||
private static void AssertConverged(
|
||||
RecordingGpuDevice device,
|
||||
LiveGpuLedger baseline,
|
||||
RecordingRendererLifetime lifetime)
|
||||
{
|
||||
Assert.Equal(baseline, LiveGpuLedger.Capture(device));
|
||||
Assert.Equal(0, device.OpenFrameCount);
|
||||
Assert.Empty(device.PipelineFormatLeases);
|
||||
Assert.Equal(0, lifetime.LiveReceiverCandidates);
|
||||
Assert.Equal(lifetime.IsDisposed ? 0 : 1, lifetime.RegisteredPackCount);
|
||||
Assert.Null(lifetime.ActiveRuntime);
|
||||
}
|
||||
|
||||
private static void PrimeDeviceOwnedSamplerCache(RecordingGpuDevice device)
|
||||
{
|
||||
// Vulkan samplers are description-keyed device objects and intentionally
|
||||
// survive individual pack runtimes. UiNearest is created with the device;
|
||||
// prime WorldClamp so the fixture baseline includes the complete cache.
|
||||
_ = device.CreateSampler(GpuSamplerDescription.WorldClamp);
|
||||
}
|
||||
|
||||
private static IRenderPackAssets BuiltInAssets() =>
|
||||
BuiltInAtmosphericRenderPack.CreateAssets(Path.Combine(
|
||||
RepositoryRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"Shaders",
|
||||
"spv"));
|
||||
|
||||
private static string RepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null
|
||||
&& !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
{
|
||||
directory = directory.Parent;
|
||||
}
|
||||
return directory?.FullName
|
||||
?? throw new InvalidOperationException("Could not locate repository root.");
|
||||
}
|
||||
|
||||
private readonly record struct LiveGpuLedger(
|
||||
int Buffers,
|
||||
int Pipelines,
|
||||
int Samplers,
|
||||
int Textures,
|
||||
int RenderTargets,
|
||||
int DirectionalDepthTargets,
|
||||
int TextureSlots,
|
||||
int PipelineFormatLeases)
|
||||
{
|
||||
internal static LiveGpuLedger Capture(RecordingGpuDevice device) => new(
|
||||
device.CreatedBuffers.Count(static value => !value.IsDisposed),
|
||||
device.CreatedPipelines.Count(static value => !value.IsDisposed),
|
||||
device.CreatedSamplers.Count(static value => !value.IsDisposed),
|
||||
device.CreatedTextures.Count(static value => !value.IsDisposed),
|
||||
device.CreatedRenderTargets.Count(static value => !value.IsDisposed),
|
||||
device.CreatedDirectionalDepthTargets.Count(static value => !value.IsDisposed),
|
||||
device.LiveTextureSlotCount,
|
||||
device.PipelineFormatLeases.Values.Sum());
|
||||
}
|
||||
|
||||
private sealed class RecordingRendererLifetime : IDisposable
|
||||
{
|
||||
private readonly BufferedRenderPackRegistry _registry = new();
|
||||
private readonly IDisposable _registration;
|
||||
private readonly RecordingReceiverCoordinator _receivers = new();
|
||||
private bool _disposed;
|
||||
|
||||
internal RecordingRendererLifetime(RecordingGpuDevice device)
|
||||
{
|
||||
_registration = _registry.Register(
|
||||
BuiltInAtmosphericRenderPack.Descriptor,
|
||||
BuiltInAssets());
|
||||
Controller = new RenderPackController(
|
||||
() => RenderPackCatalog.Build(
|
||||
_registry.Snapshot(),
|
||||
RenderPackCapabilityResolver.Resolve(device.Capabilities)),
|
||||
new AtmosphericRenderPackRuntimeFactory(device),
|
||||
_receivers,
|
||||
InlineRenderPackPreparationScheduler.Instance);
|
||||
}
|
||||
|
||||
private RenderPackController Controller { get; }
|
||||
|
||||
internal long ActivationGeneration => Controller.Snapshot.ActivationGeneration;
|
||||
|
||||
internal IRenderPackRuntime? ActiveRuntime => Controller.ActiveRuntime;
|
||||
|
||||
internal int LiveReceiverCandidates => _receivers.LiveCandidateCount;
|
||||
|
||||
internal bool IsDisposed => _disposed;
|
||||
|
||||
internal int RegisteredPackCount => _disposed ? 0 : _registry.Snapshot().Count;
|
||||
|
||||
internal AtmosphericPostProcessGraph Activate(
|
||||
string preset,
|
||||
int width,
|
||||
int height)
|
||||
{
|
||||
RenderPackActivationSnapshot snapshot = Request(
|
||||
Selection(preset),
|
||||
width,
|
||||
height);
|
||||
Assert.Equal(RenderPackActivationState.Active, snapshot.State);
|
||||
Assert.Null(snapshot.Reason);
|
||||
Assert.Equal(1, LiveReceiverCandidates);
|
||||
return Assert.IsType<AtmosphericPostProcessGraph>(Controller.ActiveRuntime);
|
||||
}
|
||||
|
||||
internal RenderPackActivationSnapshot Request(
|
||||
RenderPackSelectionSettings selection,
|
||||
int width,
|
||||
int height)
|
||||
{
|
||||
Controller.Request(selection);
|
||||
return Controller.ApplyAtFrameBoundary(
|
||||
new RenderPackActivationExtent(width, height, 1));
|
||||
}
|
||||
|
||||
internal void SelectRetail(int width, int height)
|
||||
{
|
||||
RenderPackActivationSnapshot snapshot = Request(
|
||||
RenderPackSelectionSettings.Retail,
|
||||
width,
|
||||
height);
|
||||
Assert.Equal(RenderPackActivationState.Retail, snapshot.State);
|
||||
Assert.True(snapshot.Selection.IsRetail);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
Controller.Dispose();
|
||||
_registration.Dispose();
|
||||
Assert.Empty(_registry.Snapshot());
|
||||
_registry.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingReceiverCoordinator :
|
||||
IRenderPackReceiverPipelineCoordinator
|
||||
{
|
||||
private Candidate? _active;
|
||||
|
||||
internal int LiveCandidateCount { get; private set; }
|
||||
|
||||
public IRenderPackReceiverPipelineCandidate Prepare(
|
||||
IDirectionalShadowReceiverSource? source,
|
||||
int sampleCount)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleCount);
|
||||
var candidate = new Candidate(this, source is not null);
|
||||
LiveCandidateCount++;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
public void Publish(IRenderPackReceiverPipelineCandidate candidate)
|
||||
{
|
||||
if (candidate is not Candidate prepared
|
||||
|| !ReferenceEquals(prepared.Owner, this))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Receiver candidate belongs to another coordinator.",
|
||||
nameof(candidate));
|
||||
}
|
||||
prepared.Publish();
|
||||
_active?.Dispose();
|
||||
_active = prepared;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_active?.Dispose();
|
||||
_active = null;
|
||||
}
|
||||
|
||||
private void Released() => LiveCandidateCount--;
|
||||
|
||||
private sealed class Candidate(
|
||||
RecordingReceiverCoordinator owner,
|
||||
bool hasDirectionalSource) : IRenderPackReceiverPipelineCandidate
|
||||
{
|
||||
private bool _disposed;
|
||||
private bool _published;
|
||||
|
||||
internal RecordingReceiverCoordinator Owner { get; } = owner;
|
||||
|
||||
internal void Publish()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!hasDirectionalSource)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The atmospheric candidate lost its directional receiver source.");
|
||||
}
|
||||
if (_published)
|
||||
throw new InvalidOperationException("Receiver candidate was published twice.");
|
||||
_published = true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
Owner.Released();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
using AcDream.App.Rendering.Packs;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
public sealed class RenderPackPerformanceWindowTests
|
||||
{
|
||||
[Fact]
|
||||
public void Snapshot_reports_independent_cpu_and_delayed_gpu_percentiles()
|
||||
{
|
||||
var window = new RenderPackPerformanceWindow(capacity: 8);
|
||||
window.Observe(1, 11, false, 0, 10, 2);
|
||||
window.Observe(2, 12, true, 4, 11, 3);
|
||||
window.Observe(3, 13, true, 6, 12, 4);
|
||||
window.Observe(4, 14, true, 8, 13, 5);
|
||||
|
||||
RenderPackPerformanceSnapshot value = window.Snapshot();
|
||||
|
||||
Assert.Equal(4, value.CpuSampleCount);
|
||||
Assert.Equal(4, value.AbsoluteReceiverCpuSampleCount);
|
||||
Assert.Equal(3, value.GpuSampleCount);
|
||||
Assert.Equal(2, value.IncrementalCpuMillisecondsP50);
|
||||
Assert.Equal(4, value.IncrementalCpuMillisecondsP95);
|
||||
Assert.Equal(4, value.IncrementalCpuMillisecondsP99);
|
||||
Assert.Equal(12, value.AbsoluteReceiverCpuMillisecondsP50);
|
||||
Assert.Equal(14, value.AbsoluteReceiverCpuMillisecondsP95);
|
||||
Assert.Equal(14, value.AbsoluteReceiverCpuMillisecondsP99);
|
||||
Assert.Equal(6, value.InclusiveGpuMillisecondsP50);
|
||||
Assert.Equal(8, value.InclusiveGpuMillisecondsP95);
|
||||
Assert.Equal(8, value.InclusiveGpuMillisecondsP99);
|
||||
Assert.Equal(13, value.ResidentGpuBytes);
|
||||
Assert.Equal(5, value.TransientGpuBytes);
|
||||
Assert.False(value.HasStableAutoWindow(4));
|
||||
Assert.True(value.HasStableAutoWindow(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capacity_is_a_rolling_window_and_reset_removes_mixed_quality_data()
|
||||
{
|
||||
var window = new RenderPackPerformanceWindow(capacity: 3);
|
||||
for (int i = 1; i <= 4; i++)
|
||||
window.Observe(i, i * 10, true, i * 2, i, i);
|
||||
|
||||
RenderPackPerformanceSnapshot rolled = window.Snapshot();
|
||||
Assert.Equal(3, rolled.CpuSampleCount);
|
||||
Assert.Equal(3, rolled.GpuSampleCount);
|
||||
Assert.Equal(3, rolled.IncrementalCpuMillisecondsP50);
|
||||
Assert.Equal(4, rolled.IncrementalCpuMillisecondsP99);
|
||||
Assert.Equal(30, rolled.AbsoluteReceiverCpuMillisecondsP50);
|
||||
Assert.Equal(40, rolled.AbsoluteReceiverCpuMillisecondsP99);
|
||||
Assert.Equal(6, rolled.InclusiveGpuMillisecondsP50);
|
||||
Assert.Equal(8, rolled.InclusiveGpuMillisecondsP99);
|
||||
|
||||
window.Reset();
|
||||
Assert.Equal(default, window.Snapshot());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1, 0, false, 0, 0, 0)]
|
||||
[InlineData(double.NaN, 0, false, 0, 0, 0)]
|
||||
[InlineData(0, -1, false, 0, 0, 0)]
|
||||
[InlineData(0, double.NaN, false, 0, 0, 0)]
|
||||
[InlineData(0, 0, true, -1, 0, 0)]
|
||||
[InlineData(0, 0, true, double.PositiveInfinity, 0, 0)]
|
||||
[InlineData(0, 0, false, 0, -1, 0)]
|
||||
[InlineData(0, 0, false, 0, 0, -1)]
|
||||
public void Invalid_measurements_are_rejected(
|
||||
double cpu,
|
||||
double receiverCpu,
|
||||
bool hasGpu,
|
||||
double gpu,
|
||||
long resident,
|
||||
long transient)
|
||||
{
|
||||
var window = new RenderPackPerformanceWindow();
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
window.Observe(cpu, receiverCpu, hasGpu, gpu, resident, transient));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
public sealed class RenderPackResourceBudgetPlannerTests
|
||||
{
|
||||
private const long MiB = 1024L * 1024L;
|
||||
|
||||
[Fact]
|
||||
public void Low_1080p_resolves_actual_images_below_its_64_mib_ceiling()
|
||||
{
|
||||
var descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
var preset = descriptor.QualityPresets.Single(value => value.Id == "low");
|
||||
|
||||
RenderPackResourceBudget budget = RenderPackResourceBudgetPlanner
|
||||
.RequireWithinPreset(descriptor, preset, 1920, 1080, sampleCount: 1);
|
||||
|
||||
Assert.InRange(budget.RetainedGpuBytes, 40L * MiB, 42L * MiB);
|
||||
Assert.Equal(0, budget.MultisampleGpuBytes);
|
||||
Assert.Equal(2, budget.LargestImageLayerCount);
|
||||
Assert.Equal(1920, budget.LargestImageWidth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Low_1440pFundsQuarterResolutionBloomAndPreAdmitsBothTransformFlights()
|
||||
{
|
||||
var descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
var preset = descriptor.QualityPresets.Single(value => value.Id == "low");
|
||||
|
||||
RenderPackResourceBudget budget = RenderPackResourceBudgetPlanner
|
||||
.RequireWithinPreset(descriptor, preset, 2560, 1440, sampleCount: 1);
|
||||
|
||||
long expected = checked(
|
||||
2560L * 1440L * 12L
|
||||
+ 768L * 768L * 2L * sizeof(float)
|
||||
// Bloom ping/pong, sun mask/rays, and the declared optional
|
||||
// volumetric target all remain inside the conservative admission
|
||||
// ledger even though Low keeps volumetrics disabled at runtime.
|
||||
+ 640L * 360L * (8L + 8L + 4L + 8L + 8L)
|
||||
+ 2L * WorldTransformCapacityPolicy.InitialBindingSizeBytes);
|
||||
Assert.Equal(expected, budget.RetainedGpuBytes);
|
||||
Assert.InRange(budget.RetainedGpuBytes, 62L * MiB, 64L * MiB);
|
||||
Assert.True(budget.RetainedGpuBytes <= preset.MaxResidentGpuBytes);
|
||||
Assert.Equal(2560, budget.LargestImageWidth);
|
||||
Assert.Equal(1440, budget.LargestImageHeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Medium_1080p_tracks_multisample_bytes_separately_from_resident_ceiling()
|
||||
{
|
||||
var descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
var preset = descriptor.QualityPresets.Single(value => value.Id == "medium");
|
||||
|
||||
RenderPackResourceBudget budget = RenderPackResourceBudgetPlanner
|
||||
.RequireWithinPreset(descriptor, preset, 1920, 1080, sampleCount: 2);
|
||||
|
||||
Assert.True(budget.RetainedGpuBytes < 128L * MiB);
|
||||
Assert.Equal(1920L * 1080L * 12L * 2L, budget.MultisampleGpuBytes);
|
||||
Assert.Equal(
|
||||
checked(budget.RetainedGpuBytes + budget.MultisampleGpuBytes),
|
||||
budget.TotalGpuBytes);
|
||||
Assert.Equal(3, budget.LargestImageLayerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Four_k_rejects_low_before_size_dependent_allocation()
|
||||
{
|
||||
var descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
var preset = descriptor.QualityPresets.Single(value => value.Id == "low");
|
||||
|
||||
NotSupportedException error = Assert.Throws<NotSupportedException>(() =>
|
||||
RenderPackResourceBudgetPlanner.RequireWithinPreset(
|
||||
descriptor,
|
||||
preset,
|
||||
3840,
|
||||
2160,
|
||||
sampleCount: 4));
|
||||
|
||||
Assert.Contains("low", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("3840x2160", error.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("resident GPU bytes", error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Four_k_high_remains_available_and_records_all_four_cascades()
|
||||
{
|
||||
var descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
var preset = descriptor.QualityPresets.Single(value => value.Id == "high");
|
||||
|
||||
RenderPackResourceBudget budget = RenderPackResourceBudgetPlanner
|
||||
.RequireWithinPreset(descriptor, preset, 3840, 2160, sampleCount: 4);
|
||||
|
||||
Assert.True(budget.RetainedGpuBytes <= 256L * MiB);
|
||||
Assert.Equal(4, budget.LargestImageLayerCount);
|
||||
Assert.Equal(3840, budget.LargestImageWidth);
|
||||
Assert.Equal(2160, budget.LargestImageHeight);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
using AcDream.App.Plugins;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Tests.Architecture;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
using Silk.NET.Vulkan;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
public sealed class RenderPackRuntimeFailureRecoveryTests
|
||||
{
|
||||
[Fact]
|
||||
public void DirectionalShadowFailureCancelsBorrowedTransformsBeforePackRetirementAndRetailReplay()
|
||||
{
|
||||
var render = typeof(VulkanWorldScenePhase).GetMethod(
|
||||
nameof(VulkanWorldScenePhase.Render))!;
|
||||
string[] lifetimeCalls = CompiledCallGraph.Read(render)
|
||||
.Where(call =>
|
||||
(call.Target.DeclaringType == typeof(WbDrawDispatcher)
|
||||
&& call.Target.Name == nameof(
|
||||
WbDrawDispatcher.CancelDirectionalShadowTransformFrame))
|
||||
|| (call.Target.DeclaringType == typeof(RenderPackController)
|
||||
&& call.Target.Name == nameof(RenderPackController.OnRuntimeFailure))
|
||||
|| (call.Target.DeclaringType == typeof(VulkanWorldScenePhase)
|
||||
&& call.Target.Name == "RenderRetail"))
|
||||
.Select(call => call.Target.Name)
|
||||
.ToArray();
|
||||
|
||||
bool hasSafeLateFailureHandoff = Enumerable.Range(
|
||||
0,
|
||||
Math.Max(0, lifetimeCalls.Length - 2))
|
||||
.Any(index =>
|
||||
lifetimeCalls[index]
|
||||
== nameof(WbDrawDispatcher.CancelDirectionalShadowTransformFrame)
|
||||
&& lifetimeCalls[index + 1]
|
||||
== nameof(RenderPackController.OnRuntimeFailure)
|
||||
&& lifetimeCalls[index + 2] == "RenderRetail");
|
||||
|
||||
Assert.True(
|
||||
hasSafeLateFailureHandoff,
|
||||
"A post-publication directional-shadow failure must cancel the "
|
||||
+ "borrowed transform frame before retiring the pack-owned buffer "
|
||||
+ "and replaying the frame through retail rendering.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnhancedWorldFailureQuarantinesPackAndNextFrameUsesDefaultRenderer()
|
||||
{
|
||||
using var rig = new FailureRig(failEnhancedWorld: true, failPostProcess: false);
|
||||
|
||||
WorldRenderFrameOutcome failedFrame = rig.RenderFrame();
|
||||
|
||||
Assert.Equal(default, failedFrame);
|
||||
Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State);
|
||||
Assert.Contains(
|
||||
"Atmospheric world rendering failed: enhanced world failed",
|
||||
rig.Controller.Snapshot.Reason,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Null(rig.Controller.ActiveRuntime);
|
||||
Assert.True(rig.Factory.Runtime.Disposed);
|
||||
|
||||
WorldRenderFrameOutcome recovered = rig.RenderFrame();
|
||||
|
||||
Assert.Equal(FailingWorldPhase.Success, recovered);
|
||||
Assert.Equal(2, rig.World.RenderCount);
|
||||
Assert.Contains(
|
||||
rig.Device.Calls.OfType<GpuRecordedPassBegin>(),
|
||||
call => call.Name == "vk-world");
|
||||
Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State);
|
||||
Assert.Contains("enhanced world failed", rig.Controller.Snapshot.Reason);
|
||||
|
||||
rig.Controller.Request(rig.Selection);
|
||||
Assert.Equal(FailingWorldPhase.Success, rig.RenderFrame());
|
||||
Assert.Equal(1, rig.Factory.BuildCount);
|
||||
Assert.Contains("will not be retried", rig.Controller.Snapshot.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PostProcessFailureKeepsWorldOutcomeAndNextFrameUsesDefaultRenderer()
|
||||
{
|
||||
using var rig = new FailureRig(failEnhancedWorld: false, failPostProcess: true);
|
||||
|
||||
WorldRenderFrameOutcome failedFrame = rig.RenderFrame();
|
||||
|
||||
Assert.Equal(FailingWorldPhase.Success, failedFrame);
|
||||
Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State);
|
||||
Assert.Contains(
|
||||
"Atmospheric post-processing failed: post process failed",
|
||||
rig.Controller.Snapshot.Reason,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Null(rig.Controller.ActiveRuntime);
|
||||
Assert.True(rig.Factory.Runtime.Disposed);
|
||||
|
||||
WorldRenderFrameOutcome recovered = rig.RenderFrame();
|
||||
|
||||
Assert.Equal(FailingWorldPhase.Success, recovered);
|
||||
Assert.Equal(2, rig.World.RenderCount);
|
||||
Assert.Contains(
|
||||
rig.Device.Calls.OfType<GpuRecordedPassBegin>(),
|
||||
call => call.Name == "vk-world");
|
||||
Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State);
|
||||
Assert.Contains("post process failed", rig.Controller.Snapshot.Reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Result.ErrorDeviceLost)]
|
||||
[InlineData(Result.ErrorOutOfHostMemory)]
|
||||
[InlineData(Result.ErrorOutOfDeviceMemory)]
|
||||
public void FatalVulkanPostProcessFailureIsRethrownWithoutPretendingRetailCanRecover(
|
||||
Result result)
|
||||
{
|
||||
var failure = new VulkanCallException("pack post process", result);
|
||||
using var rig = new FailureRig(
|
||||
enhancedWorldFailure: null,
|
||||
postProcessFailure: failure);
|
||||
|
||||
VulkanCallException thrown = Assert.Throws<VulkanCallException>(
|
||||
() => rig.RenderFrame());
|
||||
|
||||
Assert.Same(failure, thrown);
|
||||
Assert.Equal(RenderPackActivationState.Active, rig.Controller.Snapshot.State);
|
||||
Assert.False(rig.Factory.Runtime.Disposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonTerminalVulkanPostProcessFailureQuarantinesPackAndFallsBack()
|
||||
{
|
||||
using var rig = new FailureRig(
|
||||
enhancedWorldFailure: null,
|
||||
postProcessFailure: new VulkanCallException(
|
||||
"pack post process",
|
||||
Result.ErrorFormatNotSupported));
|
||||
|
||||
WorldRenderFrameOutcome failedFrame = rig.RenderFrame();
|
||||
|
||||
Assert.Equal(FailingWorldPhase.Success, failedFrame);
|
||||
Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State);
|
||||
Assert.Null(rig.Controller.ActiveRuntime);
|
||||
Assert.True(rig.Factory.Runtime.Disposed);
|
||||
Assert.Contains("ErrorFormatNotSupported", rig.Controller.Snapshot.Reason,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class FailureRig : IDisposable
|
||||
{
|
||||
private readonly BufferedRenderPackRegistry _registry = new();
|
||||
private readonly IDisposable _registration;
|
||||
private readonly GpuDeviceFrameLifetime _lifetime;
|
||||
private readonly VulkanWorldScenePhase _phase;
|
||||
|
||||
internal FailureRig(bool failEnhancedWorld, bool failPostProcess)
|
||||
: this(
|
||||
failEnhancedWorld
|
||||
? new InvalidOperationException("enhanced world failed")
|
||||
: null,
|
||||
failPostProcess
|
||||
? new InvalidOperationException("post process failed")
|
||||
: null)
|
||||
{
|
||||
}
|
||||
|
||||
internal FailureRig(
|
||||
Exception? enhancedWorldFailure,
|
||||
Exception? postProcessFailure)
|
||||
{
|
||||
RenderPackDescriptor descriptor = Descriptor();
|
||||
_registration = _registry.Register(descriptor, EmptyAssets.Instance);
|
||||
Device = new RecordingGpuDevice();
|
||||
Factory = new FailingGraphFactory(Device, postProcessFailure);
|
||||
Controller = new RenderPackController(
|
||||
() => RenderPackCatalog.Build(
|
||||
_registry.Snapshot(),
|
||||
RenderPackHostCapabilities.Conformance),
|
||||
Factory,
|
||||
preparationScheduler: InlineRenderPackPreparationScheduler.Instance);
|
||||
Selection = new RenderPackSelectionSettings(
|
||||
descriptor.Id,
|
||||
descriptor.PackVersion.ToString(),
|
||||
"default");
|
||||
Controller.Request(Selection);
|
||||
_lifetime = new GpuDeviceFrameLifetime(Device);
|
||||
var scope = new VulkanWorldPassScope(sampleCount: 1);
|
||||
World = new FailingWorldPhase(enhancedWorldFailure);
|
||||
_phase = new VulkanWorldScenePhase(
|
||||
_lifetime,
|
||||
new VulkanBackbufferClearState(),
|
||||
sampleCount: static () => 1,
|
||||
scope,
|
||||
World,
|
||||
Controller,
|
||||
new AtmosphericFrameInputState());
|
||||
}
|
||||
|
||||
internal RecordingGpuDevice Device { get; }
|
||||
|
||||
internal FailingGraphFactory Factory { get; }
|
||||
|
||||
internal RenderPackController Controller { get; }
|
||||
|
||||
internal RenderPackSelectionSettings Selection { get; }
|
||||
|
||||
internal FailingWorldPhase World { get; }
|
||||
|
||||
internal WorldRenderFrameOutcome RenderFrame()
|
||||
{
|
||||
_lifetime.BeginFrame();
|
||||
try
|
||||
{
|
||||
return _phase.Render(new RenderFrameInput(1.0 / 60.0, 1280, 720));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lifetime.EndFrame();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Controller.Dispose();
|
||||
_registration.Dispose();
|
||||
_registry.Dispose();
|
||||
Device.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FailingGraphFactory(
|
||||
RecordingGpuDevice device,
|
||||
Exception? postProcessFailure) : IRenderPackRuntimeFactory
|
||||
{
|
||||
internal FailingGraphRuntime Runtime { get; private set; } = null!;
|
||||
|
||||
internal int BuildCount { get; private set; }
|
||||
|
||||
public IRenderPackRuntime Build(
|
||||
RenderPackDescriptor descriptor,
|
||||
ValidatedRenderPackShaderAssets assets,
|
||||
RenderQualityPreset preset,
|
||||
IReadOnlyDictionary<string, string> userSettingOverrides)
|
||||
{
|
||||
BuildCount++;
|
||||
Runtime = new FailingGraphRuntime(device, descriptor, preset, postProcessFailure);
|
||||
return Runtime;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FailingGraphRuntime : IAtmosphericWorldGraphRuntime
|
||||
{
|
||||
private readonly RecordingGpuDevice _device;
|
||||
private readonly Exception? _postProcessFailure;
|
||||
private IGpuRenderTarget? _target;
|
||||
|
||||
internal FailingGraphRuntime(
|
||||
RecordingGpuDevice device,
|
||||
RenderPackDescriptor descriptor,
|
||||
RenderQualityPreset preset,
|
||||
Exception? postProcessFailure)
|
||||
{
|
||||
_device = device;
|
||||
Descriptor = descriptor;
|
||||
Preset = preset;
|
||||
_postProcessFailure = postProcessFailure;
|
||||
}
|
||||
|
||||
public RenderPackDescriptor Descriptor { get; }
|
||||
|
||||
public RenderQualityPreset Preset { get; }
|
||||
|
||||
internal bool Disposed { get; private set; }
|
||||
|
||||
public IGpuRenderTarget PrepareWorldTarget(int width, int height, int sampleCount) =>
|
||||
_target ??= _device.CreateRenderTarget(new GpuRenderTargetDescription(
|
||||
"failing-pack-target",
|
||||
width,
|
||||
height,
|
||||
GpuTextureFormat.Rgba16FloatRenderTarget,
|
||||
GpuTextureFormat.Depth24Stencil8,
|
||||
sampleCount));
|
||||
|
||||
public void RenderPostProcess(IGpuFrame frame, in AtmosphericFrameInputs inputs)
|
||||
{
|
||||
if (_postProcessFailure is not null)
|
||||
throw _postProcessFailure;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Disposed)
|
||||
return;
|
||||
Disposed = true;
|
||||
_target?.Dispose();
|
||||
_target = null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FailingWorldPhase(Exception? firstFailure) : IWorldSceneFramePhase
|
||||
{
|
||||
internal static WorldRenderFrameOutcome Success { get; } = new(5, 9, true);
|
||||
|
||||
internal int RenderCount { get; private set; }
|
||||
|
||||
public WorldRenderFrameOutcome Render(RenderFrameInput input)
|
||||
{
|
||||
RenderCount++;
|
||||
if (firstFailure is not null && RenderCount == 1)
|
||||
throw firstFailure;
|
||||
return Success;
|
||||
}
|
||||
}
|
||||
|
||||
private static RenderPackDescriptor Descriptor() => new(
|
||||
"failure.pack",
|
||||
"Failure pack",
|
||||
new Version(1, 0, 0),
|
||||
RenderPackApi.Current,
|
||||
RenderPackTier.Tier1,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[new RenderQualityPreset("default", "Default", [], [], [], 0, 0, 0, 0, 0)],
|
||||
[],
|
||||
null)
|
||||
{
|
||||
FeatureSummary = "Runtime failure test pack.",
|
||||
};
|
||||
|
||||
private sealed class EmptyAssets : IRenderPackAssets
|
||||
{
|
||||
internal static EmptyAssets Instance { get; } = new();
|
||||
|
||||
public Stream OpenRead(string assetKey) =>
|
||||
throw new InvalidOperationException("The failure test pack declares no assets.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,448 @@
|
|||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
public sealed class RenderPackSpirvValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuiltInDescriptorValidatesSelectedCelestialContract()
|
||||
{
|
||||
RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor(
|
||||
BuiltInAtmosphericRenderPack.Descriptor,
|
||||
RenderPackHostCapabilities.Conformance);
|
||||
|
||||
Assert.True(result.Success, result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedCelestialSemanticRequiresAuthoredCelestialCapability()
|
||||
{
|
||||
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
descriptor = descriptor with
|
||||
{
|
||||
RequiredCapabilities = descriptor.RequiredCapabilities
|
||||
.Where(static capability =>
|
||||
capability != RenderCapability.AuthoredCelestialDirectionalLight)
|
||||
.ToArray(),
|
||||
};
|
||||
|
||||
RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor(
|
||||
descriptor,
|
||||
RenderPackHostCapabilities.Conformance);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(
|
||||
$"Pack '{descriptor.Id}' declares semantic "
|
||||
+ $"'{RenderSemanticInput.SelectedCelestialDirectionalLight}' but does not "
|
||||
+ $"require capability '{RenderCapability.AuthoredCelestialDirectionalLight}'.",
|
||||
result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectionalShadowDepthRejectsSunDirectionAlias()
|
||||
{
|
||||
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
RenderPassDeclaration shadowPass = descriptor.Passes.Single(static pass =>
|
||||
pass.Semantic == RenderPassSemantic.DirectionalShadowDepth);
|
||||
descriptor = descriptor with
|
||||
{
|
||||
Passes = descriptor.Passes.Select(pass => ReferenceEquals(pass, shadowPass)
|
||||
? pass with
|
||||
{
|
||||
SemanticInputs = pass.SemanticInputs
|
||||
.Append(RenderSemanticInput.SunDirection)
|
||||
.ToArray(),
|
||||
}
|
||||
: pass).ToArray(),
|
||||
};
|
||||
|
||||
RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor(
|
||||
descriptor,
|
||||
RenderPackHostCapabilities.Conformance);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(
|
||||
$"Directional-shadow pass '{shadowPass.Id}' must declare "
|
||||
+ $"'{RenderSemanticInput.SelectedCelestialDirectionalLight}' and must not "
|
||||
+ "alias the sun-specific atmospheric direction.",
|
||||
result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInPackPassesBinaryShaderInterfaceValidation()
|
||||
{
|
||||
RenderPackValidationResult result = RenderPackValidator.ValidateSelectedAssets(
|
||||
BuiltInAtmosphericRenderPack.Descriptor,
|
||||
BuiltInAtmosphericRenderPack.CreateAssets(SpirvDirectory()));
|
||||
|
||||
Assert.True(result.Success, result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectionalShadowUniformRequiresSixMembersAndSelectedSourceAtOffset320()
|
||||
{
|
||||
byte[] valid = Shader("directional_shadow_world_opaque.vert.spv");
|
||||
PipelineVariantDeclaration variant = BuiltInAtmosphericRenderPack.Descriptor
|
||||
.PipelineVariants.Single(static value =>
|
||||
value.Semantic
|
||||
== RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster);
|
||||
|
||||
Assert.Equal(336, RenderPackShaderAbi.DirectionalShadowSizeBytes);
|
||||
Assert.Equal(
|
||||
6,
|
||||
BlockMemberCount(
|
||||
valid,
|
||||
RenderPackShaderAbi.UniformDescriptorSet,
|
||||
RenderPackShaderAbi.DirectionalShadowBinding));
|
||||
Assert.Equal(
|
||||
320u,
|
||||
BlockMemberOffset(
|
||||
valid,
|
||||
RenderPackShaderAbi.UniformDescriptorSet,
|
||||
RenderPackShaderAbi.DirectionalShadowBinding,
|
||||
member: 5));
|
||||
RenderPackSpirvValidationResult baseline =
|
||||
RenderPackSpirvValidator.ValidatePipelineVariantShader(
|
||||
valid,
|
||||
RenderPackShaderStage.Vertex,
|
||||
variant);
|
||||
Assert.True(baseline.Success, baseline.Reason);
|
||||
|
||||
byte[] wrongOffset = valid.ToArray();
|
||||
MutateBlockMemberOffset(
|
||||
wrongOffset,
|
||||
RenderPackShaderAbi.UniformDescriptorSet,
|
||||
RenderPackShaderAbi.DirectionalShadowBinding,
|
||||
member: 5,
|
||||
replacement: 304);
|
||||
RenderPackSpirvValidationResult offsetResult =
|
||||
RenderPackSpirvValidator.ValidatePipelineVariantShader(
|
||||
wrongOffset,
|
||||
RenderPackShaderStage.Vertex,
|
||||
variant);
|
||||
Assert.False(offsetResult.Success);
|
||||
Assert.Contains("336-byte ABI v1", offsetResult.Reason, StringComparison.Ordinal);
|
||||
|
||||
byte[] fiveMembers = RemoveLastBlockMember(
|
||||
valid,
|
||||
RenderPackShaderAbi.UniformDescriptorSet,
|
||||
RenderPackShaderAbi.DirectionalShadowBinding);
|
||||
RenderPackSpirvValidationResult memberCountResult =
|
||||
RenderPackSpirvValidator.ValidatePipelineVariantShader(
|
||||
fiveMembers,
|
||||
RenderPackShaderStage.Vertex,
|
||||
variant);
|
||||
Assert.False(memberCountResult.Success);
|
||||
Assert.Contains("336-byte ABI v1", memberCountResult.Reason, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullscreenShaderCannotReadReservedSetZero()
|
||||
{
|
||||
RenderPackSpirvValidationResult result =
|
||||
RenderPackSpirvValidator.ValidatePassShader(
|
||||
Shader("directional_shadow_world_opaque.vert.spv"),
|
||||
RenderPackShaderStage.Vertex,
|
||||
Pass());
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("set 0 binding 0", result.Reason, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullscreenShaderCannotAliasRetailSetOne()
|
||||
{
|
||||
RenderPackSpirvValidationResult result =
|
||||
RenderPackSpirvValidator.ValidatePassShader(
|
||||
Shader("terrain_atmospheric.vert.spv"),
|
||||
RenderPackShaderStage.Vertex,
|
||||
Pass(inputs:
|
||||
[
|
||||
RenderSemanticInput.Weather,
|
||||
RenderSemanticInput.SelectedCelestialDirectionalLight,
|
||||
]));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("set 1 binding", result.Reason, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 4u)]
|
||||
[InlineData(6, 112u)]
|
||||
public void WrongAtmosphericUniformOffsetOrSizeIsRejected(int member, uint replacement)
|
||||
{
|
||||
byte[] spirv = Shader("atmospheric_sun_occlusion.frag.spv");
|
||||
MutateBlockMemberOffset(
|
||||
spirv,
|
||||
RenderPackShaderAbi.UniformDescriptorSet,
|
||||
RenderPackShaderAbi.AtmosphericFrameBinding,
|
||||
member,
|
||||
replacement);
|
||||
|
||||
RenderPackSpirvValidationResult result =
|
||||
RenderPackSpirvValidator.ValidatePassShader(
|
||||
spirv,
|
||||
RenderPackShaderStage.Fragment,
|
||||
Pass(inputs:
|
||||
[
|
||||
RenderSemanticInput.SceneDepth,
|
||||
RenderSemanticInput.SunScreenPosition,
|
||||
RenderSemanticInput.Weather,
|
||||
]));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("AtmosphericFrame", result.Reason, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeclaredStageAndMainEntryPointAreEnforced()
|
||||
{
|
||||
RenderPackSpirvValidationResult result =
|
||||
RenderPackSpirvValidator.ValidatePassShader(
|
||||
Shader("atmospheric_sun_rays.frag.spv"),
|
||||
RenderPackShaderStage.Vertex,
|
||||
Pass(inputs: [RenderSemanticInput.FrameTime], reads: ["mask"]));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("entry point 'main'", result.Reason, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SampledTextureTableRequiresDeclaredSemanticOrResourceInput()
|
||||
{
|
||||
RenderPackSpirvValidationResult result =
|
||||
RenderPackSpirvValidator.ValidatePassShader(
|
||||
Shader("atmospheric_bloom_downsample.frag.spv"),
|
||||
RenderPackShaderStage.Fragment,
|
||||
Pass());
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("sampled without a declared", result.Reason, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WritableRendererStorageIsRejectedEvenForAnAllowedBaseRole()
|
||||
{
|
||||
byte[] spirv = Shader("directional_shadow_world_opaque.vert.spv");
|
||||
RemoveNonWritableDecoration(spirv, set: 0, binding: 0);
|
||||
var variant = new PipelineVariantDeclaration(
|
||||
"world-caster",
|
||||
RenderPipelineBaseSemantic.WorldMesh,
|
||||
"world.vert.spv",
|
||||
"world.frag.spv",
|
||||
RenderMaterialClass.Opaque,
|
||||
[RenderSemanticInput.ShadowCasterTransforms])
|
||||
{
|
||||
Semantic = RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster,
|
||||
};
|
||||
|
||||
RenderPackSpirvValidationResult result =
|
||||
RenderPackSpirvValidator.ValidatePipelineVariantShader(
|
||||
spirv,
|
||||
RenderPackShaderStage.Vertex,
|
||||
variant);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("storage writes are forbidden", result.Reason, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static RenderPassDeclaration Pass(
|
||||
IReadOnlyList<RenderSemanticInput>? inputs = null,
|
||||
IReadOnlyList<string>? reads = null) => new(
|
||||
"pass",
|
||||
RenderPassHook.ToneMap,
|
||||
"pass.vert.spv",
|
||||
"pass.frag.spv",
|
||||
inputs ?? [],
|
||||
reads ?? [],
|
||||
[]);
|
||||
|
||||
private static byte[] Shader(string name) => File.ReadAllBytes(Path.Combine(SpirvDirectory(), name));
|
||||
|
||||
private static string SpirvDirectory() => Path.Combine(
|
||||
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", "spv");
|
||||
|
||||
private static string RepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
directory = directory.Parent;
|
||||
return directory?.FullName
|
||||
?? throw new InvalidOperationException("Could not locate the repository root.");
|
||||
}
|
||||
|
||||
private static void MutateBlockMemberOffset(
|
||||
byte[] spirv,
|
||||
uint set,
|
||||
uint binding,
|
||||
int member,
|
||||
uint replacement)
|
||||
{
|
||||
uint[] words = Words(spirv);
|
||||
uint variable = DescriptorVariable(words, set, binding);
|
||||
uint pointer = VariableResultType(words, variable);
|
||||
uint structure = PointerPointee(words, pointer);
|
||||
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
|
||||
{
|
||||
int count = checked((int)(words[index] >> 16));
|
||||
uint opcode = words[index] & 0xffff;
|
||||
if (opcode == 72 && count >= 5
|
||||
&& words[index + 1] == structure
|
||||
&& words[index + 2] == (uint)member
|
||||
&& words[index + 3] == 35)
|
||||
{
|
||||
words[index + 4] = replacement;
|
||||
CopyBack(words, spirv);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException("Target block member offset was not found.");
|
||||
}
|
||||
|
||||
private static void RemoveNonWritableDecoration(byte[] spirv, uint set, uint binding)
|
||||
{
|
||||
uint[] words = Words(spirv);
|
||||
uint variable = DescriptorVariable(words, set, binding);
|
||||
uint pointer = VariableResultType(words, variable);
|
||||
uint structure = PointerPointee(words, pointer);
|
||||
bool mutated = false;
|
||||
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
|
||||
{
|
||||
int count = checked((int)(words[index] >> 16));
|
||||
uint opcode = words[index] & 0xffff;
|
||||
if (opcode == 71 && count >= 3
|
||||
&& words[index + 1] == variable
|
||||
&& words[index + 2] == 24)
|
||||
{
|
||||
words[index + 2] = 23; // Coherent, so the descriptor is no longer readonly.
|
||||
mutated = true;
|
||||
}
|
||||
else if (opcode == 72 && count >= 4
|
||||
&& words[index + 1] == structure
|
||||
&& words[index + 3] == 24)
|
||||
{
|
||||
words[index + 3] = 23;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
if (!mutated)
|
||||
throw new InvalidOperationException("Target NonWritable decoration was not found.");
|
||||
CopyBack(words, spirv);
|
||||
}
|
||||
|
||||
private static int BlockMemberCount(byte[] spirv, uint set, uint binding)
|
||||
{
|
||||
uint[] words = Words(spirv);
|
||||
uint structure = DescriptorStructure(words, set, binding);
|
||||
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
|
||||
{
|
||||
int count = checked((int)(words[index] >> 16));
|
||||
if ((words[index] & 0xffff) == 30 && count >= 2 && words[index + 1] == structure)
|
||||
return count - 2;
|
||||
}
|
||||
throw new InvalidOperationException("Descriptor block structure was not found.");
|
||||
}
|
||||
|
||||
private static uint BlockMemberOffset(
|
||||
byte[] spirv,
|
||||
uint set,
|
||||
uint binding,
|
||||
int member)
|
||||
{
|
||||
uint[] words = Words(spirv);
|
||||
uint structure = DescriptorStructure(words, set, binding);
|
||||
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
|
||||
{
|
||||
int count = checked((int)(words[index] >> 16));
|
||||
if ((words[index] & 0xffff) == 72
|
||||
&& count >= 5
|
||||
&& words[index + 1] == structure
|
||||
&& words[index + 2] == (uint)member
|
||||
&& words[index + 3] == 35)
|
||||
{
|
||||
return words[index + 4];
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException("Descriptor block member offset was not found.");
|
||||
}
|
||||
|
||||
private static byte[] RemoveLastBlockMember(byte[] spirv, uint set, uint binding)
|
||||
{
|
||||
uint[] words = Words(spirv);
|
||||
uint structure = DescriptorStructure(words, set, binding);
|
||||
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
|
||||
{
|
||||
int count = checked((int)(words[index] >> 16));
|
||||
if ((words[index] & 0xffff) != 30
|
||||
|| count < 3
|
||||
|| words[index + 1] != structure)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var mutated = words.ToList();
|
||||
mutated[index] = ((uint)(count - 1) << 16) | 30u;
|
||||
mutated.RemoveAt(index + count - 1);
|
||||
var bytes = new byte[mutated.Count * sizeof(uint)];
|
||||
Buffer.BlockCopy(mutated.ToArray(), 0, bytes, 0, bytes.Length);
|
||||
return bytes;
|
||||
}
|
||||
throw new InvalidOperationException("Descriptor block structure was not found.");
|
||||
}
|
||||
|
||||
private static uint DescriptorStructure(uint[] words, uint set, uint binding)
|
||||
{
|
||||
uint variable = DescriptorVariable(words, set, binding);
|
||||
uint pointer = VariableResultType(words, variable);
|
||||
return PointerPointee(words, pointer);
|
||||
}
|
||||
|
||||
private static uint DescriptorVariable(uint[] words, uint set, uint binding)
|
||||
{
|
||||
var sets = new Dictionary<uint, uint>();
|
||||
var bindings = new Dictionary<uint, uint>();
|
||||
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
|
||||
{
|
||||
int count = checked((int)(words[index] >> 16));
|
||||
uint opcode = words[index] & 0xffff;
|
||||
if (opcode != 71 || count < 4)
|
||||
continue;
|
||||
if (words[index + 2] == 34) sets[words[index + 1]] = words[index + 3];
|
||||
if (words[index + 2] == 33) bindings[words[index + 1]] = words[index + 3];
|
||||
}
|
||||
return sets.Keys.Single(id => sets[id] == set && bindings.GetValueOrDefault(id) == binding);
|
||||
}
|
||||
|
||||
private static uint VariableResultType(uint[] words, uint variable)
|
||||
{
|
||||
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
|
||||
{
|
||||
int count = checked((int)(words[index] >> 16));
|
||||
if ((words[index] & 0xffff) == 59 && count >= 4 && words[index + 2] == variable)
|
||||
return words[index + 1];
|
||||
}
|
||||
throw new InvalidOperationException("Descriptor variable was not found.");
|
||||
}
|
||||
|
||||
private static uint PointerPointee(uint[] words, uint pointer)
|
||||
{
|
||||
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
|
||||
{
|
||||
int count = checked((int)(words[index] >> 16));
|
||||
if ((words[index] & 0xffff) == 32 && count >= 4 && words[index + 1] == pointer)
|
||||
return words[index + 3];
|
||||
}
|
||||
throw new InvalidOperationException("Descriptor pointer type was not found.");
|
||||
}
|
||||
|
||||
private static uint[] Words(byte[] bytes)
|
||||
{
|
||||
var words = new uint[bytes.Length / 4];
|
||||
Buffer.BlockCopy(bytes, 0, words, 0, bytes.Length);
|
||||
return words;
|
||||
}
|
||||
|
||||
private static void CopyBack(uint[] words, byte[] bytes) =>
|
||||
Buffer.BlockCopy(words, 0, bytes, 0, bytes.Length);
|
||||
}
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
public sealed class VolumetricShaftRendererTests
|
||||
{
|
||||
[Fact]
|
||||
public void MediumConsumesCurrentB5B6B8AndWritesQuarterResolutionHdr()
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
using var renderer = Renderer(device, "medium");
|
||||
GpuTextureSlot depth = TextureSlot(device, "scene-depth");
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
DirectionalShadowFrameBinding shadow = Shadow(frame, device.DefaultTextureSlot);
|
||||
AtmosphericFrameInputs inputs = Inputs(800, 600);
|
||||
device.Clear();
|
||||
|
||||
VolumetricShaftOutput output = renderer.Render(frame, in inputs, in shadow, depth);
|
||||
frame.End();
|
||||
|
||||
Assert.True(output.HasTexture);
|
||||
Assert.Equal(VolumetricShaftGateReason.Rendered, output.Diagnostics.GateReason);
|
||||
Assert.Equal(200, output.Diagnostics.Width);
|
||||
Assert.Equal(150, output.Diagnostics.Height);
|
||||
Assert.Equal(40, output.Diagnostics.RayMarchSteps);
|
||||
Assert.Equal(200L * 150L * 8L, output.Diagnostics.RetainedGpuBytes);
|
||||
Assert.Equal(
|
||||
[
|
||||
GpuBindingModel.UniformAtmosphericFrame,
|
||||
GpuBindingModel.UniformDirectionalShadow,
|
||||
GpuBindingModel.UniformPackPass,
|
||||
GpuBindingModel.UniformPackSettings,
|
||||
],
|
||||
device.OfKind<GpuRecordedUniformBind>().Select(call => call.Binding));
|
||||
Assert.Equal(depth.Index,
|
||||
Assert.Single(device.OfKind<GpuRecordedPushConstants>()).Constants.TextureIndexA);
|
||||
Assert.Equal(1, renderer.Performance.CpuSampleCount);
|
||||
Assert.Equal(0, renderer.Performance.GpuSampleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LowDefaultsOffWithoutAllocatingTargetOrRecordingPass()
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
using var renderer = Renderer(device, "low");
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
DirectionalShadowFrameBinding shadow = Shadow(frame, device.DefaultTextureSlot);
|
||||
AtmosphericFrameInputs inputs = Inputs(800, 600);
|
||||
int targets = device.CreatedRenderTargets.Count;
|
||||
|
||||
VolumetricShaftOutput output = renderer.Render(
|
||||
frame,
|
||||
in inputs,
|
||||
in shadow,
|
||||
device.DefaultTextureSlot);
|
||||
frame.End();
|
||||
|
||||
Assert.False(output.HasTexture);
|
||||
Assert.Equal(VolumetricShaftGateReason.DisabledByPreset, output.Diagnostics.GateReason);
|
||||
Assert.Equal(targets, device.CreatedRenderTargets.Count);
|
||||
Assert.Empty(device.OfKind<GpuRecordedPassBegin>());
|
||||
Assert.Equal(default, renderer.Performance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LowUserOverrideEnablesQuarterResolutionTwentyFourStepShafts()
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
RenderQualityPreset low = Assert.Single(
|
||||
descriptor.QualityPresets,
|
||||
value => string.Equals(value.Id, "low", StringComparison.Ordinal));
|
||||
using var renderer = new VolumetricShaftRenderer(
|
||||
device,
|
||||
descriptor,
|
||||
Assets(),
|
||||
low,
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["volumetric-strength"] = "0.25",
|
||||
});
|
||||
GpuTextureSlot depth = TextureSlot(device, "depth");
|
||||
|
||||
RenderOne(renderer, device, Inputs(800, 600), depth);
|
||||
|
||||
Assert.Equal(VolumetricShaftGateReason.Rendered, renderer.LastDiagnostics.GateReason);
|
||||
Assert.Equal(200, renderer.LastDiagnostics.Width);
|
||||
Assert.Equal(150, renderer.LastDiagnostics.Height);
|
||||
Assert.Equal(24, renderer.LastDiagnostics.RayMarchSteps);
|
||||
Assert.True(renderer.LastDiagnostics.Strength > 0f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleShadowBindingAndIndoorFrameFailClosedWithoutSamplingOldOutput()
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
using var renderer = Renderer(device, "high");
|
||||
AtmosphericFrameInputs inputs = Inputs(1280, 720);
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
var stale = new DirectionalShadowFrameBinding(
|
||||
frame.Serial - 1,
|
||||
true,
|
||||
device.RingBuffer,
|
||||
0,
|
||||
DirectionalShadowUniforms.SizeInBytes,
|
||||
device.DefaultTextureSlot,
|
||||
4);
|
||||
|
||||
VolumetricShaftOutput staleOutput = renderer.Render(
|
||||
frame,
|
||||
in inputs,
|
||||
in stale,
|
||||
device.DefaultTextureSlot);
|
||||
DirectionalShadowFrameBinding current = Shadow(frame, device.DefaultTextureSlot);
|
||||
AtmosphericFrameInputs indoor = inputs with { IsOutdoor = false };
|
||||
VolumetricShaftOutput indoorOutput = renderer.Render(
|
||||
frame,
|
||||
in indoor,
|
||||
in current,
|
||||
device.DefaultTextureSlot);
|
||||
frame.End();
|
||||
|
||||
Assert.Equal(VolumetricShaftGateReason.NoCurrentDirectionalShadow,
|
||||
staleOutput.Diagnostics.GateReason);
|
||||
Assert.Equal(VolumetricShaftGateReason.Indoor, indoorOutput.Diagnostics.GateReason);
|
||||
Assert.False(staleOutput.HasTexture);
|
||||
Assert.False(indoorOutput.HasTexture);
|
||||
Assert.Empty(device.CreatedRenderTargets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResizeAtomicallyReplacesTargetAndResetsMixedResolutionPerformanceWindow()
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
using var renderer = Renderer(device, "high");
|
||||
GpuTextureSlot depth = TextureSlot(device, "depth");
|
||||
|
||||
RenderOne(renderer, device, Inputs(800, 600), depth);
|
||||
RecordingGpuRenderTarget first = Assert.Single(device.CreatedRenderTargets);
|
||||
Assert.Equal(400, first.Description.Width);
|
||||
Assert.Equal(1, renderer.Performance.CpuSampleCount);
|
||||
|
||||
RenderOne(renderer, device, Inputs(1200, 800), depth);
|
||||
|
||||
Assert.True(first.IsDisposed);
|
||||
Assert.Equal(600, device.CreatedRenderTargets[^1].Description.Width);
|
||||
Assert.Equal(400, device.CreatedRenderTargets[^1].Description.Height);
|
||||
Assert.Equal(1, renderer.Performance.CpuSampleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TargetFailureRollsBackAndRetryPublishesOneOwnedTexture()
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
using var renderer = Renderer(device, "medium");
|
||||
GpuTextureSlot depth = TextureSlot(device, "depth");
|
||||
int baselineSlots = device.LiveTextureSlotCount;
|
||||
device.RenderTargetFailure = _ => new InvalidOperationException("volumetric allocation failed");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => RenderOne(
|
||||
renderer,
|
||||
device,
|
||||
Inputs(800, 600),
|
||||
depth));
|
||||
Assert.Equal(baselineSlots, device.LiveTextureSlotCount);
|
||||
Assert.Empty(device.CreatedRenderTargets);
|
||||
|
||||
device.RenderTargetFailure = null;
|
||||
RenderOne(renderer, device, Inputs(800, 600), depth);
|
||||
Assert.Equal(baselineSlots + 1, device.LiveTextureSlotCount);
|
||||
Assert.Single(device.CreatedRenderTargets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeReleasesOutputSlotTargetAndPipeline()
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
GpuTextureSlot depth = TextureSlot(device, "depth");
|
||||
int baselineSlots = device.LiveTextureSlotCount;
|
||||
var renderer = Renderer(device, "medium");
|
||||
RenderOne(renderer, device, Inputs(800, 600), depth);
|
||||
RecordingGpuRenderTarget target = Assert.Single(device.CreatedRenderTargets);
|
||||
RecordingGpuPipeline pipeline = Assert.Single(device.CreatedPipelines);
|
||||
|
||||
renderer.Dispose();
|
||||
|
||||
Assert.Equal(baselineSlots, device.LiveTextureSlotCount);
|
||||
Assert.True(target.IsDisposed);
|
||||
Assert.True(pipeline.IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoredWeatherAndSunElevationContinuouslyScaleTheSameFramePolicy()
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
using var renderer = Renderer(device, "medium");
|
||||
GpuTextureSlot depth = TextureSlot(device, "depth");
|
||||
|
||||
RenderOne(renderer, device, Inputs(800, 600), depth);
|
||||
float clearLowSun = renderer.LastDiagnostics.Strength;
|
||||
AtmosphericFrameInputs overcast = Inputs(800, 600) with
|
||||
{
|
||||
Weather = WeatherKind.Overcast,
|
||||
WeatherIntensity = 1f,
|
||||
};
|
||||
RenderOne(renderer, device, overcast, depth);
|
||||
float overcastLowSun = renderer.LastDiagnostics.Strength;
|
||||
AtmosphericFrameInputs noon = Inputs(800, 600) with
|
||||
{
|
||||
SunElevationDegrees = 70f,
|
||||
};
|
||||
RenderOne(renderer, device, noon, depth);
|
||||
|
||||
Assert.True(clearLowSun > overcastLowSun);
|
||||
Assert.True(clearLowSun > renderer.LastDiagnostics.Strength);
|
||||
Assert.True(overcastLowSun > 0f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeclaredActiveDayGroupMultiplierScalesAuthoredPolicy()
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
using var renderer = Renderer(device, "medium");
|
||||
GpuTextureSlot depth = TextureSlot(device, "depth");
|
||||
|
||||
RenderOne(renderer, device, Inputs(800, 600) with { ActiveDayGroup = 0 }, depth);
|
||||
float groupZero = renderer.LastDiagnostics.Strength;
|
||||
RenderOne(renderer, device, Inputs(800, 600) with { ActiveDayGroup = 1 }, depth);
|
||||
float groupOne = renderer.LastDiagnostics.Strength;
|
||||
|
||||
Assert.Equal(groupZero * 0.35f, groupOne, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeclaredVolumetricElevationCurveControlsShaftStrength()
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
RenderPackDescriptor source = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
RenderPackDescriptor changed = source with
|
||||
{
|
||||
AtmospherePolicy = source.AtmospherePolicy! with
|
||||
{
|
||||
VolumetricShaftSunElevationResponse =
|
||||
[
|
||||
new SunElevationResponsePoint(-90, 0.25),
|
||||
new SunElevationResponsePoint(90, 0.25),
|
||||
],
|
||||
},
|
||||
};
|
||||
RenderQualityPreset medium = Assert.Single(changed.QualityPresets, value =>
|
||||
value.Semantic == RenderQualitySemantic.Medium);
|
||||
using var renderer = new VolumetricShaftRenderer(
|
||||
device,
|
||||
changed,
|
||||
Assets(),
|
||||
medium);
|
||||
GpuTextureSlot depth = TextureSlot(device, "depth");
|
||||
|
||||
RenderOne(renderer, device, Inputs(800, 600), depth);
|
||||
|
||||
Assert.Equal(0.25f * 0.35f, renderer.LastDiagnostics.Strength, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShaderUsesVulkanYFlipWorldMetreBiasAndShadowStrengthMix()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot(),
|
||||
"src", "AcDream.App", "Rendering", "Shaders", "atmospheric_volumetric.frag"));
|
||||
|
||||
Assert.Contains("0.5 - ndc.y * 0.5", source, StringComparison.Ordinal);
|
||||
Assert.Contains("surfaceToSun * max(uShadowBiasMeters.x, 0.0)", source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("mix(1.0, visible, clamp(uShadowControl.x, 0.0, 1.0))", source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("ndc.z -", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void RenderOne(
|
||||
VolumetricShaftRenderer renderer,
|
||||
RecordingGpuDevice device,
|
||||
AtmosphericFrameInputs inputs,
|
||||
GpuTextureSlot depth)
|
||||
{
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
DirectionalShadowFrameBinding shadow = Shadow(frame, device.DefaultTextureSlot);
|
||||
renderer.Render(frame, in inputs, in shadow, depth);
|
||||
frame.End();
|
||||
}
|
||||
|
||||
private static DirectionalShadowFrameBinding Shadow(
|
||||
IGpuFrame frame,
|
||||
GpuTextureSlot shadowTexture)
|
||||
{
|
||||
GpuRingAllocation allocation = frame.AllocateRing(
|
||||
DirectionalShadowUniforms.SizeInBytes,
|
||||
GpuRingUsage.Uniform);
|
||||
allocation.Data.Clear();
|
||||
return new DirectionalShadowFrameBinding(
|
||||
frame.Serial,
|
||||
true,
|
||||
allocation.Buffer,
|
||||
allocation.OffsetBytes,
|
||||
DirectionalShadowUniforms.SizeInBytes,
|
||||
shadowTexture,
|
||||
3);
|
||||
}
|
||||
|
||||
private static GpuTextureSlot TextureSlot(RecordingGpuDevice device, string name)
|
||||
{
|
||||
IGpuTexture texture = device.CreateTexture(new GpuTextureDescription(
|
||||
name,
|
||||
GpuTextureKind.Texture2D,
|
||||
GpuTextureFormat.Rgba8Unorm,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1));
|
||||
return device.RegisterTexture(texture, device.CreateSampler(GpuSamplerDescription.WorldClamp));
|
||||
}
|
||||
|
||||
private static VolumetricShaftRenderer Renderer(
|
||||
RecordingGpuDevice device,
|
||||
string presetId)
|
||||
{
|
||||
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
RenderQualityPreset preset = Assert.Single(
|
||||
descriptor.QualityPresets,
|
||||
value => string.Equals(value.Id, presetId, StringComparison.Ordinal));
|
||||
return new VolumetricShaftRenderer(device, descriptor, Assets(), preset);
|
||||
}
|
||||
|
||||
private static AtmosphericFrameInputs Inputs(int width, int height) => new(
|
||||
new Vector2(0.5f, 0.4f),
|
||||
true,
|
||||
12f,
|
||||
new Vector3(1f, 0.85f, 0.7f),
|
||||
Vector3.Normalize(new Vector3(0.2f, 0.5f, 0.8f)),
|
||||
1f,
|
||||
Matrix4x4.Identity,
|
||||
0,
|
||||
WeatherKind.Clear,
|
||||
0f,
|
||||
1d / 60d,
|
||||
width,
|
||||
height,
|
||||
true);
|
||||
|
||||
private static IRenderPackAssets Assets() =>
|
||||
BuiltInAtmosphericRenderPack.CreateAssets(Path.Combine(
|
||||
RepositoryRoot(),
|
||||
"src", "AcDream.App", "Rendering", "Shaders", "spv"));
|
||||
|
||||
private static string RepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null
|
||||
&& !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
directory = directory.Parent;
|
||||
return directory?.FullName
|
||||
?? throw new InvalidOperationException("Could not locate repository root.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class RetailDetailTextureContractTests
|
||||
{
|
||||
private static readonly TerrainAtlas.RetailDetailTextureBinding Available = new(
|
||||
new GpuTextureSlot(7),
|
||||
Tiling: 4f,
|
||||
SurfaceTextureId: 0x05001787,
|
||||
RenderSurfaceId: 0x06006D58,
|
||||
Width: 256,
|
||||
Height: 256);
|
||||
|
||||
[Fact]
|
||||
public void ExistingBuildingDetailSettingIsTheLivePassGate()
|
||||
{
|
||||
Assert.False(RetailDetailTextureContract.ShouldRender(false, Available));
|
||||
Assert.True(RetailDetailTextureContract.ShouldRender(true, Available));
|
||||
Assert.False(RetailDetailTextureContract.ShouldRender(true, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpaqueDetailUsesDepthEqualityWhileTransparentDetailDoesNot()
|
||||
{
|
||||
Assert.Equal(
|
||||
GpuCompareOp.Equal,
|
||||
RetailDetailTextureContract.DetailDepthCompare(transparent: false));
|
||||
Assert.Equal(
|
||||
GpuCompareOp.LessOrEqual,
|
||||
RetailDetailTextureContract.DetailDepthCompare(transparent: true));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0f, 1f)]
|
||||
[InlineData(10f, 1f)]
|
||||
[InlineData(30f, 0.5f)]
|
||||
[InlineData(50f, 0f)]
|
||||
[InlineData(80f, 0f)]
|
||||
public void DistanceFadeUsesPositiveViewDepthInMetres(
|
||||
float depthMetres,
|
||||
float expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
expected,
|
||||
RetailDetailTextureContract.FadeForPositiveViewDepthMetres(depthMetres),
|
||||
precision: 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FadeZeroIsExactNoOpAndNeutralRgbEqualsAlpha()
|
||||
{
|
||||
var brighteningSample = new Vector4(0.459f, 0.459f, 0.459f, 0.282f);
|
||||
Assert.Equal(
|
||||
Vector3.One,
|
||||
RetailDetailTextureContract.FramebufferFactor(brighteningSample, fade: 0f));
|
||||
|
||||
var neutral = new Vector4(0.4f, 0.4f, 0.4f, 0.4f);
|
||||
Vector3 neutralFactor = RetailDetailTextureContract.FramebufferFactor(neutral, fade: 1f);
|
||||
Assert.Equal(1f, neutralFactor.X, precision: 5);
|
||||
Assert.Equal(1f, neutralFactor.Y, precision: 5);
|
||||
Assert.Equal(1f, neutralFactor.Z, precision: 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailBlendPreservesMeasuredBrightening()
|
||||
{
|
||||
var measured = new Vector4(0.459f, 0.459f, 0.459f, 0.282f);
|
||||
Vector3 factor = RetailDetailTextureContract.FramebufferFactor(measured, fade: 1f);
|
||||
|
||||
Assert.Equal(1.177f, factor.X, precision: 5);
|
||||
Assert.Equal(1.177f, factor.Y, precision: 5);
|
||||
Assert.Equal(1.177f, factor.Z, precision: 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnabledDerethCategoryTextureKeepsItsMeasuredBrightening()
|
||||
{
|
||||
var derethCategory = new Vector4(0.165f, 0.165f, 0.165f, 0.132f);
|
||||
Vector3 factor = RetailDetailTextureContract.FramebufferFactor(
|
||||
derethCategory,
|
||||
fade: 1f);
|
||||
|
||||
Assert.Equal(1.033f, factor.X, precision: 5);
|
||||
Assert.Equal(1.033f, factor.Y, precision: 5);
|
||||
Assert.Equal(1.033f, factor.Z, precision: 5);
|
||||
}
|
||||
}
|
||||
|
|
@ -59,6 +59,27 @@ public sealed class StaticRenderProjectionJournalTests
|
|||
Assert.Equal(expected.Geometry, projected.Source.GeometryFingerprint);
|
||||
Assert.Equal(expected.Appearance, projected.Source.AppearanceFingerprint);
|
||||
Assert.Equal(LandblockId, projected.Residency.OwnerLandblockId);
|
||||
Assert.Equal(
|
||||
RenderCasterIdentityKind.OutdoorStatic,
|
||||
projected.EntityPayload.CasterIdentity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_RetainsAuthoritativeBuildingIdentity()
|
||||
{
|
||||
var journal = new RenderProjectionJournal(Generation(21));
|
||||
var statics = new StaticRenderProjectionJournal(journal);
|
||||
LandblockBuild build = Build(
|
||||
LandblockId,
|
||||
[Entity(9, building: true)],
|
||||
includeShell: false);
|
||||
|
||||
statics.Reconcile(build, Publication(build));
|
||||
|
||||
Assert.Equal(
|
||||
RenderCasterIdentityKind.Building,
|
||||
Assert.Single(journal.Pending.ToArray())
|
||||
.Record.EntityPayload.CasterIdentity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -442,7 +463,8 @@ public sealed class StaticRenderProjectionJournalTests
|
|||
private static WorldEntity Entity(
|
||||
uint id,
|
||||
uint serverGuid = 0,
|
||||
Vector3 position = default) =>
|
||||
Vector3 position = default,
|
||||
bool building = false) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
|
|
@ -450,6 +472,7 @@ public sealed class StaticRenderProjectionJournalTests
|
|||
SourceGfxObjOrSetupId = 0x01000000u + id,
|
||||
Position = position,
|
||||
Rotation = Quaternion.Identity,
|
||||
IsBuildingShell = building,
|
||||
MeshRefs =
|
||||
[
|
||||
new MeshRef(
|
||||
|
|
|
|||
106
tests/AcDream.App.Tests/Rendering/VolumetricShaftQualityTests.cs
Normal file
106
tests/AcDream.App.Tests/Rendering/VolumetricShaftQualityTests.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class VolumetricShaftQualityTests
|
||||
{
|
||||
[Fact]
|
||||
public void Weak_hardware_preserves_contract_but_defaults_volumetrics_off()
|
||||
{
|
||||
VolumetricShaftQuality low = VolumetricShaftQuality.For(DirectionalShadowPreset.Low);
|
||||
VolumetricShaftQuality medium = VolumetricShaftQuality.For(DirectionalShadowPreset.Medium);
|
||||
VolumetricShaftQuality high = VolumetricShaftQuality.For(DirectionalShadowPreset.High);
|
||||
|
||||
Assert.False(low.EnabledByDefault);
|
||||
Assert.Equal(0.25f, medium.ResolutionScale);
|
||||
Assert.Equal(0.50f, high.ResolutionScale);
|
||||
Assert.True(low.RayMarchSteps < medium.RayMarchSteps);
|
||||
Assert.True(medium.RayMarchSteps < high.RayMarchSteps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_raking_sun_is_stronger_than_noon_and_overcast()
|
||||
{
|
||||
VolumetricShaftQuality quality =
|
||||
VolumetricShaftQuality.For(DirectionalShadowPreset.High);
|
||||
DirectionalShadowEnvironmentState raking = Enabled(elevationSin: 0.20f);
|
||||
DirectionalShadowEnvironmentState noon = Enabled(elevationSin: 0.95f);
|
||||
|
||||
VolumetricShaftFrameParameters clear = VolumetricShaftPolicy.Evaluate(
|
||||
in quality,
|
||||
userEnabled: true,
|
||||
in raking,
|
||||
WeatherKind.Clear,
|
||||
Vector3.One,
|
||||
1f);
|
||||
VolumetricShaftFrameParameters highSun = VolumetricShaftPolicy.Evaluate(
|
||||
in quality,
|
||||
userEnabled: true,
|
||||
in noon,
|
||||
WeatherKind.Clear,
|
||||
Vector3.One,
|
||||
1f);
|
||||
VolumetricShaftFrameParameters overcast = VolumetricShaftPolicy.Evaluate(
|
||||
in quality,
|
||||
userEnabled: true,
|
||||
in raking,
|
||||
WeatherKind.Overcast,
|
||||
Vector3.One,
|
||||
1f);
|
||||
|
||||
Assert.True(clear.Enabled);
|
||||
Assert.True(clear.Strength > highSun.Strength);
|
||||
Assert.True(clear.Strength > overcast.Strength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_shadow_indoor_or_user_off_cannot_leave_shafts_enabled()
|
||||
{
|
||||
VolumetricShaftQuality quality =
|
||||
VolumetricShaftQuality.For(DirectionalShadowPreset.Medium);
|
||||
DirectionalShadowEnvironmentState indoor = new(
|
||||
DirectionalShadowGateReason.Indoor,
|
||||
Vector3.UnitZ,
|
||||
0.5f,
|
||||
0f,
|
||||
1f);
|
||||
DirectionalShadowEnvironmentState outdoor = Enabled(0.2f);
|
||||
|
||||
Assert.False(VolumetricShaftPolicy.Evaluate(
|
||||
in quality, true, in indoor, WeatherKind.Clear, Vector3.One, 1f).Enabled);
|
||||
Assert.False(VolumetricShaftPolicy.Evaluate(
|
||||
in quality, false, in outdoor, WeatherKind.Clear, Vector3.One, 1f).Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Moon_shadow_source_never_manufactures_sun_shafts()
|
||||
{
|
||||
VolumetricShaftQuality quality =
|
||||
VolumetricShaftQuality.For(DirectionalShadowPreset.High);
|
||||
DirectionalShadowEnvironmentState moon = Enabled(0.35f) with
|
||||
{
|
||||
SourceKind = AuthoredCelestialShadowSourceKind.DominantMoon,
|
||||
};
|
||||
|
||||
VolumetricShaftFrameParameters result = VolumetricShaftPolicy.Evaluate(
|
||||
in quality,
|
||||
userEnabled: true,
|
||||
in moon,
|
||||
WeatherKind.Clear,
|
||||
Vector3.One,
|
||||
authoredSunBrightness: 1f);
|
||||
|
||||
Assert.False(result.Enabled);
|
||||
}
|
||||
|
||||
private static DirectionalShadowEnvironmentState Enabled(float elevationSin) => new(
|
||||
DirectionalShadowGateReason.Enabled,
|
||||
Vector3.Normalize(new Vector3(0.5f, 0.5f, elevationSin)),
|
||||
elevationSin,
|
||||
Strength: 1f,
|
||||
SoftnessMultiplier: 1f,
|
||||
SourceKind: AuthoredCelestialShadowSourceKind.Sun);
|
||||
}
|
||||
|
|
@ -0,0 +1,501 @@
|
|||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.Enums;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class DirectionalShadowPreparedDrawTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(TranslucencyKind.Opaque, true, DirectionalShadowCasterMaterial.Opaque)]
|
||||
[InlineData(TranslucencyKind.ClipMap, true, DirectionalShadowCasterMaterial.AlphaCutout)]
|
||||
[InlineData(TranslucencyKind.AlphaBlend, false, DirectionalShadowCasterMaterial.Opaque)]
|
||||
[InlineData(TranslucencyKind.Additive, false, DirectionalShadowCasterMaterial.Opaque)]
|
||||
[InlineData(TranslucencyKind.InvAlpha, false, DirectionalShadowCasterMaterial.Opaque)]
|
||||
internal void MaterialPolicy_PreservesCutoutsAndExcludesTrueTransparency(
|
||||
TranslucencyKind source,
|
||||
bool expectedAccepted,
|
||||
DirectionalShadowCasterMaterial expectedMaterial)
|
||||
{
|
||||
bool accepted = DirectionalShadowPreparedDraws.TryClassifyMaterial(
|
||||
source,
|
||||
out DirectionalShadowCasterMaterial material);
|
||||
|
||||
Assert.Equal(expectedAccepted, accepted);
|
||||
if (accepted)
|
||||
Assert.Equal(expectedMaterial, material);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0f, false)]
|
||||
[InlineData(0.0001f, true)]
|
||||
[InlineData(1f, true)]
|
||||
[InlineData(float.NaN, true)]
|
||||
internal void FadePolicy_OnlyExactOpaquePartsCast(
|
||||
float translucency,
|
||||
bool excluded)
|
||||
{
|
||||
Assert.Equal(
|
||||
excluded,
|
||||
DirectionalShadowPreparedDraws.FadeExcludesCaster(translucency));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Complete_GroupsCommandsAndRetainsExactCurrentTransforms()
|
||||
{
|
||||
var product = new DirectionalShadowPreparedDraws();
|
||||
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(4);
|
||||
Assert.True(product.TryBegin(generation, 7, estimatedInstances: 3));
|
||||
Matrix4x4 first = Matrix4x4.CreateRotationZ(0.25f)
|
||||
* Matrix4x4.CreateTranslation(10f, 20f, 30f);
|
||||
Matrix4x4 second = Matrix4x4.CreateScale(1.25f)
|
||||
* Matrix4x4.CreateTranslation(-2f, 3f, 7f);
|
||||
Matrix4x4 leaves = Matrix4x4.CreateRotationX(0.5f)
|
||||
* Matrix4x4.CreateTranslation(100f, 200f, 12f);
|
||||
|
||||
product.Add(
|
||||
firstIndex: 40,
|
||||
baseVertex: 3,
|
||||
indexCount: 18,
|
||||
GpuTextureSlot.Unassigned,
|
||||
textureLayer: 0,
|
||||
CullMode.CounterClockwise,
|
||||
DirectionalShadowCasterMaterial.Opaque,
|
||||
in first);
|
||||
product.Add(
|
||||
firstIndex: 40,
|
||||
baseVertex: 3,
|
||||
indexCount: 18,
|
||||
GpuTextureSlot.Unassigned,
|
||||
textureLayer: 0,
|
||||
CullMode.CounterClockwise,
|
||||
DirectionalShadowCasterMaterial.Opaque,
|
||||
in second);
|
||||
product.Add(
|
||||
firstIndex: 90,
|
||||
baseVertex: 11,
|
||||
indexCount: 24,
|
||||
new GpuTextureSlot(17),
|
||||
textureLayer: 6,
|
||||
CullMode.None,
|
||||
DirectionalShadowCasterMaterial.AlphaCutout,
|
||||
in leaves);
|
||||
var inputStats = new DirectionalShadowPreparationStats(
|
||||
SourceCasters: 2,
|
||||
SourceMeshRefs: 3,
|
||||
SourceParts: 3,
|
||||
SourceBatches: 5,
|
||||
PreparedInstances: 0,
|
||||
PreparedOpaqueCommands: 0,
|
||||
PreparedAlphaCutoutCommands: 0,
|
||||
RejectedTransparentBatches: 2,
|
||||
RejectedFadedParts: 1,
|
||||
MissingMeshes: 0,
|
||||
UnresolvedAlphaCutoutTextures: 0);
|
||||
|
||||
product.Complete(generation, 7, in inputStats);
|
||||
|
||||
Assert.Equal(3, product.Transforms.Length);
|
||||
Assert.Equal(2, product.Commands.Length);
|
||||
Assert.Equal(1, product.OpaqueCommandCount);
|
||||
Assert.Equal(1, product.AlphaCutoutCommandCount);
|
||||
Assert.Single(product.OpaqueCommands.ToArray());
|
||||
Assert.Single(product.AlphaCutoutCommands.ToArray());
|
||||
Assert.Single(product.OpaqueBatches.ToArray());
|
||||
Assert.Single(product.AlphaCutoutBatches.ToArray());
|
||||
Assert.Single(product.OpaqueRuns.ToArray());
|
||||
Assert.Single(product.AlphaCutoutRuns.ToArray());
|
||||
Assert.Equal(0, product.OpaqueRuns[0].StartCommand);
|
||||
Assert.Equal(1, product.AlphaCutoutRuns[0].StartCommand);
|
||||
Assert.Equal(2u, product.Commands[0].InstanceCount);
|
||||
Assert.Equal(0u, product.Commands[0].BaseInstance);
|
||||
Assert.Equal(1u, product.Commands[1].InstanceCount);
|
||||
Assert.Equal(2u, product.Commands[1].BaseInstance);
|
||||
Assert.Equal(DirectionalShadowCasterMaterial.Opaque, product.Batches[0].Material);
|
||||
Assert.False(product.Batches[0].TextureSlot.IsAssigned);
|
||||
Assert.Equal(DirectionalShadowCasterMaterial.AlphaCutout, product.Batches[1].Material);
|
||||
Assert.Equal(17u, product.Batches[1].TextureSlot.Index);
|
||||
Assert.Equal(6u, product.Batches[1].TextureLayer);
|
||||
Assert.Contains(first, product.Transforms.ToArray());
|
||||
Assert.Contains(second, product.Transforms.ToArray());
|
||||
Assert.Equal(leaves, product.Transforms[2]);
|
||||
Assert.Equal(3, product.Stats.PreparedInstances);
|
||||
Assert.Equal(2, product.Stats.RejectedTransparentBatches);
|
||||
Assert.Equal(1, product.Stats.RejectedFadedParts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameCasterBuild_ReplaysWithoutReclassificationOrStorageGrowth()
|
||||
{
|
||||
var product = new DirectionalShadowPreparedDraws();
|
||||
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(8);
|
||||
Matrix4x4 exactMeshRefTransform = new(
|
||||
1, 2, 3, 4,
|
||||
5, 6, 7, 8,
|
||||
9, 10, 11, 12,
|
||||
13, 14, 15, 16);
|
||||
Assert.True(product.TryBegin(generation, 20, estimatedInstances: 1));
|
||||
product.Add(
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
GpuTextureSlot.Unassigned,
|
||||
0,
|
||||
CullMode.Clockwise,
|
||||
DirectionalShadowCasterMaterial.Opaque,
|
||||
in exactMeshRefTransform);
|
||||
DirectionalShadowPreparationStats stats = default;
|
||||
product.Complete(generation, 20, in stats);
|
||||
long retained = product.RetainedScratchBytes;
|
||||
ulong buildSequence = product.BuildSequence;
|
||||
|
||||
Assert.False(product.TryBegin(generation, 20, estimatedInstances: 100));
|
||||
|
||||
Assert.Equal(buildSequence, product.BuildSequence);
|
||||
Assert.Equal(retained, product.RetainedScratchBytes);
|
||||
Assert.Equal(exactMeshRefTransform, product.Transforms[0]);
|
||||
Assert.Single(product.Commands.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StableTopology_ComposesSlimPoseWithoutMutatingCasterOrAllocating()
|
||||
{
|
||||
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(10);
|
||||
Matrix4x4 originalRoot = Matrix4x4.CreateTranslation(1f, 2f, 3f);
|
||||
Matrix4x4 originalPart = Matrix4x4.CreateTranslation(4f, 5f, 6f);
|
||||
Matrix4x4 setupPart = Matrix4x4.CreateRotationX(0.25f)
|
||||
* Matrix4x4.CreateTranslation(7f, 8f, 9f);
|
||||
DirectionalShadowCaster[] casters =
|
||||
[
|
||||
new DirectionalShadowCaster(
|
||||
Projection(originalRoot, originalPart),
|
||||
DirectionalShadowCasterKind.LiveDynamic),
|
||||
];
|
||||
var product = new DirectionalShadowPreparedDraws();
|
||||
Assert.True(product.TryBegin(
|
||||
generation,
|
||||
casterBuildSequence: 12,
|
||||
estimatedInstances: 2,
|
||||
renderDataAvailabilityVersion: 5,
|
||||
translucencyFadeRevision: 7));
|
||||
product.MapCasterIdentity(
|
||||
0,
|
||||
casters[0].Projection.Id,
|
||||
casters[0].Projection.ProjectionClass);
|
||||
DirectionalShadowTransformSource direct =
|
||||
DirectionalShadowTransformSource.Dynamic(
|
||||
casterIndex: 0,
|
||||
meshIndex: 0,
|
||||
isSetupPart: false,
|
||||
in setupPart);
|
||||
DirectionalShadowTransformSource setup =
|
||||
DirectionalShadowTransformSource.Dynamic(
|
||||
casterIndex: 0,
|
||||
meshIndex: 0,
|
||||
isSetupPart: true,
|
||||
in setupPart);
|
||||
Matrix4x4 originalDirect = originalPart * originalRoot;
|
||||
Matrix4x4 originalSetup = setupPart * originalPart * originalRoot;
|
||||
product.Add(
|
||||
10,
|
||||
0,
|
||||
3,
|
||||
GpuTextureSlot.Unassigned,
|
||||
0,
|
||||
CullMode.Clockwise,
|
||||
DirectionalShadowCasterMaterial.Opaque,
|
||||
in originalDirect,
|
||||
in direct);
|
||||
product.Add(
|
||||
20,
|
||||
0,
|
||||
3,
|
||||
GpuTextureSlot.Unassigned,
|
||||
0,
|
||||
CullMode.Clockwise,
|
||||
DirectionalShadowCasterMaterial.Opaque,
|
||||
in originalSetup,
|
||||
in setup);
|
||||
DirectionalShadowPreparationStats stats = default;
|
||||
product.Complete(
|
||||
generation,
|
||||
12,
|
||||
in stats,
|
||||
renderDataAvailabilityVersion: 5,
|
||||
translucencyFadeRevision: 7);
|
||||
ulong topologyBuild = product.BuildSequence;
|
||||
|
||||
float exactRootX = BitConverter.Int32BitsToSingle(0x41234567);
|
||||
float exactPartY = BitConverter.Int32BitsToSingle(0x40ABCDEF);
|
||||
Matrix4x4 currentRoot = Matrix4x4.CreateRotationZ(0.15f)
|
||||
* Matrix4x4.CreateTranslation(exactRootX, 12f, 13f);
|
||||
Matrix4x4 currentPart = Matrix4x4.CreateRotationY(0.35f)
|
||||
* Matrix4x4.CreateTranslation(14f, exactPartY, 16f);
|
||||
RenderProjectionRecord current = Projection(currentRoot, currentPart);
|
||||
DirectionalShadowTransformSnapshot snapshot =
|
||||
DirectionalShadowTransformSnapshot.Capture(in current);
|
||||
DirectionalShadowChangedPose[] changed = [new(0, snapshot)];
|
||||
|
||||
product.RefreshDynamicTransforms(changed);
|
||||
|
||||
AssertMatrixBitsEqual(currentPart * currentRoot, product.Transforms[0]);
|
||||
AssertMatrixBitsEqual(
|
||||
setupPart * currentPart * currentRoot,
|
||||
product.Transforms[1]);
|
||||
Assert.Equal(topologyBuild, product.BuildSequence);
|
||||
Assert.Equal(2, product.LastDynamicTransformRefreshCount);
|
||||
Assert.False(product.RequiresTopologyBuild(generation, 12, 5, 7));
|
||||
AssertMatrixBitsEqual(
|
||||
originalRoot,
|
||||
casters[0].Projection.Transform.LocalToWorld);
|
||||
AssertMatrixBitsEqual(
|
||||
originalPart,
|
||||
casters[0].Projection.EntityPayload.MeshRefs[0].PartTransform);
|
||||
|
||||
product.RefreshDynamicTransforms(
|
||||
ReadOnlySpan<DirectionalShadowChangedPose>.Empty);
|
||||
Assert.Empty(product.DynamicTransformSlots.ToArray());
|
||||
Assert.Equal(0, product.LastDynamicTransformRefreshCount);
|
||||
AssertMatrixBitsEqual(currentPart * currentRoot, product.Transforms[0]);
|
||||
AssertMatrixBitsEqual(
|
||||
setupPart * currentPart * currentRoot,
|
||||
product.Transforms[1]);
|
||||
|
||||
product.RefreshDynamicTransforms(changed, denseRefresh: true);
|
||||
Assert.True(product.LastDynamicTransformRefreshWasDense);
|
||||
Assert.Equal([0, 1], product.DynamicTransformSlots.ToArray());
|
||||
|
||||
product.RefreshDynamicTransforms(changed);
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (int iteration = 0; iteration < 256; iteration++)
|
||||
product.RefreshDynamicTransforms(changed);
|
||||
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
Assert.Equal(0, allocated);
|
||||
|
||||
RenderProjectionRecord wrongId = current with
|
||||
{
|
||||
Id = RenderProjectionId.FromRaw(99),
|
||||
};
|
||||
DirectionalShadowTransformSnapshot wrongSnapshot =
|
||||
DirectionalShadowTransformSnapshot.Capture(in wrongId);
|
||||
DirectionalShadowChangedPose[] wrongIdentity = [new(0, wrongSnapshot)];
|
||||
InvalidOperationException identityFailure = Assert.Throws<
|
||||
InvalidOperationException>(
|
||||
() => product.RefreshDynamicTransforms(wrongIdentity));
|
||||
Assert.Contains("does not match", identityFailure.Message);
|
||||
|
||||
DirectionalShadowChangedPose[] staleSlot = [new(1, snapshot)];
|
||||
InvalidOperationException slotFailure = Assert.Throws<
|
||||
InvalidOperationException>(
|
||||
() => product.RefreshDynamicTransforms(staleSlot));
|
||||
Assert.Contains("stale or unmapped caster index", slotFailure.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StableTopology_MapsChangedCasterToOnlyItsRetainedTransformSlots()
|
||||
{
|
||||
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(12);
|
||||
Matrix4x4 firstRoot = Matrix4x4.CreateTranslation(1f, 2f, 3f);
|
||||
Matrix4x4 secondRoot = Matrix4x4.CreateTranslation(4f, 5f, 6f);
|
||||
Matrix4x4 firstPart = Matrix4x4.CreateTranslation(7f, 8f, 9f);
|
||||
Matrix4x4 secondPart = Matrix4x4.CreateTranslation(10f, 11f, 12f);
|
||||
DirectionalShadowCaster[] casters =
|
||||
[
|
||||
new DirectionalShadowCaster(
|
||||
Projection(firstRoot, firstPart),
|
||||
DirectionalShadowCasterKind.LiveDynamic),
|
||||
new DirectionalShadowCaster(
|
||||
Projection(secondRoot, secondPart) with
|
||||
{
|
||||
Id = RenderProjectionId.FromRaw(2),
|
||||
},
|
||||
DirectionalShadowCasterKind.EquippedChild),
|
||||
];
|
||||
var product = new DirectionalShadowPreparedDraws();
|
||||
Assert.True(product.TryBegin(generation, 13, estimatedInstances: 2));
|
||||
product.MapCasterIdentity(
|
||||
0,
|
||||
casters[0].Projection.Id,
|
||||
casters[0].Projection.ProjectionClass);
|
||||
product.MapCasterIdentity(
|
||||
1,
|
||||
casters[1].Projection.Id,
|
||||
casters[1].Projection.ProjectionClass);
|
||||
Matrix4x4 setupPart = Matrix4x4.Identity;
|
||||
DirectionalShadowTransformSource firstSource =
|
||||
DirectionalShadowTransformSource.Dynamic(
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
in setupPart);
|
||||
DirectionalShadowTransformSource secondSource =
|
||||
DirectionalShadowTransformSource.Dynamic(
|
||||
1,
|
||||
0,
|
||||
false,
|
||||
in setupPart);
|
||||
Matrix4x4 firstWorld = firstPart * firstRoot;
|
||||
Matrix4x4 secondWorld = secondPart * secondRoot;
|
||||
product.Add(
|
||||
1, 0, 3, GpuTextureSlot.Unassigned, 0,
|
||||
CullMode.Clockwise,
|
||||
DirectionalShadowCasterMaterial.Opaque,
|
||||
in firstWorld,
|
||||
in firstSource);
|
||||
product.Add(
|
||||
2, 0, 3, GpuTextureSlot.Unassigned, 0,
|
||||
CullMode.Clockwise,
|
||||
DirectionalShadowCasterMaterial.Opaque,
|
||||
in secondWorld,
|
||||
in secondSource);
|
||||
DirectionalShadowPreparationStats stats = default;
|
||||
product.Complete(generation, 13, in stats);
|
||||
|
||||
Matrix4x4 movedRoot = Matrix4x4.CreateTranslation(40f, 50f, 60f);
|
||||
Matrix4x4 movedPart = Matrix4x4.CreateTranslation(70f, 80f, 90f);
|
||||
casters[1] = casters[1] with
|
||||
{
|
||||
Projection = Projection(movedRoot, movedPart) with
|
||||
{
|
||||
Id = RenderProjectionId.FromRaw(2),
|
||||
},
|
||||
};
|
||||
product.RefreshDynamicTransforms(casters, [1]);
|
||||
|
||||
Assert.Equal([0, 1], product.AllDynamicTransformSlots.ToArray());
|
||||
Assert.Equal([1], product.DynamicTransformSlots.ToArray());
|
||||
AssertMatrixBitsEqual(firstWorld, product.Transforms[0]);
|
||||
AssertMatrixBitsEqual(movedPart * movedRoot, product.Transforms[1]);
|
||||
Assert.Equal(1, product.LastDynamicTransformRefreshCount);
|
||||
|
||||
product.RefreshDenseDynamicTransforms(casters);
|
||||
Assert.True(product.LastDynamicTransformRefreshWasDense);
|
||||
Assert.Equal([0, 1], product.DynamicTransformSlots.ToArray());
|
||||
Assert.Equal(2, product.LastDynamicTransformRefreshCount);
|
||||
AssertMatrixBitsEqual(firstWorld, product.Transforms[0]);
|
||||
AssertMatrixBitsEqual(movedPart * movedRoot, product.Transforms[1]);
|
||||
|
||||
Matrix4x4 denseFirstRoot = Matrix4x4.CreateTranslation(100f, 101f, 102f);
|
||||
Matrix4x4 denseFirstPart = Matrix4x4.CreateTranslation(103f, 104f, 105f);
|
||||
casters[0] = casters[0] with
|
||||
{
|
||||
Projection = Projection(denseFirstRoot, denseFirstPart),
|
||||
};
|
||||
RenderProjectionRecord firstProjection = casters[0].Projection;
|
||||
RenderProjectionRecord secondProjection = casters[1].Projection;
|
||||
DirectionalShadowTransformSnapshot firstPose =
|
||||
DirectionalShadowTransformSnapshot.Capture(in firstProjection);
|
||||
DirectionalShadowTransformSnapshot secondPose =
|
||||
DirectionalShadowTransformSnapshot.Capture(in secondProjection);
|
||||
DirectionalShadowChangedPose[] reversedDenseChanges =
|
||||
[
|
||||
new(1, secondPose),
|
||||
new(0, firstPose),
|
||||
];
|
||||
|
||||
product.RefreshDynamicTransforms(reversedDenseChanges, denseRefresh: true);
|
||||
|
||||
Assert.True(product.LastDynamicTransformRefreshWasDense);
|
||||
Assert.Equal([0, 1], product.DynamicTransformSlots.ToArray());
|
||||
Assert.Equal(2, product.LastDynamicTransformRefreshCount);
|
||||
AssertMatrixBitsEqual(
|
||||
denseFirstPart * denseFirstRoot,
|
||||
product.Transforms[0]);
|
||||
AssertMatrixBitsEqual(movedPart * movedRoot, product.Transforms[1]);
|
||||
|
||||
long beforeDense = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (int iteration = 0; iteration < 256; iteration++)
|
||||
{
|
||||
product.RefreshDynamicTransforms(
|
||||
reversedDenseChanges,
|
||||
denseRefresh: true);
|
||||
}
|
||||
long denseAllocated =
|
||||
GC.GetAllocatedBytesForCurrentThread() - beforeDense;
|
||||
Assert.Equal(0, denseAllocated);
|
||||
|
||||
Assert.True(product.TryBegin(generation, 14, estimatedInstances: 0));
|
||||
product.Complete(generation, 14, in stats);
|
||||
product.RefreshDynamicTransforms(casters, [0]);
|
||||
Assert.Empty(product.DynamicTransformSlots.ToArray());
|
||||
Assert.Equal(0, product.LastDynamicTransformRefreshCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResourceAvailabilityFadeAndPendingTextureInvalidateRetainedTopology()
|
||||
{
|
||||
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(11);
|
||||
var product = new DirectionalShadowPreparedDraws();
|
||||
Assert.True(product.TryBegin(generation, 2, 0, 20, 30));
|
||||
var pending = new DirectionalShadowPreparationStats(
|
||||
SourceCasters: 1,
|
||||
SourceMeshRefs: 1,
|
||||
SourceParts: 1,
|
||||
SourceBatches: 1,
|
||||
PreparedInstances: 0,
|
||||
PreparedOpaqueCommands: 0,
|
||||
PreparedAlphaCutoutCommands: 0,
|
||||
RejectedTransparentBatches: 0,
|
||||
RejectedFadedParts: 0,
|
||||
MissingMeshes: 1,
|
||||
UnresolvedAlphaCutoutTextures: 0);
|
||||
product.Complete(generation, 2, in pending, 20, 30);
|
||||
|
||||
Assert.False(product.RequiresTopologyBuild(generation, 2, 20, 30));
|
||||
Assert.True(product.RequiresTopologyBuild(generation, 2, 21, 30));
|
||||
Assert.True(product.RequiresTopologyBuild(generation, 2, 20, 31));
|
||||
|
||||
Assert.True(product.TryBegin(generation, 2, 0, 21, 30));
|
||||
pending = pending with { UnresolvedAlphaCutoutTextures = 1 };
|
||||
product.Complete(generation, 2, in pending, 21, 30);
|
||||
Assert.True(product.RequiresTopologyBuild(generation, 2, 21, 30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetupComposition_UsesPublishedMeshRefTransformWithoutAnotherPose()
|
||||
{
|
||||
Matrix4x4 root = Matrix4x4.CreateRotationZ(0.1f)
|
||||
* Matrix4x4.CreateTranslation(50f, 60f, 70f);
|
||||
Matrix4x4 currentMeshRef = Matrix4x4.CreateRotationY(0.2f)
|
||||
* Matrix4x4.CreateTranslation(4f, 5f, 6f);
|
||||
Matrix4x4 authoredSetupPart = Matrix4x4.CreateRotationX(0.3f)
|
||||
* Matrix4x4.CreateTranslation(1f, 2f, 3f);
|
||||
|
||||
Matrix4x4 actual = WbDrawDispatcher.ComposePartWorldMatrix(
|
||||
root,
|
||||
currentMeshRef,
|
||||
authoredSetupPart);
|
||||
|
||||
Assert.Equal(authoredSetupPart * currentMeshRef * root, actual);
|
||||
}
|
||||
|
||||
private static RenderProjectionRecord Projection(
|
||||
in Matrix4x4 root,
|
||||
in Matrix4x4 part) =>
|
||||
new RenderProjectionRecord() with
|
||||
{
|
||||
Id = RenderProjectionId.FromRaw(1),
|
||||
ProjectionClass = RenderProjectionClass.LiveDynamicRoot,
|
||||
Transform = new RenderTransform(root),
|
||||
EntityPayload = new RenderEntityPayload(
|
||||
[new MeshRef(0x01000001, part)],
|
||||
PaletteOverride: null,
|
||||
IsBuildingShell: false),
|
||||
};
|
||||
|
||||
private static void AssertMatrixBitsEqual(
|
||||
Matrix4x4 expected,
|
||||
Matrix4x4 actual)
|
||||
{
|
||||
ReadOnlySpan<byte> expectedBits = MemoryMarshal.AsBytes(
|
||||
MemoryMarshal.CreateReadOnlySpan(ref expected, 1));
|
||||
ReadOnlySpan<byte> actualBits = MemoryMarshal.AsBytes(
|
||||
MemoryMarshal.CreateReadOnlySpan(ref actual, 1));
|
||||
Assert.True(expectedBits.SequenceEqual(actualBits));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class DirectionalShadowTerrainPreparedDrawTests
|
||||
{
|
||||
[Fact]
|
||||
public void Product_ContainsEveryResidentRangeWithoutVisibilityClassification()
|
||||
{
|
||||
var product = new DirectionalShadowTerrainPreparedDraws();
|
||||
Assert.True(product.TryBegin(frameSequence: 10, estimatedCommands: 3));
|
||||
DirectionalShadowTerrainRange[] resident =
|
||||
[
|
||||
new(FirstIndex: 0, IndexCount: 384),
|
||||
new(FirstIndex: 384, IndexCount: 384),
|
||||
new(FirstIndex: 768, IndexCount: 384),
|
||||
];
|
||||
foreach (DirectionalShadowTerrainRange range in resident)
|
||||
product.Add(in range);
|
||||
|
||||
product.Complete(frameSequence: 10);
|
||||
|
||||
Assert.Equal(3, product.Commands.Length);
|
||||
Assert.Equal(0u, product.Commands[0].FirstIndex);
|
||||
Assert.Equal(384u, product.Commands[1].FirstIndex);
|
||||
Assert.Equal(768u, product.Commands[2].FirstIndex);
|
||||
Assert.All(
|
||||
product.Commands.ToArray(),
|
||||
static command =>
|
||||
{
|
||||
Assert.Equal(384u, command.Count);
|
||||
Assert.Equal(1u, command.InstanceCount);
|
||||
Assert.Equal(0, command.BaseVertex);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameFrame_AllCascadesReuseOneTerrainBuild()
|
||||
{
|
||||
var product = new DirectionalShadowTerrainPreparedDraws();
|
||||
Assert.True(product.TryBegin(frameSequence: 1, estimatedCommands: 1));
|
||||
var range = new DirectionalShadowTerrainRange(100, 24);
|
||||
product.Add(in range);
|
||||
product.Complete(frameSequence: 1);
|
||||
long retained = product.RetainedScratchBytes;
|
||||
|
||||
Assert.False(product.TryBegin(frameSequence: 1, estimatedCommands: 500));
|
||||
|
||||
Assert.Equal(1ul, product.BuildSequence);
|
||||
Assert.Equal(retained, product.RetainedScratchBytes);
|
||||
Assert.Single(product.Commands.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NextFrame_RebuildsIntoRetainedStorage()
|
||||
{
|
||||
var product = new DirectionalShadowTerrainPreparedDraws();
|
||||
Assert.True(product.TryBegin(frameSequence: 1, estimatedCommands: 2));
|
||||
var first = new DirectionalShadowTerrainRange(10, 3);
|
||||
var second = new DirectionalShadowTerrainRange(20, 6);
|
||||
product.Add(in first);
|
||||
product.Add(in second);
|
||||
product.Complete(frameSequence: 1);
|
||||
long retained = product.RetainedScratchBytes;
|
||||
|
||||
Assert.True(product.TryBegin(frameSequence: 2, estimatedCommands: 1));
|
||||
product.Add(in second);
|
||||
product.Complete(frameSequence: 2);
|
||||
|
||||
Assert.Equal(2ul, product.BuildSequence);
|
||||
Assert.Equal(retained, product.RetainedScratchBytes);
|
||||
Assert.Single(product.Commands.ToArray());
|
||||
Assert.Equal(20u, product.Commands[0].FirstIndex);
|
||||
}
|
||||
}
|
||||
|
|
@ -73,6 +73,62 @@ public class EnvCellRendererTests
|
|||
new WbFrustum());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvironmentDetailCategory_BindsValidStorageDescriptorNine()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
device.Clear();
|
||||
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
using IGpuPassEncoder pass = frame.BeginPass(
|
||||
GpuPassDescription.BackbufferClear(
|
||||
"envcell-detail-binding",
|
||||
Vector4.Zero,
|
||||
sampleCount: 1));
|
||||
|
||||
EnvCellRenderer.BindEnvironmentDetailCategory(pass, frame);
|
||||
|
||||
GpuRecordedStorageBind storageBind = Assert.Single(
|
||||
device.Calls.OfType<GpuRecordedStorageBind>());
|
||||
Assert.Equal(GpuBindingModel.StorageInstanceDetailCategory, storageBind.Binding);
|
||||
Assert.Equal((uint)sizeof(uint), storageBind.SizeBytes);
|
||||
Assert.Equal(
|
||||
1u,
|
||||
MemoryMarshal.Read<uint>(
|
||||
device.RingBytes.Slice((int)storageBind.OffsetBytes, sizeof(uint))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailDetailPipelinesPreserveOpaqueAndTransparentDepthWriteContracts()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using var meshManager = CreateMeshManager(device);
|
||||
using var renderer = new EnvCellRenderer(
|
||||
device,
|
||||
new GpuDeviceFrameLifetime(device),
|
||||
new VulkanWorldPassScope(sampleCount: 1),
|
||||
meshManager,
|
||||
new WbFrustum());
|
||||
|
||||
GpuPipelineDescription opaqueDetail = Assert.Single(
|
||||
device.CreatedPipelines,
|
||||
pipeline => pipeline.Description.Name == "envcell-retail-detail")
|
||||
.Description;
|
||||
GpuPipelineDescription transparentDetail = Assert.Single(
|
||||
device.CreatedPipelines,
|
||||
pipeline => pipeline.Description.Name == "envcell-retail-detail-alpha")
|
||||
.Description;
|
||||
|
||||
Assert.Equal(GpuBlendMode.RetailDetail, opaqueDetail.Blend);
|
||||
Assert.True(opaqueDetail.Depth.Write);
|
||||
Assert.Equal(GpuCompareOp.Equal, opaqueDetail.Depth.Compare);
|
||||
Assert.False(opaqueDetail.AlphaToCoverage);
|
||||
Assert.Equal(GpuBlendMode.RetailDetail, transparentDetail.Blend);
|
||||
Assert.False(transparentDetail.Depth.Write);
|
||||
Assert.Equal(GpuCompareOp.LessOrEqual, transparentDetail.Depth.Compare);
|
||||
Assert.False(transparentDetail.AlphaToCoverage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderedMdiRanges_CoalesceAdjacentCellsWithIdenticalState()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -169,6 +169,45 @@ public class InstanceGroupClearTests
|
|||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DispatcherFingerprint_IncludesRetailDetailCategory()
|
||||
{
|
||||
WbDrawDispatcher.InstanceGroup ordinary = MakeCompleteGroup(
|
||||
textureSlot: 0xAA,
|
||||
submissionOrder: 0);
|
||||
WbDrawDispatcher.InstanceGroup building = MakeCompleteGroup(
|
||||
textureSlot: 0xAA,
|
||||
submissionOrder: 0);
|
||||
building.DetailCategories[0] = 1u;
|
||||
var scratch = new List<WbDrawDispatcher.AlphaFingerprint>();
|
||||
|
||||
CurrentRenderDispatcherSubmission ordinarySubmission =
|
||||
WbDrawDispatcher.CreateDispatcherSubmission(
|
||||
visibleInstanceCount: 1,
|
||||
immediateInstanceCount: 0,
|
||||
deferTransparent: true,
|
||||
opaque: [],
|
||||
transparent: [ordinary],
|
||||
cameraWorldPosition: Vector3.Zero,
|
||||
alphaScratch: scratch);
|
||||
CurrentRenderDispatcherSubmission buildingSubmission =
|
||||
WbDrawDispatcher.CreateDispatcherSubmission(
|
||||
visibleInstanceCount: 1,
|
||||
immediateInstanceCount: 0,
|
||||
deferTransparent: true,
|
||||
opaque: [],
|
||||
transparent: [building],
|
||||
cameraWorldPosition: Vector3.Zero,
|
||||
alphaScratch: scratch);
|
||||
|
||||
Assert.NotEqual(
|
||||
ordinarySubmission.TransparentDigest,
|
||||
buildingSubmission.TransparentDigest);
|
||||
Assert.NotEqual(
|
||||
ordinarySubmission.TransparentSetDigest,
|
||||
buildingSubmission.TransparentSetDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CachedGroupHandle_RequiresLiveMatchingRegistration()
|
||||
{
|
||||
|
|
@ -314,15 +353,16 @@ public class InstanceGroupClearTests
|
|||
group.Slots.Add(0u);
|
||||
group.LightSets.Add(WbDrawDispatcher.InstanceLightSet.Disabled);
|
||||
group.IndoorFlags.Add(0u);
|
||||
group.DetailCategories.Add(0u);
|
||||
group.Opacities.Add(1f);
|
||||
group.SelectionLighting.Add(new Vector2(0f, 1f));
|
||||
return group;
|
||||
}
|
||||
|
||||
// #193 (regression from #188, 2026-07-09): WbDrawDispatcher's InstanceGroup holds
|
||||
// eight per-instance parallel lists — Matrices, LocalSortCenters,
|
||||
// nine per-instance parallel lists — Matrices, LocalSortCenters,
|
||||
// SubmissionOrders, Slots, LightSets, IndoorFlags, Opacities, and
|
||||
// SelectionLighting — appended in lockstep
|
||||
// DetailCategories, SelectionLighting — appended in lockstep
|
||||
// (one entry per drawn instance) every frame. The
|
||||
// per-frame reset must clear ALL of them. #188 added Opacities but left it out of
|
||||
// the inline clear loop, so it grew one float per instance per frame forever; as
|
||||
|
|
@ -339,6 +379,7 @@ public class InstanceGroupClearTests
|
|||
grp.Slots.Add(1u);
|
||||
grp.LightSets.Add(WbDrawDispatcher.InstanceLightSet.Disabled);
|
||||
grp.IndoorFlags.Add(0u);
|
||||
grp.DetailCategories.Add(1u);
|
||||
grp.Opacities.Add(1.0f);
|
||||
grp.SelectionLighting.Add(new Vector2(0f, 1f));
|
||||
|
||||
|
|
@ -350,6 +391,7 @@ public class InstanceGroupClearTests
|
|||
Assert.Empty(grp.Slots);
|
||||
Assert.Empty(grp.LightSets);
|
||||
Assert.Empty(grp.IndoorFlags);
|
||||
Assert.Empty(grp.DetailCategories);
|
||||
Assert.Empty(grp.Opacities); // #193 — the list that leaked
|
||||
Assert.Empty(grp.SelectionLighting);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,42 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class PackedDispatcherOracleTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false, 0u)]
|
||||
[InlineData(true, 1u)]
|
||||
public void PackedInstanceWriter_AppendsDetailCategoryInParallel(
|
||||
bool buildingDetail,
|
||||
uint expectedCategory)
|
||||
{
|
||||
var group = new WbDrawDispatcher.InstanceGroup();
|
||||
|
||||
WbDrawDispatcher.AppendPackedInstance(
|
||||
group,
|
||||
Matrix4x4.Identity,
|
||||
Vector3.One,
|
||||
submissionOrder: 7,
|
||||
slot: 3u,
|
||||
lights: WbDrawDispatcher.InstanceLightSet.Disabled,
|
||||
indoor: true,
|
||||
buildingDetail: buildingDetail,
|
||||
opacity: 0.5f,
|
||||
selectionLighting: new Vector2(0.25f, 0.75f));
|
||||
|
||||
Assert.Single(group.Matrices);
|
||||
Assert.Single(group.LocalSortCenters);
|
||||
Assert.Single(group.SubmissionOrders);
|
||||
Assert.Single(group.Slots);
|
||||
Assert.Single(group.LightSets);
|
||||
Assert.Single(group.IndoorFlags);
|
||||
Assert.Equal(expectedCategory, Assert.Single(group.DetailCategories));
|
||||
Assert.Single(group.Opacities);
|
||||
Assert.Single(group.SelectionLighting);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u, false, false)]
|
||||
[InlineData(0u, true, false)]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class WorldTransformFrameArenaTests
|
||||
{
|
||||
[Fact]
|
||||
public void ShadowPrefixAndOrdinaryWorldAppendShareExactBindingAndBaseInstanceSpace()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
device.Clear();
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
var arena = new WorldTransformFrameArena();
|
||||
Matrix4x4[] shadow =
|
||||
[
|
||||
Matrix4x4.CreateTranslation(1f, 2f, 3f),
|
||||
Matrix4x4.CreateRotationZ(0.4f),
|
||||
];
|
||||
Matrix4x4[] ordinary =
|
||||
[
|
||||
Matrix4x4.CreateScale(2f),
|
||||
Matrix4x4.CreateTranslation(9f, 8f, 7f),
|
||||
];
|
||||
|
||||
WorldTransformFrameSlice shadowSlice = arena.Begin(frame, shadow);
|
||||
WorldTransformFrameSlice ordinarySlice = arena.Append(frame, ordinary);
|
||||
|
||||
Assert.Same(shadowSlice.Buffer, ordinarySlice.Buffer);
|
||||
Assert.Equal(shadowSlice.BaseOffsetBytes, ordinarySlice.BaseOffsetBytes);
|
||||
Assert.Equal(shadowSlice.BindingSizeBytes, ordinarySlice.BindingSizeBytes);
|
||||
Assert.Equal(0u, shadowSlice.FirstInstance);
|
||||
Assert.Equal((uint)shadow.Length, ordinarySlice.FirstInstance);
|
||||
Assert.Equal((uint)ordinary.Length, ordinarySlice.InstanceCount);
|
||||
Assert.Equal(4u, arena.UsedInstances);
|
||||
GpuRecordedRingAllocation allocation = Assert.Single(
|
||||
device.OfKind<GpuRecordedRingAllocation>());
|
||||
Assert.Equal(GpuRingUsage.Storage, allocation.Usage);
|
||||
Assert.Equal(
|
||||
checked((int)WorldTransformCapacityPolicy.InitialBindingSizeBytes),
|
||||
allocation.ByteCount);
|
||||
|
||||
ReadOnlySpan<Matrix4x4> uploaded = MemoryMarshal.Cast<byte, Matrix4x4>(
|
||||
device.RingBytes.Slice(
|
||||
checked((int)shadowSlice.BaseOffsetBytes),
|
||||
checked((shadow.Length + ordinary.Length) * 64)));
|
||||
Assert.Equal(shadow[0], uploaded[0]);
|
||||
Assert.Equal(shadow[1], uploaded[1]);
|
||||
Assert.Equal(ordinary[0], uploaded[2]);
|
||||
Assert.Equal(ordinary[1], uploaded[3]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapacityPolicyGrowsPastTwiceTheFormerCeiling_AndHonorsDeviceLimit()
|
||||
{
|
||||
const uint moreThanTwiceFormerCapacity = 131_073u;
|
||||
uint bytes = WorldTransformCapacityPolicy.ResolveBindingSizeBytes(
|
||||
moreThanTwiceFormerCapacity,
|
||||
WorldTransformCapacityPolicy.VulkanGuaranteedMaxStorageBufferRangeBytes);
|
||||
|
||||
Assert.True(bytes > 8u * 1024u * 1024u);
|
||||
Assert.True(bytes >= moreThanTwiceFormerCapacity * 64u);
|
||||
NotSupportedException failure = Assert.Throws<NotSupportedException>(() =>
|
||||
WorldTransformCapacityPolicy.ResolveBindingSizeBytes(
|
||||
moreThanTwiceFormerCapacity,
|
||||
8u * 1024u * 1024u));
|
||||
Assert.Contains("fail safe", failure.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedShadowPrefix_AndOrdinaryAppendUseTheExactSamePoseBuffer()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using IGpuBuffer retained = device.CreateBuffer(new GpuBufferDescription(
|
||||
"retained-shadow-slot-0",
|
||||
WorldTransformCapacityPolicy.InitialBindingSizeBytes,
|
||||
GpuBufferUsage.Storage | GpuBufferUsage.TransferDestination,
|
||||
GpuMemoryResidency.HostWritable));
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
Matrix4x4[] shadow =
|
||||
[
|
||||
Matrix4x4.CreateTranslation(1f, 2f, 3f),
|
||||
Matrix4x4.CreateRotationZ(0.4f),
|
||||
];
|
||||
retained.Upload(0, MemoryMarshal.AsBytes(shadow.AsSpan()));
|
||||
var shadowSlice = new WorldTransformFrameSlice(
|
||||
frame.Serial,
|
||||
retained,
|
||||
BaseOffsetBytes: 0,
|
||||
WorldTransformCapacityPolicy.InitialBindingSizeBytes,
|
||||
FirstInstance: 0,
|
||||
InstanceCount: 2);
|
||||
var arena = new WorldTransformFrameArena();
|
||||
|
||||
WorldTransformFrameSlice published = arena.BeginRetained(
|
||||
frame,
|
||||
in shadowSlice);
|
||||
Matrix4x4[] ordinary = [Matrix4x4.CreateTranslation(9f, 8f, 7f)];
|
||||
WorldTransformFrameSlice appended = arena.Append(frame, ordinary);
|
||||
|
||||
Assert.Same(retained, published.Buffer);
|
||||
Assert.Same(retained, appended.Buffer);
|
||||
Assert.Equal(2u, appended.FirstInstance);
|
||||
Assert.Equal(WorldTransformCapacityPolicy.InitialBindingSizeBytes,
|
||||
appended.BindingSizeBytes);
|
||||
Matrix4x4[] readback = new Matrix4x4[3];
|
||||
retained.Read(0, MemoryMarshal.AsBytes(readback.AsSpan()));
|
||||
Assert.Equal(shadow[0], readback[0]);
|
||||
Assert.Equal(shadow[1], readback[1]);
|
||||
Assert.Equal(ordinary[0], readback[2]);
|
||||
Assert.Empty(device.OfKind<GpuRecordedRingAllocation>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectedDense68395CombinedMatricesRemainInOneAuthoritativeBinding()
|
||||
{
|
||||
const uint shadowPrefixInstances = 9_498u;
|
||||
const int ordinaryInstances = 68_395 - (int)shadowPrefixInstances;
|
||||
using var device = new RecordingGpuDevice();
|
||||
using IGpuBuffer retained = device.CreateBuffer(new GpuBufferDescription(
|
||||
"connected-dense-retained-transform-arena",
|
||||
WorldTransformCapacityPolicy.InitialBindingSizeBytes,
|
||||
GpuBufferUsage.Storage | GpuBufferUsage.TransferDestination,
|
||||
GpuMemoryResidency.HostWritable));
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
var prefix = new WorldTransformFrameSlice(
|
||||
frame.Serial,
|
||||
retained,
|
||||
BaseOffsetBytes: 0,
|
||||
WorldTransformCapacityPolicy.InitialBindingSizeBytes,
|
||||
FirstInstance: 0,
|
||||
InstanceCount: shadowPrefixInstances);
|
||||
var arena = new WorldTransformFrameArena();
|
||||
|
||||
arena.BeginRetained(frame, in prefix);
|
||||
WorldTransformFrameSlice ordinary = arena.Append(
|
||||
frame,
|
||||
new Matrix4x4[ordinaryInstances]);
|
||||
|
||||
Assert.Same(retained, ordinary.Buffer);
|
||||
Assert.Equal(shadowPrefixInstances, ordinary.FirstInstance);
|
||||
Assert.Equal(68_395u, arena.UsedInstances);
|
||||
Assert.Equal(prefix.BindingSizeBytes, ordinary.BindingSizeBytes);
|
||||
Assert.True(ordinary.IsValidFor(frame));
|
||||
Assert.Empty(device.OfKind<GpuRecordedRingAllocation>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppendOverflowFailsSafeWithoutAllocatingASecondPoseBuffer()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
device.Clear();
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
var arena = new WorldTransformFrameArena();
|
||||
const uint bindingBytes = 4u * 64u;
|
||||
var full = new Matrix4x4[4];
|
||||
arena.Begin(frame, full, bindingBytes);
|
||||
|
||||
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(
|
||||
() => arena.Append(frame, [Matrix4x4.Identity]));
|
||||
|
||||
Assert.Contains("fail safe", failure.Message, StringComparison.Ordinal);
|
||||
Assert.True(arena.IsActiveFor(frame.Serial));
|
||||
Assert.Equal(
|
||||
bindingBytes / WorldTransformCapacityPolicy.MatrixBytes,
|
||||
arena.UsedInstances);
|
||||
Assert.Single(device.OfKind<GpuRecordedRingAllocation>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelBeforePackRetirementAllowsSameFrameRetailRingPublication()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
device.Clear();
|
||||
var retained = Assert.IsType<RecordingGpuBuffer>(
|
||||
device.CreateBuffer(new GpuBufferDescription(
|
||||
"retained-shadow-slot-0",
|
||||
WorldTransformCapacityPolicy.InitialBindingSizeBytes,
|
||||
GpuBufferUsage.Storage | GpuBufferUsage.TransferDestination,
|
||||
GpuMemoryResidency.HostWritable)));
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
var arena = new WorldTransformFrameArena();
|
||||
var retainedPrefix = new WorldTransformFrameSlice(
|
||||
frame.Serial,
|
||||
retained,
|
||||
BaseOffsetBytes: 0,
|
||||
WorldTransformCapacityPolicy.InitialBindingSizeBytes,
|
||||
FirstInstance: 0,
|
||||
InstanceCount: 1);
|
||||
arena.BeginRetained(frame, in retainedPrefix);
|
||||
|
||||
// This is the production late-budget-failure order: release the
|
||||
// dispatcher's borrow first, then retire the active pack owner.
|
||||
arena.Cancel(frame);
|
||||
retained.Dispose();
|
||||
WorldTransformFrameSlice retail = arena.Begin(
|
||||
frame,
|
||||
[Matrix4x4.CreateTranslation(4f, 5f, 6f)]);
|
||||
|
||||
Assert.True(retained.IsDisposed);
|
||||
Assert.NotSame(retained, retail.Buffer);
|
||||
Assert.Same(device.RingBuffer, retail.Buffer);
|
||||
Assert.True(arena.IsActiveFor(frame.Serial));
|
||||
Assert.Single(device.OfKind<GpuRecordedRingAllocation>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrameSerialChangeInvalidatesPriorPublication()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
var arena = new WorldTransformFrameArena();
|
||||
using (IGpuFrame first = device.BeginFrame())
|
||||
arena.Begin(first, [Matrix4x4.Identity]);
|
||||
|
||||
using IGpuFrame second = device.BeginFrame();
|
||||
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(
|
||||
() => arena.Append(second, [Matrix4x4.Identity]));
|
||||
|
||||
Assert.Contains("has not been published", failure.Message, StringComparison.Ordinal);
|
||||
Assert.False(arena.IsActive);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ using System.Numerics;
|
|||
using System.Reflection;
|
||||
using AcDream.App.Composition;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.App.Rendering.Selection;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.Streaming;
|
||||
|
|
@ -15,6 +16,94 @@ namespace AcDream.App.Tests.Rendering;
|
|||
|
||||
public sealed class WorldSceneRendererTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnhancedPreparation_BuildsCanonicalWorldOnceBeforeExecution()
|
||||
{
|
||||
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null);
|
||||
|
||||
PreparedWorldSceneFrame prepared = rig.Renderer.PrepareEnhanced(default);
|
||||
WorldRenderFrameOutcome result = rig.Renderer.RenderPreparedEnhanced(
|
||||
default,
|
||||
in prepared);
|
||||
|
||||
Assert.True(result.NormalWorldDrawn);
|
||||
Assert.True(prepared.ShouldRender);
|
||||
Assert.Equal(1, rig.Calls.Count(value => value == "frame:build"));
|
||||
Assert.True(rig.Calls.IndexOf("frame:build") < rig.Calls.IndexOf("selection:begin"));
|
||||
Assert.Equal(prepared.World.Camera.Frustum, rig.Selection.PreparedViewFrustum);
|
||||
Assert.Equal(rig.Entities.ResidentWindow, prepared.World.ResidentStreamingWindow);
|
||||
Assert.Equal((0, 0), rig.Entities.LastResidentWindowCenter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnhancedPreparation_AttachesTheSelectedAuthoredCelestialSource()
|
||||
{
|
||||
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null);
|
||||
rig.DayGroup.SkyObjects =
|
||||
[
|
||||
new SkyObjectData
|
||||
{
|
||||
GfxObjId = AuthoredCelestialShadowSourceResolver.SunGfxObjId,
|
||||
AuthoredSortCenter = Vector3.UnitX,
|
||||
BeginTime = 0f,
|
||||
EndTime = 0f,
|
||||
BeginAngle = 90f,
|
||||
EndAngle = 90f,
|
||||
},
|
||||
];
|
||||
|
||||
PreparedWorldSceneFrame prepared = rig.Renderer.PrepareEnhanced(default);
|
||||
|
||||
Assert.True(prepared.ShouldRender);
|
||||
Assert.Equal(
|
||||
AuthoredCelestialShadowSourceKind.Sun,
|
||||
prepared.World.CelestialShadowSource.Kind);
|
||||
Assert.Equal(0, prepared.World.CelestialShadowSource.ObjectIndex);
|
||||
Assert.Equal(
|
||||
AuthoredCelestialShadowSourceResolver.SunGfxObjId,
|
||||
prepared.World.CelestialShadowSource.GfxObjId);
|
||||
Assert.InRange(
|
||||
Vector3.Distance(
|
||||
Vector3.UnitZ,
|
||||
prepared.World.CelestialShadowSource.SurfaceToLightDirection),
|
||||
0f,
|
||||
1e-5f);
|
||||
|
||||
rig.Renderer.CancelPreparedEnhanced(in prepared);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnhancedPreparation_SkippedWorldStillPublishesEmptySelectionFrame()
|
||||
{
|
||||
var rig = new Rig(portalVisible: true, waitingForLogin: false, clipRoot: null);
|
||||
|
||||
PreparedWorldSceneFrame prepared = rig.Renderer.PrepareEnhanced(default);
|
||||
WorldRenderFrameOutcome result = rig.Renderer.RenderPreparedEnhanced(
|
||||
default,
|
||||
in prepared);
|
||||
|
||||
Assert.False(prepared.ShouldRender);
|
||||
Assert.Equal(default, result);
|
||||
Assert.Equal(["selection:begin", "selection:complete"], rig.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelledEnhancedPrepass_DoesNotPoisonTheNextFrame()
|
||||
{
|
||||
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null);
|
||||
PreparedWorldSceneFrame failed = rig.Renderer.PrepareEnhanced(default);
|
||||
|
||||
// Models an exception in the shadow prepass before a world pass opens.
|
||||
rig.Renderer.CancelPreparedEnhanced(in failed);
|
||||
PreparedWorldSceneFrame recovered = rig.Renderer.PrepareEnhanced(default);
|
||||
WorldRenderFrameOutcome result = rig.Renderer.RenderPreparedEnhanced(
|
||||
default,
|
||||
in recovered);
|
||||
|
||||
Assert.True(result.NormalWorldDrawn);
|
||||
Assert.Equal(2, rig.Calls.Count(value => value == "frame:build"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortalViewport_PublishesEmptySelectionFrameAndSkipsWorldOwners()
|
||||
{
|
||||
|
|
@ -445,20 +534,21 @@ public sealed class WorldSceneRendererTests
|
|||
clipRoot,
|
||||
playerSeenOutside ?? clipRoot is not null);
|
||||
Frames = new FrameBuilder(Calls, frame);
|
||||
var selection = new SelectionFrame(Calls);
|
||||
Selection = new SelectionFrame(Calls);
|
||||
var alpha = new AlphaFrame(Calls);
|
||||
var visibility = new ParticleVisibility(Calls);
|
||||
PView = new PViewRenderer(Calls);
|
||||
Passes = new PassExecutor(Calls);
|
||||
var diagnostics = new Diagnostics(Calls);
|
||||
Entities = new EntitySource();
|
||||
|
||||
Renderer = new WorldSceneRenderer(
|
||||
foundation,
|
||||
login,
|
||||
sky,
|
||||
Frames,
|
||||
new EntitySource(),
|
||||
selection,
|
||||
Entities,
|
||||
Selection,
|
||||
alpha,
|
||||
visibility,
|
||||
PView,
|
||||
|
|
@ -479,6 +569,10 @@ public sealed class WorldSceneRendererTests
|
|||
|
||||
public FrameBuilder Frames { get; }
|
||||
|
||||
public EntitySource Entities { get; }
|
||||
|
||||
public SelectionFrame Selection { get; }
|
||||
|
||||
public PViewRenderer PView { get; }
|
||||
|
||||
public PassExecutor Passes { get; }
|
||||
|
|
@ -535,6 +629,16 @@ public sealed class WorldSceneRendererTests
|
|||
|
||||
private sealed class EntitySource : IWorldSceneEntitySource
|
||||
{
|
||||
public ResidentStreamingWindowFact ResidentWindow { get; } = new(
|
||||
Revision: 77,
|
||||
CenterX: 0,
|
||||
CenterY: 0,
|
||||
CompleteRadiusLandblocks: 2,
|
||||
PublishedLandblockCount: 25,
|
||||
HasPublishedCenter: true);
|
||||
|
||||
public (int X, int Y)? LastResidentWindowCenter { get; private set; }
|
||||
|
||||
public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries =>
|
||||
|
|
@ -543,11 +647,25 @@ public sealed class WorldSceneRendererTests
|
|||
|
||||
public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds =>
|
||||
Array.Empty<(uint, Vector3, Vector3)>();
|
||||
|
||||
public ResidentStreamingWindowFact CaptureResidentStreamingWindow(
|
||||
int centerX,
|
||||
int centerY)
|
||||
{
|
||||
LastResidentWindowCenter = (centerX, centerY);
|
||||
return ResidentWindow;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SelectionFrame(List<string> calls) : IWorldSceneSelectionFrame
|
||||
{
|
||||
public void BeginFrame() => calls.Add("selection:begin");
|
||||
public FrustumPlanes? PreparedViewFrustum { get; private set; }
|
||||
|
||||
public void BeginFrame(FrustumPlanes? preparedViewFrustum = null)
|
||||
{
|
||||
PreparedViewFrustum = preparedViewFrustum;
|
||||
calls.Add("selection:begin");
|
||||
}
|
||||
|
||||
public void CompleteFrame() => calls.Add("selection:complete");
|
||||
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ public sealed class RuntimeOptionsTests
|
|||
Assert.False(opts.UiProbeDump);
|
||||
Assert.Null(opts.UiProbeScript);
|
||||
Assert.Null(opts.AutomationArtifactDirectory);
|
||||
Assert.False(opts.ExactAutomationFramebuffer);
|
||||
Assert.Equal(0.7f, opts.FogStartMultiplier);
|
||||
Assert.Equal(0.95f, opts.FogEndMultiplier);
|
||||
Assert.False(opts.UiProbeEnabled);
|
||||
|
|
@ -411,6 +412,81 @@ public sealed class RuntimeOptionsTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExactAutomationFramebuffer_IsExplicitAndExactOneOnly()
|
||||
{
|
||||
Assert.True(RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new() { ["ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER"] = "1" }))
|
||||
.ExactAutomationFramebuffer);
|
||||
|
||||
foreach (string value in new[] { "", "0", "true", "yes" })
|
||||
{
|
||||
Assert.False(RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new() { ["ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER"] = value }))
|
||||
.ExactAutomationFramebuffer);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrbitDistanceOverride_AcceptsOnlyPositiveFiniteMeters()
|
||||
{
|
||||
Assert.Null(
|
||||
RuntimeOptions.Parse(AnyDatDir, EmptyEnv())
|
||||
.InitialOrbitDistanceMeters);
|
||||
Assert.Equal(
|
||||
120f,
|
||||
RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new() { ["ACDREAM_ORBIT_DISTANCE_METERS"] = "120" }))
|
||||
.InitialOrbitDistanceMeters);
|
||||
|
||||
foreach (string rejected in new[] { "0", "-1", "NaN", "Infinity", "near" })
|
||||
{
|
||||
Assert.Null(
|
||||
RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new() { ["ACDREAM_ORBIT_DISTANCE_METERS"] = rejected }))
|
||||
.InitialOrbitDistanceMeters);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrbitAngleOverrides_AcceptFiniteYawAndBoundedPitchDegrees()
|
||||
{
|
||||
RuntimeOptions defaults = RuntimeOptions.Parse(AnyDatDir, EmptyEnv());
|
||||
Assert.Null(defaults.InitialOrbitYawDegrees);
|
||||
Assert.Null(defaults.InitialOrbitPitchDegrees);
|
||||
|
||||
RuntimeOptions parsed = RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new()
|
||||
{
|
||||
["ACDREAM_ORBIT_YAW_DEGREES"] = "-135.5",
|
||||
["ACDREAM_ORBIT_PITCH_DEGREES"] = "7.25",
|
||||
}));
|
||||
Assert.Equal(-135.5f, parsed.InitialOrbitYawDegrees);
|
||||
Assert.Equal(7.25f, parsed.InitialOrbitPitchDegrees);
|
||||
|
||||
foreach (string rejected in new[] { "NaN", "Infinity", "angle" })
|
||||
{
|
||||
Assert.Null(
|
||||
RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new() { ["ACDREAM_ORBIT_YAW_DEGREES"] = rejected }))
|
||||
.InitialOrbitYawDegrees);
|
||||
}
|
||||
foreach (string rejected in new[] { "-90", "90", "NaN", "pitch" })
|
||||
{
|
||||
Assert.Null(
|
||||
RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new() { ["ACDREAM_ORBIT_PITCH_DEGREES"] = rejected }))
|
||||
.InitialOrbitPitchDegrees);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V7. The sky has two clocks and this pins the one
|
||||
/// ACDREAM_DAY_GROUP cannot reach — the cloud sheet's UV scroll, which
|
||||
|
|
|
|||
|
|
@ -298,12 +298,13 @@ public sealed class RuntimeSettingsControllerTests
|
|||
var target = new SilkRuntimeDisplayWindowTarget(
|
||||
surface, switcher, _ => false);
|
||||
|
||||
target.Apply(DisplaySettings.Default with
|
||||
RuntimeDisplayApplyResult result = target.Apply(DisplaySettings.Default with
|
||||
{
|
||||
Fullscreen = true,
|
||||
Resolution = "1234x777",
|
||||
});
|
||||
|
||||
Assert.False(result.Fullscreen);
|
||||
Assert.Empty(switcher.Calls);
|
||||
Assert.Equal(0, surface.Writes);
|
||||
}
|
||||
|
|
@ -318,12 +319,13 @@ public sealed class RuntimeSettingsControllerTests
|
|||
var target = new SilkRuntimeDisplayWindowTarget(
|
||||
surface, switcher, _ => true);
|
||||
|
||||
target.Apply(DisplaySettings.Default with
|
||||
RuntimeDisplayApplyResult result = target.Apply(DisplaySettings.Default with
|
||||
{
|
||||
Fullscreen = true,
|
||||
Resolution = "1920x1080",
|
||||
});
|
||||
|
||||
Assert.False(result.Fullscreen);
|
||||
Assert.False(switcher.IsFullscreen);
|
||||
Assert.Equal(0, surface.Writes);
|
||||
}
|
||||
|
|
@ -879,6 +881,80 @@ public sealed class RuntimeSettingsControllerTests
|
|||
Assert.Contains(logs, line => line.Contains("display save failed", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefusedFullscreenRequest_ReconcilesPersistedAndPublishedState()
|
||||
{
|
||||
var events = new List<string>();
|
||||
var storage = new FakeStorage(events);
|
||||
var controller = new RuntimeSettingsController(
|
||||
storage,
|
||||
static preset => QualitySettings.From(preset),
|
||||
static _ => { });
|
||||
storage.ClearEvents();
|
||||
var targets = new FakeRuntimeTargets(events)
|
||||
{
|
||||
DisplayResult = new RuntimeDisplayApplyResult(Fullscreen: false),
|
||||
};
|
||||
controller.BindRuntimeTargets(targets);
|
||||
var observed = new List<DisplaySettings>();
|
||||
controller.DisplayChanged += observed.Add;
|
||||
|
||||
controller.SaveDisplay(controller.Display with
|
||||
{
|
||||
Fullscreen = true,
|
||||
Resolution = "2056x1290",
|
||||
});
|
||||
|
||||
Assert.Equal(2, storage.DisplaySaves);
|
||||
Assert.False(storage.DisplayValue.Fullscreen);
|
||||
Assert.False(controller.Display.Fullscreen);
|
||||
Assert.False(Assert.Single(observed).Fullscreen);
|
||||
Assert.Equal(
|
||||
["save-display", "target-display", "save-display", "target-quality"],
|
||||
events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Successful_display_commit_publishes_render_pack_selection_once()
|
||||
{
|
||||
var storage = new FakeStorage();
|
||||
RuntimeSettingsController controller = CreateController(storage);
|
||||
var observed = new List<DisplaySettings>();
|
||||
controller.DisplayChanged += observed.Add;
|
||||
DisplaySettings selected = controller.Display with
|
||||
{
|
||||
RenderPack = new RenderPackSelectionSettings(
|
||||
"acdream.atmospheric",
|
||||
"1.0.0",
|
||||
"medium"),
|
||||
};
|
||||
|
||||
controller.SaveDisplay(selected);
|
||||
|
||||
Assert.Same(selected, controller.Display);
|
||||
Assert.Equal([selected], observed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_display_commit_does_not_publish_render_pack_selection()
|
||||
{
|
||||
var storage = new FakeStorage { ThrowOnDisplaySave = true };
|
||||
RuntimeSettingsController controller = CreateController(storage);
|
||||
int observed = 0;
|
||||
controller.DisplayChanged += _ => observed++;
|
||||
|
||||
controller.SaveDisplay(controller.Display with
|
||||
{
|
||||
RenderPack = new RenderPackSelectionSettings(
|
||||
"acdream.atmospheric",
|
||||
"1.0.0",
|
||||
"high"),
|
||||
});
|
||||
|
||||
Assert.Equal(0, observed);
|
||||
Assert.True(controller.Display.RenderPack.IsRetail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonDisplayPersistenceFailuresContinueAndPreserveControllerState()
|
||||
{
|
||||
|
|
@ -1132,7 +1208,7 @@ public sealed class RuntimeSettingsControllerTests
|
|||
|
||||
public int RemainingAudioFailures { get; set; }
|
||||
|
||||
public void ApplyDisplay(DisplaySettings display)
|
||||
public RuntimeDisplayApplyResult ApplyDisplay(DisplaySettings display)
|
||||
{
|
||||
events.Add("startup-display");
|
||||
|
||||
|
|
@ -1141,6 +1217,7 @@ public sealed class RuntimeSettingsControllerTests
|
|||
RemainingDisplayFailures--;
|
||||
throw new InvalidOperationException("display startup failed");
|
||||
}
|
||||
return new RuntimeDisplayApplyResult(display.Fullscreen);
|
||||
}
|
||||
|
||||
public void ApplyAudio(AudioSettings audio)
|
||||
|
|
@ -1161,15 +1238,18 @@ public sealed class RuntimeSettingsControllerTests
|
|||
|
||||
public bool ThrowOnQuality { get; init; }
|
||||
|
||||
public RuntimeDisplayApplyResult? DisplayResult { get; init; }
|
||||
|
||||
public int RemainingUiLockFailures { get; set; }
|
||||
|
||||
public int UiLockCalls { get; private set; }
|
||||
|
||||
public void ApplyDisplayWindowState(DisplaySettings display)
|
||||
public RuntimeDisplayApplyResult ApplyDisplayWindowState(DisplaySettings display)
|
||||
{
|
||||
events.Add("target-display");
|
||||
if (ThrowOnDisplay)
|
||||
throw new InvalidOperationException("display target failed");
|
||||
return DisplayResult ?? new RuntimeDisplayApplyResult(display.Fullscreen);
|
||||
}
|
||||
|
||||
public void ApplyQuality(QualitySettings quality)
|
||||
|
|
@ -1235,10 +1315,11 @@ public sealed class RuntimeSettingsControllerTests
|
|||
{
|
||||
public int ApplyCount { get; private set; }
|
||||
|
||||
public void Apply(DisplaySettings display)
|
||||
public RuntimeDisplayApplyResult Apply(DisplaySettings display)
|
||||
{
|
||||
ApplyCount++;
|
||||
apply(display);
|
||||
return new RuntimeDisplayApplyResult(display.Fullscreen);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
using AcDream.App.Streaming;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Tests.Streaming;
|
||||
|
||||
public sealed class ResidentStreamingWindowFactTests
|
||||
{
|
||||
[Fact]
|
||||
public void CaptureUsesOnlyLargestCompleteActuallyPublishedWindow()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
AddSquare(state, centerX: 100, centerY: 101, radius: 1);
|
||||
ResidentStreamingWindowFact first =
|
||||
state.CaptureResidentStreamingWindow(100, 101);
|
||||
Assert.True(first.HasPublishedCenter);
|
||||
Assert.Equal(1, first.CompleteRadiusLandblocks);
|
||||
Assert.Equal(192f, first.MaximumReachMeters);
|
||||
|
||||
// A retained outer ring with one unpublished gap is not a safe shadow
|
||||
// reach even though most of its landblocks are live.
|
||||
AddRing(state, 100, 101, radius: 2, skipX: 102, skipY: 101);
|
||||
ResidentStreamingWindowFact incomplete =
|
||||
state.CaptureResidentStreamingWindow(100, 101);
|
||||
Assert.Equal(1, incomplete.CompleteRadiusLandblocks);
|
||||
Assert.Equal(192f, incomplete.MaximumReachMeters);
|
||||
|
||||
Add(state, 102, 101);
|
||||
ResidentStreamingWindowFact complete =
|
||||
state.CaptureResidentStreamingWindow(100, 101);
|
||||
Assert.Equal(2, complete.CompleteRadiusLandblocks);
|
||||
Assert.Equal(384f, complete.MaximumReachMeters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NearToFarDemotionRetainsTerrainReachAndDoesNotRepublishTheWindow()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
AddSquare(state, centerX: 40, centerY: 50, radius: 1);
|
||||
ResidentStreamingWindowFact before =
|
||||
state.CaptureResidentStreamingWindow(40, 50);
|
||||
|
||||
GpuLandblockRetirement? retirement = state.DetachNearLayer(
|
||||
StreamingRegion.EncodeLandblockId(41, 50));
|
||||
ResidentStreamingWindowFact after =
|
||||
state.CaptureResidentStreamingWindow(40, 50);
|
||||
|
||||
Assert.NotNull(retirement);
|
||||
Assert.Equal(before.Revision, after.Revision);
|
||||
Assert.Equal(before.MaximumReachMeters, after.MaximumReachMeters);
|
||||
Assert.Equal(before.PublishedLandblockCount, after.PublishedLandblockCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecenterAndPortalGenerationTurnoverCannotReusePriorReach()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
AddSquare(state, centerX: 12, centerY: 20, radius: 2);
|
||||
ResidentStreamingWindowFact oldGeneration =
|
||||
state.CaptureResidentStreamingWindow(12, 20);
|
||||
Assert.Equal(384f, oldGeneration.MaximumReachMeters);
|
||||
|
||||
_ = state.DetachAllForOriginRecenter();
|
||||
ResidentStreamingWindowFact betweenGenerations =
|
||||
state.CaptureResidentStreamingWindow(12, 20);
|
||||
Assert.False(betweenGenerations.HasPublishedCenter);
|
||||
Assert.Equal(0f, betweenGenerations.MaximumReachMeters);
|
||||
Assert.True(betweenGenerations.Revision > oldGeneration.Revision);
|
||||
|
||||
Add(state, 220, 221);
|
||||
ResidentStreamingWindowFact newGeneration =
|
||||
state.CaptureResidentStreamingWindow(220, 221);
|
||||
Assert.True(newGeneration.HasPublishedCenter);
|
||||
Assert.Equal(0, newGeneration.CompleteRadiusLandblocks);
|
||||
Assert.Equal(0f, newGeneration.MaximumReachMeters);
|
||||
Assert.True(newGeneration.Revision > betweenGenerations.Revision);
|
||||
Assert.False(state.CaptureResidentStreamingWindow(12, 20).HasPublishedCenter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemovingOneResidentEdgeImmediatelyShrinksTheReadOnlyReach()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
AddSquare(state, centerX: 80, centerY: 90, radius: 2);
|
||||
ResidentStreamingWindowFact before =
|
||||
state.CaptureResidentStreamingWindow(80, 90);
|
||||
|
||||
state.RemoveLandblock(StreamingRegion.EncodeLandblockId(82, 90));
|
||||
ResidentStreamingWindowFact after =
|
||||
state.CaptureResidentStreamingWindow(80, 90);
|
||||
|
||||
Assert.True(after.Revision > before.Revision);
|
||||
Assert.Equal(1, after.CompleteRadiusLandblocks);
|
||||
Assert.Equal(192f, after.MaximumReachMeters);
|
||||
}
|
||||
|
||||
private static void AddSquare(
|
||||
GpuWorldState state,
|
||||
int centerX,
|
||||
int centerY,
|
||||
int radius)
|
||||
{
|
||||
for (int x = centerX - radius; x <= centerX + radius; x++)
|
||||
{
|
||||
for (int y = centerY - radius; y <= centerY + radius; y++)
|
||||
Add(state, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddRing(
|
||||
GpuWorldState state,
|
||||
int centerX,
|
||||
int centerY,
|
||||
int radius,
|
||||
int skipX,
|
||||
int skipY)
|
||||
{
|
||||
for (int x = centerX - radius; x <= centerX + radius; x++)
|
||||
{
|
||||
for (int y = centerY - radius; y <= centerY + radius; y++)
|
||||
{
|
||||
if (Math.Max(Math.Abs(x - centerX), Math.Abs(y - centerY)) != radius
|
||||
|| (x == skipX && y == skipY))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Add(state, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void Add(GpuWorldState state, int x, int y)
|
||||
{
|
||||
uint landblockId = StreamingRegion.EncodeLandblockId(x, y);
|
||||
if (state.TryGetLandblock(landblockId, out _))
|
||||
return;
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
landblockId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ using System.Linq;
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
|
@ -285,7 +286,8 @@ public sealed class ConfigOptionsPageControllerTests
|
|||
public List<CameraTurningSettings> CameraTurningSaves { get; } = new();
|
||||
public List<ChatSettings> ChatSaves { get; } = new();
|
||||
|
||||
public ConfigOptionsPageController.Bindings ToBindings() => new(
|
||||
public ConfigOptionsPageController.Bindings ToBindings(
|
||||
ConfigOptionsPageController.RenderPackBindings? renderPacks = null) => new(
|
||||
LoadDisplay: () => Display,
|
||||
SaveDisplay: value => { Display = value; DisplaySaves.Add(value); },
|
||||
LoadAudio: () => Audio,
|
||||
|
|
@ -293,7 +295,10 @@ public sealed class ConfigOptionsPageControllerTests
|
|||
LoadCameraTurning: () => CameraTurning,
|
||||
SaveCameraTurning: value => { CameraTurning = value; CameraTurningSaves.Add(value); },
|
||||
LoadChat: () => Chat,
|
||||
SaveChat: value => { Chat = value; ChatSaves.Add(value); });
|
||||
SaveChat: value => { Chat = value; ChatSaves.Add(value); })
|
||||
{
|
||||
RenderPacks = renderPacks,
|
||||
};
|
||||
}
|
||||
|
||||
private static (OptionsPanelController Panel, FakeBindings Bindings, bool Bound) BindReal(
|
||||
|
|
@ -430,6 +435,377 @@ public sealed class ConfigOptionsPageControllerTests
|
|||
Assert.Equal(39, viewport.Children.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptInRenderPackBindings_append_two_menus_without_changing_retail_only_fixture()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
||||
OptionsPanelController controller = OptionsPanelController.Bind(
|
||||
layout,
|
||||
new OptionsPanelController.Callbacks(
|
||||
Toggle: () => { },
|
||||
RequestExitToCharacterSelection: () => { },
|
||||
ExitGame: () => { },
|
||||
UseMouseTurningSettings: () => { },
|
||||
DisplaySystemMessage: _ => { }))!;
|
||||
var fake = new FakeBindings();
|
||||
string? activationFailure = "Last activation failed: shader interface mismatch.";
|
||||
var renderPacks = new ConfigOptionsPageController.RenderPackBindings(() =>
|
||||
[
|
||||
new ConfigOptionsPageController.RenderPackChoice(
|
||||
"acdream.atmospheric",
|
||||
"Atmospheric Rendering",
|
||||
"1.0.0",
|
||||
true,
|
||||
null,
|
||||
[
|
||||
new ConfigOptionsPageController.RenderPackPresetChoice(
|
||||
"low", "Low", true, null)
|
||||
{
|
||||
MaxResidentGpuBytes = 64L * 1024 * 1024,
|
||||
MaxIncrementalGpuMillisecondsP50 = 2.0,
|
||||
MaxIncrementalGpuMillisecondsP99 = 3.0,
|
||||
MaxIncrementalCpuMillisecondsP50 = 0.15,
|
||||
MaxIncrementalCpuMillisecondsP99 = 0.50,
|
||||
},
|
||||
new("medium", "Medium", true, null),
|
||||
new("high", "High", false, "High needs more GPU memory."),
|
||||
])
|
||||
{
|
||||
FeatureSummary = "Filmic atmosphere and moving-sun shadows.",
|
||||
},
|
||||
new ConfigOptionsPageController.RenderPackChoice(
|
||||
"test.unsupported",
|
||||
"Unsupported Test Pack",
|
||||
"2.0.0",
|
||||
false,
|
||||
"Directional depth sampling is unavailable.",
|
||||
[new("low", "Low", false, "Directional depth sampling is unavailable.")]),
|
||||
])
|
||||
{
|
||||
LoadFailureNotice = () => activationFailure,
|
||||
};
|
||||
|
||||
bool bound = ConfigOptionsPageController.Bind(
|
||||
layout,
|
||||
controller.ConfigPage,
|
||||
MakeTemplateResolver(),
|
||||
(_, _) => null,
|
||||
fake.ToBindings(renderPacks));
|
||||
|
||||
Assert.True(bound);
|
||||
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
|
||||
var listBox = Assert.IsType<UiTemplateListBox>(
|
||||
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
|
||||
Assert.Equal(43, Assert.Single(listBox.Children).Children.Count);
|
||||
Assert.Equal(32, controller.ConfigPage.Rows.Count);
|
||||
|
||||
List<UiMenu> menus = CollectMenus(configSlot);
|
||||
UiMenu packMenu = menus[^2];
|
||||
UiMenu presetMenu = menus[^1];
|
||||
Assert.Equal("acdream default (retail-faithful)", packMenu.Items[0].Label);
|
||||
Assert.Contains(packMenu.Items, value => Equals(value.Payload, "acdream.atmospheric"));
|
||||
Assert.False(packMenu.EnabledProvider!("test.unsupported"));
|
||||
Assert.Equal(
|
||||
"Last activation failed: shader interface mismatch.",
|
||||
packMenu.GetTooltipText()!.Split(Environment.NewLine)[0]);
|
||||
packMenu.OnSelect!("test.unsupported");
|
||||
Assert.True(fake.Display.RenderPack.IsRetail);
|
||||
|
||||
packMenu.OnSelect!("acdream.atmospheric");
|
||||
Assert.Equal("acdream.atmospheric", fake.Display.RenderPack.PackId);
|
||||
Assert.Equal("1.0.0", fake.Display.RenderPack.PackVersion);
|
||||
Assert.Equal("low", fake.Display.RenderPack.PresetId);
|
||||
activationFailure = null;
|
||||
Assert.Equal(
|
||||
"Filmic atmosphere and moving-sun shadows.",
|
||||
packMenu.GetTooltipText());
|
||||
presetMenu = CollectMenus(configSlot)[^1];
|
||||
Assert.Equal(3, presetMenu.Items.Count);
|
||||
Assert.False(presetMenu.EnabledProvider!("high"));
|
||||
Assert.Contains(
|
||||
"GPU p50/p99 ≤ 2/3 ms",
|
||||
presetMenu.GetTooltipText(),
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("pack VRAM ≤ 64 MiB", presetMenu.GetTooltipText(), StringComparison.Ordinal);
|
||||
|
||||
presetMenu.OnSelect!("medium");
|
||||
Assert.Equal("medium", fake.Display.RenderPack.PresetId);
|
||||
Assert.True(fake.DisplaySaves.Count >= 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderPackMenu_revision_refresh_removes_withdrawn_schema_and_discovers_reregistration()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
||||
OptionsPanelController controller = OptionsPanelController.Bind(
|
||||
layout,
|
||||
new OptionsPanelController.Callbacks(
|
||||
Toggle: () => { },
|
||||
RequestExitToCharacterSelection: () => { },
|
||||
ExitGame: () => { },
|
||||
UseMouseTurningSettings: () => { },
|
||||
DisplaySystemMessage: _ => { }))!;
|
||||
var alpha = new ConfigOptionsPageController.RenderPackChoice(
|
||||
"pack.alpha", "Alpha", "1.0.0", true, null,
|
||||
[new("low", "Low", true, null)])
|
||||
{
|
||||
Settings =
|
||||
[
|
||||
new RenderSettingDeclaration(
|
||||
"alpha-toggle", "Alpha toggle", RenderSettingKind.Boolean,
|
||||
"true", null, null, null, []),
|
||||
],
|
||||
};
|
||||
var beta = new ConfigOptionsPageController.RenderPackChoice(
|
||||
"pack.beta", "Beta", "2.0.0", true, null,
|
||||
[new("medium", "Medium", true, null)]);
|
||||
IReadOnlyList<ConfigOptionsPageController.RenderPackChoice> discovered = [alpha];
|
||||
long revision = 1;
|
||||
var fake = new FakeBindings
|
||||
{
|
||||
Display = DisplaySettings.Default with
|
||||
{
|
||||
RenderPack = new RenderPackSelectionSettings(
|
||||
"pack.alpha", "1.0.0", "low"),
|
||||
},
|
||||
};
|
||||
var renderPacks = new ConfigOptionsPageController.RenderPackBindings(
|
||||
() => discovered)
|
||||
{
|
||||
LoadRevision = () => revision,
|
||||
};
|
||||
|
||||
Assert.True(ConfigOptionsPageController.Bind(
|
||||
layout,
|
||||
controller.ConfigPage,
|
||||
MakeTemplateResolver(),
|
||||
(_, _) => null,
|
||||
fake.ToBindings(renderPacks)));
|
||||
|
||||
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
|
||||
var listBox = Assert.IsType<UiTemplateListBox>(
|
||||
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
|
||||
UiMenu packMenu = CollectMenus(configSlot)[^2];
|
||||
Assert.Equal(44, listBox.ItemCount);
|
||||
Assert.Contains(packMenu.Items, item => Equals(item.Payload, "pack.alpha"));
|
||||
|
||||
discovered = [beta];
|
||||
revision++;
|
||||
packMenu.BeforeOpen!();
|
||||
|
||||
Assert.DoesNotContain(packMenu.Items, item => Equals(item.Payload, "pack.alpha"));
|
||||
Assert.Contains(packMenu.Items, item => Equals(item.Payload, "pack.beta"));
|
||||
Assert.Equal(RenderPackSelectionSettings.RetailPackId, packMenu.Selected);
|
||||
Assert.Equal(43, listBox.ItemCount);
|
||||
Assert.Equal(32, controller.ConfigPage.Rows.Count);
|
||||
controller.ConfigPage.Reset();
|
||||
Assert.Equal(RenderPackSelectionSettings.RetailPackId, packMenu.Selected);
|
||||
Assert.DoesNotContain(packMenu.Items, item => Equals(item.Payload, "pack.alpha"));
|
||||
|
||||
fake.Display = fake.Display with
|
||||
{
|
||||
RenderPack = new RenderPackSelectionSettings(
|
||||
"pack.beta", "2.0.0", "medium"),
|
||||
};
|
||||
revision++;
|
||||
packMenu.BeforeOpen!();
|
||||
|
||||
Assert.Equal("pack.beta", packMenu.Selected);
|
||||
Assert.Equal(43, listBox.ItemCount);
|
||||
Assert.Equal("Medium", CollectMenus(configSlot)[^1].Items.Single().Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderPackSettings_live_schema_swap_replaces_only_the_optional_tail()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
||||
OptionsPanelController controller = OptionsPanelController.Bind(
|
||||
layout,
|
||||
new OptionsPanelController.Callbacks(
|
||||
Toggle: () => { },
|
||||
RequestExitToCharacterSelection: () => { },
|
||||
ExitGame: () => { },
|
||||
UseMouseTurningSettings: () => { },
|
||||
DisplaySystemMessage: _ => { }))!;
|
||||
var fake = new FakeBindings
|
||||
{
|
||||
Display = DisplaySettings.Default with
|
||||
{
|
||||
RenderPack = new RenderPackSelectionSettings("pack.alpha", "1.0.0", "low"),
|
||||
},
|
||||
};
|
||||
RenderSettingDeclaration[] alphaSettings =
|
||||
[
|
||||
new("enabled", "Enabled", RenderSettingKind.Boolean, "false", null, null, null, []),
|
||||
new("strength", "Strength", RenderSettingKind.Float, "0.5", 0, 1, 0.25, []),
|
||||
new("samples", "Samples", RenderSettingKind.Integer, "2", 0, 10, 2, []),
|
||||
new("mode", "Mode", RenderSettingKind.Choice, "low", null, null, null,
|
||||
["low", "high"]),
|
||||
];
|
||||
var alpha = new ConfigOptionsPageController.RenderPackChoice(
|
||||
"pack.alpha", "Alpha", "1.0.0", true, null,
|
||||
[
|
||||
new ConfigOptionsPageController.RenderPackPresetChoice(
|
||||
"low", "Low", true, null)
|
||||
{
|
||||
SettingOverrides =
|
||||
[
|
||||
new RenderQualitySettingOverride("strength", "0.75"),
|
||||
],
|
||||
},
|
||||
new ConfigOptionsPageController.RenderPackPresetChoice(
|
||||
"high", "High", true, null),
|
||||
])
|
||||
{
|
||||
Settings = alphaSettings,
|
||||
};
|
||||
var beta = new ConfigOptionsPageController.RenderPackChoice(
|
||||
"pack.beta", "Beta", "2.0.0", true, null,
|
||||
[new("default", "Default", true, null)])
|
||||
{
|
||||
Settings =
|
||||
[
|
||||
new RenderSettingDeclaration(
|
||||
"beta-enabled", "Beta enabled", RenderSettingKind.Boolean,
|
||||
"true", null, null, null, []),
|
||||
],
|
||||
};
|
||||
var renderPacks = new ConfigOptionsPageController.RenderPackBindings(() =>
|
||||
[alpha, beta]);
|
||||
|
||||
Assert.True(ConfigOptionsPageController.Bind(
|
||||
layout,
|
||||
controller.ConfigPage,
|
||||
MakeTemplateResolver(),
|
||||
(_, _) => null,
|
||||
fake.ToBindings(renderPacks)));
|
||||
|
||||
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
|
||||
var listBox = Assert.IsType<UiTemplateListBox>(
|
||||
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
|
||||
Assert.Equal(47, listBox.ItemCount); // exact retail 39 + 8-row optional tail
|
||||
Assert.Equal(36, controller.ConfigPage.Rows.Count); // retail 30 + pack/preset + 4 settings
|
||||
|
||||
IReadOnlyList<UiElement> items = listBox.ViewportForTest!.Children;
|
||||
var enabled = Assert.IsType<UiButton>(
|
||||
UiElement.FindDescendant(items[42], 0x10000219u));
|
||||
var strength = Assert.IsType<UiScrollbar>(
|
||||
UiElement.FindDescendant(items[43], 0x1000021Cu));
|
||||
var samples = Assert.IsType<UiScrollbar>(
|
||||
UiElement.FindDescendant(items[44], 0x1000021Cu));
|
||||
List<UiMenu> menus = CollectMenus(configSlot);
|
||||
UiMenu packMenu = menus[^3];
|
||||
UiMenu presetMenu = menus[^2];
|
||||
UiMenu oldModeMenu = menus[^1];
|
||||
Assert.Equal(0.75f, strength.ScalarPosition, 3); // preset wins declaration default
|
||||
Assert.Equal(["low", "high"], oldModeMenu.Items.Select(value => value.Label));
|
||||
|
||||
enabled.Selected = true;
|
||||
enabled.OnClick!();
|
||||
strength.ScalarChanged!(0.62f); // 0.62 snaps to 0.5 on the declared 0.25 step
|
||||
samples.ScalarChanged!(0.33f); // 3.3 snaps to integer step 4
|
||||
oldModeMenu.OnSelect!("high");
|
||||
|
||||
Assert.Equal("true", fake.Display.RenderPack.SettingOverrides["enabled"]);
|
||||
Assert.Equal("0.5", fake.Display.RenderPack.SettingOverrides["strength"]);
|
||||
Assert.Equal("4", fake.Display.RenderPack.SettingOverrides["samples"]);
|
||||
Assert.Equal("high", fake.Display.RenderPack.SettingOverrides["mode"]);
|
||||
|
||||
presetMenu.OnSelect!("high");
|
||||
Assert.Equal("high", fake.Display.RenderPack.PresetId);
|
||||
Assert.Equal(4, fake.Display.RenderPack.SettingOverrides.Count);
|
||||
Assert.Equal(47, listBox.ItemCount);
|
||||
Assert.Equal(36, controller.ConfigPage.Rows.Count);
|
||||
int savesBeforeStaleWidget = fake.DisplaySaves.Count;
|
||||
oldModeMenu.OnSelect!("low");
|
||||
Assert.Equal(savesBeforeStaleWidget, fake.DisplaySaves.Count);
|
||||
|
||||
packMenu.OnSelect!("pack.beta");
|
||||
Assert.Equal("pack.beta", fake.Display.RenderPack.PackId);
|
||||
Assert.Equal("2.0.0", fake.Display.RenderPack.PackVersion);
|
||||
Assert.Equal("default", fake.Display.RenderPack.PresetId);
|
||||
Assert.Empty(fake.Display.RenderPack.SettingOverrides);
|
||||
Assert.Equal(44, listBox.ItemCount); // header + pack + preset + one setting + separator
|
||||
Assert.Equal(33, controller.ConfigPage.Rows.Count);
|
||||
|
||||
oldModeMenu.OnSelect!("high");
|
||||
Assert.Empty(fake.Display.RenderPack.SettingOverrides);
|
||||
|
||||
controller.ConfigPage.Reset();
|
||||
Assert.Equal("pack.alpha", fake.Display.RenderPack.PackId);
|
||||
Assert.Equal(47, listBox.ItemCount);
|
||||
Assert.Equal(36, controller.ConfigPage.Rows.Count);
|
||||
|
||||
controller.ConfigPage.Defaults();
|
||||
Assert.True(fake.Display.RenderPack.IsRetail);
|
||||
Assert.Equal(43, listBox.ItemCount); // header + pack + retail preset + separator
|
||||
Assert.Equal(32, controller.ConfigPage.Rows.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderPackSettingEdit_sanitizes_unknown_and_invalid_persisted_values()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
||||
OptionsPanelController controller = OptionsPanelController.Bind(
|
||||
layout,
|
||||
new OptionsPanelController.Callbacks(
|
||||
Toggle: () => { },
|
||||
RequestExitToCharacterSelection: () => { },
|
||||
ExitGame: () => { },
|
||||
UseMouseTurningSettings: () => { },
|
||||
DisplaySystemMessage: _ => { }))!;
|
||||
var fake = new FakeBindings
|
||||
{
|
||||
Display = DisplaySettings.Default with
|
||||
{
|
||||
RenderPack = new RenderPackSelectionSettings("pack.alpha", "1.0.0", "low")
|
||||
{
|
||||
SettingOverrides = new RenderPackSettingOverrides(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["removed"] = "1",
|
||||
["strength"] = "0.6", // not aligned to 0.25
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
var pack = new ConfigOptionsPageController.RenderPackChoice(
|
||||
"pack.alpha", "Alpha", "1.0.0", true, null,
|
||||
[new("low", "Low", true, null)])
|
||||
{
|
||||
Settings =
|
||||
[
|
||||
new RenderSettingDeclaration(
|
||||
"enabled", "Enabled", RenderSettingKind.Boolean,
|
||||
"false", null, null, null, []),
|
||||
new RenderSettingDeclaration(
|
||||
"strength", "Strength", RenderSettingKind.Float,
|
||||
"0.5", 0, 1, 0.25, []),
|
||||
],
|
||||
};
|
||||
|
||||
Assert.True(ConfigOptionsPageController.Bind(
|
||||
layout,
|
||||
controller.ConfigPage,
|
||||
MakeTemplateResolver(),
|
||||
(_, _) => null,
|
||||
fake.ToBindings(new ConfigOptionsPageController.RenderPackBindings(() => [pack]))));
|
||||
|
||||
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
|
||||
var listBox = Assert.IsType<UiTemplateListBox>(
|
||||
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
|
||||
UiElement enabledRow = listBox.ViewportForTest!.Children[42];
|
||||
var enabled = Assert.IsType<UiButton>(
|
||||
UiElement.FindDescendant(enabledRow, 0x10000219u));
|
||||
enabled.Selected = true;
|
||||
enabled.OnClick!();
|
||||
|
||||
Assert.Single(fake.Display.RenderPack.SettingOverrides);
|
||||
Assert.Equal("true", fake.Display.RenderPack.SettingOverrides["enabled"]);
|
||||
Assert.False(fake.Display.RenderPack.SettingOverrides.ContainsKey("removed"));
|
||||
Assert.False(fake.Display.RenderPack.SettingOverrides.ContainsKey("strength"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleRow_SfxEnabled_WritesThroughAudioBindings()
|
||||
{
|
||||
|
|
@ -1043,7 +1419,7 @@ public sealed class ConfigOptionsPageControllerTests
|
|||
(24, RowKind.Menu, true, "Environment Texture Detail"), // AP-198
|
||||
(25, RowKind.Menu, true, "Texture Filtering"), // AP-198
|
||||
(26, RowKind.Menu, true, "Landscape Draw Distance"), // AP-198
|
||||
(27, RowKind.Toggle, true, "Building Detail Textures"), // AP-198
|
||||
(27, RowKind.Toggle, false, "Building Detail Textures"), // LIVE — #226
|
||||
(28, RowKind.Toggle, true, "Multi-Pass Alpha"), // AP-198
|
||||
(31, RowKind.Slider, true, "Mouse Look Sensitivity"), // TS-74
|
||||
(32, RowKind.Toggle, true, "Invert Mouselook Y Axis"), // TS-74
|
||||
|
|
|
|||
|
|
@ -431,4 +431,47 @@ public sealed class OptionPageModelTests
|
|||
Assert.Equal(0, flushCount); // NO AfterApply publication on a seed
|
||||
Assert.Equal(1, gatingCount); // Apply/Reset ghosting re-evaluated
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveTail_DetachesRowsAndPageNotifyOwnership()
|
||||
{
|
||||
var page = new OptionPage();
|
||||
var retained = new BoolOptionRow(false, false);
|
||||
var removed = new BoolOptionRow(false, false);
|
||||
int notifications = 0;
|
||||
page.OnOptionChanged = () => notifications++;
|
||||
page.Register(retained);
|
||||
page.Register(removed);
|
||||
|
||||
page.RemoveTail(1);
|
||||
notifications = 0;
|
||||
removed.SetCurrentValue(true);
|
||||
|
||||
Assert.Single(page.Rows);
|
||||
Assert.Same(retained, page.Rows[0]);
|
||||
Assert.Equal(0, notifications);
|
||||
Assert.False(page.Changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_IsSafeWhenAnEarlierRowReplacesTheDynamicTail()
|
||||
{
|
||||
var page = new OptionPage();
|
||||
int removedApplyCount = 0;
|
||||
var pack = new BoolOptionRow(
|
||||
initial: true,
|
||||
defaultValue: false,
|
||||
apply: _ => page.RemoveTail(1));
|
||||
var dynamic = new BoolOptionRow(
|
||||
initial: false,
|
||||
defaultValue: true,
|
||||
apply: _ => removedApplyCount++);
|
||||
page.Register(pack);
|
||||
page.Register(dynamic);
|
||||
|
||||
page.Defaults();
|
||||
|
||||
Assert.Single(page.Rows);
|
||||
Assert.Equal(0, removedApplyCount);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,10 +41,65 @@ public sealed class RetailUiAutomationProbeTests
|
|||
public bool IsWorldReady { get; set; }
|
||||
public bool IsWorldViewportVisible { get; set; }
|
||||
public int PortalMaterializationCount { get; set; }
|
||||
public int RenderPackPerformanceSampleCount { get; set; }
|
||||
public bool RenderPackFailedToRetail { get; set; }
|
||||
public RetailUiAutomationRenderPackStatus RenderPackStatus { get; set; } =
|
||||
RetailUiAutomationRenderPackStatus.Retail;
|
||||
public int FramebufferWidth { get; set; } = 1280;
|
||||
public int FramebufferHeight { get; set; } = 720;
|
||||
public int RenderPackPerformanceResetCount { get; private set; }
|
||||
public List<string> RenderPackSelections { get; } = [];
|
||||
public int RenderPackDisableCount { get; private set; }
|
||||
public int RenderPackReenableCount { get; private set; }
|
||||
public List<(int Width, int Height)> FramebufferResizes { get; } = [];
|
||||
public int ClientCloseRequestCount { get; private set; }
|
||||
public List<string> Checkpoints { get; } = new();
|
||||
public HashSet<string> ScreenshotRequests { get; } = new();
|
||||
public HashSet<string> CompletedScreenshots { get; } = new();
|
||||
|
||||
public bool TryResetRenderPackPerformance(out string error)
|
||||
{
|
||||
RenderPackPerformanceResetCount++;
|
||||
RenderPackPerformanceSampleCount = 0;
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TrySelectRenderPack(string presetId, out string error)
|
||||
{
|
||||
RenderPackSelections.Add(presetId);
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryDisableRenderPack(out string error)
|
||||
{
|
||||
RenderPackDisableCount++;
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryReenableRenderPack(out string error)
|
||||
{
|
||||
RenderPackReenableCount++;
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryResizeFramebuffer(int width, int height, out string error)
|
||||
{
|
||||
FramebufferResizes.Add((width, height));
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryRequestClientClose(out string error)
|
||||
{
|
||||
ClientCloseRequestCount++;
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryRequestCheckpoint(
|
||||
string name,
|
||||
out IRetailUiAutomationCheckpoint? checkpoint,
|
||||
|
|
@ -654,6 +709,199 @@ public sealed class RetailUiAutomationProbeTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptRunner_resetsThenWaitsForCompleteRenderPackEvidenceWindow()
|
||||
{
|
||||
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
||||
var runtime = new FakeRuntime
|
||||
{
|
||||
RenderPackPerformanceSampleCount = 1200,
|
||||
};
|
||||
var probe = new RetailUiAutomationProbe(root, objects);
|
||||
string path = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
Path.GetRandomFileName() + ".ui-probe.txt");
|
||||
File.WriteAllLines(path,
|
||||
[
|
||||
"renderpack reset-performance",
|
||||
"wait render-pack-samples 2048 1000",
|
||||
"screenshot complete-window 1000",
|
||||
]);
|
||||
|
||||
try
|
||||
{
|
||||
using var runner = new RetailUiAutomationScriptRunner(
|
||||
probe,
|
||||
path,
|
||||
dumpOnStart: false,
|
||||
runtime: runtime);
|
||||
|
||||
runner.Tick(0d);
|
||||
Assert.Equal(1, runtime.RenderPackPerformanceResetCount);
|
||||
Assert.Equal(0, runtime.RenderPackPerformanceSampleCount);
|
||||
Assert.Empty(runtime.ScreenshotRequests);
|
||||
|
||||
runtime.RenderPackPerformanceSampleCount = 2047;
|
||||
runner.Tick(0.5d);
|
||||
Assert.Empty(runtime.ScreenshotRequests);
|
||||
|
||||
runtime.RenderPackPerformanceSampleCount = 2048;
|
||||
runner.Tick(0.001d);
|
||||
Assert.Contains("complete-window", runtime.ScreenshotRequests);
|
||||
Assert.False(runner.Completed);
|
||||
|
||||
runtime.CompletedScreenshots.Add("complete-window");
|
||||
runner.Tick(0.001d);
|
||||
Assert.True(runner.Completed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptRunner_renderPackWaitTerminatesEarlyForFailedToRetailFallback()
|
||||
{
|
||||
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
||||
var runtime = new FakeRuntime
|
||||
{
|
||||
RenderPackFailedToRetail = true,
|
||||
};
|
||||
var probe = new RetailUiAutomationProbe(root, objects);
|
||||
string path = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
Path.GetRandomFileName() + ".ui-probe.txt");
|
||||
File.WriteAllLines(path,
|
||||
[
|
||||
"renderpack reset-performance",
|
||||
"wait render-pack-samples 2048 300000",
|
||||
"screenshot safe-fallback 1000",
|
||||
]);
|
||||
|
||||
try
|
||||
{
|
||||
using var runner = new RetailUiAutomationScriptRunner(
|
||||
probe,
|
||||
path,
|
||||
dumpOnStart: false,
|
||||
runtime: runtime);
|
||||
|
||||
runner.Tick(0d);
|
||||
|
||||
Assert.Equal(1, runtime.RenderPackPerformanceResetCount);
|
||||
Assert.Equal(0, runtime.RenderPackPerformanceSampleCount);
|
||||
Assert.Contains("safe-fallback", runtime.ScreenshotRequests);
|
||||
Assert.False(runner.Completed);
|
||||
|
||||
runtime.CompletedScreenshots.Add("safe-fallback");
|
||||
runner.Tick(0.001d);
|
||||
Assert.True(runner.Completed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptRunner_renderPackTransitionsAndResizeWaitForPublishedRuntimeState()
|
||||
{
|
||||
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
||||
var runtime = new FakeRuntime();
|
||||
var probe = new RetailUiAutomationProbe(root, objects);
|
||||
string path = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
Path.GetRandomFileName() + ".ui-probe.txt");
|
||||
File.WriteAllLines(path,
|
||||
[
|
||||
"renderpack select high",
|
||||
"wait render-pack high 1000",
|
||||
"renderpack disable",
|
||||
"wait render-pack retail 1000",
|
||||
"renderpack reenable",
|
||||
"wait render-pack high 1000",
|
||||
"resize 1024 768",
|
||||
"wait framebuffer 1024 768 1000",
|
||||
]);
|
||||
|
||||
try
|
||||
{
|
||||
using var runner = new RetailUiAutomationScriptRunner(
|
||||
probe,
|
||||
path,
|
||||
dumpOnStart: false,
|
||||
runtime: runtime);
|
||||
|
||||
runner.Tick(0d);
|
||||
Assert.Equal(["high"], runtime.RenderPackSelections);
|
||||
Assert.False(runner.Completed);
|
||||
|
||||
runtime.RenderPackStatus = new RetailUiAutomationRenderPackStatus(
|
||||
RetailUiAutomationRenderPackState.Active,
|
||||
"acdream.atmospheric",
|
||||
"high",
|
||||
ActivationGeneration: 1,
|
||||
FailureReason: null);
|
||||
runner.Tick(0.001d);
|
||||
Assert.Equal(1, runtime.RenderPackDisableCount);
|
||||
|
||||
runtime.RenderPackStatus = RetailUiAutomationRenderPackStatus.Retail;
|
||||
runner.Tick(0.001d);
|
||||
Assert.Equal(1, runtime.RenderPackReenableCount);
|
||||
|
||||
runtime.RenderPackStatus = new RetailUiAutomationRenderPackStatus(
|
||||
RetailUiAutomationRenderPackState.Active,
|
||||
"acdream.atmospheric",
|
||||
"high",
|
||||
ActivationGeneration: 3,
|
||||
FailureReason: null);
|
||||
runner.Tick(0.001d);
|
||||
Assert.Equal([(1024, 768)], runtime.FramebufferResizes);
|
||||
Assert.False(runner.Completed);
|
||||
|
||||
runtime.FramebufferWidth = 1024;
|
||||
runtime.FramebufferHeight = 768;
|
||||
runner.Tick(0.001d);
|
||||
Assert.True(runner.Completed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptRunner_closeClientRequestsNormalRuntimeShutdownExactlyOnce()
|
||||
{
|
||||
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
||||
var runtime = new FakeRuntime();
|
||||
var probe = new RetailUiAutomationProbe(root, objects);
|
||||
string path = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
Path.GetRandomFileName() + ".ui-probe.txt");
|
||||
File.WriteAllText(path, "close-client");
|
||||
|
||||
try
|
||||
{
|
||||
using var runner = new RetailUiAutomationScriptRunner(
|
||||
probe,
|
||||
path,
|
||||
dumpOnStart: false,
|
||||
runtime: runtime);
|
||||
|
||||
runner.Tick(0d);
|
||||
runner.Tick(0d);
|
||||
|
||||
Assert.True(runner.Completed);
|
||||
Assert.Equal(1, runtime.ClientCloseRequestCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RetailUiAutomationCheckpointStatus.Failed, "deferred write failed")]
|
||||
[InlineData(RetailUiAutomationCheckpointStatus.Cancelled, "shutdown cancelled")]
|
||||
|
|
|
|||
|
|
@ -77,6 +77,29 @@ public sealed class UiTemplateListBoxViewportTests
|
|||
Assert.True(row2.Visible, "row 2 culled — the #372 blank-tab bug");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveTail_PreservesPrefixAndRestacksFutureRows()
|
||||
{
|
||||
var box = MakeListBox(276f, 560f);
|
||||
var prefix = new UiText { Width = 260f, Height = 20f };
|
||||
var removedA = new UiText { Width = 260f, Height = 30f };
|
||||
var removedB = new UiText { Width = 260f, Height = 40f };
|
||||
box.AddPrebuiltRow(prefix);
|
||||
box.AddPrebuiltRow(removedA);
|
||||
box.AddPrebuiltRow(removedB);
|
||||
|
||||
box.RemoveTail(1);
|
||||
var replacement = new UiText { Width = 260f, Height = 25f };
|
||||
box.AddPrebuiltRow(replacement);
|
||||
|
||||
Assert.Equal(2, box.ItemCount);
|
||||
Assert.Same(prefix, box.ViewportForTest!.Children[0]);
|
||||
Assert.Same(replacement, box.ViewportForTest.Children[1]);
|
||||
Assert.Equal(20f, replacement.Top);
|
||||
Assert.Null(removedA.Parent);
|
||||
Assert.Null(removedB.Parent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #412-class regression (2026-08-16, overnight hover/UI round, Batch A bug
|
||||
/// 2): the Options panel's Config tab escaped past the window frame — the
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
|
||||
namespace AcDream.Core.Tests.Fixtures.HelloPlugin;
|
||||
|
||||
public sealed class HelloPlugin : IAcDreamPlugin
|
||||
public sealed class HelloPlugin : IAcDreamPlugin, IRenderPackPlugin, IRenderPackAssets
|
||||
{
|
||||
public int InitializeCount { get; private set; }
|
||||
public int EnableCount { get; private set; }
|
||||
|
|
@ -17,4 +18,39 @@ public sealed class HelloPlugin : IAcDreamPlugin
|
|||
|
||||
public void Enable() => EnableCount++;
|
||||
public void Disable() => DisableCount++;
|
||||
|
||||
public void Register(IRenderPackRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
_ = registry.Register(
|
||||
new RenderPackDescriptor(
|
||||
Id: "acdream.test.noop-pack",
|
||||
DisplayName: "Test no-op pack",
|
||||
PackVersion: new Version(1, 0, 0),
|
||||
PackApiVersion: RenderPackApi.Current,
|
||||
HighestTier: RenderPackTier.Tier1,
|
||||
RequiredCapabilities: [],
|
||||
OptionalCapabilities: [],
|
||||
Resources: [],
|
||||
Passes: [],
|
||||
SceneReplays: [],
|
||||
PipelineVariants: [],
|
||||
QualityPresets: [],
|
||||
Settings: [],
|
||||
AtmospherePolicy: null)
|
||||
{
|
||||
FeatureSummary = "Test-only no-op render-pack fixture.",
|
||||
},
|
||||
this);
|
||||
string? directory = Path.GetDirectoryName(typeof(HelloPlugin).Assembly.Location);
|
||||
if (directory is not null
|
||||
&& File.Exists(Path.Combine(directory, "throw-after-render-register")))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"fixture render-pack registration failed after publishing a descriptor");
|
||||
}
|
||||
}
|
||||
|
||||
public Stream OpenRead(string assetKey) =>
|
||||
new MemoryStream([], writable: false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using AcDream.Core.Plugins;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
|
||||
namespace AcDream.Core.Tests.Plugins;
|
||||
|
||||
|
|
@ -69,6 +70,36 @@ public class PluginLoaderTests
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingRenderPackRegistry : IRenderPackRegistry, IDisposable
|
||||
{
|
||||
private readonly List<Registration> _registrations = [];
|
||||
|
||||
public int ActiveCount => _registrations.Count(static item => item.Active);
|
||||
public RenderPackDescriptor? Descriptor { get; private set; }
|
||||
|
||||
public IDisposable Register(
|
||||
RenderPackDescriptor descriptor,
|
||||
IRenderPackAssets assets)
|
||||
{
|
||||
Descriptor = descriptor;
|
||||
var registration = new Registration();
|
||||
_registrations.Add(registration);
|
||||
return registration;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (Registration registration in _registrations)
|
||||
registration.Dispose();
|
||||
}
|
||||
|
||||
private sealed class Registration : IDisposable
|
||||
{
|
||||
public bool Active { get; private set; } = true;
|
||||
public void Dispose() => Active = false;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_FixtureDll_InstantiatesPluginAndCallsInitialize()
|
||||
{
|
||||
|
|
@ -89,13 +120,42 @@ public class PluginLoaderTests
|
|||
manifest: manifest,
|
||||
host: host);
|
||||
|
||||
Assert.True(loaded.Success);
|
||||
Assert.True(loaded.Success, loaded.Error?.ToString());
|
||||
Assert.NotNull(loaded.Plugin);
|
||||
Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name);
|
||||
loaded.Plugin.Disable();
|
||||
loaded.LoadContext!.Unload();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_RenderPackOnlyFixture_RegistersWithoutGameplayEntrypointRequirement()
|
||||
{
|
||||
string dllPath = FixturePluginPath();
|
||||
var host = new StubHost();
|
||||
using var registry = new RecordingRenderPackRegistry();
|
||||
var manifest = new PluginManifest(
|
||||
Id: "acdream.test.render-pack",
|
||||
DisplayName: "Render pack",
|
||||
Version: "1.0.0",
|
||||
EntryDll: Path.GetFileName(dllPath),
|
||||
ApiVersion: 1,
|
||||
Dependencies: [],
|
||||
Kinds: [PluginKind.RenderPack]);
|
||||
|
||||
LoadedPlugin loaded = PluginLoader.Load(
|
||||
Path.GetDirectoryName(dllPath)!,
|
||||
manifest,
|
||||
host,
|
||||
registry);
|
||||
|
||||
Assert.True(loaded.Success, loaded.Error?.ToString());
|
||||
Assert.Null(loaded.Plugin);
|
||||
Assert.NotNull(loaded.RenderPackPlugin);
|
||||
Assert.Equal(1, registry.ActiveCount);
|
||||
Assert.Equal("acdream.test.noop-pack", registry.Descriptor?.Id);
|
||||
loaded.LoadContext!.Unload();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_UnsupportedApiVersion_IsRefusedBeforeAnyCodeLoads()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ public class PluginManifestTests
|
|||
Assert.Equal("0.1.0", manifest.Version);
|
||||
Assert.Equal("AcDream.Plugins.Smoke.dll", manifest.EntryDll);
|
||||
Assert.Equal(1, manifest.ApiVersion);
|
||||
Assert.Equal([PluginKind.Gameplay], manifest.Kinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -59,4 +60,49 @@ public class PluginManifestTests
|
|||
var manifest = PluginManifest.Parse(json);
|
||||
Assert.Empty(manifest.Dependencies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_RenderPackAndHybridKinds_AreExplicitAndDeduplicated()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"id": "x",
|
||||
"displayName": "X",
|
||||
"version": "1.0.0",
|
||||
"entryDll": "x.dll",
|
||||
"apiVersion": 1,
|
||||
"kinds": ["renderPack", "gameplay", "RENDERPACK"]
|
||||
}
|
||||
""";
|
||||
|
||||
PluginManifest manifest = PluginManifest.Parse(json);
|
||||
|
||||
Assert.Equal(
|
||||
[PluginKind.RenderPack, PluginKind.Gameplay],
|
||||
manifest.Kinds);
|
||||
Assert.True(manifest.Declares(PluginKind.RenderPack));
|
||||
Assert.True(manifest.Declares(PluginKind.Gameplay));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[]", "kinds must contain at least one entry")]
|
||||
[InlineData("[\"nativeCode\"]", "unknown plugin kind: nativeCode")]
|
||||
public void Parse_InvalidKinds_Throws(string kindsJson, string expected)
|
||||
{
|
||||
string json = $$"""
|
||||
{
|
||||
"id": "x",
|
||||
"displayName": "X",
|
||||
"version": "1.0.0",
|
||||
"entryDll": "x.dll",
|
||||
"apiVersion": 1,
|
||||
"kinds": {{kindsJson}}
|
||||
}
|
||||
""";
|
||||
|
||||
PluginManifestException error = Assert.Throws<PluginManifestException>(
|
||||
() => PluginManifest.Parse(json));
|
||||
|
||||
Assert.Equal(expected, error.Message);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System.Text.Json;
|
|||
using AcDream.Core.Plugins;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
|
||||
namespace AcDream.Core.Tests.Plugins;
|
||||
|
||||
|
|
@ -73,11 +74,118 @@ public sealed class PluginSessionTests
|
|||
Assert.Empty(statuses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphicalKindSet_RegistersRenderPackAndWithdrawsBeforeUnload()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
InstallFixture(
|
||||
temporary.Path,
|
||||
"render",
|
||||
"acdream.test.render",
|
||||
[PluginKind.RenderPack]);
|
||||
var statuses = new List<PluginSessionStatus>();
|
||||
var registry = new RecordingRenderPackRegistry();
|
||||
var plugins = new PluginSession(
|
||||
new StubHost(),
|
||||
statuses.Add,
|
||||
registry,
|
||||
[PluginKind.Gameplay, PluginKind.RenderPack]);
|
||||
|
||||
plugins.Start([temporary.Path], allowList: null);
|
||||
|
||||
Assert.Equal(1, plugins.LoadedCount);
|
||||
Assert.Equal(1, registry.ActiveCount);
|
||||
Assert.Equal("acdream.test.noop-pack", registry.Descriptor?.Id);
|
||||
IReadOnlyList<WeakReference> contexts =
|
||||
plugins.CaptureLoadContextWeakReferences();
|
||||
plugins.Dispose();
|
||||
Assert.Equal(0, registry.ActiveCount);
|
||||
Collect(contexts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameplayOnlyHost_SkipsUnrequestedRenderPackBeforeDllProbe()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
InstallBroken(
|
||||
temporary.Path,
|
||||
"render",
|
||||
"acdream.test.render",
|
||||
[PluginKind.RenderPack]);
|
||||
var statuses = new List<PluginSessionStatus>();
|
||||
using var plugins = new PluginSession(new StubHost(), statuses.Add);
|
||||
|
||||
plugins.Start([temporary.Path], allowList: null);
|
||||
|
||||
Assert.Equal(0, plugins.LoadedCount);
|
||||
Assert.Empty(statuses);
|
||||
Assert.Empty(plugins.CaptureLoadContextWeakReferences());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderPackRegisterFailure_WithdrawsPartialRegistrationBeforeUnload()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
InstallFixture(
|
||||
temporary.Path,
|
||||
"render",
|
||||
"acdream.test.render",
|
||||
[PluginKind.RenderPack]);
|
||||
File.WriteAllText(
|
||||
Path.Combine(temporary.Path, "render", "throw-after-render-register"),
|
||||
string.Empty);
|
||||
var registry = new RecordingRenderPackRegistry();
|
||||
var statuses = new List<PluginSessionStatus>();
|
||||
var plugins = new PluginSession(
|
||||
new StubHost(),
|
||||
statuses.Add,
|
||||
registry,
|
||||
[PluginKind.Gameplay, PluginKind.RenderPack]);
|
||||
|
||||
plugins.Start([temporary.Path], allowList: null);
|
||||
|
||||
Assert.Equal(0, plugins.LoadedCount);
|
||||
Assert.Equal(0, registry.ActiveCount);
|
||||
PluginSessionStatus status = Assert.Single(statuses);
|
||||
Assert.Equal(PluginSessionStatusKind.Failed, status.Kind);
|
||||
Assert.Contains("failed after publishing", status.Error);
|
||||
IReadOnlyList<WeakReference> contexts =
|
||||
plugins.CaptureLoadContextWeakReferences();
|
||||
plugins.Dispose();
|
||||
Collect(contexts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameplayOnlyHost_ExplicitRenderPackReportsKindWithoutDllProbe()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
InstallBroken(
|
||||
temporary.Path,
|
||||
"render",
|
||||
"acdream.test.render",
|
||||
[PluginKind.RenderPack]);
|
||||
var statuses = new List<PluginSessionStatus>();
|
||||
using var plugins = new PluginSession(new StubHost(), statuses.Add);
|
||||
|
||||
plugins.Start([temporary.Path], ["acdream.test.render"]);
|
||||
|
||||
PluginSessionStatus status = Assert.Single(statuses);
|
||||
Assert.Equal(PluginSessionStatusKind.Failed, status.Kind);
|
||||
Assert.Contains("does not support", status.Error);
|
||||
Assert.DoesNotContain("entry dll", status.Error);
|
||||
Assert.Empty(plugins.CaptureLoadContextWeakReferences());
|
||||
}
|
||||
|
||||
private static void ReleaseAndCollect(PluginSession plugins)
|
||||
{
|
||||
IReadOnlyList<WeakReference> contexts =
|
||||
plugins.CaptureLoadContextWeakReferences();
|
||||
plugins.Dispose();
|
||||
Collect(contexts);
|
||||
}
|
||||
|
||||
private static void Collect(IReadOnlyList<WeakReference> contexts)
|
||||
{
|
||||
for (int attempt = 0;
|
||||
attempt < 10 && contexts.Any(static context => context.IsAlive);
|
||||
attempt++)
|
||||
|
|
@ -90,6 +198,13 @@ public sealed class PluginSessionTests
|
|||
}
|
||||
|
||||
private static void InstallFixture(string root, string folder, string id)
|
||||
=> InstallFixture(root, folder, id, kinds: null);
|
||||
|
||||
private static void InstallFixture(
|
||||
string root,
|
||||
string folder,
|
||||
string id,
|
||||
IReadOnlyList<PluginKind>? kinds)
|
||||
{
|
||||
string source = FixturePluginPath();
|
||||
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
|
||||
|
|
@ -97,20 +212,25 @@ public sealed class PluginSessionTests
|
|||
Directory.CreateDirectory(pluginDirectory);
|
||||
string fileName = Path.GetFileName(source);
|
||||
File.Copy(source, Path.Combine(pluginDirectory, fileName));
|
||||
WriteManifest(pluginDirectory, id, fileName);
|
||||
WriteManifest(pluginDirectory, id, fileName, kinds);
|
||||
}
|
||||
|
||||
private static void InstallBroken(string root, string folder, string id)
|
||||
private static void InstallBroken(
|
||||
string root,
|
||||
string folder,
|
||||
string id,
|
||||
IReadOnlyList<PluginKind>? kinds = null)
|
||||
{
|
||||
string pluginDirectory = Path.Combine(root, folder);
|
||||
Directory.CreateDirectory(pluginDirectory);
|
||||
WriteManifest(pluginDirectory, id, "missing.dll");
|
||||
WriteManifest(pluginDirectory, id, "missing.dll", kinds);
|
||||
}
|
||||
|
||||
private static void WriteManifest(
|
||||
string directory,
|
||||
string id,
|
||||
string entryDll) =>
|
||||
string entryDll,
|
||||
IReadOnlyList<PluginKind>? kinds = null) =>
|
||||
File.WriteAllText(
|
||||
Path.Combine(directory, "plugin.json"),
|
||||
JsonSerializer.Serialize(new
|
||||
|
|
@ -120,8 +240,46 @@ public sealed class PluginSessionTests
|
|||
version = "1.0.0",
|
||||
entryDll,
|
||||
apiVersion = 1,
|
||||
kinds = kinds?.Select(static kind => kind.ToString()),
|
||||
}));
|
||||
|
||||
private sealed class RecordingRenderPackRegistry : IRenderPackRegistry
|
||||
{
|
||||
private readonly List<Registration> _registrations = [];
|
||||
|
||||
internal int ActiveCount => _registrations.Count;
|
||||
internal RenderPackDescriptor? Descriptor { get; private set; }
|
||||
|
||||
public IDisposable Register(
|
||||
RenderPackDescriptor descriptor,
|
||||
IRenderPackAssets assets)
|
||||
{
|
||||
Descriptor = descriptor;
|
||||
var registration = new Registration(this, assets);
|
||||
_registrations.Add(registration);
|
||||
return registration;
|
||||
}
|
||||
|
||||
private void Remove(Registration registration) =>
|
||||
_registrations.Remove(registration);
|
||||
|
||||
private sealed class Registration(
|
||||
RecordingRenderPackRegistry owner,
|
||||
IRenderPackAssets assets) : IDisposable
|
||||
{
|
||||
private RecordingRenderPackRegistry? _owner = owner;
|
||||
private IRenderPackAssets? _assets = assets;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
RecordingRenderPackRegistry? current =
|
||||
Interlocked.Exchange(ref _owner, null);
|
||||
_assets = null;
|
||||
current?.Remove(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string FixturePluginPath()
|
||||
{
|
||||
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
|
|
|
|||
|
|
@ -149,4 +149,26 @@ public sealed class TranslucencyFadeManagerTests
|
|||
Assert.Equal(1f, part0); // part 0's 1s ramp is done
|
||||
Assert.Equal(0.5f, part1, 5); // part 1's 2s ramp is halfway
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Revision_AdvancesOnlyWhenCommittedCasterOpacityChanges()
|
||||
{
|
||||
var mgr = new TranslucencyFadeManager();
|
||||
ulong initial = mgr.Revision;
|
||||
|
||||
mgr.StartPartFade(1, 0, start: 0f, end: 1f, time: 1f);
|
||||
ulong started = mgr.Revision;
|
||||
Assert.True(started > initial);
|
||||
|
||||
mgr.AdvanceAll(0f);
|
||||
Assert.Equal(started, mgr.Revision);
|
||||
mgr.AdvanceAll(0.5f);
|
||||
Assert.True(mgr.Revision > started);
|
||||
|
||||
ulong advanced = mgr.Revision;
|
||||
mgr.ClearEntity(999);
|
||||
Assert.Equal(advanced, mgr.Revision);
|
||||
mgr.ClearEntity(1);
|
||||
Assert.True(mgr.Revision > advanced);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,112 @@ public sealed class WbDrawDispatcherIndirectBuilderTests
|
|||
Assert.Equal(0, result.TransparentCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryBuiltMeshMaterialSubsetIsDetailEligible()
|
||||
{
|
||||
TranslucencyKind[] kinds =
|
||||
[
|
||||
TranslucencyKind.Opaque,
|
||||
TranslucencyKind.ClipMap,
|
||||
TranslucencyKind.AlphaBlend,
|
||||
TranslucencyKind.Additive,
|
||||
TranslucencyKind.InvAlpha,
|
||||
];
|
||||
var groups = kinds.Select((kind, index) => new WbDrawDispatcher.IndirectGroupInput(
|
||||
IndexCount: 3,
|
||||
FirstIndex: (uint)(index * 3),
|
||||
BaseVertex: 0,
|
||||
InstanceCount: 1,
|
||||
FirstInstance: index,
|
||||
TextureIndex: (uint)index,
|
||||
TextureLayer: 0,
|
||||
Translucency: kind)).ToList();
|
||||
var indirect = new DrawElementsIndirectCommand[kinds.Length];
|
||||
var batches = new WbDrawDispatcher.BatchDataPublic[kinds.Length];
|
||||
|
||||
WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batches);
|
||||
|
||||
Assert.All(batches, batch => Assert.Equal(1u, batch.Flags));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetailCategoryPredicateCoversEveryInstanceInAnIndirectCommand()
|
||||
{
|
||||
var command = new DrawElementsIndirectCommand
|
||||
{
|
||||
BaseInstance = 2,
|
||||
InstanceCount = 3,
|
||||
};
|
||||
|
||||
Assert.True(WbDrawDispatcher.CommandContainsDetailCategory(
|
||||
command,
|
||||
[0u, 0u, 0u, 1u, 0u]));
|
||||
Assert.False(WbDrawDispatcher.CommandContainsDetailCategory(
|
||||
command,
|
||||
[1u, 1u, 0u, 0u, 0u]));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
WbDrawDispatcher.CommandContainsDetailCategory(
|
||||
command,
|
||||
[0u, 0u, 0u, 1u]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpaqueDetailRunsSkipNonbuildingCommandsAndKeepMixedCommands()
|
||||
{
|
||||
DrawElementsIndirectCommand[] commands =
|
||||
[
|
||||
new() { BaseInstance = 0, InstanceCount = 2 },
|
||||
new() { BaseInstance = 2, InstanceCount = 2 },
|
||||
new() { BaseInstance = 4, InstanceCount = 1 },
|
||||
];
|
||||
|
||||
// Command 1 is mixed: instance 2 is ordinary and instance 3 is a
|
||||
// building. It must be submitted once, then mesh_detail filters the
|
||||
// ordinary instance. Commands 0 and 2 must never reach the detail pipe.
|
||||
uint[] mixedCategories = [0u, 0u, 0u, 1u, 0u];
|
||||
Assert.True(WbDrawDispatcher.TryGetNextDetailCommandRun(
|
||||
commands,
|
||||
mixedCategories,
|
||||
searchStart: 0,
|
||||
exclusiveEnd: commands.Length,
|
||||
out WbDrawDispatcher.DetailCommandRun mixedRun));
|
||||
Assert.Equal(new WbDrawDispatcher.DetailCommandRun(1, 1), mixedRun);
|
||||
Assert.False(WbDrawDispatcher.TryGetNextDetailCommandRun(
|
||||
commands,
|
||||
mixedCategories,
|
||||
searchStart: mixedRun.FirstCommand + mixedRun.CommandCount,
|
||||
exclusiveEnd: commands.Length,
|
||||
out _));
|
||||
|
||||
Assert.False(WbDrawDispatcher.TryGetNextDetailCommandRun(
|
||||
commands,
|
||||
new uint[5],
|
||||
searchStart: 0,
|
||||
exclusiveEnd: commands.Length,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpaqueDetailRunsCoalesceConsecutiveEligibleCommands()
|
||||
{
|
||||
DrawElementsIndirectCommand[] commands =
|
||||
[
|
||||
new() { BaseInstance = 0, InstanceCount = 1 },
|
||||
new() { BaseInstance = 1, InstanceCount = 1 },
|
||||
new() { BaseInstance = 2, InstanceCount = 1 },
|
||||
new() { BaseInstance = 3, InstanceCount = 1 },
|
||||
];
|
||||
uint[] categories = [0u, 1u, 1u, 0u];
|
||||
|
||||
Assert.True(WbDrawDispatcher.TryGetNextDetailCommandRun(
|
||||
commands,
|
||||
categories,
|
||||
searchStart: 0,
|
||||
exclusiveEnd: commands.Length,
|
||||
out WbDrawDispatcher.DetailCommandRun run));
|
||||
Assert.Equal(new WbDrawDispatcher.DetailCommandRun(1, 2), run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchDataPublic_LayoutMatchesPrivateBatchData()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -226,4 +226,148 @@ public class LandblockMeshTests
|
|||
Assert.Equal(10.0f, atX48Y0.Position.Z);
|
||||
Assert.Equal(0.0f, atX0Y48.Position.Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NormalsMatchRetailIncidentFaceAverages_NotCentralDifferences()
|
||||
{
|
||||
// A deliberately non-planar surface makes retail's split-aware
|
||||
// incident-plane average observably different from the former
|
||||
// central-difference approximation.
|
||||
var block = BuildFlatLandBlock();
|
||||
for (int x = 0; x < LandblockMesh.HeightmapSide; x++)
|
||||
for (int y = 0; y < LandblockMesh.HeightmapSide; y++)
|
||||
block.Height[x * LandblockMesh.HeightmapSide + y] =
|
||||
(byte)((x * x * 3 + y * y * 5 + x * y * 11 + x * 7 + y * 13) % 96);
|
||||
|
||||
const uint landblockX = 0xA9;
|
||||
const uint landblockY = 0xB4;
|
||||
var mesh = LandblockMesh.Build(
|
||||
block,
|
||||
landblockX,
|
||||
landblockY,
|
||||
IdentityHeightTable,
|
||||
MakeContext(),
|
||||
new Dictionary<uint, SurfaceInfo>());
|
||||
|
||||
// Independent geometry oracle: derive each polygon plane from the
|
||||
// actual emitted positions/indices, accumulate it at the shared
|
||||
// position, and normalize only after every incident polygon is seen.
|
||||
var incidentNormalSums = new Dictionary<Vector3, Vector3>();
|
||||
for (int i = 0; i < mesh.Indices.Length; i += 3)
|
||||
{
|
||||
Vector3 p0 = mesh.Vertices[mesh.Indices[i]].Position;
|
||||
Vector3 p1 = mesh.Vertices[mesh.Indices[i + 1]].Position;
|
||||
Vector3 p2 = mesh.Vertices[mesh.Indices[i + 2]].Position;
|
||||
Vector3 planeNormal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
|
||||
|
||||
AddNormal(incidentNormalSums, p0, planeNormal);
|
||||
AddNormal(incidentNormalSums, p1, planeNormal);
|
||||
AddNormal(incidentNormalSums, p2, planeNormal);
|
||||
}
|
||||
|
||||
foreach (TerrainVertex vertex in mesh.Vertices)
|
||||
{
|
||||
Vector3 expected = Vector3.Normalize(incidentNormalSums[vertex.Position]);
|
||||
AssertVectorNear(expected, vertex.Normal, 1e-6f);
|
||||
Assert.InRange(vertex.Normal.Length(), 1f - 1e-6f, 1f + 1e-6f);
|
||||
}
|
||||
|
||||
bool differsFromCentralDifferences = false;
|
||||
for (int x = 0; x < LandblockMesh.HeightmapSide; x++)
|
||||
{
|
||||
for (int y = 0; y < LandblockMesh.HeightmapSide; y++)
|
||||
{
|
||||
int xL = Math.Max(x - 1, 0);
|
||||
int xR = Math.Min(x + 1, LandblockMesh.HeightmapSide - 1);
|
||||
int yD = Math.Max(y - 1, 0);
|
||||
int yU = Math.Min(y + 1, LandblockMesh.HeightmapSide - 1);
|
||||
float dx = (HeightAt(block, xR, y) - HeightAt(block, xL, y)) /
|
||||
((xR - xL) * LandblockMesh.CellSize);
|
||||
float dy = (HeightAt(block, x, yU) - HeightAt(block, x, yD)) /
|
||||
((yU - yD) * LandblockMesh.CellSize);
|
||||
Vector3 oldApproximation = Vector3.Normalize(new Vector3(-dx, -dy, 1f));
|
||||
Vector3 position = new(
|
||||
x * LandblockMesh.CellSize,
|
||||
y * LandblockMesh.CellSize,
|
||||
HeightAt(block, x, y));
|
||||
Vector3 actual = mesh.Vertices.First(vertex => vertex.Position == position).Normal;
|
||||
differsFromCentralDifferences |= Vector3.Distance(oldApproximation, actual) > 1e-4f;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
differsFromCentralDifferences,
|
||||
"Synthetic terrain failed to distinguish retail incident-face averaging from central differences.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u, 0u)]
|
||||
[InlineData(0xA9u, 0xB4u)]
|
||||
public void Build_RetailNormalChange_PreservesExactSplitAwarePositionsAndIndices(
|
||||
uint landblockX,
|
||||
uint landblockY)
|
||||
{
|
||||
var block = BuildFlatLandBlock();
|
||||
for (int x = 0; x < LandblockMesh.HeightmapSide; x++)
|
||||
for (int y = 0; y < LandblockMesh.HeightmapSide; y++)
|
||||
block.Height[x * LandblockMesh.HeightmapSide + y] =
|
||||
(byte)((x * 17 + y * 29 + x * y * 3) % 80);
|
||||
|
||||
var mesh = LandblockMesh.Build(
|
||||
block,
|
||||
landblockX,
|
||||
landblockY,
|
||||
IdentityHeightTable,
|
||||
MakeContext(),
|
||||
new Dictionary<uint, SurfaceInfo>());
|
||||
|
||||
Assert.Equal(
|
||||
Enumerable.Range(0, LandblockMesh.VerticesPerLandblock).Select(i => (uint)i),
|
||||
mesh.Indices);
|
||||
|
||||
int vertexIndex = 0;
|
||||
for (int cy = 0; cy < LandblockMesh.CellsPerSide; cy++)
|
||||
{
|
||||
for (int cx = 0; cx < LandblockMesh.CellsPerSide; cx++)
|
||||
{
|
||||
Vector3 bl = PositionAt(block, cx, cy);
|
||||
Vector3 br = PositionAt(block, cx + 1, cy);
|
||||
Vector3 tr = PositionAt(block, cx + 1, cy + 1);
|
||||
Vector3 tl = PositionAt(block, cx, cy + 1);
|
||||
Vector3[] expected = TerrainBlending.CalculateSplitDirection(
|
||||
landblockX, (uint)cx, landblockY, (uint)cy) == CellSplitDirection.SWtoNE
|
||||
? [bl, br, tr, bl, tr, tl]
|
||||
: [bl, br, tl, br, tr, tl];
|
||||
|
||||
foreach (Vector3 position in expected)
|
||||
Assert.Equal(position, mesh.Vertices[vertexIndex++].Position);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(LandblockMesh.VerticesPerLandblock, vertexIndex);
|
||||
}
|
||||
|
||||
private static float HeightAt(LandBlock block, int x, int y) =>
|
||||
IdentityHeightTable[block.Height[x * LandblockMesh.HeightmapSide + y]];
|
||||
|
||||
private static Vector3 PositionAt(LandBlock block, int x, int y) => new(
|
||||
x * LandblockMesh.CellSize,
|
||||
y * LandblockMesh.CellSize,
|
||||
HeightAt(block, x, y));
|
||||
|
||||
private static void AddNormal(
|
||||
IDictionary<Vector3, Vector3> sums,
|
||||
Vector3 position,
|
||||
Vector3 normal)
|
||||
{
|
||||
sums.TryGetValue(position, out Vector3 sum);
|
||||
sums[position] = sum + normal;
|
||||
}
|
||||
|
||||
private static void AssertVectorNear(Vector3 expected, Vector3 actual, float epsilon)
|
||||
{
|
||||
Assert.InRange(actual.X, expected.X - epsilon, expected.X + epsilon);
|
||||
Assert.InRange(actual.Y, expected.Y - epsilon, expected.Y + epsilon);
|
||||
Assert.InRange(actual.Z, expected.Z - epsilon, expected.Z + epsilon);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Content;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Lib.IO;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -126,6 +128,55 @@ public sealed class SkyDescLoaderTests
|
|||
Assert.Equal(0x01004C44u, obj.GfxObjId);
|
||||
Assert.Equal(0x3300042Cu, obj.PesObjectId);
|
||||
Assert.True(obj.IsPostScene);
|
||||
Assert.Equal(Vector3.Zero, obj.AuthoredSortCenter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromRegion_WithDatSourceCarriesDefaultAndReplacementSortCenters()
|
||||
{
|
||||
const uint defaultId = 0x01001348u;
|
||||
const uint replacementId = 0x01001F6Au;
|
||||
Vector3 defaultCenter = new(1050f, 0f, 0f);
|
||||
Vector3 replacementCenter = new(2066.82f, 552.99f, 0f);
|
||||
Region region = MakeRegion(dirBright: 1f, rBgrOrder: 255);
|
||||
DayGroup group = region.SkyInfo!.DayGroups[0];
|
||||
group.SkyObjects.Add(new SkyObject
|
||||
{
|
||||
DefaultGfxObjectId = defaultId,
|
||||
});
|
||||
group.SkyTime[0].SkyObjReplace.Add(new SkyObjectReplace
|
||||
{
|
||||
ObjectIndex = 0,
|
||||
GfxObjId = replacementId,
|
||||
});
|
||||
var dats = new FakeDatObjectSource();
|
||||
dats.Add(defaultId, new GfxObj { SortCenter = defaultCenter });
|
||||
dats.Add(replacementId, new GfxObj { SortCenter = replacementCenter });
|
||||
|
||||
LoadedSkyDesc loaded = Assert.IsType<LoadedSkyDesc>(
|
||||
SkyDescLoader.LoadFromRegion(region, dats));
|
||||
|
||||
Assert.Equal(defaultCenter,
|
||||
Assert.Single(loaded.DayGroups[0].SkyObjects).AuthoredSortCenter);
|
||||
Assert.Equal(replacementCenter,
|
||||
Assert.Single(loaded.DayGroups[0].SkyTimes[0].Replaces).AuthoredSortCenter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromRegion_FailedOptionalSortCenterLookupCannotFailSkyLoading()
|
||||
{
|
||||
Region region = MakeRegion(dirBright: 1f, rBgrOrder: 255);
|
||||
region.SkyInfo!.DayGroups[0].SkyObjects.Add(new SkyObject
|
||||
{
|
||||
DefaultGfxObjectId = 0x01001348u,
|
||||
});
|
||||
|
||||
LoadedSkyDesc loaded = Assert.IsType<LoadedSkyDesc>(
|
||||
SkyDescLoader.LoadFromRegion(region, new ThrowingDatObjectSource()));
|
||||
|
||||
Assert.Equal(
|
||||
Vector3.Zero,
|
||||
Assert.Single(loaded.DayGroups[0].SkyObjects).AuthoredSortCenter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -201,4 +252,31 @@ public sealed class SkyDescLoaderTests
|
|||
// At begin → begin angle.
|
||||
Assert.Equal(0f, obj.CurrentAngle(0.25f), precision: 2);
|
||||
}
|
||||
|
||||
private sealed class FakeDatObjectSource : IDatObjectSource
|
||||
{
|
||||
private readonly Dictionary<uint, IDBObj> _objects = [];
|
||||
|
||||
internal void Add(uint id, IDBObj value) => _objects[id] = value;
|
||||
|
||||
public T Get<T>(uint fileId) where T : IDBObj =>
|
||||
_objects.TryGetValue(fileId, out IDBObj? value) && value is T typed
|
||||
? typed
|
||||
: default!;
|
||||
|
||||
public bool TryGet<T>(uint fileId, out T value) where T : IDBObj
|
||||
{
|
||||
value = Get<T>(fileId);
|
||||
return value is not null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingDatObjectSource : IDatObjectSource
|
||||
{
|
||||
public T Get<T>(uint fileId) where T : IDBObj =>
|
||||
throw new InvalidOperationException("synthetic DAT lookup failure");
|
||||
|
||||
public bool TryGet<T>(uint fileId, out T value) where T : IDBObj =>
|
||||
throw new InvalidOperationException("synthetic DAT lookup failure");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,45 @@ public sealed class HeadlessPluginSessionTests
|
|||
Assert.DoesNotContain("fixture-", output.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderPackOnlyRequest_IsRejectedBeforeHeadlessLoadsItsDll()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
const string renderPackId = "acdream.test.render-only";
|
||||
string pluginDirectory = Path.Combine(temporary.Path, "render-only");
|
||||
Directory.CreateDirectory(pluginDirectory);
|
||||
File.WriteAllText(
|
||||
Path.Combine(pluginDirectory, "plugin.json"),
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
id = renderPackId,
|
||||
displayName = "Render only",
|
||||
version = "1.0.0",
|
||||
entryDll = "deliberately-missing.dll",
|
||||
apiVersion = 1,
|
||||
kinds = new[] { "renderPack" },
|
||||
}));
|
||||
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||
var credential = new HeadlessCredentialSecret("fixture", "password");
|
||||
using var session = new HeadlessSessionHost(
|
||||
Descriptor([renderPackId], statusPath),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(new StringWriter()),
|
||||
new FixtureSessionOperations(),
|
||||
pluginRoots: [temporary.Path]);
|
||||
|
||||
_ = session.Start();
|
||||
|
||||
Assert.Equal(0, session.Plugins.LoadedCount);
|
||||
Assert.Empty(session.Plugins.CaptureLoadContextWeakReferences());
|
||||
JsonElement failed = ReadStatuses(statusPath)
|
||||
.Single(static item =>
|
||||
item.GetProperty("e").GetString() == "pluginFailed");
|
||||
string error = failed.GetProperty("error").GetString()!;
|
||||
Assert.Contains("does not support", error);
|
||||
Assert.DoesNotContain("entry dll", error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowAfterRegistrationRollsBackEventsAndCollectsContext()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -28,13 +28,25 @@ if (!string.IsNullOrWhiteSpace(selfUpdateData)
|
|||
|
||||
using var http = new HttpClient();
|
||||
var manager = new LauncherSelfUpdateManager(Paths(selfUpdateData), http);
|
||||
SelfUpdateStartupResult startup = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
effectiveArgs,
|
||||
manager,
|
||||
Path.GetFullPath(AppContext.BaseDirectory),
|
||||
Path.GetFullPath(
|
||||
Environment.ProcessPath
|
||||
?? throw new InvalidOperationException("Process path is unavailable.")));
|
||||
SelfUpdateStartupResult startup;
|
||||
try
|
||||
{
|
||||
startup = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
effectiveArgs,
|
||||
manager,
|
||||
Path.GetFullPath(AppContext.BaseDirectory),
|
||||
Path.GetFullPath(
|
||||
Environment.ProcessPath
|
||||
?? throw new InvalidOperationException("Process path is unavailable.")));
|
||||
}
|
||||
catch (LauncherUpdateException)
|
||||
{
|
||||
// Refusal is an expected result in the hostile-state process tests.
|
||||
// Translate it to a stable non-zero code instead of invoking the OS
|
||||
// unhandled-exception path, which can launch a crash reporter and make
|
||||
// a process-lifetime test wait on diagnostics rather than our result.
|
||||
return 74;
|
||||
}
|
||||
if (startup.ShouldExit)
|
||||
{
|
||||
return startup.ExitCode;
|
||||
|
|
@ -149,11 +161,19 @@ static async Task<int> BootstrapProbeAsync(string[] arguments)
|
|||
|
||||
using var http = new HttpClient();
|
||||
var manager = new LauncherSelfUpdateManager(Paths(arguments[0]), http);
|
||||
SelfUpdateStartupResult result = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
["ordinary"],
|
||||
manager,
|
||||
Path.GetFullPath(arguments[1]),
|
||||
Path.GetFullPath(arguments[2]));
|
||||
SelfUpdateStartupResult result;
|
||||
try
|
||||
{
|
||||
result = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
["ordinary"],
|
||||
manager,
|
||||
Path.GetFullPath(arguments[1]),
|
||||
Path.GetFullPath(arguments[2]));
|
||||
}
|
||||
catch (LauncherUpdateException)
|
||||
{
|
||||
return 74;
|
||||
}
|
||||
File.WriteAllText(
|
||||
Path.GetFullPath(arguments[3]),
|
||||
result.ShouldExit ? "exit" : string.Join("\n", result.RemainingArguments));
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
private const string TargetEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_TARGET";
|
||||
private const string HelperPidEnvironment =
|
||||
"ACDREAM_SELF_UPDATE_FIXTURE_HELPER_PID";
|
||||
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<
|
||||
Process,
|
||||
ProcessOutputCapture> ProcessOutput = new();
|
||||
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
|
|
@ -361,8 +364,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
], environment);
|
||||
await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
string helperError = await helper.StandardError.ReadToEndAsync();
|
||||
string helperOutput = await helper.StandardOutput.ReadToEndAsync();
|
||||
string helperError = await ReadStandardErrorAsync(helper);
|
||||
string helperOutput = await ReadStandardOutputAsync(helper);
|
||||
Assert.True(
|
||||
helper.ExitCode == LauncherSelfUpdateBootstrap.UpdateLeaseBusyExitCode,
|
||||
$"helper exit {helper.ExitCode}; stdout: {helperOutput}; stderr: {helperError}");
|
||||
|
|
@ -481,7 +484,7 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
{
|
||||
using Process process = StartProcess(canonicalPath, arguments, environment);
|
||||
await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
string stderr = await process.StandardError.ReadToEndAsync();
|
||||
string stderr = await ReadStandardErrorAsync(process);
|
||||
if (exactExit.HasValue)
|
||||
{
|
||||
Assert.True(
|
||||
|
|
@ -699,10 +702,26 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
return Process.Start(start)
|
||||
Process process = Process.Start(start)
|
||||
?? throw new InvalidOperationException($"Could not start '{executable}'.");
|
||||
ProcessOutput.Add(
|
||||
process,
|
||||
new ProcessOutputCapture(
|
||||
process.StandardOutput.ReadToEndAsync(),
|
||||
process.StandardError.ReadToEndAsync()));
|
||||
return process;
|
||||
}
|
||||
|
||||
private static Task<string> ReadStandardOutputAsync(Process process) =>
|
||||
ProcessOutput.TryGetValue(process, out ProcessOutputCapture? capture)
|
||||
? capture.StandardOutput
|
||||
: process.StandardOutput.ReadToEndAsync();
|
||||
|
||||
private static Task<string> ReadStandardErrorAsync(Process process) =>
|
||||
ProcessOutput.TryGetValue(process, out ProcessOutputCapture? capture)
|
||||
? capture.StandardError
|
||||
: process.StandardError.ReadToEndAsync();
|
||||
|
||||
private static async Task WaitForFileAsync(
|
||||
string path,
|
||||
Process? process,
|
||||
|
|
@ -726,9 +745,9 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{failure} Process exited {process.ExitCode}. stdout: "
|
||||
+ await process.StandardOutput.ReadToEndAsync()
|
||||
+ await ReadStandardOutputAsync(process)
|
||||
+ " stderr: "
|
||||
+ await process.StandardError.ReadToEndAsync());
|
||||
+ await ReadStandardErrorAsync(process));
|
||||
}
|
||||
|
||||
if (DateTimeOffset.UtcNow >= deadline)
|
||||
|
|
@ -766,6 +785,10 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
private static string GetFixtureDllPath() =>
|
||||
Path.Combine(GetFixtureDirectory(), FixtureBaseName + ".dll");
|
||||
|
||||
private sealed record ProcessOutputCapture(
|
||||
Task<string> StandardOutput,
|
||||
Task<string> StandardError);
|
||||
|
||||
private static string GetFixtureDirectory()
|
||||
{
|
||||
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
|
|
|
|||
|
|
@ -117,6 +117,60 @@ public sealed class ApplicationPathSetTests
|
|||
paths.LegacyConfigDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossPlatformEnvironmentOverridesIsolateAllMutableUserState()
|
||||
{
|
||||
string root = Path.GetFullPath(
|
||||
Path.Combine(Path.GetTempPath(), "acdream-isolated-automation"));
|
||||
var platform = new FixtureEnvironment(isWindows: true)
|
||||
{
|
||||
CurrentDirectoryValue = root,
|
||||
ApplicationData = Path.Combine(root, "real-roaming"),
|
||||
LocalApplicationData = Path.Combine(root, "real-local"),
|
||||
Variables =
|
||||
{
|
||||
["ACDREAM_CONFIG_DIR"] = "capture-config",
|
||||
["ACDREAM_DATA_DIR"] = "capture-data",
|
||||
["ACDREAM_CACHE_DIR"] = "capture-cache",
|
||||
},
|
||||
};
|
||||
|
||||
ApplicationPathSet paths = ApplicationPathSet.Resolve(platform: platform);
|
||||
|
||||
Assert.Equal(Path.Combine(root, "capture-config"), paths.ConfigDirectory);
|
||||
Assert.Equal(Path.Combine(root, "capture-data"), paths.DataDirectory);
|
||||
Assert.Equal(Path.Combine(root, "capture-cache"), paths.CacheDirectory);
|
||||
Assert.Null(paths.LegacyConfigDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitArgumentsOverrideAutomationEnvironmentRoots()
|
||||
{
|
||||
string root = Path.GetFullPath(
|
||||
Path.Combine(Path.GetTempPath(), "acdream-explicit-paths"));
|
||||
var platform = new FixtureEnvironment(isWindows: false)
|
||||
{
|
||||
CurrentDirectoryValue = root,
|
||||
UserProfile = Path.Combine(root, "home"),
|
||||
Variables =
|
||||
{
|
||||
["ACDREAM_CONFIG_DIR"] = "environment-config",
|
||||
["ACDREAM_DATA_DIR"] = "environment-data",
|
||||
["ACDREAM_CACHE_DIR"] = "environment-cache",
|
||||
},
|
||||
};
|
||||
|
||||
ApplicationPathSet paths = ApplicationPathSet.Resolve(
|
||||
"explicit-config",
|
||||
"explicit-data",
|
||||
"explicit-cache",
|
||||
platform);
|
||||
|
||||
Assert.Equal(Path.Combine(root, "explicit-config"), paths.ConfigDirectory);
|
||||
Assert.Equal(Path.Combine(root, "explicit-data"), paths.DataDirectory);
|
||||
Assert.Equal(Path.Combine(root, "explicit-cache"), paths.CacheDirectory);
|
||||
}
|
||||
|
||||
private sealed class FixtureEnvironment(bool isWindows)
|
||||
: IApplicationPathEnvironment
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
namespace AcDream.Plugin.Tests.Fixtures.HostPlugin;
|
||||
|
|
@ -9,7 +10,7 @@ namespace AcDream.Plugin.Tests.Fixtures.HostPlugin;
|
|||
/// gameplay events. A headless registry must make the UI call harmless without
|
||||
/// retaining this instance in the default load context.
|
||||
/// </summary>
|
||||
public sealed class HostPlugin : IAcDreamPlugin
|
||||
public sealed class HostPlugin : IAcDreamPlugin, IRenderPackPlugin, IRenderPackAssets
|
||||
{
|
||||
private IPluginHost? _host;
|
||||
private string? _assemblyDirectory;
|
||||
|
|
@ -67,6 +68,63 @@ public sealed class HostPlugin : IAcDreamPlugin
|
|||
_host = null;
|
||||
}
|
||||
|
||||
public void Register(IRenderPackRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
string directory = Path.GetDirectoryName(
|
||||
typeof(HostPlugin).Assembly.Location)!;
|
||||
if (File.Exists(Path.Combine(directory, "register-no-render-packs")))
|
||||
return;
|
||||
string versionPath = Path.Combine(directory, "render-pack-version.txt");
|
||||
Version version = File.Exists(versionPath)
|
||||
? Version.Parse(File.ReadAllText(versionPath).Trim())
|
||||
: new Version(1, 0, 0);
|
||||
_ = registry.Register(
|
||||
new RenderPackDescriptor(
|
||||
Id: "acdream.test.external-render-pack",
|
||||
DisplayName: "External graphical render-pack fixture",
|
||||
PackVersion: version,
|
||||
PackApiVersion: RenderPackApi.Current,
|
||||
HighestTier: RenderPackTier.Tier1,
|
||||
RequiredCapabilities: [],
|
||||
OptionalCapabilities: [],
|
||||
Resources: [],
|
||||
Passes: [],
|
||||
SceneReplays: [],
|
||||
PipelineVariants: [],
|
||||
QualityPresets:
|
||||
[
|
||||
Preset("low", "Low"),
|
||||
Preset("high", "High"),
|
||||
],
|
||||
Settings: [],
|
||||
AtmospherePolicy: null)
|
||||
{
|
||||
FeatureSummary = "Collectible external no-op render-pack fixture.",
|
||||
},
|
||||
this);
|
||||
if (File.Exists(Path.Combine(directory, "throw-after-render-pack-register")))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"fixture render-pack registration failed after publishing its descriptor");
|
||||
}
|
||||
}
|
||||
|
||||
public Stream OpenRead(string assetKey) =>
|
||||
new MemoryStream([], writable: false);
|
||||
|
||||
private static RenderQualityPreset Preset(string id, string displayName) => new(
|
||||
id,
|
||||
displayName,
|
||||
RequiredCapabilities: [],
|
||||
ResourceOverrides: [],
|
||||
SettingOverrides: [],
|
||||
MaxResidentGpuBytes: 0,
|
||||
MaxIncrementalGpuMillisecondsP50: 0,
|
||||
MaxIncrementalGpuMillisecondsP99: 0,
|
||||
MaxIncrementalCpuMillisecondsP50: 0,
|
||||
MaxIncrementalCpuMillisecondsP99: 0);
|
||||
|
||||
private void RegisterHostCallbacks(IPluginHost host)
|
||||
{
|
||||
host.Ui.AddMarkupPanel(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj">
|
||||
<Private>false</Private>
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
|
||||
namespace AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal;
|
||||
|
||||
internal sealed class InternalRenderPackPlugin : IRenderPackPlugin
|
||||
{
|
||||
public void Register(IRenderPackRegistry registry) { }
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"acdream.plugin.abstractions": {
|
||||
"type": "Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj">
|
||||
<Private>false</Private>
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
|
||||
namespace AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple;
|
||||
|
||||
public sealed class FirstRenderPackPlugin : IRenderPackPlugin
|
||||
{
|
||||
public void Register(IRenderPackRegistry registry) { }
|
||||
}
|
||||
|
||||
public sealed class SecondRenderPackPlugin : IRenderPackPlugin
|
||||
{
|
||||
public void Register(IRenderPackRegistry registry) { }
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"acdream.plugin.abstractions": {
|
||||
"type": "Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
251
tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs
Normal file
251
tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Plugins.MossTank.Tests;
|
||||
|
||||
public sealed class MossTankPanelTests
|
||||
{
|
||||
[Fact]
|
||||
public void RetainedLabelReads_UseUpdateSideSnapshotsWithoutAllocating()
|
||||
{
|
||||
var automation = new FakeAutomation
|
||||
{
|
||||
CurrentHealth = 90,
|
||||
MaxHealth = 100,
|
||||
CurrentStamina = 80,
|
||||
MaxStamina = 110,
|
||||
CurrentMana = 70,
|
||||
MaxMana = 120,
|
||||
Skills =
|
||||
[
|
||||
new PluginSkillInfo(1, "Life Magic", PluginSkillTraining.Trained, 300),
|
||||
new PluginSkillInfo(2, "War Magic", PluginSkillTraining.Specialized, 350),
|
||||
new PluginSkillInfo(3, "Run", PluginSkillTraining.Untrained, 100),
|
||||
],
|
||||
Attributes =
|
||||
[
|
||||
new PluginAttributeInfo(0, "Strength", 100),
|
||||
new PluginAttributeInfo(1, "Endurance", 100),
|
||||
],
|
||||
KnownSelfBuffs =
|
||||
[
|
||||
Spell(1, 10, "Increases the caster's Life Magic skill by 10 points."),
|
||||
],
|
||||
};
|
||||
var panel = new MossTankPanel(new FakeHost(automation));
|
||||
|
||||
panel.OnTick(0.0);
|
||||
|
||||
Assert.Equal("Health 90/100 Stam 80/110 Mana 70/120", panel.Vitals);
|
||||
Assert.Equal("2 attributes, 2 trained skills, 1 buff lines", panel.Coverage);
|
||||
string expectedVitals = panel.Vitals;
|
||||
string expectedCoverage = panel.Coverage;
|
||||
|
||||
_ = panel.Vitals;
|
||||
_ = panel.Coverage;
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
bool sameReferences = true;
|
||||
for (int i = 0; i < 10_000; i++)
|
||||
{
|
||||
sameReferences &= ReferenceEquals(expectedVitals, panel.Vitals);
|
||||
sameReferences &= ReferenceEquals(expectedCoverage, panel.Coverage);
|
||||
}
|
||||
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
|
||||
Assert.True(sameReferences);
|
||||
Assert.Equal(0, allocated);
|
||||
Assert.Equal(1, automation.KnownSelfBuffReads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateTick_RefreshesRareCoverageAndChangedVitals()
|
||||
{
|
||||
var automation = new FakeAutomation
|
||||
{
|
||||
CurrentHealth = 90,
|
||||
MaxHealth = 100,
|
||||
CurrentStamina = 80,
|
||||
MaxStamina = 110,
|
||||
CurrentMana = 70,
|
||||
MaxMana = 120,
|
||||
Skills =
|
||||
[
|
||||
new PluginSkillInfo(1, "Life Magic", PluginSkillTraining.Trained, 300),
|
||||
],
|
||||
Attributes = [new PluginAttributeInfo(0, "Strength", 100)],
|
||||
KnownSelfBuffs =
|
||||
[
|
||||
Spell(1, 10, "Increases the caster's Life Magic skill by 10 points."),
|
||||
],
|
||||
};
|
||||
var panel = new MossTankPanel(new FakeHost(automation));
|
||||
panel.OnTick(0.0);
|
||||
|
||||
automation.CurrentHealth = 75;
|
||||
automation.Skills =
|
||||
[
|
||||
new PluginSkillInfo(1, "Life Magic", PluginSkillTraining.Trained, 300),
|
||||
new PluginSkillInfo(2, "War Magic", PluginSkillTraining.Specialized, 350),
|
||||
];
|
||||
panel.OnTick(0.5);
|
||||
|
||||
Assert.StartsWith("Health 75/100", panel.Vitals, StringComparison.Ordinal);
|
||||
Assert.Equal("1 attributes, 1 trained skills, 1 buff lines", panel.Coverage);
|
||||
|
||||
panel.OnTick(0.5);
|
||||
Assert.Equal("1 attributes, 2 trained skills, 1 buff lines", panel.Coverage);
|
||||
|
||||
automation.KnownSelfBuffs =
|
||||
[
|
||||
Spell(1, 10, "Increases the caster's Life Magic skill by 10 points."),
|
||||
Spell(2, 11, "Increases the caster's War Magic skill by 10 points."),
|
||||
];
|
||||
panel.OnTick(0.0);
|
||||
|
||||
Assert.Equal("1 attributes, 2 trained skills, 2 buff lines", panel.Coverage);
|
||||
}
|
||||
|
||||
private static PluginSpellInfo Spell(uint id, uint family, string description) => new(
|
||||
id,
|
||||
$"Spell {id}",
|
||||
family,
|
||||
Tier: 1,
|
||||
Difficulty: 10,
|
||||
ManaCost: 5,
|
||||
DurationSeconds: 60f,
|
||||
School: 1,
|
||||
description,
|
||||
IsSelfTargeted: true,
|
||||
IsBeneficial: true);
|
||||
|
||||
private sealed class FakeHost(IAutomationSurface automation) : IPluginHost
|
||||
{
|
||||
public bool HasUi => false;
|
||||
public IPluginLogger Log { get; } = new FakeLogger();
|
||||
public IGameState State { get; } = new FakeState();
|
||||
public IEvents Events { get; } = new FakeEvents();
|
||||
public ISelectionService Selection { get; } = new FakeSelection();
|
||||
public IUiRegistry Ui => NoOpUiRegistry.Instance;
|
||||
public IAutomationSurface Automation { get; } = automation;
|
||||
}
|
||||
|
||||
private sealed class FakeAutomation
|
||||
: IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IPluginChat
|
||||
{
|
||||
private IReadOnlyList<PluginSpellInfo> _knownSelfBuffs = [];
|
||||
|
||||
public bool IsAvailable { get; set; } = true;
|
||||
public ICharacterInfo Character => this;
|
||||
public ISpellCatalog Spells => this;
|
||||
public IMagicCommands Magic => this;
|
||||
public IPluginChat Chat => this;
|
||||
public bool IsInWorld => IsAvailable;
|
||||
public uint ObjectId { get; set; } = 1;
|
||||
public uint CurrentHealth { get; set; }
|
||||
public uint MaxHealth { get; set; }
|
||||
public uint CurrentStamina { get; set; }
|
||||
public uint MaxStamina { get; set; }
|
||||
public uint CurrentMana { get; set; }
|
||||
public uint MaxMana { get; set; }
|
||||
public IReadOnlyList<PluginSkillInfo> Skills { get; set; } = [];
|
||||
public IReadOnlyList<PluginAttributeInfo> Attributes { get; set; } = [];
|
||||
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments { get; set; } = [];
|
||||
|
||||
public int KnownSelfBuffReads { get; private set; }
|
||||
|
||||
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs
|
||||
{
|
||||
get
|
||||
{
|
||||
KnownSelfBuffReads++;
|
||||
return _knownSelfBuffs;
|
||||
}
|
||||
set => _knownSelfBuffs = value;
|
||||
}
|
||||
|
||||
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
|
||||
{
|
||||
foreach (PluginSkillInfo candidate in Skills)
|
||||
{
|
||||
if (candidate.SkillId == skillId)
|
||||
{
|
||||
skill = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
skill = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGet(uint spellId, out PluginSpellInfo info)
|
||||
{
|
||||
foreach (PluginSpellInfo candidate in _knownSelfBuffs)
|
||||
{
|
||||
if (candidate.SpellId == spellId)
|
||||
{
|
||||
info = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
info = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool IsCasting => false;
|
||||
public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Ready;
|
||||
public bool Cast(uint spellId) => true;
|
||||
public void PostSystemMessage(string text) { }
|
||||
}
|
||||
|
||||
private sealed class FakeLogger : IPluginLogger
|
||||
{
|
||||
public void Info(string message) { }
|
||||
public void Warn(string message) { }
|
||||
public void Error(string message, Exception? exception = null) { }
|
||||
}
|
||||
|
||||
private sealed class FakeState : IGameState
|
||||
{
|
||||
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
|
||||
}
|
||||
|
||||
private sealed class FakeEvents : IEvents
|
||||
{
|
||||
public event Action<WorldEntitySnapshot> EntitySpawned
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
|
||||
public event Action<double> Tick
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSelection : ISelectionService
|
||||
{
|
||||
public uint? SelectedObjectId { get; private set; }
|
||||
public uint? PreviousObjectId { get; private set; }
|
||||
|
||||
public event Action<SelectionChangedEvent> Changed
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
|
||||
public bool Select(uint objectId)
|
||||
{
|
||||
PreviousObjectId = SelectedObjectId;
|
||||
SelectedObjectId = objectId;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Clear()
|
||||
{
|
||||
PreviousObjectId = SelectedObjectId;
|
||||
SelectedObjectId = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\tools\RenderPackValidator\AcDream.Tools.RenderPackValidator.csproj" />
|
||||
<ProjectReference Include="..\..\samples\AcDream.RenderPacks.NoOp\AcDream.RenderPacks.NoOp.csproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||
</ProjectReference>
|
||||
<ProjectReference Include="..\..\samples\AcDream.RenderPacks.AtmosphericTier2\AcDream.RenderPacks.AtmosphericTier2.csproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||
</ProjectReference>
|
||||
<ProjectReference Include="..\..\samples\AcDream.RenderPacks.ShadowsOnlyTier2\AcDream.RenderPacks.ShadowsOnlyTier2.csproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,107 @@
|
|||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.NET.Test.Sdk": {
|
||||
"type": "Direct",
|
||||
"requested": "[17.14.1, )",
|
||||
"resolved": "17.14.1",
|
||||
"contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.CodeCoverage": "17.14.1",
|
||||
"Microsoft.TestPlatform.TestHost": "17.14.1"
|
||||
}
|
||||
},
|
||||
"xunit": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.9.3, )",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
|
||||
"dependencies": {
|
||||
"xunit.analyzers": "1.18.0",
|
||||
"xunit.assert": "2.9.3",
|
||||
"xunit.core": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"xunit.runner.visualstudio": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.1.4, )",
|
||||
"resolved": "3.1.4",
|
||||
"contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg=="
|
||||
},
|
||||
"Microsoft.CodeCoverage": {
|
||||
"type": "Transitive",
|
||||
"resolved": "17.14.1",
|
||||
"contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg=="
|
||||
},
|
||||
"Microsoft.TestPlatform.ObjectModel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "17.14.1",
|
||||
"contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ=="
|
||||
},
|
||||
"Microsoft.TestPlatform.TestHost": {
|
||||
"type": "Transitive",
|
||||
"resolved": "17.14.1",
|
||||
"contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==",
|
||||
"dependencies": {
|
||||
"Microsoft.TestPlatform.ObjectModel": "17.14.1",
|
||||
"Newtonsoft.Json": "13.0.3"
|
||||
}
|
||||
},
|
||||
"Newtonsoft.Json": {
|
||||
"type": "Transitive",
|
||||
"resolved": "13.0.3",
|
||||
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
|
||||
},
|
||||
"xunit.abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.0.3",
|
||||
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
|
||||
},
|
||||
"xunit.analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.18.0",
|
||||
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
|
||||
},
|
||||
"xunit.assert": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
|
||||
},
|
||||
"xunit.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
|
||||
"dependencies": {
|
||||
"xunit.extensibility.core": "[2.9.3]",
|
||||
"xunit.extensibility.execution": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"xunit.extensibility.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
|
||||
"dependencies": {
|
||||
"xunit.abstractions": "2.0.3"
|
||||
}
|
||||
},
|
||||
"xunit.extensibility.execution": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
|
||||
"dependencies": {
|
||||
"xunit.extensibility.core": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"acdream.plugin.abstractions": {
|
||||
"type": "Project"
|
||||
},
|
||||
"acdream.tools.renderpackvalidator": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"AcDream.Plugin.Abstractions": "[1.0.0, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,8 @@ public sealed class DisplaySettingsTests
|
|||
Assert.Equal(90f, d.FieldOfView);
|
||||
Assert.Equal(1.0f, d.Gamma);
|
||||
Assert.False(d.ShowFps);
|
||||
Assert.Equal(RenderPackSelectionSettings.Retail, d.RenderPack);
|
||||
Assert.True(d.RenderPack.IsRetail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -68,6 +70,46 @@ public sealed class DisplaySettingsTests
|
|||
Assert.False(d.ShowFps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_pack_selection_uses_stable_logical_ids()
|
||||
{
|
||||
var selection = new RenderPackSelectionSettings(
|
||||
"acdream.atmospheric",
|
||||
"1.0.0",
|
||||
"medium");
|
||||
DisplaySettings changed = DisplaySettings.Default with
|
||||
{
|
||||
RenderPack = selection,
|
||||
};
|
||||
|
||||
Assert.Equal("acdream.atmospheric", changed.RenderPack.PackId);
|
||||
Assert.Equal("1.0.0", changed.RenderPack.PackVersion);
|
||||
Assert.Equal("medium", changed.RenderPack.PresetId);
|
||||
Assert.False(changed.RenderPack.IsRetail);
|
||||
Assert.Equal(RenderPackSelectionSettings.Retail, DisplaySettings.Default.RenderPack);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_pack_setting_overrides_are_value_equal_and_case_insensitive_by_id()
|
||||
{
|
||||
var first = new RenderPackSelectionSettings("pack", "1.0.0", "high")
|
||||
{
|
||||
SettingOverrides = new RenderPackSettingOverrides(
|
||||
new Dictionary<string, string> { ["Exposure"] = "1.25" }),
|
||||
};
|
||||
var second = new RenderPackSelectionSettings("pack", "1.0.0", "high")
|
||||
{
|
||||
SettingOverrides = new RenderPackSettingOverrides(
|
||||
new Dictionary<string, string> { ["exposure"] = "1.25" }),
|
||||
};
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.Equal(first.GetHashCode(), second.GetHashCode());
|
||||
Assert.True(first.SettingOverrides.TryGetValue("EXPOSURE", out string? value));
|
||||
Assert.Equal("1.25", value);
|
||||
Assert.Empty(RenderPackSelectionSettings.Retail.SettingOverrides);
|
||||
}
|
||||
|
||||
private static int ParseWidth(string res)
|
||||
{
|
||||
int x = res.IndexOf('x');
|
||||
|
|
|
|||
|
|
@ -46,12 +46,28 @@ public sealed class SettingsStoreTests : System.IDisposable
|
|||
Gamma: 1.4f,
|
||||
ShowFps: true,
|
||||
Quality: AcDream.UI.Abstractions.Settings.QualityPreset.Ultra,
|
||||
ParticleRange: ParticleRange.Extended);
|
||||
ParticleRange: ParticleRange.Extended)
|
||||
{
|
||||
RenderPack = new RenderPackSelectionSettings(
|
||||
"acdream.atmospheric",
|
||||
"1.0.0",
|
||||
"high")
|
||||
{
|
||||
SettingOverrides = new RenderPackSettingOverrides(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["exposure"] = "1.25",
|
||||
["sun-rays"] = "true",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
store.SaveDisplay(original);
|
||||
var loaded = store.LoadDisplay();
|
||||
|
||||
Assert.Equal(original, loaded);
|
||||
Assert.Equal("1.25", loaded.RenderPack.SettingOverrides["exposure"]);
|
||||
Assert.Equal("true", loaded.RenderPack.SettingOverrides["sun-rays"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -85,6 +101,27 @@ public sealed class SettingsStoreTests : System.IDisposable
|
|||
Assert.Equal(DisplaySettings.Default.VSync, loaded.VSync);
|
||||
Assert.Equal(DisplaySettings.Default.FieldOfView, loaded.FieldOfView);
|
||||
Assert.Equal(ParticleRange.Extended, loaded.ParticleRange);
|
||||
Assert.Equal(RenderPackSelectionSettings.Retail, loaded.RenderPack);
|
||||
Assert.Empty(loaded.RenderPack.SettingOverrides);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadDisplay_upgrades_pre_pack_file_to_retail_without_a_write()
|
||||
{
|
||||
File.WriteAllText(_tempPath, """
|
||||
{
|
||||
"version": 3,
|
||||
"display": {
|
||||
"resolution": "1920x1080",
|
||||
"fullscreen": false
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
DisplaySettings loaded = new SettingsStore(_tempPath).LoadDisplay();
|
||||
|
||||
Assert.Equal("1920x1080", loaded.Resolution);
|
||||
Assert.Equal(RenderPackSelectionSettings.Retail, loaded.RenderPack);
|
||||
}
|
||||
|
||||
// ── #389 schema-v3 fieldOfView migration ────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue