Merge remote-tracking branch 'origin/eureka/aetheria-gravity-nebula-tint' into eureka/aetheria-gravity-merge-ag3
This commit is contained in:
commit
6865af813f
12 changed files with 537 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
244
Assets/Scripts/Editor/AmbientEdgeVerify.cs
Normal file
244
Assets/Scripts/Editor/AmbientEdgeVerify.cs
Normal file
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<GameSettings>("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<ZoneRenderer>(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<MeshFilter>().sharedMesh = Resources.GetBuiltinResource<Mesh>("Quad.fbx");
|
||||
quad.AddComponent<MeshRenderer>().sharedMaterial = AssetDatabase.LoadAssetAtPath<Material>("Assets/Materials/Brushes/Sector Tint.mat");
|
||||
return quad.transform;
|
||||
}
|
||||
|
||||
static void Check(ZoneRenderer renderer, Zone zone)
|
||||
{
|
||||
var production = AssetDatabase.LoadAssetAtPath<RenderTexture>(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<MeshRenderer>();
|
||||
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<RenderTexture>(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>();
|
||||
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<float>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Editor/AmbientEdgeVerify.cs.meta
Normal file
2
Assets/Scripts/Editor/AmbientEdgeVerify.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 63b03af05dfdae44c89e21ca7ebec892
|
||||
|
|
@ -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<Camera>();
|
||||
var slimeObject = new GameObject("Wiring Slime");
|
||||
slimeObject.SetActive(false);
|
||||
|
|
|
|||
|
|
@ -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<Camera>();
|
||||
var slimeObject = new GameObject("Smoke Slime");
|
||||
slimeObject.SetActive(false); // the real Slime needs its compute shader; LoadZone only sets its radius
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<CinemachineTransposer>();
|
||||
}
|
||||
|
||||
// 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<MeshRenderer>();
|
||||
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);
|
||||
|
|
|
|||
61
Assets/Shaders/Brushes/AmbientBrush.shader
Normal file
61
Assets/Shaders/Brushes/AmbientBrush.shader
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Assets/Shaders/Brushes/AmbientBrush.shader.meta
Normal file
9
Assets/Shaders/Brushes/AmbientBrush.shader.meta
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 782740c1a3d7455ea75dc87fafdc8432
|
||||
ShaderImporter:
|
||||
externalObjects: {}
|
||||
defaultTextures: []
|
||||
nonModifiableTextures: []
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
152
tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs
Normal file
152
tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs
Normal file
|
|
@ -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<CultCache> _caches = new List<CultCache>();
|
||||
|
||||
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<OrbitData>(rootOrbit), Distance = PlanetDistance, Phase = .1f }).Key;
|
||||
var moonOrbit = cache.Upsert(new OrbitData { Parent = new CultRecordRef<OrbitData>(planetOrbit), Distance = MoonDistance, Phase = .3f }).Key;
|
||||
var moonsMoonOrbit = cache.Upsert(new OrbitData { Parent = new CultRecordRef<OrbitData>(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<OrbitData>(orbit));
|
||||
if (belt)
|
||||
{
|
||||
var beltOrbit = cache.Upsert(new OrbitData { Parent = new CultRecordRef<OrbitData>(planetOrbit), Distance = BeltOrbitDistance, Phase = .8f }).Key;
|
||||
pack.Orbits.Add(new CultRecordRef<OrbitData>(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<BodyData>(cache.Upsert(new AsteroidBeltData { Orbit = new CultRecordRef<OrbitData>(beltOrbit), Asteroids = asteroids }).Key));
|
||||
}
|
||||
void Body(CultRecordKey orbit, float mass) =>
|
||||
pack.Planets.Add(new CultRecordRef<BodyData>(cache.Upsert(new PlanetData { Orbit = new CultRecordRef<OrbitData>(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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue