From b15500368975e95a9975b6a7bdd2f0b398625595 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 23:25:44 +0200 Subject: [PATCH] Flow: keywords created on first use, strict [0,1) phases, nebula noise on the flow clock, fold-law and cycle tests VolumeSampling created its flow keywords in static field initializers, which a scene runs inside a MonoBehaviour construction where Unity refuses GlobalKeyword.Create; they are now created lazily from LowerFlow. FlowField.Scroll and Cycle return phases strictly below 1. The nebula noise reads _NebulaNoiseTime (zone time) instead of _Time.y. New FlowFieldTests pin the per-octave fold to the unfolded law across wraps, the cycle at days of zone time, and the phase edge. The Unity checks for these are in the next commit; this one builds, its parity check does not yet read the noise clock. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/FlowField.cs | 11 +- Assets/Scripts/Zone Display/VolumeSampling.cs | 16 ++- Assets/Shaders/Volumetric.cginc | 4 +- tests/Aetheria.Shared.Tests/FlowFieldTests.cs | 105 ++++++++++++++++++ 4 files changed, 129 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/ServerShared/FlowField.cs b/Assets/Scripts/ServerShared/FlowField.cs index 0115532b6..29613cd78 100644 --- a/Assets/Scripts/ServerShared/FlowField.cs +++ b/Assets/Scripts/ServerShared/FlowField.cs @@ -21,12 +21,19 @@ public static class FlowField public static float2 Scroll(FlowSettings settings, double zoneTime) { var scroll = (double) settings.GlobalScrollSpeed * zoneTime; - return float2((float) frac(scroll), (float) frac(scroll * Golden)); + 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 : (float) frac(zoneTime / period); + 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. diff --git a/Assets/Scripts/Zone Display/VolumeSampling.cs b/Assets/Scripts/Zone Display/VolumeSampling.cs index 2c6f9ba7a..fdc378af6 100644 --- a/Assets/Scripts/Zone Display/VolumeSampling.cs +++ b/Assets/Scripts/Zone Display/VolumeSampling.cs @@ -47,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) { @@ -86,16 +87,23 @@ 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); // 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, FlowClock); + 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. - static readonly GlobalKeyword KeywordFlowGlobal = GlobalKeyword.Create("FLOW_GLOBAL"); - static readonly GlobalKeyword KeywordFlowSlope = GlobalKeyword.Create("FLOW_SLOPE"); + // 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, diff --git a/Assets/Shaders/Volumetric.cginc b/Assets/Shaders/Volumetric.cginc index d72960efd..ec95aedc3 100644 --- a/Assets/Shaders/Volumetric.cginc +++ b/Assets/Shaders/Volumetric.cginc @@ -43,6 +43,8 @@ uniform half _NebulaFillDensity, _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.)));} @@ -55,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; diff --git a/tests/Aetheria.Shared.Tests/FlowFieldTests.cs b/tests/Aetheria.Shared.Tests/FlowFieldTests.cs index 9bbbafc10..b3efe70a9 100644 --- a/tests/Aetheria.Shared.Tests/FlowFieldTests.cs +++ b/tests/Aetheria.Shared.Tests/FlowFieldTests.cs @@ -208,4 +208,109 @@ public sealed class FlowFieldTests : IDisposable 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. + 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 Probes) + 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 Probes) + { + 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); + } + } }