From 8412aa0dc3b2aeec0208bebecc5af7fbd25f0318 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 21:44:43 +0200 Subject: [PATCH] DrawAhead.RockMeshing owns the mesh ranking and each rock's scale; a mesh with no radius is refused by name (finding renderer-wiring-unpinned) Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/DrawAhead.cs | 44 ++++++++++------ Assets/Scripts/Zone Display/ZoneRenderer.cs | 17 ++++--- tests/Aetheria.Shared.Tests/DrawnPoseTests.cs | 50 +++++++++++++++---- 3 files changed, 80 insertions(+), 31 deletions(-) diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs index 2aebbf0b5..5ec84c804 100644 --- a/Assets/Scripts/ServerShared/DrawAhead.cs +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -41,24 +41,40 @@ public static class DrawAhead 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). - public static float RockScale(float gameplayRadius, float meshRadius) => gameplayRadius / meshRadius; + // 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."); - // Which mesh each rock of a belt takes (ruling rock-meshes-by-size): rocks ranked by unworn size take meshes ranked by authored size, the - // meshes in ascending radius order. The rocks split into meshCount runs by rank, the last run taking the remainder; equal sizes rank by - // index, so the answer is stable. Unworn, so a mined rock keeps its mesh and only shrinks. - public static int[] AssignRockMeshes(AsteroidBelt belt, PlanetSettings settings, int meshCount) => - AssignRockMeshes(Enumerable.Range(0, belt.Data.Asteroids.Length).Select(i => belt.UndamagedSize(i, settings)).ToArray(), meshCount); - - public static int[] AssignRockMeshes(IReadOnlyList unwornSizes, int meshCount) + // Which mesh each rock of a belt takes, and at what scale (ruling rock-meshes-by-size). meshRadii are the kept meshes' measured radii in + // the renderer's own order; the meshes are ranked by radius here, not by the caller. 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, so the answer is stable. + // Unworn, so a mined rock keeps its mesh and only shrinks. The renderer builds its instance matrices from Mesh and Scale and decides neither. + public sealed class RockMeshing { - var order = Enumerable.Range(0, unwornSizes.Count).OrderBy(i => unwornSizes[i]).ThenBy(i => i).ToArray(); - var run = System.Math.Max(1, unwornSizes.Count / meshCount); - var mesh = new int[unwornSizes.Count]; - for (var rank = 0; rank < order.Length; rank++) mesh[order[rank]] = System.Math.Min(rank / run, meshCount - 1); - return mesh; + private readonly float[] _meshRadii; + + // Per rock: the index into meshRadii (as given) of the mesh it takes. + public int[] Mesh { get; } + + public RockMeshing(IReadOnlyList unwornSizes, IReadOnlyList meshRadii) + { + _meshRadii = meshRadii.ToArray(); + for (var m = 0; m < _meshRadii.Length; m++) + if (!(_meshRadii[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, _meshRadii.Length).OrderBy(m => _meshRadii[m]).ThenBy(m => m).ToArray(); + var rockByRank = Enumerable.Range(0, unwornSizes.Count).OrderBy(i => unwornSizes[i]).ThenBy(i => i).ToArray(); + var run = System.Math.Max(1, unwornSizes.Count / _meshRadii.Length); + Mesh = new int[unwornSizes.Count]; + for (var rank = 0; rank < rockByRank.Length; rank++) Mesh[rockByRank[rank]] = meshByRank[System.Math.Min(rank / run, meshByRank.Length - 1)]; + } + + // The scale that draws the rock at its current gameplay radius on the mesh it was given. + public float Scale(int rock, float gameplayRadius) => RockScale(gameplayRadius, _meshRadii[Mesh[rock]]); } + public static RockMeshing AssignRockMeshes(AsteroidBelt belt, PlanetSettings settings, IReadOnlyList meshRadii) => + new(Enumerable.Range(0, belt.Data.Asteroids.Length).Select(i => belt.UndamagedSize(i, settings)).ToArray(), meshRadii); + // 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/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index 9ce749cbe..c3a10ffa4 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -76,7 +76,7 @@ public class ZoneRenderer : MonoBehaviour private Dictionary _beltMeshes = new Dictionary(); private Dictionary _beltMatrices = new Dictionary(); // Per rock: the mesh it takes and its slot in that mesh's matrix batch (ruling rock-meshes-by-size). - private Dictionary _beltMeshOf = new Dictionary(); + private Dictionary _beltMeshing = new Dictionary(); private Dictionary _beltSlotOf = 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. @@ -386,7 +386,7 @@ public class ZoneRenderer : MonoBehaviour Map.Clear(); _beltMeshes.Clear(); _beltMatrices.Clear(); - _beltMeshOf.Clear(); + _beltMeshing.Clear(); _beltSlotOf.Clear(); _beltTransforms.Clear(); _tourPlanets.Clear(); @@ -452,15 +452,15 @@ public class ZoneRenderer : MonoBehaviour var meshes = AsteroidMeshes.ToList(); while (meshes.Count > Settings.AsteroidMeshCount) meshes.RemoveAt(Random.Range(0, meshes.Count)); - // Ruling rock-meshes-by-size: meshes in ascending measured radius, rocks ranked by unworn size take them in that order. - meshes.Sort((a, b) => a.Radius.CompareTo(b.Radius)); _beltMeshes[key] = meshes.ToArray(); _beltTransforms[key] = new float4[beltData.Asteroids.Length]; - var meshOf = DrawAhead.AssignRockMeshes(Zone.AsteroidBelts[key], Zone.Settings, meshes.Count); + // Ruling rock-meshes-by-size: DrawAhead ranks the meshes by radius and the rocks by size, and refuses a mesh with no radius. + var meshing = DrawAhead.AssignRockMeshes(Zone.AsteroidBelts[key], Zone.Settings, meshes.Select(m => m.Radius).ToArray()); + _beltMeshing[key] = meshing; + var meshOf = meshing.Mesh; var slotOf = new int[meshOf.Length]; var perMesh = new int[meshes.Count]; for (int i = 0; i < meshOf.Length; i++) slotOf[i] = perMesh[meshOf[i]]++; - _beltMeshOf[key] = meshOf; _beltSlotOf[key] = slotOf; _beltMatrices[key] = perMesh.Select(n => new Matrix4x4[n]).ToArray(); @@ -558,7 +558,8 @@ public class ZoneRenderer : MonoBehaviour { var meshes = _beltMeshes[key]; var matrices = _beltMatrices[key]; - var meshOf = _beltMeshOf[key]; + var meshing = _beltMeshing[key]; + var meshOf = meshing.Mesh; var slotOf = _beltSlotOf[key]; for (int tx = 0; tx < transforms.Length; tx++) { @@ -569,7 +570,7 @@ public class ZoneRenderer : MonoBehaviour cos(transform.z + (float)i / meshes.Length) * 100, sin(transform.z + (float)i / meshes.Length) * 100, (float)tx / transforms.Length * 360), - Vector3.one * DrawAhead.RockScale(transform.w, meshes[i].Radius)); + Vector3.one * meshing.Scale(tx, transform.w)); } for (int i = 0; i < meshes.Length; i++) diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index 6b929ca11..7457f9861 100644 --- a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -409,7 +409,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 meshOf = DrawAhead.AssignRockMeshes(belt, w.Zone.Settings, 3); + var meshOf = DrawAhead.AssignRockMeshes(belt, w.Zone.Settings, new[] { 1f, 2f, 3f }).Mesh; var biggest = Array.IndexOf(sizes, sizes.Max()); var smallest = Array.IndexOf(sizes, sizes.Min()); Assert.Equal(2, meshOf[biggest]); @@ -423,10 +423,39 @@ public sealed class DrawnPoseTests : IDisposable [Fact] public void EqualRocksTakeMeshesByIndex() { - Assert.Equal(new[] { 0, 0, 1, 1, 2, 2, 2 }, DrawAhead.AssignRockMeshes(new[] { 5f, 5f, 5f, 5f, 5f, 5f, 5f }, 3)); - Assert.Equal(new[] { 2, 0, 2, 1, 2 }, DrawAhead.AssignRockMeshes(new[] { 9f, 1f, 4f, 2f, 7f }, 3)); - Assert.Equal(new[] { 0, 1, 1 }, DrawAhead.AssignRockMeshes(new[] { 1f, 2f, 3f }, 2)); - Assert.Equal(new[] { 0, 1 }, DrawAhead.AssignRockMeshes(new[] { 1f, 2f }, 5)); + int[] Meshes(float[] sizes, params float[] radii) => new DrawAhead.RockMeshing(sizes, radii).Mesh; + 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, 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)); + } + + // The renderer hands the kept meshes in the order it holds them: the ranking is DrawAhead's. The smallest rock takes the smallest radius wherever + // it stands in the list, and the scale it is drawn at is its gameplay radius over that mesh's radius (mutants: no ranking, descending, radius 1, + // the first mesh's radius, the wrong mesh count). + [Fact] + public void MeshesAreRankedByRadiusWhateverOrderTheyAreGiven() + { + var radii = new[] { 2.4f, 1.0f, 1.5f }; + var meshing = new DrawAhead.RockMeshing(new[] { 9f, 1f, 4f, 2f, 7f, 5f }, radii); + Assert.Equal(new[] { 0, 1, 2, 1, 0, 2 }, meshing.Mesh); + Assert.Equal(3f / 2.4f, meshing.Scale(0, 3f), 5); + Assert.Equal(3f / 1.0f, meshing.Scale(1, 3f), 5); + Assert.Equal(1.2f / 1.5f, meshing.Scale(2, 1.2f), 5); + } + + // 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(() => new DrawAhead.RockMeshing(new[] { 1f, 2f }, new[] { 1.5f, radius })); + Assert.Contains("InstancedMesh.Radius", refused.Message); + Assert.Contains("#1", refused.Message); + Assert.Contains("InstancedMesh.Radius", Assert.Throws(() => DrawAhead.RockScale(1f, radius)).Message); } // A mined rock keeps its mesh and only shrinks: the assignment reads the unworn size. @@ -435,13 +464,13 @@ public sealed class DrawnPoseTests : IDisposable { var w = Build(); var belt = w.Zone.AsteroidBelts[w.Belt]; - var before = DrawAhead.AssignRockMeshes(belt, w.Zone.Settings, 3); + var before = DrawAhead.AssignRockMeshes(belt, w.Zone.Settings, new[] { 1f, 2f, 3f }).Mesh; 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, DrawAhead.AssignRockMeshes(belt, w.Zone.Settings, 3)); + Assert.Equal(before, DrawAhead.AssignRockMeshes(belt, w.Zone.Settings, new[] { 1f, 2f, 3f }).Mesh); } // 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 @@ -455,8 +484,11 @@ public sealed class DrawnPoseTests : IDisposable var manager = Source("Assets/Scripts/Gameplay/ActionGameManager.cs"); var drawAhead = Source("Assets/Scripts/ServerShared/DrawAhead.cs"); Assert.Contains("DrawAhead.ChunkAt(", renderer); - Assert.Contains("DrawAhead.RockScale(", renderer); - Assert.Contains("DrawAhead.AssignRockMeshes(", renderer); + // What the renderer still does itself: hand DrawAhead the kept meshes' own radii and draw each rock on the mesh and scale it returns. + Assert.Contains("DrawAhead.AssignRockMeshes(Zone.AsteroidBelts[key], Zone.Settings, meshes.Select(m => m.Radius).ToArray())", renderer); + Assert.Contains("meshing.Scale(tx, transform.w)", renderer); + Assert.DoesNotContain("RockScale(", renderer); + Assert.DoesNotContain(".Sort(", renderer); Assert.DoesNotContain("FloaterHeight(", renderer); Assert.DoesNotContain("Vector3.one * transform.w", renderer); Assert.Contains("DrawAhead.Chunk(", manager);