Gravity: one potential per zone owns acceleration and height

GravityField is the law of one well (logarithmic tail, softened core).
Zone.GetForce and GetHeight sum it over the bodies; GetNormal derives from
the force for ship tilt; Flow and Ship read GetForce. Well strength, core
radius and Roche limit are PlanetSettings curves of mass, height
exaggeration K one global. Deletes GravityAcceleration, GravityReferenceRate,
GravityStrength, the PowerPulse well, GetNormal's finite differences and
BodyData.GravityDepthExponent. Settings.asset carries the calibrated curves.
Tests: PotentialOwnerTests, the moon's moon placement, retired keys over every
BodyData type, a body-derived giant sweep. Not yet built or run (verify job
follows); this commit may not build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-15 04:36:28 +02:00
commit 021c4cbcbf
16 changed files with 433 additions and 171 deletions

View file

@ -0,0 +1,27 @@
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
/// <summary>
/// The law of one gravity well: a 2D point mass with a softened core. Outside the core radius the potential is
/// logarithmic and the pull is c / r; inside it the pull falls linearly to zero at the centre and meets the tail in
/// value and slope at the core radius. c is the well's strength (u^2/s^2), R its core radius (u). No threshold
/// anywhere: a well pulls at every distance. Zone.GetForce and Zone.GetHeight sum this over a zone's bodies.
/// </summary>
public static class GravityField
{
// The potential at distance r from the body, defined up to a constant (callers subtract their reference).
public static float Potential(float c, float coreRadius, float r) =>
r < coreRadius
? c * r * r / (2 * coreRadius * coreRadius)
: c * (log(r / coreRadius) + .5f);
// The acceleration at `offsetFromBody`, toward the body: zero at the body itself.
public static float2 Acceleration(float c, float coreRadius, float2 offsetFromBody)
{
var r = length(offsetFromBody);
if (r == 0f) return float2.zero;
var pull = r < coreRadius ? c * r / (coreRadius * coreRadius) : c / r;
return -offsetFromBody / r * pull;
}
}

View file

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

View file

@ -17,11 +17,21 @@ public class PlanetSettings
public ExponentialCurve GravityRadius;
public ExponentialCurve LightRadius;
public ExponentialCurve BodyRadius;
// The closest a child may orbit its parent, in u, by the parent's mass. Authored like BodyRadius, never per body.
public ExponentialCurve RocheLimit = new ExponentialCurve();
// A body's well, by its mass (GravityField): its strength c in u^2/s^2 (the pull is c / r beyond the core) and its
// softened core radius in u, which must stay inside the RocheLimit curve.
public ExponentialCurve WellStrength = new ExponentialCurve();
public ExponentialCurve CoreRadius = new ExponentialCurve();
// The one global exaggeration of gravity height: height = potential / K, in u/s^2 per unit of height.
public float HeightExaggeration = 1f;
public float AsteroidVerticalOffset = -5f;
public ExponentialLerp AsteroidSize;
public ExponentialLerp AsteroidHitpoints;
public ExponentialLerp AsteroidRespawnTime;
public float GravityStrength;
public float SecureAreaRadiusMultiplier = .45f;
public ExponentialCurve OrbitPeriod;

View file

@ -130,9 +130,8 @@ public class Ship : Entity
Position.xz += Velocity * delta;
Velocity += Zone.GetForce(Position.xz) * delta;
var normal = Zone.GetNormal(Position.xz);
var force = new float2(normal.x, normal.z);
Velocity += Zone.GravityAcceleration(force, Zone.Settings.GravityStrength) * delta;
var shipRight = Direction.Rotate(ItemRotation.Clockwise);
var forward = cross(float3(shipRight.x, 0, shipRight.y), normal);
Rotation = quaternion.LookRotation(forward, normal);

View file

@ -617,17 +617,12 @@ public class Zone
var result = 0f;
foreach (var body in PlanetInstances.Values)
{
var p = position - body.Orbit.Position; //GetOrbitPosition(body.BodyData.Orbit)
var distSqr = lengthsq(p);
var gravityRadius = body.GravityWellRadius;
if (distSqr < gravityRadius*gravityRadius)
{
var depth = body.GravityWellDepth;
result -= PowerPulse(sqrt(distSqr) / gravityRadius, body.BodyData.GravityDepthExponent) * depth;
}
var offset = position - body.Orbit.Position;
result += GravityField.Potential(body.WellStrength, body.CoreRadius, length(offset))
- GravityField.Potential(body.WellStrength, body.CoreRadius, Pack.Radius);
}
return result;
return result / Settings.HeightExaggeration;
}
public float GetLight(float2 position)
@ -647,33 +642,21 @@ public class Zone
internal float SchematicCellSize => _itemManager.GameplaySettings.SchematicCellSize;
internal float FieldCrossSection(AsteroidBeltData belt) => _itemManager.ItemData.Get(belt.Kind)?.CrossSection ?? 0f;
// The one law of how a gravity well pushes anything: ships, floating items and mines all read it. The shape is
// the one ships have always felt, normalize(f) * strength * (1 / (1 - |f|^2) - 1), gated off below |f|^2 = .001.
// It is an acceleration, per second: the legacy law added that amount once per 60 fps frame with no dt, so
// GravityReferenceRate (60) restates it and `acceleration * dt` gives the legacy push at 60 fps.
public const float GravityReferenceRate = 60f;
public static float2 GravityAcceleration(float2 normalXz, float strength)
{
var magnitude = lengthsq(normalXz);
if (magnitude <= .001f) return float2.zero;
return normalize(normalXz) * strength * (1 / (1 - magnitude) - 1) * GravityReferenceRate;
}
// The acceleration the well exerts at `position`.
// The one source of gravitational acceleration (u/s^2): the sum of every body's well (GravityField). Ships,
// floating items, clouds and mines all read it, and so does Flow. It is the gradient of the potential GetHeight
// draws, so the two cannot disagree.
public float2 GetForce(float2 position)
{
var normal = GetNormal(position);
return GravityAcceleration(new float2(normal.x, normal.z), Settings.GravityStrength);
var force = float2.zero;
foreach (var body in PlanetInstances.Values)
force += GravityField.Acceleration(body.WellStrength, body.CoreRadius, position - body.Orbit.Position);
return force;
}
// The global flow velocity at `position` in zone time, in u/s (FlowField is the law). The slope term reads the
// downhill gradient, which GetNormal's xz is proportional to.
public float3 Flow(float3 position)
{
var normal = GetNormal(position.xz);
return FlowField.At(Settings.Flow, position, FlowScroll, new float2(normal.x, normal.z));
}
// downhill direction, which is the force.
public float3 Flow(float3 position) =>
FlowField.At(Settings.Flow, position, FlowScroll, GetForce(position.xz));
// The flow's noise scroll at zone time (Zone.Flow evaluates at exactly this); lowered to the shaders' _FlowScroll.
public float2 FlowScroll => FlowField.Scroll(Settings.Flow, _time);
@ -688,16 +671,12 @@ public class Zone
return pow((x + 1) * (1 - x), exponent);
}
public float3 GetNormal(float2 pos, float step = .1f, float mul = 1)
// The surface normal of the drawn height (potential / K), derived from the force. Display only (ship tilt): the
// sim never reads it as a force.
public float3 GetNormal(float2 pos)
{
float hL = GetHeight(new float2(pos.x - step, pos.y)) * mul;
float hR = GetHeight(new float2(pos.x + step, pos.y)) * mul;
float hD = GetHeight(new float2(pos.x, pos.y - step)) * mul;
float hU = GetHeight(new float2(pos.x, pos.y + step)) * mul;
// Deduce terrain normal
float3 normal = new float3((hL - hR), (hD - hU), step*2);
return normalize(normal).xzy;
var slope = GetForce(pos) / Settings.HeightExaggeration;
return normalize(new float3(slope.x, 1f, slope.y));
}
}
@ -706,10 +685,17 @@ public class Planet
public Orbit Orbit;
protected readonly PlanetSettings Settings;
public BodyData BodyData;
// Brush inputs only (ZoneRenderer's Gravity Well brushes and planet icon); no sim reader. grid-lowering deletes them.
public float GravityWellDepth;
public float GravityWellRadius;
public float BodyRadius;
// The well (GravityField): strength c and softened core radius R by mass, and the Roche limit, the closest a child
// may orbit. All three are authored PlanetSettings curves of mass; the core must sit inside the Roche limit.
public float WellStrength;
public float CoreRadius;
public float RocheLimit;
public Planet(PlanetSettings settings, BodyData data, Orbit orbit)
{
Settings = settings;
@ -723,6 +709,11 @@ public class Planet
BodyRadius = Settings.BodyRadius.Evaluate(BodyData.Mass) * BodyData.BodyRadiusMultiplier;
GravityWellRadius = Settings.GravityRadius.Evaluate(BodyData.Mass) * BodyData.GravityRadiusMultiplier;
GravityWellDepth = Settings.GravityDepth.Evaluate(BodyData.Mass) * BodyData.GravityDepthMultiplier;
WellStrength = Settings.WellStrength.Evaluate(BodyData.Mass);
CoreRadius = Settings.CoreRadius.Evaluate(BodyData.Mass);
RocheLimit = Settings.RocheLimit.Evaluate(BodyData.Mass);
if (WellStrength > 0 && !(0 < CoreRadius && CoreRadius < RocheLimit))
throw new InvalidOperationException("A body's core radius must be positive and inside its Roche limit (PlanetSettings.CoreRadius, RocheLimit).");
}
}

View file

@ -92,9 +92,6 @@ public abstract class BodyData
[JsonProperty("depthMul")] [Key(7)]
public float GravityDepthMultiplier = 1;
[JsonProperty("depthExp")] [Key(8)]
public float GravityDepthExponent = 16;
}
[CultDocument("aetheria.planetdata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)]