// Additive point sprites for speed streaks, pickups and impacts. One Points // object with a pool of particles, so effects never touch the garbage collector // during play. import * as THREE from 'three'; import { PALETTE } from './config.js'; const MAX = 900; function spriteTexture() { const c = document.createElement('canvas'); c.width = 64; c.height = 64; const ctx = c.getContext('2d'); const g = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); g.addColorStop(0, 'rgba(255,255,255,1)'); g.addColorStop(0.3, 'rgba(255,255,255,0.7)'); g.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, 64, 64); return new THREE.CanvasTexture(c); } export class Particles { constructor(scene) { this.positions = new Float32Array(MAX * 3); this.colors = new Float32Array(MAX * 3); this.sizes = new Float32Array(MAX); this.alpha = new Float32Array(MAX); this.vel = new Float32Array(MAX * 3); this.life = new Float32Array(MAX); this.maxLife = new Float32Array(MAX); this.drag = new Float32Array(MAX); this.gravity = new Float32Array(MAX); this.baseSize = new Float32Array(MAX); this.cursor = 0; const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(this.positions, 3)); geo.setAttribute('aColor', new THREE.BufferAttribute(this.colors, 3)); geo.setAttribute('aSize', new THREE.BufferAttribute(this.sizes, 1)); geo.setAttribute('aAlpha', new THREE.BufferAttribute(this.alpha, 1)); geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, -100), 400); const mat = new THREE.ShaderMaterial({ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, uniforms: { uTexture: { value: spriteTexture() }, uPixelRatio: { value: Math.min(devicePixelRatio, 2) }, }, vertexShader: ` attribute vec3 aColor; attribute float aSize; attribute float aAlpha; uniform float uPixelRatio; varying vec3 vColor; varying float vAlpha; void main() { vColor = aColor; vAlpha = aAlpha; vec4 mv = modelViewMatrix * vec4(position, 1.0); gl_PointSize = aSize * uPixelRatio * (26.0 / max(0.001, -mv.z)); gl_Position = projectionMatrix * mv; } `, fragmentShader: ` uniform sampler2D uTexture; varying vec3 vColor; varying float vAlpha; void main() { vec4 tex = texture2D(uTexture, gl_PointCoord); gl_FragColor = vec4(vColor, tex.a * vAlpha); } `, }); this.points = new THREE.Points(geo, mat); this.points.frustumCulled = false; scene.add(this.points); this.geo = geo; this.tmpColor = new THREE.Color(); } emit(count, opts) { const { position, spread = { x: 0.2, y: 0.2, z: 0.2 }, velocity = { x: 0, y: 0, z: 0 }, velocitySpread = { x: 1, y: 1, z: 1 }, color = 0xffffff, colorJitter = 0, size = 6, sizeSpread = 0, life = 0.6, lifeSpread = 0, gravity = 0, drag = 1.5, } = opts; for (let i = 0; i < count; i++) { const idx = this.cursor; this.cursor = (this.cursor + 1) % MAX; const i3 = idx * 3; this.positions[i3] = position.x + (Math.random() - 0.5) * spread.x; this.positions[i3 + 1] = position.y + (Math.random() - 0.5) * spread.y; this.positions[i3 + 2] = position.z + (Math.random() - 0.5) * spread.z; this.vel[i3] = velocity.x + (Math.random() - 0.5) * velocitySpread.x; this.vel[i3 + 1] = velocity.y + (Math.random() - 0.5) * velocitySpread.y; this.vel[i3 + 2] = velocity.z + (Math.random() - 0.5) * velocitySpread.z; this.tmpColor.setHex(color); const j = 1 + (Math.random() - 0.5) * colorJitter; this.colors[i3] = THREE.MathUtils.clamp(this.tmpColor.r * j, 0, 4); this.colors[i3 + 1] = THREE.MathUtils.clamp(this.tmpColor.g * j, 0, 4); this.colors[i3 + 2] = THREE.MathUtils.clamp(this.tmpColor.b * j, 0, 4); this.baseSize[idx] = Math.max(0.5, size + (Math.random() - 0.5) * sizeSpread); this.sizes[idx] = this.baseSize[idx]; this.maxLife[idx] = life + Math.random() * lifeSpread; this.life[idx] = this.maxLife[idx]; this.alpha[idx] = 1; this.drag[idx] = drag; this.gravity[idx] = gravity; } } // Trail behind the player, denser and longer at speed. Emitted slightly behind // so it reads as an exhaust plume rather than a pool of light under the node. trail(dt, position, speedNorm, boosting) { const rate = 40 + speedNorm * 190 + (boosting ? 260 : 0); const count = Math.min(24, Math.round(rate * dt)); this.emit(count, { position: { x: position.x, y: position.y, z: position.z - 0.7 }, spread: { x: 0.34, y: 0.3, z: 0.16 }, velocity: { x: 0, y: 0.4, z: 7 + speedNorm * 14 }, velocitySpread: { x: 1.5, y: 1.2, z: 4 }, color: boosting ? PALETTE.ice : PALETTE.redBright, colorJitter: 0.4, size: boosting ? 6 : 4, sizeSpread: 2.5, life: 0.35 + speedNorm * 0.25, drag: 2.6, }); } burst(position, { color = PALETTE.red, count = 46, power = 8 } = {}) { this.emit(count, { position, spread: { x: 0.3, y: 0.3, z: 0.3 }, velocity: { x: 0, y: 1.5, z: 0 }, velocitySpread: { x: power, y: power, z: power }, color, colorJitter: 0.5, size: 9, sizeSpread: 6, life: 0.7, lifeSpread: 0.4, gravity: -12, drag: 2.2, }); } ring(position, color) { const count = 34; for (let i = 0; i < count; i++) { const a = (i / count) * Math.PI * 2; this.emit(1, { position, spread: { x: 0.05, y: 0.05, z: 0.05 }, velocity: { x: Math.cos(a) * 6, y: Math.sin(a) * 6, z: 0 }, velocitySpread: { x: 0.4, y: 0.4, z: 0.4 }, color, size: 8, life: 0.45, drag: 4, }); } } // Sparks skidding along the road while landing or boosting. ground(position, color) { this.emit(3, { position, spread: { x: 0.5, y: 0.05, z: 0.3 }, velocity: { x: 0, y: 1.4, z: 5 }, velocitySpread: { x: 2.4, y: 1.6, z: 3 }, color, size: 5, sizeSpread: 3, life: 0.4, gravity: -18, drag: 2, }); } update(dt) { let live = 0; for (let i = 0; i < MAX; i++) { if (this.life[i] <= 0) { if (this.alpha[i] !== 0) this.alpha[i] = 0; continue; } live++; this.life[i] -= dt; const t = Math.max(0, this.life[i] / this.maxLife[i]); const i3 = i * 3; const d = Math.max(0, 1 - this.drag[i] * dt); this.vel[i3] *= d; this.vel[i3 + 1] = this.vel[i3 + 1] * d + this.gravity[i] * dt; this.vel[i3 + 2] *= d; this.positions[i3] += this.vel[i3] * dt; this.positions[i3 + 1] += this.vel[i3 + 1] * dt; this.positions[i3 + 2] += this.vel[i3 + 2] * dt; this.alpha[i] = t * t; this.sizes[i] = this.baseSize[i] * (0.35 + t * 0.65); if (this.life[i] <= 0) this.alpha[i] = 0; } this.geo.attributes.position.needsUpdate = true; this.geo.attributes.aColor.needsUpdate = true; this.geo.attributes.aSize.needsUpdate = true; this.geo.attributes.aAlpha.needsUpdate = true; return live; } clear() { this.life.fill(0); this.alpha.fill(0); this.geo.attributes.aAlpha.needsUpdate = true; } }