diff --git a/Assets/Scripts/Editor/FlowFieldParityVerify.cs b/Assets/Scripts/Editor/FlowFieldParityVerify.cs index a6c113881..518ec0060 100644 --- a/Assets/Scripts/Editor/FlowFieldParityVerify.cs +++ b/Assets/Scripts/Editor/FlowFieldParityVerify.cs @@ -21,10 +21,21 @@ namespace Aetheria.EditorTools public static class FlowFieldParityVerify { const string Tag = "[FlowFieldParityVerify]"; - const float RelativeTolerance = 1e-3f; + // Measured disagreement between the HLSL and the C# on this tree (float32 on both sides, positions to 10000, + // scrolls to 100): see the "max error" line the run prints; the last measurement is recorded in the report that + // landed this value. The tolerance is a small multiple of that noise so a 0.05% constant error (y factor .501, + // swirl or amplitude x1.0005) fails. + const float RelativeTolerance = 3e-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(' +')[0]); + } + static void Fail(string check, string detail) { Debug.LogError($"{Tag} FAIL {check}: {detail}"); @@ -40,6 +51,8 @@ namespace Aetheria.EditorTools 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 @@ -72,6 +85,7 @@ namespace Aetheria.EditorTools } finally { + Application.logMessageReceived -= OnLog; try { Directory.Delete(work, true); } catch { } } EditorApplication.Exit(_ok ? 0 : 1); @@ -123,6 +137,7 @@ namespace Aetheria.EditorTools var gradientBuffer = new ComputeBuffer(count, 8); var globalBuffer = new ComputeBuffer(count, 12); var slopeBuffer = new ComputeBuffer(count, 12); + var gatedBuffer = new ComputeBuffer(count, 12); try { positionBuffer.SetData(positions); @@ -131,6 +146,7 @@ namespace Aetheria.EditorTools compute.SetBuffer(kernel, "gradients", gradientBuffer); compute.SetBuffer(kernel, "globalOut", globalBuffer); compute.SetBuffer(kernel, "slopeOut", slopeBuffer); + compute.SetBuffer(kernel, "gatedOut", gatedBuffer); compute.SetInt("count", count); var elapsed = 0.0; @@ -143,16 +159,15 @@ namespace Aetheria.EditorTools zone.Update(dt); elapsed += dt; } - var expectedScroll = flow.GlobalScrollSpeed * elapsed; - var scrollTolerance = 1e-4 * Math.Max(1.0, expectedScroll); - if (Math.Abs(zone.FlowScroll - expectedScroll) > scrollTolerance) - Fail($"{label}-zone-scroll", $"at zone time {elapsed:F3} FlowScroll {zone.FlowScroll} but speed x time is {expectedScroll}"); + VolumeSampling.LowerFlow(zone.Settings.Flow, zone.ExactTime); - VolumeSampling.LowerFlow(zone.Settings.Flow, zone.FlowScroll); - - var lowered = Shader.GetGlobalFloat("_FlowScroll"); - if (Math.Abs(lowered - expectedScroll) > scrollTolerance) - Fail($"{label}-lowered-scroll", $"at zone time {elapsed:F3} the shader got _FlowScroll {lowered}, speed x time is {expectedScroll}"); + // 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) @@ -161,17 +176,31 @@ namespace Aetheria.EditorTools 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 zoneScroll = zone.FlowScroll; - var at = $"{label} zone time {elapsed:F2} scroll {zoneScroll:F4}"; + 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 zone's flat well has a zero + // gradient, so Zone.Flow is the global term here. A flow that is live must not read zero. + var live = 0; + for (var i = 0; i < positionCount; i++) + { + var p = positions[i]; + var expected = zone.Flow(new CultMath.float3(p.x, p.y, p.z)); + var e = new Vector3(expected.x, expected.y, expected.z); + if (e.magnitude > .1f * flow.GlobalAmplitude) live++; + Compare($"{label}-flow-keywords", at, i, e, gatedOut[i], flow.GlobalAmplitude); + } + if (flow.GlobalAmplitude != 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++) { @@ -183,7 +212,7 @@ namespace Aetheria.EditorTools } finally { - positionBuffer.Release(); gradientBuffer.Release(); globalBuffer.Release(); slopeBuffer.Release(); + positionBuffer.Release(); gradientBuffer.Release(); globalBuffer.Release(); slopeBuffer.Release(); gatedBuffer.Release(); } } diff --git a/Assets/Scripts/ServerShared/FlowField.cs b/Assets/Scripts/ServerShared/FlowField.cs index af5c4a192..0115532b6 100644 --- a/Assets/Scripts/ServerShared/FlowField.cs +++ b/Assets/Scripts/ServerShared/FlowField.cs @@ -11,15 +11,30 @@ using float3 = CultMath.float3; /// 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 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((float) frac(scroll), (float) frac(scroll * Golden)); + } + + // 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); // 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) + public static float3 Global(FlowSettings settings, float3 position, float2 scroll) { if (settings.GlobalAmplitude == 0f) return float3.zero; - var sample = Tri3D(position / settings.GlobalScale - float3(0f, scroll, 0f)) * settings.GlobalAmplitude; + 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; } @@ -37,8 +52,8 @@ public static class FlowField + 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); + 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); @@ -46,10 +61,11 @@ public static class FlowField 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) + // 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(p * 1.61803398875f)); + var t2 = normalize(Tri3(pGolden)); return cross(t1, t2); } } diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 6500055bc..869d6d9d7 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; } @@ -696,11 +696,14 @@ public class Zone public float3 Flow(float3 position) { var normal = GetNormal(position.xz); - return FlowField.At(Settings.Flow, position, _time, new float2(normal.x, normal.z)); + return FlowField.At(Settings.Flow, position, FlowScroll, 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); + // 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) { diff --git a/Assets/Scripts/Zone Display/VolumeSampling.cs b/Assets/Scripts/Zone Display/VolumeSampling.cs index af244911a..2c6f9ba7a 100644 --- a/Assets/Scripts/Zone Display/VolumeSampling.cs +++ b/Assets/Scripts/Zone Display/VolumeSampling.cs @@ -89,27 +89,34 @@ public class VolumeSampling : MonoBehaviour 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. - 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)); + LowerFlow(ActionGameManager.Instance?.Zone?.Settings.Flow ?? Settings.PlanetSettings.Flow, FlowClock); Shader.SetKeyword(_keywordNoiseSlope, environment.Noise.SlopeExponent != 0); } - static readonly GlobalKeyword KeywordFlowGlobal = new GlobalKeyword("FLOW_GLOBAL"); - static readonly GlobalKeyword KeywordFlowSlope = new GlobalKeyword("FLOW_SLOPE"); + // 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"); /// - /// 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. + /// 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 void LowerFlow(FlowSettings flow, float scroll) + 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); - Shader.SetGlobalFloat("_FlowScroll", scroll); + 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 index 7ebefe72e..00eb1673d 100644 --- a/Assets/Shaders/Compute/FlowParity.compute +++ b/Assets/Shaders/Compute/FlowParity.compute @@ -9,6 +9,7 @@ StructuredBuffer positions; StructuredBuffer gradients; RWStructuredBuffer globalOut; RWStructuredBuffer slopeOut; +RWStructuredBuffer gatedOut; // flow(): the keyword-gated function the nebula and stardust call uint count; [numthreads(64, 1, 1)] @@ -17,4 +18,5 @@ 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]); } 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 5ffe3f117..a6636fd2e 100644 --- a/Assets/Shaders/Compute/Stardust/Stardust.cs +++ b/Assets/Shaders/Compute/Stardust/Stardust.cs @@ -101,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 8a4c45470..d72960efd 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,7 @@ uniform half _NebulaFillDensity, _DynamicLodHigh, _DynamicIntensity, _DynamicSkyBoost; +float2 _FlowScroll; 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.)));} @@ -66,10 +67,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,7 +131,8 @@ 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; } @@ -198,13 +201,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 index 69bc72540..9bbbafc10 100644 --- a/tests/Aetheria.Shared.Tests/FlowFieldTests.cs +++ b/tests/Aetheria.Shared.Tests/FlowFieldTests.cs @@ -74,8 +74,8 @@ public sealed class FlowFieldTests : IDisposable 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); + Assert.Equal(flow.GlobalScrollSpeed * t, later.FlowScroll.x, 6); + Assert.Equal(float2(0f, 0f), start.FlowScroll); } [Fact] @@ -88,7 +88,8 @@ public sealed class FlowFieldTests : IDisposable 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); + 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); @@ -149,4 +150,62 @@ public sealed class FlowFieldTests : IDisposable // 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; + } + } } diff --git a/tests/mutation_tests_flow_parity.py b/tests/mutation_tests_flow_parity.py index c2ac1b985..2220f3277 100644 --- a/tests/mutation_tests_flow_parity.py +++ b/tests/mutation_tests_flow_parity.py @@ -28,21 +28,24 @@ UNITY_EXE = r"C:\Program Files\Unity\Hub\Editor\6000.3.24f1\Editor\Unity.exe" CGINC = "Assets/Shaders/Volumetric.cginc" VOLUME_SAMPLING = "Assets/Scripts/Zone Display/VolumeSampling.cs" FLOW_FIELD = "Assets/Scripts/ServerShared/FlowField.cs" -ZONE = "Assets/Scripts/ServerShared/Zone.cs" SETTINGS_ASSET = "Assets/Resources/Settings.asset" # (label, file, before, after, the failing check the probe must name) MUTANTS = [ ("hlsl-y-halving", CGINC, "flowSample.y *= .5;", "flowSample.y *= .6;", "global"), - ("hlsl-scroll-sign", CGINC, "pos / _FlowScale - float3(0,_FlowScroll,0)", "pos / _FlowScale + float3(0,_FlowScroll,0)", "global"), + ("hlsl-y-factor-501", CGINC, "flowSample.y *= .5;", "flowSample.y *= .501;", "global"), + ("hlsl-amplitude-1.0005", CGINC, "- float3(0,_FlowScroll.y,0) ) * _FlowAmplitude;", "- float3(0,_FlowScroll.y,0) ) * _FlowAmplitude * 1.0005;", "global"), + ("hlsl-scroll-sign", CGINC, "field - float3(0,_FlowScroll.x,0)", "field + float3(0,_FlowScroll.x,0)", "global"), + ("hlsl-scroll2-dropped", CGINC, "field * 1.61803398875 - float3(0,_FlowScroll.y,0)", "field * 1.61803398875", "global"), ("hlsl-swirl-flipped", CGINC, "float3(ngrad.y, 0, -ngrad.x)", "float3(-ngrad.y, 0, ngrad.x)", "slope"), + ("hlsl-swirl-1.0005", CGINC, "float3(ngrad.y, 0, -ngrad.x) * _FlowSwirlAmplitude", "float3(ngrad.y, 0, -ngrad.x) * _FlowSwirlAmplitude * 1.0005", "slope"), ("hlsl-zero-gradient-guard", CGINC, " if (dot(grad, grad) == 0) return 0;\n", "", "slope"), - ("lower-scroll-doubled", VOLUME_SAMPLING, 'Shader.SetGlobalFloat("_FlowScroll", scroll);', 'Shader.SetGlobalFloat("_FlowScroll", scroll * 2f);', "lowered-scroll"), + ("lower-scroll-doubled", VOLUME_SAMPLING, "new Vector4(scroll.x, scroll.y)", "new Vector4(scroll.x * 2f, scroll.y)", "lowered-scroll"), ("lower-scale-halved", VOLUME_SAMPLING, 'Shader.SetGlobalFloat("_FlowScale", flow.GlobalScale);', 'Shader.SetGlobalFloat("_FlowScale", flow.GlobalScale * .5f);', "lowered-settings"), - ("lower-frame-clock", VOLUME_SAMPLING, 'Shader.SetGlobalFloat("_FlowScroll", scroll);', 'Shader.SetGlobalFloat("_FlowScroll", scroll + Time.realtimeSinceStartup);', "lowered-scroll"), - ("csharp-scale-doubled", FLOW_FIELD, "position / settings.GlobalScale", "position / (settings.GlobalScale * 2f)", "global"), - ("csharp-scroll-offset", FLOW_FIELD, "(float) (settings.GlobalScrollSpeed * zoneTime)", "(float) (settings.GlobalScrollSpeed * (zoneTime + 1.0))", "zone-scroll"), - ("zone-scroll-scaled", ZONE, "public float FlowScroll => FlowField.Scroll(Settings.Flow, _time);", "public float FlowScroll => FlowField.Scroll(Settings.Flow, _time * 2);", "zone-scroll"), + ("lower-frame-clock", VOLUME_SAMPLING, "FlowField.Scroll(flow, flowClock)", "FlowField.Scroll(flow, flowClock + Time.realtimeSinceStartup)", "lowered-scroll"), + ("lower-cycle-frame-clock", VOLUME_SAMPLING, "FlowField.Cycle(flowClock, flow.Period)", "FlowField.Cycle(Time.realtimeSinceStartup, flow.Period)", "lowered-cycle"), + ("lower-keywords-lookup-only", VOLUME_SAMPLING, 'GlobalKeyword.Create("FLOW_GLOBAL")', 'new GlobalKeyword("FLOW_GLOBAL")', "flow-keywords"), + ("csharp-scale-doubled", FLOW_FIELD, "var field = position / settings.GlobalScale;", "var field = position / (settings.GlobalScale * 2f);", "global"), ("asset-amplitude", SETTINGS_ASSET, " GlobalAmplitude: 15\n", " GlobalAmplitude: 14\n", "authored-flow"), ]