Transfer planetary geometry into GameCult.Geometry
This commit is contained in:
parent
9293471630
commit
756f5a3337
70 changed files with 4105 additions and 31 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
15
CultLib.sln
15
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
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
<Copyright>© Meta Vens and contributors</Copyright>
|
||||
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||
<Version>1.0.3</Version>
|
||||
<CultMathCandidateFeed Condition="'$(CultMathCandidateFeed)' == ''">$(MSBuildThisFileDirectory)..\CultMath\.tools\local-feed</CultMathCandidateFeed>
|
||||
<RestoreAdditionalProjectSources Condition="Exists('$(CultMathCandidateFeed)')">$(RestoreAdditionalProjectSources);$(CultMathCandidateFeed)</RestoreAdditionalProjectSources>
|
||||
|
||||
<!-- Symbols/debugging -->
|
||||
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
10
packages/gamecult-geometry-ts/package.json
Normal file
10
packages/gamecult-geometry-ts/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
47
packages/gamecult-geometry-ts/src/planetary-tile.ts
Normal file
47
packages/gamecult-geometry-ts/src/planetary-tile.ts
Normal file
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
24
packages/gamecult-geometry-ts/test/planetary-tile.test.mjs
Normal file
24
packages/gamecult-geometry-ts/test/planetary-tile.test.mjs
Normal file
|
|
@ -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/);
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
9
scripts/test-geometry-planetary-tile.ps1
Normal file
9
scripts/test-geometry-planetary-tile.ps1
Normal file
|
|
@ -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." }
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<Description>Typed CultCache geometry domain and chunk artifact documents for VibeGeometry and Fensalir</Description>
|
||||
<Description>Spatial values, planetary geometry, queries, and typed geometry state built on CultMath primitives</Description>
|
||||
<PackageId>GameCult.Geometry</PackageId>
|
||||
<PackageTags>game;geometry;csg;lod;cultcache;cultmesh;vibegeometry;fensalir</PackageTags>
|
||||
<PackageTags>game;geometry;planetary;csg;lod;cultcache;cultmesh</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<PackageLicenseExpression></PackageLicenseExpression>
|
||||
<PackageLicenseFile>LICENSES.md</PackageLicenseFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
<CultMathVersion>0.1.0-geometry-migration.1</CultMathVersion>
|
||||
<CultMathCandidateFeed Condition="'$(CultMathCandidateFeed)' == ''">$(MSBuildThisFileDirectory)..\..\..\CultMath\.tools\local-feed</CultMathCandidateFeed>
|
||||
<RestoreAdditionalProjectSources Condition="Exists('$(CultMathCandidateFeed)')">$(RestoreAdditionalProjectSources);$(CultMathCandidateFeed)</RestoreAdditionalProjectSources>
|
||||
<CultMathVersion>0.1.0-geometry-migration.2</CultMathVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
@ -20,5 +20,9 @@
|
|||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||
<None Include="LICENSES.md" Pack="true" PackagePath="\" />
|
||||
<None Include="THIRD-PARTY-NOTICES.md" Pack="true" PackagePath="\" />
|
||||
<None Include="docs\planetary.md" Pack="true" PackagePath="docs\" />
|
||||
<None Include="Shaders\*.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
7
src/GameCult.Geometry/IsExternalInit.cs
Normal file
7
src/GameCult.Geometry/IsExternalInit.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#if NETSTANDARD2_1
|
||||
namespace System.Runtime.CompilerServices;
|
||||
|
||||
internal static class IsExternalInit
|
||||
{
|
||||
}
|
||||
#endif
|
||||
13
src/GameCult.Geometry/LICENSES.md
Normal file
13
src/GameCult.Geometry/LICENSES.md
Normal file
|
|
@ -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.
|
||||
141
src/GameCult.Geometry/Planetary/AdvancedErosionFilter.cs
Normal file
141
src/GameCult.Geometry/Planetary/AdvancedErosionFilter.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
63
src/GameCult.Geometry/Planetary/ErosionFrequencyBands.cs
Normal file
63
src/GameCult.Geometry/Planetary/ErosionFrequencyBands.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
220
src/GameCult.Geometry/Planetary/PlanetaryField.cs
Normal file
220
src/GameCult.Geometry/Planetary/PlanetaryField.cs
Normal file
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Application-supplied base evidence at one unit spherical direction.
|
||||
/// FieldValue and FieldGradient drive erosion. RadialDisplacement and
|
||||
/// RadialGradient describe the uneroded surface in metres.
|
||||
/// </summary>
|
||||
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<TSource>(
|
||||
in PlanetaryFieldDefinition definition,
|
||||
ReadOnlySpan<float3> directions,
|
||||
in PlanetaryQueryScale scale,
|
||||
TSource source,
|
||||
Span<PlanetarySurfaceSample> 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<TSource>(
|
||||
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);
|
||||
}
|
||||
60
src/GameCult.Geometry/Planetary/PlanetaryGpuPages.cs
Normal file
60
src/GameCult.Geometry/Planetary/PlanetaryGpuPages.cs
Normal file
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
122
src/GameCult.Geometry/Planetary/PlanetaryMapTileEncoding.cs
Normal file
122
src/GameCult.Geometry/Planetary/PlanetaryMapTileEncoding.cs
Normal file
|
|
@ -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());
|
||||
}
|
||||
192
src/GameCult.Geometry/Planetary/PlanetaryPages.cs
Normal file
192
src/GameCult.Geometry/Planetary/PlanetaryPages.cs
Normal file
|
|
@ -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<PlanetaryPageSample> 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<PlanetaryPageSample> 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<TSource>(
|
||||
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<PlanetaryResidentPage> 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;
|
||||
}
|
||||
}
|
||||
50
src/GameCult.Geometry/Planetary/PlanetaryPatch.cs
Normal file
50
src/GameCult.Geometry/Planetary/PlanetaryPatch.cs
Normal file
|
|
@ -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();
|
||||
}
|
||||
327
src/GameCult.Geometry/Planetary/PlanetaryProjection.cs
Normal file
327
src/GameCult.Geometry/Planetary/PlanetaryProjection.cs
Normal file
|
|
@ -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<TSource>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
198
src/GameCult.Geometry/Planetary/PlanetaryQueries.cs
Normal file
198
src/GameCult.Geometry/Planetary/PlanetaryQueries.cs
Normal file
|
|
@ -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<TSource>(
|
||||
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<TSource>(
|
||||
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<TSource>(
|
||||
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<TSource>(
|
||||
in PlanetaryFieldDefinition field,
|
||||
ReadOnlySpan<float3> 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<TSource>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
34
src/GameCult.Geometry/Planetary/PlanetaryRadialRefinement.cs
Normal file
34
src/GameCult.Geometry/Planetary/PlanetaryRadialRefinement.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
153
src/GameCult.Geometry/Planetary/PlanetaryResidency.cs
Normal file
153
src/GameCult.Geometry/Planetary/PlanetaryResidency.cs
Normal file
|
|
@ -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);
|
||||
|
||||
/// <summary>Owns presentation residency only. It never owns field content.</summary>
|
||||
public sealed class PlanetaryResidualResidency
|
||||
{
|
||||
private readonly Dictionary<ulong, PlanetaryTileAddress> residents = new();
|
||||
private readonly Dictionary<ulong, float> arrivals = new();
|
||||
private readonly Dictionary<ulong, (float Time, float StartingBlend)> departures = new();
|
||||
private float lastTime = float.NegativeInfinity;
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
residents.Clear(); arrivals.Clear(); departures.Clear(); lastTime = float.NegativeInfinity;
|
||||
}
|
||||
|
||||
public PlanetaryResidencySnapshot Update(ReadOnlySpan<PlanetaryTileAddress> 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<ulong>();
|
||||
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<ulong> 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;
|
||||
}
|
||||
}
|
||||
196
src/GameCult.Geometry/Planetary/PlanetaryTopology.cs
Normal file
196
src/GameCult.Geometry/Planetary/PlanetaryTopology.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
114
src/GameCult.Geometry/Planetary/SphericalErosion.cs
Normal file
114
src/GameCult.Geometry/Planetary/SphericalErosion.cs
Normal file
|
|
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
147
src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl
Normal file
147
src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl
Normal file
|
|
@ -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<maximum_octaves; octave++)
|
||||
{
|
||||
float weight=nyquist<=0.0?1.0:smoothstep(nyquist,nyquist*transition_ratio,wavelength);
|
||||
if(weight<=0.0) break; active++; final_weight=weight; finest=wavelength; wavelength/=lacunarity;
|
||||
}
|
||||
float omitted=0.0, amplitude=abs(base_amplitude);
|
||||
[loop] for(int index=0; index<maximum_octaves; index++)
|
||||
{
|
||||
if(index>=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
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 3252ac02e8853ef4b9d1fbc5204b5c6b
|
||||
ShaderIncludeImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
14
src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl
Normal file
14
src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl
Normal file
|
|
@ -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
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b253fa50ddd34bf3b69fb323fe6e00e8
|
||||
ShaderIncludeImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
392
src/GameCult.Geometry/Shaders/Planetary.hlsl
Normal file
392
src/GameCult.Geometry/Shaders/Planetary.hlsl
Normal file
|
|
@ -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
|
||||
7
src/GameCult.Geometry/Shaders/Planetary.hlsl.meta
Normal file
7
src/GameCult.Geometry/Shaders/Planetary.hlsl.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: bb055ad5328888e479e90ae3fe62c3fe
|
||||
ShaderIncludeImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
27
src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl
Normal file
27
src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl
Normal file
|
|
@ -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
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 356389371b854e12b9d419f7ee310d81
|
||||
ShaderIncludeImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
80
src/GameCult.Geometry/Shaders/SphericalErosion.hlsl
Normal file
80
src/GameCult.Geometry/Shaders/SphericalErosion.hlsl
Normal file
|
|
@ -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
|
||||
7
src/GameCult.Geometry/Shaders/SphericalErosion.hlsl.meta
Normal file
7
src/GameCult.Geometry/Shaders/SphericalErosion.hlsl.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 43d458fc5b6b441a941e37001df919e3
|
||||
ShaderIncludeImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
24
src/GameCult.Geometry/THIRD-PARTY-NOTICES.md
Normal file
24
src/GameCult.Geometry/THIRD-PARTY-NOTICES.md
Normal file
|
|
@ -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: <https://www.shadertoy.com/view/wXcfWn>
|
||||
- Explanation: <https://blog.runevision.com/2026/03/fast-and-gorgeous-erosion-filter.html>
|
||||
- License: Mozilla Public License 2.0, <https://www.mozilla.org/MPL/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.
|
||||
135
src/GameCult.Geometry/docs/planetary.md
Normal file
135
src/GameCult.Geometry/docs/planetary.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\GameCult.Geometry\GameCult.Geometry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit.v3" Version="3.2.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<InvalidDataException>(() => 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<InvalidDataException>(() => 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<InvalidDataException>(() => PlanetaryMapTileEncoding.Read(bytes));
|
||||
}
|
||||
|
||||
private readonly struct FlatField : IPlanetaryBaseField
|
||||
{
|
||||
public PlanetaryBaseFieldSample Sample(float3 direction) => new(0, float3.zero, 0, float3.zero, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PlanetaryCubeFace>())
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PlanetaryCubeFace>())
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\GameCult.Geometry\GameCult.Geometry.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
23
tools/GameCult.Geometry.PlanetaryTileFixture/Program.cs
Normal file
23
tools/GameCult.Geometry.PlanetaryTileFixture/Program.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
29
unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs
Normal file
29
unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs
Normal file
|
|
@ -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<PlanetaryPageInputGpu>(); 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);
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7edd3bb2edb2e6448a2d54fc082c27dc
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9ad7b581e14ef774d8435069bb77ca6a
|
||||
|
|
@ -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}
|
||||
|
|
@ -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<GameCultGeometryPlanetaryViewer>();
|
||||
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>();
|
||||
camera.nearClipPlane = 0.01f;
|
||||
camera.farClipPlane = 1000;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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}
|
||||
|
|
@ -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<Mesh> 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<MeshFilter>().sharedMesh = mesh;
|
||||
child.AddComponent<MeshRenderer>().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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue