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:
parent
4fb5cb9466
commit
250b61be2c
9 changed files with 412 additions and 60 deletions
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using GameCult.Caching;
|
||||
|
|
@ -27,14 +28,16 @@ namespace Aetheria.EditorTools
|
|||
const string Tag = "[GravityFieldParityVerify]";
|
||||
const int Size = 256;
|
||||
// Measured on this tree (the "max error" line this run prints): the largest |texel + GetHeight| over the windows of the three
|
||||
// frames is 0.0108 u, in the moved frame's planetoid window (float32 on both sides, the GPU's log and the closed form's
|
||||
// frames is 0.0103 u, in the moved-again frame's sun window (float32 on both sides, the GPU's log and the closed form's
|
||||
// single-precision floor of 1.5e-5 per unit strength against the CPU's, the quad's interpolated world position). The
|
||||
// tolerance is 2.8x that. A band whose terms were drawn from the wrong edge (reference, inner or outer) moves the
|
||||
// tolerance is 2.9x that. A band whose terms were drawn from the wrong edge (reference, inner or outer) moves the
|
||||
// drawn height by whole units.
|
||||
const float Tolerance = 0.03f;
|
||||
// The worst angle between the texture's central difference and GetForce (B28: 1.0 degrees median zone, 2.9 worst).
|
||||
const float GradientDegrees = 3f;
|
||||
static float _maxError, _maxAngle;
|
||||
// Below this force (u/s^2) the drawn height differs between neighbouring texels by less than float32 resolves.
|
||||
const float FlatForce = 1e-3f;
|
||||
static float _maxError, _maxAngle, _maxTau;
|
||||
static bool _ok = true;
|
||||
|
||||
static void OnLog(string message, string stack, LogType type)
|
||||
|
|
@ -70,7 +73,7 @@ namespace Aetheria.EditorTools
|
|||
var material = AssetDatabase.LoadAssetAtPath<Material>("Assets/Materials/Brushes/Gravity Field.mat")
|
||||
?? throw new InvalidOperationException("No Gravity Field.mat.");
|
||||
|
||||
var zone = BuildZone(settings, work, out var orbits);
|
||||
var zone = BuildZone(settings, work, out var orbits, multiRoot: true);
|
||||
GravityLowering.Lower(zone, 0f, ref wells);
|
||||
field = GravityLowering.CreateField(material);
|
||||
|
||||
|
|
@ -82,13 +85,17 @@ namespace Aetheria.EditorTools
|
|||
CheckFrame("built", zone, orbits);
|
||||
orbits[1].Position = Around(orbits[0], orbits[1], .31f);
|
||||
orbits[2].Position = Around(orbits[1], orbits[2], .77f);
|
||||
orbits[5].Position = Around(orbits[1], orbits[5], .41f);
|
||||
GravityLowering.Lower(zone, 0f, ref wells);
|
||||
CheckFrame("moved", zone, orbits);
|
||||
orbits[0].Position = new float2(210f, -95f);
|
||||
orbits[1].Position = Around(orbits[0], orbits[1], .52f);
|
||||
orbits[2].Position = Around(orbits[1], orbits[2], .05f);
|
||||
orbits[5].Position = Around(orbits[1], orbits[5], .93f);
|
||||
GravityLowering.Lower(zone, 0f, ref wells);
|
||||
CheckFrame("moved-again", zone, orbits);
|
||||
if (_maxTau <= 1f) Fail("series-branch", $"the largest tau over the drawn bounded terms is {_maxTau:G4}; no window draws the series branch");
|
||||
Debug.Log($"{Tag} largest drawn tau {_maxTau:G4}");
|
||||
Debug.Log($"{Tag} max error {_maxError:G4} u (tolerance {Tolerance:G4}); max gradient angle {_maxAngle:F2} degrees (limit {GradientDegrees:F0})");
|
||||
|
||||
CheckWiring(settings);
|
||||
|
|
@ -117,12 +124,23 @@ namespace Aetheria.EditorTools
|
|||
static Vector2 At(Orbit orbit) => new Vector2(orbit.Position.x, orbit.Position.y);
|
||||
|
||||
// Orbits 0-2 are the sun, the planet and the planetoid, each window centred where the body is now; orbit 3 is the
|
||||
// body of no strength. The zone window holds all four.
|
||||
// body of no strength, orbit 4 the second sun and orbit 5 the planet's moon inside its Roche limit (whose band has
|
||||
// tau above 1: the series branch of the shader). The zone window holds all of them; the far window is centred on the
|
||||
// zone node, so its band (nothing inside A, the zone's mass beyond Beta A) is drawn. A window draws a term when
|
||||
// the term's disc reaches it; the largest tau over the drawn terms must exceed 1 or the series branch went unchecked.
|
||||
static void CheckFrame(string frame, Zone zone, Orbit[] orbits)
|
||||
{
|
||||
CheckTexels(frame + "-planetoid", zone, At(orbits[2]), 4f * Core(zone, 29.2f));
|
||||
CheckTexels(frame + "-planet", zone, At(orbits[1]), 4f * Core(zone, 273.9f));
|
||||
CheckTexels(frame + "-sun", zone, At(orbits[0]), 4f * Core(zone, 14152f));
|
||||
CheckTexels(frame + "-zone", zone, Vector2.zero, 1.05f * zone.Pack.Radius);
|
||||
var centre = new Vector2(zone.Wells.Centre.x, zone.Wells.Centre.y);
|
||||
var span = 0f;
|
||||
foreach (var root in new[] { orbits[0], orbits[4] })
|
||||
span = Math.Max(span, (At(root) - centre).magnitude + zone.Wells.Reach(zone.Orbits.First(o => o.Value == root).Key));
|
||||
// The wells have their own windows: out here a texel is 40 u wide, so only the ring from 0.9 A outward, where the
|
||||
// zone node's band rises and the zone becomes one body, is measured.
|
||||
CheckTexels(frame + "-far", zone, centre, 1.6f * span, .9f * span);
|
||||
CheckGradient(zone, orbits.Select(At).ToArray());
|
||||
}
|
||||
|
||||
|
|
@ -131,18 +149,28 @@ namespace Aetheria.EditorTools
|
|||
// c = 0 and R = 0), at (1700, 300): a family, so the band terms are drawn (the planet's and the sun's cusps and
|
||||
// fades), off both axes and asymmetric, so the zone's wells sit where the verify says before any Update and a
|
||||
// mirrored or swapped axis cannot agree with the sim. The sun and the zero-mass body are fixed roots.
|
||||
internal static Zone BuildZone(GameSettings settings, string work, out Orbit[] orbits, float radius = 4182f)
|
||||
internal static Zone BuildZone(GameSettings settings, string work, out Orbit[] orbits, float radius = 4182f, bool multiRoot = false)
|
||||
{
|
||||
var cache = AetheriaStores.Open(Path.Combine(work, "catalog.cc"), Path.Combine(work, "run.cc"), catalogWritable: true);
|
||||
cache.Upsert(new VerseGrammar { Revision = 1 });
|
||||
cache.FlushAsync().Wait();
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { });
|
||||
var positions = new[] { new Vector2(-130f, 85f), new Vector2(610f, -240f), new Vector2(670f, -270f), new Vector2(1700f, 300f) };
|
||||
var masses = new[] { 14152f, 273.9f, 29.2f, 0f };
|
||||
var parents = new[] { -1, 0, 1, -1 };
|
||||
var keys = new CultRecordKey[positions.Length];
|
||||
var positions = new List<Vector2> { new Vector2(-130f, 85f), new Vector2(610f, -240f), new Vector2(670f, -270f), new Vector2(1700f, 300f) };
|
||||
var masses = new List<float> { 14152f, 273.9f, 29.2f, 0f };
|
||||
var parents = new List<int> { -1, 0, 1, -1 };
|
||||
if (multiRoot)
|
||||
{
|
||||
// A second sun (a second subzone, so the zone node exists) and a moon of the planet 0.6 of its Roche limit away.
|
||||
positions.Add(new Vector2(-2400f, -1900f));
|
||||
masses.Add(3000f);
|
||||
parents.Add(-1);
|
||||
positions.Add(positions[1] + new Vector2(-.6f * settings.PlanetSettings.RocheLimit.Evaluate(273.9f), 0f));
|
||||
masses.Add(1f);
|
||||
parents.Add(1);
|
||||
}
|
||||
var keys = new CultRecordKey[positions.Count];
|
||||
var pack = new ZonePack { Radius = radius };
|
||||
for (var i = 0; i < positions.Length; i++)
|
||||
for (var i = 0; i < positions.Count; i++)
|
||||
{
|
||||
var data = new OrbitData { FixedPosition = new float2(positions[i].x, positions[i].y) };
|
||||
if (parents[i] >= 0)
|
||||
|
|
@ -211,13 +239,29 @@ namespace Aetheria.EditorTools
|
|||
}
|
||||
}
|
||||
|
||||
static void CheckTexels(string window, Zone zone, Vector2 centre, float half)
|
||||
// The largest tau = (a^2 + eps^2) / (b^2 - a^2) over the bounded terms whose disc reaches the window's square.
|
||||
static void NoteTau(Zone zone, Vector2 centre, float half)
|
||||
{
|
||||
foreach (var term in zone.Wells.Terms)
|
||||
{
|
||||
if (float.IsPositiveInfinity(term.Outer)) continue;
|
||||
var at = term.Node.IsSet() ? zone.Orbits[term.Node].Position : zone.Wells.Centre;
|
||||
if (Math.Abs(at.x - centre.x) > half + term.Outer || Math.Abs(at.y - centre.y) > half + term.Outer) continue;
|
||||
_maxTau = Math.Max(_maxTau, (term.Inner * term.Inner + term.Softening * term.Softening) / (term.Outer * term.Outer - term.Inner * term.Inner));
|
||||
}
|
||||
}
|
||||
|
||||
static void CheckTexels(string window, Zone zone, Vector2 centre, float half, float ringFrom = 0f)
|
||||
{
|
||||
Render(centre, half, out var depth, out var world);
|
||||
NoteTau(zone, centre, half);
|
||||
var worst = 0f;
|
||||
var live = 0;
|
||||
var measured = 0;
|
||||
for (var i = 0; i < depth.Length; i++)
|
||||
{
|
||||
if ((world[i] - centre).magnitude < ringFrom) continue;
|
||||
measured++;
|
||||
var expected = -zone.GetHeight(new float2(world[i].x, world[i].y));
|
||||
if (float.IsNaN(depth[i])) { Fail($"{window}-texels", $"texel {i}: NaN"); return; }
|
||||
var error = Math.Abs(depth[i] - expected);
|
||||
|
|
@ -231,7 +275,7 @@ namespace Aetheria.EditorTools
|
|||
}
|
||||
_maxError = Math.Max(_maxError, worst);
|
||||
// A window that draws nothing agrees with a zone that is flat: the check must be live.
|
||||
if (live < depth.Length / 2) Fail($"{window}-texels", $"only {live} of {depth.Length} texels are deeper than 1 u; the check is vacuous");
|
||||
if (live < measured / 2) Fail($"{window}-texels", $"only {live} of {measured} texels are deeper than 1 u; the check is vacuous");
|
||||
Debug.Log($"{Tag} {window}: {depth.Length} texels, max error {worst:G4} u");
|
||||
}
|
||||
|
||||
|
|
@ -255,6 +299,7 @@ namespace Aetheria.EditorTools
|
|||
var det = au.x * av.y - au.y * av.x;
|
||||
var g = new Vector2((du * av.y - dv * au.y) / det, (dv * au.x - du * av.x) / det);
|
||||
var f = zone.GetForce(new float2(here.x, here.y));
|
||||
if (CultMath.math.length(f) < FlatForce) continue; // between the subzones the ground is flat: no direction to compare
|
||||
var angle = Vector2.Angle(g, new Vector2(f.x, f.y));
|
||||
if (float.IsNaN(angle) || angle > GradientDegrees)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@ public static class GravityField
|
|||
/// <summary>A ring member of a bodyless node reaches RingReach x its distance: whole out to the ring's diameter.</summary>
|
||||
public const float RingReach = 4f;
|
||||
|
||||
/// <summary>The series branch's length: its ratio is below 1/2, so 40 terms reach float32's floor.</summary>
|
||||
/// <summary>The band is summed as a series for tau above this. Measured (map B54): float32 within 1.0e-6 per unit strength everywhere with SeriesTerms terms; the closed form is float32-clean only to about 0.45.</summary>
|
||||
public const float SeriesFrom = .4f;
|
||||
|
||||
/// <summary>The series branch's length: its ratio is below 1/1.4, so 40 terms reach float32's floor.</summary>
|
||||
public const int SeriesTerms = 40;
|
||||
|
||||
/// <summary>S(t) as polynomial coefficients in t, lowest first: t^Order sum_j C(Order-1+j, j) (1-t)^j.</summary>
|
||||
|
|
@ -102,7 +105,7 @@ public static class GravityField
|
|||
}
|
||||
|
||||
// The band's potential at t in [0, 1], 0 at t = 1.
|
||||
private static float Band(float tau, float t) => tau <= 1f ? BandClosed(tau, t) : BandSeries(tau, t);
|
||||
private static float Band(float tau, float t) => tau <= SeriesFrom ? BandClosed(tau, t) : BandSeries(tau, t);
|
||||
|
||||
private static float BandClosed(float tau, float t)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ public static class GravityShader
|
|||
.Replace("@N@", GravityField.S.Length.ToString(CultureInfo.InvariantCulture))
|
||||
.Replace("@Q@", (GravityField.S.Length - 1).ToString(CultureInfo.InvariantCulture))
|
||||
.Replace("@QLAST@", (GravityField.S.Length - 2).ToString(CultureInfo.InvariantCulture))
|
||||
.Replace("@SERIESFROM@", GravityField.SeriesFrom.ToString("R", CultureInfo.InvariantCulture))
|
||||
.Replace("@SERIES@", GravityField.SeriesTerms.ToString(CultureInfo.InvariantCulture))
|
||||
.Replace("@ONEMINUSS@", Coefficients(GravityField.OneMinusS))
|
||||
.Replace("@S@", Coefficients(GravityField.S))
|
||||
|
|
@ -54,7 +55,7 @@ float _HeightExaggeration;
|
|||
static const float GravityOneMinusS[@N@] = { @ONEMINUSS@ };
|
||||
static const float GravityS[@N@] = { @S@ };
|
||||
|
||||
// GravityField.BandClosed: the band's potential at t for tau <= 1, per unit strength.
|
||||
// GravityField.BandClosed: the band's potential at t for tau <= @SERIESFROM@, per unit strength.
|
||||
float gravityBandClosed(float tau, float t)
|
||||
{
|
||||
float q[@Q@];
|
||||
|
|
@ -71,7 +72,7 @@ float gravityBandClosed(float tau, float t)
|
|||
return 0.5 * (remainder * log((t + tau) / (1 + tau)) - (whole - part));
|
||||
}
|
||||
|
||||
// GravityField.BandSeries: the band's potential at t for tau > 1, a series in (1 - t) / (1 + tau).
|
||||
// GravityField.BandSeries: the band's potential at t for tau > @SERIESFROM@, a series in (1 - t) / (1 + tau).
|
||||
float gravityBandSeries(float tau, float t)
|
||||
{
|
||||
float y = 1 - t;
|
||||
|
|
@ -103,9 +104,9 @@ float gravityTermPotential(float softening, float a, float b, float r)
|
|||
float b2 = b * b;
|
||||
if (s >= b2) return 0;
|
||||
float tau = (a2 + e2) / (b2 - a2);
|
||||
if (s <= a2) return 0.5 * log((s + e2) / (a2 + e2)) + (tau <= 1 ? gravityBandClosed(tau, 0) : gravityBandSeries(tau, 0));
|
||||
if (s <= a2) return 0.5 * log((s + e2) / (a2 + e2)) + (tau <= @SERIESFROM@ ? gravityBandClosed(tau, 0) : gravityBandSeries(tau, 0));
|
||||
float t = (s - a2) / (b2 - a2);
|
||||
return tau <= 1 ? gravityBandClosed(tau, t) : gravityBandSeries(tau, t);
|
||||
return tau <= @SERIESFROM@ ? gravityBandClosed(tau, t) : gravityBandSeries(tau, t);
|
||||
}
|
||||
|
||||
// Zone.GetHeight at world xz: each term's potential minus its own reference, summed, divided by the exaggeration.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,10 +193,23 @@ public class Zone
|
|||
foreach (var body in PlanetInstances)
|
||||
if (body.Value.WellStrength > 0)
|
||||
wellMass.TryAdd(Planets[body.Key].Orbit.Key, body.Value.BodyData.Mass);
|
||||
// A belt's rail carries its asteroids, which spread beyond the rail's own distance: its extent is the outermost.
|
||||
var beltExtent = new Dictionary<CultRecordKey, float>();
|
||||
foreach (var belt in AsteroidBelts)
|
||||
{
|
||||
var extent = 0f;
|
||||
foreach (var asteroid in belt.Value.Data.Asteroids) extent = Math.Max(extent, asteroid.Distance);
|
||||
beltExtent[Planets[belt.Key].Orbit.Key] = extent;
|
||||
}
|
||||
var wellNodes = new List<WellNode>();
|
||||
foreach (var orbit in Orbits)
|
||||
wellNodes.Add(new WellNode(orbit.Key, orbit.Value.Data.Parent.Key, orbit.Value.Data.Distance, orbit.Value.Data.Phase,
|
||||
wellMass.TryGetValue(orbit.Key, out var mass) ? mass : 0f));
|
||||
{
|
||||
var data = orbit.Value.Data;
|
||||
wellNodes.Add(new WellNode(orbit.Key, data.Parent.Key, data.Distance, data.Phase,
|
||||
wellMass.TryGetValue(orbit.Key, out var mass) ? mass : 0f,
|
||||
beltExtent.TryGetValue(orbit.Key, out var reach) ? Math.Max(reach, data.Distance) : data.Distance,
|
||||
data.FixedPosition + OrbitData.Evaluate(frac(data.Phase)) * data.Distance)); // a root's stationary place: Offset at an infinite period
|
||||
}
|
||||
Wells = WellTree.Build(wellNodes, settings, pack.Radius);
|
||||
foreach (var orbit in Orbits)
|
||||
orbit.Value.Period = orbit.Value.Data.Parent.IsSet()
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ public static class GravityLowering
|
|||
for (var t = 0; t < count; t++)
|
||||
{
|
||||
var term = zone.Wells.Terms[t];
|
||||
var at = DrawAhead.Orbit(zone, term.Node, lead);
|
||||
var at = term.Node.IsSet() ? DrawAhead.Orbit(zone, term.Node, lead) : zone.Wells.Centre; // an unset node is the zone node, stationary at Centre
|
||||
_records[t] = new TermRecord
|
||||
{
|
||||
Position = new Vector2(at.x, at.y),
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ float _HeightExaggeration;
|
|||
static const float GravityOneMinusS[8] = { 1, -0, -0, -0, -35, 84, -70, 20 };
|
||||
static const float GravityS[8] = { 0, 0, 0, 0, 35, -84, 70, -20 };
|
||||
|
||||
// GravityField.BandClosed: the band's potential at t for tau <= 1, per unit strength.
|
||||
// GravityField.BandClosed: the band's potential at t for tau <= 0.4, per unit strength.
|
||||
float gravityBandClosed(float tau, float t)
|
||||
{
|
||||
float q[7];
|
||||
|
|
@ -42,7 +42,7 @@ float gravityBandClosed(float tau, float t)
|
|||
return 0.5 * (remainder * log((t + tau) / (1 + tau)) - (whole - part));
|
||||
}
|
||||
|
||||
// GravityField.BandSeries: the band's potential at t for tau > 1, a series in (1 - t) / (1 + tau).
|
||||
// GravityField.BandSeries: the band's potential at t for tau > 0.4, a series in (1 - t) / (1 + tau).
|
||||
float gravityBandSeries(float tau, float t)
|
||||
{
|
||||
float y = 1 - t;
|
||||
|
|
@ -74,9 +74,9 @@ float gravityTermPotential(float softening, float a, float b, float r)
|
|||
float b2 = b * b;
|
||||
if (s >= b2) return 0;
|
||||
float tau = (a2 + e2) / (b2 - a2);
|
||||
if (s <= a2) return 0.5 * log((s + e2) / (a2 + e2)) + (tau <= 1 ? gravityBandClosed(tau, 0) : gravityBandSeries(tau, 0));
|
||||
if (s <= a2) return 0.5 * log((s + e2) / (a2 + e2)) + (tau <= 0.4 ? gravityBandClosed(tau, 0) : gravityBandSeries(tau, 0));
|
||||
float t = (s - a2) / (b2 - a2);
|
||||
return tau <= 1 ? gravityBandClosed(tau, t) : gravityBandSeries(tau, t);
|
||||
return tau <= 0.4 ? gravityBandClosed(tau, t) : gravityBandSeries(tau, t);
|
||||
}
|
||||
|
||||
// Zone.GetHeight at world xz: each term's potential minus its own reference, summed, divided by the exaggeration.
|
||||
|
|
|
|||
|
|
@ -80,25 +80,35 @@ public sealed class WellLawTests : IDisposable
|
|||
Assert.InRange(deficit(.1f) / deficit(.05f), 13.0, 15.0); // doubling t multiplies the deficit by about 14 (t^4), not by 4
|
||||
}
|
||||
|
||||
// tau = (a^2 + eps^2) / (b^2 - a^2): the closed form holds to tau 1 and the series beyond it, and the two meet to the closed
|
||||
// form's single-precision floor (the research's bound of 1.5e-5 per unit strength; the double-precision gap is 7e-7); in single
|
||||
// precision the tau 10 potential stays within 1e-5 of the double-precision integral of the pull.
|
||||
// The band's potential is the integral of its pull, in single precision, on both sides of the series threshold: tau =
|
||||
// (a^2 + eps^2) / (b^2 - a^2) is swept from 0.05 to 10 (every (a, b) where that tau has a real softening), and the
|
||||
// potential stays within 1.2e-6 per unit strength of the double-precision Simpson integral of the pull. A closed form kept
|
||||
// past tau 0.45 or a series cut short misses this by 1e-5 to 1e-3.
|
||||
[Fact]
|
||||
public void TheSeriesBranchMeetsTheClosedForm()
|
||||
public void TheBandPotentialIsExactInSinglePrecision()
|
||||
{
|
||||
// tau 1 +- 1e-6: eps^2 = b^2 - 2 a^2 gives tau 1 exactly.
|
||||
const float a = 0.8f, b = 1.28062487f;
|
||||
var below = (float) Math.Sqrt(b * b - 2 * a * a) * (1 - 1e-6f);
|
||||
var above = (float) Math.Sqrt(b * b - 2 * a * a) * (1 + 1e-6f);
|
||||
foreach (var r in new[] { 0.5f, 0.85f, 1.0f, 1.2f })
|
||||
Assert.InRange(GravityField.Potential(above, a, b, r) - GravityField.Potential(below, a, b, r), -2e-5f, 2e-5f);
|
||||
|
||||
const float eps = 59f, inner = 11f, outer = 22f;
|
||||
foreach (var r in new[] { 12f, 15f, 18f, 21f })
|
||||
{
|
||||
var oracle = -Simpson(x => GravityField.Pull(eps, inner, outer, (float) x), r, outer);
|
||||
Assert.InRange(GravityField.Potential(eps, inner, outer, r), oracle - 1e-5, oracle + 1e-5);
|
||||
}
|
||||
var checkedCases = 0;
|
||||
foreach (var tau in new[] { .05, .3, .39, .41, .6, .7, 1, 1.5, 2.5, 10 })
|
||||
foreach (var (a, b) in new[] { (1000.0, 1500.0), (40.0, 60.0), (11.0, 22.0), (0.0, 1525.0), (150.0, 4000.0) })
|
||||
{
|
||||
var e2 = tau * (b * b - a * a) - a * a;
|
||||
if (e2 < 0) continue;
|
||||
var eps = (float) Math.Sqrt(e2);
|
||||
e2 = (double) eps * eps; // the oracle integrates the softening the code is given
|
||||
for (var k = 0; k < 15; k++)
|
||||
{
|
||||
var r = b * (.02 + .979 * k / 14);
|
||||
var oracle = -Simpson(x =>
|
||||
{
|
||||
var t = x <= a ? 0.0 : (x * x - a * a) / (b * b - a * a);
|
||||
return x / (x * x + e2) * (1 - S(t));
|
||||
}, r, b, 8000);
|
||||
var actual = GravityField.Potential(eps, (float) a, (float) b, (float) r);
|
||||
Assert.InRange(actual, oracle - 1.2e-6, oracle + 1.2e-6);
|
||||
checkedCases++;
|
||||
}
|
||||
}
|
||||
Assert.True(checkedCases > 400, "the sweep ran its cases");
|
||||
}
|
||||
|
||||
private static float Plummer(PlanetSettings curves, float mass, float d) =>
|
||||
|
|
@ -156,7 +166,7 @@ public sealed class WellLawTests : IDisposable
|
|||
Assert.True(length(family.GetForce(inside) - merged.GetForce(inside)) > 1e-3f * length(merged.GetForce(inside)), "inside the support the moon is a body of its own");
|
||||
}
|
||||
|
||||
// The root has no reach: far from everything the zone is one body of its total mass, however the generator split it.
|
||||
// A sole root has no reach: far from everything the zone is one body of its total mass, however the generator split it.
|
||||
[Fact]
|
||||
public void TheZonesFarFieldIsItsTotalMass()
|
||||
{
|
||||
|
|
@ -175,6 +185,203 @@ public sealed class WellLawTests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// Three subzones: a sun of 5000 with a planet, a sun of 900 alone, and a bodyless pair; each is a root (a parentless orbit
|
||||
// stands where its distance and phase put it). Masses are the test's own, the zone's total 6100.
|
||||
private Zone ThreeSubzones(out CultRecordKey[] roots)
|
||||
{
|
||||
var w = _worlds.New();
|
||||
var big = w.Orbit(default, 0f);
|
||||
w.Body(big, 5000f);
|
||||
w.Body(w.Orbit(big, 800f), 100f);
|
||||
var small = w.Orbit(default, 9000f, .3f);
|
||||
w.Body(small, 900f);
|
||||
var pair = w.Orbit(default, 12000f, .7f);
|
||||
w.Body(w.Orbit(pair, 300f, 0f), 60f);
|
||||
w.Body(w.Orbit(pair, 300f, .5f), 40f);
|
||||
roots = new[] { big, small, pair };
|
||||
return w.Build();
|
||||
}
|
||||
|
||||
private static readonly float[] ThreeSubzoneMass = { 5100f, 900f, 100f };
|
||||
|
||||
// The zone node sits at the family-mass-weighted mean of its roots' places and pulls the zone's total mass from there.
|
||||
[Fact]
|
||||
public void AZoneOfSeveralSunsPullsAsItsTotalMassFromTheirBarycentre()
|
||||
{
|
||||
var zone = ThreeSubzones(out var roots);
|
||||
var centre = float2.zero;
|
||||
for (var i = 0; i < roots.Length; i++) centre += zone.Orbits[roots[i]].Position * ThreeSubzoneMass[i] / 6100f;
|
||||
Assert.InRange(length(zone.Wells.Centre - centre), 0f, 1e-3f);
|
||||
|
||||
var far = 10f * zone.Pack.Radius;
|
||||
foreach (var angle in new[] { 0f, 2.1f, 4.4f })
|
||||
Near(ZoneWorlds.Curves().WellStrength.Evaluate(6100f),
|
||||
length(zone.GetForce(zone.Wells.Centre + float2(far * cos(angle), far * sin(angle)))) * far, 1e-3f);
|
||||
}
|
||||
|
||||
// One sun with a planet, alone in its zone at the place a root of `distance` and `phase` stands.
|
||||
private Zone OneSubzone(float sun, float distance, float phase, out CultRecordKey root)
|
||||
{
|
||||
var w = _worlds.New();
|
||||
root = w.Orbit(default, distance, phase);
|
||||
w.Body(root, sun);
|
||||
w.Body(w.Orbit(root, 700f, .2f), 120f);
|
||||
return w.Build();
|
||||
}
|
||||
|
||||
// Isolated subzones: inside its own support a sun is felt exactly as if it were alone in the zone, the other suns and the
|
||||
// zone node adding nothing; a root's reach is half the distance to the nearest other sun, or 1.5 of its support if more.
|
||||
[Fact]
|
||||
public void ASubzoneFeelsOnlyItsOwnSun()
|
||||
{
|
||||
var w = _worlds.New();
|
||||
var a = w.Orbit(default, 0f);
|
||||
w.Body(a, 4000f);
|
||||
w.Body(w.Orbit(a, 700f, .2f), 120f);
|
||||
var b = w.Orbit(default, 9000f, 0f);
|
||||
w.Body(b, 2500f);
|
||||
w.Body(w.Orbit(b, 700f, .2f), 120f);
|
||||
var zone = w.Build();
|
||||
|
||||
var rng = new System.Random(3);
|
||||
foreach (var (root, sun, distance) in new[] { (a, 4000f, 0f), (b, 2500f, 9000f) })
|
||||
{
|
||||
var alone = OneSubzone(sun, distance, 0f, out var aloneRoot);
|
||||
Assert.True(float.IsPositiveInfinity(alone.Wells.Reach(aloneRoot)), "a sole massive root is the whole zone");
|
||||
var support = zone.Wells.Support(root);
|
||||
Assert.Equal(Math.Max(.5f * 9000f, 1.5f * support), zone.Wells.Reach(root), 2);
|
||||
var at = zone.Orbits[root].Position;
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
var angle = (float) (rng.NextDouble() * 2 * Math.PI);
|
||||
var radius = support * (.2f + .8f * (float) rng.NextDouble());
|
||||
var p = at + float2(radius * cos(angle), radius * sin(angle));
|
||||
var expected = alone.GetForce(p);
|
||||
Assert.InRange(length(zone.GetForce(p) - expected), 0f, 1e-5f * length(expected) + 1e-9f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Between the subzones the ground is flat; beyond the populated area the zone is one body of its total mass at its centre.
|
||||
[Fact]
|
||||
public void BeyondEverySubzoneTheZoneIsOneBody()
|
||||
{
|
||||
var zone = ThreeSubzones(out var roots);
|
||||
var curves = ZoneWorlds.Curves();
|
||||
var centre = zone.Wells.Centre;
|
||||
var span = roots.Max(r => length(zone.Orbits[r].Position - centre) + zone.Wells.Reach(r)); // A, as the law defines it
|
||||
|
||||
var flat = 0;
|
||||
for (var x = -14000f; x <= 14000f; x += 700f)
|
||||
for (var y = -14000f; y <= 14000f; y += 700f)
|
||||
{
|
||||
var p = float2(x, y);
|
||||
if (length(p - centre) >= span || roots.Any(r => length(p - zone.Orbits[r].Position) <= zone.Wells.Reach(r))) continue;
|
||||
flat++;
|
||||
Assert.InRange(length(zone.GetForce(p)), 0f, 1e-7f);
|
||||
}
|
||||
Assert.True(flat > 100, "fixture: there is open ground between the subzones");
|
||||
|
||||
foreach (var (radius, angle) in new[] { (1.6f, .4f), (3f, 2.5f), (8f, 5.1f) })
|
||||
{
|
||||
var p = centre + float2(span * radius * cos(angle), span * radius * sin(angle));
|
||||
var expected = (centre - p) / length(centre - p) * Plummer(curves, 6100f, length(centre - p));
|
||||
Assert.InRange(length(zone.GetForce(p) - expected), 0f, 1e-5f * length(expected));
|
||||
}
|
||||
}
|
||||
|
||||
// A belt is a child of its parent's family: the family's support covers the belt's outermost asteroid, so every asteroid
|
||||
// circles the planet's body alone at its own distance (not at the belt orbit's, 43, where the fade used to start).
|
||||
[Fact]
|
||||
public void ABeltRidesInsideItsParentsBody()
|
||||
{
|
||||
var w = _worlds.New();
|
||||
var sun = w.Orbit(default, 0f);
|
||||
w.Body(sun, 5000f);
|
||||
var planet = w.Orbit(sun, 3000f);
|
||||
w.Body(planet, 172f);
|
||||
var belt = w.Belt(w.Orbit(planet, 43f, .4f), 10f, 43f, 87f);
|
||||
var zone = w.Build();
|
||||
|
||||
Assert.Equal(1.5f * 87f, zone.Wells.Support(planet), 3);
|
||||
var curves = ZoneWorlds.Curves();
|
||||
var periods = zone.AsteroidBelts[belt].Periods;
|
||||
Assert.Equal(3, periods.Length);
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var d = zone.AsteroidBelts[belt].Data.Asteroids[i].Distance;
|
||||
var expected = 2 * PI * sqrt(d / Plummer(curves, 172f, d));
|
||||
Assert.InRange(periods[i], expected * (1 - 1e-5f), expected * (1 + 1e-5f));
|
||||
}
|
||||
}
|
||||
|
||||
// The fade of a tight family begins at its support: the planet (moon at 150, support 225, reach at its floor 337.5) pulls
|
||||
// as the whole family out to 225 and then gives way as the order-4 smoothstep of (r^2 - 225^2) / (337.5^2 - 225^2).
|
||||
[Fact]
|
||||
public void ATightFamilysPullFadesFromItsSupport()
|
||||
{
|
||||
var w = _worlds.New();
|
||||
var sun = w.Orbit(default, 0f);
|
||||
w.Body(sun, 5000f);
|
||||
var planet = w.Orbit(sun, 400f);
|
||||
w.Body(planet, 100f);
|
||||
var moon = w.Orbit(planet, 150f);
|
||||
w.Body(moon, 1f);
|
||||
var zone = w.Build();
|
||||
var curves = ZoneWorlds.Curves();
|
||||
|
||||
Assert.Equal(225f, zone.Wells.Support(planet), 3);
|
||||
Assert.Equal(337.5f, zone.Wells.Reach(planet), 3);
|
||||
foreach (var r in new[] { 232f, 250f, 270f, 290f, 310f, 325f, 333f, 336f })
|
||||
{
|
||||
var t = (r * r - 225.0 * 225) / (337.5 * 337.5 - 225.0 * 225);
|
||||
var expected = Plummer(curves, 101f, r) * (1 - S(t));
|
||||
Assert.InRange(zone.Wells.PullAround(planet, r, moon), expected * (1 - 1e-5) - 1e-9, expected * (1 + 1e-5) + 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
// A ring group's pull rises from its ring: a pair at 300 round a bodyless node pulls from the node as the order-4 smoothstep
|
||||
// of (r^2 - 300^2) / (1500^2 - 300^2) of the pair's Plummer, 1500 being the ring's distance and the members' RingReach.
|
||||
[Fact]
|
||||
public void ARingGroupsPullRisesFromItsRing()
|
||||
{
|
||||
var w = _worlds.New();
|
||||
var sun = w.Orbit(default, 0f);
|
||||
w.Body(sun, 5000f);
|
||||
var node = w.Orbit(sun, 3000f);
|
||||
w.Body(w.Orbit(node, 300f, 0f), 80f);
|
||||
w.Body(w.Orbit(node, 300f, .5f), 60f);
|
||||
var zone = w.Build();
|
||||
var curves = ZoneWorlds.Curves();
|
||||
|
||||
foreach (var r in new[] { 310f, 400f, 600f, 900f, 1200f, 1400f, 1490f })
|
||||
{
|
||||
var t = (r * r - 300.0 * 300) / (1500.0 * 1500 - 300.0 * 300);
|
||||
var expected = Plummer(curves, 140f, r) * S(t);
|
||||
Assert.InRange(zone.Wells.PullAround(node, r, default), expected * (1 - 1e-5) - 1e-9, expected * (1 + 1e-5) + 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
// The drawn height is the force's potential everywhere, however far: its slope along the radius is the pull, for a zone of
|
||||
// one sun and for one of three subzones, out to ten pack radii (a potential flattened at some radius reads zero here).
|
||||
[Fact]
|
||||
public void FarOutTheHeightIsTheForcesPotential()
|
||||
{
|
||||
var single = SunPlanetMoon(1f, out _, out _);
|
||||
var several = ThreeSubzones(out _);
|
||||
foreach (var (zone, centre) in new[] { (single, float2.zero), (several, several.Wells.Centre) })
|
||||
foreach (var radius in new[] { 2f, 4f, 7f, 10f })
|
||||
foreach (var angle in new[] { .3f, 2.4f, 4.6f })
|
||||
{
|
||||
var outward = float2(cos(angle), sin(angle));
|
||||
var p = centre + outward * (radius * zone.Pack.Radius);
|
||||
const float h = 10f;
|
||||
var slope = (zone.GetHeight(p + outward * h) - zone.GetHeight(p - outward * h)) * zone.Settings.HeightExaggeration / (2 * h);
|
||||
var pull = -dot(zone.GetForce(p), outward);
|
||||
Assert.InRange(slope, pull * .98f, pull * 1.02f);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Near(float expected, float actual, float relative) =>
|
||||
Assert.InRange(actual, expected * (1 - relative), expected * (1 + relative));
|
||||
|
||||
|
|
@ -233,18 +440,19 @@ public sealed class WellLawTests : IDisposable
|
|||
var node = w.Orbit(sunOrbit, 5200f, .13f);
|
||||
w.Body(w.Orbit(node, 300f, 0f), 80f);
|
||||
w.Body(w.Orbit(node, 300f, .5f), 60f);
|
||||
w.Body(w.Orbit(default, 9000f, .3f), 900f); // a second massive root: the zone node joins the walk
|
||||
var zone = w.Build();
|
||||
|
||||
var rng = new System.Random(7);
|
||||
for (var n = 0; n < 200; n++)
|
||||
{
|
||||
var p = float2((float) (rng.NextDouble() * 12000 - 6000), (float) (rng.NextDouble() * 12000 - 6000));
|
||||
var p = float2((float) (rng.NextDouble() * 24000 - 6000), (float) (rng.NextDouble() * 24000 - 12000));
|
||||
var force = float2.zero;
|
||||
var potential = 0f;
|
||||
var scale = 0f;
|
||||
foreach (var term in zone.Wells.Terms)
|
||||
{
|
||||
var offset = zone.Orbits[term.Node].Position - p;
|
||||
var offset = (term.Node.IsSet() ? zone.Orbits[term.Node].Position : zone.Wells.Centre) - p; // an unset node is the zone node
|
||||
var r = length(offset);
|
||||
var pull = term.Strength * GravityField.Pull(term.Softening, term.Inner, term.Outer, r);
|
||||
if (r > 0f) force += offset / r * pull;
|
||||
|
|
|
|||
|
|
@ -61,6 +61,15 @@ internal sealed class ZoneWorlds : IDisposable
|
|||
return key;
|
||||
}
|
||||
|
||||
// A belt of asteroids at the given distances riding `orbit`; returns the belt's body key.
|
||||
public CultRecordKey Belt(CultRecordKey orbit, params float[] distances)
|
||||
{
|
||||
var asteroids = distances.Select((d, i) => new Asteroid { Distance = d, Phase = frac(i * .6180339887f), Size = .5f, RotationSpeed = .1f }).ToArray();
|
||||
var key = Cache.Upsert(new AsteroidBeltData { Orbit = new CultRecordRef<OrbitData>(orbit), Asteroids = asteroids }).Key;
|
||||
Pack.Planets.Add(new CultRecordRef<BodyData>(key));
|
||||
return key;
|
||||
}
|
||||
|
||||
public Zone Build() => new Zone(Items, Curves(), Pack, new GalaxyZone { Name = "OrbitsFromField", Owner = null }, null);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue