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:
parent
8074d724a5
commit
d592ca3223
5 changed files with 212 additions and 30 deletions
62
Assets/Scripts/Editor/RockMeshRadius.cs
Normal file
62
Assets/Scripts/Editor/RockMeshRadius.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Editor/RockMeshRadius.cs.meta
Normal file
2
Assets/Scripts/Editor/RockMeshRadius.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9ea93f3d164825c4b99a0d07f24d5cf6
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue