Extract fractal GPU reservoir budget planner

This commit is contained in:
Meta Vens 2026-05-20 01:19:29 +01:00
commit bc65a4655f
4 changed files with 166 additions and 1 deletions

View file

@ -0,0 +1,109 @@
namespace Aquarium.Engine.Fractal.Lod;
public enum FractalGpuReservoirPassKind
{
SdfEnvelope,
PbrMaterial,
Radiosity,
}
public readonly record struct FractalGpuReservoirPassPlan(
FractalGpuReservoirPassKind Kind,
int ResidentEntries,
int UpdatesThisFrame,
int CandidatesPerUpdate,
double ExpectedFullCoverageFrames,
ulong ResidentBytes,
ulong CandidateEvaluationsThisFrame);
public sealed class FractalGpuReservoirFramePlan
{
public FractalGpuReservoirFramePlan(
int splatCount,
int splatStrideBytes,
ulong splatBytes,
IReadOnlyList<FractalGpuReservoirPassPlan> passes)
{
SplatCount = splatCount;
SplatStrideBytes = splatStrideBytes;
SplatBytes = splatBytes;
Passes = passes;
}
public int SplatCount { get; }
public int SplatStrideBytes { get; }
public ulong SplatBytes { get; }
public IReadOnlyList<FractalGpuReservoirPassPlan> Passes { get; }
public ulong ReservoirBytes => Passes.Aggregate(0UL, (sum, pass) => sum + pass.ResidentBytes);
public ulong TotalResidentBytes => SplatBytes + ReservoirBytes;
public ulong CandidateEvaluationsThisFrame => Passes.Aggregate(0UL, (sum, pass) => sum + pass.CandidateEvaluationsThisFrame);
}
public static class FractalGpuReservoirBudgetPlanner
{
public static FractalGpuReservoirFramePlan Plan(
int splatCount,
int reservoirUpdatesPerPass,
int candidatesPerUpdate,
int splatStrideBytes = 80,
int reservoirStrideBytes = 64)
{
if (splatCount <= 0)
{
throw new ArgumentOutOfRangeException(nameof(splatCount), splatCount, "Splat count must be positive.");
}
if (reservoirUpdatesPerPass <= 0 || reservoirUpdatesPerPass > splatCount)
{
throw new ArgumentOutOfRangeException(nameof(reservoirUpdatesPerPass), reservoirUpdatesPerPass, "Reservoir updates must be positive and no larger than the resident splat count.");
}
if (candidatesPerUpdate <= 0)
{
throw new ArgumentOutOfRangeException(nameof(candidatesPerUpdate), candidatesPerUpdate, "Candidates per update must be positive.");
}
if (splatStrideBytes <= 0)
{
throw new ArgumentOutOfRangeException(nameof(splatStrideBytes), splatStrideBytes, "Splat stride must be positive.");
}
if (reservoirStrideBytes <= 0)
{
throw new ArgumentOutOfRangeException(nameof(reservoirStrideBytes), reservoirStrideBytes, "Reservoir stride must be positive.");
}
var splatBytes = checked((ulong)splatCount * (ulong)splatStrideBytes);
var expectedCoverageFrames = splatCount / (double)reservoirUpdatesPerPass;
var passes = new[]
{
Pass(FractalGpuReservoirPassKind.SdfEnvelope),
Pass(FractalGpuReservoirPassKind.PbrMaterial),
Pass(FractalGpuReservoirPassKind.Radiosity),
};
return new FractalGpuReservoirFramePlan(
splatCount,
splatStrideBytes,
splatBytes,
passes);
FractalGpuReservoirPassPlan Pass(FractalGpuReservoirPassKind kind)
{
return new FractalGpuReservoirPassPlan(
kind,
splatCount,
reservoirUpdatesPerPass,
candidatesPerUpdate,
expectedCoverageFrames,
checked((ulong)splatCount * (ulong)reservoirStrideBytes),
checked((ulong)reservoirUpdatesPerPass * (ulong)candidatesPerUpdate));
}
}
}

View file

@ -0,0 +1,48 @@
using Aquarium.Engine.Fractal.Lod;
namespace Aquarium.Engine.Fractal.Tests;
public sealed class FractalGpuReservoirBudgetPlannerTests
{
[Fact]
public void PlanKeepsReservoirPassesDistinctWhileSharingBudgetMath()
{
var plan = FractalGpuReservoirBudgetPlanner.Plan(
splatCount: 2_000_000,
reservoirUpdatesPerPass: 50_000,
candidatesPerUpdate: 2);
Assert.Equal(2_000_000, plan.SplatCount);
Assert.Equal(160_000_000UL, plan.SplatBytes);
Assert.Equal(384_000_000UL, plan.ReservoirBytes);
Assert.Equal(544_000_000UL, plan.TotalResidentBytes);
Assert.Equal(300_000UL, plan.CandidateEvaluationsThisFrame);
Assert.Equal(
new[]
{
FractalGpuReservoirPassKind.SdfEnvelope,
FractalGpuReservoirPassKind.PbrMaterial,
FractalGpuReservoirPassKind.Radiosity,
},
plan.Passes.Select(pass => pass.Kind).ToArray());
foreach (var pass in plan.Passes)
{
Assert.Equal(2_000_000, pass.ResidentEntries);
Assert.Equal(50_000, pass.UpdatesThisFrame);
Assert.Equal(2, pass.CandidatesPerUpdate);
Assert.Equal(40.0, pass.ExpectedFullCoverageFrames);
Assert.Equal(128_000_000UL, pass.ResidentBytes);
Assert.Equal(100_000UL, pass.CandidateEvaluationsThisFrame);
}
}
[Fact]
public void PlanRejectsBudgetsThatCannotConvergeOverResidentReservoirs()
{
Assert.Throws<ArgumentOutOfRangeException>(() => FractalGpuReservoirBudgetPlanner.Plan(0, 1, 1));
Assert.Throws<ArgumentOutOfRangeException>(() => FractalGpuReservoirBudgetPlanner.Plan(16, 0, 1));
Assert.Throws<ArgumentOutOfRangeException>(() => FractalGpuReservoirBudgetPlanner.Plan(16, 17, 1));
Assert.Throws<ArgumentOutOfRangeException>(() => FractalGpuReservoirBudgetPlanner.Plan(16, 1, 0));
}
}

View file

@ -10,6 +10,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Aquarium.Engine.Contracts\Aquarium.Engine.Contracts.csproj" />
<ProjectReference Include="..\..\src\Aquarium.Engine.Fractal\Aquarium.Engine.Fractal.csproj" />
<PackageReference Include="Vortice.Direct3D12" Version="3.8.3" />
<PackageReference Include="Vortice.D3DCompiler" Version="3.8.3" />
<PackageReference Include="Vortice.DXGI" Version="3.8.3" />

View file

@ -2,6 +2,7 @@ using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text.Json;
using Aquarium.Engine.Fractal;
using Aquarium.Engine.Fractal.Lod;
using Vortice.D3DCompiler;
using Vortice.Direct3D;
using Vortice.Direct3D12;
@ -74,6 +75,12 @@ internal sealed class GpuFractalSplatReceiptRunner : IDisposable
{
var splatStride = Marshal.SizeOf<AquariumPackedFractalSdfSplat3D>();
var reservoirStride = Marshal.SizeOf<AquariumPackedSdfEnvelopeReservoir>();
var framePlan = FractalGpuReservoirBudgetPlanner.Plan(
options.SplatCount,
options.ReservoirUpdatesPerPass,
options.CandidatesPerPass,
splatStride,
reservoirStride);
var splatBytes = checked((ulong)splatStride * (ulong)options.SplatCount);
var reservoirBytes = checked((ulong)reservoirStride * (ulong)options.SplatCount);
using var splats = CreateUavBuffer(splatBytes, "Aquarium Fractal Receipt GPU Splat Buffer");
@ -150,7 +157,7 @@ internal sealed class GpuFractalSplatReceiptRunner : IDisposable
options.SplatCount,
options.CandidatesPerPass,
options.ReservoirUpdatesPerPass,
options.SplatCount / (double)options.ReservoirUpdatesPerPass,
framePlan.Passes[0].ExpectedFullCoverageFrames,
measuredFrames,
splatStride,
reservoirStride,