Complete portable planetary presentation contracts

This commit is contained in:
Meta Vens 2026-07-13 02:52:13 +01:00
commit 102403efac
74 changed files with 1166 additions and 31 deletions

1
.gitignore vendored
View file

@ -4,4 +4,5 @@ obj/
**/obj/
target/
**/target/
/native/cultmath-core/target.meta
.tools/

View file

@ -5,4 +5,7 @@
<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

@ -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/`:

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

7
docs/planetary.md.meta Normal file
View file

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

View file

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

8
scripts.meta Normal file
View file

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

View file

@ -0,0 +1,9 @@
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$fixture = Join-Path $root ".tools\planetary-tile\fixture.cmpt"
dotnet run --project (Join-Path $root "Tools~\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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,5 @@
using System;
namespace CultMath;
public readonly record struct PlanetaryQueryScale(float FootprintMeters, float MaximumUnresolvedHeight)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,5 @@
using System;
namespace CultMath;
public readonly record struct PlanetaryPageLayout(PlanetaryTileAddress Tile, int InteriorSize, int BorderSize)

View file

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

View file

@ -1,3 +1,6 @@
using System;
using System.Linq;
namespace CultMath;
public readonly record struct PlanetaryPatchVertex(float2 LocalCoordinate, float3 UnitDirection);

View file

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

View file

@ -1,3 +1,5 @@
using System;
namespace CultMath;
public enum PlanetaryProjectionKind

View file

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

View file

@ -1,3 +1,5 @@
using System;
namespace CultMath;
public readonly record struct PlanetaryRadialBounds(float MinimumDisplacement, float MaximumDisplacement)

View file

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

View file

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

View file

@ -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(
/// <summary>Owns presentation residency only. It never owns field content.</summary>
public sealed class PlanetaryResidualResidency
{
private readonly Dictionary<ulong, PlanetaryTileAddress> residents = [];
private readonly Dictionary<ulong, float> arrivals = [];
private readonly Dictionary<ulong, (float Time, float StartingBlend)> departures = [];
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()

View file

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

View file

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

View file

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

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 59ec2db73065f6c4483ad90f5e17850e

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<InvalidDataException>(() => PlanetaryMapTileEncoding.Read(new MemoryStream(new byte[] { 1, 2, 3, 4, 1, 0, 0, 0 })));
var bytes = new byte[] { (byte)'C', (byte)'M', (byte)'P', (byte)'T', 99, 0, 0, 0 };
Assert.Throws<InvalidDataException>(() => PlanetaryMapTileEncoding.Read(new MemoryStream(bytes)));
}
[Fact]
public void DecoderRejectsTrailingBytes()
{
var field = PlanetaryFieldDefinition.Create(2, 1, 0, AdvancedErosionParameters.Default);
var layout = new PlanetaryMapTileLayout(new(PlanetaryProjectionKind.Equirectangular), 0, 0, 0, 2, 0);
var key = new PlanetaryMapTileKey(field.FieldVersion, 1, 0, layout, PlanetaryQueryScale.AtFootprint(1));
var tile = PlanetaryMapTileBaker.Bake(field, key, new FlatField());
using var bytes = new MemoryStream();
PlanetaryMapTileEncoding.Write(bytes, tile, leaveOpen: true);
bytes.WriteByte(0xff);
bytes.Position = 0;
Assert.Throws<InvalidDataException>(() => PlanetaryMapTileEncoding.Read(bytes));
}
private readonly struct FlatField : IPlanetaryBaseField
{
public PlanetaryBaseFieldSample Sample(float3 direction) => new(0, float3.zero, 0, float3.zero, 0);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

8
unity.meta Normal file
View file

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

8
unity/Runtime.meta Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

8
web.meta Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

89
web/planetary-tile.ts Normal file
View file

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

View file

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