diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset index f92142d6b..c3e8fb3a1 100644 --- a/Assets/Resources/Settings.asset +++ b/Assets/Resources/Settings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ab2cdc0732716dcbfa4875e90e598b0dff3f0c8d4f871cc180649a3db2e4cf0c -size 12562 +oid sha256:b004d902ecd9c1b98ff7e0b23215225cce987fd8572fb5f55c5f53fe08f6ccba +size 12530 diff --git a/Assets/Scenes/ARPG.unity b/Assets/Scenes/ARPG.unity index 0bee4642b..4d5b15473 100644 --- a/Assets/Scenes/ARPG.unity +++ b/Assets/Scenes/ARPG.unity @@ -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} diff --git a/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs b/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs new file mode 100644 index 000000000..43153b267 --- /dev/null +++ b/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs @@ -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 -executeMethod Aetheria.EditorTools.BeltFloaterLayerVerify.Run -logFile +// +// 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 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("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().First(m => m.name == p[1]), + Material = p[2] == "" ? null : AssetDatabase.LoadAssetAtPath(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(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(beltOrbitA), Asteroids = Rocks(24, i => (i * 7 % 24) / 23f, 40f) }).Key; + var beltB = cache.Upsert(new AsteroidBeltData { Orbit = new CultRecordRef(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(orbit)); + pack.Planets.Add(new CultRecordRef(cache.Upsert(new PlanetData { Name = "Belt Sun", Orbit = new CultRecordRef(sunOrbit), Mass = 400f }).Key)); + pack.Planets.Add(new CultRecordRef(cache.Upsert(new PlanetData { Name = "Belt Planet", Orbit = new CultRecordRef(planetOrbit), Mass = 400f }).Key)); + pack.Planets.Add(new CultRecordRef(beltA)); + pack.Planets.Add(new CultRecordRef(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(); + renderer.Settings = game; + renderer.AsteroidMeshes = meshes; + renderer.ItemManager = items; + renderer.LODHandler = new GameObject("Belt LOD").AddComponent(); + 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.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(); + 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(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); } + } + } +} diff --git a/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs.meta b/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs.meta new file mode 100644 index 000000000..a383593c7 --- /dev/null +++ b/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7d25a93db1972324fa5460b2415c1e4a \ No newline at end of file diff --git a/Assets/Scripts/Editor/RockMeshRadius.cs b/Assets/Scripts/Editor/RockMeshRadius.cs new file mode 100644 index 000000000..971284924 --- /dev/null +++ b/Assets/Scripts/Editor/RockMeshRadius.cs @@ -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 -executeMethod Aetheria.EditorTools.RockMeshRadius.Fill -logFile +// Unity -batchmode -nographics -projectPath -executeMethod Aetheria.EditorTools.RockMeshRadius.Verify -logFile +// +// 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 -executeMethod Aetheria.EditorTools.RockMeshRadius.Gate -logFile +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(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 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); } + } + } +} diff --git a/Assets/Scripts/Editor/RockMeshRadius.cs.meta b/Assets/Scripts/Editor/RockMeshRadius.cs.meta new file mode 100644 index 000000000..f95fec963 --- /dev/null +++ b/Assets/Scripts/Editor/RockMeshRadius.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9ea93f3d164825c4b99a0d07f24d5cf6 \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 033060d7e..e24a6e395 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -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); diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs index c3cde0ef3..a5d4736c5 100644 --- a/Assets/Scripts/ServerShared/DrawAhead.cs +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -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 + { + private readonly float[] _radii; + private readonly int[][] _rocks; + + public T[] Meshes { get; } + public RockDraw[][] ByMesh { get; } + + public BeltDraw(IReadOnlyList unwornSizes, IReadOnlyList candidates, System.Func 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 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 DrawListOf(AsteroidBelt belt, PlanetSettings settings, IReadOnlyList meshes, System.Func 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. diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 85d1f3d96..00db76c68 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -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; diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index 3c584c956..f63f746bd 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -73,11 +73,14 @@ public class ZoneRenderer : MonoBehaviour [HideInInspector] public Dictionary EntityInstances = new Dictionary(); [HideInInspector] public Dictionary Planets = new Dictionary(); - private Dictionary _beltMeshes = new Dictionary(); + // 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> _beltDraw = new Dictionary>(); private Dictionary _beltMatrices = new Dictionary(); // 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 _beltTransforms = new Dictionary(); + // 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 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; } diff --git a/Assets/Shaders/Asteroid.shader b/Assets/Shaders/Asteroid.shader index 2558cbc53..31904120c 100644 --- a/Assets/Shaders/Asteroid.shader +++ b/Assets/Shaders/Asteroid.shader @@ -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 diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index acc7f8f72..9507d0783 100644 --- a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -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 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 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(() => 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(() => 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(() => new DrawAhead.BeltDraw(new[] { 1f }, new Candidate[0], c => c.Radius, 5, "belt")).Message); + foreach (var keep in new[] { 0, -1 }) + Assert.Contains("AsteroidMeshCount", Assert.Throws(() => new DrawAhead.BeltDraw(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(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]