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 738110fbd..a7940de8e 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} @@ -16861,7 +16862,6 @@ Transform: m_LocalScale: {x: 3000, y: 3000, z: 3000} m_ConstrainProportionsScale: 0 m_Children: - - {fileID: 23420357} - {fileID: 1108843909} - {fileID: 1647230682} - {fileID: 1254851255} @@ -38392,6 +38392,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..4a56b6ea9 --- /dev/null +++ b/Assets/Scripts/Editor/AmbientEdgeVerify.cs @@ -0,0 +1,244 @@ +using System; +using System.IO; +using GameCult.Caching; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.Experimental.Rendering; +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 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> + /// 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; + 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) + { + 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 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}"); + + ZoneRenderer.FitAmbientLight(light, zone); + var meshRenderer = light.GetComponent(); + var material = meshRenderer.sharedMaterial; + var edge = material.GetFloat("_EdgeBrightness"); + 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 }) + { + 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; + var expected = centre * Mathf.Exp(-(r / populated) * (r / populated) * logFall); + if (r <= half && expected >= FaithfulFloors * floor) + { + var error = Mathf.Abs(value[i] - expected) / (FormatRelativeError * expected); + worstProfile = Mathf.Max(worstProfile, error); + compared++; + 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 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"); + 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) { 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))); + } + } + 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 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 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.RGBAFloat, 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(); + 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++) + { + 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 96aba2819..8a170dbde 100644 --- a/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs +++ b/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs @@ -127,6 +127,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 eb4a75558..1caf77b6c 100644 --- a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs +++ b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs @@ -149,6 +149,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/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index b7b099253..87dd238ad 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,35 @@ 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 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) + 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/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index e65823d9d..8126eb096 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; @@ -187,11 +189,35 @@ 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 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 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); + 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: diff --git a/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs b/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs new file mode 100644 index 000000000..20d6793a0 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs @@ -0,0 +1,152 @@ +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, 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(); + + 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), + 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 + { + public Zone Zone; + public CultRecordKey[] Orbits; + } + + // 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); + 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)); + 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 } }; + } + + [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 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() + { + // 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); + } +}