From ecb165624d020ae00508bd133cea92c02f0bf2ef Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 04:13:43 +0200 Subject: [PATCH 1/5] Zone.PopulatedRadius: the extent of the populated area, owned by the zone The largest over bodies of the root orbit's offset from the origin, every orbit Distance along the chain, and the body's Roche limit: a static bound on where any body can be, computed once at construction and independent of Pack.Radius. Presenters read it. Builds; the Unity side follows in the next commit. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Zone.cs | 25 ++++ .../PopulatedRadiusTests.cs | 122 ++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index b7b099253..0be6b82b2 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -211,6 +211,8 @@ public class Zone belt.Value.Periods[i] = PeriodAround(beltParent, belt.Value.Data.Asteroids[i].Distance, beltOrbit); } + PopulatedRadius = ComputePopulatedRadius(); + // Aetheria gravity (target invariant zone-valid-before-tick): every orbit is placed at the pack's time // when the zone is built, so each well, light and body pose read before the first Update is where the // body is. Velocity stays zero until the first Update differences two placed positions. @@ -736,6 +738,29 @@ public class Zone // reference. GetHeight sums with it and the renderer's lowering (GravityLowering) hands it to the shader unchanged. public float WellReference(Planet body) => GravityField.Potential(body.WellStrength, body.RocheLimit, Pack.Radius); + // The extent of the populated area, from the zone's origin: owned here; presenters read it, nothing authors it. A static + // bound on where any body can be, so it holds at every time and reads nothing the pack's Radius decides: for each body, + // the root orbit's offset from the origin, plus every orbit Distance along its chain to the root, plus its Roche limit. + public float PopulatedRadius { get; } + + private float ComputePopulatedRadius() + { + var radius = 0f; + foreach (var body in PlanetInstances.Values) + { + var reach = body.RocheLimit; + var key = body.BodyData.Orbit.Key; + while (key.IsSet() && Orbits.TryGetValue(key, out var orbit)) + { + if (orbit.Data.Parent.IsSet()) reach += orbit.Data.Distance; + else reach += length(orbit.Data.FixedPosition); + key = orbit.Data.Parent.Key; + } + radius = max(radius, reach); + } + return radius; + } + public float GetHeight(float2 position) => GetHeight(position, _time); // The field at a time: each well sits where its body's rail puts it then. The sim reads it at _time, a presenter at diff --git a/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs b/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs new file mode 100644 index 000000000..dc1f5e865 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.IO; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// Zone.PopulatedRadius is the extent of the populated area, owned by the zone and read by the ambient tint's presenter +// (ruling aetheria-gravity:ruling:dark-beyond-populated-area). The zone here is the production constructor reading a pack +// of a root off the origin, a planet, a moon and a moon's moon, each with a well of its own mass. +public sealed class PopulatedRadiusTests : IDisposable +{ + private static readonly float2 RootAt = new float2(300f, -200f); + private const float PlanetDistance = 400f; + private const float MoonDistance = 100f; + private const float MoonsMoonDistance = 40f; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-populated-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + + public PopulatedRadiusTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var cache in _caches) cache.Dispose(); + Directory.Delete(_root, true); + } + + private static ExponentialCurve Flat(float v) => new ExponentialCurve { Constant = v }; + + // A body's Roche limit is 10 u per unit of mass, so each body's term differs and the outermost is the moon's moon. + private static PlanetSettings Settings() => new PlanetSettings + { + BodyRadius = Flat(20f), + WellStrength = new ExponentialCurve { Multiplier = 100f, Exponent = 1f }, + RocheLimit = new ExponentialCurve { Multiplier = 10f, Exponent = 1f }, + LightRadius = Flat(0f) + }; + + private sealed class Scene + { + public Zone Zone; + public CultRecordKey[] Orbits; + } + + private Scene Build(float radius, bool moonsMoon = true) + { + var dir = Path.Combine(_root, $"zone{_caches.Count}"); + Directory.CreateDirectory(dir); + var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); + _caches.Add(cache); + cache.Upsert(new VerseGrammar { Revision = 1 }); + var items = new ItemManager(cache, new ProvenanceLedger(), new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }, _ => { }); + + var rootOrbit = cache.Upsert(new OrbitData { FixedPosition = RootAt }).Key; + var planetOrbit = cache.Upsert(new OrbitData { Parent = new CultRecordRef(rootOrbit), Distance = PlanetDistance, Phase = .1f }).Key; + var moonOrbit = cache.Upsert(new OrbitData { Parent = new CultRecordRef(planetOrbit), Distance = MoonDistance, Phase = .3f }).Key; + var moonsMoonOrbit = cache.Upsert(new OrbitData { Parent = new CultRecordRef(moonOrbit), Distance = MoonsMoonDistance, Phase = .6f }).Key; + var pack = new ZonePack { Radius = radius, Mass = 10000f, Time = 0 }; + foreach (var orbit in new[] { rootOrbit, planetOrbit, moonOrbit, moonsMoonOrbit }) + pack.Orbits.Add(new CultRecordRef(orbit)); + void Body(CultRecordKey orbit, float mass) => + pack.Planets.Add(new CultRecordRef(cache.Upsert(new PlanetData { Orbit = new CultRecordRef(orbit), Mass = mass }).Key)); + Body(rootOrbit, 5f); + Body(planetOrbit, 1f); + Body(moonOrbit, .5f); + if (moonsMoon) Body(moonsMoonOrbit, .01f); + var zone = new Zone(items, Settings(), pack, new GalaxyZone { Name = "Populated", Owner = null }, null); + return new Scene { Zone = zone, Orbits = new[] { rootOrbit, planetOrbit, moonOrbit, moonsMoonOrbit } }; + } + + [Fact] + public void ItCoversEveryBodyAtEveryTimeAndIsTheChainSumOfTheOutermost() + { + var s = Build(5000f); + + // Worked by hand: the root's offset from the origin, every distance down the chain, the outermost body's Roche limit. + var expected = length(RootAt) + PlanetDistance + MoonDistance + MoonsMoonDistance + 10f * .01f; + Assert.Equal(expected, s.Zone.PopulatedRadius, 2); + + var worst = 0f; + for (var i = 0; i < 50; i++) + { + var time = i * 137.0; + foreach (var body in s.Zone.PlanetInstances.Values) + { + var reach = length(s.Zone.OrbitPositionAt(body.BodyData.Orbit.Key, time)) + body.RocheLimit; + Assert.True(reach <= s.Zone.PopulatedRadius + .01f, "a body's well is outside the populated radius"); + worst = max(worst, reach); + } + } + Assert.True(worst > 0f); + } + + [Fact] + public void ItBoundsTheRootOffTheOriginAndEachLevelOfTheChain() + { + // Without the moon's moon the outermost is the moon: the bound loses that level's distance and Roche term and no more. + var s = Build(5000f, moonsMoon: false); + + Assert.Equal(length(RootAt) + PlanetDistance + MoonDistance + 10f * .5f, s.Zone.PopulatedRadius, 2); + } + + [Theory] + [InlineData(100f)] + [InlineData(5000f)] + [InlineData(90000f)] + public void ItDoesNotReadPackRadius(float radius) + { + var s = Build(radius); + + Assert.Equal(length(RootAt) + PlanetDistance + MoonDistance + MoonsMoonDistance + 10f * .01f, s.Zone.PopulatedRadius, 2); + } +} From 9b05ced11f0815b08fb280f3cbc87f93a6cb05c7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 05:08:16 +0200 Subject: [PATCH 2/5] The ambient tint is a gaussian over the populated radius, not a quad that ends at Pack.Radius Ambient Fog Light leaves Zone Brushes (root object, same pose) and Sector Tint.mat moves from PowerBrush to Brushes/Ambient Brush: brightness falls as exp(-x^2 ln(1/_EdgeBrightness)), x in units of Zone.PopulatedRadius. ZoneRenderer.FitAmbientLight, called by LoadZone, is the one writer of the quad's size and _Reach (the radius where it is 1e-5 of the centre). AmbientEdgeVerify renders the real scene object through that call. Builds in Unity 6000.3.24f1. Co-Authored-By: Claude Opus 5.5 --- Assets/Materials/Brushes/Sector Tint.mat | 5 +- Assets/Scenes/ARPG.unity | 11 +- Assets/Scripts/Editor/AmbientEdgeVerify.cs | 221 ++++++++++++++++++ .../Scripts/Editor/AmbientEdgeVerify.cs.meta | 2 + .../Scripts/Editor/DrawnPoseWiringVerify.cs | 1 + .../Scripts/Editor/FloatingItemsDrawnSmoke.cs | 1 + .../Editor/GravityFieldParityVerify.cs | 4 +- Assets/Scripts/Zone Display/ZoneRenderer.cs | 19 ++ Assets/Shaders/Brushes/AmbientBrush.shader | 61 +++++ .../Shaders/Brushes/AmbientBrush.shader.meta | 9 + 10 files changed, 324 insertions(+), 10 deletions(-) create mode 100644 Assets/Scripts/Editor/AmbientEdgeVerify.cs create mode 100644 Assets/Scripts/Editor/AmbientEdgeVerify.cs.meta create mode 100644 Assets/Shaders/Brushes/AmbientBrush.shader create mode 100644 Assets/Shaders/Brushes/AmbientBrush.shader.meta diff --git a/Assets/Materials/Brushes/Sector Tint.mat b/Assets/Materials/Brushes/Sector Tint.mat index 3e5e00f24..84e252ad3 100644 --- a/Assets/Materials/Brushes/Sector Tint.mat +++ b/Assets/Materials/Brushes/Sector Tint.mat @@ -8,7 +8,7 @@ Material: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_Name: Sector Tint - m_Shader: {fileID: 4800000, guid: 268da230635cfb34ab4ed6ad39503fa0, type: 3} + m_Shader: {fileID: 4800000, guid: 782740c1a3d7455ea75dc87fafdc8432, type: 3} m_ValidKeywords: [] m_InvalidKeywords: [] m_LightmapFlags: 4 @@ -59,8 +59,8 @@ Material: m_Ints: [] m_Floats: - _BumpScale: 1 - - _Cutoff: 1 - _Depth: 0.025 + - _EdgeBrightness: 0.12 - _DetailNormalMapScale: 1 - _DstBlend: 0 - _Frequency: 6.2832 @@ -72,7 +72,6 @@ Material: - _OcclusionStrength: 1 - _Parallax: 0.02 - _Phase: 0 - - _Power: 2 - _SinePower: 1.25 - _SmoothnessTextureChannel: 0 - _SpecularHighlights: 1 diff --git a/Assets/Scenes/ARPG.unity b/Assets/Scenes/ARPG.unity index 9c650e2f0..3ce35fe93 100644 --- a/Assets/Scenes/ARPG.unity +++ b/Assets/Scenes/ARPG.unity @@ -409,13 +409,13 @@ Transform: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 23420356} serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalRotation: {x: 0.7071068, y: 0, z: 0, w: 0.7071068} + m_LocalPosition: {x: 0, y: -5, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] - m_Father: {fileID: 1037490739} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 90, y: 0, z: 0} --- !u!23 &23420359 MeshRenderer: m_ObjectHideFlags: 0 @@ -16322,6 +16322,7 @@ MonoBehaviour: Settings: {fileID: 11400000, guid: cd2344e2001a37544aab0960c164d4ff, type: 2} ZoneRoot: {fileID: 2095699057} SectorBrushes: {fileID: 1037490739} + AmbientLight: {fileID: 23420357} SceneCamera: {fileID: 887677622} FogCameras: - {fileID: 559840917} @@ -16864,7 +16865,6 @@ Transform: m_LocalScale: {x: 3000, y: 3000, z: 3000} m_ConstrainProportionsScale: 0 m_Children: - - {fileID: 23420357} - {fileID: 1108843909} - {fileID: 1647230682} - {fileID: 1254851255} @@ -38395,6 +38395,7 @@ SceneRoots: - {fileID: 906767442} - {fileID: 1009503730} - {fileID: 1037490739} + - {fileID: 23420357} - {fileID: 2095699057} - {fileID: 134113271} - {fileID: 666227432} diff --git a/Assets/Scripts/Editor/AmbientEdgeVerify.cs b/Assets/Scripts/Editor/AmbientEdgeVerify.cs new file mode 100644 index 000000000..fd0482bd9 --- /dev/null +++ b/Assets/Scripts/Editor/AmbientEdgeVerify.cs @@ -0,0 +1,221 @@ +using System; +using System.IO; +using GameCult.Caching; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.Rendering; + +namespace Aetheria.EditorTools +{ + /// + /// Batchmode check for campaign aetheria-gravity, cut ambient-follows-populated-area: the ambient tint falls softly to + /// dark and never hits an edge (ruling dark-beyond-populated-area). It opens ARPG.unity, takes the Ambient Fog Light the + /// scene wires into ZoneRenderer, sizes it for a generated zone with ZoneRenderer.FitAmbientLight (the call LoadZone + /// makes, not a copy), renders it through an orthographic camera that culls its layer into a float target, and checks the + /// radial profile against exp(-(r/P)^2 ln(1/_EdgeBrightness)) times the centre out to 2 P (P is Zone.PopulatedRadius), + /// that it is lit at Pack.Radius, that the quad's rim is at most 2e-5 of the centre, and that no texel is more than half + /// a decade from its neighbour. + /// + /// Renders, so it needs a graphics device: run WITHOUT -nographics. + /// Unity -batchmode -projectPath <repo> -executeMethod Aetheria.EditorTools.AmbientEdgeVerify.Run -logFile <path> + /// Do not pass -quit: Run exits the Editor itself, 0 on success and 1 on any failure. + /// + public static class AmbientEdgeVerify + { + const string Tag = "[AmbientEdgeVerify]"; + const int Size = 512; + const int AmbientLayer = 13; + 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; + var work = Path.Combine(Path.GetTempPath(), "ambient-edge-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(work, "probe")); + try + { + var settings = AssetDatabase.LoadAssetAtPath("Assets/Resources/Settings.asset") + ?? throw new InvalidOperationException("No Settings.asset."); + // Generated zones have the populated radius at about .78 of the pack radius (p50 over 1500 zones): this one's + // pack radius is set so, which puts Pack.Radius where it sits in play, inside the quad's reach. + var probe = GravityFieldParityVerify.BuildZone(settings, Path.Combine(work, "probe"), out _); + var zone = GravityFieldParityVerify.BuildZone(settings, work, out _, probe.PopulatedRadius / .78f); + EditorSceneManager.OpenScene("Assets/Scenes/ARPG.unity", OpenSceneMode.Single); + try + { + var renderer = UnityEngine.Object.FindFirstObjectByType(FindObjectsInactive.Include); + if (renderer == null || renderer.AmbientLight == null) throw new InvalidOperationException("ARPG.unity wires no ZoneRenderer.AmbientLight."); + Check(renderer, zone); + } + finally + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + } + 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; + try { Directory.Delete(work, true); } catch { } + } + EditorApplication.Exit(_ok ? 0 : 1); + } + + // A bare ambient quad for the verifies that run LoadZone on a renderer built in code: the scene's material, no scene. + public static Transform NewAmbientLight() + { + var quad = new GameObject("Ambient Fog Light") { layer = AmbientLayer }; + quad.AddComponent().sharedMesh = Resources.GetBuiltinResource("Quad.fbx"); + quad.AddComponent().sharedMaterial = AssetDatabase.LoadAssetAtPath("Assets/Materials/Brushes/Sector Tint.mat"); + return quad.transform; + } + + static void Check(ZoneRenderer renderer, Zone zone) + { + var light = renderer.AmbientLight; + if (light.parent != null) Fail("wiring", $"the ambient quad sits under {light.parent.name}, whose scale is the pack radius's"); + if (light.gameObject.layer != AmbientLayer) Fail("wiring", $"the ambient quad is on layer {light.gameObject.layer}, not {AmbientLayer}"); + + ZoneRenderer.FitAmbientLight(light, zone); + var meshRenderer = light.GetComponent(); + var material = meshRenderer.sharedMaterial; + var edge = material.GetFloat("_EdgeBrightness"); + var centre = material.GetFloat("_Depth") * material.GetColor("_Color").linear.r; + var populated = zone.PopulatedRadius; + var half = light.localScale.x / 2f; + Debug.Log($"{Tag} populated radius {populated:F1} u, pack radius {zone.Pack.Radius:F1} u, quad half-extent {half:F1} u, edge brightness {edge:G3}, centre {centre:G4}"); + if (!(populated > 0f) || !(half > populated)) { Fail("sizing", $"the quad half-extent {half:F1} u does not reach beyond the populated radius {populated:F1} u"); return; } + + Render(light, half * 1.05f, out var value, out var world); + var logFall = Mathf.Log(1f / edge); + foreach (var k in new[] { 0f, .5f, 1f, 1.5f, 2f }) + { + var drawn = Sample(value, world, new Vector2(k * populated, 0f)); + Debug.Log($"{Tag} r = {k} P: drawn {drawn:G5}, expected {centre * Mathf.Exp(-k * k * logFall):G5}"); + } + var worstProfile = 0f; + var compared = 0; + var rim = 0f; + for (var i = 0; i < value.Length; i++) + { + if (float.IsNaN(value[i])) { Fail("texels", $"texel {i}: NaN"); return; } + var r = world[i].magnitude; + if (r <= 2f * populated) + { + var expected = centre * Mathf.Exp(-(r / populated) * (r / populated) * logFall); + var error = Mathf.Abs(value[i] - expected) / expected; + worstProfile = Mathf.Max(worstProfile, error); + compared++; + if (error > .01f) { Fail("profile", $"at r = {r / populated:F3} P: expected {expected:G5}, drawn {value[i]:G5}, off by {error * 100f:F2}%"); return; } + } + // The rim: the texels in the quad's outermost ring (within two texels of its edge). + var edgeDistance = half - Mathf.Max(Mathf.Abs(world[i].x), Mathf.Abs(world[i].y)); + if (edgeDistance >= 0f && edgeDistance < 2f * (half * 1.05f * 2f / Size)) rim = Mathf.Max(rim, value[i]); + } + Debug.Log($"{Tag} profile: {compared} texels within 2 P, worst error {worstProfile * 100f:F3}%; rim {rim / centre:E2} of the centre"); + if (compared < Size * Size / 8) Fail("profile", $"only {compared} texels were within 2 P; the check is vacuous"); + if (rim > 2e-5f * centre) Fail("rim", $"the quad's rim is {rim / centre:E2} of the centre, above 2e-5"); + + var atPack = Sample(value, world, new Vector2(zone.Pack.Radius, 0f)); + Debug.Log($"{Tag} at Pack.Radius: {atPack / centre:E2} of the centre"); + if (!(atPack > 0f)) Fail("pack-radius", "the ambient is zero at Pack.Radius"); + + var worstJump = 0f; + for (var y = 0; y < Size; y++) + for (var x = 0; x < Size; x++) + { + var here = value[y * Size + x]; + if (here <= 0f) continue; + foreach (var next in new[] { x + 1 < Size ? value[y * Size + x + 1] : 0f, y + 1 < Size ? value[(y + 1) * Size + x] : 0f }) + { + if (next <= 0f) continue; + worstJump = Mathf.Max(worstJump, Mathf.Abs(Mathf.Log10(here / next))); + } + } + Debug.Log($"{Tag} largest jump between neighbouring lit texels: {worstJump:F3} decades (limit 0.5)"); + if (worstJump > .5f) Fail("jump", $"neighbouring texels differ by {worstJump:F2} decades"); + var lit = 0; + foreach (var v in value) if (v > 0f) lit++; + if (lit < Size * Size / 2) Fail("jump", $"only {lit} texels are lit; the check is vacuous"); + } + + static float Sample(float[] value, Vector2[] world, Vector2 at) + { + var best = 0; + for (var i = 1; i < world.Length; i++) + if ((world[i] - at).sqrMagnitude < (world[best] - at).sqrMagnitude) best = i; + return value[best]; + } + + // Renders the ambient layer through a downward orthographic camera centred on the origin, as the Tint cameras see it, + // and returns the value of every texel with the world xz of its centre, which is asked of the camera. + static void Render(Transform light, float half, out float[] value, out Vector2[] world) + { + var rt = new RenderTexture(Size, Size, 0, RenderTextureFormat.RFloat) { filterMode = FilterMode.Point }; + var cameraObject = new GameObject("Ambient Edge Camera"); + var read = new Texture2D(Size, Size, TextureFormat.RFloat, false); + var previous = RenderTexture.active; + try + { + var camera = cameraObject.AddComponent(); + camera.orthographic = true; + camera.orthographicSize = half; + camera.aspect = 1f; + camera.nearClipPlane = .3f; + camera.farClipPlane = 1000f; + camera.cullingMask = 1 << light.gameObject.layer; + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = Color.black; + camera.allowHDR = false; + camera.allowMSAA = false; + camera.targetTexture = rt; + cameraObject.transform.SetPositionAndRotation(new Vector3(0f, 100f, 0f), Quaternion.Euler(90f, 0f, 0f)); + camera.Render(); + + RenderTexture.active = rt; + read.ReadPixels(new Rect(0, 0, Size, Size), 0, 0); + read.Apply(); + value = read.GetPixelData(0).ToArray(); + world = new Vector2[Size * Size]; + for (var y = 0; y < Size; y++) + for (var x = 0; x < Size; x++) + { + var p = camera.ViewportToWorldPoint(new Vector3((x + .5f) / Size, (y + .5f) / Size, 100f)); + world[y * Size + x] = new Vector2(p.x, p.z); + } + } + finally + { + RenderTexture.active = previous; + UnityEngine.Object.DestroyImmediate(cameraObject); + UnityEngine.Object.DestroyImmediate(read); + rt.Release(); + UnityEngine.Object.DestroyImmediate(rt); + } + } + } +} diff --git a/Assets/Scripts/Editor/AmbientEdgeVerify.cs.meta b/Assets/Scripts/Editor/AmbientEdgeVerify.cs.meta new file mode 100644 index 000000000..0962854de --- /dev/null +++ b/Assets/Scripts/Editor/AmbientEdgeVerify.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 63b03af05dfdae44c89e21ca7ebec892 \ No newline at end of file diff --git a/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs b/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs index 3815ab27d..7e2701897 100644 --- a/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs +++ b/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs @@ -126,6 +126,7 @@ namespace Aetheria.EditorTools renderer.MinePrefab = minePrefab; renderer.ItemManager = items; renderer.SectorBrushes = new GameObject("Wiring Brushes").transform; + renderer.AmbientLight = AmbientEdgeVerify.NewAmbientLight(); renderer.SlimeGravityCamera = new GameObject("Wiring Gravity Camera").AddComponent(); var slimeObject = new GameObject("Wiring Slime"); slimeObject.SetActive(false); diff --git a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs index 54e2b39fb..1d8a986af 100644 --- a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs +++ b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs @@ -147,6 +147,7 @@ public static class FloatingItemsDrawnSmoke typeof(ZoneRenderer).GetField(name).SetValue(renderer, pickup); renderer.ItemManager = items; renderer.SectorBrushes = new GameObject("Smoke Brushes").transform; + renderer.AmbientLight = Aetheria.EditorTools.AmbientEdgeVerify.NewAmbientLight(); renderer.SlimeGravityCamera = new GameObject("Smoke Gravity Camera").AddComponent(); var slimeObject = new GameObject("Smoke Slime"); slimeObject.SetActive(false); // the real Slime needs its compute shader; LoadZone only sets its radius diff --git a/Assets/Scripts/Editor/GravityFieldParityVerify.cs b/Assets/Scripts/Editor/GravityFieldParityVerify.cs index bc413bfa0..1753b95ab 100644 --- a/Assets/Scripts/Editor/GravityFieldParityVerify.cs +++ b/Assets/Scripts/Editor/GravityFieldParityVerify.cs @@ -124,7 +124,7 @@ namespace Aetheria.EditorTools // mass 0, whose well has no strength (a legal body; its curves give c = 0 and R = 0), at (1700, 300): parentless, // fixed, off both axes and asymmetric, so the zone's wells sit where the verify says before any Update and a // mirrored or swapped axis cannot agree with the sim. - static Zone BuildZone(GameSettings settings, string work, out Orbit[] orbits) + internal static Zone BuildZone(GameSettings settings, string work, out Orbit[] orbits, float radius = 4182f) { var cache = AetheriaStores.Open(Path.Combine(work, "catalog.cc"), Path.Combine(work, "run.cc"), catalogWritable: true); cache.Upsert(new VerseGrammar { Revision = 1 }); @@ -133,7 +133,7 @@ namespace Aetheria.EditorTools var positions = new[] { new Vector2(-130f, 85f), new Vector2(610f, -240f), new Vector2(670f, -270f), new Vector2(1700f, 300f) }; var masses = new[] { 14152f, 273.9f, 29.2f, 0f }; var keys = new CultRecordKey[positions.Length]; - var pack = new ZonePack { Radius = 4182f }; + var pack = new ZonePack { Radius = radius }; for (var i = 0; i < positions.Length; i++) { var orbit = keys[i] = cache.Upsert(new OrbitData { FixedPosition = new float2(positions[i].x, positions[i].y) }).Key; diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index e92c78c27..31f9aab3f 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -27,6 +27,8 @@ public class ZoneRenderer : MonoBehaviour public GameSettings Settings; public Transform ZoneRoot; public Transform SectorBrushes; + // The ambient tint quad, sized from the zone's populated radius, never under SectorBrushes: FitAmbientLight. + public Transform AmbientLight; public CinemachineVirtualCamera SceneCamera; public Camera[] FogCameras; public Camera[] MinimapCameras; @@ -191,11 +193,28 @@ public class ZoneRenderer : MonoBehaviour _transposer = SceneCamera.GetCinemachineComponent(); } + // The ambient is a gaussian: its brightness at the populated edge is the material's _EdgeBrightness (a shape, authored), and + // the quad reaches out to where it falls to AmbientFloor of the centre, so no edge is ever drawn. The one writer of the + // quad's size and _Reach, and the one place the verify reads the sizing from. + const float AmbientFloor = 1e-5f; + + public static void FitAmbientLight(Transform light, Zone zone) + { + var renderer = light.GetComponent(); + var reach = Mathf.Sqrt(Mathf.Log(1f / AmbientFloor) / Mathf.Log(1f / renderer.sharedMaterial.GetFloat("_EdgeBrightness"))); + light.localScale = 2f * reach * zone.PopulatedRadius * Vector3.one; + var block = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(block); + block.SetFloat("_Reach", reach); + renderer.SetPropertyBlock(block); + } + public void LoadZone(Zone zone) { ClearZone(); Zone = zone; SectorBrushes.localScale = zone.Pack.Radius * 2 * Vector3.one; + FitAmbientLight(AmbientLight, zone); SlimeGravityCamera.orthographicSize = zone.Pack.Radius; SlimeRenderer.ZoneRadius = zone.Pack.Radius; if (_gravityField == null) _gravityField = GravityLowering.CreateField(GravityField); diff --git a/Assets/Shaders/Brushes/AmbientBrush.shader b/Assets/Shaders/Brushes/AmbientBrush.shader new file mode 100644 index 000000000..6c6202d0b --- /dev/null +++ b/Assets/Shaders/Brushes/AmbientBrush.shader @@ -0,0 +1,61 @@ +Shader "Brushes/Ambient Brush" { +Properties { + _Color ("Color", Color) = (1,1,1,1) + _Depth ("Depth", Float) = 0.5 + // The brightness at the populated edge relative to the centre: the shape of the fall, never an extent. + _EdgeBrightness ("Edge Brightness", Range(0.0001, 0.9999)) = .12 + // The quad's radius in units of the populated radius. Written by ZoneRenderer.FitAmbientLight, never authored. + _Reach ("Reach", Float) = 1 +} + +// A gaussian: log-brightness is quadratic in distance from the centre, so the fall is smooth everywhere and +// never reaches an edge or a cliff. x is the distance in units of the zone's populated radius. +Category { + Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "PreviewType"="Plane" } + Blend One One + ColorMask RGB + Cull Off Lighting Off ZWrite Off + + SubShader { + Pass { + + CGPROGRAM + #pragma vertex vert + #pragma fragment frag + #pragma target 2.0 + + #include "UnityCG.cginc" + + float4 _Color; + float _Depth; + float _EdgeBrightness; + float _Reach; + + struct appdata_t { + float4 vertex : POSITION; + float2 texcoord : TEXCOORD0; + }; + + struct v2f { + float4 vertex : SV_POSITION; + float2 texcoord : TEXCOORD0; + }; + + v2f vert (appdata_t v) + { + v2f o; + o.vertex = UnityObjectToClipPos(v.vertex); + o.texcoord = v.texcoord; + return o; + } + + float4 frag (v2f i) : SV_Target + { + float x = length(i.texcoord - float2(.5, .5)) * 2 * _Reach; + return _Depth * _Color * exp(-x * x * log(1 / _EdgeBrightness)); + } + ENDCG + } + } +} +} diff --git a/Assets/Shaders/Brushes/AmbientBrush.shader.meta b/Assets/Shaders/Brushes/AmbientBrush.shader.meta new file mode 100644 index 000000000..8f80f596b --- /dev/null +++ b/Assets/Shaders/Brushes/AmbientBrush.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 782740c1a3d7455ea75dc87fafdc8432 +ShaderImporter: + externalObjects: {} + defaultTextures: [] + nonModifiableTextures: [] + userData: + assetBundleName: + assetBundleVariant: From 13691ac1fa1dc37958cab1d1f492aba113e3d8ad Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 06:11:49 +0200 Subject: [PATCH 3/5] Zone.PopulatedRadius covers belts; the tests pin the largest body and the belt A belt's asteroids circle its orbit's parent, so a belt reaches the chain's reach to that orbit plus its Radius (AsteroidBelt.Radius, the largest asteroid Distance); the chain walk is one function for bodies and belts. The fixture inserts a smaller body after the outermost so a last-wins bound fails, and a belt beyond the outermost body pins the belt term. Builds; Unity-side files are in the next commit. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Zone.cs | 32 +++++++++++-------- .../PopulatedRadiusTests.cs | 31 ++++++++++++++++-- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 0be6b82b2..87dd238ad 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -739,28 +739,34 @@ public class Zone public float WellReference(Planet body) => GravityField.Potential(body.WellStrength, body.RocheLimit, Pack.Radius); // The extent of the populated area, from the zone's origin: owned here; presenters read it, nothing authors it. A static - // bound on where any body can be, so it holds at every time and reads nothing the pack's Radius decides: for each body, - // the root orbit's offset from the origin, plus every orbit Distance along its chain to the root, plus its Roche limit. + // bound on where anything can be, so it holds at every time and reads nothing the pack's Radius decides: for each body, + // its orbit chain's reach plus its Roche limit; for each belt, the chain's reach to the orbit its asteroids circle (the + // belt orbit's parent) plus the belt's own Radius, its largest asteroid Distance. public float PopulatedRadius { get; } private float ComputePopulatedRadius() { var radius = 0f; foreach (var body in PlanetInstances.Values) - { - var reach = body.RocheLimit; - var key = body.BodyData.Orbit.Key; - while (key.IsSet() && Orbits.TryGetValue(key, out var orbit)) - { - if (orbit.Data.Parent.IsSet()) reach += orbit.Data.Distance; - else reach += length(orbit.Data.FixedPosition); - key = orbit.Data.Parent.Key; - } - radius = max(radius, reach); - } + radius = max(radius, ChainReach(body.BodyData.Orbit.Key) + body.RocheLimit); + foreach (var belt in AsteroidBelts) + radius = max(radius, ChainReach(Orbits[Planets[belt.Key].Orbit.Key].Data.Parent.Key) + belt.Value.Radius); return radius; } + // The most an orbit can lie from the origin: the root's offset plus every orbit Distance down the chain to it. + private float ChainReach(CultRecordKey key) + { + var reach = 0f; + while (key.IsSet() && Orbits.TryGetValue(key, out var orbit)) + { + if (orbit.Data.Parent.IsSet()) reach += orbit.Data.Distance; + else reach += length(orbit.Data.FixedPosition); + key = orbit.Data.Parent.Key; + } + return reach; + } + public float GetHeight(float2 position) => GetHeight(position, _time); // The field at a time: each well sits where its body's rail puts it then. The sim reads it at _time, a presenter at diff --git a/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs b/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs index dc1f5e865..af645a68c 100644 --- a/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs +++ b/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs @@ -10,13 +10,16 @@ using float3 = CultMath.float3; // Zone.PopulatedRadius is the extent of the populated area, owned by the zone and read by the ambient tint's presenter // (ruling aetheria-gravity:ruling:dark-beyond-populated-area). The zone here is the production constructor reading a pack -// of a root off the origin, a planet, a moon and a moon's moon, each with a well of its own mass. +// of a root off the origin, a planet, a moon and a moon's moon, each with a well of its own mass, and a last body +// inserted after the outermost whose reach is the smallest, so the largest decides whatever the order. public sealed class PopulatedRadiusTests : IDisposable { private static readonly float2 RootAt = new float2(300f, -200f); private const float PlanetDistance = 400f; private const float MoonDistance = 100f; private const float MoonsMoonDistance = 40f; + private const float BeltOrbitDistance = 77f; + private const float FarthestAsteroid = 1000f; private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-populated-" + Guid.NewGuid().ToString("N")); private readonly List _caches = new List(); @@ -46,7 +49,9 @@ public sealed class PopulatedRadiusTests : IDisposable public CultRecordKey[] Orbits; } - private Scene Build(float radius, bool moonsMoon = true) + // With belt, a belt circles the planet's orbit (asteroids at FarthestAsteroid and 700, the farther first) and its own orbit + // is BeltOrbitDistance out: the asteroids' positions do not depend on that distance, only on the planet's orbit. + private Scene Build(float radius, bool moonsMoon = true, bool belt = false) { var dir = Path.Combine(_root, $"zone{_caches.Count}"); Directory.CreateDirectory(dir); @@ -67,12 +72,24 @@ public sealed class PopulatedRadiusTests : IDisposable var pack = new ZonePack { Radius = radius, Mass = 10000f, Time = 0 }; foreach (var orbit in new[] { rootOrbit, planetOrbit, moonOrbit, moonsMoonOrbit }) pack.Orbits.Add(new CultRecordRef(orbit)); + if (belt) + { + var beltOrbit = cache.Upsert(new OrbitData { Parent = new CultRecordRef(planetOrbit), Distance = BeltOrbitDistance, Phase = .8f }).Key; + pack.Orbits.Add(new CultRecordRef(beltOrbit)); + var asteroids = new[] + { + new Asteroid { Distance = FarthestAsteroid, Phase = .2f, Size = .5f }, + new Asteroid { Distance = 700f, Phase = .6f, Size = .5f } + }; + pack.Planets.Add(new CultRecordRef(cache.Upsert(new AsteroidBeltData { Orbit = new CultRecordRef(beltOrbit), Asteroids = asteroids }).Key)); + } void Body(CultRecordKey orbit, float mass) => pack.Planets.Add(new CultRecordRef(cache.Upsert(new PlanetData { Orbit = new CultRecordRef(orbit), Mass = mass }).Key)); Body(rootOrbit, 5f); Body(planetOrbit, 1f); Body(moonOrbit, .5f); if (moonsMoon) Body(moonsMoonOrbit, .01f); + Body(rootOrbit, .001f); var zone = new Zone(items, Settings(), pack, new GalaxyZone { Name = "Populated", Owner = null }, null); return new Scene { Zone = zone, Orbits = new[] { rootOrbit, planetOrbit, moonOrbit, moonsMoonOrbit } }; } @@ -100,6 +117,16 @@ public sealed class PopulatedRadiusTests : IDisposable Assert.True(worst > 0f); } + [Fact] + public void ItReachesTheFarthestAsteroidOfABeltBeyondTheOutermostBody() + { + // The belt's asteroids circle the planet's orbit, so the chain is the root's offset and the planet's distance; the belt + // orbit's own distance does not enter, and neither do the nearer asteroid or any body (all inside this). + var s = Build(5000f, belt: true); + + Assert.Equal(length(RootAt) + PlanetDistance + FarthestAsteroid, s.Zone.PopulatedRadius, 2); + } + [Fact] public void ItBoundsTheRootOffTheOriginAndEachLevelOfTheChain() { From 74e51586c2a40039d3e1876c427cd3b28a87a1bf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 06:24:36 +0200 Subject: [PATCH 4/5] The ambient quad reaches where Fog Tint's format still holds the gaussian; AmbientEdgeVerify renders into that format Fog Tint is B10G11R11_UFloatPack32. A quad sized to 1e-5 of the centre drew a plateau near 3.2e-5 from 1.75 to 2.1 P and then zero: a step in the tail the RFloat verify could not see. AmbientFloor is now 2^-15 absolute and the quad ends where the brightest channel reaches it (1.78 P), so the one step to dark is the format's last value, at most 1.5 floors. The verify builds its target from the production render texture's descriptor, asserts the format, holds the profile to the gaussian within the format's 2^-5 step where it is at least 8 floors, and bounds the rim and the step to dark by 2^-14. Builds; AmbientEdgeVerify passes on Starfire. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Editor/AmbientEdgeVerify.cs | 57 +++++++++++++++------ Assets/Scripts/Zone Display/ZoneRenderer.cs | 15 ++++-- 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/Assets/Scripts/Editor/AmbientEdgeVerify.cs b/Assets/Scripts/Editor/AmbientEdgeVerify.cs index fd0482bd9..4a56b6ea9 100644 --- a/Assets/Scripts/Editor/AmbientEdgeVerify.cs +++ b/Assets/Scripts/Editor/AmbientEdgeVerify.cs @@ -4,6 +4,7 @@ using GameCult.Caching; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; +using UnityEngine.Experimental.Rendering; using UnityEngine.Rendering; namespace Aetheria.EditorTools @@ -12,10 +13,12 @@ namespace Aetheria.EditorTools /// Batchmode check for campaign aetheria-gravity, cut ambient-follows-populated-area: the ambient tint falls softly to /// dark and never hits an edge (ruling dark-beyond-populated-area). It opens ARPG.unity, takes the Ambient Fog Light the /// scene wires into ZoneRenderer, sizes it for a generated zone with ZoneRenderer.FitAmbientLight (the call LoadZone - /// makes, not a copy), renders it through an orthographic camera that culls its layer into a float target, and checks the - /// radial profile against exp(-(r/P)^2 ln(1/_EdgeBrightness)) times the centre out to 2 P (P is Zone.PopulatedRadius), - /// that it is lit at Pack.Radius, that the quad's rim is at most 2e-5 of the centre, and that no texel is more than half - /// a decade from its neighbour. + /// makes, not a copy), renders it through an orthographic camera that culls its layer into a target built from the descriptor of the production + /// Fog Tint render texture (B10G11R11_UFloatPack32, the format the Tint cameras write), and checks the + /// radial profile against exp(-(r/P)^2 ln(1/_EdgeBrightness)) times the centre, to within what that format stores (a + /// 5-bit mantissa) wherever the gaussian is at least a few ZoneRenderer.AmbientFloor (P is Zone.PopulatedRadius), that it + /// is lit at Pack.Radius, that the quad's rim and every lit texel beside a dark one are + /// at most two floors, and that no two lit texels are more than half a decade apart. /// /// Renders, so it needs a graphics device: run WITHOUT -nographics. /// Unity -batchmode -projectPath <repo> -executeMethod Aetheria.EditorTools.AmbientEdgeVerify.Run -logFile <path> @@ -26,6 +29,14 @@ namespace Aetheria.EditorTools const string Tag = "[AmbientEdgeVerify]"; const int Size = 512; const int AmbientLayer = 13; + const string FogTintPath = "Assets/Resources/Fog Tint.renderTexture"; + // A float with a 5-bit mantissa (Fog Tint's blue) steps by 2^-5 of its value, and the target truncates to a step. + const float FormatRelativeError = 1f / 32f; + // The profile is held to the gaussian where it is at least this many floors; below that the format's tail is measured, not held. + const float FaithfulFloors = 8f; + // The rim and every lit texel beside a dark one hold at most this many floors, 2^-14 (the smallest normal of the format's + // floats): the one step to dark is the format's last value. Measured on the Fog Tint format it is 1.5 floors at most. + const float StepToDarkFloors = 2f; static bool _ok = true; static void OnLog(string message, string stack, LogType type) @@ -96,6 +107,9 @@ namespace Aetheria.EditorTools static void Check(ZoneRenderer renderer, Zone zone) { + var production = AssetDatabase.LoadAssetAtPath(FogTintPath); + if (production == null || production.graphicsFormat != GraphicsFormat.B10G11R11_UFloatPack32) + Fail("format", $"Fog Tint is {(production == null ? "missing" : production.graphicsFormat.ToString())}, not the B10G11R11_UFloatPack32 that AmbientFloor and FormatRelativeError describe"); var light = renderer.AmbientLight; if (light.parent != null) Fail("wiring", $"the ambient quad sits under {light.parent.name}, whose scale is the pack radius's"); if (light.gameObject.layer != AmbientLayer) Fail("wiring", $"the ambient quad is on layer {light.gameObject.layer}, not {AmbientLayer}"); @@ -104,13 +118,18 @@ namespace Aetheria.EditorTools var meshRenderer = light.GetComponent(); var material = meshRenderer.sharedMaterial; var edge = material.GetFloat("_EdgeBrightness"); - var centre = material.GetFloat("_Depth") * material.GetColor("_Color").linear.r; + var tint = material.GetColor("_Color").linear; + var centre = material.GetFloat("_Depth") * Mathf.Max(tint.r, Mathf.Max(tint.g, tint.b)); var populated = zone.PopulatedRadius; var half = light.localScale.x / 2f; Debug.Log($"{Tag} populated radius {populated:F1} u, pack radius {zone.Pack.Radius:F1} u, quad half-extent {half:F1} u, edge brightness {edge:G3}, centre {centre:G4}"); if (!(populated > 0f) || !(half > populated)) { Fail("sizing", $"the quad half-extent {half:F1} u does not reach beyond the populated radius {populated:F1} u"); return; } Render(light, half * 1.05f, out var value, out var world); + var floor = ZoneRenderer.AmbientFloor; + Debug.Log($"{Tag} format floor {floor:E2} = {floor / centre:E2} of the centre; tail (drawn / floor): " + + string.Join(" ", System.Linq.Enumerable.Select(new[] { 1.5f, 1.75f, 1.9f, 2f, 2.05f, 2.1f, 2.15f, 2.2f }, + k => $"{k}P:{Sample(value, world, new Vector2(k * populated, 0f)) / floor:G3}"))); var logFall = Mathf.Log(1f / edge); foreach (var k in new[] { 0f, .5f, 1f, 1.5f, 2f }) { @@ -124,21 +143,21 @@ namespace Aetheria.EditorTools { if (float.IsNaN(value[i])) { Fail("texels", $"texel {i}: NaN"); return; } var r = world[i].magnitude; - if (r <= 2f * populated) + var expected = centre * Mathf.Exp(-(r / populated) * (r / populated) * logFall); + if (r <= half && expected >= FaithfulFloors * floor) { - var expected = centre * Mathf.Exp(-(r / populated) * (r / populated) * logFall); - var error = Mathf.Abs(value[i] - expected) / expected; + var error = Mathf.Abs(value[i] - expected) / (FormatRelativeError * expected); worstProfile = Mathf.Max(worstProfile, error); compared++; - if (error > .01f) { Fail("profile", $"at r = {r / populated:F3} P: expected {expected:G5}, drawn {value[i]:G5}, off by {error * 100f:F2}%"); return; } + if (error > 1f) { Fail("profile", $"at r = {r / populated:F3} P: expected {expected:G5}, drawn {value[i]:G5}, off by {error:F2} times the format's step"); return; } } // The rim: the texels in the quad's outermost ring (within two texels of its edge). var edgeDistance = half - Mathf.Max(Mathf.Abs(world[i].x), Mathf.Abs(world[i].y)); if (edgeDistance >= 0f && edgeDistance < 2f * (half * 1.05f * 2f / Size)) rim = Mathf.Max(rim, value[i]); } - Debug.Log($"{Tag} profile: {compared} texels within 2 P, worst error {worstProfile * 100f:F3}%; rim {rim / centre:E2} of the centre"); - if (compared < Size * Size / 8) Fail("profile", $"only {compared} texels were within 2 P; the check is vacuous"); - if (rim > 2e-5f * centre) Fail("rim", $"the quad's rim is {rim / centre:E2} of the centre, above 2e-5"); + Debug.Log($"{Tag} profile: {compared} texels held to the gaussian, worst error {worstProfile:F3} of the format's step; rim {rim / centre:E2} of the centre"); + if (compared < Size * Size / 16) Fail("profile", $"only {compared} texels were held to the gaussian; the check is vacuous"); + if (rim > floor * StepToDarkFloors) Fail("rim", $"the quad's rim is {rim / floor:G3} times the format floor"); var atPack = Sample(value, world, new Vector2(zone.Pack.Radius, 0f)); Debug.Log($"{Tag} at Pack.Radius: {atPack / centre:E2} of the centre"); @@ -152,7 +171,7 @@ namespace Aetheria.EditorTools if (here <= 0f) continue; foreach (var next in new[] { x + 1 < Size ? value[y * Size + x + 1] : 0f, y + 1 < Size ? value[(y + 1) * Size + x] : 0f }) { - if (next <= 0f) continue; + if (next <= 0f) { if (here > floor * StepToDarkFloors) Fail("last-step", $"a lit texel of {here / floor:G3} floors sits beside a dark one"); continue; } worstJump = Mathf.Max(worstJump, Mathf.Abs(Mathf.Log10(here / next))); } } @@ -172,12 +191,13 @@ namespace Aetheria.EditorTools } // Renders the ambient layer through a downward orthographic camera centred on the origin, as the Tint cameras see it, - // and returns the value of every texel with the world xz of its centre, which is asked of the camera. + // and returns the brightest channel of every texel with the world xz of its centre, which is asked of the camera. static void Render(Transform light, float half, out float[] value, out Vector2[] world) { - var rt = new RenderTexture(Size, Size, 0, RenderTextureFormat.RFloat) { filterMode = FilterMode.Point }; + var production = AssetDatabase.LoadAssetAtPath(FogTintPath) ?? throw new InvalidOperationException("No Fog Tint render texture."); + var rt = new RenderTexture(production.descriptor) { filterMode = FilterMode.Point }; var cameraObject = new GameObject("Ambient Edge Camera"); - var read = new Texture2D(Size, Size, TextureFormat.RFloat, false); + var read = new Texture2D(Size, Size, TextureFormat.RGBAFloat, false); var previous = RenderTexture.active; try { @@ -199,7 +219,10 @@ namespace Aetheria.EditorTools RenderTexture.active = rt; read.ReadPixels(new Rect(0, 0, Size, Size), 0, 0); read.Apply(); - value = read.GetPixelData(0).ToArray(); + var pixels = read.GetPixelData(0); + value = new float[Size * Size]; + for (var i = 0; i < value.Length; i++) + value[i] = Mathf.Max(pixels[4 * i], Mathf.Max(pixels[4 * i + 1], pixels[4 * i + 2])); world = new Vector2[Size * Size]; for (var y = 0; y < Size; y++) for (var x = 0; x < Size; x++) diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index 31f9aab3f..fb06b871a 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -194,14 +194,21 @@ public class ZoneRenderer : MonoBehaviour } // The ambient is a gaussian: its brightness at the populated edge is the material's _EdgeBrightness (a shape, authored), and - // the quad reaches out to where it falls to AmbientFloor of the centre, so no edge is ever drawn. The one writer of the - // quad's size and _Reach, and the one place the verify reads the sizing from. - const float AmbientFloor = 1e-5f; + // the quad reaches out to where its brightest channel falls to AmbientFloor. Fog Tint is B10G11R11_UFloatPack32 and cannot + // follow a gaussian into its tail: measured, the drawn value stops following it near 4 floors and holds a plateau of + // about 3.2e-5 before dropping to zero (AmbientEdgeVerify asserts the format and measures it). A quad sized to 1e-5 of the + // centre therefore ended in a plateau and a step to zero; sized to the floor it ends at the last value the format holds, + // so the one step to dark is that value. The one writer of the quad's size and _Reach, and the one place the verify + // reads the sizing from. + public const float AmbientFloor = 1f / (1 << 15); public static void FitAmbientLight(Transform light, Zone zone) { var renderer = light.GetComponent(); - var reach = Mathf.Sqrt(Mathf.Log(1f / AmbientFloor) / Mathf.Log(1f / renderer.sharedMaterial.GetFloat("_EdgeBrightness"))); + var material = renderer.sharedMaterial; + var tint = material.GetColor("_Color").linear; + var centre = material.GetFloat("_Depth") * Mathf.Max(tint.r, Mathf.Max(tint.g, tint.b)); + var reach = Mathf.Sqrt(Mathf.Log(centre / AmbientFloor) / Mathf.Log(1f / material.GetFloat("_EdgeBrightness"))); light.localScale = 2f * reach * zone.PopulatedRadius * Vector3.one; var block = new MaterialPropertyBlock(); renderer.GetPropertyBlock(block); From e2d78b984cec92740245c3435c1cc82289299f8e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 06:25:40 +0200 Subject: [PATCH 5/5] PopulatedRadiusTests: the belt fixture carries the asteroid curves a zone with a belt reads The belt fixture's PlanetSettings lacked AsteroidSize, AsteroidHitpoints and AsteroidRespawnTime, so building the zone threw before the bound was read. Builds; the previous commit's belt test fails without this. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs b/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs index af645a68c..20d6793a0 100644 --- a/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs +++ b/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs @@ -40,7 +40,10 @@ public sealed class PopulatedRadiusTests : IDisposable BodyRadius = Flat(20f), WellStrength = new ExponentialCurve { Multiplier = 100f, Exponent = 1f }, RocheLimit = new ExponentialCurve { Multiplier = 10f, Exponent = 1f }, - LightRadius = Flat(0f) + LightRadius = Flat(0f), + AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f }, + AsteroidHitpoints = new ExponentialLerp { Minimum = 10f, Maximum = 300f, Exponent = 2f }, + AsteroidRespawnTime = new ExponentialLerp { Minimum = 5f, Maximum = 60f, Exponent = 1f } }; private sealed class Scene