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

BIN
Assets/Resources/Settings.asset (Stored with Git LFS)

Binary file not shown.

View file

@ -16337,64 +16337,94 @@ MonoBehaviour:
AsteroidMeshes:
- Mesh: {fileID: 4300004, guid: bdb4c719a85f3f2419f6c573dd1daf15, type: 3}
Material: {fileID: 2100000, guid: ce975d2d2ccd0914b9260b7d66319aaa, type: 2}
Radius: 1.5022775
- Mesh: {fileID: 4300004, guid: 19fa9aa3b5fee244fb007d58c351be01, type: 3}
Material: {fileID: 2100000, guid: 443981befe15f604aba2b04eef2f0de2, type: 2}
Radius: 1.0907339
- Mesh: {fileID: 4300004, guid: 693c11c21d8f22240ae2b6c576bf923a, type: 3}
Material: {fileID: 2100000, guid: 818a00140c1abb047be1d65d1c20b52a, type: 2}
Radius: 1.345119
- Mesh: {fileID: 4300004, guid: 73f7fb37d1925f246919650feda3df71, type: 3}
Material: {fileID: 2100000, guid: 60bb3870b22a23b458c2bc94e4403bda, type: 2}
Radius: 0.9581426
- Mesh: {fileID: 4300004, guid: 13503d5cc77421a4384053d90e12610f, type: 3}
Material: {fileID: 2100000, guid: 3175592343a31774b872b32ae89a7ee8, type: 2}
Radius: 1.3620772
- Mesh: {fileID: 4300004, guid: 7c93e75f8f4cde14d82932c65389f566, type: 3}
Material: {fileID: 2100000, guid: edee875c32d24154688acf645e314864, type: 2}
Radius: 1.1282945
- Mesh: {fileID: 4300004, guid: b2e60056527750e4c87bd4f6de85ec32, type: 3}
Material: {fileID: 2100000, guid: c3d0e5db17690724b89cff9a71876aa6, type: 2}
Radius: 1.5490558
- Mesh: {fileID: 4300004, guid: 87ef785989ec82b498488e651b61b2c6, type: 3}
Material: {fileID: 2100000, guid: 5686a9131ca8350448768971c9e21da0, type: 2}
Radius: 1.4786077
- Mesh: {fileID: 4300004, guid: a0f93e74d454f5046908a26ecbbc31cf, type: 3}
Material: {fileID: 2100000, guid: 85e9ac4c62af0fb4a94bc35ac5e24254, type: 2}
Radius: 1.1443666
- Mesh: {fileID: 4300004, guid: 4a9e0d7ffd8f8e94abf7a800653c0b4e, type: 3}
Material: {fileID: 2100000, guid: 27b1a299ede684d499133b9a0dc85bda, type: 2}
Radius: 1.1114912
- Mesh: {fileID: 4300004, guid: 232b4a71860923049890d661dee4aff7, type: 3}
Material: {fileID: 2100000, guid: d9e9ebabc89054f4495754372a7b4d8f, type: 2}
Radius: 1.5410137
- Mesh: {fileID: 4300004, guid: 335c54d8095aa3f44bab46ca5fa5bd4c, type: 3}
Material: {fileID: 2100000, guid: 71cd479bb26cd7140ba9d3753f49ffbc, type: 2}
Radius: 1.3792597
- Mesh: {fileID: 4300004, guid: 8942a135422bd014bb3451fe02d656f2, type: 3}
Material: {fileID: 2100000, guid: 0f7035043b158484995ff86e9371b141, type: 2}
Radius: 1.2433527
- Mesh: {fileID: 4300004, guid: 47e8c832aa7d46e4ba06961fddf3cc06, type: 3}
Material: {fileID: 2100000, guid: d5788bb4b0d2d93429d9c6462034e470, type: 2}
Radius: 1.3664026
- Mesh: {fileID: 4300004, guid: 7c1227bb235e0f74aa9a6cdb9e7ccac5, type: 3}
Material: {fileID: 2100000, guid: 060b5e360701d5146adc3d1c65c7a3df, type: 2}
Radius: 1.2458941
- Mesh: {fileID: 4300004, guid: 77a272b796d88344eacf56a79372b45e, type: 3}
Material: {fileID: 2100000, guid: 3cd15eb9c358aa547bcd223ee3c1edde, type: 2}
Radius: 2.157539
- Mesh: {fileID: 4300004, guid: bafc829a0712b174d9485c5bcbc1b529, type: 3}
Material: {fileID: 2100000, guid: b58fd84189173114d8708fb50b43f2d7, type: 2}
Radius: 1.5399581
- Mesh: {fileID: 4300004, guid: 47fa308b6b417034ea47809cb08bccfb, type: 3}
Material: {fileID: 2100000, guid: f56b0896907ac9e41b6fd9dca05f1fa2, type: 2}
Radius: 1.8855069
- Mesh: {fileID: 4300004, guid: fb03a3e40b409db44a810641e0a6c493, type: 3}
Material: {fileID: 2100000, guid: 78859c2284122944493cc283967036db, type: 2}
Radius: 1.2214345
- Mesh: {fileID: 4300004, guid: de7739fee205f4a4f9ae290126a36b61, type: 3}
Material: {fileID: 2100000, guid: 89b312038af5203469d447f7ff01105f, type: 2}
Radius: 1.8183402
- Mesh: {fileID: 4300004, guid: a966f9aba651bf34da1bb6e3e7f26301, type: 3}
Material: {fileID: 2100000, guid: 3326bc3e2b8ada34c9bfd44cf8cf1a1c, type: 2}
Radius: 1.5686527
- Mesh: {fileID: 4300004, guid: 0d55ec2b68453b7418abcd63378eed69, type: 3}
Material: {fileID: 2100000, guid: 12b1f515279281a42b2bd53cdb174488, type: 2}
Radius: 1.9112675
- Mesh: {fileID: 4300004, guid: e5dc38d7ffa6ed242831255d93657aaa, type: 3}
Material: {fileID: 2100000, guid: 5a06a4f6d5df32b4dae76c13939d3ce9, type: 2}
Radius: 1.9507759
- Mesh: {fileID: 4300004, guid: 9f36793bd68d3d84d8116485bf933992, type: 3}
Material: {fileID: 2100000, guid: decc37f956dfc244b956de2d6f5375c2, type: 2}
Radius: 1.5778105
- Mesh: {fileID: 4300004, guid: 214c1534cdd5b3a4a80927e9d879290f, type: 3}
Material: {fileID: 2100000, guid: a20b424628f81de4e8cf080a503b66af, type: 2}
Radius: 1.4580269
- Mesh: {fileID: 4300004, guid: 7db51bba443e10f4f9a597a296ceb0c6, type: 3}
Material: {fileID: 2100000, guid: f96d19f3fe978b044972d1360d2e7707, type: 2}
Radius: 2.430305
- Mesh: {fileID: 4300004, guid: 6116ec4d9786d04408c3b26d0c525407, type: 3}
Material: {fileID: 2100000, guid: 1788de6db766a4444a09dc0c80e1bcb2, type: 2}
Radius: 1.7872484
- Mesh: {fileID: 4300004, guid: 3d6e611c9ebead04b9d6d7d490977ddc, type: 3}
Material: {fileID: 2100000, guid: 0fab294fd86e3b84394a967642904d55, type: 2}
Radius: 1.9273407
- Mesh: {fileID: 4300004, guid: 76b1f709ca820234bbf6c03507333b7e, type: 3}
Material: {fileID: 2100000, guid: 63a8a57976371814689d7fb222e635e9, type: 2}
Radius: 1.70707
- Mesh: {fileID: 4300004, guid: d3c9f05eabff9e24ca9521e0b8dffd50, type: 3}
Material: {fileID: 2100000, guid: 9be65d82397829b46894243c2d91a06d, type: 2}
Radius: 1.6336656
AsteroidSpritesheetWidth: 4
AsteroidSpritesheetHeight: 4
LODHandler: {fileID: 1009503736}

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

View file

@ -12,7 +12,6 @@ Shader "Aetheria/Asteroid"
#pragma fragment frag
#include "UnityCG.cginc" // for UnityObjectToWorldNormal
#include_with_pragmas "Assets/Shaders/Volumetric.cginc"
uniform float _AsteroidVerticalOffset;
struct v2f
{
@ -25,11 +24,7 @@ Shader "Aetheria/Asteroid"
{
v2f o;
UNITY_SETUP_INSTANCE_ID(v);
float3 worldOrigin = mul(unity_ObjectToWorld, float4(0, 0, 0, 1.0)).xyz;
float3 worldPos = mul(unity_ObjectToWorld, float4(v.vertex.xyz, 1.0)).xyz;
const float2 uv = getUV(worldOrigin.xz);
const float surfaceDisp = tex2Dlod(_NebulaSurfaceHeight, half4(uv, 0, 0)).r;
worldPos.y += _AsteroidVerticalOffset - surfaceDisp;
o.vertex = mul(UNITY_MATRIX_VP, float4(worldPos, 1.0));
o.uv = v.texcoord;
// get vertex normal in world space

View file

@ -29,6 +29,7 @@ public sealed class DrawnPoseTests : IDisposable
private const float StationDistance = 3f, StationPhase = .4f;
private const float ShipLayerHeight = 2.5f;
private const float OrbitalLayerHeight = 7.5f;
private const float FloaterLayerHeight = -1.25f;
// The sun's rail is parentless and off-centre, the shape ZoneGenerator gives a cluster root (FixedPosition = cluster centre).
private static readonly float2 SunCentre = new float2(700f, -300f);
@ -88,7 +89,7 @@ public sealed class DrawnPoseTests : IDisposable
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
ShipLayerHeight = ShipLayerHeight, OrbitalLayerHeight = OrbitalLayerHeight
ShipLayerHeight = ShipLayerHeight, OrbitalLayerHeight = OrbitalLayerHeight, FloaterLayerHeight = FloaterLayerHeight
}, _ => { });
CultRecordKey Orbit(CultRecordKey parent, float distance, float phase, float2 fixedPosition = default)
@ -347,6 +348,242 @@ public sealed class DrawnPoseTests : IDisposable
}
}
// The reticle (DrawAhead.Chunk) and the renderer's per-rock instance (DrawAhead.ChunkAt on EvaluateBelt's drawn pose)
// are one height: the field at the rock's planar position at the drawn time, plus the floater layer, less its drawn radius, so its top
// is at the layer. Rulings asteroids-on-floater-layer, asteroid-tops-at-layer.
[Fact]
public void AChunkIsDrawnWithItsTopOnTheFloaterLayerOverTheField()
{
var w = Build();
Step(w, 1f / 60f, 301);
const float lead = .01f;
var time = w.Zone.ExactTime + lead;
var drawn = new float4[w.Asteroids.Length];
w.Zone.EvaluateBelt(w.Belt, drawn, time);
var settings = w.Items.GameplaySettings;
var deep = 0;
for (var i = 0; i < w.Asteroids.Length; i++)
{
var reticle = DrawAhead.Chunk(w.Zone, settings, new ChunkId(w.Belt, i), lead);
var field = FieldAt(w, drawn[i].xy, time);
AssertNear(drawn[i].xy, reticle.xz, 1e-3f, $"chunk {i} reticle xz");
var radius = w.Zone.ChunkRadius(new ChunkId(w.Belt, i));
Assert.True(radius > 2f, $"chunk {i} has a radius that any scaling of it would move");
Assert.Equal(radius, drawn[i].w, 4);
Assert.Equal(field + FloaterLayerHeight - radius, reticle.y, 3);
Assert.Equal(field + FloaterLayerHeight, reticle.y + radius, 3);
Assert.Equal(reticle.y, DrawAhead.ChunkAt(w.Zone, settings, drawn[i], lead).y, 4);
if (abs(field) > 1f) deep++;
}
Assert.True(deep > 0, "some rock sits where the field is more than a unit from zero, so a dropped field fails");
}
// Ruling rock-meshes-by-size: a mesh is scaled by the gameplay radius over its own measured radius, so the drawn extent (scale x mesh
// radius) is the gameplay radius and the drawn top is the floater layer. Mesh radii as measured in the project (0.96 to 2.43 around a median of 1.5).
[Theory]
[InlineData(0.958f)]
[InlineData(1.502f)]
[InlineData(2.430f)]
public void ARockIsDrawnAtItsGameplayRadiusWhateverItsMeshRadius(float meshRadius)
{
var w = Build();
Step(w, 1f / 60f, 301);
const float lead = .01f;
var time = w.Zone.ExactTime + lead;
var drawn = new float4[w.Asteroids.Length];
w.Zone.EvaluateBelt(w.Belt, drawn, time);
var settings = w.Items.GameplaySettings;
for (var i = 0; i < drawn.Length; i++)
{
var scale = DrawAhead.RockScale(drawn[i].w, meshRadius);
Assert.Equal(w.Zone.ChunkRadius(new ChunkId(w.Belt, i)), scale * meshRadius, 3);
Assert.Equal(FieldAt(w, drawn[i].xy, time) + FloaterLayerHeight, DrawAhead.ChunkAt(w.Zone, settings, drawn[i], lead).y + scale * meshRadius, 3);
}
}
// A mesh candidate as the renderer holds it: a name and a measured radius, in the renderer's own order.
private record Candidate(string Name, float Radius);
private static DrawAhead.BeltDraw<Candidate> List(float[] sizes, params Candidate[] candidates) =>
new(sizes, candidates, c => c.Radius, candidates.Length, "belt");
private static float4[] Poses(params float[] radii) => radii.Select(r => float4(0, 0, 0, r)).ToArray();
private static int[] MeshOf(DrawAhead.BeltDraw<Candidate> list, int rocks)
{
var meshOf = new int[rocks];
for (var m = 0; m < list.ByMesh.Length; m++)
foreach (var d in list.ByMesh[m]) meshOf[d.Rock] = m;
return meshOf;
}
// Biggest rock, biggest mesh; smallest, smallest; sizes from the belt's own authored asteroids.
[Fact]
public void RocksRankedBySizeTakeMeshesRankedByRadius()
{
var w = Build();
var belt = w.Zone.AsteroidBelts[w.Belt];
var sizes = Enumerable.Range(0, w.Asteroids.Length).Select(i => belt.UndamagedSize(i, w.Zone.Settings)).ToArray();
var list = DrawAhead.DrawListOf(belt, w.Zone.Settings, new[] { new Candidate("b", 3f), new Candidate("s", 1f), new Candidate("m", 2f) }, c => c.Radius, 3, w.Belt);
var meshOf = MeshOf(list, sizes.Length);
Assert.Equal(sizes.Length, list.ByMesh.Sum(r => r.Length));
Assert.Equal(new[] { "s", "m", "b" }, list.Meshes.Select(c => c.Name).ToArray());
Assert.Equal(2, meshOf[Array.IndexOf(sizes, sizes.Max())]);
Assert.Equal(0, meshOf[Array.IndexOf(sizes, sizes.Min())]);
for (var a = 0; a < sizes.Length; a++)
for (var b = 0; b < sizes.Length; b++)
if (sizes[a] < sizes[b]) Assert.True(meshOf[a] <= meshOf[b], $"rock {a} (size {sizes[a]}) is smaller than rock {b} ({sizes[b]}) and takes a bigger mesh");
}
// The renderer's draw list: more rocks than meshes, unequal radii, unsorted candidates and unsorted sizes, so a scale taken by rock index, a
// kept list left unsorted, a reversed kept order and a slot or mesh assigned by rock index all fail. Sizes by rank: 1 (rock 1), 2 (3), 4 (2), 5 (5), 7 (4), 9 (0).
[Fact]
public void TheDrawListGivesEachRockItsMeshAndItsScale()
{
var list = List(new[] { 9f, 1f, 4f, 2f, 7f, 5f }, new Candidate("big", 2.4f), new Candidate("small", 1.0f), new Candidate("mid", 1.5f));
var byMesh = list.Draw(Poses(10f, 11f, 12f, 13f, 14f, 15f));
Assert.Equal(new[] { "small", "mid", "big" }, list.Meshes.Select(c => c.Name).ToArray());
Assert.Equal(new[] { 1, 3 }, byMesh[0].Select(d => d.Rock).ToArray());
Assert.Equal(new[] { 2, 5 }, byMesh[1].Select(d => d.Rock).ToArray());
Assert.Equal(new[] { 0, 4 }, byMesh[2].Select(d => d.Rock).ToArray());
Assert.Equal(new[] { 11f / 1.0f, 13f / 1.0f }, byMesh[0].Select(d => d.Scale).ToArray());
Assert.Equal(new[] { 12f / 1.5f, 15f / 1.5f }, byMesh[1].Select(d => d.Scale).ToArray());
Assert.Equal(new[] { 10f / 2.4f, 14f / 2.4f }, byMesh[2].Select(d => d.Scale).ToArray());
}
// Worn rocks are drawn at their current radius on the mesh the unworn size gave them; the list reads the poses each call.
[Fact]
public void TheDrawListScalesByTheCurrentRadiusOnTheSameMesh()
{
var list = List(new[] { 3f, 1f, 2f }, new Candidate("a", 2f), new Candidate("b", 4f));
var first = list.Draw(Poses(8f, 8f, 8f)).Select(r => r.Select(d => d.Rock).ToArray()).ToArray();
var worn = list.Draw(Poses(2f, 8f, 8f));
Assert.Equal(first, worn.Select(r => r.Select(d => d.Rock).ToArray()).ToArray());
var rock0 = worn.SelectMany((r, m) => r.Select(d => (d.Rock, m, d.Scale))).Single(d => d.Rock == 0);
Assert.Equal((1, 2f / 4f), (rock0.m, rock0.Scale));
}
[Fact]
public void EqualRocksTakeMeshesByIndex()
{
int[] Meshes(float[] sizes, params float[] radii) =>
MeshOf(List(sizes, radii.Select((r, i) => new Candidate("c" + i, r)).ToArray()), sizes.Length);
Assert.Equal(new[] { 0, 0, 1, 1, 2, 2, 2 }, Meshes(new[] { 5f, 5f, 5f, 5f, 5f, 5f, 5f }, 1f, 2f, 3f));
Assert.Equal(new[] { 2, 0, 2, 1, 2 }, Meshes(new[] { 9f, 1f, 4f, 2f, 7f }, 1f, 2f, 3f));
Assert.Equal(new[] { 0, 1, 1 }, Meshes(new[] { 1f, 2f, 3f }, 1f, 2f));
Assert.Equal(new[] { 0, 0, 1, 1 }, Meshes(new[] { 1f, 2f, 3f, 4f }, 1f, 2f));
Assert.Equal(new[] { 0, 1 }, Meshes(new[] { 1f, 2f }, 1f, 2f, 3f, 4f, 5f));
Assert.Equal(new[] { 0, 0, 1, 1, 2, 2, 2 }, Meshes(new[] { 5f, 5f, 5f, 5f, 5f, 5f, 5f }, 4f, 4f, 4f));
}
// A mesh with no measured radius is refused at load, naming the field and the mesh, never an infinite scale.
[Theory]
[InlineData(0f)]
[InlineData(-1f)]
[InlineData(float.NaN)]
public void AMeshWithoutARadiusIsRefusedByName(float radius)
{
var refused = Assert.Throws<ArgumentException>(() => List(new[] { 1f, 2f }, new Candidate("ok", 1.5f), new Candidate("bad", radius)));
Assert.Contains("InstancedMesh.Radius", refused.Message);
Assert.Contains("#1", refused.Message);
Assert.Contains("InstancedMesh.Radius", Assert.Throws<ArgumentException>(() => DrawAhead.RockScale(1f, radius)).Message);
}
// Finding equal-radius-tie-unpinned: meshes of equal radius rank by candidate index, the first listed first.
[Fact]
public void EqualRadiusMeshesRankByCandidateIndex()
{
string[] Names(params Candidate[] candidates) => List(new[] { 1f, 2f, 3f }, candidates).Meshes.Select(c => c.Name).ToArray();
Assert.Equal(new[] { "x", "y", "z" }, Names(new Candidate("x", 2f), new Candidate("y", 2f), new Candidate("z", 2f)));
Assert.Equal(new[] { "small", "x", "y" }, Names(new Candidate("x", 3f), new Candidate("y", 3f), new Candidate("small", 1f)));
Assert.Equal(new[] { "y", "x" }, Names(new Candidate("y", 2f), new Candidate("x", 2f)));
}
// Finding empty-candidates-divide-by-zero: no candidates, or a kept count below one, is a refusal naming the field.
[Fact]
public void NoCandidatesOrNoKeptCountIsRefusedByFieldName()
{
Assert.Contains("AsteroidMeshes", Assert.Throws<ArgumentException>(() => new DrawAhead.BeltDraw<Candidate>(new[] { 1f }, new Candidate[0], c => c.Radius, 5, "belt")).Message);
foreach (var keep in new[] { 0, -1 })
Assert.Contains("AsteroidMeshCount", Assert.Throws<ArgumentException>(() => new DrawAhead.BeltDraw<Candidate>(new[] { 1f }, new[] { new Candidate("a", 1f) }, c => c.Radius, keep, "belt")).Message);
}
// Finding renderer-random-mesh-prune: which meshes a belt keeps is a function of the belt's key, the candidates and the kept count alone.
[Fact]
public void TheKeptMeshesAreAFunctionOfTheBeltKey()
{
var candidates = Enumerable.Range(0, 8).Select(i => new Candidate("c" + i, 1f + i)).ToArray();
string[] Kept(string key, int keep) =>
new DrawAhead.BeltDraw<Candidate>(new[] { 1f, 2f, 3f }, candidates, c => c.Radius, keep, key).Meshes.Select(c => c.Name).ToArray();
var keys = Enumerable.Range(0, 40).Select(i => "belt-" + i).ToArray();
foreach (var key in keys)
{
var kept = Kept(key, 3);
Assert.Equal(3, kept.Distinct().Count());
Assert.Equal(kept, Kept(key, 3));
}
Assert.True(keys.Select(k => string.Join(",", Kept(k, 3).OrderBy(n => n))).Distinct().Count() >= 8, "belts keep different meshes");
foreach (var candidate in candidates)
Assert.Contains(keys, k => Kept(k, 3).Contains(candidate.Name));
Assert.Equal(candidates.Select(c => c.Name).ToArray(), Kept("belt-0", 8));
Assert.Equal(candidates.Select(c => c.Name).ToArray(), Kept("belt-0", 20));
}
// A mined rock keeps its mesh and only shrinks: the assignment reads the unworn size.
[Fact]
public void AMinedRockKeepsItsMesh()
{
var w = Build();
var belt = w.Zone.AsteroidBelts[w.Belt];
var candidates = new[] { new Candidate("a", 1f), new Candidate("b", 2f), new Candidate("c", 3f) };
int[] Meshes() => MeshOf(DrawAhead.DrawListOf(belt, w.Zone.Settings, candidates, c => c.Radius, candidates.Length, w.Belt), w.Asteroids.Length);
var before = Meshes();
var chunk = new ChunkId(w.Belt, 5);
var hitpoints = w.Zone.Settings.AsteroidHitpoints.Evaluate(w.Asteroids[5].Size);
var unworn = w.Zone.ChunkRadius(chunk);
Assert.False(w.Zone.Wear(chunk, hitpoints * .9f));
Assert.True(w.Zone.ChunkRadius(chunk) < unworn * .5f, "the rock has shrunk to less than half its size");
Assert.Equal(before, Meshes());
}
// Where the renderer decides a rock's height and scale it calls DrawAhead, and the reticle calls the same function: no inline copy of the rock
// height in any of the three sites (finding owner-not-pinned). The renderer and the HUD are Unity-only, so this reads their source.
[Fact]
public void TheRendererAndTheReticleReadTheOneRockHeight()
{
var root = RunStartTests.FindRepoRoot();
string Source(string path) => File.ReadAllText(Path.Combine(root, path));
var renderer = Source("Assets/Scripts/Zone Display/ZoneRenderer.cs");
var manager = Source("Assets/Scripts/Gameplay/ActionGameManager.cs");
var drawAhead = Source("Assets/Scripts/ServerShared/DrawAhead.cs");
Assert.Contains("DrawAhead.ChunkAt(", renderer);
// The renderer holds no mesh choice, sort or index arithmetic: it iterates DrawAhead's list.
Assert.Contains("DrawAhead.DrawListOf(", renderer);
Assert.Matches(@"var byMesh = draw\.Draw\(transforms\);\s*for \(int m = 0;", renderer);
Assert.Contains("transforms[rock.Rock]", renderer);
Assert.Contains("Vector3.one * rock.Scale", renderer);
Assert.Contains("DrawInstanced(draw.Meshes[m].Mesh, draw.Meshes[m].Material, matrices[m])", renderer);
Assert.Contains("matrices[m][k] =", renderer);
Assert.Contains("byMesh[m][k]", renderer);
Assert.DoesNotContain("RockScale(", renderer);
Assert.DoesNotContain(".Sort(", renderer);
Assert.DoesNotContain("OrderBy", renderer);
Assert.DoesNotContain("meshOf", renderer);
Assert.DoesNotContain("slotOf", renderer);
Assert.DoesNotContain("perMesh", renderer);
Assert.DoesNotContain("FloaterHeight(", renderer);
Assert.DoesNotContain("Vector3.one * transform.w", renderer);
Assert.Contains("DrawAhead.Chunk(", manager);
Assert.DoesNotContain("FloaterHeight(", manager);
var start = drawAhead.IndexOf("public static float3 Chunk(", StringComparison.Ordinal);
Assert.True(start >= 0, "DrawAhead.Chunk exists");
var chunk = drawAhead.Substring(start, drawAhead.IndexOf(';', start) - start);
Assert.Contains("ChunkAt(", chunk);
Assert.DoesNotContain("FloaterHeight", chunk);
Assert.DoesNotContain("pose.w", chunk);
}
// The term the shader takes sits on its node's rail at the drawn time: the lowering copies DrawAhead.Orbit of the term's
// node and decides nothing, so a drawn term and the drawn planet share one pose at a non-zero lead.
[Fact]