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:
Abdullah.
2026-04-29 17:44:47 +05:00
committed by GitHub
parent abfa6200dd
commit 51384bc085
35 changed files with 1008 additions and 2073 deletions
+1
View File
@@ -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';
@@ -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']);
});
});
@@ -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"
@@ -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>
@@ -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} />,
@@ -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);
@@ -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]);
@@ -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({
+1
View File
@@ -60858,6 +60858,7 @@ __metadata:
"@sniptt/guards": "npm:^0.2.0"
"@types/babel__preset-env": "npm:^7"
"@types/handlebars": "npm:^4.1.0"
ai: "npm:6.0.97"
babel-plugin-module-resolver: "npm:^5.0.2"
class-validator: "npm:^0.14.0"
expr-eval-fork: "npm:3.0.3"