using System; using System.IO; using System.Linq; using System.Reflection; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.Rendering; namespace Aetheria.EditorTools { /// /// 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. /// 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("Assets/Resources/Settings.asset") ?? throw new InvalidOperationException("No Settings.asset."); var agm = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None).FirstOrDefault(); var volume = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None).FirstOrDefault(); var dust = UnityEngine.Object.FindObjectsByType(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"); CheckStardustUncullable(dust, bufferField); CheckStardustUnderFog(dust, bufferField); } finally { try { Directory.Delete(work, true); } catch { } } } // The event Stardust itself attaches its draw at, read from the class so the verifier cannot agree with a copy of it. static CameraEvent DrawEvent() => (CameraEvent) typeof(Stardust).GetField("DrawEvent", BindingFlags.NonPublic | BindingFlags.Static).GetRawConstantValue(); // The dust is a command buffer on the target camera, which no culling sees: with every particle 10000 below the // camera (far past any box a hand could inflate round it) a render still shows dust, and shows none when the // same particles have no size. static void CheckStardustUncullable(Stardust dust, FieldInfo bufferField) { var camera = dust.TargetCamera; if (camera == null) { Fail("scene-stardust-uncullable", "Stardust has no TargetCamera"); return; } var drawEvent = DrawEvent(); var attached = camera.GetCommandBuffers(drawEvent).Count(buffer => buffer.name == "Stardust"); if (attached != 1) Fail("scene-stardust-uncullable", $"{attached} Stardust command buffers at {drawEvent} on the target camera, expected 1"); foreach (var other in Enum.GetValues(typeof(CameraEvent)).Cast().Where(other => other != drawEvent)) { var stray = camera.GetCommandBuffers(other).Count(buffer => buffer.name == "Stardust"); if (stray != 0) Fail("scene-stardust-uncullable", $"{stray} Stardust command buffers at {other}, expected 0"); } var particles = (ComputeBuffer) bufferField.GetValue(dust); float[] Layout(float size, Vector3 centre, float below) => DustLayout(particles.count, size, centre, below); var transform = camera.transform; var (position, rotation) = (transform.position, transform.rotation); var (far, flags, background, mask, target) = (camera.farClipPlane, camera.clearFlags, camera.backgroundColor, camera.cullingMask, camera.targetTexture); var texture = RenderTexture.GetTemporary(64, 64, 24, RenderTextureFormat.ARGB32); var readback = new Texture2D(64, 64, TextureFormat.RGBA32, false); var previous = RenderTexture.active; // Image effects on the camera would write their own frame over the dust; only the dust draw is under test. var effects = camera.GetComponents().Where(behaviour => behaviour.enabled).ToArray(); Color32[] Render(float size, Vector3 centre, float below) { Call(dust, "Update"); // on a bounded draw this queues the draw for the camera; here it dispatches the compute particles.SetData(Layout(size, centre, below)); camera.Render(); RenderTexture.active = texture; readback.ReadPixels(new Rect(0, 0, 64, 64), 0, 0); readback.Apply(); return readback.GetPixels32(); } try { foreach (var effect in effects) effect.enabled = false; camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = Color.black; camera.cullingMask = 1; camera.targetTexture = texture; // control: the camera where the scene put it, dust 100 below, inside any box a hand-set extent would give transform.SetPositionAndRotation(position, Quaternion.Euler(90f, 0f, 0f)); camera.farClipPlane = 1000f; var nearWith = Render(5f, position, 100f); var nearWithout = Render(0f, position, 100f); var nearDiff = 0; for (var i = 0; i < nearWith.Length; i++) if (!nearWith[i].Equals(nearWithout[i])) nearDiff++; Debug.Log($"{Tag} stardust near-control differing pixels={nearDiff}"); if (nearDiff == 0) Fail("scene-stardust-uncullable", "control: dust 100 below the camera drew no pixels"); transform.SetPositionAndRotation(new Vector3(0f, -40000f, 0f), Quaternion.Euler(90f, 0f, 0f)); camera.farClipPlane = 30000f; var withDust = Render(400f, transform.position, 10000f); // the material scales a quad by distance^.33, so at this range it needs this size to cover pixels var without = Render(0f, transform.position, 10000f); var differing = 0; for (var i = 0; i < withDust.Length; i++) if (!withDust[i].Equals(without[i])) differing++; if (differing == 0) Fail("scene-stardust-uncullable", "the dust 10000 below the camera drew no pixels"); } finally { RenderTexture.active = previous; foreach (var effect in effects) effect.enabled = true; camera.targetTexture = target; camera.cullingMask = mask; camera.backgroundColor = background; camera.clearFlags = flags; camera.farClipPlane = far; transform.SetPositionAndRotation(position, rotation); RenderTexture.ReleaseTemporary(texture); UnityEngine.Object.DestroyImmediate(readback); } } // The nebula is an [ImageEffectOpaque] that fogs the whole ray to a depth the dust never writes (B49): dust drawn // before it is attenuated by the fog, dust drawn after it sits on top at full strength. The dust's contribution to a // pixel (render with dust minus render without) is read with the cloud renderer off and on, in an HDR target. static void CheckStardustUnderFog(Stardust dust, FieldInfo bufferField) { const int Size = 128; var camera = dust.TargetCamera; var cloud = camera == null ? null : camera.GetComponent(); if (cloud == null) { Fail("scene-stardust-under-fog", "the target camera has no VolumeCloudRenderer"); return; } var particles = (ComputeBuffer) bufferField.GetValue(dust); var transform = camera.transform; var (position, rotation) = (transform.position, transform.rotation); var (far, flags, background, mask, target, hdr) = (camera.farClipPlane, camera.clearFlags, camera.backgroundColor, camera.cullingMask, camera.targetTexture, camera.allowHDR); var texture = RenderTexture.GetTemporary(Size, Size, 24, RenderTextureFormat.ARGBHalf); var readback = new Texture2D(Size, Size, TextureFormat.RGBAHalf, false); var previous = RenderTexture.active; var effects = camera.GetComponents().Where(behaviour => behaviour.enabled).ToArray(); var fields = new System.Collections.Generic.List(); GameObject floor = null; float[] Render(bool fog, float size) { cloud.enabled = fog; for (var frame = 0; frame < 2; frame++) // the cloud's first frame forces ULTRA and fills its history; the second is the settled one { Call(dust, "Update"); particles.SetData(DustLayout(particles.count, size, position, 100f)); camera.Render(); } RenderTexture.active = texture; readback.ReadPixels(new Rect(0, 0, Size, Size), 0, 0); readback.Apply(); return readback.GetPixels().Select(pixel => pixel.r * .2126f + pixel.g * .7152f + pixel.b * .0722f).ToArray(); } try { foreach (var effect in effects) effect.enabled = false; camera.allowHDR = true; camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = Color.black; camera.cullingMask = 1; camera.targetTexture = texture; camera.farClipPlane = 1000f; transform.SetPositionAndRotation(position, Quaternion.Euler(90f, 0f, 0f)); // The fog only measures a partial density where depth is written: a pixel with no depth is fogged whole (CloudShader sets // density 1 past the far plane). A floor under the dust gives the march a finite ray. floor = GameObject.CreatePrimitive(PrimitiveType.Plane); floor.transform.position = position + Vector3.down * 200f; floor.transform.localScale = Vector3.one * 100f; UnityEngine.Object.DestroyImmediate(floor.GetComponent()); // The scene's own nebula fields are textures the game renders; here a uniform fill of white fog stands in for them. foreach (var (name, format) in new[] { ("_NebulaSurfaceHeight", TextureFormat.RFloat), ("_NebulaPatchHeight", TextureFormat.RHalf), ("_NebulaPatch", TextureFormat.RHalf) }) Shader.SetGlobalTexture(name, Uniform(format, Color.black, fields)); Shader.SetGlobalTexture("_NebulaTint", Uniform(TextureFormat.RGBA32, Color.white, fields)); Shader.DisableKeyword("NOISE_SLOPE"); Shader.SetGlobalFloat("_NebulaFillDensity", .01f); Shader.SetGlobalFloat("_NebulaFillDistance", 120f); Shader.SetGlobalFloat("_NebulaFillExponent", 0f); Shader.SetGlobalFloat("_NebulaFillOffset", 0f); Shader.SetGlobalFloat("_NebulaPatchDensity", 0f); Shader.SetGlobalFloat("_NebulaLuminance", .01f); // a fog dimmer than the dust: dust drawn over it would otherwise read as a negative contribution Shader.SetGlobalFloat("_ExtinctionCoefficient", .25f); // with the fill density, about 40% of the light to the floor is fogged away Shader.SetGlobalFloat("_SafetyDistance", -1e6f); // below every sample: only the uniform fill, none of the zone's noise Shader.SetGlobalFloat("_TintLodExponent", 0f); var clearBare = Render(false, 0f); var clearDust = Render(false, 5f); var fogBare = Render(true, 0f); var fogDust = Render(true, 5f); var fogShown = 0f; var (clearSum, fogSum, drawn) = (0f, 0f, 0); for (var i = 0; i < clearBare.Length; i++) { fogShown += Mathf.Abs(fogBare[i] - clearBare[i]) / clearBare.Length; if (clearDust[i] == clearBare[i]) continue; drawn++; clearSum += clearDust[i] - clearBare[i]; fogSum += fogDust[i] - fogBare[i]; } var (clearMean, fogMean) = (drawn == 0 ? 0f : clearSum / drawn, drawn == 0 ? 0f : fogSum / drawn); Debug.Log($"{Tag} stardust under fog: fog control mean |difference|={fogShown}, dust pixels={drawn}, dust contribution cloud off={clearMean}, cloud on={fogMean}, ratio={(clearMean == 0f ? 0f : fogMean / clearMean)}"); if (drawn == 0 || clearMean <= 0f) Fail("scene-stardust-under-fog", "control: the dust drew no pixels with the cloud renderer off"); else if (fogShown < 1e-3f) Fail("scene-stardust-under-fog", "control: the cloud renderer changed no pixels, so no fog is in view"); else if (fogMean > .9f * clearMean) Fail("scene-stardust-under-fog", "the dust is not attenuated by the fog in front of it: it was drawn after the nebula composites"); else if (fogMean < .1f * clearMean) Fail("scene-stardust-under-fog", "the dust is erased, not dimmed, by a partial fog"); } finally { RenderTexture.active = previous; foreach (var effect in effects) effect.enabled = true; camera.allowHDR = hdr; camera.targetTexture = target; camera.cullingMask = mask; camera.backgroundColor = background; camera.clearFlags = flags; camera.farClipPlane = far; transform.SetPositionAndRotation(position, rotation); RenderTexture.ReleaseTemporary(texture); UnityEngine.Object.DestroyImmediate(readback); foreach (var field in fields) UnityEngine.Object.DestroyImmediate(field); if (floor != null) UnityEngine.Object.DestroyImmediate(floor); } } static Texture2D Uniform(TextureFormat format, Color colour, System.Collections.Generic.List owner) { var texture = new Texture2D(1, 1, format, false) { wrapMode = TextureWrapMode.Clamp }; texture.SetPixel(0, 0, colour); texture.Apply(); owner.Add(texture); return texture; } // A square patch of particles 4 apart, `below` under `centre`. static float[] DustLayout(int count, float size, Vector3 centre, float below) { var side = Mathf.RoundToInt(Mathf.Sqrt(count)); var data = new float[count * 7]; for (var i = 0; i < count; i++) { data[i * 7 + 0] = centre.x + ((i % side) - side / 2f) * 4f; data[i * 7 + 1] = centre.y - below; data[i * 7 + 2] = centre.z + ((i / side) - side / 2f) * 4f; data[i * 7 + 3] = data[i * 7 + 4] = data[i * 7 + 5] = 1f; data[i * 7 + 6] = size; } return data; } // CloudShader's pass 0 compiles with the flow keywords (it must #include_with_pragmas the shared include). static void CheckCloudKeywords() { var shader = AssetDatabase.LoadAssetAtPath("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}"); } } } }