Aetheria/Assets/Scripts/ServerShared/GravityShader.cs
Meta Vens 250b61be2c 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>
2026-10-16 21:14:58 +02:00

128 lines
5.2 KiB
C#

using System.Globalization;
using System.Linq;
/// <summary>
/// The single author of Assets/Shaders/Gravity.cginc, the GPU lowering of the zone's potential. The law is
/// GravityField's (its constants and smoothstep coefficients are read here, not respelled) and the height is
/// Zone.GetHeight: each term's potential minus its reference, summed over the terms whose edge the point is inside,
/// divided by the exaggeration. Unity does not run C# when it imports a shader, so the committed cginc is this output;
/// GravityShaderTests fails unless it equals Source() exactly.
/// To change the shader, change this file and regenerate: AETHERIA_REGENERATE_GRAVITY_CGINC=1 dotnet test --filter GravityShaderTests.
/// </summary>
public static class GravityShader
{
public const string Path = "Assets/Shaders/Gravity.cginc";
/// <summary>The whole file, with LF line ends.</summary>
public static string Source() => Template
.Replace("@ORDER@", GravityField.Order.ToString(CultureInfo.InvariantCulture))
.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))
.Replace("\r\n", "\n");
private static string Coefficients(float[] c) =>
string.Join(", ", c.Select(x => x.ToString("R", CultureInfo.InvariantCulture)));
private const string Template = @"#ifndef AETHERIA_GRAVITY_INCLUDED
#define AETHERIA_GRAVITY_INCLUDED
// GENERATED by ServerShared/GravityShader.cs from GravityField; do not edit (GravityShaderTests fails on any difference).
// The lowering of the zone's potential (aetheria-gravity, cut grid-lowering). The law is ServerShared's GravityField
// and the height is Zone.GetHeight; this file decides nothing. GravityLowering.Lower fills the buffer and the globals
// from the zone's WellTree (positions from the nodes' rails at the drawn time) each frame.
// GravityFieldParityVerify renders it and compares every texel with -Zone.GetHeight.
struct GravityTerm
{
float2 position;
float strength;
float softening;
float inner;
float outer;
float reference;
};
StructuredBuffer<GravityTerm> _GravityWells;
int _GravityWellCount;
float _HeightExaggeration;
// GravityField.OneMinusS and GravityField.S: the band profile B(t) = 1 - S(t) and the order-@ORDER@ smoothstep, lowest first.
static const float GravityOneMinusS[@N@] = { @ONEMINUSS@ };
static const float GravityS[@N@] = { @S@ };
// GravityField.BandClosed: the band's potential at t for tau <= @SERIESFROM@, per unit strength.
float gravityBandClosed(float tau, float t)
{
float q[@Q@];
q[@QLAST@] = GravityOneMinusS[@Q@];
[unroll] for (int i = @QLAST@; i > 0; i--) q[i - 1] = GravityOneMinusS[i] - tau * q[i];
float remainder = GravityOneMinusS[0] - tau * q[0];
float whole = 0;
float part = 0;
[unroll] for (int j = @QLAST@; j >= 0; j--)
{
whole = whole + q[j] / (j + 1);
part = (part + q[j] / (j + 1)) * t;
}
return 0.5 * (remainder * log((t + tau) / (1 + tau)) - (whole - part));
}
// 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;
float yj[@N@];
yj[0] = 1;
[unroll] for (int j = 1; j < @N@; j++) yj[j] = yj[j - 1] * y;
float w = 1 / (1 + tau);
float wk = w;
float yk = y;
float acc = 0;
for (int k = 0; k < @SERIES@; k++)
{
float inner = 0;
[unroll] for (int m = @ORDER@; m < @N@; m++) inner += GravityS[m] * yj[m] / (m + k + 1);
acc += wk * yk * inner;
wk *= w;
yk *= y;
}
return -0.5 * acc;
}
// GravityField.Potential: one band term, per unit strength; the global term when b is infinite.
float gravityTermPotential(float softening, float a, float b, float r)
{
float s = r * r;
float e2 = softening * softening;
if (isinf(b)) return 0.5 * log(s + e2);
float a2 = a * a;
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 <= @SERIESFROM@ ? gravityBandClosed(tau, 0) : gravityBandSeries(tau, 0));
float t = (s - a2) / (b2 - a2);
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.
float gravityHeight(float2 p)
{
float result = 0;
for (int i = 0; i < _GravityWellCount; i++)
{
GravityTerm term = _GravityWells[i];
float r = length(p - term.position);
if (r < term.outer)
result += term.strength * gravityTermPotential(term.softening, term.inner, term.outer, r) - term.reference;
}
return result / _HeightExaggeration;
}
#endif
";
}