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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-18 00:23:47 +02:00
commit 72e2383ea4
3 changed files with 103 additions and 43 deletions

View file

@ -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<T>
{
private readonly float[] _radii;
@ -66,21 +68,34 @@ public static class DrawAhead
public T[] Meshes { get; }
public RockDraw[][] ByMesh { get; }
public BeltDraw(IReadOnlyList<float> unwornSizes, IReadOnlyList<T> candidates, System.Func<T, float> radius)
public BeltDraw(IReadOnlyList<float> unwornSizes, IReadOnlyList<T> candidates, System.Func<T, float> 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<float4> poses)
{
for (var m = 0; m < _rocks.Length; m++)
@ -90,8 +105,8 @@ public static class DrawAhead
}
}
public static BeltDraw<T> DrawListOf<T>(AsteroidBelt belt, PlanetSettings settings, IReadOnlyList<T> meshes, System.Func<T, float> radius) =>
new(Enumerable.Range(0, belt.Data.Asteroids.Length).Select(i => belt.UndamagedSize(i, settings)).ToArray(), meshes, radius);
public static BeltDraw<T> DrawListOf<T>(AsteroidBelt belt, PlanetSettings settings, IReadOnlyList<T> meshes, System.Func<T, float> 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.

View file

@ -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<CultRecordKey, float4[]> _beltTransforms = new Dictionary<CultRecordKey, float4[]>();
// 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<Mesh, Material, Matrix4x4[]> 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)
{

View file

@ -406,7 +406,7 @@ public sealed class DrawnPoseTests : IDisposable
private record Candidate(string Name, float Radius);
private static DrawAhead.BeltDraw<Candidate> 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<ArgumentException>(() => 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<ArgumentException>(() => new DrawAhead.BeltDraw<Candidate>(new[] { 1f }, new Candidate[0], c => c.Radius, 5, "belt")).Message);
foreach (var keep in new[] { 0, -1 })
Assert.Contains("AsteroidMeshCount", Assert.Throws<ArgumentException>(() => new DrawAhead.BeltDraw<Candidate>(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<Candidate>(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);