diff --git a/Assets/Scripts/ServerShared/ColorMath.cs b/Assets/Scripts/ServerShared/ColorMath.cs index b94764bd3..3814272cd 100644 --- a/Assets/Scripts/ServerShared/ColorMath.cs +++ b/Assets/Scripts/ServerShared/ColorMath.cs @@ -18,6 +18,29 @@ public static class ColorMath return saturate(float3(r,g,b)); } + // A hue band on the circle runs from start up to end, wrapping past 1 when end < start. + public static bool HueInBand(float hue, float start, float end) => frac(hue - start) < frac(end - start); + + // Maps u in [0,1) onto the hues outside the band, so a uniform draw stays uniform over the allowed arc. + public static float HueOutsideBand(float u, float start, float end) => frac(end + u * AllowedArc(start, end)); + + static float AllowedArc(float start, float end) => 1 - frac(end - start); + + // The second hue of a pair whose first is HueOutsideBand(u): distance away along the allowed arc, on the side given by up, + // reflected to the other side if that would leave the arc. Both hues lie on one arc, so the straight RGB blend between + // the two stops never crosses the band. The distance is capped at half the arc, so the reflection always fits and a + // wide band shortens the distance instead of leaving the secondary inside it. + public static float SecondaryHueOutsideBand(float u, bool up, float distance, float start, float end) + { + float arc = AllowedArc(start, end); + float d = min(distance, arc / 2); + float t = u * arc; + float s = up ? t + d : t - d; + if (s < 0 || s > arc) + s = up ? t - d : t + d; + return frac(end + s); + } + public static float3 HsvToRgb(in float3 hsv) { float3 rgb = HueToRgb(hsv.x); @@ -35,7 +58,7 @@ public static class ColorMath public static float3 RgbToHcv(in float3 rgb) { // Based on work by Sam Hocevar and Emil Persson - float4 p = (rgb.y < rgb.z) ? float4(rgb.zy, -1.0f, 2.0f/3.0f) : float4(rgb.zy, 0.0f, -1.0f/3.0f); + float4 p = (rgb.y < rgb.z) ? float4(rgb.zy, -1.0f, 2.0f/3.0f) : float4(rgb.yz, 0.0f, -1.0f/3.0f); float4 q = (rgb.x < p.x) ? float4(p.xyw, rgb.x) : float4(rgb.x, p.yzx); float c = q.x - min(q.w, q.y); float h = abs((q.w - q.y) / (6 * c + Epsilon) + q.z); diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index cda5e1623..af22d2840 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -142,6 +142,9 @@ public class ZoneGenerationSettings public float SunSecondaryColorDistance = .25f; public float SunLightSaturation = .5f; public float SunFogTintSaturation = .5f; + // The green band no sun colour may take, as fractions of the hue circle (75 to 165 degrees: chartreuse through spring green). + public float SunHueExcludedStart = 75f / 360f; + public float SunHueExcludedEnd = 165f / 360f; public ExponentialLerp GasGiantBandCount; public float GasGiantBandColorSeparation = .25f; diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 202e5e4e9..c6ec75375 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -159,8 +159,10 @@ public static class ZoneGenerator { if (gas is SunData sun) { - float primary = random.NextFloat(); - float secondary = frac(primary + 1 + zoneSettings.SunSecondaryColorDistance * (random.NextFloat() > .5 ? 1 : -1)); + var hueDraw = random.NextFloat(); + float primary = ColorMath.HueOutsideBand(hueDraw, zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd); + float secondary = ColorMath.SecondaryHueOutsideBand(hueDraw, random.NextFloat() > .5, zoneSettings.SunSecondaryColorDistance, + zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd); gas.Colors = new [] { float4(ColorMath.HsvToRgb(float3(primary, zoneSettings.SunColorSaturation, .5f)), 0), diff --git a/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs new file mode 100644 index 000000000..e6f5314ef --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// Operator rulings suns-never-green and suns-never-green-edges: no generated sun colour (the body's two colour stops, its +// fog tint and its light colour) takes a hue where green dominates. The band is the Settings default, 75 to 165 degrees; +// yellow (60) and teal (170) stay allowed. Hues are recovered from the RGB the production generator wrote. +public sealed class SunsNeverGreenTests : IDisposable +{ + private const float Degree = 1f / 360f; + // Hue recovered from a float RGB wobbles by a few millionths of a turn; the band is judged with this much margin. + private const float Slack = 1e-3f; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-sunsgreen-" + Guid.NewGuid().ToString("N")); + private int _runs; + private readonly List _caches = new List(); + + public SunsNeverGreenTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var cache in _caches) cache.Dispose(); + Directory.Delete(_root, true); + } + + [Theory] + [InlineData(76f)] + [InlineData(80f)] + [InlineData(120f)] + [InlineData(160f)] + [InlineData(164f)] + public void HuesInsideTheDefaultBandAreRefused(float degrees) + { + var settings = new ZoneGenerationSettings(); + Assert.True(ColorMath.HueInBand(degrees * Degree, settings.SunHueExcludedStart, settings.SunHueExcludedEnd)); + } + + [Theory] + [InlineData(0f)] + [InlineData(60f)] + [InlineData(74f)] + [InlineData(166f)] + [InlineData(170f)] + [InlineData(180f)] + [InlineData(300f)] + public void YellowTealAndTheRestOfTheCircleAreAllowed(float degrees) + { + var settings = new ZoneGenerationSettings(); + Assert.False(ColorMath.HueInBand(degrees * Degree, settings.SunHueExcludedStart, settings.SunHueExcludedEnd)); + } + + // The hues below are read back from RGB the generator wrote, so the reader must invert the writer. + [Fact] + public void ReadingHueBackFromRgbInvertsWritingIt() + { + for (var degrees = 5; degrees < 360; degrees += 5) + foreach (var value in new[] { .5f, 1f }) + { + var rgb = ColorMath.HsvToRgb(new float3(degrees * Degree, .75f, value)); + Assert.Equal(degrees * Degree, ColorMath.RgbToHsv(rgb).x, 4); + } + } + + [Fact] + public void TheDrawMapsOntoTheWholeAllowedArcAndNothingElse() + { + var settings = new ZoneGenerationSettings(); + var start = settings.SunHueExcludedStart; + var end = settings.SunHueExcludedEnd; + var low = float.MaxValue; + var high = float.MinValue; + for (var i = 0; i < 1000; i++) + { + var hue = ColorMath.HueOutsideBand(i / 1000f, start, end); + Assert.False(ColorMath.HueInBand(hue, start, end), $"draw {i}/1000 landed in the band"); + // Unwrapped from the band's far edge, the allowed arc runs 165 up to 75 + 360. + var along = ColorMath.HueInBand(hue, 0f, end) ? hue + 1f : hue; + low = Math.Min(low, along); + high = Math.Max(high, along); + } + Assert.InRange(low, end, end + 1f * Degree); + Assert.InRange(high, 1f + start - 1f * Degree, 1f + start); + } + + [Fact] + public void GeneratedSunsKeepEveryColourOutOfTheDefaultBand() + { + var settings = new ZoneGenerationSettings(); + var suns = GenerateSuns(z => { }, out _); + Assert.True(suns.Count >= 10, $"only {suns.Count} suns generated"); + foreach (var sun in suns) + foreach (var hue in Hues(sun)) + Assert.False(ColorMath.HueInBand(hue, settings.SunHueExcludedStart + Slack, settings.SunHueExcludedEnd - Slack), + $"a sun colour fell in the green band (hue {hue / Degree:F1} degrees)"); + } + + [Fact] + public void TheBandComesFromSettingsNotFromTheGenerator() + { + // A blue band, 200 to 300 degrees: suns avoid it and now take green freely. + var suns = GenerateSuns(z => + { + z.SunHueExcludedStart = 200f * Degree; + z.SunHueExcludedEnd = 300f * Degree; + }, out _); + Assert.True(suns.Count >= 10, $"only {suns.Count} suns generated"); + var hues = suns.SelectMany(Hues).ToList(); + Assert.All(hues, hue => Assert.False(ColorMath.HueInBand(hue, 200f * Degree + Slack, 300f * Degree - Slack))); + Assert.Contains(hues, hue => ColorMath.HueInBand(hue, 75f * Degree, 165f * Degree)); + } + + [Fact] + public void TheDrawIsUniformOverTheAllowedArc() + { + var settings = new ZoneGenerationSettings(); + const int draws = 1000, bins = 10; + var counts = new int[bins]; + var arc = 1f - (settings.SunHueExcludedEnd - settings.SunHueExcludedStart); + for (var i = 0; i < draws; i++) + { + var hue = ColorMath.HueOutsideBand((i + .5f) / draws, settings.SunHueExcludedStart, settings.SunHueExcludedEnd); + var along = hue - settings.SunHueExcludedEnd; + if (along < 0) along += 1f; + counts[Math.Min(bins - 1, (int)(along / arc * bins))]++; + } + Assert.All(counts, count => Assert.InRange(count, draws / bins - 1, draws / bins + 1)); + } + + // The body blends its two stops in RGB (Gradient Blend), so the blend is sampled, not just the stops. + [Theory] + [InlineData(75f, 165f)] + [InlineData(55f, 185f)] + [InlineData(20f, 250f)] + public void TheRampBetweenTheStopsNeverCrossesTheBand(float startDegrees, float endDegrees) + { + var suns = GenerateSuns(z => + { + z.SunHueExcludedStart = startDegrees * Degree; + z.SunHueExcludedEnd = endDegrees * Degree; + }, out _); + Assert.True(suns.Count >= 10, $"only {suns.Count} suns generated"); + foreach (var sun in suns) + { + var a = sun.Colors[0].xyz; + var b = sun.Colors[1].xyz; + for (var step = 0; step <= 40; step++) + { + var hue = ColorMath.RgbToHsv(a + (b - a) * (step / 40f)).x; + Assert.False(ColorMath.HueInBand(hue, startDegrees * Degree + Slack, endDegrees * Degree - Slack), + $"the ramp passed through the band (hue {hue / Degree:F1} degrees, step {step}/40)"); + } + foreach (var colourHue in Hues(sun)) + Assert.False(ColorMath.HueInBand(colourHue, startDegrees * Degree + Slack, endDegrees * Degree - Slack), + $"a sun colour fell in the band (hue {colourHue / Degree:F1} degrees)"); + } + } + + private static IEnumerable Hues(SunData sun) => + sun.Colors.Select(c => c.xyz) + .Concat(new[] { sun.FogTintColor, sun.LightColor }) + .Select(rgb => ColorMath.RgbToHsv(rgb).x); + + // Production path: Galaxy, ItemManager and ZoneGenerator.GenerateZone over the shipped ZoneSettings, as in OrbitsFromFieldTests. + private List GenerateSuns(Action adjust, out int zones) + { + var suns = new List(); + zones = 0; + _runs++; + for (uint seed = 1; seed <= 8; seed++) + { + var dir = Path.Combine(_root, $"galaxy{_runs}-{seed}"); + Directory.CreateDirectory(dir); + var catalog = Path.Combine(dir, "Aetheria.cc"); + File.Copy(TestCatalog.Repo, catalog); + var cache = new CultCache(TestCatalog.Registry()); + _caches.Add(cache); + cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(catalog), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(Path.Combine(dir, "run.cc")), AetheriaStores.RunTypes); + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); + var tutorial = authored.Read("TutorialGenerationSettings"); + var galaxy = new Galaxy(tutorial, authored.Read("TutorialBackgroundSettings"), + authored.Read("NameGeneratorSettings"), cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, seed); + var items = new ItemManager(cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); + var zoneSettings = authored.Read("ZoneSettings"); + adjust(zoneSettings); + foreach (var zone in galaxy.Zones) + { + zones++; + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, zone, false, false); + suns.AddRange(pack.Planets.Select(p => cache.Get(p)).OfType()); + } + } + return suns; + } +}