Merge gravity-drawn-pose-at-drawn-time

Operator flew 39ba73d5 on 2026-10-16: no jitter coasting beside bodies. Soul s1-s3; residuals in follow-up drawn-pose-residuals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-16 02:09:42 +02:00
commit 5a788833f0
21 changed files with 864 additions and 151 deletions

View file

@ -0,0 +1,212 @@
using System;
using System.Collections;
using System.IO;
using System.Linq;
using GameCult.Caching;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// Batch proof that the presenters read the drawn pose through the clock: DrawnPoseTests pins the pose maths headlessly,
// and this is what pins that each presenter passes ActionGameManager.Clock.Lead into it. A zone is loaded into a fresh
// ZoneRenderer with the pickup and mine prefabs ARPG.unity's ZoneRenderer names; a loose Projectile is launched on the
// zone. The zone is stepped once, then the clock is set to leads 0, 0.008 and 0.015 s with no step, and each presenter's
// own Update places its transform. A body is drawn at its last step plus its step velocity times the lead, spelled here
// from the body's fields, so a presenter that reads lead 0 (or half the lead) sits on the step and fails.
//
// Reached: ItemPickup, MineInstance, Projectile.Draw. Not reached: ZoneRenderer.Update (Lower needs a ComputeBuffer, a
// GPU device) and the ActionGameManager markers (they need the ARPG scene running); a Lower split so its record fill is
// pure, or a run with a GPU and ARPG.unity in play mode, would reach them.
//
// Unity -batchmode -nographics -projectPath <project> -executeMethod Aetheria.EditorTools.DrawnPoseWiringVerify.Run -logFile <log>
//
// Do not pass -quit: Run enters play mode (a domain reload), the check runs there, and the Editor exits itself, 0 on
// success and 1 on any failure.
namespace Aetheria.EditorTools
{
[InitializeOnLoad]
public static class DrawnPoseWiringVerify
{
private const string Tag = "[DrawnPoseWiringVerify]";
private const string Flag = "DrawnPoseWiringVerify.running";
private const string Wiring = "DrawnPoseWiringVerify.wiring";
private const double Budget = 300; // real seconds
private const float RoundSpeed = 100f, Tolerance = 1e-3f;
private static IEnumerator _check;
private static double _started;
private static string _work;
static DrawnPoseWiringVerify()
{
if (SessionState.GetBool(Flag, false)) EditorApplication.playModeStateChanged += OnPlayMode;
}
public static void Run()
{
try
{
SessionState.SetString(Wiring, ReadWiring());
SessionState.SetBool(Flag, true);
EditorApplication.EnterPlaymode();
}
catch (Exception exception)
{
Debug.LogException(exception);
EditorApplication.Exit(1);
}
}
private static void OnPlayMode(PlayModeStateChange change)
{
if (change != PlayModeStateChange.EnteredPlayMode) return;
_work = Path.Combine(Path.GetTempPath(), $"drawn-pose-wiring-{Guid.NewGuid():N}");
Directory.CreateDirectory(_work);
_started = Time.realtimeSinceStartupAsDouble;
_check = Check();
EditorApplication.update += Step;
}
private static void Step()
{
try
{
if (Time.realtimeSinceStartupAsDouble - _started > Budget) throw new InvalidOperationException($"The check did not finish in {Budget} seconds.");
if (_check.MoveNext()) return;
Finish(0);
}
catch (Exception exception)
{
Debug.LogException(exception);
Finish(1);
}
}
private static void Finish(int code)
{
EditorApplication.update -= Step;
SessionState.SetBool(Flag, false);
try { if (Directory.Exists(_work)) Directory.Delete(_work, true); } catch (Exception) { /* a temp directory */ }
EditorApplication.Exit(code);
}
private static void Require(bool condition, string what)
{
if (!condition) throw new InvalidOperationException(what);
}
private static IEnumerator Check()
{
var settings = AssetDatabase.LoadAssetAtPath<GameSettings>("Assets/Resources/Settings.asset")?.GameplaySettings
?? throw new InvalidOperationException("No Settings.asset.");
var wired = SessionState.GetString(Wiring, "").Split('|').Select(entry => entry.Split('=')).ToDictionary(pair => pair[0], pair => pair[1]);
var pickupPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(wired["SimpleCommodityPickup"])?.GetComponent<ItemPickup>();
var minePrefab = AssetDatabase.LoadAssetAtPath<GameObject>(wired["MinePrefab"])?.GetComponent<MineInstance>();
Require(pickupPrefab != null, "ZoneRenderer.SimpleCommodityPickup is not an ItemPickup in ARPG.unity.");
Require(minePrefab != null, "ZoneRenderer.MinePrefab is not a MineInstance in ARPG.unity.");
var oneCell = new Shape(1, 1);
oneCell[new CultMath.int2(0, 0)] = true;
var cache = AetheriaStores.Open(Path.Combine(_work, "wiring.cc"), catalogWritable: true);
cache.Upsert(new VerseGrammar { Revision = 1 });
cache.Upsert(new SimpleCommodityData { Name = "Wiring Ore", Shape = oneCell, MaxStack = 100 });
cache.FlushAsync().Wait();
var ledger = new ProvenanceLedger();
var items = new ItemManager(cache, ledger, settings, _ => { });
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), null, null);
var rootObject = new GameObject("Wiring Zone Root");
var rendererObject = new GameObject("Wiring Zone Renderer");
rendererObject.SetActive(false); // inactive: ZoneRenderer.Update needs a GPU and is not part of this check
var renderer = rendererObject.AddComponent<ZoneRenderer>();
renderer.ZoneRoot = rootObject.transform;
renderer.SimpleCommodityPickup = pickupPrefab;
renderer.MinePrefab = minePrefab;
renderer.ItemManager = items;
renderer.SectorBrushes = new GameObject("Wiring Brushes").transform;
renderer.SlimeGravityCamera = new GameObject("Wiring Gravity Camera").AddComponent<Camera>();
var slimeObject = new GameObject("Wiring Slime");
slimeObject.SetActive(false);
renderer.SlimeRenderer = slimeObject.AddComponent<Slime>();
renderer.LoadZone(zone);
var ore = new SimpleCommodity { Data = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<SimpleCommodityData>("Wiring Ore")), Quantity = 7 };
var floating = zone.Release(ore, new float2(3, 4), new float2(10, 0));
var mine = new Mine
{
Body = new KinematicBody { Position = new float2(-20, 8), Velocity = new float2(0, 14) },
ArmingDelay = 1e6f, FuseDelay = 1f, Lifetime = 1e6f, BlastRadius = 1f
};
zone.Lay(mine);
// A round on its frozen line, no target and no outcome known: drawn at FireOrigin + direction * speed * (T - FireTime).
var shot = new PendingShot
{
ShotId = 1, FireOrigin = new float3(0, 0, 0), FireTime = 0f, TravelDirection = new float2(1, 0), Speed = RoundSpeed,
MaxRange = 1e6f, FireRange = 1e6f, CommitTime = 1e5f, ArrivalTime = 2e5f
};
var projectile = new GameObject("Wiring Round").AddComponent<Projectile>();
projectile.Launch(zone, shot, new Vector3(0, 0, 0));
yield return null;
zone.Update(.5f);
var pickup = rootObject.GetComponentsInChildren<ItemPickup>(true).Single(p => p.Item == ore);
var mineInstance = UnityEngine.Object.FindObjectsByType<MineInstance>(FindObjectsInactive.Include, FindObjectsSortMode.None).Single();
var pickupVelocity = floating.Body.TotalVelocity;
var mineVelocity = mine.Body.TotalVelocity;
Require(Mathf.Abs(pickupVelocity.x) > 1f && Mathf.Abs(mineVelocity.y) > 1f, "The fixture's bodies stopped: a lead would not move them.");
Vector2 baseline = default;
foreach (var leadSeconds in new[] { 0f, .008f, .015f })
{
ActionGameManager.Clock.Reset();
if (leadSeconds > 0) ActionGameManager.Clock.Advance(leadSeconds, _ => throw new InvalidOperationException("a step ran"));
var lead = ActionGameManager.Clock.Lead;
Require(Mathf.Abs(lead - leadSeconds) < 1e-6f, $"The clock's lead is {lead}, not {leadSeconds}.");
yield return null;
var seen = pickup.transform.position;
var want = floating.Body.Position + pickupVelocity * lead;
Require(Mathf.Abs(seen.x - want.x) < Tolerance && Mathf.Abs(seen.z - want.y) < Tolerance,
$"ItemPickup at lead {lead}: drawn x/z {seen.x},{seen.z}, the step plus velocity times lead {want.x},{want.y}.");
seen = mineInstance.transform.position;
want = mine.Body.Position + mineVelocity * lead;
Require(Mathf.Abs(seen.x - want.x) < Tolerance && Mathf.Abs(seen.z - want.y) < Tolerance,
$"MineInstance at lead {lead}: drawn x/z {seen.x},{seen.z}, the step plus velocity times lead {want.x},{want.y}.");
// The round's line is a function of the drawn time: it moves RoundSpeed * lead along its direction between leads.
seen = projectile.transform.position;
if (leadSeconds == 0f) baseline = new Vector2(seen.x, seen.z);
var moved = new Vector2(seen.x, seen.z) - baseline;
Require(Mathf.Abs(moved.x - RoundSpeed * lead) < Tolerance && Mathf.Abs(moved.y) < Tolerance,
$"Projectile at lead {lead}: drawn {moved.x},{moved.y} past its lead-0 pose, expected {RoundSpeed * lead},0.");
}
ActionGameManager.Clock.Reset();
Debug.Log($"{Tag} ok: ItemPickup, MineInstance and Projectile draw at the clock's lead (0, 0.008, 0.015 s)");
}
// What ARPG.unity's ZoneRenderer names in the pickup and mine fields, read from the saved scene before the play session
// (a scene cannot be opened inside one) and carried across the domain reload as asset paths.
private static string ReadWiring()
{
var scene = EditorSceneManager.OpenScene("Assets/Scenes/ARPG.unity", OpenSceneMode.Additive);
try
{
var found = UnityEngine.Object.FindObjectsByType<ZoneRenderer>(FindObjectsInactive.Include, FindObjectsSortMode.None)
.Where(r => r.gameObject.scene == scene).ToArray();
Require(found.Length == 1, $"ARPG.unity has {found.Length} ZoneRenderers.");
var wired = found[0];
return string.Join("|", new (string, UnityEngine.Object)[]
{
("SimpleCommodityPickup", wired.SimpleCommodityPickup), ("MinePrefab", wired.MinePrefab)
}.Select(f => f.Item1 + "=" + (f.Item2 == null ? "" : AssetDatabase.GetAssetPath(f.Item2))));
}
finally { EditorSceneManager.CloseScene(scene, true); }
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c2270cf68a4af384eb715c8b5a55f5e8

View file

@ -157,8 +157,9 @@ public static class FloatingItemsDrawnSmoke
ItemPickup[] Pickups() => rootObject.GetComponentsInChildren<ItemPickup>(true).Where(p => p != null).ToArray();
void At(ItemPickup pickup, FloatingItem floating, string what)
{
var at = floating.Body.Position;
var want = new Vector3(at.x, zone.GetHeight(at), at.y);
var lead = ActionGameManager.Clock.Lead;
var at = DrawAhead.Position(floating.Body, lead);
var want = new Vector3(at.x, DrawAhead.Height(zone, at, lead), at.y);
Require(Vector3.Distance(pickup.transform.position, want) < 1e-3f,
$"{what}: the pickup is at {pickup.transform.position}, the body at {want}.");
}

View file

@ -71,7 +71,7 @@ namespace Aetheria.EditorTools
?? throw new InvalidOperationException("No Gravity Field.mat.");
var zone = BuildZone(settings, work, out var orbits);
GravityLowering.Lower(zone, ref wells);
GravityLowering.Lower(zone, 0f, ref wells);
field = GravityLowering.CreateField(material);
// Three frames through the one buffer, as ZoneRenderer.Update runs it: the bodies as built, then two moves
@ -80,11 +80,11 @@ namespace Aetheria.EditorTools
CheckFrame("built", zone, orbits);
orbits[1].Position = new float2(-420f, 560f);
orbits[2].Position = new float2(900f, -700f);
GravityLowering.Lower(zone, ref wells);
GravityLowering.Lower(zone, 0f, ref wells);
CheckFrame("moved", zone, orbits);
orbits[0].Position = new float2(210f, -95f);
orbits[2].Position = new float2(-1800f, 1300f);
GravityLowering.Lower(zone, ref wells);
GravityLowering.Lower(zone, 0f, ref wells);
CheckFrame("moved-again", zone, orbits);
Debug.Log($"{Tag} max error {_maxError:G4} u (tolerance {Tolerance:G4}); max gradient angle {_maxAngle:F2} degrees (limit {GradientDegrees:F0})");

View file

@ -336,7 +336,7 @@ public class ActionGameManager : MonoBehaviour
Input.Global.ZoneMap.performed += context =>
{
ToggleMenuTab(MenuTab.Map);
MenuMap.Position = CurrentEntity.Position.xz;
MenuMap.Position = DrawAhead.Position(CurrentEntity, Clock.Lead).xz;
};
Input.Global.Inventory.performed += context => ToggleMenuTab(MenuTab.Inventory);
@ -1178,10 +1178,12 @@ public class ActionGameManager : MonoBehaviour
}
}
// A chunk's simulated position is planar (Zone.ChunkPose); the renderer draws it at the asteroid layer's height.
// A chunk's pose is planar (Zone.ChunkPose), read at the drawn time so the reticle sits on the drawn rock; the renderer
// draws it at the asteroid layer's height.
private float3 ChunkWorldPosition(ChunkId chunk)
{
var pose = CurrentEntity.Zone.ChunkPose(chunk.Field, chunk.Index);
var zone = CurrentEntity.Zone;
var pose = zone.ChunkPose(chunk.Field, chunk.Index, DrawAhead.Time(zone, Clock.Lead));
return float3(pose.x, Settings.PlanetSettings.AsteroidVerticalOffset, pose.y);
}
@ -1314,13 +1316,13 @@ public class ActionGameManager : MonoBehaviour
foreach (var indicator in _visibleHostileIndicators)
{
indicator.Value.gameObject.SetActive(indicator.Key!=CurrentEntity.Target.Value.Entity);
indicator.Value.Place.Target = indicator.Key.Position.ToUnity();
indicator.Value.Place.Target = DrawAhead.Position(indicator.Key, Clock.Lead).ToUnity();
PlaceFill(indicator.Value, indicator.Key);
}
foreach (var indicator in _visibleFriendlyIndicators)
{
indicator.Value.gameObject.SetActive(indicator.Key!=CurrentEntity.Target.Value.Entity);
indicator.Value.Place.Target = indicator.Key.Position.ToUnity();
indicator.Value.Place.Target = DrawAhead.Position(indicator.Key, Clock.Lead).ToUnity();
PlaceFill(indicator.Value, indicator.Key);
}
var look = Input.Player.Look.ReadValue<Vector2>();
@ -1458,13 +1460,15 @@ public class ActionGameManager : MonoBehaviour
{
if (CurrentEntity == null || CurrentEntity.Parent != null) return;
var lead = Clock.Lead;
var ownDrawn = DrawAhead.Position(CurrentEntity, lead);
ViewDot.Target = ZoneRenderer.EntityInstances[CurrentEntity].LookAtPoint.position;
var target = CurrentEntity.Target.Value;
if (target.Chunk is ChunkId chunk)
TargetIndicator.Target = ChunkWorldPosition(chunk).ToUnity();
else if (target.Entity != null)
TargetIndicator.Target = target.Entity.Position.ToUnity();
var distance = length(ViewDot.Target.ToCultMath() - CurrentEntity.Position);
TargetIndicator.Target = DrawAhead.Position(target.Entity, lead).ToUnity();
var distance = length(ViewDot.Target.ToCultMath() - ownDrawn);
foreach (var (_, barrels, crosshair) in _articulationGroups)
{
var averagePosition = Vector3.zero;
@ -1482,7 +1486,7 @@ public class ActionGameManager : MonoBehaviour
indicator.gameObject.SetActive(showIndicator);
if(showIndicator)
{
indicator.Target = targetEntity.Position.ToUnity();
indicator.Target = DrawAhead.Position(targetEntity, lead).ToUnity();
ApplyLockProgress(indicator, spin, value);
}
}
@ -1499,8 +1503,7 @@ public class ActionGameManager : MonoBehaviour
if (strongest >= 0)
{
var warning = warnings[strongest];
incomingIndicator.Target = CurrentEntity.Position.ToUnity() +
new Vector3(warning.Bearing.x, 0, warning.Bearing.y) * IncomingLockIndicatorDistance;
incomingIndicator.Target = DrawAhead.AtBearing(CurrentEntity, warning.Bearing, IncomingLockIndicatorDistance, lead).ToUnity();
ApplyLockProgress(incomingIndicator, incomingSpin, warning.Strength);
}
@ -1531,7 +1534,7 @@ public class ActionGameManager : MonoBehaviour
}
marker.gameObject.SetActive(true);
var bearing = CurrentEntity.ContactOf(subject).Bearing;
marker.Place.Target = CurrentEntity.Position.ToUnity() + new Vector3(bearing.x, 0, bearing.y) * BearingMarkerDistance;
marker.Place.Target = DrawAhead.AtBearing(CurrentEntity, bearing, BearingMarkerDistance, Clock.Lead).ToUnity();
marker.ShowLabel(string.Join(", ", CurrentEntity.ClaimedClasses(subject).Select(c => c.Name)));
}
}
@ -1570,7 +1573,7 @@ public class ActionGameManager : MonoBehaviour
_sweepFlashes.RemoveAt(i);
continue;
}
indicator.Target = CurrentEntity.Position.ToUnity() + new Vector3(bearing.x, 0, bearing.y) * IncomingLockIndicatorDistance;
indicator.Target = DrawAhead.AtBearing(CurrentEntity, bearing, IncomingLockIndicatorDistance, Clock.Lead).ToUnity();
if (image != null) image.color = new Color(image.color.r, image.color.g, image.color.b, 1 - age / SearchFlashSeconds);
}
}

View file

@ -187,7 +187,7 @@ public class EntityInstance : MonoBehaviour
{
var pingInstance = Instantiate(PingPrefab);
var pingMesh = pingInstance.GetComponent<MeshRenderer>();
pingInstance.position = entity.Position.ToUnity();
pingInstance.position = DrawAhead.Position(entity, ActionGameManager.Clock.Lead).ToUnity();
_pingBrightness = pingMesh.material.GetFloat("_Depth");
_currentPing = (pingInstance, pingMesh);
};
@ -390,6 +390,9 @@ public class EntityInstance : MonoBehaviour
x.Value.material.SetFloat("_Emission", Entity.ItemManager.GameplaySettings.TemperatureEmissionCurve.Evaluate(x.Key.RadiatorTemperature));
}
// The drawn pose first: the camera's LookAt and the barrels read this frame's pose, not the last frame's.
var drawn = DrawAhead.Position(Entity, ActionGameManager.Clock.Lead);
LocalSpace.localPosition = transform.position = drawn.ToUnity();
var aimDistance = !Entity.Target.Value.IsNone
? max(Entity.TargetRange, Entity.ItemManager.GameplaySettings.ConvergenceMinimumDistance)
: 10000;
@ -402,8 +405,6 @@ public class EntityInstance : MonoBehaviour
var direction = FireControl.Solution(weapon, Entity, Entity.Target.Value).Direction;
group.Value.position = barrel + new Vector3(direction.x, 0, direction.y) * aimDistance;
}
var drawn = DrawAhead.Position(Entity, ActionGameManager.Clock.Lead);
LocalSpace.localPosition = transform.position = drawn.ToUnity();
if (_influenceInstance)
_influenceInstance.position = new Vector3(drawn.x, 0, drawn.z);
}

View file

@ -40,8 +40,9 @@ public class ItemPickup : MonoBehaviour
private void Place()
{
var p = _floating.Body.Position;
transform.position = new Vector3(p.x, _zone.GetHeight(p) + GridOffset, p.y);
var lead = ActionGameManager.Clock.Lead;
var p = DrawAhead.Position(_floating.Body, lead);
transform.position = new Vector3(p.x, DrawAhead.Height(_zone, p, lead) + GridOffset, p.y);
}
private void Update()

View file

@ -55,8 +55,9 @@ public class MineInstance : MonoBehaviour
private void Place()
{
var p = _mine.Body.Position;
transform.position = new Vector3(p.x, _zone.GetHeight(p) + GridOffset, p.y);
var lead = ActionGameManager.Clock.Lead;
var p = DrawAhead.Position(_mine.Body, lead);
transform.position = new Vector3(p.x, DrawAhead.Height(_zone, p, lead) + GridOffset, p.y);
}
// The mine left Zone.Mines, which only its blast does: show it where it was drawn last.

View file

@ -34,7 +34,7 @@ public class ShieldManager : MonoBehaviour, IAbsorbPresenter
{
if (contact.A != Entity && contact.B != Entity) return;
if (Entity.Shield != null && Entity.Shield.Item.Active.Value)
ShowHit(new Vector3(contact.Point.x, Entity.Position.y, contact.Point.y), CollisionHitDuration);
ShowHit(DrawAhead.Carried(Entity, float3(contact.Point.x, Entity.Position.y, contact.Point.y), ActionGameManager.Clock.Lead).ToUnity(), CollisionHitDuration);
}
private ShieldEnvelope _envelope;

View file

@ -77,7 +77,11 @@ public class GuidedProjectileManager : InstantWeaponEffectManager
p.Velocity = barrel.forward * weapon.Velocity;
p.Thrust = item.Evaluate(MunitionParts.Of<MunitionThrusterData>(item).Acceleration);
p.TopSpeed = item.Evaluate(MunitionParts.Of<MunitionThrusterData>(item).MaxSpeed);
p.TargetPosition = () => (source.Entity.Position + length(source.LookAtPoint.position.ToCultMath() - source.Entity.Position) * source.Entity.Aim).ToUnity();
p.TargetPosition = () =>
{
var drawn = DrawAhead.Position(source.Entity, ActionGameManager.Clock.Lead);
return (drawn + length(source.LookAtPoint.position.ToCultMath() - drawn) * source.Entity.Aim).ToUnity();
};
Bind(p, source.Entity.Zone, shotId);
}
else Debug.LogError($"Weapon {item.Data.Name} linked to {name} effect, but is not a Launcher!");

View file

@ -50,8 +50,9 @@ public class Projectile : MonoBehaviour
private void Draw()
{
var lead = ActionGameManager.Clock.Lead;
transform.position = V(DrawAhead.Round(_shot, _known, _barrel, BlendTime, _zone.Time, lead));
if (DrawAhead.RoundOver(_shot, _known, _zone.Time, lead)) Finish();
var drawn = DrawAhead.Time(_zone, lead);
transform.position = V(DrawAhead.Round(_shot, _known, _barrel, BlendTime, drawn));
if (DrawAhead.RoundOver(_shot, _known, drawn)) Finish();
}
// The sim's commit: the record it holds now (a contact burst's arrival is shortened here) and what it decided.

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,10 +33,39 @@ 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);
}
// The point `distance` out along a planar bearing from the ship's drawn position: where the HUD anchors a marker to the
// ship the camera follows (an incoming lock, a remembered contact, a ping's flash).
public static float3 AtBearing(Entity origin, float2 bearing, float distance, float lead)
{
var at = Position(origin, lead);
return float3(at.x + bearing.x * distance, at.y, at.z + bearing.y * distance);
}
// One well as the shader takes it: its body's rail at the drawn time, with the strength, core and reference the sim's
// GetHeight sums with. GravityLowering copies it into the shader's record and decides nothing.
public readonly struct DrawnWell
{
public readonly float2 Position;
public readonly float Strength, CoreRadius, Reference;
public DrawnWell(float2 position, float strength, float coreRadius, float reference)
{
Position = position;
Strength = strength;
CoreRadius = coreRadius;
Reference = reference;
}
}
public static DrawnWell Well(Zone zone, Planet body, float lead) =>
new DrawnWell(Orbit(zone, body.BodyData.Orbit.Key, lead), body.WellStrength, body.RocheLimit, zone.WellReference(body));
// A free body (a vapour cloud) drawn where the frame falls lead sim seconds after the last step, by the velocity
// that step moved it with: the launch velocity and the drift together.
public static float2 Position(KinematicBody body, float lead) => body.Position + body.TotalVelocity * lead;
@ -49,18 +91,18 @@ public static class DrawAhead
t.w * q.w - t.x * q.x - t.y * q.y - t.z * q.z));
}
// A ballistic round at a sim time (the clock's Zone.Time + Lead): on its frozen line while the outcome is unknown or a
// A ballistic round at the drawn time (DrawAhead.Time, passed in by the caller; the target's lead is that time less its zone's step time, derived here, never a second input): on its frozen line while the outcome is unknown or a
// miss, with the barrel's offset from that line decaying to nothing over blendTime sim seconds from the moment of firing.
// A round the sim published as a hit or burst steers from its line, from the commit tick, onto where it ends by the
// time damage lands: a burst's frozen point, else the impact cell on the target's drawn pose; and is held there after.
public static float3 Round(in PendingShot shot, ShotOutcome known, float3 barrel, float blendTime, float simTime, float lead)
public static float3 Round(in PendingShot shot, ShotOutcome known, float3 barrel, float blendTime, double drawnTime)
{
var time = simTime + lead;
var time = (float) drawnTime;
var drawn = Lifted(shot, Line(shot, known?.Result, time), barrel, blendTime, time);
if (known == null || known.Result == ShotResult.Miss) return drawn;
var end = known.HasBurstPoint
? float3(known.BurstPoint.x, shot.FireOrigin.y, known.BurstPoint.y)
: Impact(known.Target, known.Cell, lead);
: Impact(known.Target, known.Cell, (float) (drawnTime - known.Target.Zone.ExactTime));
var commit = shot.ArrivalTime - known.ArrivalIn;
var steer = shot.ArrivalTime > commit ? saturate((time - commit) / (shot.ArrivalTime - commit)) : 1f;
return drawn + (end - drawn) * steer;
@ -85,8 +127,8 @@ public static class DrawAhead
}
// Whether the drawn flight is over by time: only a published miss ends by the clock. A hit or burst ends on ShotResolved.
public static bool RoundOver(in PendingShot shot, ShotOutcome known, float simTime, float lead) =>
known != null && known.Result == ShotResult.Miss && simTime + lead >= RoundEnd(shot, ShotResult.Miss);
public static bool RoundOver(in PendingShot shot, ShotOutcome known, double drawnTime) =>
known != null && known.Result == ShotResult.Miss && (float) drawnTime >= RoundEnd(shot, ShotResult.Miss);
// The sim time a round's drawn line ends, for what is known of its outcome. A round that hit or burst (or whose outcome
// is not yet published) ends at its ArrivalTime; a round the sim has published as a Miss flies on to the weapon's frozen

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

@ -506,32 +506,59 @@ 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 :
GetOrbitPosition(orbit.Data.Parent.Key);
Orbits[orbitID].Position = parentPosition + pos;
Orbits[orbitID].Position = RailAt(orbitID, _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())
return float2.zero;
if (!Orbits.TryGetValue(orbitID, out var orbit))
{
Log?.Invoke("Requested orbit is not part of this zone!");
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 +683,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 +706,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 +736,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);
}

View file

@ -32,9 +32,10 @@ public static class GravityLowering
/// <summary>
/// Fills one record per body of the zone (a null zone has none) and sets the shader globals. The buffer is the
/// caller's, (re)allocated to the body count (at least one, so the global is always bound).
/// caller's, (re)allocated to the body count (at least one, so the global is always bound). Wells are lowered at the
/// drawn time (DrawAhead.Time), the same T the planets and a ship's ride height use.
/// </summary>
public static void Lower(Zone zone, ref ComputeBuffer wells)
public static void Lower(Zone zone, float lead, ref ComputeBuffer wells)
{
var count = zone?.PlanetInstances.Count ?? 0;
var capacity = Mathf.Max(count, 1);
@ -48,13 +49,16 @@ public static class GravityLowering
var i = 0;
if (zone != null)
foreach (var body in zone.PlanetInstances.Values)
{
var well = DrawAhead.Well(zone, body, lead);
_records[i++] = new WellRecord
{
Position = new Vector2(body.Orbit.Position.x, body.Orbit.Position.y),
Strength = body.WellStrength,
CoreRadius = body.RocheLimit,
Reference = zone.WellReference(body)
Position = new Vector2(well.Position.x, well.Position.y),
Strength = well.Strength,
CoreRadius = well.CoreRadius,
Reference = well.Reference
};
}
wells.SetData(_records);
Shader.SetGlobalBuffer(WellsId, wells);

View file

@ -106,10 +106,12 @@ public class VolumeSampling : MonoBehaviour
static GlobalKeyword KeywordFlowSlope => _keywordFlowSlope ??= GlobalKeyword.Create("FLOW_SLOPE");
/// <summary>
/// The time the flow runs on: the zone's own (so drift stops with the sim and follows sim speed), or on menus,
/// where no zone exists, the presentation clock.
/// The time the flow runs on: the zone's drawn time (DrawAhead.Time, so drift stops with the sim and follows sim
/// speed), or on menus, where no zone exists, the presentation clock.
/// </summary>
public static double FlowClock => ActionGameManager.Instance?.Zone?.ExactTime ?? Time.timeSinceLevelLoad;
public static double FlowClock => ActionGameManager.Instance?.Zone is { } zone
? DrawAhead.Time(zone, ActionGameManager.Clock.Lead)
: Time.timeSinceLevelLoad;
/// <summary>
/// The one place that sets the flow uniforms and keywords: every shader flow term derives from FlowSettings and

View file

@ -520,10 +520,11 @@ public class ZoneRenderer : MonoBehaviour
void Update()
{
var maxDepth = 0f;
GravityLowering.Lower(Zone, ref _gravityWells);
var lead = ActionGameManager.Clock.Lead;
GravityLowering.Lower(Zone, lead, ref _gravityWells);
foreach (var loot in _floatingItems.Values)
{
loot.ViewOrigin = PerspectiveEntity.Position;
loot.ViewOrigin = DrawAhead.Position(PerspectiveEntity, lead);
loot.ViewDirection = PerspectiveEntity.Aim;
}
@ -542,11 +543,11 @@ public class ZoneRenderer : MonoBehaviour
foreach (var (key, belt) in Zone.AsteroidBelts)
{
// Cut 1 (docs/mining-cut.md): belts have no stored pose any more. The renderer fills its own buffer
// from Zone.EvaluateBelt, at the current zone time, every frame.
var orbitPosition = Zone.GetOrbitPosition(Zone.Orbits[belt.Data.Orbit.Key].Data.Parent.Key);
var height = Zone.GetHeight(orbitPosition);
// from Zone.EvaluateBelt, at the drawn time (DrawAhead.Time), every frame.
var orbitPosition = DrawAhead.Orbit(Zone, Zone.Orbits[belt.Data.Orbit.Key].Data.Parent.Key, lead);
var height = DrawAhead.Height(Zone, orbitPosition, lead);
var transforms = _beltTransforms[key];
Zone.EvaluateBelt(key, transforms);
Zone.EvaluateBelt(key, transforms, DrawAhead.Time(Zone, lead));
if(isVisible(new Bounds(
new Vector3(orbitPosition.x,height,orbitPosition.y),
new Vector3(belt.Radius * 2,100,belt.Radius * 2))))
@ -579,8 +580,8 @@ public class ZoneRenderer : MonoBehaviour
foreach (var planet in Planets)
{
var planetInstance = Zone.PlanetInstances[planet.Key];
var p = Zone.GetOrbitPosition(planetInstance.BodyData.Orbit.Key);
var height = Zone.GetHeight(p);
var p = DrawAhead.Orbit(Zone, planetInstance.BodyData.Orbit.Key, lead);
var height = DrawAhead.Height(Zone, p, lead);
if (-height > maxDepth) maxDepth = -height;
planet.Value.transform.position = new Vector3(p.x, 0, p.y);
planet.Value.Body.transform.localPosition = new Vector3(0, height + planetInstance.BodyRadius * 2, 0);
@ -589,7 +590,7 @@ public class ZoneRenderer : MonoBehaviour
{
if (!(planet.Value is SunObject))
{
var toParent = normalize(Zone.GetOrbitPosition(Zone.Orbits[planetInstance.BodyData.Orbit.Key].Data.Parent.Key) - p);
var toParent = normalize(DrawAhead.Orbit(Zone, Zone.Orbits[planetInstance.BodyData.Orbit.Key].Data.Parent.Key, lead) - p);
gasGiantObject.SunMaterial.LightingDirection = new Vector3(toParent.x, 0, toParent.y);
}
}
@ -600,7 +601,7 @@ public class ZoneRenderer : MonoBehaviour
{
if(entityInstance.CompassIcon)
{
var difference = entityInstance.Entity.Position.xz - PerspectiveEntity.Position.xz;
var difference = DrawAhead.Position(entityInstance.Entity, lead).xz - DrawAhead.Position(PerspectiveEntity, lead).xz;
var distance = length(difference);
entityInstance.CompassIcon.gameObject.SetActive(
@ -612,7 +613,7 @@ public class ZoneRenderer : MonoBehaviour
foreach (var wormhole in WormholeInstances.Values)
{
var difference = wormhole.gravity.transform.position.Flatland() - PerspectiveEntity.Position.xz.ToUnity();
var difference = wormhole.gravity.transform.position.Flatland() - DrawAhead.Position(PerspectiveEntity, lead).xz.ToUnity();
var distance = difference.magnitude;
wormhole.icon.gameObject.SetActive(distance > _minimapDistance);
wormhole.icon.transform.rotation = Quaternion.Euler(0, 0, Mathf.Atan2(difference.y, difference.x) * Mathf.Rad2Deg - 90);
@ -716,7 +717,7 @@ public class AsteroidBeltUI
public void Update(float4[] transforms, float height)
{
var parentPosition = _zone.GetOrbitPosition(_orbitParent);
var parentPosition = DrawAhead.Orbit(_zone, _orbitParent, ActionGameManager.Clock.Lead);
for (var i = 0; i < transforms.Length; i++)
{
var transform = transforms[i];

View file

@ -67,28 +67,28 @@ public sealed partial class FireControlCut124Tests
// The shooter moves on after the round left: the line is the one frozen at Fire, not one drawn from where it is now.
e.Shooter.Position += float3(7, 0, 3);
var start = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime, 0f);
var start = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime);
Assert.Equal(origin.x, start.x, 3);
Assert.Equal(origin.z, start.z, 3);
var second = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime + 1f, 0f);
var second = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime + 1f);
Assert.Equal(origin.x + aim.x * BallisticSpeed, second.x, 3);
Assert.Equal(origin.z + aim.y * BallisticSpeed, second.z, 3);
foreach (var known in new[] { null, outcome })
foreach (var later in new[] { 0f, 1f, 50f })
{
var arrival = DrawAhead.Round(burst, known, burst.FireOrigin, 0f, burst.ArrivalTime + later, 0f);
var arrival = DrawAhead.Round(burst, known, burst.FireOrigin, 0f, burst.ArrivalTime + later);
Assert.Equal(burst.BurstPosition.x, arrival.x, 3);
Assert.Equal(burst.BurstPosition.z, arrival.z, 3);
}
// Past the weapon's range it is held at the range point, however late it is asked.
var held = DrawAhead.Round(burst, Known(burst, ShotResult.Miss), burst.FireOrigin, 0f, burst.FireTime + NoLockRange / BallisticSpeed + 50f, 0f);
var held = DrawAhead.Round(burst, Known(burst, ShotResult.Miss), burst.FireOrigin, 0f, burst.FireTime + NoLockRange / BallisticSpeed + 50f);
Assert.Equal(origin.x + aim.x * NoLockRange, held.x, 3);
Assert.Equal(origin.z + aim.y * NoLockRange, held.z, 3);
// Before it was fired it is still at the origin, not behind it.
var before = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime - 5f, 0f);
var before = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime - 5f);
Assert.Equal(origin.x, before.x, 3);
Assert.Equal(origin.z, before.z, 3);
@ -100,12 +100,12 @@ public sealed partial class FireControlCut124Tests
// A round not known to miss waits at its arrival point however late it is asked.
foreach (var at in new[] { direct.ArrivalTime, direct.FireTime + 99f })
{
var waiting = DrawAhead.Round(direct, null, direct.FireOrigin, 0f, at, 0f);
var waiting = DrawAhead.Round(direct, null, direct.FireOrigin, 0f, at);
Assert.Equal(d.Target.Position.x, waiting.x, 2);
Assert.Equal(d.Target.Position.z, waiting.z, 2);
}
Assert.Equal(dOrigin.z + 100f / 2f, DrawAhead.Round(direct, null, direct.FireOrigin, 0f, direct.FireTime + 2.5f, 0f).z, 2);
var heldDirect = DrawAhead.Round(direct, Known(direct, ShotResult.Miss), direct.FireOrigin, 0f, direct.FireTime + 1000f / BallisticSpeed + 99f, 0f);
Assert.Equal(dOrigin.z + 100f / 2f, DrawAhead.Round(direct, null, direct.FireOrigin, 0f, direct.FireTime + 2.5f).z, 2);
var heldDirect = DrawAhead.Round(direct, Known(direct, ShotResult.Miss), direct.FireOrigin, 0f, direct.FireTime + 1000f / BallisticSpeed + 99f);
Assert.Equal(dOrigin.z + 1000f, heldDirect.z, 2);
}
@ -119,7 +119,7 @@ public sealed partial class FireControlCut124Tests
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var shot = SafeAssert.OnlyShot(e.Zone);
var late = DrawAhead.Round(shot, Known(shot, ShotResult.Miss), shot.FireOrigin, 0f, shot.FireTime + 5000f, 0f);
var late = DrawAhead.Round(shot, Known(shot, ShotResult.Miss), shot.FireOrigin, 0f, shot.FireTime + 5000f);
Assert.Equal(origin.x, late.x, 3);
Assert.Equal(origin.z, late.z, 3);
}
@ -139,7 +139,7 @@ public sealed partial class FireControlCut124Tests
var after = SafeAssert.OnlyShot(e.Zone);
Assert.Equal(BallisticSpeed, after.Speed, 4);
var aim = normalize(float2(2, 1));
var at = DrawAhead.Round(after, null, after.FireOrigin, 0f, after.FireTime + 1f, 0f);
var at = DrawAhead.Round(after, null, after.FireOrigin, 0f, after.FireTime + 1f);
Assert.Equal(origin.x + aim.x * BallisticSpeed, at.x, 3);
Assert.Equal(origin.z + aim.y * BallisticSpeed, at.z, 3);
}
@ -164,8 +164,8 @@ public sealed partial class FireControlCut124Tests
for (var i = 0; i < 25; i++)
{
var at = shot.FireTime + i * .37f;
DrawAhead.Round(shot, known, barrel, .1f, at, .01f);
DrawAhead.RoundOver(shot, known, at, .01f);
DrawAhead.Round(shot, known, barrel, .1f, at);
DrawAhead.RoundOver(shot, known, at);
DrawAhead.RoundEnd(shot, known?.Result);
DrawAhead.Impact(e.Target, cell, .01f * i);
}
@ -198,7 +198,7 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(NoLockRange, after.MaxRange, 4);
Assert.Equal(endBefore, DrawAhead.RoundEnd(after, ShotResult.Miss), 4);
var aim = normalize(float2(2, 1));
var held = DrawAhead.Round(after, Known(after, ShotResult.Miss), after.FireOrigin, 0f, after.FireTime + 1000f, 0f);
var held = DrawAhead.Round(after, Known(after, ShotResult.Miss), after.FireOrigin, 0f, after.FireTime + 1000f);
Assert.Equal(origin.x + aim.x * NoLockRange, held.x, 3);
Assert.Equal(origin.z + aim.y * NoLockRange, held.z, 3);
}
@ -232,12 +232,12 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(direct.FireTime + 100f / speed, DrawAhead.RoundEnd(direct, ShotResult.Burst), 4);
Assert.Equal(direct.FireTime + 1000f / speed, DrawAhead.RoundEnd(direct, ShotResult.Miss), 4);
Assert.False(DrawAhead.RoundOver(direct, null, direct.FireTime + 10000f, 0f));
Assert.False(DrawAhead.RoundOver(direct, null, direct.FireTime + 10000f));
foreach (var result in new[] { ShotResult.Hit, ShotResult.Burst })
Assert.False(DrawAhead.RoundOver(direct, Known(direct, result), direct.FireTime + 10000f, 0f));
Assert.False(DrawAhead.RoundOver(direct, Known(direct, result), direct.FireTime + 10000f));
var missEnd = direct.FireTime + 1000f / speed;
Assert.False(DrawAhead.RoundOver(direct, Known(direct, ShotResult.Miss), missEnd - .01f, 0f));
Assert.True(DrawAhead.RoundOver(direct, Known(direct, ShotResult.Miss), missEnd, 0f));
Assert.False(DrawAhead.RoundOver(direct, Known(direct, ShotResult.Miss), missEnd - .01f));
Assert.True(DrawAhead.RoundOver(direct, Known(direct, ShotResult.Miss), missEnd));
// A target beyond the weapon's range: a miss never ends short of its own arrival.
var far = FireDirectRound(speed, 100f, targetRange: 150f);
@ -269,17 +269,17 @@ public sealed partial class FireControlCut124Tests
foreach (var lead in new[] { 0f, step / 2f, .999f * step })
{
var waiting = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, shot.ArrivalTime, lead);
var waiting = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, shot.ArrivalTime + lead);
Assert.Equal(origin.x, waiting.x, 3);
Assert.Equal(origin.z + 100f, waiting.z, 3);
var missed = DrawAhead.Round(shot, Known(shot, ShotResult.Miss), shot.FireOrigin, 0f, shot.ArrivalTime, lead);
var missed = DrawAhead.Round(shot, Known(shot, ShotResult.Miss), shot.FireOrigin, 0f, shot.ArrivalTime + lead);
Assert.Equal(origin.x, missed.x, 3);
Assert.Equal(origin.z + 100f + lead * speed, missed.z, 3);
}
for (var time = shot.FireTime; time <= shot.ArrivalTime + 1f; time += 1f / 144f)
{
var drawn = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, time, 0f);
var drawn = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, time);
Assert.True(drawn.z - origin.z <= 100f + 1e-3f, "the round was drawn past its arrival point");
}
}
@ -315,7 +315,7 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(fired.FireTime + 1.05f, record.ArrivalTime, 2);
Assert.True(record.ArrivalTime < fired.ArrivalTime);
var stop = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime, 0f);
var stop = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime);
var expected = outcome.HasBurstPoint
? outcome.BurstPoint
: record.FireOrigin.xz + record.TravelDirection * (20f * (record.ArrivalTime - record.FireTime));
@ -323,7 +323,7 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(expected.y, stop.z, 3);
foreach (var later in new[] { 0f, .5f, 5f })
{
var drawn = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime + later, 0f);
var drawn = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime + later);
Assert.Equal(expected.x, drawn.x, 3);
Assert.Equal(expected.y, drawn.z, 3);
}
@ -350,11 +350,11 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(want.z, got.z, 3);
}
Close(barrel, DrawAhead.Round(shot, null, barrel, blend, shot.FireTime, 0f));
Close(Line(blend / 2f) + offset * .5f, DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + blend / 2f, 0f));
Close(Line(blend), DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + blend, 0f));
Close(Line(2f * blend), DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + 2f * blend, 0f));
Close(Line(0f), DrawAhead.Round(shot, null, barrel, 0f, shot.FireTime, 0f));
Close(barrel, DrawAhead.Round(shot, null, barrel, blend, shot.FireTime));
Close(Line(blend / 2f) + offset * .5f, DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + blend / 2f));
Close(Line(blend), DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + blend));
Close(Line(2f * blend), DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + 2f * blend));
Close(Line(0f), DrawAhead.Round(shot, null, barrel, 0f, shot.FireTime));
}
// Placement depends on sim time alone: any history of frames walked to a checkpoint draws what a fresh call draws, which
@ -379,18 +379,10 @@ public sealed partial class FireControlCut124Tests
while (elapsed + 1e-4f < checkpoint)
{
elapsed = min(checkpoint, elapsed + frames[frame++ % frames.Length]);
DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + elapsed, 0f);
}
var drawn = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint, 0f);
var fresh = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint, 0f);
// A frame that falls `lead` after the step draws what the sim time lead later would.
foreach (var lead in new[] { 1f / 60f, 1f / 90f })
{
var ahead = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint - lead, lead);
Assert.Equal(fresh.x, ahead.x, 3);
Assert.Equal(fresh.y, ahead.y, 3);
Assert.Equal(fresh.z, ahead.z, 3);
DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + elapsed);
}
var drawn = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint);
var fresh = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint);
Assert.Equal(fresh.z, drawn.z, 3);
Assert.Equal(shot.FireOrigin.z + speed * min(checkpoint, 1000f / speed), drawn.z, 3);
Assert.Equal(shot.FireOrigin.x, drawn.x, 3);
@ -420,11 +412,13 @@ public sealed partial class FireControlCut124Tests
e.Target.TurnRate = 3f;
var cell = FarCell(e);
var hit = Hit(shot, e.Target, cell, shot.CommitTime);
var arrivalOnTheLine = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, shot.ArrivalTime, 0f);
var arrivalOnTheLine = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, shot.ArrivalTime);
var cellSize = e.Items.GameplaySettings.SchematicCellSize;
foreach (var lead in new[] { 0f, step / 2f, .999f * step })
// The drawn time is the only input: the target's lead is that time less its zone's step time.
foreach (var drawnTime in new[] { shot.ArrivalTime, shot.ArrivalTime + step / 2f, shot.ArrivalTime + .5f })
{
var lead = (float) (drawnTime - e.Zone.ExactTime);
var position = e.Target.Position;
var direction = e.Target.Direction;
e.Target.Position += float3(e.Target.Velocity.x, 0, e.Target.Velocity.y) * lead;
@ -439,13 +433,10 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(expected.x, impact.x, 3);
Assert.Equal(expected.y, impact.y, 3);
Assert.Equal(expected.z, impact.z, 3);
foreach (var simTime in new[] { shot.ArrivalTime, shot.ArrivalTime + .5f })
{
var drawn = DrawAhead.Round(shot, hit, shot.FireOrigin, .1f, simTime, lead);
Assert.Equal(expected.x, drawn.x, 3);
Assert.Equal(expected.y, drawn.y, 3);
Assert.Equal(expected.z, drawn.z, 3);
}
var drawn = DrawAhead.Round(shot, hit, shot.FireOrigin, .1f, drawnTime);
Assert.Equal(expected.x, drawn.x, 3);
Assert.Equal(expected.y, drawn.y, 3);
Assert.Equal(expected.z, drawn.z, 3);
}
}
@ -464,26 +455,21 @@ public sealed partial class FireControlCut124Tests
foreach (var t in new[] { commit - 1f, commit })
{
var steered = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, t, 0f);
var line = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, t, 0f);
var steered = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, t);
var line = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, t);
Assert.Equal(line.x, steered.x, 3);
Assert.Equal(line.y, steered.y, 3);
Assert.Equal(line.z, steered.z, 3);
}
var m = commit + 1f;
var onTheLine = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, m, 0f);
var end = DrawAhead.Impact(e.Target, cell, 0f);
var onTheLine = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, m);
var end = DrawAhead.Impact(e.Target, cell, (float) (m - e.Zone.ExactTime));
Assert.True(length((end - onTheLine).xz) > 1f, "the fixture put the impact cell on the line");
var halfway = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, m, 0f);
var halfway = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, m);
Assert.Equal(onTheLine.x + (end.x - onTheLine.x) * .5f, halfway.x, 3);
Assert.Equal(onTheLine.y + (end.y - onTheLine.y) * .5f, halfway.y, 3);
Assert.Equal(onTheLine.z + (end.z - onTheLine.z) * .5f, halfway.z, 3);
var ahead = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, m - .02f, .02f);
Assert.Equal(halfway.x, ahead.x, 3);
Assert.Equal(halfway.y, ahead.y, 3);
Assert.Equal(halfway.z, ahead.z, 3);
}
// The sim states facts and draws nothing: no drawing function, and no lead, blend or barrel, lives in FireControl;
@ -526,8 +512,8 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(origin.x, outcome.BurstPoint.x, 3);
Assert.Equal(origin.z + NoLockRange, outcome.BurstPoint.y, 3);
Assert.Equal(origin.z + 50f, DrawAhead.Round(record, null, record.FireOrigin, 0f, record.ArrivalTime, 0f).z, 3);
var stop = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime, 0f);
Assert.Equal(origin.z + 50f, DrawAhead.Round(record, null, record.FireOrigin, 0f, record.ArrivalTime).z, 3);
var stop = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime);
Assert.Equal(outcome.BurstPoint.x, stop.x, 3);
Assert.Equal(outcome.BurstPoint.y, stop.z, 3);
}

View file

@ -0,0 +1,416 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// Every drawn pose is evaluated at one drawn time, T = Zone.ExactTime + Clock.Lead (DrawAhead.Time). The operator's report:
// coasting beside a planet and looking at it, the planet jumped back and forth every frame, because the planet was drawn at
// the last step's orbit while the ship was drawn a lead ahead of its last step. Each rail below is spelled here from the
// field law outside the core (a = c / d, so T = 2 pi d / sqrt(c)), never by calling the production rail; the zone is built
// by the production constructor and stepped by Zone.Update, and the clock is the production SimClock.
public sealed class DrawnPoseTests : IDisposable
{
// Well strength is the body's mass (c = mass), with a small constant core, so every period is spelled from masses
// chosen here. The sun's c = 400 puts its planet's rail speed at sqrt(c) = 20 u/s at any distance.
private const float SunMass = 400f, PlanetMass = 25f, MoonMass = 4f;
private const float PlanetDistance = 1000f, PlanetPhase = .3f;
private const float MoonDistance = 60f, MoonPhase = .1f;
private const float StationDistance = 3f, StationPhase = .4f;
private const float GridOffset = 2.5f;
// The sun's rail is parentless and off-centre, the shape ZoneGenerator gives a cluster root (FixedPosition = cluster centre).
private static readonly float2 SunCentre = new float2(700f, -300f);
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-drawnpose-" + Guid.NewGuid().ToString("N"));
private readonly List<CultCache> _caches = new List<CultCache>();
public DrawnPoseTests() => Directory.CreateDirectory(_root);
public void Dispose()
{
foreach (var cache in _caches) cache.Dispose();
Directory.Delete(_root, true);
}
private sealed class World
{
public Zone Zone;
public ItemManager Items;
public EquippableItem Hull;
public CultRecordKey PlanetOrbit, MoonOrbit, StationOrbit, Belt;
public Asteroid[] Asteroids;
public float HeightExaggeration => Zone.Settings.HeightExaggeration;
}
private static PlanetSettings Curves() => new PlanetSettings
{
BodyRadius = new ExponentialCurve(),
WellStrength = new ExponentialCurve { Multiplier = 1f, Exponent = 1f },
RocheLimit = new ExponentialCurve { Constant = 2f },
AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f },
AsteroidHitpoints = new ExponentialLerp { Minimum = 10f, Maximum = 300f, Exponent = 2f },
AsteroidRespawnTime = new ExponentialLerp { Minimum = 5f, Maximum = 60f, Exponent = 1f }
};
// A sun on a parentless orbit, a planet round it, a moon round the planet, a bodyless station rail close round the planet,
// and a belt round the planet; a hull to ride the field with.
private World Build()
{
var dir = Path.Combine(_root, $"world{_caches.Count}");
Directory.CreateDirectory(dir);
var catalog = Path.Combine(dir, "Aetheria.cc");
var shape = new Shape(3, 3);
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
using (var build = AetheriaStores.Open(catalog, catalogWritable: true))
{
build.Upsert(new VerseGrammar { Revision = 1 });
build.Upsert(new HullData { Name = "TestHull", HullType = HullType.Ship, Shape = shape, Durability = 1, Mass = 1000, GridOffset = GridOffset });
build.FlushAsync().Wait();
}
var cache = AetheriaStores.Open(catalog, Path.Combine(dir, "run.cc"), catalogWritable: true);
_caches.Add(cache);
var ledger = new ProvenanceLedger();
ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 };
var items = new ItemManager(cache, ledger, new GameplaySettings
{
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp()
}, _ => { });
CultRecordKey Orbit(CultRecordKey parent, float distance, float phase, float2 fixedPosition = default)
{
var data = new OrbitData { Distance = distance, Phase = phase, FixedPosition = fixedPosition };
if (parent.IsSet()) data.Parent = new CultRecordRef<OrbitData>(parent);
return cache.Upsert(data).Key;
}
CultRecordKey Body(CultRecordKey orbit, string name, float mass) =>
cache.Upsert(new PlanetData { Name = name, Orbit = new CultRecordRef<OrbitData>(orbit), Mass = mass }).Key;
var sunOrbit = Orbit(default, 0f, 0f, SunCentre);
var planetOrbit = Orbit(sunOrbit, PlanetDistance, PlanetPhase);
var moonOrbit = Orbit(planetOrbit, MoonDistance, MoonPhase);
var stationOrbit = Orbit(planetOrbit, StationDistance, StationPhase);
var beltOrbit = Orbit(planetOrbit, 400f, .42f);
var asteroids = Enumerable.Range(0, 6).Select(i => new Asteroid
{
Distance = 120f + i * 11.3f, Phase = frac(i * .6180339887f), Size = frac(i * .37f + .1f), RotationSpeed = .15f + i * .037f
}).ToArray();
var belt = cache.Upsert(new AsteroidBeltData { Orbit = new CultRecordRef<OrbitData>(beltOrbit), Asteroids = asteroids }).Key;
var pack = new ZonePack { Radius = 5000f, Mass = 10000f, Time = 0 };
foreach (var orbit in new[] { sunOrbit, planetOrbit, moonOrbit, stationOrbit, beltOrbit })
pack.Orbits.Add(new CultRecordRef<OrbitData>(orbit));
foreach (var body in new[] { Body(sunOrbit, "Sun", SunMass), Body(planetOrbit, "Planet", PlanetMass), Body(moonOrbit, "Moon", MoonMass), belt })
pack.Planets.Add(new CultRecordRef<BodyData>(body));
var zone = new Zone(items, Curves(), pack, new GalaxyZone { Name = "DrawnPose", Owner = null }, null);
var hull = new EquippableItem { Data = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("TestHull")), Durability = 1, Lot = 1 };
return new World { Zone = zone, Items = items, Hull = hull, PlanetOrbit = planetOrbit, MoonOrbit = moonOrbit, StationOrbit = stationOrbit, Belt = belt, Asteroids = asteroids };
}
// ---- The rails, spelled from the field law ----
private static float2 Around(float2 center, double time, float distance, float phase, float strength)
{
var period = 2 * PI * distance / sqrt(strength);
var wrapped = (float) frac(time / period);
return center + OrbitData.Evaluate(frac(wrapped + phase)) * distance;
}
private static float2 PlanetAt(double time) => Around(SunCentre, time, PlanetDistance, PlanetPhase, SunMass);
private static float2 MoonAt(double time) => Around(PlanetAt(time), time, MoonDistance, MoonPhase, PlanetMass);
private static float2 StationAt(double time) => Around(PlanetAt(time), time, StationDistance, StationPhase, PlanetMass);
// The field at a point at a time, with each well where its rail puts it then.
private static float FieldAt(World w, float2 point, double time)
{
var wells = new (float2 At, float Strength)[] { (SunCentre, SunMass), (PlanetAt(time), PlanetMass), (MoonAt(time), MoonMass) };
var sum = 0f;
foreach (var (at, strength) in wells)
sum += GravityField.Potential(strength, 2f, length(point - at)) - GravityField.Potential(strength, 2f, w.Zone.Pack.Radius);
return sum / w.HeightExaggeration;
}
private static void Step(World w, float seconds, int times)
{
for (var i = 0; i < times; i++) w.Zone.Update(seconds);
}
// ---- The tests ----
// The operator's report, from the production clock: a ship coasting at its planet's step velocity, 60 u beside it, seen
// across display frames. Frames are 1/144 s (the report), and 75, 165 and 240 Hz, where a clamp or a fraction of the
// lead would also show; at 60 Hz Lead is always 0 and could not tell. The planet minus the ship must not swing in either
// world axis (0.306 u along the motion when the planet is drawn at the step's orbit).
[Theory]
[InlineData(144)]
[InlineData(75)]
[InlineData(165)]
[InlineData(240)]
public void ACoMovingShipSeesAStillPlanetAcrossFrames(int displayHz)
{
var w = Build();
var ship = new Ship(w.Items, w.Zone, w.Hull, new EntitySettings());
var clock = new SimClock(1f / 60f);
var xs = new List<float>();
var ys = new List<float>();
var leads = new List<float>();
for (var frame = 0; frame < displayHz * 2; frame++)
{
clock.Advance(1f / displayHz, dt =>
{
w.Zone.Update(dt);
var rail = w.Zone.Orbits[w.PlanetOrbit];
ship.Position = float3(rail.Position.x + 60f, 0, rail.Position.y);
ship.Velocity = rail.Velocity;
});
if (clock.Steps == 0) continue;
var lead = clock.Lead;
var relative = DrawAhead.Orbit(w.Zone, w.PlanetOrbit, lead) - DrawAhead.Position(ship, lead).xz;
xs.Add(relative.x);
ys.Add(relative.y);
leads.Add(lead);
}
Assert.True(xs.Count > displayHz, "the run covered its frames");
Assert.True(leads.Max() > .005f, $"{displayHz} Hz: the frames fall far enough past a step to tell a lead from none");
Assert.True(xs.Max() - xs.Min() < .005f, $"{displayHz} Hz: the planet swings {xs.Max() - xs.Min():F4} u in x against the ship");
Assert.True(ys.Max() - ys.Min() < .005f, $"{displayHz} Hz: the planet swings {ys.Max() - ys.Min():F4} u in z against the ship");
}
[Fact]
public void APlanetIsDrawnAtItsOrbitAtTheDrawnTime()
{
var w = Build();
Step(w, 1f / 60f, 803);
foreach (var lead in new[] { 0f, .004f, .0123f, .0166f })
{
var time = w.Zone.ExactTime + lead;
AssertNear(PlanetAt(time), DrawAhead.Orbit(w.Zone, w.PlanetOrbit, lead), 1e-3f, $"planet at lead {lead}");
AssertNear(MoonAt(time), DrawAhead.Orbit(w.Zone, w.MoonOrbit, lead), 1e-3f, $"moon (chained through the planet) at lead {lead}");
}
Assert.Equal(w.Zone.Orbits[w.PlanetOrbit].Position, DrawAhead.Orbit(w.Zone, w.PlanetOrbit, 0f));
Assert.Equal(w.Zone.Orbits[w.MoonOrbit].Position, DrawAhead.Orbit(w.Zone, w.MoonOrbit, 0f));
Assert.True(length(DrawAhead.Orbit(w.Zone, w.PlanetOrbit, .0166f) - w.Zone.Orbits[w.PlanetOrbit].Position) > .1f, "the planet really moves over a lead");
}
// The chain rule (a root at its FixedPosition, a child at its parent's node plus its offset) holds at the step's time
// (the memo) and at a drawn time (the rail) alike: an off-centre root is the same place either way, and the planet
// round it follows it.
[Fact]
public void ARootSitsAtItsFixedPositionAtEveryTime()
{
var w = Build();
var sunOrbit = w.Zone.Orbits.Single(o => !o.Value.Data.Parent.IsSet()).Key;
Step(w, 1f / 60f, 200);
foreach (var lead in new[] { 0f, .004f, .0166f })
{
AssertNear(SunCentre, DrawAhead.Orbit(w.Zone, sunOrbit, lead), 1e-3f, $"root at lead {lead}");
AssertNear(PlanetAt(w.Zone.ExactTime + lead), DrawAhead.Orbit(w.Zone, w.PlanetOrbit, lead), 1e-3f, $"planet round the root at lead {lead}");
}
Assert.True(length(w.Zone.Orbits[w.PlanetOrbit].Position - SunCentre) > PlanetDistance - 1f, "the planet circles the off-centre root, not the origin");
}
[Fact]
public void OrbitPositionAtWritesNothing()
{
var w = Build();
Step(w, 1f / 60f, 100);
var keys = w.Zone.Orbits.Keys.ToArray();
var positions = keys.Select(k => w.Zone.Orbits[k].Position).ToArray();
var velocities = keys.Select(k => w.Zone.Orbits[k].Velocity).ToArray();
var probes = new[] { float2(1100f, 20f), float2(980f, -40f), SunCentre };
var heights = probes.Select(p => w.Zone.GetHeight(p)).ToArray();
var later = w.Zone.ExactTime + .5;
foreach (var key in keys) w.Zone.OrbitPositionAt(key, later);
foreach (var p in probes) w.Zone.GetHeight(p, later);
w.Zone.ChunkPose(w.Belt, 0, later);
for (var i = 0; i < keys.Length; i++)
{
Assert.Equal(positions[i], w.Zone.Orbits[keys[i]].Position);
Assert.Equal(velocities[i], w.Zone.Orbits[keys[i]].Velocity);
Assert.Equal(positions[i], w.Zone.OrbitPositionAt(keys[i], w.Zone.ExactTime));
}
for (var i = 0; i < probes.Length; i++) Assert.Equal(heights[i], w.Zone.GetHeight(probes[i]));
Assert.True(length(w.Zone.OrbitPositionAt(w.PlanetOrbit, later) - positions[Array.IndexOf(keys, w.PlanetOrbit)]) > 1f, "the later time is a different place");
}
[Fact]
public void TheDrawnFieldMovesWithTheDrawnWell()
{
var w = Build();
Step(w, 1f / 60f, 500);
var probe = PlanetAt(w.Zone.ExactTime) + float2(30f, 0);
Assert.Equal(w.Zone.GetHeight(probe), w.Zone.GetHeight(probe, w.Zone.ExactTime));
const float lead = .0166f;
var time = w.Zone.ExactTime + lead;
var drawn = DrawAhead.Orbit(w.Zone, w.PlanetOrbit, lead) + float2(30f, 0);
Assert.Equal(FieldAt(w, drawn, time), DrawAhead.Height(w.Zone, drawn, lead), 3);
Assert.True(abs(DrawAhead.Height(w.Zone, drawn, lead) - w.Zone.GetHeight(drawn)) > .01f, "the field has moved with its well over a lead");
// A ship that stands still 40 u from the planet rides the field of the drawn well, plus its hull's offset.
var ship = new Ship(w.Items, w.Zone, w.Hull, new EntitySettings()) { Position = float3(PlanetAt(w.Zone.ExactTime).x + 40f, 0, PlanetAt(w.Zone.ExactTime).y) };
var drawnShip = DrawAhead.Position(ship, lead);
Assert.Equal(FieldAt(w, drawnShip.xz, time) + GridOffset, drawnShip.y, 3);
Assert.Equal(ship.RideHeight(drawnShip.xz, time), drawnShip.y, 4);
Assert.Equal(w.Zone.GetHeight(ship.Position.xz) + GridOffset, ship.RideHeight(ship.Position.xz), 4);
}
[Fact]
public void AStationIsDrawnOnItsOrbit()
{
var w = Build();
const float step = .1f;
Step(w, step, 40);
var station = new OrbitalEntity(w.Items, w.Zone, w.Hull, w.StationOrbit, new EntitySettings());
var rail = w.Zone.Orbits[w.StationOrbit];
station.Position = float3(rail.Position.x, 0, rail.Position.y);
station.Velocity = rail.Velocity;
const float lead = .08f;
var drawn = DrawAhead.Position(station, lead);
AssertNear(StationAt(w.Zone.ExactTime + lead), drawn.xz, 2e-3f, "the station on its rail at the drawn time");
AssertNear(DrawAhead.Orbit(w.Zone, w.StationOrbit, lead), drawn.xz, 1e-6f, "the same point DrawAhead.Orbit gives");
Assert.True(length(station.Position.xz + station.Velocity * lead - drawn.xz) > .01f, "a tight rail curves away from its straight extrapolation");
Assert.Equal(w.Zone.GetHeight(drawn.xz, w.Zone.ExactTime + lead) + GridOffset, drawn.y, 4);
}
// The generators make orbital entities with no orbit set (Loadout, LoadoutGenerator): a rail-less one is an ordinary
// integrated body, drawn at its step plus velocity times lead, never at the origin an unset rail would give.
[Fact]
public void AnOrbitalEntityWithoutAnOrbitIsDrawnWhereItIs()
{
var w = Build();
Step(w, 1f / 60f, 30);
var station = new OrbitalEntity(w.Items, w.Zone, w.Hull, default, new EntitySettings())
{
Position = float3(520f, 0, -140f),
Velocity = float2(3f, 4f)
};
const float lead = .01f;
var drawn = DrawAhead.Position(station, lead);
AssertNear(float2(520f, -140f) + float2(3f, 4f) * lead, drawn.xz, 1e-4f, "an unrailed station");
Assert.Equal(station.RideHeight(drawn.xz, w.Zone.ExactTime + lead), drawn.y, 4);
}
[Fact]
public void ABeltIsDrawnAtTheDrawnTime()
{
var w = Build();
Step(w, 1f / 60f, 301);
var time = w.Zone.ExactTime + .01;
var atStep = new float4[w.Asteroids.Length];
var drawn = new float4[w.Asteroids.Length];
w.Zone.EvaluateBelt(w.Belt, atStep, w.Zone.ExactTime);
w.Zone.EvaluateBelt(w.Belt, drawn, time);
var parent = PlanetAt(time);
for (var i = 0; i < w.Asteroids.Length; i++)
{
var asteroid = w.Asteroids[i];
var period = 2 * PI * asteroid.Distance / sqrt(PlanetMass);
var expected = OrbitData.Evaluate((float) frac(time / period + asteroid.Phase)) * asteroid.Distance + parent;
AssertNear(expected, drawn[i].xy, 1e-3f, $"chunk {i} drawn");
Assert.Equal(w.Zone.ChunkPose(w.Belt, i, time), drawn[i]);
Assert.Equal(w.Zone.ChunkPose(w.Belt, i, w.Zone.ExactTime), atStep[i]);
Assert.Equal(w.Zone.ChunkPose(w.Belt, i), atStep[i]);
Assert.True(length(drawn[i].xy - atStep[i].xy) > .01f, $"chunk {i} has moved over the lead");
}
}
// The well the shader takes is its body's rail at the drawn time: the lowering copies DrawAhead.Well and decides
// nothing, so the drawn well and the drawn planet share one pose at a non-zero lead, and the strength, core and
// reference are the field law's (spelled here from the masses, the core 2 and the zone's edge), not the step's.
[Fact]
public void AWellIsLoweredAtTheDrawnTime()
{
var w = Build();
Step(w, 1f / 60f, 803);
foreach (var lead in new[] { 0f, .004f, .0166f })
{
var time = w.Zone.ExactTime + lead;
foreach (var body in w.Zone.PlanetInstances.Values)
{
var key = body.BodyData.Orbit.Key;
var (at, strength) = key.Equals(w.PlanetOrbit) ? (PlanetAt(time), PlanetMass)
: key.Equals(w.MoonOrbit) ? (MoonAt(time), MoonMass)
: (SunCentre, SunMass);
var well = DrawAhead.Well(w.Zone, body, lead);
AssertNear(at, well.Position, 1e-3f, $"well of the {strength} body at lead {lead}");
Assert.Equal(strength, well.Strength, 3);
Assert.Equal(2f, well.CoreRadius, 3);
Assert.Equal(GravityField.Potential(strength, 2f, w.Zone.Pack.Radius), well.Reference, 3);
}
}
var planet = w.Zone.PlanetInstances.Values.Single(b => b.BodyData.Orbit.Key.Equals(w.PlanetOrbit));
Assert.True(length(DrawAhead.Well(w.Zone, planet, .0166f).Position - DrawAhead.Well(w.Zone, planet, 0f).Position) > .1f, "the well moves over a lead");
AssertNear(w.Zone.Orbits[w.PlanetOrbit].Position, DrawAhead.Well(w.Zone, planet, 0f).Position, 0f, "at lead 0 the well is the step's memo");
}
// A HUD marker round the ship the camera follows (an incoming lock, a contact's bearing, a ping's flash) is anchored on
// that ship's drawn position, so it holds still against the drawn hull at every lead. A marker anchored on the step's
// position slides v * lead off it and snaps back each step.
[Fact]
public void AMarkerAtABearingIsAnchoredOnTheDrawnShip()
{
var w = Build();
Step(w, 1f / 60f, 30);
var ship = new OrbitalEntity(w.Items, w.Zone, w.Hull, default, new EntitySettings())
{
Position = float3(520f, 0, -140f),
Velocity = float2(12f, -5f)
};
var bearing = normalize(float2(.6f, .8f));
foreach (var lead in new[] { 0f, .004f, .0166f })
{
var drawn = DrawAhead.Position(ship, lead);
var marker = DrawAhead.AtBearing(ship, bearing, 7f, lead);
AssertNear(float2(520f, -140f) + float2(12f, -5f) * lead + bearing * 7f, marker.xz, 1e-4f, $"marker at lead {lead}");
Assert.Equal(drawn.y, marker.y, 4);
AssertNear(drawn.xz, marker.xz - bearing * 7f, 1e-4f, $"the marker's anchor is the drawn ship at lead {lead}");
}
}
// An orbit whose parent the zone does not hold is reported when a rail is evaluated at a drawn time.
[Fact]
public void AMissingParentOrbitIsLoggedAtADrawnTime()
{
var w = Build();
Step(w, 1f / 60f, 30);
var log = new List<string>();
w.Zone.Log = log.Add;
w.Zone.Orbits.Remove(w.PlanetOrbit);
foreach (var lead in new[] { .004f, .5f })
{
log.Clear();
w.Zone.OrbitPositionAt(w.MoonOrbit, w.Zone.ExactTime + lead);
Assert.Contains("Requested orbit is not part of this zone!", log);
}
}
private static void AssertNear(float2 expected, float2 actual, float eps, string what) =>
Assert.True(length(expected - actual) <= eps, $"{what}: expected {expected}, got {actual} (eps {eps})");
}

View file

@ -242,7 +242,7 @@ public sealed class MiningCut1Tests : IDisposable
zone.Update(0.37f);
var buffer = new float4[asteroids.Length];
zone.EvaluateBelt(beltKey, buffer);
zone.EvaluateBelt(beltKey, buffer, zone.ExactTime);
Assert.Equal(asteroids.Length, buffer.Length);
for (var i = 0; i < asteroids.Length; i++)

View file

@ -263,7 +263,7 @@ public sealed partial class RunStartTests
const float lead = .007f;
var ahead = DrawAhead.Position(player, lead);
Assert.Equal(player.Position.x + player.Velocity.x * lead, ahead.x, 6);
Assert.Equal(player.RideHeight(float2(ahead.x, ahead.z)), ahead.y, 4);
Assert.Equal(player.RideHeight(float2(ahead.x, ahead.z), player.Zone.ExactTime + lead), ahead.y, 4);
Assert.Equal(player.Position.z + player.Velocity.y * lead, ahead.z, 6);
Assert.Equal(before, Snapshot(arena, new List<string>()));
}