Please help. Poor performance in simple scene - React Fiber / Drei

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? :frowning:

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;

This looks like it’d be a cause for concern as you’re requesting animation frame every scroll step, have you considered using useFrameand or ScrollControls from drei seeing as you’re using r3f?

1 Like