Transfer planetary geometry into GameCult.Geometry

This commit is contained in:
Meta Vens 2026-07-22 03:47:57 +01:00
commit 756f5a3337
70 changed files with 4105 additions and 31 deletions

10
.gitignore vendored
View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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.

View file

@ -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",

View 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"
}
}

View 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 };
}

View file

@ -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));
}

View 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/);

View file

@ -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)) {

View 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." }

View file

@ -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>

View file

@ -0,0 +1,7 @@
#if NETSTANDARD2_1
namespace System.Runtime.CompilerServices;
internal static class IsExternalInit
{
}
#endif

View 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.

View 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;
}

View 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);
}
}

View 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);
}

View 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));
}
}

View 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());
}

View 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;
}
}

View 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();
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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));
}

View file

@ -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`.

View 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

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3252ac02e8853ef4b9d1fbc5204b5c6b
ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b253fa50ddd34bf3b69fb323fe6e00e8
ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: bb055ad5328888e479e90ae3fe62c3fe
ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 356389371b854e12b9d419f7ee310d81
ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 43d458fc5b6b441a941e37001df919e3
ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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.

View 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.

View file

@ -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));
}
}
}

View file

@ -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);
}
}

View file

@ -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>

View file

@ -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));
}
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}
}

View file

@ -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));
});
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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.");
}
}

View file

@ -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>

View 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);
}
}

View file

@ -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.

View 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
}

View 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);
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7edd3bb2edb2e6448a2d54fc082c27dc

View file

@ -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;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9ad7b581e14ef774d8435069bb77ca6a

View file

@ -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}

View file

@ -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;
}
}

View file

@ -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.

View file

@ -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}

View file

@ -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));
}
}
}

View file

@ -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
}
}
}

View file

@ -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"}]
}