From a37b78445210a96136d6311705aa8ce519d69172 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 28 May 2026 14:10:29 +0200 Subject: [PATCH] =?UTF-8?q?perf(ui):=20F8-12=20=E2=80=94=20toggle=20respon?= =?UTF-8?q?siveness=20on=20700-planet=20legacy=20reports=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profiling KNNTS041 (700 planets, 1283 primitives, 29 LOCAL fog circles) flushed three independent costs out of the toggle path: * `setVisibilityFog` rebuilt the inverse mask + 29 × 9 paint ops on every effect run, even when the input was identical. Caches a fingerprint of the circles + wrap mode and bails on a no-op call — knocks ~1 ms off every flip, more on heavier maps. * `paintLabelEntry` was split into `paintLabelLayout` (hit-area / line positions / frame geometry — runs on every content change) and `paintLabelSelection` (text fills + frame visibility — runs only when the selection identity actually flips). The incremental path now skips the 6300 redundant `Text.style.fill = ...` writes it used to perform on every `planetNames` flip, which is what forced Pixi to invalidate the underlying text textures. * `applyLabelContent` no longer blanks `nameText.text` when the toggle hides the name — it just flips `visible`. The cached text texture survives, so the next paint frame skips ~700 texture rebuilds. Also enables Pixi-side culling on every per-copy primitive / outline / label container. With 9 torus copies × ~700 planets the scene graph holds thousands of nodes, most of which sit outside the visible viewport at any moment — the cullable flag lets Pixi skip them in the per-frame traversal. The legacy `KNNTS041` probe (chromium-desktop, headless) shows `applyVisibilityState` collapsing from ~24 ms to ~5 ms after a cache-warm flip; `app.render` drops from ~46 ms to ~22 ms. Reading the toggle delay end-to-end inside the browser still measures ~460 ms in headless, which is consistent with the runner's RAF cadence — owner can confirm on the real machine where the previous ~1 s delay was reported. Co-Authored-By: Claude Opus 4.7 --- ui/frontend/src/map/render.ts | 172 ++++++++++++++++++++++++++++------ 1 file changed, 141 insertions(+), 31 deletions(-) diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index 7fb721d..ea79376 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -515,6 +515,24 @@ export async function createRenderer(opts: RendererOptions): Promise, + wrap: WrapMode, + ): string => { + const parts: string[] = [wrap]; + for (const c of circles) { + parts.push(`${c.x};${c.y};${c.radius}`); + } + return parts.join("|"); + }; const applyHiddenStateTo = (id: PrimitiveID, list: Graphics[]): void => { const visible = !hiddenIds.has(id); for (const g of list) g.visible = visible; @@ -532,6 +550,10 @@ export async function createRenderer(opts: RendererOptions): Promise { for (const c of copies) { const g = new Graphics(); + // F8-12 perf round 4: cull off-screen primitives so a + // 9 × N scene graph (700 planets → 6300 disc Graphics on a + // legacy report) doesn't drag the per-frame paint cost. + g.cullable = true; drawPrimitiveInto(prim, g); c.addChild(g); let list = primitiveGraphics.get(prim.id); @@ -650,29 +672,22 @@ export async function createRenderer(opts: RendererOptions): Promise { - // Text colours flip on selection so the legend reads on the - // inverse-fill frame. + // `paintLabelLayout` keeps the label container's hit area + line + // positions in sync with the current text visibility / content. It + // never mutates `style.fill`, so Pixi does not invalidate the + // underlying Text textures — this is the path the F8-12 / #29 + // `planetNames` toggle walks for all 700+ planets and absolutely + // has to stay cheap. The selection-frame update is split into + // `paintLabelSelection` below because that one *does* need to + // touch fills. + const paintLabelLayout = (entry: LabelGfx): void => { const nameVisible = entry.nameText !== null && entry.nameText.visible; - const nameFill = isSelected ? theme.labelInverseText : theme.labelText; - const numberFill = isSelected - ? theme.labelInverseText - : nameVisible - ? theme.labelMuted - : theme.labelText; - if (entry.nameText !== null) { - entry.nameText.style.fill = nameFill; - } - entry.numberText.style.fill = numberFill; const nameHeight = nameVisible ? entry.nameText!.height : 0; const numberHeight = entry.numberText.height; const totalTextHeight = nameHeight + (nameVisible ? LABEL_LINE_GAP_PX : 0) + numberHeight; entry.numberText.y = nameVisible ? nameHeight + LABEL_LINE_GAP_PX : 0; - // Refresh the click hit area to match the label's bounding box - // plus the padding from the selection frame (so the player can - // click anywhere inside the visible legend, not just the glyphs). const widestText = Math.max( nameVisible ? entry.nameText!.width : 0, entry.numberText.width, @@ -685,24 +700,85 @@ export async function createRenderer(opts: RendererOptions): Promise