From 3ba1f5192f3f52b20bc6a673539524d882de64e3 Mon Sep 17 00:00:00 2001 From: Andrei Gagua Date: Sat, 30 May 2026 23:54:52 +0300 Subject: [PATCH 01/31] Fix discarding unsaved asset editor changes - clear cached JsonAsset managed instances on discard, - restore MaterialInstance parameter values and override flags, - reload model assets from disk when model edits are discarded. --- .../Windows/Assets/AssetEditorWindow.cs | 12 +++++++++ .../Editor/Windows/Assets/JsonAssetWindow.cs | 8 ++++++ .../Windows/Assets/MaterialInstanceWindow.cs | 26 ++++++++++++++----- .../Editor/Windows/Assets/ModelBaseWindow.cs | 7 +++++ Source/Engine/Content/JsonAsset.cs | 10 +++++++ 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/Source/Editor/Windows/Assets/AssetEditorWindow.cs b/Source/Editor/Windows/Assets/AssetEditorWindow.cs index 93d6c850c..17cb5e16d 100644 --- a/Source/Editor/Windows/Assets/AssetEditorWindow.cs +++ b/Source/Editor/Windows/Assets/AssetEditorWindow.cs @@ -132,12 +132,24 @@ namespace FlaxEditor.Windows.Assets // Cancel closing return true; } + else + { + // Discard and close + DiscardChanges(); + } } } return base.OnClosing(reason); } + /// + /// Discards unsaved asset changes before closing the window. + /// + protected virtual void DiscardChanges() + { + } + /// protected override void OnClose() { diff --git a/Source/Editor/Windows/Assets/JsonAssetWindow.cs b/Source/Editor/Windows/Assets/JsonAssetWindow.cs index c2722ed9d..750ae853c 100644 --- a/Source/Editor/Windows/Assets/JsonAssetWindow.cs +++ b/Source/Editor/Windows/Assets/JsonAssetWindow.cs @@ -243,6 +243,14 @@ namespace FlaxEditor.Windows.Assets base.OnAssetLoaded(); } + /// + protected override void DiscardChanges() + { + Asset?.ClearInstance(); + _object = null; + base.DiscardChanges(); + } + private void OpenOptionsContextMenu() { if (_optionsCM != null) diff --git a/Source/Editor/Windows/Assets/MaterialInstanceWindow.cs b/Source/Editor/Windows/Assets/MaterialInstanceWindow.cs index cb39930f9..b58873b1e 100644 --- a/Source/Editor/Windows/Assets/MaterialInstanceWindow.cs +++ b/Source/Editor/Windows/Assets/MaterialInstanceWindow.cs @@ -64,8 +64,14 @@ namespace FlaxEditor.Windows.Assets [CustomEditor(typeof(ParametersEditor))] private sealed class PropertiesProxy { + private struct ParameterState + { + public object Value; + public bool IsOverride; + } + private MaterialBase _restoreBase; - private Dictionary _restoreParams; + private Dictionary _restoreParams; [EditorDisplay("General"), Tooltip("The base material used to override it's properties")] public MaterialBase BaseMaterial @@ -181,9 +187,16 @@ namespace FlaxEditor.Windows.Assets var material = Window.Asset; _restoreBase = material.BaseMaterial; var parameters = material.Parameters; - _restoreParams = new Dictionary(); + _restoreParams = new Dictionary(); for (int i = 0; i < parameters.Length; i++) - _restoreParams[parameters[i].Name] = parameters[i].Value; + { + var p = parameters[i]; + _restoreParams[p.Name] = new ParameterState + { + Value = p.Value, + IsOverride = p.IsOverride, + }; + } } /// @@ -191,7 +204,7 @@ namespace FlaxEditor.Windows.Assets /// public void DiscardChanges() { - if (Window == null) + if (Window == null || _restoreParams == null) return; var material = Window.Asset; @@ -200,9 +213,10 @@ namespace FlaxEditor.Windows.Assets for (int i = 0; i < parameters.Length; i++) { var p = parameters[i]; - if (p.IsPublic && _restoreParams.TryGetValue(p.Name, out var value)) + if (p.IsPublic && _restoreParams.TryGetValue(p.Name, out var state)) { - p.Value = value; + p.Value = state.Value; + p.IsOverride = state.IsOverride; } } } diff --git a/Source/Editor/Windows/Assets/ModelBaseWindow.cs b/Source/Editor/Windows/Assets/ModelBaseWindow.cs index 3046ea43b..97a04268d 100644 --- a/Source/Editor/Windows/Assets/ModelBaseWindow.cs +++ b/Source/Editor/Windows/Assets/ModelBaseWindow.cs @@ -834,6 +834,13 @@ namespace FlaxEditor.Windows.Assets base.OnAssetLoaded(); } + /// + protected override void DiscardChanges() + { + _asset?.Reload(); + base.DiscardChanges(); + } + /// public override void OnItemReimported(ContentItem item) { diff --git a/Source/Engine/Content/JsonAsset.cs b/Source/Engine/Content/JsonAsset.cs index 9d11a8c3c..35f6675d8 100644 --- a/Source/Engine/Content/JsonAsset.cs +++ b/Source/Engine/Content/JsonAsset.cs @@ -93,5 +93,15 @@ namespace FlaxEngine string str = instance != null ? JsonSerializer.Serialize(instance) : null; Data = str; } + +#if FLAX_EDITOR + /// + /// Clears the cached managed instance. The next access will recreate it from the asset data. + /// + public void ClearInstance() + { + _instance = null; + } +#endif } } From 752dc644e5a8fadb401fe25db49cace0c26b3707 Mon Sep 17 00:00:00 2001 From: Andrei Gagua Date: Fri, 12 Jun 2026 16:26:35 +0300 Subject: [PATCH 02/31] Improve Animation Scene curve keyframe editing Improves Animation Scene timeline curve editing, including vector keyframe selection/editing, tangent interaction, draggable edit popup, and several related editor UI fixes. --- .../Editor/GUI/ContextMenu/ContextMenuBase.cs | 46 +- Source/Editor/GUI/CurveEditor.Contents.cs | 252 +++++++-- Source/Editor/GUI/CurveEditor.cs | 502 +++++++++++++++++- Source/Editor/GUI/Timeline/GUI/Background.cs | 8 +- .../GUI/Timeline/GUI/KeyframesEditor.cs | 51 +- .../GUI/Timeline/Tracks/CurvePropertyTrack.cs | 2 +- .../Windows/Assets/SceneAnimationWindow.cs | 5 +- Source/Engine/UI/GUI/Panels/ScrollBar.cs | 2 + 8 files changed, 785 insertions(+), 83 deletions(-) diff --git a/Source/Editor/GUI/ContextMenu/ContextMenuBase.cs b/Source/Editor/GUI/ContextMenu/ContextMenuBase.cs index 3947430f9..8c89a38d0 100644 --- a/Source/Editor/GUI/ContextMenu/ContextMenuBase.cs +++ b/Source/Editor/GUI/ContextMenu/ContextMenuBase.cs @@ -55,6 +55,21 @@ namespace FlaxEditor.GUI.ContextMenu private Window _window; private Control _previouslyFocused; + private static bool IsFinite(float value) + { + return !float.IsNaN(value) && !float.IsInfinity(value); + } + + private static bool IsValidWindowBounds(Float2 location, Float2 size) + { + return IsFinite(location.X) && + IsFinite(location.Y) && + IsFinite(size.X) && + IsFinite(size.Y) && + size.X > 0.0f && + size.Y > 0.0f; + } + /// /// Gets a value indicating whether use automatic popup direction fix based on the screen dimensions. /// @@ -190,6 +205,11 @@ namespace FlaxEditor.GUI.ContextMenu var dpiSize = Size * dpiScale; var locationWS = parent.PointToWindow(location); var locationSS = parentWin.PointToScreen(locationWS); + if (!IsValidWindowBounds(locationSS, dpiSize)) + { + Hide(); + return; + } var monitorBounds = Platform.GetMonitorBounds(locationSS); var rightBottomLocationSS = locationSS + dpiSize; bool isUp = false, isLeft = false; @@ -403,7 +423,31 @@ namespace FlaxEditor.GUI.ContextMenu { if (_window != null) { - _window.ClientSize = Size * _window.DpiScale; + var size = Size * _window.DpiScale; + if (!IsValidWindowBounds(_window.ClientBounds.Location, size)) + return; + _window.ClientSize = size; + } + } + + /// + /// Gets the popup window location in screen coordinates. + /// + protected Float2 WindowLocation => _window != null ? _window.ClientBounds.Location : Float2.Zero; + + /// + /// Moves the popup window to the given screen-space location. + /// + /// The popup window location in screen coordinates. + protected void MoveWindowTo(Float2 screenLocation) + { + if (_window != null) + { + var bounds = _window.ClientBounds; + if (!IsValidWindowBounds(screenLocation, bounds.Size)) + return; + bounds.Location = screenLocation; + _window.ClientBounds = bounds; } } diff --git a/Source/Editor/GUI/CurveEditor.Contents.cs b/Source/Editor/GUI/CurveEditor.Contents.cs index 75f37d457..80db7fb27 100644 --- a/Source/Editor/GUI/CurveEditor.Contents.cs +++ b/Source/Editor/GUI/CurveEditor.Contents.cs @@ -19,6 +19,16 @@ namespace FlaxEditor.GUI /// protected class ContentsBase : ContainerControl { + private const float DragStartDistance = 1.5f; + private const float DragStartDistanceSquared = DragStartDistance * DragStartDistance; + + private enum SelectionMode + { + Replace, + Add, + Remove, + } + private readonly CurveEditor _editor; internal bool _leftMouseDown; private bool _rightMouseDown; @@ -46,6 +56,89 @@ namespace FlaxEditor.GUI _editor = editor; } + private KeyframePoint GetKeyframePointAt(Float2 location, bool cycle) + { + return GetKeyframePointAt(location, cycle, out _); + } + + private KeyframePoint GetKeyframePointAt(Float2 location, bool cycle, out bool isStacked) + { + KeyframePoint firstHit = null; + KeyframePoint selectedHit = null; + KeyframePoint nextHitAfterSelected = null; + int hitsCount = 0; + for (int i = 0; i < _editor._points.Count; i++) + { + var point = _editor._points[i]; + if (!point.Visible || !point.Bounds.Contains(ref location)) + continue; + + if (hitsCount == 0) + firstHit = point; + if (selectedHit != null && nextHitAfterSelected == null) + nextHitAfterSelected = point; + if (selectedHit == null && point.IsSelected) + selectedHit = point; + hitsCount++; + } + isStacked = hitsCount > 1; + if (hitsCount == 0) + return null; + if (hitsCount == 1) + return firstHit; + if (selectedHit != null) + return cycle ? nextHitAfterSelected ?? firstHit : selectedHit; + return firstHit; + } + + private static string GetComponentName(int component) + { + switch (component) + { + case 0: return "X"; + case 1: return "Y"; + case 2: return "Z"; + case 3: return "W"; + default: return (component + 1).ToString(); + } + } + + private void SelectKeyframePoint(KeyframePoint keyframe, bool addToSelection) + { + if (!addToSelection) + { + if (_editor.KeyframesEditorContext != null) + _editor.KeyframesEditorContext.OnKeyframesDeselect(_editor); + else + _editor.ClearSelection(); + } + keyframe.IsSelected = true; + if (_editor.ShowCollapsed) + { + for (int i = 0; i < _editor._points.Count; i++) + { + var point = _editor._points[i]; + if (point.Index == keyframe.Index) + point.IsSelected = true; + } + } + _editor.UpdateTangents(); + } + + private void SelectKeyframeComponent(int keyframeIndex, int component) + { + if (_editor.KeyframesEditorContext != null) + _editor.KeyframesEditorContext.OnKeyframesDeselect(_editor); + else + _editor.ClearSelection(); + for (int i = 0; i < _editor._points.Count; i++) + { + var point = _editor._points[i]; + point.IsSelected = point.Index == keyframeIndex && point.Component == component; + } + _editor.UpdateTangents(); + } + private void UpdateSelectionRectangle() { var selectionRect = Rectangle.FromPoints(_leftMouseDownPos, _mousePos); @@ -55,14 +148,41 @@ namespace FlaxEditor.GUI UpdateSelection(ref selectionRect); } + private SelectionMode GetSelectionMode() + { + if (Root.GetKey(KeyboardKeys.Alt)) + return SelectionMode.Remove; + if (Root.GetKey(KeyboardKeys.Shift)) + return SelectionMode.Add; + return SelectionMode.Replace; + } + internal void UpdateSelection(ref Rectangle selectionRect) { + var mode = GetSelectionMode(); + // Find controls to select var children = _children; for (int i = 0; i < children.Count; i++) { if (children[i] is KeyframePoint p) - p.IsSelected = p.Bounds.Intersects(ref selectionRect); + { + var intersects = p.Bounds.Intersects(ref selectionRect); + switch (mode) + { + case SelectionMode.Replace: + p.IsSelected = intersects; + break; + case SelectionMode.Add: + if (intersects) + p.IsSelected = true; + break; + case SelectionMode.Remove: + if (intersects) + p.IsSelected = false; + break; + } + } } _editor.UpdateTangents(); } @@ -82,19 +202,15 @@ namespace FlaxEditor.GUI _editor.OnEditingStart(); } - internal void OnMove(Float2 location) + private bool MoveSelectedKeyframes(Float2 location) { - // Skip updating keyframes until move actual starts to be meaningful - if (Float2.Distance(ref _movingSelectionStartPosLock, ref location) < 1.5f) - return; - _movingSelectionStartPosLock = Float2.Minimum; - var viewRect = _editor._mainPanel.GetClientArea(); var locationKeyframes = PointToKeyframes(location, ref viewRect); var accessor = _editor.Accessor; var components = accessor.GetCurveComponents(); var snapEnabled = Root.GetKey(KeyboardKeys.Control); var snapGrid = snapEnabled ? _editor.GetGridSnap() : Float2.One; + var moved = false; for (var i = 0; i < _editor._points.Count; i++) { var p = _editor._points[i]; @@ -154,7 +270,23 @@ namespace FlaxEditor.GUI } _editor.SetKeyframeInternal(p.Index, time, value, p.Component); + moved = true; } + } + + return moved; + } + + internal void OnMove(Float2 location) + { + // Skip updating keyframes until move actual starts to be meaningful + if (Float2.Distance(ref _movingSelectionStartPosLock, ref location) < 1.5f) + return; + _movingSelectionStartPosLock = Float2.Minimum; + + var moved = MoveSelectedKeyframes(location); + if (moved) + { _editor.UpdateKeyframes(); _editor.UpdateTooltips(); if (_editor.EnablePanning == UseMode.On) @@ -168,13 +300,15 @@ namespace FlaxEditor.GUI internal void OnMoveEnd(Float2 location) { + _isMovingSelection = false; if (_movedKeyframes) { _editor.OnEdited(); _editor.OnEditingEnd(); + _editor.UpdateKeyframes(); + _editor.UpdateTooltips(); _movedKeyframes = false; } - _isMovingSelection = false; } /// @@ -199,26 +333,32 @@ namespace FlaxEditor.GUI _mousePos = location; // Start moving selection if movement started from the keyframe - if (_leftMouseDown && !_isMovingSelection && GetChildAt(_leftMouseDownPos) is KeyframePoint) + var leftMouseDownOverKeyframe = _leftMouseDown && GetKeyframePointAt(_leftMouseDownPos, false) != null; + if (leftMouseDownOverKeyframe && !_isMovingSelection) { + if (Float2.DistanceSquared(ref _leftMouseDownPos, ref location) < DragStartDistanceSquared) + return; + if (_editor.KeyframesEditorContext != null) - _editor.KeyframesEditorContext.OnKeyframesMove(_editor, this, location, true, false); + _editor.KeyframesEditorContext.OnKeyframesMove(_editor, this, _leftMouseDownPos, true, false); else - OnMoveStart(location); + OnMoveStart(_leftMouseDownPos); } // Moving view if (_rightMouseDown) { - var movingViewPos = Parent.PointToParent(PointToParent(location)); - var delta = movingViewPos - _movingViewLastPos; - if (_editor.CustomViewPanning != null) - delta = _editor.CustomViewPanning(delta); - delta *= GetUseModeMask(_editor.EnablePanning); - if (delta.LengthSquared > 0.01f) + var mousePosition = Root.MousePosition; + var mouseDelta = mousePosition - _movingViewLastPos; + if (mouseDelta.LengthSquared > 0.01f) { - _editor._mainPanel.ViewOffset += delta; - _movingViewLastPos = movingViewPos; + var delta = mouseDelta; + if (_editor.CustomViewPanning != null) + delta = _editor.CustomViewPanning(delta); + var viewDelta = delta * GetUseModeMask(_editor.EnablePanning); + if (viewDelta.LengthSquared > 0.0f) + _editor.ViewOffset += viewDelta; + _movingViewLastPos = mousePosition; _movedView = true; if (_editor.CustomViewPanning != null) { @@ -257,7 +397,7 @@ namespace FlaxEditor.GUI var tangent = PointToKeyframes(location, ref viewRect).Y - value; if (Root.GetKey(KeyboardKeys.Control)) tangent = Float2.SnapToGrid(new Float2(0, tangent), _editor.GetGridSnap()).Y; // Snap tangent over Y axis - tangent = tangent * _editor.ViewScale.X * 2; + tangent = tangent * UnitsPerSecond / _movingTangent.TangentOffset; _movingTangent.TangentValue = tangent; _editor.UpdateTangents(); Cursor = CursorType.SizeNS; @@ -265,7 +405,7 @@ namespace FlaxEditor.GUI return; } // Selecting - else if (_leftMouseDown) + else if (_leftMouseDown && !leftMouseDownOverKeyframe) { UpdateSelectionRectangle(); return; @@ -321,12 +461,13 @@ namespace FlaxEditor.GUI _rightMouseDown = true; _rightMouseDownPos = location; _movedView = false; - _movingViewLastPos = Parent.PointToParent(PointToParent(location)); + _movingViewLastPos = Root.MousePosition; } // Check if any node is under the mouse var underMouse = GetChildAt(location); - if (underMouse is KeyframePoint keyframe) + var keyframe = underMouse is KeyframePoint ? GetKeyframePointAt(location, false) : null; + if (keyframe != null) { if (_leftMouseDown) { @@ -355,12 +496,7 @@ namespace FlaxEditor.GUI { // Select node if (!Root.GetKey(KeyboardKeys.Control)) - { - if (_editor.KeyframesEditorContext != null) - _editor.KeyframesEditorContext.OnKeyframesDeselect(_editor); - else - _editor.ClearSelection(); - } + SelectKeyframePoint(keyframe, false); _toggledSelection = true; keyframe.IsSelected = true; _editor.UpdateTangents(); @@ -402,11 +538,14 @@ namespace FlaxEditor.GUI { // Start selecting StartMouseCapture(); - if (_editor.KeyframesEditorContext != null) - _editor.KeyframesEditorContext.OnKeyframesDeselect(_editor); - else - _editor.ClearSelection(); - _editor.UpdateTangents(); + if (GetSelectionMode() == SelectionMode.Replace) + { + if (_editor.KeyframesEditorContext != null) + _editor.KeyframesEditorContext.OnKeyframesDeselect(_editor); + else + _editor.ClearSelection(); + _editor.UpdateTangents(); + } Focus(); return true; } @@ -453,11 +592,21 @@ namespace FlaxEditor.GUI OnMoveEnd(location); } // Toggle selection - else if (!_toggledSelection && Root.GetKey(KeyboardKeys.Control) && GetChildAt(location) is KeyframePoint keyframe) + else if (!_toggledSelection && Root.GetKey(KeyboardKeys.Control) && GetKeyframePointAt(location, false) is KeyframePoint keyframe) { keyframe.IsSelected = !keyframe.IsSelected; _editor.UpdateTangents(); } + // Select next stacked keyframe component only after a click has completed, not before a possible drag. + else if (!_toggledSelection && !Root.GetKey(KeyboardKeys.Control) && !Root.GetKey(KeyboardKeys.Shift) && + Float2.DistanceSquared(ref _leftMouseDownPos, ref location) < DragStartDistanceSquared && + GetKeyframePointAt(_leftMouseDownPos, false, out var mouseDownStacked) != null && + mouseDownStacked && + GetKeyframePointAt(location, true, out var mouseUpStacked) is KeyframePoint clickedKeyframe && + mouseUpStacked) + { + SelectKeyframePoint(clickedKeyframe, false); + } _isMovingSelection = false; _isMovingTangent = false; @@ -473,22 +622,12 @@ namespace FlaxEditor.GUI if (!_movedView) { var selectionCount = _editor.SelectionCount; - var point = GetChildAt(location) as KeyframePoint; - if (selectionCount == 0 && point != null) + var point = GetKeyframePointAt(location, false); + if (point != null && (selectionCount == 0 || !point.IsSelected)) { // Select node selectionCount = 1; - point.IsSelected = true; - if (_editor.ShowCollapsed) - { - for (int i = 0; i < _editor._points.Count; i++) - { - var p = _editor._points[i]; - if (p.Index == point.Index) - p.IsSelected = point.IsSelected; - } - } - _editor.UpdateTangents(); + SelectKeyframePoint(point, false); } var viewRect = _editor._mainPanel.GetClientArea(); @@ -500,6 +639,16 @@ namespace FlaxEditor.GUI { cm.AddButton(selectionCount == 1 ? "Edit keyframe" : "Edit keyframes", () => _editor.EditKeyframes(this, location)); } + var components = _editor.Accessor.GetCurveComponents(); + if (point != null && !_editor.ShowCollapsed && components > 1) + { + var componentMenu = cm.AddChildMenu("Select component"); + for (int i = 0; i < components; i++) + { + var component = i; + componentMenu.ContextMenu.AddButton(GetComponentName(component), () => SelectKeyframeComponent(point.Index, component)); + } + } var totalSelectionCount = _editor.KeyframesEditorContext?.OnKeyframesSelectionCount() ?? selectionCount; if (totalSelectionCount > 0) { @@ -556,7 +705,7 @@ namespace FlaxEditor.GUI // Add keyframe on double click var child = GetChildAt(location); - if (child is not KeyframePoint && + if (GetKeyframePointAt(location, false) == null && child is not TangentPoint && _editor.KeyframesCount < _editor.MaxKeyframes) { @@ -588,7 +737,10 @@ namespace FlaxEditor.GUI // Scale relative to the curve size var scale = new Float2(delta * 0.1f); _editor._mainPanel.GetDesireClientArea(out var mainPanelArea); - var curveScale = mainPanelArea.Size / _editor._contents.Size; + var contentsSize = _editor._contents.Size; + var curveScale = new Float2( + GetSafeZoomRatio(mainPanelArea.Width, contentsSize.X), + GetSafeZoomRatio(mainPanelArea.Height, contentsSize.Y)); scale *= curveScale; if (zoomAlt) scale.X = 0; // Scale Y axis only diff --git a/Source/Editor/GUI/CurveEditor.cs b/Source/Editor/GUI/CurveEditor.cs index 4fb727ea1..7d7bb93a2 100644 --- a/Source/Editor/GUI/CurveEditor.cs +++ b/Source/Editor/GUI/CurveEditor.cs @@ -63,17 +63,29 @@ namespace FlaxEditor.GUI private class Popup : ContextMenuBase { + private const float HeaderHeight = 12.0f; + private const float MinContentHeight = 120.0f; + private CustomEditorPresenter _presenter; private CurveEditor _editor; private List _keyframeIndices; + private Panel _panel; private bool _isDirty; + private bool _isDragging; + private Float2 _dragStartScreenPos; + private Float2 _dragStartWindowPos; public Popup(CurveEditor editor, object[] selection, List keyframeIndices = null, float maxHeight = 140.0f) : this(editor, maxHeight) { _presenter.Select(selection); _presenter.OpenAllGroups(); - Size = new Float2(Size.X, Mathf.Min(_presenter.ContainerControl.Size.Y, maxHeight)); + var maxContentHeight = Mathf.Max(maxHeight - HeaderHeight, 1.0f); + var desiredHeight = _presenter.ContainerControl.Size.Y; + if (desiredHeight <= 1.0f) + desiredHeight = maxContentHeight; + Size = new Float2(Size.X, HeaderHeight + Mathf.Min(Mathf.Max(desiredHeight, MinContentHeight), maxContentHeight)); + UpdateContentBounds(); _keyframeIndices = keyframeIndices; if (keyframeIndices != null && selection.Length != keyframeIndices.Count) throw new Exception(); @@ -84,18 +96,39 @@ namespace FlaxEditor.GUI _editor = editor; const float width = 340.0f; Size = new Float2(width, height); - var panel1 = new Panel(ScrollBars.Vertical) + _panel = new Panel(ScrollBars.Vertical) { - Bounds = new Rectangle(0, 0.0f, width, height), + Bounds = new Rectangle(0, HeaderHeight, width, Mathf.Max(height - HeaderHeight, 1.0f)), Parent = this }; _presenter = new CustomEditorPresenter(null); _presenter.Panel.AnchorPreset = AnchorPresets.HorizontalStretchTop; _presenter.Panel.IsScrollable = true; - _presenter.Panel.Parent = panel1; + _presenter.Panel.Parent = _panel; _presenter.Modified += OnModified; } + private void UpdateContentBounds() + { + if (_panel != null) + _panel.Bounds = new Rectangle(0, HeaderHeight, Width, Mathf.Max(Height - HeaderHeight, 1.0f)); + } + + private bool IsOverHeader(ref Float2 location) + { + return location.X >= 0.0f && location.X <= Width && location.Y >= 0.0f && location.Y <= HeaderHeight; + } + + private void EndDragging() + { + if (_isDragging) + { + _isDragging = false; + Cursor = CursorType.Default; + EndMouseCapture(); + } + } + private void OnModified() { if (!_isDirty) @@ -108,7 +141,7 @@ namespace FlaxEditor.GUI { for (int i = 0; i < _presenter.SelectionCount; i++) { - _editor.SetKeyframeInternal(_keyframeIndices[i], _presenter.Selection[i]); + _editor.SetKeyframeInternal(_keyframeIndices[i], _editor.GetKeyframeFromEditingProxy(_presenter.Selection[i])); } } else if (_presenter.Selection[0] is IAllKeyframesProxy proxy) @@ -129,12 +162,31 @@ namespace FlaxEditor.GUI base.OnShow(); } + /// + public override void Draw() + { + base.Draw(); + + var style = Style.Current; + Render2D.FillRectangle(new Rectangle(0, 0, Width, HeaderHeight), style.BackgroundHighlighted); + Render2D.FillRectangle(new Rectangle(0, HeaderHeight - 1.0f, Width, 1.0f), style.Background); + } + + /// + protected override void OnSizeChanged() + { + base.OnSizeChanged(); + + UpdateContentBounds(); + } + /// public override void Hide() { if (!Visible) return; + EndDragging(); Focus(null); if (_isDirty) @@ -145,13 +197,87 @@ namespace FlaxEditor.GUI if (_editor._popup == this) _editor._popup = null; + _presenter.Modified -= OnModified; _presenter = null; _editor = null; _keyframeIndices = null; + _panel = null; base.Hide(); } + /// + public override bool OnMouseDown(Float2 location, MouseButton button) + { + if (button == MouseButton.Left && IsOverHeader(ref location)) + { + _isDragging = true; + _dragStartScreenPos = FlaxEngine.Input.MouseScreenPosition; + _dragStartWindowPos = WindowLocation; + Cursor = CursorType.SizeAll; + StartMouseCapture(); + Focus(); + return true; + } + + return base.OnMouseDown(location, button); + } + + /// + public override void OnMouseMove(Float2 location) + { + if (_isDragging) + { + var screenPos = FlaxEngine.Input.MouseScreenPosition; + MoveWindowTo(_dragStartWindowPos + screenPos - _dragStartScreenPos); + Cursor = CursorType.SizeAll; + return; + } + + Cursor = IsOverHeader(ref location) ? CursorType.SizeAll : CursorType.Default; + + base.OnMouseMove(location); + } + + /// + public override bool OnMouseUp(Float2 location, MouseButton button) + { + if (button == MouseButton.Left && _isDragging) + { + EndDragging(); + return true; + } + + return base.OnMouseUp(location, button); + } + + /// + public override void OnMouseLeave() + { + if (!_isDragging) + Cursor = CursorType.Default; + + base.OnMouseLeave(); + } + + /// + public override void OnLostFocus() + { + EndDragging(); + Cursor = CursorType.Default; + + base.OnLostFocus(); + } + + /// + public override void OnEndMouseCapture() + { + _isDragging = false; + Cursor = CursorType.Default; + + base.OnEndMouseCapture(); + } + /// public override bool OnKeyDown(KeyboardKeys key) { @@ -208,7 +334,8 @@ namespace FlaxEditor.GUI { var style = Style.Current; var rect = new Rectangle(Float2.Zero, Size); - var color = Editor.ShowCollapsed ? style.ForegroundDisabled : Editor.Colors[Component]; + var axisColor = Editor.ShowCollapsed ? style.ForegroundDisabled : Editor.Colors[Component]; + var color = axisColor; if (IsSelected) color = Editor.ContainsFocus ? style.SelectionBorder : Color.Lerp(style.ForegroundDisabled, style.SelectionBorder, 0.4f); if (IsMouseOver) @@ -288,22 +415,14 @@ namespace FlaxEditor.GUI set => Editor.SetKeyframeTangentInternal(Index, IsIn, Component, value); } - internal float TangentOffset => 50.0f / Editor.ViewScale.X; + private const float TangentVisualOffset = 50.0f; + + internal float TangentOffset => TangentVisualOffset / Editor.ViewScale.X; /// public override void Draw() { - var style = Style.Current; - var thickness = 6.0f / Mathf.Max(Editor.ViewScale.X, 1.0f); - var size = Size; - var pointPos = PointFromParent(Point.Center); - Render2D.DrawLine(size * 0.5f, pointPos, style.ForegroundDisabled, thickness); - - var rect = new Rectangle(Float2.Zero, size); - var color = style.BorderSelected; - if (IsMouseOver) - color *= 1.1f; - Render2D.FillRectangle(rect, color); + // Drawn by the editor overlay to keep a constant screen-space size. } /// @@ -436,12 +555,41 @@ namespace FlaxEditor.GUI /// protected readonly TangentPoint[] _tangents = new TangentPoint[2]; + private static bool IsFinite(float value) + { + return !float.IsNaN(value) && !float.IsInfinity(value); + } + + /// + /// Returns the input value if finite, otherwise returns a finite fallback or zero. + /// + protected static float SanitizeFinite(float value, float fallback) + { + if (IsFinite(value)) + return value; + return IsFinite(fallback) ? fallback : 0.0f; + } + + private static float SanitizeViewScale(float value, float fallback) + { + if (!IsFinite(value)) + value = IsFinite(fallback) ? fallback : 1.0f; + return Mathf.Clamp(value, 0.0001f, 1000.0f); + } + + private static float GetSafeZoomRatio(float viewSize, float contentsSize) + { + return IsFinite(viewSize) && IsFinite(contentsSize) && viewSize > Mathf.Epsilon && contentsSize > Mathf.Epsilon ? viewSize / contentsSize : 0.0f; + } + /// public override Float2 ViewOffset { get => _mainPanel.ViewOffset; set { + value.X = SanitizeFinite(value.X, _mainPanel.ViewOffset.X); + value.Y = SanitizeFinite(value.Y, _mainPanel.ViewOffset.Y); _mainPanel.ViewOffset = value; _mainPanel.FastScroll(); } @@ -451,7 +599,13 @@ namespace FlaxEditor.GUI public override Float2 ViewScale { get => _contents.Scale; - set => _contents.Scale = Float2.Clamp(value, new Float2(0.0001f), new Float2(1000.0f)); + set + { + var scale = _contents.Scale; + value.X = SanitizeViewScale(value.X, scale.X); + value.Y = SanitizeViewScale(value.Y, scale.Y); + _contents.Scale = value; + } } /// @@ -705,6 +859,36 @@ namespace FlaxEditor.GUI /// The proxy object. protected abstract IAllKeyframesProxy GetAllKeyframesEditingProxy(); + /// + /// Creates an editing proxy for a single keyframe. + /// + /// The keyframe index. + /// The keyframe. + /// The keyframe editing proxy. + protected virtual object CreateKeyframeEditingProxy(int index, object keyframe) + { + return keyframe; + } + + /// + /// Gets keyframe data from its editing proxy. + /// + /// The keyframe editing proxy. + /// The keyframe data. + protected virtual object GetKeyframeFromEditingProxy(object proxy) + { + return proxy; + } + + /// + /// Gets the maximum height for keyframe editing popup. + /// + /// The maximum popup height. + protected virtual float GetKeyframeEditingPopupHeight() + { + return 320.0f; + } + /// /// Interface for keyframes editing proxy objects. /// @@ -740,8 +924,8 @@ namespace FlaxEditor.GUI var selection = new object[keyframeIndices.Count]; var keyframes = GetKeyframes(); for (int i = 0; i < keyframeIndices.Count; i++) - selection[i] = keyframes[keyframeIndices[i]]; - _popup = new Popup(this, selection, keyframeIndices); + selection[i] = CreateKeyframeEditingProxy(keyframeIndices[i], keyframes[keyframeIndices[i]]); + _popup = new Popup(this, selection, keyframeIndices, GetKeyframeEditingPopupHeight()); _popup.Show(control, pos); } @@ -1049,6 +1233,70 @@ namespace FlaxEditor.GUI /// The main panel client area used as a view bounds. protected abstract void DrawCurve(ref Rectangle viewRect); + private Float2 ContentsToEditor(Float2 location) + { + location = _contents.PointToParent(location); + return _mainPanel.PointToParent(location); + } + + private Float2 GetControlCenterInEditor(Control control) + { + var center = control.PointToParent(control.Size * 0.5f); + return ContentsToEditor(center); + } + + private void DrawTangentHandles() + { + var style = Style.Current; + for (int i = 0; i < _tangents.Length; i++) + { + var tangent = _tangents[i]; + if (!tangent.Visible || tangent.Point == null || !tangent.Point.Visible) + continue; + + var tangentCenter = GetControlCenterInEditor(tangent); + var keyframeCenter = GetControlCenterInEditor(tangent.Point); + Render2D.DrawLine(tangentCenter, keyframeCenter, style.ForegroundDisabled, 2.0f); + + var rect = new Rectangle(tangentCenter - KeyframesSize * 0.5f, KeyframesSize); + var color = style.BorderSelected; + if (tangent.IsMouseOver) + color *= 1.1f; + Render2D.FillRectangle(rect, color); + } + } + + private void DrawSelectedKeyframeLabels() + { + if (ShowCollapsed) + return; + + var style = Style.Current; + for (int i = 0; i < _points.Count; i++) + { + var point = _points[i]; + if (!point.Visible || !point.IsSelected) + continue; + + var center = GetControlCenterInEditor(point); + var label = GetComponentLabel(point.Component); + var labelRect = new Rectangle(center.X + 12.0f, center.Y - 22.0f, 34.0f, 28.0f); + Render2D.DrawText(style.FontMedium, label, labelRect, Colors[point.Component], TextAlignment.Near, TextAlignment.Center, TextWrapping.NoWrap, 1.0f, 1.25f); + } + } + + private static string GetComponentLabel(int component) + { + switch (component) + { + case 0: return "x"; + case 1: return "y"; + case 2: return "z"; + case 3: return "w"; + default: return (component + 1).ToString(); + } + } + /// public override void Draw() { @@ -1097,14 +1345,18 @@ namespace FlaxEditor.GUI { var selectionRect = Rectangle.FromPoints ( - _mainPanel.PointToParent(_contents.PointToParent(_contents._leftMouseDownPos)), - _mainPanel.PointToParent(_contents.PointToParent(_contents._mousePos)) + ContentsToEditor(_contents._leftMouseDownPos), + ContentsToEditor(_contents._mousePos) ); Render2D.FillRectangle(selectionRect, style.Selection); Render2D.DrawRectangle(selectionRect, style.SelectionBorder); } base.Draw(); + Render2D.PushClip(ref viewRect); + DrawTangentHandles(); + DrawSelectedKeyframeLabels(); + Render2D.PopClip(); // Draw border if (ContainsFocus) @@ -1465,6 +1717,85 @@ namespace FlaxEditor.GUI } } + sealed class KeyframeProxy + { + [HideInEditor, NoSerialize] + public LinearCurveEditor Editor; + + [HideInEditor, NoSerialize] + public int Index; + + private float _time; + private readonly float _originalTime; + + [EditorDisplay("Time"), EditorOrder(0), VisibleIf(nameof(HasFPS))] + [Tooltip("The keyframe frame number.")] + public int Frame + { + get => HasFPS ? Mathf.FloorToInt(SanitizeFinite(_time, _originalTime) * Editor.FPS.Value) : 0; + set + { + if (Editor?.FPS.HasValue == true && Editor.FPS.Value > Mathf.Epsilon) + _time = value / Editor.FPS.Value; + } + } + + [EditorDisplay("Time"), EditorOrder(1), Limit(float.MinValue, float.MaxValue, 0.01f)] + [Tooltip("The time of the keyframe.")] + public float Time + { + get => _time; + set => _time = SanitizeFinite(value, _time); + } + + [EditorDisplay("Value"), EditorOrder(10), Limit(float.MinValue, float.MaxValue, 0.01f)] + [Tooltip("The value of the curve at keyframe.")] + public T Value; + + private bool HasFPS => Editor?.FPS.HasValue == true && Editor.FPS.Value > Mathf.Epsilon; + + public KeyframeProxy(LinearCurveEditor editor, int index, LinearCurve.Keyframe keyframe) + { + Editor = editor; + Index = index; + _time = _originalTime = keyframe.Time; + Value = keyframe.Value; + } + + private float GetValidTime() + { + if (Editor == null) + return SanitizeFinite(_time, _originalTime); + if (Editor.FPS.HasValue) + { + var fps = Editor.FPS.Value; + if (fps <= Mathf.Epsilon) + return SanitizeFinite(_time, _originalTime); + var frame = Mathf.FloorToInt(SanitizeFinite(_time, _originalTime) * fps); + var minFrame = int.MinValue; + var maxFrame = int.MaxValue; + if (Index > 0) + minFrame = Mathf.FloorToInt(Editor._keyframes[Index - 1].Time * fps) + 1; + if (Index < Editor._keyframes.Count - 1) + maxFrame = Mathf.FloorToInt(Editor._keyframes[Index + 1].Time * fps) - 1; + if (minFrame > maxFrame) + return _originalTime; + frame = Mathf.Clamp(frame, minFrame, maxFrame); + return frame / fps; + } + var minTime = Index > 0 ? Editor._keyframes[Index - 1].Time + Mathf.Epsilon : float.MinValue; + var maxTime = Index < Editor._keyframes.Count - 1 ? Editor._keyframes[Index + 1].Time - Mathf.Epsilon : float.MaxValue; + if (minTime > maxTime) + return _originalTime; + return Mathf.Clamp(SanitizeFinite(_time, _originalTime), minTime, maxTime); + } + + public LinearCurve.Keyframe ToKeyframe() + { + return new LinearCurve.Keyframe(GetValidTime(), Value); + } + } + sealed class AllKeyframesProxy : IAllKeyframesProxy { [HideInEditor, NoSerialize] @@ -1489,6 +1820,24 @@ namespace FlaxEditor.GUI }; } + /// + protected override object CreateKeyframeEditingProxy(int index, object keyframe) + { + return new KeyframeProxy(this, index, (LinearCurve.Keyframe)keyframe); + } + + /// + protected override object GetKeyframeFromEditingProxy(object proxy) + { + return proxy is KeyframeProxy keyframeProxy ? keyframeProxy.ToKeyframe() : proxy; + } + + /// + protected override float GetKeyframeEditingPopupHeight() + { + return 240.0f; + } + /// public override object[] GetKeyframes() { @@ -2213,6 +2562,95 @@ namespace FlaxEditor.GUI } } + sealed class KeyframeProxy + { + [HideInEditor, NoSerialize] + public BezierCurveEditor Editor; + + [HideInEditor, NoSerialize] + public int Index; + + private float _time; + private readonly float _originalTime; + + [EditorDisplay("Time"), EditorOrder(0), VisibleIf(nameof(HasFPS))] + [Tooltip("The keyframe frame number.")] + public int Frame + { + get => HasFPS ? Mathf.FloorToInt(SanitizeFinite(_time, _originalTime) * Editor.FPS.Value) : 0; + set + { + if (Editor?.FPS.HasValue == true && Editor.FPS.Value > Mathf.Epsilon) + _time = value / Editor.FPS.Value; + } + } + + [EditorDisplay("Time"), EditorOrder(1), Limit(float.MinValue, float.MaxValue, 0.01f)] + [Tooltip("The time of the keyframe.")] + public float Time + { + get => _time; + set => _time = SanitizeFinite(value, _time); + } + + [EditorDisplay("Value"), EditorOrder(10), Limit(float.MinValue, float.MaxValue, 0.01f)] + [Tooltip("The value of the curve at keyframe.")] + public T Value; + + [EditorDisplay("Tangents", "Tangent In"), EditorOrder(20), Limit(float.MinValue, float.MaxValue, 0.01f)] + [Tooltip("The input tangent (going from the previous key to this one) of the key.")] + public T TangentIn; + + [EditorDisplay("Tangents", "Tangent Out"), EditorOrder(21), Limit(float.MinValue, float.MaxValue, 0.01f)] + [Tooltip("The output tangent (going from this key to next one) of the key.")] + public T TangentOut; + + private bool HasFPS => Editor?.FPS.HasValue == true && Editor.FPS.Value > Mathf.Epsilon; + + public KeyframeProxy(BezierCurveEditor editor, int index, BezierCurve.Keyframe keyframe) + { + Editor = editor; + Index = index; + _time = _originalTime = keyframe.Time; + Value = keyframe.Value; + TangentIn = keyframe.TangentIn; + TangentOut = keyframe.TangentOut; + } + + private float GetValidTime() + { + if (Editor == null) + return SanitizeFinite(_time, _originalTime); + if (Editor.FPS.HasValue) + { + var fps = Editor.FPS.Value; + if (fps <= Mathf.Epsilon) + return SanitizeFinite(_time, _originalTime); + var frame = Mathf.FloorToInt(SanitizeFinite(_time, _originalTime) * fps); + var minFrame = int.MinValue; + var maxFrame = int.MaxValue; + if (Index > 0) + minFrame = Mathf.FloorToInt(Editor._keyframes[Index - 1].Time * fps) + 1; + if (Index < Editor._keyframes.Count - 1) + maxFrame = Mathf.FloorToInt(Editor._keyframes[Index + 1].Time * fps) - 1; + if (minFrame > maxFrame) + return _originalTime; + frame = Mathf.Clamp(frame, minFrame, maxFrame); + return frame / fps; + } + var minTime = Index > 0 ? Editor._keyframes[Index - 1].Time + Mathf.Epsilon : float.MinValue; + var maxTime = Index < Editor._keyframes.Count - 1 ? Editor._keyframes[Index + 1].Time - Mathf.Epsilon : float.MaxValue; + if (minTime > maxTime) + return _originalTime; + return Mathf.Clamp(SanitizeFinite(_time, _originalTime), minTime, maxTime); + } + + public BezierCurve.Keyframe ToKeyframe() + { + return new BezierCurve.Keyframe(GetValidTime(), Value, TangentIn, TangentOut); + } + } + sealed class AllKeyframesProxy : IAllKeyframesProxy { [HideInEditor, NoSerialize] @@ -2237,6 +2675,24 @@ namespace FlaxEditor.GUI }; } + /// + protected override object CreateKeyframeEditingProxy(int index, object keyframe) + { + return new KeyframeProxy(this, index, (BezierCurve.Keyframe)keyframe); + } + + /// + protected override object GetKeyframeFromEditingProxy(object proxy) + { + return proxy is KeyframeProxy keyframeProxy ? keyframeProxy.ToKeyframe() : proxy; + } + + /// + protected override float GetKeyframeEditingPopupHeight() + { + return 320.0f; + } + /// public override object[] GetKeyframes() { diff --git a/Source/Editor/GUI/Timeline/GUI/Background.cs b/Source/Editor/GUI/Timeline/GUI/Background.cs index ed9b8150b..d15e307af 100644 --- a/Source/Editor/GUI/Timeline/GUI/Background.cs +++ b/Source/Editor/GUI/Timeline/GUI/Background.cs @@ -36,6 +36,11 @@ namespace FlaxEditor.GUI.Timeline.GUI _timeline.OnKeyframesSelection(null, this, selectionRect); } + private bool HasSelectionModifier() + { + return Root.GetKey(KeyboardKeys.Shift) || Root.GetKey(KeyboardKeys.Alt); + } + /// public override bool OnMouseDown(Float2 location, MouseButton button) { @@ -48,7 +53,8 @@ namespace FlaxEditor.GUI.Timeline.GUI // Start selecting _isSelecting = true; _selectingStartPos = location; - _timeline.OnKeyframesDeselect(null); + if (!HasSelectionModifier()) + _timeline.OnKeyframesDeselect(null); Focus(); StartMouseCapture(); return true; diff --git a/Source/Editor/GUI/Timeline/GUI/KeyframesEditor.cs b/Source/Editor/GUI/Timeline/GUI/KeyframesEditor.cs index d6fa0e076..7b2500799 100644 --- a/Source/Editor/GUI/Timeline/GUI/KeyframesEditor.cs +++ b/Source/Editor/GUI/Timeline/GUI/KeyframesEditor.cs @@ -77,6 +77,13 @@ namespace FlaxEditor.GUI /// private class Contents : ContainerControl { + private enum SelectionMode + { + Replace, + Add, + Remove, + } + private readonly KeyframesEditor _editor; internal bool _leftMouseDown; private bool _rightMouseDown; @@ -108,14 +115,39 @@ namespace FlaxEditor.GUI UpdateSelection(ref selectionRect); } + private SelectionMode GetSelectionMode() + { + if (Root.GetKey(KeyboardKeys.Alt)) + return SelectionMode.Remove; + if (Root.GetKey(KeyboardKeys.Shift)) + return SelectionMode.Add; + return SelectionMode.Replace; + } + internal void UpdateSelection(ref Rectangle selectionRect) { + var mode = GetSelectionMode(); + // Find controls to select for (int i = 0; i < Children.Count; i++) { if (Children[i] is KeyframePoint p) { - p.IsSelected = p.Bounds.Intersects(ref selectionRect); + var intersects = p.Bounds.Intersects(ref selectionRect); + switch (mode) + { + case SelectionMode.Replace: + p.IsSelected = intersects; + break; + case SelectionMode.Add: + if (intersects) + p.IsSelected = true; + break; + case SelectionMode.Remove: + if (intersects) + p.IsSelected = false; + break; + } } } } @@ -138,6 +170,7 @@ namespace FlaxEditor.GUI { var viewRect = _editor._mainPanel.GetClientArea(); var locationKeyframes = PointToKeyframes(location, ref viewRect); + var moved = false; for (var i = 0; i < _editor._points.Count; i++) { var p = _editor._points[i]; @@ -160,7 +193,12 @@ namespace FlaxEditor.GUI // TODO: snapping keyframes to grid when moving _editor._keyframes[p.Index] = k; + moved = true; } + } + + if (moved) + { _editor.UpdateKeyframes(); if (_editor.EnablePanning) { @@ -351,10 +389,13 @@ namespace FlaxEditor.GUI { // Start selecting StartMouseCapture(); - if (_editor.KeyframesEditorContext != null) - _editor.KeyframesEditorContext.OnKeyframesDeselect(_editor); - else - _editor.ClearSelection(); + if (GetSelectionMode() == SelectionMode.Replace) + { + if (_editor.KeyframesEditorContext != null) + _editor.KeyframesEditorContext.OnKeyframesDeselect(_editor); + else + _editor.ClearSelection(); + } Focus(); return true; } diff --git a/Source/Editor/GUI/Timeline/Tracks/CurvePropertyTrack.cs b/Source/Editor/GUI/Timeline/Tracks/CurvePropertyTrack.cs index e6246f4c5..7e974b54f 100644 --- a/Source/Editor/GUI/Timeline/Tracks/CurvePropertyTrack.cs +++ b/Source/Editor/GUI/Timeline/Tracks/CurvePropertyTrack.cs @@ -180,7 +180,7 @@ namespace FlaxEditor.GUI.Timeline.Tracks private void OnSplitterMoved(Float2 location) { - var height = Mathf.Clamp(PointToParent(location).Y, 40.0f, 1000.0f); + var height = Mathf.Clamp(_splitter.PointToParent(location).Y + Height - Curve.Height, 40.0f, 1000.0f); if (!Mathf.NearEqual(height, _expandedHeight)) { Height = _expandedHeight = height; diff --git a/Source/Editor/Windows/Assets/SceneAnimationWindow.cs b/Source/Editor/Windows/Assets/SceneAnimationWindow.cs index de384c60a..4dfa287a9 100644 --- a/Source/Editor/Windows/Assets/SceneAnimationWindow.cs +++ b/Source/Editor/Windows/Assets/SceneAnimationWindow.cs @@ -755,6 +755,7 @@ namespace FlaxEditor.Windows.Assets } _previewPlayerPicker.Parent.Visible = !_previewButton.Checked; _timeline.CanPlayPause = _previewButton.Checked || Editor.IsPlayMode; + _timeline.CanPlayStop = _previewButton.Checked || Editor.IsPlayMode; } private void OnRenderButtonClicked() @@ -927,7 +928,7 @@ namespace FlaxEditor.Windows.Assets UpdateToolstrip(); _timeline.CanPlayPause = _previewButton.Checked; - _timeline.CanPlayStop = false; + _timeline.CanPlayStop = _previewButton.Checked; } /// @@ -982,7 +983,7 @@ namespace FlaxEditor.Windows.Assets // Preview is playing _previewPlayer.Tick(Time.UnscaledDeltaTime); } - else if (Mathf.NearEqual(_previewPlayer.Time, _timeline.CurrentFrame)) + else if (Mathf.NearEqual(_previewPlayer.Time, _timeline.CurrentTime)) { // Preview is paused _previewPlayer.Time = _timeline.CurrentTime; diff --git a/Source/Engine/UI/GUI/Panels/ScrollBar.cs b/Source/Engine/UI/GUI/Panels/ScrollBar.cs index 756c698c5..0cf3bda95 100644 --- a/Source/Engine/UI/GUI/Panels/ScrollBar.cs +++ b/Source/Engine/UI/GUI/Panels/ScrollBar.cs @@ -463,6 +463,8 @@ namespace FlaxEngine.GUI // Click change Value = _value + (mousePosition < _thumbCenter ? -1 : 1) * _clickChange; } + + return true; } return base.OnMouseDown(location, button); From 530cd44d2a69e32ab1b6128e376e47fe6ca50ac1 Mon Sep 17 00:00:00 2001 From: Nursultan Mamatov Date: Tue, 7 Jul 2026 23:30:37 +0600 Subject: [PATCH 03/31] Added colors to DebugLog window --- Source/Editor/Windows/DebugLogWindow.cs | 143 +++++++++++++++++++++++- 1 file changed, 139 insertions(+), 4 deletions(-) diff --git a/Source/Editor/Windows/DebugLogWindow.cs b/Source/Editor/Windows/DebugLogWindow.cs index 5b5abf1e2..ed3a117be 100644 --- a/Source/Editor/Windows/DebugLogWindow.cs +++ b/Source/Editor/Windows/DebugLogWindow.cs @@ -13,6 +13,7 @@ using FlaxEditor.Options; using FlaxEngine; using FlaxEngine.Assertions; using FlaxEngine.GUI; +using FlaxEngine.Utilities; using Object = FlaxEngine.Object; namespace FlaxEditor.Windows @@ -69,6 +70,28 @@ namespace FlaxEditor.Windows private class LogEntry : Control { + private struct TextColorBlock + { + public Color TextColor; + public TextRange Range; + public Float2 Location; + public Float2 Size; + } + + private struct ColorParseContext + { + public LogEntry Control; + public Float2 Caret; + public Color CurrentColor; + public Color DefaultColor; + public Font TextFont; + + public void AddBlock(ref TextColorBlock block) + { + Control._textBlocks.Add(block); + } + } + private bool _isRightMouseDown; /// @@ -82,6 +105,8 @@ namespace FlaxEditor.Windows public SpriteHandle Icon; public int LogCount = 1; + private readonly List _textBlocks = new List(); + public LogEntry(DebugLogWindow window, ref LogEntryDescription desc) : base(0, 0, 120, DefaultHeight) { @@ -106,6 +131,104 @@ namespace FlaxEditor.Windows Icon = _window._iconError; break; } + + // Color parsing + var style = Style.Current; + var color = Group == LogGroup.Error ? _window._colorError : (Group == LogGroup.Warning ? _window._colorWarning : _window._colorInfo); + + HtmlParser parser = new(); + + parser.Reset(Desc.Title); + + var context = new ColorParseContext + { + Control = this, + Caret = Float2.Zero, + CurrentColor = color, + DefaultColor = color, + TextFont = style.FontMedium, + }; + + int pointerPos = 0; + + while (parser.ParseNext(out var tag)) + { + if (tag.Name.ToLower() == "color") + { + ProcessTextBlock(ref context, pointerPos, tag.StartPosition); + + pointerPos = tag.EndPosition; + + ProcessColorTag(ref context, ref tag); + } + } + + ProcessTextBlock(ref context, pointerPos, Desc.Title.Length); + } + + private void ProcessTextBlock(ref ColorParseContext context, int startPos, int endPos) + { + var textBlock = new TextColorBlock() + { + TextColor = context.CurrentColor, + Range = new TextRange(startPos, endPos), + Location = context.Caret + }; + + var lines = context.TextFont.ProcessText(Desc.Title, ref textBlock.Range); + + if (lines == null || lines.Length == 0) + { + return; + } + for (int i = 0; i < lines.Length; i++) + { + ref var line = ref lines[i]; + textBlock.Range = new TextRange + { + StartIndex = startPos + line.FirstCharIndex, + EndIndex = startPos + line.LastCharIndex + 1 + }; + + if (i != 0) + { + context.Caret.Y += line.Size.Y; + textBlock.Location.X = 0; + textBlock.Location.Y += line.Size.Y; + } + + textBlock.Location.X += line.Location.X; + textBlock.Size = line.Size; + context.AddBlock(ref textBlock); + } + + var lastLine = lines[lines.Length - 1]; + if (lines.Length == 1) + { + context.Caret.X += lastLine.Size.X; + } + else + { + context.Caret.X = lastLine.Size.X; + } + } + + private static void ProcessColorTag(ref ColorParseContext context, ref HtmlTag tag) + { + if (tag.IsSlash) + { + context.CurrentColor = context.DefaultColor; + } + else + { + if (tag.Attributes.TryGetValue(string.Empty, out string colorText)) + { + if (Color.TryParse(colorText, out Color colorVal)) + { + context.CurrentColor = colorVal; + } + } + } } /// @@ -145,13 +268,25 @@ namespace FlaxEditor.Windows var textRect = new Rectangle(43, 2, clientRect.Width - 40, clientRect.Height - 10); Render2D.PushClip(ref clientRect); bool coloredText = _window._colorDebugLogText; - if (LogCount == 1) + + for (int i = 0; i < _textBlocks.Count; i++) { - Render2D.DrawText(style.FontMedium, Desc.Title, textRect, coloredText ? color : style.Foreground); + TextColorBlock block = _textBlocks[i]; + Render2D.DrawText(style.FontMedium, Desc.Title, ref block.Range, + coloredText ? block.TextColor : style.Foreground, textRect.TopLeft + block.Location); } - else if (LogCount > 1) + + if (LogCount > 1) { - Render2D.DrawText(style.FontMedium, $"{Desc.Title} ({LogCount})", textRect, coloredText ? color : style.Foreground); + Float2 numberLocation = textRect.TopLeft; + if (_textBlocks.Count > 0) + { + TextColorBlock block = _textBlocks[_textBlocks.Count - 1]; + numberLocation += block.Location; + numberLocation.X += block.Size.X; + } + + Render2D.DrawText(style.FontMedium, $" ({LogCount})", color, numberLocation); } Render2D.PopClip(); } From ed83634a06981aab7d8491397d62654775d3d7ba Mon Sep 17 00:00:00 2001 From: Nursultan Mamatov Date: Tue, 7 Jul 2026 23:58:52 +0600 Subject: [PATCH 04/31] DebugLogWindow cleanup + Code comments --- Source/Editor/Windows/DebugLogWindow.cs | 87 +++++++++++++++++++++---- 1 file changed, 75 insertions(+), 12 deletions(-) diff --git a/Source/Editor/Windows/DebugLogWindow.cs b/Source/Editor/Windows/DebugLogWindow.cs index ed3a117be..64cb29f88 100644 --- a/Source/Editor/Windows/DebugLogWindow.cs +++ b/Source/Editor/Windows/DebugLogWindow.cs @@ -70,22 +70,61 @@ namespace FlaxEditor.Windows private class LogEntry : Control { + /// + /// Text block with only color available, no other styling. + /// private struct TextColorBlock { + /// + /// Color of this block. + /// public Color TextColor; + + /// + /// Range of the text. + /// public TextRange Range; - public Float2 Location; - public Float2 Size; + + /// + /// The text location and size. + /// + public Rectangle Bounds; } + /// + /// Rich text parsing context. + /// private struct ColorParseContext { + /// + /// LogEntry control. + /// public LogEntry Control; + + /// + /// Caret location for the next text block. + /// public Float2 Caret; + + /// + /// Current parsed color. + /// public Color CurrentColor; + + /// + /// Default color, for reverting color on closing tags. + /// public Color DefaultColor; + + /// + /// Current text font for text processing. + /// public Font TextFont; + /// + /// Add text block to the control. + /// + /// The text block to add. public void AddBlock(ref TextColorBlock block) { Control._textBlocks.Add(block); @@ -163,18 +202,27 @@ namespace FlaxEditor.Windows } } + // Processing leftover text ProcessTextBlock(ref context, pointerPos, Desc.Title.Length); } + /// + /// Processing the text block within given range and adding it to the list. + /// + /// The parsing context. + /// Start of the range.(Character index) + /// End of the range.(Character index) private void ProcessTextBlock(ref ColorParseContext context, int startPos, int endPos) { + // Text block preset var textBlock = new TextColorBlock() { TextColor = context.CurrentColor, Range = new TextRange(startPos, endPos), - Location = context.Caret + Bounds = new Rectangle(context.Caret, Float2.Zero) }; + // Processing the text with selected font. (Handle newlines, text offsets) var lines = context.TextFont.ProcessText(Desc.Title, ref textBlock.Range); if (lines == null || lines.Length == 0) @@ -190,18 +238,20 @@ namespace FlaxEditor.Windows EndIndex = startPos + line.LastCharIndex + 1 }; + // Move to the next line if (i != 0) { context.Caret.Y += line.Size.Y; - textBlock.Location.X = 0; - textBlock.Location.Y += line.Size.Y; + textBlock.Bounds.X = 0; + textBlock.Bounds.Y += line.Size.Y; } - textBlock.Location.X += line.Location.X; - textBlock.Size = line.Size; + textBlock.Bounds.X += line.Location.X; + textBlock.Bounds.Size = line.Size; context.AddBlock(ref textBlock); } + // Caret location for the next text block var lastLine = lines[lines.Length - 1]; if (lines.Length == 1) { @@ -213,14 +263,21 @@ namespace FlaxEditor.Windows } } + /// + /// Parse color info from the tag and handle closing tags. + /// + /// The parsing context. + /// Tag to process. private static void ProcessColorTag(ref ColorParseContext context, ref HtmlTag tag) { + // Closing tag if (tag.IsSlash) { context.CurrentColor = context.DefaultColor; } else { + // Parse color if (tag.Attributes.TryGetValue(string.Empty, out string colorText)) { if (Color.TryParse(colorText, out Color colorVal)) @@ -228,6 +285,10 @@ namespace FlaxEditor.Windows context.CurrentColor = colorVal; } } + else + { + context.CurrentColor = context.DefaultColor; + } } } @@ -265,25 +326,27 @@ namespace FlaxEditor.Windows Render2D.DrawSprite(Icon, new Rectangle(8, 0, 32, 32), color); // Title - var textRect = new Rectangle(43, 2, clientRect.Width - 40, clientRect.Height - 10); + var textLocation = new Float2(43, 2); Render2D.PushClip(ref clientRect); bool coloredText = _window._colorDebugLogText; + // Render text blocks with their colors for (int i = 0; i < _textBlocks.Count; i++) { TextColorBlock block = _textBlocks[i]; Render2D.DrawText(style.FontMedium, Desc.Title, ref block.Range, - coloredText ? block.TextColor : style.Foreground, textRect.TopLeft + block.Location); + coloredText ? block.TextColor : style.Foreground, textLocation + block.Bounds.Location); } + // Adding log counter for collapsed logs if (LogCount > 1) { - Float2 numberLocation = textRect.TopLeft; + Float2 numberLocation = textLocation; if (_textBlocks.Count > 0) { TextColorBlock block = _textBlocks[_textBlocks.Count - 1]; - numberLocation += block.Location; - numberLocation.X += block.Size.X; + numberLocation += block.Bounds.Location; + numberLocation.X += block.Bounds.Size.X; } Render2D.DrawText(style.FontMedium, $" ({LogCount})", color, numberLocation); From 74327aa8cdc2f601d316d20d0d12c7c3feebde6c Mon Sep 17 00:00:00 2001 From: fibref Date: Sat, 8 Aug 2026 08:53:20 +0800 Subject: [PATCH 05/31] add MSDFsize to FontAsset --- Source/Editor/Windows/Assets/FontWindow.cs | 7 +++++ .../Content/Upgraders/FontAssetUpgrader.h | 31 +++++++++++++++++-- Source/Engine/ContentImporters/ImportFont.cpp | 3 +- Source/Engine/Render2D/FontAsset.cs | 8 ++--- Source/Engine/Render2D/FontAsset.h | 7 ++++- 5 files changed, 47 insertions(+), 9 deletions(-) diff --git a/Source/Editor/Windows/Assets/FontWindow.cs b/Source/Editor/Windows/Assets/FontWindow.cs index e08a87b05..0f0bc1630 100644 --- a/Source/Editor/Windows/Assets/FontWindow.cs +++ b/Source/Editor/Windows/Assets/FontWindow.cs @@ -1,5 +1,6 @@ // Copyright (c) Wojciech Figat. All rights reserved. +using System; using System.ComponentModel; using FlaxEditor.Content; using FlaxEditor.CustomEditors; @@ -25,6 +26,10 @@ namespace FlaxEditor.Windows.Assets [EditorOrder(5), EditorDisplay("Properties"), Tooltip("The rasterization mode used when generating font atlases.")] public FontRasterMode RasterMode; + [DefaultValue(32.0f)] + [EditorOrder(6), EditorDisplay("Properties"), Tooltip("The font size used when generating MSDF font atlases.")] + public float MSDFSize; + [DefaultValue(FontHinting.Default)] [EditorOrder(10), EditorDisplay("Properties"), Tooltip("The font hinting used when rendering characters.")] public FontHinting Hinting; @@ -47,6 +52,7 @@ namespace FlaxEditor.Windows.Assets { Hinting = Hinting, RasterMode = RasterMode, + MSDFSize = MSDFSize, }; if (AntiAliasing) options.Flags |= FontFlags.AntiAliasing; @@ -63,6 +69,7 @@ namespace FlaxEditor.Windows.Assets Bold = (options.Flags & FontFlags.Bold) == FontFlags.Bold; Italic = (options.Flags & FontFlags.Italic) == FontFlags.Italic; RasterMode = options.RasterMode; + MSDFSize = options.MSDFSize; } } diff --git a/Source/Engine/Content/Upgraders/FontAssetUpgrader.h b/Source/Engine/Content/Upgraders/FontAssetUpgrader.h index 4ca6e6d73..b6e8ec12a 100644 --- a/Source/Engine/Content/Upgraders/FontAssetUpgrader.h +++ b/Source/Engine/Content/Upgraders/FontAssetUpgrader.h @@ -19,12 +19,13 @@ public: const Upgrader upgraders[] = { { 3, 4, &Upgrade_3_To_4 }, + { 4, 5, &Upgrade_4_To_5 }, }; setup(upgraders, ARRAY_COUNT(upgraders)); } private: - struct FontOptionsOld + struct FontOptions3 { FontHinting Hinting; FontFlags Flags; @@ -34,8 +35,8 @@ private: { ASSERT(context.Input.SerializedVersion == 3 && context.Output.SerializedVersion == 4); - FontOptionsOld optionsOld; - Platform::MemoryCopy(&optionsOld, context.Input.CustomData.Get(), sizeof(FontOptionsOld)); + FontOptions3 optionsOld; + Platform::MemoryCopy(&optionsOld, context.Input.CustomData.Get(), sizeof(FontOptions3)); FontOptions options; options.Hinting = optionsOld.Hinting; @@ -45,6 +46,30 @@ private: return CopyChunk(context, 0); } + + struct FontOptions4 + { + FontHinting Hinting; + FontFlags Flags; + FontRasterMode RasterMode; + }; + + static bool Upgrade_4_To_5(AssetMigrationContext& context) + { + ASSERT(context.Input.SerializedVersion == 4 && context.Output.SerializedVersion == 5); + + FontOptions4 optionsOld; + Platform::MemoryCopy(&optionsOld, context.Input.CustomData.Get(), sizeof(FontOptions4)); + + FontOptions options; + options.Hinting = optionsOld.Hinting; + options.Flags = optionsOld.Flags; + options.RasterMode = optionsOld.RasterMode; + options.MSDFSize = 32.0f; + context.Output.CustomData.Copy(&options); + + return CopyChunk(context, 0); + } }; #endif diff --git a/Source/Engine/ContentImporters/ImportFont.cpp b/Source/Engine/ContentImporters/ImportFont.cpp index 964f3907e..b01b36627 100644 --- a/Source/Engine/ContentImporters/ImportFont.cpp +++ b/Source/Engine/ContentImporters/ImportFont.cpp @@ -12,13 +12,14 @@ CreateAssetResult ImportFont::Import(CreateAssetContext& context) { // Base - IMPORT_SETUP(FontAsset, 4); + IMPORT_SETUP(FontAsset, 5); // Setup header FontOptions options; options.Hinting = FontHinting::Default; options.Flags = FontFlags::AntiAliasing; options.RasterMode = FontRasterMode::Bitmap; + options.MSDFSize = 32.0f; context.Data.CustomData.Copy(&options); // Open the file diff --git a/Source/Engine/Render2D/FontAsset.cs b/Source/Engine/Render2D/FontAsset.cs index 624daab5b..f49ae4ebb 100644 --- a/Source/Engine/Render2D/FontAsset.cs +++ b/Source/Engine/Render2D/FontAsset.cs @@ -13,7 +13,7 @@ namespace FlaxEngine /// true if this object has the same value as ; otherwise, false public bool Equals(FontOptions other) { - return Hinting == other.Hinting && Flags == other.Flags && RasterMode == other.RasterMode; + return Hinting == other.Hinting && Flags == other.Flags && RasterMode == other.RasterMode && MSDFSize == other.MSDFSize; } /// @@ -25,7 +25,7 @@ namespace FlaxEngine /// public override int GetHashCode() { - return HashCode.Combine((int)Hinting, (int)Flags, (int)RasterMode); + return HashCode.Combine((int)Hinting, (int)Flags, (int)RasterMode, MSDFSize); } /// @@ -36,7 +36,7 @@ namespace FlaxEngine /// true if has the same value as ; otherwise, false. public static bool operator ==(FontOptions left, FontOptions right) { - return left.Hinting == right.Hinting && left.Flags == right.Flags && left.RasterMode == right.RasterMode; + return left.Hinting == right.Hinting && left.Flags == right.Flags && left.RasterMode == right.RasterMode && left.MSDFSize == right.MSDFSize; } /// @@ -47,7 +47,7 @@ namespace FlaxEngine /// true if has a different value than ; otherwise,false. public static bool operator !=(FontOptions left, FontOptions right) { - return left.Hinting != right.Hinting || left.Flags != right.Flags || left.RasterMode != right.RasterMode; + return left.Hinting != right.Hinting || left.Flags != right.Flags || left.RasterMode != right.RasterMode || left.MSDFSize != right.MSDFSize; } } } diff --git a/Source/Engine/Render2D/FontAsset.h b/Source/Engine/Render2D/FontAsset.h index c3aa141a8..4bf2114e1 100644 --- a/Source/Engine/Render2D/FontAsset.h +++ b/Source/Engine/Render2D/FontAsset.h @@ -105,6 +105,11 @@ API_STRUCT() struct FontOptions /// The font rasterization mode. /// API_FIELD() FontRasterMode RasterMode; + + /// + /// The font size used when generating MSDF font atlases. + /// + API_FIELD() float MSDFSize; }; /// @@ -112,7 +117,7 @@ API_STRUCT() struct FontOptions /// API_CLASS(NoSpawn) class FLAXENGINE_API FontAsset : public BinaryAsset { - DECLARE_BINARY_ASSET_HEADER(FontAsset, 4); + DECLARE_BINARY_ASSET_HEADER(FontAsset, 5); friend Font; private: From f9cb513fb7f5d8c65ab25b6a5fbf5c5d21cf1a3e Mon Sep 17 00:00:00 2001 From: fibref Date: Thu, 13 Aug 2026 10:43:10 +0800 Subject: [PATCH 06/31] handle generation and rendering of MSDF fonts --- Source/Engine/Render2D/Font.cpp | 10 ++++++---- Source/Engine/Render2D/Render2D.cpp | 10 ++++++---- Source/Engine/UI/TextRender.cpp | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/Source/Engine/Render2D/Font.cpp b/Source/Engine/Render2D/Font.cpp index b2e6e8b75..6a5a7127a 100644 --- a/Source/Engine/Render2D/Font.cpp +++ b/Source/Engine/Render2D/Font.cpp @@ -133,7 +133,7 @@ void Font::ProcessText(const StringView& text, ArrayGetOptions().RasterMode == FontRasterMode::MSDF ? _size / _asset->GetOptions().MSDFSize : 1.0f); float boundsWidth = layout.Bounds.GetWidth(); float baseLinesDistance = static_cast(_height) * layout.BaseLinesGapScale * scale; tmpLine.Location = Float2::Zero; @@ -339,7 +339,7 @@ int32 Font::HitTestText(const StringView& text, const Float2& location, const Te Array> lines; ProcessText(text, lines, layout); ASSERT(lines.HasItems()); - float scale = layout.Scale / FontManager::FontScale; + float scale = layout.Scale / FontManager::FontScale * (_asset->GetOptions().RasterMode == FontRasterMode::MSDF ? _size / _asset->GetOptions().MSDFSize : 1.0f); float baseLinesDistance = static_cast(_height) * layout.BaseLinesGapScale * scale; // Offset position to match lines origin space @@ -427,7 +427,7 @@ Float2 Font::GetCharPosition(const StringView& text, int32 index, const TextLayo Array> lines; ProcessText(text, lines, layout); ASSERT(lines.HasItems()); - float scale = layout.Scale / FontManager::FontScale; + float scale = layout.Scale / FontManager::FontScale * (_asset->GetOptions().RasterMode == FontRasterMode::MSDF ? _size / _asset->GetOptions().MSDFSize : 1.0f); float baseLinesDistance = static_cast(_height) * layout.BaseLinesGapScale * scale; // Find line with that position @@ -474,7 +474,9 @@ void Font::FlushFaceSize() const { // Set the character size const FT_Face face = _asset->GetFTFace(); - const FT_Error error = FT_Set_Char_Size(face, 0, ConvertPixelTo26Dot6(_size * FontManager::FontScale), DefaultDPI, DefaultDPI); + FontOptions options = _asset->GetOptions(); + float size = options.RasterMode == FontRasterMode::MSDF ? options.MSDFSize : _size; + const FT_Error error = FT_Set_Char_Size(face, 0, ConvertPixelTo26Dot6(size * FontManager::FontScale), DefaultDPI, DefaultDPI); if (error) { LOG_FT_ERROR(error); diff --git a/Source/Engine/Render2D/Render2D.cpp b/Source/Engine/Render2D/Render2D.cpp index 58f6f208e..efabc4b58 100644 --- a/Source/Engine/Render2D/Render2D.cpp +++ b/Source/Engine/Render2D/Render2D.cpp @@ -1203,7 +1203,8 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, Float2 invAtlasSize = Float2::One; FontCharacterEntry previous; int32 kerning; - float scale = 1.0f / FontManager::FontScale; + FontOptions options = font->GetAsset()->GetOptions(); + float scale = 1.0f / FontManager::FontScale * (options.RasterMode == FontRasterMode::MSDF ? font->GetSize() / options.MSDFSize : 1.0f); const bool enableFallbackFonts = EnumHasAllFlags(Features, RenderingFeatures::FallbackFonts); // Render all characters @@ -1216,7 +1217,7 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, } else { - drawCall.Type = font->GetAsset()->GetOptions().RasterMode == FontRasterMode::MSDF ? DrawCallType::DrawCharMSDF : DrawCallType::DrawChar; + drawCall.Type = options.RasterMode == FontRasterMode::MSDF ? DrawCallType::DrawCharMSDF : DrawCallType::DrawChar; drawCall.AsChar.Mat = nullptr; } Float2 pointer = location; @@ -1318,7 +1319,8 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, Float2 invAtlasSize = Float2::One; FontCharacterEntry previous; int32 kerning; - float scale = layout.Scale / FontManager::FontScale; + FontOptions options = font->GetAsset()->GetOptions(); + float scale = layout.Scale / FontManager::FontScale * (options.RasterMode == FontRasterMode::MSDF ? font->GetSize() / options.MSDFSize : 1.0f); const bool enableFallbackFonts = EnumHasAllFlags(Features, RenderingFeatures::FallbackFonts); // Process text to get lines @@ -1335,7 +1337,7 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, } else { - drawCall.Type = font->GetAsset()->GetOptions().RasterMode == FontRasterMode::MSDF ? DrawCallType::DrawCharMSDF : DrawCallType::DrawChar; + drawCall.Type = options.RasterMode == FontRasterMode::MSDF ? DrawCallType::DrawCharMSDF : DrawCallType::DrawChar; drawCall.AsChar.Mat = nullptr; } for (int32 lineIndex = 0; lineIndex < Lines.Count(); lineIndex++) diff --git a/Source/Engine/UI/TextRender.cpp b/Source/Engine/UI/TextRender.cpp index c2f6789c7..88e5f8b2b 100644 --- a/Source/Engine/UI/TextRender.cpp +++ b/Source/Engine/UI/TextRender.cpp @@ -172,7 +172,7 @@ void TextRender::UpdateLayout() // Pick a font (remove DPI text scale as the text is being placed in the world) auto font = Font->CreateFont(_size); - float scale = _layoutOptions.Scale / FontManager::FontScale; + float scale = _layoutOptions.Scale / FontManager::FontScale * (Font->GetOptions().RasterMode == FontRasterMode::MSDF ? _size / Font->GetOptions().MSDFSize : 1.0f); // Prepare FontTextureAtlas* fontAtlas = nullptr; From f6c1d48f328a46af6434a894f6c95adac6b5e5f4 Mon Sep 17 00:00:00 2001 From: fibref Date: Thu, 13 Aug 2026 20:37:51 +0800 Subject: [PATCH 07/31] improve MSDF fonts caching --- Source/Engine/Render2D/Font.cpp | 19 +---- Source/Engine/Render2D/Font.h | 111 +-------------------------- Source/Engine/Render2D/FontAsset.cpp | 6 +- Source/Engine/Render2D/FontAsset.h | 106 +++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 127 deletions(-) diff --git a/Source/Engine/Render2D/Font.cpp b/Source/Engine/Render2D/Font.cpp index 6a5a7127a..ed3093f35 100644 --- a/Source/Engine/Render2D/Font.cpp +++ b/Source/Engine/Render2D/Font.cpp @@ -13,7 +13,6 @@ Font::Font(FontAsset* parentAsset, float size) : ManagedScriptingObject(SpawnParams(Guid::New(), Font::TypeInitializer)) , _asset(parentAsset) , _size(size) - , _characters(512) { _asset->_fonts.Add(this); @@ -37,13 +36,14 @@ Font::~Font() void Font::GetCharacter(Char c, FontCharacterEntry& result, bool enableFallback) { // Try to get the character or cache it if cannot be found - if (!_characters.TryGet(c, result)) + const auto key = Pair(_asset->GetOptions().RasterMode == FontRasterMode::MSDF ? _asset->GetOptions().MSDFSize : GetSize(), c); + if (!_asset->_characterCache.TryGet(key, result)) { // This thread race condition may happen in editor but in game we usually do all stuff with fonts on main thread (chars caching) ScopeLock lock(_asset->Locker); // Handle situation when more than one thread wants to get the same character - if (_characters.TryGet(c, result)) + if (_asset->_characterCache.TryGet(key, result)) return; // Try to use fallback font if character is missing @@ -69,7 +69,7 @@ void Font::GetCharacter(Char c, FontCharacterEntry& result, bool enableFallback) ASSERT(result.Font); // Add to the dictionary - _characters.Add(c, result); + _asset->_characterCache.Add(key, result); } } @@ -112,17 +112,6 @@ void Font::CacheText(const StringView& text) } } -void Font::Invalidate() -{ - ScopeLock lock(_asset->Locker); - - for (auto i = _characters.Begin(); i.IsNotEnd(); ++i) - { - FontManager::Invalidate(i->Value); - } - _characters.Clear(); -} - void Font::ProcessText(const StringView& text, Array>& outputLines, const TextLayoutOptions& layout) { int32 textLength = text.Length(); diff --git a/Source/Engine/Render2D/Font.h b/Source/Engine/Render2D/Font.h index fc40cc16c..f176bb3f3 100644 --- a/Source/Engine/Render2D/Font.h +++ b/Source/Engine/Render2D/Font.h @@ -11,6 +11,7 @@ class FontAsset; struct FontTextureAtlasSlot; +struct FontCharacterEntry; // The default DPI that engine is using #define DefaultDPI 96 @@ -119,110 +120,6 @@ struct TIsPODType enum { Value = true }; }; -// Font glyph metrics: -// -// xmin xmax -// | | -// |<-------- width -------->| -// | | -// | +-------------------------+----------------- ymax -// | | ggggggggg ggggg | ^ ^ -// | | g:::::::::ggg::::g | | | -// | | g:::::::::::::::::g | | | -// | | g::::::ggggg::::::gg | | | -// | | g:::::g g:::::g | | | -// offsetX -|-------->| g:::::g g:::::g | offsetY | -// | | g:::::g g:::::g | | | -// | | g::::::g g:::::g | | | -// | | g:::::::ggggg:::::g | | | -// | | g::::::::::::::::g | | height -// | | gg::::::::::::::g | | | -// baseline ---*---------|---- gggggggg::::::g-----*-------- | -// / | | g:::::g | | -// origin | | gggggg g:::::g | | -// | | g:::::gg gg:::::g | | -// | | g::::::ggg:::::::g | | -// | | gg:::::::::::::g | | -// | | ggg::::::ggg | | -// | | gggggg | v -// | +-------------------------+----------------- ymin -// | | -// |------------- advanceX ----------->| - -/// -/// The cached font character entry (read for rendering and further processing). -/// -API_STRUCT(NoDefault) struct FLAXENGINE_API FontCharacterEntry -{ - DECLARE_SCRIPTING_TYPE_MINIMAL(FontCharacterEntry); - - /// - /// The character represented by this entry. - /// - API_FIELD() Char Character; - - /// - /// True if entry is valid, otherwise false. - /// - API_FIELD() bool IsValid = false; - - /// - /// The index to a specific texture in the font cache. - /// - API_FIELD() byte TextureIndex; - - /// - /// The left bearing expressed in integer pixels. - /// - API_FIELD() int16 OffsetX; - - /// - /// The top bearing expressed in integer pixels. - /// - API_FIELD() int16 OffsetY; - - /// - /// The amount to advance in X before drawing the next character in a string. - /// - API_FIELD() int16 AdvanceX; - - /// - /// The distance from baseline to glyph top most point. - /// - API_FIELD() int16 BearingY; - - /// - /// The height in pixels of the glyph. - /// - API_FIELD() int16 Height; - - /// - /// The start location of the character in the texture (in texture coordinates space). - /// - API_FIELD() Float2 UV; - - /// - /// The size the character in the texture (in texture coordinates space). - /// - API_FIELD() Float2 UVSize; - - /// - /// The slot in texture atlas, containing the pixel data of the glyph. - /// - API_FIELD() const FontTextureAtlasSlot* Slot; - - /// - /// The owner font. - /// - API_FIELD() const class Font* Font; -}; - -template<> -struct TIsPODType -{ - enum { Value = true }; -}; - /// /// Represents font object that can be using during text rendering (it uses Font Asset but with pre-cached data for chosen font properties). /// @@ -239,7 +136,6 @@ private: int32 _descender; int32 _lineGap; bool _hasKerning; - Dictionary _characters; mutable Dictionary _kerningTable; public: @@ -332,11 +228,6 @@ public: /// The text witch characters to cache. API_FUNCTION() void CacheText(const StringView& text); - /// - /// Invalidates all cached dynamic font atlases using this font. Can be used to reload font characters after changing font asset options. - /// - API_FUNCTION() void Invalidate(); - public: /// /// Processes text to get cached lines for rendering. diff --git a/Source/Engine/Render2D/FontAsset.cpp b/Source/Engine/Render2D/FontAsset.cpp index 34ffe7330..c16bd56b4 100644 --- a/Source/Engine/Render2D/FontAsset.cpp +++ b/Source/Engine/Render2D/FontAsset.cpp @@ -214,8 +214,10 @@ bool FontAsset::ContainsChar(Char c) const void FontAsset::Invalidate() { ScopeLock lock(Locker); - for (auto font : _fonts) - font->Invalidate(); + for (auto& entry : _characterCache) + FontManager::Invalidate(entry.Value); + + _characterCache.Clear(); } uint64 FontAsset::GetMemoryUsage() const diff --git a/Source/Engine/Render2D/FontAsset.h b/Source/Engine/Render2D/FontAsset.h index 4bf2114e1..fe4458f43 100644 --- a/Source/Engine/Render2D/FontAsset.h +++ b/Source/Engine/Render2D/FontAsset.h @@ -7,8 +7,113 @@ class Font; class FontManager; +struct FontTextureAtlasSlot; typedef struct FT_FaceRec_* FT_Face; +// Font glyph metrics: +// +// xmin xmax +// | | +// |<-------- width -------->| +// | | +// | +-------------------------+----------------- ymax +// | | ggggggggg ggggg | ^ ^ +// | | g:::::::::ggg::::g | | | +// | | g:::::::::::::::::g | | | +// | | g::::::ggggg::::::gg | | | +// | | g:::::g g:::::g | | | +// offsetX -|-------->| g:::::g g:::::g | offsetY | +// | | g:::::g g:::::g | | | +// | | g::::::g g:::::g | | | +// | | g:::::::ggggg:::::g | | | +// | | g::::::::::::::::g | | height +// | | gg::::::::::::::g | | | +// baseline ---*---------|---- gggggggg::::::g-----*-------- | +// / | | g:::::g | | +// origin | | gggggg g:::::g | | +// | | g:::::gg gg:::::g | | +// | | g::::::ggg:::::::g | | +// | | gg:::::::::::::g | | +// | | ggg::::::ggg | | +// | | gggggg | v +// | +-------------------------+----------------- ymin +// | | +// |------------- advanceX ----------->| + +/// +/// The cached font character entry (read for rendering and further processing). +/// +API_STRUCT(NoDefault) struct FLAXENGINE_API FontCharacterEntry +{ + DECLARE_SCRIPTING_TYPE_MINIMAL(FontCharacterEntry); + + /// + /// The character represented by this entry. + /// + API_FIELD() Char Character; + + /// + /// True if entry is valid, otherwise false. + /// + API_FIELD() bool IsValid = false; + + /// + /// The index to a specific texture in the font cache. + /// + API_FIELD() byte TextureIndex; + + /// + /// The left bearing expressed in integer pixels. + /// + API_FIELD() int16 OffsetX; + + /// + /// The top bearing expressed in integer pixels. + /// + API_FIELD() int16 OffsetY; + + /// + /// The amount to advance in X before drawing the next character in a string. + /// + API_FIELD() int16 AdvanceX; + + /// + /// The distance from baseline to glyph top most point. + /// + API_FIELD() int16 BearingY; + + /// + /// The height in pixels of the glyph. + /// + API_FIELD() int16 Height; + + /// + /// The start location of the character in the texture (in texture coordinates space). + /// + API_FIELD() Float2 UV; + + /// + /// The size the character in the texture (in texture coordinates space). + /// + API_FIELD() Float2 UVSize; + + /// + /// The slot in texture atlas, containing the pixel data of the glyph. + /// + API_FIELD() const FontTextureAtlasSlot* Slot; + + /// + /// The owner font. + /// + API_FIELD() const class Font* Font; +}; + +template<> +struct TIsPODType +{ + enum { Value = true }; +}; + /// /// The font hinting used when rendering characters. /// @@ -125,6 +230,7 @@ private: FontOptions _options; BytesContainer _fontFile; Array> _fonts; + Dictionary, FontCharacterEntry> _characterCache; AssetReference _virtualBold; AssetReference _virtualItalic; AssetReference _virtualMSDF; From cf05034d7a2e92160a12249563674f1fd980d321 Mon Sep 17 00:00:00 2001 From: fibref Date: Fri, 14 Aug 2026 21:02:03 +0800 Subject: [PATCH 08/31] fix Font and FontAsset cache not completely refreshed when options change --- Source/Editor/Windows/Assets/FontWindow.cs | 1 - Source/Engine/Render2D/Font.cpp | 18 ++++++++++++++-- Source/Engine/Render2D/Font.h | 5 +++++ Source/Engine/Render2D/FontAsset.cpp | 24 ++++++++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/Source/Editor/Windows/Assets/FontWindow.cs b/Source/Editor/Windows/Assets/FontWindow.cs index 0f0bc1630..71f8d800d 100644 --- a/Source/Editor/Windows/Assets/FontWindow.cs +++ b/Source/Editor/Windows/Assets/FontWindow.cs @@ -141,7 +141,6 @@ namespace FlaxEditor.Windows.Assets if (assetOptions != options) { Asset.Options = options; - Asset.Invalidate(); } } diff --git a/Source/Engine/Render2D/Font.cpp b/Source/Engine/Render2D/Font.cpp index ed3093f35..10b4b116f 100644 --- a/Source/Engine/Render2D/Font.cpp +++ b/Source/Engine/Render2D/Font.cpp @@ -112,6 +112,21 @@ void Font::CacheText(const StringView& text) } } +void Font::Invalidate() +{ + ScopeLock lock(_asset->Locker); + + FlushFaceSize(); + const FT_Face face = _asset->GetFTFace(); + ASSERT(face != nullptr); + _height = Convert26Dot6ToRoundedPixel(FT_MulFix(face->height, face->size->metrics.y_scale)); + _hasKerning = FT_HAS_KERNING(face) != 0; + _ascender = Convert26Dot6ToRoundedPixel(face->size->metrics.ascender); + _descender = Convert26Dot6ToRoundedPixel(face->size->metrics.descender); + _lineGap = _height - _ascender + _descender; + _kerningTable.Clear(); +} + void Font::ProcessText(const StringView& text, Array>& outputLines, const TextLayoutOptions& layout) { int32 textLength = text.Length(); @@ -463,8 +478,7 @@ void Font::FlushFaceSize() const { // Set the character size const FT_Face face = _asset->GetFTFace(); - FontOptions options = _asset->GetOptions(); - float size = options.RasterMode == FontRasterMode::MSDF ? options.MSDFSize : _size; + float size = _asset->GetOptions().RasterMode == FontRasterMode::MSDF ? _asset->GetOptions().MSDFSize : _size; const FT_Error error = FT_Set_Char_Size(face, 0, ConvertPixelTo26Dot6(size * FontManager::FontScale), DefaultDPI, DefaultDPI); if (error) { diff --git a/Source/Engine/Render2D/Font.h b/Source/Engine/Render2D/Font.h index f176bb3f3..ace21e64a 100644 --- a/Source/Engine/Render2D/Font.h +++ b/Source/Engine/Render2D/Font.h @@ -228,6 +228,11 @@ public: /// The text witch characters to cache. API_FUNCTION() void CacheText(const StringView& text); + /// + /// Refresh cached metrics. Can be used after changing font asset options. + /// + API_FUNCTION() void Invalidate(); + public: /// /// Processes text to get cached lines for rendering. diff --git a/Source/Engine/Render2D/FontAsset.cpp b/Source/Engine/Render2D/FontAsset.cpp index c16bd56b4..39fe89207 100644 --- a/Source/Engine/Render2D/FontAsset.cpp +++ b/Source/Engine/Render2D/FontAsset.cpp @@ -101,6 +101,26 @@ FontFlags FontAsset::GetStyle() const void FontAsset::SetOptions(const FontOptions& value) { _options = value; + Invalidate(); + + if (_virtualBold) + { + auto options = _options; + options.Flags |= FontFlags::Bold; + _virtualBold->SetOptions(options); + } + if (_virtualItalic) + { + auto options = _options; + options.Flags |= FontFlags::Italic; + _virtualItalic->SetOptions(options); + } + if (_virtualMSDF) + { + auto options = _options; + options.RasterMode = FontRasterMode::MSDF; + _virtualMSDF->SetOptions(options); + } } Font* FontAsset::CreateFont(float size) @@ -218,6 +238,10 @@ void FontAsset::Invalidate() FontManager::Invalidate(entry.Value); _characterCache.Clear(); + + // Refresh cached metrics of all fonts created from this asset + for (auto font : _fonts) + font->Invalidate(); } uint64 FontAsset::GetMemoryUsage() const From 32d3f69d00b77cb66e7216950595c0b5816d2cfe Mon Sep 17 00:00:00 2001 From: fibref Date: Sat, 15 Aug 2026 18:11:40 +0800 Subject: [PATCH 09/31] fix fallback font not scaled correctly --- Source/Engine/Render2D/Font.cpp | 41 ++++++++++++++++++---------- Source/Engine/Render2D/Font.h | 7 +++++ Source/Engine/Render2D/FontAsset.cpp | 24 ++++++++-------- Source/Engine/Render2D/FontAsset.h | 6 ++-- Source/Engine/Render2D/Render2D.cpp | 28 +++++++++++-------- Source/Engine/UI/TextRender.cpp | 13 +++++---- 6 files changed, 72 insertions(+), 47 deletions(-) diff --git a/Source/Engine/Render2D/Font.cpp b/Source/Engine/Render2D/Font.cpp index 10b4b116f..fb88c8256 100644 --- a/Source/Engine/Render2D/Font.cpp +++ b/Source/Engine/Render2D/Font.cpp @@ -37,14 +37,23 @@ void Font::GetCharacter(Char c, FontCharacterEntry& result, bool enableFallback) { // Try to get the character or cache it if cannot be found const auto key = Pair(_asset->GetOptions().RasterMode == FontRasterMode::MSDF ? _asset->GetOptions().MSDFSize : GetSize(), c); - if (!_asset->_characterCache.TryGet(key, result)) + if (_asset->_characterCache.TryGet(key, result)) + { + // With MSDF font introduced, cached entry may be created by a different font (with same MSDFSize) + // This is to ensure returned entry has a reference to a font whose size matches the font being used to render + result.Font = this; + } + else { // This thread race condition may happen in editor but in game we usually do all stuff with fonts on main thread (chars caching) ScopeLock lock(_asset->Locker); // Handle situation when more than one thread wants to get the same character if (_asset->_characterCache.TryGet(key, result)) + { + result.Font = this; return; + } // Try to use fallback font if character is missing if (enableFallback && !_asset->ContainsChar(c)) @@ -52,13 +61,9 @@ void Font::GetCharacter(Char c, FontCharacterEntry& result, bool enableFallback) for (int32 fallbackIndex = 0; fallbackIndex < FallbackFonts.Count(); fallbackIndex++) { FontAsset* fallbackFont = FallbackFonts.Get()[fallbackIndex].Get(); - if (fallbackFont && _asset->GetOptions().RasterMode == FontRasterMode::MSDF) - { - fallbackFont = fallbackFont->GetMSDF(); - } if (fallbackFont && fallbackFont->ContainsChar(c)) { - fallbackFont->CreateFont(GetSize())->GetCharacter(c, result, enableFallback); + fallbackFont->GetRasterMode(_asset->GetOptions().RasterMode)->CreateFont(GetSize())->GetCharacter(c, result, enableFallback); return; } } @@ -127,6 +132,11 @@ void Font::Invalidate() _kerningTable.Clear(); } +float Font::GetScale(float layoutScale) const +{ + return layoutScale / FontManager::FontScale * (_asset->GetOptions().RasterMode == FontRasterMode::MSDF ? _size / _asset->GetOptions().MSDFSize : 1.0f); +} + void Font::ProcessText(const StringView& text, Array>& outputLines, const TextLayoutOptions& layout) { int32 textLength = text.Length(); @@ -137,7 +147,7 @@ void Font::ProcessText(const StringView& text, ArrayGetOptions().RasterMode == FontRasterMode::MSDF ? _size / _asset->GetOptions().MSDFSize : 1.0f); + const float scale = GetScale(layout.Scale); float boundsWidth = layout.Bounds.GetWidth(); float baseLinesDistance = static_cast(_height) * layout.BaseLinesGapScale * scale; tmpLine.Location = Float2::Zero; @@ -181,6 +191,7 @@ void Font::ProcessText(const StringView& text, ArrayGetScale(layout.Scale); // Get kerning if (!isWhitespace && previous.IsValid) @@ -192,7 +203,7 @@ void Font::ProcessText(const StringView& text, Array> lines; ProcessText(text, lines, layout); ASSERT(lines.HasItems()); - float scale = layout.Scale / FontManager::FontScale * (_asset->GetOptions().RasterMode == FontRasterMode::MSDF ? _size / _asset->GetOptions().MSDFSize : 1.0f); + const float scale = GetScale(layout.Scale); float baseLinesDistance = static_cast(_height) * layout.BaseLinesGapScale * scale; // Offset position to match lines origin space @@ -364,12 +375,13 @@ int32 Font::HitTestText(const StringView& text, const Float2& location, const Te // Cache current character const Char currentChar = text[currentIndex]; GetCharacter(currentChar, entry); + const float entryScale = entry.Font->GetScale(layout.Scale); const bool isWhitespace = StringUtils::IsWhitespace(currentChar); // Apply kerning if (!isWhitespace && previous.IsValid) { - x += entry.Font->GetKerning(previous.Character, entry.Character); + x += entry.Font->GetKerning(previous.Character, entry.Character) * entryScale; } previous = entry; @@ -388,7 +400,7 @@ int32 Font::HitTestText(const StringView& text, const Float2& location, const Te } // Move - x += entry.AdvanceX * scale; + x += entry.AdvanceX * entryScale; } // Test line end edge @@ -431,7 +443,7 @@ Float2 Font::GetCharPosition(const StringView& text, int32 index, const TextLayo Array> lines; ProcessText(text, lines, layout); ASSERT(lines.HasItems()); - float scale = layout.Scale / FontManager::FontScale * (_asset->GetOptions().RasterMode == FontRasterMode::MSDF ? _size / _asset->GetOptions().MSDFSize : 1.0f); + const float scale = GetScale(layout.Scale); float baseLinesDistance = static_cast(_height) * layout.BaseLinesGapScale * scale; // Find line with that position @@ -452,17 +464,18 @@ Float2 Font::GetCharPosition(const StringView& text, int32 index, const TextLayo // Cache current character const Char currentChar = text[currentIndex]; GetCharacter(currentChar, entry); + const float entryScale = entry.Font->GetScale(layout.Scale); const bool isWhitespace = StringUtils::IsWhitespace(currentChar); // Apply kerning if (!isWhitespace && previous.IsValid) { - charPos.X += entry.Font->GetKerning(previous.Character, entry.Character); + charPos.X += entry.Font->GetKerning(previous.Character, entry.Character) * entryScale; } previous = entry; // Move - charPos.X += entry.AdvanceX * scale; + charPos.X += entry.AdvanceX * entryScale; } // Upper left corner of the character diff --git a/Source/Engine/Render2D/Font.h b/Source/Engine/Render2D/Font.h index ace21e64a..c8fd960d0 100644 --- a/Source/Engine/Render2D/Font.h +++ b/Source/Engine/Render2D/Font.h @@ -234,6 +234,13 @@ public: API_FUNCTION() void Invalidate(); public: + /// + /// Gets the scale factor that maps the rasterized font size to the actual rendered size. + /// + /// The layout scale. + /// The scale factor. + float GetScale(float layoutScale) const; + /// /// Processes text to get cached lines for rendering. /// diff --git a/Source/Engine/Render2D/FontAsset.cpp b/Source/Engine/Render2D/FontAsset.cpp index 39fe89207..adc641f0d 100644 --- a/Source/Engine/Render2D/FontAsset.cpp +++ b/Source/Engine/Render2D/FontAsset.cpp @@ -69,7 +69,7 @@ void FontAsset::unload(bool isReloading) _fontFile.Release(); _virtualBold = nullptr; _virtualItalic = nullptr; - _virtualMSDF = nullptr; + _virtualRasterMode = nullptr; } AssetChunksFlag FontAsset::getChunksToPreload() const @@ -115,11 +115,11 @@ void FontAsset::SetOptions(const FontOptions& value) options.Flags |= FontFlags::Italic; _virtualItalic->SetOptions(options); } - if (_virtualMSDF) + if (_virtualRasterMode) { auto options = _options; - options.RasterMode = FontRasterMode::MSDF; - _virtualMSDF->SetOptions(options); + options.RasterMode = _options.RasterMode == FontRasterMode::MSDF ? FontRasterMode::Bitmap : FontRasterMode::MSDF; + _virtualRasterMode->SetOptions(options); } } @@ -176,20 +176,20 @@ FontAsset* FontAsset::GetItalic() return _virtualItalic; } -FontAsset* FontAsset::GetMSDF() +FontAsset* FontAsset::GetRasterMode(FontRasterMode rasterMode) { ScopeLock lock(Locker); - if (_options.RasterMode == FontRasterMode::MSDF) + if (_options.RasterMode == rasterMode) return this; - if (!_virtualMSDF) + if (!_virtualRasterMode) { - _virtualMSDF = Content::CreateVirtualAsset(); - _virtualMSDF->Init(_fontFile); + _virtualRasterMode = Content::CreateVirtualAsset(); + _virtualRasterMode->Init(_fontFile); auto options = _options; - options.RasterMode = FontRasterMode::MSDF; - _virtualMSDF->SetOptions(options); + options.RasterMode = rasterMode; + _virtualRasterMode->SetOptions(options); } - return _virtualMSDF; + return _virtualRasterMode; } bool FontAsset::Init(const BytesContainer& fontFile) diff --git a/Source/Engine/Render2D/FontAsset.h b/Source/Engine/Render2D/FontAsset.h index fe4458f43..0529469b9 100644 --- a/Source/Engine/Render2D/FontAsset.h +++ b/Source/Engine/Render2D/FontAsset.h @@ -233,7 +233,7 @@ private: Dictionary, FontCharacterEntry> _characterCache; AssetReference _virtualBold; AssetReference _virtualItalic; - AssetReference _virtualMSDF; + AssetReference _virtualRasterMode; public: /// @@ -293,10 +293,10 @@ public: API_FUNCTION() FontAsset* GetItalic(); /// - /// Gets the MSDF version of the font. Returns itself or creates a new virtual font asset using this font but rasterized with Multi-channel Signed Distance Field (MSDF). + /// Gets the different rasterization mode of the font. Returns itself or creates a new virtual font asset using this font but rasterized with the specified mode. /// /// The virtual font or this. - API_FUNCTION() FontAsset* GetMSDF(); + API_FUNCTION() FontAsset* GetRasterMode(FontRasterMode rasterMode); /// /// Initializes the font with a custom font file data. diff --git a/Source/Engine/Render2D/Render2D.cpp b/Source/Engine/Render2D/Render2D.cpp index efabc4b58..de6ad9215 100644 --- a/Source/Engine/Render2D/Render2D.cpp +++ b/Source/Engine/Render2D/Render2D.cpp @@ -1204,7 +1204,7 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, FontCharacterEntry previous; int32 kerning; FontOptions options = font->GetAsset()->GetOptions(); - float scale = 1.0f / FontManager::FontScale * (options.RasterMode == FontRasterMode::MSDF ? font->GetSize() / options.MSDFSize : 1.0f); + const float scale = font->GetScale(1.0f); const bool enableFallbackFonts = EnumHasAllFlags(Features, RenderingFeatures::FallbackFonts); // Render all characters @@ -1231,6 +1231,8 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, { // Get character entry font->GetCharacter(currentChar, entry, enableFallbackFonts); + // Fallback fonts may have different MSDFSize, so we need to calculate scale per character + const float entryScale = entry.Font->GetScale(1.0f); // Check if need to select/change font atlas (since characters even in the same font may be located in different atlases) if (fontAtlas == nullptr || entry.TextureIndex != fontAtlasIndex) @@ -1263,17 +1265,17 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, { kerning = 0; } - pointer.X += kerning * scale; + pointer.X += kerning * entryScale; previous = entry; // Omit whitespace characters if (!isWhitespace) { // Calculate character size and atlas coordinates - const float x = pointer.X + entry.OffsetX * scale; - const float y = pointer.Y + (font->GetHeight() + font->GetDescender() - entry.OffsetY) * scale; + const float x = pointer.X + entry.OffsetX * entryScale; + const float y = pointer.Y - entry.OffsetY * entryScale + (font->GetHeight() + font->GetDescender()) * scale; - Rectangle charRect(x, y, entry.UVSize.X * scale, entry.UVSize.Y * scale); + Rectangle charRect(x, y, entry.UVSize.X * entryScale, entry.UVSize.Y * entryScale); Float2 upperLeftUV = entry.UV * invAtlasSize; Float2 rightBottomUV = (entry.UV + entry.UVSize) * invAtlasSize; @@ -1286,7 +1288,7 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, } // Move - pointer.X += entry.AdvanceX * scale; + pointer.X += entry.AdvanceX * entryScale; } else { @@ -1320,7 +1322,7 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, FontCharacterEntry previous; int32 kerning; FontOptions options = font->GetAsset()->GetOptions(); - float scale = layout.Scale / FontManager::FontScale * (options.RasterMode == FontRasterMode::MSDF ? font->GetSize() / options.MSDFSize : 1.0f); + const float scale = font->GetScale(layout.Scale); const bool enableFallbackFonts = EnumHasAllFlags(Features, RenderingFeatures::FallbackFonts); // Process text to get lines @@ -1356,6 +1358,8 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, { // Get character entry font->GetCharacter(currentChar, entry, enableFallbackFonts); + // Fallback fonts may have different MSDFSize, so we need to calculate scale per character + const float entryScale = entry.Font->GetScale(layout.Scale); // Check if need to select/change font atlas (since characters even in the same font may be located in different atlases) if (fontAtlas == nullptr || entry.TextureIndex != fontAtlasIndex) @@ -1386,17 +1390,17 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, { kerning = 0; } - pointer.X += (float)kerning * scale; + pointer.X += (float)kerning * entryScale; previous = entry; // Omit whitespace characters if (!isWhitespace) { // Calculate character size and atlas coordinates - const float x = pointer.X + entry.OffsetX * scale; - const float y = pointer.Y - entry.OffsetY * scale + Math::Ceil((font->GetHeight() + font->GetDescender()) * scale); + const float x = pointer.X + entry.OffsetX * entryScale; + const float y = pointer.Y - entry.OffsetY * entryScale + Math::Ceil((font->GetHeight() + font->GetDescender()) * scale); - Rectangle charRect(x, y, entry.UVSize.X * scale, entry.UVSize.Y * scale); + Rectangle charRect(x, y, entry.UVSize.X * entryScale, entry.UVSize.Y * entryScale); charRect.Offset(layout.Bounds.Location); Float2 upperLeftUV = entry.UV * invAtlasSize; @@ -1410,7 +1414,7 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, } // Move - pointer.X += entry.AdvanceX * scale; + pointer.X += entry.AdvanceX * entryScale; } } } diff --git a/Source/Engine/UI/TextRender.cpp b/Source/Engine/UI/TextRender.cpp index 88e5f8b2b..ef95d27c4 100644 --- a/Source/Engine/UI/TextRender.cpp +++ b/Source/Engine/UI/TextRender.cpp @@ -172,7 +172,7 @@ void TextRender::UpdateLayout() // Pick a font (remove DPI text scale as the text is being placed in the world) auto font = Font->CreateFont(_size); - float scale = _layoutOptions.Scale / FontManager::FontScale * (Font->GetOptions().RasterMode == FontRasterMode::MSDF ? _size / Font->GetOptions().MSDFSize : 1.0f); + const float scale = font->GetScale(_layoutOptions.Scale); // Prepare FontTextureAtlas* fontAtlas = nullptr; @@ -217,6 +217,7 @@ void TextRender::UpdateLayout() if (c != '\n') { font->GetCharacter(c, entry); + const float entryScale = entry.Font->GetScale(_layoutOptions.Scale); // Check if need to select/change font atlas (since characters even in the same font may be located in different atlases) if (fontAtlas == nullptr || entry.TextureIndex != drawChunk.FontAtlasIndex) @@ -273,17 +274,17 @@ void TextRender::UpdateLayout() { kerning = 0; } - pointer.X += (float)kerning * scale; + pointer.X += (float)kerning * entryScale; previous = entry; // Omit whitespace characters if (!isWhitespace) { // Calculate character size and atlas coordinates - const float x = pointer.X + (float)entry.OffsetX * scale; - const float y = pointer.Y + (float)(font->GetHeight() + font->GetDescender() - entry.OffsetY) * scale; + const float x = pointer.X + (float)entry.OffsetX * entryScale; + const float y = pointer.Y - (float)entry.OffsetY * entryScale + (float)(font->GetHeight() + font->GetDescender()) * scale; - Rectangle charRect(x, y, entry.UVSize.X * scale, entry.UVSize.Y * scale); + Rectangle charRect(x, y, entry.UVSize.X * entryScale, entry.UVSize.Y * entryScale); charRect.Offset(_layoutOptions.Bounds.Location); Float2 upperLeftUV = entry.UV * invAtlasSize; @@ -326,7 +327,7 @@ void TextRender::UpdateLayout() } // Move - pointer.X += (float)entry.AdvanceX * scale; + pointer.X += (float)entry.AdvanceX * entryScale; } } } From c41c5ad561fa256418a4d0d153eb10808593b1d2 Mon Sep 17 00:00:00 2001 From: fibref Date: Sat, 15 Aug 2026 18:15:44 +0800 Subject: [PATCH 10/31] add MSDFSize slider limit --- Source/Editor/Windows/Assets/FontWindow.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Editor/Windows/Assets/FontWindow.cs b/Source/Editor/Windows/Assets/FontWindow.cs index 71f8d800d..fe05b3b53 100644 --- a/Source/Editor/Windows/Assets/FontWindow.cs +++ b/Source/Editor/Windows/Assets/FontWindow.cs @@ -27,7 +27,7 @@ namespace FlaxEditor.Windows.Assets public FontRasterMode RasterMode; [DefaultValue(32.0f)] - [EditorOrder(6), EditorDisplay("Properties"), Tooltip("The font size used when generating MSDF font atlases.")] + [EditorOrder(6), Limit(4, 512), EditorDisplay("Properties"), Tooltip("The font size used when generating MSDF font atlases.")] public float MSDFSize; [DefaultValue(FontHinting.Default)] From 8fef3b7f651e693887fa73b4e210b2308cc8ae98 Mon Sep 17 00:00:00 2001 From: fibref Date: Sat, 15 Aug 2026 19:22:41 +0800 Subject: [PATCH 11/31] fix missing include --- Source/Engine/Render2D/FontAsset.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Source/Engine/Render2D/FontAsset.h b/Source/Engine/Render2D/FontAsset.h index 0529469b9..c8bcf435c 100644 --- a/Source/Engine/Render2D/FontAsset.h +++ b/Source/Engine/Render2D/FontAsset.h @@ -4,6 +4,9 @@ #include "Engine/Content/BinaryAsset.h" #include "Engine/Content/AssetReference.h" +#include "Engine/Core/Collections/Array.h" +#include "Engine/Core/Collections/Dictionary.h" +#include "Engine/Core/Math/Vector2.h" class Font; class FontManager; From 66de97c8500f074c3605e099512c5bd3746ecc57 Mon Sep 17 00:00:00 2001 From: Ari Vuollet Date: Tue, 18 Aug 2026 01:15:48 +0300 Subject: [PATCH 12/31] Fix In/Out attribute warning in generated bindings for boolean arrays --- Source/Tools/Flax.Build/Bindings/BindingsGenerator.CSharp.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Source/Tools/Flax.Build/Bindings/BindingsGenerator.CSharp.cs b/Source/Tools/Flax.Build/Bindings/BindingsGenerator.CSharp.cs index 174c6de2c..62506c5ec 100644 --- a/Source/Tools/Flax.Build/Bindings/BindingsGenerator.CSharp.cs +++ b/Source/Tools/Flax.Build/Bindings/BindingsGenerator.CSharp.cs @@ -687,7 +687,11 @@ namespace Flax.Build.Bindings else if (nativeType == "object[]") parameterMarshalType = "MarshalUsing(typeof(FlaxEngine.Interop.SystemObjectArrayMarshaller))"; else if (parameterInfo.Type.Type == "Array" && parameterInfo.Type.GenericArgs.Count > 0 && parameterInfo.Type.GenericArgs[0].Type == "bool") + { parameterMarshalType = $"MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.U1, SizeParamIndex = {(!functionInfo.IsStatic ? 1 : 0) + functionInfo.Parameters.Count + (functionInfo.Glue.CustomParameters.FindIndex(x => x.Name == $"__{parameterInfo.Name}Count"))})"; + if (!parameterInfo.IsOut && !parameterInfo.IsRef) + parameterMarshalType += ", In"; // The usage of 'LibraryImportAttribute' does not follow recommendations. It is recommended to use explicit '[In]' and '[Out]' attributes on array parameters. + } else if (parameterInfo.Type.Type == "Array" || parameterInfo.Type.Type == "Span" || parameterInfo.Type.Type == "DataContainer" || parameterInfo.Type.Type == "BytesContainer" || nativeType == "Array") { parameterMarshalType = $"MarshalUsing(typeof(FlaxEngine.Interop.ArrayMarshaller<,>), CountElementName = \"__{parameterInfo.Name}Count\")"; From 151f573040a902fe7cdcef10efe889fb6ce17ced Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Fri, 18 Sep 2026 12:55:02 +0200 Subject: [PATCH 13/31] Add current .NET version to the main window tooltip --- Source/Editor/Modules/UIModule.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Source/Editor/Modules/UIModule.cs b/Source/Editor/Modules/UIModule.cs index f94b87ec3..22f6e0233 100644 --- a/Source/Editor/Modules/UIModule.cs +++ b/Source/Editor/Modules/UIModule.cs @@ -835,6 +835,7 @@ namespace FlaxEditor.Modules tooltip.Append($" ({engineNickname})"); // Build info + tooltip.AppendLine(System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription); #if USE_LARGE_WORLDS tooltip.AppendLine("Large Worlds Enabled"); #endif From dae5b416caab819ab816eef87ce8b20067bb876b Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Fri, 18 Sep 2026 13:08:16 +0200 Subject: [PATCH 14/31] Minor tweaks #4130 --- Source/Editor/Windows/Assets/JsonAssetWindow.cs | 1 + Source/Editor/Windows/Assets/ModelBaseWindow.cs | 1 + Source/Engine/Content/JsonAsset.cs | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Source/Editor/Windows/Assets/JsonAssetWindow.cs b/Source/Editor/Windows/Assets/JsonAssetWindow.cs index b1863fce9..75acd74dd 100644 --- a/Source/Editor/Windows/Assets/JsonAssetWindow.cs +++ b/Source/Editor/Windows/Assets/JsonAssetWindow.cs @@ -268,6 +268,7 @@ namespace FlaxEditor.Windows.Assets { Asset?.ClearInstance(); _object = null; + base.DiscardChanges(); } diff --git a/Source/Editor/Windows/Assets/ModelBaseWindow.cs b/Source/Editor/Windows/Assets/ModelBaseWindow.cs index c18177b14..eb22396f0 100644 --- a/Source/Editor/Windows/Assets/ModelBaseWindow.cs +++ b/Source/Editor/Windows/Assets/ModelBaseWindow.cs @@ -841,6 +841,7 @@ namespace FlaxEditor.Windows.Assets protected override void DiscardChanges() { _asset?.Reload(); + base.DiscardChanges(); } diff --git a/Source/Engine/Content/JsonAsset.cs b/Source/Engine/Content/JsonAsset.cs index 35f6675d8..86ed68c1b 100644 --- a/Source/Engine/Content/JsonAsset.cs +++ b/Source/Engine/Content/JsonAsset.cs @@ -98,7 +98,7 @@ namespace FlaxEngine /// /// Clears the cached managed instance. The next access will recreate it from the asset data. /// - public void ClearInstance() + internal void ClearInstance() { _instance = null; } From 01c9f199043baae9e5c72be3b8ec4e999fd725dd Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Sat, 19 Sep 2026 06:20:32 +0200 Subject: [PATCH 15/31] Refactor #4179 by using `RichTextBox` for Debug Log title formatting --- Source/Editor/Windows/DebugLogWindow.cs | 244 ++++-------------- .../Engine/UI/GUI/Common/RichTextBoxBase.cs | 5 + 2 files changed, 54 insertions(+), 195 deletions(-) diff --git a/Source/Editor/Windows/DebugLogWindow.cs b/Source/Editor/Windows/DebugLogWindow.cs index 64cb29f88..5183b7454 100644 --- a/Source/Editor/Windows/DebugLogWindow.cs +++ b/Source/Editor/Windows/DebugLogWindow.cs @@ -13,7 +13,6 @@ using FlaxEditor.Options; using FlaxEngine; using FlaxEngine.Assertions; using FlaxEngine.GUI; -using FlaxEngine.Utilities; using Object = FlaxEngine.Object; namespace FlaxEditor.Windows @@ -70,67 +69,6 @@ namespace FlaxEditor.Windows private class LogEntry : Control { - /// - /// Text block with only color available, no other styling. - /// - private struct TextColorBlock - { - /// - /// Color of this block. - /// - public Color TextColor; - - /// - /// Range of the text. - /// - public TextRange Range; - - /// - /// The text location and size. - /// - public Rectangle Bounds; - } - - /// - /// Rich text parsing context. - /// - private struct ColorParseContext - { - /// - /// LogEntry control. - /// - public LogEntry Control; - - /// - /// Caret location for the next text block. - /// - public Float2 Caret; - - /// - /// Current parsed color. - /// - public Color CurrentColor; - - /// - /// Default color, for reverting color on closing tags. - /// - public Color DefaultColor; - - /// - /// Current text font for text processing. - /// - public Font TextFont; - - /// - /// Add text block to the control. - /// - /// The text block to add. - public void AddBlock(ref TextColorBlock block) - { - Control._textBlocks.Add(block); - } - } - private bool _isRightMouseDown; /// @@ -139,12 +77,13 @@ namespace FlaxEditor.Windows public const float DefaultHeight = 32.0f; private DebugLogWindow _window; + private RichTextBox _richTextBox; public LogGroup Group; public LogEntryDescription Desc; public SpriteHandle Icon; public int LogCount = 1; - private readonly List _textBlocks = new List(); + private Color Color => Group == LogGroup.Error ? _window._colorError : (Group == LogGroup.Warning ? _window._colorWarning : _window._colorInfo); public LogEntry(DebugLogWindow window, ref LogEntryDescription desc) : base(0, 0, 120, DefaultHeight) @@ -171,123 +110,21 @@ namespace FlaxEditor.Windows break; } - // Color parsing - var style = Style.Current; - var color = Group == LogGroup.Error ? _window._colorError : (Group == LogGroup.Warning ? _window._colorWarning : _window._colorInfo); - - HtmlParser parser = new(); - - parser.Reset(Desc.Title); - - var context = new ColorParseContext + // Use Rich Text Box to display title if it contains any HTML tags + if (desc.Title.Contains('<') && desc.Title.Contains('>')) { - Control = this, - Caret = Float2.Zero, - CurrentColor = color, - DefaultColor = color, - TextFont = style.FontMedium, - }; - - int pointerPos = 0; - - while (parser.ParseNext(out var tag)) - { - if (tag.Name.ToLower() == "color") + _richTextBox = new RichTextBox { - ProcessTextBlock(ref context, pointerPos, tag.StartPosition); - - pointerPos = tag.EndPosition; - - ProcessColorTag(ref context, ref tag); - } - } - - // Processing leftover text - ProcessTextBlock(ref context, pointerPos, Desc.Title.Length); - } - - /// - /// Processing the text block within given range and adding it to the list. - /// - /// The parsing context. - /// Start of the range.(Character index) - /// End of the range.(Character index) - private void ProcessTextBlock(ref ColorParseContext context, int startPos, int endPos) - { - // Text block preset - var textBlock = new TextColorBlock() - { - TextColor = context.CurrentColor, - Range = new TextRange(startPos, endPos), - Bounds = new Rectangle(context.Caret, Float2.Zero) - }; - - // Processing the text with selected font. (Handle newlines, text offsets) - var lines = context.TextFont.ProcessText(Desc.Title, ref textBlock.Range); - - if (lines == null || lines.Length == 0) - { - return; - } - for (int i = 0; i < lines.Length; i++) - { - ref var line = ref lines[i]; - textBlock.Range = new TextRange - { - StartIndex = startPos + line.FirstCharIndex, - EndIndex = startPos + line.LastCharIndex + 1 + ClipText = false, + HasBorder = false, + BackgroundColor = Color.Transparent, + Text = desc.Title, }; - - // Move to the next line - if (i != 0) + if (_window._colorDebugLogText) { - context.Caret.Y += line.Size.Y; - textBlock.Bounds.X = 0; - textBlock.Bounds.Y += line.Size.Y; - } - - textBlock.Bounds.X += line.Location.X; - textBlock.Bounds.Size = line.Size; - context.AddBlock(ref textBlock); - } - - // Caret location for the next text block - var lastLine = lines[lines.Length - 1]; - if (lines.Length == 1) - { - context.Caret.X += lastLine.Size.X; - } - else - { - context.Caret.X = lastLine.Size.X; - } - } - - /// - /// Parse color info from the tag and handle closing tags. - /// - /// The parsing context. - /// Tag to process. - private static void ProcessColorTag(ref ColorParseContext context, ref HtmlTag tag) - { - // Closing tag - if (tag.IsSlash) - { - context.CurrentColor = context.DefaultColor; - } - else - { - // Parse color - if (tag.Attributes.TryGetValue(string.Empty, out string colorText)) - { - if (Color.TryParse(colorText, out Color colorVal)) - { - context.CurrentColor = colorVal; - } - } - else - { - context.CurrentColor = context.DefaultColor; + var style = _richTextBox.TextStyle; + style.Color = Color; + _richTextBox.TextStyle = style; } } } @@ -300,12 +137,10 @@ namespace FlaxEditor.Windows /// public override void Draw() { - base.Draw(); - - // Cache data var style = Style.Current; var index = IndexInParent; var clientRect = new Rectangle(Float2.Zero, Size); + var color = Color; // Background if (_window._selected == this) @@ -320,37 +155,44 @@ namespace FlaxEditor.Windows else if (index % 2 == 0) Render2D.FillRectangle(clientRect, style.Background * 0.9f); - var color = Group == LogGroup.Error ? _window._colorError : (Group == LogGroup.Warning ? _window._colorWarning : _window._colorInfo); - // Icon Render2D.DrawSprite(Icon, new Rectangle(8, 0, 32, 32), color); // Title - var textLocation = new Float2(43, 2); + var textRect = new Rectangle(43, 2, clientRect.Width - 40, clientRect.Height - 10); Render2D.PushClip(ref clientRect); bool coloredText = _window._colorDebugLogText; - - // Render text blocks with their colors - for (int i = 0; i < _textBlocks.Count; i++) + if (_richTextBox != null) { - TextColorBlock block = _textBlocks[i]; - Render2D.DrawText(style.FontMedium, Desc.Title, ref block.Range, - coloredText ? block.TextColor : style.Foreground, textLocation + block.Bounds.Location); + Render2D.PushTransform(Matrix3x3.Translation2D(textRect.Location)); + _richTextBox.DrawSelf(); + Render2D.PopTransform(); + } + else + { + Render2D.DrawText(style.FontMedium, Desc.Title, textRect, coloredText ? color : style.Foreground); } // Adding log counter for collapsed logs if (LogCount > 1) { - Float2 numberLocation = textLocation; - if (_textBlocks.Count > 0) + Float2 logCountPos = Float2.Zero; + if (_richTextBox != null) { - TextColorBlock block = _textBlocks[_textBlocks.Count - 1]; - numberLocation += block.Bounds.Location; - numberLocation.X += block.Bounds.Size.X; + var blocks = _richTextBox.TextBlocks; + if (blocks.Count != 0) + { + var block = blocks[^1]; + logCountPos = new Float2(block.Bounds.Right, block.Bounds.Top); + } } - - Render2D.DrawText(style.FontMedium, $" ({LogCount})", color, numberLocation); + else + { + logCountPos.X = style.FontMedium.MeasureText(Desc.Title).X; + } + Render2D.DrawText(style.FontMedium, $" ({LogCount})", color, textRect.Location + logCountPos); } + Render2D.PopClip(); } @@ -489,6 +331,18 @@ namespace FlaxEditor.Windows base.OnMouseLeave(); } + + /// + public override void OnDestroy() + { + if (_richTextBox != null) + { + _richTextBox.OnDestroy(); + _richTextBox = null; + } + + base.OnDestroy(); + } } private readonly SplitPanel _split; diff --git a/Source/Engine/UI/GUI/Common/RichTextBoxBase.cs b/Source/Engine/UI/GUI/Common/RichTextBoxBase.cs index 97e1d88a5..b83a89943 100644 --- a/Source/Engine/UI/GUI/Common/RichTextBoxBase.cs +++ b/Source/Engine/UI/GUI/Common/RichTextBoxBase.cs @@ -29,6 +29,11 @@ namespace FlaxEngine.GUI [HideInEditor] public ParseTextBlocksDelegate ParseTextBlocks; + /// + /// Gets the list of parsed text blocks. + /// + public List TextBlocks => _textBlocks; + /// /// Initializes a new instance of the class. /// From acaaafacbc82473ecc4f0ab2171796eb0167a7bc Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Sat, 19 Sep 2026 06:20:47 +0200 Subject: [PATCH 16/31] Use better group name for Debug Log options --- Source/Editor/Options/VisualOptions.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Source/Editor/Options/VisualOptions.cs b/Source/Editor/Options/VisualOptions.cs index e0f0982f4..463dda590 100644 --- a/Source/Editor/Options/VisualOptions.cs +++ b/Source/Editor/Options/VisualOptions.cs @@ -106,28 +106,28 @@ namespace FlaxEditor.Options /// Gets or sets the output log text color. /// [DefaultValue(typeof(Color), "1,1,1,1")] - [EditorDisplay("Log", "Info Color"), EditorOrder(1500), Tooltip("The color used for info messages in the Debug and Output Log.")] + [EditorDisplay("Debug Log", "Info Color"), EditorOrder(1500), Tooltip("The color used for info messages in the Debug and Output Log.")] public Color LogInfoColor { get; set; } = Color.White; /// /// Gets or sets the output log text color for warnings /// [DefaultValue(typeof(Color), "1,1,0,1")] - [EditorDisplay("Log", "Warning Color"), EditorOrder(1501), Tooltip("The color used for warnings in the Debug and Output Log.")] + [EditorDisplay("Debug Log", "Warning Color"), EditorOrder(1501), Tooltip("The color used for warnings in the Debug and Output Log.")] public Color LogWarningColor { get; set; } = Color.Yellow; /// /// Gets or sets the output log text color for errors /// [DefaultValue(typeof(Color), "1,0,0,1")] - [EditorDisplay("Log", "Error Color"), EditorOrder(1502), Tooltip("The color used for errors in the Debug and Output Log.")] + [EditorDisplay("Debug Log", "Error Color"), EditorOrder(1502), Tooltip("The color used for errors in the Debug and Output Log.")] public Color LogErrorColor { get; set; } = Color.Red; /// - /// Gets or sets a value wether the Debug Log entry text color should use the set color. + /// Gets or sets a value whether the Debug Log entry text color should use the set color. /// [DefaultValue(true)] - [EditorDisplay("Log", "Color Debug Log Text"), EditorOrder(1503), Tooltip("Wether to use the set colors in the text of a Debug Log entry.")] + [EditorDisplay("Debug Log", "Color Debug Log Text"), EditorOrder(1503), Tooltip("Wether to use the set colors in the text of a Debug Log entry.")] public bool ColorDebugLogText = true; } } From 451985612b22d0ec088ea2fc6a43704d48b4cda0 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Sat, 19 Sep 2026 20:05:36 +0200 Subject: [PATCH 17/31] Various tweaks to #4218 --- Source/Editor/Windows/Assets/FontWindow.cs | 5 +++-- Source/Engine/Render2D/Font.cpp | 22 ++++++++++++++++++---- Source/Engine/Render2D/Font.h | 4 ++-- Source/Engine/Render2D/FontAsset.cpp | 3 ++- Source/Engine/Render2D/Render2D.cpp | 10 ++++------ 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/Source/Editor/Windows/Assets/FontWindow.cs b/Source/Editor/Windows/Assets/FontWindow.cs index fe05b3b53..2f27a3f1b 100644 --- a/Source/Editor/Windows/Assets/FontWindow.cs +++ b/Source/Editor/Windows/Assets/FontWindow.cs @@ -1,6 +1,5 @@ // Copyright (c) Wojciech Figat. All rights reserved. -using System; using System.ComponentModel; using FlaxEditor.Content; using FlaxEditor.CustomEditors; @@ -26,7 +25,7 @@ namespace FlaxEditor.Windows.Assets [EditorOrder(5), EditorDisplay("Properties"), Tooltip("The rasterization mode used when generating font atlases.")] public FontRasterMode RasterMode; - [DefaultValue(32.0f)] + [DefaultValue(32.0f), VisibleIf(nameof(IsMSDF))] [EditorOrder(6), Limit(4, 512), EditorDisplay("Properties"), Tooltip("The font size used when generating MSDF font atlases.")] public float MSDFSize; @@ -46,6 +45,8 @@ namespace FlaxEditor.Windows.Assets [EditorOrder(40), EditorDisplay("Properties"), Tooltip("Enables slant effect, emulating italic style.")] public bool Italic; + private bool IsMSDF => RasterMode == FontRasterMode.MSDF; + public void Get(out FontOptions options) { options = new FontOptions diff --git a/Source/Engine/Render2D/Font.cpp b/Source/Engine/Render2D/Font.cpp index fb88c8256..37ce0f505 100644 --- a/Source/Engine/Render2D/Font.cpp +++ b/Source/Engine/Render2D/Font.cpp @@ -36,7 +36,8 @@ Font::~Font() void Font::GetCharacter(Char c, FontCharacterEntry& result, bool enableFallback) { // Try to get the character or cache it if cannot be found - const auto key = Pair(_asset->GetOptions().RasterMode == FontRasterMode::MSDF ? _asset->GetOptions().MSDFSize : GetSize(), c); + const FontOptions& options = _asset->GetOptions(); + const auto key = Pair(options.RasterMode == FontRasterMode::MSDF ? options.MSDFSize : GetSize(), c); if (_asset->_characterCache.TryGet(key, result)) { // With MSDF font introduced, cached entry may be created by a different font (with same MSDFSize) @@ -63,7 +64,7 @@ void Font::GetCharacter(Char c, FontCharacterEntry& result, bool enableFallback) FontAsset* fallbackFont = FallbackFonts.Get()[fallbackIndex].Get(); if (fallbackFont && fallbackFont->ContainsChar(c)) { - fallbackFont->GetRasterMode(_asset->GetOptions().RasterMode)->CreateFont(GetSize())->GetCharacter(c, result, enableFallback); + fallbackFont->GetRasterMode(options.RasterMode)->CreateFont(GetSize())->GetCharacter(c, result, enableFallback); return; } } @@ -121,6 +122,17 @@ void Font::Invalidate() { ScopeLock lock(_asset->Locker); + // Invalidate cached characters (from atlas) + for (auto i = _asset->_characterCache.Begin(); i.IsNotEnd(); ++i) + { + if (i->Value.Font == this) + { + FontManager::Invalidate(i->Value); + _asset->_characterCache.Remove(i); + } + } + + // Rebuild font metrics FlushFaceSize(); const FT_Face face = _asset->GetFTFace(); ASSERT(face != nullptr); @@ -134,7 +146,8 @@ void Font::Invalidate() float Font::GetScale(float layoutScale) const { - return layoutScale / FontManager::FontScale * (_asset->GetOptions().RasterMode == FontRasterMode::MSDF ? _size / _asset->GetOptions().MSDFSize : 1.0f); + const FontOptions& options = _asset->GetOptions(); + return layoutScale / FontManager::FontScale * (options.RasterMode == FontRasterMode::MSDF ? _size / options.MSDFSize : 1.0f); } void Font::ProcessText(const StringView& text, Array>& outputLines, const TextLayoutOptions& layout) @@ -491,7 +504,8 @@ void Font::FlushFaceSize() const { // Set the character size const FT_Face face = _asset->GetFTFace(); - float size = _asset->GetOptions().RasterMode == FontRasterMode::MSDF ? _asset->GetOptions().MSDFSize : _size; + const FontOptions& options = _asset->GetOptions(); + float size = options.RasterMode == FontRasterMode::MSDF ? options.MSDFSize : _size; const FT_Error error = FT_Set_Char_Size(face, 0, ConvertPixelTo26Dot6(size * FontManager::FontScale), DefaultDPI, DefaultDPI); if (error) { diff --git a/Source/Engine/Render2D/Font.h b/Source/Engine/Render2D/Font.h index c8fd960d0..ada57ca9d 100644 --- a/Source/Engine/Render2D/Font.h +++ b/Source/Engine/Render2D/Font.h @@ -211,7 +211,7 @@ public: /// /// The character. /// The output character entry. - /// True if fallback to secondary font when the primary font doesn't contains this character. + /// True if fallback to secondary font when the primary font doesn't contain this character. void GetCharacter(Char c, FontCharacterEntry& result, bool enableFallback = true); /// @@ -229,7 +229,7 @@ public: API_FUNCTION() void CacheText(const StringView& text); /// - /// Refresh cached metrics. Can be used after changing font asset options. + /// Refresh cached metrics and invalidates all cached characters (in atlases). Can be used after changing font asset options. /// API_FUNCTION() void Invalidate(); diff --git a/Source/Engine/Render2D/FontAsset.cpp b/Source/Engine/Render2D/FontAsset.cpp index adc641f0d..1b58d472c 100644 --- a/Source/Engine/Render2D/FontAsset.cpp +++ b/Source/Engine/Render2D/FontAsset.cpp @@ -234,9 +234,10 @@ bool FontAsset::ContainsChar(Char c) const void FontAsset::Invalidate() { ScopeLock lock(Locker); + + // Invalidate cached characters (from atlas) for (auto& entry : _characterCache) FontManager::Invalidate(entry.Value); - _characterCache.Clear(); // Refresh cached metrics of all fonts created from this asset diff --git a/Source/Engine/Render2D/Render2D.cpp b/Source/Engine/Render2D/Render2D.cpp index de6ad9215..359190665 100644 --- a/Source/Engine/Render2D/Render2D.cpp +++ b/Source/Engine/Render2D/Render2D.cpp @@ -1203,7 +1203,7 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, Float2 invAtlasSize = Float2::One; FontCharacterEntry previous; int32 kerning; - FontOptions options = font->GetAsset()->GetOptions(); + const FontOptions& options = font->GetAsset()->GetOptions(); const float scale = font->GetScale(1.0f); const bool enableFallbackFonts = EnumHasAllFlags(Features, RenderingFeatures::FallbackFonts); @@ -1231,6 +1231,7 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, { // Get character entry font->GetCharacter(currentChar, entry, enableFallbackFonts); + // Fallback fonts may have different MSDFSize, so we need to calculate scale per character const float entryScale = entry.Font->GetScale(1.0f); @@ -1274,9 +1275,7 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, // Calculate character size and atlas coordinates const float x = pointer.X + entry.OffsetX * entryScale; const float y = pointer.Y - entry.OffsetY * entryScale + (font->GetHeight() + font->GetDescender()) * scale; - Rectangle charRect(x, y, entry.UVSize.X * entryScale, entry.UVSize.Y * entryScale); - Float2 upperLeftUV = entry.UV * invAtlasSize; Float2 rightBottomUV = (entry.UV + entry.UVSize) * invAtlasSize; @@ -1321,7 +1320,7 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, Float2 invAtlasSize = Float2::One; FontCharacterEntry previous; int32 kerning; - FontOptions options = font->GetAsset()->GetOptions(); + const FontOptions& options = font->GetAsset()->GetOptions(); const float scale = font->GetScale(layout.Scale); const bool enableFallbackFonts = EnumHasAllFlags(Features, RenderingFeatures::FallbackFonts); @@ -1358,6 +1357,7 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, { // Get character entry font->GetCharacter(currentChar, entry, enableFallbackFonts); + // Fallback fonts may have different MSDFSize, so we need to calculate scale per character const float entryScale = entry.Font->GetScale(layout.Scale); @@ -1399,10 +1399,8 @@ void Render2D::DrawText(Font* font, const StringView& text, const Color& color, // Calculate character size and atlas coordinates const float x = pointer.X + entry.OffsetX * entryScale; const float y = pointer.Y - entry.OffsetY * entryScale + Math::Ceil((font->GetHeight() + font->GetDescender()) * scale); - Rectangle charRect(x, y, entry.UVSize.X * entryScale, entry.UVSize.Y * entryScale); charRect.Offset(layout.Bounds.Location); - Float2 upperLeftUV = entry.UV * invAtlasSize; Float2 rightBottomUV = (entry.UV + entry.UVSize) * invAtlasSize; From 8565e8bfd10a4e3b6d6488dfead0514e749e1d56 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Sat, 19 Sep 2026 20:05:42 +0200 Subject: [PATCH 18/31] Update Editor assets --- Content/Editor/Fonts/Inconsolata-Regular.flax | 4 ++-- Content/Editor/Fonts/NotoSansSC-Regular.flax | 4 ++-- Content/Editor/Fonts/Roboto-Regular.flax | 4 ++-- Content/Editor/Fonts/SegMDL2.flax | 4 ++-- Content/Editor/Fonts/Segoe Media Center Regular.flax | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Content/Editor/Fonts/Inconsolata-Regular.flax b/Content/Editor/Fonts/Inconsolata-Regular.flax index d85ef610a..6046f2793 100644 --- a/Content/Editor/Fonts/Inconsolata-Regular.flax +++ b/Content/Editor/Fonts/Inconsolata-Regular.flax @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:139e2f54a268f4febc753aaa9807a33a6eb2dd84e788c5b8b4f50ca8683d2b3b -size 93116 +oid sha256:e931314736f3fd3aaa7e22a83af1fc8512824e6d6c83cde9edfc21d69d83ad23 +size 93121 diff --git a/Content/Editor/Fonts/NotoSansSC-Regular.flax b/Content/Editor/Fonts/NotoSansSC-Regular.flax index 8f8f00157..c458b3255 100644 --- a/Content/Editor/Fonts/NotoSansSC-Regular.flax +++ b/Content/Editor/Fonts/NotoSansSC-Regular.flax @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2ddc7af5fab1dd4ad858ad2abf11c6c243d908345720892cd56242fbc9c33b02 -size 10560900 +oid sha256:b19ad5b09e6fe82f7547e13d8b5dc63b84fb8253654a1f98ef784109b3d33e80 +size 10560905 diff --git a/Content/Editor/Fonts/Roboto-Regular.flax b/Content/Editor/Fonts/Roboto-Regular.flax index 01251cac6..fca17395b 100644 --- a/Content/Editor/Fonts/Roboto-Regular.flax +++ b/Content/Editor/Fonts/Roboto-Regular.flax @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c0584b3a3c186364464de155edece5cb6a8c46000676ac82a45f951f3a67fac5 -size 172194 +oid sha256:b96520c348ef1b63e7514dd739ce7440503e8b4462edd830a271b20dd60730c2 +size 172199 diff --git a/Content/Editor/Fonts/SegMDL2.flax b/Content/Editor/Fonts/SegMDL2.flax index b627849d9..ea9e3c4e2 100644 --- a/Content/Editor/Fonts/SegMDL2.flax +++ b/Content/Editor/Fonts/SegMDL2.flax @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f7a9d723d6342b8a484dfb2ee784cef506b3311a6092be0e9de35f941d698ff5 -size 204150 +oid sha256:eeeb5ea36b751130cce7b03127f7d1c26b05e906252c3532ed83a3e154a82bf9 +size 204155 diff --git a/Content/Editor/Fonts/Segoe Media Center Regular.flax b/Content/Editor/Fonts/Segoe Media Center Regular.flax index 6c17d96bf..06efc4cf0 100644 --- a/Content/Editor/Fonts/Segoe Media Center Regular.flax +++ b/Content/Editor/Fonts/Segoe Media Center Regular.flax @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c21443a111daede658cb634f56b4ae0838dbb2eaa75d4ba8ba74a4c77df44de8 -size 72798 +oid sha256:0c203e22557bbdb86f6b4c13f9fc954d88fff92911cc90706508be57506a5dd6 +size 72803 From e048913bfdfe96afde7bae2368846d10ed3a1f86 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Sat, 19 Sep 2026 20:15:02 +0200 Subject: [PATCH 19/31] Move `FontCharacterEntry` to a separate header file #4218 --- Source/Engine/Render2D/Font.h | 1 - Source/Engine/Render2D/FontAsset.h | 106 +------------------ Source/Engine/Render2D/FontCharacterEntry.h | 109 ++++++++++++++++++++ 3 files changed, 110 insertions(+), 106 deletions(-) create mode 100644 Source/Engine/Render2D/FontCharacterEntry.h diff --git a/Source/Engine/Render2D/Font.h b/Source/Engine/Render2D/Font.h index ada57ca9d..b3ddd25f8 100644 --- a/Source/Engine/Render2D/Font.h +++ b/Source/Engine/Render2D/Font.h @@ -10,7 +10,6 @@ #include "TextLayoutOptions.h" class FontAsset; -struct FontTextureAtlasSlot; struct FontCharacterEntry; // The default DPI that engine is using diff --git a/Source/Engine/Render2D/FontAsset.h b/Source/Engine/Render2D/FontAsset.h index c8bcf435c..0a5c67255 100644 --- a/Source/Engine/Render2D/FontAsset.h +++ b/Source/Engine/Render2D/FontAsset.h @@ -7,116 +7,12 @@ #include "Engine/Core/Collections/Array.h" #include "Engine/Core/Collections/Dictionary.h" #include "Engine/Core/Math/Vector2.h" +#include "Engine/Render2D/FontCharacterEntry.h" class Font; class FontManager; -struct FontTextureAtlasSlot; typedef struct FT_FaceRec_* FT_Face; -// Font glyph metrics: -// -// xmin xmax -// | | -// |<-------- width -------->| -// | | -// | +-------------------------+----------------- ymax -// | | ggggggggg ggggg | ^ ^ -// | | g:::::::::ggg::::g | | | -// | | g:::::::::::::::::g | | | -// | | g::::::ggggg::::::gg | | | -// | | g:::::g g:::::g | | | -// offsetX -|-------->| g:::::g g:::::g | offsetY | -// | | g:::::g g:::::g | | | -// | | g::::::g g:::::g | | | -// | | g:::::::ggggg:::::g | | | -// | | g::::::::::::::::g | | height -// | | gg::::::::::::::g | | | -// baseline ---*---------|---- gggggggg::::::g-----*-------- | -// / | | g:::::g | | -// origin | | gggggg g:::::g | | -// | | g:::::gg gg:::::g | | -// | | g::::::ggg:::::::g | | -// | | gg:::::::::::::g | | -// | | ggg::::::ggg | | -// | | gggggg | v -// | +-------------------------+----------------- ymin -// | | -// |------------- advanceX ----------->| - -/// -/// The cached font character entry (read for rendering and further processing). -/// -API_STRUCT(NoDefault) struct FLAXENGINE_API FontCharacterEntry -{ - DECLARE_SCRIPTING_TYPE_MINIMAL(FontCharacterEntry); - - /// - /// The character represented by this entry. - /// - API_FIELD() Char Character; - - /// - /// True if entry is valid, otherwise false. - /// - API_FIELD() bool IsValid = false; - - /// - /// The index to a specific texture in the font cache. - /// - API_FIELD() byte TextureIndex; - - /// - /// The left bearing expressed in integer pixels. - /// - API_FIELD() int16 OffsetX; - - /// - /// The top bearing expressed in integer pixels. - /// - API_FIELD() int16 OffsetY; - - /// - /// The amount to advance in X before drawing the next character in a string. - /// - API_FIELD() int16 AdvanceX; - - /// - /// The distance from baseline to glyph top most point. - /// - API_FIELD() int16 BearingY; - - /// - /// The height in pixels of the glyph. - /// - API_FIELD() int16 Height; - - /// - /// The start location of the character in the texture (in texture coordinates space). - /// - API_FIELD() Float2 UV; - - /// - /// The size the character in the texture (in texture coordinates space). - /// - API_FIELD() Float2 UVSize; - - /// - /// The slot in texture atlas, containing the pixel data of the glyph. - /// - API_FIELD() const FontTextureAtlasSlot* Slot; - - /// - /// The owner font. - /// - API_FIELD() const class Font* Font; -}; - -template<> -struct TIsPODType -{ - enum { Value = true }; -}; - /// /// The font hinting used when rendering characters. /// diff --git a/Source/Engine/Render2D/FontCharacterEntry.h b/Source/Engine/Render2D/FontCharacterEntry.h new file mode 100644 index 000000000..f73099beb --- /dev/null +++ b/Source/Engine/Render2D/FontCharacterEntry.h @@ -0,0 +1,109 @@ +// Copyright (c) Wojciech Figat. All rights reserved. + +#pragma once + +#include "Engine/Core/Math/Vector2.h" + +// Font glyph metrics: +// +// xmin xmax +// | | +// |<-------- width -------->| +// | | +// | +-------------------------+----------------- ymax +// | | ggggggggg ggggg | ^ ^ +// | | g:::::::::ggg::::g | | | +// | | g:::::::::::::::::g | | | +// | | g::::::ggggg::::::gg | | | +// | | g:::::g g:::::g | | | +// offsetX -|-------->| g:::::g g:::::g | offsetY | +// | | g:::::g g:::::g | | | +// | | g::::::g g:::::g | | | +// | | g:::::::ggggg:::::g | | | +// | | g::::::::::::::::g | | height +// | | gg::::::::::::::g | | | +// baseline ---*---------|---- gggggggg::::::g-----*-------- | +// / | | g:::::g | | +// origin | | gggggg g:::::g | | +// | | g:::::gg gg:::::g | | +// | | g::::::ggg:::::::g | | +// | | gg:::::::::::::g | | +// | | ggg::::::ggg | | +// | | gggggg | v +// | +-------------------------+----------------- ymin +// | | +// |------------- advanceX ----------->| + +/// +/// The cached font character entry (read for rendering and further processing). +/// +API_STRUCT(NoDefault) struct FLAXENGINE_API FontCharacterEntry +{ + DECLARE_SCRIPTING_TYPE_MINIMAL(FontCharacterEntry); + + /// + /// The character represented by this entry. + /// + API_FIELD() Char Character; + + /// + /// True if entry is valid, otherwise false. + /// + API_FIELD() bool IsValid = false; + + /// + /// The index to a specific texture in the font cache. + /// + API_FIELD() byte TextureIndex; + + /// + /// The left bearing expressed in integer pixels. + /// + API_FIELD() int16 OffsetX; + + /// + /// The top bearing expressed in integer pixels. + /// + API_FIELD() int16 OffsetY; + + /// + /// The amount to advance in X before drawing the next character in a string. + /// + API_FIELD() int16 AdvanceX; + + /// + /// The distance from baseline to glyph top most point. + /// + API_FIELD() int16 BearingY; + + /// + /// The height in pixels of the glyph. + /// + API_FIELD() int16 Height; + + /// + /// The start location of the character in the texture (in texture coordinates space). + /// + API_FIELD() Float2 UV; + + /// + /// The size the character in the texture (in texture coordinates space). + /// + API_FIELD() Float2 UVSize; + + /// + /// The slot in texture atlas, containing the pixel data of the glyph. + /// + API_FIELD() const struct FontTextureAtlasSlot* Slot; + + /// + /// The owner font. + /// + API_FIELD() const class Font* Font; +}; + +template<> +struct TIsPODType +{ + enum { Value = true }; +}; From 2974a0435c8da0f6cde3929b899865a962fc83d8 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Sat, 19 Sep 2026 22:43:28 +0200 Subject: [PATCH 20/31] Fix selecting CSG Brush face --- Source/Editor/SceneGraph/ActorNode.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Editor/SceneGraph/ActorNode.cs b/Source/Editor/SceneGraph/ActorNode.cs index 67f671756..e73bce2c8 100644 --- a/Source/Editor/SceneGraph/ActorNode.cs +++ b/Source/Editor/SceneGraph/ActorNode.cs @@ -312,7 +312,7 @@ namespace FlaxEditor.SceneGraph // Skip actors that should not be selected if (hit != null && _actor != null && (_actor.HideFlags & HideFlags.DontSelect) == HideFlags.DontSelect) { - hit = parentNode; + hit = null; } return hit; From 358e39caa7c749ba7dcca81263b4e912090838b1 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Sat, 19 Sep 2026 22:48:57 +0200 Subject: [PATCH 21/31] Add various improvements to CSG Brushes editing workflow --- .../Editor/SceneGraph/Actors/BoxBrushNode.cs | 5 +++ Source/Engine/CSG/CSGData.cpp | 2 ++ Source/Engine/Level/Actors/BoxBrush.cpp | 32 +++++++++++++------ Source/Engine/Level/Actors/BoxBrush.h | 8 +---- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/Source/Editor/SceneGraph/Actors/BoxBrushNode.cs b/Source/Editor/SceneGraph/Actors/BoxBrushNode.cs index 2e3d56c27..030d16d1e 100644 --- a/Source/Editor/SceneGraph/Actors/BoxBrushNode.cs +++ b/Source/Editor/SceneGraph/Actors/BoxBrushNode.cs @@ -7,6 +7,7 @@ using Real = System.Single; #endif using System; +using System.ComponentModel; using FlaxEngine; namespace FlaxEditor.SceneGraph.Actors @@ -45,6 +46,7 @@ namespace FlaxEditor.SceneGraph.Actors } } + [DefaultValue(typeof(Float2), "1,1")] [EditorOrder(30), EditorDisplay("Brush", "UV Scale"), Limit(-1000, 1000, 0.01f)] [Tooltip("The surface texture coordinates scale.")] public Float2 TexCoordScale @@ -58,6 +60,7 @@ namespace FlaxEditor.SceneGraph.Actors } } + [DefaultValue(typeof(Float2), "0,0")] [EditorOrder(40), EditorDisplay("Brush", "UV Offset"), Limit(-1000, 1000, 0.01f)] [Tooltip("The surface texture coordinates offset.")] public Float2 TexCoordOffset @@ -71,6 +74,7 @@ namespace FlaxEditor.SceneGraph.Actors } } + [DefaultValue(0.0f)] [EditorOrder(50), EditorDisplay("Brush", "UV Rotation")] [Tooltip("The surface texture coordinates rotation angle (in degrees).")] public float TexCoordRotation @@ -84,6 +88,7 @@ namespace FlaxEditor.SceneGraph.Actors } } + [DefaultValue(1.0f)] [EditorOrder(20), EditorDisplay("Brush", "Scale In Lightmap"), Limit(0, 10000, 0.1f)] [Tooltip("The scale in lightmap (per surface).")] public float ScaleInLightmap diff --git a/Source/Engine/CSG/CSGData.cpp b/Source/Engine/CSG/CSGData.cpp index 53f94c175..04a78114e 100644 --- a/Source/Engine/CSG/CSGData.cpp +++ b/Source/Engine/CSG/CSGData.cpp @@ -72,6 +72,8 @@ void RawData::AddSurface(Brush* brush, int32 brushSurfaceIndex, const Guid& surf slot->AddSurface(scaleInLightmap, lightmapUVsBox, firstVertex, vertexCount); // Add surface to brush + if (!brush) + return; auto& brushData = Brushes[brush->GetBrushID()]; if (brushData.Surfaces.Count() != brush->GetSurfacesCount()) brushData.Surfaces.Resize(brush->GetSurfacesCount()); diff --git a/Source/Engine/Level/Actors/BoxBrush.cpp b/Source/Engine/Level/Actors/BoxBrush.cpp index 06bc047bc..1935a8b51 100644 --- a/Source/Engine/Level/Actors/BoxBrush.cpp +++ b/Source/Engine/Level/Actors/BoxBrush.cpp @@ -50,7 +50,18 @@ Array BoxBrush::GetSurfaces() const void BoxBrush::SetSurfaces(const Array& value) { CHECK(value.Count() == ARRAY_COUNT(Surfaces)); - Platform::MemoryCopy(Surfaces, value.Get(), sizeof(Surfaces)); + for (int32 i = 0; i < ARRAY_COUNT(Surfaces); i++) + { + auto& dst = Surfaces[i]; + const auto& src = value[i]; + dst.Brush = this; + dst.Index = i; + dst.Material = src.Material; + dst.TexCoordScale = src.TexCoordScale; + dst.TexCoordOffset = src.TexCoordOffset; + dst.TexCoordRotation = src.TexCoordRotation; + dst.ScaleInLightmap = src.ScaleInLightmap; + } OnBrushModified(); } @@ -67,10 +78,7 @@ void BoxBrush::SetCenter(const Vector3& value) { if (value == _center) return; - _center = value; - - // Fire events UpdateBounds(); OnBrushModified(); } @@ -79,10 +87,7 @@ void BoxBrush::SetSize(const Vector3& value) { if (value == _size) return; - _size = value; - - // Fire events UpdateBounds(); OnBrushModified(); } @@ -98,7 +103,7 @@ void BoxBrush::GetSurfaces(CSG::Surface surfaces[6]) surfaces[5].Normal = Vector3::Backward; // Calculate final transformation - const auto transform = _transform.LocalToWorld(Transform(_center, Quaternion::Identity, _size)); + const auto transform = _transform.LocalToWorld(Transform(_center, Quaternion::Identity, _size.GetAbsolute())); // Set size and scale surfaces[0].D = surfaces[1].D = transform.Scale.X / 2; @@ -163,6 +168,14 @@ void BoxBrush::GetVertices(int32 surfaceIndex, Array& outputData) const } } +void BoxBrush::UpdateBounds() +{ + OrientedBoundingBox::CreateCentered(_center, _size.GetAbsolute(), _bounds); + _bounds.Transform(_transform); + _bounds.GetBoundingBox(_box); + BoundingSphere::FromBox(_box, _sphere); +} + void BoxBrush::Serialize(SerializeStream& stream, const void* otherObj) { // Base @@ -230,7 +243,8 @@ bool BoxBrush::IntersectsItself(const Ray& ray, Real& distance, Vector3& normal) void BoxBrush::OnDebugDrawSelected() { - DEBUG_DRAW_WIRE_BOX(_bounds, Color::Yellow, 0, false); + DEBUG_DRAW_WIRE_BOX(_bounds, Color::Yellow.AlphaMultiplied(0.2f), 0, false); + DEBUG_DRAW_WIRE_BOX(_bounds, Color::Yellow, 0, true); // Base Actor::OnDebugDrawSelected(); diff --git a/Source/Engine/Level/Actors/BoxBrush.h b/Source/Engine/Level/Actors/BoxBrush.h index 519ca5ad8..17941b16b 100644 --- a/Source/Engine/Level/Actors/BoxBrush.h +++ b/Source/Engine/Level/Actors/BoxBrush.h @@ -188,13 +188,7 @@ public: API_FUNCTION() void GetVertices(int32 surfaceIndex, API_PARAM(Out) Array& outputData) const; private: - FORCE_INLINE void UpdateBounds() - { - OrientedBoundingBox::CreateCentered(_center, _size, _bounds); - _bounds.Transform(_transform); - _bounds.GetBoundingBox(_box); - BoundingSphere::FromBox(_box, _sphere); - } + void UpdateBounds(); public: // [Actor] From b7077e6abb89e73f3b115669e447e05dca0ec586 Mon Sep 17 00:00:00 2001 From: David Svez Date: Sat, 19 Sep 2026 22:50:32 +0200 Subject: [PATCH 22/31] Fix CSG Brush face moving bugs #4207 https://github.com/RomanZhu/FlaxEngine/commit/87bd2dc6e42797084f56128691c506c5a7bf3364 --- .../Editor/SceneGraph/Actors/BoxBrushNode.cs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/Source/Editor/SceneGraph/Actors/BoxBrushNode.cs b/Source/Editor/SceneGraph/Actors/BoxBrushNode.cs index 030d16d1e..6fb05cc23 100644 --- a/Source/Editor/SceneGraph/Actors/BoxBrushNode.cs +++ b/Source/Editor/SceneGraph/Actors/BoxBrushNode.cs @@ -25,6 +25,67 @@ namespace FlaxEditor.SceneGraph.Actors /// public sealed class SideLinkNode : ActorChildNode { + private const float MinimumComponentExtent = 0.001f; + + internal static Vector3 GetCornerSigns(int cornerIndex) + { + switch (cornerIndex) + { + case 0: return new Vector3(1, 1, 1); + case 1: return new Vector3(1, 1, -1); + case 2: return new Vector3(-1, 1, -1); + case 3: return new Vector3(-1, 1, 1); + case 4: return new Vector3(1, -1, 1); + case 5: return new Vector3(1, -1, -1); + case 6: return new Vector3(-1, -1, -1); + default: return new Vector3(-1, -1, 1); + } + } + + internal static Vector3 GetComponentPoint(BoxBrush brush, Vector3 signs) + { + return brush.Transform.LocalToWorld(brush.Center + signs * brush.Size * 0.5f); + } + + internal static void SetComponentPoint(BoxBrush brush, Vector3 signs, Vector3 worldPoint) + { + var point = brush.Transform.WorldToLocal(worldPoint); + var center = brush.Center; + var size = brush.Size; + for (int axis = 0; axis < 3; axis++) + { + float sign = GetComponent(signs, axis); + if (Mathf.Abs(sign) < 0.5f) + continue; + float halfSize = GetComponent(size, axis) * 0.5f; + float opposite = GetComponent(center, axis) - sign * halfSize; + float target = GetComponent(point, axis); + if (sign > 0.0f) + target = Mathf.Max(target, opposite + MinimumComponentExtent); + else + target = Mathf.Min(target, opposite - MinimumComponentExtent); + SetComponent(ref center, axis, (target + opposite) * 0.5f); + SetComponent(ref size, axis, Mathf.Abs(target - opposite)); + } + brush.Center = center; + brush.Size = size; + } + + private static float GetComponent(Vector3 value, int axis) + { + return axis == 0 ? (float)value.X : axis == 1 ? (float)value.Y : (float)value.Z; + } + + private static void SetComponent(ref Vector3 value, int axis, float component) + { + if (axis == 0) + value.X = component; + else if (axis == 1) + value.Y = component; + else + value.Z = component; + } + private sealed class BrushSurfaceProxy { [HideInEditor] @@ -169,6 +230,9 @@ namespace FlaxEditor.SceneGraph.Actors set { var actor = Brush; +#if true + SetComponentPoint(actor, _offset * 2.0f, value.Translation); +#else Transform localTrans = actor.Transform.WorldToLocal(value); var prevLocalOffset = _offset * actor.Size + actor.Center; var localOffset = Vector3.Abs(_offset) * 2.0f * localTrans.Translation; @@ -176,6 +240,7 @@ namespace FlaxEditor.SceneGraph.Actors float centerScale = Index % 2 == 0 ? 0.5f : -0.5f; actor.Size += localOffsetDelta; actor.Center += localOffsetDelta * centerScale; +#endif } } From b0617e043c7ea181203f5ddd5630eb8ec60625ba Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Sat, 19 Sep 2026 22:52:52 +0200 Subject: [PATCH 23/31] Fix minor typos --- Source/Engine/Tools/TextureTool/TextureTool.stb.cpp | 2 +- Source/ThirdParty/detex/decompress-bptc.cpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Source/Engine/Tools/TextureTool/TextureTool.stb.cpp b/Source/Engine/Tools/TextureTool/TextureTool.stb.cpp index 28aa1e9f7..945050a95 100644 --- a/Source/Engine/Tools/TextureTool/TextureTool.stb.cpp +++ b/Source/Engine/Tools/TextureTool/TextureTool.stb.cpp @@ -584,7 +584,7 @@ bool TextureTool::ImportTextureStb(ImageType type, const StringView& path, Textu } } - // Use two data containers for texture importing for more optimzied performance + // Use two data containers for texture importing for more optimized performance TextureData textureDataTmp; TextureData* textureDataSrc = &textureData; TextureData* textureDataDst = &textureDataTmp; diff --git a/Source/ThirdParty/detex/decompress-bptc.cpp b/Source/ThirdParty/detex/decompress-bptc.cpp index 618220561..aac47c56e 100644 --- a/Source/ThirdParty/detex/decompress-bptc.cpp +++ b/Source/ThirdParty/detex/decompress-bptc.cpp @@ -141,9 +141,9 @@ int mode, detexBlock128 * DETEX_RESTRICT block) { // 64-bit word boundary in any mode. uint32_t bits; if (block->index < 64) - bits = block->data0 >> block->index; + bits = (uint32_t)(block->data0 >> block->index); else - bits = block->data1 >> (block->index - 64); + bits = (uint32_t)(block->data1 >> (block->index - 64)); for (int i = 0; i < nu_subsets * 2; i++) { endpoint_array[i * 4 + 0] <<= 1; endpoint_array[i * 4 + 1] <<= 1; From a39762e4dec51fbe01791b1b4187cdf79670e51f Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Sat, 19 Sep 2026 23:26:17 +0200 Subject: [PATCH 24/31] Fix typos --- Source/Editor/Options/InputOptions.cs | 8 ++++---- Source/Editor/Windows/EditorWindow.cs | 28 +++++++++++++-------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Source/Editor/Options/InputOptions.cs b/Source/Editor/Options/InputOptions.cs index 7c577ad4a..be0ba7a7e 100644 --- a/Source/Editor/Options/InputOptions.cs +++ b/Source/Editor/Options/InputOptions.cs @@ -60,14 +60,14 @@ namespace FlaxEditor.Options public sealed class InputOptions { /// - /// Gets a value based on the current settings that indicates wether window shortcuts will be avaliable during play mode. + /// Gets a value based on the current settings that indicates whether window shortcuts will be available during play mode. /// - public static bool WindowShortcutsAvaliable => !Editor.IsPlayMode || Editor.Instance.Options.Options.Input.PlayModeWindowShortcutAvaliability == PlayModeShortcutAvailability.All; + public static bool WindowShortcutsAvailable => !Editor.IsPlayMode || Editor.Instance.Options.Options.Input.PlayModeWindowShortcutAvaliability == PlayModeShortcutAvailability.All; /// - /// Gets a value based on the current settings that indicates wether the profiler window shortcut will be avaliable during play mode. + /// Gets a value based on the current settings that indicates whether the profiler window shortcut will be available during play mode. /// - public static bool ProfilerShortcutAvaliable => WindowShortcutsAvaliable || Editor.Instance.Options.Options.Input.PlayModeWindowShortcutAvaliability == PlayModeShortcutAvailability.ProfilerOnly; + public static bool ProfilerShortcutAvailable => WindowShortcutsAvailable || Editor.Instance.Options.Options.Input.PlayModeWindowShortcutAvaliability == PlayModeShortcutAvailability.ProfilerOnly; #region Common diff --git a/Source/Editor/Windows/EditorWindow.cs b/Source/Editor/Windows/EditorWindow.cs index 03d435efe..7a5eb847a 100644 --- a/Source/Editor/Windows/EditorWindow.cs +++ b/Source/Editor/Windows/EditorWindow.cs @@ -53,72 +53,72 @@ namespace FlaxEditor.Windows // Set up editor window shortcuts InputActions.Add(options => options.ContentWindow, () => { - if (InputOptions.WindowShortcutsAvaliable) + if (InputOptions.WindowShortcutsAvailable) Editor.Windows.ContentWin.FocusOrShow(); }); InputActions.Add(options => options.SceneWindow, () => { - if (InputOptions.WindowShortcutsAvaliable) + if (InputOptions.WindowShortcutsAvailable) Editor.Windows.SceneWin.FocusOrShow(); }); InputActions.Add(options => options.ToolboxWindow, () => { - if (InputOptions.WindowShortcutsAvaliable) + if (InputOptions.WindowShortcutsAvailable) Editor.Windows.ToolboxWin.FocusOrShow(); }); InputActions.Add(options => options.PropertiesWindow, () => { - if (InputOptions.WindowShortcutsAvaliable) + if (InputOptions.WindowShortcutsAvailable) Editor.Windows.PropertiesWin.FocusOrShow(); }); InputActions.Add(options => options.GameWindow, () => { - if (InputOptions.WindowShortcutsAvaliable) + if (InputOptions.WindowShortcutsAvailable) Editor.Windows.GameWin.FocusOrShow(); }); InputActions.Add(options => options.EditorWindow, () => { - if (InputOptions.WindowShortcutsAvaliable) + if (InputOptions.WindowShortcutsAvailable) Editor.Windows.EditWin.FocusOrShow(); }); InputActions.Add(options => options.DebugLogWindow, () => { - if (InputOptions.WindowShortcutsAvaliable) + if (InputOptions.WindowShortcutsAvailable) Editor.Windows.DebugLogWin.FocusOrShow(); }); InputActions.Add(options => options.OutputLogWindow, () => { - if (InputOptions.WindowShortcutsAvaliable) + if (InputOptions.WindowShortcutsAvailable) Editor.Windows.OutputLogWin.FocusOrShow(); }); InputActions.Add(options => options.GraphicsQualityWindow, () => { - if (InputOptions.WindowShortcutsAvaliable) + if (InputOptions.WindowShortcutsAvailable) Editor.Windows.GraphicsQualityWin.FocusOrShow(); }); InputActions.Add(options => options.GameCookerWindow, () => { - if (InputOptions.WindowShortcutsAvaliable) + if (InputOptions.WindowShortcutsAvailable) Editor.Windows.GameCookerWin.FocusOrShow(); }); InputActions.Add(options => options.ProfilerWindow, () => { - if (InputOptions.ProfilerShortcutAvaliable) + if (InputOptions.ProfilerShortcutAvailable) Editor.Windows.ProfilerWin.FocusOrShow(); }); InputActions.Add(options => options.ContentFinder, () => { - if (InputOptions.WindowShortcutsAvaliable) + if (InputOptions.WindowShortcutsAvailable) Editor.ContentFinding.ShowSearch(); }); InputActions.Add(options => options.VisualScriptDebuggerWindow, () => { - if (InputOptions.WindowShortcutsAvaliable) + if (InputOptions.WindowShortcutsAvailable) Editor.Windows.VisualScriptDebuggerWin.FocusOrShow(); }); InputActions.Add(options => options.EditorOptionsWindow, () => { - if (InputOptions.WindowShortcutsAvaliable) + if (InputOptions.WindowShortcutsAvailable) Editor.Windows.EditorOptionsWin.FocusOrShow(); }); From 2a0589b4c151140bed585e09c02646eef25abbc9 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Sat, 19 Sep 2026 23:26:35 +0200 Subject: [PATCH 25/31] Add context menu option to show asset in Content window quickly --- Source/Editor/Windows/Assets/AssetEditorWindow.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Source/Editor/Windows/Assets/AssetEditorWindow.cs b/Source/Editor/Windows/Assets/AssetEditorWindow.cs index 16a2cf839..c57e391b2 100644 --- a/Source/Editor/Windows/Assets/AssetEditorWindow.cs +++ b/Source/Editor/Windows/Assets/AssetEditorWindow.cs @@ -53,7 +53,7 @@ namespace FlaxEditor.Windows.Assets { Parent = this }; - _toolstrip.AddButton(editor.Icons.Search64, () => Editor.Windows.ContentWin.Select(_item)).LinkTooltip("Show and select in Content Window."); + _toolstrip.AddButton(editor.Icons.Search64, ShowInContent).LinkTooltip("Show and select in Content Window."); InputActions.Add(options => options.Save, Save); @@ -150,6 +150,11 @@ namespace FlaxEditor.Windows.Assets { } + private void ShowInContent() + { + Editor.Windows.ContentWin.Select(_item); + } + /// protected override void OnClose() { @@ -178,7 +183,9 @@ namespace FlaxEditor.Windows.Assets base.OnDestroy(); } - /// + /// + /// Called when scripts reloading begins. + /// protected virtual void OnScriptsReloadBegin() { if (!IsHidden) @@ -269,7 +276,8 @@ namespace FlaxEditor.Windows.Assets base.OnShowContextMenu(menu); menu.AddButton("Save", Save).Enabled = IsEdited; - menu.AddButton("Copy name", () => Clipboard.Text = Item.NamePath); + menu.AddButton("Copy Name", () => Clipboard.Text = Item.NamePath); + menu.AddButton("Show In Content", ShowInContent); menu.AddSeparator(); } From d1148fdcfd5f299d98581e783e352cf1e433290e Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 20 Sep 2026 00:08:13 +0200 Subject: [PATCH 26/31] Improve Dropdown Control- #4102 Merges #4102 --- Source/Engine/Core/Math/Color.cs | 10 ++++++++++ Source/Engine/UI/GUI/Common/Dropdown.cs | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Source/Engine/Core/Math/Color.cs b/Source/Engine/Core/Math/Color.cs index dbfd70c7e..be97a7994 100644 --- a/Source/Engine/Core/Math/Color.cs +++ b/Source/Engine/Core/Math/Color.cs @@ -112,6 +112,16 @@ namespace FlaxEngine /// public float ValuesSum => R + G + B + A; + /// + /// Returns true if color is fully transparent (all components are equal zero). + /// + public bool IsTransparent => Mathf.IsZero(R + G + B + A); + + /// + /// Returns true if color has opacity channel in use (different from 1). + /// + public bool HasOpacity => !Mathf.IsOne(A); + /// /// Constructs a new Color with given r,g,b,a component. /// diff --git a/Source/Engine/UI/GUI/Common/Dropdown.cs b/Source/Engine/UI/GUI/Common/Dropdown.cs index b91d70dc9..325a5c34d 100644 --- a/Source/Engine/UI/GUI/Common/Dropdown.cs +++ b/Source/Engine/UI/GUI/Common/Dropdown.cs @@ -595,7 +595,7 @@ namespace FlaxEngine.GUI Size = new Float2(size.X - margin, size.Y), Font = Font, TextColor = TextColor * 0.9f, - TextColorHighlighted = BackgroundColorSelected.Brightness < 0.05f ? Color.Lerp(TextColorHighlighted, Color.White, 0.3f) : TextColorHighlighted, + TextColorHighlighted = (Mathf.IsZero(BackgroundColorSelected.Brightness) || BackgroundColorSelected.IsTransparent) ? Color.Lerp(TextColorHighlighted, Color.White, 0.3f) : TextColorHighlighted, HorizontalAlignment = HorizontalAlignment, VerticalAlignment = VerticalAlignment, Text = _items[i], From 26d508684b601e73aed7750adbec79b0a672ea35 Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 20 Sep 2026 00:22:04 +0200 Subject: [PATCH 27/31] Add Wrap Support on Rich Text Box Merges #4196 --- .../UI/GUI/Common/RichTextBox.Parsing.cs | 120 +++++++++++++++--- Source/Engine/UI/GUI/Common/RichTextBox.cs | 41 +++++- .../Engine/UI/GUI/Common/RichTextBoxBase.cs | 20 +-- 3 files changed, 154 insertions(+), 27 deletions(-) diff --git a/Source/Engine/UI/GUI/Common/RichTextBox.Parsing.cs b/Source/Engine/UI/GUI/Common/RichTextBox.Parsing.cs index bb6ee22a5..08eda39ee 100644 --- a/Source/Engine/UI/GUI/Common/RichTextBox.Parsing.cs +++ b/Source/Engine/UI/GUI/Common/RichTextBox.Parsing.cs @@ -225,37 +225,123 @@ namespace FlaxEngine.GUI var lines = font.ProcessText(_text, ref textBlock.Range); if (lines == null || lines.Length == 0) return; + var wrapWidth = _wrapping != TextWrapping.NoWrap ? Width : -1.0f; + for (int i = 0; i < lines.Length; i++) { ref var line = ref lines[i]; - textBlock.Range = new TextRange + var lineRange = new TextRange { StartIndex = start + line.FirstCharIndex, EndIndex = start + line.LastCharIndex + 1, }; + if (i != 0) { context.Caret.X = 0; - OnLineAdded(ref context, textBlock.Range.StartIndex - 1); + OnLineAdded(ref context, lineRange.StartIndex - 1); } - textBlock.Bounds = new Rectangle(context.Caret, line.Size); - textBlock.Bounds.X += line.Location.X; - context.AddTextBlock(ref textBlock); - } - - // Update the caret location - ref var lastLine = ref lines[lines.Length - 1]; - if (lines.Length == 1) - { - context.Caret.X += lastLine.Size.X; - } - else - { - context.Caret.X = lastLine.Size.X; + if (wrapWidth > 0 && context.Caret.X + line.Size.X > wrapWidth) + { + // Line overflows the available width - split it into multiple wrapped lines + AddWrappedTextBlocks(ref context, ref textBlock, font, lineRange, wrapWidth, _wrapping); + } + else + { + // Add block + textBlock.Range = lineRange; + textBlock.Bounds = new Rectangle(context.Caret, line.Size); + textBlock.Bounds.X += line.Location.X; + context.AddTextBlock(ref textBlock); + context.Caret.X += line.Size.X; + } } } + /// + /// Splits the given text range into multiple text blocks (lines) so it fits within the available wrapping width, correctly continuing from the current caret position (eg. when a differently-styled text run follows on the same line). + /// + /// The parsing context. + /// The template text block (style is reused, range and bounds get overriden per produced block). + /// The font used to measure and render the text. + /// The text range to wrap (single logical line - no explicit newlines inside). + /// The maximum available width (in control-space) that a single visual line can use. + /// The wrapping mode - either whole-word (breaks only at whitespace) or per-character (can break in the middle of a word). + private void AddWrappedTextBlocks(ref ParsingContext context, ref TextBlock textBlock, Font font, TextRange range, float wrapWidth, TextWrapping wrapping) + { + int segmentStart = range.StartIndex; + float segmentWidth = 0.0f; + int pos = range.StartIndex; + while (pos < range.EndIndex) + { + // Consume the next "chunk" - a single character for WrapChars, or a whole word (plus trailing whitespace) for WrapWords + int chunkStart = pos; + if (wrapping == TextWrapping.WrapChars) + { + pos++; + } + else + { + while (pos < range.EndIndex && !char.IsWhiteSpace(_text[pos])) + pos++; + while (pos < range.EndIndex && char.IsWhiteSpace(_text[pos])) + pos++; + } + + var chunkRange = new TextRange { StartIndex = chunkStart, EndIndex = pos }; + var chunkWidth = font.MeasureText(_text, ref chunkRange).X; + + if ((segmentWidth > 0.0f || context.Caret.X > 0.0f) && context.Caret.X + segmentWidth + chunkWidth > wrapWidth) + { + // The next chunk no longer fits - emit the accumulated segment (if any) and start a new line + if (segmentStart < chunkStart) + AddWrappedTextBlock(ref context, ref textBlock, font, segmentStart, chunkStart); + context.Caret.X = 0; + OnLineAdded(ref context, chunkStart - 1); + segmentStart = chunkStart; + segmentWidth = 0.0f; + } + segmentWidth += chunkWidth; + + // For Wrap Words mode: A single word wider than the whole available width can't be split further, so force it onto its own line + if (wrapping == TextWrapping.WrapWords && context.Caret.X <= 0.0f && segmentStart == chunkStart && segmentWidth > wrapWidth && pos < range.EndIndex) + { + AddWrappedTextBlock(ref context, ref textBlock, font, segmentStart, pos); + context.Caret.X = 0; + OnLineAdded(ref context, pos - 1); + segmentStart = pos; + segmentWidth = 0.0f; + } + } + if (segmentStart < range.EndIndex) + AddWrappedTextBlock(ref context, ref textBlock, font, segmentStart, range.EndIndex); + } + + /// + /// Adds a single text block to the control, using the current caret position as the origin and moving the caret forward by the width of the text block. + /// + /// The parsing context. + /// The text block to add. + /// The font to use for measurement. + /// The start index of the text range. + /// The end index of the text range. + private void AddWrappedTextBlock(ref ParsingContext context, ref TextBlock textBlock, Font font, int start, int end) + { + var range = new TextRange { StartIndex = start, EndIndex = end }; + var size = font.MeasureText(_text, ref range); + textBlock.Range = range; + textBlock.Bounds = new Rectangle(context.Caret, size); + context.AddTextBlock(ref textBlock); + context.Caret.X += size.X; + } + + /// + /// Called when a new line is added (eg. after a newline character or when the text overflows the available width). + /// It organizes the text blocks within the line and moves the caret to the next line. + /// + /// The parsing context. + /// The index of the last character in the line. private void OnLineAdded(ref ParsingContext context, int lineEnd) { // Calculate size of the line @@ -333,7 +419,7 @@ namespace FlaxEngine.GUI // Move to the next line context.LineStartCharacterIndex = lineEnd + 1; context.LineStartTextBlockIndex = _textBlocks.Count; - context.Caret.Y += lineSize.Y; + context.Caret.Y += lineSize.Y * BaseLinesGapScale; } } } diff --git a/Source/Engine/UI/GUI/Common/RichTextBox.cs b/Source/Engine/UI/GUI/Common/RichTextBox.cs index f7726bf56..314af4ed8 100644 --- a/Source/Engine/UI/GUI/Common/RichTextBox.cs +++ b/Source/Engine/UI/GUI/Common/RichTextBox.cs @@ -1,6 +1,7 @@ // Copyright (c) Wojciech Figat. All rights reserved. using System.Collections.Generic; +using System.ComponentModel; namespace FlaxEngine.GUI { @@ -11,6 +12,8 @@ namespace FlaxEngine.GUI public partial class RichTextBox : RichTextBoxBase { private TextBlockStyle _textStyle; + private TextWrapping _wrapping = TextWrapping.NoWrap; + private float _baseLinesGapScale = 1.0f; /// /// The default text style applied to the whole text. @@ -26,16 +29,50 @@ namespace FlaxEngine.GUI } } + /// + /// Gets or sets the text wrapping within the control bounds. + /// + [DefaultValue(TextWrapping.NoWrap)] + [EditorOrder(30), Tooltip("The text wrapping within the control bounds.")] + public TextWrapping Wrapping + { + get => _wrapping; + set + { + if (_wrapping == value) + return; + _wrapping = value; + UpdateTextBlocks(); + } + } + + /// + /// Gets or sets the gap between lines when wrapping and more than a single line is displayed. + /// + [DefaultValue(1.0f)] + [EditorOrder(40), Tooltip("The gap between lines when wrapping and more than a single line is displayed."), Limit(0f, 100.0f, 0.01f)] + public float BaseLinesGapScale + { + get => _baseLinesGapScale; + set + { + if (Mathf.NearEqual(_baseLinesGapScale, value)) + return; + _baseLinesGapScale = value; + UpdateTextBlocks(); + } + } + /// /// The collection of custom text styles to apply (named). /// - [EditorOrder(30)] + [EditorOrder(50)] public Dictionary Styles = new Dictionary(); /// /// The collection of custom images/sprites that can be inlined in text (named). /// - [EditorOrder(40)] + [EditorOrder(60)] public Dictionary Images = new Dictionary(); /// diff --git a/Source/Engine/UI/GUI/Common/RichTextBoxBase.cs b/Source/Engine/UI/GUI/Common/RichTextBoxBase.cs index b83a89943..41927541e 100644 --- a/Source/Engine/UI/GUI/Common/RichTextBoxBase.cs +++ b/Source/Engine/UI/GUI/Common/RichTextBoxBase.cs @@ -100,15 +100,19 @@ namespace FlaxEngine.GUI } // Handle case when index is outside all text ranges - if (index >= 0 && blockCount > 0 && index <= textBlocksSpan[0].Range.StartIndex) + if (index >= 0 && blockCount > 0) { - result = textBlocksSpan[0]; - return true; - } - if (index >= 0 && blockCount > 0 && index >= textBlocksSpan[blockCount - 1].Range.StartIndex) - { - result = textBlocksSpan[blockCount - 1]; - return true; + if (index <= textBlocksSpan[0].Range.StartIndex) + { + result = textBlocksSpan[0]; + return true; + } + + if (index >= textBlocksSpan[blockCount - 1].Range.StartIndex) + { + result = textBlocksSpan[blockCount - 1]; + return true; + } } // If no text block is found From ed3700569775d94b968c38e320cc47fd63cb1208 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Sun, 20 Sep 2026 22:10:32 +0200 Subject: [PATCH 28/31] Tweak --- Source/Engine/UI/GUI/Common/RichTextBox.Parsing.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Engine/UI/GUI/Common/RichTextBox.Parsing.cs b/Source/Engine/UI/GUI/Common/RichTextBox.Parsing.cs index 08eda39ee..4de19f2f2 100644 --- a/Source/Engine/UI/GUI/Common/RichTextBox.Parsing.cs +++ b/Source/Engine/UI/GUI/Common/RichTextBox.Parsing.cs @@ -419,7 +419,7 @@ namespace FlaxEngine.GUI // Move to the next line context.LineStartCharacterIndex = lineEnd + 1; context.LineStartTextBlockIndex = _textBlocks.Count; - context.Caret.Y += lineSize.Y * BaseLinesGapScale; + context.Caret.Y += lineSize.Y * _baseLinesGapScale; } } } From 223d16b6849e8ee151be86586fbcc4add394d280 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Sun, 20 Sep 2026 22:34:49 +0200 Subject: [PATCH 29/31] Hide various Actor functions from scene animations --- Source/Engine/Level/Actor.h | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Source/Engine/Level/Actor.h b/Source/Engine/Level/Actor.h index 4271393ce..f7eb7d36e 100644 --- a/Source/Engine/Level/Actor.h +++ b/Source/Engine/Level/Actor.h @@ -788,12 +788,12 @@ public: /// /// Draws debug shapes for the actor and all child scripts. /// - API_FUNCTION() virtual void OnDebugDraw(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnDebugDraw(); /// /// Draws debug shapes for the selected actor and all child scripts. /// - API_FUNCTION() virtual void OnDebugDrawSelected(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnDebugDrawSelected(); #endif public: @@ -1056,64 +1056,64 @@ public: /// Serializes the actor object to the Json string. Serialized are only this actor properties but no child actors nor scripts. Serializes references to the other objects in a proper way using IDs. /// /// The Json container with serialized actor data. - API_FUNCTION() String ToJson(); + API_FUNCTION(Attributes="NoAnimate") String ToJson(); /// /// Deserializes the actor object to the Json string. Deserialized are only this actor properties but no child actors nor scripts. /// /// The serialized actor data (state). - API_FUNCTION() void FromJson(const StringAnsiView& json); + API_FUNCTION(Attributes="NoAnimate") void FromJson(const StringAnsiView& json); /// /// Clones actor including all scripts and any child actors (whole scene tree). Objects are duplicated via serialization (any transient/non-saved state is ignored). /// - API_FUNCTION() Actor* Clone(); + API_FUNCTION(Attributes="NoAnimate") Actor* Clone(); public: /// /// Called when actor gets added to game systems. Occurs on BeginPlay event or when actor gets activated in hierarchy. Use this event to register object to other game system (eg. audio). /// - API_FUNCTION() virtual void OnEnable(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnEnable(); /// /// Called when actor gets removed from game systems. Occurs on EndPlay event or when actor gets inactivated in hierarchy. Use this event to unregister object from other game system (eg. audio). /// - API_FUNCTION() virtual void OnDisable(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnDisable(); /// /// Called when actor parent gets changed. /// - API_FUNCTION() virtual void OnParentChanged(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnParentChanged(); /// /// Called when actor transform gets changed. /// - API_FUNCTION() virtual void OnTransformChanged(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnTransformChanged(); /// /// Called when actor active state gets changed. /// - API_FUNCTION() virtual void OnActiveChanged(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnActiveChanged(); /// /// Called when actor active in tree state gets changed. /// - API_FUNCTION() virtual void OnActiveInTreeChanged(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnActiveInTreeChanged(); /// /// Called when order in parent children array gets changed. /// - API_FUNCTION() virtual void OnOrderInParentChanged(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnOrderInParentChanged(); /// /// Called when actor static flag gets changed. /// - API_FUNCTION() virtual void OnStaticFlagsChanged(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnStaticFlagsChanged(); /// /// Called when layer gets changed. /// - API_FUNCTION() virtual void OnLayerChanged(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnLayerChanged(); /// /// Called when adding object to the game. From 7d1d222d636cd72a27cf9a8544737187cccbd3b4 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Sun, 20 Sep 2026 22:50:34 +0200 Subject: [PATCH 30/31] Improve component labels for colors #4153 --- Source/Editor/GUI/CurveEditor.Base.cs | 22 ++++++++++++++++++++++ Source/Editor/GUI/CurveEditor.Contents.cs | 14 +------------- Source/Editor/GUI/CurveEditor.cs | 14 +------------- 3 files changed, 24 insertions(+), 26 deletions(-) diff --git a/Source/Editor/GUI/CurveEditor.Base.cs b/Source/Editor/GUI/CurveEditor.Base.cs index fc9abc08e..04b4b2f72 100644 --- a/Source/Editor/GUI/CurveEditor.Base.cs +++ b/Source/Editor/GUI/CurveEditor.Base.cs @@ -179,6 +179,28 @@ namespace FlaxEditor.GUI UpdateKeyframes(); } + internal static string GetComponentLabel(int component, Type valueType) + { + if (valueType == typeof(Color) || valueType == typeof(Color32)) + { + switch (component) + { + case 0: return "r"; + case 1: return "g"; + case 2: return "b"; + case 3: return "a"; + } + } + switch (component) + { + case 0: return "x"; + case 1: return "y"; + case 2: return "z"; + case 3: return "w"; + default: return (component + 1).ToString(); + } + } + /// /// Evaluates the animation curve value at the specified time. /// diff --git a/Source/Editor/GUI/CurveEditor.Contents.cs b/Source/Editor/GUI/CurveEditor.Contents.cs index 80db7fb27..104863c40 100644 --- a/Source/Editor/GUI/CurveEditor.Contents.cs +++ b/Source/Editor/GUI/CurveEditor.Contents.cs @@ -91,18 +91,6 @@ namespace FlaxEditor.GUI return firstHit; } - private static string GetComponentName(int component) - { - switch (component) - { - case 0: return "X"; - case 1: return "Y"; - case 2: return "Z"; - case 3: return "W"; - default: return (component + 1).ToString(); - } - } - private void SelectKeyframePoint(KeyframePoint keyframe, bool addToSelection) { if (!addToSelection) @@ -646,7 +634,7 @@ namespace FlaxEditor.GUI for (int i = 0; i < components; i++) { var component = i; - componentMenu.ContextMenu.AddButton(GetComponentName(component), () => SelectKeyframeComponent(point.Index, component)); + componentMenu.ContextMenu.AddButton(GetComponentLabel(component, typeof(T)), () => SelectKeyframeComponent(point.Index, component)); } } var totalSelectionCount = _editor.KeyframesEditorContext?.OnKeyframesSelectionCount() ?? selectionCount; diff --git a/Source/Editor/GUI/CurveEditor.cs b/Source/Editor/GUI/CurveEditor.cs index 7d7bb93a2..092cda707 100644 --- a/Source/Editor/GUI/CurveEditor.cs +++ b/Source/Editor/GUI/CurveEditor.cs @@ -1279,24 +1279,12 @@ namespace FlaxEditor.GUI continue; var center = GetControlCenterInEditor(point); - var label = GetComponentLabel(point.Component); + var label = GetComponentLabel(point.Component, ValueType); var labelRect = new Rectangle(center.X + 12.0f, center.Y - 22.0f, 34.0f, 28.0f); Render2D.DrawText(style.FontMedium, label, labelRect, Colors[point.Component], TextAlignment.Near, TextAlignment.Center, TextWrapping.NoWrap, 1.0f, 1.25f); } } - private static string GetComponentLabel(int component) - { - switch (component) - { - case 0: return "x"; - case 1: return "y"; - case 2: return "z"; - case 3: return "w"; - default: return (component + 1).ToString(); - } - } - /// public override void Draw() { From ebcf315e87f662e7f31e5a4cd3430846d5a55385 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Sun, 20 Sep 2026 22:50:46 +0200 Subject: [PATCH 31/31] Add object track type tooltip --- Source/Editor/GUI/Timeline/Tracks/ActorTrack.cs | 1 - Source/Editor/GUI/Timeline/Tracks/ObjectTrack.cs | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Editor/GUI/Timeline/Tracks/ActorTrack.cs b/Source/Editor/GUI/Timeline/Tracks/ActorTrack.cs index 4a0993cb9..109bf5ccd 100644 --- a/Source/Editor/GUI/Timeline/Tracks/ActorTrack.cs +++ b/Source/Editor/GUI/Timeline/Tracks/ActorTrack.cs @@ -3,7 +3,6 @@ using System; using System.IO; using System.Linq; -using FlaxEditor.CustomEditors; using FlaxEditor.GUI.ContextMenu; using FlaxEditor.GUI.Timeline.Undo; using FlaxEditor.SceneGraph; diff --git a/Source/Editor/GUI/Timeline/Tracks/ObjectTrack.cs b/Source/Editor/GUI/Timeline/Tracks/ObjectTrack.cs index 1042e3014..f0de3584b 100644 --- a/Source/Editor/GUI/Timeline/Tracks/ObjectTrack.cs +++ b/Source/Editor/GUI/Timeline/Tracks/ObjectTrack.cs @@ -100,6 +100,7 @@ namespace FlaxEditor.GUI.Timeline.Tracks /// The object. protected virtual void OnObjectExistenceChanged(object obj) { + TooltipText = obj != null ? Editor.Instance.CodeDocs.GetTooltip(obj.GetType()) : "null"; } ///