From 2d4b8a98d549dd9a3e1a3d61e9c08476ef295f43 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Tue, 29 Sep 2026 19:48:31 +0200 Subject: [PATCH] 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) --- .../system-orbits-renderer.spec.ts | 27 ++++++++++++ .../galaxy-system/system-orbits-renderer.ts | 42 +++++++++++++++---- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/app/features/galaxy-system/system-orbits-renderer.spec.ts b/src/app/features/galaxy-system/system-orbits-renderer.spec.ts index 0512976..5ed9372 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.spec.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.spec.ts @@ -8,6 +8,7 @@ import { orientationAt } from '../../shared/astro/rotational-elements'; import { BodyRecord, RotationalElements } from '../../shared/models/body.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; 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. */ 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 => 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', () => { const maps = (renderer: SystemOrbitsRenderer): Array => renderer.members.map((member) => ((member.marker as THREE.Mesh).material as THREE.MeshStandardMaterial).map); diff --git a/src/app/features/galaxy-system/system-orbits-renderer.ts b/src/app/features/galaxy-system/system-orbits-renderer.ts index 7b209b7..90ae8d4 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.ts @@ -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 * 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. + * + * 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( id: string | undefined, kind: SystemMemberKind, radiusKm: number | undefined, appearance: PlanetAppearance | undefined, - deferSurface: (paint: () => void) => void + deferSurface: (paint: () => void) => void, + deferPhotograph: (material: THREE.MeshStandardMaterial, texture: THREE.Texture) => void ): THREE.Mesh { const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm), MARKER_WIDTH_SEGMENTS, MARKER_HEIGHT_SEGMENTS); 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. - const map = photograph ? loadCachedTexture(photograph) : null; const material = new THREE.MeshStandardMaterial({ - map, - color: map ? 0xffffff : colorForKind(kind), + map: null, + color: colorForKind(kind), roughness: 1, metalness: 0 }); - if (!photograph && appearance) { + if (photograph) { + deferPhotograph(material, loadCachedTexture(photograph)); + } else if (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. @@ -348,6 +355,11 @@ export class SystemOrbitsRenderer { this.surfacesToPaint.shift()?.(); 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( 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. */ update(epochJd: number): void { + this.showNextPhotograph(); const jdTdb = tdbFromUtc(epochJd); for (const body of this.topLevelBodies) { const current = meanElementsAt(body.elements, body.rates, jdTdb); @@ -518,6 +531,18 @@ export class SystemOrbitsRenderer { 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 * of one: a ray through Saturn's rings picks Saturn. @@ -549,6 +574,7 @@ export class SystemOrbitsRenderer { dispose(): void { clearTimeout(this.surfaceTimer); this.surfacesToPaint.length = 0; + this.photographsToShow.length = 0; this.grid?.dispose(); this.tethers?.dispose(); for (const { geometry, material } of this.disposables) { @@ -574,7 +600,7 @@ export class SystemOrbitsRenderer { rotation?: { periodHours?: number; elements?: RotationalElements } ): TrackedTopLevelBody { 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.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material); this.trackDisposable(marker.geometry, marker.material as THREE.Material); @@ -597,7 +623,7 @@ export class SystemOrbitsRenderer { ): TrackedMoon { const pivot = new THREE.Group(); 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); this.object.add(pivot); this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);