From ebce853abaa5aaf04dd8a18c4ad7c7c7a114dbe7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 18:11:04 +0200 Subject: [PATCH 1/2] Stardust draws at BeforeImageEffectsOpaque, before the nebula composite; scene verifier proves dust under fog is attenuated The nebula is an [ImageEffectOpaque] OnRenderImage; a command buffer at BeforeForwardAlpha runs after its composite, so the dust landed on top of the fog (B49). BeforeImageEffectsOpaque runs before it on the deferred camera. FlowFieldSceneVerify now requires one Stardust buffer at the new event and none at BeforeForwardAlpha, and scene-stardust-under-fog compares the dust's contribution with the cloud renderer off and on in an HDR target, with a fog control. All commits build. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Editor/FlowFieldSceneVerify.cs | 140 ++++++++++++++++-- Assets/Shaders/Compute/Stardust/Stardust.cs | 3 +- 2 files changed, 127 insertions(+), 16 deletions(-) diff --git a/Assets/Scripts/Editor/FlowFieldSceneVerify.cs b/Assets/Scripts/Editor/FlowFieldSceneVerify.cs index 771cd9396..c3f54042b 100644 --- a/Assets/Scripts/Editor/FlowFieldSceneVerify.cs +++ b/Assets/Scripts/Editor/FlowFieldSceneVerify.cs @@ -142,6 +142,7 @@ namespace Aetheria.EditorTools if (Shader.GetGlobalFloat("_NebulaNoiseTime") == noiseTime) Fail("scene-noise-clock", "_NebulaNoiseTime did not move while the zone ran"); CheckStardustUncullable(dust, bufferField); + CheckStardustUnderFog(dust, bufferField); } finally { @@ -156,24 +157,13 @@ namespace Aetheria.EditorTools { var camera = dust.TargetCamera; if (camera == null) { Fail("scene-stardust-uncullable", "Stardust has no TargetCamera"); return; } - var attached = camera.GetCommandBuffers(CameraEvent.BeforeForwardAlpha).Count(buffer => buffer.name == "Stardust"); + var attached = camera.GetCommandBuffers(CameraEvent.BeforeImageEffectsOpaque).Count(buffer => buffer.name == "Stardust"); if (attached != 1) Fail("scene-stardust-uncullable", $"{attached} Stardust command buffers on the target camera, expected 1"); + var late = camera.GetCommandBuffers(CameraEvent.BeforeForwardAlpha).Count(buffer => buffer.name == "Stardust"); + if (late != 0) Fail("scene-stardust-uncullable", $"{late} Stardust command buffers at BeforeForwardAlpha, expected 0"); var particles = (ComputeBuffer) bufferField.GetValue(dust); - var side = Mathf.RoundToInt(Mathf.Sqrt(particles.count)); - float[] Layout(float size, Vector3 centre, float below) - { - var data = new float[particles.count * 7]; - for (var i = 0; i < particles.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; - } + 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); @@ -231,6 +221,126 @@ namespace Aetheria.EditorTools } } + // 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(); + 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 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", 1f); + Shader.SetGlobalFloat("_ExtinctionCoefficient", .5f); + Shader.SetGlobalFloat("_SafetyDistance", 30f); + 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 (fogShown < 1e-3f) Fail("scene-stardust-under-fog", "control: the cloud renderer changed no pixels, so no fog is in view"); + else if (drawn == 0 || clearMean <= 0f) Fail("scene-stardust-under-fog", "the dust drew no pixels with the cloud renderer off"); + 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"); + } + 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); + } + } + + 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() { diff --git a/Assets/Shaders/Compute/Stardust/Stardust.cs b/Assets/Shaders/Compute/Stardust/Stardust.cs index 8b8b45c22..fd4ada9c0 100644 --- a/Assets/Shaders/Compute/Stardust/Stardust.cs +++ b/Assets/Shaders/Compute/Stardust/Stardust.cs @@ -59,7 +59,8 @@ public class Stardust : MonoBehaviour // The dust is drawn by a command buffer on TargetCamera: a camera replays its buffers at their event and never culls them, // so the draw needs no bounds and no extent can fall short of how deep the field is. private CommandBuffer _draw; - private const CameraEvent DrawEvent = CameraEvent.BeforeForwardAlpha; + // Before the [ImageEffectOpaque] nebula, so its march fogs the dust; a deferred camera skips the forward-opaque events when nothing forward-opaque is drawn (B46, B49). + private const CameraEvent DrawEvent = CameraEvent.BeforeImageEffectsOpaque; #endregion From 3fc59887d5cc56b613c416e5135ac7e404ad677c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 18:48:11 +0200 Subject: [PATCH 2/2] Scene verifier measures dust at partial fog and reads Stardust's own event The under-fog check lays a floor under the dust so the march has a finite ray (pixels with no depth are fogged whole), disables the zone noise, and uses a dim uniform fill: the correct event dims the dust to 0.58 of fog-free, and dust drawn after the composite reads 0.99. The check also fails when the dust is erased (below 0.1) or missing with the fog off. The attach check reads Stardust.DrawEvent by reflection and requires no Stardust buffer at any other CameraEvent. Builds. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Editor/FlowFieldSceneVerify.cs | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/Editor/FlowFieldSceneVerify.cs b/Assets/Scripts/Editor/FlowFieldSceneVerify.cs index c3f54042b..d39874f33 100644 --- a/Assets/Scripts/Editor/FlowFieldSceneVerify.cs +++ b/Assets/Scripts/Editor/FlowFieldSceneVerify.cs @@ -150,6 +150,10 @@ namespace Aetheria.EditorTools } } + // 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. @@ -157,10 +161,14 @@ namespace Aetheria.EditorTools { var camera = dust.TargetCamera; if (camera == null) { Fail("scene-stardust-uncullable", "Stardust has no TargetCamera"); return; } - var attached = camera.GetCommandBuffers(CameraEvent.BeforeImageEffectsOpaque).Count(buffer => buffer.name == "Stardust"); - if (attached != 1) Fail("scene-stardust-uncullable", $"{attached} Stardust command buffers on the target camera, expected 1"); - var late = camera.GetCommandBuffers(CameraEvent.BeforeForwardAlpha).Count(buffer => buffer.name == "Stardust"); - if (late != 0) Fail("scene-stardust-uncullable", $"{late} Stardust command buffers at BeforeForwardAlpha, expected 0"); + 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); @@ -240,6 +248,7 @@ namespace Aetheria.EditorTools 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; @@ -264,6 +273,12 @@ namespace Aetheria.EditorTools 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) }) @@ -275,9 +290,9 @@ namespace Aetheria.EditorTools Shader.SetGlobalFloat("_NebulaFillExponent", 0f); Shader.SetGlobalFloat("_NebulaFillOffset", 0f); Shader.SetGlobalFloat("_NebulaPatchDensity", 0f); - Shader.SetGlobalFloat("_NebulaLuminance", 1f); - Shader.SetGlobalFloat("_ExtinctionCoefficient", .5f); - Shader.SetGlobalFloat("_SafetyDistance", 30f); + 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); @@ -295,9 +310,10 @@ namespace Aetheria.EditorTools } 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 (fogShown < 1e-3f) Fail("scene-stardust-under-fog", "control: the cloud renderer changed no pixels, so no fog is in view"); - else if (drawn == 0 || clearMean <= 0f) Fail("scene-stardust-under-fog", "the dust drew no pixels with the cloud renderer off"); + 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 { @@ -313,6 +329,7 @@ namespace Aetheria.EditorTools RenderTexture.ReleaseTemporary(texture); UnityEngine.Object.DestroyImmediate(readback); foreach (var field in fields) UnityEngine.Object.DestroyImmediate(field); + if (floor != null) UnityEngine.Object.DestroyImmediate(floor); } }