diff --git a/.gitignore b/.gitignore index 2e7fdf6c..2fdb5151 100644 --- a/.gitignore +++ b/.gitignore @@ -209,6 +209,14 @@ PublishScripts/ !/packages/gamecult-geometry-rs/ !/packages/gamecult-geometry-rs/** packages/gamecult-geometry-rs/target/ +.tools/ +!/packages/gamecult-geometry-ts/ +!/packages/gamecult-geometry-ts/** +!/unity/org.gamecult.geometry/Samples~/ +!/unity/org.gamecult.geometry/Samples~/** +!/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs.meta +!/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs.meta +!/src/GameCult.Geometry/Shaders/*.hlsl.meta # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ # Uncomment if necessary however generally it will be regenerated when needed @@ -244,6 +252,8 @@ _pkginfo.txt ClientBin/ ~$* *~ +!/unity/org.gamecult.geometry/Samples~/ +!/unity/org.gamecult.geometry/Samples~/** *.dbmdl *.dbproj.schemaview *.jfm diff --git a/CultLib.sln b/CultLib.sln index f6ae25e9..0319071d 100644 --- a/CultLib.sln +++ b/CultLib.sln @@ -29,6 +29,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry", "src\Ga EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry.Tests", "tests\GameCult.Geometry.Tests\GameCult.Geometry.Tests.csproj", "{C95A9A21-34FA-4D82-BE5B-88FBDA836D31}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry.Planetary.Tests", "tests\GameCult.Geometry.Planetary.Tests\GameCult.Geometry.Planetary.Tests.csproj", "{CE447820-B407-48DE-8957-F373E032C4AE}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Tests", "tests\GameCult.Mesh.Tests\GameCult.Mesh.Tests.csproj", "{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}" EndProject Global @@ -173,6 +175,18 @@ Global {C95A9A21-34FA-4D82-BE5B-88FBDA836D31}.Release|x64.Build.0 = Release|Any CPU {C95A9A21-34FA-4D82-BE5B-88FBDA836D31}.Release|x86.ActiveCfg = Release|Any CPU {C95A9A21-34FA-4D82-BE5B-88FBDA836D31}.Release|x86.Build.0 = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x64.ActiveCfg = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x64.Build.0 = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x86.ActiveCfg = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x86.Build.0 = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|Any CPU.Build.0 = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|x64.ActiveCfg = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|x64.Build.0 = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|x86.ActiveCfg = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|x86.Build.0 = Release|Any CPU {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Debug|Any CPU.Build.0 = Debug|Any CPU {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -201,6 +215,7 @@ Global {EB15435B-7147-4BD9-BB9C-B0A14318D3D5} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} {94C086DA-3359-424B-9688-29A198806481} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {C95A9A21-34FA-4D82-BE5B-88FBDA836D31} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} + {CE447820-B407-48DE-8957-F373E032C4AE} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} EndGlobalSection EndGlobal diff --git a/Directory.Build.props b/Directory.Build.props index 519e7a93..e38b66bc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -15,6 +15,8 @@ © Meta Vens and contributors true 1.0.3 + $(MSBuildThisFileDirectory)..\CultMath\.tools\local-feed + $(RestoreAdditionalProjectSources);$(CultMathCandidateFeed) true diff --git a/docs/cultmath-gamecult-geometry-migration-progress.md b/docs/cultmath-gamecult-geometry-migration-progress.md index 3fefd608..3a882a81 100644 --- a/docs/cultmath-gamecult-geometry-migration-progress.md +++ b/docs/cultmath-gamecult-geometry-migration-progress.md @@ -57,7 +57,7 @@ Demotions: | 0. Freeze and baseline | Complete | Source bodies, contracts, consumers, licenses, fixtures, and focused baselines mapped | | 1. Destination/dependency/formatters/SoA | Complete | NuGet and dedicated UPM artifacts prove dependency direction and owned vector integration | | 2. Primitive deletion and v2 schemas | Complete | C# and Geometry-owned Rust emit identical v2 bytes and record keys for all four roots | -| 3. Planetary transfer | In progress | Transfer C#/tests first, then shaders and runtime projections; delete CultMath owners in the same stage | +| 3. Planetary transfer | Complete | Managed, HLSL, CMPT, Unity, web, tool, docs, tests, and package authority moved; CultMath owners deleted | | 4. Mine `vg-csg` | Pending | Requires v1 fixture capture and target Rust package seam | | 5. CultCache/CultNet/CultMesh integration | Pending | Requires current schemas and runtime packages | | 6. Consumer cutover/release | Pending | Requires local candidate feed and downstream passes | @@ -199,11 +199,38 @@ a broader stage gate. the v2 boundary; signed zero, a chosen NaN payload, and infinity are pinned across MessagePack so this cannot quietly diverge again. +### Stage 3 receipts + +- Thirteen planetary/erosion C# sources and thirteen xUnit suites now live + under GameCult.Geometry; `CultMath.rect`, its factories, and all original + planetary sources/tests are deleted. Destination planetary tests pass 41/41; + remaining CultMath tests pass 28/28 for both target frameworks. +- Geometry owns five HLSL files behind `GameCult.Geometry.hlsl`; public geometry + symbols use `gamecult_geometry_*`. CultMath's umbrella and NuGet candidate + contain only numeric `CultMath.hlsl`. A DXC SPIR-V smoke compiled the joined + umbrella successfully to a 1,428-byte module. +- The CMPT v1 tool and TypeScript decoder moved intact. The end-to-end fixture + is exactly 1,221 bytes with SHA-256 + `8b2bc46e4123b8ac8936b43f8d08a34e3f2e74f38d0bb3f101cf3728cd201962`; + the C# emitter and TypeScript decoder pass together. +- Unity adapters retain their original `.meta` GUIDs; the Planetary Viewer uses + the Geometry umbrella and owned shader symbols. The staged UPM artifact has + one owned DLL, both adapters, the sample, all five shaders, and explicit + MIT/MPL license and provenance documents. +- Stale candidate cache was made impossible by rotating CultMath and Geometry + to `0.1.0-geometry-migration.2`. CultMath's candidate contains only its DLLs, + README, and numeric shader. Geometry's candidate declares the exact CultMath + `.2` dependency and contains all Geometry shaders/docs/notices under a + mixed-license file declaration rather than the inherited blanket MIT claim. +- Full Geometry tests pass 27/27 after the joined shader/Unity integration; + planetary tests pass 41/41. Negative searches find obsolete CultMath geometry + names only inside tests that assert their absence or ignored generated output. + ## Next Actions -1. Establish the Geometry-owned planetary parity harness and move the 13 C# - source/test files while deleting the matching CultMath owners and `rect`. -2. Transfer and rename planetary/erosion HLSL ownership, then remove the - implicit geometry includes and bodies from `CultMath.hlsl`. -3. Transfer the CMPT tool/web decoder, Unity adapters/sample, docs, and license - notices; inspect both packages and run the complete negative gate. +1. Mine convex splitting, brush/CSG tree, domain lowering, dirty-frontier, + selected-cut, and mesh assembly into `gamecult-geometry-rs` in dependency + order, preserving the pinned v1 evidence while emitting v2. +2. Remove `bevy_math` from the public Rust boundary and port earned correctness + and performance fixtures before deleting VibeGeometry build authority. +3. Connect the completed Rust runtime to the v2 CultCache/CultNet/CultMesh path. diff --git a/package.json b/package.json index f1b995a2..a6803b6b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "workspaces": [ "packages/cultcache-ts", "packages/cultnet-ts", - "packages/cultmesh-ts" + "packages/cultmesh-ts", + "packages/gamecult-geometry-ts" ], "scripts": { "build:ts": "npm --workspaces run build", diff --git a/packages/gamecult-geometry-ts/package.json b/packages/gamecult-geometry-ts/package.json new file mode 100644 index 00000000..ea7f8a32 --- /dev/null +++ b/packages/gamecult-geometry-ts/package.json @@ -0,0 +1,10 @@ +{ + "name": "@gamecult/geometry", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "node --no-warnings --experimental-strip-types --check src/planetary-tile.ts", + "test": "node --no-warnings --experimental-strip-types test/planetary-tile.test.mjs" + } +} diff --git a/packages/gamecult-geometry-ts/src/planetary-tile.ts b/packages/gamecult-geometry-ts/src/planetary-tile.ts new file mode 100644 index 00000000..82849c82 --- /dev/null +++ b/packages/gamecult-geometry-ts/src/planetary-tile.ts @@ -0,0 +1,47 @@ +export const GAMECULT_GEOMETRY_PLANETARY_TILE_VERSION = 1; + +export interface PlanetaryTileHeader { + fieldVersion: bigint; projectionVersion: number; layerId: number; projectionKind: number; + centerLongitude: number; centerLatitude: number; projectionScale: number; + level: number; x: number; y: number; interiorSize: number; borderSize: number; + footprintMeters: number; maximumUnresolvedHeight: number; +} +export interface PlanetaryTileSample { + direction: readonly [number, number, number]; radius: number; radialDisplacement: number; + tangentGradient: readonly [number, number, number]; surfaceNormal: readonly [number, number, number]; + slope: number; ridge: number; gully: number; finestResolvedWavelength: number; unresolvedHeightBound: number; +} +export interface PlanetarySurfaceTile { header: PlanetaryTileHeader; storageSize: number; samples: readonly (PlanetaryTileSample | null)[]; } + +export function decodePlanetarySurfaceTile(bytes: ArrayBuffer): PlanetarySurfaceTile { + const view = new DataView(bytes); let offset = 0; + const u8 = () => view.getUint8(offset++); + const u32 = () => { const value = view.getUint32(offset, true); offset += 4; return value; }; + const i32 = () => { const value = view.getInt32(offset, true); offset += 4; return value; }; + const f32 = () => { const value = view.getFloat32(offset, true); offset += 4; return value; }; + const f64 = () => { const value = view.getFloat64(offset, true); offset += 8; return value; }; + const u64 = () => { const value = view.getBigUint64(offset, true); offset += 8; return value; }; + const vector3 = (): [number, number, number] => [f32(), f32(), f32()]; + const requireRemaining = (count: number) => { if (offset + count > view.byteLength) throw new Error("Truncated GameCult.Geometry planetary tile"); }; + requireRemaining(8); + if (String.fromCharCode(u8(), u8(), u8(), u8()) !== "CMPT") throw new Error("Not a GameCult.Geometry planetary tile"); + const version = u32(); + if (version !== GAMECULT_GEOMETRY_PLANETARY_TILE_VERSION) throw new Error(`Unsupported GameCult.Geometry planetary tile version ${version}`); + requireRemaining(76); + const header: PlanetaryTileHeader = { + fieldVersion: u64(), projectionVersion: u32(), layerId: u32(), projectionKind: i32(), + centerLongitude: f64(), centerLatitude: f64(), projectionScale: f64(), + level: i32(), x: i32(), y: i32(), interiorSize: i32(), borderSize: i32(), + footprintMeters: f32(), maximumUnresolvedHeight: f32(), + }; + const count = i32(); const storageSize = header.interiorSize + header.borderSize * 2; + if (count !== storageSize * storageSize) throw new Error("GameCult.Geometry planetary tile sample count does not match layout"); + const samples: (PlanetaryTileSample | null)[] = new Array(count); + for (let index = 0; index < count; index++) { + requireRemaining(1); if (u8() === 0) { samples[index] = null; continue; } + requireRemaining(64); + samples[index] = { direction: vector3(), radius: f32(), radialDisplacement: f32(), tangentGradient: vector3(), surfaceNormal: vector3(), slope: f32(), ridge: f32(), gully: f32(), finestResolvedWavelength: f32(), unresolvedHeightBound: f32() }; + } + if (offset !== view.byteLength) throw new Error("GameCult.Geometry planetary tile has trailing bytes"); + return { header, storageSize, samples }; +} diff --git a/packages/gamecult-geometry-ts/test/planetary-tile.cross-runtime.test.mjs b/packages/gamecult-geometry-ts/test/planetary-tile.cross-runtime.test.mjs new file mode 100644 index 00000000..04243ce8 --- /dev/null +++ b/packages/gamecult-geometry-ts/test/planetary-tile.cross-runtime.test.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { decodePlanetarySurfaceTile } from "../src/planetary-tile.ts"; +if (process.argv.length !== 3) throw new Error("Expected a generated CMPT fixture path"); +const bytes = await readFile(process.argv[2]); +assert.equal(bytes.length, 1221); +assert.equal(createHash("sha256").update(bytes).digest("hex"), "8b2bc46e4123b8ac8936b43f8d08a34e3f2e74f38d0bb3f101cf3728cd201962"); +const tile = decodePlanetarySurfaceTile(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); +assert.equal(tile.header.projectionVersion, 3); assert.equal(tile.header.layerId, 29); +assert.equal(tile.header.projectionKind, 2); assert.equal(tile.header.interiorSize, 5); +assert.equal(tile.header.borderSize, 1); assert.equal(tile.storageSize, 7); assert.equal(tile.samples.length, 49); +assert.ok(tile.samples.some(sample => sample !== null)); +for (const sample of tile.samples) if (sample !== null) { + assert.ok(Number.isFinite(sample.radialDisplacement)); assert.ok(Number.isFinite(sample.unresolvedHeightBound)); +} diff --git a/packages/gamecult-geometry-ts/test/planetary-tile.test.mjs b/packages/gamecult-geometry-ts/test/planetary-tile.test.mjs new file mode 100644 index 00000000..c5c27027 --- /dev/null +++ b/packages/gamecult-geometry-ts/test/planetary-tile.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { decodePlanetarySurfaceTile } from "../src/planetary-tile.ts"; +const bytes = new ArrayBuffer(152); const view = new DataView(bytes); let offset = 0; +const u8 = value => view.setUint8(offset++, value); +const u32 = value => { view.setUint32(offset, value, true); offset += 4; }; +const i32 = value => { view.setInt32(offset, value, true); offset += 4; }; +const u64 = value => { view.setBigUint64(offset, value, true); offset += 8; }; +const f32 = value => { view.setFloat32(offset, value, true); offset += 4; }; +const f64 = value => { view.setFloat64(offset, value, true); offset += 8; }; +for (const character of "CMPT") u8(character.charCodeAt(0)); +u32(1); u64(0x1234n); u32(2); u32(17); i32(0); f64(0); f64(0); f64(1); +i32(0); i32(0); i32(0); i32(2); i32(0); f32(10); f32(0.5); i32(4); u8(1); +for (const value of [1, 0, 0]) f32(value); f32(8.4); f32(0.25); +for (const value of [0, 0.1, 0]) f32(value); +for (const value of [0.99, -0.1, 0]) f32(value); +for (const value of [0.1, 0.8, 0.2, 2.5, 0.05]) f32(value); +u8(0); u8(0); u8(0); assert.equal(offset, bytes.byteLength); +const tile = decodePlanetarySurfaceTile(bytes); +assert.equal(tile.header.fieldVersion, 0x1234n); assert.equal(tile.header.layerId, 17); +assert.equal(tile.storageSize, 2); assert.equal(tile.samples.length, 4); +assert.equal(tile.samples[0]?.radialDisplacement, 0.25); assert.equal(tile.samples[1], null); +assert.throws(() => decodePlanetarySurfaceTile(bytes.slice(0, -1)), /Truncated/); +const trailing = new Uint8Array(bytes.byteLength + 1); trailing.set(new Uint8Array(bytes)); +assert.throws(() => decodePlanetarySurfaceTile(trailing.buffer), /trailing/); diff --git a/scripts/build-geometry-unity-package.ps1 b/scripts/build-geometry-unity-package.ps1 index c5665b1e..8c1b8b1f 100644 --- a/scripts/build-geometry-unity-package.ps1 +++ b/scripts/build-geometry-unity-package.ps1 @@ -22,7 +22,7 @@ $candidateFeed = if ([System.IO.Path]::IsPathRooted($CultMathCandidateFeed)) { } $pluginRoot = Join-Path $outputRoot "Runtime\Plugins" -if (-not (Test-Path -LiteralPath (Join-Path $candidateFeed "CultMath.0.1.0-geometry-migration.1.nupkg"))) { +if (-not (Test-Path -LiteralPath (Join-Path $candidateFeed "CultMath.0.1.0-geometry-migration.2.nupkg"))) { throw "CultMath migration candidate is missing from $candidateFeed. Pack CultMath before staging Geometry." } @@ -47,6 +47,18 @@ New-Item -ItemType Directory -Force -Path $pluginRoot | Out-Null Copy-Item -LiteralPath (Join-Path $templateRoot "package.json") -Destination $outputRoot Copy-Item -LiteralPath (Join-Path $templateRoot "README.md") -Destination $outputRoot Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.Geometry.asmdef") -Destination (Join-Path $outputRoot "Runtime") +Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.Geometry.Unity.asmdef") -Destination (Join-Path $outputRoot "Runtime") +Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\PlanetaryPatchMeshAdapter.cs") -Destination (Join-Path $outputRoot "Runtime") +Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\PlanetaryPatchMeshAdapter.cs.meta") -Destination (Join-Path $outputRoot "Runtime") +Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\PlanetaryPageUpload.cs") -Destination (Join-Path $outputRoot "Runtime") +Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\PlanetaryPageUpload.cs.meta") -Destination (Join-Path $outputRoot "Runtime") +Copy-Item -LiteralPath (Join-Path $templateRoot "Samples~") -Destination $outputRoot -Recurse +$shaderOutputRoot = Join-Path $outputRoot "Shaders" +New-Item -ItemType Directory -Force -Path $shaderOutputRoot | Out-Null +Copy-Item -Path (Join-Path $repoRoot "src\GameCult.Geometry\Shaders\*.hlsl") -Destination $shaderOutputRoot +Copy-Item -Path (Join-Path $repoRoot "src\GameCult.Geometry\Shaders\*.hlsl.meta") -Destination $shaderOutputRoot -ErrorAction SilentlyContinue +Copy-Item -LiteralPath (Join-Path $repoRoot "src\GameCult.Geometry\LICENSES.md") -Destination $outputRoot +Copy-Item -LiteralPath (Join-Path $repoRoot "src\GameCult.Geometry\THIRD-PARTY-NOTICES.md") -Destination $outputRoot $geometryAssembly = Join-Path $publishRoot "GameCult.Geometry.dll" if (-not (Test-Path -LiteralPath $geometryAssembly)) { diff --git a/scripts/test-geometry-planetary-tile.ps1 b/scripts/test-geometry-planetary-tile.ps1 new file mode 100644 index 00000000..59cc859b --- /dev/null +++ b/scripts/test-geometry-planetary-tile.ps1 @@ -0,0 +1,9 @@ +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$fixture = Join-Path $root ".tools\planetary-tile\fixture.cmpt" +dotnet run --project (Join-Path $root "tools\GameCult.Geometry.PlanetaryTileFixture\GameCult.Geometry.PlanetaryTileFixture.csproj") -- $fixture +if ($LASTEXITCODE -ne 0) { throw "Planetary tile fixture generation failed." } +node --no-warnings --experimental-strip-types (Join-Path $root "packages\gamecult-geometry-ts\test\planetary-tile.cross-runtime.test.mjs") $fixture +if ($LASTEXITCODE -ne 0) { throw "Planetary tile web decoding failed." } +node --no-warnings --experimental-strip-types (Join-Path $root "packages\gamecult-geometry-ts\test\planetary-tile.test.mjs") +if ($LASTEXITCODE -ne 0) { throw "Planetary tile decoder unit test failed." } diff --git a/src/GameCult.Geometry/GameCult.Geometry.csproj b/src/GameCult.Geometry/GameCult.Geometry.csproj index 22c991ab..32d72d0a 100644 --- a/src/GameCult.Geometry/GameCult.Geometry.csproj +++ b/src/GameCult.Geometry/GameCult.Geometry.csproj @@ -1,13 +1,13 @@ - Typed CultCache geometry domain and chunk artifact documents for VibeGeometry and Fensalir + Spatial values, planetary geometry, queries, and typed geometry state built on CultMath primitives GameCult.Geometry - game;geometry;csg;lod;cultcache;cultmesh;vibegeometry;fensalir + game;geometry;planetary;csg;lod;cultcache;cultmesh README.md + + LICENSES.md $(NoWarn);1591 - 0.1.0-geometry-migration.1 - $(MSBuildThisFileDirectory)..\..\..\CultMath\.tools\local-feed - $(RestoreAdditionalProjectSources);$(CultMathCandidateFeed) + 0.1.0-geometry-migration.2 @@ -20,5 +20,9 @@ + + + + diff --git a/src/GameCult.Geometry/IsExternalInit.cs b/src/GameCult.Geometry/IsExternalInit.cs new file mode 100644 index 00000000..b33e9e1d --- /dev/null +++ b/src/GameCult.Geometry/IsExternalInit.cs @@ -0,0 +1,7 @@ +#if NETSTANDARD2_1 +namespace System.Runtime.CompilerServices; + +internal static class IsExternalInit +{ +} +#endif diff --git a/src/GameCult.Geometry/LICENSES.md b/src/GameCult.Geometry/LICENSES.md new file mode 100644 index 00000000..67455b87 --- /dev/null +++ b/src/GameCult.Geometry/LICENSES.md @@ -0,0 +1,13 @@ +# GameCult.Geometry licenses + +GameCult.Geometry is a mixed-license package. License follows file ownership; +the package as a whole is not relicensed under one blanket expression. + +- CultLib-authored integration, persistence, schema, and packaging files are + licensed under the MIT License published by the CultLib repository. +- Files under `Planetary/` and `Shaders/` transferred from CultMath remain + licensed under the Mozilla Public License 2.0. Their source notices and + provenance are preserved in `THIRD-PARTY-NOTICES.md`. + +Downstream redistributors must preserve the applicable notices for the files +they redistribute. diff --git a/src/GameCult.Geometry/Planetary/AdvancedErosionFilter.cs b/src/GameCult.Geometry/Planetary/AdvancedErosionFilter.cs new file mode 100644 index 00000000..487cdc87 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/AdvancedErosionFilter.cs @@ -0,0 +1,141 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. +// +// Mathematical procedure adapted from Rune Skovbo Johansen's Advanced Terrain +// Erosion Filter (2025): https://www.shadertoy.com/view/wXcfWn + +using CultMath; + +namespace GameCult.Geometry; + +public readonly record struct AdvancedErosionParameters( + float Scale, + float Strength, + float GullyWeight, + float Detail, + float4 Rounding, + float4 Onset, + float2 AssumedSlope, + float CellScale, + float Normalization, + int Octaves, + float Lacunarity, + float Gain) +{ + public static AdvancedErosionParameters Default => new( + 0.15f, 0.22f, 0.5f, 1.5f, + new float4(0.1f, 0.0f, 0.1f, 2.0f), + new float4(1.25f, 1.25f, 2.8f, 1.5f), + new float2(0.7f, 1.0f), + 0.7f, 0.5f, 5, 2.0f, 0.5f); +} + +public readonly record struct AdvancedErosionResult(float3 Delta, float Magnitude, float RidgeMap, float FadeTarget); + +public static class AdvancedErosionFilter +{ + public static AdvancedErosionResult Sample(float2 position, float3 baseHeightAndSlope, float fadeTarget, AdvancedErosionParameters p) + => Sample(position, baseHeightAndSlope, fadeTarget, p, new ErosionBandSelection(p.Octaves, 1.0f, 0.0f, 0.0f)); + + public static AdvancedErosionResult Sample(float2 position, float3 baseHeightAndSlope, float fadeTarget, AdvancedErosionParameters p, ErosionBandSelection band) + { + var strength = p.Strength * p.Scale; + fadeTarget = math.clamp(fadeTarget, -1.0f, 1.0f); + var input = baseHeightAndSlope; + var heightAndSlope = input; + var frequency = 1.0f / math.max(p.Scale * p.CellScale, 1.0e-10f); + var slope = new float2(heightAndSlope.y, heightAndSlope.z); + var slopeLength = math.max(math.length(slope), 1.0e-10f); + var magnitude = 0.0f; + var roundingMultiplier = 1.0f; + + var inputRounding = math.lerp(p.Rounding.y, p.Rounding.x, math.saturate(fadeTarget + 0.5f)) * p.Rounding.z; + var combinedMask = EaseOut(SmoothStart(slopeLength * p.Onset.x, inputRounding * p.Onset.x)); + var ridgeMask = EaseOut(slopeLength * p.Onset.z); + var ridgeFade = fadeTarget; + var gullySlope = math.lerp(slope, slope / slopeLength * p.AssumedSlope.x, p.AssumedSlope.y); + + var octaves = math.min(math.min(math.max(p.Octaves, 0), 8), math.max(band.ActiveOctaves, 0)); + for (var octave = 0; octave < octaves; octave++) + { + var phacelle = Phacelle(position * frequency, SafeNormalize(gullySlope), p.CellScale, 0.25f, p.Normalization); + var derivativeDirection = new float2(phacelle.z, phacelle.w) * -frequency; + var sloping = math.abs(phacelle.y); + gullySlope += math.sign(phacelle.y) * derivativeDirection * strength * p.GullyWeight; + + var gullies = new float3(phacelle.x, phacelle.y * derivativeDirection.x, phacelle.y * derivativeDirection.y); + var faded = math.lerp(new float3(fadeTarget, 0.0f, 0.0f), gullies * p.GullyWeight, combinedMask); + var octaveWeight = octave == octaves - 1 ? math.saturate(band.FinalOctaveWeight) : 1.0f; + heightAndSlope += faded * strength * octaveWeight; + magnitude += strength * octaveWeight; + fadeTarget = faded.x; + + var octaveRounding = math.lerp(p.Rounding.y, p.Rounding.x, math.saturate(phacelle.x + 0.5f)) * roundingMultiplier; + var newMask = EaseOut(SmoothStart(sloping * p.Onset.y, octaveRounding * p.Onset.y)); + combinedMask = PowInverse(combinedMask, p.Detail) * newMask; + + ridgeFade = math.lerp(ridgeFade, math.lerp(ridgeFade, gullies.x, ridgeMask), octaveWeight); + ridgeMask *= math.lerp(1.0f, EaseOut(sloping * p.Onset.w), octaveWeight); + strength *= p.Gain; + frequency *= p.Lacunarity; + roundingMultiplier *= p.Rounding.w; + } + + return new AdvancedErosionResult(heightAndSlope - input, magnitude, ridgeFade * (1.0f - ridgeMask), fadeTarget); + } + + public static float4 Phacelle(float2 position, float2 normalDirection, float frequency, float offsetCycles, float normalization) + { + var sideDirection = new float2(-normalDirection.y, normalDirection.x) * frequency * math.TAU; + var offset = offsetCycles * math.TAU; + var cell = math.floor(position); + var local = math.frac(position); + var phase = float2.zero; + var weightSum = 0.0f; + + for (var x = -1; x <= 2; x++) + for (var y = -1; y <= 2; y++) + { + var gridOffset = new float2(x, y); + var gridPoint = cell + gridOffset; + var randomOffset = Hash2(gridPoint) * 0.5f; + var delta = local - gridOffset - randomOffset; + var weight = math.max(0.0f, math.exp(-math.dot(delta, delta) * 2.0f) - 0.01111f); + weightSum += weight; + var wave = math.dot(delta, sideDirection) + offset; + phase += new float2(math.cos(wave), math.sin(wave)) * weight; + } + + var interpolated = phase / math.max(weightSum, 1.0e-20f); + var magnitude = math.max(1.0f - math.saturate(normalization), math.length(interpolated)); + var normalized = interpolated / math.max(magnitude, 1.0e-20f); + return new float4(normalized, sideDirection.x, sideDirection.y); + } + + private static float2 Hash2(float2 input) + { + var x = (int)input.x; + var y = (int)input.y; + return new float2(HashUnit(x, y, 0x68bc21ebu), HashUnit(x, y, 0x02e5be93u)) * 2.0f - float2.one; + } + + private static float HashUnit(int x, int y, uint salt) + { + var value = unchecked((uint)x * 0x8da6b343u ^ (uint)y * 0xd8163841u ^ salt); + value ^= value >> 16; value *= 0x7feb352du; value ^= value >> 15; value *= 0x846ca68bu; value ^= value >> 16; + return (value & 0x00ffffffu) * (1.0f / 16777216.0f); + } + + private static float2 SafeNormalize(float2 value) + { + var length = math.length(value); + return math.abs(length) > 1.0e-10f ? value / length : value; + } + + private static float PowInverse(float value, float power) => 1.0f - math.pow(1.0f - math.saturate(value), power); + private static float EaseOut(float value) { var inverse = 1.0f - math.saturate(value); return 1.0f - inverse * inverse; } + private static float SmoothStart(float value, float smoothing) => smoothing <= 1.0e-10f || value >= smoothing + ? value - 0.5f * smoothing + : 0.5f * value * value / smoothing; +} diff --git a/src/GameCult.Geometry/Planetary/ErosionFrequencyBands.cs b/src/GameCult.Geometry/Planetary/ErosionFrequencyBands.cs new file mode 100644 index 00000000..8cadbf11 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/ErosionFrequencyBands.cs @@ -0,0 +1,63 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +using CultMath; + +namespace GameCult.Geometry; + +public readonly record struct ErosionBandSelection( + int ActiveOctaves, + float FinalOctaveWeight, + float FinestIncludedWavelength, + float UnresolvedHeightBound) +{ + public bool HasSamples => ActiveOctaves > 0; +} + +public static class ErosionFrequencyBands +{ + public static ErosionBandSelection Select( + float baseWavelength, + float sampleSpacing, + int maximumOctaves, + float lacunarity, + float baseAmplitude, + float gain, + float transitionRatio = 2.0f) + { + baseWavelength = math.max(baseWavelength, 1.0e-6f); + sampleSpacing = math.max(sampleSpacing, 0.0f); + lacunarity = math.max(lacunarity, 1.01f); + transitionRatio = math.max(transitionRatio, 1.01f); + maximumOctaves = math.min(math.max(maximumOctaves, 0), 16); + var nyquistWavelength = sampleSpacing * 2.0f; + var wavelength = baseWavelength; + var active = 0; + var finalWeight = 0.0f; + var finest = 0.0f; + + for (var octave = 0; octave < maximumOctaves; octave++) + { + var weight = nyquistWavelength <= 0.0f + ? 1.0f + : math.smoothstep(nyquistWavelength, nyquistWavelength * transitionRatio, wavelength); + if (weight <= 0.0f) break; + active++; + finalWeight = weight; + finest = wavelength; + wavelength /= lacunarity; + } + + var omittedAmplitude = 0.0f; + var amplitude = math.abs(baseAmplitude); + for (var octave = 0; octave < maximumOctaves; octave++) + { + if (octave >= active) omittedAmplitude += amplitude; + else if (octave == active - 1) omittedAmplitude += amplitude * (1.0f - finalWeight); + amplitude *= math.abs(gain); + } + + return new ErosionBandSelection(active, active > 0 ? finalWeight : 0.0f, finest, omittedAmplitude); + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryField.cs b/src/GameCult.Geometry/Planetary/PlanetaryField.cs new file mode 100644 index 00000000..21f4f805 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryField.cs @@ -0,0 +1,220 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryQueryScale(float FootprintMeters, float MaximumUnresolvedHeight) +{ + public static PlanetaryQueryScale AtFootprint(float footprintMeters, float maximumUnresolvedHeight = float.PositiveInfinity) + => new PlanetaryQueryScale(footprintMeters, maximumUnresolvedHeight).Validate(); + + public PlanetaryQueryScale Validate() + { + if (!float.IsFinite(FootprintMeters) || FootprintMeters < 0) throw new ArgumentOutOfRangeException(nameof(FootprintMeters)); + if (float.IsNaN(MaximumUnresolvedHeight) || MaximumUnresolvedHeight < 0) throw new ArgumentOutOfRangeException(nameof(MaximumUnresolvedHeight)); + return this; + } +} + +public readonly record struct PlanetaryFieldDefinition( + ulong FieldVersion, + float Radius, + int Seed, + AdvancedErosionParameters Erosion) +{ + public static PlanetaryFieldDefinition Create( + ulong baseFieldVersion, + float radius, + int seed, + AdvancedErosionParameters erosion) + => new PlanetaryFieldDefinition(PlanetaryFieldIdentity.Compute(baseFieldVersion, radius, seed, erosion), radius, seed, erosion).Validate(); + + public PlanetaryFieldDefinition Validate() + { + if (FieldVersion == 0) throw new ArgumentOutOfRangeException(nameof(FieldVersion)); + if (!float.IsFinite(Radius) || Radius <= 0) throw new ArgumentOutOfRangeException(nameof(Radius)); + if (!float.IsFinite(Erosion.Scale) || Erosion.Scale <= 0) throw new ArgumentOutOfRangeException(nameof(Erosion)); + return this; + } +} + +public static class PlanetaryFieldIdentity +{ + public static ulong Compute(ulong baseFieldVersion, float radius, int seed, in AdvancedErosionParameters erosion) + { + if (baseFieldVersion == 0) throw new ArgumentOutOfRangeException(nameof(baseFieldVersion)); + const ulong offset = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + var hash = offset; + Add(baseFieldVersion); + Add((uint)BitConverter.SingleToInt32Bits(radius)); Add((uint)seed); + Add((uint)BitConverter.SingleToInt32Bits(erosion.Scale)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Strength)); + Add((uint)BitConverter.SingleToInt32Bits(erosion.GullyWeight)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Detail)); + AddFloat4(erosion.Rounding); AddFloat4(erosion.Onset); + Add((uint)BitConverter.SingleToInt32Bits(erosion.AssumedSlope.x)); Add((uint)BitConverter.SingleToInt32Bits(erosion.AssumedSlope.y)); + Add((uint)BitConverter.SingleToInt32Bits(erosion.CellScale)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Normalization)); + Add((uint)erosion.Octaves); Add((uint)BitConverter.SingleToInt32Bits(erosion.Lacunarity)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Gain)); + return hash == 0 ? 1 : hash; + + void Add(ulong value) => hash = unchecked((hash ^ value) * prime); + void AddFloat4(float4 value) + { + Add((uint)BitConverter.SingleToInt32Bits(value.x)); Add((uint)BitConverter.SingleToInt32Bits(value.y)); + Add((uint)BitConverter.SingleToInt32Bits(value.z)); Add((uint)BitConverter.SingleToInt32Bits(value.w)); + } + } +} + +/// +/// Application-supplied base evidence at one unit spherical direction. +/// FieldValue and FieldGradient drive erosion. RadialDisplacement and +/// RadialGradient describe the uneroded surface in metres. +/// +public readonly record struct PlanetaryBaseFieldSample( + float RadialDisplacement, + float3 RadialGradient, + float FieldValue, + float3 FieldGradient, + float FadeTarget); + +public readonly record struct PlanetarySurfaceSample( + ulong FieldVersion, + float3 UnitDirection, + float Radius, + float RadialDisplacement, + float3 TangentGradient, + float3 SurfaceNormal, + float Slope, + float Ridge, + float Gully, + float FinestResolvedWavelength, + float UnresolvedHeightBound) +{ + public float SurfaceRadius => Radius + RadialDisplacement; + public float3 PlanetLocalPosition => UnitDirection * SurfaceRadius; + public bool Meets(PlanetaryQueryScale scale) => UnresolvedHeightBound <= scale.MaximumUnresolvedHeight; +} + +public static class PlanetaryField +{ + public static PlanetarySurfaceSample Sample( + in PlanetaryFieldDefinition definition, + float3 unitDirection, + in PlanetaryBaseFieldSample baseSample, + in PlanetaryQueryScale scale) + { + definition.Validate(); + scale.Validate(); + PlanetaryTopology.ValidateDirection(unitDirection); + PlanetaryTopology.ValidateFinite(baseSample.RadialGradient, nameof(baseSample)); + PlanetaryTopology.ValidateFinite(baseSample.FieldGradient, nameof(baseSample)); + if (!float.IsFinite(baseSample.RadialDisplacement) || !float.IsFinite(baseSample.FieldValue) || !float.IsFinite(baseSample.FadeTarget)) + throw new ArgumentOutOfRangeException(nameof(baseSample)); + + var direction = math.normalize(unitDirection); + var radialGradient = Tangent(direction, baseSample.RadialGradient); + var fieldGradient = Tangent(direction, baseSample.FieldGradient); + var p = definition.Erosion; + var band = ErosionFrequencyBands.Select( + p.Scale * p.CellScale, + scale.FootprintMeters, + p.Octaves, + p.Lacunarity, + p.Strength * p.Scale, + p.Gain); + + var powers = new float3( + Pow4(MathF.Abs(direction.x)), + Pow4(MathF.Abs(direction.y)), + Pow4(MathF.Abs(direction.z))); + var powerSum = math.max(powers.x + powers.y + powers.z, 1.0e-6f); + var weights = powers / powerSum; + var world = direction * definition.Radius; + var seed = definition.Seed; + var xy = AdvancedErosionFilter.Sample( + world.xy + new float2(713 + seed * 0.754877666f, -291 + seed * 0.569840296f), + new float3(baseSample.FieldValue, fieldGradient.x, fieldGradient.y), baseSample.FadeTarget, p, band); + var yz = AdvancedErosionFilter.Sample( + world.yz + new float2(-431 + seed * 0.438289027f, 887 + seed * 0.328438163f), + new float3(baseSample.FieldValue, fieldGradient.y, fieldGradient.z), baseSample.FadeTarget, p, band); + var zx = AdvancedErosionFilter.Sample( + new float2(world.z, world.x) + new float2(197 + seed * 0.219783071f, 557 + seed * 0.145898034f), + new float3(baseSample.FieldValue, fieldGradient.z, fieldGradient.x), baseSample.FadeTarget, p, band); + + var erosionHeight = xy.Delta.x * weights.z + yz.Delta.x * weights.x + zx.Delta.x * weights.y; + var erosionGradient = new float3( + xy.Delta.y * weights.z + zx.Delta.z * weights.y, + xy.Delta.z * weights.z + yz.Delta.y * weights.x, + yz.Delta.z * weights.x + zx.Delta.y * weights.y); + var powerGradientX = Tangent(direction, new float3(4 * direction.x * direction.x * direction.x / definition.Radius, 0, 0)); + var powerGradientY = Tangent(direction, new float3(0, 4 * direction.y * direction.y * direction.y / definition.Radius, 0)); + var powerGradientZ = Tangent(direction, new float3(0, 0, 4 * direction.z * direction.z * direction.z / definition.Radius)); + var sumGradient = powerGradientX + powerGradientY + powerGradientZ; + var weightGradientX = (powerGradientX * powerSum - sumGradient * powers.x) / (powerSum * powerSum); + var weightGradientY = (powerGradientY * powerSum - sumGradient * powers.y) / (powerSum * powerSum); + var weightGradientZ = (powerGradientZ * powerSum - sumGradient * powers.z) / (powerSum * powerSum); + erosionGradient += yz.Delta.x * weightGradientX + zx.Delta.x * weightGradientY + xy.Delta.x * weightGradientZ; + var gradient = Tangent(direction, radialGradient + erosionGradient); + var ridgeEvidence = xy.RidgeMap * weights.z + yz.RidgeMap * weights.x + zx.RidgeMap * weights.y; + var ridge = math.saturate(ridgeEvidence * 0.5f + 0.5f); + var gully = math.saturate(0.5f - ridgeEvidence * 0.5f); + var displacement = baseSample.RadialDisplacement + erosionHeight; + + return new( + definition.FieldVersion, + direction, + definition.Radius, + displacement, + gradient, + PlanetaryTopology.SurfaceNormal(direction, gradient), + math.length(gradient), + ridge, + gully, + band.FinestIncludedWavelength, + band.UnresolvedHeightBound); + } + + public static void SampleBatch( + in PlanetaryFieldDefinition definition, + ReadOnlySpan directions, + in PlanetaryQueryScale scale, + TSource source, + Span destination) + where TSource : IPlanetaryBaseField + { + if (destination.Length < directions.Length) throw new ArgumentException("Destination is shorter than directions.", nameof(destination)); + for (var i = 0; i < directions.Length; i++) + { + var direction = math.normalize(directions[i]); + var baseSample = source.Sample(direction); + destination[i] = Sample(definition, direction, baseSample, scale); + } + } + + public static PlanetarySurfaceSample SamplePosition( + in PlanetaryFieldDefinition definition, + double3 planetLocalPosition, + in PlanetaryQueryScale scale, + TSource source) + where TSource : IPlanetaryBaseField + { + if (!double.IsFinite(planetLocalPosition.x) || !double.IsFinite(planetLocalPosition.y) || !double.IsFinite(planetLocalPosition.z)) + throw new ArgumentOutOfRangeException(nameof(planetLocalPosition)); + var length = Math.Sqrt(planetLocalPosition.x * planetLocalPosition.x + planetLocalPosition.y * planetLocalPosition.y + planetLocalPosition.z * planetLocalPosition.z); + if (length < 1.0e-12) throw new ArgumentOutOfRangeException(nameof(planetLocalPosition)); + var direction = new float3((float)(planetLocalPosition.x / length), (float)(planetLocalPosition.y / length), (float)(planetLocalPosition.z / length)); + return Sample(definition, direction, source.Sample(direction), scale); + } + + private static float3 Tangent(float3 direction, float3 gradient) => gradient - direction * math.dot(gradient, direction); + private static float Pow4(float value) { var square = value * value; return square * square; } +} + +public interface IPlanetaryBaseField +{ + PlanetaryBaseFieldSample Sample(float3 unitDirection); +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryGpuPages.cs b/src/GameCult.Geometry/Planetary/PlanetaryGpuPages.cs new file mode 100644 index 00000000..a6d7f4df --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryGpuPages.cs @@ -0,0 +1,60 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryGpuPageInput(float4 DirectionRadius, float4 Sampling); + +public readonly record struct PlanetaryGpuPageMetadata(float4 Address, float4 Layout, float4 Bounds, float4 State); + +public readonly record struct PlanetaryGpuPageContent( + PlanetaryPageLayout Layout, + PlanetaryGpuPageInput[] Inputs, + float SampleSpacing, + float ParentSampleSpacing); + +public static class PlanetaryGpuPageBuilder +{ + public static PlanetaryGpuPageContent BuildContent(PlanetaryPageLayout layout, float radius) + { + layout.Validate(); + if (!float.IsFinite(radius) || radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius)); + var spacing = PlanetaryPageSampling.NominalAngularTexelSize(layout) * radius; + var parentSpacing = layout.Tile.Level == 0 + ? 0 + : PlanetaryPageSampling.NominalAngularTexelSize(new(layout.Tile.Parent(), layout.InteriorSize, layout.BorderSize)) * radius; + var inputs = new PlanetaryGpuPageInput[layout.StorageSize * layout.StorageSize]; + for (var y = 0; y < layout.StorageSize; y++) + for (var x = 0; x < layout.StorageSize; x++) + { + var direction = PlanetaryPageSampling.Direction(layout, x, y); + inputs[y * layout.StorageSize + x] = new( + new float4(direction, radius), + new float4(spacing, parentSpacing, 0, 0)); + } + return new(layout, inputs, spacing, parentSpacing); + } + + public static PlanetaryGpuPageMetadata Metadata( + in PlanetaryGpuPageContent content, + int outputOffset, + float blend, + float4 bounds = default) + { + content.Layout.Validate(); + if (content.Inputs == null || content.Inputs.Length != content.Layout.StorageSize * content.Layout.StorageSize) + throw new ArgumentException("GPU page content does not match its layout.", nameof(content)); + if (outputOffset < 0) throw new ArgumentOutOfRangeException(nameof(outputOffset)); + if (!float.IsFinite(blend) || blend is < 0 or > 1) throw new ArgumentOutOfRangeException(nameof(blend)); + var tile = content.Layout.Tile; + return new( + new float4((int)tile.Face, tile.Level, tile.X, tile.Y), + new float4(outputOffset, content.Layout.StorageSize, content.Layout.InteriorSize, content.Layout.BorderSize), + bounds, + new float4(1, blend, PlanetaryPageSampling.NominalAngularTexelSize(content.Layout), content.SampleSpacing)); + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryMapTileEncoding.cs b/src/GameCult.Geometry/Planetary/PlanetaryMapTileEncoding.cs new file mode 100644 index 00000000..90c93fee --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryMapTileEncoding.cs @@ -0,0 +1,122 @@ +using System; +using System.IO; +using System.Text; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public static class PlanetaryMapTileEncoding +{ + public const uint FormatVersion = 1; + private static readonly byte[] Magic = Encoding.ASCII.GetBytes("CMPT"); + + public static void Write(Stream stream, in PlanetarySurfaceMapTile tile, bool leaveOpen = false) + { + if (stream == null || !stream.CanWrite) throw new ArgumentException("A writable stream is required.", nameof(stream)); + tile.Key.Validate(); + if (tile.Samples == null || tile.Validity == null || tile.Samples.Length != tile.Validity.Length) + throw new ArgumentException("Tile samples and validity must have equal lengths.", nameof(tile)); + var expectedCount = tile.Key.Layout.StorageSize * tile.Key.Layout.StorageSize; + if (tile.Samples.Length != expectedCount) throw new ArgumentException("Tile payload does not match its layout.", nameof(tile)); + + using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen); + writer.Write(Magic); + writer.Write(FormatVersion); + writer.Write(tile.Key.FieldVersion); + writer.Write(tile.Key.ProjectionVersion); + writer.Write(tile.Key.LayerId); + var projection = tile.Key.Layout.Projection; + writer.Write((int)projection.Kind); + writer.Write(projection.CenterLongitude); + writer.Write(projection.CenterLatitude); + writer.Write(projection.Scale); + writer.Write(tile.Key.Layout.Level); + writer.Write(tile.Key.Layout.X); + writer.Write(tile.Key.Layout.Y); + writer.Write(tile.Key.Layout.InteriorSize); + writer.Write(tile.Key.Layout.BorderSize); + writer.Write(tile.Key.QueryScale.FootprintMeters); + writer.Write(tile.Key.QueryScale.MaximumUnresolvedHeight); + writer.Write(tile.Samples.Length); + for (var i = 0; i < tile.Samples.Length; i++) + { + writer.Write(tile.Validity[i]); + if (!tile.Validity[i]) continue; + WriteSample(writer, tile.Samples[i], tile.Key.FieldVersion); + } + } + + public static PlanetarySurfaceMapTile Read(Stream stream, bool leaveOpen = false) + { + if (stream == null || !stream.CanRead) throw new ArgumentException("A readable stream is required.", nameof(stream)); + using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen); + var magic = reader.ReadBytes(Magic.Length); + if (magic.Length != Magic.Length || !magic.AsSpan().SequenceEqual(Magic)) throw new InvalidDataException("Not a CultMath planetary tile."); + var version = reader.ReadUInt32(); + if (version != FormatVersion) throw new InvalidDataException($"Unsupported planetary tile version {version}."); + var fieldVersion = reader.ReadUInt64(); + var projectionVersion = reader.ReadUInt32(); + var layerId = reader.ReadUInt32(); + var kindValue = reader.ReadInt32(); + if (!Enum.IsDefined(typeof(PlanetaryProjectionKind), kindValue)) throw new InvalidDataException("Unknown planetary projection kind."); + var projection = new PlanetaryProjectionParameters( + (PlanetaryProjectionKind)kindValue, + reader.ReadDouble(), reader.ReadDouble(), reader.ReadDouble()).Validate(); + var layout = new PlanetaryMapTileLayout( + projection, + reader.ReadInt32(), reader.ReadInt32(), reader.ReadInt32(), + reader.ReadInt32(), reader.ReadInt32()).Validate(); + var scale = new PlanetaryQueryScale(reader.ReadSingle(), reader.ReadSingle()).Validate(); + var key = new PlanetaryMapTileKey(fieldVersion, projectionVersion, layerId, layout, scale).Validate(); + var count = reader.ReadInt32(); + if (count != layout.StorageSize * layout.StorageSize) throw new InvalidDataException("Planetary tile sample count does not match layout."); + var samples = new PlanetarySurfaceSample[count]; + var validity = new bool[count]; + for (var i = 0; i < count; i++) + { + validity[i] = reader.ReadBoolean(); + if (validity[i]) samples[i] = ReadSample(reader, fieldVersion); + } + if (stream.CanSeek && stream.Position != stream.Length) throw new InvalidDataException("Planetary tile has trailing bytes."); + return new(key, samples, validity); + } + + private static void WriteSample(BinaryWriter writer, PlanetarySurfaceSample sample, ulong fieldVersion) + { + if (sample.FieldVersion != fieldVersion) throw new ArgumentException("Sample field version does not match tile identity."); + Write(writer, sample.UnitDirection); + writer.Write(sample.Radius); + writer.Write(sample.RadialDisplacement); + Write(writer, sample.TangentGradient); + Write(writer, sample.SurfaceNormal); + writer.Write(sample.Slope); + writer.Write(sample.Ridge); + writer.Write(sample.Gully); + writer.Write(sample.FinestResolvedWavelength); + writer.Write(sample.UnresolvedHeightBound); + } + + private static PlanetarySurfaceSample ReadSample(BinaryReader reader, ulong fieldVersion) + { + var direction = ReadFloat3(reader); + var radius = reader.ReadSingle(); + var displacement = reader.ReadSingle(); + var gradient = ReadFloat3(reader); + var normal = ReadFloat3(reader); + return new( + fieldVersion, direction, radius, displacement, gradient, normal, + reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), + reader.ReadSingle(), reader.ReadSingle()); + } + + private static void Write(BinaryWriter writer, float3 value) + { + writer.Write(value.x); writer.Write(value.y); writer.Write(value.z); + } + + private static float3 ReadFloat3(BinaryReader reader) => new(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryPages.cs b/src/GameCult.Geometry/Planetary/PlanetaryPages.cs new file mode 100644 index 00000000..919a6320 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryPages.cs @@ -0,0 +1,192 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryPageLayout(PlanetaryTileAddress Tile, int InteriorSize, int BorderSize) +{ + public int StorageSize => checked(InteriorSize + BorderSize * 2); + + public PlanetaryPageLayout Validate() + { + if (InteriorSize < 2) throw new ArgumentOutOfRangeException(nameof(InteriorSize)); + if (BorderSize < 0 || BorderSize >= InteriorSize) throw new ArgumentOutOfRangeException(nameof(BorderSize)); + return this; + } +} + +public readonly record struct PlanetaryPageSummary( + float MinimumHeight, + float MaximumHeight, + float MaximumSlope, + float UnresolvedHeightBound, + float AngularTexelSize); + +public readonly record struct PlanetaryPageSample(float Height, float3 TangentGradient, float Ridge, float Gully); + +public static class PlanetaryPageSampling +{ + public static float3 Direction(PlanetaryPageLayout page, int storageX, int storageY) + { + page.Validate(); + if ((uint)storageX >= (uint)page.StorageSize || (uint)storageY >= (uint)page.StorageSize) + throw new ArgumentOutOfRangeException(nameof(storageX)); + var localU = (storageX - page.BorderSize) / (double)(page.InteriorSize - 1); + var localV = (storageY - page.BorderSize) / (double)(page.InteriorSize - 1); + return DirectionAtLocal(page, localU, localV); + } + + public static float3 DirectionAtLocal(PlanetaryPageLayout page, double localU, double localV) + { + page.Validate(); + if (!double.IsFinite(localU) || !double.IsFinite(localV)) throw new ArgumentOutOfRangeException(nameof(localU)); + var count = page.Tile.AxisTileCount; + return PlanetaryTopology.Direction(new( + page.Tile.Face, + -1.0 + 2.0 * (page.Tile.X + localU) / count, + -1.0 + 2.0 * (page.Tile.Y + localV) / count)); + } + + public static float AngularTexelSize(PlanetaryPageLayout page) + { + var center = page.BorderSize + (page.InteriorSize - 1) / 2; + var a = Direction(page, center, center); + var b = Direction(page, Math.Min(center + 1, page.StorageSize - 1), center); + return MathF.Acos(math.clamp(math.dot(a, b), -1.0f, 1.0f)); + } + + public static float NominalAngularTexelSize(PlanetaryPageLayout page) + { + page.Validate(); + return MathF.PI * 0.5f / (page.Tile.AxisTileCount * (page.InteriorSize - 1)); + } + + public static PlanetaryPageSample Bilinear( + ReadOnlySpan samples, + PlanetaryPageLayout page, + float2 local) + { + page.Validate(); + if (samples.Length != page.StorageSize * page.StorageSize) throw new ArgumentException("Page sample count does not match layout.", nameof(samples)); + var texel = local * (page.InteriorSize - 1) + page.BorderSize; + var x0 = Math.Clamp((int)MathF.Floor(texel.x), 0, page.StorageSize - 1); + var y0 = Math.Clamp((int)MathF.Floor(texel.y), 0, page.StorageSize - 1); + var x1 = Math.Min(x0 + 1, page.StorageSize - 1); + var y1 = Math.Min(y0 + 1, page.StorageSize - 1); + var tx = math.frac(texel.x); + var ty = math.frac(texel.y); + var a = Lerp(samples[y0 * page.StorageSize + x0], samples[y0 * page.StorageSize + x1], tx); + var b = Lerp(samples[y1 * page.StorageSize + x0], samples[y1 * page.StorageSize + x1], tx); + return Lerp(a, b, ty); + } + + public static PlanetaryPageSummary Summarize(ReadOnlySpan samples, PlanetaryPageLayout page, float unresolvedHeightBound) + { + page.Validate(); + if (samples.Length != page.StorageSize * page.StorageSize) throw new ArgumentException("Page sample count does not match layout.", nameof(samples)); + var min = float.PositiveInfinity; + var max = float.NegativeInfinity; + var maxSlope = 0.0f; + foreach (var sample in samples) + { + min = MathF.Min(min, sample.Height); + max = MathF.Max(max, sample.Height); + maxSlope = MathF.Max(maxSlope, math.length(sample.TangentGradient)); + } + return new(min, max, maxSlope, MathF.Max(unresolvedHeightBound, 0), AngularTexelSize(page)); + } + + private static PlanetaryPageSample Lerp(PlanetaryPageSample a, PlanetaryPageSample b, float t) => new( + math.lerp(a.Height, b.Height, t), + math.lerp(a.TangentGradient, b.TangentGradient, t), + math.lerp(a.Ridge, b.Ridge, t), + math.lerp(a.Gully, b.Gully, t)); +} + +public readonly record struct PlanetaryBakedPage( + PlanetaryPageLayout Layout, + PlanetaryPageSample[] Samples, + PlanetaryPageSummary Summary); + +public static class PlanetaryPageBaker +{ + public static PlanetaryBakedPage Bake( + in PlanetaryFieldDefinition field, + PlanetaryPageLayout layout, + TSource source) + where TSource : IPlanetaryBaseField + { + layout.Validate(); field.Validate(); + var spacing = PlanetaryPageSampling.NominalAngularTexelSize(layout) * field.Radius; + var parentSpacing = layout.Tile.Level == 0 + ? 0 + : PlanetaryPageSampling.NominalAngularTexelSize(new(layout.Tile.Parent(), layout.InteriorSize, layout.BorderSize)) * field.Radius; + var samples = new PlanetaryPageSample[layout.StorageSize * layout.StorageSize]; + var unresolved = 0.0f; + for (var y = 0; y < layout.StorageSize; y++) + for (var x = 0; x < layout.StorageSize; x++) + { + var direction = PlanetaryPageSampling.Direction(layout, x, y); + var baseSample = source.Sample(direction); + var child = PlanetaryField.Sample(field, direction, baseSample, PlanetaryQueryScale.AtFootprint(spacing)); + var height = child.RadialDisplacement; + var gradient = child.TangentGradient; + if (parentSpacing > 0) + { + var parent = PlanetaryField.Sample(field, direction, baseSample, PlanetaryQueryScale.AtFootprint(parentSpacing)); + height -= parent.RadialDisplacement; + gradient -= parent.TangentGradient; + } + samples[y * layout.StorageSize + x] = new(height, gradient, child.Ridge, child.Gully); + unresolved = MathF.Max(unresolved, child.UnresolvedHeightBound); + } + return new(layout, samples, PlanetaryPageSampling.Summarize(samples, layout, unresolved)); + } +} + +public readonly record struct PlanetaryResidentPage(PlanetaryBakedPage Page, float Blend) +{ + public PlanetaryResidentPage Validate() + { + if (!float.IsFinite(Blend) || Blend is < 0 or > 1) throw new ArgumentOutOfRangeException(nameof(Blend)); + Page.Layout.Validate(); + if (Page.Samples.Length != Page.Layout.StorageSize * Page.Layout.StorageSize) throw new ArgumentException("Page payload does not match layout."); + return this; + } +} + +public static class PlanetaryPageSetSampling +{ + public static bool TrySample(ReadOnlySpan pages, float3 direction, out PlanetaryPageSample sample) + { + PlanetaryTopology.ValidateDirection(direction); + var height = 0.0f; + var gradient = float3.zero; + var ridge = 0.0f; + var gully = 0.0f; + var found = false; + var deepestLevel = -1; + foreach (var resident in pages) + { + resident.Validate(); + if (!PlanetaryTopology.TryLocalCoordinate(direction, resident.Page.Layout.Tile, out var local)) continue; + var contribution = PlanetaryPageSampling.Bilinear(resident.Page.Samples, resident.Page.Layout, local); + var blend = math.saturate(resident.Blend); + height += contribution.Height * blend; + gradient += contribution.TangentGradient * blend; + if (resident.Page.Layout.Tile.Level >= deepestLevel) + { + ridge = found ? math.lerp(ridge, contribution.Ridge, blend) : contribution.Ridge; + gully = found ? math.lerp(gully, contribution.Gully, blend) : contribution.Gully; + deepestLevel = resident.Page.Layout.Tile.Level; + } + found = true; + } + sample = new(height, gradient, ridge, gully); + return found; + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryPatch.cs b/src/GameCult.Geometry/Planetary/PlanetaryPatch.cs new file mode 100644 index 00000000..76a3fe92 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryPatch.cs @@ -0,0 +1,50 @@ +using System; +using System.Linq; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryPatchVertex(float2 LocalCoordinate, float3 UnitDirection); + +public readonly record struct PlanetaryPatchMesh( + PlanetaryCubeFace Face, + int CellsPerAxis, + PlanetaryPatchVertex[] Vertices, + int[] Indices); + +public static class PlanetaryPatch +{ + public static PlanetaryPatchMesh CreateFace(PlanetaryCubeFace face, int cellsPerAxis) + { + if (!Enum.IsDefined(typeof(PlanetaryCubeFace), face)) throw new ArgumentOutOfRangeException(nameof(face)); + if (cellsPerAxis < 1 || cellsPerAxis > 4096) throw new ArgumentOutOfRangeException(nameof(cellsPerAxis)); + var axisVertices = cellsPerAxis + 1; + var vertices = new PlanetaryPatchVertex[checked(axisVertices * axisVertices)]; + for (var y = 0; y < axisVertices; y++) + for (var x = 0; x < axisVertices; x++) + { + var local = new float2(x / (float)cellsPerAxis, y / (float)cellsPerAxis); + vertices[y * axisVertices + x] = new(local, PlanetaryTopology.Direction(new(face, local.x * 2 - 1, local.y * 2 - 1))); + } + var indices = new int[checked(cellsPerAxis * cellsPerAxis * 6)]; + var write = 0; + for (var y = 0; y < cellsPerAxis; y++) + for (var x = 0; x < cellsPerAxis; x++) + { + var lowerLeft = y * axisVertices + x; + var lowerRight = lowerLeft + 1; + var upperLeft = lowerLeft + axisVertices; + var upperRight = upperLeft + 1; + indices[write++] = lowerLeft; indices[write++] = lowerRight; indices[write++] = upperRight; + indices[write++] = lowerLeft; indices[write++] = upperRight; indices[write++] = upperLeft; + } + return new(face, cellsPerAxis, vertices, indices); + } + + public static PlanetaryPatchMesh[] CreateCubeSphere(int cellsPerAxis) + => ((PlanetaryCubeFace[])Enum.GetValues(typeof(PlanetaryCubeFace))).Select(face => CreateFace(face, cellsPerAxis)).ToArray(); +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryProjection.cs b/src/GameCult.Geometry/Planetary/PlanetaryProjection.cs new file mode 100644 index 00000000..5851d367 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryProjection.cs @@ -0,0 +1,327 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public enum PlanetaryProjectionKind +{ + Equirectangular, + WebMercator, + EqualEarth, + Orthographic, + AzimuthalEquidistant, + AzimuthalEqualArea, + CubeAtlas, + LocalTangent, +} + +public readonly record struct PlanetaryProjectionParameters( + PlanetaryProjectionKind Kind, + double CenterLongitude = 0, + double CenterLatitude = 0, + double Scale = 1) +{ + public PlanetaryProjectionParameters Validate() + { + if (!double.IsFinite(CenterLongitude) || !double.IsFinite(CenterLatitude) || !double.IsFinite(Scale) || Scale <= 0) + throw new ArgumentOutOfRangeException(nameof(Scale)); + if (CenterLatitude is < -Math.PI / 2 or > Math.PI / 2) throw new ArgumentOutOfRangeException(nameof(CenterLatitude)); + return this; + } +} + +public static class PlanetaryProjection +{ + private const double EqualEarthA1 = 1.340264; + private const double EqualEarthA2 = -0.081106; + private const double EqualEarthA3 = 0.000893; + private const double EqualEarthA4 = 0.003796; + private static readonly double EqualEarthM = Math.Sqrt(3) / 2; + private static readonly double EqualEarthXMax = Math.PI / (EqualEarthM * EqualEarthA1); + private static readonly double EqualEarthYMax = EqualEarthY(Math.Asin(EqualEarthM)); + public static readonly double WebMercatorMaximumLatitude = Math.Atan(Math.Sinh(Math.PI)); + + public static bool TryForward(float3 direction, PlanetaryProjectionParameters parameters, out double2 coordinate) + { + parameters.Validate(); + PlanetaryTopology.ValidateDirection(direction); + var d = math.normalize(direction); + if (parameters.Kind == PlanetaryProjectionKind.CubeAtlas) + { + var face = PlanetaryTopology.FaceCoordinate(d); + var index = (int)face.Face; + var column = index % 3; + var row = index / 3; + coordinate = new( + (-1 + (column + (face.U + 1) * 0.5) * (2.0 / 3.0)) / parameters.Scale, + (1 - (row + (face.V + 1) * 0.5)) / parameters.Scale); + return true; + } + + var longitude = Math.Atan2(d.y, d.x); + var latitude = Math.Asin(Math.Clamp(d.z, -1, 1)); + var deltaLongitude = WrapLongitude(longitude - parameters.CenterLongitude); + var centered = CenteredForward(deltaLongitude, latitude, parameters, out coordinate); + coordinate /= parameters.Scale; + return centered; + } + + public static bool TryInverse(double2 coordinate, PlanetaryProjectionParameters parameters, out float3 direction) + { + parameters.Validate(); + if (!double.IsFinite(coordinate.x) || !double.IsFinite(coordinate.y)) + { + direction = float3.zero; + return false; + } + coordinate *= parameters.Scale; + if (parameters.Kind == PlanetaryProjectionKind.CubeAtlas) + return TryInverseCubeAtlas(coordinate, out direction); + + if (!CenteredInverse(coordinate, parameters, out var deltaLongitude, out var latitude)) + { + direction = float3.zero; + return false; + } + var longitude = WrapLongitude(deltaLongitude + parameters.CenterLongitude); + var cosLatitude = Math.Cos(latitude); + direction = math.normalize(new float3( + (float)(cosLatitude * Math.Cos(longitude)), + (float)(cosLatitude * Math.Sin(longitude)), + (float)Math.Sin(latitude))); + return true; + } + + private static bool CenteredForward(double longitude, double latitude, PlanetaryProjectionParameters p, out double2 coordinate) + { + switch (p.Kind) + { + case PlanetaryProjectionKind.Equirectangular: + coordinate = new(longitude / Math.PI, latitude / (Math.PI * 0.5)); + return true; + case PlanetaryProjectionKind.WebMercator: + if (Math.Abs(latitude) > WebMercatorMaximumLatitude) { coordinate = double2.zero; return false; } + coordinate = new(longitude / Math.PI, Math.Asinh(Math.Tan(latitude)) / Math.PI); + return true; + case PlanetaryProjectionKind.EqualEarth: + { + var theta = Math.Asin(EqualEarthM * Math.Sin(latitude)); + var theta2 = theta * theta; + var theta6 = theta2 * theta2 * theta2; + var denominator = EqualEarthM * (EqualEarthA1 + 3 * EqualEarthA2 * theta2 + theta6 * (7 * EqualEarthA3 + 9 * EqualEarthA4 * theta2)); + coordinate = new(longitude * Math.Cos(theta) / denominator / EqualEarthXMax, EqualEarthY(theta) / EqualEarthYMax); + return true; + } + case PlanetaryProjectionKind.Orthographic: + case PlanetaryProjectionKind.AzimuthalEquidistant: + case PlanetaryProjectionKind.AzimuthalEqualArea: + case PlanetaryProjectionKind.LocalTangent: + return ForwardAzimuthal(longitude, latitude, p, out coordinate); + default: + throw new ArgumentOutOfRangeException(nameof(p)); + } + } + + private static bool CenteredInverse(double2 coordinate, PlanetaryProjectionParameters p, out double longitude, out double latitude) + { + longitude = latitude = 0; + switch (p.Kind) + { + case PlanetaryProjectionKind.Equirectangular: + if (Math.Abs(coordinate.y) > 1) return false; + longitude = coordinate.x * Math.PI; latitude = coordinate.y * Math.PI * 0.5; return Math.Abs(coordinate.x) <= 1; + case PlanetaryProjectionKind.WebMercator: + if (Math.Abs(coordinate.x) > 1 || Math.Abs(coordinate.y) > 1) return false; + longitude = coordinate.x * Math.PI; latitude = Math.Atan(Math.Sinh(coordinate.y * Math.PI)); return true; + case PlanetaryProjectionKind.EqualEarth: + { + if (Math.Abs(coordinate.x) > 1 || Math.Abs(coordinate.y) > 1) return false; + var targetY = coordinate.y * EqualEarthYMax; + var theta = targetY / EqualEarthA1; + for (var i = 0; i < 8; i++) + { + var theta2 = theta * theta; + var theta6 = theta2 * theta2 * theta2; + var f = EqualEarthY(theta) - targetY; + var derivative = EqualEarthA1 + 3 * EqualEarthA2 * theta2 + theta6 * (7 * EqualEarthA3 + 9 * EqualEarthA4 * theta2); + theta -= f / derivative; + } + var sinLatitude = Math.Sin(theta) / EqualEarthM; + if (Math.Abs(sinLatitude) > 1.0000000001) return false; + latitude = Math.Asin(Math.Clamp(sinLatitude, -1, 1)); + var thetaSquared = theta * theta; + var thetaSixth = thetaSquared * thetaSquared * thetaSquared; + var denominator = EqualEarthM * (EqualEarthA1 + 3 * EqualEarthA2 * thetaSquared + thetaSixth * (7 * EqualEarthA3 + 9 * EqualEarthA4 * thetaSquared)); + longitude = coordinate.x * EqualEarthXMax * denominator / Math.Cos(theta); + return Math.Abs(longitude) <= Math.PI + 1.0e-10; + } + case PlanetaryProjectionKind.Orthographic: + case PlanetaryProjectionKind.AzimuthalEquidistant: + case PlanetaryProjectionKind.AzimuthalEqualArea: + case PlanetaryProjectionKind.LocalTangent: + return InverseAzimuthal(coordinate, p, out longitude, out latitude); + default: + throw new ArgumentOutOfRangeException(nameof(p)); + } + } + + private static bool ForwardAzimuthal(double longitude, double latitude, PlanetaryProjectionParameters p, out double2 coordinate) + { + var sin0 = Math.Sin(p.CenterLatitude); var cos0 = Math.Cos(p.CenterLatitude); + var sin = Math.Sin(latitude); var cos = Math.Cos(latitude); var cosLon = Math.Cos(longitude); + var cosC = Math.Clamp(sin0 * sin + cos0 * cos * cosLon, -1, 1); + var rawX = cos * Math.Sin(longitude); + var rawY = cos0 * sin - sin0 * cos * cosLon; + double factor; + switch (p.Kind) + { + case PlanetaryProjectionKind.Orthographic: + if (cosC < 0) { coordinate = double2.zero; return false; } + factor = 1; break; + case PlanetaryProjectionKind.AzimuthalEquidistant: + { + var c = Math.Acos(cosC); factor = c < 1.0e-12 ? 1 : c / Math.Sin(c); factor /= Math.PI; break; + } + case PlanetaryProjectionKind.AzimuthalEqualArea: + factor = Math.Sqrt(2 / Math.Max(1 + cosC, 1.0e-20)) * 0.5; break; + case PlanetaryProjectionKind.LocalTangent: + if (cosC <= 0) { coordinate = double2.zero; return false; } + factor = 1 / cosC; break; + default: throw new ArgumentOutOfRangeException(nameof(p)); + } + coordinate = new(rawX * factor, rawY * factor); + return true; + } + + private static bool InverseAzimuthal(double2 coordinate, PlanetaryProjectionParameters p, out double longitude, out double latitude) + { + longitude = latitude = 0; + var rho = Math.Sqrt(coordinate.x * coordinate.x + coordinate.y * coordinate.y); + double c; + switch (p.Kind) + { + case PlanetaryProjectionKind.Orthographic: if (rho > 1) return false; c = Math.Asin(Math.Clamp(rho, 0, 1)); break; + case PlanetaryProjectionKind.AzimuthalEquidistant: if (rho > 1) return false; c = rho * Math.PI; break; + case PlanetaryProjectionKind.AzimuthalEqualArea: if (rho > 1) return false; c = 2 * Math.Asin(Math.Clamp(rho, 0, 1)); break; + case PlanetaryProjectionKind.LocalTangent: c = Math.Atan(rho); break; + default: throw new ArgumentOutOfRangeException(nameof(p)); + } + if (rho < 1.0e-12) { latitude = p.CenterLatitude; return true; } + var sinC = Math.Sin(c); var cosC = Math.Cos(c); + var sin0 = Math.Sin(p.CenterLatitude); var cos0 = Math.Cos(p.CenterLatitude); + latitude = Math.Asin(Math.Clamp(cosC * sin0 + coordinate.y * sinC * cos0 / rho, -1, 1)); + longitude = Math.Atan2(coordinate.x * sinC, rho * cos0 * cosC - coordinate.y * sin0 * sinC); + return true; + } + + private static bool TryInverseCubeAtlas(double2 coordinate, out float3 direction) + { + direction = float3.zero; + if (coordinate.x is < -1 or > 1 || coordinate.y is < -1 or > 1) return false; + var column = Math.Min((int)((coordinate.x + 1) * 1.5), 2); + var row = Math.Min((int)((1 - coordinate.y)), 1); + var u = ((coordinate.x + 1) * 1.5 - column) * 2 - 1; + var v = ((1 - coordinate.y) - row) * 2 - 1; + direction = PlanetaryTopology.Direction(new((PlanetaryCubeFace)(row * 3 + column), u, v)); + return true; + } + + private static double EqualEarthY(double theta) + { + var theta2 = theta * theta; + var theta6 = theta2 * theta2 * theta2; + return theta * (EqualEarthA1 + EqualEarthA2 * theta2 + theta6 * (EqualEarthA3 + EqualEarthA4 * theta2)); + } + + private static double WrapLongitude(double longitude) + { + longitude %= Math.PI * 2; + if (longitude > Math.PI) longitude -= Math.PI * 2; + if (longitude < -Math.PI) longitude += Math.PI * 2; + return longitude; + } +} + +public readonly record struct PlanetaryMapTileLayout( + PlanetaryProjectionParameters Projection, + int Level, + int X, + int Y, + int InteriorSize, + int BorderSize) +{ + public int StorageSize => checked(InteriorSize + BorderSize * 2); + + public PlanetaryMapTileLayout Validate() + { + Projection.Validate(); + if (Level is < 0 or > 30) throw new ArgumentOutOfRangeException(nameof(Level)); + var count = 1 << Level; + if ((uint)X >= (uint)count || (uint)Y >= (uint)count) throw new ArgumentOutOfRangeException(nameof(X)); + if (InteriorSize < 2 || BorderSize < 0 || BorderSize >= InteriorSize) throw new ArgumentOutOfRangeException(nameof(InteriorSize)); + return this; + } +} + +public static class PlanetaryMapTileSampling +{ + public static bool TryDirection(PlanetaryMapTileLayout tile, int storageX, int storageY, out float3 direction) + { + tile.Validate(); + if ((uint)storageX >= (uint)tile.StorageSize || (uint)storageY >= (uint)tile.StorageSize) throw new ArgumentOutOfRangeException(nameof(storageX)); + var count = 1 << tile.Level; + var localU = (storageX - tile.BorderSize) / (double)(tile.InteriorSize - 1); + var localV = (storageY - tile.BorderSize) / (double)(tile.InteriorSize - 1); + var map = new double2(-1 + 2 * (tile.X + localU) / count, 1 - 2 * (tile.Y + localV) / count); + return PlanetaryProjection.TryInverse(map, tile.Projection, out direction); + } +} + +public readonly record struct PlanetaryMapTileKey( + ulong FieldVersion, + uint ProjectionVersion, + uint LayerId, + PlanetaryMapTileLayout Layout, + PlanetaryQueryScale QueryScale) +{ + public PlanetaryMapTileKey Validate() + { + if (FieldVersion == 0 || ProjectionVersion == 0) throw new ArgumentOutOfRangeException(nameof(FieldVersion)); + Layout.Validate(); QueryScale.Validate(); + return this; + } +} + +public readonly record struct PlanetarySurfaceMapTile( + PlanetaryMapTileKey Key, + PlanetarySurfaceSample[] Samples, + bool[] Validity); + +public static class PlanetaryMapTileBaker +{ + public static PlanetarySurfaceMapTile Bake( + in PlanetaryFieldDefinition field, + PlanetaryMapTileKey key, + TSource source) + where TSource : IPlanetaryBaseField + { + field.Validate(); key.Validate(); + if (field.FieldVersion != key.FieldVersion) throw new ArgumentException("Tile field version does not match definition.", nameof(key)); + var count = key.Layout.StorageSize * key.Layout.StorageSize; + var samples = new PlanetarySurfaceSample[count]; + var validity = new bool[count]; + for (var y = 0; y < key.Layout.StorageSize; y++) + for (var x = 0; x < key.Layout.StorageSize; x++) + { + var index = y * key.Layout.StorageSize + x; + if (!PlanetaryMapTileSampling.TryDirection(key.Layout, x, y, out var direction)) continue; + samples[index] = PlanetaryField.Sample(field, direction, source.Sample(direction), key.QueryScale); + validity[index] = true; + } + return new(key, samples, validity); + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryQueries.cs b/src/GameCult.Geometry/Planetary/PlanetaryQueries.cs new file mode 100644 index 00000000..b50b7785 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryQueries.cs @@ -0,0 +1,198 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryRadialBounds(float MinimumDisplacement, float MaximumDisplacement) +{ + public PlanetaryRadialBounds Validate() + { + if (!float.IsFinite(MinimumDisplacement) || !float.IsFinite(MaximumDisplacement) || MinimumDisplacement > MaximumDisplacement) + throw new ArgumentOutOfRangeException(nameof(MinimumDisplacement)); + return this; + } +} + +public readonly record struct PlanetaryRayHit( + double Distance, + double3 PlanetLocalPosition, + PlanetarySurfaceSample Surface); + +public readonly record struct PlanetaryPathSample(double Distance, PlanetarySurfaceSample Surface); + +public readonly record struct PlanetaryRegionSummary( + float MinimumDisplacement, + float MaximumDisplacement, + float MaximumSlope, + float MaximumUnresolvedHeight); + +public static class PlanetaryQueries +{ + public static bool TryIntersectRay( + in PlanetaryFieldDefinition field, + double3 rayOrigin, + double3 rayDirection, + double maximumDistance, + in PlanetaryQueryScale scale, + in PlanetaryRadialBounds bounds, + TSource source, + out PlanetaryRayHit hit) + where TSource : IPlanetaryBaseField + { + field.Validate(); scale.Validate(); bounds.Validate(); + ValidateFinite(rayOrigin, nameof(rayOrigin)); ValidateFinite(rayDirection, nameof(rayDirection)); + if (!double.IsFinite(maximumDistance) || maximumDistance <= 0) throw new ArgumentOutOfRangeException(nameof(maximumDistance)); + var directionLength = Length(rayDirection); + if (directionLength < 1.0e-15) throw new ArgumentOutOfRangeException(nameof(rayDirection)); + var direction = rayDirection / directionLength; + var outerRadius = field.Radius + bounds.MaximumDisplacement + scale.MaximumUnresolvedHeight; + if (!double.IsFinite(outerRadius)) outerRadius = field.Radius + bounds.MaximumDisplacement; + if (!TrySphereInterval(rayOrigin, direction, outerRadius, out var enter, out var leave)) { hit = default; return false; } + var start = Math.Max(enter, 0); + var end = Math.Min(leave, maximumDistance); + if (start > end) { hit = default; return false; } + + var previousDistance = start; + var previous = SignedSurfaceDistance(field, rayOrigin + direction * previousDistance, scale, source, out var previousSample); + if (previous <= 0) + { + hit = new(previousDistance, rayOrigin + direction * previousDistance, previousSample); + return true; + } + var minimumStep = Math.Max(scale.FootprintMeters * 0.5, field.Radius * 1.0e-7); + for (var iteration = 0; iteration < 512 && previousDistance < end; iteration++) + { + var nextDistance = Math.Min(previousDistance + Math.Max(previous * 0.5, minimumStep), end); + var next = SignedSurfaceDistance(field, rayOrigin + direction * nextDistance, scale, source, out var nextSample); + if (next <= 0) + { + var low = previousDistance; + var high = nextDistance; + var surface = nextSample; + for (var refine = 0; refine < 32; refine++) + { + var middle = (low + high) * 0.5; + var value = SignedSurfaceDistance(field, rayOrigin + direction * middle, scale, source, out var middleSample); + if (value > 0) low = middle; + else { high = middle; surface = middleSample; } + } + var position = rayOrigin + direction * high; + hit = new(high, position, surface); + return true; + } + if (nextDistance >= end) break; + previousDistance = nextDistance; + previous = next; + previousSample = nextSample; + } + hit = default; + return false; + } + + public static double SegmentClearance( + in PlanetaryFieldDefinition field, + double3 start, + double3 end, + double sampleSpacing, + in PlanetaryQueryScale scale, + TSource source) + where TSource : IPlanetaryBaseField + { + ValidateFinite(start, nameof(start)); ValidateFinite(end, nameof(end)); + if (!double.IsFinite(sampleSpacing) || sampleSpacing <= 0) throw new ArgumentOutOfRangeException(nameof(sampleSpacing)); + var delta = end - start; + var distance = Length(delta); + var steps = Math.Max((int)Math.Ceiling(distance / sampleSpacing), 1); + var minimum = double.PositiveInfinity; + for (var i = 0; i <= steps; i++) + { + var position = start + delta * (i / (double)steps); + minimum = Math.Min(minimum, SignedSurfaceDistance(field, position, scale, source, out _)); + } + return minimum; + } + + public static PlanetaryPathSample[] SampleGreatCircle( + in PlanetaryFieldDefinition field, + float3 startDirection, + float3 endDirection, + int segmentCount, + in PlanetaryQueryScale scale, + TSource source) + where TSource : IPlanetaryBaseField + { + if (segmentCount < 1) throw new ArgumentOutOfRangeException(nameof(segmentCount)); + PlanetaryTopology.ValidateDirection(startDirection); PlanetaryTopology.ValidateDirection(endDirection); + var a = math.normalize(startDirection); var b = math.normalize(endDirection); + var angle = MathF.Acos(math.clamp(math.dot(a, b), -1, 1)); + var sinAngle = MathF.Sin(angle); + var result = new PlanetaryPathSample[segmentCount + 1]; + for (var i = 0; i <= segmentCount; i++) + { + var t = i / (float)segmentCount; + var direction = sinAngle < 1.0e-6f + ? math.normalize(math.lerp(a, b, t)) + : math.normalize(a * (MathF.Sin((1 - t) * angle) / sinAngle) + b * (MathF.Sin(t * angle) / sinAngle)); + var sample = PlanetaryField.Sample(field, direction, source.Sample(direction), scale); + result[i] = new(angle * field.Radius * t, sample); + } + return result; + } + + public static PlanetaryRegionSummary Summarize( + in PlanetaryFieldDefinition field, + ReadOnlySpan directions, + in PlanetaryQueryScale scale, + TSource source) + where TSource : IPlanetaryBaseField + { + if (directions.IsEmpty) throw new ArgumentException("At least one direction is required.", nameof(directions)); + var min = float.PositiveInfinity; var max = float.NegativeInfinity; var slope = 0.0f; var unresolved = 0.0f; + foreach (var direction in directions) + { + var normalized = math.normalize(direction); + var sample = PlanetaryField.Sample(field, normalized, source.Sample(normalized), scale); + min = MathF.Min(min, sample.RadialDisplacement); + max = MathF.Max(max, sample.RadialDisplacement); + slope = MathF.Max(slope, sample.Slope); + unresolved = MathF.Max(unresolved, sample.UnresolvedHeightBound); + } + return new(min, max, slope, unresolved); + } + + private static double SignedSurfaceDistance( + in PlanetaryFieldDefinition field, + double3 position, + in PlanetaryQueryScale scale, + TSource source, + out PlanetarySurfaceSample sample) + where TSource : IPlanetaryBaseField + { + var radius = Length(position); + if (radius < 1.0e-15) { sample = default; return double.NegativeInfinity; } + var direction = new float3((float)(position.x / radius), (float)(position.y / radius), (float)(position.z / radius)); + sample = PlanetaryField.Sample(field, direction, source.Sample(direction), scale); + return radius - sample.SurfaceRadius; + } + + private static bool TrySphereInterval(double3 origin, double3 direction, double radius, out double enter, out double leave) + { + var b = Dot(origin, direction); + var c = Dot(origin, origin) - radius * radius; + var discriminant = b * b - c; + if (discriminant < 0) { enter = leave = 0; return false; } + var root = Math.Sqrt(discriminant); + enter = -b - root; leave = -b + root; return leave >= 0; + } + + private static double Dot(double3 a, double3 b) => a.x * b.x + a.y * b.y + a.z * b.z; + private static double Length(double3 value) => Math.Sqrt(Dot(value, value)); + private static void ValidateFinite(double3 value, string name) + { + if (!double.IsFinite(value.x) || !double.IsFinite(value.y) || !double.IsFinite(value.z)) throw new ArgumentOutOfRangeException(name); + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryRadialRefinement.cs b/src/GameCult.Geometry/Planetary/PlanetaryRadialRefinement.cs new file mode 100644 index 00000000..a958bc1a --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryRadialRefinement.cs @@ -0,0 +1,34 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +using CultMath; + +namespace GameCult.Geometry; + +public static class PlanetaryRadialRefinement +{ + public static float3 Step( + float3 position, + float3 rayDirection, + float3 center, + float targetRadius, + float minimumDerivative = 0.2f, + float maximumCorrection = 0.08f) + { + var local = position - center; + var radius = math.length(local); + var radial = local / math.max(radius, 0.0001f); + var error = radius - targetRadius; + var derivative = math.dot(rayDirection, radial); + var safeMinimum = math.max(minimumDerivative, 0.0001f); + var safeDerivative = math.abs(derivative) > safeMinimum + ? derivative + : derivative < 0.0f ? -safeMinimum : safeMinimum; + var correction = math.clamp( + -error / safeDerivative, + -math.abs(maximumCorrection), + math.abs(maximumCorrection)); + return position + rayDirection * correction; + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryResidency.cs b/src/GameCult.Geometry/Planetary/PlanetaryResidency.cs new file mode 100644 index 00000000..79acc317 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryResidency.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryLodParameters( + int MaximumLevel, + int PageInteriorSize, + int PageBorderSize, + float ViewportHeightPixels, + float MinimumFootprintMeters) +{ + public PlanetaryLodParameters Validate() + { + if (MaximumLevel is < 0 or > PlanetaryTileAddress.MaxLevel) throw new ArgumentOutOfRangeException(nameof(MaximumLevel)); + _ = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveX, 0, 0, 0), PageInteriorSize, PageBorderSize).Validate(); + if (!float.IsFinite(ViewportHeightPixels) || ViewportHeightPixels <= 0) throw new ArgumentOutOfRangeException(nameof(ViewportHeightPixels)); + if (!float.IsFinite(MinimumFootprintMeters) || MinimumFootprintMeters < 0) throw new ArgumentOutOfRangeException(nameof(MinimumFootprintMeters)); + return this; + } +} + +public static class PlanetaryLodSelector +{ + public static int SelectLevel( + in PlanetaryFieldDefinition field, + float cameraDistanceFromCenter, + in PlanetaryLodParameters parameters) + { + field.Validate(); parameters.Validate(); + if (!float.IsFinite(cameraDistanceFromCenter) || cameraDistanceFromCenter < 0) throw new ArgumentOutOfRangeException(nameof(cameraDistanceFromCenter)); + var altitude = MathF.Max(cameraDistanceFromCenter - field.Radius, 0.001f); + var footprint = MathF.Max(altitude / parameters.ViewportHeightPixels * 2, parameters.MinimumFootprintMeters); + var erosion = field.Erosion; + for (var level = 0; level <= parameters.MaximumLevel; level++) + { + var page = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveX, level, 0, 0), parameters.PageInteriorSize, parameters.PageBorderSize); + var spacing = PlanetaryPageSampling.NominalAngularTexelSize(page) * field.Radius; + var unresolved = ErosionFrequencyBands.Select( + erosion.Scale * erosion.CellScale, spacing, erosion.Octaves, + erosion.Lacunarity, erosion.Strength * erosion.Scale, erosion.Gain).UnresolvedHeightBound; + if (unresolved <= footprint) return level; + } + return parameters.MaximumLevel; + } + + public static PlanetaryTileAddress[] SelectAncestorChain( + in PlanetaryFieldDefinition field, + float3 cameraDirection, + float cameraDistanceFromCenter, + in PlanetaryLodParameters parameters) + { + PlanetaryTopology.ValidateDirection(cameraDirection); + var level = SelectLevel(field, cameraDistanceFromCenter, parameters); + var result = new PlanetaryTileAddress[6 + level]; + for (var face = 0; face < 6; face++) result[face] = new((PlanetaryCubeFace)face, 0, 0, 0); + var leaf = PlanetaryTopology.TileAt(cameraDirection, level); + for (var currentLevel = level; currentLevel >= 1; currentLevel--) + { + result[6 + currentLevel - 1] = leaf; + leaf = leaf.Parent(); + } + return result; + } +} + +public readonly record struct PlanetaryResidentTile(PlanetaryTileAddress Tile, float Blend, bool Departing); + +public readonly record struct PlanetaryResidencySnapshot( + ulong ContentVersion, + ulong PresentationVersion, + PlanetaryResidentTile[] Tiles); + +/// Owns presentation residency only. It never owns field content. +public sealed class PlanetaryResidualResidency +{ + private readonly Dictionary residents = new(); + private readonly Dictionary arrivals = new(); + private readonly Dictionary departures = new(); + private float lastTime = float.NegativeInfinity; + + public void Reset() + { + residents.Clear(); arrivals.Clear(); departures.Clear(); lastTime = float.NegativeInfinity; + } + + public PlanetaryResidencySnapshot Update(ReadOnlySpan desired, float timeSeconds, float transitionSeconds) + { + if (!float.IsFinite(timeSeconds)) throw new ArgumentOutOfRangeException(nameof(timeSeconds)); + if (!float.IsFinite(transitionSeconds) || transitionSeconds < 0) throw new ArgumentOutOfRangeException(nameof(transitionSeconds)); + if (timeSeconds < lastTime) Reset(); + lastTime = timeSeconds; + + var desiredKeys = new HashSet(); + foreach (var tile in desired) + { + var key = tile.StableKey; + desiredKeys.Add(key); + residents[key] = tile; + if (!arrivals.ContainsKey(key)) arrivals[key] = timeSeconds; + departures.Remove(key); + } + + foreach (var pair in residents.ToArray()) + { + if (pair.Value.Level == 0 || desiredKeys.Contains(pair.Key)) continue; + if (!departures.ContainsKey(pair.Key)) + { + var startingBlend = transitionSeconds == 0 + ? 1 + : Math.Clamp((timeSeconds - arrivals[pair.Key]) / transitionSeconds, 0, 1); + departures[pair.Key] = (timeSeconds, startingBlend); + } + if (transitionSeconds > 0 && timeSeconds - departures[pair.Key].Time < transitionSeconds) continue; + residents.Remove(pair.Key); arrivals.Remove(pair.Key); departures.Remove(pair.Key); + } + + var ordered = residents + .OrderBy(pair => pair.Value.Level) + .ThenBy(pair => pair.Value.Face) + .ThenBy(pair => pair.Value.X) + .ThenBy(pair => pair.Value.Y) + .ToArray(); + var tiles = new PlanetaryResidentTile[ordered.Length]; + for (var i = 0; i < ordered.Length; i++) + { + var pair = ordered[i]; + var departing = departures.TryGetValue(pair.Key, out var departure); + var blend = pair.Value.Level == 0 || transitionSeconds == 0 + ? 1 + : departing + ? departure.StartingBlend * Math.Clamp(1 - (timeSeconds - departure.Time) / transitionSeconds, 0, 1) + : Math.Clamp((timeSeconds - arrivals[pair.Key]) / transitionSeconds, 0, 1); + tiles[i] = new(pair.Value, blend, departing); + } + return new(Hash(ordered.Select(pair => pair.Key)), Hash(tiles.Select(tile => tile.Tile.StableKey ^ (uint)BitConverter.SingleToInt32Bits(tile.Blend))), tiles); + } + + private static ulong Hash(IEnumerable values) + { + const ulong offset = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + var hash = offset; + foreach (var value in values) hash = unchecked((hash ^ value) * prime); + return hash == 0 ? 1 : hash; + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryTopology.cs b/src/GameCult.Geometry/Planetary/PlanetaryTopology.cs new file mode 100644 index 00000000..18ff4f31 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryTopology.cs @@ -0,0 +1,196 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public enum PlanetaryCubeFace +{ + PositiveX, + NegativeX, + PositiveY, + NegativeY, + PositiveZ, + NegativeZ, +} + +public readonly record struct PlanetaryFaceCoordinate(PlanetaryCubeFace Face, double U, double V) +{ + public PlanetaryFaceCoordinate Validate(bool allowOutsideFace = false) + { + if (!double.IsFinite(U) || !double.IsFinite(V)) throw new ArgumentOutOfRangeException(nameof(U)); + if (!allowOutsideFace && (U is < -1.0 or > 1.0 || V is < -1.0 or > 1.0)) + throw new ArgumentOutOfRangeException(nameof(U), "Face coordinates must be in [-1, 1]."); + if (!Enum.IsDefined(typeof(PlanetaryCubeFace), Face)) throw new ArgumentOutOfRangeException(nameof(Face)); + return this; + } +} + +public readonly record struct PlanetaryTileAddress +{ + public const int MaxLevel = 30; + + public PlanetaryTileAddress(PlanetaryCubeFace face, int level, int x, int y) + { + if (!Enum.IsDefined(typeof(PlanetaryCubeFace), face)) throw new ArgumentOutOfRangeException(nameof(face)); + if (level is < 0 or > MaxLevel) throw new ArgumentOutOfRangeException(nameof(level)); + var count = 1 << level; + if ((uint)x >= (uint)count) throw new ArgumentOutOfRangeException(nameof(x)); + if ((uint)y >= (uint)count) throw new ArgumentOutOfRangeException(nameof(y)); + Face = face; + Level = level; + X = x; + Y = y; + } + + public PlanetaryCubeFace Face { get; } + public int Level { get; } + public int X { get; } + public int Y { get; } + public int AxisTileCount => 1 << Level; + + public PlanetaryTileAddress Parent() => Level == 0 + ? throw new InvalidOperationException("Root cube tile has no parent.") + : new(Face, Level - 1, X >> 1, Y >> 1); + + public PlanetaryTileAddress Child(int childX, int childY) + { + if ((uint)childX > 1) throw new ArgumentOutOfRangeException(nameof(childX)); + if ((uint)childY > 1) throw new ArgumentOutOfRangeException(nameof(childY)); + if (Level == MaxLevel) throw new InvalidOperationException($"Level {MaxLevel} cannot be subdivided."); + return new(Face, Level + 1, (X << 1) | childX, (Y << 1) | childY); + } + + public PlanetaryFaceCoordinate PositionAt(double localU, double localV) + { + if (!double.IsFinite(localU) || localU is < 0.0 or > 1.0) throw new ArgumentOutOfRangeException(nameof(localU)); + if (!double.IsFinite(localV) || localV is < 0.0 or > 1.0) throw new ArgumentOutOfRangeException(nameof(localV)); + var count = AxisTileCount; + return new(Face, -1.0 + 2.0 * (X + localU) / count, -1.0 + 2.0 * (Y + localV) / count); + } + + public ulong StableKey + { + get + { + const ulong offset = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + var hash = offset; + hash = unchecked((hash ^ (uint)Face) * prime); + hash = unchecked((hash ^ (uint)Level) * prime); + hash = unchecked((hash ^ (uint)X) * prime); + hash = unchecked((hash ^ (uint)Y) * prime); + return hash == 0 ? 1 : hash; + } + } + public override string ToString() => $"{Face}/L{Level:D2}/{X}/{Y}"; +} + +public static class PlanetaryTopology +{ + private const float QuarterPi = MathF.PI * 0.25f; + + public static float3 Direction(PlanetaryFaceCoordinate coordinate) + { + coordinate.Validate(allowOutsideFace: true); + var u = MathF.Tan((float)coordinate.U * QuarterPi); + var v = MathF.Tan((float)coordinate.V * QuarterPi); + var cube = coordinate.Face switch + { + PlanetaryCubeFace.PositiveX => new float3(1, v, -u), + PlanetaryCubeFace.NegativeX => new float3(-1, v, u), + PlanetaryCubeFace.PositiveY => new float3(u, 1, -v), + PlanetaryCubeFace.NegativeY => new float3(u, -1, v), + PlanetaryCubeFace.PositiveZ => new float3(u, v, 1), + PlanetaryCubeFace.NegativeZ => new float3(-u, v, -1), + _ => throw new ArgumentOutOfRangeException(nameof(coordinate)), + }; + return math.normalize(cube); + } + + public static float3 NormalizedCubeDirection(PlanetaryFaceCoordinate coordinate) + { + coordinate.Validate(allowOutsideFace: true); + var u = (float)coordinate.U; + var v = (float)coordinate.V; + return math.normalize(CubeVector(coordinate.Face, u, v)); + } + + public static PlanetaryFaceCoordinate FaceCoordinate(float3 direction) + { + ValidateDirection(direction); + var d = math.normalize(direction); + var a = math.abs(d); + if (a.x >= a.y && a.x >= a.z) + return d.x >= 0 + ? FromCubeUv(PlanetaryCubeFace.PositiveX, -d.z / a.x, d.y / a.x) + : FromCubeUv(PlanetaryCubeFace.NegativeX, d.z / a.x, d.y / a.x); + if (a.y >= a.z) + return d.y >= 0 + ? FromCubeUv(PlanetaryCubeFace.PositiveY, d.x / a.y, -d.z / a.y) + : FromCubeUv(PlanetaryCubeFace.NegativeY, d.x / a.y, d.z / a.y); + return d.z >= 0 + ? FromCubeUv(PlanetaryCubeFace.PositiveZ, d.x / a.z, d.y / a.z) + : FromCubeUv(PlanetaryCubeFace.NegativeZ, -d.x / a.z, d.y / a.z); + } + + public static PlanetaryTileAddress TileAt(float3 direction, int level) + { + if (level is < 0 or > PlanetaryTileAddress.MaxLevel) throw new ArgumentOutOfRangeException(nameof(level)); + var face = FaceCoordinate(direction); + var count = 1 << level; + var x = Math.Clamp((int)((face.U * 0.5 + 0.5) * count), 0, count - 1); + var y = Math.Clamp((int)((face.V * 0.5 + 0.5) * count), 0, count - 1); + return new(face.Face, level, x, y); + } + + public static bool TryLocalCoordinate(float3 direction, PlanetaryTileAddress tile, out float2 local) + { + var face = FaceCoordinate(direction); + if (face.Face != tile.Face) { local = float2.zero; return false; } + var scaledU = (face.U * 0.5 + 0.5) * tile.AxisTileCount; + var scaledV = (face.V * 0.5 + 0.5) * tile.AxisTileCount; + local = new((float)(scaledU - tile.X), (float)(scaledV - tile.Y)); + return local.x is >= 0 and <= 1 && local.y is >= 0 and <= 1; + } + + public static float3 SurfaceNormal(float3 unitDirection, float3 worldDistanceTangentGradient) + { + ValidateDirection(unitDirection); + ValidateFinite(worldDistanceTangentGradient, nameof(worldDistanceTangentGradient)); + var direction = math.normalize(unitDirection); + var tangent = worldDistanceTangentGradient - direction * math.dot(worldDistanceTangentGradient, direction); + return math.normalize(direction - tangent); + } + + private static PlanetaryFaceCoordinate FromCubeUv(PlanetaryCubeFace face, float cubeU, float cubeV) => new( + face, + MathF.Atan(cubeU) / QuarterPi, + MathF.Atan(cubeV) / QuarterPi); + + private static float3 CubeVector(PlanetaryCubeFace face, float u, float v) => face switch + { + PlanetaryCubeFace.PositiveX => new float3(1, v, -u), + PlanetaryCubeFace.NegativeX => new float3(-1, v, u), + PlanetaryCubeFace.PositiveY => new float3(u, 1, -v), + PlanetaryCubeFace.NegativeY => new float3(u, -1, v), + PlanetaryCubeFace.PositiveZ => new float3(u, v, 1), + PlanetaryCubeFace.NegativeZ => new float3(-u, v, -1), + _ => throw new ArgumentOutOfRangeException(nameof(face)), + }; + + internal static void ValidateDirection(float3 direction) + { + ValidateFinite(direction, nameof(direction)); + if (math.length(direction) < 1.0e-12f) throw new ArgumentOutOfRangeException(nameof(direction)); + } + + internal static void ValidateFinite(float3 value, string name) + { + if (!float.IsFinite(value.x) || !float.IsFinite(value.y) || !float.IsFinite(value.z)) + throw new ArgumentOutOfRangeException(name); + } +} diff --git a/src/GameCult.Geometry/Planetary/SphericalErosion.cs b/src/GameCult.Geometry/Planetary/SphericalErosion.cs new file mode 100644 index 00000000..b20e0f00 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/SphericalErosion.cs @@ -0,0 +1,114 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +using CultMath; + +namespace GameCult.Geometry; + +public readonly record struct SphericalErosionParameters( + float Frequency, + float Amplitude, + float Lacunarity, + float Gain, + float SlopeStrength, + float Detail, + float Seed, + int Octaves) +{ + public static SphericalErosionParameters Default => new(18.0f, 0.035f, 2.03f, 0.52f, 6.0f, 1.35f, 0.0f, 5); +} + +public readonly record struct SphericalErosionSample(float HeightOffset, float Ridge, float Gully, float3 Gradient); + +/// +/// Stateless, point-evaluable erosion-like synthesis on a unit sphere. +/// The matching shader body is gamecult_geometry_spherical_erosion in GameCult.Geometry.hlsl. +/// Position and gradient are planet-space; the gradient is projected tangent +/// internally so cube faces and tile request order never enter the result. +/// +public static class SphericalErosion +{ + public static SphericalErosionSample Sample( + float3 unitPosition, + float baseHeight, + float3 baseGradient, + SphericalErosionParameters parameters) + { + var position = math.normalize(unitPosition); + var gradient = baseGradient - position * math.dot(baseGradient, position); + var slope = math.length(gradient); + var flow = slope > 1.0e-6f ? -gradient / slope : StableTangent(position); + var frequency = math.max(parameters.Frequency, 0.001f); + var amplitude = parameters.Amplitude; + var combiMask = EaseOut(math.saturate(slope * parameters.SlopeStrength)); + var fadeTarget = math.saturate(baseHeight * 0.5f + 0.5f) * 2.0f - 1.0f; + var heightOffset = 0.0f; + var ridge = 0.0f; + var gully = 0.0f; + + var octaveCount = math.min(math.max(parameters.Octaves, 0), 8); + for (var octave = 0; octave < octaveCount; octave++) + { + var phase = DirectionalCellWave(position * frequency, flow, parameters.Seed + octave * 19.19f); + var wave = math.cos(phase); + var waveSlope = math.sin(phase); + var faded = math.lerp(fadeTarget, wave, combiMask); + heightOffset += faded * amplitude; + ridge = math.max(ridge, math.saturate(faded)); + gully = math.max(gully, math.saturate(-faded)); + + var across = math.normalize(math.cross(position, flow)); + gradient += across * (math.sign(waveSlope) * amplitude * frequency * combiMask); + gradient -= position * math.dot(gradient, position); + slope = math.length(gradient); + flow = slope > 1.0e-6f ? -gradient / slope : StableTangent(position); + + var newMask = EaseOut(math.saturate(math.abs(waveSlope) * 1.35f)); + combiMask = PowInverse(combiMask, parameters.Detail) * newMask; + fadeTarget = faded; + frequency *= math.max(parameters.Lacunarity, 1.01f); + amplitude *= math.saturate(parameters.Gain); + } + + return new SphericalErosionSample(heightOffset, ridge, gully, gradient); + } + + private static float DirectionalCellWave(float3 position, float3 flow, float seed) + { + var cell = math.floor(position); + var local = math.frac(position); + var weights = local * local * (3.0f - 2.0f * local); + var x00 = CellWave(position, flow, cell + new float3(0, 0, 0), seed); + var x10 = CellWave(position, flow, cell + new float3(1, 0, 0), seed); + var x01 = CellWave(position, flow, cell + new float3(0, 1, 0), seed); + var x11 = CellWave(position, flow, cell + new float3(1, 1, 0), seed); + var y00 = math.lerp(x00, x10, weights.x); + var y10 = math.lerp(x01, x11, weights.x); + var z0 = math.lerp(y00, y10, weights.y); + var x00z = CellWave(position, flow, cell + new float3(0, 0, 1), seed); + var x10z = CellWave(position, flow, cell + new float3(1, 0, 1), seed); + var x01z = CellWave(position, flow, cell + new float3(0, 1, 1), seed); + var x11z = CellWave(position, flow, cell + new float3(1, 1, 1), seed); + var z1 = math.lerp(math.lerp(x00z, x10z, weights.x), math.lerp(x01z, x11z, weights.x), weights.y); + return math.lerp(z0, z1, weights.z); + } + + private static float CellWave(float3 position, float3 flow, float3 cell, float seed) + { + var pivot = cell + new float3( + math.hash(cell + seed + 11.7f), + math.hash(cell.yzx + seed + 37.1f), + math.hash(cell.zyx + seed + 73.9f)); + return math.dot(position - pivot, flow) * math.TAU + math.hash(cell + seed + 101.3f) * math.TAU; + } + + private static float3 StableTangent(float3 position) + { + var axis = math.abs(position.z) < 0.8f ? new float3(0, 0, 1) : new float3(0, 1, 0); + return math.normalize(math.cross(axis, position)); + } + + private static float EaseOut(float value) => 1.0f - (1.0f - value) * (1.0f - value); + private static float PowInverse(float value, float power) => 1.0f - math.pow(1.0f - math.saturate(value), math.max(power, 0.001f)); +} diff --git a/src/GameCult.Geometry/README.md b/src/GameCult.Geometry/README.md index 4fb881bc..3c1f75bd 100644 --- a/src/GameCult.Geometry/README.md +++ b/src/GameCult.Geometry/README.md @@ -1,8 +1,8 @@ # GameCult.Geometry -`GameCult.Geometry` defines shared geometry values plus CultCache-native -documents for procedural geometry domains, LOD build requests, selected-cut -manifests, and mesh chunk artifacts. +`GameCult.Geometry` owns spatial values, planetary fields and topology, +residual-page residency, map projections and tiles, geometry queries, and the +CultCache-native documents that coordinate geometry production. The package is deliberately substrate-shaped: @@ -20,8 +20,8 @@ The package is deliberately substrate-shaped: The small value layer is for cross-runtime query contracts, physics probes, native slices, and UI surfaces: -- `CultMath.float2` and `CultMath.float3`: whole-vector values for positions, velocities, - accelerations, and query inputs. SoA layouts may store them efficiently, but +- `CultMath.float2` and `CultMath.float3`: whole-vector values for positions, + velocities, accelerations, and query inputs. SoA layouts may store them efficiently, but the semantic API does not force callers to split x/y/z into unrelated fields. - `CultRect`: canonical XY viewport/query rectangle stored as min/max corners. - `CultCircle`: 2D influence/query brush with rect intersection helpers. @@ -33,10 +33,10 @@ runtime equivalents instead of bespoke `{ minX, minY, maxX, maxY }` payloads. ## Runtime Path -`vg-csg` emits domain documents, selected-cut manifests, and chunk artifacts as -MessagePack CultCache documents. A Unity runtime, local worker, or remote -geometry process should receive the same records through CultMesh/CultNet and -read them back as `CultGeometry*` types. +Geometry workers emit domain documents, selected-cut manifests, and chunk +artifacts as MessagePack CultCache documents. A Unity runtime, local worker, or +remote geometry process receives the same records through CultMesh/CultNet and +reads them back as `CultGeometry*` types. ```csharp using GameCult.Caching; @@ -66,18 +66,27 @@ using var chunks = node.Database The record key is part of the contract. Rust and C# must compute the same key for the same geometry record, so stable fingerprints use the Rust canonical -field order and invariant float text. If this drifts, the same chunk becomes two +field order and exact IEEE-754 component bits. If this drifts, the same chunk becomes two different chunks depending on which runtime touched it first. That is not a cache miss; that is split-brain geometry. ## Documents -- `CultMath.float2`, `CultMath.float3`, `CultRect`, `CultCircle`, `CultSphere`: shared value - primitives for query and view contracts. +- `CultMath.float2`, `CultMath.float3`, `CultRect`, `CultCircle`, and + `CultSphere`: shared value primitives for query and view contracts. - `CultGeometryDomainDocument`: one hierarchical domain tree, suitable for - Ragnarok/Fensalir-style feature DSL output. + hierarchical feature DSL output. - `CultGeometryBuildRequest`: one LOD/frustum/budget request for workers. - `CultGeometrySelectedCutManifest`: the selected domain cut, deferred children, fallback rows, and diagnostic contribution rows. - `CultGeometryChunkArtifact`: render mesh, optional collider mesh, source domain/claim identities, build counters, and transition clipping seed. + +## Planetary geometry + +The planetary field, cube-sphere topology, pages, residency, projections, +queries, and versioned `CMPT` map tiles are described in +[`docs/planetary.md`](docs/planetary.md). Shader consumers include +`Shaders/GameCult.Geometry.hlsl`; Unity consumers use the +`org.gamecult.geometry` package, and browser tile consumers use +`packages/gamecult-geometry-ts`. diff --git a/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl b/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl new file mode 100644 index 00000000..219afc86 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl @@ -0,0 +1,147 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. +// Mathematical procedure adapted from Rune Skovbo Johansen's Advanced Terrain +// Erosion Filter (2025): https://www.shadertoy.com/view/wXcfWn + +#ifndef GAMECULT_GEOMETRY_ADVANCED_EROSION_FILTER_HLSL +#define GAMECULT_GEOMETRY_ADVANCED_EROSION_FILTER_HLSL + +struct GameCultGeometryAdvancedErosionParameters +{ + float scale; float strength; float gully_weight; float detail; + float4 rounding; float4 onset; float2 assumed_slope; + float cell_scale; float normalization; int octaves; float lacunarity; float gain; +}; + +struct GameCultGeometryAdvancedErosionResult +{ + float3 delta; float magnitude; float ridge_map; float fade_target; +}; + +struct GameCultGeometryErosionBandSelection +{ + int active_octaves; float final_octave_weight; float finest_included_wavelength; float unresolved_height_bound; +}; + +GameCultGeometryErosionBandSelection gamecult_geometry_select_erosion_bands(float base_wavelength, float sample_spacing, int maximum_octaves, float lacunarity, float base_amplitude, float gain, float transition_ratio) +{ + base_wavelength=max(base_wavelength,1.0e-6); sample_spacing=max(sample_spacing,0.0); lacunarity=max(lacunarity,1.01); transition_ratio=max(transition_ratio,1.01); maximum_octaves=min(max(maximum_octaves,0),16); + float nyquist=sample_spacing*2.0, wavelength=base_wavelength, final_weight=0.0, finest=0.0; int active=0; + [loop] for(int octave=0; octave=active) omitted+=amplitude; else if(index==active-1) omitted+=amplitude*(1.0-final_weight); amplitude*=abs(gain); + } + GameCultGeometryErosionBandSelection result; result.active_octaves=active; result.final_octave_weight=active>0?final_weight:0.0; result.finest_included_wavelength=finest; result.unresolved_height_bound=omitted; return result; +} + +float gamecult_geometry_erosion_hash_unit(int2 cell, uint salt) +{ + uint value = (uint)cell.x * 0x8da6b343u ^ (uint)cell.y * 0xd8163841u ^ salt; + value ^= value >> 16; value *= 0x7feb352du; value ^= value >> 15; value *= 0x846ca68bu; value ^= value >> 16; + return (value & 0x00ffffffu) * (1.0 / 16777216.0); +} +float2 gamecult_geometry_erosion_hash2(float2 input) +{ + int2 cell = (int2)input; + return float2(gamecult_geometry_erosion_hash_unit(cell, 0x68bc21ebu), gamecult_geometry_erosion_hash_unit(cell, 0x02e5be93u)) * 2.0 - 1.0; +} + +float4 gamecult_geometry_phacelle_noise(float2 position, float2 normal_direction, float frequency, float offset_cycles, float normalization) +{ + float2 side_direction = float2(-normal_direction.y, normal_direction.x) * frequency * 6.28318530718; + float offset = offset_cycles * 6.28318530718; + float2 cell = floor(position); + float2 local = frac(position); + float2 phase = 0.0; + float weight_sum = 0.0; + [unroll] for (int x = -1; x <= 2; x++) + [unroll] for (int y = -1; y <= 2; y++) + { + float2 grid_offset = float2(x, y); + float2 grid_point = cell + grid_offset; + float2 random_offset = gamecult_geometry_erosion_hash2(grid_point) * 0.5; + float2 delta = local - grid_offset - random_offset; + float weight = max(0.0, exp(-dot(delta, delta) * 2.0) - 0.01111); + weight_sum += weight; + float wave = dot(delta, side_direction) + offset; + phase += float2(cos(wave), sin(wave)) * weight; + } + float2 interpolated = phase / max(weight_sum, 1.0e-20); + float magnitude = max(1.0 - saturate(normalization), length(interpolated)); + return float4(interpolated / max(magnitude, 1.0e-20), side_direction); +} + +float gamecult_geometry_erosion_pow_inverse(float value, float power) { return 1.0 - pow(1.0 - saturate(value), power); } +float gamecult_geometry_erosion_ease_out(float value) { float inverse = 1.0 - saturate(value); return 1.0 - inverse * inverse; } +float gamecult_geometry_erosion_smooth_start(float value, float smoothing) +{ + return smoothing <= 1.0e-10 || value >= smoothing ? value - 0.5 * smoothing : 0.5 * value * value / smoothing; +} +float2 gamecult_geometry_erosion_safe_normalize(float2 value) +{ + float value_length = length(value); + return abs(value_length) > 1.0e-10 ? value / value_length : value; +} + +GameCultGeometryAdvancedErosionResult gamecult_geometry_advanced_erosion_filter_banded(float2 position, float3 base_height_and_slope, float fade_target, GameCultGeometryAdvancedErosionParameters p, GameCultGeometryErosionBandSelection band) +{ + float strength = p.strength * p.scale; + fade_target = clamp(fade_target, -1.0, 1.0); + float3 input_value = base_height_and_slope; + float3 height_and_slope = input_value; + float frequency = 1.0 / max(p.scale * p.cell_scale, 1.0e-10); + float2 slope = height_and_slope.yz; + float slope_length = max(length(slope), 1.0e-10); + float magnitude = 0.0; + float rounding_multiplier = 1.0; + float input_rounding = lerp(p.rounding.y, p.rounding.x, saturate(fade_target + 0.5)) * p.rounding.z; + float combined_mask = gamecult_geometry_erosion_ease_out(gamecult_geometry_erosion_smooth_start(slope_length * p.onset.x, input_rounding * p.onset.x)); + float ridge_mask = gamecult_geometry_erosion_ease_out(slope_length * p.onset.z); + float ridge_fade = fade_target; + float2 gully_slope = lerp(slope, slope / slope_length * p.assumed_slope.x, p.assumed_slope.y); + + int octave_count=min(min(max(p.octaves,0),8),max(band.active_octaves,0)); + [loop] for (int octave = 0; octave < octave_count; octave++) + { + float4 phacelle = gamecult_geometry_phacelle_noise(position * frequency, gamecult_geometry_erosion_safe_normalize(gully_slope), p.cell_scale, 0.25, p.normalization); + float2 derivative_direction = phacelle.zw * -frequency; + float sloping = abs(phacelle.y); + gully_slope += sign(phacelle.y) * derivative_direction * strength * p.gully_weight; + float3 gullies = float3(phacelle.x, phacelle.y * derivative_direction.x, phacelle.y * derivative_direction.y); + float3 faded = lerp(float3(fade_target, 0.0, 0.0), gullies * p.gully_weight, combined_mask); + float octave_weight=octave==octave_count-1?saturate(band.final_octave_weight):1.0; + height_and_slope += faded * strength * octave_weight; + magnitude += strength * octave_weight; + fade_target = faded.x; + float octave_rounding = lerp(p.rounding.y, p.rounding.x, saturate(phacelle.x + 0.5)) * rounding_multiplier; + float new_mask = gamecult_geometry_erosion_ease_out(gamecult_geometry_erosion_smooth_start(sloping * p.onset.y, octave_rounding * p.onset.y)); + combined_mask = gamecult_geometry_erosion_pow_inverse(combined_mask, p.detail) * new_mask; + ridge_fade = lerp(ridge_fade, lerp(ridge_fade, gullies.x, ridge_mask), octave_weight); + ridge_mask *= lerp(1.0, gamecult_geometry_erosion_ease_out(sloping * p.onset.w), octave_weight); + strength *= p.gain; + frequency *= p.lacunarity; + rounding_multiplier *= p.rounding.w; + } + + GameCultGeometryAdvancedErosionResult result; + result.delta = height_and_slope - input_value; + result.magnitude = magnitude; + result.ridge_map = ridge_fade * (1.0 - ridge_mask); + result.fade_target = fade_target; + return result; +} + +GameCultGeometryAdvancedErosionResult gamecult_geometry_advanced_erosion_filter(float2 position, float3 base_height_and_slope, float fade_target, GameCultGeometryAdvancedErosionParameters p) +{ + GameCultGeometryErosionBandSelection band; band.active_octaves=p.octaves; band.final_octave_weight=1.0; band.finest_included_wavelength=0.0; band.unresolved_height_bound=0.0; + return gamecult_geometry_advanced_erosion_filter_banded(position, base_height_and_slope, fade_target, p, band); +} + +#endif diff --git a/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl.meta b/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl.meta new file mode 100644 index 00000000..015c51c2 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3252ac02e8853ef4b9d1fbc5204b5c6b +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl b/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl new file mode 100644 index 00000000..fb159077 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl @@ -0,0 +1,14 @@ +#ifndef GAMECULT_GEOMETRY_HLSL +#define GAMECULT_GEOMETRY_HLSL + +#ifndef GAMECULT_GEOMETRY_CULTMATH_INCLUDE +#define GAMECULT_GEOMETRY_CULTMATH_INCLUDE "CultMath.hlsl" +#endif + +#include GAMECULT_GEOMETRY_CULTMATH_INCLUDE +#include "AdvancedErosionFilter.hlsl" +#include "Planetary.hlsl" +#include "SphericalErosion.hlsl" +#include "PlanetaryRadialRefinement.hlsl" + +#endif diff --git a/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl.meta b/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl.meta new file mode 100644 index 00000000..d685ccce --- /dev/null +++ b/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b253fa50ddd34bf3b69fb323fe6e00e8 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/GameCult.Geometry/Shaders/Planetary.hlsl b/src/GameCult.Geometry/Shaders/Planetary.hlsl new file mode 100644 index 00000000..8e16bfff --- /dev/null +++ b/src/GameCult.Geometry/Shaders/Planetary.hlsl @@ -0,0 +1,392 @@ +#ifndef GAMECULT_GEOMETRY_PLANETARY_HLSL +#define GAMECULT_GEOMETRY_PLANETARY_HLSL + +static const float GAMECULT_GEOMETRY_PLANETARY_QUARTER_PI = 0.78539816339744830962; + +float3 gamecult_geometry_planetary_face_direction(int face, float2 coordinate) +{ + float2 tangent = tan(coordinate * GAMECULT_GEOMETRY_PLANETARY_QUARTER_PI); + float3 cube = face == 0 ? float3(1.0, tangent.y, -tangent.x) + : face == 1 ? float3(-1.0, tangent.y, tangent.x) + : face == 2 ? float3(tangent.x, 1.0, -tangent.y) + : face == 3 ? float3(tangent.x, -1.0, tangent.y) + : face == 4 ? float3(tangent.x, tangent.y, 1.0) + : float3(-tangent.x, tangent.y, -1.0); + return cultmath_normalize(cube); +} + +float2 gamecult_geometry_planetary_face_coordinate(float3 direction, out int face) +{ + float3 d = cultmath_normalize(direction); + float3 a = abs(d); + float2 cube_uv; + if (a.x >= a.y && a.x >= a.z) + { + if (d.x >= 0.0) { face = 0; cube_uv = float2(-d.z, d.y) / a.x; } + else { face = 1; cube_uv = float2(d.z, d.y) / a.x; } + } + else if (a.y >= a.z) + { + if (d.y >= 0.0) { face = 2; cube_uv = float2(d.x, -d.z) / a.y; } + else { face = 3; cube_uv = float2(d.x, d.z) / a.y; } + } + else + { + if (d.z >= 0.0) { face = 4; cube_uv = d.xy / a.z; } + else { face = 5; cube_uv = float2(-d.x, d.y) / a.z; } + } + return atan(cube_uv) / GAMECULT_GEOMETRY_PLANETARY_QUARTER_PI; +} + +bool gamecult_geometry_planetary_page_local(float3 direction, float4 tile_address, float active, out float2 local) +{ + int face; + float2 uv = gamecult_geometry_planetary_face_coordinate(direction, face); + local = 0.0; + if (active < 0.5 || abs((float)face - tile_address.x) > 0.25) return false; + float axis_tiles = exp2(tile_address.y); + float2 scaled = (uv * 0.5 + 0.5) * axis_tiles; + float2 canonical = floor(min(scaled, axis_tiles - 1.0e-5)); + if (any(abs(canonical - tile_address.zw) > 0.25)) return false; + local = scaled - tile_address.zw; + return all(local >= 0.0) && all(local <= 1.0); +} + +float3 gamecult_geometry_planetary_surface_normal(float3 direction, float3 world_distance_tangent_gradient) +{ + direction = cultmath_normalize(direction); + float3 tangent_gradient = world_distance_tangent_gradient - direction * dot(world_distance_tangent_gradient, direction); + return cultmath_normalize(direction - tangent_gradient); +} + +float2 gamecult_geometry_planetary_equirectangular_forward(float3 direction) +{ + direction = cultmath_normalize(direction); + return float2(atan2(direction.y, direction.x) / CULTMATH_PI, asin(clamp(direction.z, -1.0, 1.0)) / CULTMATH_HALF_PI); +} + +float3 gamecult_geometry_planetary_equirectangular_inverse(float2 coordinate) +{ + float longitude = coordinate.x * CULTMATH_PI; + float latitude = coordinate.y * CULTMATH_HALF_PI; + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A1 = 1.340264; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A2 = -0.081106; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A3 = 0.000893; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A4 = 0.003796; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_M = 0.8660254037844386; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_X_MAX = 2.706629; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_Y_MAX = 1.3173628; + +float gamecult_geometry_equal_earth_y(float theta) +{ + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + return theta * (GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2)); +} + +float2 gamecult_geometry_planetary_equal_earth_forward(float3 direction) +{ + direction = cultmath_normalize(direction); + float longitude = atan2(direction.y, direction.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + float theta = asin(GAMECULT_GEOMETRY_EQUAL_EARTH_M * sin(latitude)); + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float denominator = GAMECULT_GEOMETRY_EQUAL_EARTH_M * (GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + 3.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + 9.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2)); + return float2(longitude * cos(theta) / denominator / GAMECULT_GEOMETRY_EQUAL_EARTH_X_MAX, gamecult_geometry_equal_earth_y(theta) / GAMECULT_GEOMETRY_EQUAL_EARTH_Y_MAX); +} + +float3 gamecult_geometry_planetary_equal_earth_inverse(float2 coordinate) +{ + float target_y = coordinate.y * GAMECULT_GEOMETRY_EQUAL_EARTH_Y_MAX; + float theta = target_y / GAMECULT_GEOMETRY_EQUAL_EARTH_A1; + [unroll] for (int iteration = 0; iteration < 8; iteration++) + { + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float derivative = GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + 3.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + 9.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2); + theta -= (gamecult_geometry_equal_earth_y(theta) - target_y) / derivative; + } + float latitude = asin(clamp(sin(theta) / GAMECULT_GEOMETRY_EQUAL_EARTH_M, -1.0, 1.0)); + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float denominator = GAMECULT_GEOMETRY_EQUAL_EARTH_M * (GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + 3.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + 9.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2)); + float longitude = coordinate.x * GAMECULT_GEOMETRY_EQUAL_EARTH_X_MAX * denominator / cos(theta); + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +float gamecult_geometry_planetary_wrap_longitude(float longitude) +{ + longitude = fmod(longitude, CULTMATH_TAU); + if (longitude > CULTMATH_PI) longitude -= CULTMATH_TAU; + if (longitude < -CULTMATH_PI) longitude += CULTMATH_TAU; + return longitude; +} + +float3 gamecult_geometry_planetary_lon_lat_direction(float longitude, float latitude) +{ + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +float gamecult_geometry_planetary_asinh(float value) { return log(value + sqrt(value * value + 1.0)); } +float gamecult_geometry_planetary_sinh(float value) { return 0.5 * (exp(value) - exp(-value)); } + +bool gamecult_geometry_planetary_azimuthal_forward(float3 direction, float2 center_lon_lat, int kind, out float2 coordinate) +{ + direction = cultmath_normalize(direction); + float longitude = gamecult_geometry_planetary_wrap_longitude(atan2(direction.y, direction.x) - center_lon_lat.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + float sin0 = sin(center_lon_lat.y), cos0 = cos(center_lon_lat.y); + float sin_latitude = sin(latitude), cos_latitude = cos(latitude), cos_longitude = cos(longitude); + float cos_c = clamp(sin0 * sin_latitude + cos0 * cos_latitude * cos_longitude, -1.0, 1.0); + float2 raw = float2(cos_latitude * sin(longitude), cos0 * sin_latitude - sin0 * cos_latitude * cos_longitude); + float factor = 1.0; + if (kind == 3) + { + if (cos_c < 0.0) { coordinate = 0.0; return false; } + } + else if (kind == 4) + { + float c = acos(cos_c); factor = (c < 1.0e-6 ? 1.0 : c / sin(c)) / CULTMATH_PI; + } + else if (kind == 5) factor = sqrt(2.0 / max(1.0 + cos_c, 1.0e-20)) * 0.5; + else + { + if (cos_c <= 0.0) { coordinate = 0.0; return false; } + factor = 1.0 / cos_c; + } + coordinate = raw * factor; + return true; +} + +bool gamecult_geometry_planetary_azimuthal_inverse(float2 coordinate, float2 center_lon_lat, int kind, out float3 direction) +{ + float rho = length(coordinate); + float c; + if (kind == 3) { if (rho > 1.0) { direction = 0.0; return false; } c = asin(clamp(rho, 0.0, 1.0)); } + else if (kind == 4) { if (rho > 1.0) { direction = 0.0; return false; } c = rho * CULTMATH_PI; } + else if (kind == 5) { if (rho > 1.0) { direction = 0.0; return false; } c = 2.0 * asin(clamp(rho, 0.0, 1.0)); } + else c = atan(rho); + if (rho < 1.0e-6) { direction = gamecult_geometry_planetary_lon_lat_direction(center_lon_lat.x, center_lon_lat.y); return true; } + float sin_c = sin(c), cos_c = cos(c), sin0 = sin(center_lon_lat.y), cos0 = cos(center_lon_lat.y); + float latitude = asin(clamp(cos_c * sin0 + coordinate.y * sin_c * cos0 / rho, -1.0, 1.0)); + float delta_longitude = atan2(coordinate.x * sin_c, rho * cos0 * cos_c - coordinate.y * sin0 * sin_c); + direction = gamecult_geometry_planetary_lon_lat_direction(gamecult_geometry_planetary_wrap_longitude(delta_longitude + center_lon_lat.x), latitude); + return true; +} + +bool gamecult_geometry_planetary_cube_atlas_forward(float3 direction, out float2 coordinate) +{ + int face; + float2 face_coordinate = gamecult_geometry_planetary_face_coordinate(direction, face); + int column = face % 3; + int row = face / 3; + coordinate = float2(-1.0 + (column + (face_coordinate.x + 1.0) * 0.5) * (2.0 / 3.0), 1.0 - (row + (face_coordinate.y + 1.0) * 0.5)); + return true; +} + +bool gamecult_geometry_planetary_cube_atlas_inverse(float2 coordinate, out float3 direction) +{ + if (any(coordinate < -1.0) || any(coordinate > 1.0)) { direction = 0.0; return false; } + int column = min((int)((coordinate.x + 1.0) * 1.5), 2); + int row = min((int)(1.0 - coordinate.y), 1); + float u = ((coordinate.x + 1.0) * 1.5 - column) * 2.0 - 1.0; + float v = ((1.0 - coordinate.y) - row) * 2.0 - 1.0; + direction = gamecult_geometry_planetary_face_direction(row * 3 + column, float2(u, v)); + return true; +} + +bool gamecult_geometry_planetary_projection_forward(float3 direction, int kind, float3 center_lon_lat_scale, out float2 coordinate) +{ + float scale = max(center_lon_lat_scale.z, 1.0e-20); + if (kind == 6) + { + bool valid = gamecult_geometry_planetary_cube_atlas_forward(direction, coordinate); + coordinate /= scale; return valid; + } + if (kind >= 3) + { + bool valid = gamecult_geometry_planetary_azimuthal_forward(direction, center_lon_lat_scale.xy, kind, coordinate); + coordinate /= scale; return valid; + } + direction = cultmath_normalize(direction); + float longitude = gamecult_geometry_planetary_wrap_longitude(atan2(direction.y, direction.x) - center_lon_lat_scale.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + if (kind == 0) coordinate = float2(longitude / CULTMATH_PI, latitude / CULTMATH_HALF_PI); + else if (kind == 1) + { + const float maximum_latitude = 1.4844222297453324; + if (abs(latitude) > maximum_latitude) { coordinate = 0.0; return false; } + coordinate = float2(longitude / CULTMATH_PI, gamecult_geometry_planetary_asinh(tan(latitude)) / CULTMATH_PI); + } + else + { + float3 centered = gamecult_geometry_planetary_lon_lat_direction(longitude, latitude); + coordinate = gamecult_geometry_planetary_equal_earth_forward(centered); + } + coordinate /= scale; + return true; +} + +bool gamecult_geometry_planetary_projection_inverse(float2 coordinate, int kind, float3 center_lon_lat_scale, out float3 direction) +{ + coordinate *= max(center_lon_lat_scale.z, 1.0e-20); + if (kind == 6) return gamecult_geometry_planetary_cube_atlas_inverse(coordinate, direction); + if (kind >= 3) return gamecult_geometry_planetary_azimuthal_inverse(coordinate, center_lon_lat_scale.xy, kind, direction); + if (any(abs(coordinate) > 1.0)) { direction = 0.0; return false; } + float longitude, latitude; + if (kind == 0) { longitude = coordinate.x * CULTMATH_PI; latitude = coordinate.y * CULTMATH_HALF_PI; } + else if (kind == 1) { longitude = coordinate.x * CULTMATH_PI; latitude = atan(gamecult_geometry_planetary_sinh(coordinate.y * CULTMATH_PI)); } + else + { + float3 centered = gamecult_geometry_planetary_equal_earth_inverse(coordinate); + longitude = atan2(centered.y, centered.x); latitude = asin(clamp(centered.z, -1.0, 1.0)); + } + direction = gamecult_geometry_planetary_lon_lat_direction(gamecult_geometry_planetary_wrap_longitude(longitude + center_lon_lat_scale.x), latitude); + return true; +} + +struct GameCultGeometryPlanetaryFieldDefinition +{ + float radius; + int seed; + GameCultGeometryAdvancedErosionParameters erosion; +}; + +struct GameCultGeometryPlanetaryBaseFieldSample +{ + float radial_displacement; + float3 radial_gradient; + float field_value; + float3 field_gradient; + float fade_target; +}; + +struct GameCultGeometryPlanetarySurfaceSample +{ + float radial_displacement; + float3 tangent_gradient; + float3 surface_normal; + float slope; + float ridge; + float gully; + float finest_resolved_wavelength; + float unresolved_height_bound; +}; + +struct GameCultGeometryPlanetaryPageSample +{ + float4 height_gradient; + float2 masks; +}; + +GameCultGeometryPlanetaryPageSample gamecult_geometry_planetary_page_lerp( + GameCultGeometryPlanetaryPageSample a, + GameCultGeometryPlanetaryPageSample b, + GameCultGeometryPlanetaryPageSample c, + GameCultGeometryPlanetaryPageSample d, + float2 fraction) +{ + GameCultGeometryPlanetaryPageSample result; + result.height_gradient = lerp(lerp(a.height_gradient, b.height_gradient, fraction.x), lerp(c.height_gradient, d.height_gradient, fraction.x), fraction.y); + result.masks = lerp(lerp(a.masks, b.masks, fraction.x), lerp(c.masks, d.masks, fraction.x), fraction.y); + return result; +} + +GameCultGeometryPlanetaryPageSample gamecult_geometry_planetary_residual_sample( + GameCultGeometryPlanetarySurfaceSample child, + GameCultGeometryPlanetarySurfaceSample parent, + bool has_parent) +{ + GameCultGeometryPlanetaryPageSample result; + result.height_gradient = float4(child.radial_displacement, child.tangent_gradient); + if (has_parent) result.height_gradient -= float4(parent.radial_displacement, parent.tangent_gradient); + result.masks = float2(child.ridge, child.gully); + return result; +} + +float4 gamecult_geometry_planetary_summary_empty() +{ + return float4(3.402823466e+38, -3.402823466e+38, 0.0, 0.0); +} + +float4 gamecult_geometry_planetary_summary_accumulate(float4 summary, float4 height_gradient, float unresolved_height) +{ + summary.x = min(summary.x, height_gradient.x); + summary.y = max(summary.y, height_gradient.x); + summary.z = max(summary.z, length(height_gradient.yzw)); + summary.w = max(summary.w, max(unresolved_height, 0.0)); + return summary; +} + +float gamecult_geometry_planetary_pow4(float value) { float square = value * value; return square * square; } + +GameCultGeometryPlanetarySurfaceSample gamecult_geometry_planetary_field_sample( + GameCultGeometryPlanetaryFieldDefinition definition, + float3 unit_direction, + GameCultGeometryPlanetaryBaseFieldSample base_sample, + float footprint_meters) +{ + float3 direction = cultmath_normalize(unit_direction); + float3 radial_gradient = base_sample.radial_gradient - direction * dot(base_sample.radial_gradient, direction); + float3 field_gradient = base_sample.field_gradient - direction * dot(base_sample.field_gradient, direction); + GameCultGeometryAdvancedErosionParameters p = definition.erosion; + GameCultGeometryErosionBandSelection band = gamecult_geometry_select_erosion_bands( + p.scale * p.cell_scale, footprint_meters, p.octaves, p.lacunarity, + p.strength * p.scale, p.gain, 2.0); + float3 powers = float3( + gamecult_geometry_planetary_pow4(abs(direction.x)), + gamecult_geometry_planetary_pow4(abs(direction.y)), + gamecult_geometry_planetary_pow4(abs(direction.z))); + float power_sum = max(powers.x + powers.y + powers.z, 1.0e-6); + float3 weights = powers / power_sum; + float3 world = direction * definition.radius; + float seed = (float)definition.seed; + GameCultGeometryAdvancedErosionResult xy = gamecult_geometry_advanced_erosion_filter_banded( + world.xy + float2(713.0 + seed * 0.754877666, -291.0 + seed * 0.569840296), + float3(base_sample.field_value, field_gradient.x, field_gradient.y), base_sample.fade_target, p, band); + GameCultGeometryAdvancedErosionResult yz = gamecult_geometry_advanced_erosion_filter_banded( + world.yz + float2(-431.0 + seed * 0.438289027, 887.0 + seed * 0.328438163), + float3(base_sample.field_value, field_gradient.y, field_gradient.z), base_sample.fade_target, p, band); + GameCultGeometryAdvancedErosionResult zx = gamecult_geometry_advanced_erosion_filter_banded( + world.zx + float2(197.0 + seed * 0.219783071, 557.0 + seed * 0.145898034), + float3(base_sample.field_value, field_gradient.z, field_gradient.x), base_sample.fade_target, p, band); + float erosion_height = xy.delta.x * weights.z + yz.delta.x * weights.x + zx.delta.x * weights.y; + float3 erosion_gradient = float3( + xy.delta.y * weights.z + zx.delta.z * weights.y, + xy.delta.z * weights.z + yz.delta.y * weights.x, + yz.delta.z * weights.x + zx.delta.y * weights.y); + float3 power_gradient_x = float3(4.0 * direction.x * direction.x * direction.x / definition.radius, 0.0, 0.0); + float3 power_gradient_y = float3(0.0, 4.0 * direction.y * direction.y * direction.y / definition.radius, 0.0); + float3 power_gradient_z = float3(0.0, 0.0, 4.0 * direction.z * direction.z * direction.z / definition.radius); + power_gradient_x -= direction * dot(power_gradient_x, direction); + power_gradient_y -= direction * dot(power_gradient_y, direction); + power_gradient_z -= direction * dot(power_gradient_z, direction); + float3 power_sum_gradient = power_gradient_x + power_gradient_y + power_gradient_z; + float inverse_power_sum_squared = 1.0 / (power_sum * power_sum); + float3 weight_gradient_x = (power_gradient_x * power_sum - power_sum_gradient * powers.x) * inverse_power_sum_squared; + float3 weight_gradient_y = (power_gradient_y * power_sum - power_sum_gradient * powers.y) * inverse_power_sum_squared; + float3 weight_gradient_z = (power_gradient_z * power_sum - power_sum_gradient * powers.z) * inverse_power_sum_squared; + erosion_gradient += yz.delta.x * weight_gradient_x + zx.delta.x * weight_gradient_y + xy.delta.x * weight_gradient_z; + float3 gradient = radial_gradient + erosion_gradient; + gradient -= direction * dot(gradient, direction); + float ridge_evidence = xy.ridge_map * weights.z + yz.ridge_map * weights.x + zx.ridge_map * weights.y; + GameCultGeometryPlanetarySurfaceSample result; + result.radial_displacement = base_sample.radial_displacement + erosion_height; + result.tangent_gradient = gradient; + result.surface_normal = gamecult_geometry_planetary_surface_normal(direction, gradient); + result.slope = length(gradient); + result.ridge = saturate(ridge_evidence * 0.5 + 0.5); + result.gully = saturate(0.5 - ridge_evidence * 0.5); + result.finest_resolved_wavelength = band.finest_included_wavelength; + result.unresolved_height_bound = band.unresolved_height_bound; + return result; +} + +#endif diff --git a/src/GameCult.Geometry/Shaders/Planetary.hlsl.meta b/src/GameCult.Geometry/Shaders/Planetary.hlsl.meta new file mode 100644 index 00000000..009c2989 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/Planetary.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: bb055ad5328888e479e90ae3fe62c3fe +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl b/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl new file mode 100644 index 00000000..20f3e2e5 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl @@ -0,0 +1,27 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#ifndef GAMECULT_GEOMETRY_PLANETARY_RADIAL_REFINEMENT_HLSL +#define GAMECULT_GEOMETRY_PLANETARY_RADIAL_REFINEMENT_HLSL + +float3 gamecult_geometry_planetary_radial_refinement_step( + float3 position, + float3 ray_direction, + float3 center, + float target_radius, + float minimum_derivative, + float maximum_correction) +{ + float3 local=position-center; + float radius=length(local); + float3 radial=local/max(radius,0.0001); + float error=radius-target_radius; + float derivative=dot(ray_direction,radial); + float safe_minimum=max(minimum_derivative,0.0001); + float safe_derivative=abs(derivative)>safe_minimum?derivative:(derivative<0?-safe_minimum:safe_minimum); + float correction=clamp(-error/safe_derivative,-abs(maximum_correction),abs(maximum_correction)); + return position+ray_direction*correction; +} + +#endif diff --git a/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl.meta b/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl.meta new file mode 100644 index 00000000..28d629b1 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 356389371b854e12b9d419f7ee310d81 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl b/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl new file mode 100644 index 00000000..107ba24b --- /dev/null +++ b/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl @@ -0,0 +1,80 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#ifndef GAMECULT_GEOMETRY_SPHERICAL_EROSION_HLSL +#define GAMECULT_GEOMETRY_SPHERICAL_EROSION_HLSL + +struct GameCultGeometrySphericalErosionParameters +{ + float frequency; float amplitude; float lacunarity; float gain; + float slope_strength; float detail; float seed; int octaves; +}; + +struct GameCultGeometrySphericalErosionSample +{ + float height_offset; float ridge; float gully; float3 gradient; +}; + +float gamecult_geometry_spherical_cell_wave(float3 position, float3 flow, float3 cell, float seed) +{ + float3 pivot = cell + float3(cultmath_hash(cell + seed + 11.7), cultmath_hash(cell.yzx + seed + 37.1), cultmath_hash(cell.zyx + seed + 73.9)); + return dot(position - pivot, flow) * 6.28318530718 + cultmath_hash(cell + seed + 101.3) * 6.28318530718; +} + +float gamecult_geometry_spherical_directional_wave(float3 position, float3 flow, float seed) +{ + float3 cell = floor(position); + float3 local = cultmath_frac(position); + float3 weights = local * local * (3.0 - 2.0 * local); + float z0 = cultmath_lerp( + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,0,0), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,0,0), seed), weights.x), + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,1,0), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,1,0), seed), weights.x), weights.y); + float z1 = cultmath_lerp( + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,0,1), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,0,1), seed), weights.x), + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,1,1), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,1,1), seed), weights.x), weights.y); + return cultmath_lerp(z0, z1, weights.z); +} + +float3 gamecult_geometry_spherical_stable_tangent(float3 position) +{ + float3 axis = abs(position.z) < 0.8 ? float3(0,0,1) : float3(0,1,0); + return cultmath_normalize(cross(axis, position)); +} + +GameCultGeometrySphericalErosionSample gamecult_geometry_spherical_erosion(float3 unit_position, float base_height, float3 base_gradient, GameCultGeometrySphericalErosionParameters p) +{ + float3 position = cultmath_normalize(unit_position); + float3 gradient = base_gradient - position * dot(base_gradient, position); + float slope = length(gradient); + float3 flow = slope > 1.0e-6 ? -gradient / slope : gamecult_geometry_spherical_stable_tangent(position); + float frequency = max(p.frequency, 0.001); + float amplitude = p.amplitude; + float combi_mask = 1.0 - pow(1.0 - cultmath_saturate(slope * p.slope_strength), 2.0); + float fade_target = cultmath_saturate(base_height * 0.5 + 0.5) * 2.0 - 1.0; + GameCultGeometrySphericalErosionSample result = (GameCultGeometrySphericalErosionSample)0; + [loop] for (int octave = 0; octave < min(max(p.octaves, 0), 8); octave++) + { + float phase = gamecult_geometry_spherical_directional_wave(position * frequency, flow, p.seed + octave * 19.19); + float wave = cos(phase); + float wave_slope = sin(phase); + float faded = cultmath_lerp(fade_target, wave, combi_mask); + result.height_offset += faded * amplitude; + result.ridge = max(result.ridge, cultmath_saturate(faded)); + result.gully = max(result.gully, cultmath_saturate(-faded)); + float3 across = cultmath_normalize(cross(position, flow)); + gradient += across * (sign(wave_slope) * amplitude * frequency * combi_mask); + gradient -= position * dot(gradient, position); + slope = length(gradient); + flow = slope > 1.0e-6 ? -gradient / slope : gamecult_geometry_spherical_stable_tangent(position); + float new_mask = 1.0 - pow(1.0 - cultmath_saturate(abs(wave_slope) * 1.35), 2.0); + combi_mask = (1.0 - pow(1.0 - cultmath_saturate(combi_mask), max(p.detail, 0.001))) * new_mask; + fade_target = faded; + frequency *= max(p.lacunarity, 1.01); + amplitude *= cultmath_saturate(p.gain); + } + result.gradient = gradient; + return result; +} + +#endif diff --git a/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl.meta b/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl.meta new file mode 100644 index 00000000..7b7ddc79 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 43d458fc5b6b441a941e37001df919e3 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/GameCult.Geometry/THIRD-PARTY-NOTICES.md b/src/GameCult.Geometry/THIRD-PARTY-NOTICES.md new file mode 100644 index 00000000..3388b601 --- /dev/null +++ b/src/GameCult.Geometry/THIRD-PARTY-NOTICES.md @@ -0,0 +1,24 @@ +# GameCult.Geometry source provenance + +## CultMath planetary geometry + +The C# files under `Planetary/` and the HLSL files under `Shaders/` are licensed +under the Mozilla Public License 2.0 and retain MPL-2.0 notices. CultMath owns +the numeric primitives used by these geometry systems; GameCult.Geometry owns +their spatial meaning. + +## Advanced Terrain Erosion Filter + +`Planetary/AdvancedErosionFilter.cs` and `Shaders/AdvancedErosionFilter.hlsl` +adapt the mathematical procedure from +Rune Skovbo Johansen's **Advanced Terrain Erosion Filter**. + +- Copyright 2025 Rune Skovbo Johansen. +- Upstream: +- Explanation: +- License: Mozilla Public License 2.0, + +The implementation includes C#/HLSL type lowering, bounded octave counts, +finite zero-rounding behavior, and a shared integer avalanche hash for CPU/GPU +cell-selection parity. The technique builds on earlier work credited upstream +to Fewes and Clay John; the hash form follows public shader math by Inigo Quilez. diff --git a/src/GameCult.Geometry/docs/planetary.md b/src/GameCult.Geometry/docs/planetary.md new file mode 100644 index 00000000..94eab553 --- /dev/null +++ b/src/GameCult.Geometry/docs/planetary.md @@ -0,0 +1,135 @@ +# Planetary Field Runtime + +GameCult.Geometry owns the deterministic, renderer-independent planetary field. The same +definition can be queried by a CPU simulation, baked into residual pages, +sampled through a map projection, or evaluated by HLSL. Render resources and +gameplay interpretation remain consumer responsibilities. + +## Authority + +- `PlanetaryFieldDefinition` identifies radius, seed, erosion parameters, and a + version that includes the consumer-owned base-field version. +- `IPlanetaryBaseField` supplies deterministic application-authored base + evidence without making Geometry depend on an application. +- `PlanetaryQueryScale` makes the physical footprint and accepted unresolved + error explicit. There is deliberately no scale-free authoritative + `HeightAt` call. +- `PlanetarySurfaceSample` carries displacement, tangent gradient, normal, + slope, ridge/gully evidence, resolved wavelength, and unresolved bound. +- pages, residency, patches, and map tiles are disposable lowerings of that + contract. + +The C# surface targets .NET and Unity-compatible `netstandard2.1`. The matching +HLSL lives in `Shaders/Planetary.hlsl`. Consumers include +`Shaders/GameCult.Geometry.hlsl`, which imports CultMath's numeric mirror before +the geometry kernels. + +## Point and batch queries + +```csharp +var erosion = AdvancedErosionParameters.Default with +{ + Scale = radius * 0.075f, + Strength = 0.12f, + Octaves = 7, +}; +var field = PlanetaryFieldDefinition.Create( + baseFieldVersion: 17, + radius, + seed: 42, + erosion); +var scale = PlanetaryQueryScale.AtFootprint( + footprintMeters: 10, + maximumUnresolvedHeight: 0.5f); + +PlanetaryBaseFieldSample basis = source.Sample(direction); +PlanetarySurfaceSample sample = PlanetaryField.Sample( + field, direction, basis, scale); +``` + +`PlanetaryField.SampleBatch` writes into a caller-owned span. `SamplePosition` +accepts `double3`, reduces the astronomical position to a stable unit direction, +and then evaluates the same float field contract. + +Higher-level CPU queries include bounded ray intersection, segment clearance, +great-circle profiles, and region summaries. Their answers remain field queries; +only a consuming simulation can turn them into committed game consequences. + +## Cube-sphere topology and pages + +`PlanetaryTopology` owns the tangent/QSC cube-sphere mapping, inverse face +coordinates, tile selection, local page coordinates, and radial-graph surface +normal. `PlanetaryPageSampling` owns bordered directions, nominal spacing, +bilinear sampling, and summaries. + +`PlanetaryPageBaker` evaluates a root page at its physical sample spacing. A +child stores `child filtered field - parent filtered field`. A +`PlanetaryPageSetSampling` query adds every containing ancestor contribution +and applies transition weights only to residuals. + +`PlanetaryLodSelector` selects six roots plus one camera-facing ancestor chain +from the unresolved-height error. `PlanetaryResidualResidency` owns arrival and +departure presentation weights, content and presentation versions, and nothing +about field truth. + +## Patches + +`PlanetaryPatch.CreateFace` and `CreateCubeSphere` produce renderer-neutral +coarse meshes from the canonical topology. HLSL calls +`gamecult_geometry_planetary_face_direction` for procedural vertices and +`gamecult_geometry_planetary_radial_refinement_step` for bounded pixel refinement. + +Renderers still own cameras, clip space, depth, materials, buffers, dispatch, +and draw submission. + +`PlanetaryGpuPageBuilder` owns the renderer-neutral page input and metadata +payload. Host adapters may convert its `float4` values to native vector types, +but do not recalculate directions, spacing, addressing, or transition state. + +The `GameCult.Geometry.Unity` assembly in `org.gamecult.geometry` converts a canonical face patch into a +Unity `Mesh` and converts the shared GPU payload into Unity-native structs. It +owns no field parameters, page selection, or simulation state. The package's +Planetary Field Viewer sample renders the shared HLSL field and reports matching +CPU queries. Unity render code remains responsible for allocating/binding +graphics buffers and dispatching application-specific base-field entry points. +The host Unity project must set `-langversion:latest` in `Assets/csc.rsp`. + +## Maps + +`PlanetaryProjection` supplies forward and inverse transforms for: + +- equirectangular; +- Web Mercator with an explicit polar cutoff; +- Equal Earth; +- orthographic; +- azimuthal equidistant and equal-area; +- cube atlas; +- local tangent/gnomonic views. + +`PlanetaryMapTileBaker` maps each valid tile sample through the inverse +projection and canonical field query. `PlanetaryMapTileKey` includes field, +projection, layer, layout, and query-scale identity. Palettes, contours, labels, +and strategic overlays do not enter GameCult.Geometry's canonical field. + +`PlanetaryMapTileEncoding` writes the resulting evidence as a versioned +little-endian `CMPT` binary asset. +`packages/gamecult-geometry-ts/src/planetary-tile.ts` decodes that exact format +into browser-native values without defining another terrain generator. +Cross-runtime fixtures are generated by +`tools/GameCult.Geometry.PlanetaryTileFixture`. +Typed CultMesh documents should carry the tile key and asset reference; the +binary payload is derived CDN/cache data, not committed game state. + +## Numeric contract + +Integer hashes and published field versions are exact. Basic arithmetic follows +CultMath's matched C#/HLSL semantics. Hardware transcendental functions such as +`atan` and `tan` use measured tolerances rather than fictional bit identity. +Every filtered terrain query carries an unresolved-height bound so consumers can +reject insufficient fidelity explicitly. + +Current D3D12 parity evidence covers the advanced erosion kernel, QSC topology, +and forward/inverse transforms for every supported map projection, including +center and scale parameters. Production page and patch shaders use the common +functions and retain their seam, residual, summary, and radial-hit integration +probes in Fensalir. diff --git a/tests/GameCult.Geometry.Planetary.Tests/AdvancedErosionFilterTests.cs b/tests/GameCult.Geometry.Planetary.Tests/AdvancedErosionFilterTests.cs new file mode 100644 index 00000000..de5ccfb0 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/AdvancedErosionFilterTests.cs @@ -0,0 +1,47 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class AdvancedErosionFilterTests +{ + [Fact] + public void DefaultReferenceFixtureIsStable() + { + var result = AdvancedErosionFilter.Sample( + new float2(0.42f, 0.31f), + new float3(0.5f, 0.1f, -0.2f), + 0.0f, + AdvancedErosionParameters.Default); + + Assert.Equal(0.0639375f, result.Magnitude, precision: 7); + Assert.True(float.IsFinite(result.Delta.x)); + Assert.True(float.IsFinite(result.Delta.y)); + Assert.True(float.IsFinite(result.Delta.z)); + Assert.InRange(result.RidgeMap, -1.0f, 1.0f); + } + + [Fact] + public void FlatInputAndZeroRoundingRemainFinite() + { + var parameters = AdvancedErosionParameters.Default with { Rounding = float4.zero }; + var result = AdvancedErosionFilter.Sample(new float2(0.25f, 0.75f), float3.zero, 0.0f, parameters); + + Assert.True(float.IsFinite(result.Delta.x)); + Assert.True(float.IsFinite(result.RidgeMap)); + Assert.True(float.IsFinite(result.FadeTarget)); + } + + [Fact] + public void PhacellePartialNormalizationIsFiniteAtEndpoints() + { + foreach (var normalization in new[] { 0.0f, 0.5f, 1.0f }) + { + var sample = AdvancedErosionFilter.Phacelle(new float2(1.7f, -3.2f), new float2(0.6f, -0.8f), 0.7f, 0.25f, normalization); + Assert.True(float.IsFinite(sample.x)); + Assert.True(float.IsFinite(sample.y)); + } + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/ErosionFrequencyBandsTests.cs b/tests/GameCult.Geometry.Planetary.Tests/ErosionFrequencyBandsTests.cs new file mode 100644 index 00000000..b17a2292 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/ErosionFrequencyBandsTests.cs @@ -0,0 +1,37 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class ErosionFrequencyBandsTests +{ + [Fact] + public void RadiusIndependentWorldWavelengthSelectsSameBands() + { + var small = ErosionFrequencyBands.Select(1_000.0f, 100.0f, 8, 2.0f, 20.0f, 0.5f); + var large = ErosionFrequencyBands.Select(1_000.0f, 100.0f, 8, 2.0f, 20.0f, 0.5f); + Assert.Equal(small, large); + Assert.Equal(3, small.ActiveOctaves); + } + + [Fact] + public void TransitionPartiallyWeightsLastResolvableOctave() + { + var selection = ErosionFrequencyBands.Select(1_000.0f, 50.0f, 8, 2.0f, 1.0f, 0.5f); + Assert.Equal(4, selection.ActiveOctaves); + Assert.InRange(selection.FinalOctaveWeight, 0.0f, 1.0f); + Assert.True(selection.UnresolvedHeightBound > 0.0f); + } + + [Fact] + public void BandedFilterPreservesLowOctaveResultExactly() + { + var p = AdvancedErosionParameters.Default; + var band = new ErosionBandSelection(2, 1.0f, 0.0f, 0.0f); + var banded = AdvancedErosionFilter.Sample(new float2(0.42f, 0.31f), new float3(0.5f, 0.1f, -0.2f), 0.0f, p, band); + var truncated = AdvancedErosionFilter.Sample(new float2(0.42f, 0.31f), new float3(0.5f, 0.1f, -0.2f), 0.0f, p with { Octaves = 2 }); + Assert.Equal(truncated, banded); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/GameCult.Geometry.Planetary.Tests.csproj b/tests/GameCult.Geometry.Planetary.Tests/GameCult.Geometry.Planetary.Tests.csproj new file mode 100644 index 00000000..b2511182 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/GameCult.Geometry.Planetary.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryFieldTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryFieldTests.cs new file mode 100644 index 00000000..ba934f33 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryFieldTests.cs @@ -0,0 +1,83 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryFieldTests +{ + private static readonly PlanetaryFieldDefinition Definition = new( + 0x7f59_6d11_02ab_9c31UL, + 8.4f, + 42, + AdvancedErosionParameters.Default with { Scale = 0.63f, Strength = 0.12f, Octaves = 7 }); + + [Fact] + public void SampleIsDeterministicScaleAwareAndTangent() + { + var direction = math.normalize(new float3(0.31f, -0.72f, 0.61f)); + var source = new FixtureField(); + var coarse = PlanetaryField.Sample(Definition, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(0.1f)); + var fine = PlanetaryField.Sample(Definition, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(0.01f)); + var repeated = PlanetaryField.Sample(Definition, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(0.01f)); + + Assert.Equal(fine, repeated); + Assert.Equal(Definition.FieldVersion, fine.FieldVersion); + Assert.True(fine.UnresolvedHeightBound <= coarse.UnresolvedHeightBound); + Assert.True(fine.FinestResolvedWavelength <= coarse.FinestResolvedWavelength); + Assert.InRange(MathF.Abs(math.dot(fine.UnitDirection, fine.TangentGradient)), 0, 2.0e-5f); + Assert.Equal(1, math.length(fine.SurfaceNormal), 5); + } + + [Fact] + public void BatchAndDoublePositionUseThePointContract() + { + var directions = new[] + { + math.normalize(new float3(1, 2, 3)), + math.normalize(new float3(-2, 0.5f, 1)), + math.normalize(new float3(0.1f, -0.2f, -1)), + }; + var output = new PlanetarySurfaceSample[directions.Length]; + var source = new FixtureField(); + var scale = PlanetaryQueryScale.AtFootprint(0.05f); + PlanetaryField.SampleBatch(Definition, directions, scale, source, output); + + for (var i = 0; i < directions.Length; i++) + { + var normalized = math.normalize(directions[i]); + var expected = PlanetaryField.Sample(Definition, normalized, source.Sample(normalized), scale); + Assert.InRange(MathF.Abs(expected.RadialDisplacement - output[i].RadialDisplacement), 0, 1.0e-6f); + Assert.InRange(math.distance(expected.TangentGradient, output[i].TangentGradient), 0, 1.0e-6f); + } + + var d = directions[0]; + var position = new double3(d.x * 8.0e11, d.y * 8.0e11, d.z * 8.0e11); + var positioned = PlanetaryField.SamplePosition(Definition, position, scale, source); + Assert.InRange(math.distance(positioned.UnitDirection, d), 0, 2.0e-6f); + } + + [Fact] + public void FieldIdentityChangesWithEveryLoadBearingInput() + { + var erosion = AdvancedErosionParameters.Default; + var baseline = PlanetaryFieldDefinition.Create(3, 8.4f, 7, erosion); + Assert.Equal(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.4f, 7, erosion).FieldVersion); + Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(4, 8.4f, 7, erosion).FieldVersion); + Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.5f, 7, erosion).FieldVersion); + Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.4f, 8, erosion).FieldVersion); + Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.4f, 7, erosion with { Gain = 0.49f }).FieldVersion); + } + + private readonly struct FixtureField : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 direction) + { + var value = direction.x * 0.6f + direction.y * direction.z * 0.3f; + var fieldGradient = new float3(0.6f, direction.z * 0.3f, direction.y * 0.3f); + fieldGradient -= direction * math.dot(fieldGradient, direction); + return new(value * 0.1f, fieldGradient * 0.1f, value, fieldGradient, math.clamp(value, -1, 1)); + } + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryGpuPageTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryGpuPageTests.cs new file mode 100644 index 00000000..1a8f96ef --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryGpuPageTests.cs @@ -0,0 +1,28 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryGpuPageTests +{ + [Fact] + public void UploadContentUsesCanonicalDirectionsSpacingAndMetadata() + { + var layout = new PlanetaryPageLayout(new(PlanetaryCubeFace.NegativeY, 3, 4, 2), 9, 2); + var content = PlanetaryGpuPageBuilder.BuildContent(layout, 8.4f); + Assert.Equal(layout.StorageSize * layout.StorageSize, content.Inputs.Length); + Assert.True(content.ParentSampleSpacing > content.SampleSpacing); + var first = content.Inputs[0]; + Assert.Equal(PlanetaryPageSampling.Direction(layout, 0, 0), first.DirectionRadius.xyz); + Assert.Equal(8.4f, first.DirectionRadius.w); + Assert.Equal(content.SampleSpacing, first.Sampling.x); + Assert.Equal(content.ParentSampleSpacing, first.Sampling.y); + + var metadata = PlanetaryGpuPageBuilder.Metadata(content, 37, 0.25f); + Assert.Equal(new float4((int)layout.Tile.Face, layout.Tile.Level, layout.Tile.X, layout.Tile.Y), metadata.Address); + Assert.Equal(new float4(37, layout.StorageSize, layout.InteriorSize, layout.BorderSize), metadata.Layout); + Assert.Equal(0.25f, metadata.State.y); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryMapTileEncodingTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryMapTileEncodingTests.cs new file mode 100644 index 00000000..5b7f2397 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryMapTileEncodingTests.cs @@ -0,0 +1,55 @@ +using System.IO; +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryMapTileEncodingTests +{ + [Fact] + public void BinaryTileRoundTripsIdentityValidityAndSurfaceEvidence() + { + var field = PlanetaryFieldDefinition.Create(5, 10, 3, AdvancedErosionParameters.Default); + var layout = new PlanetaryMapTileLayout(new(PlanetaryProjectionKind.EqualEarth), 1, 1, 0, 5, 1); + var key = new PlanetaryMapTileKey(field.FieldVersion, 2, 17, layout, PlanetaryQueryScale.AtFootprint(0.25f, 0.1f)); + var tile = PlanetaryMapTileBaker.Bake(field, key, new FlatField()); + using var bytes = new MemoryStream(); + PlanetaryMapTileEncoding.Write(bytes, tile, leaveOpen: true); + bytes.Position = 0; + var decoded = PlanetaryMapTileEncoding.Read(bytes); + + Assert.Equal(tile.Key, decoded.Key); + Assert.Equal(tile.Validity, decoded.Validity); + for (var i = 0; i < tile.Samples.Length; i++) + if (tile.Validity[i]) Assert.Equal(tile.Samples[i], decoded.Samples[i]); + } + + [Fact] + public void DecoderRejectsForeignMagicAndVersions() + { + Assert.Throws(() => PlanetaryMapTileEncoding.Read(new MemoryStream(new byte[] { 1, 2, 3, 4, 1, 0, 0, 0 }))); + var bytes = new byte[] { (byte)'C', (byte)'M', (byte)'P', (byte)'T', 99, 0, 0, 0 }; + Assert.Throws(() => PlanetaryMapTileEncoding.Read(new MemoryStream(bytes))); + } + + [Fact] + public void DecoderRejectsTrailingBytes() + { + var field = PlanetaryFieldDefinition.Create(2, 1, 0, AdvancedErosionParameters.Default); + var layout = new PlanetaryMapTileLayout(new(PlanetaryProjectionKind.Equirectangular), 0, 0, 0, 2, 0); + var key = new PlanetaryMapTileKey(field.FieldVersion, 1, 0, layout, PlanetaryQueryScale.AtFootprint(1)); + var tile = PlanetaryMapTileBaker.Bake(field, key, new FlatField()); + using var bytes = new MemoryStream(); + PlanetaryMapTileEncoding.Write(bytes, tile, leaveOpen: true); + bytes.WriteByte(0xff); + bytes.Position = 0; + Assert.Throws(() => PlanetaryMapTileEncoding.Read(bytes)); + } + + private readonly struct FlatField : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 direction) => new(0, float3.zero, 0, float3.zero, 0); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPageTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPageTests.cs new file mode 100644 index 00000000..88ae15c1 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPageTests.cs @@ -0,0 +1,60 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryPageTests +{ + [Fact] + public void BilinearSamplingAndSummaryUseOnePageContract() + { + var layout = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveZ, 0, 0, 0), 3, 1); + var samples = new PlanetaryPageSample[layout.StorageSize * layout.StorageSize]; + for (var y = 0; y < layout.StorageSize; y++) + for (var x = 0; x < layout.StorageSize; x++) + samples[y * layout.StorageSize + x] = new(x + y * 10, new float3(x, y, 0), x / 10.0f, y / 10.0f); + + var sample = PlanetaryPageSampling.Bilinear(samples, layout, new float2(0.25f, 0.75f)); + Assert.Equal(26.5f, sample.Height, 4); + Assert.Equal(new float3(1.5f, 2.5f, 0), sample.TangentGradient); + var summary = PlanetaryPageSampling.Summarize(samples, layout, 0.125f); + Assert.Equal(0, summary.MinimumHeight); + Assert.Equal(44, summary.MaximumHeight); + Assert.Equal(0.125f, summary.UnresolvedHeightBound); + Assert.True(summary.MaximumSlope > 0); + } + + [Fact] + public void ChildPageStoresOnlyResidualAndComposesBackToDirectField() + { + var field = new PlanetaryFieldDefinition(7, 8.4f, 3, AdvancedErosionParameters.Default with + { + Scale = 0.63f, + Strength = 0.12f, + Octaves = 7, + }); + var source = new FixtureField(); + var root = PlanetaryPageBaker.Bake(field, new(new(PlanetaryCubeFace.PositiveZ, 0, 0, 0), 17, 2), source); + var child = PlanetaryPageBaker.Bake(field, new(new(PlanetaryCubeFace.PositiveZ, 1, 1, 0), 17, 2), source); + var direction = PlanetaryPageSampling.DirectionAtLocal(child.Layout, 0.5, 0.5); + var pages = new[] { new PlanetaryResidentPage(root, 1), new PlanetaryResidentPage(child, 1) }; + Assert.True(PlanetaryPageSetSampling.TrySample(pages, direction, out var composed)); + var spacing = PlanetaryPageSampling.NominalAngularTexelSize(child.Layout) * field.Radius; + var direct = PlanetaryField.Sample(field, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(spacing)); + Assert.InRange(MathF.Abs(composed.Height - direct.RadialDisplacement), 0, 2.0e-5f); + Assert.InRange(math.distance(composed.TangentGradient, direct.TangentGradient), 0, 3.0e-5f); + } + + private readonly struct FixtureField : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 direction) + { + var value = direction.x * 0.4f - direction.y * 0.2f + direction.z * 0.5f; + var gradient = new float3(0.4f, -0.2f, 0.5f); + gradient -= direction * math.dot(gradient, direction); + return new(value * 0.08f, gradient * 0.08f, value, gradient, value); + } + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPatchTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPatchTests.cs new file mode 100644 index 00000000..bea83edf --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPatchTests.cs @@ -0,0 +1,43 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryPatchTests +{ + [Fact] + public void CubeSpherePatchMeshUsesCanonicalTopology() + { + var meshes = PlanetaryPatch.CreateCubeSphere(8); + Assert.Equal(6, meshes.Length); + foreach (var mesh in meshes) + { + Assert.Equal(81, mesh.Vertices.Length); + Assert.Equal(8 * 8 * 6, mesh.Indices.Length); + Assert.All(mesh.Vertices, vertex => Assert.Equal(1, math.length(vertex.UnitDirection), 5)); + foreach (var index in mesh.Indices) Assert.InRange(index, 0, mesh.Vertices.Length - 1); + } + } + + [Fact] + public void NeighborFaceCornersAreSingleValued() + { + var directions = PlanetaryPatch.CreateCubeSphere(1) + .SelectMany(mesh => mesh.Vertices.Select(vertex => vertex.UnitDirection)) + .ToArray(); + var groups = directions.GroupBy(direction => new + { + X = Math.Sign(direction.x), + Y = Math.Sign(direction.y), + Z = Math.Sign(direction.z), + }); + Assert.Equal(8, groups.Count()); + Assert.All(groups, group => + { + var first = group.First(); + Assert.All(group, direction => Assert.InRange(math.distance(first, direction), 0, 1.0e-6f)); + }); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryProjectionTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryProjectionTests.cs new file mode 100644 index 00000000..55f5676b --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryProjectionTests.cs @@ -0,0 +1,76 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryProjectionTests +{ + [Theory] + [InlineData(PlanetaryProjectionKind.Equirectangular)] + [InlineData(PlanetaryProjectionKind.WebMercator)] + [InlineData(PlanetaryProjectionKind.EqualEarth)] + [InlineData(PlanetaryProjectionKind.Orthographic)] + [InlineData(PlanetaryProjectionKind.AzimuthalEquidistant)] + [InlineData(PlanetaryProjectionKind.AzimuthalEqualArea)] + [InlineData(PlanetaryProjectionKind.LocalTangent)] + public void ProjectionRoundTripsVisibleDirections(PlanetaryProjectionKind kind) + { + var parameters = new PlanetaryProjectionParameters(kind, 0.31, -0.22); + foreach (var longitude in new[] { -0.4, -0.1, 0.2, 0.65 }) + foreach (var latitude in new[] { -0.6, -0.15, 0.3, 0.7 }) + { + var direction = Direction(longitude + parameters.CenterLongitude, latitude); + if (!PlanetaryProjection.TryForward(direction, parameters, out var map)) continue; + Assert.True(PlanetaryProjection.TryInverse(map, parameters, out var roundTrip)); + Assert.InRange(math.distance(direction, roundTrip), 0, 3.0e-5f); + } + } + + [Fact] + public void CubeAtlasRoundTripsEveryFaceInterior() + { + var projection = new PlanetaryProjectionParameters(PlanetaryProjectionKind.CubeAtlas); + foreach (var face in Enum.GetValues()) + { + var direction = PlanetaryTopology.Direction(new(face, 0.23, -0.41)); + Assert.True(PlanetaryProjection.TryForward(direction, projection, out var map)); + Assert.True(PlanetaryProjection.TryInverse(map, projection, out var roundTrip)); + Assert.InRange(math.distance(direction, roundTrip), 0, 2.0e-6f); + } + } + + [Fact] + public void MercatorRejectsPolarSingularity() + { + var northPole = new float3(0, 0, 1); + Assert.False(PlanetaryProjection.TryForward(northPole, new(PlanetaryProjectionKind.WebMercator), out _)); + } + + [Fact] + public void MapTileBakerCarriesFieldIdentityAndProjectionValidity() + { + var field = new PlanetaryFieldDefinition(99, 10, 0, AdvancedErosionParameters.Default); + var layout = new PlanetaryMapTileLayout( + new(PlanetaryProjectionKind.Orthographic), 0, 0, 0, 9, 1); + var key = new PlanetaryMapTileKey(field.FieldVersion, 1, 7, layout, PlanetaryQueryScale.AtFootprint(0.1f)); + var tile = PlanetaryMapTileBaker.Bake(field, key, new FlatField()); + Assert.Equal(key, tile.Key); + Assert.Equal(layout.StorageSize * layout.StorageSize, tile.Samples.Length); + Assert.Contains(tile.Validity, valid => valid); + Assert.Contains(tile.Validity, valid => !valid); + Assert.All(tile.Samples.Where((_, index) => tile.Validity[index]), sample => Assert.Equal(field.FieldVersion, sample.FieldVersion)); + } + + private static float3 Direction(double longitude, double latitude) + { + var cos = Math.Cos(latitude); + return new((float)(cos * Math.Cos(longitude)), (float)(cos * Math.Sin(longitude)), (float)Math.Sin(latitude)); + } + + private readonly struct FlatField : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 unitDirection) => new(0, float3.zero, 0, float3.zero, 0); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryQueryTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryQueryTests.cs new file mode 100644 index 00000000..17636165 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryQueryTests.cs @@ -0,0 +1,49 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryQueryTests +{ + private static readonly PlanetaryFieldDefinition Field = new(12, 10, 0, AdvancedErosionParameters.Default with { Strength = 0, Octaves = 0 }); + private static readonly PlanetaryQueryScale Scale = PlanetaryQueryScale.AtFootprint(0.1f, 0); + + [Fact] + public void RayIntersectionAndClearanceUseTheCpuSurfaceContract() + { + var source = new BulgedSphere(); + Assert.True(PlanetaryQueries.TryIntersectRay( + Field, new double3(0, 0, 20), new double3(0, 0, -1), 30, + Scale, new PlanetaryRadialBounds(-1, 1), source, out var hit)); + Assert.InRange(hit.Distance, 8.99999, 9.00001); + Assert.InRange(hit.PlanetLocalPosition.z, 10.99999, 11.00001); + var clearance = PlanetaryQueries.SegmentClearance( + Field, new double3(-2, 0, 12), new double3(2, 0, 12), 0.1, Scale, source); + Assert.True(clearance > 0.8); + } + + [Fact] + public void GreatCircleAndRegionQueriesReturnVersionedSamples() + { + var source = new BulgedSphere(); + var path = PlanetaryQueries.SampleGreatCircle(Field, new float3(1, 0, 0), new float3(0, 1, 0), 8, Scale, source); + Assert.Equal(9, path.Length); + Assert.Equal(MathF.PI * 5, path[^1].Distance, 4); + Assert.All(path, point => Assert.Equal(Field.FieldVersion, point.Surface.FieldVersion)); + var summary = PlanetaryQueries.Summarize(Field, path.Select(point => point.Surface.UnitDirection).ToArray(), Scale, source); + Assert.True(summary.MaximumDisplacement >= summary.MinimumDisplacement); + Assert.True(summary.MaximumSlope >= 0); + } + + private readonly struct BulgedSphere : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 direction) + { + var displacement = direction.z; + var gradient = new float3(0, 0, 1) - direction * direction.z; + return new(displacement, gradient, 0, float3.zero, 0); + } + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryRadialRefinementTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryRadialRefinementTests.cs new file mode 100644 index 00000000..18d8529b --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryRadialRefinementTests.cs @@ -0,0 +1,32 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryRadialRefinementTests +{ + [Fact] + public void FourBoundedStepsConvergeFromTheRasterCaptureShell() + { + var center=new float3(0.3f,-0.2f,0.7f); + var radial=math.normalize(new float3(0.2f,-0.4f,0.9f)); + var targetRadius=3.85f; + var target=center+radial*targetRadius; + var origin=target+math.normalize(radial+new float3(0.12f,-0.05f,0.02f))*6.0f; + var position=center+radial*(targetRadius+0.30f); + var ray=math.normalize(position-origin); + for(var step=0;step<4;step++) + position=PlanetaryRadialRefinement.Step(position,ray,center,targetRadius); + Assert.InRange(math.abs(math.length(position-center)-targetRadius),0.0f,1.0e-4f); + } + + [Fact] + public void StepClampsWeakDerivativeCorrections() + { + var position=new float3(0,0,4.1f); + var result=PlanetaryRadialRefinement.Step(position,new float3(1,0,0),float3.zero,3.8f); + Assert.InRange(math.length(result-position),0.07999f,0.08001f); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryResidencyTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryResidencyTests.cs new file mode 100644 index 00000000..b8ed63f5 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryResidencyTests.cs @@ -0,0 +1,60 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryResidencyTests +{ + private static readonly PlanetaryFieldDefinition Field = new( + 1, 8.4f, 0, AdvancedErosionParameters.Default with { Scale = 0.63f, Strength = 0.12f, Octaves = 7 }); + private static readonly PlanetaryLodParameters Lod = new(5, 17, 2, 720, 8.4f / 65536); + + [Fact] + public void SelectorReturnsSixRootsAndOneAncestorChain() + { + var direction = math.normalize(new float3(-0.2f, 0.91f, 0.37f)); + var selected = PlanetaryLodSelector.SelectAncestorChain(Field, direction, 8.45f, Lod); + Assert.Equal(6, selected.Count(tile => tile.Level == 0)); + var descendants = selected.Where(tile => tile.Level > 0).OrderBy(tile => tile.Level).ToArray(); + for (var i = 1; i < descendants.Length; i++) Assert.Equal(descendants[i - 1], descendants[i].Parent()); + if (descendants.Length > 0) Assert.Equal(PlanetaryTopology.TileAt(direction, descendants[^1].Level), descendants[^1]); + } + + [Fact] + public void ResidencyFadesResidualsWithoutChangingContentDuringPresentation() + { + var root = new PlanetaryTileAddress(PlanetaryCubeFace.PositiveX, 0, 0, 0); + var child = root.Child(1, 0); + var residency = new PlanetaryResidualResidency(); + var arrival = residency.Update(new[] { root, child }, 0, 1); + var midpoint = residency.Update(new[] { root, child }, 0.5f, 1); + var settled = residency.Update(new[] { root, child }, 1, 1); + Assert.Equal(arrival.ContentVersion, midpoint.ContentVersion); + Assert.Equal(midpoint.ContentVersion, settled.ContentVersion); + Assert.Equal(0, arrival.Tiles.Single(tile => tile.Tile == child).Blend); + Assert.Equal(0.5f, midpoint.Tiles.Single(tile => tile.Tile == child).Blend); + Assert.Equal(1, settled.Tiles.Single(tile => tile.Tile == child).Blend); + + var leaving = residency.Update(new[] { root }, 1.25f, 1); + Assert.True(leaving.Tiles.Single(tile => tile.Tile == child).Departing); + Assert.Equal(1, leaving.Tiles.Single(tile => tile.Tile == child).Blend); + var gone = residency.Update(new[] { root }, 2.25f, 1); + Assert.DoesNotContain(gone.Tiles, tile => tile.Tile == child); + } + + [Fact] + public void ReversingAnArrivalDepartsFromItsCurrentWeightWithoutJumping() + { + var root = new PlanetaryTileAddress(PlanetaryCubeFace.PositiveX, 0, 0, 0); + var child = root.Child(0, 1); + var residency = new PlanetaryResidualResidency(); + _ = residency.Update(new[] { root, child }, 0, 1); + var arriving = residency.Update(new[] { root, child }, 0.25f, 1); + var departing = residency.Update(new[] { root }, 0.25f, 1); + Assert.Equal(0.25f, arriving.Tiles.Single(tile => tile.Tile == child).Blend); + Assert.Equal(0.25f, departing.Tiles.Single(tile => tile.Tile == child).Blend); + Assert.True(departing.Tiles.Single(tile => tile.Tile == child).Departing); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryTopologyTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryTopologyTests.cs new file mode 100644 index 00000000..27bb4cef --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryTopologyTests.cs @@ -0,0 +1,66 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryTopologyTests +{ + [Fact] + public void FaceDirectionRoundTripsAcrossEveryFace() + { + foreach (var face in Enum.GetValues()) + foreach (var u in new[] { -0.9, -0.25, 0.0, 0.4, 0.9 }) + foreach (var v in new[] { -0.85, -0.1, 0.3, 0.88 }) + { + var direction = PlanetaryTopology.Direction(new(face, u, v)); + var roundTrip = PlanetaryTopology.FaceCoordinate(direction); + Assert.Equal(face, roundTrip.Face); + Assert.Equal(u, roundTrip.U, 5); + Assert.Equal(v, roundTrip.V, 5); + } + } + + [Fact] + public void SiblingPagesShareBitStableBoundaryDirections() + { + var left = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveZ, 1, 0, 0), 17, 2); + var right = new PlanetaryPageLayout(new(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 DirectionSelectsContainingTileAndLocalCoordinate() + { + var expected = new PlanetaryTileAddress(PlanetaryCubeFace.NegativeY, 5, 19, 7); + var direction = PlanetaryPageSampling.DirectionAtLocal(new(expected, 17, 2), 0.37, 0.61); + Assert.Equal(expected, PlanetaryTopology.TileAt(direction, expected.Level)); + Assert.True(PlanetaryTopology.TryLocalCoordinate(direction, expected, out var local)); + Assert.Equal(0.37f, local.x, 4); + Assert.Equal(0.61f, local.y, 4); + } + + [Fact] + public void BorderSamplesDoNotRequireNeighborResidency() + { + var page = new PlanetaryPageLayout(new(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, math.length(border), 5); + } + + [Fact] + public void NormalizedCubeBaselineSharesFaceEdges() + { + var a = PlanetaryTopology.NormalizedCubeDirection(new(PlanetaryCubeFace.PositiveZ, 1, -0.4)); + var b = PlanetaryTopology.NormalizedCubeDirection(new(PlanetaryCubeFace.PositiveX, -1, -0.4)); + Assert.InRange(math.distance(a, b), 0, 1.0e-6f); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/SphericalErosionTests.cs b/tests/GameCult.Geometry.Planetary.Tests/SphericalErosionTests.cs new file mode 100644 index 00000000..adf7ea0e --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/SphericalErosionTests.cs @@ -0,0 +1,37 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class SphericalErosionTests +{ + [Fact] + public void SampleIsDeterministicAndScaleInvariant() + { + var parameters = SphericalErosionParameters.Default with { Seed = 42.0f }; + var a = SphericalErosion.Sample(new float3(0.31f, -0.74f, 0.59f), 0.2f, new float3(0.4f, -0.1f, 0.2f), parameters); + var repeated = SphericalErosion.Sample(new float3(0.31f, -0.74f, 0.59f), 0.2f, new float3(0.4f, -0.1f, 0.2f), parameters); + var b = SphericalErosion.Sample(new float3(3.1f, -7.4f, 5.9f), 0.2f, new float3(0.4f, -0.1f, 0.2f), parameters); + + Assert.Equal(a, repeated); + Assert.InRange(math.abs(a.HeightOffset-b.HeightOffset),0.0f,1.0e-6f); + Assert.InRange(math.abs(a.Ridge-b.Ridge),0.0f,1.0e-6f); + Assert.InRange(math.abs(a.Gully-b.Gully),0.0f,5.0e-5f); + Assert.InRange(math.length(a.Gradient-b.Gradient),0.0f,5.0e-6f); + Assert.InRange(a.HeightOffset, -0.08f, 0.08f); + Assert.InRange(a.Ridge, 0.0f, 1.0f); + Assert.InRange(a.Gully, 0.0f, 1.0f); + } + + [Fact] + public void NearbySamplesRemainContinuousAcrossAChartBoundary() + { + var parameters = SphericalErosionParameters.Default; + var left = SphericalErosion.Sample(math.normalize(new float3(1.0f, 0.99999f, 0.2f)), 0.1f, new float3(0.2f, -0.1f, 0.05f), parameters); + var right = SphericalErosion.Sample(math.normalize(new float3(0.99999f, 1.0f, 0.2f)), 0.1f, new float3(0.2f, -0.1f, 0.05f), parameters); + + Assert.InRange(math.abs(left.HeightOffset - right.HeightOffset), 0.0f, 0.0002f); + } +} diff --git a/tests/GameCult.Geometry.Tests/GeometryShaderOwnershipTests.cs b/tests/GameCult.Geometry.Tests/GeometryShaderOwnershipTests.cs new file mode 100644 index 00000000..ba5bcf64 --- /dev/null +++ b/tests/GameCult.Geometry.Tests/GeometryShaderOwnershipTests.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using NUnit.Framework; + +namespace GameCult.Geometry.Tests; + +public sealed class GeometryShaderOwnershipTests +{ + [Test] + public void GeometryUmbrellaOwnsTheCompleteShaderSurface() + { + var shaders = Path.Combine(GetRepositoryRoot(), "src", "GameCult.Geometry", "Shaders"); + var umbrella = File.ReadAllText(Path.Combine(shaders, "GameCult.Geometry.hlsl")); + var erosion = File.ReadAllText(Path.Combine(shaders, "AdvancedErosionFilter.hlsl")); + var planetary = File.ReadAllText(Path.Combine(shaders, "Planetary.hlsl")); + var spherical = File.ReadAllText(Path.Combine(shaders, "SphericalErosion.hlsl")); + var refinement = File.ReadAllText(Path.Combine(shaders, "PlanetaryRadialRefinement.hlsl")); + + Assert.That(umbrella, Does.Contain("#define GAMECULT_GEOMETRY_CULTMATH_INCLUDE \"CultMath.hlsl\"") + .And.Contain("#include GAMECULT_GEOMETRY_CULTMATH_INCLUDE") + .And.Contain("#include \"AdvancedErosionFilter.hlsl\"") + .And.Contain("#include \"Planetary.hlsl\"") + .And.Contain("#include \"SphericalErosion.hlsl\"") + .And.Contain("#include \"PlanetaryRadialRefinement.hlsl\"")); + Assert.That(erosion, Does.Contain("gamecult_geometry_advanced_erosion_filter") + .And.Contain("GameCultGeometryAdvancedErosionParameters")); + Assert.That(planetary, Does.Contain("gamecult_geometry_planetary_face_direction") + .And.Contain("gamecult_geometry_planetary_field_sample") + .And.Contain("GameCultGeometryPlanetarySurfaceSample")); + Assert.That(spherical, Does.Contain("gamecult_geometry_spherical_erosion") + .And.Contain("GameCultGeometrySphericalErosionParameters")); + Assert.That(refinement, Does.Contain("gamecult_geometry_planetary_radial_refinement_step")); + } + + [Test] + public void GeometryShadersDoNotPublishCultMathGeometrySymbols() + { + var shaders = Path.Combine(GetRepositoryRoot(), "src", "GameCult.Geometry", "Shaders"); + foreach (var path in Directory.GetFiles(shaders, "*.hlsl")) + { + var source = File.ReadAllText(path); + Assert.That(source, Does.Not.Contain("cultmath_planetary_"), Path.GetFileName(path)); + Assert.That(source, Does.Not.Contain("cultmath_spherical_"), Path.GetFileName(path)); + Assert.That(source, Does.Not.Contain("cultmath_advanced_erosion_"), Path.GetFileName(path)); + } + } + + [Test] + public void UnityPlanetaryViewerUsesTheGeometryUmbrellaAndOwnedSymbols() + { + var shader = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "unity", "org.gamecult.geometry", "Samples~", "PlanetaryViewer", "Runtime", + "GameCultGeometryPlanetaryViewer.shader")); + + Assert.That(shader, Does.Contain( + "#define GAMECULT_GEOMETRY_CULTMATH_INCLUDE \"Packages/org.gamecult.cultmath/shaders/CultMath.hlsl\"") + .And.Contain("#include \"Packages/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl\"") + .And.Contain("gamecult_geometry_planetary_field_sample") + .And.Not.Contain("cultmath_planetary_") + .And.Not.Contain("CultMathPlanetary")); + } + + private static string GetRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "CultLib.sln"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not locate CultLib repository root."); + } +} diff --git a/tools/GameCult.Geometry.PlanetaryTileFixture/GameCult.Geometry.PlanetaryTileFixture.csproj b/tools/GameCult.Geometry.PlanetaryTileFixture/GameCult.Geometry.PlanetaryTileFixture.csproj new file mode 100644 index 00000000..21a2c04b --- /dev/null +++ b/tools/GameCult.Geometry.PlanetaryTileFixture/GameCult.Geometry.PlanetaryTileFixture.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/tools/GameCult.Geometry.PlanetaryTileFixture/Program.cs b/tools/GameCult.Geometry.PlanetaryTileFixture/Program.cs new file mode 100644 index 00000000..c9ce965b --- /dev/null +++ b/tools/GameCult.Geometry.PlanetaryTileFixture/Program.cs @@ -0,0 +1,23 @@ +using CultMath; +using GameCult.Geometry; + +if (args.Length != 1) throw new ArgumentException("Expected one output path."); +var field = GameCult.Geometry.PlanetaryFieldDefinition.Create(0x55aa, 10, 7, GameCult.Geometry.AdvancedErosionParameters.Default); +var layout = new GameCult.Geometry.PlanetaryMapTileLayout(new(GameCult.Geometry.PlanetaryProjectionKind.EqualEarth), 0, 0, 0, 5, 1); +var key = new GameCult.Geometry.PlanetaryMapTileKey(field.FieldVersion, 3, 29, layout, GameCult.Geometry.PlanetaryQueryScale.AtFootprint(0.25f, 0.125f)); +var tile = GameCult.Geometry.PlanetaryMapTileBaker.Bake(field, key, new FixtureField()); +var path = Path.GetFullPath(args[0]); +Directory.CreateDirectory(Path.GetDirectoryName(path)!); +using var stream = File.Create(path); +GameCult.Geometry.PlanetaryMapTileEncoding.Write(stream, tile); + +readonly struct FixtureField : GameCult.Geometry.IPlanetaryBaseField +{ + public GameCult.Geometry.PlanetaryBaseFieldSample Sample(float3 direction) + { + var value = direction.x * 0.2f + direction.z * 0.4f; + var gradient = new float3(0.2f, 0, 0.4f); + gradient -= direction * math.dot(gradient, direction); + return new(value, gradient, value, gradient * 0.1f, value); + } +} diff --git a/unity/org.gamecult.geometry/README.md b/unity/org.gamecult.geometry/README.md index 7a6e0823..3fe2f10e 100644 --- a/unity/org.gamecult.geometry/README.md +++ b/unity/org.gamecult.geometry/README.md @@ -7,6 +7,9 @@ depends on `org.gamecult.cultmath` for numeric primitives and Build it with `scripts/build-geometry-unity-package.ps1`. Do not edit generated plugin assemblies in the package output. -CultLib-authored files are MIT licensed. Geometry sources transferred from -CultMath retain their MPL-2.0 notices and provenance; those files will be listed -here as they enter this package. No transferred CultMath source is relicensed. +CultLib-authored integration files are MIT licensed. The planetary C# compiled +into `GameCult.Geometry.dll` and the geometry HLSL under `Shaders` are +MPL-2.0 and retain their source notices and provenance. See `LICENSES.md` and +`THIRD-PARTY-NOTICES.md` in the staged package. No MPL-covered source is +relicensed. The package manifest therefore uses `SEE LICENSES IN README.md` +rather than claiming one license for every file. diff --git a/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.Unity.asmdef b/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.Unity.asmdef new file mode 100644 index 00000000..3a0a02ee --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.Unity.asmdef @@ -0,0 +1,8 @@ +{ + "name": "GameCult.Geometry.Unity", + "rootNamespace": "GameCult.Geometry.Unity", + "references": ["CultMath", "GameCult.Geometry"], + "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, + "overrideReferences": false, "precompiledReferences": [], "autoReferenced": true, + "defineConstraints": [], "versionDefines": [], "noEngineReferences": false +} diff --git a/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs b/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs new file mode 100644 index 00000000..8e0b431e --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using CultMath; +using UnityEngine; + +namespace GameCult.Geometry.Unity; + +[StructLayout(LayoutKind.Sequential)] public readonly struct PlanetaryPageInputGpu { public PlanetaryPageInputGpu(Vector4 directionRadius, Vector4 sampling) { DirectionRadius = directionRadius; Sampling = sampling; } public readonly Vector4 DirectionRadius; public readonly Vector4 Sampling; } +[StructLayout(LayoutKind.Sequential)] public readonly struct PlanetaryPageMetadataGpu { public PlanetaryPageMetadataGpu(Vector4 address, Vector4 layout, Vector4 bounds, Vector4 state) { Address = address; Layout = layout; Bounds = bounds; State = state; } public readonly Vector4 Address; public readonly Vector4 Layout; public readonly Vector4 Bounds; public readonly Vector4 State; } +public readonly struct PlanetaryPageUpload { public PlanetaryPageUpload(PlanetaryPageInputGpu[] inputs, PlanetaryPageMetadataGpu[] metadata, int outputSampleCount, ulong contentVersion, ulong presentationVersion) { Inputs = inputs; Metadata = metadata; OutputSampleCount = outputSampleCount; ContentVersion = contentVersion; PresentationVersion = presentationVersion; } public PlanetaryPageInputGpu[] Inputs { get; } public PlanetaryPageMetadataGpu[] Metadata { get; } public int OutputSampleCount { get; } public ulong ContentVersion { get; } public ulong PresentationVersion { get; } } +public static class PlanetaryPageUploadBuilder +{ + public static PlanetaryPageUpload Build(in PlanetaryResidencySnapshot snapshot, int interiorSize, int borderSize, float radius) + { + if (!float.IsFinite(radius) || radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius)); + var inputs = new List(); var metadata = new PlanetaryPageMetadataGpu[snapshot.Tiles.Length]; + foreach (var (resident, pageIndex) in snapshot.Tiles.Select((resident, index) => (resident, index))) + { + var content = PlanetaryGpuPageBuilder.BuildContent(new PlanetaryPageLayout(resident.Tile, interiorSize, borderSize).Validate(), radius); var outputOffset = inputs.Count; + foreach (var input in content.Inputs) inputs.Add(new(ToUnity(input.DirectionRadius), ToUnity(input.Sampling))); + var common = PlanetaryGpuPageBuilder.Metadata(content, outputOffset, resident.Blend); + metadata[pageIndex] = new(ToUnity(common.Address), ToUnity(common.Layout), ToUnity(common.Bounds), ToUnity(common.State)); + } + return new(inputs.ToArray(), metadata, inputs.Count, snapshot.ContentVersion, snapshot.PresentationVersion); + } + private static Vector4 ToUnity(float4 value) => new(value.x, value.y, value.z, value.w); +} diff --git a/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs.meta b/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs.meta new file mode 100644 index 00000000..904f752e --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7edd3bb2edb2e6448a2d54fc082c27dc diff --git a/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs b/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs new file mode 100644 index 00000000..8e758426 --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs @@ -0,0 +1,27 @@ +using System; +using CultMath; +using UnityEngine; +using UnityEngine.Rendering; + +namespace GameCult.Geometry.Unity; + +public static class PlanetaryPatchMeshAdapter +{ + public static Mesh CreateFaceMesh(PlanetaryCubeFace face, int cellsPerAxis, float radius = 1) + { + if (!float.IsFinite(radius) || radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius)); + var source = PlanetaryPatch.CreateFace(face, cellsPerAxis); + var vertices = new Vector3[source.Vertices.Length]; + var normals = new Vector3[source.Vertices.Length]; + var uv = new Vector2[source.Vertices.Length]; + for (var i = 0; i < source.Vertices.Length; i++) + { + var direction = source.Vertices[i].UnitDirection; + var normal = new Vector3(direction.x, direction.y, direction.z); + vertices[i] = normal * radius; normals[i] = normal; + uv[i] = new Vector2(source.Vertices[i].LocalCoordinate.x, source.Vertices[i].LocalCoordinate.y); + } + var mesh = new Mesh { name = $"GameCult.Geometry Planetary {face} {cellsPerAxis}x{cellsPerAxis}", indexFormat = vertices.Length > ushort.MaxValue ? IndexFormat.UInt32 : IndexFormat.UInt16, vertices = vertices, normals = normals, uv = uv, triangles = source.Indices }; + mesh.RecalculateBounds(); return mesh; + } +} diff --git a/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs.meta b/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs.meta new file mode 100644 index 00000000..4a2d9754 --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9ad7b581e14ef774d8435069bb77ca6a diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCult.Geometry.PlanetaryViewer.Editor.asmdef b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCult.Geometry.PlanetaryViewer.Editor.asmdef new file mode 100644 index 00000000..7f842880 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCult.Geometry.PlanetaryViewer.Editor.asmdef @@ -0,0 +1 @@ +{"name":"GameCult.Geometry.PlanetaryViewer.Editor","rootNamespace":"GameCult.Geometry.Unity.Samples.Editor","references":["GameCult.Geometry.PlanetaryViewer"],"includePlatforms":["Editor"],"excludePlatforms":[],"allowUnsafeCode":false,"overrideReferences":false,"precompiledReferences":[],"autoReferenced":true,"defineConstraints":[],"versionDefines":[],"noEngineReferences":false} diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs new file mode 100644 index 00000000..e2a32d17 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs @@ -0,0 +1,27 @@ +using UnityEditor; +using UnityEngine; +using GameCult.Geometry.Unity.Samples; + +namespace GameCult.Geometry.Unity.Samples.Editor; + +public static class GameCultGeometryPlanetaryViewerMenu +{ + [MenuItem("GameObject/GameCult.Geometry/Planetary Field Viewer", false, 10)] + public static void CreateViewer() + { + var root = new GameObject("GameCult.Geometry Planetary Field Viewer"); + Undo.RegisterCreatedObjectUndo(root, "Create GameCult.Geometry Planetary Viewer"); + root.AddComponent(); + Selection.activeGameObject = root; + + if (Camera.main != null) return; + var cameraObject = new GameObject("Planetary Viewer Camera"); + Undo.RegisterCreatedObjectUndo(cameraObject, "Create Planetary Viewer Camera"); + cameraObject.tag = "MainCamera"; + cameraObject.transform.position = new Vector3(0, 0, -24); + cameraObject.transform.LookAt(root.transform); + var camera = cameraObject.AddComponent(); + camera.nearClipPlane = 0.01f; + camera.farClipPlane = 1000; + } +} diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/README.md b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/README.md new file mode 100644 index 00000000..85bb4585 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/README.md @@ -0,0 +1,7 @@ +# Planetary Field Viewer + +Import this sample through Unity Package Manager, then choose +`GameObject > GameCult.Geometry > Planetary Field Viewer`. + +The six-face mesh and planetary field are owned by GameCult.Geometry and use +CultMath numeric primitives. This is a renderer fixture, not gameplay state. diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCult.Geometry.PlanetaryViewer.asmdef b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCult.Geometry.PlanetaryViewer.asmdef new file mode 100644 index 00000000..ff41a80b --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCult.Geometry.PlanetaryViewer.asmdef @@ -0,0 +1 @@ +{"name":"GameCult.Geometry.PlanetaryViewer","rootNamespace":"GameCult.Geometry.Unity.Samples","references":["CultMath","GameCult.Geometry","GameCult.Geometry.Unity"],"includePlatforms":[],"excludePlatforms":[],"allowUnsafeCode":false,"overrideReferences":false,"precompiledReferences":[],"autoReferenced":true,"defineConstraints":[],"versionDefines":[],"noEngineReferences":false} diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs new file mode 100644 index 00000000..708d7423 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using CultMath; +using GameCult.Geometry; +using UnityEngine; + +namespace GameCult.Geometry.Unity.Samples; + +[ExecuteAlways] +public sealed class GameCultGeometryPlanetaryViewer : MonoBehaviour +{ + [Min(0.001f)] public float Radius = 8.4f; + [Range(1, 256)] public int CellsPerFace = 64; + public int Seed = 42; + [Min(0.0001f)] public float SampleFootprint = 0.01f; + public Shader? PlanetShader; + public Vector3 LightDirection = new(-0.4f, 0.7f, -0.6f); + public Color LowColor = new(0.055f, 0.16f, 0.09f, 1); + public Color HighColor = new(0.62f, 0.48f, 0.28f, 1); + public Color RidgeColor = new(0.88f, 0.83f, 0.7f, 1); + + private readonly List meshes = new(); + private Material? material; + private int builtCells; + + public PlanetaryFieldDefinition Definition => PlanetaryFieldDefinition.Create( + 1, + Radius, + Seed, + ViewerErosion(Radius)); + + public PlanetarySurfaceSample Sample(float3 direction) + { + var source = new ViewerBaseField(Radius); + var normalized = math.normalize(direction); + return PlanetaryField.Sample(Definition, normalized, source.Sample(normalized), PlanetaryQueryScale.AtFootprint(SampleFootprint)); + } + + private void OnEnable() => Rebuild(); + + private void OnValidate() + { + Radius = Mathf.Max(Radius, 0.001f); + CellsPerFace = Mathf.Clamp(CellsPerFace, 1, 256); + SampleFootprint = Mathf.Max(SampleFootprint, 0.0001f); + if (isActiveAndEnabled) Rebuild(); + } + + private void Update() + { + if (builtCells != CellsPerFace || material == null) Rebuild(); + ApplyMaterial(); + } + + private void Rebuild() + { + ClearGenerated(); + PlanetShader ??= Shader.Find("GameCult.Geometry/Planetary Field Viewer"); + if (PlanetShader == null) return; + material = new Material(PlanetShader) { name = "CultMath Planetary Viewer Material", hideFlags = HideFlags.HideAndDontSave }; + foreach (var face in (PlanetaryCubeFace[])Enum.GetValues(typeof(PlanetaryCubeFace))) + { + var mesh = PlanetaryPatchMeshAdapter.CreateFaceMesh(face, CellsPerFace); + mesh.hideFlags = HideFlags.HideAndDontSave; + meshes.Add(mesh); + var child = new GameObject(face.ToString()) { hideFlags = HideFlags.DontSave }; + child.transform.SetParent(transform, false); + child.AddComponent().sharedMesh = mesh; + child.AddComponent().sharedMaterial = material; + } + builtCells = CellsPerFace; + ApplyMaterial(); + } + + private void ApplyMaterial() + { + if (material == null) return; + var p = ViewerErosion(Radius); + material.SetFloat("_Radius", Radius); + material.SetFloat("_SampleFootprint", SampleFootprint); + material.SetInt("_Seed", Seed); + material.SetFloat("_ErosionScale", p.Scale); + material.SetFloat("_ErosionStrength", p.Strength); + material.SetFloat("_GullyWeight", p.GullyWeight); + material.SetFloat("_Detail", p.Detail); + material.SetVector("_Rounding", new(p.Rounding.x, p.Rounding.y, p.Rounding.z, p.Rounding.w)); + material.SetVector("_Onset", new(p.Onset.x, p.Onset.y, p.Onset.z, p.Onset.w)); + material.SetVector("_AssumedSlope", new(p.AssumedSlope.x, p.AssumedSlope.y, 0, 0)); + material.SetFloat("_CellScale", p.CellScale); + material.SetFloat("_Normalization", p.Normalization); + material.SetInt("_Octaves", p.Octaves); + material.SetFloat("_Lacunarity", p.Lacunarity); + material.SetFloat("_Gain", p.Gain); + material.SetVector("_LightDirection", LightDirection.normalized); + material.SetColor("_LowColor", LowColor); + material.SetColor("_HighColor", HighColor); + material.SetColor("_RidgeColor", RidgeColor); + } + + private void OnDisable() => ClearGenerated(); + private void OnDestroy() => ClearGenerated(); + + private void ClearGenerated() + { + for (var i = transform.childCount - 1; i >= 0; i--) DestroyGenerated(transform.GetChild(i).gameObject); + foreach (var mesh in meshes) DestroyGenerated(mesh); + meshes.Clear(); + if (material != null) DestroyGenerated(material); + material = null; + builtCells = 0; + } + + private static void DestroyGenerated(UnityEngine.Object value) + { + if (Application.isPlaying) Destroy(value); + else DestroyImmediate(value); + } + + private static AdvancedErosionParameters ViewerErosion(float radius) => new( + radius * 0.075f, 0.12f, 0.58f, 1.45f, + new float4(0.1f, 0.015f, 0.1f, 2), + new float4(1.25f, 1.25f, 2.8f, 1.5f), + new float2(0.7f, 0.85f), 0.7f, 0.5f, 7, 2, 0.5f); + + private readonly struct ViewerBaseField : IPlanetaryBaseField + { + private readonly float radius; + public ViewerBaseField(float radius) => this.radius = radius; + + public PlanetaryBaseFieldSample Sample(float3 direction) + { + var value = direction.x * 0.6f + direction.y * direction.z * 0.3f; + var angularGradient = new float3(0.6f, direction.z * 0.3f, direction.y * 0.3f); + angularGradient -= direction * math.dot(angularGradient, direction); + var fieldGradient = angularGradient / radius; + var radialDisplacement = value * radius * 0.035f; + var radialGradient = angularGradient * 0.035f; + return new(radialDisplacement, radialGradient, value, fieldGradient, math.clamp(value, -1, 1)); + } + } +} diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader new file mode 100644 index 00000000..850e1dd5 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader @@ -0,0 +1,91 @@ +Shader "GameCult.Geometry/Planetary Field Viewer" +{ + Properties + { + _LowColor ("Low Color", Color) = (0.055, 0.16, 0.09, 1) + _HighColor ("High Color", Color) = (0.62, 0.48, 0.28, 1) + _RidgeColor ("Ridge Color", Color) = (0.88, 0.83, 0.7, 1) + } + SubShader + { + Tags { "RenderType"="Opaque" "Queue"="Geometry" } + Pass + { + Cull Back + ZWrite On + HLSLPROGRAM + #pragma target 5.0 + #pragma vertex vert + #pragma fragment frag + #include "UnityCG.cginc" + #define GAMECULT_GEOMETRY_CULTMATH_INCLUDE "Packages/org.gamecult.cultmath/shaders/CultMath.hlsl" + #include "Packages/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl" + + float _Radius; + float _SampleFootprint; + int _Seed; + float _ErosionScale, _ErosionStrength, _GullyWeight, _Detail; + float4 _Rounding, _Onset, _AssumedSlope; + float _CellScale, _Normalization, _Lacunarity, _Gain; + int _Octaves; + float3 _LightDirection; + float4 _LowColor, _HighColor, _RidgeColor; + + struct Attributes { float4 vertex : POSITION; }; + struct Varyings + { + float4 position : SV_POSITION; + float3 worldNormal : TEXCOORD0; + float height : TEXCOORD1; + float ridge : TEXCOORD2; + }; + + GameCultGeometryPlanetarySurfaceSample viewer_sample(float3 direction) + { + direction = normalize(direction); + float value = direction.x * 0.6 + direction.y * direction.z * 0.3; + float3 angular_gradient = float3(0.6, direction.z * 0.3, direction.y * 0.3); + angular_gradient -= direction * dot(angular_gradient, direction); + GameCultGeometryPlanetaryBaseFieldSample base_sample; + base_sample.radial_displacement = value * _Radius * 0.035; + base_sample.radial_gradient = angular_gradient * 0.035; + base_sample.field_value = value; + base_sample.field_gradient = angular_gradient / _Radius; + base_sample.fade_target = clamp(value, -1.0, 1.0); + GameCultGeometryAdvancedErosionParameters erosion; + erosion.scale = _ErosionScale; erosion.strength = _ErosionStrength; + erosion.gully_weight = _GullyWeight; erosion.detail = _Detail; + erosion.rounding = _Rounding; erosion.onset = _Onset; + erosion.assumed_slope = _AssumedSlope.xy; erosion.cell_scale = _CellScale; + erosion.normalization = _Normalization; erosion.octaves = _Octaves; + erosion.lacunarity = _Lacunarity; erosion.gain = _Gain; + GameCultGeometryPlanetaryFieldDefinition definition; + definition.radius = _Radius; definition.seed = _Seed; definition.erosion = erosion; + return gamecult_geometry_planetary_field_sample(definition, direction, base_sample, _SampleFootprint); + } + + Varyings vert(Attributes input) + { + float3 direction = normalize(input.vertex.xyz); + GameCultGeometryPlanetarySurfaceSample sample = viewer_sample(direction); + float3 local = direction * (_Radius + sample.radial_displacement); + Varyings output; + output.position = UnityObjectToClipPos(float4(local, 1)); + output.worldNormal = UnityObjectToWorldNormal(sample.surface_normal); + output.height = sample.radial_displacement / max(_Radius * 0.1, 1.0e-5); + output.ridge = sample.ridge; + return output; + } + + float4 frag(Varyings input) : SV_Target + { + float height = saturate(input.height * 0.5 + 0.5); + float3 albedo = lerp(_LowColor.rgb, _HighColor.rgb, height); + albedo = lerp(albedo, _RidgeColor.rgb, saturate((input.ridge - 0.55) * 2.5)); + float diffuse = saturate(dot(normalize(input.worldNormal), normalize(-_LightDirection))) * 0.82 + 0.08; + return float4(albedo * diffuse, 1); + } + ENDHLSL + } + } +} diff --git a/unity/org.gamecult.geometry/package.json b/unity/org.gamecult.geometry/package.json index 023fed98..7ac223b5 100644 --- a/unity/org.gamecult.geometry/package.json +++ b/unity/org.gamecult.geometry/package.json @@ -11,5 +11,6 @@ "dependencies": { "org.gamecult.cultmath": "0.1.0", "org.gamecult.cultlib": "1.0.7" - } + }, + "samples": [{"displayName":"Planetary Field Viewer","description":"Planetary geometry CPU/HLSL parity viewer.","path":"Samples~/PlanetaryViewer"}] }