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/Editor/FlowFieldParityVerify.cs b/Assets/Scripts/Editor/FlowFieldParityVerify.cs
new file mode 100644
index 000000000..90979f7e8
--- /dev/null
+++ b/Assets/Scripts/Editor/FlowFieldParityVerify.cs
@@ -0,0 +1,302 @@
+using System;
+using System.IO;
+using GameCult.Caching;
+using UnityEditor;
+using UnityEngine;
+
+namespace Aetheria.EditorTools
+{
+ ///
+ /// Batchmode check for flow-shader-parity (campaign aetheria-fauna, cut fauna-flow-field): the HLSL globalFlow and
+ /// slopeFlow of Assets/Shaders/Volumetric.cginc give the values of FlowField (ServerShared) for the same position,
+ /// scroll, settings and gradient. It drives the real lowering: a Zone is stepped, VolumeSampling.LowerFlow sets the
+ /// shader globals from the zone's Settings.Flow and FlowScroll, FlowParity.compute reads those globals, and the
+ /// results are compared with FlowField. It also checks that Settings.asset carries the authored flow under
+ /// PlanetSettings and that the shader got the zone's scroll (speed times zone time).
+ ///
+ /// Dispatches a compute kernel, so it needs a graphics device: run WITHOUT -nographics (as ShieldPanelCut3Verify).
+ /// Unity -batchmode -projectPath <repo> -executeMethod Aetheria.EditorTools.FlowFieldParityVerify.Run -logFile <path>
+ /// Do not pass -quit: Run exits the Editor itself, 0 on success and 1 on any failure.
+ ///
+ public static class FlowFieldParityVerify
+ {
+ const string Tag = "[FlowFieldParityVerify]";
+ // Measured disagreement between the HLSL and the C# on this tree (float32 on both sides, positions to 10000,
+ // scrolls to 100, through the keyword-gated flow()): max 2.9e-5 of amplitude (the "max error" line this run
+ // prints). The tolerance is 5x that noise, so constant errors of 0.05% (y factor .501, swirl or amplitude
+ // x1.0005: 4e-4 to 8e-4) fail; a 0.01% error is at the noise floor and unresolvable.
+ const float RelativeTolerance = 1.5e-4f;
+ static float _maxError;
+ static bool _ok = true;
+
+ static void OnLog(string message, string stack, LogType type)
+ {
+ if ((type == LogType.Error || type == LogType.Exception || type == LogType.Assert) && !message.StartsWith(Tag))
+ Fail("logged-error", message.Split('\n')[0]);
+ }
+
+ static void Fail(string check, string detail)
+ {
+ Debug.LogError($"{Tag} FAIL {check}: {detail}");
+ _ok = false;
+ }
+
+ public static void Run()
+ {
+ if (SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.Null)
+ {
+ Debug.LogError($"{Tag} FAIL: no graphics device in this session; rerun batchmode without -nographics.");
+ EditorApplication.Exit(1);
+ return;
+ }
+
+ // The flow's error lines (a shader keyword that does not exist, a missing property) fail the check.
+ Application.logMessageReceived += OnLog;
+ var work = Path.Combine(Path.GetTempPath(), "flow-parity-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(work);
+ try
+ {
+ var settings = AssetDatabase.LoadAssetAtPath("Assets/Resources/Settings.asset")
+ ?? throw new InvalidOperationException("No Settings.asset.");
+ var compute = AssetDatabase.LoadAssetAtPath("Assets/Shaders/Compute/FlowParity.compute")
+ ?? throw new InvalidOperationException("No FlowParity.compute.");
+
+ CheckAuthoredFlow(settings.PlanetSettings.Flow);
+
+ // The authored flow (global term only: its slope amplitudes are authored zero), then a flow with every
+ // term live and a scroll speed that reaches scrolls up to 100.
+ var authored = settings.PlanetSettings.Flow;
+ CheckParity("authored", settings, work, compute, authored, new[] { 0.0, 3.1, 40.0, 100.0 });
+ var wide = new FlowSettings
+ {
+ GlobalScale = authored.GlobalScale, GlobalAmplitude = authored.GlobalAmplitude, Period = authored.Period,
+ GlobalScrollSpeed = 1f, SlopeAmplitude = 3f, SwirlAmplitude = 5f
+ };
+ CheckParity("wide", settings, work, compute, wide, new[] { 0.0, 1.0, 17.0, 60.0, 100.0 });
+ CheckNoiseClock(compute);
+
+ Debug.Log($"{Tag} max error {_maxError:G4} of amplitude (tolerance {RelativeTolerance:G4})");
+ Debug.Log(_ok ? $"{Tag} PASS" : $"{Tag} FAIL: see errors above");
+ }
+ catch (Exception e)
+ {
+ Debug.LogError($"{Tag} FAIL exception: {e}");
+ _ok = false;
+ }
+ finally
+ {
+ Application.logMessageReceived -= OnLog;
+ try { Directory.Delete(work, true); } catch { }
+ }
+ EditorApplication.Exit(_ok ? 0 : 1);
+ }
+
+ // Settings.asset carries the authored flow under PlanetSettings (it moved from DefaultEnvironment unchanged).
+ static void CheckAuthoredFlow(FlowSettings flow)
+ {
+ if (flow == null) { Fail("authored-flow", "PlanetSettings.Flow is null"); return; }
+ if (flow.GlobalScale != 512f || flow.GlobalAmplitude != 15f || flow.GlobalScrollSpeed != .025f ||
+ flow.Period != 8f || flow.SlopeAmplitude != 0f || flow.SwirlAmplitude != 0f)
+ Fail("authored-flow", $"PlanetSettings.Flow is {flow.GlobalScale}/{flow.GlobalAmplitude}/{flow.GlobalScrollSpeed}/" +
+ $"{flow.Period}/{flow.SlopeAmplitude}/{flow.SwirlAmplitude}, authored 512/15/0.025/8/0/0");
+ }
+
+ internal static Zone BuildZone(GameSettings settings, string work, FlowSettings flow, double time = 0)
+ {
+ var cache = AetheriaStores.Open(Path.Combine(work, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true);
+ cache.Upsert(new VerseGrammar { Revision = 1 });
+ cache.FlushAsync().Wait();
+ var items = new ItemManager(cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { });
+ return new Zone(items, new PlanetSettings { Flow = flow }, new ZonePack { Time = time }, new GalaxyZone { Name = "Parity", Owner = null }, null);
+ }
+
+ static void CheckParity(string label, GameSettings settings, string work, ComputeShader compute, FlowSettings flow,
+ double[] zoneTimes)
+ {
+ var zone = BuildZone(settings, work, flow);
+ var kernel = compute.FindKernel("Evaluate");
+ var rng = new System.Random(label.Length * 7919);
+ const int positionCount = 256, gradientCount = 64;
+ const int count = positionCount;
+
+ var positions = new Vector3[count];
+ for (var i = 0; i < positionCount; i++)
+ positions[i] = new Vector3((float) (rng.NextDouble() * 20000 - 10000), (float) (rng.NextDouble() * 200 - 100),
+ (float) (rng.NextDouble() * 20000 - 10000));
+ // Gradients of any direction and magnitude, plus one zero gradient.
+ var gradients = new Vector2[count];
+ for (var i = 1; i < gradientCount; i++)
+ {
+ var angle = rng.NextDouble() * Math.PI * 2;
+ var magnitude = (float) Math.Exp(rng.NextDouble() * 8 - 4);
+ gradients[i] = new Vector2((float) Math.Cos(angle) * magnitude, (float) Math.Sin(angle) * magnitude);
+ }
+ gradients[0] = Vector2.zero;
+
+ var positionBuffer = new ComputeBuffer(count, 12);
+ var gradientBuffer = new ComputeBuffer(count, 8);
+ var globalBuffer = new ComputeBuffer(count, 12);
+ var slopeBuffer = new ComputeBuffer(count, 12);
+ var gatedBuffer = new ComputeBuffer(count, 12);
+ ComputeBuffer noiseBuffer = null;
+ // flow() reads the nebula's gravity height under FLOW_SLOPE. A ramp rising along the texture's x gives every
+ // position inside the grid the gradient direction (1, 0), so the slope term is live where a flat height
+ // would zero it (and the FLOW_SLOPE keyword and its pragma would not matter).
+ const int rampSize = 64;
+ var height = new Texture2D(rampSize, rampSize, TextureFormat.RFloat, false)
+ { filterMode = FilterMode.Point, wrapMode = TextureWrapMode.Clamp };
+ var ramp = new float[rampSize * rampSize];
+ for (var i = 0; i < ramp.Length; i++) ramp[i] = (i % rampSize) / (float) (rampSize - 1);
+ height.SetPixelData(ramp, 0);
+ height.Apply();
+ var rampGradient = new CultMath.float2(1f, 0f);
+ try
+ {
+ positionBuffer.SetData(positions);
+ gradientBuffer.SetData(gradients);
+ compute.SetBuffer(kernel, "positions", positionBuffer);
+ compute.SetBuffer(kernel, "gradients", gradientBuffer);
+ compute.SetBuffer(kernel, "globalOut", globalBuffer);
+ compute.SetBuffer(kernel, "slopeOut", slopeBuffer);
+ compute.SetBuffer(kernel, "gatedOut", gatedBuffer);
+ compute.SetTexture(kernel, "_NebulaSurfaceHeight", height);
+ compute.SetVector("_NebulaSurfaceHeight_TexelSize", new Vector4(1f / rampSize, 1f / rampSize, rampSize, rampSize));
+ // The grid spans 40000: every sampled position (|xz| to 10000) is inside the ramp's uv range.
+ Shader.SetGlobalVector("_GridTransform", new Vector4(0f, 0f, 40000f, 0f));
+ noiseBuffer = new ComputeBuffer(count, 4);
+ compute.SetBuffer(kernel, "noiseOut", noiseBuffer);
+ compute.SetInt("count", count);
+
+ var elapsed = 0.0;
+ foreach (var target in zoneTimes)
+ {
+ // Step the zone to the target by an awkward step; the sum is the zone's time.
+ while (elapsed < target - 1e-9)
+ {
+ var dt = (float) Math.Min(0.37, target - elapsed);
+ zone.Update(dt);
+ elapsed += dt;
+ }
+ VolumeSampling.LowerFlow(zone.Settings.Flow, zone.ExactTime);
+
+ // The shader got the zone's scroll and cycle: the same zone time Zone.Flow evaluates at.
+ var zoneScroll = zone.FlowScroll;
+ var lowered = Shader.GetGlobalVector("_FlowScroll");
+ if (Math.Abs(lowered.x - zoneScroll.x) > 1e-6 || Math.Abs(lowered.y - zoneScroll.y) > 1e-6)
+ Fail($"{label}-lowered-scroll", $"at zone time {elapsed:F3} the shader got _FlowScroll ({lowered.x}, {lowered.y}), the zone's is ({zoneScroll.x}, {zoneScroll.y})");
+ if (Math.Abs(Shader.GetGlobalFloat("_FlowCycle") - zone.CyclePhase(flow.Period)) > 1e-6)
+ Fail($"{label}-lowered-cycle", $"at zone time {elapsed:F3} the shader got _FlowCycle {Shader.GetGlobalFloat("_FlowCycle")}, the zone's phase is {zone.CyclePhase(flow.Period)}");
+ if (Shader.GetGlobalFloat("_FlowScale") != flow.GlobalScale || Shader.GetGlobalFloat("_FlowAmplitude") != flow.GlobalAmplitude ||
+ Shader.GetGlobalFloat("_FlowSlopeAmplitude") != flow.SlopeAmplitude ||
+ Shader.GetGlobalFloat("_FlowSwirlAmplitude") != flow.SwirlAmplitude || Shader.GetGlobalFloat("_FlowPeriod") != flow.Period)
+ Fail($"{label}-lowered-settings", "a flow uniform does not equal the zone's FlowSettings");
+
+ compute.Dispatch(kernel, (count + 63) / 64, 1, 1);
+ var globalOut = new Vector3[count];
+ var slopeOut = new Vector3[count];
+ var gatedOut = new Vector3[count];
+ globalBuffer.GetData(globalOut);
+ slopeBuffer.GetData(slopeOut);
+ gatedBuffer.GetData(gatedOut);
+
+ var at = $"{label} zone time {elapsed:F2} scroll {zoneScroll.x:F4}";
+ for (var i = 0; i < positionCount; i++)
+ {
+ var p = positions[i];
+ var expected = FlowField.Global(flow, new CultMath.float3(p.x, p.y, p.z), zoneScroll);
+ Compare($"{label}-global", at, i, new Vector3(expected.x, expected.y, expected.z), globalOut[i], flow.GlobalAmplitude);
+ }
+ // flow() is the keyword-gated function the nebula and stardust call: the global term plus, under
+ // FLOW_SLOPE, the slope term of the ramp's gradient. A flow that is live must not read zero.
+ var live = 0;
+ var gatedScale = Math.Max(Math.Abs(flow.GlobalAmplitude), Math.Max(Math.Abs(flow.SlopeAmplitude), Math.Abs(flow.SwirlAmplitude)));
+ for (var i = 0; i < positionCount; i++)
+ {
+ var p = positions[i];
+ var expected = FlowField.At(flow, new CultMath.float3(p.x, p.y, p.z), zoneScroll, rampGradient);
+ var e = new Vector3(expected.x, expected.y, expected.z);
+ if (e.magnitude > .1f * gatedScale) live++;
+ Compare($"{label}-flow-keywords", at, i, e, gatedOut[i], gatedScale);
+ }
+ if (gatedScale != 0f && live < positionCount / 4)
+ Fail($"{label}-flow-keywords", $"{at}: only {live} of {positionCount} zone flows are live; the check is vacuous");
+ var slopeScale = Math.Max(Math.Abs(flow.SlopeAmplitude), Math.Abs(flow.SwirlAmplitude));
+ for (var i = 0; i < gradientCount; i++)
+ {
+ var g = gradients[i];
+ var expected = FlowField.Slope(flow, new CultMath.float2(g.x, g.y));
+ Compare($"{label}-slope", at, i, new Vector3(expected.x, expected.y, expected.z), slopeOut[i], slopeScale);
+ }
+ }
+ }
+ finally
+ {
+ positionBuffer.Release(); gradientBuffer.Release(); globalBuffer.Release(); slopeBuffer.Release(); gatedBuffer.Release(); noiseBuffer?.Release(); UnityEngine.Object.DestroyImmediate(height);
+ }
+ }
+
+ // The nebula noise the flow advects evolves on the flow clock VolumeSampling lowers as _NebulaNoiseTime, never on
+ // _Time: with the speed non-zero, the noise changes when _NebulaNoiseTime does and not otherwise (a frame clock
+ // would not follow the global set here).
+ static void CheckNoiseClock(ComputeShader compute)
+ {
+ const int count = 64;
+ var kernel = compute.FindKernel("Evaluate");
+ var rng = new System.Random(4217);
+ var positions = new Vector3[count];
+ for (var i = 0; i < count; i++)
+ positions[i] = new Vector3((float) (rng.NextDouble() * 4000 - 2000), (float) (rng.NextDouble() * 100 - 50), (float) (rng.NextDouble() * 4000 - 2000));
+ var buffers = new[] { new ComputeBuffer(count, 12), new ComputeBuffer(count, 8), new ComputeBuffer(count, 12), new ComputeBuffer(count, 12), new ComputeBuffer(count, 12), new ComputeBuffer(count, 4) };
+ var height = new Texture2D(1, 1, TextureFormat.RFloat, false);
+ try
+ {
+ buffers[0].SetData(positions);
+ buffers[1].SetData(new Vector2[count]);
+ compute.SetBuffer(kernel, "positions", buffers[0]);
+ compute.SetBuffer(kernel, "gradients", buffers[1]);
+ compute.SetBuffer(kernel, "globalOut", buffers[2]);
+ compute.SetBuffer(kernel, "slopeOut", buffers[3]);
+ compute.SetBuffer(kernel, "gatedOut", buffers[4]);
+ compute.SetBuffer(kernel, "noiseOut", buffers[5]);
+ compute.SetTexture(kernel, "_NebulaSurfaceHeight", height);
+ compute.SetInt("count", count);
+ Shader.SetGlobalFloat("_NebulaNoiseSpeed", 1f);
+ float[] Noise(float noiseTime)
+ {
+ Shader.SetGlobalFloat("_NebulaNoiseTime", noiseTime);
+ compute.Dispatch(kernel, 1, 1, 1);
+ var result = new float[count];
+ buffers[5].GetData(result);
+ return result;
+ }
+ var a = Noise(0f);
+ var b = Noise(3f);
+ var again = Noise(3f);
+ int moved = 0, repeated = 0;
+ for (var i = 0; i < count; i++)
+ {
+ if (Math.Abs(a[i] - b[i]) > 1e-4f) moved++;
+ if (b[i] == again[i]) repeated++;
+ }
+ if (moved < count / 2) Fail("nebula-noise-clock", $"only {moved} of {count} noise samples follow _NebulaNoiseTime");
+ if (repeated != count) Fail("nebula-noise-clock", $"{count - repeated} of {count} noise samples changed with _NebulaNoiseTime held");
+ }
+ finally
+ {
+ foreach (var buffer in buffers) buffer.Release();
+ UnityEngine.Object.DestroyImmediate(height);
+ }
+ }
+
+ // Error is measured against the term's amplitude; a term with no amplitude must be exactly zero and never NaN.
+ static void Compare(string check, string at, int index, Vector3 expected, Vector3 actual, float amplitude)
+ {
+ var error = (expected - actual).magnitude;
+ if (float.IsNaN(error)) { Fail(check, $"{at} sample {index}: NaN (expected {expected}, shader {actual})"); return; }
+ if (amplitude != 0f) _maxError = Math.Max(_maxError, error / Math.Abs(amplitude));
+ var tolerance = RelativeTolerance * Math.Abs(amplitude);
+ if (error > tolerance)
+ Fail(check, $"{at} sample {index}: expected {expected}, shader {actual}, error {error:G4} > {tolerance:G4}");
+ }
+ }
+}
diff --git a/Assets/Scripts/Editor/FlowFieldParityVerify.cs.meta b/Assets/Scripts/Editor/FlowFieldParityVerify.cs.meta
new file mode 100644
index 000000000..50248c5f3
--- /dev/null
+++ b/Assets/Scripts/Editor/FlowFieldParityVerify.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8fae18f5f32f10f43ba892a097f18caa
\ No newline at end of file
diff --git a/Assets/Scripts/Editor/FlowFieldSceneVerify.cs b/Assets/Scripts/Editor/FlowFieldSceneVerify.cs
new file mode 100644
index 000000000..80d9a4e31
--- /dev/null
+++ b/Assets/Scripts/Editor/FlowFieldSceneVerify.cs
@@ -0,0 +1,181 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using UnityEditor;
+using UnityEditor.SceneManagement;
+using UnityEngine;
+using UnityEngine.Rendering;
+
+namespace Aetheria.EditorTools
+{
+ ///
+ /// Batchmode check for the flow as the scene runs it (campaign aetheria-fauna, cut fauna-flow-field; target
+ /// invariants flow-one-owner and flow-shader-parity). FlowFieldParityVerify proves the HLSL against FlowField with
+ /// no scene loaded; this opens ARPG.unity, lets VolumeSampling and Stardust initialise as the game does, drives
+ /// their own Start and Update with a zone attached, and asserts what the presenters did:
+ /// - nothing threw and nothing logged an error (a type that cannot initialise in a scene, as VolumeSampling once
+ /// could when it created its keywords in a static field initializer, throws on its first use);
+ /// - FLOW_GLOBAL and FLOW_SLOPE are enabled, _FlowAmplitude is the zone's, and the nebula noise clock is the zone's;
+ /// - the stardust particle buffer changes with the flow and freezes when the zone pauses;
+ /// - CloudShader pass 0 carries the FLOW_* keywords.
+ /// The scene is opened before any Aetheria type is touched: run this method alone in its own Unity session, never
+ /// after FlowFieldParityVerify (which would initialise VolumeSampling outside a MonoBehaviour constructor).
+ /// Dispatches compute and renders, so it needs a graphics device: run WITHOUT -nographics.
+ /// Unity -batchmode -projectPath <repo> -executeMethod Aetheria.EditorTools.FlowFieldSceneVerify.Run -logFile <path>
+ /// Do not pass -quit: Run exits the Editor itself, 0 on success and 1 on any failure.
+ ///
+ public static class FlowFieldSceneVerify
+ {
+ const string Tag = "[FlowFieldSceneVerify]";
+ static bool _ok = true;
+
+ static void OnLog(string message, string stack, LogType type)
+ {
+ if ((type == LogType.Error || type == LogType.Exception || type == LogType.Assert) && !message.StartsWith(Tag))
+ Fail("logged-error", message.Split('\n')[0]);
+ }
+
+ static void Fail(string check, string detail)
+ {
+ Debug.LogError($"{Tag} FAIL {check}: {detail}");
+ _ok = false;
+ }
+
+ public static void Run()
+ {
+ if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null)
+ {
+ Debug.LogError($"{Tag} FAIL: no graphics device in this session; rerun batchmode without -nographics.");
+ EditorApplication.Exit(1);
+ return;
+ }
+
+ Application.logMessageReceived += OnLog;
+ try
+ {
+ // Before anything else touches the project's types: the scene constructs its MonoBehaviours here.
+ EditorSceneManager.OpenScene("Assets/Scenes/ARPG.unity", OpenSceneMode.Single);
+ Check();
+ Debug.Log(_ok ? $"{Tag} PASS" : $"{Tag} FAIL: see errors above");
+ }
+ catch (Exception e)
+ {
+ Debug.LogError($"{Tag} FAIL exception: {e}");
+ _ok = false;
+ }
+ finally
+ {
+ Application.logMessageReceived -= OnLog;
+ }
+ EditorApplication.Exit(_ok ? 0 : 1);
+ }
+
+ static void Check()
+ {
+ var settings = AssetDatabase.LoadAssetAtPath("Assets/Resources/Settings.asset")
+ ?? throw new InvalidOperationException("No Settings.asset.");
+ var agm = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None).FirstOrDefault();
+ var volume = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None).FirstOrDefault();
+ var dust = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None).FirstOrDefault();
+ if (agm == null || volume == null || dust == null)
+ {
+ Fail("scene-objects", $"ARPG.unity has ActionGameManager={agm != null}, VolumeSampling={volume != null}, Stardust={dust != null}");
+ return;
+ }
+
+ // Every flow term live, so FLOW_SLOPE is on; and the same flow with every amplitude zero.
+ var on = new FlowSettings
+ {
+ GlobalScale = 512f, GlobalAmplitude = 15f, GlobalScrollSpeed = .025f, Period = 8f, SlopeAmplitude = 3f, SwirlAmplitude = 5f
+ };
+ var off = new FlowSettings { GlobalScale = on.GlobalScale, GlobalScrollSpeed = on.GlobalScrollSpeed, Period = on.Period };
+ var work = Path.Combine(Path.GetTempPath(), "flow-scene-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(work);
+ try
+ {
+ var zone = FlowFieldParityVerify.BuildZone(settings, work, on, 86400.0 * 3 + 12.3);
+ typeof(ActionGameManager).GetProperty("Instance").GetSetMethod(true).Invoke(null, new object[] { agm });
+ typeof(ActionGameManager).GetProperty("Zone").GetSetMethod(true).Invoke(agm, new object[] { zone });
+ if (agm.Settings == null) agm.Settings = settings;
+
+ Call(volume, "Start");
+ Call(volume, "Update");
+ if (!Shader.IsKeywordEnabled("FLOW_GLOBAL")) Fail("scene-keywords", "FLOW_GLOBAL is not enabled after VolumeSampling.Update");
+ if (!Shader.IsKeywordEnabled("FLOW_SLOPE")) Fail("scene-keywords", "FLOW_SLOPE is not enabled after VolumeSampling.Update");
+ if (Shader.GetGlobalFloat("_FlowAmplitude") != on.GlobalAmplitude)
+ Fail("scene-amplitude", $"_FlowAmplitude is {Shader.GetGlobalFloat("_FlowAmplitude")}, the zone's flow has {on.GlobalAmplitude}");
+ var scroll = Shader.GetGlobalVector("_FlowScroll");
+ if (scroll.x != zone.FlowScroll.x || scroll.y != zone.FlowScroll.y)
+ Fail("scene-scroll", $"_FlowScroll ({scroll.x}, {scroll.y}) is not the zone's ({zone.FlowScroll.x}, {zone.FlowScroll.y})");
+ var noiseTime = Shader.GetGlobalFloat("_NebulaNoiseTime");
+ if (noiseTime != (float) zone.ExactTime)
+ Fail("scene-noise-clock", $"_NebulaNoiseTime is {noiseTime}, the zone's time is {(float) zone.ExactTime}");
+
+ CheckCloudKeywords();
+
+ // Stardust follows the flow through its own dispatch: the particle buffer read back each frame.
+ Call(dust, "Start");
+ var bufferField = typeof(Stardust).GetField("_particlesBuffer", BindingFlags.NonPublic | BindingFlags.Instance);
+ float[] Frame()
+ {
+ Call(dust, "Update");
+ var buffer = (ComputeBuffer) bufferField.GetValue(dust);
+ var data = new float[buffer.count * 7];
+ buffer.GetData(data);
+ return data;
+ }
+ var flowing = Frame();
+ VolumeSampling.LowerFlow(off, VolumeSampling.FlowClock);
+ var still = Frame();
+ if (Differing(flowing, still) == 0) Fail("scene-stardust", "the particle buffer is the same with the zone's flow and with no flow");
+
+ // A paused zone freezes the particles and the nebula noise clock; a running one moves both.
+ Call(volume, "Update");
+ zone.Update(0f);
+ Call(volume, "Update");
+ if (Differing(flowing, Frame()) != 0) Fail("scene-stardust-pause", "the particle buffer moved while the zone was paused");
+ if (Shader.GetGlobalFloat("_NebulaNoiseTime") != noiseTime) Fail("scene-noise-clock", "_NebulaNoiseTime moved while the zone was paused");
+ zone.Update(.05f);
+ Call(volume, "Update");
+ if (Differing(flowing, Frame()) == 0) Fail("scene-stardust-run", "the particle buffer did not move while the zone ran");
+ if (Shader.GetGlobalFloat("_NebulaNoiseTime") == noiseTime) Fail("scene-noise-clock", "_NebulaNoiseTime did not move while the zone ran");
+ }
+ finally
+ {
+ try { Directory.Delete(work, true); } catch { }
+ }
+ }
+
+ // CloudShader's pass 0 compiles with the flow keywords (it must #include_with_pragmas the shared include).
+ static void CheckCloudKeywords()
+ {
+ var shader = AssetDatabase.LoadAssetAtPath("Assets/Shaders/Raymarching/CloudShader.shader");
+ if (shader == null) { Fail("scene-cloud-keywords", "No CloudShader.shader."); return; }
+ var keywords = ShaderUtil.GetPassKeywords(shader, new PassIdentifier(0, 0), UnityEditor.Rendering.ShaderType.Fragment)
+ .Select(keyword => keyword.name).ToArray();
+ foreach (var name in new[] { "FLOW_GLOBAL", "FLOW_SLOPE" })
+ if (!keywords.Contains(name)) Fail("scene-cloud-keywords", $"CloudShader pass 0 does not carry {name}");
+ }
+
+ static int Differing(float[] a, float[] b)
+ {
+ var count = 0;
+ for (var i = 0; i < a.Length; i++)
+ if (Math.Abs(a[i] - b[i]) > 1e-4f) count++;
+ return count;
+ }
+
+ // The presenters' own Start and Update, as the game calls them; an exception is a failure that names the type.
+ static void Call(object target, string method)
+ {
+ var info = target.GetType().GetMethod(method, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance);
+ if (info == null) { Fail("scene-presenter-exception", $"{target.GetType().Name}.{method} not found"); return; }
+ try { info.Invoke(target, null); }
+ catch (TargetInvocationException e)
+ {
+ Fail("scene-presenter-exception", $"{target.GetType().Name}.{method} threw {e.InnerException?.GetType().Name}");
+ }
+ }
+ }
+}
diff --git a/Assets/Scripts/Editor/FlowFieldSceneVerify.cs.meta b/Assets/Scripts/Editor/FlowFieldSceneVerify.cs.meta
new file mode 100644
index 000000000..77339880b
--- /dev/null
+++ b/Assets/Scripts/Editor/FlowFieldSceneVerify.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 25fbd79fca1de49498c3bebf9f63243b
\ No newline at end of file
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..29613cd78
--- /dev/null
+++ b/Assets/Scripts/ServerShared/FlowField.cs
@@ -0,0 +1,78 @@
+using CultMath;
+using static CultMath.math;
+using float2 = CultMath.float2;
+using float3 = CultMath.float3;
+
+///
+/// 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 second octave of the noise samples at position * Golden (Volumetric.cginc Tri3D).
+ const float Golden = 1.61803398875f;
+
+ // The scroll of the noise field, in field units: speed times zone time, for each of the two octaves, as a phase.
+ // Every term of Tri3D reaches the scrolled axis through frac, so a phase is only meaningful modulo 1: the fold
+ // happens here in double, where zone time is exact, and the float that reaches the shaders is in [0, 1) however
+ // old the zone is. x is the first octave's phase, y the second's (the second octave's axis is scaled by Golden).
+ public static float2 Scroll(FlowSettings settings, double zoneTime)
+ {
+ var scroll = (double) settings.GlobalScrollSpeed * zoneTime;
+ return float2(Phase(scroll), Phase(scroll * Golden));
+ }
+
+ // A phase in [0, 1) strictly: a double just below an integer rounds up to 1f in the cast, and 1 is 0 modulo 1.
+ static float Phase(double value)
+ {
+ var phase = (float) frac(value);
+ return phase < 1f ? phase : 0f;
+ }
+
+ // The phase of a cycle of `period` seconds at zone time, in [0, 1). The nebula's flow-advected noise cycles and
+ // Stardust's particle lifetimes take their phase from here, so drift follows sim time and stops with the sim.
+ public static float Cycle(double zoneTime, float period) => period == 0f ? 0f : Phase(zoneTime / period);
+
+ // 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, float2 scroll)
+ {
+ if (settings.GlobalAmplitude == 0f) return float3.zero;
+ var field = position / settings.GlobalScale;
+ var sample = Tri3D(field - float3(0f, scroll.x, 0f), field * Golden - float3(0f, scroll.y, 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, float2 scroll, float2 downhill) =>
+ Global(settings, position, scroll) + 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)));
+
+ // The two octaves' positions are given already scrolled (see Scroll).
+ static float3 Tri3D(float3 p, float3 pGolden)
+ {
+ var t1 = normalize(Tri3(p));
+ var t2 = normalize(Tri3(pGolden));
+ return cross(t1, t2);
+ }
+}
diff --git a/Assets/Scripts/ServerShared/FlowField.cs.meta b/Assets/Scripts/ServerShared/FlowField.cs.meta
new file mode 100644
index 000000000..c0b18d70e
--- /dev/null
+++ b/Assets/Scripts/ServerShared/FlowField.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8105a45840c4552459aaf44e6c0e19ce
\ No newline at end of file
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 e7d17bd54..16373f147 100644
--- a/Assets/Scripts/ServerShared/Zone.cs
+++ b/Assets/Scripts/ServerShared/Zone.cs
@@ -110,7 +110,7 @@ public class Zone
}
// Zone time at full precision, for the index's orbital keys, which must agree with ChunkPose's own.
- internal double ExactTime => _time;
+ public double ExactTime => _time;
public ZonePack Pack { get; }
public GalaxyZone GalaxyZone { get; }
public Galaxy Galaxy { get; }
@@ -695,6 +695,20 @@ 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, FlowScroll, new float2(normal.x, normal.z));
+ }
+
+ // The flow's noise scroll at zone time (Zone.Flow evaluates at exactly this); lowered to the shaders' _FlowScroll.
+ public float2 FlowScroll => FlowField.Scroll(Settings.Flow, _time);
+
+ // The phase of a cycle of `period` seconds at zone time, for presenters that animate on the flow.
+ public float CyclePhase(float period) => FlowField.Cycle(_time, period);
+
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..fdc378af6 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)
@@ -52,6 +47,7 @@ public class VolumeSampling : MonoBehaviour
Shader.SetGlobalVector("_GridTransform", new Vector4(_gridTransform.position.x,_gridTransform.position.z,GridCamera.orthographicSize*2));
var environment = ActionGameManager.Instance?.CurrentEnvironment ?? Settings.DefaultEnvironment;
+ var clock = FlowClock;
if (GridMesh != null)
{
@@ -91,18 +87,46 @@ public class VolumeSampling : MonoBehaviour
Shader.SetGlobalFloat("_NebulaNoiseExponent", environment.Noise.Exponent);
Shader.SetGlobalFloat("_NebulaNoiseAmplitude", environment.Noise.Amplitude);
Shader.SetGlobalFloat("_NebulaNoiseSpeed", environment.Noise.Speed);
+ // The noise the flow advects evolves on the flow's clock too: it pauses with the sim.
+ Shader.SetGlobalFloat("_NebulaNoiseTime", (float) clock);
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.
+ LowerFlow(ActionGameManager.Instance?.Zone?.Settings.Flow ?? Settings.PlanetSettings.Flow, clock);
Shader.SetKeyword(_keywordNoiseSlope, environment.Noise.SlopeExponent != 0);
}
+
+ // GlobalKeyword's constructor only looks a keyword up; Create registers it. The shaders' pragmas and this owner
+ // agree on the names. Unity refuses Create inside a MonoBehaviour's construction, and a scene constructing the
+ // first VolumeSampling runs this type's static initializer there, so the keywords are created on first use, from
+ // LowerFlow, never in a field initializer.
+ static GlobalKeyword? _keywordFlowGlobal;
+ static GlobalKeyword? _keywordFlowSlope;
+ static GlobalKeyword KeywordFlowGlobal => _keywordFlowGlobal ??= GlobalKeyword.Create("FLOW_GLOBAL");
+ 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.
+ ///
+ public static double FlowClock => ActionGameManager.Instance?.Zone?.ExactTime ?? Time.timeSinceLevelLoad;
+
+ ///
+ /// The one place that sets the flow uniforms and keywords: every shader flow term derives from FlowSettings and
+ /// the flow clock through FlowField, so no presenter keeps its own flow clock.
+ ///
+ public static void LowerFlow(FlowSettings flow, double flowClock)
+ {
+ Shader.SetGlobalFloat("_FlowScale", flow.GlobalScale);
+ Shader.SetGlobalFloat("_FlowAmplitude", flow.GlobalAmplitude);
+ Shader.SetGlobalFloat("_FlowSlopeAmplitude", flow.SlopeAmplitude);
+ Shader.SetGlobalFloat("_FlowSwirlAmplitude", flow.SwirlAmplitude);
+ var scroll = FlowField.Scroll(flow, flowClock);
+ Shader.SetGlobalVector("_FlowScroll", new Vector4(scroll.x, scroll.y));
+ Shader.SetGlobalFloat("_FlowPeriod", flow.Period);
+ Shader.SetGlobalFloat("_FlowCycle", FlowField.Cycle(flowClock, flow.Period));
+
+ Shader.SetKeyword(KeywordFlowGlobal, flow.GlobalAmplitude != 0);
+ Shader.SetKeyword(KeywordFlowSlope, flow.SlopeAmplitude != 0 || flow.SwirlAmplitude != 0);
+ }
}
diff --git a/Assets/Shaders/Compute/FlowParity.compute b/Assets/Shaders/Compute/FlowParity.compute
new file mode 100644
index 000000000..a431accef
--- /dev/null
+++ b/Assets/Shaders/Compute/FlowParity.compute
@@ -0,0 +1,27 @@
+// Evaluates the shader flow functions for a buffer of positions and gradients, so that
+// Aetheria.EditorTools.FlowFieldParityVerify can compare them with FlowField (ServerShared). Reads the same global
+// _Flow* uniforms the nebula and stardust read, set by VolumeSampling.LowerFlow.
+#pragma kernel Evaluate
+// A compute shader declares the global keywords itself (as Stardust.compute does); the flow() it calls is keyword-gated.
+#pragma multi_compile __ FLOW_GLOBAL
+#pragma multi_compile __ FLOW_SLOPE
+
+#include "Assets/Shaders/Volumetric.cginc"
+
+StructuredBuffer positions;
+StructuredBuffer gradients;
+RWStructuredBuffer globalOut;
+RWStructuredBuffer slopeOut;
+RWStructuredBuffer gatedOut; // flow(): the keyword-gated function the nebula and stardust call
+RWStructuredBuffer noiseOut; // triNoise3d: the nebula noise the flow advects
+uint count;
+
+[numthreads(64, 1, 1)]
+void Evaluate(uint3 id : SV_DispatchThreadID)
+{
+ if (id.x >= count) return;
+ globalOut[id.x] = globalFlow(positions[id.x]);
+ slopeOut[id.x] = slopeFlow(gradients[id.x]);
+ gatedOut[id.x] = flow(positions[id.x]);
+ noiseOut[id.x] = triNoise3d(positions[id.x] / 512);
+}
diff --git a/Assets/Shaders/Compute/FlowParity.compute.meta b/Assets/Shaders/Compute/FlowParity.compute.meta
new file mode 100644
index 000000000..30b9beea8
--- /dev/null
+++ b/Assets/Shaders/Compute/FlowParity.compute.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: fd5f95c7d7c63b243aadd16f6e9dc702
+ComputeShaderImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Shaders/Compute/Stardust/Stardust.compute b/Assets/Shaders/Compute/Stardust/Stardust.compute
index c2698f766..1188cb237 100644
--- a/Assets/Shaders/Compute/Stardust/Stardust.compute
+++ b/Assets/Shaders/Compute/Stardust/Stardust.compute
@@ -25,7 +25,7 @@ Texture2D HueTexture;
SamplerState samplerHueTexture;
float period;
-float time;
+float phase; // the cycle phase from zone time (FlowField.Cycle), in [0, 1)
float spacing;
float ceilingHeight;
float floorHeight;
@@ -125,7 +125,7 @@ void UpdateParticles(uint3 id : SV_DispatchThreadID, int3 groupID : SV_GroupThre
uint xp = x + xt + offset;
uint yp = y + yt + offset;
- const float lifetime = frac(time / period + RandomFirst(xp, yp));
+ const float lifetime = frac(phase + RandomFirst(xp, yp));
particles[id.x].position = float3(
x * spacing + xt * spacing + perlinNoise1D(RandomValue()*1024)*spacing, 0,
diff --git a/Assets/Shaders/Compute/Stardust/Stardust.cs b/Assets/Shaders/Compute/Stardust/Stardust.cs
index d75b18f99..a6636fd2e 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
@@ -102,7 +101,8 @@ public class Stardust : MonoBehaviour
//Bind resources to compute shader
ParticleCalculation.SetBuffer(_updateParticlesKernel, "particles", _particlesBuffer);
- ParticleCalculation.SetFloat("time", Time.time);
+ // Particle lifetimes cycle on the flow clock (zone time), so the dust stops with the sim like everything the flow moves.
+ ParticleCalculation.SetFloat("phase", FlowField.Cycle(VolumeSampling.FlowClock, Period));
var pos = GravityCamera.transform.position;
ParticleCalculation.SetFloat("period", Period);
diff --git a/Assets/Shaders/Volumetric.cginc b/Assets/Shaders/Volumetric.cginc
index 44fad2c06..ec95aedc3 100644
--- a/Assets/Shaders/Volumetric.cginc
+++ b/Assets/Shaders/Volumetric.cginc
@@ -33,7 +33,7 @@ uniform half _NebulaFillDensity,
_NebulaNoiseSlopeExponent,
_FlowScale,
_FlowAmplitude,
- _FlowScroll,
+ _FlowCycle,
_FlowPeriod,
_FlowSlopeAmplitude,
_FlowSwirlAmplitude,
@@ -42,6 +42,9 @@ uniform half _NebulaFillDensity,
_DynamicLodHigh,
_DynamicIntensity,
_DynamicSkyBoost;
+float2 _FlowScroll;
+// The flow clock (zone time, VolumeSampling.FlowClock): the nebula noise evolves on it, never on _Time.
+float _NebulaNoiseTime;
inline float tri(in float x){return abs(frac(x)-.5);}
inline float3 tri3(in float3 p){return float3( tri(p.z+tri(p.y*1.)), tri(p.z+tri(p.x*1.)), tri(p.y+tri(p.x*1.)));}
@@ -54,7 +57,7 @@ float triNoise3d(in float3 p)
for (float i=0.; i<=1.; i++ )
{
float3 dg = tri3(bp * 2.);
- p += dg + _Time.y * _NebulaNoiseSpeed;
+ p += dg + _NebulaNoiseTime * _NebulaNoiseSpeed;
bp *= 1.8;
z *= 1.5;
@@ -66,10 +69,11 @@ float triNoise3d(in float3 p)
return rz;
}
-float3 Tri3D( float3 P )
+// The two octaves' positions arrive already scrolled (FlowField.Scroll folds each octave's phase in double).
+float3 Tri3D( float3 P, float3 PGolden )
{
float3 t1 = normalize(tri3(P));
- float3 t2 = normalize(tri3(P * 1.61803398875));
+ float3 t2 = normalize(tri3(PGolden));
return cross(t1,t2);
}
@@ -129,13 +133,15 @@ float3 gravNormal (float2 grad)
float3 globalFlow(float3 pos)
{
- float3 flowSample = Tri3D( pos / _FlowScale - float3(0,_FlowScroll,0) ) * _FlowAmplitude;
+ const float3 field = pos / _FlowScale;
+ float3 flowSample = Tri3D( field - float3(0,_FlowScroll.x,0), field * 1.61803398875 - float3(0,_FlowScroll.y,0) ) * _FlowAmplitude;
flowSample.y *= .5;
return flowSample;
}
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;
}
@@ -197,13 +203,13 @@ float density(float3 pos)
#endif
#endif
- const float lerp1 = frac(_Time.y / _FlowPeriod);
- const float lerp2 = frac(_Time.y / _FlowPeriod + .5);
+ const float lerp1 = frac(_FlowCycle);
+ const float lerp2 = frac(_FlowCycle + .5);
const float noise1 = pow(triNoise3d((pos+fl.xyz * (lerp1 - .5) * _FlowPeriod) / _NebulaNoiseScale),exponent) * tri2(lerp1);
const float noise2 = pow(triNoise3d((pos+fl.xyz * (lerp2 - .5) * _FlowPeriod) / _NebulaNoiseScale),exponent) * tri2(lerp2);
pos.y += (noise1 + noise2) * heightFade * _NebulaNoiseAmplitude;
- const float lerp3 = frac(_Time.y / _FlowPeriod * 2 + .25);
- const float lerp4 = frac(_Time.y / _FlowPeriod * 2 + .75);
+ const float lerp3 = frac(_FlowCycle * 2 + .25);
+ const float lerp4 = frac(_FlowCycle * 2 + .75);
const float noise3 = pow(triNoise3d((pos+fl.xyz * (lerp3 - .5) * _FlowPeriod / 2) / _NebulaNoiseScale * 8), exponent) * tri2(lerp3);
const float noise4 = pow(triNoise3d((pos+fl.xyz * (lerp4 - .5) * _FlowPeriod / 2) / _NebulaNoiseScale * 8), exponent) * tri2(lerp4);
pos.y -= (noise3 + noise4) * heightFade * _NebulaNoiseAmplitude / 2;
diff --git a/tests/Aetheria.Shared.Tests/FlowFieldTests.cs b/tests/Aetheria.Shared.Tests/FlowFieldTests.cs
new file mode 100644
index 000000000..2c08c3aa1
--- /dev/null
+++ b/tests/Aetheria.Shared.Tests/FlowFieldTests.cs
@@ -0,0 +1,319 @@
+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.x, 6);
+ Assert.Equal(float2(0f, 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.x, coarse.FlowScroll.x, 5);
+ Assert.Equal(fine.FlowScroll.y, coarse.FlowScroll.y, 5);
+ // The flow moved: a scroll of 0.025 per second is a different field from the start, somewhere.
+ var start = ZoneWith(Flow());
+ Assert.Contains(Probes, p => length(start.Flow(p) - fine.Flow(p)) > .05f);
+ }
+
+ [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) AssertClose(one.Flow(p) * 2f, two.Flow(p));
+ // The flow is live (the origin is a legitimate zero of the field, so it is not every probe).
+ Assert.Contains(Probes, p => length(one.Flow(p)) > 1f);
+ }
+
+ [Fact]
+ public void ZoneFlowIsTheLawAtTheScrollTheShaderGets()
+ {
+ // Zone.Flow and the lowered scroll are one zone-time value: the flow at p is the law evaluated at the zone's
+ // own FlowScroll, after any run of steps (a flow that ran ahead of or behind the shader's scroll differs here).
+ var zone = ZoneWith(Flow(), 3.25);
+ for (var step = 0; step < 90; step++)
+ {
+ zone.Update(1f / 60f);
+ foreach (var p in Probes) AssertClose(FlowField.Global(Flow(), p, zone.FlowScroll), zone.Flow(p), 1e-4f);
+ }
+ }
+
+ [Fact]
+ public void PausedSimFreezesTheFlowPhases()
+ {
+ // Presenters take their cycle phase from zone time: a zone that does not advance does not move them, and a
+ // zone that does moves them by dt / period (mod 1).
+ const float period = 8f;
+ var zone = ZoneWith(Flow(), 5.0);
+ var before = zone.CyclePhase(period);
+ var scroll = zone.FlowScroll;
+ zone.Update(0f);
+ Assert.Equal(before, zone.CyclePhase(period));
+ Assert.Equal(scroll, zone.FlowScroll);
+ zone.Update(2f);
+ Assert.Equal(frac(before + 2f / period), zone.CyclePhase(period), 5);
+ Assert.InRange(zone.CyclePhase(period), 0f, .9999999f);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(10)]
+ public void ScrollKeepsAdvancingAtDaysOfZoneTime(int days)
+ {
+ // Zone time persists and only grows. A float of speed x time stops resolving a 1/60 s step after days; the
+ // phases the shader gets stay in [0, 1) and advance by speed x dt on every step.
+ const double dt = 1.0 / 60.0;
+ var flow = Flow();
+ var zone = ZoneWith(flow, days * 86400.0 + .1);
+ var previous = zone.FlowScroll;
+ for (var step = 0; step < 30; step++)
+ {
+ zone.Update((float) dt);
+ var now = zone.FlowScroll;
+ Assert.InRange(now.x, 0f, .9999999f);
+ Assert.InRange(now.y, 0f, .9999999f);
+ var dx = now.x - previous.x;
+ var dy = now.y - previous.y;
+ if (dx < -.5f) dx += 1f;
+ if (dy < -.5f) dy += 1f;
+ var expected = flow.GlobalScrollSpeed * dt;
+ Assert.InRange((double) dx, expected - 5e-7, expected + 5e-7);
+ Assert.InRange((double) dy, expected * 1.61803398875 - 5e-7, expected * 1.61803398875 + 5e-7);
+ previous = now;
+ }
+ }
+
+ // The law as Volumetric.cginc spelled it before the per-octave fold: Tri3D(p) with p * Golden for the second
+ // octave and p = pos / scale - (0, speed * t, 0), in double with the unfolded scroll. The oracle the fold must
+ // agree with; it shares no code with FlowField.
+ // The origin is a degenerate point of the field (a zero cross product), where no law can be compared.
+ private static readonly float3[] OffOrigin = Probes[1..];
+
+ private static double OTri(double x) => Math.Abs(x - Math.Floor(x) - .5);
+ private static double[] OTri3(double x, double y, double z) => new[] { OTri(z + OTri(y)), OTri(z + OTri(x)), OTri(y + OTri(x)) };
+
+ private static double[] ONormalize(double[] v)
+ {
+ var l = Math.Sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
+ return new[] { v[0] / l, v[1] / l, v[2] / l };
+ }
+
+ private static double[] UnfoldedLaw(FlowSettings f, float3 p, double t)
+ {
+ double g = 1.61803398875f;
+ var s = (double) f.GlobalScrollSpeed * t;
+ double x = p.x / (double) f.GlobalScale, y = p.y / (double) f.GlobalScale - s, z = p.z / (double) f.GlobalScale;
+ var t1 = ONormalize(OTri3(x, y, z));
+ var t2 = ONormalize(OTri3(x * g, y * g, z * g));
+ var c = new[] { t1[1] * t2[2] - t1[2] * t2[1], t1[2] * t2[0] - t1[0] * t2[2], t1[0] * t2[1] - t1[1] * t2[0] };
+ return new[] { c[0] * f.GlobalAmplitude, c[1] * f.GlobalAmplitude * .5, c[2] * f.GlobalAmplitude };
+ }
+
+ private static double Distance(float3 a, double[] b) =>
+ Math.Sqrt((a.x - b[0]) * (a.x - b[0]) + (a.y - b[1]) * (a.y - b[1]) + (a.z - b[2]) * (a.z - b[2]));
+
+ [Fact]
+ public void FoldedScrollIsTheUnfoldedLawAtAnyAge()
+ {
+ // fold-law: the phase each octave gets is the unfolded speed x time (second octave: x Golden), folded once in
+ // double. Computing the second from the folded first (frac(frac(s) x Golden)) agrees below 40 s and nowhere after.
+ var f = Flow();
+ foreach (var t in new[] { 0.0, 3.1, 39.99, 40.0, 40.01, 86400.0, 864000.0 + .3, 8640000.0 + .7 })
+ foreach (var p in OffOrigin)
+ Assert.True(Distance(FlowField.Global(f, p, FlowField.Scroll(f, t)), UnfoldedLaw(f, p, t)) / f.GlobalAmplitude < 5e-5,
+ $"t={t} p={p}");
+ }
+
+ [Fact]
+ public void NoJumpWhereAPhaseWraps()
+ {
+ // Each octave's phase wraps at its own times (the first every 40 s of zone time at speed .025): across a wrap
+ // the flow moves by what the unfolded law moves, so nothing jumps.
+ var f = Flow();
+ double speed = f.GlobalScrollSpeed, golden = 1.61803398875f;
+ const double h = 1.0 / 120.0;
+ foreach (var (octave, cycles) in new[] { (0, 1.0), (0, 21600.0), (1, 1.0), (1, 34950.0) })
+ {
+ var t = octave == 0 ? cycles / speed : cycles / (speed * golden);
+ var before = FlowField.Scroll(f, t - h);
+ var after = FlowField.Scroll(f, t + h);
+ Assert.True(octave == 0 ? before.x > .9f && after.x < .1f : before.y > .9f && after.y < .1f,
+ $"octave {octave} cycles {cycles}: the probe times do not straddle a wrap");
+ foreach (var p in OffOrigin)
+ {
+ var folded = FlowField.Global(f, p, after) - FlowField.Global(f, p, before);
+ var a = UnfoldedLaw(f, p, t + h);
+ var b = UnfoldedLaw(f, p, t - h);
+ Assert.True(Distance(folded, new[] { a[0] - b[0], a[1] - b[1], a[2] - b[2] }) / f.GlobalAmplitude < 1e-4,
+ $"octave {octave} cycles {cycles} p={p}");
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData(10)]
+ [InlineData(100)]
+ public void CycleKeepsAdvancingAtDays(int days)
+ {
+ // The cycle phase takes zone time in double: a float zone time at 10 days (8.6e5 s) resolves 0.06 s and at
+ // 100 days 1 s, so a 1/60 s step would not move the phase.
+ foreach (var period in new[] { 8f, .25f })
+ {
+ const double dt = 1.0 / 60.0;
+ var time = days * 86400.0 + .1;
+ var previous = FlowField.Cycle(time, period);
+ for (var step = 0; step < 30; step++)
+ {
+ time += dt;
+ var now = FlowField.Cycle(time, period);
+ var advance = now - previous;
+ if (advance < -.5f) advance += 1f;
+ Assert.InRange((double) advance, dt / period - 1e-5, dt / period + 1e-5);
+ previous = now;
+ }
+ }
+ }
+
+ [Fact]
+ public void PhasesAreStrictlyBelowOne()
+ {
+ // A phase just below an integer rounds to 1f in a float cast; the contract is [0, 1), so it reads 0 instead
+ // (1 is 0 modulo 1: the flow there is the flow at 0).
+ var f = Flow();
+ double speed = f.GlobalScrollSpeed, golden = 1.61803398875f;
+ foreach (var gap in new[] { 1e-9, 1e-12 })
+ {
+ var first = FlowField.Scroll(f, (1.0 - gap) / speed);
+ var second = FlowField.Scroll(f, (2.0 - gap) / (speed * golden));
+ Assert.InRange(first.x, 0f, .9999999f);
+ Assert.InRange(second.y, 0f, .9999999f);
+ Assert.InRange(FlowField.Cycle(8.0 * (1.0 - gap), 8f), 0f, .9999999f);
+ }
+ }
+}
diff --git a/tests/mutation_tests_flow_parity.py b/tests/mutation_tests_flow_parity.py
new file mode 100644
index 000000000..d0893e702
--- /dev/null
+++ b/tests/mutation_tests_flow_parity.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""Mutation tests for the flow-shader-parity rule (campaign aetheria-fauna, cut fauna-flow-field).
+
+Assets/Scripts/Editor/FlowFieldSceneVerify.cs opens ARPG.unity and drives VolumeSampling and Stardust as the game
+does (the keywords, the stardust buffer, CloudShader's keywords); each mutant below names the check that must catch it.
+Assets/Scripts/Editor/FlowFieldParityVerify.cs evaluates the HLSL globalFlow and slopeFlow
+(Assets/Shaders/Volumetric.cginc, through FlowParity.compute) and FlowField (ServerShared) for the
+same positions, scrolls, settings and gradients, after VolumeSampling.LowerFlow has lowered a stepped
+Zone's flow into the shader globals. Tests.asmdef cannot see Assembly-CSharp and the check needs a GPU,
+so it runs here, in Unity batchmode WITHOUT -nographics (see ShieldPanelCut3Verify.cs).
+
+A control run proves the unmutated tree passes. Then each mutant edits the real source in one way a
+rule of the cut forbids, runs the check, asserts it goes red naming the check that owns the rule, and
+restores the file byte for byte. A mutant that fails for the wrong reason (a broken harness) cannot
+hide behind the control.
+
+Run from the repo root: py -3 tests/mutation_tests_flow_parity.py [--only