Skip to content

Instantly share code, notes, and snippets.

@lukevanin
Created March 22, 2025 08:44
Show Gist options
  • Save lukevanin/f7bf87ee42c97964e74c4df42f8c709b to your computer and use it in GitHub Desktop.
Save lukevanin/f7bf87ee42c97964e74c4df42f8c709b to your computer and use it in GitHub Desktop.
Vibe coded particle system
import * as THREE from 'three';
import { World } from './world';
/**
* Particle configuration class
*/
export class ParticleConfig {
name: string;
textures: string[];
lifetime: number;
birthrate: number;
size?: number[];
color?: number[];
opacity?: number[];
velocity?: number[];
spread?: number;
worldSpace?: boolean;
animateOpacity?: boolean;
animateScale?: boolean;
animatePosition?: boolean;
numberOfParticles?: number;
planeSize?: number[];
blendMode?: string;
constructor(data: any) {
this.name = data.name || 'default';
this.textures = data.textures || [];
this.lifetime = data.lifetime || 1.0;
this.birthrate = data.birthrate || 10;
this.size = data.size || [0.1, 0.1];
this.color = data.color || [255, 255, 255];
this.opacity = data.opacity || [1.0, 0.0];
this.velocity = data.velocity || [0.1, 0.1];
this.spread = data.spread || 30;
this.worldSpace = data.worldSpace || false;
this.animateOpacity = data.animateOpacity !== undefined ? data.animateOpacity : true;
this.animateScale = data.animateScale !== undefined ? data.animateScale : true;
this.animatePosition = data.animatePosition !== undefined ? data.animatePosition : true;
this.numberOfParticles = data.numberOfParticles || 100; // Default max particle count
this.planeSize = data.planeSize || [0.5, 0.5]; // Default plane size (width, height)
this.blendMode = data.blendMode || 'normal'; // Default blend mode
}
}
/**
* Particle class representing a single particle instance
*/
export class Particle {
private mesh: THREE.Mesh;
private startTime: number;
private lifetime: number;
private initialSize: number;
private finalSize: number;
private initialOpacity: number;
private finalOpacity: number;
private velocity: THREE.Vector3;
private animateOpacity: boolean;
private animateScale: boolean;
private animatePosition: boolean;
private textureLoader: THREE.TextureLoader;
constructor(
position: THREE.Vector3,
lifetime: number,
size: number[],
color: number[],
opacity: number[],
velocity: THREE.Vector3,
options?: {
animateOpacity?: boolean;
animateScale?: boolean;
animatePosition?: boolean;
texturePath?: string;
planeSize?: number[];
blendMode?: string;
}
) {
this.textureLoader = new THREE.TextureLoader();
// Default options
const defaultOptions = options || {};
const planeSize = defaultOptions.planeSize || [0.5, 0.5];
// Create a plane for the particle
const geometry = new THREE.PlaneGeometry(planeSize[0], planeSize[1]);
// Create material - if texture path is provided, use it; otherwise use color
let material: THREE.Material;
if (defaultOptions.texturePath) {
// Create textured material
material = new THREE.MeshBasicMaterial({
map: this.textureLoader.load(defaultOptions.texturePath),
transparent: true,
opacity: opacity[0],
depthWrite: false,
side: THREE.DoubleSide
});
} else {
// Create colored material
material = new THREE.MeshBasicMaterial({
color: new THREE.Color(
color[0] / 255,
color[1] / 255,
color[2] / 255
),
transparent: true,
opacity: opacity[0],
depthWrite: false,
side: THREE.DoubleSide
});
}
// Apply blend mode if specified
const blendMode = defaultOptions.blendMode || 'normal';
this.applyBlendMode(material as THREE.MeshBasicMaterial, blendMode);
this.mesh = new THREE.Mesh(geometry, material);
this.mesh.position.copy(position);
this.mesh.scale.set(size[0], size[0], size[0]); // Initial size
// Make the particle face the camera
// this.mesh.rotation.set(-Math.PI / 2, 0, 0); // Initially face up (will be updated to face camera)
this.startTime = performance.now() / 1000; // Current time in seconds
this.lifetime = lifetime;
this.initialSize = size[0];
this.finalSize = size[1];
this.initialOpacity = opacity[0];
this.finalOpacity = opacity[1];
this.velocity = velocity.clone();
// Set animation flags with defaults
this.animateOpacity = defaultOptions.animateOpacity !== undefined ? defaultOptions.animateOpacity : true;
this.animateScale = defaultOptions.animateScale !== undefined ? defaultOptions.animateScale : true;
this.animatePosition = defaultOptions.animatePosition !== undefined ? defaultOptions.animatePosition : true;
}
/**
* Configure an existing particle with new parameters
* @param position New position for the particle
* @param lifetime New lifetime
* @param size New size range [initial, final]
* @param color New color
* @param opacity New opacity range [initial, final]
* @param velocity New velocity
* @param options Animation options
*/
public configure(
position: THREE.Vector3,
lifetime: number,
size: number[],
color: number[],
opacity: number[],
velocity: THREE.Vector3,
options?: {
animateOpacity?: boolean;
animateScale?: boolean;
animatePosition?: boolean;
texturePath?: string;
planeSize?: number[];
blendMode?: string;
}
): void {
// Update mesh position
this.mesh.position.copy(position);
// Handle texture or color update
const material = this.mesh.material as THREE.MeshBasicMaterial;
if (options?.texturePath) {
// Update texture if provided
material.map = this.textureLoader.load(options.texturePath);
} else {
// Update color if no texture
material.color.setRGB(
color[0] / 255,
color[1] / 255,
color[2] / 255
);
}
// If plane size changed, update geometry
if (options?.planeSize &&
((this.mesh.geometry as THREE.PlaneGeometry).parameters.width !== options.planeSize[0] ||
(this.mesh.geometry as THREE.PlaneGeometry).parameters.height !== options.planeSize[1])) {
// Dispose old geometry
this.mesh.geometry.dispose();
// Create new geometry with updated size
this.mesh.geometry = new THREE.PlaneGeometry(options.planeSize[0], options.planeSize[1]);
}
// Apply blend mode if specified
if (options?.blendMode) {
this.applyBlendMode(material, options.blendMode);
}
// Update material opacity
material.opacity = opacity[0];
// Update mesh scale
this.mesh.scale.set(size[0], size[0], size[0]);
// Reset rotation to face up (will be updated to face camera)
this.mesh.rotation.set(-Math.PI / 2, 0, 0);
// Update particle properties
this.startTime = performance.now() / 1000;
this.lifetime = lifetime;
this.initialSize = size[0];
this.finalSize = size[1];
this.initialOpacity = opacity[0];
this.finalOpacity = opacity[1];
this.velocity = velocity.clone();
// Update animation flags
if (options) {
this.animateOpacity = options.animateOpacity !== undefined ? options.animateOpacity : this.animateOpacity;
this.animateScale = options.animateScale !== undefined ? options.animateScale : this.animateScale;
this.animatePosition = options.animatePosition !== undefined ? options.animatePosition : this.animatePosition;
}
}
/**
* Get the mesh of this particle
*/
getMesh(): THREE.Mesh {
return this.mesh;
}
/**
* Update the particle
* @param currentTime Current time in seconds
* @returns True if particle is still alive, false if it should be removed
*/
update(currentTime: number): boolean {
const age = currentTime - this.startTime;
// Check if particle has exceeded its lifetime
if (age >= this.lifetime) {
return false;
}
// Calculate life progress (0 to 1)
const lifeProgress = age / this.lifetime;
// Update position based on velocity if animatePosition is true
if (this.animatePosition) {
this.mesh.position.add(this.velocity.clone().multiplyScalar(0.016)); // Assuming 60fps
}
// Update size (linear interpolation) if animateScale is true
if (this.animateScale) {
const currentSize = this.initialSize + (this.finalSize - this.initialSize) * lifeProgress;
this.mesh.scale.set(currentSize, currentSize, currentSize);
}
// Update opacity (linear interpolation) if animateOpacity is true
if (this.animateOpacity) {
const material = this.mesh.material as THREE.MeshBasicMaterial;
material.opacity = this.initialOpacity + (this.finalOpacity - this.initialOpacity) * lifeProgress;
}
// Always make the particle face the camera by using billboard technique
// This will be called in the emitter's update method
return true;
}
/**
* Make the particle face the camera (billboard technique)
* @param camera The camera to face
*/
public faceCamera(camera: THREE.Camera): void {
// this.mesh.lookAt(camera.position);
const cameraPosition = camera.position.clone();
// Project camera position onto the XZ plane (ignore Y for horizontal facing)
// const target = new THREE.Vector3(cameraPosition.x, this.mesh.position.y, cameraPosition.z);
// const target = new THREE.Vector3(this.mesh.position.x, cameraPosition.y, cameraPosition.z);
// Make the billboard look at the camera, but only rotate around Y-axis
this.mesh.lookAt(cameraPosition);
}
/**
* Dispose of the particle resources
*/
dispose(): void {
if (this.mesh.parent) {
this.mesh.parent.remove(this.mesh);
}
(this.mesh.geometry as THREE.BufferGeometry).dispose();
const material = this.mesh.material as THREE.MeshBasicMaterial;
if (material.map) {
material.map.dispose();
}
material.dispose();
}
/**
* Applies a blend mode to a material
* @param material The material to apply the blend mode to
* @param blendMode The blend mode to apply: 'normal', 'add', 'multiply', 'screen', etc.
*/
private applyBlendMode(material: THREE.MeshBasicMaterial, blendMode: string): void {
// TODO: Only update the material if the blend mode has changed
// Set up blending mode
switch (blendMode.toLowerCase()) {
case 'add':
case 'additive':
material.blending = THREE.AdditiveBlending;
break;
case 'multiply':
material.blending = THREE.MultiplyBlending;
break;
case 'screen':
material.blending = THREE.CustomBlending;
material.blendSrc = THREE.OneFactor;
material.blendDst = THREE.OneMinusSrcColorFactor;
break;
case 'normal':
default:
material.blending = THREE.NormalBlending;
break;
}
}
}
/**
* Particle emitter class for creating visual effects
*/
export class ParticleEmitter {
private attachmentTarget: THREE.Object3D;
private attachmentOffset: THREE.Vector3;
private debugMesh: THREE.Mesh;
private isAttached: boolean = false;
private isEmitting: boolean = false;
private config: ParticleConfig | null = null;
private activeParticles: Particle[] = [];
private inactiveParticles: Particle[] = [];
private lastEmitTime: number = 0;
private clock: THREE.Clock;
private world: World | null = null;
/**
* Create a particle emitter
* @param target THREE.js node where particles will be emitted
* @param offset Relative offset on the node where the emitter will be attached
* @param config Optional particle configuration
* @param world Reference to the world
*/
constructor(target: THREE.Object3D, offset: THREE.Vector3, config?: ParticleConfig, world?: World | null) {
this.attachmentTarget = target;
this.attachmentOffset = offset.clone();
this.config = config || null;
this.world = world || null;
this.clock = new THREE.Clock();
// Create debug visualization (magenta sphere with radius 0.2)
const geometry = new THREE.SphereGeometry(0.05, 16, 16);
const material = new THREE.MeshBasicMaterial({ color: 0xff00ff });
this.debugMesh = new THREE.Mesh(geometry, material);
// Attach to target
this.attach();
// Initialize the particle pool if we have a config
if (this.config) {
this.initializeParticlePool();
}
}
/**
* Set the particle configuration
* @param config Particle configuration to use
*/
public setConfig(config: ParticleConfig): void {
this.config = config;
// Clear any existing particles
this.clearParticles();
// Initialize the particle pool with the new config
this.initializeParticlePool();
}
/**
* Initialize the particle pool with inactive particles
*/
private initializeParticlePool(): void {
if (!this.config) return;
const maxParticles = this.config.numberOfParticles || 100;
// Clear existing pools
this.clearParticles();
// Create particles and add them to the inactive pool
for (let i = 0; i < maxParticles; i++) {
const particle = this.createParticle();
// Hide the particle initially
particle.getMesh().visible = false;
// Add to inactive pool
this.inactiveParticles.push(particle);
}
console.log(`Initialized particle pool with ${maxParticles} particles for emitter ${this.config.name}`);
}
/**
* Create a new particle without configuring it
* @returns A new particle instance
*/
private createParticle(): Particle {
// Create with dummy values - will be configured when activated
const position = new THREE.Vector3();
const lifetime = 1.0;
const size = [0.1, 0.1];
const color = [255, 255, 255];
const opacity = [1.0, 0.0];
const velocity = new THREE.Vector3();
// Get plane size from config if available
const planeSize = this.config?.planeSize || [0.5, 0.5];
// Select a random texture if textures are available in config
let texturePath: string | undefined = undefined;
if (this.config?.textures && this.config.textures.length > 0) {
const randomIndex = Math.floor(Math.random() * this.config.textures.length);
texturePath = this.config.textures[randomIndex];
}
// Get blend mode from config
const blendMode = this.config?.blendMode || 'normal';
return new Particle(
position,
lifetime,
size,
color,
opacity,
velocity,
{
planeSize: planeSize,
texturePath: texturePath,
blendMode: blendMode
}
);
}
/**
* Clear all particles from both active and inactive pools
*/
private clearParticles(): void {
// Dispose active particles
for (const particle of this.activeParticles) {
particle.dispose();
}
// Dispose inactive particles
for (const particle of this.inactiveParticles) {
particle.dispose();
}
// Clear arrays
this.activeParticles = [];
this.inactiveParticles = [];
}
/**
* Get the current particle configuration
* @returns The current particle configuration or null if not set
*/
public getConfig(): ParticleConfig | null {
return this.config;
}
/**
* Attach the emitter to its target
*/
public attach(): void {
if (!this.isAttached && this.attachmentTarget) {
// this.attachmentTarget.add(this.debugMesh);
// this.debugMesh.position.copy(this.attachmentOffset);
this.isAttached = true;
}
}
/**
* Detach the emitter from its target
*/
public detach(): void {
if (this.isAttached && this.attachmentTarget) {
this.attachmentTarget.remove(this.debugMesh);
this.isAttached = false;
}
}
/**
* Change the attachment target
* @param newTarget New THREE.js node to attach to
* @param newOffset Optional new offset position
*/
public setTarget(newTarget: THREE.Object3D, newOffset?: THREE.Vector3): void {
// Detach from current target
this.detach();
// Update target and offset
this.attachmentTarget = newTarget;
if (newOffset) {
this.attachmentOffset = newOffset.clone();
}
// Attach to new target
this.attach();
}
/**
* Start emitting particles
*/
public start(): void {
this.isEmitting = true;
this.debugMesh.visible = true;
this.lastEmitTime = this.clock.getElapsedTime();
}
/**
* Stop emitting particles
*/
public stop(): void {
this.isEmitting = false;
this.debugMesh.visible = false;
}
/**
* Check if the emitter is currently emitting
* @returns True if emitting, false otherwise
*/
public getIsEmitting(): boolean {
return this.isEmitting;
}
/**
* Update emitter position and emit particles
*/
public update(): void {
// Skip if no config
if (!this.config) {
return;
}
// Update existing active particles
this.updateParticles();
// Skip emission if not emitting
if (!this.isEmitting) {
return;
}
// Check if it's time to emit new particles
const currentTime = this.clock.getElapsedTime();
const timeSinceLastEmit = currentTime - this.lastEmitTime;
const emitInterval = 1.0 / this.config.birthrate;
if (timeSinceLastEmit >= emitInterval) {
this.emitParticle();
this.lastEmitTime = currentTime;
}
}
/**
* Emit a new particle from the pool
*/
private emitParticle(): void {
if (!this.config || !this.isAttached || !this.attachmentTarget) {
return;
}
// Get a particle from the pool
let particle: Particle;
if (this.inactiveParticles.length > 0) {
// Take from inactive pool
particle = this.inactiveParticles.pop()!;
} else if (this.activeParticles.length > 0) {
// No inactive particles available, recycle the oldest active one
particle = this.activeParticles.shift()!;
// If it's in a scene, remove it first
const mesh = particle.getMesh();
if (mesh.parent) {
mesh.parent.remove(mesh);
}
} else {
// This shouldn't happen with proper initialization
console.warn('No particles available in pool');
return;
}
// Get world position of attachment point
const worldPosition = new THREE.Vector3();
const targetWorldPosition = new THREE.Vector3();
const targetWorldQuaternion = new THREE.Quaternion();
// Get world position of attachment target
this.attachmentTarget.getWorldPosition(targetWorldPosition);
this.attachmentTarget.getWorldQuaternion(targetWorldQuaternion);
// Add offset in local space, then transform to world space
const offsetWorldMatrix = new THREE.Matrix4().makeTranslation(
this.attachmentOffset.x,
this.attachmentOffset.y,
this.attachmentOffset.z
);
// Create rotation matrix from quaternion
const rotationMatrix = new THREE.Matrix4().makeRotationFromQuaternion(targetWorldQuaternion);
// Apply rotation to offset matrix
const attachmentWorldMatrix = offsetWorldMatrix.multiply(rotationMatrix);
// Combine with the attachment target's matrix
const combinedMatrix = new THREE.Matrix4().copy(this.attachmentTarget.matrixWorld).multiply(attachmentWorldMatrix);
// Extract the position from the combined matrix
worldPosition.setFromMatrixPosition(combinedMatrix);
// Generate random size within configured range
const sizeRange = this.config.size || [0.1, 0.1];
const size = sizeRange[0];
// Get color from config
const color = this.config.color || [255, 255, 255];
// Get opacity from config
const opacity = this.config.opacity || [1.0, 0.0];
// Calculate random velocity based on config
const velocityRange = this.config.velocity || [0.1, 0.1];
const speed = velocityRange[0] + Math.random() * (velocityRange[1] - velocityRange[0]);
// Calculate direction based on spread
const spread = this.config.spread || 0;
const angle = (Math.random() - 0.5) * spread * (Math.PI / 180);
// Calculate velocity direction (forward with random spread)
const forward = new THREE.Vector3(0, 0, -1);
forward.applyQuaternion(this.attachmentTarget.getWorldQuaternion(new THREE.Quaternion()));
// Apply spread angle
const spreadMatrix = new THREE.Matrix4().makeRotationY(angle);
forward.applyMatrix4(spreadMatrix);
// Create velocity vector
const velocity = forward.multiplyScalar(speed);
// Select a random texture if textures are available
let texturePath: string | undefined = undefined;
if (this.config.textures && this.config.textures.length > 0) {
const randomIndex = Math.floor(Math.random() * this.config.textures.length);
texturePath = this.config.textures[randomIndex];
}
// Animation options
const animationOptions = {
animateOpacity: this.config.animateOpacity,
animateScale: this.config.animateScale,
animatePosition: this.config.animatePosition,
texturePath: texturePath,
planeSize: this.config.planeSize,
blendMode: this.config.blendMode
};
// Configure the particle
particle.configure(
worldPosition,
this.config.lifetime,
[size, this.config.size ? this.config.size[1] : size],
color,
opacity,
velocity,
animationOptions
);
// Make particle visible
particle.getMesh().visible = true;
// Add particle to scene based on particle space configuration
if (this.config.worldSpace && this.world) {
// World space: Add to world scene
this.world.getScene().add(particle.getMesh());
} else {
// Local space: Add to attachment target
this.attachmentTarget.add(particle.getMesh());
// For local particles, reset to local position
particle.getMesh().position.copy(this.attachmentOffset);
}
// Store the particle in the active list
this.activeParticles.push(particle);
}
/**
* Update existing particles
*/
private updateParticles(): void {
if (!this.world) return;
const currentTime = performance.now() / 1000;
const camera = this.world.getCamera();
// Process particles in reverse to safely remove during iteration
for (let i = this.activeParticles.length - 1; i >= 0; i--) {
const particle = this.activeParticles[i];
const isAlive = particle.update(currentTime);
// Make the particle face the camera (billboard technique)
if (camera) {
particle.faceCamera(camera);
}
if (!isAlive) {
// Remove from active list
this.activeParticles.splice(i, 1);
// Hide the particle
particle.getMesh().visible = false;
// Remove from scene if it has a parent
const mesh = particle.getMesh();
if (mesh.parent) {
mesh.parent.remove(mesh);
}
// Add to inactive list
this.inactiveParticles.push(particle);
}
}
}
/**
* Set the world reference
* @param world Reference to the world
*/
public setWorld(world: World | null): void {
this.world = world;
}
/**
* Clean up resources when emitter is no longer needed
*/
public dispose(): void {
// Stop emitting
this.stop();
// Detach from target
this.detach();
// Clear all particles
this.clearParticles();
// Dispose debug mesh
if (this.debugMesh.geometry) {
this.debugMesh.geometry.dispose();
}
if (this.debugMesh.material) {
if (Array.isArray(this.debugMesh.material)) {
for (const material of this.debugMesh.material) {
material.dispose();
}
} else {
this.debugMesh.material.dispose();
}
}
}
}
/**
* System for managing particle emitters
*/
export class ParticleSystem {
private emitters: ParticleEmitter[] = [];
private configs: Map<string, ParticleConfig> = new Map();
private world: World | null = null;
constructor(world?: World | null) {
this.world = world || null;
}
/**
* Load particle configurations from JSON
* @param data JSON data with particle configurations
*/
public loadConfigurations(data: any): void {
if (!data || !data.particles || !Array.isArray(data.particles)) {
console.error('Invalid particle configuration data');
return;
}
// Clear existing configurations
this.configs.clear();
// Load new configurations
for (const particleData of data.particles) {
if (particleData.name) {
const config = new ParticleConfig(particleData);
this.configs.set(config.name, config);
console.log(`Loaded particle configuration: ${config.name}`);
}
}
console.log(`Loaded ${this.configs.size} particle configurations`);
}
/**
* Get a particle configuration by name
* @param name Name of the configuration to get
* @returns The particle configuration or null if not found
*/
public getConfig(name: string): ParticleConfig | null {
return this.configs.get(name) || null;
}
/**
* Create a particle emitter attached to a given THREE.js node
* @param target THREE.js node where particles will be emitted
* @param offset Relative offset on the node where the emitter will be attached
* @param configName Optional name of the particle configuration to use
* @returns The created emitter
*/
public createEmitter(target: THREE.Object3D, offset: THREE.Vector3, configName?: string): ParticleEmitter {
let config: ParticleConfig | undefined = undefined;
if (configName) {
const foundConfig = this.getConfig(configName);
if (foundConfig) {
config = foundConfig;
}
}
const emitter = new ParticleEmitter(target, offset, config, this.world);
this.emitters.push(emitter);
return emitter;
}
/**
* Update all emitters
*/
public update(): void {
for (const emitter of this.emitters) {
emitter.update();
}
}
/**
* Set the world reference
* @param world Reference to the world
*/
public setWorld(world: World): void {
this.world = world;
// Update world reference for all existing emitters
for (const emitter of this.emitters) {
emitter.setWorld(world);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment