feat(scoreboard): it ticks — the marquee is the design, not an option
Creator, plainly: "the entire point of a ticker is to tick… so, tick." The static strip was my reading of an earlier exchange; it is gone. How it ticks without becoming the compositor's most expensive layer: - `ScoreboardRenderer.RenderRun` rasterises the run ONCE per value change at its natural width plus a gap. - `ScoreboardPresenter` crops a 1920-wide window at a moving offset each tick — a row-wise byte copy, no text layout, no re-raster. - `TickScoreboard` runs on the existing 30 Hz meter clock and only while the strip is shown, so a hidden or disabled scoreboard costs nothing. The dev simulation uses the same path, so what is previewed is what ships. Test updated to the real contract: no tick → identical pixels; a tick → different pixels; a value change → re-rasterised run. (The old test asserted frame-instance identity, which a cropped window can never satisfy.)
This commit is contained in:
@@ -32,6 +32,73 @@ public static class ScoreboardRenderer
|
||||
private const double PaddingY = 8;
|
||||
private const double SeparatorGap = 26;
|
||||
|
||||
/// <summary>Builds the SCROLLING run: the strip's line plus a gap, at its natural width, so the
|
||||
/// publisher can crop a 1920-wide window that moves. One raster per value change, never per
|
||||
/// tick — the tick is a row-wise byte copy.</summary>
|
||||
public static VideoFrame? RenderRun(IReadOnlyList<string>? tiles, ScoreboardStyle style, double gapPx = 240)
|
||||
{
|
||||
var texts = tiles?.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).ToList();
|
||||
if (texts is not { Count: > 0 }) return null;
|
||||
|
||||
var typeface = new Typeface(OutputPalette.Sans, FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal);
|
||||
var size = Math.Clamp(style.FontSize, ScoreboardLayout.FloorFontSize, ScoreboardLayout.MaxFontSize);
|
||||
var textBrush = Brush(style.TextColor, 1.0, Colors.White);
|
||||
var separator = Brush(style.TextColor, Math.Clamp(style.SeparatorOpacity, 0, 1), Colors.Gray);
|
||||
|
||||
var measured = texts.Select(t => Text(t, typeface, size, textBrush)).ToList();
|
||||
var lineWidth = measured.Sum(t => t.WidthIncludingTrailingWhitespace) + SeparatorGap * (texts.Count - 1);
|
||||
var height = (int)Math.Ceiling(size * 1.45 + PaddingY * 2);
|
||||
var width = (int)Math.Ceiling(lineWidth + gapPx);
|
||||
|
||||
var plate = Brush(style.BackgroundColor, Math.Clamp(style.BackgroundOpacity, 0, 1), Colors.Black);
|
||||
var visual = new DrawingVisual();
|
||||
using (var dc = visual.RenderOpen())
|
||||
{
|
||||
if (style.BackgroundEnabled)
|
||||
dc.DrawRectangle(plate, null, new Rect(0, 0, width, height));
|
||||
|
||||
var x = 0.0;
|
||||
for (var i = 0; i < measured.Count; i++)
|
||||
{
|
||||
dc.DrawText(measured[i], new Point(x, (height - size) / 2));
|
||||
x += measured[i].WidthIncludingTrailingWhitespace;
|
||||
if (i == measured.Count - 1) continue;
|
||||
var dot = Text("·", typeface, size, separator);
|
||||
dc.DrawText(dot, new Point(x + (SeparatorGap - dot.WidthIncludingTrailingWhitespace) / 2, (height - size) / 2));
|
||||
x += SeparatorGap;
|
||||
}
|
||||
}
|
||||
|
||||
return Rasterise(visual, width, height);
|
||||
}
|
||||
|
||||
/// <summary>Crops a <paramref name="width"/>-wide window out of the run at
|
||||
/// <paramref name="offsetPx"/>, wrapping. Straight-alpha rows, no scaling.</summary>
|
||||
public static VideoFrame Crop(VideoFrame run, int offsetPx, int width)
|
||||
{
|
||||
var height = run.Height;
|
||||
var pixels = new byte[width * height * 4];
|
||||
var runStride = run.Width * 4;
|
||||
var start = ((offsetPx % run.Width) + run.Width) % run.Width;
|
||||
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
var srcRow = y * runStride;
|
||||
var dstRow = y * width * 4;
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
var sx = (start + x) % run.Width;
|
||||
var src = srcRow + sx * 4;
|
||||
var dst = dstRow + x * 4;
|
||||
pixels[dst] = run.BgraPixels[src];
|
||||
pixels[dst + 1] = run.BgraPixels[src + 1];
|
||||
pixels[dst + 2] = run.BgraPixels[src + 2];
|
||||
pixels[dst + 3] = run.BgraPixels[src + 3];
|
||||
}
|
||||
}
|
||||
return new VideoFrame(width, height, pixels);
|
||||
}
|
||||
|
||||
public static VideoFrame? Render(IReadOnlyList<string>? tiles, ScoreboardStyle style)
|
||||
{
|
||||
var texts = tiles?.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).ToList();
|
||||
@@ -84,11 +151,18 @@ public static class ScoreboardRenderer
|
||||
}
|
||||
}
|
||||
|
||||
var bitmap = new RenderTargetBitmap(Width, height, 96, 96, PixelFormats.Pbgra32);
|
||||
return Rasterise(visual, Width, height);
|
||||
}
|
||||
|
||||
/// <summary>Draws the visual into a straight-alpha BGRA frame (unpremultiplied, because the
|
||||
/// compositor blends straight alpha).</summary>
|
||||
private static VideoFrame Rasterise(DrawingVisual visual, int width, int height)
|
||||
{
|
||||
var bitmap = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32);
|
||||
bitmap.Render(visual);
|
||||
|
||||
var pixels = new byte[Width * height * 4];
|
||||
bitmap.CopyPixels(pixels, Width * 4, 0);
|
||||
var pixels = new byte[width * height * 4];
|
||||
bitmap.CopyPixels(pixels, width * 4, 0);
|
||||
for (var i = 0; i < pixels.Length; i += 4)
|
||||
{
|
||||
var a = pixels[i + 3];
|
||||
@@ -97,7 +171,7 @@ public static class ScoreboardRenderer
|
||||
pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a);
|
||||
pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a);
|
||||
}
|
||||
return new VideoFrame(Width, height, pixels);
|
||||
return new VideoFrame(width, height, pixels);
|
||||
}
|
||||
|
||||
/// <summary>Greedy line packing at the fitted size — mirrors <see cref="ScoreboardLayout.FitLine"/>
|
||||
|
||||
Reference in New Issue
Block a user