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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-17 20:44:13 +02:00
commit d592ca3223
5 changed files with 212 additions and 30 deletions

View file

@ -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 <project> -executeMethod Aetheria.EditorTools.RockMeshRadius.Fill -logFile <log>
// Unity -batchmode -nographics -projectPath <project> -executeMethod Aetheria.EditorTools.RockMeshRadius.Verify -logFile <log>
//
// 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<ZoneRenderer>(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);
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9ea93f3d164825c4b99a0d07f24d5cf6

View file

@ -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<float> 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,

View file

@ -75,6 +75,9 @@ public class ZoneRenderer : MonoBehaviour
private Dictionary<CultRecordKey, InstancedMesh[]> _beltMeshes = new Dictionary<CultRecordKey, InstancedMesh[]>();
private Dictionary<CultRecordKey, Matrix4x4[][]> _beltMatrices = new Dictionary<CultRecordKey, Matrix4x4[][]>();
// Per rock: the mesh it takes and its slot in that mesh's matrix batch (ruling rock-meshes-by-size).
private Dictionary<CultRecordKey, int[]> _beltMeshOf = new Dictionary<CultRecordKey, int[]>();
private Dictionary<CultRecordKey, int[]> _beltSlotOf = new Dictionary<CultRecordKey, int[]>();
// 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<CultRecordKey, float4[]> _beltTransforms = new Dictionary<CultRecordKey, float4[]>();
@ -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;
}