Well law fix batch: zone node for several massive roots, belt extent, series from tau 0.4, Unity parity on the multi-root fixture

One zone node at the massive roots' centre of mass owns the only global term (SubzoneReach is the one place the subzone shape is decided: isolated-subzones). WellNode gains Extent and Anchor; a belt orbit's extent is its outermost asteroid. GravityField.SeriesFrom = 0.4 is emitted into the shader template and Gravity.cginc. Tests: the spec's WellLawTests; the old seam test is replaced by TheBandPotentialIsExactInSinglePrecision. GravityFieldParityVerify runs the multi-root fixture (second sun, moon inside the planet's Roche limit, far window): PASS, largest drawn tau 1.228, max error 0.01028 u. The dotnet suite has not been run at this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-16 21:14:58 +02:00
commit 250b61be2c
9 changed files with 412 additions and 60 deletions

View file

@ -5,19 +5,26 @@ using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
/// <summary>An orbit as the well tree reads it: where it hangs on the tree and the mass of the well-bearing body on it (0 for none).</summary>
/// <summary>
/// An orbit as the well tree reads it: where it hangs on the tree and the mass of the well-bearing body on it (0 for none).
/// Extent is the outermost distance from the parent of anything riding this orbit's rail (a belt orbit: its outermost
/// asteroid; any other orbit: its Distance). Anchor is a parentless orbit's stationary position, ignored with a parent.
/// </summary>
public readonly struct WellNode
{
public readonly CultRecordKey Key, Parent;
public readonly float Distance, Phase, Mass;
public readonly float Distance, Phase, Mass, Extent;
public readonly float2 Anchor;
public WellNode(CultRecordKey key, CultRecordKey parent, float distance, float phase, float mass)
public WellNode(CultRecordKey key, CultRecordKey parent, float distance, float phase, float mass, float extent, float2 anchor)
{
Key = key;
Parent = parent;
Distance = distance;
Phase = phase;
Mass = mass;
Extent = extent;
Anchor = anchor;
}
}
@ -28,9 +35,12 @@ public readonly struct WellNode
/// Every node F (a body, or a bodyless node with its ring group standing in for the body) contributes at its own position
/// c(m_F) U(eps(m_F), L_F, B_F) + c(M_F) [ U(eps(M_F), max(B_F, Gamma Lam_F), Lam_F) - U(eps(M_F), L_F, B_F) ]
/// m_F is the body's mass, M_F the family's, c and eps the WellStrength and RocheLimit curves. L_F is the outermost
/// child's distance: inside it the node pulls exactly as its body alone. B_F, the support, bounds every descendant term:
/// child's extent: inside it the node pulls exactly as its body alone. B_F, the support, bounds every descendant term:
/// beyond it the family pulls exactly as one body of mass M_F, out to max(B_F, Gamma Lam_F), and is gone at its reach
/// Lam_F. A root's reach is infinite, so the zone's far field is exactly c(M_zone) however the generator split it.
/// Lam_F. A zone's sole massive root has infinite reach, so its far field is exactly c(M_zone). A zone of several massive
/// roots (subzones) has one zone node, which is not an orbit: at the roots' centre of mass it owns the only global term,
/// c(M_zone) U_global less c(M_zone) U(eps, A, Beta A), nothing inside A and the whole zone as one body beyond Beta A;
/// each root is bounded to its own subzone (SubzoneReach).
/// A body with no massive descendants is one term c(m) U(eps(m), Gamma Lam, Lam). Beyond a node's reach its whole
/// subtree contributes exactly nothing, so Force and Potential skip it: the walk is exact, not an approximation.
/// </summary>
@ -62,15 +72,22 @@ public sealed class WellTree
private int[] _roots;
private int[][] _ring;
private int[] _termStart, _termCount;
private float[] _distance, _phase, _reach, _support;
private float[] _distance, _phase, _reach, _support, _extent;
private float2[] _anchor;
private int[] _massiveRoots;
private float _packRadius;
private int _zoneStart, _zoneCount;
private Term[] _terms;
/// <summary>Every term of the zone, grouped by node: what the lowering uploads and the shader sums.</summary>
/// <summary>Where the zone node's terms sit (the family-mass-weighted mean of the massive roots' anchors); meaningful when it has terms.</summary>
public float2 Centre { get; private set; }
/// <summary>Every term of the zone, grouped by node, the zone node's last (its Node is unset: it sits at Centre): what the lowering uploads and the shader sums.</summary>
public IReadOnlyList<Term> Terms => _terms;
private WellTree() { }
/// <summary>A node's reach in its parent's field (+inf for a root); 0 for an orbit that carries no mass.</summary>
/// <summary>A node's reach in its parent's field (+inf for a zone's sole massive root; a subzone's radius for a root of several); 0 for an orbit that carries no mass.</summary>
public float Reach(CultRecordKey node) => _reach[_index[node]];
/// <summary>A node's support B: every term of its descendants lies inside it, so beyond it the family is one body.</summary>
@ -102,8 +119,13 @@ public sealed class WellTree
tree._key = new CultRecordKey[n];
tree._distance = new float[n];
tree._phase = new float[n];
tree._extent = new float[n];
tree._anchor = new float2[n];
tree._packRadius = packRadius;
for (var i = 0; i < n; i++)
{
tree._extent[i] = nodes[i].Extent;
tree._anchor[i] = nodes[i].Anchor;
tree._index[nodes[i].Key] = i;
tree._key[i] = nodes[i].Key;
tree._distance[i] = nodes[i].Distance;
@ -135,6 +157,20 @@ public sealed class WellTree
foreach (var i in deepestFirst)
if (parent[i] >= 0) family[parent[i]] += family[i];
// Massive roots are the zone's subzones; with two or more, their centre of mass is the zone node's position.
var massive = new List<int>();
foreach (var r in roots)
if (family[r] > 0) massive.Add(r);
tree._massiveRoots = massive.ToArray();
var centre = float2.zero;
var total = 0f;
foreach (var r in massive)
{
centre += tree._anchor[r] * family[r];
total += family[r];
}
tree.Centre = total > 0f ? centre / total : float2.zero;
tree._kids = new int[n][];
var ring = tree._ring = new int[n][];
for (var i = 0; i < n; i++)
@ -175,7 +211,7 @@ public sealed class WellTree
}
}
// Children before parents: a family's support needs its children's reaches. lower = L_F, the outermost child's distance.
// Children before parents: a family's support needs its children's reaches. lower = L_F, the outermost child's extent.
private static void BuildReaches(WellTree t, int[] parent, float[] body, float[] family, int[][] ring, int[] order,
out float[] lower, out RingGroup[] group)
{
@ -188,9 +224,9 @@ public sealed class WellTree
foreach (var i in order)
{
var low = 0f;
foreach (var k in t._kids[i]) low = Math.Max(low, d[k]);
foreach (var k in t._kids[i]) low = Math.Max(low, t._extent[k]);
var support = GravityField.Beta * low;
foreach (var k in t._kids[i]) support = Math.Max(support, d[k] + t._reach[k]);
foreach (var k in t._kids[i]) support = Math.Max(support, t._extent[k] + t._reach[k]);
if (family[i] > 0 && body[i] <= 0)
{
// Bodyless: its ring group stands in for a body (it has massive children, or it would carry no mass).
@ -213,7 +249,7 @@ public sealed class WellTree
var floor = GravityField.Beta * support;
var p = parent[i];
float reach;
if (p < 0) reach = float.PositiveInfinity;
if (p < 0) reach = t.SubzoneReach(i, floor);
else if (body[p] <= 0 && ring[p] != null && ring[p].Length >= 2 && Array.IndexOf(ring[p], i) >= 0)
reach = Math.Max(GravityField.RingReach * d[i], floor);
else reach = Math.Max(GravityField.Reach * d[i], floor);
@ -221,6 +257,19 @@ public sealed class WellTree
}
}
// A root's reach. The one place the subzone shape is decided (question multi-root-zone-shape): a sole massive root is the
// whole zone (+inf, so its global term is the far field); with several, isolated-subzones bounds each root to Reach x the
// distance to the nearest other massive root, or its floor (Beta x support) if more. The superposed-inside option is
// this one return: Beta x max(pack radius, max over massive roots of |anchor - Centre| + support).
private float SubzoneReach(int root, float floor)
{
if (_massiveRoots.Length < 2) return float.PositiveInfinity;
var nearest = float.PositiveInfinity;
foreach (var other in _massiveRoots)
if (other != root) nearest = Math.Min(nearest, length(_anchor[other] - _anchor[root]));
return Math.Max(GravityField.Reach * nearest, floor);
}
private void EmitTerms(int[] parent, float[] body, float[] family, int[][] ring, float[] lower, RingGroup[] group,
PlanetSettings settings, float packRadius)
{
@ -259,14 +308,32 @@ public sealed class WellTree
Add(-cf, ef, lower[i], _support[i]);
_termCount[i] = terms.Count - _termStart[i];
}
_zoneStart = terms.Count;
if (_massiveRoots.Length >= 2)
{
var total = 0f;
var span = 0f;
foreach (var r in _massiveRoots)
{
total += family[r];
span = Math.Max(span, length(_anchor[r] - Centre) + _reach[r]);
}
var (c, eps) = Curves(settings, total);
terms.Add(new Term(default, c, eps, float.PositiveInfinity, float.PositiveInfinity,
c * GravityField.Potential(eps, float.PositiveInfinity, float.PositiveInfinity, packRadius)));
if (span > 0f) terms.Add(new Term(default, -c, eps, span, GravityField.Beta * span, 0f));
}
_zoneCount = terms.Count - _zoneStart;
_terms = terms.ToArray();
}
// One node's own terms at distance r from it, toward its centre.
private float PullOf(int node, float r)
private float PullOf(int node, float r) => PullOf(_termStart[node], _termCount[node], r);
private float PullOf(int start, int count, float r)
{
var pull = 0f;
for (var t = _termStart[node]; t < _termStart[node] + _termCount[node]; t++)
for (var t = start; t < start + count; t++)
{
var term = _terms[t];
pull += term.Strength * GravityField.Pull(term.Softening, term.Inner, term.Outer, r);
@ -281,6 +348,9 @@ public sealed class WellTree
public float2 Force(float2 p, Func<CultRecordKey, double, float2> positionAt, double time)
{
var force = float2.zero;
var toZone = Centre - p;
var rz = length(toZone);
if (_zoneCount > 0 && rz > 0f) force += toZone / rz * PullOf(_zoneStart, _zoneCount, rz);
foreach (var root in _roots) ForceFrom(root, p, positionAt, time, ref force);
return force;
}
@ -299,6 +369,9 @@ public sealed class WellTree
public float Potential(float2 p, Func<CultRecordKey, double, float2> positionAt, double time)
{
var sum = 0f;
var rz = length(Centre - p);
for (var t = _zoneStart; t < _zoneStart + _zoneCount; t++)
sum += _terms[t].Strength * GravityField.Potential(_terms[t].Softening, _terms[t].Inner, _terms[t].Outer, rz) - _terms[t].Reference;
foreach (var root in _roots) sum += PotentialFrom(root, p, positionAt, time);
return sum;
}