Add devwindow tab listing render targets

Copy TableContainer from content into engine. It's internal though.

Add various stuff to Clyde to allow the UI to access it. Includes a new IClydeInternal.RenderNow() which seems to not completely explode in my face.
This commit is contained in:
PJB3005
2025-09-13 01:00:11 +02:00
parent 186392ea80
commit 60d26be139
11 changed files with 604 additions and 6 deletions

View File

@@ -8,3 +8,18 @@ dev-window-tab-textures-info = Width: { $width } Height: { $height }
PixelType: { $pixelType } sRGB: { $srgb }
Name: { $name }
Est. memory usage: { $bytes }
## "Render Targets" dev window tab
dev-window-tab-render-targets-title = Render Targets
dev-window-tab-render-targets-reload = Reload
dev-window-tab-render-targets-filter = Filter
dev-window-tab-render-targets-column-id = ID
dev-window-tab-render-targets-column-name = Name
dev-window-tab-render-targets-column-size = Size
dev-window-tab-render-targets-column-type = Type
dev-window-tab-render-targets-column-vram = VRAM
dev-window-tab-render-targets-column-thumbnail = Thumbnail
dev-window-tab-render-targets-value-null = null
dev-window-tab-render-targets-value-not-available = Not available
dev-window-tab-render-targets-summary = Total VRAM: { $vram }

View File

@@ -121,6 +121,19 @@ namespace Robust.Client.Graphics.Clyde
}
}
public void RenderNow(IRenderTarget renderTarget, Action<IRenderHandle> callback)
{
ClearRenderState();
_renderHandle.RenderInRenderTarget(
renderTarget,
() =>
{
callback(_renderHandle);
},
null);
}
private void RenderSingleWorldOverlay(Overlay overlay, Viewport vp, OverlaySpace space, in Box2 worldBox, in Box2Rotated worldBounds)
{
// Check that entity manager has started.

View File

@@ -209,6 +209,7 @@ namespace Robust.Client.Graphics.Clyde
var pressure = estPixSize * size.X * size.Y;
var handle = AllocRid();
var renderTarget = new RenderTexture(size, textureObject, this, handle);
var data = new LoadedRenderTarget
{
IsWindow = false,
@@ -220,10 +221,11 @@ namespace Robust.Client.Graphics.Clyde
MemoryPressure = pressure,
ColorFormat = format.ColorFormat,
SampleParameters = sampleParameters,
Instance = new WeakReference<RenderTargetBase>(renderTarget),
Name = name,
};
//GC.AddMemoryPressure(pressure);
var renderTarget = new RenderTexture(size, textureObject, this, handle);
_renderTargets.Add(handle, data);
return renderTarget;
}
@@ -301,10 +303,22 @@ namespace Robust.Client.Graphics.Clyde
}
}
private sealed class LoadedRenderTarget
public IEnumerable<(RenderTargetBase, LoadedRenderTarget)> GetLoadedRenderTextures()
{
foreach (var loaded in _renderTargets.Values)
{
if (!loaded.Instance.TryGetTarget(out var instance))
continue;
yield return (instance, loaded);
}
}
internal sealed class LoadedRenderTarget
{
public bool IsWindow;
public WindowId WindowId;
public string? Name;
public Vector2i Size;
public bool IsSrgb;
@@ -325,9 +339,11 @@ namespace Robust.Client.Graphics.Clyde
public long MemoryPressure;
public TextureSampleParameters? SampleParameters;
public required WeakReference<RenderTargetBase> Instance;
}
private abstract class RenderTargetBase : IRenderTarget
internal abstract class RenderTargetBase : IRenderTarget
{
protected readonly Clyde Clyde;
private bool _disposed;
@@ -389,7 +405,7 @@ namespace Robust.Client.Graphics.Clyde
}
}
private sealed class RenderTexture : RenderTargetBase, IRenderTexture
internal sealed class RenderTexture : RenderTargetBase, IRenderTexture
{
public RenderTexture(Vector2i size, ClydeTexture texture, Clyde clyde, ClydeHandle handle)
: base(clyde, handle)

View File

@@ -354,15 +354,17 @@ namespace Robust.Client.Graphics.Clyde
_windowHandles.Add(reg.Handle);
var rtId = AllocRid();
var renderTarget = new RenderWindow(this, rtId);
_renderTargets.Add(rtId, new LoadedRenderTarget
{
Size = reg.FramebufferSize,
IsWindow = true,
WindowId = reg.Id,
IsSrgb = true
IsSrgb = true,
Instance = new WeakReference<RenderTargetBase>(renderTarget),
});
reg.RenderTarget = new RenderWindow(this, rtId);
reg.RenderTarget = renderTarget;
_glContext!.WindowCreated(glSpec, reg);
}

View File

@@ -76,6 +76,11 @@ namespace Robust.Client.Graphics.Clyde
return [];
}
public IEnumerable<(Clyde.RenderTargetBase, Clyde.LoadedRenderTarget)> GetLoadedRenderTextures()
{
return [];
}
public ClydeDebugLayers DebugLayers { get; set; }
public string GetKeyName(Keyboard.Key key) => string.Empty;
@@ -317,6 +322,10 @@ namespace Robust.Client.Graphics.Clyde
}
#endif // TOOLS
public void RenderNow(IRenderTarget renderTarget, Action<IRenderHandle> callback)
{
}
private sealed class DummyCursor : ICursor
{
public void Dispose()

View File

@@ -55,6 +55,7 @@ namespace Robust.Client.Graphics
Texture GetStockTexture(ClydeStockTexture stockTexture);
IEnumerable<(Clyde.Clyde.ClydeTexture, Clyde.Clyde.LoadedTexture)> GetLoadedTextures();
IEnumerable<(Clyde.Clyde.RenderTargetBase, Clyde.Clyde.LoadedRenderTarget)> GetLoadedRenderTextures();
ClydeDebugLayers DebugLayers { get; set; }
@@ -85,5 +86,7 @@ namespace Robust.Client.Graphics
void ViewportsClearAllCached();
#endif // TOOLS
void RenderNow(IRenderTarget renderTarget, Action<IRenderHandle> callback);
}
}

View File

@@ -0,0 +1,270 @@
using System;
using System.Numerics;
using Robust.Shared.Maths;
namespace Robust.Client.UserInterface.Controls;
internal sealed class TableContainer : Container
{
private int _columns = 1;
/// <summary>
/// The absolute minimum width a column can be forced to.
/// </summary>
/// <remarks>
/// <para>
/// If a column *asks* for less width than this (small contents), it can still be smaller.
/// But if it asks for more it cannot go below this width.
/// </para>
/// </remarks>
public float MinForcedColumnWidth { get; set; } = 50;
// Scratch space used while calculating layout, cached to avoid regular allocations during layout pass.
private ColumnData[] _columnDataCache = [];
private RowData[] _rowDataCache = [];
/// <summary>
/// How many columns should be displayed.
/// </summary>
public int Columns
{
get => _columns;
set
{
ArgumentOutOfRangeException.ThrowIfLessThan(value, 1, nameof(value));
_columns = value;
}
}
protected override Vector2 MeasureOverride(Vector2 availableSize)
{
ResetCachedArrays();
// Do a first pass measuring all child controls as if they're given infinite space.
// This gives us a maximum width the columns want, which we use to proportion them later.
var columnIdx = 0;
foreach (var child in Children)
{
ref var column = ref _columnDataCache[columnIdx];
child.Measure(new Vector2(float.PositiveInfinity, float.PositiveInfinity));
column.MaxWidth = Math.Max(column.MaxWidth, child.DesiredSize.X);
columnIdx += 1;
if (columnIdx == _columns)
columnIdx = 0;
}
// Calculate Slack and MinWidth for all columns. Also calculate sums for all columns.
var totalMinWidth = 0f;
var totalMaxWidth = 0f;
var totalSlack = 0f;
for (var c = 0; c < _columns; c++)
{
ref var column = ref _columnDataCache[c];
column.MinWidth = Math.Min(column.MaxWidth, MinForcedColumnWidth);
column.Slack = column.MaxWidth - column.MinWidth;
totalMinWidth += column.MinWidth;
totalMaxWidth += column.MaxWidth;
totalSlack += column.Slack;
}
if (totalMaxWidth <= availableSize.X)
{
// We want less horizontal space than we're given. Huh, that's convenient.
// Just set assigned width to be however much they asked for.
// We could probably skip the second measure pass in this scenario,
// but that's just an optimization, so I don't care right now.
//
// There's probably a very clever way to make this behavior work with the else block of logic,
// just by fiddling with the math.
// I'm dumb, it's 4:30 AM. Yeah, I *started* at 2 AM.
for (var c = 0; c < _columns; c++)
{
ref var column = ref _columnDataCache[c];
column.AssignedWidth = column.MaxWidth;
}
}
else
{
// We don't have enough horizontal space,
// at least without causing *some* sort of word wrapping (assuming text contents).
//
// Assign horizontal space proportional to the wanted maximum size of the columns.
var assignableWidth = Math.Max(0, availableSize.X - totalMinWidth);
for (var c = 0; c < _columns; c++)
{
ref var column = ref _columnDataCache[c];
var slackRatio = column.Slack / totalSlack;
column.AssignedWidth = column.MinWidth + slackRatio * assignableWidth;
}
}
// Go over controls for a second measuring pass, this time giving them their assigned measure width.
// This will give us a height to slot into per-row data.
// We still measure assuming infinite vertical space.
// This control can't properly handle being constrained on the Y axis.
columnIdx = 0;
var rowIdx = 0;
foreach (var child in Children)
{
ref var column = ref _columnDataCache[columnIdx];
ref var row = ref _rowDataCache[rowIdx];
child.Measure(new Vector2(column.AssignedWidth, float.PositiveInfinity));
row.MeasuredHeight = Math.Max(row.MeasuredHeight, child.DesiredSize.Y);
columnIdx += 1;
if (columnIdx == _columns)
{
columnIdx = 0;
rowIdx += 1;
}
}
// Sum up height of all rows to get final measured table height.
var totalHeight = 0f;
for (var r = 0; r < _rowDataCache.Length; r++)
{
ref var row = ref _rowDataCache[r];
totalHeight += row.MeasuredHeight;
}
return new Vector2(Math.Min(availableSize.X, totalMaxWidth), totalHeight);
}
protected override Vector2 ArrangeOverride(Vector2 finalSize)
{
// TODO: Expand to fit given vertical space.
// Calculate MinWidth and Slack sums again from column data.
// We could've cached these from measure but whatever.
var totalMinWidth = 0f;
var totalSlack = 0f;
for (var c = 0; c < _columns; c++)
{
ref var column = ref _columnDataCache[c];
totalMinWidth += column.MinWidth;
totalSlack += column.Slack;
}
// Calculate new width based on final given size, also assign horizontal positions of all columns.
var assignableWidth = Math.Max(0, finalSize.X - totalMinWidth);
var xPos = 0f;
for (var c = 0; c < _columns; c++)
{
ref var column = ref _columnDataCache[c];
var slackRatio = column.Slack / totalSlack;
column.ArrangedWidth = column.MinWidth + slackRatio * assignableWidth;
column.ArrangedX = xPos;
xPos += column.ArrangedWidth;
}
// Do actual arrangement row-by-row.
var arrangeY = 0f;
for (var r = 0; r < _rowDataCache.Length; r++)
{
ref var row = ref _rowDataCache[r];
for (var c = 0; c < _columns; c++)
{
ref var column = ref _columnDataCache[c];
var index = c + r * _columns;
if (index >= ChildCount) // Quit early if we don't actually fill out the row.
break;
var child = GetChild(c + r * _columns);
child.Arrange(UIBox2.FromDimensions(column.ArrangedX, arrangeY, column.ArrangedWidth, row.MeasuredHeight));
}
arrangeY += row.MeasuredHeight;
}
return finalSize with { Y = arrangeY };
}
/// <summary>
/// Ensure cached array space is allocated to correct size and is reset to a clean slate.
/// </summary>
private void ResetCachedArrays()
{
// 1-argument Array.Clear() is not currently available in sandbox (added in .NET 6).
if (_columnDataCache.Length != _columns)
_columnDataCache = new ColumnData[_columns];
Array.Clear(_columnDataCache, 0, _columnDataCache.Length);
var rowCount = ChildCount / _columns;
if (ChildCount % _columns != 0)
rowCount += 1;
if (rowCount != _rowDataCache.Length)
_rowDataCache = new RowData[rowCount];
Array.Clear(_rowDataCache, 0, _rowDataCache.Length);
}
/// <summary>
/// Per-column data used during layout.
/// </summary>
private struct ColumnData
{
// Measure data.
/// <summary>
/// The maximum width any control in this column wants, if given infinite space.
/// Maximum of all controls on the column.
/// </summary>
public float MaxWidth;
/// <summary>
/// The minimum width this column may be given.
/// This is either <see cref="MaxWidth"/> or <see cref="TableContainer.MinForcedColumnWidth"/>.
/// </summary>
public float MinWidth;
/// <summary>
/// Difference between max and min width; how much this column can expand from its minimum.
/// </summary>
public float Slack;
/// <summary>
/// How much horizontal space this column was assigned at measure time.
/// </summary>
public float AssignedWidth;
// Arrange data.
/// <summary>
/// How much horizontal space this column was assigned at arrange time.
/// </summary>
public float ArrangedWidth;
/// <summary>
/// The horizontal position this column was assigned at arrange time.
/// </summary>
public float ArrangedX;
}
private struct RowData
{
// Measure data.
/// <summary>
/// How much height the tallest control on this row was measured at,
/// measuring for infinite vertical space but assigned column width.
/// </summary>
public float MeasuredHeight;
}
}

View File

@@ -6,5 +6,6 @@
<DevWindowTabUI Name="UI" />
<DevWindowTabPerf Name="Perf" />
<DevWindowTabTextures Name="Textures" />
<DevWindowTabRenderTargets Name="RenderTargets" />
</TabContainer>
</Control>

View File

@@ -28,6 +28,7 @@ namespace Robust.Client.UserInterface
TabContainer.SetTabTitle(UI, "User Interface");
TabContainer.SetTabTitle(Perf, "Profiling");
TabContainer.SetTabTitle(Textures, Loc.GetString("dev-window-tab-textures-title"));
TabContainer.SetTabTitle(RenderTargets, Loc.GetString("dev-window-tab-render-targets-title"));
Stylesheet =
new DefaultStylesheet(IoCManager.Resolve<IResourceCache>(), IoCManager.Resolve<IUserInterfaceManager>()).Stylesheet;

View File

@@ -0,0 +1,26 @@
<Control xmlns="https://spacestation14.io"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Robust.Client.UserInterface.DevWindowTabRenderTargets">
<BoxContainer Orientation="Vertical">
<BoxContainer Orientation="Horizontal">
<Button Text="{Loc 'dev-window-tab-render-targets-reload'}" Name="ReloadButton" />
</BoxContainer>
<LineEdit Name="FilterEdit" PlaceHolder="{Loc 'dev-window-tab-render-targets-filter'}" />
<ScrollContainer VerticalExpand="True" VScrollEnabled="True" HScrollEnabled="False">
<BoxContainer Orientation="Vertical">
<TableContainer Name="Table" Margin="2" Columns="6">
<Label Margin="4" Text="{Loc 'dev-window-tab-render-targets-column-id'}" />
<Label Margin="4" Text="{Loc 'dev-window-tab-render-targets-column-name'}" />
<Label Margin="4" Text="{Loc 'dev-window-tab-render-targets-column-size'}" />
<Label Margin="4" Text="{Loc 'dev-window-tab-render-targets-column-type'}" />
<Label Margin="4" Text="{Loc 'dev-window-tab-render-targets-column-vram'}" />
<Label Margin="4" Text="{Loc 'dev-window-tab-render-targets-column-thumbnail'}" />
</TableContainer>
</BoxContainer>
</ScrollContainer>
<BoxContainer Orientation="Horizontal">
<Label Name="TotalLabel" Margin="4" />
</BoxContainer>
</BoxContainer>
</Control>

View File

@@ -0,0 +1,242 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Graphics;
using Robust.Shared.IoC;
using Robust.Shared.Localization;
using Robust.Shared.Map;
using Robust.Shared.Maths;
using Robust.Shared.Utility;
using RTCF = Robust.Client.Graphics.RenderTargetColorFormat;
namespace Robust.Client.UserInterface;
[GenerateTypedNameReferences]
internal sealed partial class DevWindowTabRenderTargets : Control
{
[Dependency] private readonly IClydeInternal _clyde = default!;
[Dependency] private readonly ILocalizationManager _loc = default!;
private readonly Control[] _gridHeader;
private readonly StyleBoxFlat _styleAltRow = new() { BackgroundColor = Color.FromHex("#222") };
#if TOOLS
private readonly HashSet<IRenderTarget> _copyTextures = new();
#endif // TOOLS
public DevWindowTabRenderTargets()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
_gridHeader = Table.Children.ToArray();
ReloadButton.OnPressed += _ => Reload();
FilterEdit.OnTextChanged += _ => Reload();
}
protected override void VisibilityChanged(bool newVisible)
{
base.VisibilityChanged(newVisible);
if (newVisible)
{
Reload();
}
else
{
// Clear to release memory when tab not visible.
Clear();
}
}
protected override void ExitedTree()
{
base.ExitedTree();
Clear();
}
private void Clear()
{
Table.RemoveAllChildren();
#if TOOLS
foreach (var copiedTexture in _copyTextures)
{
copiedTexture.Dispose();
}
_copyTextures.Clear();
#endif
}
private void Reload()
{
Table.RemoveAllChildren();
foreach (var header in _gridHeader)
{
Table.AddChild(header);
}
var totalVram = 0L;
var even = true;
var rts = _clyde.GetLoadedRenderTextures().OrderBy(x => x.Item1.Handle.Value).ToArray();
foreach (var (instance, loaded) in rts)
{
#if TOOLS
if (_copyTextures.Contains(instance))
continue;
#endif // TOOLS
if (!string.IsNullOrWhiteSpace(FilterEdit.Text))
{
if (loaded.Name is not { } name)
continue;
if (!name.Contains(FilterEdit.Text, StringComparison.CurrentCultureIgnoreCase))
continue;
}
AddColumnText(instance.Handle.Value.ToString());
if (loaded.Name != null)
AddColumnText(loaded.Name);
else if (loaded.WindowId != WindowId.Invalid)
AddColumnText(loaded.WindowId.ToString());
else
AddColumnText(_loc.GetString("dev-window-tab-render-targets-value-null"));
AddColumnText(loaded.Size.ToString());
var type = loaded.ColorFormat.ToString();
if (loaded.DepthStencilHandle != default)
type += "+DS";
AddColumnText(type);
AddColumnText(ByteHelpers.FormatBytes(loaded.MemoryPressure));
// Disable texture thumbnails outside TOOLS.
// Avoid people cheating by using devwindow to see through walls. Barely.
#if TOOLS
if (instance is IRenderTexture renderTexture)
{
var clone = CloneTexture(renderTexture.Texture, loaded.ColorFormat);
_copyTextures.Add(clone);
AddColumn(new TextureRect
{
Texture = clone.Texture,
Stretch = TextureRect.StretchMode.KeepAspect
});
}
else
#endif // TOOLS
{
AddColumnText(_loc.GetString("dev-window-tab-render-targets-value-not-available"));
}
totalVram += loaded.MemoryPressure;
even = !even;
}
TotalLabel.Text = Loc.GetString(
"dev-window-tab-render-targets-summary",
("vram", ByteHelpers.FormatBytes(totalVram)));
return;
void AddColumnText(string text)
{
var richTextLabel = new RichTextLabel { Margin = new Thickness(4) };
richTextLabel.SetMessage(text, defaultColor: Color.White);
AddColumn(richTextLabel);
}
void AddColumn(Control control)
{
control.VerticalAlignment = VAlignment.Center;
if (even)
{
control = new PanelContainer
{
PanelOverride = _styleAltRow,
Children = { control },
};
}
else
{
// Wrapping control so we can use SetHeight.
control = new Control
{
Children = { control },
};
}
control.SetHeight = 50;
Table.AddChild(control);
}
}
#if TOOLS
private IRenderTexture CloneTexture(Texture texture, RTCF colorFormat)
{
var thumbnailSize = GetThumbnailSize(texture.Size);
var rt = _clyde.CreateRenderTarget(
thumbnailSize,
new RenderTargetFormatParameters
{
ColorFormat = colorFormat,
HasDepthStencil = false,
},
new TextureSampleParameters
{
Filter = true,
},
name: $"{nameof(DevWindowTabRenderTargets)}-clone");
_clyde.RenderNow(rt,
handle =>
{
handle.DrawingHandleScreen.DrawTextureRect(texture, UIBox2.FromDimensions(Vector2.Zero, thumbnailSize));
});
return rt;
}
private static Vector2i GetThumbnailSize(Vector2i textureSize)
{
const int maxHeight = 50;
const int maxWidth = 100;
var (w, h) = (Vector2)textureSize;
if (h > maxHeight)
{
w /= h / maxHeight;
h = maxHeight;
}
if (w > maxWidth)
{
h /= w / maxWidth;
w = maxWidth;
}
return new Vector2i((int)w, (int)h);
}
#endif // TOOLS
}