diff --git a/.gitignore b/.gitignore
index 5ae20c8..3779002 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,5 @@ obj/
**/obj/
target/
**/target/
+/native/cultmath-core/target.meta
.tools/
diff --git a/CultMath.slnx b/CultMath.slnx
index 9583457..05d1788 100644
--- a/CultMath.slnx
+++ b/CultMath.slnx
@@ -5,4 +5,7 @@
+
+
+
diff --git a/README.md b/README.md
index 29eb115..c60a991 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,20 @@ cargo test --manifest-path native/cultmath-core/Cargo.toml
cargo build --release --manifest-path native/cultmath-core/Cargo.toml
```
+## Unity compiler
+
+CultMath uses C# 10 record structs and file-scoped namespaces. Unity projects
+must select the compiler's latest language mode with an `Assets/csc.rsp` file:
+
+```text
+-langversion:latest
+```
+
+The package's Unity smoke project verifies this contract against Unity 6. The
+setting is already present in current GameCult Unity clients; it belongs to the
+consumer project because Unity only reads compiler response files from its
+`Assets` root.
+
## Shader Tooling
CultMath keeps portable DXC outside git under `.tools/`:
diff --git a/Samples~/PlanetaryViewer/Editor/CultMath.PlanetaryViewer.Editor.asmdef b/Samples~/PlanetaryViewer/Editor/CultMath.PlanetaryViewer.Editor.asmdef
new file mode 100644
index 0000000..1051232
--- /dev/null
+++ b/Samples~/PlanetaryViewer/Editor/CultMath.PlanetaryViewer.Editor.asmdef
@@ -0,0 +1,18 @@
+{
+ "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
+}
diff --git a/Samples~/PlanetaryViewer/Editor/CultMathPlanetaryViewerMenu.cs b/Samples~/PlanetaryViewer/Editor/CultMathPlanetaryViewerMenu.cs
new file mode 100644
index 0000000..ade28c3
--- /dev/null
+++ b/Samples~/PlanetaryViewer/Editor/CultMathPlanetaryViewerMenu.cs
@@ -0,0 +1,26 @@
+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();
+ Selection.activeGameObject = root;
+
+ if (Camera.main != null) return;
+ var cameraObject = new GameObject("Planetary Viewer Camera");
+ Undo.RegisterCreatedObjectUndo(cameraObject, "Create Planetary Viewer Camera");
+ cameraObject.tag = "MainCamera";
+ cameraObject.transform.position = new Vector3(0, 0, -24);
+ cameraObject.transform.LookAt(root.transform);
+ var camera = cameraObject.AddComponent();
+ camera.nearClipPlane = 0.01f;
+ camera.farClipPlane = 1000;
+ }
+}
diff --git a/Samples~/PlanetaryViewer/README.md b/Samples~/PlanetaryViewer/README.md
new file mode 100644
index 0000000..ec4dcc3
--- /dev/null
+++ b/Samples~/PlanetaryViewer/README.md
@@ -0,0 +1,16 @@
+# 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.
diff --git a/Samples~/PlanetaryViewer/Runtime/CultMath.PlanetaryViewer.asmdef b/Samples~/PlanetaryViewer/Runtime/CultMath.PlanetaryViewer.asmdef
new file mode 100644
index 0000000..2d3676f
--- /dev/null
+++ b/Samples~/PlanetaryViewer/Runtime/CultMath.PlanetaryViewer.asmdef
@@ -0,0 +1,17 @@
+{
+ "name": "CultMath.PlanetaryViewer",
+ "rootNamespace": "CultMath.Unity.Samples",
+ "references": [
+ "CultMath",
+ "CultMath.Unity"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.cs b/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.cs
new file mode 100644
index 0000000..0611372
--- /dev/null
+++ b/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.cs
@@ -0,0 +1,139 @@
+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 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().sharedMesh = mesh;
+ child.AddComponent().sharedMaterial = material;
+ }
+ builtCells = CellsPerFace;
+ ApplyMaterial();
+ }
+
+ private void ApplyMaterial()
+ {
+ if (material == null) return;
+ var p = ViewerErosion(Radius);
+ material.SetFloat("_Radius", Radius);
+ material.SetFloat("_SampleFootprint", SampleFootprint);
+ material.SetInt("_Seed", Seed);
+ material.SetFloat("_ErosionScale", p.Scale);
+ material.SetFloat("_ErosionStrength", p.Strength);
+ material.SetFloat("_GullyWeight", p.GullyWeight);
+ material.SetFloat("_Detail", p.Detail);
+ material.SetVector("_Rounding", new(p.Rounding.x, p.Rounding.y, p.Rounding.z, p.Rounding.w));
+ material.SetVector("_Onset", new(p.Onset.x, p.Onset.y, p.Onset.z, p.Onset.w));
+ material.SetVector("_AssumedSlope", new(p.AssumedSlope.x, p.AssumedSlope.y, 0, 0));
+ material.SetFloat("_CellScale", p.CellScale);
+ material.SetFloat("_Normalization", p.Normalization);
+ material.SetInt("_Octaves", p.Octaves);
+ material.SetFloat("_Lacunarity", p.Lacunarity);
+ material.SetFloat("_Gain", p.Gain);
+ material.SetVector("_LightDirection", LightDirection.normalized);
+ material.SetColor("_LowColor", LowColor);
+ material.SetColor("_HighColor", HighColor);
+ material.SetColor("_RidgeColor", RidgeColor);
+ }
+
+ private void OnDisable() => ClearGenerated();
+ private void OnDestroy() => ClearGenerated();
+
+ private void ClearGenerated()
+ {
+ for (var i = transform.childCount - 1; i >= 0; i--) DestroyGenerated(transform.GetChild(i).gameObject);
+ foreach (var mesh in meshes) DestroyGenerated(mesh);
+ meshes.Clear();
+ if (material != null) DestroyGenerated(material);
+ material = null;
+ builtCells = 0;
+ }
+
+ private static void DestroyGenerated(UnityEngine.Object value)
+ {
+ if (Application.isPlaying) Destroy(value);
+ else DestroyImmediate(value);
+ }
+
+ private static AdvancedErosionParameters ViewerErosion(float radius) => new(
+ radius * 0.075f, 0.12f, 0.58f, 1.45f,
+ new float4(0.1f, 0.015f, 0.1f, 2),
+ new float4(1.25f, 1.25f, 2.8f, 1.5f),
+ new float2(0.7f, 0.85f), 0.7f, 0.5f, 7, 2, 0.5f);
+
+ private readonly struct ViewerBaseField : IPlanetaryBaseField
+ {
+ private readonly float radius;
+ public ViewerBaseField(float radius) => this.radius = radius;
+
+ public PlanetaryBaseFieldSample Sample(float3 direction)
+ {
+ var value = direction.x * 0.6f + direction.y * direction.z * 0.3f;
+ var angularGradient = new float3(0.6f, direction.z * 0.3f, direction.y * 0.3f);
+ angularGradient -= direction * math.dot(angularGradient, direction);
+ var fieldGradient = angularGradient / radius;
+ var radialDisplacement = value * radius * 0.035f;
+ var radialGradient = angularGradient * 0.035f;
+ return new(radialDisplacement, radialGradient, value, fieldGradient, math.clamp(value, -1, 1));
+ }
+ }
+}
diff --git a/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.shader b/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.shader
new file mode 100644
index 0000000..b5f7274
--- /dev/null
+++ b/Samples~/PlanetaryViewer/Runtime/CultMathPlanetaryViewer.shader
@@ -0,0 +1,90 @@
+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
+ }
+ }
+}
diff --git a/THIRD-PARTY-NOTICES.md.meta b/THIRD-PARTY-NOTICES.md.meta
new file mode 100644
index 0000000..4b43dda
--- /dev/null
+++ b/THIRD-PARTY-NOTICES.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 7c28c8631c7d4054eb751d7ce952df33
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tools~/CultMath.PlanetaryTileFixture/CultMath.PlanetaryTileFixture.csproj b/Tools~/CultMath.PlanetaryTileFixture/CultMath.PlanetaryTileFixture.csproj
new file mode 100644
index 0000000..9a6f8bd
--- /dev/null
+++ b/Tools~/CultMath.PlanetaryTileFixture/CultMath.PlanetaryTileFixture.csproj
@@ -0,0 +1,11 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
diff --git a/Tools~/CultMath.PlanetaryTileFixture/Program.cs b/Tools~/CultMath.PlanetaryTileFixture/Program.cs
new file mode 100644
index 0000000..1952fa2
--- /dev/null
+++ b/Tools~/CultMath.PlanetaryTileFixture/Program.cs
@@ -0,0 +1,22 @@
+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);
+ }
+}
diff --git a/docs/planetary.md b/docs/planetary.md
index 8196c1c..3503429 100644
--- a/docs/planetary.md
+++ b/docs/planetary.md
@@ -80,11 +80,17 @@ coarse meshes from the canonical topology. HLSL calls
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 a residency snapshot into Unity-native page input and
-metadata payloads. It owns no field parameters, page selection, or simulation
-state. Unity render code remains responsible for allocating/binding graphics
-buffers and dispatching application-specific base-field entry points.
+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
@@ -103,6 +109,12 @@ 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
@@ -111,7 +123,8 @@ CultMath's matched C#/HLSL semantics. Hardware transcendental functions such as
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 and the QSC
-forward/inverse topology. The remaining production page and patch shaders use
-the common functions and retain their seam, residual, summary, and radial-hit
-integration probes in Fensalir.
+Current D3D12 parity evidence covers the advanced erosion kernel, QSC topology,
+and forward/inverse transforms for every supported map projection, including
+center and scale parameters. Production page and patch shaders use the common
+functions and retain their seam, residual, summary, and radial-hit integration
+probes in Fensalir.
diff --git a/docs/planetary.md.meta b/docs/planetary.md.meta
new file mode 100644
index 0000000..af364c2
--- /dev/null
+++ b/docs/planetary.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 23a5751e4cececc4e99e024058d69add
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/package.json b/package.json
index 6da2a17..2564360 100644
--- a/package.json
+++ b/package.json
@@ -10,5 +10,12 @@
"Simulation"
],
"license": "MPL-2.0",
- "category": "Scripting"
+ "category": "Scripting",
+ "samples": [
+ {
+ "displayName": "Planetary Field Viewer",
+ "description": "Runnable Unity viewer using the canonical CultMath CPU and HLSL planetary field.",
+ "path": "Samples~/PlanetaryViewer"
+ }
+ ]
}
diff --git a/scripts.meta b/scripts.meta
new file mode 100644
index 0000000..6f03c56
--- /dev/null
+++ b/scripts.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 0ddff5b782cf14e43ae5cda048e040d8
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/scripts/test-web-planetary-tile.ps1 b/scripts/test-web-planetary-tile.ps1
new file mode 100644
index 0000000..5221fb3
--- /dev/null
+++ b/scripts/test-web-planetary-tile.ps1
@@ -0,0 +1,9 @@
+$ErrorActionPreference = "Stop"
+$root = Split-Path -Parent $PSScriptRoot
+$fixture = Join-Path $root ".tools\planetary-tile\fixture.cmpt"
+dotnet run --project (Join-Path $root "Tools~\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." }
diff --git a/scripts/test-web-planetary-tile.ps1.meta b/scripts/test-web-planetary-tile.ps1.meta
new file mode 100644
index 0000000..ffd7323
--- /dev/null
+++ b/scripts/test-web-planetary-tile.ps1.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 235e8f203bd1c4d40a8fa211273106ba
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/shaders/AdvancedErosionFilter.hlsl.meta b/shaders/AdvancedErosionFilter.hlsl.meta
new file mode 100644
index 0000000..a354e33
--- /dev/null
+++ b/shaders/AdvancedErosionFilter.hlsl.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 3252ac02e8853ef4b9d1fbc5204b5c6b
+ShaderIncludeImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/shaders/Planetary.hlsl b/shaders/Planetary.hlsl
index 729c633..81e312b 100644
--- a/shaders/Planetary.hlsl
+++ b/shaders/Planetary.hlsl
@@ -120,6 +120,138 @@ float3 cultmath_planetary_equal_earth_inverse(float2 coordinate)
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;
diff --git a/shaders/Planetary.hlsl.meta b/shaders/Planetary.hlsl.meta
new file mode 100644
index 0000000..f777135
--- /dev/null
+++ b/shaders/Planetary.hlsl.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: bb055ad5328888e479e90ae3fe62c3fe
+ShaderIncludeImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/src/CultMath/AdvancedErosionFilter.cs.meta b/src/CultMath/AdvancedErosionFilter.cs.meta
new file mode 100644
index 0000000..52f7ca1
--- /dev/null
+++ b/src/CultMath/AdvancedErosionFilter.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: bec15fdcbe97a7f44a24bdb3ff79e1e7
\ No newline at end of file
diff --git a/src/CultMath/ErosionFrequencyBands.cs.meta b/src/CultMath/ErosionFrequencyBands.cs.meta
new file mode 100644
index 0000000..cae9964
--- /dev/null
+++ b/src/CultMath/ErosionFrequencyBands.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 05e7969589dd09b43bd0fda672d25a0b
\ No newline at end of file
diff --git a/src/CultMath/PlanetaryField.cs b/src/CultMath/PlanetaryField.cs
index 13f36c0..4d5596e 100644
--- a/src/CultMath/PlanetaryField.cs
+++ b/src/CultMath/PlanetaryField.cs
@@ -1,3 +1,5 @@
+using System;
+
namespace CultMath;
public readonly record struct PlanetaryQueryScale(float FootprintMeters, float MaximumUnresolvedHeight)
diff --git a/src/CultMath/PlanetaryField.cs.meta b/src/CultMath/PlanetaryField.cs.meta
new file mode 100644
index 0000000..3e83f63
--- /dev/null
+++ b/src/CultMath/PlanetaryField.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b61d6354e3aab054e94721e77e619bfa
\ No newline at end of file
diff --git a/src/CultMath/PlanetaryGpuPages.cs b/src/CultMath/PlanetaryGpuPages.cs
new file mode 100644
index 0000000..024d26b
--- /dev/null
+++ b/src/CultMath/PlanetaryGpuPages.cs
@@ -0,0 +1,55 @@
+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));
+ }
+}
diff --git a/src/CultMath/PlanetaryGpuPages.cs.meta b/src/CultMath/PlanetaryGpuPages.cs.meta
new file mode 100644
index 0000000..7afe847
--- /dev/null
+++ b/src/CultMath/PlanetaryGpuPages.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 42173c60766948a4e8c376687033532e
\ No newline at end of file
diff --git a/src/CultMath/PlanetaryMapTileEncoding.cs b/src/CultMath/PlanetaryMapTileEncoding.cs
new file mode 100644
index 0000000..ec55661
--- /dev/null
+++ b/src/CultMath/PlanetaryMapTileEncoding.cs
@@ -0,0 +1,117 @@
+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());
+}
diff --git a/src/CultMath/PlanetaryMapTileEncoding.cs.meta b/src/CultMath/PlanetaryMapTileEncoding.cs.meta
new file mode 100644
index 0000000..76484f6
--- /dev/null
+++ b/src/CultMath/PlanetaryMapTileEncoding.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 6898f6de57df20646a0b9077bb923592
\ No newline at end of file
diff --git a/src/CultMath/PlanetaryPages.cs b/src/CultMath/PlanetaryPages.cs
index c9cff51..699a5c1 100644
--- a/src/CultMath/PlanetaryPages.cs
+++ b/src/CultMath/PlanetaryPages.cs
@@ -1,3 +1,5 @@
+using System;
+
namespace CultMath;
public readonly record struct PlanetaryPageLayout(PlanetaryTileAddress Tile, int InteriorSize, int BorderSize)
diff --git a/src/CultMath/PlanetaryPages.cs.meta b/src/CultMath/PlanetaryPages.cs.meta
new file mode 100644
index 0000000..91fdc27
--- /dev/null
+++ b/src/CultMath/PlanetaryPages.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7af9a0354faa56348a2e07188739a0ae
\ No newline at end of file
diff --git a/src/CultMath/PlanetaryPatch.cs b/src/CultMath/PlanetaryPatch.cs
index 0fd3cbb..30c852f 100644
--- a/src/CultMath/PlanetaryPatch.cs
+++ b/src/CultMath/PlanetaryPatch.cs
@@ -1,3 +1,6 @@
+using System;
+using System.Linq;
+
namespace CultMath;
public readonly record struct PlanetaryPatchVertex(float2 LocalCoordinate, float3 UnitDirection);
diff --git a/src/CultMath/PlanetaryPatch.cs.meta b/src/CultMath/PlanetaryPatch.cs.meta
new file mode 100644
index 0000000..efd8156
--- /dev/null
+++ b/src/CultMath/PlanetaryPatch.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 04651571f7d240a418c1f6aafa217fa6
\ No newline at end of file
diff --git a/src/CultMath/PlanetaryProjection.cs b/src/CultMath/PlanetaryProjection.cs
index f849fe2..a82ebfd 100644
--- a/src/CultMath/PlanetaryProjection.cs
+++ b/src/CultMath/PlanetaryProjection.cs
@@ -1,3 +1,5 @@
+using System;
+
namespace CultMath;
public enum PlanetaryProjectionKind
diff --git a/src/CultMath/PlanetaryProjection.cs.meta b/src/CultMath/PlanetaryProjection.cs.meta
new file mode 100644
index 0000000..7b1e665
--- /dev/null
+++ b/src/CultMath/PlanetaryProjection.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5021202cf626f7442a57961255a8a217
\ No newline at end of file
diff --git a/src/CultMath/PlanetaryQueries.cs b/src/CultMath/PlanetaryQueries.cs
index 1f11ca7..54a9a3e 100644
--- a/src/CultMath/PlanetaryQueries.cs
+++ b/src/CultMath/PlanetaryQueries.cs
@@ -1,3 +1,5 @@
+using System;
+
namespace CultMath;
public readonly record struct PlanetaryRadialBounds(float MinimumDisplacement, float MaximumDisplacement)
diff --git a/src/CultMath/PlanetaryQueries.cs.meta b/src/CultMath/PlanetaryQueries.cs.meta
new file mode 100644
index 0000000..6fd1141
--- /dev/null
+++ b/src/CultMath/PlanetaryQueries.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 2ec43c567214b8247bda19b5c120c200
\ No newline at end of file
diff --git a/src/CultMath/PlanetaryRadialRefinement.cs.meta b/src/CultMath/PlanetaryRadialRefinement.cs.meta
new file mode 100644
index 0000000..32af265
--- /dev/null
+++ b/src/CultMath/PlanetaryRadialRefinement.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 82e4db521edc253499220458b417fd1e
\ No newline at end of file
diff --git a/src/CultMath/PlanetaryResidency.cs b/src/CultMath/PlanetaryResidency.cs
index e5dca11..6918ac5 100644
--- a/src/CultMath/PlanetaryResidency.cs
+++ b/src/CultMath/PlanetaryResidency.cs
@@ -1,3 +1,7 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
namespace CultMath;
public readonly record struct PlanetaryLodParameters(
@@ -71,9 +75,9 @@ public readonly record struct PlanetaryResidencySnapshot(
/// Owns presentation residency only. It never owns field content.
public sealed class PlanetaryResidualResidency
{
- private readonly Dictionary residents = [];
- private readonly Dictionary arrivals = [];
- private readonly Dictionary departures = [];
+ private readonly Dictionary residents = new();
+ private readonly Dictionary arrivals = new();
+ private readonly Dictionary departures = new();
private float lastTime = float.NegativeInfinity;
public void Reset()
diff --git a/src/CultMath/PlanetaryResidency.cs.meta b/src/CultMath/PlanetaryResidency.cs.meta
new file mode 100644
index 0000000..2434762
--- /dev/null
+++ b/src/CultMath/PlanetaryResidency.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0585ea15edd7f1c4fa272df4911a8d6f
\ No newline at end of file
diff --git a/src/CultMath/PlanetaryTopology.cs b/src/CultMath/PlanetaryTopology.cs
index c853507..57d562c 100644
--- a/src/CultMath/PlanetaryTopology.cs
+++ b/src/CultMath/PlanetaryTopology.cs
@@ -1,3 +1,5 @@
+using System;
+
namespace CultMath;
public enum PlanetaryCubeFace
@@ -104,6 +106,14 @@ public static class PlanetaryTopology
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);
@@ -156,6 +166,17 @@ public static class PlanetaryTopology
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));
diff --git a/src/CultMath/PlanetaryTopology.cs.meta b/src/CultMath/PlanetaryTopology.cs.meta
new file mode 100644
index 0000000..411a8bd
--- /dev/null
+++ b/src/CultMath/PlanetaryTopology.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 88f6789c4384f7846ac4be37613fe5ed
\ No newline at end of file
diff --git a/src/CultMath/SphericalErosion.cs.meta b/src/CultMath/SphericalErosion.cs.meta
new file mode 100644
index 0000000..82db773
--- /dev/null
+++ b/src/CultMath/SphericalErosion.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 59ec2db73065f6c4483ad90f5e17850e
\ No newline at end of file
diff --git a/tests/CultMath.Tests/AdvancedErosionFilterTests.cs.meta b/tests/CultMath.Tests/AdvancedErosionFilterTests.cs.meta
new file mode 100644
index 0000000..253ee42
--- /dev/null
+++ b/tests/CultMath.Tests/AdvancedErosionFilterTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a7c85309318ae3f4ba75ac28a4f661cf
\ No newline at end of file
diff --git a/tests/CultMath.Tests/ErosionFrequencyBandsTests.cs.meta b/tests/CultMath.Tests/ErosionFrequencyBandsTests.cs.meta
new file mode 100644
index 0000000..ef15b7e
--- /dev/null
+++ b/tests/CultMath.Tests/ErosionFrequencyBandsTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 095f8ed13d2f2f74baefd23310395256
\ No newline at end of file
diff --git a/tests/CultMath.Tests/HlslMirrorTests.cs b/tests/CultMath.Tests/HlslMirrorTests.cs
index 567b038..5e1451c 100644
--- a/tests/CultMath.Tests/HlslMirrorTests.cs
+++ b/tests/CultMath.Tests/HlslMirrorTests.cs
@@ -78,6 +78,8 @@ public sealed class HlslMirrorTests
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);
}
diff --git a/tests/CultMath.Tests/PlanetaryFieldTests.cs.meta b/tests/CultMath.Tests/PlanetaryFieldTests.cs.meta
new file mode 100644
index 0000000..1710b5f
--- /dev/null
+++ b/tests/CultMath.Tests/PlanetaryFieldTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c747cfda80df21d41a57412d8d07c08a
\ No newline at end of file
diff --git a/tests/CultMath.Tests/PlanetaryGpuPageTests.cs b/tests/CultMath.Tests/PlanetaryGpuPageTests.cs
new file mode 100644
index 0000000..ea17b4b
--- /dev/null
+++ b/tests/CultMath.Tests/PlanetaryGpuPageTests.cs
@@ -0,0 +1,25 @@
+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);
+ }
+}
diff --git a/tests/CultMath.Tests/PlanetaryGpuPageTests.cs.meta b/tests/CultMath.Tests/PlanetaryGpuPageTests.cs.meta
new file mode 100644
index 0000000..00f4a3a
--- /dev/null
+++ b/tests/CultMath.Tests/PlanetaryGpuPageTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 87266638e8010f546ab6c430a712868b
\ No newline at end of file
diff --git a/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs b/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs
new file mode 100644
index 0000000..112cb31
--- /dev/null
+++ b/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs
@@ -0,0 +1,52 @@
+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(() => PlanetaryMapTileEncoding.Read(new MemoryStream(new byte[] { 1, 2, 3, 4, 1, 0, 0, 0 })));
+ var bytes = new byte[] { (byte)'C', (byte)'M', (byte)'P', (byte)'T', 99, 0, 0, 0 };
+ Assert.Throws(() => PlanetaryMapTileEncoding.Read(new MemoryStream(bytes)));
+ }
+
+ [Fact]
+ public void DecoderRejectsTrailingBytes()
+ {
+ var field = PlanetaryFieldDefinition.Create(2, 1, 0, AdvancedErosionParameters.Default);
+ var layout = new PlanetaryMapTileLayout(new(PlanetaryProjectionKind.Equirectangular), 0, 0, 0, 2, 0);
+ var key = new PlanetaryMapTileKey(field.FieldVersion, 1, 0, layout, PlanetaryQueryScale.AtFootprint(1));
+ var tile = PlanetaryMapTileBaker.Bake(field, key, new FlatField());
+ using var bytes = new MemoryStream();
+ PlanetaryMapTileEncoding.Write(bytes, tile, leaveOpen: true);
+ bytes.WriteByte(0xff);
+ bytes.Position = 0;
+ Assert.Throws(() => PlanetaryMapTileEncoding.Read(bytes));
+ }
+
+ private readonly struct FlatField : IPlanetaryBaseField
+ {
+ public PlanetaryBaseFieldSample Sample(float3 direction) => new(0, float3.zero, 0, float3.zero, 0);
+ }
+}
diff --git a/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs.meta b/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs.meta
new file mode 100644
index 0000000..027754e
--- /dev/null
+++ b/tests/CultMath.Tests/PlanetaryMapTileEncodingTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e916ef83478d48b43ac99777f5e0f731
\ No newline at end of file
diff --git a/tests/CultMath.Tests/PlanetaryPageTests.cs.meta b/tests/CultMath.Tests/PlanetaryPageTests.cs.meta
new file mode 100644
index 0000000..70a4386
--- /dev/null
+++ b/tests/CultMath.Tests/PlanetaryPageTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7c3ae5598d3b9164a9bd3a736a22ba9a
\ No newline at end of file
diff --git a/tests/CultMath.Tests/PlanetaryPatchTests.cs.meta b/tests/CultMath.Tests/PlanetaryPatchTests.cs.meta
new file mode 100644
index 0000000..8599047
--- /dev/null
+++ b/tests/CultMath.Tests/PlanetaryPatchTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 36840ef44326f84409f850163b00e1f7
\ No newline at end of file
diff --git a/tests/CultMath.Tests/PlanetaryProjectionTests.cs.meta b/tests/CultMath.Tests/PlanetaryProjectionTests.cs.meta
new file mode 100644
index 0000000..7f37f78
--- /dev/null
+++ b/tests/CultMath.Tests/PlanetaryProjectionTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f370036bb1663524fb4a4d47dfd50401
\ No newline at end of file
diff --git a/tests/CultMath.Tests/PlanetaryQueryTests.cs.meta b/tests/CultMath.Tests/PlanetaryQueryTests.cs.meta
new file mode 100644
index 0000000..d72b2cb
--- /dev/null
+++ b/tests/CultMath.Tests/PlanetaryQueryTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 93cf8771fdd520b41a4e931a6566a80a
\ No newline at end of file
diff --git a/tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs.meta b/tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs.meta
new file mode 100644
index 0000000..b9f3bea
--- /dev/null
+++ b/tests/CultMath.Tests/PlanetaryRadialRefinementTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d34c0018ee2b700448b16540eca8ebcf
\ No newline at end of file
diff --git a/tests/CultMath.Tests/PlanetaryResidencyTests.cs.meta b/tests/CultMath.Tests/PlanetaryResidencyTests.cs.meta
new file mode 100644
index 0000000..be7de2b
--- /dev/null
+++ b/tests/CultMath.Tests/PlanetaryResidencyTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9ece3b02dbd3d7448baabee7f1fba0ab
\ No newline at end of file
diff --git a/tests/CultMath.Tests/PlanetaryTopologyTests.cs b/tests/CultMath.Tests/PlanetaryTopologyTests.cs
index 6e6b083..759928d 100644
--- a/tests/CultMath.Tests/PlanetaryTopologyTests.cs
+++ b/tests/CultMath.Tests/PlanetaryTopologyTests.cs
@@ -52,4 +52,12 @@ public sealed class PlanetaryTopologyTests
Assert.NotEqual(edge, border);
Assert.Equal(1, math.length(border), 5);
}
+
+ [Fact]
+ public void NormalizedCubeBaselineSharesFaceEdges()
+ {
+ var a = PlanetaryTopology.NormalizedCubeDirection(new(PlanetaryCubeFace.PositiveZ, 1, -0.4));
+ var b = PlanetaryTopology.NormalizedCubeDirection(new(PlanetaryCubeFace.PositiveX, -1, -0.4));
+ Assert.InRange(math.distance(a, b), 0, 1.0e-6f);
+ }
}
diff --git a/tests/CultMath.Tests/PlanetaryTopologyTests.cs.meta b/tests/CultMath.Tests/PlanetaryTopologyTests.cs.meta
new file mode 100644
index 0000000..b693014
--- /dev/null
+++ b/tests/CultMath.Tests/PlanetaryTopologyTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: be3b0ec07fe65344db889d1e98448113
\ No newline at end of file
diff --git a/tests/CultMath.Tests/SphericalErosionTests.cs.meta b/tests/CultMath.Tests/SphericalErosionTests.cs.meta
new file mode 100644
index 0000000..00ff1de
--- /dev/null
+++ b/tests/CultMath.Tests/SphericalErosionTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 60eb9e9e9f8ae2948b13403f5080168e
\ No newline at end of file
diff --git a/tools/CultMath.PlanetaryTileFixture.meta b/tools/CultMath.PlanetaryTileFixture.meta
new file mode 100644
index 0000000..7f40b6a
--- /dev/null
+++ b/tools/CultMath.PlanetaryTileFixture.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 7197a2202273a9445b974bdd69109b1a
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity.meta b/unity.meta
new file mode 100644
index 0000000..de47a61
--- /dev/null
+++ b/unity.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 366efeaef9f68224b80229332c7ecfc6
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity/Runtime.meta b/unity/Runtime.meta
new file mode 100644
index 0000000..8e0678f
--- /dev/null
+++ b/unity/Runtime.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 8226365c03c5e1a438408b7e42b98740
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity/Runtime/CultMath.Unity.asmdef.meta b/unity/Runtime/CultMath.Unity.asmdef.meta
new file mode 100644
index 0000000..2dd3a36
--- /dev/null
+++ b/unity/Runtime/CultMath.Unity.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: d1906c397c420ae4eb222a3f09f36843
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity/Runtime/PlanetaryPageUpload.cs b/unity/Runtime/PlanetaryPageUpload.cs
index f0b168c..1a61a70 100644
--- a/unity/Runtime/PlanetaryPageUpload.cs
+++ b/unity/Runtime/PlanetaryPageUpload.cs
@@ -36,12 +36,28 @@ public readonly struct PlanetaryPageMetadataGpu
public readonly Vector4 State;
}
-public readonly record struct PlanetaryPageUpload(
- PlanetaryPageInputGpu[] Inputs,
- PlanetaryPageMetadataGpu[] Metadata,
- int OutputSampleCount,
- ulong ContentVersion,
- ulong PresentationVersion);
+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
{
@@ -57,25 +73,23 @@ public static class PlanetaryPageUploadBuilder
foreach (var (resident, pageIndex) in snapshot.Tiles.Select((resident, index) => (resident, index)))
{
var layout = new PlanetaryPageLayout(resident.Tile, interiorSize, borderSize).Validate();
- var spacing = PlanetaryPageSampling.NominalAngularTexelSize(layout) * radius;
- var parentSpacing = resident.Tile.Level == 0
- ? 0
- : PlanetaryPageSampling.NominalAngularTexelSize(new(resident.Tile.Parent(), interiorSize, borderSize)) * radius;
+ var content = PlanetaryGpuPageBuilder.BuildContent(layout, radius);
var outputOffset = inputs.Count;
- for (var y = 0; y < layout.StorageSize; y++)
- for (var x = 0; x < layout.StorageSize; x++)
+ foreach (var input in content.Inputs)
{
- var direction = PlanetaryPageSampling.Direction(layout, x, y);
inputs.Add(new(
- new Vector4(direction.x, direction.y, direction.z, radius),
- new Vector4(spacing, parentSpacing, 0, 0)));
+ ToUnity(input.DirectionRadius),
+ ToUnity(input.Sampling)));
}
+ var commonMetadata = PlanetaryGpuPageBuilder.Metadata(content, outputOffset, resident.Blend);
metadata[pageIndex] = new(
- new Vector4((int)resident.Tile.Face, resident.Tile.Level, resident.Tile.X, resident.Tile.Y),
- new Vector4(outputOffset, layout.StorageSize, layout.InteriorSize, layout.BorderSize),
- Vector4.zero,
- new Vector4(1, resident.Blend, PlanetaryPageSampling.NominalAngularTexelSize(layout), spacing));
+ 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);
}
diff --git a/unity/Runtime/PlanetaryPageUpload.cs.meta b/unity/Runtime/PlanetaryPageUpload.cs.meta
new file mode 100644
index 0000000..87bcb61
--- /dev/null
+++ b/unity/Runtime/PlanetaryPageUpload.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7edd3bb2edb2e6448a2d54fc082c27dc
\ No newline at end of file
diff --git a/unity/Runtime/PlanetaryPatchMeshAdapter.cs.meta b/unity/Runtime/PlanetaryPatchMeshAdapter.cs.meta
new file mode 100644
index 0000000..69af0ab
--- /dev/null
+++ b/unity/Runtime/PlanetaryPatchMeshAdapter.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9ad7b581e14ef774d8435069bb77ca6a
\ No newline at end of file
diff --git a/web.meta b/web.meta
new file mode 100644
index 0000000..d65ff3e
--- /dev/null
+++ b/web.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: daf45471f8b5f4f44a2cb86e77e010db
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/web/planetary-tile.cross-runtime.test.mjs b/web/planetary-tile.cross-runtime.test.mjs
new file mode 100644
index 0000000..2b45721
--- /dev/null
+++ b/web/planetary-tile.cross-runtime.test.mjs
@@ -0,0 +1,21 @@
+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));
+}
diff --git a/web/planetary-tile.cross-runtime.test.mjs.meta b/web/planetary-tile.cross-runtime.test.mjs.meta
new file mode 100644
index 0000000..3c38da5
--- /dev/null
+++ b/web/planetary-tile.cross-runtime.test.mjs.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 7bc397e5660d2b6448919c41983d35e8
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/web/planetary-tile.test.mjs b/web/planetary-tile.test.mjs
new file mode 100644
index 0000000..cb054f2
--- /dev/null
+++ b/web/planetary-tile.test.mjs
@@ -0,0 +1,39 @@
+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/);
diff --git a/web/planetary-tile.test.mjs.meta b/web/planetary-tile.test.mjs.meta
new file mode 100644
index 0000000..d622095
--- /dev/null
+++ b/web/planetary-tile.test.mjs.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 04c93ecab90d465499aa3d60722e6fc3
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/web/planetary-tile.ts b/web/planetary-tile.ts
new file mode 100644
index 0000000..e68d70f
--- /dev/null
+++ b/web/planetary-tile.ts
@@ -0,0 +1,89 @@
+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 };
+}
diff --git a/web/planetary-tile.ts.meta b/web/planetary-tile.ts.meta
new file mode 100644
index 0000000..337da3b
--- /dev/null
+++ b/web/planetary-tile.ts.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: f284e64628320844196b7aaea76475a4
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant: