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:
parent
1e219e96e3
commit
e8ec8e903c
9 changed files with 248 additions and 18 deletions
BIN
Assets/Resources/Settings.asset
(Stored with Git LFS)
BIN
Assets/Resources/Settings.asset
(Stored with Git LFS)
Binary file not shown.
|
|
@ -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;
|
||||
|
|
|
|||
53
Assets/Scripts/ServerShared/FlowField.cs
Normal file
53
Assets/Scripts/ServerShared/FlowField.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
154
tests/Aetheria.Shared.Tests/FlowFieldTests.cs
Normal file
154
tests/Aetheria.Shared.Tests/FlowFieldTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue