diff --git a/RLBotCS/ManagerTools/RectUtil.cs b/RLBotCS/ManagerTools/RectUtil.cs new file mode 100644 index 0000000..df3c26b --- /dev/null +++ b/RLBotCS/ManagerTools/RectUtil.cs @@ -0,0 +1,125 @@ +using System.Collections.Immutable; + +namespace RLBotCS.ManagerTools; + +public static class RectUtil +{ + /// + /// The maximum number of subdivisions of the [0,1] interval to store. + /// The maximum possible resulting number of entries is ⌈MaxSubdivisions/2⌉, + /// but only those whose sum of the numerator and denominator does + /// not excede Rendering.RectangleStringMaxLength are included, + /// so ideally this should be a highly composite number.
+ /// For 55440, there are 17635 entries, corresponding to 137.77 kiB of memory. + ///
+ private const ushort MaxSubdivisions = 55440; + + private const float HalfPrecisionRangeHigh = 4096f; + private const float HalfPrecisionRangeLow = 1.0f / HalfPrecisionRangeHigh; + + private static readonly ImmutableArray ratios; + private static readonly ImmutableArray<(ushort, ushort)> rects; + + static RectUtil() + { + static int Gcd(int a, int b) + { + // Greatest common divisor by Euclidean algorithm https://stackoverflow.com/a/41766138 + while (a != 0 && b != 0) + { + if (a > b) + a %= b; + else + b %= a; + } + + return a | b; + } + + SortedDictionary dictionary = []; + float fMaxSubdivisions = MaxSubdivisions; + for (ushort i = MaxSubdivisions / 2 + MaxSubdivisions % 2; i <= MaxSubdivisions; ++i) + { + ushort gcd = (ushort)Gcd(i, MaxSubdivisions); + ushort num = (ushort)(i / gcd); + ushort den = (ushort)(MaxSubdivisions / gcd); + if (num + den <= Rendering.RectangleStringMaxLength) + dictionary.Add(i / fMaxSubdivisions, (num, den)); + } + + ratios = [.. dictionary.Keys]; + rects = [.. dictionary.Values]; + } + + private static float GeoMean(float a, float b) + { + if ( + a >= HalfPrecisionRangeHigh + || b >= HalfPrecisionRangeHigh + || a <= HalfPrecisionRangeLow + || b <= HalfPrecisionRangeLow + ) + return MathF.Sqrt(a) * MathF.Sqrt(b); + return MathF.Sqrt(a * b); + } + + private static (ushort, ushort) FindImpl(float value) + { + int higherIdx = ratios.BinarySearch(value); + + if (higherIdx >= 0) + return rects[higherIdx]; + + higherIdx = ~higherIdx; + + // No need to handle this because value >= 0.5 == ratios.First() + //if (higherIdx == 0) + // return rects.First(); + + // No need to handle this because value <= 1.0 == ratios.Last() + //if (higherIdx == ratios.Length) + // return rects.Last(); + + int lowerIdx = higherIdx - 1; + return rects[value * 2 < ratios[lowerIdx] + ratios[higherIdx] ? lowerIdx : higherIdx]; + } + + private static (ushort, ushort) Find(float value) + { + if (value >= 0.5) + return FindImpl(value); + + (ushort num, ushort den) = FindImpl(1f - value); + return ((ushort)(den - num), den); + } + + private static (ushort cols, ushort rows) Find(float width, float height) + { + if (width <= height) + return Find(width / height); + + (ushort rows, ushort cols) = Find(height / width); + return (cols, rows); + } + + /// + /// Approximates the rectangle width×height with cols×rows + /// rectangles with dimensions elementWidth×elementHeight scaled by scale. + /// + public static (ushort cols, ushort rows, float scale) ApproximateRect( + uint width, + uint height, + uint elementWidth, + uint elementHeight + ) + { + float elementsInWidth = (float)width / elementWidth; + float elementsInHeight = (float)height / elementHeight; + (ushort cols, ushort rows) = Find(elementsInWidth, elementsInHeight); + + // Ideal horizontal and vertical scale are + // ((float)width / cols) / elementWidth == ((float)width / elementWidth) / cols == elementsInWidth / cols + // ((float)height / rows) / elementHeight == ((float)height / elementHeight) / rows == elementsInHeight / rows + return (cols, rows, GeoMean(elementsInWidth / cols, elementsInHeight / rows)); + } +} diff --git a/RLBotCS/ManagerTools/Rendering.cs b/RLBotCS/ManagerTools/Rendering.cs index 8305e62..a2514bd 100644 --- a/RLBotCS/ManagerTools/Rendering.cs +++ b/RLBotCS/ManagerTools/Rendering.cs @@ -94,8 +94,8 @@ private ushort SendRect2D(Rect2DT rect2Dt) // Fake a filled rectangle using a string with colored background var (text, scale) = MakeFakeRectangleString( - (int)Math.Abs(rect2Dt.Width * ResolutionWidthPixels), - (int)Math.Abs(rect2Dt.Height * ResolutionHeightPixels) + (uint)Math.Abs(rect2Dt.Width * ResolutionWidthPixels), + (uint)Math.Abs(rect2Dt.Height * ResolutionHeightPixels) ); return _renderingCommandQueue.AddText2D( @@ -114,8 +114,8 @@ private ushort SendRect3D(Rect3DT rect3Dt, GameState gameState) { // Fake a filled rectangle using a string with colored background var (text, scale) = MakeFakeRectangleString( - (int)Math.Abs(rect3Dt.Width * ResolutionWidthPixels), - (int)Math.Abs(rect3Dt.Height * ResolutionHeightPixels) + (uint)Math.Abs(rect3Dt.Width * ResolutionWidthPixels), + (uint)Math.Abs(rect3Dt.Height * ResolutionHeightPixels) ); return _renderingCommandQueue.AddText3D( @@ -135,37 +135,16 @@ private ushort SendRect3D(Rect3DT rect3Dt, GameState gameState) /// for rectangle rendering. /// /// The rectangle string and the font scaling - private (string, float) MakeFakeRectangleString(int width, int height) + private (string, float) MakeFakeRectangleString(uint width, uint height) { - int Gcd(int a, int b) - { - // Greatest common divisor by Euclidean algorithm https://stackoverflow.com/a/41766138 - while (a != 0 && b != 0) - { - if (a > b) - a %= b; - else - b %= a; - } - - return a | b; - } - - int gcd = Gcd(width, height); - int cols = (width / gcd) * (FontHeightPixels / FontWidthPixels); - int rows = height / gcd; - float scale = gcd / (float)FontHeightPixels; - - if (cols + rows > RectangleStringMaxLength) - { - // The width-height ratio has resulting in a very long string. - // TODO: Consider an approximate solution as backup. Do we ever hit this case though? - Logger.LogWarning( - "A rendered rectangle requires more characters than budget allows. Consider different width-height ratio." - ); - } + (ushort cols, ushort rows, float scale) = RectUtil.ApproximateRect( + width, + height, + FontWidthPixels, + FontHeightPixels + ); - StringBuilder str = new StringBuilder(cols + rows); + StringBuilder str = new(cols + rows); for (int c = 0; c < cols; c++) { str.Append(' '); diff --git a/RLBotCSTests/ManagerTools/RectUtilTest.cs b/RLBotCSTests/ManagerTools/RectUtilTest.cs new file mode 100644 index 0000000..fe93db8 --- /dev/null +++ b/RLBotCSTests/ManagerTools/RectUtilTest.cs @@ -0,0 +1,51 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using RLBotCS.ManagerTools; + +namespace RLBotCSTests.ManagerTools; + +[TestClass] +public class RectUtilTest +{ + const uint TestUpTo = 64; + + [TestMethod] + public void ApproximateRectTest() + { + for (uint i = 1; i <= TestUpTo; ++i) + { + for (uint j = 1; j <= TestUpTo; ++j) + { + (ushort cols, ushort rows, float scale) = RectUtil.ApproximateRect(i, i, j, j); + Assert.AreEqual(1, cols); + Assert.AreEqual(1, rows); + // Slightly iffy, but it passes. + Assert.AreEqual((float)i / j, scale); + } + } + + for (uint i = 1; i <= TestUpTo; ++i) + { + float iMin = i * 0.96f; + float iMax = i * 1.04f; + for (uint j = 1; j <= TestUpTo; ++j) + { + float jMin = j * 0.96f; + float jMax = j * 1.04f; + for (uint k = 1; k <= TestUpTo; ++k) + { + for (uint l = 1; l <= TestUpTo; ++l) + { + (ushort cols, ushort rows, float scale) = RectUtil.ApproximateRect( + i, + j, + k, + l + ); + Assert.IsInRange(iMin, iMax, k * scale * cols); + Assert.IsInRange(jMin, jMax, l * scale * rows); + } + } + } + } + } +}