Three.js API
prompts/pkg/llms/three-js.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
Essential classes, methods, and patterns for Three.js development
Any request for a 3D scene, model viewer, solar system, globe, spinning
product, or game is a Three.js app: a THREE.Scene, a THREE.PerspectiveCamera,
and a THREE.WebGLRenderer, driven from a single useEffect(() => {…}, []) and
disposed on unmount. Three.js gives you real depth, lighting, and a moving
camera for free.
Complete starter — the shell every Three.js vibe begins from
This is a full, runnable App.jsx. Start here and grow it: the engine lives in
one useEffect([]), the animation loop is a requestAnimationFrame, resize
keeps the camera aspect correct, and the cleanup disposes geometry, material,
and renderer so remounts don't leak GPU memory.
App.jsx
jsximport React from "react";
import * as THREE from "three";
export default function App() {
const mountRef = React.useRef(null);
React.useEffect(() => {
const mount = mountRef.current;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b0f1a);
const camera = new THREE.PerspectiveCamera(60, mount.clientWidth / mount.clientHeight, 0.1, 1000);
camera.position.set(0, 1.2, 4);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(mount.clientWidth, mount.clientHeight);
mount.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
const key = new THREE.DirectionalLight(0xffffff, 1.2);
key.position.set(3, 5, 2);
scene.add(key);
const geometry = new THREE.BoxGeometry(1.5, 1.5, 1.5);
const material = new THREE.MeshStandardMaterial({ color: 0x4f8cff, roughness: 0.4, metalness: 0.1 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
let raf = 0;
const clock = new THREE.Clock();
const animate = () => {
const dt = clock.getDelta();
cube.rotation.x += dt * 0.6;
cube.rotation.y += dt * 0.9;
renderer.render(scene, camera);
raf = requestAnimationFrame(animate);
};
animate();
const onResize = () => {
if (!mount) return;
camera.aspect = mount.clientWidth / mount.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(mount.clientWidth, mount.clientHeight);
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
geometry.dispose();
material.dispose();
renderer.dispose();
if (renderer.domElement.parentNode === mount) mount.removeChild(renderer.domElement);
};
}, []);
// select-none touch-none: a drag on a phone steers the scene instead of
// selecting nearby text, opening the copy callout, or scrolling the page.
return <div ref={mountRef} className="w-full h-dvh select-none touch-none" />;
}
The reference below is the API surface you draw on to grow that shell — swap the
cube for real geometry, add OrbitControls or your own camera rig, load models,
etc. — but the lifecycle above (one useEffect, one loop, dispose on unmount)
stays the spine of every Three.js vibe.
Full screen by default; a smaller view only when asked
A 3D game or scene you move through fills the screen: the canvas is the whole view
(relative h-dvh w-full root, absolute inset-0 canvas — h-dvh tracks the phone's real
height as the address bar slides). A quiet HUD (a score, a hint) stays in a top corner; the
title, settings, scores, sharing and every other panel live behind one menu button in the
top-right corner (IconButton, never the bottom-right, which the platform owns) that toggles an
overlay — see the Sky Glider example below. The game stays in view the moment the overlay closes.
The lens follows the screen: pass the camera to useWorldControls and it widens on a phone held upright.
An overview camera that orbits the scene keeps its own distance: scale the radius by
0.36 / (tan(fov / 2) · aspect), clamped to 1–2.2×, so the whole scene stays in frame upright.
When the person asks for a smaller view — a model on a product page, a scene inside a card, a viewer beside a form — give the viewport a box instead and keep the page around it:
jsx<Card>
<Stack gap={3}>
<Heading level={2}>Chair, in 3D</Heading>
<div ref={mountRef} className="w-full aspect-video overflow-hidden rounded-lg select-none touch-none" />
<Text size="sm" muted>
Drag to turn it around.
</Text>
</Stack>
</Card>
Size the renderer from the box (mount.clientWidth, mount.clientHeight) and watch it with a
ResizeObserver, since the box changes size without the window resizing.
Touch controls on a phone
On a phone, every drag across the canvas is a touch the browser would otherwise claim for itself:
it selects the nearest text, pops the copy / look-up callout, scrolls the page, or zooms. Hand
those touches to the scene with select-none touch-none on the mount <div> / <canvas> and
on the container that holds the HUD and labels. OrbitControls already speaks pointer events and
receives the drag once its element is touch-none.
- A world you move through — walked, explored, flown around — calls
useWorldControlsfromuse-vibes, taught in thetouch-gamesskill (Walk + look): left thumb walks, right thumb looks, tap acts, and a click-to-play overlay carries the desktop. - A flyer or a car that charges forward calls for hold + steer from the
touch-gamesskill: thumb down is the throttle and its offset steers.
Core Setup
Scene Graph Hierarchy
javascriptimport * as THREE from "three";
// Core trinity
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
// Everything is an Object3D
scene.add(mesh); // Mesh extends Object3D
group.add(light); // Light extends Object3D
parent.add(child); // Hierarchical transforms
Essential Classes
Cameras
javascript// Perspective (most common)
const camera = new THREE.PerspectiveCamera(
75, // field of view
aspect, // aspect ratio
0.1, // near plane
1000 // far plane
);
// Orthographic (2D/technical)
const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
// Camera controls
camera.position.set(x, y, z);
camera.lookAt(target);
camera.updateProjectionMatrix(); // After changing properties
Geometries
javascript// Primitive geometries
const box = new THREE.BoxGeometry(1, 1, 1);
const sphere = new THREE.SphereGeometry(1, 32, 32);
const plane = new THREE.PlaneGeometry(1, 1);
const cylinder = new THREE.CylinderGeometry(1, 1, 2, 32);
// Custom geometry
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));
geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
geometry.setIndex(indices);
Materials
javascript// Basic materials
const basic = new THREE.MeshBasicMaterial({ color: 0xff0000 });
const lambert = new THREE.MeshLambertMaterial({ color: 0x00ff00 });
const phong = new THREE.MeshPhongMaterial({ color: 0x0000ff });
// PBR materials (most realistic)
const standard = new THREE.MeshStandardMaterial({
color: 0xffffff,
metalness: 0.5,
roughness: 0.5,
map: texture,
normalMap: normalTexture,
envMap: environmentTexture,
});
const physical = new THREE.MeshPhysicalMaterial({
...standard,
clearcoat: 1.0,
transmission: 0.5,
thickness: 1.0,
});
Lights
javascript// Ambient (global illumination)
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
// Directional (sun-like)
const directional = new THREE.DirectionalLight(0xffffff, 1);
directional.position.set(1, 1, 1);
directional.castShadow = true;
// Point (bulb-like)
const point = new THREE.PointLight(0xffffff, 1, 100);
point.position.set(0, 10, 0);
// Spot (flashlight-like)
const spot = new THREE.SpotLight(0xffffff, 1, 100, Math.PI / 4);
Textures
javascript// Texture loading
const loader = new THREE.TextureLoader();
const texture = loader.load("path/to/texture.jpg");
// Texture properties
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(2, 2);
texture.flipY = false;
// HDR textures (loaders live in addons, not the core THREE namespace)
import { HDRLoader } from "three/addons/loaders/HDRLoader.js";
const hdrLoader = new HDRLoader();
const envMap = hdrLoader.load("environment.hdr");
envMap.mapping = THREE.EquirectangularReflectionMapping;
Object3D Fundamentals
Transform Properties
javascript// Position
object.position.set(x, y, z);
object.position.copy(otherObject.position);
object.translateX(distance);
// Rotation (Euler angles)
object.rotation.set(x, y, z);
object.rotation.y = Math.PI / 4;
object.rotateY(Math.PI / 4);
// Scale
object.scale.set(2, 2, 2);
object.scale.multiplyScalar(0.5);
// Quaternion (preferred for animations)
object.quaternion.setFromAxisAngle(axis, angle);
object.lookAt(target);
Hierarchy Operations
javascript// Adding/removing children
parent.add(child);
parent.remove(child);
scene.add(mesh, light, helper);
// Traversal
object.traverse((child) => {
if (child.isMesh) {
child.material.wireframe = true;
}
});
// Finding objects
const found = scene.getObjectByName("myObject");
const found = scene.getObjectById(id);
Math Utilities
Vectors
javascript// Vector3 (most common)
const v = new THREE.Vector3(1, 2, 3);
v.add(otherVector);
v.multiplyScalar(2);
v.normalize();
v.cross(otherVector);
v.dot(otherVector);
v.distanceTo(otherVector);
// Vector2 (UV coordinates)
const uv = new THREE.Vector2(0.5, 0.5);
Matrices
javascript// Matrix4 (transformations)
const matrix = new THREE.Matrix4();
matrix.makeTranslation(x, y, z);
matrix.makeRotationY(angle);
matrix.makeScale(x, y, z);
matrix.multiply(otherMatrix);
// Apply to object
object.applyMatrix4(matrix);
Colors
javascriptconst color = new THREE.Color();
color.set(0xff0000); // hex
color.setRGB(1, 0, 0); // RGB values 0-1
color.setHSL(0, 1, 0.5); // HSL values
color.lerp(targetColor, 0.1); // interpolation
Picking things in the scene (mouse interaction)
Selection has two halves: which objects the ray is allowed to hit, and which object a hit stands for. Keep both explicit and clicking a planet selects the planet rather than the glow around it.
Raycast against a selectables array, never the whole scene graph. The array
holds the things a person can pick, so lights, helpers, the skybox and every
decorative mesh are out of the answer before the ray is cast.
Resolve a hit up .parent to the first ancestor carrying userData.id. A hit
lands on whichever mesh the ray touched first, which is usually a child — a halo
shell, a label sprite, a bevel — so read the identity off the ancestor that owns
it instead of off the mesh that happened to be in front.
Decorative children opt out of hit testing with raycast = () => {}. A glow
shell exists to be seen and not to be picked, and an empty raycast removes it
from every intersection test at the source.
Show selection by changing a material property on the resolved parent. Adding
a new mesh to mark the selection adds another thing the ray can hit; changing
emissive, opacity or color on what is already there does not.
javascriptconst raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
// The things a person can pick. Everything else in the scene stays out of it.
const selectables = [];
function addPlanet(name) {
const planet = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color: 0x88aaff }));
planet.userData.id = name; // the identity a hit resolves to
const glow = new THREE.Mesh(glowGeometry, glowMaterial);
glow.raycast = () => {}; // decorative: never a hit
planet.add(glow);
scene.add(planet);
selectables.push(planet);
return planet;
}
// A hit lands on whichever mesh the ray touched — walk up to the owner of the id.
function selectableOf(object) {
let node = object;
while (node && node.userData?.id === undefined) node = node.parent;
return node ?? null;
}
function onPointerDown(event) {
const rect = renderer.domElement.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(selectables, true)[0];
const picked = hit ? selectableOf(hit.object) : null;
for (const s of selectables) s.material.emissive?.setHex(0x000000);
if (picked) picked.material.emissive?.setHex(0x333311); // a property change, not a new mesh
setSelectedId(picked?.userData.id ?? null);
}
Importing three
A bare package name at the top of the file resolves through the platform's esm.sh
map, and a full esm.sh URL loads too. The bare form follows whatever version the
platform serves, which moves as esm.sh publishes; a version-pinned URL such as
https://esm.sh/[email protected] is the lever for holding a scene on one release of
the library.
await import() defers a library until it is needed. Give it a full esm.sh URL,
which the browser resolves on its own — for a third-party library like three,
that is the form that both resolves and defers.
A bare specifier resolves through the page's import map, which carries the
packages the platform serves plus the ones your files import at the top. So a
bare name loads on demand for a platform package; for three, a name your file
also imports at the top is already loaded by the time the call runs, and a name
that appears only inside await import() has nothing to resolve through.
Animation System
Animation Mixer
javascript// For GLTF animations
const mixer = new THREE.AnimationMixer(model);
const action = mixer.clipAction(animationClip);
action.play();
// Update in render loop
function animate() {
const delta = clock.getDelta();
mixer.update(delta);
renderer.render(scene, camera);
}
Manual Animation
javascriptconst clock = new THREE.Clock();
function animate() {
const time = clock.getElapsedTime();
// Rotate object
mesh.rotation.y = time * 0.5;
// Oscillate position
mesh.position.y = Math.sin(time) * 2;
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
Loading Assets
GLTF Models (Recommended)
javascriptimport { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
const loader = new GLTFLoader();
loader.load("model.gltf", (gltf) => {
const model = gltf.scene;
scene.add(model);
// Access animations
if (gltf.animations.length > 0) {
const mixer = new THREE.AnimationMixer(model);
gltf.animations.forEach((clip) => {
mixer.clipAction(clip).play();
});
}
});
Other Loaders
javascript// OBJ files
import { OBJLoader } from "three/addons/loaders/OBJLoader.js";
// FBX files
import { FBXLoader } from "three/addons/loaders/FBXLoader.js";
// Textures
const textureLoader = new THREE.TextureLoader();
const cubeLoader = new THREE.CubeTextureLoader();
Renderer Configuration
Basic Setup
javascriptconst renderer = new THREE.WebGLRenderer({
canvas: canvasElement, // Existing canvas
antialias: true, // Smooth edges
alpha: true, // Transparent background
powerPreference: "high-performance",
});
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setClearColor(0x000000, 1);
Advanced Settings
javascript// Shadows
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// Tone mapping (HDR)
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
// Color space
renderer.outputColorSpace = THREE.SRGBColorSpace;
// Performance
renderer.setAnimationLoop(animate); // Preferred over requestAnimationFrame
Common Patterns
Responsive Canvas
javascriptfunction onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
window.addEventListener("resize", onWindowResize);
Performance Optimization
javascript// Frustum culling
object.frustumCulled = true;
// LOD (Level of Detail)
const lod = new THREE.LOD();
lod.addLevel(highDetailMesh, 0);
lod.addLevel(lowDetailMesh, 100);
// Instancing for many objects
const instancedMesh = new THREE.InstancedMesh(geometry, material, count);
const matrix = new THREE.Matrix4();
for (let i = 0; i < count; i++) {
matrix.setPosition(x, y, z);
instancedMesh.setMatrixAt(i, matrix);
}
instancedMesh.instanceMatrix.needsUpdate = true;
Dispose Pattern (Memory Management)
javascript// Clean up resources
geometry.dispose();
material.dispose();
texture.dispose();
renderer.dispose();
// Traverse and dispose
object.traverse((child) => {
if (child.geometry) child.geometry.dispose();
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach((m) => m.dispose());
} else {
child.material.dispose();
}
}
});
Buffer Attributes (Advanced)
Custom Geometry Data
javascriptconst geometry = new THREE.BufferGeometry();
// Vertex positions (required)
const positions = new Float32Array([
-1,
-1,
0, // vertex 0
1,
-1,
0, // vertex 1
0,
1,
0, // vertex 2
]);
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
// Vertex colors
const colors = new Float32Array([
1,
0,
0, // red
0,
1,
0, // green
0,
0,
1, // blue
]);
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
// Custom attributes for shaders
const customData = new Float32Array(vertexCount);
geometry.setAttribute("customAttribute", new THREE.BufferAttribute(customData, 1));
Events and Interaction
Event Dispatcher
javascript// Custom events
const emitter = new THREE.EventDispatcher();
emitter.addEventListener("customEvent", (event) => {
console.log("Event fired:", event.data);
});
emitter.dispatchEvent({ type: "customEvent", data: "hello" });
Built-in Events
javascript// Loading progress
loader.onProgress = (progress) => {
console.log(`Loading: ${(progress.loaded / progress.total) * 100}%`);
};
// Window resize
window.addEventListener("resize", onWindowResize);
// Mouse events
canvas.addEventListener("click", onMouseClick);
canvas.addEventListener("mousemove", onMouseMove);
Constants Reference
Material Constants
javascript// Blending modes
THREE.NormalBlending;
THREE.AdditiveBlending;
THREE.SubtractiveBlending;
THREE.MultiplyBlending;
// Culling
THREE.FrontSide;
THREE.BackSide;
THREE.DoubleSide;
// Depth modes
THREE.NeverDepth;
THREE.AlwaysDepth;
THREE.LessDepth;
THREE.LessEqualDepth;
Texture Constants
javascript// Wrapping
THREE.RepeatWrapping;
THREE.ClampToEdgeWrapping;
THREE.MirroredRepeatWrapping;
// Filtering
THREE.NearestFilter;
THREE.LinearFilter;
THREE.NearestMipmapNearestFilter;
THREE.LinearMipmapLinearFilter;
// Formats
THREE.RGBAFormat;
THREE.RGBFormat;
THREE.RedFormat;
Rendering Constants
javascript// Shadow types
THREE.BasicShadowMap;
THREE.PCFShadowMap;
THREE.PCFSoftShadowMap;
THREE.VSMShadowMap;
// Tone mapping
THREE.NoToneMapping;
THREE.LinearToneMapping;
THREE.ReinhardToneMapping;
THREE.CineonToneMapping;
THREE.ACESFilmicToneMapping;
Common Gotchas
Matrix Updates
javascript// Force matrix update after transform changes
object.updateMatrix();
object.updateMatrixWorld();
// Automatic updates (default: true)
object.matrixAutoUpdate = false; // Manual control
Geometry Modifications
javascript// After modifying geometry attributes
geometry.attributes.position.needsUpdate = true;
geometry.computeBoundingSphere();
geometry.computeBoundingBox();
Material Updates
javascript// After changing material properties
material.needsUpdate = true;
// Texture updates
texture.needsUpdate = true;
Performance Tips
Efficient Rendering
javascript// Batch similar objects
const geometry = new THREE.InstancedBufferGeometry();
const material = new THREE.MeshStandardMaterial();
const instancedMesh = new THREE.InstancedMesh(geometry, material, 1000);
// Freeze objects that don't move
object.matrixAutoUpdate = false;
object.updateMatrix();
// Use appropriate geometry detail
const sphere = new THREE.SphereGeometry(1, 8, 6); // Low poly
const sphere = new THREE.SphereGeometry(1, 32, 32); // High poly
Memory Management
javascript// Remove from scene
scene.remove(object);
// Dispose resources
object.traverse((child) => {
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
});
// Clear references
object = null;
Quick Reference
Essential Imports
javascript// Core
import * as THREE from "three";
// Controls
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { FlyControls } from "three/addons/controls/FlyControls.js";
// Loaders
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { OBJLoader } from "three/addons/loaders/OBJLoader.js";
// Post-processing
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
// Helpers
import { GUI } from "three/addons/libs/lil-gui.module.min.js";
import Stats from "three/addons/libs/stats.module.js";
Minimal Working Example
javascriptimport * as THREE from "three";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
camera.position.z = 5;
function animate() {
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
Three.js Condensed Guide: Most Impressive Examples
A curated collection of Three.js's most visually stunning and technically advanced examples
Quick Start Template
javascriptimport * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
// Basic setup
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Controls
const controls = new OrbitControls(camera, renderer.domElement);
camera.position.set(5, 5, 5);
controls.update();
// Animation loop
function animate() {
controls.update();
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
1. Spectacular Visual Effects
Galaxy Generator (WebGPU + TSL)
Creates a procedural spiral galaxy with thousands of animated particles.
javascriptimport * as THREE from "three/webgpu";
import { color, cos, sin, time, uniform, range, vec3, PI2 } from "three/tsl";
const material = new THREE.SpriteNodeMaterial({
depthWrite: false,
blending: THREE.AdditiveBlending,
});
// Procedural galaxy structure
const radiusRatio = range(0, 1);
const radius = radiusRatio.pow(1.5).mul(5);
const branches = 3;
const branchAngle = range(0, branches).floor().mul(PI2.div(branches));
const angle = branchAngle.add(time.mul(radiusRatio.oneMinus()));
const position = vec3(cos(angle), 0, sin(angle)).mul(radius);
material.positionNode = position.add(randomOffset);
// Dynamic colors
const colorInside = uniform(color("#ffa575"));
const colorOutside = uniform(color("#311599"));
material.colorNode = mix(colorInside, colorOutside, radiusRatio);
const galaxy = new THREE.InstancedMesh(new THREE.PlaneGeometry(1, 1), material, 20000);
Ocean Shaders
Realistic water simulation with dynamic waves and sky reflections.
javascriptimport { Water } from "three/addons/objects/Water.js";
import { Sky } from "three/addons/objects/Sky.js";
const waterGeometry = new THREE.PlaneGeometry(10000, 10000);
const water = new Water(waterGeometry, {
textureWidth: 512,
textureHeight: 512,
waterNormals: new THREE.TextureLoader().load("textures/waternormals.jpg"),
sunDirection: new THREE.Vector3(),
sunColor: 0xffffff,
waterColor: 0x001e0f,
distortionScale: 3.7,
});
// Sky system
const sky = new Sky();
sky.scale.setScalar(10000);
const skyUniforms = sky.material.uniforms;
skyUniforms["turbidity"].value = 10;
skyUniforms["rayleigh"].value = 2;
Unreal Bloom Effect
Cinematic glow and HDR post-processing.
javascriptimport { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
const composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.5, // strength
0.4, // radius
0.85 // threshold
);
composer.addPass(bloomPass);
// Render with bloom
composer.render();
2. Advanced GPU Computing
Flocking Birds (GPGPU)
GPU-accelerated boid simulation with emergent flocking behavior.
javascript// Position computation shader
const fragmentShaderPosition = `
uniform float time;
uniform float delta;
void main() {
vec2 uv = gl_FragCoord.xy / resolution.xy;
vec4 tmpPos = texture2D(texturePosition, uv);
vec3 position = tmpPos.xyz;
vec3 velocity = texture2D(textureVelocity, uv).xyz;
gl_FragColor = vec4(position + velocity * delta * 15.0, tmpPos.w);
}`;
// Velocity computation (separation, alignment, cohesion)
const fragmentShaderVelocity = `
uniform float separationDistance;
uniform float alignmentDistance;
uniform float cohesionDistance;
uniform vec3 predator;
void main() {
// Boid algorithm implementation
// ...separation, alignment, cohesion logic
}`;
Cloth Physics (WebGPU Compute)
Real-time fabric simulation using compute shaders.
javascriptimport { Fn, uniform, attribute, Loop } from "three/tsl";
// Verlet integration in compute shader
const computeVertexForces = Fn(() => {
const position = attribute("position");
const velocity = attribute("velocity");
// Spring forces, wind, gravity
const force = uniform("wind").add(uniform("gravity"));
// Verlet integration
const newPosition = position.add(velocity.mul(uniform("deltaTime")));
return newPosition;
})();
const clothMaterial = new THREE.MeshPhysicalMaterial({
color: 0x204080,
roughness: 0.8,
transmission: 0.2,
sheen: 0.5,
});
3. Impressive 3D Scenes
Photorealistic Car
Advanced PBR materials with interactive customization.
javascriptimport { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { HDRLoader } from "three/addons/loaders/HDRLoader.js";
// Environment setup
scene.environment = new HDRLoader().load("textures/equirectangular/venice_sunset_1k.hdr");
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.85;
// Load car model
const loader = new GLTFLoader();
const gltf = await loader.loadAsync("models/gltf/ferrari.glb");
// Material customization
gltf.scene.traverse((child) => {
if (child.isMesh && child.material.name === "body") {
child.material.color.setHex(bodyColor);
child.material.metalness = 1.0;
child.material.roughness = 0.5;
child.material.clearcoat = 1.0;
}
});
Minecraft World Generator
Procedural voxel terrain. For a block world, the voxel skill is the
authority: it draws each chunk as one InstancedMesh of the exposed blocks
(or Block Craft's per-face BufferGeometry batching) and never a Mesh per
block — follow it rather than the merge sketch below, which is only the noise
half. The same rule holds outside voxels: many things of one shape are one
InstancedMesh or Points, never a Mesh each, and nothing allocates a
geometry or material inside the frame loop.
javascriptimport { ImprovedNoise } from "three/addons/math/ImprovedNoise.js";
import * as BufferGeometryUtils from "three/addons/utils/BufferGeometryUtils.js";
function generateTerrain(width, depth) {
const noise = new ImprovedNoise();
const data = [];
for (let x = 0; x < width; x++) {
for (let z = 0; z < depth; z++) {
// Multi-octave noise
const height = noise.noise(x / 100, z / 100, 0) * 50 + noise.noise(x / 50, z / 50, 0) * 25;
data.push(Math.floor(height));
}
}
return data;
}
// Merge geometries for performance
const geometries = [];
// ...create individual cube geometries
const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries);
4. Interactive Experiences
VR Painting
Virtual reality 3D painting with hand tracking.
javascript// WebXR setup
renderer.xr.enabled = true;
document.body.appendChild(VRButton.createButton(renderer));
// Hand input
const controller1 = renderer.xr.getController(0);
const controller2 = renderer.xr.getController(1);
controller1.addEventListener("selectstart", onSelectStart);
controller1.addEventListener("selectend", onSelectEnd);
function onSelectStart(event) {
// Start painting stroke
const geometry = new THREE.BufferGeometry();
const material = new THREE.LineBasicMaterial({
color: currentColor,
linewidth: brushSize,
});
const line = new THREE.Line(geometry, material);
scene.add(line);
}
Physics Vehicle Controller
Real-time vehicle physics with Rapier.js integration.
javascriptimport { World } from "@dimforge/rapier3d-compat";
// Physics world
const world = new World({ x: 0, y: -9.81, z: 0 });
// Vehicle setup
const vehicleDesc = world.createRigidBody({
type: "dynamic",
translation: { x: 0, y: 1, z: 0 },
});
// Wheel constraints
wheels.forEach((wheel, index) => {
const wheelJoint = world.createImpulseJoint(vehicleDesc, wheel.body, wheelConstraints[index]);
});
5. Cutting-Edge WebGPU Features
Path Tracing
Realistic ray-traced lighting with global illumination.
javascriptimport { PathTracingRenderer } from "three/addons/renderers/PathTracingRenderer.js";
const ptRenderer = new PathTracingRenderer(renderer);
ptRenderer.setSize(window.innerWidth, window.innerHeight);
// Progressive rendering
let sampleCount = 0;
function animate() {
if (sampleCount < 1000) {
ptRenderer.update();
sampleCount++;
}
}
TSL (Three.js Shading Language)
Modern node-based shader programming.
javascriptimport { mix, noise, time, uv, vec3, sin, cos } from "three/tsl";
// Procedural materials with TSL
const proceduralMaterial = new THREE.MeshStandardNodeMaterial();
// Animated noise texture
const noiseValue = noise(uv().mul(10).add(time.mul(0.1)));
const colorA = vec3(1, 0.5, 0.2);
const colorB = vec3(0.2, 0.5, 1);
proceduralMaterial.colorNode = mix(colorA, colorB, noiseValue);
proceduralMaterial.roughnessNode = noiseValue.mul(0.5).add(0.3);
Performance Tips for Impressive Results
Instancing for Massive Scenes
javascriptconst instancedMesh = new THREE.InstancedMesh(geometry, material, 100000);
const matrix = new THREE.Matrix4();
for (let i = 0; i < instancedMesh.count; i++) {
matrix.setPosition(Math.random() * 2000 - 1000, Math.random() * 2000 - 1000, Math.random() * 2000 - 1000);
instancedMesh.setMatrixAt(i, matrix);
}
LOD for Complex Models
javascriptconst lod = new THREE.LOD();
lod.addLevel(highDetailMesh, 0);
lod.addLevel(mediumDetailMesh, 50);
lod.addLevel(lowDetailMesh, 200);
Render Targets for Effects
javascriptconst renderTarget = new THREE.WebGLRenderTarget(1024, 1024);
renderer.setRenderTarget(renderTarget);
renderer.render(effectScene, effectCamera);
renderer.setRenderTarget(null);
// Use render target as texture
material.map = renderTarget.texture;
Essential Setup for Maximum Impact
HDR Environment
javascriptimport { HDRLoader } from "three/addons/loaders/HDRLoader.js";
const hdrTexture = new HDRLoader().load("environment.hdr");
hdrTexture.mapping = THREE.EquirectangularReflectionMapping;
scene.environment = hdrTexture;
scene.background = hdrTexture;
Tone Mapping
javascriptrenderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
renderer.outputColorSpace = THREE.SRGBColorSpace;
Post-Processing Chain
javascriptimport { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new UnrealBloomPass(resolution, strength, radius, threshold));
composer.addPass(new OutputPass());
This guide focuses on Three.js's most impressive capabilities. Each example demonstrates advanced techniques that create visually stunning results with minimal code complexity.
Code Structure for Three.js + React Apps
Structure your component in this order:
- Three.js helper functions — scene setup, object creation, animation loops separate from React
- Hooks and state — useFireproof, useRef for canvas/scene, useState for UI controls
- Effects — useEffect to initialize scene on mount, update on parameter changes, cleanup on unmount
- ClassNames object — colors, sizes right before JSX so they stay consistent
- JSX return — canvas element with refs, UI controls referencing classNames
Real world example
javascriptimport React, { useState, useEffect, useRef, useCallback } from "react";
import { useFireproof } from "use-fireproof";
import * as THREE from "three";
import { useWorldControls } from "use-vibes";
import { Card, Heading, IconButton, Stack, Text } from "@vibes.diy/pouf";
export default function SkyGlider() {
const { database, useLiveQuery } = useFireproof("skyGliderScores");
const rootRef = useRef(null);
const canvasRef = useRef(null);
const [camera] = useState(() => new THREE.PerspectiveCamera(60, 1, 0.1, 1000));
const gameStateRef = useRef({
scene: null,
camera: null,
renderer: null,
glider: null,
clouds: [],
coins: [],
glowEffects: [],
smokeTrail: [],
lastSmokeTime: 0,
score: 0,
gameRunning: false,
stick: { x: 0, y: 0 },
velocity: { x: 0, y: 0, z: 0 },
heading: 0,
forwardSpeed: 0,
pitch: 0,
roll: 0,
});
const [currentScore, setCurrentScore] = useState(0);
const [menuOpen, setMenuOpen] = useState(false);
// The left thumb (or WASD) steers through useWorldControls, a flick up thrusts, and the hook
// keeps the lens fitted to the screen because it holds the camera.
const { keys, tick } = useWorldControls(rootRef, {
mode: "player",
flying: true,
camera,
onMove: (x, y) => Object.assign(gameStateRef.current.stick, { x, y }),
onFlick: (dir) => dir === "up" && (gameStateRef.current.forwardSpeed = 0.3),
});
gameStateRef.current.tick = tick;
gameStateRef.current.held = keys;
const { docs: scoreData } = useLiveQuery("type", { key: "score" }) || {
docs: [],
};
const saveScore = useCallback(
async (score) => {
await database.put({
_id: `score-${Date.now()}`,
type: "score",
value: score,
timestamp: Date.now(),
});
},
[database]
);
const createGlowEffect = useCallback((position) => {
const state = gameStateRef.current;
if (!state.scene) return;
const glowSphere = new THREE.Mesh(
new THREE.SphereGeometry(8, 16, 16),
new THREE.MeshBasicMaterial({
color: 0xffd670,
transparent: true,
opacity: 0.8,
})
);
glowSphere.position.copy(position);
state.scene.add(glowSphere);
const glowEffect = {
mesh: glowSphere,
createdAt: Date.now(),
scale: 1,
};
state.glowEffects.push(glowEffect);
// Remove after animation
setTimeout(() => {
state.scene.remove(glowSphere);
const index = state.glowEffects.indexOf(glowEffect);
if (index > -1) state.glowEffects.splice(index, 1);
}, 1000);
}, []);
const createSmokeCloud = useCallback((position) => {
const state = gameStateRef.current;
if (!state.scene) return;
const smokeGeometry = new THREE.SphereGeometry(0.1 + Math.random() * 0.05, 4, 3);
const smokeMaterial = new THREE.MeshLambertMaterial({
color: 0x242424,
transparent: true,
opacity: 0.7 + Math.random() * 0.2,
});
const smokeCloud = new THREE.Mesh(smokeGeometry, smokeMaterial);
// Position behind the glider
const heading = state.heading;
const offsetX = Math.sin(heading) * -4;
const offsetZ = Math.cos(heading) * -4;
smokeCloud.position.set(
position.x + offsetX + (Math.random() - 0.5) * 0.2,
position.y - 0.2 + (Math.random() - 0.5) * 0.1,
position.z + offsetZ + Math.random() * 0.3
);
state.scene.add(smokeCloud);
state.smokeTrail.push({
mesh: smokeCloud,
createdAt: Date.now(),
});
// Keep trail manageable
while (state.smokeTrail.length > 100) {
const oldSmoke = state.smokeTrail.shift();
state.scene.remove(oldSmoke.mesh);
}
}, []);
const createTexturedCoin = useCallback((scene, position) => {
// Create procedural gold texture
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 128;
const ctx = canvas.getContext("2d");
// Gold gradient
const gradient = ctx.createRadialGradient(64, 64, 20, 64, 64, 64);
gradient.addColorStop(0, "#ffd670");
gradient.addColorStop(0.5, "#ff9770");
gradient.addColorStop(1, "#ffb347");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 128, 128);
// Add metallic shine lines
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;
ctx.setLineDash([5, 3]);
for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(64 + Math.cos(angle) * 30, 64 + Math.sin(angle) * 30);
ctx.lineTo(64 + Math.cos(angle) * 50, 64 + Math.sin(angle) * 50);
ctx.stroke();
}
const texture = new THREE.CanvasTexture(canvas);
const coin = new THREE.Mesh(
new THREE.CylinderGeometry(2, 2, 0.3, 16),
new THREE.MeshStandardMaterial({
map: texture,
metalness: 0.8,
roughness: 0.2,
})
);
coin.position.copy(position);
coin.rotation.z = Math.PI / 2;
scene.add(coin);
return {
mesh: coin,
collected: false,
rotation: Math.random() * 0.02 + 0.01,
};
}, []);
const initThreeJS = useCallback(() => {
if (!canvasRef.current) return;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x70d6ff);
scene.fog = new THREE.Fog(0x70d6ff, 50, 300);
camera.position.set(0, 10, 20);
const renderer = new THREE.WebGLRenderer({
canvas: canvasRef.current,
antialias: true,
});
renderer.setSize(window.innerWidth, window.innerHeight);
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(50, 100, 50);
scene.add(directionalLight);
// Glider
const glider = new THREE.Group();
const body = new THREE.Mesh(new THREE.ConeGeometry(2, 8, 3), new THREE.MeshLambertMaterial({ color: 0xff70a6 }));
body.rotation.x = Math.PI / 2;
glider.add(body);
glider.position.set(0, 10, 0);
scene.add(glider);
// Create simple clouds
const clouds = [];
for (let i = 0; i < 30; i++) {
const cloud = new THREE.Mesh(
new THREE.SphereGeometry(Math.random() * 5 + 3, 8, 6),
new THREE.MeshLambertMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.7,
})
);
cloud.position.set((Math.random() - 0.5) * 400, Math.random() * 30 + 10, (Math.random() - 0.5) * 400);
scene.add(cloud);
clouds.push({
mesh: cloud,
drift: {
x: (Math.random() - 0.5) * 0.01,
y: 0,
z: (Math.random() - 0.5) * 0.01,
},
});
}
// Create initial coins
const coins = [];
for (let i = 0; i < 20; i++) {
const coin = createTexturedCoin(
scene,
new THREE.Vector3((Math.random() - 0.5) * 200, Math.random() * 40 + 10, (Math.random() - 0.5) * 200)
);
coins.push(coin);
}
gameStateRef.current = {
...gameStateRef.current,
scene,
camera,
renderer,
glider,
clouds,
coins,
lastSmokeTime: Date.now(),
heading: 0,
forwardSpeed: 0.1,
pitch: 0,
roll: 0,
};
// dt is the real frame time in seconds, clamped so a backgrounded tab resumes gently.
let last = performance.now();
const gameLoop = (now) => {
if (gameStateRef.current.gameRunning) {
const dt = Math.min((now - last) / 1000, 0.1);
last = now;
updateGame(dt);
requestAnimationFrame(gameLoop);
}
};
gameStateRef.current.gameRunning = true;
requestAnimationFrame(gameLoop);
}, [createTexturedCoin, camera]);
const checkCoinCollisions = useCallback(() => {
const state = gameStateRef.current;
if (!state.glider) return;
state.coins.forEach((coin) => {
if (!coin.collected) {
const distance = state.glider.position.distanceTo(coin.mesh.position);
if (distance < 4) {
coin.collected = true;
coin.mesh.visible = false;
createGlowEffect(coin.mesh.position);
state.score += 1;
setCurrentScore(state.score);
// Respawn coin at random location
setTimeout(() => {
coin.mesh.position.set((Math.random() - 0.5) * 200, Math.random() * 40 + 10, (Math.random() - 0.5) * 200);
coin.mesh.visible = true;
coin.collected = false;
}, 5000);
}
}
});
}, [createGlowEffect]);
const handleResize = useCallback(() => {
const state = gameStateRef.current;
if (!state.renderer) return;
state.renderer.setSize(window.innerWidth, window.innerHeight);
}, []);
const updateGame = useCallback((dt) => {
const state = gameStateRef.current;
if (!state.gameRunning || !state.glider) return;
const { stick, held, glider } = state;
state.tick(dt);
// Controls: the stick (thumb or WASD) banks and pitches; Space thrusts
state.heading -= stick.x * 0.03;
state.pitch -= stick.y * 0.01; // forward on the stick (y < 0) climbs
if (held.has("Space")) state.forwardSpeed = Math.min(0.3, state.forwardSpeed + 0.005);
// Physics
state.forwardSpeed = Math.max(0.05, state.forwardSpeed * 0.995);
state.velocity.x = Math.sin(state.heading) * Math.cos(state.pitch) * state.forwardSpeed;
state.velocity.y = Math.sin(-state.pitch) * state.forwardSpeed;
state.velocity.z = Math.cos(state.heading) * Math.cos(state.pitch) * state.forwardSpeed;
glider.position.add(new THREE.Vector3(state.velocity.x, state.velocity.y, state.velocity.z));
// Point glider in thrust vector direction
const thrustDirection = new THREE.Vector3(state.velocity.x, state.velocity.y, state.velocity.z).normalize();
if (thrustDirection.length() > 0) {
glider.lookAt(glider.position.clone().add(thrustDirection));
}
// Camera follow
const cameraDistance = 15;
state.camera.position.set(
glider.position.x - Math.sin(state.heading) * cameraDistance,
glider.position.y + 10,
glider.position.z - Math.cos(state.heading) * cameraDistance
);
state.camera.lookAt(glider.position);
// Create smoke trail
const currentTime = Date.now();
const timeSinceLastSmoke = currentTime - state.lastSmokeTime;
const smokeInterval = 150 + Math.random() * 200;
if (timeSinceLastSmoke > smokeInterval) {
createSmokeCloud(glider.position);
state.lastSmokeTime = currentTime;
}
// Animate
checkCoinCollisions();
state.coins.forEach((coin) => {
if (!coin.collected) coin.mesh.rotation.y += coin.rotation;
});
state.clouds.forEach((cloud) => {
cloud.mesh.position.add(new THREE.Vector3(cloud.drift.x, cloud.drift.y, cloud.drift.z));
});
// Animate glow effects
state.glowEffects.forEach((effect) => {
const age = Date.now() - effect.createdAt;
const progress = age / 1000;
effect.scale = 1 + progress * 2;
effect.mesh.scale.setScalar(effect.scale);
effect.mesh.material.opacity = 0.8 * (1 - progress);
});
// Fade smoke trail
state.smokeTrail.forEach((smoke) => {
const age = currentTime - smoke.createdAt;
const maxAge = 15000;
if (age > maxAge) {
smoke.mesh.material.opacity = 0;
} else if (age > 7500) {
const fadeProgress = (age - 7500) / 7500;
smoke.mesh.material.opacity = (0.7 + Math.random() * 0.2) * (1 - fadeProgress);
}
});
state.renderer.render(state.scene, state.camera);
}, [checkCoinCollisions, createSmokeCloud]);
useEffect(() => {
initThreeJS();
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
const state = gameStateRef.current;
state.gameRunning = false;
// Release GPU resources so remounts don't leak WebGL contexts
if (state.scene) {
state.scene.traverse((obj) => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
const materials = Array.isArray(obj.material) ? obj.material : [obj.material];
materials.forEach((material) => {
if (material.map) material.map.dispose(); // CanvasTextures, etc.
material.dispose();
});
}
});
}
if (state.renderer) state.renderer.dispose();
};
}, [initThreeJS, handleResize]);
return (
// Full screen by default: the game is the whole view. The score stays as a quiet HUD; the title,
// the controls legend and the high scores live behind ONE menu button, so nothing covers play.
<div
ref={rootRef}
className="relative h-dvh w-full overflow-hidden bg-background text-foreground font-sans select-none touch-none"
>
<canvas ref={canvasRef} className="absolute inset-0 select-none touch-none" />
<div className="absolute top-3 left-3 pointer-events-none">
<Text num>Score: {currentScore}</Text>
</div>
<div className="absolute top-3 right-3">
<IconButton icon="☰" label={menuOpen ? "Hide menu" : "Show menu"} onClick={() => setMenuOpen(!menuOpen)} />
</div>
{menuOpen && (
<div className="absolute top-16 right-3 w-72">
<Card>
<Stack gap={2}>
<Heading level={2}>Sky Glider</Heading>
<Text size="sm" muted>
Left thumb or WASD: steer · flick up or Space: thrust
</Text>
{scoreData
.sort((a, b) => b.value - a.value)
.slice(0, 3)
.map((score, i) => (
<Text key={score._id} num>
#{i + 1}: {score.value}
</Text>
))}
</Stack>
</Card>
</div>
)}
</div>
);
}
Visual effects example
javascriptimport React, { useState, useEffect, useRef, useCallback } from "react";
import { useFireproof } from "use-fireproof";
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { HalftonePass } from "three/addons/postprocessing/HalftonePass.js";
import { Button, Card, Field, Heading, Input, RowCard, Stack, Text } from "@vibes.diy/pouf";
import { Checkbox, Select, Separator, Slider } from "@vibes.diy/pouf/interactive";
export default function HalftoneArtStudio() {
const { database, useLiveQuery } = useFireproof("halftoneStudio");
const canvasRef = useRef(null);
const sceneRef = useRef(null);
const [currentPreset, setCurrentPreset] = useState(null);
const [presetName, setPresetName] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [showParameters, setShowParameters] = useState(false);
const { docs: presets } = useLiveQuery("type", { key: "preset" }) || {
docs: [],
};
const { docs: parameterHistory } = useLiveQuery("type", {
key: "parameter-state",
}) || {
docs: [],
};
const [parameters, setParameters] = useState({
shape: 1, // 1=Dot, 2=Ellipse, 3=Line, 4=Square
radius: 4,
rotateR: 15,
rotateG: 30,
rotateB: 45,
scatter: 0,
blending: 1,
blendingMode: 1, // 1=Linear, 2=Multiply, 3=Add, 4=Lighter, 5=Darker
greyscale: false,
disable: false,
objectCount: 25,
rotationSpeed: 1,
colorTheme: 0, // 0=Rainbow, 1=Warm, 2=Cool, 3=Monochrome
});
const saveParameterState = useCallback(
async (params, action = "manual") => {
await database.put({
_id: `param-state-${Date.now()}`,
type: "parameter-state",
parameters: { ...params },
action,
timestamp: Date.now(),
});
},
[database]
);
const savePreset = useCallback(async () => {
if (!presetName.trim()) return;
await database.put({
_id: `preset-${Date.now()}`,
type: "preset",
name: presetName,
parameters: { ...parameters },
timestamp: Date.now(),
});
setPresetName("");
}, [database, presetName, parameters]);
const loadPreset = useCallback((preset) => {
setParameters({ ...preset.parameters });
setCurrentPreset(preset);
}, []);
const loadParameterState = useCallback((state) => {
setParameters({ ...state.parameters });
}, []);
const generateRandomScene = useCallback(async () => {
setIsGenerating(true);
// Save current state before randomizing
await saveParameterState(parameters, "before-randomize");
// Generate random parameters
const newParams = {
shape: Math.floor(Math.random() * 4) + 1,
radius: Math.random() * 20 + 2,
rotateR: Math.random() * 90,
rotateG: Math.random() * 90,
rotateB: Math.random() * 90,
scatter: Math.random(),
blending: Math.random(),
blendingMode: Math.floor(Math.random() * 5) + 1,
greyscale: Math.random() > 0.7,
disable: false,
objectCount: Math.floor(Math.random() * 40) + 10,
rotationSpeed: Math.random() * 3 + 0.5,
colorTheme: Math.floor(Math.random() * 4),
};
setParameters(newParams);
// Save the new randomized state
setTimeout(async () => {
await saveParameterState(newParams, "randomized");
setIsGenerating(false);
}, 500);
}, [parameters, saveParameterState]);
// Save parameter changes for history
useEffect(() => {
const timeoutId = setTimeout(() => {
saveParameterState(parameters, "manual");
}, 1000);
return () => clearTimeout(timeoutId);
}, [parameters, saveParameterState]);
useEffect(() => {
if (!canvasRef.current) return;
// Scene setup
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x242424);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 1, 1000);
camera.position.z = 12;
const renderer = new THREE.WebGLRenderer({
canvas: canvasRef.current,
antialias: true,
preserveDrawingBuffer: true,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
// Controls
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.5;
// Group for all objects
const group = new THREE.Group();
scene.add(group);
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
const pointLight = new THREE.PointLight(0xffffff, 1, 100);
pointLight.position.set(10, 10, 10);
scene.add(pointLight);
// Post-processing
const composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
const halftonePass = new HalftonePass({
shape: parameters.shape,
radius: parameters.radius,
rotateR: parameters.rotateR * (Math.PI / 180),
rotateG: parameters.rotateG * (Math.PI / 180),
rotateB: parameters.rotateB * (Math.PI / 180),
scatter: parameters.scatter,
blending: parameters.blending,
blendingMode: parameters.blendingMode,
greyscale: parameters.greyscale,
disable: parameters.disable,
});
composer.addPass(halftonePass);
// Store refs
sceneRef.current = {
scene,
camera,
renderer,
composer,
halftonePass,
group,
controls,
objects: [],
};
// Create initial objects
const createObjects = () => {
// Clear existing objects
sceneRef.current.objects.forEach((obj) => {
group.remove(obj);
});
sceneRef.current.objects = [];
// Color themes
const colorThemes = [
[0xff70a6, 0x70d6ff, 0xffd670, 0xe9ff70, 0xff9770], // Rainbow
[0xff9770, 0xffd670, 0xff70a6], // Warm
[0x70d6ff, 0xe9ff70, 0x242424], // Cool
[0xffffff, 0x242424], // Monochrome
];
const colors = colorThemes[parameters.colorTheme] || colorThemes[0];
// Shader material for interesting effects
const material = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
},
vertexShader: `
varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vPosition;
uniform float time;
void main() {
vUv = uv;
vNormal = normalize(normalMatrix * normal);
vPosition = position;
vec3 pos = position;
pos += sin(pos * 2.0 + time) * 0.1;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`,
fragmentShader: `
varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vPosition;
uniform float time;
void main() {
vec3 color = abs(vNormal) + vec3(vUv, sin(time + vPosition.x));
color = mix(color, vec3(1.0, 0.4, 0.6), sin(time + vPosition.y) * 0.5 + 0.5);
gl_FragColor = vec4(color, 1.0);
}
`,
});
// Create various geometric shapes
const geometries = [
new THREE.BoxGeometry(2, 2, 2),
new THREE.SphereGeometry(1.2, 16, 16),
new THREE.ConeGeometry(1, 2, 8),
new THREE.CylinderGeometry(0.8, 0.8, 2, 8),
new THREE.OctahedronGeometry(1.2),
new THREE.TetrahedronGeometry(1.5),
new THREE.DodecahedronGeometry(1),
new THREE.IcosahedronGeometry(1.2),
];
for (let i = 0; i < parameters.objectCount; i++) {
const geometry = geometries[Math.floor(Math.random() * geometries.length)];
const basicMaterial = new THREE.MeshPhongMaterial({
color: colors[Math.floor(Math.random() * colors.length)],
shininess: 100,
transparent: true,
opacity: 0.8 + Math.random() * 0.2,
});
const mesh = new THREE.Mesh(geometry, Math.random() > 0.3 ? basicMaterial : material);
mesh.position.set((Math.random() - 0.5) * 20, (Math.random() - 0.5) * 20, (Math.random() - 0.5) * 20);
mesh.rotation.set(Math.random() * Math.PI * 2, Math.random() * Math.PI * 2, Math.random() * Math.PI * 2);
mesh.scale.setScalar(0.5 + Math.random() * 1.5);
group.add(mesh);
sceneRef.current.objects.push(mesh);
}
};
createObjects();
// Animation loop — capture the id so cleanup can cancel it. Because this
// effect re-runs on every `parameters` change, an uncancelled loop would
// keep calling composer.render() on a disposed renderer.
const clock = new THREE.Clock();
let animationId;
const animate = () => {
const delta = clock.getDelta();
const elapsed = clock.getElapsedTime();
// Update material uniforms
sceneRef.current.objects.forEach((obj) => {
if (obj.material.uniforms && obj.material.uniforms.time) {
obj.material.uniforms.time.value = elapsed;
}
// Animate objects
obj.rotation.x += delta * parameters.rotationSpeed * 0.2;
obj.rotation.y += delta * parameters.rotationSpeed * 0.3;
obj.rotation.z += delta * parameters.rotationSpeed * 0.1;
});
controls.update();
composer.render();
animationId = requestAnimationFrame(animate);
};
animate();
// Handle resize
const handleResize = () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
cancelAnimationFrame(animationId);
controls.dispose();
// Dispose every geometry + material we created (ShaderMaterials included)
sceneRef.current.objects.forEach((obj) => {
obj.geometry.dispose();
const materials = Array.isArray(obj.material) ? obj.material : [obj.material];
materials.forEach((material) => material.dispose());
});
// Dispose post-processing passes + the composer's render targets
composer.passes.forEach((pass) => pass.dispose?.());
composer.dispose();
renderer.dispose();
};
}, [parameters]);
// Update halftone parameters
useEffect(() => {
if (sceneRef.current?.halftonePass) {
const pass = sceneRef.current.halftonePass;
pass.uniforms.shape.value = parameters.shape;
pass.uniforms.radius.value = parameters.radius;
pass.uniforms.rotateR.value = parameters.rotateR * (Math.PI / 180);
pass.uniforms.rotateG.value = parameters.rotateG * (Math.PI / 180);
pass.uniforms.rotateB.value = parameters.rotateB * (Math.PI / 180);
pass.uniforms.scatter.value = parameters.scatter;
pass.uniforms.blending.value = parameters.blending;
pass.uniforms.blendingMode.value = parameters.blendingMode;
pass.uniforms.greyscale.value = parameters.greyscale;
pass.uniforms.disable.value = parameters.disable;
}
}, [parameters]);
const shapeName = ["", "Dot", "Ellipse", "Line", "Square"][parameters.shape] || "Dot";
const blendModeName = ["", "Linear", "Multiply", "Add", "Lighter", "Darker"][parameters.blendingMode] || "Linear";
const actionNames = {
"before-randomize": "Before Random",
randomized: "Randomized",
manual: "Manual Edit",
};
return (
<div className="relative h-dvh w-full overflow-hidden bg-background text-foreground font-sans">
<canvas ref={canvasRef} className="absolute inset-0 select-none touch-none" />
{/* One control panel. The pouf `Card` IS the panel — it takes no className,
so a plain wrapper carries its placement; the cushion, corner and ink are its own. */}
<div className="absolute inset-y-4 left-4 w-80 overflow-y-auto">
<Card>
<Stack gap={3}>
<Heading level={2}>RGB Halftone Studio</Heading>
<Button block loading={isGenerating} onClick={generateRandomScene}>
{isGenerating ? "Generating…" : "Random Art"}
</Button>
<Button block variant="quiet" aria-expanded={showParameters} onClick={() => setShowParameters(!showParameters)}>
{showParameters ? "Hide Controls" : "Show Controls"}
</Button>
{showParameters && (
<Stack gap={4}>
<Separator />
{/* A chooser is `Select`; its options are data. */}
<Field label={`Shape: ${shapeName}`}>
{(id, describedBy) => (
<Select
id={id}
describedBy={describedBy}
value={String(parameters.shape)}
onChange={(value) => setParameters((prev) => ({ ...prev, shape: parseInt(value) }))}
options={[
{ value: "1", label: "Dot" },
{ value: "2", label: "Ellipse" },
{ value: "3", label: "Line" },
{ value: "4", label: "Square" },
]}
/>
)}
</Field>
{/* A range is `Slider`, which hands over an array of values. Blend amount
and the per-channel R/G/B rotations are this same labelled Slider,
one per uniform. */}
<Stack gap={2}>
<Text size="sm">Size: {parameters.radius.toFixed(1)}</Text>
<Slider
label="Size"
min={1}
max={25}
step={0.5}
value={[parameters.radius]}
onChange={([radius]) => setParameters((prev) => ({ ...prev, radius }))}
/>
</Stack>
<Stack gap={2}>
<Text size="sm">Scatter: {(parameters.scatter * 100).toFixed(0)}%</Text>
<Slider
label="Scatter"
min={0}
max={1}
step={0.01}
value={[parameters.scatter]}
onChange={([scatter]) => setParameters((prev) => ({ ...prev, scatter }))}
/>
</Stack>
<Field label={`Blend Mode: ${blendModeName}`}>
{(id, describedBy) => (
<Select
id={id}
describedBy={describedBy}
value={String(parameters.blendingMode)}
onChange={(value) => setParameters((prev) => ({ ...prev, blendingMode: parseInt(value) }))}
options={[
{ value: "1", label: "Linear" },
{ value: "2", label: "Multiply" },
{ value: "3", label: "Add" },
{ value: "4", label: "Lighter" },
{ value: "5", label: "Darker" },
]}
/>
)}
</Field>
<Checkbox
label="Greyscale"
checked={parameters.greyscale}
onChange={(checked) => setParameters((prev) => ({ ...prev, greyscale: checked === true }))}
/>
<Checkbox
label="Disable Effect"
checked={parameters.disable}
onChange={(checked) => setParameters((prev) => ({ ...prev, disable: checked === true }))}
/>
<Field label="Preset name">
{(id, describedBy) => (
<Input id={id} describedBy={describedBy} placeholder="Preset name…" value={presetName} onChange={setPresetName} />
)}
</Field>
<Button block variant="quiet" onClick={savePreset} disabled={!presetName.trim()}>
Save Preset
</Button>
{/* A row that selects something is a `RowCard`, and which one is
chosen is its `selected` prop. */}
{presets.length > 0 && (
<Stack gap={2}>
<Heading level={3}>Saved Presets</Heading>
{presets
.sort((a, b) => b.timestamp - a.timestamp)
.map((preset) => (
<RowCard key={preset._id} selected={currentPreset?._id === preset._id} onClick={() => loadPreset(preset)}>
{preset.name} · {["", "Dot", "Ellipse", "Line", "Square"][preset.parameters.shape]}
</RowCard>
))}
</Stack>
)}
{parameterHistory.length > 0 && (
<Stack gap={2}>
<Heading level={3}>Parameter History</Heading>
{parameterHistory
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 10)
.map((state) => (
<RowCard key={state._id} onClick={() => loadParameterState(state)}>
{actionNames[state.action] || "Unknown"} · {new Date(state.timestamp).toLocaleTimeString()}
</RowCard>
))}
</Stack>
)}
</Stack>
)}
</Stack>
</Card>
</div>
</div>
);
}