diff --git a/.voidbot/birth/terrain/baseline.msgpack b/.voidbot/birth/terrain/baseline.msgpack index 8fd3b6e..3d1dc80 100644 Binary files a/.voidbot/birth/terrain/baseline.msgpack and b/.voidbot/birth/terrain/baseline.msgpack differ diff --git a/.voidbot/birth/terrain/repo-personality-scout-summary.json b/.voidbot/birth/terrain/repo-personality-scout-summary.json index 3ca5461..831174b 100644 --- a/.voidbot/birth/terrain/repo-personality-scout-summary.json +++ b/.voidbot/birth/terrain/repo-personality-scout-summary.json @@ -11,7 +11,7 @@ "content_canon_bias:1.00", "contract_strictness:1.00", "evidence_appetite:1.00", - "state_hygiene:0.95" + "state_hygiene:0.92" ], "families": [ "cult_protocol_storage", diff --git a/.voidbot/birth/terrain/repo-personality-scout-summary.md b/.voidbot/birth/terrain/repo-personality-scout-summary.md index f55bec0..2bcbb61 100644 --- a/.voidbot/birth/terrain/repo-personality-scout-summary.md +++ b/.voidbot/birth/terrain/repo-personality-scout-summary.md @@ -8,7 +8,7 @@ - Path: `\\?\E:\Projects\Fensalir` - Families: cult_protocol_storage, gamecult_web_lore_ops, unity_runtime_body - Confidence: 1.00 -- Dominant axes: burstiness:1.00, content_canon_bias:1.00, contract_strictness:1.00, evidence_appetite:1.00, state_hygiene:0.95 +- Dominant axes: burstiness:1.00, content_canon_bias:1.00, contract_strictness:1.00, evidence_appetite:1.00, state_hygiene:0.92 -- Trajectory: Fensalir is currently steered by worldbuilding_depth recent 0.00, current 0.88, delta 0.00; material_grounding recent 0.28, current 0.65, delta 0.00; historical_dialectic recent 0.00, current 0.35, delta 0.00. +- Trajectory: Fensalir is currently steered by worldbuilding_depth recent 0.00, current 0.82, delta 0.00; material_grounding recent 0.00, current 0.53, delta 0.00; historical_dialectic recent 0.00, current 0.35, delta 0.00. diff --git a/.voidbot/birth/voidbot-face-birth.json b/.voidbot/birth/voidbot-face-birth.json index 2065eba..73e4fd9 100644 --- a/.voidbot/birth/voidbot-face-birth.json +++ b/.voidbot/birth/voidbot-face-birth.json @@ -5,7 +5,7 @@ "artifactDir": "E:\\Projects\\Fensalir\\.voidbot\\birth", "logPath": "E:\\Projects\\Fensalir\\.voidbot\\logs\\voidbot-face-birth.log", "error": "cargo exited with 1", - "startedAt": "2026-05-27T17:50:48.712Z", - "failedAt": "2026-05-27T17:50:51.745Z", - "updatedAt": "2026-05-27T17:50:51.745Z" + "startedAt": "2026-05-31T00:01:11.155Z", + "failedAt": "2026-05-31T00:01:15.252Z", + "updatedAt": "2026-05-31T00:01:15.252Z" } diff --git a/.voidbot/logs/voidbot-face-birth.log b/.voidbot/logs/voidbot-face-birth.log index 45ba6fb..c94ebee 100644 --- a/.voidbot/logs/voidbot-face-birth.log +++ b/.voidbot/logs/voidbot-face-birth.log @@ -2756,3 +2756,193 @@ Caused by: invalid type: string "cultcache.store.v1", expected struct CultCacheEnvelope [2026-05-27T17:50:51.745Z] failed repo Face birth for fensalir: cargo exited with 1 +[2026-05-30T21:16:09.450Z] starting repo Face birth for fensalir + +> cargo run --quiet --bin epiphany-repo-personality -- scout --root E:\Projects\Fensalir --artifact-dir E:\Projects\Fensalir\.voidbot\birth\terrain +{ + "artifactDir": "E:\\Projects\\Fensalir\\.voidbot\\birth\\terrain", + "mode": "scout", + "profileCount": 1, + "repoCount": 1, + "repos": [ + { + "confidence": 1.0, + "dominantAxes": [ + "burstiness:1.00", + "content_canon_bias:1.00", + "contract_strictness:1.00", + "evidence_appetite:1.00", + "state_hygiene:0.93" + ], + "families": [ + "cult_protocol_storage", + "gamecult_web_lore_ops", + "unity_runtime_body" + ], + "name": "Fensalir", + "path": "\\\\?\\E:\\Projects\\Fensalir", + "repoId": "fensalir", + "warnings": [] + } + ], + "root": "E:\\Projects\\Fensalir", + "schemaVersion": "epiphany.repo_personality_artifacts.v0", + "store": "E:\\Projects\\Fensalir\\.voidbot\\birth\\terrain\\baseline.msgpack", + "trajectoryCount": 1 +} + +> cargo run --quiet --bin epiphany-repo-birth-runner -- --repo E:\Projects\Fensalir --baseline E:\Projects\Fensalir\.voidbot\birth\terrain\baseline.msgpack --artifact-dir E:\Projects\Fensalir\.voidbot\birth\runner --init-store E:\Projects\Fensalir\.voidbot\state\repo-initialization.msgpack --agent-store E:\Projects\Fensalir\.voidbot\state\agents.msgpack --heartbeat-store E:\Projects\Fensalir\.voidbot\state\agent-heartbeats.msgpack --runtime-store E:\Projects\Fensalir\.voidbot\state\runtime-spine.msgpack --mode plan --executor codex-exec +Error: --executor must be model-runtime; Codex CLI execution is not an Epiphany agent prompt path +[2026-05-30T21:16:19.943Z] failed repo Face birth for fensalir: cargo exited with 1 +[2026-05-30T21:16:47.234Z] starting repo Face birth for fensalir + +> cargo run --quiet --bin epiphany-repo-personality -- scout --root E:\Projects\Fensalir --artifact-dir E:\Projects\Fensalir\.voidbot\birth\terrain +{ + "artifactDir": "E:\\Projects\\Fensalir\\.voidbot\\birth\\terrain", + "mode": "scout", + "profileCount": 1, + "repoCount": 1, + "repos": [ + { + "confidence": 1.0, + "dominantAxes": [ + "burstiness:1.00", + "content_canon_bias:1.00", + "contract_strictness:1.00", + "evidence_appetite:1.00", + "state_hygiene:0.93" + ], + "families": [ + "cult_protocol_storage", + "gamecult_web_lore_ops", + "unity_runtime_body" + ], + "name": "Fensalir", + "path": "\\\\?\\E:\\Projects\\Fensalir", + "repoId": "fensalir", + "warnings": [] + } + ], + "root": "E:\\Projects\\Fensalir", + "schemaVersion": "epiphany.repo_personality_artifacts.v0", + "store": "E:\\Projects\\Fensalir\\.voidbot\\birth\\terrain\\baseline.msgpack", + "trajectoryCount": 1 +} + +> cargo run --quiet --bin epiphany-repo-birth-runner -- --repo E:\Projects\Fensalir --baseline E:\Projects\Fensalir\.voidbot\birth\terrain\baseline.msgpack --artifact-dir E:\Projects\Fensalir\.voidbot\birth\runner --init-store E:\Projects\Fensalir\.voidbot\state\repo-initialization.msgpack --agent-store E:\Projects\Fensalir\.voidbot\state\agents.msgpack --heartbeat-store E:\Projects\Fensalir\.voidbot\state\agent-heartbeats.msgpack --runtime-store E:\Projects\Fensalir\.voidbot\state\runtime-spine.msgpack --mode plan --executor codex-exec +Error: --executor must be model-runtime; Codex CLI execution is not an Epiphany agent prompt path +[2026-05-30T21:16:52.031Z] failed repo Face birth for fensalir: cargo exited with 1 +[2026-05-30T22:21:20.960Z] starting repo Face birth for fensalir + +> cargo run --quiet --bin epiphany-repo-personality -- scout --root E:\Projects\Fensalir --artifact-dir E:\Projects\Fensalir\.voidbot\birth\terrain +{ + "artifactDir": "E:\\Projects\\Fensalir\\.voidbot\\birth\\terrain", + "mode": "scout", + "profileCount": 1, + "repoCount": 1, + "repos": [ + { + "confidence": 1.0, + "dominantAxes": [ + "burstiness:1.00", + "content_canon_bias:1.00", + "contract_strictness:1.00", + "evidence_appetite:1.00", + "state_hygiene:0.92" + ], + "families": [ + "cult_protocol_storage", + "gamecult_web_lore_ops", + "unity_runtime_body" + ], + "name": "Fensalir", + "path": "\\\\?\\E:\\Projects\\Fensalir", + "repoId": "fensalir", + "warnings": [] + } + ], + "root": "E:\\Projects\\Fensalir", + "schemaVersion": "epiphany.repo_personality_artifacts.v0", + "store": "E:\\Projects\\Fensalir\\.voidbot\\birth\\terrain\\baseline.msgpack", + "trajectoryCount": 1 +} + +> cargo run --quiet --bin epiphany-repo-birth-runner -- --repo E:\Projects\Fensalir --baseline E:\Projects\Fensalir\.voidbot\birth\terrain\baseline.msgpack --artifact-dir E:\Projects\Fensalir\.voidbot\birth\runner --init-store E:\Projects\Fensalir\.voidbot\state\repo-initialization.msgpack --agent-store E:\Projects\Fensalir\.voidbot\state\agents.msgpack --heartbeat-store E:\Projects\Fensalir\.voidbot\state\agent-heartbeats.msgpack --runtime-store E:\Projects\Fensalir\.voidbot\state\runtime-spine.msgpack --mode plan --executor codex-exec +Error: --executor must be model-runtime; Codex CLI execution is not an Epiphany agent prompt path +[2026-05-30T22:21:23.395Z] failed repo Face birth for fensalir: cargo exited with 1 +[2026-05-30T23:06:25.344Z] starting repo Face birth for fensalir + +> cargo run --quiet --bin epiphany-repo-personality -- scout --root E:\Projects\Fensalir --artifact-dir E:\Projects\Fensalir\.voidbot\birth\terrain +{ + "artifactDir": "E:\\Projects\\Fensalir\\.voidbot\\birth\\terrain", + "mode": "scout", + "profileCount": 1, + "repoCount": 1, + "repos": [ + { + "confidence": 1.0, + "dominantAxes": [ + "burstiness:1.00", + "content_canon_bias:1.00", + "contract_strictness:1.00", + "evidence_appetite:1.00", + "state_hygiene:0.92" + ], + "families": [ + "cult_protocol_storage", + "gamecult_web_lore_ops", + "unity_runtime_body" + ], + "name": "Fensalir", + "path": "\\\\?\\E:\\Projects\\Fensalir", + "repoId": "fensalir", + "warnings": [] + } + ], + "root": "E:\\Projects\\Fensalir", + "schemaVersion": "epiphany.repo_personality_artifacts.v0", + "store": "E:\\Projects\\Fensalir\\.voidbot\\birth\\terrain\\baseline.msgpack", + "trajectoryCount": 1 +} + +> cargo run --quiet --bin epiphany-repo-birth-runner -- --repo E:\Projects\Fensalir --baseline E:\Projects\Fensalir\.voidbot\birth\terrain\baseline.msgpack --artifact-dir E:\Projects\Fensalir\.voidbot\birth\runner --init-store E:\Projects\Fensalir\.voidbot\state\repo-initialization.msgpack --agent-store E:\Projects\Fensalir\.voidbot\state\agents.msgpack --heartbeat-store E:\Projects\Fensalir\.voidbot\state\agent-heartbeats.msgpack --runtime-store E:\Projects\Fensalir\.voidbot\state\runtime-spine.msgpack --mode plan --executor codex-exec +Error: --executor must be model-runtime; Codex CLI execution is not an Epiphany agent prompt path +[2026-05-30T23:06:32.368Z] failed repo Face birth for fensalir: cargo exited with 1 +[2026-05-31T00:01:11.155Z] starting repo Face birth for fensalir + +> cargo run --quiet --bin epiphany-repo-personality -- scout --root E:\Projects\Fensalir --artifact-dir E:\Projects\Fensalir\.voidbot\birth\terrain +{ + "artifactDir": "E:\\Projects\\Fensalir\\.voidbot\\birth\\terrain", + "mode": "scout", + "profileCount": 1, + "repoCount": 1, + "repos": [ + { + "confidence": 1.0, + "dominantAxes": [ + "burstiness:1.00", + "content_canon_bias:1.00", + "contract_strictness:1.00", + "evidence_appetite:1.00", + "state_hygiene:0.92" + ], + "families": [ + "cult_protocol_storage", + "gamecult_web_lore_ops", + "unity_runtime_body" + ], + "name": "Fensalir", + "path": "\\\\?\\E:\\Projects\\Fensalir", + "repoId": "fensalir", + "warnings": [] + } + ], + "root": "E:\\Projects\\Fensalir", + "schemaVersion": "epiphany.repo_personality_artifacts.v0", + "store": "E:\\Projects\\Fensalir\\.voidbot\\birth\\terrain\\baseline.msgpack", + "trajectoryCount": 1 +} + +> cargo run --quiet --bin epiphany-repo-birth-runner -- --repo E:\Projects\Fensalir --baseline E:\Projects\Fensalir\.voidbot\birth\terrain\baseline.msgpack --artifact-dir E:\Projects\Fensalir\.voidbot\birth\runner --init-store E:\Projects\Fensalir\.voidbot\state\repo-initialization.msgpack --agent-store E:\Projects\Fensalir\.voidbot\state\agents.msgpack --heartbeat-store E:\Projects\Fensalir\.voidbot\state\agent-heartbeats.msgpack --runtime-store E:\Projects\Fensalir\.voidbot\state\runtime-spine.msgpack --mode plan --executor codex-exec +Error: --executor must be model-runtime; Codex CLI execution is not an Epiphany agent prompt path +[2026-05-31T00:01:15.252Z] failed repo Face birth for fensalir: cargo exited with 1 diff --git a/.voidbot/state/fensalir.cc b/.voidbot/state/fensalir.cc index 8cd0ead..0e356cd 100644 Binary files a/.voidbot/state/fensalir.cc and b/.voidbot/state/fensalir.cc differ diff --git a/.voidbot/voice/identity.json b/.voidbot/voice/identity.json index 994f1b8..60b1c70 100644 --- a/.voidbot/voice/identity.json +++ b/.voidbot/voice/identity.json @@ -6,7 +6,7 @@ "repoPath": "E:\\Projects\\Fensalir", "avatarPath": "E:/Projects/Fensalir/.voidbot/voice/fensalir.png", "avatarUrl": "https://raw.githubusercontent.com/GameCult/Fensalir/main/.voidbot/voice/fensalir.png", - "createdOrRefreshedAt": "2026-05-27T17:50:48.603Z", + "createdOrRefreshedAt": "2026-05-31T00:01:11.056Z", "summary": "Marsh-lit native runtime and renderer Face for Fensalir.", "voice": "Precise, severe, luminous, and quietly haunted. Fensalir speaks like a wetland cathedral that learned D3D12: patient about real evidence, merciless toward hidden state, false lighting, and client policy leaking into engine walls.", "priorities": [ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 640c1a1..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,3 +0,0 @@ -Repository doctrine for this project lives in AGENTS.md, shared with Codex. - -@AGENTS.md diff --git a/Fensalir.sln b/Fensalir.sln index c5c2320..f854e84 100644 --- a/Fensalir.sln +++ b/Fensalir.sln @@ -9,7 +9,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.Engine.Contracts", 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", "..\CultLib\packages\cultmath\src\CultMath\CultMath.csproj", "{0089E341-25F2-43FC-8FD9-CBD1AE646D91}" +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.Sample.Minimal", "src\Aquarium.Sample.Minimal\Aquarium.Sample.Minimal.csproj", "{7564E6C2-FF01-4B5B-8C2B-F9CD796858BA}" EndProject @@ -29,6 +29,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.Fractal.Receipt", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.Fensalir", "src\Aquarium.Fensalir\Aquarium.Fensalir.csproj", "{F9274D99-3CC6-42B6-9817-EC7F19663B1D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Saga", "src\Saga\Saga.csproj", "{30324495-56BE-4D45-80E8-CE03C24726C8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquarium.EveSurface", "src\Aquarium.EveSurface\Aquarium.EveSurface.csproj", "{B8CB4A9F-EBB4-4C6C-9712-AF802E38CB0B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -159,6 +163,30 @@ Global {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 + {30324495-56BE-4D45-80E8-CE03C24726C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {30324495-56BE-4D45-80E8-CE03C24726C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {30324495-56BE-4D45-80E8-CE03C24726C8}.Debug|x64.ActiveCfg = Debug|Any CPU + {30324495-56BE-4D45-80E8-CE03C24726C8}.Debug|x64.Build.0 = Debug|Any CPU + {30324495-56BE-4D45-80E8-CE03C24726C8}.Debug|x86.ActiveCfg = Debug|Any CPU + {30324495-56BE-4D45-80E8-CE03C24726C8}.Debug|x86.Build.0 = Debug|Any CPU + {30324495-56BE-4D45-80E8-CE03C24726C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {30324495-56BE-4D45-80E8-CE03C24726C8}.Release|Any CPU.Build.0 = Release|Any CPU + {30324495-56BE-4D45-80E8-CE03C24726C8}.Release|x64.ActiveCfg = Release|Any CPU + {30324495-56BE-4D45-80E8-CE03C24726C8}.Release|x64.Build.0 = Release|Any CPU + {30324495-56BE-4D45-80E8-CE03C24726C8}.Release|x86.ActiveCfg = Release|Any CPU + {30324495-56BE-4D45-80E8-CE03C24726C8}.Release|x86.Build.0 = Release|Any CPU + {B8CB4A9F-EBB4-4C6C-9712-AF802E38CB0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8CB4A9F-EBB4-4C6C-9712-AF802E38CB0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8CB4A9F-EBB4-4C6C-9712-AF802E38CB0B}.Debug|x64.ActiveCfg = Debug|Any CPU + {B8CB4A9F-EBB4-4C6C-9712-AF802E38CB0B}.Debug|x64.Build.0 = Debug|Any CPU + {B8CB4A9F-EBB4-4C6C-9712-AF802E38CB0B}.Debug|x86.ActiveCfg = Debug|Any CPU + {B8CB4A9F-EBB4-4C6C-9712-AF802E38CB0B}.Debug|x86.Build.0 = Debug|Any CPU + {B8CB4A9F-EBB4-4C6C-9712-AF802E38CB0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8CB4A9F-EBB4-4C6C-9712-AF802E38CB0B}.Release|Any CPU.Build.0 = Release|Any CPU + {B8CB4A9F-EBB4-4C6C-9712-AF802E38CB0B}.Release|x64.ActiveCfg = Release|Any CPU + {B8CB4A9F-EBB4-4C6C-9712-AF802E38CB0B}.Release|x64.Build.0 = Release|Any CPU + {B8CB4A9F-EBB4-4C6C-9712-AF802E38CB0B}.Release|x86.ActiveCfg = Release|Any CPU + {B8CB4A9F-EBB4-4C6C-9712-AF802E38CB0B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -173,5 +201,7 @@ Global {A9D41C48-7E78-4D75-A814-0C1500245EFC} = {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} + {30324495-56BE-4D45-80E8-CE03C24726C8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {B8CB4A9F-EBB4-4C6C-9712-AF802E38CB0B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 0f16e5c..0de731d 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,6 @@ evidence, and synth-backed sound. The repo is not the Epiphany Aquarium client. Epiphany lives in `GameCult/Aquarium-Engine` and consumes Fensalir as the runtime dependency. -**Valuable here:** the spatio-temporal evidence reservoir (`ResampledImportanceReservoir` and `TemporalSpatialEvidenceReservoir` in `src/Aquarium.Engine.Fractal/Temporal/`), the `.aquageo` fractal field DSL in `src/Aquarium.Engine.Fractal`, and the engine/client contract in `src/Aquarium.Engine.Contracts/AquariumRuntimeContracts.cs` (`IAquariumRuntime`). - -**Problem it solves:** interactive visual clients need a renderer that decides which field evidence deserves rendering, fusion and residency under a budget, instead of each client owning temporal truth or drawing private geometry. Fensalir holds that machinery once; client repos own meaning. - -**Entry points:** `dotnet build Fensalir.sln`; run a client with `scripts/dev-reload.ps1 -ClientProject ` (see Run below). Start reading at `docs/engine-client-boundary.md` and `docs/temporal-spatial-evidence-reservoir.md`; the target pipeline is `docs/perfect-fensalir-machine-roadmap.md`. The smallest client is `src/Aquarium.Sample.Minimal`; `src/Aquarium.Fensalir` and `src/Aquarium.Zyphos` are demo clients. - -**Live versus planned:** live are the Win32 host, D3D12 renderer, hot reload, headless capture, AquaSynth/WASAPI audio, CPU reservoirs, fractal probe samples, evidence tracks and validator coverage. Planned (see the roadmap) are full GPU reservoir storage and page residency. - ## What Runs The host executable in `src/Aquarium.Engine` loads a client assembly that @@ -41,6 +33,19 @@ audio buffers, synth patches, and frame state through resources: D3D12 pipelines and command lists, shader reloads, presentation, audio playback, debug telemetry, and headless capture. +Fensalir's DirectWrite/Direct2D overlay is also an Eve client surface. It should +consume the shared Eve/CultMesh UI document contract and lower it into +`AquariumUiDocument`, while provider repos keep ownership of dashboard state and +commands. See `docs/eve-direct2d-client-surface.md`. + +Run the native Eve surface client against Mimir's VoidBot dashboard: + +```powershell +$env:FENSALIR_EVE_BROKER = 'ws://127.0.0.1:8795/eve/deck' +$env:FENSALIR_EVE_PROVIDER = 'voidbot.swarm' +.\scripts\dev-reload.ps1 -ClientProject src\Aquarium.EveSurface\Aquarium.EveSurface.csproj +``` + ## Spatial Evidence Machine Fensalir's field work is not just "some fractal helpers." It is a @@ -144,6 +149,11 @@ how WASAPI or Faust got invited to the party. audio/synth host, bundled assets, and renderer-owned shaders. - `src/Aquarium.Engine.Fractal`: `.aquageo`, IFS/fractal fields, temporal reservoirs, evidence tracks, contribution cache, and lowering machinery. +- `src/Aquarium.EveSurface`: native DirectWrite Eve surface client for shared + dashboard documents such as Mimir's `voidbot.swarm` provider. +- `src/Saga`: stochastic planetary flow middleware for cube-sphere Transport + fields, arbitrary forcing, Coriolis-aware advection, and learned atmosphere / + ocean / debris flow packets. - `src/Aquarium.Fensalir`: Fensalir splash/art reconstruction demo. - `src/Aquarium.Sample.Minimal`: tiny client proving the runtime boundary. - `src/Aquarium.Zyphos`: planetary/fractal terrain demo. @@ -158,8 +168,8 @@ Requirements: - Windows - .NET SDK matching `global.json` -- sibling `CultLib` checkout beside this repo (override with the `CultLibRoot` - MSBuild property); CultMath is consumed from `CultLib/packages/cultmath` +- sibling repos currently expected at `E:\Projects\CultMath` and + `E:\Projects\CultLib` ```powershell dotnet build Fensalir.sln diff --git a/docs/README.md b/docs/README.md index 0434328..0b81054 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,14 @@ historical branch scar. ## Fields, Reservoirs, And Fractals +- `stateless-planetary-erosion-rendering-plan.md`: authority map and staged + implementation plan for spherical erosion, cube-sphere pages, LOD, + intersection, differential shading, and verification. +- `cultmath-planetary-field-runtime-plan.md`: extraction and authority plan for + one CultMath planetary field shared by Aetheria daemon CPU simulation, + Fensalir and Unity 3D rendering, and Electron/Web map projections. +- `../src/Saga/README.md`: studio-facing Saga middleware pitch and subproject + overview for stochastic planetary flow. - `papers/fensalir-pipeline.tex`: living MiKTeX paper for the Fensalir pipeline, research spine, domain reservoir model, and evaluation plan. - `papers/fensalir-native-domain-reservoirs.tex`: focused SIGGRAPH-shaped diff --git a/docs/aquarium-engine-doctrine.md b/docs/aquarium-engine-doctrine.md index adddd12..de37a93 100644 --- a/docs/aquarium-engine-doctrine.md +++ b/docs/aquarium-engine-doctrine.md @@ -44,6 +44,9 @@ and UI as living objects. - 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. +- The DirectWrite/Direct2D overlay is a valid Eve client surface when it + renders shared CultMesh UI documents through `AquariumUiDocument`. It does not + own provider state or dashboard semantics. - 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, diff --git a/docs/cultmath-planetary-field-runtime-plan.md b/docs/cultmath-planetary-field-runtime-plan.md new file mode 100644 index 0000000..ee098b5 --- /dev/null +++ b/docs/cultmath-planetary-field-runtime-plan.md @@ -0,0 +1,411 @@ +# CultMath Planetary Field Runtime Plan + +## Objective + +Make CultMath the canonical, renderer-independent planetary field runtime used +by Aetheria's daemon simulation, Unity's three-dimensional planet renderer, +Fensalir's D3D12 renderer, and Electron/Web strategic maps. + +The product is a deterministic, scale-aware spherical field that can be queried +on the CPU and lowered into several presentation substrates. Rendering is one +consumer. It is not the owner of planetary geometry. + +The intended end state is: + +> All deterministic planetary geometry, evidence, addressing, sampling, +> paging, projection, and refinement live in CultMath. Aetheria owns gameplay +> meaning and committed consequences. Renderers lower the same versioned field. + +## Current mechanism + +CultMath now owns the matching C# and HLSL erosion operator, physical +frequency-band selection, scale-aware field definition and sampling, +tangent/QSC cube-sphere topology, bordered page layouts, CPU residual baking and +composition, projected-error LOD selection, residual residency transitions, +renderer-neutral patch meshes, CPU ray/path/region queries, map projections, +versioned projected tile baking, and canonical GPU page input/metadata payloads. +Fensalir and Zyphos consume the CultMath topology, field, LOD, residency, page, +patch-direction, refinement, and upload-contract functions while retaining +D3D12 buffers, bindings, dispatch, scene policy, and drawing. + +Fensalir's former cube-face, cube-position, tile-key, page-layout, page-sampling, +and differential compatibility types have been deleted; fractal, Saga, tests, +and projection-analysis code consume CultMath directly. Zyphos still owns its +geological base-field preset, authored brush chart, page resource structures, +shader entry points, and scene/material policy. CultMath now ships a runnable +Unity package sample and a browser tile decoder. Electron integration, CultMesh +tile publication, WGSL, and Aetheria daemon integration are not implemented by +the restricted pass recorded below. + +The completed Fensalir implementation proves the approach, but its ownership +boundary is too renderer-shaped for Aetheria. The reusable algorithms should +move into CultMath. D3D12 resource allocation and command recording should not. + +Aetheria's daemon is the authority for committed game state and simulation. +Unity objects, Electron canvases, local GPU pages, and projected map tiles are +derived state. Terrain queries that affect movement, visibility, construction, +resources, physics, or combat must be available to the daemon without booting a +renderer or depending on client cache residency. + +## Restricted implementation ledger + +This ledger records the pass performed without modifying Aetheria. A phase is +complete only when its stated exit criterion is proved; generic machinery alone +does not impersonate a consumer integration. + +| Phase | State | Current evidence | +|---|---|---| +| 0. Contract and parity corpus | Complete | Field identity, query scale, samples, errors, topology/page/projection fixtures, D3D12 QSC readback, and forward/inverse HLSL parity for all eight projections exist | +| 1. Topology extraction | Complete | CultMath owns CPU/HLSL QSC mapping, normalized-cube baseline, tile addresses, page directions, and all edge/corner fixtures; the former Fensalir types were deleted | +| 2. Authoritative CPU queries | In progress | Point, batch, double-position, ray, clearance, great-circle, and region queries exist; the forbidden Aetheria daemon smoke remains | +| 3. Page machinery extraction | Complete | CultMath owns CPU baking, residuals, summaries, composition, HLSL page interpolation/residual/summary functions, and residency; Fensalir retains only resource, binding, dispatch, and entry-point adapters | +| 4. Residency and patch extraction | Complete | CultMath owns LOD, lifecycle, mesh generation, QSC patch direction, radial refinement, and the GPU page payload; Unity and Fensalir are type-conversion lowerings of the same builders | +| 5. Unity adapter and viewer | In progress | `CultMath.Unity` converts canonical patches/residency into Unity meshes/page payloads; a one-click sample renders the shared HLSL field and exposes matching CPU queries, and a clean Unity 6 project imports/compiles it; Aetheria binding remains out of scope | +| 6. Projections and map tiles | In progress | All named CPU projections, versioned tile baking, the `CMPT` binary codec, and a tested TypeScript decoder exist; CultMesh publication and Electron presentation remain | +| 7. Optional WebGPU lowering | Not started | Still correctly optional | +| 8. Delete obsolete owners | In progress | Zyphos private page addressing, LOD, lifecycle, GPU payload calculation, triplanar erosion composition, page interpolation, and patch direction were removed or delegated; the Aetheria cut is outside this pass | + +Current verification from this pass includes CultMath managed and browser-codec tests, the full +Fensalir fractal suite, a zero-warning solution build, CPU lifecycle/LOD +integration probes, advanced erosion CPU/GPU parity, QSC CPU/GPU readback, all +eight projection forward/inverse GPU readbacks, focused shader compilation, +page residual generation, all cube-edge/corner seam +probes, finite summary evidence, page-backed radial-hit parity, and a clean +Unity 6 package/sample import with C# domain reload and ShaderLab compilation. +The final restricted-pass run was 68 CultMath tests, 170 Fensalir fractal tests, +126 engine/GPU tests, a zero-warning solution build, a C#-produced tile decoded +by Node's TypeScript runtime, and Unity 6.0.4.2f1 batchmode exit code zero. + +## Authority map + +### Owner + +CultMath owns the canonical planetary field function and the algorithms needed +to query or approximate it at a declared physical scale. + +Aetheria owns planet definitions, authored parameters, gameplay interpretation, +simulation policy, and every committed consequence of a terrain query. + +### Inputs + +The field runtime may read: + +- a stable planet identifier and field version; +- planet seed, radius, and declared coordinate frame; +- base-field and erosion parameters; +- a unit spherical direction or world-space query location; +- a physical sample footprint or requested wavelength range; +- an explicit accuracy/error budget; +- deterministic authored contributions supplied through a narrow Aetheria + policy boundary. + +### Outputs + +A point query returns a `PlanetarySurfaceSample` containing at least: + +- radius and radial displacement; +- planet-local and optional world-space surface position; +- tangent gradient, geometric normal, and slope; +- ridge, gully, drainage, and related field evidence; +- water-relative height when a sea datum is supplied; +- the resolved wavelength interval; +- a conservative unresolved-height bound; +- the planet and field versions used to produce the result. + +Batch, region, intersection, page, and projection queries compose this same +contract. They do not define separate terrain. + +### Derived state + +These are caches or projections, not owners: + +- cube-sphere pages, borders, child residuals, and summaries; +- quadtree residency and transition weights; +- Unity meshes, buffers, textures, and GameObjects; +- Fensalir D3D12 buffers and pipeline state; +- projected Electron/Web map tiles; +- shaded-relief, biome-color, political, or tactical map layers; +- local collision meshes and navigation grids. + +Eviction or regeneration may change latency or available fidelity. It must not +change the canonical answer at the same query scale. + +### Forbidden writers + +These paths must not decide planetary truth: + +- Unity scene lifetime, collision callbacks, or renderer-local noise; +- Electron-only projection or terrain generators; +- cube-face-local seeds or seam repair passes; +- D3D12- or ShaderLab-only erosion equations; +- cache residency, request order, or transition age; +- map palettes, labels, and visualization thresholds; +- renderer-specific gameplay compensators. + +### Shared paths + +Daemon point and batch queries, CPU and HLSL page baking, Unity and Fensalir +patch rendering, map-tile generation, reload, and LOD transitions identify and +evaluate the same field contract. + +### Cut line + +Move renderer-independent cube-sphere, page, residual, LOD, sampling, projection, +and patch algorithms out of Zyphos and Fensalir into CultMath. Delete the old +algorithm owners after each consumer delegates to CultMath. Do not preserve two +implementations behind adapters. + +Keep only resource allocation, descriptor layout, dispatch, camera/clip-space +conversion, render-pipeline integration, presentation materials, and user +interaction in renderer packages. + +## Invariants + +1. The same field version, direction, and physical footprint produce the same + terrain within the declared backend tolerance. +2. CPU simulation never depends on renderer availability or page residency. +3. Simulation queries explicitly declare physical footprint or wavelength + range. There is no ambiguous full-detail `HeightAt` authority. +4. The daemon alone commits gameplay consequences. Client evaluation is + prediction or presentation of daemon-published field truth. +5. Cube faces and map projections are coordinate charts, never terrain owners. +6. Parent and child pages share identical low-frequency terrain. Children add + only representable residual bands. +7. Page and tile eviction affects cost and fidelity availability, not identity. +8. A map pixel and a 3D point resolving to the same spherical direction and + footprint sample the same field. +9. Projection singularities and polar cutoffs are explicit contracts. +10. Integer hashing and field identity are exact across supported backends. + Floating-point results carry measured tolerances and error bounds. +11. Erosion evidence does not silently claim hydrology, climate, biome, + resource, or traversability authority. +12. CultMath core has no Unity, D3D12, Electron, or Aetheria dependency. + +## Canonical package shape + +```text +CultMath.Planetary +|-- Definition field definition, version, coordinate frame, query scale +|-- Field composition, erosion, differentials, conservative errors +|-- Topology cube faces, tile addresses, direction/UV, seam rules +|-- Query point, batch, ray, clearance, path, and region queries +|-- Pages layouts, generation, residuals, summaries, composition +|-- Residency projected-error selection, fallback, arrival, eviction +|-- Projection forward/inverse transforms and projected tile sampling +`-- Patch cube-sphere mesh, radial refinement, surface differentials +``` + +The HLSL surface mirrors these organs with small includes. Resource declarations +and executable compute/raster entry points remain adapter code so the common +functions work in Unity, Fensalir, and standalone parity probes. + +## CPU query contract + +The primary API is scale-aware: + +```csharp +PlanetarySurfaceSample SampleDirection( + in PlanetaryFieldDefinition planet, + float3 unitDirection, + in PlanetaryQueryScale scale); + +PlanetarySurfaceSample SamplePosition( + in PlanetaryFieldDefinition planet, + double3 planetLocalPosition, + in PlanetaryQueryScale scale); +``` + +`PlanetaryQueryScale` declares a footprint in metres or an explicit wavelength +range, plus an accepted unresolved-height bound. Named Aetheria policies such +as strategic, navigation, vehicle, or character scale belong to Aetheria and +lower to this physical contract before evaluation. + +Required CPU compositions are: + +- allocation-free point sampling and structure-of-arrays batches; +- bounded ray/surface intersection and segment clearance; +- great-circle and arbitrary path profiles; +- region min/max/slope queries with declared error; +- deterministic page baking identical to the GPU page contract; +- projection-tile sampling. + +World-space APIs use double precision for astronomical positions and reduce to +a stable planet-local frame before float field evaluation. Floating-origin +policy must not perturb the field. + +## Daemon integration + +The Aetheria daemon publishes typed planet definitions and field versions +through CultCache/CultMesh. Simulation systems query CultMath locally in the +daemon process. A result becomes game truth only when an Aetheria simulation +owner uses it in a committed operation or frame. + +The daemon may maintain CPU pages, spatial summaries, collision meshes, or +navigation grids. Every cache key includes complete field identity and query +scale. Cache misses evaluate the canonical field or report deferral; they never +substitute a different terrain function. + +The initial integration targets Aetheria's current C# daemon host. If authority +moves to Rust, the CultMath semantics and parity corpus specify the Rust port. +C# and HLSL remain first-class rather than becoming Unity artifacts. + +## Unity 3D lowering + +`CultMath.Unity` is a thin package outside core CultMath. It owns conversion to +Unity types, graphics buffers, shader entry points, page-atlas dispatch, +camera-relative patch submission, render-pipeline integration, and materials. + +It consumes CultMath HLSL for page generation, residual composition, sampling, +radial refinement, and differentials. Aetheria supplies the published planet +definition. Unity does not retain an independent planet generator. + +## Electron/Web 2D lowering + +A map follows one path: + +```text +pixel -> inverse projection -> unit sphere direction + -> scale-aware surface query -> evidence layer + -> client-owned palette, labels, and interaction +``` + +CultMath initially owns forward and inverse transforms for: + +- equirectangular; +- Web Mercator with an explicit polar cutoff; +- Equal Earth or Mollweide equal-area projection; +- orthographic globe view; +- azimuthal equidistant and equal-area local views; +- cube-face atlas; +- local tangent-plane maps. + +Two delivery modes share the same identity: + +1. The daemon or attached worker generates versioned projection tiles and + publishes typed descriptors plus binary assets through CultMesh/CDN. This is + the baseline for Electron and thin browser clients. +2. A future WebGPU lowering evaluates locally for unusual projections or highly + interactive inspection. + +The first mode provides one auditable CPU implementation without requiring a +JavaScript terrain port. WebGPU should be added only with a WGSL parity harness, +not as a hand-translated browser generator. + +A projection-tile key includes planet and field version, projection and +parameters, evidence layer, tile level/coordinates, dimensions and border, +wavelength/footprint policy, and sampling/encoding version. Tiles are derived +assets. Strategic objects, territories, visibility, orders, and simulation +facts remain typed Aetheria state layered over them. + +## Field definition and publication + +The portable field definition is a typed document, not renderer configuration. +It contains stable parameters, coordinate conventions, component identities, +and a content fingerprint. CultCache stores it; CultMesh publishes it. JSON is +only a schema, debug, or xenos-boundary projection. + +- CultMath emits geometry and field evidence. +- Aetheria decides biomes, resources, movement, settlement, and gameplay. +- Unity decides 3D material lowering. +- Electron decides map palettes, contours, labels, and overlays. + +## Execution roadmap + +### Phase 0: contract and parity corpus + +Define field identity, coordinates, query scale, sample, and error types. Record +canonical point, seam, corner, wavelength, and projection fixtures. Separate +exact integer/hash requirements from bounded floating-point parity. + +Exit: CPU, HLSL, page, and projection tests name the same field contract without +renderer-owned types. + +### Phase 1: topology extraction + +Move cube-face, tile-key, direction/UV, border, and seam algorithms to CultMath. +Replace Fensalir copies and test all edges and corners. + +Exit: Fensalir contains no private cube-sphere terrain topology. + +### Phase 2: authoritative CPU queries + +Add scale-aware point/batch queries, double-to-local reduction, differentials, +bounds, ray intersection, clearance, and path profiles. Integrate an Aetheria +daemon simulation smoke. + +Exit: the daemon makes a deterministic terrain decision without Unity or +Fensalir assemblies. + +### Phase 3: page machinery extraction + +Move page layouts, CPU baking, residual bands, summaries, sampling, and matching +HLSL into CultMath. Preserve Fensalir measurements and transition evidence. + +Exit: CPU and HLSL pages match direct queries within declared unresolved bounds; +Fensalir is only a resource/dispatch adapter. + +### Phase 4: residency and patch extraction + +Move projected-error selection, fallback, transition weights, patch topology, +and refinement composition. Keep camera projection and drawing in renderers. + +Exit: Unity and Fensalir consume the same page set and patch description for +equivalent camera/error inputs. + +### Phase 5: Unity adapter and Aetheria viewer + +Build `CultMath.Unity`, bind daemon-published definitions, render from orbit to +ground, and compare Unity readback and seams against the parity corpus. + +Exit: Aetheria views the daemon-defined planet without a second generator. + +### Phase 6: projections and map tiles + +Implement projection transforms, footprint calculation, antialiased tile +sampling, CultMesh/CDN publication, and Electron elevation, slope, shaded +relief, plus one Aetheria strategic overlay. + +Exit: Unity and Electron identify matching landmarks at shared directions; +changing projection changes only the chart. + +### Phase 7: optional WebGPU lowering + +Add WGSL only if interactive local evaluation earns its cost, then prove point, +seam, page, and projection parity. Daemon tiles remain production until then. + +### Phase 8: delete obsolete owners + +Remove superseded Zyphos/Fensalir algorithms and Aetheria's Unity-only celestial +terrain from authoritative paths. + +Exit: renderer code, cache state, or projection cannot change the planet. + +## Verification matrix + +| Claim | CPU daemon | Fensalir | Unity | Electron tiles | WebGPU later | +|---|---:|---:|---:|---:|---:| +| Field identity/hash | exact | exact | exact | exact metadata | exact | +| Point height/evidence | reference | bounded | bounded | sampled | bounded | +| Six-face edges/corners | required | required | required | projection-specific | required | +| Wavelength selection | reference | bounded | bounded | encoded in key | bounded | +| Page residual sum | reference | required | required | optional source | required | +| Projection round trip | reference | optional | optional | required | required | +| Cache-independent answer | required | required | required | required | required | + +Negative checks must prove that residency cannot change a direct query, +projection cannot change the spherical field, Unity settings cannot alter daemon +identity, stale tiles fail version checks, omitted wavelengths remain within +their bound, seams/poles remain single-valued, and reload/eviction cannot commit +gameplay state. + +## Initial deliverable + +1. Extract cube-sphere topology and scale-aware CPU point/batch queries. +2. Add an Aetheria daemon smoke for height, normal, slope, and error. +3. Add equirectangular and Equal Earth CPU projection sampling. +4. Produce one versioned elevation tile and one slope tile. +5. Match the same fixture directions against Fensalir's HLSL oracle. + +This proves the daemon and map architecture before moving the larger page and +patch machine. It produces useful Aetheria output without letting a quick tile +generator become a second planetary authority. diff --git a/docs/eve-direct2d-client-surface.md b/docs/eve-direct2d-client-surface.md new file mode 100644 index 0000000..ee77b12 --- /dev/null +++ b/docs/eve-direct2d-client-surface.md @@ -0,0 +1,68 @@ +# Eve Direct2D Client Surface + +Fensalir's DirectWrite/Direct2D overlay is an Eve client surface. + +That means it should consume the same CultMesh UI surface documents as the +browser, iOS, Android, and Flutter clients. It should not become a second +dashboard language or an app-specific policy owner. The provider still owns +state and side effects; Fensalir owns the native desktop/runtime lowering. + +## Authority Map + +- Owner: provider repos own surface state, accepted commands, and meaning. +- Input document: the shared Eve/CultMesh surface contract owns panels, nodes, + assets, commands, selection, and state paths. +- Lowering: Fensalir translates the surface document into `AquariumUiDocument` + panels and controls. +- Renderer: `DirectWriteOverlay` draws the resulting panels through the + D3D11-on-12 Direct2D bridge after the D3D12 scene. +- Output: Fensalir emits user commands back through the same Eve/CultNet command + path as browser and mobile clients. + +## Current Landing Zone + +The existing engine contract already has the right narrow surface: + +- `AquariumUiDocument` +- `AquariumUiPanel` +- `AquariumUiControl` +- `D3D12Renderer.UpdateUi` +- `DirectWriteOverlay` + +`src/Aquarium.EveSurface` is the first native client cut. It subscribes to +Mimir's `/eve/deck` WebSocket, opens a provider such as `voidbot.swarm`, and +lowers the received surface document into `AquariumUiDocument` panels. It does +not teach the renderer about VoidBot, Mimir, or any provider-specific dashboard. +Provider specificity belongs in the provider document. + +Run it locally: + +```powershell +$env:FENSALIR_EVE_BROKER = 'ws://127.0.0.1:8795/eve/deck' +$env:FENSALIR_EVE_PROVIDER = 'voidbot.swarm' +.\scripts\dev-reload.ps1 -ClientProject src\Aquarium.EveSurface\Aquarium.EveSurface.csproj +``` + +## Invariants + +- Browser Eve remains the behavior and layout reference. +- Direct2D is a renderer for the shared contract, not the contract owner. +- Fensalir may own native text metrics, focus, keyboard, pointer dispatch, + opacity, panel positioning, and GPU/CPU timing telemetry. +- Fensalir must not own app-specific command semantics. +- Images and media assets need explicit asset handles/cache keys before they are + renderer inputs. +- High-frequency sensor data stays in the timestamped sensor path, not in UI + panel state. + +## First Cut + +1. Move the mirrored first schema into a neutral shared Eve/CultMesh contract + package once the browser reference stops changing daily. +2. Expand node lowering into `AquariumUiPanel` and existing controls: + readouts, buttons, toggles, sliders, options, text boxes, tree items, and + detail panes. +3. Route control activation back to a provider command sink. +4. Add a replay smoke using a recorded VoidBot/Mimir surface fixture. +5. Compare the Direct2D rendering against the browser reference fixture before + adding provider-specific polish. diff --git a/docs/perfect-fensalir-machine-roadmap.md b/docs/perfect-fensalir-machine-roadmap.md index ec71763..e23337d 100644 --- a/docs/perfect-fensalir-machine-roadmap.md +++ b/docs/perfect-fensalir-machine-roadmap.md @@ -394,8 +394,12 @@ Add contributors in this order: levels, aggregation path count, census radius, and P1/P2 smoothness penalties. The D3D12 renderer now accounts for dispatch-ready stereo lowerings only when the planned Height claim and all three resolved resources - are present; it reports that readiness separately from the not-yet-installed - SGM kernel. + are present. The first installed compute lane is `D3D12StereoDepth.hlsl`, + which handles the immediate Leap shape where left and right observations are + packed into the R/G channels of one Texture2D and writes a crude R16F + disparity by local SAD/block matching. This is a socket-owning live depth + proof, not the final libSGM-style census/aggregation SGM kernel and not + calibrated metric depth. Exit gate: diff --git a/docs/planet-work-assimilation.md b/docs/planet-work-assimilation.md deleted file mode 100644 index 403e8e5..0000000 --- a/docs/planet-work-assimilation.md +++ /dev/null @@ -1,479 +0,0 @@ -# Fensalir planet work: validation and reorientation beside Asura - -Status: Imagination pass 1, 2026-09-30. Nothing committed and no working tree -switched. The build probes ran on Yggdrasil through `ygg-verify.sh`, using -composite snapshots in a scratch repo. That repo lives in the scratchpad, not -in any project. - -Adopting instruction (operator, 2026-09-30): "Fensalir planet work is old, -needs validation and reorientation beside the Asura campaign". - - -## Rulings (operator, 2026-09-30) - -- **Q-F1: no planet terrain in Fensalir for now.** The July planetary runtime retires behind an archive - tag. A landing-scale Zyphos would be a new campaign built on Asura, not a revival of that runtime. -- **Q-F2: park the flow docs,** with a status line: parked, superseded by Asura for the field, no code. -- **Q-F3: triage now.** Eyes classifies each non-planet line on the donor ref before anything is tagged or - deleted: the 66 Bokusho commits, the Eve native surface work, Leap/stereo, and the reservoir work. P1 - waits for that triage. -- **Q-F4: delete Geometry's duplicate vector types before Asura Cut 3** (P4, in CultLib). This is on - Asura's critical path. - -## Pinned heads (every `file:line` below is against these) - -| Repo | Ref | SHA | Note | -| --- | --- | --- | --- | -| Fensalir | `origin/main` = local `main` | `e053486` | 3 commits past the merge base, all housekeeping | -| Fensalir | `origin/codex/geometry-ownership-migration` (the donor) | `42778c1` | 125 ahead of merge base `57b6040` (2026-05-30) | -| Fensalir | `origin/codex/leap-packed-depth` | `492d123` | the donor minus its last commit | -| CultLib | `origin/main` | `93fe1b9` | includes the bounded-least-squares merge; local `main` is `069ecc3` | -| CultLib | `origin/codex/geometry-ownership-migration` | `6a65369` | the donor's other half: 31 ahead of `a58daa5` (2026-07-12), 832 behind | -| CultLib | tags `gamecult-geometry{,-rs,-unity}-v0.1.0` | `b65619f` | pushed to origin, on `origin/codex/geometry-cultmesh-unity-release`, not in main | -| CultMath (standalone, retired) | `main` | `1c007a5` | "Point CultMath history at its CultLib home"; planetary deleted at `6a16260` | -| Asura | `main` | `63f9216` | snoise fix status recorded; the fix is on CultLib `hands/cultmath-snoise-grad` `0a5f4bf` | - -## Verdict - -**Retire the planet work. Do not land it.** The donor has no surviving code -owner: CultMath deleted the planetary runtime in July, and CultLib never merged -it. Asura has since claimed the planet field with a design that contradicts the -donor's central promises. Its erosion port is the one Asura already judged "not -a source". Its only consumer is a Fensalir demo client that has not changed -since 2026-05-30. - -One idea from the geometry half is still live and time-sensitive: deleting the -duplicate vector types from `GameCult.Geometry`. That is separate work, but it -has a closing window. Once Asura Cut 3 ships `GameCult.Geometry.dll` into Unity, -the duplicate `CultVec3` becomes public Unity API (Q-F4). - -Most of the branch is not planet work at all. 66 Bokusho commits, 10 Eve -commits, and the Leap/stereo and reservoir work ride on the same ref. They need -their own adoption decision before any ref is deleted (Q-F3). - ---- - -## 1. Validation report - -### 1.1 What the donor actually is - -Grouping `git log 57b6040..42778c1` by subject: - -| Slice | Commits | Dates | Size | -| --- | --- | --- | --- | -| Leap stereo depth, point cloud, reservoir history, camera fusion, loopback audio | ~20 | 05-30 to 05-31 | inside the 61-file, +8832/−279 pre-planet diff | -| Eve native surface client (Direct2D, CultUI previews) | ~17 | 05-31 to 06-02 | same | -| Bokusho brush simulation on D3D12 | 66 | 06-12 to 06-16 | `BokushoBrushSimulation.cs` +1076, `D3D12BokushoBrush.hlsl` +699, tests +1143 | -| **Planet slice** | **23** | **07-12 to 07-22** | **61 files, +2865/−359 (`5854630^..42778c1`)** | - -Inside the planet slice, only the last three commits are the "geometry ownership -migration": -- `2531df5` moves Fensalir onto CultMath's July planetary runtime. -- `492d123` deletes Fensalir's `CubeFace`, `CubeFacePosition` and `CubeTileKey`. -- `42778c1` renames every consumer from `cultmath_planetary_*` to - `gamecult_geometry_planetary_*`. - -Its partner commits live in CultLib (`13e8c56..6a65369`) and CultMath (`6a16260`). - -### 1.2 Merge probes (`git merge-tree --write-tree`) - -- **Fensalir, donor into `origin/main`: 7 conflicts.** - - Content conflicts in `Fensalir.sln`, `README.md`, `docs/README.md` and - `src/Aquarium.Engine/Aquarium.Engine.csproj`. - - The csproj conflict matters. Main's `dc35ecc` moved CultMath to - `$(CultLibRoot)\packages\cultmath\...` (`Aquarium.Engine.csproj:4,26` at - `e053486`). The donor still points at the standalone repo - (`..\..\..\CultMath\src\CultMath\CultMath.csproj`, `Aquarium.Engine.csproj:25` - and `Aquarium.Engine.Contracts.csproj:10` at `42778c1`). - - Modify/delete conflicts in `src/Saga/{README.md,Saga.csproj,SagaOrgan.cs}`. - Main deleted Saga as dead in `d755d9c`; the donor still migrates it. -- **CultLib, July branch into `origin/main`: 23 conflicts.** - - 13 are source and docs, including a modify/delete on - `CultDocumentMessagePackGenerator.cs`, which main deleted. The others are in - `CultManagedDocument.cs`, the serialization code, `GameCult.Geometry.csproj` - and `README.md`, `GeometryDocumentTests.cs` and `build-unity-package.ps1`. - - 10 are prebuilt Unity DLLs and pdbs. - - The branch is 832 commits behind. That is not a merge; it would be a - re-implementation. - -### 1.3 Build probes (Yggdrasil, `mcr.microsoft.com/dotnet/sdk:10.0`, Linux) - -The probes use three composite snapshots in the scratch repo. Each lays the -three repos out as siblings, the way the csproj relative paths expect. - -| Probe | Layout | Result | -| --- | --- | --- | -| **mainline** | Fensalir `e053486` + CultLib `93fe1b9` | `Aquarium.Engine.Fractal` builds. Fractal tests: **167/167 pass** | -| **then** (the July world) | Fensalir `42778c1` + CultLib `6a65369` + CultMath `b43cf49` | Restore fails first with NU1101: `CultMath 0.1.0` comes only from a git-ignored local feed (`CultLib/Directory.Build.props:18-19` at `6a65369`: `..\CultMath\.tools\local-feed`). After `dotnet pack` of CultMath `b43cf49` and passing the feed explicitly, the lib builds and the Fractal tests give **169/170**. The failure is `ProjectionTests.CheckedInProjectionReportMatchesSampler`, which passes on mainline. The donor regenerated `research/rendering/fractal-projection-report.md` against the July projection code on Windows; this was not chased. | -| **now** (donor against today's owners) | Fensalir `42778c1` + CultLib `93fe1b9` + CultMath `1c007a5` | **Fails** with 10 unique errors (`FRACTAL_LIB_EXIT=1`). Nine are CS0246 for `PlanetaryCubeFace`, `PlanetaryTileAddress` and `PlanetaryFaceCoordinate` (`FractalDslCompiler.cs:505`, `FractalStableKeyBuilder.cs:8`, `ICubeSphereProjection.cs:11`, `NormalizeCubeSphereProjection.cs:11`, `ProjectionDistortionResult.cs:8`, `ProjectionDistortionSampler.cs:14,61,68`, `TangentCubeSphereProjection.cs:11`). The tenth is CS1704: two assemblies named `CultMath` are loaded, one from the standalone repo through the donor's `Contracts` reference and one from CultLib `packages/cultmath` through `GameCult.Geometry`. The donor straddles the pre-monorepo and monorepo layouts. | - -Static proof that "now" cannot build, independent of the probe: every -`Planetary*` symbol the donor consumes is absent from CultLib main. -- The donor uses these (`git grep` at `42778c1`): `PlanetaryCubeFace` (47 uses), - `PlanetaryTileAddress` (24), `PlanetarySurfacePages`, - `PlanetaryProjectionKind`, `PlanetaryFaceCoordinate`, `PlanetaryPageSampling`, - `PlanetaryPageLayout`, `PlanetaryResidualResidency`, `PlanetaryLodSelector`, - `PlanetaryGpuPageBuilder`, `AdvancedErosionParameters` and the - `GameCultGeometry*` HLSL structs. -- Counts on CultLib `93fe1b9`: `PlanetaryCubeFace` 0, `PlanetaryTileAddress` 0, - `PlanetaryTopology` 0. -- `src/GameCult.Geometry/Shaders/` does not exist on main, so - `#include "GameCult.Geometry/GameCult.Geometry.hlsl"` - (`D3D12ZyphosTerrain.hlsli:4` at `42778c1`) has nothing to resolve. - -**Not run, because it needs Windows and D3D12:** -- `tests/Aquarium.Engine.Tests`: `D3D12PlanetarySurfacePageTests`, - `D3D12PlanetaryTopologyParityTests` and `D3D12AdvancedErosionParityTests`, - which are GPU readback parity tests; -- `Aquarium.Engine` itself (Vortice D3D12); -- the Zyphos client. - -Per the workstation budget, none of this ran on Starfire. Given the verdict, -it does not need to run at all: the GPU parity would prove a runtime nobody -will keep. - -### 1.4 What is still right, and what has rotted - -Still right: -- The donor's direction of dependency. Algorithms sit in a library and D3D12 - resources stay in Fensalir. That matches the doctrine and Asura's own shape - (CultLib algorithms, consumer-owned rendering). -- The July migration plan's authority sentence: "CultMath owns how numeric - primitives behave; GameCult.Geometry owns what those primitives mean in - space" (CultLib `docs/cultmath-gamecult-geometry-migration-plan.md:56-57` at - `6a65369`). CultLib main adopted the same split at `63d6094` (2026-08-26), - and `HlslMirrorTests.cs:7-15` on main enforces the CultMath side. -- The idea of deleting `CultVec2`/`CultVec3` (CultLib `317b7d5`). Main still - carries both (see Q-F4). - -Rotted: -1. **Ownership of the planet field.** The donor's objective - (`docs/cultmath-planetary-field-runtime-plan.md:3-15` at `42778c1`) makes a - shared library the canonical planet field for "Aetheria's daemon - simulation, Unity's three-dimensional planet renderer, Fensalir's D3D12 - renderer, and Electron/Web strategic maps". Asura now owns Aetheria's - planets and rules the opposite: - - The field is HLSL only, and the CPU never evaluates it (Asura - `docs/target.md:42-47`, invariant 2). - - There is no landing and no surface streaming (the scope boundary). - - The donor's authoritative CPU queries, residency LOD and QSC page - streaming are therefore out of scope, or forbidden outright. -2. **The erosion port.** Asura's 4b-iii already rejected the July - `spherical_erosion` because it "lerps raw phases across cells instead of - blending phasors, which is not Phacelle" (`docs/cut-map.md:1132-1135`, - `:1736` at `63f9216`). Phacelle is going into CultMath fresh (2a-ii-b). -3. **Derivatives.** Zyphos's base field is ad hoc sine plates with a - central-difference gradient (`D3D12ZyphosTerrain.hlsli:113-129` at - `42778c1`). That is the pattern Asura's invariant 8 exists to prevent. -4. **The code owner is gone.** CultMath deleted the planetary runtime - (`6a16260`, reachable from CultLib main through the subtree). The receiving - CultLib branch never merged. On mainline the runtime is orphaned: it exists - only on unmerged branches and the three pushed `v0.1.0` tags. -5. **The build contract.** It needs an untracked local NuGet feed (see 1.3) - and paths from before the monorepo. -6. **The CultLib geometry half diverged from main's ruling.** - - The branch absorbs CSG into `GameCult.Geometry` (`6b44f96`). Main ruled - that `GameCult.Geometry.Csg` owns CSG production behaviour (`63d6094`, - `src/GameCult.Geometry/README.md`), and that repo shipped `distance(p)` - at `d84acb6`. - - The branch also adds a geometry worker, `CultGeometryWorkerDocuments`, - `CultGeometryWorkerProvider` and "v2 contracts". None of these exist on - main, and main's documents never moved to v2. - ---- - -## 2. Ownership table - -| Piece (donor) | Where it sits in the donor | Owner today | Disposition | -| --- | --- | --- | --- | -| Planet field definition, base field and erosion as one function | CultLib branch `src/GameCult.Geometry/Planetary/PlanetaryField.cs`, `Shaders/Planetary.hlsl` | **Asura** (field in HLSL, per body archetype) | Retire. Duplicates Asura's field | -| Advanced erosion filter (Johansen port) and `SphericalErosion` | branch `Planetary/AdvancedErosionFilter.cs`, `SphericalErosion.cs`, `Shaders/AdvancedErosionFilter.hlsl` | **Asura** 4b-iii (`AsuraErosion.hlsl`, fresh port with provenance) | Retire. Asura already ruled it "not a source" | -| Phacelle and noise primitives | embedded in the erosion port | **CultMath** (2a-ii-b `phacelle`, `CultPhasor`) | Retire the embedded copy | -| Cube-sphere/QSC topology, tile addresses, page layout, residency, LOD selection, patch meshes, radial refinement | branch `Planetary/PlanetaryTopology.cs`, `PlanetaryPages.cs`, `PlanetaryResidency.cs`, `PlanetaryPatch.cs`, `PlanetaryRadialRefinement.cs` | **No live consumer.** Asura meshes with surface nets and tiles per quad; streaming is out of scope | Retire. Revive only through a new campaign with a named consumer (Q-F1) | -| CPU ray, region and great-circle queries for an "Aetheria daemon" | branch `Planetary/PlanetaryQueries.cs` | **Forbidden** by Asura invariant 2. The dock-site query is Asura's | Retire | -| Map projections and `CMPT` tile codec (Electron maps) | branch `PlanetaryProjection.cs`, `PlanetaryMapTileEncoding.cs`, TS decoder | No consumer exists or is planned | Retire | -| `org.gamecult.geometry` Unity package and planetary viewer sample | branch `unity/org.gamecult.geometry/**`, tags `b65619f` | Superseded: Asura Cut 3 ships Geometry inside `org.gamecult.cultlib` | Retire the branch. Keep the pushed tags as immutable history (Q-F3) | -| Frequency-band selection by sample footprint | branch `Planetary/ErosionFrequencyBands.cs` | **Asura** (band limits per lowering; target invariant 8, Q11) | Retire. Same concept, and Asura implements it fresh | -| D3D12 page buffers, root parameters 29-32, dispatch, patch raster, debug modes | Fensalir `D3D12Renderer.cs:99-102,223` and about 157 planetary lines; `AquariumRenderPlan.cs:526-565` (render ABI) at `42778c1` | **Fensalir** would own it if Fensalir rendered planets. Its only producer is the retired runtime | Retire with the runtime | -| DXC include resolver for `Packages/org.gamecult.cultmath/shaders/` and `GameCult.Geometry/` | Fensalir `D3D12ShaderCompiler.cs:68-102` at `42778c1` | Fensalir. On main no shader includes CultMath HLSL at all | Retire. Its search paths are also stale (the standalone `CultMath/shaders`, pre-monorepo). Rebuild against `packages/cultmath/shaders` when a Fensalir shader first includes CultMath | -| Zyphos terrain shaders, page and patch entry points, `ZyphosPlanetarySurfacePages` | Fensalir `src/Aquarium.Zyphos/**` at `42778c1` | Fensalir's demo client. Main's Zyphos keeps its pre-July analytic sphere | Retire | -| Fensalir's `CubeFace`, `CubeFacePosition`, `CubeTileKey` and the projection code in `Aquarium.Engine.Fractal` | **Main keeps them.** The donor deleted them in favour of Geometry | **Fensalir** (fractal DSL, projection reports; 167 tests green on main) | Keep on main; nothing to do. The donor's deletion is void once the runtime is retired | -| Planetary flow organ (ocean and atmosphere advection), documentation only | Fensalir main: `docs/planetary-flow-{organ,nn-architecture,implementation-plan}.md` (289/462/185 lines); `docs/README.md:39-44`; `state/map.yaml:114-120` and the flow `next_actions` entries after `:171` | Fensalir, as plans. No code exists; last touched 2026-05-29. Not in Asura's scope (no atmospheres or oceans) | Q-F2 | -| Vector-type unification (delete `CultVec2`/`CultVec3`/`CultRect` in favour of CultMath `float2`/`float3`/`rect`) | CultLib branch `317b7d5`, `364c9cd` | **CultMath** owns the vector vocabulary (main `63d6094`). Main still has the duplicates: `CultGeometryPrimitives.cs:11,62,128` and the `origin` parameter of `CultGeometryIsoSurface.cs:42` and `CultGeometrySurfaceNets.cs:47` | Q-F4: a fresh cut on main, not the branch code | -| CSG absorption, geometry worker, v2 schemas, Rust `gamecult-geometry-rs` | CultLib branch | CSG: `GameCult.Geometry.Csg` (main's ruling). The rest have no owner and no consumer | Retire with the branch | - -### Duplication against Asura (one side is cut, not kept in parallel) - -| Concept | Donor | Asura | Side cut | -| --- | --- | --- | --- | -| Planet shape function | `PlanetaryFieldDefinition` plus base-field provider (CPU and HLSL) | the field `f(p) = |p| − (1 + h(u))`, HLSL only | donor | -| Erosion | July port with raw-phase lerp | 4b-iii fresh port, exact Phacelle, provenance file | donor | -| Level of detail | QSC pages, residency, projected-error LOD | surface-net grid resolution plus per-body tile N | donor. Asura's scope rules streaming out | -| Band limiting | `ErosionFrequencyBands` | per-lowering band limit (Q11) | donor | -| Unity packaging | `org.gamecult.geometry` | `org.gamecult.cultlib` (Cut 3, default A) | donor | - ---- - -## 3. Cuts - -Every cut below is deletion or documentation, with no new code. Numbers are -prefixed `P` so they cannot be confused with Asura's cut numbers. - -### P0. Operator rulings (Self; blocks everything below) - -Q-F1 and Q-F3 must be ruled before P1. Q-F2 gates P3. Q-F4 gates P4. - -### P1. Archive the refs, then delete them (Fensalir, CultLib, standalone CultMath) - -- **Precondition:** Q-F3 ruled. The non-planet work on the Fensalir donor - (Bokusho, Eve surface, Leap) must be either archived with it or adopted - separately first. -- **Deletes first:** - - Fensalir remote branches: `codex/geometry-ownership-migration` (`42778c1`) - and `codex/leap-packed-depth` (`492d123`, a strict ancestor). - - CultLib remote branches: `codex/geometry-ownership-migration` (`6a65369`) - and `codex/geometry-cultmesh-unity-release` (`b65619f`). - - The standalone CultMath remote `codex/geometry-ownership-migration` - (`b43cf49`), already an ancestor of CultMath `main`, so deleting it loses - nothing. -- **Then:** - - An annotated tag at each unique tip, so every commit stays reachable: - - Fensalir `archive/geometry-ownership-migration` → `42778c1`; - - CultLib `archive/geometry-ownership-migration` → `6a65369`; - - CultLib `archive/geometry-cultmesh-unity-release` → `b65619f`, if a - branch ref is wanted beyond the release tags that already pin it. - - The tag message names this map and the ruling. - - The published `gamecult-geometry{,-rs,-unity}-v0.1.0` tags stay: they are - released, immutable artifacts. -- **Authority map:** - - Owner of planet shape: Asura. Owner of numeric primitives: CultMath. - - Derived: the archive tags are history only, and nothing resolves a build - from them. - - Forbidden writers: any branch that could be merged back to revive - `Planetary*` in CultLib, or `cultmath_planetary_*` in CultMath. - - Shared paths: none. No mainline build references these refs; the mainline - probe proves Fensalir main builds without them. -- **Verification:** - - `git merge-base --is-ancestor archive/` for each deleted - ref, run before deleting. - - `git ls-remote --heads origin | grep -c geometry-ownership` gives 0 in all - three repos. - - Mainline probe re-run (Fensalir `e053486` + CultLib main): 167/167. - - Negative: `git grep -c "Planetary" origin/main -- src packages` in CultLib - stays at its current count (only `HlslMirrorTests.cs:12,14`). -- **Subtraction:** 5 remote branch refs, net of the 2–3 archive tags. The - unmerged surface removed from view is Fensalir +11,650/−591 (116 files) and - CultLib +14,933/−317 (156 files). No mainline lines change. -- **Hands budget:** about 20k tokens. It is ref surgery and needs the - operator's credentials for the pushes. - -### P2. Asura documents: replace "do not delete it" with the ruling (Asura, docs only) - -- **Deletes first, at Asura `63f9216`:** - - `docs/target.md:110-113`: the "Not consumers" bullet naming the branches. - - `docs/cut-map.md:814-820`, Cut 3's "Not a source", including - "do not delete it; see findings". -- **Then:** - - One sentence in each place: the July planetary line was retired on - 2026-09-30 and archived at the tags from P1; Asura is the sole owner of - planet shape. - - `cut-map.md:1132-1135` and `:1736` keep their technical reason (raw-phase - lerp, not Phacelle). They point at the archive tag instead of the branch - names. - - `target.md:252` (the substrate fact about the `unity-v0.1.0` tag) stays - true and is unchanged. -- **Authority map:** `target.md` owns the ends, so the retirement ruling is - recorded under its dated operator rulings. The cut map only points at it. -- **Verification:** - - `rg -n "codex/geometry-" docs/` in Asura gives 0 lines. - - `rg -n "do not delete it" docs/` gives 0. -- **Subtraction:** about −8 +4 lines. -- **Timing:** Self can do this in the same pass that records the rulings. It - does not touch 2a-ii. - -### P3. Fensalir main: record the disposition; flow docs per Q-F2 (Fensalir `main`) - -- **If Q-F2 = retire. Deletes first, at `e053486`:** - - `docs/planetary-flow-organ.md`, `docs/planetary-flow-nn-architecture.md` - and `docs/planetary-flow-implementation-plan.md` (936 lines); - - `docs/README.md:39-44`; - - `state/map.yaml:114-120`, and the flow entries in `next_actions` (the - bullets beginning "Build the planetary flow organ" and "For planetary flow - modeling", about 10 lines after `:171`). -- **If Q-F2 = park:** add one "Status: parked 2026-09-30, no consumer" line at - the top of each of the three docs instead. -- **Then, either way:** - - One line in `state/map.yaml`: Fensalir renders no planet terrain beyond - the Zyphos demo's analytic sphere. Planet shape for GameCult games is - Asura's. If Fensalir ever renders an Asura body, it lowers Asura's field - HLSL and owns only D3D12 resources. - - Fix `README.md:141` from "planetary/fractal terrain demo" to "fractal - terrain demo", only if the operator agrees the demo carries no planet - ambition. Otherwise leave it. -- **Authority map:** Fensalir owns its renderer and demos. It claims no planet - field. -- **Verification:** - - `git grep -n -i "planetary" origin/main -- docs/README.md state/map.yaml` - matches the intended remainder. - - The mainline Fractal probe still gives 167/167; docs cannot break it. -- **Subtraction:** retire gives about −950 lines and +2. Park gives +3. -- **Hands budget:** about 15k tokens. - -### P4. CultLib: collapse `CultVec2`, `CultVec3` and `CultRect` into CultMath, before Asura Cut 3 (only if Q-F4 = A) - -- **Why now:** - - Asura Cut 3 (`docs/cut-map.md:801`) ships `GameCult.Geometry.dll` in - `org.gamecult.cultlib` 1.1.0. - - Both extractors take `CultVec3 origin`, so the duplicate type becomes - Unity public API in that release. Deleting it afterwards is a semver - major. - - `CultGeometrySurfaceNets.cs:83` already converts `CultVec3` to `float3` - internally. -- **Deletes first, at CultLib `93fe1b9`:** - - In `src/GameCult.Geometry/CultGeometryPrimitives.cs`: - - `CultVec2` (`:11-60`) and `CultVec3` (`:62-126`); - - `CultRect` (`:128-197`), which duplicates CultMath `rect` - (`packages/cultmath/src/CultMath/rect.cs:7`, added 2026-08-07). - - `CultCircle` and `CultSphere` stay; they are spatial meaning, and that is - Geometry's. They are rebuilt on `float2`/`float3`. -- **Then:** - - `CultGeometryIsoSurface.Extract(..., float3 origin = default, ...)` - (`:42`) and `CultGeometrySurfaceNets.Extract(..., float3 origin = default, - ...)` (`:47`); delete the conversion at `:83`. - - Update the tests: `GeometryDocumentTests.cs` (16 uses), `IsoSurfaceTests.cs` - and `SurfaceNetsTests.cs`. - - Update `src/GameCult.Geometry/README.md`. -- **Explicitly out of scope:** CultMesh's native-slice value-type names - `"CultVec2"` and `"CultVec3"` (`packages/cultmesh-ts/test/cultmesh.test.ts:1717,2440,2453`, - and `src/GameCult.Mesh/docs/public-api.md`). Those are wire vocabulary with - their own owner. Record them as a finding for the CultMesh owner; do not - rename them here. -- **Authority map:** - - Owner: CultMath owns vector and rectangle values. Geometry owns what they - mean in space. - - Forbidden writers: any type in `GameCult.Geometry` that carries x/y/z - fields. -- **Verification (Yggdrasil):** - - `dotnet test tests/GameCult.Geometry.Tests`, with the per-file counts. The - Surface Nets byte-identical fixtures from Asura Cut 2 (31 fixtures) must - stay byte-identical. - - Negative: `git grep -n "CultVec\|CultRect" -- src/GameCult.Geometry tests/GameCult.Geometry.Tests` - gives 0. - - Stryker `--since:93fe1b9` on the touched files. -- **Subtraction:** about −190 lines of duplicate value types, plus test - churn, net negative. -- **Sequencing:** after Q13 (it blocks Cut 3 anyway) and before Cut 3's - release tag. It can run beside 2a-ii-b. -- **Hands budget:** about 60k tokens. - ---- - -## 4. Sequencing against Asura - -```text -Asura: 2a-ii-a snoise fix (Hands done 0a5f4bf, Soul running) -> 2a-ii-b Phacelle -> 2a-ii-c cultmath 0.3.0 (with bounded LS, already on main 93fe1b9) - \ - Q13 (Geometry CultReference strings) ------------------------------------> Cut 3 cultlib-unity 1.1.0 ---> 4a ... - P4 (Q-F4 = A) ------------------/ (must land before this tag) - -This map: P0 rulings -> P2 (Asura docs, any time) ; P1 (refs, after Q-F3) ; P3 (Fensalir docs, after Q-F2) -``` - -- None of P1–P3 touches CultMath or CultLib source, so none can collide with - the snoise fix, Phacelle, or the 0.3.0 release that carries bounded least - squares. -- P4 is the only cut on Asura's critical path. It must land before Cut 3's - tag, and it touches `GameCult.Geometry`, which Cut 3 packages. Run it in its - own worktree from `main`, never in the snoise worktree. -- The Asura campaign gains nothing from the donor. 4b-iii stays a fresh port. - ---- - -## 5. Operator questions (one fork each) - -### Q-F1. What is a planet in Fensalir *for*? - -Asura answers this for Aetheria: orbital tiny planets, no landing. The donor -answered it for "every renderer, including an Aetheria daemon and strategic -maps", and that answer has been overtaken. What remains is Fensalir's own -Zyphos demo: the domain catalog runs from planet to continent, forest, tree, -leaf and pebble field (`ZyphosSpatialDomainCatalog.cs` on main). That is -landing-scale LOD, which Asura rules out. - -- **A. No planet terrain in Fensalir for now.** Retire the donor (P1–P3). If a - GameCult game later wants planets in Fensalir, it lowers Asura's field HLSL. -- **B. Zyphos needs landing-scale planets.** That is a new campaign with its - own target. It would decide whether streaming LOD extends Asura's field - (one field, two lowerings) or is a separate organ. It must not revive the - July runtime as a second planet-field owner. -- **C. Keep the donor alive as a Fensalir-private runtime.** This recreates a - second planet-field authority beside Asura, over an orphaned library. - -**Recommended: A.** Nothing on main consumes the donor. The Zyphos client has -not moved since 2026-05-30. B stays open as a future campaign and loses nothing -by waiting. - -### Q-F2. The planetary *flow* documents in Fensalir: what are they for now? - -There are three documents (936 lines) for a cube-sphere ocean and atmosphere -advection organ with neural-network budgets (FlowNet-S/M/T). No code exists, -they were last touched 2026-05-29, and their only client is the Zyphos demo. -Asura is not affected. Separately, Asura's target queues "the flow field as the -single owner of motion in Aetheria" as a later campaign. That is a different -flow. - -- **A. Retire** (P3 deletes them). If Zyphos oceans return, their campaign - starts from a target, not from these plans. -- **B. Park them** with a status line. - -**Recommended: B, weakly.** They carry real research (datasets, model sizing), -nothing depends on them, and parking costs 3 lines. Choose A if you want the -docs tree to describe only live machinery. - -### Q-F3. The non-planet work that rides on the same Fensalir branch - -The donor ref also holds 66 Bokusho commits (a D3D12 port beside the TS/WebGPU -`bokusho` repo), 10 Eve native-surface commits, and the Leap/stereo depth, -reservoir and loopback-audio work. None is on Fensalir main. - -- **A. Archive the whole ref** under one tag in P1, and route each of those - lines to its own adoption decision later. -- **B. Adopt them before touching the ref.** - -**Recommended: A.** An archive tag keeps every commit reachable, so B is still -possible afterwards. This map does not judge those lines. - -### Q-F4. Delete Geometry's duplicate vectors before Asura Cut 3 ships them to Unity? - -What is `CultVec3` for? Nothing that `float3` does not already do, and the -surface-nets code converts it straight to `float3` (`CultGeometrySurfaceNets.cs:83`). -The July branch deleted it, and main never did. - -- **A. P4 before Cut 3:** no semver cost, and about −190 lines. -- **B. Ship Cut 3 as is:** a later deletion is a major bump for - `org.gamecult.cultlib`. - -**Recommended: A.** It is the one piece of the geometry half that is right, and -it gets more expensive after Cut 3's release. - -## 6. Findings not assigned to a cut - -- **Unreproducible build (CultLib `6a65369`).** The July CultLib branch - restores `CultMath 0.1.0` from a git-ignored local feed. The "Fensalir and - Aetheria green" receipts in its progress document - (`docs/cultmath-gamecult-geometry-migration-progress.md`, stage 6) could not - have been reproduced from committed sources alone. The branch is being - retired, so this is recorded as a scar and needs no fix. -- **A failing test in the donor's July world.** - `ProjectionTests.CheckedInProjectionReportMatchesSampler` fails on Linux in - the July composite (169/170), and the same test passes on mainline. The most - likely cause is a platform difference in the regenerated report. It was not - chased, because the code is being retired. -- **CultMath's `HlslMirrorTests.cs:7-15`** is a negative spelling test. It - guards against planetary kernels returning to CultMath and is cited by Asura - as the ruling's enforcement. Keep it; after P1 its only threat is gone, but it - is cheap and names the boundary. -- **Stopgap note.** Composite multi-repo snapshots are how a Fensalir build - can be probed on Yggdrasil at all: `ygg-verify.sh` takes one repo, and - Fensalir needs sibling checkouts. The Idunn verify campaign should take a - multi-repo revision set rather than rely on this trick. diff --git a/docs/stateless-planetary-erosion-rendering-plan.md b/docs/stateless-planetary-erosion-rendering-plan.md new file mode 100644 index 0000000..6eb02af --- /dev/null +++ b/docs/stateless-planetary-erosion-rendering-plan.md @@ -0,0 +1,378 @@ +# Stateless Planetary Erosion Rendering Plan + +## Objective + +Render planetary-scale, erosion-shaped terrain from one deterministic spherical +field, from orbit to ground, without making cube faces, cache residency, request +order, or the renderer into competing terrain authorities. + +CultMath now carries the faithful point-evaluable C# and HLSL erosion contract, +and Zyphos lowers it through persistent residual pages into coarse raster +patches with bounded per-pixel refinement. The implementation and verification +evidence for each phase are recorded below. + +## Current mechanism + +CultMath now owns the source-grounded erosion filter in matching C# and HLSL, +including deterministic integer cell hashing, physical wavelength selection, +and a measured compute-shader parity surface. Zyphos can evaluate that field +directly and can generate bordered cube-sphere pages whose overlapping samples +and GPU-reduced summaries have dedicated tests. + +The renderer now owns a persistent six-root page set plus camera-selected +residual descendants. Pages carry height, world-space tangent gradient, +material evidence, conservative summaries, and independent content and +presentation versions. Parent/child arrival and eviction preserve one terrain +field by fading only the child residual. + +The production lowering is coarse cube-sphere raster patches with bounded +per-pixel radial refinement against those pages. The general SDF renderer is a +debug oracle, not the primary planet backend: enabling the full planet SDF has +removed the D3D12 device under the current workload, while page generation with +the planet SDF disabled completes cleanly. The page-backed planet is visibly +proven through the candidate reservoir with corrected pixel depth. Zyphos's +legacy two-million-splat planet writer has been removed; it ran after the patch, +erased the same surface, and consumed most of the former frame budget. + +## Progress ledger + +| Phase | State | Evidence | +|---|---|---| +| 1. Faithful filter | Complete | C# reference fixtures and adapted HLSL contract | +| 2. CPU/GPU parity | Complete | Compute readback comparison over 2,052 samples | +| 3. Physical bands | Complete | Wavelength selection, fractional terminal octave, unresolved bound | +| 4. GPU pages | Complete | Spherical borders, sibling seam tests, GPU summary reduction | +| 5. Page-backed intersection | Complete | Persistent page and summary buffers, conservative bounds/steps, bracketed refinement, radial hit parity | +| 6. Quadtree transitions | Complete | Exact 0/0.5/1 arrival captures plus cross-face half-eviction capture and lifecycle probes | +| 7. Unified differentials/materials | Complete | World-gradient pages, composed radial normals, shared ridge/gully material evidence | +| 8. Profiling/backend decision | Complete | Orbit-to-ground raster path, page generation, residency, and total frame fit named budgets | + +"Complete" means the phase exit criterion has authoritative evidence. + +## Authority map + +- **Owner:** CultMath owns the deterministic spherical erosion operator and its + matching C# and HLSL semantics. +- **Inputs:** unit planet direction, base height, world-distance tangent + gradient, planet seed, planet radius, requested frequency band, and bounded + erosion parameters. +- **Outputs:** radial height residual, tangent gradient, ridge mask, gully mask, + drainage hint, and declared unresolved error. +- **Derived state:** cube-sphere pages, page borders, min/max reductions, + residency records, render meshes, and debug projections are disposable + lowerings. +- **Client policy:** Zyphos owns terrain composition, parameter presets, + geological meaning, biome use, and authored overrides. +- **Forbidden writers:** face-local terrain noise, tile-local seeds, seam repair + passes, renderer-only erosion logic, and caches that change terrain when + evicted. +- **Shared path:** direct compute evaluation, CPU page baking, GPU page baking, + reload, and LOD transitions evaluate the same field contract. +- **Deletion line:** remove direct full-spectrum erosion from the repeated + sphere-tracing loop once page-backed geometry proves equivalent. Keep direct + evaluation as a verification oracle. The visible fallback is the nearest + resident ancestor band; the root fallback is the uneroded base field until a + root page is available. + +## Invariants + +1. The same seed and spherical point produce the same terrain regardless of + cube face, tile level, request order, cache state, or evaluation backend. +2. Parent and child pages share identical low-frequency terrain. A child adds + only the residual band it can represent. +3. Cube faces are charts and scheduling domains, never terrain owners. +4. Geometry displacement, visible normals, material masks, and CPU queries + describe the same final field. +5. Cache eviction affects latency and fidelity availability, not world truth. +6. Frequencies above the projected or page Nyquist limit do not enter geometry. +7. Visual erosion does not claim watershed, sediment, lake, or river authority. +8. SDF stepping remains conservative throughout page arrival and LOD blending. + +## Target rendering pipeline + +```text +planet seed + spherical position + client terrain policy + | + v + canonical spherical field + | + GPU compute or CPU oracle + | + v + cube-sphere height/gradient/material pages + | + quadtree residency + frequency-band LOD + | + v + coarse tessellated cube-sphere patches + | + bounded pixel radial refinement + | + v + differential normal + material evaluation + | + v + diegetic PBR presentation +``` + +## Surface-page contract + +Each resident page represents one cube-sphere quadtree tile and stores an +interior region plus a measured filtering border. Initial measurements should +start at 128 by 128 interior samples and 4--8 border texels; these values are +experiments, not doctrine. + +Required channels: + +- radial displacement; +- two-component tangent gradient in a declared tile tangent frame; +- ridge and gully masks; +- material or climate evidence required by the client; +- validity/confidence and active frequency-band metadata. + +Required summaries are minimum and maximum displacement, unresolved height +error, maximum slope or curvature, angular texel size, included wavelength +range, field version, and parameter identity. + +Borders sample the canonical spherical function beyond face and tile edges. +They do not copy a neighbor's potentially missing page. Seam truth therefore +does not depend on residency. + +## Geometry and intersection + +The primary backend rasterizes coarse cube-sphere quad patches. Vertex work +places a conservative, low-frequency surface from the resident root and +ancestor pages. The pixel shader follows the interpolated camera ray through a +small fixed number of radial corrections against the complete resident page +field, then evaluates the shared differential and material contract. Fine +terrain therefore costs only covered pixels; the rasterizer supplies coverage, +clipping, and coarse visibility instead of idling beside a full-screen marcher. + +Patch selection and tessellation use page-summary projected error. Fine bands +do not force matching geometric subdivision unless their displacement error can +move the silhouette or exceed the pixel-refinement capture range. Summary +bounds must make the coarse patch conservative so the true surface cannot fall +outside its rasterized coverage. + +The general SDF path remains a direct verification oracle and diagnostic view. +It may use expanded-sphere broad phase and bracketed radial refinement, but it +does not own production visibility or terrain truth. + +## Differential and normal contract + +For unit direction `d`, planet radius `R`, radial height `h`, and +world-distance tangent gradient `grad(h)`, the surface is + +```text +P(d) = (R + h) d +``` + +and the normal is derived from the same differential, approximately + +```text +N = normalize((R + h) d - grad(h)) +``` + +The exact scaling must be derived and tested for the selected gradient units. +Cube-face UV differences are not the visible-normal authority. Authored +residuals publish compatible derivatives or use the same bounded sampling +contract. + +The raster lowering derives its visible normal from screen derivatives of the +four-step refined world position, oriented toward the camera with a radial +fallback. This differentiates the final composed surface without repeating five +base-field evaluations per pixel. CPU queries and the direct oracle retain the +analytic planet-space differential contract. + +## LOD and frequency bands + +The terrain is one spectrum evaluated through different filters, not separate +orbital and ground worlds. + +- Express erosion scale as physical wavelength using planet radius. +- Select octaves from projected footprint and page sampling rate. +- Fade bands over a narrow transition interval. +- Store children as shared parent low frequencies plus child-resolvable + residual bands. +- Blend residual contribution; never blend two independent full terrains. +- Keep parent pages renderable while children generate or stream. + +Orbital views consume continental and major-relief bands. Flight adds regional +drainage and ridge bands. Ground adds local geometry and may lower smaller +omitted bands into normal/material detail from the same field. + +## Material use + +Ridge, gully, slope, height, climate, and authored masks are material evidence. +They may inform exposed rock, soil, moisture, snow, vegetation, and client +overrides. Drainage hints are not guaranteed rivers. Saga or a later hydrology +organ owns conserved flow, watershed topology, lakes, sediment, and climate +coupling. + +## Implementation roadmap + +### Phase 1: faithful filter and reference fixtures + +- Replace the approximation with a source-grounded advanced erosion filter. +- Implement cell pivots, fade handling, stacked fading, partial normalization, + straight-gully slopes, ridge/crease rounding, and drainage outputs. +- Record provenance and MPL obligations for adapted reference code. +- Create fixed CPU fixtures from the reference implementation. + +Exit: CultMath C# matches reference samples and exposes the complete contract. + +### Phase 2: measured CPU/GPU parity + +- Evaluate fixed spherical samples in a small compute shader. +- Read results back and compare height, gradient, and masks against C#. +- Replace transcendental hashing with integer hashing if strict cross-device + determinism requires it. +- Document ordinary and singular-region tolerances. + +Exit: parity is measured over thousands of samples, not inferred from similar +source text. + +### Phase 3: physical scale and band selection + +- Add planet radius and wavelength-based parameters. +- Select octaves from requested world-space footprint. +- Prove low-frequency identity when higher bands are absent. +- Add broad, regional, local, and shading-only band presets. + +Exit: planet radius does not silently alter geological scale and band limiting +does not rearrange broad terrain. + +### Phase 4: GPU surface-page generation + +- Generate displacement, tangent gradient, ridge, gully, and material evidence. +- Sample borders through the spherical field across cube adjacencies. +- Reduce displacement bounds and error/slope summaries. +- Key pages by planet, field version, parameters, tile, and frequency band. + +Exit: independently generated overlapping pages produce identical samples. + +### Phase 5: page-backed terrain intersection + +- Add page lookup to the Zyphos intersection path. +- Use min/max summaries for broad-phase culling and conservative stepping. +- Add bracketed near-surface root refinement. +- Preserve direct compute evaluation as the oracle. Use the nearest resident + ancestor, or the base field before root residency, as the visible fallback. +- Remove repeated full-spectrum erosion from page-backed sphere tracing. + +Exit: direct and page-backed hits agree within declared page-filter error. + +### Phase 6: stable quadtree transitions + +- Select the cut by projected geometric error and residency. +- Generate child residual bands relative to parent low frequencies. +- Fade residuals without double counting. +- Exercise orbital descent, lateral flight, teleport, and reload. + +Exit: no visible popping, crawling, or terrain rearrangement. + +### Phase 7: differential shading and materials + +- Derive normals from composed displacement differentials. +- Incorporate authored residual gradients. +- Feed terrain evidence into client material policy. +- Lower unresolved bands into matching normal/material detail where useful. + +Exit: geometry, lighting, materials, and CPU queries agree on one surface. + +### Phase 8: performance and backend decision + +- Measure per-ray calls, octave cost, page generation, occupancy, residency, + cache hit rate, and frame cost from orbit to ground. +- Render coarse tessellated cube-sphere patches and perform a fixed, bounded + page-backed radial refinement in the pixel shader. +- Use the SDF backend only as a correctness oracle and diagnostic comparison. +- Prove candidate-buffer, depth, front/back-face, and reservoir ownership with + an actual visible capture before tuning appearance. +- Keep one terrain contract regardless of backend. + +Exit: the chosen lowering fits named frame and memory budgets. + +Initial budgets at 640 by 360 are: no more than 1.0 ms GPU for terrain patch +raster plus pixel refinement, no more than 1.0 ms amortized GPU for page +generation, no more than 64 MiB resident terrain pages, and 16.67 ms total GPU +for a 60 Hz frame. The first six-root measurement is 2,646 page samples, +approximately 83.3 KiB resident, and about 0.04--0.05 ms for the patch draw. +The visible six-root capture measures approximately 0.071 ms for patch raster +and refinement, 0.932 ms for the complete candidate pass, and 1.37 ms recorded +GPU work, with 2,646 samples occupying about 83.3 KiB. At the exterior ground +camera, eleven pages occupy 152.6 KiB, repeated page generation measures 0.624 +ms, the 64 by 64 patch measures 0.799 ms, and recorded GPU work is 1.649 ms. +The earlier 24--26 ms +frame and black capture came from the obsolete fractal-splat planet writer, +which repainted the patch and is no longer published by Zyphos. Page generation +and the selected raster lowering therefore fit the named budgets. + +## Verification matrix + +Numerical probes cover repeatability, CPU/GPU parity, scale invariance, +band-limited identity, direct-versus-page error, and finite behavior near weak +gradients and basis switches. + +Topology probes cover all twelve cube edges, all eight corners, sibling and +parent/child neighbors, independent request order, eviction, and page-border +overlap at several levels. + +The root-page GPU topology probe groups canonical spherical boundary samples +and verifies all `12 * (9 - 2) = 84` non-corner edge groups plus all eight +three-face corners within `1e-5`. + +Timeline probes cover direct load, orbital descent, cube-edge crossing, +transition midpoint and arrival, missing-page fallback, page arrival, eviction, +regeneration, reload, and field-version change. + +Visible debug views expose face/tile/level, residency, field version, active +wavelengths, height bounds, tangent gradient, final normal, ridge/gully masks, +parent residual blend, direct-minus-page error, and cross-edge discontinuity. + +Patch-native runtime modes currently provide: + +- mode 22: selected face, deepest level, and residual blend; +- mode 23: sampled height, maximum slope, and unresolved/error bound; +- mode 24: refined raster normal; +- mode 25: ridge, gully, and residual blend; +- mode 26: cube-edge proximity, cross-edge height delta, and missing-side + residency; +- mode 27: direct filtered erosion minus summed page height, normalized by the + declared error bound, with bound violations in blue; +- mode 28: content-version fingerprint, active spacing ratio, and deepest + resident level. + +## Performance questions + +- How many terrain evaluations occur per ray and resolved pixel? +- What is the cost per octave on target GPUs? +- What page size and border minimize generation and filtering waste? +- Can page generation overlap rendering without starving the D3D12 queue? +- What page budget preserves orbital and ground traversal locality? +- Does conservative SDF refinement remain competitive with opaque patches? + +No performance claim follows from a headless boot or successful shader compile. +Measure the actual visible path. + +## Completion audit + +1. The four-step correction primitive has matching CultMath C# and HLSL bodies; + CPU convergence probes, direct/page GPU readback, cube topology probes, and + transition debug captures cover interiors, edges, and lifecycle states. +2. The complete audit passes: 106 Engine tests, 170 Fractal tests, and 36 + CultMath tests. The current solution builds without warnings or errors. +3. The patch stages compile in the focused shader smoke and in visible runtime + captures. Runtime shader-build latency is tracked separately from frame cost. +4. Page generation, patch refinement, candidate/reservoir work, total GPU time, + and residency have separate measurements and fit the named budgets. + +## Sources + +- Rune Skovbo Johansen, *Fast and Gorgeous Erosion Filter*: + +- Alexander Goslin, *Terrain Diffusion / InfiniteDiffusion*: + +- Existing Fensalir cube-sphere and field architecture: + `research/rendering/cube-sphere-fractal-brushes.md` and + `docs/perfect-machine-architecture.md`. diff --git a/docs/temporal-sdf-gaussian-field.md b/docs/temporal-sdf-gaussian-field.md index 0ac385d..2a86c39 100644 --- a/docs/temporal-sdf-gaussian-field.md +++ b/docs/temporal-sdf-gaussian-field.md @@ -91,9 +91,9 @@ residency, tiled/bin dispatch, GPU accumulation, and clustered visibility. The active GPU boundary is deliberately narrow: ```text -Mimir calibration/device metadata --> AquariumGpuSensorFrame { calibrated cameras + shared GPU textures } --> D3D12 sensor metadata buffers + imported texture SRVs +Mimir calibration/device metadata or FieldEvidence camera Feature claims +-> AquariumGpuSensorFrame or planned GpuSensorFusion packets +-> D3D12 sensor metadata buffers + imported/leased texture SRVs -> D3D12 GPU sensor fusion compute shader -> RWStructuredBuffer -> instanced SDF Gaussian draw @@ -103,7 +103,11 @@ Mimir calibration/device metadata Ownership: - `AquariumGpuSensorFrame` is the live renderer contract for GPU-owned fusion - input. + input when a client publishes the legacy direct sensor frame. +- FieldEvidence camera-sensor `Feature` claims with `SensorObservation` + proposal policy are the current Mimir sensor-fusion contract. They plan to + the `GpuSensorFusion` backend, and `D3D12Renderer` derives sensor metadata + plus SRV bindings from resolved Texture2D resources. - `AquariumGpuFusionField` remains a temporary fallback/debug contract for already-derived point claims. - Mimir-owned adapters may convert app-specific point claims into compact seeds @@ -131,6 +135,16 @@ 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. +The current FieldEvidence cut also consumes Mimir camera texture leases without +restoring the retired direct sensor DTO path. LeapStereoIr, Bayer8/R8-style, +and YUY2 camera textures are sampled directly by the compute shader. YUY2 uses +the documented D3D12/DXGI SRV view path for `DXGI_FORMAT_YUY2`: Fensalir binds +an `R8G8B8A8_UNORM` SRV, reads `Y0/U/Y1/V`, and converts to RGB in +`D3D12GpuSensorFusion.hlsl` before feature scoring. Provenance: +. +Packed video formats must not pretend to be ordinary RGBA; the declared format +selects the decode path. + 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 diff --git a/packages/AquaSynth.Core.0.1.0-alpha.2.nupkg b/packages/AquaSynth.Core.0.1.0-alpha.2.nupkg new file mode 100644 index 0000000..3b7ea64 Binary files /dev/null and b/packages/AquaSynth.Core.0.1.0-alpha.2.nupkg differ diff --git a/packages/AquaSynth.Faust.0.1.0-alpha.2.nupkg b/packages/AquaSynth.Faust.0.1.0-alpha.2.nupkg new file mode 100644 index 0000000..45b861f Binary files /dev/null and b/packages/AquaSynth.Faust.0.1.0-alpha.2.nupkg differ diff --git a/packages/AquaSynth.Faust.0.1.0-alpha.3.nupkg b/packages/AquaSynth.Faust.0.1.0-alpha.3.nupkg new file mode 100644 index 0000000..bd1391e Binary files /dev/null and b/packages/AquaSynth.Faust.0.1.0-alpha.3.nupkg differ diff --git a/research/rendering/fractal-projection-report.md b/research/rendering/fractal-projection-report.md index b96e462..0c0d025 100644 --- a/research/rendering/fractal-projection-report.md +++ b/research/rendering/fractal-projection-report.md @@ -11,12 +11,12 @@ Lower mean absolute relative area error is better for terrain density. | 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 | +| tangent | PositiveX | 16 | 0.523872 | 0.873549 | 1.175269 | 0.075933 | 0.088550 | +| tangent | NegativeX | 16 | 0.523872 | 0.873549 | 1.175269 | 0.075933 | 0.088550 | +| tangent | PositiveY | 16 | 0.523872 | 0.873549 | 1.175269 | 0.075933 | 0.088550 | +| tangent | NegativeY | 16 | 0.523872 | 0.873549 | 1.175269 | 0.075933 | 0.088550 | +| tangent | PositiveZ | 16 | 0.523866 | 0.873549 | 1.175245 | 0.075944 | 0.088557 | +| tangent | NegativeZ | 16 | 0.523866 | 0.873549 | 1.175245 | 0.075944 | 0.088557 | 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. diff --git a/scripts/capture-reservoir-mode-sequence.ps1 b/scripts/capture-reservoir-mode-sequence.ps1 index 211b492..a7a96e1 100644 --- a/scripts/capture-reservoir-mode-sequence.ps1 +++ b/scripts/capture-reservoir-mode-sequence.ps1 @@ -120,6 +120,7 @@ foreach ($mode in $modes) { RenderDebugMode = $debugMode ReservoirScale = $captureScale SpatialReuseBudget = $captureSpatialReuseBudget + CaptureSeconds = 0.0 Suffix = $suffix Path = $outputPath }) @@ -128,6 +129,7 @@ foreach ($mode in $modes) { Write-Host ("Capturing {0} ready={1} debug={2} -> {3}" -f $mode, $readyFrame, $debugMode, $outputPath) $env:AQUARIUM_HEADLESS_READY_FRAMES = [string][Math]::Max(1, $readyFrame) + $captureTimer = [System.Diagnostics.Stopwatch]::StartNew() $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) @@ -136,6 +138,7 @@ foreach ($mode in $modes) { } $process.WaitForExit() + $captureTimer.Stop() $process.Refresh() $exitCode = $process.ExitCode if ($null -eq $exitCode -and (Test-Path $outputPath)) { @@ -155,6 +158,7 @@ foreach ($mode in $modes) { RenderDebugMode = $debugMode ReservoirScale = $captureScale SpatialReuseBudget = $captureSpatialReuseBudget + CaptureSeconds = $captureTimer.Elapsed.TotalSeconds Suffix = $suffix Path = $outputPath }) diff --git a/scripts/measure-reservoir-loss-curve.ps1 b/scripts/measure-reservoir-loss-curve.ps1 new file mode 100644 index 0000000..899956d --- /dev/null +++ b/scripts/measure-reservoir-loss-curve.ps1 @@ -0,0 +1,255 @@ +param( + [string]$CaptureDirectory = "", + [string]$Stamp, + [string]$ManifestPath = "", + [string]$CandidateMode = "native", + [string]$OutputPath = "" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +if ([string]::IsNullOrWhiteSpace($CaptureDirectory)) { + $CaptureDirectory = Join-Path $repoRoot "artifacts\fensalir-captures" +} +if ([string]::IsNullOrWhiteSpace($Stamp)) { + throw "Stamp is required, for example: -Stamp 20260530-003006" +} + +$CaptureDirectory = [System.IO.Path]::GetFullPath($CaptureDirectory) +if ([string]::IsNullOrWhiteSpace($ManifestPath)) { + $ManifestPath = Join-Path $CaptureDirectory "reservoir-sequence-$Stamp-manifest.csv" +} +if ([string]::IsNullOrWhiteSpace($OutputPath)) { + $OutputPath = Join-Path $CaptureDirectory "reservoir-loss-curve-$Stamp.md" +} +$ManifestPath = [System.IO.Path]::GetFullPath($ManifestPath) +$OutputPath = [System.IO.Path]::GetFullPath($OutputPath) +$csvPath = [System.IO.Path]::ChangeExtension($OutputPath, ".csv") + +if (-not (Test-Path -LiteralPath $ManifestPath)) { + throw "Missing sequence manifest: $ManifestPath" +} + +Add-Type -AssemblyName System.Drawing + +$captures = Import-Csv -Path $ManifestPath +$byKey = @{} +foreach ($capture in $captures) { + $key = "{0}|{1}|{2}" -f $capture.Mode, $capture.ReadyFrames, $capture.Suffix + $byKey[$key] = $capture +} + +function Find-Capture { + param( + [string]$Mode, + [int]$ReadyFrames, + [string]$Suffix + ) + + $key = "{0}|{1}|{2}" -f $Mode, $ReadyFrames, $Suffix + if ($byKey.ContainsKey($key)) { + return $byKey[$key] + } + + return $null +} + +function Find-PreferredMaskPath { + param( + [string]$Mode, + [int]$ReadyFrames + ) + + $disocclusion = Find-Capture -Mode $Mode -ReadyFrames $ReadyFrames -Suffix "disocclusion" + if ($null -ne $disocclusion) { + return $disocclusion.Path + } + + $rejection = Find-Capture -Mode $Mode -ReadyFrames $ReadyFrames -Suffix "rejection" + if ($null -ne $rejection) { + return $rejection.Path + } + + return "" +} + +function Measure-Loss { + param( + [string]$CandidatePath, + [string]$ReferencePath, + [string]$Label, + [int]$ReadyFrames, + [string]$CandidateMaskPath = "", + [string]$ReferenceMaskPath = "" + ) + + $candidate = [System.Drawing.Bitmap]::new($CandidatePath) + $reference = [System.Drawing.Bitmap]::new($ReferencePath) + $candidateMask = if ([string]::IsNullOrWhiteSpace($CandidateMaskPath)) { $null } else { [System.Drawing.Bitmap]::new($CandidateMaskPath) } + $referenceMask = if ([string]::IsNullOrWhiteSpace($ReferenceMaskPath)) { $null } else { [System.Drawing.Bitmap]::new($ReferenceMaskPath) } + try { + if ($candidate.Width -ne $reference.Width -or $candidate.Height -ne $reference.Height) { + throw "Capture dimensions differ for $Label." + } + if ($candidateMask -ne $null -and ($candidateMask.Width -ne $candidate.Width -or $candidateMask.Height -ne $candidate.Height)) { + throw "Candidate mask dimensions differ for $Label." + } + if ($referenceMask -ne $null -and ($referenceMask.Width -ne $candidate.Width -or $referenceMask.Height -ne $candidate.Height)) { + throw "Reference mask dimensions differ for $Label." + } + + $measuredPixels = 0 + $changedPixels = 0 + $absoluteErrorSum = 0.0 + $squaredErrorSum = 0.0 + $maxChannelDelta = 0 + for ($y = 0; $y -lt $candidate.Height; $y++) { + for ($x = 0; $x -lt $candidate.Width; $x++) { + $maskActive = $true + if ($candidateMask -ne $null -or $referenceMask -ne $null) { + $maskActive = $false + if ($candidateMask -ne $null) { + $maskColor = $candidateMask.GetPixel($x, $y) + $maskActive = $maskActive -or ([Math]::Max([int]$maskColor.R, [Math]::Max([int]$maskColor.G, [int]$maskColor.B)) -gt 12) + } + if ($referenceMask -ne $null) { + $maskColor = $referenceMask.GetPixel($x, $y) + $maskActive = $maskActive -or ([Math]::Max([int]$maskColor.R, [Math]::Max([int]$maskColor.G, [int]$maskColor.B)) -gt 12) + } + } + + if (-not $maskActive) { + continue + } + + $a = $candidate.GetPixel($x, $y) + $b = $reference.GetPixel($x, $y) + $dr = [int]$a.R - [int]$b.R + $dg = [int]$a.G - [int]$b.G + $db = [int]$a.B - [int]$b.B + $maxDelta = [Math]::Max([Math]::Abs($dr), [Math]::Max([Math]::Abs($dg), [Math]::Abs($db))) + if ($maxDelta -gt 0) { + $changedPixels++ + } + + $absoluteErrorSum += [Math]::Abs($dr) + [Math]::Abs($dg) + [Math]::Abs($db) + $squaredErrorSum += ($dr * $dr) + ($dg * $dg) + ($db * $db) + if ($maxDelta -gt $maxChannelDelta) { + $maxChannelDelta = $maxDelta + } + $measuredPixels++ + } + } + + $totalPixels = $candidate.Width * $candidate.Height + $pixelDenominator = if ($measuredPixels -gt 0) { $measuredPixels } else { 1 } + $channelDenominator = $pixelDenominator * 3.0 + $mse = $squaredErrorSum / $channelDenominator + $rmse = [Math]::Sqrt($mse) + $psnr = if ($mse -le 0.0) { [double]::PositiveInfinity } else { 10.0 * [Math]::Log10((255.0 * 255.0) / $mse) } + + [pscustomobject]@{ + Label = $Label + ReadyFrames = $ReadyFrames + MeasuredPixels = $measuredPixels + TotalPixels = $totalPixels + FramePercent = $measuredPixels / $totalPixels + ChangedPixels = $changedPixels + ChangedPercent = $changedPixels / $pixelDenominator + MAE = $absoluteErrorSum / $channelDenominator + MSE = $mse + RMSE = $rmse + PSNR = $psnr + MaxChannelDelta = $maxChannelDelta + CandidatePath = $CandidatePath + ReferencePath = $ReferencePath + CandidateMaskPath = $CandidateMaskPath + ReferenceMaskPath = $ReferenceMaskPath + } + } + finally { + if ($candidateMask -ne $null) { + $candidateMask.Dispose() + } + if ($referenceMask -ne $null) { + $referenceMask.Dispose() + } + $candidate.Dispose() + $reference.Dispose() + } +} + +$readyFrames = @($captures | + Where-Object { $_.Mode -eq $CandidateMode -and $_.Suffix -eq "final" } | + ForEach-Object { [int]$_.ReadyFrames } | + Sort-Object -Unique) +if ($readyFrames.Count -eq 0) { + throw "No final candidate captures found for mode '$CandidateMode'." +} + +$results = [System.Collections.Generic.List[object]]::new() +foreach ($readyFrame in $readyFrames) { + $candidate = Find-Capture -Mode $CandidateMode -ReadyFrames $readyFrame -Suffix "final" + $reference = Find-Capture -Mode "reference" -ReadyFrames $readyFrame -Suffix "final" + if ($null -eq $candidate -or $null -eq $reference) { + continue + } + + $results.Add((Measure-Loss -CandidatePath $candidate.Path -ReferencePath $reference.Path -Label "final" -ReadyFrames $readyFrame)) + + $candidateMask = Find-PreferredMaskPath -Mode $CandidateMode -ReadyFrames $readyFrame + $referenceMask = Find-PreferredMaskPath -Mode "reference" -ReadyFrames $readyFrame + if (-not [string]::IsNullOrWhiteSpace($candidateMask) -or -not [string]::IsNullOrWhiteSpace($referenceMask)) { + $results.Add((Measure-Loss -CandidatePath $candidate.Path -ReferencePath $reference.Path -Label "masked" -ReadyFrames $readyFrame -CandidateMaskPath $candidateMask -ReferenceMaskPath $referenceMask)) + } +} + +if ($results.Count -eq 0) { + throw "No candidate/reference final pairs found for stamp '$Stamp'." +} + +$results | Export-Csv -NoTypeInformation -Path $csvPath + +$lines = [System.Collections.Generic.List[string]]::new() +$lines.Add("# Reservoir Reconstruction Loss Curve") +$lines.Add("") +$lines.Add(("Stamp: ``{0}``" -f $Stamp)) +$lines.Add(("Candidate mode: ``{0}``" -f $CandidateMode)) +$lines.Add("") +$lines.Add("| Scope | Ready frames | Measured/frame % | Changed % | MAE | MSE | RMSE | PSNR dB | Max delta |") +$lines.Add("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |") +foreach ($result in ($results | Sort-Object ReadyFrames, Label)) { + $psnrText = if ([double]::IsInfinity($result.PSNR)) { "inf" } else { $result.PSNR.ToString("N3") } + $lines.Add(("| {0} | {1} | {2} | {3} | {4} | {5} | {6} | {7} | {8} |" -f + $result.Label, + $result.ReadyFrames, + $result.FramePercent.ToString("P4"), + $result.ChangedPercent.ToString("P4"), + $result.MAE.ToString("N4"), + $result.MSE.ToString("N4"), + $result.RMSE.ToString("N4"), + $psnrText, + $result.MaxChannelDelta)) +} +$lines.Add("") +$lines.Add("Loss is RGB reconstruction error against the same-time native-domain reference capture. The reference is higher work-grid scale and higher spatial reuse budget when the manifest was captured that way; it is not an offline path-traced ground truth.") +Set-Content -LiteralPath $OutputPath -Value $lines -Encoding UTF8 + +Write-Host "Reservoir reconstruction loss curve:" +foreach ($result in ($results | Sort-Object ReadyFrames, Label)) { + $psnrText = if ([double]::IsInfinity($result.PSNR)) { "inf" } else { $result.PSNR.ToString("N3") } + Write-Host (" {0} f{1}: measured {2} changed {3} MAE {4} RMSE {5} PSNR {6} dB max {7}" -f + $result.Label, + $result.ReadyFrames, + $result.FramePercent.ToString("P4"), + $result.ChangedPercent.ToString("P4"), + $result.MAE.ToString("N4"), + $result.RMSE.ToString("N4"), + $psnrText, + $result.MaxChannelDelta) +} +Write-Host "Wrote:" +Write-Host " $OutputPath" +Write-Host " $csvPath" diff --git a/src/Aquarium.Engine.Contracts/Aquarium.Engine.Contracts.csproj b/src/Aquarium.Engine.Contracts/Aquarium.Engine.Contracts.csproj index 9ed914b..908afcd 100644 --- a/src/Aquarium.Engine.Contracts/Aquarium.Engine.Contracts.csproj +++ b/src/Aquarium.Engine.Contracts/Aquarium.Engine.Contracts.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/src/Aquarium.Engine.Contracts/Audio/AquariumAudio.cs b/src/Aquarium.Engine.Contracts/Audio/AquariumAudio.cs index 05e8fc8..c290253 100644 --- a/src/Aquarium.Engine.Contracts/Audio/AquariumAudio.cs +++ b/src/Aquarium.Engine.Contracts/Audio/AquariumAudio.cs @@ -8,6 +8,7 @@ public sealed class AquariumAudioDocument private readonly ConcurrentQueue controlFrames = new(); private readonly ConcurrentQueue streamingDspPrograms = new(); private readonly ConcurrentQueue streamingAudioBlocks = new(); + private readonly ConcurrentQueue captureRequests = new(); public static AquariumAudioDocument Empty { get; } = new(); @@ -131,6 +132,38 @@ public sealed class AquariumAudioDocument return drained; } + + public void EnqueueSystemLoopbackCapture( + string profileId, + string sourceId, + string displayName, + bool enabled, + long sequence) + { + if (string.IsNullOrWhiteSpace(profileId)) + { + return; + } + + captureRequests.Enqueue(new AquariumAudioCaptureRequest( + profileId, + string.IsNullOrWhiteSpace(sourceId) ? "system-loopback" : sourceId, + string.IsNullOrWhiteSpace(displayName) ? "System audio" : displayName, + AquariumAudioCaptureKind.SystemLoopback, + enabled, + sequence)); + } + + public IReadOnlyList DrainCaptureRequests(int maxRequests = 8) + { + var drained = new List(); + while (drained.Count < maxRequests && captureRequests.TryDequeue(out var request)) + { + drained.Add(request); + } + + return drained; + } } public sealed record AquariumPcmAudioChunk(float[] MonoSamples, int SampleRate, float LeftGain = 1.0f, float RightGain = 1.0f); @@ -178,6 +211,19 @@ public sealed record AquariumStreamingAudioChannel( string SourceId, float[] Samples); +public enum AquariumAudioCaptureKind +{ + SystemLoopback +} + +public sealed record AquariumAudioCaptureRequest( + string ProfileId, + string SourceId, + string DisplayName, + AquariumAudioCaptureKind Kind, + bool Enabled, + long Sequence); + public sealed record AquariumAudioStemFrame( string ProfileId, IReadOnlyList Channels, diff --git a/src/Aquarium.Engine.Contracts/GraphicsSettings.cs b/src/Aquarium.Engine.Contracts/GraphicsSettings.cs index 7ef15ff..5d711d5 100644 --- a/src/Aquarium.Engine.Contracts/GraphicsSettings.cs +++ b/src/Aquarium.Engine.Contracts/GraphicsSettings.cs @@ -10,7 +10,7 @@ public readonly record struct GraphicsSettings( float FieldReservoirSpatialReuseBudget) { public const int MinRenderDebugMode = 0; - public const int MaxRenderDebugMode = 20; + public const int MaxRenderDebugMode = 28; public const int FieldReservoirModeNativeDomain = 0; public const int FieldReservoirModeTexelBaseline = 1; public const float MinFieldReservoirScale = 0.25f; diff --git a/src/Aquarium.Engine.Contracts/Input/InputState.cs b/src/Aquarium.Engine.Contracts/Input/InputState.cs index 8911258..3f2930e 100644 --- a/src/Aquarium.Engine.Contracts/Input/InputState.cs +++ b/src/Aquarium.Engine.Contracts/Input/InputState.cs @@ -128,8 +128,13 @@ public enum KeyCode Delete, Enter, LeftArrow, + UpArrow, RightArrow, + DownArrow, Home, End, + PageUp, + PageDown, Shift, + Control, } diff --git a/src/Aquarium.Engine.Contracts/Render/AquariumApp.cs b/src/Aquarium.Engine.Contracts/Render/AquariumApp.cs index 993d960..87f9c96 100644 --- a/src/Aquarium.Engine.Contracts/Render/AquariumApp.cs +++ b/src/Aquarium.Engine.Contracts/Render/AquariumApp.cs @@ -156,6 +156,24 @@ public sealed class AquariumShaderBuilder(AquariumShaderManifest manifest) return this; } + public AquariumShaderBuilder PlanetarySurfacePage(string path) + { + manifest.PlanetarySurfacePage(path); + return this; + } + + public AquariumShaderBuilder PlanetarySurfacePageSummary(string path) + { + manifest.PlanetarySurfacePageSummary(path); + return this; + } + + public AquariumShaderBuilder PlanetarySurfacePatch(string path) + { + manifest.PlanetarySurfacePatch(path); + return this; + } + public AquariumShaderBuilder Include(string path) { manifest.Include(path); diff --git a/src/Aquarium.Engine.Contracts/Render/AquariumFieldEvidence.cs b/src/Aquarium.Engine.Contracts/Render/AquariumFieldEvidence.cs index 0faa4ae..fce9351 100644 --- a/src/Aquarium.Engine.Contracts/Render/AquariumFieldEvidence.cs +++ b/src/Aquarium.Engine.Contracts/Render/AquariumFieldEvidence.cs @@ -62,6 +62,7 @@ public enum AquariumFieldBackendKind SurfacePage = 5, TubeField = 6, DebugOverlay = 7, + GpuSensorFusion = 8, } public enum AquariumFieldResourceKind @@ -1124,6 +1125,9 @@ public static class AquariumFieldLoweringPlanner AquariumFieldEncoding.Feature when domainKind == AquariumFieldDomainKind.CameraSensor && request.ProposalKind == AquariumFieldProposalKind.DeterministicStructural => AquariumFieldBackendKind.DebugOverlay, + AquariumFieldEncoding.Feature + when domainKind == AquariumFieldDomainKind.CameraSensor && + request.ProposalKind == AquariumFieldProposalKind.SensorObservation => AquariumFieldBackendKind.GpuSensorFusion, _ => AquariumFieldBackendKind.Unknown, }; diff --git a/src/Aquarium.Engine.Contracts/Render/AquariumRenderPlan.cs b/src/Aquarium.Engine.Contracts/Render/AquariumRenderPlan.cs index 96537b3..765828d 100644 --- a/src/Aquarium.Engine.Contracts/Render/AquariumRenderPlan.cs +++ b/src/Aquarium.Engine.Contracts/Render/AquariumRenderPlan.cs @@ -36,6 +36,12 @@ public sealed class AquariumShaderManifest public string FractalSplatRenderShader { get; private set; } = "D3D12FractalSplatRender.hlsl"; + public string PlanetarySurfacePageShader { get; private set; } = "D3D12PlanetarySurfacePage.hlsl"; + + public string PlanetarySurfacePageSummaryShader { get; private set; } = "D3D12PlanetarySurfacePageSummary.hlsl"; + + public string PlanetarySurfacePatchShader { get; private set; } = "D3D12PlanetarySurfacePatch.hlsl"; + public string PostShader { get; private set; } = "D3D12Post.hlsl"; public string SdfCommonInclude { get; private set; } = "D3D12SdfCommon.hlsli"; @@ -92,6 +98,24 @@ public sealed class AquariumShaderManifest return this; } + public AquariumShaderManifest PlanetarySurfacePage(string path) + { + PlanetarySurfacePageShader = path; + return this; + } + + public AquariumShaderManifest PlanetarySurfacePageSummary(string path) + { + PlanetarySurfacePageSummaryShader = path; + return this; + } + + public AquariumShaderManifest PlanetarySurfacePatch(string path) + { + PlanetarySurfacePatchShader = path; + return this; + } + public AquariumShaderManifest Post(string path) { PostShader = path; @@ -312,6 +336,7 @@ public enum AquariumGpuSensorPixelFormat R16Float, Rg8Unorm, LeapPackedMap, + Yuy2, } public readonly record struct AquariumExternalGpuTexture( @@ -336,7 +361,8 @@ public readonly record struct AquariumGpuSensorCamera( int Height, int FirstTextureIndex, int TextureCount, - long TimestampNs); + long TimestampNs, + AquariumGpuSensorPixelFormat PixelFormat = AquariumGpuSensorPixelFormat.Unknown); public sealed class AquariumGpuSensorFrame { @@ -497,6 +523,8 @@ public sealed class AquariumSceneState public AquariumFractalReservoirField FractalReservoirField { get; init; } = AquariumFractalReservoirField.Empty; + public AquariumPlanetarySurfacePageSet PlanetarySurfacePages { get; init; } = AquariumPlanetarySurfacePageSet.Empty; + public AquariumGpuSensorFrame GpuSensorFrame { get; init; } = AquariumGpuSensorFrame.Empty; public AquariumAcousticFieldFrame AcousticFieldFrame { get; init; } = AquariumAcousticFieldFrame.Empty; @@ -509,9 +537,189 @@ public sealed class AquariumSceneState public AquariumBufferFieldFrame BufferFieldFrame { get; init; } = AquariumBufferFieldFrame.Empty; + public AquariumBokushoBrushFrame BokushoBrushFrame { get; init; } = AquariumBokushoBrushFrame.Empty; + public AquariumSplineFrame SplineFrame { get; init; } = AquariumSplineFrame.Empty; } +[StructLayout(LayoutKind.Sequential)] +public readonly record struct AquariumPlanetarySurfacePageInput(Vector4 DirectionRadius, Vector4 Sampling); + +[StructLayout(LayoutKind.Sequential)] +public readonly record struct AquariumPlanetarySurfacePageMetadata(Vector4 Address, Vector4 Layout, Vector4 Bounds, Vector4 State); + +public sealed class AquariumPlanetarySurfacePage +{ + public long ContentKey { get; init; } + public IReadOnlyList Samples { get; init; } = []; + public AquariumPlanetarySurfacePageMetadata Metadata { get; init; } + public bool HasInput => ContentKey != 0 && Samples.Count > 0; +} + +public sealed class AquariumPlanetarySurfacePageSet +{ + public static AquariumPlanetarySurfacePageSet Empty { get; } = new(); + public string GeneratorEntryPoint { get; init; } = "D3D12PlanetaryTerrainPageCS"; + public long ContentVersion { get; init; } + public long PresentationVersion { get; init; } + public IReadOnlyList Pages { get; init; } = []; + public bool RenderCoarsePatches { get; init; } + public int PatchCells { get; init; } = 16; + public int SampleCount => Pages.Sum(page => page.Samples.Count); + public bool HasInput => ContentVersion != 0 && Pages.Count > 0 && Pages.All(page => page.HasInput); +} + +public sealed class AquariumBokushoBrushFrame +{ + public static AquariumBokushoBrushFrame Empty { get; } = new(); + + public int TuftCount { get; init; } + + public int SampleCount { get; init; } + + public float PhysicsHz { get; init; } = 500.0f; + + public float BrushRadius { get; init; } = 2.2f; + + public float Pressure { get; init; } = 0.72f; + + public float InkLoad { get; init; } = 0.90f; + + public float Wetness { get; init; } = 0.86f; + + public float Splay { get; init; } = 0.72f; + + public float Bend { get; init; } = 0.58f; + + public float Friction { get; init; } = 0.62f; + + public float RadiusScale { get; init; } = 1.0f; + + public float PressureScale { get; init; } = 1.0f; + + public float NormalScale { get; init; } = 1.0f; + + public float TangentScale { get; init; } = 1.0f; + + public Vector4 StrokeP0 { get; init; } = new(-7.2f, 1.0f, 0.0f, 0.0f); + + public Vector4 StrokeP1 { get; init; } = new(-2.4f, -1.8f, 0.0f, 0.0f); + + public Vector4 StrokeP2 { get; init; } = new(2.4f, -1.6f, 0.0f, 0.0f); + + public Vector4 StrokeP3 { get; init; } = new(7.2f, 0.8f, 0.0f, 0.0f); + + public IReadOnlyList Strokes { get; init; } = []; + + public IReadOnlyList SourcePoints { get; init; } = []; + + public bool HasInput => TuftCount > 0 && SampleCount > 1 && PhysicsHz > 0.0f; + + public AquariumBokushoBrushFrame Normalized() => new() + { + TuftCount = Math.Clamp(TuftCount, 1, 4096), + SampleCount = Math.Clamp(SampleCount, 2, 4096), + PhysicsHz = Math.Clamp(PhysicsHz, 60.0f, 2000.0f), + BrushRadius = MathF.Max(0.0001f, BrushRadius), + Pressure = Math.Clamp(Pressure, 0.0f, 2.0f), + InkLoad = Math.Clamp(InkLoad, 0.0f, 3.0f), + Wetness = Math.Clamp(Wetness, 0.0f, 1.6f), + Splay = Math.Clamp(Splay, 0.0f, 2.0f), + Bend = Math.Clamp(Bend, 0.0f, 2.4f), + Friction = Math.Clamp(Friction, 0.0f, 1.0f), + RadiusScale = Math.Clamp(RadiusScale, 0.1f, 4.0f), + PressureScale = Math.Clamp(PressureScale, 0.1f, 4.0f), + NormalScale = Math.Clamp(NormalScale, 0.1f, 4.0f), + TangentScale = Math.Clamp(TangentScale, 0.1f, 4.0f), + StrokeP0 = StrokeP0, + StrokeP1 = StrokeP1, + StrokeP2 = StrokeP2, + StrokeP3 = StrokeP3, + Strokes = Strokes.Select(stroke => stroke.Normalized()).ToArray(), + SourcePoints = SourcePoints.Select(point => point.Normalized()).ToArray(), + }; +} + +public sealed class AquariumBokushoSourcePoint +{ + public int SourceStrokeId { get; init; } = -1; + + public float T { get; init; } + + public Vector2 Position { get; init; } + + public AquariumBokushoSourcePoint Normalized() => new() + { + SourceStrokeId = SourceStrokeId, + T = Math.Clamp(T, 0.0f, 1.0f), + Position = Position, + }; +} + +public sealed class AquariumBokushoBrushStroke +{ + public Vector4 StrokeP0 { get; init; } = new(-7.2f, 1.0f, 0.0f, 0.0f); + + public Vector4 StrokeP1 { get; init; } = new(-2.4f, -1.8f, 0.0f, 0.0f); + + public Vector4 StrokeP2 { get; init; } = new(2.4f, -1.6f, 0.0f, 0.0f); + + public Vector4 StrokeP3 { get; init; } = new(7.2f, 0.8f, 0.0f, 0.0f); + + public float RadiusScale { get; init; } = 1.0f; + + public float PressureScale { get; init; } = 1.0f; + + public float NormalScale { get; init; } = 1.0f; + + public float TangentScale { get; init; } = 1.0f; + + public float ShaftTilt { get; init; } + + public float ShaftRotation { get; init; } + + public float GripHeight { get; init; } = 1.0f; + + public float Compliance { get; init; } = 1.0f; + + public float EntryTaper { get; init; } = 0.10f; + + public float ExitTaper { get; init; } = 0.14f; + + public float PigmentScale { get; init; } = 1.0f; + + public float SplitScale { get; init; } = 1.0f; + + public float SegmentStart { get; init; } + + public float SegmentEnd { get; init; } = 1.0f; + + public int SourceStrokeId { get; init; } = -1; + + public AquariumBokushoBrushStroke Normalized() => new() + { + StrokeP0 = StrokeP0, + StrokeP1 = StrokeP1, + StrokeP2 = StrokeP2, + StrokeP3 = StrokeP3, + RadiusScale = Math.Clamp(RadiusScale, 0.1f, 4.0f), + PressureScale = Math.Clamp(PressureScale, 0.1f, 4.0f), + NormalScale = Math.Clamp(NormalScale, 0.1f, 4.0f), + TangentScale = Math.Clamp(TangentScale, 0.1f, 4.0f), + ShaftTilt = Math.Clamp(ShaftTilt, -1.5f, 1.5f), + ShaftRotation = Math.Clamp(ShaftRotation, -2.0f, 2.0f), + GripHeight = Math.Clamp(GripHeight, 0.2f, 2.4f), + Compliance = Math.Clamp(Compliance, 0.1f, 2.5f), + EntryTaper = Math.Clamp(EntryTaper, 0.01f, 0.50f), + ExitTaper = Math.Clamp(ExitTaper, 0.01f, 0.50f), + PigmentScale = Math.Clamp(PigmentScale, 0.05f, 4.0f), + SplitScale = Math.Clamp(SplitScale, 0.0f, 4.0f), + SegmentStart = Math.Clamp(SegmentStart, 0.0f, 1.0f), + SegmentEnd = Math.Clamp(SegmentEnd, 0.0f, 1.0f), + SourceStrokeId = SourceStrokeId, + }; +} + public sealed class AquariumBufferFieldFrame { public static AquariumBufferFieldFrame Empty { get; } = new(); diff --git a/src/Aquarium.Engine.Contracts/Render/BokushoBrushSimulation.cs b/src/Aquarium.Engine.Contracts/Render/BokushoBrushSimulation.cs new file mode 100644 index 0000000..c306851 --- /dev/null +++ b/src/Aquarium.Engine.Contracts/Render/BokushoBrushSimulation.cs @@ -0,0 +1,1076 @@ +using System.Numerics; +using CultMath; + +namespace Aquarium.Engine.Render; + +public static class BokushoBrushSimulation +{ + public const float PageDensityScale = 65535.0f; + public const float PagePatchTransferGain = 0.0044f; + public const float PagePatchTangentRadiusScale = 0.32f; + public const float PagePatchNormalRadiusScale = 0.32f; + public const float PagePatchMinimumRadius = 0.001f; + public const float PagePatchWorldQuantum = 1.0f / 1024.0f; + private const float BristleTangentWorldQuantum = 1.0f / 4096.0f; + private const float StrokeRhythmQuantum = 1.0f / 4096.0f; + public const uint PagePatchInkUnits = 2048u; + public const uint PagePatchCoverageUnits = 1024u; + public const uint PagePatchTransferEncodedGain = 288u; + public const uint PageDensityContributionQuantum = 8u; + + public static BokushoBrushSimulationResult Evaluate(AquariumBokushoBrushFrame source) + { + var frame = source.HasInput ? source.Normalized() : AquariumBokushoBrushFrame.Empty; + if (!frame.HasInput) + { + return BokushoBrushSimulationResult.Empty; + } + + var sampleCount = Math.Clamp(frame.SampleCount, 2, 4096); + var tuftCount = Math.Clamp(frame.TuftCount, 1, 4096); + var strokeCount = EffectiveStrokes(frame).Length; + var trace = new float[checked(sampleCount * tuftCount * strokeCount)]; + var canvas = new float[trace.Length]; + var tips = new Vector4[trace.Length]; + Evaluate(frame, trace, canvas, tips); + return new BokushoBrushSimulationResult(sampleCount, tuftCount, strokeCount, trace, canvas, tips); + } + + public static void Evaluate(AquariumBokushoBrushFrame source, Span trace, Span canvas) + { + var frame = source.Normalized(); + var sampleCount = Math.Clamp(frame.SampleCount, 2, 4096); + var tuftCount = Math.Clamp(frame.TuftCount, 1, 4096); + var strokeCount = EffectiveStrokes(frame).Length; + var tips = new Vector4[checked(sampleCount * tuftCount * strokeCount)]; + Evaluate(frame, trace, canvas, tips); + } + + public static void Evaluate(AquariumBokushoBrushFrame source, Span trace, Span canvas, Span tips) + { + var frame = source.Normalized(); + var sampleCount = Math.Clamp(frame.SampleCount, 2, 4096); + var tuftCount = Math.Clamp(frame.TuftCount, 1, 4096); + var strokes = EffectiveStrokes(frame); + var sourcePaths = SourcePathIndex.Create(frame.SourcePoints); + var valueCount = checked(sampleCount * tuftCount * strokes.Length); + if (trace.Length < valueCount) + { + throw new ArgumentException("Trace buffer is smaller than the brush simulation frame.", nameof(trace)); + } + + if (canvas.Length < valueCount) + { + throw new ArgumentException("Canvas buffer is smaller than the brush simulation frame.", nameof(canvas)); + } + + if (tips.Length < valueCount) + { + throw new ArgumentException("Tip buffer is smaller than the brush simulation frame.", nameof(tips)); + } + + var wetness = Saturate(frame.Wetness / 1.6f); + var load = Math.Clamp(frame.InkLoad / 1.05f, 0.0f, 2.7f); + var splay = Saturate(frame.Splay / 2.0f); + var bend = Saturate(frame.Bend / 2.4f); + var friction = Saturate(frame.Friction); + + for (var strokeIndex = 0; strokeIndex < strokes.Length; strokeIndex++) + { + var stroke = strokes[strokeIndex]; + var chainStart = ChainStart(strokes, strokeIndex); + var strokeStart = Math.Clamp(stroke.SegmentStart, 0.0f, 1.0f); + var pressure = Saturate(frame.Pressure * stroke.PressureScale * 0.5f) * 2.0f; + var radius = MathF.Max(frame.BrushRadius * stroke.RadiusScale, 0.0001f); + var normalRadius = MathF.Max(radius * stroke.NormalScale, 0.0001f); + var tangentRadius = MathF.Max(radius * stroke.TangentScale, 0.0001f); + var poseTilt = stroke.ShaftTilt; + var poseRotation = stroke.ShaftRotation; + var gripHeight = stroke.GripHeight; + var compliance = stroke.Compliance; + + for (var tuft = 0; tuft < tuftCount; tuft++) + { + var laneT = tuftCount <= 1 ? 0.5f : tuft / (float)(tuftCount - 1); + var restOffset = laneT * 2.0f - 1.0f; + var edge = MathF.Abs(restOffset); + var laneHash = LaneHash(chainStart, tuft, 17); + var laneLoad = 0.76f + laneHash * 0.34f; + var seedStroke = strokes[chainStart]; + var seedPressure = Saturate(frame.Pressure * seedStroke.PressureScale * 0.5f) * 2.0f; + var seedRadius = MathF.Max(frame.BrushRadius * seedStroke.RadiusScale, 0.0001f); + var seedNormalRadius = MathF.Max(seedRadius * seedStroke.NormalScale, 0.0001f); + var seedSplit = Saturate((0.28f - LaneHash(chainStart, tuft, 53)) * 3.0f) * Saturate((edge - 0.20f) * 1.5f) * seedStroke.SplitScale; + var initialCohesion = LaneCohesion(edge, seedSplit, wetness, seedPressure); + var tip = StrokePoint(sourcePaths, seedStroke, 0.0f); + var poseBias = seedStroke.ShaftTilt * 0.18f + seedStroke.ShaftRotation * 0.08f; + var offset = (restOffset + poseBias * (1.0f - edge * 0.35f)) * seedNormalRadius * (0.42f + splay * 0.38f) + (laneHash - 0.5f) * seedNormalRadius * 0.05f; + var stateLoad = load * (0.86f + initialCohesion * 0.48f) * (1.0f - edge * 0.08f) * laneLoad * (1.0f - seedSplit * 0.18f); + var stateWet = wetness * (0.74f + initialCohesion * 0.24f - seedSplit * 0.08f); + + for (var replayStrokeIndex = chainStart; replayStrokeIndex < strokeIndex; replayStrokeIndex++) + { + SimulateTuftSegment(sourcePaths, strokes, strokes[replayStrokeIndex], replayStrokeIndex, chainStart, tuft, sampleCount, tuftCount, frame.PhysicsHz, frame.Pressure, frame.BrushRadius, wetness, load, splay, bend, friction, restOffset, edge, ref offset, ref tip, ref stateLoad, ref stateWet, Span.Empty, Span.Empty, Span.Empty, writeOutput: false); + } + + SimulateTuftSegment(sourcePaths, strokes, stroke, strokeIndex, chainStart, tuft, sampleCount, tuftCount, frame.PhysicsHz, frame.Pressure, frame.BrushRadius, wetness, load, splay, bend, friction, restOffset, edge, ref offset, ref tip, ref stateLoad, ref stateWet, trace, canvas, tips, writeOutput: true); + } + } + } + + public static float[] EvaluatePage( + AquariumBokushoBrushFrame source, + int width, + int height, + Vector2 viewCenter, + float viewRadius) + { + ArgumentOutOfRangeException.ThrowIfLessThan(width, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); + var page = new float[checked(width * height)]; + var densityPage = EvaluatePageDensity(source, width, height, viewCenter, viewRadius); + for (var index = 0; index < page.Length; index++) + { + page[index] = DecodePageDensity(densityPage[index]); + } + + return page; + } + + public static uint[] EvaluatePageDensity( + AquariumBokushoBrushFrame source, + int width, + int height, + Vector2 viewCenter, + float viewRadius) + { + ArgumentOutOfRangeException.ThrowIfLessThan(width, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); + var densityPage = new uint[checked(width * height)]; + EvaluatePageDensity(source, width, height, viewCenter, viewRadius, densityPage); + return densityPage; + } + + private static void EvaluatePageDensity( + AquariumBokushoBrushFrame source, + int width, + int height, + Vector2 viewCenter, + float viewRadius, + Span densityPage) + { + ArgumentOutOfRangeException.ThrowIfLessThan(width, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); + var valueCount = checked(width * height); + if (densityPage.Length < valueCount) + { + throw new ArgumentException("Density page buffer is smaller than the brush simulation page.", nameof(densityPage)); + } + + var frame = source.HasInput ? source.Normalized() : AquariumBokushoBrushFrame.Empty; + if (!frame.HasInput) + { + return; + } + + var sampleCount = Math.Clamp(frame.SampleCount, 2, 4096); + var tuftCount = Math.Clamp(frame.TuftCount, 1, 4096); + var strokes = EffectiveStrokes(frame); + var sourcePaths = SourcePathIndex.Create(frame.SourcePoints); + var wetness = Saturate(frame.Wetness / 1.6f); + var load = Math.Clamp(frame.InkLoad / 1.05f, 0.0f, 2.7f); + var splay = Saturate(frame.Splay / 2.0f); + var bend = Saturate(frame.Bend / 2.4f); + var friction = Saturate(frame.Friction); + var safeRadius = MathF.Max(viewRadius, 0.001f); + + for (var strokeIndex = 0; strokeIndex < strokes.Length; strokeIndex++) + { + var stroke = strokes[strokeIndex]; + var chainStart = ChainStart(strokes, strokeIndex); + + for (var tuft = 0; tuft < tuftCount; tuft++) + { + var laneT = tuftCount <= 1 ? 0.5f : tuft / (float)(tuftCount - 1); + var restOffset = laneT * 2.0f - 1.0f; + var edge = MathF.Abs(restOffset); + var laneHash = LaneHash(chainStart, tuft, 17); + var laneLoad = 0.76f + laneHash * 0.34f; + var seedStroke = strokes[chainStart]; + var seedPressure = Saturate(frame.Pressure * seedStroke.PressureScale * 0.5f) * 2.0f; + var seedRadius = MathF.Max(frame.BrushRadius * seedStroke.RadiusScale, 0.0001f); + var seedNormalRadius = MathF.Max(seedRadius * seedStroke.NormalScale, 0.0001f); + var seedSplit = Saturate((0.28f - LaneHash(chainStart, tuft, 53)) * 3.0f) * Saturate((edge - 0.20f) * 1.5f) * seedStroke.SplitScale; + var initialCohesion = LaneCohesion(edge, seedSplit, wetness, seedPressure); + var tip = StrokePoint(sourcePaths, seedStroke, 0.0f); + var poseBias = seedStroke.ShaftTilt * 0.18f + seedStroke.ShaftRotation * 0.08f; + var offset = (restOffset + poseBias * (1.0f - edge * 0.35f)) * seedNormalRadius * (0.42f + splay * 0.38f) + (laneHash - 0.5f) * seedNormalRadius * 0.05f; + var stateLoad = load * (0.86f + initialCohesion * 0.48f) * (1.0f - edge * 0.08f) * laneLoad * (1.0f - seedSplit * 0.18f); + var stateWet = wetness * (0.74f + initialCohesion * 0.24f - seedSplit * 0.08f); + var previousPatchNormalRadius = 0.0f; + var previousPatchTangentRadius = 0.0f; + + for (var replayStrokeIndex = chainStart; replayStrokeIndex < strokeIndex; replayStrokeIndex++) + { + SimulateTuftSegmentToPage(sourcePaths, strokes, strokes[replayStrokeIndex], replayStrokeIndex, chainStart, tuft, sampleCount, frame.PhysicsHz, frame.Pressure, frame.BrushRadius, splay, bend, friction, restOffset, edge, ref offset, ref tip, ref stateLoad, ref stateWet, ref previousPatchNormalRadius, ref previousPatchTangentRadius, densityPage, width, height, viewCenter, safeRadius, writeOutput: false, writeInitialStep: true); + } + + SimulateTuftSegmentToPage(sourcePaths, strokes, stroke, strokeIndex, chainStart, tuft, sampleCount, frame.PhysicsHz, frame.Pressure, frame.BrushRadius, splay, bend, friction, restOffset, edge, ref offset, ref tip, ref stateLoad, ref stateWet, ref previousPatchNormalRadius, ref previousPatchTangentRadius, densityPage, width, height, viewCenter, safeRadius, writeOutput: true, writeInitialStep: strokeIndex == chainStart); + } + } + } + + private static void SimulateTuftSegment( + SourcePathIndex sourcePaths, + IReadOnlyList strokes, + AquariumBokushoBrushStroke stroke, + int strokeIndex, + int laneKey, + int tuft, + int sampleCount, + int tuftCount, + float physicsHz, + float framePressure, + float frameBrushRadius, + float wetness, + float load, + float splay, + float bend, + float friction, + float restOffset, + float edge, + ref float offset, + ref Vector2 tip, + ref float stateLoad, + ref float stateWet, + Span trace, + Span canvas, + Span tips, + bool writeOutput) + { + var pressure = Saturate(framePressure * stroke.PressureScale * 0.5f) * 2.0f; + var radius = MathF.Max(frameBrushRadius * stroke.RadiusScale, 0.0001f); + var normalRadius = MathF.Max(radius * stroke.NormalScale, 0.0001f); + var tangentRadius = MathF.Max(radius * stroke.TangentScale, 0.0001f); + var segmentSpan = SegmentSpan(stroke, sampleCount); + var integrationSpan = segmentSpan / MathF.Max(sampleCount - 1.0f, 1.0f); + var segmentVelocityScale = 1.0f / segmentSpan; + var split = Saturate((0.28f - LaneHash(laneKey, tuft, 53)) * 3.0f) * Saturate((edge - 0.20f) * 1.5f) * stroke.SplitScale; + for (var sample = 0; sample < sampleCount; sample++) + { + var t = sampleCount <= 1 ? 0.0f : sample / (float)(sampleCount - 1); + var center = StrokePoint(sourcePaths, stroke, t); + var tangent = StrokeTangent(sourcePaths, strokes, strokeIndex, stroke, t, sampleCount); + var normal = new Vector2(-tangent.Y, tangent.X); + var strokeT = StrokeProgress(stroke, t); + var taper = StrokeTaper(strokeT, stroke.EntryTaper, stroke.ExitTaper); + var rhythm = StrokeRhythm(stroke, strokeT); + var localPressure = pressure * taper * rhythm.Pressure; + var laneCore = SmoothStep(0.0f, 0.78f, 1.0f - edge); + var cohesion = Saturate(0.28f + stateWet * 0.44f + laneCore * 0.24f + localPressure * 0.10f - split * 0.18f); + var localNormalRadius = MathF.Max(normalRadius * rhythm.NormalRadius * (0.18f + taper * 0.82f) * (0.72f + splay * 0.34f + localPressure * 0.16f - cohesion * 0.08f), 0.0001f); + var localTangentRadius = MathF.Max(tangentRadius * rhythm.TangentRadius * (0.24f + taper * 0.76f) * (0.86f + bend * 0.18f), 0.0001f); + var targetOffset = (restOffset + stroke.ShaftRotation * 0.12f) * localNormalRadius * (0.58f + splay * 0.34f + localPressure * 0.18f - cohesion * 0.20f); + var recovery = Saturate(0.05f + stroke.Compliance * 0.08f + stateWet * 0.08f + localPressure * 0.10f); + offset = Lerp(offset, targetOffset, recovery); + + var poseLead = stroke.ShaftTilt * 0.36f + stroke.ShaftRotation * 0.16f; + var lag = localTangentRadius * (0.10f + bend * 0.32f + friction * localPressure * 0.16f + edge * 0.06f); + var dragVector = Normalize(tangent + normal * poseLead); + var desiredTip = center + normal * offset - dragVector * lag; + var slip = desiredTip - tip; + var velocity = slip.Length() * physicsHz * segmentVelocityScale / MathF.Max(radius, 0.001f); + var contact = Saturate(localPressure * stateLoad * (0.24f + stateWet * 0.62f + laneCore * 0.18f)); + var drag = contact * friction * (0.28f + stateWet * 0.34f); + var previousTip = tip; + tip += slip * (0.18f + recovery * 0.82f) * (1.0f - drag * 0.52f); + var sweptDistance = (tip - previousTip).Length(); + var sweptPatch = Saturate(sweptDistance / MathF.Max(localTangentRadius, 0.001f) * 0.42f); + + var tension = Saturate(velocity * 0.014f + MathF.Abs(targetOffset - offset) / MathF.Max(localNormalRadius, 0.001f) * 0.22f + edge * 0.12f); + var separation = Saturate(split * 0.34f + tension * 0.46f + edge * 0.20f - cohesion * 0.24f); + var adhesion = Saturate(stateWet * (0.36f + cohesion * 0.40f) + localPressure * 0.10f - separation * 0.22f); + var dryMemory = Saturate((1.0f - stateWet) * 0.62f + separation * 0.32f + velocity * 0.004f); + var fiberNoise = LaneHash(laneKey + StrokeNoiseStep(strokeT) * 13, tuft, 101); + var continuity = 1.0f - SmoothStep(0.18f + dryMemory * 0.28f, 0.96f, fiberNoise) * dryMemory * (0.38f + edge * 0.22f); + var inkFilm = Saturate(stateLoad * (0.28f + stateWet * 0.34f)); + var bristleTooth = (0.62f + continuity * (0.30f + inkFilm * 0.08f)) * (0.82f + inkFilm * 0.24f - edge * 0.06f); + var contactTransfer = contact * stateLoad * (0.16f + stateWet * 0.92f) * (0.30f + drag * 0.64f + localPressure * 0.22f + sweptPatch * 0.18f) * (0.68f + laneCore * 0.50f - separation * 0.14f) * bristleTooth; + var airborneRelease = (1.0f - contact) * stateLoad * stateWet * Saturate(velocity * 0.010f - adhesion * 0.16f) * (0.20f + separation * 0.42f + edge * 0.18f); + var consumedPigment = contactTransfer + airborneRelease; + stateLoad = MathF.Max(0.0f, stateLoad - consumedPigment * integrationSpan * (0.014f + localPressure * 0.009f + dryMemory * 0.004f)); + stateWet = MathF.Max(0.0f, stateWet - consumedPigment * integrationSpan * (0.006f + dryMemory * 0.003f)); + + if (writeOutput) + { + var index = ((strokeIndex * tuftCount) + tuft) * sampleCount + sample; + var pigment = (contactTransfer * (0.95f + localPressure * 0.34f + sweptPatch * 0.24f) + airborneRelease * (1.6f + velocity * 0.002f)) * stroke.PigmentScale * rhythm.Pigment; + canvas[index] = CanvasPigment(pigment); + trace[index] = Saturate(contact * 0.70f + airborneRelease * 2.0f + stateLoad * 0.18f); + tips[index] = new Vector4( + tip.X, + tip.Y, + localNormalRadius * (0.74f + inkFilm * 0.10f + laneCore * 0.20f + localPressure * 0.14f + sweptPatch * 0.08f - separation * 0.10f), + localTangentRadius * (0.86f + inkFilm * 0.06f + bend * 0.22f + drag * 0.16f) + sweptDistance * 0.36f); + } + } + } + + private static void SimulateTuftSegmentToPage( + SourcePathIndex sourcePaths, + IReadOnlyList strokes, + AquariumBokushoBrushStroke stroke, + int strokeIndex, + int laneKey, + int tuft, + int sampleCount, + float physicsHz, + float framePressure, + float frameBrushRadius, + float splay, + float bend, + float friction, + float restOffset, + float edge, + ref float offset, + ref Vector2 tip, + ref float stateLoad, + ref float stateWet, + ref float previousPatchNormalRadius, + ref float previousPatchTangentRadius, + Span densityPage, + int width, + int height, + Vector2 viewCenter, + float viewRadius, + bool writeOutput, + bool writeInitialStep) + { + var pressure = Saturate(framePressure * stroke.PressureScale * 0.5f) * 2.0f; + var radius = MathF.Max(frameBrushRadius * stroke.RadiusScale, 0.0001f); + var normalRadius = MathF.Max(radius * stroke.NormalScale, 0.0001f); + var tangentRadius = MathF.Max(radius * stroke.TangentScale, 0.0001f); + var segmentSpan = SegmentSpan(stroke, sampleCount); + var stepCount = Math.Clamp((int)MathF.Ceiling(segmentSpan * physicsHz), 2, 4096); + var integrationSpan = segmentSpan / MathF.Max(stepCount - 1.0f, 1.0f); + var segmentVelocityScale = 1.0f / segmentSpan; + var split = Saturate((0.28f - LaneHash(laneKey, tuft, 53)) * 3.0f) * Saturate((edge - 0.20f) * 1.5f) * stroke.SplitScale; + + for (var step = 0; step < stepCount; step++) + { + var t = stepCount <= 1 ? 0.0f : step / (float)(stepCount - 1); + var center = StrokePoint(sourcePaths, stroke, t); + var tangent = StrokeTangent(sourcePaths, strokes, strokeIndex, stroke, t, stepCount); + var normal = new Vector2(-tangent.Y, tangent.X); + var strokeT = StrokeProgress(stroke, t); + var taper = StrokeTaper(strokeT, stroke.EntryTaper, stroke.ExitTaper); + var rhythm = StrokeRhythm(stroke, strokeT); + var localPressure = pressure * taper * rhythm.Pressure; + var laneCore = SmoothStep(0.0f, 0.78f, 1.0f - edge); + var cohesion = Saturate(0.28f + stateWet * 0.44f + laneCore * 0.24f + localPressure * 0.10f - split * 0.18f); + var localNormalRadius = MathF.Max(normalRadius * rhythm.NormalRadius * (0.18f + taper * 0.82f) * (0.72f + splay * 0.34f + localPressure * 0.16f - cohesion * 0.08f), 0.0001f); + var localTangentRadius = MathF.Max(tangentRadius * rhythm.TangentRadius * (0.24f + taper * 0.76f) * (0.86f + bend * 0.18f), 0.0001f); + var targetOffset = (restOffset + stroke.ShaftRotation * 0.12f) * localNormalRadius * (0.58f + splay * 0.34f + localPressure * 0.18f - cohesion * 0.20f); + var recovery = Saturate(0.05f + stroke.Compliance * 0.08f + stateWet * 0.08f + localPressure * 0.10f); + offset = Lerp(offset, targetOffset, recovery); + + var poseLead = stroke.ShaftTilt * 0.36f + stroke.ShaftRotation * 0.16f; + var lag = localTangentRadius * (0.10f + bend * 0.32f + friction * localPressure * 0.16f + edge * 0.06f); + var dragVector = Normalize(tangent + normal * poseLead); + var desiredTip = center + normal * offset - dragVector * lag; + var slip = desiredTip - tip; + var velocity = slip.Length() * physicsHz * segmentVelocityScale / MathF.Max(radius, 0.001f); + var contact = Saturate(localPressure * stateLoad * (0.24f + stateWet * 0.62f + laneCore * 0.18f)); + var drag = contact * friction * (0.28f + stateWet * 0.34f); + var previousTip = tip; + tip += slip * (0.18f + recovery * 0.82f) * (1.0f - drag * 0.52f); + var sweptDistance = (tip - previousTip).Length(); + var sweptPatch = Saturate(sweptDistance / MathF.Max(localTangentRadius, 0.001f) * 0.42f); + + var tension = Saturate(velocity * 0.014f + MathF.Abs(targetOffset - offset) / MathF.Max(localNormalRadius, 0.001f) * 0.22f + edge * 0.12f); + var separation = Saturate(split * 0.34f + tension * 0.46f + edge * 0.20f - cohesion * 0.24f); + var adhesion = Saturate(stateWet * (0.36f + cohesion * 0.40f) + localPressure * 0.10f - separation * 0.22f); + var dryMemory = Saturate((1.0f - stateWet) * 0.62f + separation * 0.32f + velocity * 0.004f); + var fiberNoise = LaneHash(laneKey + StrokeNoiseStep(strokeT) * 13, tuft, 101); + var continuity = 1.0f - SmoothStep(0.18f + dryMemory * 0.28f, 0.96f, fiberNoise) * dryMemory * (0.38f + edge * 0.22f); + var inkFilm = Saturate(stateLoad * (0.28f + stateWet * 0.34f)); + var bristleTooth = (0.62f + continuity * (0.30f + inkFilm * 0.08f)) * (0.82f + inkFilm * 0.24f - edge * 0.06f); + var paperTooth = Saturate(dryMemory * 0.42f + separation * 0.22f + edge * 0.10f + (1.0f - inkFilm) * 0.18f); + var contactTransfer = contact * stateLoad * (0.16f + stateWet * 0.92f) * (0.30f + drag * 0.64f + localPressure * 0.22f + sweptPatch * 0.18f) * (0.68f + laneCore * 0.50f - separation * 0.14f) * bristleTooth; + var airborneRelease = (1.0f - contact) * stateLoad * stateWet * Saturate(velocity * 0.010f - adhesion * 0.16f) * (0.20f + separation * 0.42f + edge * 0.18f); + var consumedPigment = contactTransfer + airborneRelease; + stateLoad = MathF.Max(0.0f, stateLoad - consumedPigment * integrationSpan * (0.014f + localPressure * 0.009f + dryMemory * 0.004f)); + stateWet = MathF.Max(0.0f, stateWet - consumedPigment * integrationSpan * (0.006f + dryMemory * 0.003f)); + + if (writeOutput && (writeInitialStep || step > 0)) + { + var pigment = (contactTransfer * (0.95f + localPressure * 0.34f + sweptPatch * 0.24f) + airborneRelease * (1.6f + velocity * 0.002f)) * stroke.PigmentScale * rhythm.Pigment; + var patchNormalRadius = localNormalRadius * (0.74f + inkFilm * 0.10f + laneCore * 0.20f + localPressure * 0.14f + sweptPatch * 0.08f - separation * 0.10f); + var patchTangentRadius = localTangentRadius * (0.86f + inkFilm * 0.06f + bend * 0.22f + drag * 0.16f) + sweptDistance * 0.36f; + var sweepStartNormalRadius = previousPatchNormalRadius > 0.0f ? previousPatchNormalRadius : patchNormalRadius; + var sweepStartTangentRadius = previousPatchTangentRadius > 0.0f ? previousPatchTangentRadius : patchTangentRadius; + DepositSweptPatch(densityPage, width, height, viewCenter, viewRadius, previousTip, tip, sweepStartNormalRadius, sweepStartTangentRadius, patchNormalRadius, patchTangentRadius, pigment, paperTooth); + } + + previousPatchNormalRadius = localNormalRadius * (0.74f + inkFilm * 0.10f + laneCore * 0.20f + localPressure * 0.14f + sweptPatch * 0.08f - separation * 0.10f); + previousPatchTangentRadius = localTangentRadius * (0.86f + inkFilm * 0.06f + bend * 0.22f + drag * 0.16f) + sweptDistance * 0.36f; + } + } + + public static float[] ProjectCanvasToPage( + AquariumBokushoBrushFrame source, + ReadOnlySpan canvas, + int width, + int height, + Vector2 viewCenter, + float viewRadius) + { + ArgumentOutOfRangeException.ThrowIfLessThan(width, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); + var frame = source.Normalized(); + var tips = Evaluate(frame).Tips; + return ProjectCanvasToPage(source, canvas, tips, width, height, viewCenter, viewRadius); + } + + public static float[] ProjectCanvasToPage( + AquariumBokushoBrushFrame source, + ReadOnlySpan canvas, + ReadOnlySpan tips, + int width, + int height, + Vector2 viewCenter, + float viewRadius) + { + ArgumentOutOfRangeException.ThrowIfLessThan(width, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); + var frame = source.Normalized(); + var sampleCount = Math.Clamp(frame.SampleCount, 2, 4096); + var tuftCount = Math.Clamp(frame.TuftCount, 1, 4096); + var strokes = EffectiveStrokes(frame); + var valueCount = checked(sampleCount * tuftCount * strokes.Length); + if (canvas.Length < valueCount) + { + throw new ArgumentException("Canvas buffer is smaller than the brush simulation frame.", nameof(canvas)); + } + + if (tips.Length < valueCount) + { + throw new ArgumentException("Tip buffer is smaller than the brush simulation frame.", nameof(tips)); + } + + var page = new float[checked(width * height)]; + var safeRadius = MathF.Max(viewRadius, 0.001f); + for (var y = 0; y < height; y++) + { + var uvY = height <= 1 ? 0.0f : y / (float)(height - 1); + for (var x = 0; x < width; x++) + { + var uvX = width <= 1 ? 0.0f : x / (float)(width - 1); + var world = viewCenter + (new Vector2(uvX, uvY) * 2.0f - Vector2.One) * safeRadius; + var value = 0.0f; + for (var strokeIndex = 0; strokeIndex < strokes.Length; strokeIndex++) + { + value += ProjectCanvasSample(frame, strokes, canvas, tips, strokeIndex, world); + } + + page[y * width + x] = Saturate(value); + } + } + + return page; + } + + private static AquariumBokushoBrushStroke[] EffectiveStrokes(AquariumBokushoBrushFrame frame) + { + if (frame.Strokes.Count > 0) + { + return frame.Strokes.Select(stroke => stroke.Normalized()).ToArray(); + } + + return + [ + new AquariumBokushoBrushStroke + { + StrokeP0 = frame.StrokeP0, + StrokeP1 = frame.StrokeP1, + StrokeP2 = frame.StrokeP2, + StrokeP3 = frame.StrokeP3, + RadiusScale = frame.RadiusScale, + PressureScale = frame.PressureScale, + NormalScale = frame.NormalScale, + TangentScale = frame.TangentScale, + EntryTaper = 0.10f, + ExitTaper = 0.14f, + PigmentScale = 1.0f, + SplitScale = 1.0f, + SegmentStart = 0.0f, + SegmentEnd = 1.0f, + SourceStrokeId = 0, + }.Normalized() + ]; + } + + private static int ChainStart(IReadOnlyList strokes, int strokeIndex) + { + var chainStart = strokeIndex; + var sourceStrokeId = strokes[strokeIndex].SourceStrokeId; + var expectedStart = Math.Clamp(strokes[strokeIndex].SegmentStart, 0.0f, 1.0f); + while (chainStart > 0 && expectedStart > 0.0001f) + { + var previous = strokes[chainStart - 1]; + if (sourceStrokeId >= 0 && previous.SourceStrokeId != sourceStrokeId) + { + break; + } + + var previousEnd = Math.Clamp(previous.SegmentEnd, 0.0f, 1.0f); + if (MathF.Abs(previousEnd - expectedStart) > 0.001f) + { + break; + } + + chainStart--; + expectedStart = Math.Clamp(strokes[chainStart].SegmentStart, 0.0f, 1.0f); + } + + return chainStart; + } + + private static Vector2 StrokePoint(AquariumBokushoBrushStroke stroke, float t) + { + return CatmullRom( + new Vector2(stroke.StrokeP0.X, stroke.StrokeP0.Y), + new Vector2(stroke.StrokeP1.X, stroke.StrokeP1.Y), + new Vector2(stroke.StrokeP2.X, stroke.StrokeP2.Y), + new Vector2(stroke.StrokeP3.X, stroke.StrokeP3.Y), + t); + } + + private static Vector2 StrokePoint(SourcePathIndex sourcePaths, AquariumBokushoBrushStroke stroke, float t) + => sourcePaths.TrySample(stroke.SourceStrokeId, StrokeProgress(stroke, t), out var point) + ? Quantize(point, BristleTangentWorldQuantum) + : StrokePoint(stroke, t); + + private static Vector2 StrokeTangent(AquariumBokushoBrushStroke stroke, float t, int sampleCount) + { + var dt = 1.0f / MathF.Max(sampleCount - 1.0f, 1.0f); + var before = StrokePoint(stroke, Saturate(t - dt)); + var after = StrokePoint(stroke, Saturate(t + dt)); + return Normalize(after - before); + } + + private static Vector2 StrokeTangent(SourcePathIndex sourcePaths, IReadOnlyList strokes, int strokeIndex, AquariumBokushoBrushStroke stroke, float t, int sampleCount) + { + var dt = 1.0f / MathF.Max(sampleCount - 1.0f, 1.0f); + if (sourcePaths.HasSource(stroke.SourceStrokeId)) + { + var strokeT = StrokeProgress(stroke, t); + var sourceDt = SegmentSpan(stroke, sampleCount) * dt; + if (sourcePaths.TrySample(stroke.SourceStrokeId, Saturate(strokeT - sourceDt), out var sourceBefore) && + sourcePaths.TrySample(stroke.SourceStrokeId, Saturate(strokeT + sourceDt), out var sourceAfter)) + { + sourceBefore = Quantize(sourceBefore, BristleTangentWorldQuantum); + sourceAfter = Quantize(sourceAfter, BristleTangentWorldQuantum); + return Normalize(sourceAfter - sourceBefore); + } + } + + var beforeT = t - dt; + var afterT = t + dt; + var before = beforeT < 0.0f && PreviousSourcePacket(strokes, strokeIndex) is { } previous + ? StrokePoint(previous, Saturate(1.0f + beforeT)) + : StrokePoint(stroke, Saturate(beforeT)); + var after = afterT > 1.0f && NextSourcePacket(strokes, strokeIndex) is { } next + ? StrokePoint(next, Saturate(afterT - 1.0f)) + : StrokePoint(stroke, Saturate(afterT)); + before = Quantize(before, BristleTangentWorldQuantum); + after = Quantize(after, BristleTangentWorldQuantum); + return Normalize(after - before); + } + + private static AquariumBokushoBrushStroke? PreviousSourcePacket(IReadOnlyList strokes, int strokeIndex) + { + if (strokeIndex <= 0) + { + return null; + } + + var stroke = strokes[strokeIndex]; + var previous = strokes[strokeIndex - 1]; + return SameSourceBoundary(previous, stroke) ? previous : null; + } + + private static AquariumBokushoBrushStroke? NextSourcePacket(IReadOnlyList strokes, int strokeIndex) + { + if (strokeIndex + 1 >= strokes.Count) + { + return null; + } + + var stroke = strokes[strokeIndex]; + var next = strokes[strokeIndex + 1]; + return SameSourceBoundary(stroke, next) ? next : null; + } + + private static bool SameSourceBoundary(AquariumBokushoBrushStroke previous, AquariumBokushoBrushStroke next) + => previous.SourceStrokeId >= 0 && + previous.SourceStrokeId == next.SourceStrokeId && + MathF.Abs(Math.Clamp(previous.SegmentEnd, 0.0f, 1.0f) - Math.Clamp(next.SegmentStart, 0.0f, 1.0f)) <= 0.001f; + + private sealed class SourcePathIndex + { + private static readonly SourcePathIndex EmptyIndex = new(new Dictionary()); + + private readonly Dictionary pointsBySourceId; + + private SourcePathIndex(Dictionary pointsBySourceId) + { + this.pointsBySourceId = pointsBySourceId; + } + + public static SourcePathIndex Create(IReadOnlyList sourcePoints) + { + if (sourcePoints.Count == 0) + { + return EmptyIndex; + } + + var groups = sourcePoints + .Select(point => point.Normalized()) + .Where(point => point.SourceStrokeId >= 0) + .GroupBy(point => point.SourceStrokeId) + .ToDictionary( + static group => group.Key, + static group => group + .OrderBy(point => point.T) + .ToArray()); + return groups.Count == 0 ? EmptyIndex : new SourcePathIndex(groups); + } + + public bool HasSource(int sourceStrokeId) + => sourceStrokeId >= 0 && pointsBySourceId.TryGetValue(sourceStrokeId, out var points) && points.Length >= 2; + + public bool TrySample(int sourceStrokeId, float t, out Vector2 point) + { + point = default; + if (!HasSource(sourceStrokeId)) + { + return false; + } + + var points = pointsBySourceId[sourceStrokeId]; + var target = Saturate(t); + if (target <= points[0].T) + { + point = points[0].Position; + return true; + } + + var last = points[^1]; + if (target >= last.T) + { + point = last.Position; + return true; + } + + var low = 0; + var high = points.Length - 1; + while (low + 1 < high) + { + var mid = (low + high) / 2; + if (points[mid].T <= target) + { + low = mid; + } + else + { + high = mid; + } + } + + var a = points[low]; + var b = points[high]; + var span = MathF.Max(b.T - a.T, 0.000001f); + var local = Saturate((target - a.T) / span); + point = Vector2.Lerp(a.Position, b.Position, local); + return true; + } + } + + private static float ProjectCanvasSample(AquariumBokushoBrushFrame frame, IReadOnlyList strokes, ReadOnlySpan canvas, ReadOnlySpan tips, int strokeIndex, Vector2 world) + { + var stroke = strokes[strokeIndex]; + var bestDistance = float.PositiveInfinity; + var bestT = 0.0f; + for (var scan = 0; scan < 16; scan++) + { + var t = scan / 15.0f; + var center = StrokePoint(stroke, t); + var distanceSquared = Vector2.DistanceSquared(world, center); + if (distanceSquared < bestDistance) + { + bestDistance = distanceSquared; + bestT = t; + } + } + + for (var refine = 0; refine < 3; refine++) + { + var span = 1.0f / (15.0f * MathF.Pow(2.0f, refine)); + var leftT = Saturate(bestT - span); + var rightT = Saturate(bestT + span); + var leftDistance = Vector2.DistanceSquared(world, StrokePoint(stroke, leftT)); + var rightDistance = Vector2.DistanceSquared(world, StrokePoint(stroke, rightT)); + if (leftDistance < bestDistance) + { + bestDistance = leftDistance; + bestT = leftT; + } + + if (rightDistance < bestDistance) + { + bestDistance = rightDistance; + bestT = rightT; + } + } + + var sampleCount = Math.Clamp(frame.SampleCount, 2, 4096); + var tuftCount = Math.Clamp(frame.TuftCount, 1, 4096); + var centerPoint = StrokePoint(stroke, bestT); + var tangent = StrokeTangent(stroke, bestT, sampleCount); + var normal = new Vector2(-tangent.Y, tangent.X); + var lateral = Vector2.Dot(world - centerPoint, normal); + var strokeT = StrokeProgress(stroke, bestT); + var taper = StrokeTaper(strokeT, stroke.EntryTaper, stroke.ExitTaper); + var radius = MathF.Max(frame.BrushRadius * stroke.RadiusScale * stroke.NormalScale * (0.18f + taper * 0.82f), 0.0001f); + var splay = Saturate(frame.Splay / 2.0f); + var pressure = Saturate(frame.Pressure * stroke.PressureScale * 0.5f) * taper; + var wetSpread = 0.86f + Saturate(frame.Wetness / 1.6f) * 0.18f; + var footprint = radius * wetSpread * (0.72f + splay * 0.44f + pressure * 0.22f); + var tuftT = Saturate(lateral / MathF.Max(footprint, 0.001f) * 0.5f + 0.5f); + var distance = MathF.Sqrt(bestDistance); + var contact = SmoothStep(1.0f, 0.0f, distance / MathF.Max(footprint * 0.96f, 0.001f)); + var contactCore = contact * contact * (3.0f - 2.0f * contact); + var centerSample = Math.Clamp((int)MathF.Round(bestT * (sampleCount - 1)), 0, sampleCount - 1); + var centerTuft = Math.Clamp((int)MathF.Round(tuftT * MathF.Max(tuftCount - 1.0f, 0.0f)), 0, tuftCount - 1); + var pigmentPeak = 0.0f; + var pigmentFlow = 0.0f; + + for (var sampleDelta = -3; sampleDelta <= 3; sampleDelta++) + { + var rawSampleIndex = centerSample + sampleDelta; + var sampleStrokeIndex = strokeIndex; + var sampleStroke = stroke; + var sampleIndex = Math.Clamp(rawSampleIndex, 0, sampleCount - 1); + var sampleT = sampleIndex / MathF.Max(sampleCount - 1.0f, 1.0f); + var sampleTangent = StrokeTangent(sampleStroke, sampleT, sampleCount); + var sampleNormal = new Vector2(-sampleTangent.Y, sampleTangent.X); + + for (var tuftDelta = -2; tuftDelta <= 2; tuftDelta++) + { + var tuftIndex = Math.Clamp(centerTuft + tuftDelta, 0, tuftCount - 1); + var previousStrokeIndex = sampleStrokeIndex; + var previousSampleIndex = Math.Max(sampleIndex - 1, 0); + + var index = ((sampleStrokeIndex * tuftCount) + tuftIndex) * sampleCount + sampleIndex; + var previousIndex = ((previousStrokeIndex * tuftCount) + tuftIndex) * sampleCount + previousSampleIndex; + var tip = tips[index]; + var previousTip = tips[previousIndex]; + var pigment = canvas[index]; + var tipPoint = new Vector2(tip.X, tip.Y); + var previousTipPoint = new Vector2(previousTip.X, previousTip.Y); + var sweep = tipPoint - previousTipPoint; + var sweepLengthSquared = sweep.LengthSquared(); + var sweepT = sweepLengthSquared <= 0.000001f + ? 1.0f + : Saturate(Vector2.Dot(world - previousTipPoint, sweep) / sweepLengthSquared); + var contactPoint = previousTipPoint + sweep * sweepT; + var sweepDistance = MathF.Sqrt(Vector2.DistanceSquared(world, contactPoint)); + var sweepContact = SmoothStep(1.0f, 0.0f, sweepDistance / MathF.Max(MathF.Max(tip.Z, previousTip.Z) * 1.04f, 0.001f)); + var delta = world - contactPoint; + var normalDistance = Vector2.Dot(delta, sampleNormal) / MathF.Max(tip.Z, 0.001f); + var tangentDistance = Vector2.Dot(delta, sampleTangent) / MathF.Max(tip.W, 0.001f); + var ellipse = MathF.Sqrt(normalDistance * normalDistance + tangentDistance * tangentDistance); + var tipContact = MathF.Max(SmoothStep(1.0f, 0.0f, ellipse), sweepContact * 0.86f); + var longitudinalGate = SmoothStep(1.0f, 0.0f, MathF.Abs(tangentDistance) * 0.62f); + var contribution = pigment + * tipContact + * (0.006f + contactCore * 0.994f) + * (0.06f + longitudinalGate * 0.94f) + * (1.0f - MathF.Abs(sampleDelta) * 0.070f) + * (0.82f + sweepContact * 0.24f); + pigmentPeak = MathF.Max(pigmentPeak, contribution); + pigmentFlow += contribution; + } + } + + var decisiveInk = Saturate((pigmentPeak - 0.012f) * 2.25f + pigmentFlow * 0.002f); + return decisiveInk * (0.44f + pressure * 0.36f + Saturate(frame.InkLoad * 0.5f) * 0.20f); + } + + private static void DepositSweptPatch( + Span densityPage, + int width, + int height, + Vector2 viewCenter, + float viewRadius, + Vector2 previousTip, + Vector2 tip, + float previousNormalRadius, + float previousTangentRadius, + float normalRadius, + float tangentRadius, + float pigment, + float paperTooth) + { + var inkUnits = QuantizePositiveUnits(Saturate(pigment), PagePatchInkUnits); + if (inkUnits == 0u) + { + return; + } + + previousTip = Quantize(previousTip, PagePatchWorldQuantum); + tip = Quantize(tip, PagePatchWorldQuantum); + previousNormalRadius = QuantizePositive(previousNormalRadius, PagePatchWorldQuantum); + previousTangentRadius = QuantizePositive(previousTangentRadius, PagePatchWorldQuantum); + normalRadius = QuantizePositive(normalRadius, PagePatchWorldQuantum); + tangentRadius = QuantizePositive(tangentRadius, PagePatchWorldQuantum); + var sweep = tip - previousTip; + var sweepLength = sweep.Length(); + var tangent = sweepLength > 0.000001f ? sweep / sweepLength : Vector2.UnitX; + var normal = new Vector2(-tangent.Y, tangent.X); + var (previousPatchTangentRadius, previousPatchNormalRadius) = PagePatchRadii(previousTangentRadius, previousNormalRadius, sweepLength); + var (currentPatchTangentRadius, currentPatchNormalRadius) = PagePatchRadii(tangentRadius, normalRadius, sweepLength); + var patchTangentRadius = MathF.Max(previousPatchTangentRadius, currentPatchTangentRadius); + var patchNormalRadius = MathF.Max(previousPatchNormalRadius, currentPatchNormalRadius); + var center = (previousTip + tip) * 0.5f; + var halfSweepLength = sweepLength * 0.5f; + var extents = new Vector2( + MathF.Abs(tangent.X) * (halfSweepLength + patchTangentRadius) + MathF.Abs(normal.X) * patchNormalRadius, + MathF.Abs(tangent.Y) * (halfSweepLength + patchTangentRadius) + MathF.Abs(normal.Y) * patchNormalRadius); + var minWorld = center - extents; + var maxWorld = center + extents; + var minPixel = WorldToPixel(minWorld, width, height, viewCenter, viewRadius); + var maxPixel = WorldToPixel(maxWorld, width, height, viewCenter, viewRadius); + var minX = Math.Clamp((int)MathF.Floor(MathF.Min(minPixel.X, maxPixel.X)), 0, width - 1); + var maxX = Math.Clamp((int)MathF.Ceiling(MathF.Max(minPixel.X, maxPixel.X)), 0, width - 1); + var minY = Math.Clamp((int)MathF.Floor(MathF.Min(minPixel.Y, maxPixel.Y)), 0, height - 1); + var maxY = Math.Clamp((int)MathF.Ceiling(MathF.Max(minPixel.Y, maxPixel.Y)), 0, height - 1); + + for (var y = minY; y <= maxY; y++) + { + var uvY = height <= 1 ? 0.0f : y / (float)(height - 1); + for (var x = minX; x <= maxX; x++) + { + var uvX = width <= 1 ? 0.0f : x / (float)(width - 1); + var world = viewCenter + (new Vector2(uvX, uvY) * 2.0f - Vector2.One) * viewRadius; + var local = world - center; + var axialExcess = MathF.Max(MathF.Abs(Vector2.Dot(local, tangent)) - halfSweepLength, 0.0f); + var tangentDistance = axialExcess / patchTangentRadius; + var normalDistance = Vector2.Dot(local, normal) / patchNormalRadius; + var ellipse = MathF.Sqrt(tangentDistance * tangentDistance + normalDistance * normalDistance); + var coverage = SmoothStep(1.0f, 0.0f, ellipse); + var tooth = PageToothHash((uint)x, (uint)y); + var interiorTooth = (1.0f - SmoothStep(0.0f, 0.34f, ellipse)) * Saturate(paperTooth); + coverage *= 1.0f - interiorTooth * (0.03f + tooth * 0.10f); + var edgeTooth = SmoothStep(0.38f, 1.0f, ellipse) * Saturate(paperTooth); + if (edgeTooth > 0.0f) + { + coverage *= 1.0f - edgeTooth * (0.18f + tooth * 0.34f); + } + + var coverageUnits = QuantizePositiveUnits(coverage, PagePatchCoverageUnits); + if (coverageUnits == 0u) + { + continue; + } + + var sampleIndex = y * width + x; + densityPage[sampleIndex] += EncodePageDensityContribution(inkUnits, coverageUnits); + } + } + } + + private static Vector2 WorldToPixel(Vector2 world, int width, int height, Vector2 viewCenter, float viewRadius) + { + var uv = (world - viewCenter) / MathF.Max(viewRadius, 0.001f) * 0.5f + new Vector2(0.5f, 0.5f); + return new Vector2(uv.X * (width - 1), uv.Y * (height - 1)); + } + + private static float SegmentSpan(AquariumBokushoBrushStroke stroke, int sampleCount) + { + var start = Math.Clamp(stroke.SegmentStart, 0.0f, 1.0f); + var end = Math.Clamp(stroke.SegmentEnd, start, 1.0f); + return MathF.Max(end - start, 1.0f / MathF.Max(sampleCount - 1.0f, 1.0f)); + } + + private static float StrokeTaper(float t, float entryTaper, float exitTaper) + { + var entry = SmoothStep(0.0f, entryTaper, t); + var exit = 1.0f - SmoothStep(1.0f - exitTaper, 1.0f, t); + var contact = TaperContact(entry * exit); + return 0.018f + contact * 0.982f; + } + + private static StrokeRhythmFactors StrokeRhythm(AquariumBokushoBrushStroke stroke, float t) + { + var entry = SmoothStep(0.0f, 0.18f, t); + var release = SmoothStep(0.74f, 1.0f, t); + var belly = entry * (1.0f - SmoothStep(0.62f, 0.94f, t)); + var pose = Saturate(MathF.Abs(stroke.ShaftTilt) * 0.34f + MathF.Abs(stroke.ShaftRotation) * 0.26f + (stroke.Compliance - 1.0f) * 0.18f); + return new StrokeRhythmFactors( + Pressure: Quantize(0.90f + belly * (0.15f + pose * 0.04f) + entry * 0.04f - release * (0.12f + pose * 0.03f), StrokeRhythmQuantum), + NormalRadius: Quantize(0.94f + belly * (0.09f + pose * 0.03f) - release * 0.08f, StrokeRhythmQuantum), + TangentRadius: Quantize(0.96f + belly * 0.08f + pose * 0.02f - release * 0.04f, StrokeRhythmQuantum), + Pigment: Quantize(0.94f + belly * (0.12f + pose * 0.04f) - release * 0.10f, StrokeRhythmQuantum)); + } + + private readonly record struct StrokeRhythmFactors(float Pressure, float NormalRadius, float TangentRadius, float Pigment); + + private static float TaperContact(float value) + { + var x = Saturate(value); + return x * (0.76f + x * 0.24f); + } + + private static float StrokeProgress(AquariumBokushoBrushStroke stroke, float t) + { + var start = Math.Clamp(stroke.SegmentStart, 0.0f, 1.0f); + var end = Math.Clamp(stroke.SegmentEnd, start, 1.0f); + return Saturate(Lerp(start, end, t)); + } + + private static int StrokeNoiseStep(float strokeT) + { + return (int)MathF.Floor(Saturate(strokeT) * 4096.0f + 0.5f); + } + + private static float LaneCohesion(float edge, float split, float wetness, float pressure) + { + return Saturate(0.50f + (1.0f - edge) * 0.38f + wetness * 0.22f + pressure * 0.12f - split * 0.18f); + } + + private static float LaneHash(int strokeIndex, int tuft, uint salt) + { + var value = (uint)(strokeIndex + 1) * 0x9E3779B9u ^ (uint)(tuft + 1) * 0x85EBCA6Bu ^ salt; + value ^= value >> 16; + value *= 0x7FEB352Du; + value ^= value >> 15; + value *= 0x846CA68Bu; + value ^= value >> 16; + return (value & 0x00FFFFFFu) / 16777215.0f; + } + + private static float PageToothHash(uint x, uint y) + { + var value = (x + 1u) * 0x9E3779B9u ^ (y + 1u) * 0x85EBCA6Bu ^ 0xC2B2AE35u; + value ^= value >> 16; + value *= 0x7FEB352Du; + value ^= value >> 15; + value *= 0x846CA68Bu; + value ^= value >> 16; + return (value & 0x00FFFFFFu) / 16777215.0f; + } + + private static float BilinearCanvasSample(ReadOnlySpan canvas, int strokeIndex, int tuftCount, int sampleCount, float samplePosition, float tuftPosition) + { + var sample0 = Math.Clamp((int)MathF.Floor(samplePosition), 0, sampleCount - 1); + var sample1 = Math.Clamp(sample0 + 1, 0, sampleCount - 1); + var tuft0 = Math.Clamp((int)MathF.Floor(tuftPosition), 0, tuftCount - 1); + var tuft1 = Math.Clamp(tuft0 + 1, 0, tuftCount - 1); + var sampleBlend = Saturate(samplePosition - sample0); + var tuftBlend = Saturate(tuftPosition - tuft0); + var rowOffset = strokeIndex * tuftCount; + var p00 = canvas[(rowOffset + tuft0) * sampleCount + sample0]; + var p10 = canvas[(rowOffset + tuft0) * sampleCount + sample1]; + var p01 = canvas[(rowOffset + tuft1) * sampleCount + sample0]; + var p11 = canvas[(rowOffset + tuft1) * sampleCount + sample1]; + var lower = Lerp(p00, p10, sampleBlend); + var upper = Lerp(p01, p11, sampleBlend); + return Saturate(Lerp(lower, upper, tuftBlend)); + } + + private static Vector2 CatmullRom(Vector2 p0, Vector2 p1, Vector2 p2, Vector2 p3, float t) + { + return (Vector2)math.catmullrom((float2)p0, (float2)p1, (float2)p2, (float2)p3, t); + } + + private static Vector2 Normalize(Vector2 value) + { + var length = value.Length(); + return length > 0.000001f && float.IsFinite(length) ? (Vector2)math.normalize((float2)value) : Vector2.Zero; + } + + private static float Lerp(float left, float right, float t) => left + (right - left) * t; + + private static float CanvasPigment(float value) => 0.96f * (1.0f - MathF.Exp(-MathF.Max(value, 0.0f) * 0.82f)); + + public static float PagePatchContribution(float ink, float coverage) => Saturate(ink) * Saturate(coverage) * PagePatchTransferGain; + + public static (float TangentRadius, float NormalRadius) PagePatchRadii(float tangentRadius, float normalRadius, float sweepLength) + { + return ( + MathF.Max(tangentRadius * PagePatchTangentRadiusScale, PagePatchMinimumRadius), + MathF.Max(normalRadius * PagePatchNormalRadiusScale, PagePatchMinimumRadius)); + } + + public static uint EncodePageDensityContribution(float contribution) + { + var density = PageOpticalDensity(contribution); + var encoded = (uint)MathF.Floor(MathF.Min(density * PageDensityScale, PageDensityScale) + 0.5f); + return QuantizeDensityContribution(encoded); + } + + public static uint EncodePageDensityContribution(uint inkUnits, uint coverageUnits) + { + var product = inkUnits * coverageUnits; + var denominator = PagePatchInkUnits * PagePatchCoverageUnits; + var encoded = (product * PagePatchTransferEncodedGain + denominator / 2u) / denominator; + return QuantizeDensityContribution(encoded); + } + + private static uint QuantizeDensityContribution(uint encoded) + => PageDensityContributionQuantum > 1u + ? ((encoded + (PageDensityContributionQuantum / 2u)) / PageDensityContributionQuantum) * PageDensityContributionQuantum + : encoded; + + private static float PageOpticalDensity(float contribution) + { + var c = Saturate(contribution); + var c2 = c * c; + var c3 = c2 * c; + var c4 = c2 * c2; + return c + (c2 * 0.5f) + (c3 * (1.0f / 3.0f)) + (c4 * 0.25f); + } + + public static float DecodePageDensity(uint encodedDensity) + { + return 1.0f - MathF.Exp(-encodedDensity / PageDensityScale); + } + + private static float SmoothStep(float edge0, float edge1, float x) + { + return math.smoothstep(edge0, edge1, x); + } + + private static Vector2 Quantize(Vector2 value, float quantum) + => new(Quantize(value.X, quantum), Quantize(value.Y, quantum)); + + private static float QuantizePositive(float value, float quantum) + => Quantize(MathF.Max(value, 0.0f), quantum); + + private static uint QuantizePositiveUnits(float value, uint units) + => (uint)MathF.Floor(Saturate(value) * units + 0.5f); + + private static float Quantize(float value, float quantum) + => quantum > 0.0f ? MathF.Floor((value / quantum) + 0.5f) * quantum : value; + + private static float Saturate(float value) => Math.Clamp(value, 0.0f, 1.0f); +} + +public sealed record BokushoBrushSimulationResult(int SampleCount, int TuftCount, int StrokeCount, float[] Trace, float[] Canvas, Vector4[] Tips) +{ + public static BokushoBrushSimulationResult Empty { get; } = new(0, 0, 0, [], [], []); + + public bool HasSamples => Trace.Length > 0 && Canvas.Length == Trace.Length; +} diff --git a/src/Aquarium.Engine.Contracts/Ui/AquariumUi.cs b/src/Aquarium.Engine.Contracts/Ui/AquariumUi.cs index d7d4645..70b9881 100644 --- a/src/Aquarium.Engine.Contracts/Ui/AquariumUi.cs +++ b/src/Aquarium.Engine.Contracts/Ui/AquariumUi.cs @@ -3,12 +3,15 @@ namespace Aquarium.Engine.Ui; public sealed class AquariumUiDocument { private readonly List panels = []; + private readonly List surfaces = []; private readonly List commands = []; public static AquariumUiDocument Empty { get; } = new(); public IReadOnlyList Panels => panels; + public IReadOnlyList Surfaces => surfaces; + public IReadOnlyList Commands => commands; public AquariumUiDocument Panel(string title, Action compose) @@ -29,6 +32,35 @@ public sealed class AquariumUiDocument return this; } + public AquariumUiDocument Surface(string id, string title, float left, float top, float width, float height, Action compose) + { + return Surface(id, title, left, top, width, height, contentPadding: 8.0f, rootGap: 8.0f, rootPadding: 8.0f, compose); + } + + public AquariumUiDocument Surface( + string id, + string title, + float left, + float top, + float width, + float height, + float contentPadding, + float rootGap, + float rootPadding, + Action compose) + { + var children = new List(); + compose(new AquariumUiSurfaceBuilder(children)); + surfaces.Add(new AquariumUiSurface( + "cultmesh.eve_surface.v0", + id, + title, + new AquariumUiRect(left, top, width, height), + new AquariumUiElement(id + ".root", "dashboard", title, Role: "root", Layout: AquariumUiLayout.Vertical(rootGap, rootPadding), Children: children), + Math.Max(0.0f, contentPadding))); + return this; + } + public AquariumUiDocument Command(string name, Func, string> execute, string description = "") { commands.Add(new AquariumConsoleCommand(name, execute, description)); @@ -44,6 +76,218 @@ public sealed record AquariumUiPanel( IReadOnlyList Controls, bool FadeWhenMouseDistant = false); +public sealed record AquariumUiSurface( + string Schema, + string Id, + string Title, + AquariumUiRect Bounds, + AquariumUiElement Root, + float ContentPadding = 8.0f); + +public sealed record AquariumUiRect(float Left, float Top, float Width, float Height); + +public sealed record AquariumUiLayout( + string Direction = "vertical", + float Gap = 8.0f, + float Padding = 8.0f) +{ + public static AquariumUiLayout Vertical(float gap = 8.0f, float padding = 8.0f) => new("vertical", gap, padding); + + public static AquariumUiLayout Horizontal(float gap = 8.0f, float padding = 8.0f) => new("horizontal", gap, padding); +} + +public sealed record AquariumUiStyle(string Variant = "default", string Tone = "neutral"); + +public sealed record AquariumUiBinding( + string DocumentSchema, + string DocumentId, + string Path, + string ValueKind, + string Access = "read", + string Authority = "", + string? CommandId = null) +{ + public static AquariumUiBinding Read(string documentSchema, string documentId, string path, string valueKind, string authority = "") + => new(documentSchema, documentId, path, valueKind, "read", authority); + + public static AquariumUiBinding ReadWrite(string documentSchema, string documentId, string path, string valueKind, string authority = "") + => new(documentSchema, documentId, path, valueKind, "read-write", authority); + + public static AquariumUiBinding Command(string documentSchema, string documentId, string path, string authority, string commandId) + => new(documentSchema, documentId, path, "command", "command", authority, commandId); +} + +public sealed record AquariumUiPreviewItem( + string Id, + string Label, + float X, + float Y, + float Width, + float Height, + bool Selected = false, + string Tone = "neutral"); + +public readonly record struct AquariumUiPreviewInteraction( + string ItemId, + string Handle, + string Phase, + float X, + float Y, + float DeltaX, + float DeltaY); + +public readonly record struct AquariumUiPreviewState( + string HoverItemId = "", + string HoverHandle = "", + string ActiveItemId = "", + string ActiveHandle = ""); + +public readonly record struct AquariumUiPreviewGuide( + string Axis, + float Position, + string Tone = "neutral"); + +public sealed record AquariumUiElement( + string Id, + string Kind, + string? Text = null, + string? Role = null, + AquariumUiLayout? Layout = null, + AquariumUiStyle? Style = null, + AquariumUiBinding? Binding = null, + Func? ReadText = null, + Func? ReadMetric = null, + Func? ReadToggle = null, + Action? WriteToggle = null, + Func? ReadFloat = null, + Action? WriteFloat = null, + float Min = 0.0f, + float Max = 1.0f, + string Format = "0.###", + Func? ReadOption = null, + Action? WriteOption = null, + IReadOnlyList? Options = null, + Func>? ReadPreviewItems = null, + Func? ReadPreviewState = null, + Func>? ReadPreviewGuides = null, + bool PreviewBlitsOutputBuffer = false, + Action? HandlePreviewInteraction = null, + Action? Invoke = null, + Func? IsVisible = null, + float Weight = 1.0f, + float? FixedExtent = null, + IReadOnlyList? Children = null) +{ + public bool Visible => IsVisible?.Invoke() ?? true; +} + +public sealed class AquariumUiSurfaceBuilder(List children) +{ + public AquariumUiSurfaceBuilder Vertical(string id, Action compose, float weight = 1.0f, float gap = 8.0f, float padding = 0.0f, Func? isVisible = null, float? fixedExtent = null) + { + var scopeChildren = new List(); + compose(new AquariumUiSurfaceBuilder(scopeChildren)); + children.Add(new AquariumUiElement(id, "group", Layout: AquariumUiLayout.Vertical(gap, padding), IsVisible: isVisible, Weight: Math.Max(0.001f, weight), FixedExtent: fixedExtent, Children: scopeChildren)); + return this; + } + + public AquariumUiSurfaceBuilder Horizontal(string id, Action compose, float weight = 1.0f, float gap = 8.0f, float padding = 0.0f, Func? isVisible = null, float? fixedExtent = null) + { + var scopeChildren = new List(); + compose(new AquariumUiSurfaceBuilder(scopeChildren)); + children.Add(new AquariumUiElement(id, "group", Layout: AquariumUiLayout.Horizontal(gap, padding), IsVisible: isVisible, Weight: Math.Max(0.001f, weight), FixedExtent: fixedExtent, Children: scopeChildren)); + return this; + } + + public AquariumUiSurfaceBuilder Row(string id, Action compose, float weight = 1.0f, Func? isVisible = null) + { + return Horizontal(id, compose, weight, isVisible: isVisible); + } + + public AquariumUiSurfaceBuilder Pane(string id, string title, Action compose, string tone = "neutral", float weight = 1.0f, float? fixedExtent = null) + { + var paneChildren = new List(); + compose(new AquariumUiSurfaceBuilder(paneChildren)); + children.Add(new AquariumUiElement(id, "pane", title, Style: new AquariumUiStyle("panel", tone), Layout: AquariumUiLayout.Vertical(8.0f, 10.0f), Weight: Math.Max(0.001f, weight), FixedExtent: fixedExtent, Children: paneChildren)); + return this; + } + + public AquariumUiSurfaceBuilder Card(string id, Action compose, string tone = "neutral", float weight = 1.0f) + { + var cardChildren = new List(); + compose(new AquariumUiSurfaceBuilder(cardChildren)); + children.Add(new AquariumUiElement(id, "card", Style: new AquariumUiStyle("compact", tone), Layout: AquariumUiLayout.Vertical(4.0f, 8.0f), Weight: Math.Max(0.001f, weight), Children: cardChildren)); + return this; + } + + public AquariumUiSurfaceBuilder Text(string id, string text, string role = "body", float weight = 1.0f, Func? isVisible = null, AquariumUiBinding? binding = null) + { + children.Add(new AquariumUiElement(id, "text", text, role, Binding: binding, IsVisible: isVisible, Weight: Math.Max(0.001f, weight))); + return this; + } + + public AquariumUiSurfaceBuilder Text(string id, Func read, string role = "body", float weight = 1.0f, Func? isVisible = null, AquariumUiBinding? binding = null) + { + children.Add(new AquariumUiElement(id, "text", Role: role, Binding: binding, ReadText: read, IsVisible: isVisible, Weight: Math.Max(0.001f, weight))); + return this; + } + + public AquariumUiSurfaceBuilder Metric(string id, string label, Func read, string tone = "neutral", float weight = 1.0f, AquariumUiBinding? binding = null) + { + children.Add(new AquariumUiElement(id, "metric", label, Style: new AquariumUiStyle("compact", tone), Binding: binding, ReadMetric: read, Weight: Math.Max(0.001f, weight))); + return this; + } + + public AquariumUiSurfaceBuilder Toggle(string id, string label, Func read, Action write, float weight = 1.0f, AquariumUiBinding? binding = null) + { + children.Add(new AquariumUiElement(id, "toggle", label, Binding: binding, ReadToggle: read, WriteToggle: write, Weight: Math.Max(0.001f, weight))); + return this; + } + + public AquariumUiSurfaceBuilder Slider(string id, string label, Func read, Action write, float min, float max, string format = "0.###", float weight = 1.0f, AquariumUiBinding? binding = null) + { + children.Add(new AquariumUiElement(id, "slider", label, Binding: binding, ReadFloat: read, WriteFloat: write, Min: min, Max: max, Format: format, Weight: Math.Max(0.001f, weight))); + return this; + } + + public AquariumUiSurfaceBuilder Options(string id, string label, Func read, Action write, IReadOnlyList options, float weight = 1.0f, AquariumUiBinding? binding = null) + { + children.Add(new AquariumUiElement(id, "select", label, Binding: binding, ReadOption: read, WriteOption: write, Options: options, Weight: Math.Max(0.001f, weight))); + return this; + } + + public AquariumUiSurfaceBuilder Button(string id, string label, Action invoke, float weight = 1.0f, Func? isVisible = null, AquariumUiBinding? binding = null) + { + children.Add(new AquariumUiElement(id, "button", label, Binding: binding, Invoke: invoke, IsVisible: isVisible, Weight: Math.Max(0.001f, weight))); + return this; + } + + public AquariumUiSurfaceBuilder Preview( + string id, + string label, + Func> readItems, + float weight = 1.0f, + bool blitOutputBuffer = false, + Action? handleInteraction = null, + Func? readState = null, + Func>? readGuides = null, + AquariumUiBinding? binding = null) + { + children.Add(new AquariumUiElement( + id, + "preview", + label, + ReadPreviewItems: readItems, + ReadPreviewState: readState, + ReadPreviewGuides: readGuides, + Binding: binding, + PreviewBlitsOutputBuffer: blitOutputBuffer, + HandlePreviewInteraction: handleInteraction, + Weight: Math.Max(0.001f, weight))); + return this; + } +} + public sealed class AquariumUiPanelBuilder(List controls) { public AquariumUiPanelBuilder Section(string title, Func? isVisible = null) diff --git a/src/Aquarium.Engine.Fractal/Aquarium.Engine.Fractal.csproj b/src/Aquarium.Engine.Fractal/Aquarium.Engine.Fractal.csproj index dff661c..3f7f284 100644 --- a/src/Aquarium.Engine.Fractal/Aquarium.Engine.Fractal.csproj +++ b/src/Aquarium.Engine.Fractal/Aquarium.Engine.Fractal.csproj @@ -2,6 +2,7 @@ + diff --git a/src/Aquarium.Engine.Fractal/CubeFace.cs b/src/Aquarium.Engine.Fractal/CubeFace.cs deleted file mode 100644 index f90f96c..0000000 --- a/src/Aquarium.Engine.Fractal/CubeFace.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Aquarium.Engine.Fractal; - -public enum CubeFace -{ - PositiveX, - NegativeX, - PositiveY, - NegativeY, - PositiveZ, - NegativeZ, -} diff --git a/src/Aquarium.Engine.Fractal/CubeFacePosition.cs b/src/Aquarium.Engine.Fractal/CubeFacePosition.cs deleted file mode 100644 index d59560d..0000000 --- a/src/Aquarium.Engine.Fractal/CubeFacePosition.cs +++ /dev/null @@ -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."), - }; - } -} diff --git a/src/Aquarium.Engine.Fractal/CubeTileKey.cs b/src/Aquarium.Engine.Fractal/CubeTileKey.cs deleted file mode 100644 index fcd0cb2..0000000 --- a/src/Aquarium.Engine.Fractal/CubeTileKey.cs +++ /dev/null @@ -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}"; - } -} diff --git a/src/Aquarium.Engine.Fractal/Grammar/FractalDslCompiler.cs b/src/Aquarium.Engine.Fractal/Grammar/FractalDslCompiler.cs index a9dc950..d38c851 100644 --- a/src/Aquarium.Engine.Fractal/Grammar/FractalDslCompiler.cs +++ b/src/Aquarium.Engine.Fractal/Grammar/FractalDslCompiler.cs @@ -1,6 +1,8 @@ using System.Globalization; using System.Numerics; using Aquarium.Engine.Fractal; +using CultMath; +using GameCult.Geometry; namespace Aquarium.Engine.Fractal.Grammar; @@ -37,7 +39,7 @@ public static class FractalDslCompiler 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 tile = new PlanetaryTileAddress(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"); @@ -500,9 +502,9 @@ public static class FractalDslCompiler return new Vector4(minX, minY, maxX, maxY); } - private static CubeFace ParseFace(string value, int lineIndex) + private static PlanetaryCubeFace ParseFace(string value, int lineIndex) { - if (Enum.TryParse(value, ignoreCase: false, out var face)) + if (Enum.TryParse(value, ignoreCase: false, out var face)) { return face; } diff --git a/src/Aquarium.Engine.Fractal/Grammar/FractalStableKeyBuilder.cs b/src/Aquarium.Engine.Fractal/Grammar/FractalStableKeyBuilder.cs index 969a003..59f99d2 100644 --- a/src/Aquarium.Engine.Fractal/Grammar/FractalStableKeyBuilder.cs +++ b/src/Aquarium.Engine.Fractal/Grammar/FractalStableKeyBuilder.cs @@ -1,10 +1,11 @@ -using Aquarium.Engine.Fractal; +using CultMath; +using GameCult.Geometry; namespace Aquarium.Engine.Fractal.Grammar; public static class FractalStableKeyBuilder { - public static AquariumFractalKey ForCubeTile(CubeTileKey tile, string grammarPath) + public static AquariumFractalKey ForCubeTile(PlanetaryTileAddress tile, string grammarPath) { return new AquariumFractalKey($"cube/{tile.Face}/L{tile.Level:D2}/{tile.X}/{tile.Y}:{NormalizePath(grammarPath)}"); } diff --git a/src/Aquarium.Engine.Fractal/Lod/FractalGpuProgramCompiler.cs b/src/Aquarium.Engine.Fractal/Lod/FractalGpuProgramCompiler.cs index d247865..bf13601 100644 --- a/src/Aquarium.Engine.Fractal/Lod/FractalGpuProgramCompiler.cs +++ b/src/Aquarium.Engine.Fractal/Lod/FractalGpuProgramCompiler.cs @@ -1,4 +1,6 @@ using System.Numerics; +using CultMath; +using GameCult.Geometry; using Aquarium.Engine.Fractal.Grammar; namespace Aquarium.Engine.Fractal.Lod; @@ -79,7 +81,7 @@ public static class FractalGpuProgramCompiler 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); + : new Vector4((float)PlanetaryCubeFace.PositiveZ, 0.0f, 0.0f, 0.0f); var rotationCos = MathF.Cos(claim.RotationRadians); var rotationSin = MathF.Sin(claim.RotationRadians); diff --git a/src/Aquarium.Engine.Fractal/Projection/ICubeSphereProjection.cs b/src/Aquarium.Engine.Fractal/Projection/ICubeSphereProjection.cs index 70dd2f0..54f3148 100644 --- a/src/Aquarium.Engine.Fractal/Projection/ICubeSphereProjection.cs +++ b/src/Aquarium.Engine.Fractal/Projection/ICubeSphereProjection.cs @@ -1,4 +1,6 @@ using System.Numerics; +using CultMath; +using GameCult.Geometry; namespace Aquarium.Engine.Fractal.Projection; @@ -6,5 +8,5 @@ public interface ICubeSphereProjection { string Name { get; } - Vector3 Project(CubeFacePosition position); + Vector3 Project(PlanetaryFaceCoordinate position); } diff --git a/src/Aquarium.Engine.Fractal/Projection/NormalizeCubeSphereProjection.cs b/src/Aquarium.Engine.Fractal/Projection/NormalizeCubeSphereProjection.cs index 37572c7..d91fc09 100644 --- a/src/Aquarium.Engine.Fractal/Projection/NormalizeCubeSphereProjection.cs +++ b/src/Aquarium.Engine.Fractal/Projection/NormalizeCubeSphereProjection.cs @@ -1,4 +1,6 @@ using System.Numerics; +using CultMath; +using GameCult.Geometry; namespace Aquarium.Engine.Fractal.Projection; @@ -6,8 +8,8 @@ public sealed class NormalizeCubeSphereProjection : ICubeSphereProjection { public string Name => "normalize"; - public Vector3 Project(CubeFacePosition position) + public Vector3 Project(PlanetaryFaceCoordinate position) { - return Vector3.Normalize(position.ToCubeVector()); + return (Vector3)PlanetaryTopology.NormalizedCubeDirection(position); } } diff --git a/src/Aquarium.Engine.Fractal/Projection/ProjectionDistortionResult.cs b/src/Aquarium.Engine.Fractal/Projection/ProjectionDistortionResult.cs index bb5e1a9..832782d 100644 --- a/src/Aquarium.Engine.Fractal/Projection/ProjectionDistortionResult.cs +++ b/src/Aquarium.Engine.Fractal/Projection/ProjectionDistortionResult.cs @@ -1,8 +1,11 @@ +using CultMath; +using GameCult.Geometry; + namespace Aquarium.Engine.Fractal.Projection; public readonly record struct ProjectionDistortionResult( string ProjectionName, - CubeFace Face, + PlanetaryCubeFace Face, int SamplesPerAxis, double AverageAreaScale, double MinRelativeArea, diff --git a/src/Aquarium.Engine.Fractal/Projection/ProjectionDistortionSampler.cs b/src/Aquarium.Engine.Fractal/Projection/ProjectionDistortionSampler.cs index 0434f09..08d0a1b 100644 --- a/src/Aquarium.Engine.Fractal/Projection/ProjectionDistortionSampler.cs +++ b/src/Aquarium.Engine.Fractal/Projection/ProjectionDistortionSampler.cs @@ -1,4 +1,6 @@ using System.Numerics; +using CultMath; +using GameCult.Geometry; namespace Aquarium.Engine.Fractal.Projection; @@ -9,7 +11,7 @@ public static class ProjectionDistortionSampler public static ProjectionDistortionResult SampleFace( ICubeSphereProjection projection, - CubeFace face, + PlanetaryCubeFace face, int samplesPerAxis) { ArgumentNullException.ThrowIfNull(projection); @@ -56,14 +58,14 @@ public static class ProjectionDistortionSampler Math.Sqrt(totalSquaredRelativeError / sampleCount)); } - private static double SurfaceAreaScale(ICubeSphereProjection projection, CubeFace face, double u, double v) + private static double SurfaceAreaScale(ICubeSphereProjection projection, PlanetaryCubeFace face, double u, double v) { var du = Derivative(projection, face, u, v, DerivativeAxis.U); var dv = Derivative(projection, face, u, v, DerivativeAxis.V); return Vector3.Cross(du, dv).Length(); } - private static Vector3 Derivative(ICubeSphereProjection projection, CubeFace face, double u, double v, DerivativeAxis axis) + private static Vector3 Derivative(ICubeSphereProjection projection, PlanetaryCubeFace face, double u, double v, DerivativeAxis axis) { var low = axis == DerivativeAxis.U ? u : v; var high = low; @@ -86,13 +88,13 @@ public static class ProjectionDistortionSampler Vector3 highPoint; if (axis == DerivativeAxis.U) { - lowPoint = projection.Project(new CubeFacePosition(face, lowCoordinate, v)); - highPoint = projection.Project(new CubeFacePosition(face, highCoordinate, v)); + lowPoint = projection.Project(new PlanetaryFaceCoordinate(face, lowCoordinate, v)); + highPoint = projection.Project(new PlanetaryFaceCoordinate(face, highCoordinate, v)); } else { - lowPoint = projection.Project(new CubeFacePosition(face, u, lowCoordinate)); - highPoint = projection.Project(new CubeFacePosition(face, u, highCoordinate)); + lowPoint = projection.Project(new PlanetaryFaceCoordinate(face, u, lowCoordinate)); + highPoint = projection.Project(new PlanetaryFaceCoordinate(face, u, highCoordinate)); } return (highPoint - lowPoint) / (float)(forward + backward); diff --git a/src/Aquarium.Engine.Fractal/Projection/ProjectionReport.cs b/src/Aquarium.Engine.Fractal/Projection/ProjectionReport.cs index c6dbf16..12a6e56 100644 --- a/src/Aquarium.Engine.Fractal/Projection/ProjectionReport.cs +++ b/src/Aquarium.Engine.Fractal/Projection/ProjectionReport.cs @@ -1,6 +1,9 @@ using System.Globalization; using System.Text; +using CultMath; +using GameCult.Geometry; + namespace Aquarium.Engine.Fractal.Projection; public static class ProjectionReport @@ -25,7 +28,7 @@ public static class ProjectionReport foreach (var projection in projections) { - foreach (var face in Enum.GetValues()) + foreach (var face in Enum.GetValues()) { var result = ProjectionDistortionSampler.SampleFace(projection, face, samplesPerAxis); builder.Append("| ") diff --git a/src/Aquarium.Engine.Fractal/Projection/TangentCubeSphereProjection.cs b/src/Aquarium.Engine.Fractal/Projection/TangentCubeSphereProjection.cs index 12ea804..52bd3ed 100644 --- a/src/Aquarium.Engine.Fractal/Projection/TangentCubeSphereProjection.cs +++ b/src/Aquarium.Engine.Fractal/Projection/TangentCubeSphereProjection.cs @@ -1,16 +1,15 @@ using System.Numerics; +using CultMath; +using GameCult.Geometry; namespace Aquarium.Engine.Fractal.Projection; public sealed class TangentCubeSphereProjection : ICubeSphereProjection { - private const double QuarterPi = Math.PI * 0.25; - public string Name => "tangent"; - public Vector3 Project(CubeFacePosition position) + public Vector3 Project(PlanetaryFaceCoordinate position) { - var warped = new CubeFacePosition(position.Face, Math.Tan(position.U * QuarterPi), Math.Tan(position.V * QuarterPi)); - return Vector3.Normalize(warped.ToCubeVector()); + return (Vector3)PlanetaryTopology.Direction(position); } } diff --git a/src/Aquarium.Engine/Aquarium.Engine.csproj b/src/Aquarium.Engine/Aquarium.Engine.csproj index add87e4..fe36a8d 100644 --- a/src/Aquarium.Engine/Aquarium.Engine.csproj +++ b/src/Aquarium.Engine/Aquarium.Engine.csproj @@ -1,7 +1,6 @@ - $(MSBuildThisFileDirectory)..\..\..\CultLib WinExe net10.0 enable @@ -23,7 +22,8 @@ - + + diff --git a/src/Aquarium.Engine/AquariumHost.cs b/src/Aquarium.Engine/AquariumHost.cs index 7ba0c72..dabedc3 100644 --- a/src/Aquarium.Engine/AquariumHost.cs +++ b/src/Aquarium.Engine/AquariumHost.cs @@ -76,7 +76,11 @@ public static class AquariumHost var readyFrames = 0; var requiredReadyFrames = ParseHeadlessReadyFrames(); var captureFramePath = ParseCaptureFramePath(args); + var captureBokushoCanvasPath = ParseCaptureBokushoCanvasPath(args); + var captureBokushoDensityPath = ParseCaptureBokushoDensityPath(args); var capturedFrame = false; + var capturedBokushoCanvas = false; + var capturedBokushoDensity = false; IAquariumRuntime? sceneReadyRuntime = null; try @@ -134,6 +138,7 @@ public static class AquariumHost activeRuntime.OnSceneReady(); sceneReadyRuntime = activeRuntime; synthHost ??= new AquariumSynthHost(audioStemBus); + synthHost.Update(AquariumSynthDocument.Combine(activeRuntime.Synth, renderer.DebugSynth), activeRuntime.Audio, 0.0f); lastFrame = frameClock.Elapsed; } } @@ -148,7 +153,35 @@ public static class AquariumHost capturedFrame = true; } - if (runtime.Options.Headless && frames >= 2 && readyFrames >= requiredReadyFrames) + if (runtime.Options.Headless + && !capturedBokushoCanvas + && !string.IsNullOrWhiteSpace(captureBokushoCanvasPath) + && readyFrames >= requiredReadyFrames) + { + renderer.SaveBokushoBrushCanvasRaw(captureBokushoCanvasPath); + Console.WriteLine($"Headless Aquarium Bokusho canvas captured: {Path.GetFullPath(captureBokushoCanvasPath)}"); + capturedBokushoCanvas = true; + } + + if (runtime.Options.Headless + && !capturedBokushoDensity + && !string.IsNullOrWhiteSpace(captureBokushoDensityPath) + && readyFrames >= requiredReadyFrames) + { + renderer.SaveBokushoPageDensityRaw(captureBokushoDensityPath); + Console.WriteLine($"Headless Aquarium Bokusho density captured: {Path.GetFullPath(captureBokushoDensityPath)}"); + capturedBokushoDensity = true; + } + + var needsFrameCapture = !string.IsNullOrWhiteSpace(captureFramePath) && !capturedFrame; + var needsBokushoCanvasCapture = !string.IsNullOrWhiteSpace(captureBokushoCanvasPath) && !capturedBokushoCanvas; + var needsBokushoDensityCapture = !string.IsNullOrWhiteSpace(captureBokushoDensityPath) && !capturedBokushoDensity; + if (runtime.Options.Headless + && frames >= 2 + && readyFrames >= requiredReadyFrames + && !needsFrameCapture + && !needsBokushoCanvasCapture + && !needsBokushoDensityCapture) { Console.WriteLine("Headless Aquarium completed requested frames."); break; @@ -214,6 +247,34 @@ public static class AquariumHost return Environment.GetEnvironmentVariable("AQUARIUM_CAPTURE_FRAME"); } + private static string? ParseCaptureBokushoCanvasPath(IReadOnlyCollection args) + { + var values = args.ToArray(); + for (var index = 0; index < values.Length - 1; index++) + { + if (string.Equals(values[index], "--capture-bokusho-canvas", StringComparison.OrdinalIgnoreCase)) + { + return values[index + 1]; + } + } + + return Environment.GetEnvironmentVariable("AQUARIUM_CAPTURE_BOKUSHO_CANVAS"); + } + + private static string? ParseCaptureBokushoDensityPath(IReadOnlyCollection args) + { + var values = args.ToArray(); + for (var index = 0; index < values.Length - 1; index++) + { + if (string.Equals(values[index], "--capture-bokusho-density", StringComparison.OrdinalIgnoreCase)) + { + return values[index + 1]; + } + } + + return Environment.GetEnvironmentVariable("AQUARIUM_CAPTURE_BOKUSHO_DENSITY"); + } + private static string? ParseCachePath(IReadOnlyCollection args) { var values = args.ToArray(); diff --git a/src/Aquarium.Engine/Audio/AquariumSynthHost.cs b/src/Aquarium.Engine/Audio/AquariumSynthHost.cs index 798c7df..84e5b91 100644 --- a/src/Aquarium.Engine/Audio/AquariumSynthHost.cs +++ b/src/Aquarium.Engine/Audio/AquariumSynthHost.cs @@ -13,6 +13,8 @@ internal sealed class AquariumSynthHost : IDisposable private readonly IAquariumAudioStemBus audioStemBus; private readonly WasapiAudioDevice audioDevice = new(); private readonly AquaSynthPatchCompiler patchCompiler = new(new AquaSynthNativeOptions(DspSourceDirectory: Path.Combine(AppContext.BaseDirectory, "Synth"))); + private WasapiLoopbackCaptureDevice? loopbackCapture; + private AquariumAudioCaptureRequest? activeCaptureRequest; private float timeSeconds; public AquariumSynthHost(IAquariumAudioStemBus? audioStemBus = null) @@ -81,6 +83,11 @@ internal sealed class AquariumSynthHost : IDisposable PlayMonitorOutputs(block, stemFrame); } + foreach (var request in audio.DrainCaptureRequests()) + { + ApplyCaptureRequest(request); + } + if (!synth.Enabled) { return; @@ -113,6 +120,42 @@ internal sealed class AquariumSynthHost : IDisposable } } + private void ApplyCaptureRequest(AquariumAudioCaptureRequest request) + { + if (request.Kind != AquariumAudioCaptureKind.SystemLoopback) + { + return; + } + + if (!request.Enabled) + { + if (activeCaptureRequest?.ProfileId == request.ProfileId) + { + loopbackCapture?.Dispose(); + loopbackCapture = null; + activeCaptureRequest = null; + } + + return; + } + + if (activeCaptureRequest is not null && + activeCaptureRequest.ProfileId == request.ProfileId && + activeCaptureRequest.SourceId == request.SourceId && + activeCaptureRequest.DisplayName == request.DisplayName) + { + return; + } + + loopbackCapture?.Dispose(); + loopbackCapture = new WasapiLoopbackCaptureDevice( + audioStemBus, + request.ProfileId, + request.SourceId, + request.DisplayName); + activeCaptureRequest = request; + } + private void PlayMonitorOutputs(AquariumStreamingAudioBlock block, AquariumAudioStemFrame stemFrame) { if (block.MonitorLeftChannel < 0 && block.MonitorRightChannel < 0) @@ -341,6 +384,7 @@ internal sealed class AquariumSynthHost : IDisposable } streamingDsp.Dispose(); + loopbackCapture?.Dispose(); audioDevice.Dispose(); patchCompiler.Dispose(); } diff --git a/src/Aquarium.Engine/Audio/WasapiLoopbackCaptureDevice.cs b/src/Aquarium.Engine/Audio/WasapiLoopbackCaptureDevice.cs new file mode 100644 index 0000000..93bee09 --- /dev/null +++ b/src/Aquarium.Engine/Audio/WasapiLoopbackCaptureDevice.cs @@ -0,0 +1,331 @@ +using System.Runtime.InteropServices; + +namespace Aquarium.Engine.Audio; + +internal sealed class WasapiLoopbackCaptureDevice : IDisposable +{ + private static readonly bool TraceAudio = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("AQUARIUM_AUDIO_TRACE")); + + private readonly IAquariumAudioStemBus stemBus; + private readonly string profileId; + private readonly string sourceId; + private readonly string displayName; + private readonly Thread thread; + private volatile bool running = true; + private volatile bool started; + private long sequence; + + public WasapiLoopbackCaptureDevice( + IAquariumAudioStemBus stemBus, + string profileId, + string sourceId, + string displayName) + { + this.stemBus = stemBus; + this.profileId = profileId; + this.sourceId = sourceId; + this.displayName = displayName; + thread = new Thread(CaptureThread) + { + IsBackground = true, + Name = "Aquarium WASAPI Loopback" + }; + thread.Start(); + } + + public void Dispose() + { + running = false; + if (started) + { + thread.Join(TimeSpan.FromSeconds(2.0)); + } + } + + private void CaptureThread() + { + started = true; + var comInitialized = false; + IAudioClient? audioClient = null; + try + { + var hr = CoInitializeEx(IntPtr.Zero, CoInit.Multithreaded); + if (hr < 0) + { + Marshal.ThrowExceptionForHR(hr); + } + + comInitialized = true; +#pragma warning disable CA1416 + var enumeratorType = Type.GetTypeFromCLSID(MMDeviceEnumeratorClsid, throwOnError: true) + ?? throw new InvalidOperationException("MMDeviceEnumerator COM type is not available."); +#pragma warning restore CA1416 + var deviceEnumerator = (IMMDeviceEnumerator)(Activator.CreateInstance(enumeratorType) + ?? throw new InvalidOperationException("failed to create MMDeviceEnumerator")); + Marshal.ThrowExceptionForHR(deviceEnumerator.GetDefaultAudioEndpoint(EDataFlow.Render, ERole.Console, out var device)); + var audioClientId = typeof(IAudioClient).GUID; + Marshal.ThrowExceptionForHR(device.Activate(ref audioClientId, ClsCtx.All, IntPtr.Zero, out var audioClientObject)); + audioClient = (IAudioClient)audioClientObject; + Marshal.ThrowExceptionForHR(audioClient.GetMixFormat(out var mixFormatPointer)); + try + { + var format = WasapiFormat.FromPointer(mixFormatPointer); + Marshal.ThrowExceptionForHR(audioClient.Initialize( + AudioClientShareMode.Shared, + AudioClientStreamFlags.Loopback | AudioClientStreamFlags.NoPersist, + 0, + 0, + mixFormatPointer, + IntPtr.Zero)); + var captureClientId = typeof(IAudioCaptureClient).GUID; + Marshal.ThrowExceptionForHR(audioClient.GetService(ref captureClientId, out var captureClientObject)); + var captureClient = (IAudioCaptureClient)captureClientObject; + Marshal.ThrowExceptionForHR(audioClient.Start()); + Console.WriteLine($"Aquarium WASAPI loopback started: profile={profileId} {format.SampleRate} Hz, {format.Channels} channels, {format.BitsPerSample}-bit {format.SampleKind}."); + + while (running) + { + Marshal.ThrowExceptionForHR(captureClient.GetNextPacketSize(out var packetFrames)); + while (packetFrames > 0) + { + Marshal.ThrowExceptionForHR(captureClient.GetBuffer( + out var buffer, + out var frames, + out var flags, + out _, + out _)); + try + { + PublishPacket(buffer, frames, flags, format); + } + finally + { + Marshal.ThrowExceptionForHR(captureClient.ReleaseBuffer(frames)); + } + + Marshal.ThrowExceptionForHR(captureClient.GetNextPacketSize(out packetFrames)); + } + + Thread.Sleep(4); + } + + audioClient.Stop(); + } + finally + { + Marshal.FreeCoTaskMem(mixFormatPointer); + } + } + catch (Exception ex) + { + Console.WriteLine($"Aquarium WASAPI loopback unavailable: {ex.Message}"); + } + finally + { + audioClient?.Stop(); + if (comInitialized) + { + CoUninitialize(); + } + } + } + + private void PublishPacket(IntPtr buffer, int frameCount, uint flags, WasapiFormat format) + { + if (frameCount <= 0) + { + return; + } + + var mono = (flags & AudioCaptureBufferFlagsSilent) != 0 + ? new float[frameCount] + : format.SampleKind == WasapiSampleKind.Float32 + ? ReadFloat32(buffer, frameCount, format.Channels) + : ReadPcm16(buffer, frameCount, format.Channels); + stemBus.Publish(new AquariumAudioStemFrame( + profileId, + [new AquariumAudioStemChannel(0, "system_mix", displayName, sourceId, mono)], + frameCount, + format.SampleRate, + Interlocked.Increment(ref sequence))); + + if (TraceAudio) + { + Console.WriteLine($"Aquarium loopback packet: profile={profileId} frames={frameCount} seq={sequence}"); + } + } + + private static unsafe float[] ReadFloat32(IntPtr buffer, int frameCount, int channels) + { + var output = new float[frameCount]; + var input = (float*)buffer; + for (var frame = 0; frame < frameCount; frame++) + { + var sum = 0.0f; + for (var channel = 0; channel < channels; channel++) + { + sum += *input++; + } + + output[frame] = Math.Clamp(sum / Math.Max(1, channels), -1.0f, 1.0f); + } + + return output; + } + + private static unsafe float[] ReadPcm16(IntPtr buffer, int frameCount, int channels) + { + var output = new float[frameCount]; + var input = (short*)buffer; + for (var frame = 0; frame < frameCount; frame++) + { + var sum = 0.0f; + for (var channel = 0; channel < channels; channel++) + { + sum += *input++ / 32768.0f; + } + + output[frame] = Math.Clamp(sum / Math.Max(1, channels), -1.0f, 1.0f); + } + + return output; + } + + private readonly record struct WasapiFormat(int Channels, int SampleRate, int BitsPerSample, int BlockAlign, WasapiSampleKind SampleKind) + { + public static WasapiFormat FromPointer(IntPtr pointer) + { + var tag = Marshal.ReadInt16(pointer, 0); + var channels = Marshal.ReadInt16(pointer, 2); + var sampleRate = Marshal.ReadInt32(pointer, 4); + var blockAlign = Marshal.ReadInt16(pointer, 12); + var bitsPerSample = Marshal.ReadInt16(pointer, 14); + var kind = tag switch + { + WaveFormatIeeeFloat => WasapiSampleKind.Float32, + WaveFormatPcm => WasapiSampleKind.Pcm16, + WaveFormatExtensible when bitsPerSample == 32 => WasapiSampleKind.Float32, + _ => WasapiSampleKind.Pcm16 + }; + return new WasapiFormat(channels, sampleRate, bitsPerSample, blockAlign, kind); + } + } + + private enum WasapiSampleKind + { + Float32, + Pcm16 + } + + private const uint AudioCaptureBufferFlagsSilent = 0x2; + private const short WaveFormatPcm = 1; + private const short WaveFormatIeeeFloat = 3; + private const short WaveFormatExtensible = -2; + + [DllImport("ole32.dll")] + private static extern int CoInitializeEx(IntPtr pvReserved, CoInit dwCoInit); + + [DllImport("ole32.dll")] + private static extern void CoUninitialize(); + + [Flags] + private enum CoInit : uint + { + Multithreaded = 0x0 + } + + [Flags] + private enum ClsCtx : uint + { + All = 23 + } + + private enum EDataFlow + { + Render, + Capture, + All + } + + private enum ERole + { + Console, + Multimedia, + Communications + } + + private enum AudioClientShareMode + { + Shared, + Exclusive + } + + [Flags] + private enum AudioClientStreamFlags : uint + { + Loopback = 0x00020000, + NoPersist = 0x00080000 + } + + private static readonly Guid MMDeviceEnumeratorClsid = new("BCDE0395-E52F-467C-8E3D-C4579291692E"); + + [ComImport] + [Guid("A95664D2-9614-4F35-A746-DE8DB63617E6")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IMMDeviceEnumerator + { + int EnumAudioEndpoints(EDataFlow dataFlow, uint stateMask, out IntPtr devices); + + int GetDefaultAudioEndpoint(EDataFlow dataFlow, ERole role, out IMMDevice endpoint); + } + + [ComImport] + [Guid("D666063F-1587-4E43-81F1-B948E807363F")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IMMDevice + { + int Activate(ref Guid iid, ClsCtx clsCtx, IntPtr activationParams, [MarshalAs(UnmanagedType.IUnknown)] out object interfaceObject); + } + + [ComImport] + [Guid("1CB9AD4C-DBFA-4c32-B178-C2F568A703B2")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IAudioClient + { + int Initialize(AudioClientShareMode shareMode, AudioClientStreamFlags streamFlags, long bufferDuration, long periodicity, IntPtr format, IntPtr audioSessionGuid); + + int GetBufferSize(out int bufferFrameCount); + + int GetStreamLatency(out long latency); + + int GetCurrentPadding(out int paddingFrameCount); + + int IsFormatSupported(AudioClientShareMode shareMode, IntPtr format, out IntPtr closestMatch); + + int GetMixFormat(out IntPtr deviceFormat); + + int GetDevicePeriod(out long defaultDevicePeriod, out long minimumDevicePeriod); + + int Start(); + + int Stop(); + + int Reset(); + + int SetEventHandle(IntPtr eventHandle); + + int GetService(ref Guid iid, [MarshalAs(UnmanagedType.IUnknown)] out object serviceInterface); + } + + [ComImport] + [Guid("C8ADBD64-E71E-48a0-A4DE-185C395CD317")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IAudioCaptureClient + { + int GetBuffer(out IntPtr dataBuffer, out int numFramesToRead, out uint bufferFlags, out long devicePosition, out long qpcPosition); + + int ReleaseBuffer(int numFramesRead); + + int GetNextPacketSize(out int numFramesInNextPacket); + } +} diff --git a/src/Aquarium.Engine/Platform/Win32Window.cs b/src/Aquarium.Engine/Platform/Win32Window.cs index 5754be8..a118917 100644 --- a/src/Aquarium.Engine/Platform/Win32Window.cs +++ b/src/Aquarium.Engine/Platform/Win32Window.cs @@ -69,10 +69,15 @@ public sealed class Win32Window : IDisposable private const int VK_DELETE = 0x2E; private const int VK_RETURN = 0x0D; private const int VK_LEFT = 0x25; + private const int VK_UP = 0x26; private const int VK_RIGHT = 0x27; + private const int VK_DOWN = 0x28; private const int VK_HOME = 0x24; private const int VK_END = 0x23; + private const int VK_PRIOR = 0x21; + private const int VK_NEXT = 0x22; private const int VK_SHIFT = 0x10; + private const int VK_CONTROL = 0x11; private const int WHEEL_DELTA = 120; private readonly WndProc windowProcedure; @@ -824,18 +829,33 @@ public sealed class Win32Window : IDisposable case VK_LEFT: input.SetKey(KeyCode.LeftArrow, isDown); break; + case VK_UP: + input.SetKey(KeyCode.UpArrow, isDown); + break; case VK_RIGHT: input.SetKey(KeyCode.RightArrow, isDown); break; + case VK_DOWN: + input.SetKey(KeyCode.DownArrow, isDown); + break; case VK_HOME: input.SetKey(KeyCode.Home, isDown); break; case VK_END: input.SetKey(KeyCode.End, isDown); break; + case VK_PRIOR: + input.SetKey(KeyCode.PageUp, isDown); + break; + case VK_NEXT: + input.SetKey(KeyCode.PageDown, isDown); + break; case VK_SHIFT: input.SetKey(KeyCode.Shift, isDown); break; + case VK_CONTROL: + input.SetKey(KeyCode.Control, isDown); + break; } } diff --git a/src/Aquarium.Engine/Render/D3D12ComputeProbe.cs b/src/Aquarium.Engine/Render/D3D12ComputeProbe.cs new file mode 100644 index 0000000..f8b8b84 --- /dev/null +++ b/src/Aquarium.Engine/Render/D3D12ComputeProbe.cs @@ -0,0 +1,64 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +using Vortice.Direct3D; +using Vortice.Direct3D12; + +namespace Aquarium.Engine.Render; + +internal static class D3D12ComputeProbe +{ + public static unsafe TOutput[] Run(string shaderPath, string entryPoint, ReadOnlySpan inputs) + where TInput : unmanaged where TOutput : unmanaged + { + if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("D3D12 probes require Windows."); + var inputBytes = checked(inputs.Length * Unsafe.SizeOf()); + var outputBytes = checked(inputs.Length * Unsafe.SizeOf()); + using var device = D3D12.D3D12CreateDevice(IntPtr.Zero, FeatureLevel.Level_11_0); + using var queue = device.CreateCommandQueue(new CommandQueueDescription(CommandListType.Direct)); + using var allocator = device.CreateCommandAllocator(CommandListType.Direct); + using var list = device.CreateCommandList(0, CommandListType.Direct, allocator, null); + using var fence = device.CreateFence(0); + using var fenceEvent = new EventWaitHandle(false, EventResetMode.AutoReset); + using var input = device.CreateCommittedResource(HeapType.Upload, ResourceDescription.Buffer((ulong)inputBytes), ResourceStates.GenericRead); + using var output = device.CreateCommittedResource(HeapType.Default, ResourceDescription.Buffer((ulong)outputBytes, ResourceFlags.AllowUnorderedAccess), ResourceStates.UnorderedAccess); + using var readback = device.CreateCommittedResource(HeapType.Readback, ResourceDescription.Buffer((ulong)outputBytes), ResourceStates.CopyDest); + + var mappedInput = input.Map(0); + MemoryMarshal.AsBytes(inputs).CopyTo(new Span(mappedInput, inputBytes)); + input.Unmap(0); + + var parameters = new[] + { + new RootParameter(RootParameterType.ShaderResourceView, new RootDescriptor(0, 0), ShaderVisibility.All), + new RootParameter(RootParameterType.UnorderedAccessView, new RootDescriptor(0, 0), ShaderVisibility.All), + new RootParameter(new RootConstants(0, 0, 3), ShaderVisibility.All), + }; + var signatureDescription = new RootSignatureDescription(RootSignatureFlags.None, parameters, []); + using var signature = device.CreateRootSignature(0, in signatureDescription, RootSignatureVersion.Version1); + using var pipeline = device.CreateComputePipelineState(new ComputePipelineStateDescription + { + RootSignature = signature, + ComputeShader = D3D12ShaderCompiler.Compile(shaderPath, entryPoint, "cs_5_0"), + }); + list.SetComputeRootSignature(signature); + list.SetPipelineState(pipeline); + list.SetComputeRootShaderResourceView(0, input.GPUVirtualAddress); + list.SetComputeRootUnorderedAccessView(1, output.GPUVirtualAddress); + list.SetComputeRoot32BitConstant(2, (uint)inputs.Length, 0); + list.Dispatch((uint)((inputs.Length + 63) / 64), 1, 1); + list.ResourceBarrier(ResourceBarrier.BarrierTransition(output, ResourceStates.UnorderedAccess, ResourceStates.CopySource)); + list.CopyBufferRegion(readback, 0, output, 0, (ulong)outputBytes); + list.Close(); + queue.ExecuteCommandList(list); + queue.Signal(fence, 1).CheckError(); + fence.SetEventOnCompletion(1, fenceEvent.SafeWaitHandle.DangerousGetHandle()).CheckError(); + fenceEvent.WaitOne(); + + var results = new TOutput[inputs.Length]; + var mappedOutput = readback.Map(0); + new ReadOnlySpan(mappedOutput, outputBytes).CopyTo(MemoryMarshal.AsBytes(results.AsSpan())); + readback.Unmap(0); + return results; + } +} diff --git a/src/Aquarium.Engine/Render/D3D12ExternalSensorTexture.cs b/src/Aquarium.Engine/Render/D3D12ExternalSensorTexture.cs index 775a48a..2a75e57 100644 --- a/src/Aquarium.Engine/Render/D3D12ExternalSensorTexture.cs +++ b/src/Aquarium.Engine/Render/D3D12ExternalSensorTexture.cs @@ -59,7 +59,7 @@ internal sealed class D3D12ExternalSensorTexture : IDisposable Resource, new ShaderResourceViewDescription { - Format = Format, + Format = ToShaderResourceViewFormat(Format), ViewDimension = ShaderResourceViewDimension.Texture2D, Shader4ComponentMapping = ShaderComponentMapping.Default, Texture2D = new Texture2DShaderResourceView { MipLevels = 1 }, @@ -131,10 +131,18 @@ internal sealed class D3D12ExternalSensorTexture : IDisposable AquariumGpuSensorPixelFormat.R16Float => Format.R16_Float, AquariumGpuSensorPixelFormat.Rg8Unorm => Format.R8G8_UNorm, AquariumGpuSensorPixelFormat.LeapPackedMap => Format.R8G8B8A8_UNorm, + AquariumGpuSensorPixelFormat.Yuy2 => Format.YUY2, _ => Format.Unknown, }; } + private static Format ToShaderResourceViewFormat(Format format) + { + return format == Format.YUY2 + ? Format.R8G8B8A8_UNorm + : format; + } + public void Dispose() { Resource.Dispose(); diff --git a/src/Aquarium.Engine/Render/D3D12FieldMesh.cs b/src/Aquarium.Engine/Render/D3D12FieldMesh.cs index 28b6928..d51f050 100644 --- a/src/Aquarium.Engine/Render/D3D12FieldMesh.cs +++ b/src/Aquarium.Engine/Render/D3D12FieldMesh.cs @@ -13,7 +13,8 @@ internal sealed class D3D12FieldMesh : IDisposable AquariumFieldMeshIndexFormat indexFormat, AquariumFieldMeshLayout layout, int submeshCount, - ulong version) + ulong version, + string sourceUri) { Vertices = vertices; Indices = indices; @@ -22,6 +23,7 @@ internal sealed class D3D12FieldMesh : IDisposable Layout = layout; SubmeshCount = submeshCount; Version = version; + SourceUri = sourceUri; } public D3D12StructuredBuffer Vertices { get; } @@ -44,6 +46,11 @@ internal sealed class D3D12FieldMesh : IDisposable public ulong Version { get; } + public string SourceUri { get; } + + public bool IsGeneratedFromResource => + SourceUri.StartsWith("derived-from:", StringComparison.Ordinal); + public PrimitiveTopology D3DTopology => Topology switch { AquariumFieldMeshTopology.TriangleStrip => PrimitiveTopology.TriangleStrip, diff --git a/src/Aquarium.Engine/Render/D3D12FieldResourceRegistry.cs b/src/Aquarium.Engine/Render/D3D12FieldResourceRegistry.cs index b6d4ece..f50132b 100644 --- a/src/Aquarium.Engine/Render/D3D12FieldResourceRegistry.cs +++ b/src/Aquarium.Engine/Render/D3D12FieldResourceRegistry.cs @@ -668,7 +668,8 @@ internal sealed class D3D12FieldResourceRegistry : IDisposable existing.Indices.StrideBytes == mesh.Indices.StrideBytes && existing.Topology == mesh.Topology && existing.IndexFormat == mesh.IndexFormat && - existing.Layout == mesh.Layout) + existing.Layout == mesh.Layout && + string.Equals(existing.SourceUri, declaration.SourceUri, StringComparison.Ordinal)) { return true; } @@ -683,12 +684,14 @@ internal sealed class D3D12FieldResourceRegistry : IDisposable device, mesh.Vertices.Count, mesh.Vertices.StrideBytes, - $"Aquarium D3D12 Field Mesh Vertices {declaration.ResourceKey}"); + $"Aquarium D3D12 Field Mesh Vertices {declaration.ResourceKey}", + allowUnorderedAccess: IsGeneratedMeshSource(declaration.SourceUri)); var indices = new D3D12StructuredBuffer( device, mesh.Indices.Count, mesh.Indices.StrideBytes, - $"Aquarium D3D12 Field Mesh Indices {declaration.ResourceKey}"); + $"Aquarium D3D12 Field Mesh Indices {declaration.ResourceKey}", + allowUnorderedAccess: IsGeneratedMeshSource(declaration.SourceUri)); meshes[declaration.ResourceKey] = new D3D12FieldMesh( vertices, indices, @@ -696,10 +699,14 @@ internal sealed class D3D12FieldResourceRegistry : IDisposable mesh.IndexFormat, mesh.Layout, mesh.SubmeshCount, - declaration.Version); + declaration.Version, + declaration.SourceUri); return true; } + private static bool IsGeneratedMeshSource(string sourceUri) => + sourceUri.StartsWith("derived-from:", StringComparison.Ordinal); + private int RemoveStaleStructuredBuffers(D3D12ResourceRegistry resourceRegistry, HashSet currentKeys) { var removed = 0; diff --git a/src/Aquarium.Engine/Render/D3D12FieldTexture2D.cs b/src/Aquarium.Engine/Render/D3D12FieldTexture2D.cs index 304660d..9b54e2c 100644 --- a/src/Aquarium.Engine/Render/D3D12FieldTexture2D.cs +++ b/src/Aquarium.Engine/Render/D3D12FieldTexture2D.cs @@ -165,13 +165,34 @@ internal sealed unsafe class D3D12FieldTexture2D : IDisposable } } + public static D3D12FieldTexture2D WrapOwned( + ID3D12Resource resource, + int width, + int height, + Format format, + bool allowsUnorderedAccess, + ResourceStates state, + string name) + { + var texture = new D3D12FieldTexture2D( + resource, + uploadResource: null, + Math.Max(1, width), + Math.Max(1, height), + format, + allowsUnorderedAccess, + name); + texture.State = state; + return texture; + } + public void CreateShaderResourceView(ID3D12Device device, D3D12DescriptorSlot descriptor) { device.CreateShaderResourceView( Resource, new ShaderResourceViewDescription { - Format = Format, + Format = ToShaderResourceViewFormat(Format), ViewDimension = ShaderResourceViewDimension.Texture2D, Shader4ComponentMapping = ShaderComponentMapping.Default, Texture2D = new Texture2DShaderResourceView { MipLevels = 1 }, @@ -216,6 +237,13 @@ internal sealed unsafe class D3D12FieldTexture2D : IDisposable Resource.Dispose(); } + private static Format ToShaderResourceViewFormat(Format format) + { + return format == Format.YUY2 + ? Format.R8G8B8A8_UNorm + : format; + } + private static D3D12FieldTexture2D CreateRgba8( ID3D12Device device, ID3D12GraphicsCommandList commandList, diff --git a/src/Aquarium.Engine/Render/D3D12FieldTextureFormat.cs b/src/Aquarium.Engine/Render/D3D12FieldTextureFormat.cs index 1c3cf12..ea46412 100644 --- a/src/Aquarium.Engine/Render/D3D12FieldTextureFormat.cs +++ b/src/Aquarium.Engine/Render/D3D12FieldTextureFormat.cs @@ -8,7 +8,7 @@ internal static class D3D12FieldTextureFormat { format = formatName switch { - "Gray8" or "R8" or "R8Unorm" or "R8_UNorm" or "R8_UNORM" => Format.R8_UNorm, + "Bayer8" or "Gray8" or "R8" or "R8Unorm" or "R8_UNorm" or "R8_UNORM" => Format.R8_UNorm, "R16Float" or "R16_Float" or "R16_FLOAT" => Format.R16_Float, "R32Float" or "R32_Float" or "R32_FLOAT" or "Float32" => Format.R32_Float, "Rg8" or "Rg8Unorm" or "R8G8_UNorm" or "R8G8_UNORM" or "LeapStereoIr" or "LeapPackedMap" => Format.R8G8_UNorm, diff --git a/src/Aquarium.Engine/Render/D3D12Renderer.cs b/src/Aquarium.Engine/Render/D3D12Renderer.cs index 6cdb517..23e3205 100644 --- a/src/Aquarium.Engine/Render/D3D12Renderer.cs +++ b/src/Aquarium.Engine/Render/D3D12Renderer.cs @@ -6,6 +6,8 @@ using Aquarium.Engine.Render.Graph; using SharpGen.Runtime; using Aquarium.Engine.Render.Ui; using Aquarium.Engine.Ui; +using CultMath; +using GameCult.Geometry; using Vortice; using Vortice.D3DCompiler; using Vortice.Direct3D; @@ -28,7 +30,7 @@ namespace Aquarium.Engine.Render; public sealed class D3D12Renderer : IAquariumRenderer { private const int BackBufferCount = 2; - private const int HeightFieldTextureSize = 128; + private const int HeightFieldTextureSize = 512; private const Format HeightFieldFormat = Format.R16_Float; private const Format SceneDepthFormat = Format.D32_Float; private const int MaxSdfLightCount = 64; @@ -36,7 +38,7 @@ public sealed class D3D12Renderer : IAquariumRenderer private const int MaxGpuSensorCameraCount = 32; private const int MaxGpuSensorTextureCount = 8; private const int MaxAcousticConstraintCount = 128; - private const int GpuSensorSamplesPerTexture = 131_072; + private const int GpuSensorSamplesPerTexture = 16_384; private const int MaxTemporalGaussianCount = 1_048_576; private const int MaxVisibleFractalSplatCount = 524_288; private const int MaxTubeFieldSegments = 65_536; @@ -44,6 +46,11 @@ public sealed class D3D12Renderer : IAquariumRenderer private const int MaxTubeFieldIndices = MaxTubeFieldSegments * 6; private const int MaxTubeFieldDrawBatches = 4_096; private const int MaxTubeFieldReplaySources = 16; + private const int MaxBokushoBrushTufts = 1024; + private const int MaxBokushoBrushSamples = 512; + private const int MaxBokushoBrushStrokes = 256; + private const int MaxBokushoSourcePoints = 4096; + private const int MaxBokushoBrushValues = MaxBokushoBrushTufts * MaxBokushoBrushSamples; private const int FieldReservoirSlotsPerPixel = 4; private const int FieldReservoirCandidateStrideBytes = 144; private const int FieldReservoirHistoryStrideBytes = 144; @@ -53,6 +60,7 @@ public sealed class D3D12Renderer : IAquariumRenderer private const float SurfaceTransparentMaxZ = 0.45f; private const int BloomLevelCount = 8; private const Format SceneHdrFormat = Format.R16G16B16A16_Float; + private const Format SceneOverdrawFormat = Format.R16_Float; private const string StudioPmremRelativePath = "Assets/Textures/studio3_pmrem.dds"; private const string StudioIrradianceRelativePath = "Assets/Textures/studio3_irradiance.dds"; private const string ProgramOutputEnabledEnvironmentVariable = "FENSALIR_PROGRAM_OUTPUT_D3D12"; @@ -63,6 +71,7 @@ public sealed class D3D12Renderer : IAquariumRenderer private const string DefaultProgramOutputSharedName = "Global\\MimirFensalirProgramTexture"; private const string DefaultProgramOutputFenceName = "Global\\MimirFensalirProgramFence"; private const int MaxProgramOutputRingCount = 4; + private const int GpuTimingQueryCount = (int)D3D12GpuTimingPass.Count * 2; private const int RootFrameConstants = 0; private const int RootSourceTexture = 1; private const int RootHeightFieldBrushes = 2; @@ -86,6 +95,11 @@ public sealed class D3D12Renderer : IAquariumRenderer private const int RootReservoirResolvedOutput = 20; private const int RootTubeFieldReplayManifest = 21; private const int RootTubeFieldReplaySources = 22; + private const int RootSceneOverdraw = 23; + private const int RootPlanetarySurfacePage = 29; + private const int RootPlanetarySurfacePageMetadata = 30; + private const int RootPlanetarySurfacePageSummary = 31; + private const int RootPlanetarySurfacePageSet = 32; private const int RootFusionFrameConstants = 0; private const int RootFusionSeeds = 1; private const int RootFusionSensorCameras = 2; @@ -93,6 +107,15 @@ public sealed class D3D12Renderer : IAquariumRenderer private const int RootFusionAcousticConstraints = 4; private const int RootFusionOutput = 5; private const int RootFusionNativePoints = 6; + private const int RootPointCloudFrameConstants = 0; + private const int RootPointCloudConstants = 1; + private const int RootPointCloudDisparity = 2; + private const int RootPointCloudVertices = 3; + private const int RootPointCloudIndices = 4; + private const int RootPointCloudFractalSplats = 5; + private const int RootPointCloudSdfReservoirs = 6; + private const int RootPointCloudPbrReservoirs = 7; + private const int RootPointCloudRadiosityReservoirs = 8; private const int RootFractalConstants = 0; private const int RootFractalSplats = 1; private const int RootFractalSdfReservoirs = 2; @@ -117,6 +140,18 @@ public sealed class D3D12Renderer : IAquariumRenderer private const int RootTubeFieldRenderBlueNoise = 4; private const int RootTubeFieldRenderReservoirCandidates = 5; private const int RootTubeFieldRenderReservoirLocks = 6; + private const int RootBokushoBrushConstants = 0; + private const int RootBokushoBrushTrace = 1; + private const int RootBokushoBrushCanvas = 2; + private const int RootBokushoBrushTips = 3; + private const int RootBokushoBrushStrokes = 4; + private const int RootBokushoBrushPage = 5; + private const int RootBokushoBrushSourcePoints = 6; + private const int RootBokushoPageConstants = 24; + private const int RootBokushoPageCanvas = 25; + private const int RootBokushoPageStrokes = 26; + private const int RootBokushoPageTips = 27; + private const int RootBokushoPageDensity = 28; private static readonly DebugUi.DebugUiOption[] RenderDebugOptions = [ new(0, "Final"), @@ -140,6 +175,22 @@ public sealed class D3D12Renderer : IAquariumRenderer new(18, "Reservoir Shift"), new(19, "Reservoir Disocclusion"), new(20, "Reservoir Spatial Budget"), + new(21, "Overdraw"), + new(22, "Planet Tile/Level/Blend"), + new(23, "Planet Bounds/Error"), + new(24, "Planet Refined Normal"), + new(25, "Planet Ridge/Gully"), + new(26, "Planet Cross-Edge Error"), + new(27, "Planet Direct/Page Error"), + new(28, "Planet Version/Wavelength"), + ]; + private static readonly Format[] SceneEvidenceRenderTargetFormats = + [ + SceneHdrFormat, + SceneHdrFormat, + SceneHdrFormat, + SceneHdrFormat, + SceneOverdrawFormat, ]; private static readonly DebugUi.DebugUiOption[] FieldReservoirModeOptions = [ @@ -169,10 +220,13 @@ public sealed class D3D12Renderer : IAquariumRenderer private readonly FrameResources[] frames = new FrameResources[BackBufferCount]; private readonly ID3D12GraphicsCommandList commandList; private readonly ID3D12RootSignature fullscreenRootSignature; + private readonly ID3D12RootSignature planetarySurfacePageRootSignature; private readonly ID3D12RootSignature gpuSensorFusionRootSignature; + private readonly ID3D12RootSignature pointCloudRootSignature; private readonly ID3D12RootSignature fractalReservoirRootSignature; private readonly ID3D12RootSignature tubeFieldRootSignature; private readonly ID3D12RootSignature tubeFieldRenderRootSignature; + private readonly ID3D12RootSignature bokushoBrushRootSignature; private readonly ID3D12CommandSignature generatedMeshDrawCommandSignature; private readonly D3D12BlueNoiseTexture blueNoiseTexture; private D3D12FieldTexture2D? tubeFieldFallbackRampTexture; @@ -182,14 +236,23 @@ public sealed class D3D12Renderer : IAquariumRenderer private ID3D12PipelineState? temporalGaussianPipelineState; private ID3D12PipelineState? splinePipelineState; private ID3D12PipelineState? gpuSensorFusionPipelineState; + private ID3D12PipelineState? stereoDepthPipelineState; + private ID3D12PipelineState? pointCloudComputePipelineState; + private ID3D12PipelineState? pointCloudRenderPipelineState; private ID3D12PipelineState? fractalSurfaceSplatRenderPipelineState; private ID3D12PipelineState? fractalTransparentSplatRenderPipelineState; private ID3D12PipelineState? fractalSplatPipelineState; private ID3D12PipelineState? fractalSdfReservoirPipelineState; private ID3D12PipelineState? fractalPbrReservoirPipelineState; private ID3D12PipelineState? fractalRadiosityReservoirPipelineState; + private ID3D12PipelineState? planetarySurfacePagePipelineState; + private ID3D12PipelineState? planetarySurfacePageSummaryPipelineState; + private ID3D12PipelineState? planetarySurfacePatchPipelineState; private ID3D12PipelineState? tubeFieldComputePipelineState; private ID3D12PipelineState? tubeFieldRenderPipelineState; + private ID3D12PipelineState? bokushoBrushPipelineState; + private ID3D12PipelineState? bokushoPageClearPipelineState; + private ID3D12PipelineState? bokushoPageDepositPipelineState; private ID3D12PipelineState? fieldReservoirResolvePipelineState; private ID3D12PipelineState?[] sdfProxyPipelineStates = []; private ID3D12PipelineState? bloomPrefilterPipelineState; @@ -202,7 +265,10 @@ public sealed class D3D12Renderer : IAquariumRenderer private readonly AutoResetEvent fenceEvent = new(false); private DebugUi debugUi; private IReadOnlyList clientUiPanels = []; + private IReadOnlyList clientUiSurfaces = []; private AquariumUiDocument? currentClientUi; + private AquariumUiPreviewDrag? activePreviewDrag; + private bool clientUiSurfaceWantsMouse; private int activeDebugTab; private string[] debugTabTitles = ["Aquarium", "Terminal", "Synth"]; private string terminalInput = "help"; @@ -224,6 +290,7 @@ public sealed class D3D12Renderer : IAquariumRenderer private D3D12RenderTarget sceneCandidateMetadataRenderTarget; private D3D12RenderTarget sceneCandidateControlRenderTarget; private D3D12RenderTarget sceneCandidateReservoirGuideRenderTarget; + private D3D12RenderTarget sceneOverdrawRenderTarget; private D3D12RenderTarget reservoirResolvedRenderTarget; private readonly Dictionary graphRenderTargets = new(StringComparer.Ordinal); private D3D12TrackedResource sceneDepthTarget; @@ -254,6 +321,21 @@ public sealed class D3D12Renderer : IAquariumRenderer private readonly D3D12StructuredBuffer tubeFieldStatsBuffer; private readonly D3D12StructuredBuffer tubeFieldDrawArgumentBuffer; private readonly D3D12StructuredBuffer tubeFieldReplayManifestBuffer; + private readonly D3D12StructuredBuffer bokushoBrushTraceBuffer; + private readonly D3D12StructuredBuffer bokushoBrushCanvasBuffer; + private readonly D3D12StructuredBuffer bokushoBrushTipBuffer; + private readonly D3D12StructuredBuffer bokushoBrushStrokeBuffer; + private readonly D3D12StructuredBuffer bokushoSourcePointBuffer; + private readonly D3D12StructuredBuffer bokushoPageDensityBuffer; + private D3D12StructuredBuffer? planetarySurfacePageInputBuffer; + private D3D12StructuredBuffer? planetarySurfacePageOutputBuffer; + private D3D12StructuredBuffer? planetarySurfacePageMetadataBuffer; + private D3D12StructuredBuffer? planetarySurfacePageSummaryBuffer; + private D3D12StructuredBuffer? planetarySurfacePageSetBuffer; + private AquariumPlanetarySurfacePageSet activePlanetarySurfacePages = AquariumPlanetarySurfacePageSet.Empty; + private long residentPlanetarySurfacePageContentVersion; + private long residentPlanetarySurfacePagePresentationVersion; + private int residentPlanetarySurfacePatchCells; private D3D12StructuredBuffer fieldReservoirCandidateBuffer = null!; private D3D12StructuredBuffer fieldReservoirLockBuffer = null!; private readonly List tubeFieldDrawBatches = []; @@ -279,6 +361,7 @@ public sealed class D3D12Renderer : IAquariumRenderer private readonly AquariumSdfLight[] sdfLights = new AquariumSdfLight[MaxSdfLightCount]; private readonly AquariumSdfObject[] sdfObjects = new AquariumSdfObject[MaxSdfObjectCount]; private readonly D3D12GpuSensorCameraPacket[] gpuSensorCameras = new D3D12GpuSensorCameraPacket[MaxGpuSensorCameraCount]; + private readonly string[] gpuSensorFieldTextureKeys = new string[MaxGpuSensorTextureCount]; private readonly D3D12AcousticConstraintPacket[] acousticConstraints = new D3D12AcousticConstraintPacket[MaxAcousticConstraintCount]; private readonly D3D12GpuFusionSeedPacket[] gpuFusionSeeds = new D3D12GpuFusionSeedPacket[MaxTemporalGaussianCount]; private readonly D3D12TemporalGaussianPacket[] temporalGaussians = new D3D12TemporalGaussianPacket[MaxTemporalGaussianCount]; @@ -286,6 +369,9 @@ public sealed class D3D12Renderer : IAquariumRenderer private int temporalGaussianCount; private int gpuSensorCameraCount; private int gpuSensorTextureCount; + private int gpuSensorExternalTextureCount; + private int gpuSensorFieldTextureCount; + private int gpuSensorFieldUnsupportedTextureCount; private int acousticConstraintCount; private int gpuFusionSeedCount; private int gpuFusionPointCount; @@ -294,6 +380,7 @@ public sealed class D3D12Renderer : IAquariumRenderer private bool temporalGaussiansGpuGenerated; private AquariumFractalReservoirField activeFractalReservoirField = AquariumFractalReservoirField.Empty; private AquariumBufferFieldFrame activeBufferFieldFrame = AquariumBufferFieldFrame.Empty; + private AquariumBokushoBrushFrame activeBokushoBrushFrame = AquariumBokushoBrushFrame.Empty; private AquariumPackedTextureSplineFieldProgram[] activeTextureSplinePrograms = []; private float[] activeTextureFieldSamples = []; private AquariumSplineFrame activeSplineFrame = AquariumSplineFrame.Empty; @@ -310,8 +397,14 @@ public sealed class D3D12Renderer : IAquariumRenderer private int activeTubeFieldUnplannedLowerings; private int activeTubeFieldInvalidColumns; private int activeStereoDepthDispatchReadyLowerings; + private int activeStereoDepthDispatchedLowerings; private int activeStereoDepthUnplannedLowerings; private int activeStereoDepthUnresolvedLowerings; + private int activePointCloudGeneratedMeshes; + private int activePointCloudRenderedMeshes; + private int activePointCloudUnresolvedMeshes; + private int activePointCloudUnplannedMeshes; + private bool activePointCloudSurfaceReservoir; private int activeFieldResourceUploadCount; private int activeFieldResourceUploadSkippedCount; private Viewport viewport; @@ -329,6 +422,8 @@ public sealed class D3D12Renderer : IAquariumRenderer private Vector3 previousCameraTarget; private Vector3 activeCameraPosition; private Vector3 activeCameraTarget; + private Vector2 activeViewCenter; + private float activeViewRadius; private Vector2 previousViewCenter; private Vector2 previousCursorWorld; private Vector2 previousJitterPixels; @@ -346,8 +441,13 @@ public sealed class D3D12Renderer : IAquariumRenderer private readonly string shaderSourceRoot; private readonly D3D12ShaderPaths shaderPaths; private readonly CompiledRenderGraph renderGraph; + private readonly bool hasSceneRenderingPass; + private readonly bool profilePlanetarySurfacePages; private readonly Stopwatch shaderReloadClock = Stopwatch.StartNew(); private readonly Dictionary shaderWriteTimesUtc = new(StringComparer.OrdinalIgnoreCase); + private readonly double[] accumulatedGpuTimingMilliseconds = new double[(int)D3D12GpuTimingPass.Count]; + private readonly int[] accumulatedGpuTimingPassFrames = new int[(int)D3D12GpuTimingPass.Count]; + private ulong gpuTimestampFrequency; private Task? pipelineBuildTask; private TimeSpan lastShaderReloadCheck; private bool shaderReloadFailureReported; @@ -384,6 +484,12 @@ public sealed class D3D12Renderer : IAquariumRenderer programOutputRingCount = ResolveProgramOutputRingCount(); var activeRenderPlan = renderPlan ?? new AquariumRenderPlan(); renderGraph = D3D12RenderGraphCompiler.Compile(activeRenderPlan); + hasSceneRenderingPass = renderGraph.Passes.Any(static pass => + pass.Kind is AquariumPassKind.Fullscreen or AquariumPassKind.Proxy or AquariumPassKind.Instanced or AquariumPassKind.Compute); + profilePlanetarySurfacePages = string.Equals( + Environment.GetEnvironmentVariable("AQUARIUM_PROFILE_PLANETARY_PAGES"), + "1", + StringComparison.Ordinal); shaderSourceRoot = ResolveShaderSourceRoot(shaderPath, activeRenderPlan.Shaders); shaderPaths = D3D12ShaderPaths.FromManifest(shaderSourceRoot, activeRenderPlan.Shaders); sdfProxyPipelineStates = new ID3D12PipelineState?[shaderPaths.SdfShaders.Count]; @@ -394,6 +500,7 @@ public sealed class D3D12Renderer : IAquariumRenderer device.Name = "Aquarium D3D12 Device"; commandQueue = device.CreateCommandQueue(new CommandQueueDescription(CommandListType.Direct)); commandQueue.Name = "Aquarium D3D12 Direct Queue"; + commandQueue.GetTimestampFrequency(out gpuTimestampFrequency); CreateOverlayDevice(out overlayDevice, out overlayContext, out overlayOn12Device); var swapChainDescription = new SwapChainDescription1 @@ -435,6 +542,7 @@ public sealed class D3D12Renderer : IAquariumRenderer sceneCandidateMetadataRenderTarget = CreateSceneAuxiliaryRenderTarget("scene-candidate-metadata-target", "Aquarium D3D12 Scene Candidate Metadata Target"); sceneCandidateControlRenderTarget = CreateSceneAuxiliaryRenderTarget("scene-candidate-control-target", "Aquarium D3D12 Scene Candidate Control Target"); sceneCandidateReservoirGuideRenderTarget = CreateSceneAuxiliaryRenderTarget("scene-candidate-reservoir-guide-target", "Aquarium D3D12 Scene Candidate Reservoir Guide Target"); + sceneOverdrawRenderTarget = CreateSceneOverdrawRenderTarget(); reservoirResolvedRenderTarget = CreateReservoirResolvedRenderTarget(); sceneDepthStencilView = depthStencilViewArena.Allocate(); sceneDepthTarget = CreateSceneDepthTarget(sceneDepthStencilView); @@ -453,6 +561,12 @@ public sealed class D3D12Renderer : IAquariumRenderer tubeFieldStatsBuffer = new D3D12StructuredBuffer(device, 4, Marshal.SizeOf(), "Aquarium D3D12 TubeField Stats Buffer", allowUnorderedAccess: true); tubeFieldDrawArgumentBuffer = new D3D12StructuredBuffer(device, MaxTubeFieldDrawBatches * GeneratedMeshDrawArgumentUIntCount, Marshal.SizeOf(), "Aquarium D3D12 TubeField Indirect Draw Arguments", allowUnorderedAccess: true); tubeFieldReplayManifestBuffer = new D3D12StructuredBuffer(device, MaxTubeFieldReplaySources, Marshal.SizeOf(), "Aquarium D3D12 TubeField Replay Manifest"); + bokushoBrushTraceBuffer = new D3D12StructuredBuffer(device, MaxBokushoBrushValues, sizeof(float), "Aquarium D3D12 Bokusho Brush Trace Field", allowUnorderedAccess: true); + bokushoBrushCanvasBuffer = new D3D12StructuredBuffer(device, MaxBokushoBrushValues, sizeof(float), "Aquarium D3D12 Bokusho Brush Canvas Field", allowUnorderedAccess: true); + bokushoBrushTipBuffer = new D3D12StructuredBuffer(device, MaxBokushoBrushValues, Marshal.SizeOf(), "Aquarium D3D12 Bokusho Brush Tip Field", allowUnorderedAccess: true); + bokushoBrushStrokeBuffer = new D3D12StructuredBuffer(device, MaxBokushoBrushStrokes, Marshal.SizeOf(), "Aquarium D3D12 Bokusho Brush Stroke Packet Buffer"); + bokushoSourcePointBuffer = new D3D12StructuredBuffer(device, MaxBokushoSourcePoints, Marshal.SizeOf(), "Aquarium D3D12 Bokusho Source Point Buffer"); + bokushoPageDensityBuffer = new D3D12StructuredBuffer(device, HeightFieldTextureSize * HeightFieldTextureSize, sizeof(uint), "Aquarium D3D12 Bokusho Page Density Field", allowUnorderedAccess: true); CreateFieldReservoirBuffers(); resourceRegistry.Add("sdf-light-buffer", sdfLightBuffer); resourceRegistry.Add("sdf-object-buffer", sdfObjectBuffer); @@ -467,6 +581,12 @@ public sealed class D3D12Renderer : IAquariumRenderer resourceRegistry.Add("tube-field-stats-buffer", tubeFieldStatsBuffer); resourceRegistry.Add("tube-field-indirect-draw-arguments", tubeFieldDrawArgumentBuffer); resourceRegistry.Add("tube-field-replay-manifest", tubeFieldReplayManifestBuffer); + resourceRegistry.Add("bokusho-brush-trace-buffer", bokushoBrushTraceBuffer); + resourceRegistry.Add("bokusho-brush-canvas-buffer", bokushoBrushCanvasBuffer); + resourceRegistry.Add("bokusho-brush-tip-buffer", bokushoBrushTipBuffer); + resourceRegistry.Add("bokusho-brush-stroke-buffer", bokushoBrushStrokeBuffer); + resourceRegistry.Add("bokusho-source-point-buffer", bokushoSourcePointBuffer); + resourceRegistry.Add("bokusho-page-density-buffer", bokushoPageDensityBuffer); commandList = device.CreateCommandList(0, CommandListType.Direct, frames[frameIndex].CommandAllocator, null); commandList.Name = "Aquarium D3D12 Graphics Command List"; commandList.Close(); @@ -492,14 +612,20 @@ public sealed class D3D12Renderer : IAquariumRenderer ReportStartupProgress(startupProgress, "Creating D3D12 render pipelines"); fullscreenRootSignature = CreateFullscreenRootSignature(); fullscreenRootSignature.Name = "Aquarium D3D12 Fullscreen Root Signature"; + planetarySurfacePageRootSignature = CreatePlanetarySurfacePageRootSignature(); + planetarySurfacePageRootSignature.Name = "Aquarium D3D12 Planetary Surface Page Root Signature"; gpuSensorFusionRootSignature = CreateGpuSensorFusionRootSignature(); gpuSensorFusionRootSignature.Name = "Aquarium D3D12 GPU Sensor Fusion Root Signature"; + pointCloudRootSignature = CreatePointCloudRootSignature(); + pointCloudRootSignature.Name = "Aquarium D3D12 Point Cloud Root Signature"; fractalReservoirRootSignature = CreateFractalReservoirRootSignature(); fractalReservoirRootSignature.Name = "Aquarium D3D12 Fractal Reservoir Root Signature"; tubeFieldRootSignature = CreateTubeFieldRootSignature(); tubeFieldRootSignature.Name = "Aquarium D3D12 TubeField Root Signature"; tubeFieldRenderRootSignature = CreateTubeFieldRenderRootSignature(); tubeFieldRenderRootSignature.Name = "Aquarium D3D12 TubeField Render Root Signature"; + bokushoBrushRootSignature = CreateBokushoBrushRootSignature(); + bokushoBrushRootSignature.Name = "Aquarium D3D12 Bokusho Brush Root Signature"; generatedMeshDrawCommandSignature = CreateGeneratedMeshDrawCommandSignature(); generatedMeshDrawCommandSignature.Name = "Aquarium D3D12 Generated Mesh Draw Command Signature"; CaptureShaderWriteTimes(); @@ -526,7 +652,7 @@ public sealed class D3D12Renderer : IAquariumRenderer public bool HasPresentedReadyFrame => hasPresentedReadyFrame; - public bool CapturesInput => debugUi.WantsKeyboard || debugUi.WantsMouse || clientUiPanels.Any(panel => panel.WantsKeyboard || panel.WantsMouse); + public bool CapturesInput => debugUi.WantsKeyboard || debugUi.WantsMouse || clientUiSurfaceWantsMouse || clientUiPanels.Any(panel => panel.WantsKeyboard || panel.WantsMouse); public AquariumSynthDocument DebugSynth => new AquariumSynthDocument { @@ -547,6 +673,7 @@ public sealed class D3D12Renderer : IAquariumRenderer { currentClientUi = clientUi; clientCommands = clientUi.Commands; + clientUiSurfaces = clientUi.Surfaces; var debugPanels = clientUi.Panels.Where(panel => !panel.FadeWhenMouseDistant).ToArray(); var floatingPanels = clientUi.Panels.Where(panel => panel.FadeWhenMouseDistant).ToArray(); debugTabTitles = ["Aquarium", "Terminal", "Synth", .. debugPanels.Select(panel => panel.Title)]; @@ -554,7 +681,9 @@ public sealed class D3D12Renderer : IAquariumRenderer var debugUiVisible = debugUi.IsVisible; debugUi = CreateDebugUi(debugPanels); debugUi.IsVisible = debugUiVisible; - clientUiPanels = floatingPanels.Select(DebugUi.FromContract).ToArray(); + clientUiPanels = floatingPanels + .Select(panel => DebugUi.FromContract(panel with { FadeWhenMouseDistant = false })) + .ToArray(); } debugUi.Update(input); @@ -562,8 +691,339 @@ public sealed class D3D12Renderer : IAquariumRenderer { panel.Update(input); } + + UpdateSurfaceInteractions(input); } + private void UpdateSurfaceInteractions(InputState input) + { + clientUiSurfaceWantsMouse = false; + if (activePreviewDrag is { } active) + { + clientUiSurfaceWantsMouse = true; + if (!input.LeftMouseDown) + { + active.Element.HandlePreviewInteraction?.Invoke(new AquariumUiPreviewInteraction( + active.ItemId, + active.Handle, + "end", + Math.Clamp((input.MousePosition.X - active.Canvas.Left) / Math.Max(1.0f, active.Canvas.Width), 0.0f, 1.0f), + Math.Clamp((input.MousePosition.Y - active.Canvas.Top) / Math.Max(1.0f, active.Canvas.Height), 0.0f, 1.0f), + 0.0f, + 0.0f)); + activePreviewDrag = null; + return; + } + + var x = Math.Clamp((input.MousePosition.X - active.Canvas.Left) / Math.Max(1.0f, active.Canvas.Width), 0.0f, 1.0f); + var y = Math.Clamp((input.MousePosition.Y - active.Canvas.Top) / Math.Max(1.0f, active.Canvas.Height), 0.0f, 1.0f); + active.Element.HandlePreviewInteraction?.Invoke(new AquariumUiPreviewInteraction( + active.ItemId, + active.Handle, + "drag", + x, + y, + input.MouseDelta.X / Math.Max(1.0f, active.Canvas.Width), + input.MouseDelta.Y / Math.Max(1.0f, active.Canvas.Height))); + return; + } + + if (TryHitPreview(input.MousePosition, out var hit)) + { + clientUiSurfaceWantsMouse = true; + hit.Element.HandlePreviewInteraction?.Invoke(new AquariumUiPreviewInteraction( + hit.ItemId, + hit.Handle, + "hover", + hit.X, + hit.Y, + 0.0f, + 0.0f)); + if (input.IsMousePressed(MouseButton.Left)) + { + activePreviewDrag = new AquariumUiPreviewDrag(hit.Element, hit.ItemId, hit.Handle, hit.Canvas); + hit.Element.HandlePreviewInteraction?.Invoke(new AquariumUiPreviewInteraction( + hit.ItemId, + hit.Handle, + "begin", + hit.X, + hit.Y, + 0.0f, + 0.0f)); + } + } + } + + private bool TryHitPreview(Vector2 point, out AquariumUiPreviewHit hit) + { + for (var surfaceIndex = clientUiSurfaces.Count - 1; surfaceIndex >= 0; surfaceIndex--) + { + var surface = clientUiSurfaces[surfaceIndex]; + var surfaceBounds = SurfaceBounds(surface); + var hasTitle = !string.IsNullOrWhiteSpace(surface.Title); + var contentTop = hasTitle ? surfaceBounds.Top + 42.0f : surfaceBounds.Top + surface.ContentPadding; + var content = RectFromEdges(surfaceBounds.Left + surface.ContentPadding, contentTop, surfaceBounds.Right - surface.ContentPadding, surfaceBounds.Bottom - surface.ContentPadding); + if (TryHitPreviewChildren(surface.Root.Children ?? [], content, surface.Root.Layout ?? AquariumUiLayout.Vertical(), point, out hit)) + { + return true; + } + } + + hit = default; + return false; + } + + private static bool TryHitPreviewChildren(IReadOnlyList elements, Rect bounds, AquariumUiLayout layout, Vector2 point, out AquariumUiPreviewHit hit) + { + var visible = elements.Where(static element => element.Visible).ToArray(); + if (visible.Length == 0) + { + hit = default; + return false; + } + + var content = RectFromEdges( + bounds.Left + layout.Padding, + bounds.Top + layout.Padding, + bounds.Right - layout.Padding, + bounds.Bottom - layout.Padding); + var totalGap = layout.Gap * Math.Max(0, visible.Length - 1); + var horizontal = string.Equals(layout.Direction, "horizontal", StringComparison.Ordinal); + var cursor = horizontal ? content.Left : content.Top; + var available = Math.Max(0.0f, (horizontal ? content.Width : content.Height) - totalGap); + var preferred = visible.Select(element => PreferredUiExtent(element, horizontal)).ToArray(); + var preferredTotal = preferred.Sum(static value => value ?? 0.0f); + var preferredScale = preferredTotal > available && preferredTotal > 0.0f ? available / preferredTotal : 1.0f; + var flexibleWeight = Math.Max(0.001f, visible.Where((_, index) => preferred[index] is null).Sum(static element => Math.Max(0.001f, element.Weight))); + var flexibleAvailable = Math.Max(0.0f, available - preferredTotal * preferredScale); + for (var index = 0; index < visible.Length; index++) + { + var element = visible[index]; + var extent = preferred[index] is { } fixedExtent + ? fixedExtent * preferredScale + : flexibleAvailable * Math.Max(0.001f, element.Weight) / flexibleWeight; + var childBounds = horizontal + ? RectFromEdges(cursor, content.Top, Math.Min(content.Right, cursor + extent), content.Bottom) + : RectFromEdges(content.Left, cursor, content.Right, Math.Min(content.Bottom, cursor + extent)); + cursor += extent + layout.Gap; + if (!ContainsRect(childBounds, point)) + { + continue; + } + + if (TryHitPreviewElement(element, childBounds, point, out hit)) + { + return true; + } + } + + hit = default; + return false; + } + + private static bool TryHitPreviewElement(AquariumUiElement element, Rect bounds, Vector2 point, out AquariumUiPreviewHit hit) + { + switch (element.Kind) + { + case "group": + return TryHitPreviewChildren(element.Children ?? [], bounds, element.Layout ?? AquariumUiLayout.Vertical(), point, out hit); + case "pane": + var paneContent = !string.IsNullOrWhiteSpace(element.Text) + ? RectFromEdges(bounds.Left + 6.0f, bounds.Top + 32.0f, bounds.Right - 6.0f, bounds.Bottom - 6.0f) + : bounds; + return TryHitPreviewChildren(element.Children ?? [], paneContent, element.Layout ?? AquariumUiLayout.Vertical(), point, out hit); + case "card": + return TryHitPreviewChildren(element.Children ?? [], bounds, element.Layout ?? AquariumUiLayout.Vertical(4.0f, 8.0f), point, out hit); + case "preview": + return TryHitPreviewElementCanvas(element, bounds, point, out hit); + default: + hit = default; + return false; + } + } + + private static bool TryHitPreviewElementCanvas(AquariumUiElement element, Rect bounds, Vector2 point, out AquariumUiPreviewHit hit) + { + hit = default; + if (element.HandlePreviewInteraction is null || !TryPreviewCanvas(bounds, !string.IsNullOrWhiteSpace(element.Text), out var canvas) || !ContainsRect(canvas, point)) + { + return false; + } + + var items = element.ReadPreviewItems?.Invoke() ?? []; + for (var index = items.Count - 1; index >= 0; index--) + { + var item = items[index]; + var itemBounds = RectFromEdges( + canvas.Left + Math.Clamp(item.X, 0.0f, 1.0f) * canvas.Width, + canvas.Top + Math.Clamp(item.Y, 0.0f, 1.0f) * canvas.Height, + canvas.Left + Math.Clamp(item.X + item.Width, 0.0f, 1.0f) * canvas.Width, + canvas.Top + Math.Clamp(item.Y + item.Height, 0.0f, 1.0f) * canvas.Height); + if (!ContainsRect(itemBounds, point)) + { + continue; + } + + var handle = HitPreviewHandle(itemBounds, point); + hit = new AquariumUiPreviewHit( + element, + item.Id, + handle, + canvas, + Math.Clamp((point.X - canvas.Left) / Math.Max(1.0f, canvas.Width), 0.0f, 1.0f), + Math.Clamp((point.Y - canvas.Top) / Math.Max(1.0f, canvas.Height), 0.0f, 1.0f)); + return true; + } + + hit = new AquariumUiPreviewHit( + element, + "", + "canvas", + canvas, + Math.Clamp((point.X - canvas.Left) / Math.Max(1.0f, canvas.Width), 0.0f, 1.0f), + Math.Clamp((point.Y - canvas.Top) / Math.Max(1.0f, canvas.Height), 0.0f, 1.0f)); + return true; + } + + private static bool TryPreviewCanvas(Rect bounds, bool hasLabel, out Rect canvas) + { + var padded = hasLabel + ? RectFromEdges(bounds.Left + 10.0f, bounds.Top + 30.0f, bounds.Right - 10.0f, bounds.Bottom - 10.0f) + : RectFromEdges(bounds.Left + 2.0f, bounds.Top + 2.0f, bounds.Right - 2.0f, bounds.Bottom - 2.0f); + if (padded.Width <= 1.0f || padded.Height <= 1.0f) + { + canvas = default; + return false; + } + + const float targetAspect = 16.0f / 9.0f; + var canvasWidth = padded.Width; + var canvasHeight = canvasWidth / targetAspect; + if (canvasHeight > padded.Height) + { + canvasHeight = padded.Height; + canvasWidth = canvasHeight * targetAspect; + } + + canvas = RectFromEdges( + padded.Left + (padded.Width - canvasWidth) * 0.5f, + padded.Top + (padded.Height - canvasHeight) * 0.5f, + padded.Left + (padded.Width + canvasWidth) * 0.5f, + padded.Top + (padded.Height + canvasHeight) * 0.5f); + return true; + } + + private Rect SurfaceBounds(AquariumUiSurface surface) + { + var edgePadding = surface.ContentPadding <= 0.0f ? 0.0f : 8.0f; + return RectFromEdges( + Math.Clamp(surface.Bounds.Left, edgePadding, Math.Max(edgePadding, width - 80.0f)), + Math.Clamp(surface.Bounds.Top, edgePadding, Math.Max(edgePadding, height - 48.0f)), + Math.Clamp(surface.Bounds.Left + surface.Bounds.Width, 80.0f + edgePadding, width - edgePadding), + Math.Clamp(surface.Bounds.Top + surface.Bounds.Height, 48.0f + edgePadding, height - edgePadding)); + } + + private static string HitPreviewHandle(Rect itemBounds, Vector2 point) + { + const float radius = 10.0f; + if (DistanceSquared(point, itemBounds.Left, itemBounds.Top) <= radius * radius) + { + return "nw"; + } + + if (DistanceSquared(point, itemBounds.Right, itemBounds.Top) <= radius * radius) + { + return "ne"; + } + + if (DistanceSquared(point, itemBounds.Left, itemBounds.Bottom) <= radius * radius) + { + return "sw"; + } + + if (DistanceSquared(point, itemBounds.Right, itemBounds.Bottom) <= radius * radius) + { + return "se"; + } + + return "move"; + } + + private static float? PreferredUiExtent(AquariumUiElement element, bool horizontal) + { + if (element.FixedExtent is { } fixedExtent) + { + return Math.Max(0.0f, fixedExtent); + } + + if (horizontal) + { + return null; + } + + var weight = Math.Max(0.001f, element.Weight); + return element.Kind switch + { + "group" or "pane" or "card" when HasFlexibleUiChild(element) => null, + "group" or "pane" or "card" => PreferredUiContainerExtent(element) * weight, + "text" when element.Role is "mono" => 18.0f * weight, + "text" when element.Role is "strong" or "title" => 22.0f * weight, + "text" => 18.0f * weight, + "toggle" or "select" or "slider" => 24.0f * weight, + "button" => 30.0f * weight, + "metric" => 36.0f * weight, + _ => null, + }; + } + + private static bool HasFlexibleUiChild(AquariumUiElement element) + { + var children = element.Children?.Where(static child => child.Visible).ToArray() ?? []; + return children.Any(static child => PreferredUiExtent(child, horizontal: false) is null); + } + + private static float PreferredUiContainerExtent(AquariumUiElement element) + { + var layout = element.Layout ?? AquariumUiLayout.Vertical(); + var children = element.Children?.Where(static child => child.Visible).ToArray() ?? []; + if (children.Length == 0) + { + return element.Kind == "pane" ? 72.0f : 24.0f; + } + + var horizontal = string.Equals(layout.Direction, "horizontal", StringComparison.Ordinal); + var childExtents = children.Select(static child => PreferredUiExtent(child, horizontal: false) ?? 48.0f * Math.Max(0.001f, child.Weight)).ToArray(); + var contentExtent = horizontal + ? childExtents.Max() + : childExtents.Sum() + layout.Gap * Math.Max(0, childExtents.Length - 1); + contentExtent += layout.Padding * 2.0f; + + return element.Kind switch + { + "pane" when !string.IsNullOrWhiteSpace(element.Text) => contentExtent + 38.0f, + "pane" => contentExtent, + "card" => contentExtent, + _ => contentExtent, + }; + } + + private static bool ContainsRect(Rect rect, Vector2 point) => + point.X >= rect.Left && point.X <= rect.Right && point.Y >= rect.Top && point.Y <= rect.Bottom; + + private static float DistanceSquared(Vector2 point, float x, float y) + { + var dx = point.X - x; + var dy = point.Y - y; + return dx * dx + dy * dy; + } + + private static Rect RectFromEdges(float left, float top, float right, float bottom) => + new(left, top, Math.Max(0.0f, right - left), Math.Max(0.0f, bottom - top)); + + private readonly record struct AquariumUiPreviewHit(AquariumUiElement Element, string ItemId, string Handle, Rect Canvas, float X, float Y); + + private readonly record struct AquariumUiPreviewDrag(AquariumUiElement Element, string ItemId, string Handle, Rect Canvas); + public void CycleRenderDebugMode() { RenderDebugMode = (RenderDebugMode + 1) % (GraphicsSettings.MaxRenderDebugMode + 1); @@ -767,7 +1227,6 @@ public sealed class D3D12Renderer : IAquariumRenderer { var resource = device.CreateCommittedResource( HeapType.Default, - HeapFlags.Shared, ResourceDescription.Texture2D( format, (uint)width, @@ -779,16 +1238,19 @@ public sealed class D3D12Renderer : IAquariumRenderer resourceFlags), ResourceStates.PixelShaderResource, null); - resource.Name = $"Aquarium D3D12 Field Texture2D Lease {request.ResourceKey}"; - var nativeHandle = device.CreateSharedHandle(resource, null, null!); + var texture = D3D12FieldTexture2D.WrapOwned( + resource, + width, + height, + format, + resourceFlags.HasFlag(Vortice.Direct3D12.ResourceFlags.AllowUnorderedAccess), + ResourceStates.PixelShaderResource, + $"Aquarium D3D12 Field Texture2D Lease {request.ResourceKey}"); var producerFence = device.CreateFence(0); producerFence.Name = $"Aquarium D3D12 Field Texture2D Producer Fence {request.ResourceKey}"; - var producerFenceHandle = device.CreateSharedHandle(producerFence, null, null!); var slot = new SharedTextureLeaseSlot( - resource, + texture, producerFence, - nativeHandle, - producerFenceHandle, width, height, format, @@ -797,20 +1259,30 @@ public sealed class D3D12Renderer : IAquariumRenderer sharedTextureLeases[request.ResourceKey] = slot; return CreateLease(request, slot); } - catch (SharpGenException) + catch (SharpGenException ex) { + LogTextureLeaseFailure(request, ex); return AquariumFieldResourceLease.Invalid; } - catch (InvalidOperationException) + catch (InvalidOperationException ex) { + LogTextureLeaseFailure(request, ex); return AquariumFieldResourceLease.Invalid; } } + private static void LogTextureLeaseFailure(AquariumTexture2DLeaseRequest request, Exception exception) + { + if (Environment.GetEnvironmentVariable("AQUARIUM_TEXTURE_LEASE_DIAG") == "1") + { + Console.WriteLine( + $"aquarium-texture-lease-failed resource={request.ResourceKey} {request.Width}x{request.Height} format={request.Format} access={request.ProducerAccess} error={exception.GetType().Name}: {exception.Message}"); + } + } + public bool CommitLeaseVersion(string resourceKey, ulong version, ulong producerFenceValue) { if (string.IsNullOrWhiteSpace(resourceKey) || - producerFenceValue == 0 || !sharedTextureLeases.TryGetValue(resourceKey, out var slot)) { return false; @@ -819,7 +1291,8 @@ public sealed class D3D12Renderer : IAquariumRenderer sharedTextureLeases[resourceKey] = slot with { Version = version, - CommittedProducerFenceValue = Math.Max(slot.CommittedProducerFenceValue, producerFenceValue), + CommittedProducerFenceValue = 0, + WaitedProducerFenceValue = 0, }; return true; } @@ -852,6 +1325,7 @@ public sealed class D3D12Renderer : IAquariumRenderer TryHotReloadShaders(); var frameResources = frames[frameIndex]; WaitForFrame(frameResources); + AccumulateCompletedGpuTimings(frameResources); frameResources.UploadRing.Reset(); frameResources.TransientShaderDescriptors.Reset(); @@ -879,9 +1353,12 @@ public sealed class D3D12Renderer : IAquariumRenderer CopySceneState(frame.Scene); activeCameraPosition = frame.CameraPosition; activeCameraTarget = frame.CameraTarget; + activeViewCenter = frame.View.Center; + activeViewRadius = frame.View.Radius; EnsureFractalReservoirBuffers(activeFractalReservoirField); EnsureFractalProgramTransformBuffer(); EnsureBufferFieldProgramBuffers(); + EnsurePlanetarySurfacePageBuffers(); var activeGpuSensorInput = frame.Scene.GpuSensorFrame.HasInput; var activeAccumulationWindow = activeGpuSensorInput ? frame.Scene.GpuSensorFrame.AccumulationWindowSeconds @@ -994,6 +1471,8 @@ public sealed class D3D12Renderer : IAquariumRenderer sceneCandidateControlRenderTarget.CreateShaderResourceView(device, frameResources.SceneCandidateControlDescriptor); frameResources.SceneCandidateReservoirGuideDescriptor = frameResources.TransientShaderDescriptors.Allocate(); sceneCandidateReservoirGuideRenderTarget.CreateShaderResourceView(device, frameResources.SceneCandidateReservoirGuideDescriptor); + frameResources.SceneOverdrawDescriptor = frameResources.TransientShaderDescriptors.Allocate(); + sceneOverdrawRenderTarget.CreateShaderResourceView(device, frameResources.SceneOverdrawDescriptor); frameResources.ReservoirResolvedDescriptor = frameResources.TransientShaderDescriptors.Allocate(); reservoirResolvedRenderTarget.CreateShaderResourceView(device, frameResources.ReservoirResolvedDescriptor); frameResources.ReservoirResolvedUnorderedAccessDescriptor = frameResources.TransientShaderDescriptors.Allocate(); @@ -1023,13 +1502,27 @@ public sealed class D3D12Renderer : IAquariumRenderer var recordCpuStart = Stopwatch.GetTimestamp(); commandList.BeginEvent("Aquarium D3D12 Frame"); + BeginGpuTiming(commandList, frameResources, D3D12GpuTimingPass.FrameRecord); commandList.SetDescriptorHeaps(frameResources.TransientShaderDescriptors.Heap); - UploadSceneStructuredResources(commandList, frameResources); - DispatchGpuSensorFusion(commandList, frameResources); - DispatchFractalReservoirs(commandList, frameResources); - DispatchTubeFields(commandList, frameResources); - RenderHeightField(commandList, frameResources); - RenderSceneAndPresent(new D3D12PassContext(commandList, frameResources.BackBuffer, frameResources.BackBufferRenderTargetView.Cpu), frameResources); + var passContext = new D3D12PassContext(commandList, frameResources.BackBuffer, frameResources.BackBufferRenderTargetView.Cpu); + if (hasSceneRenderingPass) + { + UploadSceneStructuredResources(commandList, frameResources); + DispatchGpuSensorFusion(commandList, frameResources); + DispatchStereoDepth(commandList, frameResources); + DispatchPointClouds(commandList, frameResources); + DispatchFractalReservoirs(commandList, frameResources); + DispatchPlanetarySurfacePage(commandList, frameResources); + DispatchTubeFields(commandList, frameResources); + RenderHeightField(commandList, frameResources); + RenderSceneAndPresent(passContext, frameResources); + } + else + { + ClearEmptyBackBuffer(passContext); + } + EndGpuTiming(commandList, frameResources, D3D12GpuTimingPass.FrameRecord); + ResolveGpuTimings(commandList, frameResources, temporalFrameIndex > 4); commandList.EndEvent(); commandList.Close(); var recordCpuMilliseconds = ElapsedMilliseconds(recordCpuStart); @@ -1131,6 +1624,51 @@ public sealed class D3D12Renderer : IAquariumRenderer PngImageWriter.WriteRgba(path, width, height, rgba); } + public void SaveBokushoBrushCanvasRaw(string path) + => SaveStructuredBufferRaw(bokushoBrushCanvasBuffer, path, "Aquarium D3D12 Bokusho Brush Canvas Readback"); + + public void SaveBokushoPageDensityRaw(string path) + => SaveStructuredBufferRaw(bokushoPageDensityBuffer, path, "Aquarium D3D12 Bokusho Page Density Readback"); + + private unsafe void SaveStructuredBufferRaw(D3D12StructuredBuffer buffer, string path, string readbackName) + { + if (!hasPresentedReadyFrame) + { + throw new InvalidOperationException("No completed frame is available to save."); + } + + WaitForGpu(); + + var bufferBytes = buffer.SizeBytes; + using var readback = device.CreateCommittedResource( + HeapType.Readback, + ResourceDescription.Buffer((ulong)bufferBytes), + ResourceStates.CopyDest, + null); + readback.Name = readbackName; + + using var captureAllocator = device.CreateCommandAllocator(CommandListType.Direct); + using var captureList = device.CreateCommandList(0, CommandListType.Direct, captureAllocator, null); + buffer.Transition(captureList, ResourceStates.CopySource); + captureList.CopyBufferRegion(readback, 0, buffer.Resource, 0, (ulong)bufferBytes); + buffer.Transition(captureList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + captureList.Close(); + commandQueue.ExecuteCommandList(captureList); + WaitForGpu(); + + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path)) ?? "."); + var mapped = readback.Map(0); + try + { + using var stream = File.Create(path); + stream.Write(new ReadOnlySpan(mapped, bufferBytes)); + } + finally + { + readback.Unmap(0, null); + } + } + private static Vector2 TemporalJitterPixels(int index) { const float scale = 0.42f; @@ -1160,14 +1698,23 @@ public sealed class D3D12Renderer : IAquariumRenderer && splinePipelineState is not null && temporalGaussianPipelineState is not null && gpuSensorFusionPipelineState is not null + && stereoDepthPipelineState is not null + && pointCloudComputePipelineState is not null + && pointCloudRenderPipelineState is not null && fractalSurfaceSplatRenderPipelineState is not null && fractalTransparentSplatRenderPipelineState is not null && fractalSplatPipelineState is not null && fractalSdfReservoirPipelineState is not null && fractalPbrReservoirPipelineState is not null && fractalRadiosityReservoirPipelineState is not null + && planetarySurfacePagePipelineState is not null + && planetarySurfacePageSummaryPipelineState is not null + && planetarySurfacePatchPipelineState is not null && tubeFieldComputePipelineState is not null && tubeFieldRenderPipelineState is not null + && bokushoBrushPipelineState is not null + && bokushoPageClearPipelineState is not null + && bokushoPageDepositPipelineState is not null && fieldReservoirResolvePipelineState is not null && sdfProxyPipelineStates.All(pipeline => pipeline is not null) && bloomPrefilterPipelineState is not null @@ -1206,10 +1753,13 @@ public sealed class D3D12Renderer : IAquariumRenderer overlayDevice.Dispose(); DisposePipelineStates(); fullscreenRootSignature.Dispose(); + planetarySurfacePageRootSignature.Dispose(); gpuSensorFusionRootSignature.Dispose(); + pointCloudRootSignature.Dispose(); fractalReservoirRootSignature.Dispose(); tubeFieldRootSignature.Dispose(); tubeFieldRenderRootSignature.Dispose(); + bokushoBrushRootSignature.Dispose(); generatedMeshDrawCommandSignature.Dispose(); blueNoiseTexture.Dispose(); tubeFieldFallbackRampTexture?.Dispose(); @@ -1224,6 +1774,17 @@ public sealed class D3D12Renderer : IAquariumRenderer tubeFieldStatsBuffer.Dispose(); tubeFieldDrawArgumentBuffer.Dispose(); tubeFieldReplayManifestBuffer.Dispose(); + bokushoBrushCanvasBuffer.Dispose(); + bokushoBrushTipBuffer.Dispose(); + bokushoBrushTraceBuffer.Dispose(); + bokushoBrushStrokeBuffer.Dispose(); + bokushoSourcePointBuffer.Dispose(); + bokushoPageDensityBuffer.Dispose(); + planetarySurfacePageMetadataBuffer?.Dispose(); + planetarySurfacePageSetBuffer?.Dispose(); + planetarySurfacePageSummaryBuffer?.Dispose(); + planetarySurfacePageOutputBuffer?.Dispose(); + planetarySurfacePageInputBuffer?.Dispose(); tubeFieldSegmentBuffer.Dispose(); tubeFieldIndexBuffer.Dispose(); tubeFieldVertexBuffer.Dispose(); @@ -1255,6 +1816,7 @@ public sealed class D3D12Renderer : IAquariumRenderer sceneCandidateControlRenderTarget.Dispose(); sceneCandidateMetadataRenderTarget.Dispose(); sceneCandidateRenderTarget.Dispose(); + sceneOverdrawRenderTarget.Dispose(); reservoirResolvedRenderTarget.Dispose(); sceneDepthTarget.Dispose(); heightFieldRenderTarget.Dispose(); @@ -1308,7 +1870,7 @@ public sealed class D3D12Renderer : IAquariumRenderer declaration, slot.NativeHandle, "fensalir-owned-d3d12-texture2d", - slot.ProducerFenceHandle, + IntPtr.Zero, request.Version, true); } @@ -1367,14 +1929,14 @@ public sealed class D3D12Renderer : IAquariumRenderer Offset = 0, Footprint = new SubresourceFootPrint(slot.Format, (uint)upload.Width, (uint)upload.Height, 1, (uint)rowPitch), }); - var destination = new TextureCopyLocation(slot.Resource, 0); + var destination = new TextureCopyLocation(slot.Texture.Resource, 0); uploadList.ResourceBarrier(ResourceBarrier.BarrierTransition( - slot.Resource, + slot.Texture.Resource, ResourceStates.PixelShaderResource, ResourceStates.CopyDest)); uploadList.CopyTextureRegion(destination, 0, 0, 0, source, null); uploadList.ResourceBarrier(ResourceBarrier.BarrierTransition( - slot.Resource, + slot.Texture.Resource, ResourceStates.CopyDest, ResourceStates.PixelShaderResource)); uploadList.Close(); @@ -1802,7 +2364,7 @@ public sealed class D3D12Renderer : IAquariumRenderer { var wrappedBackBuffer = overlayWrappedBackBuffers[frameIndex]; overlayOn12Device.AcquireWrappedResources([wrappedBackBuffer]); - overlays[frameIndex].Render(frame, RenderDebugMode, debugUi, clientUiPanels, visibleFrameRateText); + overlays[frameIndex].Render(frame, RenderDebugMode, debugUi, clientUiPanels, clientUiSurfaces, visibleFrameRateText); overlayOn12Device.ReleaseWrappedResources([wrappedBackBuffer]); overlayContext.Flush(); frameResources.BackBuffer.MarkState(ResourceStates.Present); @@ -1822,57 +2384,124 @@ public sealed class D3D12Renderer : IAquariumRenderer overlays = []; overlayWrappedBackBuffers = []; + overlayContext.ClearState(); + overlayContext.Flush(); } private D3D12PipelineSet CreatePipelineSet(D3D12ShaderPaths paths) { + static void Step(string name) + { + if (Environment.GetEnvironmentVariable("AQUARIUM_PIPELINE_DIAG") == "1") + { + Console.WriteLine($"D3D12 pipeline build step: {name}"); + } + } + + Step("height-field-base"); var heightFieldBase = CreateHeightFieldBasePipelineState(paths.HeightField); heightFieldBase.Name = "Aquarium D3D12 Height Field Base Pipeline"; + Step("height-field-brush"); var heightFieldBrush = CreateHeightFieldBrushPipelineState(paths.HeightField); heightFieldBrush.Name = "Aquarium D3D12 Height Field Brush Pipeline"; + Step("scene"); var scene = CreateScenePipelineState(paths.Scene); scene.Name = "Aquarium D3D12 Scene Pipeline"; + Step("spline"); var spline = CreateSplinePipelineState(paths.Spline); spline.Name = "Aquarium D3D12 Spline Surface Claim Pipeline"; + Step("temporal-gaussian"); var temporalGaussian = CreateTemporalGaussianPipelineState(paths.TemporalGaussian); temporalGaussian.Name = "Aquarium D3D12 Temporal Gaussian Pipeline"; + Step("gpu-sensor-fusion"); var gpuSensorFusion = CreateGpuSensorFusionPipelineState(paths.GpuSensorFusion); gpuSensorFusion.Name = "Aquarium D3D12 GPU Sensor Fusion Compute Pipeline"; + Step("stereo-depth"); + var stereoDepth = CreateStereoDepthPipelineState(paths.StereoDepth); + stereoDepth.Name = "Aquarium D3D12 Packed Stereo Depth Compute Pipeline"; + Step("point-cloud-compute"); + var pointCloudCompute = CreatePointCloudComputePipelineState(paths.PointCloud); + pointCloudCompute.Name = "Aquarium D3D12 Point Cloud Projection Compute Pipeline"; + Step("point-cloud-render"); + var pointCloudRender = CreatePointCloudRenderPipelineState(paths.PointCloud); + pointCloudRender.Name = "Aquarium D3D12 Point Cloud Render Pipeline"; + Step("fractal-surface-splat"); var fractalSurfaceSplatRender = CreateFractalSurfaceSplatRenderPipelineState(paths.FractalSplatRender); fractalSurfaceSplatRender.Name = "Aquarium D3D12 Fractal Surface Splat Render Pipeline"; + Step("fractal-transparent-splat"); var fractalTransparentSplatRender = CreateFractalTransparentSplatRenderPipelineState(paths.FractalSplatRender); fractalTransparentSplatRender.Name = "Aquarium D3D12 Fractal Transparent Splat Render Pipeline"; + Step("fractal-splat"); var fractalSplat = CreateFractalReservoirPipelineState(paths.FractalReservoir, "D3D12FractalSplatReceiptCS"); fractalSplat.Name = "Aquarium D3D12 Fractal Splat Compute Pipeline"; + Step("fractal-sdf-reservoir"); var fractalSdfReservoir = CreateFractalReservoirPipelineState(paths.FractalReservoir, "D3D12SdfEnvelopeReservoirCS"); fractalSdfReservoir.Name = "Aquarium D3D12 Fractal SDF Reservoir Compute Pipeline"; + Step("fractal-pbr-reservoir"); var fractalPbrReservoir = CreateFractalReservoirPipelineState(paths.FractalReservoir, "D3D12PbrMaterialReservoirCS"); fractalPbrReservoir.Name = "Aquarium D3D12 Fractal PBR Reservoir Compute Pipeline"; + Step("fractal-radiosity-reservoir"); var fractalRadiosityReservoir = CreateFractalReservoirPipelineState(paths.FractalReservoir, "D3D12RadiosityReservoirCS"); fractalRadiosityReservoir.Name = "Aquarium D3D12 Fractal Radiosity Reservoir Compute Pipeline"; + Step("planetary-surface-page"); + var planetarySurfacePage = CreatePlanetarySurfacePagePipelineState(paths.PlanetarySurfacePage); + planetarySurfacePage.Name = "Aquarium D3D12 Planetary Surface Page Compute Pipeline"; + Step("planetary-surface-page-summary"); + var planetarySurfacePageSummary = CreatePlanetarySurfacePageSummaryPipelineState(paths.PlanetarySurfacePageSummary); + planetarySurfacePageSummary.Name = "Aquarium D3D12 Planetary Surface Page Summary Compute Pipeline"; + Step("planetary-surface-patch"); + var planetarySurfacePatch = CreatePlanetarySurfacePatchPipelineState(paths.PlanetarySurfacePatch); + planetarySurfacePatch.Name = "Aquarium D3D12 Planetary Surface Patch Pipeline"; + Step("tube-field-compute"); var tubeFieldCompute = CreateTubeFieldComputePipelineState(paths.TubeField); tubeFieldCompute.Name = "Aquarium D3D12 TubeField Compute Pipeline"; + Step("tube-field-render"); var tubeFieldRender = CreateTubeFieldRenderPipelineState(paths.TubeField); tubeFieldRender.Name = "Aquarium D3D12 TubeField Render Pipeline"; + Step("bokusho-brush"); + var bokushoBrush = CreateBokushoBrushPipelineState(paths.BokushoBrush); + bokushoBrush.Name = "Aquarium D3D12 Bokusho Brush Compute Pipeline"; + Step("bokusho-page-clear"); + var bokushoPageClear = CreateBokushoBrushPipelineState(paths.BokushoBrush, "D3D12BokushoPageClearCS"); + bokushoPageClear.Name = "Aquarium D3D12 Bokusho Page Clear Compute Pipeline"; + Step("bokusho-page-deposit"); + var bokushoPageDeposit = CreateBokushoBrushPipelineState(paths.BokushoBrush, "D3D12BokushoPageDepositCS"); + bokushoPageDeposit.Name = "Aquarium D3D12 Bokusho Page Deposit Compute Pipeline"; + Step("field-reservoir-resolve"); var fieldReservoirResolve = CreateFieldReservoirResolvePipelineState(paths.Post); fieldReservoirResolve.Name = "Aquarium D3D12 Field Reservoir Resolve Pipeline"; var sdfProxies = new ID3D12PipelineState[paths.SdfShaders.Count]; for (var index = 0; index < paths.SdfShaders.Count; index++) { + Step($"sdf-proxy-{index}"); sdfProxies[index] = CreateSdfObjectProxyPipelineState(paths.SdfShaders[index]); sdfProxies[index].Name = $"Aquarium D3D12 Sdf Proxy Pipeline {index}"; } + Step("bloom-prefilter"); var bloomPrefilter = CreateBloomPrefilterPipelineState(paths.Post); bloomPrefilter.Name = "Aquarium D3D12 Bloom Prefilter Pipeline"; + Step("bloom-downsample"); var bloomDownsample = CreateBloomDownsamplePipelineState(paths.Post); bloomDownsample.Name = "Aquarium D3D12 Bloom Downsample Pipeline"; + Step("bloom-blur-horizontal"); var bloomBlurHorizontal = CreateBloomBlurHorizontalPipelineState(paths.Post); bloomBlurHorizontal.Name = "Aquarium D3D12 Bloom Blur Horizontal Pipeline"; + Step("bloom-blur-vertical"); var bloomBlurVertical = CreateBloomBlurVerticalPipelineState(paths.Post); bloomBlurVertical.Name = "Aquarium D3D12 Bloom Blur Vertical Pipeline"; - var reservoirHistoryUpdate = CreateReservoirHistoryUpdatePipelineState(paths.Post); - reservoirHistoryUpdate.Name = "Aquarium D3D12 Reservoir History Update Pipeline"; + ID3D12PipelineState? reservoirHistoryUpdate = null; + if (Environment.GetEnvironmentVariable("AQUARIUM_DISABLE_RESERVOIR_HISTORY") == "1") + { + Step("reservoir-history-update disabled"); + } + else + { + Step("reservoir-history-update"); + reservoirHistoryUpdate = CreateReservoirHistoryUpdatePipelineState(paths.ReservoirHistoryUpdate); + reservoirHistoryUpdate.Name = "Aquarium D3D12 Reservoir History Update Pipeline"; + } + Step("resolve"); var resolve = CreateResolvePipelineState(paths.Post); resolve.Name = "Aquarium D3D12 Resolve Pipeline"; @@ -1883,14 +2512,23 @@ public sealed class D3D12Renderer : IAquariumRenderer spline, temporalGaussian, gpuSensorFusion, + stereoDepth, + pointCloudCompute, + pointCloudRender, fractalSurfaceSplatRender, fractalTransparentSplatRender, fractalSplat, fractalSdfReservoir, fractalPbrReservoir, fractalRadiosityReservoir, + planetarySurfacePage, + planetarySurfacePageSummary, + planetarySurfacePatch, tubeFieldCompute, tubeFieldRender, + bokushoBrush, + bokushoPageClear, + bokushoPageDeposit, fieldReservoirResolve, sdfProxies, bloomPrefilter, @@ -1912,6 +2550,7 @@ public sealed class D3D12Renderer : IAquariumRenderer WaitForGpu(); DisposeBackBufferOverlays(); + DisposeProgramOutputTexture(); RemoveBloomRenderTargets(); RemoveGraphRenderTargets(); resourceRegistry.RemoveRenderTarget("scene-hdr-target"); @@ -1922,6 +2561,7 @@ public sealed class D3D12Renderer : IAquariumRenderer resourceRegistry.RemoveRenderTarget("scene-candidate-metadata-target"); resourceRegistry.RemoveRenderTarget("scene-candidate-control-target"); resourceRegistry.RemoveRenderTarget("scene-candidate-reservoir-guide-target"); + resourceRegistry.RemoveRenderTarget("scene-overdraw-target"); resourceRegistry.RemoveRenderTarget("reservoir-resolved-target"); resourceRegistry.RemoveResource("scene-depth-target"); resourceRegistry.RemoveRenderTarget("height-field-target"); @@ -1935,6 +2575,7 @@ public sealed class D3D12Renderer : IAquariumRenderer sceneCandidateMetadataRenderTarget.Dispose(); sceneCandidateControlRenderTarget.Dispose(); sceneCandidateReservoirGuideRenderTarget.Dispose(); + sceneOverdrawRenderTarget.Dispose(); reservoirResolvedRenderTarget.Dispose(); fieldReservoirCandidateBuffer.Dispose(); fieldReservoirLockBuffer.Dispose(); @@ -1971,6 +2612,7 @@ public sealed class D3D12Renderer : IAquariumRenderer sceneCandidateMetadataRenderTarget = CreateSceneAuxiliaryRenderTarget("scene-candidate-metadata-target", "Aquarium D3D12 Scene Candidate Metadata Target"); sceneCandidateControlRenderTarget = CreateSceneAuxiliaryRenderTarget("scene-candidate-control-target", "Aquarium D3D12 Scene Candidate Control Target"); sceneCandidateReservoirGuideRenderTarget = CreateSceneAuxiliaryRenderTarget("scene-candidate-reservoir-guide-target", "Aquarium D3D12 Scene Candidate Reservoir Guide Target"); + sceneOverdrawRenderTarget = CreateSceneOverdrawRenderTarget(); reservoirResolvedRenderTarget = CreateReservoirResolvedRenderTarget(); CreateFieldReservoirBuffers(); sceneDepthStencilView = depthStencilViewArena.Allocate(); @@ -2010,6 +2652,7 @@ public sealed class D3D12Renderer : IAquariumRenderer resourceRegistry.RemoveRenderTarget("scene-candidate-metadata-target"); resourceRegistry.RemoveRenderTarget("scene-candidate-control-target"); resourceRegistry.RemoveRenderTarget("scene-candidate-reservoir-guide-target"); + resourceRegistry.RemoveRenderTarget("scene-overdraw-target"); resourceRegistry.RemoveRenderTarget("reservoir-resolved-target"); resourceRegistry.RemoveResource("scene-depth-target"); DisposeBloomRenderTargets(); @@ -2021,6 +2664,7 @@ public sealed class D3D12Renderer : IAquariumRenderer sceneCandidateMetadataRenderTarget.Dispose(); sceneCandidateControlRenderTarget.Dispose(); sceneCandidateReservoirGuideRenderTarget.Dispose(); + sceneOverdrawRenderTarget.Dispose(); reservoirResolvedRenderTarget.Dispose(); fieldReservoirCandidateBuffer.Dispose(); fieldReservoirLockBuffer.Dispose(); @@ -2039,6 +2683,7 @@ public sealed class D3D12Renderer : IAquariumRenderer sceneCandidateMetadataRenderTarget = CreateSceneAuxiliaryRenderTarget("scene-candidate-metadata-target", "Aquarium D3D12 Scene Candidate Metadata Target"); sceneCandidateControlRenderTarget = CreateSceneAuxiliaryRenderTarget("scene-candidate-control-target", "Aquarium D3D12 Scene Candidate Control Target"); sceneCandidateReservoirGuideRenderTarget = CreateSceneAuxiliaryRenderTarget("scene-candidate-reservoir-guide-target", "Aquarium D3D12 Scene Candidate Reservoir Guide Target"); + sceneOverdrawRenderTarget = CreateSceneOverdrawRenderTarget(); reservoirResolvedRenderTarget = CreateReservoirResolvedRenderTarget(); CreateFieldReservoirBuffers(); sceneDepthTarget = CreateSceneDepthTarget(sceneDepthStencilView); @@ -2087,6 +2732,23 @@ public sealed class D3D12Renderer : IAquariumRenderer return target; } + private D3D12RenderTarget CreateSceneOverdrawRenderTarget() + { + var target = new D3D12RenderTarget( + device, + reservoirWidth, + reservoirHeight, + SceneOverdrawFormat, + renderTargetViewArena.Allocate(), + staticShaderDescriptorArena.Allocate(), + null, + false, + new Color4(0.0f, 0.0f, 0.0f, 1.0f), + "Aquarium D3D12 Scene Overdraw Target"); + resourceRegistry.Add("scene-overdraw-target", target); + return target; + } + private D3D12RenderTarget CreateReservoirResolvedRenderTarget() { var target = new D3D12RenderTarget( @@ -2471,6 +3133,7 @@ public sealed class D3D12Renderer : IAquariumRenderer private void RenderSceneAndPresent(D3D12PassContext context, FrameResources frameResources) { context.CommandList.BeginEvent("Scene Candidate Pass"); + BeginGpuTiming(context.CommandList, frameResources, D3D12GpuTimingPass.SceneCandidate); try { heightFieldRenderTarget.Transition(context.CommandList, ResourceStates.PixelShaderResource); @@ -2478,11 +3141,13 @@ public sealed class D3D12Renderer : IAquariumRenderer sceneCandidateMetadataRenderTarget.Transition(context.CommandList, ResourceStates.RenderTarget); sceneCandidateControlRenderTarget.Transition(context.CommandList, ResourceStates.RenderTarget); sceneCandidateReservoirGuideRenderTarget.Transition(context.CommandList, ResourceStates.RenderTarget); + sceneOverdrawRenderTarget.Transition(context.CommandList, ResourceStates.RenderTarget); sceneDepthTarget.Transition(context.CommandList, ResourceStates.DepthWrite); context.CommandList.ClearRenderTargetView(sceneCandidateRenderTarget.RenderTargetView.Cpu, new Color4(0.0f, 0.0f, 0.0f, 1.0f)); context.CommandList.ClearRenderTargetView(sceneCandidateMetadataRenderTarget.RenderTargetView.Cpu, new Color4(0.0f, 0.0f, 0.0f, 1.0f)); context.CommandList.ClearRenderTargetView(sceneCandidateControlRenderTarget.RenderTargetView.Cpu, new Color4(0.0f, 0.0f, 0.0f, 1.0f)); context.CommandList.ClearRenderTargetView(sceneCandidateReservoirGuideRenderTarget.RenderTargetView.Cpu, new Color4(1.0f, 0.0f, 1.0f, 0.0f)); + context.CommandList.ClearRenderTargetView(sceneOverdrawRenderTarget.RenderTargetView.Cpu, new Color4(0.0f, 0.0f, 0.0f, 1.0f)); context.CommandList.ClearDepthStencilView(sceneDepthStencilView.Cpu, ClearFlags.Depth, 1.0f, 0); context.CommandList.SetDescriptorHeaps(frameResources.TransientShaderDescriptors.Heap); ClearFieldReservoir(context.CommandList, frameResources); @@ -2505,13 +3170,33 @@ public sealed class D3D12Renderer : IAquariumRenderer sceneCandidateMetadataRenderTarget.RenderTargetView.Cpu, sceneCandidateControlRenderTarget.RenderTargetView.Cpu, sceneCandidateReservoirGuideRenderTarget.RenderTargetView.Cpu, + sceneOverdrawRenderTarget.RenderTargetView.Cpu, ], sceneDepthStencilView.Cpu); context.CommandList.IASetPrimitiveTopology(PrimitiveTopology.TriangleList); context.CommandList.DrawInstanced(3, 1, 0, 0); + if (activePlanetarySurfacePages.RenderCoarsePatches && activePlanetarySurfacePages.Pages.Count >= 6 && residentPlanetarySurfacePageContentVersion == activePlanetarySurfacePages.ContentVersion) + { + BeginGpuTiming(context.CommandList, frameResources, D3D12GpuTimingPass.PlanetarySurfacePatches); + context.CommandList.ClearDepthStencilView(sceneDepthStencilView.Cpu,ClearFlags.Depth,1.0f,0); + planetarySurfacePageOutputBuffer!.Transition(context.CommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + planetarySurfacePageMetadataBuffer!.Transition(context.CommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + planetarySurfacePageSummaryBuffer!.Transition(context.CommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + planetarySurfacePageSetBuffer!.Transition(context.CommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + context.CommandList.SetGraphicsRootShaderResourceView(RootPlanetarySurfacePage, planetarySurfacePageOutputBuffer.Resource.GPUVirtualAddress); + context.CommandList.SetGraphicsRootShaderResourceView(RootPlanetarySurfacePageMetadata, planetarySurfacePageMetadataBuffer.Resource.GPUVirtualAddress); + context.CommandList.SetGraphicsRootShaderResourceView(RootPlanetarySurfacePageSummary, planetarySurfacePageSummaryBuffer.Resource.GPUVirtualAddress); + context.CommandList.SetGraphicsRootShaderResourceView(RootPlanetarySurfacePageSet, planetarySurfacePageSetBuffer.Resource.GPUVirtualAddress); + context.CommandList.SetPipelineState(planetarySurfacePatchPipelineState!); + var patchCells = (uint)Math.Clamp(activePlanetarySurfacePages.PatchCells, 1, 256); + context.CommandList.DrawInstanced(patchCells * patchCells * 6u, 6, 0, 0); + EndGpuTiming(context.CommandList, frameResources, D3D12GpuTimingPass.PlanetarySurfacePatches); + } + for (var sdfIndex = 0; sdfIndex < sdfProxyPipelineStates.Length; sdfIndex++) { + if (sdfObjects[sdfIndex].CenterRadius.W <= 0.0f) continue; context.CommandList.SetPipelineState(sdfProxyPipelineStates[sdfIndex]!); context.CommandList.DrawInstanced(6, 1, 0, 0); } @@ -2538,18 +3223,33 @@ public sealed class D3D12Renderer : IAquariumRenderer context.CommandList.DrawInstanced(6, (uint)visibleFractalSplatCount, 0, 0); } + RenderPointCloudMeshes(context.CommandList, frameResources); RenderTubeFields(context.CommandList, frameResources); RenderSplineSurfaceClaims(context.CommandList, frameResources); } finally { + EndGpuTiming(context.CommandList, frameResources, D3D12GpuTimingPass.SceneCandidate); context.CommandList.EndEvent(); } ResolveFieldReservoir(context.CommandList, frameResources); - UpdateReservoirHistory(context.CommandList, frameResources); - RenderBloom(context.CommandList, frameResources, reservoirResolvedRenderTarget, frameResources.ReservoirResolvedDescriptor); - PresentBackBuffer(context, frameResources); + var historyUpdated = ShouldUseReservoirHistoryPresentation() + && UpdateReservoirHistory(context.CommandList, frameResources); + var presentationSourceTarget = historyUpdated ? reservoirResolvedRenderTarget : sceneRenderTarget; + var presentationSourceDescriptor = historyUpdated ? frameResources.ReservoirResolvedDescriptor : frameResources.SceneDescriptor; + RenderBloom(context.CommandList, frameResources, presentationSourceTarget, presentationSourceDescriptor); + PresentBackBuffer(context, frameResources, presentationSourceTarget, presentationSourceDescriptor); + } + + private bool ShouldUseReservoirHistoryPresentation() + { + return activeFractalReservoirField.HasInput || + activeBufferFieldFrame.UseReservoirLowering || + activeTubeFieldDrawIndexCount > 0 || + temporalGaussianCount > 0 || + gpuFusionSeedCount > 0 || + gpuFusionPointCount > 0; } private void RenderSplineSurfaceClaims(ID3D12GraphicsCommandList activeCommandList, FrameResources frameResources) @@ -2615,6 +3315,7 @@ public sealed class D3D12Renderer : IAquariumRenderer } activeCommandList.BeginEvent("Field Reservoir Resolve"); + BeginGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.FieldReservoirResolve); try { sceneCandidateRenderTarget.Transition(activeCommandList, ResourceStates.PixelShaderResource); @@ -2649,18 +3350,20 @@ public sealed class D3D12Renderer : IAquariumRenderer } finally { + EndGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.FieldReservoirResolve); activeCommandList.EndEvent(); } } - private void UpdateReservoirHistory(ID3D12GraphicsCommandList activeCommandList, FrameResources frameResources) + private bool UpdateReservoirHistory(ID3D12GraphicsCommandList activeCommandList, FrameResources frameResources) { if (reservoirHistoryUpdatePipelineState is null) { - return; + return false; } activeCommandList.BeginEvent("Reservoir History Update"); + BeginGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.ReservoirHistoryUpdate); try { var historyReadIndex = temporalFrameIndex & 1; @@ -2717,8 +3420,11 @@ public sealed class D3D12Renderer : IAquariumRenderer } finally { + EndGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.ReservoirHistoryUpdate); activeCommandList.EndEvent(); } + + return true; } private void RenderTubeFields(ID3D12GraphicsCommandList activeCommandList, FrameResources frameResources) @@ -2767,6 +3473,59 @@ public sealed class D3D12Renderer : IAquariumRenderer activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(fieldReservoirLockBuffer.Resource)); } + private void RenderPointCloudMeshes(ID3D12GraphicsCommandList activeCommandList, FrameResources frameResources) + { + activePointCloudRenderedMeshes = 0; + if (pointCloudRenderPipelineState is null || + Environment.GetEnvironmentVariable("AQUARIUM_RENDER_POINT_CLOUD_DIAG") != "1") + { + return; + } + + var plannedMeshes = PlannedMeshPayloadsByClaim(); + if (plannedMeshes.Count == 0) + { + return; + } + + activeCommandList.BeginEvent("Point Cloud Render"); + try + { + activeCommandList.SetPipelineState(pointCloudRenderPipelineState); + activeCommandList.SetGraphicsRootSignature(fullscreenRootSignature); + activeCommandList.SetGraphicsRootDescriptorTable(RootFrameConstants, frameResources.FrameConstantsDescriptor.Gpu); + foreach (var packet in activeFieldLoweringPlan.Packets) + { + if (packet.Backend != AquariumFieldBackendKind.Mesh || + packet.Encoding != AquariumFieldEncoding.Mesh || + !plannedMeshes.TryGetValue(packet.ClaimKey, out var resourceKey)) + { + continue; + } + + if (!fieldResourceRegistry.TryGetMesh(resourceKey, out var mesh) || + mesh.Topology != AquariumFieldMeshTopology.PointList || + !mesh.HasStandardImportedLayout || + !mesh.IsGeneratedFromResource) + { + continue; + } + + mesh.Vertices.Transition(activeCommandList, ResourceStates.VertexAndConstantBuffer); + mesh.Indices.Transition(activeCommandList, ResourceStates.IndexBuffer); + activeCommandList.IASetPrimitiveTopology(mesh.D3DTopology); + activeCommandList.IASetVertexBuffers(0, [mesh.VertexBufferView]); + activeCommandList.IASetIndexBuffer(mesh.IndexBufferView); + activeCommandList.DrawIndexedInstanced((uint)mesh.Indices.ElementCount, 1, 0, 0, 0); + activePointCloudRenderedMeshes++; + } + } + finally + { + activeCommandList.EndEvent(); + } + } + private void BindPipelinePrivateGeneratedMesh( ID3D12GraphicsCommandList activeCommandList, D3D12PipelinePrivateGeneratedMesh generatedMesh) @@ -2865,16 +3624,18 @@ public sealed class D3D12Renderer : IAquariumRenderer var p1 = points[segment]; var p2 = points[Math.Min(points.Count - 1, segment + 1)]; var p3 = points[Math.Min(points.Count - 1, segment + 2)]; - var t2 = t * t; - var t3 = t2 * t; - var position = 0.5f * ((2.0f * p1.Position) + - (-p0.Position + p2.Position) * t + - (2.0f * p0.Position - 5.0f * p1.Position + 4.0f * p2.Position - p3.Position) * t2 + - (-p0.Position + 3.0f * p1.Position - 3.0f * p2.Position + p3.Position) * t3); - var color = 0.5f * ((2.0f * p1.Color) + - (-p0.Color + p2.Color) * t + - (2.0f * p0.Color - 5.0f * p1.Color + 4.0f * p2.Color - p3.Color) * t2 + - (-p0.Color + 3.0f * p1.Color - 3.0f * p2.Color + p3.Color) * t3); + var position = (Vector3)math.catmullrom( + (float3)p0.Position, + (float3)p1.Position, + (float3)p2.Position, + (float3)p3.Position, + t); + var color = (Vector4)math.catmullrom( + (float4)p0.Color, + (float4)p1.Color, + (float4)p2.Color, + (float4)p3.Color, + t); return new AquariumSplineVertex(position, Vector4.Clamp(color, Vector4.Zero, new Vector4(float.MaxValue, float.MaxValue, float.MaxValue, 1.0f))); } @@ -2959,6 +3720,7 @@ public sealed class D3D12Renderer : IAquariumRenderer D3D12DescriptorSlot initialSourceDescriptor) { activeCommandList.BeginEvent("Bloom Pyramid"); + BeginGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.Bloom); try { activeCommandList.SetDescriptorHeaps(frameResources.TransientShaderDescriptors.Heap); @@ -2999,6 +3761,7 @@ public sealed class D3D12Renderer : IAquariumRenderer } finally { + EndGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.Bloom); activeCommandList.EndEvent(); } } @@ -3022,22 +3785,28 @@ public sealed class D3D12Renderer : IAquariumRenderer activeCommandList.DrawInstanced(3, 1, 0, 0); } - private void PresentBackBuffer(D3D12PassContext context, FrameResources frameResources) + private void PresentBackBuffer( + D3D12PassContext context, + FrameResources frameResources, + D3D12RenderTarget sourceTarget, + D3D12DescriptorSlot sourceDescriptor) { context.CommandList.BeginEvent("Reservoir Presentation Resolve"); + BeginGpuTiming(context.CommandList, frameResources, D3D12GpuTimingPass.PresentationResolve); try { - reservoirResolvedRenderTarget.Transition(context.CommandList, ResourceStates.PixelShaderResource); + sourceTarget.Transition(context.CommandList, ResourceStates.PixelShaderResource); sceneMetadataRenderTarget.Transition(context.CommandList, ResourceStates.PixelShaderResource); sceneControlRenderTarget.Transition(context.CommandList, ResourceStates.PixelShaderResource); sceneReservoirGuideRenderTarget.Transition(context.CommandList, ResourceStates.PixelShaderResource); + sceneOverdrawRenderTarget.Transition(context.CommandList, ResourceStates.PixelShaderResource); context.BackBuffer.Transition(context.CommandList, ResourceStates.RenderTarget); context.CommandList.SetDescriptorHeaps(frameResources.TransientShaderDescriptors.Heap); context.CommandList.SetPipelineState(resolvePipelineState!); context.CommandList.SetGraphicsRootSignature(fullscreenRootSignature); context.CommandList.SetGraphicsRootDescriptorTable(RootFrameConstants, frameResources.FrameConstantsDescriptor.Gpu); - context.CommandList.SetGraphicsRootDescriptorTable(RootSourceTexture, frameResources.ReservoirResolvedDescriptor.Gpu); + context.CommandList.SetGraphicsRootDescriptorTable(RootSourceTexture, sourceDescriptor.Gpu); context.CommandList.SetGraphicsRootDescriptorTable(RootBloom, frameResources.BloomPresentationDescriptor.Gpu); context.CommandList.SetGraphicsRootDescriptorTable(RootCurrentSceneMetadata, frameResources.SceneMetadataDescriptor.Gpu); context.CommandList.SetGraphicsRootDescriptorTable(RootCurrentSceneControl, frameResources.SceneControlDescriptor.Gpu); @@ -3045,6 +3814,14 @@ public sealed class D3D12Renderer : IAquariumRenderer context.CommandList.SetGraphicsRootDescriptorTable(RootTemporalGaussians, frameResources.TemporalGaussianDescriptor.Gpu); context.CommandList.SetGraphicsRootDescriptorTable(RootCurrentReservoirGuide, frameResources.SceneReservoirGuideDescriptor.Gpu); context.CommandList.SetGraphicsRootDescriptorTable(RootBlueNoise, frameResources.BlueNoiseDescriptor.Gpu); + planetarySurfacePageOutputBuffer!.Transition(context.CommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + planetarySurfacePageMetadataBuffer!.Transition(context.CommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + planetarySurfacePageSummaryBuffer!.Transition(context.CommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + context.CommandList.SetGraphicsRootShaderResourceView(RootPlanetarySurfacePage, planetarySurfacePageOutputBuffer.Resource.GPUVirtualAddress); + context.CommandList.SetGraphicsRootShaderResourceView(RootPlanetarySurfacePageMetadata, planetarySurfacePageMetadataBuffer.Resource.GPUVirtualAddress); + context.CommandList.SetGraphicsRootShaderResourceView(RootPlanetarySurfacePageSummary, planetarySurfacePageSummaryBuffer!.Resource.GPUVirtualAddress); + context.CommandList.SetGraphicsRootShaderResourceView(RootPlanetarySurfacePageSet, planetarySurfacePageSetBuffer!.Resource.GPUVirtualAddress); + context.CommandList.SetGraphicsRootDescriptorTable(RootSceneOverdraw, frameResources.SceneOverdrawDescriptor.Gpu); context.CommandList.RSSetViewports(viewport); context.CommandList.RSSetScissorRects(scissorRect); @@ -3058,6 +3835,28 @@ public sealed class D3D12Renderer : IAquariumRenderer CopyProgramOutputBackBuffer(context.CommandList, context.BackBuffer); } finally + { + EndGpuTiming(context.CommandList, frameResources, D3D12GpuTimingPass.PresentationResolve); + context.CommandList.EndEvent(); + } + } + + private void ClearEmptyBackBuffer(D3D12PassContext context) + { + context.CommandList.BeginEvent("Empty Presentation Clear"); + try + { + context.BackBuffer.Transition(context.CommandList, ResourceStates.RenderTarget); + context.CommandList.RSSetViewports(viewport); + context.CommandList.RSSetScissorRects(scissorRect); + context.CommandList.OMSetRenderTargets( + [ + context.RenderTargetView, + ], + null); + context.CommandList.ClearRenderTargetView(context.RenderTargetView, new Color4(0.0f, 0.0f, 0.0f, 1.0f)); + } + finally { context.CommandList.EndEvent(); } @@ -3107,10 +3906,19 @@ public sealed class D3D12Renderer : IAquariumRenderer } activeCommandList.BeginEvent("GPU Sensor Fusion"); + BeginGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.GpuSensorFusion); try { gpuFusionSeedBuffer.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); gpuFusionPointBuffer.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + for (var index = 0; index < gpuSensorFieldTextureCount; index++) + { + if (TryGetFieldTexture2D(gpuSensorFieldTextureKeys[index], out var fieldTexture)) + { + fieldTexture.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + } + } + temporalGaussianBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); activeCommandList.SetDescriptorHeaps(frameResources.TransientShaderDescriptors.Heap); activeCommandList.SetComputeRootSignature(gpuSensorFusionRootSignature); @@ -3127,12 +3935,100 @@ public sealed class D3D12Renderer : IAquariumRenderer } finally { + EndGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.GpuSensorFusion); activeCommandList.EndEvent(); } } + private void EnsurePlanetarySurfacePageBuffers() + { + var sampleCount = Math.Max(activePlanetarySurfacePages.SampleCount, 1); + var pageCount = Math.Max(activePlanetarySurfacePages.Pages.Count, 1); + if (planetarySurfacePageInputBuffer?.ElementCount == sampleCount && planetarySurfacePageMetadataBuffer?.ElementCount == pageCount) return; + planetarySurfacePageSetBuffer?.Dispose(); planetarySurfacePageMetadataBuffer?.Dispose(); planetarySurfacePageSummaryBuffer?.Dispose(); planetarySurfacePageOutputBuffer?.Dispose(); planetarySurfacePageInputBuffer?.Dispose(); + planetarySurfacePageInputBuffer = new D3D12StructuredBuffer(device, sampleCount, Marshal.SizeOf(), "Aquarium Planetary Page Inputs"); + planetarySurfacePageOutputBuffer = new D3D12StructuredBuffer(device, sampleCount, 32, "Aquarium Planetary Page Outputs", allowUnorderedAccess: true); + planetarySurfacePageMetadataBuffer = new D3D12StructuredBuffer(device, pageCount, Marshal.SizeOf(), "Aquarium Planetary Page Metadata"); + planetarySurfacePageSummaryBuffer = new D3D12StructuredBuffer(device, pageCount, 32, "Aquarium Planetary Page Summaries", allowUnorderedAccess: true); + planetarySurfacePageSetBuffer = new D3D12StructuredBuffer(device, 1, 16, "Aquarium Planetary Page Set"); + residentPlanetarySurfacePageContentVersion = 0; + residentPlanetarySurfacePagePresentationVersion = 0; + residentPlanetarySurfacePatchCells = 0; + } + + private void DispatchPlanetarySurfacePage(ID3D12GraphicsCommandList activeCommandList, FrameResources frameResources) + { + if (planetarySurfacePageInputBuffer is null || planetarySurfacePageOutputBuffer is null || planetarySurfacePageMetadataBuffer is null || planetarySurfacePageSummaryBuffer is null || planetarySurfacePageSetBuffer is null) return; + if (!activePlanetarySurfacePages.HasInput) + { + if (residentPlanetarySurfacePageContentVersion != 0) + { + planetarySurfacePageMetadataBuffer.Upload(activeCommandList, frameResources.UploadRing, new[] { default(AquariumPlanetarySurfacePageMetadata) }); + planetarySurfacePageSetBuffer.Upload(activeCommandList, frameResources.UploadRing, new[] { Vector4.Zero }); + residentPlanetarySurfacePageContentVersion = 0; + residentPlanetarySurfacePagePresentationVersion = 0; + residentPlanetarySurfacePatchCells = 0; + } + return; + } + var pages = activePlanetarySurfacePages.Pages; + var metadata = new AquariumPlanetarySurfacePageMetadata[pages.Count]; + var samples = new AquariumPlanetarySurfacePageInput[activePlanetarySurfacePages.SampleCount]; + var sampleOffset = 0; + for (var pageIndex = 0; pageIndex < pages.Count; pageIndex++) + { + var page = pages[pageIndex]; + for (var index = 0; index < page.Samples.Count; index++) samples[sampleOffset + index] = page.Samples[index]; + metadata[pageIndex] = page.Metadata with { Layout = new Vector4(sampleOffset, page.Metadata.Layout.Y, page.Metadata.Layout.Z, page.Metadata.Layout.W) }; + sampleOffset += page.Samples.Count; + } + var patchCells = Math.Clamp(activePlanetarySurfacePages.PatchCells, 1, 256); + if (residentPlanetarySurfacePagePresentationVersion != activePlanetarySurfacePages.PresentationVersion || residentPlanetarySurfacePageContentVersion != activePlanetarySurfacePages.ContentVersion || residentPlanetarySurfacePatchCells != patchCells) + { + var foldedContentVersion=(uint)(activePlanetarySurfacePages.ContentVersion^(activePlanetarySurfacePages.ContentVersion>>32)); + var contentFingerprint=(foldedContentVersion&0x00ffffffu)/16777215.0f; + planetarySurfacePageMetadataBuffer.Upload(activeCommandList, frameResources.UploadRing, metadata); + planetarySurfacePageSetBuffer.Upload(activeCommandList, frameResources.UploadRing, new[] { new Vector4(pages.Count, patchCells, contentFingerprint, 1) }); + residentPlanetarySurfacePagePresentationVersion = activePlanetarySurfacePages.PresentationVersion; + residentPlanetarySurfacePatchCells = patchCells; + } + if (residentPlanetarySurfacePageContentVersion == activePlanetarySurfacePages.ContentVersion && !profilePlanetarySurfacePages) return; + BeginGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.PlanetarySurfacePages); + planetarySurfacePageInputBuffer.Upload(activeCommandList, frameResources.UploadRing, samples); + planetarySurfacePageOutputBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + activeCommandList.SetComputeRootSignature(planetarySurfacePageRootSignature); + activeCommandList.SetPipelineState(planetarySurfacePagePipelineState!); + activeCommandList.SetComputeRootShaderResourceView(0, planetarySurfacePageInputBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetComputeRootUnorderedAccessView(1, planetarySurfacePageOutputBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetComputeRoot32BitConstant(2, (uint)samples.Length, 0); + activeCommandList.Dispatch((uint)((samples.Length + 63) / 64), 1, 1); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(planetarySurfacePageOutputBuffer.Resource)); + planetarySurfacePageOutputBuffer.Transition(activeCommandList, ResourceStates.NonPixelShaderResource); + planetarySurfacePageSummaryBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + activeCommandList.SetPipelineState(planetarySurfacePageSummaryPipelineState!); + activeCommandList.SetComputeRootShaderResourceView(0, planetarySurfacePageOutputBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetComputeRootUnorderedAccessView(1, planetarySurfacePageSummaryBuffer.Resource.GPUVirtualAddress); + for (var pageIndex = 0; pageIndex < pages.Count; pageIndex++) + { + activeCommandList.SetComputeRoot32BitConstant(2, (uint)pages[pageIndex].Samples.Count, 0); + activeCommandList.SetComputeRoot32BitConstant(2, (uint)metadata[pageIndex].Layout.X, 1); + activeCommandList.SetComputeRoot32BitConstant(2, (uint)pageIndex, 2); + activeCommandList.Dispatch(1, 1, 1); + } + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(planetarySurfacePageSummaryBuffer.Resource)); + planetarySurfacePageSummaryBuffer.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + planetarySurfacePageOutputBuffer.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + residentPlanetarySurfacePageContentVersion = activePlanetarySurfacePages.ContentVersion; + EndGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.PlanetarySurfacePages); + } + private void DispatchFractalReservoirs(ID3D12GraphicsCommandList activeCommandList, FrameResources frameResources) { + if (activePointCloudSurfaceReservoir) + { + return; + } + if (!activeFractalReservoirField.HasInput || fractalSplatBuffer is null || fractalSdfReservoirBuffer is null || @@ -3145,6 +4041,7 @@ public sealed class D3D12Renderer : IAquariumRenderer } activeCommandList.BeginEvent("Fractal Reservoir GPU Update"); + BeginGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.FractalReservoirUpdate); try { if (activeFractalProgramTransforms.Length > 0) @@ -3193,6 +4090,7 @@ public sealed class D3D12Renderer : IAquariumRenderer } finally { + EndGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.FractalReservoirUpdate); activeCommandList.EndEvent(); } @@ -3216,128 +4114,157 @@ public sealed class D3D12Renderer : IAquariumRenderer activeFieldResourceUploadCount = 0; activeFieldResourceUploadSkippedCount = 0; tubeFieldDrawBatches.Clear(); - if (tubeFieldComputePipelineState is null || activeFieldEvidenceFrame.TubeSplineLowerings.Count == 0) + if (tubeFieldComputePipelineState is null && + (!activeBokushoBrushFrame.HasInput || + bokushoBrushPipelineState is null || + bokushoPageClearPipelineState is null || + bokushoPageDepositPipelineState is null)) { return; } - UploadFieldResourceData(activeCommandList, frameResources); - - var plannedTubeFieldClaims = PlannedTubeFieldClaimKeys(); - if (plannedTubeFieldClaims.Count == 0) + activeCommandList.BeginEvent("Tube Field GPU Update"); + BeginGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.TubeFieldUpdate); + try { - activeTubeFieldUnplannedLowerings = activeFieldEvidenceFrame.TubeSplineLowerings.Count; - return; - } + UploadFieldResourceData(activeCommandList, frameResources); - var segmentBase = 0; - foreach (var lowering in activeFieldEvidenceFrame.TubeSplineLowerings) + var segmentBase = 0; + DispatchBokushoBrushField(activeCommandList, frameResources, ref segmentBase); + + if (tubeFieldComputePipelineState is null) + { + activeTubeFieldDrawIndexCount = activeTubeFieldDispatchedSegments * 6; + return; + } + + if (activeFieldEvidenceFrame.TubeSplineLowerings.Count == 0) + { + activeTubeFieldDrawIndexCount = activeTubeFieldDispatchedSegments * 6; + return; + } + + var plannedTubeFieldClaims = PlannedTubeFieldClaimKeys(); + if (plannedTubeFieldClaims.Count == 0) + { + activeTubeFieldUnplannedLowerings = activeFieldEvidenceFrame.TubeSplineLowerings.Count; + activeTubeFieldDrawIndexCount = activeTubeFieldDispatchedSegments * 6; + return; + } + + foreach (var lowering in activeFieldEvidenceFrame.TubeSplineLowerings) + { + if (!plannedTubeFieldClaims.Contains(lowering.ClaimKey)) + { + activeTubeFieldUnplannedLowerings++; + continue; + } + + if (!lowering.IsValid || + !fieldResourceRegistry.TryGetStructuredBuffer(lowering.ResourceKey, out var sourceBuffer)) + { + continue; + } + + if (tubeFieldDrawBatches.Count >= MaxTubeFieldDrawBatches) + { + activeTubeFieldSkippedDrawBatches++; + continue; + } + + var normalized = lowering.Normalized(); + var validColumnCount = fieldResourceRegistry.CountContiguousValidColumns(normalized); + activeTubeFieldInvalidColumns += Math.Max(0, normalized.ColumnCount - validColumnCount); + if (validColumnCount <= 0) + { + continue; + } + + normalized = normalized with { ColumnCount = validColumnCount }; + var subdivisions = Math.Max(1, normalized.CatmullRomSubdivisions); + var piecesPerColumn = checked(Math.Max(1, normalized.Width - 1) * subdivisions); + var requestedSegments = checked(piecesPerColumn * Math.Max(1, normalized.ColumnCount)); + activeTubeFieldRequestedSegments += requestedSegments; + var remainingSegments = MaxTubeFieldSegments - segmentBase; + if (remainingSegments <= 0) + { + activeTubeFieldTruncatedSegments += requestedSegments; + continue; + } + + var dispatchSegments = Math.Min(requestedSegments, remainingSegments); + if (dispatchSegments <= 0) + { + continue; + } + + activeTubeFieldTruncatedSegments += requestedSegments - dispatchSegments; + var startIndex = segmentBase * 6; + var fieldId = StableFieldId(lowering.ClaimKey, 5100.0f, 4096); + var previousRollingOffset = previousTubeFieldRollingOffsets.TryGetValue(lowering.ClaimKey, out var storedRollingOffset) + ? storedRollingOffset + : normalized.RollingOffset; + var constants = new D3D12TubeFieldConstants( + new Vector4(normalized.Width, normalized.Height, normalized.StrideBytes, normalized.FirstColumn), + new Vector4(normalized.ColumnCount, normalized.ColumnStride, normalized.RollingModulo, normalized.RollingOffset), + new Vector4(normalized.AmplitudePower, normalized.AmplitudeScale, normalized.NormalizeMin, normalized.NormalizeMax), + new Vector4(normalized.BaseRadius, normalized.RadiusScale, normalized.Alpha, normalized.Feather), + new Vector4(normalized.EmissionScale, subdivisions, segmentBase, dispatchSegments), + new Vector4(tubeFieldDrawBatches.Count * GeneratedMeshDrawArgumentUIntCount, startIndex, fieldId, previousRollingOffset), + normalized.Origin, + 0.0f, + normalized.AxisStep, + 0.0f, + normalized.ColumnStep, + 0.0f); + var constantsUpload = frameResources.UploadRing.WriteConstant(constants); + + sourceBuffer.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + tubeFieldVertexBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + tubeFieldIndexBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + tubeFieldStatsBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + tubeFieldDrawArgumentBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + activeCommandList.SetComputeRootSignature(tubeFieldRootSignature); + activeCommandList.SetPipelineState(tubeFieldComputePipelineState); + activeCommandList.SetComputeRootConstantBufferView(RootTubeFieldConstants, constantsUpload.GpuVirtualAddress); + activeCommandList.SetComputeRootShaderResourceView(RootTubeFieldSource, sourceBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetComputeRootUnorderedAccessView(RootTubeFieldVertices, tubeFieldVertexBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetComputeRootUnorderedAccessView(RootTubeFieldIndices, tubeFieldIndexBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetComputeRootUnorderedAccessView(RootTubeFieldStats, tubeFieldStatsBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetComputeRootUnorderedAccessView(RootTubeFieldDrawArguments, tubeFieldDrawArgumentBuffer.Resource.GPUVirtualAddress); + tubeFieldSegmentBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + activeCommandList.SetComputeRootUnorderedAccessView(RootTubeFieldSegments, tubeFieldSegmentBuffer.Resource.GPUVirtualAddress); + var ramp = ResolveTubeFieldRamp(activeCommandList, normalized.RampResourceKey); + ramp.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + var rampDescriptor = frameResources.TransientShaderDescriptors.Allocate(); + ramp.CreateShaderResourceView(device, rampDescriptor); + activeCommandList.SetComputeRootDescriptorTable(RootTubeFieldRamp, rampDescriptor.Gpu); + activeCommandList.Dispatch((uint)((dispatchSegments + 255) / 256), 1, 1); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(tubeFieldVertexBuffer.Resource)); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(tubeFieldIndexBuffer.Resource)); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(tubeFieldSegmentBuffer.Resource)); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(tubeFieldStatsBuffer.Resource)); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(tubeFieldDrawArgumentBuffer.Resource)); + + segmentBase += dispatchSegments; + activeTubeFieldDispatchedSegments += dispatchSegments; + activeTubeFieldIndirectDrawCount++; + tubeFieldDrawBatches.Add(new D3D12TubeFieldDrawBatch( + sourceBuffer, + constants, + constantsUpload.GpuVirtualAddress, + normalized.RampResourceKey, + tubeFieldDrawBatches.Count * GeneratedMeshDrawArgumentBytes)); + previousTubeFieldRollingOffsets[lowering.ClaimKey] = normalized.RollingOffset; + } + + activeTubeFieldDrawIndexCount = activeTubeFieldDispatchedSegments * 6; + } + finally { - if (!plannedTubeFieldClaims.Contains(lowering.ClaimKey)) - { - activeTubeFieldUnplannedLowerings++; - continue; - } - - if (!lowering.IsValid || - !fieldResourceRegistry.TryGetStructuredBuffer(lowering.ResourceKey, out var sourceBuffer)) - { - continue; - } - - if (tubeFieldDrawBatches.Count >= MaxTubeFieldDrawBatches) - { - activeTubeFieldSkippedDrawBatches++; - continue; - } - - var normalized = lowering.Normalized(); - var validColumnCount = fieldResourceRegistry.CountContiguousValidColumns(normalized); - activeTubeFieldInvalidColumns += Math.Max(0, normalized.ColumnCount - validColumnCount); - if (validColumnCount <= 0) - { - continue; - } - - normalized = normalized with { ColumnCount = validColumnCount }; - var subdivisions = Math.Max(1, normalized.CatmullRomSubdivisions); - var piecesPerColumn = checked(Math.Max(1, normalized.Width - 1) * subdivisions); - var requestedSegments = checked(piecesPerColumn * Math.Max(1, normalized.ColumnCount)); - activeTubeFieldRequestedSegments += requestedSegments; - var remainingSegments = MaxTubeFieldSegments - segmentBase; - if (remainingSegments <= 0) - { - activeTubeFieldTruncatedSegments += requestedSegments; - continue; - } - - var dispatchSegments = Math.Min(requestedSegments, remainingSegments); - if (dispatchSegments <= 0) - { - continue; - } - - activeTubeFieldTruncatedSegments += requestedSegments - dispatchSegments; - var startIndex = segmentBase * 6; - var fieldId = StableFieldId(lowering.ClaimKey, 5100.0f, 4096); - var previousRollingOffset = previousTubeFieldRollingOffsets.TryGetValue(lowering.ClaimKey, out var storedRollingOffset) - ? storedRollingOffset - : normalized.RollingOffset; - var constants = new D3D12TubeFieldConstants( - new Vector4(normalized.Width, normalized.Height, normalized.StrideBytes, normalized.FirstColumn), - new Vector4(normalized.ColumnCount, normalized.ColumnStride, normalized.RollingModulo, normalized.RollingOffset), - new Vector4(normalized.AmplitudePower, normalized.AmplitudeScale, normalized.NormalizeMin, normalized.NormalizeMax), - new Vector4(normalized.BaseRadius, normalized.RadiusScale, normalized.Alpha, normalized.Feather), - new Vector4(normalized.EmissionScale, subdivisions, segmentBase, dispatchSegments), - new Vector4(tubeFieldDrawBatches.Count * GeneratedMeshDrawArgumentUIntCount, startIndex, fieldId, previousRollingOffset), - normalized.Origin, - 0.0f, - normalized.AxisStep, - 0.0f, - normalized.ColumnStep, - 0.0f); - var constantsUpload = frameResources.UploadRing.WriteConstant(constants); - - sourceBuffer.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); - tubeFieldVertexBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); - tubeFieldIndexBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); - tubeFieldStatsBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); - tubeFieldDrawArgumentBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); - activeCommandList.SetComputeRootSignature(tubeFieldRootSignature); - activeCommandList.SetPipelineState(tubeFieldComputePipelineState); - activeCommandList.SetComputeRootConstantBufferView(RootTubeFieldConstants, constantsUpload.GpuVirtualAddress); - activeCommandList.SetComputeRootShaderResourceView(RootTubeFieldSource, sourceBuffer.Resource.GPUVirtualAddress); - activeCommandList.SetComputeRootUnorderedAccessView(RootTubeFieldVertices, tubeFieldVertexBuffer.Resource.GPUVirtualAddress); - activeCommandList.SetComputeRootUnorderedAccessView(RootTubeFieldIndices, tubeFieldIndexBuffer.Resource.GPUVirtualAddress); - activeCommandList.SetComputeRootUnorderedAccessView(RootTubeFieldStats, tubeFieldStatsBuffer.Resource.GPUVirtualAddress); - activeCommandList.SetComputeRootUnorderedAccessView(RootTubeFieldDrawArguments, tubeFieldDrawArgumentBuffer.Resource.GPUVirtualAddress); - tubeFieldSegmentBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); - activeCommandList.SetComputeRootUnorderedAccessView(RootTubeFieldSegments, tubeFieldSegmentBuffer.Resource.GPUVirtualAddress); - var ramp = ResolveTubeFieldRamp(activeCommandList, normalized.RampResourceKey); - ramp.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); - var rampDescriptor = frameResources.TransientShaderDescriptors.Allocate(); - ramp.CreateShaderResourceView(device, rampDescriptor); - activeCommandList.SetComputeRootDescriptorTable(RootTubeFieldRamp, rampDescriptor.Gpu); - activeCommandList.Dispatch((uint)((dispatchSegments + 255) / 256), 1, 1); - activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(tubeFieldVertexBuffer.Resource)); - activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(tubeFieldIndexBuffer.Resource)); - activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(tubeFieldSegmentBuffer.Resource)); - activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(tubeFieldStatsBuffer.Resource)); - activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(tubeFieldDrawArgumentBuffer.Resource)); - - segmentBase += dispatchSegments; - activeTubeFieldDispatchedSegments += dispatchSegments; - activeTubeFieldIndirectDrawCount++; - tubeFieldDrawBatches.Add(new D3D12TubeFieldDrawBatch( - sourceBuffer, - constants, - constantsUpload.GpuVirtualAddress, - normalized.RampResourceKey, - tubeFieldDrawBatches.Count * GeneratedMeshDrawArgumentBytes)); - previousTubeFieldRollingOffsets[lowering.ClaimKey] = normalized.RollingOffset; + EndGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.TubeFieldUpdate); + activeCommandList.EndEvent(); } - - activeTubeFieldDrawIndexCount = activeTubeFieldDispatchedSegments * 6; } private HashSet PlannedTubeFieldClaimKeys() @@ -3355,9 +4282,149 @@ public sealed class D3D12Renderer : IAquariumRenderer return planned; } + private void DispatchBokushoBrushField(ID3D12GraphicsCommandList activeCommandList, FrameResources frameResources, ref int segmentBase) + { + if (!activeBokushoBrushFrame.HasInput || + bokushoBrushPipelineState is null || + bokushoPageClearPipelineState is null || + bokushoPageDepositPipelineState is null) + { + return; + } + + var frame = activeBokushoBrushFrame.Normalized(); + var sampleCount = Math.Clamp(frame.SampleCount, 2, MaxBokushoBrushSamples); + var tuftCount = Math.Clamp(frame.TuftCount, 1, MaxBokushoBrushTufts); + var strokes = PackBokushoBrushStrokes(frame); + var sourcePoints = PackBokushoSourcePoints(frame); + var debugStrokeCount = BoundedBokushoDebugStrokeCount(strokes.Length, sampleCount, tuftCount); + var pageStrokeCount = Math.Min(strokes.Length, MaxBokushoBrushStrokes); + var sourcePointCount = Math.Min(sourcePoints.Length, MaxBokushoSourcePoints); + var valueCount = checked(sampleCount * tuftCount * debugStrokeCount); + var debugConstants = new D3D12BokushoBrushConstants( + new Vector4(sampleCount, tuftCount, frame.PhysicsHz, debugStrokeCount), + new Vector4(frame.BrushRadius, frame.Pressure, frame.InkLoad, frame.Wetness), + new Vector4(frame.Splay, frame.Bend, frame.Friction, sourcePointCount), + new Vector4(frame.RadiusScale, frame.PressureScale, frame.NormalScale, frame.TangentScale), + frame.StrokeP0, + frame.StrokeP1, + frame.StrokeP2, + frame.StrokeP3, + new Vector4(activeViewCenter.X, activeViewCenter.Y, Math.Max(activeViewRadius, 0.001f), HeightFieldTextureSize)); + var pageConstants = debugConstants with { Shape = new Vector4(sampleCount, tuftCount, frame.PhysicsHz, pageStrokeCount) }; + var debugConstantsUpload = frameResources.UploadRing.WriteConstant(debugConstants); + var pageConstantsUpload = frameResources.UploadRing.WriteConstant(pageConstants); + bokushoBrushStrokeBuffer.UploadPartial(activeCommandList, frameResources.UploadRing, strokes.AsSpan(0, pageStrokeCount)); + bokushoSourcePointBuffer.UploadPartial(activeCommandList, frameResources.UploadRing, sourcePoints.AsSpan(0, sourcePointCount)); + + bokushoBrushTraceBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + bokushoBrushCanvasBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + bokushoBrushTipBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + bokushoPageDensityBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + bokushoBrushStrokeBuffer.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + bokushoSourcePointBuffer.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + activeCommandList.SetComputeRootSignature(bokushoBrushRootSignature); + activeCommandList.SetPipelineState(bokushoBrushPipelineState); + activeCommandList.SetComputeRootConstantBufferView(RootBokushoBrushConstants, debugConstantsUpload.GpuVirtualAddress); + activeCommandList.SetComputeRootUnorderedAccessView(RootBokushoBrushTrace, bokushoBrushTraceBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetComputeRootUnorderedAccessView(RootBokushoBrushCanvas, bokushoBrushCanvasBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetComputeRootUnorderedAccessView(RootBokushoBrushTips, bokushoBrushTipBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetComputeRootShaderResourceView(RootBokushoBrushStrokes, bokushoBrushStrokeBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetComputeRootUnorderedAccessView(RootBokushoBrushPage, bokushoPageDensityBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetComputeRootShaderResourceView(RootBokushoBrushSourcePoints, bokushoSourcePointBuffer.Resource.GPUVirtualAddress); + activeCommandList.Dispatch((uint)(((tuftCount * debugStrokeCount) + 127) / 128), 1, 1); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(bokushoBrushTraceBuffer.Resource)); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(bokushoBrushCanvasBuffer.Resource)); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(bokushoBrushTipBuffer.Resource)); + + activeCommandList.SetPipelineState(bokushoPageClearPipelineState); + activeCommandList.SetComputeRootConstantBufferView(RootBokushoBrushConstants, pageConstantsUpload.GpuVirtualAddress); + activeCommandList.Dispatch((uint)(((HeightFieldTextureSize * HeightFieldTextureSize) + 127) / 128), 1, 1); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(bokushoPageDensityBuffer.Resource)); + activeCommandList.SetPipelineState(bokushoPageDepositPipelineState); + activeCommandList.Dispatch((uint)(((tuftCount * pageStrokeCount) + 127) / 128), 1, 1); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(bokushoPageDensityBuffer.Resource)); + + if (tubeFieldComputePipelineState is null || + tubeFieldDrawBatches.Count >= MaxTubeFieldDrawBatches) + { + return; + } + + var subdivisions = 2; + var requestedSegments = checked((sampleCount - 1) * subdivisions * tuftCount * debugStrokeCount); + activeTubeFieldRequestedSegments += requestedSegments; + var remainingSegments = MaxTubeFieldSegments - segmentBase; + if (remainingSegments <= 0) + { + activeTubeFieldTruncatedSegments += requestedSegments; + return; + } + + var dispatchSegments = Math.Min(requestedSegments, remainingSegments); + activeTubeFieldTruncatedSegments += requestedSegments - dispatchSegments; + var startIndex = segmentBase * 6; + var fieldId = StableFieldId("bokusho.brush.direct-compute", 5600.0f, 1024); + var tubeConstants = new D3D12TubeFieldConstants( + new Vector4(sampleCount, tuftCount, sizeof(float), 0.0f), + new Vector4(tuftCount * debugStrokeCount, 1.0f, 0.0f, 0.0f), + new Vector4(1.0f, 2.8f, 0.0f, 1.0f), + new Vector4(0.010f, 0.030f, 0.92f, 0.14f), + new Vector4(2.8f, subdivisions, segmentBase, dispatchSegments), + new Vector4(tubeFieldDrawBatches.Count * GeneratedMeshDrawArgumentUIntCount, startIndex, fieldId, 0.0f), + new Vector3(-7.0f, -2.2f, -0.45f), + 0.0f, + new Vector3(14.0f / Math.Max(1, sampleCount - 1), 0.0f, 0.0f), + 0.0f, + new Vector3(0.0f, 0.0f, 0.9f / Math.Max(1, tuftCount * debugStrokeCount - 1)), + 0.0f); + var tubeConstantsUpload = frameResources.UploadRing.WriteConstant(tubeConstants); + + bokushoBrushTraceBuffer.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + bokushoBrushCanvasBuffer.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + bokushoBrushTipBuffer.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + tubeFieldVertexBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + tubeFieldIndexBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + tubeFieldStatsBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + tubeFieldDrawArgumentBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + tubeFieldSegmentBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + activeCommandList.SetComputeRootSignature(tubeFieldRootSignature); + activeCommandList.SetPipelineState(tubeFieldComputePipelineState); + activeCommandList.SetComputeRootConstantBufferView(RootTubeFieldConstants, tubeConstantsUpload.GpuVirtualAddress); + activeCommandList.SetComputeRootShaderResourceView(RootTubeFieldSource, bokushoBrushTraceBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetComputeRootUnorderedAccessView(RootTubeFieldVertices, tubeFieldVertexBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetComputeRootUnorderedAccessView(RootTubeFieldIndices, tubeFieldIndexBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetComputeRootUnorderedAccessView(RootTubeFieldStats, tubeFieldStatsBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetComputeRootUnorderedAccessView(RootTubeFieldDrawArguments, tubeFieldDrawArgumentBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetComputeRootUnorderedAccessView(RootTubeFieldSegments, tubeFieldSegmentBuffer.Resource.GPUVirtualAddress); + var ramp = ResolveTubeFieldRamp(activeCommandList, ""); + ramp.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + var rampDescriptor = frameResources.TransientShaderDescriptors.Allocate(); + ramp.CreateShaderResourceView(device, rampDescriptor); + activeCommandList.SetComputeRootDescriptorTable(RootTubeFieldRamp, rampDescriptor.Gpu); + activeCommandList.Dispatch((uint)((dispatchSegments + 255) / 256), 1, 1); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(tubeFieldVertexBuffer.Resource)); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(tubeFieldIndexBuffer.Resource)); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(tubeFieldSegmentBuffer.Resource)); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(tubeFieldStatsBuffer.Resource)); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(tubeFieldDrawArgumentBuffer.Resource)); + + segmentBase += dispatchSegments; + activeTubeFieldDispatchedSegments += dispatchSegments; + activeTubeFieldIndirectDrawCount++; + tubeFieldDrawBatches.Add(new D3D12TubeFieldDrawBatch( + bokushoBrushTraceBuffer, + tubeConstants, + tubeConstantsUpload.GpuVirtualAddress, + "", + tubeFieldDrawBatches.Count * GeneratedMeshDrawArgumentBytes)); + _ = valueCount; + } + private void EvaluateStereoDepthLowerings() { activeStereoDepthDispatchReadyLowerings = 0; + activeStereoDepthDispatchedLowerings = 0; activeStereoDepthUnplannedLowerings = 0; activeStereoDepthUnresolvedLowerings = 0; if (activeFieldEvidenceFrame.StereoDepthLowerings.Count == 0) @@ -3375,8 +4442,8 @@ public sealed class D3D12Renderer : IAquariumRenderer } if (!lowering.IsValid || - !fieldResourceRegistry.TryGetTexture2D(lowering.LeftResourceKey, out _) || - !fieldResourceRegistry.TryGetTexture2D(lowering.RightResourceKey, out _) || + !TryGetFieldTexture2D(lowering.LeftResourceKey, out _) || + !TryGetFieldTexture2D(lowering.RightResourceKey, out _) || !fieldResourceRegistry.TryGetSurfacePage(lowering.DisparityResourceKey, out var disparity) || !disparity.AllowsUnorderedAccess) { @@ -3403,6 +4470,254 @@ public sealed class D3D12Renderer : IAquariumRenderer return planned; } + private void DispatchStereoDepth(ID3D12GraphicsCommandList activeCommandList, FrameResources frameResources) + { + activeStereoDepthDispatchedLowerings = 0; + if (stereoDepthPipelineState is null || activeFieldEvidenceFrame.StereoDepthLowerings.Count == 0) + { + return; + } + + activeCommandList.BeginEvent("Stereo Depth GPU Update"); + BeginGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.StereoDepthUpdate); + try + { + var plannedClaims = PlannedStereoDepthClaimKeys(); + foreach (var lowering in activeFieldEvidenceFrame.StereoDepthLowerings) + { + var normalized = lowering.Normalized(); + if (!plannedClaims.Contains(normalized.ClaimKey) || + !string.Equals(normalized.LeftResourceKey, normalized.RightResourceKey, StringComparison.Ordinal) || + !TryGetFieldTexture2D(normalized.LeftResourceKey, out var packedInput) || + !fieldResourceRegistry.TryGetSurfacePage(normalized.DisparityResourceKey, out var disparityOutput) || + !disparityOutput.AllowsUnorderedAccess) + { + continue; + } + + packedInput.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + disparityOutput.Transition(activeCommandList, ResourceStates.UnorderedAccess); + var inputDescriptor = frameResources.TransientShaderDescriptors.Allocate(); + packedInput.CreateShaderResourceView(device, inputDescriptor); + var outputDescriptor = frameResources.TransientShaderDescriptors.Allocate(); + if (!disparityOutput.TryCreateUnorderedAccessView(device, outputDescriptor)) + { + continue; + } + + var constants = new D3D12StereoDepthConstants( + new Vector4(normalized.Width, normalized.Height, normalized.MinDisparity, normalized.DisparityLevels), + new Vector4(normalized.CensusRadius, normalized.AggregationPathCount, normalized.SmoothnessPenaltySmall, normalized.SmoothnessPenaltyLarge), + new Vector4(1.0f / MathF.Max(1.0f, normalized.DisparityLevels), 0.0f, 0.0f, 0.0f)); + var constantsUpload = frameResources.UploadRing.WriteConstant(constants); + activeCommandList.SetComputeRootSignature(gpuSensorFusionRootSignature); + activeCommandList.SetPipelineState(stereoDepthPipelineState); + activeCommandList.SetComputeRootConstantBufferView(RootFusionFrameConstants, constantsUpload.GpuVirtualAddress); + activeCommandList.SetComputeRootDescriptorTable(RootFusionSensorTextures, inputDescriptor.Gpu); + activeCommandList.SetComputeRootDescriptorTable(RootFusionOutput, outputDescriptor.Gpu); + activeCommandList.Dispatch((uint)((normalized.Width + 7) / 8), (uint)((normalized.Height + 7) / 8), 1); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(disparityOutput.Resource)); + activeStereoDepthDispatchedLowerings++; + } + } + finally + { + EndGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.StereoDepthUpdate); + activeCommandList.EndEvent(); + } + } + + private bool TryGetFieldTexture2D(string resourceKey, out D3D12FieldTexture2D texture) + { + if (fieldResourceRegistry.TryGetTexture2D(resourceKey, out texture)) + { + return true; + } + + if (sharedTextureLeases.TryGetValue(resourceKey, out var slot)) + { + texture = slot.Texture; + return true; + } + + texture = null!; + return false; + } + + private void DispatchPointClouds(ID3D12GraphicsCommandList activeCommandList, FrameResources frameResources) + { + activePointCloudGeneratedMeshes = 0; + activePointCloudUnresolvedMeshes = 0; + activePointCloudUnplannedMeshes = 0; + if (pointCloudComputePipelineState is null || + !activePointCloudSurfaceReservoir || + fractalSplatBuffer is null || + fractalSdfReservoirBuffer is null || + fractalPbrReservoirBuffer is null || + fractalRadiosityReservoirBuffer is null) + { + return; + } + + var plannedMeshes = PlannedMeshPayloadsByClaim(); + if (plannedMeshes.Count == 0) + { + return; + } + + activeCommandList.BeginEvent("Point Cloud GPU Update"); + try + { + foreach (var claim in activeFieldEvidenceFrame.Claims) + { + if (claim.Encoding != AquariumFieldEncoding.Mesh || + !plannedMeshes.TryGetValue(claim.ClaimKey, out var meshResourceKey)) + { + continue; + } + + if (!fieldResourceRegistry.TryGetMesh(meshResourceKey, out var mesh) || + mesh.Topology != AquariumFieldMeshTopology.PointList || + !mesh.HasStandardImportedLayout || + !TryGetDerivedResourceKey(mesh.SourceUri, out var disparityResourceKey)) + { + activePointCloudUnresolvedMeshes++; + continue; + } + + if (!fieldResourceRegistry.TryGetSurfacePage(disparityResourceKey, out var disparity)) + { + activePointCloudUnresolvedMeshes++; + continue; + } + + var pointCount = Math.Max(1, Math.Min(mesh.Vertices.ElementCount, mesh.Indices.ElementCount)); + var sampledWidth = Math.Max(1, (int)MathF.Ceiling(MathF.Sqrt(pointCount * Math.Max(1.0f, disparity.Width / Math.Max(1.0f, disparity.Height))))); + var sampleStride = Math.Max(1, (int)MathF.Ceiling(disparity.Width / (float)sampledWidth)); + var constants = new D3D12PointCloudConstants( + new Vector4(disparity.Width, disparity.Height, pointCount, sampleStride), + new Vector4(10.0f, 250.0f, disparity.Width * 0.5f, disparity.Height * 0.5f), + new Vector4(0.20f, 4.0f, 5.0f, 1.2f)); + var constantsUpload = frameResources.UploadRing.WriteConstant(constants); + + disparity.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + mesh.Vertices.Transition(activeCommandList, ResourceStates.UnorderedAccess); + mesh.Indices.Transition(activeCommandList, ResourceStates.UnorderedAccess); + if (activePointCloudSurfaceReservoir && + fractalSplatBuffer is not null && + fractalSdfReservoirBuffer is not null && + fractalPbrReservoirBuffer is not null && + fractalRadiosityReservoirBuffer is not null) + { + fractalSplatBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + fractalSdfReservoirBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + fractalPbrReservoirBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + fractalRadiosityReservoirBuffer.Transition(activeCommandList, ResourceStates.UnorderedAccess); + } + + var disparityDescriptor = frameResources.TransientShaderDescriptors.Allocate(); + disparity.CreateShaderResourceView(device, disparityDescriptor); + var vertexDescriptor = frameResources.TransientShaderDescriptors.Allocate(); + mesh.Vertices.CreateUnorderedAccessView(device, vertexDescriptor); + var indexDescriptor = frameResources.TransientShaderDescriptors.Allocate(); + mesh.Indices.CreateUnorderedAccessView(device, indexDescriptor); + D3D12DescriptorSlot splatDescriptor = default; + D3D12DescriptorSlot sdfDescriptor = default; + D3D12DescriptorSlot pbrDescriptor = default; + D3D12DescriptorSlot radiosityDescriptor = default; + if (activePointCloudSurfaceReservoir && + fractalSplatBuffer is not null && + fractalSdfReservoirBuffer is not null && + fractalPbrReservoirBuffer is not null && + fractalRadiosityReservoirBuffer is not null) + { + splatDescriptor = frameResources.TransientShaderDescriptors.Allocate(); + fractalSplatBuffer.CreateUnorderedAccessView(device, splatDescriptor); + sdfDescriptor = frameResources.TransientShaderDescriptors.Allocate(); + fractalSdfReservoirBuffer.CreateUnorderedAccessView(device, sdfDescriptor); + pbrDescriptor = frameResources.TransientShaderDescriptors.Allocate(); + fractalPbrReservoirBuffer.CreateUnorderedAccessView(device, pbrDescriptor); + radiosityDescriptor = frameResources.TransientShaderDescriptors.Allocate(); + fractalRadiosityReservoirBuffer.CreateUnorderedAccessView(device, radiosityDescriptor); + } + + activeCommandList.SetComputeRootSignature(pointCloudRootSignature); + activeCommandList.SetPipelineState(pointCloudComputePipelineState); + activeCommandList.SetComputeRootDescriptorTable(RootPointCloudFrameConstants, frameResources.FrameConstantsDescriptor.Gpu); + activeCommandList.SetComputeRootConstantBufferView(RootPointCloudConstants, constantsUpload.GpuVirtualAddress); + activeCommandList.SetComputeRootDescriptorTable(RootPointCloudDisparity, disparityDescriptor.Gpu); + activeCommandList.SetComputeRootDescriptorTable(RootPointCloudVertices, vertexDescriptor.Gpu); + activeCommandList.SetComputeRootDescriptorTable(RootPointCloudIndices, indexDescriptor.Gpu); + if (activePointCloudSurfaceReservoir && + fractalSplatBuffer is not null && + fractalSdfReservoirBuffer is not null && + fractalPbrReservoirBuffer is not null && + fractalRadiosityReservoirBuffer is not null) + { + activeCommandList.SetComputeRootDescriptorTable(RootPointCloudFractalSplats, splatDescriptor.Gpu); + activeCommandList.SetComputeRootDescriptorTable(RootPointCloudSdfReservoirs, sdfDescriptor.Gpu); + activeCommandList.SetComputeRootDescriptorTable(RootPointCloudPbrReservoirs, pbrDescriptor.Gpu); + activeCommandList.SetComputeRootDescriptorTable(RootPointCloudRadiosityReservoirs, radiosityDescriptor.Gpu); + } + + activeCommandList.Dispatch((uint)((pointCount + 127) / 128), 1, 1); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(mesh.Vertices.Resource)); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(mesh.Indices.Resource)); + if (activePointCloudSurfaceReservoir && + fractalSplatBuffer is not null && + fractalSdfReservoirBuffer is not null && + fractalPbrReservoirBuffer is not null && + fractalRadiosityReservoirBuffer is not null) + { + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(fractalSplatBuffer.Resource)); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(fractalSdfReservoirBuffer.Resource)); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(fractalPbrReservoirBuffer.Resource)); + activeCommandList.ResourceBarrier(ResourceBarrier.BarrierUnorderedAccessView(fractalRadiosityReservoirBuffer.Resource)); + } + + activePointCloudGeneratedMeshes++; + } + } + finally + { + activeCommandList.EndEvent(); + } + } + + private Dictionary PlannedMeshPayloadsByClaim() + { + var planned = new Dictionary(StringComparer.Ordinal); + foreach (var packet in activeFieldLoweringPlan.Packets) + { + if (packet.Backend == AquariumFieldBackendKind.Mesh && + packet.Encoding == AquariumFieldEncoding.Mesh && + LooksLikeFieldResourceKey(packet.PayloadHandle)) + { + planned[packet.ClaimKey] = packet.PayloadHandle; + } + } + + return planned; + } + + private static bool TryGetDerivedResourceKey(string sourceUri, out string resourceKey) + { + const string prefix = "derived-from:"; + resourceKey = ""; + if (!sourceUri.StartsWith(prefix, StringComparison.Ordinal)) + { + return false; + } + + resourceKey = sourceUri[prefix.Length..]; + return !string.IsNullOrWhiteSpace(resourceKey); + } + + private static bool LooksLikeFieldResourceKey(string payloadHandle) => + payloadHandle.StartsWith("resource:", StringComparison.Ordinal) || + payloadHandle.StartsWith("mimir:resource:", StringComparison.Ordinal) || + payloadHandle.StartsWith("aquarium:resource:", StringComparison.Ordinal); + private void UploadFieldResourceData(ID3D12GraphicsCommandList activeCommandList, FrameResources frameResources) { foreach (var upload in activeFieldEvidenceFrame.ResourceUploads) @@ -3450,7 +4765,7 @@ public sealed class D3D12Renderer : IAquariumRenderer private void CreateGpuSensorTextureViews(AquariumGpuSensorFrame sensorFrame, D3D12DescriptorSlot firstDescriptor) { var activeHandles = new HashSet(StringComparer.Ordinal); - gpuSensorTextureCount = Math.Min(sensorFrame.ExternalTextures.Count, MaxGpuSensorTextureCount); + gpuSensorExternalTextureCount = Math.Min(sensorFrame.ExternalTextures.Count, MaxGpuSensorTextureCount); for (var index = 0; index < MaxGpuSensorTextureCount; index++) { @@ -3461,6 +4776,22 @@ public sealed class D3D12Renderer : IAquariumRenderer continue; } + if (index >= gpuSensorExternalTextureCount) + { + var fieldTextureIndex = index - gpuSensorExternalTextureCount; + if (fieldTextureIndex < gpuSensorFieldTextureCount && + TryGetFieldTexture2D(gpuSensorFieldTextureKeys[fieldTextureIndex], out var fieldTexture)) + { + fieldTexture.CreateShaderResourceView(device, descriptor); + } + else + { + CreateNullSensorTextureView(descriptor); + } + + continue; + } + var texture = sensorFrame.ExternalTextures[index]; var cacheKey = D3D12ExternalSensorTexture.CacheKey(texture); activeHandles.Add(cacheKey); @@ -3512,6 +4843,7 @@ public sealed class D3D12Renderer : IAquariumRenderer var scissorRect = new RawRect(0, 0, HeightFieldTextureSize, HeightFieldTextureSize); activeCommandList.BeginEvent("Height Field Pass"); + BeginGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.HeightField); try { heightFieldRenderTarget.Transition(activeCommandList, ResourceStates.RenderTarget); @@ -3520,6 +4852,17 @@ public sealed class D3D12Renderer : IAquariumRenderer activeCommandList.SetPipelineState(heightFieldBasePipelineState!); activeCommandList.SetGraphicsRootSignature(fullscreenRootSignature); activeCommandList.SetGraphicsRootDescriptorTable(RootFrameConstants, frameResources.FrameConstantsDescriptor.Gpu); + var pageConstants = BuildBokushoPageConstants(); + var pageConstantsUpload = frameResources.UploadRing.WriteConstant(pageConstants); + bokushoBrushCanvasBuffer.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + bokushoBrushTipBuffer.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + bokushoBrushStrokeBuffer.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + bokushoPageDensityBuffer.Transition(activeCommandList, ResourceStates.PixelShaderResource | ResourceStates.NonPixelShaderResource); + activeCommandList.SetGraphicsRootConstantBufferView(RootBokushoPageConstants, pageConstantsUpload.GpuVirtualAddress); + activeCommandList.SetGraphicsRootShaderResourceView(RootBokushoPageCanvas, bokushoBrushCanvasBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetGraphicsRootShaderResourceView(RootBokushoPageStrokes, bokushoBrushStrokeBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetGraphicsRootShaderResourceView(RootBokushoPageTips, bokushoBrushTipBuffer.Resource.GPUVirtualAddress); + activeCommandList.SetGraphicsRootShaderResourceView(RootBokushoPageDensity, bokushoPageDensityBuffer.Resource.GPUVirtualAddress); activeCommandList.RSSetViewports(viewport); activeCommandList.RSSetScissorRects(scissorRect); activeCommandList.OMSetRenderTargets(heightFieldRenderTarget.RenderTargetView.Cpu, null); @@ -3531,6 +4874,7 @@ public sealed class D3D12Renderer : IAquariumRenderer } finally { + EndGpuTiming(activeCommandList, frameResources, D3D12GpuTimingPass.HeightField); activeCommandList.EndEvent(); } } @@ -3542,6 +4886,10 @@ public sealed class D3D12Renderer : IAquariumRenderer temporalGaussianCount = 0; gpuSensorCameraCount = 0; gpuSensorTextureCount = 0; + gpuSensorExternalTextureCount = 0; + gpuSensorFieldTextureCount = 0; + gpuSensorFieldUnsupportedTextureCount = 0; + Array.Clear(gpuSensorFieldTextureKeys); acousticConstraintCount = 0; gpuFusionSeedCount = 0; gpuFusionPointCount = 0; @@ -3556,6 +4904,12 @@ public sealed class D3D12Renderer : IAquariumRenderer activeBufferFieldFrame = scene.BufferFieldFrame.HasInput ? scene.BufferFieldFrame : AquariumBufferFieldFrame.Empty; + activeBokushoBrushFrame = scene.BokushoBrushFrame.HasInput + ? scene.BokushoBrushFrame.Normalized() + : AquariumBokushoBrushFrame.Empty; + activePlanetarySurfacePages = scene.PlanetarySurfacePages.HasInput + ? scene.PlanetarySurfacePages + : AquariumPlanetarySurfacePageSet.Empty; activeTextureSplinePrograms = PackTextureSplinePrograms(activeBufferFieldFrame); activeTextureFieldSamples = FlattenTextureSamples(activeBufferFieldFrame); activeSplineFrame = MergeSplineFrames( @@ -3572,9 +4926,20 @@ public sealed class D3D12Renderer : IAquariumRenderer ? AquariumFieldLoweringPlan.Empty : AquariumFieldLoweringPlanner.Plan(activeFieldEvidenceFrame); EvaluateStereoDepthLowerings(); + var pointCloudReservoirField = BuildPointCloudSurfaceReservoirField(); activeFractalProgramTransforms = activeFractalReservoirField.HasInput && scene.FractalReservoirField.ProgramTransforms.Count > 0 ? scene.FractalReservoirField.ProgramTransforms as AquariumPackedFractalIfsTransform[] ?? scene.FractalReservoirField.ProgramTransforms.ToArray() : []; + if (!activeFractalReservoirField.HasInput && pointCloudReservoirField.HasInput) + { + activeFractalReservoirField = pointCloudReservoirField; + activePointCloudSurfaceReservoir = true; + } + else + { + activePointCloudSurfaceReservoir = false; + } + visibleFractalSplatCount = activeFractalReservoirField.HasInput ? Math.Min(activeFractalReservoirField.SplatCount, MaxVisibleFractalSplatCount) : 0; @@ -3597,6 +4962,8 @@ public sealed class D3D12Renderer : IAquariumRenderer { gpuSensorCameras[index] = ToGpuSensorCameraPacket(scene.GpuSensorFrame.Cameras[index]); } + gpuSensorExternalTextureCount = Math.Min(scene.GpuSensorFrame.ExternalTextures.Count, MaxGpuSensorTextureCount); + AppendFieldEvidenceGpuSensorInputs(); acousticConstraintCount = Math.Min(scene.AcousticFieldFrame.Constraints.Count, MaxAcousticConstraintCount); for (var index = 0; index < acousticConstraintCount; index++) @@ -3636,7 +5003,6 @@ public sealed class D3D12Renderer : IAquariumRenderer return; } - gpuSensorTextureCount = Math.Min(scene.GpuSensorFrame.ExternalTextures.Count, MaxGpuSensorTextureCount); if (gpuSensorTextureCount > 0 && gpuSensorCameraCount > 0) { temporalGaussianCount = Math.Min(MaxTemporalGaussianCount, gpuSensorTextureCount * GpuSensorSamplesPerTexture); @@ -3652,6 +5018,263 @@ public sealed class D3D12Renderer : IAquariumRenderer } } + private D3D12BokushoBrushConstants BuildBokushoPageConstants() + { + if (!activeBokushoBrushFrame.HasInput) + { + return new D3D12BokushoBrushConstants( + Vector4.Zero, + Vector4.Zero, + Vector4.Zero, + Vector4.Zero, + Vector4.Zero, + Vector4.Zero, + Vector4.Zero, + Vector4.Zero, + Vector4.Zero); + } + + var frame = activeBokushoBrushFrame.Normalized(); + var sampleCount = Math.Clamp(frame.SampleCount, 2, MaxBokushoBrushSamples); + var tuftCount = Math.Clamp(frame.TuftCount, 1, MaxBokushoBrushTufts); + var strokeCount = Math.Min(PackBokushoBrushStrokes(frame).Length, MaxBokushoBrushStrokes); + return new D3D12BokushoBrushConstants( + new Vector4(sampleCount, tuftCount, frame.PhysicsHz, strokeCount), + new Vector4(frame.BrushRadius, frame.Pressure, frame.InkLoad, frame.Wetness), + new Vector4(frame.Splay, frame.Bend, frame.Friction, 0.0f), + new Vector4(frame.RadiusScale, frame.PressureScale, frame.NormalScale, frame.TangentScale), + frame.StrokeP0, + frame.StrokeP1, + frame.StrokeP2, + frame.StrokeP3, + new Vector4(activeViewCenter.X, activeViewCenter.Y, Math.Max(activeViewRadius, 0.001f), HeightFieldTextureSize)); + } + + private static int BoundedBokushoDebugStrokeCount(int strokeCount, int sampleCount, int tuftCount) + { + var maxStrokeCountForBuffer = Math.Max(1, MaxBokushoBrushValues / checked(sampleCount * tuftCount)); + return Math.Min(strokeCount, Math.Min(MaxBokushoBrushStrokes, maxStrokeCountForBuffer)); + } + + private static D3D12BokushoBrushStrokePacket[] PackBokushoBrushStrokes(AquariumBokushoBrushFrame frame) + { + if (frame.Strokes.Count > 0) + { + var sourceRanges = BokushoSourcePointRanges(frame); + var packets = new D3D12BokushoBrushStrokePacket[frame.Strokes.Count]; + for (var index = 0; index < packets.Length; index++) + { + var stroke = frame.Strokes[index].Normalized(); + sourceRanges.TryGetValue(stroke.SourceStrokeId, out var range); + packets[index] = PackBokushoBrushStroke(stroke, range); + } + + return packets; + } + + return + [ + new D3D12BokushoBrushStrokePacket( + new Vector4(frame.RadiusScale, frame.PressureScale, frame.NormalScale, frame.TangentScale), + new Vector4(0.10f, 0.14f, 1.0f, 1.0f), + new Vector4(0.0f, 0.0f, 1.0f, 1.0f), + new Vector4(frame.StrokeP0.X, frame.StrokeP0.Y, 0.0f, 1.0f), + frame.StrokeP1, + frame.StrokeP2, + frame.StrokeP3), + ]; + } + + private static D3D12BokushoBrushStrokePacket PackBokushoBrushStroke(AquariumBokushoBrushStroke stroke) + => PackBokushoBrushStroke(stroke, default); + + private static D3D12BokushoBrushStrokePacket PackBokushoBrushStroke(AquariumBokushoBrushStroke stroke, BokushoSourcePointRange sourceRange) + { + return new D3D12BokushoBrushStrokePacket( + new Vector4(stroke.RadiusScale, stroke.PressureScale, stroke.NormalScale, stroke.TangentScale), + new Vector4(stroke.EntryTaper, stroke.ExitTaper, stroke.PigmentScale, stroke.SplitScale), + new Vector4(stroke.ShaftTilt, stroke.ShaftRotation, stroke.GripHeight, stroke.Compliance), + new Vector4(stroke.StrokeP0.X, stroke.StrokeP0.Y, stroke.SegmentStart, stroke.SegmentEnd), + new Vector4(stroke.StrokeP1.X, stroke.StrokeP1.Y, sourceRange.Start, sourceRange.Count), + stroke.StrokeP2, + new Vector4(stroke.StrokeP3.X, stroke.StrokeP3.Y, stroke.StrokeP3.Z, stroke.SourceStrokeId)); + } + + private static D3D12BokushoSourcePointPacket[] PackBokushoSourcePoints(AquariumBokushoBrushFrame frame) + => frame.SourcePoints + .Select(point => point.Normalized()) + .Where(point => point.SourceStrokeId >= 0) + .OrderBy(point => point.SourceStrokeId) + .ThenBy(point => point.T) + .Take(MaxBokushoSourcePoints) + .Select(static point => new D3D12BokushoSourcePointPacket( + new Vector4(point.Position.X, point.Position.Y, point.T, point.SourceStrokeId))) + .ToArray(); + + private static Dictionary BokushoSourcePointRanges(AquariumBokushoBrushFrame frame) + { + var ranges = new Dictionary(); + var sorted = frame.SourcePoints + .Select(point => point.Normalized()) + .Where(point => point.SourceStrokeId >= 0) + .OrderBy(point => point.SourceStrokeId) + .ThenBy(point => point.T) + .Take(MaxBokushoSourcePoints) + .ToArray(); + var index = 0; + while (index < sorted.Length) + { + var sourceStrokeId = sorted[index].SourceStrokeId; + var start = index; + while (index < sorted.Length && sorted[index].SourceStrokeId == sourceStrokeId) + { + index++; + } + + ranges[sourceStrokeId] = new BokushoSourcePointRange(start, index - start); + } + + return ranges; + } + + private void AppendFieldEvidenceGpuSensorInputs() + { + gpuSensorTextureCount = gpuSensorExternalTextureCount; + if (!activeFieldEvidenceFrame.HasInput || + activeFieldLoweringPlan.Packets.Count == 0 || + gpuSensorCameraCount >= MaxGpuSensorCameraCount || + gpuSensorTextureCount >= MaxGpuSensorTextureCount) + { + return; + } + + var resources = activeFieldEvidenceFrame.Resources.ToDictionary(static resource => resource.ResourceKey, StringComparer.Ordinal); + var usedResources = new HashSet(StringComparer.Ordinal); + foreach (var packet in activeFieldLoweringPlan.Packets) + { + if (packet.Backend != AquariumFieldBackendKind.GpuSensorFusion || + packet.Encoding != AquariumFieldEncoding.Feature || + string.IsNullOrWhiteSpace(packet.PayloadHandle) || + !usedResources.Add(packet.PayloadHandle) || + gpuSensorCameraCount >= MaxGpuSensorCameraCount || + gpuSensorTextureCount >= MaxGpuSensorTextureCount || + !resources.TryGetValue(packet.PayloadHandle, out var declaration) || + declaration.Kind != AquariumFieldResourceKind.Texture2D || + !declaration.IsGpuVisible) + { + continue; + } + + if (!IsGpuSensorFusionSampleable(declaration) || + !TryGetFieldTexture2D(declaration.ResourceKey, out var texture)) + { + gpuSensorFieldUnsupportedTextureCount++; + continue; + } + + var firstTextureIndex = gpuSensorTextureCount; + gpuSensorFieldTextureKeys[gpuSensorFieldTextureCount] = declaration.ResourceKey; + gpuSensorFieldTextureCount++; + gpuSensorTextureCount++; + var width = Math.Max(1, texture.Width); + var height = Math.Max(1, texture.Height); + var kind = SensorKindForFieldTexture(declaration); + gpuSensorCameras[gpuSensorCameraCount] = ToGpuSensorCameraPacket(new AquariumGpuSensorCamera( + SensorId: declaration.ResourceKey, + Kind: kind, + WorldFromSensor: Matrix4x4.Identity, + SensorFromWorld: Matrix4x4.Identity, + Intrinsics: new Vector4(width, height, width * 0.5f, height * 0.5f), + Distortion01: Vector4.Zero, + Distortion23: Vector4.Zero, + Width: width, + Height: height, + FirstTextureIndex: firstTextureIndex, + TextureCount: 1, + TimestampNs: declaration.ValidFromNs > 0 ? declaration.ValidFromNs : unchecked((long)Math.Min(declaration.Version, (ulong)long.MaxValue)), + PixelFormat: PixelFormatForFieldTexture(declaration))); + gpuSensorCameraCount++; + } + } + + private static AquariumGpuSensorKind SensorKindForFieldTexture(AquariumFieldResourceDeclaration declaration) + { + if (declaration.Format.Equals("LeapStereoIr", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("LeapPackedMap", StringComparison.OrdinalIgnoreCase)) + { + return AquariumGpuSensorKind.LeapPackedMap; + } + + if (declaration.Format.Equals("Bayer8", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("Gray8", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("R8", StringComparison.OrdinalIgnoreCase) || + declaration.ResourceKey.Contains("ps3-eye", StringComparison.OrdinalIgnoreCase)) + { + return AquariumGpuSensorKind.HighRateTracker; + } + + return AquariumGpuSensorKind.RgbCamera; + } + + private static bool IsGpuSensorFusionSampleable(AquariumFieldResourceDeclaration declaration) => + declaration.Format.Equals("LeapStereoIr", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("LeapPackedMap", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("Bayer8", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("Gray8", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("R8", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("R8Unorm", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("R8_UNorm", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("Rg8", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("Rg8Unorm", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("R8G8_UNorm", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("Bgra8", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("Bgra8Unorm", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("B8G8R8A8_UNorm", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("Rgba8Unorm", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("R8G8B8A8_UNorm", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("Yuy2", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("YUY2", StringComparison.OrdinalIgnoreCase); + + private static AquariumGpuSensorPixelFormat PixelFormatForFieldTexture(AquariumFieldResourceDeclaration declaration) + { + if (declaration.Format.Equals("LeapStereoIr", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("LeapPackedMap", StringComparison.OrdinalIgnoreCase)) + { + return AquariumGpuSensorPixelFormat.LeapPackedMap; + } + + if (declaration.Format.Equals("Bayer8", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("Gray8", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("R8", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("R8Unorm", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("R8_UNorm", StringComparison.OrdinalIgnoreCase)) + { + return AquariumGpuSensorPixelFormat.R8Unorm; + } + + if (declaration.Format.Equals("Rg8", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("Rg8Unorm", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("R8G8_UNorm", StringComparison.OrdinalIgnoreCase)) + { + return AquariumGpuSensorPixelFormat.Rg8Unorm; + } + + if (declaration.Format.Equals("Bgra8", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("Bgra8Unorm", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("B8G8R8A8_UNorm", StringComparison.OrdinalIgnoreCase)) + { + return AquariumGpuSensorPixelFormat.Bgra8Unorm; + } + + if (declaration.Format.Equals("Yuy2", StringComparison.OrdinalIgnoreCase) || + declaration.Format.Equals("YUY2", StringComparison.OrdinalIgnoreCase)) + { + return AquariumGpuSensorPixelFormat.Yuy2; + } + + return AquariumGpuSensorPixelFormat.Rgba8Unorm; + } + private static D3D12AcousticConstraintPacket ToAcousticConstraintPacket(AquariumAcousticConstraint constraint) { var confidence = Math.Clamp(constraint.Confidence, 0.0f, 1.0f); @@ -3689,7 +5312,7 @@ public sealed class D3D12Renderer : IAquariumRenderer Math.Max(0, camera.FirstTextureIndex), camera.TextureCount, camera.TimestampNs * 0.000000001f, - 0.0f), + (int)camera.PixelFormat), Row0(camera.WorldFromSensor), Row1(camera.WorldFromSensor), Row2(camera.WorldFromSensor), @@ -3786,6 +5409,43 @@ public sealed class D3D12Renderer : IAquariumRenderer }; } + private AquariumFractalReservoirField BuildPointCloudSurfaceReservoirField() + { + var splatCount = 0; + foreach (var packet in activeFieldLoweringPlan.Packets) + { + if (packet.Backend != AquariumFieldBackendKind.Mesh || + packet.Encoding != AquariumFieldEncoding.Mesh || + !fieldResourceRegistry.TryGetMesh(packet.PayloadHandle, out var mesh) || + mesh.Topology != AquariumFieldMeshTopology.PointList || + !mesh.IsGeneratedFromResource || + !mesh.HasStandardImportedLayout) + { + continue; + } + + splatCount = Math.Max(splatCount, Math.Min(mesh.Vertices.ElementCount, mesh.Indices.ElementCount)); + } + + if (splatCount <= 0) + { + return AquariumFractalReservoirField.Empty; + } + + splatCount = Math.Min(splatCount, MaxVisibleFractalSplatCount); + return new AquariumFractalReservoirField + { + SplatCount = splatCount, + Depth = 1, + Seed = 0x1EA9D371u, + CandidatesPerReservoirUpdate = 1, + SplatUpdatesPerFrame = splatCount, + ReservoirUpdatesPerPass = splatCount, + WorldCenterRadius = new Vector4(0.0f, 0.0f, 0.0f, 1.0f), + PriorityFocus = new Vector4(0.0f, 0.0f, 2.0f, 1.0f), + }; + } + private static int EstimateTextureSplineColumnCount(AquariumBufferFieldFrame frame) { if (!frame.HasInput || frame.TextureSplineFields.Count == 0 || frame.Textures.Count == 0) @@ -4056,6 +5716,72 @@ public sealed class D3D12Renderer : IAquariumRenderer frameResources.FenceValue = signalValue; } + private void BeginGpuTiming(ID3D12GraphicsCommandList activeCommandList, FrameResources frameResources, D3D12GpuTimingPass pass) + { + var passIndex = (int)pass; + activeCommandList.EndQuery(frameResources.GpuTimingQueryHeap, QueryType.Timestamp, (uint)(passIndex * 2)); + frameResources.GpuTimingPassMask |= 1u << passIndex; + } + + private void EndGpuTiming(ID3D12GraphicsCommandList activeCommandList, FrameResources frameResources, D3D12GpuTimingPass pass) + { + activeCommandList.EndQuery(frameResources.GpuTimingQueryHeap, QueryType.Timestamp, (uint)(((int)pass * 2) + 1)); + } + + private void ResolveGpuTimings(ID3D12GraphicsCommandList activeCommandList, FrameResources frameResources, bool eligibleForAccumulation) + { + activeCommandList.ResolveQueryData( + frameResources.GpuTimingQueryHeap, + QueryType.Timestamp, + 0, + GpuTimingQueryCount, + frameResources.GpuTimingReadback, + 0); + frameResources.GpuTimingPendingPassMask = frameResources.GpuTimingPassMask; + frameResources.GpuTimingEligibleForAccumulation = eligibleForAccumulation; + frameResources.GpuTimingPassMask = 0; + } + + private unsafe void AccumulateCompletedGpuTimings(FrameResources frameResources) + { + if (frameResources.GpuTimingPendingPassMask == 0) + { + return; + } + + if (frameResources.GpuTimingEligibleForAccumulation && gpuTimestampFrequency > 0) + { + var timestamps = (ulong*)frameResources.GpuTimingReadback.Map(0); + try + { + for (var passIndex = 0; passIndex < (int)D3D12GpuTimingPass.Count; passIndex++) + { + if ((frameResources.GpuTimingPendingPassMask & (1u << passIndex)) == 0) + { + continue; + } + + var start = timestamps[passIndex * 2]; + var end = timestamps[(passIndex * 2) + 1]; + if (end < start) + { + continue; + } + + accumulatedGpuTimingMilliseconds[passIndex] += (end - start) * 1000.0 / gpuTimestampFrequency; + accumulatedGpuTimingPassFrames[passIndex]++; + } + } + finally + { + frameResources.GpuTimingReadback.Unmap(0); + } + } + + frameResources.GpuTimingPendingPassMask = 0; + frameResources.GpuTimingEligibleForAccumulation = false; + } + private float lastCapacityReportSecond = -1.0f; private void ReportCapacityOncePerSecond(float timeSeconds, FrameResources frameResources) @@ -4131,9 +5857,45 @@ public sealed class D3D12Renderer : IAquariumRenderer Console.WriteLine( $"D3D12 stereo depth: lowerings {activeFieldEvidenceFrame.StereoDepthLowerings.Count:N0}; " + $"dispatch-ready {activeStereoDepthDispatchReadyLowerings:N0}; " + + $"dispatched {activeStereoDepthDispatchedLowerings:N0}; " + $"unplanned {activeStereoDepthUnplannedLowerings:N0}; " + $"unresolved {activeStereoDepthUnresolvedLowerings:N0}; " + - "kernel not installed"); + "kernel packed-block-match"); + } + + if (activePlanetarySurfacePages.HasInput && planetarySurfacePageOutputBuffer is not null && planetarySurfacePageSummaryBuffer is not null && planetarySurfacePageMetadataBuffer is not null) + { + var residentBytes=planetarySurfacePageOutputBuffer.SizeBytes+planetarySurfacePageSummaryBuffer.SizeBytes+planetarySurfacePageMetadataBuffer.SizeBytes; + Console.WriteLine( + $"D3D12 planetary pages: pages {activePlanetarySurfacePages.Pages.Count:N0}; " + + $"samples {activePlanetarySurfacePages.SampleCount:N0}; " + + $"resident {residentBytes / 1024.0:0.0} KiB; " + + $"content-version {activePlanetarySurfacePages.ContentVersion}; " + + $"presentation-version {activePlanetarySurfacePages.PresentationVersion}"); + } + + if (gpuSensorTextureCount > 0 || + gpuSensorFieldUnsupportedTextureCount > 0) + { + Console.WriteLine( + $"D3D12 GPU sensor fusion: cameras {gpuSensorCameraCount:N0}; " + + $"textures {gpuSensorTextureCount:N0}; " + + $"field textures {gpuSensorFieldTextureCount:N0}; " + + $"unsupported field textures {gpuSensorFieldUnsupportedTextureCount:N0}; " + + $"samples/texture {GpuSensorSamplesPerTexture:N0}; " + + $"generated gaussians {(temporalGaussiansGpuGenerated ? temporalGaussianCount : 0):N0}"); + } + + if (activePointCloudGeneratedMeshes > 0 || + activePointCloudRenderedMeshes > 0 || + activePointCloudUnresolvedMeshes > 0 || + activePointCloudUnplannedMeshes > 0) + { + Console.WriteLine( + $"D3D12 point clouds: generated {activePointCloudGeneratedMeshes:N0}; " + + $"rendered {activePointCloudRenderedMeshes:N0}; " + + $"unresolved {activePointCloudUnresolvedMeshes:N0}; " + + $"unplanned {activePointCloudUnplannedMeshes:N0}"); } if (accumulatedTimingFrames > 0) @@ -4149,6 +5911,36 @@ public sealed class D3D12Renderer : IAquariumRenderer accumulatedOverlayCpuMilliseconds = 0.0; accumulatedTimingFrames = 0; } + + if (accumulatedGpuTimingPassFrames[(int)D3D12GpuTimingPass.FrameRecord] > 0) + { + Console.WriteLine( + "D3D12 GPU timing avg: " + + $"{FormatGpuTiming(D3D12GpuTimingPass.FrameRecord, "record")}; " + + $"{FormatGpuTiming(D3D12GpuTimingPass.GpuSensorFusion, "sensor-fusion")}; " + + $"{FormatGpuTiming(D3D12GpuTimingPass.StereoDepthUpdate, "stereo-depth")}; " + + $"{FormatGpuTiming(D3D12GpuTimingPass.FractalReservoirUpdate, "fractal-reservoir")}; " + + $"{FormatGpuTiming(D3D12GpuTimingPass.PlanetarySurfacePages, "planetary-pages")}; " + + $"{FormatGpuTiming(D3D12GpuTimingPass.PlanetarySurfacePatches, "planetary-patches")}; " + + $"{FormatGpuTiming(D3D12GpuTimingPass.TubeFieldUpdate, "tube-field")}; " + + $"{FormatGpuTiming(D3D12GpuTimingPass.HeightField, "height-field")}; " + + $"{FormatGpuTiming(D3D12GpuTimingPass.SceneCandidate, "scene-candidate")}; " + + $"{FormatGpuTiming(D3D12GpuTimingPass.FieldReservoirResolve, "reservoir-resolve")}; " + + $"{FormatGpuTiming(D3D12GpuTimingPass.ReservoirHistoryUpdate, "reservoir-update")}; " + + $"{FormatGpuTiming(D3D12GpuTimingPass.Bloom, "bloom")}; " + + $"{FormatGpuTiming(D3D12GpuTimingPass.PresentationResolve, "presentation")}"); + Array.Clear(accumulatedGpuTimingMilliseconds); + Array.Clear(accumulatedGpuTimingPassFrames); + } + } + + private string FormatGpuTiming(D3D12GpuTimingPass pass, string label) + { + var passIndex = (int)pass; + var frames = accumulatedGpuTimingPassFrames[passIndex]; + return frames > 0 + ? $"{label} {accumulatedGpuTimingMilliseconds[passIndex] / frames:0.###} ms/{frames}" + : $"{label} n/a"; } private static double ElapsedMilliseconds(long startTimestamp) @@ -4287,6 +6079,7 @@ public sealed class D3D12Renderer : IAquariumRenderer else { Console.Error.WriteLine($"D3D12 initial shader pipeline build failed; retrying after source change. {error}"); + throw new InvalidOperationException("D3D12 initial shader pipeline build failed.", error); } shaderReloadFailureReported = true; @@ -4326,21 +6119,30 @@ public sealed class D3D12Renderer : IAquariumRenderer splinePipelineState!, temporalGaussianPipelineState!, gpuSensorFusionPipelineState!, + stereoDepthPipelineState!, + pointCloudComputePipelineState!, + pointCloudRenderPipelineState!, fractalSurfaceSplatRenderPipelineState!, fractalTransparentSplatRenderPipelineState!, fractalSplatPipelineState!, fractalSdfReservoirPipelineState!, fractalPbrReservoirPipelineState!, fractalRadiosityReservoirPipelineState!, + planetarySurfacePagePipelineState!, + planetarySurfacePageSummaryPipelineState!, + planetarySurfacePatchPipelineState!, tubeFieldComputePipelineState!, tubeFieldRenderPipelineState!, + bokushoBrushPipelineState!, + bokushoPageClearPipelineState!, + bokushoPageDepositPipelineState!, fieldReservoirResolvePipelineState!, sdfProxyPipelineStates.Select(pipeline => pipeline!).ToArray(), bloomPrefilterPipelineState!, bloomDownsamplePipelineState!, bloomBlurHorizontalPipelineState!, bloomBlurVerticalPipelineState!, - reservoirHistoryUpdatePipelineState!, + reservoirHistoryUpdatePipelineState, resolvePipelineState!) : null; } @@ -4353,14 +6155,23 @@ public sealed class D3D12Renderer : IAquariumRenderer splinePipelineState = pipelines.Spline; temporalGaussianPipelineState = pipelines.TemporalGaussian; gpuSensorFusionPipelineState = pipelines.GpuSensorFusion; + stereoDepthPipelineState = pipelines.StereoDepth; + pointCloudComputePipelineState = pipelines.PointCloudCompute; + pointCloudRenderPipelineState = pipelines.PointCloudRender; fractalSurfaceSplatRenderPipelineState = pipelines.FractalSurfaceSplatRender; fractalTransparentSplatRenderPipelineState = pipelines.FractalTransparentSplatRender; fractalSplatPipelineState = pipelines.FractalSplat; fractalSdfReservoirPipelineState = pipelines.FractalSdfReservoir; fractalPbrReservoirPipelineState = pipelines.FractalPbrReservoir; fractalRadiosityReservoirPipelineState = pipelines.FractalRadiosityReservoir; + planetarySurfacePagePipelineState = pipelines.PlanetarySurfacePage; + planetarySurfacePageSummaryPipelineState = pipelines.PlanetarySurfacePageSummary; + planetarySurfacePatchPipelineState = pipelines.PlanetarySurfacePatch; tubeFieldComputePipelineState = pipelines.TubeFieldCompute; tubeFieldRenderPipelineState = pipelines.TubeFieldRender; + bokushoBrushPipelineState = pipelines.BokushoBrush; + bokushoPageClearPipelineState = pipelines.BokushoPageClear; + bokushoPageDepositPipelineState = pipelines.BokushoPageDeposit; fieldReservoirResolvePipelineState = pipelines.FieldReservoirResolve; for (var index = 0; index < sdfProxyPipelineStates.Length; index++) { @@ -4382,14 +6193,23 @@ public sealed class D3D12Renderer : IAquariumRenderer scenePipelineState = null; temporalGaussianPipelineState = null; gpuSensorFusionPipelineState = null; + stereoDepthPipelineState = null; + pointCloudComputePipelineState = null; + pointCloudRenderPipelineState = null; fractalSurfaceSplatRenderPipelineState = null; fractalTransparentSplatRenderPipelineState = null; fractalSplatPipelineState = null; fractalSdfReservoirPipelineState = null; fractalPbrReservoirPipelineState = null; fractalRadiosityReservoirPipelineState = null; + planetarySurfacePagePipelineState = null; + planetarySurfacePageSummaryPipelineState = null; + planetarySurfacePatchPipelineState = null; tubeFieldComputePipelineState = null; tubeFieldRenderPipelineState = null; + bokushoBrushPipelineState = null; + bokushoPageClearPipelineState = null; + bokushoPageDepositPipelineState = null; Array.Clear(sdfProxyPipelineStates); bloomPrefilterPipelineState = null; bloomDownsamplePipelineState = null; @@ -4559,6 +6379,12 @@ public sealed class D3D12Renderer : IAquariumRenderer 60, 0, D3D12.DescriptorRangeOffsetAppend); + var sceneOverdrawRange = new DescriptorRange( + DescriptorRangeType.ShaderResourceView, + 1, + 76, + 0, + D3D12.DescriptorRangeOffsetAppend); var rootParameters = new[] { new RootParameter(new RootDescriptorTable([constantBufferRange]), ShaderVisibility.All), @@ -4584,6 +6410,16 @@ public sealed class D3D12Renderer : IAquariumRenderer new RootParameter(new RootDescriptorTable([reservoirResolvedOutputRange]), ShaderVisibility.All), new RootParameter(RootParameterType.ShaderResourceView, new RootDescriptor(59, 0), ShaderVisibility.All), new RootParameter(new RootDescriptorTable([tubeFieldReplaySourceRange]), ShaderVisibility.All), + new RootParameter(new RootDescriptorTable([sceneOverdrawRange]), ShaderVisibility.Pixel), + new RootParameter(RootParameterType.ConstantBufferView, new RootDescriptor(5, 0), ShaderVisibility.All), + new RootParameter(RootParameterType.ShaderResourceView, new RootDescriptor(77, 0), ShaderVisibility.All), + new RootParameter(RootParameterType.ShaderResourceView, new RootDescriptor(78, 0), ShaderVisibility.All), + new RootParameter(RootParameterType.ShaderResourceView, new RootDescriptor(79, 0), ShaderVisibility.All), + new RootParameter(RootParameterType.ShaderResourceView, new RootDescriptor(80, 0), ShaderVisibility.All), + new RootParameter(RootParameterType.ShaderResourceView, new RootDescriptor(81, 0), ShaderVisibility.All), + new RootParameter(RootParameterType.ShaderResourceView, new RootDescriptor(82, 0), ShaderVisibility.All), + new RootParameter(RootParameterType.ShaderResourceView, new RootDescriptor(83, 0), ShaderVisibility.All), + new RootParameter(RootParameterType.ShaderResourceView, new RootDescriptor(84, 0), ShaderVisibility.All), }; var staticSamplers = new[] { @@ -4609,6 +6445,18 @@ public sealed class D3D12Renderer : IAquariumRenderer return device.CreateRootSignature(0, in description, RootSignatureVersion.Version1); } + private ID3D12RootSignature CreatePlanetarySurfacePageRootSignature() + { + var parameters = new[] + { + new RootParameter(RootParameterType.ShaderResourceView, new RootDescriptor(0, 0), ShaderVisibility.All), + new RootParameter(RootParameterType.UnorderedAccessView, new RootDescriptor(0, 0), ShaderVisibility.All), + new RootParameter(new RootConstants(0, 0, 3), ShaderVisibility.All), + }; + var description = new RootSignatureDescription(RootSignatureFlags.None, parameters, []); + return device.CreateRootSignature(0, in description, RootSignatureVersion.Version1); + } + private ID3D12RootSignature CreateGpuSensorFusionRootSignature() { var frameRange = new DescriptorRange( @@ -4670,6 +6518,75 @@ public sealed class D3D12Renderer : IAquariumRenderer return device.CreateRootSignature(0, in description, RootSignatureVersion.Version1); } + private ID3D12RootSignature CreatePointCloudRootSignature() + { + var disparityRange = new DescriptorRange( + DescriptorRangeType.ShaderResourceView, + 1, + 0, + 0, + D3D12.DescriptorRangeOffsetAppend); + var frameConstantsRange = new DescriptorRange( + DescriptorRangeType.ConstantBufferView, + 1, + 0, + 0, + D3D12.DescriptorRangeOffsetAppend); + var vertexRange = new DescriptorRange( + DescriptorRangeType.UnorderedAccessView, + 1, + 0, + 0, + D3D12.DescriptorRangeOffsetAppend); + var indexRange = new DescriptorRange( + DescriptorRangeType.UnorderedAccessView, + 1, + 1, + 0, + D3D12.DescriptorRangeOffsetAppend); + var splatRange = new DescriptorRange( + DescriptorRangeType.UnorderedAccessView, + 1, + 2, + 0, + D3D12.DescriptorRangeOffsetAppend); + var sdfReservoirRange = new DescriptorRange( + DescriptorRangeType.UnorderedAccessView, + 1, + 3, + 0, + D3D12.DescriptorRangeOffsetAppend); + var pbrReservoirRange = new DescriptorRange( + DescriptorRangeType.UnorderedAccessView, + 1, + 4, + 0, + D3D12.DescriptorRangeOffsetAppend); + var radiosityReservoirRange = new DescriptorRange( + DescriptorRangeType.UnorderedAccessView, + 1, + 5, + 0, + D3D12.DescriptorRangeOffsetAppend); + var rootParameters = new[] + { + new RootParameter(new RootDescriptorTable([frameConstantsRange]), ShaderVisibility.All), + new RootParameter(RootParameterType.ConstantBufferView, new RootDescriptor(1, 0), ShaderVisibility.All), + new RootParameter(new RootDescriptorTable([disparityRange]), ShaderVisibility.All), + new RootParameter(new RootDescriptorTable([vertexRange]), ShaderVisibility.All), + new RootParameter(new RootDescriptorTable([indexRange]), ShaderVisibility.All), + new RootParameter(new RootDescriptorTable([splatRange]), ShaderVisibility.All), + new RootParameter(new RootDescriptorTable([sdfReservoirRange]), ShaderVisibility.All), + new RootParameter(new RootDescriptorTable([pbrReservoirRange]), ShaderVisibility.All), + new RootParameter(new RootDescriptorTable([radiosityReservoirRange]), ShaderVisibility.All), + }; + var description = new RootSignatureDescription( + RootSignatureFlags.None, + rootParameters, + []); + return device.CreateRootSignature(0, in description, RootSignatureVersion.Version1); + } + private ID3D12RootSignature CreateFractalReservoirRootSignature() { var rootParameters = new[] @@ -4734,6 +6651,25 @@ public sealed class D3D12Renderer : IAquariumRenderer return device.CreateRootSignature(0, in description, RootSignatureVersion.Version1); } + private ID3D12RootSignature CreateBokushoBrushRootSignature() + { + var rootParameters = new[] + { + new RootParameter(RootParameterType.ConstantBufferView, new RootDescriptor(4, 0), ShaderVisibility.All), + new RootParameter(RootParameterType.UnorderedAccessView, new RootDescriptor(20, 0), ShaderVisibility.All), + new RootParameter(RootParameterType.UnorderedAccessView, new RootDescriptor(21, 0), ShaderVisibility.All), + new RootParameter(RootParameterType.UnorderedAccessView, new RootDescriptor(22, 0), ShaderVisibility.All), + new RootParameter(RootParameterType.ShaderResourceView, new RootDescriptor(78, 0), ShaderVisibility.All), + new RootParameter(RootParameterType.UnorderedAccessView, new RootDescriptor(23, 0), ShaderVisibility.All), + new RootParameter(RootParameterType.ShaderResourceView, new RootDescriptor(81, 0), ShaderVisibility.All), + }; + var description = new RootSignatureDescription( + RootSignatureFlags.None, + rootParameters, + []); + return device.CreateRootSignature(0, in description, RootSignatureVersion.Version1); + } + private ID3D12CommandSignature CreateGeneratedMeshDrawCommandSignature() { var arguments = new[] @@ -4816,13 +6752,22 @@ public sealed class D3D12Renderer : IAquariumRenderer 128, DescriptorHeapFlags.ShaderVisible, $"Aquarium D3D12 Frame {index} Transient Shader Descriptor Arena"); + var gpuTimingQueryHeap = device.CreateQueryHeap( + new QueryHeapDescription(QueryHeapType.Timestamp, GpuTimingQueryCount)); + gpuTimingQueryHeap.Name = $"Aquarium D3D12 Frame {index} GPU Timing Query Heap"; + var gpuTimingReadback = device.CreateCommittedResource( + HeapType.Readback, + ResourceDescription.Buffer((ulong)(GpuTimingQueryCount * sizeof(ulong))), + ResourceStates.CopyDest, + null); + gpuTimingReadback.Name = $"Aquarium D3D12 Frame {index} GPU Timing Readback"; - return new FrameResources(commandAllocator, uploadRing, transientDescriptors); + return new FrameResources(commandAllocator, uploadRing, transientDescriptors, gpuTimingQueryHeap, gpuTimingReadback); } private ID3D12PipelineState CreateScenePipelineState(string path) { - return CreateFullscreenPipelineState(path, "FullscreenTriangleVS", "D3D12ScenePS", [SceneHdrFormat, SceneHdrFormat, SceneHdrFormat, SceneHdrFormat], enableDepth: true); + return CreateFullscreenPipelineState(path, "FullscreenTriangleVS", "D3D12ScenePS", SceneEvidenceRenderTargetFormats, CreateSceneEvidenceBlend(), enableDepth: true); } private ID3D12PipelineState CreateSplinePipelineState(string path) @@ -4830,6 +6775,7 @@ public sealed class D3D12Renderer : IAquariumRenderer var vertexShader = CompileShader(path, "D3D12SplineVS", "vs_5_0"); var pixelShader = CompileShader(path, "D3D12SplinePS", "ps_5_0"); var blend = BlendDescription.Opaque; + blend.IndependentBlendEnable = true; for (var index = 1; index < 8; index++) { blend.RenderTarget[index] = new RenderTargetBlendDescription( @@ -4844,6 +6790,7 @@ public sealed class D3D12Renderer : IAquariumRenderer LogicOp.Noop, index < 4 ? ColorWriteEnable.All : ColorWriteEnable.None); } + blend.RenderTarget[4] = AdditiveOverdrawBlend(); var description = new GraphicsPipelineStateDescription { @@ -4873,7 +6820,7 @@ public sealed class D3D12Renderer : IAquariumRenderer new InputElementDescription("COLOR", 0, Format.R32G32B32A32_Float, 92, 0), new InputElementDescription("TEXCOORD", 6, Format.R32G32B32A32_Float, 108, 0), ]), - RenderTargetFormats = [SceneHdrFormat, SceneHdrFormat, SceneHdrFormat, SceneHdrFormat], + RenderTargetFormats = SceneEvidenceRenderTargetFormats, SampleDescription = new SampleDescription(1, 0), DepthStencilFormat = SceneDepthFormat, }; @@ -4890,17 +6837,17 @@ public sealed class D3D12Renderer : IAquariumRenderer private ID3D12PipelineState CreateSdfObjectProxyPipelineState(string path) { - return CreateFullscreenPipelineState(path, "D3D12SdfObjectProxyVS", "D3D12SdfProxyPS", [SceneHdrFormat, SceneHdrFormat, SceneHdrFormat, SceneHdrFormat], enableDepth: true); + return CreateFullscreenPipelineState(path, "D3D12SdfObjectProxyVS", "D3D12SdfProxyPS", SceneEvidenceRenderTargetFormats, CreateSceneEvidenceBlend(), enableDepth: true); } private ID3D12PipelineState CreateTemporalGaussianPipelineState(string path) { - return CreateFullscreenPipelineState(path, "D3D12TemporalGaussianVS", "D3D12TemporalGaussianPS", [SceneHdrFormat, SceneHdrFormat, SceneHdrFormat, SceneHdrFormat], enableDepth: true); + return CreateFullscreenPipelineState(path, "D3D12TemporalGaussianVS", "D3D12TemporalGaussianPS", SceneEvidenceRenderTargetFormats, CreateSceneEvidenceBlend(), enableDepth: true); } private ID3D12PipelineState CreateFractalSurfaceSplatRenderPipelineState(string path) { - return CreateFullscreenPipelineState(path, "D3D12FractalSplatVS", "D3D12FractalSurfaceSplatPS", [SceneHdrFormat, SceneHdrFormat, SceneHdrFormat, SceneHdrFormat], enableDepth: true); + return CreateFullscreenPipelineState(path, "D3D12FractalSplatVS", "D3D12FractalSurfaceSplatPS", SceneEvidenceRenderTargetFormats, CreateSceneEvidenceBlend(), enableDepth: true); } private ID3D12PipelineState CreateFractalTransparentSplatRenderPipelineState(string path) @@ -4909,7 +6856,7 @@ public sealed class D3D12Renderer : IAquariumRenderer path, "D3D12FractalSplatVS", "D3D12FractalTransparentSplatPS", - [SceneHdrFormat, SceneHdrFormat, SceneHdrFormat, SceneHdrFormat], + SceneEvidenceRenderTargetFormats, CreateTransparentFieldBlend(), enableDepth: false); } @@ -4925,6 +6872,94 @@ public sealed class D3D12Renderer : IAquariumRenderer return device.CreateComputePipelineState(description); } + private ID3D12PipelineState CreatePlanetarySurfacePagePipelineState(string path) + { + var shader = CompileShader(path, "D3D12ZyphosTerrainPageCS", "cs_5_0"); + return device.CreateComputePipelineState(new ComputePipelineStateDescription + { + RootSignature = planetarySurfacePageRootSignature, + ComputeShader = shader, + }); + } + + private ID3D12PipelineState CreatePlanetarySurfacePageSummaryPipelineState(string path) + { + var shader = CompileShader(path, "D3D12ZyphosTerrainPageSummaryCS", "cs_5_0"); + return device.CreateComputePipelineState(new ComputePipelineStateDescription + { + RootSignature = planetarySurfacePageRootSignature, + ComputeShader = shader, + }); + } + + private ID3D12PipelineState CreatePlanetarySurfacePatchPipelineState(string path) + { + var description=new GraphicsPipelineStateDescription + { + RootSignature=fullscreenRootSignature,VertexShader=CompileShader(path,"D3D12ZyphosTerrainPatchVS","vs_5_0"),PixelShader=CompileShader(path,"D3D12ZyphosTerrainPatchPS","ps_5_0"), + BlendState=CreateSceneEvidenceBlend(),RasterizerState=RasterizerDescription.CullNone, + DepthStencilState=new DepthStencilDescription { DepthEnable=true,DepthWriteMask=DepthWriteMask.All,DepthFunc=ComparisonFunction.LessEqual,StencilEnable=false }, + SampleMask=uint.MaxValue,PrimitiveTopologyType=PrimitiveTopologyType.Triangle,RenderTargetFormats=SceneEvidenceRenderTargetFormats,SampleDescription=new SampleDescription(1,0),DepthStencilFormat=SceneDepthFormat, + }; + return device.CreateGraphicsPipelineState(description); + } + + private ID3D12PipelineState CreateStereoDepthPipelineState(string path) + { + var computeShader = CompileShader(path, "D3D12PackedStereoDepthCS", "cs_5_0"); + var description = new ComputePipelineStateDescription + { + RootSignature = gpuSensorFusionRootSignature, + ComputeShader = computeShader, + }; + return device.CreateComputePipelineState(description); + } + + private ID3D12PipelineState CreatePointCloudComputePipelineState(string path) + { + var computeShader = CompileShader(path, "D3D12PointCloudFromDisparityCS", "cs_5_0"); + var description = new ComputePipelineStateDescription + { + RootSignature = pointCloudRootSignature, + ComputeShader = computeShader, + }; + return device.CreateComputePipelineState(description); + } + + private ID3D12PipelineState CreatePointCloudRenderPipelineState(string path) + { + var vertexShader = CompileShader(path, "D3D12PointCloudVS", "vs_5_0"); + var pixelShader = CompileShader(path, "D3D12PointCloudPS", "ps_5_0"); + var description = new GraphicsPipelineStateDescription + { + RootSignature = fullscreenRootSignature, + VertexShader = vertexShader, + PixelShader = pixelShader, + BlendState = CreateSceneEvidenceBlend(), + RasterizerState = RasterizerDescription.CullNone, + DepthStencilState = new DepthStencilDescription + { + DepthEnable = true, + DepthWriteMask = DepthWriteMask.All, + DepthFunc = ComparisonFunction.LessEqual, + StencilEnable = false, + }, + SampleMask = uint.MaxValue, + PrimitiveTopologyType = PrimitiveTopologyType.Point, + InputLayout = new InputLayoutDescription( + [ + new InputElementDescription("POSITION", 0, Format.R32G32B32_Float, 0, 0), + new InputElementDescription("NORMAL", 0, Format.R32G32B32_Float, 12, 0), + new InputElementDescription("TEXCOORD", 0, Format.R32G32_Float, 24, 0), + new InputElementDescription("COLOR", 0, Format.R32G32B32A32_Float, 32, 0), + ]), + RenderTargetFormats = SceneEvidenceRenderTargetFormats, + SampleDescription = new SampleDescription(1, 0), + DepthStencilFormat = SceneDepthFormat, + }; + return device.CreateGraphicsPipelineState(description); + } + private ID3D12PipelineState CreateFractalReservoirPipelineState(string path, string entryPoint) { var computeShader = CompileShader(path, entryPoint, "cs_5_0"); @@ -4947,6 +6982,20 @@ public sealed class D3D12Renderer : IAquariumRenderer return device.CreateComputePipelineState(description); } + private ID3D12PipelineState CreateBokushoBrushPipelineState(string path) + => CreateBokushoBrushPipelineState(path, "D3D12BokushoBrushCS"); + + private ID3D12PipelineState CreateBokushoBrushPipelineState(string path, string entryPoint) + { + var computeShader = CompileShader(path, entryPoint, "cs_5_0"); + var description = new ComputePipelineStateDescription + { + RootSignature = bokushoBrushRootSignature, + ComputeShader = computeShader, + }; + return device.CreateComputePipelineState(description); + } + private ID3D12PipelineState CreateTubeFieldRenderPipelineState(string path) { var vertexShader = CompileShader(path, "D3D12TubeFieldVS", "vs_5_0"); @@ -4976,7 +7025,7 @@ public sealed class D3D12Renderer : IAquariumRenderer new InputElementDescription("TEXCOORD", 6, Format.R32G32B32A32_Float, 108, 0), new InputElementDescription("TEXCOORD", 7, Format.R32G32B32A32_Float, 124, 0), ]), - RenderTargetFormats = [SceneHdrFormat, SceneHdrFormat, SceneHdrFormat, SceneHdrFormat], + RenderTargetFormats = SceneEvidenceRenderTargetFormats, SampleDescription = new SampleDescription(1, 0), }; @@ -5001,6 +7050,7 @@ public sealed class D3D12Renderer : IAquariumRenderer LogicOp.Noop, ColorWriteEnable.None); } + blend.RenderTarget[4] = AdditiveOverdrawBlend(); return blend; } @@ -5036,13 +7086,25 @@ public sealed class D3D12Renderer : IAquariumRenderer private ID3D12PipelineState CreateReservoirHistoryUpdatePipelineState(string path) { - var computeShader = CompileShader(path, "D3D12ReservoirHistoryUpdateCS", "cs_5_0"); + static void Step(string name) + { + if (Environment.GetEnvironmentVariable("AQUARIUM_PIPELINE_DIAG") == "1") + { + Console.WriteLine($"D3D12 pipeline build step: {name}"); + } + } + + Step("reservoir-history-update compile"); + var computeShader = CompileShader(path, "D3D12ReservoirHistoryUpdateCS", "cs_5_0", skipOptimizationInDebug: false); + Step("reservoir-history-update create-pso"); var description = new ComputePipelineStateDescription { RootSignature = fullscreenRootSignature, ComputeShader = computeShader, }; - return device.CreateComputePipelineState(description); + var pipelineState = device.CreateComputePipelineState(description); + Step("reservoir-history-update done"); + return pipelineState; } private ID3D12PipelineState CreateResolvePipelineState(string path) @@ -5099,10 +7161,34 @@ public sealed class D3D12Renderer : IAquariumRenderer logicOp: LogicOp.Noop, renderTargetWriteMask: ColorWriteEnable.None); } + blend.RenderTarget[4] = AdditiveOverdrawBlend(); return blend; } + private static BlendDescription CreateSceneEvidenceBlend() + { + var blend = BlendDescription.Opaque; + blend.IndependentBlendEnable = true; + blend.RenderTarget[4] = AdditiveOverdrawBlend(); + return blend; + } + + private static RenderTargetBlendDescription AdditiveOverdrawBlend() + { + return new RenderTargetBlendDescription( + blendEnable: true, + logicOpEnable: false, + srcBlend: Blend.One, + destBlend: Blend.One, + blendOp: BlendOperation.Add, + srcBlendAlpha: Blend.One, + destBlendAlpha: Blend.One, + blendOpAlpha: BlendOperation.Add, + logicOp: LogicOp.Noop, + renderTargetWriteMask: ColorWriteEnable.All); + } + private ID3D12PipelineState CreateFullscreenPipelineState( string path, string vertexEntryPoint, @@ -5165,19 +7251,9 @@ public sealed class D3D12Renderer : IAquariumRenderer private static ReadOnlyMemory CompileShader(string path, string entryPoint, string profile, bool skipOptimizationInDebug = true) { - var shaderFlags = ShaderFlags.EnableStrictness; -#if DEBUG - shaderFlags |= ShaderFlags.Debug; - if (skipOptimizationInDebug) - { - shaderFlags |= ShaderFlags.SkipOptimization; - } -#endif - - var source = ExpandShaderIncludes(path, []); try { - return Compiler.Compile(source, entryPoint, path, profile, shaderFlags, EffectFlags.None); + return D3D12ShaderCompiler.Compile(path, entryPoint, profile, skipOptimizationInDebug); } catch (Exception ex) { @@ -5185,43 +7261,6 @@ public sealed class D3D12Renderer : IAquariumRenderer } } - private static string ExpandShaderIncludes(string path, HashSet stack) - { - var fullPath = Path.GetFullPath(path); - if (!stack.Add(fullPath)) - { - throw new InvalidOperationException($"Circular shader include detected at {fullPath}"); - } - - var lines = File.ReadAllLines(fullPath); - var expanded = new List(lines.Length); - var directory = Path.GetDirectoryName(fullPath) ?? AppContext.BaseDirectory; - for (var lineIndex = 0; lineIndex < lines.Length; lineIndex++) - { - var line = lines[lineIndex]; - var trimmed = line.TrimStart(); - if (trimmed.StartsWith("#include \"", StringComparison.Ordinal)) - { - var firstQuote = trimmed.IndexOf('"'); - var secondQuote = trimmed.IndexOf('"', firstQuote + 1); - if (firstQuote >= 0 && secondQuote > firstQuote) - { - var includeName = trimmed.Substring(firstQuote + 1, secondQuote - firstQuote - 1); - var includePath = Path.GetFullPath(Path.Combine(directory, includeName)); - expanded.Add($"#line 1 \"{includePath.Replace("\\", "\\\\")}\""); - expanded.Add(ExpandShaderIncludes(includePath, stack)); - expanded.Add($"#line {lineIndex + 2} \"{fullPath.Replace("\\", "\\\\")}\""); - continue; - } - } - - expanded.Add(line); - } - - stack.Remove(fullPath); - return string.Join(Environment.NewLine, expanded); - } - private readonly record struct D3D12PassContext( ID3D12GraphicsCommandList CommandList, D3D12TrackedResource BackBuffer, @@ -5303,6 +7342,33 @@ public sealed class D3D12Renderer : IAquariumRenderer string RampResourceKey, int DrawArgumentOffsetBytes); + [StructLayout(LayoutKind.Sequential)] + private readonly record struct D3D12BokushoBrushConstants( + Vector4 Shape, + Vector4 Brush, + Vector4 Dynamics, + Vector4 Profile, + Vector4 StrokeP0, + Vector4 StrokeP1, + Vector4 StrokeP2, + Vector4 StrokeP3, + Vector4 PageView); + + [StructLayout(LayoutKind.Sequential)] + private readonly record struct D3D12BokushoBrushStrokePacket( + Vector4 Profile, + Vector4 Dynamics, + Vector4 Pose, + Vector4 StrokeP0, + Vector4 StrokeP1, + Vector4 StrokeP2, + Vector4 StrokeP3); + + [StructLayout(LayoutKind.Sequential)] + private readonly record struct D3D12BokushoSourcePointPacket(Vector4 Point); + + private readonly record struct BokushoSourcePointRange(int Start, int Count); + private readonly record struct D3D12PipelinePrivateGeneratedMesh( D3D12StructuredBuffer Vertices, D3D12StructuredBuffer Indices, @@ -5372,23 +7438,42 @@ public sealed class D3D12Renderer : IAquariumRenderer } } + [StructLayout(LayoutKind.Sequential)] + private readonly record struct D3D12StereoDepthConstants( + Vector4 Shape, + Vector4 Match, + Vector4 Output); + + [StructLayout(LayoutKind.Sequential)] + private readonly record struct D3D12PointCloudConstants( + Vector4 SourceShape, + Vector4 Projection, + Vector4 DepthRange); + private sealed record D3D12ShaderPaths( string HeightField, string Scene, string Spline, string TemporalGaussian, string GpuSensorFusion, + string StereoDepth, + string PointCloud, string TubeField, + string BokushoBrush, string FractalReservoir, string FractalSplatRender, + string PlanetarySurfacePage, + string PlanetarySurfacePageSummary, + string PlanetarySurfacePatch, string SdfCommon, string SdfProxy, IReadOnlyList SdfShaders, string SdfMath, IReadOnlyList Includes, - string Post) + string Post, + string ReservoirHistoryUpdate) { - public IReadOnlyList All { get; } = [HeightField, Scene, Spline, TemporalGaussian, GpuSensorFusion, TubeField, FractalReservoir, FractalSplatRender, SdfCommon, SdfProxy, ..SdfShaders, SdfMath, ..Includes, Post]; + public IReadOnlyList All { get; } = [HeightField, Scene, Spline, TemporalGaussian, GpuSensorFusion, StereoDepth, PointCloud, TubeField, BokushoBrush, FractalReservoir, FractalSplatRender, PlanetarySurfacePage, PlanetarySurfacePageSummary, PlanetarySurfacePatch, SdfCommon, SdfProxy, ..SdfShaders, SdfMath, ..Includes, Post, ReservoirHistoryUpdate]; public static D3D12ShaderPaths FromManifest(string root, AquariumShaderManifest manifest) { @@ -5402,15 +7487,22 @@ public sealed class D3D12Renderer : IAquariumRenderer shaderPath("D3D12Scene.hlsl"), shaderPath(manifest.TemporalGaussianShader), shaderPath(manifest.GpuSensorFusionShader), + shaderPath("D3D12StereoDepth.hlsl"), + shaderPath("D3D12PointCloud.hlsl"), shaderPath("D3D12TubeField.hlsl"), + shaderPath("D3D12BokushoBrush.hlsl"), shaderPath(manifest.FractalReservoirShader), shaderPath(manifest.FractalSplatRenderShader), + shaderPath(manifest.PlanetarySurfacePageShader), + shaderPath(manifest.PlanetarySurfacePageSummaryShader), + shaderPath(manifest.PlanetarySurfacePatchShader), shaderPath(manifest.SdfCommonInclude), shaderPath(manifest.SdfProxyInclude), manifest.SdfShaderPaths.Select(shaderPath).ToArray(), shaderPath(manifest.SdfMathInclude), [.. includes.Select(shaderPath), shaderPath("D3D12Aces2.hlsl")], - shaderPath(manifest.PostShader)); + shaderPath(manifest.PostShader), + shaderPath("D3D12ReservoirHistoryUpdate.hlsl")); } } @@ -5421,35 +7513,53 @@ public sealed class D3D12Renderer : IAquariumRenderer ID3D12PipelineState Spline, ID3D12PipelineState TemporalGaussian, ID3D12PipelineState GpuSensorFusion, + ID3D12PipelineState StereoDepth, + ID3D12PipelineState PointCloudCompute, + ID3D12PipelineState PointCloudRender, ID3D12PipelineState FractalSurfaceSplatRender, ID3D12PipelineState FractalTransparentSplatRender, ID3D12PipelineState FractalSplat, ID3D12PipelineState FractalSdfReservoir, ID3D12PipelineState FractalPbrReservoir, ID3D12PipelineState FractalRadiosityReservoir, + ID3D12PipelineState PlanetarySurfacePage, + ID3D12PipelineState PlanetarySurfacePageSummary, + ID3D12PipelineState PlanetarySurfacePatch, ID3D12PipelineState TubeFieldCompute, ID3D12PipelineState TubeFieldRender, + ID3D12PipelineState BokushoBrush, + ID3D12PipelineState BokushoPageClear, + ID3D12PipelineState BokushoPageDeposit, ID3D12PipelineState FieldReservoirResolve, IReadOnlyList SdfProxies, ID3D12PipelineState BloomPrefilter, ID3D12PipelineState BloomDownsample, ID3D12PipelineState BloomBlurHorizontal, ID3D12PipelineState BloomBlurVertical, - ID3D12PipelineState ReservoirHistoryUpdate, + ID3D12PipelineState? ReservoirHistoryUpdate, ID3D12PipelineState Resolve) : IDisposable { public void Dispose() { Resolve.Dispose(); - ReservoirHistoryUpdate.Dispose(); + ReservoirHistoryUpdate?.Dispose(); BloomBlurVertical.Dispose(); BloomBlurHorizontal.Dispose(); BloomDownsample.Dispose(); BloomPrefilter.Dispose(); FieldReservoirResolve.Dispose(); + BokushoPageDeposit.Dispose(); + BokushoPageClear.Dispose(); + BokushoBrush.Dispose(); FractalRadiosityReservoir.Dispose(); + PlanetarySurfacePage.Dispose(); + PlanetarySurfacePageSummary.Dispose(); + PlanetarySurfacePatch.Dispose(); TubeFieldRender.Dispose(); TubeFieldCompute.Dispose(); + PointCloudRender.Dispose(); + PointCloudCompute.Dispose(); + StereoDepth.Dispose(); FractalPbrReservoir.Dispose(); FractalSdfReservoir.Dispose(); FractalSplat.Dispose(); @@ -5470,10 +7580,8 @@ public sealed class D3D12Renderer : IAquariumRenderer } private sealed record SharedTextureLeaseSlot( - ID3D12Resource Resource, + D3D12FieldTexture2D Texture, ID3D12Fence ProducerFence, - IntPtr NativeHandle, - IntPtr ProducerFenceHandle, int Width, int Height, Format Format, @@ -5484,20 +7592,12 @@ public sealed class D3D12Renderer : IAquariumRenderer public ulong WaitedProducerFenceValue { get; set; } + public IntPtr NativeHandle => IntPtr.Zero; + public void Dispose() { - if (ProducerFenceHandle != IntPtr.Zero) - { - CloseHandle(ProducerFenceHandle); - } - - if (NativeHandle != IntPtr.Zero) - { - CloseHandle(NativeHandle); - } - ProducerFence.Dispose(); - Resource.Dispose(); + Texture.Dispose(); } } @@ -5515,10 +7615,30 @@ public sealed class D3D12Renderer : IAquariumRenderer } } + private enum D3D12GpuTimingPass + { + FrameRecord, + GpuSensorFusion, + StereoDepthUpdate, + FractalReservoirUpdate, + PlanetarySurfacePages, + PlanetarySurfacePatches, + TubeFieldUpdate, + HeightField, + SceneCandidate, + FieldReservoirResolve, + ReservoirHistoryUpdate, + Bloom, + PresentationResolve, + Count, + } + private sealed class FrameResources( ID3D12CommandAllocator commandAllocator, D3D12UploadRing uploadRing, - D3D12DescriptorArena transientShaderDescriptors) : IDisposable + D3D12DescriptorArena transientShaderDescriptors, + ID3D12QueryHeap gpuTimingQueryHeap, + ID3D12Resource gpuTimingReadback) : IDisposable { public ID3D12CommandAllocator CommandAllocator { get; } = commandAllocator; @@ -5526,6 +7646,16 @@ public sealed class D3D12Renderer : IAquariumRenderer public D3D12DescriptorArena TransientShaderDescriptors { get; } = transientShaderDescriptors; + public ID3D12QueryHeap GpuTimingQueryHeap { get; } = gpuTimingQueryHeap; + + public ID3D12Resource GpuTimingReadback { get; } = gpuTimingReadback; + + public uint GpuTimingPassMask { get; set; } + + public uint GpuTimingPendingPassMask { get; set; } + + public bool GpuTimingEligibleForAccumulation { get; set; } + public D3D12DescriptorSlot FrameConstantsDescriptor { get; set; } public D3D12DescriptorSlot HeightFieldBrushConstantsDescriptor { get; set; } @@ -5572,6 +7702,8 @@ public sealed class D3D12Renderer : IAquariumRenderer public D3D12DescriptorSlot SceneCandidateReservoirGuideDescriptor { get; set; } + public D3D12DescriptorSlot SceneOverdrawDescriptor { get; set; } + public D3D12DescriptorSlot ReservoirResolvedDescriptor { get; set; } public D3D12DescriptorSlot ReservoirResolvedUnorderedAccessDescriptor { get; set; } @@ -5595,6 +7727,8 @@ public sealed class D3D12Renderer : IAquariumRenderer public void Dispose() { BackBuffer.Dispose(); + GpuTimingReadback.Dispose(); + GpuTimingQueryHeap.Dispose(); TransientShaderDescriptors.Dispose(); UploadRing.Dispose(); CommandAllocator.Dispose(); diff --git a/src/Aquarium.Engine/Render/D3D12ShaderCompiler.cs b/src/Aquarium.Engine/Render/D3D12ShaderCompiler.cs new file mode 100644 index 0000000..3147a3b --- /dev/null +++ b/src/Aquarium.Engine/Render/D3D12ShaderCompiler.cs @@ -0,0 +1,105 @@ +using Vortice.D3DCompiler; + +namespace Aquarium.Engine.Render; + +internal static class D3D12ShaderCompiler +{ + public static ReadOnlyMemory Compile(string path, string entryPoint, string profile, bool skipOptimizationInDebug = true) + { + var flags = ShaderFlags.EnableStrictness; +#if DEBUG + flags |= ShaderFlags.Debug; + if (skipOptimizationInDebug) flags |= ShaderFlags.SkipOptimization; +#endif + var source = ExpandIncludes(path, []); + return Compiler.Compile(source, entryPoint, path, profile, flags, EffectFlags.None); + } + + internal static string ExpandIncludes(string path, HashSet stack) + { + var fullPath = Path.GetFullPath(path); + if (!stack.Add(fullPath)) throw new InvalidOperationException($"Circular shader include detected at {fullPath}"); + var lines = File.ReadAllLines(fullPath); + var expanded = new List(lines.Length); + var directory = Path.GetDirectoryName(fullPath) ?? AppContext.BaseDirectory; + for (var index = 0; index < lines.Length; index++) + { + var trimmed = lines[index].TrimStart(); + if (trimmed.StartsWith("#include \"", StringComparison.Ordinal)) + { + var first = trimmed.IndexOf('"'); + var second = trimmed.IndexOf('"', first + 1); + if (second > first) + { + var include = ResolveInclude(directory, trimmed.Substring(first + 1, second - first - 1)); + expanded.Add($"#line 1 \"{include.Replace("\\", "\\\\")}\""); + expanded.Add(ExpandIncludes(include, stack)); + expanded.Add($"#line {index + 2} \"{fullPath.Replace("\\", "\\\\")}\""); + continue; + } + } + expanded.Add(lines[index]); + } + stack.Remove(fullPath); + return string.Join(Environment.NewLine, expanded); + } + + private static string ResolveInclude(string directory, string includeName) + { + var local = Path.GetFullPath(Path.Combine(directory, includeName)); + if (File.Exists(local)) return local; + var normalized = includeName.Replace('\\', '/'); + if (normalized.StartsWith("CultMath/", StringComparison.Ordinal)) + { + var relative = normalized["CultMath/".Length..].Replace('/', Path.DirectorySeparatorChar); + foreach (var start in new[] { directory, AppContext.BaseDirectory }) + { + var current = Path.GetFullPath(start); + while (!string.IsNullOrEmpty(current)) + { + var candidate = Path.Combine(current, "CultMath", "shaders", relative); + if (File.Exists(candidate)) return candidate; + var parent = Path.GetDirectoryName(current); + if (string.IsNullOrEmpty(parent) || parent == current) break; + current = parent; + } + } + } + const string cultMathUnityPrefix = "Packages/org.gamecult.cultmath/shaders/"; + if (normalized.StartsWith(cultMathUnityPrefix, StringComparison.Ordinal)) + { + var relative = normalized[cultMathUnityPrefix.Length..].Replace('/', Path.DirectorySeparatorChar); + foreach (var start in new[] { directory, AppContext.BaseDirectory }) + { + var current = Path.GetFullPath(start); + while (!string.IsNullOrEmpty(current)) + { + var localPackage = Path.Combine(current, "CultMath", relative); + if (File.Exists(localPackage)) return localPackage; + var siblingRepository = Path.Combine(current, "CultMath", "shaders", relative); + if (File.Exists(siblingRepository)) return siblingRepository; + var parent = Path.GetDirectoryName(current); + if (string.IsNullOrEmpty(parent) || parent == current) break; + current = parent; + } + } + } + if (normalized.StartsWith("GameCult.Geometry/", StringComparison.Ordinal)) + { + var relative = normalized["GameCult.Geometry/".Length..].Replace('/', Path.DirectorySeparatorChar); + foreach (var start in new[] { directory, AppContext.BaseDirectory }) + { + var current = Path.GetFullPath(start); + while (!string.IsNullOrEmpty(current)) + { + var candidate = Path.Combine(current, "CultLib", "src", "GameCult.Geometry", "Shaders", relative); + if (File.Exists(candidate)) return candidate; + var parent = Path.GetDirectoryName(current); + if (string.IsNullOrEmpty(parent) || parent == current) break; + current = parent; + } + } + } + return local; + } +} diff --git a/src/Aquarium.Engine/Render/DirectWriteOverlay.cs b/src/Aquarium.Engine/Render/DirectWriteOverlay.cs index 7e7b982..5ae2c80 100644 --- a/src/Aquarium.Engine/Render/DirectWriteOverlay.cs +++ b/src/Aquarium.Engine/Render/DirectWriteOverlay.cs @@ -1,9 +1,11 @@ +using System.Numerics; using Vortice.DCommon; using Vortice.Direct2D1; using Vortice.DirectWrite; using Vortice.DXGI; using Vortice.Mathematics; using Aquarium.Engine.Render.Ui; +using Aquarium.Engine.Ui; using D2DFactoryType = Vortice.Direct2D1.FactoryType; using DWriteFactoryType = Vortice.DirectWrite.FactoryType; @@ -16,6 +18,7 @@ internal sealed class DirectWriteOverlay : IDisposable private readonly IDWriteFontCollection fontCollection; private readonly IDWriteTypography smallCapsTypography; private readonly ID2D1RenderTarget renderTarget; + private readonly ID2D1Bitmap outputSnapshot; private readonly ID2D1SolidColorBrush primaryTextBrush; private readonly ID2D1SolidColorBrush quietTextBrush; private readonly ID2D1SolidColorBrush panelBrush; @@ -65,6 +68,14 @@ internal sealed class DirectWriteOverlay : IDisposable renderTarget = direct2DFactory.CreateDxgiSurfaceRenderTarget(backBufferSurface, renderTargetProperties); renderTarget.AntialiasMode = AntialiasMode.PerPrimitive; renderTarget.TextAntialiasMode = Vortice.Direct2D1.TextAntialiasMode.Grayscale; + outputSnapshot = renderTarget.CreateBitmap( + new SizeI(width, height), + IntPtr.Zero, + 0, + new BitmapProperties( + new PixelFormat(Format.B8G8R8A8_UNorm, Vortice.DCommon.AlphaMode.Ignore), + 96.0f, + 96.0f)); primaryTextBrush = renderTarget.CreateSolidColorBrush(new Color4(0.88f, 0.96f, 1.0f, 0.92f)); quietTextBrush = renderTarget.CreateSolidColorBrush(new Color4(0.54f, 0.68f, 0.72f, 0.72f)); @@ -84,8 +95,10 @@ internal sealed class DirectWriteOverlay : IDisposable monospaceFormat = CreateTextFormat("Ubuntu Sans Mono", 11.0f, FontWeight.Regular, ParagraphAlignment.Near); } - public void Render(AquariumFrame frame, int renderDebugMode, DebugUi? debugUi, IReadOnlyList clientUiPanels, string performanceText) + public void Render(AquariumFrame frame, int renderDebugMode, DebugUi? debugUi, IReadOnlyList clientUiPanels, IReadOnlyList clientUiSurfaces, string performanceText) { + CaptureOutputSnapshot(); + renderTarget.BeginDraw(); DrawPerformanceCounter(performanceText); if (debugUi is not null) @@ -98,6 +111,11 @@ internal sealed class DirectWriteOverlay : IDisposable DrawPanel(panel, panel.DrawOpacity()); } + foreach (var surface in clientUiSurfaces) + { + DrawSurface(surface); + } + renderTarget.EndDraw(); } @@ -174,6 +192,387 @@ internal sealed class DirectWriteOverlay : IDisposable } } + private void DrawSurface(AquariumUiSurface surface) + { + var edgePadding = surface.ContentPadding <= 0.0f ? 0.0f : 8.0f; + var bounds = RectFromEdges( + Math.Clamp(surface.Bounds.Left, edgePadding, Math.Max(edgePadding, width - 80.0f)), + Math.Clamp(surface.Bounds.Top, edgePadding, Math.Max(edgePadding, height - 48.0f)), + Math.Clamp(surface.Bounds.Left + surface.Bounds.Width, 80.0f + edgePadding, width - edgePadding), + Math.Clamp(surface.Bounds.Top + surface.Bounds.Height, 48.0f + edgePadding, height - edgePadding)); + renderTarget.FillRectangle(bounds, panelBrush); + renderTarget.DrawRectangle(bounds, outlineBrush, 1.0f); + var hasTitle = !string.IsNullOrWhiteSpace(surface.Title); + if (hasTitle) + { + DrawHeader(surface.Title, titleFormat, RectFromEdges(bounds.Left + 12.0f, bounds.Top + 8.0f, bounds.Right - 12.0f, bounds.Top + 34.0f), primaryTextBrush); + } + + var contentTop = hasTitle ? bounds.Top + 42.0f : bounds.Top + surface.ContentPadding; + var content = RectFromEdges(bounds.Left + surface.ContentPadding, contentTop, bounds.Right - surface.ContentPadding, bounds.Bottom - surface.ContentPadding); + DrawSurfaceChildren(surface.Root.Children ?? [], content, surface.Root.Layout ?? AquariumUiLayout.Vertical()); + } + + private void DrawSurfaceChildren(IReadOnlyList elements, Rect bounds, AquariumUiLayout layout) + { + var visible = elements.Where(static element => element.Visible).ToArray(); + if (visible.Length == 0) + { + return; + } + + var content = RectFromEdges( + bounds.Left + layout.Padding, + bounds.Top + layout.Padding, + bounds.Right - layout.Padding, + bounds.Bottom - layout.Padding); + var totalGap = layout.Gap * Math.Max(0, visible.Length - 1); + var horizontal = string.Equals(layout.Direction, "horizontal", StringComparison.Ordinal); + var cursor = horizontal ? content.Left : content.Top; + var available = Math.Max(0.0f, (horizontal ? content.Width : content.Height) - totalGap); + var preferred = visible.Select(element => PreferredExtent(element, horizontal)).ToArray(); + var preferredTotal = preferred.Sum(static value => value ?? 0.0f); + var preferredScale = preferredTotal > available && preferredTotal > 0.0f ? available / preferredTotal : 1.0f; + var flexibleWeight = Math.Max(0.001f, visible.Where((_, index) => preferred[index] is null).Sum(static element => Math.Max(0.001f, element.Weight))); + var flexibleAvailable = Math.Max(0.0f, available - preferredTotal * preferredScale); + for (var index = 0; index < visible.Length; index++) + { + var element = visible[index]; + var extent = preferred[index] is { } fixedExtent + ? fixedExtent * preferredScale + : flexibleAvailable * Math.Max(0.001f, element.Weight) / flexibleWeight; + var childBounds = horizontal + ? RectFromEdges(cursor, content.Top, Math.Min(content.Right, cursor + extent), content.Bottom) + : RectFromEdges(content.Left, cursor, content.Right, Math.Min(content.Bottom, cursor + extent)); + DrawSurfaceElement(element, childBounds); + cursor += extent + layout.Gap; + } + } + + private void DrawSurfaceElement(AquariumUiElement element, Rect bounds) + { + switch (element.Kind) + { + case "group": + DrawSurfaceChildren(element.Children ?? [], bounds, element.Layout ?? AquariumUiLayout.Vertical()); + break; + case "pane": + var hasPaneTitle = !string.IsNullOrWhiteSpace(element.Text); + DrawSurfaceFrame(bounds, element.Text, element.Style?.Tone ?? "neutral", drawTitle: hasPaneTitle); + var paneContent = hasPaneTitle + ? RectFromEdges(bounds.Left + 6.0f, bounds.Top + 32.0f, bounds.Right - 6.0f, bounds.Bottom - 6.0f) + : bounds; + DrawSurfaceChildren(element.Children ?? [], paneContent, element.Layout ?? AquariumUiLayout.Vertical()); + break; + case "card": + DrawSurfaceFrame(bounds, null, element.Style?.Tone ?? "neutral", drawTitle: false); + DrawSurfaceChildren(element.Children ?? [], bounds, element.Layout ?? AquariumUiLayout.Vertical(4.0f, 8.0f)); + break; + case "metric": + DrawMetricElement(element, bounds); + break; + case "toggle": + DrawToggleElement(element, bounds); + break; + case "slider": + DrawSliderElement(element, bounds); + break; + case "select": + DrawSelectElement(element, bounds); + break; + case "button": + DrawButtonElement(element, bounds); + break; + case "preview": + DrawPreviewElement(element, bounds); + break; + default: + DrawTextElement(element, bounds); + break; + } + } + + private void DrawSurfaceFrame(Rect bounds, string? titleText, string tone, bool drawTitle) + { + renderTarget.FillRectangle(bounds, tone == "danger" ? activeRowBrush : tone == "warm" ? hoverRowBrush : rowBrush); + renderTarget.DrawRectangle(bounds, ToneBrush(tone), 1.0f); + if (drawTitle && !string.IsNullOrWhiteSpace(titleText)) + { + DrawHeader(titleText, smallFormat, RectFromEdges(bounds.Left + 8.0f, bounds.Top + 6.0f, bounds.Right - 8.0f, bounds.Top + 28.0f), accentBrush); + } + } + + private void DrawTextElement(AquariumUiElement element, Rect bounds) + { + var text = element.ReadText?.Invoke() ?? element.Text ?? ""; + var format = element.Role is "mono" ? monospaceFormat : smallFormat; + var brush = element.Role is "caption" ? quietTextBrush : element.Role is "strong" or "title" ? primaryTextBrush : primaryTextBrush; + renderTarget.DrawText(text, format, bounds, brush, DrawTextOptions.Clip); + } + + private void DrawMetricElement(AquariumUiElement element, Rect bounds) + { + var value = element.ReadMetric?.Invoke() ?? 0.0; + var normalized = Math.Clamp(value, 0.0, 1.0); + DrawSurfaceFrame(bounds, null, element.Style?.Tone ?? "neutral", drawTitle: false); + renderTarget.DrawText($"{element.Text}: {value:0.000}", smallFormat, RectFromEdges(bounds.Left + 8.0f, bounds.Top + 2.0f, bounds.Right - 8.0f, bounds.Top + 18.0f), primaryTextBrush, DrawTextOptions.Clip); + var track = RectFromEdges(bounds.Left + 8.0f, bounds.Bottom - 10.0f, bounds.Right - 8.0f, bounds.Bottom - 5.0f); + renderTarget.FillRectangle(track, dimAccentBrush); + renderTarget.FillRectangle(RectFromEdges(track.Left, track.Top, track.Left + track.Width * (float)normalized, track.Bottom), ToneBrush(element.Style?.Tone ?? "neutral")); + } + + private void DrawToggleElement(AquariumUiElement element, Rect bounds) + { + var isOn = element.ReadToggle?.Invoke() ?? false; + DrawSurfaceFrame(bounds, null, isOn ? "cool" : "neutral", drawTitle: false); + renderTarget.DrawText(element.Text ?? "", smallFormat, RectFromEdges(bounds.Left + 8.0f, bounds.Top, bounds.Right - 36.0f, bounds.Bottom), primaryTextBrush, DrawTextOptions.Clip); + var box = RectFromEdges(bounds.Right - 26.0f, bounds.Top + 7.0f, bounds.Right - 10.0f, bounds.Top + 23.0f); + renderTarget.DrawRectangle(box, isOn ? accentBrush : outlineBrush, 1.0f); + if (isOn) + { + renderTarget.FillRectangle(RectFromEdges(box.Left + 3.0f, box.Top + 3.0f, box.Right - 3.0f, box.Bottom - 3.0f), accentBrush); + } + } + + private void DrawSliderElement(AquariumUiElement element, Rect bounds) + { + var raw = element.ReadFloat?.Invoke() ?? element.Min; + var span = Math.Max(0.0001f, element.Max - element.Min); + var normalized = Math.Clamp((raw - element.Min) / span, 0.0f, 1.0f); + DrawSurfaceFrame(bounds, null, "neutral", drawTitle: false); + renderTarget.DrawText($"{element.Text}: {raw.ToString(element.Format, System.Globalization.CultureInfo.InvariantCulture)}", smallFormat, RectFromEdges(bounds.Left + 8.0f, bounds.Top, bounds.Left + 170.0f, bounds.Bottom), primaryTextBrush, DrawTextOptions.Clip); + var track = RectFromEdges(bounds.Left + 178.0f, bounds.Top + 13.0f, bounds.Right - 12.0f, bounds.Top + 18.0f); + renderTarget.FillRectangle(track, dimAccentBrush); + renderTarget.FillRectangle(RectFromEdges(track.Left, track.Top, track.Left + track.Width * normalized, track.Bottom), accentBrush); + } + + private void DrawSelectElement(AquariumUiElement element, Rect bounds) + { + var selected = element.ReadOption?.Invoke() ?? 0; + var label = element.Options?.FirstOrDefault(option => option.Value == selected).Label ?? selected.ToString(System.Globalization.CultureInfo.InvariantCulture); + DrawSurfaceFrame(bounds, null, "neutral", drawTitle: false); + renderTarget.DrawText(element.Text ?? "", smallFormat, RectFromEdges(bounds.Left + 8.0f, bounds.Top, bounds.Left + 130.0f, bounds.Bottom), accentBrush, DrawTextOptions.Clip); + renderTarget.DrawText(label, smallFormat, RectFromEdges(bounds.Left + 138.0f, bounds.Top, bounds.Right - 8.0f, bounds.Bottom), primaryTextBrush, DrawTextOptions.Clip); + } + + private void DrawButtonElement(AquariumUiElement element, Rect bounds) + { + DrawSurfaceFrame(bounds, null, "warm", drawTitle: false); + renderTarget.DrawText(element.Text ?? "", smallFormat, RectFromEdges(bounds.Left + 8.0f, bounds.Top, bounds.Right - 8.0f, bounds.Bottom), primaryTextBrush, DrawTextOptions.Clip); + } + + private void DrawPreviewElement(AquariumUiElement element, Rect bounds) + { + var hasLabel = !string.IsNullOrWhiteSpace(element.Text); + if (hasLabel) + { + DrawSurfaceFrame(bounds, null, "neutral", drawTitle: false); + renderTarget.DrawText(element.Text ?? "", smallFormat, RectFromEdges(bounds.Left + 8.0f, bounds.Top + 6.0f, bounds.Right - 8.0f, bounds.Top + 24.0f), quietTextBrush, DrawTextOptions.Clip); + } + + var padded = hasLabel + ? RectFromEdges(bounds.Left + 10.0f, bounds.Top + 30.0f, bounds.Right - 10.0f, bounds.Bottom - 10.0f) + : RectFromEdges(bounds.Left + 2.0f, bounds.Top + 2.0f, bounds.Right - 2.0f, bounds.Bottom - 2.0f); + if (padded.Width <= 1.0f || padded.Height <= 1.0f) + { + return; + } + + var targetAspect = 16.0f / 9.0f; + var canvasWidth = padded.Width; + var canvasHeight = canvasWidth / targetAspect; + if (canvasHeight > padded.Height) + { + canvasHeight = padded.Height; + canvasWidth = canvasHeight * targetAspect; + } + + var canvas = RectFromEdges( + padded.Left + (padded.Width - canvasWidth) * 0.5f, + padded.Top + (padded.Height - canvasHeight) * 0.5f, + padded.Left + (padded.Width + canvasWidth) * 0.5f, + padded.Top + (padded.Height + canvasHeight) * 0.5f); + if (element.PreviewBlitsOutputBuffer) + { + DrawOutputSnapshot(canvas); + } + else + { + renderTarget.FillRectangle(canvas, panelBrush); + } + + renderTarget.DrawRectangle(canvas, outlineBrush, 1.0f); + DrawPreviewGuides(element, canvas); + + var items = element.ReadPreviewItems?.Invoke() ?? []; + var state = element.ReadPreviewState?.Invoke() ?? default; + foreach (var item in items) + { + var left = canvas.Left + Math.Clamp(item.X, 0.0f, 1.0f) * canvas.Width; + var top = canvas.Top + Math.Clamp(item.Y, 0.0f, 1.0f) * canvas.Height; + var right = canvas.Left + Math.Clamp(item.X + item.Width, 0.0f, 1.0f) * canvas.Width; + var bottom = canvas.Top + Math.Clamp(item.Y + item.Height, 0.0f, 1.0f) * canvas.Height; + var itemBounds = RectFromEdges(left, top, right, bottom); + if (itemBounds.Width <= 1.0f || itemBounds.Height <= 1.0f) + { + continue; + } + + var hovered = string.Equals(state.HoverItemId, item.Id, StringComparison.Ordinal); + var active = string.Equals(state.ActiveItemId, item.Id, StringComparison.Ordinal); + var brush = active || item.Selected ? accentBrush : ToneBrush(item.Tone); + renderTarget.FillRectangle(itemBounds, active ? activeRowBrush : hovered ? hoverRowBrush : rowBrush); + renderTarget.DrawRectangle(itemBounds, brush, active || item.Selected ? 2.0f : hovered ? 1.5f : 1.0f); + renderTarget.DrawText(item.Label, smallFormat, RectFromEdges(itemBounds.Left + 6.0f, itemBounds.Top + 3.0f, itemBounds.Right - 6.0f, itemBounds.Bottom), primaryTextBrush, DrawTextOptions.Clip); + if (item.Selected && element.HandlePreviewInteraction is not null) + { + DrawPreviewHandle(itemBounds.Left, itemBounds.Top, state, item.Id, "nw"); + DrawPreviewHandle(itemBounds.Right, itemBounds.Top, state, item.Id, "ne"); + DrawPreviewHandle(itemBounds.Left, itemBounds.Bottom, state, item.Id, "sw"); + DrawPreviewHandle(itemBounds.Right, itemBounds.Bottom, state, item.Id, "se"); + } + } + } + + private void DrawPreviewGuides(AquariumUiElement element, Rect canvas) + { + var guides = element.ReadPreviewGuides?.Invoke() ?? []; + foreach (var guide in guides) + { + var position = Math.Clamp(guide.Position, 0.0f, 1.0f); + var brush = ToneBrush(guide.Tone); + if (string.Equals(guide.Axis, "x", StringComparison.Ordinal)) + { + var x = canvas.Left + position * canvas.Width; + renderTarget.DrawLine(new Vector2(x, canvas.Top), new Vector2(x, canvas.Bottom), brush, 1.25f); + } + else if (string.Equals(guide.Axis, "y", StringComparison.Ordinal)) + { + var y = canvas.Top + position * canvas.Height; + renderTarget.DrawLine(new Vector2(canvas.Left, y), new Vector2(canvas.Right, y), brush, 1.25f); + } + } + } + + private void DrawPreviewHandle(float x, float y, AquariumUiPreviewState state, string itemId, string handleId) + { + var active = string.Equals(state.ActiveItemId, itemId, StringComparison.Ordinal) && + string.Equals(state.ActiveHandle, handleId, StringComparison.Ordinal); + var hovered = string.Equals(state.HoverItemId, itemId, StringComparison.Ordinal) && + string.Equals(state.HoverHandle, handleId, StringComparison.Ordinal); + var radius = active ? 6.0f : hovered ? 5.0f : 4.0f; + var handle = RectFromEdges(x - radius, y - radius, x + radius, y + radius); + renderTarget.FillRectangle(handle, active ? accentActiveBrush : hovered ? accentHoverBrush : accentBrush); + renderTarget.DrawRectangle(handle, panelBrush, 1.0f); + } + + private void CaptureOutputSnapshot() + { + try + { + outputSnapshot.CopyFromRenderTarget(renderTarget); + } + catch + { + // The overlay must never kill the frame if the swapchain surface is not copyable. + } + } + + private void DrawOutputSnapshot(Rect canvas) + { + if (canvas.Width <= 1.0f || canvas.Height <= 1.0f) + { + return; + } + + var sourceWidth = (float)Math.Max(1, width); + var sourceHeight = (float)Math.Max(1, height); + var sourceAspect = sourceWidth / sourceHeight; + var targetAspect = canvas.Width / Math.Max(1.0f, canvas.Height); + if (sourceAspect > targetAspect) + { + sourceWidth = sourceHeight * targetAspect; + } + else + { + sourceHeight = sourceWidth / targetAspect; + } + + var source = new Rect( + (width - sourceWidth) * 0.5f, + (height - sourceHeight) * 0.5f, + sourceWidth, + sourceHeight); + renderTarget.DrawBitmap(outputSnapshot, canvas, 1.0f, BitmapInterpolationMode.Linear, source); + } + + private ID2D1SolidColorBrush ToneBrush(string tone) => + tone switch + { + "cool" => primaryTextBrush, + "warm" => accentBrush, + "danger" => accentActiveBrush, + _ => outlineBrush, + }; + + private static float? PreferredExtent(AquariumUiElement element, bool horizontal) + { + if (element.FixedExtent is { } fixedExtent) + { + return Math.Max(0.0f, fixedExtent); + } + + if (horizontal) + { + return null; + } + + var weight = Math.Max(0.001f, element.Weight); + return element.Kind switch + { + "group" or "pane" or "card" when HasFlexibleChild(element) => null, + "group" or "pane" or "card" => PreferredContainerExtent(element) * weight, + "text" when element.Role is "mono" => 18.0f * weight, + "text" when element.Role is "strong" or "title" => 22.0f * weight, + "text" => 18.0f * weight, + "toggle" or "select" or "slider" => 24.0f * weight, + "button" => 30.0f * weight, + "metric" => 36.0f * weight, + _ => null, + }; + } + + private static bool HasFlexibleChild(AquariumUiElement element) + { + var children = element.Children?.Where(static child => child.Visible).ToArray() ?? []; + return children.Any(static child => PreferredExtent(child, horizontal: false) is null); + } + + private static float PreferredContainerExtent(AquariumUiElement element) + { + var layout = element.Layout ?? AquariumUiLayout.Vertical(); + var children = element.Children?.Where(static child => child.Visible).ToArray() ?? []; + if (children.Length == 0) + { + return element.Kind == "pane" ? 72.0f : 24.0f; + } + + var horizontal = string.Equals(layout.Direction, "horizontal", StringComparison.Ordinal); + var childExtents = children.Select(static child => PreferredExtent(child, horizontal: false) ?? 48.0f * Math.Max(0.001f, child.Weight)).ToArray(); + var contentExtent = horizontal + ? childExtents.Max() + : childExtents.Sum() + layout.Gap * Math.Max(0, childExtents.Length - 1); + contentExtent += layout.Padding * 2.0f; + + return element.Kind switch + { + "pane" when !string.IsNullOrWhiteSpace(element.Text) => contentExtent + 38.0f, + "pane" => contentExtent, + "card" => contentExtent, + _ => contentExtent, + }; + } + public void Dispose() { smallFormat.Dispose(); @@ -194,6 +593,7 @@ internal sealed class DirectWriteOverlay : IDisposable panelBrush.Dispose(); quietTextBrush.Dispose(); primaryTextBrush.Dispose(); + outputSnapshot.Dispose(); renderTarget.Dispose(); directWriteFactory.Dispose(); direct2DFactory.Dispose(); diff --git a/src/Aquarium.Engine/Render/IAquariumRenderer.cs b/src/Aquarium.Engine/Render/IAquariumRenderer.cs index cf94a54..ddda9c6 100644 --- a/src/Aquarium.Engine/Render/IAquariumRenderer.cs +++ b/src/Aquarium.Engine/Render/IAquariumRenderer.cs @@ -27,4 +27,8 @@ public interface IAquariumRenderer : IAquariumFieldResourceBroker, IDisposable void Render(AquariumFrame frame, int width, int height); void SaveFramePng(string path); + + void SaveBokushoBrushCanvasRaw(string path); + + void SaveBokushoPageDensityRaw(string path); } diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12AdvancedErosionParity.hlsl b/src/Aquarium.Engine/Render/Shaders/D3D12AdvancedErosionParity.hlsl new file mode 100644 index 0000000..574cad4 --- /dev/null +++ b/src/Aquarium.Engine/Render/Shaders/D3D12AdvancedErosionParity.hlsl @@ -0,0 +1,23 @@ +#include "GameCult.Geometry/GameCult.Geometry.hlsl" + +struct ParityInput { float4 position_base; float4 slope_fade; float4 scalar0; float4 rounding; float4 onset; float4 scalar1; float4 scalar2; float4 scalar3; }; +struct ParityOutput { float4 delta_magnitude; float4 ridge_fade; }; +StructuredBuffer inputs : register(t0); +RWStructuredBuffer outputs : register(u0); +cbuffer ParityConstants : register(b0) { uint input_count; } + +[numthreads(64, 1, 1)] +void D3D12AdvancedErosionParityCS(uint3 id : SV_DispatchThreadID) +{ + if (id.x >= input_count) return; + ParityInput input = inputs[id.x]; + GameCultGeometryAdvancedErosionParameters p; + p.scale=input.scalar0.x; p.strength=input.scalar0.y; p.gully_weight=input.scalar0.z; p.detail=input.scalar0.w; + p.rounding=input.rounding; p.onset=input.onset; p.assumed_slope=input.scalar1.xy; + p.cell_scale=input.scalar1.z; p.normalization=input.scalar1.w; p.octaves=(int)input.scalar2.x; + p.lacunarity=input.scalar2.y; p.gain=input.scalar2.z; + GameCultGeometryErosionBandSelection band; band.active_octaves=(int)input.scalar3.x; band.final_octave_weight=input.scalar3.y; band.finest_included_wavelength=input.scalar3.z; band.unresolved_height_bound=input.scalar3.w; + GameCultGeometryAdvancedErosionResult r = gamecult_geometry_advanced_erosion_filter_banded(input.position_base.xy, float3(input.position_base.z, input.slope_fade.xy), input.slope_fade.z, p, band); + outputs[id.x].delta_magnitude=float4(r.delta, r.magnitude); + outputs[id.x].ridge_fade=float4(r.ridge_map, r.fade_target, 0, 0); +} diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12BokushoBrush.hlsl b/src/Aquarium.Engine/Render/Shaders/D3D12BokushoBrush.hlsl new file mode 100644 index 0000000..0b06d1f --- /dev/null +++ b/src/Aquarium.Engine/Render/Shaders/D3D12BokushoBrush.hlsl @@ -0,0 +1,699 @@ +#include "CultMath/CultMath.hlsl" + +static const float BOKUSHO_PAGE_DENSITY_SCALE = 65535.0; +static const float BOKUSHO_PAGE_PATCH_TRANSFER_GAIN = 0.0044; +static const float BOKUSHO_PAGE_PATCH_TANGENT_RADIUS_SCALE = 0.32; +static const float BOKUSHO_PAGE_PATCH_NORMAL_RADIUS_SCALE = 0.32; +static const float BOKUSHO_PAGE_PATCH_MINIMUM_RADIUS = 0.001; +static const float BOKUSHO_PAGE_PATCH_WORLD_QUANTUM = 1.0 / 1024.0; +static const float BOKUSHO_BRISTLE_TANGENT_WORLD_QUANTUM = 1.0 / 4096.0; +static const float BOKUSHO_STROKE_RHYTHM_QUANTUM = 1.0 / 4096.0; +static const uint BOKUSHO_PAGE_PATCH_INK_UNITS = 2048u; +static const uint BOKUSHO_PAGE_PATCH_COVERAGE_UNITS = 1024u; +static const uint BOKUSHO_PAGE_PATCH_TRANSFER_ENCODED_GAIN = 288u; +static const uint BOKUSHO_PAGE_DENSITY_CONTRIBUTION_QUANTUM = 8u; + +cbuffer BokushoBrushConstants : register(b4) +{ + float4 brushShape; // sampleCount, tuftCount, physicsHz, strokeCount + float4 brushMaterial; // radius, pressure, inkLoad, wetness + float4 brushDynamics; // splay, bend, friction, reserved + float4 brushProfile; // radiusScale, pressureScale, normalScale, tangentScale + float4 strokeP0; + float4 strokeP1; + float4 strokeP2; + float4 strokeP3; + float4 pageView; // center.xy, radius, pageSize +}; + +struct BokushoBrushStroke +{ + float4 profile; + float4 dynamics; + float4 pose; + float4 p0; + float4 p1; + float4 p2; + float4 p3; +}; + +RWStructuredBuffer BokushoTraceField : register(u20); +RWStructuredBuffer BokushoCanvasField : register(u21); +RWStructuredBuffer BokushoTipField : register(u22); +RWStructuredBuffer BokushoPageDensityField : register(u23); +StructuredBuffer BokushoBrushStrokes : register(t78); +StructuredBuffer BokushoSourcePoints : register(t81); + +float QuantizeScalar(float value, float quantum) +{ + return quantum > 0.0 ? floor((value / quantum) + 0.5) * quantum : value; +} + +float2 QuantizeFloat2(float2 value, float quantum) +{ + return float2(QuantizeScalar(value.x, quantum), QuantizeScalar(value.y, quantum)); +} + +float QuantizePositive(float value, float quantum) +{ + return QuantizeScalar(max(value, 0.0), quantum); +} + +uint QuantizePositiveUnits(float value, uint units) +{ + return (uint)floor(saturate(value) * (float)units + 0.5); +} + +float2 PacketStrokePoint(BokushoBrushStroke stroke, float t) +{ + return cultmath_catmullrom(stroke.p0.xy, stroke.p1.xy, stroke.p2.xy, stroke.p3.xy, t); +} + +bool TrySampleSourcePoint(BokushoBrushStroke stroke, float sourceT, out float2 sampledPoint) +{ + sampledPoint = float2(0.0, 0.0); + uint sourcePointCount = (uint)round(max(brushDynamics.w, 0.0)); + uint start = (uint)round(max(stroke.p1.z, 0.0)); + uint count = (uint)round(max(stroke.p1.w, 0.0)); + if (count < 2u || sourcePointCount == 0u || start >= sourcePointCount) + { + return false; + } + + count = min(count, sourcePointCount - start); + if (count < 2u) + { + return false; + } + + float target = saturate(sourceT); + float4 first = BokushoSourcePoints[start]; + if (target <= first.z) + { + sampledPoint = first.xy; + return true; + } + + uint lastIndex = start + count - 1u; + float4 last = BokushoSourcePoints[lastIndex]; + if (target >= last.z) + { + sampledPoint = last.xy; + return true; + } + + uint low = start; + uint high = lastIndex; + [loop] + while (low + 1u < high) + { + uint mid = (low + high) / 2u; + if (BokushoSourcePoints[mid].z <= target) + { + low = mid; + } + else + { + high = mid; + } + } + + float4 a = BokushoSourcePoints[low]; + float4 b = BokushoSourcePoints[high]; + float span = max(b.z - a.z, 0.000001); + float local = saturate((target - a.z) / span); + sampledPoint = lerp(a.xy, b.xy, local); + return true; +} + +float2 StrokePoint(BokushoBrushStroke stroke, float t) +{ + float2 sampledPoint; + float sourceT = saturate(lerp(stroke.p0.z, max(stroke.p0.z, stroke.p0.w), t)); + if (TrySampleSourcePoint(stroke, sourceT, sampledPoint)) + { + return QuantizeFloat2(sampledPoint, BOKUSHO_BRISTLE_TANGENT_WORLD_QUANTUM); + } + + return PacketStrokePoint(stroke, t); +} + +float2 StrokeTangent(BokushoBrushStroke stroke, float t) +{ + float dt = 1.0 / max(brushShape.x - 1.0, 1.0); + float2 before = StrokePoint(stroke, saturate(t - dt)); + float2 after = StrokePoint(stroke, saturate(t + dt)); + return cultmath_normalize(after - before); +} + +float2 StrokeTangent(BokushoBrushStroke stroke, float t, uint stepCount) +{ + float dt = 1.0 / max((float)stepCount - 1.0, 1.0); + float2 before = StrokePoint(stroke, saturate(t - dt)); + float2 after = StrokePoint(stroke, saturate(t + dt)); + return cultmath_normalize(after - before); +} + +bool SameSourceBoundary(BokushoBrushStroke previous, BokushoBrushStroke next) +{ + return previous.p3.w >= 0.0 && + abs(previous.p3.w - next.p3.w) <= 0.5 && + abs(saturate(previous.p0.w) - saturate(next.p0.z)) <= 0.001; +} + +float2 StrokeTangent(BokushoBrushStroke stroke, uint strokeIndex, float t, uint stepCount) +{ + float dt = 1.0 / max((float)stepCount - 1.0, 1.0); + uint sourcePointCount = (uint)round(max(brushDynamics.w, 0.0)); + uint sourceRangeCount = (uint)round(max(stroke.p1.w, 0.0)); + if (sourcePointCount > 0u && sourceRangeCount >= 2u) + { + float sourceStart = saturate(stroke.p0.z); + float sourceEnd = saturate(max(stroke.p0.z, stroke.p0.w)); + float sourceSpan = max(sourceEnd - sourceStart, 1.0 / max((float)(stepCount - 1u), 1.0)); + float sourceT = saturate(lerp(sourceStart, sourceEnd, t)); + float sourceDt = sourceSpan * dt; + float2 sourceBefore; + float2 sourceAfter; + if (TrySampleSourcePoint(stroke, saturate(sourceT - sourceDt), sourceBefore) && + TrySampleSourcePoint(stroke, saturate(sourceT + sourceDt), sourceAfter)) + { + sourceBefore = QuantizeFloat2(sourceBefore, BOKUSHO_BRISTLE_TANGENT_WORLD_QUANTUM); + sourceAfter = QuantizeFloat2(sourceAfter, BOKUSHO_BRISTLE_TANGENT_WORLD_QUANTUM); + return cultmath_normalize(sourceAfter - sourceBefore); + } + } + + float beforeT = t - dt; + float afterT = t + dt; + float2 before = StrokePoint(stroke, saturate(beforeT)); + float2 after = StrokePoint(stroke, saturate(afterT)); + uint strokeCount = max((uint)round(brushShape.w), 1u); + if (beforeT < 0.0 && strokeIndex > 0u) + { + BokushoBrushStroke previous = BokushoBrushStrokes[strokeIndex - 1u]; + if (SameSourceBoundary(previous, stroke)) + { + before = StrokePoint(previous, saturate(1.0 + beforeT)); + } + } + + if (afterT > 1.0 && strokeIndex + 1u < strokeCount) + { + BokushoBrushStroke next = BokushoBrushStrokes[strokeIndex + 1u]; + if (SameSourceBoundary(stroke, next)) + { + after = StrokePoint(next, saturate(afterT - 1.0)); + } + } + + before = QuantizeFloat2(before, BOKUSHO_BRISTLE_TANGENT_WORLD_QUANTUM); + after = QuantizeFloat2(after, BOKUSHO_BRISTLE_TANGENT_WORLD_QUANTUM); + return cultmath_normalize(after - before); +} + +float StrokeTaper(float t) +{ + float entry = cultmath_smoothstep(0.0, 0.10, t); + float exit = 1.0 - cultmath_smoothstep(0.86, 1.0, t); + return 0.04 + entry * exit * 0.96; +} + +float StrokeProgress(BokushoBrushStroke stroke, float t) +{ + return saturate(lerp(stroke.p0.z, max(stroke.p0.z, stroke.p0.w), t)); +} + +float StrokeTaper(BokushoBrushStroke stroke, float t) +{ + float strokeT = StrokeProgress(stroke, t); + float entryTaper = clamp(stroke.dynamics.x, 0.01, 0.50); + float exitTaper = clamp(stroke.dynamics.y, 0.01, 0.50); + float entry = cultmath_smoothstep(0.0, entryTaper, strokeT); + float exit = 1.0 - cultmath_smoothstep(1.0 - exitTaper, 1.0, strokeT); + float x = saturate(entry * exit); + float contact = x * (0.76 + x * 0.24); + return 0.018 + contact * 0.982; +} + +uint StrokeNoiseStep(float strokeT) +{ + return (uint)floor(saturate(strokeT) * 4096.0 + 0.5); +} + +float LaneCohesion(float edge, float split, float wetness, float pressure) +{ + return saturate(0.50 + (1.0 - edge) * 0.38 + wetness * 0.22 + pressure * 0.12 - split * 0.18); +} + +float StrokeSegmentSpan(BokushoBrushStroke stroke, uint sampleCount) +{ + float start = saturate(stroke.p0.z); + float endValue = saturate(max(stroke.p0.z, stroke.p0.w)); + return max(endValue - start, 1.0 / max((float)(sampleCount - 1u), 1.0)); +} + +float CanvasPigment(float value) +{ + return 0.96 * (1.0 - exp(-max(value, 0.0) * 0.82)); +} + +float4 StrokeRhythm(BokushoBrushStroke stroke, float strokeT) +{ + float entry = cultmath_smoothstep(0.0, 0.18, strokeT); + float release = cultmath_smoothstep(0.74, 1.0, strokeT); + float belly = entry * (1.0 - cultmath_smoothstep(0.62, 0.94, strokeT)); + float pose = saturate(abs(stroke.pose.x) * 0.34 + abs(stroke.pose.y) * 0.26 + (stroke.pose.w - 1.0) * 0.18); + return float4( + QuantizeScalar(0.90 + belly * (0.15 + pose * 0.04) + entry * 0.04 - release * (0.12 + pose * 0.03), BOKUSHO_STROKE_RHYTHM_QUANTUM), + QuantizeScalar(0.94 + belly * (0.09 + pose * 0.03) - release * 0.08, BOKUSHO_STROKE_RHYTHM_QUANTUM), + QuantizeScalar(0.96 + belly * 0.08 + pose * 0.02 - release * 0.04, BOKUSHO_STROKE_RHYTHM_QUANTUM), + QuantizeScalar(0.94 + belly * (0.12 + pose * 0.04) - release * 0.10, BOKUSHO_STROKE_RHYTHM_QUANTUM)); +} + +uint EncodePageDensityContribution(float contribution) +{ + float c = saturate(contribution); + float c2 = c * c; + float c3 = c2 * c; + float c4 = c2 * c2; + float density = c + (c2 * 0.5) + (c3 * (1.0 / 3.0)) + (c4 * 0.25); + uint encoded = (uint)floor(min(density * BOKUSHO_PAGE_DENSITY_SCALE, BOKUSHO_PAGE_DENSITY_SCALE) + 0.5); + return BOKUSHO_PAGE_DENSITY_CONTRIBUTION_QUANTUM > 1u + ? ((encoded + (BOKUSHO_PAGE_DENSITY_CONTRIBUTION_QUANTUM / 2u)) / BOKUSHO_PAGE_DENSITY_CONTRIBUTION_QUANTUM) * BOKUSHO_PAGE_DENSITY_CONTRIBUTION_QUANTUM + : encoded; +} + +uint EncodePageDensityContribution(uint inkUnits, uint coverageUnits) +{ + uint product = inkUnits * coverageUnits; + uint denominator = BOKUSHO_PAGE_PATCH_INK_UNITS * BOKUSHO_PAGE_PATCH_COVERAGE_UNITS; + uint encoded = (product * BOKUSHO_PAGE_PATCH_TRANSFER_ENCODED_GAIN + denominator / 2u) / denominator; + return BOKUSHO_PAGE_DENSITY_CONTRIBUTION_QUANTUM > 1u + ? ((encoded + (BOKUSHO_PAGE_DENSITY_CONTRIBUTION_QUANTUM / 2u)) / BOKUSHO_PAGE_DENSITY_CONTRIBUTION_QUANTUM) * BOKUSHO_PAGE_DENSITY_CONTRIBUTION_QUANTUM + : encoded; +} + +float PageToothHash(uint x, uint y) +{ + uint value = (x + 1u) * 0x9E3779B9u ^ (y + 1u) * 0x85EBCA6Bu ^ 0xC2B2AE35u; + value ^= value >> 16; + value *= 0x7FEB352Du; + value ^= value >> 15; + value *= 0x846CA68Bu; + value ^= value >> 16; + return (float)(value & 0x00FFFFFFu) / 16777215.0; +} + +void DepositSweptPatch(float2 previousTip, float2 tip, float previousNormalRadius, float previousTangentRadius, float normalRadius, float tangentRadius, float pigment, float paperTooth) +{ + uint inkUnits = QuantizePositiveUnits(pigment, BOKUSHO_PAGE_PATCH_INK_UNITS); + if (inkUnits == 0u) + { + return; + } + + previousTip = QuantizeFloat2(previousTip, BOKUSHO_PAGE_PATCH_WORLD_QUANTUM); + tip = QuantizeFloat2(tip, BOKUSHO_PAGE_PATCH_WORLD_QUANTUM); + previousNormalRadius = QuantizePositive(previousNormalRadius, BOKUSHO_PAGE_PATCH_WORLD_QUANTUM); + previousTangentRadius = QuantizePositive(previousTangentRadius, BOKUSHO_PAGE_PATCH_WORLD_QUANTUM); + normalRadius = QuantizePositive(normalRadius, BOKUSHO_PAGE_PATCH_WORLD_QUANTUM); + tangentRadius = QuantizePositive(tangentRadius, BOKUSHO_PAGE_PATCH_WORLD_QUANTUM); + float2 sweep = tip - previousTip; + float sweepLength = length(sweep); + float2 tangent = sweepLength > 0.000001 ? sweep / sweepLength : float2(1.0, 0.0); + float2 normal = float2(-tangent.y, tangent.x); + float previousPatchTangentRadius = max(previousTangentRadius * BOKUSHO_PAGE_PATCH_TANGENT_RADIUS_SCALE, BOKUSHO_PAGE_PATCH_MINIMUM_RADIUS); + float previousPatchNormalRadius = max(previousNormalRadius * BOKUSHO_PAGE_PATCH_NORMAL_RADIUS_SCALE, BOKUSHO_PAGE_PATCH_MINIMUM_RADIUS); + float currentPatchTangentRadius = max(tangentRadius * BOKUSHO_PAGE_PATCH_TANGENT_RADIUS_SCALE, BOKUSHO_PAGE_PATCH_MINIMUM_RADIUS); + float currentPatchNormalRadius = max(normalRadius * BOKUSHO_PAGE_PATCH_NORMAL_RADIUS_SCALE, BOKUSHO_PAGE_PATCH_MINIMUM_RADIUS); + float patchTangentRadius = max(previousPatchTangentRadius, currentPatchTangentRadius); + float patchNormalRadius = max(previousPatchNormalRadius, currentPatchNormalRadius); + float2 center = (previousTip + tip) * 0.5; + float halfSweepLength = sweepLength * 0.5; + float2 extents = float2( + abs(tangent.x) * (halfSweepLength + patchTangentRadius) + abs(normal.x) * patchNormalRadius, + abs(tangent.y) * (halfSweepLength + patchTangentRadius) + abs(normal.y) * patchNormalRadius); + float pageSize = max(pageView.w, 1.0); + float2 minUv = (center - extents - pageView.xy) / max(pageView.z, 0.001) * 0.5 + 0.5; + float2 maxUv = (center + extents - pageView.xy) / max(pageView.z, 0.001) * 0.5 + 0.5; + int minX = clamp((int)floor(min(minUv.x, maxUv.x) * (pageSize - 1.0)), 0, (int)pageSize - 1); + int maxX = clamp((int)ceil(max(minUv.x, maxUv.x) * (pageSize - 1.0)), 0, (int)pageSize - 1); + int minY = clamp((int)floor(min(minUv.y, maxUv.y) * (pageSize - 1.0)), 0, (int)pageSize - 1); + int maxY = clamp((int)ceil(max(minUv.y, maxUv.y) * (pageSize - 1.0)), 0, (int)pageSize - 1); + + [loop] + for (int y = minY; y <= maxY; y += 1) + { + float uvY = pageSize <= 1.0 ? 0.0 : (float)y / (pageSize - 1.0); + [loop] + for (int x = minX; x <= maxX; x += 1) + { + float uvX = pageSize <= 1.0 ? 0.0 : (float)x / (pageSize - 1.0); + float2 world = pageView.xy + (float2(uvX, uvY) * 2.0 - 1.0) * pageView.z; + float2 local = world - center; + float axialExcess = max(abs(dot(local, tangent)) - halfSweepLength, 0.0); + float tangentDistance = axialExcess / patchTangentRadius; + float normalDistance = dot(local, normal) / patchNormalRadius; + float ellipse = sqrt(tangentDistance * tangentDistance + normalDistance * normalDistance); + float coverage = cultmath_smoothstep(1.0, 0.0, ellipse); + float tooth = PageToothHash((uint)x, (uint)y); + float interiorTooth = (1.0 - cultmath_smoothstep(0.0, 0.34, ellipse)) * saturate(paperTooth); + coverage *= 1.0 - interiorTooth * (0.03 + tooth * 0.10); + float edgeTooth = cultmath_smoothstep(0.38, 1.0, ellipse) * saturate(paperTooth); + if (edgeTooth > 0.0) + { + coverage *= 1.0 - edgeTooth * (0.18 + tooth * 0.34); + } + + uint coverageUnits = QuantizePositiveUnits(coverage, BOKUSHO_PAGE_PATCH_COVERAGE_UNITS); + if (coverageUnits == 0u) + { + continue; + } + + InterlockedAdd(BokushoPageDensityField[(uint)(y * (int)pageSize + x)], EncodePageDensityContribution(inkUnits, coverageUnits)); + } + } +} + +float LaneHash(uint strokeIndex, uint tuft, uint salt) +{ + uint value = (strokeIndex + 1u) * 0x9E3779B9u ^ (tuft + 1u) * 0x85EBCA6Bu ^ salt; + value ^= value >> 16; + value *= 0x7FEB352Du; + value ^= value >> 15; + value *= 0x846CA68Bu; + value ^= value >> 16; + return (float)(value & 0x00FFFFFFu) / 16777215.0; +} + +uint StrokeChainStart(uint strokeIndex) +{ + uint chainStart = strokeIndex; + float expectedStart = saturate(BokushoBrushStrokes[strokeIndex].p0.z); + float sourceStrokeId = BokushoBrushStrokes[strokeIndex].p3.w; + [loop] + while (chainStart > 0u && expectedStart > 0.0001) + { + if (sourceStrokeId >= 0.0 && abs(BokushoBrushStrokes[chainStart - 1u].p3.w - sourceStrokeId) > 0.5) + { + break; + } + + float previousEnd = saturate(BokushoBrushStrokes[chainStart - 1u].p0.w); + if (abs(previousEnd - expectedStart) > 0.001) + { + break; + } + + chainStart = chainStart - 1u; + expectedStart = saturate(BokushoBrushStrokes[chainStart].p0.z); + } + + return chainStart; +} + +void SimulateBokushoTuftSegment( + BokushoBrushStroke stroke, + uint outputStrokeIndex, + uint laneKey, + uint tuft, + uint sampleCount, + uint tuftCount, + float restOffset, + float edge, + inout float offset, + inout float2 tip, + inout float stateLoad, + inout float stateWet, + bool writeOutput) +{ + float pressure = saturate(brushMaterial.y * stroke.profile.y * 0.5) * 2.0; + float splay = saturate(brushDynamics.x / 2.0); + float bend = saturate(brushDynamics.y / 2.4); + float friction = saturate(brushDynamics.z); + float radius = max(brushMaterial.x * stroke.profile.x, 0.0001); + float normalRadius = max(radius * stroke.profile.z, 0.0001); + float tangentRadius = max(radius * stroke.profile.w, 0.0001); + float segmentSpan = StrokeSegmentSpan(stroke, sampleCount); + float integrationSpan = segmentSpan / max((float)sampleCount - 1.0, 1.0); + float segmentVelocityScale = 1.0 / segmentSpan; + float split = saturate((0.28 - LaneHash(laneKey, tuft, 53u)) * 3.0) * saturate((edge - 0.20) * 1.5) * stroke.dynamics.w; + + [loop] + for (uint sample = 0u; sample < sampleCount; sample += 1u) + { + float t = sampleCount <= 1u ? 0.0 : (float)sample / (float)(sampleCount - 1u); + float2 center = StrokePoint(stroke, t); + float2 tangent = StrokeTangent(stroke, outputStrokeIndex, t, sampleCount); + float2 normal = float2(-tangent.y, tangent.x); + float strokeT = StrokeProgress(stroke, t); + float taper = StrokeTaper(stroke, t); + float4 rhythm = StrokeRhythm(stroke, strokeT); + float localPressure = pressure * taper * rhythm.x; + float laneCore = cultmath_smoothstep(0.0, 0.78, 1.0 - edge); + float cohesion = saturate(0.28 + stateWet * 0.44 + laneCore * 0.24 + localPressure * 0.10 - split * 0.18); + float localNormalRadius = max(normalRadius * rhythm.y * (0.18 + taper * 0.82) * (0.72 + splay * 0.34 + localPressure * 0.16 - cohesion * 0.08), 0.0001); + float localTangentRadius = max(tangentRadius * rhythm.z * (0.24 + taper * 0.76) * (0.86 + bend * 0.18), 0.0001); + float targetOffset = (restOffset + stroke.pose.y * 0.12) * localNormalRadius * (0.58 + splay * 0.34 + localPressure * 0.18 - cohesion * 0.20); + float recovery = saturate(0.05 + stroke.pose.w * 0.08 + stateWet * 0.08 + localPressure * 0.10); + offset = cultmath_lerp(offset, targetOffset, recovery); + + float poseLead = stroke.pose.x * 0.36 + stroke.pose.y * 0.16; + float lag = localTangentRadius * (0.10 + bend * 0.32 + friction * localPressure * 0.16 + edge * 0.06); + float2 dragVector = cultmath_normalize(tangent + normal * poseLead); + float2 desiredTip = center + normal * offset - dragVector * lag; + float2 slip = desiredTip - tip; + float velocity = length(slip) * brushShape.z * segmentVelocityScale / max(radius, 0.001); + float contact = saturate(localPressure * stateLoad * (0.24 + stateWet * 0.62 + laneCore * 0.18)); + float drag = contact * friction * (0.28 + stateWet * 0.34); + float2 previousTip = tip; + tip = tip + slip * (0.18 + recovery * 0.82) * (1.0 - drag * 0.52); + float sweptDistance = length(tip - previousTip); + float sweptPatch = saturate(sweptDistance / max(localTangentRadius, 0.001) * 0.42); + + float tension = saturate(velocity * 0.014 + abs(targetOffset - offset) / max(localNormalRadius, 0.001) * 0.22 + edge * 0.12); + float separation = saturate(split * 0.34 + tension * 0.46 + edge * 0.20 - cohesion * 0.24); + float adhesion = saturate(stateWet * (0.36 + cohesion * 0.40) + localPressure * 0.10 - separation * 0.22); + float dryMemory = saturate((1.0 - stateWet) * 0.62 + separation * 0.32 + velocity * 0.004); + float fiberNoise = LaneHash(laneKey + StrokeNoiseStep(strokeT) * 13u, tuft, 101u); + float continuity = 1.0 - cultmath_smoothstep(0.18 + dryMemory * 0.28, 0.96, fiberNoise) * dryMemory * (0.38 + edge * 0.22); + float inkFilm = saturate(stateLoad * (0.28 + stateWet * 0.34)); + float bristleTooth = (0.62 + continuity * (0.30 + inkFilm * 0.08)) * (0.82 + inkFilm * 0.24 - edge * 0.06); + float contactTransfer = contact * stateLoad * (0.16 + stateWet * 0.92) * (0.30 + drag * 0.64 + localPressure * 0.22 + sweptPatch * 0.18) * (0.68 + laneCore * 0.50 - separation * 0.14) * bristleTooth; + float airborneRelease = (1.0 - contact) * stateLoad * stateWet * saturate(velocity * 0.010 - adhesion * 0.16) * (0.20 + separation * 0.42 + edge * 0.18); + float consumedPigment = contactTransfer + airborneRelease; + stateLoad = max(0.0, stateLoad - consumedPigment * integrationSpan * (0.014 + localPressure * 0.009 + dryMemory * 0.004)); + stateWet = max(0.0, stateWet - consumedPigment * integrationSpan * (0.006 + dryMemory * 0.003)); + + if (writeOutput) + { + uint index = ((outputStrokeIndex * tuftCount) + tuft) * sampleCount + sample; + float pigment = (contactTransfer * (0.95 + localPressure * 0.34 + sweptPatch * 0.24) + airborneRelease * (1.6 + velocity * 0.002)) * stroke.dynamics.z * rhythm.w; + float localPigment = CanvasPigment(pigment); + float trace = saturate(contact * 0.70 + airborneRelease * 2.0 + stateLoad * 0.18); + BokushoTraceField[index] = trace; + BokushoCanvasField[index] = localPigment; + BokushoTipField[index] = float4( + tip, + localNormalRadius * (0.74 + inkFilm * 0.10 + laneCore * 0.20 + localPressure * 0.14 + sweptPatch * 0.08 - separation * 0.10), + localTangentRadius * (0.86 + inkFilm * 0.06 + bend * 0.22 + drag * 0.16) + sweptDistance * 0.36); + } + } +} + +void SimulateBokushoTuftSegmentToPage( + BokushoBrushStroke stroke, + uint strokeIndex, + uint laneKey, + uint tuft, + uint sampleCount, + float restOffset, + float edge, + inout float offset, + inout float2 tip, + inout float stateLoad, + inout float stateWet, + inout float previousPatchNormalRadius, + inout float previousPatchTangentRadius, + bool writeOutput, + bool writeInitialStep) +{ + float pressure = saturate(brushMaterial.y * stroke.profile.y * 0.5) * 2.0; + float splay = saturate(brushDynamics.x / 2.0); + float bend = saturate(brushDynamics.y / 2.4); + float friction = saturate(brushDynamics.z); + float radius = max(brushMaterial.x * stroke.profile.x, 0.0001); + float normalRadius = max(radius * stroke.profile.z, 0.0001); + float tangentRadius = max(radius * stroke.profile.w, 0.0001); + float segmentSpan = StrokeSegmentSpan(stroke, sampleCount); + uint stepCount = min(max((uint)ceil(segmentSpan * brushShape.z), 2u), 4096u); + float integrationSpan = segmentSpan / max((float)stepCount - 1.0, 1.0); + float segmentVelocityScale = 1.0 / segmentSpan; + float split = saturate((0.28 - LaneHash(laneKey, tuft, 53u)) * 3.0) * saturate((edge - 0.20) * 1.5) * stroke.dynamics.w; + + [loop] + for (uint step = 0u; step < stepCount; step += 1u) + { + float t = stepCount <= 1u ? 0.0 : (float)step / (float)(stepCount - 1u); + float2 center = StrokePoint(stroke, t); + float2 tangent = StrokeTangent(stroke, strokeIndex, t, stepCount); + float2 normal = float2(-tangent.y, tangent.x); + float strokeT = StrokeProgress(stroke, t); + float taper = StrokeTaper(stroke, t); + float4 rhythm = StrokeRhythm(stroke, strokeT); + float localPressure = pressure * taper * rhythm.x; + float laneCore = cultmath_smoothstep(0.0, 0.78, 1.0 - edge); + float cohesion = saturate(0.28 + stateWet * 0.44 + laneCore * 0.24 + localPressure * 0.10 - split * 0.18); + float localNormalRadius = max(normalRadius * rhythm.y * (0.18 + taper * 0.82) * (0.72 + splay * 0.34 + localPressure * 0.16 - cohesion * 0.08), 0.0001); + float localTangentRadius = max(tangentRadius * rhythm.z * (0.24 + taper * 0.76) * (0.86 + bend * 0.18), 0.0001); + float targetOffset = (restOffset + stroke.pose.y * 0.12) * localNormalRadius * (0.58 + splay * 0.34 + localPressure * 0.18 - cohesion * 0.20); + float recovery = saturate(0.05 + stroke.pose.w * 0.08 + stateWet * 0.08 + localPressure * 0.10); + offset = cultmath_lerp(offset, targetOffset, recovery); + + float poseLead = stroke.pose.x * 0.36 + stroke.pose.y * 0.16; + float lag = localTangentRadius * (0.10 + bend * 0.32 + friction * localPressure * 0.16 + edge * 0.06); + float2 dragVector = cultmath_normalize(tangent + normal * poseLead); + float2 desiredTip = center + normal * offset - dragVector * lag; + float2 slip = desiredTip - tip; + float velocity = length(slip) * brushShape.z * segmentVelocityScale / max(radius, 0.001); + float contact = saturate(localPressure * stateLoad * (0.24 + stateWet * 0.62 + laneCore * 0.18)); + float drag = contact * friction * (0.28 + stateWet * 0.34); + float2 previousTip = tip; + tip = tip + slip * (0.18 + recovery * 0.82) * (1.0 - drag * 0.52); + float sweptDistance = length(tip - previousTip); + float sweptPatch = saturate(sweptDistance / max(localTangentRadius, 0.001) * 0.42); + + float tension = saturate(velocity * 0.014 + abs(targetOffset - offset) / max(localNormalRadius, 0.001) * 0.22 + edge * 0.12); + float separation = saturate(split * 0.34 + tension * 0.46 + edge * 0.20 - cohesion * 0.24); + float adhesion = saturate(stateWet * (0.36 + cohesion * 0.40) + localPressure * 0.10 - separation * 0.22); + float dryMemory = saturate((1.0 - stateWet) * 0.62 + separation * 0.32 + velocity * 0.004); + float fiberNoise = LaneHash(laneKey + StrokeNoiseStep(strokeT) * 13u, tuft, 101u); + float continuity = 1.0 - cultmath_smoothstep(0.18 + dryMemory * 0.28, 0.96, fiberNoise) * dryMemory * (0.38 + edge * 0.22); + float inkFilm = saturate(stateLoad * (0.28 + stateWet * 0.34)); + float bristleTooth = (0.62 + continuity * (0.30 + inkFilm * 0.08)) * (0.82 + inkFilm * 0.24 - edge * 0.06); + float paperTooth = saturate(dryMemory * 0.42 + separation * 0.22 + edge * 0.10 + (1.0 - inkFilm) * 0.18); + float contactTransfer = contact * stateLoad * (0.16 + stateWet * 0.92) * (0.30 + drag * 0.64 + localPressure * 0.22 + sweptPatch * 0.18) * (0.68 + laneCore * 0.50 - separation * 0.14) * bristleTooth; + float airborneRelease = (1.0 - contact) * stateLoad * stateWet * saturate(velocity * 0.010 - adhesion * 0.16) * (0.20 + separation * 0.42 + edge * 0.18); + float consumedPigment = contactTransfer + airborneRelease; + stateLoad = max(0.0, stateLoad - consumedPigment * integrationSpan * (0.014 + localPressure * 0.009 + dryMemory * 0.004)); + stateWet = max(0.0, stateWet - consumedPigment * integrationSpan * (0.006 + dryMemory * 0.003)); + + if (writeOutput && (writeInitialStep || step > 0u)) + { + float pigment = (contactTransfer * (0.95 + localPressure * 0.34 + sweptPatch * 0.24) + airborneRelease * (1.6 + velocity * 0.002)) * stroke.dynamics.z * rhythm.w; + float patchNormalRadius = localNormalRadius * (0.74 + inkFilm * 0.10 + laneCore * 0.20 + localPressure * 0.14 + sweptPatch * 0.08 - separation * 0.10); + float patchTangentRadius = localTangentRadius * (0.86 + inkFilm * 0.06 + bend * 0.22 + drag * 0.16) + sweptDistance * 0.36; + float sweepStartNormalRadius = previousPatchNormalRadius > 0.0 ? previousPatchNormalRadius : patchNormalRadius; + float sweepStartTangentRadius = previousPatchTangentRadius > 0.0 ? previousPatchTangentRadius : patchTangentRadius; + DepositSweptPatch(previousTip, tip, sweepStartNormalRadius, sweepStartTangentRadius, patchNormalRadius, patchTangentRadius, pigment, paperTooth); + } + + previousPatchNormalRadius = localNormalRadius * (0.74 + inkFilm * 0.10 + laneCore * 0.20 + localPressure * 0.14 + sweptPatch * 0.08 - separation * 0.10); + previousPatchTangentRadius = localTangentRadius * (0.86 + inkFilm * 0.06 + bend * 0.22 + drag * 0.16) + sweptDistance * 0.36; + } +} + +[numthreads(128, 1, 1)] +void D3D12BokushoBrushCS(uint3 dispatchThreadId : SV_DispatchThreadID) +{ + uint sampleCount = max((uint)round(brushShape.x), 2u); + uint tuftCount = max((uint)round(brushShape.y), 1u); + uint strokeCount = max((uint)round(brushShape.w), 1u); + uint globalIndex = dispatchThreadId.x; + uint strokeIndex = globalIndex / tuftCount; + uint tuft = globalIndex - strokeIndex * tuftCount; + if (strokeIndex >= strokeCount || tuft >= tuftCount) + { + return; + } + + BokushoBrushStroke stroke = BokushoBrushStrokes[strokeIndex]; + uint chainStart = StrokeChainStart(strokeIndex); + BokushoBrushStroke seedStroke = BokushoBrushStrokes[chainStart]; + float laneT = tuftCount <= 1u ? 0.5 : (float)tuft / (float)(tuftCount - 1u); + float restOffset = laneT * 2.0 - 1.0; + float edge = abs(restOffset); + float laneHash = LaneHash(chainStart, tuft, 17u); + float laneLoad = 0.76 + laneHash * 0.34; + float seedPressure = saturate(brushMaterial.y * seedStroke.profile.y * 0.5) * 2.0; + float wetness = saturate(brushMaterial.w / 1.6); + float load = clamp(brushMaterial.z / 1.05, 0.0, 2.7); + float splay = saturate(brushDynamics.x / 2.0); + float seedRadius = max(brushMaterial.x * seedStroke.profile.x, 0.0001); + float seedNormalRadius = max(seedRadius * seedStroke.profile.z, 0.0001); + float seedSplit = saturate((0.28 - LaneHash(chainStart, tuft, 53u)) * 3.0) * saturate((edge - 0.20) * 1.5) * seedStroke.dynamics.w; + float initialCohesion = LaneCohesion(edge, seedSplit, wetness, seedPressure); + float2 tip = StrokePoint(seedStroke, 0.0); + float poseBias = seedStroke.pose.x * 0.18 + seedStroke.pose.y * 0.08; + float offset = (restOffset + poseBias * (1.0 - edge * 0.35)) * seedNormalRadius * (0.42 + splay * 0.38) + (laneHash - 0.5) * seedNormalRadius * 0.05; + float stateLoad = load * (0.86 + initialCohesion * 0.48) * (1.0 - edge * 0.08) * laneLoad * (1.0 - seedSplit * 0.18); + float stateWet = wetness * (0.74 + initialCohesion * 0.24 - seedSplit * 0.08); + + [loop] + for (uint replayStrokeIndex = chainStart; replayStrokeIndex < strokeIndex; replayStrokeIndex += 1u) + { + SimulateBokushoTuftSegment(BokushoBrushStrokes[replayStrokeIndex], replayStrokeIndex, chainStart, tuft, sampleCount, tuftCount, restOffset, edge, offset, tip, stateLoad, stateWet, false); + } + + SimulateBokushoTuftSegment(stroke, strokeIndex, chainStart, tuft, sampleCount, tuftCount, restOffset, edge, offset, tip, stateLoad, stateWet, true); +} + +[numthreads(128, 1, 1)] +void D3D12BokushoPageClearCS(uint3 dispatchThreadId : SV_DispatchThreadID) +{ + uint pageValueCount = (uint)(max(pageView.w, 1.0) * max(pageView.w, 1.0)); + uint index = dispatchThreadId.x; + if (index < pageValueCount) + { + BokushoPageDensityField[index] = 0u; + } +} + +[numthreads(128, 1, 1)] +void D3D12BokushoPageDepositCS(uint3 dispatchThreadId : SV_DispatchThreadID) +{ + uint sampleCount = max((uint)round(brushShape.x), 2u); + uint tuftCount = max((uint)round(brushShape.y), 1u); + uint strokeCount = max((uint)round(brushShape.w), 1u); + uint globalIndex = dispatchThreadId.x; + uint strokeIndex = globalIndex / tuftCount; + uint tuft = globalIndex - strokeIndex * tuftCount; + if (strokeIndex >= strokeCount || tuft >= tuftCount) + { + return; + } + + BokushoBrushStroke stroke = BokushoBrushStrokes[strokeIndex]; + uint chainStart = StrokeChainStart(strokeIndex); + BokushoBrushStroke seedStroke = BokushoBrushStrokes[chainStart]; + float laneT = tuftCount <= 1u ? 0.5 : (float)tuft / (float)(tuftCount - 1u); + float restOffset = laneT * 2.0 - 1.0; + float edge = abs(restOffset); + float laneHash = LaneHash(chainStart, tuft, 17u); + float laneLoad = 0.76 + laneHash * 0.34; + float seedPressure = saturate(brushMaterial.y * seedStroke.profile.y * 0.5) * 2.0; + float wetness = saturate(brushMaterial.w / 1.6); + float load = clamp(brushMaterial.z / 1.05, 0.0, 2.7); + float splay = saturate(brushDynamics.x / 2.0); + float seedRadius = max(brushMaterial.x * seedStroke.profile.x, 0.0001); + float seedNormalRadius = max(seedRadius * seedStroke.profile.z, 0.0001); + float seedSplit = saturate((0.28 - LaneHash(chainStart, tuft, 53u)) * 3.0) * saturate((edge - 0.20) * 1.5) * seedStroke.dynamics.w; + float initialCohesion = LaneCohesion(edge, seedSplit, wetness, seedPressure); + float2 tip = StrokePoint(seedStroke, 0.0); + float poseBias = seedStroke.pose.x * 0.18 + seedStroke.pose.y * 0.08; + float offset = (restOffset + poseBias * (1.0 - edge * 0.35)) * seedNormalRadius * (0.42 + splay * 0.38) + (laneHash - 0.5) * seedNormalRadius * 0.05; + float stateLoad = load * (0.86 + initialCohesion * 0.48) * (1.0 - edge * 0.08) * laneLoad * (1.0 - seedSplit * 0.18); + float stateWet = wetness * (0.74 + initialCohesion * 0.24 - seedSplit * 0.08); + float previousPatchNormalRadius = 0.0; + float previousPatchTangentRadius = 0.0; + + [loop] + for (uint replayStrokeIndex = chainStart; replayStrokeIndex < strokeIndex; replayStrokeIndex += 1u) + { + SimulateBokushoTuftSegmentToPage(BokushoBrushStrokes[replayStrokeIndex], replayStrokeIndex, chainStart, tuft, sampleCount, restOffset, edge, offset, tip, stateLoad, stateWet, previousPatchNormalRadius, previousPatchTangentRadius, false, true); + } + + SimulateBokushoTuftSegmentToPage(stroke, strokeIndex, chainStart, tuft, sampleCount, restOffset, edge, offset, tip, stateLoad, stateWet, previousPatchNormalRadius, previousPatchTangentRadius, true, strokeIndex == chainStart); +} diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12FractalSplatRender.hlsl b/src/Aquarium.Engine/Render/Shaders/D3D12FractalSplatRender.hlsl index 08af411..04c6d5c 100644 --- a/src/Aquarium.Engine/Render/Shaders/D3D12FractalSplatRender.hlsl +++ b/src/Aquarium.Engine/Render/Shaders/D3D12FractalSplatRender.hlsl @@ -87,6 +87,7 @@ struct SceneOut float4 metadata : SV_Target1; float4 control : SV_Target2; float4 reservoirGuide : SV_Target3; + float overdraw : SV_Target4; float depth : SV_Depth; }; @@ -237,6 +238,7 @@ SceneOut ResolveFractalSplat(FractalSplatVertexOut input, bool renderTransparent output.metadata = float4(FIELD_ID_FRACTAL_SPLAT_BASE, normal); output.control = float4(opacity, reservoirConfidence, transparentField ? splat.materialConfidence.z / 10.0 : saturate((sdfResident ? sdf.centerRadius.w : splat.centerRadius.w) * 40.0), 0.0); output.reservoirGuide = float4(reservoirConfidence, reservoirSampleAge, domainValidity, 0.0); + output.overdraw = 1.0; output.depth = saturate(input.travel / max(farDistance, 0.0001)); return output; } diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12GpuSensorFusion.hlsl b/src/Aquarium.Engine/Render/Shaders/D3D12GpuSensorFusion.hlsl index e1e4bf3..0b4fc23 100644 --- a/src/Aquarium.Engine/Render/Shaders/D3D12GpuSensorFusion.hlsl +++ b/src/Aquarium.Engine/Render/Shaders/D3D12GpuSensorFusion.hlsl @@ -94,6 +94,11 @@ StructuredBuffer acousticConstraints : register(t36); StructuredBuffer fusionPoints : register(t37); RWStructuredBuffer temporalGaussiansOut : register(u0); +static const uint SensorFormatR8Unorm = 3u; +static const uint SensorFormatRg8Unorm = 6u; +static const uint SensorFormatLeapPackedMap = 7u; +static const uint SensorFormatYuy2 = 8u; + float Hash01(uint value) { value ^= value >> 16; @@ -128,6 +133,41 @@ float4 LoadSensorTexture(uint slot, int3 pixel) } } +float3 YuvToRgb(float y, float u, float v) +{ + float cb = u - 0.5; + float cr = v - 0.5; + return saturate(float3( + y + 1.5748 * cr, + y - 0.1873 * cb - 0.4681 * cr, + y + 1.8556 * cb)); +} + +float4 DecodeSensorSample(uint slot, GpuSensorCamera camera, uint x, uint y) +{ + uint format = (uint)round(camera.textureRangeTime.w); + if (format == SensorFormatYuy2) + { + float4 yuyv = LoadSensorTexture(slot, int3((int)(x >> 1), (int)y, 0)); + float luma = (x & 1u) == 0u ? yuyv.r : yuyv.b; + return float4(YuvToRgb(luma, yuyv.g, yuyv.a), 1.0); + } + + float4 sample = LoadSensorTexture(slot, int3((int)x, (int)y, 0)); + if (format == SensorFormatR8Unorm) + { + return float4(sample.rrr, 1.0); + } + + if (format == SensorFormatRg8Unorm || + format == SensorFormatLeapPackedMap) + { + return float4(sample.rg, 0.5 * (sample.r + sample.g), 1.0); + } + + return sample; +} + float AcousticSupport(float3 center, out float3 velocityBias) { uint count = (uint)gpuFusionInfo.x; @@ -224,9 +264,12 @@ void D3D12GpuSensorFusionCS(uint3 dispatchThreadId : SV_DispatchThreadID) uint localIndex = textureSampleIndex + textureSlot * 747796405u; uint x = (localIndex * 73u + (uint)(Hash01(localIndex) * 37.0)) % width; uint y = (localIndex / width * 41u + (uint)(Hash01(localIndex ^ 0x9e3779b9u) * 29.0)) % height; - float4 rgba = LoadSensorTexture(textureSlot, int3((int)x, (int)y, 0)); uint pairSlot = (textureSlot + 1u) % max(1u, textureCount); - float4 pairRgba = LoadSensorTexture(pairSlot, int3((int)x, (int)y, 0)); + GpuSensorCamera pairCamera = sensorCameras[min(cameraCount - 1, pairSlot % cameraCount)]; + uint pairWidth = max(1u, (uint)pairCamera.extentsKind.x); + uint pairHeight = max(1u, (uint)pairCamera.extentsKind.y); + float4 rgba = DecodeSensorSample(textureSlot, camera, x, y); + float4 pairRgba = DecodeSensorSample(pairSlot, pairCamera, min(x, pairWidth - 1u), min(y, pairHeight - 1u)); float luma = dot(rgba.rgb, float3(0.2126, 0.7152, 0.0722)); float pairLuma = dot(pairRgba.rgb, float3(0.2126, 0.7152, 0.0722)); float descriptor = frac(luma * 3.17 + rgba.r * 1.91 + rgba.g * 2.37 + Hash01(localIndex ^ 0x27d4eb2du) * 0.07); diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12HeightField.hlsl b/src/Aquarium.Engine/Render/Shaders/D3D12HeightField.hlsl index 7912b84..06ebf44 100644 --- a/src/Aquarium.Engine/Render/Shaders/D3D12HeightField.hlsl +++ b/src/Aquarium.Engine/Render/Shaders/D3D12HeightField.hlsl @@ -33,6 +33,38 @@ cbuffer HeightFieldBrushes : register(b1) float4 brushDomain[64]; }; +cbuffer BokushoPageConstants : register(b5) +{ + float4 bokushoShape; // sampleCount, tuftCount, physicsHz, strokeCount + float4 bokushoMaterial; // radius, pressure, inkLoad, wetness + float4 bokushoDynamics; // splay, bend, friction, reserved + float4 bokushoProfile; // radiusScale, pressureScale, normalScale, tangentScale + float4 bokushoStrokeP0; + float4 bokushoStrokeP1; + float4 bokushoStrokeP2; + float4 bokushoStrokeP3; +}; + +struct BokushoBrushStroke +{ + float4 profile; + float4 dynamics; + float4 pose; + float4 p0; + float4 p1; + float4 p2; + float4 p3; +}; + +StructuredBuffer BokushoCanvasField : register(t77); +StructuredBuffer BokushoBrushStrokes : register(t78); +StructuredBuffer BokushoTipField : register(t79); +StructuredBuffer BokushoPageDensityField : register(t80); + +#include "CultMath/CultMath.hlsl" + +static const float BOKUSHO_PAGE_DENSITY_SCALE = 65535.0; + struct VertexOut { float4 position : SV_Position; @@ -64,6 +96,196 @@ float2 viewWorld(float2 uv) return viewCenter + (uv * 2.0 - 1.0) * viewRadius; } +float2 bokushoStrokePoint(BokushoBrushStroke stroke, float t) +{ + return cultmath_catmullrom(stroke.p0.xy, stroke.p1.xy, stroke.p2.xy, stroke.p3.xy, t); +} + +float2 bokushoStrokeTangent(BokushoBrushStroke stroke, float t) +{ + float dt = 1.0 / max(bokushoShape.x - 1.0, 1.0); + float2 before = bokushoStrokePoint(stroke, saturate(t - dt)); + float2 after = bokushoStrokePoint(stroke, saturate(t + dt)); + return cultmath_normalize(after - before); +} + +float bokushoStrokeTaper(BokushoBrushStroke stroke, float t) +{ + float strokeT = saturate(cultmath_lerp(stroke.p0.z, max(stroke.p0.z, stroke.p0.w), t)); + float entryTaper = clamp(stroke.dynamics.x, 0.01, 0.50); + float exitTaper = clamp(stroke.dynamics.y, 0.01, 0.50); + float entry = smoothstep(0.0, entryTaper, strokeT); + float exit = 1.0 - smoothstep(1.0 - exitTaper, 1.0, strokeT); + float contact = pow(entry * exit, 1.12); + return 0.018 + contact * 0.982; +} + +float bokushoCanvasSample(uint strokeIndex, float sampleIndex, float tuftIndex) +{ + uint sampleCount = max((uint)round(bokushoShape.x), 2u); + uint tuftCount = max((uint)round(bokushoShape.y), 1u); + float clampedSample = clamp(sampleIndex, 0.0, (float)(sampleCount - 1u)); + float clampedTuft = clamp(tuftIndex, 0.0, (float)(tuftCount - 1u)); + uint sample0 = min((uint)floor(clampedSample), sampleCount - 1u); + uint sample1 = min(sample0 + 1u, sampleCount - 1u); + uint tuft0 = min((uint)floor(clampedTuft), tuftCount - 1u); + uint tuft1 = min(tuft0 + 1u, tuftCount - 1u); + float sampleBlend = saturate(clampedSample - (float)sample0); + float tuftBlend = saturate(clampedTuft - (float)tuft0); + uint rowOffset = strokeIndex * tuftCount; + float p00 = BokushoCanvasField[(rowOffset + tuft0) * sampleCount + sample0]; + float p10 = BokushoCanvasField[(rowOffset + tuft0) * sampleCount + sample1]; + float p01 = BokushoCanvasField[(rowOffset + tuft1) * sampleCount + sample0]; + float p11 = BokushoCanvasField[(rowOffset + tuft1) * sampleCount + sample1]; + float lower = cultmath_lerp(p00, p10, sampleBlend); + float upper = cultmath_lerp(p01, p11, sampleBlend); + return saturate(cultmath_lerp(lower, upper, tuftBlend)); +} + +float4 bokushoTipSample(uint strokeIndex, uint sampleIndex, uint tuftIndex) +{ + uint sampleCount = max((uint)round(bokushoShape.x), 2u); + uint tuftCount = max((uint)round(bokushoShape.y), 1u); + uint rowOffset = strokeIndex * tuftCount; + return BokushoTipField[(rowOffset + min(tuftIndex, tuftCount - 1u)) * sampleCount + min(sampleIndex, sampleCount - 1u)]; +} + +float bokushoStrokePageHeight(float2 world, BokushoBrushStroke stroke, uint strokeIndex) +{ + float bestDistance = 1.0e20; + float bestT = 0.0; + [unroll] + for (uint scan = 0u; scan < 16u; scan += 1u) + { + float t = (float)scan / 15.0; + float2 center = bokushoStrokePoint(stroke, t); + float distanceSquared = dot(world - center, world - center); + if (distanceSquared < bestDistance) + { + bestDistance = distanceSquared; + bestT = t; + } + } + + [unroll] + for (uint refine = 0u; refine < 3u; refine += 1u) + { + float span = 1.0 / (15.0 * pow(2.0, (float)refine)); + float leftT = saturate(bestT - span); + float rightT = saturate(bestT + span); + float2 left = bokushoStrokePoint(stroke, leftT); + float2 right = bokushoStrokePoint(stroke, rightT); + float leftDistance = dot(world - left, world - left); + float rightDistance = dot(world - right, world - right); + if (leftDistance < bestDistance) + { + bestDistance = leftDistance; + bestT = leftT; + } + + if (rightDistance < bestDistance) + { + bestDistance = rightDistance; + bestT = rightT; + } + } + + uint sampleCount = max((uint)round(bokushoShape.x), 2u); + uint tuftCount = max((uint)round(bokushoShape.y), 1u); + float2 center = bokushoStrokePoint(stroke, bestT); + float2 tangent = bokushoStrokeTangent(stroke, bestT); + float2 normal = float2(-tangent.y, tangent.x); + float lateral = dot(world - center, normal); + float taper = bokushoStrokeTaper(stroke, bestT); + float radius = max(bokushoMaterial.x * stroke.profile.x * stroke.profile.z * (0.18 + taper * 0.82), 0.0001); + float splay = saturate(bokushoDynamics.x / 2.0); + float pressure = saturate(bokushoMaterial.y * stroke.profile.y * 0.5) * taper; + float wetSpread = 0.86 + saturate(bokushoMaterial.w / 1.6) * 0.18; + float footprint = radius * wetSpread * (0.72 + splay * 0.44 + pressure * 0.22); + float tuftT = saturate(lateral / max(footprint, 0.001) * 0.5 + 0.5); + float distance = sqrt(bestDistance); + float contact = smoothstep(1.0, 0.0, distance / max(footprint * 0.96, 0.001)); + float contactCore = contact * contact * (3.0 - 2.0 * contact); + uint centerSample = min((uint)round(bestT * (float)(sampleCount - 1u)), sampleCount - 1u); + uint centerTuft = min((uint)round(tuftT * (float)max((int)tuftCount - 1, 0)), tuftCount - 1u); + float pigmentPeak = 0.0; + float pigmentFlow = 0.0; + + [unroll] + for (int sampleDelta = -3; sampleDelta <= 3; sampleDelta += 1) + { + int rawSampleIndex = (int)centerSample + sampleDelta; + uint sampleStrokeIndex = strokeIndex; + BokushoBrushStroke sampleStroke = stroke; + int resolvedSampleIndex = clamp(rawSampleIndex, 0, (int)sampleCount - 1); + uint sampleIndex = min((uint)max(resolvedSampleIndex, 0), sampleCount - 1u); + float sampleT = (float)sampleIndex / max((float)(sampleCount - 1u), 1.0); + float2 sampleTangent = bokushoStrokeTangent(sampleStroke, sampleT); + float2 sampleNormal = float2(-sampleTangent.y, sampleTangent.x); + + [unroll] + for (int tuftDelta = -2; tuftDelta <= 2; tuftDelta += 1) + { + uint tuftIndex = min((uint)max((int)centerTuft + tuftDelta, 0), tuftCount - 1u); + float4 tip = bokushoTipSample(sampleStrokeIndex, sampleIndex, tuftIndex); + uint previousStrokeIndex = sampleStrokeIndex; + int previousSampleIndexValue = max((int)sampleIndex - 1, 0); + uint previousSampleIndex = min((uint)max(previousSampleIndexValue, 0), sampleCount - 1u); + float4 previousTip = bokushoTipSample(previousStrokeIndex, previousSampleIndex, tuftIndex); + float2 sweep = tip.xy - previousTip.xy; + float sweepLengthSquared = dot(sweep, sweep); + float sweepT = sweepLengthSquared <= 0.000001 ? 1.0 : saturate(dot(world - previousTip.xy, sweep) / sweepLengthSquared); + float2 contactPoint = previousTip.xy + sweep * sweepT; + float sweepDistance = length(world - contactPoint); + float sweepContact = smoothstep(1.0, 0.0, sweepDistance / max(max(tip.z, previousTip.z) * 1.04, 0.001)); + float2 delta = world - contactPoint; + float normalDistance = dot(delta, sampleNormal) / max(tip.z, 0.001); + float tangentDistance = dot(delta, sampleTangent) / max(tip.w, 0.001); + float ellipse = sqrt(normalDistance * normalDistance + tangentDistance * tangentDistance); + float tipContact = max(smoothstep(1.0, 0.0, ellipse), sweepContact * 0.86); + float longitudinalGate = smoothstep(1.0, 0.0, abs(tangentDistance) * 0.62); + float pigment = BokushoCanvasField[((sampleStrokeIndex * tuftCount) + tuftIndex) * sampleCount + sampleIndex]; + float contribution = pigment + * tipContact + * (0.006 + contactCore * 0.994) + * (0.06 + longitudinalGate * 0.94) + * (1.0 - abs((float)sampleDelta) * 0.070) + * (0.82 + sweepContact * 0.24); + pigmentPeak = max(pigmentPeak, contribution); + pigmentFlow += contribution; + } + } + + float decisiveInk = saturate((pigmentPeak - 0.012) * 2.25 + pigmentFlow * 0.002); + return decisiveInk * (0.44 + pressure * 0.36 + saturate(bokushoMaterial.z * 0.5) * 0.20); +} + +float bokushoPageHeight(float2 world) +{ + if (bokushoShape.w > 0.5) + { + float pageSize = 512.0; + float2 uv = (world - viewCenter) / max(viewRadius, 0.001) * 0.5 + 0.5; + int2 pixel = int2(floor(uv * (pageSize - 1.0) + 0.5)); + if (all(pixel >= int2(0, 0)) && all(pixel < int2((int)pageSize, (int)pageSize))) + { + uint densityValue = BokushoPageDensityField[(uint)(pixel.y * (int)pageSize + pixel.x)]; + float density = (float)densityValue / BOKUSHO_PAGE_DENSITY_SCALE; + return saturate(1.0 - exp(-density)); + } + } + + uint strokeCount = min(max((uint)round(bokushoShape.w), 0u), 64u); + float height = 0.0; + [loop] + for (uint strokeIndex = 0u; strokeIndex < strokeCount; strokeIndex += 1u) + { + height += bokushoStrokePageHeight(world, BokushoBrushStrokes[strokeIndex], strokeIndex); + } + + return saturate(height); +} + float powerPulse(float distanceValue, float radius, float power) { float normalized = saturate(distanceValue / max(radius, 0.001)); @@ -98,7 +320,7 @@ float D3D12HeightFieldBasePS(VertexOut input) : SV_Target float2 world = viewWorld(saturate(input.uv)); float slow = sin((world.x * 0.08 + world.y * 0.06) + timeSeconds * 0.27) * sin((world.x * -0.04 + world.y * 0.07) - timeSeconds * 0.19) * 0.035; - return slow; + return slow + bokushoPageHeight(world) * 5.0; } BrushVertexOut D3D12HeightFieldBrushVS(uint vertexId : SV_VertexID, uint instanceId : SV_InstanceID) diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12PlanetarySurfacePage.hlsl b/src/Aquarium.Engine/Render/Shaders/D3D12PlanetarySurfacePage.hlsl new file mode 100644 index 0000000..d883cf4 --- /dev/null +++ b/src/Aquarium.Engine/Render/Shaders/D3D12PlanetarySurfacePage.hlsl @@ -0,0 +1,12 @@ +struct PageInput { float4 direction_radius; float4 sampling; }; +struct PageOutput { float4 height_gradient; float4 masks; }; +StructuredBuffer page_inputs : register(t0); +RWStructuredBuffer page_outputs : register(u0); +cbuffer PageConstants : register(b0) { uint page_sample_count; } +[numthreads(64,1,1)] +void D3D12ZyphosTerrainPageCS(uint3 id : SV_DispatchThreadID) +{ + if(id.x>=page_sample_count)return; + page_outputs[id.x].height_gradient=0; + page_outputs[id.x].masks=float4(0,0,0,1); +} diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12PlanetarySurfacePageSummary.hlsl b/src/Aquarium.Engine/Render/Shaders/D3D12PlanetarySurfacePageSummary.hlsl new file mode 100644 index 0000000..2b73b76 --- /dev/null +++ b/src/Aquarium.Engine/Render/Shaders/D3D12PlanetarySurfacePageSummary.hlsl @@ -0,0 +1,21 @@ +#include "GameCult.Geometry/GameCult.Geometry.hlsl" + +struct PageOutput { float4 height_gradient; float4 masks; }; +struct PageSummary { float4 bounds; float4 metadata; }; +StructuredBuffer summary_inputs : register(t0); +RWStructuredBuffer summary_outputs : register(u0); +cbuffer PageConstants : register(b0) { uint page_sample_count; uint page_sample_offset; uint page_summary_index; } + +[numthreads(1,1,1)] +void D3D12ZyphosTerrainPageSummaryCS(uint3 id : SV_DispatchThreadID) +{ + if (id.x != 0 || page_sample_count == 0) return; + float4 summary = gamecult_geometry_planetary_summary_empty(); + [loop] for (uint index = 0; index < page_sample_count; index++) + { + PageOutput sample = summary_inputs[page_sample_offset + index]; + summary = gamecult_geometry_planetary_summary_accumulate(summary, sample.height_gradient, sample.masks.w); + } + summary_outputs[page_summary_index].bounds = summary; + summary_outputs[page_summary_index].metadata = float4(page_sample_count, page_sample_offset, page_summary_index, 1.0); +} diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12PlanetarySurfacePatch.hlsl b/src/Aquarium.Engine/Render/Shaders/D3D12PlanetarySurfacePatch.hlsl new file mode 100644 index 0000000..df4c6e8 --- /dev/null +++ b/src/Aquarium.Engine/Render/Shaders/D3D12PlanetarySurfacePatch.hlsl @@ -0,0 +1,11 @@ +#include "D3D12SdfCommon.hlsli" + +struct PlanetaryPatchVertexOut { float4 position:SV_Position; }; +PlanetaryPatchVertexOut D3D12ZyphosTerrainPatchVS(uint vertexId:SV_VertexID,uint instanceId:SV_InstanceID) +{ + PlanetaryPatchVertexOut output; output.position=float4(-2,-2,1,1); return output; +} +SceneOut D3D12ZyphosTerrainPatchPS(PlanetaryPatchVertexOut input) +{ + discard; return (SceneOut)0; +} diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12PlanetaryTopologyParity.hlsl b/src/Aquarium.Engine/Render/Shaders/D3D12PlanetaryTopologyParity.hlsl new file mode 100644 index 0000000..49d819d --- /dev/null +++ b/src/Aquarium.Engine/Render/Shaders/D3D12PlanetaryTopologyParity.hlsl @@ -0,0 +1,74 @@ +#include "GameCult.Geometry/GameCult.Geometry.hlsl" + +struct PlanetaryTopologyInput +{ + float4 direction_face; + float4 coordinate_mode; + float4 projection_parameters; +}; + +struct PlanetaryTopologyOutput +{ + float4 direction_face; + float4 coordinate_valid; +}; + +StructuredBuffer topology_inputs : register(t0); +RWStructuredBuffer topology_outputs : register(u0); +cbuffer PlanetaryTopologyConstants : register(b0) { uint topology_input_count; } + +[numthreads(64, 1, 1)] +void D3D12PlanetaryTopologyParityCS(uint3 id : SV_DispatchThreadID) +{ + if (id.x >= topology_input_count) return; + PlanetaryTopologyInput input = topology_inputs[id.x]; + PlanetaryTopologyOutput output = (PlanetaryTopologyOutput)0; + if (input.coordinate_mode.z < 0.5) + { + int face = (int)input.direction_face.w; + output.direction_face = float4(gamecult_geometry_planetary_face_direction(face, input.coordinate_mode.xy), (float)face); + output.coordinate_valid = float4(input.coordinate_mode.xy, 0.0, 1.0); + } + else if (input.coordinate_mode.z < 1.5) + { + int face; + float2 coordinate = gamecult_geometry_planetary_face_coordinate(input.direction_face.xyz, face); + output.direction_face = float4(normalize(input.direction_face.xyz), (float)face); + output.coordinate_valid = float4(coordinate, 0.0, 1.0); + } + else if (input.coordinate_mode.z < 2.5) + { + output.direction_face = float4(normalize(input.direction_face.xyz), 0.0); + output.coordinate_valid = float4(gamecult_geometry_planetary_equirectangular_forward(input.direction_face.xyz), 0.0, 1.0); + } + else if (input.coordinate_mode.z < 3.5) + { + output.direction_face = float4(gamecult_geometry_planetary_equirectangular_inverse(input.coordinate_mode.xy), 0.0); + output.coordinate_valid = float4(input.coordinate_mode.xy, 0.0, 1.0); + } + else if (input.coordinate_mode.z < 4.5) + { + output.direction_face = float4(normalize(input.direction_face.xyz), 0.0); + output.coordinate_valid = float4(gamecult_geometry_planetary_equal_earth_forward(input.direction_face.xyz), 0.0, 1.0); + } + else if (input.coordinate_mode.z < 5.5) + { + output.direction_face = float4(gamecult_geometry_planetary_equal_earth_inverse(input.coordinate_mode.xy), 0.0); + output.coordinate_valid = float4(input.coordinate_mode.xy, 0.0, 1.0); + } + else if (input.coordinate_mode.z < 6.5) + { + float2 coordinate; + bool valid=gamecult_geometry_planetary_projection_forward(input.direction_face.xyz,(int)input.coordinate_mode.w,input.projection_parameters.xyz,coordinate); + output.direction_face=float4(normalize(input.direction_face.xyz),0); + output.coordinate_valid=float4(coordinate,0,valid?1.0:0.0); + } + else + { + float3 direction; + bool valid=gamecult_geometry_planetary_projection_inverse(input.coordinate_mode.xy,(int)input.coordinate_mode.w,input.projection_parameters.xyz,direction); + output.direction_face=float4(direction,0); + output.coordinate_valid=float4(input.coordinate_mode.xy,0,valid?1.0:0.0); + } + topology_outputs[id.x] = output; +} diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12PointCloud.hlsl b/src/Aquarium.Engine/Render/Shaders/D3D12PointCloud.hlsl new file mode 100644 index 0000000..d7ca078 --- /dev/null +++ b/src/Aquarium.Engine/Render/Shaders/D3D12PointCloud.hlsl @@ -0,0 +1,292 @@ +cbuffer AquariumFrame : register(b0) +{ + float2 resolution; + float timeSeconds; + float viewRadius; + float3 cameraPosition; + float farDistance; + float3 cameraTarget; + float sceneFlags; + float2 viewCenter; + float frameIndex; + float previousTimeSeconds; + float3 previousCameraPosition; + float previousViewRadius; + float3 previousCameraTarget; + float previousSceneFlags; + float2 previousViewCenter; + float2 jitterPixels; + float2 previousJitterPixels; + float renderDebugMode; + float exposure; + float bloomIntensity; + float bloomVeilIntensity; + float4 cursorWorlds; + float4 temporalGaussianInfo; + float4 cameraFrustumXy; + float4 cameraFrustumZ; +}; + +cbuffer D3D12PointCloudConstants : register(b1) +{ + float4 sourceShape; + float4 projection; + float4 depthRange; +}; + +static const float FIELD_ID_POINT_CLOUD = 71.0; + +struct PointCloudVertex +{ + float3 position; + float3 normal; + float2 uv; + float4 color; +}; + +struct FractalSdfSplat +{ + float4 centerRadius; + float4 orientation; + float4 radiiFalloff; + float4 materialConfidence; + float4 key; +}; + +struct SdfEnvelopeReservoir +{ + float4 centerRadius; + float4 radiiFalloff; + float4 weightTargetCount; + float4 validation; +}; + +struct PbrMaterialReservoir +{ + float4 baseColorRoughMetal; + float4 normalVariance; + float4 weightTargetCount; + float4 validation; +}; + +struct RadiosityReservoir +{ + float4 radianceDistance; + float4 directionOcclusion; + float4 weightTargetCount; + float4 validation; +}; + +Texture2D disparityTexture : register(t0); +RWStructuredBuffer pointVertices : register(u0); +RWStructuredBuffer pointIndices : register(u1); +RWStructuredBuffer surfaceSplats : register(u2); +RWStructuredBuffer surfaceSdfReservoirs : register(u3); +RWStructuredBuffer surfacePbrReservoirs : register(u4); +RWStructuredBuffer surfaceRadiosityReservoirs : register(u5); + +struct VertexIn +{ + float3 position : POSITION; + float3 normal : NORMAL; + float2 uv : TEXCOORD0; + float4 color : COLOR0; +}; + +struct VertexOut +{ + float4 position : SV_Position; + float3 worldPosition : TEXCOORD0; + float3 normal : TEXCOORD1; + float2 uv : TEXCOORD2; + float4 color : COLOR0; +}; + +struct SceneOut +{ + float4 colorTravel : SV_Target0; + float4 metadata : SV_Target1; + float4 control : SV_Target2; + float4 reservoirGuide : SV_Target3; + float overdraw : SV_Target4; + float depth : SV_Depth; +}; + +float DepthFromDisparity(float disparity) +{ + float minDepth = max(0.001, depthRange.x); + float maxDepth = max(minDepth + 0.001, depthRange.y); + return disparity > 0.0001 + ? saturate(projection.x / max(disparity, 0.0001)) * (maxDepth - minDepth) + minDepth + : maxDepth; +} + +void cameraBasis(float3 camera, float3 target, out float3 forward, out float3 right, out float3 up) +{ + forward = normalize(target - camera); + float3 worldUp = abs(forward.y) > 0.96 ? float3(0.0, 0.0, 1.0) : float3(0.0, 1.0, 0.0); + right = normalize(cross(worldUp, forward)); + up = normalize(cross(forward, right)); +} + +float3 PositionFromPixel(uint2 pixel, float depth) +{ + float focalPixels = max(1.0, projection.y); + float principalX = projection.z; + float principalY = projection.w; + float minDepth = max(0.001, depthRange.x); + float maxDepth = max(minDepth + 0.001, depthRange.y); + float lateralDisplayScale = max(1.0, depthRange.z); + float forwardDisplayAnchor = depthRange.w > 0.0 ? depthRange.w : (minDepth + maxDepth) * 0.5; + float3 cameraLocal = float3( + ((float)pixel.x - principalX) / focalPixels * depth, + -((float)pixel.y - principalY) / focalPixels * depth, + depth); + float3 forward; + float3 right; + float3 up; + cameraBasis(cameraPosition, cameraTarget, forward, right, up); + return cameraTarget + + right * cameraLocal.x * lateralDisplayScale + + up * cameraLocal.y * lateralDisplayScale + + forward * (cameraLocal.z - forwardDisplayAnchor); +} + +float DisparityAt(int2 pixel, uint width, uint height) +{ + pixel.x = clamp(pixel.x, 0, (int)width - 1); + pixel.y = clamp(pixel.y, 0, (int)height - 1); + return disparityTexture.Load(int3(pixel, 0)); +} + +float Hash01(uint value) +{ + value ^= value >> 16; + value *= 0x7feb352du; + value ^= value >> 15; + value *= 0x846ca68bu; + value ^= value >> 16; + return (float)(value & 0x00ffffffu) / 16777216.0; +} + +float4 projectWorld(float3 worldPosition) +{ + float3 forward; + float3 right; + float3 up; + cameraBasis(cameraPosition, cameraTarget, forward, right, up); + float3 relative = worldPosition - cameraPosition; + float3 view = float3(dot(relative, right), dot(relative, up), dot(relative, forward)); + float z = max(view.z, 0.0001); + float2 slope = view.xy / z; + float2 frustumMin = float2(cameraFrustumXy.x, cameraFrustumXy.z); + float2 frustumMax = float2(cameraFrustumXy.y, cameraFrustumXy.w); + float2 uv = (slope - frustumMin) / max(frustumMax - frustumMin, float2(0.0001, 0.0001)); + return float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), saturate(z / max(farDistance, 0.0001)), 1.0); +} + +[numthreads(128, 1, 1)] +void D3D12PointCloudFromDisparityCS(uint3 dispatchThreadId : SV_DispatchThreadID) +{ + uint pointIndex = dispatchThreadId.x; + uint pointCount = (uint)max(1.0, sourceShape.z); + if (pointIndex >= pointCount) + { + return; + } + + uint width = (uint)max(1.0, sourceShape.x); + uint height = (uint)max(1.0, sourceShape.y); + uint stride = (uint)max(1.0, sourceShape.w); + uint sampledWidth = max(1, (width + stride - 1) / stride); + uint x = (pointIndex % sampledWidth) * stride; + uint y = (pointIndex / sampledWidth) * stride; + x = min(x, width - 1); + y = min(y, height - 1); + + float disparity = disparityTexture.Load(int3(x, y, 0)); + float confidence = disparity > 0.0001 ? 1.0 : 0.0; + float depth = DepthFromDisparity(disparity); + + float3 position = PositionFromPixel(uint2(x, y), depth); + float minDepth = max(0.001, depthRange.x); + float maxDepth = max(minDepth + 0.001, depthRange.y); + float normalizedDepth = saturate((depth - minDepth) / max(maxDepth - minDepth, 0.0001)); + float leftDepth = DepthFromDisparity(DisparityAt(int2((int)x - (int)stride, (int)y), width, height)); + float rightDepth = DepthFromDisparity(DisparityAt(int2((int)x + (int)stride, (int)y), width, height)); + float upDepth = DepthFromDisparity(DisparityAt(int2((int)x, (int)y - (int)stride), width, height)); + float downDepth = DepthFromDisparity(DisparityAt(int2((int)x, (int)y + (int)stride), width, height)); + float3 leftPosition = PositionFromPixel(uint2(max((int)x - (int)stride, 0), y), leftDepth); + float3 rightPosition = PositionFromPixel(uint2(min(x + stride, width - 1), y), rightDepth); + float3 upPosition = PositionFromPixel(uint2(x, max((int)y - (int)stride, 0)), upDepth); + float3 downPosition = PositionFromPixel(uint2(x, min(y + stride, height - 1)), downDepth); + float3 normal = normalize(cross(rightPosition - leftPosition, downPosition - upPosition)); + normal = dot(normal, normal) > 0.25 ? normal : float3(0.0, 0.0, -1.0); + float curvature = saturate((abs(leftDepth + rightDepth - 2.0 * depth) + abs(upDepth + downDepth - 2.0 * depth)) * 4.0); + + PointCloudVertex vertex; + vertex.position = position; + vertex.normal = normal; + vertex.uv = float2((float)x / max(1.0, (float)(width - 1)), (float)y / max(1.0, (float)(height - 1))); + vertex.color = confidence > 0.0 + ? float4(lerp(float3(0.2, 0.75, 1.0), float3(1.0, 0.95, 0.35), normalizedDepth), 1.0) + : float4(0.08, 0.14, 0.22, 0.18); + pointVertices[pointIndex] = vertex; + pointIndices[pointIndex] = pointIndex; + + float reservoirConfidence = confidence; + float sampleRadius = lerp(0.080, 0.035, saturate(confidence)) * (1.0 + curvature * 2.5); + FractalSdfSplat splat; + splat.centerRadius = float4(position, sampleRadius); + splat.orientation = float4(normal, 1.0); + splat.radiiFalloff = float4(sampleRadius, sampleRadius * (1.0 + curvature), sampleRadius * 0.35, 3.0 + curvature * 4.0); + splat.materialConfidence = float4(saturate(1.0 - normalizedDepth), 1.0, 1.0, reservoirConfidence); + splat.key = float4((float)pointIndex, frameIndex, curvature, Hash01(pointIndex + (uint)frameIndex * 1664525u)); + surfaceSplats[pointIndex] = splat; + + SdfEnvelopeReservoir sdf; + sdf.centerRadius = splat.centerRadius; + sdf.radiiFalloff = splat.radiiFalloff; + sdf.weightTargetCount = float4(reservoirConfidence, reservoirConfidence, 1.0, reservoirConfidence); + sdf.validation = float4(reservoirConfidence, frameIndex, curvature, 1.0); + surfaceSdfReservoirs[pointIndex] = sdf; + + PbrMaterialReservoir pbr; + pbr.baseColorRoughMetal = float4(vertex.color.rgb, 0.38 + curvature * 0.28); + pbr.normalVariance = float4(normal, curvature); + pbr.weightTargetCount = sdf.weightTargetCount; + pbr.validation = sdf.validation; + surfacePbrReservoirs[pointIndex] = pbr; + + RadiosityReservoir radiosity; + radiosity.radianceDistance = float4(vertex.color.rgb * 0.18, depth); + radiosity.directionOcclusion = float4(normalize(-normal + 0.01), 0.35 + confidence * 0.45); + radiosity.weightTargetCount = sdf.weightTargetCount; + radiosity.validation = sdf.validation; + surfaceRadiosityReservoirs[pointIndex] = radiosity; +} + +VertexOut D3D12PointCloudVS(VertexIn input) +{ + VertexOut output; + output.position = projectWorld(input.position); + output.worldPosition = input.position; + output.normal = input.normal; + output.uv = input.uv; + output.color = input.color; + return output; +} + +SceneOut D3D12PointCloudPS(VertexOut input) +{ + SceneOut output; + float travel = length(input.worldPosition - cameraPosition); + float alpha = saturate(input.color.a); + output.colorTravel = float4(input.color.rgb * alpha, travel); + output.metadata = float4(FIELD_ID_POINT_CLOUD, normalize(input.normal)); + output.control = float4(alpha, 1.0, 0.35, 0.0); + output.reservoirGuide = float4(alpha, 0.0, alpha, 0.0); + output.overdraw = 1.0; + output.depth = input.position.z / input.position.w; + return output; +} diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12Post.hlsl b/src/Aquarium.Engine/Render/Shaders/D3D12Post.hlsl index 6c95ef1..7ccf90b 100644 --- a/src/Aquarium.Engine/Render/Shaders/D3D12Post.hlsl +++ b/src/Aquarium.Engine/Render/Shaders/D3D12Post.hlsl @@ -42,6 +42,7 @@ Texture2D bloomTexture4 : register(t33); Texture2D bloomTexture5 : register(t34); Texture2D bloomTexture6 : register(t35); Texture2D bloomTexture7 : register(t36); +Texture2D sceneOverdrawTexture : register(t76); Texture2D currentReservoirGuideTexture : register(t26); Texture2D blueNoiseTexture : register(t28); SamplerState sourceSampler : register(s0); @@ -524,6 +525,113 @@ float3 presentColor(float3 scene, float2 uv, float bloomScale) return aces(exposedScene + bloomContribution); } +float reservoirGuideConfidence(float4 guide, float4 control) +{ + float confidence = guide.x > 0.0 ? saturate(guide.x) : (control.w > 0.0 ? saturate(control.w / MAX_HISTORY_AGE) : 1.0); + float validity = guide.z > 0.0 ? saturate(guide.z) : 1.0; + return confidence * validity; +} + +float reservoirReconstructionWeight( + float4 centerColorTravel, + float4 centerMetadata, + float4 centerControl, + float4 centerGuide, + float4 sampleColorTravel, + float4 sampleMetadata, + float4 sampleControl, + float4 sampleGuide, + float2 offset) +{ + float centerTravel = centerColorTravel.w; + float sampleTravel = sampleColorTravel.w; + float travelTolerance = max(0.05, max(centerTravel, 0.0) * 0.02); + float travelWeight = 1.0 - smoothstep(travelTolerance, travelTolerance * 4.0, abs(sampleTravel - centerTravel)); + float fieldWeight = abs(sampleMetadata.x - centerMetadata.x) < 0.001 ? 1.0 : 0.0; + float normalWeight = 1.0; + if (dot(centerMetadata.yzw, centerMetadata.yzw) > 0.01 && + dot(sampleMetadata.yzw, sampleMetadata.yzw) > 0.01) + { + normalWeight = smoothstep(0.62, 0.97, dot(normalize(centerMetadata.yzw), normalize(sampleMetadata.yzw))); + } + + float coverageWeight = 1.0 - smoothstep(0.12, 0.55, abs(saturate(sampleControl.x) - saturate(centerControl.x))); + float detailWeight = 1.0 - smoothstep(0.08, 0.38, abs(saturate(sampleControl.z) - saturate(centerControl.z))); + float colorWeight = 1.0 - smoothstep(0.20, 1.35, length(sampleColorTravel.rgb - centerColorTravel.rgb)); + float sampleConfidence = lerp(0.35, 1.0, reservoirGuideConfidence(sampleGuide, sampleControl)); + float distanceWeight = exp2(-dot(offset, offset) * 0.85); + return fieldWeight * travelWeight * normalWeight * coverageWeight * detailWeight * colorWeight * sampleConfidence * distanceWeight; +} + +float3 reconstructReservoirColor(float2 uv, out float reconstructionMix) +{ + uint sourceWidth; + uint sourceHeight; + sourceTexture.GetDimensions(sourceWidth, sourceHeight); + uint2 dimensions = max(uint2(sourceWidth, sourceHeight), uint2(1, 1)); + float2 texel = 1.0 / (float2)dimensions; + int2 centerPixel = clamp((int2)floor(uv * (float2)dimensions), int2(0, 0), (int2)dimensions - int2(1, 1)); + float2 centerUv = ((float2)centerPixel + 0.5) * texel; + + float4 centerColorTravel = sourceTexture.SampleLevel(sourceSampler, uv, 0.0); + float4 centerMetadata = currentSceneMetadataTexture.Load(int3(centerPixel, 0)); + float4 centerControl = currentSceneControlTexture.Load(int3(centerPixel, 0)); + float4 centerGuide = currentReservoirGuideTexture.Load(int3(centerPixel, 0)); + float centerConfidence = reservoirGuideConfidence(centerGuide, centerControl); + float centerDetail = saturate(centerControl.z); + float edgePreservation = 1.0 - smoothstep(0.28, 0.82, centerDetail); + + float centerWeight = lerp(2.25, 1.15, saturate(1.0 - centerConfidence)); + float3 sumColor = centerColorTravel.rgb * centerWeight; + float sumWeight = centerWeight; + static const int2 offsets[8] = + { + int2(-1, 0), + int2(1, 0), + int2(0, -1), + int2(0, 1), + int2(-1, -1), + int2(1, -1), + int2(-1, 1), + int2(1, 1) + }; + + [unroll] + for (int index = 0; index < 8; index++) + { + int2 offset = offsets[index]; + int2 samplePixel = clamp(centerPixel + offset, int2(0, 0), (int2)dimensions - int2(1, 1)); + float4 sampleColorTravel = sourceTexture.Load(int3(samplePixel, 0)); + float4 sampleMetadata = currentSceneMetadataTexture.Load(int3(samplePixel, 0)); + float4 sampleControl = currentSceneControlTexture.Load(int3(samplePixel, 0)); + float4 sampleGuide = currentReservoirGuideTexture.Load(int3(samplePixel, 0)); + float weight = reservoirReconstructionWeight( + centerColorTravel, + centerMetadata, + centerControl, + centerGuide, + sampleColorTravel, + sampleMetadata, + sampleControl, + sampleGuide, + (float2)offset); + sumColor += sampleColorTravel.rgb * weight; + sumWeight += weight; + } + + float3 filtered = sumColor / max(sumWeight, 0.0001); + float neighborAgreement = saturate((sumWeight - centerWeight) / 4.0); + float blendNeed = saturate((1.0 - centerConfidence) * 0.35 + neighborAgreement * 0.10); + float reservoirMaturity = smoothstep(0.5, 1.5, max(centerGuide.y, centerControl.w)); + float filterStrength = min(0.20, blendNeed * edgePreservation * reservoirMaturity); + reconstructionMix = filterStrength; + float3 reconstructed = lerp(centerColorTravel.rgb, filtered, filterStrength); + float3 neighborhoodMin; + float3 neighborhoodMax; + currentNeighborhood(centerUv, neighborhoodMin, neighborhoodMax); + return clamp(reconstructed, neighborhoodMin, neighborhoodMax); +} + float blueNoiseAt(float2 uv, uint salt) { uint width; @@ -1119,15 +1227,29 @@ float4 reservoirDebugOrColor(FieldReservoirSample candidate, uint2 pixel) return float4(fieldReservoirResolvedColor(candidate, farDistance), candidate.colorTravel.w); } +float3 overdrawHeat(float overdraw) +{ + if (overdraw <= 0.5) + { + return float3(0.0, 0.0, 0.0); + } + + float hot = saturate(log2(max(overdraw, 1.0)) / 4.0); + float3 low = lerp(float3(0.0, 0.22, 0.95), float3(0.0, 0.95, 0.35), saturate(overdraw - 1.0)); + float3 mid = lerp(low, float3(1.0, 0.92, 0.05), saturate((overdraw - 2.0) / 2.0)); + return lerp(mid, float3(1.0, 0.05, 0.35), hot); +} + FieldReservoirResolveOut D3D12FieldReservoirResolvePS(VertexOut input) { uint2 pixel = (uint2)pixelFromUv(input.uv); + FieldReservoirSample current = currentFrameReservoirSample(pixel, input.uv); FieldReservoirResolveOut output; - output.colorTravel = sourceTexture.Load(int3(pixel, 0)); - output.metadata = loadCurrentMetadata(input.uv); - output.control = loadCurrentControl(input.uv); - output.reservoirGuide = loadCurrentReservoirGuide(input.uv); + output.colorTravel = reservoirDebugOrColor(current, pixel); + output.metadata = current.metadata; + output.control = current.control; + output.reservoirGuide = current.guide; return output; } @@ -1175,7 +1297,13 @@ ResolveOut D3D12ReservoirPresentationResolvePS(VertexOut input) float currentReservoirConfidence = currentReservoirGuide.x > 0.0 ? saturate(currentReservoirGuide.x) : 1.0; float reservoirSampleAge = max(currentReservoirGuide.y, 0.0); float currentReservoirDomainValidity = currentReservoirGuide.z > 0.0 ? saturate(currentReservoirGuide.z) : 1.0; + float reconstructionMix = 0.0; float3 resolved = resolvedSample.rgb; + if (renderDebugMode < 0.5 || (renderDebugMode >= 20.5 && renderDebugMode < 21.5)) + { + resolved = reconstructReservoirColor(input.uv, reconstructionMix); + } + float3 finalColor = presentColor(resolved, input.uv, 1.0); if (renderDebugMode > 0.5 && renderDebugMode < 1.5) { @@ -1223,6 +1351,11 @@ ResolveOut D3D12ReservoirPresentationResolvePS(VertexOut input) { finalColor = float3(currentReservoirConfidence, saturate(reservoirSampleAge / MAX_HISTORY_AGE), currentReservoirDomainValidity); } + else if (renderDebugMode >= 20.5 && renderDebugMode < 21.5) + { + float overdraw = sceneOverdrawTexture.SampleLevel(sourceSampler, input.uv, 0.0); + finalColor = overdrawHeat(overdraw); + } else if (renderDebugMode >= 12.5 && renderDebugMode < 18.5) { finalColor = saturate(resolved); diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12ReservoirHistoryUpdate.hlsl b/src/Aquarium.Engine/Render/Shaders/D3D12ReservoirHistoryUpdate.hlsl new file mode 100644 index 0000000..17aa4fb --- /dev/null +++ b/src/Aquarium.Engine/Render/Shaders/D3D12ReservoirHistoryUpdate.hlsl @@ -0,0 +1,116 @@ +cbuffer AquariumFrame : register(b0) +{ + float2 resolution; + float timeSeconds; + float viewRadius; + float3 cameraPosition; + float farDistance; + float3 cameraTarget; + float sceneFlags; + float2 viewCenter; + float frameIndex; + float previousTimeSeconds; + float3 previousCameraPosition; + float previousViewRadius; + float3 previousCameraTarget; + float previousSceneFlags; + float2 previousViewCenter; + float2 jitterPixels; + float2 previousJitterPixels; + float renderDebugMode; + float exposure; + float bloomIntensity; + float bloomVeilIntensity; + float4 cursorWorlds; + float4 temporalGaussianInfo; + float4 cameraFrustumXy; + float4 cameraFrustumZ; + float4 gpuFusionInfo; + float4 fractalReservoirInfo; + float4 fractalReservoirFrame; + float4 reservoirBudgetInfo; +}; + +Texture2D sourceTexture : register(t0); +Texture2D currentSceneMetadataTexture : register(t5); +Texture2D currentSceneControlTexture : register(t7); +Texture2D currentReservoirGuideTexture : register(t26); + +#include "D3D12FieldReservoir.hlsli" + +StructuredBuffer fieldReservoirCandidates : register(t45); +RWStructuredBuffer reservoirHistoryWrite : register(u22); +RWTexture2D reservoirResolvedTexture : register(u23); + +float4 loadTexturePixel(Texture2D source, uint2 pixel) +{ + return source.Load(int3(pixel, 0)); +} + +FieldReservoirSample sceneFieldReservoirSample(uint2 pixel, float2 uv) +{ + float4 colorTravel = loadTexturePixel(sourceTexture, pixel); + float4 metadata = loadTexturePixel(currentSceneMetadataTexture, pixel); + float4 control = loadTexturePixel(currentSceneControlTexture, pixel); + float4 guide = loadTexturePixel(currentReservoirGuideTexture, pixel); + float target = fieldReservoirDefaultTarget(colorTravel, control, guide); + return makeFieldReservoirSample( + colorTravel, + metadata, + control, + guide, + float4(uv, colorTravel.w, 0.0), + float4(uv, uv), + float4(1.0, 1.0, FieldDomainKindScreen, FieldShiftKindNone), + target, + 1.0, + 1.0, + FieldProposalKindDeterministicStructural); +} + +FieldReservoirSample currentFrameReservoirSample(uint2 pixel, float2 uv) +{ + uint width = (uint)max(resolution.x, 1.0); + uint baseIndex = (pixel.y * width + pixel.x) * FieldReservoirSlotsPerPixel; + FieldReservoirSample current = sceneFieldReservoirSample(pixel, uv); + FieldReservoirSample candidate = fieldReservoirCandidates[baseIndex + FieldReservoirRowCurrent]; + current = mergeFieldReservoirVisibilityProposals( + current, + candidate, + fieldReservoirRandom01(pixel, (uint)frameIndex, 31u), + farDistance); + current.guide.y = 0.0; + current.guide.w = 0.0; + return current; +} + +float4 reservoirHistoryColor(FieldReservoirSample sample) +{ + if (!fieldReservoirSampleValid(sample, farDistance)) + { + return float4(0.0, 0.0, 0.0, farDistance + 1.0); + } + + return float4(fieldReservoirResolvedColor(sample, farDistance), sample.colorTravel.w); +} + +[numthreads(8, 8, 1)] +void D3D12ReservoirHistoryUpdateCS(uint3 dispatchThreadId : SV_DispatchThreadID) +{ + uint2 dimensions = (uint2)max(resolution, float2(1.0, 1.0)); + if (dispatchThreadId.x >= dimensions.x || dispatchThreadId.y >= dimensions.y) + { + return; + } + + uint2 pixel = dispatchThreadId.xy; + float2 uv = (float2(pixel) + 0.5) / float2(dimensions); + uint baseIndex = (pixel.y * dimensions.x + pixel.x) * FieldReservoirSlotsPerPixel; + FieldReservoirSample current = currentFrameReservoirSample(pixel, uv); + + reservoirHistoryWrite[baseIndex + FieldReservoirRowCurrent] = current; + reservoirHistoryWrite[baseIndex + FieldReservoirRowTemporal] = current; + reservoirHistoryWrite[baseIndex + FieldReservoirRowSpatial] = current; + reservoirHistoryWrite[baseIndex + FieldReservoirRowFinal] = current; + reservoirResolvedTexture[pixel] = reservoirHistoryColor(current); +} diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12Scene.hlsl b/src/Aquarium.Engine/Render/Shaders/D3D12Scene.hlsl index 327acec..53363dc 100644 --- a/src/Aquarium.Engine/Render/Shaders/D3D12Scene.hlsl +++ b/src/Aquarium.Engine/Render/Shaders/D3D12Scene.hlsl @@ -34,7 +34,7 @@ SamplerState linearSampler : register(s0); static const float FIELD_ID_HEIGHT_FIELD = 4.0; static const float PI = 3.14159265359; -static const float HEIGHT_FIELD_TEXEL_COUNT = 128.0; +static const float HEIGHT_FIELD_TEXEL_COUNT = 512.0; static const float SURFACE_FLAT_REFLECTION_MAX_LOD = 3.0; static const float BACKGROUND_PMREM_LOD = 3.0; static const float BACKGROUND_PMREM_CONE = 0.16; @@ -55,6 +55,7 @@ struct SceneOut float4 metadata : SV_Target1; float4 control : SV_Target2; float4 reservoirGuide : SV_Target3; + float overdraw : SV_Target4; float depth : SV_Depth; }; @@ -335,12 +336,18 @@ float3 fractalStarClusters(float3 direction) float3 surfaceMirrorRadiance(float3 p, float3 direction, out float3 normal) { + float height = terrainHeight(p.xy); float2 gradient = terrainGradient(p.xy); normal = normalize(float3(-gradient.x, -gradient.y, 1.0)); + float slope = length(gradient); float3 reflectionDirection = reflect(direction, normal); - float flatness = 1.0 - smoothstep(SURFACE_FLAT_SLOPE_START, SURFACE_FLAT_SLOPE_END, length(gradient)); + float flatness = 1.0 - smoothstep(SURFACE_FLAT_SLOPE_START, SURFACE_FLAT_SLOPE_END, slope); float lod = flatness * SURFACE_FLAT_REFLECTION_MAX_LOD; - return studioPmremConeSample(reflectionDirection, lod, flatness * 0.055); + float3 reflected = studioPmremConeSample(reflectionDirection, lod, flatness * 0.055); + float inkMass = saturate((height - 0.006) * 32.0 + smoothstep(0.03, 0.16, slope) * 0.42); + float3 paper = float3(0.84, 0.75, 0.55) * (0.62 + flatness * 0.28); + float3 ink = float3(0.010, 0.008, 0.006); + return lerp(reflected * 0.18 + paper, ink, inkMass * 0.90); } float3 backgroundRadiance(float3 direction) @@ -408,6 +415,7 @@ SceneOut D3D12ScenePS(VertexOut input) output.metadata = float4(result.fieldId, result.normal); output.control = float4(result.coverage, result.stepCount / 72.0, 0.0, 0.0); output.reservoirGuide = float4(1.0, 0.0, 1.0, 0.0); + output.overdraw = 1.0; output.depth = saturate(result.travel / max(farDistance, 0.001)); return output; } @@ -662,6 +670,7 @@ SceneOut D3D12SplinePS(SplineVertexOut input) output.metadata = float4(5000.0, closestT, sdf, radiusPx); output.control = float4(claimCoverage, coverage, saturate(radiusPx / 32.0), 0.0); output.reservoirGuide = float4(claimCoverage, 0.0, coverage, 0.0); + output.overdraw = 1.0; output.depth = saturate(travel / max(farDistance, 0.0001)); return output; } diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12SdfCommon.hlsli b/src/Aquarium.Engine/Render/Shaders/D3D12SdfCommon.hlsli index 192618b..ffc5436 100644 --- a/src/Aquarium.Engine/Render/Shaders/D3D12SdfCommon.hlsli +++ b/src/Aquarium.Engine/Render/Shaders/D3D12SdfCommon.hlsli @@ -77,6 +77,7 @@ struct SceneOut float4 metadata : SV_Target1; float4 control : SV_Target2; float4 reservoirGuide : SV_Target3; + float overdraw : SV_Target4; float depth : SV_Depth; }; diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12SdfProxy.hlsli b/src/Aquarium.Engine/Render/Shaders/D3D12SdfProxy.hlsli index a52c704..8a967f3 100644 --- a/src/Aquarium.Engine/Render/Shaders/D3D12SdfProxy.hlsli +++ b/src/Aquarium.Engine/Render/Shaders/D3D12SdfProxy.hlsli @@ -14,7 +14,19 @@ #define SDF_TRACE_MAX_STEP_RADIUS_SCALE 0.012 #endif -float3 sdfNormal(float3 p, int sdfIndex) +#ifndef SDF_TRACE_BOUND_RADIUS +#define SDF_TRACE_BOUND_RADIUS(sdfObject) max((sdfObject).centerRadius.w * 1.42, 0.001) +#endif + +#ifndef SDF_SURFACE_NORMAL +#define SDF_SURFACE_NORMAL(p,sdfIndex) sdfFiniteDifferenceNormal(p,sdfIndex) +#endif + +#ifndef SDF_TRACE_FUNCTION +#define SDF_TRACE_FUNCTION traceSdf +#endif + +float3 sdfFiniteDifferenceNormal(float3 p, int sdfIndex) { float epsilon = 0.006; float dx = sdfDistance(p + float3(epsilon, 0.0, 0.0), sdfIndex) - sdfDistance(p - float3(epsilon, 0.0, 0.0), sdfIndex); @@ -43,14 +55,14 @@ bool refineSdfHit(float3 origin, float3 direction, int sdfIndex, float lowTravel travel = highTravel; float3 p = origin + direction * travel; surface = sdfSurface(p, sdfIndex); - normal = sdfNormal(p, sdfIndex); + normal = SDF_SURFACE_NORMAL(p, sdfIndex); return true; } bool traceSdf(float3 origin, float3 direction, int sdfIndex, out float travel, out float3 normal, out SdfSurface surface, out float stepCount) { SdfObject sdfObject = sdfObjects[sdfIndex]; - float boundRadius = max(sdfObject.centerRadius.w * 1.42, 0.001); + float boundRadius = SDF_TRACE_BOUND_RADIUS(sdfObject); if (!traceSphere(origin, direction, sdfObject.centerRadius.xyz, boundRadius, travel)) { normal = 0.0; @@ -96,7 +108,7 @@ bool traceSdf(float3 origin, float3 direction, int sdfIndex, out float travel, o } surface = sdfSurface(p, sdfIndex); - normal = sdfNormal(p, sdfIndex); + normal = SDF_SURFACE_NORMAL(p, sdfIndex); return true; } @@ -119,7 +131,7 @@ SceneOut D3D12SdfProxyPS(SdfObjectProxyVertexOut input) float3 normal; SdfSurface surface; float stepCount; - if (!traceSdf(cameraPosition, rayDirection, sdfIndex, travel, normal, surface, stepCount)) + if (!SDF_TRACE_FUNCTION(cameraPosition, rayDirection, sdfIndex, travel, normal, surface, stepCount)) { discard; } @@ -135,6 +147,7 @@ SceneOut D3D12SdfProxyPS(SdfObjectProxyVertexOut input) saturate(surface.temporalDetail), saturate(surface.reservoirConfidence)); output.reservoirGuide = float4(saturate(surface.reservoirConfidence), 0.0, 1.0, 0.0); + output.overdraw = 1.0; output.depth = saturate(travel / max(farDistance, 0.001)); return output; } diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12StereoDepth.hlsl b/src/Aquarium.Engine/Render/Shaders/D3D12StereoDepth.hlsl new file mode 100644 index 0000000..82d3af2 --- /dev/null +++ b/src/Aquarium.Engine/Render/Shaders/D3D12StereoDepth.hlsl @@ -0,0 +1,69 @@ +cbuffer D3D12StereoDepthConstants : register(b0) +{ + float4 Shape; // width, height, minDisparity, disparityLevels + float4 Match; // censusRadius, aggregationPathCount, p1, p2 + float4 Output; // invDisparityScale, reserved +}; + +Texture2D PackedStereoIr : register(t28); +RWTexture2D DisparityOut : register(u0); + +float PackedLeft(uint2 pixel) +{ + return PackedStereoIr.Load(int3(pixel, 0)).r; +} + +float PackedRight(uint2 pixel) +{ + const uint stereoWidth = max(1u, (uint)Shape.x); + return PackedStereoIr.Load(int3(uint2(pixel.x + stereoWidth, pixel.y), 0)).r; +} + +[numthreads(8, 8, 1)] +void D3D12PackedStereoDepthCS(uint3 dispatchThreadId : SV_DispatchThreadID) +{ + const uint width = max(1u, (uint)Shape.x); + const uint height = max(1u, (uint)Shape.y); + const uint2 pixel = dispatchThreadId.xy; + if (pixel.x >= width || pixel.y >= height) + { + return; + } + + const int minDisparity = max(0, (int)Shape.z); + const int disparityLevels = max(1, min(256, (int)Shape.w)); + const int radius = max(1, min(4, (int)Match.x)); + const int maxDisparity = min(disparityLevels - 1, (int)pixel.x - radius - minDisparity); + if (maxDisparity <= 0 || pixel.y < (uint)radius || pixel.y + (uint)radius >= height) + { + DisparityOut[pixel] = 0.0f; + return; + } + + float bestCost = 1.0e20f; + int bestDisparity = 0; + [loop] + for (int disparity = minDisparity; disparity <= maxDisparity; disparity++) + { + float cost = 0.0f; + [loop] + for (int y = -radius; y <= radius; y++) + { + [loop] + for (int x = -radius; x <= radius; x++) + { + const uint2 leftPixel = uint2((uint)((int)pixel.x + x), (uint)((int)pixel.y + y)); + const uint2 rightPixel = uint2((uint)((int)pixel.x + x - disparity), (uint)((int)pixel.y + y)); + cost += abs(PackedLeft(leftPixel) - PackedRight(rightPixel)); + } + } + + if (cost < bestCost) + { + bestCost = cost; + bestDisparity = disparity; + } + } + + DisparityOut[pixel] = (float)bestDisparity; +} diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12TemporalGaussian.hlsl b/src/Aquarium.Engine/Render/Shaders/D3D12TemporalGaussian.hlsl index 2d42c7e..54b325a 100644 --- a/src/Aquarium.Engine/Render/Shaders/D3D12TemporalGaussian.hlsl +++ b/src/Aquarium.Engine/Render/Shaders/D3D12TemporalGaussian.hlsl @@ -52,6 +52,7 @@ struct SceneOut float4 metadata : SV_Target1; float4 control : SV_Target2; float4 reservoirGuide : SV_Target3; + float overdraw : SV_Target4; float depth : SV_Depth; }; @@ -185,6 +186,7 @@ SceneOut D3D12TemporalGaussianPS(TemporalGaussianVertexOut input) output.metadata = float4(FIELD_ID_TEMPORAL_GAUSSIAN_BASE + gaussian.previousCenterFieldId.w, normal); output.control = float4(opacity, saturate(gaussian.centerHistoryWeight.w), saturate(gaussian.shapePad.x / 8.0), 0.0); output.reservoirGuide = float4(saturate(gaussian.velocityConfidence.w), 0.0, 1.0, 0.0); + output.overdraw = 1.0; output.depth = saturate(travel / max(farDistance, 0.001)); return output; } diff --git a/src/Aquarium.Engine/Render/Shaders/D3D12TubeField.hlsl b/src/Aquarium.Engine/Render/Shaders/D3D12TubeField.hlsl index 7807e11..3241181 100644 --- a/src/Aquarium.Engine/Render/Shaders/D3D12TubeField.hlsl +++ b/src/Aquarium.Engine/Render/Shaders/D3D12TubeField.hlsl @@ -24,6 +24,7 @@ struct TubeFieldSegment float4 tubeData; }; +#include "CultMath/CultMath.hlsl" #include "D3D12FieldReservoir.hlsli" cbuffer AquariumFrame : register(b0) @@ -178,16 +179,6 @@ float NormalizedSampleWithOffset(uint logicalColumn, float x, int rollingOffset) } -float Catmull(float p0, float p1, float p2, float p3, float t) -{ - float t2 = t * t; - float t3 = t2 * t; - return 0.5 * ((2.0 * p1) + - (-p0 + p2) * t + - (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + - (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); -} - float SampleCurve(uint logicalColumn, float x) { int i1 = (int)floor(x); @@ -196,7 +187,7 @@ float SampleCurve(uint logicalColumn, float x) float p1 = NormalizedSample(logicalColumn, (float)i1); float p2 = NormalizedSample(logicalColumn, (float)(i1 + 1)); float p3 = NormalizedSample(logicalColumn, (float)(i1 + 2)); - return saturate(Catmull(p0, p1, p2, p3, t)); + return saturate(cultmath_catmullrom(p0, p1, p2, p3, t)); } float SampleCurveWithOffset(uint logicalColumn, float x, int rollingOffset) @@ -207,7 +198,7 @@ float SampleCurveWithOffset(uint logicalColumn, float x, int rollingOffset) float p1 = NormalizedSampleWithOffset(logicalColumn, (float)i1, rollingOffset); float p2 = NormalizedSampleWithOffset(logicalColumn, (float)(i1 + 1), rollingOffset); float p3 = NormalizedSampleWithOffset(logicalColumn, (float)(i1 + 2), rollingOffset); - return saturate(Catmull(p0, p1, p2, p3, t)); + return saturate(cultmath_catmullrom(p0, p1, p2, p3, t)); } @@ -369,6 +360,7 @@ struct SceneOut float4 metadata : SV_Target1; float4 control : SV_Target2; float4 reservoirGuide : SV_Target3; + float overdraw : SV_Target4; float depth : SV_Depth; }; @@ -877,6 +869,7 @@ SceneOut D3D12TubeFieldPS(TubeFieldVertexOut input) output.metadata = float4(candidateFieldId, tubeNormal); output.control = float4(claimCoverage, supportCoverage, saturate(radiusWorld / max(viewRadius, 0.0001)), value); output.reservoirGuide = float4(claimCoverage, 0.0, supportCoverage, value); + output.overdraw = 1.0; output.depth = saturate(travel / max(farDistance, 0.0001)); float target = fieldReservoirDefaultTarget(output.colorTravel, output.control, output.reservoirGuide); float selectedColumn = round(input.tubeData.x); diff --git a/src/Aquarium.Engine/Render/Ui/DebugUi.cs b/src/Aquarium.Engine/Render/Ui/DebugUi.cs index b0aebb7..c470333 100644 --- a/src/Aquarium.Engine/Render/Ui/DebugUi.cs +++ b/src/Aquarium.Engine/Render/Ui/DebugUi.cs @@ -87,7 +87,10 @@ internal sealed class DebugUi public static DebugUi FromContract(AquariumUiPanel source) { - var ui = new DebugUi(source.Title, source.Left, source.Top, source.Width, source.FadeWhenMouseDistant, [], () => 0, _ => { }); + var ui = new DebugUi(source.Title, source.Left, source.Top, source.Width, source.FadeWhenMouseDistant, [], () => 0, _ => { }) + { + IsVisible = true + }; foreach (var control in source.Controls) { ui.AddContractControl(control); diff --git a/src/Aquarium.EveSurface/Aquarium.EveSurface.csproj b/src/Aquarium.EveSurface/Aquarium.EveSurface.csproj new file mode 100644 index 0000000..dc1f5bf --- /dev/null +++ b/src/Aquarium.EveSurface/Aquarium.EveSurface.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + Aquarium.EveSurface + Aquarium.EveSurface + enable + enable + true + + + + + + + diff --git a/src/Aquarium.EveSurface/EveSurfaceRuntime.cs b/src/Aquarium.EveSurface/EveSurfaceRuntime.cs new file mode 100644 index 0000000..ea3a59c --- /dev/null +++ b/src/Aquarium.EveSurface/EveSurfaceRuntime.cs @@ -0,0 +1,448 @@ +using System.Globalization; +using System.Net.WebSockets; +using System.Numerics; +using System.Text; +using System.Text.Json; +using Aquarium.Engine; +using Aquarium.Engine.Audio; +using Aquarium.Engine.Input; +using Aquarium.Engine.Render; +using Aquarium.Engine.Ui; + +namespace Aquarium.EveSurface; + +public sealed class EveSurfaceRuntime : IAquariumRuntime +{ + private const string DefaultBrokerUrl = "ws://127.0.0.1:8795/eve/deck"; + private const string DefaultProviderId = "voidbot.swarm"; + private readonly object sync = new(); + private readonly string brokerUrl = Environment.GetEnvironmentVariable("FENSALIR_EVE_BROKER") ?? DefaultBrokerUrl; + private readonly string providerId = Environment.GetEnvironmentVariable("FENSALIR_EVE_PROVIDER") ?? DefaultProviderId; + private CancellationTokenSource? connectionLifetime; + private Task? connectionTask; + private EveSurfaceState state = EveSurfaceState.Empty; + private string status = "not started"; + private string selectedNodeId = ""; + private float timeSeconds; + + public AquariumRuntimeOptions Options { get; private set; } + + public GraphicsSettings GraphicsSettings { get; set; } = GraphicsSettings.Default; + + public AquariumRenderPlan RenderPlan { get; } = CreateRenderPlan(); + + public AquariumUiDocument Ui => ComposeUi(); + + public AquariumAudioDocument Audio { get; } = new(); + + public AquariumSynthDocument Synth { get; } = AquariumSynthDocument.Empty; + + public AquariumFrame Frame => new( + new ViewFrame(Vector2.Zero, 24.0f), + new Vector3(0.0f, -10.0f, 7.0f), + timeSeconds, + Vector2.Zero, + AquariumSceneState.Empty); + + public AquariumFrame ComposeFrame(AquariumFrame frame, AquariumFrameInput input) => frame; + + public void Start() + { + Console.WriteLine($"Eve surface client connecting to {brokerUrl} provider {providerId}."); + connectionLifetime = new CancellationTokenSource(); + connectionTask = Task.Run(() => RunConnectionLoopAsync(connectionLifetime.Token)); + } + + public void Update(float deltaSeconds, InputState input) + { + timeSeconds += Math.Max(deltaSeconds, 0.0f); + } + + public void FlushState() + { + } + + public void Dispose() + { + connectionLifetime?.Cancel(); + try + { + connectionTask?.Wait(TimeSpan.FromSeconds(1)); + } + catch (AggregateException) + { + } + + connectionLifetime?.Dispose(); + } + + internal void SetOptions(AquariumRuntimeOptions options) + { + Options = options; + } + + private AquariumUiDocument ComposeUi() + { + EveSurfaceState snapshot; + string connectionStatus; + lock (sync) + { + snapshot = state; + connectionStatus = status; + if (string.IsNullOrWhiteSpace(selectedNodeId)) + { + selectedNodeId = snapshot.SelectedNodeId; + } + } + + var nodes = snapshot.Nodes; + var selected = nodes.FirstOrDefault(node => node.Id == selectedNodeId) + ?? nodes.FirstOrDefault(node => node.Id == snapshot.SelectedNodeId) + ?? nodes.FirstOrDefault(); + var ctb = nodes.Where(static node => string.Equals(node.Kind, "ctb-turn", StringComparison.OrdinalIgnoreCase)).Take(12).ToArray(); + var leaves = nodes.Where(static node => string.Equals(node.Kind, "state-leaf", StringComparison.OrdinalIgnoreCase)).Take(32).ToArray(); + var primaryNodes = ctb.Length > 0 ? ctb : nodes.Take(16).ToArray(); + var detailNodes = leaves.Length > 0 ? leaves : nodes.Skip(primaryNodes.Length).Take(32).ToArray(); + if (detailNodes.Length == 0 && ctb.Length == 0) + { + detailNodes = nodes.Take(32).ToArray(); + } + var summary = nodes.FirstOrDefault(static node => node.Id == "voidbot-summary"); + var agent = nodes.FirstOrDefault(static node => node.Id == "agent-detail"); + + return new AquariumUiDocument() + .Panel("Eve Surface", 18.0f, 82.0f, 420.0f, fadeWhenMouseDistant: true, panel => + { + panel.Section("CultMesh Provider"); + panel.Readout("Provider", () => string.IsNullOrWhiteSpace(snapshot.ProviderId) ? providerId : snapshot.ProviderId); + panel.Readout("Title", () => snapshot.Title); + panel.Readout("Version", () => snapshot.Version.ToString(CultureInfo.InvariantCulture)); + panel.Readout("Broker", () => brokerUrl); + panel.Readout("Status", () => connectionStatus); + panel.Button("Reconnect", Reconnect, "Restart the WebSocket subscription and reopen the provider."); + }) + .Panel(ctb.Length > 0 ? "CTB" : "Surface Nodes", 456.0f, 82.0f, 520.0f, fadeWhenMouseDistant: true, panel => + { + panel.Section(ctb.Length > 0 ? "VoidBot Turn Bar" : snapshot.Title); + foreach (var node in primaryNodes) + { + panel.TreeItem( + CompactLabel(node.Label), + 0, + canExpand: false, + isExpanded: static () => false, + setExpanded: static _ => { }, + isSelected: () => selectedNodeId == node.Id, + select: () => selectedNodeId = node.Id, + detail: CompactDetail(node)); + } + }) + .Panel(ctb.Length > 0 ? "Selected Face" : "Selected Node", 18.0f, 382.0f, 420.0f, fadeWhenMouseDistant: true, panel => + { + panel.Section(summary?.Label ?? "VoidBot Swarm"); + panel.Readout("Summary", () => summary?.Health ?? "waiting"); + panel.TextBox("Selected", () => selected?.Label ?? "none", _ => { }, lines: 1, acceptsReturn: false, monospace: false); + panel.TextBox("Detail", () => selected?.Detail ?? agent?.Detail ?? "No provider detail yet.", _ => { }, lines: 12, acceptsReturn: false, monospace: true); + }) + .Panel("State Graph", 456.0f, 222.0f, 520.0f, fadeWhenMouseDistant: true, panel => + { + panel.Section(leaves.Length > 0 ? "Leaves" : "Details"); + foreach (var node in detailNodes) + { + panel.TreeItem( + CompactLabel(node.Label), + 0, + canExpand: false, + isExpanded: static () => false, + setExpanded: static _ => { }, + isSelected: () => selectedNodeId == node.Id, + select: () => selectedNodeId = node.Id, + detail: CompactDetail(node)); + } + }) + .Panel("Surface Detail", 994.0f, 82.0f, 520.0f, fadeWhenMouseDistant: true, panel => + { + panel.Section(selected?.Kind ?? "detail"); + panel.Readout("Node", () => selected?.Id ?? "none"); + panel.Readout("Health", () => selected?.Health ?? ""); + panel.TextBox("State", () => selected?.Detail ?? "No selected node.", _ => { }, lines: 18, acceptsReturn: false, monospace: true); + }) + .Command("eve-status", _ => $"{connectionStatus}; {snapshot.ProviderId} v{snapshot.Version}; nodes {nodes.Count}; surface {snapshot.Surface?.Schema ?? "none"} root {snapshot.Surface?.Root?.Kind ?? "none"}", "Report the active Eve surface subscription."); + } + + private void Reconnect() + { + connectionLifetime?.Cancel(); + connectionLifetime = new CancellationTokenSource(); + connectionTask = Task.Run(() => RunConnectionLoopAsync(connectionLifetime.Token)); + } + + private async Task RunConnectionLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + SetStatus("connecting"); + using var socket = new ClientWebSocket(); + await socket.ConnectAsync(new Uri(brokerUrl), cancellationToken).ConfigureAwait(false); + SetStatus("connected"); + await SendOpenProviderAsync(socket, cancellationToken).ConfigureAwait(false); + + var buffer = new byte[64 * 1024]; + while (socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) + { + var message = await ReceiveTextAsync(socket, buffer, cancellationToken).ConfigureAwait(false); + if (message is null) + { + break; + } + + var next = EveSurfaceState.Parse(message); + if (!string.Equals(next.ProviderId, providerId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + lock (sync) + { + state = next; + status = $"live {next.UpdatedAt}"; + if (string.IsNullOrWhiteSpace(selectedNodeId)) + { + selectedNodeId = next.SelectedNodeId; + } + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception exception) + { + SetStatus($"error {exception.Message}"); + await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false); + } + } + } + + private async Task SendOpenProviderAsync(ClientWebSocket socket, CancellationToken cancellationToken) + { + var payload = Encoding.UTF8.GetBytes($$"""{"type":"open-provider","providerId":"{{providerId}}"}"""); + await socket.SendAsync(payload, WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false); + } + + private static async Task ReceiveTextAsync(ClientWebSocket socket, byte[] buffer, CancellationToken cancellationToken) + { + using var stream = new MemoryStream(); + while (true) + { + var result = await socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + { + return null; + } + + stream.Write(buffer, 0, result.Count); + if (result.EndOfMessage) + { + return Encoding.UTF8.GetString(stream.ToArray()); + } + } + } + + private void SetStatus(string value) + { + lock (sync) + { + status = value; + } + } + + private static string CompactLabel(string value) + { + var label = value.Replace('\n', ' ').Replace('\r', ' ').Trim(); + return label.Length <= 72 ? label : label[..69] + "..."; + } + + private static string CompactDetail(EveSurfaceNode node) + { + var parts = new[] { node.Kind, node.Health, node.StatePath } + .Where(static value => !string.IsNullOrWhiteSpace(value)); + return string.Join(" ", parts); + } + + private static AquariumRenderPlan CreateRenderPlan() + { + var app = new AquariumApp(); + var scene = app.RenderTargets.Hdr("scene"); + app.Cameras.Perspective("main"); + app.Features.Presentation(scene.Color); + app.Features.DirectWriteOverlay(); + app.Debug.View("Scene", scene.Color); + return app.Plan; + } +} + +public sealed class EveSurfaceRuntimeFactory : IAquariumRuntimeFactory +{ + public IAquariumRuntime Create(AquariumRuntimeOptions options) + { + var runtime = new EveSurfaceRuntime(); + runtime.SetOptions(options); + return runtime; + } +} + +internal sealed record EveSurfaceState( + string ProviderId, + string Title, + long Version, + string UpdatedAt, + string SelectedNodeId, + IReadOnlyList Nodes, + EveCompositionSurface? Surface) +{ + public static EveSurfaceState Empty { get; } = new( + "", + "Eve Surface", + 0, + "", + "", + [], + null); + + public static EveSurfaceState Parse(string json) + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + var nodes = new List(); + if (root.TryGetProperty("nodes", out var nodeArray) && nodeArray.ValueKind == JsonValueKind.Array) + { + foreach (var node in nodeArray.EnumerateArray()) + { + nodes.Add(new EveSurfaceNode( + ReadString(node, "id"), + ReadString(node, "label"), + ReadString(node, "kind"), + ReadDouble(node, "x"), + ReadDouble(node, "y"), + ReadDouble(node, "width"), + ReadDouble(node, "height"), + ReadString(node, "health"), + ReadString(node, "detail"), + ReadString(node, "statePath"), + ReadString(node, "avatarUrl"))); + } + } + + return new EveSurfaceState( + ReadString(root, "providerId"), + ReadString(root, "title", "Eve Surface"), + ReadInt64(root, "version"), + ReadString(root, "updatedAt"), + ReadString(root, "selectedNodeId"), + nodes, + ReadSurface(root)); + } + + private static EveCompositionSurface? ReadSurface(JsonElement root) + { + if (!root.TryGetProperty("surface", out var surface) || surface.ValueKind != JsonValueKind.Object) + { + return null; + } + + return new EveCompositionSurface( + ReadString(surface, "schema"), + ReadString(surface, "id"), + ReadString(surface, "title"), + surface.TryGetProperty("root", out var rootElement) ? ReadElement(rootElement) : null, + surface.TryGetProperty("assets", out var assets) && assets.ValueKind == JsonValueKind.Array + ? assets.EnumerateArray() + .Select(static asset => new EveCompositionAsset( + ReadString(asset, "id"), + ReadString(asset, "kind"), + ReadString(asset, "uri"))) + .Where(static asset => !string.IsNullOrWhiteSpace(asset.Id)) + .ToArray() + : []); + } + + private static EveCompositionElement? ReadElement(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + var children = element.TryGetProperty("children", out var childArray) && childArray.ValueKind == JsonValueKind.Array + ? childArray.EnumerateArray().Select(ReadElement).OfType().ToArray() + : []; + return new EveCompositionElement( + ReadString(element, "id"), + ReadString(element, "kind"), + ReadString(element, "role"), + ReadString(element, "text"), + ReadString(element, "assetRef"), + ReadString(element, "assetUri"), + ReadString(element, "bindNodeId"), + ReadString(element, "commandId"), + children); + } + + private static string ReadString(JsonElement element, string property, string fallback = "") + { + return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() ?? fallback + : fallback; + } + + private static long ReadInt64(JsonElement element, string property) + { + return element.TryGetProperty(property, out var value) && value.TryGetInt64(out var number) + ? number + : 0; + } + + private static double ReadDouble(JsonElement element, string property) + { + return element.TryGetProperty(property, out var value) && value.TryGetDouble(out var number) + ? number + : 0.0; + } +} + +internal sealed record EveSurfaceNode( + string Id, + string Label, + string Kind, + double X, + double Y, + double Width, + double Height, + string Health, + string Detail, + string StatePath, + string AvatarUrl); + +internal sealed record EveCompositionSurface( + string Schema, + string Id, + string Title, + EveCompositionElement? Root, + IReadOnlyList Assets); + +internal sealed record EveCompositionElement( + string Id, + string Kind, + string Role, + string Text, + string AssetRef, + string AssetUri, + string BindNodeId, + string CommandId, + IReadOnlyList Children); + +internal sealed record EveCompositionAsset(string Id, string Kind, string Uri); diff --git a/src/Aquarium.Fensalir/Aquarium.Fensalir.csproj b/src/Aquarium.Fensalir/Aquarium.Fensalir.csproj index 09900e4..6d9a7cc 100644 --- a/src/Aquarium.Fensalir/Aquarium.Fensalir.csproj +++ b/src/Aquarium.Fensalir/Aquarium.Fensalir.csproj @@ -17,6 +17,9 @@ + + + diff --git a/src/Aquarium.Sample.Minimal/MinimalRuntime.cs b/src/Aquarium.Sample.Minimal/MinimalRuntime.cs index 703a581..76ea8af 100644 --- a/src/Aquarium.Sample.Minimal/MinimalRuntime.cs +++ b/src/Aquarium.Sample.Minimal/MinimalRuntime.cs @@ -10,6 +10,20 @@ namespace Aquarium.Sample.Minimal; public sealed class MinimalRuntime : IAquariumRuntime { private float timeSeconds; + private int selectedFeed; + private int selectedLut; + private bool feedEnabled = true; + private bool visible = true; + private bool locked; + private float opacity = 1.0f; + private float gain = 0.72f; + private float lutStrength = 1.0f; + private float x = -3.2f; + private float y = 2.1f; + private float z; + private float rotation; + private float scaleX = 2.4f; + private float scaleY = 0.8f; public AquariumRuntimeOptions Options { get; private set; } @@ -17,7 +31,7 @@ public sealed class MinimalRuntime : IAquariumRuntime public AquariumRenderPlan RenderPlan { get; } = CreateRenderPlan(); - public AquariumUiDocument Ui { get; } = AquariumUiDocument.Empty; + public AquariumUiDocument Ui { get; } public AquariumAudioDocument Audio { get; } = new(); @@ -30,6 +44,11 @@ public sealed class MinimalRuntime : IAquariumRuntime Vector2.Zero, AquariumSceneState.Empty); + public MinimalRuntime() + { + Ui = CreateUi(); + } + public AquariumFrame ComposeFrame(AquariumFrame frame, AquariumFrameInput input) { return frame; @@ -58,6 +77,119 @@ public sealed class MinimalRuntime : IAquariumRuntime Options = options; } + private AquariumUiDocument CreateUi() + { + var feeds = new[] + { + new AquariumUiOption(0, "PS3 Eye 0 high-speed"), + new AquariumUiOption(1, "Kiyo Pro RGB"), + new AquariumUiOption(2, "Synthetic checker field"), + }; + var luts = new[] + { + new AquariumUiOption(0, "Neutral"), + new AquariumUiOption(1, "Thermal"), + new AquariumUiOption(2, "Depth debug"), + }; + + return new AquariumUiDocument() + .Surface("aquarium.ui_smoke", "CultUI Smoke", 16.0f, 56.0f, 980.0f, 620.0f, surface => + { + surface.Horizontal("columns", columns => + { + columns.Vertical("left", left => + { + left.Pane("program", "Program", pane => + { + pane.Options("feed", "Feed", () => selectedFeed, value => selectedFeed = value, feeds); + pane.Toggle("feed-enabled", "Visible", () => feedEnabled, value => feedEnabled = value); + pane.Slider("opacity", "Opacity", () => opacity, value => opacity = value, 0.0f, 1.0f, "0.00"); + pane.Text("program-compose", () => $"1:{feeds[selectedFeed].Label}@{opacity:0.00} | LUT:{luts[selectedLut].Label}", "mono"); + pane.Options("stem", "Stem", () => 0, _ => { }, [new AquariumUiOption(0, "Focusrite Input 1")]); + pane.Slider("gain", "Gain", () => gain, value => gain = value, 0.0f, 1.5f, "0.00"); + pane.Options("lut", "LUT", () => selectedLut, value => selectedLut = value, luts); + pane.Slider("lut-strength", "LUT Strength", () => lutStrength, value => lutStrength = value, 0.0f, 1.0f, "0.00"); + }, weight: 1.2f); + + left.Pane("scene", "Scene", pane => + { + pane.Options("editor-view", "Editor View", () => 0, _ => { }, [new AquariumUiOption(0, "Scene")]); + pane.Card("tree", card => + { + card.Text("tree-0", "Scene", "strong"); + card.Text("tree-1", " - [eye] Editor Camera ", "mono"); + card.Text("tree-2", " - Sensor Feeds (4)", "mono"); + card.Text("tree-3", " - [eye] Kiyo Pro RGB context ", "mono"); + card.Text("tree-4", " - [eye] Synthetic checker field ", "mono"); + }); + pane.Row("scene-buttons", row => + { + row.Button("previous", "Previous", () => selectedFeed = Math.Max(0, selectedFeed - 1)); + row.Button("next", "Next", () => selectedFeed = Math.Min(feeds.Length - 1, selectedFeed + 1)); + }); + pane.Text("scene-status", () => $"SdfTextPanel pos={x:0.00},{y:0.00},{z:0.00} scale={scaleX:0.00},{scaleY:0.00}", "mono"); + }); + }, weight: 1.05f); + + columns.Vertical("middle", middle => + { + middle.Pane("sync", "Sync", pane => + { + pane.Text("streams", "4 video / 4 audio buffers", "strong"); + pane.Metric("sources", "sources", () => 5.0); + pane.Metric("ingested", "samples ingested", () => 533_939.0 + (timeSeconds * 120.0)); + pane.Text("buffer-detail", "Focusrite Input 1 [asio-ch0]: 3751 1ch 192000Hz; passive mode waiting for program audio column", "body"); + pane.Text("audio-sync", () => $"asio-ch0: {1484.0 + Math.Sin(timeSeconds) * 0.2:0.00} samples {779.2 + Math.Cos(timeSeconds) * 0.1:0.00} us", "mono"); + pane.Text("chirplet", "chirplet reference: asio-ch1 passive; calibration emission command-ready", "body"); + pane.Slider("perspective", "Perspective", () => 10.0f, _ => { }, 1.0f, 20.0f, "0.0x"); + pane.Slider("angle", "Angle", () => 25.0f, _ => { }, 0.0f, 90.0f, "0.0 deg"); + pane.Text("tube-budget", "40/40 age columns x 4/8 lanes x 4 subdivisions", "mono"); + }, weight: 1.15f); + + middle.Pane("transform", "Transform", pane => + { + pane.Toggle("visible", "Visible", () => visible, value => visible = value); + pane.Toggle("locked", "Locked", () => locked, value => locked = value); + pane.Text("mode", "Mode: Grab", "mono"); + pane.Slider("x", "X", () => x, value => x = value, -10.0f, 10.0f, "0.00"); + pane.Slider("y", "Y", () => y, value => y = value, -10.0f, 10.0f, "0.00"); + pane.Slider("z", "Z", () => z, value => z = value, -10.0f, 10.0f, "0.00"); + pane.Slider("rotation", "Rotation", () => rotation, value => rotation = value, -180.0f, 180.0f, "0.00"); + pane.Slider("scale-x", "Scale X", () => scaleX, value => scaleX = value, 0.1f, 4.0f, "0.00"); + pane.Slider("scale-y", "Scale Y", () => scaleY, value => scaleY = value, 0.1f, 4.0f, "0.00"); + pane.Row("transform-buttons", row => + { + row.Button("reset-transform", "Reset Transform", ResetTransform); + row.Button("reset-camera", "Reset Camera", ResetTransform); + }); + }); + }, weight: 1.1f); + + columns.Pane("create", "Create", pane => + { + pane.Text("text-label", "Text", "body"); + pane.Text("text-value", "Mimir", "strong"); + pane.Button("add-sdf-text", "Add SDF Text", () => { }); + pane.Text("model-path-label", "Model Path", "body"); + pane.Text("model-path", "assets/models/example.glb", "mono"); + pane.Button("import-model", "Import Model", () => { }); + pane.Metric("fps", "ui smoke fps proxy", () => 1.0 / Math.Max(0.001, Math.Min(0.25, timeSeconds % 1.0))); + pane.Text("budget", "This pane is intentionally narrow enough to prove labels clip inside their cells instead of wandering across neighbors.", "body"); + }, weight: 0.75f); + }); + }); + } + + private void ResetTransform() + { + x = -3.2f; + y = 2.1f; + z = 0.0f; + rotation = 0.0f; + scaleX = 2.4f; + scaleY = 0.8f; + } + private static AquariumRenderPlan CreateRenderPlan() { var app = new AquariumApp(); diff --git a/src/Aquarium.Zyphos/Aquarium.Zyphos.csproj b/src/Aquarium.Zyphos/Aquarium.Zyphos.csproj index 0f12427..831e1ab 100644 --- a/src/Aquarium.Zyphos/Aquarium.Zyphos.csproj +++ b/src/Aquarium.Zyphos/Aquarium.Zyphos.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Aquarium.Zyphos/Properties/AssemblyInfo.cs b/src/Aquarium.Zyphos/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..71b3f58 --- /dev/null +++ b/src/Aquarium.Zyphos/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Aquarium.Engine.Tests")] diff --git a/src/Aquarium.Zyphos/Shaders/D3D12ZyphosPlanet.hlsl b/src/Aquarium.Zyphos/Shaders/D3D12ZyphosPlanet.hlsl index d75b6bb..c2d3de1 100644 --- a/src/Aquarium.Zyphos/Shaders/D3D12ZyphosPlanet.hlsl +++ b/src/Aquarium.Zyphos/Shaders/D3D12ZyphosPlanet.hlsl @@ -3,6 +3,8 @@ static const int ZYPHOS_GEOMETRY_BRUSH_LIMIT = 4; #include "D3D12SdfCommon.hlsli" #include "D3D12SdfMath.hlsli" +#include "GameCult.Geometry/GameCult.Geometry.hlsl" +#include "D3D12ZyphosTerrain.hlsli" float3 zyRotateZ(float3 p, float angle) { @@ -28,19 +30,6 @@ float zyUmbrosEclipse(float3 starDirectionLocal) return smoothstep(cos(umbrosAngularRadius * 1.45), cos(umbrosAngularRadius * 0.45), alignment); } -float zySphericalField(float3 dir) -{ - float latitude = asin(saturate(abs(dir.z)) * 2.0 - 1.0); - float longitude = atan2(dir.y, dir.x); - float plates = - sin(longitude * 2.0 + dir.z * 4.5) * 0.28 + - sin(longitude * 3.0 - dir.z * 7.0 + 1.7) * 0.22 + - sin((dir.x + dir.y * 0.7 + dir.z * 0.4) * 8.0) * 0.16 + - sin((dir.x * 1.3 - dir.y + dir.z * 1.9) * 13.0) * 0.08; - float equatorialWarmth = cos(latitude) * 0.18; - return 0.50 + plates + equatorialWarmth; -} - float zyHash21(float2 p) { p = frac(p * float2(123.34, 456.21)); @@ -68,39 +57,21 @@ float zyCompactBrush(float2 delta, float2 radii, float rotation, float falloff, float2 zyCubeFaceUv(float3 dir, out float face) { + // Authored brush packets retain their legacy cube-ratio chart. Planetary + // Page addressing and patch geometry use GameCult.Geometry's QSC topology. float3 a = abs(dir); if (a.x >= a.y && a.x >= a.z) { - if (dir.x >= 0.0) - { - face = 0.0; - return float2(-dir.z, dir.y) / max(a.x, 0.0001); - } - - face = 1.0; - return float2(dir.z, dir.y) / max(a.x, 0.0001); + if (dir.x >= 0.0) { face = 0.0; return float2(-dir.z, dir.y) / max(a.x, 0.0001); } + face = 1.0; return float2(dir.z, dir.y) / max(a.x, 0.0001); } - if (a.y >= a.z) { - if (dir.y >= 0.0) - { - face = 2.0; - return float2(dir.x, -dir.z) / max(a.y, 0.0001); - } - - face = 3.0; - return float2(dir.x, dir.z) / max(a.y, 0.0001); + if (dir.y >= 0.0) { face = 2.0; return float2(dir.x, -dir.z) / max(a.y, 0.0001); } + face = 3.0; return float2(dir.x, dir.z) / max(a.y, 0.0001); } - - if (dir.z >= 0.0) - { - face = 4.0; - return dir.xy / max(a.z, 0.0001); - } - - face = 5.0; - return float2(-dir.x, dir.y) / max(a.z, 0.0001); + if (dir.z >= 0.0) { face = 4.0; return dir.xy / max(a.z, 0.0001); } + face = 5.0; return float2(-dir.x, dir.y) / max(a.z, 0.0001); } float2 zyTileBrushPlane(float2 faceUv, float level, float tileX, float tileY, out float inTile) @@ -224,11 +195,11 @@ float zyPebbleCluster(float3 dir) return coast * smoothstep(0.48, 0.94, grains * 0.5 + 0.5); } -float zyTerrainOffset(float3 dir, SdfObject sdfObject) +float zyTerrainBaseOffset(float3 dir, SdfObject sdfObject, out float land) { float field = zySphericalField(dir); float seaLevel = sdfObject.state.z; - float land = smoothstep(seaLevel - 0.04, seaLevel + 0.08, field); + land = smoothstep(seaLevel - 0.04, seaLevel + 0.08, field); float mountain = pow(saturate(field - seaLevel), 1.65); float polarCap = pow(abs(dir.z), 8.0) * 0.035; float authoredMaterial; @@ -237,13 +208,57 @@ float zyTerrainOffset(float3 dir, SdfObject sdfObject) return (field - seaLevel) * 0.10 + mountain * 0.13 + polarCap * land + tileRelief + authoredGeometry * 0.52; } +float zyTerrainOffset(float3 dir, SdfObject sdfObject) +{ + float land; float baseOffset=zyTerrainBaseOffset(dir,sdfObject,land); + float4 pageHeightGradient; float2 pageMasks; + float erosionHeight=zyTrySampleErosionPage(dir,pageHeightGradient,pageMasks)?pageHeightGradient.x:0.0; + return baseOffset+erosionHeight*land; +} + float sdfDistance(float3 p, int sdfIndex) { SdfObject sdfObject = sdfObjects[sdfIndex]; float planetRadius = max(sdfObject.state.x, 0.001); float3 local = p - sdfObject.centerRadius.xyz; float3 dir = zyPlanetDir(local, sdfObject); - return length(local) - (planetRadius + zyTerrainOffset(dir, sdfObject)); + float radialDistance = length(local) - (planetRadius + zyTerrainOffset(dir, sdfObject)); + return radialDistance * zyTerrainConservativeDistanceScale(dir); +} + +float zyPlanetTraceBoundRadius(SdfObject sdfObject) +{ + float legacyBound = max(sdfObject.centerRadius.w * 1.42, 0.001); + float erosionExtent=0.0; int pageCount=min((int)zy_planet_page_set[0].state.x,64); + [loop] for(int pageIndex=0;pageIndexexit){travel=farDistance+1.0;return false;} + float low=entry; float lowDistance=sdfDistance(origin+direction*low,sdfIndex); + if(lowDistance<=0.0){travel=low;float3 p=origin+direction*travel;normal=zyPlanetSurfaceNormal(p,sdfIndex);surface=sdfSurface(p,sdfIndex);return true;} + float high=low; bool bracketed=false; + [unroll] for(int coarse=1;coarse<=24;coarse++) + { + high=lerp(entry,closest,(float)coarse/24.0); float highDistance=sdfDistance(origin+direction*high,sdfIndex); stepCount=(float)coarse; + if(highDistance<=0.0){bracketed=true;break;} low=high; lowDistance=highDistance; + } + if(!bracketed){travel=farDistance+1.0;return false;} + [unroll] for(int refine=0;refine<10;refine++) + { + float mid=(low+high)*0.5; float value=sdfDistance(origin+direction*mid,sdfIndex); + if(value<=0.0)high=mid;else low=mid; + } + travel=high; float3 p=origin+direction*travel; normal=zyPlanetSurfaceNormal(p,sdfIndex); surface=sdfSurface(p,sdfIndex); stepCount+=10.0; return true; +} + float3 shadeSdf(float2 uv, float travel, float3 p, float3 normal, int sdfIndex, SdfSurface surface) { SdfObject sdfObject = sdfObjects[sdfIndex]; @@ -310,4 +356,7 @@ float3 shadeSdf(float2 uv, float travel, float3 p, float3 normal, int sdfIndex, return shadeSdfPbr(p, normal, surface) * daylight + city + atmosphere + eclipseTint; } +#define SDF_TRACE_BOUND_RADIUS(sdfObject) zyPlanetTraceBoundRadius(sdfObject) +#define SDF_SURFACE_NORMAL(p,sdfIndex) zyPlanetSurfaceNormal(p,sdfIndex) +#define SDF_TRACE_FUNCTION zyTracePlanet #include "D3D12SdfProxy.hlsli" diff --git a/src/Aquarium.Zyphos/Shaders/D3D12ZyphosTerrain.hlsli b/src/Aquarium.Zyphos/Shaders/D3D12ZyphosTerrain.hlsli new file mode 100644 index 0000000..56fba6a --- /dev/null +++ b/src/Aquarium.Zyphos/Shaders/D3D12ZyphosTerrain.hlsli @@ -0,0 +1,156 @@ +#ifndef D3D12_ZYPHOS_TERRAIN_HLSLI +#define D3D12_ZYPHOS_TERRAIN_HLSLI + +#include "GameCult.Geometry/GameCult.Geometry.hlsl" + +struct ZyPlanetPageOutput { float4 height_gradient; float4 masks; }; +struct ZyPlanetPageMetadata { float4 address; float4 layout; float4 bounds; float4 state; }; +struct ZyPlanetPageSummary { float4 bounds; float4 metadata; }; +struct ZyPlanetPageSet { float4 state; }; +StructuredBuffer zy_planet_page : register(t81); +StructuredBuffer zy_planet_page_metadata : register(t82); +StructuredBuffer zy_planet_page_summary : register(t83); +StructuredBuffer zy_planet_page_set : register(t84); + +static const float ZY_TERRAIN_NON_EROSION_SLOPE_BOUND = 8.0; + +bool zyTerrainPageLocal(float3 dir, ZyPlanetPageMetadata metadata, out float2 local) +{ + return gamecult_geometry_planetary_page_local(dir,metadata.address,metadata.state.x,local); +} + +void zySampleErosionPage(int pageIndex, float2 local, out float4 heightGradient, out float2 masks) +{ + ZyPlanetPageMetadata metadata=zy_planet_page_metadata[pageIndex]; + float storage=metadata.layout.y, interior=metadata.layout.z, border=metadata.layout.w; + float2 texel=local*(interior-1.0)+border; float2 base=floor(texel); float2 fraction=frac(texel); + int2 p0=(int2)clamp(base,0.0,storage-1.0); int2 p1=min(p0+1,(int2)(storage-1.0)); + int offset=(int)metadata.layout.x; + ZyPlanetPageOutput a=zy_planet_page[offset+p0.y*(int)storage+p0.x], b=zy_planet_page[offset+p0.y*(int)storage+p1.x]; + ZyPlanetPageOutput c=zy_planet_page[offset+p1.y*(int)storage+p0.x], d=zy_planet_page[offset+p1.y*(int)storage+p1.x]; + GameCultGeometryPlanetaryPageSample ca,cb,cc,cd; + ca.height_gradient=a.height_gradient; ca.masks=a.masks.xy; + cb.height_gradient=b.height_gradient; cb.masks=b.masks.xy; + cc.height_gradient=c.height_gradient; cc.masks=c.masks.xy; + cd.height_gradient=d.height_gradient; cd.masks=d.masks.xy; + GameCultGeometryPlanetaryPageSample sample=gamecult_geometry_planetary_page_lerp(ca,cb,cc,cd,fraction); + heightGradient=sample.height_gradient; masks=sample.masks; +} + +bool zyTrySampleErosionPage(float3 dir, out float4 heightGradient, out float2 masks) +{ + heightGradient=0.0; masks=0.0; bool found=false; + int pageCount=min((int)zy_planet_page_set[0].state.x,64); + [loop] for(int pageIndex=0;pageIndex=0.0; +} + +float zyTerrainConservativeDistanceScale(float3 dir) +{ + float erosionSlope=0.0; int pageCount=min((int)zy_planet_page_set[0].state.x,64); + [loop] for(int pageIndex=0;pageIndex0.5) + erosionSlope+=max(zy_planet_page_summary[pageIndex].bounds.z,0.0)*saturate(metadata.state.y); + } + float totalSlopeBound = ZY_TERRAIN_NON_EROSION_SLOPE_BOUND + max(erosionSlope, 0.0); + return rsqrt(1.0 + totalSlopeBound * totalSlopeBound); +} + +float zyTerrainPatchCaptureMargin(float3 dir) +{ + // The raster shell must contain unresolved relief between coarse vertices; + // otherwise the pixel refinement has no fragment on which to recover it. + float margin=0.03; + int pageCount=min((int)zy_planet_page_set[0].state.x,64); + [loop] for(int pageIndex=0;pageIndex page_inputs : register(t0); +RWStructuredBuffer page_outputs : register(u0); +cbuffer PageConstants : register(b0) { uint page_sample_count; } + +GameCultGeometryPlanetaryPageSample zyPageErosion(float3 direction, float radius, float spacing, float parent_spacing) +{ + float3 dir=normalize(direction); + float field=zySphericalField(dir); + GameCultGeometryPlanetarySurfaceSample child=zyAdvancedErosionSurface(dir,field,radius,spacing); + GameCultGeometryPlanetarySurfaceSample parent=(GameCultGeometryPlanetarySurfaceSample)0; + bool has_parent=parent_spacing>0.0; + if(has_parent)parent=zyAdvancedErosionSurface(dir,field,radius,parent_spacing); + return gamecult_geometry_planetary_residual_sample(child,parent,has_parent); +} + + +[numthreads(64,1,1)] +void D3D12ZyphosTerrainPageCS(uint3 id : SV_DispatchThreadID) +{ + if(id.x>=page_sample_count) return; + PageInput input=page_inputs[id.x]; float3 dir=normalize(input.direction_radius.xyz); float radius=input.direction_radius.w; float spacing=input.sampling.x; float parent_spacing=input.sampling.y; + GameCultGeometryPlanetaryPageSample sample=zyPageErosion(dir,radius,spacing,parent_spacing); + GameCultGeometryPlanetarySurfaceSample child=zyAdvancedErosionSurface(dir,zySphericalField(dir),radius,spacing); + page_outputs[id.x].height_gradient=sample.height_gradient; + page_outputs[id.x].masks=float4(sample.masks,0,child.unresolved_height_bound); +} diff --git a/src/Aquarium.Zyphos/Shaders/D3D12ZyphosTerrainPageSummary.hlsl b/src/Aquarium.Zyphos/Shaders/D3D12ZyphosTerrainPageSummary.hlsl new file mode 100644 index 0000000..e686515 --- /dev/null +++ b/src/Aquarium.Zyphos/Shaders/D3D12ZyphosTerrainPageSummary.hlsl @@ -0,0 +1,21 @@ +#include "GameCult.Geometry/GameCult.Geometry.hlsl" + +struct PageOutput { float4 height_gradient; float4 masks; }; +struct PageSummary { float4 bounds; float4 metadata; }; +StructuredBuffer summary_inputs : register(t0); +RWStructuredBuffer summary_outputs : register(u0); +cbuffer PageConstants : register(b0) { uint page_sample_count; uint page_sample_offset; uint page_summary_index; } + +[numthreads(1,1,1)] +void D3D12ZyphosTerrainPageSummaryCS(uint3 id : SV_DispatchThreadID) +{ + if(id.x!=0 || page_sample_count==0) return; + float4 summary=gamecult_geometry_planetary_summary_empty(); + [loop] for(uint index=0;indexfarDistance)discard; + float3 radial=normalize(p-planet.centerRadius.xyz); + float3 normal=cross(ddy(p),ddx(p)); + if(!all(isfinite(normal))||dot(normal,normal)<1.0e-10)normal=radial; else normal=normalize(normal); + if(dot(normal,cameraPosition-p)<0.0)normal=-normal; + float3 terrainDir=zyPlanetDir(p-planet.centerRadius.xyz,planet); + ZyPlanetPageMetadata debugMetadata; ZyPlanetPageSummary debugSummary; float4 debugHeightGradient; float2 debugMasks; + bool hasDebugPage=zyTerrainDeepestPageEvidence(terrainDir,debugMetadata,debugSummary,debugHeightGradient,debugMasks); + float3 debugColor=0.0; + if(renderDebugMode>=21.5&&renderDebugMode<22.5) + debugColor=hasDebugPage?float3(debugMetadata.address.x/5.0,debugMetadata.address.y/5.0,saturate(debugMetadata.state.y)):float3(1,0,1); + else if(renderDebugMode>=22.5&&renderDebugMode<23.5) + debugColor=hasDebugPage?float3(saturate(0.5+debugHeightGradient.x*2.0),saturate(debugSummary.bounds.z*0.1),saturate(debugSummary.bounds.w*10.0)):float3(1,0,1); + else if(renderDebugMode>=23.5&&renderDebugMode<24.5) + debugColor=abs(normal); + else if(renderDebugMode>=24.5&&renderDebugMode<25.5) + debugColor=hasDebugPage?float3(saturate(debugMasks),saturate(debugMetadata.state.y)):float3(1,0,1); + else if(renderDebugMode>=25.5&&renderDebugMode<26.5) + { + float3 absoluteDirection=abs(terrainDir); + float dominant=max(absoluteDirection.x,max(absoluteDirection.y,absoluteDirection.z)); + float second=absoluteDirection.x+absoluteDirection.y+absoluteDirection.z-dominant-min(absoluteDirection.x,min(absoluteDirection.y,absoluteDirection.z)); + float3 dominantAxis=absoluteDirection.x>=absoluteDirection.y&&absoluteDirection.x>=absoluteDirection.z?float3(sign(terrainDir.x),0,0):absoluteDirection.y>=absoluteDirection.z?float3(0,sign(terrainDir.y),0):float3(0,0,sign(terrainDir.z)); + float3 secondaryAxis=absoluteDirection.x=min(absoluteDirection.y,absoluteDirection.z)?float3(sign(terrainDir.x),0,0):absoluteDirection.y=min(absoluteDirection.x,absoluteDirection.z)?float3(0,sign(terrainDir.y),0):float3(0,0,sign(terrainDir.z)); + float3 crossDirection=normalize(secondaryAxis-dominantAxis*dot(secondaryAxis,terrainDir)); + float4 sideA,sideB; float2 sideMasks; bool hasA=zyTrySampleErosionPage(normalize(terrainDir-crossDirection*0.001),sideA,sideMasks); bool hasB=zyTrySampleErosionPage(normalize(terrainDir+crossDirection*0.001),sideB,sideMasks); + float edgeProximity=1.0-saturate((dominant-second)*100.0); + debugColor=float3(saturate(abs(sideA.x-sideB.x)*100.0)*edgeProximity,edgeProximity,(hasA&&hasB)?0.0:1.0); + } + else if(renderDebugMode>=26.5&&renderDebugMode<27.5) + { + float4 pageField; float2 pageMasks; bool hasPage=zyTrySampleErosionPage(terrainDir,pageField,pageMasks); + float directHeight=hasDebugPage?zyAdvancedErosion(terrainDir,zySphericalField(terrainDir),planet.state.x,max(debugMetadata.state.w,1.0e-5)).x:0.0; + float error=abs(directHeight-pageField.x); + float bound=hasDebugPage?max(debugSummary.bounds.w,1.0e-5):1.0; + debugColor=hasPage?float3(saturate(error/bound),saturate(error*100.0),error<=bound?0.0:1.0):float3(1,0,1); + } + else if(renderDebugMode>=27.5&&renderDebugMode<28.5) + { + float spacing=hasDebugPage?max(debugMetadata.state.w,1.0e-6):1.0; + float wavelengthRatio=hasDebugPage?saturate(log2(max(planet.state.x,0.001)/spacing)/16.0):0.0; + debugColor=float3(frac(zy_planet_page_set[0].state.z),wavelengthRatio,hasDebugPage?saturate(debugMetadata.address.y/5.0):0.0); + } + SdfSurface surface=sdfSurface(p,0); + float3 shaded=(renderDebugMode>=21.5&&renderDebugMode<28.5)?debugColor:shadeSdf(0.0,travel,p,normal,0,surface); + ZyPatchSceneOut output; output.colorTravel=float4(shaded,min(travel,farDistance+1.0)); + output.metadata=float4(FIELD_ID_SDF_OBJECT_BASE,normal); output.control=float4(1,4.0/384.0,saturate(surface.temporalDetail),saturate(surface.reservoirConfidence)); + float3 forward,right,up; cameraBasis(cameraPosition,cameraTarget,forward,right,up); + output.reservoirGuide=float4(saturate(surface.reservoirConfidence),0,1,0); output.overdraw=1; + output.depth=saturate(dot(p-cameraPosition,forward)/max(farDistance,0.0001)); return output; +} diff --git a/src/Aquarium.Zyphos/ZyphosPlanetarySurfacePages.cs b/src/Aquarium.Zyphos/ZyphosPlanetarySurfacePages.cs new file mode 100644 index 0000000..2aa1db8 --- /dev/null +++ b/src/Aquarium.Zyphos/ZyphosPlanetarySurfacePages.cs @@ -0,0 +1,104 @@ +using System.Numerics; +using Aquarium.Engine.Render; +using CultMath; +using GameCult.Geometry; + +namespace Aquarium.Zyphos; + +internal static class ZyphosPlanetarySurfacePages +{ + private const int InteriorSize = 17; + private const int BorderSize = 2; + private const int MaximumLevel = 5; + private const float ArrivalSeconds = 0.35f; + private static readonly Dictionary Content = []; + private static readonly PlanetaryResidualResidency Residency = new(); + + internal static void Reset() + { + Residency.Reset(); + } + + internal static void PrimeForCapture(Vector3 cameraPosition, float pageAgeSeconds) + { + Reset(); + _ = ForCamera(cameraPosition, -Math.Max(pageAgeSeconds, 0.0f)); + } + + internal static void PrimeEvictionForCapture(Vector3 cameraPosition, float pageAgeSeconds) + { + var center=ZyphosUmbrosSystem.ZyphosCenter; + var relative=cameraPosition-center; + var oldDirection=Vector3.Normalize(new Vector3(relative.Z,relative.Y,-relative.X)); + var oldCamera=center+oldDirection*Math.Max(relative.Length(),0.001f); + Reset(); + _=ForCamera(oldCamera,-1.0f); + _=ForCamera(oldCamera,-0.65f); + _=ForCamera(cameraPosition,-Math.Max(pageAgeSeconds,0.0f)); + } + + public static AquariumPlanetarySurfacePageSet ForCamera(Vector3 cameraPosition, float timeSeconds) + { + var relative = cameraPosition - ZyphosUmbrosSystem.ZyphosCenter; + var distance = Math.Max(relative.Length(), 0.001f); + var direction = relative / distance; + var field = FieldDefinition(ZyphosUmbrosSystem.ZyphosSurfaceRadius); + var lod = new PlanetaryLodParameters(MaximumLevel, InteriorSize, BorderSize, 720, field.Radius / 65536); + var desired = PlanetaryLodSelector.SelectAncestorChain(field, (float3)direction, distance, lod); + var snapshot = Residency.Update(desired, timeSeconds, ArrivalSeconds); + var pages = snapshot.Tiles.Select(resident => BuildPage(resident.Tile, resident.Blend)).ToArray(); + return new AquariumPlanetarySurfacePageSet + { + GeneratorEntryPoint = "D3D12ZyphosTerrainPageCS", + ContentVersion = unchecked((long)snapshot.ContentVersion), + PresentationVersion = unchecked((long)snapshot.PresentationVersion), + Pages = pages, + RenderCoarsePatches = true, + PatchCells = 64, + }; + } + + private static AquariumPlanetarySurfacePage BuildPage(PlanetaryTileAddress tile, float blend) + { + var layout = new PlanetaryPageLayout(tile, InteriorSize, BorderSize); + var key = tile.StableKey; + var radius = ZyphosUmbrosSystem.ZyphosSurfaceRadius; + if (!Content.TryGetValue(key, out var cached)) + { + var common = PlanetaryGpuPageBuilder.BuildContent(layout, radius); + var samples = common.Inputs.Select(input => new AquariumPlanetarySurfacePageInput( + ToNumerics(input.DirectionRadius), ToNumerics(input.Sampling))).ToArray(); + cached = new(common, samples); + Content[key] = cached; + } + + var metadata = PlanetaryGpuPageBuilder.Metadata(cached.Common, 0, blend); + + return new AquariumPlanetarySurfacePage + { + ContentKey = unchecked((long)key), + Samples = cached.Samples, + Metadata = new AquariumPlanetarySurfacePageMetadata( + ToNumerics(metadata.Address), + ToNumerics(metadata.Layout), + ToNumerics(metadata.Bounds), + ToNumerics(metadata.State)), + }; + } + + private static Vector4 ToNumerics(float4 value) => new(value.x, value.y, value.z, value.w); + + private sealed record CachedPageContent( + PlanetaryGpuPageContent Common, + AquariumPlanetarySurfacePageInput[] Samples); + + private static PlanetaryFieldDefinition FieldDefinition(float radius) + { + var erosion = new AdvancedErosionParameters( + radius * 0.075f, 0.12f, 0.58f, 1.45f, + new float4(0.1f, 0.015f, 0.1f, 2.0f), + new float4(1.25f, 1.25f, 2.8f, 1.5f), + new float2(0.7f, 0.85f), 0.7f, 0.5f, 7, 2.0f, 0.5f); + return new(1, radius, 0, erosion); + } +} diff --git a/src/Aquarium.Zyphos/ZyphosRenderPlan.cs b/src/Aquarium.Zyphos/ZyphosRenderPlan.cs index 528bd0d..26bb6d0 100644 --- a/src/Aquarium.Zyphos/ZyphosRenderPlan.cs +++ b/src/Aquarium.Zyphos/ZyphosRenderPlan.cs @@ -14,6 +14,11 @@ public static class ZyphosRenderPlan var app = new AquariumApp(); app.Shaders .Core("D3D12HeightField.hlsl", "D3D12Scene.hlsl", "D3D12Post.hlsl") + .PlanetarySurfacePage("D3D12ZyphosTerrainPage.hlsl") + .PlanetarySurfacePageSummary("D3D12ZyphosTerrainPageSummary.hlsl") + .PlanetarySurfacePatch("D3D12ZyphosTerrainPatch.hlsl") + .SdfShader("D3D12ZyphosPlanet.hlsl") + .SdfShader("D3D12ZyphosUmbros.hlsl") .SdfShader("D3D12ZyphosStar.hlsl"); var heightField = app.RenderTargets.Create("height-field") diff --git a/src/Aquarium.Zyphos/ZyphosRuntime.cs b/src/Aquarium.Zyphos/ZyphosRuntime.cs index 15defe8..e06c4d3 100644 --- a/src/Aquarium.Zyphos/ZyphosRuntime.cs +++ b/src/Aquarium.Zyphos/ZyphosRuntime.cs @@ -1,4 +1,5 @@ using System.Numerics; +using System.Globalization; using Aquarium.Engine; using Aquarium.Engine.Audio; using Aquarium.Engine.Input; @@ -70,13 +71,53 @@ public sealed class ZyphosRuntime : IAquariumRuntime shot.CameraTarget, timeSeconds, Vector2.Zero, - ZyphosSceneBuilder.Build(timeSeconds, previousTimeSeconds, fractalPlan)); + ZyphosSceneBuilder.Build(timeSeconds, previousTimeSeconds, shot.CameraPosition, fractalPlan)); } } public ZyphosRuntime() { - Ui = new AquariumUiDocument() + var captureDomain = Environment.GetEnvironmentVariable("AQUARIUM_ZYPHOS_DOMAIN"); + if (!string.IsNullOrWhiteSpace(captureDomain)) + { + var domain = ZyphosSpatialDomainCatalog.Domains.FirstOrDefault(candidate => + string.Equals(candidate.Key.Value, captureDomain, StringComparison.OrdinalIgnoreCase)); + if (domain is not null) + { + selectedDomainKey = domain.Key; + autoOrbit = false; + } + } + if (float.TryParse( + Environment.GetEnvironmentVariable("AQUARIUM_ZYPHOS_ORBIT_DISTANCE"), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var captureDistance)) + { + orbitDistance = ClampOrbitDistance(captureDistance); + autoOrbit = false; + } + if (float.TryParse( + Environment.GetEnvironmentVariable("AQUARIUM_ZYPHOS_PAGE_AGE_SECONDS"), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var pageAgeSeconds)) + { + timeScale = 0.0f; + autoOrbit = false; + ZyphosPlanetarySurfacePages.PrimeForCapture(CurrentShot().CameraPosition, pageAgeSeconds); + } + if (float.TryParse( + Environment.GetEnvironmentVariable("AQUARIUM_ZYPHOS_EVICTION_AGE_SECONDS"), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var evictionAgeSeconds)) + { + timeScale = 0.0f; + autoOrbit = false; + ZyphosPlanetarySurfacePages.PrimeEvictionForCapture(CurrentShot().CameraPosition, evictionAgeSeconds); + } + var ui = new AquariumUiDocument() .Panel("Zyphos", 18.0f, 82.0f, 340.0f, panel => { panel.Section("Planetary Demo"); @@ -119,6 +160,7 @@ public sealed class ZyphosRuntime : IAquariumRuntime .Command("zyphos-fractal", _ => ZyphosFractalTerrain.DebugDump, "Dump the compiled Zyphos fractal terrain grammar.") .Command("zyphos-fractal-plan", _ => ZyphosFractalTerrain.BuildPlanDebugDump(ZyphosFractalTerrain.BuildRenderPlan(CurrentShot())), "Dump the current Zyphos fractal selected cut and resource budget.") .Command("zyphos-system", _ => $"Zyphos-Umbros: separation {ZyphosUmbrosSystem.SeparationInZyphosRadii:0.0} Zyphos radii, Umbros radius {ZyphosUmbrosSystem.UmbrosRadiusRatio:0.00} Zyphos, apparent diameter {ZyphosUmbrosSystem.UmbrosAngularDiameterDegrees:0.0} degrees.", "Report the modeled Zyphos/Umbros/star baseline."); + Ui = Environment.GetEnvironmentVariable("AQUARIUM_ZYPHOS_HIDE_UI") == "1" ? AquariumUiDocument.Empty : ui; } public AquariumFrame ComposeFrame(AquariumFrame frame, AquariumFrameInput input) @@ -129,6 +171,8 @@ public sealed class ZyphosRuntime : IAquariumRuntime public void Start() { Console.WriteLine("Zyphos planetary demo booted."); + var shot=CurrentShot(); + Console.WriteLine($"Zyphos camera: position {shot.CameraPosition}; target {shot.CameraTarget}; planet distance {Vector3.Distance(shot.CameraPosition,ZyphosUmbrosSystem.ZyphosCenter):0.###}"); } public void Update(float deltaSeconds, InputState input) diff --git a/src/Aquarium.Zyphos/ZyphosSceneBuilder.cs b/src/Aquarium.Zyphos/ZyphosSceneBuilder.cs index bacd53a..5774081 100644 --- a/src/Aquarium.Zyphos/ZyphosSceneBuilder.cs +++ b/src/Aquarium.Zyphos/ZyphosSceneBuilder.cs @@ -6,21 +6,22 @@ namespace Aquarium.Zyphos; public static class ZyphosSceneBuilder { public static AquariumSceneState Build(float timeSeconds, float previousTimeSeconds, ZyphosFractalRenderPlan fractalPlan) + => Build(timeSeconds, previousTimeSeconds, ZyphosUmbrosSystem.ZyphosCenter + Vector3.UnitZ * 12.0f, fractalPlan); + + public static AquariumSceneState Build(float timeSeconds, float previousTimeSeconds, Vector3 cameraPosition, ZyphosFractalRenderPlan fractalPlan) { return new AquariumSceneState { TraceHeightFieldSurface = false, UseStarfieldBackground = true, HeightFieldBrushes = fractalPlan.HeightBrushes, - FractalReservoirField = new AquariumFractalReservoirField - { - SplatCount = 2_000_000, - SplatUpdatesPerFrame = 50_000, - ReservoirUpdatesPerPass = 20_000, - WorldCenterRadius = new Vector4(ZyphosUmbrosSystem.ZyphosCenter, ZyphosUmbrosSystem.ZyphosSurfaceRadius), - PriorityFocus = fractalPlan.ReservoirPriorityFocus, - ProgramTransforms = fractalPlan.GpuProgramTransforms, - }, + // Planet visibility belongs to the page-backed raster patches. + // The fractal grammar still feeds authored terrain inputs, but its + // legacy splats may not independently repaint the same surface. + FractalReservoirField = AquariumFractalReservoirField.Empty, + PlanetarySurfacePages = Environment.GetEnvironmentVariable("AQUARIUM_ZYPHOS_DISABLE_PLANETARY_PAGES") == "1" + ? AquariumPlanetarySurfacePageSet.Empty + : ZyphosPlanetarySurfacePages.ForCamera(cameraPosition, timeSeconds), SdfObjects = BuildSdfObjects(timeSeconds, previousTimeSeconds), SdfLights = BuildSdfLights(timeSeconds), }; @@ -29,10 +30,22 @@ public static class ZyphosSceneBuilder private static AquariumSdfObject[] BuildSdfObjects(float timeSeconds, float previousTimeSeconds) { var rotation = ZyphosUmbrosSystem.MutualPhase(timeSeconds); + var previousRotation = ZyphosUmbrosSystem.MutualPhase(previousTimeSeconds); + var umbrosCenter = ZyphosUmbrosSystem.UmbrosCenter(timeSeconds); + var previousUmbrosCenter = ZyphosUmbrosSystem.UmbrosCenter(previousTimeSeconds); var starCenter = ZyphosUmbrosSystem.PrimaryStarCenter(timeSeconds); var previousStarCenter = ZyphosUmbrosSystem.PrimaryStarCenter(previousTimeSeconds); var objects = new AquariumSdfObject[ZyphosRenderPlan.SdfObjectCount]; + var planetBoundRadius = Environment.GetEnvironmentVariable("AQUARIUM_ZYPHOS_ENABLE_PLANET_SDF_ORACLE") == "1" ? ZyphosUmbrosSystem.ZyphosBoundRadius : 0.0f; + objects[ZyphosRenderPlan.PlanetIndex] = new AquariumSdfObject( + new Vector4(ZyphosUmbrosSystem.ZyphosCenter, planetBoundRadius), + new Vector4(ZyphosUmbrosSystem.ZyphosCenter, previousRotation), + new Vector4(ZyphosUmbrosSystem.ZyphosSurfaceRadius, rotation, ZyphosUmbrosSystem.SeaLevel, rotation)); + objects[ZyphosRenderPlan.UmbrosIndex] = new AquariumSdfObject( + new Vector4(umbrosCenter, ZyphosUmbrosSystem.UmbrosBoundRadius), + new Vector4(previousUmbrosCenter, 0.0f), + new Vector4(ZyphosUmbrosSystem.UmbrosSurfaceRadius, rotation, 0.0f, 0.0f)); objects[ZyphosRenderPlan.StarIndex] = new AquariumSdfObject( new Vector4(starCenter, ZyphosUmbrosSystem.PrimaryStarVisualRadius), new Vector4(previousStarCenter, 0.0f), diff --git a/src/Saga/README.md b/src/Saga/README.md new file mode 100644 index 0000000..f4a4e29 --- /dev/null +++ b/src/Saga/README.md @@ -0,0 +1,165 @@ +# Saga + +**Planetary flow middleware for worlds that refuse to sit still.** + +Saga is Fensalir's stochastic planetary flow organ: a cube-sphere native system +for streaming atmosphere, ocean, gas, debris, heat, and other transport fields +across whole planets at arbitrary level of detail. + +It is built for studios that want living planets without hand-authoring a fake +weather loop, baking one heroic texture atlas, or stapling a particle system to +a skybox and calling it climate. Saga turns terrain, bathymetry, atmospheric +state, ocean reanalysis, arbitrary forcing sources, and learned stochastic +transport into compact flow packets the engine can stream, inspect, simulate, +and render. + +No poles. No flat-Earth texture tricks. No private renderer arrows pretending +to be physics. Just a planet-scale flow field with provenance, uncertainty, and +enough teeth to survive a camera dive from orbit to coastline. + +## The Pitch + +Modern games and simulation tools can render planets. They rarely make them +move like planets. + +Saga is the missing middleware layer between static world geometry and dynamic +planetary behavior: + +```text +planet domain + relief + state + forcing + learned transport +-> stochastic advection packets +-> streaming flow pages, particles, weather, debris, clouds, currents, debug +``` + +Use it for: + +- ocean surface currents and debris drift; +- atmospheric wind fields and gas-giant bands; +- volcanic, nuclear, magical, artificial, or stellar energy injection; +- multi-sun lighting and thermal forcing; +- tides, rotation, Coriolis-dominated flow, and exotic planetary regimes; +- gameplay-visible particles, clouds, smoke, spores, ash, foam, or pollutants; +- offline worldbuilding, cinematic previs, and realtime renderer guidance. + +Saga is not a weather forecast API wearing a costume. It is a reusable engine +organ for representing and streaming stochastic flow over Fensalir's planetary +domains. + +## What Makes It Different + +### Cube-Sphere Native + +Saga speaks Fensalir's optimized cube-sphere address space directly. Lat/lon +grids are source formats, not runtime truth. Every packet belongs to a tile, +LOD, projection, local tangent frame, and provenance chain. + +### Stochastic By Design + +Real flow is not one obedient arrow. Saga packets carry mean transport, +diffusivity/covariance, confidence, and optional transition kernels. Renderers +can draw clean vectors; simulations can keep the uncertainty instead of +throwing it into the sea and acting surprised later. + +### Arbitrary Forcing + +The evaluator is not Earth-locked. Saga conditions on explicit rotation and +Coriolis features plus variable-cardinality energy, mass, and momentum forcing +sources: + +- one sun, binary suns, or no sun worth trusting; +- moons and tidal drivers; +- eruptions, impacts, reactors, orbital mirrors, industrial heat, spellstorms; +- gas-giant internal heat and extreme atmosphere bands; +- transient shock events and persistent planetary gradients. + +Source count is data, not architecture. + +### Built For Streaming + +Saga is designed for camera-relative planet rendering: + +- coarse flow far from the camera; +- refined flow where the player, shot, simulation, or uncertainty demands it; +- parent/child LOD consistency; +- cross-face seam continuity; +- explicit stale-page and model-version checks. + +The renderer samples packets. It does not invent climate in a pixel shader +because the schedule got nervous. + +## Architecture + +Saga lives inside Fensalir because Fensalir already owns the machinery it needs: + +- CultMath `PlanetaryTileAddress` and cube-sphere projection math; +- field evidence layers, especially `Transport`; +- selected cuts and residency planning; +- GPU resource pages and debug telemetry; +- renderer/simulation lowering boundaries. + +Saga's live shape: + +```text +source manifests + -> cube-sphere dataset bakes + -> training shards + -> FlowNet-S / FlowNet-M / optional FlowNet-T + -> Saga stochastic advection packets + -> Fensalir Transport field claims + -> GPU pages, particle advection, weather visuals, debug probes +``` + +## Model Line + +Saga uses a staged neural architecture plan: + +- `FlowNet-S`: 8-15M parameters, local baseline for ingestion, calibration, and + export. +- `FlowNet-M`: 40-60M parameters, first serious runtime/distillation model with + local tile encoders, a cube-sphere graph processor, and stochastic heads. +- `FlowNet-T`: 150-300M parameters, optional global teacher for offline + distillation and ensemble behavior. + +The model emits stochastic advection parameters, not just velocity: + +- tangent mean velocity; +- positive-semidefinite 2x2 covariance/diffusivity; +- calibrated confidence; +- optional neighbor transition logits; +- provenance and model identity. + +## Data Spine + +Saga's first research targets: + +- ETOPO / GEBCO for relief and bathymetry; +- ERA5 for atmosphere; +- GLORYS / OSCAR / HYCOM candidates for ocean currents; +- PlasticAdrift and drifter transition matrices for stochastic debris + transport validation; +- synthetic analytic worlds for contract and seam tests before the ocean gets + expensive. + +Every source needs a manifest. No manifest, no training shard. The altar wants +receipts. + +## First Vertical Slice + +1. Define the Saga packet contracts. +2. Bake a synthetic cube-sphere flow world. +3. Export Transport field pages. +4. Render/debug vectors, covariance ellipses, confidence, and selected LOD. +5. Train FlowNet-S on synthetic + tiny ERA5 wind. +6. Add ocean surface current targets. +7. Add PlasticAdrift transition validation. +8. Promote FlowNet-M when the baseline has failed in useful, measured ways. + +## Boundary + +Saga owns reusable planetary flow mechanics. Clients own meaning. + +Fensalir clients may ask for currents, winds, debris drift, cloud motion, ash +transport, or gas-giant bands. They may not create a private flow authority that +bypasses Saga packets and Fensalir Transport claims. + +That is the deal: the planet can be wild, but the authority has to be clean. diff --git a/src/Saga/Saga.csproj b/src/Saga/Saga.csproj new file mode 100644 index 0000000..ec70067 --- /dev/null +++ b/src/Saga/Saga.csproj @@ -0,0 +1,15 @@ + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/Saga/SagaOrgan.cs b/src/Saga/SagaOrgan.cs new file mode 100644 index 0000000..e84e213 --- /dev/null +++ b/src/Saga/SagaOrgan.cs @@ -0,0 +1,66 @@ +using CultMath; +using GameCult.Geometry; +using System.Numerics; + +namespace Saga; + +public static class SagaOrgan +{ + public const string Name = "Saga"; + + public const string Description = + "Cube-sphere stochastic planetary flow for Fensalir Transport fields."; +} + +public readonly record struct SagaFlowDomain( + string DomainId, + double PlanetRadiusMeters, + string ProjectionId, + string GridVersion); + +public readonly record struct SagaFlowRequest( + SagaFlowDomain Domain, + PlanetaryTileAddress Tile, + int SampleResolution, + double TimeSeconds, + SagaCoriolisFrame Coriolis, + IReadOnlyList ForcingSources); + +public readonly record struct SagaCoriolisFrame( + Vector3 RotationAxis, + double AngularVelocityRadiansPerSecond, + double LocalF, + Vector2 LocalBeta); + +public readonly record struct SagaForcingSource( + string SourceId, + SagaForcingKind Kind, + SagaForcingGeometry Geometry, + Vector3 PlanetDirection, + Vector3 LocalTangentDirection, + double Intensity, + double RadiusMeters, + double Attenuation, + double TimePhase); + +public readonly record struct SagaAdvectionSample( + Vector2 MeanVelocity, + SagaCovariance2D Diffusivity, + double Confidence); + +public readonly record struct SagaCovariance2D(double C00, double C01, double C11); + +public enum SagaForcingKind +{ + Energy, + Mass, + Momentum, +} + +public enum SagaForcingGeometry +{ + Directional, + Point, + Area, + Volume, +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 5b1268f..d118371 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -305,7 +305,38 @@ {"timestamp":"2026-05-30T01:40:00+01:00","topic":"reservoir-budget-pressure-probe","lesson":"The reservoir quality harness now converts same-time higher-work-grid reference error into per-tile budget pressure without making the probe a runtime owner. measure-reservoir-budget-pressure.ps1 reads a sequence manifest, compares a candidate final capture against the native-domain reference final capture at the same ready frame, applies disocclusion/rejection masks when present, and ranks fixed tiles by mean reference error times mask coverage. On reservoir-budget-pressure-20260530-001046.* the probe measured 1306/42864 pixels and found the top 5/50 tiles carried 99.3305% of measured reference-error delta, giving a concrete target for adaptive update policy while preserving the no-native-resolution-reservoir invariant.","files":["scripts/measure-reservoir-budget-pressure.ps1","docs/perfect-fensalir-machine-roadmap.md","state/map.yaml","state/scratch.md"],"source":"artifacts/fensalir-captures/reservoir-budget-pressure-20260530-001046.*"} {"timestamp":"2026-05-30T01:58:00+01:00","topic":"reservoir-spatial-reuse-budget","lesson":"The renderer now has a first runtime sampling budget inside the reservoir history update owner. GraphicsSettings.FieldReservoirSpatialReuseBudget flows through UI, runtime options, CLI/env overrides, capture scripts, frame constants, and D3D12ReservoirHistoryUpdateCS. Current and temporal rows are still written for every reservoir work-grid pixel; only the 3x3 spatial neighbor reuse pass is spent on the deterministic budget phase. The default is 0.5, so the machine combines half presentation grid reservoir surfaces with half spatial reuse sampling by default. Smoke reservoir-sequence-20260530-003006-* compared native scale 0.5/spatial budget 0.5 against reference scale 0.75/spatial budget 1.0; masked reference-error-native covered 3.8261% of frame pixels, and reservoir-budget-pressure-20260530-003006.* found the top 5/50 tiles carried 90.8321% of measured reference-error delta.","files":["src/Aquarium.Engine.Contracts/GraphicsSettings.cs","src/Aquarium.Engine/AquariumHost.cs","src/Aquarium.Engine/Render/D3D12Renderer.cs","src/Aquarium.Engine/Render/Shaders/D3D12Post.hlsl","scripts/capture-fensalir-frame.ps1","scripts/capture-reservoir-mode-sequence.ps1","tests/Aquarium.Engine.Tests/FieldReservoirAbiTests.cs","docs/perfect-fensalir-machine-roadmap.md","state/map.yaml","state/scratch.md"],"source":"artifacts/fensalir-captures/reservoir-sequence-20260530-003006-*"} {"timestamp":"2026-05-30T02:10:00+01:00","topic":"reservoir-spatial-budget-debug","lesson":"The spatial reuse sampling budget now has a visible owner-layer probe. Render debug mode 20, Reservoir Spatial Budget, colors work-grid pixels that spend 3x3 spatial reuse green and skipped pixels blue; capture-reservoir-mode-sequence.ps1 names the mode spatial-budget. Smoke reservoir-sequence-20260530-003746-* captured the debug view at native scale 0.5/spatial budget 0.5. A bitmap probe on the 304x141 present output counted 32078 green pixels, 6818 blue pixels, and 3968 filtering/other pixels, proving the budget branch is observable where the user sees it.","files":["src/Aquarium.Engine.Contracts/GraphicsSettings.cs","src/Aquarium.Engine/Render/D3D12Renderer.cs","src/Aquarium.Engine/Render/Shaders/D3D12Post.hlsl","scripts/capture-reservoir-mode-sequence.ps1","tests/Aquarium.Engine.Tests/FieldReservoirAbiTests.cs","docs/perfect-fensalir-machine-roadmap.md","state/map.yaml","state/scratch.md"],"source":"artifacts/fensalir-captures/reservoir-sequence-20260530-003746-*"} +{"timestamp":"2026-05-30T22:02:00+01:00","topic":"d3d12-resize-reference-release","lesson":"The Fensalir resize crash was a hidden-reference ownership bug in the swapchain path. Resize disposed DirectWrite overlays and D3D11-on-12 wrapped backbuffers, but the D3D11 immediate context could still retain references unless it was explicitly cleared and flushed before IDXGISwapChain::ResizeBuffers. Resize now clears/flushes the overlay context and drops program-output shared textures before recreating size-dependent surfaces. A visible dev slot survived seven programmatic window resizes and logged target rebuilds without stderr.","files":["src/Aquarium.Engine/Render/D3D12Renderer.cs","tests/Aquarium.Engine.Tests/D3D12ResizeTests.cs","state/scratch.md","state/map.yaml"],"source":"artifacts/dev-reload/latest.out.log"} +{"timestamp":"2026-05-31T02:58:00+01:00","topic":"eve-direct2d-client-surface","lesson":"Fensalir's DirectWrite/Direct2D overlay is a first-class Eve client surface. It should consume shared Eve/CultMesh UI documents and lower them into AquariumUiDocument, while provider repos keep ownership of dashboard state, accepted commands, and app-specific semantics.","files":["docs/eve-direct2d-client-surface.md","README.md","docs/aquarium-engine-doctrine.md","state/map.yaml"],"source":"Eve cross-client surface cut"} +{"timestamp":"2026-05-30T22:24:00+01:00","topic":"reservoir-reconstruction-loss-curve","lesson":"Reservoir superresolution quality now has a curve metric instead of only smoke deltas. measure-reservoir-loss-curve.ps1 reads candidate/reference sequence manifests and reports MAE, MSE, RMSE, PSNR, changed pixels, max channel delta, and optional disocclusion/rejection-masked loss. The 320x180 smoke sweep reservoir-sequence-20260530-221043-* compared native scale 0.5/spatial budget 0.5 against reference scale 0.75/spatial budget 1.0 at ready frames 1,2,4,8. Whole-frame PSNR was 21.668, 20.981, 20.911, and 21.690 dB; the non-monotonic curve means current sampling/reconstruction is not yet converging cleanly against the higher-budget reference. Performance still needs GPU timestamp queries; capture wall time is process-level and not a pass cost metric.","files":["scripts/measure-reservoir-loss-curve.ps1","scripts/capture-reservoir-mode-sequence.ps1","state/scratch.md","state/map.yaml"],"source":"artifacts/fensalir-captures/reservoir-loss-curve-20260530-221043.csv"} {"timestamp":"2026-05-30T00:29:26+01:00","topic":"surfacepage-uav-depth-socket","lesson":"SurfacePage resources can now be GPU-resident unordered-access outputs as well as shader-readable pages. D3D12FieldTexture2D tracks UAV capability, creates UAV descriptors, and D3D12FieldResourceRegistry resolves UnorderedAccess SurfacePages while reusing empty pages across version changes when shape/format/access are unchanged. This gives Mimir stereo-depth/height outputs a real compute-writable resource socket without private output textures or CPU readback.","files":["src/Aquarium.Engine/Render/D3D12FieldTexture2D.cs","src/Aquarium.Engine/Render/D3D12FieldResourceRegistry.cs","tests/Aquarium.Engine.Tests/D3D12SharedTextureInteropTests.cs","docs/perfect-fensalir-machine-roadmap.md","state/map.yaml"],"source":"dotnet test tests/Aquarium.Engine.Tests/Aquarium.Engine.Tests.csproj --filter RegistryResolvesUnorderedAccessSurfacePageForComputeDepthOutput"} {"timestamp":"2026-05-30T00:37:29+01:00","topic":"stereo-depth-lowering-contract","lesson":"FieldEvidence now carries typed stereo-depth lowering metadata for future D3D12 SGM dispatch. AquariumFieldStereoDepthLowering binds a Height claim to shader-readable left/right texture resources, profile/calibration/camera-pair identity, depth range/disparity settings, and a compute-writable UAV disparity SurfacePage. Validation rejects claim encoding mismatches, payload/resource mismatches, non-UAV disparity outputs, and missing/non-readable inputs. This keeps Mimir libSGM-provenance depth inside Fensalir resource authority rather than a private texture, CPU readback, CUDA, or TensorRT path.","files":["src/Aquarium.Engine.Contracts/Render/AquariumFieldEvidence.cs","tests/Aquarium.Engine.Tests/FieldEvidenceContractTests.cs","docs/perfect-fensalir-machine-roadmap.md","state/map.yaml"],"source":"dotnet test tests/Aquarium.Engine.Tests/Aquarium.Engine.Tests.csproj --filter StereoDepth"} {"timestamp":"2026-05-30T00:43:53+01:00","topic":"stereo-depth-dispatch-readiness","lesson":"D3D12Renderer now evaluates stereo-depth lowerings for dispatch readiness without installing a fake depth kernel. A lowering is counted ready only when the SurfacePage Height claim is planned, left/right input Texture2D resources resolve through the field resource registry, and the disparity SurfacePage resolves with UAV capability. Capacity logs report ready/unplanned/unresolved counts and explicitly say the kernel is not installed.","files":["src/Aquarium.Engine/Render/D3D12Renderer.cs","docs/perfect-fensalir-machine-roadmap.md","state/map.yaml"],"source":"dotnet build src/Aquarium.Engine/Aquarium.Engine.csproj --no-restore"} {"timestamp":"2026-05-30T00:45:50+01:00","topic":"stereo-depth-sgm-kernel-constants","lesson":"AquariumFieldStereoDepthLowering now carries SGM-shaped kernel constants instead of leaving the future D3D12 shader to infer policy: min disparity, disparity levels, aggregation path count, census radius, and P1/P2 smoothness penalties. Validation requires positive census/P1/P2 values and P2 \u003e= P1. This preserves libSGM-style provenance as typed dispatch data before the HLSL kernel exists.","files":["src/Aquarium.Engine.Contracts/Render/AquariumFieldEvidence.cs","tests/Aquarium.Engine.Tests/FieldEvidenceContractTests.cs","docs/perfect-fensalir-machine-roadmap.md","state/map.yaml"],"source":"dotnet test tests/Aquarium.Engine.Tests/Aquarium.Engine.Tests.csproj --filter StereoDepth"} +{"timestamp":"2026-05-30T21:59:00+01:00","topic":"packed-leap-stereo-depth-kernel","lesson":"D3D12Renderer now installs and dispatches the first stereo-depth compute lane for the live Mimir Leap shape. D3D12StereoDepth.hlsl samples a packed R8G8 Texture2D as left/right IR lanes, performs a small local SAD/block match over the lowering's disparity/radius constants, and writes R16F disparity to the planned UAV SurfacePage. The dispatch only runs for planned Height claims whose left/right keys are the same packed texture and whose input/output resources resolve. This is a live depth proof and provenance-bearing socket for the later libSGM-style SGM rebuild, not calibrated metric depth or full SGM.","files":["src/Aquarium.Engine/Render/D3D12Renderer.cs","src/Aquarium.Engine/Render/Shaders/D3D12StereoDepth.hlsl","docs/perfect-fensalir-machine-roadmap.md","state/map.yaml"],"source":"dotnet build src/Aquarium.Engine/Aquarium.Engine.csproj --no-restore; temp Vortice.D3DCompiler probe compiled D3D12PackedStereoDepthCS bytes=2168; full headless host compile attempted but hung before capture"} +{"timestamp":"2026-05-30T22:33:00+01:00","topic":"headless-pipeline-hang-root-cause","lesson":"The apparent Fensalir headless hang was an initial shader pipeline failure hidden behind the loading-frame loop. dotnet-stack showed the main thread sleeping in D3D12Renderer.RenderPipelineLoadingFrame; a dump of the background pipeline task revealed FileNotFoundException for Render/Shaders/D3D12SdfCommon.hlsli beside the Aquarium.Fensalir client shader output. D3D12FensalirSpine.hlsl includes engine SDF shared files by relative path, but Aquarium.Fensalir.csproj only copied client shaders. The fix copies D3D12SdfCommon.hlsli, D3D12SdfMath.hlsli, and D3D12SdfProxy.hlsli into the client shader output and makes initial pipeline failure throw instead of spinning silently. Verification: Fensalir and Engine builds passed, then headless Engine run compiled pipelines, captured artifacts/runtime/fensalir-stereo-depth-pipeline-smoke.png, and exited.","files":["src/Aquarium.Fensalir/Aquarium.Fensalir.csproj","src/Aquarium.Engine/Render/D3D12Renderer.cs","state/evidence.jsonl","state/map.yaml"],"source":"dotnet-stack report plus dotnet-dump analyze on stuck headless process; dotnet run --project src/Aquarium.Engine/Aquarium.Engine.csproj --no-build -- --headless --capture-frame artifacts/runtime/fensalir-stereo-depth-pipeline-smoke.png"} +{"ts":"2026-05-30T23:10:27+01:00","type":"decision","status":"ok","note":"Added the first Fensalir Leap point-cloud render lane: Mesh resources whose SourceUri is derived-from a SurfacePage allocate UAV-capable point-list buffers, D3D12PointCloudFromDisparityCS fills PositionNormalUvColor vertices/indices from disparity, and D3D12PointCloudPS renders them into scene candidate MRTs. Verified engine/client build, stereo/resize tests, and headless shader pipeline capture artifacts/runtime/fensalir-point-cloud-pipeline-smoke.png."} +{"timestamp":"2026-05-30T23:41:55+01:00","topic":"reservoir-presentation-reconstruction","lesson":"The first final-pass reservoir reconstruction denoiser is worth keeping only after maturity gating. The shader samples a bounded resolved-reservoir neighborhood in D3D12ReservoirPresentationResolvePS and weights neighbors by field id, travel, normal, coverage, detail, color, guide confidence, domain validity, and neighbor agreement. Ungated and age-eager versions improved some frames but regressed the curve; the kept gate requires reservoir maturity before filter strength is allowed. Latest 320x180 sweep reservoir-sequence-20260530-232852-* versus higher-work-grid reference improved f2/f4/f8 and reduced average RMSE from 21.944 to 21.221 against the pre-reconstruction 20260530-221043 curve, while f1 still regressed and remains the next target.","files":["src/Aquarium.Engine/Render/Shaders/D3D12Post.hlsl","state/scratch.md","state/map.yaml"],"source":"artifacts/fensalir-captures/reservoir-loss-curve-20260530-232852.csv"} +{"timestamp":"2026-05-31T23:58:00+01:00","topic":"system-loopback-audio-stems","lesson":"System speaker capture now enters Fensalir through an explicit AquariumAudioDocument capture request. AquariumSynthHost owns the WASAPI loopback device, publishes mono system_mix AquariumAudioStemFrame packets through IAquariumAudioStemBus, and clients consume those stems without owning capture APIs. The host drains pending audio requests once when the scene-ready audio host is created, so short clients do not strand Start-time requests. This closes the live-audio input seam for visualizers such as Perlines while preserving the engine audio host as the authority for OS audio I/O.","files":["src/Aquarium.Engine.Contracts/Audio/AquariumAudio.cs","src/Aquarium.Engine/AquariumHost.cs","src/Aquarium.Engine/Audio/AquariumSynthHost.cs","src/Aquarium.Engine/Audio/WasapiLoopbackCaptureDevice.cs","tests/Aquarium.Engine.Tests/SynthPlaybackTests.cs","state/map.yaml"],"source":"dotnet build Fensalir.sln; dotnet test tests/Aquarium.Engine.Tests/Aquarium.Engine.Tests.csproj --no-build; scripts/dev-reload.ps1 -Headless -RetainSlots 1 -ClientProject E:\\Projects\\Perlines\\src\\Perlines\\Perlines.csproj showed WASAPI loopback started for profile perlines:system-loopback"} +{"ts":"2026-05-31T03:52:00+01:00","type":"verification","status":"ok","note":"Mimir FieldEvidence camera Feature/SensorObservation claims now plan to Fensalir's GpuSensorFusion backend instead of deferring. D3D12Renderer derives sensor metadata and SRV bindings from resolved camera Texture2D resources and dispatches D3D12GpuSensorFusionCS. Live Mimir proof planned 12 packets with 0 deferred; headless D3D12 dispatched LeapStereoIr plus both Bayer8 PS3 Eyes at 16,384 sparse samples per texture, generated 49,152 gaussians, and reported sensor-fusion timing around 0.059 ms/frame. Kiyo YUY2 claims are selected but skipped until a format-aware YUY2 conversion/feature lane exists; packed video must not masquerade as RGBA.","files":["src/Aquarium.Engine.Contracts/Render/AquariumFieldEvidence.cs","src/Aquarium.Engine/Render/D3D12Renderer.cs","src/Aquarium.Engine/Render/D3D12FieldTextureFormat.cs","docs/temporal-sdf-gaussian-field.md","state/map.yaml"],"source":"dotnet build Fensalir.sln; Mimir --perfect-machine-live-smoke --seconds 1.0; Mimir.App headless captured artifacts/runtime/mimir-perfect-machine-gpu-feature-extraction-headless.png"} +{"ts":"2026-05-31T04:18:00+01:00","type":"verification","status":"ok","note":"GpuSensorFusion now samples YUY2 camera Texture2D resources instead of skipping them. Fensalir exposes DXGI_FORMAT_YUY2 through the Microsoft-documented R8G8B8A8_UNORM SRV view, passes declared pixel format into the GPU sensor camera packet, and D3D12GpuSensorFusion.hlsl decodes Y0/U/Y1/V to RGB before feature scoring. Mimir.App headless all-sensor proof dispatched five camera textures with zero unsupported field textures, generated 81,920 sparse gaussians at 16,384 samples per texture, reported sensor-fusion GPU timing around 0.08 ms/frame, and captured artifacts/runtime/mimir-perfect-machine-yuy2-gpu-feature-extraction-headless.png.","files":["src/Aquarium.Engine.Contracts/Render/AquariumRenderPlan.cs","src/Aquarium.Engine/Render/D3D12ExternalSensorTexture.cs","src/Aquarium.Engine/Render/D3D12FieldTexture2D.cs","src/Aquarium.Engine/Render/D3D12Renderer.cs","src/Aquarium.Engine/Render/Shaders/D3D12GpuSensorFusion.hlsl","docs/temporal-sdf-gaussian-field.md","state/map.yaml"],"source":"Microsoft DXGI_FORMAT docs for YUY2 view mapping; dotnet build Fensalir.sln; Mimir.App headless captured artifacts/runtime/mimir-perfect-machine-yuy2-gpu-feature-extraction-headless.png"} +{"timestamp":"2026-05-31T08:48:00+01:00","type":"verification","status":"ok","note":"Aquarium.EveSurface no longer assumes every Eve dashboard document is VoidBot-shaped. Generic providers now render their node list and selected detail through Surface Nodes / State Graph panels. Verified by connecting to Mimir.EveDashboard provider mimir.stream.layout and capturing the decoded surface at E:/Projects/Mimir/artifacts/runtime/mimir-eve-surface-layout-generic-decoder.png.","files":["src/Aquarium.EveSurface/EveSurfaceRuntime.cs"],"source":"dotnet build src/Aquarium.EveSurface/Aquarium.EveSurface.csproj; live Mimir.EveDashboard ws://127.0.0.1:8795/eve/deck provider mimir.stream.layout"} +{"ts":"2026-05-31T18:06:36+01:00","type":"implementation","status":"ok","note":"AquariumUiDocument now carries Eve/CultUI-style composition surfaces in addition to legacy debug panels. Surface authoring defaults to vertical groups and uses explicit Horizontal scopes for columns; DirectWrite lowering subdivides each parent rectangle by axis and child weight instead of measuring children into sibling space, making overlap structurally impossible for lowered siblings.","files":["src/Aquarium.Engine.Contracts/Ui/AquariumUi.cs","src/Aquarium.Engine/Render/D3D12Renderer.cs","src/Aquarium.Engine/Render/DirectWriteOverlay.cs"],"source":"dotnet build src/Aquarium.Engine/Aquarium.Engine.csproj --no-restore; dotnet build E:/Projects/Mimir/src/Mimir.App/Mimir.App.csproj --no-restore"} +{"ts":"2026-05-31T18:18:00+01:00","type":"implementation","status":"ok","note":"Eve/CultUI Direct2D lowering now distinguishes fixed preferred control rows from flexible panes/groups. Vertical scopes allocate compact preferred extents to leaf controls such as text, toggles, selects, sliders, metrics, and buttons, then give remaining space to flexible children by weight. This preserves the no-overlap subdivision invariant while avoiding equal-height slabs for ordinary controls.","files":["src/Aquarium.Engine/Render/DirectWriteOverlay.cs"],"source":"dotnet build src/Aquarium.Engine/Aquarium.Engine.csproj --no-restore; dotnet build E:/Projects/Mimir/src/Mimir.App/Mimir.App.csproj --no-restore; relaunched Mimir.App with mimir-runtime.desktop-editor.no-leap.mic-reference-sync.local.json"} +{"ts":"2026-05-31T18:24:00+01:00","type":"verification","status":"ok","note":"Aquarium.Sample.Minimal now carries a reusable CultUI smoke surface so Eve/CultUI layout can be verified without Mimir camera, USB, FFT, or sync workload. The smoke surface exercises nested vertical/horizontal scopes, panes, cards, rows, selects, toggles, sliders, buttons, metrics, live text, and clipped long readouts. DirectWrite lowering now derives preferred container height from nested child layout and displays metric raw values while clamping only the fill bar; the headless capture showed readable nested grids with no sibling overlap.","files":["src/Aquarium.Sample.Minimal/MinimalRuntime.cs","src/Aquarium.Engine/Render/DirectWriteOverlay.cs"],"source":"dotnet build src/Aquarium.Engine/Aquarium.Engine.csproj --no-restore; dotnet build src/Aquarium.Sample.Minimal/Aquarium.Sample.Minimal.csproj --no-restore; scripts/dev-reload.ps1 -ClientProject src/Aquarium.Sample.Minimal/Aquarium.Sample.Minimal.csproj; capture artifacts/fensalir-captures/cultui-smoke-minimal-fixed.png"} +{"ts":"2026-05-31T22:05:00+01:00","type":"implementation","status":"ok","note":"Eve/CultUI surfaces now include a DirectWrite preview primitive for editor/compositor surfaces. The primitive renders a bounded 16:9 program canvas and normalized item rectangles supplied by the client, allowing Mimir to show OBS-style program arrangement without inventing a Mimir-only overlay path.","files":["src/Aquarium.Engine.Contracts/Ui/AquariumUi.cs","src/Aquarium.Engine/Render/DirectWriteOverlay.cs"],"source":"dotnet build src/Aquarium.Engine/Aquarium.Engine.csproj --no-restore; dotnet build src/Aquarium.Sample.Minimal/Aquarium.Sample.Minimal.csproj --no-restore"} +{"ts":"2026-05-31T22:17:00+01:00","type":"implementation","status":"ok","note":"CultUI preview elements are now flexible layout surfaces rather than fixed preferred-height controls. Containers with flexible children report as flexible to their parent, so editor panes containing a compositor preview can take the available center-panel space instead of collapsing to a small fixed strip.","files":["src/Aquarium.Engine/Render/DirectWriteOverlay.cs"],"source":"dotnet build src/Aquarium.Engine/Aquarium.Engine.csproj --no-restore; Mimir headless capture artifacts/runtime/mimir-editor-preview-maximized-headless.png"} +{"ts":"2026-05-31T22:55:00+01:00","type":"implementation","status":"ok","note":"CultUI preview lowering treats an empty preview label as an intentional chrome-free canvas: no nested frame/title is drawn, and padding collapses to two pixels. Mimir uses this for the central OBS-style program compositor so the preview fills the editor center instead of rendering as a card-within-a-pane.","files":["src/Aquarium.Engine/Render/DirectWriteOverlay.cs"],"source":"dotnet build src/Aquarium.Engine/Aquarium.Engine.csproj; dotnet build E:/Projects/Mimir/src/Mimir.App/Mimir.App.csproj; Mimir.App --headless --headless-width 1280 --headless-height 720 --capture-frame artifacts/runtime/mimir-editor-flat-2d-program-mixer-headless.png"} +{"ts":"2026-06-01T13:48:00+01:00","type":"implementation","status":"ok","note":"CultUI preview elements now support direct compositor gestures. AquariumUiElement can carry a preview interaction callback, D3D12Renderer hit-tests preview item bodies and selected corner handles in normalized canvas coordinates, captures input while dragging, and DirectWriteOverlay draws visible selected corner handles. Clients still own gesture meaning; Fensalir owns the reusable UI hit-test and rendering affordance.","files":["src/Aquarium.Engine.Contracts/Ui/AquariumUi.cs","src/Aquarium.Engine/Render/D3D12Renderer.cs","src/Aquarium.Engine/Render/DirectWriteOverlay.cs"],"source":"dotnet build src/Aquarium.Engine.Contracts/Aquarium.Engine.Contracts.csproj -p:UseSharedCompilation=false; dotnet build src/Aquarium.Engine/Aquarium.Engine.csproj -p:UseSharedCompilation=false"} +{"ts":"2026-06-01T14:12:00+01:00","type":"implementation","status":"ok","note":"CultUI preview affordances now include hover/active state, empty-canvas hits, drag-end events, and normalized guide rendering. D3D12Renderer emits hover/begin/drag/end interactions for preview bodies, handles, and empty canvas; DirectWriteOverlay draws hovered/active selected handles and client-provided x/y guides. Added Up/Down/PageUp/PageDown/Control key codes for editor hotkeys.","files":["src/Aquarium.Engine.Contracts/Input/InputState.cs","src/Aquarium.Engine.Contracts/Ui/AquariumUi.cs","src/Aquarium.Engine/Platform/Win32Window.cs","src/Aquarium.Engine/Render/D3D12Renderer.cs","src/Aquarium.Engine/Render/DirectWriteOverlay.cs"],"source":"dotnet build src/Aquarium.Engine.Contracts/Aquarium.Engine.Contracts.csproj -p:UseSharedCompilation=false; dotnet build src/Aquarium.Engine/Aquarium.Engine.csproj -p:UseSharedCompilation=false"} +{"ts":"2026-06-01T15:05:00+01:00","type":"implementation","status":"ok","note":"Eve remains the portable dashboard authority; the C# Aquarium/CultUI UI contract is the reference API shape. Added content padding and fixed-extent layout fields to retained surface documents, made pane headers conditional, and kept DirectWrite drawing plus D3D12 preview hit-testing on the same document geometry. Mimir now uses this for an edge-to-edge headerless editor shell with fixed output controls and an expanding inspector.","files":["src/Aquarium.Engine.Contracts/Ui/AquariumUi.cs","src/Aquarium.Engine/Render/D3D12Renderer.cs","src/Aquarium.Engine/Render/DirectWriteOverlay.cs"],"source":"dotnet build src/Aquarium.Engine/Aquarium.Engine.csproj -p:UseSharedCompilation=false; dotnet build E:/Projects/Mimir/src/Mimir.App/Mimir.App.csproj -p:UseSharedCompilation=false; Mimir headless capture artifacts/runtime/mimir-output-panel-headless.png"} +{"ts":"2026-06-01T15:40:00+01:00","type":"implementation","status":"ok","note":"AquariumUiElement now carries an optional typed AquariumUiBinding descriptor for Verse-backed CultCache/CultMesh fields: document schema/id, field path, value kind, access, authority, and optional command id. Fensalir lowerings keep using local callbacks for native actuation; the binding is provenance/state authority metadata, not renderer-owned dashboard semantics.","files":["src/Aquarium.Engine.Contracts/Ui/AquariumUi.cs","state/map.yaml"]} +{"ts":"2026-06-01T15:48:00+01:00","type":"verification","status":"ok","note":"Release build passed for Aquarium.Engine after adding AquariumUiBinding; Mimir Release builds and scene-editor smoke also passed against the updated contract.","files":["src/Aquarium.Engine.Contracts/Ui/AquariumUi.cs"],"source":"dotnet build src/Aquarium.Engine/Aquarium.Engine.csproj -c Release -p:UseSharedCompilation=false; dotnet run --project E:/Projects/Mimir/src/Mimir.BufferSmoke/Mimir.BufferSmoke.csproj -c Release -- --scene-editor-smoke"} +{"ts":"2026-06-01T20:15:00+01:00","type":"implementation","status":"ok","note":"Render debug mode 21 is now an owner-layer overdraw view. Scene/evidence pixel shaders write a fifth additive R16_FLOAT overdraw target after discard/depth eligibility; presentation samples that target as a heat map. Coverage, step count, reservoir stats, and spatial-budget modes remain separate diagnostics instead of being relabeled as overdraw.","files":["src/Aquarium.Engine.Contracts/GraphicsSettings.cs","src/Aquarium.Engine/Render/D3D12Renderer.cs","src/Aquarium.Engine/Render/Shaders/D3D12Scene.hlsl","src/Aquarium.Engine/Render/Shaders/D3D12SdfCommon.hlsli","src/Aquarium.Engine/Render/Shaders/D3D12SdfProxy.hlsli","src/Aquarium.Engine/Render/Shaders/D3D12TemporalGaussian.hlsl","src/Aquarium.Engine/Render/Shaders/D3D12FractalSplatRender.hlsl","src/Aquarium.Engine/Render/Shaders/D3D12PointCloud.hlsl","src/Aquarium.Engine/Render/Shaders/D3D12TubeField.hlsl","src/Aquarium.Engine/Render/Shaders/D3D12Post.hlsl"],"source":"dotnet build Fensalir.sln; AQUARIUM_RENDER_DEBUG_MODE=21 .\\scripts\\dev-reload.ps1 -Headless -RetainSlots 4 -ClientProject src\\Aquarium.Fensalir\\Aquarium.Fensalir.csproj"} +{"timestamp":"2026-07-12T19:25:00+01:00","topic":"stateless-spherical-erosion","lesson":"Cube-sphere erosion must be a pure planet-space point function rather than six face-local filters or a repair pass. CultMath now owns matching C# and HLSL kernels keyed by unit direction, tangent base gradient, seed, and bounded octave parameters; Zyphos composes the result after its continental field, while cube faces remain lookup and scheduling charts.","files":["E:\\Projects\\CultMath\\src\\CultMath\\SphericalErosion.cs","E:\\Projects\\CultMath\\shaders\\CultMath.hlsl","src/Aquarium.Zyphos/Shaders/D3D12ZyphosPlanet.hlsl","state/map.yaml"],"source":"user requested a C# CPU implementation in CultMath matching the GPU code"} +{"timestamp":"2026-07-12T20:30:00+01:00","topic":"advanced-erosion-gpu-parity","lesson":"Float-based cell hashes diverged enough between C# and HLSL to select different erosion pivots across a 2052-sample corpus. CultMath now uses the same integer avalanche hash in both paths, and Fensalir verifies the actual HLSL kernel with a standalone D3D12 compute/readback probe instead of inferring parity from similar source text or booting the renderer.","files":["E:\\Projects\\CultMath\\src\\CultMath\\AdvancedErosionFilter.cs","E:\\Projects\\CultMath\\shaders\\AdvancedErosionFilter.hlsl","src/Aquarium.Engine/Render/D3D12ComputeProbe.cs","src/Aquarium.Engine/Render/D3D12ShaderCompiler.cs","tests/Aquarium.Engine.Tests/D3D12AdvancedErosionParityTests.cs"],"source":"measured CPU/GPU parity implementation"} +{"timestamp":"2026-07-12T21:00:00+01:00","topic":"erosion-physical-frequency-bands","lesson":"Planetary erosion LOD is now a filter over one world-space spectrum: CultMath selects active octaves from physical wavelength and sample spacing, fractionally weights the last resolvable octave, and reports omitted height. Zyphos uses that contract through smooth planet-space triplanar charts, so changing view footprint changes represented bands rather than switching terrain authorities.","files":["E:\\Projects\\CultMath\\src\\CultMath\\ErosionFrequencyBands.cs","E:\\Projects\\CultMath\\src\\CultMath\\AdvancedErosionFilter.cs","E:\\Projects\\CultMath\\shaders\\AdvancedErosionFilter.hlsl","src/Aquarium.Zyphos/Shaders/D3D12ZyphosPlanet.hlsl"],"source":"phase 3 of stateless planetary erosion rendering plan"} +{"timestamp":"2026-07-12T21:35:00+01:00","topic":"planetary-page-boundary-authority","lesson":"Using each tile's locally measured angular texel size caused independently generated sibling pages to choose different erosion weights at the same shared boundary. Band selection now uses a level-global nominal angular footprint; local projection distortion remains summary evidence. GPU-generated sibling boundaries are identical and borders sample the spherical field without neighbor residency.","files":["src/Aquarium.Engine.Fractal/Lod/PlanetarySurfacePage.cs","src/Aquarium.Zyphos/Shaders/D3D12ZyphosTerrain.hlsli","src/Aquarium.Zyphos/Shaders/D3D12ZyphosTerrainPage.hlsl","tests/Aquarium.Engine.Tests/D3D12PlanetarySurfacePageTests.cs"],"source":"phase 4 cross-page GPU verification"} +{"timestamp":"2026-07-12T22:00:00+01:00","topic":"planetary-page-summary-reduction","lesson":"Planetary erosion pages now reduce minimum/maximum displacement, maximum tangent slope, and maximum unresolved height through the GPU kernel and verify those results against CPU reduction. These summaries are the conservative evidence needed by later intersection and residency; they are not inferred from parameters.","files":["src/Aquarium.Zyphos/Shaders/D3D12ZyphosTerrainPageSummary.hlsl","tests/Aquarium.Engine.Tests/D3D12PlanetarySurfacePageTests.cs"],"source":"phase 4 GPU summary gate"} +{"timestamp":"2026-07-12T23:15:00+01:00","topic":"page-backed-planetary-intersection","lesson":"Planetary erosion rendering now generates a persistent D3D12 page and GPU summary, binds both to Zyphos SDF tracing, uses measured displacement/slope/unresolved evidence for bounds and conservative radial stepping, and retains the shared bracketed sign-change refiner. Compiling the previously unregistered planet proxy proved that advanced erosion cannot remain nested in the Shader Model 5 pixel trace; exact direct evaluation is therefore the CPU/compute oracle, while visible misses use the resident ancestor or base field. Zyphos now registers and publishes planet, Umbros, and star in matching object-index order.","files":["src/Aquarium.Engine/Render/D3D12Renderer.cs","src/Aquarium.Engine/Render/Shaders/D3D12SdfProxy.hlsli","src/Aquarium.Zyphos/Shaders/D3D12ZyphosPlanet.hlsl","src/Aquarium.Zyphos/Shaders/D3D12ZyphosTerrain.hlsli","src/Aquarium.Zyphos/ZyphosRenderPlan.cs","src/Aquarium.Zyphos/ZyphosSceneBuilder.cs","tests/Aquarium.Engine.Tests/D3D12PlanetarySurfacePageTests.cs"],"source":"phase 5 compile, D3D12 summary, lifecycle, and direct-versus-page hit verification"} +{"timestamp":"2026-07-13T00:10:00+01:00","topic":"planetary-residual-quadtree-lod","lesson":"Planetary LOD now keeps six root pages and represents a selected camera path as parent low frequencies plus child-only residual bands. CultMath's declared unresolved-height bound divided by projected world footprint chooses the level. Content and presentation versions are separate, so arrival/departure fades update metadata without regenerating pages; departing chains remain resident through zero weight before eviction. GPU tests prove parent plus child residual reconstructs the child filtered field, and timeline tests cover descent, teleport, midpoint, eviction, and reload. Visible capture evidence is still required before claiming the no-pop Phase 6 exit.","files":["src/Aquarium.Engine.Contracts/Render/AquariumRenderPlan.cs","src/Aquarium.Engine/Render/D3D12Renderer.cs","src/Aquarium.Zyphos/ZyphosPlanetarySurfacePages.cs","src/Aquarium.Zyphos/Shaders/D3D12ZyphosTerrainPage.hlsl","src/Aquarium.Zyphos/Shaders/D3D12ZyphosTerrain.hlsli","tests/Aquarium.Engine.Tests/D3D12PlanetarySurfacePageTests.cs"],"source":"phase 6 residual LOD implementation and numerical/timeline verification"} +{"timestamp":"2026-07-13T00:40:00+01:00","topic":"planetary-composed-differentials","lesson":"Planetary pages now carry a full 3D planet-space tangent gradient in world-distance units. Zyphos combines that derivative with the base/authored terrain differential and the erosion-times-land product derivative, then derives its visible radial-graph normal from the same composed field. The generic SDF proxy delegates normals through a client hook instead of differentiating the conservatively scaled trace distance. Ridge and gully outputs from the same pages now affect planet color and roughness, and the CPU surface-normal helper implements the same contract.","files":["src/Aquarium.Engine.Fractal/Lod/PlanetarySurfacePage.cs","src/Aquarium.Engine/Render/Shaders/D3D12SdfProxy.hlsli","src/Aquarium.Zyphos/Shaders/D3D12ZyphosTerrainPage.hlsl","src/Aquarium.Zyphos/Shaders/D3D12ZyphosTerrainPageSummary.hlsl","src/Aquarium.Zyphos/Shaders/D3D12ZyphosPlanet.hlsl","tests/Aquarium.Engine.Tests/D3D12PlanetarySurfacePageTests.cs"],"source":"phase 7 differential, normal, material, CPU/GPU contract verification"} diff --git a/state/map.yaml b/state/map.yaml index 3a7327d..055853f 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -21,6 +21,12 @@ current_model: implementation: src/Aquarium.Engine.Fractal tests: tests/Aquarium.Engine.Fractal.Tests receipt_tool: tools/Aquarium.Fractal.Receipt + saga: + implementation: src/Saga + purpose: > + Stochastic planetary flow middleware for cube-sphere Transport fields, + arbitrary forcing, explicit Coriolis/rotation conditioning, and learned + atmosphere/ocean/debris advection packets. demos: fensalir_splash: src/Aquarium.Fensalir minimal: src/Aquarium.Sample.Minimal @@ -44,6 +50,11 @@ invariants: - Fensalir may own reusable built-in field resources, such as the blackbody ramp, when the resource is generic engine utility. Clients should reference those through contracts instead of hardcoded project-local asset paths. + - Client shaders that include engine shared HLSL files must receive those + includes in their output shader directory or use explicit engine include + paths. Missing shared includes are shader-pipeline failures, not loading + states. Initial pipeline build failure now throws instead of spinning behind + the startup/loading frame. - Epiphany Aquarium depends on Fensalir; Fensalir must not depend on Epiphany. - Demo projects inside this repo are allowed to exercise engine features but must not become hidden engine policy. - Public `Aquarium.*` assembly names remain for continuity until a deliberate namespace/package migration. @@ -60,6 +71,98 @@ invariants: terrain should be a quadtree of brush-painted surface tiles, subdivided by projected footprint, curvature, brush/material detail, silhouettes, and temporal uncertainty before SDF probes emit detail-adaptive surface splats. + - Deterministic planetary geometry is owned by GameCult.Geometry's scale-aware + planetary field runtime. It owns field identity, point/batch and + double-position CPU queries, tangent/QSC cube-sphere topology, bordered + pages and child residual composition, projected-error LOD, residual + lifecycle, ray/path/region queries, coarse patch topology, and map + projections/tiles. GameCult.Geometry maintains matching HLSL for field evaluation, + topology, page interpolation/residuals, and radial refinement. Clients own + base fields, terrain parameters, gameplay meaning, and presentation policy. + - The production lowering for stateless planetary erosion is specified in + docs/stateless-planetary-erosion-rendering-plan.md: direct evaluation is the + oracle, while GPU-generated cube-sphere pages carry cached displacement, + differential, mask, bound, and frequency-band evidence for rendering. + - CPU/GPU terrain parity is measured through a standalone D3D12 compute and + readback probe. Shader include expansion is owned by D3D12ShaderCompiler + and shared with the renderer; parity probes must not boot a swapchain or + duplicate GameCult.Geometry equations. + - Planetary erosion frequencies are selected in world-distance wavelengths + from the current sample footprint. The last resolvable octave fades + fractionally and reports its omitted-height bound; Zyphos lowers the planar + reference filter through smooth planet-space triplanar charts, not cube-face + terrain ownership. + - Planetary surface pages use one level-global nominal angular footprint for + band selection. Per-tile projection distortion is summary evidence only; + it may not change terrain at a shared boundary. Page borders evaluate the + spherical field beyond tile bounds without reading neighbor residency. + - D3D12 owns persistent planetary page input, output, metadata, and summary + buffers, their bindings, dispatch, and draw submission. GameCult.Geometry owns the + page layout, spherical sample directions, canonical GPU input/metadata + payload, residual semantics, interpolation, and summaries. Client adapters + only convert CultMath primitives to their host ABI. Client compute entry points bind the client base field to + those functions before GPU reduction. Zyphos registers planet, Umbros, and + star proxies in object index order and publishes all three SDF objects from + one scene builder. + - Production planet visibility is owned by coarse cube-sphere raster patches, + not the general SDF proxy. Patch vertices consume the resident ancestor + field; covered pixels perform four bounded radial corrections against the + complete resident page field, then use the shared differential/material + contract. Missing pages lower to the nearest resident ancestor (the base + field at root). Direct GameCult.Geometry evaluation and the bracketed planet SDF are + verification oracles only; neither owns production visibility. + - D3D12 binds the page atlas, metadata, summaries, and page set to both patch + shader stages at t81-t84. Patch projection follows the renderer's established + camera-basis convention, including clip-space Y inversion and normalized + forward depth. Ordinary `LessEqual` depth owns front/back visibility; + diagnostic `Always` depth is not a production path. + - Zyphos's legacy two-million-splat fractal reservoir is no longer a visible + planet writer. It previously ran after the raster patch and erased the + page-backed candidate. Fractal grammar and authored brushes remain inputs + to terrain composition; `PlanetarySurfacePages` plus the patch lowering own + the visible surface and corrected pixel depth. + - Patch density is explicit page-set policy, carried to GPU in page-set state + rather than hardcoded independently in C# and HLSL. Zyphos currently uses a + 64 by 64 grid per root face; fine relief remains a four-step per-pixel page + refinement. Raster normals use derivatives of the refined world position, + while CPU/direct queries retain the analytic planet-space differential. + `AQUARIUM_PROFILE_PLANETARY_PAGES=1` deliberately regenerates unchanged + pages so their one-shot GPU cost can be measured; production content-version + caching remains authoritative. + - Deterministic capture controls can select spatial domain, orbit distance, + exact residual age, or a cross-face eviction midpoint. Capture priming is + presentation evidence only: production arrival/departure ownership remains + the time-based resident-page lifecycle. + - Planet patch debug modes 22-28 expose selected face/level/blend, page + bounds and unresolved error, refined normal, ridge/gully evidence, and + cross-edge discontinuity/missing residency. Mode 27 evaluates direct + filtered erosion against the summed page field at the selected spacing; + mode 28 exposes content fingerprint, spacing ratio, and deepest level. + The unresolved bound and measured direct error remain distinct signals. + - The bounded radial correction step is owned by GameCult.Geometry in matching C# and + HLSL bodies. Zyphos composes four steps with its terrain-offset callback; + the renderer does not carry a private correction equation. + - Planetary page residency is owned by GameCult.Geometry and separates content from + presentation state. ContentVersion changes only when the resident tile set + changes; PresentationVersion carries arrival/departure weights without + regenerating erosion. Six root pages remain resident. GameCult.Geometry's + projected-error selector uses unresolved-height bounds against camera + footprint to request one ancestor chain, and its residual lifecycle keeps + departing chains addressable until their weights reach zero. + - Root pages contain the resolvable low-frequency erosion field. Every child + page stores `child filtered field - parent filtered field`; the planet + shader sums the containing ancestor contributions and weights only those + residuals during transitions. Independent full-spectrum pages are never + blended. D3D12 flattens resident payloads into one output atlas with indexed + metadata and one GPU summary per page at t81-t84. + - Planetary page differentials are world-distance tangent gradients expressed + as a 3D planet-space vector, not face-local UV derivatives. Zyphos composes + the page gradient with finite differentials of its authored/base residual, + including the land-mask product term, and derives the radial graph normal + as `normalize(direction - tangentGradient)`. The generic SDF proxy exposes + a client normal hook; other SDFs retain its finite-distance fallback. + Ridge and gully page evidence feeds the same planet material evaluation. + `PlanetarySurfaceDifferential.SurfaceNormal` is the matching CPU contract. - The shared reservoir is a fractal domain reservoir, not a texel cache. Pixel rows are the current D3D12 execution projection of selected domain evidence. Area-domain contributors must carry selected subpixel or local @@ -78,6 +181,12 @@ invariants: direct SDF proxies, meshes, volumes, fractal probes, and Mimir sensor observations must publish bounded Form/Appearance/Transport field claims before they can participate in reusable field evidence. + - Eve is the portable retained-dashboard authority. The C# Aquarium/CultUI + contract is the reference API shape for those documents; Fensalir Direct2D + and D3D12 hit testing lower that document locally, but must not become the + owner of dashboard semantics or client policy. `AquariumUiBinding` names + the Verse-backed CultCache/CultMesh field behind a UI element; local + callbacks remain lowering actuators, not a second state authority. - Final composition reads the resolved HDR field texture produced by the shared reservoir history update pass. TubeField render lowerings inject visibility proposals into the shared field reservoir; they must not regain @@ -106,11 +215,29 @@ invariants: owns an `IAquariumAudioStemBus` service for published processed stems, optional monitor playback is explicit, and OBS-facing publication remains a later host cut. + - System audio capture is an engine-owned audio-host request, not a client + sidecar. Clients enqueue a profile-keyed system-loopback capture request on + `AquariumAudioDocument`; Fensalir owns WASAPI loopback capture, publishes a + mono `system_mix` stem through `IAquariumAudioStemBus`, and clients consume + it through the same stem service as streaming DSP outputs. - The named D3D12 program-output texture is an external publication surface. After copying the completed backbuffer into it, the renderer must leave the resource in `Common` so OBS/EVE/native consumers can transition it for GPU-side reads without inheriting Fensalir's internal copy-destination state. + - Fensalir's DirectWrite/Direct2D overlay is an Eve client surface for shared + CultMesh UI documents. The adapter should lower those documents into + `AquariumUiDocument` and route commands back through Eve/CultNet; provider + repos still own dashboard state, accepted commands, and meaning. CultUI + bindings carry document schema/id, path, value kind, access, authority, and + optional command id so provider state can move into CultCache without + changing the native renderer contract. + preview elements may expose reusable item-body/corner-handle gestures in + normalized preview coordinates, but the client remains the owner of what + those gestures do. The preview primitive also carries hover/active state, + empty-canvas hits, drag-end events, and client-provided normalized guides + so compositor surfaces can show snap/selection feedback without becoming + renderer-owned policy. - Planetary ocean/atmosphere flow is an engine field organ when reduced to reusable stochastic advection machinery. Fensalir owns cube-sphere flow addressing, dataset provenance, model contracts, stochastic advection @@ -168,6 +295,30 @@ next_actions: `reservoir-sequence-20260530-003746-*` proves it. The next cut is turning budget-pressure evidence into a renderer-owned per-tile adaptive update contract plus future exact producer SDF replay. + - Window resize no longer crashes on the D3D12 swapchain path: resize now + disposes DirectWrite overlays and wrapped backbuffers, clears/flushes the + D3D11-on-12 context before `ResizeBuffers`, and drops program-output shared + textures before recreating size-dependent surfaces. Visible resize probe + `20260530-215113-639e6612` survived seven programmatic resizes. + - Reservoir reconstruction now has a loss-curve reporter: + `scripts/measure-reservoir-loss-curve.ps1` computes MAE/MSE/RMSE/PSNR + against same-time higher-work-grid native references. Smoke + `reservoir-sequence-20260530-221043-*` at 320x180 gave whole-frame PSNR + 21.668, 20.981, 20.911, and 21.690 dB for ready frames 1, 2, 4, and 8. + D3D12 now owns fixed per-frame GPU timestamp queries for frame record, + scene candidate, reservoir resolve/update, bloom, presentation resolve, and + active compute lowerings, so quality can be plotted against pass cost + instead of process capture wall time. The presentation resolve pass also + owns the first full-resolution reconstruction filter: it samples a bounded + resolved-reservoir neighborhood, validates neighbors by field id, travel, + normal, coverage, detail, color, guide confidence, domain validity, and + maturity, and writes only to the final present buffer. Latest smoke + `reservoir-sequence-20260530-232852-*` improved f2/f4/f8 and lowered + average RMSE from 21.944 to 21.221 against the 20260530-221043 + pre-reconstruction curve, while f1 still regressed. Next cut: merge loss + and GPU timing into one benchmark artifact and tune first-frame + reconstruction maturity without adding native-resolution reservoir + intermediates. - Build the planetary flow organ in staged cuts: provenance manifest, static relief and reanalysis cube-sphere bake pipeline, stochastic advection contracts, synthetic runtime slice, ERA5/OSCAR baseline learning, then @@ -224,10 +375,24 @@ next_actions: until a real planar copy path exists; GPU producers can still write NV12 leases directly. This makes imported handles an edge case, not the primary live camera authority. Missing or unresolved TubeField ramps bind a Fensalir-owned linear fallback ramp. - Camera texture claims can therefore resolve resources, but their first - visual use stays debug-view-only for now; production visual-fusion and - feature-extraction lowerings are deferred until the renderer and audio work - are fixed. TubeField compute now + Camera texture claims can therefore resolve resources. Camera-sensor + Feature claims with `SensorObservation` proposal policy now plan to the + `GpuSensorFusion` backend instead of deferring. `D3D12Renderer` derives + sensor-camera packets and SRV table entries from planned FieldEvidence + Texture2D resources, then dispatches `D3D12GpuSensorFusionCS` into the + temporal-gaussian buffer. The 2026-05-31 Mimir all-sensor proof planned all + 12 packets with zero deferred requests; the first D3D12 host dispatched + feature extraction for LeapStereoIr plus both Bayer8 PS3 Eyes at 16,384 + sparse samples per texture, generating 49,152 gaussians with sensor-fusion + timing around 0.059 ms/frame. The follow-up YUY2 decode cut binds + `DXGI_FORMAT_YUY2` resources through the Microsoft-documented + `R8G8B8A8_UNORM` SRV view, decodes `Y0/U/Y1/V` in + `D3D12GpuSensorFusion.hlsl`, and the Mimir all-sensor proof dispatched all + five camera textures with zero unsupported field textures, generating + 81,920 sparse gaussians at about 0.08 ms/frame sensor-fusion GPU time. + Packed video is not allowed to masquerade as ordinary RGBA; declared format + metadata selects the decode path. + TubeField compute now emits one GPU-resident D3D12 DrawIndexed indirect argument packet per material/resource batch, and render binds the batch source/ramp state then executes the packet with ExecuteIndirect. The first material is opaque field @@ -244,20 +409,34 @@ next_actions: compute-writable UAV-backed pages. Empty SurfacePage version is content freshness, not texture identity, so compute outputs can update without reallocating when shape/format/access are unchanged. FieldEvidence now has - an explicit `AquariumFieldStereoDepthLowering` sidecar for future calibrated + an explicit `AquariumFieldStereoDepthLowering` sidecar for calibrated stereo compute dispatch: validation requires a Height claim whose payload is the compute-writable disparity SurfacePage, shader-readable left/right Texture2D or RollingTexture resources, profile/calibration/camera-pair identity, and coherent output shape/range. This keeps Mimir's libSGM-shaped D3D12 depth path inside typed field/resource authority instead of a private texture or CUDA/TensorRT dependency. The D3D12 renderer now evaluates - stereo-depth dispatch readiness without writing fake depth: a lowering is - ready only when its Height claim is planned and left/right input textures - plus UAV disparity SurfacePage resolve through the field resource registry; - capacity logs still state that the kernel is not installed. The lowering + stereo-depth dispatch readiness and runs the first packed-Leap compute + kernel: a lowering is eligible only when its Height claim is planned and + left/right input textures plus UAV disparity SurfacePage resolve through + the field resource registry. The first `D3D12StereoDepth.hlsl` lane handles + the immediate Mimir Leap shape where left and right observations are packed + into the R/G channels of one Texture2D, then writes crude R16F disparity by + local SAD/block matching. This is a live depth proof, not final + libSGM-style SGM or calibrated metric depth. The lowering sidecar now carries the first SGM-shaped kernel constants too: min disparity, disparity levels, aggregation paths, census radius, and P1/P2 - smoothness penalties. VolumeTexture + smoothness penalties. The first Leap point-cloud render lane now follows + that disparity socket: Mesh packages with + `SourceUri=derived-from:` allocate UAV-capable + vertex/index buffers, `D3D12PointCloudFromDisparityCS` fills a standard + `PositionNormalUvColor` PointList from the disparity SurfacePage, and + `D3D12PointCloudPS` renders it into the scene candidate MRTs through the + normal depth/camera path. This is intentionally sourced from typed Mesh and + SurfacePage resources; CPU point arrays and unproven RGB depth are not live + authorities. Headless pipeline smoke captured + `artifacts/runtime/fensalir-point-cloud-pipeline-smoke.png`. + VolumeTexture resources now have the same first resolver shape: contracts and DSL can declare GPU-resident 3D textures with dimensions, depth, format, and version, and the D3D12 registry diff --git a/state/scratch-fractal-surface-visible.md b/state/scratch-fractal-surface-visible.md new file mode 100644 index 0000000..438d920 --- /dev/null +++ b/state/scratch-fractal-surface-visible.md @@ -0,0 +1,18 @@ +# Fractal Surface Visible Slice + +Objective: make the live Zyphos scene test authored fractal reservoir terrain instead of a decorative proxy. + +Kept: +- GPU-resident 2M splat reservoir field. +- Separate SDF, PBR, and radiosity reservoir layers. +- `.aquageo` selected-cut program lowering. + +Changed: +- Packed IFS transform rows now preserve cube-sphere tile face/level/x/y. +- Compute maps authored splats onto cube-sphere faces instead of a generic +Z cap. +- Visible splats render as tangent-frame surface patches, not camera-facing stickers. +- Zyphos camera targets the selected domain while keeping the parent as orbit anchor. + +Still not good enough: +- The live view is fast and the reservoir is populated, but the current surface-consumption pass still reads too abstractly under the noisy starfield. +- Next coherent cut is a real clustered surface resolve or ray-query style splat surface pass, not more billboard cosmetics. diff --git a/state/scratch.md b/state/scratch.md index 8fa8566..42b04d9 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -2,6 +2,16 @@ ## Current Slice +Leap/Mimir point-cloud bridge: +Mimir now declares a Leap disparity-derived `FieldMesh` PointList. Fensalir now +preserves mesh `SourceUri`, allocates `derived-from:*` mesh buffers as UAV-capable +GPU resources, fills those vertices/indices from the disparity SurfacePage in +`D3D12PointCloudFromDisparityCS`, and renders the standard +`PositionNormalUvColor` point list through `D3D12PointCloudPS` into the scene +candidate targets. This is the first visible point-cloud lane; full SGM, +metric calibration, splat sizing, and the global residual/calibration owner are +still separate cuts. + Turn the native-domain reservoir from a native-resolution cache into a budgeted spatiotemporal sampler. The work grid now defaults to half present resolution and is clamped below native presentation scale; scene evidence, @@ -87,6 +97,59 @@ for the current frame. Smoke mode at scale 0.5 / spatial budget 0.5. A bitmap probe counted 32078 green, 6818 blue, and 3968 other/filtering pixels in the 304x141 present output. +Window resize fix: +The Fensalir window resize path now explicitly clears and flushes the D3D11-on-12 +overlay context after disposing DirectWrite overlays and wrapped backbuffer +resources, before `IDXGISwapChain::ResizeBuffers`. Resize also disposes +program-output shared textures before recreating size-dependent output surfaces. +Probe `scripts/dev-reload.ps1` visible slot `20260530-215113-639e6612` survived +seven programmatic resizes and logged rebuilt D3D12 targets at each size with no +stderr. + +Reservoir loss curve: +`scripts/measure-reservoir-loss-curve.ps1` now reports RGB reconstruction loss +against same-time higher-work-grid native references. Metrics include MAE, MSE, +RMSE, PSNR, changed pixels, max channel delta, and optional masked loss when +disocclusion/rejection masks are present. Smoke +`artifacts/fensalir-captures/reservoir-sequence-20260530-221043-*` compared +native scale 0.5 / spatial budget 0.5 against reference scale 0.75 / spatial +budget 1.0 at ready frames 1, 2, 4, and 8. Whole-frame PSNR was 21.668, 20.981, +20.911, and 21.690 dB respectively; RMSE was 21.043, 22.777, 22.962, and 20.992. +This is a higher-budget online reference, not offline ground truth. + +GPU pass timing: +The D3D12 renderer now owns fixed pass-level GPU timestamp queries in per-frame +resources and reports once-per-second averages beside CPU capacity logs. The +timed passes are frame command recording, GPU sensor fusion, stereo depth, +fractal reservoir update, tube-field update, height field, scene candidate, +field-reservoir resolve, reservoir-history update, bloom, and presentation +resolve. A 320x180 headless timing smoke after the reconstruction pass logged +record 1.338 ms, scene-candidate 0.364 ms, reservoir-resolve 0.004 ms, +reservoir-update 0.853 ms, bloom 0.029 ms, and presentation 0.077 ms over 61 +frames in `artifacts/dev-reload/fensalir-capture.out.log`. + +Reservoir presentation reconstruction: +`D3D12ReservoirPresentationResolvePS` now performs the first final-pass +spatiotemporal reservoir reconstruction filter. The reservoir/history grid +remains below native resolution; the final present pass is the only +full-resolution surface. Reconstruction samples a bounded 3x3 neighborhood from +the resolved reservoir and weights neighbors by field id, travel/depth, normal, +coverage, detail, color, guide confidence, domain validity, and neighbor +agreement. The filter is maturity-gated so the first visible frame cannot blur +from unearned history. Debug mode 21 visualizes reconstruction mix, confidence, +and domain validity. + +Latest measured curve: +`artifacts/fensalir-captures/reservoir-sequence-20260530-232852-*` compared +native scale 0.5 / spatial budget 0.5 against reference scale 0.75 / spatial +budget 1.0 at 320x180, ready frames 1, 2, 4, and 8. Whole-frame RMSE/PSNR: +f1 21.788 / 21.366 dB, f2 21.068 / 21.658 dB, f4 21.473 / 21.493 dB, f8 20.556 +/ 21.872 dB. Against the pre-reconstruction curve +`reservoir-sequence-20260530-221043-*`, f2/f4/f8 improved and f1 regressed; +average RMSE dropped from 21.944 to 21.221. This is competent enough to keep, +but the next quality cut should target the first-frame regression and plot the +curve against GPU pass timings in one artifact. + ## Hot Lesson Area ReSTIR confirms that reuse over an area domain is invalid unless the @@ -113,15 +176,23 @@ SDF history reconstructs the previous selected world hit from stored UV/travel, carries it through object center motion, and validates current ray/travel/support before temporal reuse can merge it. TubeField batches beyond the manifest source cap remain intentionally non-authoritative. +Swapchain resize correctness depends on releasing both D3D12 backbuffer owners +and D3D11-on-12 overlay references before `ResizeBuffers`. Disposing wrapped +resources is not enough; the D3D11 immediate context must be cleared and flushed +so it stops holding hidden references. +Reservoir quality must be golfed against loss-over-time, not single-frame smoke +deltas. The current curve is noisy and not monotonically improving, which means +the candidate/reference comparison is exposing temporal instability rather than +a settled convergence story. ## Next Bounded Move Follow `docs/perfect-fensalir-machine-roadmap.md` Phase 1: -1. Convert the fixed spatial-reuse budget into a pressure-guided adaptive - update contract only after the renderer owns a per-tile pressure input path. - Debug mode 20 is the visible layer to validate the active budget phase. Do - not add native-resolution reservoir intermediates. +1. Put loss and GPU timing into one benchmark artifact, then tune the + presentation reconstruction first-frame maturity gate so f1 no longer + regresses while f2/f4/f8 keep their improvement. Do not add native-resolution + reservoir intermediates. 2. Add exact SDF producer replay only when client SDF distance functions have a shared replay include or manifest instead of duplicating shader policy in post. @@ -129,6 +200,9 @@ Follow `docs/perfect-fensalir-machine-roadmap.md` Phase 1: can tell whether extra samples buy visible stability. 4. Replace the bounded TubeField replay source descriptor table with a replay atlas only if real scenes hit the cap. +5. For Mimir geometry, verify live Leap point clouds in the editor, then add + the residual/calibration owner that consumes Leap point error before other + cameras contribute motion/RGB evidence. ## Verification diff --git a/tests/Aquarium.Engine.Fractal.Tests/Aquarium.Engine.Fractal.Tests.csproj b/tests/Aquarium.Engine.Fractal.Tests/Aquarium.Engine.Fractal.Tests.csproj index 59d26df..bfce143 100644 --- a/tests/Aquarium.Engine.Fractal.Tests/Aquarium.Engine.Fractal.Tests.csproj +++ b/tests/Aquarium.Engine.Fractal.Tests/Aquarium.Engine.Fractal.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/tests/Aquarium.Engine.Fractal.Tests/CubeTileKeyTests.cs b/tests/Aquarium.Engine.Fractal.Tests/CubeTileKeyTests.cs index 498762b..880804e 100644 --- a/tests/Aquarium.Engine.Fractal.Tests/CubeTileKeyTests.cs +++ b/tests/Aquarium.Engine.Fractal.Tests/CubeTileKeyTests.cs @@ -1,13 +1,14 @@ -using Aquarium.Engine.Fractal; +using CultMath; +using GameCult.Geometry; namespace Aquarium.Engine.Fractal.Tests; -public sealed class CubeTileKeyTests +public sealed class PlanetaryTileAddressTests { [Fact] public void PositionAtMapsTileLocalCoordinatesIntoFaceCoordinates() { - var key = new CubeTileKey(CubeFace.PositiveZ, 2, 1, 2); + var key = new PlanetaryTileAddress(PlanetaryCubeFace.PositiveZ, 2, 1, 2); var lowerLeft = key.PositionAt(0.0, 0.0); var upperRight = key.PositionAt(1.0, 1.0); @@ -21,11 +22,11 @@ public sealed class CubeTileKeyTests [Fact] public void ChildAndParentPreserveQuadtreeAddress() { - var root = new CubeTileKey(CubeFace.NegativeY, 0, 0, 0); + var root = new PlanetaryTileAddress(PlanetaryCubeFace.NegativeY, 0, 0, 0); var child = root.Child(1, 0).Child(0, 1); - Assert.Equal(new CubeTileKey(CubeFace.NegativeY, 2, 2, 1), child); - Assert.Equal(new CubeTileKey(CubeFace.NegativeY, 1, 1, 0), child.Parent()); + Assert.Equal(new PlanetaryTileAddress(PlanetaryCubeFace.NegativeY, 2, 2, 1), child); + Assert.Equal(new PlanetaryTileAddress(PlanetaryCubeFace.NegativeY, 1, 1, 0), child.Parent()); Assert.Equal("NegativeY/L02/2/1", child.ToString()); } } diff --git a/tests/Aquarium.Engine.Fractal.Tests/FractalBoundaryTests.cs b/tests/Aquarium.Engine.Fractal.Tests/FractalBoundaryTests.cs index c7c01b3..6b80461 100644 --- a/tests/Aquarium.Engine.Fractal.Tests/FractalBoundaryTests.cs +++ b/tests/Aquarium.Engine.Fractal.Tests/FractalBoundaryTests.cs @@ -1,4 +1,6 @@ using Aquarium.Engine.Fractal; +using CultMath; +using GameCult.Geometry; namespace Aquarium.Engine.Fractal.Tests; @@ -7,7 +9,7 @@ public sealed class FractalBoundaryTests [Fact] public void FractalAssemblyDoesNotReferenceRendererOrD3D12Assemblies() { - var references = typeof(CubeTileKey) + var references = typeof(PlanetaryTileAddress) .Assembly .GetReferencedAssemblies() .Select(reference => reference.Name ?? string.Empty); diff --git a/tests/Aquarium.Engine.Fractal.Tests/FractalGpuProgramCompilerTests.cs b/tests/Aquarium.Engine.Fractal.Tests/FractalGpuProgramCompilerTests.cs index dcc850e..76038f6 100644 --- a/tests/Aquarium.Engine.Fractal.Tests/FractalGpuProgramCompilerTests.cs +++ b/tests/Aquarium.Engine.Fractal.Tests/FractalGpuProgramCompilerTests.cs @@ -1,5 +1,7 @@ using Aquarium.Engine.Fractal.Grammar; using Aquarium.Engine.Fractal.Lod; +using CultMath; +using GameCult.Geometry; namespace Aquarium.Engine.Fractal.Tests; @@ -30,7 +32,7 @@ public sealed class FractalGpuProgramCompilerTests Assert.InRange(transform.MaterialSeedShape.X, 0.0f, 1.0f); }); Assert.Contains(transforms, transform => - transform.TileAddress.X == (float)CubeFace.PositiveZ + transform.TileAddress.X == (float)PlanetaryCubeFace.PositiveZ && transform.TileAddress.Y == 2.0f && transform.TileAddress.Z == 1.0f && transform.TileAddress.W == 1.0f); diff --git a/tests/Aquarium.Engine.Fractal.Tests/FractalGrammarContractTests.cs b/tests/Aquarium.Engine.Fractal.Tests/FractalGrammarContractTests.cs index cafedf1..c1f8385 100644 --- a/tests/Aquarium.Engine.Fractal.Tests/FractalGrammarContractTests.cs +++ b/tests/Aquarium.Engine.Fractal.Tests/FractalGrammarContractTests.cs @@ -1,6 +1,8 @@ using System.Numerics; using Aquarium.Engine.Fractal; using Aquarium.Engine.Fractal.Grammar; +using CultMath; +using GameCult.Geometry; namespace Aquarium.Engine.Fractal.Tests; @@ -9,7 +11,7 @@ public sealed class FractalGrammarContractTests [Fact] public void CubeTileStableKeyIncludesTileAddressAndNormalizedGrammarPath() { - var tile = new CubeTileKey(CubeFace.PositiveZ, 3, 2, 5); + var tile = new PlanetaryTileAddress(PlanetaryCubeFace.PositiveZ, 3, 2, 5); var key = FractalStableKeyBuilder.ForCubeTile(tile, @"terrain\ridge/root"); diff --git a/tests/Aquarium.Engine.Fractal.Tests/FractalHeightBrushCompilerTests.cs b/tests/Aquarium.Engine.Fractal.Tests/FractalHeightBrushCompilerTests.cs index 6213772..583344b 100644 --- a/tests/Aquarium.Engine.Fractal.Tests/FractalHeightBrushCompilerTests.cs +++ b/tests/Aquarium.Engine.Fractal.Tests/FractalHeightBrushCompilerTests.cs @@ -3,6 +3,8 @@ using Aquarium.Engine.Fractal; using Aquarium.Engine.Fractal.Brushes; using Aquarium.Engine.Fractal.Grammar; using Aquarium.Engine.Fractal.Lod; +using CultMath; +using GameCult.Geometry; namespace Aquarium.Engine.Fractal.Tests; @@ -64,7 +66,7 @@ public sealed class FractalHeightBrushCompilerTests new AquariumFractalKey("cube/NegativeX/L02/2/1:zyphos/leaf"), AquariumFractalDomainKind.CubeSphereTile, default, - new Vector4((float)CubeFace.NegativeX, 2.0f, 2.0f, 1.0f), + new Vector4((float)PlanetaryCubeFace.NegativeX, 2.0f, 2.0f, 1.0f), Vector4.Zero); var rootKey = new AquariumFractalKey("cube/NegativeX/L02/2/1:zyphos/leaf/root"); var claim = new AquariumBrushClaim( @@ -84,7 +86,7 @@ public sealed class FractalHeightBrushCompilerTests var brush = Assert.Single(FractalHeightBrushCompiler.CompileTree(tree)); - Assert.Equal((float)CubeFace.NegativeX, brush.DomainFace); + Assert.Equal((float)PlanetaryCubeFace.NegativeX, brush.DomainFace); Assert.Equal(2.0f, brush.DomainLevel); Assert.Equal(2.0f, brush.DomainX); Assert.Equal(1.0f, brush.DomainY); @@ -126,6 +128,6 @@ public sealed class FractalHeightBrushCompilerTests var brush = Assert.Single(FractalHeightBrushCompiler.CompileSelectedTree(tree, selectedCut)); Assert.Equal(1.0f, brush.Center.X); - Assert.Equal((float)CubeFace.PositiveX, brush.DomainFace); + Assert.Equal((float)PlanetaryCubeFace.PositiveX, brush.DomainFace); } } diff --git a/tests/Aquarium.Engine.Fractal.Tests/FractalPipelineFixtureTests.cs b/tests/Aquarium.Engine.Fractal.Tests/FractalPipelineFixtureTests.cs index 8bbc019..aab2d60 100644 --- a/tests/Aquarium.Engine.Fractal.Tests/FractalPipelineFixtureTests.cs +++ b/tests/Aquarium.Engine.Fractal.Tests/FractalPipelineFixtureTests.cs @@ -3,6 +3,8 @@ using Aquarium.Engine.Fractal; using Aquarium.Engine.Fractal.Brushes; using Aquarium.Engine.Fractal.Grammar; using Aquarium.Engine.Fractal.Lod; +using CultMath; +using GameCult.Geometry; namespace Aquarium.Engine.Fractal.Tests; @@ -11,7 +13,7 @@ public sealed class FractalPipelineFixtureTests [Fact] public void CubeTileClaimPipelineProducesRenderableBudgetedBrush() { - var tile = new CubeTileKey(CubeFace.PositiveZ, 2, 1, 1); + var tile = new PlanetaryTileAddress(PlanetaryCubeFace.PositiveZ, 2, 1, 1); var domainKey = FractalStableKeyBuilder.ForCubeTile(tile, "terrain/ridges"); var rootKey = FractalStableKeyBuilder.Child(domainKey, "root"); var claimKey = FractalStableKeyBuilder.Child(rootKey, "claim/0"); diff --git a/tests/Aquarium.Engine.Fractal.Tests/PlanetarySurfacePageTests.cs b/tests/Aquarium.Engine.Fractal.Tests/PlanetarySurfacePageTests.cs new file mode 100644 index 0000000..370b73f --- /dev/null +++ b/tests/Aquarium.Engine.Fractal.Tests/PlanetarySurfacePageTests.cs @@ -0,0 +1,38 @@ +using CultMath; +using GameCult.Geometry; + +namespace Aquarium.Engine.Fractal.Tests; + +public sealed class PlanetarySurfacePageTests +{ + [Fact] + public void SiblingPagesShareBitStableBoundaryDirections() + { + var left = new PlanetaryPageLayout(new PlanetaryTileAddress(PlanetaryCubeFace.PositiveZ, 1, 0, 0), 17, 2); + var right = new PlanetaryPageLayout(new PlanetaryTileAddress(PlanetaryCubeFace.PositiveZ, 1, 1, 0), 17, 2); + for (var y = 0; y < left.InteriorSize; y++) + { + var a = PlanetaryPageSampling.Direction(left, left.BorderSize + left.InteriorSize - 1, left.BorderSize + y); + var b = PlanetaryPageSampling.Direction(right, right.BorderSize, right.BorderSize + y); + Assert.Equal(a, b); + } + } + + [Fact] + public void BorderSamplesExtendPastTileWithoutNeighborResidency() + { + var page = new PlanetaryPageLayout(new PlanetaryTileAddress(PlanetaryCubeFace.PositiveX, 0, 0, 0), 17, 3); + var edge = PlanetaryPageSampling.Direction(page, page.BorderSize + page.InteriorSize - 1, page.BorderSize + 8); + var border = PlanetaryPageSampling.Direction(page, page.StorageSize - 1, page.BorderSize + 8); + Assert.NotEqual(edge, border); + Assert.Equal(1.0f, math.length(border), precision: 5); + } + + [Fact] + public void AngularTexelSizeShrinksWithQuadtreeLevel() + { + var root = new PlanetaryPageLayout(new PlanetaryTileAddress(PlanetaryCubeFace.PositiveZ, 0, 0, 0), 129, 4); + var child = new PlanetaryPageLayout(new PlanetaryTileAddress(PlanetaryCubeFace.PositiveZ, 1, 0, 0), 129, 4); + Assert.True(PlanetaryPageSampling.AngularTexelSize(child) < PlanetaryPageSampling.AngularTexelSize(root)); + } +} diff --git a/tests/Aquarium.Engine.Fractal.Tests/ProjectionTests.cs b/tests/Aquarium.Engine.Fractal.Tests/ProjectionTests.cs index f387fd3..d1b3908 100644 --- a/tests/Aquarium.Engine.Fractal.Tests/ProjectionTests.cs +++ b/tests/Aquarium.Engine.Fractal.Tests/ProjectionTests.cs @@ -1,6 +1,8 @@ using System.Numerics; using Aquarium.Engine.Fractal; using Aquarium.Engine.Fractal.Projection; +using CultMath; +using GameCult.Geometry; namespace Aquarium.Engine.Fractal.Tests; @@ -17,7 +19,7 @@ public sealed class ProjectionTests { foreach (var projection in Projections) { - var point = projection.Project(new CubeFacePosition(CubeFace.PositiveZ, 0.35, -0.25)); + var point = projection.Project(new PlanetaryFaceCoordinate(PlanetaryCubeFace.PositiveZ, 0.35, -0.25)); Assert.Equal(1.0f, point.Length(), 5); } @@ -26,12 +28,12 @@ public sealed class ProjectionTests [Fact] public void FaceEdgesMeetAtTheSameProjectedPoints() { - var edges = new (CubeFacePosition A, CubeFacePosition B)[] + var edges = new (PlanetaryFaceCoordinate A, PlanetaryFaceCoordinate B)[] { - (new CubeFacePosition(CubeFace.PositiveZ, 1.0, -0.5), new CubeFacePosition(CubeFace.PositiveX, -1.0, -0.5)), - (new CubeFacePosition(CubeFace.PositiveZ, -1.0, 0.25), new CubeFacePosition(CubeFace.NegativeX, 1.0, 0.25)), - (new CubeFacePosition(CubeFace.PositiveZ, 0.5, 1.0), new CubeFacePosition(CubeFace.PositiveY, 0.5, -1.0)), - (new CubeFacePosition(CubeFace.PositiveZ, -0.25, -1.0), new CubeFacePosition(CubeFace.NegativeY, -0.25, 1.0)), + (new PlanetaryFaceCoordinate(PlanetaryCubeFace.PositiveZ, 1.0, -0.5), new PlanetaryFaceCoordinate(PlanetaryCubeFace.PositiveX, -1.0, -0.5)), + (new PlanetaryFaceCoordinate(PlanetaryCubeFace.PositiveZ, -1.0, 0.25), new PlanetaryFaceCoordinate(PlanetaryCubeFace.NegativeX, 1.0, 0.25)), + (new PlanetaryFaceCoordinate(PlanetaryCubeFace.PositiveZ, 0.5, 1.0), new PlanetaryFaceCoordinate(PlanetaryCubeFace.PositiveY, 0.5, -1.0)), + (new PlanetaryFaceCoordinate(PlanetaryCubeFace.PositiveZ, -0.25, -1.0), new PlanetaryFaceCoordinate(PlanetaryCubeFace.NegativeY, -0.25, 1.0)), }; foreach (var projection in Projections) @@ -46,8 +48,8 @@ public sealed class ProjectionTests [Fact] public void TangentProjectionHasLowerSampledAreaSpreadThanNormalizeBaseline() { - var normalize = ProjectionDistortionSampler.SampleFace(new NormalizeCubeSphereProjection(), CubeFace.PositiveZ, 16); - var tangent = ProjectionDistortionSampler.SampleFace(new TangentCubeSphereProjection(), CubeFace.PositiveZ, 16); + var normalize = ProjectionDistortionSampler.SampleFace(new NormalizeCubeSphereProjection(), PlanetaryCubeFace.PositiveZ, 16); + var tangent = ProjectionDistortionSampler.SampleFace(new TangentCubeSphereProjection(), PlanetaryCubeFace.PositiveZ, 16); Assert.True(tangent.RootMeanSquareRelativeAreaError < normalize.RootMeanSquareRelativeAreaError); Assert.True(tangent.MeanAbsoluteRelativeAreaError < normalize.MeanAbsoluteRelativeAreaError); diff --git a/tests/Aquarium.Engine.Fractal.Tests/ZyphosFractalTerrainTests.cs b/tests/Aquarium.Engine.Fractal.Tests/ZyphosFractalTerrainTests.cs index 693f10f..f500dab 100644 --- a/tests/Aquarium.Engine.Fractal.Tests/ZyphosFractalTerrainTests.cs +++ b/tests/Aquarium.Engine.Fractal.Tests/ZyphosFractalTerrainTests.cs @@ -81,20 +81,16 @@ public sealed class ZyphosFractalTerrainTests } [Fact] - public void ZyphosSceneRequestsGpuFractalReservoirField() + public void ZyphosSceneLowersFractalTerrainIntoThePageBackedPlanetOwner() { var shot = ZyphosCameraComposer.Compose(ZyphosSpatialDomainCatalog.CanopyLeaf, 0.1f, 0.4f, 0.1f, 0.0f); var scene = ZyphosSceneBuilder.Build(1.0f, 0.9f, ZyphosFractalTerrain.BuildRenderPlan(shot)); - Assert.True(scene.FractalReservoirField.HasInput); - Assert.Equal(2_000_000, scene.FractalReservoirField.SplatCount); - Assert.Equal(50_000, scene.FractalReservoirField.SplatUpdatesPerFrame); - Assert.Equal(20_000, scene.FractalReservoirField.ReservoirUpdatesPerPass); - Assert.Equal(2, scene.FractalReservoirField.CandidatesPerReservoirUpdate); - Assert.Equal(ZyphosUmbrosSystem.ZyphosSurfaceRadius, scene.FractalReservoirField.WorldCenterRadius.W); - Assert.True(scene.FractalReservoirField.PriorityFocus.Z > 0.0f); - Assert.True(scene.FractalReservoirField.PriorityFocus.W > 0.0f); - Assert.NotEmpty(scene.FractalReservoirField.ProgramTransforms); + Assert.False(scene.FractalReservoirField.HasInput); + Assert.True(scene.PlanetarySurfacePages.RenderCoarsePatches); + Assert.Equal(64, scene.PlanetarySurfacePages.PatchCells); + Assert.NotEmpty(scene.PlanetarySurfacePages.Pages); + Assert.NotEmpty(scene.HeightFieldBrushes); } [Fact] diff --git a/tests/Aquarium.Engine.Tests/Aquarium.Engine.Tests.csproj b/tests/Aquarium.Engine.Tests/Aquarium.Engine.Tests.csproj index 63d3d04..34c4cf7 100644 --- a/tests/Aquarium.Engine.Tests/Aquarium.Engine.Tests.csproj +++ b/tests/Aquarium.Engine.Tests/Aquarium.Engine.Tests.csproj @@ -20,6 +20,9 @@ + + + diff --git a/tests/Aquarium.Engine.Tests/BokushoBrushSimulationTests.cs b/tests/Aquarium.Engine.Tests/BokushoBrushSimulationTests.cs new file mode 100644 index 0000000..3a8998d --- /dev/null +++ b/tests/Aquarium.Engine.Tests/BokushoBrushSimulationTests.cs @@ -0,0 +1,1143 @@ +using System.Numerics; +using Aquarium.Engine.Render; + +namespace Aquarium.Engine.Tests; + +public sealed class BokushoBrushSimulationTests +{ + [Fact] + public void CpuBrushSimulationProducesDeterministicTraceAndCanvasFields() + { + var frame = new AquariumBokushoBrushFrame + { + TuftCount = 9, + SampleCount = 32, + PhysicsHz = 500.0f, + BrushRadius = 2.2f, + Pressure = 0.84f, + InkLoad = 1.12f, + Wetness = 0.92f, + Splay = 0.86f, + Bend = 0.72f, + Friction = 0.66f, + StrokeP0 = new Vector4(-7.2f, 0.95f, 0.0f, 0.0f), + StrokeP1 = new Vector4(-2.8f, -1.95f, 0.0f, 0.0f), + StrokeP2 = new Vector4(2.6f, -1.70f, 0.0f, 0.0f), + StrokeP3 = new Vector4(7.0f, 0.82f, 0.0f, 0.0f), + }; + + var first = BokushoBrushSimulation.Evaluate(frame); + var second = BokushoBrushSimulation.Evaluate(frame); + + Assert.True(first.HasSamples); + Assert.Equal(32, first.SampleCount); + Assert.Equal(9, first.TuftCount); + Assert.Equal(first.Trace, second.Trace); + Assert.Equal(first.Canvas, second.Canvas); + Assert.All(first.Trace, value => Assert.InRange(value, 0.0f, 1.0f)); + Assert.All(first.Canvas, value => Assert.InRange(value, 0.0f, 1.0f)); + } + + [Fact] + public void CpuBrushSimulationDepositsLocalPigmentPerTuftWithoutHistorySmear() + { + var frame = new AquariumBokushoBrushFrame + { + TuftCount = 7, + SampleCount = 48, + PhysicsHz = 500.0f, + Pressure = 0.76f, + InkLoad = 1.24f, + Wetness = 0.88f, + Splay = 0.74f, + Bend = 0.68f, + Friction = 0.62f, + }; + + var result = BokushoBrushSimulation.Evaluate(frame); + + for (var tuft = 0; tuft < result.TuftCount; tuft++) + { + var variation = 0.0f; + for (var sample = 0; sample < result.SampleCount; sample++) + { + var current = result.Canvas[tuft * result.SampleCount + sample]; + Assert.InRange(current, 0.0f, 1.0f); + if (sample > 0) + { + var previous = result.Canvas[tuft * result.SampleCount + sample - 1]; + variation += MathF.Abs(current - previous); + } + } + + Assert.True(variation > 0.0001f, $"Canvas pigment for tuft {tuft} collapsed to an unchanging history field."); + } + + var centerTuft = result.TuftCount / 2; + var centerCanvas = result.Canvas + .Skip(centerTuft * result.SampleCount) + .Take(result.SampleCount) + .Max(); + var edgeCanvas = result.Canvas.Take(result.SampleCount).Max(); + Assert.True(centerCanvas > 0.0f); + Assert.True(edgeCanvas > 0.0f); + Assert.True(result.Canvas.Max() < 0.98f, "Canvas pigment clipped into a binary ink mask."); + } + + [Fact] + public void CpuBrushSimulationProjectsDepositedCanvasToPageField() + { + var frame = new AquariumBokushoBrushFrame + { + TuftCount = 11, + SampleCount = 64, + PhysicsHz = 500.0f, + BrushRadius = 2.0f, + Pressure = 0.92f, + InkLoad = 1.36f, + Wetness = 0.94f, + Splay = 0.82f, + Bend = 0.78f, + Friction = 0.68f, + StrokeP0 = new Vector4(-7.0f, 0.6f, 0.0f, 0.0f), + StrokeP1 = new Vector4(-2.6f, -1.8f, 0.0f, 0.0f), + StrokeP2 = new Vector4(2.4f, -1.6f, 0.0f, 0.0f), + StrokeP3 = new Vector4(7.0f, 0.7f, 0.0f, 0.0f), + }; + var result = BokushoBrushSimulation.Evaluate(frame); + + var page = BokushoBrushSimulation.ProjectCanvasToPage( + frame, + result.Canvas, + width: 96, + height: 96, + viewCenter: Vector2.Zero, + viewRadius: 8.0f); + + var max = page.Max(); + var offStrokeCorner = page[0]; + + Assert.True(max > 0.01f); + Assert.True(max > offStrokeCorner * 8.0f); + Assert.Contains(page, value => value > 0.0f); + } + + [Fact] + public void CpuBrushPageEvaluationDepositsSweptContactContinuously() + { + var frame = new AquariumBokushoBrushFrame + { + TuftCount = 64, + SampleCount = 96, + PhysicsHz = 500.0f, + BrushRadius = 0.72f, + Pressure = 1.08f, + InkLoad = 1.36f, + Wetness = 1.18f, + Splay = 0.70f, + Bend = 0.68f, + Friction = 0.62f, + Strokes = + [ + new AquariumBokushoBrushStroke + { + StrokeP0 = new Vector4(-3.20f, 0.02f, 0.0f, 0.0f), + StrokeP1 = new Vector4(-2.20f, 0.00f, 0.0f, 0.0f), + StrokeP2 = new Vector4(2.20f, -0.02f, 0.0f, 0.0f), + StrokeP3 = new Vector4(3.20f, 0.00f, 0.0f, 0.0f), + RadiusScale = 0.92f, + PressureScale = 1.04f, + NormalScale = 0.58f, + TangentScale = 1.18f, + ShaftTilt = 0.04f, + ShaftRotation = 0.24f, + GripHeight = 0.88f, + Compliance = 1.06f, + EntryTaper = 0.06f, + ExitTaper = 0.16f, + PigmentScale = 1.20f, + SplitScale = 0.92f, + }, + ], + }; + + var width = 160; + var height = 80; + var page = BokushoBrushSimulation.EvaluatePage(frame, width, height, Vector2.Zero, 4.2f); + var row = height / 2; + var longestRun = 0; + var currentRun = 0; + for (var x = 0; x < width; x++) + { + if (page[row * width + x] > 0.004f) + { + currentRun++; + longestRun = Math.Max(longestRun, currentRun); + } + else + { + currentRun = 0; + } + } + + Assert.True(page.Max() > 0.05f); + Assert.True(longestRun > width / 4, $"longest continuous contact run={longestRun}"); + } + + [Fact] + public void CpuBrushPageEvaluationDoesNotDryOutWhenPhysicsRateIncreases() + { + AquariumBokushoBrushFrame Frame(float physicsHz) => new() + { + TuftCount = 32, + SampleCount = 72, + PhysicsHz = physicsHz, + BrushRadius = 0.78f, + Pressure = 1.02f, + InkLoad = 1.20f, + Wetness = 1.04f, + Splay = 0.74f, + Bend = 0.70f, + Friction = 0.64f, + Strokes = + [ + new AquariumBokushoBrushStroke + { + StrokeP0 = new Vector4(-3.20f, 0.18f, 0.0f, 0.0f), + StrokeP1 = new Vector4(-2.10f, -0.12f, 0.0f, 0.0f), + StrokeP2 = new Vector4(1.80f, -0.08f, 0.0f, 0.0f), + StrokeP3 = new Vector4(3.20f, 0.12f, 0.0f, 0.0f), + RadiusScale = 0.96f, + PressureScale = 1.04f, + NormalScale = 0.60f, + TangentScale = 1.16f, + ShaftTilt = 0.10f, + ShaftRotation = 0.18f, + GripHeight = 0.86f, + Compliance = 1.08f, + EntryTaper = 0.05f, + ExitTaper = 0.18f, + PigmentScale = 1.22f, + SplitScale = 0.88f, + }, + ], + }; + + var lowRate = BokushoBrushSimulation.EvaluatePage(Frame(125.0f), 144, 72, Vector2.Zero, 4.2f); + var highRate = BokushoBrushSimulation.EvaluatePage(Frame(500.0f), 144, 72, Vector2.Zero, 4.2f); + var lowMass = lowRate.Sum(); + var highMass = highRate.Sum(); + + Assert.True(lowMass > 0.0f); + Assert.True(highMass >= lowMass * 0.80f, $"500 Hz integration dried the brush out: low={lowMass} high={highMass}"); + } + + [Fact] + public void PageDensityEncodingMatchesMultiplicativeInkComposition() + { + var cpuPage = 0.0f; + uint gpuDensity = 0; + var contacts = new (float Ink, float Coverage)[] + { + (0.84f, 0.95f), + (0.62f, 0.42f), + (0.30f, 0.80f), + (1.00f, 0.22f), + (0.18f, 0.66f), + }; + + foreach (var (ink, coverage) in contacts) + { + var contribution = BokushoBrushSimulation.PagePatchContribution(ink, coverage); + cpuPage = 1.0f - (1.0f - cpuPage) * (1.0f - contribution); + gpuDensity += BokushoBrushSimulation.EncodePageDensityContribution(contribution); + } + + var gpuPage = BokushoBrushSimulation.DecodePageDensity(gpuDensity); + Assert.InRange(MathF.Abs(gpuPage - cpuPage), 0.0f, 0.00008f); + } + + [Fact] + public void PageDensityContributionUnitsAvoidFloatAddendDrift() + { + var full = BokushoBrushSimulation.EncodePageDensityContribution( + BokushoBrushSimulation.PagePatchInkUnits, + BokushoBrushSimulation.PagePatchCoverageUnits); + var halfInkHalfCoverage = BokushoBrushSimulation.EncodePageDensityContribution( + BokushoBrushSimulation.PagePatchInkUnits / 2u, + BokushoBrushSimulation.PagePatchCoverageUnits / 2u); + + Assert.Equal(288u, full); + Assert.Equal(72u, halfInkHalfCoverage); + } + + [Fact] + public void CpuPageEvaluationDecodesSharedDensityComposition() + { + var frame = new AquariumBokushoBrushFrame + { + TuftCount = 17, + SampleCount = 64, + PhysicsHz = 500.0f, + BrushRadius = 0.82f, + Pressure = 1.04f, + InkLoad = 1.42f, + Wetness = 1.12f, + Splay = 0.76f, + Bend = 0.70f, + Friction = 0.64f, + Strokes = + [ + new AquariumBokushoBrushStroke + { + StrokeP0 = new Vector4(-3.10f, -0.10f, 0.0f, 0.0f), + StrokeP1 = new Vector4(-2.20f, 0.16f, 0.0f, 0.0f), + StrokeP2 = new Vector4(0.28f, 0.24f, 0.0f, 0.0f), + StrokeP3 = new Vector4(1.24f, -0.24f, 0.0f, 0.0f), + RadiusScale = 0.94f, + PressureScale = 1.06f, + NormalScale = 0.62f, + TangentScale = 1.18f, + ShaftTilt = 0.12f, + ShaftRotation = 0.22f, + GripHeight = 0.88f, + Compliance = 1.08f, + EntryTaper = 0.05f, + ExitTaper = 0.18f, + PigmentScale = 1.30f, + SplitScale = 0.82f, + SegmentStart = 0.0f, + SegmentEnd = 0.55f, + SourceStrokeId = 7, + }, + new AquariumBokushoBrushStroke + { + StrokeP0 = new Vector4(-0.28f, 0.24f, 0.0f, 0.0f), + StrokeP1 = new Vector4(0.28f, 0.24f, 0.0f, 0.0f), + StrokeP2 = new Vector4(1.82f, -0.18f, 0.0f, 0.0f), + StrokeP3 = new Vector4(2.76f, -0.48f, 0.0f, 0.0f), + RadiusScale = 0.90f, + PressureScale = 0.98f, + NormalScale = 0.58f, + TangentScale = 1.24f, + ShaftTilt = 0.10f, + ShaftRotation = 0.18f, + GripHeight = 0.90f, + Compliance = 1.10f, + EntryTaper = 0.05f, + ExitTaper = 0.24f, + PigmentScale = 1.18f, + SplitScale = 0.92f, + SegmentStart = 0.55f, + SegmentEnd = 1.0f, + SourceStrokeId = 7, + }, + ], + }; + + var width = 96; + var height = 72; + var page = BokushoBrushSimulation.EvaluatePage(frame, width, height, Vector2.Zero, 4.0f); + var density = BokushoBrushSimulation.EvaluatePageDensity(frame, width, height, Vector2.Zero, 4.0f); + var decodedInkPixels = 0; + for (var index = 0; index < page.Length; index++) + { + var decoded = BokushoBrushSimulation.DecodePageDensity(density[index]); + Assert.Equal(decoded, page[index]); + if (decoded > 0.001f) + { + decodedInkPixels++; + } + } + + Assert.True(decodedInkPixels > 0); + } + + [Fact] + public void PagePatchRadiiKeepSweepLengthOutOfBristleFootprint() + { + var (tangentRadius, normalRadius) = BokushoBrushSimulation.PagePatchRadii( + tangentRadius: 0.42f, + normalRadius: 0.35f, + sweepLength: 0.18f); + + Assert.Equal(0.42f * BokushoBrushSimulation.PagePatchTangentRadiusScale, tangentRadius, precision: 6); + Assert.Equal(0.35f * BokushoBrushSimulation.PagePatchNormalRadiusScale, normalRadius, precision: 6); + + var tiny = BokushoBrushSimulation.PagePatchRadii(0.0f, 0.0f, 0.0f); + Assert.Equal(BokushoBrushSimulation.PagePatchMinimumRadius, tiny.TangentRadius); + Assert.Equal(BokushoBrushSimulation.PagePatchMinimumRadius, tiny.NormalRadius); + } + + [Fact] + public void CpuBrushProfileControlsPageFootprint() + { + var narrow = new AquariumBokushoBrushFrame + { + TuftCount = 9, + SampleCount = 48, + PhysicsHz = 500.0f, + BrushRadius = 1.8f, + Pressure = 0.86f, + InkLoad = 1.18f, + Wetness = 0.88f, + NormalScale = 0.55f, + TangentScale = 1.10f, + }; + var wide = new AquariumBokushoBrushFrame + { + TuftCount = narrow.TuftCount, + SampleCount = narrow.SampleCount, + PhysicsHz = narrow.PhysicsHz, + BrushRadius = narrow.BrushRadius, + Pressure = narrow.Pressure, + InkLoad = narrow.InkLoad, + Wetness = narrow.Wetness, + NormalScale = 1.45f, + TangentScale = narrow.TangentScale, + }; + var narrowResult = BokushoBrushSimulation.Evaluate(narrow); + var wideResult = BokushoBrushSimulation.Evaluate(wide); + + var narrowPage = BokushoBrushSimulation.ProjectCanvasToPage(narrow, narrowResult.Canvas, 64, 64, Vector2.Zero, 8.0f); + var widePage = BokushoBrushSimulation.ProjectCanvasToPage(wide, wideResult.Canvas, 64, 64, Vector2.Zero, 8.0f); + var narrowCoverage = narrowPage.Count(value => value > 0.001f); + var wideCoverage = widePage.Count(value => value > 0.001f); + + Assert.True(wideCoverage > narrowCoverage); + } + + [Fact] + public void CpuBrushSimulationProjectsExplicitStrokePacketsToSharedPage() + { + var frame = new AquariumBokushoBrushFrame + { + TuftCount = 9, + SampleCount = 48, + PhysicsHz = 500.0f, + BrushRadius = 1.5f, + Pressure = 0.90f, + InkLoad = 1.22f, + Wetness = 0.90f, + Splay = 0.78f, + Bend = 0.70f, + Friction = 0.64f, + Strokes = + [ + new AquariumBokushoBrushStroke + { + StrokeP0 = new Vector4(-5.5f, 1.2f, 0.0f, 0.0f), + StrokeP1 = new Vector4(-3.2f, 0.8f, 0.0f, 0.0f), + StrokeP2 = new Vector4(-0.4f, 0.8f, 0.0f, 0.0f), + StrokeP3 = new Vector4(1.8f, 1.1f, 0.0f, 0.0f), + RadiusScale = 0.78f, + PressureScale = 0.82f, + NormalScale = 0.70f, + TangentScale = 1.10f, + }, + new AquariumBokushoBrushStroke + { + StrokeP0 = new Vector4(1.4f, 1.0f, 0.0f, 0.0f), + StrokeP1 = new Vector4(0.4f, 0.2f, 0.0f, 0.0f), + StrokeP2 = new Vector4(-0.2f, -1.6f, 0.0f, 0.0f), + StrokeP3 = new Vector4(-1.5f, -2.6f, 0.0f, 0.0f), + RadiusScale = 1.08f, + PressureScale = 1.12f, + NormalScale = 0.86f, + TangentScale = 1.24f, + }, + ], + }; + + var result = BokushoBrushSimulation.Evaluate(frame); + var page = BokushoBrushSimulation.ProjectCanvasToPage(frame, result.Canvas, 96, 96, Vector2.Zero, 6.0f); + + Assert.Equal(2, result.StrokeCount); + Assert.Equal(frame.SampleCount * frame.TuftCount * 2, result.Canvas.Length); + Assert.True(SamplePage(page, 96, 96, new Vector2(-2.6f, 0.8f), 6.0f) > 0.001f); + Assert.True(SamplePage(page, 96, 96, new Vector2(0.0f, -1.0f), 6.0f) > 0.001f); + } + + [Fact] + public void CpuBrushStrokeDynamicsControlPigmentAndTaper() + { + var faint = StrokeDynamicsFrame(pigmentScale: 0.35f, entryTaper: 0.24f); + var strong = StrokeDynamicsFrame(pigmentScale: 1.65f, entryTaper: 0.04f); + + var faintResult = BokushoBrushSimulation.Evaluate(faint); + var strongResult = BokushoBrushSimulation.Evaluate(strong); + var faintPage = BokushoBrushSimulation.ProjectCanvasToPage(faint, faintResult.Canvas, 96, 96, Vector2.Zero, 4.0f); + var strongPage = BokushoBrushSimulation.ProjectCanvasToPage(strong, strongResult.Canvas, 96, 96, Vector2.Zero, 4.0f); + + Assert.True(strongResult.Canvas.Sum() > faintResult.Canvas.Sum() * 1.45f); + Assert.True(strongPage.Sum() > faintPage.Sum() * 1.45f); + } + + [Fact] + public void CpuBrushPageProjectionUsesWetnessForFootprintOnly() + { + var dry = new AquariumBokushoBrushFrame + { + TuftCount = 13, + SampleCount = 64, + PhysicsHz = 500.0f, + BrushRadius = 1.8f, + Pressure = 0.64f, + InkLoad = 1.0f, + Wetness = 0.0f, + Splay = 0.88f, + Strokes = + [ + new AquariumBokushoBrushStroke + { + StrokeP0 = new Vector4(-3.2f, 0.0f, 0.0f, 0.0f), + StrokeP1 = new Vector4(-2.0f, 0.0f, 0.0f, 0.0f), + StrokeP2 = new Vector4(2.0f, 0.0f, 0.0f, 0.0f), + StrokeP3 = new Vector4(3.2f, 0.0f, 0.0f, 0.0f), + RadiusScale = 1.0f, + PressureScale = 1.0f, + NormalScale = 0.84f, + TangentScale = 1.0f, + EntryTaper = 0.08f, + ExitTaper = 0.08f, + PigmentScale = 1.0f, + SplitScale = 1.5f, + }, + ], + }; + var wet = new AquariumBokushoBrushFrame + { + TuftCount = dry.TuftCount, + SampleCount = dry.SampleCount, + PhysicsHz = dry.PhysicsHz, + BrushRadius = dry.BrushRadius, + Pressure = dry.Pressure, + InkLoad = dry.InkLoad, + Wetness = 1.6f, + Splay = dry.Splay, + Strokes = dry.Strokes, + }; + var canvas = Enumerable.Repeat(1.0f, dry.TuftCount * dry.SampleCount).ToArray(); + + var dryPage = BokushoBrushSimulation.ProjectCanvasToPage(dry, canvas, 96, 96, Vector2.Zero, 4.0f); + var wetPage = BokushoBrushSimulation.ProjectCanvasToPage(wet, canvas, 96, 96, Vector2.Zero, 4.0f); + var edge = new Vector2(0.0f, 0.66f); + + Assert.True(wetPage.Sum() > dryPage.Sum() * 1.01f); + Assert.True(SamplePage(wetPage, 96, 96, edge, 4.0f) > SamplePage(dryPage, 96, 96, edge, 4.0f)); + } + + [Fact] + public void CpuBrushPageProjectionNarrowsTaperedStrokeEnds() + { + var frame = new AquariumBokushoBrushFrame + { + TuftCount = 11, + SampleCount = 80, + PhysicsHz = 500.0f, + BrushRadius = 1.8f, + Pressure = 0.84f, + InkLoad = 1.0f, + Wetness = 1.2f, + Splay = 0.72f, + Strokes = + [ + new AquariumBokushoBrushStroke + { + StrokeP0 = new Vector4(-3.2f, 0.0f, 0.0f, 0.0f), + StrokeP1 = new Vector4(-2.1f, 0.0f, 0.0f, 0.0f), + StrokeP2 = new Vector4(2.1f, 0.0f, 0.0f, 0.0f), + StrokeP3 = new Vector4(3.2f, 0.0f, 0.0f, 0.0f), + RadiusScale = 1.0f, + PressureScale = 1.0f, + NormalScale = 0.80f, + TangentScale = 1.0f, + EntryTaper = 0.20f, + ExitTaper = 0.20f, + PigmentScale = 1.0f, + SplitScale = 1.0f, + }, + ], + }; + var canvas = Enumerable.Repeat(1.0f, frame.TuftCount * frame.SampleCount).ToArray(); + + var page = BokushoBrushSimulation.ProjectCanvasToPage(frame, canvas, 128, 128, Vector2.Zero, 4.0f); + var middleShoulder = SamplePage(page, 128, 128, new Vector2(0.0f, 0.46f), 4.0f); + var entryShoulder = SamplePage(page, 128, 128, new Vector2(-2.1f, 0.46f), 4.0f); + + Assert.True(middleShoulder > entryShoulder * 1.8f); + } + + [Fact] + public void CpuBrushWetLoadSustainsContactTransfer() + { + var wet = LaneCohesionFrame(wetness: 1.35f); + var dry = LaneCohesionFrame(wetness: 0.32f); + + var wetResult = BokushoBrushSimulation.Evaluate(wet); + var dryResult = BokushoBrushSimulation.Evaluate(dry); + var centerTuft = wetResult.TuftCount / 2; + var wetCenter = TuftSum(wetResult.Canvas, wetResult.SampleCount, centerTuft); + var wetEdge = TuftSum(wetResult.Canvas, wetResult.SampleCount, 0); + var dryCenter = TuftSum(dryResult.Canvas, dryResult.SampleCount, centerTuft); + var sustainedWetSamples = wetResult.Canvas + .Skip(centerTuft * wetResult.SampleCount) + .Take(wetResult.SampleCount) + .Count(value => value > 0.06f); + + Assert.True(wetCenter > dryCenter * 1.25f); + Assert.True(wetEdge > 0.0f); + Assert.True(wetResult.Canvas.Sum() > dryResult.Canvas.Sum() * 2.0f); + Assert.True(sustainedWetSamples > wetResult.SampleCount / 3); + } + + [Fact] + public void CpuBrushLaneStateCarriesLoadedBodyAndEdges() + { + var frame = LaneCohesionFrame(wetness: 0.68f, inkLoad: 1.46f, splay: 1.08f); + + var result = BokushoBrushSimulation.Evaluate(frame); + var centerTuft = result.TuftCount / 2; + var edgeTuft = 0; + var center = result.Canvas.Skip(centerTuft * result.SampleCount).Take(result.SampleCount).ToArray(); + var edge = result.Canvas.Skip(edgeTuft * result.SampleCount).Take(result.SampleCount).ToArray(); + var centerSum = center.Sum(); + var edgeSum = edge.Sum(); + var centerBodySamples = center.Count(value => value > 0.10f); + + Assert.True(centerSum > 0.0f); + Assert.True(edgeSum > 0.0f); + Assert.True(centerBodySamples > result.SampleCount / 3); + Assert.True(result.Canvas.Max() < 0.98f, "Loaded body clipped into a saturated mask."); + } + + [Fact] + public void CpuBrushDryFastStateStillTransfersPigmentThroughBristles() + { + var dry = LaneCohesionFrame(wetness: 0.46f, inkLoad: 1.48f, splay: 1.14f); + + var dryResult = BokushoBrushSimulation.Evaluate(dry); + var dryEdge = TuftSum(dryResult.Canvas, dryResult.SampleCount, 0); + var dryCenter = TuftSum(dryResult.Canvas, dryResult.SampleCount, dryResult.TuftCount / 2); + + Assert.True(dryCenter > 0.30f); + Assert.True(dryEdge > 0.30f); + Assert.True(dryResult.Canvas.Max() < 0.98f, "Dry transfer clipped into a saturated mask."); + } + + [Fact] + public void CpuBrushSourceStrokeIdPreventsAccidentalSegmentReplay() + { + var shared = SourceIdentityFrame(sameSourceId: true); + var split = SourceIdentityFrame(sameSourceId: false); + + var sharedResult = BokushoBrushSimulation.Evaluate(shared); + var splitResult = BokushoBrushSimulation.Evaluate(split); + var secondStrokeOffset = sharedResult.SampleCount * sharedResult.TuftCount; + var sharedSecond = sharedResult.Canvas.Skip(secondStrokeOffset).Sum(); + var splitSecond = splitResult.Canvas.Skip(secondStrokeOffset).Sum(); + + Assert.True(MathF.Abs(sharedSecond - splitSecond) > 0.05f); + } + + [Fact] + public void CpuBrushSourceStrokeIdKeepsFanPhaseContinuousAcrossSegments() + { + var shared = SourceIdentityFrame(sameSourceId: true); + var split = SourceIdentityFrame(sameSourceId: false); + + var sharedResult = BokushoBrushSimulation.Evaluate(shared); + var splitResult = BokushoBrushSimulation.Evaluate(split); + var centerTuft = sharedResult.TuftCount / 2; + var sharedJump = TipJumpAtSecondSegmentStart(sharedResult, centerTuft); + var splitJump = TipJumpAtSecondSegmentStart(splitResult, centerTuft); + + Assert.True(sharedJump < splitJump * 1.25f); + } + + [Fact] + public void CpuBrushProjectionIgnoresSourceIdentityAndUsesCanvasSamples() + { + var shared = SourceIdentityFrame(sameSourceId: true); + var split = SourceIdentityFrame(sameSourceId: false); + var sharedResult = BokushoBrushSimulation.Evaluate(shared); + + var sharedPage = BokushoBrushSimulation.ProjectCanvasToPage(shared, sharedResult.Canvas, sharedResult.Tips, 128, 128, Vector2.Zero, 4.0f); + var splitPage = BokushoBrushSimulation.ProjectCanvasToPage(split, sharedResult.Canvas, sharedResult.Tips, 128, 128, Vector2.Zero, 4.0f); + var sharedJoin = SamplePage(sharedPage, 128, 128, new Vector2(0.0f, 0.0f), 4.0f); + var splitJoin = SamplePage(splitPage, 128, 128, new Vector2(0.0f, 0.0f), 4.0f); + + Assert.True(sharedJoin > 0.0f); + Assert.True(MathF.Abs(sharedJoin - splitJoin) < 0.0001f, $"shared join={sharedJoin:0.000000}; split join={splitJoin:0.000000}"); + } + + [Fact] + public void CpuBrushProjectionDoesNotBorrowSamplesAcrossSegmentEdges() + { + var shared = SourceIdentityFrame(sameSourceId: true); + var split = SourceIdentityFrame(sameSourceId: false); + var sharedResult = BokushoBrushSimulation.Evaluate(shared); + var splitResult = BokushoBrushSimulation.Evaluate(split); + var sharedCanvas = LastSampleOnly(sharedResult.Canvas, sharedResult.SampleCount, sharedResult.TuftCount); + var splitCanvas = LastSampleOnly(splitResult.Canvas, splitResult.SampleCount, splitResult.TuftCount); + + var sharedPage = BokushoBrushSimulation.ProjectCanvasToPage(shared, sharedCanvas, sharedResult.Tips, 160, 160, Vector2.Zero, 4.0f); + var splitPage = BokushoBrushSimulation.ProjectCanvasToPage(split, splitCanvas, splitResult.Tips, 160, 160, Vector2.Zero, 4.0f); + var sharedJoin = SamplePage(sharedPage, 160, 160, new Vector2(0.03f, 0.0f), 4.0f); + var splitJoin = SamplePage(splitPage, 160, 160, new Vector2(0.03f, 0.0f), 4.0f); + + Assert.True(sharedJoin <= 0.0001f, $"shared join={sharedJoin:0.000000}"); + Assert.True(splitJoin <= 0.0001f, $"split join={splitJoin:0.000000}"); + } + + [Fact] + public void CpuBrushProjectionTreatsSegmentEdgesAsLocalCanvasSamples() + { + var shared = SourceIdentityFrame(sameSourceId: true); + var split = SourceIdentityFrame(sameSourceId: false); + var sharedResult = BokushoBrushSimulation.Evaluate(shared); + + var sharedPage = BokushoBrushSimulation.ProjectCanvasToPage(shared, sharedResult.Canvas, sharedResult.Tips, 160, 160, Vector2.Zero, 4.0f); + var splitPage = BokushoBrushSimulation.ProjectCanvasToPage(split, sharedResult.Canvas, sharedResult.Tips, 160, 160, Vector2.Zero, 4.0f); + var sharedJoin = SamplePage(sharedPage, 160, 160, new Vector2(0.0f, 0.0f), 4.0f); + var splitJoin = SamplePage(splitPage, 160, 160, new Vector2(0.0f, 0.0f), 4.0f); + + Assert.True(sharedJoin > 0.0f); + Assert.True(MathF.Abs(sharedJoin - splitJoin) < 0.0001f, $"shared join={sharedJoin:0.000000}; split join={splitJoin:0.000000}"); + } + + [Fact] + public void CpuPageDepositDoesNotDoubleStampContinuousSegmentBoundary() + { + var shared = SourceIdentityFrame(sameSourceId: true); + var split = SourceIdentityFrame(sameSourceId: false); + + var sharedDensity = BokushoBrushSimulation.EvaluatePageDensity(shared, 160, 160, Vector2.Zero, 4.0f); + var splitDensity = BokushoBrushSimulation.EvaluatePageDensity(split, 160, 160, Vector2.Zero, 4.0f); + var sharedJoin = SampleDensity(sharedDensity, 160, 160, new Vector2(0.0f, 0.0f), 4.0f); + var splitJoin = SampleDensity(splitDensity, 160, 160, new Vector2(0.0f, 0.0f), 4.0f); + + Assert.True(sharedJoin > 0.0f); + Assert.True(splitJoin > sharedJoin, $"shared join={sharedJoin:0.000000}; split join={splitJoin:0.000000}"); + } + + [Fact] + public void CpuBrushSegmentSpanKeepsLaterFittedPiecesLoaded() + { + var frame = SourceIdentityFrame(sameSourceId: true); + var result = BokushoBrushSimulation.Evaluate(frame); + var firstStrokeInk = StrokeSum(result.Canvas, result.SampleCount, result.TuftCount, stroke: 0); + var secondStrokeInk = StrokeSum(result.Canvas, result.SampleCount, result.TuftCount, stroke: 1); + + Assert.True(secondStrokeInk > firstStrokeInk * 0.25f, $"first={firstStrokeInk:0.000000}; second={secondStrokeInk:0.000000}"); + } + + [Fact] + public void CpuBrushSourcePointsOwnStrokeGeometryWhenPresent() + { + var frame = new AquariumBokushoBrushFrame + { + TuftCount = 17, + SampleCount = 64, + PhysicsHz = 500.0f, + BrushRadius = 0.78f, + Pressure = 1.04f, + InkLoad = 1.36f, + Wetness = 1.06f, + Splay = 0.78f, + Bend = 0.70f, + Friction = 0.66f, + SourcePoints = + [ + new AquariumBokushoSourcePoint { SourceStrokeId = 12, T = 0.0f, Position = new Vector2(-2.8f, 0.0f) }, + new AquariumBokushoSourcePoint { SourceStrokeId = 12, T = 0.5f, Position = new Vector2(0.0f, 0.0f) }, + new AquariumBokushoSourcePoint { SourceStrokeId = 12, T = 1.0f, Position = new Vector2(2.8f, 0.0f) }, + ], + Strokes = + [ + new AquariumBokushoBrushStroke + { + StrokeP0 = new Vector4(-2.8f, 2.4f, 0.0f, 0.0f), + StrokeP1 = new Vector4(-1.2f, 2.2f, 0.0f, 0.0f), + StrokeP2 = new Vector4(1.2f, 2.1f, 0.0f, 0.0f), + StrokeP3 = new Vector4(2.8f, 2.4f, 0.0f, 0.0f), + RadiusScale = 0.92f, + PressureScale = 1.0f, + NormalScale = 0.58f, + TangentScale = 1.10f, + PigmentScale = 1.24f, + SplitScale = 0.82f, + SegmentStart = 0.0f, + SegmentEnd = 1.0f, + SourceStrokeId = 12, + }, + ], + }; + + var page = BokushoBrushSimulation.EvaluatePage(frame, 128, 128, Vector2.Zero, 4.0f); + var sourceLine = SamplePage(page, 128, 128, new Vector2(0.0f, 0.0f), 4.0f); + var packetCurve = SamplePage(page, 128, 128, new Vector2(0.0f, 2.2f), 4.0f); + + Assert.True(sourceLine > 0.001f, $"source line={sourceLine:0.000000}"); + Assert.True(sourceLine > packetCurve * 2.0f, $"source line={sourceLine:0.000000}; packet curve={packetCurve:0.000000}"); + } + + [Fact] + public void CpuBrushCurvedPathChangesBristleSweepAndCoverage() + { + var straight = GestureFrame(curved: false); + var curved = GestureFrame(curved: true); + + var straightResult = BokushoBrushSimulation.Evaluate(straight); + var curvedResult = BokushoBrushSimulation.Evaluate(curved); + var straightPage = BokushoBrushSimulation.ProjectCanvasToPage(straight, straightResult.Canvas, 128, 128, Vector2.Zero, 4.0f); + var curvedPage = BokushoBrushSimulation.ProjectCanvasToPage(curved, curvedResult.Canvas, 128, 128, Vector2.Zero, 4.0f); + var straightCoverage = straightPage.Count(value => value > 0.004f); + var curvedCoverage = curvedPage.Count(value => value > 0.004f); + + Assert.True(MathF.Abs(curvedResult.Canvas.Sum() - straightResult.Canvas.Sum()) > 0.01f); + Assert.True(curvedCoverage != straightCoverage); + } + + [Fact] + public void CpuBrushSourceTaperBuildsBellyAndLiftedEnds() + { + var frame = GestureFrame(curved: false); + var result = BokushoBrushSimulation.Evaluate(frame); + var centerTuft = result.TuftCount / 2; + var entryRadius = TipRadius(result, centerTuft, sample: 1); + var bellyRadius = TipRadius(result, centerTuft, sample: result.SampleCount / 2); + var exitRadius = TipRadius(result, centerTuft, sample: result.SampleCount - 2); + var entryInk = CanvasWindow(result, centerTuft, sample: 2, radius: 2); + var bellyInk = CanvasWindow(result, centerTuft, sample: result.SampleCount / 2, radius: 4); + var exitInk = CanvasWindow(result, centerTuft, sample: result.SampleCount - 3, radius: 2); + + Assert.True(bellyRadius > entryRadius * 2.0f, $"entry={entryRadius:0.000000}; belly={bellyRadius:0.000000}"); + Assert.True(bellyRadius > exitRadius * 1.7f, $"exit={exitRadius:0.000000}; belly={bellyRadius:0.000000}"); + Assert.True(bellyInk > entryInk * 2.0f, $"entry={entryInk:0.000000}; belly={bellyInk:0.000000}"); + Assert.True(bellyInk > exitInk * 1.5f, $"exit={exitInk:0.000000}; belly={bellyInk:0.000000}"); + } + + [Fact] + public void CpuBrushPoseControlsStrokeSpread() + { + var neutral = PoseFrame(tilt: 0.0f, rotation: 0.0f, gripHeight: 1.0f, compliance: 1.0f); + var expressive = PoseFrame(tilt: 0.85f, rotation: 0.65f, gripHeight: 0.58f, compliance: 1.65f); + + var neutralResult = BokushoBrushSimulation.Evaluate(neutral); + var expressiveResult = BokushoBrushSimulation.Evaluate(expressive); + var neutralPage = BokushoBrushSimulation.ProjectCanvasToPage(neutral, neutralResult.Canvas, 128, 128, Vector2.Zero, 4.0f); + var expressivePage = BokushoBrushSimulation.ProjectCanvasToPage(expressive, expressiveResult.Canvas, 128, 128, Vector2.Zero, 4.0f); + var neutralCoverage = neutralPage.Count(value => value > 0.004f); + var expressiveCoverage = expressivePage.Count(value => value > 0.004f); + + Assert.True(MathF.Abs(expressiveResult.Canvas.Sum() - neutralResult.Canvas.Sum()) > 0.01f); + Assert.True(expressiveCoverage > neutralCoverage); + } + + [Fact] + public void CpuBrushPoseSteersTuftDragDirection() + { + var leftLead = PoseFrame(tilt: -0.95f, rotation: -0.70f, gripHeight: 0.58f, compliance: 1.45f); + var rightLead = PoseFrame(tilt: 0.95f, rotation: 0.70f, gripHeight: 0.58f, compliance: 1.45f); + var leftResult = BokushoBrushSimulation.Evaluate(leftLead); + var rightResult = BokushoBrushSimulation.Evaluate(rightLead); + var tuft = leftResult.TuftCount / 2; + var sample = leftResult.SampleCount / 2; + var leftTip = TipPoint(leftResult, tuft, sample); + var rightTip = TipPoint(rightResult, tuft, sample); + var stroke = leftLead.Strokes[0]; + var center = CatmullRomPoint(stroke, sample / (float)(leftResult.SampleCount - 1)); + var tangent = CatmullRomTangent(stroke, sample / (float)(leftResult.SampleCount - 1), leftResult.SampleCount); + var normal = new Vector2(-tangent.Y, tangent.X); + var leftNormalOffset = Vector2.Dot(leftTip - center, normal); + var rightNormalOffset = Vector2.Dot(rightTip - center, normal); + + Assert.True(leftNormalOffset > rightNormalOffset + 0.03f, $"left={leftNormalOffset:0.000000}; right={rightNormalOffset:0.000000}"); + } + + private static AquariumBokushoBrushFrame StrokeDynamicsFrame(float pigmentScale, float entryTaper) + { + return new AquariumBokushoBrushFrame + { + TuftCount = 11, + SampleCount = 56, + PhysicsHz = 500.0f, + BrushRadius = 1.4f, + Pressure = 0.92f, + InkLoad = 1.24f, + Wetness = 0.90f, + Splay = 0.82f, + Bend = 0.74f, + Friction = 0.66f, + Strokes = + [ + new AquariumBokushoBrushStroke + { + StrokeP0 = new Vector4(-2.8f, 0.1f, 0.0f, 0.0f), + StrokeP1 = new Vector4(-1.7f, 0.0f, 0.0f, 0.0f), + StrokeP2 = new Vector4(1.4f, 0.0f, 0.0f, 0.0f), + StrokeP3 = new Vector4(2.8f, -0.1f, 0.0f, 0.0f), + RadiusScale = 0.86f, + PressureScale = 1.0f, + NormalScale = 0.66f, + TangentScale = 1.2f, + EntryTaper = entryTaper, + ExitTaper = 0.18f, + PigmentScale = pigmentScale, + SplitScale = 1.0f, + }, + ], + }; + } + + private static AquariumBokushoBrushFrame GestureFrame(bool curved) + { + return new AquariumBokushoBrushFrame + { + TuftCount = 13, + SampleCount = 84, + PhysicsHz = 500.0f, + BrushRadius = 1.25f, + Pressure = 0.84f, + InkLoad = 1.20f, + Wetness = 1.0f, + Splay = 0.78f, + Bend = 0.72f, + Friction = 0.66f, + Strokes = + [ + new AquariumBokushoBrushStroke + { + StrokeP0 = new Vector4(-2.8f, curved ? 0.8f : 0.0f, 0.0f, 0.0f), + StrokeP1 = new Vector4(-1.4f, curved ? -1.2f : 0.0f, 0.0f, 0.0f), + StrokeP2 = new Vector4(1.4f, curved ? 1.1f : 0.0f, 0.0f, 0.0f), + StrokeP3 = new Vector4(2.8f, curved ? -0.7f : 0.0f, 0.0f, 0.0f), + RadiusScale = 0.88f, + PressureScale = 1.0f, + NormalScale = 0.62f, + TangentScale = 1.12f, + EntryTaper = 0.08f, + ExitTaper = 0.24f, + PigmentScale = 1.0f, + SplitScale = 1.1f, + }, + ], + }; + } + + private static AquariumBokushoBrushFrame PoseFrame(float tilt, float rotation, float gripHeight, float compliance) + { + return new AquariumBokushoBrushFrame + { + TuftCount = 15, + SampleCount = 84, + PhysicsHz = 500.0f, + BrushRadius = 1.16f, + Pressure = 0.86f, + InkLoad = 1.24f, + Wetness = 1.08f, + Splay = 0.84f, + Bend = 0.76f, + Friction = 0.68f, + Strokes = + [ + new AquariumBokushoBrushStroke + { + StrokeP0 = new Vector4(-2.8f, 0.4f, 0.0f, 0.0f), + StrokeP1 = new Vector4(-1.2f, -0.7f, 0.0f, 0.0f), + StrokeP2 = new Vector4(1.6f, 0.5f, 0.0f, 0.0f), + StrokeP3 = new Vector4(2.8f, -0.3f, 0.0f, 0.0f), + RadiusScale = 0.90f, + PressureScale = 1.0f, + NormalScale = 0.66f, + TangentScale = 1.18f, + ShaftTilt = tilt, + ShaftRotation = rotation, + GripHeight = gripHeight, + Compliance = compliance, + EntryTaper = 0.08f, + ExitTaper = 0.24f, + PigmentScale = 1.0f, + SplitScale = 1.25f, + }, + ], + }; + } + + private static AquariumBokushoBrushFrame LaneCohesionFrame(float wetness, float inkLoad = 1.32f, float splay = 0.92f) + { + return new AquariumBokushoBrushFrame + { + TuftCount = 15, + SampleCount = 72, + PhysicsHz = 500.0f, + BrushRadius = 1.2f, + Pressure = 0.88f, + InkLoad = inkLoad, + Wetness = wetness, + Splay = splay, + Bend = 0.78f, + Friction = 0.70f, + Strokes = + [ + new AquariumBokushoBrushStroke + { + StrokeP0 = new Vector4(-3.2f, 0.2f, 0.0f, 0.0f), + StrokeP1 = new Vector4(-1.8f, -0.2f, 0.0f, 0.0f), + StrokeP2 = new Vector4(1.8f, -0.1f, 0.0f, 0.0f), + StrokeP3 = new Vector4(3.2f, 0.3f, 0.0f, 0.0f), + RadiusScale = 0.92f, + PressureScale = 1.0f, + NormalScale = 0.72f, + TangentScale = 1.16f, + EntryTaper = 0.08f, + ExitTaper = 0.22f, + PigmentScale = 1.0f, + SplitScale = 1.8f, + }, + ], + }; + } + + private static AquariumBokushoBrushFrame SourceIdentityFrame(bool sameSourceId) + { + var secondId = sameSourceId ? 7 : 8; + return new AquariumBokushoBrushFrame + { + TuftCount = 9, + SampleCount = 48, + PhysicsHz = 500.0f, + BrushRadius = 1.35f, + Pressure = 0.92f, + InkLoad = 1.28f, + Wetness = 1.02f, + Splay = 0.82f, + Bend = 0.74f, + Friction = 0.68f, + Strokes = + [ + new AquariumBokushoBrushStroke + { + StrokeP0 = new Vector4(-3.0f, 0.2f, 0.0f, 0.0f), + StrokeP1 = new Vector4(-2.0f, 0.0f, 0.0f, 0.0f), + StrokeP2 = new Vector4(-0.6f, 0.0f, 0.0f, 0.0f), + StrokeP3 = new Vector4(0.0f, 0.1f, 0.0f, 0.0f), + RadiusScale = 0.90f, + PressureScale = 1.0f, + NormalScale = 0.70f, + TangentScale = 1.08f, + SegmentStart = 0.0f, + SegmentEnd = 0.5f, + SourceStrokeId = 7, + }, + new AquariumBokushoBrushStroke + { + StrokeP0 = new Vector4(-0.1f, 0.1f, 0.0f, 0.0f), + StrokeP1 = new Vector4(0.0f, 0.0f, 0.0f, 0.0f), + StrokeP2 = new Vector4(1.8f, 0.0f, 0.0f, 0.0f), + StrokeP3 = new Vector4(3.0f, -0.2f, 0.0f, 0.0f), + RadiusScale = 1.0f, + PressureScale = 1.0f, + NormalScale = 0.70f, + TangentScale = 1.08f, + SegmentStart = 0.5f, + SegmentEnd = 1.0f, + SourceStrokeId = secondId, + }, + ], + }; + } + + private static float TuftSum(float[] canvas, int sampleCount, int tuft) + { + return canvas.Skip(tuft * sampleCount).Take(sampleCount).Sum(); + } + + private static float StrokeSum(float[] canvas, int sampleCount, int tuftCount, int stroke) + { + return canvas.Skip(stroke * sampleCount * tuftCount).Take(sampleCount * tuftCount).Sum(); + } + + private static float TipRadius(BokushoBrushSimulationResult result, int tuft, int sample, int stroke = 0) + { + var index = ((stroke * result.TuftCount) + tuft) * result.SampleCount + Math.Clamp(sample, 0, result.SampleCount - 1); + return result.Tips[index].Z; + } + + private static Vector2 TipPoint(BokushoBrushSimulationResult result, int tuft, int sample, int stroke = 0) + { + var index = ((stroke * result.TuftCount) + tuft) * result.SampleCount + Math.Clamp(sample, 0, result.SampleCount - 1); + var tip = result.Tips[index]; + return new Vector2(tip.X, tip.Y); + } + + private static Vector2 CatmullRomPoint(AquariumBokushoBrushStroke stroke, float t) + { + var p0 = new Vector2(stroke.StrokeP0.X, stroke.StrokeP0.Y); + var p1 = new Vector2(stroke.StrokeP1.X, stroke.StrokeP1.Y); + var p2 = new Vector2(stroke.StrokeP2.X, stroke.StrokeP2.Y); + var p3 = new Vector2(stroke.StrokeP3.X, stroke.StrokeP3.Y); + var tt = t * t; + var ttt = tt * t; + return 0.5f * ((2.0f * p1) + (-p0 + p2) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * tt + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * ttt); + } + + private static Vector2 CatmullRomTangent(AquariumBokushoBrushStroke stroke, float t, int sampleCount) + { + var dt = 1.0f / MathF.Max(sampleCount - 1.0f, 1.0f); + var before = CatmullRomPoint(stroke, Math.Clamp(t - dt, 0.0f, 1.0f)); + var after = CatmullRomPoint(stroke, Math.Clamp(t + dt, 0.0f, 1.0f)); + return Vector2.Normalize(after - before); + } + + private static float CanvasWindow(BokushoBrushSimulationResult result, int tuft, int sample, int radius, int stroke = 0) + { + var start = Math.Clamp(sample - radius, 0, result.SampleCount - 1); + var end = Math.Clamp(sample + radius, 0, result.SampleCount - 1); + var total = 0.0f; + for (var index = start; index <= end; index++) + { + total += result.Canvas[((stroke * result.TuftCount) + tuft) * result.SampleCount + index]; + } + + return total; + } + + private static float[] LastSampleOnly(IReadOnlyList canvas, int sampleCount, int tuftCount) + { + var sparse = new float[canvas.Count]; + for (var tuft = 0; tuft < tuftCount; tuft++) + { + var index = (tuft * sampleCount) + sampleCount - 1; + sparse[index] = canvas[index]; + } + + return sparse; + } + + private static float TipJumpAtSecondSegmentStart(BokushoBrushSimulationResult result, int tuft) + { + var lastFirstIndex = (tuft * result.SampleCount) + result.SampleCount - 1; + var firstSecondIndex = ((result.TuftCount + tuft) * result.SampleCount); + var first = result.Tips[lastFirstIndex]; + var second = result.Tips[firstSecondIndex]; + return Vector2.Distance(new Vector2(first.X, first.Y), new Vector2(second.X, second.Y)); + } + + private static float SamplePage(float[] page, int width, int height, Vector2 world, float viewRadius) + { + var uv = world / viewRadius * 0.5f + new Vector2(0.5f, 0.5f); + var x = Math.Clamp((int)MathF.Round(uv.X * (width - 1)), 0, width - 1); + var y = Math.Clamp((int)MathF.Round(uv.Y * (height - 1)), 0, height - 1); + return page[y * width + x]; + } + + private static float SampleDensity(uint[] page, int width, int height, Vector2 world, float viewRadius) + { + var uv = world / viewRadius * 0.5f + new Vector2(0.5f, 0.5f); + var x = Math.Clamp((int)MathF.Round(uv.X * (width - 1)), 0, width - 1); + var y = Math.Clamp((int)MathF.Round(uv.Y * (height - 1)), 0, height - 1); + return BokushoBrushSimulation.DecodePageDensity(page[y * width + x]); + } +} diff --git a/tests/Aquarium.Engine.Tests/D3D12AdvancedErosionParityTests.cs b/tests/Aquarium.Engine.Tests/D3D12AdvancedErosionParityTests.cs new file mode 100644 index 0000000..4cfde0f --- /dev/null +++ b/tests/Aquarium.Engine.Tests/D3D12AdvancedErosionParityTests.cs @@ -0,0 +1,80 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using Aquarium.Engine.Render; +using CultMath; +using GameCult.Geometry; + +namespace Aquarium.Engine.Tests; + +public sealed class D3D12AdvancedErosionParityTests +{ + [Fact] + public void AdvancedErosionMatchesGameCultGeometryCpuAcrossDeterministicCorpus() + { + if (!OperatingSystem.IsWindows()) return; + var parameters = AdvancedErosionParameters.Default; + var fixedCases = new[] + { + Case.Full(new float2(0.42f, 0.31f), new float3(0.5f, 0.1f, -0.2f), 0.0f, parameters), + Case.Full(new float2(1.7f, -3.2f), new float3(-0.2f, 0.0f, 0.0f), -0.7f, parameters with { Normalization = 0.0f, Octaves = 1 }), + Case.Full(new float2(50.5f, 77.25f), new float3(0.8f, -0.3f, 0.4f), 0.9f, parameters with { Normalization = 1.0f, Octaves = 8 }), + Case.Full(new float2(-8.125f, 3.75f), float3.zero, 0.0f, parameters with { Rounding = float4.zero }), + }; + var random = new System.Random(713); + var cases = fixedCases.Concat(Enumerable.Range(0, 2048).Select(index => + { + float Next(float min, float max) => min + (float)random.NextDouble() * (max - min); + var p = parameters with { Octaves = 1 + index % 8, Normalization = index % 3 * 0.5f }; + return new Case( + new float2(Next(-10, 10), Next(-10, 10)), + new float3(Next(-1, 1), Next(-0.8f, 0.8f), Next(-0.8f, 0.8f)), + Next(-1, 1), + p, + new ErosionBandSelection(p.Octaves, index % 2 == 0 ? 1.0f : 0.37f, 0.0f, 0.0f)); + })).ToArray(); + var gpuInputs = cases.Select(ParityInput.From).ToArray(); + var shader = Path.Combine(RepositoryRoot(), "src", "Aquarium.Engine", "Render", "Shaders", "D3D12AdvancedErosionParity.hlsl"); + var gpu = D3D12ComputeProbe.Run(shader, "D3D12AdvancedErosionParityCS", gpuInputs); + + for (var index = 0; index < cases.Length; index++) + { + var cpu = AdvancedErosionFilter.Sample(cases[index].Position, cases[index].Base, cases[index].Fade, cases[index].Parameters, cases[index].Band); + AssertClose(cpu.Delta.x, gpu[index].DeltaMagnitude.X, index, "height", 2.0e-5f); + AssertClose(cpu.Delta.y, gpu[index].DeltaMagnitude.Y, index, "dx", 2.0e-4f); + AssertClose(cpu.Delta.z, gpu[index].DeltaMagnitude.Z, index, "dy", 2.0e-4f); + AssertClose(cpu.Magnitude, gpu[index].DeltaMagnitude.W, index, "magnitude", 2.0e-6f); + AssertClose(cpu.RidgeMap, gpu[index].RidgeFade.X, index, "ridge", 5.0e-4f); + AssertClose(cpu.FadeTarget, gpu[index].RidgeFade.Y, index, "fade", 5.0e-4f); + } + } + + private static void AssertClose(float expected, float actual, int sample, string channel, float tolerance) + => Assert.True(MathF.Abs(expected - actual) <= tolerance, $"sample {sample} {channel}: CPU={expected:R} GPU={actual:R} delta={MathF.Abs(expected-actual):R}"); + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Fensalir.sln"))) directory = directory.Parent; + return directory?.FullName ?? throw new InvalidOperationException("Fensalir root not found."); + } + + private readonly record struct Case(float2 Position, float3 Base, float Fade, AdvancedErosionParameters Parameters, ErosionBandSelection Band) + { + public static Case Full(float2 position, float3 @base, float fade, AdvancedErosionParameters p) => new(position, @base, fade, p, new ErosionBandSelection(p.Octaves, 1.0f, 0.0f, 0.0f)); + } + + [StructLayout(LayoutKind.Sequential)] + private readonly record struct ParityInput(Vector4 PositionBase, Vector4 SlopeFade, Vector4 Scalar0, Vector4 Rounding, Vector4 Onset, Vector4 Scalar1, Vector4 Scalar2, Vector4 Scalar3) + { + public static ParityInput From(Case c) => new( + new(c.Position.x, c.Position.y, c.Base.x, 0), new(c.Base.y, c.Base.z, c.Fade, 0), + new(c.Parameters.Scale, c.Parameters.Strength, c.Parameters.GullyWeight, c.Parameters.Detail), + (Vector4)c.Parameters.Rounding, (Vector4)c.Parameters.Onset, + new(c.Parameters.AssumedSlope.x, c.Parameters.AssumedSlope.y, c.Parameters.CellScale, c.Parameters.Normalization), + new(c.Parameters.Octaves, c.Parameters.Lacunarity, c.Parameters.Gain, 0), + new(c.Band.ActiveOctaves, c.Band.FinalOctaveWeight, c.Band.FinestIncludedWavelength, c.Band.UnresolvedHeightBound)); + } + + [StructLayout(LayoutKind.Sequential)] + private readonly record struct ParityOutput(Vector4 DeltaMagnitude, Vector4 RidgeFade); +} diff --git a/tests/Aquarium.Engine.Tests/D3D12PlanetarySurfacePageTests.cs b/tests/Aquarium.Engine.Tests/D3D12PlanetarySurfacePageTests.cs new file mode 100644 index 0000000..a0ad962 --- /dev/null +++ b/tests/Aquarium.Engine.Tests/D3D12PlanetarySurfacePageTests.cs @@ -0,0 +1,364 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using Aquarium.Engine.Render; +using Aquarium.Zyphos; +using CultMath; +using GameCult.Geometry; + +namespace Aquarium.Engine.Tests; + +public sealed class D3D12PlanetarySurfacePageTests +{ + [Fact] + public void CpuSurfaceNormalUsesTheSameWorldDistanceGradientContract() + { + var direction=Vector3.Normalize(new Vector3(0.31f,-0.27f,0.91f)); + var tangentX=Vector3.Normalize(Vector3.Cross(Vector3.UnitY,direction)); + var tangentY=Vector3.Normalize(Vector3.Cross(direction,tangentX)); + var gradient=tangentX*0.35f+tangentY*-0.18f; + var normal=(Vector3)PlanetaryTopology.SurfaceNormal((float3)direction,(float3)gradient); + Assert.InRange(MathF.Abs(Vector3.Dot(normal,Vector3.Normalize(direction-gradient)))-1.0f,-1.0e-6f,1.0e-6f); + Assert.True(Vector3.Dot(normal,direction)>0.0f); + } + + [Fact] + public void ZyphosRegistersAndPublishesAllSdfAuthoritiesInIndexOrder() + { + var plan = ZyphosRenderPlan.Create(); + Assert.Equal( + new[] { "D3D12ZyphosPlanet.hlsl", "D3D12ZyphosUmbros.hlsl", "D3D12ZyphosStar.hlsl" }, + plan.Shaders.SdfShaderPaths.Select(Path.GetFileName)); + + var scene = ZyphosSceneBuilder.Build(1.0f, 0.5f, ZyphosUmbrosSystem.ZyphosCenter + Vector3.UnitZ * 12.0f, default); + Assert.Equal(ZyphosRenderPlan.SdfObjectCount, scene.SdfObjects.Count); + Assert.Equal(0.0f, scene.SdfObjects[ZyphosRenderPlan.PlanetIndex].CenterRadius.W); + Assert.Equal(ZyphosUmbrosSystem.ZyphosSurfaceRadius, scene.SdfObjects[ZyphosRenderPlan.PlanetIndex].State.X); + Assert.Equal(ZyphosUmbrosSystem.UmbrosBoundRadius, scene.SdfObjects[ZyphosRenderPlan.UmbrosIndex].CenterRadius.W); + Assert.Equal(ZyphosUmbrosSystem.PrimaryStarVisualRadius, scene.SdfObjects[ZyphosRenderPlan.StarIndex].CenterRadius.W); + } + + [Fact] + public void QuadtreeSelectionKeepsSixRootsAndAddsOneResidualAncestorChain() + { + ZyphosPlanetarySurfacePages.Reset(); + var center = ZyphosUmbrosSystem.ZyphosCenter; + var orbital = ZyphosSceneBuilder.Build(0.0f, 0.0f, center + Vector3.UnitZ * 1000.0f, default).PlanetarySurfacePages; + var near = ZyphosSceneBuilder.Build(2.0f, 1.0f, center + Vector3.Normalize(new Vector3(0.13f, 0.19f, 1.0f)) * (ZyphosUmbrosSystem.ZyphosSurfaceRadius + 0.05f), default).PlanetarySurfacePages; + + Assert.Equal(6, orbital.Pages.Count); + Assert.Equal(Enum.GetValues().Select(face => (float)face), orbital.Pages.Select(page => page.Metadata.Address.X)); + Assert.True(near.Pages.Count > 6); + Assert.Equal(Enumerable.Range(1, near.Pages.Count - 6).Select(level => (float)level), near.Pages.Skip(6).Select(page => page.Metadata.Address.Y)); + Assert.All(near.Pages.Skip(6), page => Assert.True(page.Samples[0].Sampling.Y > page.Samples[0].Sampling.X)); + } + + [Fact] + public void ZyphosUploadUsesCultMathGpuPageContractWithoutPrivateRecalculation() + { + ZyphosPlanetarySurfacePages.Reset(); + var pages = ZyphosPlanetarySurfacePages.ForCamera( + ZyphosUmbrosSystem.ZyphosCenter + Vector3.UnitZ * 1000.0f, + 0.0f); + var actual = pages.Pages[0]; + var tile = new PlanetaryTileAddress((PlanetaryCubeFace)(int)actual.Metadata.Address.X, 0, 0, 0); + var layout = new PlanetaryPageLayout(tile, 17, 2); + var common = PlanetaryGpuPageBuilder.BuildContent(layout, ZyphosUmbrosSystem.ZyphosSurfaceRadius); + var metadata = PlanetaryGpuPageBuilder.Metadata(common, 0, 1.0f); + + Assert.Equal((Vector4)metadata.Address, actual.Metadata.Address); + Assert.Equal((Vector4)metadata.Layout, actual.Metadata.Layout); + Assert.Equal((Vector4)metadata.State, actual.Metadata.State); + Assert.Equal((Vector4)common.Inputs[0].DirectionRadius, actual.Samples[0].DirectionRadius); + Assert.Equal((Vector4)common.Inputs[0].Sampling, actual.Samples[0].Sampling); + } + + [Fact] + public void ChildPageStoresOnlyTheNewlyResolvableResidualBand() + { + const float radius=8.4f; + var parent=new PlanetaryPageLayout(new PlanetaryTileAddress(PlanetaryCubeFace.PositiveZ,2,2,1),9,2); + var child=new PlanetaryPageLayout(parent.Tile.Child(1,0),9,2); + var direction=PlanetaryPageSampling.DirectionAtLocal(child,0.43,0.57); + var childSpacing=PlanetaryPageSampling.NominalAngularTexelSize(child)*radius; + var parentSpacing=PlanetaryPageSampling.NominalAngularTexelSize(parent)*radius; + var input=new[] + { + new PageInput(new Vector4((Vector3)direction,radius),new Vector4(childSpacing,0,0,0)), + new PageInput(new Vector4((Vector3)direction,radius),new Vector4(parentSpacing,0,0,0)), + new PageInput(new Vector4((Vector3)direction,radius),new Vector4(childSpacing,parentSpacing,0,0)), + }; + var output=D3D12ComputeProbe.Run(ShaderPath(),"D3D12ZyphosTerrainPageCS",input); + Assert.InRange(MathF.Abs(output[0].HeightGradient.X-(output[1].HeightGradient.X+output[2].HeightGradient.X)),0,2.0e-5f); + Assert.InRange(MathF.Abs(output[0].HeightGradient.Y-(output[1].HeightGradient.Y+output[2].HeightGradient.Y)),0,2.0e-4f); + Assert.InRange(MathF.Abs(output[0].HeightGradient.Z-(output[1].HeightGradient.Z+output[2].HeightGradient.Z)),0,2.0e-4f); + Assert.InRange(MathF.Abs(output[0].HeightGradient.W-(output[1].HeightGradient.W+output[2].HeightGradient.W)),0,2.0e-4f); + } + + [Fact] + public void ResidualArrivalChangesPresentationWithoutRegeneratingContent() + { + ZyphosPlanetarySurfacePages.Reset(); + var center=ZyphosUmbrosSystem.ZyphosCenter; + var direction=Vector3.Normalize(new Vector3(-0.17f,-1.0f,0.23f)); + var camera=center+direction*(ZyphosUmbrosSystem.ZyphosSurfaceRadius+0.03f); + var start=ZyphosSceneBuilder.Build(100.0f,99.9f,camera,default).PlanetarySurfacePages; + var middle=ZyphosSceneBuilder.Build(100.175f,100.0f,camera,default).PlanetarySurfacePages; + var settled=ZyphosSceneBuilder.Build(100.35f,100.175f,camera,default).PlanetarySurfacePages; + Assert.Equal(start.ContentVersion,middle.ContentVersion); + Assert.Equal(middle.ContentVersion,settled.ContentVersion); + Assert.NotEqual(start.PresentationVersion,middle.PresentationVersion); + Assert.All(start.Pages.Skip(6),page=>Assert.Equal(0.0f,page.Metadata.State.Y)); + Assert.All(middle.Pages.Skip(6),page=>Assert.InRange(page.Metadata.State.Y,0.49f,0.51f)); + Assert.All(settled.Pages.Skip(6),page=>Assert.InRange(page.Metadata.State.Y,0.999f,1.0f)); + } + + [Fact] + public void TeleportCrossFadesOldAndNewResidualChainsBeforeEviction() + { + ZyphosPlanetarySurfacePages.Reset(); + var center=ZyphosUmbrosSystem.ZyphosCenter; + var radius=ZyphosUmbrosSystem.ZyphosSurfaceRadius+0.03f; + var cameraA=center+Vector3.Normalize(new Vector3(1.0f,0.12f,0.18f))*radius; + var cameraB=center+Vector3.Normalize(new Vector3(-0.21f,1.0f,-0.14f))*radius; + _=ZyphosSceneBuilder.Build(200.0f,199.9f,cameraA,default); + var settledA=ZyphosSceneBuilder.Build(200.35f,200.0f,cameraA,default).PlanetarySurfacePages; + var oldKeys=settledA.Pages.Skip(6).Select(page=>page.ContentKey).ToHashSet(); + var departure=ZyphosSceneBuilder.Build(201.0f,200.35f,cameraB,default).PlanetarySurfacePages; + var newKeys=departure.Pages.Skip(6).Select(page=>page.ContentKey).Where(key=>!oldKeys.Contains(key)).ToHashSet(); + Assert.NotEmpty(newKeys); + Assert.All(departure.Pages.Where(page=>oldKeys.Contains(page.ContentKey)),page=>Assert.InRange(page.Metadata.State.Y,0.999f,1.0f)); + Assert.All(departure.Pages.Where(page=>newKeys.Contains(page.ContentKey)),page=>Assert.Equal(0.0f,page.Metadata.State.Y)); + + var midpoint=ZyphosSceneBuilder.Build(201.175f,201.0f,cameraB,default).PlanetarySurfacePages; + Assert.All(midpoint.Pages.Where(page=>oldKeys.Contains(page.ContentKey)||newKeys.Contains(page.ContentKey)),page=>Assert.InRange(page.Metadata.State.Y,0.49f,0.51f)); + var settledB=ZyphosSceneBuilder.Build(201.36f,201.175f,cameraB,default).PlanetarySurfacePages; + Assert.DoesNotContain(settledB.Pages,page=>oldKeys.Contains(page.ContentKey)); + Assert.All(settledB.Pages.Where(page=>newKeys.Contains(page.ContentKey)),page=>Assert.InRange(page.Metadata.State.Y,0.999f,1.0f)); + } + + [Fact] + public void ProjectedErrorSelectionAddsLevelsMonotonicallyDuringDescent() + { + ZyphosPlanetarySurfacePages.Reset(); + var center=ZyphosUmbrosSystem.ZyphosCenter; + var direction=Vector3.Normalize(new Vector3(0.11f,0.17f,1.0f)); + var distances=new[]{1000.0f,100.0f,20.0f,ZyphosUmbrosSystem.ZyphosSurfaceRadius+0.05f}; + var levels=new List(); + for(var index=0;indexpage.Metadata.Address.X==(float)PlanetaryCubeFace.PositiveZ).Max(page=>page.Metadata.Address.Y)); + } + Assert.Equal(levels.Order(),levels); + Assert.True(levels[^1]>levels[0]); + } + + [Fact] + public void ReloadReconstructsTheSameCutAndFadesResidualsFromItsParent() + { + ZyphosPlanetarySurfacePages.Reset(); + var center=ZyphosUmbrosSystem.ZyphosCenter; + var camera=center+Vector3.Normalize(new Vector3(0.61f,-0.22f,0.76f))*(ZyphosUmbrosSystem.ZyphosSurfaceRadius+0.04f); + var before=ZyphosSceneBuilder.Build(401.0f,400.0f,camera,default).PlanetarySurfacePages; + var reloaded=ZyphosSceneBuilder.Build(0.0f,0.0f,camera,default).PlanetarySurfacePages; + Assert.Equal(before.Pages.Select(page=>page.ContentKey),reloaded.Pages.Select(page=>page.ContentKey)); + Assert.Equal(before.ContentVersion,reloaded.ContentVersion); + Assert.All(reloaded.Pages.Take(6),page=>Assert.Equal(1.0f,page.Metadata.State.Y)); + Assert.All(reloaded.Pages.Skip(6),page=>Assert.Equal(0.0f,page.Metadata.State.Y)); + } + + [Fact] + public void CapturePrimingProducesAnExactResidualArrivalMidpoint() + { + var camera=ZyphosUmbrosSystem.ZyphosCenter+Vector3.Normalize(new Vector3(0.2f,-0.4f,0.9f))*4.1f; + try + { + ZyphosPlanetarySurfacePages.PrimeForCapture(camera,0.175f); + var pages=ZyphosPlanetarySurfacePages.ForCamera(camera,0.0f); + Assert.All(pages.Pages.Take(6),page=>Assert.Equal(1.0f,page.Metadata.State.Y)); + Assert.All(pages.Pages.Skip(6),page=>Assert.Equal(0.5f,page.Metadata.State.Y,3)); + } + finally + { + ZyphosPlanetarySurfacePages.Reset(); + } + } + + [Fact] + public void CapturePrimingProducesAnExactCrossFaceEvictionMidpoint() + { + var camera=ZyphosUmbrosSystem.ZyphosCenter+Vector3.Normalize(new Vector3(0.1f,-0.3f,1.0f))*4.1f; + try + { + ZyphosPlanetarySurfacePages.PrimeEvictionForCapture(camera,0.175f); + var pages=ZyphosPlanetarySurfacePages.ForCamera(camera,0.0f); + var residuals=pages.Pages.Skip(6).ToArray(); + Assert.NotEmpty(residuals); + Assert.Contains(residuals,page=>MathF.Abs(page.Metadata.State.Y-0.5f)<0.001f); + Assert.All(residuals,page=>Assert.InRange(page.Metadata.State.Y,0.5f,1.0f)); + Assert.Contains(residuals,page=>page.Metadata.Address.X==(float)PlanetaryCubeFace.PositiveX); + Assert.Contains(residuals,page=>page.Metadata.Address.X==(float)PlanetaryCubeFace.PositiveZ); + } + finally + { + ZyphosPlanetarySurfacePages.Reset(); + } + } + + [Fact] + public void ZyphosPlanetaryProxyShadersCompileWithPageBindings() + { + var temporaryRoot = Path.Combine(Path.GetTempPath(), $"aquarium-zyphos-shaders-{Guid.NewGuid():N}"); + Directory.CreateDirectory(temporaryRoot); + try + { + CopyShaderTree(Path.Combine(RepositoryRoot(), "src", "Aquarium.Engine", "Render", "Shaders"), temporaryRoot); + CopyShaderTree(Path.Combine(RepositoryRoot(), "src", "Aquarium.Zyphos", "Shaders"), temporaryRoot); + CopyShaderTree(Path.GetFullPath(Path.Combine(RepositoryRoot(), "..", "CultMath", "shaders")), Path.Combine(temporaryRoot, "CultMath")); + CopyShaderTree(Path.GetFullPath(Path.Combine(RepositoryRoot(), "..", "CultLib", "src", "GameCult.Geometry", "Shaders")), Path.Combine(temporaryRoot, "GameCult.Geometry")); + foreach (var shaderName in new[] { "D3D12ZyphosPlanet.hlsl", "D3D12ZyphosUmbros.hlsl", "D3D12ZyphosStar.hlsl" }) + { + var bytecode = D3D12ShaderCompiler.Compile(Path.Combine(temporaryRoot, shaderName), "D3D12SdfProxyPS", "ps_5_0"); + Assert.False(bytecode.IsEmpty); + } + var patchPath = Path.Combine(temporaryRoot, "D3D12ZyphosTerrainPatch.hlsl"); + Assert.False(D3D12ShaderCompiler.Compile(patchPath, "D3D12ZyphosTerrainPatchVS", "vs_5_0").IsEmpty); + Assert.False(D3D12ShaderCompiler.Compile(patchPath, "D3D12ZyphosTerrainPatchPS", "ps_5_0").IsEmpty); + } + finally + { + Directory.Delete(temporaryRoot, recursive: true); + } + } + + [Fact] + public void IndependentlyGeneratedSiblingPagesAgreeOnSharedBoundary() + { + var left = new PlanetaryPageLayout(new PlanetaryTileAddress(PlanetaryCubeFace.PositiveZ,1,0,0),9,2); + var right = new PlanetaryPageLayout(new PlanetaryTileAddress(PlanetaryCubeFace.PositiveZ,1,1,0),9,2); + var leftOutput = Generate(left); var rightOutput = Generate(right); + for(int y=0;y>(); + var requests=Enum.GetValues().Select(face=>new PlanetaryPageLayout(new PlanetaryTileAddress(face,0,0,0),interior,border)).ToArray(); + const float radius=8.4f; + var inputs=requests.SelectMany(request=> + { + var spacing=PlanetaryPageSampling.NominalAngularTexelSize(request)*radius; + return Enumerable.Range(0,request.StorageSize*request.StorageSize).Select(index=> + { + var x=index%request.StorageSize; var y=index/request.StorageSize; + return new PageInput(new Vector4((Vector3)PlanetaryPageSampling.Direction(request,x,y),radius),new Vector4(spacing,0,0,0)); + }); + }).ToArray(); + var generated=D3D12ComputeProbe.Run(ShaderPath(),"D3D12ZyphosTerrainPageCS",inputs); + var outputOffset=0; + foreach(var request in requests) + { + for(var y=0;ysamples.Count==2).ToArray(); + var corners=groups.Values.Where(samples=>samples.Count==3).ToArray(); + Assert.Equal(12*(interior-2),edges.Length); + Assert.Equal(8,corners.Length); + foreach(var samples in edges.Concat(corners)) + { + var expected=samples[0]; + Assert.All(samples.Skip(1),actual=>AssertPageOutputClose(expected,actual,1.0e-5f)); + } + } + + [Fact] + public void GeneratedPagePublishesFiniteChannelsAndSummaryEvidence() + { + var request=new PlanetaryPageLayout(new PlanetaryTileAddress(PlanetaryCubeFace.PositiveX,2,1,2),9,2); + var output=Generate(request); + Assert.All(output,s=>{Assert.True(float.IsFinite(s.HeightGradient.X));Assert.True(float.IsFinite(s.HeightGradient.Y));Assert.True(float.IsFinite(s.HeightGradient.Z));Assert.True(float.IsFinite(s.HeightGradient.W));Assert.InRange(s.Masks.X,0,1);Assert.InRange(s.Masks.Y,0,1);}); + var min=output.Min(s=>s.HeightGradient.X); var max=output.Max(s=>s.HeightGradient.X); var slope=output.Max(s=>new Vector3(s.HeightGradient.Y,s.HeightGradient.Z,s.HeightGradient.W).Length()); + Assert.True(min<=max); Assert.True(slope>=0); Assert.True(output.Max(s=>s.Masks.W)>=0); + var shader=Path.Combine(RepositoryRoot(),"src","Aquarium.Zyphos","Shaders","D3D12ZyphosTerrainPageSummary.hlsl"); + var summary=D3D12ComputeProbe.Run(shader,"D3D12ZyphosTerrainPageSummaryCS",output)[0]; + Assert.Equal(min,summary.Bounds.X); Assert.Equal(max,summary.Bounds.Y); Assert.Equal(slope,summary.Bounds.Z); Assert.Equal(output.Max(s=>s.Masks.W),summary.Bounds.W); + } + + [Fact] + public void PageBackedRadialHitsAgreeWithDirectOracleWithinDeclaredError() + { + var request=new PlanetaryPageLayout(new PlanetaryTileAddress(PlanetaryCubeFace.PositiveZ,1,1,0),9,2); + var page=Generate(request); const float radius=8.4f,rayOriginRadius=12.0f; + var spacing=PlanetaryPageSampling.NominalAngularTexelSize(request)*radius; + var points=new[]{new Vector2(0.08f,0.11f),new Vector2(0.37f,0.61f),new Vector2(0.52f,0.48f),new Vector2(0.79f,0.22f),new Vector2(0.91f,0.88f)}; + var directInput=points.Select(point=>new PageInput(new Vector4((Vector3)PlanetaryPageSampling.DirectionAtLocal(request,point.X,point.Y),radius),new Vector4(spacing,0,0,0))).ToArray(); + var direct=D3D12ComputeProbe.Run(ShaderPath(),"D3D12ZyphosTerrainPageCS",directInput); + float H(int px,int py)=>page[py*request.StorageSize+px].HeightGradient.X; + float Lerp(float a,float b,float t)=>a+(b-a)*t; + var maximumSlope=page.Max(s=>new Vector3(s.HeightGradient.Y,s.HeightGradient.Z,s.HeightGradient.W).Length()); + for(var index=0;index(shader,"D3D12ZyphosTerrainPageCS",input); + } + + private static void AssertPageOutputClose(PageOutput expected,PageOutput actual,float tolerance) + { + static void Close(Vector4 a,Vector4 b,float epsilon) + { + Assert.InRange(MathF.Abs(a.X-b.X),0,epsilon); Assert.InRange(MathF.Abs(a.Y-b.Y),0,epsilon); + Assert.InRange(MathF.Abs(a.Z-b.Z),0,epsilon); Assert.InRange(MathF.Abs(a.W-b.W),0,epsilon); + } + Close(expected.HeightGradient,actual.HeightGradient,tolerance); + Close(expected.Masks,actual.Masks,tolerance); + } + + private static string ShaderPath()=>Path.Combine(RepositoryRoot(),"src","Aquarium.Zyphos","Shaders","D3D12ZyphosTerrainPage.hlsl"); + + private static void CopyShaderTree(string source, string destination) + { + foreach (var file in Directory.EnumerateFiles(source, "*.*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(source, file); + var target = Path.Combine(destination, relative); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target, overwrite: true); + } + } + + private static string RepositoryRoot(){var d=new DirectoryInfo(AppContext.BaseDirectory);while(d is not null&&!File.Exists(Path.Combine(d.FullName,"Fensalir.sln")))d=d.Parent;return d?.FullName??throw new InvalidOperationException();} + [StructLayout(LayoutKind.Sequential)] private readonly record struct PageInput(Vector4 DirectionRadius,Vector4 Sampling); + [StructLayout(LayoutKind.Sequential)] private readonly record struct PageOutput(Vector4 HeightGradient,Vector4 Masks); + [StructLayout(LayoutKind.Sequential)] private readonly record struct PageSummary(Vector4 Bounds,Vector4 Metadata); +} diff --git a/tests/Aquarium.Engine.Tests/D3D12PlanetaryTopologyParityTests.cs b/tests/Aquarium.Engine.Tests/D3D12PlanetaryTopologyParityTests.cs new file mode 100644 index 0000000..38c270c --- /dev/null +++ b/tests/Aquarium.Engine.Tests/D3D12PlanetaryTopologyParityTests.cs @@ -0,0 +1,160 @@ +using System.Numerics; +using Aquarium.Engine.Render; +using CultMath; +using GameCult.Geometry; + +namespace Aquarium.Engine.Tests; + +public sealed class D3D12PlanetaryTopologyParityTests +{ + [Fact] + public void QscFaceDirectionsAndInverseCoordinatesMatchGameCultGeometryCpu() + { + var cases = new List(); + foreach (var face in Enum.GetValues()) + foreach (var u in new[] { -0.9f, -0.25f, 0.0f, 0.43f, 0.9f }) + foreach (var v in new[] { -0.85f, -0.1f, 0.31f, 0.88f }) + { + cases.Add(new(new Vector4(0, 0, 0, (int)face), new Vector4(u, v, 0, 0))); + var direction = (Vector3)PlanetaryTopology.Direction(new(face, u, v)); + cases.Add(new(new Vector4(direction, 0), new Vector4(0, 0, 1, 0))); + } + + var output = D3D12ComputeProbe.Run(ShaderPath(), "D3D12PlanetaryTopologyParityCS", cases.ToArray()); + for (var i = 0; i < cases.Count; i += 2) + { + var directionCase = cases[i]; + var face = (PlanetaryCubeFace)(int)directionCase.DirectionFace.W; + var expectedDirection = (Vector3)PlanetaryTopology.Direction(new(face, directionCase.CoordinateMode.X, directionCase.CoordinateMode.Y)); + Assert.InRange(Vector3.Distance(expectedDirection, new(output[i].DirectionFace.X, output[i].DirectionFace.Y, output[i].DirectionFace.Z)), 0, 2.0e-6f); + Assert.Equal((float)face, output[i].DirectionFace.W); + + var expectedCoordinate = PlanetaryTopology.FaceCoordinate((float3)new Vector3( + cases[i + 1].DirectionFace.X, cases[i + 1].DirectionFace.Y, cases[i + 1].DirectionFace.Z)); + Assert.Equal((float)expectedCoordinate.Face, output[i + 1].DirectionFace.W); + // atan is a hardware transcendental; identity is bounded rather + // than bit exact across CPU and GPU implementations. + Assert.InRange(MathF.Abs((float)expectedCoordinate.U - output[i + 1].CoordinateValid.X), 0, 2.0e-5f); + Assert.InRange(MathF.Abs((float)expectedCoordinate.V - output[i + 1].CoordinateValid.Y), 0, 2.0e-5f); + } + } + + [Theory] + [InlineData(PlanetaryProjectionKind.Equirectangular, 2, 3)] + [InlineData(PlanetaryProjectionKind.EqualEarth, 4, 5)] + public void MapProjectionForwardAndInverseMatchGameCultGeometryCpu(PlanetaryProjectionKind kind, int forwardMode, int inverseMode) + { + var projection = new PlanetaryProjectionParameters(kind); + var inputs = new List(); + var expectedDirections = new List(); + foreach (var longitude in new[] { -2.4, -0.7, 0.0, 0.9, 2.5 }) + foreach (var latitude in new[] { -1.1, -0.35, 0.2, 0.95 }) + { + var cos = Math.Cos(latitude); + var direction = new Vector3((float)(cos * Math.Cos(longitude)), (float)(cos * Math.Sin(longitude)), (float)Math.Sin(latitude)); + Assert.True(PlanetaryProjection.TryForward((float3)direction, projection, out var coordinate)); + inputs.Add(new(new Vector4(direction, 0), new Vector4(0, 0, forwardMode, 0))); + inputs.Add(new(Vector4.Zero, new Vector4((float)coordinate.x, (float)coordinate.y, inverseMode, 0))); + expectedDirections.Add(direction); + } + var output = D3D12ComputeProbe.Run(ShaderPath(), "D3D12PlanetaryTopologyParityCS", inputs.ToArray()); + for (var sample = 0; sample < expectedDirections.Count; sample++) + { + var forward = output[sample * 2].CoordinateValid; + Assert.True(PlanetaryProjection.TryForward((float3)expectedDirections[sample], projection, out var expectedCoordinate)); + // The legacy SM5 implementation uses float transcendental + // approximations while the CPU projection uses double precision. + // The measured normalized-map bound stays below 0.0002. + Assert.InRange(Math.Abs(expectedCoordinate.x - forward.X), 0, 2.0e-4); + Assert.InRange(Math.Abs(expectedCoordinate.y - forward.Y), 0, 2.0e-4); + var inverse = output[sample * 2 + 1].DirectionFace; + Assert.InRange(Vector3.Distance(expectedDirections[sample], new(inverse.X, inverse.Y, inverse.Z)), 0, 3.0e-4f); + } + } + + [Theory] + [InlineData(PlanetaryProjectionKind.Equirectangular)] + [InlineData(PlanetaryProjectionKind.WebMercator)] + [InlineData(PlanetaryProjectionKind.EqualEarth)] + [InlineData(PlanetaryProjectionKind.Orthographic)] + [InlineData(PlanetaryProjectionKind.AzimuthalEquidistant)] + [InlineData(PlanetaryProjectionKind.AzimuthalEqualArea)] + [InlineData(PlanetaryProjectionKind.CubeAtlas)] + [InlineData(PlanetaryProjectionKind.LocalTangent)] + public void PortableProjectionDispatcherMatchesEveryCpuProjection(PlanetaryProjectionKind kind) + { + var projection = new PlanetaryProjectionParameters(kind); + var inputs = new List(); + var expected = new List<(Vector3 Direction, Vector2 Coordinate)>(); + foreach (var longitude in new[] { -1.2, -0.3, 0.2, 0.9 }) + foreach (var latitude in new[] { -0.7, -0.1, 0.45 }) + { + var cos = Math.Cos(latitude); + var direction = new Vector3((float)(cos * Math.Cos(longitude)), (float)(cos * Math.Sin(longitude)), (float)Math.Sin(latitude)); + if (!PlanetaryProjection.TryForward((float3)direction, projection, out var coordinate)) continue; + var map = new Vector2((float)coordinate.x, (float)coordinate.y); + inputs.Add(new(new Vector4(direction, 0), new Vector4(0, 0, 6, (int)kind))); + inputs.Add(new(Vector4.Zero, new Vector4(map, 7, (int)kind))); + expected.Add((direction, map)); + } + var output = D3D12ComputeProbe.Run(ShaderPath(), "D3D12PlanetaryTopologyParityCS", inputs.ToArray()); + for (var i = 0; i < expected.Count; i++) + { + Assert.Equal(1, output[i * 2].CoordinateValid.W); + Assert.InRange(Vector2.Distance(expected[i].Coordinate, new(output[i * 2].CoordinateValid.X, output[i * 2].CoordinateValid.Y)), 0, 3.0e-4f); + Assert.Equal(1, output[i * 2 + 1].CoordinateValid.W); + var inverse = output[i * 2 + 1].DirectionFace; + Assert.InRange(Vector3.Distance(expected[i].Direction, new(inverse.X, inverse.Y, inverse.Z)), 0, 5.0e-4f); + } + } + + [Theory] + [InlineData(PlanetaryProjectionKind.Equirectangular)] + [InlineData(PlanetaryProjectionKind.WebMercator)] + [InlineData(PlanetaryProjectionKind.EqualEarth)] + [InlineData(PlanetaryProjectionKind.Orthographic)] + [InlineData(PlanetaryProjectionKind.AzimuthalEquidistant)] + [InlineData(PlanetaryProjectionKind.AzimuthalEqualArea)] + [InlineData(PlanetaryProjectionKind.CubeAtlas)] + [InlineData(PlanetaryProjectionKind.LocalTangent)] + public void ProjectionDispatcherHonorsCenterAndScale(PlanetaryProjectionKind kind) + { + var projection = new PlanetaryProjectionParameters(kind, 0.31, -0.22, 1.4); + var parameters = new Vector4((float)projection.CenterLongitude, (float)projection.CenterLatitude, (float)projection.Scale, 0); + var direction = Vector3.Normalize(new Vector3(0.91f, 0.23f, 0.34f)); + if (!PlanetaryProjection.TryForward((float3)direction, projection, out var coordinate)) return; + var map = new Vector2((float)coordinate.x, (float)coordinate.y); + var inputs = new[] + { + new TopologyInput(new Vector4(direction,0),new Vector4(0,0,6,(int)kind),parameters), + new TopologyInput(Vector4.Zero,new Vector4(map,7,(int)kind),parameters), + }; + var output = D3D12ComputeProbe.Run(ShaderPath(), "D3D12PlanetaryTopologyParityCS", inputs); + Assert.Equal(1, output[0].CoordinateValid.W); + Assert.InRange(Vector2.Distance(map, new(output[0].CoordinateValid.X, output[0].CoordinateValid.Y)), 0, 4.0e-4f); + Assert.Equal(1, output[1].CoordinateValid.W); + Assert.InRange(Vector3.Distance(direction, new(output[1].DirectionFace.X, output[1].DirectionFace.Y, output[1].DirectionFace.Z)), 0, 6.0e-4f); + } + + private static string ShaderPath() => Path.Combine(RepositoryRoot(), "src", "Aquarium.Engine", "Render", "Shaders", "D3D12PlanetaryTopologyParity.hlsl"); + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "Fensalir.sln"))) return directory.FullName; + directory = directory.Parent; + } + throw new InvalidOperationException("Could not locate Fensalir repository root."); + } + + private readonly record struct TopologyInput(Vector4 DirectionFace, Vector4 CoordinateMode, Vector4 ProjectionParameters) + { + public TopologyInput(Vector4 directionFace, Vector4 coordinateMode) + : this(directionFace, coordinateMode, new Vector4(0, 0, 1, 0)) + { + } + } + private readonly record struct TopologyOutput(Vector4 DirectionFace, Vector4 CoordinateValid); +} diff --git a/tests/Aquarium.Engine.Tests/D3D12ResizeTests.cs b/tests/Aquarium.Engine.Tests/D3D12ResizeTests.cs new file mode 100644 index 0000000..cda6947 --- /dev/null +++ b/tests/Aquarium.Engine.Tests/D3D12ResizeTests.cs @@ -0,0 +1,45 @@ +namespace Aquarium.Engine.Tests; + +public sealed class D3D12ResizeTests +{ + [Fact] + public void ResizeReleasesOverlayAndProgramOutputReferencesBeforeResizeBuffers() + { + var repoRoot = FindRepoRoot(); + var renderer = File.ReadAllText(Path.Combine(repoRoot, "src", "Aquarium.Engine", "Render", "D3D12Renderer.cs")); + + var disposeOverlayIndex = renderer.IndexOf("private void DisposeBackBufferOverlays()", StringComparison.Ordinal); + Assert.True(disposeOverlayIndex >= 0); + var disposeOverlayEndIndex = renderer.IndexOf("private void ResizeIfNeeded", disposeOverlayIndex, StringComparison.Ordinal); + Assert.True(disposeOverlayEndIndex > disposeOverlayIndex); + var disposeOverlayBody = renderer[disposeOverlayIndex..disposeOverlayEndIndex]; + + Assert.Contains("overlayContext.ClearState();", disposeOverlayBody); + Assert.Contains("overlayContext.Flush();", disposeOverlayBody); + + var resizeIndex = renderer.IndexOf("private void ResizeIfNeeded", StringComparison.Ordinal); + Assert.True(resizeIndex >= 0); + var resizeBuffersIndex = renderer.IndexOf("swapChain.ResizeBuffers", resizeIndex, StringComparison.Ordinal); + Assert.True(resizeBuffersIndex > resizeIndex); + var preResizeBuffersBody = renderer[resizeIndex..resizeBuffersIndex]; + + Assert.Contains("DisposeBackBufferOverlays();", preResizeBuffersBody); + Assert.Contains("DisposeProgramOutputTexture();", preResizeBuffersBody); + } + + private static string FindRepoRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "Fensalir.sln"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not locate Fensalir.sln from test output directory."); + } +} diff --git a/tests/Aquarium.Engine.Tests/FieldEvidenceContractTests.cs b/tests/Aquarium.Engine.Tests/FieldEvidenceContractTests.cs index ff171fd..00f9693 100644 --- a/tests/Aquarium.Engine.Tests/FieldEvidenceContractTests.cs +++ b/tests/Aquarium.Engine.Tests/FieldEvidenceContractTests.cs @@ -544,7 +544,7 @@ public sealed class FieldEvidenceContractTests } [Fact] - public void LoweringPlannerDefersAmbiguousFeatureEvidence() + public void LoweringPlannerSelectsCameraSensorFeatureEvidenceForGpuSensorFusion() { var support = new AquariumFieldSupport( Vector3.Zero, @@ -603,10 +603,11 @@ public sealed class FieldEvidenceContractTests var plan = AquariumFieldLoweringPlanner.Plan(frame); - Assert.False(plan.HasPackets); - Assert.Empty(plan.Packets); - Assert.Single(plan.DeferredRequests); - Assert.Equal("claim:feature", plan.DeferredRequests[0].ClaimKey); + Assert.True(plan.HasPackets); + Assert.Empty(plan.DeferredRequests); + Assert.Single(plan.Packets); + Assert.Equal("claim:feature", plan.Packets[0].ClaimKey); + Assert.Equal(AquariumFieldBackendKind.GpuSensorFusion, plan.Packets[0].Backend); } [Fact] diff --git a/tests/Aquarium.Engine.Tests/FieldReservoirAbiTests.cs b/tests/Aquarium.Engine.Tests/FieldReservoirAbiTests.cs index 0c875ce..a105a95 100644 --- a/tests/Aquarium.Engine.Tests/FieldReservoirAbiTests.cs +++ b/tests/Aquarium.Engine.Tests/FieldReservoirAbiTests.cs @@ -109,7 +109,7 @@ public sealed class FieldReservoirAbiTests var renderer = File.ReadAllText(Path.Combine(repoRoot, "src", "Aquarium.Engine", "Render", "D3D12Renderer.cs")); var post = File.ReadAllText(Path.Combine(repoRoot, "src", "Aquarium.Engine", "Render", "Shaders", "D3D12Post.hlsl")); - Assert.Contains("MaxRenderDebugMode = 20", settings); + Assert.Contains("MaxRenderDebugMode = 28", settings); Assert.Contains("Reservoir Disocclusion", renderer); Assert.Contains("previousUvOut", post); Assert.Contains("previousCloser", post); @@ -125,7 +125,7 @@ public sealed class FieldReservoirAbiTests var post = File.ReadAllText(Path.Combine(repoRoot, "src", "Aquarium.Engine", "Render", "Shaders", "D3D12Post.hlsl")); var sequenceCapture = File.ReadAllText(Path.Combine(repoRoot, "scripts", "capture-reservoir-mode-sequence.ps1")); - Assert.Contains("MaxRenderDebugMode = 20", settings); + Assert.Contains("MaxRenderDebugMode = 28", settings); Assert.Contains("Reservoir Spatial Budget", renderer); Assert.Contains("fieldReservoirShouldSpendSpatialReuse(pixel)", post); Assert.Contains("spatial-budget", sequenceCapture); diff --git a/tests/Aquarium.Engine.Tests/GpuSensorFrameContractTests.cs b/tests/Aquarium.Engine.Tests/GpuSensorFrameContractTests.cs index 6da7640..2760c83 100644 --- a/tests/Aquarium.Engine.Tests/GpuSensorFrameContractTests.cs +++ b/tests/Aquarium.Engine.Tests/GpuSensorFrameContractTests.cs @@ -46,6 +46,7 @@ public sealed class GpuSensorFrameContractTests Assert.True(scene.GpuSensorFrame.HasInput); Assert.Equal("kiyo-pro-left", scene.GpuSensorFrame.Cameras[0].SensorId); Assert.Equal(AquariumGpuSensorPixelFormat.Bgra8Unorm, scene.GpuSensorFrame.ExternalTextures[0].PixelFormat); + Assert.Equal(AquariumGpuSensorPixelFormat.Unknown, scene.GpuSensorFrame.Cameras[0].PixelFormat); Assert.Equal("Mimir/KiyoProLeft/Bgra", scene.GpuSensorFrame.ExternalTextures[0].SharedHandleName); Assert.Equal(18.0f, scene.GpuSensorFrame.AccumulationWindowSeconds, 6); } diff --git a/tests/Aquarium.Engine.Tests/SynthPlaybackTests.cs b/tests/Aquarium.Engine.Tests/SynthPlaybackTests.cs index 6d5bef6..a99cf3a 100644 --- a/tests/Aquarium.Engine.Tests/SynthPlaybackTests.cs +++ b/tests/Aquarium.Engine.Tests/SynthPlaybackTests.cs @@ -59,6 +59,26 @@ public sealed class SynthPlaybackTests Assert.Empty(document.DrainStreamingAudioBlocks()); } + [Fact] + public void AquariumAudioDocumentDrainsLoopbackCaptureRequests() + { + var document = new AquariumAudioDocument(); + document.EnqueueSystemLoopbackCapture( + "perlines:system-loopback", + "windows-default-render", + "System mix", + enabled: true, + sequence: 7); + + var drained = document.DrainCaptureRequests(); + + Assert.Single(drained); + Assert.Equal(AquariumAudioCaptureKind.SystemLoopback, drained[0].Kind); + Assert.Equal("perlines:system-loopback", drained[0].ProfileId); + Assert.True(drained[0].Enabled); + Assert.Empty(document.DrainCaptureRequests()); + } + [Fact] public void StreamingDspHostProcessesControlDrivenInputBlocksWhenToolchainIsAvailable() {