diff --git a/docs/temporal-spatial-evidence-reservoir.md b/docs/temporal-spatial-evidence-reservoir.md index 01191b0..9bad62c 100644 --- a/docs/temporal-spatial-evidence-reservoir.md +++ b/docs/temporal-spatial-evidence-reservoir.md @@ -120,9 +120,10 @@ Built: candidate count, and exposes the RIS contribution weight. - `TemporalSpatialEvidenceReservoir` remains the stable track layer used by temporal Gaussian fields and LocalCast fallback GPU fusion. -- `FractalContributionCache` remains the node-summary LOD estimator. It should - become a candidate generator feeding the resampling core rather than a - separate claim to temporal-reservoir theory. +- `FractalContributionCache` remains the node-summary LOD estimator, and now + frames scheduled update nodes as weighted reservoir candidates with a + per-frame reservoir snapshot for debug and tests. It is the candidate source, + not the full temporal/spatial reuse pass. Not built yet: diff --git a/research/rendering/fractal-brush-implementation-roadmap.md b/research/rendering/fractal-brush-implementation-roadmap.md index e8a21ac..2d975ae 100644 --- a/research/rendering/fractal-brush-implementation-roadmap.md +++ b/research/rendering/fractal-brush-implementation-roadmap.md @@ -333,7 +333,7 @@ Tasks: - [x] Add exploration budget. - [x] Add live contribution cache that observes only scheduled nodes each frame and feeds the next resource plan. -- [ ] Convert contribution-cache refresh into a candidate generator that submits +- [x] Convert contribution-cache refresh into a candidate generator that submits weighted candidates to the resampled-importance core. - [ ] Add fractal SDF probe candidate records: domain key, local frame, bound, projected contribution target, source PDF, payload handle, and material delta. @@ -354,6 +354,7 @@ Tests: - [x] reservoir accepts candidates proportional to target/source PDF; - [x] reservoir merge preserves represented candidate count and weight sum; - [x] invalid zero-weight candidates do not corrupt reservoir state; +- [x] contribution cache exposes scheduled updates as reservoir candidates; - deterministic RNG fixture repeats schedule; - visible nodes keep nonzero update probability; - near-threshold nodes keep nonzero update probability; diff --git a/src/Aquarium.Engine.Fractal/Lod/FractalContributionCache.cs b/src/Aquarium.Engine.Fractal/Lod/FractalContributionCache.cs index d8ef219..15ae972 100644 --- a/src/Aquarium.Engine.Fractal/Lod/FractalContributionCache.cs +++ b/src/Aquarium.Engine.Fractal/Lod/FractalContributionCache.cs @@ -1,4 +1,5 @@ using Aquarium.Engine.Fractal; +using Aquarium.Engine.Fractal.Temporal; namespace Aquarium.Engine.Fractal.Lod; @@ -7,6 +8,8 @@ public sealed class FractalContributionCache private readonly FractalContributionTable table = new(); private uint frameIndex; + public FractalContributionReservoirSnapshot LastFrameReservoir { get; private set; } + public FractalResourcePlan PlanFrame( IReadOnlyList summaries, Func projectedPixelsPerWorldUnit, @@ -39,6 +42,7 @@ public sealed class FractalContributionCache payloadStore, frameIndex); + var reservoir = new ResampledImportanceReservoir(); var resident = plan.Residency.ResidentNodes.Select(key => key.Value).ToHashSet(StringComparer.Ordinal); foreach (var nodeKey in plan.UpdateNodes) { @@ -48,9 +52,19 @@ public sealed class FractalContributionCache } var state = table.GetOrCreate(nodeKey); - table.Store(FractalContributionEstimator.Observe(state, observedScore, frameIndex, resident.Contains(nodeKey.Value))); + var updateProbability = FractalStochasticUpdateScheduler.UpdateProbability(state, observedScore, frameIndex); + var isResident = resident.Contains(nodeKey.Value); + var candidate = FractalContributionCandidateGenerator.Build( + nodeKey, + observedScore, + updateProbability, + frameIndex, + isResident); + reservoir.Add(candidate, random.NextDouble()); + table.Store(FractalContributionEstimator.Observe(state, observedScore, frameIndex, isResident)); } + LastFrameReservoir = FractalContributionCandidateGenerator.Snapshot(reservoir); return plan; } diff --git a/src/Aquarium.Engine.Fractal/Lod/FractalContributionCandidate.cs b/src/Aquarium.Engine.Fractal/Lod/FractalContributionCandidate.cs new file mode 100644 index 0000000..9e1495e --- /dev/null +++ b/src/Aquarium.Engine.Fractal/Lod/FractalContributionCandidate.cs @@ -0,0 +1,71 @@ +using Aquarium.Engine.Fractal.Temporal; + +namespace Aquarium.Engine.Fractal.Lod; + +public readonly record struct FractalContributionCandidate( + AquariumFractalKey NodeKey, + float ObservedContribution, + float UpdateProbability, + uint FrameIndex, + bool Resident); + +public readonly record struct FractalContributionReservoirSnapshot( + bool HasSample, + AquariumFractalKey SelectedNodeKey, + float SelectedContribution, + float SelectedTarget, + float WeightSum, + int CandidateCount, + float ContributionWeight); + +public static class FractalContributionCandidateGenerator +{ + public static ResampledImportanceCandidate Build( + AquariumFractalKey nodeKey, + float observedContribution, + double updateProbability, + uint frameIndex, + bool resident) + { + var target = MathF.Max(observedContribution, 0.0f); + var sourcePdf = MathF.Max((float)updateProbability, 1.0e-6f); + var sample = new FractalContributionCandidate( + nodeKey, + target, + sourcePdf, + frameIndex, + resident); + + return new ResampledImportanceCandidate( + sample, + target, + sourcePdf); + } + + public static FractalContributionReservoirSnapshot Snapshot( + ResampledImportanceReservoir reservoir) + { + ArgumentNullException.ThrowIfNull(reservoir); + if (!reservoir.HasSample) + { + return new FractalContributionReservoirSnapshot( + HasSample: false, + SelectedNodeKey: default, + SelectedContribution: 0.0f, + SelectedTarget: 0.0f, + WeightSum: reservoir.WeightSum, + CandidateCount: reservoir.CandidateCount, + ContributionWeight: 0.0f); + } + + var selected = reservoir.SelectedSample; + return new FractalContributionReservoirSnapshot( + HasSample: true, + SelectedNodeKey: selected.NodeKey, + SelectedContribution: selected.ObservedContribution, + SelectedTarget: reservoir.SelectedTarget, + WeightSum: reservoir.WeightSum, + CandidateCount: reservoir.CandidateCount, + ContributionWeight: reservoir.ContributionWeight); + } +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 76da81c..0c8d3fb 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -159,3 +159,4 @@ {"timestamp":"2026-05-19T16:11:00+01:00","topic":"zyphos-budgeted-fractal-geometry","lesson":"Authored fractal packets now affect Zyphos traced planet geometry, but only through a hard-budgeted top-influence subset. Selected height brushes are sorted by abs(amplitude)*max(radius), `sdfDistance` samples the first four, and `sdfSurface` still evaluates the full selected packet table for material/detail. A 12-brush geometry loop was tested and cut after pushing the live frame to about 33 ms; the four-brush budget returns the live view to about 16.4 ms after shader pipelines are ready.","files":["src/Aquarium.Zyphos/ZyphosFractalTerrain.cs","src/Aquarium.Zyphos/Shaders/D3D12ZyphosPlanet.hlsl","tests/Aquarium.Engine.Fractal.Tests/ZyphosFractalTerrainTests.cs"],"source":"runtime proof after moving authored fractal packet evaluation into the hot SDF distance path"} {"timestamp":"2026-05-19T16:27:00+01:00","topic":"zyphos-fractal-domain-debug-mode","lesson":"Render debug mode 11 now exposes a client fractal-domain view. Zyphos colors cube faces, draws an 8x8 tile grid, and overlays authored brush material influence so face/tile/material packet mapping can be inspected from the renderer instead of inferred from final beauty shading.","files":["src/Aquarium.Engine.Contracts/GraphicsSettings.cs","src/Aquarium.Engine/Render/D3D12Renderer.cs","src/Aquarium.Zyphos/Shaders/D3D12ZyphosPlanet.hlsl","research/rendering/fractal-brush-implementation-roadmap.md"],"source":"roadmap called for face/tile/LOD/cache debug modes; this is the first renderer-visible face/tile/material influence slice"} {"timestamp":"2026-05-19T17:35:00+01:00","topic":"restir-reservoir-core","lesson":"The previous stable-key temporal smoother was not ReSTIR. Aquarium now treats `ResampledImportanceReservoir` as the RIS/ReSTIR math authority: selected sample, target value, weight sum, represented candidate count, merge, and contribution weight. `TemporalSpatialEvidenceReservoir` is a stable resolved-track layer, while fractal contribution scoring should become a candidate generator feeding reservoir resampling with temporal/spatial/domain validation and TAA guide-buffer integration.","files":["src/Aquarium.Engine.Fractal/Temporal/ResampledImportanceCandidate.cs","src/Aquarium.Engine.Fractal/Temporal/ResampledImportanceReservoir.cs","tests/Aquarium.Engine.Fractal.Tests/ResampledImportanceReservoirTests.cs","docs/temporal-spatial-evidence-reservoir.md","research/rendering/fractal-brush-implementation-roadmap.md","state/map.yaml","state/memory.json"],"source":"user corrected the architecture toward NVIDIA ReSTIR/GRIS rather than invented spatiotemporal reservoir sampling"} +{"timestamp":"2026-05-19T18:03:00+01:00","topic":"fractal-contribution-candidates","lesson":"FractalContributionCache now frames scheduled update nodes as weighted `ResampledImportanceReservoir` candidates and exposes a per-frame reservoir snapshot for debug/tests. This is the handoff from heuristic LOD scoring into reservoir evidence; temporal/spatial reuse and domain shift validation still need to be built explicitly before calling the fractal path ReSTIR-complete.","files":["src/Aquarium.Engine.Fractal/Lod/FractalContributionCandidate.cs","src/Aquarium.Engine.Fractal/Lod/FractalContributionCache.cs","tests/Aquarium.Engine.Fractal.Tests/FractalContributionCacheTests.cs","docs/temporal-spatial-evidence-reservoir.md","research/rendering/fractal-brush-implementation-roadmap.md","state/memory.json"],"source":"continued Perfect Machine work after adding the ReSTIR-style reservoir core"} diff --git a/state/memory.json b/state/memory.json index 87b19a2..fc574ed 100644 --- a/state/memory.json +++ b/state/memory.json @@ -89,7 +89,7 @@ "Zyphos passes cube-tile domain metadata through height brush packets and binds those packets into the SDF pass. The planet shader may use authored brush packets for post-hit material/detail evaluation; traced geometry must wait for selected-cut/resident packet policy so SDF distance loops do not evaluate every brush at every step.", "Zyphos traced planet geometry may sample only a hard-budgeted top-influence subset of authored fractal brush packets in `sdfDistance`; the current proven budget is four brushes after sorting selected packets by abs(amplitude)*max(radius). A 12-brush distance loop was tested and rejected because it pushed the live frame to about 33 ms; the four-brush loop returns the live view to about 16.4 ms while full packet richness remains available to post-hit material/detail.", "Temporal resolve has a cache-facing detail lane: SdfSurface.temporalDetail writes through scene-control.z into history-control.z, and D3D12Post reduces history confidence when current and previous temporal detail disagree. Use this lane for stochastic fractal/cache stability signals, not as geometry authority.", - "FractalContributionCache is the live cache loop: each frame builds a resource plan from current summaries and cached states, stochastically observes only budgeted update nodes, stores mean/variance/confidence/sample age through FractalContributionEstimator, and lets backend packet compilation stay cached by selected-cut key. It should evolve into a candidate generator feeding ResampledImportanceReservoir, not pretend the estimator alone is spatiotemporal reservoir sampling. Do not replace this with a static plan cache that stops learning after the first camera bucket.", + "FractalContributionCache is the live cache loop: each frame builds a resource plan from current summaries and cached states, stochastically observes only budgeted update nodes, stores mean/variance/confidence/sample age through FractalContributionEstimator, frames scheduled updates as weighted ResampledImportanceReservoir candidates, and lets backend packet compilation stay cached by selected-cut key. It is a candidate source, not the whole temporal/spatial reuse pass. Do not replace this with a static plan cache that stops learning after the first camera bucket.", "Zyphos camera distance limits belong to the selected spatial domain, not one global planetary clamp. Leaf/pebble domains must be able to zoom below whole-body distances, and the parent star must be a visible SDF body when it is also the primary light source.", "The consolidated fractal brush architecture lives in research/rendering/fractal-brush-architecture-plan.md. Its pipeline is client intent -> domain roots -> semantic IFS brush grammar -> ownership tree -> node summaries -> contribution cache/residency scheduler -> backend packets -> renderer passes -> debug/evidence. Its credibility depends on module boundaries and tests: contracts, pure CPU fractal core, D3D12 backend, client adapters, mock payload/probe/random/clock seams, shader parity tests, and deterministic performance fixtures.", "The actionable fractal brush roadmap lives in research/rendering/fractal-brush-implementation-roadmap.md. Start with the first work packet: add Aquarium.Engine.Fractal and tests, cube-face/tile key types, identity and tangent projection candidates, an area distortion sampler, face-edge tests, and a projection report before touching recursive detail." diff --git a/tests/Aquarium.Engine.Fractal.Tests/FractalContributionCacheTests.cs b/tests/Aquarium.Engine.Fractal.Tests/FractalContributionCacheTests.cs index fc929be..1c22f3b 100644 --- a/tests/Aquarium.Engine.Fractal.Tests/FractalContributionCacheTests.cs +++ b/tests/Aquarium.Engine.Fractal.Tests/FractalContributionCacheTests.cs @@ -28,6 +28,10 @@ public sealed class FractalContributionCacheTests Assert.True(cache.TryGet(plan.UpdateNodes[0], out var updated)); Assert.Equal(1, updated.SampleCount); Assert.True(updated.Confidence > 0.0f); + Assert.True(cache.LastFrameReservoir.HasSample); + Assert.Equal(plan.UpdateNodes[0], cache.LastFrameReservoir.SelectedNodeKey); + Assert.Equal(1, cache.LastFrameReservoir.CandidateCount); + Assert.True(cache.LastFrameReservoir.WeightSum > 0.0f); } [Fact] @@ -46,6 +50,27 @@ public sealed class FractalContributionCacheTests Assert.True(state.Resident); } + [Fact] + public void CacheFramesScheduledContributionAsReservoirCandidate() + { + var cache = new FractalContributionCache(); + var summary = Summary("node/bright", maxHeightError: 3.0f); + + cache.PlanFrame( + [summary], + _ => 4.0f, + new FractalResourceBudget(MaxCpuUpdates: 1, MaxGpuEstimatedCost: 4.0f, MaxResidentPayloads: 4, MaxSsdRequests: 0), + new TestFractalRandom(0.01, 0.0), + new ResidentStore()); + + var reservoir = cache.LastFrameReservoir; + Assert.True(reservoir.HasSample); + Assert.Equal(summary.NodeKey, reservoir.SelectedNodeKey); + Assert.True(reservoir.SelectedContribution > 0.0f); + Assert.True(reservoir.SelectedTarget > 0.0f); + Assert.True(reservoir.ContributionWeight > 0.0f); + } + private static AquariumFractalSummary Summary(string key, float maxHeightError) { return new AquariumFractalSummary(