edge-runner / src /particles.js
jedisct1's picture
Update Edge Runner
939b5a8 verified
Raw History Blame Contribute Delete
7.54 kB
// 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;
}
}