Put each body's photograph on it one frame at a time, so entering the Sun's system no longer stalls

buildMarker gave every photographed body its map at once. A texture is copied to the GPU in the
first frame that draws it, and the 28 maps arrive within about 40 ms of each other, so that frame
copied some 20 megapixels of JPEG (seven maps at 2048x1024) through copyExternalImageToTexture: a
second long task of 135-162 ms about 1.25 s after entering, measured here four times on the
committed renderer (reviewers measured 160-210 against 85-100 without the 18 new maps). de34fff's
"adds no long task" was measured before those maps landed.

A photographed body now starts in its kind's flat colour, as a derived one does, and its texture
waits in a queue; each update() puts the first one that has loaded on its body. The copies are
spread one a frame, and all 38 bodies have their maps within half a second of the first. In the
running app, five fresh entries into the Sun's system at 1600x1000 left one long task of 52-66 ms
or none at all ([66], [52], [62], [], [56] ms, where the committed renderer gave [62, 149], [56,
135], [74, 162], [78, 162]).

Control: putting every loaded photograph on in one frame fails "puts them on their bodies once
loaded, one a frame".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 19:48:31 +02:00
co-authored by Claude Opus 5.5
parent 6c0626ca38
commit 2d4b8a98d5
2 changed files with 61 additions and 8 deletions
@@ -8,6 +8,7 @@ import { orientationAt } from '../../shared/astro/rotational-elements';
import { BodyRecord, RotationalElements } from '../../shared/models/body.model'; import { BodyRecord, RotationalElements } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { SystemOrbitsRenderer } from './system-orbits-renderer'; import { SystemOrbitsRenderer } from './system-orbits-renderer';
import { bodyTexturePath, loadCachedTexture } from '../../shared/rendering/texture-catalog';
/** The clock's UT date that names a TDB one: TT - UT, which moves by under a second a year, earlier. */ /** The clock's UT date that names a TDB one: TT - UT, which moves by under a second a year, earlier. */
const utOf = (jdTdb: number): number => jdTdb - ttMinusUtSeconds(jdTdb) / 86400; const utOf = (jdTdb: number): number => jdTdb - ttMinusUtSeconds(jdTdb) / 86400;
@@ -448,6 +449,32 @@ describe('rotation without IAU elements', () => {
}); });
}); });
describe('photographs', () => {
it('puts them on their bodies once loaded, one a frame, so the GPU is not handed every map at once', () => {
// Ids no other test here draws, since the loaded textures are shared through the cache.
const ids = ['ganymede', 'callisto'];
const records: BodyRecord[] = ids.map((id, index) => ({
id, systemStarId: 0, name: id, kind: 'planet', radiusKm: 2500, orbitSource: 'test',
orbit: { semiMajorAxisAu: 1 + index, eccentricity: 0, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD },
rates: keplerRates(1 + index, GM_SUN_AU3_PER_DAY2)
}));
const renderer = new SystemOrbitsRenderer(records, []);
const maps = (): Array<THREE.Texture | null> => renderer.members.map((member) => ((member.marker as THREE.Mesh).material as THREE.MeshStandardMaterial).map);
renderer.update(DEFAULT_EPOCH_JD);
expect(maps()).toEqual([null, null]); // not loaded yet: jsdom never loads an image
for (const id of ids) {
loadCachedTexture(bodyTexturePath(id)!).image = { width: 2, height: 1 };
}
renderer.update(DEFAULT_EPOCH_JD);
expect(maps().filter(Boolean)).toHaveLength(1);
renderer.update(DEFAULT_EPOCH_JD);
expect(maps()).toEqual(ids.map((id) => loadCachedTexture(bodyTexturePath(id)!)));
renderer.dispose();
});
});
describe('derived surfaces', () => { describe('derived surfaces', () => {
const maps = (renderer: SystemOrbitsRenderer): Array<THREE.Texture | null> => const maps = (renderer: SystemOrbitsRenderer): Array<THREE.Texture | null> =>
renderer.members.map((member) => ((member.marker as THREE.Mesh).material as THREE.MeshStandardMaterial).map); renderer.members.map((member) => ((member.marker as THREE.Mesh).material as THREE.MeshStandardMaterial).map);
@@ -170,26 +170,33 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame
* system is built: at about 4.4 ms each, the twenty bodies the solar system gained with its dwarf * 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 * 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. * then the body is its kind's flat colour.
*
* A photograph is handed to `deferPhotograph`, which puts it on the body once it has loaded, one a
* frame: a texture is copied to the GPU in the first frame that draws it, and the 28 maps, which
* arrive within 40 ms of each other, made that one frame a 160-210 ms task on entering the Sun's
* system (copyExternalImageToTexture, about 20 megapixels of JPEG).
*/ */
function buildMarker( function buildMarker(
id: string | undefined, id: string | undefined,
kind: SystemMemberKind, kind: SystemMemberKind,
radiusKm: number | undefined, radiusKm: number | undefined,
appearance: PlanetAppearance | undefined, appearance: PlanetAppearance | undefined,
deferSurface: (paint: () => void) => void deferSurface: (paint: () => void) => void,
deferPhotograph: (material: THREE.MeshStandardMaterial, texture: THREE.Texture) => void
): THREE.Mesh { ): THREE.Mesh {
const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm), MARKER_WIDTH_SEGMENTS, MARKER_HEIGHT_SEGMENTS); const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm), MARKER_WIDTH_SEGMENTS, MARKER_HEIGHT_SEGMENTS);
const photograph = id ? bodyTexturePath(id) : undefined; const photograph = id ? bodyTexturePath(id) : undefined;
// null, not undefined, where there is none yet: three warns "parameter 'map' has value of // null, not undefined, until there is one: three warns "parameter 'map' has value of
// undefined" for every body built so, eleven of them on entering the Sun's system. // undefined" for every body built so, eleven of them on entering the Sun's system.
const map = photograph ? loadCachedTexture(photograph) : null;
const material = new THREE.MeshStandardMaterial({ const material = new THREE.MeshStandardMaterial({
map, map: null,
color: map ? 0xffffff : colorForKind(kind), color: colorForKind(kind),
roughness: 1, roughness: 1,
metalness: 0 metalness: 0
}); });
if (!photograph && appearance) { if (photograph) {
deferPhotograph(material, loadCachedTexture(photograph));
} else if (appearance) {
deferSurface(() => { deferSurface(() => {
// 128 by 64, not the detail page's 512 by 256: that size costs about 60 ms a body on the // 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. // main thread, for a disc that is a few pixels across until the camera is on top of it.
@@ -348,6 +355,11 @@ export class SystemOrbitsRenderer {
this.surfacesToPaint.shift()?.(); this.surfacesToPaint.shift()?.();
this.surfaceTimer = this.surfacesToPaint.length > 0 ? setTimeout(this.paintNextSurface, 0) : undefined; this.surfaceTimer = this.surfacesToPaint.length > 0 ? setTimeout(this.paintNextSurface, 0) : undefined;
}; };
/** Photographs still to put on their bodies, one a frame once loaded; see `buildMarker`. */
private readonly photographsToShow: Array<{ material: THREE.MeshStandardMaterial; texture: THREE.Texture }> = [];
private readonly deferPhotograph = (material: THREE.MeshStandardMaterial, texture: THREE.Texture): void => {
this.photographsToShow.push({ material, texture });
};
constructor( constructor(
bodies: readonly BodyRecord[], bodies: readonly BodyRecord[],
@@ -474,6 +486,7 @@ export class SystemOrbitsRenderer {
* the orbits are taken at its TDB, as the spins are. Call once per tick. * the orbits are taken at its TDB, as the spins are. Call once per tick.
*/ */
update(epochJd: number): void { update(epochJd: number): void {
this.showNextPhotograph();
const jdTdb = tdbFromUtc(epochJd); const jdTdb = tdbFromUtc(epochJd);
for (const body of this.topLevelBodies) { for (const body of this.topLevelBodies) {
const current = meanElementsAt(body.elements, body.rates, jdTdb); const current = meanElementsAt(body.elements, body.rates, jdTdb);
@@ -518,6 +531,18 @@ export class SystemOrbitsRenderer {
this.tethers?.setTargets(this.tetherPoints); this.tethers?.setTargets(this.tetherPoints);
} }
/** Puts the first photograph that has loaded on its body: one texture for the GPU a frame. */
private showNextPhotograph(): void {
const index = this.photographsToShow.findIndex(({ texture }) => texture.image);
if (index < 0) {
return;
}
const [{ material, texture }] = this.photographsToShow.splice(index, 1);
material.map = texture;
material.color.set(0xffffff);
material.needsUpdate = true;
}
/** /**
* Looks up which system member a marker object belongs to (e.g. from a raycast hit), or a part * Looks up which system member a marker object belongs to (e.g. from a raycast hit), or a part
* of one: a ray through Saturn's rings picks Saturn. * of one: a ray through Saturn's rings picks Saturn.
@@ -549,6 +574,7 @@ export class SystemOrbitsRenderer {
dispose(): void { dispose(): void {
clearTimeout(this.surfaceTimer); clearTimeout(this.surfaceTimer);
this.surfacesToPaint.length = 0; this.surfacesToPaint.length = 0;
this.photographsToShow.length = 0;
this.grid?.dispose(); this.grid?.dispose();
this.tethers?.dispose(); this.tethers?.dispose();
for (const { geometry, material } of this.disposables) { for (const { geometry, material } of this.disposables) {
@@ -574,7 +600,7 @@ export class SystemOrbitsRenderer {
rotation?: { periodHours?: number; elements?: RotationalElements } rotation?: { periodHours?: number; elements?: RotationalElements }
): TrackedTopLevelBody { ): TrackedTopLevelBody {
const orbitLine = buildOrbitLine(elements, kind, frame); const orbitLine = buildOrbitLine(elements, kind, frame);
const marker = buildMarker(id, kind, radiusKm, appearance, this.deferSurface); const marker = buildMarker(id, kind, radiusKm, appearance, this.deferSurface, this.deferPhotograph);
this.object.add(orbitLine, marker); this.object.add(orbitLine, marker);
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material); this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
this.trackDisposable(marker.geometry, marker.material as THREE.Material); this.trackDisposable(marker.geometry, marker.material as THREE.Material);
@@ -597,7 +623,7 @@ export class SystemOrbitsRenderer {
): TrackedMoon { ): TrackedMoon {
const pivot = new THREE.Group(); const pivot = new THREE.Group();
const orbitLine = buildOrbitLine(elements, 'moon', frame); const orbitLine = buildOrbitLine(elements, 'moon', frame);
const marker = buildMarker(id, 'moon', radiusKm, appearance, this.deferSurface); const marker = buildMarker(id, 'moon', radiusKm, appearance, this.deferSurface, this.deferPhotograph);
pivot.add(orbitLine, marker); pivot.add(orbitLine, marker);
this.object.add(pivot); this.object.add(pivot);
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material); this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);