diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset
index 50124154b..032d8c898 100644
--- a/Assets/Resources/Settings.asset
+++ b/Assets/Resources/Settings.asset
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:ffc1465cae49e57dbfd25e6c685b30388e9e208ea81ae50a4c560764a12ba016
+oid sha256:667368f42385152984589abb3803aa88a51937286ac2450d890970710ec38e37
size 13039
diff --git a/Assets/Scripts/ServerShared/Environment.cs b/Assets/Scripts/ServerShared/Environment.cs
index 2424ca214..737033e4e 100644
--- a/Assets/Scripts/ServerShared/Environment.cs
+++ b/Assets/Scripts/ServerShared/Environment.cs
@@ -9,7 +9,7 @@ using float2 = CultMath.float2;
public class ZoneEnvironment
{
[JsonProperty("nebula"), Key(0)] public NebulaSettings Nebula;
- [JsonProperty("flow"), Key(1)] public FlowSettings Flow;
+ // Key 1 is retired (it held the flow, now PlanetSettings.Flow); never reuse it.
[JsonProperty("noise"), Key(2)] public NoiseSettings Noise;
[JsonProperty("lighting"), Key(3)] public AmbientLightingSettings Lighting;
[JsonProperty("grid"), Key(4)] public GridSettings Grid;
diff --git a/Assets/Scripts/ServerShared/FlowField.cs b/Assets/Scripts/ServerShared/FlowField.cs
new file mode 100644
index 000000000..8813fd274
--- /dev/null
+++ b/Assets/Scripts/ServerShared/FlowField.cs
@@ -0,0 +1,53 @@
+using CultMath;
+using static CultMath.math;
+
+///
+/// The global flow law: the one definition of the drift that stardust and nebula follow and that the sim knows.
+/// A pure function of the zone's FlowSettings, a position, a scroll and a downhill gradient; nothing here keeps
+/// a clock. Zone.Flow evaluates it at zone time. Assets/Shaders/Volumetric.cginc (globalFlow, slopeFlow) is its
+/// HLSL lowering, driven by VolumeSampling.LowerFlow and pinned to it by Aetheria.EditorTools.FlowFieldParityVerify.
+///
+public static class FlowField
+{
+ // The scroll offset of the noise field, in field units: speed times zone time and nothing else.
+ public static float Scroll(FlowSettings settings, double zoneTime) => (float) (settings.GlobalScrollSpeed * zoneTime);
+
+ // The procedural term (Volumetric.cginc globalFlow): a Tri3D field of position / scale, scrolled along y.
+ // Skipped when its amplitude is zero, as the shader's FLOW_GLOBAL keyword skips it.
+ public static float3 Global(FlowSettings settings, float3 position, float scroll)
+ {
+ if (settings.GlobalAmplitude == 0f) return float3.zero;
+ var sample = Tri3D(position / settings.GlobalScale - float3(0f, scroll, 0f)) * settings.GlobalAmplitude;
+ sample.y *= .5f;
+ return sample;
+ }
+
+ // The slope term (Volumetric.cginc slopeFlow): swirl around and slide along the gravity gradient.
+ // `downhill` is the DOWNHILL gradient, -grad(height) in world xz. The shader's texel gradient is that same
+ // vector (getUV negates the world x and z axes), and Zone.GetNormal's xz is proportional to it.
+ // Skipped when both amplitudes are zero (the FLOW_SLOPE keyword), and zero for a zero gradient.
+ public static float3 Slope(FlowSettings settings, float2 downhill)
+ {
+ if (settings.SlopeAmplitude == 0f && settings.SwirlAmplitude == 0f) return float3.zero;
+ if (dot(downhill, downhill) == 0f) return float3.zero;
+ var direction = normalize(downhill);
+ return float3(direction.y, 0f, -direction.x) * settings.SwirlAmplitude
+ + float3(direction.x, 0f, direction.y) * settings.SlopeAmplitude;
+ }
+
+ public static float3 At(FlowSettings settings, float3 position, double zoneTime, float2 downhill) =>
+ Global(settings, position, Scroll(settings, zoneTime)) + Slope(settings, downhill);
+
+ // Volumetric.cginc tri / tri3 / Tri3D, term for term.
+ static float Tri(float x) => abs(frac(x) - .5f);
+
+ static float3 Tri3(float3 p) =>
+ float3(Tri(p.z + Tri(p.y)), Tri(p.z + Tri(p.x)), Tri(p.y + Tri(p.x)));
+
+ static float3 Tri3D(float3 p)
+ {
+ var t1 = normalize(Tri3(p));
+ var t2 = normalize(Tri3(p * 1.61803398875f));
+ return cross(t1, t2);
+ }
+}
diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs
index c6ffe93d9..1a04dd2ae 100644
--- a/Assets/Scripts/ServerShared/Settings.cs
+++ b/Assets/Scripts/ServerShared/Settings.cs
@@ -32,6 +32,9 @@ public class PlanetSettings
public float SecureAreaRadiusMultiplier = .45f;
public ExponentialCurve OrbitPeriod;
+
+ // The global flow stardust and nebula follow and the sim knows (FlowField). Zero flow by default.
+ public FlowSettings Flow = new FlowSettings();
}
[Serializable, MessagePackObject(keyAsPropertyName:true), JsonObject]
diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs
index 29262c69c..6500055bc 100644
--- a/Assets/Scripts/ServerShared/Zone.cs
+++ b/Assets/Scripts/ServerShared/Zone.cs
@@ -691,6 +691,17 @@ public class Zone
return GravityAcceleration(new float2(normal.x, normal.z), Settings.GravityStrength);
}
+ // The global flow velocity at `position` in zone time, in u/s (FlowField is the law). The slope term reads the
+ // downhill gradient, which GetNormal's xz is proportional to.
+ public float3 Flow(float3 position)
+ {
+ var normal = GetNormal(position.xz);
+ return FlowField.At(Settings.Flow, position, _time, new float2(normal.x, normal.z));
+ }
+
+ // The flow's noise scroll at zone time; lowered to the shaders' _FlowScroll.
+ public float FlowScroll => FlowField.Scroll(Settings.Flow, _time);
+
public static float PowerPulse(float x, float exponent)
{
x *= 2;
diff --git a/Assets/Scripts/Zone Display/VolumeSampling.cs b/Assets/Scripts/Zone Display/VolumeSampling.cs
index fd9d9f1e9..af244911a 100644
--- a/Assets/Scripts/Zone Display/VolumeSampling.cs
+++ b/Assets/Scripts/Zone Display/VolumeSampling.cs
@@ -27,16 +27,11 @@ public class VolumeSampling : MonoBehaviour
public RenderTexture NebulaTint;
private MeshRenderer _gridMeshRenderer;
- private float _flowScroll;
private Transform _gridTransform;
- private GlobalKeyword _keywordFlowGlobal;
- private GlobalKeyword _keywordFlowSlope;
private GlobalKeyword _keywordNoiseSlope;
private void Start()
{
- _keywordFlowGlobal = GlobalKeyword.Create("FLOW_GLOBAL");
- _keywordFlowSlope = GlobalKeyword.Create("FLOW_SLOPE");
_keywordNoiseSlope = GlobalKeyword.Create("NOISE_SLOPE");
if (GridMesh != null)
@@ -93,16 +88,30 @@ public class VolumeSampling : MonoBehaviour
Shader.SetGlobalFloat("_NebulaNoiseSpeed", environment.Noise.Speed);
Shader.SetGlobalFloat("_NebulaNoiseSlopeExponent", environment.Noise.SlopeExponent);
- Shader.SetGlobalFloat("_FlowScale", environment.Flow.GlobalScale);
- Shader.SetGlobalFloat("_FlowAmplitude", environment.Flow.GlobalAmplitude);
- Shader.SetGlobalFloat("_FlowSlopeAmplitude", environment.Flow.SlopeAmplitude);
- Shader.SetGlobalFloat("_FlowSwirlAmplitude", environment.Flow.SwirlAmplitude);
- _flowScroll += environment.Flow.GlobalScrollSpeed * Time.deltaTime;
- Shader.SetGlobalFloat("_FlowScroll", _flowScroll);
- Shader.SetGlobalFloat("_FlowPeriod", environment.Flow.Period);
-
- Shader.SetKeyword(_keywordFlowGlobal, environment.Flow.GlobalAmplitude != 0);
- Shader.SetKeyword(_keywordFlowSlope, environment.Flow.SlopeAmplitude != 0 || environment.Flow.SwirlAmplitude != 0);
+ // The flow is the zone's: its settings and its time. Without a zone (menus) the same law runs on the presentation clock.
+ var zone = ActionGameManager.Instance?.Zone;
+ if (zone != null) LowerFlow(zone.Settings.Flow, zone.FlowScroll);
+ else LowerFlow(Settings.PlanetSettings.Flow, FlowField.Scroll(Settings.PlanetSettings.Flow, Time.timeSinceLevelLoad));
Shader.SetKeyword(_keywordNoiseSlope, environment.Noise.SlopeExponent != 0);
}
+
+ static readonly GlobalKeyword KeywordFlowGlobal = new GlobalKeyword("FLOW_GLOBAL");
+ static readonly GlobalKeyword KeywordFlowSlope = new GlobalKeyword("FLOW_SLOPE");
+
+ ///
+ /// The one place that sets the flow uniforms and keywords: every shader flow term derives from FlowSettings and a
+ /// scroll (FlowField.Scroll), so no presenter keeps its own flow clock.
+ ///
+ public static void LowerFlow(FlowSettings flow, float scroll)
+ {
+ Shader.SetGlobalFloat("_FlowScale", flow.GlobalScale);
+ Shader.SetGlobalFloat("_FlowAmplitude", flow.GlobalAmplitude);
+ Shader.SetGlobalFloat("_FlowSlopeAmplitude", flow.SlopeAmplitude);
+ Shader.SetGlobalFloat("_FlowSwirlAmplitude", flow.SwirlAmplitude);
+ Shader.SetGlobalFloat("_FlowScroll", scroll);
+ Shader.SetGlobalFloat("_FlowPeriod", flow.Period);
+
+ Shader.SetKeyword(KeywordFlowGlobal, flow.GlobalAmplitude != 0);
+ Shader.SetKeyword(KeywordFlowSlope, flow.SlopeAmplitude != 0 || flow.SwirlAmplitude != 0);
+ }
}
diff --git a/Assets/Shaders/Compute/Stardust/Stardust.cs b/Assets/Shaders/Compute/Stardust/Stardust.cs
index d75b18f99..5ffe3f117 100644
--- a/Assets/Shaders/Compute/Stardust/Stardust.cs
+++ b/Assets/Shaders/Compute/Stardust/Stardust.cs
@@ -36,7 +36,6 @@ public class Stardust : MonoBehaviour
//public Transform TargetTransform;
public Camera GravityCamera;
- private float _flowScroll;
private const int GROUP_SIZE = 128;
private int _updateParticlesKernel;
#endregion
diff --git a/Assets/Shaders/Volumetric.cginc b/Assets/Shaders/Volumetric.cginc
index 44fad2c06..8a4c45470 100644
--- a/Assets/Shaders/Volumetric.cginc
+++ b/Assets/Shaders/Volumetric.cginc
@@ -136,6 +136,7 @@ float3 globalFlow(float3 pos)
float3 slopeFlow(float2 grad)
{
+ if (dot(grad, grad) == 0) return 0;
float2 ngrad = normalize(grad);
return float3(ngrad.y, 0, -ngrad.x) * _FlowSwirlAmplitude + float3(ngrad.x, 0, ngrad.y) * _FlowSlopeAmplitude;
}
diff --git a/tests/Aetheria.Shared.Tests/FlowFieldTests.cs b/tests/Aetheria.Shared.Tests/FlowFieldTests.cs
new file mode 100644
index 000000000..1c1d50264
--- /dev/null
+++ b/tests/Aetheria.Shared.Tests/FlowFieldTests.cs
@@ -0,0 +1,154 @@
+using System;
+using System.IO;
+using CultMath;
+using GameCult.Caching;
+using Xunit;
+using static CultMath.math;
+using float2 = CultMath.float2;
+using float3 = CultMath.float3;
+
+// The global flow as a world fact (campaign aetheria-fauna, cut fauna-flow-field; ruling sim-knows-the-flow; target
+// invariants flow-one-owner and flow-step-free). Every test reads Zone.Flow / Zone.FlowScroll, the path a fauna
+// class will read, from zones built the way the other ServerShared tests build them.
+public sealed class FlowFieldTests : IDisposable
+{
+ private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-flow-" + Guid.NewGuid().ToString("N"));
+ private readonly CultCache _cache;
+ private readonly ItemManager _items;
+
+ public FlowFieldTests()
+ {
+ Directory.CreateDirectory(_root);
+ _cache = AetheriaStores.Open(Path.Combine(_root, "flow.cc"), catalogWritable: true);
+ _cache.Upsert(new VerseGrammar { Revision = 1 });
+ _cache.FlushAsync().Wait();
+ var settings = new GameplaySettings
+ {
+ DefaultEntitySettings = new EntitySettings(),
+ Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
+ QualityPriceModifier = new ExponentialLerp(),
+ TargetDetectionInfoThreshold = .1f, TargetGearInfoThreshold = .8f,
+ FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 2f
+ };
+ _items = new ItemManager(_cache, new ProvenanceLedger(), settings, _ => { });
+ }
+
+ public void Dispose()
+ {
+ _cache.Dispose();
+ Directory.Delete(_root, true);
+ }
+
+ private static FlowSettings Flow(float amplitude = 15f, float slide = 0f, float swirl = 0f) => new FlowSettings
+ {
+ GlobalScale = 512f, GlobalAmplitude = amplitude, GlobalScrollSpeed = .025f, Period = 8f,
+ SlopeAmplitude = slide, SwirlAmplitude = swirl
+ };
+
+ // A zone at `time` (the pack's clock, as a saved zone restores it), flat unless `depth` digs the zone's own well.
+ private Zone ZoneWith(FlowSettings flow, double time = 0, float depth = 0f) => new Zone(_items,
+ new PlanetSettings { Flow = flow, ZoneDepth = depth, ZoneDepthExponent = 2f },
+ new ZonePack { Time = time }, new GalaxyZone { Name = "Flow Zone", Owner = null }, null);
+
+ private static readonly float3[] Probes =
+ {
+ new float3(0, 0, 0), new float3(37.5f, 12f, -80.25f), new float3(-410f, -30f, 125f),
+ new float3(1234f, 50f, 987f), new float3(-2501f, 7f, -3333f), new float3(9000f, 90f, -4000f)
+ };
+
+ private static void AssertClose(float3 expected, float3 actual, float tolerance = 1e-3f)
+ {
+ Assert.True(abs(expected.x - actual.x) <= tolerance && abs(expected.y - actual.y) <= tolerance &&
+ abs(expected.z - actual.z) <= tolerance, $"expected {expected} but flow was {actual}");
+ }
+
+ [Fact]
+ public void ScrollIsSpeedTimesZoneTime()
+ {
+ // The flow at zone time t is the flow at time 0 of a field moved along y by -scale * speed * t, so the
+ // noise scrolls by speed x zone time and nothing else (an odd t keeps the shift off a whole field period).
+ const double t = 7.0;
+ var flow = Flow();
+ var later = ZoneWith(flow, t);
+ var start = ZoneWith(flow, 0);
+ var shift = flow.GlobalScale * flow.GlobalScrollSpeed * (float) t;
+ foreach (var p in Probes)
+ AssertClose(start.Flow(new float3(p.x, p.y - shift, p.z)), later.Flow(p));
+ Assert.Equal(flow.GlobalScrollSpeed * t, later.FlowScroll, 6);
+ Assert.Equal(0f, start.FlowScroll);
+ }
+
+ [Fact]
+ public void FlowDoesNotDependOnStepSize()
+ {
+ var fine = ZoneWith(Flow());
+ var coarse = ZoneWith(Flow());
+ for (var i = 0; i < 60; i++) fine.Update(1f / 60f);
+ for (var i = 0; i < 7; i++) coarse.Update(1f / 7f);
+ Assert.Equal(1.0, fine.Time, 4);
+ Assert.Equal(1.0, coarse.Time, 4);
+ foreach (var p in Probes) AssertClose(fine.Flow(p), coarse.Flow(p));
+ Assert.Equal(fine.FlowScroll, coarse.FlowScroll, 5);
+ // The flow moved: a scroll of 0.025 per second is a different field from the start.
+ Assert.NotEqual(ZoneWith(Flow()).Flow(Probes[1]).y, fine.Flow(Probes[1]).y);
+ }
+
+ [Fact]
+ public void ZeroAmplitudesGiveZeroFlowAndNoNaN()
+ {
+ // All amplitudes zero (and a zero scale a bad config could carry): no term runs, so nothing divides by zero.
+ var none = ZoneWith(new FlowSettings());
+ foreach (var p in Probes) Assert.Equal(float3.zero, none.Flow(p));
+
+ // A zone built from default PlanetSettings has no flow, the baseline every other ServerShared test relies on.
+ var defaults = new Zone(_items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Default", Owner = null }, null);
+ foreach (var p in Probes) Assert.Equal(float3.zero, defaults.Flow(p));
+
+ // A term with zero amplitudes is skipped outright: not even a gradient that cannot be normalised reaches it.
+ var unusable = float2(float.PositiveInfinity, float.NaN);
+ Assert.Equal(float3.zero, FlowField.Slope(new FlowSettings(), unusable));
+
+ // A flat zone has a zero gradient; swirl and slide are zero there, never NaN.
+ var flat = ZoneWith(Flow(amplitude: 0f, slide: 1f, swirl: 1f));
+ foreach (var p in Probes)
+ {
+ var f = flat.Flow(p);
+ Assert.False(float.IsNaN(f.x) || float.IsNaN(f.y) || float.IsNaN(f.z), "zero gradient gave NaN");
+ Assert.Equal(float3.zero, f);
+ }
+ }
+
+ [Fact]
+ public void SlopeTermSwirlsAndSlidesOnTheGradient()
+ {
+ // The zone's own well is deepest at the origin: downhill at p is toward it.
+ var at = new float3(500f, 3f, 300f);
+ var downhill = normalize(float2(-at.x, -at.z));
+
+ var slide = ZoneWith(Flow(amplitude: 0f, slide: 1.5f), depth: 64f).Flow(at);
+ Assert.Equal(0f, slide.y, 4);
+ Assert.Equal(1.5f * downhill.x, slide.x, 2);
+ Assert.Equal(1.5f * downhill.y, slide.z, 2);
+
+ // Swirl is perpendicular to downhill, and has a handedness: (down.y, -down.x).
+ var swirl = ZoneWith(Flow(amplitude: 0f, swirl: 2f), depth: 64f).Flow(at);
+ Assert.Equal(0f, swirl.y, 4);
+ Assert.Equal(2f * downhill.y, swirl.x, 2);
+ Assert.Equal(-2f * downhill.x, swirl.z, 2);
+ Assert.Equal(0f, dot(float2(swirl.x, swirl.z), downhill), 2);
+ Assert.Equal(2f, length(float2(swirl.x, swirl.z)), 2);
+ }
+
+ [Fact]
+ public void ZoneFlowReadsItsPlanetSettings()
+ {
+ var one = ZoneWith(Flow(amplitude: 15f), 3.0);
+ var two = ZoneWith(Flow(amplitude: 30f), 3.0);
+ foreach (var p in Probes)
+ {
+ var a = one.Flow(p);
+ Assert.NotEqual(float3.zero, a);
+ AssertClose(a * 2f, two.Flow(p));
+ }
+ }
+}