perf(ui): F8-12 — pixel-space planet sizing + single-copy label/outline layers (#55)
* Planet size formula moves to pixel-space: `pointRadiusBasePx = 2 + 2 * cbrt(size / SIZE_NORMALIZER)`. The on-screen disc now reads ~4-7 px at the reference zoom regardless of how large the world rectangle is — the previous `world-units` formulation blew up on small maps and made Source-class planets swallow their neighbours. * Labels + outlines live in the origin copy only. The 9× replication across torus copies was the dominant cost on a 100+ planet map (Pixi.Text creation + Graphics rebuilds on every zoom step); the origin-copy layout is what the camera-wrap listener guarantees the user actually sees. * `setPlanetLabels` and `setPlanetOutlines` skip Pixi-object rebuilds when the input fingerprint is unchanged — toggle flips and selection changes now keep the existing Text / Graphics instances alive and only repaint the affected pieces. * `renderer.md` updated to the new contract. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+138
-104
@@ -453,29 +453,19 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
return c;
|
||||
});
|
||||
|
||||
// Outline layer per copy (F8-12 / #30). Sits between the
|
||||
// primitive disc and the labels so the stroke reads against the
|
||||
// planet fill while staying below the textual layer. Each entry
|
||||
// is rebuilt in `updateOutlineTransforms` on every zoom step so
|
||||
// the radius hugs the visible disc.
|
||||
const outlineLayers: Container[] = copies.map((c) => {
|
||||
const layer = new Container();
|
||||
c.addChild(layer);
|
||||
return layer;
|
||||
});
|
||||
|
||||
// Label layer per copy (F8-12 / #29). Labels render above every
|
||||
// primitive so the text reads on top of fog / route lines, and the
|
||||
// per-copy layout mirrors the primitive copies so wrap mode still
|
||||
// shows the labels in whichever torus tile the user is panned over.
|
||||
// Each layer holds one `Container` per planet (built lazily by
|
||||
// `setPlanetLabels`), and we keep the scale + y-offset of those
|
||||
// containers in lock-step with the camera in `updateLabelTransforms`.
|
||||
const labelLayers: Container[] = copies.map((c) => {
|
||||
const layer = new Container();
|
||||
c.addChild(layer);
|
||||
return layer;
|
||||
});
|
||||
// Outline + label layers (F8-12 / #29 + #30). Both live in the
|
||||
// origin copy only — replicating Pixi.Text / Graphics across all
|
||||
// nine torus copies is the dominant cost on a 100+ planet map,
|
||||
// and the player almost never sees a label "wrap" out of the
|
||||
// central tile because the camera-wrap listener snaps the centre
|
||||
// back into `[0, W) × [0, H)` whenever it walks past the seam.
|
||||
// Outlines sit between the primitive disc and the labels so the
|
||||
// stroke reads against the planet fill while staying below the
|
||||
// textual layer.
|
||||
const outlineLayer = new Container();
|
||||
const labelLayer = new Container();
|
||||
copies[ORIGIN_COPY_INDEX].addChild(outlineLayer);
|
||||
copies[ORIGIN_COPY_INDEX].addChild(labelLayer);
|
||||
|
||||
// Per-id `Graphics` lookup. Each primitive lives in nine copies
|
||||
// (one per torus tile); pick-mode dims them by id, so the lookup
|
||||
@@ -563,19 +553,30 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
populatePrimitives(p, false);
|
||||
}
|
||||
|
||||
// Planet label state (F8-12 / #29 + #30). The renderer holds one
|
||||
// `Container` per planet per torus copy; text + selection frame
|
||||
// live inside that container. `currentLabels` mirrors the dataset
|
||||
// last passed into `setPlanetLabels` so a zoom-driven transform
|
||||
// update does not need a fresh report.
|
||||
// Planet label state (F8-12 / #29 + #30). One Container per
|
||||
// planet, anchored at the planet's `(x, y)` in the origin copy.
|
||||
// `currentLabels` mirrors the dataset last passed into
|
||||
// `setPlanetLabels` so a zoom-driven transform update does not
|
||||
// need a fresh report.
|
||||
interface LabelGfx {
|
||||
readonly container: Container;
|
||||
readonly frame: Graphics;
|
||||
readonly nameText: Text | null;
|
||||
readonly numberText: Text;
|
||||
}
|
||||
const planetLabelInstances = new Map<number, LabelGfx[]>();
|
||||
const planetLabelInstances = new Map<number, LabelGfx>();
|
||||
let currentLabels: ReadonlyArray<PlanetLabelData> = [];
|
||||
let currentLabelsFingerprint: string | null = null;
|
||||
let currentLabelsSelectedId: number | null = null;
|
||||
const fingerprintPlanetLabels = (
|
||||
labels: ReadonlyArray<PlanetLabelData>,
|
||||
): string => {
|
||||
const parts: string[] = [];
|
||||
for (const l of labels) {
|
||||
parts.push(`${l.planetNumber};${l.name ?? ""};${l.numberLabel}`);
|
||||
}
|
||||
return parts.join("|");
|
||||
};
|
||||
const LABEL_FONT_SIZE_PX = 11;
|
||||
const LABEL_LINE_GAP_PX = 0;
|
||||
const LABEL_FRAME_PADDING_PX = 3;
|
||||
@@ -608,10 +609,12 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
};
|
||||
|
||||
const clearAllLabels = (): void => {
|
||||
for (const list of planetLabelInstances.values()) {
|
||||
for (const entry of list) disposeLabelGfx(entry);
|
||||
for (const entry of planetLabelInstances.values()) {
|
||||
disposeLabelGfx(entry);
|
||||
}
|
||||
planetLabelInstances.clear();
|
||||
currentLabelsFingerprint = null;
|
||||
currentLabelsSelectedId = null;
|
||||
};
|
||||
|
||||
const paintLabelEntry = (entry: LabelGfx, isSelected: boolean): void => {
|
||||
@@ -659,59 +662,64 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
if (cameraScale <= 0) return;
|
||||
const labelScale = 1 / cameraScale;
|
||||
const gapWorld = LABEL_OFFSET_PX / cameraScale;
|
||||
for (const [planetNumber, list] of planetLabelInstances) {
|
||||
const planetPrim = pointPrimitivesById.get(planetNumber);
|
||||
if (planetPrim === undefined) continue;
|
||||
const visibleRadius = displayPointRadiusWorld(
|
||||
planetPrim.style,
|
||||
cameraScale,
|
||||
currentScaleRef,
|
||||
);
|
||||
const labelData = currentLabels.find(
|
||||
(l) => l.planetNumber === planetNumber,
|
||||
);
|
||||
const anchorX = labelData?.x ?? planetPrim.x;
|
||||
const anchorY = labelData?.y ?? planetPrim.y;
|
||||
for (const entry of list) {
|
||||
entry.container.x = anchorX;
|
||||
entry.container.y = anchorY + visibleRadius + gapWorld;
|
||||
entry.container.scale.set(labelScale);
|
||||
}
|
||||
for (const data of currentLabels) {
|
||||
const entry = planetLabelInstances.get(data.planetNumber);
|
||||
if (entry === undefined) continue;
|
||||
const planetPrim = pointPrimitivesById.get(data.planetNumber);
|
||||
const visibleRadius =
|
||||
planetPrim === undefined
|
||||
? 0
|
||||
: displayPointRadiusWorld(
|
||||
planetPrim.style,
|
||||
cameraScale,
|
||||
currentScaleRef,
|
||||
);
|
||||
entry.container.x = data.x;
|
||||
entry.container.y = data.y + visibleRadius + gapWorld;
|
||||
entry.container.scale.set(labelScale);
|
||||
}
|
||||
};
|
||||
|
||||
// Planet outline state (F8-12 / #30). One Graphics per planet per
|
||||
// torus copy. Width and colour come from `PlanetOutlineSpec`; the
|
||||
// radius is recomputed on every zoom step so the outline tracks
|
||||
// the visible disc — the planet itself may grow / shrink with
|
||||
// zoom (`pointRadiusWorld` softening) or stay constant
|
||||
// Planet outline state (F8-12 / #30). One Graphics per planet,
|
||||
// painted in the origin copy alongside the label container. Width
|
||||
// and colour come from `PlanetOutlineSpec`; the radius is
|
||||
// recomputed on every zoom step so the outline tracks the visible
|
||||
// disc — the planet itself may grow / shrink with zoom
|
||||
// (`pointRadiusBasePx` softening) or stay constant
|
||||
// (`pointRadiusPx` pixel-space).
|
||||
interface PlanetOutlineGfx {
|
||||
readonly graphics: Graphics[];
|
||||
readonly graphics: Graphics;
|
||||
readonly spec: PlanetOutlineSpec;
|
||||
}
|
||||
const planetOutlineInstances = new Map<number, PlanetOutlineGfx>();
|
||||
let currentOutlinesFingerprint: string | null = null;
|
||||
const fingerprintPlanetOutlines = (
|
||||
outlines: ReadonlyArray<PlanetOutlineSpec>,
|
||||
): string => {
|
||||
const parts: string[] = [];
|
||||
for (const o of outlines) {
|
||||
parts.push(`${o.planetNumber};${o.color};${o.widthPx ?? -1}`);
|
||||
}
|
||||
return parts.join("|");
|
||||
};
|
||||
const OUTLINE_DEFAULT_WIDTH_PX = 1.5;
|
||||
const OUTLINE_RADIUS_PADDING_PX = 1; // gap between disc edge and stroke
|
||||
|
||||
const clearAllOutlines = (): void => {
|
||||
for (const entry of planetOutlineInstances.values()) {
|
||||
for (const g of entry.graphics) {
|
||||
g.parent?.removeChild(g);
|
||||
g.destroy();
|
||||
}
|
||||
entry.graphics.parent?.removeChild(entry.graphics);
|
||||
entry.graphics.destroy();
|
||||
}
|
||||
planetOutlineInstances.clear();
|
||||
currentOutlinesFingerprint = null;
|
||||
};
|
||||
|
||||
const paintOutlineEntry = (entry: PlanetOutlineGfx): void => {
|
||||
const cameraScale = viewport.scaled;
|
||||
if (cameraScale <= 0) return;
|
||||
const planetPrim = pointPrimitivesById.get(entry.spec.planetNumber);
|
||||
if (planetPrim === undefined) {
|
||||
for (const g of entry.graphics) g.clear();
|
||||
return;
|
||||
}
|
||||
entry.graphics.clear();
|
||||
if (planetPrim === undefined) return;
|
||||
const visibleRadius = displayPointRadiusWorld(
|
||||
planetPrim.style,
|
||||
cameraScale,
|
||||
@@ -721,15 +729,12 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
const widthWorld =
|
||||
(entry.spec.widthPx ?? OUTLINE_DEFAULT_WIDTH_PX) / cameraScale;
|
||||
const outlineRadius = visibleRadius + paddingWorld;
|
||||
for (const g of entry.graphics) {
|
||||
g.clear();
|
||||
g.circle(planetPrim.x, planetPrim.y, outlineRadius);
|
||||
g.stroke({
|
||||
color: entry.spec.color,
|
||||
alpha: 0.95,
|
||||
width: widthWorld,
|
||||
});
|
||||
}
|
||||
entry.graphics.circle(planetPrim.x, planetPrim.y, outlineRadius);
|
||||
entry.graphics.stroke({
|
||||
color: entry.spec.color,
|
||||
alpha: 0.95,
|
||||
width: widthWorld,
|
||||
});
|
||||
};
|
||||
|
||||
const updateOutlineTransforms = (): void => {
|
||||
@@ -741,15 +746,22 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
const setPlanetOutlines = (
|
||||
outlines: ReadonlyArray<PlanetOutlineSpec>,
|
||||
): void => {
|
||||
clearAllOutlines();
|
||||
for (const spec of outlines) {
|
||||
const list: Graphics[] = [];
|
||||
for (const layer of outlineLayers) {
|
||||
const g = new Graphics();
|
||||
layer.addChild(g);
|
||||
list.push(g);
|
||||
const fp = fingerprintPlanetOutlines(outlines);
|
||||
if (fp === currentOutlinesFingerprint) {
|
||||
// Same dataset — just refresh the geometry (the planet
|
||||
// position / size may have changed in the underlying
|
||||
// primitive). Keeps Graphics instances around.
|
||||
for (const entry of planetOutlineInstances.values()) {
|
||||
paintOutlineEntry(entry);
|
||||
}
|
||||
const entry: PlanetOutlineGfx = { graphics: list, spec };
|
||||
return;
|
||||
}
|
||||
clearAllOutlines();
|
||||
currentOutlinesFingerprint = fp;
|
||||
for (const spec of outlines) {
|
||||
const g = new Graphics();
|
||||
outlineLayer.addChild(g);
|
||||
const entry: PlanetOutlineGfx = { graphics: g, spec };
|
||||
planetOutlineInstances.set(spec.planetNumber, entry);
|
||||
paintOutlineEntry(entry);
|
||||
}
|
||||
@@ -760,33 +772,55 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
labels: ReadonlyArray<PlanetLabelData>,
|
||||
selectedPlanetId: number | null,
|
||||
): void => {
|
||||
const fp = fingerprintPlanetLabels(labels);
|
||||
const sameContent = fp === currentLabelsFingerprint;
|
||||
const sameSelection = selectedPlanetId === currentLabelsSelectedId;
|
||||
if (sameContent && sameSelection) {
|
||||
// Position-only update (a zoom step may have moved planets
|
||||
// in the data) — keep Pixi.Text instances alive.
|
||||
currentLabels = labels.slice();
|
||||
updateLabelTransforms();
|
||||
return;
|
||||
}
|
||||
if (sameContent) {
|
||||
// Text + planet identity unchanged; only the selection frame
|
||||
// flips. Repaint the affected entries instead of rebuilding.
|
||||
currentLabels = labels.slice();
|
||||
for (const data of labels) {
|
||||
const entry = planetLabelInstances.get(data.planetNumber);
|
||||
if (entry === undefined) continue;
|
||||
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
|
||||
}
|
||||
currentLabelsSelectedId = selectedPlanetId;
|
||||
updateLabelTransforms();
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
clearAllLabels();
|
||||
currentLabels = labels.slice();
|
||||
currentLabelsFingerprint = fp;
|
||||
currentLabelsSelectedId = selectedPlanetId;
|
||||
for (const data of labels) {
|
||||
const list: LabelGfx[] = [];
|
||||
for (const layer of labelLayers) {
|
||||
const container = new Container();
|
||||
const frame = new Graphics();
|
||||
frame.visible = false;
|
||||
container.addChild(frame);
|
||||
const nameText =
|
||||
data.name === null
|
||||
? null
|
||||
: buildLabelText(data.name, theme.labelText);
|
||||
if (nameText !== null) container.addChild(nameText);
|
||||
const numberText = buildLabelText(data.numberLabel, theme.labelMuted);
|
||||
container.addChild(numberText);
|
||||
layer.addChild(container);
|
||||
const entry: LabelGfx = {
|
||||
container,
|
||||
frame,
|
||||
nameText,
|
||||
numberText,
|
||||
};
|
||||
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
|
||||
list.push(entry);
|
||||
}
|
||||
planetLabelInstances.set(data.planetNumber, list);
|
||||
const container = new Container();
|
||||
const frame = new Graphics();
|
||||
frame.visible = false;
|
||||
container.addChild(frame);
|
||||
const nameText =
|
||||
data.name === null
|
||||
? null
|
||||
: buildLabelText(data.name, theme.labelText);
|
||||
if (nameText !== null) container.addChild(nameText);
|
||||
const numberText = buildLabelText(data.numberLabel, theme.labelMuted);
|
||||
container.addChild(numberText);
|
||||
labelLayer.addChild(container);
|
||||
const entry: LabelGfx = {
|
||||
container,
|
||||
frame,
|
||||
nameText,
|
||||
numberText,
|
||||
};
|
||||
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
|
||||
planetLabelInstances.set(data.planetNumber, entry);
|
||||
}
|
||||
updateLabelTransforms();
|
||||
requestRender();
|
||||
|
||||
Reference in New Issue
Block a user