← All primitives
CanvasPerf
UtilityMake any R3F scene smooth on any device: useDeviceTier (device heuristic for a starting quality), AutoDpr (PerformanceMonitor + AdaptiveDpr — holds framerate by trading resolution), and PauseWhenOffscreen (stop rendering when off-screen).
AutoDpr + PauseWhenOffscreen render INSIDE a <Canvas>; useDeviceTier is a plain hook the wrapper uses to pick a starting dpr/count/bloom.
$
npx shadcn@latest add https://prism.icglabs.co/r/canvas-perf.jsonDependencies:@react-three/fiber@react-three/drei
View raw manifest →lib/useDeviceTier.ts
"use client";
import { useEffect, useState } from "react";
/**
* useDeviceTier — a cheap, synchronous device-capability heuristic that picks a
* *starting* quality for WebGL scenes. Runtime adaptation (PerformanceMonitor +
* AdaptiveDpr) then corrects from here, so a wrong guess self-heals — this just
* avoids opening a heavy device at full quality and stuttering on the first frames.
*
* SSR-safe: returns the "high" default on the server / first render, then resolves
* the real tier on mount.
*/
export type Quality = {
tier: "low" | "mid" | "high";
/** [min, max] DPR range — AdaptiveDpr scales within it by measured FPS. */
dpr: [number, number];
/** whether to render the (expensive) bloom pass at all. */
bloom: boolean;
/** instance/particle count multiplier. */
scale: number;
};
const HIGH: Quality = { tier: "high", dpr: [0.8, 1.5], bloom: true, scale: 1 };
const MID: Quality = { tier: "mid", dpr: [0.7, 1.3], bloom: true, scale: 0.75 };
const LOW: Quality = { tier: "low", dpr: [0.6, 1], bloom: false, scale: 0.5 };
export function useDeviceTier(): Quality {
const [q, setQ] = useState<Quality>(HIGH);
useEffect(() => {
const nav = navigator as Navigator & { deviceMemory?: number };
const coarse = window.matchMedia("(pointer: coarse)").matches; // phones/tablets
const cores = nav.hardwareConcurrency ?? 8;
const mem = nav.deviceMemory ?? 8;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let base: Quality;
if (coarse || cores <= 4 || mem <= 4) base = LOW;
else if (cores <= 6 || mem <= 6) base = MID;
else base = HIGH;
// reduced-motion users get the static path anyway; keep counts modest.
setQ(reduce ? { ...base, scale: Math.min(base.scale, 0.6) } : base);
}, []);
return q;
}
components/showcase/CanvasPerf.tsx
"use client";
import { useEffect } from "react";
import { useThree } from "@react-three/fiber";
import { PerformanceMonitor, AdaptiveDpr } from "@react-three/drei";
/**
* AutoDpr — drop inside any <Canvas>. PerformanceMonitor watches the real
* framerate and AdaptiveDpr trades resolution to hold it steady (within the
* Canvas's dpr range). If the device keeps struggling, onLow fires once so the
* scene can shed its heaviest pass (bloom). This is the core "smooth on anything"
* mechanism — it self-corrects no matter the device.
*/
export function AutoDpr({ onLow }: { onLow?: () => void }) {
return (
<>
<PerformanceMonitor flipflops={3} onFallback={() => onLow?.()} />
<AdaptiveDpr />
</>
);
}
/**
* PauseWhenOffscreen — stops the render loop when the canvas leaves the viewport
* and resumes it on return. Kills wasted GPU/CPU from sticky or below-fold
* canvases that stay mounted while you're looking elsewhere.
*/
export function PauseWhenOffscreen() {
const gl = useThree((s) => s.gl);
const setFrameloop = useThree((s) => s.setFrameloop);
useEffect(() => {
const el = gl.domElement;
// Lenient margin so we only pause when clearly off-screen; IntersectionObserver
// also re-fires when the canvas resizes, so a briefly-mismeasured canvas resumes.
const io = new IntersectionObserver(
([entry]) => setFrameloop(entry.isIntersecting ? "always" : "never"),
{ rootMargin: "200px" }
);
io.observe(el);
return () => io.disconnect();
}, [gl, setFrameloop]);
return null;
}