Transfer planetary geometry to GameCult.Geometry

This commit is contained in:
Meta Vens 2026-07-22 03:47:38 +01:00
commit 6a162606e4
91 changed files with 6 additions and 4067 deletions

View file

@ -5,7 +5,4 @@
<Folder Name="/tests/">
<Project Path="tests/CultMath.Tests/CultMath.Tests.csproj" />
</Folder>
<Folder Name="/tools/">
<Project Path="Tools~/CultMath.PlanetaryTileFixture/CultMath.PlanetaryTileFixture.csproj" />
</Folder>
</Solution>

View file

@ -30,12 +30,6 @@ shader semantics.
- `Voronoi.SampleTones`, a C# batch surface that calls the Rust
`cultmath-core` native kernel when `cultmath_core` is available and falls back
to the managed parity path otherwise.
- `PlanetaryField`, a scale-aware CPU/HLSL spherical field contract with
deterministic identity, cube-sphere topology, residual pages, LOD residency,
ray/path/region queries, coarse patches, and projected map-tile baking. See
`docs/planetary.md`.
- `CultMath.Unity`, an engine-facing assembly containing only Unity mesh and
page-upload adapters for the canonical planetary contracts.
## Native Core
@ -72,11 +66,6 @@ local `AetheriaMath` surface. The value-noise family follows public shader
literature from Inigo Quilez's noise and mini-spline articles, expressed in
CultMath names with tests instead of copied project-local helper drift.
The advanced erosion filter is an MPL-2.0 adaptation of Rune Skovbo Johansen's
published filter. Its faithful planar core is isolated in
`AdvancedErosionFilter.cs` and `AdvancedErosionFilter.hlsl`; spherical mapping
is a separate CultMath concern. See `THIRD-PARTY-NOTICES.md`.
## Build
```powershell

View file

@ -1,18 +0,0 @@
{
"name": "CultMath.PlanetaryViewer.Editor",
"rootNamespace": "CultMath.Unity.Samples.Editor",
"references": [
"CultMath.PlanetaryViewer"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View file

@ -1,26 +0,0 @@
using UnityEditor;
using UnityEngine;
namespace CultMath.Unity.Samples.Editor;
public static class CultMathPlanetaryViewerMenu
{
[MenuItem("GameObject/CultMath/Planetary Field Viewer", false, 10)]
public static void CreateViewer()
{
var root = new GameObject("CultMath Planetary Field Viewer");
Undo.RegisterCreatedObjectUndo(root, "Create CultMath Planetary Viewer");
root.AddComponent<CultMathPlanetaryViewer>();
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

@ -1,16 +0,0 @@
# Planetary Field Viewer
Import this sample through Unity's Package Manager, then choose
`GameObject > CultMath > Planetary Field Viewer`.
The host project must contain `Assets/csc.rsp` with
`-langversion:latest`, as documented in the CultMath package README.
The generated six-face mesh uses `PlanetaryPatch`. Its shader calls
`cultmath_planetary_field_sample` from the packaged CultMath HLSL, while the
component exposes the matching CPU `Sample` method. Seed, radius, physical
sample footprint, erosion settings, and base-field equations are shared between
the two bodies.
This is a renderer integration fixture, not a gameplay owner. It deliberately
contains no Aetheria state, biomes, resources, or simulation decisions.

View file

@ -1,17 +0,0 @@
{
"name": "CultMath.PlanetaryViewer",
"rootNamespace": "CultMath.Unity.Samples",
"references": [
"CultMath",
"CultMath.Unity"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View file

@ -1,139 +0,0 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace CultMath.Unity.Samples;
[ExecuteAlways]
public sealed class CultMathPlanetaryViewer : 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("CultMath/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

@ -1,90 +0,0 @@
Shader "CultMath/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"
#include "Packages/org.gamecult.cultmath/shaders/CultMath.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;
};
CultMathPlanetarySurfaceSample 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);
CultMathPlanetaryBaseFieldSample 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);
CultMathAdvancedErosionParameters 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;
CultMathPlanetaryFieldDefinition definition;
definition.radius = _Radius; definition.seed = _Seed; definition.erosion = erosion;
return cultmath_planetary_field_sample(definition, direction, base_sample, _SampleFootprint);
}
Varyings vert(Attributes input)
{
float3 direction = normalize(input.vertex.xyz);
CultMathPlanetarySurfaceSample 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

@ -9,26 +9,3 @@ Gustavson's `webgl-noise` project and Unity Mathematics.
- Copyright 2011 Ashima Arts.
- Upstream: <https://github.com/ashima/webgl-noise>
- License: MIT.
## Advanced Terrain Erosion Filter
`src/CultMath/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 CultMath files retain the MPL notice and separate the faithful planar
filter from CultMath's spherical adaptation. Modifications include C#/HLSL
type lowering, explicit bounded octave counts, finite zero-rounding behavior,
and CultMath naming. CultMath replaces the reference floating hash with a
shared integer avalanche hash so C# and GPU evaluation select identical cell
pivots across devices; this changes the stochastic pattern, not the filter
mechanics.
The technique builds on earlier work credited by the upstream author to Fewes
and Clay John. The hash form follows the public shader math of Inigo Quilez.

View file

@ -1,11 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\CultMath\CultMath.csproj" />
</ItemGroup>
</Project>

View file

@ -1,22 +0,0 @@
using CultMath;
if (args.Length != 1) throw new ArgumentException("Expected one output path.");
var field = PlanetaryFieldDefinition.Create(0x55aa, 10, 7, AdvancedErosionParameters.Default);
var layout = new PlanetaryMapTileLayout(new(PlanetaryProjectionKind.EqualEarth), 0, 0, 0, 5, 1);
var key = new PlanetaryMapTileKey(field.FieldVersion, 3, 29, layout, PlanetaryQueryScale.AtFootprint(0.25f, 0.125f));
var tile = PlanetaryMapTileBaker.Bake(field, key, new FixtureField());
var path = Path.GetFullPath(args[0]);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var stream = File.Create(path);
PlanetaryMapTileEncoding.Write(stream, tile);
readonly struct FixtureField : IPlanetaryBaseField
{
public 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

@ -19,12 +19,6 @@ Rust hot kernels, C# host code, and HLSL shader code.
- Keep the surface boring and exact. Noise primitives are allowed when they are
source-grounded, shader-shaped, and shared by more than one project. Matrices,
quaternions, and packing helpers still need an owner before they enter.
- Planetary geometry is a scale-aware field query, not a renderer asset. CPU
simulation is the reference consumer; pages, patches, and maps are derived
lowerings carrying field identity and unresolved error.
- Application base fields and gameplay meaning enter through narrow inputs.
CultMath does not own biomes, resources, traversability, render materials, or
committed game consequences.
## Non-Goals

View file

@ -1,130 +0,0 @@
# Planetary Field Runtime
CultMath 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 CultMath 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` and is included by `CultMath.hlsl`.
## 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
`cultmath_planetary_face_direction` for procedural vertices and
`cultmath_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 optional `CultMath.Unity` assembly 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 CultMath.
`PlanetaryMapTileEncoding` writes the resulting evidence as a versioned
little-endian `CMPT` binary asset. `web/planetary-tile.ts` decodes that exact
format into browser-native values without defining another terrain generator.
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

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 23a5751e4cececc4e99e024058d69add
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -10,12 +10,5 @@
"Simulation"
],
"license": "MPL-2.0",
"category": "Scripting",
"samples": [
{
"displayName": "Planetary Field Viewer",
"description": "Runnable Unity viewer using the canonical CultMath CPU and HLSL planetary field.",
"path": "Samples~/PlanetaryViewer"
}
]
"category": "Scripting"
}

View file

@ -1,9 +0,0 @@
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$fixture = Join-Path $root ".tools\planetary-tile\fixture.cmpt"
dotnet run --project (Join-Path $root "Tools~\CultMath.PlanetaryTileFixture\CultMath.PlanetaryTileFixture.csproj") -- $fixture
if ($LASTEXITCODE -ne 0) { throw "Planetary tile fixture generation failed." }
node --no-warnings --experimental-strip-types (Join-Path $root "web\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 "web\planetary-tile.test.mjs")
if ($LASTEXITCODE -ne 0) { throw "Planetary tile decoder unit test failed." }

View file

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 235e8f203bd1c4d40a8fa211273106ba
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,147 +0,0 @@
// 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 CULTMATH_ADVANCED_EROSION_FILTER_HLSL
#define CULTMATH_ADVANCED_EROSION_FILTER_HLSL
struct CultMathAdvancedErosionParameters
{
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 CultMathAdvancedErosionResult
{
float3 delta; float magnitude; float ridge_map; float fade_target;
};
struct CultMathErosionBandSelection
{
int active_octaves; float final_octave_weight; float finest_included_wavelength; float unresolved_height_bound;
};
CultMathErosionBandSelection cultmath_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);
}
CultMathErosionBandSelection 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 cultmath_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 cultmath_erosion_hash2(float2 input)
{
int2 cell = (int2)input;
return float2(cultmath_erosion_hash_unit(cell, 0x68bc21ebu), cultmath_erosion_hash_unit(cell, 0x02e5be93u)) * 2.0 - 1.0;
}
float4 cultmath_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 = cultmath_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 cultmath_erosion_pow_inverse(float value, float power) { return 1.0 - pow(1.0 - saturate(value), power); }
float cultmath_erosion_ease_out(float value) { float inverse = 1.0 - saturate(value); return 1.0 - inverse * inverse; }
float cultmath_erosion_smooth_start(float value, float smoothing)
{
return smoothing <= 1.0e-10 || value >= smoothing ? value - 0.5 * smoothing : 0.5 * value * value / smoothing;
}
float2 cultmath_erosion_safe_normalize(float2 value)
{
float value_length = length(value);
return abs(value_length) > 1.0e-10 ? value / value_length : value;
}
CultMathAdvancedErosionResult cultmath_advanced_erosion_filter_banded(float2 position, float3 base_height_and_slope, float fade_target, CultMathAdvancedErosionParameters p, CultMathErosionBandSelection 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 = cultmath_erosion_ease_out(cultmath_erosion_smooth_start(slope_length * p.onset.x, input_rounding * p.onset.x));
float ridge_mask = cultmath_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 = cultmath_phacelle_noise(position * frequency, cultmath_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 = cultmath_erosion_ease_out(cultmath_erosion_smooth_start(sloping * p.onset.y, octave_rounding * p.onset.y));
combined_mask = cultmath_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, cultmath_erosion_ease_out(sloping * p.onset.w), octave_weight);
strength *= p.gain;
frequency *= p.lacunarity;
rounding_multiplier *= p.rounding.w;
}
CultMathAdvancedErosionResult 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;
}
CultMathAdvancedErosionResult cultmath_advanced_erosion_filter(float2 position, float3 base_height_and_slope, float fade_target, CultMathAdvancedErosionParameters p)
{
CultMathErosionBandSelection 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 cultmath_advanced_erosion_filter_banded(position, base_height_and_slope, fade_target, p, band);
}
#endif

View file

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

View file

@ -299,98 +299,4 @@ float cultmath_value_noise_texture_bicubic(Texture2D<float> noise_texture, Sampl
return cultmath_catmullrom(row0, row1, row2, row3, local.y);
}
#include "AdvancedErosionFilter.hlsl"
#include "Planetary.hlsl"
struct CultMathSphericalErosionParameters
{
float frequency; float amplitude; float lacunarity; float gain;
float slope_strength; float detail; float seed; int octaves;
};
struct CultMathSphericalErosionSample
{
float height_offset; float ridge; float gully; float3 gradient;
};
float cultmath_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 cultmath_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(cultmath_spherical_cell_wave(position, flow, cell + float3(0,0,0), seed), cultmath_spherical_cell_wave(position, flow, cell + float3(1,0,0), seed), weights.x),
cultmath_lerp(cultmath_spherical_cell_wave(position, flow, cell + float3(0,1,0), seed), cultmath_spherical_cell_wave(position, flow, cell + float3(1,1,0), seed), weights.x), weights.y);
float z1 = cultmath_lerp(
cultmath_lerp(cultmath_spherical_cell_wave(position, flow, cell + float3(0,0,1), seed), cultmath_spherical_cell_wave(position, flow, cell + float3(1,0,1), seed), weights.x),
cultmath_lerp(cultmath_spherical_cell_wave(position, flow, cell + float3(0,1,1), seed), cultmath_spherical_cell_wave(position, flow, cell + float3(1,1,1), seed), weights.x), weights.y);
return cultmath_lerp(z0, z1, weights.z);
}
float3 cultmath_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));
}
CultMathSphericalErosionSample cultmath_spherical_erosion(float3 unit_position, float base_height, float3 base_gradient, CultMathSphericalErosionParameters 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 : cultmath_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;
CultMathSphericalErosionSample result = (CultMathSphericalErosionSample)0;
[loop] for (int octave = 0; octave < min(max(p.octaves, 0), 8); octave++)
{
float phase = cultmath_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 : cultmath_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;
}
float3 cultmath_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

@ -1,392 +0,0 @@
#ifndef CULTMATH_PLANETARY_HLSL
#define CULTMATH_PLANETARY_HLSL
static const float CULTMATH_PLANETARY_QUARTER_PI = 0.78539816339744830962;
float3 cultmath_planetary_face_direction(int face, float2 coordinate)
{
float2 tangent = tan(coordinate * CULTMATH_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 cultmath_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) / CULTMATH_PLANETARY_QUARTER_PI;
}
bool cultmath_planetary_page_local(float3 direction, float4 tile_address, float active, out float2 local)
{
int face;
float2 uv = cultmath_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 cultmath_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 cultmath_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 cultmath_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 CULTMATH_EQUAL_EARTH_A1 = 1.340264;
static const float CULTMATH_EQUAL_EARTH_A2 = -0.081106;
static const float CULTMATH_EQUAL_EARTH_A3 = 0.000893;
static const float CULTMATH_EQUAL_EARTH_A4 = 0.003796;
static const float CULTMATH_EQUAL_EARTH_M = 0.8660254037844386;
static const float CULTMATH_EQUAL_EARTH_X_MAX = 2.706629;
static const float CULTMATH_EQUAL_EARTH_Y_MAX = 1.3173628;
float cultmath_equal_earth_y(float theta)
{
float theta2 = theta * theta;
float theta6 = theta2 * theta2 * theta2;
return theta * (CULTMATH_EQUAL_EARTH_A1 + CULTMATH_EQUAL_EARTH_A2 * theta2 + theta6 * (CULTMATH_EQUAL_EARTH_A3 + CULTMATH_EQUAL_EARTH_A4 * theta2));
}
float2 cultmath_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(CULTMATH_EQUAL_EARTH_M * sin(latitude));
float theta2 = theta * theta;
float theta6 = theta2 * theta2 * theta2;
float denominator = CULTMATH_EQUAL_EARTH_M * (CULTMATH_EQUAL_EARTH_A1 + 3.0 * CULTMATH_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * CULTMATH_EQUAL_EARTH_A3 + 9.0 * CULTMATH_EQUAL_EARTH_A4 * theta2));
return float2(longitude * cos(theta) / denominator / CULTMATH_EQUAL_EARTH_X_MAX, cultmath_equal_earth_y(theta) / CULTMATH_EQUAL_EARTH_Y_MAX);
}
float3 cultmath_planetary_equal_earth_inverse(float2 coordinate)
{
float target_y = coordinate.y * CULTMATH_EQUAL_EARTH_Y_MAX;
float theta = target_y / CULTMATH_EQUAL_EARTH_A1;
[unroll] for (int iteration = 0; iteration < 8; iteration++)
{
float theta2 = theta * theta;
float theta6 = theta2 * theta2 * theta2;
float derivative = CULTMATH_EQUAL_EARTH_A1 + 3.0 * CULTMATH_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * CULTMATH_EQUAL_EARTH_A3 + 9.0 * CULTMATH_EQUAL_EARTH_A4 * theta2);
theta -= (cultmath_equal_earth_y(theta) - target_y) / derivative;
}
float latitude = asin(clamp(sin(theta) / CULTMATH_EQUAL_EARTH_M, -1.0, 1.0));
float theta2 = theta * theta;
float theta6 = theta2 * theta2 * theta2;
float denominator = CULTMATH_EQUAL_EARTH_M * (CULTMATH_EQUAL_EARTH_A1 + 3.0 * CULTMATH_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * CULTMATH_EQUAL_EARTH_A3 + 9.0 * CULTMATH_EQUAL_EARTH_A4 * theta2));
float longitude = coordinate.x * CULTMATH_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 cultmath_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 cultmath_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 cultmath_planetary_asinh(float value) { return log(value + sqrt(value * value + 1.0)); }
float cultmath_planetary_sinh(float value) { return 0.5 * (exp(value) - exp(-value)); }
bool cultmath_planetary_azimuthal_forward(float3 direction, float2 center_lon_lat, int kind, out float2 coordinate)
{
direction = cultmath_normalize(direction);
float longitude = cultmath_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 cultmath_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 = cultmath_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 = cultmath_planetary_lon_lat_direction(cultmath_planetary_wrap_longitude(delta_longitude + center_lon_lat.x), latitude);
return true;
}
bool cultmath_planetary_cube_atlas_forward(float3 direction, out float2 coordinate)
{
int face;
float2 face_coordinate = cultmath_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 cultmath_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 = cultmath_planetary_face_direction(row * 3 + column, float2(u, v));
return true;
}
bool cultmath_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 = cultmath_planetary_cube_atlas_forward(direction, coordinate);
coordinate /= scale; return valid;
}
if (kind >= 3)
{
bool valid = cultmath_planetary_azimuthal_forward(direction, center_lon_lat_scale.xy, kind, coordinate);
coordinate /= scale; return valid;
}
direction = cultmath_normalize(direction);
float longitude = cultmath_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, cultmath_planetary_asinh(tan(latitude)) / CULTMATH_PI);
}
else
{
float3 centered = cultmath_planetary_lon_lat_direction(longitude, latitude);
coordinate = cultmath_planetary_equal_earth_forward(centered);
}
coordinate /= scale;
return true;
}
bool cultmath_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 cultmath_planetary_cube_atlas_inverse(coordinate, direction);
if (kind >= 3) return cultmath_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(cultmath_planetary_sinh(coordinate.y * CULTMATH_PI)); }
else
{
float3 centered = cultmath_planetary_equal_earth_inverse(coordinate);
longitude = atan2(centered.y, centered.x); latitude = asin(clamp(centered.z, -1.0, 1.0));
}
direction = cultmath_planetary_lon_lat_direction(cultmath_planetary_wrap_longitude(longitude + center_lon_lat_scale.x), latitude);
return true;
}
struct CultMathPlanetaryFieldDefinition
{
float radius;
int seed;
CultMathAdvancedErosionParameters erosion;
};
struct CultMathPlanetaryBaseFieldSample
{
float radial_displacement;
float3 radial_gradient;
float field_value;
float3 field_gradient;
float fade_target;
};
struct CultMathPlanetarySurfaceSample
{
float radial_displacement;
float3 tangent_gradient;
float3 surface_normal;
float slope;
float ridge;
float gully;
float finest_resolved_wavelength;
float unresolved_height_bound;
};
struct CultMathPlanetaryPageSample
{
float4 height_gradient;
float2 masks;
};
CultMathPlanetaryPageSample cultmath_planetary_page_lerp(
CultMathPlanetaryPageSample a,
CultMathPlanetaryPageSample b,
CultMathPlanetaryPageSample c,
CultMathPlanetaryPageSample d,
float2 fraction)
{
CultMathPlanetaryPageSample 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;
}
CultMathPlanetaryPageSample cultmath_planetary_residual_sample(
CultMathPlanetarySurfaceSample child,
CultMathPlanetarySurfaceSample parent,
bool has_parent)
{
CultMathPlanetaryPageSample 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 cultmath_planetary_summary_empty()
{
return float4(3.402823466e+38, -3.402823466e+38, 0.0, 0.0);
}
float4 cultmath_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 cultmath_planetary_pow4(float value) { float square = value * value; return square * square; }
CultMathPlanetarySurfaceSample cultmath_planetary_field_sample(
CultMathPlanetaryFieldDefinition definition,
float3 unit_direction,
CultMathPlanetaryBaseFieldSample 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);
CultMathAdvancedErosionParameters p = definition.erosion;
CultMathErosionBandSelection band = cultmath_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(
cultmath_planetary_pow4(abs(direction.x)),
cultmath_planetary_pow4(abs(direction.y)),
cultmath_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;
CultMathAdvancedErosionResult xy = cultmath_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);
CultMathAdvancedErosionResult yz = cultmath_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);
CultMathAdvancedErosionResult zx = cultmath_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;
CultMathPlanetarySurfaceSample result;
result.radial_displacement = base_sample.radial_displacement + erosion_height;
result.tangent_gradient = gradient;
result.surface_normal = cultmath_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

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

View file

@ -1,139 +0,0 @@
// 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
namespace CultMath;
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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: bec15fdcbe97a7f44a24bdb3ff79e1e7

View file

@ -19,8 +19,6 @@
<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
<None Include="..\..\shaders\CultMath.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.hlsl" CopyToOutputDirectory="PreserveNewest" />
<None Include="..\..\shaders\AdvancedErosionFilter.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\AdvancedErosionFilter.hlsl" CopyToOutputDirectory="PreserveNewest" />
<None Include="..\..\shaders\Planetary.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\Planetary.hlsl" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View file

@ -1,57 +0,0 @@
namespace CultMath;
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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 05e7969589dd09b43bd0fda672d25a0b

View file

@ -1,215 +0,0 @@
using System;
namespace CultMath;
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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: b61d6354e3aab054e94721e77e619bfa

View file

@ -1,55 +0,0 @@
using System;
namespace CultMath;
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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 42173c60766948a4e8c376687033532e

View file

@ -1,117 +0,0 @@
using System;
using System.IO;
using System.Text;
namespace CultMath;
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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6898f6de57df20646a0b9077bb923592

View file

@ -1,187 +0,0 @@
using System;
namespace CultMath;
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

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

View file

@ -1,45 +0,0 @@
using System;
using System.Linq;
namespace CultMath;
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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 04651571f7d240a418c1f6aafa217fa6

View file

@ -1,322 +0,0 @@
using System;
namespace CultMath;
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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 5021202cf626f7442a57961255a8a217

View file

@ -1,193 +0,0 @@
using System;
namespace CultMath;
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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 2ec43c567214b8247bda19b5c120c200

View file

@ -1,28 +0,0 @@
namespace CultMath;
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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 82e4db521edc253499220458b417fd1e

View file

@ -1,148 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace CultMath;
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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 0585ea15edd7f1c4fa272df4911a8d6f

View file

@ -1,191 +0,0 @@
using System;
namespace CultMath;
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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 88f6789c4384f7846ac4be37613fe5ed

View file

@ -1,108 +0,0 @@
namespace CultMath;
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 cultmath_spherical_erosion in CultMath.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,2 +0,0 @@
fileFormatVersion: 2
guid: 59ec2db73065f6c4483ad90f5e17850e

View file

@ -22,9 +22,6 @@ public static class math
public static float4 float4(float3 xyz, float w) => new(xyz, w);
public static float4 float4(float x, float3 yzw) => new(x, yzw);
public static float4 float4(float value) => new(value, value, value, value);
public static rect rect(float2 min, float2 max) => new(min, max);
public static rect rect(float minX, float minY, float maxX, float maxY) => new(minX, minY, maxX, maxY);
public static float radians(float degrees) => degrees * (PI / 180.0f);
public static float degrees(float radians) => radians * (180.0f / PI);
public static float sin(float value) => MathF.Sin(value);

View file

@ -1,21 +0,0 @@
namespace CultMath;
public readonly record struct rect(float2 min, float2 max)
{
public rect(float minX, float minY, float maxX, float maxY)
: this(math.float2(math.min(minX, maxX), math.min(minY, maxY)),
math.float2(math.max(minX, maxX), math.max(minY, maxY)))
{
}
public float2 size => max - min;
public float2 center => (min + max) * 0.5f;
public bool Contains(float2 point)
{
return point.x >= min.x &&
point.x <= max.x &&
point.y >= min.y &&
point.y <= max.y;
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: b00dc5f108967db47ac2f54a47033e33

View file

@ -1,44 +0,0 @@
using Xunit;
namespace CultMath.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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: a7c85309318ae3f4ba75ac28a4f661cf

View file

@ -1,34 +0,0 @@
using Xunit;
namespace CultMath.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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 095f8ed13d2f2f74baefd23310395256

View file

@ -5,22 +5,13 @@ namespace CultMath.Tests;
public sealed class HlslMirrorTests
{
[Fact]
public void HlslMirrorPublishesSphericalErosionKernel()
public void HlslMirrorDoesNotPublishGeometryKernels()
{
var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "shaders", "CultMath.hlsl"));
Assert.Contains("cultmath_spherical_erosion", source);
Assert.Contains("CultMathSphericalErosionParameters", source);
}
[Fact]
public void HlslMirrorPublishesFaithfulAdvancedErosionKernel()
{
var mirror = Path.Combine(GetRepositoryRoot(), "shaders", "CultMath.hlsl");
var source = File.ReadAllText(mirror);
Assert.Contains("AdvancedErosionFilter.hlsl", source);
var advanced = File.ReadAllText(Path.Combine(Path.GetDirectoryName(mirror)!, "AdvancedErosionFilter.hlsl"));
Assert.Contains("cultmath_phacelle_noise", advanced);
Assert.Contains("cultmath_advanced_erosion_filter", advanced);
Assert.DoesNotContain("AdvancedErosionFilter.hlsl", source);
Assert.DoesNotContain("Planetary.hlsl", source);
Assert.DoesNotContain("spherical_erosion", source);
Assert.DoesNotContain("planetary_radial_refinement", source);
}
[Fact]
@ -66,24 +57,6 @@ public sealed class HlslMirrorTests
Assert.Contains("max(length(value), CULTMATH_NORMALIZE_EPSILON)", include);
}
[Fact]
public void HlslMirrorPublishesPlanetaryTopologyAndField()
{
var root = GetRepositoryRoot();
var include = File.ReadAllText(Path.Combine(root, "shaders", "CultMath.hlsl"));
var planetary = File.ReadAllText(Path.Combine(root, "shaders", "Planetary.hlsl"));
Assert.Contains("Planetary.hlsl", include);
Assert.Contains("cultmath_planetary_face_direction", planetary);
Assert.Contains("cultmath_planetary_face_coordinate", planetary);
Assert.Contains("cultmath_planetary_page_local", planetary);
Assert.Contains("cultmath_planetary_field_sample", planetary);
Assert.Contains("cultmath_planetary_equirectangular_forward", planetary);
Assert.Contains("cultmath_planetary_equal_earth_forward", planetary);
Assert.Contains("cultmath_planetary_projection_forward", planetary);
Assert.Contains("cultmath_planetary_projection_inverse", planetary);
Assert.Contains("CultMathPlanetarySurfaceSample", planetary);
}
private static string GetRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);

View file

@ -32,19 +32,6 @@ public sealed class MathTests
Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(new float3(1.0f, 2.0f, 3.0f), 4.0f));
}
[Fact]
public void RectNormalizesBoundsAndContainsPoints()
{
var viewport = math.rect(10.0f, 20.0f, -5.0f, -2.0f);
Assert.Equal(new float2(-5.0f, -2.0f), viewport.min);
Assert.Equal(new float2(10.0f, 20.0f), viewport.max);
Assert.Equal(new float2(15.0f, 22.0f), viewport.size);
Assert.Equal(new float2(2.5f, 9.0f), viewport.center);
Assert.True(viewport.Contains(new float2(0.0f, 0.0f)));
Assert.False(viewport.Contains(new float2(11.0f, 0.0f)));
}
[Fact]
public void VectorsExposeCommonReadOnlySwizzles()
{

View file

@ -1,80 +0,0 @@
using Xunit;
namespace CultMath.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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: c747cfda80df21d41a57412d8d07c08a

View file

@ -1,25 +0,0 @@
using Xunit;
namespace CultMath.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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 87266638e8010f546ab6c430a712868b

View file

@ -1,52 +0,0 @@
using System.IO;
using Xunit;
namespace CultMath.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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: e916ef83478d48b43ac99777f5e0f731

View file

@ -1,57 +0,0 @@
using Xunit;
namespace CultMath.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

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

View file

@ -1,40 +0,0 @@
using Xunit;
namespace CultMath.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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 36840ef44326f84409f850163b00e1f7

View file

@ -1,73 +0,0 @@
using Xunit;
namespace CultMath.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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: f370036bb1663524fb4a4d47dfd50401

View file

@ -1,46 +0,0 @@
using Xunit;
namespace CultMath.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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 93cf8771fdd520b41a4e931a6566a80a

View file

@ -1,29 +0,0 @@
using Xunit;
namespace CultMath.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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: d34c0018ee2b700448b16540eca8ebcf

View file

@ -1,57 +0,0 @@
using Xunit;
namespace CultMath.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

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

View file

@ -1,63 +0,0 @@
using Xunit;
namespace CultMath.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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: be3b0ec07fe65344db889d1e98448113

View file

@ -1,34 +0,0 @@
using Xunit;
namespace CultMath.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

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 60eb9e9e9f8ae2948b13403f5080168e

View file

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 7197a2202273a9445b974bdd69109b1a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,95 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using UnityEngine;
namespace CultMath.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 layout = new PlanetaryPageLayout(resident.Tile, interiorSize, borderSize).Validate();
var content = PlanetaryGpuPageBuilder.BuildContent(layout, radius);
var outputOffset = inputs.Count;
foreach (var input in content.Inputs)
{
inputs.Add(new(
ToUnity(input.DirectionRadius),
ToUnity(input.Sampling)));
}
var commonMetadata = PlanetaryGpuPageBuilder.Metadata(content, outputOffset, resident.Blend);
metadata[pageIndex] = new(
ToUnity(commonMetadata.Address),
ToUnity(commonMetadata.Layout),
ToUnity(commonMetadata.Bounds),
ToUnity(commonMetadata.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

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

View file

@ -1,36 +0,0 @@
using System;
using UnityEngine;
using UnityEngine.Rendering;
namespace CultMath.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 = $"CultMath 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

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

View file

@ -1,21 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { decodePlanetarySurfaceTile } from "./planetary-tile.ts";
if (process.argv.length !== 3) throw new Error("Expected a generated CMPT fixture path");
const bytes = await readFile(process.argv[2]);
const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
const tile = decodePlanetarySurfaceTile(arrayBuffer);
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) continue;
assert.ok(Number.isFinite(sample.radialDisplacement));
assert.ok(Number.isFinite(sample.unresolvedHeightBound));
}

View file

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

View file

@ -1,39 +0,0 @@
import assert from "node:assert/strict";
import { decodePlanetarySurfaceTile } from "./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, bytes.byteLength - 1)), /Truncated/);
const trailing = new Uint8Array(bytes.byteLength + 1);
trailing.set(new Uint8Array(bytes));
assert.throws(() => decodePlanetarySurfaceTile(trailing.buffer), /trailing/);

View file

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 04c93ecab90d465499aa3d60722e6fc3
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,89 +0,0 @@
export const CULTMATH_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 CultMath planetary tile");
};
requireRemaining(8);
if (String.fromCharCode(u8(), u8(), u8(), u8()) !== "CMPT") throw new Error("Not a CultMath planetary tile");
const version = u32();
if (version !== CULTMATH_PLANETARY_TILE_VERSION) throw new Error(`Unsupported CultMath 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("CultMath 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("CultMath planetary tile has trailing bytes");
return { header, storageSize, samples };
}

View file

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