From e7d244bb50efd55960a523e1ffb6329dbc1279c4 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Mon, 5 Oct 2026 18:26:31 -0300 Subject: [PATCH] perf(renderer): a nebula of patches stays under the opacity ceiling; Cassiopeia A's zoom out repainted every patch in one 291 ms frame --- docs/performance/motion-freezes-membership.md | 7 +++---- .../prepared-image-layer-runtime.test.ts | 4 ++-- .../prepared-image-layer-runtime.ts | 17 ++++++++--------- .../src/universe/universe-catalog-banks.test.ts | 2 +- .../src/universe/universe-catalog-banks.ts | 10 +++++----- 5 files changed, 19 insertions(+), 21 deletions(-) diff --git a/docs/performance/motion-freezes-membership.md b/docs/performance/motion-freezes-membership.md index 7dc5d21a8bd..c6ec5c18e21 100644 --- a/docs/performance/motion-freezes-membership.md +++ b/docs/performance/motion-freezes-membership.md @@ -31,10 +31,9 @@ held differently while coasting: 2026-10-04). A stack's opacity in the mix stops at 0.999: Safari painted every slice under a stack again each time its opacity left or reached 1 (54 ms for 88 slices with the camera still, nothing for 0.99 to 0.98). A galaxy's or nebula's picture layers follow the same two rules (`image-layers/prepared-image-layer-runtime.ts`, - `universe/universe-catalog-banks.ts`): a bank of sheets stops its own opacity at 0.999 too. A bank of patches draws - at 1, since a group under 1 lost a wedge of the picture at the Homunculus's nearest view, and pays the repaint each - time it crosses 1 (180 to 208 ms for Cassiopeia A's 1,397 patches). A stack the camera turns to, or a bank shown - again, stays hidden until its + `universe/universe-catalog-banks.ts`): the bank's own opacity stops at 0.999 too, for sheets and for patches + (Cassiopeia A's 1,397 patches repainted in one frame of 291 ms each time the bank crossed 1 on a zoom out, 51 ms + under the ceiling). A stack the camera turns to, or a bank shown again, stays hidden until its images are decoded off the page's thread, then shows whole: decoded inside the paint, NGC 2392's 57 layers made a frame of 235 ms on a turn and 270 ms on a dataset switch, against 34 and 31 ms decoded first (iPad, 2026-10-05). Joining such a stack a share a frame was measured and rejected: each joining frame painted the layers already shown. diff --git a/packages/renderer/src/image-layers/prepared-image-layer-runtime.test.ts b/packages/renderer/src/image-layers/prepared-image-layer-runtime.test.ts index c4bb4e7cbfc..bd326b722b4 100644 --- a/packages/renderer/src/image-layers/prepared-image-layer-runtime.test.ts +++ b/packages/renderer/src/image-layers/prepared-image-layer-runtime.test.ts @@ -244,8 +244,8 @@ test('a stack mounts a camera for each of its scenes, and a stack without leaves assert.ok(first!.style.transform === camera && second!.style.transform === camera && cameras.every(each => each.style.perspective === '600px')); // Seen edge-on the stack is still the only one that draws: it keeps the whole weight. publish([Math.SQRT1_2, 0, 0, Math.SQRT1_2]); - // A stack of patches is drawn at 1, not under the ceiling a stack of sheets is held to. - assert.equal(named('css-volume-projection')[0]!.style.opacity, '1'); + // A stack of patches stays under the ceiling too, as a stack of sheets does. + assert.equal(named('css-volume-projection')[0]!.style.opacity, '0.999'); assert.equal(named('css-volume-projection')[0]!.style.visibility, 'visible'); assert.deepEqual(imageLayerAxisWeights([Math.SQRT1_2, 0, 0, Math.SQRT1_2], views.filter(view => view.axis === 'z')), { z: 1 }); }); diff --git a/packages/renderer/src/image-layers/prepared-image-layer-runtime.ts b/packages/renderer/src/image-layers/prepared-image-layer-runtime.ts index 83dee6fcc70..db0d1244180 100644 --- a/packages/renderer/src/image-layers/prepared-image-layer-runtime.ts +++ b/packages/renderer/src/image-layers/prepared-image-layer-runtime.ts @@ -127,9 +127,6 @@ export function mountPreparedCssImageLayers({ host, before, payload, resolveReso let destroyed = false; const quotable = (address: string) => address.replace(/["\\\n\r]/g, char => `\\${char}`); return Object.freeze({ root, - /** The opacity the bank's root stops at, as its stacks do (`publish`): 1 for a bank of patches, under 1 - * (STACK_OPACITY_CEILING) for any other. */ - ceiling: banks.every(bank => bank.patches) ? 1 : STACK_OPACITY_CEILING, /** The bank root is shown again: each stack it draws waits for its images' decode, as one the camera turns to does. */ resume() { for (const bank of banks) { bank.drawn = false; bank.turn++; } }, /** `around`: the bank is drawn around a body that stands inside it, at that place (reference metres). The sheets the @@ -196,12 +193,14 @@ export function mountPreparedCssImageLayers({ host, before, payload, resolveReso const shown = body && texture.sheet && (withinReach(texture.sheet, local.positionUnits, bank.reach) || withinReach(texture.sheet, body, bank.reach)) ? '0' : ''; if (texture.shown !== shown) { texture.shown = shown; texture.element.style.opacity = shown; } } - // Never 1 for a stack of sheets (STACK_OPACITY_CEILING): a drag across M31 had its longest frame at 108 to 111 ms - // with a bank's opacity reaching 1, and 67 to 72 ms under the ceiling (iPad, 2026-10-04). A stack of patches is - // the only stack of its bank and keeps the whole weight, so it is drawn at 1: under the ceiling the browser - // composites the stack as one group, and at the Homunculus's nearest view a wedge of that group, a third of the - // picture, was black for one step of a drag, at the same camera places on every pass (headless Chrome, 2026-10-05). - set(bank.projection, 'opacity', String(Math.min(bank.patches ? 1 : STACK_OPACITY_CEILING, weight))); + // Never 1 (STACK_OPACITY_CEILING): a drag across M31 had its longest frame at 108 to 111 ms with a bank's opacity + // reaching 1, and 67 to 72 ms under the ceiling (iPad, 2026-10-04). A stack of patches was drawn at 1 for a day: + // under the ceiling a wedge of the Homunculus had gone black for a step of a drag at its nearest view, in headless + // Chrome. That was looked for again and not found. At the nearest view, on the nebula's page and around its star, + // the picture under the ceiling matched the picture at 1 at every step: 580 steps in Chrome on a GPU, 90 in the + // software-rendered shell, 11 on an iPad. And at 1 the bank crossed 1 on every zoom out: Cassiopeia A's 1,397 + // patches repainted in one frame of 291 ms on the iPad, 51 ms under the ceiling (2026-10-05). + set(bank.projection, 'opacity', String(Math.min(STACK_OPACITY_CEILING, weight))); set(bank.projection, 'visibility', wanted && bank.ready ? 'visible' : 'hidden'); // A zero-weight axis contributes nothing; its 3D leaves leave compositing. set(bank.projection, 'display', wanted ? '' : 'none'); diff --git a/packages/renderer/src/universe/universe-catalog-banks.test.ts b/packages/renderer/src/universe/universe-catalog-banks.test.ts index 8a8b79984a9..7679fc4c602 100644 --- a/packages/renderer/src/universe/universe-catalog-banks.test.ts +++ b/packages/renderer/src/universe/universe-catalog-banks.test.ts @@ -11,7 +11,7 @@ mock.module('../image-layers/prepared-image-layer-runtime.js', { namedExports: { mountPreparedCssImageLayers: ({ host, before }: { host: HTMLElement; before: Element }) => { const root = host.ownerDocument.createElement('div'); host.insertBefore(root, before); - return { root, ceiling: 0.999, publish(_publication: unknown, around: readonly number[] | false = false) { drawnAround.push(around !== false); }, resume() {}, destroy() { root.remove(); } }; + return { root, publish(_publication: unknown, around: readonly number[] | false = false) { drawnAround.push(around !== false); }, resume() {}, destroy() { root.remove(); } }; }, } }); // The modules under test import the mocked ones, so they load after the mocks. diff --git a/packages/renderer/src/universe/universe-catalog-banks.ts b/packages/renderer/src/universe/universe-catalog-banks.ts index b5d84d35a9c..46748b426df 100644 --- a/packages/renderer/src/universe/universe-catalog-banks.ts +++ b/packages/renderer/src/universe/universe-catalog-banks.ts @@ -1,4 +1,5 @@ import { createImageFocusBank, createPointFocusBank } from './prepared-focus-bank.js'; +import { STACK_OPACITY_CEILING } from '../volume/prepared-volume-runtime.js'; import type { SceneLifetime } from '@cssearth/engine'; import { type PreparedCatalogObject, type DensityVolumeFrame } from '@cssearth/objects'; import { type WorldCameraPose } from '@cssearth/engine'; @@ -225,11 +226,10 @@ export function createUniverseCatalogBanks({ root, end, stage, lifetime, declara bank.dotsShown = dotOpacity > 0; } if (opacity !== bank.publishedOpacity) { - // The root stops where its stacks do (the bank's `ceiling`). Under 1 for a bank of sheets: Safari paints every leaf - // under the root again when its opacity leaves or reaches 1. At 1 for a bank of patches, which a group under 1 - // draws with a wedge missing at its nearest view (prepared-image-layer-runtime.ts); such a bank pays that repaint, - // 180 to 208 ms each way for Cassiopeia A's 1,397 patches with the camera still on an iPad (2026-10-05). - bank.mounted.root.style.opacity = String(Math.min(bank.mounted.ceiling, opacity)); + // Never 1 (STACK_OPACITY_CEILING), as a stack's own opacity: Safari paints every leaf under the root again when its + // opacity leaves or reaches 1. Cassiopeia A's 1,397 patches made a frame of 180 to 208 ms each way with the camera + // still on an iPad, and none between 0.999 and 0.99 (2026-10-05): the first frames of every zoom out of a nebula. + bank.mounted.root.style.opacity = String(Math.min(STACK_OPACITY_CEILING, opacity)); if (opacity > 0 && bank.mounted.root.style.display === 'none') bank.mounted.resume(); bank.mounted.root.style.display = opacity > 0 ? '' : 'none'; bank.publishedOpacity = opacity;