From e8ec8e903c6ff5dce3739cca44486341fa497817 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 20:06:35 +0200 Subject: [PATCH] Flow field: FlowField owns the global flow, Zone.Flow evaluates it at zone time, VolumeSampling.LowerFlow lowers it FlowField (ServerShared) is the flow law; Zone.Flow/FlowScroll read PlanetSettings.Flow at zone time. The Flow block moves from DefaultEnvironment to PlanetSettings (ZoneEnvironment key 1 is retired), VolumeSampling's frame-clock scroll accumulator and the Stardust leftover field are deleted, and slopeFlow returns 0 for a zero gradient. Unity parity probe follows in the next commit; this commit has not been built in Unity. Co-Authored-By: Claude Opus 5.5 --- Assets/Resources/Settings.asset | 2 +- Assets/Scripts/ServerShared/Environment.cs | 2 +- Assets/Scripts/ServerShared/FlowField.cs | 53 ++++++ Assets/Scripts/ServerShared/Settings.cs | 3 + Assets/Scripts/ServerShared/Zone.cs | 11 ++ Assets/Scripts/Zone Display/VolumeSampling.cs | 39 +++-- Assets/Shaders/Compute/Stardust/Stardust.cs | 1 - Assets/Shaders/Volumetric.cginc | 1 + tests/Aetheria.Shared.Tests/FlowFieldTests.cs | 154 ++++++++++++++++++ 9 files changed, 248 insertions(+), 18 deletions(-) create mode 100644 Assets/Scripts/ServerShared/FlowField.cs create mode 100644 tests/Aetheria.Shared.Tests/FlowFieldTests.cs 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)); + } + } +}