New dirty flag system. Avoid calling updates in rendering (yuck!)

This commit is contained in:
2026-06-02 00:54:36 +02:00
parent 2576ea87bc
commit b29ab443fe
14 changed files with 295 additions and 237 deletions

View File

@@ -20,7 +20,7 @@ public class TestGame : Game
InitializeSystemsDefault();
_uiSystem = new UISystem(Input);
_uiSystem.RenderDebugRects = true;
_uiSystem.RenderDebugRects = false;
ResourceManager.EnableFileWatching();
@@ -73,7 +73,7 @@ public class TestGame : Game
_uiSystem.SetStyleSheet(_styleSheet);
var addButton = new Button("", _defaultFontSet);
var addButton = new Button("Default button", _defaultFontSet);
var removeButton = new Button("Danger button", _defaultFontSet);
@@ -90,7 +90,7 @@ public class TestGame : Game
{
StyleVariant = "Layer01",
ConfineToContents = true,
Anchor = Anchor.TopCenter
Anchor = Anchor.TopCenter,
};
var inputField = new InputField(string.Empty, _defaultFontSet)
@@ -124,6 +124,8 @@ public class TestGame : Game
// ResourceManager.Reload();
// _particleSystem!.RestartEmitter(_emitterId);
}
_uiSystem.SetWindowSize(Renderer.WindowSize);
}
protected override void Render(double deltaTime)

View File

@@ -40,19 +40,21 @@ public abstract class Container : UIElement, IParentableElement
MarkDirty();
}
protected override void OnUpdate()
protected override void OnUpdate(LayoutContext layoutContext)
{
foreach (var child in _children)
for (int i = 0; i < _children.Count; i++)
{
if (child is not IUpdatableElement updatable) continue;
var child = _children[i];
updatable.Update();
if (!child.Visible)
continue;
if (child is IUpdatableElement updatable && updatable.Dirty)
updatable.Update(layoutContext);
if (child is IAnchorableElement anchorable)
{
anchorable.ApplyAnchor(GlobalPosition, Size);
}
}
Arrange();
@@ -62,15 +64,15 @@ public abstract class Container : UIElement, IParentableElement
}
}
public override void MarkDirty()
public override void MarkDirty(DirtyFlags flags = DirtyFlags.Layout)
{
base.MarkDirty();
base.MarkDirty(flags);
foreach (var child in _children)
{
if (child is not IUpdatableElement updatable) continue;
updatable.MarkDirty();
updatable.MarkDirty(flags);
}
}
@@ -85,6 +87,9 @@ public abstract class Container : UIElement, IParentableElement
/// </summary>
public void RecalculateSizes()
{
if (_children.Count == 0)
return;
float minX = float.MaxValue;
float minY = float.MaxValue;
float maxX = float.MinValue;
@@ -113,31 +118,45 @@ public abstract class Container : UIElement, IParentableElement
if (finalSize != Size)
{
Size = finalSize;
LayoutSize = finalSize;
}
if (_minimumSize > Size)
{
Size = _minimumSize;
LayoutSize = _minimumSize;
}
}
/// <summary>
/// Adds an <see cref="UIElement"/> to the list of children.
/// </summary>
/// <param name="child">Child <see cref="UIElement"/> to add.</param>
/// <exception cref="InvalidOperationException"></exception>
public void AddChild(UIElement child)
{
// child.StyleSheetOverride = StyleSheet;
if (child.Parent != null)
throw new InvalidOperationException("This UIElement already contains a parent.");
_children.Add(child);
child.SetParent(this);
MarkDirty();
Update();
}
/// <summary>
/// Removes an <see cref="UIElement"/> from the list of children.
/// </summary>
/// <param name="child">Child <see cref="UIElement"/> to remove.</param>
/// <exception cref="InvalidOperationException"></exception>
public void RemoveChild(UIElement child)
{
if (child.Parent != this)
throw new InvalidOperationException("This UIElement is not a child of this Container.");
_children.Remove(child);
MarkDirty();
Update();
}
protected override void OnRender(RenderSystem renderer, Style style)

View File

@@ -1,5 +1,3 @@
using Voile.Rendering;
namespace Voile.UI.Containers;
/// <summary>
@@ -8,56 +6,18 @@ namespace Voile.UI.Containers;
/// </summary>
public class FillContainer : Container
{
public FillContainer()
{
}
public FillContainer(Rect minimumSize) : base(minimumSize)
{
}
public override void Arrange()
{
// FillContainer does not position children.
// Children handle their own layout or are absolute.
}
protected override void OnRender(RenderSystem renderer, Style style)
protected override void OnUpdate(LayoutContext layout)
{
base.OnRender(renderer, style);
Size = Parent != null
? Parent.Size
: new Rect(layout.WindowSize.X, layout.WindowSize.Y);
Rect parentSize;
if (Parent != null)
{
parentSize = Parent.Size;
base.OnUpdate(layout);
}
else
{
var windowSize = renderer.WindowSize;
var windowRect = new Rect(windowSize.X, windowSize.Y);
parentSize = windowRect;
}
if (_lastParentSize != parentSize)
{
Size = parentSize;
_lastParentSize = parentSize;
}
}
protected override void OnUpdate()
{
base.OnUpdate();
Size = _lastParentSize;
if (Children.Count != 0)
{
Children[0].Size = Size;
}
}
private Rect _lastParentSize = Rect.Zero;
}

View File

@@ -124,7 +124,7 @@ public class FlexContainer : Container
float justifyOffset = GetJustifyOffset(containerMainSize, lineMainLength, line.Count);
float currentMain = mainPos + justifyOffset;
float maxLineCross = line.Select(child => GetCrossSize(GetChildSize(child))).Max();
float maxLineCross = line.Max(child => GetCrossSize(GetChildSize(child)));
foreach (var child in line)
{

View File

@@ -21,9 +21,10 @@ public class MarginContainer : Container
Margin = margin;
}
protected override void OnUpdate()
protected override void OnUpdate(LayoutContext layoutContext)
{
base.OnUpdate();
base.OnUpdate(layoutContext);
if (Parent == null) return;
if (Size != Parent.Size)

View File

@@ -0,0 +1,10 @@
namespace Voile.UI;
[Flags]
public enum DirtyFlags
{
None = 0,
Layout = 1 << 0,
Content = 1 << 1,
Style = 1 << 2,
}

View File

@@ -25,7 +25,7 @@ public interface IElement
public interface ITickableElement
{
/// <summary>
/// Excutes unconditionally on every frame engine loop step.
/// Executes unconditionally on every frame engine loop step.
/// </summary>
/// <param name="dt">Elapsed delta frame time in seconds.</param>
/// <param name="input">InputSystem that this tickable element should use to poll input events.</param>
@@ -77,11 +77,12 @@ public interface IUpdatableElement
/// <summary>
/// Update this element.
/// </summary>
void Update(float dt = 0.0f);
void Update(LayoutContext layoutContext);
/// <summary>
/// Marks this element as changed, requiring an update.
/// Marks this element as dirty (i.e. requiring an update).
/// </summary>
void MarkDirty();
/// <param name="flags">The parts that were updated, as flags.</param>
void MarkDirty(DirtyFlags flags = DirtyFlags.Layout);
}
/// <summary>

View File

@@ -0,0 +1,13 @@
using System.Numerics;
namespace Voile.UI;
public readonly struct LayoutContext
{
public Vector2 WindowSize { get; }
public LayoutContext(Vector2 windowSize)
{
WindowSize = windowSize;
}
}

View File

@@ -55,7 +55,7 @@ public abstract class UIElement : IElement, IRenderableElement, IResizeableEleme
_size.Width = width;
_size.Height = height;
MarkDirty();
MarkDirty(DirtyFlags.Layout);
}
}
@@ -63,57 +63,112 @@ public abstract class UIElement : IElement, IRenderableElement, IResizeableEleme
public Anchor Anchor { get; set; } = Anchor.TopLeft;
public abstract Rect MinimumSize { get; }
public bool Dirty => _dirty;
public bool Dirty => _dirty != DirtyFlags.None || _pendingDirty != DirtyFlags.None;
public bool TryGetStyle(StyleSheet styleSheet, [NotNullWhen(true)] out Style? style)
{
return styleSheet.TryGet(StyleName, out style);
}
public virtual void MarkDirty()
{
if (Parent != null && !Parent.Dirty)
{
Parent.MarkDirty();
}
_dirty = true;
}
public virtual void MarkDirty(DirtyFlags flags = DirtyFlags.Layout) => _pendingDirty |= flags;
/// <summary>
/// Sets a parent element for this <see cref="UIElement"/>.
/// </summary>
/// <param name="parent">Element to parent this <see cref="UIElement"/> to.</param>
public void SetParent(UIElement parent)
{
_parent = parent;
MarkDirty();
MarkDirty(DirtyFlags.Layout);
}
public void Update(float dt = 0.0f)
public void Update(LayoutContext layoutContext)
{
if (!_dirty) return;
_dirty = false;
_dirty |= _pendingDirty;
_pendingDirty = DirtyFlags.None;
if (Size == Rect.Zero)
Size = MinimumSize;
if (_dirty == DirtyFlags.None)
return;
OnUpdate();
if (HasDirty(DirtyFlags.Layout))
OnLayoutUpdate();
if (_parent is not null && _parent.Size != Rect.Zero)
{
ApplyAnchor(_parent.GlobalPosition, _parent.Size);
}
if (HasDirty(DirtyFlags.Content))
OnContentUpdate();
if (HasDirty(DirtyFlags.Style))
OnStyleUpdate();
_dirty = DirtyFlags.None;
OnUpdate(layoutContext);
}
public void Render(RenderSystem renderer, Style style)
{
RenderStyleBox(renderer, style);
OnRender(renderer, style);
}
public void DrawSize(RenderSystem renderer)
{
renderer.SetTransform(GlobalPosition, Vector2.Zero);
renderer.DrawRectangleOutline(new Vector2(Size.Width, Size.Height), Color.Red, 2.0f);
}
/// <summary>
/// Determines if this <see cref="UIElement"/> contains a point within its confines.
/// </summary>
/// <param name="pointPosition">A global position of the point.</param>
/// <returns>True if the point is inside the widget; otherwise, false.</returns>
public bool ContainsPoint(Vector2 point)
{
return point.X >= GlobalPosition.X && point.Y >= GlobalPosition.Y &&
point.X <= GlobalPosition.X + Size.Width &&
point.Y <= GlobalPosition.Y + Size.Height;
}
/// <summary>
/// Applies this <see cref="UIElement"/> anchor.
/// </summary>
public virtual void ApplyAnchor(Vector2 parentPosition, Rect parentRect)
{
LocalPosition = Anchor.Calculate(parentPosition, parentRect, Size) + new Vector2(AnchorOffset.X, AnchorOffset.Y);
}
/// <summary>
/// Helper method for determining if this <see cref="UIElement"/> has a specific dirty flag.
/// </summary>
/// <param name="flags"></param>
/// <returns></returns>
public bool HasDirty(DirtyFlags flags) =>
(_dirty & flags) != 0 || (_pendingDirty & flags) != 0;
/// <summary>
/// The layout-computed size of this UI element that doesn't trigger
/// dirty state propagation.
/// </summary>
/// <remarks>
/// This property is intended to be used exclusively by the layout system
/// (e.g. containers during <c>RecalculateSizes</c>).
///
/// Unlike the <see cref="Size"/> property setter, this property does not call
/// <c>MarkDirty</c>, and therefore will not trigger layout invalidation or
/// parent propagation.
/// </remarks>
protected Rect LayoutSize
{
get => _size; set => _size = value;
}
protected abstract void OnRender(RenderSystem renderer, Style style);
protected abstract void OnUpdate();
protected virtual void OnLayoutUpdate() { }
protected virtual void OnContentUpdate() { }
protected virtual void OnStyleUpdate() { }
protected abstract void OnUpdate(LayoutContext layoutContext);
/// <summary>
/// Renders a stylebox from a given style.
@@ -174,32 +229,6 @@ public abstract class UIElement : IElement, IRenderableElement, IResizeableEleme
}
}
public void DrawSize(RenderSystem renderer)
{
renderer.SetTransform(GlobalPosition, Vector2.Zero);
renderer.DrawRectangleOutline(new Vector2(Size.Width, Size.Height), Color.Red, 2.0f);
}
/// <summary>
/// Determines if this <see cref="UIElement"/> contains a point within its confines.
/// </summary>
/// <param name="pointPosition">A global position of the point.</param>
/// <returns>True if the point is inside the widget; otherwise, false.</returns>
public bool ContainsPoint(Vector2 point)
{
return point.X >= GlobalPosition.X && point.Y >= GlobalPosition.Y &&
point.X <= GlobalPosition.X + Size.Width &&
point.Y <= GlobalPosition.Y + Size.Height;
}
/// <summary>
/// Applies this <see cref="UIElement"/> anchor.
/// </summary>
public virtual void ApplyAnchor(Vector2 parentPosition, Rect parentRect)
{
LocalPosition = Anchor.Calculate(parentPosition, parentRect, Size) + new Vector2(AnchorOffset.X, AnchorOffset.Y);
}
private string ConstructStyleModifiers(string[]? modifiers)
{
if (modifiers == null)
@@ -225,7 +254,8 @@ public abstract class UIElement : IElement, IRenderableElement, IResizeableEleme
return $".{StyleVariant}";
}
private bool _dirty = true;
private DirtyFlags _dirty = DirtyFlags.Layout;
private DirtyFlags _pendingDirty = DirtyFlags.None;
private Rect _size = Rect.Zero;
private UIElement? _parent;

View File

@@ -1,7 +1,6 @@
using System.Numerics;
using Voile.Input;
using Voile.Rendering;
using Voile.Resources;
namespace Voile.UI;
@@ -33,10 +32,20 @@ public class UISystem : IUpdatableSystem, IRenderableSystem, IReloadableSystem
{
element.StyleSheetOverride = _styleSheet;
_elements.Add(element);
_inputElementIndices.Add(element.GlobalPosition, _elements.Count - 1);
}
public void RemoveElement(UIElement element) => _elements.Remove(element);
public void SetWindowSize(Vector2 size)
{
if (_windowSize == size)
return;
_windowSize = size;
foreach (var element in _elements)
element.MarkDirty(DirtyFlags.Layout);
}
public void Update(double deltaTime)
{
float dt = (float)deltaTime;
@@ -46,7 +55,11 @@ public class UISystem : IUpdatableSystem, IRenderableSystem, IReloadableSystem
foreach (var element in _elements)
{
if (element is not IUpdatableElement updatable) continue;
updatable.Update();
if (!updatable.Dirty)
continue;
updatable.Update(new LayoutContext(_windowSize));
}
}
@@ -213,11 +226,10 @@ public class UISystem : IUpdatableSystem, IRenderableSystem, IReloadableSystem
return false;
}
private Vector2 _windowSize;
private ResourceRef<StyleSheet> _styleSheet;
private List<UIElement> _elements = new();
private InputSystem _input;
private GridSet<int> _inputElementIndices = new();
private Vector2 _lastMousePosition = Vector2.Zero;
}

View File

@@ -23,7 +23,7 @@ public class Button : Widget
get => _text; set
{
_text = value;
MarkDirty();
MarkDirty(DirtyFlags.Content);
}
}
@@ -57,7 +57,6 @@ public class Button : Widget
FontSet.AddFont(fontOverride);
MarkDirty();
Update();
}
public Button(string text, FontSet fontSet)
@@ -67,7 +66,6 @@ public class Button : Widget
FontSet = fontSet;
MarkDirty();
Update();
}
public Button(string text, FontSet fontSet, Action pressedAction)
@@ -78,25 +76,23 @@ public class Button : Widget
FontSet = fontSet;
MarkDirty();
Update();
}
protected override void OnRender(RenderSystem renderer, Style style)
{
if (_padding != style.Padding)
{
MarkDirty();
}
_padding = style.Padding ?? Voile.Size.Zero;
var textColor = style.TextColor ?? Color.Black;
var textPosition = new Vector2(GlobalPosition.X + Padding.Left, GlobalPosition.Y + Padding.Top);
renderer.SetTransform(textPosition, Vector2.Zero);
var color = style.TextColor ?? Color.Black;
if (_suitableFont.HasValue)
var pos = new Vector2(
GlobalPosition.X + _padding.Left,
GlobalPosition.Y + _padding.Top);
renderer.SetTransform(pos, Vector2.Zero);
if (_font.HasValue)
{
renderer.DrawText(_suitableFont, _text, textColor);
renderer.DrawText(_font, _text, color);
}
}
@@ -139,39 +135,55 @@ public class Button : Widget
}
}
protected override void OnUpdate()
protected override void OnUpdate(LayoutContext layoutContext)
{
ResourceRef<Font> fontRef = ResourceRef<Font>.Empty();
ResolveFont();
foreach (var c in _text)
{
if (FontSet.TryGetFontFor(c, out var fallbackFont))
{
if (fallbackFont != fontRef)
{
fontRef = fallbackFont;
}
}
if (!_font.HasValue)
return;
var font = _font.Value;
var newSize = font.Measure(_text);
_textSize = newSize;
var final = _textSize + _padding;
if (_cachedSize.Width == final.Width &&
_cachedSize.Height == final.Height)
return;
_cachedSize = final;
Size = final;
MarkDirty(DirtyFlags.Layout);
}
if (fontRef.HasValue)
private void ResolveFont()
{
_suitableFont = fontRef;
if (_font.HasValue)
return;
var font = _suitableFont.Value;
_textSize = font.Measure(_text);
var text = _text;
for (int i = 0; i < text.Length; i++)
{
if (FontSet.TryGetFontFor(text[i], out var f))
{
_font = f;
break;
}
}
Size = _padding + _textSize;
}
private Action? _pressedAction;
private ResourceRef<Font> _suitableFont = ResourceRef<Font>.Empty();
private Rect _cachedSize = Rect.Zero;
private string _text = "Hello, World!";
private Rect _textSize = Rect.Zero;
private ResourceRef<Font> _font = ResourceRef<Font>.Empty();
private Size _padding;
private bool _isHeldDown;

View File

@@ -27,7 +27,8 @@ public class InputField : Widget, ITickableElement
{
_input = value ?? string.Empty;
_cursor = Math.Clamp(_cursor, 0, _input.Length);
MarkDirty();
MarkDirty(DirtyFlags.Content);
}
}
@@ -43,7 +44,6 @@ public class InputField : Widget, ITickableElement
FontSet = fontSet;
MarkDirty();
Update();
}
public void Tick(float dt, InputSystem input)
@@ -94,7 +94,8 @@ public class InputField : Widget, ITickableElement
{
_input = _input.Insert(_cursor, c.ToString());
_cursor++;
MarkDirty();
MarkDirty(DirtyFlags.Content);
}
context.SetHandled();
@@ -149,7 +150,7 @@ public class InputField : Widget, ITickableElement
{
_input = _input.Remove(_cursor - 1, 1);
_cursor--;
MarkDirty();
MarkDirty(DirtyFlags.Content);
}
break;
@@ -157,7 +158,7 @@ public class InputField : Widget, ITickableElement
if (_cursor > 0)
{
_cursor--;
MarkDirty();
MarkDirty(DirtyFlags.Content);
}
break;
@@ -165,7 +166,7 @@ public class InputField : Widget, ITickableElement
if (_cursor < _input.Length)
{
_cursor++;
MarkDirty();
MarkDirty(DirtyFlags.Content);
}
break;
}
@@ -178,97 +179,96 @@ public class InputField : Widget, ITickableElement
protected override void OnRender(RenderSystem renderer, Style style)
{
if (_padding != style.Padding)
{
MarkDirty();
}
_padding = style.Padding ?? Voile.Size.Zero;
var textColor = style.TextColor ?? Color.Black;
var caretColor = style.BorderColor ?? Color.Black;
var textPosition = new Vector2(GlobalPosition.X + _padding.Left, GlobalPosition.Y + _padding.Top);
renderer.SetTransform(textPosition, Vector2.Zero);
var pos = new Vector2(GlobalPosition.X + _padding.Left,
GlobalPosition.Y + _padding.Top);
// Placeholder
// TODO: use a color from the style instead of making it less transparent.
if (string.IsNullOrEmpty(_input) && !string.IsNullOrEmpty(PlaceholderText))
{
renderer.SetTransform(pos, Vector2.Zero);
string text = string.IsNullOrEmpty(_input)
? PlaceholderText
: _input;
// TODO: use a placeholder color from the style instead of making it lighter than the original text color.
var placeholderColor = textColor.Lightened(0.5f);
if (_suitableFont.HasValue)
{
renderer.DrawText(_suitableFont, PlaceholderText, placeholderColor);
}
}
else
{
if (_suitableFont.HasValue)
{
renderer.DrawText(_suitableFont, _input, textColor);
}
}
var color = string.IsNullOrEmpty(_input)
? placeholderColor
: textColor;
if (_font.HasValue)
renderer.DrawText(_font, text, color);
// Caret
if (_isFocused && _blink)
{
var caretX = MeasureTextWidth(_input.AsSpan(0, _cursor));
float caretX = GetCaretX(_cursor);
renderer.SetTransform(
new Vector2(GlobalPosition.X + caretX + _padding.Left, GlobalPosition.Y + _padding.Top),
Vector2.Zero
);
new Vector2(GlobalPosition.X + _padding.Left + caretX,
GlobalPosition.Y + _padding.Top),
Vector2.Zero);
var caretHeight = Math.Max(_placeholderSize.Height, _textSize.Height);
renderer.DrawRectangle(
new Vector2(1, caretHeight),
caretColor
);
float h = Math.Max(_textSize.Height, _placeholderSize.Height);
renderer.DrawRectangle(new Vector2(1, h), caretColor);
}
}
protected override void OnUpdate()
protected override void OnUpdate(LayoutContext layoutContext)
{
ResourceRef<Font> fontRef = ResourceRef<Font>.Empty();
if (!_font.HasValue)
ResolveFont();
if (!_font.HasValue)
return;
var font = _font.Value;
_textSize = font.Measure(_input);
if (_input.Length == 0)
_placeholderSize = font.Measure(PlaceholderText);
var content = Rect.MaxWidth(_textSize, _placeholderSize);
float width = content.Width + _padding.Left + _padding.Right;
float height = content.Height + _padding.Top + _padding.Bottom;
if (LayoutSize.Width == width && LayoutSize.Height == height)
return;
LayoutSize = new Rect(width, height);
MarkDirty(DirtyFlags.Layout);
}
private float GetCaretX(int index)
{
var span = _input.AsSpan(0, index);
if (!_font.HasValue)
return 0.0f;
return _font.Value.Measure(span).Width;
}
private void ResolveFont()
{
if (_font.HasValue)
return;
var text = string.IsNullOrEmpty(_input) ? PlaceholderText : _input;
foreach (var c in text)
{
if (FontSet.TryGetFontFor(c, out var fallbackFont))
{
fontRef = fallbackFont;
if (FontSet.TryGetFontFor(c, out var f))
_font = f;
}
}
if (fontRef.HasValue)
{
_suitableFont = fontRef;
var font = _suitableFont.Value;
_textSize = font.Measure(_input);
if (string.IsNullOrEmpty(_input))
_placeholderSize = font.Measure(PlaceholderText);
}
var size = Rect.MaxWidth(_placeholderSize, _textSize);
Size = _padding + size;
}
private float MeasureTextWidth(ReadOnlySpan<char> chars)
{
if (!_suitableFont.HasValue)
{
return 0.0f;
}
return _suitableFont.Value.Measure(chars).Width;
}
private float _repeatTimer = 0.0f;
private ActionType _lastActiveAction = ActionType.None;
private const float INITIAL_DELAY = 0.5f;
@@ -281,7 +281,7 @@ public class InputField : Widget, ITickableElement
private bool _blink = true;
private ResourceRef<Font> _suitableFont = ResourceRef<Font>.Empty();
private ResourceRef<Font> _font = ResourceRef<Font>.Empty();
private Size _padding = Voile.Size.Zero;
private Rect _textSize = Rect.Zero;

View File

@@ -32,7 +32,6 @@ public class Label : Widget
FontSet.AddFont(fontOverride);
MarkDirty();
Update();
}
public Label(string text, FontSet fontSet)
@@ -42,7 +41,6 @@ public class Label : Widget
FontSet = fontSet;
MarkDirty();
Update();
}
protected override void OnInput(UIInputContext action)
@@ -56,7 +54,7 @@ public class Label : Widget
renderer.DrawText(_suitableFont, _text, style.TextColor ?? Color.Black);
}
protected override void OnUpdate()
protected override void OnUpdate(LayoutContext layoutContext)
{
ResourceRef<Font> fontRef = ResourceRef<Font>.Empty();
foreach (var c in _text)

View File

@@ -65,7 +65,7 @@ public class RectangleWidget : Widget
}
}
protected override void OnUpdate()
protected override void OnUpdate(LayoutContext layoutContext)
{
}