Flow field: FlowField owns the global flow, Zone.Flow evaluates it at zone time, VolumeSampling.LowerFlow lowers it

FlowField (ServerShared) is the flow law; Zone.Flow/FlowScroll read PlanetSettings.Flow at zone
time. The Flow block moves from DefaultEnvironment to PlanetSettings (ZoneEnvironment key 1 is
retired), VolumeSampling's frame-clock scroll accumulator and the Stardust leftover field are
deleted, and slopeFlow returns 0 for a zero gradient. Unity parity probe follows in the next
commit; this commit has not been built in Unity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-13 20:06:35 +02:00
commit e8ec8e903c
9 changed files with 248 additions and 18 deletions

BIN
Assets/Resources/Settings.asset (Stored with Git LFS)

Binary file not shown.

View file

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

View file

@ -0,0 +1,53 @@
using CultMath;
using static CultMath.math;
/// <summary>
/// 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.
/// </summary>
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 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)
{
if (settings.GlobalAmplitude == 0f) return float3.zero;
var sample = Tri3D(position / settings.GlobalScale - float3(0f, scroll, 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, double zoneTime, float2 downhill) =>
Global(settings, position, Scroll(settings, zoneTime)) + 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)));
static float3 Tri3D(float3 p)
{
var t1 = normalize(Tri3(p));
var t2 = normalize(Tri3(p * 1.61803398875f));
return cross(t1, t2);
}
}

View file

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

View file

@ -691,6 +691,17 @@ 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, _time, 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);
public static float PowerPulse(float x, float exponent)
{
x *= 2;

View file

@ -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)
@ -93,16 +88,30 @@ public class VolumeSampling : MonoBehaviour
Shader.SetGlobalFloat("_NebulaNoiseSpeed", environment.Noise.Speed);
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.
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));
Shader.SetKeyword(_keywordNoiseSlope, environment.Noise.SlopeExponent != 0);
}
static readonly GlobalKeyword KeywordFlowGlobal = new GlobalKeyword("FLOW_GLOBAL");
static readonly GlobalKeyword KeywordFlowSlope = new GlobalKeyword("FLOW_SLOPE");
/// <summary>
/// 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.
/// </summary>
public static void LowerFlow(FlowSettings flow, float scroll)
{
Shader.SetGlobalFloat("_FlowScale", flow.GlobalScale);
Shader.SetGlobalFloat("_FlowAmplitude", flow.GlobalAmplitude);
Shader.SetGlobalFloat("_FlowSlopeAmplitude", flow.SlopeAmplitude);
Shader.SetGlobalFloat("_FlowSwirlAmplitude", flow.SwirlAmplitude);
Shader.SetGlobalFloat("_FlowScroll", scroll);
Shader.SetGlobalFloat("_FlowPeriod", flow.Period);
Shader.SetKeyword(KeywordFlowGlobal, flow.GlobalAmplitude != 0);
Shader.SetKeyword(KeywordFlowSlope, flow.SlopeAmplitude != 0 || flow.SwirlAmplitude != 0);
}
}

View file

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

View file

@ -136,6 +136,7 @@ float3 globalFlow(float3 pos)
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;
}

View file

@ -0,0 +1,154 @@
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, 6);
Assert.Equal(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, coarse.FlowScroll, 5);
// The flow moved: a scroll of 0.025 per second is a different field from the start.
Assert.NotEqual(ZoneWith(Flow()).Flow(Probes[1]).y, fine.Flow(Probes[1]).y);
}
[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)
{
var a = one.Flow(p);
Assert.NotEqual(float3.zero, a);
AssertClose(a * 2f, two.Flow(p));
}
}
}