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);
+ }
+ }
+ }
+ }
+ }
+}