2026-10-13 20:49:22 +02:00
using System ;
using System.IO ;
using GameCult.Caching ;
using UnityEditor ;
using UnityEngine ;
namespace Aetheria.EditorTools
{
/// <summary>
/// Batchmode check for flow-shader-parity (campaign aetheria-fauna, cut fauna-flow-field): the HLSL globalFlow and
/// slopeFlow of Assets/Shaders/Volumetric.cginc give the values of FlowField (ServerShared) for the same position,
/// scroll, settings and gradient. It drives the real lowering: a Zone is stepped, VolumeSampling.LowerFlow sets the
/// shader globals from the zone's Settings.Flow and FlowScroll, FlowParity.compute reads those globals, and the
/// results are compared with FlowField. It also checks that Settings.asset carries the authored flow under
/// PlanetSettings and that the shader got the zone's scroll (speed times zone time).
///
/// Dispatches a compute kernel, so it needs a graphics device: run WITHOUT -nographics (as ShieldPanelCut3Verify).
/// Unity -batchmode -projectPath <repo> -executeMethod Aetheria.EditorTools.FlowFieldParityVerify.Run -logFile <path>
/// Do not pass -quit: Run exits the Editor itself, 0 on success and 1 on any failure.
/// </summary>
public static class FlowFieldParityVerify
{
const string Tag = "[FlowFieldParityVerify]" ;
Flow: register the flow keywords, one zone-time value for Zone.Flow and the scroll, presenter phases from zone time, folded scroll, tighter parity
- VolumeSampling registers FLOW_GLOBAL / FLOW_SLOPE with GlobalKeyword.Create (the constructor only looks up).
- FlowField.Scroll folds each Tri3D octave's phase modulo 1 in double (float2, [0,1)); Zone.Flow evaluates at Zone.FlowScroll.
- FlowField.Cycle: the nebula's flow cycle and Stardust's particle lifetime phase come from zone time (_FlowCycle, phase uniform), not _Time.y / Time.time.
- FlowFieldParityVerify reaches the keyword-gated flow(), fails on logged errors, tolerance 3e-4; mutants updated.
Not built or run when committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:43:41 +02:00
// Measured disagreement between the HLSL and the C# on this tree (float32 on both sides, positions to 10000,
2026-10-13 21:52:13 +02:00
// scrolls to 100, through the keyword-gated flow()): max 2.9e-5 of amplitude (the "max error" line this run
// prints). The tolerance is 5x that noise, so constant errors of 0.05% (y factor .501, swirl or amplitude
// x1.0005: 4e-4 to 8e-4) fail; a 0.01% error is at the noise floor and unresolvable.
const float RelativeTolerance = 1.5e-4f ;
2026-10-13 20:49:22 +02:00
static float _maxError ;
static bool _ok = true ;
Flow: register the flow keywords, one zone-time value for Zone.Flow and the scroll, presenter phases from zone time, folded scroll, tighter parity
- VolumeSampling registers FLOW_GLOBAL / FLOW_SLOPE with GlobalKeyword.Create (the constructor only looks up).
- FlowField.Scroll folds each Tri3D octave's phase modulo 1 in double (float2, [0,1)); Zone.Flow evaluates at Zone.FlowScroll.
- FlowField.Cycle: the nebula's flow cycle and Stardust's particle lifetime phase come from zone time (_FlowCycle, phase uniform), not _Time.y / Time.time.
- FlowFieldParityVerify reaches the keyword-gated flow(), fails on logged errors, tolerance 3e-4; mutants updated.
Not built or run when committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:43:41 +02:00
static void OnLog ( string message , string stack , LogType type )
{
if ( ( type = = LogType . Error | | type = = LogType . Exception | | type = = LogType . Assert ) & & ! message . StartsWith ( Tag ) )
2026-10-13 21:49:16 +02:00
Fail ( "logged-error" , message . Split ( '\n' ) [ 0 ] ) ;
Flow: register the flow keywords, one zone-time value for Zone.Flow and the scroll, presenter phases from zone time, folded scroll, tighter parity
- VolumeSampling registers FLOW_GLOBAL / FLOW_SLOPE with GlobalKeyword.Create (the constructor only looks up).
- FlowField.Scroll folds each Tri3D octave's phase modulo 1 in double (float2, [0,1)); Zone.Flow evaluates at Zone.FlowScroll.
- FlowField.Cycle: the nebula's flow cycle and Stardust's particle lifetime phase come from zone time (_FlowCycle, phase uniform), not _Time.y / Time.time.
- FlowFieldParityVerify reaches the keyword-gated flow(), fails on logged errors, tolerance 3e-4; mutants updated.
Not built or run when committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:43:41 +02:00
}
2026-10-13 20:49:22 +02:00
static void Fail ( string check , string detail )
{
Debug . LogError ( $"{Tag} FAIL {check}: {detail}" ) ;
_ok = false ;
}
public static void Run ( )
{
if ( SystemInfo . graphicsDeviceType = = UnityEngine . Rendering . GraphicsDeviceType . Null )
{
Debug . LogError ( $"{Tag} FAIL: no graphics device in this session; rerun batchmode without -nographics." ) ;
EditorApplication . Exit ( 1 ) ;
return ;
}
Flow: register the flow keywords, one zone-time value for Zone.Flow and the scroll, presenter phases from zone time, folded scroll, tighter parity
- VolumeSampling registers FLOW_GLOBAL / FLOW_SLOPE with GlobalKeyword.Create (the constructor only looks up).
- FlowField.Scroll folds each Tri3D octave's phase modulo 1 in double (float2, [0,1)); Zone.Flow evaluates at Zone.FlowScroll.
- FlowField.Cycle: the nebula's flow cycle and Stardust's particle lifetime phase come from zone time (_FlowCycle, phase uniform), not _Time.y / Time.time.
- FlowFieldParityVerify reaches the keyword-gated flow(), fails on logged errors, tolerance 3e-4; mutants updated.
Not built or run when committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:43:41 +02:00
// The flow's error lines (a shader keyword that does not exist, a missing property) fail the check.
Application . logMessageReceived + = OnLog ;
2026-10-13 20:49:22 +02:00
var work = Path . Combine ( Path . GetTempPath ( ) , "flow-parity-" + Guid . NewGuid ( ) . ToString ( "N" ) ) ;
Directory . CreateDirectory ( work ) ;
try
{
var settings = AssetDatabase . LoadAssetAtPath < GameSettings > ( "Assets/Resources/Settings.asset" )
? ? throw new InvalidOperationException ( "No Settings.asset." ) ;
var compute = AssetDatabase . LoadAssetAtPath < ComputeShader > ( "Assets/Shaders/Compute/FlowParity.compute" )
? ? throw new InvalidOperationException ( "No FlowParity.compute." ) ;
CheckAuthoredFlow ( settings . PlanetSettings . Flow ) ;
// The authored flow (global term only: its slope amplitudes are authored zero), then a flow with every
// term live and a scroll speed that reaches scrolls up to 100.
var authored = settings . PlanetSettings . Flow ;
CheckParity ( "authored" , settings , work , compute , authored , new [ ] { 0.0 , 3.1 , 40.0 , 100.0 } ) ;
var wide = new FlowSettings
{
GlobalScale = authored . GlobalScale , GlobalAmplitude = authored . GlobalAmplitude , Period = authored . Period ,
GlobalScrollSpeed = 1f , SlopeAmplitude = 3f , SwirlAmplitude = 5f
} ;
CheckParity ( "wide" , settings , work , compute , wide , new [ ] { 0.0 , 1.0 , 17.0 , 60.0 , 100.0 } ) ;
2026-10-13 23:25:45 +02:00
CheckNoiseClock ( compute ) ;
2026-10-13 20:49:22 +02:00
Debug . Log ( $"{Tag} max error {_maxError:G4} of amplitude (tolerance {RelativeTolerance:G4})" ) ;
Debug . Log ( _ok ? $"{Tag} PASS" : $"{Tag} FAIL: see errors above" ) ;
}
catch ( Exception e )
{
Debug . LogError ( $"{Tag} FAIL exception: {e}" ) ;
_ok = false ;
}
finally
{
Flow: register the flow keywords, one zone-time value for Zone.Flow and the scroll, presenter phases from zone time, folded scroll, tighter parity
- VolumeSampling registers FLOW_GLOBAL / FLOW_SLOPE with GlobalKeyword.Create (the constructor only looks up).
- FlowField.Scroll folds each Tri3D octave's phase modulo 1 in double (float2, [0,1)); Zone.Flow evaluates at Zone.FlowScroll.
- FlowField.Cycle: the nebula's flow cycle and Stardust's particle lifetime phase come from zone time (_FlowCycle, phase uniform), not _Time.y / Time.time.
- FlowFieldParityVerify reaches the keyword-gated flow(), fails on logged errors, tolerance 3e-4; mutants updated.
Not built or run when committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:43:41 +02:00
Application . logMessageReceived - = OnLog ;
2026-10-13 20:49:22 +02:00
try { Directory . Delete ( work , true ) ; } catch { }
}
EditorApplication . Exit ( _ok ? 0 : 1 ) ;
}
// Settings.asset carries the authored flow under PlanetSettings (it moved from DefaultEnvironment unchanged).
static void CheckAuthoredFlow ( FlowSettings flow )
{
if ( flow = = null ) { Fail ( "authored-flow" , "PlanetSettings.Flow is null" ) ; return ; }
if ( flow . GlobalScale ! = 512f | | flow . GlobalAmplitude ! = 15f | | flow . GlobalScrollSpeed ! = . 025f | |
flow . Period ! = 8f | | flow . SlopeAmplitude ! = 0f | | flow . SwirlAmplitude ! = 0f )
Fail ( "authored-flow" , $"PlanetSettings.Flow is {flow.GlobalScale}/{flow.GlobalAmplitude}/{flow.GlobalScrollSpeed}/" +
$"{flow.Period}/{flow.SlopeAmplitude}/{flow.SwirlAmplitude}, authored 512/15/0.025/8/0/0" ) ;
}
2026-10-13 23:25:45 +02:00
internal static Zone BuildZone ( GameSettings settings , string work , FlowSettings flow , double time = 0 )
2026-10-13 20:49:22 +02:00
{
var cache = AetheriaStores . Open ( Path . Combine ( work , Guid . NewGuid ( ) . ToString ( "N" ) + ".cc" ) , catalogWritable : true ) ;
cache . Upsert ( new VerseGrammar { Revision = 1 } ) ;
cache . FlushAsync ( ) . Wait ( ) ;
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , settings . GameplaySettings , _ = > { } ) ;
2026-10-13 23:25:45 +02:00
return new Zone ( items , new PlanetSettings { Flow = flow } , new ZonePack { Time = time } , new GalaxyZone { Name = "Parity" , Owner = null } , null ) ;
2026-10-13 20:49:22 +02:00
}
static void CheckParity ( string label , GameSettings settings , string work , ComputeShader compute , FlowSettings flow ,
double [ ] zoneTimes )
{
var zone = BuildZone ( settings , work , flow ) ;
var kernel = compute . FindKernel ( "Evaluate" ) ;
var rng = new System . Random ( label . Length * 7919 ) ;
const int positionCount = 256 , gradientCount = 64 ;
const int count = positionCount ;
var positions = new Vector3 [ count ] ;
for ( var i = 0 ; i < positionCount ; i + + )
positions [ i ] = new Vector3 ( ( float ) ( rng . NextDouble ( ) * 20000 - 10000 ) , ( float ) ( rng . NextDouble ( ) * 200 - 100 ) ,
( float ) ( rng . NextDouble ( ) * 20000 - 10000 ) ) ;
// Gradients of any direction and magnitude, plus one zero gradient.
var gradients = new Vector2 [ count ] ;
for ( var i = 1 ; i < gradientCount ; i + + )
{
var angle = rng . NextDouble ( ) * Math . PI * 2 ;
var magnitude = ( float ) Math . Exp ( rng . NextDouble ( ) * 8 - 4 ) ;
gradients [ i ] = new Vector2 ( ( float ) Math . Cos ( angle ) * magnitude , ( float ) Math . Sin ( angle ) * magnitude ) ;
}
gradients [ 0 ] = Vector2 . zero ;
var positionBuffer = new ComputeBuffer ( count , 12 ) ;
var gradientBuffer = new ComputeBuffer ( count , 8 ) ;
var globalBuffer = new ComputeBuffer ( count , 12 ) ;
var slopeBuffer = new ComputeBuffer ( count , 12 ) ;
Flow: register the flow keywords, one zone-time value for Zone.Flow and the scroll, presenter phases from zone time, folded scroll, tighter parity
- VolumeSampling registers FLOW_GLOBAL / FLOW_SLOPE with GlobalKeyword.Create (the constructor only looks up).
- FlowField.Scroll folds each Tri3D octave's phase modulo 1 in double (float2, [0,1)); Zone.Flow evaluates at Zone.FlowScroll.
- FlowField.Cycle: the nebula's flow cycle and Stardust's particle lifetime phase come from zone time (_FlowCycle, phase uniform), not _Time.y / Time.time.
- FlowFieldParityVerify reaches the keyword-gated flow(), fails on logged errors, tolerance 3e-4; mutants updated.
Not built or run when committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:43:41 +02:00
var gatedBuffer = new ComputeBuffer ( count , 12 ) ;
2026-10-13 23:25:45 +02:00
ComputeBuffer noiseBuffer = null ;
// flow() reads the nebula's gravity height under FLOW_SLOPE. A ramp rising along the texture's x gives every
// position inside the grid the gradient direction (1, 0), so the slope term is live where a flat height
// would zero it (and the FLOW_SLOPE keyword and its pragma would not matter).
const int rampSize = 64 ;
var height = new Texture2D ( rampSize , rampSize , TextureFormat . RFloat , false )
{ filterMode = FilterMode . Point , wrapMode = TextureWrapMode . Clamp } ;
var ramp = new float [ rampSize * rampSize ] ;
for ( var i = 0 ; i < ramp . Length ; i + + ) ramp [ i ] = ( i % rampSize ) / ( float ) ( rampSize - 1 ) ;
height . SetPixelData ( ramp , 0 ) ;
height . Apply ( ) ;
var rampGradient = new CultMath . float2 ( 1f , 0f ) ;
2026-10-13 20:49:22 +02:00
try
{
positionBuffer . SetData ( positions ) ;
gradientBuffer . SetData ( gradients ) ;
compute . SetBuffer ( kernel , "positions" , positionBuffer ) ;
compute . SetBuffer ( kernel , "gradients" , gradientBuffer ) ;
compute . SetBuffer ( kernel , "globalOut" , globalBuffer ) ;
compute . SetBuffer ( kernel , "slopeOut" , slopeBuffer ) ;
Flow: register the flow keywords, one zone-time value for Zone.Flow and the scroll, presenter phases from zone time, folded scroll, tighter parity
- VolumeSampling registers FLOW_GLOBAL / FLOW_SLOPE with GlobalKeyword.Create (the constructor only looks up).
- FlowField.Scroll folds each Tri3D octave's phase modulo 1 in double (float2, [0,1)); Zone.Flow evaluates at Zone.FlowScroll.
- FlowField.Cycle: the nebula's flow cycle and Stardust's particle lifetime phase come from zone time (_FlowCycle, phase uniform), not _Time.y / Time.time.
- FlowFieldParityVerify reaches the keyword-gated flow(), fails on logged errors, tolerance 3e-4; mutants updated.
Not built or run when committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:43:41 +02:00
compute . SetBuffer ( kernel , "gatedOut" , gatedBuffer ) ;
2026-10-13 21:51:22 +02:00
compute . SetTexture ( kernel , "_NebulaSurfaceHeight" , height ) ;
2026-10-13 23:25:45 +02:00
compute . SetVector ( "_NebulaSurfaceHeight_TexelSize" , new Vector4 ( 1f / rampSize , 1f / rampSize , rampSize , rampSize ) ) ;
// The grid spans 40000: every sampled position (|xz| to 10000) is inside the ramp's uv range.
Shader . SetGlobalVector ( "_GridTransform" , new Vector4 ( 0f , 0f , 40000f , 0f ) ) ;
noiseBuffer = new ComputeBuffer ( count , 4 ) ;
compute . SetBuffer ( kernel , "noiseOut" , noiseBuffer ) ;
2026-10-13 20:49:22 +02:00
compute . SetInt ( "count" , count ) ;
var elapsed = 0.0 ;
foreach ( var target in zoneTimes )
{
// Step the zone to the target by an awkward step; the sum is the zone's time.
while ( elapsed < target - 1e-9 )
{
var dt = ( float ) Math . Min ( 0.37 , target - elapsed ) ;
zone . Update ( dt ) ;
elapsed + = dt ;
}
Flow: register the flow keywords, one zone-time value for Zone.Flow and the scroll, presenter phases from zone time, folded scroll, tighter parity
- VolumeSampling registers FLOW_GLOBAL / FLOW_SLOPE with GlobalKeyword.Create (the constructor only looks up).
- FlowField.Scroll folds each Tri3D octave's phase modulo 1 in double (float2, [0,1)); Zone.Flow evaluates at Zone.FlowScroll.
- FlowField.Cycle: the nebula's flow cycle and Stardust's particle lifetime phase come from zone time (_FlowCycle, phase uniform), not _Time.y / Time.time.
- FlowFieldParityVerify reaches the keyword-gated flow(), fails on logged errors, tolerance 3e-4; mutants updated.
Not built or run when committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:43:41 +02:00
VolumeSampling . LowerFlow ( zone . Settings . Flow , zone . ExactTime ) ;
2026-10-13 20:49:22 +02:00
Flow: register the flow keywords, one zone-time value for Zone.Flow and the scroll, presenter phases from zone time, folded scroll, tighter parity
- VolumeSampling registers FLOW_GLOBAL / FLOW_SLOPE with GlobalKeyword.Create (the constructor only looks up).
- FlowField.Scroll folds each Tri3D octave's phase modulo 1 in double (float2, [0,1)); Zone.Flow evaluates at Zone.FlowScroll.
- FlowField.Cycle: the nebula's flow cycle and Stardust's particle lifetime phase come from zone time (_FlowCycle, phase uniform), not _Time.y / Time.time.
- FlowFieldParityVerify reaches the keyword-gated flow(), fails on logged errors, tolerance 3e-4; mutants updated.
Not built or run when committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:43:41 +02:00
// 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)}" ) ;
2026-10-13 20:49:22 +02:00
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 )
Fail ( $"{label}-lowered-settings" , "a flow uniform does not equal the zone's FlowSettings" ) ;
compute . Dispatch ( kernel , ( count + 63 ) / 64 , 1 , 1 ) ;
var globalOut = new Vector3 [ count ] ;
var slopeOut = new Vector3 [ count ] ;
Flow: register the flow keywords, one zone-time value for Zone.Flow and the scroll, presenter phases from zone time, folded scroll, tighter parity
- VolumeSampling registers FLOW_GLOBAL / FLOW_SLOPE with GlobalKeyword.Create (the constructor only looks up).
- FlowField.Scroll folds each Tri3D octave's phase modulo 1 in double (float2, [0,1)); Zone.Flow evaluates at Zone.FlowScroll.
- FlowField.Cycle: the nebula's flow cycle and Stardust's particle lifetime phase come from zone time (_FlowCycle, phase uniform), not _Time.y / Time.time.
- FlowFieldParityVerify reaches the keyword-gated flow(), fails on logged errors, tolerance 3e-4; mutants updated.
Not built or run when committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:43:41 +02:00
var gatedOut = new Vector3 [ count ] ;
2026-10-13 20:49:22 +02:00
globalBuffer . GetData ( globalOut ) ;
slopeBuffer . GetData ( slopeOut ) ;
Flow: register the flow keywords, one zone-time value for Zone.Flow and the scroll, presenter phases from zone time, folded scroll, tighter parity
- VolumeSampling registers FLOW_GLOBAL / FLOW_SLOPE with GlobalKeyword.Create (the constructor only looks up).
- FlowField.Scroll folds each Tri3D octave's phase modulo 1 in double (float2, [0,1)); Zone.Flow evaluates at Zone.FlowScroll.
- FlowField.Cycle: the nebula's flow cycle and Stardust's particle lifetime phase come from zone time (_FlowCycle, phase uniform), not _Time.y / Time.time.
- FlowFieldParityVerify reaches the keyword-gated flow(), fails on logged errors, tolerance 3e-4; mutants updated.
Not built or run when committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:43:41 +02:00
gatedBuffer . GetData ( gatedOut ) ;
2026-10-13 20:49:22 +02:00
Flow: register the flow keywords, one zone-time value for Zone.Flow and the scroll, presenter phases from zone time, folded scroll, tighter parity
- VolumeSampling registers FLOW_GLOBAL / FLOW_SLOPE with GlobalKeyword.Create (the constructor only looks up).
- FlowField.Scroll folds each Tri3D octave's phase modulo 1 in double (float2, [0,1)); Zone.Flow evaluates at Zone.FlowScroll.
- FlowField.Cycle: the nebula's flow cycle and Stardust's particle lifetime phase come from zone time (_FlowCycle, phase uniform), not _Time.y / Time.time.
- FlowFieldParityVerify reaches the keyword-gated flow(), fails on logged errors, tolerance 3e-4; mutants updated.
Not built or run when committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:43:41 +02:00
var at = $"{label} zone time {elapsed:F2} scroll {zoneScroll.x:F4}" ;
2026-10-13 20:49:22 +02:00
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 ) ;
}
2026-10-13 23:25:45 +02:00
// flow() is the keyword-gated function the nebula and stardust call: the global term plus, under
// FLOW_SLOPE, the slope term of the ramp's gradient. A flow that is live must not read zero.
Flow: register the flow keywords, one zone-time value for Zone.Flow and the scroll, presenter phases from zone time, folded scroll, tighter parity
- VolumeSampling registers FLOW_GLOBAL / FLOW_SLOPE with GlobalKeyword.Create (the constructor only looks up).
- FlowField.Scroll folds each Tri3D octave's phase modulo 1 in double (float2, [0,1)); Zone.Flow evaluates at Zone.FlowScroll.
- FlowField.Cycle: the nebula's flow cycle and Stardust's particle lifetime phase come from zone time (_FlowCycle, phase uniform), not _Time.y / Time.time.
- FlowFieldParityVerify reaches the keyword-gated flow(), fails on logged errors, tolerance 3e-4; mutants updated.
Not built or run when committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:43:41 +02:00
var live = 0 ;
2026-10-13 23:25:45 +02:00
var gatedScale = Math . Max ( Math . Abs ( flow . GlobalAmplitude ) , Math . Max ( Math . Abs ( flow . SlopeAmplitude ) , Math . Abs ( flow . SwirlAmplitude ) ) ) ;
Flow: register the flow keywords, one zone-time value for Zone.Flow and the scroll, presenter phases from zone time, folded scroll, tighter parity
- VolumeSampling registers FLOW_GLOBAL / FLOW_SLOPE with GlobalKeyword.Create (the constructor only looks up).
- FlowField.Scroll folds each Tri3D octave's phase modulo 1 in double (float2, [0,1)); Zone.Flow evaluates at Zone.FlowScroll.
- FlowField.Cycle: the nebula's flow cycle and Stardust's particle lifetime phase come from zone time (_FlowCycle, phase uniform), not _Time.y / Time.time.
- FlowFieldParityVerify reaches the keyword-gated flow(), fails on logged errors, tolerance 3e-4; mutants updated.
Not built or run when committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:43:41 +02:00
for ( var i = 0 ; i < positionCount ; i + + )
{
var p = positions [ i ] ;
2026-10-13 23:25:45 +02:00
var expected = FlowField . At ( flow , new CultMath . float3 ( p . x , p . y , p . z ) , zoneScroll , rampGradient ) ;
Flow: register the flow keywords, one zone-time value for Zone.Flow and the scroll, presenter phases from zone time, folded scroll, tighter parity
- VolumeSampling registers FLOW_GLOBAL / FLOW_SLOPE with GlobalKeyword.Create (the constructor only looks up).
- FlowField.Scroll folds each Tri3D octave's phase modulo 1 in double (float2, [0,1)); Zone.Flow evaluates at Zone.FlowScroll.
- FlowField.Cycle: the nebula's flow cycle and Stardust's particle lifetime phase come from zone time (_FlowCycle, phase uniform), not _Time.y / Time.time.
- FlowFieldParityVerify reaches the keyword-gated flow(), fails on logged errors, tolerance 3e-4; mutants updated.
Not built or run when committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:43:41 +02:00
var e = new Vector3 ( expected . x , expected . y , expected . z ) ;
2026-10-13 23:25:45 +02:00
if ( e . magnitude > . 1f * gatedScale ) live + + ;
Compare ( $"{label}-flow-keywords" , at , i , e , gatedOut [ i ] , gatedScale ) ;
Flow: register the flow keywords, one zone-time value for Zone.Flow and the scroll, presenter phases from zone time, folded scroll, tighter parity
- VolumeSampling registers FLOW_GLOBAL / FLOW_SLOPE with GlobalKeyword.Create (the constructor only looks up).
- FlowField.Scroll folds each Tri3D octave's phase modulo 1 in double (float2, [0,1)); Zone.Flow evaluates at Zone.FlowScroll.
- FlowField.Cycle: the nebula's flow cycle and Stardust's particle lifetime phase come from zone time (_FlowCycle, phase uniform), not _Time.y / Time.time.
- FlowFieldParityVerify reaches the keyword-gated flow(), fails on logged errors, tolerance 3e-4; mutants updated.
Not built or run when committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:43:41 +02:00
}
2026-10-13 23:25:45 +02:00
if ( gatedScale ! = 0f & & live < positionCount / 4 )
Flow: register the flow keywords, one zone-time value for Zone.Flow and the scroll, presenter phases from zone time, folded scroll, tighter parity
- VolumeSampling registers FLOW_GLOBAL / FLOW_SLOPE with GlobalKeyword.Create (the constructor only looks up).
- FlowField.Scroll folds each Tri3D octave's phase modulo 1 in double (float2, [0,1)); Zone.Flow evaluates at Zone.FlowScroll.
- FlowField.Cycle: the nebula's flow cycle and Stardust's particle lifetime phase come from zone time (_FlowCycle, phase uniform), not _Time.y / Time.time.
- FlowFieldParityVerify reaches the keyword-gated flow(), fails on logged errors, tolerance 3e-4; mutants updated.
Not built or run when committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:43:41 +02:00
Fail ( $"{label}-flow-keywords" , $"{at}: only {live} of {positionCount} zone flows are live; the check is vacuous" ) ;
2026-10-13 20:49:22 +02:00
var slopeScale = Math . Max ( Math . Abs ( flow . SlopeAmplitude ) , Math . Abs ( flow . SwirlAmplitude ) ) ;
for ( var i = 0 ; i < gradientCount ; i + + )
{
var g = gradients [ i ] ;
var expected = FlowField . Slope ( flow , new CultMath . float2 ( g . x , g . y ) ) ;
Compare ( $"{label}-slope" , at , i , new Vector3 ( expected . x , expected . y , expected . z ) , slopeOut [ i ] , slopeScale ) ;
}
}
}
finally
{
2026-10-13 23:25:45 +02:00
positionBuffer . Release ( ) ; gradientBuffer . Release ( ) ; globalBuffer . Release ( ) ; slopeBuffer . Release ( ) ; gatedBuffer . Release ( ) ; noiseBuffer ? . Release ( ) ; UnityEngine . Object . DestroyImmediate ( height ) ;
}
}
// The nebula noise the flow advects evolves on the flow clock VolumeSampling lowers as _NebulaNoiseTime, never on
// _Time: with the speed non-zero, the noise changes when _NebulaNoiseTime does and not otherwise (a frame clock
// would not follow the global set here).
static void CheckNoiseClock ( ComputeShader compute )
{
const int count = 64 ;
var kernel = compute . FindKernel ( "Evaluate" ) ;
var rng = new System . Random ( 4217 ) ;
var positions = new Vector3 [ count ] ;
for ( var i = 0 ; i < count ; i + + )
positions [ i ] = new Vector3 ( ( float ) ( rng . NextDouble ( ) * 4000 - 2000 ) , ( float ) ( rng . NextDouble ( ) * 100 - 50 ) , ( float ) ( rng . NextDouble ( ) * 4000 - 2000 ) ) ;
var buffers = new [ ] { new ComputeBuffer ( count , 12 ) , new ComputeBuffer ( count , 8 ) , new ComputeBuffer ( count , 12 ) , new ComputeBuffer ( count , 12 ) , new ComputeBuffer ( count , 12 ) , new ComputeBuffer ( count , 4 ) } ;
var height = new Texture2D ( 1 , 1 , TextureFormat . RFloat , false ) ;
try
{
buffers [ 0 ] . SetData ( positions ) ;
buffers [ 1 ] . SetData ( new Vector2 [ count ] ) ;
compute . SetBuffer ( kernel , "positions" , buffers [ 0 ] ) ;
compute . SetBuffer ( kernel , "gradients" , buffers [ 1 ] ) ;
compute . SetBuffer ( kernel , "globalOut" , buffers [ 2 ] ) ;
compute . SetBuffer ( kernel , "slopeOut" , buffers [ 3 ] ) ;
compute . SetBuffer ( kernel , "gatedOut" , buffers [ 4 ] ) ;
compute . SetBuffer ( kernel , "noiseOut" , buffers [ 5 ] ) ;
compute . SetTexture ( kernel , "_NebulaSurfaceHeight" , height ) ;
compute . SetInt ( "count" , count ) ;
Shader . SetGlobalFloat ( "_NebulaNoiseSpeed" , 1f ) ;
float [ ] Noise ( float noiseTime )
{
Shader . SetGlobalFloat ( "_NebulaNoiseTime" , noiseTime ) ;
compute . Dispatch ( kernel , 1 , 1 , 1 ) ;
var result = new float [ count ] ;
buffers [ 5 ] . GetData ( result ) ;
return result ;
}
var a = Noise ( 0f ) ;
var b = Noise ( 3f ) ;
var again = Noise ( 3f ) ;
int moved = 0 , repeated = 0 ;
for ( var i = 0 ; i < count ; i + + )
{
if ( Math . Abs ( a [ i ] - b [ i ] ) > 1e-4f ) moved + + ;
if ( b [ i ] = = again [ i ] ) repeated + + ;
}
if ( moved < count / 2 ) Fail ( "nebula-noise-clock" , $"only {moved} of {count} noise samples follow _NebulaNoiseTime" ) ;
if ( repeated ! = count ) Fail ( "nebula-noise-clock" , $"{count - repeated} of {count} noise samples changed with _NebulaNoiseTime held" ) ;
}
finally
{
foreach ( var buffer in buffers ) buffer . Release ( ) ;
UnityEngine . Object . DestroyImmediate ( height ) ;
2026-10-13 20:49:22 +02:00
}
}
// Error is measured against the term's amplitude; a term with no amplitude must be exactly zero and never NaN.
static void Compare ( string check , string at , int index , Vector3 expected , Vector3 actual , float amplitude )
{
var error = ( expected - actual ) . magnitude ;
if ( float . IsNaN ( error ) ) { Fail ( check , $"{at} sample {index}: NaN (expected {expected}, shader {actual})" ) ; return ; }
if ( amplitude ! = 0f ) _maxError = Math . Max ( _maxError , error / Math . Abs ( amplitude ) ) ;
var tolerance = RelativeTolerance * Math . Abs ( amplitude ) ;
if ( error > tolerance )
Fail ( check , $"{at} sample {index}: expected {expected}, shader {actual}, error {error:G4} > {tolerance:G4}" ) ;
}
}
}