[READ-ONLY] Mirror of https://github.com/flo-bit/particle-surfer. flo-bit.dev/particle-surfer/
0

Configure Feed

Select the types of activity you want to include in your feed.

commit

Florian (Aug 2, 2025, 11:10 PM +0200) d95fd9bd 314f0d60

+178 -61
+19 -12
src/colors.ts
··· 104 104 } 105 105 } 106 106 107 + const temp1 = { r: 0, g: 0, b: 0 }; 108 + const temp2 = { r: 0, g: 0, b: 0 }; 109 + 107 110 /** 108 111 * Writes interpolated color into `out` (mutated), each channel in [0,1]. 109 112 */ ··· 128 131 const c11 = grid[y1][x1]; 129 132 130 133 // bilinear interpolation 131 - const mix = (a: RGB01, b: RGB01, t: number): RGB01 => ({ 132 - r: lerp(a.r, b.r, t), 133 - g: lerp(a.g, b.g, t), 134 - b: lerp(a.b, b.b, t), 135 - }); 136 - 137 - const c0 = mix(c00, c10, wx); 138 - const c1 = mix(c01, c11, wx); 139 - const c = mix(c0, c1, wy); 134 + const mix = (a: RGB01, b: RGB01, t: number, out?: RGB01): RGB01 => { 135 + if (out) { 136 + out.r = lerp(a.r, b.r, t); 137 + out.g = lerp(a.g, b.g, t); 138 + out.b = lerp(a.b, b.b, t); 139 + return out; 140 + } 141 + return { 142 + r: lerp(a.r, b.r, t), 143 + g: lerp(a.g, b.g, t), 144 + b: lerp(a.b, b.b, t), 145 + }; 146 + }; 140 147 141 - out.r = c.r; 142 - out.g = c.g; 143 - out.b = c.b; 148 + mix(c00, c10, wx, temp1); 149 + mix(c01, c11, wx, temp2); 150 + mix(temp1, temp2, wy, out); 144 151 } 145 152 146 153 return { grid, getColor };
+36
src/helper.ts
··· 1 + import * as THREE from "three"; 2 + 3 + const DEADZONE = 0.2; 4 + 5 + export function getCameraMinMax(camera: THREE.OrthographicCamera) { 6 + // Get camera-space axes in world space 7 + const right = new THREE.Vector3(1, 0, 0) 8 + .applyQuaternion(camera.quaternion) 9 + .normalize(); 10 + const up = new THREE.Vector3(0, 1, 0) 11 + .applyQuaternion(camera.quaternion) 12 + .normalize(); 13 + 14 + const halfWidth = (camera.right - camera.left) / 2 / camera.zoom; 15 + const halfHeight = (camera.top - camera.bottom) / 2 / camera.zoom; 16 + 17 + const offsetRight = right.clone().multiplyScalar(halfWidth); 18 + const offsetUp = up.clone().multiplyScalar(halfHeight); 19 + 20 + const corners = [ 21 + camera.position.clone().add(offsetRight).add(offsetUp), // +X, +Y 22 + camera.position.clone().sub(offsetRight).add(offsetUp), // -X, +Y 23 + camera.position.clone().sub(offsetRight).sub(offsetUp), // -X, -Y 24 + camera.position.clone().add(offsetRight).sub(offsetUp), // +X, -Y 25 + ]; 26 + 27 + const allX = corners.map((c) => c.x); 28 + const allY = corners.map((c) => c.y); 29 + 30 + const minX = Math.min(...allX); 31 + const maxX = Math.max(...allX); 32 + const minY = Math.min(...allY); 33 + const maxY = Math.max(...allY); 34 + 35 + return { minX, maxX, minY, maxY }; 36 + }
+59 -14
src/main.ts
··· 4 4 import Stats from "stats.js"; 5 5 import { ParticleSystem } from "./particle-system"; 6 6 import { Particle } from "./particle"; 7 + import { getCameraMinMax } from "./helper"; 8 + import { mul } from "three/tsl"; 7 9 8 10 let stats: Stats = new Stats(); 9 11 stats.showPanel(0); ··· 13 15 const trailHalfLife = 0.15; // seconds, adjust to taste 14 16 const tau = trailHalfLife / Math.log(2); 15 17 16 - const innerRingRadius = 5; 17 - const outerRingRadius = 7; 18 + const innerRingRadius = 8; 19 + const outerRingRadius = 12; 20 + 21 + let multiplier = 1; 18 22 19 23 let renderer: THREE.WebGLRenderer, 20 24 scene: THREE.Scene, ··· 59 63 60 64 function setupScene(): void { 61 65 renderer = new THREE.WebGLRenderer({ 62 - antialising: true, 66 + antialias: true, 63 67 preserveDrawingBuffer: true, 64 - } as any); 68 + }); 65 69 66 70 renderer.setSize(window.innerWidth, window.innerHeight); 67 71 renderer.autoClearColor = false; ··· 71 75 window.addEventListener("resize", onResize, false); 72 76 73 77 scene = new THREE.Scene(); 78 + 79 + renderer.toneMapping = THREE.ACESFilmicToneMapping; 74 80 75 81 // add black transparent rect 76 82 overlay = new THREE.Mesh( ··· 102 108 if (event.key == "l") { 103 109 nextLevel(); 104 110 } 111 + 105 112 if (event.key == "r") { 106 113 let s1 = Math.floor(Math.random() * 100000); 107 114 let s2 = Math.floor(Math.random() * 100000); ··· 116 123 }, 117 124 false, 118 125 ); 126 + 127 + document.addEventListener("keyup", (event: KeyboardEvent) => { 128 + keys[event.key] = false; 129 + }); 119 130 120 131 document.addEventListener("mouseup", () => { 121 132 switchActive(); ··· 176 187 new THREE.MeshBasicMaterial({ color: 0xffffff }), 177 188 ); 178 189 playerPart = new Particle(); 179 - playerPart.pos.set(0, 0); 180 - playerPart.time = 1000000; 190 + resetPlayer(); 191 + 181 192 scene.add(player); 182 193 } 183 194 function nextLevel(): void { ··· 207 218 activeMesh.visible = true; 208 219 } 209 220 function resetPlayer(): void { 210 - playerPart.reset(camera); 211 - playerPart.pos.set(0, 0); 212 - playerPart.time = 1000000; 213 - camera.position.x = 0; 214 - camera.position.y = 0; 221 + const { minX, maxX, minY, maxY } = getCameraMinMax(camera); 222 + 223 + playerPart.reset( 224 + minX, 225 + maxX, 226 + minY, 227 + maxY, 228 + innerRingRadius * innerRingRadius, 229 + outerRingRadius * outerRingRadius, 230 + ); 231 + playerPart.pos.set(0, (innerRingRadius + outerRingRadius) / 2); 232 + playerPart.time = 100000000; 233 + camera.position.x = playerPart.pos.x; 234 + camera.position.y = playerPart.pos.y; 215 235 renderer.clear(); 216 236 } 217 237 function animate(): void { ··· 221 241 222 242 let dt: number = clock.getDelta(); 223 243 224 - if (particles[0].mesh?.visible) particles[0].update(dt, camera); 225 - if (particles[1].mesh?.visible) particles[1].update(dt, camera); 244 + if (particles[0].mesh?.visible) particles[0].update(dt, camera, multiplier); 245 + if (particles[1].mesh?.visible) particles[1].update(dt, camera, multiplier); 226 246 247 + const { minX, maxX, minY, maxY } = getCameraMinMax(camera); 248 + 249 + multiplier = keys["m"] ? 0.1 : 1; 250 + console.log(multiplier); 227 251 if (!stop) { 228 - particles[active].applyNoiseForce(playerPart, dt); 252 + particles[active].applyNoiseForce(playerPart, dt * multiplier); 229 253 230 254 playerPart.update(dt, particles[active].maxSpeed); 231 255 player.position.set(playerPart.pos.x, playerPart.pos.y, 0); 232 256 257 + // get angle of player position 258 + const angle = Math.atan2(playerPart.pos.y, playerPart.pos.x) - Math.PI / 2; 259 + camera.rotation.z = angle; 260 + 233 261 camera.position.x = (playerPart.pos.x + camera.position.x * 49) / 50; 234 262 camera.position.y = (playerPart.pos.y + camera.position.y * 49) / 50; 263 + 264 + camera.position.z = 0; 265 + camera.position.setLength((innerRingRadius + outerRingRadius) / 2); 266 + camera.position.z = 10; 267 + 268 + if ( 269 + playerPart.isDead( 270 + minX, 271 + maxX, 272 + minY, 273 + maxY, 274 + innerRingRadius * innerRingRadius, 275 + outerRingRadius * outerRingRadius, 276 + ) 277 + ) { 278 + resetPlayer(); 279 + } 235 280 } 236 281 237 282 const alpha = 1 - Math.exp(-dt / tau);
+42 -7
src/particle-system.ts
··· 2 2 import { UberNoise } from "uber-noise"; 3 3 import { Particle } from "./particle"; 4 4 import { createColorGrid, type ColorGridResult } from "./colors"; 5 + import { getCameraMinMax } from "./helper"; 6 + 7 + const DEADZONE = 0.2; 5 8 6 9 interface ParticlesOptions { 7 10 num?: number; ··· 87 90 this.noise = new UberNoise({ 88 91 min: -0.01, 89 92 max: 0.01, 90 - scale: 0.4, 93 + scale: 0.5, 91 94 warp: 0.02, 92 95 lacunarity: 0.5, 93 96 seed: seed, ··· 121 124 122 125 // p.acc.x = this.noiseData[x][y][0] * dt * 20; 123 126 // p.acc.y = this.noiseData[x][y][1] * dt * 20; 124 - p.acc.x = this.noise.get(p.pos.x, p.pos.y) * dt * 20; 125 - p.acc.y = this.noise.get(p.pos.x + 123, p.pos.y - 543) * dt * 20; 127 + 128 + const clockwise = p.pos.clone(); 129 + clockwise.normalize(); 130 + 131 + const x = clockwise.x; 132 + clockwise.x = clockwise.y; 133 + clockwise.y = -x; 134 + 135 + clockwise.setLength(0.003); 136 + 137 + p.acc.x = (this.noise.get(p.pos.x, p.pos.y) + clockwise.x) * dt * 20; 138 + p.acc.y = 139 + (this.noise.get(p.pos.x + 123, p.pos.y - 543) + clockwise.y) * dt * 20; 126 140 } 127 141 128 142 createParticles() { ··· 162 176 this.mesh = new THREE.Points(this.geo, mat); 163 177 this.mesh.frustumCulled = false; 164 178 } 165 - update(dt: number, camera: THREE.OrthographicCamera) { 179 + update(dt: number, camera: THREE.OrthographicCamera, multiplier: number) { 166 180 let color = new THREE.Color(0, 0, 0); 167 181 182 + const { minX, maxX, minY, maxY } = getCameraMinMax(camera); 183 + 184 + const innerRingRadiusSquared = this.innerRingRadius * this.innerRingRadius; 185 + const outerRingRadiusSquared = this.outerRingRadius * this.outerRingRadius; 186 + 168 187 for (let i = 0; i < this.num; i++) { 169 188 let p = this.parts[i]; 170 189 171 - if (p.isDead(camera)) { 172 - p.reset(camera); 190 + if ( 191 + p.isDead( 192 + minX, 193 + maxX, 194 + minY, 195 + maxY, 196 + innerRingRadiusSquared, 197 + outerRingRadiusSquared, 198 + ) 199 + ) { 200 + p.reset( 201 + minX, 202 + maxX, 203 + minY, 204 + maxY, 205 + innerRingRadiusSquared, 206 + outerRingRadiusSquared, 207 + ); 173 208 } 174 209 175 210 this.applyNoiseForce(p, dt); 176 - p.update(dt, this.maxSpeed); 211 + p.update(dt * multiplier, this.maxSpeed); 177 212 178 213 if (!this.positionData) return; 179 214 this.positionData[i * 3] = p.pos.x;
+22 -28
src/particle.ts
··· 1 1 import * as THREE from "three"; 2 2 3 - interface ParticleOptions { 4 - time?: number; 5 - } 6 - 7 - const DEADZONE = 0.2; 8 - 9 3 export class Particle { 10 4 pos: THREE.Vector2; 11 5 speed: THREE.Vector2; ··· 14 8 15 9 normalizedSpeed: THREE.Vector2; 16 10 17 - constructor(opt?: ParticleOptions) { 18 - opt = opt || {}; 19 - 11 + constructor() { 20 12 this.pos = new THREE.Vector2(0, 0); 21 13 this.speed = new THREE.Vector2(0, 0); 22 14 this.acc = new THREE.Vector2(0, 0); 23 - this.time = opt.time ?? Math.random() * 1.8 + 0.3; 15 + this.time = Math.random() * 1.8 + 0.3; 24 16 25 17 this.normalizedSpeed = new THREE.Vector2(0, 0); 26 18 } 27 - reset(camera: THREE.OrthographicCamera) { 28 - const halfWidth = (camera.right - camera.left) / 2 / camera.zoom; 29 - const halfHeight = (camera.top - camera.bottom) / 2 / camera.zoom; 30 - 31 - const minX = camera.position.x - halfWidth - DEADZONE; 32 - const maxX = camera.position.x + halfWidth + DEADZONE; 33 - const minY = camera.position.y - halfHeight - DEADZONE; 34 - const maxY = camera.position.y + halfHeight + DEADZONE; 35 - 19 + reset( 20 + minX: number, 21 + maxX: number, 22 + minY: number, 23 + maxY: number, 24 + innerRingRadiusSquared: number, 25 + outerRingRadiusSquared: number, 26 + ) { 36 27 this.pos.set( 37 28 Math.random() * (maxX - minX) + minX, 38 29 Math.random() * (maxY - minY) + minY, ··· 50 41 51 42 this.normalizedSpeed.set(this.speed.x / maxSpeed, this.speed.y / maxSpeed); 52 43 } 53 - isDead(camera: THREE.OrthographicCamera): boolean { 44 + isDead( 45 + minX: number, 46 + maxX: number, 47 + minY: number, 48 + maxY: number, 49 + innerRingRadiusSquared: number, 50 + outerRingRadiusSquared: number, 51 + ): boolean { 54 52 // check if within camera bounds 55 53 56 - const halfWidth = (camera.right - camera.left) / 2 / camera.zoom; 57 - const halfHeight = (camera.top - camera.bottom) / 2 / camera.zoom; 58 - 59 - const minX = camera.position.x - halfWidth - DEADZONE; 60 - const maxX = camera.position.x + halfWidth + DEADZONE; 61 - const minY = camera.position.y - halfHeight - DEADZONE; 62 - const maxY = camera.position.y + halfHeight + DEADZONE; 63 - 64 54 if (this.time < 0) return true; 65 55 66 56 if (this.pos.x < minX || this.pos.x > maxX) return true; 67 57 if (this.pos.y < minY || this.pos.y > maxY) return true; 58 + 59 + const distSquared = this.pos.x * this.pos.x + this.pos.y * this.pos.y; 60 + if (distSquared < innerRingRadiusSquared) return true; 61 + if (distSquared > outerRingRadiusSquared) return true; 68 62 69 63 return false; 70 64 }