The Fire Triangle - Three.js Simulation
body { margin: 0; overflow: hidden; background-color: #111; color: white; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
#canvas-container { width: 100vw; height: 100vh; position: absolute; top: 0; left: 0; z-index: 1; }
/* UI Overlay */
#ui-layer {
position: absolute;
z-index: 10;
top: 0;
.control-group { margin-bottom: 20px; }
.control-label { display: flex; justify-content: space-between; font-weight: bold; margin-bottom: 5px; font-size: 0.9rem;}
/* Progress Bars */
.meter-container {
width: 100%;
background: #333;
height: 8px;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.meter-fill {
height: 100%;
transition: width 0.3s ease;
}
/* Action Buttons Grid */
.action-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
margin-top: 8px;
}
.action-btn {
padding: 8px 4px;
font-size: 0.75rem;
border-radius: 4px;
cursor: pointer;
border: 1px solid rgba(255,255,255,0.15);
transition: all 0.2s;
color: white;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.action-btn:hover { filter: brightness(1.2); transform: translateY(-1px); }
.action-btn:active { transform: scale(0.98); transform: translateY(0px); }
.btn-add { background: rgba(255, 255, 255, 0.15); border-color: rgba(255,255,255,0.3); }
.btn-sub-small { background: rgba(0, 0, 0, 0.3); }
.btn-sub-large { background: rgba(0, 0, 0, 0.6); border: 1px solid rgba(255,100,100,0.3); }
.status-box {
text-align: center;
max-width: 600px;
margin: 20px auto 0 auto;
}
h1 { margin: 0 0 10px 0; font-size: 1.5rem; color: #fbbf24; }
p { margin: 0; font-size: 0.95rem; color: #ccc; }
#fire-status {
font-size: 1.2rem;
font-weight: bold;
margin-top: 10px;
padding: 10px;
border-radius: 6px;
transition: all 0.3s ease;
}
.status-burning { background: rgba(255, 100, 0, 0.2); color: #ffadad; border: 1px solid #ff4500; }
.status-out { background: rgba(50, 50, 50, 0.5); color: #888; border: 1px solid #555; }
/* Mobile Adjustments */
@media (max-width: 768px) {
.panel { margin: 10px; padding: 15px; }
h1 { font-size: 1.2rem; }
#ui-layer { flex-direction: column; }
.bottom-controls { width: calc(100% - 20px); box-sizing: border-box; }
}
The Fire Triangle
Fire requires Fuel, Oxygen, and Heat.
State: IGNITED
FUEL (Material)
50%
+ Newspaper Stack
+ Cooking Grease
+ Dry Christmas Tree
- Move Furniture
- Close Gas Valve
- Fire Break
OXYGEN (Air)
50%
+ Ceiling Fan On
+ Open Window
+ Drafty Doorway
- Close Door
- Pan Lid (Smother)
- Fire Blanket
HEAT (Temperature)
50%
+ Space Heater
+ Stove Left On
+ Electrical Short
- Wet Towel
- Bucket of Water
- Fire Extinguisher
Reset Experiment
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.157.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.157.0/examples/jsm/"
}
}
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// --- Configuration ---
const config = {
fuel: 50,
oxygen: 50,
heat: 50,
maxParticles: 800,
particleSpeed: 2.5
};
let scene, camera, renderer, controls;
let particles = [];
let particleSystem;
let pointLight, ambientLight;
// Visual elements representing the inputs
const tanks = {};
const pipes = {};
// DOM Elements
const ui = {
fuelVal: document.getElementById('fuel-val'),
oxyVal: document.getElementById('oxy-val'),
heatVal: document.getElementById('heat-val'),
fuelBar: document.getElementById('fuel-bar'),
oxyBar: document.getElementById('oxy-bar'),
heatBar: document.getElementById('heat-bar'),
status: document.getElementById('fire-status'),
reset: document.getElementById('reset-btn')
};
// Expose function to window for HTML buttons
window.adjust = function(type, amount) {
config[type] = Math.max(0, Math.min(100, config[type] + amount));
updateUI();
};
init();
animate();
function init() {
// 1. Scene Setup
const container = document.getElementById('canvas-container');
scene = new THREE.Scene();
scene.background = new THREE.Color(0x050505);
scene.fog = new THREE.Fog(0x050505, 10, 50);
camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 10, 20);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
// 2. Controls
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.maxPolarAngle = Math.PI / 2 - 0.1; // Don't go below ground
controls.minDistance = 10;
controls.maxDistance = 40;
// 3. Lighting
ambientLight = new THREE.AmbientLight(0xffffff, 0.1);
scene.add(ambientLight);
// The light emanating from the fire
pointLight = new THREE.PointLight(0xffaa33, 1, 20);
pointLight.position.set(0, 2, 0);
scene.add(pointLight);
// 4. Build the "Stage"
buildEnvironment();
// 5. Build the Fire System
createFireSystem();
// 6. Listeners
window.addEventListener('resize', onWindowResize);
ui.reset.addEventListener('click', resetSimulation);
// Initial Update
updateUI();
}
// --- Scene Construction ---
function buildEnvironment() {
// Ground
const groundGeo = new THREE.CylinderGeometry(12, 12, 0.5, 64);
const groundMat = new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.8, metalness: 0.2 });
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.position.y = -0.25;
scene.add(ground);
// Fire Pit (Burner)
const pitGeo = new THREE.CylinderGeometry(2, 2.5, 0.5, 32);
const pitMat = new THREE.MeshStandardMaterial({ color: 0x111111 });
const pit = new THREE.Mesh(pitGeo, pitMat);
pit.position.y = 0.25;
scene.add(pit);
// Create 3 "Source Tanks" arranged in a triangle
createSourceUnit('fuel', 0x4ade80, 0); // Green
createSourceUnit('oxygen', 0x60a5fa, (Math.PI * 2) / 3); // Blue
createSourceUnit('heat', 0xf87171, (Math.PI * 2) * 2 / 3); // Red
}
function createSourceUnit(type, colorHex, angle) {
const radius = 8;
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;
// Tank Mesh
const tankGeo = new THREE.CylinderGeometry(1, 1, 3, 16);
const tankMat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.3, metalness: 0.4 });
const tank = new THREE.Mesh(tankGeo, tankMat);
tank.position.set(x, 1.5, z);
scene.add(tank);
tanks[type] = tank;
// Pipe connecting tank to center
const dist = Math.sqrt(x*x + z*z);
const pipeLen = dist - 2; // Subtract pit radius approx
const pipeGeo = new THREE.BoxGeometry(0.4, 0.4, pipeLen);
pipeGeo.translate(0, 0, pipeLen / 2); // Pivot at start
const pipeMat = new THREE.MeshStandardMaterial({
color: 0x333333,
emissive: colorHex,
emissiveIntensity: 0.5
});
const pipe = new THREE.Mesh(pipeGeo, pipeMat);
pipe.position.set(0, 0.5, 0);
pipe.lookAt(x, 0.5, z);
scene.add(pipe);
pipes[type] = pipeMat; // Store material to animate emission
}
// --- Particle System (The Fire) ---
function generateFireTexture() {
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
const context = canvas.getContext('2d');
// Radial gradient for soft flame look
const gradient = context.createRadialGradient(32, 32, 0, 32, 32, 32);
gradient.addColorStop(0, 'rgba(255, 255, 255, 1)');
gradient.addColorStop(0.3, 'rgba(255, 200, 0, 1)');
gradient.addColorStop(0.5, 'rgba(200, 0, 0, 0.8)');
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
context.fillStyle = gradient;
context.fillRect(0, 0, 64, 64);
const texture = new THREE.CanvasTexture(canvas);
return texture;
}
function createFireSystem() {
const texture = generateFireTexture();
const geometry = new THREE.BufferGeometry();
const positions = [];
const sizes = [];
const colors = [];
// Initialize pools
for (let i = 0; i < config.maxParticles; i++) {
positions.push(0, -100, 0); // Hide initially
sizes.push(0);
colors.push(1, 1, 1);
}
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('size', new THREE.Float32BufferAttribute(sizes, 1));
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
size: 1.0,
map: texture,
transparent: true,
depthWrite: false,
vertexColors: true,
blending: THREE.AdditiveBlending
});
particleSystem = new THREE.Points(geometry, material);
scene.add(particleSystem);
// Store logic data separately
particleSystem.userData = {
particles: []
};
for (let i = 0; i < config.maxParticles; i++) {
particleSystem.userData.particles.push({
x: 0, y: -10, z: 0,
vx: 0, vy: 0, vz: 0,
age: 0, life: 0,
active: false
});
}
}
function updateParticles(dt) {
const positions = particleSystem.geometry.attributes.position.array;
const sizes = particleSystem.geometry.attributes.size.array;
const colors = particleSystem.geometry.attributes.color.array;
const data = particleSystem.userData.particles;
// Calculate overall fire intensity based on the "Triangle"
// The fire is only as strong as its weakest link.
const minComponent = Math.min(config.fuel, config.oxygen, config.heat);
const intensity = minComponent / 100; // 0.0 to 1.0
// Adjust Light
pointLight.intensity = THREE.MathUtils.lerp(pointLight.intensity, intensity * 2, 0.1);
pointLight.distance = intensity * 20;
// Adjust Pipes Glow
pipes.fuel.emissiveIntensity = (config.fuel / 100) * 2;
pipes.oxygen.emissiveIntensity = (config.oxygen / 100) * 2;
pipes.heat.emissiveIntensity = (config.heat / 100) * 2;
// Spawn new particles
// We spawn fewer particles if intensity is low
const spawnRate = Math.floor(intensity * 10) + 1;
if (intensity > 0.05) { // Only spawn if there is *some* of everything
let spawned = 0;
for (let i = 0; i < config.maxParticles; i++) {
if (!data[i].active && spawned < spawnRate) {
data[i].active = true;
data[i].age = 0;
data[i].life = 1.0 + Math.random() * 0.5; // Random life span
// Spawn in a circle at the bottom
const angle = Math.random() * Math.PI * 2;
const r = Math.random() * 1.5 * intensity; // Radius depends on intensity
data[i].x = Math.cos(angle) * r;
data[i].y = 0.5; // Start just above burner
data[i].z = Math.sin(angle) * r;
// Upward velocity
data[i].vy = (2 + Math.random() * 2) * config.particleSpeed * intensity;
data[i].vx = (Math.random() - 0.5) * 0.5;
data[i].vz = (Math.random() - 0.5) * 0.5;
spawned++;
}
}
}
// Update existing particles
for (let i = 0; i < config.maxParticles; i++) {
const p = data[i];
if (p.active) {
p.age += dt;
if (p.age > p.life) {
p.active = false;
sizes[i] = 0;
positions[i * 3 + 1] = -100; // hide
continue;
}
// Move
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
// Update Buffer
positions[i * 3] = p.x;
positions[i * 3 + 1] = p.y;
positions[i * 3 + 2] = p.z;
// Size and Color based on age
const lifeRatio = p.age / p.life;
// Size: Grow then shrink
sizes[i] = (Math.sin(lifeRatio * Math.PI) * 2.5) * intensity;
// Color: White -> Yellow -> Red -> Dark
// Simple logic via direct array manipulation
// We keep colors somewhat constant but fade alpha via the texture,
// but here we can tint toward red as it ages
const r = 1.0;
const g = 1.0 - (lifeRatio * 0.8); // Fade green to make it redder
const b = 1.0 - (lifeRatio * 2.0); // Fade blue quickly
colors[i * 3] = Math.max(0, r);
colors[i * 3 + 1] = Math.max(0, g);
colors[i * 3 + 2] = Math.max(0, b);
}
}
particleSystem.geometry.attributes.position.needsUpdate = true;
particleSystem.geometry.attributes.size.needsUpdate = true;
particleSystem.geometry.attributes.color.needsUpdate = true;
}
// --- Logic & Interaction ---
function updateUI() {
ui.fuelVal.innerText = config.fuel + '%';
ui.oxyVal.innerText = config.oxygen + '%';
ui.heatVal.innerText = config.heat + '%';
ui.fuelBar.style.width = config.fuel + '%';
ui.oxyBar.style.width = config.oxygen + '%';
ui.heatBar.style.width = config.heat + '%';
updateStatusText();
}
function updateStatusText() {
const f = config.fuel;
const o = config.oxygen;
const h = config.heat;
// Threshold for "extinguished"
const threshold = 10;
ui.status.className = '';
if (f > threshold && o > threshold && h > threshold) {
ui.status.innerText = "State: FIRE IGNITED (Reaction Sustained)";
ui.status.classList.add('status-burning');
} else {
ui.status.classList.add('status-out');
// Determine the cause
let causes = [];
if (f <= threshold) causes.push("STARVATION (No Fuel)");
if (o <= threshold) causes.push("SMOTHERING (No Oxygen)");
if (h <= threshold) causes.push("COOLING (No Heat)");
ui.status.innerText = "State: EXTINGUISHED via " + causes.join(" + ");
}
}
function resetSimulation() {
config.fuel = 50;
config.oxygen = 50;
config.heat = 50;
updateUI();
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function animate() {
requestAnimationFrame(animate);
const dt = 0.016; // approximate delta time (60fps)
updateParticles(dt);
controls.update();
renderer.render(scene, camera);
}