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); }