Paint a system's derived surfaces after entering it, not while building it

Every body with no photograph gets a surface derived from its measurements, a 128 by 64 texture
painted on the main thread as its marker was built, inside the task that enters the system. At
about 4.4 ms each (measured in node for the twenty the next commit adds, 88 ms together), that is
the cost that grows with the number of bodies: with the solar system at 38 bodies, the long tasks
after selectStar(0) were [219, 72], [228, 79] and [177, 72] ms over three runs, against [85, 72],
[94, 75] and [78, 67] at 18.

buildMarker now gives such a body its kind's flat colour and hands the painting to the renderer,
which paints one surface per task (setTimeout 0) once the constructor has returned, and drops the
rest if the system is left first. Measured in the running app (port 4311, three runs each, long
tasks over 50 ms in the 9 s after entering the Sun's system):

- 18 bodies: [79], [73], [77] ms. The task that entered the system is under 50 ms.
- 38 bodies: [55, 72], [69, 78], [60, 83], and [52, 78] on a fourth run. The entering task is
  52-69 ms, down from 78-94 before this change with 18 bodies, so the twenty new bodies add no
  long task over what the branch had. What they still add to it is not measured apart.

The flat colour shows for a moment: the Sun's 29 derived surfaces were all painted 436, 689 and
399 ms after its renderer was built (three runs), and a surface once painted is cached, so a
return visit paints them at once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 21:52:32 +02:00
co-authored by Claude Opus 5.5
parent 7b65ab4812
commit de34ffff43
2 changed files with 62 additions and 7 deletions
@@ -453,6 +453,31 @@ describe('rotation', () => {
});
});
describe('derived surfaces', () => {
const maps = (renderer: SystemOrbitsRenderer): Array<THREE.Texture | null> =>
renderer.members.map((member) => ((member.marker as THREE.Mesh).material as THREE.MeshStandardMaterial).map);
const nextTask = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
const twoPlanets = (): SystemOrbitsRenderer =>
new SystemOrbitsRenderer([], [exoplanet({ radiusEarth: 1.1 }), exoplanet({ id: 'TRAPPIST-1 c', name: 'TRAPPIST-1 c', radiusEarth: 1.0 })], undefined, 1);
it('paints them after the system is built, one a task, so entering a system is not held up', async () => {
const renderer = twoPlanets();
expect(maps(renderer)).toEqual([null, null]);
await nextTask();
expect(maps(renderer).filter(Boolean)).toHaveLength(1);
await nextTask();
expect(maps(renderer).every(Boolean)).toBe(true);
renderer.dispose();
});
it('paints nothing once the system is left', async () => {
const renderer = twoPlanets();
renderer.dispose();
await nextTask();
expect(maps(renderer)).toEqual([null, null]);
});
});
describe('exoplanet size without a measured radius', () => {
const radiusOf = (overrides: Partial<ExoplanetRecord>): number => {
const renderer = new SystemOrbitsRenderer([], [exoplanet(overrides)], undefined, 1);
@@ -170,20 +170,37 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame
* drew every body from a 32 by 16 pixel procedural texture instead, which at a few pixels across
* was indistinguishable from its average colour and, once the camera closed in, was a blur. A
* marker can now fill the frame, so it takes the real image at the size the detail page uses.
*
* A derived texture is not painted here but handed to `deferSurface`, which paints it after the
* system is built: at about 4.4 ms each, the twenty bodies the solar system gained with its dwarf
* planets and smaller moons lengthened the task that enters it from 78-94 ms to 177-228. Until
* then the body is its kind's flat colour.
*/
function buildMarker(id: string | undefined, kind: SystemMemberKind, radiusKm: number | undefined, appearance: PlanetAppearance | undefined): THREE.Mesh {
function buildMarker(
id: string | undefined,
kind: SystemMemberKind,
radiusKm: number | undefined,
appearance: PlanetAppearance | undefined,
deferSurface: (paint: () => void) => void
): THREE.Mesh {
const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm), MARKER_WIDTH_SEGMENTS, MARKER_HEIGHT_SEGMENTS);
const photograph = id ? bodyTexturePath(id) : undefined;
// 128 by 64 for the derived texture, not the detail page's 512 by 256: that size costs about
// 60 ms a body on the main thread, 360 ms on entering a six-planet system, for a disc that is a
// few pixels across until the camera is on top of it.
const map = photograph ? loadCachedTexture(photograph) : appearance ? planetTexture(appearance, { width: 128, height: 64 }) : undefined;
const map = photograph ? loadCachedTexture(photograph) : undefined;
const material = new THREE.MeshStandardMaterial({
map,
color: map ? 0xffffff : colorForKind(kind),
roughness: 1,
metalness: 0
});
if (!photograph && appearance) {
deferSurface(() => {
// 128 by 64, not the detail page's 512 by 256: that size costs about 60 ms a body on the
// main thread, for a disc that is a few pixels across until the camera is on top of it.
material.map = planetTexture(appearance, { width: 128, height: 64 });
material.color.set(0xffffff);
material.needsUpdate = true;
});
}
return new THREE.Mesh(geometry, material);
}
@@ -335,6 +352,17 @@ export class SystemOrbitsRenderer {
* following them each tick costs no allocation at all.
*/
private tetherPoints: readonly THREE.Vector3[] = [];
/** Derived surfaces still to paint, one a task, once the constructor is done; see `buildMarker`. */
private readonly surfacesToPaint: Array<() => void> = [];
private surfaceTimer?: ReturnType<typeof setTimeout>;
private readonly deferSurface = (paint: () => void): void => {
this.surfacesToPaint.push(paint);
this.surfaceTimer ??= setTimeout(this.paintNextSurface, 0);
};
private readonly paintNextSurface = (): void => {
this.surfacesToPaint.shift()?.();
this.surfaceTimer = this.surfacesToPaint.length > 0 ? setTimeout(this.paintNextSurface, 0) : undefined;
};
constructor(
bodies: readonly BodyRecord[],
@@ -515,6 +543,8 @@ export class SystemOrbitsRenderer {
}
dispose(): void {
clearTimeout(this.surfaceTimer);
this.surfacesToPaint.length = 0;
this.grid?.dispose();
this.tethers?.dispose();
for (const { geometry, material } of this.disposables) {
@@ -540,7 +570,7 @@ export class SystemOrbitsRenderer {
rotation?: { periodHours?: number; obliquityDeg?: number }
): TrackedTopLevelBody {
const orbitLine = buildOrbitLine(elements, kind, frame);
const marker = buildMarker(id, kind, radiusKm, appearance);
const marker = buildMarker(id, kind, radiusKm, appearance, this.deferSurface);
this.object.add(orbitLine, marker);
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
@@ -563,7 +593,7 @@ export class SystemOrbitsRenderer {
): TrackedMoon {
const pivot = new THREE.Group();
const orbitLine = buildOrbitLine(elements, 'moon', frame);
const marker = buildMarker(id, 'moon', radiusKm, appearance);
const marker = buildMarker(id, 'moon', radiusKm, appearance, this.deferSurface);
pivot.add(orbitLine, marker);
this.object.add(pivot);
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);