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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-13 23:25:44 +02:00
commit b155003689
4 changed files with 129 additions and 7 deletions

View file

@ -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.

View file

@ -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");
/// <summary>
/// The time the flow runs on: the zone's own (so drift stops with the sim and follows sim speed), or on menus,

View file

@ -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;

View file

@ -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);
}
}
}