Solving Three.js 3D Rendering Performance and Memory Issues
Debugging Three.js rendering problems including memory leaks, texture loading failures, material inconsistencies, and performance optimization techniques.
Introduction
Three.js makes WebGL accessible, but the abstraction layer hides memory management responsibilities that can cause serious performance issues in production. After building several 3D web applications, I discovered that the most common problems are not rendering bugs but memory leaks that accumulate over time and eventually crash the browser tab.
This post covers the memory management patterns, texture loading strategies, and rendering optimizations that transformed unstable 3D applications into reliable production tools.
Environment
node --version
# v20.11.0
npm list three @react-three/fiber @react-three/drei
# three@0.161.0
# @react-three/fiber@8.15.12
# @react-three/drei@9.92.7
npm list webpack
# webpack@5.90.0The application structure:
3d-viewer/
βββ src/
β βββ components/
β β βββ Scene.tsx
β β βββ Model.tsx
β β βββ Lighting.tsx
β βββ hooks/
β β βββ useLoader.ts
β β βββ useAnimation.ts
β βββ utils/
β β βββ dispose.ts
β β βββ materials.ts
β βββ App.tsxProblem
The first and most critical issue was memory leaks. The 3D viewer consumed 500MB of memory after loading just five models. After navigating away and back to the viewer, memory usage doubled because the previous scene was never properly disposed:
// This common pattern causes memory leaks
function loadModel(url) {
const loader = new GLTFLoader();
loader.load(url, (gltf) => {
scene.add(gltf.scene);
// The scene is added but never disposed
// when the component unmounts
});
}
// After 5 load/unload cycles: memory usage grows linearlyThe second issue was black textures on some GPU configurations. Models that appeared correctly on NVIDIA GPUs showed black materials on integrated Intel GPUs. The issue was related to texture format compatibility and missing normal maps:
// This texture format fails on some GPUs
const textureLoader = new TextureLoader();
const texture = textureLoader.load("texture.png");
// texture.format defaults to RGBAFormat
// But the source image might be JPEG (RGB)
// This mismatch causes black rendering on some GPUsThe third issue was frame rate drops when the scene contained more than 100,000 triangles. The rendering pipeline was not optimized for the geometry complexity, causing the frame rate to drop from 60fps to under 15fps.
Analysis
Three.js creates GPU resources (textures, geometries, materials) that must be explicitly released. Unlike JavaScript garbage collection, WebGL resources are not automatically freed when their JavaScript references are garbage collected. The dispose() method must be called on every resource to release GPU memory.
The texture format issue occurs because Three.js infers the texture format from the source image extension. When the extension does not match the actual file format (which can happen with CDN-served images), the GPU receives incorrectly formatted data. The solution is to explicitly specify the texture format.
The performance issue with complex scenes stems from the draw call overhead. Each mesh in the scene generates at least one draw call, and each draw call has a fixed CPU cost. With 100,000 triangles across many meshes, the CPU overhead of managing draw calls exceeds the GPU rendering time.
Solution
Implement proper disposal for all Three.js resources:
// utils/dispose.ts
import * as THREE from "three";
export function disposeObject(obj: THREE.Object3D) {
if (obj instanceof THREE.Mesh) {
// Dispose geometry
if (obj.geometry) {
obj.geometry.dispose();
}
// Dispose material(s)
if (obj.material) {
if (Array.isArray(obj.material)) {
obj.material.forEach((material) => disposeMaterial(material));
} else {
disposeMaterial(obj.material);
}
}
}
// Dispose children recursively
if (obj.children) {
obj.children.forEach((child) => disposeObject(child));
}
}
function disposeMaterial(material: THREE.Material) {
// Dispose all texture properties
const textureProperties = [
"map",
"normalMap",
"roughnessMap",
"metalnessMap",
"emissiveMap",
"aoMap",
"alphaMap",
"envMap",
];
textureProperties.forEach((prop) => {
const texture = (material as any)[prop];
if (texture instanceof THREE.Texture) {
texture.dispose();
}
});
material.dispose();
}
// React hook for automatic cleanup
function useDisposal() {
const objectsToDispose = useRef([]);
useEffect(() => {
return () => {
objectsToDispose.current.forEach((obj) => disposeObject(obj));
objectsToDispose.current = [];
};
}, []);
const addForDisposal = useCallback((obj: THREE.Object3D) => {
objectsToDispose.current.push(obj);
}, []);
return { addForDisposal };
} Fix texture loading for GPU compatibility:
// utils/materials.ts
import * as THREE from "three";
export function createCompatibleTexture(
url: string,
options: {
format?: THREE.PixelFormat;
encoding?: THREE.TextureEncoding;
flipY?: boolean;
} = {}
): Promise {
return new Promise((resolve, reject) => {
const loader = new THREE.TextureLoader();
loader.load(
url,
(texture) => {
// Force the correct format
texture.format = options.format || THREE.RGBAFormat;
texture.encoding = options.encoding || THREE.sRGBEncoding;
texture.flipY = options.flipY ?? true;
// Ensure power-of-two for older GPUs
if (!isPowerOfTwo(texture.image.width) ||
!isPowerOfTwo(texture.image.height)) {
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.minFilter = THREE.LinearFilter;
}
texture.needsUpdate = true;
resolve(texture);
},
undefined,
reject
);
});
}
function isPowerOfTwo(value: number): boolean {
return (value & (value - 1)) === 0 && value !== 0;
} Optimize rendering with instancing and LOD:
// components/InstancedScene.tsx
import { useMemo } from "react";
import * as THREE from "three";
function InstancedObjects({ positions }: { positions: Vector3[] }) {
const meshRef = useRef(null);
const { geometry, material, count } = useMemo(() => {
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: "steelblue" });
return { geometry, material, count: positions.length };
}, [positions]);
useEffect(() => {
if (!meshRef.current) return;
const matrix = new THREE.Matrix4();
positions.forEach((pos, i) => {
matrix.setPosition(pos.x, pos.y, pos.z);
meshRef.current!.setMatrixAt(i, matrix);
});
meshRef.current.instanceMatrix.needsUpdate = true;
}, [positions]);
return (
);
} Configure the renderer for optimal performance:
// components/Scene.tsx
import { Canvas } from "@react-three/fiber";
function Scene({ children }: { children: React.ReactNode }) {
return (
);
}Implement Level of Detail (LOD) for complex models:
// components/LODModel.tsx
import { useLoader } from "@react-three/fiber";
import * as THREE from "three";
function LODModel({ url }: { url: string }) {
const lodRef = useRef(null);
// Load different detail levels
const highDetail = useLoader(GLTFLoader, `${url}/high.glb`);
const mediumDetail = useLoader(GLTFLoader, `${url}/medium.glb`);
const lowDetail = useLoader(GLTFLoader, `${url}/low.glb`);
useEffect(() => {
if (!lodRef.current) return;
lodRef.current.addLevel(highDetail.scene, 0);
lodRef.current.addLevel(mediumDetail.scene, 50);
lodRef.current.addLevel(lowDetail.scene, 100);
}, [highDetail, mediumDetail, lowDetail]);
return ;
} Lessons Learned
Always call
dispose()on Three.js objects when they are removed from the scene. Use a React cleanup function or component lifecycle to ensure disposal happens.Explicitly set texture format and encoding to ensure cross-GPU compatibility. Do not rely on automatic format detection.
Use
InstancedMeshfor repeated geometry to reduce draw calls. A single instanced mesh can render thousands of objects with one draw call.Set
frameloop="demand"on the Canvas to prevent unnecessary renders when the scene is static. Callinvalidate()to trigger a render when needed.Implement LOD for complex models to automatically reduce detail based on camera distance. This maintains visual quality while improving performance.
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.