Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Cloud Chamber Simulator</title> | |
| <style> | |
| body { margin: 0; overflow: hidden; background: #000; color: white; font-family: sans-serif; } | |
| canvas { display: block; } | |
| #rock-sprite { | |
| position: absolute; | |
| width: 80px; | |
| height: 80px; | |
| left: 50%; | |
| top: 50%; | |
| transform: translate(-50%, -50%); | |
| cursor: grab; | |
| z-index: 10; | |
| user-select: none; | |
| } | |
| #rock-sprite:active { | |
| cursor: grabbing; | |
| } | |
| #gui { | |
| position: absolute; | |
| top: 10px; | |
| right: 10px; | |
| background: rgba(0, 0, 0, 0.7); | |
| padding: 10px; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| } | |
| #performance { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| background: rgba(0, 0, 0, 0.7); | |
| padding: 10px; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| font-family: monospace; | |
| color: #0f0; | |
| min-width: 120px; | |
| } | |
| #gui label { | |
| display: block; | |
| margin-top: 10px; | |
| } | |
| #gui input, #gui select { | |
| width: 100%; | |
| margin-top: 4px; | |
| } | |
| .slider-container { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-top: 4px; | |
| } | |
| .slider-value { | |
| font-size: 12px; | |
| color: #ccc; | |
| white-space: nowrap; | |
| } | |
| .slider-input { | |
| flex: 1; | |
| } | |
| </style> | |
| <script src="https://cdn.jsdelivr.net/npm/gl-matrix@3.4.3/gl-matrix-min.js"></script> | |
| </head> | |
| <body> | |
| <canvas id="glcanvas"></canvas> | |
| <img id="rock-sprite" src="rock-sprite.png" alt="Radiation Source"> | |
| <div id="performance"> | |
| <div>FPS: <span id="fps-counter">0</span></div> | |
| <div>Particles: <span id="particle-counter">0</span></div> | |
| </div> | |
| <div id="gui"> | |
| <label>Background Cosmic Intensity | |
| <div class="slider-container"> | |
| <span class="slider-value">0</span> | |
| <input type="range" id="intensity" class="slider-input" min="0" max="100" step="0.1" value="0"> | |
| <span class="slider-value">100</span> | |
| </div> | |
| <div class="slider-value">Current: <span id="intensity-value">0</span></div> | |
| </label> | |
| <label>Radiation Source Intensity | |
| <div class="slider-container"> | |
| <span class="slider-value">0</span> | |
| <input type="range" id="sourceIntensity" class="slider-input" min="0" max="10" step="0.1" value="2"> | |
| <span class="slider-value">10</span> | |
| </div> | |
| <div class="slider-value">Current: <span id="sourceIntensity-value">2</span></div> | |
| </label> | |
| <label>Particle Size | |
| <div class="slider-container"> | |
| <span class="slider-value">0.05</span> | |
| <input type="range" id="size" class="slider-input" min="0.05" max="2.0" step="0.05" value="0.6"> | |
| <span class="slider-value">2.0</span> | |
| </div> | |
| <div class="slider-value">Current: <span id="size-value">0.6</span></div> | |
| </label> | |
| <label>Heat Flow | |
| <div class="slider-container"> | |
| <span class="slider-value">0</span> | |
| <input type="range" id="heatFlow" class="slider-input" min="0" max="0.5" step="0.01" value="0.04"> | |
| <span class="slider-value">0.5</span> | |
| </div> | |
| <div class="slider-value">Current: <span id="heatFlow-value">0.04</span></div> | |
| </label> | |
| <label>Trail Lifetime | |
| <div class="slider-container"> | |
| <span class="slider-value">0.01</span> | |
| <input type="range" id="trailLife" class="slider-input" min="0.01" max="1.0" step="0.01" value="0.03"> | |
| <span class="slider-value">1.0</span> | |
| </div> | |
| <div class="slider-value">Current: <span id="trailLife-value">0.03</span></div> | |
| </label> | |
| <label>Central Source Material | |
| <select id="radiationType"> | |
| <option value="Uranium-238">Uranium-238 (Alpha)</option> | |
| <option value="Cesium-137">Cesium-137 (Beta/Gamma)</option> | |
| <option value="Cobalt-60">Cobalt-60 (Gamma)</option> | |
| <option value="Radium-226">Radium-226 (Alpha)</option> | |
| <option value="Strontium-90">Strontium-90 (Beta)</option> | |
| <option value="Americium-241">Americium-241 (Alpha)</option> | |
| </select> | |
| </label> | |
| <label>Cosmic Particle Type | |
| <select id="cosmicType"> | |
| <option value="Muons">Muons</option> | |
| <option value="Protons">Protons</option> | |
| <option value="Electrons">Electrons</option> | |
| <option value="Pions">Pions</option> | |
| <option value="Mixed">Mixed Cosmic Ray</option> | |
| </select> | |
| </label> | |
| <label>Lift Force | |
| <div class="slider-container"> | |
| <span class="slider-value">0</span> | |
| <input type="range" id="liftForce" class="slider-input" min="0" max="0.1" step="0.005" value="0.05"> | |
| <span class="slider-value">0.1</span> | |
| </div> | |
| <div class="slider-value">Current: <span id="liftForce-value">0.05</span></div> | |
| </label> | |
| <label>Friction Variability | |
| <div class="slider-container"> | |
| <span class="slider-value">0</span> | |
| <input type="range" id="frictionVariance" class="slider-input" min="0" max="1.0" step="0.005" value="0.4"> | |
| <span class="slider-value">1.0</span> | |
| </div> | |
| <div class="slider-value">Current: <span id="frictionVariance-value">0.4</span></div> | |
| </label> | |
| <label>Particle Curvature | |
| <div class="slider-container"> | |
| <span class="slider-value">0</span> | |
| <input type="range" id="curvatureMultiplier" class="slider-input" min="0" max="5.0" step="0.1" value="1.0"> | |
| <span class="slider-value">5.0</span> | |
| </div> | |
| <div class="slider-value">Current: <span id="curvatureMultiplier-value">1.0</span></div> | |
| </label> | |
| <label>Trail Length Scale | |
| <div class="slider-container"> | |
| <span class="slider-value">1.0</span> | |
| <input type="range" id="trailLengthScale" class="slider-input" min="1.0" max="10.0" step="0.1" value="2.0"> | |
| <span class="slider-value">10.0</span> | |
| </div> | |
| <div class="slider-value">Current: <span id="trailLengthScale-value">2.0</span></div> | |
| </label> | |
| </div> | |
| <script> | |
| const params = { | |
| intensity: 0, | |
| sourceIntensity: 2, | |
| size: 0.6, | |
| heatFlow: 0.04, | |
| trailLife: 0.03, | |
| trailLengthScale: 2.0, | |
| radiationType: 'Cesium-137', | |
| cosmicType: 'Muons', | |
| liftForce: 0.05, | |
| frictionVariance: 0.4, | |
| curvatureMultiplier: 1.0 | |
| }; | |
| for (const id in params) { | |
| const el = document.getElementById(id); | |
| el.addEventListener('input', () => { | |
| params[id] = el.type === 'range' ? parseFloat(el.value) : el.value; | |
| if (el.type === 'range') { | |
| const valueSpan = document.getElementById(id + '-value'); | |
| if (valueSpan) valueSpan.textContent = el.value; | |
| } | |
| }); | |
| } | |
| // Sprite dragging functionality | |
| const rockSprite = document.getElementById('rock-sprite'); | |
| let isDragging = false; | |
| let dragOffset = { x: 0, y: 0 }; | |
| let spritePosition = { x: 0, y: 0 }; // Position in world coordinates | |
| function screenToWorld(screenX, screenY) { | |
| const canvas = document.getElementById("glcanvas"); | |
| const rect = canvas.getBoundingClientRect(); | |
| // Convert to normalized device coordinates (-1 to 1) | |
| const ndcX = ((screenX - rect.left) / rect.width) * 2 - 1; | |
| const ndcY = -(((screenY - rect.top) / rect.height) * 2 - 1); | |
| // Calculate world space dimensions at z=0 plane | |
| // Camera is at z=-50, FOV = PI/3, distance to z=0 = 50 | |
| const fov = Math.PI / 3; | |
| const distance = 50; | |
| const halfHeight = Math.tan(fov / 2) * distance; | |
| const halfWidth = halfHeight * (canvas.width / canvas.height); | |
| // Convert NDC to world coordinates | |
| const worldX = ndcX * halfWidth; | |
| const worldY = ndcY * halfHeight; | |
| return { x: worldX, y: worldY }; | |
| } | |
| function worldToScreen(worldX, worldY) { | |
| const canvas = document.getElementById("glcanvas"); | |
| const rect = canvas.getBoundingClientRect(); | |
| // Calculate world space dimensions at z=0 plane | |
| const fov = Math.PI / 3; | |
| const distance = 50; | |
| const halfHeight = Math.tan(fov / 2) * distance; | |
| const halfWidth = halfHeight * (canvas.width / canvas.height); | |
| // Convert world coordinates to NDC | |
| const ndcX = worldX / halfWidth; | |
| const ndcY = worldY / halfHeight; | |
| // Convert NDC to screen coordinates | |
| const screenX = (ndcX + 1) * 0.5 * rect.width + rect.left; | |
| const screenY = (-ndcY + 1) * 0.5 * rect.height + rect.top; | |
| return { x: screenX, y: screenY }; | |
| } | |
| rockSprite.addEventListener('mousedown', (e) => { | |
| isDragging = true; | |
| const rect = rockSprite.getBoundingClientRect(); | |
| dragOffset.x = e.clientX - rect.left - rect.width / 2; | |
| dragOffset.y = e.clientY - rect.top - rect.height / 2; | |
| e.preventDefault(); | |
| }); | |
| document.addEventListener('mousemove', (e) => { | |
| if (isDragging) { | |
| const newX = e.clientX - dragOffset.x; | |
| const newY = e.clientY - dragOffset.y; | |
| rockSprite.style.left = newX + 'px'; | |
| rockSprite.style.top = newY + 'px'; | |
| rockSprite.style.transform = 'translate(-50%, -50%)'; | |
| // Update world position | |
| const worldPos = screenToWorld(newX, newY); // newX, newY is already the center due to CSS transform | |
| spritePosition.x = worldPos.x; | |
| spritePosition.y = worldPos.y; | |
| } | |
| }); | |
| document.addEventListener('mouseup', () => { | |
| isDragging = false; | |
| }); | |
| const canvas = document.getElementById("glcanvas"); | |
| const gl = canvas.getContext("webgl"); | |
| if (!gl) { | |
| alert("WebGL not supported"); | |
| } | |
| // Create matrices early so they can be used in resize function | |
| const projection = glMatrix.mat4.create(); | |
| const modelView = glMatrix.mat4.create(); | |
| function resize() { | |
| canvas.width = window.innerWidth; | |
| canvas.height = window.innerHeight; | |
| gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); | |
| // Update projection matrix with new aspect ratio | |
| glMatrix.mat4.perspective(projection, Math.PI / 3, canvas.width / canvas.height, 0.1, 100); | |
| // Update sprite screen position to maintain world coordinates | |
| if (spritePosition.x !== 0 || spritePosition.y !== 0) { | |
| const screenPos = worldToScreen(spritePosition.x, spritePosition.y); | |
| rockSprite.style.left = screenPos.x + 'px'; | |
| rockSprite.style.top = screenPos.y + 'px'; | |
| } | |
| } | |
| window.addEventListener('resize', resize); | |
| resize(); | |
| const vsSource = ` | |
| attribute vec3 aPosition; | |
| attribute float aLife; | |
| attribute vec3 aColor; | |
| attribute float aDensity; | |
| uniform float uPointSize; | |
| uniform mat4 uProjection; | |
| uniform mat4 uModelView; | |
| varying float vLife; | |
| varying vec3 vColor; | |
| varying float vDensity; | |
| void main() { | |
| gl_Position = uProjection * uModelView * vec4(aPosition, 1.0); | |
| gl_PointSize = uPointSize * (0.5 + vDensity); | |
| vLife = aLife; | |
| vColor = aColor; | |
| vDensity = aDensity; | |
| } | |
| `; | |
| const fsSource = ` | |
| precision mediump float; | |
| varying float vLife; | |
| varying vec3 vColor; | |
| varying float vDensity; | |
| void main() { | |
| vec2 coord = gl_PointCoord - vec2(0.5); | |
| float distance = length(coord); | |
| float alpha = vLife * vDensity * (1.0 - smoothstep(0.0, 0.5, distance)); | |
| alpha *= exp(-distance * (2.0 + vDensity)); | |
| gl_FragColor = vec4(vColor, alpha); | |
| } | |
| `; | |
| function compileShader(source, type) { | |
| const shader = gl.createShader(type); | |
| gl.shaderSource(shader, source); | |
| gl.compileShader(shader); | |
| if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { | |
| console.error("Shader compile error:", gl.getShaderInfoLog(shader)); | |
| gl.deleteShader(shader); | |
| return null; | |
| } | |
| return shader; | |
| } | |
| const vertexShader = compileShader(vsSource, gl.VERTEX_SHADER); | |
| const fragmentShader = compileShader(fsSource, gl.FRAGMENT_SHADER); | |
| const program = gl.createProgram(); | |
| gl.attachShader(program, vertexShader); | |
| gl.attachShader(program, fragmentShader); | |
| gl.linkProgram(program); | |
| gl.useProgram(program); | |
| gl.enable(gl.BLEND); | |
| gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); | |
| const aPosition = gl.getAttribLocation(program, "aPosition"); | |
| const aLife = gl.getAttribLocation(program, "aLife"); | |
| const aColor = gl.getAttribLocation(program, "aColor"); | |
| const aDensity = gl.getAttribLocation(program, "aDensity"); | |
| const uPointSize = gl.getUniformLocation(program, "uPointSize"); | |
| const uProjection = gl.getUniformLocation(program, "uProjection"); | |
| const uModelView = gl.getUniformLocation(program, "uModelView"); | |
| // Fixed particle pool system | |
| const PARTICLE_POOL_SIZE = 40000; | |
| const particles = []; | |
| let activeParticleCount = 0; | |
| // Initialize particle pool | |
| for (let i = 0; i < PARTICLE_POOL_SIZE; i++) { | |
| particles.push({ | |
| position: [0, 0, 0], | |
| age: 0, | |
| totalLife: 0, | |
| friction: 1.0, | |
| color: [1.0, 1.0, 1.0], | |
| source: 'cosmic', | |
| density: 0.5, | |
| particleType: 'mixed', | |
| active: false | |
| }); | |
| } | |
| // Performance tracking | |
| let frameCount = 0; | |
| let lastTime = performance.now(); | |
| let fps = 0; | |
| const fpsCounter = document.getElementById('fps-counter'); | |
| const particleCounter = document.getElementById('particle-counter'); | |
| // Particle recycling | |
| function getInactiveParticle() { | |
| for (let i = 0; i < PARTICLE_POOL_SIZE; i++) { | |
| if (!particles[i].active) { | |
| return particles[i]; | |
| } | |
| } | |
| return null; // Pool is full | |
| } | |
| function activateParticle(particle, position, totalLife, friction, color, source, density, particleType) { | |
| particle.position[0] = position[0]; | |
| particle.position[1] = position[1]; | |
| particle.position[2] = position[2]; | |
| particle.age = 0; | |
| particle.totalLife = totalLife; | |
| particle.friction = friction; | |
| particle.color = color; | |
| particle.source = source; | |
| particle.density = density; | |
| particle.particleType = particleType; | |
| particle.active = true; | |
| activeParticleCount++; | |
| } | |
| function deactivateParticle(particle) { | |
| if (particle.active) { | |
| particle.active = false; | |
| activeParticleCount--; | |
| } | |
| } | |
| function getRadiationProperties(material) { | |
| const properties = { | |
| 'Uranium-238': { | |
| length: [15, 25], spacing: 0.08, color: [1.0, 0.8, 0.6], | |
| density: 0.9, curvature: 0.02, scattering: 0.1, type: 'alpha' | |
| }, | |
| 'Cesium-137': { | |
| length: [20, 40], spacing: 0.06, color: [0.8, 1.0, 0.8], | |
| density: 0.6, curvature: 0.05, scattering: 0.3, type: 'beta-gamma' | |
| }, | |
| 'Cobalt-60': { | |
| length: [25, 50], spacing: 0.04, color: [0.6, 0.8, 1.0], | |
| density: 0.3, curvature: 0.001, scattering: 0.05, type: 'gamma' | |
| }, | |
| 'Radium-226': { | |
| length: [12, 20], spacing: 0.1, color: [1.0, 0.6, 0.8], | |
| density: 0.95, curvature: 0.015, scattering: 0.08, type: 'alpha' | |
| }, | |
| 'Strontium-90': { | |
| length: [30, 45], spacing: 0.05, color: [1.0, 1.0, 0.6], | |
| density: 0.5, curvature: 0.08, scattering: 0.4, type: 'beta' | |
| }, | |
| 'Americium-241': { | |
| length: [10, 18], spacing: 0.12, color: [0.8, 0.6, 1.0], | |
| density: 0.85, curvature: 0.025, scattering: 0.12, type: 'alpha' | |
| } | |
| }; | |
| return properties[material] || properties['Uranium-238']; | |
| } | |
| function getCosmicProperties(type) { | |
| const properties = { | |
| 'Muons': { | |
| length: [200, 300], spacing: 0.03, penetration: 0.9, | |
| density: 0.2, curvature: 0.001, scattering: 0.02, type: 'muon' | |
| }, | |
| 'Protons': { | |
| length: [150, 250], spacing: 0.05, penetration: 0.7, | |
| density: 0.6, curvature: 0.03, scattering: 0.15, type: 'proton' | |
| }, | |
| 'Electrons': { | |
| length: [100, 180], spacing: 0.08, penetration: 0.3, | |
| density: 0.15, curvature: 0.12, scattering: 0.6, type: 'electron' | |
| }, | |
| 'Pions': { | |
| length: [180, 280], spacing: 0.06, penetration: 0.6, | |
| density: 0.4, curvature: 0.04, scattering: 0.25, type: 'pion' | |
| }, | |
| 'Mixed': { | |
| length: [150, 300], spacing: 0.04, penetration: 0.8, | |
| density: 0.3, curvature: 0.06, scattering: 0.3, type: 'mixed' | |
| } | |
| }; | |
| return properties[type] || properties['Muons']; | |
| } | |
| function spawnCentralSourceTrail() { | |
| const props = getRadiationProperties(params.radiationType); | |
| const baseLength = props.length[0] + Math.floor(Math.random() * (props.length[1] - props.length[0])); | |
| const length = Math.floor(baseLength * 5 * params.trailLengthScale * props.density); | |
| let dir = [Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5]; | |
| const mag = Math.sqrt(dir[0]**2 + dir[1]**2 + dir[2]**2); | |
| dir[0] /= mag; dir[1] /= mag; dir[2] /= mag; | |
| let currentPos = [spritePosition.x, spritePosition.y, 0]; | |
| let currentDir = [...dir]; | |
| for (let i = 0; i < length; i++) { | |
| if (Math.random() < props.scattering && i > 5) { | |
| const scatterAngle = (Math.random() - 0.5) * props.scattering * 2; | |
| const perpDir = [ | |
| Math.random() - 0.5, | |
| Math.random() - 0.5, | |
| Math.random() - 0.5 | |
| ]; | |
| currentDir[0] += perpDir[0] * scatterAngle; | |
| currentDir[1] += perpDir[1] * scatterAngle; | |
| currentDir[2] += perpDir[2] * scatterAngle; | |
| const newMag = Math.sqrt(currentDir[0]**2 + currentDir[1]**2 + currentDir[2]**2); | |
| currentDir[0] /= newMag; currentDir[1] /= newMag; currentDir[2] /= newMag; | |
| } | |
| const curveFactor = props.curvature * params.curvatureMultiplier * Math.sin(i * 0.1) * (1 + Math.random() * 0.5); | |
| currentDir[0] += curveFactor * (Math.random() - 0.5); | |
| currentDir[1] += curveFactor * (Math.random() - 0.5); | |
| currentDir[2] += curveFactor * (Math.random() - 0.5); | |
| const dirMag = Math.sqrt(currentDir[0]**2 + currentDir[1]**2 + currentDir[2]**2); | |
| currentDir[0] /= dirMag; currentDir[1] /= dirMag; currentDir[2] /= dirMag; | |
| currentPos[0] += currentDir[0] * props.spacing; | |
| currentPos[1] += currentDir[1] * props.spacing; | |
| currentPos[2] += currentDir[2] * props.spacing; | |
| const particle = getInactiveParticle(); | |
| if (particle) { | |
| activateParticle( | |
| particle, | |
| [...currentPos], | |
| params.trailLife + 1.0, | |
| 1.0 - Math.random() * params.frictionVariance, | |
| props.color, | |
| 'central', | |
| props.density, | |
| props.type | |
| ); | |
| } | |
| } | |
| } | |
| function spawnCosmicTrail() { | |
| const props = getCosmicProperties(params.cosmicType); | |
| const baseLength = props.length[0] + Math.floor(Math.random() * (props.length[1] - props.length[0])); | |
| const length = Math.floor(baseLength * 3 * params.trailLengthScale * props.density); | |
| // Generate completely random direction for cosmic particles | |
| const angle1 = Math.random() * Math.PI * 2; | |
| const angle2 = Math.random() * Math.PI; | |
| let currentDir = [ | |
| Math.sin(angle2) * Math.cos(angle1), | |
| Math.sin(angle2) * Math.sin(angle1), | |
| Math.cos(angle2) | |
| ]; | |
| // Start from a random position on the edge of the simulation space | |
| const startDistance = 60 + Math.random() * 20; | |
| const startAngle1 = Math.random() * Math.PI * 2; | |
| const startAngle2 = Math.random() * Math.PI; | |
| let currentPos = [ | |
| Math.sin(startAngle2) * Math.cos(startAngle1) * startDistance, | |
| Math.sin(startAngle2) * Math.sin(startAngle1) * startDistance, | |
| Math.cos(startAngle2) * startDistance | |
| ]; | |
| for (let i = 0; i < length; i++) { | |
| if (Math.random() < props.scattering && i > 10) { | |
| const scatterAngle = (Math.random() - 0.5) * props.scattering; | |
| const perpDir = [ | |
| Math.random() - 0.5, | |
| Math.random() - 0.5, | |
| Math.random() - 0.5 | |
| ]; | |
| currentDir[0] += perpDir[0] * scatterAngle; | |
| currentDir[1] += perpDir[1] * scatterAngle; | |
| currentDir[2] += perpDir[2] * scatterAngle; | |
| const newMag = Math.sqrt(currentDir[0]**2 + currentDir[1]**2 + currentDir[2]**2); | |
| currentDir[0] /= newMag; currentDir[1] /= newMag; currentDir[2] /= newMag; | |
| } | |
| if (props.type === 'electron' || props.type === 'mixed') { | |
| const tangleFactor = props.curvature * params.curvatureMultiplier * (1 + Math.random()); | |
| currentDir[0] += tangleFactor * (Math.random() - 0.5); | |
| currentDir[1] += tangleFactor * (Math.random() - 0.5); | |
| currentDir[2] += tangleFactor * (Math.random() - 0.5); | |
| const dirMag = Math.sqrt(currentDir[0]**2 + currentDir[1]**2 + currentDir[2]**2); | |
| currentDir[0] /= dirMag; currentDir[1] /= dirMag; currentDir[2] /= dirMag; | |
| } | |
| currentPos[0] += currentDir[0] * props.spacing; | |
| currentPos[1] += currentDir[1] * props.spacing; | |
| currentPos[2] += currentDir[2] * props.spacing; | |
| const particle = getInactiveParticle(); | |
| if (particle) { | |
| activateParticle( | |
| particle, | |
| [...currentPos], | |
| (params.trailLife + 1.0) * props.penetration, | |
| 1.0 - Math.random() * params.frictionVariance * 0.5, | |
| [1.0, 1.0, 1.0], | |
| 'cosmic', | |
| props.density, | |
| props.type | |
| ); | |
| } | |
| } | |
| } | |
| // Set up model view matrix (projection is handled in resize function) | |
| glMatrix.mat4.translate(modelView, modelView, [0, 0, -50]); | |
| const positionBuffer = gl.createBuffer(); | |
| const lifeBuffer = gl.createBuffer(); | |
| const colorBuffer = gl.createBuffer(); | |
| const densityBuffer = gl.createBuffer(); | |
| function render() { | |
| // FPS calculation | |
| frameCount++; | |
| const currentTime = performance.now(); | |
| if (currentTime - lastTime >= 1000) { | |
| fps = Math.round((frameCount * 1000) / (currentTime - lastTime)); | |
| frameCount = 0; | |
| lastTime = currentTime; | |
| fpsCounter.textContent = fps; | |
| } | |
| // Update particle count | |
| particleCounter.textContent = activeParticleCount; | |
| gl.clearColor(0, 0, 0, 1); | |
| gl.clear(gl.COLOR_BUFFER_BIT); | |
| if (Math.random() < 0.1 * params.intensity) { | |
| spawnCosmicTrail(); | |
| } | |
| if (Math.random() < 0.1 * params.sourceIntensity) { | |
| spawnCentralSourceTrail(); | |
| } | |
| // Update and deactivate particles | |
| for (let i = 0; i < PARTICLE_POOL_SIZE; i++) { | |
| const p = particles[i]; | |
| if (!p.active) continue; | |
| p.age += 0.01 * p.friction; | |
| // Apply physics if particle is still in visible lifetime | |
| if (p.age <= params.trailLife + 1.0) { | |
| const f = params.heatFlow; | |
| p.position[0] += (Math.random() - 0.5) * f * p.friction; | |
| p.position[1] += (Math.random() - 0.5) * f * p.friction + params.liftForce * p.friction; | |
| p.position[2] += (Math.random() - 0.5) * f * p.friction; | |
| } | |
| // Deactivate particles that have exceeded their lifetime or are out of bounds | |
| if (p.age > p.totalLife || | |
| Math.abs(p.position[0]) > 100 || | |
| Math.abs(p.position[1]) > 100 || | |
| Math.abs(p.position[2]) > 100) { | |
| deactivateParticle(p); | |
| } | |
| } | |
| // Build arrays only for active particles | |
| const posArray = new Float32Array(activeParticleCount * 3); | |
| const lifeArray = new Float32Array(activeParticleCount); | |
| const colorArray = new Float32Array(activeParticleCount * 3); | |
| const densityArray = new Float32Array(activeParticleCount); | |
| let activeIndex = 0; | |
| for (let i = 0; i < PARTICLE_POOL_SIZE; i++) { | |
| const p = particles[i]; | |
| if (!p.active) continue; | |
| posArray.set(p.position, activeIndex * 3); | |
| let alpha = 1.0; | |
| const fadeStart = params.trailLife * 0.7; | |
| if (p.age < fadeStart) { | |
| alpha = 1.0; | |
| } else if (p.age < p.totalLife) { | |
| const fadeProgress = (p.age - fadeStart) / (p.totalLife - fadeStart); | |
| alpha = 1.0 - Math.pow(fadeProgress, 0.8); | |
| } else { | |
| alpha = 0.0; | |
| } | |
| lifeArray[activeIndex] = Math.max(0.0, alpha); | |
| colorArray.set(p.color || [1.0, 1.0, 1.0], activeIndex * 3); | |
| densityArray[activeIndex] = p.density || 0.5; | |
| activeIndex++; | |
| } | |
| gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); | |
| gl.bufferData(gl.ARRAY_BUFFER, posArray, gl.DYNAMIC_DRAW); | |
| gl.enableVertexAttribArray(aPosition); | |
| gl.vertexAttribPointer(aPosition, 3, gl.FLOAT, false, 0, 0); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, lifeBuffer); | |
| gl.bufferData(gl.ARRAY_BUFFER, lifeArray, gl.DYNAMIC_DRAW); | |
| gl.enableVertexAttribArray(aLife); | |
| gl.vertexAttribPointer(aLife, 1, gl.FLOAT, false, 0, 0); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer); | |
| gl.bufferData(gl.ARRAY_BUFFER, colorArray, gl.DYNAMIC_DRAW); | |
| gl.enableVertexAttribArray(aColor); | |
| gl.vertexAttribPointer(aColor, 3, gl.FLOAT, false, 0, 0); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, densityBuffer); | |
| gl.bufferData(gl.ARRAY_BUFFER, densityArray, gl.DYNAMIC_DRAW); | |
| gl.enableVertexAttribArray(aDensity); | |
| gl.vertexAttribPointer(aDensity, 1, gl.FLOAT, false, 0, 0); | |
| gl.uniformMatrix4fv(uProjection, false, projection); | |
| gl.uniformMatrix4fv(uModelView, false, modelView); | |
| gl.uniform1f(uPointSize, 10.0 * params.size); | |
| gl.drawArrays(gl.POINTS, 0, activeParticleCount); | |
| requestAnimationFrame(render); | |
| } | |
| requestAnimationFrame(render); | |
| </script> | |
| </body> | |
| </html> | |