Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>3D Continuous Particle Life</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;500;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --primary: #00f2ff; | |
| --glass-bg: rgba(15, 23, 42, 0.6); | |
| --glass-border: rgba(255, 255, 255, 0.1); | |
| --text: #e2e8f0; | |
| } | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| user-select: none; | |
| } | |
| body { | |
| overflow: hidden; | |
| background-color: #050505; | |
| font-family: 'Inter', sans-serif; | |
| color: var(--text); | |
| } | |
| /* Canvas */ | |
| #canvas-container { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| z-index: 1; | |
| } | |
| /* Header Link */ | |
| .brand-link { | |
| position: fixed; | |
| top: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| z-index: 100; | |
| color: var(--primary); | |
| text-decoration: none; | |
| font-weight: 700; | |
| font-size: 0.9rem; | |
| background: var(--glass-bg); | |
| padding: 8px 16px; | |
| border-radius: 20px; | |
| border: 1px solid var(--glass-border); | |
| backdrop-filter: blur(10px); | |
| transition: all 0.3s ease; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| box-shadow: 0 4px 15px rgba(0, 242, 255, 0.1); | |
| } | |
| .brand-link:hover { | |
| background: rgba(0, 242, 255, 0.1); | |
| box-shadow: 0 4px 25px rgba(0, 242, 255, 0.3); | |
| transform: translateX(-50%) translateY(-2px); | |
| } | |
| /* UI Overlay */ | |
| .ui-panel { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| width: 300px; | |
| background: var(--glass-bg); | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 16px; | |
| padding: 20px; | |
| z-index: 10; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 15px; | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.3); | |
| transition: transform 0.3s ease, opacity 0.3s ease; | |
| max-height: 90vh; | |
| overflow-y: auto; | |
| } | |
| .ui-panel.hidden { | |
| transform: translateX(120%); | |
| opacity: 0; | |
| } | |
| .panel-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 5px; | |
| } | |
| h1 { | |
| font-size: 1.1rem; | |
| font-weight: 700; | |
| background: linear-gradient(90deg, #fff, #94a3b8); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .control-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| label { | |
| font-size: 0.8rem; | |
| color: #94a3b8; | |
| display: flex; | |
| justify-content: space-between; | |
| } | |
| input[type="range"] { | |
| -webkit-appearance: none; | |
| width: 100%; | |
| height: 4px; | |
| background: rgba(255,255,255,0.1); | |
| border-radius: 2px; | |
| outline: none; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 14px; | |
| height: 14px; | |
| border-radius: 50%; | |
| background: var(--primary); | |
| cursor: pointer; | |
| transition: transform 0.1s; | |
| } | |
| input[type="range"]::-webkit-slider-thumb:hover { | |
| transform: scale(1.2); | |
| } | |
| button { | |
| background: rgba(255,255,255,0.05); | |
| border: 1px solid var(--glass-border); | |
| color: white; | |
| padding: 10px; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-weight: 500; | |
| transition: all 0.2s; | |
| font-family: 'Inter', sans-serif; | |
| } | |
| button:hover { | |
| background: var(--primary); | |
| color: black; | |
| border-color: var(--primary); | |
| } | |
| button.secondary { | |
| background: transparent; | |
| border: 1px solid rgba(255,255,255,0.2); | |
| } | |
| button.secondary:hover { | |
| background: rgba(255,255,255,0.1); | |
| color: white; | |
| } | |
| .toggle-btn { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| z-index: 11; | |
| width: 40px; | |
| height: 40px; | |
| border-radius: 50%; | |
| background: var(--glass-bg); | |
| backdrop-filter: blur(10px); | |
| border: 1px solid var(--glass-border); | |
| color: white; | |
| display: none; /* Shown via JS logic if needed, or just use CSS media queries */ | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| } | |
| .stats { | |
| font-size: 0.7rem; | |
| color: #64748b; | |
| margin-top: 10px; | |
| text-align: center; | |
| } | |
| /* Scrollbar */ | |
| .ui-panel::-webkit-scrollbar { | |
| width: 4px; | |
| } | |
| .ui-panel::-webkit-scrollbar-thumb { | |
| background: rgba(255,255,255,0.2); | |
| border-radius: 4px; | |
| } | |
| @media (max-width: 600px) { | |
| .ui-panel { | |
| width: calc(100% - 40px); | |
| bottom: 20px; | |
| top: auto; | |
| max-height: 50vh; | |
| } | |
| .brand-link { | |
| top: 10px; | |
| font-size: 0.75rem; | |
| } | |
| } | |
| </style> | |
| <!-- Three.js and dependencies --> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://unpkg.com/three@0.160.0/build/three.module.js", | |
| "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" | |
| } | |
| } | |
| </script> | |
| </head> | |
| <body> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="brand-link">Built with anycoder</a> | |
| <div id="canvas-container"></div> | |
| <div class="ui-panel" id="uiPanel"> | |
| <div class="panel-header"> | |
| <h1>Particle Life 3D</h1> | |
| </div> | |
| <div class="control-group"> | |
| <label>Particles <span id="val-count">1000</span></label> | |
| <input type="range" id="inp-count" min="200" max="2000" step="100" value="1000"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Interaction Radius <span id="val-radius">30</span></label> | |
| <input type="range" id="inp-radius" min="10" max="100" value="30"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Force Strength <span id="val-force">1.0</span></label> | |
| <input type="range" id="inp-force" min="0.1" max="5.0" step="0.1" value="1.0"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Friction <span id="val-friction">0.85</span></label> | |
| <input type="range" id="inp-friction" min="0.50" max="0.99" step="0.01" value="0.85"> | |
| </div> | |
| <button id="btn-randomize">🎲 Randomize Rules</button> | |
| <button id="btn-reset" class="secondary">Example: Cells</button> | |
| <button id="btn-reset-snake" class="secondary">Example: Serpents</button> | |
| <div class="stats" id="stats"> | |
| FPS: 60 | Types: 4 | |
| </div> | |
| </div> | |
| <script type="module"> | |
| 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 { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'; | |
| // --- Configuration --- | |
| const CONFIG = { | |
| particleCount: 1000, | |
| types: 4, | |
| radius: 30, | |
| forceFactor: 1.0, | |
| friction: 0.85, | |
| worldSize: 200, | |
| wrap: true | |
| }; | |
| // --- State --- | |
| let particles = []; | |
| let rules = []; // Interaction matrix | |
| let typeColors = []; | |
| // --- Three.js Setup --- | |
| const container = document.getElementById('canvas-container'); | |
| const scene = new THREE.Scene(); | |
| scene.fog = new THREE.FogExp2(0x050505, 0.002); | |
| const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.set(0, 100, 250); | |
| const renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: "high-performance" }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // Cap pixel ratio for performance | |
| container.appendChild(renderer.domElement); | |
| // Post-processing (Bloom) | |
| const renderScene = new RenderPass(scene, camera); | |
| const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85); | |
| bloomPass.threshold = 0.1; | |
| bloomPass.strength = 1.2; // Glowing effect | |
| bloomPass.radius = 0.5; | |
| const composer = new EffectComposer(renderer); | |
| composer.addPass(renderScene); | |
| composer.addPass(bloomPass); | |
| // Controls | |
| const controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| controls.autoRotate = true; | |
| controls.autoRotateSpeed = 0.5; | |
| // Lighting | |
| const ambientLight = new THREE.AmbientLight(0x404040); | |
| scene.add(ambientLight); | |
| const dirLight = new THREE.DirectionalLight(0xffffff, 1); | |
| dirLight.position.set(10, 10, 10); | |
| scene.add(dirLight); | |
| // --- Particle System (InstancedMesh) --- | |
| const geometry = new THREE.SphereGeometry(1, 16, 16); | |
| const material = new THREE.MeshStandardMaterial({ | |
| color: 0xffffff, | |
| roughness: 0.2, | |
| metalness: 0.8, | |
| emissive: 0xffffff, | |
| emissiveIntensity: 0.2 | |
| }); | |
| let instancedMesh; | |
| const dummy = new THREE.Object3D(); | |
| const _color = new THREE.Color(); | |
| // --- Logic --- | |
| function initSystem() { | |
| // Clear old mesh | |
| if (instancedMesh) { | |
| scene.remove(instancedMesh); | |
| instancedMesh.dispose(); | |
| } | |
| // Create Colors | |
| typeColors = [ | |
| new THREE.Color(0x00f2ff), // Cyan | |
| new THREE.Color(0xff0055), // Magenta | |
| new THREE.Color(0x00ffaa), // Green | |
| new THREE.Color(0xffaa00) // Orange | |
| ]; | |
| CONFIG.types = typeColors.length; | |
| // Create Particles | |
| particles = new Float32Array(CONFIG.particleCount * 8); // x, y, z, vx, vy, vz, type, size | |
| for (let i = 0; i < CONFIG.particleCount; i++) { | |
| const i8 = i * 8; | |
| // Position | |
| particles[i8] = (Math.random() - 0.5) * CONFIG.worldSize; | |
| particles[i8+1] = (Math.random() - 0.5) * CONFIG.worldSize; | |
| particles[i8+2] = (Math.random() - 0.5) * CONFIG.worldSize; | |
| // Velocity | |
| particles[i8+3] = 0; | |
| particles[i8+4] = 0; | |
| particles[i8+5] = 0; | |
| // Type | |
| particles[i8+6] = Math.floor(Math.random() * CONFIG.types); | |
| // Size (base) | |
| particles[i8+7] = 1.0 + Math.random() * 1.5; | |
| } | |
| // Create Instanced Mesh | |
| instancedMesh = new THREE.InstancedMesh(geometry, material, CONFIG.particleCount); | |
| instancedMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); | |
| scene.add(instancedMesh); | |
| randomizeRules(); | |
| } | |
| function randomizeRules() { | |
| // Generate interaction matrix: force between type A and B | |
| rules = []; | |
| for (let i = 0; i < CONFIG.types; i++) { | |
| rules[i] = []; | |
| for (let j = 0; j < CONFIG.types; j++) { | |
| // Value between -1 (repel) and 1 (attract) | |
| rules[i][j] = (Math.random() * 2 - 1); | |
| } | |
| } | |
| console.log("Rules Randomized", rules); | |
| } | |
| function setPreset(name) { | |
| // Presets for interesting behaviors | |
| if (name === 'cells') { | |
| // Similar types attract, different repel slightly | |
| for(let i=0; i<CONFIG.types; i++) { | |
| for(let j=0; j<CONFIG.types; j++) { | |
| if (i === j) rules[i][j] = 0.8; | |
| else rules[i][j] = -0.4; | |
| } | |
| } | |
| } else if (name === 'snakes') { | |
| // Cyclic attraction: 0->1, 1->2, 2->3, 3->0 | |
| for(let i=0; i<CONFIG.types; i++) { | |
| for(let j=0; j<CONFIG.types; j++) { | |
| rules[i][j] = 0; | |
| } | |
| rules[i][(i+1)%CONFIG.types] = 0.6; // Chase next | |
| rules[i][(i-1+CONFIG.types)%CONFIG.types] = -0.2; // Run from prev | |
| } | |
| } | |
| } | |
| // --- Physics Loop --- | |
| function updatePhysics() { | |
| const count = CONFIG.particleCount; | |
| const rMax = CONFIG.radius; | |
| const rMaxSq = rMax * rMax; | |
| const forceFactor = CONFIG.forceFactor; | |
| const friction = CONFIG.friction; | |
| const worldSize = CONFIG.worldSize; | |
| const halfWorld = worldSize / 2; | |
| // Brute force O(N^2) - acceptable for N < 1500 in JS on modern devices | |
| // Optimized slightly by pre-calculating constants | |
| for (let i = 0; i < count; i++) { | |
| const i8 = i * 8; | |
| let fx = 0, fy = 0, fz = 0; | |
| const typeI = particles[i8+6]; | |
| const px = particles[i8]; | |
| const py = particles[i8+1]; | |
| const pz = particles[i8+2]; | |
| for (let j = 0; j < count; j++) { | |
| if (i === j) continue; | |
| const j8 = j * 8; | |
| let dx = particles[j8] - px; | |
| let dy = particles[j8+1] - py; | |
| let dz = particles[j8+2] - pz; | |
| // Wrap around distance for continuous field illusion | |
| if (dx > halfWorld) dx -= worldSize; | |
| if (dx < -halfWorld) dx += worldSize; | |
| if (dy > halfWorld) dy -= worldSize; | |
| if (dy < -halfWorld) dy += worldSize; | |
| if (dz > halfWorld) dz -= worldSize; | |
| if (dz < -halfWorld) dz += worldSize; | |
| const distSq = dx*dx + dy*dy + dz*dz; | |
| if (distSq > 0 && distSq < rMaxSq) { | |
| const dist = Math.sqrt(distSq); | |
| const q = dist / rMax; // Normalized distance 0..1 | |
| const typeJ = particles[j8+6]; | |
| // Force calculation | |
| // 1. Repulsion if very close (prevent overlap) | |
| // 2. Interaction based on rule matrix | |
| let f = 0; | |
| if (q < 0.3) { | |
| // Strong repulsion | |
| f = q - 1; | |
| } else { | |
| // Rule based force. | |
| // Smooth curve: rises then falls off | |
| // Standard Particle Life formula variant | |
| const g = rules[typeI][typeJ]; | |
| // Peak force at q=0.5 approx | |
| f = g * (1 - Math.abs(2 * q - 1)) * 0.5; | |
| } | |
| // Normalize force vector | |
| const fScaled = (f * forceFactor) / dist; | |
| fx += dx * fScaled; | |
| fy += dy * fScaled; | |
| fz += dz * fScaled; | |
| } | |
| } | |
| // Apply Force to Velocity | |
| particles[i8+3] = (particles[i8+3] + fx) * friction; | |
| particles[i8+4] = (particles[i8+4] + fy) * friction; | |
| particles[i8+5] = (particles[i8+5] + fz) * friction; | |
| // Update Position | |
| particles[i8] += particles[i8+3]; | |
| particles[i8+1] += particles[i8+4]; | |
| particles[i8+2] += particles[i8+5]; | |
| // Boundary Wrap | |
| if (particles[i8] <= -halfWorld) particles[i8] += worldSize; | |
| if (particles[i8] >= halfWorld) particles[i8] -= worldSize; | |
| if (particles[i8+1] <= -halfWorld) particles[i8+1] += worldSize; | |
| if (particles[i8+1] >= halfWorld) particles[i8+1] -= worldSize; | |
| if (particles[i8+2] <= -halfWorld) particles[i8+2] += worldSize; | |
| if (particles[i8+2] >= halfWorld) particles[i8+2] -= worldSize; | |
| } | |
| } | |
| function updateVisuals() { | |
| const count = CONFIG.particleCount; | |
| for (let i = 0; i < count; i++) { | |
| const i8 = i * 8; | |
| dummy.position.set( | |
| particles[i8], | |
| particles[i8+1], | |
| particles[i8+2] | |
| ); | |
| // Dynamic Scale based on velocity (stretch effect) | |
| const speed = Math.sqrt(particles[i8+3]**2 + particles[i8+4]**2 + particles[i8+5]**2); | |
| const baseSize = particles[i8+7]; | |
| const scale = baseSize + speed * 2; | |
| // Orient towards velocity for "looking alive" | |
| // Not strictly necessary for spheres but helps if we change geometry later | |
| // dummy.lookAt(dummy.position.clone().add(new THREE.Vector3(particles[i8+3], particles[i8+4], particles[i8+5]))); | |
| dummy.scale.set(scale, scale, scale); | |
| dummy.updateMatrix(); | |
| instancedMesh.setMatrixAt(i, dummy.matrix); | |
| // Color | |
| const type = particles[i8+6]; | |
| _color.copy(typeColors[type]); | |
| // Brighten based on speed (activity) | |
| const intensity = 1 + speed * 0.5; | |
| _color.multiplyScalar(intensity); | |
| instancedMesh.setColorAt(i, _color); | |
| } | |
| instancedMesh.instanceMatrix.needsUpdate = true; | |
| if(instancedMesh.instanceColor) instancedMesh.instanceColor.needsUpdate = true; | |
| } | |
| // --- Main Loop --- | |
| const statsEl = document.getElementById('stats'); | |
| let lastTime = 0; | |
| let frames = 0; | |
| let fpsTime = 0; | |
| function animate(time) { | |
| requestAnimationFrame(animate); | |
| // FPS Calculation | |
| frames++; | |
| if (time - fpsTime > 1000) { | |
| statsEl.innerHTML = `FPS: ${frames} | Particles: ${CONFIG.particleCount}`; | |
| frames = 0; | |
| fpsTime = time; | |
| } | |
| updatePhysics(); | |
| updateVisuals(); | |
| controls.update(); | |
| // Render with Bloom | |
| composer.render(); | |
| } | |
| // --- UI Event Listeners --- | |
| document.getElementById('inp-count').addEventListener('input', (e) => { | |
| CONFIG.particleCount = parseInt(e.target.value); | |
| document.getElementById('val-count').innerText = CONFIG.particleCount; | |
| initSystem(); | |
| }); | |
| document.getElementById('inp-radius').addEventListener('input', (e) => { | |
| CONFIG.radius = parseInt(e.target.value); | |
| document.getElementById('val-radius').innerText = CONFIG.radius; | |
| }); | |
| document.getElementById('inp-force').addEventListener('input', (e) => { | |
| CONFIG.forceFactor = parseFloat(e.target.value); | |
| document.getElementById('val-force').innerText = CONFIG.forceFactor; | |
| }); | |
| document.getElementById('inp-friction').addEventListener('input', (e) => { | |
| CONFIG.friction = parseFloat(e.target.value); | |
| document.getElementById('val-friction').innerText = CONFIG.friction; | |
| }); | |
| document.getElementById('btn-randomize').addEventListener('click', () => { | |
| randomizeRules(); | |
| // Add a flash effect to canvas maybe? | |
| }); | |
| document.getElementById('btn-reset').addEventListener('click', () => { | |
| setPreset('cells'); | |
| // Reset Friction/Force for this preset | |
| CONFIG.friction = 0.80; | |
| CONFIG.forceFactor = 1.2; | |
| CONFIG.radius = 40; | |
| updateUiValues(); | |
| }); | |
| document.getElementById('btn-reset-snake').addEventListener('click', () => { | |
| setPreset('snakes'); | |
| CONFIG.friction = 0.90; | |
| CONFIG.forceFactor = 2.0; | |
| CONFIG.radius = 60; | |
| updateUiValues(); | |
| }); | |
| function updateUiValues() { | |
| document.getElementById('inp-friction').value = CONFIG.friction; | |
| document.getElementById('val-friction').innerText = CONFIG.friction; | |
| document.getElementById('inp-force').value = CONFIG.forceFactor; | |
| document.getElementById('val-force').innerText = CONFIG.forceFactor; | |
| document.getElementById('inp-radius').value = CONFIG.radius; | |
| document.getElementById('val-radius').innerText = CONFIG.radius; | |
| } | |
| // Resize Handler | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| composer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| // --- Boot --- | |
| initSystem(); | |
| animate(); | |
| </script> | |
| </body> | |
| </html> |