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/[email protected]/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> | |