From ab86ac993e7660fce0f7e230275f0bab69ba70a6 Mon Sep 17 00:00:00 2001 From: mfranzon <43796979+mfranzon@users.noreply.github.com> Date: Sat, 18 Oct 2025 00:35:57 +0200 Subject: [PATCH] particles optimization --- src/js/3d/coolerParticles.js | 86 ++++++++++-------- src/js/3d/particleCollisions.js | 156 ++++++++++++++++++++------------ src/js/3d/rackParticles.js | 79 +++++++++------- 3 files changed, 190 insertions(+), 131 deletions(-) diff --git a/src/js/3d/coolerParticles.js b/src/js/3d/coolerParticles.js index a6caebc..c5923ac 100644 --- a/src/js/3d/coolerParticles.js +++ b/src/js/3d/coolerParticles.js @@ -9,6 +9,8 @@ export let particleSystems = [] // along z on the plane parrallel to the floor is constant const accelerations = [0.5, 1.5, 1] +let frameCount = 0 + /** * createCoolerParticles creates and attaches a particle system to a cooler object. * @@ -16,7 +18,7 @@ const accelerations = [0.5, 1.5, 1] * @returns {void} */ export function createCoolerParticles(coolerObject) { - const particleCount = 500 + const particleCount = 200 const particlesGeometry = new THREE.BufferGeometry() const positions = new Float32Array(particleCount * 3) const worldPositions = new Float32Array(particleCount * 3) @@ -83,6 +85,9 @@ export function createCoolerParticles(coolerObject) { * @returns {void} */ export function updateCoolerParticles() { + frameCount++ + const doRaycast = frameCount % 5 === 0 // Raycast every 5 frames + particleSystems.forEach((particleData) => { const geometry = particleData.geometry const positions = geometry.attributes.position.array @@ -115,19 +120,19 @@ export function updateCoolerParticles() { positions[i + 1] += velocities[i + 1] * accelerations[1] positions[i + 2] += velocities[i + 2] * accelerations[2] - // Add some turbulence - velocities[i] += (Math.random() - 0.5) * 0.001 - velocities[i + 1] += (Math.random() - 0.5) * 0.01 - velocities[i + 2] += (Math.random() - 0.5) * 0.02 // it expands along z-axis orthogonal to main direction x + // Add some turbulence (reduced frequency) + if (doRaycast) { + velocities[i] += (Math.random() - 0.5) * 0.001 + velocities[i + 1] += (Math.random() - 0.5) * 0.01 + velocities[i + 2] += (Math.random() - 0.5) * 0.02 + } - // Check for collisions + // Convert particle position from local cooler space to world space const particlePosition = new THREE.Vector3( positions[i], positions[i + 1], positions[i + 2] ) - - // Convert particle position from local cooler space to world space const worldPosition = particlePosition.clone() particleData.cooler.localToWorld(worldPosition) @@ -136,43 +141,46 @@ export function updateCoolerParticles() { worldPositions[i + 1] = worldPosition['y'] worldPositions[i + 2] = worldPosition['z'] - // Set up raycaster from particle position - const rayDirection = new THREE.Vector3( - velocities[i], - velocities[i + 1], - velocities[i + 2] - ).normalize() + if (doRaycast) { + // Check for collisions + // Set up raycaster from particle position + const rayDirection = new THREE.Vector3( + velocities[i], + velocities[i + 1], + velocities[i + 2] + ).normalize() - raycasterCollision.set(worldPosition, rayDirection) + raycasterCollision.set(worldPosition, rayDirection) - // Get all objects except the cooler itself - const filteredObjects = models.filter( - (obj) => obj !== particleData.cooler - ) - // Take only the object itself without its particles if present - const filteredObjectsMeshes = filteredObjects.map((obj) => - obj.getObjectByProperty('type', 'Mesh') - ) - const objectsToTest = [...filteredObjectsMeshes, floor, ...walls] + // Get all objects except the cooler itself + const filteredObjects = models.filter( + (obj) => obj !== particleData.cooler + ) + // Take only the object itself without its particles if present + const filteredObjectsMeshes = filteredObjects.map((obj) => + obj.getObjectByProperty('type', 'Mesh') + ) + const objectsToTest = [...filteredObjectsMeshes, floor, ...walls] - const intersects = raycasterCollision.intersectObjects( - objectsToTest, - false // do not check descendants - ) + const intersects = raycasterCollision.intersectObjects( + objectsToTest, + false // do not check descendants + ) - // Check if collision is close enough (within particle size) - if (intersects.length > 0 && intersects[0].distance < 0.1) { - // Change particle color on collision - colors[i] = 1.0 // Red - colors[i + 1] = 0.5 // Orange-ish - colors[i + 2] = 0.0 // Blue = 0 + // Check if collision is close enough (within particle size) + if (intersects.length > 0 && intersects[0].distance < 0.1) { + // Change particle color on collision + colors[i] = 1.0 // Red + colors[i + 1] = 0.5 // Orange-ish + colors[i + 2] = 0.0 // Blue = 0 - // Stop the particle completely on wall collision - velocities[i] = 0 - velocities[i + 1] = 0 - velocities[i + 2] = 0 + // Stop the particle completely on wall collision + velocities[i] = 0 + velocities[i + 1] = 0 + velocities[i + 2] = 0 - maxLifetime[particleIndex] *= 0.9 // Reduce maxLifetime to avoid accumulation + maxLifetime[particleIndex] *= 0.9 // Reduce maxLifetime to avoid accumulation + } } } diff --git a/src/js/3d/particleCollisions.js b/src/js/3d/particleCollisions.js index a81a9bb..2c581dd 100644 --- a/src/js/3d/particleCollisions.js +++ b/src/js/3d/particleCollisions.js @@ -2,6 +2,46 @@ import * as THREE from 'three' import { particleSystems as rackParticleSystems } from './rackParticles.js' import { particleSystems as coolerParticleSystems } from './coolerParticles.js' +const GRID_SIZE = 1.0 // Cell size for spatial partitioning +const COLLISION_DISTANCE = 0.1 + +// Spatial hash for particles +function getGridKey(x, y, z) { + const ix = Math.floor(x / GRID_SIZE) + const iy = Math.floor(y / GRID_SIZE) + const iz = Math.floor(z / GRID_SIZE) + return `${ix},${iy},${iz}` +} + +function buildSpatialHash(particleSystems) { + const hash = new Map() + particleSystems.forEach((systemData) => { + const worldPositions = systemData.geometry.attributes.worldPosition.array + for (let i = 0; i < worldPositions.length; i += 3) { + const x = worldPositions[i] + const y = worldPositions[i + 1] + const z = worldPositions[i + 2] + const key = getGridKey(x, y, z) + if (!hash.has(key)) hash.set(key, []) + hash.get(key).push({ systemData, index: i }) + } + }) + return hash +} + +function getNearbyCells(key) { + const [ix, iy, iz] = key.split(',').map(Number) + const cells = [] + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + for (let dz = -1; dz <= 1; dz++) { + cells.push(`${ix + dx},${iy + dy},${iz + dz}`) + } + } + } + return cells +} + /** * checkInterParticleCollisions checks for collisions between particles emitted from racks (red) * and particles emitted from coolers (blue). @@ -12,70 +52,68 @@ import { particleSystems as coolerParticleSystems } from './coolerParticles.js' * @returns {void} */ export function checkInterParticleCollisions() { - rackParticleSystems.forEach((rackData) => { - coolerParticleSystems.forEach((coolerData) => { - checkCollisionsBetweenSystems(rackData, coolerData) + // Build spatial hashes for efficient collision detection + const rackHash = buildSpatialHash(rackParticleSystems) + const coolerHash = buildSpatialHash(coolerParticleSystems) + + // Check collisions using spatial partitioning + const checkedPairs = new Set() // To avoid duplicate checks + + rackHash.forEach((rackParticles, key) => { + const nearbyCells = getNearbyCells(key) + nearbyCells.forEach((cellKey) => { + if (coolerHash.has(cellKey)) { + const coolerParticles = coolerHash.get(cellKey) + rackParticles.forEach((rackParticle) => { + coolerParticles.forEach((coolerParticle) => { + const pairKey = `${rackParticle.systemData.system.id}-${rackParticle.index}-${coolerParticle.systemData.system.id}-${coolerParticle.index}` + if (!checkedPairs.has(pairKey)) { + checkedPairs.add(pairKey) + checkCollisionBetweenParticles(rackParticle, coolerParticle) + } + }) + }) + } }) }) } -function checkCollisionsBetweenSystems(rackData, coolerData) { - const rackGeometry = rackData.geometry - const coolerGeometry = coolerData.geometry +function checkCollisionBetweenParticles(rackParticle, coolerParticle) { + const rackWorldPositions = + rackParticle.systemData.geometry.attributes.worldPosition.array + const coolerWorldPositions = + coolerParticle.systemData.geometry.attributes.worldPosition.array - const rackWorldPositions = rackGeometry.attributes.worldPosition.array - const coolerWorldPositions = coolerGeometry.attributes.worldPosition.array + const rackPos = new THREE.Vector3( + rackWorldPositions[rackParticle.index], + rackWorldPositions[rackParticle.index + 1], + rackWorldPositions[rackParticle.index + 2] + ) - // Check for collisions between each rack particle and cooler particle - const collisionDistance = 0.1 // Same as used in individual particle collision detection + const coolerPos = new THREE.Vector3( + coolerWorldPositions[coolerParticle.index], + coolerWorldPositions[coolerParticle.index + 1], + coolerWorldPositions[coolerParticle.index + 2] + ) - // Track which rack particles have already collided this frame - const collidedRackParticles = new Set() + const distance = rackPos.distanceTo(coolerPos) - for ( - let rackIndex = 0; - rackIndex < rackWorldPositions.length; - rackIndex += 3 - ) { - // Skip if this rack particle already collided - if (collidedRackParticles.has(rackIndex)) { - continue - } - - const rackPos = new THREE.Vector3( - rackWorldPositions[rackIndex], - rackWorldPositions[rackIndex + 1], - rackWorldPositions[rackIndex + 2] + if (distance < COLLISION_DISTANCE) { + // Collision detected! + handleCollision( + rackParticle.systemData, + rackParticle.index, + coolerParticle.systemData, + coolerParticle.index ) - - for ( - let coolerIndex = 0; - coolerIndex < coolerWorldPositions.length; - coolerIndex += 3 - ) { - const coolerPos = new THREE.Vector3( - coolerWorldPositions[coolerIndex], - coolerWorldPositions[coolerIndex + 1], - coolerWorldPositions[coolerIndex + 2] - ) - const distance = rackPos.distanceTo(coolerPos) - - if (distance < collisionDistance) { - // Collision detected! - handleCollision(rackData, rackIndex, coolerData, coolerIndex) - // Mark this rack particle as collided so it won't hit other particles - collidedRackParticles.add(rackIndex) - break // Stop checking this rack particle against other cooler particles - } - } } } function handleCollision( rackData, - rackParticleIndex, + rackArrayIndex, coolerData, - coolerParticleIndex + coolerArrayIndex ) { // Make both particles disappear immediately by setting their lifetime to max const rackGeometry = rackData.geometry @@ -86,18 +124,20 @@ function handleCollision( const coolerMaxLifetimes = coolerGeometry.attributes.maxLifetime.array const coolerColors = coolerGeometry.attributes.color.array - // Rember to multiply indexes by 3 as colors are defined 3 dimensional vectors - rackColors[rackParticleIndex] = 1.0 // Red - rackColors[rackParticleIndex + 1] = 0.5 // Yellow - rackColors[rackParticleIndex + 2] = 0.0 // Blue + // Colors are at arrayIndex + rackColors[rackArrayIndex] = 1.0 // Red + rackColors[rackArrayIndex + 1] = 0.5 // Yellow + rackColors[rackArrayIndex + 2] = 0.0 // Blue - coolerColors[coolerParticleIndex] = 1.0 // Red - coolerColors[coolerParticleIndex + 1] = 0.5 // Yellow - coolerColors[coolerParticleIndex + 2] = 0.0 // Blue + coolerColors[coolerArrayIndex] = 1.0 // Red + coolerColors[coolerArrayIndex + 1] = 0.5 // Yellow + coolerColors[coolerArrayIndex + 2] = 0.0 // Blue // Reduce maxLifetimes to avoid accumulation - rackMaxLifetimes[rackParticleIndex / 3] *= 0.9 - coolerMaxLifetimes[coolerParticleIndex / 3] *= 0.9 + const rackParticleIndex = rackArrayIndex / 3 + const coolerParticleIndex = coolerArrayIndex / 3 + rackMaxLifetimes[rackParticleIndex] *= 0.9 + coolerMaxLifetimes[coolerParticleIndex] *= 0.9 rackGeometry.attributes.maxLifetime.needsUpdate = true coolerGeometry.attributes.maxLifetime.needsUpdate = true diff --git a/src/js/3d/rackParticles.js b/src/js/3d/rackParticles.js index 2e4d242..2b48fe9 100644 --- a/src/js/3d/rackParticles.js +++ b/src/js/3d/rackParticles.js @@ -9,6 +9,8 @@ export let particleSystems = [] // velocity reduced at every update along z (backwards on the main direction, due to friction) const accelerations = [1, 1.2, 0.3] +let frameCount = 0 + /** * createRackParticles creates a particle system attached to a given rack object. * @@ -16,7 +18,7 @@ const accelerations = [1, 1.2, 0.3] * @returns {void} */ export function createRackParticles(rackObject) { - const particleCount = 250 + const particleCount = 100 const particlesGeometry = new THREE.BufferGeometry() const positions = new Float32Array(particleCount * 3) const worldPositions = new Float32Array(particleCount * 3) @@ -84,6 +86,9 @@ export function createRackParticles(rackObject) { * @returns {void} */ export function updateRackParticles() { + frameCount++ + const doRaycast = frameCount % 5 === 0 // Raycast every 5 frames + particleSystems.forEach((particleData) => { const geometry = particleData.geometry const positions = geometry.attributes.position.array @@ -116,10 +121,12 @@ export function updateRackParticles() { positions[i + 1] += velocities[i + 1] * accelerations[1] positions[i + 2] += velocities[i + 2] * accelerations[2] - // // Add some turbulence - // velocities[i] += (Math.random() - 0.5) * 0.0001 - // velocities[i + 1] += (Math.random() - 0.5) * 0.0001 - // velocities[i + 2] += (Math.random() - 0.5) * 0.0001 // it expands along z-axis orthogonal to main direction x + // Add some turbulence (reduced frequency) + if (doRaycast) { + velocities[i] += (Math.random() - 0.5) * 0.0001 + velocities[i + 1] += (Math.random() - 0.5) * 0.0001 + velocities[i + 2] += (Math.random() - 0.5) * 0.0001 + } // Check for collisions const particlePosition = new THREE.Vector3( @@ -137,41 +144,45 @@ export function updateRackParticles() { worldPositions[i + 1] = worldPosition['y'] worldPositions[i + 2] = worldPosition['z'] - // Set up raycaster from particle position - const rayDirection = new THREE.Vector3( - velocities[i], - velocities[i + 1], - velocities[i + 2] - ).normalize() + if (doRaycast) { + // Set up raycaster from particle position + const rayDirection = new THREE.Vector3( + velocities[i], + velocities[i + 1], + velocities[i + 2] + ).normalize() - raycasterCollision.set(worldPosition, rayDirection) + raycasterCollision.set(worldPosition, rayDirection) - // Get all objects except the rack itself - const filteredObjects = models.filter((obj) => obj !== particleData.rack) - // Take only the object itself without its particles if present - const filteredObjectsMeshes = filteredObjects.map((obj) => - obj.getObjectByProperty('type', 'Mesh') - ) - const objectsToTest = [...filteredObjectsMeshes, floor, ...walls] + // Get all objects except the rack itself + const filteredObjects = models.filter( + (obj) => obj !== particleData.rack + ) + // Take only the object itself without its particles if present + const filteredObjectsMeshes = filteredObjects.map((obj) => + obj.getObjectByProperty('type', 'Mesh') + ) + const objectsToTest = [...filteredObjectsMeshes, floor, ...walls] - const intersects = raycasterCollision.intersectObjects( - objectsToTest, - false // do not check descendants - ) + const intersects = raycasterCollision.intersectObjects( + objectsToTest, + false // do not check descendants + ) - // Check if collision is close enough (within particle size) - if (intersects.length > 0 && intersects[0].distance < 0.1) { - // Change particle color on collision - colors[i] = 1.0 // Red - colors[i + 1] = 0.5 // Orange-ish - colors[i + 2] = 0.0 // Blue = 0 + // Check if collision is close enough (within particle size) + if (intersects.length > 0 && intersects[0].distance < 0.1) { + // Change particle color on collision + colors[i] = 1.0 // Red + colors[i + 1] = 0.5 // Orange-ish + colors[i + 2] = 0.0 // Blue = 0 - // Stop the particle completely on wall collision - velocities[i] = 0 - velocities[i + 1] = 0 - velocities[i + 2] = 0 + // Stop the particle completely on wall collision + velocities[i] = 0 + velocities[i + 1] = 0 + velocities[i + 2] = 0 - maxLifetime[particleIndex] *= 0.9 // Reduce maxLifetime to avoid accumulation + maxLifetime[particleIndex] *= 0.9 // Reduce maxLifetime to avoid accumulation + } } }