3D ROCKFIRE
EXPLOSION EFFECT
Build a clickable 3D rockfire explosion in Three.js — jagged debris rocks blasted outward with real velocity, gravity, and bounce, wrapped in additive fire and sparks, drifting smoke, an expanding shockwave ring, a bright flash, and a jolt of camera shake.
LIVE DEMO
The rocks, fire, smoke, and shockwave firing behind this page are the live demo. Click anywhere, or press Space, to set off a rockfire blast. Scroll to keep reading.
Hides the page so you get the scene full screen — click or press Space to blast, press Esc to exit.
Introduction
A "rockfire" blast is the full-fat version of a particle explosion — not just a puff of embers, but chunks of rock hurled into the air, glowing fire and sparks, a roll of smoke, a shockwave ring on the ground, and a camera that kicks when it goes off. In this tutorial you'll build the whole thing in Three.js, and it still comes in at a single file you can copy and run.
The debris rides on a THREE.InstancedMesh — every rock shares one jagged geometry and renders in a single draw call, so dozens of them cost almost nothing. The fire, smoke, and sparks are three THREE.Points systems sharing one soft sprite texture. A shockwave RingGeometry, a flash sphere, a transient point light, and decaying camera shake sell the impact.
Learn the pattern once and reuse it everywhere — mortar impacts, demolitions, meteor strikes, boss deaths. It's all the same loop: launch the debris, spawn the particles, flash and shake, then let gravity and fade do the rest.
Frequently Asked Questions
How do you make an exploding rocks and fire effect in Three.js?
Launch a batch of 3D rock meshes outward from a center point with random velocity and spin, step them each frame under gravity with a floor bounce, and layer glowing fire, smoke, and spark particles plus an expanding shockwave ring over the top. In Three.js you draw all the rocks with a single InstancedMesh and the fire, smoke, and sparks with THREE.Points, so the whole blast stays fast.
How do you throw many 3D rocks efficiently in Three.js?
Use a THREE.InstancedMesh so every rock shares one geometry and one material and renders in a single draw call. Give each instance its own position, rotation, and scale by writing a matrix with a dummy Object3D each frame and setting instanceMatrix.needsUpdate = true. A displaced icosahedron makes a cheap jagged rock, and marking the instance matrix as DynamicDrawUsage tells the GPU it changes every frame.
How do you add fire, smoke, and sparks to a Three.js explosion?
Build three separate THREE.Points systems that share a soft radial-gradient sprite texture: fire and sparks use additive blending so they glow, while smoke uses normal blending so it reads as dark drifting haze. Each frame you move every mote, apply a little gravity, and fade its per-vertex color toward black as its life runs out, which gives the burst its natural rise-and-dissolve look.
How do you make a Three.js explosion feel powerful?
Stack a few punchy transient effects on the first frames of the blast: an expanding shockwave ring that scales outward and fades, a bright flash core sphere, a strong but short-lived point light, and camera shake that decays over about a second. Add an optional WebAudio boom made from a pitch-dropping sine plus filtered noise and the hit lands much harder than the particles alone.
How It Works
- Jagged rock geometry comes from an
IcosahedronGeometrywhose vertices are randomly displaced, so every rock looks hand-chipped. - Two InstancedMeshes draw the debris — plain rocks and emissive "hot" rocks — each in one draw call, with a per-instance matrix written every frame.
- launch() gives each rock a random outward-and-upward velocity plus a fast random spin when the blast fires.
- stepBodies() applies gravity, then bounces and damps each rock when it hits the floor so the debris settles naturally.
- Three Points systems — fire, smoke, sparks — share a soft sprite and fade their per-vertex color over each mote's life. Fire and sparks use additive blending to glow.
- A shockwave RingGeometry scales outward and fades in the first second, with a bright flash sphere and a transient point-light flare at the center.
- Camera shake decays each frame, ACES tone mapping and fog set the mood, and
blast()— wired to click, Space, and a button — resets every age and re-launches the whole thing.
Code Breakdown
Here is the whole effect in plain English. Beginners: read the bold summaries. Developers: the notes after each explain the why and the performance choices.
1. Renderer with tone mapping + import map
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.186.1/build/three.module.js" } }
</script>
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
In plain English: the import map lets you write import ... from "three" with no build step, and ACES tone mapping gives the fire a filmic, non-blown-out glow. Developer note: these modern APIs need a recent Three.js, so we pin 0.186.1 from a CDN rather than an old local build.
2. A jagged rock from a displaced icosahedron
function jaggedRock() {
const geo = new THREE.IcosahedronGeometry(1, 0);
const p = geo.attributes.position;
for (let i = 0; i < p.count; i++) {
const s = 0.72 + Math.random() * 0.5;
p.setXYZ(i, p.getX(i)*s, p.getY(i)*(0.65 + Math.random()*0.55), p.getZ(i)*s);
}
geo.computeVertexNormals();
return geo;
}
In plain English: start with a low-poly ball and shove each corner in or out by a random amount — instant chunky rock. Developer note: recomputing vertex normals after the displacement is what makes the lighting read the new faceted shape instead of a smooth sphere.
3. InstancedMesh debris + per-instance matrices
const rocksMesh = new THREE.InstancedMesh(rockGeo, rockMat, ROCKS);
rocksMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
const dummy = new THREE.Object3D();
function writeBodies(list, mesh) {
for (let i = 0; i < list.length; i++) {
const b = list[i];
dummy.position.set(b.px, b.py, b.pz);
dummy.rotation.set(b.rx, b.ry, b.rz);
dummy.scale.setScalar(b.scale);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
}
In plain English: all the rocks are one object. Each frame we use a throwaway Object3D to build each rock's transform and copy it into the instance. Developer note: DynamicDrawUsage hints that the matrix buffer changes every frame, and needsUpdate = true is what actually re-uploads it.
4. Debris physics — gravity + floor bounce
function stepBodies(list, dt) {
for (const b of list) {
b.vy += GRAV * dt;
b.px += b.vx*dt; b.py += b.vy*dt; b.pz += b.vz*dt;
const floorY = b.scale * 0.32;
if (b.py < floorY) { // hit the ground
b.py = floorY;
b.vy *= -0.28; // bounce, losing energy
b.vx *= 0.7; b.vz *= 0.7; // friction
}
b.rx += b.sx*dt; b.ry += b.sy*dt; b.rz += b.sz*dt; // tumble
}
}
In plain English: every rock falls under gravity, tumbles as it flies, and bounces with less energy each time it hits the floor until it comes to rest. Developer note: multiplying by dt keeps the sim frame-rate independent; the negative restitution (-0.28) plus horizontal friction is what makes debris "settle" instead of sliding forever.
5. Fire, smoke & sparks as THREE.Points
// one soft radial-gradient sprite, reused by all three systems
const flames = points(FLAME, softTex(...), 1.25, THREE.AdditiveBlending);
const smokes = points(SMOKE, softTex(...), 2.2, THREE.NormalBlending);
const sparks = points(SPARK, softTex(...), 0.16, THREE.AdditiveBlending);
function stepMotes(list, store, dt, grav, paint) {
for (let i = 0; i < list.length; i++) {
const m = list[i];
m.age += dt; m.vy += grav * dt;
m.px += m.vx*dt; m.py += m.vy*dt; m.pz += m.vz*dt;
const k = Math.max(0, 1 - m.age / m.life); // 1 -> 0 over its life
store.pos[i*3] = m.px; store.pos[i*3+1] = m.py; store.pos[i*3+2] = m.pz;
paint(m, k); // choose a color
store.col[i*3] = color.r*k; store.col[i*3+1] = color.g*k; store.col[i*3+2] = color.b*k;
}
}
In plain English: three clouds of dots — glowing fire, glowing sparks, and dark smoke — each dot drifting, slowing, and fading from bright to black as it ages. Developer note: additive blending makes fire and sparks stack into hot cores; normal blending keeps smoke readable. Multiplying the color by k fades brightness via vertex colors, so no per-particle opacity is needed.
6. blast() + the impact: shockwave, flash, shake
function blast() {
age = 0; shake = 1;
for (const b of rocks) launch(b, 5.4);
for (const b of hots) launch(b, 7.2);
flameP = makeMotes(FLAME, "flame"); // respawn particles
smokeP = makeMotes(SMOKE, "smoke");
sparkP = makeMotes(SPARK, "spark");
try { boom(); } catch {} // optional WebAudio hit
}
// each frame, driven by "age":
const flash = Math.max(0, 1 - age / 0.16);
core.material.opacity = flash;
fireLight.intensity = 0.35 + 9*Math.max(0, 1 - age/1.7) + flash*16;
ring.scale.setScalar(0.3 + Math.min(age/0.85, 1) * 13); // shockwave
camera.position.copy(camBase);
if (shake > 0.01) camera.position.x += (Math.random()-0.5) * shake * 0.38;
shake *= 0.9;
In plain English: firing the blast resets a timer; everything that sells the punch — the flash, the light flare, the expanding ring, and the shake — is just a function of how long ago it went off. Developer note: keying every transient to a single age value makes the whole impact perfectly repeatable and trivial to retrigger — one blast() call restarts it all.
The Full Code
Copy this into a file named index.html and open it in any modern browser — or hit Launch. Three.js loads from a CDN via the import map, so it runs as-is. Click anywhere, press Space, or hit the Blast button to detonate.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Rockfire</title>
<style>
html, body { margin: 0; height: 100%; background: #0c0d10; color: #f3eadc; overflow: hidden; }
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
header { position: absolute; top: 0; left: 0; right: 0; padding: 28px; pointer-events: none; }
h1 { margin: 0; font: 700 48px/1 Georgia, serif; }
p { margin: 10px 0 0; max-width: 22rem; font: 18px/1.4 system-ui, sans-serif; color: #b7aea0; }
.dock { position: absolute; left: 0; right: 0; bottom: 0; display: flex; justify-content: center; padding: 24px 20px 40px; }
button {
min-height: 64px; min-width: 192px; border: 0; border-radius: 999px;
background: #ff6a1a; color: #0c0d10; cursor: pointer;
font: 700 28px/1 Georgia, serif;
}
button:active { transform: scale(0.96); }
</style>
</head>
<body>
<canvas id="view"></canvas>
<header>
<h1>Rockfire</h1>
<p>Rocks, fire, smoke, and a shockwave. Hit blast to run it again.</p>
</header>
<div class="dock">
<button id="blast" type="button">Blast again</button>
</div>
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.186.1/build/three.module.js" } }
</script>
<script type="module">
import * as THREE from "three";
const ROCKS = 72, HOTS = 16, FLAME = 200, SMOKE = 120, SPARK = 160, GRAV = -9.4;
const canvas = document.getElementById("view");
let audioCtx = null;
function softTex(inner, mid) {
const c = document.createElement("canvas");
c.width = c.height = 128;
const g = c.getContext("2d");
const grd = g.createRadialGradient(64, 64, 2, 64, 64, 64);
grd.addColorStop(0, inner);
grd.addColorStop(0.4, mid);
grd.addColorStop(1, "rgba(0,0,0,0)");
g.fillStyle = grd;
g.fillRect(0, 0, 128, 128);
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
return t;
}
function jaggedRock() {
const geo = new THREE.IcosahedronGeometry(1, 0);
const p = geo.attributes.position;
for (let i = 0; i < p.count; i++) {
const s = 0.72 + Math.random() * 0.5;
p.setXYZ(i, p.getX(i) * s, p.getY(i) * (0.65 + Math.random() * 0.55), p.getZ(i) * s);
}
geo.computeVertexNormals();
return geo;
}
function pile(scale, hot) {
const ang = Math.random() * Math.PI * 2;
const rad = Math.random() * (hot ? 0.35 : 0.7);
return {
px: Math.cos(ang) * rad,
py: 0.15 + Math.random() * (hot ? 0.45 : 0.7),
pz: Math.sin(ang) * rad,
vx: 0, vy: 0, vz: 0,
rx: Math.random() * 6, ry: Math.random() * 6, rz: Math.random() * 6,
sx: (Math.random() - 0.5) * 0.4, sy: (Math.random() - 0.5) * 0.4, sz: (Math.random() - 0.5) * 0.4,
scale,
};
}
function launch(b, kick) {
const ang = Math.random() * Math.PI * 2;
const elev = 0.35 + Math.random() * 1.2;
const speed = kick + Math.random() * kick;
const dir = new THREE.Vector3(Math.cos(ang), elev, Math.sin(ang)).normalize();
b.vx = dir.x * speed; b.vy = dir.y * speed; b.vz = dir.z * speed;
b.sx = (Math.random() - 0.5) * 9;
b.sy = (Math.random() - 0.5) * 9;
b.sz = (Math.random() - 0.5) * 9;
}
function makeMotes(count, kind) {
const list = [];
for (let i = 0; i < count; i++) {
const ang = Math.random() * Math.PI * 2;
if (kind === "flame") {
const sp = 1.4 + Math.random() * 4.8;
list.push({
px: (Math.random() - 0.5) * 0.2, py: 0.35, pz: (Math.random() - 0.5) * 0.2,
vx: Math.cos(ang) * sp * 0.5, vy: 2.4 + Math.random() * 4.2, vz: Math.sin(ang) * sp * 0.5,
life: 0.4 + Math.random() * 0.7, age: 0, hot: Math.random(),
});
} else if (kind === "smoke") {
list.push({
px: (Math.random() - 0.5) * 0.35, py: 0.4, pz: (Math.random() - 0.5) * 0.35,
vx: Math.cos(ang) * (0.3 + Math.random()),
vy: 1.1 + Math.random() * 1.6,
vz: Math.sin(ang) * (0.3 + Math.random()),
life: 1.7 + Math.random() * 1.6, age: Math.random() * 0.15, hot: 0,
});
} else {
const elev = 0.45 + Math.random() * 1.35;
const sp = 7 + Math.random() * 11;
const len = Math.hypot(Math.cos(ang), elev, Math.sin(ang));
list.push({
px: 0, py: 0.45, pz: 0,
vx: (Math.cos(ang) / len) * sp, vy: (elev / len) * sp, vz: (Math.sin(ang) / len) * sp,
life: 0.45 + Math.random() * 0.85, age: 0, hot: 1,
});
}
}
return list;
}
function boom() {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return;
audioCtx = audioCtx || new Ctx();
if (audioCtx.state === "suspended") audioCtx.resume();
const now = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(110, now);
osc.frequency.exponentialRampToValueAtTime(32, now + 0.42);
gain.gain.setValueAtTime(0.32, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.55);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(now);
osc.stop(now + 0.6);
const buffer = audioCtx.createBuffer(1, audioCtx.sampleRate * 0.35, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / data.length);
const noise = audioCtx.createBufferSource();
noise.buffer = buffer;
const ng = audioCtx.createGain();
const filter = audioCtx.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.setValueAtTime(900, now);
filter.frequency.exponentialRampToValueAtTime(120, now + 0.3);
ng.gain.setValueAtTime(0.45, now);
ng.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
noise.connect(filter);
filter.connect(ng);
ng.connect(audioCtx.destination);
noise.start(now);
}
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
renderer.shadowMap.enabled = true;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0c0d10);
scene.fog = new THREE.FogExp2(0x0c0d10, 0.02);
const camera = new THREE.PerspectiveCamera(48, 1, 0.1, 80);
const camBase = new THREE.Vector3(6.4, 3.6, 8.6);
scene.add(new THREE.AmbientLight(0x2c3544, 0.4));
const moon = new THREE.DirectionalLight(0xb7c6da, 0.75);
moon.position.set(-7, 9, 5);
moon.castShadow = true;
scene.add(moon);
const fireLight = new THREE.PointLight(0xff6a1a, 0.4, 16, 1.7);
fireLight.position.set(0, 1.1, 0);
scene.add(fireLight);
const ground = new THREE.Mesh(
new THREE.CircleGeometry(16, 48),
new THREE.MeshStandardMaterial({ color: 0x3a3d36, roughness: 0.96, metalness: 0.02 }),
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
const scorch = new THREE.Mesh(
new THREE.CircleGeometry(1.1, 40),
new THREE.MeshBasicMaterial({ color: 0x140e0a, transparent: true, opacity: 0.15 }),
);
scorch.rotation.x = -Math.PI / 2;
scorch.position.y = 0.015;
scene.add(scorch);
const boulderGeo = jaggedRock();
const boulderMat = new THREE.MeshStandardMaterial({ color: 0x6a6258, roughness: 0.9 });
for (let i = 0; i < 5; i++) {
const m = new THREE.Mesh(boulderGeo, boulderMat);
const a = (i / 5) * Math.PI * 2 + 0.4;
m.position.set(Math.cos(a) * (3.4 + (i % 2) * 1.1), 0.28, Math.sin(a) * (3.4 + (i % 2) * 1.1));
m.scale.setScalar(0.45 + (i % 3) * 0.22);
m.rotation.set(i, i * 1.7, 0.2);
m.castShadow = true;
scene.add(m);
}
const rockMat = new THREE.MeshStandardMaterial({ color: 0x6e665c, roughness: 0.88, metalness: 0.04 });
const hotMat = new THREE.MeshStandardMaterial({
color: 0x4a3428, roughness: 0.5, emissive: new THREE.Color(0xff5a18), emissiveIntensity: 0.15,
});
const rockGeo = jaggedRock();
const rocksMesh = new THREE.InstancedMesh(rockGeo, rockMat, ROCKS);
const hotsMesh = new THREE.InstancedMesh(rockGeo, hotMat, HOTS);
rocksMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
hotsMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
scene.add(rocksMesh, hotsMesh);
const rocks = Array.from({ length: ROCKS }, () => pile(0.12 + Math.random() * 0.38, false));
const hots = Array.from({ length: HOTS }, () => pile(0.16 + Math.random() * 0.28, true));
function points(count, tex, size, blending) {
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(count * 3), 3));
geo.setAttribute("color", new THREE.BufferAttribute(new Float32Array(count * 3), 3));
const mat = new THREE.PointsMaterial({
size, map: tex, transparent: true, depthWrite: false, blending, vertexColors: true,
});
const pts = new THREE.Points(geo, mat);
pts.frustumCulled = false;
scene.add(pts);
return { pos: geo.attributes.position.array, col: geo.attributes.color.array, geo };
}
const flames = points(FLAME, softTex("rgba(255,246,214,1)", "rgba(255,90,16,0.5)"), 1.25, THREE.AdditiveBlending);
const smokes = points(SMOKE, softTex("rgba(90,84,76,0.45)", "rgba(40,36,32,0.12)"), 2.2, THREE.NormalBlending);
const sparks = points(SPARK, softTex("rgba(255,232,176,1)", "rgba(255,140,40,0.15)"), 0.16, THREE.AdditiveBlending);
let flameP = makeMotes(FLAME, "flame").map((m) => ({ ...m, age: m.life }));
let smokeP = makeMotes(SMOKE, "smoke").map((m) => ({ ...m, age: m.life }));
let sparkP = makeMotes(SPARK, "spark").map((m) => ({ ...m, age: m.life }));
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.25, 0.48, 64),
new THREE.MeshBasicMaterial({ color: 0xffc48a, side: THREE.DoubleSide, transparent: true, opacity: 0 }),
);
ring.rotation.x = -Math.PI / 2;
ring.position.y = 0.04;
scene.add(ring);
const core = new THREE.Mesh(
new THREE.SphereGeometry(0.4, 18, 14),
new THREE.MeshBasicMaterial({ color: 0xfff3d4, transparent: true, opacity: 0 }),
);
core.position.y = 0.55;
scene.add(core);
const dummy = new THREE.Object3D();
const color = new THREE.Color();
let age = 8;
let shake = 0;
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
function writeBodies(list, mesh) {
for (let i = 0; i < list.length; i++) {
const b = list[i];
dummy.position.set(b.px, b.py, b.pz);
dummy.rotation.set(b.rx, b.ry, b.rz);
dummy.scale.setScalar(b.scale);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
}
function stepBodies(list, dt) {
for (const b of list) {
b.vy += GRAV * dt;
b.px += b.vx * dt; b.py += b.vy * dt; b.pz += b.vz * dt;
const floorY = b.scale * 0.32;
if (b.py < floorY) {
b.py = floorY;
b.vy *= -0.28; b.vx *= 0.7; b.vz *= 0.7;
b.sx *= 0.55; b.sy *= 0.55; b.sz *= 0.55;
}
b.rx += b.sx * dt; b.ry += b.sy * dt; b.rz += b.sz * dt;
}
}
function stepMotes(list, store, dt, grav, paint) {
for (let i = 0; i < list.length; i++) {
const m = list[i];
m.age += dt;
m.vy += grav * dt;
m.px += m.vx * dt; m.py += m.vy * dt; m.pz += m.vz * dt;
const k = Math.max(0, 1 - m.age / m.life);
store.pos[i * 3] = m.px;
store.pos[i * 3 + 1] = m.py;
store.pos[i * 3 + 2] = m.pz;
paint(m, k);
store.col[i * 3] = color.r * k;
store.col[i * 3 + 1] = color.g * k;
store.col[i * 3 + 2] = color.b * k;
}
store.geo.attributes.position.needsUpdate = true;
store.geo.attributes.color.needsUpdate = true;
}
function blast() {
age = 0;
shake = reduceMotion ? 0 : 1;
for (const b of rocks) launch(b, 5.4);
for (const b of hots) launch(b, 7.2);
flameP = makeMotes(FLAME, "flame");
smokeP = makeMotes(SMOKE, "smoke");
sparkP = makeMotes(SPARK, "spark");
try { boom(); } catch { /* audio optional */ }
}
function resize() {
const w = canvas.clientWidth || 1;
const h = canvas.clientHeight || 1;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
let last = performance.now();
function frame(now) {
requestAnimationFrame(frame);
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
age += dt;
stepBodies(rocks, dt);
stepBodies(hots, dt);
writeBodies(rocks, rocksMesh);
writeBodies(hots, hotsMesh);
hotMat.emissiveIntensity = 0.15 + 1.5 * Math.max(0, 1 - age / 1.5);
stepMotes(flameP, flames, dt, GRAV * 0.22, (m, k) => {
color.set(k < 0.3 ? 0x5a1808 : m.hot > 0.45 ? 0xfff0c2 : 0xff6a1c);
});
stepMotes(smokeP, smokes, dt, GRAV * 0.08, () => color.set(0x6e675e));
stepMotes(sparkP, sparks, dt, GRAV, () => color.set(0xffd27a));
const flash = Math.max(0, 1 - age / 0.16);
core.material.opacity = flash;
core.scale.setScalar(1 + Math.min(age, 0.4) * 8);
fireLight.intensity = 0.35 + 9 * Math.max(0, 1 - age / 1.7) + flash * 16;
const wave = Math.min(age / 0.85, 1);
ring.scale.setScalar(0.3 + wave * 13);
ring.material.opacity = age < 0.9 ? (1 - wave) * 0.9 : 0;
if (age < 1.4) {
scorch.scale.setScalar(1 + Math.min(age, 1.1) * 3.1);
scorch.material.opacity = 0.82;
}
shake *= 0.9;
camera.position.copy(camBase);
if (shake > 0.01) {
camera.position.x += (Math.random() - 0.5) * shake * 0.38;
camera.position.y += (Math.random() - 0.5) * shake * 0.2;
}
camera.lookAt(0, 0.7, 0);
renderer.render(scene, camera);
}
resize();
writeBodies(rocks, rocksMesh);
writeBodies(hots, hotsMesh);
requestAnimationFrame(frame);
addEventListener("resize", resize);
document.getElementById("blast").addEventListener("click", (e) => { e.stopPropagation(); blast(); });
canvas.addEventListener("pointerdown", blast);
addEventListener("keydown", (e) => {
if (e.code === "Space" || e.code === "Enter") { e.preventDefault(); blast(); }
});
</script>
</body>
</html>
What To Try Next
- Tune the
ROCKS,FLAME,SMOKE, andSPARKcounts to scale the blast up or down for your target hardware. - Recolor the hot-rock
emissiveand the flame palette for lava-orange, toxic-green, or icy-blue variants. - Leave a lingering scorch decal on the ground after the shockwave fades instead of clearing it.
- Call
blast()from game events — a projectile hit or a boss death — instead of a click. - Swap the synthesized
boom()for a real explosion sound file loaded through the Web Audio API.
WANT THE SIMPLER PARTICLE VERSION?
If you want the lighter, pure-particle take — a single THREE.Points burst with velocity, gravity, and fade, under 100 lines — start with the original explosion tutorial, then come back here for the full rockfire treatment.
PLAY MY FREE BROWSER GAMES
Built with Three.js and JavaScript — free, no download, plays right in your browser.