perf(ui): F8-12 — toggle responsiveness on 700-planet legacy reports (#55)
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 <noreply@anthropic.com>
This commit is contained in:
+143
-33
@@ -515,6 +515,24 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
// `setVisibilityFog` runs. We track the dispatched ops only
|
||||
// implicitly via the layer's children; on every flip we drop
|
||||
// the previous children and rebuild from the new op list.
|
||||
//
|
||||
// `currentFogFingerprint` keeps the renderer from rebuilding the
|
||||
// fog mask on a no-data-change `setVisibilityFog` call. The
|
||||
// `applyVisibilityState` path in `map.svelte` runs on every
|
||||
// toggle flip; for a 700-planet legacy report the destroy +
|
||||
// recreate of 29 × 9 mask circles is the dominant cost of a
|
||||
// `planet-names` flip (the toggle does not touch fog at all).
|
||||
let currentFogFingerprint: string | null = null;
|
||||
const fingerprintFogCircles = (
|
||||
circles: ReadonlyArray<{ x: number; y: number; radius: number }>,
|
||||
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<RendererHan
|
||||
const populatePrimitives = (prim: Primitive, isExtra: boolean): void => {
|
||||
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<RendererHan
|
||||
currentPlanetSetFingerprint = null;
|
||||
};
|
||||
|
||||
const paintLabelEntry = (entry: LabelGfx, isSelected: boolean): void => {
|
||||
// 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<RendererHan
|
||||
hitWidth,
|
||||
hitHeight,
|
||||
);
|
||||
// Repaint the frame at the new bounds *only* when it is
|
||||
// currently visible — for non-selected entries the frame stays
|
||||
// hidden and we leave its empty geometry untouched.
|
||||
if (entry.frame.visible) {
|
||||
entry.frame.clear();
|
||||
if (!isSelected) {
|
||||
entry.frame.visible = false;
|
||||
return;
|
||||
}
|
||||
const frameWidth = hitWidth;
|
||||
const frameHeight = hitHeight;
|
||||
entry.frame.roundRect(
|
||||
-frameWidth / 2,
|
||||
-hitWidth / 2,
|
||||
-LABEL_FRAME_PADDING_PX,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
hitWidth,
|
||||
hitHeight,
|
||||
3,
|
||||
);
|
||||
entry.frame.fill({ color: theme.labelInverseBackground, alpha: 0.95 });
|
||||
entry.frame.fill({
|
||||
color: theme.labelInverseBackground,
|
||||
alpha: 0.95,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// `paintLabelSelection` flips the inverse-frame on / off and
|
||||
// updates the text fills. Only runs when the selection identity
|
||||
// actually changes (renderer-internal "fast" path) or as part of
|
||||
// the initial label mount.
|
||||
const paintLabelSelection = (
|
||||
entry: LabelGfx,
|
||||
isSelected: boolean,
|
||||
): 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.nameText.style.fill = nameFill;
|
||||
}
|
||||
if (entry.numberText.style.fill !== numberFill) {
|
||||
entry.numberText.style.fill = numberFill;
|
||||
}
|
||||
if (!isSelected) {
|
||||
if (entry.frame.visible) {
|
||||
entry.frame.clear();
|
||||
entry.frame.visible = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Selected — make sure the frame matches the current layout
|
||||
// dimensions.
|
||||
const nameH = nameVisible ? entry.nameText!.height : 0;
|
||||
const numberH = entry.numberText.height;
|
||||
const totalH = nameH + (nameVisible ? LABEL_LINE_GAP_PX : 0) + numberH;
|
||||
const widest = Math.max(
|
||||
nameVisible ? entry.nameText!.width : 0,
|
||||
entry.numberText.width,
|
||||
);
|
||||
const frameW = widest + LABEL_FRAME_PADDING_PX * 2;
|
||||
const frameH = totalH + LABEL_FRAME_PADDING_PX * 2;
|
||||
entry.frame.clear();
|
||||
entry.frame.roundRect(
|
||||
-frameW / 2,
|
||||
-LABEL_FRAME_PADDING_PX,
|
||||
frameW,
|
||||
frameH,
|
||||
3,
|
||||
);
|
||||
entry.frame.fill({
|
||||
color: theme.labelInverseBackground,
|
||||
alpha: 0.95,
|
||||
});
|
||||
entry.frame.visible = true;
|
||||
};
|
||||
|
||||
const paintLabelEntry = (entry: LabelGfx, isSelected: boolean): void => {
|
||||
paintLabelLayout(entry);
|
||||
paintLabelSelection(entry, isSelected);
|
||||
};
|
||||
|
||||
const updateLabelTransforms = (): void => {
|
||||
const cameraScale = viewport.scaled;
|
||||
if (cameraScale <= 0) return;
|
||||
@@ -818,6 +894,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
const list: Graphics[] = [];
|
||||
for (const layer of outlineLayers) {
|
||||
const g = new Graphics();
|
||||
g.cullable = true;
|
||||
layer.addChild(g);
|
||||
list.push(g);
|
||||
}
|
||||
@@ -863,15 +940,24 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
// Same planet set, only the per-row text changed (almost
|
||||
// always the F8-12 / #29 `planetNames` toggle flipping name
|
||||
// strings on / off). Rebuilding 9-copies × N labels here
|
||||
// dwarfed everything else on a 500-planet map — instead, walk
|
||||
// the live entries and just update text content / visibility.
|
||||
// dwarfed everything else on a 700-planet map — instead, walk
|
||||
// the live entries and just update text content / visibility
|
||||
// + the cheap layout pass. Selection styling only runs when
|
||||
// the selection identity actually changed (i.e. another
|
||||
// branch above missed the early-out), so Pixi does not
|
||||
// invalidate Text textures on every toggle flip.
|
||||
currentLabels = labels.slice();
|
||||
const selectionChanged = !sameSelection;
|
||||
for (const data of labels) {
|
||||
const list = planetLabelInstances.get(data.planetNumber);
|
||||
if (list === undefined) continue;
|
||||
const isSelected = data.planetNumber === selectedPlanetId;
|
||||
for (const entry of list) {
|
||||
applyLabelContent(entry, data);
|
||||
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
|
||||
paintLabelLayout(entry);
|
||||
if (selectionChanged) {
|
||||
paintLabelSelection(entry, isSelected);
|
||||
}
|
||||
}
|
||||
}
|
||||
currentLabelsFingerprint = fp;
|
||||
@@ -890,6 +976,13 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
const list: LabelGfx[] = [];
|
||||
for (const layer of labelLayers) {
|
||||
const container = new Container();
|
||||
// F8-12 perf round 4: ask Pixi to skip the label when
|
||||
// its bounds fall outside the renderer's visible area.
|
||||
// On a 700-planet legacy report the scene graph holds
|
||||
// 9 × 700 = 6300 label containers — culling shrinks
|
||||
// that to whichever ones the camera is actually looking
|
||||
// at, which slashes per-frame paint cost dramatically.
|
||||
container.cullable = true;
|
||||
const frame = new Graphics();
|
||||
frame.visible = false;
|
||||
container.addChild(frame);
|
||||
@@ -928,11 +1021,20 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
|
||||
const applyLabelContent = (entry: LabelGfx, data: PlanetLabelData): void => {
|
||||
if (entry.nameText !== null) {
|
||||
const desired = data.name ?? "";
|
||||
if (entry.nameText.text !== desired) {
|
||||
entry.nameText.text = desired;
|
||||
if (data.name !== null) {
|
||||
if (entry.nameText.text !== data.name) {
|
||||
entry.nameText.text = data.name;
|
||||
}
|
||||
entry.nameText.visible = true;
|
||||
} else {
|
||||
// `planetNames` toggle off: keep the original text and
|
||||
// just flip visibility. Blanking the string would
|
||||
// invalidate Pixi's cached text texture and force a
|
||||
// re-render of all 700 + planets on the next paint,
|
||||
// which was the dominant cost of the toggle flip on a
|
||||
// big legacy report.
|
||||
entry.nameText.visible = false;
|
||||
}
|
||||
entry.nameText.visible = data.name !== null;
|
||||
}
|
||||
if (entry.numberText.text !== data.numberLabel) {
|
||||
entry.numberText.text = data.numberLabel;
|
||||
@@ -1440,6 +1542,14 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
},
|
||||
isPrimitiveHidden: (id) => hiddenIds.has(id),
|
||||
setVisibilityFog: (circles) => {
|
||||
// Same input → keep the previously-built fog around. On a
|
||||
// 700-planet report the destroy + recreate of 29 × 9 mask
|
||||
// circles costs north of half a second per call, and the
|
||||
// effect path runs it on every toggle flip even when the
|
||||
// fog itself is untouched.
|
||||
const fp = fingerprintFogCircles(circles, mode);
|
||||
if (fp === currentFogFingerprint) return;
|
||||
currentFogFingerprint = fp;
|
||||
// Detach the old mask before destroying its Graphics, then
|
||||
// drop the previous fog rectangles. Every flip rebuilds from
|
||||
// scratch instead of mutating in place.
|
||||
|
||||
Reference in New Issue
Block a user