181 lines
9.9 KiB
C#
181 lines
9.9 KiB
C#
|
|
using System;
|
||
|
|
using System.IO;
|
||
|
|
using System.Linq;
|
||
|
|
using System.Reflection;
|
||
|
|
using UnityEditor;
|
||
|
|
using UnityEditor.SceneManagement;
|
||
|
|
using UnityEngine;
|
||
|
|
using UnityEngine.Rendering;
|
||
|
|
|
||
|
|
namespace Aetheria.EditorTools
|
||
|
|
{
|
||
|
|
/// <summary>
|
||
|
|
/// Batchmode check for the flow as the scene runs it (campaign aetheria-fauna, cut fauna-flow-field; target
|
||
|
|
/// invariants flow-one-owner and flow-shader-parity). FlowFieldParityVerify proves the HLSL against FlowField with
|
||
|
|
/// no scene loaded; this opens ARPG.unity, lets VolumeSampling and Stardust initialise as the game does, drives
|
||
|
|
/// their own Start and Update with a zone attached, and asserts what the presenters did:
|
||
|
|
/// - nothing threw and nothing logged an error (a type that cannot initialise in a scene, as VolumeSampling once
|
||
|
|
/// could when it created its keywords in a static field initializer, throws on its first use);
|
||
|
|
/// - FLOW_GLOBAL and FLOW_SLOPE are enabled, _FlowAmplitude is the zone's, and the nebula noise clock is the zone's;
|
||
|
|
/// - the stardust particle buffer changes with the flow and freezes when the zone pauses;
|
||
|
|
/// - CloudShader pass 0 carries the FLOW_* keywords.
|
||
|
|
/// The scene is opened before any Aetheria type is touched: run this method alone in its own Unity session, never
|
||
|
|
/// after FlowFieldParityVerify (which would initialise VolumeSampling outside a MonoBehaviour constructor).
|
||
|
|
/// Dispatches compute and renders, so it needs a graphics device: run WITHOUT -nographics.
|
||
|
|
/// Unity -batchmode -projectPath <repo> -executeMethod Aetheria.EditorTools.FlowFieldSceneVerify.Run -logFile <path>
|
||
|
|
/// Do not pass -quit: Run exits the Editor itself, 0 on success and 1 on any failure.
|
||
|
|
/// </summary>
|
||
|
|
public static class FlowFieldSceneVerify
|
||
|
|
{
|
||
|
|
const string Tag = "[FlowFieldSceneVerify]";
|
||
|
|
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('\n')[0]);
|
||
|
|
}
|
||
|
|
|
||
|
|
static void Fail(string check, string detail)
|
||
|
|
{
|
||
|
|
Debug.LogError($"{Tag} FAIL {check}: {detail}");
|
||
|
|
_ok = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static void Run()
|
||
|
|
{
|
||
|
|
if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null)
|
||
|
|
{
|
||
|
|
Debug.LogError($"{Tag} FAIL: no graphics device in this session; rerun batchmode without -nographics.");
|
||
|
|
EditorApplication.Exit(1);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
Application.logMessageReceived += OnLog;
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// Before anything else touches the project's types: the scene constructs its MonoBehaviours here.
|
||
|
|
EditorSceneManager.OpenScene("Assets/Scenes/ARPG.unity", OpenSceneMode.Single);
|
||
|
|
Check();
|
||
|
|
Debug.Log(_ok ? $"{Tag} PASS" : $"{Tag} FAIL: see errors above");
|
||
|
|
}
|
||
|
|
catch (Exception e)
|
||
|
|
{
|
||
|
|
Debug.LogError($"{Tag} FAIL exception: {e}");
|
||
|
|
_ok = false;
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
Application.logMessageReceived -= OnLog;
|
||
|
|
}
|
||
|
|
EditorApplication.Exit(_ok ? 0 : 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
static void Check()
|
||
|
|
{
|
||
|
|
var settings = AssetDatabase.LoadAssetAtPath<GameSettings>("Assets/Resources/Settings.asset")
|
||
|
|
?? throw new InvalidOperationException("No Settings.asset.");
|
||
|
|
var agm = UnityEngine.Object.FindObjectsByType<ActionGameManager>(FindObjectsInactive.Include, FindObjectsSortMode.None).FirstOrDefault();
|
||
|
|
var volume = UnityEngine.Object.FindObjectsByType<VolumeSampling>(FindObjectsInactive.Include, FindObjectsSortMode.None).FirstOrDefault();
|
||
|
|
var dust = UnityEngine.Object.FindObjectsByType<Stardust>(FindObjectsInactive.Include, FindObjectsSortMode.None).FirstOrDefault();
|
||
|
|
if (agm == null || volume == null || dust == null)
|
||
|
|
{
|
||
|
|
Fail("scene-objects", $"ARPG.unity has ActionGameManager={agm != null}, VolumeSampling={volume != null}, Stardust={dust != null}");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Every flow term live, so FLOW_SLOPE is on; and the same flow with every amplitude zero.
|
||
|
|
var on = new FlowSettings
|
||
|
|
{
|
||
|
|
GlobalScale = 512f, GlobalAmplitude = 15f, GlobalScrollSpeed = .025f, Period = 8f, SlopeAmplitude = 3f, SwirlAmplitude = 5f
|
||
|
|
};
|
||
|
|
var off = new FlowSettings { GlobalScale = on.GlobalScale, GlobalScrollSpeed = on.GlobalScrollSpeed, Period = on.Period };
|
||
|
|
var work = Path.Combine(Path.GetTempPath(), "flow-scene-" + Guid.NewGuid().ToString("N"));
|
||
|
|
Directory.CreateDirectory(work);
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var zone = FlowFieldParityVerify.BuildZone(settings, work, on, 86400.0 * 3 + 12.3);
|
||
|
|
typeof(ActionGameManager).GetProperty("Instance").GetSetMethod(true).Invoke(null, new object[] { agm });
|
||
|
|
typeof(ActionGameManager).GetProperty("Zone").GetSetMethod(true).Invoke(agm, new object[] { zone });
|
||
|
|
if (agm.Settings == null) agm.Settings = settings;
|
||
|
|
|
||
|
|
Call(volume, "Start");
|
||
|
|
Call(volume, "Update");
|
||
|
|
if (!Shader.IsKeywordEnabled("FLOW_GLOBAL")) Fail("scene-keywords", "FLOW_GLOBAL is not enabled after VolumeSampling.Update");
|
||
|
|
if (!Shader.IsKeywordEnabled("FLOW_SLOPE")) Fail("scene-keywords", "FLOW_SLOPE is not enabled after VolumeSampling.Update");
|
||
|
|
if (Shader.GetGlobalFloat("_FlowAmplitude") != on.GlobalAmplitude)
|
||
|
|
Fail("scene-amplitude", $"_FlowAmplitude is {Shader.GetGlobalFloat("_FlowAmplitude")}, the zone's flow has {on.GlobalAmplitude}");
|
||
|
|
var scroll = Shader.GetGlobalVector("_FlowScroll");
|
||
|
|
if (scroll.x != zone.FlowScroll.x || scroll.y != zone.FlowScroll.y)
|
||
|
|
Fail("scene-scroll", $"_FlowScroll ({scroll.x}, {scroll.y}) is not the zone's ({zone.FlowScroll.x}, {zone.FlowScroll.y})");
|
||
|
|
var noiseTime = Shader.GetGlobalFloat("_NebulaNoiseTime");
|
||
|
|
if (noiseTime != (float) zone.ExactTime)
|
||
|
|
Fail("scene-noise-clock", $"_NebulaNoiseTime is {noiseTime}, the zone's time is {(float) zone.ExactTime}");
|
||
|
|
|
||
|
|
CheckCloudKeywords();
|
||
|
|
|
||
|
|
// Stardust follows the flow through its own dispatch: the particle buffer read back each frame.
|
||
|
|
Call(dust, "Start");
|
||
|
|
var bufferField = typeof(Stardust).GetField("_particlesBuffer", BindingFlags.NonPublic | BindingFlags.Instance);
|
||
|
|
float[] Frame()
|
||
|
|
{
|
||
|
|
Call(dust, "Update");
|
||
|
|
var buffer = (ComputeBuffer) bufferField.GetValue(dust);
|
||
|
|
var data = new float[buffer.count * 7];
|
||
|
|
buffer.GetData(data);
|
||
|
|
return data;
|
||
|
|
}
|
||
|
|
var flowing = Frame();
|
||
|
|
VolumeSampling.LowerFlow(off, VolumeSampling.FlowClock);
|
||
|
|
var still = Frame();
|
||
|
|
if (Differing(flowing, still) == 0) Fail("scene-stardust", "the particle buffer is the same with the zone's flow and with no flow");
|
||
|
|
|
||
|
|
// A paused zone freezes the particles and the nebula noise clock; a running one moves both.
|
||
|
|
Call(volume, "Update");
|
||
|
|
zone.Update(0f);
|
||
|
|
Call(volume, "Update");
|
||
|
|
if (Differing(flowing, Frame()) != 0) Fail("scene-stardust-pause", "the particle buffer moved while the zone was paused");
|
||
|
|
if (Shader.GetGlobalFloat("_NebulaNoiseTime") != noiseTime) Fail("scene-noise-clock", "_NebulaNoiseTime moved while the zone was paused");
|
||
|
|
zone.Update(.05f);
|
||
|
|
Call(volume, "Update");
|
||
|
|
if (Differing(flowing, Frame()) == 0) Fail("scene-stardust-run", "the particle buffer did not move while the zone ran");
|
||
|
|
if (Shader.GetGlobalFloat("_NebulaNoiseTime") == noiseTime) Fail("scene-noise-clock", "_NebulaNoiseTime did not move while the zone ran");
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
try { Directory.Delete(work, true); } catch { }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// CloudShader's pass 0 compiles with the flow keywords (it must #include_with_pragmas the shared include).
|
||
|
|
static void CheckCloudKeywords()
|
||
|
|
{
|
||
|
|
var shader = AssetDatabase.LoadAssetAtPath<Shader>("Assets/Shaders/Raymarching/CloudShader.shader");
|
||
|
|
if (shader == null) { Fail("scene-cloud-keywords", "No CloudShader.shader."); return; }
|
||
|
|
var keywords = ShaderUtil.GetPassKeywords(shader, new PassIdentifier(0, 0), UnityEditor.Rendering.ShaderType.Fragment)
|
||
|
|
.Select(keyword => keyword.name).ToArray();
|
||
|
|
foreach (var name in new[] { "FLOW_GLOBAL", "FLOW_SLOPE" })
|
||
|
|
if (!keywords.Contains(name)) Fail("scene-cloud-keywords", $"CloudShader pass 0 does not carry {name}");
|
||
|
|
}
|
||
|
|
|
||
|
|
static int Differing(float[] a, float[] b)
|
||
|
|
{
|
||
|
|
var count = 0;
|
||
|
|
for (var i = 0; i < a.Length; i++)
|
||
|
|
if (Math.Abs(a[i] - b[i]) > 1e-4f) count++;
|
||
|
|
return count;
|
||
|
|
}
|
||
|
|
|
||
|
|
// The presenters' own Start and Update, as the game calls them; an exception is a failure that names the type.
|
||
|
|
static void Call(object target, string method)
|
||
|
|
{
|
||
|
|
var info = target.GetType().GetMethod(method, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance);
|
||
|
|
if (info == null) { Fail("scene-presenter-exception", $"{target.GetType().Name}.{method} not found"); return; }
|
||
|
|
try { info.Invoke(target, null); }
|
||
|
|
catch (TargetInvocationException e)
|
||
|
|
{
|
||
|
|
Fail("scene-presenter-exception", $"{target.GetType().Name}.{method} threw {e.InnerException?.GetType().Name}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|