From 72e2383ea446c1cdc6632a95a6b592d05288d79e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 00:23:47 +0200 Subject: [PATCH] DrawAhead.BeltDraw keeps the belt's meshes by the belt's key; empty candidates and a zero kept count are named refusals; ZoneRenderer draws through DrawBelts and one DrawInstanced seam The unseeded Random.Range prune leaves ZoneRenderer: the kept set is a function of the candidates, the kept count and the belt key. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/DrawAhead.cs | 47 +++++++++++------ Assets/Scripts/Zone Display/ZoneRenderer.cs | 51 ++++++++++--------- tests/Aetheria.Shared.Tests/DrawnPoseTests.cs | 48 +++++++++++++++-- 3 files changed, 103 insertions(+), 43 deletions(-) diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs index 7115ccf30..a5d4736c5 100644 --- a/Assets/Scripts/ServerShared/DrawAhead.cs +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -53,11 +53,13 @@ public static class DrawAhead 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. 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 and - // equal radii rank by 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. + // 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; @@ -66,21 +68,34 @@ public static class DrawAhead public T[] Meshes { get; } public RockDraw[][] ByMesh { get; } - public BeltDraw(IReadOnlyList unwornSizes, IReadOnlyList candidates, System.Func radius) + 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 m = 0; m < radii.Length; m++) - if (!(radii[m] > 0f)) throw new System.ArgumentException($"InstancedMesh.Radius of mesh #{m} is not set (zero, negative or not a number): run RockMeshRadius.Fill."); - var meshByRank = Enumerable.Range(0, radii.Length).OrderBy(m => radii[m]).ThenBy(m => m).ToArray(); - _radii = meshByRank.Select(m => radii[m]).ToArray(); - Meshes = meshByRank.Select(m => candidates[m]).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 / radii.Length); - _rocks = Enumerable.Range(0, radii.Length).Select(m => rockByRank - .Where((_, rank) => System.Math.Min(rank / run, radii.Length - 1) == m).OrderBy(i => i).ToArray()).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++) @@ -90,8 +105,8 @@ public static class DrawAhead } } - public static BeltDraw DrawListOf(AsteroidBelt belt, PlanetSettings settings, IReadOnlyList meshes, System.Func radius) => - new(Enumerable.Range(0, belt.Data.Asteroids.Length).Select(i => belt.UndamagedSize(i, settings)).ToArray(), meshes, radius); + 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. diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index 741f36218..f63f746bd 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -79,6 +79,8 @@ public class ZoneRenderer : MonoBehaviour // 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; @@ -445,11 +447,8 @@ 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)); _beltTransforms[key] = new float4[beltData.Asteroids.Length]; - var draw = DrawAhead.DrawListOf(Zone.AsteroidBelts[key], Zone.Settings, meshes, m => m.Radius); + 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(); @@ -512,27 +511,12 @@ public class ZoneRenderer : MonoBehaviour Map.Draw(Zone, ActionGameManager.Clock.Lead); } - void Update() + // 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) { - var lead = ActionGameManager.Clock.Lead; - GravityLowering.Lower(Zone, lead, ref _gravityWells); - foreach (var loot in _floatingItems.Values) - { - loot.ViewOrigin = DrawAhead.Position(PerspectiveEntity, lead); - loot.ViewDirection = PerspectiveEntity.Aim; - } - - // if (SlimeRenderer.SpawnPositions.Length != _suns.Length) - // SlimeRenderer.SpawnPositions = new Vector2[_suns.Length]; - // for (var i = 0; i < _suns.Length; i++) - // { - // SlimeRenderer.SpawnPositions[i] = _suns[i].Body.transform.position.Flatland(); - // } - - 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 @@ -558,10 +542,31 @@ public class ZoneRenderer : MonoBehaviour Quaternion.Euler(cos(transform.z) * 100, sin(transform.z) * 100, (float)rock.Rock / transforms.Length * 360), Vector3.one * rock.Scale); } - Graphics.DrawMeshInstanced(draw.Meshes[m].Mesh, 0, draw.Meshes[m].Material, matrices[m]); + DrawInstanced(draw.Meshes[m].Mesh, draw.Meshes[m].Material, matrices[m]); } } } + } + + void Update() + { + var lead = ActionGameManager.Clock.Lead; + GravityLowering.Lower(Zone, lead, ref _gravityWells); + foreach (var loot in _floatingItems.Values) + { + loot.ViewOrigin = DrawAhead.Position(PerspectiveEntity, lead); + loot.ViewDirection = PerspectiveEntity.Aim; + } + + // if (SlimeRenderer.SpawnPositions.Length != _suns.Length) + // SlimeRenderer.SpawnPositions = new Vector2[_suns.Length]; + // for (var i = 0; i < _suns.Length; i++) + // { + // SlimeRenderer.SpawnPositions[i] = _suns[i].Body.transform.position.Flatland(); + // } + + + DrawBelts(lead); foreach (var planet in Planets) { diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index 735ef6cb5..9507d0783 100644 --- a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -406,7 +406,7 @@ public sealed class DrawnPoseTests : IDisposable private record Candidate(string Name, float Radius); private static DrawAhead.BeltDraw List(float[] sizes, params Candidate[] candidates) => - new(sizes, candidates, c => c.Radius); + 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(); @@ -425,7 +425,7 @@ public sealed class DrawnPoseTests : IDisposable 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); + 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()); @@ -490,6 +490,46 @@ public sealed class DrawnPoseTests : IDisposable 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() @@ -497,7 +537,7 @@ public sealed class DrawnPoseTests : IDisposable 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), w.Asteroids.Length); + 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); @@ -523,7 +563,7 @@ public sealed class DrawnPoseTests : IDisposable 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("draw.Meshes[m].Mesh, 0, draw.Meshes[m].Material, matrices[m]", 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);