THREE.JS PARTICLE SYSTEM
ROCKET ENGINE
By Shane Brumback · Updated · Free Three.js tutorial
A Three.js rocket engine particle system combines a glowing shader flame cone at the nozzle with pooled THREE.Points particles for sparks, smoke, and dust. In this demo a Falcon 9-style booster with its landing legs deployed fires a landing burn: smoothed thrust drives the flame length, the spark rate, and a dust ring that blasts across the pad at touchdown.
This free tutorial rebuilds the engine plume effect from my game Super Soldier Battle as one copy-paste file, broken down step by step.
LIVE DEMO
The Falcon 9-style booster behind this page is the live demo. It drops in with its landing legs deployed, fires its landing burn, and touches down on the pad — watch the flame stretch and the dust ring blast outward as it gets close to the ground.
Full screen with a throttle slider and orbit camera — press Esc to exit.
3D Rocket Plume Assets
Want a different look for your game? A pack of ready-to-drop-in Three.js rocket plume effects is coming soon to my 3D assets shop — Falcon 9 landing burns, blue-white shock-diamond cores, long orange burns, thick smoke trails, spark-heavy plumes, dusty touchdowns, sci-fi ion and plasma thrusters, gunship jets, and solid-rocket boosters. Each one is a copy-paste module you can tune.
Introduction
Rocket engines, jet thrusters, jump packs, and hovering gunships all need the same thing: a flame that looks hot at the nozzle, glows, flickers, and reacts when you push the throttle. A pile of sprites alone looks like a sparkler, and a single mesh alone looks like a traffic cone. The trick is to layer the two.
In this tutorial you build a shader flame cone for the solid core of the exhaust, then two particle pools on top: additive sparks and nozzle glow, and soft smoke for heat haze and the dust ring. It is the exact recipe from the engine effects in Super Soldier Battle, trimmed down to about 200 lines with no build tools — Three.js loads from a CDN.
What you should know first: a basic Three.js scene (renderer, camera, render loop). If you are brand new, try Simple Particle System first.
Frequently Asked Questions
How do you make a rocket engine flame in Three.js?
Draw the flame as an open cone (a CylinderGeometry with a wide top and narrow tip) attached to the nozzle, and give it a ShaderMaterial with additive blending. The fragment shader fades a white-blue hot core into orange and dark red along the cone, fades the edges with a fresnel term, and flickers the length every frame, so one cheap mesh reads as a glowing exhaust flame.
How do you make a rocket exhaust particle system in Three.js?
Keep a fixed pool of particles in one THREE.Points object with per-particle position, velocity, life, size, and color buffers. Each frame spawn new sparks at the nozzle moving down the engine axis, move and age every live particle, swap-remove dead ones, and copy the live ones into the GPU buffers. One additive pool handles glowing sparks and a second normal-blended pool handles smoke and dust, so the whole plume costs two draw calls.
How do you make the rocket flame react to throttle?
Store a throttle value from 0 to 1 and smooth the actual thrust toward a target every frame instead of jumping to it. The target rises with throttle and as the engine gets close to the ground, like a landing burn, and drops to zero at touchdown. Feed the smoothed thrust into the shader as a uniform that stretches the flame and brightens it, and use it to scale how many sparks spawn.
How do you add a ground dust ring under a rocket in Three.js?
Measure the nozzle height above the ground every frame. When it is low, spawn smoke particles in a circle at ground level with velocity pointing outward, and scale the count and size by thrust and how close the nozzle is. Lengthen the flame and add faint heat haze at the same time, and the engine looks like it is washing over the launch pad.
Is this the same rocket plume as Super Soldier Battle?
Yes. The flame cone shader, colors, flicker, shock-diamond bands, thrust smoothing, nozzle glow, plume sparks, heat haze, and dust ring are ported from the engine plume effect in Super Soldier Battle, my free Three.js browser game, and simplified into one standalone file.
How It Works
- A flame cone shader sits on the nozzle: blue-white core to orange to dark red, fresnel-faded edges, flicker, and scrolling shock diamonds.
- Two particle pools (THREE.Points) reuse a fixed set of particles — additive glow for sparks, normal blending for smoke and dust.
- Smoothed thrust follows the throttle and a hover boost near the ground, rising through the landing burn and cutting off at touchdown, and drives the flame and spawn rates.
- Ground effects — a longer flame, heat haze, and an outward dust ring — fade in as the nozzle nears the pad.
Code Breakdown
The full file is six short sections. Each step below shows the real code from the full example, what it does in plain English, and a note for developers.
Step 1: Scene, camera, renderer
In plain English: Set up a dark scene, a camera looking at the launch pad, and a renderer. OrbitControls let you spin the camera in full-screen demo mode.
Developer note: ACES filmic tone mapping keeps the additive flame from clipping to flat white. Controls start disabled so the page can scroll over the background scene.
const canvas = document.getElementById("bg-canvas");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.35;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0d18);
scene.fog = new THREE.Fog(0x0a0d18, 30, 90);
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 200);
camera.position.set(14, 7, 22);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 5, 0); controls.enableDamping = true; controls.enabled = false;
scene.add(new THREE.HemisphereLight(0x8899ff, 0x332211, 1.1));
const sun = new THREE.DirectionalLight(0xffffff, 1.8); sun.position.set(5, 10, 6); scene.add(sun);
const floor = new THREE.Mesh(new THREE.CircleGeometry(40, 64), new THREE.MeshStandardMaterial({ color: 0x2a2e3a, roughness: 0.95 }));
floor.rotation.x = -Math.PI / 2; scene.add(floor);
const pad = new THREE.Mesh(new THREE.RingGeometry(2.2, 2.6, 48), new THREE.MeshBasicMaterial({ color: 0x2ad1d6 }));
pad.rotation.x = -Math.PI / 2; pad.position.y = 0.01; scene.add(pad);
Step 2: Falcon 9-style booster with landing legs deployed
In plain English: Build a Falcon 9-style booster: a white tank with a sooty bottom, a black interstage, four grid fins, an engine bell, and four landing legs already deployed, all inside one group called engine so they move together.
Developer note: NOZZLE_Y and NOZZLE_R mark where the flame starts and how wide it is. Everything else - flame, sparks, dust - is positioned from those two numbers, so you can swap in any model. Each leg is a box stretched between a hinge point and a foot point with setFromUnitVectors, so moving the feet re-poses the legs.
const engine = new THREE.Group(); scene.add(engine);
const white = new THREE.MeshStandardMaterial({ color: 0xeeeef0, metalness: 0.2, roughness: 0.45 });
const soot = new THREE.MeshStandardMaterial({ color: 0x8c8c92, metalness: 0.2, roughness: 0.7 });
const black = new THREE.MeshStandardMaterial({ color: 0x1c1d22, metalness: 0.5, roughness: 0.5 });
const add = (geo, mat, y) => { const m = new THREE.Mesh(geo, mat); m.position.y = y; engine.add(m); return m; };
add(new THREE.CylinderGeometry(0.62, 0.62, 1.6, 32), soot, 1.2); // sooty lower tank after re-entry
add(new THREE.CylinderGeometry(0.62, 0.62, 5.4, 32), white, 4.7); // white first-stage tank
add(new THREE.CylinderGeometry(0.63, 0.63, 0.9, 32), black, 7.85); // black interstage on top
add(new THREE.CylinderGeometry(0.64, 0.6, 0.4, 32), black, 0.2); // octaweb engine section
const bell = add(new THREE.CylinderGeometry(0.22, 0.5, 0.6, 32, 1, true),
new THREE.MeshStandardMaterial({ color: 0x3a3f4a, metalness: 0.9, roughness: 0.3, side: THREE.DoubleSide }), -0.2);
for (let i = 0; i < 4; i++) {
const a = i * Math.PI / 2 + Math.PI / 4, ca = Math.cos(a), sa = Math.sin(a);
const fin = add(new THREE.BoxGeometry(0.5, 0.05, 0.45), black, 7.6); // grid fins
fin.position.set(ca * 0.85, 7.6, sa * 0.85); fin.rotation.y = -a;
// landing leg: hinge at the base of the tank, foot out on the ground
const hinge = new THREE.Vector3(ca * 0.62, 0.7, sa * 0.62), foot = new THREE.Vector3(ca * 2.1, -1.3, sa * 2.1);
const len = hinge.distanceTo(foot);
const leg = add(new THREE.BoxGeometry(0.14, len, 0.22), black, 0);
leg.position.copy(hinge).add(foot).multiplyScalar(0.5);
leg.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), hinge.clone().sub(foot).normalize());
const top = new THREE.Vector3(ca * 0.62, 2.1, sa * 0.62), mid = hinge.clone().lerp(foot, 0.55);
const strut = add(new THREE.CylinderGeometry(0.04, 0.04, top.distanceTo(mid), 8), black, 0); // telescoping strut
strut.position.copy(top).add(mid).multiplyScalar(0.5);
strut.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), top.clone().sub(mid).normalize());
add(new THREE.CylinderGeometry(0.28, 0.32, 0.08, 16), black, -1.26).position.set(foot.x, -1.26, foot.z); // foot pad
}
const NOZZLE_Y = -0.5, NOZZLE_R = 0.5; // where the flame starts, in engine-local units
const LANDED_Y = 1.32; // engine height when the feet touch the pad
Step 3: Flame cone shader (ported from the Super Soldier Battle gunship)
In plain English: The flame is an open cone with a custom shader. It is white-blue at the nozzle, orange in the middle, and dark red at the tip, and it flickers and shimmers like real exhaust.
Developer note: This is the engine flame from Super Soldier Battle. Additive blending plus a fresnel edge fade (pow(vF,1.4)) hides the cone silhouette; uThrust stretches the length; sin(vT*30 - t*60) scrolls shock-diamond bands; a second thinner cone adds a hot core.
const coneGeo = new THREE.CylinderGeometry(0.75, 0.12, 1, 24, 8, true);
coneGeo.translate(0, -0.5, 0); // nozzle at y=0, tail at y=-1
const flameMat = new THREE.ShaderMaterial({
uniforms: { uTime: { value: 0 }, uThrust: { value: 0.8 }, uSteady: { value: 1 } },
vertexShader: `uniform float uTime; uniform float uThrust; uniform float uSteady;
varying float vT; varying float vF;
void main(){
vec3 p = position; float t = -p.y; vT = t;
float fl = 0.86 + (0.1*sin(uTime*41.0) + 0.06*sin(uTime*23.0)) * uSteady; // flicker
p.y *= (0.35 + uThrust*1.9) * fl; // thrust = length
p.xz *= 1.0 + 0.09*t*sin(uTime*33.0 + t*14.0) * uSteady; // heat shimmer
vec4 mv = modelViewMatrix * vec4(p, 1.0);
vec3 n = normalize(normalMatrix * normal);
vF = abs(dot(n, normalize(-mv.xyz))); // fresnel-ish edge fade
gl_Position = projectionMatrix * mv; }`,
fragmentShader: `uniform float uTime; uniform float uThrust; uniform float uSteady;
varying float vT; varying float vF;
void main(){
vec3 hot = vec3(0.75,0.88,1.0), mid = vec3(1.0,0.5,0.15), tail = vec3(0.45,0.1,0.02);
vec3 c = mix(hot, mid, smoothstep(0.0,0.35,vT)); c = mix(c, tail, smoothstep(0.35,1.0,vT));
float a = pow(vF,1.4) * pow(1.0 - vT,1.3) * (0.15 + 0.85*uThrust) * 1.3;
a *= 0.85 + 0.15*sin(vT*30.0 - uTime*60.0) * uSteady; // shock-diamond bands
gl_FragColor = vec4(c * a, 1.0); }`,
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide
});
const flame = new THREE.Mesh(coneGeo, flameMat);
flame.position.y = NOZZLE_Y; flame.scale.set(NOZZLE_R * 1.35, NOZZLE_R * 3.2, NOZZLE_R * 1.35); engine.add(flame);
const flameLight = new THREE.PointLight(0xff8a3c, 0, 14, 1.6); flameLight.position.y = NOZZLE_Y - 0.8; engine.add(flameLight); // flame lights the pad + rocket
const inner = new THREE.Mesh(coneGeo, flameMat); // a second, tighter core cone
inner.position.y = NOZZLE_Y; inner.scale.set(NOZZLE_R * 0.7, NOZZLE_R * 3.2, NOZZLE_R * 0.7); engine.add(inner);
Step 4: Particle pools: glow sparks + smoke/dust (one draw call each)
In plain English: Instead of creating a new object for every spark, we keep a fixed pool of particles and reuse them. One pool glows (sparks), the other is soft smoke and dust.
Developer note: Each pool is a THREE.Points with position, color, and size buffers and a soft round point shader. Dead particles are swap-removed (the last live one moves into their slot) so updates stay O(live) with no allocations.
const VS = `attribute float aSize; attribute vec4 aColor; uniform float uScale; varying vec4 vColor;
void main(){ vec4 mv = modelViewMatrix * vec4(position,1.0); gl_Position = projectionMatrix * mv;
gl_PointSize = aSize * uScale / max(-mv.z, 0.2); vColor = aColor; }`;
const ADD_FS = `varying vec4 vColor;
void main(){ vec2 p = gl_PointCoord*2.0-1.0; float r2 = dot(p,p); if (r2 > 1.0) discard;
float core = exp(-r2*7.0), halo = exp(-r2*2.6)*0.45;
float e = (core + halo) * (1.0 - smoothstep(0.7,1.0,r2));
gl_FragColor = vec4(mix(vColor.rgb, vec3(1.0), core*0.55) * e * vColor.a, 1.0); }`;
const SMOKE_FS = `varying vec4 vColor;
void main(){ vec2 p = gl_PointCoord*2.0-1.0; float r = length(p); if (r > 1.0) discard;
float a = smoothstep(1.0, 0.1, r) * vColor.a; gl_FragColor = vec4(vColor.rgb, a); }`;
function makePool(max, additive) {
const g = new THREE.BufferGeometry();
const P = { max, n: 0, g, pos: new Float32Array(max * 3), vel: new Float32Array(max * 3), col: new Float32Array(max * 4),
size: new Float32Array(max), age: new Float32Array(max), life: new Float32Array(max),
s0: new Float32Array(max), s1: new Float32Array(max), a0: new Float32Array(max), rgb: new Float32Array(max * 3),
drag: new Float32Array(max), buoy: new Float32Array(max) };
g.setAttribute("position", new THREE.BufferAttribute(P.pos, 3).setUsage(THREE.DynamicDrawUsage));
g.setAttribute("aSize", new THREE.BufferAttribute(P.size, 1).setUsage(THREE.DynamicDrawUsage));
g.setAttribute("aColor", new THREE.BufferAttribute(P.col, 4).setUsage(THREE.DynamicDrawUsage));
const m = new THREE.ShaderMaterial({ uniforms: { uScale: { value: 500 } }, vertexShader: VS,
fragmentShader: additive ? ADD_FS : SMOKE_FS, transparent: true, depthWrite: false,
blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending });
const pts = new THREE.Points(g, m); pts.frustumCulled = false; pts.renderOrder = additive ? 11 : 10; scene.add(pts);
return P;
}
function spawn(P, x, y, z, vx, vy, vz, life, s0, s1, a, r, g, b, drag, buoy) {
if (P.n >= P.max) return; const i = P.n++, i3 = i * 3;
P.pos[i3] = x; P.pos[i3 + 1] = y; P.pos[i3 + 2] = z; P.vel[i3] = vx; P.vel[i3 + 1] = vy; P.vel[i3 + 2] = vz;
P.age[i] = 0; P.life[i] = life; P.s0[i] = s0; P.s1[i] = s1; P.a0[i] = a;
P.rgb[i3] = r; P.rgb[i3 + 1] = g; P.rgb[i3 + 2] = b; P.drag[i] = drag || 0; P.buoy[i] = buoy || 0;
}
function step(P, dt, smoky) {
let i = 0;
while (i < P.n) {
P.age[i] += dt; const k = P.age[i] / P.life[i];
if (k >= 1) { const j = --P.n; if (i !== j) copy(P, j, i); continue; } // swap-remove dead particle
const i3 = i * 3, i4 = i * 4, dr = Math.exp(-P.drag[i] * dt);
P.vel[i3] *= dr; P.vel[i3 + 1] *= dr; P.vel[i3 + 2] *= dr;
if (smoky) { P.vel[i3 + 1] += P.buoy[i] * (1 - k) * dt; P.vel[i3] += 0.35 * dt; } // rise + wind
P.pos[i3] += P.vel[i3] * dt; P.pos[i3 + 1] += P.vel[i3 + 1] * dt; P.pos[i3 + 2] += P.vel[i3 + 2] * dt;
P.size[i] = P.s0[i] + (P.s1[i] - P.s0[i]) * (1 - Math.pow(1 - k, 2.2));
const fadeIn = Math.min(1, k / (smoky ? 0.12 : 0.05));
P.col[i4] = P.rgb[i3]; P.col[i4 + 1] = P.rgb[i3 + 1]; P.col[i4 + 2] = P.rgb[i3 + 2];
P.col[i4 + 3] = P.a0[i] * fadeIn * Math.pow(1 - k, smoky ? 1.4 : 1.0);
i++;
}
P.g.setDrawRange(0, P.n);
P.g.attributes.position.needsUpdate = P.g.attributes.aSize.needsUpdate = P.g.attributes.aColor.needsUpdate = true;
}
function copy(P, j, i) {
for (const A of [P.pos, P.vel, P.rgb]) { A[i * 3] = A[j * 3]; A[i * 3 + 1] = A[j * 3 + 1]; A[i * 3 + 2] = A[j * 3 + 2]; }
for (const A of [P.age, P.life, P.s0, P.s1, P.a0, P.drag, P.buoy, P.size]) A[i] = A[j];
for (let c = 0; c < 4; c++) P.col[i * 4 + c] = P.col[j * 4 + c];
}
const glow = makePool(3000, true), smoke = makePool(3200, false);
Step 5: Throttle + hover flight
In plain English: Every frame the booster descends toward the pad on an easing curve, burns harder the closer it gets, then shuts off after touchdown - with a big glowing plume, sparks, a thick trail of billowing exhaust smoke full of sparkling embers, sparks skipping across the pad, heat haze, and a dust ring to match.
Developer note: Thrust is smoothed toward a target driven by throttle plus a hover boost near the ground, same as in Super Soldier Battle, and drops to zero on touchdown so the flame dies out smoothly. Sparks are shot along the engine's local down vector, so tilting the rocket tilts the plume.
const state = { throttle: 0.8, autoFly: true, thrust: 0.5, time: 0 };
const nozzle = new THREE.Vector3(), down = new THREE.Vector3();
let lastY = 0, vy = 0;
function touchdownBurst() { // one big billow of dust + smoke + sparks on pad impact
for (let i = 0; i < 260; i++) {
const a = Math.random() * Math.PI * 2, v = 3 + Math.random() * 9, rr = 0.4 + Math.random() * 1.5;
spawn(smoke, Math.cos(a) * rr, 0.3 + Math.random() * 0.6, Math.sin(a) * rr, Math.cos(a) * v, 0.6 + Math.random() * 2.2, Math.sin(a) * v,
2.2 + Math.random() * 1.8, 1.2, 6 + Math.random() * 5, 0.32, 0.7, 0.68, 0.66, 1.3, 1.1);
}
for (let i = 0; i < 160; i++) {
const a = Math.random() * Math.PI * 2, v = 8 + Math.random() * 12;
spawn(glow, Math.cos(a) * 0.5, 0.2, Math.sin(a) * 0.5, Math.cos(a) * v, 1 + Math.random() * 5, Math.sin(a) * v,
0.4 + Math.random() * 0.7, NOZZLE_R * 0.8, NOZZLE_R * 0.2, 1.0, 1.0, 0.6 + Math.random() * 0.3, 0.2, 2.0, 0);
}
}
function updateEngine(dt) {
state.time += dt;
let burning = true;
if (state.autoFly) { // landing loop: descend, touch down, shut off
const t = state.time % 9.5;
if (t < 5.5) { const k = 1 - t / 5.5; engine.position.y = LANDED_Y + 18 * Math.pow(k, 2.2); engine.rotation.z = 0.08 * k * Math.sin(state.time * 0.9); }
else { engine.position.y = LANDED_Y; engine.rotation.z = 0; burning = false; if (!state.landed) { state.landed = true; touchdownBurst(); } }
if (t < 5.5) state.landed = false;
}
vy += ((engine.position.y - lastY) / Math.max(dt, 1e-3) - vy) * Math.min(1, dt * 4); lastY = engine.position.y;
const agl = engine.position.y + NOZZLE_Y; // height of the nozzle above the pad
const hover = Math.max(0, 1 - agl / 4); // 1 = right on the ground
const target = burning ? state.throttle * (0.75 + 0.45 * hover) : 0; // landing burn gets stronger near the pad
state.thrust += (Math.max(0, Math.min(1.25, target)) - state.thrust) * Math.min(1, dt * 3);
const th = state.thrust;
flameMat.uniforms.uTime.value = state.time; flameMat.uniforms.uThrust.value = th;
flameLight.intensity = 6 + 14 * th * (0.9 + 0.1 * Math.sin(state.time * 41));
flame.scale.y = NOZZLE_R * 15 * (1 + 0.4 * hover); // big default plume, longer near the pad
inner.scale.y = NOZZLE_R * 8.4 * (1 + 0.4 * hover);
engine.updateMatrixWorld();
nozzle.set(0, NOZZLE_Y, 0).applyMatrix4(engine.matrixWorld);
down.set(0, -1, 0).applyQuaternion(engine.quaternion);
if (th < 0.03) return;
const r = NOZZLE_R;
spawn(glow, nozzle.x, nozzle.y, nozzle.z, 0, 0, 0, dt * 1.01, r * (6 + 3.5 * th), r * 6, 0.45 + 0.55 * th, 1.0, 0.9, 0.6); // nozzle glow (white-yellow tip)
spawn(glow, nozzle.x + down.x * r * 1.6, nozzle.y + down.y * r * 1.6, nozzle.z + down.z * r * 1.6, 0, 0, 0, dt * 1.01, r * 10, r * 10, 0.25 * th, 1.0, 0.55, 0.15); // orange bloom
let n = Math.floor((30 + 70 * th + 60 * hover) * dt + Math.random());
while (n-- > 0) { // plume sparks shot down the engine axis
const sp = (9 + 12 * th + 10 * hover) * 0.6;
spawn(glow, nozzle.x + (Math.random() - 0.5) * r, nozzle.y, nozzle.z + (Math.random() - 0.5) * r,
down.x * sp + (Math.random() - 0.5) * 0.6, down.y * sp, down.z * sp + (Math.random() - 0.5) * 0.6,
0.18 + 0.2 * Math.random() + 0.2 * hover, r * 2.8, r * (1.0 + 1.5 * hover), 0.45 * (0.3 + th), 1.0, 0.5 + Math.random() * 0.2, 0.2, 2, 0);
}
// exhaust smoke trailing out of the plume (Space Wow look): dense, warm-grey, billowing
let m = Math.floor((30 + 40 * th) * dt + Math.random());
while (m-- > 0) {
const sp = r * (6 + 4 * Math.random()), o = r * (3 + 3 * Math.random());
spawn(smoke, nozzle.x + down.x * o + (Math.random() - 0.5) * r, nozzle.y + down.y * o, nozzle.z + down.z * o + (Math.random() - 0.5) * r,
down.x * sp + (Math.random() - 0.5) * r * 2, down.y * sp + (Math.random() - 0.5) * r, down.z * sp + (Math.random() - 0.5) * r * 2,
1.8 + Math.random() * 1.2, r * 2.5, r * 11, 0.55 * th, 0.66, 0.6, 0.54, 1.0, 1.4);
}
// sparkling embers riding in the smoke: long-lived hot points that drift and fade out
let e = Math.floor((25 + 45 * th) * dt + Math.random());
while (e-- > 0) {
const o = r * (2 + 6 * Math.random()), sp = r * (4 + 6 * Math.random());
spawn(glow, nozzle.x + down.x * o + (Math.random() - 0.5) * r * 2, nozzle.y + down.y * o, nozzle.z + down.z * o + (Math.random() - 0.5) * r * 2,
down.x * sp + (Math.random() - 0.5) * 3, down.y * sp + (Math.random() - 0.3) * 2, down.z * sp + (Math.random() - 0.5) * 3,
0.6 + Math.random() * 0.9, r * (0.5 + Math.random() * 0.5), r * 0.15, 0.9, 1.0, 0.55 + Math.random() * 0.35, 0.15 + Math.random() * 0.2, 1.4, 0);
}
if (hover > 0.25 && Math.random() < dt * 6 * hover) // heat haze close to the ground
spawn(smoke, nozzle.x, nozzle.y - r, nozzle.z, down.x, down.y * 1.5, down.z, 0.9, r * 1.5, r * 5, 0.12 * hover, 0.8, 0.78, 0.75, 1.2, 0.4);
if (agl < 3.5) { // ground dust ring blasting outward
const gf = th * (1 - agl / 3.5);
let d = Math.floor((70 + 180 * gf) * dt + Math.random());
while (d-- > 0) {
const a = Math.random() * Math.PI * 2, rr = 0.6 + Math.random() * 1.2, v = (4 + 8 * gf) * 0.6;
spawn(smoke, nozzle.x + Math.cos(a) * rr, 0.25, nozzle.z + Math.sin(a) * rr, Math.cos(a) * v, 0.4 + Math.random() * 0.6, Math.sin(a) * v,
1.8 + Math.random() * 1.4, 1.2, 5.5 + 4 * gf, 0.5 * gf, 0.72, 0.66, 0.6, 1.6, 0.6);
}
let k = Math.floor(90 * gf * dt + Math.random()); // sparks skipping across the pad
while (k-- > 0) {
const a = Math.random() * Math.PI * 2, v = 6 + 10 * Math.random();
spawn(glow, nozzle.x + Math.cos(a) * 0.5, 0.15, nozzle.z + Math.sin(a) * 0.5, Math.cos(a) * v, 1 + Math.random() * 3, Math.sin(a) * v,
0.35 + Math.random() * 0.5, r * 0.7, r * 0.2, 1.0, 1.0, 0.6 + Math.random() * 0.3, 0.2, 2.2, 0);
}
}
}
Step 6: Render loop
In plain English: Run the update and draw the scene about 60 times a second, and keep it sized to the window.
Developer note: dt is clamped to 0.05 s so a background tab does not dump a huge burst of particles when you come back.
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
updateEngine(dt); step(glow, dt, false); step(smoke, dt, true);
controls.update(); renderer.render(scene, camera);
}
animate();
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
The Full Code
Save this as rocket-engine.html and open it, or press Launch. No installs — it loads Three.js 0.186.1 from unpkg.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Three.js Rocket Engine Particle System</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #05060c; font-family: sans-serif; }
#bg-canvas { display: block; width: 100%; height: 100%; touch-action: none; }
#ui { position: fixed; left: 12px; bottom: 12px; padding: 10px 14px; border-radius: 10px; background: rgba(0,0,0,0.6); color: #ddd; font-size: 13px; }
#ui label { display: block; margin: 4px 0; }
</style>
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.186.1/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.186.1/examples/jsm/" } }
</script>
</head>
<body>
<canvas id="bg-canvas"></canvas>
<div id="ui">
<label>Throttle <input id="throttle" type="range" min="0" max="120" value="80"></label>
<label><input id="autofly" type="checkbox" checked> Auto landing loop (off = hover)</label>
</div>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
// ---------- 1. Scene, camera, renderer ----------
const canvas = document.getElementById("bg-canvas");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.35;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0d18);
scene.fog = new THREE.Fog(0x0a0d18, 30, 90);
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 200);
camera.position.set(14, 7, 22);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 5, 0); controls.enableDamping = true; controls.enabled = false;
scene.add(new THREE.HemisphereLight(0x8899ff, 0x332211, 1.1));
const sun = new THREE.DirectionalLight(0xffffff, 1.8); sun.position.set(5, 10, 6); scene.add(sun);
const floor = new THREE.Mesh(new THREE.CircleGeometry(40, 64), new THREE.MeshStandardMaterial({ color: 0x2a2e3a, roughness: 0.95 }));
floor.rotation.x = -Math.PI / 2; scene.add(floor);
const pad = new THREE.Mesh(new THREE.RingGeometry(2.2, 2.6, 48), new THREE.MeshBasicMaterial({ color: 0x2ad1d6 }));
pad.rotation.x = -Math.PI / 2; pad.position.y = 0.01; scene.add(pad);
// ---------- 2. Falcon 9-style booster with landing legs deployed ----------
const engine = new THREE.Group(); scene.add(engine);
const white = new THREE.MeshStandardMaterial({ color: 0xeeeef0, metalness: 0.2, roughness: 0.45 });
const soot = new THREE.MeshStandardMaterial({ color: 0x8c8c92, metalness: 0.2, roughness: 0.7 });
const black = new THREE.MeshStandardMaterial({ color: 0x1c1d22, metalness: 0.5, roughness: 0.5 });
const add = (geo, mat, y) => { const m = new THREE.Mesh(geo, mat); m.position.y = y; engine.add(m); return m; };
add(new THREE.CylinderGeometry(0.62, 0.62, 1.6, 32), soot, 1.2); // sooty lower tank after re-entry
add(new THREE.CylinderGeometry(0.62, 0.62, 5.4, 32), white, 4.7); // white first-stage tank
add(new THREE.CylinderGeometry(0.63, 0.63, 0.9, 32), black, 7.85); // black interstage on top
add(new THREE.CylinderGeometry(0.64, 0.6, 0.4, 32), black, 0.2); // octaweb engine section
const bell = add(new THREE.CylinderGeometry(0.22, 0.5, 0.6, 32, 1, true),
new THREE.MeshStandardMaterial({ color: 0x3a3f4a, metalness: 0.9, roughness: 0.3, side: THREE.DoubleSide }), -0.2);
for (let i = 0; i < 4; i++) {
const a = i * Math.PI / 2 + Math.PI / 4, ca = Math.cos(a), sa = Math.sin(a);
const fin = add(new THREE.BoxGeometry(0.5, 0.05, 0.45), black, 7.6); // grid fins
fin.position.set(ca * 0.85, 7.6, sa * 0.85); fin.rotation.y = -a;
// landing leg: hinge at the base of the tank, foot out on the ground
const hinge = new THREE.Vector3(ca * 0.62, 0.7, sa * 0.62), foot = new THREE.Vector3(ca * 2.1, -1.3, sa * 2.1);
const len = hinge.distanceTo(foot);
const leg = add(new THREE.BoxGeometry(0.14, len, 0.22), black, 0);
leg.position.copy(hinge).add(foot).multiplyScalar(0.5);
leg.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), hinge.clone().sub(foot).normalize());
const top = new THREE.Vector3(ca * 0.62, 2.1, sa * 0.62), mid = hinge.clone().lerp(foot, 0.55);
const strut = add(new THREE.CylinderGeometry(0.04, 0.04, top.distanceTo(mid), 8), black, 0); // telescoping strut
strut.position.copy(top).add(mid).multiplyScalar(0.5);
strut.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), top.clone().sub(mid).normalize());
add(new THREE.CylinderGeometry(0.28, 0.32, 0.08, 16), black, -1.26).position.set(foot.x, -1.26, foot.z); // foot pad
}
const NOZZLE_Y = -0.5, NOZZLE_R = 0.5; // where the flame starts, in engine-local units
const LANDED_Y = 1.32; // engine height when the feet touch the pad
// ---------- 3. Flame cone shader (ported from the Super Soldier Battle gunship) ----------
const coneGeo = new THREE.CylinderGeometry(0.75, 0.12, 1, 24, 8, true);
coneGeo.translate(0, -0.5, 0); // nozzle at y=0, tail at y=-1
const flameMat = new THREE.ShaderMaterial({
uniforms: { uTime: { value: 0 }, uThrust: { value: 0.8 }, uSteady: { value: 1 } },
vertexShader: `uniform float uTime; uniform float uThrust; uniform float uSteady;
varying float vT; varying float vF;
void main(){
vec3 p = position; float t = -p.y; vT = t;
float fl = 0.86 + (0.1*sin(uTime*41.0) + 0.06*sin(uTime*23.0)) * uSteady; // flicker
p.y *= (0.35 + uThrust*1.9) * fl; // thrust = length
p.xz *= 1.0 + 0.09*t*sin(uTime*33.0 + t*14.0) * uSteady; // heat shimmer
vec4 mv = modelViewMatrix * vec4(p, 1.0);
vec3 n = normalize(normalMatrix * normal);
vF = abs(dot(n, normalize(-mv.xyz))); // fresnel-ish edge fade
gl_Position = projectionMatrix * mv; }`,
fragmentShader: `uniform float uTime; uniform float uThrust; uniform float uSteady;
varying float vT; varying float vF;
void main(){
vec3 hot = vec3(0.75,0.88,1.0), mid = vec3(1.0,0.5,0.15), tail = vec3(0.45,0.1,0.02);
vec3 c = mix(hot, mid, smoothstep(0.0,0.35,vT)); c = mix(c, tail, smoothstep(0.35,1.0,vT));
float a = pow(vF,1.4) * pow(1.0 - vT,1.3) * (0.15 + 0.85*uThrust) * 1.3;
a *= 0.85 + 0.15*sin(vT*30.0 - uTime*60.0) * uSteady; // shock-diamond bands
gl_FragColor = vec4(c * a, 1.0); }`,
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide
});
const flame = new THREE.Mesh(coneGeo, flameMat);
flame.position.y = NOZZLE_Y; flame.scale.set(NOZZLE_R * 1.35, NOZZLE_R * 3.2, NOZZLE_R * 1.35); engine.add(flame);
const flameLight = new THREE.PointLight(0xff8a3c, 0, 14, 1.6); flameLight.position.y = NOZZLE_Y - 0.8; engine.add(flameLight); // flame lights the pad + rocket
const inner = new THREE.Mesh(coneGeo, flameMat); // a second, tighter core cone
inner.position.y = NOZZLE_Y; inner.scale.set(NOZZLE_R * 0.7, NOZZLE_R * 3.2, NOZZLE_R * 0.7); engine.add(inner);
// ---------- 4. Particle pools: glow sparks + smoke/dust (one draw call each) ----------
const VS = `attribute float aSize; attribute vec4 aColor; uniform float uScale; varying vec4 vColor;
void main(){ vec4 mv = modelViewMatrix * vec4(position,1.0); gl_Position = projectionMatrix * mv;
gl_PointSize = aSize * uScale / max(-mv.z, 0.2); vColor = aColor; }`;
const ADD_FS = `varying vec4 vColor;
void main(){ vec2 p = gl_PointCoord*2.0-1.0; float r2 = dot(p,p); if (r2 > 1.0) discard;
float core = exp(-r2*7.0), halo = exp(-r2*2.6)*0.45;
float e = (core + halo) * (1.0 - smoothstep(0.7,1.0,r2));
gl_FragColor = vec4(mix(vColor.rgb, vec3(1.0), core*0.55) * e * vColor.a, 1.0); }`;
const SMOKE_FS = `varying vec4 vColor;
void main(){ vec2 p = gl_PointCoord*2.0-1.0; float r = length(p); if (r > 1.0) discard;
float a = smoothstep(1.0, 0.1, r) * vColor.a; gl_FragColor = vec4(vColor.rgb, a); }`;
function makePool(max, additive) {
const g = new THREE.BufferGeometry();
const P = { max, n: 0, g, pos: new Float32Array(max * 3), vel: new Float32Array(max * 3), col: new Float32Array(max * 4),
size: new Float32Array(max), age: new Float32Array(max), life: new Float32Array(max),
s0: new Float32Array(max), s1: new Float32Array(max), a0: new Float32Array(max), rgb: new Float32Array(max * 3),
drag: new Float32Array(max), buoy: new Float32Array(max) };
g.setAttribute("position", new THREE.BufferAttribute(P.pos, 3).setUsage(THREE.DynamicDrawUsage));
g.setAttribute("aSize", new THREE.BufferAttribute(P.size, 1).setUsage(THREE.DynamicDrawUsage));
g.setAttribute("aColor", new THREE.BufferAttribute(P.col, 4).setUsage(THREE.DynamicDrawUsage));
const m = new THREE.ShaderMaterial({ uniforms: { uScale: { value: 500 } }, vertexShader: VS,
fragmentShader: additive ? ADD_FS : SMOKE_FS, transparent: true, depthWrite: false,
blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending });
const pts = new THREE.Points(g, m); pts.frustumCulled = false; pts.renderOrder = additive ? 11 : 10; scene.add(pts);
return P;
}
function spawn(P, x, y, z, vx, vy, vz, life, s0, s1, a, r, g, b, drag, buoy) {
if (P.n >= P.max) return; const i = P.n++, i3 = i * 3;
P.pos[i3] = x; P.pos[i3 + 1] = y; P.pos[i3 + 2] = z; P.vel[i3] = vx; P.vel[i3 + 1] = vy; P.vel[i3 + 2] = vz;
P.age[i] = 0; P.life[i] = life; P.s0[i] = s0; P.s1[i] = s1; P.a0[i] = a;
P.rgb[i3] = r; P.rgb[i3 + 1] = g; P.rgb[i3 + 2] = b; P.drag[i] = drag || 0; P.buoy[i] = buoy || 0;
}
function step(P, dt, smoky) {
let i = 0;
while (i < P.n) {
P.age[i] += dt; const k = P.age[i] / P.life[i];
if (k >= 1) { const j = --P.n; if (i !== j) copy(P, j, i); continue; } // swap-remove dead particle
const i3 = i * 3, i4 = i * 4, dr = Math.exp(-P.drag[i] * dt);
P.vel[i3] *= dr; P.vel[i3 + 1] *= dr; P.vel[i3 + 2] *= dr;
if (smoky) { P.vel[i3 + 1] += P.buoy[i] * (1 - k) * dt; P.vel[i3] += 0.35 * dt; } // rise + wind
P.pos[i3] += P.vel[i3] * dt; P.pos[i3 + 1] += P.vel[i3 + 1] * dt; P.pos[i3 + 2] += P.vel[i3 + 2] * dt;
P.size[i] = P.s0[i] + (P.s1[i] - P.s0[i]) * (1 - Math.pow(1 - k, 2.2));
const fadeIn = Math.min(1, k / (smoky ? 0.12 : 0.05));
P.col[i4] = P.rgb[i3]; P.col[i4 + 1] = P.rgb[i3 + 1]; P.col[i4 + 2] = P.rgb[i3 + 2];
P.col[i4 + 3] = P.a0[i] * fadeIn * Math.pow(1 - k, smoky ? 1.4 : 1.0);
i++;
}
P.g.setDrawRange(0, P.n);
P.g.attributes.position.needsUpdate = P.g.attributes.aSize.needsUpdate = P.g.attributes.aColor.needsUpdate = true;
}
function copy(P, j, i) {
for (const A of [P.pos, P.vel, P.rgb]) { A[i * 3] = A[j * 3]; A[i * 3 + 1] = A[j * 3 + 1]; A[i * 3 + 2] = A[j * 3 + 2]; }
for (const A of [P.age, P.life, P.s0, P.s1, P.a0, P.drag, P.buoy, P.size]) A[i] = A[j];
for (let c = 0; c < 4; c++) P.col[i * 4 + c] = P.col[j * 4 + c];
}
const glow = makePool(3000, true), smoke = makePool(3200, false);
// ---------- 5. Throttle + hover flight ----------
const state = { throttle: 0.8, autoFly: true, thrust: 0.5, time: 0 };
const nozzle = new THREE.Vector3(), down = new THREE.Vector3();
let lastY = 0, vy = 0;
function touchdownBurst() { // one big billow of dust + smoke + sparks on pad impact
for (let i = 0; i < 260; i++) {
const a = Math.random() * Math.PI * 2, v = 3 + Math.random() * 9, rr = 0.4 + Math.random() * 1.5;
spawn(smoke, Math.cos(a) * rr, 0.3 + Math.random() * 0.6, Math.sin(a) * rr, Math.cos(a) * v, 0.6 + Math.random() * 2.2, Math.sin(a) * v,
2.2 + Math.random() * 1.8, 1.2, 6 + Math.random() * 5, 0.32, 0.7, 0.68, 0.66, 1.3, 1.1);
}
for (let i = 0; i < 160; i++) {
const a = Math.random() * Math.PI * 2, v = 8 + Math.random() * 12;
spawn(glow, Math.cos(a) * 0.5, 0.2, Math.sin(a) * 0.5, Math.cos(a) * v, 1 + Math.random() * 5, Math.sin(a) * v,
0.4 + Math.random() * 0.7, NOZZLE_R * 0.8, NOZZLE_R * 0.2, 1.0, 1.0, 0.6 + Math.random() * 0.3, 0.2, 2.0, 0);
}
}
function updateEngine(dt) {
state.time += dt;
let burning = true;
if (state.autoFly) { // landing loop: descend, touch down, shut off
const t = state.time % 9.5;
if (t < 5.5) { const k = 1 - t / 5.5; engine.position.y = LANDED_Y + 18 * Math.pow(k, 2.2); engine.rotation.z = 0.08 * k * Math.sin(state.time * 0.9); }
else { engine.position.y = LANDED_Y; engine.rotation.z = 0; burning = false; if (!state.landed) { state.landed = true; touchdownBurst(); } }
if (t < 5.5) state.landed = false;
}
vy += ((engine.position.y - lastY) / Math.max(dt, 1e-3) - vy) * Math.min(1, dt * 4); lastY = engine.position.y;
const agl = engine.position.y + NOZZLE_Y; // height of the nozzle above the pad
const hover = Math.max(0, 1 - agl / 4); // 1 = right on the ground
const target = burning ? state.throttle * (0.75 + 0.45 * hover) : 0; // landing burn gets stronger near the pad
state.thrust += (Math.max(0, Math.min(1.25, target)) - state.thrust) * Math.min(1, dt * 3);
const th = state.thrust;
flameMat.uniforms.uTime.value = state.time; flameMat.uniforms.uThrust.value = th;
flameLight.intensity = 6 + 14 * th * (0.9 + 0.1 * Math.sin(state.time * 41));
flame.scale.y = NOZZLE_R * 15 * (1 + 0.4 * hover); // big default plume, longer near the pad
inner.scale.y = NOZZLE_R * 8.4 * (1 + 0.4 * hover);
engine.updateMatrixWorld();
nozzle.set(0, NOZZLE_Y, 0).applyMatrix4(engine.matrixWorld);
down.set(0, -1, 0).applyQuaternion(engine.quaternion);
if (th < 0.03) return;
const r = NOZZLE_R;
spawn(glow, nozzle.x, nozzle.y, nozzle.z, 0, 0, 0, dt * 1.01, r * (6 + 3.5 * th), r * 6, 0.45 + 0.55 * th, 1.0, 0.9, 0.6); // nozzle glow (white-yellow tip)
spawn(glow, nozzle.x + down.x * r * 1.6, nozzle.y + down.y * r * 1.6, nozzle.z + down.z * r * 1.6, 0, 0, 0, dt * 1.01, r * 10, r * 10, 0.25 * th, 1.0, 0.55, 0.15); // orange bloom
let n = Math.floor((30 + 70 * th + 60 * hover) * dt + Math.random());
while (n-- > 0) { // plume sparks shot down the engine axis
const sp = (9 + 12 * th + 10 * hover) * 0.6;
spawn(glow, nozzle.x + (Math.random() - 0.5) * r, nozzle.y, nozzle.z + (Math.random() - 0.5) * r,
down.x * sp + (Math.random() - 0.5) * 0.6, down.y * sp, down.z * sp + (Math.random() - 0.5) * 0.6,
0.18 + 0.2 * Math.random() + 0.2 * hover, r * 2.8, r * (1.0 + 1.5 * hover), 0.45 * (0.3 + th), 1.0, 0.5 + Math.random() * 0.2, 0.2, 2, 0);
}
// exhaust smoke trailing out of the plume (Space Wow look): dense, warm-grey, billowing
let m = Math.floor((30 + 40 * th) * dt + Math.random());
while (m-- > 0) {
const sp = r * (6 + 4 * Math.random()), o = r * (3 + 3 * Math.random());
spawn(smoke, nozzle.x + down.x * o + (Math.random() - 0.5) * r, nozzle.y + down.y * o, nozzle.z + down.z * o + (Math.random() - 0.5) * r,
down.x * sp + (Math.random() - 0.5) * r * 2, down.y * sp + (Math.random() - 0.5) * r, down.z * sp + (Math.random() - 0.5) * r * 2,
1.8 + Math.random() * 1.2, r * 2.5, r * 11, 0.55 * th, 0.66, 0.6, 0.54, 1.0, 1.4);
}
// sparkling embers riding in the smoke: long-lived hot points that drift and fade out
let e = Math.floor((25 + 45 * th) * dt + Math.random());
while (e-- > 0) {
const o = r * (2 + 6 * Math.random()), sp = r * (4 + 6 * Math.random());
spawn(glow, nozzle.x + down.x * o + (Math.random() - 0.5) * r * 2, nozzle.y + down.y * o, nozzle.z + down.z * o + (Math.random() - 0.5) * r * 2,
down.x * sp + (Math.random() - 0.5) * 3, down.y * sp + (Math.random() - 0.3) * 2, down.z * sp + (Math.random() - 0.5) * 3,
0.6 + Math.random() * 0.9, r * (0.5 + Math.random() * 0.5), r * 0.15, 0.9, 1.0, 0.55 + Math.random() * 0.35, 0.15 + Math.random() * 0.2, 1.4, 0);
}
if (hover > 0.25 && Math.random() < dt * 6 * hover) // heat haze close to the ground
spawn(smoke, nozzle.x, nozzle.y - r, nozzle.z, down.x, down.y * 1.5, down.z, 0.9, r * 1.5, r * 5, 0.12 * hover, 0.8, 0.78, 0.75, 1.2, 0.4);
if (agl < 3.5) { // ground dust ring blasting outward
const gf = th * (1 - agl / 3.5);
let d = Math.floor((70 + 180 * gf) * dt + Math.random());
while (d-- > 0) {
const a = Math.random() * Math.PI * 2, rr = 0.6 + Math.random() * 1.2, v = (4 + 8 * gf) * 0.6;
spawn(smoke, nozzle.x + Math.cos(a) * rr, 0.25, nozzle.z + Math.sin(a) * rr, Math.cos(a) * v, 0.4 + Math.random() * 0.6, Math.sin(a) * v,
1.8 + Math.random() * 1.4, 1.2, 5.5 + 4 * gf, 0.5 * gf, 0.72, 0.66, 0.6, 1.6, 0.6);
}
let k = Math.floor(90 * gf * dt + Math.random()); // sparks skipping across the pad
while (k-- > 0) {
const a = Math.random() * Math.PI * 2, v = 6 + 10 * Math.random();
spawn(glow, nozzle.x + Math.cos(a) * 0.5, 0.15, nozzle.z + Math.sin(a) * 0.5, Math.cos(a) * v, 1 + Math.random() * 3, Math.sin(a) * v,
0.35 + Math.random() * 0.5, r * 0.7, r * 0.2, 1.0, 1.0, 0.6 + Math.random() * 0.3, 0.2, 2.2, 0);
}
}
}
// ---------- 6. Render loop ----------
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
updateEngine(dt); step(glow, dt, false); step(smoke, dt, true);
controls.update(); renderer.render(scene, camera);
}
animate();
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// ---------- 7. Controls UI: throttle slider + auto takeoff/land ----------
controls.enabled = true;
document.getElementById("throttle").addEventListener("input", (e) => { state.throttle = e.target.value / 100; });
document.getElementById("autofly").addEventListener("change", (e) => { state.autoFly = e.target.checked; if (!e.target.checked) { engine.position.y = 4; engine.rotation.z = 0; } });
</script>
</body>
</html>
What To Try Next
- Change the hot/mid/tail colors to make a blue ion drive or a green alien thruster.
- Parent
engineto a ship or jetpack model and move NOZZLE_Y to its exhaust. - Hook the throttle to the W key or a gamepad trigger for a playable lander.
- Add several engines by cloning the flame cone and spawning from each nozzle into the same pools.