diff --git a/Assets/Scripts/Editor/RockMeshRadius.cs b/Assets/Scripts/Editor/RockMeshRadius.cs new file mode 100644 index 000000000..cdc7c663f --- /dev/null +++ b/Assets/Scripts/Editor/RockMeshRadius.cs @@ -0,0 +1,62 @@ +using System; +using System.Linq; +using UnityEditor; +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. +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() => Run(fill: true); + + public static void Verify() => Run(fill: false); + + private static void Run(bool fill) + { + try + { + var scene = EditorSceneManager.OpenScene(Scene, OpenSceneMode.Single); + 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."); + var measured = meshes.Select(m => Measure(m.Mesh)).ToArray(); + 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 (fill) + { + 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."); + } + else if (failures.Length > 0) + throw new InvalidOperationException("Stored radius differs from measured: " + string.Join(", ", + failures.Select(f => $"{f.m.Mesh.name} stored {f.m.Radius} measured {measured[f.i]}"))); + var sorted = measured.OrderBy(r => r).ToArray(); + Debug.Log($"[RockMeshRadius] {(fill ? "filled" : "ok")}: {sorted.Length} meshes, radius min {sorted[0]:F3} median {sorted[sorted.Length / 2]:F3} max {sorted[^1]:F3}"); + EditorApplication.Exit(0); + } + catch (Exception exception) + { + Debug.LogException(exception); + EditorApplication.Exit(1); + } + } + } +} 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/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs index 8c25ac060..2aebbf0b5 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,15 +31,32 @@ 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 (ruling asteroids-on-floater-layer): its planar pose at the drawn time, on the floater layer over the field. - // Ruling asteroid-tops-at-layer: the rock's top sits at the layer, so its centre is a drawn radius below it. pose.w is that radius: ZoneRenderer scales the rock by - // Vector3.one * pose.w, and Zone.ChunkRadius is the same number. - public static float ChunkHeight(Zone zone, GameplaySettings settings, float4 pose, float lead) => FloaterHeight(zone, settings, pose.xy, lead) - pose.w; + // 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) + 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). + public static float RockScale(float gameplayRadius, float meshRadius) => gameplayRadius / meshRadius; + + // 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) { - var pose = zone.ChunkPose(chunk.Field, chunk.Index, Time(zone, lead)); - return float3(pose.x, ChunkHeight(zone, settings, pose, lead), pose.y); + 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; } // A ship in a wormhole animation has its pose scripted by the step, not integrated from Velocity and TurnRate, diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index ae2f106b7..9ce749cbe 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -75,6 +75,9 @@ 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 _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. private Dictionary _beltTransforms = new Dictionary(); @@ -383,6 +386,8 @@ public class ZoneRenderer : MonoBehaviour Map.Clear(); _beltMeshes.Clear(); _beltMatrices.Clear(); + _beltMeshOf.Clear(); + _beltSlotOf.Clear(); _beltTransforms.Clear(); _tourPlanets.Clear(); } @@ -447,15 +452,17 @@ 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(); - _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 meshOf = DrawAhead.AssignRockMeshes(Zone.AsteroidBelts[key], Zone.Settings, meshes.Count); + 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(); Map.RegisterBelt(key, _beltTransforms[key]); } @@ -551,23 +558,22 @@ public class ZoneRenderer : MonoBehaviour { var meshes = _beltMeshes[key]; var matrices = _beltMatrices[key]; - var count = transforms.Length / meshes.Length; - for (int i = 0; i < meshes.Length; i++) + var meshOf = _beltMeshOf[key]; + var slotOf = _beltSlotOf[key]; + for (int tx = 0; tx < transforms.Length; tx++) { - 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,DrawAhead.ChunkHeight(Zone,ItemManager.GameplaySettings,transform,lead),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]); + var transform = transforms[tx]; + var i = meshOf[tx]; + matrices[i][slotOf[tx]] = Matrix4x4.TRS(DrawAhead.ChunkAt(Zone, ItemManager.GameplaySettings, transform, lead).ToUnity(), + 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 * DrawAhead.RockScale(transform.w, meshes[i].Radius)); } + + for (int i = 0; i < meshes.Length; i++) + Graphics.DrawMeshInstanced(meshes[i].Mesh, 0, meshes[i].Material, matrices[i]); } } @@ -623,4 +629,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/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index 33b37f44e..73753cfee 100644 --- a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -348,7 +348,7 @@ public sealed class DrawnPoseTests : IDisposable } } - // The reticle (DrawAhead.Chunk) and the renderer's per-rock instance (DrawAhead.ChunkHeight on EvaluateBelt's drawn pose) + // 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] @@ -373,12 +373,102 @@ public sealed class DrawnPoseTests : IDisposable 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.ChunkHeight(w.Zone, settings, drawn[i], lead), 4); + 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); + } + } + + // 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 meshOf = DrawAhead.AssignRockMeshes(belt, w.Zone.Settings, 3); + var biggest = Array.IndexOf(sizes, sizes.Max()); + var smallest = Array.IndexOf(sizes, sizes.Min()); + Assert.Equal(2, meshOf[biggest]); + Assert.Equal(0, meshOf[smallest]); + 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"); + Assert.Equal(new[] { 0, 1, 2 }, meshOf.Distinct().OrderBy(m => m).ToArray()); + } + + [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, 1, 0, 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)); + } + + // 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 before = DrawAhead.AssignRockMeshes(belt, w.Zone.Settings, 3); + 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)); + } + + // 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); + Assert.Contains("DrawAhead.RockScale(", renderer); + Assert.Contains("DrawAhead.AssignRockMeshes(", 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]