perf(ui): F8-12 — pixel-space planet sizing + single-copy label/outline layers (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m20s

* 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:
Ilia Denisov
2026-05-28 00:39:19 +02:00
parent 75a4211373
commit 6996a79286
6 changed files with 218 additions and 167 deletions
+138 -104
View File
@@ -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();
+16 -14
View File
@@ -39,20 +39,21 @@ import {
// extra lookup.
/**
* KNOWN_PLANET_BASE_RADIUS_WORLD calibrates the cube-root size
* mapping so that an "average" planet (`size === SIZE_NORMALIZER`)
* renders at roughly this radius in world units when the camera is
* at the reference scale. Larger / smaller planets scale by
* KNOWN_PLANET_MIN_RADIUS_PX / KNOWN_PLANET_GROWTH_PX calibrate the
* cube-root size mapping in screen-pixel space. At the "whole world
* fits" reference zoom (`scaleRef`) a Size-`SIZE_NORMALIZER` planet
* reads at `MIN + GROWTH` pixels; smaller / larger planets scale by
* `cbrt(size / SIZE_NORMALIZER)`, which keeps disc area proportional
* to volume — a Size-800 planet reads twice as big as a Size-100 one,
* eight times its volume but only 2× the radius.
* to volume — Size-800 reads twice as big as Size-100. The pixel
* frame is the right one to calibrate in, because it stays sane no
* matter how large the world rectangle is.
*
* `SIZE_NORMALIZER` follows the engine's typical mid-range. Without
* it, the raw cube-root grows huge for legacy fixtures that record
* Size in hundreds; with it, the disc stays in a sane world-unit
* band so neighbouring planets never overlap on the default zoom.
* The renderer combines these with `PLANET_SIZE_ZOOM_ALPHA` so the
* pixel radius grows sub-linearly as the player zooms in: 10× zoom
* scales the radius by ~2.15×, not by 10×.
*/
const KNOWN_PLANET_BASE_RADIUS_WORLD = 4;
const KNOWN_PLANET_MIN_RADIUS_PX = 2;
const KNOWN_PLANET_GROWTH_PX = 2;
const SIZE_NORMALIZER = 100;
/**
@@ -68,9 +69,10 @@ function styleFor(planet: ReportPlanet, theme: Theme): Style {
if (planet.kind === "unidentified" || size === null || !(size > 0)) {
return { ...fill, pointRadiusPx: UNKNOWN_PLANET_PIXEL_RADIUS };
}
const baseRadius =
KNOWN_PLANET_BASE_RADIUS_WORLD * Math.cbrt(size / SIZE_NORMALIZER);
return { ...fill, pointRadiusWorld: baseRadius };
const basePx =
KNOWN_PLANET_MIN_RADIUS_PX +
KNOWN_PLANET_GROWTH_PX * Math.cbrt(size / SIZE_NORMALIZER);
return { ...fill, pointRadiusBasePx: basePx };
}
function fillForKind(
+23 -16
View File
@@ -26,11 +26,12 @@ export type WrapMode = "torus" | "no-wrap";
// thickening that the old contract promised but never delivered is
// gone.
//
// `pointRadiusWorld` is the opposite intent: a planet's known
// `size` produces a base radius in world units, and the renderer
// softens its growth with the camera scale through
// `PLANET_SIZE_ZOOM_ALPHA` (F8-12 / #31). When `pointRadiusWorld`
// is set on a `PointPrim`, `pointRadiusPx` is ignored.
// `pointRadiusBasePx` is the opposite intent: a planet's known
// `size` produces a base on-screen pixel radius at the "whole world
// fits" reference zoom, and the renderer grows it sub-linearly with
// the camera scale through `PLANET_SIZE_ZOOM_ALPHA` (F8-12 / #31).
// When `pointRadiusBasePx` is set on a `PointPrim`, `pointRadiusPx`
// is ignored.
export interface Style {
fillColor?: number; // 0xRRGGBB
fillAlpha?: number; // 0..1
@@ -38,7 +39,7 @@ export interface Style {
strokeAlpha?: number; // 0..1
strokeWidthPx?: number; // screen pixels at any zoom
pointRadiusPx?: number; // screen pixels at any zoom (for kind === 'point')
pointRadiusWorld?: number; // world units, softened by PLANET_SIZE_ZOOM_ALPHA
pointRadiusBasePx?: number; // screen pixels at scaleRef, softened by PLANET_SIZE_ZOOM_ALPHA
// strokeDashPx — when set on a `LinePrim`, the line is rendered as
// a dashed pattern whose dash and gap are both this length. When
// unset (or zero), the stroke is solid. Interpreted in world-unit
@@ -231,12 +232,13 @@ export const PLANET_SIZE_ZOOM_ALPHA = 0.33;
/**
* displayPointRadiusWorld returns the world-space radius the renderer
* should draw a `PointPrim` with at the current camera scale. When
* `style.pointRadiusWorld` is set (known-size planets), the radius is
* the base world radius softened by `PLANET_SIZE_ZOOM_ALPHA` relative
* to `scaleRef` — at `scale = scaleRef` it equals the base radius;
* zooming in grows it sub-linearly. Otherwise the radius collapses to
* `pointRadiusPx / cameraScale` so the on-screen disc stays the same
* pixel size regardless of zoom.
* `style.pointRadiusBasePx` is set (known-size planets), the radius
* is the base pixel size at `scaleRef`, grown by
* `(scale / scaleRef)^α` and converted back into world units —
* `α = PLANET_SIZE_ZOOM_ALPHA`. At `scale = scaleRef` the visible
* pixel size equals the base; a 10× zoom-in only grows it ~2.15×.
* Otherwise the radius collapses to `pointRadiusPx / cameraScale` so
* the on-screen disc stays the same pixel size regardless of zoom.
*
* Used by both the renderer (`render.ts:drawPoint`) and the hit-test
* (`hit-test.ts:matchPoint`) so the visible disc and the click zone
@@ -247,12 +249,17 @@ export function displayPointRadiusWorld(
cameraScale: number,
scaleRef: number,
): number {
if (style.pointRadiusWorld !== undefined) {
const softening = Math.pow(cameraScale / scaleRef, PLANET_SIZE_ZOOM_ALPHA - 1);
return style.pointRadiusWorld * softening;
if (cameraScale <= 0) {
return style.pointRadiusBasePx ?? style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
}
if (style.pointRadiusBasePx !== undefined) {
const refScale = scaleRef > 0 ? scaleRef : cameraScale;
const screenPx =
style.pointRadiusBasePx *
Math.pow(cameraScale / refScale, PLANET_SIZE_ZOOM_ALPHA);
return screenPx / cameraScale;
}
const px = style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
if (cameraScale <= 0) return px;
return px / cameraScale;
}