Draw every moving body at the drawn time T = ExactTime + Lead

Planets, belts, wells, loot, mines, stations, the chunk reticle and the flow
clock are evaluated through DrawAhead (Time, Orbit, Height, Position) at one
drawn time; the rail law lives once in Zone.Offset; OrbitPositionAt, GetHeight,
ChunkPose, EvaluateBelt and RideHeight take a time. Fixes the planet jumping
against a co-moving ship at display rates above the step rate.

Not yet built or run (Unity presenter files are verified by compile only on
Starfire).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-15 21:26:36 +02:00
commit b57207045d
14 changed files with 429 additions and 62 deletions

View file

@ -3,16 +3,29 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using CultMath;
using GameCult.Caching;
using static CultMath.math;
// Pure presentation statics over sim state, never called by ServerShared, with no state of their own: the sim owns every
// pose and no presenter stores one, a previous state or a velocity of its own. Ships are drawn where the frame falls Lead
// sim seconds after the last step, by their last step's velocity and turn, and ride the field at that drawn xz (a ship's height is the field's there, not
// the last step's). A ballistic round's flight is presentation
// pose and no presenter stores one, a previous state or a velocity of its own.
// The rule: a presenter draws every moving thing at the drawn time T = Zone.ExactTime + Clock.Lead. A pose the sim defines
// in closed form of time (an orbit, a belt chunk, a well, the field's height, the flow scroll) is that function evaluated
// at T; a pose the sim integrates (a ship, a kinematic body) is its last step plus its step velocity times Lead; a child
// is drawn where its parent is. DrawAhead.Time is the one definition of T. Ships ride the field at that drawn xz and T (a
// ship's height is the field's there, not the last step's). A ballistic round's flight is presentation
// (ruling projectile-flight-is-presentation), drawn from the sim's facts: its frozen line, the published outcome, the
// commit tick, the impact cell on the target's drawn pose, the burst point.
public static class DrawAhead
{
// The drawn time: the one definition of T.
public static double Time(Zone zone, float lead) => zone.ExactTime + lead;
// An orbit's node at the drawn time: the rail law at T, not the step's memo.
public static float2 Orbit(Zone zone, CultRecordKey orbit, float lead) => zone.OrbitPositionAt(orbit, Time(zone, lead));
// The field's height at a point at the drawn time, its wells where their rails put them at T.
public static float Height(Zone zone, float2 planar, float lead) => zone.GetHeight(planar, Time(zone, lead));
// A ship in a wormhole animation has its pose scripted by the step, not integrated from Velocity and TurnRate,
// so it is drawn where the step put it: extrapolating a scripted pose draws a streak that resets every step.
// A child is drawn where its parent is drawn, as the step copies the parent's pose onto it.
@ -20,8 +33,11 @@ public static class DrawAhead
{
if (entity is Ship { WormholeAnimationInProgress: true }) return entity.Position;
if (entity.Parent != null) return Position(entity.Parent, lead);
var planar = entity.Position.xz + entity.Velocity * lead;
return float3(planar.x, entity.RideHeight(planar), planar.y);
// A body on a rail (a station) is on its orbit at T: the rail is closed form, not integrated.
var planar = entity is OrbitalEntity { OrbitData: var orbit } && orbit.IsSet()
? Orbit(entity.Zone, orbit, lead)
: entity.Position.xz + entity.Velocity * lead;
return float3(planar.x, entity.RideHeight(planar, Time(entity.Zone, lead)), planar.y);
}
// A free body (a vapour cloud) drawn where the frame falls lead sim seconds after the last step, by the velocity

View file

@ -1975,8 +1975,10 @@ public abstract class Entity
}
// Where an unparented entity sits on the field at a planar point: the zone's height there plus the hull's grid offset.
// The step puts the entity here at its own xz, and the presenter at its drawn xz (DrawAhead).
public float RideHeight(float2 planar) => Zone.GetHeight(planar) + (ItemManager.GetData(Hull) as HullData).GridOffset;
// The step rides at its own time and xz, the presenter at the drawn time and its drawn xz (DrawAhead).
public float RideHeight(float2 planar, double time) => Zone.GetHeight(planar, time) + (ItemManager.GetData(Hull) as HullData).GridOffset;
public float RideHeight(float2 planar) => RideHeight(planar, Zone.ExactTime);
private void UpdateTemperature(float delta)
{

View file

@ -507,31 +507,57 @@ public class Zone
if (!_updatedOrbits.Contains(orbitID))
{
var orbit = Orbits[orbitID];
float2 pos = float2.zero;
// An infinite period (a parentless orbit, or one with no field to circle in) leaves the orbit at its phase:
// frac(t / inf) is 0.
if (orbit.Period > .01f)
{
var phase = (float) frac(_time / orbit.Period);
pos = OrbitData.Evaluate(frac(phase + orbit.Data.Phase)) * orbit.Data.Distance;
if (float.IsNaN(pos.x))
{
//_context.Log("Orbit position is NaN, something went very wrong!");
pos = float2.zero;
}
}
var parentPosition = !Orbits[orbitID].Data.Parent.IsSet()
? Orbits[orbitID].Data.FixedPosition :
var parentPosition = !orbit.Data.Parent.IsSet()
? orbit.Data.FixedPosition :
GetOrbitPosition(orbit.Data.Parent.Key);
Orbits[orbitID].Position = parentPosition + pos;
orbit.Position = parentPosition + Offset(orbit, _time);
_updatedOrbits.Add(orbitID);
}
return Orbits[orbitID].Position;
}
// The rail law, once: where an orbit sits from its parent's node at a time. An infinite period (a parentless orbit,
// or one with no field to circle in) leaves the orbit at its phase: frac(t / inf) is 0.
private static float2 Offset(Orbit orbit, double time)
{
var pos = float2.zero;
if (orbit.Period > .01f)
{
var phase = (float) frac(time / orbit.Period);
pos = OrbitData.Evaluate(frac(phase + orbit.Data.Phase)) * orbit.Data.Distance;
if (float.IsNaN(pos.x))
pos = float2.zero;
}
return pos;
}
// The same rail law at any time, with no memo read or written: a parent is its own rail at that time.
private float2 RailAt(CultRecordKey orbitID, double time)
{
if (!orbitID.IsSet() || !Orbits.TryGetValue(orbitID, out var orbit))
return float2.zero;
var parentPosition = !orbit.Data.Parent.IsSet() ? orbit.Data.FixedPosition : RailAt(orbit.Data.Parent.Key, time);
return parentPosition + Offset(orbit, time);
}
// Where an orbit is at a time: pure, writes nothing. At the step's own time it is the step's memo (GetOrbitPosition);
// at any other time (a presenter's drawn time) it is the rail law evaluated there.
public float2 OrbitPositionAt(CultRecordKey orbitID, double time)
{
if (!orbitID.IsSet())
return float2.zero;
if (!Orbits.ContainsKey(orbitID))
{
Log?.Invoke("Requested orbit is not part of this zone!");
return float2.zero;
}
return time == _time ? GetOrbitPosition(orbitID) : RailAt(orbitID, time);
}
// orbit -> the well-bearing body on it, and orbit -> the orbits whose parent it is. Derived from the pack in the
// constructor, read only by PeriodAround and Strength.
private readonly Dictionary<CultRecordKey, Planet> _wellOn = new Dictionary<CultRecordKey, Planet>();
@ -656,12 +682,15 @@ public class Zone
// Cut 1 (docs/mining-cut.md): a chunk's pose is a pure function of zone time, computed when asked. No stored
// pose survives a tick; the sim and the renderer both read it fresh through here.
public float4 ChunkPose(CultRecordKey belt, int index)
public float4 ChunkPose(CultRecordKey belt, int index) => ChunkPose(belt, index, _time);
// The same pose at any time: the sim reads it at _time, a presenter at the drawn time (DrawAhead.Time).
public float4 ChunkPose(CultRecordKey belt, int index, double time)
{
var beltData = Planets[belt] as AsteroidBeltData;
var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key);
var parentPosition = OrbitPositionAt(Orbits[beltData.Orbit.Key].Data.Parent.Key, time);
var size = ChunkRadius(new ChunkId(belt, index));
return AsteroidBelts[belt].Pose(index, _time, parentPosition, size);
return AsteroidBelts[belt].Pose(index, time, parentPosition, size);
}
// Mining Cut 3 (docs/mining-cut-refresh.md): how bright a chunk is -- Reflector's rule (cross-section × the light
@ -676,13 +705,13 @@ public class Zone
return kind.CrossSection * PI * cells * cells * GetLight(ChunkPose(chunk.Field, chunk.Index).xy);
}
public void EvaluateBelt(CultRecordKey belt, Span<float4> into)
public void EvaluateBelt(CultRecordKey belt, Span<float4> into, double time)
{
var beltData = Planets[belt] as AsteroidBeltData;
var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key);
var parentPosition = OrbitPositionAt(Orbits[beltData.Orbit.Key].Data.Parent.Key, time);
var beltInstance = AsteroidBelts[belt];
for (var i = 0; i < beltData.Asteroids.Length; i++)
into[i] = beltInstance.Pose(i, _time, parentPosition, ChunkRadius(new ChunkId(belt, i)));
into[i] = beltInstance.Pose(i, time, parentPosition, ChunkRadius(new ChunkId(belt, i)));
}
public SecurityLevel GetSecurityLevel(float2 pos)
@ -706,12 +735,16 @@ public class Zone
// reference. GetHeight sums with it and the renderer's lowering (GravityLowering) hands it to the shader unchanged.
public float WellReference(Planet body) => GravityField.Potential(body.WellStrength, body.RocheLimit, Pack.Radius);
public float GetHeight(float2 position)
public float GetHeight(float2 position) => GetHeight(position, _time);
// The field at a time: each well sits where its body's rail puts it then. The sim reads it at _time, a presenter at
// the drawn time, so a ship rides the same slope the drawn planet's well makes.
public float GetHeight(float2 position, double time)
{
var result = 0f;
foreach (var body in PlanetInstances.Values)
{
var offset = position - body.Orbit.Position;
var offset = position - OrbitPositionAt(body.BodyData.Orbit.Key, time);
result += GravityField.Potential(body.WellStrength, body.RocheLimit, length(offset)) - WellReference(body);
}