Split Epiphany client from Fensalir engine

This commit is contained in:
Meta Vens 2026-05-23 18:54:26 +01:00
commit c0841d4652
287 changed files with 194 additions and 37974 deletions

View file

@ -1,86 +1,44 @@
# Aquarium Engine Instructions
# Epiphany Aquarium Instructions
## Purpose
Aquarium Engine is the native C# runtime for the Epiphany Aquarium client. It
owns the window, renderer, input loop, live-reload boundary, persistent client
state, and eventual diegetic UI machinery.
This repo is the Epiphany Aquarium client. It owns Epiphany-facing runtime
semantics, agent visuals, CultCache documents, CultNet interpretation, voice
surfaces, and client-authored render plans.
This repo is not the Epiphany harness. The visible frame belongs to Aquarium.
The native host and renderer live in the adjacent Fensalir repo:
`E:\Projects\Fensalir`.
## Operating Doctrine
- The renderer is the source of truth for visible world pixels.
- Keep the frame legible: explicit passes, explicit ownership, explicit state.
- Do not leave lies in the walls. When building engine systems meant to last,
refuse half-assed shortcuts, hidden compensators, mystery state, and
prototype-shaped production code. Prefer full-ass, research-backed,
steel-plated resilience with padded failure boundaries so users do not cut
themselves on sharp internals.
- This is game engine work: every millisecond counts. Do not spend runtime
casually on avoidable JSON churn, spaghetti state, redundant ray work,
unbounded full-resolution filters, or convenience abstractions that hide
performance costs. If a shortcut is temporary, mark it as scaffolding and cut
it before it becomes architecture.
- Before inventing renderer, simulation, storage, or post-processing machinery,
check standard literature, production talks, engine docs, or proven local
patterns. Build the durable machine, not a sequence of shy approximations that
accrete into Jenga.
- When a visual, physical, or simulation spec describes a continuous form, start
with continuous field math or a coherent parameterization that owns the form.
Do not assemble the thing from piecewise capsules, boxes, slabs, adapters, or
helper organs just because that is the shortest path to pixels. Piecewise
construction is allowed only when the form itself is piecewise or when a
measured constraint makes the continuous model impractical; document that cut
line and keep the compensator small.
- The camera target is the Grid center. Grid radius follows zoom distance.
Pitch, yaw, and screen projection do not resize the Grid.
- Body anchors are world-space simulation state. They do not ride with the Grid
visibility window.
- Lighting is diegetic. Self, field sources, and Aquarium-owned transport light
the scene; ambient convenience light is guilty until proven useful.
- Overlay UI and diegetic UI are different beasts. Overlay text can use
DirectWrite. Scene text should become renderer-owned MSDF/SDF billboards when
it belongs in the world.
- CultCache is the runtime memory organ. Camera state, reload state, renderer
knobs, UI state, body state, and future Epiphany connection state should be
typed Cult documents, not loose JSON or process-only folklore.
- CultNet is the communication surface for Epiphany agents. Add transport
deliberately and keep authority boundaries typed.
- Epiphany owns meaning; Fensalir owns the machine that displays and reloads it.
- Keep dependency direction one-way: this repo can reference Fensalir, but
Fensalir must not learn Epiphany nouns.
- Client render code should publish explicit contracts and shaders. Do not sneak
renderer policy, D3D12 plumbing, or host lifecycle code back into this repo.
- CultCache documents are typed client state, not loose JSON sidecars.
- CultNet surfaces are semantic input. Spatial placement, body projection, and
renderer budget belong to the client/runtime contract, not to chat folklore.
- If an engine capability is missing, add it in Fensalir and consume it here.
Do not build a small private engine because the real one is across the hall.
## Persistent State
- `state/map.yaml` is the canonical project map.
- `state/memory.json` is durable engine doctrine and taste.
- `state/evidence.jsonl` stores distilled lessons that should change future
behavior.
- `state/map.yaml` is the canonical client map.
- `state/memory.json` is durable Epiphany Aquarium doctrine and taste.
- `state/evidence.jsonl` stores lessons that should change future behavior.
- `state/scratch.md` is disposable working context for the active slice.
Update these when the engine learns something durable. Delete stale guidance
instead of embalming it.
Update state when ownership, contracts, or client doctrine changes.
## Verification
Use focused checks:
```powershell
dotnet build Aquarium.Engine.sln
dotnet build EpiphanyAquarium.sln
.\scripts\dev-reload.ps1 -Headless -RetainSlots 4
```
For normal iteration:
```powershell
.\scripts\dev-watch.ps1
```
For shipping:
```powershell
.\scripts\publish-win-x64.ps1 -Clean -Zip
```
## Style
Prefer small, intentional commits. Push completed work. Keep docs about the live
system; put old branch scars only in evidence when they alter future decisions.
Keep docs about the live system. Historical scars belong in evidence only when
they change future decisions.

View file

@ -1,322 +0,0 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.Engine.Contracts", "src\Aquarium.Engine.Contracts\Aquarium.Engine.Contracts.csproj", "{08D15A38-B6AB-459B-8BF9-3CCA8555A022}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.Engine", "src\Aquarium.Engine\Aquarium.Engine.csproj", "{51FE01D8-9EEF-46B2-8D8C-3307AC2221C1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CultMath", "..\CultMath\src\CultMath\CultMath.csproj", "{0089E341-25F2-43FC-8FD9-CBD1AE646D91}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.Epiphany", "src\Aquarium.Epiphany\Aquarium.Epiphany.csproj", "{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack", "..\CultLib\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj", "{A8EDAE19-805B-4B09-9566-BE63937CA1B9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching", "..\CultLib\src\GameCult.Caching\GameCult.Caching.csproj", "{6E874823-17F8-453C-A077-4A101D425C84}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Logging", "..\CultLib\src\GameCult.Logging\GameCult.Logging.csproj", "{68AF73A1-65FE-4BCE-BDF6-8CB3DF12033E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack.Analyzers", "..\CultLib\src\GameCult.Caching.MessagePack.Analyzers\GameCult.Caching.MessagePack.Analyzers.csproj", "{225265F1-8278-48D7-B271-BE8841D0C1CA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack.Generator", "..\CultLib\src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj", "{435C602F-025E-4948-8285-E83E693DAA7B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking", "..\CultLib\src\GameCult.Networking\GameCult.Networking.csproj", "{BDE44048-7BA1-45C0-A69C-F70E5188FFE1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.Sample.Minimal", "src\Aquarium.Sample.Minimal\Aquarium.Sample.Minimal.csproj", "{7564E6C2-FF01-4B5B-8C2B-F9CD796858BA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.Engine.Tests", "tests\Aquarium.Engine.Tests\Aquarium.Engine.Tests.csproj", "{37363FC6-5AA3-49B4-A3B1-E4AB2CC986EB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.Epiphany.AgentPreview", "src\Aquarium.Epiphany.AgentPreview\Aquarium.Epiphany.AgentPreview.csproj", "{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.Zyphos", "src\Aquarium.Zyphos\Aquarium.Zyphos.csproj", "{801B1913-6AE1-4B8C-8030-4669AC4FE25E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.Engine.Fractal", "src\Aquarium.Engine.Fractal\Aquarium.Engine.Fractal.csproj", "{FF232A59-4E8D-4D56-A2C3-ACC02F5371D0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.Engine.Fractal.Tests", "tests\Aquarium.Engine.Fractal.Tests\Aquarium.Engine.Fractal.Tests.csproj", "{A9D41C48-7E78-4D75-A814-0C1500245EFC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.LocalCast", "src\Aquarium.LocalCast\Aquarium.LocalCast.csproj", "{E8C66A46-1F56-484D-8CDD-5C0A3FD84A5F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.LocalCast.Tests", "tests\Aquarium.LocalCast.Tests\Aquarium.LocalCast.Tests.csproj", "{E6560D72-B018-4E00-AEBD-C1B9419E0BC7}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.Fractal.Receipt", "tools\Aquarium.Fractal.Receipt\Aquarium.Fractal.Receipt.csproj", "{C0D1BFAC-B1B4-4350-92A7-D537AE382909}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.Fensalir", "src\Aquarium.Fensalir\Aquarium.Fensalir.csproj", "{F9274D99-3CC6-42B6-9817-EC7F19663B1D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{08D15A38-B6AB-459B-8BF9-3CCA8555A022}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{08D15A38-B6AB-459B-8BF9-3CCA8555A022}.Debug|Any CPU.Build.0 = Debug|Any CPU
{08D15A38-B6AB-459B-8BF9-3CCA8555A022}.Debug|x64.ActiveCfg = Debug|Any CPU
{08D15A38-B6AB-459B-8BF9-3CCA8555A022}.Debug|x64.Build.0 = Debug|Any CPU
{08D15A38-B6AB-459B-8BF9-3CCA8555A022}.Debug|x86.ActiveCfg = Debug|Any CPU
{08D15A38-B6AB-459B-8BF9-3CCA8555A022}.Debug|x86.Build.0 = Debug|Any CPU
{08D15A38-B6AB-459B-8BF9-3CCA8555A022}.Release|Any CPU.ActiveCfg = Release|Any CPU
{08D15A38-B6AB-459B-8BF9-3CCA8555A022}.Release|Any CPU.Build.0 = Release|Any CPU
{08D15A38-B6AB-459B-8BF9-3CCA8555A022}.Release|x64.ActiveCfg = Release|Any CPU
{08D15A38-B6AB-459B-8BF9-3CCA8555A022}.Release|x64.Build.0 = Release|Any CPU
{08D15A38-B6AB-459B-8BF9-3CCA8555A022}.Release|x86.ActiveCfg = Release|Any CPU
{08D15A38-B6AB-459B-8BF9-3CCA8555A022}.Release|x86.Build.0 = Release|Any CPU
{51FE01D8-9EEF-46B2-8D8C-3307AC2221C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{51FE01D8-9EEF-46B2-8D8C-3307AC2221C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{51FE01D8-9EEF-46B2-8D8C-3307AC2221C1}.Debug|x64.ActiveCfg = Debug|Any CPU
{51FE01D8-9EEF-46B2-8D8C-3307AC2221C1}.Debug|x64.Build.0 = Debug|Any CPU
{51FE01D8-9EEF-46B2-8D8C-3307AC2221C1}.Debug|x86.ActiveCfg = Debug|Any CPU
{51FE01D8-9EEF-46B2-8D8C-3307AC2221C1}.Debug|x86.Build.0 = Debug|Any CPU
{51FE01D8-9EEF-46B2-8D8C-3307AC2221C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{51FE01D8-9EEF-46B2-8D8C-3307AC2221C1}.Release|Any CPU.Build.0 = Release|Any CPU
{51FE01D8-9EEF-46B2-8D8C-3307AC2221C1}.Release|x64.ActiveCfg = Release|Any CPU
{51FE01D8-9EEF-46B2-8D8C-3307AC2221C1}.Release|x64.Build.0 = Release|Any CPU
{51FE01D8-9EEF-46B2-8D8C-3307AC2221C1}.Release|x86.ActiveCfg = Release|Any CPU
{51FE01D8-9EEF-46B2-8D8C-3307AC2221C1}.Release|x86.Build.0 = Release|Any CPU
{0089E341-25F2-43FC-8FD9-CBD1AE646D91}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0089E341-25F2-43FC-8FD9-CBD1AE646D91}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0089E341-25F2-43FC-8FD9-CBD1AE646D91}.Debug|x64.ActiveCfg = Debug|Any CPU
{0089E341-25F2-43FC-8FD9-CBD1AE646D91}.Debug|x64.Build.0 = Debug|Any CPU
{0089E341-25F2-43FC-8FD9-CBD1AE646D91}.Debug|x86.ActiveCfg = Debug|Any CPU
{0089E341-25F2-43FC-8FD9-CBD1AE646D91}.Debug|x86.Build.0 = Debug|Any CPU
{0089E341-25F2-43FC-8FD9-CBD1AE646D91}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0089E341-25F2-43FC-8FD9-CBD1AE646D91}.Release|Any CPU.Build.0 = Release|Any CPU
{0089E341-25F2-43FC-8FD9-CBD1AE646D91}.Release|x64.ActiveCfg = Release|Any CPU
{0089E341-25F2-43FC-8FD9-CBD1AE646D91}.Release|x64.Build.0 = Release|Any CPU
{0089E341-25F2-43FC-8FD9-CBD1AE646D91}.Release|x86.ActiveCfg = Release|Any CPU
{0089E341-25F2-43FC-8FD9-CBD1AE646D91}.Release|x86.Build.0 = Release|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Debug|Any CPU.Build.0 = Debug|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Debug|x64.ActiveCfg = Debug|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Debug|x64.Build.0 = Debug|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Debug|x86.ActiveCfg = Debug|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Debug|x86.Build.0 = Debug|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Release|Any CPU.ActiveCfg = Release|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Release|Any CPU.Build.0 = Release|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Release|x64.ActiveCfg = Release|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Release|x64.Build.0 = Release|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Release|x86.ActiveCfg = Release|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Release|x86.Build.0 = Release|Any CPU
{A8EDAE19-805B-4B09-9566-BE63937CA1B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A8EDAE19-805B-4B09-9566-BE63937CA1B9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A8EDAE19-805B-4B09-9566-BE63937CA1B9}.Debug|x64.ActiveCfg = Debug|Any CPU
{A8EDAE19-805B-4B09-9566-BE63937CA1B9}.Debug|x64.Build.0 = Debug|Any CPU
{A8EDAE19-805B-4B09-9566-BE63937CA1B9}.Debug|x86.ActiveCfg = Debug|Any CPU
{A8EDAE19-805B-4B09-9566-BE63937CA1B9}.Debug|x86.Build.0 = Debug|Any CPU
{A8EDAE19-805B-4B09-9566-BE63937CA1B9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A8EDAE19-805B-4B09-9566-BE63937CA1B9}.Release|Any CPU.Build.0 = Release|Any CPU
{A8EDAE19-805B-4B09-9566-BE63937CA1B9}.Release|x64.ActiveCfg = Release|Any CPU
{A8EDAE19-805B-4B09-9566-BE63937CA1B9}.Release|x64.Build.0 = Release|Any CPU
{A8EDAE19-805B-4B09-9566-BE63937CA1B9}.Release|x86.ActiveCfg = Release|Any CPU
{A8EDAE19-805B-4B09-9566-BE63937CA1B9}.Release|x86.Build.0 = Release|Any CPU
{6E874823-17F8-453C-A077-4A101D425C84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6E874823-17F8-453C-A077-4A101D425C84}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6E874823-17F8-453C-A077-4A101D425C84}.Debug|x64.ActiveCfg = Debug|Any CPU
{6E874823-17F8-453C-A077-4A101D425C84}.Debug|x64.Build.0 = Debug|Any CPU
{6E874823-17F8-453C-A077-4A101D425C84}.Debug|x86.ActiveCfg = Debug|Any CPU
{6E874823-17F8-453C-A077-4A101D425C84}.Debug|x86.Build.0 = Debug|Any CPU
{6E874823-17F8-453C-A077-4A101D425C84}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6E874823-17F8-453C-A077-4A101D425C84}.Release|Any CPU.Build.0 = Release|Any CPU
{6E874823-17F8-453C-A077-4A101D425C84}.Release|x64.ActiveCfg = Release|Any CPU
{6E874823-17F8-453C-A077-4A101D425C84}.Release|x64.Build.0 = Release|Any CPU
{6E874823-17F8-453C-A077-4A101D425C84}.Release|x86.ActiveCfg = Release|Any CPU
{6E874823-17F8-453C-A077-4A101D425C84}.Release|x86.Build.0 = Release|Any CPU
{68AF73A1-65FE-4BCE-BDF6-8CB3DF12033E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{68AF73A1-65FE-4BCE-BDF6-8CB3DF12033E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{68AF73A1-65FE-4BCE-BDF6-8CB3DF12033E}.Debug|x64.ActiveCfg = Debug|Any CPU
{68AF73A1-65FE-4BCE-BDF6-8CB3DF12033E}.Debug|x64.Build.0 = Debug|Any CPU
{68AF73A1-65FE-4BCE-BDF6-8CB3DF12033E}.Debug|x86.ActiveCfg = Debug|Any CPU
{68AF73A1-65FE-4BCE-BDF6-8CB3DF12033E}.Debug|x86.Build.0 = Debug|Any CPU
{68AF73A1-65FE-4BCE-BDF6-8CB3DF12033E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{68AF73A1-65FE-4BCE-BDF6-8CB3DF12033E}.Release|Any CPU.Build.0 = Release|Any CPU
{68AF73A1-65FE-4BCE-BDF6-8CB3DF12033E}.Release|x64.ActiveCfg = Release|Any CPU
{68AF73A1-65FE-4BCE-BDF6-8CB3DF12033E}.Release|x64.Build.0 = Release|Any CPU
{68AF73A1-65FE-4BCE-BDF6-8CB3DF12033E}.Release|x86.ActiveCfg = Release|Any CPU
{68AF73A1-65FE-4BCE-BDF6-8CB3DF12033E}.Release|x86.Build.0 = Release|Any CPU
{225265F1-8278-48D7-B271-BE8841D0C1CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{225265F1-8278-48D7-B271-BE8841D0C1CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{225265F1-8278-48D7-B271-BE8841D0C1CA}.Debug|x64.ActiveCfg = Debug|Any CPU
{225265F1-8278-48D7-B271-BE8841D0C1CA}.Debug|x64.Build.0 = Debug|Any CPU
{225265F1-8278-48D7-B271-BE8841D0C1CA}.Debug|x86.ActiveCfg = Debug|Any CPU
{225265F1-8278-48D7-B271-BE8841D0C1CA}.Debug|x86.Build.0 = Debug|Any CPU
{225265F1-8278-48D7-B271-BE8841D0C1CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{225265F1-8278-48D7-B271-BE8841D0C1CA}.Release|Any CPU.Build.0 = Release|Any CPU
{225265F1-8278-48D7-B271-BE8841D0C1CA}.Release|x64.ActiveCfg = Release|Any CPU
{225265F1-8278-48D7-B271-BE8841D0C1CA}.Release|x64.Build.0 = Release|Any CPU
{225265F1-8278-48D7-B271-BE8841D0C1CA}.Release|x86.ActiveCfg = Release|Any CPU
{225265F1-8278-48D7-B271-BE8841D0C1CA}.Release|x86.Build.0 = Release|Any CPU
{435C602F-025E-4948-8285-E83E693DAA7B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{435C602F-025E-4948-8285-E83E693DAA7B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{435C602F-025E-4948-8285-E83E693DAA7B}.Debug|x64.ActiveCfg = Debug|Any CPU
{435C602F-025E-4948-8285-E83E693DAA7B}.Debug|x64.Build.0 = Debug|Any CPU
{435C602F-025E-4948-8285-E83E693DAA7B}.Debug|x86.ActiveCfg = Debug|Any CPU
{435C602F-025E-4948-8285-E83E693DAA7B}.Debug|x86.Build.0 = Debug|Any CPU
{435C602F-025E-4948-8285-E83E693DAA7B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{435C602F-025E-4948-8285-E83E693DAA7B}.Release|Any CPU.Build.0 = Release|Any CPU
{435C602F-025E-4948-8285-E83E693DAA7B}.Release|x64.ActiveCfg = Release|Any CPU
{435C602F-025E-4948-8285-E83E693DAA7B}.Release|x64.Build.0 = Release|Any CPU
{435C602F-025E-4948-8285-E83E693DAA7B}.Release|x86.ActiveCfg = Release|Any CPU
{435C602F-025E-4948-8285-E83E693DAA7B}.Release|x86.Build.0 = Release|Any CPU
{BDE44048-7BA1-45C0-A69C-F70E5188FFE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BDE44048-7BA1-45C0-A69C-F70E5188FFE1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BDE44048-7BA1-45C0-A69C-F70E5188FFE1}.Debug|x64.ActiveCfg = Debug|Any CPU
{BDE44048-7BA1-45C0-A69C-F70E5188FFE1}.Debug|x64.Build.0 = Debug|Any CPU
{BDE44048-7BA1-45C0-A69C-F70E5188FFE1}.Debug|x86.ActiveCfg = Debug|Any CPU
{BDE44048-7BA1-45C0-A69C-F70E5188FFE1}.Debug|x86.Build.0 = Debug|Any CPU
{BDE44048-7BA1-45C0-A69C-F70E5188FFE1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BDE44048-7BA1-45C0-A69C-F70E5188FFE1}.Release|Any CPU.Build.0 = Release|Any CPU
{BDE44048-7BA1-45C0-A69C-F70E5188FFE1}.Release|x64.ActiveCfg = Release|Any CPU
{BDE44048-7BA1-45C0-A69C-F70E5188FFE1}.Release|x64.Build.0 = Release|Any CPU
{BDE44048-7BA1-45C0-A69C-F70E5188FFE1}.Release|x86.ActiveCfg = Release|Any CPU
{BDE44048-7BA1-45C0-A69C-F70E5188FFE1}.Release|x86.Build.0 = Release|Any CPU
{7564E6C2-FF01-4B5B-8C2B-F9CD796858BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7564E6C2-FF01-4B5B-8C2B-F9CD796858BA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7564E6C2-FF01-4B5B-8C2B-F9CD796858BA}.Debug|x64.ActiveCfg = Debug|Any CPU
{7564E6C2-FF01-4B5B-8C2B-F9CD796858BA}.Debug|x64.Build.0 = Debug|Any CPU
{7564E6C2-FF01-4B5B-8C2B-F9CD796858BA}.Debug|x86.ActiveCfg = Debug|Any CPU
{7564E6C2-FF01-4B5B-8C2B-F9CD796858BA}.Debug|x86.Build.0 = Debug|Any CPU
{7564E6C2-FF01-4B5B-8C2B-F9CD796858BA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7564E6C2-FF01-4B5B-8C2B-F9CD796858BA}.Release|Any CPU.Build.0 = Release|Any CPU
{7564E6C2-FF01-4B5B-8C2B-F9CD796858BA}.Release|x64.ActiveCfg = Release|Any CPU
{7564E6C2-FF01-4B5B-8C2B-F9CD796858BA}.Release|x64.Build.0 = Release|Any CPU
{7564E6C2-FF01-4B5B-8C2B-F9CD796858BA}.Release|x86.ActiveCfg = Release|Any CPU
{7564E6C2-FF01-4B5B-8C2B-F9CD796858BA}.Release|x86.Build.0 = Release|Any CPU
{37363FC6-5AA3-49B4-A3B1-E4AB2CC986EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{37363FC6-5AA3-49B4-A3B1-E4AB2CC986EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{37363FC6-5AA3-49B4-A3B1-E4AB2CC986EB}.Debug|x64.ActiveCfg = Debug|Any CPU
{37363FC6-5AA3-49B4-A3B1-E4AB2CC986EB}.Debug|x64.Build.0 = Debug|Any CPU
{37363FC6-5AA3-49B4-A3B1-E4AB2CC986EB}.Debug|x86.ActiveCfg = Debug|Any CPU
{37363FC6-5AA3-49B4-A3B1-E4AB2CC986EB}.Debug|x86.Build.0 = Debug|Any CPU
{37363FC6-5AA3-49B4-A3B1-E4AB2CC986EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{37363FC6-5AA3-49B4-A3B1-E4AB2CC986EB}.Release|Any CPU.Build.0 = Release|Any CPU
{37363FC6-5AA3-49B4-A3B1-E4AB2CC986EB}.Release|x64.ActiveCfg = Release|Any CPU
{37363FC6-5AA3-49B4-A3B1-E4AB2CC986EB}.Release|x64.Build.0 = Release|Any CPU
{37363FC6-5AA3-49B4-A3B1-E4AB2CC986EB}.Release|x86.ActiveCfg = Release|Any CPU
{37363FC6-5AA3-49B4-A3B1-E4AB2CC986EB}.Release|x86.Build.0 = Release|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Debug|x64.ActiveCfg = Debug|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Debug|x64.Build.0 = Debug|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Debug|x86.ActiveCfg = Debug|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Debug|x86.Build.0 = Debug|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Release|Any CPU.Build.0 = Release|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Release|x64.ActiveCfg = Release|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Release|x64.Build.0 = Release|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Release|x86.ActiveCfg = Release|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Release|x86.Build.0 = Release|Any CPU
{801B1913-6AE1-4B8C-8030-4669AC4FE25E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{801B1913-6AE1-4B8C-8030-4669AC4FE25E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{801B1913-6AE1-4B8C-8030-4669AC4FE25E}.Debug|x64.ActiveCfg = Debug|Any CPU
{801B1913-6AE1-4B8C-8030-4669AC4FE25E}.Debug|x64.Build.0 = Debug|Any CPU
{801B1913-6AE1-4B8C-8030-4669AC4FE25E}.Debug|x86.ActiveCfg = Debug|Any CPU
{801B1913-6AE1-4B8C-8030-4669AC4FE25E}.Debug|x86.Build.0 = Debug|Any CPU
{801B1913-6AE1-4B8C-8030-4669AC4FE25E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{801B1913-6AE1-4B8C-8030-4669AC4FE25E}.Release|Any CPU.Build.0 = Release|Any CPU
{801B1913-6AE1-4B8C-8030-4669AC4FE25E}.Release|x64.ActiveCfg = Release|Any CPU
{801B1913-6AE1-4B8C-8030-4669AC4FE25E}.Release|x64.Build.0 = Release|Any CPU
{801B1913-6AE1-4B8C-8030-4669AC4FE25E}.Release|x86.ActiveCfg = Release|Any CPU
{801B1913-6AE1-4B8C-8030-4669AC4FE25E}.Release|x86.Build.0 = Release|Any CPU
{FF232A59-4E8D-4D56-A2C3-ACC02F5371D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FF232A59-4E8D-4D56-A2C3-ACC02F5371D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FF232A59-4E8D-4D56-A2C3-ACC02F5371D0}.Debug|x64.ActiveCfg = Debug|Any CPU
{FF232A59-4E8D-4D56-A2C3-ACC02F5371D0}.Debug|x64.Build.0 = Debug|Any CPU
{FF232A59-4E8D-4D56-A2C3-ACC02F5371D0}.Debug|x86.ActiveCfg = Debug|Any CPU
{FF232A59-4E8D-4D56-A2C3-ACC02F5371D0}.Debug|x86.Build.0 = Debug|Any CPU
{FF232A59-4E8D-4D56-A2C3-ACC02F5371D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FF232A59-4E8D-4D56-A2C3-ACC02F5371D0}.Release|Any CPU.Build.0 = Release|Any CPU
{FF232A59-4E8D-4D56-A2C3-ACC02F5371D0}.Release|x64.ActiveCfg = Release|Any CPU
{FF232A59-4E8D-4D56-A2C3-ACC02F5371D0}.Release|x64.Build.0 = Release|Any CPU
{FF232A59-4E8D-4D56-A2C3-ACC02F5371D0}.Release|x86.ActiveCfg = Release|Any CPU
{FF232A59-4E8D-4D56-A2C3-ACC02F5371D0}.Release|x86.Build.0 = Release|Any CPU
{A9D41C48-7E78-4D75-A814-0C1500245EFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A9D41C48-7E78-4D75-A814-0C1500245EFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A9D41C48-7E78-4D75-A814-0C1500245EFC}.Debug|x64.ActiveCfg = Debug|Any CPU
{A9D41C48-7E78-4D75-A814-0C1500245EFC}.Debug|x64.Build.0 = Debug|Any CPU
{A9D41C48-7E78-4D75-A814-0C1500245EFC}.Debug|x86.ActiveCfg = Debug|Any CPU
{A9D41C48-7E78-4D75-A814-0C1500245EFC}.Debug|x86.Build.0 = Debug|Any CPU
{A9D41C48-7E78-4D75-A814-0C1500245EFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A9D41C48-7E78-4D75-A814-0C1500245EFC}.Release|Any CPU.Build.0 = Release|Any CPU
{A9D41C48-7E78-4D75-A814-0C1500245EFC}.Release|x64.ActiveCfg = Release|Any CPU
{A9D41C48-7E78-4D75-A814-0C1500245EFC}.Release|x64.Build.0 = Release|Any CPU
{A9D41C48-7E78-4D75-A814-0C1500245EFC}.Release|x86.ActiveCfg = Release|Any CPU
{A9D41C48-7E78-4D75-A814-0C1500245EFC}.Release|x86.Build.0 = Release|Any CPU
{E8C66A46-1F56-484D-8CDD-5C0A3FD84A5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E8C66A46-1F56-484D-8CDD-5C0A3FD84A5F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E8C66A46-1F56-484D-8CDD-5C0A3FD84A5F}.Debug|x64.ActiveCfg = Debug|Any CPU
{E8C66A46-1F56-484D-8CDD-5C0A3FD84A5F}.Debug|x64.Build.0 = Debug|Any CPU
{E8C66A46-1F56-484D-8CDD-5C0A3FD84A5F}.Debug|x86.ActiveCfg = Debug|Any CPU
{E8C66A46-1F56-484D-8CDD-5C0A3FD84A5F}.Debug|x86.Build.0 = Debug|Any CPU
{E8C66A46-1F56-484D-8CDD-5C0A3FD84A5F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E8C66A46-1F56-484D-8CDD-5C0A3FD84A5F}.Release|Any CPU.Build.0 = Release|Any CPU
{E8C66A46-1F56-484D-8CDD-5C0A3FD84A5F}.Release|x64.ActiveCfg = Release|Any CPU
{E8C66A46-1F56-484D-8CDD-5C0A3FD84A5F}.Release|x64.Build.0 = Release|Any CPU
{E8C66A46-1F56-484D-8CDD-5C0A3FD84A5F}.Release|x86.ActiveCfg = Release|Any CPU
{E8C66A46-1F56-484D-8CDD-5C0A3FD84A5F}.Release|x86.Build.0 = Release|Any CPU
{E6560D72-B018-4E00-AEBD-C1B9419E0BC7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E6560D72-B018-4E00-AEBD-C1B9419E0BC7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E6560D72-B018-4E00-AEBD-C1B9419E0BC7}.Debug|x64.ActiveCfg = Debug|Any CPU
{E6560D72-B018-4E00-AEBD-C1B9419E0BC7}.Debug|x64.Build.0 = Debug|Any CPU
{E6560D72-B018-4E00-AEBD-C1B9419E0BC7}.Debug|x86.ActiveCfg = Debug|Any CPU
{E6560D72-B018-4E00-AEBD-C1B9419E0BC7}.Debug|x86.Build.0 = Debug|Any CPU
{E6560D72-B018-4E00-AEBD-C1B9419E0BC7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E6560D72-B018-4E00-AEBD-C1B9419E0BC7}.Release|Any CPU.Build.0 = Release|Any CPU
{E6560D72-B018-4E00-AEBD-C1B9419E0BC7}.Release|x64.ActiveCfg = Release|Any CPU
{E6560D72-B018-4E00-AEBD-C1B9419E0BC7}.Release|x64.Build.0 = Release|Any CPU
{E6560D72-B018-4E00-AEBD-C1B9419E0BC7}.Release|x86.ActiveCfg = Release|Any CPU
{E6560D72-B018-4E00-AEBD-C1B9419E0BC7}.Release|x86.Build.0 = Release|Any CPU
{C0D1BFAC-B1B4-4350-92A7-D537AE382909}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C0D1BFAC-B1B4-4350-92A7-D537AE382909}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C0D1BFAC-B1B4-4350-92A7-D537AE382909}.Debug|x64.ActiveCfg = Debug|Any CPU
{C0D1BFAC-B1B4-4350-92A7-D537AE382909}.Debug|x64.Build.0 = Debug|Any CPU
{C0D1BFAC-B1B4-4350-92A7-D537AE382909}.Debug|x86.ActiveCfg = Debug|Any CPU
{C0D1BFAC-B1B4-4350-92A7-D537AE382909}.Debug|x86.Build.0 = Debug|Any CPU
{C0D1BFAC-B1B4-4350-92A7-D537AE382909}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C0D1BFAC-B1B4-4350-92A7-D537AE382909}.Release|Any CPU.Build.0 = Release|Any CPU
{C0D1BFAC-B1B4-4350-92A7-D537AE382909}.Release|x64.ActiveCfg = Release|Any CPU
{C0D1BFAC-B1B4-4350-92A7-D537AE382909}.Release|x64.Build.0 = Release|Any CPU
{C0D1BFAC-B1B4-4350-92A7-D537AE382909}.Release|x86.ActiveCfg = Release|Any CPU
{C0D1BFAC-B1B4-4350-92A7-D537AE382909}.Release|x86.Build.0 = Release|Any CPU
{F9274D99-3CC6-42B6-9817-EC7F19663B1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F9274D99-3CC6-42B6-9817-EC7F19663B1D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F9274D99-3CC6-42B6-9817-EC7F19663B1D}.Debug|x64.ActiveCfg = Debug|Any CPU
{F9274D99-3CC6-42B6-9817-EC7F19663B1D}.Debug|x64.Build.0 = Debug|Any CPU
{F9274D99-3CC6-42B6-9817-EC7F19663B1D}.Debug|x86.ActiveCfg = Debug|Any CPU
{F9274D99-3CC6-42B6-9817-EC7F19663B1D}.Debug|x86.Build.0 = Debug|Any CPU
{F9274D99-3CC6-42B6-9817-EC7F19663B1D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F9274D99-3CC6-42B6-9817-EC7F19663B1D}.Release|Any CPU.Build.0 = Release|Any CPU
{F9274D99-3CC6-42B6-9817-EC7F19663B1D}.Release|x64.ActiveCfg = Release|Any CPU
{F9274D99-3CC6-42B6-9817-EC7F19663B1D}.Release|x64.Build.0 = Release|Any CPU
{F9274D99-3CC6-42B6-9817-EC7F19663B1D}.Release|x86.ActiveCfg = Release|Any CPU
{F9274D99-3CC6-42B6-9817-EC7F19663B1D}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{08D15A38-B6AB-459B-8BF9-3CCA8555A022} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{51FE01D8-9EEF-46B2-8D8C-3307AC2221C1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{7564E6C2-FF01-4B5B-8C2B-F9CD796858BA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{37363FC6-5AA3-49B4-A3B1-E4AB2CC986EB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{801B1913-6AE1-4B8C-8030-4669AC4FE25E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{F9274D99-3CC6-42B6-9817-EC7F19663B1D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{FF232A59-4E8D-4D56-A2C3-ACC02F5371D0} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{A9D41C48-7E78-4D75-A814-0C1500245EFC} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{E8C66A46-1F56-484D-8CDD-5C0A3FD84A5F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{E6560D72-B018-4E00-AEBD-C1B9419E0BC7} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{C0D1BFAC-B1B4-4350-92A7-D537AE382909} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
{F9274D99-3CC6-42B6-9817-EC7F19663B1D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal

54
EpiphanyAquarium.sln Normal file
View file

@ -0,0 +1,54 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.Epiphany", "src\Aquarium.Epiphany\Aquarium.Epiphany.csproj", "{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.Epiphany.AgentPreview", "src\Aquarium.Epiphany.AgentPreview\Aquarium.Epiphany.AgentPreview.csproj", "{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Debug|Any CPU.Build.0 = Debug|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Debug|x64.ActiveCfg = Debug|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Debug|x64.Build.0 = Debug|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Debug|x86.ActiveCfg = Debug|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Debug|x86.Build.0 = Debug|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Release|Any CPU.ActiveCfg = Release|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Release|Any CPU.Build.0 = Release|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Release|x64.ActiveCfg = Release|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Release|x64.Build.0 = Release|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Release|x86.ActiveCfg = Release|Any CPU
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65}.Release|x86.Build.0 = Release|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Debug|x64.ActiveCfg = Debug|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Debug|x64.Build.0 = Debug|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Debug|x86.ActiveCfg = Debug|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Debug|x86.Build.0 = Debug|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Release|Any CPU.Build.0 = Release|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Release|x64.ActiveCfg = Release|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Release|x64.Build.0 = Release|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Release|x86.ActiveCfg = Release|Any CPU
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{33654E0D-B31A-4062-B6AC-C1CE4C1A1F65} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{F12A367A-8D8E-490D-B60E-3FDBEA3E07E2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

252
README.md
View file

@ -1,248 +1,54 @@
# Fensalir
# Epiphany Aquarium
<p align="center">
<img src="Fensalir-Icon.png" alt="Fensalir icon" width="160" />
</p>
Epiphany Aquarium is the Epiphany-owned client for the Fensalir native runtime.
This repo owns client semantics: agent bodies, camera intent, CultCache state,
CultNet interpretation, voice surfaces, and Epiphany-specific shaders.
Fensalir is the C# native rendering spine for Aquarium clients: windowing,
D3D12 rendering, input, reload, persistent state, debug UI, and packageable
engine machinery.
Epiphany is the first serious client in this repo. It is not the engine's
identity.
## Intent
- Native C# host with a legible renderer spine and explicit ownership.
- Vortice/D3D12 backend with graphics API details contained inside the engine.
- `Aquarium.Engine.Contracts` as the client-facing boundary.
- `Aquarium.Epiphany` as the current Epiphany Aquarium client.
- `Aquarium.Sample.Minimal` as a small non-Epiphany boundary proof.
- DirectWrite/Direct2D overlay text after the scene pass for crisp debug and UI
typography.
- Diegetic scene/UI surfaces driven by client objects instead of admin chrome.
- CultCache for typed settings, persistent client state, and reload recovery.
- CultNet-ready runtime identity for clients that need Epiphany communication.
- Procedural audio and visual state treated as coupled signals.
## Current Shape
The engine opens a Win32 window, owns a D3D12 swapchain, compiles and hot-reloads
HLSL, allocates render targets, preserves last-good shader/runtime state across
failed reloads, hosts the debug overlay, and presents scene-linear HDR through
the current post stack.
Client code is loaded behind `Aquarium.Engine.Contracts`, so a client can reload
while the engine host, window, renderer, and D3D device stay alive. Runtime state
is banked through CultCache MessagePack documents instead of loose JSON.
`Aquarium.Epiphany` currently supplies the Grid-centered camera policy, Epiphany
agent SDF shaders, body placement, CultCache documents, and future CultNet
interpretation. Those are client semantics. The engine consumes them as generic
rendering and runtime data.
Fensalir owns the window, renderer, D3D12 backend, audio device path, hot reload,
debug chrome, contracts, fractal machinery, and engine demos. It lives next to
this repo at `E:\Projects\Fensalir`.
## Repository Shape
This repository intentionally contains both the reusable Aquarium engine and the
Epiphany client while the client API is still being carved into its final shape:
- `src/Aquarium.Epiphany`: the runtime loaded by the Fensalir host.
- `src/Aquarium.Epiphany.AgentPreview`: isolated Epiphany agent preview tool.
- `docs`: Epiphany/client boundary notes.
- `state`: repo memory, map, evidence, and scratch state for the client.
- `scripts`: thin wrappers that delegate host/watch/reload work to Fensalir.
- `src/Aquarium.Engine.Contracts`: Vortice-free client API and shared data
contracts.
- `src/Aquarium.Engine`: Win32 host, D3D12 backend, reload transport, debug UI,
audio host, and engine-owned assets.
- `src/Aquarium.Epiphany`: Epiphany Aquarium runtime, state interpretation,
render-plan configuration, and agent shaders.
- `src/Aquarium.Zyphos`: planetary-scale rendering demo client with its own
runtime, controls, scene state, and SDF planet shaders.
- `src/Aquarium.Sample.Minimal`: tiny non-Epiphany client that proves the host
can run something other than Epiphany.
The client references Fensalir source projects directly while the package
boundary is still young. Do not copy engine contracts back into this repo.
Splitting Aquarium and Epiphany into separate repos is plausible, but not owed
yet. The cut becomes clean when the contracts are stable enough that Epiphany can
move out without taking renderer policy, shader include plumbing, or reload
folklore with it. Until then, the repo split would mostly create paperwork and a
new place for confusion to hide.
## Build
```powershell
dotnet build EpiphanyAquarium.sln
```
## Run
Aquarium currently expects the sibling `CultMath` and `CultLib` repos at
`E:\Projects\CultMath` and `E:\Projects\CultLib`, or the equivalent
`..\..\..\CultMath` / `..\..\..\CultLib` paths from the engine project.
Aquarium consumes `AquaSynth.Faust` from the repo-local `packages` NuGet feed,
not as a live project reference to `AquaSynth`. That package pulls
`AquaSynth.Core` transitively for the built-in patch editor. This keeps Aquarium
builds stable while synth work is in flight. To intentionally update that
boundary, pack a new synth version and bump the `Aquarium.Engine` package
reference:
```powershell
.\scripts\update-synth-package.ps1
```
```powershell
dotnet build Aquarium.Engine.sln
dotnet run --project src\Aquarium.Engine\Aquarium.Engine.csproj -- --client-assembly src\Aquarium.Epiphany\bin\Debug\net10.0\Aquarium.Epiphany.dll
```
Open or build `Aquarium.Engine.sln`. It is the single solution inventory for the
repo.
`src\Aquarium.Sample.Minimal` is a tiny non-Epiphany client used to keep the
engine boundary honest. It references only `Aquarium.Engine.Contracts`, declares
an `AquariumRenderPlan`, and boots through the same host/client loader path.
`src\Aquarium.Zyphos` is the first nontrivial non-Epiphany demo. It renders a
rotating spherical planet, procedural continents, atmospheric rim light,
night-side city glints, and an orbiting moon through the same host, renderer,
reload, UI, bloom, and SDF proxy machinery used by Epiphany.
For iteration while an Aquarium window is already open, use the dev reload
runner instead of `dotnet run`:
```powershell
.\scripts\dev-reload.ps1
```
It builds an apphost executable into a fresh disposable slot under
`artifacts\dev-reload`, records the slot/PID in PowerShell CLIXML, and launches
the runtime with a MessagePack CultCache store at
`artifacts\dev-reload\cultcache\aquarium-client.msgpack`.
The previous script-owned process is stopped only after the replacement build
succeeds. This keeps MSBuild away from the locked normal `bin\Debug` output
while a live window is still open.
The wrapper calls `E:\Projects\Fensalir\scripts\dev-reload.ps1` with
`src\Aquarium.Epiphany\Aquarium.Epiphany.csproj` as the client project.
Run a different client project through the same slot machinery with
`-ClientProject`:
For a headless smoke:
```powershell
.\scripts\dev-reload.ps1 -ClientProject src\Aquarium.Zyphos\Aquarium.Zyphos.csproj
.\scripts\dev-reload.ps1 -Headless -RetainSlots 4
```
If the visible dev window was closed but the last slot is still present, reopen
it without rebuilding:
```powershell
.\scripts\dev-reload.ps1 -Reopen
```
Visible and headless runs keep separate PID state and logs, so a headless smoke
does not replace the visible dev window.
For automatic rebuild/restart on source changes, use the watcher:
For the watcher:
```powershell
.\scripts\dev-watch.ps1
```
Watch a specific client project the same way:
## Boundary
```powershell
.\scripts\dev-watch.ps1 -ClientProject src\Aquarium.Zyphos\Aquarium.Zyphos.csproj
```
The watcher polls source files, waits for writes to settle, builds into a new
slot, and only replaces the running Aquarium after the new build succeeds. If a
build fails, the previous good process keeps running and that same broken source
fingerprint is not retried until files change again.
It also watches the recorded script-owned process. If the visible window is
closed or the recorded process dies, the watcher relaunches from a fresh slot on
the next poll instead of sitting there with its hands folded like this is fine.
To reopen a closed visible window from the last good slot without rebuilding,
run the watcher with:
```powershell
.\scripts\dev-watch.ps1 -ReopenWhenClosed
```
Shader edits do not restart the process. The dev watcher fingerprints
`.hlsl`/`.hlsli` files under both the engine and selected client shader roots,
copies them into the running apphost slot, and lets the renderer rebuild the
active D3D12 pipelines from its current shader source root. A bad shader edit
leaves the previous working shaders bound and writes the compiler failure to
stderr.
Epiphany client/runtime code is split into `Aquarium.Epiphany` behind
`Aquarium.Engine.Contracts`. The host loads that client DLL through a collectible
assembly load context, while `dev-watch.ps1` builds client-only changes into
`artifacts\dev-reload\live-slots` and updates `live-current.txt`. The running
host sees the pointer change, loads and starts the new runtime first, then
disposes the old one only after the replacement is valid. A bad client DLL leaves
the previous runtime alive and reports the reload failure to stderr. The watcher
waits for the host log to acknowledge the exact client DLL path before calling a
client reload successful; pointer updates without host acknowledgement are treated
as reload failures. Successful reloads rehydrate through CultCache without
restarting the window or D3D device.
Host, renderer, contract, project, script, and `src\Aquarium.Engine\Assets`
content changes still rebuild and restart the apphost. Runtime content belongs
to the process image; shader hot reload cannot make a running renderer discover
new files it never loaded.
Runtime live state is a typed CultCache document, not a loose sidecar file. The
first document is `epiphany.aquarium.live_state`, currently banking camera
target, yaw, pitch, distance, time, and save generation every few frames so a
reload can rehydrate without pretending memory is vibes.
Stop the script-owned Aquarium process:
```powershell
.\scripts\dev-stop.ps1
```
Headless smoke:
```powershell
dotnet run --project src\Aquarium.Engine\Aquarium.Engine.csproj -- --headless --client-assembly src\Aquarium.Epiphany\bin\Debug\net10.0\Aquarium.Epiphany.dll
```
Headless reload smoke without touching the normal build output:
```powershell
.\scripts\dev-reload.ps1 -Headless
```
Renderer/client boundary check:
```powershell
.\scripts\verify-boundaries.ps1
```
Headless watch smoke:
```powershell
.\scripts\dev-watch.ps1 -Headless
```
Shipping publish:
```powershell
.\scripts\publish-win-x64.ps1 -Clean -Zip
```
This writes a self-contained Windows build to
`artifacts\publish\win-x64`, verifies the executable, Epiphany client DLL,
contracts DLL, icon, bundled fonts, and shader source, then optionally creates
`artifacts\publish\EpiphanyAquariumEngine-win-x64.zip`.
## Persistent State
This repo carries its own Aquarium memory:
- `state/map.yaml`: canonical project map and next actions.
- `state/memory.json`: durable taste, doctrine, renderer rules, and warnings.
- `state/evidence.jsonl`: compact lessons that should change future behavior.
- `state/scratch.md`: disposable working context for the current pass.
Keep that state current when the engine learns something durable. Dead lessons
go out with the trash; the repo is not a scrapbook.
## Docs
- `docs/aquarium-engine-doctrine.md`: engine doctrine and invariants.
- `docs/engine-client-boundary.md`: Engine/Epiphany ownership split.
- `docs/epiphany-extraction-render-api-plan.md`: migration plan for the client
extraction and ergonomic render graph API.
- `docs/vortice-spine.md`: native host/renderer spine.
- `docs/hlsl-renderer.md`: current shader path.
- `docs/input.md`: camera and input contract.
- `docs/cult-runtime-surface.md`: CultCache/CultNet runtime surface.
Epiphany may depend on Fensalir contracts and renderer services. Fensalir must
not depend on Epiphany policy, role names, CultNet surfaces, or client layout.
If a change needs new engine authority, make it in `E:\Projects\Fensalir` first
and consume the resulting contract here.

View file

@ -1,58 +0,0 @@
# Aquarium Engine Doctrine
Aquarium is a native engine now. The job is not to wrap a dashboard in a nicer
skin; the job is to build a field machine that can host agents, state, sound,
and UI as living objects.
## Frame Contract
- One camera target sits on the global XY plane.
- That target is the Grid center.
- Grid radius is derived from camera zoom distance.
- Orbit changes yaw and pitch. Zoom changes distance. Pan moves the target.
- Body anchors remain in world space.
- The renderer may move the Grid visibility window, but world-space texture and
field domains must stay stable.
## Renderer Contract
- The visible world is shader-owned.
- Traditional geometry is scaffolding only when it helps the renderer stand up.
- Raymarched bodies and Grid terrain must shade from the same field story that
created their hits.
- Broad-phase structures may cull, bin, and skip, but they must remain
conservative. A culling primitive does not get to edit visible geometry.
- Planet-local detail is local to the body domain. Moving a body must move its
surface with it, not scroll the noise through the body.
- Grid texturing and weather sample world-space domains.
- HDR bloom spreads energy before tonemapping. ACES or its successor owns final
display compression.
## Lighting Contract
- Self is the first diegetic emitter.
- Surfaces should never be brighter than the light source unless the transport
story explicitly explains it.
- No ambient or global fill light in the default Aquarium path.
- The long-term lighting target is a field sampled by terrain, bodies,
atmospheres, particles, and diegetic UI.
- Debug modes must expose hit coverage, normals, depth, field occupancy,
lighting energy, and post-stack behavior before beauty work gets clever.
## UI Contract
- Debug overlay is allowed, but it is explicit chrome.
- Overlay text uses DirectWrite and stays out of the HDR scene unless a feature
intentionally projects it into the world.
- Diegetic labels and menus should anchor to world objects and unfold locally.
- Object-owned controls beat global panels.
- Crisp text, focus, keyboard input, and accessibility are real requirements,
not optional little manners.
## State Contract
- Runtime state is CultCache-backed and typed.
- Live reload must rehydrate from cache before pretending it survived.
- Shader reload may fail without killing the last good frame.
- Live assembly reload may fail without killing the last good runtime.
- Persistent state should be small, structured, and useful for future work.

View file

@ -8,7 +8,7 @@ Aquarium uses CultLib directly for runtime identity and reload recovery.
it to a `CultCache`. The dev scripts pass:
```powershell
--cache E:\Projects\Aquarium-Engine\artifacts\dev-reload\cultcache\aquarium-client.msgpack
--cache E:\Projects\Fensalir\artifacts\dev-reload\cultcache\aquarium-client.msgpack
```
Direct runs can also use `--cache <path>` or `AQUARIUM_CULTCACHE_PATH`.

View file

@ -1,60 +0,0 @@
# D3D12 Best Practices Audit
## Verdict
The current D3D12 path is a solid foundation for Aquarium's focused client
renderer. It proves per-frame command allocators, fence-protected reuse,
shader-visible descriptors, persistent mapped constants, explicit render-target
state, brush-rendered Grid height, HDR/bloom presentation, temporal diagnostic
targets, async shader pipeline builds, and a narrow DirectWrite overlay bridge.
## Good Current Decisions
- Command allocators are per swapchain frame and waited on before reset.
- Shader-visible descriptor slots are not overwritten while in flight.
- Constant upload memory is persistently mapped, 256-byte aligned, and allocated
through a per-frame upload ring.
- Offscreen render target state is tracked and redundant transitions are
skipped.
- Backbuffer state flows through the same transition helper style instead of raw
assumed barriers.
- D3D12 objects are named, and command-list events mark the frame for
PIX/graphics diagnostics.
- Shader-visible descriptors are split into static and per-frame transient
arenas. Transient arenas reset only after the owning frame fence clears.
- D3D12 resources are registered by name, and capacity diagnostics report upload,
transient descriptor, static descriptor, and RTV usage.
- The height-field target is a proper brush pass into scalar `R16_Float`.
- Transparent event lanes are not preserved as dormant scaffolding. When Grid
linework, particles, glyph motes, or other coverage events return, they need a
live producer/consumer contract instead of alpha-blended final-frame hacks.
- HDR bloom is pre-tonemap, multi-level, and separable.
- The D3D11On12 bridge is overlay-only, keeping native hinted text without
mixing overlay UI into scene rendering.
## Temporary Acceptable Debt
- Committed resources are used directly. Replace with D3D12MA or placed-resource
suballocation before the renderer owns many textures/buffers.
- Descriptor arenas are simple bump allocators. Add free lists or descriptor
table paging only when real resource churn demands it.
- Static shader and RTV descriptor arenas rebuild wholesale on resize. This is
deliberately simple and correct for swapchain-dependent resources.
- Upload ring is per-frame and reset after fence wait. It is not yet a global
transient allocator with suballocation statistics or overflow diagnostics.
- Removed-pass scaffolding should stay out of the live frame. Runtime
volumetrics, medium/froxel targets, and medium history lanes are shelved.
## Required Before Larger Renderer Work
- Add GPU timestamp queries when frame cost starts mattering beyond CPU timing
diagnostics.
- Add descriptor-owner diagnostics beyond heap-level capacity summaries when
descriptor churn becomes hard to reason about.
- Keep future renderer systems tied to explicit live pass contracts before
adding new render targets or history lanes.
## Rule
Build the resource machine first, then move pixels through it. Dead experiments
belong to Git, not to the live frame graph.

View file

@ -1,48 +0,0 @@
# D3D12 Renderer
D3D12 is the live renderer. The old D3D11 scene backend has been removed; the
only remaining D3D11 use is the narrow D3D11On12 bridge required for native
DirectWrite/Direct2D overlay text.
## Current State
- D3D12 owns the device, command queue, flip-discard swapchain, RTV heap,
per-backbuffer command allocators, command list, fence-backed present loop,
static and transient shader-visible descriptor arenas, upload buffers,
renderer-owned height-field, HDR scene, bloom, temporal diagnostic targets,
named objects, command-list events, and explicit tracked transitions.
- The height-field pass uploads Aquarium frame constants plus client-authored
brush data, renders a base field, then draws additive brush quads into a
128x128 scalar `R16_Float` target.
- The fullscreen scene pass traces the background and height-field surface.
Epiphany bodies render afterward through client-declared SDF proxy pipelines
that share engine-owned proxy mechanics and write into the same scene targets.
- Presentation is scene-linear until the post pass. The scene renders to
`R16G16B16A16_Float`, a three-level bloom pyramid performs firefly-safe
downsample plus separable blur, and final presentation applies exposure,
bloom/veil, and ACES.
- The scene pass writes color/travel, field id/normal metadata, temporal
control, and event targets. The present shader writes ping-pong history and
exposes debug views for raw scene, history, control, identity, bloom, and
exposed luminance.
- DirectWrite and Direct2D remain for crisp overlay text through D3D11On12:
D3D12 renders the frame, then the overlay acquires the swapchain backbuffer,
draws debug UI, releases it to Present, and never participates in world
rendering.
- Shader and PSO creation runs off the main thread. Startup holds the splash
until the first pipeline set is ready. Runtime shader edits under the engine
or Epiphany shader roots are copied into the running apphost slot and trigger a
background rebuild; successful builds swap in after a GPU wait and failures
keep the previous pipeline set.
- Resize waits for the GPU, releases swapchain-dependent resources, rebuilds
descriptor arenas, and recreates backbuffer views plus dependent render
targets.
## Invariants
- `IAquariumRenderer` is the host boundary. The host should not learn renderer
internals as D3D12 grows teeth.
- D3D12 work must be validated by actual headless runs, not just compilation.
- Transparent-looking Grid output is an event lane, not canonical opaque depth.
- DirectWrite overlay text belongs behind the narrow D3D11On12 bridge. Do not
use it for diegetic/world text or let it leak into the scene graph.

View file

@ -1,83 +1,23 @@
# Engine And Client Boundary
# Fensalir Dependency Boundary
Aquarium Engine is not the Epiphany frontend client.
Epiphany Aquarium is a client of Fensalir.
`Aquarium.Engine` owns the native machinery: Win32 windowing, D3D12 device
state, swapchain presentation, resource lifetime, shader compilation, reload
transport, input translation, render target allocation, and future render graph
execution.
This repo owns:
`Aquarium.Engine.Contracts` owns the Vortice-free public boundary clients use to
describe runtime state, render intent, UI, audio patches, and persistent
settings. Contracts may name reusable engine concepts. They may not smuggle
Epiphany policy into the host under a nicer namespace.
- Epiphany agent/body semantics.
- Epiphany shader code.
- Client render-plan configuration.
- CultCache documents for Epiphany Aquarium state.
- CultNet interpretation and voice surfaces.
`Aquarium.Epiphany` owns the client meaning: CultCache runtime state, CultNet
interpretation, camera intent, cursor semantics, agent visual state, diegetic
layout rules, and the render graph configuration that turns Epiphany state into
world pixels.
`E:\Projects\Fensalir` owns:
`Aquarium.Sample.Minimal` exists as the tiny smoke test. `Aquarium.Zyphos`
exists as the first real non-Epiphany demo: a planetary client with its own
runtime, controls, scene state, and SDF shaders. If a feature cannot be
explained without Epiphany or Zyphos, it is not engine API yet.
- Native windowing and process host.
- D3D12 renderer, shader compilation, and render graph execution.
- Input translation, audio output, debug chrome, and live reload.
- `Aquarium.Engine.Contracts`.
- Reusable fractal, field, reservoir, and demo machinery.
## Hard Rules
- Engine code may expose rendering abstractions. It may not know Epiphany roles,
sub-agent identities, CultNet surface semantics, or frontend-specific layout
policy.
- Epiphany code may configure cameras, render targets, shader passes, body
registries, and resource bindings through engine APIs. It may not reach into
D3D12 implementation details.
- Hot reload is transport. It is not architecture. The reload boundary exists so
the client can change without killing the device, not so client policy can
leak into the engine under a nicer hat.
- If a feature name includes `Self`, `Face`, `Imagination`, `Eyes`, `Body`,
`Hands`, `Soul`, `Life`, `CultNet`, or Epiphany state semantics, the default
home is `Aquarium.Epiphany` unless it is a data-only contract.
- If a feature owns descriptors, command lists, fences, barriers, render target
transitions, shader object lifetime, or swapchain presentation, the default
home is `Aquarium.Engine`.
## Next Engine API Shape
The next durable renderer cut should make the client configure a small set of
engine primitives:
- Render targets: format, dimensions, history policy, clear policy, and exported
handles.
- Cameras: view/projection data, previous-frame data, jitter policy, and named
consumers.
- Shader passes: shader module, root bindings, target outputs, depth policy,
dispatch/draw shape, and reload identity.
- Scene resources: structured buffers, textures, samplers, body registries, and
light registries behind typed handles.
- Frame graph edges: explicit read/write dependencies so pass ordering is data,
not hidden renderer folklore.
Do not build this by piling adapters around the current monolith. First expose
the smallest real engine abstraction that lets `Aquarium.Epiphany` describe one
pass without D3D12 details. Then move one client-owned concept through it. Then
repeat.
## Repository Split Line
Aquarium should eventually be usable without carrying the Epiphany client in the
same repository. That does not mean the repo should be split before the boundary
is mechanically clean.
Keep both in this repo while the public contracts, graph declarations, shader
include layout, reload transport, and sample-client path are still changing
together. Split Epiphany out only when:
- `Aquarium.Engine` and `Aquarium.Engine.Contracts` contain no Epiphany role,
CultNet-surface, Grid-policy, or agent-layout semantics.
- A non-Epiphany client can declare cameras, targets, shaders, UI, and
presentation without referencing `Aquarium.Epiphany`.
- Epiphany shaders, state documents, and visual grammar live entirely on the
client side of the contracts.
- The host can load an external client assembly without repo-local path lore.
Until those are true, a repo split would not simplify ownership. It would just
make the same leak harder to see.
The dependency direction is one-way. If Epiphany needs new engine authority,
add that authority in Fensalir and consume it here through contracts. Do not
copy contracts, host scripts, or renderer helpers into this repo.

View file

@ -1,666 +0,0 @@
# Epiphany Extraction And Render API Plan
Aquarium is the engine. Epiphany is one client app.
The current solution has the first physical boundary in place:
- `Aquarium.Engine` owns the host, D3D12 renderer, input loop, shader reload,
debug overlay, and client runtime loader.
- `Aquarium.Engine.Contracts` is the shared API surface between host and client.
- `Aquarium.Epiphany` owns the current runtime state, camera rig, CultCache
documents, and client factory.
That split is real but shallow. The engine still carries Epiphany-shaped world
knowledge inside `D3D12Renderer`: fixed role counts, Self/cursor constants,
agent shader paths, body light setup, Grid brush generation, orbit fallback
motion, and the body visual buffer layout. The client can reload, but it cannot
yet describe what gets rendered. The renderer remains a monolith with a client
mask taped to the front. Rude, but accurate.
## Design Target
Client authors should be able to describe a frame in C# using named engine
abstractions:
- render targets with formats, size policies, clears, history, and persistence
- cameras with projection, view, previous-frame state, jitter, and named use
- shaders with hot reload, includes, entry points, root bindings, and defaults
- passes that declare reads, writes, depth, viewport, draw shape, and diagnostics
- structured buffers, constants, textures, samplers, cubemaps, and body registries
- standard features such as bloom, tonemapping, overlay, IBL, resize handling,
shader reload, transient descriptors, barriers, and debug views
Aquarium should make the ordinary path boring:
```csharp
public sealed class EpiphanyClient : AquariumClient
{
protected override void Configure(AquariumApp app)
{
var mainCamera = app.Cameras.Orbit("main")
.LookAtGrid()
.WithHistory();
var gridHeight = app.RenderTargets.Create("grid-height")
.FixedSize(128, 128)
.Format(RenderFormat.R16Float)
.Clear(0.0f);
var scene = app.RenderTargets.Hdr("scene")
.MatchWindow()
.WithHistory();
app.Graph.Pass("grid-height")
.Fullscreen("Shaders/GridHeight.hlsl", "GridHeightPS")
.Write(gridHeight)
.BindFrame()
.Bind("GridBrushes", GridBrushes);
app.Graph.Pass("scene")
.Fullscreen("Shaders/Scene.hlsl", "ScenePS")
.Read(gridHeight)
.Write(scene.Color, scene.Metadata, scene.Control)
.Depth(scene.Depth)
.UseCamera(mainCamera)
.Bind("BodyLights", BodyLights);
app.Features.Bloom(scene.Color);
app.Features.Tonemap(scene.Color).Present();
app.Features.DirectWriteOverlay();
}
}
```
That is the level of API Aquarium should expose: explicit enough to be durable,
ergonomic enough that nobody has to learn descriptor heaps before rendering a
flower. Descriptor heaps are Aquarium's problem. If the client author asks for a
sharp object, the engine hands them a handle, not the knife factory.
## Literature Anchor
This plan follows the render graph pattern used in production engines:
- Unity describes render graphs as high-level render-pass and resource
declarations that simplify pipeline configuration and let the system manage
resources efficiently: https://docs.unity.cn/Packages/com.unity.render-pipelines.core%4014.0/manual/render-graph-system.html
- Unreal's Rendering Dependency Graph defers execution until all passes are
gathered, then uses whole-frame knowledge for scheduling, barriers, memory,
async compute, and validation:
https://dev.epicgames.com/documentation/ru-ru/unreal-engine/rendering-dependency-graph?application_version=4.27
- Frostbite's FrameGraph talk frames the same problem as a graph of passes and
resources that keeps rendering features modular without giving up efficiency:
https://www.gdcvault.com/play/1024612/FrameGraph-Extensible-RenderingArc
- Granite's Vulkan render graph writeup is useful implementation pressure:
declare reads and writes, validate, traverse dependencies, assign logical
resources to physical resources, generate barriers, alias compatible
transient targets, then execute:
https://themaister.net/blog/2017/08/15/render-graphs-and-vulkan-a-deep-dive/
The Aquarium version should start smaller than those engines. No async compute,
no memory aliasing, no multipass merging in the first cut. First: client-owned
declarations, engine-owned execution.
## Current Coupling Inventory
These are the seams to cut.
### Shared Contracts
`Aquarium.Engine.Contracts` currently exposes:
- `IAquariumRuntime`
- `IAquariumRuntimeFactory`
- `AquariumRuntimeOptions`
- `AquariumFrame`
- `GraphicsSettings`
- `GridFrame`
- input state
`AquariumFrame` is too specific. It assumes one Grid, one camera position, one
cursor, one time scalar, and a renderer that knows what those mean.
### Host
`AquariumHost` owns:
- client assembly load and reload pointer
- window creation
- renderer construction
- debug input routing
- settings synchronization
- mouse projection onto the Grid plane
The mouse projection is client policy. The engine may provide ray utilities,
but `ProjectMouseToGridPlane` belongs in the client or in a reusable camera
helper chosen by the client.
### Renderer
`D3D12Renderer` owns both engine machinery and Epiphany scene policy:
- engine machinery: device, swapchain, fences, command list, descriptor arenas,
upload ring, shader compilation, render targets, overlays, resize, resource
transitions, pipeline state
- Epiphany policy: Grid height target, Self gravity brush, role agent count,
fixed agent body shader list, cursor hibiscus buffer slot, body lights, orbit
fallback positions, Grid placement math, agent state scalars, debug mode names
The renderer should retain the machinery and lose the policy.
### Shader Assets
Engine shader assets currently include Epiphany bodies:
- `D3D12SelfBody.hlsl`
- `D3D12FaceAgent.hlsl`
- `D3D12ImaginationAgent.hlsl`
- `D3D12EyesAgent.hlsl`
- `D3D12BodyAgent.hlsl`
- `D3D12HandsAgent.hlsl`
- `D3D12SoulAgent.hlsl`
- `D3D12LifeAgent.hlsl`
- `D3D12CursorBody.hlsl`
- `D3D12AgentCharacters.hlsli`
Those should move under the Epiphany client shader root once the engine can load
client-declared shaders. Engine-owned includes should remain only when they are
generic: SDF primitives, body proxy mechanics, PBR lighting, fullscreen vertex
helpers, packing helpers, and graph binding conventions.
## Target Project Shape
First stable shape inside this repo:
```text
src/
Aquarium.Engine.Contracts/ public client-facing API
Aquarium.Engine/ host plus D3D12 backend implementation
Aquarium.Epiphany/ Epiphany client app/runtime
```
Longer-term package shape:
```text
src/
Aquarium.Engine.Abstractions/ public API, no Vortice dependency
Aquarium.Engine.D3D12/ D3D12 backend
Aquarium.Engine.Host/ default Win32 host executable
Aquarium.Epiphany/ client app, eventually movable out of repo
```
Do not split projects before the API shape earns it. Premature project shrapnel
is not architecture. It is paperwork with delusions.
## Public API Model
### Client Entry
Replace `IAquariumRuntime.Frame` with a richer client lifecycle:
```csharp
public interface IAquariumClient : IDisposable
{
void Configure(AquariumAppBuilder app);
void Load(AquariumLoadContext load);
void Update(AquariumUpdateContext update);
void Render(AquariumRenderContext render);
void FlushState();
}
```
`Configure` declares stable graph shape. `Update` mutates CPU state. `Render`
uploads per-frame data and selects dynamic pass instances. The engine compiles
and executes the graph.
Keep a compatibility adapter from the old `IAquariumRuntime` while migrating,
then remove it when Epiphany is fully on the new lifecycle.
### Handles
Expose typed handles, not D3D12 objects:
```csharp
public readonly record struct RenderTargetHandle(string Name);
public readonly record struct DepthTargetHandle(string Name);
public readonly record struct CameraHandle(string Name);
public readonly record struct ShaderHandle(string Name);
public readonly record struct BufferHandle<T>(string Name) where T : unmanaged;
public readonly record struct TextureHandle(string Name);
public readonly record struct PassHandle(string Name);
```
Names are diagnostic identity. The engine maps them to physical resources.
### Render Targets
Render target declarations should cover the common case without ceremony:
```csharp
app.RenderTargets.Create("scene")
.MatchWindow(scale: 1.0f)
.Format(RenderFormat.Rgba16Float)
.Clear(Color.Black)
.Sampled()
.History(frames: 2);
app.RenderTargets.Create("grid-height")
.FixedSize(128, 128)
.Format(RenderFormat.R16Float)
.Clear(0.0f)
.Sampled();
```
Engine responsibilities:
- allocate and resize physical resources
- allocate RTV, DSV, SRV, UAV descriptors
- create transient descriptors per frame
- preserve history targets
- validate format and usage combinations
- transition states
- expose debug names and graph viewer data
### Cameras
The engine should provide camera math utilities and history storage. The client
chooses semantics:
```csharp
var camera = app.Cameras.Perspective("main")
.Position(() => CameraRig.Position)
.LookAt(() => CameraRig.Target3)
.VerticalFovDegrees(58)
.NearFar(0.05f, 500.0f)
.WithPreviousFrame()
.WithMouseRays();
```
Engine-owned:
- view/projection matrices
- inverse matrices
- viewport transforms
- previous-frame storage
- jitter policy
- screen ray generation helpers
Client-owned:
- what the camera tracks
- Grid center
- orbit controls
- mouse-to-world projection policy
### Shaders And Includes
Shader declarations should hide D3DCompile details but expose the real contract:
```csharp
var sceneShader = app.Shaders.Hlsl("scene")
.File("Shaders/Scene.hlsl")
.Vertex("FullscreenTriangleVS")
.Pixel("ScenePS")
.IncludeDirectory("Shaders/Includes")
.HotReload();
```
Engine-owned features:
- include expansion
- compile diagnostics
- previous-good shader retention
- pipeline rebuild in the background
- root binding validation
- standard include library
Client-owned:
- shader source files
- shader pass declarations
- object-specific SDFs and materials
### Passes
Passes should declare resource use explicitly:
```csharp
app.Graph.Pass("bloom-prefilter")
.Fullscreen(sceneShader)
.Read("scene")
.Write("bloom-0")
.BindFrame()
.When(features => features.BloomEnabled);
```
Initial pass types:
- fullscreen graphics pass
- proxy rectangle graphics pass
- instanced graphics pass
- compute pass
- copy/blit pass
- present pass
- overlay pass
Initial graph compiler:
- validate resource existence
- validate acyclic dependencies
- topologically sort by declared reads/writes
- emit D3D12 transitions
- allocate transient frame descriptors
- rebuild on graph, shader, target, or window changes
Later graph compiler:
- transient target aliasing
- pass culling
- async compute
- graph visualization
- queue scheduling
## It-Just-Works Feature Layer
The public API should include feature builders over the low-level graph. They
generate normal graph declarations, not secret renderer code.
### Presentation
```csharp
app.Features.Presentation("default")
.From(scene.Color)
.Exposure(settings.SceneExposure)
.Bloom(settings.Bloom)
.Tonemap(Tonemap.AcesFitted)
.Present();
```
### Bloom
```csharp
app.Features.Bloom(scene.Color)
.Levels(3)
.FireflySafe()
.PreTonemap();
```
### IBL
```csharp
app.Features.ImageBasedLighting("studio")
.Pmrem("Textures/studio2_pmrem.dds")
.Irradiance("Textures/studio2_irradiance.dds")
.BindAs("StudioIbl");
```
### SDF Bodies
```csharp
app.Features.SdfBodies("agents")
.Buffer<AgentVisualGpu>("AgentVisuals", maxCount: 64)
.SharedInclude("Shaders/D3D12SdfProxy.hlsli")
.Role("Self", "Shaders/SelfBody.hlsl")
.Role("Imagination", "Shaders/ImaginationBody.hlsl")
.DepthAgainst(scene.Depth)
.Write(scene.Color, scene.Metadata, scene.Control);
```
The engine supplies proxy drawing, bounds, depth writes, common lighting, and
debug counters. The client supplies the body list and shaders.
### Debug
The debug layer should read graph metadata:
- pass list and timing
- target dimensions, formats, and history state
- shader compile status
- descriptor arena usage
- upload ring usage
- selected resource visualizers
- graph validation warnings
Debug views should be registered, not hardcoded:
```csharp
app.Debug.Views
.Add("Final", scene.Color)
.Add("Bloom", bloom.Output)
.Add("Agent Steps", scene.Metadata.Channel("steps"));
```
## Migration Plan
### Phase 0: Freeze The Boundary
Goal: prevent new leaks while the old renderer still exists.
Tasks:
- Keep `docs/engine-client-boundary.md` authoritative.
- Add an architectural test or script check that `src/Aquarium.Engine` contains
no `Aquarium.Epiphany` references.
- Add a second check that engine shader names do not include Epiphany role names
once shaders move.
- Mark current fixed renderer path as legacy in docs, not as permanent API.
Exit criteria:
- Boundary checks run in local verification.
- New work has an obvious place to live.
### Phase 1: Rename Contracts Into Abstractions
Goal: turn `Aquarium.Engine.Contracts` into an actual engine API surface.
Tasks:
- Introduce `AquariumAppBuilder`, `AquariumClient`, graph handles, resource
descriptors, camera descriptors, shader descriptors, pass descriptors, and
feature descriptors.
- Keep old `IAquariumRuntime` beside the new API temporarily.
- Add XML docs or README examples for the common path.
- Keep all contracts Vortice-free.
Exit criteria:
- A tiny sample client can declare one target, one fullscreen shader, and a
presentation pass without referencing `Aquarium.Engine.Render` or Vortice.
### Phase 2: Build The Minimal Graph Compiler Inside Engine
Goal: execute client-declared resources and passes without replacing the whole
renderer at once.
Tasks:
- Add internal `RenderGraph`, `RenderGraphCompiler`, `CompiledRenderGraph`, and
`RenderGraphExecutor`.
- Map public descriptors to internal D3D12 resources.
- Support fullscreen graphics passes first.
- Support named render target reads and writes.
- Support one depth target.
- Generate transitions from declared pass use.
- Keep shader hot reload and previous-good pipeline behavior.
- Add graph validation errors with pass/resource names.
Exit criteria:
- Existing scene, bloom, and present path can be represented as graph passes,
even if some callbacks still call existing private methods.
### Phase 3: Convert Presentation And Bloom To Features
Goal: move generic post-processing out of bespoke renderer flow.
Tasks:
- Express HDR scene target, bloom pyramid, history targets, and present pass as
graph resources.
- Implement `app.Features.Bloom`.
- Implement `app.Features.Presentation`.
- Move render debug mode registration into graph/debug descriptors.
- Keep DirectWrite overlay as an engine feature.
Exit criteria:
- A non-Epiphany sample can render a color target, bloom it, tonemap it, and
present without defining Grid, agents, cursor, or CultNet.
### Phase 4: Move Epiphany Shaders And Body Policy To Client
Goal: remove role bodies and cursor semantics from engine source.
Tasks:
- Move role shader files and `D3D12AgentCharacters.hlsli` into
`src/Aquarium.Epiphany/Shaders`.
- Keep generic includes in engine:
- fullscreen helpers
- SDF primitives
- PBR lighting
- body proxy mechanics
- packing and frame constants
- Replace fixed `RoleAgentCount`, fixed shader path arrays, and fixed
`AgentVisualGpu` ownership with a client-declared SDF body feature.
- Move `BuildAgentVisualTable`, `BuildBodyLightTable`, `BuildGridHeightBrushes`,
orbit fallback positions, cursor radius, and Self constants into
`Aquarium.Epiphany`.
- Let Epiphany upload buffers by handle.
Exit criteria:
- `src/Aquarium.Engine` contains no role names, no cursor hibiscus, no Self
constants, no agent orbit fallback, and no Epiphany shader paths.
### Phase 5: Move Grid Policy To Epiphany Or A Reusable Feature
Goal: decide whether Grid is a generic Aquarium feature or Epiphany client
policy.
Recommended split:
- Engine feature: height-field target, brush rendering primitive, reflective
surface shader helper, debug visualization.
- Epiphany policy: Grid center, radius rule, Self gravity bowl, role gravity
wells, radial wave parameters, body placement rule.
Tasks:
- Expose `app.Features.HeightField`.
- Expose CPU helper for evaluating brush stacks when the client asks for it.
- Move current brush stack construction into Epiphany.
- Move mouse-to-Grid projection out of `AquariumHost`.
Exit criteria:
- A different client can choose no Grid, a different height field, or a static
render target without touching engine code.
### Phase 6: Replace Frame Contract
Goal: stop `AquariumFrame` from being the rendering protocol.
Tasks:
- Introduce `AquariumFrameContext` for time, window size, input, graph resource
access, frame constants, and upload helpers.
- Let clients define their own frame constants structs.
- Keep engine standard constants optional:
- resolution
- time
- camera matrices
- previous camera matrices
- presentation settings
- Provide `Frame.UploadConstant<T>`, `Frame.UploadBuffer<T>`, and
`Frame.SetGraphValue`.
- Remove renderer dependence on `AquariumFrame`.
Exit criteria:
- `IAquariumRenderer.Render` consumes a compiled graph frame, not an Epiphany
shaped frame record.
### Phase 7: Split Host From Backend When Earned
Goal: make Aquarium usable as an engine package, not only an executable.
Tasks:
- Split public abstractions from D3D12 backend if the contract has stabilized.
- Keep Win32 host as the default app host.
- Let clients choose hosted mode:
- engine-owned window
- headless
- later embedded/native handle mode
- Package shader include library and default features as engine assets.
Exit criteria:
- A fresh client can reference Aquarium abstractions, implement a client class,
run through the host, and declare render graph resources without copying
Epiphany code.
## Order Of Attack
Do not start by moving every file. That produces thrilling amounts of rubble.
Recommended first implementation slice:
1. Add public render target, shader, pass, camera, and feature descriptor types
to contracts.
2. Add a graph builder that can describe the current fixed pipeline without
executing it.
3. Add a debug dump of the declared graph.
4. Convert bloom and presentation to execute from graph declarations.
5. Convert body proxy draws to a client-declared body registry.
6. Move Epiphany shaders out of engine.
7. Move Grid and agent/cursor frame construction out of renderer.
Each slice must preserve:
- shader reload keeps last good pipelines
- failed client reload keeps previous client alive
- resize rebuilds all size-bound targets
- debug overlay remains readable
- headless smoke still completes
- engine source stays free of Epiphany references
## Verification Gates
Every migration pass should run:
```powershell
dotnet build Aquarium.Engine.sln --no-restore
.\scripts\dev-reload.ps1 -Headless -RetainSlots 4
.\scripts\verify-boundaries.ps1
```
The final `rg` should shrink over time. At the end it should be empty except
for generic documentation comments if any survive, and they probably should not.
## Non-Goals For The First Graph
- no async compute
- no transient memory aliasing
- no general material system
- no visual node editor
- no automatic shader reflection dependency on day one
- no project split until the API stops moving under our feet
- no "compatibility mode" escape hatch that lets client code call D3D12 directly
The first graph exists to establish ownership and data flow. Optimization comes
after the graph tells the truth.
## Success Definition
Aquarium is clean when:
- Engine code can render a non-Epiphany sample client.
- Epiphany can add a render target, camera, shader, buffer, and pass without
editing `D3D12Renderer`.
- Epiphany shaders live with Epiphany.
- Engine shader includes are generic.
- Resource lifetime, descriptors, barriers, resize, hot reload, presentation,
and debug inspection are engine-owned.
- Client code describes intent. Engine code makes it work.

View file

@ -1,117 +0,0 @@
# HLSL Renderer
Aquarium's visible world is currently a D3D12 HLSL renderer with a small,
explicit frame graph.
## Current Path
1. Engine-owned `D3D12HeightField.hlsl` renders a 128x128 scalar height-field
target from client-authored brush data.
2. Engine-owned `D3D12Scene.hlsl` traces the background and Grid into
scene-linear HDR targets.
3. Epiphany-owned body shaders each get their own proxy pipeline:
`D3D12FaceAgent.hlsl`, `D3D12ImaginationAgent.hlsl`,
`D3D12EyesAgent.hlsl`, `D3D12BodyAgent.hlsl`, `D3D12HandsAgent.hlsl`,
`D3D12SoulAgent.hlsl`, `D3D12LifeAgent.hlsl`, `D3D12SelfBody.hlsl`, and
`D3D12CursorBody.hlsl`. Shared proxy mechanics live in the engine includes
`D3D12SdfCommon.hlsli`, `D3D12SdfProxy.hlsli`, and `D3D12SdfMath.hlsli`.
4. Engine-owned `D3D12Post.hlsl` builds bloom, resolves diagnostics/history, applies
exposure and ACES, then presents.
5. `DirectWriteOverlay` draws crisp final-pixel debug UI after scene rendering.
C# feeds explicit frame constants: resolution, time, Grid radius, camera
position, presentation controls, previous frame state, cursor anchors, and debug
mode. CultMath is the CPU-side math grammar. Vortice math stays at the graphics
API membrane.
## Grid
The Grid height target is centered on the camera target; body anchors remain in
world space. The target stores height in `.r` only. A future useful expansion is
packed gradients in `.g/.b` if normal cost becomes worth the extra bandwidth.
Grid linework is derivative-aware in the final scene pass. Cartesian lines use
screen-space `fwidth` against their world-domain coordinate so minor and major
lines stay the same pixel width across zoom distances. Terrain isolines derive
from the Grid height field, while field lines quantize terrain gradient angle
and fade out on flat surfaces.
The fullscreen scene pass owns environment and Grid only. Bodies are not folded
into that pass. After the Grid writes scene color and travel-derived depth,
each body gets its own proxy draw and shader pipeline. The shared proxy vertex
shader computes a conservative screen rectangle from the uploaded body center
and bound radius. The body-specific pixel shader raymarches only that object
and writes travel-derived depth so the Grid and nearer bodies arbitrate
visibility through the depth target.
## Solids
Epiphany uploads Self, Face, Imagination, Eyes, Body, Hands, Soul, Life, and the
cursor through the engine `AquariumSdfObject` buffer, including current center,
previous center, and state scalars. Aquarium owns the D3D12 upload, proxy draw,
depth, and presentation machinery; Epiphany owns which bodies exist, where they
are, and which shader files describe them.
The cursor is a luminous three-lobed hibiscus SDF that lives on the XY plane:
its center is one cursor radius above XY, so its lower contact tip lands at
Grid z=0 instead of sampling the Grid height field. The previous MathWorld
teardrop formula remains in `D3D12SdfMath.hlsli` for later reuse. Agent visuals
carry their previous centers in the structured buffer for temporal reprojection.
Lighting is currently direct and diegetic: Self is the emitter. No ambient fill
is allowed to quietly solve a bad light story.
## Temporal Diagnostics
The temporal resolver is documented in `docs/tsr-inspired-taa-spec.md`. The live
frame keeps color, metadata, and control history. Projection jitter uses a small
Halton sequence, and final presentation blends validated history. SDF object
highlights get a narrow hot-current history path so transient specular spikes
can be damped without relaxing travel, field, or normal validation. Bloom is
clamped at prefilter for unsupported single-pixel HDR spikes, then attenuated
when the resolved scene has damped a much hotter current sample, so transient
fireflies do not keep blooming through the current-frame bloom path.
Dormant event lanes are not preserved without a producer and pass contract.
Debug modes:
- `0` final
- `1` raw current scene
- `2` reprojected history sample
- `3` history age
- `4` history weight
- `5` coverage and step ratio
- `6` current field identity
- `7` bloom contribution
- `8` exposed luminance
- `9` agent identity
- `10` agent SDF step count
`F1` cycles modes. Number keys select the first modes directly. Startup mode can
be set with `--render-debug` or `AQUARIUM_RENDER_DEBUG_MODE`.
## HDR
Presentation applies explicit exposure before display transformation and adds a
low-gain pre-tonemap bloom/veil pyramid. The bloom pass renders exposed
scene-linear HDR color into half-, quarter-, and eighth-resolution targets, uses
firefly-safe downsampling, blurs each level with separable horizontal/vertical
passes, then contributes gently before the ACES fit. This is not threshold glow.
## Overlay Text
Readable overlay text is handled by `DirectWriteOverlay`, using DirectWrite for
font layout/rasterization and Direct2D for final draw. D3D12 reaches that path
through D3D11On12 only after the scene frame is rendered. The bridge is
overlay-only.
The debug controls live in `Render/Ui/DebugUi.cs`. The grammar is a native
version of CultLib's generator API: panels, sections, sliders, toggles, options,
and buttons are declared in code and bound to engine state with explicit read
and write delegates. The visual language is compact, flat, and allowed to have
taste. Frighteningly controversial, apparently.
The overlay owns a private DirectWrite font collection built from bundled Google
Fonts files in `Assets/Fonts`: Montserrat for thin small-cap display text and
Ubuntu Sans for body/debug copy. Runtime must not depend on system-installed
fonts.

View file

@ -1,21 +0,0 @@
# Input
Input is currently Win32-backed and intentionally small.
## Flow
- `Win32Window` translates window messages into `InputState`.
- `AquariumHost` begins each input frame, pumps messages, then updates runtime.
- `AquariumRuntime` passes input into `OrbitCameraRig`.
- The renderer receives the updated camera and Grid frame as constants.
## Controls
- Middle mouse drag: orbit camera around the Grid center.
- Mouse wheel: exponential zoom.
- Right mouse drag: pan along the Grid plane.
- `WASD`: pan along the Grid plane.
The camera target remains the Grid center. Panning moves that shared target;
orbiting changes only yaw/pitch; zoom changes only camera distance and therefore
Grid radius.

View file

@ -1,683 +0,0 @@
# Perfect Machine Architecture
## Objective
Build one real-time spatial evidence machine that can render and resolve:
- fast 2D scalar-field splats on tileable surfaces;
- fast 3D scalar-field splats in object/world volumes;
- SDF/level-set surfaces for opaque solids;
- density/extinction fields for flames, smoke, fog, plasma, and uncertain
sensor volumes;
- 2D fields projected onto 3D domains such as cube-sphere planets and toroidal
station decks;
- camera/microphone sensor evidence from Mimir-style capture;
- cached structural probes emitted by IFS `.aquageo` grammars;
- stochastic updates that converge under CPU, GPU, RAM, and SSD budgets.
The product requirement is not "infinite detail." It is a compact authored
description that can put detail where pixels can see it, keep enough evidence
alive to stabilize it, and refuse work that cannot justify its cost.
## Research Spine
This architecture is not invented from the floorboards:
- ReSTIR DI and GI prove reservoir reuse as a real-time sampling strategy for
expensive candidates across time and screen space.
- GRIS generalizes reservoir resampling beyond direct light candidates through
target functions, correlated samples, domains, and shift mappings.
- EWA surface/volume splatting and 3D Gaussian splatting prove anisotropic
projected support as a practical rendering primitive.
- Geometry clipmaps and Nanite-style virtualized geometry prove that visible
projected error and resident summaries beat global leaf traversal.
- TSDF fusion/KinectFusion prove that streamed sensor samples can accumulate
into a coherent implicit spatial field.
- Instant-NGP's multiresolution hash encoding is a warning and inspiration:
stochastic evidence can be fast when the data layout is explicit and GPU
friendly, but Aquarium should preserve deterministic summaries and bounds as
safety authority.
## Prime Invariants
- Authored semantic domains are the source of truth.
- Backend packets are compiled output and may be deleted/rebuilt.
- Every domain, node, claim, probe, payload, page, and reservoir has a stable
key.
- Every reusable sample must name its target function and source PDF or state
why it is non-probabilistic structural evidence.
- Every LOD subtree has a conservative parent summary.
- Learned/stochastic priority may rank work; it may not own field safety,
visibility safety, or calibration safety.
- Missing children render through parent summaries. A frame does not wait for
SSD.
- TAA owns pixel history, not producer identity.
- Mimir sensor fusion and fractal rendering share the evidence machinery; they
do not share client policy.
## System Pipeline
```text
Authored intent or live sensor input
-> Domain binding
-> Evidence candidate generation
-> Target evaluation
-> Resampled importance reservoir
-> Temporal reuse validation
-> Spatial/domain reuse validation
-> Occupancy graph update
-> Residency/page scheduling
-> Backend packet lowering
-> 2D/3D/projected field splat passes
-> Temporal resolve guide buffers
-> Debug/evidence telemetry
```
The machine is allowed to have multiple candidate producers. The reservoir
contract makes them comparable without pretending they are the same thing.
## Module Network
```text
Aquarium.Engine.Contracts
Stable DTOs: domains, claims, nodes, summaries, probes, reservoir guides,
payload pages, backend packets, debug rows.
Aquarium.Engine.Fractal
Pure CPU algorithms: domain math, grammar expansion, ownership trees,
summaries, scoring, reservoirs, occupancy graph updates, residency planning,
packet planning.
Aquarium.Engine.SensorFusion
Future shared adapter layer: camera/audio feature candidates, calibration
confidence, raw retention lowerings, Mimir-facing packet contracts.
Aquarium.Engine.Render
D3D12 resources, page tables, structured buffers, field splat passes,
surface/volume resolves, TAA guide buffers, debug visualization.
Aquarium.Zyphos
World policy, cube-sphere/tile roots, planet grammar seeds, setting-safe
visuals.
Aquarium.Epiphany
Agent/body policy, role grammar selection, semantic bindings.
Mimir/LocalCast
Sensor capture, raw rolling retention, feature extraction, calibration facts.
It does not own resolved spatial evidence history.
```
## Data Model
### Domain
```text
DomainKey
ParentKey
Kind: solar/orbital/planet/cubeTile/torus/surface2d/object3d/volume3d/sensorRig
Frame
Projection
Bounds
Periodicity
Owner
```
Domains say where coordinates live and how they map to parent space. A domain is
allowed to be 2D, 3D, or 2D projected onto 3D.
### Claim
```text
ClaimKey
DomainKey
NodeKey
Layer: form/appearance/transport
Kind: height/sdf2d/sdf3d/density/extinction/material/phase/emission/light/feature/confidence
LocalFrame
Envelope
Payload
Tags
CostTier
Seed
```
Claims are authored or inferred statements about a field. They are not renderer
packets.
### Field Layer Split
The old shorthand was `SDF / PBR / Radiosity`. That naming was useful for the
first opaque-object path, but it is not the architecture.
The durable split is:
```text
Form -> what exists where
Appearance -> how it interacts locally with light, color, and material
Transport -> how energy moves through or from it
```
Opaque solids lower through level-set form:
```text
phi(x) = signed distance
gradient(phi) = surface normal
surface exists near phi = 0
```
Transparent media lower through density/extinction form:
```text
rho(x) = density / extinction / confidence
gradient(rho) = direction of local density change
volume exists where rho contributes above threshold
```
Sensor fusion may begin as confidence density and sharpen into surface claims
when multi-view/acoustic evidence makes a stable boundary plausible. Fractal
terrain may begin as SDF/height surfaces. Flames stay participating fields:
their form is density/extinction, their appearance is emission/color/phase, and
their transport is emission/scattering reuse.
Implementation note: current packet names still say SDF/PBR/radiosity in places
because the first GPU receipt was built for opaque splats. Treat those as
backend packet names, not the conceptual contract.
### Probe
```text
ProbeKey
DomainKey
LocalPosition
LocalNormalOrGradient
BoundRadius
PayloadHandle
TargetContribution
SourcePdf
Confidence
ObservedFrame
ObservedTime
ProducerKind
FieldLayer
FieldEncoding
```
A structural probe can come from an IFS grammar. A sensor probe can come from a
camera/audio feature. Both become candidates only after they have a target
function and validation rules.
### Reservoir
```text
SelectedSample
SelectedTarget
WeightSum
CandidateCount
ContributionWeight
ValidationMask
SampleAge
DomainKey
```
The reservoir owns resampling math. It does not own stable tracks, packet
lowering, or TAA history.
### Occupancy Graph
```text
NodeKey
DomainKey
Bounds
SummaryPayload
ChildPayload
MeanContribution
Variance
Confidence
SampleAge
ResidencyState
LastVisibleFrame
LastUpdateFrame
```
The occupancy graph is the shared heuristic memory of where useful field
evidence likely lives. It is structural enough for IFS trees and statistical
enough for sensor fusion.
### Page
```text
PageKey
PayloadKind
ByteRangeOrHandle
ResidentTier: gpu/ram/ssd/missing
EstimatedGpuCost
EstimatedRamBytes
EstimatedSsdBytes
LastUseFrame
```
Pages are storage decisions, not semantic identity.
## Algorithms
### 1. Domain Binding
Every candidate is first bound to a domain path. Cube-sphere terrain binds to a
face/tile path. Torus surfaces bind to periodic `u/v` patches. Agent details
bind to object-local sheets, curves, or volumes. Sensor features bind to a
sensor rig frame and then to a calibrated world or object domain.
Validation starts here. If two samples do not share a valid lineage or shift
mapping, they do not reuse each other.
### 2. IFS Grammar Expansion
The `.aquageo` DSL emits semantic claims and ownership nodes:
```text
grammar -> domains -> claims -> ownership tree -> summaries
```
Expansion is deterministic by stable key and seed. Generated leaves do not all
need to be resident. The ownership tree must summarize itself before it earns
LOD rights.
### 3. Sensor Candidate Lowering
Camera and microphone producers retain raw capture ordering, calibration, and
provenance. Aquarium receives resolved candidate observations:
```text
camera/audio feature -> calibrated local/world candidate -> target evaluator
```
Sensor adapters own modality interpretation. Aquarium owns spatial evidence
history after lowering.
### 4. Target Evaluation
Targets are comparable scalar contributions:
- projected SDF/form error;
- density/extinction contribution;
- appearance delta over visible coverage;
- transport/radiance contribution;
- feature confidence times calibration confidence;
- expected pixel influence;
- expected information gain for an uncertain region.
The source PDF is the probability by which the candidate was proposed. If the
candidate is deterministic structural evidence, it uses an explicit proposal
policy rather than a magical free sample.
### 5. Reservoir Update
Each reservoir stores one representative sample plus the weight mass of the
candidates it represents:
```text
candidateWeight = target / sourcePdf
weightSum += candidateWeight
candidateCount += representedCount
select candidate with probability candidateWeight / weightSum
```
Final contribution weight follows the RIS/ReSTIR shape:
```text
weightSum / (candidateCount * selectedTarget)
```
Temporal reuse and spatial/domain reuse are explicit passes, not side effects.
### 6. Reuse Validation
Temporal reuse validates:
- camera motion/reprojection;
- previous/current domain lineage;
- disocclusion;
- field id/material class;
- local-frame error;
- conservative bounds;
- sample age and confidence.
Spatial/domain reuse validates:
- neighbor surface compatibility;
- tile adjacency or periodic wrap;
- cube-face seam mapping;
- torus seam mapping;
- object-local parent frame stability;
- sensor calibration agreement.
GRIS-style shift mappings live here. A shift is an owned object with tests, not
a helper function hiding inside a shader.
### 7. Occupancy Graph Update
The occupancy graph receives selected reservoirs and sparse high-quality probes.
It updates node statistics under budget:
```text
mean, variance, confidence, sampleAge, lastVisible, lastUpdated
```
Unvisited nodes decay. Stale uncertainty grows. Visible, uncertain, stale, and
near-threshold nodes retain nonzero exploration probability.
### 8. Selected Cut And Residency
A hierarchy cut is selected from summaries:
```text
projected error
material delta
coverage
current reservoir confidence
estimated GPU cost
residency state
```
Children stream only when the cut justifies them. Parent summaries remain
renderable. Eviction favors low-score high-cost payloads and protects
near-threshold nodes from thrash.
### 9. Backend Lowering
The same semantic field can lower to:
- 2D SDF tile pages;
- 2D height/material pages;
- 3D SDF splat packets;
- 3D density/extinction splat packets;
- transparent emission/scattering splat packets;
- 2D-projected-to-3D splat packets;
- sensor confidence volumes;
- debug overlays.
The first renderer path should use compact-support anisotropic envelopes. They
borrow Gaussian splatting's covariance discipline without inheriting infinite
support as a default runtime tax. Surface and volume encodings may share the
same envelope math; they do not share resolve rules.
### 10. TAA Guide Integration
TAA consumes guide signals:
- reservoir confidence;
- sample age;
- domain validity;
- temporal detail;
- invalidation reason;
- field id and motion.
TAA does not own reservoirs. It only decides how much pixel history may survive.
## Resource Tradeoffs
### CPU
CPU performs grammar expansion, coarse scoring, stochastic scheduling, page
requests, eviction decisions, and selected-cut planning. It must not rescore
every leaf or rebuild payload pages mid-frame.
### RAM
RAM keeps summaries, estimator state, hot/warm metadata, selected cuts, and
recent probes. It does not keep every generated child payload.
### SSD
SSD stores payload pages, summary pages, probe history, and optional training
datasets. SSD never blocks a frame; parent summaries render while requests are
pending.
### GPU
GPU evaluates selected packets, splats, SDF proxy passes, density/transparent
volume passes, compute scoring where profitable, page-table sampling, and debug
views. It must not walk the authored grammar tree.
## Render Frame Flow
```text
1. Gather visible domains and active sensor regions.
2. Load resident summaries and previous reservoirs.
3. Generate structural and sensor candidates under CPU budget.
4. Evaluate targets and source PDFs.
5. Update local reservoirs.
6. Validate temporal reuse.
7. Validate spatial/domain reuse.
8. Update occupancy graph statistics.
9. Select hierarchy cut under CPU/GPU/RAM/SSD budgets.
10. Queue missing pages; keep parent summaries active.
11. Lower selected evidence to 2D/3D/projected field splat packets.
12. Render surface and/or transparent field passes.
13. Resolve with TAA guide buffers.
14. Emit debug telemetry and evidence logs.
```
## Test Boundaries
Pure unit seams:
- projection round trips and seam continuity;
- domain lineage and shift mapping;
- DSL expansion determinism;
- ownership bounds;
- summary conservativeness;
- reservoir math;
- stochastic scheduler convergence;
- residency selection under fake stores.
Mock boundaries:
- `IFractalRandom`;
- `IFractalClock`;
- `IContributionProbe`;
- `IFractalPayloadStore`;
- `ISensorCandidateSource`;
- `IDomainShiftMap`;
- `ITaaGuideSink`;
- `IFractalDebugSink`.
GPU parity:
- envelope evaluator;
- projection evaluator;
- packet decoding;
- selected-cut fade;
- guide-buffer packing.
Performance fixtures:
- fixed camera path;
- fixed DSL world;
- fixed sensor trace;
- fixed budget profile for GTX 1070-class hardware;
- output CPU ms, GPU ms, payload count, page requests, selected cut size,
reservoir confidence, and convergence error.
## Waterfall Implementation Phases
### Phase A: Architecture Freeze
Deliver this document, the public thesis article, updated memory, and a cut
line for the TAA guide-buffer fork.
### Phase B: Explicit Reservoir Guide Layout
Add a dedicated reservoir guide history target rather than packing previous
reservoir validity into existing control channels. Preserve current-control.w
as the current confidence lane.
Guide schema:
```text
x: reservoir confidence
y: reservoir sample age
z: domain validity
w: invalidation code
```
Current state: SDF and temporal Gaussian passes write a scene reservoir guide
MRT. Resolve reads current and previous guide textures, folds confidence and
domain validity into history validation, and writes a ping-ponged history
reservoir guide target. `history-control.w` remains pixel history age.
### Phase C: Fractal Probe Pipeline
Turn IFS node probes into typed reservoir candidates with renderer-facing camera
motion, disocclusion, material, and visibility validation.
Current state: `FractalStructuralProbeGenerator` lowers an
`AquariumFractalSummary` into `FractalProbeSample` candidates with local center,
bound radius, projected target contribution, source PDF, material delta, and
payload handle. Renderer-facing temporal validation remains open.
`FractalStructuralProbeReservoir` builds a selected-cut reservoir from an
ownership tree, summaries, and projected pixels per world unit; Zyphos now
exposes that reservoir in its fractal render plan/debug dump.
`FractalProbeReuseValidator.ValidateTemporal` now names the renderer-facing
rejection reasons for camera motion, disocclusion, material mismatch, and
visibility before those checks are lowered to GPU reuse passes.
### Phase D: Occupancy Graph
Promote contribution state into the heuristic occupancy graph and add
confidence decay, stale uncertainty growth, and convergence telemetry.
Current state: `FractalOccupancyGraph` now owns per-node contribution state,
last visible frame, last observed score, and update probability. The
contribution cache routes through this graph, and unobserved nodes decay
confidence while stale uncertainty grows. Residency planning now keeps resident
payloads by score-per-cost and emits eviction decisions for low-value expensive
payloads. Convergence telemetry and richer resource-tier residency fields remain
open.
### Phase E: 2D Surface Field Tile Backend
Build cached 2D SDF/height/material/confidence pages for cube-sphere and torus domains.
Use parent summaries while child pages stream.
Current state: `AquariumFractalSurfacePageKey` and
`AquariumFractalSurfacePage` define renderer-agnostic page metadata for height,
2D signed-distance, material, and confidence pages. `FractalSurfacePagePlanner`
turns a selected IFS cut into stable page metadata, and the Zyphos render plan
now exposes height, 2D signed-distance, material, and confidence pages beside
its structural probe reservoir. `FractalSurfacePageResidencyPlanner` adds a
mockable page-store boundary with byte-budgeted resident, missing, requested,
and evicted page decisions. `FractalSurfacePageRasterizer` now lowers resident
pages to CPU height, 2D signed-distance support, material, and confidence
sample payloads using the same shaped brush envelope math as the live brush
compiler. The real page store, atlas, and D3D12 lowering remain open.
### Phase F: 3D Form Field Splat Backend
Build compact-support 3D form splat packets for object/volume domains. Opaque
solids use SDF/level-set packets; transparent media use density/extinction
packets. Keep distance safety conservative and LOD gated for solids, and keep
volume cost bounded by explicit extinction/support budgets.
Current state: `FractalProbeSample` now carries explicit field layer and
encoding metadata. The same reservoir sample type can represent opaque
`Form/SignedDistance` and transparent `Form/Density` or `Form/Extinction`
evidence without creating a second cache. `AquariumFractalSdfSplat3D` and
`FractalSdfSplat3DCompiler` own the opaque SDF lowering path;
`AquariumFractalDensitySplat3D` and `FractalDensitySplat3DCompiler` own the
transparent density/extinction lowering path. Reuse rejects mismatched
encodings before lineage/local-shift reuse can smear density and SDF together.
The `.aquageo` DSL accepts `density` and `extinction` claims beside `height`,
and its `flame` recursive primitive now emits density claims instead of height
claims. Height brush lowering filters to height payloads, leaving transparent
Form claims for field lowering. Zyphos now exposes a first structural SDF splat
from its probe reservoir. D3D12 lowering and object/body recursive form
integration remain open.
### Phase G: 2D-Projected-To-3D Field Backend
Project 2D field pages onto 3D domains: cube-sphere planets, torus stations,
curved sheets, object-local surfaces, and transparent sheets/volumes when the
domain mapping supports them.
Current state: `FractalProjectedSdfSplatCompiler` lowers resident
`SignedDistance2D` page payloads into compact 3D SDF splats through an explicit
`Vector2 -> Vector3` mapping and surface normal. Zyphos caches a first bounded
set of projected surface splats from its resident SDF pages using a flat local
mapping; real cube-sphere and torus mappings remain open.
### Phase H: Mimir Adapter
Add sensor candidate adapters after the fractal reservoir path proves the
contract. Camera/audio features become candidates; Aquarium owns resolved
evidence. Sensor fusion initially emits confidence/density form claims; only
coherent gradients and multi-view/acoustic agreement should promote them into
surface claims.
### Phase I: GPU Reservoirs And Spatial Reuse
Move hot reservoirs to GPU buffers, add spatial reuse across screen tiles and
domain neighbors, and keep CPU/GPU parity fixtures.
Current state: `tools/Aquarium.Fractal.Receipt` is the first GPU-resident
receipt harness. It allocates an 80-byte packed SDF splat UAV plus three
separate 64-byte reservoir UAVs for SDF envelopes, PBR material envelopes, and
radiosity because that was the first opaque-object packet slice. Conceptually
those are the first Form, Appearance, and Transport reservoirs. The receipt
runs independent D3D12 compute passes for splat population, form/SDF reservoir
sampling, appearance/PBR reservoir sampling, and transport/radiosity reservoir
sampling; the three reservoir passes share only the stochastic update budget
vocabulary, not packet anatomy. On the local GTX 1070, the budgeted receipt
kept `2,000,000` splats and `2,000,000` reservoirs per layer resident, updated
`50,000` entries per reservoir layer per frame with two candidates per update,
and measured `4.471 ms/frame`, `223.7 FPS` equivalent over `120` frames. This
proves compute-side GPU residency and budgeted temporal convergence for the
layered cache; final shaded visibility still has to consume the same packed
buffers. Next packet evolution should add density/extinction Form rows and
participating-medium Appearance/Transport rows instead of forcing flames through
opaque SDF/PBR semantics.
### Phase J: Learned Priority Gate
Only after telemetry exists, train or calibrate a predictor for update/residency
priority. Delete it if it does not beat the heuristic on held-out camera/sensor
traces.
## Cut Lines
- No learned predictor before telemetry.
- No recursive 3D form before 2D tile summaries and debug views are boring.
- No consumer-owned stable temporal cache.
- No GPU grammar traversal.
- No SSD frame stalls.
- No reuse without an explicit domain shift and validation contract.
- No debug-free recursive density.
## Sources
- NVIDIA ReSTIR DI:
https://research.nvidia.com/publication/2020-07_spatiotemporal-reservoir-resampling-real-time-ray-tracing-dynamic-direct
- NVIDIA ReSTIR GI:
https://research.nvidia.com/publication/2021-06_restir-gi-path-resampling-real-time-path-tracing
- GRIS:
https://graphics.cs.utah.edu/research/projects/gris/sig22_GRIS.pdf
- RTXDI ReSTIR docs:
https://github.com/NVIDIA-RTX/RTXDI/blob/main/Doc/RestirGI.md
https://github.com/NVIDIA-RTX/RTXDI/blob/main/Doc/RestirPT.md
- EWA splatting:
https://dash.harvard.edu/bitstream/handle/1/4138240/Zwicker_EWA.pdf
- Object-space EWA surface splatting:
https://publications.ri.cmu.edu/object-space-ewa-surface-splatting-a-hardware-accelerated-approach-to-high-quality-point-rendering/
- 3D Gaussian Splatting:
https://arxiv.org/abs/2308.04079
- Geometry clipmaps:
https://developer.nvidia.com/gpugems/gpugems2/part-i-geometric-complexity/chapter-2-terrain-rendering-using-gpu-based-geometry
- Nanite:
https://advances.realtimerendering.com/s2021/Karis_Nanite_SIGGRAPH_Advances_2021_final.pdf
- KinectFusion:
https://www.microsoft.com/en-us/research/publication/kinectfusion-real-time-dense-surface-mapping-tracking/
- Curless and Levoy volumetric fusion:
https://graphics.stanford.edu/papers/volrange/volrange.pdf
- Instant-NGP:
https://research.nvidia.com/publication/2022-07_instant-neural-graphics-primitives-multiresolution-hash-encoding

View file

@ -1,120 +0,0 @@
# Stochastic Transparent Surface Pipeline
Aquarium needs a shared path for transparent stochastic surfaces: Grid lines,
billboard particles, glyph motes, glints, future sprite cards, and any other
thing that is visible as coverage rather than as one opaque surface hit.
The important rule is that these objects do not own canonical depth. A ray may
encounter several candidates, and blue-noise/stochastic coverage may choose
different candidates across frames. Treating one event as "the" surface creates
self-occlusion, false depth rejection, and temporal history murder.
## Classes
### Opaque Solids
Examples: Epiphany agent bodies, cursor bodies, and future solid SDF objects.
Output:
- color
- travel/depth
- field id
- normal
- material id
- reactive/velocity if needed
This is the ordinary one-ray-one-hit contract.
### Stochastic Transparent Events
Examples: Grid line support, particles, billboard cards.
Output per event:
- travel
- field or batch id
- support/coverage
- premultiplied color
- approximate normal or facing axis when useful
- thickness/depth band when useful
The event stream is sorted front-to-back and clipped by opaque solid travel.
Events do not clip each other as opaque surfaces. They composite.
## Frame Shape
1. Render opaque solid scene and depth/travel.
2. Render/integrate participating media against opaque solid travel.
3. Generate stochastic transparent events up to opaque solid travel.
4. Apply blue-noise coverage decisions or alpha-to-coverage equivalent.
5. Composite selected transparent events premultiplied front-to-back.
6. Write transparent temporal descriptors separate from opaque metadata.
The Grid should not be a special scene surface. It should emit events from its
heightfield/line-support intersection. Particles emit events from their sorted
billboards or bins. Both land in the same transparent event pipe.
## Temporal Contract
Transparent history cannot validate like opaque history.
Opaque validation compares one depth, normal, field id, and color neighborhood.
Transparent validation should compare a distribution summary:
- accumulated support/coverage
- nearest event travel
- weighted mean travel
- travel variance or thickness band
- dominant transparent class id
- optional batch/field id where stable
History should survive ordinary stochastic hit/miss changes inside the same
support distribution. It should reject when the distribution moves, disappears,
becomes occluded by a solid, or changes class.
## Grid Events
The Grid is an implicit heightfield plus analytic line support, not an opaque
terrain surface.
A robust Grid event generator should:
- use the existing slope-aware `terrainGap = p.z - terrainHeight(p.xy)` march
- find refined heightfield crossings up to opaque solid travel
- evaluate line/isoline/field-line support at each crossing
- emit only crossings with meaningful support
- composite events front-to-back in the transparent pipe
- keep opaque scene metadata untouched
It must not:
- write Grid as the scene's opaque travel
- let one Grid crossing occlude later Grid crossings
- fixed-step sample a thin band and hope not to miss it
- invent a Grid-only temporal path that particles cannot use
## Particle Events
Particles should use the same pipe.
The particle system supplies candidate events from sorted/binned billboards.
Each event carries travel, coverage, premultiplied color, and a stable batch or
particle id when available. Stochastic coverage chooses visibility. Temporal
validation compares the transparent distribution rather than demanding exact
per-particle depth stability.
## Debug Views
Required before relying on the pipe:
- transparent event count
- selected stochastic event coverage
- accumulated transparent alpha
- nearest transparent travel
- weighted transparent travel
- transparent travel variance/thickness
- transparent class id
- opaque terminator travel
If these views do not agree with the final composite, the pipe is lying.

View file

@ -1,159 +0,0 @@
# Temporal Field Gaussian
## Objective
Aquarium owns the renderer feature for live volumetric point-cloud/splat input:
clients provide stable world-space Gaussian observations, and the engine turns
them into a buffered, reprojectable, D3D12-rendered field. LocalCastBridge can
then feed sensor-fusion output into Aquarium as a normal scene contract instead
of smuggling renderer policy through client code.
The historical `SDF` name is now backend baggage. This contract is a temporal
Form-field path: opaque producers may resolve to SDF/level-set surfaces, while
flames, smoke, transparent gradients, and sensor fusion may stay as
density/extinction/confidence fields until evidence justifies a surface.
## Current Mechanism
The client-facing contract is `AquariumSceneState.TemporalGaussianField`.
Each `AquariumTemporalSdfGaussian` carries stable identity, current and previous
center, velocity, oriented radii, color/opacity, confidence, history weight,
compact-kernel controls, and field id.
`Aquarium.Engine.Fractal.Temporal.TemporalGaussianAccumulator` consumes
`TemporalGaussianObservation` rows and lowers them through the shared
`TemporalSpatialEvidenceReservoir`. The reservoir owns the live buffering
policy:
- stable keys identify tracks across camera/sensor frames
- presentation delay lets late sensor data converge before rendering
- accumulation window expires old tracks
- max-track budgets evict low-confidence stale tracks before renderer lowering
- smoothed velocity predicts the presented center
- history weight is confidence scaled by age inside the window
The Gaussian field is one backend for this reservoir, not the reservoir's
identity. Fractal form probes, visual point-cloud features, transparent density
samples, and future acoustic constraints can all enter as stable keyed spatial
evidence as long as each producer declares confidence, time, bounds, field
layer, field encoding, and payload lowering rules.
`TemporalSpatialEvidenceLowering` owns the current packet conversions for
`TemporalGaussianObservation`, `AquariumTemporalSdfGaussian`, and
`AquariumGpuFusionSeed`; consumers should use those helpers instead of packing
payload vectors privately.
The D3D12 renderer lowers the field into `D3D12TemporalGaussianPacket`, uploads
the active packet span into a 1,048,576-entry structured buffer, and renders an
instanced proxy-quad pass from
`D3D12TemporalGaussian.hlsl`. The pixel shader evaluates a compact anisotropic
Gaussian kernel in world space, writes HDR scene color/travel, field metadata,
normal, and temporal-control coverage so the existing resolve sees the field as
diegetic scene content.
`Aquarium.LocalCast` is the first concrete live client for this contract. It
reads `localcast.visual.render_frame` from LocalCastBridge's typed CultCache
MessagePack document. That point/seed route is now explicitly a fallback and
debug harness: it proves the million-slot temporal Gaussian draw and keeps OBS
fed while the live capture path moves into Aquarium. The production ownership
line is `AquariumGpuSensorFrame`: camera and Leap inputs arrive as calibrated
sensor records plus shared GPU texture handles, the D3D12 backend owns their
metadata buffers, and fusion kernels lower those GPU-resident inputs into the
temporal Gaussian buffer consumed by the SDF Gaussian draw.
LocalCast GPU fusion also has its own live-history accumulator. New frames
update stable-key tracks, stale tracks expire after a bounded horizon, and the
whole retained set is sent to the GPU seed buffer every frame. The default
history horizon is 18 seconds and may be overridden with
`LOCALCAST_GPU_HISTORY_SECONDS` from 1 to 120 seconds. That is the reconstruction
buffer: long enough to harvest samples, bounded enough to remain a machine
instead of a scrapbook with a power cord.
## Invariants
- Field accumulation happens in world space before pixel history. TAA is the
resolver, not the owner of sensor identity.
- Stable keys belong to the producer/accumulator boundary; shader packets are
backend output and do not invent identity.
- The compact support kernel has a finite bound. Renderer cost must scale from
declared bounds, not from infinite translucent fog.
- Density/opacity observations are not required to become surfaces. A
transparent medium may remain a participating Form field with Appearance and
Transport payloads instead of being collapsed into a fake SDF.
- Client code may construct observations or a field for diagnostics, but
Aquarium owns live sensor texture import, packet layout, root binding, shader
evaluation, fusion, and temporal-control metadata.
- JSON is not a renderer boundary. CultCache/CultNet producers should lower into
typed contract rows before Aquarium sees the data.
## Cut Line
This cut deliberately claims million-slot ingestion, not a finished million-splat
renderer architecture. The live D3D12 path can draw up to 1,048,576 temporal
Gaussians through instanced proxy quads, only uploads the active seed/packet
span, and now owns the GPU lowering step. Python/LocalCastBridge may still
produce calibration artifacts and reference captures, but it must not own
per-frame dense stereo, feature tracking, or reconstruction compute. The next
scaling cut belongs to the Aquarium renderer: shared texture import, packed
camera planes, Leap packed-map channel extraction, selected-cut residency,
tiled/bin dispatch, GPU accumulation, and clustered visibility.
## GPU Fusion Spine
The active GPU boundary is deliberately narrow:
```text
LocalCast calibration/device metadata
-> AquariumGpuSensorFrame { calibrated cameras + shared GPU textures }
-> D3D12 sensor metadata buffers + imported texture SRVs
-> D3D12 LocalCast fusion compute shader
-> RWStructuredBuffer<TemporalGaussian>
-> instanced SDF Gaussian draw
-> TAA/resolve
```
Ownership:
- `AquariumGpuSensorFrame` is the live renderer contract for GPU-owned fusion
input.
- `AquariumGpuFusionField` remains a temporary fallback/debug contract for
already-derived point claims.
- `LocalCastGpuFusionMapper` converts typed LocalCast point claims into compact
seeds only for that fallback path.
- `LocalCastGpuFusionAccumulator` is only the LocalCast adapter around
`TemporalSpatialEvidenceReservoir`; it maps fallback stable seeds into shared
evidence observations and lowers reservoir samples back to
`AquariumGpuFusionSeed`.
- `D3D12LocalCastFusion.hlsl` owns the first compute lowering pass.
- `D3D12Renderer` owns GPU sensor camera metadata storage, UAV-capable temporal
Gaussian storage, dispatch, and the transition back to shader-resource state
for the draw.
- `AquariumAcousticFieldFrame` carries the ultrasonic chirplet timing oracle and
room/position constraints. Visual evidence may arrive late and be refined, but
acoustic timing is the clock witness for aligning the delayed broadcast world.
- `AquariumCalibrationEventFrame` carries deliberate calibration actions such
as claps. A clap gives the system one event that every camera can see and the
audio timing oracle can timestamp to microsecond-scale uncertainty, making it
the cheap, brutal alignment hammer for camera clock offsets and pose drift.
Current Aquarium cut: the contract, D3D12 camera metadata buffer, external
texture importer, and sensor SRV table exist. A producer may provide either a
duplicated shared handle or a named shared handle for each camera/Leap plane.
When sensor textures are present, Aquarium can dispatch fusion without fallback
seeds and write RGB-derived Gaussian samples into the temporal buffer. Those
samples are no longer isolated per-camera flecks: the shader computes a compact
per-sample visual descriptor, compares it against a neighboring camera stream,
raises confidence when stochastic samples appear to correspond, and shrinks the
kernel toward the matched surface. Acoustic constraints from the ultrasonic
chirplet loop bias confidence and velocity near measured room/position returns.
Next cut: replace the first-pass descriptor comparison with calibrated
epipolar/flow search, Leap packed-map channel extraction, and a persistent GPU
correspondence/refinement buffer that can update camera pose and surface tracks
over the several-second broadcast delay window. Deliberate clap events should
feed that buffer as high-confidence timing correspondences: visual impact frame
per camera against acoustic oracle time, then pose/clock correction under the
same delayed broadcast horizon.
The first shader pass also uses camera-facing proxy planes to evaluate each
kernel. True ray-integrated volume compositing, Gaussian depth sorting, and
per-Gaussian previous-center reprojection remain the next renderer cuts.

View file

@ -1,203 +0,0 @@
# Temporal Spatial Evidence Reservoir
## Objective
Aquarium owns the shared temporal evidence machine for two customers:
- fractal rendering, where Form/Appearance/Transport candidates need bounded
reuse across pixels, frames, and nested domains;
- Mimir/LocalCast sensor fusion, where camera and microphone features need a
delayed coherence window before the resolved field is rendered.
The old stable-key accumulator was useful, but it was not ReSTIR. The live
architecture now splits the problem into a small resampled-importance core, a
spatial evidence track layer, typed lowerings, renderer passes, and TAA history.
## Research Spine
- NVIDIA ReSTIR DI repeatedly resamples candidate light samples, then applies
spatial and temporal resampling to share useful samples across nearby pixels
and frames. The paper reports equal-error speedups of 6-60x, with biased
variants reaching 35-65x in its tested direct-lighting workloads.
Source: https://research.nvidia.com/labs/rtr/publication/bitterli2020spatiotemporal/
- ReSTIR GI applies the same screen-space spatiotemporal reuse idea to
multi-bounce indirect paths and reports 9.3x-166x MSE improvement at one
sample per pixel in tested scenes.
Source: https://research.nvidia.com/publication/2021-06_restir-gi-path-resampling-real-time-path-tracing
- GRIS generalizes RIS/ReSTIR to correlated samples, unknown PDFs, varied
domains, and shift mappings. That is the important part for Aquarium:
fractal domains and sensor-fusion samples are not all light samples.
Source: https://research.nvidia.com/labs/rtr/publication/lin2022generalized/
- RTXDI's integration docs make the pass boundary concrete: acquire initial
samples, store reservoir data, validate temporal reuse through reprojection,
validate spatial reuse against nearby surfaces, shift candidate paths between
domains, then shade/denoise from the final reservoir.
Sources:
https://github.com/NVIDIA-RTX/RTXDI/blob/main/Doc/RestirGI.md
https://github.com/NVIDIA-RTX/RTXDI/blob/main/Doc/RestirPT.md
## Pipeline Map
```text
producer observations
-> candidate generator
-> target evaluator
-> ResampledImportanceReservoir<TSample>
-> temporal reuse pass
-> spatial/domain reuse pass
-> validation + shift mapping
-> TemporalSpatialEvidenceReservoir track layer when stable fields are needed
-> TemporalSpatialEvidenceLowering
-> backend packet stream
-> renderer/fusion passes
-> TAA guide/history buffers
```
## Ownership
`ResampledImportanceReservoir<TSample>` owns RIS/ReSTIR-style candidate math:
- one selected representative sample;
- summed importance weight of accepted candidates;
- number of source candidates represented by the reservoir;
- selected sample target value;
- final contribution weight `weightSum / (candidateCount * selectedTarget)`.
Candidate generators own proposal distributions. A fractal renderer may propose
form probe candidates from projected error, node bounds, blue-noise screen
tiles, or resident children. Mimir may propose visual/audio feature candidates
from sensor confidence and calibration state. The reservoir does not know these
domains; it only receives target and source-PDF values.
Every evidence sample should state its layer and encoding before lowering:
Form, Appearance, or Transport; then SDF, height, density, extinction,
material, phase, emission, radiance, feature, or confidence as appropriate.
Opaque worlds can promote Form evidence into SDF surfaces. Flames and uncertain
sensor fields may remain density/extinction/confidence volumes. That is not a
failure to find the surface; it is the correct field.
Reuse passes own validity and shift mapping. A sample may be reused only when
the target domain can explain it. For pixels this means depth/normal/material
compatibility, motion vectors, conservative visibility, and disocclusion tests.
For fractal domains it means matching domain ancestry, bounded local-frame
error, resident payload compatibility, conservative surface bounds for SDF
encodings, and conservative support/extinction bounds for density encodings.
For sensor fusion it means time delay, calibration confidence, modality
agreement, and feature reprojection error.
`TemporalSpatialEvidenceReservoir` owns stable resolved spatial tracks after
candidate selection when the output is a field with persistent identity:
- stable key identity;
- accumulation window and presentation delay;
- confidence-weighted smoothing;
- velocity prediction;
- history weight from confidence and sample age;
- expiry;
- max-track eviction by confidence and age.
`TemporalSpatialEvidenceLowering` owns packet conversion. Consumers do not pack
payload vectors by private convention when a lowering helper exists.
TAA owns pixel history validation and temporal guide buffers. It may consume
reservoir confidence, sample age, domain id, motion, and temporal-detail lanes,
but it does not own producer identity or stable spatial evidence.
## Invariants
- Reservoir math is a pure, testable core before it becomes HLSL.
- A reservoir is not a dictionary of tracks. A track layer may use reservoirs,
but it does not replace candidate resampling.
- Reuse is invalid until a pass proves the shift/validation contract for the
source and target domains.
- Conservative bounds remain the safety authority. Learned or stochastic
priority may decide what to refresh first; it must not replace field bounds,
visibility bounds, or calibration bounds.
- CPU, GPU, RAM, and SSD budgets are inputs to candidate generation and
residency. They are not hidden side effects of renderer convenience code.
- Consumer repos do not grow parallel stable-key temporal caches.
## Current State
Built:
- `ResampledImportanceCandidate<TSample>` stores a sample, target value, source
PDF, and represented candidate count.
- `ResampledImportanceReservoir<TSample>` accepts candidates by weight
proportional to `target / sourcePdf`, merges reservoirs, preserves represented
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, 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.
- `FractalProbeSample` is the first typed fractal form/detail sample shape for
the ReSTIR/GRIS path. It carries domain key, node key, local center, bound
radius, target contribution, source PDF, material delta, and payload handle.
`FractalProbeReuseValidator` currently proves domain lineage and local-shift
compatibility; renderer-facing camera, disocclusion, material, and visibility
checks still need to be added before temporal/spatial reuse is complete.
Not built yet:
- GPU reservoir buffers;
- camera/disocclusion/material validation for fractal form/appearance reservoirs;
- spatial neighbor reuse across screen tiles and cube-sphere neighbor domains;
- GRIS-style domain shift mappings for nested `.aquageo` domains;
- expanded TAA guide-buffer storage for previous-frame reservoir confidence,
sample age, domain validity, and invalidation reason;
- SSD/RAM residency queues driven by reservoir contribution estimates.
## TAA Guide Layout
Reservoir/TAA guide data has a dedicated current scene target and ping-ponged
history target. It is deliberately separate from `history-control.w`, which
continues to own pixel history age.
```text
x: reservoir confidence
y: reservoir sample age
z: domain validity
w: invalidation code
```
The first live producers are SDF surfaces and temporal Gaussian splats, but the
schema is not surface-only. Resolve reads the current and previous guide
textures, folds confidence and domain validity into history validation, then
writes the next history guide. Future ReSTIR/GRIS passes should extend the
producer side of this schema with explicit Form/Appearance/Transport fields
rather than packing more reservoir folklore into scene-control channels.
## Implementation Roadmap
1. Keep the CPU reservoir core pure and exhaustive under unit tests.
2. Add typed reservoir samples for fractal field probes: domain key, local
frame, bound radius, target contribution, source PDF, layer/encoding, payload
handle, and material or transport delta.
3. Turn `FractalContributionCache` into a candidate generator that refreshes
nodes under CPU budget and submits candidates to the reservoir core.
4. Add temporal reuse for fractal probes using camera motion, domain ancestry,
local-frame error, and conservative bounds.
5. Add spatial reuse across screen tiles and quadtree neighbors.
6. Lower selected reservoirs into GPU field packets. SDF packets are the solid
surface encoding; density/extinction packets are the transparent/sensor
volume encoding. Expose debug views for weight sum, selected target,
candidate count, confidence, age, and invalidation reason.
7. Weave reservoir confidence and temporal detail into TAA guide buffers so the
history filter can distinguish stable reused evidence from fresh stochastic
noise. The first pass uses current scene-control.w for reservoir confidence
and keeps history-control.w as age; full previous-frame reservoir validity
still needs an expanded guide layout.
8. Add Mimir-facing candidate adapters only after the fractal path proves the
contract: modality features are candidates, not a second reservoir system.
9. Port the pure core to HLSL and add CPU/GPU parity fixtures.
10. Add residency scheduling: hot selected reservoirs keep GPU packets resident,
warm reservoirs keep RAM payloads, cold reservoirs fall to SSD handles.
## Cut Line
If a new subsystem stores temporal candidates, it must state whether it owns raw
producer retention, resampled candidate selection, stable resolved tracks,
packet lowering, or TAA history. If it cannot name that authority, it is not an
architecture. It is a decorative leak.

View file

@ -1,301 +0,0 @@
# TSR-Inspired TAA Spec
Aquarium needs an owned temporal resolver, not a copied implementation. The
target is inspired by public TSR behavior: stable subpixel detail, aggressive
history validation, visible debug terms, and special handling for pixels whose
appearance is not fully described by motion vectors.
## Inputs
The resolver owns these signals:
- current HDR scene color
- current ray travel
- previous HDR history color
- previous ray travel
- current camera/Grid constants
- previous camera/Grid constants
- current and previous projection jitter
- stochastic coverage from diegetic Grid surfaces
Later gates add:
- field id
- normal
- explicit velocity
- reactive mask
- transparency/composition mask
- resurrection history
## Current Pass Contract
The scene pass renders into an HDR texture:
```text
rgb = linear scene color
a = ray travel in world units
```
The pass does not tonemap. Tonemapping belongs after temporal resolve.
## Reprojection
For the first implementation, motion is camera-derived:
1. Reconstruct the current world hit from current camera, current jittered ray,
and current travel.
2. Project that world hit into the previous camera basis.
3. Remove previous jitter to find the previous history UV.
4. Sample previous history if the UV is valid.
5. Compare previous history travel against the reprojected point's expected
distance from the previous camera, not against current-frame travel.
This is sufficient for camera motion and stochastic Grid coverage. It is not the
final answer for animated bodies or field animation.
## Rejection
Reject or weaken history when:
- previous UV is outside the frame
- travel differs too much
- current or previous travel is invalid
- current neighborhood color bounds do not contain the reprojected history
The first pass uses a 3x3 neighborhood min/max clamp and travel-difference
weighting. Later passes add field/material ids, normals, disocclusion masks, and
reactive masks.
## Resolve
The resolver outputs both:
- tonemapped final backbuffer color
- linear HDR history for the next frame
Current blend target:
```text
resolved = lerp(current, clamped_history, history_weight)
```
History weight starts conservative. Stochastic Grid coverage needs accumulation,
but ghosting is worse than shimmer while the renderer lacks full velocity,
material id, and reactive masks. The resolver should earn trust before taking
more history.
## Non-Negotiables
- Do not use Playdead-style TAA as the final model; it ghosts too easily for the
scene we are building.
- Do not vendor Unreal TSR code. Study behavior and build a clean-room resolver.
- Do not hide missing renderer signals with larger blend weights.
- Do not make the Grid a final-image HUD layer; it is diegetic scene UI and must
respect nearer solids.
## Implementation Gates
### Gate 1: Owned Temporal Pass
- HDR scene target
- history ping-pong
- camera jitter
- current/previous camera reprojection
- travel rejection
- 3x3 color clamp
- final tonemap after resolve
### Gate 2: Renderer Signals
- material/field id buffer
- normal buffer
- explicit velocity for animated SDF bodies
- coverage/reactive masks for stochastic Grid and future particles
Current Gate 2A implementation:
- scene metadata target stores stable field id plus surface normal
- history metadata ping-pongs alongside color/travel history
- field id mismatch rejects history
- normal mismatch weakens/rejects history
- Grid stochastic surfaces write Grid travel/normal even when the current
dither sample does not draw color, so the temporal resolver accumulates the
diegetic surface rather than reprojecting from the solid behind it
Current Gate 2B implementation:
- Self, Grid, and each orbiting planet have distinct field ids
- orbiting planet hits map their current world hit back to the planet's previous
center before camera reprojection
- this gives analytic SDF bodies object motion without a full velocity buffer
yet
Current Gate 2C implementation:
- `AquariumSceneState.TemporalGaussianField` carries world-space temporal SDF
Gaussian splats with stable keys, current/previous center, velocity,
confidence, history weight, field id, and compact-kernel controls
- `TemporalGaussianAccumulator` buffers sensor observations in world space before
rendering, using a presentation delay plus accumulation window instead of
asking final-pixel TAA to discover identity after projection
- D3D12 uploads the field into a structured buffer and renders it through
`D3D12TemporalGaussian.hlsl`, writing color/travel, field metadata, normals,
and temporal-control coverage into the existing scene/resolve path
Still missing:
- separate field ids once the SDF field registry exists
- reactive/coverage masks
- richer disocclusion classification
- a general velocity buffer for non-rigid fields and deformation
- per-Gaussian previous-center reprojection in the resolve pass
### Gate 3: Stochastic Event Control
Current Gate 3A implementation:
- scene pass writes a temporal-control target separate from color/travel and
field/normal metadata
- temporal control currently stores:
```text
x = reactive strength
y = stochastic/composition coverage
z = reserved
w = reserved
```
- Grid coverage feeds the coverage channel
- low-coverage Grid samples raise reactive strength and reduce history weight
- the resolve combines reactive and coverage weights with travel, field, normal,
and neighborhood-color validation
Current Gate 3B implementation:
- temporal-control history ping-pongs alongside color/travel and field/normal
history
- resolve samples previous-frame temporal control at the reprojected UV
- coverage discontinuity reduces history
- current temporal control is written into the history set for the next frame
Current Gate 3C implementation:
- temporal-control history `w` stores accepted history age in frames
- validation grows age when reprojected history survives the travel, field,
normal, color, and coverage checks
- history authority ramps with age, so newly accepted history can contribute but
does not immediately get the same weight as stable history
- age resets to zero on validation failure
Current Gate 3D implementation:
- travel validation uses the reprojected previous-world position's distance from
the previous camera
- raw current-frame travel is no longer compared directly to previous history
travel, because camera motion changes the ray distance even when the world hit
is stable
Current Gate 3E implementation:
- stochastic Grid history bypasses opaque-surface neighborhood color clamping
and current-frame color-delta rejection
- Grid validation still uses field id, travel, normal, coverage continuity,
reactive weight, and history age
- this prevents a current dither miss from declaring the true neighborhood to be
background and crushing the very history that should accumulate stochastic
coverage
Current Gate 3F implementation:
- stochastic Grid history uses a high-retention blend policy distinct from
opaque surfaces
- Grid history weight no longer scales down with current coverage or reactive
strength; low coverage is exactly where temporal accumulation is needed
- opaque surfaces keep the stricter color/reactive validation path
- debug mode `2` should now show a smoother history signal than raw scene mode
`1` when the Grid is stable
Current Gate 3G implementation:
- identity/control buffers are point-loaded, not linearly sampled
- field id equality is exact after point-loading, so Grid/Self/planet boundaries
hard-reject mismatched history
- Grid history color is sampled with the same nearest pixel used for metadata so
bright foreground silhouettes do not bilinearly bleed into background Grid
history
- Grid max history weight is reduced from the too-soft `0.975` to `0.94`
Current Gate 3H implementation:
- Grid temporal `coverage` is now line/contour/field-line support, not broad
overlay alpha
- broad weather/field tint can still render through stochastic coverage, but it
does not keep history age alive across the whole Grid surface
- Grid history is gated by current support, so stale stochastic hits cannot
billow away from the analytic line support
- Grid max history weight is reduced again to `0.90` with a lower fresh-history
scale for crisper line response
Current Gate 3I implementation:
- Grid resolve reconstructs the analytic Grid overlay at the current world hit
and uses premultiplied overlay color as the current color estimate
- the scene pass may still use stochastic coverage, but the temporal resolver
no longer treats the current binary hit/miss sample as the true radiance
- debug mode `1` can remain noisy because it shows the raw stochastic scene;
mode `0` should now use the analytic Grid estimate plus validated history
Current Gate 3J implementation:
- debug mode `1` is restored to raw current scene color, before Grid analytic
reconstruction
- Grid final color no longer blends previous color history; it uses the analytic
current Grid estimate and writes that estimate into history
- history/age/weight debug modes remain useful diagnostics, but stale Grid color
history no longer drives the final visible Grid
Current Gate 3L/3M implementation:
- projection jitter uses a small Halton sequence, currently scaled below half a
pixel so the resolve gets sample diversity without visible whole-frame wobble
- current and previous jitter are both uploaded through frame constants; previous
history projection removes the previous jitter before sampling history
- final presentation blends validated temporal color history in mode `0`
- SDF object highlights have a narrow hot-current path: when the current SDF
sample is much brighter than valid reprojected history, color rejection is
relaxed so history can damp transient specular fireflies while travel, field,
normal, coverage, and coverage-continuity validation still apply
- bloom presentation is attenuated when the resolved scene luminance is much
lower than the raw current scene luminance, so current-frame fireflies do not
keep blooming after temporal history has damped the underlying scene color
- bloom prefilter clamps unsupported single-pixel HDR spikes against local
neighborhood luminance before downsample/blur, so one-frame fireflies do not
spread into neighboring pixels before the temporal resolve sees them
- modes `2` through `4` show the sampled history, accepted history age, and
history weight
Still missing:
- reactive reset for lighting and density changes
- separate history policy for opaque surfaces and stochastic surfaces
### Gate 4: TSR Features Worth Stealing In Spirit
- flicker analysis for high-frequency linework and stochastic coverage
- history resurrection for reappearing stable details
- debug visualizations for rejection, clamp, velocity, travel, and history age
Current debug view controls:
- `F1` cycles temporal debug modes in the running window
- `0` final resolved color
- `1` raw current scene color
- `2` reprojected history color used by the resolver
- `3` accepted history age
- `4` history weight
- `5` current temporal control: reactive in red and Grid support/coverage in
green
- `6` current field identity palette for Grid, Self, and planets
- `7` bloom/veil contribution after exposure and before tonemapping
- `8` exposed luminance bands
- `--render-debug` and `AQUARIUM_RENDER_DEBUG_MODE` set the startup mode

View file

@ -1,34 +0,0 @@
# Vortice Spine
Aquarium starts from a thin native spine:
- `Platform.Win32Window` owns window creation and the message pump.
- `Render.D3D12Renderer` owns the D3D12 device, command queue, swapchain,
frame resources, render targets, shaders, and present path.
- `AquariumRuntime` owns simulation state and emits one `AquariumFrame` per
tick.
- `OrbitCameraRig` and `GridFrame` preserve the first invariant: the camera
target is the Grid center, and Grid radius derives from zoom distance rather
than pitch, yaw, or screen projection.
## Why Vortice
Vortice gives direct access to D3D/DXGI without forcing an engine protocol on
top. It is low ceremony enough to build the renderer Aquarium needs while
keeping C# as the everyday working environment.
## Current Renderer
The renderer owns the visible Aquarium frame:
1. Camera and Grid constants come from the client runtime.
2. A height-field pass renders the scalar field target from client brush data.
3. A fullscreen D3D12 HLSL pass traces the background and height-field surface.
4. Client-declared SDF proxy pipelines render Epiphany bodies into the shared
scene targets.
5. HDR bloom and presentation run before the swapchain is handed to the overlay.
6. DirectWrite overlay text and debug UI draw after the scene pass.
No framework-owned deferred path or ambient light is hiding under the floor.
When richer lighting returns, it enters through an Aquarium-owned renderer
contract, not convenience globals.

View file

@ -1,90 +0,0 @@
# Zyphos and Eusocial Interbeing Sync
Zyphos simulates the world defined by the neighboring Eusocial Interbeing vault
at `E:\Projects\Eusocial Interbeing`. The vault is the setting authority.
Aquarium is the renderer and interaction testbed.
## Objective
Use Zyphos as a visible planetary demo for Eusocial Interbeing without turning
Aquarium into a second lore repository.
## Current Mechanism
- Eusocial Interbeing stores canon and design inference in Obsidian notes under
`Eusocial Interbeing/`.
- Aquarium.Zyphos renders Zyphos, fixed-sky Umbros, atmosphere, night-side
glints, and first-pass eclipse cadence through the shared Aquarium host and
SDF renderer path.
- `ZyphosUmbrosSystem` owns the current render-scale binary constants:
Zyphos radius, Umbros radius ratio, 8-Zyphos-radii separation, sea level, and
star phase used by the shader.
- `Worlds/zyphos-first-fractal-terrain.aquageo` owns the first semantic world
patch: solar, orbital, planetary, and lat/long domains wrap the tile terrain
so later fractal grammar nodes have real nested reference frames instead of
undocumented coordinate folklore.
- No automatic sync exists. Changes cross the boundary by explicit notes and
state-map updates.
## Invariants
- Setting canon belongs in the vault.
- Aquarium docs may describe render requirements, demo scope, and questions, but
must not become the authoritative setting bible.
- Zyphos bends to physics. If orbital mechanics, light budget, eclipse geometry,
or climate constraints narrow an early visual idea, the demo should use that
pressure as worldbuilding fuel rather than hiding an impossible sky in shader
code.
- Zyphos-specific code belongs under `src/Aquarium.Zyphos`; generic renderer
machinery belongs under Aquarium.Engine only when it serves multiple clients.
- Design inference must be labeled before it hardens into canon.
- Rendering constraints should feed back into the vault as questions, not silent
lore edits hidden in shader code.
## First Conversation
The first Zyphos pass should stage the planet as a dimly lit close-binary world
with two legible continental systems:
- Umbros is the slightly smaller twin of Zyphos, mutually tidally locked with it
and fixed in the sky except for precession/libration;
- the primary star is dim, so the habitable zone is close and the biosphere is
energy-starved relative to Earth;
- the working vault baseline gives Umbros a very large apparent diameter
(roughly 10-13 degrees for the current 8-10 Earth-radii separation candidates)
and daily central eclipses around an hour near the relevant equatorial track;
- the biosphere is founded on mutable cellular memory exchange, so sentience and
eusocial contracts exist across cellular, organismal, social, and ecological
scales instead of being reserved for a few special species;
- the Airawa home continent, where living memory networks, mother trees,
disconnected networks, and imperial memetic infrastructure create visible
ecological-political structure;
- the Sa'auei'a continent, where nomadic reciprocity, breeding grounds, remembered
routes, and mobile family-unit infrastructure create a different map logic.
This does not require detailed species bodies yet. The first useful render target
is planetary-scale readability: landmass logic, ecological network hints,
settlement/non-settlement patterns, and night-side signals that reflect distinct
civilizational structures.
Aquarium should treat these as lighting, sky, time-of-day, and ecological
pressure constraints. The accepted physical baseline lives in the vault note
`Eusocial Interbeing/World/Zyphos Umbros Binary System.md`.
The accepted biosphere baseline lives in
`Eusocial Interbeing/Ecology/Mutable Memory Endosymbiosis.md`.
First concrete biosphere handles live in
`Eusocial Interbeing/Ecology/Zyphos Biosphere Examples.md`.
Future inhabitant-language names should be coordinated through Weksa at
`E:\Projects\weksa`, with names derived from language-project state rather than
English label substitution.
## Feedback Path
When Zyphos work creates a worldbuilding question, update the vault note
`Eusocial Interbeing/World/Zyphos Simulation Brief.md` first. Aquarium should
then reference the settled result, not carry private lore.
When the vault changes canon that affects the demo, update this file and
`state/map.yaml` so Zyphos does not keep simulating an obsolete world because a
shader once looked nice. The shader has no voting rights.

View file

@ -1,28 +0,0 @@
# D3D12 Source Manifest
## Primary Sources
- Microsoft Learn, Resource binding overview:
https://learn.microsoft.com/en-us/windows/win32/direct3d12/resource-binding-flow-of-control
- Microsoft Learn, Fence-based resource management:
https://learn.microsoft.com/en-us/windows/win32/direct3d12/fence-based-resource-management
- Microsoft Learn, Memory Management in Direct3D 12:
https://learn.microsoft.com/en-us/windows/win32/direct3d12/memory-management
- Microsoft Learn, Direct3D 12 core interfaces:
https://learn.microsoft.com/en-us/windows/win32/direct3d12/direct3d-12-interfaces
- Microsoft DirectX Developer Blog, A Look Inside D3D12 Resource State Barriers:
https://devblogs.microsoft.com/directx/a-look-inside-d3d12-resource-state-barriers/
- NVIDIA Technical Blog, Advanced API Performance: Barriers:
https://developer.nvidia.com/blog/advanced-api-performance-barriers/
- AMD GPUOpen, RDNA Performance Guide:
https://gpuopen.com/learn/rdna-performance-guide/
- AMD GPUOpen, D3D12 Memory Allocator:
https://gpuopen.com/d3d12-memory-allocator/
- AMD GPUOpen, Effective Use of the New D3D12_HEAP_TYPE_GPU_UPLOAD:
https://gpuopen.com/learn/using-d3d12-heap-type-gpu-upload/
## Scope
This pass focused on D3D12 renderer architecture, not beginner API syntax:
descriptor heaps, root signatures, command allocators, fences, upload/ring
buffers, memory allocation, resource barriers, and GPU/CPU synchronization.

View file

@ -1,132 +0,0 @@
# D3D12 Synthesis
## Core Model
D3D12 is an explicit ownership API. The renderer owns command memory, resource
states, descriptor lifetime, upload lifetime, and GPU/CPU synchronization. The
driver stops hiding most of that work. Good D3D12 code is therefore less about
calling the right incantations and more about making ownership impossible to
misread.
The source material converges on a few hard rules:
- Keep root signatures stable and few. Bind a root signature once per command
list or pass family, not as routine draw noise.
- Use descriptor heaps deliberately. Shader-visible CBV/SRV/UAV heaps are
application-versioned memory; never overwrite descriptors still referenced by
in-flight GPU work.
- Treat RTV/DSV descriptors differently from shader-visible descriptors. RTV/DSV
bindings are versioned during command recording, but shader-visible heap
contents are not protected by the runtime.
- Reset command allocators only after the GPU has completed the work recorded
into them.
- Use fences to reclaim transient upload/descriptors/resources. A frame fence is
the coarse tool; finer fences can protect suballocations later.
- Prefer ring/suballocation systems for transient uploads and descriptors. Fixed
one-resource-per-use committed allocations are acceptable scaffolding, not the
renderer we keep.
- Track resource states explicitly and emit the minimum correct barriers. Avoid
broad generic states and redundant transitions.
- Batch or group barriers when that becomes practical; barriers and fences can
reduce GPU parallelism.
- Keep command submissions reasonably chunky. Tiny command lists and frequent
queue submissions pay CPU and GPU scheduling costs.
- Use PIX and vendor validation early. D3D12 mistakes are often legal-looking
until the GPU catches fire behind a curtain.
## Descriptor Doctrine
Microsoft's binding model puts descriptor heap content lifetime squarely on the
application for shader-visible heaps. The long-term Aquarium shape should be:
- One large shader-visible CBV/SRV/UAV heap for the frame, or one persistent
heap plus suballocated dynamic ranges.
- A CPU-visible/staging descriptor store for reusable descriptors.
- Per-frame transient descriptor ranges that are reclaimed by fence value.
- Stable descriptor ranges for long-lived textures/buffers.
- Minimal `SetDescriptorHeaps` calls. Heap changes can invalidate descriptor
table bindings, so pass code should bind the heap once before draws.
Current Aquarium D3D12 status:
- Good: shader-visible descriptors are split into static and per-frame
transient arenas. Transient CBV/UAV descriptors reset only after the owning
frame fence clears, and static descriptors rebuild on resize after a GPU wait.
- Temporary: arenas are still simple bump allocators. Keep them until real
non-resize churn demands free lists, paging, or a persistent global heap.
## Upload Doctrine
Transient CPU-to-GPU data belongs in persistent mapped upload memory, typically
suballocated as a ring and reclaimed by fences. Constant buffers require 256-byte
alignment. Reading directly from upload memory is acceptable for small constant
data, but large frequently-read resources should be copied to default heap
memory before shader use.
Current Aquarium D3D12 status:
- Good: each frame owns a persistently mapped upload ring, and constant-buffer
writes are 256-byte aligned.
- Temporary: upload policy is still per-frame and constant-only. Structured
scene data should move through typed upload allocations and default-heap
buffers before real pass migration grows data volume.
- Guardrail: keep direct shader reads from upload memory limited to small
constants. Structured/large scene data should move to default memory.
## Memory Doctrine
Committed resources are fine while bringing up a backend. They are not a
production memory manager. Microsoft recommends classifying resources and
budgeting/streaming residency; AMD recommends large heaps and suballocation,
with D3D12 Memory Allocator as the practical library path.
Current Aquarium D3D12 status:
- Good: committed default resources make ownership explicit during bring-up.
- Temporary: once scene/history/froxel resources grow, add D3D12MA or an
equivalent placed-resource allocator instead of keeping one committed resource
per texture/buffer.
## Barrier Doctrine
Barrier correctness is not optional, but barrier volume is a performance risk.
The renderer should track state per resource, use the narrowest valid states,
avoid redundant transitions, and group barriers when pass complexity grows.
Current Aquarium D3D12 status:
- Good: the offscreen render target tracks its current state and transitions
only on change.
- Good: swapchain backbuffers are wrapped as tracked resources and transition
explicitly between present and render-target states.
- Watch item: rendering to an offscreen target then copying to the swapchain is
useful for proving texture ownership, but final composition should avoid
unnecessary full-frame copies unless a post-process graph requires them.
## Command/Fence Doctrine
Per-frame command allocators must not be reset until the GPU is done with their
recorded work. Frame resources should carry fence values, and transient
descriptor/upload/resource allocations should tie reclamation to those values.
Current Aquarium D3D12 status:
- Good: one command allocator per swapchain frame and fence wait before reuse.
- Temporary: the renderer still uses a single graphics command list and a coarse
frame fence. That is fine until multi-threaded recording or async copy/compute
becomes real.
## Aquarium Next Steps
1. Add a D3D12 frame graph/resource map for named render targets and buffers.
2. Promote descriptor allocation from smoke-path arenas into renderer-wide
static/transient descriptor policy.
3. Extend the upload ring from constants into typed uploads for structured
buffers and texture staging.
4. Add D3D12MA or a deliberate placed-resource allocator before the resource
count becomes serious.
5. Add debug names and PIX markers while the graph is still small.
6. Track backbuffer resources with the same state wrapper used for offscreen
targets.
7. Port the first real pass only after descriptors, upload, render targets, and
state tracking have durable ownership.

View file

@ -1,561 +0,0 @@
# Cube-Sphere Fractal Brush Research
Aquarium needs one scalable surface/object grammar, not separate tricks for
planets, Grid brushes, and agent bodies.
The working target is a cube-sphere domain: six square faces, each subdivided by
quadtree addresses, projected onto a sphere with bounded distortion. Each tile
owns a local 2D chart for terrain and surface detail. The same grammar extends
inward to 3D object detail through bounded implicit brush nodes.
This is not a license to build a universal registry-shaped fog machine. The
authority should be small:
- Tile address owns where a surface patch lives.
- Projection owns how a face chart maps to the sphere.
- Brush grammar owns what detail exists at a scale.
- Brush envelope owns where that detail can affect rendering.
- Renderer LOD/sampling owns how much of the grammar is evaluated this frame.
## Surface Domain
The established name is the quadrilateralized spherical cube, often shortened to
QSC or quad sphere. It was designed as a discrete global grid for spherical
data, including Earth/sky data, and avoids the polar singularities of lat-long.
PROJ describes QSC as equal-area, treating all cube sides equally, and explicitly
mentions planetary-scale terrain rendering with quadtree data per cube side.
The older COBE/Chan-O'Neill family is approximately equal-area and historically
important. For engine use, the more relevant graphics framing is cube-to-sphere
projection for procedural texturing: generate a point or tile on a cube face,
warp the face coordinates to reduce area distortion, then normalize/project to
the sphere.
The cheap mapping ladder:
1. Identity cube map then normalize: fastest, worst area stretch near cube
corners.
2. Tangent/univariate warp: still simple, analytic inverse, much better area
distribution.
3. Fifth-order odd polynomial warp: fastest high-quality univariate candidate
in the JCGT comparison; good for GPU hot paths, inverse is approximate.
4. COBE-style bivariate polynomial: best area preservation in that comparison,
but more expensive.
5. Exact equal-area constructions: useful reference, not automatically the right
hot shader choice.
For Aquarium, the first serious candidate should be the fifth-order odd
polynomial warp from the JCGT paper, measured against tangent and COBE on the
actual 1070 target. It is the right shape of compromise: cheap scalar math,
separable face axes, low area error, and no lat-long singularity. If inverse
address lookup dominates, tangent may win despite worse area preservation. If
offline baking or CPU authoring needs maximum area regularity, COBE/QSC can be
used there without forcing it into every pixel path.
## Multiscale Detail
Every visible primitive should be able to answer: what detail exists below this
scale? The proposed answer is an iterated function system brush grammar.
For terrain:
- Each cube-face quadtree tile has a deterministic grammar seed.
- The grammar emits bounded 2D brushes at octave/LOD levels.
- Coarse levels define form: continents, basins, ridges, crater families,
river-like masks, weathered bands.
- Fine levels define material/texture response first; traced geometry only when
its bound is visible enough to deserve SDF cost.
For 3D bodies:
- Each agent/body starts from one coherent base SDF form.
- Its grammar emits bounded 3D brush nodes in that local form domain.
- A brush may carry texture/material response, displacement, negative space, or
form contribution, but it must share an explicit envelope.
- The grammar is LOD-gated and budgeted by proxy bounds. No scene-global
mega-SDF. No evaluating all organs because one pixel got curious.
IFS here means a deterministic recursive emitter of child transforms and brush
parameters, not necessarily a textbook fractal exposed directly to the user. The
important invariant is that detail exists at every scale because the primitive
can generate it locally, not because a finite table happened to be hand-filled.
## IFS DSL Shape
VibeGeometry is the nearest local evidence for the authoring layer Aquarium
needs. It is not about SDFs, but it has spent real work on spatial mapping,
procedural graph translation, CSG claims, and keeping geometry machines
inspectable.
The lesson is not "copy Blender nodes." The lesson is that a spatial DSL needs
more than primitives and transforms:
- explicit local frames and frame transitions;
- deterministic seeded variation;
- named fields/attributes that survive domain changes;
- tags explaining why a generated thing exists;
- repeat/foreach-style rule composition;
- authoring handles/gizmos for parameters humans and agents will tune;
- asset/material binding outside the hot geometry grammar;
- debug/evidence outputs for each layer, not only the final pretty object.
Aquarium's IFS DSL should emit semantic brush claims before it emits shader
work. A claim is a bounded statement in a local domain:
```text
claim {
name
kind: material | height | displacement | sdf_form | void | mask | light
frame
envelope
payload
tags
lod_range
cost_tier
seed
children
}
```
For terrain, the claim domain is a cube-face tile chart plus height/material
space. For bodies, the claim domain is the object-local anatomical/form frame.
For both, rule output stays semantic until a backend lowers it into one of the
live render paths: height brush pass, material field, SDF evaluator, impostor
splat, debug view, or offline bake.
Do not flatten this into a bag of anonymous brushes too early. VibeGeometry's
`vg_grammar` layer keeps `Solid` and `Void` claims tagged as room, corridor,
door, wall, floor, or ceiling before compiling to CSG. Aquarium needs the same
shape of truth for craters, ridges, rivers, veins, ribs, petals, apertures,
scars, and agent state features. The renderer can be brutal and cheap later;
the authoring layer should still know what each brush means.
## CSG Tree Lesson
The really useful VibeGeometry path is the CSG tree attempt. It separates three
authorities:
- grammar/rules decide which semantic claims should exist;
- the CSG tree decides boolean ownership of space;
- the output mesh or brush stream is a compiled verdict, not the source of
truth.
`vg_csg` currently has a small `CsgTreeArena` with brush leaves and branch ops:
addition, subtraction, and common/intersection. Branch bounds follow boolean
meaning: addition unions child bounds, subtraction keeps the left/source bound,
and common intersects child bounds. The tree can compile down to the current
ordered brush stream, where the first child of a subtraction remains additive
and later children become subtractive operators.
That flattening bridge matters. Aquarium can preserve an authored IFS/CSG tree
even if the first GPU path lowers it to fixed-size brush tables or per-object
SDF includes. The tree is the durable design. The shader packet is a backend.
For Aquarium, the tree should probably not be a full CAD boolean kernel in the
hot path. It should be a semantic spatial program that lowers into cheap
bounded evaluators:
```text
rule tree
-> semantic brush/void/material claims
-> boolean/IFS ownership tree
-> backend lowering:
- 2D tile material/height brushes
- 3D object-local SDF form brushes
- compact anisotropic splat/impostor batches
- offline baked summaries and mips
```
The important tree operations for the first Aquarium DSL:
- `Union`: multiple claims contribute in the same domain.
- `Subtract`: voids, apertures, channels, scars, doorways, river cuts.
- `Intersect/Common`: masks, biome regions, anatomical zones, tile clipping.
- `Repeat`: emit child rules over a count, curve, grid, face, or stochastic set.
- `Refine`: emit lower-scale children when screen/error budget asks for them.
- `Capture`: name a value, station, mask, or frame for later child rules.
- `Bind`: attach material/asset/state payload outside the form rule.
The tree must carry bounds at every node. Bounds are not an acceleration garnish;
they are the contract that keeps a grammar from becoming a frame-eating sermon.
Branch bounds also define LOD and invalidation: if a tile/body query does not
touch a subtree's bound, that subtree does not get to spend shader time.
## VibeGeometry Distillation
Local source paths worth treating as evidence:
- `E:\Projects\VibeGeometry\docs\research\procedural-doctrine.md`
- `E:\Projects\VibeGeometry\docs\research\translations\ircss-french-houses-node-atlas.md`
- `E:\Projects\VibeGeometry\docs\research\translations\ircss-french-houses-node-mechanisms.md`
- `E:\Projects\VibeGeometry\docs\research\dream-machine-grammar.md`
- `E:\Projects\VibeGeometry\docs\research\realtime-csg-doctrine.md`
- `E:\Projects\VibeGeometry\crates\vg_grammar\src`
- `E:\Projects\VibeGeometry\crates\vg_csg\src\tree.rs`
- `E:\Projects\VibeGeometry\crates\vg_csg\src\frontier.rs`
Distilled rules:
- Build the host form first; ornament grows on captured structure.
- Attributes/fields are not bookkeeping. They are how identity survives
instancing, deletion, scattering, and later detail passes.
- Groups can pass batons: running values such as angle, offset, frame, count,
seed, aperture, or path state should be explicit outputs.
- Scatter has layers: nursery surface, point field, instance body, realization
boundary. Aquarium's brush grammar should mirror this with tile/body domain,
spawn field, brush claim, and backend lowering.
- Paths are topology first. Draw cracks, veins, river paths, routes, or rails
after solving connectivity or stations.
- Boolean thickets often want tables. Repeated near-clone rules should become
one rule plus rows of parameters, not copied child subtrees.
- CSG is an adjudicator, not the authoring language. Domain rules should be
written in terrain, anatomy, tile, curve, or habitat coordinates, then lowered
into boolean/brush evaluation late.
- Demand frontiers beat world rebuilds. The query already says which tile,
proxy, dirty brush, screen cone, or body bound matters; use that before
building generic indexes.
## Brush Envelope
The current cheap circular/Gaussian brush idea is too blunt. The next primitive
should look more like a shaped Gaussian splat: an anisotropic bounded kernel
with rotation, scale, falloff, and optional local shape controls.
Relevant splatting lineage:
- EWA/surface/volume splatting uses elliptical Gaussian kernels and screen-space
filtering to avoid aliasing and excess blur.
- 3D Gaussian Splatting models scenes with explicit anisotropic 3D Gaussians,
each carrying position, opacity, scale, rotation/covariance, and appearance,
then bins/sorts/rasterizes them efficiently.
Aquarium should borrow the cheap structural idea, not the whole radiance-field
machine. A brush envelope can be stored as:
```text
center
orientation basis or compressed rotation
radii / inverse covariance diagonal
falloff exponent or compact support radius
shape parameters for taper, skew, ridge, hollow, or signed lobe response
payload type: material, height/displacement, SDF form, mask, light, debug
```
Hot shader evaluation should prefer compact-support anisotropic kernels over
infinite Gaussians:
```text
q = transpose(rotation) * (p - center)
m = dot(q * invRadii, q * invRadii)
support = saturate(1 - m)
weight = support * support * (3 - 2 * support)
```
That is not a true Gaussian. Good. Infinite support is expensive folklore unless
the renderer can cull it. The useful inheritance from Gaussian splatting is
anisotropic covariance-like shape control, tile/proxy binning, and LOD-aware
visibility, not religious devotion to exp().
## Sampling Strategy
SDF performance hinges on conservative bounds and choosing the right evaluator
late. Hart's sphere tracing framing is still the base law: steps are safe when
the distance estimate respects a derivative/Lipschitz bound. Aquarium already
learned the local version of this for thin agent SDF organs.
Migration target:
- Keep proxy bounds first. A tile or body proxy decides whether any expensive
grammar can matter.
- March conservative macro distance before micro detail.
- Evaluate grammar detail only after the ray enters a tile/body budget zone.
- Carry LOD tier, cost tier, step count, proxy-hit coverage, and grammar node
count debug views before trusting the system.
- Use cone/pixel-footprint-aware sampling for material/height detail; do not
trace subpixel grammar detail as geometry.
- Use prefiltered/mip summaries per tile and per grammar family where possible.
- Use stochastic/blue-noise selection for dense fine detail when exact full
evaluation would alias or exceed budget.
The hard rule: form SDF detail must have a conservative envelope and a measured
cost. Texture/detail brushes can be abundant; traced form brushes must earn
their steps.
## LOD And Contribution Caching
The LOD problem is not "pick a depth." The real question is: would this
subtree change currently visible pixels enough to justify its cost?
The model should combine three proven ideas:
- Geometry clipmaps: keep nested, incrementally updated caches around the
viewer for terrain-like domains, and fade between levels in transition
regions.
- Nanite-style hierarchy cuts: select exactly one useful representation along
each ancestor/descendant chain using projected error. Parent summaries let
traversal stop without asking every leaf for permission.
- Gaussian splat/octree LOD: score an anisotropic primitive by projecting its
covariance/envelope footprint into screen space, then choose coarser or finer
anchors based on pixel impact and budget.
For Aquarium's IFS brush tree, every node should cache a compact summary:
```text
nodeId
parentId / firstChild / childCount
domain: cubeFaceTile | bodyLocal | curveLocal | volumeLocal
localFrame
worldOrTileBound
envelopeBound
maxDisplacementOrDistanceError
maxMaterialDelta
normalConeOrGradientBound
opacityOrCoverageBound
frequencyBand / octave
estimatedCost
lastContributionScore
lastVisibleFrame
residentState
summaryPayloadHandle
childPayloadHandle
```
The projected contribution score can start simple:
```text
projectedRadiusPx = project_bound_radius(node.bound, camera)
projectedErrorPx = project_world_error(node.maxError, node.bound, camera)
materialScore = node.maxMaterialDelta * projectedCoveragePx
formScore = projectedErrorPx * visibleCoverageEstimate
score = max(formScore, materialScore) * importanceBias / estimatedCost
```
This is only a heuristic, but it protects the right invariant: children fade in
when their worst possible visible contribution rises above a pixel/error
threshold, and fade out when the parent summary is good enough. For anisotropic
brushes, use the projected ellipse/covariance footprint rather than a sphere
whenever that data is already cheap; otherwise fall back to a conservative
sphere/AABB.
The runtime cut:
```text
parentGoodEnough = parent.projectedErrorPx <= threshold
nodeNeeded = node.projectedErrorPx > threshold
renderNodeSummary = parentNeeded && !nodeNeeded
descend = nodeNeeded && budgetAllows && childrenResidentOrRequestable
```
For smooth transitions, each node gets a fade weight based on hysteresis around
the threshold:
```text
fade = smoothstep(thresholdLow, thresholdHigh, projectedErrorPx)
```
The parent summary fades out as children fade in. Geometry clipmap transition
regions are the terrain precedent; for object/body brushes this becomes
cross-fading material contribution or displacing geometry only after the
micro-form is stable enough not to pop. Never fade signed distance itself in a
way that breaks conservative marching. Fade payload amplitude, material
response, or selected child contribution after the macro hit is established.
### Dynamic Cache
The cache should be a demand cache, not a tree-shaped shrine in memory.
Inputs:
- camera and previous camera;
- visible cube-face tile windows or body proxy hits;
- node summaries resident on CPU/GPU;
- per-node contribution score from the previous frame;
- request budget for new child payloads;
- eviction budget for stale low-score payloads.
- update budget for how many node weights may be refreshed this frame.
Per frame:
1. Traverse only visible roots: active cube faces/tiles and body proxies.
2. Project each node summary and compute contribution score.
3. If the summary is enough, draw/evaluate it and stop.
4. If children are needed and resident, descend.
5. If children are needed and missing, render the parent summary, enqueue child
payloads, and keep a fade-in reservation.
6. Update a small score cache with temporal smoothing:
```text
score = lerp(oldScore, currentScore, currentVisible ? 0.35 : 0.08)
```
7. Evict children whose smoothed score stays below threshold for enough frames,
starting with high-cost low-score nodes.
### Probabilistic Weight Updates
The contribution cache cannot refresh every branch every frame. A large IFS tree
will always have more latent branches than budget. The cache therefore needs an
online estimator: every frame it samples a subset of nodes, updates their
weights, and lets the whole system converge over time.
The first version can be a stochastic scheduler, not a neural net:
```text
priority =
visibilityProbability
* staleWeight
* uncertainty
* max(previousScore, parentScoreBias)
/ max(estimatedUpdateCost, epsilon)
```
Nodes enter the update lottery from visible roots, near-threshold cut nodes,
recently visible descendants, and a small exploration set. The scheduler spends
the frame's update budget on weighted random samples, not only on the current
top scores. Greedy-only updates are how stale branches stay confidently wrong.
Each node should carry estimator state:
```text
meanContribution
varianceOrUncertainty
sampleCount
lastSampleFrame
lastVisibleFrame
decayRate
confidence
```
On update:
```text
delta = observedContribution - meanContribution
meanContribution += alpha * delta
variance = lerp(variance, delta * delta, beta)
confidence = saturate(confidence + confidenceGain)
```
On non-update:
```text
confidence *= decay
uncertainty += staleGrowth
```
This is machine-learning territory in the plain sense: an online model predicts
which branches matter, samples under budget, updates from observations, and
should converge toward stable weights for stable views. Start with exponential
moving averages, variance, and bandit-style exploration. Do not jump straight to
a bespoke neural predictor unless the simpler estimator cannot hit fidelity or
budget.
The convergence contract:
- Every resident visible or near-threshold node must have nonzero probability of
being resampled.
- High-uncertainty nodes get exploration budget even when their old score is
low.
- Scores decay when nodes go unseen, but summaries remain available.
- Parent and child scores must be monotonic enough for stable cuts: a parent
summary cannot claim less possible contribution than all of the children it
hides.
- Debug views must expose mean, variance/uncertainty, sample age, confidence,
and update probability.
The higher-fidelity path is learned scoring:
```text
features:
projected bound, projected anisotropic footprint, depth, view angle,
normal/gradient cone, material delta, motion, parent score, tile/body state,
historical score/variance, previous visibility, estimated cost
prediction:
expectedContribution
uncertainty
updatePriority
```
Training signal can come from occasional high-quality probes: render/evaluate a
more detailed subtree, compare against the parent summary, and record the
observed pixel/material/SDF delta. That makes the cache a budgeted fidelity
estimator rather than a pile of thresholds pretending to be wise.
Cut line: the runtime still needs deterministic safety. A learned predictor may
rank update and residency priority; it must not be the only guard for SDF
marching bounds or object/tile visibility. Conservative summaries remain law.
The contribution cache should be keyed by stable domain address, not by transient
allocation:
```text
cube face + quadtree path + grammar rule path
body id + local grammar rule path
```
For a GTX 1070-class target, keep the first implementation embarrassingly
simple:
- CPU scores coarse roots and streaming decisions.
- GPU evaluates fixed-size resident node tables per visible tile/body.
- Node summaries are small structured-buffer rows.
- A stochastic CPU/GPU scheduler refreshes only a bounded subset of node weights
each frame, using uncertainty and stale age so weights converge instead of
freezing.
- Child payload pages are append-only within a frame and compacted only at safe
sync points.
- Debug views show projected error, contribution score, uncertainty, sample age,
confidence, update probability, selected depth, cache residency, fade weight,
and estimated cost.
The cache must preserve summaries even when child payloads are evicted. A parent
summary is the fallback representation and the promise that the tree can always
render something coherent. If a subtree cannot summarize its children with a
bounded error/material delta, it is not ready to be an LOD subtree.
## Migration Shape
1. Add the cube-sphere domain math as generic renderer math, not Zyphos policy.
2. Convert Zyphos planet surface addressing from lat-long/spherical noise toward
six face quadtrees.
3. Replace height brushes with shaped anisotropic envelopes while preserving the
existing scalar height target until a better pass contract exists.
4. Define a minimal semantic brush-claim format: name, kind, frame, envelope,
payload, tags, LOD range, cost tier, seed, and children.
5. Preserve an authored IFS/CSG ownership tree even when the first backend
lowers it into flattened brush tables or SDF shader code.
6. Make one terrain grammar and one agent/body grammar share the same envelope
evaluator.
7. Add node summaries and contribution-score caching before allowing recursive
detail to expand at runtime.
8. Add debug visualizations before expanding detail density.
If the first implementation needs a cut, cut 3D recursive form detail first.
Keep the cube-sphere address space and shaped 2D brush envelope. Those are the
foundation stones.
## Sources
- PROJ QSC documentation: https://proj.org/en/stable/operations/projections/qsc.html
- NASA NTRS, "A quadrilateralized spherical cube Earth data base": https://ntrs.nasa.gov/citations/19810002572
- Clarberg, "Cube-to-sphere Projections for Procedural Texturing and Beyond,"
JCGT 2018: https://jcgt.org/published/0007/02/01/paper.pdf
- Kerbl et al., "3D Gaussian Splatting for Real-Time Radiance Field Rendering,"
SIGGRAPH/TOG 2023: https://arxiv.org/abs/2308.04079
- Hart, "Sphere tracing: A geometric method for the antialiased ray tracing of
implicit surfaces," The Visual Computer 1996:
https://experts.illinois.edu/en/publications/sphere-tracing-a-geometric-method-for-the-antialiased-ray-tracing/
- Zwicker et al., EWA surface/volume splatting lineage:
https://vcg.seas.harvard.edu/publications/20011001-ewa-volume-splatting
- Local VibeGeometry procedural and CSG research:
`E:\Projects\VibeGeometry\docs\research\procedural-doctrine.md`,
`E:\Projects\VibeGeometry\docs\research\dream-machine-grammar.md`,
`E:\Projects\VibeGeometry\docs\research\realtime-csg-doctrine.md`,
`E:\Projects\VibeGeometry\crates\vg_grammar`, and
`E:\Projects\VibeGeometry\crates\vg_csg`
- Losasso and Hoppe / Asirvatham and Hoppe geometry clipmaps:
https://hhoppe.com/proj/gpugcm/
- Karis et al., Nanite virtualized geometry:
https://advances.realtimerendering.com/s2021/Karis_Nanite_SIGGRAPH_Advances_2021_final.pdf
- Ren et al., Octree-GS LOD-structured Gaussian splatting:
https://arxiv.org/abs/2403.17898

View file

@ -1,72 +0,0 @@
# Fensalir Spine Emblem IFS Demo Reference
## Purpose
This note preserves the visual target for a possible Fensalir IFS demo scene.
The current fullscreen splash art is strong enough to become a procedural scene
rather than only a bitmap.
## Source Asset
- Splash bitmap: `src/Aquarium.Engine/Assets/Fensalir-Splash.png`
- Startup BMP copy: `src/Aquarium.Engine/Assets/Fensalir-Splash.bmp`
- Icon derivative: `src/Aquarium.Engine/Assets/Fensalir-Icon-96.png`
## Prompt Of Record
Use case: stylized-concept
Asset type: 16:9 engine splash art concept
Primary request: A bold, logo-adjacent Fensalir engine splash direction that
could sit behind a startup screen, combining Norse fen hall mythology with a
neon rendering engine identity.
Scene/backdrop: minimal dark void fading into shallow marsh water, abstract hall
silhouette made from vertical luminous runic pillars and clean renderer-like
geometry, reeds only at the extreme edges.
Subject: a central vertical spine of light formed by stacked translucent
polygons, runic strokes, and watery reflections, suggesting the engine as the
structural backbone of visible worlds.
Style/medium: sleek futuristic key visual, premium software splash art, less
literal, high contrast, graphic and memorable.
Composition/framing: centered emblem-like composition, strong silhouette, wide
negative space, symmetrical but not flat, the spine rising from reflective water
into mist.
Lighting/mood: intense cyan-white central light, restrained magenta edge glow,
tiny gold sparks, calm authority.
Color palette: black, cyan-white, magenta, muted gold, dark green-black.
Materials/textures: glass, black metal, wet stone, reflective water,
laser-etched rune light, subtle fog.
Constraints: no readable text, no logo, no watermark, no characters, no
weapons, no horned helmets, avoid looking like a crypto logo or generic esports
background.
## Implementation Invariants
- The central cyan-white vertical spine owns the scene. Everything else supports
it.
- The geometry should read as stacked translucent diamond/polygon forms, not a
literal tower or generic sci-fi logo.
- Magenta belongs in restrained side glow and reflected water, not as the main
light authority.
- The base is black reflective marsh water with minimal reeds at the edges.
- The hall/rune language should be abstract and structural. No readable runic
text.
- The scene must remain legible at splash scale and should degrade cleanly into
the 96 px icon silhouette.
## IFS Demo Cut Line
Keep the demo procedural if the IFS/fractal machinery can own the central spine,
pillar rhythm, and reflections directly. If the implementation needs a bag of
hand-placed quads plus post-process glow to resemble the splash, call it a
staged splash scene, not an IFS demo. The demo should prove an invariant of the
IFS work, not merely cosplay it with decoration.

View file

@ -1,948 +0,0 @@
# Fractal Brush Architecture Plan
Aquarium needs one multiscale spatial machine for planets, Grid surfaces, and
agent bodies. The target is not "infinite detail" as a slogan. The target is a
bounded semantic tree that can always answer four questions:
- What does this detail mean?
- What pixels could it affect?
- Is it worth its CPU, RAM, SSD, and GPU cost this frame?
- Which module owns the answer, and how do tests prove that boundary?
The architecture below is the coherent machine that falls out of the research
pass.
## Core Model
```text
client intent / world seed / agent visual state
-> domain roots
-> semantic IFS brush grammar
-> ownership tree
-> node summaries
-> contribution cache and residency scheduler
-> backend packets
-> renderer passes
-> debug and evidence
```
The authored tree is the source of truth. Backend packets are compiled output.
That distinction is the spine. If the grammar is flattened too early into
anonymous shader tables, we lose meaning, invalidation, LOD, test boundaries,
and future editing handles in one cheap little bonfire.
## Module Network
The architecture should be split into modules with narrow contracts and boring
mock seams. The goal is not maximum abstraction. The goal is that every module
owns one invariant that can be tested without booting the whole renderer.
```text
Aquarium.Engine.Contracts
Fractal domain, claim, tree, summary, packet, and debug data contracts.
Aquarium.Engine.Fractal
Pure CPU domain math, grammar expansion, ownership tree, summaries, scoring,
stochastic estimator, residency decisions, and backend packet planning.
Aquarium.Engine.Render
D3D12 resource residency, structured buffers, shader bindings, render graph
passes, and debug visualization.
Aquarium.Zyphos
Planet-specific domain roots, world seeds, terrain grammar choices, and
setting-safe visual policy.
Aquarium.Epiphany
Agent/body visual state, role grammar choices, and semantic state bindings.
Tests
Contract tests, pure CPU unit tests, renderer packet golden tests, shader
parity tests, and smoke/integration tests.
```
### Module Boundaries
| Module | Owns | Does Not Own | Mock Boundary |
| --- | --- | --- | --- |
| Contracts | Stable data shapes and versionable packets | Projection algorithms or D3D12 objects | Plain DTO fixtures |
| Fractal Domain | Cube-face addresses, projections, local frames | Client lore or GPU resources | `IProjection`, camera fixture |
| Grammar | Semantic claims and rule expansion | Rendering or residency | seeded rule fixtures |
| Ownership Tree | Boolean/IFS topology, bounds, stable keys | Shader packet layout | tree builder fixtures |
| Summary Builder | Conservative error/material summaries | Per-frame scheduling | child-node fixtures |
| Contribution Cache | score state, stochastic updates, convergence | payload loading or drawing | deterministic RNG, fake frame clock |
| Residency Scheduler | request/evict decisions under budgets | SSD implementation or descriptors | fake payload store |
| Backend Planner | selected cut to backend packets | D3D12 upload | packet golden files |
| D3D12 Backend | buffers, descriptors, shader passes | semantic grammar | packet fixtures and GPU smoke |
| Client Adapters | Zyphos/Epiphany policy | generic renderer math | fake engine contracts |
The hard rule: a module must be defended by the invariant it owns, not by how
cleverly it forwards data.
## Testing Strategy
This system only deserves to exist if it is testable at its natural seams.
### Pure Unit Tests
These run without D3D12:
- cube-face address round trips;
- projection candidate continuity and area-error sampling;
- local frame point/vector transforms;
- envelope CPU evaluator parity against known samples;
- grammar expansion from seed to deterministic claims;
- ownership tree bounds for union/subtract/common/repeat;
- summary conservativeness against generated children;
- projected score monotonicity with distance and error;
- stochastic estimator convergence with deterministic RNG;
- residency eviction/request decisions under fixed budgets.
### Contract Tests
These protect module boundaries:
- serialized contract rows round-trip without renderer types;
- stable keys remain stable across reload and equivalent grammar expansion;
- backend packet planner emits the same packet for the same selected cut;
- clients can declare domains and grammars without referencing D3D12.
### Mock Boundaries
Required mocks/fakes:
- `IFractalClock`: deterministic frame number and time.
- `IFractalRandom`: seeded random/update lottery.
- `IProjection`: projection candidate swap for tests.
- `IContributionProbe`: fake observed pixel/material/SDF delta.
- `IFractalPayloadStore`: fake RAM/SSD payload availability.
- `IFractalGpuBudget`: fake GPU table/page budget.
- `IFractalDebugSink`: captures telemetry without UI.
Mocks should sit at resource and observation boundaries, not inside algorithms.
Mocking every helper is how tests become a puppet show for implementation
details. Good tests feed the module its owned inputs and inspect its owned
outputs.
### Shader Parity Tests
For GPU math that must match CPU decisions:
- shaped envelope weight;
- cube-face projection used by shader sampling;
- packet decoding;
- selected-cut fade weights;
- conservative SDF bound constants.
The CPU implementation is the spec. The HLSL implementation is the fast copy.
When they disagree, the test should fail before the disagreement becomes a
screenshot mystery.
### Integration Tests
Small but real:
- build one cube-face terrain grammar into current height brush backend;
- render/debug one Zyphos planet frame headless;
- render one body proxy with selected-cut packet fixtures;
- verify debug modes expose score, uncertainty, selected depth, residency, and
cost.
### Performance Fixtures
Performance tests must be deterministic:
- fixed camera paths;
- fixed world seed;
- fixed update budget;
- fixed payload budget;
- fixed RNG seed;
- output: CPU ms, GPU ms if available, resident payload count, update count,
evictions, selected node count, score convergence.
Passing visual tests without performance fixtures is permission to grow a slow
machine with a nice face. No.
## Ownership
### Client
The client owns semantic intent:
- planet/world seed and setting policy;
- agent visual state and role/body grammar selection;
- authored grammar parameters and state bindings;
- stable domain ids for persistence and cache keys.
For Zyphos, this means planet style, biome rules, and simulation-facing terrain
policy. For Epiphany, this means role/body visual state and semantic feature
bindings. The engine should not learn client nouns just because the first
grammar is interesting.
### Engine Contracts
Contracts own portable declarations:
- domain roots;
- semantic brush claims;
- grammar tree nodes;
- node summary schema;
- backend packet schema;
- debug channel names.
Contracts must stay Vortice-free and renderer-agnostic. They describe the
machine; D3D12 is one lowering target.
### Engine Fractal Core
The fractal core owns pure CPU algorithms:
- cube-sphere domain math;
- grammar expansion;
- ownership tree bounds;
- summary generation;
- contribution scoring;
- stochastic estimator updates;
- selected-cut and residency decisions;
- backend packet planning.
This should be the main testable body of the system.
### Engine Renderer
The renderer owns:
- GPU buffers, textures, pages, and descriptors;
- screen-space scoring when moved to compute;
- backend packet upload;
- render graph execution;
- shader evaluation;
- visual diagnostics.
## Algorithms
### 1. Cube-Sphere Surface Domain
Planetary surfaces use six quadtree cube faces projected to a sphere. This cuts
lat-long pole singularities and gives each face a simple 2D tile domain.
Projection candidates:
- tangent warp: cheap, analytic inverse, acceptable area behavior;
- fifth-order odd polynomial warp: strong GPU candidate, low area error;
- COBE/QSC-style bivariate warp: best area behavior, more expensive;
- exact equal-area reference: validation and offline bake target, not default
hot path.
Tradeoff:
- Time: tangent/polynomial are cheapest per sample; COBE buys area quality.
- Memory: all use the same tile address space; no major residency difference.
- CPU/GPU: CPU can use exact or COBE for authoring/bakes; GPU hot shaders can
use tangent or polynomial if measured faster.
Decision rule: measure area error, inverse lookup cost, and shader time on the
actual target hardware before choosing.
### 2. Semantic IFS Brush Grammar
The grammar emits claims, not final triangles or shader code.
```text
claim {
stableKey
name
kind
domain
localFrame
envelope
payload
tags
lodRange
costTier
seed
children
}
```
Kinds:
- `material`
- `height`
- `displacement`
- `sdf_form`
- `void`
- `mask`
- `light`
- `debug`
Required grammar operations:
- `Union`
- `Subtract`
- `Common`
- `Repeat`
- `Refine`
- `Capture`
- `Bind`
VibeGeometry's useful lesson is the split between semantic claims and CSG
adjudication. Aquarium should keep that split even when the current renderer
only supports a flattened evaluator. The authored tree remains durable; the
flattened packets are expendable.
Tradeoff:
- Time: semantic grammar costs CPU work up front but lets runtime avoid global
interpretation in hot shaders.
- Memory: preserving tags, frames, and captures costs RAM, but buys cache keys,
debugging, and selective invalidation.
- SSD: authored grammar is compact; generated payloads and summaries can be
cached separately.
- GPU: shaders consume fixed compact packets instead of walking prose-shaped
abstractions.
### 3. Ownership Tree
The ownership tree is a lightweight CSG/IFS program:
```text
Rule tree
-> semantic claims
-> boolean/IFS ownership tree
-> node summaries
-> backend lowering
```
It is not a full CAD kernel in the first version. It needs:
- stable node ids;
- parent/child topology;
- branch operation;
- child ranges;
- node bounds;
- summary error;
- payload handles;
- debug labels.
Bounds follow operation semantics:
- union bounds are child-bound unions;
- subtraction bounds are source/left bounds;
- common/intersection bounds are child-bound intersections;
- repeat/refine bounds are generated or conservative aggregate bounds.
Tradeoff:
- Time: tree bounds stop traversal before child work.
- Memory: summaries are kept resident even when children are evicted.
- SSD: child payloads can stream by stable key.
- GPU: renderer receives selected cuts and compact resident node tables.
### 4. Shaped Brush Envelope
Every brush has a compact-support anisotropic envelope. It borrows the useful
part of Gaussian splatting: covariance-like shape and projected footprint. It
does not inherit infinite support as a default tax.
Hot evaluator:
```text
q = transpose(rotation) * (p - center)
m = dot(q * invRadii, q * invRadii)
support = saturate(1 - m)
weight = support * support * (3 - 2 * support)
```
Shape controls can add taper, skew, ridge, hollow, signed lobe, or curve-local
response, but every control must preserve an explicit conservative bound.
Tradeoff:
- Time: compact support enables culling and fixed packet limits.
- Memory: envelope rows are small and SIMD/GPU friendly.
- GPU: projected ellipse scoring is better than sphere scoring when affordable;
sphere/AABB fallback remains legal.
- Fidelity: anisotropy gives terrain ridges, rivers, petals, scars, and veins
fewer fake circular footprints.
### 5. Node Summaries
Every tree node needs a renderable summary:
```text
stableKey
parent
childRange
domain
localFrame
worldOrTileBound
envelopeBound
maxFormError
maxMaterialDelta
normalOrGradientBound
coverageBound
frequencyBand
estimatedCost
summaryPayloadHandle
childPayloadHandle
```
The summary is both fallback and promise. If a node cannot summarize its
children with bounded error, it is not ready to be an LOD node.
Tradeoff:
- Time: summaries stop traversal and keep missing children renderable.
- Memory: summaries stay hot; child payloads can be evicted.
- SSD: summaries form the streaming index.
- GPU: fixed summary buffers feed scoring and debug views.
### 6. Contribution Scoring
The first score is heuristic and conservative:
```text
projectedRadiusPx = project_bound_radius(node.bound, camera)
projectedErrorPx = project_world_error(node.maxFormError, node.bound, camera)
materialScore = node.maxMaterialDelta * projectedCoveragePx
formScore = projectedErrorPx * visibleCoverageEstimate
score = max(formScore, materialScore) * importanceBias / estimatedCost
```
LOD cut:
```text
render summary if parent needed and node is good enough
descend if node is not good enough, children are resident, and budget allows
request children if node is not good enough and children are missing
```
Fade:
- use hysteresis around the threshold;
- fade payload amplitude, material response, or selected child contribution;
- do not fade signed-distance safety.
Tradeoff:
- Time: cuts avoid leaf scoring.
- Memory: only selected subtrees need payload residency.
- GPU: scoring can move progressively from CPU to compute.
- Fidelity: error thresholds target visible pixel impact, not arbitrary depth.
### 7. Probabilistic Online Estimator
The contribution cache cannot refresh every branch every frame. It must update
weights probabilistically under budget and converge.
Estimator state:
```text
meanContribution
varianceOrUncertainty
sampleCount
lastSampleFrame
lastVisibleFrame
confidence
decayRate
updateProbability
```
Sampling priority:
```text
priority =
visibilityProbability
* staleWeight
* uncertainty
* max(previousScore, parentScoreBias)
/ max(estimatedUpdateCost, epsilon)
```
Update:
```text
delta = observedContribution - meanContribution
meanContribution += alpha * delta
variance = lerp(variance, delta * delta, beta)
confidence = saturate(confidence + confidenceGain)
```
Non-update:
```text
confidence *= decay
uncertainty += staleGrowth
```
This is machine learning in the practical online-estimation sense. The first
version should be an exponential moving average plus variance and bandit-style
exploration. A later learned predictor may rank update priority and residency,
but it must not own conservative SDF bounds, object visibility, or safety.
Tradeoff:
- CPU: stochastic scheduling keeps per-frame management bounded.
- RAM: estimator state is a few scalars per summary.
- SSD: probes and observed deltas can become offline training/evidence data.
- GPU: high-quality probes can be sparse and amortized.
- Fidelity: exploration prevents stale branches from staying confidently wrong.
### 8. Residency And Streaming
Stable key:
```text
planet: cubeFace + quadtreePath + grammarPath
body: bodyId + grammarPath
curve: ownerId + curvePath + grammarPath
```
Memory tiers:
- CPU hot: visible roots, node summaries, estimator state, selected cuts.
- CPU warm/RAM: nearby summaries and recently used child payload metadata.
- SSD: serialized child payload pages, baked summaries, probe history.
- GPU hot: selected cut buffers, resident payload pages, tile/body packet tables.
Residency rules:
- summaries outlive child payloads;
- parent summary renders while child payload streams;
- evict low-score high-cost children first;
- keep near-threshold children longer to avoid popping;
- write payload pages append-only within a frame;
- compact only at safe sync points.
Tradeoff:
- Time: avoid global rebuilds and expensive compaction mid-frame.
- Memory: child payloads are elastic; summaries are stable.
- SSD: streaming is by stable semantic key, not transient allocation.
- GPU: residency buffers stay compact and bounded.
### 9. Backend Lowering
One authored tree can lower into several backend shapes:
- 2D tile height/material brushes;
- scalar height-field brush pass;
- object-local SDF form packets;
- anisotropic impostor/splat batches;
- offline mip summaries;
- debug overlays.
The first backend should be deliberately modest:
- shaped 2D terrain brushes on cube-face tiles;
- CPU-side node summary and contribution cache;
- fixed GPU structured buffers;
- one Zyphos planet demo path;
- one Epiphany/body proof path later.
Tradeoff:
- Time: start with the cheapest visible proof.
- Memory: defer 3D recursive form until 2D summaries and scoring are real.
- GPU: fixed tables are ugly but honest; graph-driven dynamic evaluation comes
after debug views prove the cut.
## Resource Strategy
### CPU
CPU owns grammar expansion, coarse scoring, stochastic update scheduling,
streaming requests, eviction, selected cuts, and debug collection. CPU must
avoid per-leaf full traversal, synchronous SSD waits, and rebuilding payload
pages during rendering.
### RAM
RAM owns node summaries, estimator state, warm payload metadata, recent probe
results, and selected cuts. RAM should not keep all recursive child payloads
resident.
### SSD
SSD owns serialized payload pages, offline baked summaries, probe/evidence
datasets, and optional learned-predictor training data. SSD is never on the
critical path for a missing child this frame; the parent summary renders.
### GPU
GPU owns selected-cut evaluation, brush envelope evaluation, height/material
passes, SDF proxy evaluation, optional compute scoring, and debug visualization.
GPU must not walk the whole grammar tree or trust learned scores for
conservative distance safety.
## Data Structures
### `AquariumFractalDomain`
```text
domainId
kind
rootKey
rootFrame
projection
bound
clientOwner
```
### `AquariumBrushClaim`
```text
stableKey
kind
domainId
localFrame
envelope
payload
tags
lodRange
costTier
seed
```
### `AquariumFractalNode`
```text
stableKey
operation
claimRange
childRange
summary
debugName
```
### `AquariumFractalSummary`
```text
bound
maxFormError
maxMaterialDelta
normalOrGradientBound
coverageBound
estimatedCost
summaryPayload
childPayload
```
### `AquariumContributionState`
```text
mean
variance
confidence
sampleCount
lastSampleFrame
lastVisibleFrame
updateProbability
residentState
fade
```
### `AquariumSelectedCut`
```text
domainId
nodeKey
lodDepth
fade
payloadHandle
debugScore
```
## Render Frame Flow
```text
1. Collect visible domains from camera, cube faces, and SDF proxy bounds.
2. Load resident summaries for visible roots.
3. Score summaries against the camera and previous history.
4. Select a hierarchy cut under CPU/GPU budget.
5. Queue missing child payloads; render parent summaries meanwhile.
6. Sample a probabilistic subset of node weights for estimator convergence.
7. Upload selected cut and resident payload tables.
8. Run tile/body backend passes.
9. Resolve fade transitions and temporal history.
10. Emit debug surfaces and cache telemetry.
```
## Debug Views
No recursive detail without these:
- cube face / tile id;
- selected LOD depth;
- projected error;
- mean contribution;
- uncertainty;
- sample age;
- update probability;
- residency state;
- fade weight;
- estimated cost;
- selected backend;
- SDF step count where form detail is active.
## Implementation Roadmap
### Phase 0: Architecture Freeze
Deliverables:
- this plan;
- updated research note;
- state memory/map entries.
Exit criteria:
- the live architecture can be explained without hidden compensators;
- module boundaries and test seams are named;
- roadmap names what gets cut if complexity rises.
### Phase 1: Cube-Sphere Projection Harness
Build:
- generic cube-face address type;
- forward/inverse projection candidates;
- area distortion test harness;
- GPU shader microbench for candidate mappings;
- Zyphos debug view for face/tile coordinates.
Tests:
- projection round trips;
- face-edge continuity cases;
- sampled area distortion baselines;
- shader/CPU projection parity.
Cut line:
- if projection research blocks rendering, ship tangent first behind an
interface and keep the measured comparison harness.
### Phase 2: Shaped Brush Envelope
Build:
- shared CPU/GPU envelope struct;
- compact-support anisotropic evaluator in HLSL;
- CPU mirror evaluator for tests;
- replacement path for current circular height brushes;
- debug view for brush bounds and weight.
Tests:
- CPU/GPU evaluator parity;
- support bound rejection;
- deterministic packet serialization;
- side-by-side old/new brush fixture.
Cut line:
- start with rotation plus radii and falloff only. Taper/skew/ridge wait.
### Phase 3: Semantic Claims And Ownership Tree
Build:
- contract structs for domains, claims, nodes, summaries;
- small in-memory grammar builder;
- union/subtract/common/repeat/refine/capture/bind operations;
- flattening compiler to current brush tables;
- debug dump of authored tree and flattened backend packets.
Tests:
- seeded grammar determinism;
- stable keys across reload;
- operation bounds for union/subtract/common;
- flattening golden packets;
- client adapter does not reference renderer types.
Cut line:
- do not implement full CAD CSG. Keep boolean ownership semantic and bounded.
### Phase 4: Node Summaries
Build:
- summary generation for 2D terrain brush trees;
- max error/material delta fields;
- parent fallback payload;
- serialization format for summaries;
- debug view for summary bounds and error.
Tests:
- summary conservativeness against generated children;
- parent fallback when children are missing;
- summary serialization round trips;
- evicted children do not remove fallback.
Cut line:
- if exact summary generation is hard, use conservative amplitude/frequency
bounds first. Ugly but safe beats precise lies.
### Phase 5: Contribution Cache
Build:
- stable-keyed contribution table;
- projected error scorer;
- selected-cut builder;
- hysteresis fade weights;
- debug views for score, depth, fade, cost.
Tests:
- distance monotonicity;
- stable cut under small camera motion;
- budget clamp behavior;
- fade never changes conservative SDF bounds.
Cut line:
- keep scoring on CPU until the data layout is stable.
### Phase 6: Probabilistic Online Updates
Build:
- estimator state per node;
- stochastic update scheduler;
- exploration budget;
- confidence decay;
- high-quality probe hook;
- telemetry for convergence.
Tests:
- deterministic RNG schedule fixture;
- visible and near-threshold nodes have nonzero update probability;
- stale wrong score recovery;
- convergence under fixed camera path;
- update cost respects budget.
Cut line:
- no neural predictor until EMA/variance/bandit scheduling fails with evidence.
### Phase 7: Residency And Streaming
Build:
- CPU warm cache;
- GPU payload pages;
- SSD-backed payload serialization;
- child request queue;
- eviction policy;
- safe compaction points.
Tests:
- fake payload store miss renders parent summary;
- request queue ordering;
- eviction by score/cost;
- frame path never blocks on fake SSD.
Cut line:
- first version can keep payloads generated in RAM and simulate SSD requests.
The API must still be page/key based.
### Phase 8: Zyphos Planet Integration
Build:
- cube-sphere terrain domain in Zyphos;
- first terrain grammar: continents/ridges/craters/material bands;
- selected-cut-driven tile payloads;
- atmospheric/planet shader sampling from tile domain.
Tests:
- planet renders across face edges;
- debug views identify face/tile/LOD/cache state;
- old spherical-noise path remains only as baseline fixture or is removed.
Cut line:
- if 3D body grammar tempts us here, cut it. Planet terrain proves the address,
envelope, summary, and LOD cache first.
### Phase 9: Agent Body Grammar Pilot
Build:
- one role body grammar using semantic claims;
- object-local summary tree;
- material/detail brush payloads before traced form recursion;
- SDF proxy integration through selected cut.
Tests:
- silhouette coherent across LODs;
- fine detail fades by contribution;
- SDF step count stays inside budget;
- no scene-global mega-SDF.
Cut line:
- if form recursion is unstable, keep recursive detail in material payloads and
leave traced form as coarse base SDF.
### Phase 10: Learned Scoring Research Gate
Build only after telemetry exists:
- probe dataset writer;
- offline analysis of predicted vs observed contribution;
- small learned predictor or calibrated regression;
- A/B mode against heuristic scheduler.
Tests:
- held-out camera paths;
- learned mode beats heuristic fidelity/cost clearly;
- conservative summaries still own safety;
- fallback heuristic remains available.
Cut line:
- if learned scoring cannot beat the estimator clearly, delete it.
## Current First Slice
The first implementation slice should be:
```text
cube-face address + projection harness
-> shaped 2D brush envelope
-> semantic claim/tree structs
-> flatten to current height brush pass
-> debug face/tile/brush/score overlays
```
This gives a coherent foundation without forcing the whole fractal object system
to exist at once. The machine earns recursion only after address, envelope,
summary, tests, and debug surfaces are real.
## Sources
This plan consolidates:
- `research/rendering/cube-sphere-fractal-brushes.md`
- local VibeGeometry `vg_grammar` and `vg_csg` tree work;
- QSC / cube-sphere projection research;
- EWA and 3D Gaussian splatting envelope ideas;
- Hart sphere tracing;
- geometry clipmaps;
- Nanite-style projected-error hierarchy cuts;
- Octree-GS / LOD-structured Gaussian splatting;
- Aquarium's existing SDF proxy, height brush, and debug-pass constraints.

View file

@ -1,597 +0,0 @@
# Fractal Brush Implementation Roadmap
This is the execution map for the fractal brush architecture in
`research/rendering/fractal-brush-architecture-plan.md`.
The architecture is allowed to be ambitious. This roadmap is not. It exists to
keep the work shippable, testable, and cuttable.
## Objective
Build a reusable mapped fractal field system for planetary terrain, tileable
surface detail, and later agent/body detail.
The first live proof is deliberately narrow:
```text
cube-face address + projection harness
-> shaped 2D brush envelope
-> semantic claim/tree structs
-> flatten to current height brush pass
-> debug face/tile/brush/score overlays
```
No 3D recursive body form. No learned predictor. No streaming page store. The
first pass proves address, envelope, semantic tree, flattening, tests, and
debug visibility.
## Standing Invariants
- The authored semantic tree is the source of truth.
- Backend packets are compiled output and may be replaced.
- Every domain, node, claim, and payload has a stable key.
- Every LOD subtree has a conservative parent summary.
- The cache may learn priority; it may not own field safety.
- Missing children render through parent summaries instead of stalling.
- Debug views are required before recursive detail density increases.
- Each module owns one invariant and exposes a mockable seam.
## Module Targets
### `Aquarium.Engine.Contracts`
Owns portable DTOs:
- `AquariumFractalDomain`
- `AquariumBrushClaim`
- `AquariumFractalNode`
- `AquariumFractalSummary`
- `AquariumContributionState`
- `AquariumSelectedCut`
- backend packet rows
- debug channel rows
Tests:
- serialization round trips;
- stable key equality;
- no renderer/Vortice dependency.
### `Aquarium.Engine.Fractal`
Owns pure CPU algorithms:
- cube-face addressing;
- projection candidates;
- local frame/domain math;
- grammar expansion;
- ownership tree bounds;
- summary building;
- contribution scoring;
- stochastic estimator;
- selected-cut planning;
- residency decisions.
Tests:
- regular unit tests with deterministic fakes;
- no D3D12 boot required.
### `Aquarium.Engine.Render`
Owns D3D12 lowering:
- GPU buffers;
- descriptor binding;
- shader packet decode;
- height/material/Form/Appearance/Transport backend passes;
- debug visualizations.
Tests:
- packet golden tests;
- CPU/HLSL parity tests;
- headless smoke renders.
### Client Adapters
`Aquarium.Zyphos` owns planet domain roots and terrain grammar policy.
`Aquarium.Epiphany` later owns agent/body grammar selection and semantic state
bindings.
Tests:
- clients emit contracts without renderer types;
- stable seeds emit stable keys.
## Mock Boundaries
Add these before the modules need them:
- `IFractalClock`: deterministic frame/time.
- `IFractalRandom`: deterministic update lottery.
- `IProjection`: projection candidate swap.
- `IContributionProbe`: observed pixel/form/appearance/transport delta.
- `IFractalPayloadStore`: fake RAM/SSD payload availability.
- `IFractalGpuBudget`: fake GPU table/page limits.
- `IFractalDebugSink`: telemetry capture without UI.
Mock resources and observations, not private helper functions. The tests should
pin module contracts, not puppet implementation details.
## Phase 0: Test And Project Skeleton
Goal: create the module/testing skeleton before algorithm code spreads.
Tasks:
- [x] Add `src/Aquarium.Engine.Fractal`.
- [x] Add `tests/Aquarium.Engine.Fractal.Tests`.
- [x] Add project references from tests to contracts/fractal core.
- [x] Add deterministic fake clock/random/debug sink test helpers.
- [x] Add a boundary test proving `Aquarium.Engine.Fractal` has no D3D12/Vortice dependency.
Exit gate:
- `dotnet build Aquarium.Engine.sln`
- `dotnet test tests/Aquarium.Engine.Fractal.Tests`
Cut line:
- If module wiring fights the existing solution, keep the new core smaller, not
sneak fractal logic into `D3D12Renderer`.
## Phase 1: Cube-Face Address And Projection Harness
Goal: establish stable planetary tile addresses and measured projection choices.
Tasks:
- [x] Add `CubeFace`, `CubeTileKey`, and quadtree path helpers.
- [x] Add local tile uv to cube-face coordinate conversion.
- [x] Implement identity-normalize projection as baseline.
- [x] Implement tangent projection candidate.
- [ ] Implement fifth-order odd polynomial projection candidate.
- [ ] Add placeholder/interface for COBE/QSC candidate.
- [x] Add area distortion sampler.
- [x] Add face-edge continuity tests.
- [x] Add CPU benchmark or fixture reporting candidate costs.
- [ ] Add Zyphos debug mode that colors face/tile coordinates.
Tests:
- projection round trips where inverse exists;
- face-edge continuity cases;
- deterministic tile key formatting;
- sampled area error fixtures.
Exit gate:
- projection report names current default and measured error/cost;
- Zyphos can show face/tile debug colors.
Cut line:
- Ship tangent projection first if polynomial/COBE cost delays visible work.
Keep the harness so the choice stays measurable.
## Phase 2: Shaped 2D Brush Envelope
Goal: replace circular brush thinking with compact-support anisotropic fields.
Tasks:
- [x] Add CPU `FractalBrushEnvelope2D`.
- [x] Add HLSL packed envelope row.
- [x] Implement compact-support ellipse evaluator.
- [x] Add CPU/HLSL parity fixture.
- [x] Add brush bounds and support rejection tests.
- [x] Add a small adapter that lowers one claim into the current height brush path.
- [ ] Add debug overlay for envelope bounds and weight.
Tests:
- weight at center/edge/outside support;
- rotation parity;
- deterministic packet packing;
- old circular brush fixture still reproducible through a round envelope.
Exit gate:
- current height pass can render shaped 2D envelopes through a compatibility adapter.
Cut line:
- Do not add taper/skew/ridge controls until the basic envelope has debug views
and parity tests.
## Phase 3: Semantic Claims And Ownership Tree
Goal: preserve authoring meaning before backend flattening.
Tasks:
- [x] Add contract DTOs for domains, claims, nodes, summaries, and selected cuts.
- [x] Add stable key builder for planet tile + grammar path.
- [x] Add in-memory ownership tree builder.
- [x] Implement operations: `Union`, `Subtract`, `Common`, `Repeat`, `Refine`, `Capture`, `Bind`.
- [ ] Implement operation bounds.
- [x] Add flattening compiler to shaped 2D brush packets.
- [x] Add debug dump for authored tree and flattened packets.
- [x] Preserve multiple `.aquageo` tile roots in the ownership tree instead of
collapsing every claim under the final tile.
- [x] Add compact `flame` grammar primitive for deterministic curled recursive
brush emission.
Tests:
- [x] seeded grammar determinism;
- stable keys across equivalent expansion;
- union/subtract/common bounds;
- flattening golden packets;
- client contracts contain no renderer type.
Exit gate:
- one toy terrain grammar emits semantic claims, an ownership tree, and flattened brush packets.
Cut line:
- Do not build a CAD CSG kernel. This tree adjudicates semantic ownership and
lowering, not arbitrary mesh booleans.
## Phase 4: Node Summaries
Goal: make every LOD subtree renderable without its children.
Tasks:
- [x] Add summary builder for 2D brush tree nodes.
- [x] Track max height/form error.
- [x] Track max material delta placeholder.
- [x] Track coverage and estimated cost.
- [ ] Add parent fallback payload.
- [ ] Add summary serialization.
- [ ] Add debug overlay for summary bounds/error.
Tests:
- parent summary conservatively covers child height contribution;
- child eviction leaves parent renderable;
- summary round trip preserves error bounds;
- summary cost monotonically covers descendants.
Exit gate:
- selected parent summary can render when children are absent.
Cut line:
- Use conservative amplitude/frequency bounds before clever exact summaries.
## Phase 5: Contribution Cache And Selected Cut
Goal: choose a stable hierarchy cut without scoring every leaf.
Tasks:
- [x] Add stable-keyed contribution table.
- [x] Add projected error scorer.
- [x] Add selected-cut builder.
- [x] Add hysteresis fade weights.
- [x] Add budget clamp.
- [x] Add debug channels: score, depth, fade, estimated cost.
- [x] Add explicit resource budget plan for CPU update count, GPU estimated
packet cost, RAM residency, and SSD request pressure.
Tests:
- [x] score decreases with distance for fixed error;
- [x] score increases with error for fixed distance;
- small camera motion keeps cut stable;
- [x] budget clamp chooses parent summaries;
- fade never changes conservative SDF/height safety fields.
Exit gate:
- toy terrain grammar selects parent at distance and children nearby.
Cut line:
- Keep scoring on CPU until data layout and debug channels are stable.
## Phase 6: Probabilistic Online Updates
Goal: refresh contribution estimates under budget and converge without
pretending an online smoother is ReSTIR.
Research authority:
- ReSTIR DI: candidate generation plus temporal and spatial reservoir reuse for
real-time direct lighting.
https://research.nvidia.com/labs/rtr/publication/bitterli2020spatiotemporal/
- ReSTIR GI: path reservoirs for indirect lighting, with final shading fed by a
selected reservoir sample.
https://research.nvidia.com/publication/2021-06_restir-gi-path-resampling-real-time-path-tracing
- GRIS: the generalized theory for correlated samples, unknown PDFs, varied
domains, and shift mappings. This is the fractal/SDF/sensor-fusion door.
https://research.nvidia.com/labs/rtr/publication/lin2022generalized/
- RTXDI integration docs: initial sampling, temporal reprojection, spatial
reuse, validation, shift mapping, reservoir buffers, and denoiser guide
buffers.
https://github.com/NVIDIA-RTX/RTXDI/blob/main/Doc/RestirGI.md
https://github.com/NVIDIA-RTX/RTXDI/blob/main/Doc/RestirPT.md
Tasks:
- [x] Add pure `ResampledImportanceReservoir<TSample>` core with selected
sample, target, weight sum, represented candidate count, merge, and
contribution weight.
- [x] Add estimator state: mean, variance/uncertainty, confidence, sample count, sample age.
- [x] Add stochastic scheduler with deterministic random source.
- [x] Add exploration budget.
- [x] Add live contribution cache that observes only scheduled nodes each frame
and feeds the next resource plan.
- [x] Promote contribution state into `FractalOccupancyGraph` with visibility,
update probability, confidence decay, and stale uncertainty growth.
- [x] Convert contribution-cache refresh into a candidate generator that submits
weighted candidates to the resampled-importance core.
- [x] Add fractal SDF probe candidate records: domain key, local frame, bound,
projected contribution target, source PDF, payload handle, and material delta.
- [x] Add structural probe generator that lowers IFS node summaries into
`FractalProbeSample` reservoir candidates.
- [x] Add selected-cut structural probe reservoir and expose it through Zyphos'
fractal render plan/debug dump.
- [ ] Add temporal reuse validation: camera motion, domain ancestry, local-frame
error, bounds, and disocclusion/material compatibility. Domain ancestry and
local-shift checks exist; camera/disocclusion/material validation still need
renderer inputs.
- [x] Add CPU temporal reuse validation contract with camera motion,
disocclusion, material mismatch, visibility, domain ancestry, local-frame
error, and bound rejection reasons.
- [ ] Add spatial reuse validation across screen tiles and cube-sphere neighbor
domains.
- [ ] Feed reservoir confidence, selected target, candidate count, sample age,
and invalidation reason into TAA guide/history buffers. Current
reservoir confidence, sample age, domain validity, and invalidation code now
have explicit scene/history reservoir guide targets; selected target and
candidate count still need producer-side packing once GPU reservoirs exist.
- [x] Add confidence decay and stale uncertainty growth.
- [ ] Add fake contribution probe.
- [ ] Add convergence telemetry.
- [ ] Add debug channels: uncertainty, sample age, update probability,
reservoir weight sum, selected target, candidate count, reuse validity.
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;
- stale wrong score recovers after observations;
- fixed camera path converges within tolerance;
- [x] temporal reuse rejects mismatched domain ancestry;
- [x] spatial reuse rejects invalid local-frame shifts;
- [x] temporal reuse rejects camera motion, disocclusion, material mismatch,
and low visibility;
- [x] TAA history confidence is modulated by current reservoir confidence;
- [x] TAA history confidence drops when previous-frame reservoir/domain validity changes;
- update cost respects budget.
Exit gate:
- cache updates only a bounded subset per frame while selected cuts converge,
and the selected detail path is driven by tested reservoir math rather than a
frozen score table.
Cut line:
- No neural predictor until the ReSTIR/GRIS-shaped reservoir pipeline fails
with captured telemetry. No consumer-owned temporal cache unless it clearly
owns raw capture retention rather than resolved evidence.
## Phase 7: Residency Simulation
Goal: prove page/key semantics before real SSD/GPU streaming.
Tasks:
- [x] Add renderer-agnostic surface page key/metadata contracts for height,
2D signed-distance, material, and confidence pages.
- [x] Add CPU surface page planner that lowers selected IFS cuts into stable
page metadata.
- [x] Add mockable surface page store boundary with resident/request/evict
decisions under a byte budget.
- [x] Add CPU surface page rasterizer for resident height, 2D signed-distance,
material, and confidence pages.
- [x] Add fake payload store with resident/missing states.
- [x] Add child request queue.
- [x] Add score/cost eviction policy.
- [x] Add parent-summary fallback path.
- [x] Clamp simulated RAM residency and SSD requests through the resource plan.
- [x] Add debug channels: residency, requests, evictions.
Tests:
- [x] missing child renders parent summary;
- [x] high-score missing child enqueues request;
- [x] low-score high-cost payload evicts first;
- [x] frame path never blocks on fake store.
Exit gate:
- selected cut can tolerate missing children without frame stalls.
Cut line:
- Simulate SSD first. Real async file IO waits until page semantics are boring.
## Phase 8: Zyphos Planet Proof
Goal: replace spherical terrain demo path with cube-sphere domain proof.
Tasks:
- [x] Add Zyphos cube-sphere domain root.
- [x] Add first tile grammar: ridges, craters, and material bands.
- [x] Add first flame-style microdetail grammar line inside the Zyphos leaf
domain.
- [x] Lower selected cut to shaped 2D height/material brush packets.
- [x] Feed planet shader from tile-local cube-face domains for post-hit
material/detail evaluation.
- [x] Route Zyphos render-plan construction through the resource budget planner.
- [x] Update Zyphos contribution cache every render-plan request while reusing
compiled brush packets for equivalent selected cuts.
- [x] Feed traced planet geometry from a hard-budgeted top-influence subset of
selected tile packets.
- [x] Lower the structural probe reservoir into a first compact 3D SDF splat
packet for inspection.
- [x] Lower resident 2D SDF pages into bounded projected 3D splat packets
through an explicit page-to-world mapping boundary.
- [x] Add first face/tile/material influence debug view through render debug
mode 11.
- [ ] Add LOD/cache debug overlays.
- [ ] Remove or quarantine old spherical noise path as baseline fixture.
Tests:
- face seams render continuously;
- [x] tile keys remain stable under camera movement;
- debug modes show selected depth and cache state;
- headless Zyphos smoke render passes.
Exit gate:
- Zyphos renders terrain from semantic cube-sphere tile grammar.
Cut line:
- Do not start agent-body recursive form here. Planet terrain proves the shared
address, envelope, summary, and cache spine first.
## Phase 9: Agent/Body Pilot
Goal: prove the same grammar works for object-local detail.
Tasks:
- [ ] Pick one role/body with a stable base SDF.
- [ ] Add object-local domain root.
- [ ] Add 2D material/detail claims before traced form recursion.
- [ ] Add object-local summary tree.
- [ ] Integrate selected cut with SDF proxy packet.
- [ ] Add debug channels for body detail score and SDF cost.
Tests:
- silhouette stays coherent across LOD;
- material/detail fades by contribution;
- SDF step budget remains bounded;
- no scene-global mega-SDF path appears.
Exit gate:
- one body uses semantic detail grammar without violating proxy bounds.
Cut line:
- If form recursion destabilizes tracing, keep recursive detail in material
payloads and leave traced form coarse.
## Phase 10: Learned Scoring Research Gate
Goal: only add learned scoring after the estimator produces data.
Tasks:
- [ ] Add probe dataset writer.
- [ ] Capture camera paths, predicted scores, observed deltas, and costs.
- [ ] Analyze heuristic error offline.
- [ ] Try calibrated regression or tiny predictor.
- [ ] Add A/B mode against heuristic estimator.
Tests:
- held-out camera path comparison;
- learned mode beats heuristic fidelity/cost clearly;
- conservative summaries still own safety;
- fallback heuristic remains available.
Exit gate:
- learned scorer improves measured fidelity/cost without removing deterministic safety.
Cut line:
- If it does not beat the estimator clearly, delete it. No ornamental oracle.
## Phase 11: GPU-Resident Splat And Reservoir Receipt
Goal: prove hot fractal splat and reservoir population belongs on the GPU, not
in managed object graphs.
Tasks:
- [x] Add packed GPU splat buffer contract with no per-splat strings or object
ownership.
- [x] Add native packed CPU buffer helper for the rare staging/readback paths
that genuinely need C# memory.
- [x] Add D3D12 compute receipt harness that writes millions of splats directly
into a GPU-resident UAV.
- [x] Add distinct GPU reservoir packet contracts for the first opaque slice:
SDF envelopes, PBR material envelopes, and radiosity. These are backend packet
names for Form, Appearance, and Transport, not the permanent conceptual
architecture.
- [x] Sample those three reservoir layers in independent compute passes under a
stochastic per-frame update budget.
- [x] Measure with GPU timestamp queries and read back only a tiny checksum
sample.
Receipt:
- GTX 1070, `2,000,000` splats, `2,000,000` SDF/Form reservoirs,
`2,000,000` PBR/Appearance reservoirs, and `2,000,000`
radiosity/Transport reservoirs.
- Packet sizes: `80` bytes/splat, `64` bytes/reservoir.
- Budget: `50,000` updates per reservoir pass per frame, `2`
candidates/update, full reservoir coverage in `40` frames.
- GPU time: `4.471 ms/frame`.
- Equivalent throughput: `223.7 FPS`, about `447.3M` splats/sec.
- Command: `.\scripts\fractal-splat-receipt.ps1 -Splats 2000000 -Frames 120 -Candidates 2 -ReservoirUpdates 50000`.
Exit gate:
- Receipt must show plural-millions of GPU-resident splats and distinct
reservoir layers populated or updated at hundreds of FPS equivalent on the
target tired GTX 1070.
Cut line:
- This is compute-side population and cache-update throughput, not final shaded
visibility. Do not sell it as renderer completion until the
draw/raster/resolve path consumes the same packed buffers.
- The next coherent packet evolution is density/extinction Form plus
participating-medium Appearance/Transport. Do not force flames or sensor
confidence volumes through solid SDF semantics just because the first packet
names said SDF.
## First Work Packet
Start here:
1. Add `Aquarium.Engine.Fractal` and its test project.
2. Add cube-face/tile key types.
3. Add identity and tangent projection candidates.
4. Add area distortion sampler and face-edge tests.
5. Add a simple projection report fixture.
Definition of done:
- `dotnet build Aquarium.Engine.sln`
- new fractal tests pass;
- projection report is checked into test output or docs;
- `state/evidence.jsonl` records the chosen first projection default and why.

View file

@ -1,22 +0,0 @@
# Cube-Sphere Projection Report
Unit sphere area target per cube-coordinate area is `pi / 6`.
Lower mean absolute relative area error is better for terrain density.
| Projection | Face | Samples | Avg area scale | Min rel area | Max rel area | Mean abs rel error | RMS rel error |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| normalize | PositiveX | 16 | 0.524151 | 0.417233 | 1.887723 | 0.315999 | 0.378463 |
| normalize | NegativeX | 16 | 0.524151 | 0.417233 | 1.887723 | 0.315999 | 0.378463 |
| normalize | PositiveY | 16 | 0.524151 | 0.417233 | 1.887723 | 0.315999 | 0.378463 |
| normalize | NegativeY | 16 | 0.524151 | 0.417233 | 1.887723 | 0.315999 | 0.378463 |
| normalize | PositiveZ | 16 | 0.524151 | 0.417233 | 1.887723 | 0.315999 | 0.378463 |
| normalize | NegativeZ | 16 | 0.524151 | 0.417233 | 1.887723 | 0.315999 | 0.378463 |
| tangent | PositiveX | 16 | 0.523892 | 0.873521 | 1.175214 | 0.075978 | 0.088579 |
| tangent | NegativeX | 16 | 0.523892 | 0.873521 | 1.175214 | 0.075978 | 0.088579 |
| tangent | PositiveY | 16 | 0.523892 | 0.873521 | 1.175214 | 0.075978 | 0.088579 |
| tangent | NegativeY | 16 | 0.523892 | 0.873521 | 1.175214 | 0.075978 | 0.088579 |
| tangent | PositiveZ | 16 | 0.523891 | 0.873521 | 1.175189 | 0.075993 | 0.088592 |
| tangent | NegativeZ | 16 | 0.523891 | 0.873521 | 1.175189 | 0.075993 | 0.088592 |
Default for the first terrain slice: `tangent`.
It is still cheap, remains seam-compatible with the cube face mapping, and the sampler gives it lower area spread than direct normalization on this harness.

View file

@ -1,568 +0,0 @@
# Fractal Reference Oracles
Aquarium needs boring reference pressure before it earns pretty fractal claims.
The renderer is not allowed to prove itself by looking interesting.
## Apophysis/FLAM3 Boundary
Apophysis 7x is the classic editor lineage for fractal flames, and the
repository is GPL-2.0. FLAM3 is the older portable renderer/genome toolchain
with the public `.flame`/`.flam3` ecosystem and documented variation list.
Use them like AquaSynth uses synth references:
- Reference fixtures live in tests or artifacts, not engine runtime.
- GPL renderer/source stays external; Aquarium does not paste implementation
code into the runtime.
- Aquarium DSL candidates must reproduce reference behavior through numeric
gates before GPU rendering claims are accepted.
- Image parity receipts should be produced by an external reference renderer
when a local FLAM3/Apophysis-compatible binary is available.
## First Gate
The first committed fixture is deliberately small:
- `tests/Aquarium.Engine.Fractal.Tests/Fixtures/Apophysis/linear-sierpinski.flame`
- `tests/Aquarium.Engine.Fractal.Tests/Fixtures/Apophysis/linear-sierpinski.aquageo`
The `.flame` file uses three Apophysis/FLAM3-style linear xforms. The
`.aquageo` file expresses the same affine IFS using `affineifs` and
`affinemap`. `ApophysisReferenceParityTests` compares both through a fixed
transform sequence and through chaos-game moment bounds.
This is not full flame parity. It is the first ruler on the desk.
## Next Gates
1. DONE: parse a minimal `.flame` subset into a renderer-agnostic flame
definition: xform weight, color, affine coefficients, and `linear`,
`spherical`, and `bubble` variation weights.
2. DONE: add exact point parity for `linear`, `spherical`, and `bubble`,
plus a deterministic histogram checksum for the mixed fixture.
3. DONE: add an optional external FLAM3-compatible render receipt path. When
`flam3-render` is available, Aquarium renders the fixture to binary PPM and
records image-shape, non-black pixel count, RGB checksum, and luminance
checksum under `artifacts/fractal-flame-reference`.
4. DONE: lower proven flame definitions into GPU program rows for the receipt
shader's `flame 2D affine/variation program` mode.
5. Use those rows as a visual test scene only through GPU-resident reservoirs,
not by restoring a CPU direct renderer.
## Local Receipt
Run:
```powershell
.\scripts\fractal-flame-receipt.ps1
```
Default receipt:
- fixture: `linear-spherical-bubble.flame`
- samples: `8192`
- histogram: `64x64`
- bounds: `-8,-8,8,8`
- seed: `0x0BADC0DE`
- expected checksum: `0xAEB1C81B`
This receipt is Aquarium's local CPU parser/evaluator checksum. It is not yet
an external FLAM3/Apophysis renderer receipt.
## External Renderer Receipt
FLAM3 stays outside the repo and outside runtime binaries. If a local
`flam3-render` executable is installed, run:
```powershell
.\scripts\fractal-flame-reference-render.ps1 -Flam3Render C:\path\to\flam3-render.exe
```
The script follows FLAM3's documented environment-variable interface: `in`
selects the flame file, `out` selects the output image, `format=ppm` requests a
plain reference image, `seed` fixes the random sequence, and `qs` controls
quality scale. Aquarium then hashes the PPM through
`tools/Aquarium.Fractal.Receipt --reference-ppm`.
This is the first external renderer receipt. It proves the fixture can be
rendered by the reference family and gives Aquarium a stable image artifact to
compare as the local flame subset grows.
## JWildfire / FARender Visual Reference
Aquarium now has a reproducible higher-quality visual reference lane that does
not depend on tools already installed on the workstation:
```powershell
.\scripts\install-flame-reference-tools.ps1
.\scripts\fractal-flame-jwildfire-reference.ps1 `
-Renderer FARender `
-Width 1024 `
-Height 1024 `
-Quality 2000 `
-NoDensityEstimation
```
`install-flame-reference-tools.ps1` downloads JWildfire 9.00 and a local
Temurin JRE into ignored `artifacts/reference-tools`. These are reference
artifacts, not Aquarium runtime dependencies.
`fractal-flame-jwildfire-reference.ps1` prefers JWildfire's bundled FARender
GPU path when available because it accepts an explicit output file and fails
the script when no image is produced. The Java batch renderer remains available
through `-Renderer JWildfireJava`, but local smoke showed that it can complete
without writing an image into the batch directory because JWildfire's output
folder is preference-driven. Silent reference success is not a reference.
Local GTX 1070 smoke receipt from 2026-05-20:
- renderer: JWildfire 9.00 bundled FARenderJWF
- fixture: `artifacts/reference-tools/j-wildfire-9.00/lib/FARenderJWF/selftest.flame`
- command: `1024x1024`, quality `2000`, CUDA, no density-estimation phase
- time: `7.69 sec`
- artifact:
`artifacts/fractal-flame-jwildfire-reference/selftest-farender-20260520-233638.png`
The repository's tiny `linear-spherical-bubble.flame` fixture is still useful
for Aquarium subset parity, but FARender crashed on it because the fixture is
Apophysis-style minimal XML rather than full JWildfire `variationGroup` flame
XML. Do not "fix" that by making the fixture bigger until the parser and DSL
contract deliberately support the richer reference dialect.
## External Density Oracle
Aquarium's CPU flame oracle is no longer the only visual-parity target. The GPU
receipt can compare Aquarium splat density against a binary PPM image rendered
by an external renderer:
```powershell
.\scripts\new-jwildfire-baby-fixture.ps1
.\scripts\fractal-flame-jwildfire-reference.ps1 `
-Renderer FARender `
-Flame artifacts\fractal-flame-jwildfire-reference\julian-disc-baby.flame `
-Width 768 `
-Height 768 `
-Quality 2000 `
-OutputDirectory artifacts\fractal-flame-jwildfire-reference
magick artifacts\fractal-flame-jwildfire-reference\julian-disc-baby-farender-*.png `
-depth 8 artifacts\fractal-flame-jwildfire-reference\julian-disc-baby-farender.ppm
.\scripts\fractal-splat-receipt.ps1 `
-ProgramFlame artifacts\fractal-flame-jwildfire-reference\julian-disc-baby.flame `
-Splats 2000000 `
-WarmupSplatUpdates 2000000 `
-SplatUpdates 50000 `
-Warmup 8 `
-Frames 10 `
-Depth 8 `
-ReservoirUpdates 15000 `
-ReadbackSplats 1000000 `
-ReferenceDensityPpm artifacts\fractal-flame-jwildfire-reference\julian-disc-baby-farender.ppm `
-HistogramSize 256x256 `
-HistogramBounds '-2,-2,2,2'
```
`new-jwildfire-baby-fixture.ps1` derives a smaller, reproducible target from
FARender's bundled `selftest.flame`, preserving the boilerplate FARender needs
while reducing the nastier variation pressure: `jwf_julian_power` drops from
`126` to `8`, `jwf_gaussian_blur` becomes `0`, and the `wfield_*` amount fields
are set to zero. This is not a pristine flame design. It is a compatible rung
on the ladder that FARender actually renders.
First external-density receipt, local GTX 1070, 2026-05-21:
- reference renderer: JWildfire 9.00 bundled FARenderJWF
- fixture: `artifacts/fractal-flame-jwildfire-reference/julian-disc-baby.flame`
- reference render: `768x768`, quality `2000`, CUDA
- Aquarium steady GPU time: `1.145 ms/frame`, `873.4 FPS` equivalent
- external density score: `15.33%` in `256x256` bounds `-2,-2,2,2`
- score sweep at `128x128`: best tested bound was `-4,-4,4,4` at `32.95%`
- contact sheet:
`artifacts/fractal-flame-jwildfire-reference/julian-disc-baby-external-contact.png`
The low external score is useful. Aquarium still scores about `89.94%` against
its CPU interpretation of the same baby fixture in `128x128` bounds
`-4,-4,4,4`, while FARender density disagrees hard. That means the next work is
not "more samples"; it is dialect and camera semantics: FARender flame camera
mapping, color/density filtering, chaos/xaos and weighting-field behavior, and
variation exactness.
The first post-oracle correction found two concrete import/math lies:
- JWildfire `coefs` are packed for FARender's affine as `a b d e c f`; Aquarium
had transposed the off-diagonal terms as `a d / b e`.
- FARender's `jwf_disc` source uses `atan2(x, y) / PI`; Aquarium used
`atan2(y, x) / PI`.
After fixing both, the same external-density receipt scored `26.87%` at
`256x256` bounds `-2,-2,2,2`, up from `15.33%`.
The parity candidate no longer bins raw splat occupancy. That was the wrong
organ. The GPU receipt now builds the Aquarium comparison image from the SDF
envelope reservoir layer by stamping each populated envelope into a shaped
density histogram. PBR and radiosity reservoirs stay separate; PBR will score
surface appearance later, and radiosity is not part of this flame-density gate
yet. With full SDF reservoir coverage on the local GTX 1070, the same
`julian-disc-baby` receipt scored `22.14%` against the FARender PPM at
`256x256` bounds `-2,-2,2,2`, with `42.053 ms/frame` while refreshing all
2,000,000 reservoir rows in all three layers. The contact sheet lives at
`artifacts/fractal-flame-jwildfire-reference/julian-disc-baby-sdf-density-contact.png`.
The next correction removed another dialect lie. JWildfire's
`variationGroup normal="1"` is not the same thing as an explicit `linear`
variation in FARender. A normal-only diagnostic renders as a constant point in
FARender, while Aquarium had been adding affine linear geometry. The importer
now ignores JWildfire `normal` as variation-group metadata; explicit `linear`
still stays linear for Apophysis/FLAM3-style fixtures. The SDF-density receipt
also gives subpixel envelopes a minimum one-bin reconstruction footprint so the
comparison does not quietly collapse back into point occupancy.
After those fixes, the `julian-disc-baby` receipt scored `31.36%` against the
FARender PPM at `256x256` bounds `-2,-2,2,2`, with `41.692 ms/frame` while
refreshing all 2,000,000 reservoir rows in all three layers. Switching the
receipt to FARender-style flame camera bounds (`size / scale / cam_zoom`) raised
the same gate to `43.13%` with `68.01%` occupancy overlap; pass
`-HistogramBounds flame-camera` through `scripts/fractal-splat-receipt.ps1` for
that path. The contact sheet lives at
`artifacts/fractal-flame-jwildfire-reference/julian-disc-baby-normal-cut-area-contact.png`.
Full reservoir refreshes must not use the stochastic hash indexer. When
`ReservoirUpdatesPerPass == SplatCount`, the compute shader now updates row
`updateIndex` directly for each reservoir layer. That keeps under-budget passes
stochastic while making full coverage actually full and memory-coherent. The
same `julian-disc-baby` full-refresh receipt stayed visually equivalent
(`43.09%`, `67.99%` occupancy overlap) but dropped from roughly `41 ms/frame`
to `7.508 ms/frame`, or `133.2 FPS` equivalent.
The remaining visible mismatch is now narrower: Aquarium draws the same family
of form, but its density is still too compact and differently weighted. Fixing
that belongs in exact JWildfire variation/camera parity, target density
weighting, and view-conditioned reservoir feedback.
The next oracle step is to compare Aquarium through a matching density image
resolve, not raw point occupancy.
## JWildfire Selftest Faceplant
Running JWildfire's bundled `selftest.flame` through Aquarium is the correct
kind of embarrassing:
```powershell
.\scripts\fractal-splat-receipt.ps1 `
-ProgramFlame artifacts\reference-tools\j-wildfire-9.00\lib\FARenderJWF\selftest.flame `
-Splats 2000000 `
-SplatUpdates 50000 `
-Warmup 0 `
-Frames 1 `
-Depth 64 `
-ReservoirUpdates 15000 `
-ReadbackSplats 250000 `
-VisualParity `
-VisualParityReferenceSamples 1000000 `
-HistogramSize 256x256 `
-HistogramBounds '-128,-128,128,128'
```
First receipt after adding the JWildfire `variationGroup`/coefficient-layout
slice and the `disc`, `julian`, and `gaussian_blur` variation subset, before
lowering `post`:
- GPU time: `66.446 ms/frame`, `15.0 FPS` equivalent
- visual score: `5.01%`
- occupancy overlap: `3.04%`
- L1 distance: `1.899840`
- artifact:
`artifacts/fractal-jwildfire-faceplant/jwildfire-vs-aquarium-expanded.png`
The first unexpanded run scored `0.00%` and rendered a single dot. The expanded
run produces a recognizable central flame knot, but it still does not match the
reference. This is not just "more variations needed." It exposes three distinct
gaps:
1. **DSL expressiveness.** A serious flame dialect needs named variation
nodes, parameters, post transforms, palette/color semantics, xaos/chaos
weights, and optional weighting fields. The current Aquarium flame subset is
a compact test dialect, not a full JWildfire import surface.
2. **Translation quality.** JWildfire's coefficient layout differs from the
older Apophysis-style fixtures. `variationGroup` can hide the real variation
list from the xform attributes. Rich dialect import needs a translation
report that says exactly which fields were accepted, approximated, ignored,
or rejected.
3. **Hot-path shape.** Brute replaying 64 flame iterations per splat in one
compute invocation is too slow. Rich flame rendering should store continuing
iteration state in GPU reservoirs and advance it stochastically across
frames, then density-estimate/shade from those resident samples. The reservoir
is the renderer, not an expensive reset button.
The next coherent slice is therefore not "add 200 variations." It is an
import/lowering contract: parse a richer flame AST, produce an explicit
translation report, lower supported variation nodes into compact GPU op rows,
and carry persistent per-sample iteration state so zoom/movement can reuse
sample history instead of starting every splat from zero.
That import report now exists. GPU and histogram flame receipts emit a sibling
`fractal-flame-import-report-*.json` next to the numeric receipt. For the
JWildfire selftest flame, the first report after `post` lowering says:
- accepted fields: `15`
- approximated fields: `3`
- ignored fields: `45`
- rejected fields: `11`
- rejected geometry/sampling losses: `wfield_*` weighting-field attributes on
the disc xform
This report is the next expansion gate. A flame may parse successfully and
still be a bad import; unsupported fields must stay visible until the DSL,
compiler, or renderer can actually own them.
Lowering `post` required expanding only the small authored-program transform
row, not the resident million-splat or reservoir packets. The closer
CPU/GPU-slice receipt now gives:
- bounds `-8,-8,8,8`: `84.19%` GPU-vs-Aquarium-CPU slice score at
`129.073 ms/frame`
- bounds `-128,-128,128,128`: `88.01%` GPU-vs-Aquarium-CPU slice score at
`129.404 ms/frame`
- artifact:
`artifacts/fractal-jwildfire-faceplant/jwildfire-vs-aquarium-post-close.png`
This is not external JWildfire visual parity. It says Aquarium's GPU path is
now much closer to Aquarium's CPU interpretation of the supported slice. The
image is still missing JWildfire's palette, density estimation, weighting-field
behavior, render filters, and likely more dialect detail. The speed is also
unacceptable for realtime because depth-64 replay is the wrong execution model.
The first persistent-iteration invariant is now tested on CPU:
`FractalFlameIterationState` plus `FractalFlameIterationStepper` prove that
advancing one sample by 64 iterations in one call matches advancing the same
state in eight 8-iteration chunks. This is not a CPU renderer. It is the
mockable contract the GPU state buffer must preserve when the shader stops
replaying every splat from zero.
## GPU Flame Receipt
The flame fixture can now drive the D3D12 reservoir receipt directly:
```powershell
.\scripts\fractal-splat-receipt.ps1 `
-Splats 2000000 `
-SplatUpdates 50000 `
-Warmup 10 `
-Frames 60 `
-Depth 8 `
-ReservoirUpdates 15000 `
-ProgramFlame tests\Aquarium.Engine.Fractal.Tests\Fixtures\Apophysis\linear-spherical-bubble.flame
```
Local GTX 1070 receipt from 2026-05-20:
- resident splats: `2,000,000`
- resident reservoir rows: `2,000,000` each for SDF, PBR, and radiosity
- program transforms: `3`
- splat updates/frame: `50,000`
- reservoir updates/pass/frame: `15,000`
- GPU time: `1.030 ms/frame`
- equivalent FPS: `971.3`
- readback checksum: `0x2647D8B08575B848`
The flame hot path now keeps per-splat iteration state in a GPU UAV instead of
replaying every chaos-game ancestry from the origin. `Depth` is the number of
iterations advanced when a row is touched; the row stores point, support,
material, random state, and accumulated step for the next stochastic update.
This makes the receipt a two-phase cache:
- acquisition: seed every resident row and optionally advance the full resident
population for a few warmup frames
- maintenance: update only the budgeted stochastic subset while the layered
SDF/PBR/radiosity reservoirs continue their independent passes
```powershell
.\scripts\fractal-splat-receipt.ps1 `
-ProgramFlame artifacts\reference-tools\j-wildfire-9.00\lib\FARenderJWF\selftest.flame `
-Splats 2000000 `
-WarmupSplatUpdates 2000000 `
-SplatUpdates 50000 `
-Warmup 8 `
-Frames 20 `
-Depth 8 `
-ReservoirUpdates 15000 `
-ReadbackSplats 250000 `
-VisualParity `
-VisualParityReferenceSamples 1000000 `
-HistogramSize 256x256 `
-HistogramBounds -8,-8,8,8 `
-VisualParityImageDirectory artifacts\fractal-jwildfire-faceplant\source `
-VisualParityImagePrefix selftest-persistent-warmfull-depth008-frame-020
```
Local GTX 1070 receipt from 2026-05-21:
- resident splats: `2,000,000`
- flame iteration states: `2,000,000` at `32` bytes each
- resident reservoir rows: `2,000,000` each for SDF, PBR, and radiosity
- warmup splat updates/frame: `2,000,000`
- steady splat updates/frame: `50,000`
- reservoir updates/pass/frame: `15,000`
- measured steady GPU time: `1.011 ms/frame`
- equivalent steady FPS: `988.8`
- visual parity against Aquarium CPU slice oracle: `84.86%`
- cosine similarity: `0.986955`
- readback checksum: `0xC633FADC3BD7E5BE`
Without the full-population acquisition pass, the same steady budget scored only
`32.64%` on the JWildfire selftest slice because most rows were still shallow
ancestry. That is a useful failure: persistent state is not magic, it is a cache
that must be filled before a tiny maintenance budget can look clever.
## Visual Parity Receipt
The receipt tool can compare GPU flame splat positions against the local CPU
flame oracle:
```powershell
.\scripts\fractal-splat-receipt.ps1 `
-Splats 2000000 `
-SplatUpdates 50000 `
-Warmup 0 `
-Frames 20 `
-Depth 8 `
-ReservoirUpdates 15000 `
-ProgramFlame tests\Aquarium.Engine.Fractal.Tests\Fixtures\Apophysis\linear-spherical-bubble.flame `
-ReadbackSplats 250000 `
-VisualParity `
-VisualParityReferenceSamples 1000000 `
-HistogramSize 128x128 `
-HistogramBounds -8,-8,8,8
```
First local scores, GPU 250k readback samples against 1M CPU reference samples:
| Frames | GPU ms/frame | Distribution score | L1 distance | RMSE | Cosine |
| ---: | ---: | ---: | ---: | ---: | ---: |
| 1 | 15.645 | 84.05% | 0.319072 | 0.000090 | 0.995365 |
| 5 | 4.017 | 84.10% | 0.318042 | 0.000089 | 0.995402 |
| 20 | 1.738 | 84.20% | 0.316019 | 0.000088 | 0.995591 |
| 60 + 10 warmup | 0.952 | 84.24% | 0.315136 | 0.000088 | 0.995505 |
This proves the current GPU flame program has high distribution similarity, but
it does **not** prove learned reservoir convergence. The score plateaus because
the current flame path initializes resident splats from the program and then
performs stochastic replacement; it does not yet use visual error feedback,
MIS/ReSTIR weights, or an external rendered-image residual to steer updates.
## View-Conditioned Parity
Global parity is a weak metric. Realtime usefulness depends on the current
camera window: panning or zooming should keep the visible image smooth while the
cache spends new work on underrepresented regions.
The receipt tool therefore accepts named viewport bounds:
```powershell
.\scripts\fractal-splat-receipt.ps1 `
-Splats 2000000 `
-SplatUpdates 50000 `
-Warmup 10 `
-Frames 60 `
-Depth 16 `
-ReservoirUpdates 15000 `
-ProgramFlame tests\Aquarium.Engine.Fractal.Tests\Fixtures\Apophysis\linear-spherical-bubble.flame `
-ReadbackSplats 250000 `
-VisualParity `
-VisualParityReferenceSamples 1000000 `
-HistogramSize 128x128 `
-VisualParityView global:-8,-8,8,8 `
-VisualParityView mid:-1,-1,1,1 `
-VisualParityView close:-0.5,-0.5,0.5,0.5 `
-VisualParityView micro:-0.25,-0.25,0.25,0.25
```
First view-conditioned receipt:
| View | Distribution score | GPU hits | Reference hits | Starved bins | Underrepresented mass |
| --- | ---: | ---: | ---: | ---: | ---: |
| global | 97.09% | 249,972 | 999,836 | 1,034 | 2.91% |
| mid | 91.93% | 166,654 | 667,105 | 1,792 | 8.07% |
| close | 88.97% | 125,448 | 501,033 | 2,266 | 11.03% |
| micro | 82.05% | 71,348 | 284,011 | 2,656 | 17.95% |
This is the first metric that catches the real problem. The reservoir is fast,
but it is not yet view-adaptive. It needs camera-conditioned target weights,
underrepresented-bin feedback, and temporal reuse validation before it deserves
claims about maintaining flame quality during movement and zoom.
## Contact Sheets
The quick 1M-sample oracle image is too noisy for visual judgment. Use the image
export path with a much larger CPU oracle when making contact sheets:
```powershell
.\scripts\fractal-splat-receipt.ps1 `
-ProgramFlame tests\Aquarium.Engine.Fractal.Tests\Fixtures\Apophysis\linear-spherical-bubble.flame `
-VisualParity `
-VisualParityReferenceSamples 20000000 `
-VisualParityImageDirectory artifacts\fractal-contact-sheet-hq\source `
-VisualParityImagePrefix zoom-frame-010 `
-ReadbackSplats 250000 `
-Depth 16 `
-Frames 10 `
-VisualParityView zoom:-0.25,-0.25,0.25,0.25
```
The current high-reference contact sheet lives at:
- `artifacts/fractal-contact-sheet-hq/flame-parity-contact-sheet-hq.png`
It uses 20M CPU oracle samples and 250k Aquarium readback samples per panel.
This still is not a full FLAM3 density-estimated render, but it is no longer a
1M-sample noise patch masquerading as ground truth.
## Sources
- Apophysis 7x repository: https://github.com/wanily/apophysis7x
- FLAM3 repository and variation list: https://github.com/scottdraves/flam3
- Draves, *The Fractal Flame Algorithm*: https://flam3.com/flame_draves.pdf
## Reference Ladder
Stop calling Aquarium's CPU histogram the visual ground truth. It is a math
oracle for a tiny variation subset. It is useful because it is deterministic,
small, and debuggable. It is not the bar for beautiful flame rendering.
Use this ladder:
1. **Gold visual reference: Chaotica.** Chaotica is the quality target for
flame art. Its own manual frames progressive rendering as the alternative
to Apophysis/FLAM3 fixed-quality rerendering, and says high quality often
means stopping at a sampling level after the image is no longer noisy. Use
Chaotica renders as imported reference artifacts when available.
2. **Scriptable visual reference: JWildfire/FARender.** JWildfire is LGPL,
has a documented headless batch renderer path:
`java org.jwildfire.create.tina.batch.HeadlessBatchRendererController <dir> <w> <h> <quality>`.
Its current public bundle also ships FARenderJWF, a Windows/NVIDIA GPU
renderer that the JWildfire changelog says may still be used while it works.
Use `scripts/install-flame-reference-tools.ps1` to hydrate the local tools
and `scripts/fractal-flame-jwildfire-reference.ps1 -Renderer FARender` for
explicit PNG output.
3. **Baseline renderer reference: FLAM3/Apophysis family.** Use FLAM3 for
portable `.flame`/`.flam3` compatibility, deterministic external receipts,
and old-school density-estimation sanity.
4. **Aquarium CPU oracle.** Use only for unit tests, point parity, histogram
smoke checks, and shader debugging. Never present it as market-quality
visual ground truth.
Local machine status on 2026-05-20: Chaotica and `flam3-render` were not found
on PATH; only Ultra Fractal 6 was visible under Program Files. JWildfire 9.00
and Temurin JRE 17 were downloaded into ignored artifacts, and FARenderJWF
successfully rendered its bundled selftest flame on the GTX 1070. Aquarium can
now compare against a real external flame renderer artifact, but it still
cannot claim best-in-market visual parity until imported Chaotica renders or a
fully matched JWildfire flame dialect are wired into residual metrics.
Additional sources:
- Chaotica features: https://chaoticafractals.com/features
- Chaotica progressive rendering: https://www.chaoticafractals.com/manual/rendering/progressive_rendering
- Chaotica render settings / sampling level: https://www.chaoticafractals.com/manual/user_interface/render_settings
- JWildfire GitHub / LGPL license: https://github.com/thargor6/JWildfire
- JWildfire headless batch renderer notes: https://www.jwfsanctuary.club/tutorial/how-to/how-do-i-render-a-full-folder-of-flames/
- JWildfire bundled FARender/Swan notes: `artifacts/reference-tools/j-wildfire-9.00/CHANGES.txt`

View file

@ -1,142 +0,0 @@
# HDR/PBR Presentation Research
Aquarium needs an HDR presentation path before the SDF field renderer grows real
lighting and participating media. The target is not "more bloom." The target is a
scene-linear renderer with believable dynamic range, camera exposure, optical
spill, and a display transform that preserves contrast without boiling the frame
in arcade syrup.
## Sources
- [ACES System overview](https://docs.acescentral.com/background/overview/)
- [Epic: How Epic Games is handling Auto Exposure in 4.25](https://www.unrealengine.com/tech-blog/how-epic-games-is-handling-auto-exposure-in-4-25?lang=en-US)
- [Unreal Engine bloom documentation](https://dev.epicgames.com/documentation/en-us/unreal-engine/bloom-in-unreal-engine?application_version=5.6)
- [Jorge Jimenez: Next Generation Post Processing in Call of Duty: Advanced Warfare](https://www.iryoku.com/next-generation-post-processing-in-call-of-duty-advanced-warfare/)
- [John Hable: Filmic Tonemapping Operators](https://filmicworlds.com/blog/filmic-tonemapping-operators/)
- [John Hable: Filmic Tonemapping with Piecewise Power Curves](https://filmicworlds.com/blog/filmic-tonemapping-with-piecewise-power-curves/)
- [Hable: Uncharted 2 HDR Lighting, SIGGRAPH 2010](https://advances.realtimerendering.com/s2010/Hable-Uncharted2%28SIGGRAPH%202010%20Advanced%20RealTime%20Rendering%20Course%29.pdf)
- [Lagarde/de Rousiers: Moving Frostbite to Physically Based Rendering 3.0](https://cgvr.cs.uni-bremen.de/teaching/cg_literatur/Moving%20Frostbite%20to%20Physically%20Based%20Rendering%203.0%2C%202014%2C%20Sebastien%20Lagarde.pdf)
## Distillation
Modern HDR presentation is a chain, not a knob:
1. Render lighting and emissive terms in scene-linear HDR.
2. Apply exposure in the HDR domain, preferably from a physical-camera or EV100
model even if Aquarium initially uses manual exposure.
3. Generate bloom/glare from exposed HDR color before tonemapping.
4. Composite bloom back into exposed scene-linear color with low intensity and
broad support.
5. Apply a display transform or filmic tonemap with toe and shoulder.
6. Convert to the swapchain/display encoding only at the end.
The important production lesson is that bloom is not a thresholded special
effect for "pixels above 1." In a high-dynamic-range PBR scene, broad low-gain
scatter can apply to the whole image while only genuinely bright energy becomes
visibly luminous. Threshold bloom is the cheap costume version; it cuts out the
low-level optical veil and encourages artists to overdrive emissive values until
the frame looks diseased.
ACES-style and filmic transforms are useful because they give highlights a
shoulder and shadows a toe. Aquarium's current tiny ACES approximation is a
reasonable placeholder, but it is not a color-management system, not a look
authoring surface, and not enough to make the scene feel HDR by itself.
## Aquarium Gate Plan
### Gate HDR-A: Explicit Exposure
Add an exposure scalar in `AquariumFrame`, initially manual and persisted through
CultCache later.
Start simple:
- `exposedColor = sceneLinearColor * exposure`
- default exposure chosen so Self is bright but not pinned white across its
whole disc
- debug mode for log luminance or EV bands
Avoid auto exposure until the scene has enough stable luminance content. A
volumetric/SDF scene with huge black regions and a giant Self emitter will make
naive histogram adaptation twitch like a bad witness.
### Gate HDR-B: Bloom Pyramid
Add a pre-tonemap bloom pass:
- source: exposed HDR scene color
- no hard threshold at first
- downsample pyramid with firefly-safe weighted averages
- upsample with small tent filters and per-level radius
- composite with low intensity into exposed scene color before tonemap
This follows the Jimenez production shape: pyramidal bloom/veil for stability
and robustness, not a single huge blur taped over bright pixels.
### Gate HDR-C: Display Transform
Keep the current ACES approximation only as `DisplayTransform.AcesFit`.
Add an explicit display-transform function boundary:
- ACES fit for default
- Hable/filmic curve as an alternate debug/look path
- later: LUT-based transform if Aquarium needs an authored look
Do not bury exposure, bloom, and tonemapping in one shader lump. The whole point
is to keep the light transport legible before volumetrics arrive and start
lying convincingly.
### Gate HDR-D: HDR Debug Views
Add debug views before tuning:
- scene-linear luminance
- exposed luminance
- bloom contribution
- tonemapped final
- over-range / clipped highlight mask
If Self, planets, Grid, and later clouds cannot be inspected in each stage, the
pipeline will become a little shrine to superstition. Burn the shrine early.
## Rules For Aquarium
- Bloom happens before tonemapping.
- Bloom spreads existing energy; it does not invent light.
- Prefer broad low-intensity bloom/veil over hard-threshold glow.
- Keep overlay text out of HDR and bloom unless it becomes a diegetic scene
surface.
- Do not tune emissive values against SDR final output alone. Inspect linear and
exposed luminance.
- Volumetrics must feed and be fed by the same exposure/display path as solids.
- If the frame needs "more HDR," first check exposure, display shoulder, and
source radiance before turning up bloom.
## Immediate Recommendation
Before the field renderer:
1. Add explicit exposure constants.
2. Split resolve into exposed HDR color and final display transform.
3. Add a bloom pyramid between exposure and tonemap.
4. Add debug views for luminance and bloom contribution.
This gives SDF solids/clouds a real presentation pipe to land in. Otherwise the
field renderer will arrive carrying gorgeous radiance values into a cramped SDR
mail slot, and everyone will pretend the answer is blur radius. It is not.
## Aquarium Implementation Note
The first implementation now follows this shape:
- fixed manual exposure in the frame constants
- exposed scene-linear HDR source for bloom
- three-level bloom pyramid at half, quarter, and eighth resolution
- firefly-safe downsampling
- separable horizontal/vertical blur per level
- low-gain bloom/veil contribution before the ACES fit
- debug mode `7` for bloom contribution and mode `8` for exposed luminance
The remaining missing piece is a persisted/inspectable exposure control and,
later, a proper authored display-transform boundary.

View file

@ -1,278 +0,0 @@
#!/usr/bin/env python3
"""Bake a Radiance HDR latlong map into a GGX-prefiltered DDS cubemap."""
from __future__ import annotations
import argparse
import math
import struct
from pathlib import Path
import numpy as np
DXGI_FORMAT_R16G16B16A16_FLOAT = 10
D3D10_RESOURCE_DIMENSION_TEXTURE2D = 3
D3D11_RESOURCE_MISC_TEXTURECUBE = 0x4
def read_radiance_hdr(path: Path) -> np.ndarray:
data = path.read_bytes()
cursor = 0
while True:
end = data.index(b"\n", cursor)
line = data[cursor:end].decode("ascii", errors="replace").strip()
cursor = end + 1
if not line:
break
while True:
end = data.index(b"\n", cursor)
line = data[cursor:end].decode("ascii", errors="replace").strip()
cursor = end + 1
if line:
resolution = line.split()
break
if len(resolution) != 4 or resolution[0] != "-Y" or resolution[2] != "+X":
raise ValueError(f"Unsupported Radiance orientation in {path}: {' '.join(resolution)}")
height = int(resolution[1])
width = int(resolution[3])
rgbe = np.empty((height, width, 4), dtype=np.uint8)
for y in range(height):
if data[cursor] != 2 or data[cursor + 1] != 2:
raise ValueError(f"Unsupported non-RLE Radiance scanline {y}")
scan_width = (data[cursor + 2] << 8) | data[cursor + 3]
cursor += 4
if scan_width != width:
raise ValueError(f"Radiance scanline {y} has width {scan_width}, expected {width}")
scanline = np.empty((4, width), dtype=np.uint8)
for channel in range(4):
x = 0
while x < width:
code = data[cursor]
cursor += 1
if code > 128:
count = code - 128
scanline[channel, x : x + count] = data[cursor]
cursor += 1
else:
count = code
scanline[channel, x : x + count] = np.frombuffer(data, dtype=np.uint8, count=count, offset=cursor)
cursor += count
x += count
rgbe[y] = scanline.T
exponent = rgbe[..., 3].astype(np.int32)
scale = np.zeros((height, width), dtype=np.float32)
valid = exponent > 0
scale[valid] = np.ldexp(np.ones(np.count_nonzero(valid), dtype=np.float32), exponent[valid] - (128 + 8))
return rgbe[..., :3].astype(np.float32) * scale[..., None]
def radical_inverse_vdc(bits: int) -> float:
bits = (bits << 16) | (bits >> 16)
bits = ((bits & 0x55555555) << 1) | ((bits & 0xAAAAAAAA) >> 1)
bits = ((bits & 0x33333333) << 2) | ((bits & 0xCCCCCCCC) >> 2)
bits = ((bits & 0x0F0F0F0F) << 4) | ((bits & 0xF0F0F0F0) >> 4)
bits = ((bits & 0x00FF00FF) << 8) | ((bits & 0xFF00FF00) >> 8)
return bits * 2.3283064365386963e-10
def hammersley(count: int) -> np.ndarray:
values = np.empty((count, 2), dtype=np.float32)
for i in range(count):
values[i, 0] = (i + 0.5) / count
values[i, 1] = radical_inverse_vdc(i)
return values
def face_directions(face: int, size: int) -> np.ndarray:
coord = (np.arange(size, dtype=np.float32) + 0.5) / size * 2.0 - 1.0
u, v = np.meshgrid(coord, coord)
if face == 0: # +X
d = np.stack([np.ones_like(u), -v, -u], axis=-1)
elif face == 1: # -X
d = np.stack([-np.ones_like(u), -v, u], axis=-1)
elif face == 2: # +Y
d = np.stack([u, np.ones_like(u), v], axis=-1)
elif face == 3: # -Y
d = np.stack([u, -np.ones_like(u), -v], axis=-1)
elif face == 4: # +Z
d = np.stack([u, -v, np.ones_like(u)], axis=-1)
elif face == 5: # -Z
d = np.stack([-u, -v, -np.ones_like(u)], axis=-1)
else:
raise ValueError(face)
return normalize(d)
def normalize(v: np.ndarray) -> np.ndarray:
return v / np.maximum(np.linalg.norm(v, axis=-1, keepdims=True), 1e-20)
def tangent_basis(n: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
up = np.zeros_like(n)
up[..., 1] = 1.0
alt = np.zeros_like(n)
alt[..., 2] = 1.0
use_alt = np.abs(n[..., 1:2]) > 0.98
up = np.where(use_alt, alt, up)
tangent = normalize(np.cross(up, n))
bitangent = np.cross(n, tangent)
return tangent, bitangent
def sample_latlong(env: np.ndarray, directions: np.ndarray) -> np.ndarray:
height, width, _ = env.shape
d = normalize(directions)
u = np.mod(np.arctan2(d[..., 2], d[..., 0]) / (2.0 * math.pi) + 0.5, 1.0) * width - 0.5
v = np.arccos(np.clip(d[..., 1], -1.0, 1.0)) / math.pi * height - 0.5
x0 = np.floor(u).astype(np.int32)
y0 = np.floor(v).astype(np.int32)
tx = (u - x0)[..., None]
ty = (v - y0)[..., None]
x0 %= width
x1 = (x0 + 1) % width
y0 = np.clip(y0, 0, height - 1)
y1 = np.clip(y0 + 1, 0, height - 1)
c00 = env[y0, x0]
c10 = env[y0, x1]
c01 = env[y1, x0]
c11 = env[y1, x1]
return (c00 * (1.0 - tx) + c10 * tx) * (1.0 - ty) + (c01 * (1.0 - tx) + c11 * tx) * ty
def prefilter_face(env: np.ndarray, face: int, size: int, roughness: float, sample_count: int) -> np.ndarray:
n = face_directions(face, size)
if roughness <= 0.0001:
return sample_latlong(env, n).astype(np.float32)
tangent, bitangent = tangent_basis(n)
v = n
alpha = max(roughness * roughness, 0.001)
result = np.zeros((size, size, 3), dtype=np.float32)
weight_sum = np.zeros((size, size, 1), dtype=np.float32)
for xi1, xi2 in hammersley(sample_count):
phi = 2.0 * math.pi * float(xi1)
cos_theta = math.sqrt((1.0 - float(xi2)) / (1.0 + (alpha * alpha - 1.0) * float(xi2)))
sin_theta = math.sqrt(max(0.0, 1.0 - cos_theta * cos_theta))
h = normalize(
tangent * (math.cos(phi) * sin_theta)
+ bitangent * (math.sin(phi) * sin_theta)
+ n * cos_theta
)
l = normalize(2.0 * np.sum(v * h, axis=-1, keepdims=True) * h - v)
ndotl = np.maximum(np.sum(n * l, axis=-1, keepdims=True), 0.0).astype(np.float32)
result += sample_latlong(env, l) * ndotl
weight_sum += ndotl
return result / np.maximum(weight_sum, 1e-6)
def irradiance_face(env: np.ndarray, face: int, size: int, sample_count: int) -> np.ndarray:
n = face_directions(face, size)
tangent, bitangent = tangent_basis(n)
result = np.zeros((size, size, 3), dtype=np.float32)
for xi1, xi2 in hammersley(sample_count):
radius = math.sqrt(float(xi1))
phi = 2.0 * math.pi * float(xi2)
direction = normalize(
tangent * (math.cos(phi) * radius)
+ bitangent * (math.sin(phi) * radius)
+ n * math.sqrt(max(0.0, 1.0 - float(xi1)))
)
result += sample_latlong(env, direction)
return result * (math.pi / sample_count)
def dds_header(width: int, height: int, mip_count: int) -> bytes:
flags = 0x1 | 0x2 | 0x4 | 0x8 | 0x1000 | 0x20000
caps = 0x1000 | 0x8 | 0x400000
caps2 = 0x200 | 0x400 | 0x800 | 0x1000 | 0x2000 | 0x4000 | 0x8000
pitch = width * 8
pixel_format = struct.pack("<8I", 32, 0x4, int.from_bytes(b"DX10", "little"), 0, 0, 0, 0, 0)
header = struct.pack(
"<7I11I",
124,
flags,
height,
width,
pitch,
0,
mip_count,
*([0] * 11),
)
header += pixel_format
header += struct.pack("<5I", caps, caps2, 0, 0, 0)
dx10 = struct.pack(
"<5I",
DXGI_FORMAT_R16G16B16A16_FLOAT,
D3D10_RESOURCE_DIMENSION_TEXTURE2D,
D3D11_RESOURCE_MISC_TEXTURECUBE,
1,
0,
)
return b"DDS " + header + dx10
def write_dds(path: Path, mip_faces: list[list[np.ndarray]]) -> None:
base_size = mip_faces[0][0].shape[0]
payload = bytearray(dds_header(base_size, base_size, len(mip_faces)))
for face in range(6):
for mip in mip_faces:
rgb = np.maximum(mip[face], 0.0).astype(np.float16)
alpha = np.ones(rgb.shape[:2] + (1,), dtype=np.float16)
payload.extend(np.concatenate([rgb, alpha], axis=-1).tobytes())
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(payload)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("input", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--mode", choices=["specular", "irradiance"], default="specular")
parser.add_argument("--size", type=int, default=256)
parser.add_argument("--samples", type=int, default=128)
args = parser.parse_args()
if args.size <= 0 or args.size & (args.size - 1):
raise ValueError("--size must be a power of two")
env = read_radiance_hdr(args.input)
if args.mode == "irradiance":
print(f"irradiance: {args.size}x{args.size}, samples={args.samples}")
write_dds(
args.output,
[[irradiance_face(env, face, args.size, args.samples) for face in range(6)]])
print(f"wrote {args.output}")
return
mip_count = int(math.log2(args.size)) + 1
mip_faces: list[list[np.ndarray]] = []
for mip_index in range(mip_count):
size = max(1, args.size >> mip_index)
roughness = mip_index / max(1, mip_count - 1)
sample_count = 1 if mip_index == 0 else max(32, int(args.samples * (0.5 + roughness * 0.5)))
print(f"mip {mip_index}/{mip_count - 1}: {size}x{size}, roughness={roughness:.3f}, samples={sample_count}")
mip_faces.append([
prefilter_face(env, face, size, roughness, sample_count)
for face in range(6)
])
write_dds(args.output, mip_faces)
print(f"wrote {args.output}")
if __name__ == "__main__":
main()

View file

@ -1,88 +0,0 @@
param(
[string]$OutputPath,
[int]$Width = 1280,
[int]$Height = 720,
[int]$ReadyFrames = 8,
[int]$RetainSlots = 4,
[int]$TimeoutSeconds = 120
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
$OutputPath = Join-Path $repoRoot ("artifacts\fensalir-captures\fensalir-fixed-" + (Get-Date -Format "yyyyMMdd-HHmmss") + ".png")
}
$OutputPath = [System.IO.Path]::GetFullPath($OutputPath)
$clientProject = Join-Path $repoRoot "src\Aquarium.Fensalir\Aquarium.Fensalir.csproj"
$devRoot = Join-Path $repoRoot "artifacts\dev-reload"
$slotRoot = Join-Path $devRoot "slots"
$slotPath = Join-Path $slotRoot ("fensalir-capture-" + (Get-Date -Format "yyyyMMdd-HHmmss") + "-" + [guid]::NewGuid().ToString("N").Substring(0, 8))
$cachePath = Join-Path $devRoot "fensalir-capture-cultcache\aquarium-client.msgpack"
$stdoutLog = Join-Path $devRoot "fensalir-capture.out.log"
$stderrLog = Join-Path $devRoot "fensalir-capture.err.log"
New-Item -ItemType Directory -Force -Path $slotPath | Out-Null
New-Item -ItemType Directory -Force -Path (Split-Path $cachePath) | Out-Null
New-Item -ItemType Directory -Force -Path (Split-Path $OutputPath) | Out-Null
Write-Host "Building Fensalir capture slot:"
Write-Host " $slotPath"
dotnet build (Join-Path $repoRoot "src\Aquarium.Engine\Aquarium.Engine.csproj") -c Debug -o $slotPath /p:UseAppHost=true
if ($LASTEXITCODE -ne 0) {
throw "Engine build failed with exit code $LASTEXITCODE."
}
dotnet build $clientProject -c Debug -o $slotPath /p:UseAppHost=false
if ($LASTEXITCODE -ne 0) {
throw "Fensalir client build failed with exit code $LASTEXITCODE."
}
$exePath = Join-Path $slotPath "Aquarium.Engine.exe"
$clientAssembly = Join-Path $slotPath "Aquarium.Fensalir.dll"
$arguments = @(
"--headless",
"--headless-width", $Width,
"--headless-height", $Height,
"--client-assembly", $clientAssembly,
"--cache", $cachePath,
"--shader-source", (Join-Path $slotPath "Render\Shaders\D3D12HeightField.hlsl"),
"--capture-frame", $OutputPath
)
$env:AQUARIUM_HEADLESS_READY_FRAMES = [string][Math]::Max(1, $ReadyFrames)
$process = Start-Process -FilePath $exePath -ArgumentList $arguments -NoNewWindow -PassThru -RedirectStandardOutput $stdoutLog -RedirectStandardError $stderrLog
if (-not $process.WaitForExit([Math]::Max(1, $TimeoutSeconds) * 1000)) {
$process.Kill($true)
$tail = if (Test-Path $stderrLog) { Get-Content -Path $stderrLog -Tail 80 } else { "<stderr missing>" }
throw "Fensalir capture timed out after $TimeoutSeconds seconds.`n$($tail -join [Environment]::NewLine)"
}
$process.WaitForExit()
$process.Refresh()
$exitCode = $process.ExitCode
if ($null -eq $exitCode -and (Test-Path $OutputPath)) {
$exitCode = 0
}
if ($exitCode -ne 0) {
$tail = if (Test-Path $stderrLog) { Get-Content -Path $stderrLog -Tail 80 } else { "<stderr missing>" }
throw "Fensalir capture failed with exit code $exitCode.`n$($tail -join [Environment]::NewLine)"
}
if (-not (Test-Path $OutputPath)) {
throw "Fensalir capture did not produce expected PNG: $OutputPath"
}
if ($RetainSlots -gt 0) {
Get-ChildItem -Path $slotRoot -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like "fensalir-capture-*" } |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -Skip $RetainSlots |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
}
Write-Host "Fensalir frame captured:"
Write-Host " $OutputPath"

View file

@ -1,359 +1,11 @@
param(
[switch]$Headless,
[switch]$NoStop,
[switch]$BuildOnly,
[switch]$Reopen,
[string]$ClientProject = "src\Aquarium.Epiphany\Aquarium.Epiphany.csproj",
[int]$RetainSlots = 12,
[int]$StartupTimeoutSeconds = 5,
[int]$HeadlessTimeoutSeconds = 60
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$RemainingArgs
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$projectPath = Join-Path $repoRoot "src\Aquarium.Engine\Aquarium.Engine.csproj"
$liveProjectPath = if ([System.IO.Path]::IsPathRooted($ClientProject)) { $ClientProject } else { Join-Path $repoRoot $ClientProject }
$liveProjectPath = (Resolve-Path $liveProjectPath).Path
$liveAssemblyName = [System.IO.Path]::GetFileNameWithoutExtension($liveProjectPath)
$devRoot = Join-Path $repoRoot "artifacts\dev-reload"
$slotRoot = Join-Path $devRoot "slots"
$visibleStatePath = Join-Path $devRoot "state.clixml"
$headlessStatePath = Join-Path $devRoot "headless-state.clixml"
$statePath = if ($Headless) { $headlessStatePath } else { $visibleStatePath }
$buildStatePath = Join-Path $devRoot "last-build.clixml"
$liveReloadPointerPath = Join-Path $devRoot "live-current.txt"
$cultCacheDirectory = if ($Headless) { "headless-cultcache" } else { "cultcache" }
$cultCachePath = Join-Path $devRoot "$cultCacheDirectory\aquarium-client.msgpack"
$logName = if ($Headless) { "headless" } else { "latest" }
$stdoutLogPath = Join-Path $devRoot "$logName.out.log"
$stderrLogPath = Join-Path $devRoot "$logName.err.log"
$fensalirRoot = Resolve-Path (Join-Path $repoRoot "..\Fensalir")
$clientProject = Join-Path $repoRoot "src\Aquarium.Epiphany\Aquarium.Epiphany.csproj"
$script = Join-Path $fensalirRoot "scripts\dev-reload.ps1"
New-Item -ItemType Directory -Force -Path $slotRoot | Out-Null
New-Item -ItemType Directory -Force -Path (Split-Path $cultCachePath) | Out-Null
if ($Reopen -and $Headless) {
throw "-Reopen is for the visible dev window; do not combine it with -Headless."
}
function Stop-PreviousOwnedProcess {
if ($NoStop -or -not (Test-Path $statePath)) {
return
}
$state = Import-Clixml -Path $statePath
if (-not ($state.PSObject.Properties.Name -contains "pid")) {
return
}
if (-not $state.pid) {
return
}
$process = Get-Process -Id ([int]$state.pid) -ErrorAction SilentlyContinue
if (-not $process) {
return
}
$recordedPath = [string]$state.slot
$commandLine = $null
try {
$commandLine = (Get-CimInstance Win32_Process -Filter "ProcessId = $($process.Id)").CommandLine
}
catch {
$commandLine = $null
}
if (-not $commandLine -or $commandLine -notlike "*$recordedPath*") {
Write-Host "Previous PID $($state.pid) no longer belongs to the recorded slot; leaving it alone."
return
}
Write-Host "Stopping previous script-owned Aquarium process $($state.pid)."
Stop-Process -Id ([int]$state.pid) -Force
}
function Remove-OldSlots {
if ($RetainSlots -lt 1) {
return
}
$slots = Get-ChildItem -Path $slotRoot -Directory -ErrorAction SilentlyContinue |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -Skip $RetainSlots
foreach ($slot in $slots) {
try {
Remove-Item -LiteralPath $slot.FullName -Recurse -Force
}
catch {
Write-Host "Could not remove old slot $($slot.FullName): $($_.Exception.Message)"
}
}
}
function Get-StderrTail {
if (-not (Test-Path $stderrLogPath)) {
return "<stderr log does not exist>"
}
$tail = Get-Content -Path $stderrLogPath -Tail 80 -ErrorAction SilentlyContinue
if (-not $tail) {
return "<stderr log is empty>"
}
return ($tail -join [Environment]::NewLine)
}
function Wait-ForStartedAquarium {
param(
[System.Diagnostics.Process]$Process,
[bool]$ExpectWindow
)
$deadline = (Get-Date).AddSeconds([Math]::Max(1, $StartupTimeoutSeconds))
while ((Get-Date) -lt $deadline) {
$Process.Refresh()
if ($Process.HasExited) {
throw "Aquarium process exited during startup with code $($Process.ExitCode).`n$(Get-StderrTail)"
}
if (-not $ExpectWindow) {
return
}
if ($Process.MainWindowHandle -ne [IntPtr]::Zero) {
return
}
Start-Sleep -Milliseconds 100
}
if ($ExpectWindow) {
try {
Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue
}
catch {
# Best effort cleanup before surfacing the broken reload.
}
throw "Aquarium process started but did not open a visible window within $StartupTimeoutSeconds seconds.`n$(Get-StderrTail)"
}
}
function Wait-ForHeadlessAquarium {
param(
[System.Diagnostics.Process]$Process
)
$deadline = (Get-Date).AddSeconds([Math]::Max(1, $HeadlessTimeoutSeconds))
while ((Get-Date) -lt $deadline) {
$Process.Refresh()
if ($Process.HasExited) {
$Process.WaitForExit()
$exitCode = [int]$Process.ExitCode
if ($exitCode -ne 0) {
throw "Headless Aquarium exited with code $exitCode.`n$(Get-StderrTail)"
}
return
}
Start-Sleep -Milliseconds 100
}
try {
Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue
}
catch {
# Best effort cleanup before surfacing the broken smoke test.
}
throw "Headless Aquarium did not exit within $HeadlessTimeoutSeconds seconds; killed PID $($Process.Id).`n$(Get-StderrTail)"
}
function Start-AquariumProcess {
param(
[string]$SlotPath,
[string]$LiveAssemblyPath,
[bool]$RunHeadless
)
$exePath = Join-Path $SlotPath "Aquarium.Engine.exe"
if (-not (Test-Path $exePath)) {
throw "Expected apphost does not exist: $exePath"
}
if (-not (Test-Path $LiveAssemblyPath)) {
throw "Expected client runtime assembly does not exist: $LiveAssemblyPath"
}
Set-Content -Path $liveReloadPointerPath -Value $LiveAssemblyPath -Encoding UTF8
$arguments = @(
"--cache", $cultCachePath,
"--shader-source", (Join-Path $SlotPath "Render\Shaders\D3D12HeightField.hlsl"),
"--client-assembly", $LiveAssemblyPath,
"--client-reload-pointer", $liveReloadPointerPath
)
if ($RunHeadless) {
$arguments += "--headless"
}
$startProcessParameters = @{
FilePath = $exePath
ArgumentList = $arguments
WorkingDirectory = $SlotPath
RedirectStandardOutput = $stdoutLogPath
RedirectStandardError = $stderrLogPath
PassThru = $true
}
if ($RunHeadless) {
$startProcessParameters.WindowStyle = "Hidden"
}
return Start-Process @startProcessParameters
}
function Reopen-VisibleProcess {
if (-not (Test-Path $visibleStatePath)) {
throw "No visible dev-reload state file exists. Run scripts\dev-reload.ps1 once to create a launch slot."
}
$state = Import-Clixml -Path $visibleStatePath
if (-not $state.slot) {
throw "Visible dev-reload state does not record a slot."
}
if ($state.PSObject.Properties.Name -contains "pid" -and $state.pid) {
$existing = Get-Process -Id ([int]$state.pid) -ErrorAction SilentlyContinue
if ($existing) {
Write-Host "Visible Aquarium process $($state.pid) is already running."
return
}
}
$slotPath = [string]$state.slot
$liveAssemblyPath = Join-Path $slotPath "$liveAssemblyName.dll"
if ($state.PSObject.Properties.Name -contains "liveAssembly" -and $state.liveAssembly) {
$liveAssemblyPath = [string]$state.liveAssembly
}
$process = Start-AquariumProcess -SlotPath $slotPath -LiveAssemblyPath $liveAssemblyPath -RunHeadless:$false
Wait-ForStartedAquarium -Process $process -ExpectWindow:$true
@{
slot = $slotPath
builtAt = $state.builtAt
reopenedAt = (Get-Date).ToString("o")
pid = $process.Id
liveAssembly = $liveAssemblyPath
headless = $false
stdoutLog = $stdoutLogPath
stderrLog = $stderrLogPath
} | Export-Clixml -Path $visibleStatePath
Write-Host "Aquarium reopened from last visible slot."
Write-Host " PID: $($process.Id)"
Write-Host " Slot: $slotPath"
return
}
if ($Reopen) {
Reopen-VisibleProcess
exit 0
}
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$slotName = "$timestamp-$([guid]::NewGuid().ToString("N").Substring(0, 8))"
$slotPath = Join-Path $slotRoot $slotName
New-Item -ItemType Directory -Force -Path $slotPath | Out-Null
Write-Host "Building Aquarium into disposable slot:"
Write-Host " $slotPath"
dotnet build $projectPath `
-c Debug `
-o $slotPath `
/p:UseAppHost=true
if ($LASTEXITCODE -ne 0) {
throw "dotnet build failed with exit code $LASTEXITCODE."
}
dotnet build $liveProjectPath `
-c Debug `
-o $slotPath
if ($LASTEXITCODE -ne 0) {
throw "client runtime build failed with exit code $LASTEXITCODE."
}
if ($BuildOnly) {
@{
slot = $slotPath
builtAt = (Get-Date).ToString("o")
stdoutLog = $stdoutLogPath
stderrLog = $stderrLogPath
} | Export-Clixml -Path $buildStatePath
Write-Host "Build-only slot ready."
Remove-OldSlots
exit 0
}
Stop-PreviousOwnedProcess
$exePath = Join-Path $slotPath "Aquarium.Engine.exe"
$liveAssemblyPath = Join-Path $slotPath "$liveAssemblyName.dll"
if (-not (Test-Path $exePath)) {
throw "Expected apphost was not produced: $exePath"
}
if (-not (Test-Path $liveAssemblyPath)) {
throw "Expected client runtime assembly was not produced: $liveAssemblyPath"
}
$process = Start-AquariumProcess -SlotPath $slotPath -LiveAssemblyPath $liveAssemblyPath -RunHeadless:$Headless
if ($Headless) {
Wait-ForHeadlessAquarium -Process $process
@{
slot = $slotPath
builtAt = (Get-Date).ToString("o")
liveAssembly = $liveAssemblyPath
headless = $true
stdoutLog = $stdoutLogPath
stderrLog = $stderrLogPath
} | Export-Clixml -Path $statePath
Write-Host "Headless Aquarium completed from disposable slot."
Write-Host " Slot: $slotPath"
Write-Host " CultCache: $cultCachePath"
Write-Host " Stdout: $stdoutLogPath"
Write-Host " Stderr: $stderrLogPath"
Remove-OldSlots
exit 0
}
Wait-ForStartedAquarium -Process $process -ExpectWindow:$true
@{
slot = $slotPath
builtAt = (Get-Date).ToString("o")
pid = $process.Id
liveAssembly = $liveAssemblyPath
headless = [bool]$Headless
stdoutLog = $stdoutLogPath
stderrLog = $stderrLogPath
} | Export-Clixml -Path $statePath
Write-Host "Aquarium running from disposable slot."
Write-Host " PID: $($process.Id)"
Write-Host " CultCache: $cultCachePath"
Write-Host " Stdout: $stdoutLogPath"
Write-Host " Stderr: $stderrLogPath"
Write-Host "Run this script again to replace only this script-owned process."
if (-not $Headless) {
Write-Host "Run with -Reopen to reopen this visible slot without rebuilding."
}
Remove-OldSlots
& $script -ClientProject $clientProject @RemainingArgs

View file

@ -1,39 +1,5 @@
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$statePath = Join-Path $repoRoot "artifacts\dev-reload\state.clixml"
$fensalirRoot = Resolve-Path (Join-Path $repoRoot "..\Fensalir")
$script = Join-Path $fensalirRoot "scripts\dev-stop.ps1"
if (-not (Test-Path $statePath)) {
Write-Host "No dev-reload state file exists."
exit 0
}
$state = Import-Clixml -Path $statePath
if (-not $state.pid) {
Write-Host "No script-owned Aquarium PID is recorded."
exit 0
}
$process = Get-Process -Id ([int]$state.pid) -ErrorAction SilentlyContinue
if (-not $process) {
Write-Host "Recorded Aquarium process $($state.pid) is not running."
exit 0
}
$recordedPath = [string]$state.slot
$commandLine = $null
try {
$commandLine = (Get-CimInstance Win32_Process -Filter "ProcessId = $($process.Id)").CommandLine
}
catch {
$commandLine = $null
}
if (-not $commandLine -or $commandLine -notlike "*$recordedPath*") {
Write-Host "PID $($state.pid) no longer belongs to the recorded slot; leaving it alone."
exit 0
}
Write-Host "Stopping script-owned Aquarium process $($state.pid)."
Stop-Process -Id ([int]$state.pid) -Force
& $script

View file

@ -1,561 +1,11 @@
param(
[switch]$Headless,
[switch]$NoInitialLaunch,
[switch]$ReopenWhenClosed,
[switch]$Once,
[string]$ClientProject = "src\Aquarium.Epiphany\Aquarium.Epiphany.csproj",
[int]$IntervalMilliseconds = 1000,
[int]$DebounceMilliseconds = 350,
[int]$RetainSlots = 12,
[int]$LiveReloadAckTimeoutSeconds = 6
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$RemainingArgs
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$reloadScript = Join-Path $PSScriptRoot "dev-reload.ps1"
$liveProjectPath = if ([System.IO.Path]::IsPathRooted($ClientProject)) { $ClientProject } else { Join-Path $repoRoot $ClientProject }
$liveProjectPath = (Resolve-Path $liveProjectPath).Path
$liveAssemblyName = [System.IO.Path]::GetFileNameWithoutExtension($liveProjectPath)
$clientSourceRoot = Split-Path $liveProjectPath
$watchLogPath = Join-Path $repoRoot "artifacts\dev-reload\watch.log"
$liveSlotRoot = Join-Path $repoRoot "artifacts\dev-reload\live-slots"
$liveReloadPointerPath = Join-Path $repoRoot "artifacts\dev-reload\live-current.txt"
$shaderPath = Join-Path $repoRoot "src\Aquarium.Engine\Render\Shaders\Aquarium.hlsl"
$engineShaderRoot = Join-Path $repoRoot "src\Aquarium.Engine\Render\Shaders"
$clientShaderRoot = Join-Path $clientSourceRoot "Shaders"
$visibleStatePath = Join-Path $repoRoot "artifacts\dev-reload\state.clixml"
$headlessStatePath = Join-Path $repoRoot "artifacts\dev-reload\headless-state.clixml"
$ownedProcessStatePath = if ($Headless) { $headlessStatePath } else { $visibleStatePath }
$fensalirRoot = Resolve-Path (Join-Path $repoRoot "..\Fensalir")
$clientProject = Join-Path $repoRoot "src\Aquarium.Epiphany\Aquarium.Epiphany.csproj"
$script = Join-Path $fensalirRoot "scripts\dev-watch.ps1"
New-Item -ItemType Directory -Force -Path (Split-Path $watchLogPath) | Out-Null
New-Item -ItemType Directory -Force -Path $liveSlotRoot | Out-Null
if ($Headless -and $ReopenWhenClosed) {
throw "-ReopenWhenClosed is for the visible dev window; do not combine it with -Headless."
}
function Write-WatchLog([string]$message) {
$line = "$(Get-Date -Format o) $message"
Write-Host $line
Add-Content -Path $watchLogPath -Value $line
}
function Get-SourceFiles {
$roots = @(
(Join-Path $repoRoot "src"),
(Join-Path $repoRoot "scripts"),
(Join-Path $repoRoot "Aquarium.Engine.sln"),
(Join-Path $repoRoot "global.json")
)
foreach ($root in $roots) {
if (-not (Test-Path $root)) {
continue
}
$item = Get-Item -LiteralPath $root
if (-not $item.PSIsContainer) {
$item
continue
}
Get-ChildItem -LiteralPath $item.FullName -Recurse -File |
Where-Object {
$_.FullName -notmatch "\\bin\\" -and
$_.FullName -notmatch "\\obj\\" -and
$_.FullName -notmatch "\\artifacts\\" -and
(
$_.Extension -in @(".cs", ".csproj", ".json", ".sln", ".ps1") -or
$_.FullName -like (Join-Path $repoRoot "src\Aquarium.Engine\Assets\*")
)
}
}
}
function Get-ShaderFiles {
foreach ($root in @($engineShaderRoot, $clientShaderRoot)) {
if (-not (Test-Path $root)) {
continue
}
Get-ChildItem -LiteralPath $root -Recurse -File |
Where-Object { $_.Extension -in @(".hlsl", ".hlsli") }
}
}
function Get-LiveFiles {
Get-SourceFiles | Where-Object {
$_.FullName -like (Join-Path $clientSourceRoot "*")
}
}
function Get-RestartFiles {
Get-SourceFiles | Where-Object {
$_.FullName -notlike (Join-Path $clientSourceRoot "*")
}
}
function Get-Fingerprint($files) {
$fingerprintInput = $files |
Sort-Object FullName |
ForEach-Object {
"$($_.FullName)|$($_.Length)|$($_.LastWriteTimeUtc.Ticks)"
}
$text = [string]::Join("`n", $fingerprintInput)
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$hashBytes = $sha.ComputeHash($bytes)
return -join ($hashBytes | ForEach-Object { $_.ToString("x2") })
}
finally {
$sha.Dispose()
}
}
function Get-Hash([string]$text) {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$hashBytes = $sha.ComputeHash($bytes)
return -join ($hashBytes | ForEach-Object { $_.ToString("x2") })
}
finally {
$sha.Dispose()
}
}
function Get-AquariumFrameContractText {
if (-not (Test-Path $shaderPath)) {
return ""
}
$text = Get-Content -Raw -LiteralPath $shaderPath
$match = [regex]::Match(
$text,
"cbuffer\s+AquariumFrame\s*:\s*register\(b0\)\s*\{(?<body>.*?)\};",
[System.Text.RegularExpressions.RegexOptions]::Singleline)
if (-not $match.Success) {
return ""
}
return $match.Value
}
function Get-SourceFingerprint {
$fileFingerprint = Get-Fingerprint (Get-SourceFiles)
$contractFingerprint = Get-Hash (Get-AquariumFrameContractText)
Get-Hash "$fileFingerprint`nAquariumFrame:$contractFingerprint"
}
function Get-ShaderFingerprint {
Get-Fingerprint (Get-ShaderFiles)
}
function Get-AllFingerprint {
$sourceFingerprint = Get-SourceFingerprint
$shaderFingerprint = Get-ShaderFingerprint
Get-Hash "$sourceFingerprint`nShaders:$shaderFingerprint"
}
function Get-LiveFingerprint {
Get-Fingerprint (Get-LiveFiles)
}
function Get-RestartFingerprint {
$fileFingerprint = Get-Fingerprint (Get-RestartFiles)
$contractFingerprint = Get-Hash (Get-AquariumFrameContractText)
Get-Hash "$fileFingerprint`nAquariumFrame:$contractFingerprint"
}
function Wait-StableFingerprints {
$first = @{
all = Get-AllFingerprint
live = Get-LiveFingerprint
restart = Get-RestartFingerprint
shaders = Get-ShaderFingerprint
}
Start-Sleep -Milliseconds $DebounceMilliseconds
$second = @{
all = Get-AllFingerprint
live = Get-LiveFingerprint
restart = Get-RestartFingerprint
shaders = Get-ShaderFingerprint
}
while ($first.all -ne $second.all) {
$first = $second
Start-Sleep -Milliseconds $DebounceMilliseconds
$second = @{
all = Get-AllFingerprint
live = Get-LiveFingerprint
restart = Get-RestartFingerprint
shaders = Get-ShaderFingerprint
}
}
return $second
}
function Invoke-Reload {
param([string]$reason)
Write-WatchLog "Reload requested: $reason"
$arguments = @(
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-File", $reloadScript,
"-ClientProject", $liveProjectPath,
"-RetainSlots", "$RetainSlots"
)
if ($Headless) {
$arguments += "-Headless"
}
Push-Location $repoRoot
try {
& powershell @arguments
if ($LASTEXITCODE -ne 0) {
throw "dev-reload failed with exit code $LASTEXITCODE."
}
}
finally {
Pop-Location
}
}
function Get-OwnedProcessState {
if (-not (Test-Path $ownedProcessStatePath)) {
return $null
}
try {
return Import-Clixml -Path $ownedProcessStatePath
}
catch {
Write-WatchLog "Could not read script-owned process state. $($_.Exception.Message)"
return $null
}
}
function Test-RecordedProcessRunning {
$state = Get-OwnedProcessState
if (-not $state -or -not $state.pid -or -not $state.slot) {
return $false
}
$process = Get-Process -Id ([int]$state.pid) -ErrorAction SilentlyContinue
if (-not $process) {
return $false
}
$recordedPath = [string]$state.slot
$commandLine = $null
try {
$commandLine = (Get-CimInstance Win32_Process -Filter "ProcessId = $($process.Id)").CommandLine
}
catch {
$commandLine = $null
}
return [bool]($commandLine -and $commandLine -like "*$recordedPath*")
}
function Test-OwnedProcessRunning {
if ($NoInitialLaunch) {
return $true
}
return Test-RecordedProcessRunning
}
function Invoke-Reopen {
param([string]$reason)
if ($Headless) {
Invoke-Reload $reason
return
}
Write-WatchLog "Reopen requested: $reason"
$arguments = @(
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-File", $reloadScript,
"-Reopen",
"-ClientProject", $liveProjectPath,
"-RetainSlots", "$RetainSlots"
)
Push-Location $repoRoot
try {
& powershell @arguments
if ($LASTEXITCODE -ne 0) {
throw "dev-reload -Reopen failed with exit code $LASTEXITCODE."
}
}
finally {
Pop-Location
}
}
function Invoke-LiveReload {
param([string]$reason)
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$liveSlot = Join-Path $liveSlotRoot "$timestamp-$([guid]::NewGuid().ToString("N").Substring(0, 8))"
New-Item -ItemType Directory -Force -Path $liveSlot | Out-Null
Write-WatchLog "Live reload requested: $reason"
dotnet build $liveProjectPath -c Debug -o $liveSlot
if ($LASTEXITCODE -ne 0) {
throw "client runtime build failed with exit code $LASTEXITCODE."
}
$liveAssemblyPath = Join-Path $liveSlot "$liveAssemblyName.dll"
if (-not (Test-Path $liveAssemblyPath)) {
throw "Expected client runtime assembly was not produced: $liveAssemblyPath"
}
Set-Content -Path $liveReloadPointerPath -Value $liveAssemblyPath -Encoding UTF8
Write-WatchLog "Live reload pointer updated: $liveAssemblyPath"
Wait-LiveReloadAcknowledged $liveAssemblyPath
}
function Copy-ShaderTree {
param(
[string]$SourceRoot,
[string]$DestinationRoot
)
if (-not (Test-Path $SourceRoot)) {
return
}
Get-ChildItem -LiteralPath $SourceRoot -Recurse -File |
Where-Object { $_.Extension -in @(".hlsl", ".hlsli") } |
ForEach-Object {
$relativePath = [System.IO.Path]::GetRelativePath($SourceRoot, $_.FullName)
$destinationPath = Join-Path $DestinationRoot $relativePath
$destinationDirectory = Split-Path $destinationPath
New-Item -ItemType Directory -Force -Path $destinationDirectory | Out-Null
Copy-Item -LiteralPath $_.FullName -Destination $destinationPath -Force
}
}
function Read-LogTextAfterLength {
param(
[string]$Path,
[long]$StartLength
)
if (-not (Test-Path $Path)) {
return ""
}
$stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
try {
if ($StartLength -gt 0 -and $StartLength -lt $stream.Length) {
$stream.Seek($StartLength, [System.IO.SeekOrigin]::Begin) | Out-Null
}
elseif ($StartLength -ge $stream.Length) {
return ""
}
$reader = [System.IO.StreamReader]::new($stream)
try {
return $reader.ReadToEnd()
}
finally {
$reader.Dispose()
}
}
finally {
$stream.Dispose()
}
}
function Wait-ShaderHotReloadAcknowledged {
param(
[string]$StdoutLog,
[string]$StderrLog,
[long]$StdoutStartLength,
[long]$StderrStartLength
)
$deadline = (Get-Date).AddSeconds([Math]::Max(1, $LiveReloadAckTimeoutSeconds))
while ((Get-Date) -lt $deadline) {
$stdoutText = Read-LogTextAfterLength -Path $StdoutLog -StartLength $StdoutStartLength
if ($stdoutText -match "D3D12 shader hot reload applied") {
Write-WatchLog "Shader hot reload acknowledged by renderer."
return
}
$stderrText = Read-LogTextAfterLength -Path $StderrLog -StartLength $StderrStartLength
if ($stderrText -match "D3D12 shader hot reload failed") {
throw "renderer reported shader hot reload failure. See $StderrLog"
}
Start-Sleep -Milliseconds 250
}
throw "shader files were copied, but renderer did not acknowledge hot reload within $LiveReloadAckTimeoutSeconds seconds. Check $StdoutLog and $StderrLog."
}
function Invoke-ShaderHotReload {
param([string]$reason)
Write-WatchLog "Shader hot reload requested: $reason"
$state = Get-OwnedProcessState
if (-not $state -or -not $state.slot) {
throw "no script-owned process slot is recorded for shader hot reload."
}
if (-not (Test-RecordedProcessRunning)) {
throw "no script-owned process is running to receive shader hot reload."
}
$slotPath = [string]$state.slot
$shaderDestinationRoot = Join-Path $slotPath "Render\Shaders"
New-Item -ItemType Directory -Force -Path $shaderDestinationRoot | Out-Null
$stdoutLog = if ($state.PSObject.Properties.Name -contains "stdoutLog" -and $state.stdoutLog) { [string]$state.stdoutLog } else { $stdoutLogPath }
$stderrLog = if ($state.PSObject.Properties.Name -contains "stderrLog" -and $state.stderrLog) { [string]$state.stderrLog } else { $stderrLogPath }
$stdoutStartLength = if (Test-Path $stdoutLog) { (Get-Item -LiteralPath $stdoutLog).Length } else { 0 }
$stderrStartLength = if (Test-Path $stderrLog) { (Get-Item -LiteralPath $stderrLog).Length } else { 0 }
Copy-ShaderTree -SourceRoot $engineShaderRoot -DestinationRoot $shaderDestinationRoot
Copy-ShaderTree -SourceRoot $clientShaderRoot -DestinationRoot $shaderDestinationRoot
Write-WatchLog "Shader files copied into running slot: $shaderDestinationRoot"
Wait-ShaderHotReloadAcknowledged -StdoutLog $stdoutLog -StderrLog $stderrLog -StdoutStartLength $stdoutStartLength -StderrStartLength $stderrStartLength
}
function Wait-LiveReloadAcknowledged {
param([string]$liveAssemblyPath)
if (-not (Test-RecordedProcessRunning)) {
throw "live reload pointer was updated, but no script-owned process is running to load it."
}
$state = Get-OwnedProcessState
if (-not $state -or -not $state.stdoutLog) {
Write-WatchLog "Live reload acknowledgement skipped: no running process log is recorded."
return
}
$stdoutLog = [string]$state.stdoutLog
$stderrLog = if ($state.stderrLog) { [string]$state.stderrLog } else { $null }
$timeoutSeconds = [Math]::Max(1, $LiveReloadAckTimeoutSeconds)
$deadline = (Get-Date).AddSeconds($timeoutSeconds)
$appliedLine = "Client runtime reload applied: $liveAssemblyPath"
while ((Get-Date) -lt $deadline) {
if (Test-Path $stdoutLog) {
$stdoutTail = (Get-Content -Path $stdoutLog -Tail 120 -ErrorAction SilentlyContinue) -join "`n"
if ($stdoutTail.Contains($appliedLine)) {
Write-WatchLog "Live reload acknowledged by host: $liveAssemblyPath"
return
}
}
if ($stderrLog -and (Test-Path $stderrLog)) {
$stderrTail = (Get-Content -Path $stderrLog -Tail 120 -ErrorAction SilentlyContinue) -join "`n"
if ($stderrTail -match "Client runtime reload failed") {
throw "host reported client runtime reload failure. See $stderrLog"
}
}
Start-Sleep -Milliseconds 250
}
throw "live reload pointer was updated, but host did not acknowledge loading $liveAssemblyPath within $timeoutSeconds seconds. Check $stdoutLog and $stderrLog."
}
$lastGoodFingerprint = Wait-StableFingerprints
$lastFailedFingerprint = $null
if (-not $NoInitialLaunch) {
try {
Invoke-Reload "initial launch"
$lastGoodFingerprint = Wait-StableFingerprints
$lastFailedFingerprint = $null
}
catch {
$lastFailedFingerprint = $lastGoodFingerprint.all
Write-WatchLog "Initial launch failed; keeping any previous good process alive. $($_.Exception.Message)"
}
}
Write-WatchLog "Watching Aquarium sources. Press Ctrl+C to stop."
do {
Start-Sleep -Milliseconds $IntervalMilliseconds
$currentFingerprint = Wait-StableFingerprints
if (-not (Test-OwnedProcessRunning)) {
try {
if ($ReopenWhenClosed) {
Invoke-Reopen "script-owned process is not running"
}
else {
Invoke-Reload "script-owned process is not running"
}
$lastGoodFingerprint = Wait-StableFingerprints
$lastFailedFingerprint = $null
$currentFingerprint = $lastGoodFingerprint
}
catch {
$lastFailedFingerprint = $currentFingerprint.all
Write-WatchLog "Relaunch failed; watcher remains alive. $($_.Exception.Message)"
}
if ($Once) {
break
}
}
if ($currentFingerprint.all -eq $lastGoodFingerprint.all -or $currentFingerprint.all -eq $lastFailedFingerprint) {
if ($Once) {
break
}
continue
}
try {
if ($currentFingerprint.restart -eq $lastGoodFingerprint.restart -and
$currentFingerprint.live -eq $lastGoodFingerprint.live -and
$currentFingerprint.shaders -ne $lastGoodFingerprint.shaders) {
Invoke-ShaderHotReload "shader source change"
}
elseif ($currentFingerprint.restart -eq $lastGoodFingerprint.restart -and $currentFingerprint.live -ne $lastGoodFingerprint.live) {
Invoke-LiveReload "live source change"
if ($currentFingerprint.shaders -ne $lastGoodFingerprint.shaders) {
Invoke-ShaderHotReload "shader source change accompanying live reload"
}
}
else {
Invoke-Reload "host/core/content source change"
}
$lastGoodFingerprint = $currentFingerprint
$lastFailedFingerprint = $null
}
catch {
$lastFailedFingerprint = $currentFingerprint.all
Write-WatchLog "Reload failed; previous good process stays alive. $($_.Exception.Message)"
}
if ($Once) {
break
}
} while ($true)
& $script -ClientProject $clientProject @RemainingArgs

View file

@ -1,124 +0,0 @@
param(
[ValidateSet("Auto", "FARender", "JWildfireJava")]
[string]$Renderer = "Auto",
[string]$JWildfireRoot = $env:AQUARIUM_JWILDFIRE_ROOT,
[string]$FARender = $env:AQUARIUM_FARENDER,
[string]$JWildfireJar = $env:AQUARIUM_JWILDFIRE_JAR,
[string]$Java = $env:AQUARIUM_JAVA,
[string]$Flame = "artifacts\reference-tools\j-wildfire-9.00\lib\FARenderJWF\selftest.flame",
[int]$Width = 1024,
[int]$Height = 1024,
[int]$Quality = 5000,
[string]$OutputDirectory = "artifacts\fractal-flame-jwildfire-reference",
[switch]$NoCuda,
[switch]$NoDensityEstimation
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
if ([string]::IsNullOrWhiteSpace($JWildfireRoot)) {
$defaultRoot = Join-Path $repoRoot "artifacts\reference-tools\j-wildfire-9.00"
if (Test-Path $defaultRoot) {
$JWildfireRoot = $defaultRoot
}
}
$flameInput = if ([System.IO.Path]::IsPathRooted($Flame)) { $Flame } else { Join-Path $repoRoot $Flame }
$flamePath = (Resolve-Path $flameInput).Path
$outDir = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory } else { Join-Path $repoRoot $OutputDirectory }
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
if ([string]::IsNullOrWhiteSpace($FARender) -and -not [string]::IsNullOrWhiteSpace($JWildfireRoot)) {
$candidate = Join-Path $JWildfireRoot "lib\FARenderJWF\FARender.exe"
if (Test-Path $candidate) {
$FARender = $candidate
}
}
if ([string]::IsNullOrWhiteSpace($JWildfireJar) -and -not [string]::IsNullOrWhiteSpace($JWildfireRoot)) {
$candidate = Join-Path $JWildfireRoot "lib\j-wildfire.jar"
if (Test-Path $candidate) {
$JWildfireJar = $candidate
}
}
if ($Renderer -eq "Auto") {
$Renderer = if (-not [string]::IsNullOrWhiteSpace($FARender)) { "FARender" } else { "JWildfireJava" }
}
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
if ($Renderer -eq "FARender") {
if ([string]::IsNullOrWhiteSpace($FARender)) {
throw "Set -FARender, AQUARIUM_FARENDER, or AQUARIUM_JWILDFIRE_ROOT. FARender is the preferred scriptable GPU visual reference."
}
$farenderPath = (Resolve-Path $FARender).Path
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($flamePath)
$outputPath = Join-Path $outDir "$baseName-farender-$stamp.png"
$arguments = @($Width, $Height, $flamePath, "-o", $outputPath, "-q", $Quality)
if ($NoDensityEstimation) {
$arguments += "-nde"
}
if (-not $NoCuda) {
$arguments += "-cuda"
}
Push-Location (Split-Path -Parent $farenderPath)
try {
& $farenderPath @arguments
}
finally {
Pop-Location
}
if ($LASTEXITCODE -ne 0) {
throw "FARender failed with exit code $LASTEXITCODE."
}
$rendered = Get-Item $outputPath -ErrorAction SilentlyContinue
if ($null -eq $rendered -or $rendered.Length -le 0) {
throw "FARender reported success but did not produce a non-empty image at $outputPath."
}
Write-Host "JWildfire FARender reference image: $outputPath"
exit 0
}
if ([string]::IsNullOrWhiteSpace($Java)) {
$localJava = Get-ChildItem (Join-Path $repoRoot "artifacts\reference-tools\temurin-jre-17") -Recurse -Filter java.exe -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -ne $localJava) {
$Java = $localJava.FullName
}
else {
$javaCommand = Get-Command java -ErrorAction SilentlyContinue
if ($null -eq $javaCommand) {
throw "Set -Java or AQUARIUM_JAVA to a Java executable. JWildfire's Java renderer needs Java."
}
$Java = $javaCommand.Source
}
}
if ([string]::IsNullOrWhiteSpace($JWildfireJar)) {
throw "Set -JWildfireJar, AQUARIUM_JWILDFIRE_JAR, or AQUARIUM_JWILDFIRE_ROOT to a local JWildfire install."
}
$jarPath = (Resolve-Path $JWildfireJar).Path
$batchDir = Join-Path $outDir "jwildfire-batch-$stamp"
New-Item -ItemType Directory -Force -Path $batchDir | Out-Null
Copy-Item -LiteralPath $flamePath -Destination (Join-Path $batchDir (Split-Path -Leaf $flamePath))
& $Java -cp $jarPath org.jwildfire.create.tina.batch.HeadlessBatchRendererController $batchDir $Width $Height $Quality
if ($LASTEXITCODE -ne 0) {
throw "JWildfire headless renderer failed with exit code $LASTEXITCODE."
}
$renderedImages = Get-ChildItem $batchDir -Include *.png,*.jpg,*.jpeg -Recurse -ErrorAction SilentlyContinue
if ($renderedImages.Count -eq 0) {
Write-Warning "JWildfire Java batch completed, but no image was written into the batch directory. Check JWildfire's configured output folder or use -Renderer FARender for explicit -o output."
}
Write-Host "JWildfire reference render directory: $batchDir"

View file

@ -1,19 +0,0 @@
param(
[string]$Flame = "tests\Aquarium.Engine.Fractal.Tests\Fixtures\Apophysis\linear-spherical-bubble.flame",
[int]$Samples = 8192,
[int]$BurnIn = 64,
[string]$HistogramSize = "64x64",
[string]$Bounds = "-8,-8,8,8",
[string]$Seed = "0x0BADC0DE"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
dotnet run --project (Join-Path $repoRoot "tools\Aquarium.Fractal.Receipt\Aquarium.Fractal.Receipt.csproj") -c Release -- `
--flame (Join-Path $repoRoot $Flame) `
--histogram-samples $Samples `
--histogram-burn-in $BurnIn `
--histogram-size $HistogramSize `
--histogram-bounds $Bounds `
--seed $Seed `
--out (Join-Path $repoRoot "artifacts\fractal-flame-receipts")

View file

@ -1,62 +0,0 @@
param(
[string]$Flam3Render = $env:AQUARIUM_FLAM3_RENDER,
[string]$Flame = "tests\Aquarium.Engine.Fractal.Tests\Fixtures\Apophysis\linear-spherical-bubble.flame",
[string]$Seed = "0x0BADC0DE",
[string]$QualityScale = "1",
[string]$OutputDirectory = "artifacts\fractal-flame-reference"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
if ([string]::IsNullOrWhiteSpace($Flam3Render)) {
throw "Set -Flam3Render or AQUARIUM_FLAM3_RENDER to a local flam3-render executable. Aquarium does not vendor FLAM3 or Apophysis."
}
$rendererCommand = Get-Command $Flam3Render -ErrorAction SilentlyContinue
if ($null -eq $rendererCommand) {
throw "Could not find flam3-render executable: $Flam3Render"
}
$renderer = $rendererCommand.Source
$flameInput = if ([System.IO.Path]::IsPathRooted($Flame)) { $Flame } else { Join-Path $repoRoot $Flame }
$flamePath = (Resolve-Path $flameInput).Path
$outDir = Join-Path $repoRoot $OutputDirectory
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
$imagePath = Join-Path $outDir "flam3-reference-$stamp.ppm"
$previous = @{
in = $env:in
out = $env:out
format = $env:format
seed = $env:seed
qs = $env:qs
verbose = $env:verbose
}
try {
$env:in = $flamePath
$env:out = $imagePath
$env:format = "ppm"
$env:seed = $Seed
$env:qs = $QualityScale
$env:verbose = "0"
& $renderer
if ($LASTEXITCODE -ne 0) {
throw "flam3-render failed with exit code $LASTEXITCODE."
}
}
finally {
$env:in = $previous.in
$env:out = $previous.out
$env:format = $previous.format
$env:seed = $previous.seed
$env:qs = $previous.qs
$env:verbose = $previous.verbose
}
dotnet run --project (Join-Path $repoRoot "tools\Aquarium.Fractal.Receipt\Aquarium.Fractal.Receipt.csproj") -c Release -- `
--reference-ppm $imagePath `
--out $outDir

View file

@ -1,65 +0,0 @@
param(
[int]$Splats = 2000000,
[int]$WarmupSplatUpdates = 2000000,
[int]$SplatUpdates = 2000000,
[int]$Warmup = 30,
[int]$Frames = 120,
[int]$Depth = 8,
[int]$Candidates = 2,
[int]$ReservoirUpdates = 50000,
[int]$ProgramTransforms = 0,
[string]$ProgramFlame = "",
[switch]$VisualParity,
[int]$VisualParityReferenceSamples = 1000000,
[string]$HistogramSize = "64x64",
[string]$HistogramBounds = "-8,-8,8,8",
[string]$ReferenceDensityPpm = "",
[string[]]$VisualParityView = @(),
[string]$VisualParityImageDirectory = "",
[string]$VisualParityImagePrefix = "visual-parity",
[int]$ReadbackSplats = 64
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$programArgs = @("--program-transforms", "$ProgramTransforms")
if (-not [string]::IsNullOrWhiteSpace($ProgramFlame)) {
$programFlamePath = if ([System.IO.Path]::IsPathRooted($ProgramFlame)) { $ProgramFlame } else { Join-Path $repoRoot $ProgramFlame }
$programArgs = @("--program-flame", $programFlamePath)
}
$parityArgs = @()
if ($VisualParity -or -not [string]::IsNullOrWhiteSpace($ReferenceDensityPpm)) {
$parityArgs = @(
"--visual-parity-reference-samples", "$VisualParityReferenceSamples",
"--histogram-size", $HistogramSize,
"--histogram-bounds", $HistogramBounds
)
if ($VisualParity) {
$parityArgs = @("--visual-parity") + $parityArgs
}
foreach ($view in $VisualParityView) {
$parityArgs += @("--visual-parity-view", $view)
}
if (-not [string]::IsNullOrWhiteSpace($VisualParityImageDirectory)) {
$parityImageDir = if ([System.IO.Path]::IsPathRooted($VisualParityImageDirectory)) { $VisualParityImageDirectory } else { Join-Path $repoRoot $VisualParityImageDirectory }
$parityArgs += @("--visual-parity-image-dir", $parityImageDir, "--visual-parity-image-prefix", $VisualParityImagePrefix)
}
}
if (-not [string]::IsNullOrWhiteSpace($ReferenceDensityPpm)) {
$referenceDensityPpmPath = if ([System.IO.Path]::IsPathRooted($ReferenceDensityPpm)) { $ReferenceDensityPpm } else { Join-Path $repoRoot $ReferenceDensityPpm }
$parityArgs += @("--reference-density-ppm", $referenceDensityPpmPath)
}
dotnet run --project (Join-Path $repoRoot "tools\Aquarium.Fractal.Receipt\Aquarium.Fractal.Receipt.csproj") -c Release -- `
--splats $Splats `
--warmup-splat-updates $WarmupSplatUpdates `
--splat-updates $SplatUpdates `
--warmup $Warmup `
--frames $Frames `
--depth $Depth `
--candidates $Candidates `
--reservoir-updates $ReservoirUpdates `
$programArgs `
$parityArgs `
--readback-splats $ReadbackSplats

View file

@ -1,53 +0,0 @@
param(
[string]$Destination = "artifacts\reference-tools",
[string]$JWildfireUrl = "https://jwildfire.overwhale.com/downloads/j-wildfire-9.00.zip",
[string]$TemurinJreUrl = "https://api.adoptium.net/v3/binary/latest/17/ga/windows/x64/jre/hotspot/normal/eclipse",
[switch]$Force
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$destinationPath = if ([System.IO.Path]::IsPathRooted($Destination)) { $Destination } else { Join-Path $repoRoot $Destination }
New-Item -ItemType Directory -Force -Path $destinationPath | Out-Null
$jwildfireZip = Join-Path $destinationPath "j-wildfire-9.00.zip"
$jwildfireRoot = Join-Path $destinationPath "j-wildfire-9.00"
if ($Force -or -not (Test-Path $jwildfireRoot)) {
if ($Force -or -not (Test-Path $jwildfireZip)) {
Invoke-WebRequest -Uri $JWildfireUrl -OutFile $jwildfireZip
}
Expand-Archive -LiteralPath $jwildfireZip -DestinationPath $destinationPath -Force
}
$temurinZip = Join-Path $destinationPath "temurin-jre-17.zip"
$temurinRoot = Join-Path $destinationPath "temurin-jre-17"
if ($Force -or -not (Test-Path $temurinRoot)) {
if ($Force -or -not (Test-Path $temurinZip)) {
Invoke-WebRequest -Uri $TemurinJreUrl -OutFile $temurinZip
}
New-Item -ItemType Directory -Force -Path $temurinRoot | Out-Null
Expand-Archive -LiteralPath $temurinZip -DestinationPath $temurinRoot -Force
}
$java = Get-ChildItem $temurinRoot -Recurse -Filter java.exe | Select-Object -First 1
$farender = Join-Path $jwildfireRoot "lib\FARenderJWF\FARender.exe"
$jar = Join-Path $jwildfireRoot "lib\j-wildfire.jar"
if (-not (Test-Path $jar)) {
throw "JWildfire install completed, but lib\j-wildfire.jar was not found under $jwildfireRoot."
}
if (-not (Test-Path $farender)) {
Write-Warning "JWildfire installed, but FARender.exe was not found. Java rendering may still work."
}
if ($null -eq $java) {
throw "Temurin JRE install completed, but java.exe was not found under $temurinRoot."
}
Write-Host "AQUARIUM_JWILDFIRE_ROOT=$jwildfireRoot"
Write-Host "AQUARIUM_JWILDFIRE_JAR=$jar"
Write-Host "AQUARIUM_FARENDER=$farender"
Write-Host "AQUARIUM_JAVA=$($java.FullName)"

View file

@ -1,21 +0,0 @@
param(
[string]$Source = "artifacts\reference-tools\j-wildfire-9.00\lib\FARenderJWF\selftest.flame",
[string]$Output = "artifacts\fractal-flame-jwildfire-reference\julian-disc-baby.flame"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$sourcePath = if ([System.IO.Path]::IsPathRooted($Source)) { $Source } else { Join-Path $repoRoot $Source }
$outputPath = if ([System.IO.Path]::IsPathRooted($Output)) { $Output } else { Join-Path $repoRoot $Output }
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $outputPath) | Out-Null
$text = [System.IO.File]::ReadAllText((Resolve-Path $sourcePath))
$text = $text.Replace('JulianDisc - 535389044', 'julian-disc-baby')
$text = $text.Replace('jwf_julian_power="126"', 'jwf_julian_power="8"')
$text = $text.Replace('jwf_gaussian_blur="0.039366260122290476"', 'jwf_gaussian_blur="0.0"')
$text = $text.Replace('wfield_var_amount="0.21416904132907888"', 'wfield_var_amount="0.0"')
$text = $text.Replace('wfield_color_amount="0.26564915787570376"', 'wfield_color_amount="0.0"')
$text = $text.Replace('wfield_jitter_amount="0.13887944166913668"', 'wfield_jitter_amount="0.0"')
[System.IO.File]::WriteAllText($outputPath, $text, [System.Text.UTF8Encoding]::new($false))
Write-Host "JWildfire baby fixture: $outputPath"

View file

@ -1,100 +0,0 @@
param(
[switch]$Zip,
[switch]$NoSelfContained,
[switch]$Clean,
[string]$Configuration = "Release",
[string]$Runtime = "win-x64",
[string]$OutputRoot
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$projectPath = Join-Path $repoRoot "src\Aquarium.Engine\Aquarium.Engine.csproj"
$liveProjectPath = Join-Path $repoRoot "src\Aquarium.Epiphany\Aquarium.Epiphany.csproj"
$publishRoot = if ([string]::IsNullOrWhiteSpace($OutputRoot)) {
Join-Path $repoRoot "artifacts\publish"
}
else {
$OutputRoot
}
$publishPath = Join-Path $publishRoot $Runtime
$zipPath = Join-Path $publishRoot "EpiphanyAquariumEngine-$Runtime.zip"
if ($Clean -and (Test-Path $publishPath)) {
Remove-Item -LiteralPath $publishPath -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $publishPath | Out-Null
$selfContained = if ($NoSelfContained) { "false" } else { "true" }
$publishArguments = @(
"publish",
$projectPath,
"-c", $Configuration,
"-r", $Runtime,
"--self-contained", $selfContained,
"-o", $publishPath,
"/p:UseAppHost=true"
)
Write-Host "Publishing Aquarium Engine:"
Write-Host " Project: $projectPath"
Write-Host " Client: $liveProjectPath"
Write-Host " Runtime: $Runtime"
Write-Host " Output: $publishPath"
dotnet @publishArguments
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE."
}
dotnet publish $liveProjectPath -c $Configuration -r $Runtime --self-contained false -o $publishPath
if ($LASTEXITCODE -ne 0) {
throw "client runtime publish failed with exit code $LASTEXITCODE."
}
& (Join-Path $PSScriptRoot "sync-faust-toolchain.ps1") -DestinationRoot (Join-Path $publishPath "Tools\Faust")
$requiredFiles = @(
"Aquarium.Engine.exe",
"Aquarium.Epiphany.dll",
"Aquarium.Engine.Contracts.dll",
"AquaSynth.Core.dll",
"AquaSynth.Faust.dll",
"Tools\Faust\lib\faust.dll",
"Tools\Faust\share\faust\stdfaust.lib",
"Assets\Fensalir-Icon.ico",
"Assets\Fensalir-Icon-96.png",
"Assets\Fensalir-Splash.bmp",
"Assets\Fensalir-Splash.png",
"Assets\Fonts\Montserrat[wght].ttf",
"Assets\Fonts\UbuntuSans[wdth,wght].ttf",
"Render\Shaders\D3D12HeightField.hlsl",
"Render\Shaders\D3D12SdfCommon.hlsli",
"Render\Shaders\D3D12SdfProxy.hlsli",
"Render\Shaders\D3D12SelfBody.hlsl",
"Render\Shaders\D3D12CursorBody.hlsl"
)
foreach ($relativePath in $requiredFiles) {
$path = Join-Path $publishPath $relativePath
if (-not (Test-Path -LiteralPath $path)) {
throw "Publish output is missing required file: $relativePath"
}
}
Write-Host "Publish output verified."
if ($Zip) {
if (Test-Path $zipPath) {
Remove-Item -LiteralPath $zipPath -Force
}
Compress-Archive -Path (Join-Path $publishPath "*") -DestinationPath $zipPath -CompressionLevel Optimal
Write-Host "Zip ready:"
Write-Host " $zipPath"
}
Write-Host "Shipping build ready:"
Write-Host " $publishPath"

View file

@ -21,11 +21,11 @@ if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
New-Item -ItemType Directory -Force -Path $slotPath | Out-Null
New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
Write-Host "Building isolated preview renderer:"
Write-Host "Building isolated Epiphany preview renderer:"
Write-Host " $slotPath"
dotnet build $projectPath -c Debug -o $slotPath /p:UseAppHost=true
if ($LASTEXITCODE -ne 0) {
throw "preview build failed with exit code $LASTEXITCODE."
throw "Preview build failed with exit code $LASTEXITCODE."
}
$exePath = Join-Path $slotPath "Aquarium.Epiphany.AgentPreview.exe"
@ -41,7 +41,7 @@ if (-not (Test-Path $exePath)) {
--frames $FramesPerView `
--time $TimeSeconds
if ($LASTEXITCODE -ne 0) {
throw "preview render failed with exit code $LASTEXITCODE."
throw "Preview render failed with exit code $LASTEXITCODE."
}
Write-Host "Preview frames written:"

View file

@ -1,57 +0,0 @@
param(
[string]$SourceRoot,
[string]$DestinationRoot
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
if ([string]::IsNullOrWhiteSpace($SourceRoot)) {
$SourceRoot = if ($env:AQUASYNTH_FAUST_HOME) { $env:AQUASYNTH_FAUST_HOME } elseif ($env:AQUARIUM_FAUST_HOME) { $env:AQUARIUM_FAUST_HOME } else { "C:\Program Files\Faust" }
}
if ([string]::IsNullOrWhiteSpace($DestinationRoot)) {
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$DestinationRoot = Join-Path $repoRoot "artifacts\faust\win-x64"
}
$source = Resolve-Path $SourceRoot
$destination = $DestinationRoot
$sourceLib = Join-Path $source "lib\faust.dll"
$sourceShare = Join-Path $source "share\faust"
$sourceBin = Join-Path $source "bin"
if (-not (Test-Path -LiteralPath $sourceLib)) {
throw "Faust DLL not found at $sourceLib"
}
if (-not (Test-Path -LiteralPath $sourceShare)) {
throw "Faust libraries not found at $sourceShare"
}
if (Test-Path -LiteralPath $destination) {
Remove-Item -LiteralPath $destination -Recurse -Force
}
New-Item -ItemType Directory -Force -Path (Join-Path $destination "lib") | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $destination "bin") | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $destination "share\faust") | Out-Null
Copy-Item -LiteralPath $sourceLib -Destination (Join-Path $destination "lib\faust.dll") -Force
if (Test-Path -LiteralPath $sourceBin) {
Get-ChildItem -LiteralPath $sourceBin -File -Filter *.dll |
Copy-Item -Destination (Join-Path $destination "bin") -Force
}
Get-ChildItem -LiteralPath $sourceShare -File -Filter *.lib |
Copy-Item -Destination (Join-Path $destination "share\faust") -Force
$version = & (Join-Path $sourceBin "faust.exe") --version 2>$null | Select-Object -First 1
if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($version)) {
Set-Content -Path (Join-Path $destination "VERSION.txt") -Value $version -Encoding UTF8
}
Write-Host "Faust toolchain synchronized:"
Write-Host " Source: $source"
Write-Host " Destination: $destination"

View file

@ -1,26 +0,0 @@
param(
[string]$SynthRepo = "E:\Projects\AquaSynth",
[string]$Configuration = "Release"
)
$ErrorActionPreference = "Stop"
$root = Resolve-Path (Join-Path $PSScriptRoot "..")
$feed = Join-Path $root "packages"
$coreProject = Join-Path $SynthRepo "src\AquaSynth.Core\AquaSynth.Core.csproj"
$faustProject = Join-Path $SynthRepo "src\AquaSynth.Faust\AquaSynth.Faust.csproj"
if (-not (Test-Path $coreProject)) {
throw "AquaSynth.Core project not found at $coreProject"
}
if (-not (Test-Path $faustProject)) {
throw "AquaSynth.Faust project not found at $faustProject"
}
New-Item -ItemType Directory -Force -Path $feed | Out-Null
dotnet pack $coreProject -c $Configuration -o $feed
dotnet pack $faustProject -c $Configuration -o $feed
Write-Host "Packed AquaSynth.Core and AquaSynth.Faust into $feed"
Write-Host "If the package version changed, update src\Aquarium.Engine\Aquarium.Engine.csproj to match."

View file

@ -1,19 +0,0 @@
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$engineRoots = @(
(Join-Path $repoRoot "src\Aquarium.Engine"),
(Join-Path $repoRoot "src\Aquarium.Engine.Contracts")
)
$forbiddenEngineTerms = "grid|agent|body|epiphany"
$engineReferences = rg -n -i -- $forbiddenEngineTerms $engineRoots
if ($LASTEXITCODE -eq 0) {
throw "Aquarium Engine and contracts must not contain Epiphany/client policy terms:`n$engineReferences"
}
elseif ($LASTEXITCODE -ne 1) {
throw "Boundary search for Aquarium Engine terms failed."
}
Write-Host "Aquarium boundary checks passed."

View file

@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -1,25 +0,0 @@
using System.Numerics;
using Aquarium.Engine.Render;
namespace Aquarium.Engine;
public readonly record struct AquariumFrame(ViewFrame View, Vector3 CameraPosition, Vector3 CameraTarget, float TimeSeconds, Vector2 CursorWorld, AquariumSceneState Scene)
{
public AquariumFrame(ViewFrame view, Vector3 cameraPosition, float timeSeconds)
: this(view, cameraPosition, new Vector3(view.Center, 0.0f), timeSeconds, view.Center, AquariumSceneState.Empty)
{
}
public AquariumFrame(ViewFrame view, Vector3 cameraPosition, float timeSeconds, Vector2 cursorWorld)
: this(view, cameraPosition, new Vector3(view.Center, 0.0f), timeSeconds, cursorWorld, AquariumSceneState.Empty)
{
}
public AquariumFrame(ViewFrame view, Vector3 cameraPosition, float timeSeconds, Vector2 cursorWorld, AquariumSceneState scene)
: this(view, cameraPosition, new Vector3(view.Center, 0.0f), timeSeconds, cursorWorld, scene)
{
}
}
public readonly record struct AquariumFrameInput(Vector2 MousePosition, int Width, int Height);

View file

@ -1,39 +0,0 @@
using Aquarium.Engine.Audio;
using Aquarium.Engine.Input;
using Aquarium.Engine.Render;
using Aquarium.Engine.Ui;
namespace Aquarium.Engine;
public interface IAquariumRuntime : IDisposable
{
AquariumRuntimeOptions Options { get; }
AquariumFrame Frame { get; }
GraphicsSettings GraphicsSettings { get; set; }
AquariumRenderPlan RenderPlan { get; }
AquariumUiDocument Ui { get; }
AquariumAudioDocument Audio { get; }
AquariumSynthDocument Synth { get; }
void Start();
void Update(float deltaSeconds, InputState input);
AquariumFrame ComposeFrame(AquariumFrame frame, AquariumFrameInput input);
void FlushState();
}
public interface IAquariumRuntimeFactory
{
IAquariumRuntime Create(AquariumRuntimeOptions options);
}
public readonly record struct AquariumRuntimeOptions(bool Headless, string? CultCachePath, int? RenderDebugModeOverride = null);

View file

@ -1,68 +0,0 @@
using System.Collections.Concurrent;
namespace Aquarium.Engine.Audio;
public sealed class AquariumAudioDocument
{
private readonly ConcurrentQueue<AquariumPcmAudioChunk> pcmChunks = new();
public static AquariumAudioDocument Empty { get; } = new();
public void EnqueuePcm16Base64(string base64Data, int sampleRate, int channels, float gain = 1.0f, float pan = 0.0f)
{
if (string.IsNullOrWhiteSpace(base64Data) || sampleRate <= 0 || channels <= 0)
{
return;
}
byte[] bytes;
try
{
bytes = Convert.FromBase64String(base64Data);
}
catch (FormatException)
{
return;
}
var frameCount = bytes.Length / Math.Max(1, channels) / 2;
if (frameCount <= 0)
{
return;
}
var mono = new float[frameCount];
var byteIndex = 0;
var safeGain = Math.Clamp(gain, 0.0f, 4.0f);
for (var frame = 0; frame < frameCount; frame++)
{
var sum = 0.0f;
for (var channel = 0; channel < channels && byteIndex + 1 < bytes.Length; channel++)
{
var sample = (short)(bytes[byteIndex] | (bytes[byteIndex + 1] << 8));
sum += sample / 32768.0f;
byteIndex += 2;
}
mono[frame] = Math.Clamp(sum / channels, -1.0f, 1.0f);
}
var safePan = Math.Clamp(pan, -1.0f, 1.0f);
var leftGain = safeGain * MathF.Sqrt((1.0f - safePan) * 0.5f);
var rightGain = safeGain * MathF.Sqrt((1.0f + safePan) * 0.5f);
pcmChunks.Enqueue(new AquariumPcmAudioChunk(mono, sampleRate, leftGain, rightGain));
}
public IReadOnlyList<AquariumPcmAudioChunk> DrainPcmChunks(int maxChunks = 64)
{
var drained = new List<AquariumPcmAudioChunk>();
while (drained.Count < maxChunks && pcmChunks.TryDequeue(out var chunk))
{
drained.Add(chunk);
}
return drained;
}
}
public sealed record AquariumPcmAudioChunk(float[] MonoSamples, int SampleRate, float LeftGain = 1.0f, float RightGain = 1.0f);

View file

@ -1,84 +0,0 @@
namespace Aquarium.Engine.Audio;
public sealed class AquariumSynthDocument
{
private readonly List<AquariumSynthPatch> patches = [];
public static AquariumSynthDocument Empty { get; } = new();
public bool Enabled { get; init; } = true;
public float MasterGain { get; init; } = 0.55f;
public IReadOnlyList<AquariumSynthPatch> Patches => patches;
public static AquariumSynthDocument Combine(params AquariumSynthDocument[] documents)
{
var combined = new AquariumSynthDocument
{
Enabled = documents.Any(document => document.Enabled),
MasterGain = documents.Length == 0 ? 1.0f : documents.Max(document => document.MasterGain)
};
foreach (var document in documents)
{
if (!document.Enabled)
{
continue;
}
foreach (var patch in document.Patches)
{
combined.patches.Add(patch);
}
}
return combined;
}
public AquariumSynthDocument Patch(
string id,
string script,
AquariumSynthTrigger trigger,
float gain = 1.0f,
int faustCompileRevision = 0,
string? faustName = null,
Action<AquariumSynthPatchStatus>? statusSink = null)
{
patches.Add(new AquariumSynthPatch(id, script, trigger, gain, faustCompileRevision, faustName ?? id, statusSink));
return this;
}
}
public sealed record AquariumSynthPatch(
string Id,
string Script,
AquariumSynthTrigger Trigger,
float Gain = 1.0f,
int FaustCompileRevision = 0,
string FaustName = "aquarium_patch",
Action<AquariumSynthPatchStatus>? StatusSink = null);
public sealed record AquariumSynthPatchStatus(
string Id,
AquariumSynthPatchCompileState State,
string Message,
int Revision,
double ChangedAtSeconds);
public enum AquariumSynthPatchCompileState
{
Idle,
Queued,
Compiling,
Ready,
Failed
}
public readonly record struct AquariumSynthTrigger(float IntervalSeconds, float PhaseSeconds = 0.0f, int FireRevision = 0)
{
public static AquariumSynthTrigger Repeat(float intervalSeconds, float phaseSeconds = 0.0f) =>
new(MathF.Max(0.001f, intervalSeconds), phaseSeconds);
public static AquariumSynthTrigger Manual(int fireRevision) =>
new(float.PositiveInfinity, 0.0f, fireRevision);
}

View file

@ -1,370 +0,0 @@
using System.Numerics;
using System.Runtime.InteropServices;
namespace Aquarium.Engine.Fractal;
public readonly record struct AquariumFractalKey
{
public AquariumFractalKey(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Fractal key must not be empty.", nameof(value));
}
Value = value;
}
public string Value { get; }
public override string ToString()
{
return Value;
}
}
public enum AquariumFractalDomainKind
{
Solar,
Orbital,
Planetary,
LatLong,
CubeSphereTile,
Surface2D,
Object3D,
}
public readonly record struct AquariumFractalDomain(
AquariumFractalKey Key,
AquariumFractalDomainKind Kind,
AquariumFractalKey ParentKey,
Vector4 Parameters0,
Vector4 Parameters1);
public enum AquariumFractalPayloadKind
{
Height,
Material,
SignedDistance,
Density,
Extinction,
}
public enum AquariumFieldLayer
{
Form,
Appearance,
Transport,
}
public enum AquariumFieldEncoding
{
SignedDistance,
Height,
Density,
Extinction,
Material,
Phase,
Emission,
Radiance,
Feature,
Confidence,
}
[Flags]
public enum AquariumFieldEncodingFlags
{
None = 0,
SignedDistance = 1 << 0,
Height = 1 << 1,
Density = 1 << 2,
Extinction = 1 << 3,
Material = 1 << 4,
Phase = 1 << 5,
Emission = 1 << 6,
Radiance = 1 << 7,
Feature = 1 << 8,
Confidence = 1 << 9,
}
public enum AquariumFractalSurfacePageKind
{
Height,
SignedDistance2D,
Material,
Confidence,
}
public readonly record struct AquariumFractalSurfacePageKey
{
public AquariumFractalSurfacePageKey(
AquariumFractalKey domainKey,
AquariumFractalKey nodeKey,
AquariumFractalSurfacePageKind kind,
int mipLevel)
{
if (domainKey.Value is null)
{
throw new ArgumentException("Surface page domain key must not be empty.", nameof(domainKey));
}
if (nodeKey.Value is null)
{
throw new ArgumentException("Surface page node key must not be empty.", nameof(nodeKey));
}
if (mipLevel < 0)
{
throw new ArgumentOutOfRangeException(nameof(mipLevel), mipLevel, "Surface page mip level must not be negative.");
}
DomainKey = domainKey;
NodeKey = nodeKey;
Kind = kind;
MipLevel = mipLevel;
}
public AquariumFractalKey DomainKey { get; }
public AquariumFractalKey NodeKey { get; }
public AquariumFractalSurfacePageKind Kind { get; }
public int MipLevel { get; }
public string Value => $"{DomainKey.Value}:{NodeKey.Value}:{Kind}:mip{MipLevel:D2}";
public override string ToString()
{
return Value;
}
}
public readonly record struct AquariumFractalSurfacePage(
AquariumFractalSurfacePageKey Key,
Vector4 BoundsMinMax,
int Width,
int Height,
int PayloadHandle,
float MaxError);
public readonly record struct AquariumFractalSdfSplat3DKey
{
public AquariumFractalSdfSplat3DKey(
AquariumFractalKey domainKey,
AquariumFractalKey nodeKey,
int payloadHandle)
{
if (domainKey.Value is null)
{
throw new ArgumentException("3D SDF splat domain key must not be empty.", nameof(domainKey));
}
if (nodeKey.Value is null)
{
throw new ArgumentException("3D SDF splat node key must not be empty.", nameof(nodeKey));
}
if (payloadHandle < 0)
{
throw new ArgumentOutOfRangeException(nameof(payloadHandle), payloadHandle, "3D SDF splat payload handle must not be negative.");
}
DomainKey = domainKey;
NodeKey = nodeKey;
PayloadHandle = payloadHandle;
}
public AquariumFractalKey DomainKey { get; }
public AquariumFractalKey NodeKey { get; }
public int PayloadHandle { get; }
public string Value => $"{DomainKey.Value}:{NodeKey.Value}:sdf3d:{PayloadHandle:D6}";
public override string ToString()
{
return Value;
}
}
public readonly record struct AquariumFractalSdfSplat3D(
AquariumFractalSdfSplat3DKey Key,
Vector3 Center,
Quaternion Orientation,
Vector3 Radii,
float Falloff,
float ShapePower,
float DistanceOffset,
float MaterialValue,
float Confidence);
public readonly record struct AquariumFractalDensitySplat3DKey
{
public AquariumFractalDensitySplat3DKey(
AquariumFractalKey domainKey,
AquariumFractalKey nodeKey,
int payloadHandle)
{
if (domainKey.Value is null)
{
throw new ArgumentException("3D density splat domain key must not be empty.", nameof(domainKey));
}
if (nodeKey.Value is null)
{
throw new ArgumentException("3D density splat node key must not be empty.", nameof(nodeKey));
}
if (payloadHandle < 0)
{
throw new ArgumentOutOfRangeException(nameof(payloadHandle), payloadHandle, "3D density splat payload handle must not be negative.");
}
DomainKey = domainKey;
NodeKey = nodeKey;
PayloadHandle = payloadHandle;
}
public AquariumFractalKey DomainKey { get; }
public AquariumFractalKey NodeKey { get; }
public int PayloadHandle { get; }
public string Value => $"{DomainKey.Value}:{NodeKey.Value}:density3d:{PayloadHandle:D6}";
public override string ToString()
{
return Value;
}
}
public readonly record struct AquariumFractalDensitySplat3D(
AquariumFractalDensitySplat3DKey Key,
Vector3 Center,
Quaternion Orientation,
Vector3 Radii,
float Falloff,
float ShapePower,
float Density,
float Extinction,
float Confidence);
[StructLayout(LayoutKind.Sequential)]
public readonly record struct AquariumPackedFractalSdfSplat3D(
Vector4 CenterRadius,
Vector4 Orientation,
Vector4 RadiiFalloff,
Vector4 MaterialConfidence,
Vector4 Key);
[StructLayout(LayoutKind.Sequential)]
public readonly record struct AquariumPackedFractalDensitySplat3D(
Vector4 CenterRadius,
Vector4 Orientation,
Vector4 RadiiFalloff,
Vector4 DensityExtinctionConfidence,
Vector4 Key);
[StructLayout(LayoutKind.Sequential)]
public readonly record struct AquariumPackedFractalIfsTransform(
Vector4 OffsetScaleAmplitude,
Vector4 RadiiRotationFalloff,
Vector4 MaterialSeedShape,
Vector4 TileAddress,
Vector4 PostMatrix,
Vector4 PostTranslation);
[StructLayout(LayoutKind.Sequential)]
public readonly record struct AquariumPackedFractalFlameState(
Vector4 PointSupportMaterial,
Vector4 RandomStep);
[StructLayout(LayoutKind.Sequential)]
public readonly record struct AquariumPackedSdfEnvelopeReservoir(
Vector4 CenterRadius,
Vector4 RadiiFalloff,
Vector4 WeightTargetCount,
Vector4 Validation);
[StructLayout(LayoutKind.Sequential)]
public readonly record struct AquariumPackedPbrMaterialReservoir(
Vector4 BaseColorRoughMetal,
Vector4 NormalVariance,
Vector4 WeightTargetCount,
Vector4 Validation);
[StructLayout(LayoutKind.Sequential)]
public readonly record struct AquariumPackedRadiosityReservoir(
Vector4 RadianceDistance,
Vector4 DirectionOcclusion,
Vector4 WeightTargetCount,
Vector4 Validation);
public readonly record struct AquariumBrushClaim(
AquariumFractalKey Key,
AquariumFractalKey DomainKey,
AquariumFractalKey NodeKey,
AquariumFractalPayloadKind PayloadKind,
Vector2 Center,
Vector2 Radii,
float RotationRadians,
float Falloff,
float ShapePower,
float Amplitude,
int Seed,
string Tags,
float WaveAmplitude = 0.0f,
float WaveFrequency = 0.0f,
float WaveSpeed = 0.0f,
float WaveSinePower = 0.0f);
public enum AquariumFractalOperation
{
Union,
Subtract,
Common,
Repeat,
Refine,
Capture,
Bind,
}
public readonly record struct AquariumFractalNode(
AquariumFractalKey Key,
AquariumFractalKey DomainKey,
AquariumFractalKey ParentKey,
AquariumFractalOperation Operation,
int FirstChildIndex,
int ChildCount,
int FirstClaimIndex,
int ClaimCount,
Vector4 BoundsMinMax,
int Seed);
public readonly record struct AquariumFractalSummary(
AquariumFractalKey NodeKey,
Vector4 BoundsMinMax,
float MaxHeightError,
float MaxMaterialDelta,
float EstimatedCost,
int DescendantCount,
AquariumFieldEncodingFlags FormEncodingMask = AquariumFieldEncodingFlags.None);
public readonly record struct AquariumContributionState(
AquariumFractalKey NodeKey,
float MeanContribution,
float Variance,
float Confidence,
int SampleCount,
uint LastSampledFrame,
bool Resident);
public readonly record struct AquariumSelectedCut(
AquariumFractalKey NodeKey,
float Score,
float Fade,
bool UsesSummary,
bool RequestedChildren);

View file

@ -1,32 +0,0 @@
namespace Aquarium.Engine;
public readonly record struct GraphicsSettings(
int RenderDebugMode,
float SceneExposure,
float BloomIntensity,
float BloomVeilIntensity)
{
public const int MinRenderDebugMode = 0;
public const int MaxRenderDebugMode = 11;
public const float MinSceneExposure = 0.02f;
public const float MaxSceneExposure = 1.2f;
public const float MinBloomIntensity = 0.0f;
public const float MaxBloomIntensity = 0.24f;
public const float MinBloomVeilIntensity = 0.0f;
public const float MaxBloomVeilIntensity = 0.08f;
public static GraphicsSettings Default { get; } = new(
RenderDebugMode: 0,
SceneExposure: 0.16f,
BloomIntensity: 0.072f,
BloomVeilIntensity: 0.014f);
public GraphicsSettings Normalized()
{
return new GraphicsSettings(
Math.Clamp(RenderDebugMode, MinRenderDebugMode, MaxRenderDebugMode),
Math.Clamp(SceneExposure, MinSceneExposure, MaxSceneExposure),
Math.Clamp(BloomIntensity, MinBloomIntensity, MaxBloomIntensity),
Math.Clamp(BloomVeilIntensity, MinBloomVeilIntensity, MaxBloomVeilIntensity));
}
}

View file

@ -1,135 +0,0 @@
using System.Numerics;
namespace Aquarium.Engine.Input;
public sealed class InputState
{
private readonly HashSet<KeyCode> downKeys = [];
private readonly HashSet<KeyCode> pressedKeys = [];
private readonly HashSet<MouseButton> downMouseButtons = [];
private readonly HashSet<MouseButton> pressedMouseButtons = [];
private readonly List<char> textInput = [];
public Vector2 MousePosition { get; private set; }
public Vector2 MouseDelta { get; private set; }
public float WheelDelta { get; private set; }
public IReadOnlyList<char> TextInput => textInput;
public bool LeftMouseDown => IsMouseDown(MouseButton.Left);
public bool MiddleMouseDown => IsMouseDown(MouseButton.Middle);
public bool RightMouseDown => IsMouseDown(MouseButton.Right);
public bool IsKeyDown(KeyCode key) => downKeys.Contains(key);
public bool IsKeyPressed(KeyCode key) => pressedKeys.Contains(key);
public bool IsMouseDown(MouseButton button) => downMouseButtons.Contains(button);
public bool IsMousePressed(MouseButton button) => pressedMouseButtons.Contains(button);
public void BeginFrame()
{
MouseDelta = Vector2.Zero;
WheelDelta = 0.0f;
pressedKeys.Clear();
pressedMouseButtons.Clear();
textInput.Clear();
}
public void SetMousePosition(Vector2 position)
{
MouseDelta += position - MousePosition;
MousePosition = position;
}
public void AddWheelDelta(float delta)
{
WheelDelta += delta;
}
public void SetMouseButton(MouseButton button, bool isDown)
{
if (isDown)
{
if (!downMouseButtons.Contains(button))
{
pressedMouseButtons.Add(button);
}
downMouseButtons.Add(button);
}
else
{
downMouseButtons.Remove(button);
}
}
public void SetKey(KeyCode key, bool isDown)
{
if (isDown)
{
if (!downKeys.Contains(key))
{
pressedKeys.Add(key);
}
downKeys.Add(key);
}
else
{
downKeys.Remove(key);
}
}
public void AddTextInput(char value)
{
textInput.Add(value);
}
public InputState WithoutInteractiveInput()
{
var snapshot = new InputState();
snapshot.SetMousePosition(MousePosition);
return snapshot;
}
}
public enum MouseButton
{
Left,
Middle,
Right,
}
public enum KeyCode
{
W,
A,
S,
D,
Digit0,
Digit1,
Digit2,
Digit3,
Digit4,
Digit5,
Digit6,
Digit7,
Digit8,
Digit9,
RenderDebugCycle,
DebugUiToggle,
Backspace,
Delete,
Enter,
LeftArrow,
RightArrow,
Home,
End,
Shift,
}

View file

@ -1,234 +0,0 @@
using System.Numerics;
namespace Aquarium.Engine.Render;
public sealed class AquariumApp
{
public AquariumApp(AquariumRenderPlan? plan = null)
{
Plan = plan ?? new AquariumRenderPlan();
RenderTargets = new AquariumRenderTargetBuilder(Plan.Graph);
Cameras = new AquariumCameraBuilder(Plan.Graph);
Shaders = new AquariumShaderBuilder(Plan.Shaders);
Graph = new AquariumGraphBuilder(Plan.Graph);
Features = new AquariumFeatureBuilder(Plan.Graph);
Debug = new AquariumDebugBuilder(Plan.Graph);
}
public AquariumRenderPlan Plan { get; }
public AquariumRenderTargetBuilder RenderTargets { get; }
public AquariumCameraBuilder Cameras { get; }
public AquariumShaderBuilder Shaders { get; }
public AquariumGraphBuilder Graph { get; }
public AquariumFeatureBuilder Features { get; }
public AquariumDebugBuilder Debug { get; }
}
public sealed class AquariumRenderTargetBuilder(AquariumRenderGraphDescription graph)
{
public AquariumRenderTargetDraft Create(string name)
{
return new AquariumRenderTargetDraft(graph, name);
}
public AquariumRenderTargetSet Hdr(string name)
{
var color = graph.RenderTarget(name, RenderFormat.Rgba16Float, AquariumTargetSize.MatchWindow(), historyFrames: 2);
var metadata = graph.RenderTarget($"{name}-metadata", RenderFormat.Rgba16Float, AquariumTargetSize.MatchWindow(), historyFrames: 2);
var control = graph.RenderTarget($"{name}-control", RenderFormat.Rgba16Float, AquariumTargetSize.MatchWindow(), historyFrames: 2);
return new AquariumRenderTargetSet(color, metadata, control, new DepthTargetHandle($"{name}-depth"));
}
}
public sealed class AquariumRenderTargetDraft(AquariumRenderGraphDescription graph, string name)
{
private RenderFormat format = RenderFormat.Rgba16Float;
private AquariumTargetSize size = AquariumTargetSize.MatchWindow();
private bool sampled = true;
private int historyFrames;
public AquariumRenderTargetDraft FixedSize(int width, int height)
{
size = AquariumTargetSize.Fixed(width, height);
return this;
}
public AquariumRenderTargetDraft MatchWindow(float scale = 1.0f)
{
size = AquariumTargetSize.MatchWindow(scale);
return this;
}
public AquariumRenderTargetDraft Format(RenderFormat value)
{
format = value;
return this;
}
public AquariumRenderTargetDraft Sampled(bool value = true)
{
sampled = value;
return this;
}
public AquariumRenderTargetDraft History(int frames)
{
historyFrames = Math.Max(0, frames);
return this;
}
public AquariumRenderTargetDraft Clear(float value)
{
return this;
}
public AquariumRenderTargetDraft Clear(Vector4 value)
{
return this;
}
public RenderTargetHandle Register()
{
return graph.RenderTarget(name, format, size, sampled, historyFrames);
}
}
public readonly record struct AquariumRenderTargetSet(
RenderTargetHandle Color,
RenderTargetHandle Metadata,
RenderTargetHandle Control,
DepthTargetHandle Depth);
public sealed class AquariumCameraBuilder(AquariumRenderGraphDescription graph)
{
public CameraHandle Perspective(string name)
{
return graph.Camera(name);
}
public CameraHandle Orbit(string name)
{
return graph.Camera(name);
}
}
public sealed class AquariumShaderBuilder(AquariumShaderManifest manifest)
{
public AquariumShaderBuilder Root(string path)
{
manifest.Root(path);
return this;
}
public AquariumShaderBuilder Core(string heightField, string scene, string post)
{
manifest.HeightField(heightField).Scene(scene).Post(post);
return this;
}
public AquariumShaderBuilder TemporalGaussian(string path)
{
manifest.TemporalGaussian(path);
return this;
}
public AquariumShaderBuilder LocalCastFusion(string path)
{
manifest.LocalCastFusion(path);
return this;
}
public AquariumShaderBuilder FractalReservoir(string path)
{
manifest.FractalReservoir(path);
return this;
}
public AquariumShaderBuilder FractalSplatRender(string path)
{
manifest.FractalSplatRender(path);
return this;
}
public AquariumShaderBuilder Include(string path)
{
manifest.Include(path);
return this;
}
public AquariumShaderBuilder SdfShader(string path)
{
manifest.SdfShader(path);
return this;
}
public AquariumShaderBuilder SdfLibrary(string path)
{
manifest.SdfLibrary(path);
return this;
}
}
public sealed class AquariumGraphBuilder(AquariumRenderGraphDescription graph)
{
public AquariumPassDraft Pass(string name)
{
return new AquariumPassDraft(graph, name);
}
}
public sealed class AquariumPassDraft(AquariumRenderGraphDescription graph, string name)
{
public PassHandle Fullscreen()
{
return graph.Pass(name, AquariumPassKind.Fullscreen);
}
public PassHandle Proxy()
{
return graph.Pass(name, AquariumPassKind.Proxy);
}
public PassHandle Present()
{
return graph.Pass(name, AquariumPassKind.Present);
}
public PassHandle Overlay()
{
return graph.Pass(name, AquariumPassKind.Overlay);
}
}
public sealed class AquariumFeatureBuilder(AquariumRenderGraphDescription graph)
{
public PassHandle Bloom(RenderTargetHandle source)
{
return graph.Pass($"bloom:{source.Name}", AquariumPassKind.Fullscreen);
}
public PassHandle Presentation(RenderTargetHandle source)
{
return graph.Pass($"present:{source.Name}", AquariumPassKind.Present);
}
public PassHandle DirectWriteOverlay()
{
return graph.Pass("directwrite-overlay", AquariumPassKind.Overlay);
}
}
public sealed class AquariumDebugBuilder(AquariumRenderGraphDescription graph)
{
public AquariumDebugBuilder View(string name, RenderTargetHandle target)
{
graph.DebugView(name, target);
return this;
}
}

View file

@ -1,503 +0,0 @@
using System.Numerics;
using System.Runtime.InteropServices;
using Aquarium.Engine.Fractal;
namespace Aquarium.Engine.Render;
public sealed class AquariumRenderPlan
{
public AquariumRenderPlan()
{
Shaders = new AquariumShaderManifest();
Graph = new AquariumRenderGraphDescription();
}
public AquariumShaderManifest Shaders { get; }
public AquariumRenderGraphDescription Graph { get; }
}
public sealed class AquariumShaderManifest
{
private readonly List<string> sdfShaderPaths = [];
private readonly List<string> includePaths = [];
public string? ShaderRoot { get; private set; }
public string HeightFieldShader { get; private set; } = "D3D12HeightField.hlsl";
public string SceneShader { get; private set; } = "D3D12Scene.hlsl";
public string TemporalGaussianShader { get; private set; } = "D3D12TemporalGaussian.hlsl";
public string LocalCastFusionShader { get; private set; } = "D3D12LocalCastFusion.hlsl";
public string FractalReservoirShader { get; private set; } = "D3D12FractalReservoirCompute.hlsl";
public string FractalSplatRenderShader { get; private set; } = "D3D12FractalSplatRender.hlsl";
public string PostShader { get; private set; } = "D3D12Post.hlsl";
public string SdfCommonInclude { get; private set; } = "D3D12SdfCommon.hlsli";
public string SdfProxyInclude { get; private set; } = "D3D12SdfProxy.hlsli";
public string SdfMathInclude { get; private set; } = "D3D12SdfMath.hlsli";
public string? SdfLibraryInclude { get; private set; }
public IReadOnlyList<string> SdfShaderPaths => sdfShaderPaths;
public IReadOnlyList<string> IncludePaths => includePaths;
public AquariumShaderManifest Root(string path)
{
ShaderRoot = path;
return this;
}
public AquariumShaderManifest HeightField(string path)
{
HeightFieldShader = path;
return this;
}
public AquariumShaderManifest Scene(string path)
{
SceneShader = path;
return this;
}
public AquariumShaderManifest TemporalGaussian(string path)
{
TemporalGaussianShader = path;
return this;
}
public AquariumShaderManifest LocalCastFusion(string path)
{
LocalCastFusionShader = path;
return this;
}
public AquariumShaderManifest FractalReservoir(string path)
{
FractalReservoirShader = path;
return this;
}
public AquariumShaderManifest FractalSplatRender(string path)
{
FractalSplatRenderShader = path;
return this;
}
public AquariumShaderManifest Post(string path)
{
PostShader = path;
return this;
}
public AquariumShaderManifest SdfCommon(string path)
{
SdfCommonInclude = path;
return this;
}
public AquariumShaderManifest SdfProxy(string path)
{
SdfProxyInclude = path;
return this;
}
public AquariumShaderManifest SdfMath(string path)
{
SdfMathInclude = path;
return this;
}
public AquariumShaderManifest SdfLibrary(string? path)
{
SdfLibraryInclude = path;
return this;
}
public AquariumShaderManifest Include(string path)
{
includePaths.Add(path);
return this;
}
public AquariumShaderManifest SdfShader(string path)
{
sdfShaderPaths.Add(path);
return this;
}
}
public sealed class AquariumRenderGraphDescription
{
private readonly List<AquariumRenderTargetDescription> renderTargets = [];
private readonly List<AquariumCameraDescription> cameras = [];
private readonly List<AquariumPassDescription> passes = [];
private readonly List<AquariumDebugViewDescription> debugViews = [];
public IReadOnlyList<AquariumRenderTargetDescription> RenderTargets => renderTargets;
public IReadOnlyList<AquariumCameraDescription> Cameras => cameras;
public IReadOnlyList<AquariumPassDescription> Passes => passes;
public IReadOnlyList<AquariumDebugViewDescription> DebugViews => debugViews;
public RenderTargetHandle RenderTarget(string name, RenderFormat format, AquariumTargetSize size, bool sampled = true, int historyFrames = 0)
{
var handle = new RenderTargetHandle(name);
renderTargets.Add(new AquariumRenderTargetDescription(handle, format, size, sampled, historyFrames));
return handle;
}
public CameraHandle Camera(string name)
{
var handle = new CameraHandle(name);
cameras.Add(new AquariumCameraDescription(handle));
return handle;
}
public PassHandle Pass(string name, AquariumPassKind kind)
{
var handle = new PassHandle(name);
passes.Add(new AquariumPassDescription(handle, kind));
return handle;
}
public AquariumRenderGraphDescription DebugView(string name, RenderTargetHandle target)
{
debugViews.Add(new AquariumDebugViewDescription(name, target));
return this;
}
}
public readonly record struct RenderTargetHandle(string Name);
public readonly record struct DepthTargetHandle(string Name);
public readonly record struct CameraHandle(string Name);
public readonly record struct ShaderHandle(string Name);
public readonly record struct BufferHandle<T>(string Name) where T : unmanaged;
public readonly record struct TextureHandle(string Name);
public readonly record struct PassHandle(string Name);
public readonly record struct AquariumRenderTargetDescription(
RenderTargetHandle Handle,
RenderFormat Format,
AquariumTargetSize Size,
bool Sampled,
int HistoryFrames);
public readonly record struct AquariumCameraDescription(CameraHandle Handle);
public readonly record struct AquariumPassDescription(PassHandle Handle, AquariumPassKind Kind);
public readonly record struct AquariumDebugViewDescription(string Name, RenderTargetHandle Target);
public readonly record struct AquariumTargetSize(AquariumTargetSizeKind Kind, int Width, int Height, float Scale)
{
public static AquariumTargetSize Fixed(int width, int height) => new(AquariumTargetSizeKind.Fixed, width, height, 1.0f);
public static AquariumTargetSize MatchWindow(float scale = 1.0f) => new(AquariumTargetSizeKind.MatchWindow, 0, 0, scale);
}
public enum AquariumTargetSizeKind
{
Fixed,
MatchWindow,
}
public enum RenderFormat
{
Unknown,
R16Float,
Rgba16Float,
Bgra8Unorm,
Depth32Float,
}
public enum AquariumPassKind
{
Fullscreen,
Proxy,
Instanced,
Compute,
Copy,
Present,
Overlay,
}
[StructLayout(LayoutKind.Sequential)]
public readonly record struct AquariumSdfLight(
Vector4 CenterRadius,
Vector4 RadianceFieldId);
[StructLayout(LayoutKind.Sequential)]
public readonly record struct AquariumSdfObject(
Vector4 CenterRadius,
Vector4 PreviousCenterPad,
Vector4 State);
public readonly record struct AquariumHeightFieldBrush(
Vector2 Center,
float Radius,
float Power,
float Amplitude,
float WaveAmplitude,
float WaveFrequency,
float WaveSpeed,
float WaveSinePower,
float RadiusY = 0.0f,
float RotationRadians = 0.0f,
float EnvelopeFalloff = 0.0f,
float DomainFace = -1.0f,
float DomainLevel = 0.0f,
float DomainX = 0.0f,
float DomainY = 0.0f);
public readonly record struct AquariumTemporalSdfGaussian(
string StableKey,
Vector3 Center,
Vector3 PreviousCenter,
Vector3 Velocity,
Vector3 Radii,
Quaternion Orientation,
Vector4 ColorOpacity,
float Confidence,
float HistoryWeight,
float LastObservedTimeSeconds,
float Falloff,
float ShapePower,
int FieldId);
public readonly record struct AquariumGpuFusionSeed(
string StableKey,
Vector3 Center,
Vector3 PreviousCenter,
Vector3 Velocity,
Vector3 Radii,
Vector4 ColorOpacity,
float Confidence,
float HistoryWeight,
float Falloff,
float ShapePower,
int FieldId);
public enum AquariumGpuSensorKind
{
Unknown,
RgbCamera,
HighRateTracker,
LeapPackedMap,
}
public enum AquariumGpuSensorPixelFormat
{
Unknown,
Bgra8Unorm,
Rgba8Unorm,
R8Unorm,
R16Unorm,
R16Float,
Rg8Unorm,
LeapPackedMap,
}
public readonly record struct AquariumExternalGpuTexture(
TextureHandle Handle,
IntPtr SharedHandle,
int Width,
int Height,
AquariumGpuSensorPixelFormat PixelFormat,
long TimestampNs,
int PlaneIndex = 0,
string SharedHandleName = "");
public readonly record struct AquariumGpuSensorCamera(
string SensorId,
AquariumGpuSensorKind Kind,
Matrix4x4 WorldFromSensor,
Matrix4x4 SensorFromWorld,
Vector4 Intrinsics,
Vector4 Distortion01,
Vector4 Distortion23,
int Width,
int Height,
int FirstTextureIndex,
int TextureCount,
long TimestampNs);
public sealed class AquariumGpuSensorFrame
{
public static AquariumGpuSensorFrame Empty { get; } = new();
public IReadOnlyList<AquariumGpuSensorCamera> Cameras { get; init; } = [];
public IReadOnlyList<AquariumExternalGpuTexture> ExternalTextures { get; init; } = [];
public float AccumulationWindowSeconds { get; init; }
public float PresentationDelaySeconds { get; init; }
public bool HasInput => Cameras.Count > 0 || ExternalTextures.Count > 0;
}
public enum AquariumAcousticConstraintKind
{
Unknown,
UltrasonicReflector,
VoiceTdoa,
SpeakerProbe,
ClapImpact,
}
public readonly record struct AquariumAcousticConstraint(
string StableKey,
AquariumAcousticConstraintKind Kind,
Vector3 Position,
Vector3 Velocity,
float RadiusMeters,
float Confidence,
long TimestampNs);
public sealed class AquariumAcousticFieldFrame
{
public static AquariumAcousticFieldFrame Empty { get; } = new();
public IReadOnlyList<AquariumAcousticConstraint> Constraints { get; init; } = [];
public long TimingOracleNs { get; init; }
public float TimingConfidence { get; init; }
public float TimingUncertaintyMicroseconds { get; init; }
public float AccumulationWindowSeconds { get; init; }
public float PresentationDelaySeconds { get; init; }
public bool HasInput => Constraints.Count > 0;
}
public readonly record struct AquariumClapCalibrationEvent(
string StableKey,
Vector3 Position,
long AcousticOracleNs,
long VisualObservedNs,
float TimingUncertaintyMicroseconds,
float VisualConfidence,
float AcousticConfidence);
public sealed class AquariumCalibrationEventFrame
{
public static AquariumCalibrationEventFrame Empty { get; } = new();
public IReadOnlyList<AquariumClapCalibrationEvent> ClapEvents { get; init; } = [];
public bool HasInput => ClapEvents.Count > 0;
}
public sealed class AquariumGpuFusionField
{
public static AquariumGpuFusionField Empty { get; } = new();
public IReadOnlyList<AquariumGpuFusionSeed> Seeds { get; init; } = [];
public AquariumGpuFusionPointBuffer PointBuffer { get; init; }
public float AccumulationWindowSeconds { get; init; }
public float PresentationDelaySeconds { get; init; }
public bool HasInput => Seeds.Count > 0 || PointBuffer.HasInput;
}
public readonly record struct AquariumGpuFusionPointBuffer(
IntPtr Buffer,
int Count,
int StrideBytes)
{
// Caller owns the memory and must keep it stable until the render frame has uploaded it.
public bool HasInput => Buffer != IntPtr.Zero && Count > 0 && StrideBytes > 0;
}
public sealed class AquariumTemporalGaussianField
{
public static AquariumTemporalGaussianField Empty { get; } = new();
public IReadOnlyList<AquariumTemporalSdfGaussian> Gaussians { get; init; } = [];
public float AccumulationWindowSeconds { get; init; }
public float PresentationDelaySeconds { get; init; }
}
public sealed class AquariumFractalReservoirField
{
public static AquariumFractalReservoirField Empty { get; } = new();
public int SplatCount { get; init; }
public int Depth { get; init; } = 8;
public uint Seed { get; init; } = 0xA17EA11u;
public int CandidatesPerReservoirUpdate { get; init; } = 2;
public int SplatUpdatesPerFrame { get; init; }
public int ReservoirUpdatesPerPass { get; init; }
public IReadOnlyList<AquariumPackedFractalIfsTransform> ProgramTransforms { get; init; } = [];
public Vector4 WorldCenterRadius { get; init; }
public Vector4 PriorityFocus { get; init; }
public bool HasInput =>
SplatCount > 0 &&
Depth > 0 &&
CandidatesPerReservoirUpdate > 0 &&
SplatUpdatesPerFrame > 0 &&
SplatUpdatesPerFrame <= SplatCount &&
ReservoirUpdatesPerPass > 0 &&
ReservoirUpdatesPerPass <= SplatCount;
}
public sealed class AquariumSceneState
{
public static AquariumSceneState Empty { get; } = new();
public bool TraceHeightFieldSurface { get; init; } = true;
public bool UseStarfieldBackground { get; init; }
public IReadOnlyList<AquariumHeightFieldBrush> HeightFieldBrushes { get; init; } = [];
public IReadOnlyList<AquariumSdfObject> SdfObjects { get; init; } = [];
public IReadOnlyList<AquariumSdfLight> SdfLights { get; init; } = [];
public AquariumTemporalGaussianField TemporalGaussianField { get; init; } = AquariumTemporalGaussianField.Empty;
public AquariumFractalReservoirField FractalReservoirField { get; init; } = AquariumFractalReservoirField.Empty;
public AquariumGpuSensorFrame GpuSensorFrame { get; init; } = AquariumGpuSensorFrame.Empty;
public AquariumAcousticFieldFrame AcousticFieldFrame { get; init; } = AquariumAcousticFieldFrame.Empty;
public AquariumCalibrationEventFrame CalibrationEventFrame { get; init; } = AquariumCalibrationEventFrame.Empty;
public AquariumGpuFusionField GpuFusionField { get; init; } = AquariumGpuFusionField.Empty;
}

View file

@ -1,147 +0,0 @@
namespace Aquarium.Engine.Ui;
public sealed class AquariumUiDocument
{
private readonly List<AquariumUiPanel> panels = [];
private readonly List<AquariumConsoleCommand> commands = [];
public static AquariumUiDocument Empty { get; } = new();
public IReadOnlyList<AquariumUiPanel> Panels => panels;
public IReadOnlyList<AquariumConsoleCommand> Commands => commands;
public AquariumUiDocument Panel(string title, Action<AquariumUiPanelBuilder> compose)
{
return Panel(title, 18.0f, 82.0f, 360.0f, compose);
}
public AquariumUiDocument Panel(string title, float left, float top, float width, Action<AquariumUiPanelBuilder> compose)
{
return Panel(title, left, top, width, fadeWhenMouseDistant: false, compose);
}
public AquariumUiDocument Panel(string title, float left, float top, float width, bool fadeWhenMouseDistant, Action<AquariumUiPanelBuilder> compose)
{
var controls = new List<AquariumUiControl>();
compose(new AquariumUiPanelBuilder(controls));
panels.Add(new AquariumUiPanel(title, left, top, width, controls, fadeWhenMouseDistant));
return this;
}
public AquariumUiDocument Command(string name, Func<IReadOnlyList<string>, string> execute, string description = "")
{
commands.Add(new AquariumConsoleCommand(name, execute, description));
return this;
}
}
public sealed record AquariumUiPanel(
string Title,
float Left,
float Top,
float Width,
IReadOnlyList<AquariumUiControl> Controls,
bool FadeWhenMouseDistant = false);
public sealed class AquariumUiPanelBuilder(List<AquariumUiControl> controls)
{
public AquariumUiPanelBuilder Section(string title, Func<bool>? isVisible = null)
{
controls.Add(new AquariumUiSection(title, isVisible));
return this;
}
public AquariumUiPanelBuilder Button(string label, Action action, string? tooltip = null, Func<bool>? isVisible = null)
{
controls.Add(new AquariumUiButton(label, action, tooltip, isVisible));
return this;
}
public AquariumUiPanelBuilder Toggle(string label, Func<bool> read, Action<bool> write, string? tooltip = null, Func<bool>? isVisible = null)
{
controls.Add(new AquariumUiToggle(label, read, write, tooltip, isVisible));
return this;
}
public AquariumUiPanelBuilder TreeItem(string label, int depth, bool canExpand, Func<bool> isExpanded, Action<bool> setExpanded, Func<bool> isSelected, Action select, string? detail = null, string? tooltip = null, Func<bool>? isVisible = null)
{
controls.Add(new AquariumUiTreeItem(label, Math.Max(0, depth), canExpand, isExpanded, setExpanded, isSelected, select, detail, tooltip, isVisible));
return this;
}
public AquariumUiPanelBuilder Slider(string label, Func<float> read, Action<float> write, float min, float max, string format = "0.###", string? tooltip = null, Func<bool>? isVisible = null)
{
controls.Add(new AquariumUiFloatSlider(label, read, write, min, max, format, tooltip, isVisible));
return this;
}
public AquariumUiPanelBuilder Slider(string label, Func<int> read, Action<int> write, int min, int max, string? tooltip = null, Func<bool>? isVisible = null)
{
controls.Add(new AquariumUiIntSlider(label, read, write, min, max, tooltip, isVisible));
return this;
}
public AquariumUiPanelBuilder Options(string label, Func<int> read, Action<int> write, IReadOnlyList<AquariumUiOption> options, string? tooltip = null, Func<bool>? isVisible = null)
{
controls.Add(new AquariumUiOptions(label, read, write, options, tooltip, isVisible));
return this;
}
public AquariumUiPanelBuilder Text(string label, Func<string> read, Action<string> write, string? tooltip = null, Func<bool>? isVisible = null)
{
controls.Add(new AquariumUiText(label, read, write, tooltip, isVisible));
return this;
}
public AquariumUiPanelBuilder TextBox(string label, Func<string> read, Action<string> write, int lines = 3, bool acceptsReturn = true, Action? submit = null, bool monospace = false, bool alignBottom = false, string? tooltip = null, Func<bool>? isVisible = null)
{
controls.Add(new AquariumUiTextBox(label, read, write, Math.Max(1, lines), acceptsReturn, submit, monospace, alignBottom, tooltip, isVisible));
return this;
}
public AquariumUiPanelBuilder Readout(string label, Func<string> read, string? tooltip = null, Func<bool>? isVisible = null)
{
controls.Add(new AquariumUiReadout(label, read, tooltip, isVisible));
return this;
}
}
public readonly record struct AquariumUiOption(int Value, string Label);
public sealed record AquariumConsoleCommand(string Name, Func<IReadOnlyList<string>, string> Execute, string Description = "");
public abstract record AquariumUiControl(string Label, string? Tooltip, Func<bool>? IsVisible)
{
public bool Visible => IsVisible?.Invoke() ?? true;
}
public sealed record AquariumUiSection(string Label, Func<bool>? IsVisible = null)
: AquariumUiControl(Label, null, IsVisible);
public sealed record AquariumUiButton(string Label, Action Action, string? Tooltip = null, Func<bool>? IsVisible = null)
: AquariumUiControl(Label, Tooltip, IsVisible);
public sealed record AquariumUiToggle(string Label, Func<bool> Read, Action<bool> Write, string? Tooltip = null, Func<bool>? IsVisible = null)
: AquariumUiControl(Label, Tooltip, IsVisible);
public sealed record AquariumUiTreeItem(string Label, int Depth, bool CanExpand, Func<bool> IsExpanded, Action<bool> SetExpanded, Func<bool> IsSelected, Action Select, string? Detail = null, string? Tooltip = null, Func<bool>? IsVisible = null)
: AquariumUiControl(Label, Tooltip, IsVisible);
public sealed record AquariumUiFloatSlider(string Label, Func<float> Read, Action<float> Write, float Min, float Max, string Format = "0.###", string? Tooltip = null, Func<bool>? IsVisible = null)
: AquariumUiControl(Label, Tooltip, IsVisible);
public sealed record AquariumUiIntSlider(string Label, Func<int> Read, Action<int> Write, int Min, int Max, string? Tooltip = null, Func<bool>? IsVisible = null)
: AquariumUiControl(Label, Tooltip, IsVisible);
public sealed record AquariumUiOptions(string Label, Func<int> Read, Action<int> Write, IReadOnlyList<AquariumUiOption> Options, string? Tooltip = null, Func<bool>? IsVisible = null)
: AquariumUiControl(Label, Tooltip, IsVisible);
public sealed record AquariumUiText(string Label, Func<string> Read, Action<string> Write, string? Tooltip = null, Func<bool>? IsVisible = null)
: AquariumUiControl(Label, Tooltip, IsVisible);
public sealed record AquariumUiTextBox(string Label, Func<string> Read, Action<string> Write, int Lines = 3, bool AcceptsReturn = true, Action? Submit = null, bool Monospace = false, bool AlignBottom = false, string? Tooltip = null, Func<bool>? IsVisible = null)
: AquariumUiControl(Label, Tooltip, IsVisible);
public sealed record AquariumUiReadout(string Label, Func<string> Read, string? Tooltip = null, Func<bool>? IsVisible = null)
: AquariumUiControl(Label, Tooltip, IsVisible);

View file

@ -1,15 +0,0 @@
using System.Numerics;
namespace Aquarium.Engine;
public readonly record struct ViewFrame(Vector2 Center, float Radius)
{
private const float MinimumRadius = 20.0f;
private const float MaximumRadius = 80.0f;
private const float CameraDistanceScale = 2.0f;
public static ViewFrame FromCamera(Vector2 target, float distance)
{
return new ViewFrame(target, Math.Clamp(distance * CameraDistanceScale, MinimumRadius, MaximumRadius));
}
}

View file

@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Aquarium.Engine.Contracts\Aquarium.Engine.Contracts.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>

View file

@ -1,120 +0,0 @@
using System.Numerics;
namespace Aquarium.Engine.Fractal.Brushes;
public readonly record struct FractalBrushEnvelope2D
{
private const double MinRadius = 1.0e-6;
private const double MinFalloff = 1.0e-6;
private const double MinShapePower = 1.0e-6;
public FractalBrushEnvelope2D(
Vector2 center,
double radiusX,
double radiusY,
double rotationRadians,
double falloff,
double shapePower = 1.0)
{
if (!double.IsFinite(radiusX) || radiusX <= MinRadius)
{
throw new ArgumentOutOfRangeException(nameof(radiusX), radiusX, "Envelope radius X must be positive and finite.");
}
if (!double.IsFinite(radiusY) || radiusY <= MinRadius)
{
throw new ArgumentOutOfRangeException(nameof(radiusY), radiusY, "Envelope radius Y must be positive and finite.");
}
if (!double.IsFinite(rotationRadians))
{
throw new ArgumentOutOfRangeException(nameof(rotationRadians), rotationRadians, "Envelope rotation must be finite.");
}
if (!double.IsFinite(falloff) || falloff <= MinFalloff)
{
throw new ArgumentOutOfRangeException(nameof(falloff), falloff, "Envelope falloff must be positive and finite.");
}
if (!double.IsFinite(shapePower) || shapePower <= MinShapePower)
{
throw new ArgumentOutOfRangeException(nameof(shapePower), shapePower, "Envelope shape power must be positive and finite.");
}
Center = center;
RadiusX = radiusX;
RadiusY = radiusY;
RotationRadians = rotationRadians;
Falloff = falloff;
ShapePower = shapePower;
}
public Vector2 Center { get; }
public double RadiusX { get; }
public double RadiusY { get; }
public double RotationRadians { get; }
public double Falloff { get; }
public double ShapePower { get; }
public double Evaluate(Vector2 point)
{
var normalizedRadiusSquared = NormalizedRadiusSquared(point);
if (normalizedRadiusSquared >= 1.0)
{
return 0.0;
}
var edgeValue = Math.Exp(-Falloff);
var gaussianValue = Math.Exp(-Falloff * normalizedRadiusSquared);
var compactValue = (gaussianValue - edgeValue) / (1.0 - edgeValue);
return Math.Pow(Math.Clamp(compactValue, 0.0, 1.0), ShapePower);
}
public bool Contains(Vector2 point)
{
return NormalizedRadiusSquared(point) <= 1.0;
}
public double SignedSupportDistanceEstimate(Vector2 point)
{
var normalizedRadius = Math.Sqrt(NormalizedRadiusSquared(point));
return (normalizedRadius - 1.0) * Math.Min(RadiusX, RadiusY);
}
public FractalBrushEnvelopeBounds2D AxisAlignedBounds()
{
var cos = Math.Cos(RotationRadians);
var sin = Math.Sin(RotationRadians);
var extentX = Math.Sqrt((RadiusX * cos * RadiusX * cos) + (RadiusY * sin * RadiusY * sin));
var extentY = Math.Sqrt((RadiusX * sin * RadiusX * sin) + (RadiusY * cos * RadiusY * cos));
var extents = new Vector2((float)extentX, (float)extentY);
return new FractalBrushEnvelopeBounds2D(Center - extents, Center + extents);
}
public FractalBrushEnvelope2DPacket ToPacket(double amplitude = 1.0)
{
var cos = Math.Cos(RotationRadians);
var sin = Math.Sin(RotationRadians);
return new FractalBrushEnvelope2DPacket(
new Vector4(Center.X, Center.Y, (float)RadiusX, (float)RadiusY),
new Vector4((float)cos, (float)sin, (float)Falloff, (float)ShapePower),
new Vector4((float)amplitude, 0.0f, 0.0f, 0.0f));
}
private double NormalizedRadiusSquared(Vector2 point)
{
var delta = point - Center;
var cos = Math.Cos(RotationRadians);
var sin = Math.Sin(RotationRadians);
var localX = delta.X * cos + delta.Y * sin;
var localY = -delta.X * sin + delta.Y * cos;
var normalizedX = localX / RadiusX;
var normalizedY = localY / RadiusY;
return (normalizedX * normalizedX) + (normalizedY * normalizedY);
}
}

View file

@ -1,11 +0,0 @@
using System.Numerics;
namespace Aquarium.Engine.Fractal.Brushes;
public readonly record struct FractalBrushEnvelopeBounds2D(Vector2 Min, Vector2 Max)
{
public bool Contains(Vector2 point)
{
return point.X >= Min.X && point.X <= Max.X && point.Y >= Min.Y && point.Y <= Max.Y;
}
}

View file

@ -1,8 +0,0 @@
using System.Numerics;
namespace Aquarium.Engine.Fractal.Brushes;
public readonly record struct FractalBrushEnvelope2DPacket(
Vector4 CenterRadii,
Vector4 RotationFalloffShape,
Vector4 Payload);

View file

@ -1,107 +0,0 @@
using Aquarium.Engine.Render;
using Aquarium.Engine.Fractal.Grammar;
namespace Aquarium.Engine.Fractal.Brushes;
public static class FractalHeightBrushCompiler
{
public static AquariumHeightFieldBrush Compile(AquariumBrushClaim claim)
{
return Compile(claim, null);
}
public static AquariumHeightFieldBrush Compile(AquariumBrushClaim claim, AquariumFractalDomain? domain)
{
if (claim.PayloadKind != AquariumFractalPayloadKind.Height)
{
throw new ArgumentException($"Claim {claim.Key} carries {claim.PayloadKind}; only height claims can compile to height-field brushes.", nameof(claim));
}
if (claim.Radii.X <= 0.0f || claim.Radii.Y <= 0.0f)
{
throw new ArgumentOutOfRangeException(nameof(claim), claim.Radii, "Height claim radii must be positive.");
}
return new AquariumHeightFieldBrush(
claim.Center,
claim.Radii.X,
claim.ShapePower,
claim.Amplitude,
claim.WaveAmplitude,
claim.WaveFrequency,
claim.WaveSpeed,
claim.WaveSinePower,
RadiusY: claim.Radii.Y,
RotationRadians: claim.RotationRadians,
EnvelopeFalloff: claim.Falloff,
DomainFace: domain is { Kind: AquariumFractalDomainKind.CubeSphereTile } ? domain.Value.Parameters0.X : -1.0f,
DomainLevel: domain is { Kind: AquariumFractalDomainKind.CubeSphereTile } ? domain.Value.Parameters0.Y : 0.0f,
DomainX: domain is { Kind: AquariumFractalDomainKind.CubeSphereTile } ? domain.Value.Parameters0.Z : 0.0f,
DomainY: domain is { Kind: AquariumFractalDomainKind.CubeSphereTile } ? domain.Value.Parameters0.W : 0.0f);
}
public static AquariumHeightFieldBrush[] CompileMany(IReadOnlyList<AquariumBrushClaim> claims)
{
ArgumentNullException.ThrowIfNull(claims);
var brushes = new AquariumHeightFieldBrush[claims.Count];
for (var index = 0; index < claims.Count; index++)
{
brushes[index] = Compile(claims[index]);
}
return brushes;
}
public static AquariumHeightFieldBrush[] CompileTree(FractalOwnershipTree tree)
{
ArgumentNullException.ThrowIfNull(tree);
var domains = tree.Domains.ToDictionary(domain => domain.Key, domain => domain);
var brushes = new List<AquariumHeightFieldBrush>();
for (var index = 0; index < tree.Claims.Count; index++)
{
var claim = tree.Claims[index];
if (claim.PayloadKind != AquariumFractalPayloadKind.Height)
{
continue;
}
domains.TryGetValue(claim.DomainKey, out var domain);
brushes.Add(Compile(claim, domain));
}
return brushes.ToArray();
}
public static AquariumHeightFieldBrush[] CompileSelectedTree(FractalOwnershipTree tree, IReadOnlyList<AquariumSelectedCut> selectedCut)
{
ArgumentNullException.ThrowIfNull(tree);
ArgumentNullException.ThrowIfNull(selectedCut);
var selectedNodes = selectedCut.Select(cut => cut.NodeKey).ToHashSet();
var domains = tree.Domains.ToDictionary(domain => domain.Key, domain => domain);
var brushes = new List<AquariumHeightFieldBrush>();
foreach (var node in tree.Nodes)
{
if (!selectedNodes.Contains(node.Key))
{
continue;
}
for (var claimIndex = node.FirstClaimIndex; claimIndex < node.FirstClaimIndex + node.ClaimCount; claimIndex++)
{
var claim = tree.Claims[claimIndex];
if (claim.PayloadKind != AquariumFractalPayloadKind.Height)
{
continue;
}
domains.TryGetValue(claim.DomainKey, out var domain);
brushes.Add(Compile(claim, domain));
}
}
return brushes.ToArray();
}
}

View file

@ -1,11 +0,0 @@
namespace Aquarium.Engine.Fractal;
public enum CubeFace
{
PositiveX,
NegativeX,
PositiveY,
NegativeY,
PositiveZ,
NegativeZ,
}

View file

@ -1,43 +0,0 @@
using System.Numerics;
namespace Aquarium.Engine.Fractal;
public readonly record struct CubeFacePosition
{
public CubeFacePosition(CubeFace face, double u, double v)
{
if (!double.IsFinite(u) || u is < -1.0 or > 1.0)
{
throw new ArgumentOutOfRangeException(nameof(u), u, "Cube face U must be finite and in [-1, 1].");
}
if (!double.IsFinite(v) || v is < -1.0 or > 1.0)
{
throw new ArgumentOutOfRangeException(nameof(v), v, "Cube face V must be finite and in [-1, 1].");
}
Face = face;
U = u;
V = v;
}
public CubeFace Face { get; }
public double U { get; }
public double V { get; }
public Vector3 ToCubeVector()
{
return Face switch
{
CubeFace.PositiveX => new Vector3(1.0f, (float)V, (float)-U),
CubeFace.NegativeX => new Vector3(-1.0f, (float)V, (float)U),
CubeFace.PositiveY => new Vector3((float)U, 1.0f, (float)-V),
CubeFace.NegativeY => new Vector3((float)U, -1.0f, (float)V),
CubeFace.PositiveZ => new Vector3((float)U, (float)V, 1.0f),
CubeFace.NegativeZ => new Vector3((float)-U, (float)V, -1.0f),
_ => throw new ArgumentOutOfRangeException(nameof(Face), Face, "Unknown cube face."),
};
}
}

View file

@ -1,93 +0,0 @@
namespace Aquarium.Engine.Fractal;
public readonly record struct CubeTileKey
{
public const int MaxLevel = 30;
public CubeTileKey(CubeFace face, int level, int x, int y)
{
if (level is < 0 or > MaxLevel)
{
throw new ArgumentOutOfRangeException(nameof(level), level, $"Cube tile level must be in [0, {MaxLevel}].");
}
var axisTileCount = 1 << level;
if (x < 0 || x >= axisTileCount)
{
throw new ArgumentOutOfRangeException(nameof(x), x, $"Cube tile X must be in [0, {axisTileCount - 1}] for level {level}.");
}
if (y < 0 || y >= axisTileCount)
{
throw new ArgumentOutOfRangeException(nameof(y), y, $"Cube tile Y must be in [0, {axisTileCount - 1}] for level {level}.");
}
Face = face;
Level = level;
X = x;
Y = y;
}
public CubeFace Face { get; }
public int Level { get; }
public int X { get; }
public int Y { get; }
public int AxisTileCount => 1 << Level;
public CubeTileKey Parent()
{
if (Level == 0)
{
throw new InvalidOperationException("Root cube tile has no parent.");
}
return new CubeTileKey(Face, Level - 1, X >> 1, Y >> 1);
}
public CubeTileKey Child(int childX, int childY)
{
if (childX is < 0 or > 1)
{
throw new ArgumentOutOfRangeException(nameof(childX), childX, "Child X must be 0 or 1.");
}
if (childY is < 0 or > 1)
{
throw new ArgumentOutOfRangeException(nameof(childY), childY, "Child Y must be 0 or 1.");
}
if (Level == MaxLevel)
{
throw new InvalidOperationException($"Cube tile level {MaxLevel} cannot be subdivided.");
}
return new CubeTileKey(Face, Level + 1, (X << 1) | childX, (Y << 1) | childY);
}
public CubeFacePosition PositionAt(double localU, double localV)
{
if (!double.IsFinite(localU) || localU is < 0.0 or > 1.0)
{
throw new ArgumentOutOfRangeException(nameof(localU), localU, "Local U must be finite and in [0, 1].");
}
if (!double.IsFinite(localV) || localV is < 0.0 or > 1.0)
{
throw new ArgumentOutOfRangeException(nameof(localV), localV, "Local V must be finite and in [0, 1].");
}
var axisTileCount = AxisTileCount;
var faceU = -1.0 + (2.0 * (X + localU) / axisTileCount);
var faceV = -1.0 + (2.0 * (Y + localV) / axisTileCount);
return new CubeFacePosition(Face, faceU, faceV);
}
public override string ToString()
{
return $"{Face}/L{Level:D2}/{X}/{Y}";
}
}

View file

@ -1,63 +0,0 @@
using System.Globalization;
using System.Text;
using Aquarium.Engine.Fractal;
using Aquarium.Engine.Fractal.Grammar;
namespace Aquarium.Engine.Fractal.Debug;
public static class FractalDebugDump
{
public static string Build(
FractalOwnershipTree tree,
IReadOnlyList<AquariumFractalSummary> summaries,
IReadOnlyList<AquariumSelectedCut> selectedCut)
{
ArgumentNullException.ThrowIfNull(tree);
ArgumentNullException.ThrowIfNull(summaries);
ArgumentNullException.ThrowIfNull(selectedCut);
var builder = new StringBuilder();
foreach (var domain in tree.Domains)
{
builder.AppendLine($"domain {domain.Key} kind={domain.Kind} parent={domain.ParentKey}");
}
builder.AppendLine($"domain-path {string.Join(" -> ", tree.DomainGraph.GetPath(tree.Domain.Key).Select(domain => domain.Key.ToString()))}");
builder.AppendLine($"nodes {tree.Nodes.Count} claims {tree.Claims.Count} summaries {summaries.Count} cut {selectedCut.Count}");
foreach (var node in tree.Nodes)
{
builder.AppendLine($"node {node.Key} op={node.Operation} claims={node.ClaimCount} bounds={FormatBounds(node.BoundsMinMax)}");
}
foreach (var claim in tree.Claims)
{
builder.AppendLine(
string.Create(
CultureInfo.InvariantCulture,
$"claim {claim.Key} payload={claim.PayloadKind} center=({claim.Center.X:0.###},{claim.Center.Y:0.###}) radii=({claim.Radii.X:0.###},{claim.Radii.Y:0.###}) amp={claim.Amplitude:0.###} tags={claim.Tags}"));
}
foreach (var summary in summaries)
{
builder.AppendLine(
string.Create(
CultureInfo.InvariantCulture,
$"summary {summary.NodeKey} heightError={summary.MaxHeightError:0.###} cost={summary.EstimatedCost:0.###} descendants={summary.DescendantCount}"));
}
foreach (var cut in selectedCut)
{
builder.AppendLine(
string.Create(
CultureInfo.InvariantCulture,
$"cut {cut.NodeKey} score={cut.Score:0.###} fade={cut.Fade:0.###} summary={cut.UsesSummary} requestChildren={cut.RequestedChildren}"));
}
return builder.ToString();
}
private static string FormatBounds(System.Numerics.Vector4 bounds)
{
return string.Create(CultureInfo.InvariantCulture, $"({bounds.X:0.###},{bounds.Y:0.###})-({bounds.Z:0.###},{bounds.W:0.###})");
}
}

View file

@ -1,22 +0,0 @@
namespace Aquarium.Engine.Fractal;
public sealed class FractalXorShiftRandom(uint seed) : IFractalRandom
{
private uint state = seed == 0 ? 0xA341316Cu : seed;
public double NextDouble()
{
var value = NextUInt32();
return value / ((double)uint.MaxValue + 1.0);
}
private uint NextUInt32()
{
var value = state;
value ^= value << 13;
value ^= value >> 17;
value ^= value << 5;
state = value;
return value;
}
}

View file

@ -1,57 +0,0 @@
using System.Numerics;
namespace Aquarium.Engine.Fractal.Grammar;
public static class FractalAffineIfsChaosGame
{
public static Vector2[] Generate(
FractalAffineIfsDefinition definition,
int count,
int burnIn,
IFractalRandom random)
{
ArgumentNullException.ThrowIfNull(definition);
ArgumentNullException.ThrowIfNull(random);
ArgumentOutOfRangeException.ThrowIfNegative(count);
ArgumentOutOfRangeException.ThrowIfNegative(burnIn);
var points = new Vector2[count];
var point = definition.Start;
var total = definition.Transforms.Sum(transform => MathF.Max(transform.Weight, 0.0f));
if (total <= 0.0f)
{
throw new ArgumentException("Affine IFS transform weights must have positive total.", nameof(definition));
}
for (var step = 0; step < burnIn + count; step++)
{
point = SelectTransform(definition.Transforms, total, (float)random.NextDouble()).Apply(point);
if (step >= burnIn)
{
points[step - burnIn] = point;
}
}
return points;
}
private static FractalAffineIfsTransform2D SelectTransform(
IReadOnlyList<FractalAffineIfsTransform2D> transforms,
float total,
float unit)
{
var target = unit * total;
var cumulative = 0.0f;
for (var index = 0; index < transforms.Count; index++)
{
cumulative += MathF.Max(transforms[index].Weight, 0.0f);
if (target <= cumulative)
{
return transforms[index];
}
}
return transforms[^1];
}
}

View file

@ -1,59 +0,0 @@
using System.Numerics;
using Aquarium.Engine.Fractal;
namespace Aquarium.Engine.Fractal.Grammar;
public readonly record struct FractalAffineIfsTransform2D(
string Name,
Vector4 Matrix,
Vector2 Translation,
float Weight)
{
public Vector2 Apply(Vector2 point)
{
return new Vector2(
(Matrix.X * point.X) + (Matrix.Y * point.Y) + Translation.X,
(Matrix.Z * point.X) + (Matrix.W * point.Y) + Translation.Y);
}
}
public sealed class FractalAffineIfsDefinition
{
public FractalAffineIfsDefinition(
AquariumFractalKey key,
AquariumFractalKey domainKey,
string name,
Vector2 start,
int seed,
string tags,
IReadOnlyList<FractalAffineIfsTransform2D> transforms)
{
if (transforms.Count == 0)
{
throw new ArgumentException("Affine IFS definitions need at least one transform.", nameof(transforms));
}
Key = key;
DomainKey = domainKey;
Name = name;
Start = start;
Seed = seed;
Tags = tags;
Transforms = transforms;
}
public AquariumFractalKey Key { get; }
public AquariumFractalKey DomainKey { get; }
public string Name { get; }
public Vector2 Start { get; }
public int Seed { get; }
public string Tags { get; }
public IReadOnlyList<FractalAffineIfsTransform2D> Transforms { get; }
}

View file

@ -1,85 +0,0 @@
using Aquarium.Engine.Fractal;
namespace Aquarium.Engine.Fractal.Grammar;
public sealed class FractalDomainGraph
{
private readonly Dictionary<AquariumFractalKey, int> indices;
public FractalDomainGraph(IReadOnlyList<AquariumFractalDomain> domains)
{
ArgumentNullException.ThrowIfNull(domains);
if (domains.Count == 0)
{
throw new FormatException("Fractal domain graph must contain at least one domain.");
}
indices = new Dictionary<AquariumFractalKey, int>(domains.Count);
for (var index = 0; index < domains.Count; index++)
{
var domain = domains[index];
if (domain.Key.Value is null)
{
throw new FormatException($"Fractal domain at index {index} has no key.");
}
if (!indices.TryAdd(domain.Key, index))
{
throw new FormatException($"Duplicate fractal domain key `{domain.Key}`.");
}
}
Domains = domains.ToArray();
ValidateParents();
}
public IReadOnlyList<AquariumFractalDomain> Domains { get; }
public bool Contains(AquariumFractalKey key)
{
return key.Value is not null && indices.ContainsKey(key);
}
public AquariumFractalDomain GetRequired(AquariumFractalKey key)
{
if (key.Value is null || !indices.TryGetValue(key, out var index))
{
throw new FormatException($"Unknown fractal domain `{key}`.");
}
return Domains[index];
}
public IReadOnlyList<AquariumFractalDomain> GetPath(AquariumFractalKey leafKey)
{
var path = new List<AquariumFractalDomain>();
var seen = new HashSet<AquariumFractalKey>();
var current = GetRequired(leafKey);
while (true)
{
if (!seen.Add(current.Key))
{
throw new FormatException($"Fractal domain graph contains a parent cycle at `{current.Key}`.");
}
path.Add(current);
if (current.ParentKey.Value is null)
{
break;
}
current = GetRequired(current.ParentKey);
}
path.Reverse();
return path;
}
private void ValidateParents()
{
foreach (var domain in Domains)
{
_ = GetPath(domain.Key);
}
}
}

View file

@ -1,598 +0,0 @@
using System.Globalization;
using System.Numerics;
using Aquarium.Engine.Fractal;
namespace Aquarium.Engine.Fractal.Grammar;
public static class FractalDslCompiler
{
public static FractalOwnershipTree Compile(string source)
{
ArgumentNullException.ThrowIfNull(source);
var lines = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
AquariumFractalDomain? domain = null;
AquariumFractalKey rootKey = default;
NodeDraft? currentNode = null;
var domains = new List<AquariumFractalDomain>();
var claims = new List<AquariumBrushClaim>();
var nodes = new List<NodeDraft>();
var affineIfsDrafts = new List<AffineIfsDraft>();
AffineIfsDraft? currentAffineIfs = null;
for (var lineIndex = 0; lineIndex < lines.Length; lineIndex++)
{
var line = StripComment(lines[lineIndex]).Trim();
if (line.Length == 0)
{
continue;
}
var tokens = line.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
switch (tokens[0])
{
case "domain":
EnsureTokenCount(tokens, 4, lineIndex);
domains.Add(ParseDomain(tokens, lineIndex));
break;
case "tile":
EnsureTokenCount(tokens, 6, lineIndex);
var tile = new CubeTileKey(ParseFace(tokens[1], lineIndex), ParseInt(tokens[2], lineIndex), ParseInt(tokens[3], lineIndex), ParseInt(tokens[4], lineIndex));
var domainKey = FractalStableKeyBuilder.ForCubeTile(tile, tokens[5]);
var parentKey = tokens.Length >= 7 ? new AquariumFractalKey(tokens[6]) : default;
rootKey = FractalStableKeyBuilder.Child(domainKey, "root");
domain = new AquariumFractalDomain(
domainKey,
AquariumFractalDomainKind.CubeSphereTile,
parentKey,
new Vector4((float)tile.Face, tile.Level, tile.X, tile.Y),
Vector4.Zero);
domains.Add(domain.Value);
rootKey = FractalStableKeyBuilder.Child(domainKey, "root");
currentNode = new NodeDraft(rootKey, domainKey, claims.Count);
nodes.Add(currentNode);
currentAffineIfs = null;
break;
case "affineifs":
EnsureDomain(domain, lineIndex);
EnsureNode(currentNode, lineIndex);
EnsureTokenCount(tokens, 6, lineIndex);
currentAffineIfs = new AffineIfsDraft(
FractalStableKeyBuilder.Child(currentNode!.Key, $"affineifs/{tokens[1]}"),
domain!.Value.Key,
tokens[1],
new Vector2(ParseFloat(tokens[2], lineIndex), ParseFloat(tokens[3], lineIndex)),
ParseInt(tokens[4], lineIndex),
tokens[5]);
affineIfsDrafts.Add(currentAffineIfs);
break;
case "affinemap":
EnsureAffineIfs(currentAffineIfs, lineIndex);
EnsureTokenCount(tokens, 9, lineIndex);
currentAffineIfs!.Transforms.Add(new FractalAffineIfsTransform2D(
tokens[1],
new Vector4(
ParseFloat(tokens[2], lineIndex),
ParseFloat(tokens[3], lineIndex),
ParseFloat(tokens[5], lineIndex),
ParseFloat(tokens[6], lineIndex)),
new Vector2(ParseFloat(tokens[4], lineIndex), ParseFloat(tokens[7], lineIndex)),
ParsePositiveFloat(tokens[8], lineIndex)));
break;
case "height":
case "density":
case "extinction":
EnsureDomain(domain, lineIndex);
EnsureNode(currentNode, lineIndex);
EnsureTokenCount(tokens, 12, lineIndex);
var fieldDomain = domain!.Value;
claims.Add(ParseFieldClaim(tokens, fieldDomain.Key, currentNode!.Key, lineIndex, claims.Count - currentNode.FirstClaimIndex));
currentNode.ClaimCount++;
break;
case "ripple":
EnsureDomain(domain, lineIndex);
EnsureNode(currentNode, lineIndex);
EnsureTokenCount(tokens, 16, lineIndex);
var rippleDomain = domain!.Value;
claims.Add(ParseRippleClaim(tokens, rippleDomain.Key, currentNode!.Key, lineIndex, claims.Count - currentNode.FirstClaimIndex));
currentNode.ClaimCount++;
break;
case "ifs":
EnsureDomain(domain, lineIndex);
EnsureNode(currentNode, lineIndex);
EnsureTokenCount(tokens, 15, lineIndex);
var ifsDomain = domain!.Value;
var beforeIfs = claims.Count;
AddIfsClaims(tokens, ifsDomain.Key, currentNode!.Key, lineIndex, claims);
currentNode.ClaimCount += claims.Count - beforeIfs;
break;
case "flame":
EnsureDomain(domain, lineIndex);
EnsureNode(currentNode, lineIndex);
EnsureTokenCount(tokens, 15, lineIndex);
var flameDomain = domain!.Value;
var beforeFlame = claims.Count;
AddFlameClaims(tokens, flameDomain.Key, currentNode!.Key, lineIndex, claims);
currentNode.ClaimCount += claims.Count - beforeFlame;
break;
default:
throw new FormatException($"Unknown fractal DSL command `{tokens[0]}` at line {lineIndex + 1}.");
}
}
if (domain is null)
{
throw new FormatException("Fractal DSL must declare a `tile <face> <level> <x> <y> <path>` line before claims.");
}
return new FractalOwnershipTree(domain.Value, domains, BuildNodes(nodes, claims), claims, BuildAffineIfsDefinitions(affineIfsDrafts));
}
private static AquariumFractalDomain ParseDomain(string[] tokens, int lineIndex)
{
if (!Enum.TryParse<AquariumFractalDomainKind>(tokens[1], ignoreCase: true, out var kind))
{
throw new FormatException($"Invalid domain kind `{tokens[1]}` at line {lineIndex + 1}.");
}
var parent = tokens[3] == "-" ? default : new AquariumFractalKey(tokens[3]);
var parameters = new float[8];
for (var index = 4; index < tokens.Length && index < 12; index++)
{
parameters[index - 4] = ParseFloat(tokens[index], lineIndex);
}
return new AquariumFractalDomain(
new AquariumFractalKey(tokens[2]),
kind,
parent,
new Vector4(parameters[0], parameters[1], parameters[2], parameters[3]),
new Vector4(parameters[4], parameters[5], parameters[6], parameters[7]));
}
private static AquariumBrushClaim ParseFieldClaim(string[] tokens, AquariumFractalKey domainKey, AquariumFractalKey nodeKey, int lineIndex, int claimIndex)
{
var name = tokens[1];
return FieldClaim(
ParsePayloadKind(tokens[0], lineIndex),
domainKey,
nodeKey,
name,
claimIndex,
new Vector2(ParseFloat(tokens[2], lineIndex), ParseFloat(tokens[3], lineIndex)),
new Vector2(ParsePositiveFloat(tokens[4], lineIndex), ParsePositiveFloat(tokens[5], lineIndex)),
ParseFloat(tokens[6], lineIndex),
ParsePositiveFloat(tokens[7], lineIndex),
ParsePositiveFloat(tokens[8], lineIndex),
ParseFloat(tokens[9], lineIndex),
ParseInt(tokens[10], lineIndex),
tokens[11]);
}
private static AquariumBrushClaim ParseRippleClaim(string[] tokens, AquariumFractalKey domainKey, AquariumFractalKey nodeKey, int lineIndex, int claimIndex)
{
var name = tokens[1];
return FieldClaim(
AquariumFractalPayloadKind.Height,
domainKey,
nodeKey,
name,
claimIndex,
new Vector2(ParseFloat(tokens[2], lineIndex), ParseFloat(tokens[3], lineIndex)),
new Vector2(ParsePositiveFloat(tokens[4], lineIndex), ParsePositiveFloat(tokens[5], lineIndex)),
ParseFloat(tokens[6], lineIndex),
ParsePositiveFloat(tokens[7], lineIndex),
ParsePositiveFloat(tokens[8], lineIndex),
ParseFloat(tokens[9], lineIndex),
ParseInt(tokens[14], lineIndex),
tokens[15],
ParseFloat(tokens[10], lineIndex),
ParsePositiveFloat(tokens[11], lineIndex),
ParseFloat(tokens[12], lineIndex),
ParsePositiveFloat(tokens[13], lineIndex));
}
private static AquariumFractalPayloadKind ParsePayloadKind(string token, int lineIndex)
{
return token switch
{
"height" => AquariumFractalPayloadKind.Height,
"density" => AquariumFractalPayloadKind.Density,
"extinction" => AquariumFractalPayloadKind.Extinction,
_ => throw new FormatException($"Invalid field payload `{token}` at line {lineIndex + 1}."),
};
}
private static void AddIfsClaims(string[] tokens, AquariumFractalKey domainKey, AquariumFractalKey nodeKey, int lineIndex, List<AquariumBrushClaim> claims)
{
var name = tokens[1];
var levels = ParsePositiveInt(tokens[2], lineIndex);
var branches = ParsePositiveInt(tokens[3], lineIndex);
var center = new Vector2(ParseFloat(tokens[4], lineIndex), ParseFloat(tokens[5], lineIndex));
var radii = new Vector2(ParsePositiveFloat(tokens[6], lineIndex), ParsePositiveFloat(tokens[7], lineIndex));
var childScale = ParsePositiveFloat(tokens[8], lineIndex);
var spread = ParsePositiveFloat(tokens[9], lineIndex);
var rotationStep = ParseFloat(tokens[10], lineIndex);
var falloff = ParsePositiveFloat(tokens[11], lineIndex);
var shapePower = ParsePositiveFloat(tokens[12], lineIndex);
var amplitude = ParseFloat(tokens[13], lineIndex);
var seed = ParseInt(tokens[14], lineIndex);
var tags = tokens.Length >= 16 ? tokens[15] : name;
if (childScale >= 1.0f)
{
throw new FormatException($"IFS child scale must be below 1 at line {lineIndex + 1}.");
}
EmitIfsLevel(domainKey, nodeKey, name, center, radii, levels, branches, childScale, spread, rotationStep, falloff, shapePower, amplitude, seed, tags, claims);
}
private static void AddFlameClaims(string[] tokens, AquariumFractalKey domainKey, AquariumFractalKey nodeKey, int lineIndex, List<AquariumBrushClaim> claims)
{
var name = tokens[1];
var levels = ParsePositiveInt(tokens[2], lineIndex);
var branches = ParsePositiveInt(tokens[3], lineIndex);
var center = new Vector2(ParseFloat(tokens[4], lineIndex), ParseFloat(tokens[5], lineIndex));
var radii = new Vector2(ParsePositiveFloat(tokens[6], lineIndex), ParsePositiveFloat(tokens[7], lineIndex));
var childScale = ParsePositiveFloat(tokens[8], lineIndex);
var curl = ParseFloat(tokens[9], lineIndex);
var spread = ParsePositiveFloat(tokens[10], lineIndex);
var falloff = ParsePositiveFloat(tokens[11], lineIndex);
var shapePower = ParsePositiveFloat(tokens[12], lineIndex);
var amplitude = ParseFloat(tokens[13], lineIndex);
var seed = ParseInt(tokens[14], lineIndex);
var tags = tokens.Length >= 16 ? tokens[15] : name;
if (childScale >= 1.0f)
{
throw new FormatException($"Flame child scale must be below 1 at line {lineIndex + 1}.");
}
EmitFlameLevel(domainKey, nodeKey, name, center, radii, 0.0f, levels, branches, childScale, curl, spread, falloff, shapePower, amplitude, seed, tags, claims);
}
private static void EmitIfsLevel(
AquariumFractalKey domainKey,
AquariumFractalKey nodeKey,
string name,
Vector2 center,
Vector2 radii,
int levelsRemaining,
int branches,
float childScale,
float spread,
float rotationStep,
float falloff,
float shapePower,
float amplitude,
int seed,
string tags,
List<AquariumBrushClaim> claims)
{
var level = claims.Count;
claims.Add(FieldClaim(AquariumFractalPayloadKind.Height, domainKey, nodeKey, $"{name}/{level}", claims.Count, center, radii, rotationStep * level, falloff, shapePower, amplitude, seed + level, tags));
if (levelsRemaining <= 1)
{
return;
}
var nextRadii = radii * childScale;
for (var branch = 0; branch < branches; branch++)
{
var phase = ((MathF.Tau * branch) / branches) + rotationStep * level + HashAngle(seed, level, branch);
var offset = new Vector2(MathF.Cos(phase), MathF.Sin(phase)) * spread * MathF.Max(nextRadii.X, nextRadii.Y);
EmitIfsLevel(
domainKey,
nodeKey,
name,
center + offset,
nextRadii,
levelsRemaining - 1,
branches,
childScale,
spread,
rotationStep,
falloff,
shapePower,
amplitude * childScale,
seed + 31 + branch,
tags,
claims);
}
}
private static void EmitFlameLevel(
AquariumFractalKey domainKey,
AquariumFractalKey nodeKey,
string name,
Vector2 center,
Vector2 radii,
float spiralAngle,
int levelsRemaining,
int branches,
float childScale,
float curl,
float spread,
float falloff,
float shapePower,
float amplitude,
int seed,
string tags,
List<AquariumBrushClaim> claims)
{
var level = claims.Count;
var rotation = spiralAngle + curl * level;
claims.Add(FieldClaim(AquariumFractalPayloadKind.Density, domainKey, nodeKey, $"{name}/{level}", claims.Count, center, radii, rotation, falloff, shapePower, amplitude, seed + level, tags));
if (levelsRemaining <= 1)
{
return;
}
var nextRadii = radii * childScale;
var branchStep = MathF.Tau / branches;
for (var branch = 0; branch < branches; branch++)
{
var turn = spiralAngle + curl + (branchStep * branch) + HashAngle(seed, level, branch) * 0.5f;
var reach = spread * MathF.Max(nextRadii.X, nextRadii.Y) * (1.0f + branch * 0.17f);
var offset = new Vector2(MathF.Cos(turn), MathF.Sin(turn)) * reach;
EmitFlameLevel(
domainKey,
nodeKey,
name,
center + offset,
nextRadii,
turn,
levelsRemaining - 1,
branches,
childScale,
curl,
spread,
falloff,
shapePower,
amplitude * MathF.Sqrt(childScale),
seed + 97 + (branch * 17),
tags,
claims);
}
}
private static AquariumBrushClaim FieldClaim(
AquariumFractalPayloadKind payloadKind,
AquariumFractalKey domainKey,
AquariumFractalKey nodeKey,
string name,
int claimIndex,
Vector2 center,
Vector2 radii,
float rotationRadians,
float falloff,
float shapePower,
float amplitude,
int seed,
string tags,
float waveAmplitude = 0.0f,
float waveFrequency = 0.0f,
float waveSpeed = 0.0f,
float waveSinePower = 0.0f)
{
return new AquariumBrushClaim(
FractalStableKeyBuilder.Child(nodeKey, $"claim/{claimIndex:0000}/{name}"),
domainKey,
nodeKey,
payloadKind,
center,
radii,
rotationRadians,
falloff,
shapePower,
amplitude,
seed,
tags,
waveAmplitude,
waveFrequency,
waveSpeed,
waveSinePower);
}
private static string StripComment(string line)
{
var index = line.IndexOf('#', StringComparison.Ordinal);
return index < 0 ? line : line[..index];
}
private static void EnsureTokenCount(string[] tokens, int minimum, int lineIndex)
{
if (tokens.Length < minimum)
{
throw new FormatException($"Command `{tokens[0]}` at line {lineIndex + 1} expected at least {minimum - 1} arguments.");
}
}
private static void EnsureDomain(AquariumFractalDomain? domain, int lineIndex)
{
if (domain is null)
{
throw new FormatException($"Fractal DSL claim at line {lineIndex + 1} appears before a tile declaration.");
}
}
private static void EnsureNode(NodeDraft? node, int lineIndex)
{
if (node is null)
{
throw new FormatException($"Fractal DSL claim at line {lineIndex + 1} appears before a tile root was created.");
}
}
private static void EnsureAffineIfs(AffineIfsDraft? draft, int lineIndex)
{
if (draft is null)
{
throw new FormatException($"Affine IFS map at line {lineIndex + 1} appears before an affineifs declaration.");
}
}
private static FractalAffineIfsDefinition[] BuildAffineIfsDefinitions(IReadOnlyList<AffineIfsDraft> drafts)
{
var definitions = new FractalAffineIfsDefinition[drafts.Count];
for (var index = 0; index < drafts.Count; index++)
{
var draft = drafts[index];
definitions[index] = new FractalAffineIfsDefinition(
draft.Key,
draft.DomainKey,
draft.Name,
draft.Start,
draft.Seed,
draft.Tags,
draft.Transforms.ToArray());
}
return definitions;
}
private static AquariumFractalNode[] BuildNodes(IReadOnlyList<NodeDraft> drafts, IReadOnlyList<AquariumBrushClaim> claims)
{
var nodes = new AquariumFractalNode[drafts.Count];
for (var nodeIndex = 0; nodeIndex < drafts.Count; nodeIndex++)
{
var draft = drafts[nodeIndex];
nodes[nodeIndex] = new AquariumFractalNode(
draft.Key,
draft.DomainKey,
default,
AquariumFractalOperation.Union,
FirstChildIndex: 0,
ChildCount: 0,
draft.FirstClaimIndex,
draft.ClaimCount,
BoundsForClaims(claims, draft.FirstClaimIndex, draft.ClaimCount),
Seed: 0);
}
return nodes;
}
private static Vector4 BoundsForClaims(IReadOnlyList<AquariumBrushClaim> claims, int firstClaimIndex, int claimCount)
{
if (claimCount == 0)
{
return Vector4.Zero;
}
var minX = float.PositiveInfinity;
var minY = float.PositiveInfinity;
var maxX = float.NegativeInfinity;
var maxY = float.NegativeInfinity;
for (var index = firstClaimIndex; index < firstClaimIndex + claimCount; index++)
{
var claim = claims[index];
var radius = MathF.Max(claim.Radii.X, claim.Radii.Y);
minX = MathF.Min(minX, claim.Center.X - radius);
minY = MathF.Min(minY, claim.Center.Y - radius);
maxX = MathF.Max(maxX, claim.Center.X + radius);
maxY = MathF.Max(maxY, claim.Center.Y + radius);
}
return new Vector4(minX, minY, maxX, maxY);
}
private static CubeFace ParseFace(string value, int lineIndex)
{
if (Enum.TryParse<CubeFace>(value, ignoreCase: false, out var face))
{
return face;
}
throw new FormatException($"Invalid cube face `{value}` at line {lineIndex + 1}.");
}
private static int ParsePositiveInt(string value, int lineIndex)
{
var parsed = ParseInt(value, lineIndex);
if (parsed <= 0)
{
throw new FormatException($"Expected positive integer at line {lineIndex + 1}, got `{value}`.");
}
return parsed;
}
private static int ParseInt(string value, int lineIndex)
{
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
return parsed;
}
throw new FormatException($"Invalid integer `{value}` at line {lineIndex + 1}.");
}
private static float ParsePositiveFloat(string value, int lineIndex)
{
var parsed = ParseFloat(value, lineIndex);
if (parsed <= 0.0f)
{
throw new FormatException($"Expected positive number at line {lineIndex + 1}, got `{value}`.");
}
return parsed;
}
private static float ParseFloat(string value, int lineIndex)
{
if (float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
{
return parsed;
}
throw new FormatException($"Invalid number `{value}` at line {lineIndex + 1}.");
}
private static float HashAngle(int seed, int level, int branch)
{
var value = (seed * 1103515245) ^ (level * 374761393) ^ (branch * 668265263);
value ^= value >> 13;
value *= 1274126177;
value ^= value >> 16;
var normalized = (value & 0xFFFF) / 65535.0f;
return (normalized - 0.5f) * 0.35f;
}
private sealed class NodeDraft(AquariumFractalKey key, AquariumFractalKey domainKey, int firstClaimIndex)
{
public AquariumFractalKey Key { get; } = key;
public AquariumFractalKey DomainKey { get; } = domainKey;
public int FirstClaimIndex { get; } = firstClaimIndex;
public int ClaimCount { get; set; }
}
private sealed class AffineIfsDraft(
AquariumFractalKey key,
AquariumFractalKey domainKey,
string name,
Vector2 start,
int seed,
string tags)
{
public AquariumFractalKey Key { get; } = key;
public AquariumFractalKey DomainKey { get; } = domainKey;
public string Name { get; } = name;
public Vector2 Start { get; } = start;
public int Seed { get; } = seed;
public string Tags { get; } = tags;
public List<FractalAffineIfsTransform2D> Transforms { get; } = [];
}
}

View file

@ -1,56 +0,0 @@
using System.Numerics;
namespace Aquarium.Engine.Fractal.Grammar;
public static class FractalFlameChaosGame
{
public static Vector2[] Generate(
FractalFlameDefinition definition,
int count,
int burnIn,
IFractalRandom random)
{
ArgumentNullException.ThrowIfNull(definition);
ArgumentNullException.ThrowIfNull(random);
ArgumentOutOfRangeException.ThrowIfNegative(count);
ArgumentOutOfRangeException.ThrowIfNegative(burnIn);
var points = new Vector2[count];
var point = definition.Start;
var total = definition.Transforms.Sum(transform => MathF.Max(transform.Weight, 0.0f));
if (total <= 0.0f)
{
throw new ArgumentException("Flame transform weights must have positive total.", nameof(definition));
}
for (var step = 0; step < burnIn + count; step++)
{
point = SelectTransform(definition.Transforms, total, (float)random.NextDouble()).Apply(point, random);
if (step >= burnIn)
{
points[step - burnIn] = point;
}
}
return points;
}
private static FractalFlameTransform2D SelectTransform(
IReadOnlyList<FractalFlameTransform2D> transforms,
float total,
float unit)
{
var target = unit * total;
var cumulative = 0.0f;
for (var index = 0; index < transforms.Count; index++)
{
cumulative += MathF.Max(transforms[index].Weight, 0.0f);
if (target <= cumulative)
{
return transforms[index];
}
}
return transforms[^1];
}
}

View file

@ -1,136 +0,0 @@
using System.Numerics;
namespace Aquarium.Engine.Fractal.Grammar;
public readonly record struct FractalFlameVariationWeights(
float Linear,
float Spherical,
float Bubble,
float Disc = 0.0f,
float Julian = 0.0f,
float JulianPower = 2.0f,
float JulianDist = 1.0f,
float GaussianBlur = 0.0f)
{
public bool HasAny =>
Linear != 0.0f ||
Spherical != 0.0f ||
Bubble != 0.0f ||
Disc != 0.0f ||
Julian != 0.0f ||
GaussianBlur != 0.0f;
public Vector2 Apply(Vector2 point, IFractalRandom? random = null)
{
var result = Vector2.Zero;
if (Linear != 0.0f)
{
result += point * Linear;
}
var radiusSquared = Vector2.Dot(point, point);
if (Spherical != 0.0f)
{
result += point * (Spherical / MathF.Max(radiusSquared, 0.000001f));
}
if (Bubble != 0.0f)
{
result += point * (Bubble * 4.0f / (radiusSquared + 4.0f));
}
if (Disc != 0.0f)
{
var radius = MathF.Sqrt(radiusSquared);
var theta = MathF.Atan2(point.X, point.Y);
var scaledTheta = Disc * theta / MathF.PI;
result += new Vector2(
MathF.Sin(MathF.PI * radius) * scaledTheta,
MathF.Cos(MathF.PI * radius) * scaledTheta);
}
if (Julian != 0.0f)
{
var power = MathF.Abs(JulianPower) < 1.0f ? 1.0f : JulianPower;
var absPower = MathF.Max(MathF.Abs(power), 1.0f);
var branch = MathF.Floor(NextUnit(random) * absPower);
var angle = (MathF.Atan2(point.Y, point.X) + (2.0f * MathF.PI * branch)) / power;
var radius = MathF.Pow(MathF.Max(MathF.Sqrt(radiusSquared), 0.000001f), JulianDist / power);
result += new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * (Julian * radius);
}
if (GaussianBlur != 0.0f)
{
var amount = GaussianBlur * (NextUnit(random) + NextUnit(random) + NextUnit(random) + NextUnit(random) - 2.0f);
var angle = NextUnit(random) * 2.0f * MathF.PI;
result += new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * amount;
}
return result;
}
private static float NextUnit(IFractalRandom? random)
{
return random is null ? 0.5f : (float)random.NextDouble();
}
}
public readonly record struct FractalFlameTransform2D(
string Name,
Vector4 Matrix,
Vector2 Translation,
Vector4 PostMatrix,
Vector2 PostTranslation,
float Weight,
float Color,
FractalFlameVariationWeights Variations)
{
public Vector2 Apply(Vector2 point)
{
return Apply(point, random: null);
}
public Vector2 Apply(Vector2 point, IFractalRandom? random)
{
var affine = new Vector2(
(Matrix.X * point.X) + (Matrix.Y * point.Y) + Translation.X,
(Matrix.Z * point.X) + (Matrix.W * point.Y) + Translation.Y);
var varied = Variations.HasAny ? Variations.Apply(affine, random) : affine;
return new Vector2(
(PostMatrix.X * varied.X) + (PostMatrix.Y * varied.Y) + PostTranslation.X,
(PostMatrix.Z * varied.X) + (PostMatrix.W * varied.Y) + PostTranslation.Y);
}
}
public sealed class FractalFlameDefinition
{
public FractalFlameDefinition(
string name,
Vector2 start,
int seed,
IReadOnlyList<FractalFlameTransform2D> transforms)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Flame name must not be empty.", nameof(name));
}
if (transforms.Count == 0)
{
throw new ArgumentException("Flame definitions need at least one transform.", nameof(transforms));
}
Name = name;
Start = start;
Seed = seed;
Transforms = transforms;
}
public string Name { get; }
public Vector2 Start { get; }
public int Seed { get; }
public IReadOnlyList<FractalFlameTransform2D> Transforms { get; }
}

View file

@ -1,332 +0,0 @@
using System.Globalization;
using System.Numerics;
using System.Xml.Linq;
namespace Aquarium.Engine.Fractal.Grammar;
public static class FractalFlameFileParser
{
public static FractalFlameDefinition ParseFirst(string source, int seed = 0)
{
return ParseFirstWithReport(source, seed).Definition;
}
public static FractalFlameParseResult ParseFirstWithReport(string source, int seed = 0)
{
ArgumentNullException.ThrowIfNull(source);
var document = XDocument.Parse(source);
var flame = document.Descendants("flame").FirstOrDefault()
?? throw new FormatException("Flame file does not contain a flame element.");
var name = ((string?)flame.Attribute("name")) ?? "flame";
var isJWildfireDialect = string.Equals(document.Root?.Name.LocalName, "Flames", StringComparison.Ordinal)
|| flame.Elements("xform").Any(xform => xform.Element("variationGroup") is not null);
var parsed = flame.Elements("xform").Select((xform, index) => ParseXform(xform, index, isJWildfireDialect)).ToArray();
var definition = new FractalFlameDefinition(name, Vector2.Zero, seed, parsed.Select(row => row.Transform).ToArray());
var report = new FractalFlameImportReport(
name,
isJWildfireDialect ? "JWildfire" : "Apophysis/FLAM3 subset",
parsed.Length,
BuildFlameFieldReports(flame),
parsed.Select(row => row.Report).ToArray());
return new FractalFlameParseResult(definition, report);
}
private static ParsedXform ParseXform(XElement element, int index, bool isJWildfireDialect)
{
var coefs = ParseFloatList((string?)element.Attribute("coefs"), expectedCount: 6, "xform coefs");
var variationGroup = element.Element("variationGroup");
var variationSource = variationGroup ?? element;
var accepted = new List<string> { "coefs", "weight", "color" };
var approximated = new List<string>();
var ignored = new List<string>();
var rejected = new List<string>();
if (element.Attribute("name") is not null)
{
accepted.Add("name");
}
if (isJWildfireDialect)
{
accepted.Add("coefs: JWildfire xy layout");
}
if (variationGroup is not null)
{
accepted.Add("variationGroup");
}
var matrix = isJWildfireDialect
? new Vector4(coefs[0], coefs[1], coefs[2], coefs[3])
: new Vector4(coefs[0], coefs[1], coefs[3], coefs[4]);
var translation = isJWildfireDialect
? new Vector2(coefs[4], coefs[5])
: new Vector2(coefs[2], coefs[5]);
var post = ParseOptionalFloatList((string?)element.Attribute("post"), expectedCount: 6);
var postMatrix = post is null
? new Vector4(1.0f, 0.0f, 0.0f, 1.0f)
: isJWildfireDialect
? new Vector4(post[0], post[1], post[2], post[3])
: new Vector4(post[0], post[1], post[3], post[4]);
var postTranslation = post is null
? Vector2.Zero
: isJWildfireDialect
? new Vector2(post[4], post[5])
: new Vector2(post[2], post[5]);
var variations = new FractalFlameVariationWeights(
ParseOptionalFloat(variationSource, "linear", 0.0f)
+ (isJWildfireDialect ? 0.0f : ParseOptionalFloat(variationSource, "normal", 0.0f)),
ParseOptionalFloat(variationSource, "spherical", 0.0f),
ParseOptionalFloat(variationSource, "bubble", 0.0f),
ParseOptionalFloat(variationSource, "disc", 0.0f) + ParseOptionalFloat(variationSource, "jwf_disc", 0.0f),
ParseOptionalFloat(variationSource, "julian", 0.0f) + ParseOptionalFloat(variationSource, "jwf_julian", 0.0f),
ParseOptionalFloat(variationSource, "julian_power", ParseOptionalFloat(variationSource, "jwf_julian_power", 2.0f)),
ParseOptionalFloat(variationSource, "julian_dist", ParseOptionalFloat(variationSource, "jwf_julian_dist", 1.0f)),
ParseOptionalFloat(variationSource, "gaussian_blur", 0.0f) + ParseOptionalFloat(variationSource, "jwf_gaussian_blur", 0.0f));
ClassifyVariationFields(variationSource, isJWildfireDialect, accepted, approximated, ignored, rejected);
ClassifyXformFields(element, variationGroup, accepted, ignored, rejected);
var transformName = ((string?)element.Attribute("name")) ?? $"xform/{index:0000}";
var transform = new FractalFlameTransform2D(
transformName,
matrix,
translation,
postMatrix,
postTranslation,
ParseOptionalFloat(element, "weight", 1.0f),
ParseOptionalFloat(element, "color", 0.0f),
variations);
return new ParsedXform(transform, new FractalFlameTransformImportReport(
index,
transformName,
accepted.Distinct().OrderBy(value => value, StringComparer.Ordinal).ToArray(),
approximated.Distinct().OrderBy(value => value, StringComparer.Ordinal).ToArray(),
ignored.Distinct().OrderBy(value => value, StringComparer.Ordinal).ToArray(),
rejected.Distinct().OrderBy(value => value, StringComparer.Ordinal).ToArray()));
}
private static float ParseOptionalFloat(XElement element, string name, float fallback)
{
var attribute = (string?)element.Attribute(name);
return attribute is null ? fallback : float.Parse(attribute, CultureInfo.InvariantCulture);
}
private static float[] ParseFloatList(string? source, int expectedCount, string label)
{
if (string.IsNullOrWhiteSpace(source))
{
throw new FormatException($"{label} must not be empty.");
}
var values = source
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(value => float.Parse(value, CultureInfo.InvariantCulture))
.ToArray();
if (values.Length != expectedCount)
{
throw new FormatException($"{label} expected {expectedCount} values, got {values.Length}.");
}
return values;
}
private static float[]? ParseOptionalFloatList(string? source, int expectedCount)
{
return string.IsNullOrWhiteSpace(source)
? null
: ParseFloatList(source, expectedCount, "optional float list");
}
private static IReadOnlyList<string> BuildFlameFieldReports(XElement flame)
{
var structural = new HashSet<string>(StringComparer.Ordinal)
{
"name",
"size",
"center",
"scale",
"rotate",
};
return flame.Attributes()
.Select(attribute => attribute.Name.LocalName)
.Where(name => !structural.Contains(name))
.OrderBy(name => name, StringComparer.Ordinal)
.ToArray();
}
private static void ClassifyVariationFields(
XElement variationSource,
bool isJWildfireDialect,
List<string> accepted,
List<string> approximated,
List<string> ignored,
List<string> rejected)
{
var supported = new HashSet<string>(StringComparer.Ordinal)
{
"linear",
"spherical",
"bubble",
"disc",
"jwf_disc",
"julian",
"jwf_julian",
"julian_power",
"jwf_julian_power",
"julian_dist",
"jwf_julian_dist",
};
foreach (var attribute in variationSource.Attributes())
{
var name = attribute.Name.LocalName;
if (supported.Contains(name))
{
accepted.Add($"variation:{name}");
continue;
}
if (name is "normal")
{
if (isJWildfireDialect)
{
ignored.Add("variationGroup:normal");
}
else
{
approximated.Add("variation:normal -> linear");
}
continue;
}
if (name is "gaussian_blur" or "jwf_gaussian_blur")
{
approximated.Add($"variation:{name} -> stochastic gaussian_blur");
continue;
}
if (TryParseFloat(attribute.Value, out var value) && value == 0.0f)
{
ignored.Add($"variation:{name}=0");
continue;
}
rejected.Add($"variation:{name}");
}
}
private static void ClassifyXformFields(
XElement element,
XElement? variationGroup,
List<string> accepted,
List<string> ignored,
List<string> rejected)
{
var handled = new HashSet<string>(StringComparer.Ordinal)
{
"name",
"coefs",
"weight",
"color",
};
foreach (var attribute in element.Attributes())
{
var name = attribute.Name.LocalName;
if (handled.Contains(name))
{
continue;
}
if (name is "post")
{
accepted.Add(IsIdentityPost(attribute.Value) ? "post=identity" : "post");
continue;
}
if (name is "chaos")
{
if (IsNeutralFloatList(attribute.Value, 1.0f))
{
ignored.Add("chaos=all-ones");
}
else
{
rejected.Add("chaos");
}
continue;
}
if (name.StartsWith("wfield_", StringComparison.Ordinal))
{
rejected.Add(name);
continue;
}
ignored.Add(name);
}
if (variationGroup is not null)
{
foreach (var child in element.Elements())
{
if (child != variationGroup)
{
ignored.Add(child.Name.LocalName);
}
}
}
}
private static bool IsIdentityPost(string value)
{
var values = ParseFloatList(value, 6, "post");
return values[0] == 1.0f
&& values[1] == 0.0f
&& values[2] == 0.0f
&& values[3] == 1.0f
&& values[4] == 0.0f
&& values[5] == 0.0f;
}
private static bool IsNeutralFloatList(string value, float neutral)
{
var values = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return values.Length > 0 && values.All(item => TryParseFloat(item, out var parsed) && parsed == neutral);
}
private static bool TryParseFloat(string source, out float value)
{
return float.TryParse(source, NumberStyles.Float, CultureInfo.InvariantCulture, out value);
}
private readonly record struct ParsedXform(FractalFlameTransform2D Transform, FractalFlameTransformImportReport Report);
}
public sealed record FractalFlameParseResult(
FractalFlameDefinition Definition,
FractalFlameImportReport Report);
public sealed record FractalFlameImportReport(
string FlameName,
string Dialect,
int TransformCount,
IReadOnlyList<string> IgnoredFlameAttributes,
IReadOnlyList<FractalFlameTransformImportReport> Transforms)
{
public int AcceptedFieldCount => Transforms.Sum(transform => transform.AcceptedFields.Count);
public int ApproximatedFieldCount => Transforms.Sum(transform => transform.ApproximatedFields.Count);
public int IgnoredFieldCount => IgnoredFlameAttributes.Count + Transforms.Sum(transform => transform.IgnoredFields.Count);
public int RejectedFieldCount => Transforms.Sum(transform => transform.RejectedFields.Count);
}
public sealed record FractalFlameTransformImportReport(
int Index,
string Name,
IReadOnlyList<string> AcceptedFields,
IReadOnlyList<string> ApproximatedFields,
IReadOnlyList<string> IgnoredFields,
IReadOnlyList<string> RejectedFields);

View file

@ -1,86 +0,0 @@
using System.Numerics;
namespace Aquarium.Engine.Fractal.Grammar;
public readonly record struct FractalFlameIterationState(
Vector2 Point,
uint RandomState,
int Step)
{
public static FractalFlameIterationState Create(uint seed, int sampleIndex)
{
var state = Hash(seed ^ (uint)sampleIndex);
return new FractalFlameIterationState(Vector2.Zero, state == 0 ? 0xA341316Cu : state, 0);
}
private static uint Hash(uint value)
{
value ^= value >> 16;
value *= 747796405u;
value ^= value >> 16;
value *= 2891336453u;
value ^= value >> 16;
return value;
}
}
public static class FractalFlameIterationStepper
{
public static FractalFlameIterationState Advance(
FractalFlameDefinition definition,
FractalFlameIterationState state,
int iterations)
{
ArgumentNullException.ThrowIfNull(definition);
ArgumentOutOfRangeException.ThrowIfNegative(iterations);
var random = new StatefulRandom(state.RandomState);
var point = state.Point;
var total = definition.Transforms.Sum(transform => MathF.Max(transform.Weight, 0.0f));
if (total <= 0.0f)
{
throw new ArgumentException("Flame transform weights must have positive total.", nameof(definition));
}
for (var iteration = 0; iteration < iterations; iteration++)
{
point = SelectTransform(definition.Transforms, total, (float)random.NextDouble()).Apply(point, random);
}
return new FractalFlameIterationState(point, random.State, checked(state.Step + iterations));
}
private static FractalFlameTransform2D SelectTransform(
IReadOnlyList<FractalFlameTransform2D> transforms,
float total,
float unit)
{
var target = unit * total;
var cumulative = 0.0f;
for (var index = 0; index < transforms.Count; index++)
{
cumulative += MathF.Max(transforms[index].Weight, 0.0f);
if (target <= cumulative)
{
return transforms[index];
}
}
return transforms[^1];
}
private sealed class StatefulRandom(uint state) : IFractalRandom
{
public uint State { get; private set; } = state == 0 ? 0xA341316Cu : state;
public double NextDouble()
{
var value = State;
value ^= value << 13;
value ^= value >> 17;
value ^= value << 5;
State = value;
return value / ((double)uint.MaxValue + 1.0);
}
}
}

View file

@ -1,78 +0,0 @@
using System.Numerics;
namespace Aquarium.Engine.Fractal.Grammar;
public static class FractalHistogramResidualFocus
{
public static Vector4 Estimate(FractalPointHistogram reference, FractalPointHistogram candidate)
{
if (reference.Width != candidate.Width || reference.Height != candidate.Height)
{
throw new ArgumentException("Histogram dimensions must match.", nameof(candidate));
}
if (reference.Bins.Length != candidate.Bins.Length)
{
throw new ArgumentException("Histogram bin counts must match.", nameof(candidate));
}
var referenceTotal = Math.Max(reference.HitCount, 1);
var candidateTotal = Math.Max(candidate.HitCount, 1);
var span = new Vector2(reference.Bounds.Z - reference.Bounds.X, reference.Bounds.W - reference.Bounds.Y);
var weighted = Vector2.Zero;
var mass = 0.0f;
for (var y = 0; y < reference.Height; y++)
{
for (var x = 0; x < reference.Width; x++)
{
var index = (y * reference.Width) + x;
var delta = (reference.Bins[index] / (float)referenceTotal) - (candidate.Bins[index] / (float)candidateTotal);
if (delta <= 0.0f)
{
continue;
}
var world = BinCenter(reference, span, x, y);
weighted += world * delta;
mass += delta;
}
}
if (mass <= 0.0f)
{
return Vector4.Zero;
}
var center = weighted / mass;
var variance = 0.0f;
for (var y = 0; y < reference.Height; y++)
{
for (var x = 0; x < reference.Width; x++)
{
var index = (y * reference.Width) + x;
var delta = (reference.Bins[index] / (float)referenceTotal) - (candidate.Bins[index] / (float)candidateTotal);
if (delta <= 0.0f)
{
continue;
}
var world = BinCenter(reference, span, x, y);
variance += Vector2.DistanceSquared(world, center) * delta;
}
}
var minRadius = MathF.Max(span.X / MathF.Max(reference.Width, 1), span.Y / MathF.Max(reference.Height, 1)) * 1.5f;
var maxRadius = MathF.Max(span.X, span.Y) * 0.75f;
var radius = Math.Clamp(MathF.Sqrt(variance / mass) * 2.0f, minRadius, maxRadius);
var strength = Math.Clamp(mass * 3.0f, 0.05f, 1.0f);
return new Vector4(center.X, center.Y, radius, strength);
}
private static Vector2 BinCenter(FractalPointHistogram histogram, Vector2 span, int x, int y)
{
return new Vector2(
histogram.Bounds.X + ((x + 0.5f) / histogram.Width) * span.X,
histogram.Bounds.Y + ((y + 0.5f) / histogram.Height) * span.Y);
}
}

View file

@ -1,51 +0,0 @@
using Aquarium.Engine.Fractal;
namespace Aquarium.Engine.Fractal.Grammar;
public sealed class FractalOwnershipTree
{
public FractalOwnershipTree(
AquariumFractalDomain domain,
IReadOnlyList<AquariumFractalNode> nodes,
IReadOnlyList<AquariumBrushClaim> claims)
: this(domain, [domain], nodes, claims, [])
{
}
public FractalOwnershipTree(
AquariumFractalDomain domain,
IReadOnlyList<AquariumFractalDomain> domains,
IReadOnlyList<AquariumFractalNode> nodes,
IReadOnlyList<AquariumBrushClaim> claims)
: this(domain, domains, nodes, claims, [])
{
}
public FractalOwnershipTree(
AquariumFractalDomain domain,
IReadOnlyList<AquariumFractalDomain> domains,
IReadOnlyList<AquariumFractalNode> nodes,
IReadOnlyList<AquariumBrushClaim> claims,
IReadOnlyList<FractalAffineIfsDefinition> affineIfsDefinitions)
{
Domain = domain;
DomainGraph = new FractalDomainGraph(domains);
DomainGraph.GetRequired(domain.Key);
Domains = DomainGraph.Domains;
Nodes = nodes;
Claims = claims;
AffineIfsDefinitions = affineIfsDefinitions;
}
public AquariumFractalDomain Domain { get; }
public IReadOnlyList<AquariumFractalDomain> Domains { get; }
public FractalDomainGraph DomainGraph { get; }
public IReadOnlyList<AquariumFractalNode> Nodes { get; }
public IReadOnlyList<AquariumBrushClaim> Claims { get; }
public IReadOnlyList<FractalAffineIfsDefinition> AffineIfsDefinitions { get; }
}

View file

@ -1,64 +0,0 @@
using System.Numerics;
using Aquarium.Engine.Fractal;
namespace Aquarium.Engine.Fractal.Grammar;
public static class FractalOwnershipTreeBuilder
{
public static FractalOwnershipTree BuildFlatUnion(
AquariumFractalDomain domain,
AquariumFractalKey rootKey,
IReadOnlyList<AquariumBrushClaim> claims)
{
return BuildFlatUnion(domain, [domain], rootKey, claims);
}
public static FractalOwnershipTree BuildFlatUnion(
AquariumFractalDomain domain,
IReadOnlyList<AquariumFractalDomain> domains,
AquariumFractalKey rootKey,
IReadOnlyList<AquariumBrushClaim> claims)
{
ArgumentNullException.ThrowIfNull(claims);
ArgumentNullException.ThrowIfNull(domains);
var bounds = BoundsForClaims(claims);
var root = new AquariumFractalNode(
rootKey,
domain.Key,
default,
AquariumFractalOperation.Union,
FirstChildIndex: 0,
ChildCount: 0,
FirstClaimIndex: 0,
ClaimCount: claims.Count,
bounds,
Seed: 0);
return new FractalOwnershipTree(domain, domains, [root], claims);
}
private static Vector4 BoundsForClaims(IReadOnlyList<AquariumBrushClaim> claims)
{
if (claims.Count == 0)
{
return Vector4.Zero;
}
var minX = float.PositiveInfinity;
var minY = float.PositiveInfinity;
var maxX = float.NegativeInfinity;
var maxY = float.NegativeInfinity;
foreach (var claim in claims)
{
var radius = MathF.Max(claim.Radii.X, claim.Radii.Y);
minX = MathF.Min(minX, claim.Center.X - radius);
minY = MathF.Min(minY, claim.Center.Y - radius);
maxX = MathF.Max(maxX, claim.Center.X + radius);
maxY = MathF.Max(maxY, claim.Center.Y + radius);
}
return new Vector4(minX, minY, maxX, maxY);
}
}

View file

@ -1,46 +0,0 @@
using System.Numerics;
namespace Aquarium.Engine.Fractal.Grammar;
public readonly record struct FractalPointHistogram(
int Width,
int Height,
Vector4 Bounds,
int[] Bins)
{
public int HitCount => Bins.Sum();
public int this[int x, int y] => Bins[y * Width + x];
}
public static class FractalPointHistogramBuilder
{
public static FractalPointHistogram Build(
IReadOnlyList<Vector2> points,
int width,
int height,
Vector4 bounds)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
var bins = new int[width * height];
var span = new Vector2(bounds.Z - bounds.X, bounds.W - bounds.Y);
foreach (var point in points)
{
var u = (point.X - bounds.X) / MathF.Max(span.X, 0.000001f);
var v = (point.Y - bounds.Y) / MathF.Max(span.Y, 0.000001f);
if (u < 0.0f || u >= 1.0f || v < 0.0f || v >= 1.0f)
{
continue;
}
var x = Math.Clamp((int)(u * width), 0, width - 1);
var y = Math.Clamp((int)(v * height), 0, height - 1);
bins[y * width + x]++;
}
return new FractalPointHistogram(width, height, bounds, bins);
}
}

View file

@ -1,149 +0,0 @@
namespace Aquarium.Engine.Fractal.Grammar;
public readonly record struct FractalPpmImageReceipt(
int Width,
int Height,
int MaxChannelValue,
int PixelCount,
int NonBlackPixelCount,
ulong RgbChecksum,
ulong LuminanceChecksum);
public static class FractalPpmImageReceiptBuilder
{
public static FractalPpmImageReceipt Build(ReadOnlySpan<byte> bytes)
{
var cursor = 0;
var magic = ReadToken(bytes, ref cursor);
if (!magic.SequenceEqual("P6"u8))
{
throw new FormatException("Only binary P6 PPM reference renders are supported.");
}
var width = ParsePositiveInt(ReadToken(bytes, ref cursor), "width");
var height = ParsePositiveInt(ReadToken(bytes, ref cursor), "height");
var maxChannelValue = ParsePositiveInt(ReadToken(bytes, ref cursor), "max channel value");
if (maxChannelValue > 255)
{
throw new FormatException("Only 8-bit PPM reference renders are supported.");
}
ConsumeSingleWhitespace(bytes, ref cursor);
var expectedBytes = checked(width * height * 3);
if (bytes.Length - cursor < expectedBytes)
{
throw new FormatException("PPM reference render ended before all RGB pixels were present.");
}
var rgbChecksum = 1469598103934665603UL;
var luminanceChecksum = 1469598103934665603UL;
var nonBlack = 0;
var pixels = bytes.Slice(cursor, expectedBytes);
for (var index = 0; index < pixels.Length; index += 3)
{
var r = pixels[index];
var g = pixels[index + 1];
var b = pixels[index + 2];
rgbChecksum = Step(rgbChecksum, r);
rgbChecksum = Step(rgbChecksum, g);
rgbChecksum = Step(rgbChecksum, b);
var luminance = (byte)(((r * 54) + (g * 183) + (b * 19)) >> 8);
luminanceChecksum = Step(luminanceChecksum, luminance);
if ((r | g | b) != 0)
{
nonBlack++;
}
}
return new FractalPpmImageReceipt(
width,
height,
maxChannelValue,
width * height,
nonBlack,
rgbChecksum,
luminanceChecksum);
}
private static ulong Step(ulong hash, byte value)
{
hash ^= value;
hash *= 1099511628211UL;
return hash;
}
private static ReadOnlySpan<byte> ReadToken(ReadOnlySpan<byte> bytes, ref int cursor)
{
SkipWhitespaceAndComments(bytes, ref cursor);
var start = cursor;
while (cursor < bytes.Length && !IsWhitespace(bytes[cursor]) && bytes[cursor] != (byte)'#')
{
cursor++;
}
if (cursor == start)
{
throw new FormatException("PPM reference render header ended unexpectedly.");
}
return bytes[start..cursor];
}
private static void SkipWhitespaceAndComments(ReadOnlySpan<byte> bytes, ref int cursor)
{
while (cursor < bytes.Length)
{
if (IsWhitespace(bytes[cursor]))
{
cursor++;
continue;
}
if (bytes[cursor] != (byte)'#')
{
return;
}
while (cursor < bytes.Length && bytes[cursor] != (byte)'\n')
{
cursor++;
}
}
}
private static void ConsumeSingleWhitespace(ReadOnlySpan<byte> bytes, ref int cursor)
{
if (cursor >= bytes.Length || !IsWhitespace(bytes[cursor]))
{
throw new FormatException("PPM reference render header is missing the pixel-data separator.");
}
cursor++;
}
private static bool IsWhitespace(byte value)
{
return value is (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n';
}
private static int ParsePositiveInt(ReadOnlySpan<byte> token, string name)
{
var value = 0;
foreach (var digit in token)
{
if (digit is < (byte)'0' or > (byte)'9')
{
throw new FormatException($"PPM {name} is not an integer.");
}
value = checked((value * 10) + digit - (byte)'0');
}
if (value <= 0)
{
throw new FormatException($"PPM {name} must be positive.");
}
return value;
}
}

View file

@ -1,26 +0,0 @@
using Aquarium.Engine.Fractal;
namespace Aquarium.Engine.Fractal.Grammar;
public static class FractalStableKeyBuilder
{
public static AquariumFractalKey ForCubeTile(CubeTileKey tile, string grammarPath)
{
return new AquariumFractalKey($"cube/{tile.Face}/L{tile.Level:D2}/{tile.X}/{tile.Y}:{NormalizePath(grammarPath)}");
}
public static AquariumFractalKey Child(AquariumFractalKey parent, string segment)
{
return new AquariumFractalKey($"{parent.Value}/{NormalizePath(segment)}");
}
private static string NormalizePath(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Fractal key path segment must not be empty.", nameof(value));
}
return value.Trim().Replace('\\', '/');
}
}

View file

@ -1,62 +0,0 @@
using Aquarium.Engine.Fractal;
namespace Aquarium.Engine.Fractal.Grammar;
public static class FractalSummaryBuilder
{
public static AquariumFractalSummary[] Build(FractalOwnershipTree tree)
{
ArgumentNullException.ThrowIfNull(tree);
var summaries = new AquariumFractalSummary[tree.Nodes.Count];
for (var nodeIndex = 0; nodeIndex < tree.Nodes.Count; nodeIndex++)
{
var node = tree.Nodes[nodeIndex];
var maxHeightError = 0.0f;
var estimatedCost = 0.0f;
var formEncodingMask = AquariumFieldEncodingFlags.None;
for (var claimIndex = node.FirstClaimIndex; claimIndex < node.FirstClaimIndex + node.ClaimCount; claimIndex++)
{
var claim = tree.Claims[claimIndex];
if (IsFormPayload(claim.PayloadKind))
{
maxHeightError += MathF.Abs(claim.Amplitude);
formEncodingMask |= EncodingFlagFor(claim.PayloadKind);
}
estimatedCost += 1.0f;
}
summaries[nodeIndex] = new AquariumFractalSummary(
node.Key,
node.BoundsMinMax,
maxHeightError,
MaxMaterialDelta: 0.0f,
estimatedCost,
DescendantCount: node.ChildCount + node.ClaimCount,
formEncodingMask);
}
return summaries;
}
private static bool IsFormPayload(AquariumFractalPayloadKind kind)
{
return kind is AquariumFractalPayloadKind.Height
or AquariumFractalPayloadKind.SignedDistance
or AquariumFractalPayloadKind.Density
or AquariumFractalPayloadKind.Extinction;
}
private static AquariumFieldEncodingFlags EncodingFlagFor(AquariumFractalPayloadKind kind)
{
return kind switch
{
AquariumFractalPayloadKind.Height => AquariumFieldEncodingFlags.Height,
AquariumFractalPayloadKind.SignedDistance => AquariumFieldEncodingFlags.SignedDistance,
AquariumFractalPayloadKind.Density => AquariumFieldEncodingFlags.Density,
AquariumFractalPayloadKind.Extinction => AquariumFieldEncodingFlags.Extinction,
_ => AquariumFieldEncodingFlags.None,
};
}
}

View file

@ -1,8 +0,0 @@
namespace Aquarium.Engine.Fractal;
public interface IFractalClock
{
double TimeSeconds { get; }
ulong FrameIndex { get; }
}

View file

@ -1,6 +0,0 @@
namespace Aquarium.Engine.Fractal;
public interface IFractalDebugSink
{
void Record(string channel, string key, double value);
}

View file

@ -1,6 +0,0 @@
namespace Aquarium.Engine.Fractal;
public interface IFractalRandom
{
double NextDouble();
}

View file

@ -1,92 +0,0 @@
using Aquarium.Engine.Fractal;
using Aquarium.Engine.Fractal.Temporal;
namespace Aquarium.Engine.Fractal.Lod;
public sealed class FractalContributionCache
{
private readonly FractalOccupancyGraph occupancy = new();
private uint frameIndex;
public FractalContributionReservoirSnapshot LastFrameReservoir { get; private set; }
public FractalResourcePlan PlanFrame(
IReadOnlyList<AquariumFractalSummary> summaries,
Func<AquariumFractalSummary, float> projectedPixelsPerWorldUnit,
FractalResourceBudget budget,
IFractalRandom random,
IFractalPayloadStore payloadStore)
{
ArgumentNullException.ThrowIfNull(summaries);
ArgumentNullException.ThrowIfNull(projectedPixelsPerWorldUnit);
ArgumentNullException.ThrowIfNull(random);
ArgumentNullException.ThrowIfNull(payloadStore);
frameIndex++;
var states = new AquariumContributionState[summaries.Count];
var scores = new Dictionary<string, float>(StringComparer.Ordinal);
for (var index = 0; index < summaries.Count; index++)
{
var summary = summaries[index];
states[index] = occupancy.GetOrCreate(summary.NodeKey).Contribution;
var score = FractalProjectedErrorScorer.Score(summary, projectedPixelsPerWorldUnit(summary));
scores[summary.NodeKey.Value] = score;
var updateProbability = FractalStochasticUpdateScheduler.UpdateProbability(states[index], score, frameIndex);
occupancy.MarkVisible(summary.NodeKey, score, frameIndex, updateProbability);
}
var plan = FractalResourceBudgetPlanner.Plan(
summaries,
states,
projectedPixelsPerWorldUnit,
state => scores.TryGetValue(state.NodeKey.Value, out var score) ? score : 0.0f,
budget,
random,
payloadStore,
frameIndex);
var reservoir = new ResampledImportanceReservoir<FractalContributionCandidate>();
var resident = plan.Residency.ResidentNodes.Select(key => key.Value).ToHashSet(StringComparer.Ordinal);
foreach (var nodeKey in plan.UpdateNodes)
{
if (!scores.TryGetValue(nodeKey.Value, out var observedScore))
{
continue;
}
var state = occupancy.GetOrCreate(nodeKey).Contribution;
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());
occupancy.Observe(nodeKey, observedScore, frameIndex, isResident);
}
occupancy.DecayUnobserved(frameIndex);
LastFrameReservoir = FractalContributionCandidateGenerator.Snapshot(reservoir);
return plan;
}
public bool TryGet(AquariumFractalKey nodeKey, out AquariumContributionState state)
{
if (occupancy.TryGet(nodeKey, out var node))
{
state = node.Contribution;
return true;
}
state = default;
return false;
}
public bool TryGetOccupancy(AquariumFractalKey nodeKey, out FractalOccupancyNodeState state)
{
return occupancy.TryGet(nodeKey, out state);
}
}

View file

@ -1,71 +0,0 @@
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<FractalContributionCandidate> 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<FractalContributionCandidate>(
sample,
target,
sourcePdf);
}
public static FractalContributionReservoirSnapshot Snapshot(
ResampledImportanceReservoir<FractalContributionCandidate> 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);
}
}

View file

@ -1,31 +0,0 @@
using Aquarium.Engine.Fractal;
namespace Aquarium.Engine.Fractal.Lod;
public static class FractalContributionEstimator
{
public static AquariumContributionState Observe(
AquariumContributionState state,
float observedContribution,
uint frameIndex,
bool resident)
{
var sampleCount = state.SampleCount + 1;
var delta = observedContribution - state.MeanContribution;
var mean = state.MeanContribution + (delta / sampleCount);
var variance = sampleCount <= 1
? 0.0f
: (((sampleCount - 2) * state.Variance) + (delta * (observedContribution - mean))) / (sampleCount - 1);
var confidence = Math.Clamp(sampleCount / 16.0f, 0.0f, 1.0f);
return state with
{
MeanContribution = mean,
Variance = MathF.Max(variance, 0.0f),
Confidence = confidence,
SampleCount = sampleCount,
LastSampledFrame = frameIndex,
Resident = resident,
};
}
}

View file

@ -1,37 +0,0 @@
using Aquarium.Engine.Fractal;
namespace Aquarium.Engine.Fractal.Lod;
public sealed class FractalContributionTable
{
private readonly Dictionary<string, AquariumContributionState> states = new(StringComparer.Ordinal);
public bool TryGet(AquariumFractalKey nodeKey, out AquariumContributionState state)
{
return states.TryGetValue(nodeKey.Value, out state);
}
public AquariumContributionState GetOrCreate(AquariumFractalKey nodeKey)
{
if (states.TryGetValue(nodeKey.Value, out var state))
{
return state;
}
state = new AquariumContributionState(
nodeKey,
MeanContribution: 0.0f,
Variance: 1.0f,
Confidence: 0.0f,
SampleCount: 0,
LastSampledFrame: 0,
Resident: false);
states.Add(nodeKey.Value, state);
return state;
}
public void Store(AquariumContributionState state)
{
states[state.NodeKey.Value] = state;
}
}

View file

@ -1,70 +0,0 @@
using System.Numerics;
namespace Aquarium.Engine.Fractal.Lod;
public static class FractalDensitySplat3DCompiler
{
public static AquariumFractalDensitySplat3D FromProbe(
FractalProbeSample probe,
float depthRadius,
float densityScale = 1.0f,
float extinctionScale = 1.0f,
float falloff = 4.0f,
float shapePower = 1.0f)
{
if (probe.Layer != AquariumFieldLayer.Form ||
(probe.Encoding != AquariumFieldEncoding.Density && probe.Encoding != AquariumFieldEncoding.Extinction))
{
throw new ArgumentException("3D density splat lowering only accepts Form/Density or Form/Extinction probes.", nameof(probe));
}
if (!float.IsFinite(probe.BoundRadius) || probe.BoundRadius <= 0.0f)
{
throw new ArgumentOutOfRangeException(nameof(probe), probe.BoundRadius, "Probe bound radius must be positive and finite.");
}
if (!float.IsFinite(depthRadius) || depthRadius <= 0.0f)
{
throw new ArgumentOutOfRangeException(nameof(depthRadius), depthRadius, "3D density splat depth radius must be positive and finite.");
}
if (!float.IsFinite(densityScale) || densityScale < 0.0f)
{
throw new ArgumentOutOfRangeException(nameof(densityScale), densityScale, "Density scale must be finite and non-negative.");
}
if (!float.IsFinite(extinctionScale) || extinctionScale < 0.0f)
{
throw new ArgumentOutOfRangeException(nameof(extinctionScale), extinctionScale, "Extinction scale must be finite and non-negative.");
}
if (!float.IsFinite(falloff) || falloff <= 0.0f)
{
throw new ArgumentOutOfRangeException(nameof(falloff), falloff, "3D density splat falloff must be positive and finite.");
}
if (!float.IsFinite(shapePower) || shapePower <= 0.0f)
{
throw new ArgumentOutOfRangeException(nameof(shapePower), shapePower, "3D density splat shape power must be positive and finite.");
}
var contribution = Math.Clamp(probe.TargetContribution, 0.0f, 1.0f);
var density = probe.Encoding == AquariumFieldEncoding.Density
? contribution * densityScale
: contribution;
var extinction = probe.Encoding == AquariumFieldEncoding.Extinction
? contribution * extinctionScale
: contribution * densityScale * extinctionScale;
return new AquariumFractalDensitySplat3D(
new AquariumFractalDensitySplat3DKey(probe.DomainKey, probe.NodeKey, probe.PayloadHandle),
probe.LocalCenter,
Quaternion.Identity,
new Vector3(probe.BoundRadius, probe.BoundRadius, depthRadius),
falloff,
shapePower,
Math.Clamp(density, 0.0f, 1.0f),
Math.Clamp(extinction, 0.0f, 1.0f),
contribution);
}
}

View file

@ -1,119 +0,0 @@
using System.Numerics;
using Aquarium.Engine.Fractal.Grammar;
namespace Aquarium.Engine.Fractal.Lod;
public static class FractalGpuProgramCompiler
{
public static AquariumPackedFractalIfsTransform[] CompileFlame2D(
FractalFlameDefinition flame,
int maxTransformCount)
{
ArgumentNullException.ThrowIfNull(flame);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxTransformCount);
var transforms = new AquariumPackedFractalIfsTransform[Math.Min(maxTransformCount, flame.Transforms.Count)];
for (var index = 0; index < transforms.Length; index++)
{
transforms[index] = PackFlame(flame.Transforms[index]);
}
return transforms;
}
public static AquariumPackedFractalIfsTransform[] CompileSelectedTree(
FractalOwnershipTree tree,
IReadOnlyList<AquariumSelectedCut> selectedCut,
int maxTransformCount)
{
ArgumentNullException.ThrowIfNull(tree);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxTransformCount);
if (selectedCut.Count == 0 || tree.Claims.Count == 0)
{
return [];
}
var selected = selectedCut.Select(cut => cut.NodeKey).ToHashSet();
var domains = tree.Domains.ToDictionary(domain => domain.Key);
var transforms = new List<AquariumPackedFractalIfsTransform>(Math.Min(maxTransformCount, tree.Claims.Count));
foreach (var node in tree.Nodes)
{
if (!selected.Contains(node.Key))
{
continue;
}
var claimEnd = Math.Min(node.FirstClaimIndex + node.ClaimCount, tree.Claims.Count);
for (var claimIndex = node.FirstClaimIndex; claimIndex < claimEnd && transforms.Count < maxTransformCount; claimIndex++)
{
transforms.Add(Pack(tree.Claims[claimIndex], domains));
}
if (transforms.Count >= maxTransformCount)
{
break;
}
}
return transforms.ToArray();
}
private static AquariumPackedFractalIfsTransform PackFlame(FractalFlameTransform2D transform)
{
return new AquariumPackedFractalIfsTransform(
transform.Matrix,
new Vector4(transform.Translation, transform.Variations.Linear, transform.Variations.Spherical),
new Vector4(transform.Variations.Bubble, MathF.Max(transform.Weight, 0.0f), transform.Color, transform.Variations.JulianDist),
new Vector4(transform.Variations.Disc, transform.Variations.Julian, transform.Variations.JulianPower, transform.Variations.GaussianBlur),
transform.PostMatrix,
new Vector4(transform.PostTranslation, 0.0f, 0.0f));
}
private static AquariumPackedFractalIfsTransform Pack(
AquariumBrushClaim claim,
IReadOnlyDictionary<AquariumFractalKey, AquariumFractalDomain> domains)
{
var radius = MathF.Max(claim.Radii.X, claim.Radii.Y);
var material = StableUnit(claim.Tags, claim.Seed);
var tileAddress = domains.TryGetValue(claim.DomainKey, out var domainRow)
&& domainRow.Kind == AquariumFractalDomainKind.CubeSphereTile
? domainRow.Parameters0
: new Vector4((float)CubeFace.PositiveZ, 0.0f, 0.0f, 0.0f);
var rotationCos = MathF.Cos(claim.RotationRadians);
var rotationSin = MathF.Sin(claim.RotationRadians);
return new AquariumPackedFractalIfsTransform(
new Vector4(claim.Center, radius, claim.Amplitude),
new Vector4(claim.Radii, claim.RotationRadians, claim.Falloff),
new Vector4(material, claim.Seed, rotationCos, rotationSin),
tileAddress,
new Vector4(1.0f, 0.0f, 0.0f, 1.0f),
new Vector4(0.0f, 0.0f, (float)EncodingFor(claim.PayloadKind), 0.0f));
}
private static AquariumFieldEncoding EncodingFor(AquariumFractalPayloadKind payloadKind)
{
return payloadKind switch
{
AquariumFractalPayloadKind.Height => AquariumFieldEncoding.Height,
AquariumFractalPayloadKind.SignedDistance => AquariumFieldEncoding.SignedDistance,
AquariumFractalPayloadKind.Density => AquariumFieldEncoding.Density,
AquariumFractalPayloadKind.Extinction => AquariumFieldEncoding.Extinction,
AquariumFractalPayloadKind.Material => AquariumFieldEncoding.Material,
_ => throw new ArgumentOutOfRangeException(nameof(payloadKind), payloadKind, "Unknown fractal payload kind."),
};
}
private static float StableUnit(string tags, int seed)
{
var hash = unchecked((uint)seed);
foreach (var ch in tags)
{
hash ^= ch;
hash *= 16777619u;
}
return (hash & 0x00FF_FFFFu) / 16777215.0f;
}
}

View file

@ -1,135 +0,0 @@
namespace Aquarium.Engine.Fractal.Lod;
public enum FractalGpuReservoirPassKind
{
SdfEnvelope,
PbrMaterial,
Radiosity,
}
public readonly record struct FractalGpuReservoirPassPlan(
FractalGpuReservoirPassKind Kind,
AquariumFieldLayer Layer,
AquariumFieldEncoding Encoding,
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,
LayerFor(kind),
EncodingFor(kind),
splatCount,
reservoirUpdatesPerPass,
candidatesPerUpdate,
expectedCoverageFrames,
checked((ulong)splatCount * (ulong)reservoirStrideBytes),
checked((ulong)reservoirUpdatesPerPass * (ulong)candidatesPerUpdate));
}
}
private static AquariumFieldLayer LayerFor(FractalGpuReservoirPassKind kind)
{
return kind switch
{
FractalGpuReservoirPassKind.SdfEnvelope => AquariumFieldLayer.Form,
FractalGpuReservoirPassKind.PbrMaterial => AquariumFieldLayer.Appearance,
FractalGpuReservoirPassKind.Radiosity => AquariumFieldLayer.Transport,
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown reservoir pass kind."),
};
}
private static AquariumFieldEncoding EncodingFor(FractalGpuReservoirPassKind kind)
{
return kind switch
{
FractalGpuReservoirPassKind.SdfEnvelope => AquariumFieldEncoding.SignedDistance,
FractalGpuReservoirPassKind.PbrMaterial => AquariumFieldEncoding.Material,
FractalGpuReservoirPassKind.Radiosity => AquariumFieldEncoding.Radiance,
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown reservoir pass kind."),
};
}
}

View file

@ -1,95 +0,0 @@
using System.Runtime.InteropServices;
namespace Aquarium.Engine.Fractal.Lod;
public sealed unsafe class FractalNativeBuffer<T> : IDisposable
where T : unmanaged
{
private readonly nuint byteCount;
private bool disposed;
public FractalNativeBuffer(int length, nuint alignment = 64)
{
if (length < 0)
{
throw new ArgumentOutOfRangeException(nameof(length), length, "Native buffer length must not be negative.");
}
if (alignment == 0 || (alignment & (alignment - 1)) != 0)
{
throw new ArgumentOutOfRangeException(nameof(alignment), alignment, "Native buffer alignment must be a power of two.");
}
Length = length;
byteCount = checked((nuint)length * (nuint)sizeof(T));
Pointer = byteCount == 0
? null
: (T*)NativeMemory.AlignedAlloc(byteCount, alignment);
if (Pointer is null && byteCount > 0)
{
throw new OutOfMemoryException($"Could not allocate {byteCount} bytes for native buffer.");
}
if (Pointer is not null)
{
NativeMemory.Clear(Pointer, byteCount);
}
}
public int Length { get; }
public T* Pointer { get; private set; }
public Span<T> Span
{
get
{
ThrowIfDisposed();
return new Span<T>(Pointer, Length);
}
}
public ref T this[int index]
{
get
{
ThrowIfDisposed();
if ((uint)index >= (uint)Length)
{
throw new ArgumentOutOfRangeException(nameof(index), index, "Native buffer index is outside the allocated range.");
}
return ref Pointer[index];
}
}
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
if (Pointer is not null)
{
NativeMemory.AlignedFree(Pointer);
Pointer = null;
}
GC.SuppressFinalize(this);
}
~FractalNativeBuffer()
{
Dispose();
}
private void ThrowIfDisposed()
{
if (disposed)
{
throw new ObjectDisposedException(nameof(FractalNativeBuffer<T>));
}
}
}

View file

@ -1,107 +0,0 @@
using Aquarium.Engine.Fractal;
namespace Aquarium.Engine.Fractal.Lod;
public readonly record struct FractalOccupancyNodeState(
AquariumContributionState Contribution,
uint LastVisibleFrame,
float LastObservedScore,
double UpdateProbability);
public sealed class FractalOccupancyGraph
{
private const float ConfidenceDecayPerFrame = 0.985f;
private const float StaleVarianceGrowth = 0.035f;
private readonly Dictionary<string, FractalOccupancyNodeState> nodes = new(StringComparer.Ordinal);
public FractalOccupancyNodeState GetOrCreate(AquariumFractalKey nodeKey)
{
if (nodes.TryGetValue(nodeKey.Value, out var state))
{
return state;
}
state = new FractalOccupancyNodeState(
CreateEmptyContributionState(nodeKey),
LastVisibleFrame: 0,
LastObservedScore: 0.0f,
UpdateProbability: 0.0);
nodes.Add(nodeKey.Value, state);
return state;
}
public bool TryGet(AquariumFractalKey nodeKey, out FractalOccupancyNodeState state)
{
return nodes.TryGetValue(nodeKey.Value, out state);
}
public void MarkVisible(AquariumFractalKey nodeKey, float observedScore, uint frameIndex, double updateProbability)
{
var state = GetOrCreate(nodeKey);
nodes[nodeKey.Value] = state with
{
LastVisibleFrame = frameIndex,
LastObservedScore = MathF.Max(observedScore, 0.0f),
UpdateProbability = Math.Clamp(updateProbability, 0.0, 1.0),
};
}
public AquariumContributionState Observe(AquariumFractalKey nodeKey, float observedContribution, uint frameIndex, bool resident)
{
var state = GetOrCreate(nodeKey);
var contribution = FractalContributionEstimator.Observe(
state.Contribution,
observedContribution,
frameIndex,
resident);
nodes[nodeKey.Value] = state with
{
Contribution = contribution,
LastVisibleFrame = frameIndex,
LastObservedScore = MathF.Max(observedContribution, 0.0f),
};
return contribution;
}
public void DecayUnobserved(uint frameIndex)
{
foreach (var key in nodes.Keys.ToArray())
{
var state = nodes[key];
if (state.Contribution.LastSampledFrame == frameIndex)
{
continue;
}
var age = frameIndex >= state.Contribution.LastSampledFrame
? frameIndex - state.Contribution.LastSampledFrame
: 0;
if (age == 0)
{
continue;
}
var staleGrowth = MathF.Min(age * StaleVarianceGrowth, 4.0f);
nodes[key] = state with
{
Contribution = state.Contribution with
{
Confidence = MathF.Max(0.0f, state.Contribution.Confidence * ConfidenceDecayPerFrame),
Variance = MathF.Max(state.Contribution.Variance, state.Contribution.Variance + staleGrowth),
},
};
}
}
private static AquariumContributionState CreateEmptyContributionState(AquariumFractalKey nodeKey)
{
return new AquariumContributionState(
nodeKey,
MeanContribution: 0.0f,
Variance: 1.0f,
Confidence: 0.0f,
SampleCount: 0,
LastSampledFrame: 0,
Resident: false);
}
}

View file

@ -1,152 +0,0 @@
using System.Numerics;
using Aquarium.Engine.Fractal.Grammar;
using Aquarium.Engine.Fractal.Temporal;
namespace Aquarium.Engine.Fractal.Lod;
public readonly record struct FractalProbeSample(
AquariumFractalKey DomainKey,
AquariumFractalKey NodeKey,
Vector3 LocalCenter,
float BoundRadius,
float TargetContribution,
float SourcePdf,
float MaterialDelta,
int PayloadHandle,
AquariumFieldLayer Layer = AquariumFieldLayer.Form,
AquariumFieldEncoding Encoding = AquariumFieldEncoding.SignedDistance)
{
public ResampledImportanceCandidate<FractalProbeSample> ToReservoirCandidate()
{
return new ResampledImportanceCandidate<FractalProbeSample>(
this,
MathF.Max(TargetContribution, 0.0f),
MathF.Max(SourcePdf, 1.0e-6f));
}
}
public enum FractalProbeReuseRejection
{
None,
UnknownDomain,
DifferentLineage,
ExcessiveLocalShift,
InvalidBounds,
FieldMismatch,
ExcessiveCameraMotion,
Disoccluded,
MaterialMismatch,
NotVisible,
}
public readonly record struct FractalProbeReuseResult(
bool CanReuse,
FractalProbeReuseRejection Rejection,
float LocalShift);
public readonly record struct FractalProbeTemporalValidation(
float CameraMotionPixels,
float MaxCameraMotionPixels,
float DisocclusionConfidence,
float MinDisocclusionConfidence,
float MaterialDelta,
float MaxMaterialDelta,
float VisibilityConfidence,
float MinVisibilityConfidence);
public static class FractalProbeReuseValidator
{
public static FractalProbeReuseResult Validate(
FractalProbeSample source,
FractalProbeSample target,
FractalDomainGraph domains,
float maxLocalShift)
{
ArgumentNullException.ThrowIfNull(domains);
if (maxLocalShift < 0.0f)
{
throw new ArgumentOutOfRangeException(nameof(maxLocalShift), maxLocalShift, "Maximum local shift must not be negative.");
}
if (!float.IsFinite(source.BoundRadius) || !float.IsFinite(target.BoundRadius) || source.BoundRadius <= 0.0f || target.BoundRadius <= 0.0f)
{
return new FractalProbeReuseResult(false, FractalProbeReuseRejection.InvalidBounds, 0.0f);
}
if (!domains.Contains(source.DomainKey) || !domains.Contains(target.DomainKey))
{
return new FractalProbeReuseResult(false, FractalProbeReuseRejection.UnknownDomain, 0.0f);
}
if (source.Layer != target.Layer || source.Encoding != target.Encoding)
{
return new FractalProbeReuseResult(false, FractalProbeReuseRejection.FieldMismatch, 0.0f);
}
if (!ShareLineage(source.DomainKey, target.DomainKey, domains))
{
return new FractalProbeReuseResult(false, FractalProbeReuseRejection.DifferentLineage, 0.0f);
}
var localShift = Vector3.Distance(source.LocalCenter, target.LocalCenter);
var allowedShift = MathF.Min(maxLocalShift, source.BoundRadius + target.BoundRadius);
if (!float.IsFinite(localShift) || localShift > allowedShift)
{
return new FractalProbeReuseResult(false, FractalProbeReuseRejection.ExcessiveLocalShift, localShift);
}
return new FractalProbeReuseResult(true, FractalProbeReuseRejection.None, localShift);
}
public static FractalProbeReuseResult ValidateTemporal(
FractalProbeSample source,
FractalProbeSample target,
FractalDomainGraph domains,
float maxLocalShift,
FractalProbeTemporalValidation temporal)
{
var spatial = Validate(source, target, domains, maxLocalShift);
if (!spatial.CanReuse)
{
return spatial;
}
if (!float.IsFinite(temporal.CameraMotionPixels) || temporal.CameraMotionPixels > MathF.Max(temporal.MaxCameraMotionPixels, 0.0f))
{
return spatial with { CanReuse = false, Rejection = FractalProbeReuseRejection.ExcessiveCameraMotion };
}
if (!float.IsFinite(temporal.DisocclusionConfidence) || temporal.DisocclusionConfidence < Math.Clamp(temporal.MinDisocclusionConfidence, 0.0f, 1.0f))
{
return spatial with { CanReuse = false, Rejection = FractalProbeReuseRejection.Disoccluded };
}
if (!float.IsFinite(temporal.MaterialDelta) || temporal.MaterialDelta > MathF.Max(temporal.MaxMaterialDelta, 0.0f))
{
return spatial with { CanReuse = false, Rejection = FractalProbeReuseRejection.MaterialMismatch };
}
if (!float.IsFinite(temporal.VisibilityConfidence) || temporal.VisibilityConfidence < Math.Clamp(temporal.MinVisibilityConfidence, 0.0f, 1.0f))
{
return spatial with { CanReuse = false, Rejection = FractalProbeReuseRejection.NotVisible };
}
return spatial;
}
private static bool ShareLineage(AquariumFractalKey sourceDomain, AquariumFractalKey targetDomain, FractalDomainGraph domains)
{
var sourcePath = domains.GetPath(sourceDomain).Select(domain => domain.Key).ToArray();
var targetPath = domains.GetPath(targetDomain).Select(domain => domain.Key).ToArray();
var shorterLength = Math.Min(sourcePath.Length, targetPath.Length);
for (var index = 0; index < shorterLength; index++)
{
if (!sourcePath[index].Equals(targetPath[index]))
{
return false;
}
}
return shorterLength == sourcePath.Length || shorterLength == targetPath.Length;
}
}

View file

@ -1,17 +0,0 @@
using Aquarium.Engine.Fractal;
namespace Aquarium.Engine.Fractal.Lod;
public static class FractalProjectedErrorScorer
{
public static float Score(AquariumFractalSummary summary, float projectedPixelsPerWorldUnit)
{
if (projectedPixelsPerWorldUnit <= 0.0f || summary.MaxHeightError <= 0.0f)
{
return 0.0f;
}
var cost = MathF.Max(summary.EstimatedCost, 1.0f);
return summary.MaxHeightError * projectedPixelsPerWorldUnit / cost;
}
}

Some files were not shown because too many files have changed in this diff Show more