perf: reuse a stationary reading mask during scrolling
Keep native document geometry while translating content behind one viewport fade. Preserve navigation, focus, media preloading, and Static cleanup without changing 3D quality or render resolution. Validated assets, types, production builds, prerendered artifacts and transfer budgets; compared 64 layouts and checked 87 live interactions plus uncached media and repeated cleanup. High improved from 71.7 to 101.4 FPS at DPR 1 and 47.8 to 62.2 FPS in the full-size DPR 1.5 window, with occasional sub-60 intervals still remaining.
This commit is contained in:
@@ -3,144 +3,109 @@ import { usesRoadsideSign } from './sign-layout';
|
||||
|
||||
const stageOwners = new WeakMap<HTMLElement, object>();
|
||||
|
||||
/** Feather the reading edge into the world. This is a spatial alpha ramp,
|
||||
* not a time-based disappearance: reversing scroll restores the same pixels. */
|
||||
/** Keep the reading-edge mask stationary while native document scrolling
|
||||
* moves the content behind it. Changing chapter-sized gradients on every
|
||||
* scroll invalidates their rasterized content in Chromium. */
|
||||
export function createSignStage(resources: SceneResourceBag) {
|
||||
const main = document.querySelector<HTMLElement>('.portfolio-main');
|
||||
const stage = main?.closest<HTMLElement>('.reading-stage');
|
||||
const viewport = main?.parentElement;
|
||||
const nav = document.querySelector<HTMLElement>('.site-navigation');
|
||||
const root = document.documentElement;
|
||||
const panels = main
|
||||
? Array.from(main.children).filter(
|
||||
(element): element is HTMLElement => element instanceof HTMLElement,
|
||||
)
|
||||
const lazyImages = main
|
||||
? Array.from(main.querySelectorAll<HTMLImageElement>('img[loading="lazy"]'))
|
||||
: [];
|
||||
// Read the complete stage before writing masks. Interleaving each panel's
|
||||
// reads and writes forces style recalculation again for the next panel.
|
||||
const measurements = panels.map((panel) => ({
|
||||
panel,
|
||||
heading: panel.querySelector<HTMLElement>(':scope > h2'),
|
||||
bounds: null as DOMRect | null,
|
||||
headingOffset: '',
|
||||
}));
|
||||
let imageObserver: IntersectionObserver | undefined;
|
||||
const headings = main
|
||||
? Array.from(main.children).flatMap((panel) => {
|
||||
const heading = panel.querySelector<HTMLElement>(':scope > h2');
|
||||
return panel instanceof HTMLElement && heading
|
||||
? [{ panel, heading }]
|
||||
: [];
|
||||
})
|
||||
: [];
|
||||
const token = {};
|
||||
let dirty = true;
|
||||
let cutoff = 0;
|
||||
let stageBottom = 176;
|
||||
let navigationBottom = 66;
|
||||
let readingTop = 220;
|
||||
const token = {};
|
||||
const mark = () => {
|
||||
dirty = true;
|
||||
};
|
||||
resources.listen(window, 'scroll', mark, { passive: true });
|
||||
resources.listen(window, 'resize', mark, { passive: true });
|
||||
resources.listen(document, 'focusin', (event) => {
|
||||
if (!(event.target instanceof HTMLElement) || !main?.contains(event.target))
|
||||
return;
|
||||
if (event.target.matches('h1,h2,main')) return;
|
||||
const rect = event.target.getBoundingClientRect();
|
||||
const top = readingTop;
|
||||
let topEdge = 188;
|
||||
let feather = 32;
|
||||
let contentHeight = 0;
|
||||
let viewportHeight = 0;
|
||||
let viewportWidth = 0;
|
||||
let lastMask = '';
|
||||
let lastOffset = Number.NaN;
|
||||
|
||||
const ownsStage = () => !!main && stageOwners.get(main) === token;
|
||||
|
||||
const moveContent = () => {
|
||||
if (!main || !viewport || !ownsStage()) return;
|
||||
// Sticky positioning supplies any overscroll beyond the document edges.
|
||||
const offset = Math.max(
|
||||
0,
|
||||
Math.min(window.scrollY, contentHeight - viewportHeight),
|
||||
);
|
||||
if (offset !== lastOffset) {
|
||||
main.style.transform = `translate3d(0, ${-offset}px, 0)`;
|
||||
lastOffset = offset;
|
||||
}
|
||||
const reveal = Math.min(
|
||||
1,
|
||||
Math.max(0, window.scrollY / Math.max(240, viewportHeight * 0.58)),
|
||||
);
|
||||
const top = topEdge * reveal;
|
||||
const bottom =
|
||||
window.innerHeight < 560 && window.innerWidth < 768
|
||||
? window.innerHeight - 164
|
||||
: window.innerHeight - 74;
|
||||
if (rect.top < top)
|
||||
window.scrollBy({ top: rect.top - top - 12, behavior: 'instant' });
|
||||
else if (rect.bottom > bottom)
|
||||
window.scrollBy({ top: rect.bottom - bottom + 12, behavior: 'instant' });
|
||||
});
|
||||
const observer = resources.observe(new ResizeObserver(mark));
|
||||
if (main) observer.observe(main);
|
||||
if (nav) observer.observe(nav);
|
||||
resources.own('other', {
|
||||
dispose() {
|
||||
if (!main || stageOwners.get(main) !== token) return;
|
||||
stageOwners.delete(main);
|
||||
main?.style.removeProperty('clip-path');
|
||||
main.style.removeProperty('mask-image');
|
||||
main.style.removeProperty('-webkit-mask-image');
|
||||
for (const panel of panels) {
|
||||
panel.style.removeProperty('mask-image');
|
||||
panel.style.removeProperty('-webkit-mask-image');
|
||||
panel.style.removeProperty('--chapter-heading-offset');
|
||||
}
|
||||
root.style.removeProperty('--sign-stage-bottom');
|
||||
root.style.removeProperty('--sign-reading-top');
|
||||
delete root.dataset.expressway;
|
||||
},
|
||||
});
|
||||
return {
|
||||
update(width: number, height: number) {
|
||||
if (dirty && main) {
|
||||
navigationBottom = nav?.getBoundingClientRect().bottom ?? 66;
|
||||
const faceWidth = Math.min(
|
||||
width - 44,
|
||||
width < 900 ? 288 : 356,
|
||||
height < 650 ? 250 : 356,
|
||||
);
|
||||
const shelfBottom = navigationBottom + 24 + faceWidth / 3.18;
|
||||
const roadside = usesRoadsideSign(width, height);
|
||||
const feather = height < 650 ? 28 : roadside ? 32 : 48;
|
||||
stageBottom = shelfBottom + (height < 650 ? feather : 0);
|
||||
// Gradually establish the strip while the opening leaves the viewport.
|
||||
const reveal = Math.min(
|
||||
1,
|
||||
Math.max(0, window.scrollY / Math.max(240, height * 0.58)),
|
||||
);
|
||||
const short = height < 560;
|
||||
const proposedTopEdge = short
|
||||
? navigationBottom + 10
|
||||
: height < 650
|
||||
? shelfBottom - 22
|
||||
: roadside
|
||||
? navigationBottom + 128 + (height - 900) * 0.085
|
||||
: shelfBottom;
|
||||
// Keep the heading and its eyebrow readable above the existing activation line.
|
||||
const topEdge = Math.min(proposedTopEdge, height * 0.35 - feather - 36);
|
||||
readingTop = topEdge + feather;
|
||||
// Only the panel crossing the edge needs a composited mask. Masking
|
||||
// the complete document would allocate a very tall intermediate layer.
|
||||
for (const measurement of measurements) {
|
||||
const bounds = measurement.panel.getBoundingClientRect();
|
||||
measurement.bounds = bounds;
|
||||
if (measurement.heading)
|
||||
measurement.headingOffset = `${(measurement.heading.getBoundingClientRect().top - bounds.top).toFixed(2)}px`;
|
||||
}
|
||||
for (const { panel, bounds, heading, headingOffset } of measurements) {
|
||||
if (!bounds) continue;
|
||||
if (heading)
|
||||
panel.style.setProperty('--chapter-heading-offset', headingOffset);
|
||||
const top = topEdge * reveal;
|
||||
const bottom = short && width < 768 ? height - 164 : Infinity;
|
||||
if (
|
||||
bounds.bottom > 0 &&
|
||||
(bounds.bottom <= top || bounds.top >= bottom)
|
||||
) {
|
||||
panel.style.maskImage = 'linear-gradient(transparent, transparent)';
|
||||
panel.style.setProperty(
|
||||
'-webkit-mask-image',
|
||||
'linear-gradient(transparent, transparent)',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
bounds.bottom <= 0 ||
|
||||
(bounds.top >= top + feather && bounds.bottom <= bottom - feather)
|
||||
) {
|
||||
panel.style.removeProperty('mask-image');
|
||||
panel.style.removeProperty('-webkit-mask-image');
|
||||
continue;
|
||||
}
|
||||
cutoff = Math.max(0, top - bounds.top);
|
||||
const bottomEdge = Number.isFinite(bottom)
|
||||
? Math.max(cutoff + feather, bottom - bounds.top)
|
||||
: bounds.height + feather;
|
||||
const mask = `linear-gradient(to bottom, transparent ${cutoff.toFixed(2)}px, rgb(0 0 0 / 18%) ${(cutoff + feather * 0.25).toFixed(2)}px, rgb(0 0 0 / 78%) ${(cutoff + feather * 0.72).toFixed(2)}px, black ${(cutoff + feather).toFixed(2)}px, black ${(bottomEdge - feather).toFixed(2)}px, transparent ${bottomEdge.toFixed(2)}px)`;
|
||||
panel.style.maskImage = mask;
|
||||
panel.style.setProperty('-webkit-mask-image', mask);
|
||||
}
|
||||
main.style.removeProperty('clip-path');
|
||||
main.style.removeProperty('mask-image');
|
||||
main.style.removeProperty('-webkit-mask-image');
|
||||
viewportHeight < 560 && viewportWidth < 768 ? viewportHeight - 164 : null;
|
||||
const mask = `linear-gradient(to bottom, transparent ${top.toFixed(2)}px, rgb(0 0 0 / 18%) ${(top + feather * 0.25).toFixed(2)}px, rgb(0 0 0 / 78%) ${(top + feather * 0.72).toFixed(2)}px, black ${(top + feather).toFixed(2)}px${bottom === null ? '' : `, black ${(bottom - feather).toFixed(2)}px, transparent ${bottom.toFixed(2)}px`})`;
|
||||
// Once the opening has departed, this image stays unchanged until resize.
|
||||
if (mask !== lastMask) {
|
||||
viewport.style.maskImage = mask;
|
||||
lastMask = mask;
|
||||
}
|
||||
};
|
||||
|
||||
const measureContent = () => {
|
||||
if (!main || !stage) return;
|
||||
const height = main.getBoundingClientRect().height;
|
||||
const offsets = headings.map(({ panel, heading }) => ({
|
||||
panel,
|
||||
offset: `${(heading.getBoundingClientRect().top - panel.getBoundingClientRect().top).toFixed(2)}px`,
|
||||
}));
|
||||
if (height !== contentHeight) {
|
||||
contentHeight = height;
|
||||
stage.style.height = `${height}px`;
|
||||
}
|
||||
for (const { panel, offset } of offsets)
|
||||
panel.style.setProperty('--chapter-heading-offset', offset);
|
||||
};
|
||||
|
||||
const update = (width: number, height: number) => {
|
||||
if (dirty || width !== viewportWidth || height !== viewportHeight) {
|
||||
dirty = false;
|
||||
viewportWidth = width;
|
||||
viewportHeight = height;
|
||||
navigationBottom = nav?.getBoundingClientRect().bottom ?? 66;
|
||||
const faceWidth = Math.min(
|
||||
width - 44,
|
||||
width < 900 ? 288 : 356,
|
||||
height < 650 ? 250 : 356,
|
||||
);
|
||||
const shelfBottom = navigationBottom + 24 + faceWidth / 3.18;
|
||||
const roadside = usesRoadsideSign(width, height);
|
||||
feather = height < 650 ? 28 : roadside ? 32 : 48;
|
||||
stageBottom = shelfBottom + (height < 650 ? feather : 0);
|
||||
const short = height < 560;
|
||||
const proposedTopEdge = short
|
||||
? navigationBottom + 10
|
||||
: height < 650
|
||||
? shelfBottom - 22
|
||||
: roadside
|
||||
? navigationBottom + 128 + (height - 900) * 0.085
|
||||
: shelfBottom;
|
||||
topEdge = Math.min(proposedTopEdge, height * 0.35 - feather - 36);
|
||||
readingTop = topEdge + feather;
|
||||
if (main && stage && viewport?.classList.contains('reading-viewport')) {
|
||||
root.style.setProperty(
|
||||
'--sign-stage-bottom',
|
||||
`${(short ? topEdge : stageBottom).toFixed(2)}px`,
|
||||
@@ -150,10 +115,82 @@ export function createSignStage(resources: SceneResourceBag) {
|
||||
`${readingTop.toFixed(2)}px`,
|
||||
);
|
||||
root.dataset.expressway = 'live';
|
||||
// Measure in normal flow before enabling the viewport, so the native
|
||||
// document keeps its full height and its original anchor coordinates.
|
||||
measureContent();
|
||||
stageOwners.set(main, token);
|
||||
dirty = false;
|
||||
stage.dataset.readingStage = 'live';
|
||||
viewport.style.height = `${height}px`;
|
||||
moveContent();
|
||||
if (!imageObserver && lazyImages.length) {
|
||||
// A masked ancestor clips native lazy-loading margins. Observe the
|
||||
// reading viewport directly so images still load before arrival.
|
||||
imageObserver = resources.observe(
|
||||
new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!ownsStage()) return;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
(entry.target as HTMLImageElement).loading = 'eager';
|
||||
imageObserver?.unobserve(entry.target);
|
||||
}
|
||||
},
|
||||
{ root: viewport, rootMargin: '1250px 0px' },
|
||||
),
|
||||
);
|
||||
for (const image of lazyImages) imageObserver.observe(image);
|
||||
}
|
||||
}
|
||||
return { navigationBottom, stageBottom };
|
||||
},
|
||||
}
|
||||
return { navigationBottom, stageBottom };
|
||||
};
|
||||
|
||||
resources.listen(window, 'scroll', moveContent, { passive: true });
|
||||
const layoutChanged = () => {
|
||||
dirty = true;
|
||||
if (ownsStage()) update(root.clientWidth, window.innerHeight);
|
||||
};
|
||||
resources.listen(window, 'resize', layoutChanged, { passive: true });
|
||||
const observer = resources.observe(new ResizeObserver(layoutChanged));
|
||||
if (main) observer.observe(main);
|
||||
if (nav) observer.observe(nav);
|
||||
for (const { heading } of headings) observer.observe(heading);
|
||||
|
||||
resources.listen(document, 'focusin', (event) => {
|
||||
if (!(event.target instanceof HTMLElement) || !main?.contains(event.target))
|
||||
return;
|
||||
if (event.target.matches('h1,h2,main')) return;
|
||||
moveContent();
|
||||
const rect = event.target.getBoundingClientRect();
|
||||
const bottom =
|
||||
window.innerHeight < 560 && window.innerWidth < 768
|
||||
? window.innerHeight - 164
|
||||
: window.innerHeight - 74;
|
||||
if (rect.top < readingTop)
|
||||
window.scrollBy({ top: rect.top - readingTop - 12, behavior: 'instant' });
|
||||
else if (rect.bottom > bottom)
|
||||
window.scrollBy({ top: rect.bottom - bottom + 12, behavior: 'instant' });
|
||||
moveContent();
|
||||
});
|
||||
|
||||
resources.own('other', {
|
||||
dispose() {
|
||||
if (!main || !ownsStage()) return;
|
||||
stageOwners.delete(main);
|
||||
if (stage) {
|
||||
delete stage.dataset.readingStage;
|
||||
stage.style.removeProperty('height');
|
||||
}
|
||||
main.style.removeProperty('transform');
|
||||
viewport?.style.removeProperty('mask-image');
|
||||
viewport?.style.removeProperty('height');
|
||||
for (const image of lazyImages) image.loading = 'lazy';
|
||||
for (const { panel } of headings)
|
||||
panel.style.removeProperty('--chapter-heading-offset');
|
||||
root.style.removeProperty('--sign-stage-bottom');
|
||||
root.style.removeProperty('--sign-reading-top');
|
||||
delete root.dataset.expressway;
|
||||
},
|
||||
});
|
||||
return { update };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user