Merge eureka/aetheria-gravity-asteroid-floater-layer (head 01d3058d)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-18 01:26:03 +02:00
commit 6fcdb9cfea
12 changed files with 776 additions and 65 deletions

View file

@ -0,0 +1,265 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using GameCult.Caching;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using float2 = CultMath.float2;
using float4 = CultMath.float4;
// End-to-end proof of the belt draw (rulings asteroid-tops-at-layer, rock-meshes-by-size), measured where it fails: two belts are loaded
// into a ZoneRenderer carrying ARPG.unity's real AsteroidMeshes (meshes, stored radii) and Settings.asset, over a zone whose wells make the
// field differ rock to rock, and ZoneRenderer.DrawBelts runs as Update runs it. Every instanced matrix it hands to the GPU is read back with the
// mesh it is drawn with (ZoneRenderer.DrawInstanced), and for each drawn rock the verify measures, from the mesh's real vertices:
// - the farthest vertex from the rock's centre after the matrix (the reach, a bound on the rock's top under any rotation) is the rock's
// gameplay radius, and centre y + reach is the floater layer over the field at the rock's xz and the drawn time;
// - the rock is drawn exactly once, on exactly one matrix of a real pose (no spare instance in a batch);
// - within a belt a bigger rock never takes a smaller mesh (by the mesh's own farthest vertex), and the belt keeps min(AsteroidMeshCount, meshes) meshes.
// The arguments the renderer hands the draw list (radius field, belt, batch size, kept count) are not inspected: their consequence is.
//
// Reached: ZoneRenderer.LoadPlanet for each belt, DrawBelts. Not reached: LoadZone's other planets (their prefabs), the GPU draw itself.
// Needs a GPU device for MapPresenter's belt buffer: run without -nographics.
//
// Unity -batchmode -projectPath <project> -executeMethod Aetheria.EditorTools.BeltFloaterLayerVerify.Run -logFile <log>
//
// Do not pass -quit: Run enters play mode (a domain reload), the check runs there, and the Editor exits itself, 0 on success and 1 on any failure.
namespace Aetheria.EditorTools
{
[InitializeOnLoad]
public static class BeltFloaterLayerVerify
{
private const string Tag = "[BeltFloaterLayerVerify]";
private const string Flag = "BeltFloaterLayerVerify.running";
private const string Wiring = "BeltFloaterLayerVerify.wiring";
private const double Budget = 300; // real seconds
private const float Lead = .008f, Layer = -1.5f, Tolerance = .02f;
private static IEnumerator _check;
private static double _started;
private static string _work;
static BeltFloaterLayerVerify()
{
if (SessionState.GetBool(Flag, false)) EditorApplication.playModeStateChanged += OnPlayMode;
}
public static void Run()
{
try
{
SessionState.SetString(Wiring, ReadWiring());
SessionState.SetBool(Flag, true);
EditorApplication.EnterPlaymode();
}
catch (Exception exception)
{
Debug.LogException(exception);
EditorApplication.Exit(1);
}
}
private static void OnPlayMode(PlayModeStateChange change)
{
if (change != PlayModeStateChange.EnteredPlayMode) return;
_work = Path.Combine(Path.GetTempPath(), $"belt-floater-{Guid.NewGuid():N}");
Directory.CreateDirectory(_work);
_started = Time.realtimeSinceStartupAsDouble;
_check = Check();
EditorApplication.update += Step;
}
private static void Step()
{
try
{
if (Time.realtimeSinceStartupAsDouble - _started > Budget) throw new InvalidOperationException($"The check did not finish in {Budget} seconds.");
if (_check.MoveNext()) return;
Finish(0);
}
catch (Exception exception)
{
Debug.LogException(exception);
Finish(1);
}
}
private static void Finish(int code)
{
EditorApplication.update -= Step;
SessionState.SetBool(Flag, false);
try { if (Directory.Exists(_work)) Directory.Delete(_work, true); } catch (Exception) { /* a temp directory */ }
EditorApplication.Exit(code);
}
private static void Require(bool condition, string what)
{
if (!condition) throw new InvalidOperationException(what);
}
// A belt of rocks of every size, its sizes in the given order, round an orbit about the planet.
private static Asteroid[] Rocks(int count, Func<int, float> size, float start) => Enumerable.Range(0, count).Select(i => new Asteroid
{
Distance = start + i * 9.7f, Phase = Mathf.Repeat(i * .6180339887f, 1f), Size = size(i), RotationSpeed = .1f + i * .013f
}).ToArray();
private static IEnumerator Check()
{
var game = AssetDatabase.LoadAssetAtPath<GameSettings>("Assets/Resources/Settings.asset") ?? throw new InvalidOperationException("No Settings.asset.");
var settings = game.GameplaySettings;
settings.FloaterLayerHeight = Layer; // in memory, never saved back to the asset: a draw that drops the layer fails
var meshes = SessionState.GetString(Wiring, "").Split('|').Select(entry => entry.Split('~')).Select(p => new InstancedMesh
{
Mesh = AssetDatabase.LoadAllAssetsAtPath(p[0]).OfType<Mesh>().First(m => m.name == p[1]),
Material = p[2] == "" ? null : AssetDatabase.LoadAssetAtPath<Material>(p[2]),
Radius = float.Parse(p[3], System.Globalization.CultureInfo.InvariantCulture)
}).ToArray();
Require(meshes.Length > game.AsteroidMeshCount, $"ARPG.unity has {meshes.Length} rock meshes for a kept count of {game.AsteroidMeshCount}: nothing to prune.");
var cache = AetheriaStores.Open(Path.Combine(_work, "belts.cc"), Path.Combine(_work, "belts-run.cc"), catalogWritable: true);
cache.Upsert(new VerseGrammar { Revision = 1 });
cache.FlushAsync().Wait();
var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { });
CultRecordKey Orbit(CultRecordKey parent, float distance, float phase, float2 fixedPosition = default)
{
var data = new OrbitData { Distance = distance, Phase = phase, FixedPosition = fixedPosition };
if (parent.IsSet()) data.Parent = new CultRecordRef<OrbitData>(parent);
return cache.Upsert(data).Key;
}
// A heavy sun and a planet on a moving rail, the belts round the planet: the field under a rock differs from rock to rock.
var sunOrbit = Orbit(default, 0f, 0f, new float2(700f, -300f));
var planetOrbit = Orbit(sunOrbit, 600f, .3f);
var beltOrbitA = Orbit(planetOrbit, 150f, .1f);
var beltOrbitB = Orbit(planetOrbit, 300f, .6f);
var beltA = cache.Upsert(new AsteroidBeltData { Orbit = new CultRecordRef<OrbitData>(beltOrbitA), Asteroids = Rocks(24, i => (i * 7 % 24) / 23f, 40f) }).Key;
var beltB = cache.Upsert(new AsteroidBeltData { Orbit = new CultRecordRef<OrbitData>(beltOrbitB), Asteroids = Rocks(24, i => (i * 5 % 24) / 23f, 60f) }).Key;
var pack = new ZonePack { Radius = 5000f, Mass = 10000f };
foreach (var orbit in new[] { sunOrbit, planetOrbit, beltOrbitA, beltOrbitB }) pack.Orbits.Add(new CultRecordRef<OrbitData>(orbit));
pack.Planets.Add(new CultRecordRef<BodyData>(cache.Upsert(new PlanetData { Name = "Belt Sun", Orbit = new CultRecordRef<OrbitData>(sunOrbit), Mass = 400f }).Key));
pack.Planets.Add(new CultRecordRef<BodyData>(cache.Upsert(new PlanetData { Name = "Belt Planet", Orbit = new CultRecordRef<OrbitData>(planetOrbit), Mass = 400f }).Key));
pack.Planets.Add(new CultRecordRef<BodyData>(beltA));
pack.Planets.Add(new CultRecordRef<BodyData>(beltB));
var curves = new PlanetSettings
{
BodyRadius = new ExponentialCurve(),
WellStrength = new ExponentialCurve { Multiplier = 1f, Exponent = 1f },
RocheLimit = new ExponentialCurve { Constant = 10f },
AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f },
AsteroidHitpoints = new ExponentialLerp { Minimum = 10f, Maximum = 300f, Exponent = 2f },
AsteroidRespawnTime = new ExponentialLerp { Minimum = 5f, Maximum = 60f, Exponent = 1f }
};
var zone = new Zone(items, curves, pack, null, null);
zone.Update(.5f);
// The renderer, configured as the scene configures it for belts: Awake builds the map, LoadPlanet reads Zone, Settings and AsteroidMeshes.
var rendererObject = new GameObject("Belt Zone Renderer");
rendererObject.SetActive(false);
var renderer = rendererObject.AddComponent<ZoneRenderer>();
renderer.Settings = game;
renderer.AsteroidMeshes = meshes;
renderer.ItemManager = items;
renderer.LODHandler = new GameObject("Belt LOD").AddComponent<LODHandler>();
const BindingFlags Any = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public;
typeof(ZoneRenderer).GetProperty("Zone").GetSetMethod(true).Invoke(renderer, new object[] { zone });
typeof(ZoneRenderer).GetMethod("Awake", Any).Invoke(renderer, null);
var load = typeof(ZoneRenderer).GetMethod("LoadPlanet", Any);
foreach (var belt in new[] { beltA, beltB }) load.Invoke(renderer, new object[] { belt, zone.Planets[belt] });
var planetAt = DrawAhead.Orbit(zone, planetOrbit, Lead);
var camera = new GameObject("Belt Camera").AddComponent<Camera>();
camera.transform.position = new Vector3(planetAt.x, 4000f, planetAt.y);
camera.transform.rotation = Quaternion.Euler(90, 0, 0);
camera.farClipPlane = 20000f;
renderer.MainCamera = camera;
yield return null;
ActionGameManager.Clock.Reset();
ActionGameManager.Clock.Advance(Lead, _ => throw new InvalidOperationException("a step ran"));
var lead = ActionGameManager.Clock.Lead;
Require(Mathf.Abs(lead - Lead) < 1e-6f, $"The clock's lead is {lead}, not {Lead}.");
var drawn = new List<(Mesh mesh, Matrix4x4[] matrices)>();
renderer.DrawInstanced = (mesh, _, matrices) => drawn.Add((mesh, (Matrix4x4[]) matrices.Clone()));
renderer.DrawBelts(lead);
// The poses the sim defines at the drawn time: every rock of both belts.
var rocks = new List<(CultRecordKey belt, int index, float4 pose)>();
foreach (var belt in new[] { beltA, beltB })
{
var poses = new float4[((AsteroidBeltData) zone.Planets[belt]).Asteroids.Length];
zone.EvaluateBelt(belt, poses, DrawAhead.Time(zone, lead));
for (var i = 0; i < poses.Length; i++) rocks.Add((belt, i, poses[i]));
}
Require(drawn.Count > 0, "DrawBelts drew nothing: the belts are out of the camera or have no rocks.");
// The field differs between rocks, so a draw that drops it or samples it at the wrong place fails.
var layers = rocks.Select(r => DrawAhead.FloaterHeight(zone, settings, r.pose.xy, lead)).ToArray();
Require(layers.Max() - layers.Min() > 1f, $"The fixture's field is flat under the rocks: layer heights span {layers.Max() - layers.Min()}.");
var seen = new Dictionary<(CultRecordKey, int), float>(); // rock -> its mesh's own farthest vertex
var vertices = new Dictionary<Mesh, Vector3[]>();
foreach (var (mesh, matrices) in drawn)
{
if (!vertices.TryGetValue(mesh, out var v)) vertices[mesh] = v = mesh.vertices;
var own = Mathf.Sqrt(v.Max(x => x.sqrMagnitude));
foreach (var matrix in matrices)
{
var centre = (Vector3) matrix.GetColumn(3);
var at = rocks.Where(r => Mathf.Abs(r.pose.x - centre.x) < .01f && Mathf.Abs(r.pose.y - centre.z) < .01f).ToArray();
Require(at.Length == 1, $"An instance of {mesh.name} sits at {centre.x},{centre.z}, where {at.Length} rocks are (a spare or misplaced instance).");
var rock = at[0];
Require(seen.TryAdd((rock.belt, rock.index), own), $"Rock {rock.index} of {rock.belt} is drawn twice.");
var reach = v.Max(x => (matrix.MultiplyPoint3x4(x) - centre).magnitude);
Require(Mathf.Abs(reach - rock.pose.w) < Tolerance * Mathf.Max(1f, rock.pose.w),
$"Rock {rock.index} of {rock.belt} on {mesh.name}: its farthest vertex is {reach} from its centre, its gameplay radius {rock.pose.w}.");
var top = centre.y + reach;
var want = DrawAhead.FloaterHeight(zone, settings, rock.pose.xy, lead);
Require(Mathf.Abs(top - want) < Tolerance,
$"Rock {rock.index} of {rock.belt} on {mesh.name}: its top is {top}, the floater layer over the field there {want}.");
}
}
Require(seen.Count == rocks.Count, $"{seen.Count} of {rocks.Count} rocks were drawn.");
foreach (var belt in new[] { beltA, beltB })
{
var mine = rocks.Where(r => r.belt.Equals(belt)).ToArray();
var radii = mine.Select(r => seen[(belt, r.index)]).Distinct().OrderBy(x => x).ToArray();
Require(radii.Length >= 2, $"Belt {belt} is drawn on {radii.Length} distinct mesh radii: the size match is not exercised.");
var kept = drawn.Where(d => d.matrices.Any(m => mine.Any(r => Mathf.Abs(r.pose.x - m.m03) < .01f && Mathf.Abs(r.pose.y - m.m23) < .01f))).Select(d => d.mesh).Distinct().Count();
Require(kept == Mathf.Min(game.AsteroidMeshCount, meshes.Length), $"Belt {belt} is drawn on {kept} meshes, not the kept count {game.AsteroidMeshCount}.");
var size = mine.ToDictionary(r => r.index, r => zone.AsteroidBelts[belt].UndamagedSize(r.index, zone.Settings));
foreach (var a in mine)
foreach (var b in mine)
if (size[a.index] < size[b.index])
Require(seen[(belt, a.index)] <= seen[(belt, b.index)],
$"Belt {belt}: rock {a.index} (size {size[a.index]}) is smaller than rock {b.index} ({size[b.index]}) and takes a bigger mesh ({seen[(belt, a.index)]} against {seen[(belt, b.index)]}).");
var biggest = mine.OrderByDescending(r => size[r.index]).First();
Require(Mathf.Approximately(seen[(belt, biggest.index)], radii[^1]), $"Belt {belt}: its biggest rock is not on its biggest mesh.");
}
ActionGameManager.Clock.Reset();
Debug.Log($"{Tag} ok: {rocks.Count} rocks of 2 belts drawn at the floater layer, big rocks on big meshes ({drawn.Count} batches)");
}
// The AsteroidMeshes ARPG.unity's ZoneRenderer names, read before the play session (a scene cannot be opened inside one) and carried across the
// domain reload as asset paths: mesh path, mesh name, material path, stored radius.
private static string ReadWiring()
{
var scene = EditorSceneManager.OpenScene("Assets/Scenes/ARPG.unity", OpenSceneMode.Additive);
try
{
var found = UnityEngine.Object.FindObjectsByType<ZoneRenderer>(FindObjectsInactive.Include, FindObjectsSortMode.None)
.Where(r => r.gameObject.scene == scene).ToArray();
Require(found.Length == 1, $"ARPG.unity has {found.Length} ZoneRenderers.");
var meshes = found[0].AsteroidMeshes ?? throw new InvalidOperationException("ARPG.unity's ZoneRenderer has no AsteroidMeshes.");
return string.Join("|", meshes.Select(m => string.Join("~", AssetDatabase.GetAssetPath(m.Mesh), m.Mesh.name,
m.Material == null ? "" : AssetDatabase.GetAssetPath(m.Material), m.Radius.ToString("R", System.Globalization.CultureInfo.InvariantCulture))));
}
finally { EditorSceneManager.CloseScene(scene, true); }
}
}
}

View file

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

View file

@ -0,0 +1,112 @@
using System;
using System.Linq;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEditor.SceneManagement;
using UnityEngine;
// The radius of each rock mesh ZoneRenderer draws (ruling rock-meshes-by-size): the farthest vertex from the mesh origin, which bounds the
// mesh's top under any rotation. DrawAhead.RockScale divides the gameplay radius by it, so the drawn rock is the gameplay rock. It is stored on
// InstancedMesh.Radius in ARPG.unity, because a build cannot read vertices of a mesh without Read/Write import.
//
// Unity -batchmode -nographics -projectPath <project> -executeMethod Aetheria.EditorTools.RockMeshRadius.Fill -logFile <log>
// Unity -batchmode -nographics -projectPath <project> -executeMethod Aetheria.EditorTools.RockMeshRadius.Verify -logFile <log>
//
// Fill measures every AsteroidMeshes entry and saves the scene; Verify fails (exit 1) when any stored radius is not the measured one.
// RockMeshRadiusBuildGate runs the same check before every player build and fails the build, so a mesh with no filled radius cannot ship;
// Gate runs that gate in batch mode.
// Unity -batchmode -nographics -projectPath <project> -executeMethod Aetheria.EditorTools.RockMeshRadius.Gate -logFile <log>
namespace Aetheria.EditorTools
{
public static class RockMeshRadius
{
private const string Scene = "Assets/Scenes/ARPG.unity";
private const float Tolerance = 1e-4f;
public static float Measure(Mesh mesh) => Mathf.Sqrt(mesh.vertices.Max(v => v.sqrMagnitude));
public static void Fill() => Batch(() =>
{
var scene = EditorSceneManager.OpenScene(Scene, OpenSceneMode.Single);
var (renderer, meshes, measured) = Load(scene);
for (var i = 0; i < meshes.Length; i++) meshes[i].Radius = measured[i];
EditorUtility.SetDirty(renderer);
EditorSceneManager.MarkSceneDirty(scene);
if (!EditorSceneManager.SaveScene(scene)) throw new InvalidOperationException("The scene did not save.");
return Summary("filled", measured);
});
public static void Verify() => Batch(() => Check(OpenSceneMode.Single));
// The build gate's own entry in batch mode: exit 1 exactly when a build would fail (RockMeshRadiusBuildGate).
public static void Gate() => Batch(() =>
{
try { new RockMeshRadiusBuildGate().OnPreprocessBuild(null); }
catch (BuildFailedException failure) { throw new InvalidOperationException(failure.Message); }
return "the build gate passes";
});
// Throws unless every AsteroidMeshes entry of ARPG.unity has a stored radius equal to its mesh's measured one (a zero radius never does).
public static string Check(OpenSceneMode mode)
{
var scene = EditorSceneManager.OpenScene(Scene, mode);
try
{
var (_, meshes, measured) = Load(scene);
var failures = meshes.Select((m, i) => (m, i)).Where(p => !(measured[p.i] > 0f) || Mathf.Abs(p.m.Radius - measured[p.i]) > Tolerance).ToArray();
if (failures.Length > 0)
throw new InvalidOperationException("InstancedMesh.Radius differs from the measured one, or is unset, on mesh (run RockMeshRadius.Fill): " + string.Join(", ",
failures.Select(f => f.m.Mesh.name)));
return Summary("ok", measured);
}
finally
{
if (mode == OpenSceneMode.Additive) EditorSceneManager.CloseScene(scene, true);
}
}
private static (ZoneRenderer renderer, InstancedMesh[] meshes, float[] measured) Load(UnityEngine.SceneManagement.Scene scene)
{
var renderer = UnityEngine.Object.FindObjectsByType<ZoneRenderer>(FindObjectsInactive.Include, FindObjectsSortMode.None)
.Single(r => r.gameObject.scene == scene);
var meshes = renderer.AsteroidMeshes;
if (meshes == null || meshes.Length == 0) throw new InvalidOperationException("ARPG.unity's ZoneRenderer has no AsteroidMeshes.");
var bad = meshes.Where(m => m.Mesh == null).ToArray();
if (bad.Length > 0) throw new InvalidOperationException($"{bad.Length} AsteroidMeshes entries have no mesh.");
return (renderer, meshes, meshes.Select(m => Measure(m.Mesh)).ToArray());
}
private static string Summary(string what, float[] measured)
{
var sorted = measured.OrderBy(r => r).ToArray();
return $"{what}: {sorted.Length} meshes, radius min {sorted[0]:F3} median {sorted[sorted.Length / 2]:F3} max {sorted[^1]:F3}";
}
private static void Batch(Func<string> run)
{
try
{
Debug.Log($"[RockMeshRadius] {run()}");
EditorApplication.Exit(0);
}
catch (Exception exception)
{
Debug.LogException(exception);
EditorApplication.Exit(1);
}
}
}
// A mesh in AsteroidMeshes with no filled radius must not ship: the build fails, naming the field.
public class RockMeshRadiusBuildGate : IPreprocessBuildWithReport
{
public int callbackOrder => 0;
public void OnPreprocessBuild(BuildReport report)
{
try { RockMeshRadius.Check(OpenSceneMode.Additive); }
catch (InvalidOperationException failure) { throw new BuildFailedException(failure.Message); }
}
}
}

View file

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

View file

@ -1174,15 +1174,6 @@ public class ActionGameManager : MonoBehaviour
}
}
// A chunk's pose is planar (Zone.ChunkPose), read at the drawn time so the reticle sits on the drawn rock; the renderer
// draws it at the asteroid layer's height.
private float3 ChunkWorldPosition(ChunkId chunk)
{
var zone = CurrentEntity.Zone;
var pose = zone.ChunkPose(chunk.Field, chunk.Index, DrawAhead.Time(zone, Clock.Lead));
return float3(pose.x, Settings.PlanetSettings.AsteroidVerticalOffset, pose.y);
}
// The one way a run ends, won or lost: the run is cleared and the main menu returns.
private void EndRun()
{
@ -1465,7 +1456,7 @@ public class ActionGameManager : MonoBehaviour
ViewDot.Target = ZoneRenderer.EntityInstances[CurrentEntity].LookAtPoint.position;
var target = CurrentEntity.Target.Value;
if (target.Chunk is ChunkId chunk)
TargetIndicator.Target = ChunkWorldPosition(chunk).ToUnity();
TargetIndicator.Target = DrawAhead.Chunk(CurrentEntity.Zone, Settings.GameplaySettings, chunk, lead).ToUnity();
else if (target.Entity != null)
TargetIndicator.Target = DrawAhead.Position(target.Entity, lead).ToUnity();
var distance = length(ViewDot.Target.ToCultMath() - ownDrawn);

View file

@ -2,6 +2,8 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System.Collections.Generic;
using System.Linq;
using CultMath;
using GameCult.Caching;
using static CultMath.math;
@ -29,6 +31,83 @@ public static class DrawAhead
// The drawn height of a floater (loot, mines): the field at the drawn time plus the floater layer.
public static float FloaterHeight(Zone zone, GameplaySettings settings, float2 planar, float lead) => Height(zone, planar, lead) + settings.FloaterLayerHeight;
// A belt rock's drawn position (rulings asteroids-on-floater-layer, asteroid-tops-at-layer): its planar pose at the drawn time, its top on
// the floater layer over the field, so its centre is a drawn radius below it. pose.w is that radius because the renderer scales each mesh by
// RockScale(pose.w, the mesh's own measured radius). The renderer's belt loop and the reticle both call this; nothing else derives a rock height.
public static float3 ChunkAt(Zone zone, GameplaySettings settings, float4 pose, float lead) =>
float3(pose.x, FloaterHeight(zone, settings, pose.xy, lead) - pose.w, pose.y);
public static float3 Chunk(Zone zone, GameplaySettings settings, ChunkId chunk, float lead) =>
ChunkAt(zone, settings, zone.ChunkPose(chunk.Field, chunk.Index, Time(zone, lead)), lead);
// The scale that draws a mesh of the given measured radius (its farthest vertex from the mesh origin, a bound under any rotation) at the
// rock's gameplay radius (ruling rock-meshes-by-size). A radius that is not set is refused by name, never divided by.
public static float RockScale(float gameplayRadius, float meshRadius) =>
meshRadius > 0f ? gameplayRadius / meshRadius : throw new System.ArgumentException("InstancedMesh.Radius is not set (zero, negative or not a number): run RockMeshRadius.Fill.");
// One rock's entry in a belt's draw list: the rock (its index in the belt) and the scale that draws it at its gameplay radius on the mesh it is listed under.
public readonly struct RockDraw
{
public readonly int Rock;
public readonly float Scale;
public RockDraw(int rock, float scale) { Rock = rock; Scale = scale; }
}
// The whole per-rock draw list of a belt (ruling rock-meshes-by-size); the renderer decides nothing and indexes nothing. The belt keeps `keep` of
// the candidate meshes, chosen by the belt's key alone (a hash of key and candidate index, ties by index), so a belt looks the same on every load
// and every client. Meshes are the kept candidates ranked ascending by radius, and ByMesh[m] lists the rocks drawn on Meshes[m], so a renderer
// batching by mesh iterates the two together. Rocks ranked by unworn size split into one run per mesh, smallest rocks on the smallest mesh and the
// last run taking the remainder; equal sizes rank by index, equal radii by candidate index, and a mesh lists its rocks by index. Unworn, so a mined
// rock keeps its mesh and only shrinks. A list starts with every Scale NaN; Draw fills each entry's Scale from the poses (pose.w is the rock's
// gameplay radius) and returns the same arrays every call.
public sealed class BeltDraw<T>
{
private readonly float[] _radii;
private readonly int[][] _rocks;
public T[] Meshes { get; }
public RockDraw[][] ByMesh { get; }
public BeltDraw(IReadOnlyList<float> unwornSizes, IReadOnlyList<T> candidates, System.Func<T, float> radius, int keep, string beltKey)
{
if (candidates.Count == 0) throw new System.ArgumentException("AsteroidMeshes is empty: a belt needs at least one rock mesh.");
if (keep < 1) throw new System.ArgumentException("AsteroidMeshCount is below 1: a belt keeps at least one rock mesh.");
var radii = candidates.Select(radius).ToArray();
for (var c = 0; c < radii.Length; c++)
if (!(radii[c] > 0f)) throw new System.ArgumentException($"InstancedMesh.Radius of mesh #{c} is not set (zero, negative or not a number): run RockMeshRadius.Fill.");
var meshByRank = Enumerable.Range(0, radii.Length).OrderBy(c => KeepOrder(beltKey, c)).ThenBy(c => c).Take(keep)
.OrderBy(c => radii[c]).ThenBy(c => c).ToArray();
_radii = meshByRank.Select(c => radii[c]).ToArray();
Meshes = meshByRank.Select(c => candidates[c]).ToArray();
var rockByRank = Enumerable.Range(0, unwornSizes.Count).OrderBy(i => unwornSizes[i]).ThenBy(i => i).ToArray();
var run = System.Math.Max(1, unwornSizes.Count / Meshes.Length);
_rocks = Enumerable.Range(0, Meshes.Length).Select(m => rockByRank
.Where((_, rank) => System.Math.Min(rank / run, Meshes.Length - 1) == m).OrderBy(i => i).ToArray()).ToArray();
ByMesh = _rocks.Select(r => r.Select(rock => new RockDraw(rock, float.NaN)).ToArray()).ToArray();
}
// FNV-1a over the belt's key, mixed with the candidate index: a fixed function of its inputs, no global random state.
private static ulong KeepOrder(string beltKey, int candidate)
{
var h = 14695981039346656037UL;
foreach (var ch in beltKey) { h ^= ch; h *= 1099511628211UL; }
h ^= (uint)candidate; h *= 1099511628211UL;
h ^= h >> 32; h *= 0x9E3779B97F4A7C15UL; h ^= h >> 29;
return h;
}
public RockDraw[][] Draw(IReadOnlyList<float4> poses)
{
for (var m = 0; m < _rocks.Length; m++)
for (var k = 0; k < _rocks[m].Length; k++)
ByMesh[m][k] = new RockDraw(_rocks[m][k], RockScale(poses[_rocks[m][k]].w, _radii[m]));
return ByMesh;
}
}
public static BeltDraw<T> DrawListOf<T>(AsteroidBelt belt, PlanetSettings settings, IReadOnlyList<T> meshes, System.Func<T, float> radius, int keep, CultRecordKey beltKey) =>
new(Enumerable.Range(0, belt.Data.Asteroids.Length).Select(i => belt.UndamagedSize(i, settings)).ToArray(), meshes, radius, keep, beltKey.Value);
// A ship in a wormhole animation has its pose scripted by the step, not integrated from Velocity and TurnRate,
// so it is drawn where the step put it: extrapolating a scripted pose draws a streak that resets every step.
// A child is drawn where its parent is drawn, as the step copies the parent's pose onto it.

View file

@ -26,7 +26,6 @@ public class PlanetSettings
// The one global exaggeration of gravity height: height = potential / K, in u/s^2 per unit of height.
public float HeightExaggeration = 1f;
public float AsteroidVerticalOffset = -5f;
public ExponentialLerp AsteroidSize;
public ExponentialLerp AsteroidHitpoints;
public ExponentialLerp AsteroidRespawnTime;

View file

@ -73,11 +73,14 @@ public class ZoneRenderer : MonoBehaviour
[HideInInspector] public Dictionary<Entity, EntityInstance> EntityInstances = new Dictionary<Entity, EntityInstance>();
[HideInInspector] public Dictionary<CultRecordKey, PlanetObject> Planets = new Dictionary<CultRecordKey, PlanetObject>();
private Dictionary<CultRecordKey, InstancedMesh[]> _beltMeshes = new Dictionary<CultRecordKey, InstancedMesh[]>();
// Ruling rock-meshes-by-size: DrawAhead owns the whole draw list (kept meshes ranked, each rock's mesh and scale); the renderer only draws it.
private Dictionary<CultRecordKey, DrawAhead.BeltDraw<InstancedMesh>> _beltDraw = new Dictionary<CultRecordKey, DrawAhead.BeltDraw<InstancedMesh>>();
private Dictionary<CultRecordKey, Matrix4x4[][]> _beltMatrices = new Dictionary<CultRecordKey, Matrix4x4[][]>();
// Cut 1 (docs/mining-cut.md): the renderer's own buffer, filled from Zone.EvaluateBelt each frame. No pose
// is stored on AsteroidBelt any more.
private Dictionary<CultRecordKey, float4[]> _beltTransforms = new Dictionary<CultRecordKey, float4[]>();
// The one place a belt's rocks reach the GPU; the end-to-end verify swaps it to read what is drawn.
public System.Action<Mesh, Material, Matrix4x4[]> DrawInstanced = (mesh, material, matrices) => Graphics.DrawMeshInstanced(mesh, 0, material, matrices);
private float _viewDistance;
private GameObject _gravityField;
private ComputeBuffer _gravityWells;
@ -381,7 +384,7 @@ public class ZoneRenderer : MonoBehaviour
Planets.Clear();
Map.Clear();
_beltMeshes.Clear();
_beltDraw.Clear();
_beltMatrices.Clear();
_beltTransforms.Clear();
_tourPlanets.Clear();
@ -444,18 +447,10 @@ public class ZoneRenderer : MonoBehaviour
{
if (planetData is AsteroidBeltData beltData)
{
var meshes = AsteroidMeshes.ToList();
while (meshes.Count > Settings.AsteroidMeshCount)
meshes.RemoveAt(Random.Range(0, meshes.Count));
_beltMeshes[key] = meshes.ToArray();
_beltMatrices[key] = new Matrix4x4[meshes.Count][];
_beltTransforms[key] = new float4[beltData.Asteroids.Length];
var count = beltData.Asteroids.Length / meshes.Count;
var remainder = beltData.Asteroids.Length - count * meshes.Count;
for (int i = 0; i < meshes.Count; i++)
{
_beltMatrices[key][i] = new Matrix4x4[i < meshes.Count - 1 ? count : count + remainder];
}
var draw = DrawAhead.DrawListOf(Zone.AsteroidBelts[key], Zone.Settings, AsteroidMeshes, m => m.Radius, Settings.AsteroidMeshCount, key);
_beltDraw[key] = draw;
_beltMatrices[key] = draw.ByMesh.Select(rocks => new Matrix4x4[rocks.Length]).ToArray();
Map.RegisterBelt(key, _beltTransforms[key]);
}
@ -516,6 +511,43 @@ public class ZoneRenderer : MonoBehaviour
Map.Draw(Zone, ActionGameManager.Clock.Lead);
}
// The instanced draw of every belt, at the drawn time: Update's belt step, public so the end-to-end verify can run it on a loaded zone.
public void DrawBelts(float lead)
{
Plane[] planes = GeometryUtility.CalculateFrustumPlanes(MainCamera);
bool isVisible(Bounds bounds) => GeometryUtility.TestPlanesAABB(planes, bounds);
foreach (var (key, belt) in Zone.AsteroidBelts)
{
// Cut 1 (docs/mining-cut.md): belts have no stored pose any more. The renderer fills its own buffer
// from Zone.EvaluateBelt, at the drawn time (DrawAhead.Time), every frame.
var orbitPosition = DrawAhead.Orbit(Zone, Zone.Orbits[belt.Data.Orbit.Key].Data.Parent.Key, lead);
var height = DrawAhead.Height(Zone, orbitPosition, lead);
var transforms = _beltTransforms[key];
Zone.EvaluateBelt(key, transforms, DrawAhead.Time(Zone, lead));
if(isVisible(new Bounds(
new Vector3(orbitPosition.x,height,orbitPosition.y),
new Vector3(belt.Radius * 2,100,belt.Radius * 2))))
{
var draw = _beltDraw[key];
var matrices = _beltMatrices[key];
var byMesh = draw.Draw(transforms);
for (int m = 0; m < byMesh.Length; m++)
{
for (int k = 0; k < byMesh[m].Length; k++)
{
var rock = byMesh[m][k];
var transform = transforms[rock.Rock];
matrices[m][k] = Matrix4x4.TRS(DrawAhead.ChunkAt(Zone, ItemManager.GameplaySettings, transform, lead).ToUnity(),
Quaternion.Euler(cos(transform.z) * 100, sin(transform.z) * 100, (float)rock.Rock / transforms.Length * 360),
Vector3.one * rock.Scale);
}
DrawInstanced(draw.Meshes[m].Mesh, draw.Meshes[m].Material, matrices[m]);
}
}
}
}
void Update()
{
var lead = ActionGameManager.Clock.Lead;
@ -533,44 +565,8 @@ public class ZoneRenderer : MonoBehaviour
// SlimeRenderer.SpawnPositions[i] = _suns[i].Body.transform.position.Flatland();
// }
Shader.SetGlobalFloat("_AsteroidVerticalOffset", ActionGameManager.Instance.Settings.PlanetSettings.AsteroidVerticalOffset);
Plane[] planes = GeometryUtility.CalculateFrustumPlanes(MainCamera);
bool isVisible(Bounds bounds) => GeometryUtility.TestPlanesAABB(planes, bounds);
foreach (var (key, belt) in Zone.AsteroidBelts)
{
// Cut 1 (docs/mining-cut.md): belts have no stored pose any more. The renderer fills its own buffer
// from Zone.EvaluateBelt, at the drawn time (DrawAhead.Time), every frame.
var orbitPosition = DrawAhead.Orbit(Zone, Zone.Orbits[belt.Data.Orbit.Key].Data.Parent.Key, lead);
var height = DrawAhead.Height(Zone, orbitPosition, lead);
var transforms = _beltTransforms[key];
Zone.EvaluateBelt(key, transforms, DrawAhead.Time(Zone, lead));
if(isVisible(new Bounds(
new Vector3(orbitPosition.x,height,orbitPosition.y),
new Vector3(belt.Radius * 2,100,belt.Radius * 2))))
{
var meshes = _beltMeshes[key];
var matrices = _beltMatrices[key];
var count = transforms.Length / meshes.Length;
for (int i = 0; i < meshes.Length; i++)
{
for (int t = 0; t < matrices[i].Length; t++)
{
var tx = t + i * count;
var transform = transforms[tx];
matrices[i][t] = Matrix4x4.TRS(new Vector3(transform.x,0,transform.y),
Quaternion.Euler(
cos(transform.z + (float)i / meshes.Length) * 100,
sin(transform.z + (float)i / meshes.Length) * 100,
(float)tx / transforms.Length * 360),
Vector3.one * transform.w);
}
Graphics.DrawMeshInstanced(meshes[i].Mesh, 0, meshes[i].Material, matrices[i]);
}
}
}
DrawBelts(lead);
foreach (var planet in Planets)
{
@ -624,4 +620,7 @@ public class InstancedMesh
{
public Mesh Mesh;
public Material Material;
// The mesh's farthest vertex from its origin, measured by Aetheria.EditorTools.RockMeshRadius and verified against the mesh;
// the renderer scales the mesh by gameplay radius over this.
public float Radius;
}