From 2055e18d3ed035f5c673044a66f1473173904eee Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 13:48:29 +0200 Subject: [PATCH 01/15] Delete AsteroidVerticalOffset: setting, asset line, shader global, and the asteroid shader's field read Rock height leaves the shader; DrawAhead owns it (next commit). Does not build alone: ZoneRenderer and ActionGameManager still reference the deleted names. Co-Authored-By: Claude Opus 5.5 --- Assets/Resources/Settings.asset | 4 ++-- Assets/Scripts/ServerShared/Settings.cs | 1 - Assets/Shaders/Asteroid.shader | 5 ----- 3 files changed, 2 insertions(+), 8 deletions(-) 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/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/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 From 71fa9411c67953e201698c0c858cff1be83ca9b1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 13:48:34 +0200 Subject: [PATCH 02/15] Belt rocks and their reticle are drawn on the floater layer over the field by DrawAhead DrawAhead.Chunk/ChunkHeight (ruling asteroids-on-floater-layer); ZoneRenderer and the chunk reticle both read it; ChunkWorldPosition and the renderer's global are gone. Fixes the reticle sitting off the rock by |field|. Test: AChunkIsDrawnOnTheFloaterLayerOverTheField. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 11 +------ Assets/Scripts/ServerShared/DrawAhead.cs | 10 +++++++ Assets/Scripts/Zone Display/ZoneRenderer.cs | 3 +- tests/Aetheria.Shared.Tests/DrawnPoseTests.cs | 29 ++++++++++++++++++- 4 files changed, 40 insertions(+), 13 deletions(-) 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..3f2c870bc 100644 --- a/Assets/Scripts/ServerShared/DrawAhead.cs +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -29,6 +29,16 @@ 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. + // Open question asteroid-clearance: the rock centre sits at the layer. ChunkHeight is the one line that changes if its top should (pose.w is the radius). + public static float ChunkHeight(Zone zone, GameplaySettings settings, float4 pose, float lead) => FloaterHeight(zone, settings, pose.xy, lead); + + public static float3 Chunk(Zone zone, GameplaySettings settings, ChunkId chunk, float lead) + { + var pose = zone.ChunkPose(chunk.Field, chunk.Index, Time(zone, lead)); + return float3(pose.x, ChunkHeight(zone, settings, pose, lead), pose.y); + } + // 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 3c584c956..ae2f106b7 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -533,7 +533,6 @@ 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); @@ -559,7 +558,7 @@ public class ZoneRenderer : MonoBehaviour { var tx = t + i * count; var transform = transforms[tx]; - matrices[i][t] = Matrix4x4.TRS(new Vector3(transform.x,0,transform.y), + 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, diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index acc7f8f72..8ed638905 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,32 @@ public sealed class DrawnPoseTests : IDisposable } } + // The reticle (DrawAhead.Chunk) and the renderer's per-rock instance (DrawAhead.ChunkHeight on EvaluateBelt's drawn pose) + // are one height: the field at the rock's planar position at the drawn time, plus the floater layer. Ruling asteroids-on-floater-layer. + [Fact] + public void AChunkIsDrawnOnTheFloaterLayerOverTheField() + { + 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"); + Assert.Equal(field + FloaterLayerHeight, reticle.y, 3); + Assert.Equal(reticle.y, DrawAhead.ChunkHeight(w.Zone, settings, drawn[i], lead), 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"); + } + // 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] From 72ed5cc15f9931efa597ac5c7a746a1d462528f8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 18:34:20 +0200 Subject: [PATCH 03/15] ChunkHeight puts a rock's top on the floater layer: its centre is a drawn radius below (ruling asteroid-tops-at-layer) DrawAhead.ChunkHeight subtracts pose.w, the radius the renderer scales the rock by; reticle and renderer both read it. DrawnPoseTests pins top == layer at radii 2..9. Builds. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/DrawAhead.cs | 5 +++-- tests/Aetheria.Shared.Tests/DrawnPoseTests.cs | 11 ++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs index 3f2c870bc..159e51c44 100644 --- a/Assets/Scripts/ServerShared/DrawAhead.cs +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -30,8 +30,9 @@ public static class DrawAhead 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. - // Open question asteroid-clearance: the rock centre sits at the layer. ChunkHeight is the one line that changes if its top should (pose.w is the radius). - public static float ChunkHeight(Zone zone, GameplaySettings settings, float4 pose, float lead) => FloaterHeight(zone, settings, pose.xy, lead); + // 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: + // the renderer scales the unit rock mesh by it. + public static float ChunkHeight(Zone zone, GameplaySettings settings, float4 pose, float lead) => FloaterHeight(zone, settings, pose.xy, lead) - pose.w; public static float3 Chunk(Zone zone, GameplaySettings settings, ChunkId chunk, float lead) { diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index 8ed638905..33b37f44e 100644 --- a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -349,9 +349,10 @@ public sealed class DrawnPoseTests : IDisposable } // The reticle (DrawAhead.Chunk) and the renderer's per-rock instance (DrawAhead.ChunkHeight on EvaluateBelt's drawn pose) - // are one height: the field at the rock's planar position at the drawn time, plus the floater layer. Ruling asteroids-on-floater-layer. + // 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 AChunkIsDrawnOnTheFloaterLayerOverTheField() + public void AChunkIsDrawnWithItsTopOnTheFloaterLayerOverTheField() { var w = Build(); Step(w, 1f / 60f, 301); @@ -367,7 +368,11 @@ public sealed class DrawnPoseTests : IDisposable 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"); - Assert.Equal(field + FloaterLayerHeight, reticle.y, 3); + 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.ChunkHeight(w.Zone, settings, drawn[i], lead), 4); if (abs(field) > 1f) deep++; } From 8074d724a5d059c8e4be60ce5f31d9bfeeeaae6a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 18:38:58 +0200 Subject: [PATCH 04/15] ChunkHeight comment names what the renderer does with pose.w Comment only. Builds. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/DrawAhead.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs index 159e51c44..8c25ac060 100644 --- a/Assets/Scripts/ServerShared/DrawAhead.cs +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -30,8 +30,8 @@ public static class DrawAhead 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: - // the renderer scales the unit rock mesh by it. + // 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; public static float3 Chunk(Zone zone, GameplaySettings settings, ChunkId chunk, float lead) From d592ca3223f366e28dce0c80b9dd9bcd2ee067bc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 20:44:13 +0200 Subject: [PATCH 05/15] Rocks take meshes by size and are scaled by gameplay radius over mesh radius (ruling rock-meshes-by-size) DrawAhead.AssignRockMeshes ranks rocks by unworn size onto meshes in ascending radius; RockScale divides the gameplay radius by the mesh's measured radius; ChunkAt is the one rock position for the renderer and the reticle (ChunkHeight folded into it). InstancedMesh.Radius is filled and verified by RockMeshRadius. Tests pin the assignment, the scale and that the renderer and reticle call DrawAhead (finding owner-not-pinned). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Editor/RockMeshRadius.cs | 62 ++++++++++++ Assets/Scripts/Editor/RockMeshRadius.cs.meta | 2 + Assets/Scripts/ServerShared/DrawAhead.cs | 33 +++++-- Assets/Scripts/Zone Display/ZoneRenderer.cs | 53 ++++++----- tests/Aetheria.Shared.Tests/DrawnPoseTests.cs | 94 ++++++++++++++++++- 5 files changed, 213 insertions(+), 31 deletions(-) create mode 100644 Assets/Scripts/Editor/RockMeshRadius.cs create mode 100644 Assets/Scripts/Editor/RockMeshRadius.cs.meta 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] From 64d5e4534d7740338bbc80b6e012697cac2e30fa Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 20:44:28 +0200 Subject: [PATCH 06/15] ARPG.unity: measured rock mesh radii (RockMeshRadius.Fill), 30 meshes, 0.958 to 2.430 Co-Authored-By: Claude Opus 5.5 --- Assets/Scenes/ARPG.unity | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) 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} From ed6463dca08ce280383787c0b224503fb8214ce6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 20:55:09 +0200 Subject: [PATCH 07/15] DrawnPoseTests: expected mesh runs for five distinct rocks over three meshes (the last run takes the remainder) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/DrawnPoseTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index 73753cfee..6b929ca11 100644 --- a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -424,7 +424,7 @@ public sealed class DrawnPoseTests : IDisposable 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[] { 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)); } From 8412aa0dc3b2aeec0208bebecc5af7fbd25f0318 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 21:44:43 +0200 Subject: [PATCH 08/15] 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); From 2d61afa40fed9d5ddd250622e03a890402b8149c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 21:44:46 +0200 Subject: [PATCH 09/15] RockMeshRadiusBuildGate: a player build fails when an AsteroidMeshes radius is unfilled or stale (finding verify-not-gated) Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Editor/RockMeshRadius.cs | 98 +++++++++++++++++++------ 1 file changed, 74 insertions(+), 24 deletions(-) diff --git a/Assets/Scripts/Editor/RockMeshRadius.cs b/Assets/Scripts/Editor/RockMeshRadius.cs index cdc7c663f..cefb3b1b1 100644 --- a/Assets/Scripts/Editor/RockMeshRadius.cs +++ b/Assets/Scripts/Editor/RockMeshRadius.cs @@ -1,6 +1,8 @@ using System; using System.Linq; using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; using UnityEditor.SceneManagement; using UnityEngine; @@ -12,6 +14,9 @@ using UnityEngine; // 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 @@ -21,35 +26,68 @@ namespace Aetheria.EditorTools public static float Measure(Mesh mesh) => Mathf.Sqrt(mesh.vertices.Max(v => v.sqrMagnitude)); - public static void Fill() => Run(fill: true); + 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() => Run(fill: false); + public static void Verify() => Batch(() => Check(OpenSceneMode.Single)); - private static void Run(bool fill) + // 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 (run RockMeshRadius.Fill): " + string.Join(", ", + failures.Select(f => $"{f.m.Mesh.name} stored {f.m.Radius} measured {measured[f.i]}"))); + 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 { - 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}"); + Debug.Log($"[RockMeshRadius] {run()}"); EditorApplication.Exit(0); } catch (Exception exception) @@ -59,4 +97,16 @@ namespace Aetheria.EditorTools } } } + + // 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); } + } + } } From c576e0ac187e4a43a0bc1593ec08c52d63c32034 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 22:07:57 +0200 Subject: [PATCH 10/15] DrawnPoseTests: four rocks over two meshes split two and two (the mesh count decides the run length) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/DrawnPoseTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index 7457f9861..f5de7dc14 100644 --- a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -427,6 +427,7 @@ public sealed class DrawnPoseTests : IDisposable 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)); } From c5ad4676f73aa544bb88ce6a3ce1fc2c37fbe638 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 23:12:33 +0200 Subject: [PATCH 11/15] DrawAhead.BeltDraw owns the whole per-rock draw list; ZoneRenderer iterates it with no mesh choice, sort or index arithmetic Replaces RockMeshing/AssignRockMeshes and the renderer's slot bucketing (findings scale-lookup-unpinned, renderer-index-wiring-unpinned). The build gate message names the mesh and the field, not the radii. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Editor/RockMeshRadius.cs | 4 +- Assets/Scripts/ServerShared/DrawAhead.cs | 70 ++++++++------ Assets/Scripts/Zone Display/ZoneRenderer.cs | 51 ++++------- tests/Aetheria.Shared.Tests/DrawnPoseTests.cs | 91 +++++++++++++------ 4 files changed, 128 insertions(+), 88 deletions(-) diff --git a/Assets/Scripts/Editor/RockMeshRadius.cs b/Assets/Scripts/Editor/RockMeshRadius.cs index cefb3b1b1..971284924 100644 --- a/Assets/Scripts/Editor/RockMeshRadius.cs +++ b/Assets/Scripts/Editor/RockMeshRadius.cs @@ -56,8 +56,8 @@ namespace Aetheria.EditorTools 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 (run RockMeshRadius.Fill): " + string.Join(", ", - failures.Select(f => $"{f.m.Mesh.name} stored {f.m.Radius} measured {measured[f.i]}"))); + 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 diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs index 5ec84c804..7115ccf30 100644 --- a/Assets/Scripts/ServerShared/DrawAhead.cs +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -45,35 +45,53 @@ public static class DrawAhead 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, 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 + // 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 { - 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 readonly int Rock; + public readonly float Scale; + public RockDraw(int rock, float scale) { Rock = rock; Scale = scale; } } - 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); + // 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. + 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) + { + 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(); + 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(); + ByMesh = _rocks.Select(r => r.Select(rock => new RockDraw(rock, float.NaN)).ToArray()).ToArray(); + } + + 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) => + new(Enumerable.Range(0, belt.Data.Asteroids.Length).Select(i => belt.UndamagedSize(i, settings)).ToArray(), meshes, radius); // 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 c3a10ffa4..741f36218 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -73,11 +73,9 @@ 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(); - // Per rock: the mesh it takes and its slot in that mesh's matrix batch (ruling rock-meshes-by-size). - 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. private Dictionary _beltTransforms = new Dictionary(); @@ -384,10 +382,8 @@ public class ZoneRenderer : MonoBehaviour Planets.Clear(); Map.Clear(); - _beltMeshes.Clear(); + _beltDraw.Clear(); _beltMatrices.Clear(); - _beltMeshing.Clear(); - _beltSlotOf.Clear(); _beltTransforms.Clear(); _tourPlanets.Clear(); } @@ -452,17 +448,10 @@ public class ZoneRenderer : MonoBehaviour var meshes = AsteroidMeshes.ToList(); while (meshes.Count > Settings.AsteroidMeshCount) meshes.RemoveAt(Random.Range(0, meshes.Count)); - _beltMeshes[key] = meshes.ToArray(); _beltTransforms[key] = new float4[beltData.Asteroids.Length]; - // 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]]++; - _beltSlotOf[key] = slotOf; - _beltMatrices[key] = perMesh.Select(n => new Matrix4x4[n]).ToArray(); + var draw = DrawAhead.DrawListOf(Zone.AsteroidBelts[key], Zone.Settings, meshes, m => m.Radius); + _beltDraw[key] = draw; + _beltMatrices[key] = draw.ByMesh.Select(rocks => new Matrix4x4[rocks.Length]).ToArray(); Map.RegisterBelt(key, _beltTransforms[key]); } @@ -556,25 +545,21 @@ public class ZoneRenderer : MonoBehaviour new Vector3(orbitPosition.x,height,orbitPosition.y), new Vector3(belt.Radius * 2,100,belt.Radius * 2)))) { - var meshes = _beltMeshes[key]; + var draw = _beltDraw[key]; var matrices = _beltMatrices[key]; - var meshing = _beltMeshing[key]; - var meshOf = meshing.Mesh; - var slotOf = _beltSlotOf[key]; - for (int tx = 0; tx < transforms.Length; tx++) + var byMesh = draw.Draw(transforms); + for (int m = 0; m < byMesh.Length; m++) { - 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 * meshing.Scale(tx, transform.w)); + 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); + } + Graphics.DrawMeshInstanced(draw.Meshes[m].Mesh, 0, draw.Meshes[m].Material, matrices[m]); } - - for (int i = 0; i < meshes.Length; i++) - Graphics.DrawMeshInstanced(meshes[i].Mesh, 0, meshes[i].Material, matrices[i]); } } diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index f5de7dc14..e4c2537dd 100644 --- a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -402,6 +402,22 @@ public sealed class DrawnPoseTests : IDisposable } } + // 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); + + 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() @@ -409,21 +425,50 @@ 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, new[] { 1f, 2f, 3f }).Mesh; - var biggest = Array.IndexOf(sizes, sizes.Max()); - var smallest = Array.IndexOf(sizes, sizes.Min()); - Assert.Equal(2, meshOf[biggest]); - Assert.Equal(0, meshOf[smallest]); + 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 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"); - Assert.Equal(new[] { 0, 1, 2 }, meshOf.Distinct().OrderBy(m => m).ToArray()); + } + + // 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) => new DrawAhead.RockMeshing(sizes, radii).Mesh; + 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)); @@ -432,20 +477,6 @@ public sealed class DrawnPoseTests : IDisposable 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)] @@ -453,7 +484,7 @@ public sealed class DrawnPoseTests : IDisposable [InlineData(float.NaN)] public void AMeshWithoutARadiusIsRefusedByName(float radius) { - var refused = Assert.Throws(() => new DrawAhead.RockMeshing(new[] { 1f, 2f }, new[] { 1.5f, 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); @@ -465,13 +496,15 @@ public sealed class DrawnPoseTests : IDisposable { var w = Build(); var belt = w.Zone.AsteroidBelts[w.Belt]; - var before = DrawAhead.AssignRockMeshes(belt, w.Zone.Settings, new[] { 1f, 2f, 3f }).Mesh; + 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); + 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, DrawAhead.AssignRockMeshes(belt, w.Zone.Settings, new[] { 1f, 2f, 3f }).Mesh); + 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 @@ -485,11 +518,15 @@ 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); - // 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); + // The renderer holds no mesh choice, sort or index arithmetic: it iterates DrawAhead's list. + Assert.Contains("DrawAhead.DrawListOf(", renderer); + Assert.Contains("draw.Draw(transforms)", 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); From f5f0634b1488a688bb1d5437284e85aa2f0147a5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 23:16:32 +0200 Subject: [PATCH 12/15] DrawnPoseTests: the renderer's remaining lines are pinned whole (the draw call, the pose, the scale, the slot) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/DrawnPoseTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index e4c2537dd..735ef6cb5 100644 --- a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -520,7 +520,12 @@ public sealed class DrawnPoseTests : IDisposable 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.Contains("draw.Draw(transforms)", 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("draw.Meshes[m].Mesh, 0, 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); From 72e2383ea446c1cdc6632a95a6b592d05288d79e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 00:23:47 +0200 Subject: [PATCH 13/15] 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); From b320a8cf6dfb29a25b58899b6bc9a87b2853d160 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 00:29:04 +0200 Subject: [PATCH 14/15] BeltFloaterLayerVerify: two belts loaded through ZoneRenderer with ARPG.unity's meshes; every drawn rock's reach and top measured from the instanced matrices and the mesh's vertices Co-Authored-By: Claude Opus 5.5 --- .../Scripts/Editor/BeltFloaterLayerVerify.cs | 265 ++++++++++++++++++ .../Editor/BeltFloaterLayerVerify.cs.meta | 2 + 2 files changed, 267 insertions(+) create mode 100644 Assets/Scripts/Editor/BeltFloaterLayerVerify.cs create mode 100644 Assets/Scripts/Editor/BeltFloaterLayerVerify.cs.meta diff --git a/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs b/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs new file mode 100644 index 000000000..5e5313730 --- /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(30, i => (i * 11 % 30) / 29f, 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 From 01d3058d2174ee39f2fa9fc032a370c81e89d42b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 00:34:50 +0200 Subject: [PATCH 15/15] BeltFloaterLayerVerify: both belts the same length, so a draw list built from the other belt is measured as wrong sizes rather than an index fault Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Editor/BeltFloaterLayerVerify.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs b/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs index 5e5313730..43153b267 100644 --- a/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs +++ b/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs @@ -137,7 +137,7 @@ namespace Aetheria.EditorTools 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(30, i => (i * 11 % 30) / 29f, 60f) }).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));