perf(draw): defer non-essential first-frame rendering (v1.4.9 R2)

Cut first-frame HITCH from ~127ms median down to ~76ms median (4-reload
sample, threshold lowered to 1ms for measurement) — comfortably under
Dalamud's 100ms warning threshold. ChatTwo upstream sits at ~63ms median
for comparison; the remaining ~13ms gap is the cost of HellionChat-only
features (Sidebar tab view, custom StatusBar, Honorific integration).

Mechanism: a single `_firstFrameDone` flag (flipped in Draw's finally
block) gates six sections that don't need to render on frame 0:

  - StatusBar.Draw (~12ms): the bottom status bar
  - DrawChannelName chunks (~17ms): SeString-Renderer layout, replaced
    with a plain-text fallback (activeTab.Name) for frame 0
  - PositionReset/BoundsCheck (~10ms): EnsureWindowOnScreen viewport
    iteration, only matters once the user notices a mispositioned window
  - DrawV061HintBannerIfNeeded (~3-5ms): v0.6.1 migration notice
  - DrawAutoComplete (~6ms): renders nothing until the user types a command
  - InputPreview.CalculatePreview (~3-5ms): triggers InputPreview first-
    frame lazy init, user-typing-driven anyway

Frame 1 then renders all of them in ~40ms (still well under the warning
threshold), and frames 2+ stay at 0ms as before. User sees the deferred
sections ~17ms (60fps) later than before — invisible inside the ~2.5s
Atlas-Build window after every plugin reload.

Hypothesis triage from the R2-profiling pass:
  - (a) Atlas-Sync-Fallback: falsified. xllog shows the Atlas-Complete
    line always lands ~2.5s before the HITCH frame.
  - (b) Theme-Apply ABGR-Cache-Init: not dominant. PushGlobal is 5ms.
  - (c) Multiple-Window-Render: falsified in v1.4.9 Stage-2-Lazy-Init
    diagnose (deferred 4 windows, no measurable delta).
  - (d) DrawList-Setup-Cost per Window: actual root cause. Layout cost
    distributes evenly across ~10 ImGui sections inside ChatLogWindow
    (5-20ms each). No single hot-spot to optimise — the six selective
    skips above are the pragmatic fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 10:14:13 +02:00
parent 8ed10a536b
commit 011490368b
+47 -1
View File
@@ -636,6 +636,15 @@ public sealed class ChatLogWindow : Window
IsOpen = true;
}
// v1.4.9 R2: defer non-essential rendering on the first Draw call so the
// plugin-load stays under Dalamud's 100ms HITCH warning threshold. First-
// frame ImGui layout cost on a populated ChatLog ~127ms — deferring six
// non-essential sections (StatusBar, ChannelName chunks, PositionReset/
// BoundsCheck, HintBanner, AutoComplete, InputPreview.CalculatePreview)
// shaves ~33ms down to ~94ms. User sees the deferred sections one frame
// (~17ms at 60fps) late, invisible inside the post-reload Atlas-Build.
private bool _firstFrameDone;
public override void Draw()
{
DrewThisFrame = true;
@@ -643,6 +652,10 @@ public sealed class ChatLogWindow : Window
{
DrawChatLog();
AddPopOutsToDraw();
// v1.4.9 R2: AutoComplete renders nothing until the user starts
// typing a command — safe to skip on the first frame. ~6ms.
if (_firstFrameDone)
DrawAutoComplete();
}
catch (Exception ex)
@@ -665,6 +678,13 @@ public sealed class ChatLogWindow : Window
// input focus, which breaks every other ImGui window.
Activate = false;
}
finally
{
// Flag flips after the first Draw completes (success or caught
// exception). Sub-methods read it to decide whether to render
// non-essential UI sections.
_firstFrameDone = true;
}
}
private static bool IsChatMode =>
@@ -680,6 +700,12 @@ public sealed class ChatLogWindow : Window
LastWindowSize = currentSize;
LastWindowPos = ImGui.GetWindowPos();
// v1.4.9 R2: skip the bounds-check chain on the first frame. The
// EnsureWindowOnScreen viewport iteration is ~10ms first-frame and
// not user-visible — frame 1 catches the same check before the
// user notices a mispositioned window.
if (_firstFrameDone)
{
// Manual reset snaps unconditionally; on-load check only fires when the
// stored position has no overlap with any visible viewport.
if (RequestPositionReset)
@@ -693,6 +719,7 @@ public sealed class ChatLogWindow : Window
DidOnLoadBoundsCheck = true;
EnsureWindowOnScreen("on-load");
}
}
if (resized)
LastResize.Restart();
@@ -700,11 +727,16 @@ public sealed class ChatLogWindow : Window
LastViewport = ImGui.GetWindowViewport().Handle;
WasDocked = ImGui.IsWindowDocked();
if (IsChatMode && Plugin.InputPreview.IsDrawable)
// v1.4.9 R2: CalculatePreview triggers InputPreview's first-frame
// lazy init (~3-5ms). User-typing-driven, safe to defer one frame.
if (_firstFrameDone && IsChatMode && Plugin.InputPreview.IsDrawable)
Plugin.InputPreview.CalculatePreview();
// Render the hint banner first so it sits above the tab area at full
// window width. ImGui accounts for its height automatically.
// v1.4.9 R2: skip on first frame (~3-5ms layout cost). The banner
// is a v0.6.1 migration notice that returns the same result frame 1.
if (_firstFrameDone)
DrawV061HintBannerIfNeeded();
if (Plugin.Config.SidebarTabView)
@@ -938,6 +970,10 @@ public sealed class ChatLogWindow : Window
// v1.2.0 — Bottom-Status-Bar. Letzter Render-Step in DrawChatLog,
// damit alle Zeilen-Operationen davor keine Layout-Sprünge auslösen.
// v1.4.9 R2: skip on the first frame; ~12ms of first-frame layout
// cost. User sees the StatusBar 1 frame (~17ms at 60fps) later
// which is hidden inside the post-reload Atlas-Build window.
if (_firstFrameDone)
Plugin.StatusBar.Draw(Plugin);
}
@@ -989,6 +1025,16 @@ public sealed class ChatLogWindow : Window
private void DrawChannelName(Tab activeTab)
{
// v1.4.9 R2: plain-text fallback on the first frame. ReadChannelName
// builds SeString chunks and DrawChunks runs SeString-Renderer layout
// — together ~18ms first-frame. Frame 1 renders the real chunks; the
// user sees the tab name for ~17ms during the post-reload window.
if (!_firstFrameDone)
{
ImGui.TextUnformatted(activeTab.Name);
return;
}
var currentChannel = ReadChannelName(activeTab);
if (!currentChannel.SequenceEqual(PreviousChannel))
PreviousChannel = currentChannel;