refactor: optimize website visual runtime (#20120)
Refactors the website visual runtime to make WebGL-heavy sections more reliable and less expensive. This adds shared image/model loading caches, safer WebGL context recovery, staggered visual mounting, and static rendering for decorative Helped card visuals. It also removes a large bespoke Helped renderer in favor of the shared halftone model canvas, reduces scroll/layout work in the Helped section, and cleans up duplicated model-loading code across several visuals.
This commit is contained in:
@@ -30,6 +30,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sniptt/guards": "^0.2.0",
|
||||
"ai": "6.0.97",
|
||||
"class-validator": "^0.14.0",
|
||||
"expr-eval-fork": "3.0.3",
|
||||
"handlebars": "^4.7.9",
|
||||
|
||||
@@ -21,10 +21,28 @@ type GeometryCacheEntry = THREE.BufferGeometry | Promise<THREE.BufferGeometry>;
|
||||
|
||||
export type ImportedGeometryNormalizationOptions = {
|
||||
postRotateZ?: number;
|
||||
scaleTarget?: number;
|
||||
useLegacyNormalization?: boolean;
|
||||
};
|
||||
|
||||
const LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET = 2.75;
|
||||
const importedGeometryCache = new Map<string, GeometryCacheEntry>();
|
||||
|
||||
function getImportedGeometryCacheKey({
|
||||
geometryOptions,
|
||||
loader,
|
||||
modelUrl,
|
||||
}: {
|
||||
geometryOptions?: ImportedGeometryNormalizationOptions;
|
||||
loader: HalftoneModelLoader;
|
||||
modelUrl: string;
|
||||
}) {
|
||||
return JSON.stringify({
|
||||
geometryOptions: geometryOptions ?? null,
|
||||
loader,
|
||||
modelUrl,
|
||||
});
|
||||
}
|
||||
|
||||
function mergeGeometries(geometries: THREE.BufferGeometry[]) {
|
||||
if (geometries.length === 1) {
|
||||
@@ -128,7 +146,11 @@ function normalizeImportedGeometry(
|
||||
geometry: THREE.BufferGeometry,
|
||||
options: ImportedGeometryNormalizationOptions = {},
|
||||
) {
|
||||
const { postRotateZ = 0, useLegacyNormalization = false } = options;
|
||||
const {
|
||||
postRotateZ = 0,
|
||||
scaleTarget,
|
||||
useLegacyNormalization = false,
|
||||
} = options;
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
|
||||
@@ -156,9 +178,9 @@ function normalizeImportedGeometry(
|
||||
|
||||
const radius = geometry.boundingSphere?.radius || 1;
|
||||
const scale = useLegacyNormalization
|
||||
? LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET /
|
||||
? (scaleTarget ?? LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET) /
|
||||
Math.max(size.x, size.y, size.z, 0.001)
|
||||
: 1.6 / radius;
|
||||
: (scaleTarget ?? 1.6) / radius;
|
||||
geometry.scale(scale, scale, scale);
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
@@ -309,19 +331,44 @@ export async function loadImportedGeometryFromUrl(
|
||||
label: string,
|
||||
geometryOptions?: ImportedGeometryNormalizationOptions,
|
||||
) {
|
||||
const response = await fetch(modelUrl);
|
||||
const cacheKey = getImportedGeometryCacheKey({
|
||||
geometryOptions,
|
||||
loader,
|
||||
modelUrl,
|
||||
});
|
||||
const cachedGeometry = importedGeometryCache.get(cacheKey);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load ${label} from ${modelUrl}.`);
|
||||
if (cachedGeometry) {
|
||||
return (await cachedGeometry).clone();
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const geometryPromise = (async () => {
|
||||
const response = await fetch(modelUrl);
|
||||
|
||||
if (loader === 'fbx') {
|
||||
return parseFbxGeometry(buffer, '', label, geometryOptions);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load ${label} from ${modelUrl}.`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
if (loader === 'fbx') {
|
||||
return parseFbxGeometry(buffer, '', label, geometryOptions);
|
||||
}
|
||||
|
||||
return parseGlbGeometry(buffer, '', label, geometryOptions);
|
||||
})();
|
||||
importedGeometryCache.set(cacheKey, geometryPromise);
|
||||
|
||||
try {
|
||||
const geometry = await geometryPromise;
|
||||
|
||||
importedGeometryCache.set(cacheKey, geometry);
|
||||
|
||||
return geometry.clone();
|
||||
} catch (error) {
|
||||
importedGeometryCache.delete(cacheKey);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return parseGlbGeometry(buffer, '', label, geometryOptions);
|
||||
}
|
||||
|
||||
function makePolarShape(
|
||||
|
||||
@@ -309,6 +309,8 @@ export type HalftoneSnapshotFn = (
|
||||
},
|
||||
) => Promise<Blob | null>;
|
||||
|
||||
export type HalftoneRenderStrategy = 'continuous' | 'static';
|
||||
|
||||
export type HalftoneImageInteractionSettings = {
|
||||
hoverFadeIn: number;
|
||||
hoverFadeOut: number;
|
||||
@@ -332,6 +334,7 @@ type HalftoneCanvasProps = {
|
||||
onFirstInteraction: () => void;
|
||||
onPoseChange: (pose: HalftoneExportPose) => void;
|
||||
previewDistance: number;
|
||||
renderStrategy?: HalftoneRenderStrategy;
|
||||
settings: HalftoneStudioSettings;
|
||||
snapshotRef?: MutableRefObject<HalftoneSnapshotFn | null>;
|
||||
virtualRenderHeight?: number;
|
||||
@@ -557,6 +560,7 @@ export function HalftoneCanvas({
|
||||
onFirstInteraction,
|
||||
onPoseChange,
|
||||
previewDistance,
|
||||
renderStrategy = 'continuous',
|
||||
settings,
|
||||
snapshotRef,
|
||||
virtualRenderHeight = VIRTUAL_RENDER_HEIGHT,
|
||||
@@ -738,10 +742,14 @@ export function HalftoneCanvas({
|
||||
renderer.setSize(getRenderWidth(), getRenderHeight(), false);
|
||||
|
||||
const canvas = renderer.domElement;
|
||||
canvas.style.cursor = getCanvasCursor(settingsReference.current, false);
|
||||
canvas.style.cursor =
|
||||
renderStrategy === 'static'
|
||||
? 'default'
|
||||
: getCanvasCursor(settingsReference.current, false);
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.touchAction = 'none';
|
||||
canvas.style.pointerEvents = renderStrategy === 'static' ? 'none' : 'auto';
|
||||
canvas.style.touchAction = renderStrategy === 'static' ? 'auto' : 'none';
|
||||
canvas.style.width = '100%';
|
||||
container.appendChild(canvas);
|
||||
|
||||
@@ -1312,8 +1320,6 @@ export function HalftoneCanvas({
|
||||
);
|
||||
};
|
||||
|
||||
const stopObservingSize = observeElementSize(container, syncSize);
|
||||
|
||||
const updatePointerPosition = (
|
||||
event: PointerEvent,
|
||||
options?: { resetVelocity?: boolean },
|
||||
@@ -1546,13 +1552,6 @@ export function HalftoneCanvas({
|
||||
handlePointerCancel();
|
||||
};
|
||||
|
||||
canvas.addEventListener('pointermove', handlePointerMove);
|
||||
canvas.addEventListener('pointerleave', handlePointerLeave);
|
||||
canvas.addEventListener('pointerup', handlePointerUp);
|
||||
canvas.addEventListener('pointercancel', handlePointerCancel);
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
canvas.addEventListener('pointerdown', handlePointerDown);
|
||||
|
||||
const clock = new THREE.Timer();
|
||||
clock.connect(document);
|
||||
|
||||
@@ -1985,18 +1984,36 @@ export function HalftoneCanvas({
|
||||
renderer.render(postScene, orthographicCamera);
|
||||
};
|
||||
|
||||
renderLoop = createVisualRenderLoop({
|
||||
renderFrame,
|
||||
shouldRender: () => !cancelled,
|
||||
target: container,
|
||||
targetVisibilityOptions: { rootMargin: '100px' },
|
||||
});
|
||||
renderLoop.start();
|
||||
const renderCurrentFrame = () => {
|
||||
renderFrame(
|
||||
typeof performance === 'undefined' ? undefined : performance.now(),
|
||||
);
|
||||
};
|
||||
|
||||
cleanup = () => {
|
||||
runCleanupTasks([
|
||||
() => stopObservingSize(),
|
||||
() => renderLoop?.dispose(),
|
||||
const syncSizeAndRenderIfStatic = () => {
|
||||
syncSize();
|
||||
|
||||
if (renderStrategy === 'static') {
|
||||
renderCurrentFrame();
|
||||
}
|
||||
};
|
||||
|
||||
const stopObservingSize = observeElementSize(
|
||||
container,
|
||||
syncSizeAndRenderIfStatic,
|
||||
);
|
||||
|
||||
const interactionCleanupTasks: Array<() => void> = [];
|
||||
|
||||
if (renderStrategy !== 'static') {
|
||||
canvas.addEventListener('pointermove', handlePointerMove);
|
||||
canvas.addEventListener('pointerleave', handlePointerLeave);
|
||||
canvas.addEventListener('pointerup', handlePointerUp);
|
||||
canvas.addEventListener('pointercancel', handlePointerCancel);
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
canvas.addEventListener('pointerdown', handlePointerDown);
|
||||
|
||||
interactionCleanupTasks.push(
|
||||
() => canvas.removeEventListener('pointermove', handlePointerMove),
|
||||
() => canvas.removeEventListener('pointerleave', handlePointerLeave),
|
||||
() => canvas.removeEventListener('pointerup', handlePointerUp),
|
||||
@@ -2004,6 +2021,24 @@ export function HalftoneCanvas({
|
||||
canvas.removeEventListener('pointercancel', handlePointerCancel),
|
||||
() => window.removeEventListener('blur', handleWindowBlur),
|
||||
() => canvas.removeEventListener('pointerdown', handlePointerDown),
|
||||
);
|
||||
|
||||
renderLoop = createVisualRenderLoop({
|
||||
renderFrame,
|
||||
shouldRender: () => !cancelled,
|
||||
target: container,
|
||||
targetVisibilityOptions: { rootMargin: '100px' },
|
||||
});
|
||||
renderLoop.start();
|
||||
} else {
|
||||
renderCurrentFrame();
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
runCleanupTasks([
|
||||
() => stopObservingSize(),
|
||||
() => renderLoop?.dispose(),
|
||||
...interactionCleanupTasks,
|
||||
() => clock.dispose(),
|
||||
|
||||
() => blurHorizontalMaterial.dispose(),
|
||||
@@ -2050,6 +2085,7 @@ export function HalftoneCanvas({
|
||||
}, [
|
||||
onFirstInteraction,
|
||||
poseChangeReference,
|
||||
renderStrategy,
|
||||
snapshotReference,
|
||||
virtualRenderHeight,
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useLatestRef } from '@/lib/react';
|
||||
import { loadVisualImage } from '@/lib/visual-runtime';
|
||||
import {
|
||||
type MutableRefObject,
|
||||
type RefObject,
|
||||
@@ -74,22 +75,22 @@ function useImageElement({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const image = new Image();
|
||||
|
||||
setImageElement(null);
|
||||
|
||||
if (typeof crossOrigin !== 'undefined') {
|
||||
image.crossOrigin = crossOrigin;
|
||||
}
|
||||
void loadVisualImage(imageUrl, {
|
||||
crossOrigin,
|
||||
label: 'halftone image',
|
||||
})
|
||||
.then((image) => {
|
||||
if (!cancelled) {
|
||||
setImageElement(image);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
image.decoding = 'async';
|
||||
image.onload = () => {
|
||||
if (!cancelled) {
|
||||
setImageElement(image);
|
||||
}
|
||||
};
|
||||
image.onerror = () => {
|
||||
if (!cancelled) {
|
||||
const handler = onImageLoadErrorReference.current;
|
||||
const error = createImageLoadError(imageUrl);
|
||||
|
||||
@@ -101,15 +102,10 @@ function useImageElement({
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
image.src = imageUrl;
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
image.src = '';
|
||||
};
|
||||
}, [crossOrigin, imageUrl, onImageLoadErrorReference]);
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
loadImportedGeometryFromUrl,
|
||||
type ImportedGeometryNormalizationOptions,
|
||||
} from './geometry-registry';
|
||||
import { HalftoneCanvas, type HalftoneSnapshotFn } from './halftone-canvas';
|
||||
import {
|
||||
HalftoneCanvas,
|
||||
type HalftoneRenderStrategy,
|
||||
type HalftoneSnapshotFn,
|
||||
} from './halftone-canvas';
|
||||
import type {
|
||||
HalftoneExportPose,
|
||||
HalftoneModelLoader,
|
||||
@@ -31,6 +35,7 @@ type HalftoneModelCanvasProps = {
|
||||
onGeometryLoadError?: (error: Error) => void;
|
||||
onPoseChange?: (pose: HalftoneExportPose) => void;
|
||||
previewDistance: number;
|
||||
renderStrategy?: HalftoneRenderStrategy;
|
||||
settings: HalftoneStudioSettings;
|
||||
snapshotRef?: MutableRefObject<HalftoneSnapshotFn | null>;
|
||||
virtualRenderHeight?: number;
|
||||
@@ -121,6 +126,7 @@ export function HalftoneModelCanvas({
|
||||
onGeometryLoadError,
|
||||
onPoseChange = noopPoseChange,
|
||||
previewDistance,
|
||||
renderStrategy,
|
||||
settings,
|
||||
snapshotRef,
|
||||
virtualRenderHeight,
|
||||
@@ -146,6 +152,7 @@ export function HalftoneModelCanvas({
|
||||
onFirstInteraction={onFirstInteraction}
|
||||
onPoseChange={onPoseChange}
|
||||
previewDistance={previewDistance}
|
||||
renderStrategy={renderStrategy}
|
||||
settings={settings}
|
||||
snapshotRef={snapshotRef}
|
||||
virtualRenderHeight={virtualRenderHeight}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
HalftoneCanvas,
|
||||
type HalftoneImageInteractionSettings,
|
||||
type HalftoneRenderStrategy,
|
||||
type HalftoneSnapshotFn,
|
||||
} from './halftone-canvas';
|
||||
export { HalftoneImageCanvas } from './halftone-image-canvas';
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import { createVisualMountScheduler } from '../visual-mount-scheduler';
|
||||
|
||||
function createManualIdleHost() {
|
||||
let nextHandle = 1;
|
||||
const callbacks = new Map<
|
||||
number,
|
||||
{
|
||||
callback: IdleRequestCallback;
|
||||
options?: IdleRequestOptions;
|
||||
}
|
||||
>();
|
||||
|
||||
return {
|
||||
callbacks,
|
||||
cancelIdleCallback: jest.fn((handle: number) => {
|
||||
callbacks.delete(handle);
|
||||
}),
|
||||
requestIdleCallback: jest.fn(
|
||||
(callback: IdleRequestCallback, options?: IdleRequestOptions) => {
|
||||
const handle = nextHandle;
|
||||
nextHandle += 1;
|
||||
callbacks.set(handle, { callback, options });
|
||||
|
||||
return handle;
|
||||
},
|
||||
),
|
||||
runIdle: (handle: number) => {
|
||||
const task = callbacks.get(handle);
|
||||
callbacks.delete(handle);
|
||||
task?.callback({
|
||||
didTimeout: false,
|
||||
timeRemaining: () => 50,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('createVisualMountScheduler', () => {
|
||||
it('runs scheduled visual mounts during idle time', () => {
|
||||
const host = createManualIdleHost();
|
||||
const scheduler = createVisualMountScheduler(host);
|
||||
const callback = jest.fn();
|
||||
|
||||
scheduler.schedule(callback);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
expect(host.requestIdleCallback).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
{ timeout: 80 },
|
||||
);
|
||||
|
||||
host.runIdle(1);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels pending visual mounts before they run', () => {
|
||||
const host = createManualIdleHost();
|
||||
const scheduler = createVisualMountScheduler(host);
|
||||
const callback = jest.fn();
|
||||
|
||||
const cancel = scheduler.schedule(callback);
|
||||
cancel();
|
||||
|
||||
host.runIdle(1);
|
||||
|
||||
expect(host.cancelIdleCallback).toHaveBeenCalledWith(1);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('processes one visual mount per idle slot', () => {
|
||||
const host = createManualIdleHost();
|
||||
const scheduler = createVisualMountScheduler(host);
|
||||
const firstCallback = jest.fn();
|
||||
const secondCallback = jest.fn();
|
||||
|
||||
scheduler.schedule(firstCallback);
|
||||
scheduler.schedule(secondCallback);
|
||||
|
||||
expect(host.requestIdleCallback).toHaveBeenCalledTimes(1);
|
||||
|
||||
host.runIdle(1);
|
||||
|
||||
expect(firstCallback).toHaveBeenCalledTimes(1);
|
||||
expect(secondCallback).not.toHaveBeenCalled();
|
||||
expect(host.requestIdleCallback).toHaveBeenCalledTimes(2);
|
||||
|
||||
host.runIdle(2);
|
||||
|
||||
expect(secondCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('moves priority visual mounts ahead of normal visual mounts', () => {
|
||||
const host = createManualIdleHost();
|
||||
const scheduler = createVisualMountScheduler(host);
|
||||
const calls: string[] = [];
|
||||
|
||||
scheduler.schedule(() => calls.push('normal'));
|
||||
scheduler.schedule(() => calls.push('priority'), { priority: 'priority' });
|
||||
|
||||
expect(host.cancelIdleCallback).toHaveBeenCalledWith(1);
|
||||
expect(host.requestIdleCallback).toHaveBeenLastCalledWith(
|
||||
expect.any(Function),
|
||||
{ timeout: 0 },
|
||||
);
|
||||
|
||||
host.runIdle(2);
|
||||
host.runIdle(3);
|
||||
|
||||
expect(calls).toEqual(['priority', 'normal']);
|
||||
});
|
||||
});
|
||||
+21
@@ -125,6 +125,27 @@ describe('createVisualRenderLoop', () => {
|
||||
expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not reschedule when stopped during a frame callback', () => {
|
||||
const scheduler = createAnimationFrameScheduler();
|
||||
let loop: ReturnType<typeof createVisualRenderLoop>;
|
||||
const renderFrame = jest.fn(() => {
|
||||
loop.stop();
|
||||
});
|
||||
loop = createVisualRenderLoop({
|
||||
cancelAnimationFrame: scheduler.cancelAnimationFrame,
|
||||
document: null,
|
||||
renderFrame,
|
||||
requestAnimationFrame: scheduler.requestAnimationFrame,
|
||||
});
|
||||
|
||||
loop.start();
|
||||
scheduler.runFrame(1, 16);
|
||||
|
||||
expect(renderFrame).toHaveBeenCalledTimes(1);
|
||||
expect(loop.isRunning()).toBe(false);
|
||||
expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('pauses and resumes with document visibility', () => {
|
||||
const scheduler = createAnimationFrameScheduler();
|
||||
const { documentStub, setHidden } = createDocumentVisibilityStub();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const DEFAULT_MAX_ACTIVE_WEBGL_CONTEXTS = 8;
|
||||
const DEFAULT_MAX_ACTIVE_WEBGL_CONTEXTS = 6;
|
||||
|
||||
function readNumberEnv(value: string | undefined, fallback: number): number {
|
||||
if (value === undefined) {
|
||||
|
||||
@@ -4,6 +4,8 @@ export type SiteWebGlRendererParameters = THREE.WebGLRendererParameters & {
|
||||
onContextLost?: (event: WebGLContextEvent) => void;
|
||||
};
|
||||
|
||||
export const SITE_WEBGL_CONTEXT_LOST_EVENT = 'sitewebglcontextlost';
|
||||
|
||||
export function createSiteWebGlRenderer(
|
||||
parameters?: SiteWebGlRendererParameters,
|
||||
): THREE.WebGLRenderer {
|
||||
@@ -42,6 +44,10 @@ export function createSiteWebGlRenderer(
|
||||
}
|
||||
}
|
||||
|
||||
canvas.dispatchEvent(
|
||||
new CustomEvent(SITE_WEBGL_CONTEXT_LOST_EVENT, { bubbles: true }),
|
||||
);
|
||||
|
||||
safeDispose();
|
||||
};
|
||||
|
||||
|
||||
@@ -18,9 +18,19 @@ export {
|
||||
export { useWebGlPolicy } from './use-webgl-policy';
|
||||
export { WebGlErrorBoundary } from './webgl-error-boundary';
|
||||
export { WebGlMount } from './webgl-mount';
|
||||
export { loadVisualImage } from './load-visual-image';
|
||||
export {
|
||||
createVisualMountScheduler,
|
||||
scheduleVisualMount,
|
||||
visualMountScheduler,
|
||||
type ScheduleVisualMountOptions,
|
||||
type VisualMountPriority,
|
||||
type VisualMountScheduler,
|
||||
} from './visual-mount-scheduler';
|
||||
export {
|
||||
createSiteWebGlRenderer,
|
||||
reportSiteWebGlRendererCreationFailure,
|
||||
SITE_WEBGL_CONTEXT_LOST_EVENT,
|
||||
tryCreateSiteWebGlRenderer,
|
||||
type SiteWebGlRendererCreationFailureHandler,
|
||||
type SiteWebGlRendererParameters,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
type LoadVisualImageOptions = {
|
||||
crossOrigin?: HTMLImageElement['crossOrigin'];
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const visualImageCache = new Map<string, Promise<HTMLImageElement>>();
|
||||
|
||||
function getVisualImageCacheKey(
|
||||
imageUrl: string,
|
||||
crossOrigin: HTMLImageElement['crossOrigin'] | undefined,
|
||||
) {
|
||||
return JSON.stringify({ crossOrigin: crossOrigin ?? null, imageUrl });
|
||||
}
|
||||
|
||||
function createVisualImageLoadError(imageUrl: string, label: string) {
|
||||
return new Error(`Failed to load ${label}: ${imageUrl}`);
|
||||
}
|
||||
|
||||
async function settleImageDecode(image: HTMLImageElement) {
|
||||
if (typeof image.decode !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await image.decode();
|
||||
} catch {
|
||||
// Some engines reject decode() after a successful load for progressive or
|
||||
// cached images. The loaded element is still usable as a WebGL texture.
|
||||
}
|
||||
}
|
||||
|
||||
export function loadVisualImage(
|
||||
imageUrl: string,
|
||||
{ crossOrigin, label = 'visual image' }: LoadVisualImageOptions = {},
|
||||
) {
|
||||
const cacheKey = getVisualImageCacheKey(imageUrl, crossOrigin);
|
||||
const cachedImage = visualImageCache.get(cacheKey);
|
||||
|
||||
if (cachedImage) {
|
||||
return cachedImage;
|
||||
}
|
||||
|
||||
const imagePromise = new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
|
||||
image.decoding = 'async';
|
||||
|
||||
if (typeof crossOrigin !== 'undefined') {
|
||||
image.crossOrigin = crossOrigin;
|
||||
}
|
||||
|
||||
image.onload = () => {
|
||||
void settleImageDecode(image).then(() => resolve(image));
|
||||
};
|
||||
image.onerror = () => {
|
||||
reject(createVisualImageLoadError(imageUrl, label));
|
||||
};
|
||||
image.src = imageUrl;
|
||||
}).catch((error: unknown) => {
|
||||
visualImageCache.delete(cacheKey);
|
||||
throw error;
|
||||
});
|
||||
|
||||
visualImageCache.set(cacheKey, imagePromise);
|
||||
|
||||
return imagePromise;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
export type VisualMountPriority = 'normal' | 'priority';
|
||||
|
||||
type TimeoutHandle = ReturnType<typeof setTimeout>;
|
||||
|
||||
type VisualMountSchedulerHost = {
|
||||
cancelAnimationFrame?: (handle: number) => void;
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
clearTimeout?: (handle: TimeoutHandle) => void;
|
||||
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
|
||||
requestIdleCallback?: (
|
||||
callback: IdleRequestCallback,
|
||||
options?: IdleRequestOptions,
|
||||
) => number;
|
||||
setTimeout?: (callback: () => void, delayMs: number) => TimeoutHandle;
|
||||
};
|
||||
|
||||
export type ScheduleVisualMountOptions = {
|
||||
priority?: VisualMountPriority;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type VisualMountScheduler = {
|
||||
schedule: (
|
||||
callback: () => void,
|
||||
options?: ScheduleVisualMountOptions,
|
||||
) => () => void;
|
||||
};
|
||||
|
||||
type VisualMountJob = {
|
||||
callback: () => void;
|
||||
cancelled: boolean;
|
||||
priority: VisualMountPriority;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
const NORMAL_MOUNT_TIMEOUT_MS = 80;
|
||||
const PRIORITY_MOUNT_TIMEOUT_MS = 0;
|
||||
|
||||
function getDefaultHost(): VisualMountSchedulerHost {
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
|
||||
cancelIdleCallback: window.cancelIdleCallback?.bind(window),
|
||||
clearTimeout: window.clearTimeout.bind(window),
|
||||
requestAnimationFrame: window.requestAnimationFrame.bind(window),
|
||||
requestIdleCallback: window.requestIdleCallback?.bind(window),
|
||||
setTimeout: window.setTimeout.bind(window),
|
||||
};
|
||||
}
|
||||
|
||||
function getJobTimeoutMs({
|
||||
priority,
|
||||
timeoutMs,
|
||||
}: {
|
||||
priority: VisualMountPriority;
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
if (typeof timeoutMs === 'number') {
|
||||
return timeoutMs;
|
||||
}
|
||||
|
||||
return priority === 'priority'
|
||||
? PRIORITY_MOUNT_TIMEOUT_MS
|
||||
: NORMAL_MOUNT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function insertJobByPriority(queue: VisualMountJob[], job: VisualMountJob) {
|
||||
if (job.priority === 'normal') {
|
||||
queue.push(job);
|
||||
return;
|
||||
}
|
||||
|
||||
const firstNormalIndex = queue.findIndex(
|
||||
(queuedJob) => queuedJob.priority === 'normal',
|
||||
);
|
||||
|
||||
if (firstNormalIndex === -1) {
|
||||
queue.push(job);
|
||||
return;
|
||||
}
|
||||
|
||||
queue.splice(firstNormalIndex, 0, job);
|
||||
}
|
||||
|
||||
function removeJob(queue: VisualMountJob[], job: VisualMountJob) {
|
||||
const index = queue.indexOf(job);
|
||||
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
queue.splice(index, 1);
|
||||
}
|
||||
|
||||
export function createVisualMountScheduler(
|
||||
host: VisualMountSchedulerHost = getDefaultHost(),
|
||||
): VisualMountScheduler {
|
||||
const {
|
||||
cancelAnimationFrame,
|
||||
cancelIdleCallback,
|
||||
clearTimeout = globalThis.clearTimeout,
|
||||
requestAnimationFrame,
|
||||
requestIdleCallback,
|
||||
setTimeout = globalThis.setTimeout,
|
||||
} = host;
|
||||
|
||||
const queue: VisualMountJob[] = [];
|
||||
let cancelScheduledDrain: (() => void) | null = null;
|
||||
|
||||
const cancelDrainIfIdle = () => {
|
||||
if (queue.length > 0 || cancelScheduledDrain === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelScheduledDrain();
|
||||
cancelScheduledDrain = null;
|
||||
};
|
||||
|
||||
const scheduleDrain = () => {
|
||||
if (cancelScheduledDrain !== null || queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextJob = queue[0];
|
||||
const runNextJob = () => {
|
||||
cancelScheduledDrain = null;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const job = queue.shift();
|
||||
|
||||
if (!job || job.cancelled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
job.callback();
|
||||
break;
|
||||
}
|
||||
|
||||
scheduleDrain();
|
||||
};
|
||||
|
||||
if (requestIdleCallback && cancelIdleCallback) {
|
||||
const idleHandle = requestIdleCallback(runNextJob, {
|
||||
timeout: nextJob.timeoutMs,
|
||||
});
|
||||
cancelScheduledDrain = () => cancelIdleCallback(idleHandle);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestAnimationFrame && cancelAnimationFrame) {
|
||||
let timeoutHandle: TimeoutHandle | null = null;
|
||||
const animationFrameHandle = requestAnimationFrame(() => {
|
||||
timeoutHandle = setTimeout(runNextJob, 0);
|
||||
});
|
||||
|
||||
cancelScheduledDrain = () => {
|
||||
cancelAnimationFrame(animationFrameHandle);
|
||||
|
||||
if (timeoutHandle !== null) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutHandle = setTimeout(runNextJob, nextJob.timeoutMs);
|
||||
cancelScheduledDrain = () => clearTimeout(timeoutHandle);
|
||||
};
|
||||
|
||||
return {
|
||||
schedule: (callback, options = {}) => {
|
||||
const priority = options.priority ?? 'normal';
|
||||
const job: VisualMountJob = {
|
||||
callback,
|
||||
cancelled: false,
|
||||
priority,
|
||||
timeoutMs: getJobTimeoutMs({
|
||||
priority,
|
||||
timeoutMs: options.timeoutMs,
|
||||
}),
|
||||
};
|
||||
|
||||
insertJobByPriority(queue, job);
|
||||
|
||||
if (priority === 'priority' && cancelScheduledDrain !== null) {
|
||||
cancelScheduledDrain();
|
||||
cancelScheduledDrain = null;
|
||||
}
|
||||
|
||||
scheduleDrain();
|
||||
|
||||
return () => {
|
||||
if (job.cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
job.cancelled = true;
|
||||
removeJob(queue, job);
|
||||
cancelDrainIfIdle();
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const visualMountScheduler = createVisualMountScheduler();
|
||||
|
||||
export const scheduleVisualMount = visualMountScheduler.schedule;
|
||||
@@ -9,12 +9,20 @@ import {
|
||||
subscribeToActiveWebGlContextCount,
|
||||
tryReserveWebGlContextSlot,
|
||||
} from './active-webgl-context-budget';
|
||||
import { SITE_WEBGL_CONTEXT_LOST_EVENT } from './create-site-webgl-renderer';
|
||||
import {
|
||||
scheduleVisualMount,
|
||||
type VisualMountPriority,
|
||||
} from './visual-mount-scheduler';
|
||||
import { useWebGlPolicy } from './use-webgl-policy';
|
||||
import { WebGlErrorBoundary } from './webgl-error-boundary';
|
||||
|
||||
const NON_PRIORITY_ROOT_MARGIN = '50% 0px 50% 0px';
|
||||
const PRIORITY_ROOT_MARGIN = '125% 0px 125% 0px';
|
||||
const EAGER_ROOT_MARGIN = '600% 0px 600% 0px';
|
||||
|
||||
const OUT_OF_VIEW_DISPOSE_MS = 4_000;
|
||||
const PRIORITY_OUT_OF_VIEW_DISPOSE_MS = 1_500;
|
||||
|
||||
const ObserverRoot = styled.div<{ detachFromLayout: boolean }>`
|
||||
height: 100%;
|
||||
@@ -40,10 +48,13 @@ const ObserverRoot = styled.div<{ detachFromLayout: boolean }>`
|
||||
`}
|
||||
`;
|
||||
|
||||
type WebGlMountLoading = 'lazy' | 'eager';
|
||||
|
||||
type WebGlMountProps = {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
detachFromLayout?: boolean;
|
||||
loading?: WebGlMountLoading;
|
||||
priority?: boolean;
|
||||
};
|
||||
|
||||
@@ -51,6 +62,7 @@ export function WebGlMount({
|
||||
children,
|
||||
fallback,
|
||||
detachFromLayout = false,
|
||||
loading = 'lazy',
|
||||
priority = false,
|
||||
}: WebGlMountProps) {
|
||||
const policy = useWebGlPolicy();
|
||||
@@ -58,18 +70,28 @@ export function WebGlMount({
|
||||
|
||||
const [isInViewport, setIsInViewport] = useState(priority);
|
||||
|
||||
const [isMountReady, setIsMountReady] = useState(false);
|
||||
|
||||
const [hasContextSlot, setHasContextSlot] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (priority) {
|
||||
return;
|
||||
}
|
||||
const [contextEpoch, setContextEpoch] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const element = rootReference.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isEager = loading === 'eager';
|
||||
const effectiveDisposeDelayMs =
|
||||
priority || isEager
|
||||
? PRIORITY_OUT_OF_VIEW_DISPOSE_MS
|
||||
: OUT_OF_VIEW_DISPOSE_MS;
|
||||
const effectiveRootMargin = isEager
|
||||
? EAGER_ROOT_MARGIN
|
||||
: priority
|
||||
? PRIORITY_ROOT_MARGIN
|
||||
: NON_PRIORITY_ROOT_MARGIN;
|
||||
let disposeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const clearDisposeTimer = () => {
|
||||
if (disposeTimer !== null) {
|
||||
@@ -91,24 +113,65 @@ export function WebGlMount({
|
||||
disposeTimer = setTimeout(() => {
|
||||
setIsInViewport(false);
|
||||
disposeTimer = null;
|
||||
}, OUT_OF_VIEW_DISPOSE_MS);
|
||||
}, effectiveDisposeDelayMs);
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: effectiveRootMargin,
|
||||
threshold: 0,
|
||||
},
|
||||
{ root: null, rootMargin: NON_PRIORITY_ROOT_MARGIN, threshold: 0 },
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearDisposeTimer();
|
||||
stopObservingVisibility();
|
||||
};
|
||||
}, [priority]);
|
||||
|
||||
const wantsScene = policy.allowed && isInViewport;
|
||||
}, [loading, priority]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = rootReference.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleContextLost = () => {
|
||||
setHasContextSlot(false);
|
||||
setIsMountReady(false);
|
||||
setContextEpoch((epoch) => epoch + 1);
|
||||
};
|
||||
|
||||
element.addEventListener(SITE_WEBGL_CONTEXT_LOST_EVENT, handleContextLost);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener(
|
||||
SITE_WEBGL_CONTEXT_LOST_EVENT,
|
||||
handleContextLost,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const wantsScene = policy.allowed && isInViewport;
|
||||
const wantsContextSlot = wantsScene && isMountReady;
|
||||
const effectiveMountPriority: VisualMountPriority =
|
||||
priority || loading === 'eager' ? 'priority' : 'normal';
|
||||
|
||||
useEffect(() => {
|
||||
setIsMountReady(false);
|
||||
|
||||
if (!wantsScene) {
|
||||
return;
|
||||
}
|
||||
|
||||
return scheduleVisualMount(() => setIsMountReady(true), {
|
||||
priority: effectiveMountPriority,
|
||||
});
|
||||
}, [contextEpoch, effectiveMountPriority, wantsScene]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wantsContextSlot) {
|
||||
return;
|
||||
}
|
||||
|
||||
let release: (() => void) | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
@@ -145,14 +208,16 @@ export function WebGlMount({
|
||||
}
|
||||
setHasContextSlot(false);
|
||||
};
|
||||
}, [wantsScene]);
|
||||
}, [wantsContextSlot]);
|
||||
|
||||
const renderInner = wantsScene && hasContextSlot;
|
||||
const renderInner = wantsContextSlot && hasContextSlot;
|
||||
|
||||
return (
|
||||
<ObserverRoot ref={rootReference} detachFromLayout={detachFromLayout}>
|
||||
{renderInner ? (
|
||||
<WebGlErrorBoundary fallback={fallback}>{children}</WebGlErrorBoundary>
|
||||
<WebGlErrorBoundary key={contextEpoch} fallback={fallback}>
|
||||
{children}
|
||||
</WebGlErrorBoundary>
|
||||
) : (
|
||||
(fallback ?? null)
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
|
||||
import { loadImportedGeometryFromUrl } from '@/lib/halftone';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import { createAnimationFrameLoop } from '@/lib/animation';
|
||||
import { observeElementSize } from '@/lib/dom/observe-element-size';
|
||||
import {
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
type VisualRenderLoop,
|
||||
type VisualRenderLoopFrame,
|
||||
} from '@/lib/visual-runtime';
|
||||
import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path';
|
||||
|
||||
const VIRTUAL_RENDER_HEIGHT = 768;
|
||||
|
||||
@@ -283,9 +281,6 @@ const INITIAL_TIME_ELAPSED = 249.21849999998116;
|
||||
const BASE_CAMERA_DISTANCE = 4;
|
||||
const MODEL_OFFSET_Y = 0.52;
|
||||
|
||||
const EMPTY_TEXTURE_DATA_URL =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII=';
|
||||
|
||||
function createEnvironmentTexture(renderer: THREE.WebGLRenderer) {
|
||||
const pmremGenerator = new THREE.PMREMGenerator(renderer);
|
||||
const environmentTexture = pmremGenerator.fromScene(
|
||||
@@ -305,121 +300,6 @@ function createRenderTarget(width: number, height: number) {
|
||||
});
|
||||
}
|
||||
|
||||
function disposeMaterial(material: THREE.Material | THREE.Material[]) {
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((item) => item.dispose());
|
||||
return;
|
||||
}
|
||||
|
||||
material.dispose();
|
||||
}
|
||||
|
||||
function disposeObjectSubtree(root: THREE.Object3D) {
|
||||
root.traverse((sceneObject) => {
|
||||
if (!(sceneObject instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sceneObject.geometry?.dispose();
|
||||
disposeMaterial(sceneObject.material);
|
||||
});
|
||||
}
|
||||
|
||||
function mergeGeometries(geometries: THREE.BufferGeometry[]) {
|
||||
if (geometries.length === 1) {
|
||||
return geometries[0];
|
||||
}
|
||||
|
||||
let totalVertices = 0;
|
||||
let totalIndices = 0;
|
||||
let hasUv = false;
|
||||
|
||||
const geometryInfos = geometries.map((geometry) => {
|
||||
const position = geometry.attributes.position;
|
||||
const normal = geometry.attributes.normal;
|
||||
const uv = geometry.attributes.uv ?? null;
|
||||
const index = geometry.index;
|
||||
const indexCount = index ? index.count : position.count;
|
||||
|
||||
totalVertices += position.count;
|
||||
totalIndices += indexCount;
|
||||
hasUv = hasUv || uv !== null;
|
||||
|
||||
return {
|
||||
index,
|
||||
indexCount,
|
||||
normal,
|
||||
position,
|
||||
uv,
|
||||
vertexCount: position.count,
|
||||
};
|
||||
});
|
||||
|
||||
const positions = new Float32Array(totalVertices * 3);
|
||||
const normals = new Float32Array(totalVertices * 3);
|
||||
const uvs = hasUv ? new Float32Array(totalVertices * 2) : null;
|
||||
const indices = new Uint32Array(totalIndices);
|
||||
|
||||
let vertexOffset = 0;
|
||||
let indexOffset = 0;
|
||||
|
||||
for (const geometryInfo of geometryInfos) {
|
||||
for (
|
||||
let vertexIndex = 0;
|
||||
vertexIndex < geometryInfo.vertexCount;
|
||||
vertexIndex += 1
|
||||
) {
|
||||
const positionOffset = (vertexOffset + vertexIndex) * 3;
|
||||
positions[positionOffset] = geometryInfo.position.getX(vertexIndex);
|
||||
positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex);
|
||||
positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex);
|
||||
normals[positionOffset] = geometryInfo.normal.getX(vertexIndex);
|
||||
normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex);
|
||||
normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex);
|
||||
|
||||
if (uvs !== null) {
|
||||
const uvOffset = (vertexOffset + vertexIndex) * 2;
|
||||
uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0;
|
||||
uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (geometryInfo.index) {
|
||||
for (
|
||||
let localIndex = 0;
|
||||
localIndex < geometryInfo.indexCount;
|
||||
localIndex += 1
|
||||
) {
|
||||
indices[indexOffset + localIndex] =
|
||||
geometryInfo.index.getX(localIndex) + vertexOffset;
|
||||
}
|
||||
} else {
|
||||
for (
|
||||
let localIndex = 0;
|
||||
localIndex < geometryInfo.indexCount;
|
||||
localIndex += 1
|
||||
) {
|
||||
indices[indexOffset + localIndex] = localIndex + vertexOffset;
|
||||
}
|
||||
}
|
||||
|
||||
vertexOffset += geometryInfo.vertexCount;
|
||||
indexOffset += geometryInfo.indexCount;
|
||||
}
|
||||
|
||||
const merged = new THREE.BufferGeometry();
|
||||
merged.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
||||
|
||||
if (uvs !== null) {
|
||||
merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||||
}
|
||||
|
||||
merged.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function setPrimaryLightPosition(
|
||||
light: THREE.DirectionalLight,
|
||||
angleDegrees: number,
|
||||
@@ -433,120 +313,6 @@ function setPrimaryLightPosition(
|
||||
);
|
||||
}
|
||||
|
||||
function createLoadingManager() {
|
||||
const loadingManager = new THREE.LoadingManager();
|
||||
loadingManager.setURLModifier((url) =>
|
||||
/\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url,
|
||||
);
|
||||
|
||||
return loadingManager;
|
||||
}
|
||||
|
||||
function normalizeImportedGeometry(geometry: THREE.BufferGeometry) {
|
||||
geometry.computeBoundingBox();
|
||||
|
||||
let boundingBox = geometry.boundingBox;
|
||||
let center = new THREE.Vector3();
|
||||
let size = new THREE.Vector3();
|
||||
|
||||
boundingBox?.getCenter(center);
|
||||
boundingBox?.getSize(size);
|
||||
geometry.translate(-center.x, -center.y, -center.z);
|
||||
|
||||
const dimensions = [size.x, size.y, size.z];
|
||||
const thinnestAxis = dimensions.indexOf(Math.min(...dimensions));
|
||||
|
||||
if (thinnestAxis === 0) {
|
||||
geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2));
|
||||
} else if (thinnestAxis === 1) {
|
||||
geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
|
||||
}
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
const radius = geometry.boundingSphere?.radius || 1;
|
||||
const scale = 1.6 / radius;
|
||||
geometry.scale(scale, scale, scale);
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
boundingBox = geometry.boundingBox;
|
||||
center = new THREE.Vector3();
|
||||
boundingBox?.getCenter(center);
|
||||
geometry.translate(-center.x, -center.y, -center.z);
|
||||
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) {
|
||||
root.updateMatrixWorld(true);
|
||||
|
||||
const geometries: THREE.BufferGeometry[] = [];
|
||||
|
||||
root.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh) || !object.geometry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geometry = object.geometry.clone();
|
||||
|
||||
if (!geometry.attributes.normal) {
|
||||
geometry.computeVertexNormals();
|
||||
}
|
||||
|
||||
geometry.applyMatrix4(object.matrixWorld);
|
||||
geometries.push(geometry);
|
||||
});
|
||||
|
||||
if (geometries.length === 0) {
|
||||
throw new Error(emptyMessage);
|
||||
}
|
||||
|
||||
return normalizeImportedGeometry(mergeGeometries(geometries));
|
||||
}
|
||||
|
||||
async function loadFaqGeometry(modelUrl: string) {
|
||||
const response = await fetch(modelUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load FAQ model from ${modelUrl}.`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
const dracoLoader = new DRACOLoader();
|
||||
dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
|
||||
|
||||
const gltfLoader = new GLTFLoader(createLoadingManager());
|
||||
gltfLoader.setDRACOLoader(dracoLoader);
|
||||
|
||||
return await new Promise<THREE.BufferGeometry>((resolve, reject) => {
|
||||
gltfLoader.parse(
|
||||
buffer,
|
||||
'',
|
||||
(gltf) => {
|
||||
try {
|
||||
resolve(
|
||||
extractMergedGeometry(
|
||||
gltf.scene,
|
||||
'FAQ model did not contain any mesh geometry.',
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
disposeObjectSubtree(gltf.scene);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function createFaqMaterial(environmentTexture: THREE.Texture) {
|
||||
return new THREE.MeshPhysicalMaterial({
|
||||
color: 0xd4d0c8,
|
||||
@@ -827,7 +593,7 @@ export function FaqBackground() {
|
||||
applySize();
|
||||
const stopObservingSize = observeElementSize(container, applySize);
|
||||
|
||||
loadFaqGeometry(GLB_URL)
|
||||
loadImportedGeometryFromUrl('glb', GLB_URL, 'FAQ model')
|
||||
.then((geometry) => {
|
||||
if (cancelled) {
|
||||
geometry.dispose();
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
|
||||
import { loadImportedGeometryFromUrl } from '@/lib/halftone';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import { observeElementSize } from '@/lib/dom/observe-element-size';
|
||||
import {
|
||||
createVisualRenderLoop,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
type VisualRenderLoop,
|
||||
type VisualRenderLoopFrame,
|
||||
} from '@/lib/visual-runtime';
|
||||
import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path';
|
||||
|
||||
const VIRTUAL_RENDER_HEIGHT = 768;
|
||||
|
||||
@@ -287,9 +285,6 @@ const INITIAL_TARGET_ROTATION_Y = 0.4573343542024313;
|
||||
const INITIAL_TIME_ELAPSED = 287.372499999977;
|
||||
const BASE_CAMERA_DISTANCE = 4;
|
||||
|
||||
const EMPTY_TEXTURE_DATA_URL =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII=';
|
||||
|
||||
function createEnvironmentTexture(renderer: THREE.WebGLRenderer) {
|
||||
const pmremGenerator = new THREE.PMREMGenerator(renderer);
|
||||
const environmentTexture = pmremGenerator.fromScene(
|
||||
@@ -341,121 +336,6 @@ function createRenderTarget(width: number, height: number) {
|
||||
});
|
||||
}
|
||||
|
||||
function disposeMaterial(material: THREE.Material | THREE.Material[]) {
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((item) => item.dispose());
|
||||
return;
|
||||
}
|
||||
|
||||
material.dispose();
|
||||
}
|
||||
|
||||
function disposeObjectSubtree(root: THREE.Object3D) {
|
||||
root.traverse((sceneObject) => {
|
||||
if (!(sceneObject instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sceneObject.geometry?.dispose();
|
||||
disposeMaterial(sceneObject.material);
|
||||
});
|
||||
}
|
||||
|
||||
function mergeGeometries(geometries: THREE.BufferGeometry[]) {
|
||||
if (geometries.length === 1) {
|
||||
return geometries[0];
|
||||
}
|
||||
|
||||
let totalVertices = 0;
|
||||
let totalIndices = 0;
|
||||
let hasUv = false;
|
||||
|
||||
const geometryInfos = geometries.map((geometry) => {
|
||||
const position = geometry.attributes.position;
|
||||
const normal = geometry.attributes.normal;
|
||||
const uv = geometry.attributes.uv ?? null;
|
||||
const index = geometry.index;
|
||||
const indexCount = index ? index.count : position.count;
|
||||
|
||||
totalVertices += position.count;
|
||||
totalIndices += indexCount;
|
||||
hasUv = hasUv || uv !== null;
|
||||
|
||||
return {
|
||||
index,
|
||||
indexCount,
|
||||
normal,
|
||||
position,
|
||||
uv,
|
||||
vertexCount: position.count,
|
||||
};
|
||||
});
|
||||
|
||||
const positions = new Float32Array(totalVertices * 3);
|
||||
const normals = new Float32Array(totalVertices * 3);
|
||||
const uvs = hasUv ? new Float32Array(totalVertices * 2) : null;
|
||||
const indices = new Uint32Array(totalIndices);
|
||||
|
||||
let vertexOffset = 0;
|
||||
let indexOffset = 0;
|
||||
|
||||
for (const geometryInfo of geometryInfos) {
|
||||
for (
|
||||
let vertexIndex = 0;
|
||||
vertexIndex < geometryInfo.vertexCount;
|
||||
vertexIndex += 1
|
||||
) {
|
||||
const positionOffset = (vertexOffset + vertexIndex) * 3;
|
||||
positions[positionOffset] = geometryInfo.position.getX(vertexIndex);
|
||||
positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex);
|
||||
positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex);
|
||||
normals[positionOffset] = geometryInfo.normal.getX(vertexIndex);
|
||||
normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex);
|
||||
normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex);
|
||||
|
||||
if (uvs !== null) {
|
||||
const uvOffset = (vertexOffset + vertexIndex) * 2;
|
||||
uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0;
|
||||
uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (geometryInfo.index) {
|
||||
for (
|
||||
let localIndex = 0;
|
||||
localIndex < geometryInfo.indexCount;
|
||||
localIndex += 1
|
||||
) {
|
||||
indices[indexOffset + localIndex] =
|
||||
geometryInfo.index.getX(localIndex) + vertexOffset;
|
||||
}
|
||||
} else {
|
||||
for (
|
||||
let localIndex = 0;
|
||||
localIndex < geometryInfo.indexCount;
|
||||
localIndex += 1
|
||||
) {
|
||||
indices[indexOffset + localIndex] = localIndex + vertexOffset;
|
||||
}
|
||||
}
|
||||
|
||||
vertexOffset += geometryInfo.vertexCount;
|
||||
indexOffset += geometryInfo.indexCount;
|
||||
}
|
||||
|
||||
const merged = new THREE.BufferGeometry();
|
||||
merged.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
||||
|
||||
if (uvs !== null) {
|
||||
merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||||
}
|
||||
|
||||
merged.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function setPrimaryLightPosition(
|
||||
light: THREE.DirectionalLight,
|
||||
angleDegrees: number,
|
||||
@@ -469,120 +349,6 @@ function setPrimaryLightPosition(
|
||||
);
|
||||
}
|
||||
|
||||
function createLoadingManager() {
|
||||
const loadingManager = new THREE.LoadingManager();
|
||||
loadingManager.setURLModifier((url) =>
|
||||
/\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url,
|
||||
);
|
||||
|
||||
return loadingManager;
|
||||
}
|
||||
|
||||
function normalizeImportedGeometry(geometry: THREE.BufferGeometry) {
|
||||
geometry.computeBoundingBox();
|
||||
|
||||
let boundingBox = geometry.boundingBox;
|
||||
let center = new THREE.Vector3();
|
||||
let size = new THREE.Vector3();
|
||||
|
||||
boundingBox?.getCenter(center);
|
||||
boundingBox?.getSize(size);
|
||||
geometry.translate(-center.x, -center.y, -center.z);
|
||||
|
||||
const dimensions = [size.x, size.y, size.z];
|
||||
const thinnestAxis = dimensions.indexOf(Math.min(...dimensions));
|
||||
|
||||
if (thinnestAxis === 0) {
|
||||
geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2));
|
||||
} else if (thinnestAxis === 1) {
|
||||
geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
|
||||
}
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
const radius = geometry.boundingSphere?.radius || 1;
|
||||
const scale = 2 / radius;
|
||||
geometry.scale(scale, scale, scale);
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
boundingBox = geometry.boundingBox;
|
||||
center = new THREE.Vector3();
|
||||
boundingBox?.getCenter(center);
|
||||
geometry.translate(-center.x, -center.y, -center.z);
|
||||
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) {
|
||||
root.updateMatrixWorld(true);
|
||||
|
||||
const geometries: THREE.BufferGeometry[] = [];
|
||||
|
||||
root.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh) || !object.geometry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geometry = object.geometry.clone();
|
||||
|
||||
if (!geometry.attributes.normal) {
|
||||
geometry.computeVertexNormals();
|
||||
}
|
||||
|
||||
geometry.applyMatrix4(object.matrixWorld);
|
||||
geometries.push(geometry);
|
||||
});
|
||||
|
||||
if (geometries.length === 0) {
|
||||
throw new Error(emptyMessage);
|
||||
}
|
||||
|
||||
return normalizeImportedGeometry(mergeGeometries(geometries));
|
||||
}
|
||||
|
||||
async function loadFooterGeometry(modelUrl: string) {
|
||||
const response = await fetch(modelUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load footer model from ${modelUrl}.`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
const dracoLoader = new DRACOLoader();
|
||||
dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
|
||||
|
||||
const gltfLoader = new GLTFLoader(createLoadingManager());
|
||||
gltfLoader.setDRACOLoader(dracoLoader);
|
||||
|
||||
return await new Promise<THREE.BufferGeometry>((resolve, reject) => {
|
||||
gltfLoader.parse(
|
||||
buffer,
|
||||
'',
|
||||
(gltf) => {
|
||||
try {
|
||||
resolve(
|
||||
extractMergedGeometry(
|
||||
gltf.scene,
|
||||
'Footer model did not contain any mesh geometry.',
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
disposeObjectSubtree(gltf.scene);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function createFooterMaterial(environmentTexture: THREE.Texture) {
|
||||
return new THREE.MeshPhysicalMaterial({
|
||||
color: 0xd4d0c8,
|
||||
@@ -892,7 +658,9 @@ export function FooterBackground() {
|
||||
window.addEventListener('pointermove', handleWindowPointerMove);
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
|
||||
loadFooterGeometry(GLB_URL)
|
||||
loadImportedGeometryFromUrl('glb', GLB_URL, 'Footer model', {
|
||||
scaleTarget: 2,
|
||||
})
|
||||
.then((geometry) => {
|
||||
if (cancelled) {
|
||||
geometry.dispose();
|
||||
|
||||
@@ -21,11 +21,6 @@ const CardRoot = styled.article`
|
||||
position: relative;
|
||||
row-gap: ${theme.spacing(2.5)};
|
||||
width: 100%;
|
||||
transition:
|
||||
transform 0.6s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
filter 0.6s ease;
|
||||
transform-style: preserve-3d;
|
||||
perspective: 1200px;
|
||||
`;
|
||||
|
||||
const LogoRow = styled.div`
|
||||
@@ -50,14 +45,6 @@ const VisualShell = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const VisualFallback = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const CopyBlock = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
@@ -81,8 +68,6 @@ const CtaRow = styled.div`
|
||||
`;
|
||||
|
||||
const LOGO_FILL = theme.colors.secondary.text[100];
|
||||
const FALLBACK_LOGO_FILL = theme.colors.secondary.text[80];
|
||||
const FALLBACK_LOGO_WIDTH = 160;
|
||||
|
||||
type CardProps = {
|
||||
card: HeadingCardType;
|
||||
@@ -93,15 +78,6 @@ export function Card({ card }: CardProps) {
|
||||
const Visual = HELPED_VISUALS[card.illustration];
|
||||
const logoWidth = 104;
|
||||
|
||||
const visualFallback = IconComponent ? (
|
||||
<VisualFallback aria-hidden="true">
|
||||
<IconComponent
|
||||
fillColor={FALLBACK_LOGO_FILL}
|
||||
size={FALLBACK_LOGO_WIDTH}
|
||||
/>
|
||||
</VisualFallback>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<CardRoot>
|
||||
<HelpedCardShape
|
||||
@@ -115,7 +91,7 @@ export function Card({ card }: CardProps) {
|
||||
</LogoRow>
|
||||
<Rule aria-hidden="true" />
|
||||
<VisualShell>
|
||||
<WebGlMount fallback={visualFallback}>
|
||||
<WebGlMount loading="eager">
|
||||
<Visual />
|
||||
</WebGlMount>
|
||||
</VisualShell>
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import { Eyebrow, GuideCrosshair, Heading } from '@/design-system/components';
|
||||
import { HelpedSceneScrollLayoutEffect } from '@/sections/Helped/effect-components/HelpedSceneScrollLayoutEffect';
|
||||
import type { HelpedDataType } from '@/sections/Helped/types/HelpedData';
|
||||
import { preloadHelpedVisualGeometries } from '@/sections/Helped/visuals/helped-visual-models';
|
||||
import { theme } from '@/theme';
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useRef } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Card } from './Card';
|
||||
|
||||
const GUIDE_INTERSECTION_TOP = '176px';
|
||||
@@ -80,8 +81,10 @@ const CardsLayer = styled.div`
|
||||
`;
|
||||
|
||||
const CardPositioner = styled.div`
|
||||
left: 0;
|
||||
position: absolute;
|
||||
will-change: top, opacity;
|
||||
top: 0;
|
||||
will-change: transform, opacity;
|
||||
`;
|
||||
|
||||
type SceneProps = {
|
||||
@@ -93,6 +96,10 @@ export function Scene({ data }: SceneProps) {
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void preloadHelpedVisualGeometries();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollStage
|
||||
aria-label="Customer stories"
|
||||
|
||||
+8
-2
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { useScheduledOnScroll } from '@/lib/scroll';
|
||||
import type { HeadingCardType } from '@/sections/Helped/types/HeadingCard';
|
||||
import {
|
||||
applyHelpedSceneLayout,
|
||||
createHelpedSceneLayoutState,
|
||||
type HelpedSceneLayoutRefs,
|
||||
} from '@/sections/Helped/utils/helped-scene-layout';
|
||||
|
||||
@@ -19,8 +20,13 @@ export function HelpedSceneScrollLayoutEffect({
|
||||
innerRef,
|
||||
sectionRef,
|
||||
}: HelpedSceneScrollLayoutEffectProps) {
|
||||
const layoutStateRef = useRef(createHelpedSceneLayoutState());
|
||||
const runLayout = useCallback(() => {
|
||||
applyHelpedSceneLayout({ cardRefs, innerRef, sectionRef }, cards);
|
||||
applyHelpedSceneLayout(
|
||||
{ cardRefs, innerRef, sectionRef },
|
||||
cards,
|
||||
layoutStateRef.current,
|
||||
);
|
||||
}, [cardRefs, cards, innerRef, sectionRef]);
|
||||
|
||||
useScheduledOnScroll(runLayout);
|
||||
|
||||
@@ -13,6 +13,31 @@ const CARD_TRAVEL_START = 0.05;
|
||||
const CARD_TRAVEL_STEP = 0.3;
|
||||
const POST_STICKY_PARALLAX_DISTANCE = 0.7;
|
||||
|
||||
type HelpedSceneLayoutMeasurements = {
|
||||
cardCount: number;
|
||||
cardWidth: number;
|
||||
innerHeight: number;
|
||||
innerWidth: number;
|
||||
isDesktop: boolean;
|
||||
progressMetrics: HelpedSceneProgressMetrics | null;
|
||||
progressScale: number;
|
||||
sectionHeight: number;
|
||||
viewportHeight: number;
|
||||
};
|
||||
|
||||
type HelpedSceneProgressMetrics = {
|
||||
exitTargetTop: number;
|
||||
lastCardHeight: number;
|
||||
};
|
||||
|
||||
export type HelpedSceneLayoutState = {
|
||||
measurements: HelpedSceneLayoutMeasurements | null;
|
||||
};
|
||||
|
||||
export function createHelpedSceneLayoutState(): HelpedSceneLayoutState {
|
||||
return { measurements: null };
|
||||
}
|
||||
|
||||
function clamp01(value: number) {
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
@@ -23,6 +48,18 @@ function easeOutQuad(value: number) {
|
||||
return 1 - (1 - clampedValue) * (1 - clampedValue);
|
||||
}
|
||||
|
||||
function setStyleProperty(
|
||||
node: HTMLElement,
|
||||
property: 'opacity' | 'transform' | 'width' | 'zIndex',
|
||||
value: string,
|
||||
) {
|
||||
if (node.style[property] === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
node.style[property] = value;
|
||||
}
|
||||
|
||||
function cardLeft(
|
||||
index: number,
|
||||
innerWidth: number,
|
||||
@@ -47,35 +84,140 @@ export type HelpedSceneLayoutRefs = {
|
||||
sectionRef: RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
function getProgressScale(
|
||||
function toStableLayoutMetric(value: number) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function readProgressScaleMetrics(
|
||||
refs: HelpedSceneLayoutRefs,
|
||||
cards: readonly HeadingCardType[],
|
||||
cardRefs: RefObject<(HTMLDivElement | null)[]>,
|
||||
inner: HTMLDivElement,
|
||||
innerHeight: number,
|
||||
) {
|
||||
): HelpedSceneProgressMetrics | null {
|
||||
const exitTargetNode = inner.querySelector('[data-helped-exit-target]');
|
||||
const lastCardNode = cardRefs.current[cards.length - 1];
|
||||
const lastCardNode = refs.cardRefs.current[cards.length - 1];
|
||||
|
||||
if (!(exitTargetNode instanceof HTMLElement) || !lastCardNode) {
|
||||
return 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
const exitTargetTop =
|
||||
exitTargetNode.getBoundingClientRect().top -
|
||||
inner.getBoundingClientRect().top;
|
||||
const lastCardHeight = lastCardNode.offsetHeight;
|
||||
|
||||
return {
|
||||
exitTargetTop: toStableLayoutMetric(exitTargetTop),
|
||||
lastCardHeight: lastCardNode.offsetHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function getProgressScale(
|
||||
cards: readonly HeadingCardType[],
|
||||
innerHeight: number,
|
||||
progressMetrics: HelpedSceneProgressMetrics | null,
|
||||
) {
|
||||
if (progressMetrics === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const lastCardStart =
|
||||
CARD_TRAVEL_START + (cards.length - 1) * CARD_TRAVEL_STEP;
|
||||
const requiredTravel = clamp01(
|
||||
(innerHeight * 1.1 + lastCardHeight - exitTargetTop) / (innerHeight * 1.4),
|
||||
(innerHeight * 1.1 +
|
||||
progressMetrics.lastCardHeight -
|
||||
progressMetrics.exitTargetTop) /
|
||||
(innerHeight * 1.4),
|
||||
);
|
||||
|
||||
return lastCardStart + requiredTravel * CARD_TRAVEL_RANGE;
|
||||
}
|
||||
|
||||
function areProgressMetricsEqual(
|
||||
left: HelpedSceneProgressMetrics | null,
|
||||
right: HelpedSceneProgressMetrics | null,
|
||||
) {
|
||||
if (left === null || right === null) {
|
||||
return left === right;
|
||||
}
|
||||
|
||||
return (
|
||||
left.exitTargetTop === right.exitTargetTop &&
|
||||
left.lastCardHeight === right.lastCardHeight
|
||||
);
|
||||
}
|
||||
|
||||
function getCardWidth(innerWidth: number, isDesktop: boolean) {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
isDesktop ? CARD_WIDTH_DESKTOP : CARD_WIDTH_MOBILE,
|
||||
innerWidth - 64,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRecomputeMeasurements(
|
||||
measurements: HelpedSceneLayoutMeasurements,
|
||||
nextMeasurements: Omit<
|
||||
HelpedSceneLayoutMeasurements,
|
||||
'cardWidth' | 'progressScale'
|
||||
>,
|
||||
) {
|
||||
return (
|
||||
measurements.cardCount !== nextMeasurements.cardCount ||
|
||||
measurements.innerHeight !== nextMeasurements.innerHeight ||
|
||||
measurements.innerWidth !== nextMeasurements.innerWidth ||
|
||||
measurements.isDesktop !== nextMeasurements.isDesktop ||
|
||||
!areProgressMetricsEqual(
|
||||
measurements.progressMetrics,
|
||||
nextMeasurements.progressMetrics,
|
||||
) ||
|
||||
measurements.sectionHeight !== nextMeasurements.sectionHeight ||
|
||||
measurements.viewportHeight !== nextMeasurements.viewportHeight
|
||||
);
|
||||
}
|
||||
|
||||
function measureHelpedSceneLayout(
|
||||
refs: HelpedSceneLayoutRefs,
|
||||
cards: readonly HeadingCardType[],
|
||||
inner: HTMLDivElement,
|
||||
nextMeasurements: Omit<
|
||||
HelpedSceneLayoutMeasurements,
|
||||
'cardWidth' | 'progressScale'
|
||||
>,
|
||||
): HelpedSceneLayoutMeasurements {
|
||||
const cardWidth = getCardWidth(
|
||||
nextMeasurements.innerWidth,
|
||||
nextMeasurements.isDesktop,
|
||||
);
|
||||
|
||||
cards.forEach((_, index) => {
|
||||
const node = refs.cardRefs.current[index];
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStyleProperty(node, 'width', `${cardWidth}px`);
|
||||
setStyleProperty(node, 'zIndex', String(10 + index));
|
||||
});
|
||||
|
||||
const progressMetrics = readProgressScaleMetrics(refs, cards, inner);
|
||||
|
||||
return {
|
||||
...nextMeasurements,
|
||||
cardWidth,
|
||||
progressMetrics,
|
||||
progressScale: getProgressScale(
|
||||
cards,
|
||||
nextMeasurements.innerHeight,
|
||||
progressMetrics,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyHelpedSceneLayout(
|
||||
refs: HelpedSceneLayoutRefs,
|
||||
cards: readonly HeadingCardType[],
|
||||
state: HelpedSceneLayoutState = createHelpedSceneLayoutState(),
|
||||
): void {
|
||||
const section = refs.sectionRef.current;
|
||||
const inner = refs.innerRef.current;
|
||||
@@ -86,43 +228,43 @@ export function applyHelpedSceneLayout(
|
||||
const reducedMotion = getPrefersReducedMotionSnapshot();
|
||||
const isDesktop = getStepperMdUpSnapshot();
|
||||
const sectionRect = section.getBoundingClientRect();
|
||||
|
||||
const scrollRange = Math.max(1, section.offsetHeight - window.innerHeight);
|
||||
const viewportHeight = window.innerHeight;
|
||||
const preStickyRevealOffset =
|
||||
window.innerHeight * PRE_STICKY_REVEAL_VIEWPORT_FRACTION;
|
||||
const innerWidth = inner.offsetWidth;
|
||||
const innerHeight = inner.offsetHeight;
|
||||
const cardWidth = Math.min(
|
||||
isDesktop ? CARD_WIDTH_DESKTOP : CARD_WIDTH_MOBILE,
|
||||
innerWidth - 64,
|
||||
);
|
||||
viewportHeight * PRE_STICKY_REVEAL_VIEWPORT_FRACTION;
|
||||
const nextMeasurements = {
|
||||
cardCount: cards.length,
|
||||
innerHeight: inner.clientHeight,
|
||||
innerWidth: inner.clientWidth,
|
||||
isDesktop,
|
||||
progressMetrics: readProgressScaleMetrics(refs, cards, inner),
|
||||
sectionHeight: sectionRect.height,
|
||||
viewportHeight,
|
||||
};
|
||||
|
||||
cards.forEach((_, index) => {
|
||||
const node = refs.cardRefs.current[index];
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
let measurements = state.measurements;
|
||||
if (
|
||||
measurements === null ||
|
||||
shouldRecomputeMeasurements(measurements, nextMeasurements)
|
||||
) {
|
||||
measurements = measureHelpedSceneLayout(
|
||||
refs,
|
||||
cards,
|
||||
inner,
|
||||
nextMeasurements,
|
||||
);
|
||||
state.measurements = measurements;
|
||||
}
|
||||
|
||||
node.style.width = `${cardWidth}px`;
|
||||
node.style.zIndex = String(10 + index);
|
||||
node.style.left = `${cardLeft(index, innerWidth, cardWidth, isDesktop)}px`;
|
||||
});
|
||||
|
||||
const progressScale = getProgressScale(
|
||||
cards,
|
||||
refs.cardRefs,
|
||||
inner,
|
||||
innerHeight,
|
||||
);
|
||||
const { cardWidth, innerHeight, innerWidth, progressScale, sectionHeight } =
|
||||
measurements;
|
||||
const scrollRange = Math.max(1, sectionHeight - viewportHeight);
|
||||
const progress =
|
||||
clamp01(
|
||||
(preStickyRevealOffset - sectionRect.top) /
|
||||
(scrollRange + preStickyRevealOffset),
|
||||
) * progressScale;
|
||||
const postStickyParallaxOffset =
|
||||
easeOutQuad(
|
||||
(window.innerHeight - sectionRect.bottom) / window.innerHeight,
|
||||
) *
|
||||
easeOutQuad((viewportHeight - sectionRect.bottom) / viewportHeight) *
|
||||
innerHeight *
|
||||
POST_STICKY_PARALLAX_DISTANCE;
|
||||
|
||||
@@ -133,20 +275,24 @@ export function applyHelpedSceneLayout(
|
||||
}
|
||||
|
||||
if (reducedMotion) {
|
||||
node.style.opacity = '1';
|
||||
node.style.top = `${innerHeight * (0.15 + index * 0.25)}px`;
|
||||
const x = cardLeft(index, innerWidth, cardWidth, isDesktop);
|
||||
const y = innerHeight * (0.15 + index * 0.25);
|
||||
|
||||
setStyleProperty(node, 'opacity', '1');
|
||||
setStyleProperty(node, 'transform', `translate3d(${x}px, ${y}px, 0)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const cardStart = CARD_TRAVEL_START + index * CARD_TRAVEL_STEP;
|
||||
const travel = clamp01((progress - cardStart) / CARD_TRAVEL_RANGE);
|
||||
|
||||
node.style.top = `${innerHeight * (1.1 - travel * 1.4) - postStickyParallaxOffset}px`;
|
||||
node.style.opacity = String(
|
||||
Math.min(
|
||||
clamp01(travel / FADE_FRACTION),
|
||||
clamp01((1 - travel) / FADE_FRACTION),
|
||||
),
|
||||
const x = cardLeft(index, innerWidth, cardWidth, isDesktop);
|
||||
const y = innerHeight * (1.1 - travel * 1.4) - postStickyParallaxOffset;
|
||||
const opacity = Math.min(
|
||||
clamp01(travel / FADE_FRACTION),
|
||||
clamp01((1 - travel) / FADE_FRACTION),
|
||||
);
|
||||
|
||||
setStyleProperty(node, 'opacity', String(opacity));
|
||||
setStyleProperty(node, 'transform', `translate3d(${x}px, ${y}px, 0)`);
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,8 @@ import {
|
||||
type HelpedHalftonePose,
|
||||
type HelpedHalftoneSettings,
|
||||
} from './HelpedHalftoneModel';
|
||||
import { HELPED_VISUAL_MODEL_URLS } from './helped-visual-models';
|
||||
|
||||
const GLB_URL = '/illustrations/home/helped/money.glb';
|
||||
const MONEY_PREVIEW_DISTANCE = 5;
|
||||
|
||||
const MONEY_SETTINGS: HelpedHalftoneSettings = {
|
||||
@@ -105,9 +105,8 @@ export function Money() {
|
||||
<HelpedHalftoneModel
|
||||
initialPose={MONEY_INITIAL_POSE}
|
||||
label="money.glb"
|
||||
modelUrl={GLB_URL}
|
||||
modelUrl={HELPED_VISUAL_MODEL_URLS.money}
|
||||
previewDistance={MONEY_PREVIEW_DISTANCE}
|
||||
renderer="studio"
|
||||
settings={MONEY_SETTINGS}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
type HelpedHalftonePose,
|
||||
type HelpedHalftoneSettings,
|
||||
} from './HelpedHalftoneModel';
|
||||
import { HELPED_VISUAL_MODEL_URLS } from './helped-visual-models';
|
||||
|
||||
const GLB_URL = '/illustrations/home/helped/spaceship.glb';
|
||||
const SPACESHIP_PREVIEW_DISTANCE = 3.5;
|
||||
|
||||
const SPACESHIP_SETTINGS: HelpedHalftoneSettings = {
|
||||
@@ -105,9 +105,8 @@ export function Spaceship() {
|
||||
<HelpedHalftoneModel
|
||||
initialPose={SPACESHIP_INITIAL_POSE}
|
||||
label="spaceship.glb"
|
||||
modelUrl={GLB_URL}
|
||||
modelUrl={HELPED_VISUAL_MODEL_URLS.spaceship}
|
||||
previewDistance={SPACESHIP_PREVIEW_DISTANCE}
|
||||
renderer="studio"
|
||||
settings={SPACESHIP_SETTINGS}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
type HelpedHalftonePose,
|
||||
type HelpedHalftoneSettings,
|
||||
} from './HelpedHalftoneModel';
|
||||
import { HELPED_VISUAL_MODEL_URLS } from './helped-visual-models';
|
||||
|
||||
const GLB_URL = '/illustrations/home/helped/target.glb';
|
||||
const TARGET_PREVIEW_DISTANCE = 4;
|
||||
|
||||
const TARGET_SETTINGS: HelpedHalftoneSettings = {
|
||||
@@ -105,9 +105,8 @@ export function Target() {
|
||||
<HelpedHalftoneModel
|
||||
initialPose={TARGET_INITIAL_POSE}
|
||||
label="target.glb"
|
||||
modelUrl={GLB_URL}
|
||||
modelUrl={HELPED_VISUAL_MODEL_URLS.target}
|
||||
previewDistance={TARGET_PREVIEW_DISTANCE}
|
||||
renderer="studio"
|
||||
settings={TARGET_SETTINGS}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { loadImportedGeometryFromUrl } from '@/lib/halftone';
|
||||
|
||||
export const HELPED_VISUAL_MODEL_URLS = {
|
||||
money: '/illustrations/home/helped/money.glb',
|
||||
spaceship: '/illustrations/home/helped/spaceship.glb',
|
||||
target: '/illustrations/home/helped/target.glb',
|
||||
} as const;
|
||||
|
||||
let preloadPromise: Promise<void> | null = null;
|
||||
|
||||
export function preloadHelpedVisualGeometries() {
|
||||
preloadPromise ??= (async () => {
|
||||
const geometries = await Promise.all(
|
||||
Object.entries(HELPED_VISUAL_MODEL_URLS).map(([label, modelUrl]) =>
|
||||
loadImportedGeometryFromUrl('glb', modelUrl, `${label}.glb`),
|
||||
),
|
||||
);
|
||||
|
||||
geometries.forEach((geometry) => geometry.dispose());
|
||||
})().catch((error: unknown) => {
|
||||
preloadPromise = null;
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error('Helped visual geometry preload failed:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return preloadPromise;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { scheduleVisualMount } from '@/lib/visual-runtime';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useEffect } from 'react';
|
||||
import type { HeroVisualType } from '@/sections/Hero/types';
|
||||
import { DraggableAppWindow } from './DraggableAppWindow/DraggableAppWindow';
|
||||
import { DraggableTerminal } from './DraggableTerminal/DraggableTerminal';
|
||||
@@ -9,7 +11,10 @@ import { COLORS } from './Shared/home-visual-theme';
|
||||
import { HomeVisualNavbar } from './Shell/HomeVisualNavbar';
|
||||
import { HomeVisualSidebar } from './Shell/HomeVisualSidebar';
|
||||
import { HomeVisualViewbar } from './Shell/HomeVisualViewbar';
|
||||
import { renderPageDefinition } from './Shell/home-visual-page-renderers';
|
||||
import {
|
||||
preloadDeferredHomeVisualPages,
|
||||
renderPageDefinition,
|
||||
} from './Shell/home-visual-page-renderers';
|
||||
import { useHomeVisualState } from './Shell/use-home-visual-state';
|
||||
import { WindowOrderProvider } from './WindowOrder/WindowOrderProvider';
|
||||
|
||||
@@ -97,6 +102,17 @@ export function HomeVisual({ visual }: { visual: HeroVisualType }) {
|
||||
activePage.type !== 'dashboard' &&
|
||||
activePage.type !== 'workflow';
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
scheduleVisualMount(
|
||||
() => {
|
||||
void preloadDeferredHomeVisualPages();
|
||||
},
|
||||
{ timeoutMs: 900 },
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledHomeVisual>
|
||||
<ShellScene>
|
||||
|
||||
+26
-2
@@ -29,9 +29,14 @@ const DashboardViewport = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const loadSalesDashboardPageModule = () =>
|
||||
import('../Pages/Dashboard/SalesDashboardPage');
|
||||
|
||||
const loadWorkflowPageModule = () => import('../Pages/Workflow/WorkflowPage');
|
||||
|
||||
const SalesDashboardPage = dynamic(
|
||||
() =>
|
||||
import('../Pages/Dashboard/SalesDashboardPage').then((mod) => ({
|
||||
loadSalesDashboardPageModule().then((mod) => ({
|
||||
default: mod.SalesDashboardPage,
|
||||
})),
|
||||
{
|
||||
@@ -44,7 +49,7 @@ const SalesDashboardPage = dynamic(
|
||||
|
||||
const WorkflowPage = dynamic(
|
||||
() =>
|
||||
import('../Pages/Workflow/WorkflowPage').then((mod) => ({
|
||||
loadWorkflowPageModule().then((mod) => ({
|
||||
default: mod.WorkflowPage,
|
||||
})),
|
||||
{
|
||||
@@ -53,6 +58,25 @@ const WorkflowPage = dynamic(
|
||||
},
|
||||
);
|
||||
|
||||
let deferredPagePreloadPromise: Promise<void> | null = null;
|
||||
|
||||
export function preloadDeferredHomeVisualPages() {
|
||||
deferredPagePreloadPromise ??= Promise.all([
|
||||
loadSalesDashboardPageModule(),
|
||||
loadWorkflowPageModule(),
|
||||
])
|
||||
.then(() => undefined)
|
||||
.catch((error: unknown) => {
|
||||
deferredPagePreloadPromise = null;
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error('Home visual deferred page preload failed:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return deferredPagePreloadPromise;
|
||||
}
|
||||
|
||||
const PAGE_RENDERERS = {
|
||||
table: (page: HeroTablePageDefinition) => <TablePage page={page} />,
|
||||
kanban: (page: HeroKanbanPageDefinition) => <KanbanPage page={page} />,
|
||||
|
||||
+4
-12
@@ -8,6 +8,7 @@ import { getPrefersReducedMotionSnapshot } from '@/lib/motion';
|
||||
import { observeElementSize } from '@/lib/dom/observe-element-size';
|
||||
import {
|
||||
createVisualRenderLoop,
|
||||
loadVisualImage,
|
||||
tryCreateSiteWebGlRenderer,
|
||||
type VisualRenderLoop,
|
||||
} from '@/lib/visual-runtime';
|
||||
@@ -421,17 +422,6 @@ function createRenderTarget(width: number, height: number) {
|
||||
});
|
||||
}
|
||||
|
||||
function loadImage(imageUrl: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.decoding = 'async';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () =>
|
||||
reject(new Error(`Failed to load hero image: ${imageUrl}`));
|
||||
image.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
async function mountHalftoneOverlay({
|
||||
container,
|
||||
imageUrl,
|
||||
@@ -439,7 +429,9 @@ async function mountHalftoneOverlay({
|
||||
container: HTMLDivElement;
|
||||
imageUrl: string;
|
||||
}): Promise<() => void> {
|
||||
const image = await loadImage(imageUrl);
|
||||
const image = await loadVisualImage(imageUrl, {
|
||||
label: 'partner hero image',
|
||||
});
|
||||
|
||||
const getWidth = () => Math.max(container.clientWidth, 1);
|
||||
const getHeight = () => Math.max(container.clientHeight, 1);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { createAnimationFrameLoop } from '@/lib/animation';
|
||||
import {
|
||||
createVisualRenderLoop,
|
||||
loadVisualImage,
|
||||
tryCreateSiteWebGlRenderer,
|
||||
type VisualRenderLoopFrame,
|
||||
type VisualRenderLoop,
|
||||
@@ -404,17 +405,6 @@ function createRenderTarget(width: number, height: number) {
|
||||
});
|
||||
}
|
||||
|
||||
function loadImage(imageUrl: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.decoding = 'async';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () =>
|
||||
reject(new Error(`Failed to load home background image: ${imageUrl}`));
|
||||
image.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
type PointerState = {
|
||||
hoverStrength: number;
|
||||
mouseX: number;
|
||||
@@ -431,7 +421,9 @@ async function mountHomeBackgroundCanvas({
|
||||
container: HTMLDivElement;
|
||||
imageUrl: string;
|
||||
}): Promise<() => void> {
|
||||
const image = await loadImage(imageUrl);
|
||||
const image = await loadVisualImage(imageUrl, {
|
||||
label: 'home background image',
|
||||
});
|
||||
|
||||
const getWidth = () => Math.max(container.clientWidth, 1);
|
||||
const getHeight = () => Math.max(container.clientHeight, 1);
|
||||
|
||||
+4
-10
@@ -9,6 +9,7 @@ import { observeElementSize } from '@/lib/dom/observe-element-size';
|
||||
import { getPrefersReducedMotionSnapshot } from '@/lib/motion';
|
||||
import {
|
||||
createVisualRenderLoop,
|
||||
loadVisualImage,
|
||||
tryCreateSiteWebGlRenderer,
|
||||
type VisualRenderLoop,
|
||||
} from '@/lib/visual-runtime';
|
||||
@@ -286,15 +287,6 @@ function createPointerState(): PointerState {
|
||||
};
|
||||
}
|
||||
|
||||
function loadImage(src: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`Failed to load image: ${src}`));
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
async function mountHalftoneCanvas({
|
||||
container,
|
||||
imageUrl,
|
||||
@@ -311,7 +303,9 @@ async function mountHalftoneCanvas({
|
||||
1,
|
||||
);
|
||||
|
||||
const image = await loadImage(imageUrl);
|
||||
const image = await loadVisualImage(imageUrl, {
|
||||
label: 'stepper background image',
|
||||
});
|
||||
|
||||
if (!container.isConnected) {
|
||||
return;
|
||||
|
||||
@@ -96,10 +96,6 @@ const NavItem = styled(LocalizedLink)`
|
||||
${navItemStyles}
|
||||
`;
|
||||
|
||||
const ExternalNavItem = styled.a`
|
||||
${navItemStyles}
|
||||
`;
|
||||
|
||||
const NavGroupButton = styled.button`
|
||||
${navItemStyles}
|
||||
align-items: center;
|
||||
@@ -215,11 +211,11 @@ const NavChildLabel = styled.span`
|
||||
}
|
||||
`;
|
||||
|
||||
const HorizontalSeparator = styled(Separator)<{ $separatorColor: string }>`
|
||||
const HorizontalSeparator = styled(Separator)`
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
${({ $separatorColor }) => $separatorColor} 0,
|
||||
${({ $separatorColor }) => $separatorColor} 1px,
|
||||
var(--menu-separator-color) 0,
|
||||
var(--menu-separator-color) 1px,
|
||||
transparent 2px,
|
||||
transparent 4px
|
||||
);
|
||||
@@ -414,8 +410,12 @@ export function MenuDrawer({ navItems, scheme, socialLinks }: MenuDrawerProps) {
|
||||
) : null}
|
||||
{index < topLevelItems.length - 1 && (
|
||||
<HorizontalSeparator
|
||||
$separatorColor={separatorColor}
|
||||
orientation="horizontal"
|
||||
style={
|
||||
{
|
||||
'--menu-separator-color': separatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as THREE from 'three';
|
||||
import { observeElementSize } from '@/lib/dom/observe-element-size';
|
||||
import {
|
||||
createVisualRenderLoop,
|
||||
loadVisualImage,
|
||||
tryCreateSiteWebGlRenderer,
|
||||
type VisualRenderLoop,
|
||||
type VisualRenderLoopFrame,
|
||||
@@ -445,38 +446,24 @@ function createAbortError() {
|
||||
return new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
function loadImage(imageUrl: string, signal: AbortSignal) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
function createAbortTask(signal: AbortSignal) {
|
||||
let cleanup = () => {};
|
||||
const promise = new Promise<never>((_, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(createAbortError());
|
||||
return;
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
image.decoding = 'async';
|
||||
|
||||
const cleanup = () => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
signal.removeEventListener('abort', handleAbort);
|
||||
};
|
||||
const handleAbort = () => {
|
||||
cleanup();
|
||||
image.src = '';
|
||||
reject(createAbortError());
|
||||
};
|
||||
|
||||
image.onload = () => {
|
||||
cleanup();
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
cleanup();
|
||||
reject(new Error(`Failed to load image: ${imageUrl}`));
|
||||
};
|
||||
signal.addEventListener('abort', handleAbort, { once: true });
|
||||
image.src = imageUrl;
|
||||
cleanup = () => {
|
||||
signal.removeEventListener('abort', handleAbort);
|
||||
};
|
||||
});
|
||||
|
||||
return { cleanup, promise };
|
||||
}
|
||||
|
||||
async function mountHalftoneCanvas(options: MountHalftoneCanvasOptions) {
|
||||
@@ -492,7 +479,11 @@ async function mountHalftoneCanvas(options: MountHalftoneCanvasOptions) {
|
||||
1,
|
||||
);
|
||||
|
||||
const image = await loadImage(imageUrl, signal);
|
||||
const abortTask = createAbortTask(signal);
|
||||
const image = await Promise.race([
|
||||
loadVisualImage(imageUrl, { label: 'problem monolith image' }),
|
||||
abortTask.promise,
|
||||
]).finally(abortTask.cleanup);
|
||||
|
||||
if (signal.aborted) {
|
||||
return undefined;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { HalftoneCanvas, type HalftoneStudioSettings } from '@/lib/halftone';
|
||||
import { loadVisualImage } from '@/lib/visual-runtime';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
@@ -160,29 +161,20 @@ export function PartnerEffect({ alt, fallback, src }: PartnerEffectProps) {
|
||||
|
||||
setImageElement(null);
|
||||
|
||||
const image = new Image();
|
||||
image.decoding = 'async';
|
||||
image.onload = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setImageElement(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Failed to load testimonial portrait: ${src}`);
|
||||
};
|
||||
image.src = src;
|
||||
void loadVisualImage(src, { label: 'testimonial portrait' })
|
||||
.then((image) => {
|
||||
if (!cancelled) {
|
||||
setImageElement(image);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancelled) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
image.src = '';
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
|
||||
+5
-9
@@ -7,6 +7,7 @@ import * as THREE from 'three';
|
||||
import { observeElementSize } from '@/lib/dom/observe-element-size';
|
||||
import {
|
||||
createVisualRenderLoop,
|
||||
loadVisualImage,
|
||||
tryCreateSiteWebGlRenderer,
|
||||
type VisualRenderLoop,
|
||||
} from '@/lib/visual-runtime';
|
||||
@@ -188,14 +189,6 @@ function createInteractionState(): InteractionState {
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDecodedImage(imageUrl: string) {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.src = imageUrl;
|
||||
await image.decode();
|
||||
return image;
|
||||
}
|
||||
|
||||
function getImagePreviewZoom(previewDistance: number) {
|
||||
return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001);
|
||||
}
|
||||
@@ -218,7 +211,10 @@ async function mountHalftoneImageBackdrop({
|
||||
isExternallyActive: () => boolean;
|
||||
pointerTarget?: HTMLElement | null;
|
||||
}) {
|
||||
const image = await loadDecodedImage(config.imageUrl);
|
||||
const image = await loadVisualImage(config.imageUrl, {
|
||||
crossOrigin: 'anonymous',
|
||||
label: 'halftone image backdrop',
|
||||
});
|
||||
|
||||
let renderLoop: VisualRenderLoop | null = null;
|
||||
const renderer = tryCreateSiteWebGlRenderer({
|
||||
|
||||
Reference in New Issue
Block a user