diff --git a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs index a3ac42b54..54e2b39fb 100644 --- a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs +++ b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs @@ -157,8 +157,9 @@ public static class FloatingItemsDrawnSmoke ItemPickup[] Pickups() => rootObject.GetComponentsInChildren(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}."); } diff --git a/Assets/Scripts/Editor/GravityFieldParityVerify.cs b/Assets/Scripts/Editor/GravityFieldParityVerify.cs index 20b116189..bc413bfa0 100644 --- a/Assets/Scripts/Editor/GravityFieldParityVerify.cs +++ b/Assets/Scripts/Editor/GravityFieldParityVerify.cs @@ -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})"); diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index eab9531a1..99f2a3af2 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -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); } diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 2d6cbb88e..63bcfe48e 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -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); } diff --git a/Assets/Scripts/Gameplay/ItemPickup.cs b/Assets/Scripts/Gameplay/ItemPickup.cs index 157f28547..4f78dc848 100644 --- a/Assets/Scripts/Gameplay/ItemPickup.cs +++ b/Assets/Scripts/Gameplay/ItemPickup.cs @@ -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() diff --git a/Assets/Scripts/Gameplay/MineInstance.cs b/Assets/Scripts/Gameplay/MineInstance.cs index d269cacb1..c09de1fdf 100644 --- a/Assets/Scripts/Gameplay/MineInstance.cs +++ b/Assets/Scripts/Gameplay/MineInstance.cs @@ -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. diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs index 88fa08861..75d43cb65 100644 --- a/Assets/Scripts/ServerShared/DrawAhead.cs +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -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 diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 4a57fbe6e..edfe29a67 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -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) { diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index d6981f30d..1940e6e18 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -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 _wellOn = new Dictionary(); @@ -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 into) + public void EvaluateBelt(CultRecordKey belt, Span 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); } diff --git a/Assets/Scripts/Zone Display/GravityLowering.cs b/Assets/Scripts/Zone Display/GravityLowering.cs index b8c2a062a..37e50992f 100644 --- a/Assets/Scripts/Zone Display/GravityLowering.cs +++ b/Assets/Scripts/Zone Display/GravityLowering.cs @@ -32,9 +32,10 @@ public static class GravityLowering /// /// 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. /// - 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); @@ -50,7 +51,7 @@ public static class GravityLowering foreach (var body in zone.PlanetInstances.Values) _records[i++] = new WellRecord { - Position = new Vector2(body.Orbit.Position.x, body.Orbit.Position.y), + Position = DrawAhead.Orbit(zone, body.BodyData.Orbit.Key, lead).ToUnity(), Strength = body.WellStrength, CoreRadius = body.RocheLimit, Reference = zone.WellReference(body) diff --git a/Assets/Scripts/Zone Display/VolumeSampling.cs b/Assets/Scripts/Zone Display/VolumeSampling.cs index fdc378af6..72c5e269b 100644 --- a/Assets/Scripts/Zone Display/VolumeSampling.cs +++ b/Assets/Scripts/Zone Display/VolumeSampling.cs @@ -106,10 +106,12 @@ public class VolumeSampling : MonoBehaviour static GlobalKeyword KeywordFlowSlope => _keywordFlowSlope ??= GlobalKeyword.Create("FLOW_SLOPE"); /// - /// 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. /// - 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; /// /// The one place that sets the flow uniforms and keywords: every shader flow term derives from FlowSettings and diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index 4c63ed7b8..b06f8eeff 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -520,7 +520,8 @@ 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; @@ -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); } } @@ -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]; diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs new file mode 100644 index 000000000..cfafd2092 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -0,0 +1,306 @@ +/* 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; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-drawnpose-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + + 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) + { + var data = new OrbitData { Distance = distance, Phase = phase }; + if (parent.IsSet()) data.Parent = new CultRecordRef(parent); + return cache.Upsert(data).Key; + } + + CultRecordKey Body(CultRecordKey orbit, string name, float mass) => + cache.Upsert(new PlanetData { Name = name, Orbit = new CultRecordRef(orbit), Mass = mass }).Key; + + var sunOrbit = Orbit(default, 0f, 0f); + 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(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(orbit)); + foreach (var body in new[] { Body(sunOrbit, "Sun", SunMass), Body(planetOrbit, "Planet", PlanetMass), Body(moonOrbit, "Moon", MoonMass), belt }) + pack.Planets.Add(new CultRecordRef(body)); + + var zone = new Zone(items, Curves(), pack, new GalaxyZone { Name = "DrawnPose", Owner = null }, null); + var hull = new EquippableItem { Data = items.ItemData.RefOf(items.ItemData.GetByName("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(float2.zero, 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)[] { (float2.zero, 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 along the + // motion (0.306 u 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 along = new List(); + var across = new List(); + var leads = new List(); + + 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 direction = normalize(w.Zone.Orbits[w.PlanetOrbit].Velocity); + var relative = DrawAhead.Orbit(w.Zone, w.PlanetOrbit, lead) - DrawAhead.Position(ship, lead).xz; + along.Add(dot(relative, direction)); + across.Add(dot(relative, float2(-direction.y, direction.x))); + leads.Add(lead); + } + + Assert.True(along.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(along.Max() - along.Min() < .005f, $"{displayHz} Hz: the planet swings {along.Max() - along.Min():F4} u along the ship's motion"); + Assert.True(across.Max() - across.Min() < .005f, $"{displayHz} Hz: the planet swings {across.Max() - across.Min():F4} u across it"); + } + + [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"); + } + + [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), float2.zero }; + 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); + } + + [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"); + } + } + + 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})"); +} diff --git a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs index cea5467e1..c3700bd81 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs @@ -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++)