Hi!
I’m quite new into three.js. I’ve created a simple animation of VideoTexture masked out by a shape. This shapes animates initially, and later on on scroll event. It works fine on faster computers, but there’s a poor performance problem especially on older Macs on Safari (laptop fans spinning like crazy). I memoized everything I could already but it didn’t help.
What am I missing?
import { useEffect, useRef, useState, useCallback, useMemo, memo } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { Mask, PerformanceMonitor, Preload, useAspect, useMask, useVideoTexture } from "@react-three/drei";
import { Mesh, Vector3 } from "three";
import { easing } from "maath";
import { degToRad } from "three/src/math/MathUtils.js";
// Memoize scale calculations
const scaleToViewport = (viewportWidth: number): Vector3 => {
const factor = viewportWidth <= 1.46666 ? 0.5 : viewportWidth <= 3.3 ? 1 / 3.5 : viewportWidth <= 4.8 ? 1 / 4 : 1 / 5;
return new Vector3(viewportWidth * factor, viewportWidth * factor, viewportWidth * factor);
};
// Memoized VideoMaterial component
const VideoMaterial = memo(({ src, syncRef, ...props }: { src: string; syncRef: React.RefObject<HTMLVideoElement | null> }) => {
const texture = useVideoTexture(src, {
loop: true,
muted: true,
start: false,
crossOrigin: "anonymous",
});
const video = texture.source.data as HTMLVideoElement;
useEffect(() => {
if (!video || !syncRef.current) return;
let rafId: number;
const sync = () => {
if (!syncRef.current) return;
const timeDiff = Math.abs(video.currentTime - syncRef.current.currentTime);
if (timeDiff > 0.2) {
video.currentTime = syncRef.current.currentTime;
}
if (syncRef.current.paused !== video.paused) {
if (syncRef.current.paused) {
video.pause();
} else {
video.play().catch(() => {});
}
}
rafId = requestAnimationFrame(sync);
};
rafId = requestAnimationFrame(sync);
return () => cancelAnimationFrame(rafId);
}, [video, syncRef]);
return <meshBasicMaterial map={texture} toneMapped={false} {...props} />;
});
VideoMaterial.displayName = "VideoMaterial";
// Memoized MaskedContent component
const MaskedContent = memo(
({ src, videoRef, invert, ...props }: { src: string; videoRef: React.RefObject<HTMLVideoElement | null>; invert: boolean }) => {
const { viewport } = useThree();
const stencil = useMask(3, invert);
const group = useRef(null);
const size = useAspect(viewport.width, (viewport.width / 16) * 9, 1);
return (
<group {...props}>
<mesh ref={group} scale={size}>
<planeGeometry />
<VideoMaterial src={src} syncRef={videoRef} {...stencil} />
</mesh>
</group>
);
},
);
MaskedContent.displayName = "MaskedContent";
const MaskContainer = memo(({ id, ...props }: { id: number }) => {
const ref = useRef<Mesh>(null);
const frame = useRef<number>(0);
const [isOnTop, setIsOnTop] = useState<boolean>(true);
const lastScrollY = useRef<number>(0);
const handleScroll = useCallback(() => {
const currentScrollY = window.scrollY;
if (Math.abs(currentScrollY - lastScrollY.current) > 10) {
setIsOnTop(currentScrollY <= 20);
lastScrollY.current = currentScrollY;
}
}, []);
useEffect(() => {
let ticking = false;
const throttledScroll = () => {
if (!ticking) {
requestAnimationFrame(() => {
handleScroll();
ticking = false;
});
ticking = true;
}
};
window.addEventListener("scroll", throttledScroll, { passive: true });
return () => window.removeEventListener("scroll", throttledScroll);
}, [handleScroll]);
const rotations = useMemo(
() => ({
initial: [degToRad(34), 0, 0] as [number, number, number],
secondary: [degToRad(34), degToRad(-45), 0] as [number, number, number],
scrolled: [degToRad(-135), 2.31, 0] as [number, number, number],
default: [0, degToRad(-180), 0] as [number, number, number],
}),
[],
);
useFrame((state, delta) => {
if (!ref.current) return;
frame.current += 1;
if (frame.current > 110 && frame.current <= 125) {
easing.dampE(ref.current.rotation, rotations.initial, 0.35, delta);
} else if (frame.current > 125) {
easing.dampE(ref.current.rotation, rotations.secondary, 0.5, delta);
}
if (!isOnTop && state.viewport.width > 3.3) {
easing.dampE(ref.current.rotation, rotations.scrolled, 0.75, delta);
easing.damp3(ref.current.scale, [11, 4.5, 0], 0.5, delta);
} else {
easing.damp3(ref.current.scale, scaleToViewport(state.viewport.width), 0.5, delta);
}
});
return (
<Mask ref={ref} id={id} colorWrite={true} depthWrite={false} rotation={rotations.default} {...props}>
<boxGeometry args={[0.66, 1, 1.6]} />
</Mask>
);
});
MaskContainer.displayName = "MaskContainer";
const BgVideo = ({ src }: { src: string }) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [dpr, setDpr] = useState<number>(1.5);
const canvasProps = useMemo(
() => ({
orthographic: true,
camera: { position: [0, 0, 100] as const, zoom: 300 },
gl: { stencil: true },
}),
[],
);
return (
<div className="relative h-full w-full">
<Canvas {...canvasProps} dpr={dpr}>
<PerformanceMonitor onIncline={() => setDpr(2)} onDecline={() => setDpr(1)} />
{/* <Suspense fallback={null}> */}
<MaskContainer id={3} />
<MaskedContent src={src} videoRef={videoRef} invert={false} />
<Preload all />
{/* </Suspense> */}
</Canvas>
<div className="opacity-1 absolute left-0 top-1/2 -z-30 col-start-1 row-start-1 h-[75%] w-[100%] max-w-[100vw] -translate-y-1/2 blur-[200px]">
<video
ref={videoRef}
playsInline
autoPlay
muted
loop
crossOrigin="anonymous"
preload="metadata"
src={src}
className="h-full w-full object-cover mix-blend-color-burn blur-[200px] dark:mix-blend-normal"
/>
</div>
</div>
);
};
export default BgVideo;