Gravity.cginc is generated: GravityShader.Source() is its one author, a test pins the committed file to it byte for byte

The mirror test compared a normalised reading of two spellings and was beaten twice (a statement before the return, gravityHeight's sum). GravityShader writes the whole file (potential, zero-strength guard, reference term, height sum) from GravityField's constant (TailOffset); GravityShaderTests fails on any difference. GravityShaderMirrorTests deleted. GravityFieldParityVerify opens Main Menu.unity: no ZoneRenderer there, its Fog Surface Displacement camera is the menu's own brush presentation (layers 9 and 10) by spec.

Builds not yet verified on this commit; verified at the final merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-15 08:44:11 +02:00
commit 13c7a08707
7 changed files with 138 additions and 55 deletions

View file

@ -251,6 +251,34 @@ namespace Aetheria.EditorTools
if (compared < Size * Size / 2) Fail("gradient", $"only {compared} texels were compared");
}
// Main Menu.unity has no ZoneRenderer, so no Gravity Field and no lowered potential: its gravity cameras draw the
// menu's own brush objects (Boundary on layer 9, the Waves on layer 10) and are presentation by spec (follow-up
// menu-grid-brushes), not a reader of the potential. This pins that: a ZoneRenderer in the menu would make its
// cameras readers of the field, and they would have to cull layer 9 alone like ARPG's.
static void CheckMenuWiring()
{
var scene = EditorSceneManager.OpenScene("Assets/Scenes/Main Menu.unity", OpenSceneMode.Single);
try
{
if (UnityEngine.Object.FindFirstObjectByType<ZoneRenderer>(FindObjectsInactive.Include) != null)
Fail("wiring", "Main Menu.unity has a ZoneRenderer: its gravity cameras now read the Gravity Field and must cull layer 9 alone");
var seen = 0;
foreach (var camera in UnityEngine.Object.FindObjectsByType<Camera>(FindObjectsInactive.Include, FindObjectsSortMode.None))
{
if (camera.targetTexture == null || camera.targetTexture.name != "Fog Surface Displacement") continue;
seen++;
const int menuBrushLayers = (1 << 9) | (1 << 10);
if (camera.cullingMask != menuBrushLayers)
Fail("wiring", $"Main Menu camera {camera.name} culls mask {camera.cullingMask}, not the menu brush layers 9 and 10");
}
if (seen != 1) Fail("wiring", $"{seen} Fog Surface Displacement cameras in Main Menu.unity, expected 1");
}
finally
{
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
}
}
static void CheckWiring(GameSettings settings)
{
var scene = EditorSceneManager.OpenScene("Assets/Scenes/ARPG.unity", OpenSceneMode.Single);
@ -287,6 +315,8 @@ namespace Aetheria.EditorTools
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
}
CheckMenuWiring();
foreach (var path in new[]
{
"Assets/Prefabs/RPG/Planets/Planet.prefab", "Assets/Prefabs/RPG/Planets/Gas Giant.prefab",

View file

@ -10,13 +10,16 @@ using float2 = CultMath.float2;
/// </summary>
public static class GravityField
{
// The tail's additive constant: it makes the tail meet the core in value at the core radius. Gravity.cginc is generated from it.
public const float TailOffset = .5f;
// The potential at distance r from the body, defined up to a constant (callers subtract their reference).
// A well of no strength (a body whose curves are unauthored, so c = 0 and R = 0) digs nothing.
public static float Potential(float c, float coreRadius, float r) =>
c == 0f ? 0f :
r < coreRadius
? c * r * r / (2 * coreRadius * coreRadius)
: c * (log(r / coreRadius) + .5f);
: c * (log(r / coreRadius) + TailOffset);
// The acceleration at `offsetFromBody`, toward the body: zero at the body itself.
public static float2 Acceleration(float c, float coreRadius, float2 offsetFromBody)

View file

@ -0,0 +1,65 @@
using System.Globalization;
/// <summary>
/// The single author of Assets/Shaders/Gravity.cginc, the GPU lowering of the zone's potential. The law is
/// GravityField's (its constants are read here, not respelled) and the height is Zone.GetHeight: each well's
/// potential minus its reference, summed, 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("@TAIL@", GravityField.TailOffset.ToString("R", CultureInfo.InvariantCulture)))
.Replace("\r\n", "\n");
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 (positions from Orbit.Position, reference from Zone.WellReference) each frame.
// GravityFieldParityVerify renders it and compares every texel with -Zone.GetHeight.
struct GravityWell
{
float2 position;
float strength;
float coreRadius;
float reference;
};
StructuredBuffer<GravityWell> _GravityWells;
int _GravityWellCount;
float _HeightExaggeration;
// GravityField.Potential: c is the well's strength, R its core radius, r the distance.
float gravityPotential(float c, float R, float r)
{
return c == 0 ? 0 :
r < R
? c * r * r / (2 * R * R)
: c * (log(r / R) + @TAIL@);
}
// Zone.GetHeight at world xz: each well'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++)
{
GravityWell well = _GravityWells[i];
result += gravityPotential(well.strength, well.coreRadius, length(p - well.position)) - well.reference;
}
return result / _HeightExaggeration;
}
#endif
""";
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9a3fa1e9df764459b709270e58e79974
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,9 +1,10 @@
#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 mirrors them and decides nothing. GravityLowering.Lower fills the
// buffer and the globals from the zone (positions from Orbit.Position, reference from Zone.WellReference) each frame.
// and the height is Zone.GetHeight; this file decides nothing. GravityLowering.Lower fills the buffer and the globals
// from the zone (positions from Orbit.Position, reference from Zone.WellReference) each frame.
// GravityFieldParityVerify renders it and compares every texel with -Zone.GetHeight.
struct GravityWell
@ -18,13 +19,13 @@ StructuredBuffer<GravityWell> _GravityWells;
int _GravityWellCount;
float _HeightExaggeration;
// GravityField.Potential, line for line: c is the well's strength, R its core radius, r the distance.
// GravityField.Potential: c is the well's strength, R its core radius, r the distance.
float gravityPotential(float c, float R, float r)
{
return c == 0 ? 0 :
r < R
? c * r * r / (2 * R * R)
: c * (log(r / R) + .5);
: c * (log(r / R) + 0.5);
}
// Zone.GetHeight at world xz: each well's potential minus its own reference, summed, divided by the exaggeration.

View file

@ -1,50 +0,0 @@
using System.IO;
using System.Text.RegularExpressions;
using Xunit;
// potential-owns-gravity (aetheria-gravity, cut grid-lowering): Assets/Shaders/Gravity.cginc lowers the potential for
// the GPU and decides nothing, so its gravityPotential must be GravityField.Potential, term for term, zero-strength
// guard and core branch included. The shader cannot be compiled here (GravityFieldParityVerify renders it, on Starfire
// only), so this test is the automated guard: each copy's expression is read from its file, reduced to the same
// spelling (float suffixes, the core radius's name, whitespace and comments), and compared. A drift in either file fails it.
public sealed class GravityShaderMirrorTests
{
private static string Read(string relative) =>
File.ReadAllText(Path.Combine(RunStartTests.FindRepoRoot(), relative));
// The text between `start` and the first `;` after it, comments removed and spellings reduced.
private static string Expression(string source, string start, string then = "")
{
source = Regex.Replace(source, @"//[^\n]*", "");
var from = source.IndexOf(start, System.StringComparison.Ordinal);
Assert.True(from >= 0, $"'{start}' not found");
var body = source.Substring(from + start.Length);
if (then != "")
{
var at = body.IndexOf(then, System.StringComparison.Ordinal);
Assert.True(at >= 0, $"'{then}' not found after '{start}'");
body = body.Substring(at + then.Length);
}
body = body.Substring(0, body.IndexOf(';'));
body = Regex.Replace(body, @"(?<=[\d.])f\b", "");
body = body.Replace("coreRadius", "R");
return Regex.Replace(body, @"\s+", "");
}
private static string CSharpLaw() =>
Expression(Read("Assets/Scripts/ServerShared/GravityField.cs"), "Potential(float c, float coreRadius, float r) =>");
private static string ShaderLaw() =>
Expression(Read("Assets/Shaders/Gravity.cginc"), "float gravityPotential(float c, float R, float r)", "return");
[Fact]
public void TheShaderRunsTheSameLawAsGravityFieldPotential()
{
var law = CSharpLaw();
// The reduction found the whole law, so equality is not two empty strings agreeing.
Assert.Contains("log(r/R)", law);
Assert.Contains("c==0?0:", law);
Assert.Contains("c*r*r/(2*R*R)", law);
Assert.Equal(law, ShaderLaw());
}
}

View file

@ -0,0 +1,23 @@
using System;
using System.IO;
using Xunit;
// potential-owns-gravity (aetheria-gravity, cut grid-lowering): Assets/Shaders/Gravity.cginc is the output of
// GravityShader.Source(), which reads GravityField's constants and writes the one lowering of the potential, the
// zero-strength guard, each well's reference and gravityHeight's sum. The committed file must equal it, character for
// character (line ends aside): any added statement, respelling, flipped sign, swapped argument or macro fails here.
// To change the shader, change GravityShader and run once with AETHERIA_REGENERATE_GRAVITY_CGINC=1.
public sealed class GravityShaderTests
{
[Fact]
public void TheCommittedShaderIsTheGeneratedOne()
{
var path = Path.Combine(RunStartTests.FindRepoRoot(), GravityShader.Path);
if (Environment.GetEnvironmentVariable("AETHERIA_REGENERATE_GRAVITY_CGINC") == "1")
File.WriteAllText(path, GravityShader.Source());
var generated = GravityShader.Source();
// The law was generated, not an empty template agreeing with an empty file.
Assert.Contains("log(r / R) + 0.5", generated);
Assert.Equal(generated, File.ReadAllText(path).Replace("\r\n", "\n"));
}
}