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"),
]