├── examples ├── src │ ├── App.scss │ ├── logo.png │ ├── global.d.ts │ ├── demos │ │ ├── assets │ │ │ └── images │ │ │ │ ├── laser1.png │ │ │ │ ├── checker.png │ │ │ │ ├── images.jpeg │ │ │ │ ├── spritesheet.png │ │ │ │ ├── spritesheet.psd │ │ │ │ ├── trailsheet.png │ │ │ │ ├── 6dcab22d4b7e1fa83a15a27c5ae5e48b4c8ed8c0.jpeg │ │ │ │ └── de650c206bdd19e51202a275c9e53e5f887ba875.png │ │ ├── index.ts │ │ ├── Simple.tsx │ │ ├── Three.tsx │ │ ├── MultipleSystems.tsx │ │ ├── Color.tsx │ │ ├── RibbonTest.tsx │ │ ├── RibbonBurst.tsx │ │ ├── Collision.tsx │ │ └── Burst.tsx │ ├── main.tsx │ ├── GridPlate.tsx │ ├── favicon.svg │ ├── index.css │ └── App.tsx ├── tsconfig.node.json ├── vite.config.js ├── .gitignore ├── index.html ├── tsconfig.json └── package.json ├── .eslintignore ├── .prettierignore ├── tsconfig.node.json ├── src ├── index.ts ├── systems │ ├── index.ts │ ├── livingSystem.ts │ ├── keyframeSystem.ts │ ├── movingSystem.ts │ ├── collidingSystem.ts │ └── emittingSystem.ts ├── vallidateBurst.ts ├── ParticleSystem.ts ├── ParticleGeometry.ts ├── points.glsl.js ├── ParticleMaterial.ts ├── RibbonMaterial.ts ├── ribbons.glsl.js ├── validateParticle.ts └── RibbonGeometry.ts ├── .prettierrc.json ├── .npmignore ├── tsconfig.json ├── rollup.config.js ├── package.json ├── .gitignore ├── .eslintrc.json └── README.md /examples/src/App.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | examples/dist 3 | examples/public 4 | node_modules 5 | -------------------------------------------------------------------------------- /examples/src/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joergjaeckel/sprudel/HEAD/examples/src/logo.png -------------------------------------------------------------------------------- /examples/src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' { 2 | const value: any 3 | export = value 4 | } 5 | -------------------------------------------------------------------------------- /examples/src/demos/assets/images/laser1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joergjaeckel/sprudel/HEAD/examples/src/demos/assets/images/laser1.png -------------------------------------------------------------------------------- /examples/src/demos/assets/images/checker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joergjaeckel/sprudel/HEAD/examples/src/demos/assets/images/checker.png -------------------------------------------------------------------------------- /examples/src/demos/assets/images/images.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joergjaeckel/sprudel/HEAD/examples/src/demos/assets/images/images.jpeg -------------------------------------------------------------------------------- /examples/src/demos/assets/images/spritesheet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joergjaeckel/sprudel/HEAD/examples/src/demos/assets/images/spritesheet.png -------------------------------------------------------------------------------- /examples/src/demos/assets/images/spritesheet.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joergjaeckel/sprudel/HEAD/examples/src/demos/assets/images/spritesheet.psd -------------------------------------------------------------------------------- /examples/src/demos/assets/images/trailsheet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joergjaeckel/sprudel/HEAD/examples/src/demos/assets/images/trailsheet.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .github/* 2 | .husky/* 3 | dist/* 4 | examples/dist/* 5 | examples/public/* 6 | examples/node_modules/* 7 | node_modules/* 8 | package.json 9 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /examples/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ParticleGeometry' 2 | export * from './ParticleMaterial' 3 | export * from './ParticleSystem' 4 | export * from './RibbonGeometry' 5 | export * from './RibbonMaterial' 6 | -------------------------------------------------------------------------------- /src/systems/index.ts: -------------------------------------------------------------------------------- 1 | export * from './emittingSystem' 2 | export * from './keyframeSystem' 3 | export * from './livingSystem' 4 | export * from './movingSystem' 5 | export * from './collidingSystem' 6 | -------------------------------------------------------------------------------- /examples/src/demos/assets/images/6dcab22d4b7e1fa83a15a27c5ae5e48b4c8ed8c0.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joergjaeckel/sprudel/HEAD/examples/src/demos/assets/images/6dcab22d4b7e1fa83a15a27c5ae5e48b4c8ed8c0.jpeg -------------------------------------------------------------------------------- /examples/src/demos/assets/images/de650c206bdd19e51202a275c9e53e5f887ba875.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joergjaeckel/sprudel/HEAD/examples/src/demos/assets/images/de650c206bdd19e51202a275c9e53e5f887ba875.png -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 110, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "all", 8 | "bracketSpacing": true 9 | } 10 | -------------------------------------------------------------------------------- /examples/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App' 5 | 6 | createRoot(document.getElementById('root')!).render() 7 | -------------------------------------------------------------------------------- /examples/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | // https://github.com/vitejs/vite/issues/6215 8 | optimizeDeps: { 9 | include: ['react/jsx-runtime'], 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | examples/ 3 | Thumbs.db 4 | ehthumbs.db 5 | Desktop.ini 6 | \$RECYCLE.BIN/ 7 | .DS_Store 8 | .vscode 9 | .docz/ 10 | .idea 11 | .rpt2_cache/ 12 | .eslintcache 13 | .eslintignore 14 | .eslintrc.json 15 | .github/ 16 | .husky/ 17 | .prettierignore 18 | .prettierrc.json 19 | rollup.config.js 20 | tsconfig.json 21 | tsconfig.tsbuildinfo 22 | -------------------------------------------------------------------------------- /examples/src/GridPlate.tsx: -------------------------------------------------------------------------------- 1 | export default () => ( 2 | <> 3 | 4 | object.layers.enable( 1 )}> 5 | 6 | 7 | 8 | 9 | ) 10 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/vallidateBurst.ts: -------------------------------------------------------------------------------- 1 | export type Burst = { 2 | count: number 3 | cycleCount: number 4 | repeatInterval: number 5 | time: number 6 | } 7 | 8 | export type RuntimeBurst = Burst & { 9 | cycle: number 10 | } 11 | 12 | export const defaultBurst = { 13 | count: 10, 14 | cycleCount: -1, 15 | repeatInterval: 1, 16 | time: 0, 17 | 18 | /* Internal */ 19 | cycle: 0, 20 | } 21 | 22 | export const validateBurst = (burst: Burst): RuntimeBurst => { 23 | return Object.assign({}, defaultBurst, burst) 24 | } 25 | -------------------------------------------------------------------------------- /src/systems/livingSystem.ts: -------------------------------------------------------------------------------- 1 | import type { World } from 'miniplex' 2 | 3 | export const livingSystem = (world: World, delta: number) => { 4 | const { entities } = world.archetype('startLifetime') 5 | 6 | for (let i = 0; i < entities.length; i++) { 7 | const entity = entities[i] 8 | 9 | entity.remainingLifetime -= delta 10 | 11 | entity.operationalLifetime += delta 12 | 13 | if (entity.startLifetime !== -1 && entity.remainingLifetime <= 0) world.queue.destroyEntity(entity) 14 | } 15 | 16 | world.queue.flush() 17 | } 18 | -------------------------------------------------------------------------------- /src/systems/keyframeSystem.ts: -------------------------------------------------------------------------------- 1 | import type { World } from 'miniplex' 2 | import { IGeneric, RuntimeParticle } from '../validateParticle' 3 | 4 | export const keyframeSystem = (world: World, key: keyof RuntimeParticle, delta: number) => { 5 | const { entities } = world.archetype(`${key}OverLifetime`) 6 | 7 | for (let i = 0; i < entities.length; i++) { 8 | const entity = entities[i] 9 | 10 | const component = entity[key] as IGeneric 11 | 12 | if (component.interpolant) 13 | component.value = component.interpolant.evaluate(entity.operationalLifetime / entity.startLifetime) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "pretty": true, 9 | "strict": true, 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "declaration": true, 13 | "removeComments": true, 14 | "emitDeclarationOnly": true, 15 | "outDir": "dist", 16 | "resolveJsonModule": true, 17 | "noImplicitThis": false, 18 | "baseUrl": "./src", 19 | "incremental": true 20 | }, 21 | "include": ["./src"], 22 | "exclude": ["./node_modules/**/*"] 23 | } 24 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": false, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /examples/src/demos/index.ts: -------------------------------------------------------------------------------- 1 | import { lazy } from 'react' 2 | 3 | const Simple = { Component: lazy(() => import('./Simple')) } 4 | const Burst = { Component: lazy(() => import('./Burst')) } 5 | const Color = { Component: lazy(() => import('./Color')) } 6 | const RibbonBurst = { Component: lazy(() => import('./RibbonBurst')) } 7 | const RibbonTest = { Component: lazy(() => import('./RibbonTest')) } 8 | const Three = { Component: lazy(() => import('./Three')) } 9 | const MultipleSystems = { Component: lazy(() => import('./MultipleSystems')) } 10 | const Collision = { Component: lazy(() => import('./Collision')) } 11 | 12 | export { Simple, Burst, Color, RibbonBurst, RibbonTest, Three, MultipleSystems, Collision } 13 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sprudel", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "tsc && vite build", 8 | "preview": "vite preview" 9 | }, 10 | "dependencies": { 11 | "@react-three/drei": "^9.0.4", 12 | "@react-three/fiber": "^8.0.7", 13 | "react": "^18.0.0", 14 | "react-dom": "^18.0.0", 15 | "react-router-dom": "^6.2.2", 16 | "sprudel": "file:../", 17 | "three": "^0.139.2" 18 | }, 19 | "devDependencies": { 20 | "@vitejs/plugin-react": "^1.3.0", 21 | "sass": "^1.49.11", 22 | "vite": "^2.9.1", 23 | "@types/react": "^17.0.33", 24 | "@types/react-dom": "^17.0.10", 25 | "@types/three": "^0.139.0", 26 | "typescript": "^4.5.4" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/systems/movingSystem.ts: -------------------------------------------------------------------------------- 1 | import type { World } from 'miniplex' 2 | import {Vector3} from "three"; 3 | 4 | const gravityForce = new Vector3(0,-10,0) 5 | 6 | export const movingSystem = (world: World, delta: number) => { 7 | const { entities } = world.archetype('speed') 8 | 9 | for (let i = 0; i < entities.length; i++) { 10 | const entity = entities[i] 11 | 12 | if (entity.startDelay > 0) { 13 | entity.startDelay -= delta 14 | continue 15 | } 16 | 17 | if (entity.speedModifier) { 18 | entity.speed *= entity.speedModifier 19 | } else { 20 | entity.speed = entity.startSpeed 21 | } 22 | 23 | entity.velocity.setLength(entity.speed).add(gravityForce.setLength(delta)) 24 | 25 | //entity.velocity.y -= entity.mass * delta 26 | 27 | entity.position.add(entity.velocity) 28 | 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import image from '@rollup/plugin-image' 4 | 5 | const external = ['miniplex', 'react/jsx-runtime', 'three'] 6 | const extensions = ['.js', '.jsx', '.ts', '.tsx', '.json', '.png'] 7 | 8 | const getBabelOptions = ({ useESModules }, targets) => ({ 9 | babelHelpers: 'runtime', 10 | babelrc: false, 11 | extensions, 12 | include: ['src/**/*', '**/node_modules/**'], 13 | plugins: [['@babel/transform-runtime', { regenerator: false, useESModules }]], 14 | presets: [['@babel/preset-env', { loose: true, modules: false, targets }], '@babel/preset-typescript'], 15 | }) 16 | 17 | export default [ 18 | { 19 | external, 20 | input: `./src/index.ts`, 21 | output: { dir: 'dist', format: 'esm' }, 22 | plugins: [ 23 | image(), 24 | resolve({ extensions }), 25 | babel(getBabelOptions({ useESModules: true }, '>1%, not dead, not ie 11, not op_mini all')), 26 | ], 27 | }, 28 | ] 29 | -------------------------------------------------------------------------------- /src/ParticleSystem.ts: -------------------------------------------------------------------------------- 1 | import { RegisteredEntity, World } from 'miniplex' 2 | import * as systems from './systems' 3 | import { Particle, validateParticle } from './validateParticle' 4 | import {Scene} from "three"; 5 | 6 | export class ParticleSystem { 7 | world: World 8 | scene: Scene 9 | 10 | constructor(scene: Scene) { 11 | this.world = new World() 12 | this.scene = scene 13 | } 14 | 15 | update = (delta: number) => { 16 | systems.livingSystem(this.world, delta) 17 | systems.emittingSystem(this.world, delta) 18 | systems.collidingSystem(this.world, this.scene, delta) 19 | systems.movingSystem(this.world, delta) 20 | 21 | systems.keyframeSystem(this.world, 'size', delta) 22 | systems.keyframeSystem(this.world, 'color', delta) 23 | systems.keyframeSystem(this.world, 'opacity', delta) 24 | } 25 | 26 | addParticle = (object: Particle): RegisteredEntity => { 27 | const entity = this.world.createEntity(validateParticle(object)) 28 | 29 | return entity 30 | } 31 | 32 | destroyParticle = (object: Particle) => { 33 | this.world.destroyEntity(object) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/systems/collidingSystem.ts: -------------------------------------------------------------------------------- 1 | import type { World } from 'miniplex' 2 | import {ArrowHelper, Raycaster, Scene, Vector3} from "three"; 3 | 4 | const raycaster = new Raycaster(); 5 | raycaster.layers.set( 1 ); 6 | 7 | //const helper = new ArrowHelper(new Vector3(0,1,0), new Vector3(0,0,0), 10) 8 | 9 | let tempVec0 = new Vector3() 10 | 11 | export const collidingSystem = (world: World, scene: Scene, delta: number) => { 12 | const { entities } = world.archetype('collide') 13 | 14 | //if(!helper.parent) scene.add(helper) 15 | 16 | for (let i = 0; i < entities.length; i++) { 17 | const entity = entities[i] 18 | 19 | raycaster.set(entity.position, entity.velocity) 20 | const intersects = raycaster.intersectObjects( scene.children, true ); 21 | 22 | if(intersects[0] && intersects[0].distance < 1 && intersects[ 0 ].face) { 23 | 24 | tempVec0 = intersects[0].face.normal.clone(); 25 | tempVec0.transformDirection( intersects[ 0 ].object.matrixWorld ); 26 | entity.velocity.reflect(tempVec0) 27 | 28 | } 29 | 30 | //helper.position.copy(entity.position) 31 | //helper.setDirection(entity.velocity) 32 | 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/src/demos/Simple.tsx: -------------------------------------------------------------------------------- 1 | import { Canvas, extend, useFrame } from '@react-three/fiber' 2 | import { useEffect, useMemo, useRef } from 'react' 3 | import { OrbitControls } from '@react-three/drei' 4 | import { ParticleGeometry, ParticleMaterial, ParticleSystem } from 'sprudel' 5 | import GridPlate from '../GridPlate' 6 | 7 | extend({ ParticleGeometry, ParticleMaterial }) 8 | 9 | const Particles = () => { 10 | const ref = useRef() 11 | 12 | const particleSystem = useMemo(() => new ParticleSystem(), []) 13 | 14 | useFrame((state, delta) => { 15 | particleSystem.update(delta) 16 | ref.current?.update() 17 | }) 18 | 19 | useEffect(() => { 20 | const main = particleSystem.addParticle({ 21 | size: 3, 22 | emitting: [ 23 | { 24 | rateOverTime: 10, 25 | startLifetime: 2, 26 | startSpeed: 0.3, 27 | startRotation: [1, 1, 0], 28 | }, 29 | ], 30 | }) 31 | 32 | return () => particleSystem.destroyParticle(main) 33 | }, []) 34 | 35 | return ( 36 | 37 | 38 | 39 | 40 | ) 41 | } 42 | 43 | const Simple = () => { 44 | return ( 45 | 46 | 47 | 48 | 49 | 50 | 51 | ) 52 | } 53 | 54 | export default Simple 55 | -------------------------------------------------------------------------------- /examples/src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/src/demos/Three.tsx: -------------------------------------------------------------------------------- 1 | import { Canvas, extend, useFrame, useThree } from '@react-three/fiber' 2 | import { useEffect, useMemo, useRef } from 'react' 3 | import { OrbitControls } from '@react-three/drei' 4 | import { ParticleGeometry, ParticleMaterial, ParticleSystem } from 'sprudel' 5 | import { Points } from 'three' 6 | import GridPlate from '../GridPlate' 7 | 8 | extend({ ParticleGeometry, ParticleMaterial }) 9 | 10 | const Particles = () => { 11 | const ref = useRef() 12 | 13 | const { scene } = useThree() 14 | 15 | const particleSystem = useMemo(() => new ParticleSystem(), []) 16 | 17 | useFrame((state, delta) => { 18 | particleSystem.update(delta) 19 | ref.current?.update() 20 | }) 21 | 22 | useEffect(() => { 23 | const geo = new ParticleGeometry(particleSystem.world) 24 | 25 | ref.current = geo 26 | 27 | const mat = new ParticleMaterial() 28 | 29 | const points = new Points(geo, mat) 30 | 31 | scene.add(points) 32 | 33 | const main = particleSystem.addParticle({ 34 | size: 3, 35 | emitting: [ 36 | { 37 | rateOverTime: 10, 38 | startLifetime: 2, 39 | startSpeed: 0.3, 40 | size: 3, 41 | startRotation: [1, 1, 0], 42 | }, 43 | ], 44 | }) 45 | 46 | return () => particleSystem.destroyParticle(main) 47 | }, []) 48 | 49 | return null 50 | } 51 | 52 | const Simple = () => { 53 | return ( 54 | 55 | 56 | 57 | 58 | 59 | 60 | ) 61 | } 62 | 63 | export default Simple 64 | -------------------------------------------------------------------------------- /examples/src/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | canvas { 6 | touch-action: none; 7 | } 8 | 9 | html, 10 | body, 11 | #root { 12 | width: 100%; 13 | height: 100%; 14 | margin: 0; 15 | padding: 0; 16 | -webkit-touch-callout: none; 17 | -webkit-user-select: none; 18 | -khtml-user-select: none; 19 | -moz-user-select: none; 20 | -ms-user-select: none; 21 | user-select: none; 22 | overflow: hidden; 23 | } 24 | 25 | #root { 26 | overflow: auto; 27 | } 28 | 29 | body { 30 | position: fixed; 31 | overflow: hidden; 32 | overscroll-behavior-y: none; 33 | font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, helvetica neue, helvetica, ubuntu, 34 | roboto, noto, segoe ui, arial, sans-serif; 35 | color: black; 36 | background: #131228; 37 | } 38 | 39 | .page { 40 | position: relative; 41 | width: 100%; 42 | height: 100vh; 43 | } 44 | 45 | .page > h1 { 46 | font-family: 'Roboto', sans-serif; 47 | font-weight: 900; 48 | font-size: 8em; 49 | margin: 0; 50 | color: white; 51 | line-height: 0.59em; 52 | letter-spacing: -2px; 53 | } 54 | 55 | @media only screen and (max-width: 1000px) { 56 | .page > h1 { 57 | font-size: 5em; 58 | letter-spacing: -1px; 59 | } 60 | } 61 | 62 | .page > a { 63 | margin: 0; 64 | color: white; 65 | text-decoration: none; 66 | } 67 | 68 | .demo-panel { 69 | position: absolute; 70 | bottom: 50px; 71 | left: 50px; 72 | max-width: 300px; 73 | } 74 | 75 | .spot { 76 | display: inline-block; 77 | width: 14px; 78 | height: 14px; 79 | border-radius: 50%; 80 | margin: 8px; 81 | background: #49148a; 82 | } 83 | 84 | .spot.selected { 85 | display: inline-block; 86 | width: 20px; 87 | height: 20px; 88 | border-radius: 50%; 89 | margin: 5px; 90 | border: 3px solid #b12c52; 91 | background: #fbff4e; 92 | } 93 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sprudel", 3 | "version": "0.0.5", 4 | "description": "three.js ECS Particle System", 5 | "keywords": [ 6 | "three", 7 | "vfx", 8 | "particle", 9 | "trail", 10 | "effect" 11 | ], 12 | "author": "Joerg Jaeckel", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/joergjaeckel/sprudel.git" 16 | }, 17 | "license": "MIT", 18 | "module": "dist/index.js", 19 | "types": "dist/index.d.ts", 20 | "sideEffects": false, 21 | "scripts": { 22 | "build": "rollup -c", 23 | "dev": "rollup -c --watch", 24 | "eslint": "eslint .", 25 | "eslint-fix": "eslint --fix .", 26 | "prebuild": "tsc", 27 | "prepare": "husky install", 28 | "prettier": "prettier --list-different .", 29 | "prettier-fix": "prettier --write .", 30 | "test": "echo tests are missing" 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.16.7", 34 | "@babel/plugin-transform-runtime": "^7.16.8", 35 | "@babel/preset-env": "^7.16.8", 36 | "@babel/preset-typescript": "^7.16.5", 37 | "@rollup/plugin-babel": "^5.3.0", 38 | "@rollup/plugin-image": "^2.1.1", 39 | "@rollup/plugin-node-resolve": "^13.1.3", 40 | "@types/node": "^17.0.23", 41 | "@types/three": "^0.139.0", 42 | "@typescript-eslint/eslint-plugin": "^5.17.0", 43 | "@typescript-eslint/parser": "^5.17.0", 44 | "eslint": "^8.12.0", 45 | "eslint-config-prettier": "^8.3.0", 46 | "eslint-plugin-es": "^4.1.0", 47 | "eslint-plugin-simple-import-sort": "^7.0.0", 48 | "eslint-plugin-typescript-enum": "^2.1.0", 49 | "husky": "^7.0.4", 50 | "lint-staged": "^12.3.2", 51 | "prettier": "^2.6.1", 52 | "rollup": "^2.64.0", 53 | "three": "^0.139.2", 54 | "typescript": "^4.6.3" 55 | }, 56 | "lint-staged": { 57 | "*.{js,jsx,ts,tsx}": "eslint --cache --fix", 58 | "*.{js,jsx,ts,tsx,md}": "prettier --write" 59 | }, 60 | "dependencies": { 61 | "miniplex": "^0.9.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/ParticleGeometry.ts: -------------------------------------------------------------------------------- 1 | import { BufferAttribute, BufferGeometry } from 'three' 2 | import type { Archetype, World } from 'miniplex' 3 | 4 | export class ParticleGeometry extends BufferGeometry { 5 | isParticleGeometry: boolean 6 | 7 | world: World 8 | 9 | archetype: Archetype 10 | 11 | constructor(world: World, maxCount = 10000) { 12 | super() 13 | 14 | this.isParticleGeometry = true 15 | 16 | this.type = 'isParticleGeometry' 17 | 18 | this.world = world 19 | 20 | this.archetype = world.archetype('particle') 21 | 22 | this.setAttribute('position', new BufferAttribute(new Float32Array(maxCount * 3), 3)) 23 | this.setAttribute('color', new BufferAttribute(new Float32Array(maxCount * 3), 3)) 24 | this.setAttribute('opacity', new BufferAttribute(new Float32Array(maxCount), 1)) 25 | this.setAttribute('size', new BufferAttribute(new Float32Array(maxCount), 1)) 26 | this.setAttribute('sprite', new BufferAttribute(new Float32Array(maxCount), 1)) 27 | 28 | this.update() 29 | } 30 | 31 | update() { 32 | for (let i = 0; i < this.archetype.entities.length; i++) { 33 | const { 34 | position = { x: 0, y: 0, z: 0 }, 35 | opacity = { value: [1] }, 36 | size = { value: [1] }, 37 | color = { value: [1, 1, 1] }, 38 | sprite = 0, 39 | } = this.archetype.entities[i] 40 | 41 | this.attributes.position.setXYZ(i, position.x, position.y, position.z) 42 | this.attributes.color.setXYZ(i, color.value[0], color.value[1], color.value[2]) 43 | this.attributes.opacity.setX(i, opacity.value[0]) 44 | this.attributes.size.setX(i, size.value[0]) 45 | this.attributes.sprite.setX(i, sprite) 46 | } 47 | 48 | this.setDrawRange(0, this.archetype.entities.length) 49 | 50 | this.attributes.position.needsUpdate = true 51 | this.attributes.color.needsUpdate = true 52 | this.attributes.opacity.needsUpdate = true 53 | this.attributes.size.needsUpdate = true 54 | this.attributes.sprite.needsUpdate = true 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /examples/src/demos/MultipleSystems.tsx: -------------------------------------------------------------------------------- 1 | import { Canvas, extend, useFrame } from '@react-three/fiber' 2 | import { useEffect, useMemo, useRef } from 'react' 3 | import { OrbitControls } from '@react-three/drei' 4 | import { ParticleGeometry, ParticleMaterial, ParticleSystem } from 'sprudel' 5 | import { NumberKeyframeTrack, Vector3 } from 'three' 6 | import GridPlate from '../GridPlate' 7 | 8 | extend({ ParticleGeometry, ParticleMaterial }) 9 | 10 | const Particles = ({ 11 | position, 12 | color, 13 | }: { 14 | position: [number, number, number] 15 | color: [number, number, number] 16 | }) => { 17 | const ref = useRef() 18 | 19 | const particleSystem = useMemo(() => new ParticleSystem(), []) 20 | 21 | useFrame((state, delta) => { 22 | particleSystem.update(delta) 23 | ref.current?.update() 24 | }) 25 | 26 | useEffect(() => { 27 | const main = particleSystem.addParticle({ 28 | size: 3, 29 | position: new Vector3(...position), 30 | color, 31 | emitting: [ 32 | { 33 | color, 34 | sprite: 1, 35 | rateOverTime: 10, 36 | startLifetime: 2, 37 | startSpeed: 0.3, 38 | startRotation: [0, 1, 0], 39 | randomizeRotation: 1.5, 40 | sizeOverLifetime: new NumberKeyframeTrack('Particle Size', [0, 0.2, 1], [0, 1, 0]), 41 | }, 42 | ], 43 | }) 44 | 45 | return () => particleSystem.destroyParticle(main) 46 | }, []) 47 | 48 | return ( 49 | 50 | 51 | 52 | 53 | ) 54 | } 55 | 56 | const Simple = () => { 57 | return ( 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | ) 66 | } 67 | 68 | export default Simple 69 | -------------------------------------------------------------------------------- /examples/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react' 2 | import { HashRouter as Router, Link, Route, Routes, useMatch } from 'react-router-dom' 3 | import logo from './logo.png' 4 | import * as demos from './demos' 5 | 6 | declare global { 7 | namespace JSX { 8 | interface IntrinsicElements { 9 | particleGeometry: any 10 | particleMaterial: any 11 | ribbonGeometry: any 12 | ribbonMaterial: any 13 | } 14 | } 15 | } 16 | 17 | const defaultName = 'Simple' 18 | 19 | const visibleComponents = Object.entries(demos).reduce( 20 | (acc, [name, component]) => ({ ...acc, [name]: component }), 21 | {}, 22 | ) 23 | 24 | // @ts-ignore 25 | const DefaultComponent = visibleComponents[defaultName].Component 26 | 27 | const RoutedComponent = () => { 28 | const { 29 | params: { name }, 30 | } = useMatch('/demo/:name') || { params: { name: defaultName } } 31 | // @ts-ignore 32 | const Component = visibleComponents[name || defaultName].Component 33 | return 34 | } 35 | 36 | function Intro() { 37 | return ( 38 |
39 | 40 | 41 | } /> 42 | } /> 43 | 44 | 45 | 46 | 50 | 51 | 52 |
53 | ) 54 | } 55 | 56 | function Demos() { 57 | const { 58 | params: { name: routeName }, 59 | } = useMatch('/demo/:name') || { params: { name: defaultName } } 60 | return ( 61 |
62 | {Object.entries(visibleComponents).map(([name], key) => ( 63 | 64 |
65 | 66 | ))} 67 |
68 | ) 69 | } 70 | 71 | export default function App() { 72 | return ( 73 | 74 | 75 | 76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # PHPStorm project files 107 | .idea 108 | 109 | # Mac OS folder settings file 110 | .DS_Store 111 | -------------------------------------------------------------------------------- /examples/src/demos/Color.tsx: -------------------------------------------------------------------------------- 1 | import { Canvas, extend, useFrame, useLoader } from '@react-three/fiber' 2 | import { useEffect, useMemo, useRef } from 'react' 3 | import { OrbitControls } from '@react-three/drei' 4 | import { ParticleGeometry, ParticleMaterial, ParticleSystem } from 'sprudel' 5 | import { ColorKeyframeTrack, NumberKeyframeTrack, TextureLoader } from 'three' 6 | import spriteSheet from './assets/images/spritesheet.png' 7 | import GridPlate from '../GridPlate' 8 | 9 | extend({ ParticleGeometry, ParticleMaterial }) 10 | 11 | const Particles = () => { 12 | const ref = useRef() 13 | 14 | const alphaMap = useLoader(TextureLoader, spriteSheet) 15 | 16 | const particleSystem = useMemo(() => new ParticleSystem(), []) 17 | 18 | useFrame((state, delta) => { 19 | particleSystem.update(delta) 20 | ref.current?.update() 21 | }) 22 | 23 | useEffect(() => { 24 | const main = particleSystem.addParticle({ 25 | sprite: 0, 26 | size: 3, 27 | emitting: [ 28 | { 29 | rateOverTime: 3, 30 | startLifetime: 2, 31 | startSpeed: 0.2, 32 | size: 6, 33 | sprite: 11, 34 | startRotation: [0, 1, 0], 35 | randomizeRotation: 0.1, 36 | mass: -2, 37 | colorOverLifetime: new ColorKeyframeTrack( 38 | 'Particle Color', 39 | [0, 0.25, 0.8], 40 | [0, 0, 0.5, 0.4, 0, 1, 1, 0, 0], 41 | ), 42 | opacityOverLifetime: new NumberKeyframeTrack('Particle Opacity', [0, 0.2, 0.8, 1], [0, 1, 0.8, 0]), 43 | bursts: [ 44 | { 45 | count: 0, 46 | cycleCount: -1, 47 | repeatInterval: 3, 48 | time: 0, 49 | }, 50 | ], 51 | }, 52 | ], 53 | }) 54 | 55 | return () => particleSystem.destroyParticle(main) 56 | }, []) 57 | 58 | return ( 59 | 60 | 61 | 66 | 67 | ) 68 | } 69 | 70 | const Simple = () => ( 71 | 72 | 73 | 74 | 75 | 76 | 77 | ) 78 | 79 | export default Simple 80 | -------------------------------------------------------------------------------- /examples/src/demos/RibbonTest.tsx: -------------------------------------------------------------------------------- 1 | import { Canvas, extend, useLoader } from '@react-three/fiber' 2 | import { useEffect, useMemo, useRef } from 'react' 3 | import { OrbitControls } from '@react-three/drei' 4 | import { ParticleSystem, ParticleGeometry, ParticleMaterial, RibbonGeometry, RibbonMaterial } from 'sprudel' 5 | import spriteSheet from './assets/images/spritesheet.png' 6 | import trailSheet from './assets/images/trailsheet.png' 7 | import { TextureLoader, Vector3 } from 'three' 8 | import GridPlate from '../GridPlate' 9 | 10 | extend({ ParticleGeometry, ParticleMaterial, RibbonGeometry, RibbonMaterial }) 11 | 12 | const Particles = () => { 13 | const particleRef = useRef() 14 | const ribbonRef = useRef() 15 | 16 | const [alphaMap, trailMap] = useLoader(TextureLoader, [spriteSheet, trailSheet]) 17 | 18 | const particleSystem = useMemo(() => new ParticleSystem(), []) 19 | 20 | useEffect(() => { 21 | const start = particleSystem.addParticle({ 22 | sprite: 0, 23 | size: 3, 24 | position: new Vector3(-5, 2, 0), 25 | ribbon: true, 26 | parent: 99, 27 | linewidth: 2, 28 | color: [1, 0, 0], 29 | }) 30 | 31 | const end = particleSystem.addParticle({ 32 | sprite: 0, 33 | size: 3, 34 | position: new Vector3(5, 2, 0), 35 | ribbon: true, 36 | parent: 99, 37 | linewidth: 1, 38 | color: [0, 0, 1], 39 | }) 40 | 41 | particleSystem.update(0) 42 | particleRef.current?.update() 43 | ribbonRef.current?.update() 44 | 45 | return () => { 46 | particleSystem.destroyParticle(start) 47 | particleSystem.destroyParticle(end) 48 | } 49 | }, []) 50 | 51 | return ( 52 | <> 53 | 54 | 55 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | ) 67 | } 68 | 69 | const Bursts = () => { 70 | return ( 71 | 72 | 73 | 74 | 75 | 76 | 77 | ) 78 | } 79 | 80 | export default Bursts 81 | -------------------------------------------------------------------------------- /examples/src/demos/RibbonBurst.tsx: -------------------------------------------------------------------------------- 1 | import { Canvas, extend, useFrame, useLoader } from '@react-three/fiber' 2 | import { useEffect, useMemo, useRef } from 'react' 3 | import { OrbitControls } from '@react-three/drei' 4 | import { ParticleSystem, ParticleGeometry, ParticleMaterial, RibbonGeometry, RibbonMaterial } from 'sprudel' 5 | import spriteSheet from './assets/images/spritesheet.png' 6 | import { TextureLoader } from 'three' 7 | import trailSheet from './assets/images/trailsheet.png' 8 | import GridPlate from '../GridPlate' 9 | 10 | extend({ ParticleGeometry, ParticleMaterial, RibbonGeometry, RibbonMaterial }) 11 | 12 | const Particles = () => { 13 | const particleRef = useRef() 14 | const ribbonRef = useRef() 15 | 16 | const [alphaMap, trailMap] = useLoader(TextureLoader, [spriteSheet, trailSheet]) 17 | 18 | const particleSystem = useMemo(() => new ParticleSystem(), []) 19 | 20 | useFrame((state, delta) => { 21 | particleSystem.update(delta) 22 | particleRef.current?.update() 23 | ribbonRef.current?.update() 24 | }) 25 | 26 | useEffect(() => { 27 | const main = particleSystem.addParticle({ 28 | sprite: 0, 29 | size: 3, 30 | emitting: [ 31 | { 32 | hideParticle: true, 33 | rateOverTime: 0, 34 | startLifetime: 2, 35 | startSpeed: 0.3, 36 | size: 2, 37 | randomizeRotation: 2, 38 | randomizeLifetime: 1, 39 | randomizeSpeed: 0.1, 40 | bursts: [ 41 | { 42 | count: 16, 43 | cycleCount: -1, 44 | repeatInterval: 0.75, 45 | time: 0, 46 | }, 47 | ], 48 | emitting: [ 49 | { 50 | hideParticle: true, 51 | rateOverTime: 30, 52 | startLifetime: 1, 53 | startSpeed: 0, 54 | size: 3, 55 | mass: 0.1, 56 | linewidth: 0.5, 57 | ribbon: true, 58 | }, 59 | ], 60 | }, 61 | ], 62 | }) 63 | 64 | return () => particleSystem.destroyParticle(main) 65 | }, []) 66 | 67 | return ( 68 | <> 69 | 70 | 71 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | ) 83 | } 84 | 85 | const Bursts = () => { 86 | return ( 87 | 88 | 89 | 90 | 91 | 92 | 93 | ) 94 | } 95 | 96 | export default Bursts 97 | -------------------------------------------------------------------------------- /examples/src/demos/Collision.tsx: -------------------------------------------------------------------------------- 1 | import {Canvas, extend, useFrame, useLoader, useThree} from '@react-three/fiber' 2 | import { useEffect, useMemo, useRef } from 'react' 3 | import { OrbitControls } from '@react-three/drei' 4 | import { ParticleGeometry, ParticleMaterial, ParticleSystem } from 'sprudel' 5 | import {NumberKeyframeTrack, TextureLoader, Vector3} from 'three' 6 | import spriteSheet from './assets/images/spritesheet.png' 7 | import GridPlate from '../GridPlate' 8 | 9 | extend({ ParticleGeometry, ParticleMaterial }) 10 | 11 | const Particles = () => { 12 | const ref = useRef() 13 | 14 | const alphaMap = useLoader(TextureLoader, spriteSheet) 15 | 16 | const { scene } = useThree() 17 | 18 | const particleSystem = useMemo(() => new ParticleSystem(scene), []) 19 | 20 | useFrame((state, delta) => { 21 | particleSystem.update(delta) 22 | ref.current?.update() 23 | }) 24 | 25 | useEffect(() => { 26 | const main = particleSystem.addParticle({ 27 | size: 3, 28 | position: new Vector3(0,1,0), 29 | emitting: [ 30 | { 31 | rateOverTime: 5, 32 | startLifetime: 10, 33 | startSpeed: 0.25, 34 | size: 2, 35 | sprite: 1, 36 | randomizeRotation: 2, 37 | speedModifier: .99, 38 | startRotation: [0.2, 0, -1], 39 | color: [2, 2, 2], 40 | mass: 1, 41 | opacityOverLifetime: new NumberKeyframeTrack('Particle Opacity', [0, 0.66, 1], [1, 0.85, 0]), 42 | collide: true, 43 | bursts: [ 44 | { 45 | count: 50, 46 | cycleCount: -1, 47 | repeatInterval: 1, 48 | time: 0, 49 | }, 50 | ], 51 | }, 52 | ], 53 | }) 54 | 55 | return () => particleSystem.destroyParticle(main) 56 | }, []) 57 | 58 | return ( 59 | 60 | 61 | 66 | 67 | ) 68 | } 69 | 70 | const Bursts = () => ( 71 | 72 | 73 | 74 | 75 | 76 | object.layers.enable( 1 )} position={[3,1.5,3]}> 77 | 78 | 79 | 80 | object.layers.enable( 1 )} position={[-3,1.5,-2]} rotation-y={Math.PI/4}> 81 | 82 | 83 | 84 | object.layers.enable( 1 )} position={[5,-10,-10]} rotation-x={Math.PI/4}> 85 | 86 | 87 | 88 | 89 | ) 90 | 91 | export default Bursts 92 | -------------------------------------------------------------------------------- /src/systems/emittingSystem.ts: -------------------------------------------------------------------------------- 1 | import type { World } from 'miniplex' 2 | import { Interpolant, KeyframeTrack, Vector3 } from 'three' 3 | import { validateParticle } from '../validateParticle' 4 | 5 | export const emittingSystem = (world: World, delta: number) => { 6 | const { entities } = world.archetype('emitting') 7 | 8 | for (let i = 0; i < entities.length; i++) { 9 | const entity = entities[i] 10 | 11 | for (let j = 0; j < entity.emitting.length; j++) { 12 | const emitter = entity.emitting[j] 13 | 14 | emitter.accumulate += emitter.rateOverTime * delta 15 | 16 | if (emitter.bursts) { 17 | for (let k = 0; k < emitter.bursts.length; k++) { 18 | const burst = emitter.bursts[k] 19 | 20 | if (burst.cycleCount > 0 && burst.cycle >= burst.cycleCount) continue 21 | 22 | if ( 23 | entity.operationalLifetime >= burst.time && 24 | burst.cycle < Math.floor(entity.operationalLifetime / burst.repeatInterval + burst.repeatInterval) 25 | ) { 26 | burst.cycle++ 27 | emitter.accumulate += burst.count 28 | } 29 | } 30 | } 31 | 32 | for (let k = 0; k < Math.floor(emitter.accumulate); k++) { 33 | const startLifetime = emitter.startLifetime + (Math.random() - 0.5) * emitter.randomizeLifetime 34 | 35 | const startRotation = emitter.inheritVelocity 36 | ? entity.velocity.clone() 37 | : new Vector3().fromArray(emitter.startRotation) 38 | 39 | const startSpeed = emitter.inheritVelocity 40 | ? entity.speed 41 | : emitter.startSpeed + Math.random() * emitter.randomizeSpeed - emitter.randomizeSpeed / 2 42 | 43 | /* deep cloning objects, otherwise they won't live independent lives */ 44 | 45 | world.queue.createEntity( 46 | validateParticle({ 47 | ...emitter, 48 | ...(emitter.opacityOverLifetime && { 49 | opacity: { 50 | value: [1], 51 | interpolant: ( 52 | emitter.opacityOverLifetime as KeyframeTrack & { createInterpolant: () => Interpolant } 53 | ).createInterpolant(), 54 | }, 55 | }), 56 | ...(emitter.colorOverLifetime && { 57 | color: { 58 | value: [1, 1, 1], 59 | interpolant: ( 60 | emitter.colorOverLifetime as KeyframeTrack & { createInterpolant: () => Interpolant } 61 | ).createInterpolant(), 62 | }, 63 | }), 64 | ...(emitter.sizeOverLifetime && { 65 | size: { 66 | value: [1], 67 | interpolant: ( 68 | emitter.sizeOverLifetime as KeyframeTrack & { createInterpolant: () => Interpolant } 69 | ).createInterpolant(), 70 | }, 71 | }), 72 | position: new Vector3() 73 | .copy(entity.position) 74 | .addScaledVector(new Vector3().random(), emitter.randomizePosition), 75 | velocity: startRotation 76 | .addScaledVector(new Vector3().randomDirection(), emitter.randomizeRotation) 77 | .setLength(startSpeed), 78 | startLifetime, 79 | startSpeed, 80 | speed: startSpeed, 81 | parent: entity.id, 82 | }), 83 | ) 84 | } 85 | 86 | emitter.accumulate %= 1 87 | } 88 | } 89 | 90 | world.queue.flush() 91 | } 92 | -------------------------------------------------------------------------------- /src/points.glsl.js: -------------------------------------------------------------------------------- 1 | export const vertex = /* glsl */ ` 2 | attribute float size; 3 | attribute float opacity; 4 | attribute float sprite; 5 | varying float vOpacity; 6 | varying float vSprite; 7 | uniform float scale; 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | void main() { 15 | #include 16 | #include 17 | #include 18 | #include 19 | gl_PointSize = size; 20 | vOpacity = opacity; 21 | vSprite = sprite; 22 | #ifdef USE_SIZEATTENUATION 23 | bool isPerspective = isPerspectiveMatrix( projectionMatrix ); 24 | if ( isPerspective ) gl_PointSize *= ( scale / - mvPosition.z ); 25 | #endif 26 | #include 27 | #include 28 | #include 29 | #include 30 | } 31 | ` 32 | 33 | export const fragment = /* glsl */ ` 34 | uniform vec3 diffuse; 35 | varying float vOpacity; 36 | varying float vSprite; 37 | //uniform float opacity; 38 | #include 39 | #include 40 | #include 41 | #include 42 | #include 43 | #include 44 | #include 45 | 46 | //uniform sampler2D spriteSheet; 47 | uniform vec2 spriteSheetSize; // In px 48 | uniform vec2 spriteSize; // In px 49 | //uniform float sprite; // Sprite index in sprite sheet (0-...) 50 | 51 | void main() { 52 | #include 53 | vec3 outgoingLight = vec3( 0.0 ); 54 | vec4 diffuseColor = vec4( diffuse, vOpacity ); 55 | #include 56 | 57 | float w = spriteSheetSize.x; 58 | float h = spriteSheetSize.y; 59 | 60 | // Normalize sprite size (0.0-1.0) 61 | float dx = spriteSize.x / w; 62 | float dy = spriteSize.y / h; 63 | 64 | // Figure out number of tile cols of sprite sheet 65 | // cols = 1024 / 128 = 8 66 | float cols = w / spriteSize.x; 67 | 68 | // From linear index to row/col pair 69 | // col = mod(0, 8) = 0 70 | // row = floor(0 / 8) = 0 71 | float col = mod(vSprite, cols); 72 | float row = floor(vSprite / cols); 73 | 74 | // Finally to UV texture coordinates 75 | // 1.0 - 0,125 - 1 * 0,125 + 0,125 * y 76 | 77 | #if defined( USE_SPRITE ) 78 | vec2 uv = vec2(dx * gl_PointCoord.x + col * dx, 1.0 - row * dy - dy * gl_PointCoord.y); 79 | // flipY: 1.0 - dy - row * dy + dy * gl_PointCoord.y 80 | #elif defined( USE_UV ) 81 | vec2 uv = vec2(gl_PointCoord.x, 1.0 - gl_PointCoord.y); 82 | #endif 83 | 84 | #if defined( USE_MAP ) 85 | diffuseColor = texture2D( map, uv ); 86 | #endif 87 | 88 | #if defined( USE_ALPHAMAP ) 89 | diffuseColor.a *= texture2D( alphaMap, uv ).g; 90 | #endif 91 | 92 | //include 93 | #include 94 | #include 95 | 96 | outgoingLight = diffuseColor.rgb; 97 | #include 98 | #include 99 | #include 100 | #include 101 | #include 102 | 103 | gl_FragColor.xyz *= gl_FragColor.w; 104 | gl_FragColor.w *= 0.0; 105 | 106 | } 107 | ` 108 | -------------------------------------------------------------------------------- /src/ParticleMaterial.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AddEquation, 3 | Color, 4 | CustomBlending, 5 | Matrix3, 6 | OneFactor, 7 | OneMinusSrcAlphaFactor, 8 | ShaderChunk, 9 | ShaderMaterial, 10 | } from 'three' 11 | import { fragment, vertex } from './points.glsl' 12 | 13 | var pattern = /#include <(.*)>/gm 14 | 15 | function parseIncludes(string: string) { 16 | function replace(match: string, include: string) { 17 | var replace = ShaderChunk[include] 18 | return parseIncludes(replace) 19 | } 20 | 21 | return string.replace(pattern, replace) 22 | } 23 | 24 | //console.log(parseIncludes(vertex)); 25 | //console.log(parseIncludes(fragment)); 26 | 27 | export class ParticleMaterial extends ShaderMaterial { 28 | constructor() { 29 | super() 30 | 31 | this.type = 'ParticleMaterial' 32 | 33 | this.vertexShader = vertex 34 | 35 | this.fragmentShader = fragment 36 | 37 | this.uniforms = { 38 | diffuse: { value: new Color(0xffffff) }, 39 | opacity: { value: 1.0 }, 40 | scale: { value: 500.0 }, 41 | map: { value: null }, 42 | alphaMap: { value: null }, 43 | alphaTest: { value: 0 }, 44 | uvTransform: { value: new Matrix3() }, 45 | fogDensity: { value: 0.00025 }, 46 | fogNear: { value: 1 }, 47 | fogFar: { value: 2000 }, 48 | fogColor: { value: new Color(0xffffff) }, 49 | spriteSheetSize: { value: { x: 1024, y: 1024 } }, 50 | spriteSize: { value: { x: 1024, y: 1024 } }, 51 | } 52 | 53 | this.defines = { 54 | USE_SIZEATTENUATION: 1, 55 | } 56 | 57 | /* 58 | Blending optimized for glowing, light particles 59 | https://gdcvault.com/play/1017660/Technical-Artist-Bootcamp-The-VFX 60 | https://github.com/simondevyoutube/ThreeJS_Tutorial_BlendModes 61 | https://youtu.be/AxopC4yW4uY 62 | also the last two lines of the fragment shader were added 63 | possibly pass in an animatable value for emission level like simon did 64 | */ 65 | this.blending = CustomBlending 66 | this.blendEquation = AddEquation 67 | this.blendSrc = OneFactor 68 | this.blendDst = OneMinusSrcAlphaFactor 69 | 70 | this.depthTest = true 71 | this.depthWrite = false 72 | 73 | this.vertexColors = true 74 | 75 | this.transparent = true 76 | 77 | Object.defineProperties(this, { 78 | alphaMap: { 79 | enumerable: true, 80 | get() { 81 | //return this.material.alphaMap 82 | }, 83 | set(value) { 84 | this.uniforms.alphaMap.value = value 85 | this.defines.USE_ALPHAMAP = 1 86 | }, 87 | }, 88 | sizeAttenuation: { 89 | enumerable: true, 90 | get() { 91 | //return this.material.alphaMap 92 | }, 93 | set(value) { 94 | this.defines.USE_SIZEATTENUATION = value 95 | }, 96 | }, 97 | spriteSheetSize: { 98 | enumerable: true, 99 | get() { 100 | //return this.material.alphaMap 101 | }, 102 | set(value) { 103 | this.uniforms.spriteSheetSize.value = value 104 | this.defines.USE_SPRITE = 1 105 | }, 106 | }, 107 | spriteSize: { 108 | enumerable: true, 109 | get() { 110 | //return this.material.alphaMap 111 | }, 112 | set(value) { 113 | this.uniforms.spriteSize.value = value 114 | this.defines.USE_SPRITE = 1 115 | }, 116 | }, 117 | }) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /examples/src/demos/Burst.tsx: -------------------------------------------------------------------------------- 1 | import { Canvas, extend, useFrame, useLoader } from '@react-three/fiber' 2 | import { useEffect, useMemo, useRef } from 'react' 3 | import { OrbitControls } from '@react-three/drei' 4 | import { ParticleGeometry, ParticleMaterial, ParticleSystem } from 'sprudel' 5 | import { ColorKeyframeTrack, NumberKeyframeTrack, TextureLoader } from 'three' 6 | import spriteSheet from './assets/images/spritesheet.png' 7 | import GridPlate from '../GridPlate' 8 | 9 | extend({ ParticleGeometry, ParticleMaterial }) 10 | 11 | const Particles = () => { 12 | const ref = useRef() 13 | 14 | const alphaMap = useLoader(TextureLoader, spriteSheet) 15 | 16 | const particleSystem = useMemo(() => new ParticleSystem(), []) 17 | 18 | useFrame((state, delta) => { 19 | particleSystem.update(delta) 20 | ref.current?.update() 21 | }) 22 | 23 | useEffect(() => { 24 | const main = particleSystem.addParticle({ 25 | size: 3, 26 | emitting: [ 27 | { 28 | rateOverTime: 0, 29 | startLifetime: 1, 30 | startSpeed: 0.4, 31 | size: 3, 32 | sprite: 1, 33 | randomizeRotation: 2, 34 | startRotation: [0, 1, 0], 35 | color: [2, 2, 2], 36 | opacityOverLifetime: new NumberKeyframeTrack('Particle Opacity', [0, 0.66, 1], [1, 0.85, 0]), 37 | bursts: [ 38 | { 39 | count: 8, 40 | cycleCount: -1, 41 | repeatInterval: 1, 42 | time: 0, 43 | }, 44 | ], 45 | emitting: [ 46 | { 47 | rateOverTime: 45, 48 | startLifetime: 2, 49 | startSpeed: 0.05, 50 | size: 6, 51 | sprite: 1, 52 | randomizeRotation: 2, 53 | mass: 0.3, 54 | startRotation: [0, 0, 0], 55 | color: [0.6, 0, 2], 56 | sizeOverLifetime: new NumberKeyframeTrack('Glowing Smoke Size', [0, 1], [3, 7]), 57 | opacityOverLifetime: new NumberKeyframeTrack( 58 | 'Glowing Smoke Opacity', 59 | [0, 0.6, 1], 60 | [0.3, 0.2, 0], 61 | ), 62 | colorOverLifetime: new ColorKeyframeTrack( 63 | 'Glowing Smoke Color', 64 | [0, 0.7], 65 | [0.6, 0, 2, 0, 0, 0], 66 | ), 67 | }, 68 | { 69 | rateOverTime: 20, 70 | startLifetime: 1, 71 | startSpeed: 0.05, 72 | size: 0.5, 73 | sprite: 12, 74 | randomizeRotation: 0.2, 75 | mass: 1.5, 76 | startRotation: [0, 0, 0], 77 | color: [4, 4, 4], 78 | opacity: 3, 79 | sizeOverLifetime: new NumberKeyframeTrack('Sparkles Size', [0, 1], [0.5, 0]), 80 | }, 81 | ], 82 | }, 83 | ], 84 | }) 85 | 86 | return () => particleSystem.destroyParticle(main) 87 | }, []) 88 | 89 | return ( 90 | 91 | 92 | 97 | 98 | ) 99 | } 100 | 101 | const Bursts = () => ( 102 | 103 | 104 | 105 | 106 | 107 | 108 | ) 109 | 110 | export default Bursts 111 | -------------------------------------------------------------------------------- /src/RibbonMaterial.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AddEquation, 3 | Color, 4 | CustomBlending, 5 | Matrix3, 6 | OneFactor, 7 | OneMinusSrcAlphaFactor, 8 | ShaderChunk, 9 | ShaderLib, 10 | ShaderMaterial, 11 | UniformsLib, 12 | Vector2, 13 | } from 'three' 14 | import { vertex } from './ribbons.glsl' 15 | import { mergeUniforms } from 'three/src/renderers/shaders/UniformsUtils.js' 16 | 17 | var pattern = /#include <(.*)>/gm 18 | 19 | function parseIncludes(string: string) { 20 | function replace(match: string, include: string) { 21 | var replace = ShaderChunk[include] 22 | return parseIncludes(replace) 23 | } 24 | 25 | return string.replace(pattern, replace) 26 | } 27 | 28 | //console.log(parseIncludes(vertex)); 29 | //console.log(parseIncludes(fragment)); 30 | 31 | export class RibbonMaterial extends ShaderMaterial { 32 | constructor() { 33 | super() 34 | 35 | this.type = 'RibbonMaterial' 36 | 37 | this.vertexShader = vertex 38 | 39 | this.fragmentShader = ShaderLib.basic.fragmentShader 40 | 41 | this.uniforms = mergeUniforms([ 42 | UniformsLib.common, 43 | UniformsLib.specularmap, 44 | UniformsLib.envmap, 45 | UniformsLib.aomap, 46 | UniformsLib.lightmap, 47 | UniformsLib.fog, 48 | { 49 | resolution: { value: new Vector2(1, 1) }, 50 | sizeAttenuation: { value: 1 }, 51 | dashArray: { value: 0 }, 52 | dashOffset: { value: 0 }, 53 | dashRatio: { value: 0.5 }, 54 | useDash: { value: 0 }, 55 | visibility: { value: 1 }, 56 | alphaTest: { value: 0 }, 57 | //diffuse: { value: new Color(1,0,0) }, 58 | alphaMap: { value: null }, 59 | }, 60 | ]) 61 | 62 | /* 63 | Blending optimized for glowing, light particles 64 | https://gdcvault.com/play/1017660/Technical-Artist-Bootcamp-The-VFX 65 | https://github.com/simondevyoutube/ThreeJS_Tutorial_BlendModes 66 | https://youtu.be/AxopC4yW4uY 67 | also the last two lines of the fragment shader were added 68 | possibly pass in an animatable value for emission level like simon did 69 | */ 70 | /*this.blending = CustomBlending 71 | this.blendEquation = AddEquation 72 | this.blendSrc = OneFactor 73 | this.blendDst = OneMinusSrcAlphaFactor*/ 74 | 75 | this.depthTest = true 76 | this.depthWrite = false 77 | 78 | this.vertexColors = true 79 | 80 | this.transparent = true 81 | 82 | Object.defineProperties(this, { 83 | alphaMap: { 84 | enumerable: true, 85 | get() { 86 | //return this.material.alphaMap 87 | }, 88 | set(value) { 89 | this.uniforms.alphaMap.value = value 90 | this.defines.USE_ALPHAMAP = 1 91 | this.defines.USE_UV = 1 92 | }, 93 | }, 94 | sizeAttenuation: { 95 | enumerable: true, 96 | get() { 97 | //return this.material.alphaMap 98 | }, 99 | set(value) { 100 | this.defines.USE_SIZEATTENUATION = value 101 | }, 102 | }, 103 | spriteSheetSize: { 104 | enumerable: true, 105 | get() { 106 | //return this.material.alphaMap 107 | }, 108 | set(value) { 109 | this.uniforms.spriteSheetSize.value = value 110 | this.defines.USE_SPRITE = 1 111 | }, 112 | }, 113 | spriteSize: { 114 | enumerable: true, 115 | get() { 116 | //return this.material.alphaMap 117 | }, 118 | set(value) { 119 | this.uniforms.spriteSize.value = value 120 | this.defines.USE_SPRITE = 1 121 | }, 122 | }, 123 | }) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": ["prettier", "plugin:react/recommended", "eslint:recommended"], 7 | "overrides": [ 8 | { 9 | "extends": "plugin:@typescript-eslint/recommended", 10 | "files": ["*.tsx", "*.ts"], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "project": ["tsconfig.json", "examples/tsconfig.json"] 14 | }, 15 | "plugins": ["@typescript-eslint", "typescript-enum"], 16 | "rules": { 17 | "@typescript-eslint/ban-types": ["error", { "extendDefaults": true, "types": { "{}": false } }], 18 | "@typescript-eslint/consistent-type-imports": "error", 19 | "@typescript-eslint/explicit-module-boundary-types": "off", 20 | "@typescript-eslint/member-ordering": [ 21 | "error", 22 | { 23 | "default": { 24 | "order": "alphabetically-case-insensitive" 25 | }, 26 | "classes": { 27 | "order": "alphabetically-case-insensitive", 28 | "memberTypes": [ 29 | "public-static-field", 30 | "protected-static-field", 31 | "private-static-field", 32 | "public-instance-field", 33 | "public-decorated-field", 34 | "public-abstract-field", 35 | "protected-instance-field", 36 | "protected-decorated-field", 37 | "protected-abstract-field", 38 | "private-instance-field", 39 | "private-decorated-field", 40 | "private-abstract-field", 41 | "static-field", 42 | "public-field", 43 | "instance-field", 44 | "protected-field", 45 | "private-field", 46 | "abstract-field", 47 | "constructor", 48 | "public-static-method", 49 | "protected-static-method", 50 | "private-static-method", 51 | "public-method", 52 | "protected-method", 53 | "private-method" 54 | ] 55 | } 56 | } 57 | ], 58 | "@typescript-eslint/no-namespace": ["error", { "allowDeclarations": true }], 59 | "@typescript-eslint/no-non-null-assertion": "off", 60 | "@typescript-eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true }], 61 | "@typescript-eslint/quotes": [ 62 | "error", 63 | "single", 64 | { 65 | "allowTemplateLiterals": true, 66 | "avoidEscape": true 67 | } 68 | ], 69 | "@typescript-eslint/semi": ["error", "never"], 70 | "typescript-enum/no-enum": "error" 71 | } 72 | }, 73 | { 74 | "files": ["*.jsx", "*.js"] 75 | } 76 | ], 77 | "parserOptions": { 78 | "ecmaFeatures": { 79 | "jsx": true 80 | }, 81 | "ecmaVersion": 12, 82 | "sourceType": "module" 83 | }, 84 | "plugins": ["es", "react", "simple-import-sort"], 85 | "rules": { 86 | "eol-last": ["error", "always"], 87 | "es/no-logical-assignment-operators": "error", 88 | "es/no-nullish-coalescing-operators": "error", 89 | "no-debugger": "error", 90 | "no-unused-vars": ["error", { "ignoreRestSiblings": true }], 91 | "react/no-children-prop": 0, 92 | "react/display-name": 0, 93 | "react/prop-types": 0, 94 | "react/react-in-jsx-scope": 0, 95 | "semi": ["error", "never"], 96 | "simple-import-sort/imports": "error", 97 | "simple-import-sort/exports": "error", 98 | "sort-keys": "error" 99 | }, 100 | "settings": { 101 | "react": { 102 | "version": "detect" 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/ribbons.glsl.js: -------------------------------------------------------------------------------- 1 | export const vertex = /* glsl */ ` 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | attribute vec3 previous; 9 | attribute vec3 next; 10 | attribute float side; 11 | attribute float width; 12 | attribute float counters; 13 | 14 | uniform vec2 resolution; 15 | uniform float lineWidth; 16 | //uniform vec3 color; 17 | uniform float opacity; 18 | uniform float sizeAttenuation; 19 | 20 | //varying vec2 vUV; 21 | //varying vec4 vColor; 22 | varying float vCounters; 23 | varying float vWidth; 24 | 25 | vec2 fix(vec4 i, float aspect) { 26 | vec2 res = i.xy / i.w; 27 | res.x *= aspect; 28 | vCounters = counters; 29 | return res; 30 | } 31 | 32 | void main() { 33 | float aspect = resolution.x / resolution.y; 34 | 35 | //vColor = vec4(color, opacity); 36 | //vUV = uv; 37 | 38 | #include 39 | #include 40 | 41 | mat4 m = projectionMatrix * modelViewMatrix; 42 | vec4 finalPosition = m * vec4(position, 1.0); 43 | vec4 prevPos = m * vec4(previous, 1.0); 44 | vec4 nextPos = m * vec4(next, 1.0); 45 | 46 | vec2 currentP = fix(finalPosition, aspect); 47 | vec2 prevP = fix(prevPos, aspect); 48 | vec2 nextP = fix(nextPos, aspect); 49 | 50 | vec2 dir; 51 | 52 | if (nextP == currentP) dir = normalize(currentP - prevP); 53 | else if (prevP == currentP) dir = normalize(nextP - currentP); 54 | else { 55 | vec2 dir1 = normalize(currentP - prevP); 56 | vec2 dir2 = normalize(nextP - currentP); 57 | dir = normalize(dir1 + dir2); 58 | 59 | vec2 perp = vec2(-dir1.y, dir1.x); 60 | vec2 miter = vec2(-dir.y, dir.x); 61 | //w = clamp( w / dot( miter, perp ), 0., 4. * width ); 62 | } 63 | 64 | //vec2 normal = ( cross( vec3( dir, 0. ), vec3( 0., 0., 1. ) ) ).xy; 65 | vec4 normal = vec4(-dir.y, dir.x, 0., 1.); 66 | normal.xy *= .5 * width; 67 | normal *= projectionMatrix; 68 | 69 | if (sizeAttenuation == 0.) { 70 | normal.xy *= finalPosition.w; 71 | normal.xy /= (vec4(resolution, 0., 1.) * projectionMatrix).xy; 72 | } 73 | 74 | finalPosition.xy += normal.xy * side; 75 | 76 | gl_Position = finalPosition; 77 | 78 | #include 79 | 80 | #include 81 | 82 | vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); 83 | }` 84 | 85 | export const fragment = /* glsl */ ` 86 | uniform vec3 diffuse; 87 | uniform float opacity; 88 | 89 | #include 90 | #include 91 | #include 92 | 93 | uniform sampler2D map; 94 | uniform sampler2D alphaMap; 95 | 96 | uniform float useMap; 97 | uniform float useAlphaMap; 98 | uniform float useDash; 99 | uniform float dashArray; 100 | uniform float dashOffset; 101 | uniform float dashRatio; 102 | uniform float visibility; 103 | uniform float alphaTest; 104 | uniform vec2 repeat; 105 | 106 | //varying vec2 vUV; 107 | #include 108 | 109 | varying vec4 vColor; 110 | varying float vCounters; 111 | 112 | void main() { 113 | vec4 diffuseColor = vec4( diffuse, opacity ); 114 | #include 115 | #include 116 | 117 | vec4 c = vColor; 118 | // if( useMap == 1. ) c *= texture2D( map, vUV * repeat ); 119 | #include 120 | // if( useAlphaMap == 1. ) c.a *= texture2D( alphaMap, vUV * repeat ).a; 121 | if( c.a < alphaTest ) discard; 122 | if( useDash == 1. ){ 123 | c.a *= ceil(mod(vCounters + dashOffset, dashArray) - (dashArray * dashRatio)); 124 | } 125 | gl_FragColor = c; 126 | gl_FragColor.a *= step(vCounters, visibility); 127 | 128 | THREE.ShaderChunk.fog_fragment, 129 | }` 130 | -------------------------------------------------------------------------------- /src/validateParticle.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ColorKeyframeTrack, 3 | Interpolant, 4 | InterpolateLinear, 5 | KeyframeTrack, 6 | NumberKeyframeTrack, 7 | Vector3, 8 | } from 'three' 9 | import { Burst, RuntimeBurst, validateBurst } from './vallidateBurst' 10 | 11 | export interface IGeneric { 12 | value: number[] 13 | interpolant?: Interpolant 14 | keyframes?: KeyframeTrack 15 | customFn?: (delta: number) => void 16 | } 17 | 18 | export type Particle = { 19 | hideParticle?: boolean 20 | 21 | color?: IGeneric | [number, number, number] 22 | colorOverLifetime?: ColorKeyframeTrack 23 | 24 | size?: IGeneric | number 25 | sizeOverLifetime?: NumberKeyframeTrack 26 | 27 | opacity?: IGeneric | number 28 | opacityOverLifetime?: NumberKeyframeTrack 29 | 30 | startDelay?: number 31 | startLifetime?: number 32 | startSpeed?: number 33 | startPosition?: [number, number, number] 34 | startRotation?: [number, number, number] 35 | 36 | rateOverTime?: number 37 | 38 | randomizePosition?: number 39 | randomizeRotation?: number 40 | randomizeSpeed?: number 41 | randomizeLifetime?: number 42 | 43 | mass?: number 44 | collide?: boolean 45 | 46 | linewidth?: number 47 | ribbon?: boolean 48 | 49 | sprite?: number 50 | 51 | inheritVelocity?: boolean 52 | 53 | speed?: number 54 | speedModifier?: number 55 | 56 | emitting?: Particle[] 57 | bursts?: Burst[] 58 | 59 | position?: Vector3 60 | parent?: number 61 | } 62 | 63 | export type RuntimeParticle = Particle & { 64 | id: number 65 | 66 | particle?: boolean 67 | hideParticle?: boolean 68 | 69 | color: IGeneric 70 | colorOverLifetime?: ColorKeyframeTrack 71 | 72 | size: IGeneric 73 | sizeOverLifetime?: NumberKeyframeTrack 74 | 75 | opacity: IGeneric 76 | opacityOverLifetime?: NumberKeyframeTrack 77 | 78 | startDelay: number 79 | startLifetime: number 80 | startSpeed: number 81 | startPosition: [number, number, number] 82 | startRotation: [number, number, number] 83 | 84 | rateOverTime: number 85 | 86 | randomizePosition: number 87 | randomizeRotation: number 88 | randomizeSpeed: number 89 | randomizeLifetime: number 90 | 91 | mass: number 92 | 93 | sprite?: number 94 | 95 | inheritVelocity?: boolean 96 | 97 | speed: number 98 | speedModifier: number 99 | 100 | emitting?: RuntimeParticle[] 101 | bursts?: RuntimeBurst[] 102 | 103 | /* Emitting specific */ 104 | accumulate: number 105 | 106 | /* Internals */ 107 | remainingLifetime: number 108 | operationalLifetime: number 109 | position: Vector3 110 | velocity: Vector3 111 | parent: number | undefined 112 | } 113 | 114 | export const defaultParticle = { 115 | particle: true, 116 | 117 | size: { value: [1] }, 118 | opacity: { value: [1] }, 119 | color: { value: [1, 1, 1] }, 120 | 121 | //duration: 2, 122 | //looping: false, 123 | //prewarm: false, 124 | startDelay: 0, 125 | 126 | startLifetime: -1, 127 | startSpeed: 1, 128 | startPosition: [0, 0, 0], 129 | startRotation: [0, 0, 0], 130 | //flipRotation: false, 131 | //gravityModifier: 0, 132 | 133 | rateOverTime: 1, 134 | //rateOverDistance: 0, 135 | 136 | //velocityOverLifetime 137 | speedModifier: 0.95, 138 | 139 | randomizeLifetime: 0, 140 | randomizeSpeed: 0, 141 | randomizePosition: 0, 142 | randomizeRotation: 0, 143 | 144 | mass: 1, 145 | 146 | sprite: 0, 147 | 148 | inheritVelocity: false, 149 | 150 | /* Created for internal usage */ 151 | remainingLifetime: 1, 152 | operationalLifetime: 0, 153 | position: new Vector3(), 154 | accumulate: 0, 155 | } 156 | 157 | const evalKeys = ['size', 'color', 'opacity'] 158 | 159 | let nextParticleId = 1 160 | 161 | export const validateParticle = (entity: Particle | RuntimeParticle): RuntimeParticle => { 162 | const e = Object.assign({}, defaultParticle, entity) as RuntimeParticle 163 | 164 | e.id = nextParticleId++ 165 | 166 | evalKeys.map((key) => { 167 | const component = entity[`${key}OverLifetime` as keyof Particle] 168 | 169 | /* parse static color from array */ 170 | // @ts-ignore non-changing color 171 | if (Array.isArray(entity[key])) e[key] = { value: entity[key] } 172 | 173 | /* parse static opacity or size from number */ 174 | // @ts-ignore 175 | if (typeof entity[key] === 'number') e[key] = { value: [entity[key]] } 176 | 177 | /* parse dynamic ...OverLifetime props from KeyframeTrack */ 178 | if (component && component instanceof KeyframeTrack) { 179 | component.setInterpolation(InterpolateLinear) 180 | 181 | //@ts-ignore 182 | e[key].interpolant = component.createInterpolant() 183 | 184 | // @ts-ignore 185 | e[key].keyframes = component 186 | 187 | // @ts-ignore 188 | e[key].value = e[key].interpolant.evaluate(0) 189 | } 190 | }) 191 | 192 | if (entity.startLifetime) e.remainingLifetime = entity.startLifetime 193 | 194 | if (entity.bursts) e.bursts = entity.bursts?.map((burst) => validateBurst(burst)) 195 | 196 | if (entity.emitting) e.emitting = entity.emitting?.map((emitter: Particle) => validateParticle(emitter)) 197 | 198 | if (e.hideParticle) delete e.particle 199 | 200 | return e 201 | } 202 | -------------------------------------------------------------------------------- /src/RibbonGeometry.ts: -------------------------------------------------------------------------------- 1 | import { BufferAttribute, BufferGeometry } from 'three' 2 | import type { Archetype } from 'miniplex' 3 | import { World } from 'miniplex' 4 | 5 | export class RibbonGeometry extends BufferGeometry { 6 | isRibbonGeometry: boolean 7 | world: World 8 | archetype: Archetype 9 | 10 | positions: Float32Array 11 | previous: Float32Array 12 | next: Float32Array 13 | sides: Float32Array 14 | widths: Float32Array 15 | uvs: Float32Array 16 | counters: Float32Array 17 | indices: Uint16Array 18 | 19 | _positions: number[] 20 | _counters: number[] 21 | 22 | constructor(world: World, maxCount = 10000) { 23 | super() 24 | 25 | this.isRibbonGeometry = true 26 | 27 | this.type = 'isRibbonGeometry' 28 | 29 | this.world = world 30 | 31 | this.archetype = world.archetype('ribbon') 32 | 33 | this.setAttribute('position', new BufferAttribute(new Float32Array(maxCount * 3), 3)) 34 | this.setAttribute('previous', new BufferAttribute(new Float32Array(maxCount * 3), 3)) 35 | this.setAttribute('next', new BufferAttribute(new Float32Array(maxCount * 3), 3)) 36 | this.setAttribute('side', new BufferAttribute(new Float32Array(maxCount), 1)) 37 | this.setAttribute('width', new BufferAttribute(new Float32Array(maxCount), 1)) 38 | this.setAttribute('uv', new BufferAttribute(new Float32Array(maxCount * 2), 2)) 39 | this.setAttribute('index', new BufferAttribute(new Float32Array(maxCount), 1)) 40 | this.setAttribute('counters', new BufferAttribute(new Float32Array(maxCount), 1)) 41 | 42 | this.positions = new Float32Array(maxCount * 3) 43 | this.previous = new Float32Array(maxCount * 3) 44 | this.next = new Float32Array(maxCount * 3) 45 | this.sides = new Float32Array(maxCount) 46 | this.widths = new Float32Array(maxCount) 47 | this.uvs = new Float32Array(maxCount * 2) 48 | this.counters = new Float32Array(maxCount) 49 | this.indices = new Uint16Array(maxCount) 50 | 51 | this._positions = [] 52 | this._counters = [] 53 | } 54 | 55 | copyV3 = (a: number) => { 56 | const aa = a * 6 57 | return [this._positions[aa], this._positions[aa + 1], this._positions[aa + 2]] 58 | } 59 | 60 | compareV3 = (a: number, b: number) => { 61 | const aa = a * 6 62 | const ab = b * 6 63 | return ( 64 | this._positions[aa] === this._positions[ab] && 65 | this._positions[aa + 1] === this._positions[ab + 1] && 66 | this._positions[aa + 2] === this._positions[ab + 2] 67 | ) 68 | } 69 | 70 | update = () => { 71 | this._positions = [] 72 | this._counters = [] 73 | const _previous = [] as number[] 74 | const _next = [] as number[] 75 | const _side = [] 76 | const _width = [] 77 | const _indices_array = [] 78 | const _uvs = [] 79 | let BPI = 0 80 | let BII = 0 81 | 82 | let _sorted = [] 83 | 84 | for (let i = 0; i < this.archetype.entities.length; i++) { 85 | const e = this.archetype.entities[i] 86 | const array = _sorted[e.parent] 87 | array ? array.push(this.archetype.entities[i]) : (_sorted[e.parent] = [e]) 88 | } 89 | 90 | //normalize indices 91 | _sorted = _sorted.filter((v) => v !== undefined && v.length > 1) 92 | 93 | for (let i = 0; i < _sorted.length; i++) { 94 | const iPoints = _sorted[i] 95 | 96 | for (let j = 0; j < iPoints.length; j++) { 97 | const p = iPoints[j].position 98 | var c = j / iPoints.length 99 | this._positions.push(p.x, p.y, p.z) 100 | this._positions.push(p.x, p.y, p.z) 101 | this._counters.push(c) 102 | this._counters.push(c) 103 | } 104 | 105 | const l = iPoints.length // this._positions.current.length / 6; 106 | 107 | let w 108 | 109 | let v 110 | // initial previous points 111 | if (this.compareV3(BPI, BPI + l - 1)) { 112 | v = this.copyV3(BPI + l - 2) 113 | } else { 114 | v = this.copyV3(BPI) 115 | } 116 | 117 | _previous.push(v[0], v[1], v[2]) 118 | _previous.push(v[0], v[1], v[2]) 119 | 120 | for (let j = 0; j < l; j++) { 121 | // sides 122 | _side.push(1) 123 | _side.push(-1) 124 | 125 | // widths 126 | //if (this._widthCallback) w = this._widthCallback(j / (l - 1)) 127 | //else w = 1 128 | _width.push(iPoints[j].linewidth) 129 | _width.push(iPoints[j].linewidth) 130 | 131 | // uvs 132 | _uvs.push(j / (l - 1), 0) 133 | _uvs.push(j / (l - 1), 1) 134 | 135 | if (j < l - 1) { 136 | // points previous to positions 137 | v = this.copyV3(BPI + j) 138 | _previous.push(v[0], v[1], v[2]) 139 | _previous.push(v[0], v[1], v[2]) 140 | 141 | // indices 142 | const n = BPI * 2 + j * 2 143 | 144 | _indices_array.push(n + 0, n + 1, n + 2) 145 | _indices_array.push(n + 2, n + 1, n + 3) 146 | } 147 | if (j > 0) { 148 | // points after positions 149 | v = this.copyV3(BPI + j) 150 | _next.push(v[0], v[1], v[2]) 151 | _next.push(v[0], v[1], v[2]) 152 | } 153 | } 154 | 155 | // last next point 156 | if (this.compareV3(BPI + l - 1, BPI)) { 157 | // if last is first one 158 | v = this.copyV3(BPI + 1) 159 | } else { 160 | v = this.copyV3(BPI + l - 1) 161 | } 162 | 163 | _next.push(v[0], v[1], v[2]) 164 | _next.push(v[0], v[1], v[2]) 165 | 166 | BPI += iPoints.length 167 | BII += (iPoints.length - 1) * 2 * 3 168 | } 169 | 170 | ;(this.attributes.position as BufferAttribute).copyArray(this._positions) 171 | this.attributes.position.needsUpdate = true 172 | ;(this.attributes.counters as BufferAttribute).copyArray(this._counters) 173 | this.attributes.counters.needsUpdate = true 174 | ;(this.attributes.previous as BufferAttribute).copyArray(_previous) 175 | this.attributes.previous.needsUpdate = true 176 | ;(this.attributes.next as BufferAttribute).copyArray(_next) 177 | this.attributes.next.needsUpdate = true 178 | ;(this.attributes.side as BufferAttribute).copyArray(_side) 179 | this.attributes.side.needsUpdate = true 180 | ;(this.attributes.width as BufferAttribute).copyArray(_width) 181 | this.attributes.width.needsUpdate = true 182 | ;(this.attributes.index as BufferAttribute).copyArray([0, 2, 1, 0, 3, 2]) 183 | this.attributes.index.needsUpdate = true 184 | ;(this.attributes.uv as BufferAttribute).copyArray(_uvs) 185 | this.attributes.uv.needsUpdate = true 186 | 187 | this.setDrawRange(0, BPI * 3 * 2) 188 | //this.geometry.setIndex(new BufferAttribute(new Uint16Array([0, 1, 2, 2, 1, 3]), 1)); 189 | this.setIndex(new BufferAttribute(new Uint16Array(_indices_array), 1)) 190 | 191 | this.computeBoundingSphere() 192 | this.computeBoundingBox() 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Imgur](https://imgur.com/rqS9FVr.png) 2 | 3 | # sprudel 4 | 5 | This is meant to be a nice and flexible Particle System for three.js. In its core it utilizes `miniplex` as ECS. 6 | 7 | ## Core Concepts 8 | 9 | Every particle is represented as entity in an ECS world. It's components holds all the data needed to simulate the particle behaviour. 10 | 11 | Systems iterate over sets of particles every frame and advance their values accordingly to their behaviour. 12 | 13 | A BufferGeometry reads the values and puts them into its attributes. Vertex and fragment shader uses them to appropriate display each particle. 14 | 15 | ## Usage 16 | 17 | ### @react-three/fiber 18 | 19 | At the moment sprudel is tested in a react-three-fiber environment to avoid boilerplate. 20 | It's designed to be used in both worlds. Below the r3f examples there's a short hint on plain three.js usage. 21 | 22 | First, import `ParticleGeometry` and `ParticleMaterial` and extend them to make sure r3f will know them. 23 | 24 | ```JavaScript 25 | import {extend} from "@react-three/fiber"; 26 | import { 27 | ParticleGeometry, 28 | ParticleMaterial 29 | } from "sprudel"; 30 | 31 | extend({ParticleGeometry, ParticleMaterial}) 32 | ``` 33 | 34 | In your Component wrap them in a points object. 35 | 36 | ```JavaScript 37 | const Particles = () => { 38 | 39 | return ( 40 | 41 | 42 | 43 | 44 | ) 45 | } 46 | ``` 47 | 48 | Since particles will move and change its appearance we need to update a few things and its up to you to trigger it. 49 | You need to create the ParticleSystem and update both, the geometry and the system. 50 | 51 | ```JavaScript 52 | const Particles = () => { 53 | 54 | const ref = useRef() 55 | 56 | const particleSystem = useMemo(() => new ParticleSystem(), []) 57 | 58 | useFrame((state, delta) => { 59 | particleSystem.update(delta) 60 | ref.current.update() 61 | }); 62 | 63 | return ( 64 | 65 | 66 | 67 | 68 | ) 69 | } 70 | ``` 71 | 72 | Now you are ready to add your first particle to your scene. 73 | 74 | ```JavaScript 75 | useEffect(() => { 76 | 77 | const main = particleSystem.addParticle({ 78 | startSize: 3, 79 | emitting: [ 80 | { 81 | sprite: 1, 82 | size: 3, 83 | rateOverTime: 10, 84 | startLifetime: 2, 85 | startSpeed: 0.3, 86 | startRotation: [1, 1, 0], 87 | }, 88 | ] 89 | }); 90 | 91 | return () => particleSystem.destroyParticle(main); 92 | 93 | }, []); 94 | ``` 95 | 96 | There are a bunch of examples showing different configurations and behaviours in `/examples` 97 | 98 | ### three.js 99 | 100 | In plain three.js you just create the objects like you are used to. 101 | 102 | ```JavaScript 103 | const geo = new ParticleGeometry() 104 | const mat = new ParticleMaterial() 105 | const points = new Points(geo, mat) 106 | 107 | scene.add(points) 108 | ``` 109 | 110 | You'd call geo.update() and the systems in your render loop and create the particle wherever you want. 111 | 112 | ## Properties 113 | 114 | Your Particle System is defined by a data structure. 115 | 116 | ### Emitting 117 | 118 | Each particle you create or emit can be an emitter too. Emitting can be done by a rate over time, defining how many particles shall be emitted in a second. 119 | Props in an emitting object describe the particles that will be emitted. 120 | 121 | ```JavaScript 122 | { 123 | emitting: [ 124 | { 125 | rateOverTime: 10, 126 | startLifetime: 1, 127 | size: 4, 128 | // ... 129 | }, 130 | { 131 | rateOverTime: 30, 132 | startLifetime: .5, 133 | color: [1, 0, 0], 134 | // ... 135 | } 136 | ] 137 | } 138 | ``` 139 | 140 | Or you create bursts which emit an amount of particles at the same time. By setting rateOverTime to zero, particles are only emitted by the burst. 141 | 142 | You can have multiple emitters and bursts on the same particle. 143 | 144 | ```JavaScript 145 | { 146 | emitting: [ 147 | { 148 | // ... 149 | rateOverTime: 0, 150 | startLifetime: 2, 151 | bursts: [ 152 | { 153 | count: 80, 154 | cycleCount: -1, 155 | repeatInterval: 1, 156 | time: 0, 157 | } 158 | ], 159 | } 160 | ] 161 | } 162 | ``` 163 | 164 | ### Appearance 165 | 166 | Visuals can be static or change over lifetime. It's simple like that. 167 | 168 | ```JavaScript 169 | const particle = { 170 | color: [1, 1, 1], 171 | size: 1, 172 | opacity: 1, 173 | // ... 174 | } 175 | ``` 176 | 177 | To change appearance over lifetime, assign a …OverLifetime prop. We use the three.js KeyframeTracks. 178 | 179 | ```JavaScript 180 | const particle = { 181 | sizeOverLifetime: new NumberKeyframeTrack('Glowing Smoke Size', [0, 1], [3, 7]), 182 | opacityOverLifetime: new NumberKeyframeTrack('Glowing Smoke Opacity', [0, .6, 1], [.3, .2, 0]), 183 | colorOverLifetime: new ColorKeyframeTrack('Glowing Smoke Color', [0, .7], [.6, 0, 2, 0, 0, 0]), 184 | // ... 185 | } 186 | ``` 187 | 188 | ### Behaviour 189 | 190 | There are several props influencing particle and emitting behaviour. 191 | 192 | | Prop | Description | 193 | | ------------------- | --------------------------------------- | 194 | | `startDelay` | time to wait until animation starts | 195 | | `startLifetime` | initial lifetime (maximum age) | 196 | | `startSpeed` | initial speed | 197 | | `startPosition` | initial position | 198 | | `startRotation` | initial rotation | 199 | | `randomizeLifetime` | randomize initial lifetime | 200 | | `randomizeSpeed` | randomize initial speed | 201 | | `randomizePosition` | randomize initial position | 202 | | `randomizeRotation` | randomize initial rotation | 203 | | `speedModifier` | factor to change speed every frame | 204 | | `mass` | factor to change y-position every frame | 205 | 206 | ## Performance 207 | 208 | By putting all the data into one BufferGeometry it takes only one drawcall disregarding how many particles you display. 209 | 210 | Theres a spritesheet option to put all textures into one file and let each particle use another image. 211 | 212 | RibbonRenderer isn't optimized at all. 213 | 214 | There's the plan to built in an object pool or ring buffer to reuse entities instead of creating and deleting them over and over again. 215 | 216 | ## Examples 217 | 218 | Build the package `npm run build`. Go to `/examples`, install the packages and run `npm run dev` to show them in your browser. 219 | 220 | ## Changelog 221 | 222 | ### v0.0.5 31-3-22 223 | 224 | - Hotfix: Instant emit burst without waiting one interval 225 | 226 | ### v0.0.4 31-3-22 227 | 228 | - Clean build and publish 229 | 230 | ### v0.0.3 31-3-22 231 | 232 | - Refactored RibbonRenderer to plain three.js 233 | - Removed all react dependencies from src 234 | 235 | ### v0.0.2 31-3-22 236 | 237 | - Separated systems into concerns. Color, size and opacity are handled individually now. 238 | - Allow multiple instances by creating a new world along with a new ParticleSystem 239 | - Entity creation moved inside ParticleSystem and aliased with addParticle and destroyParticle 240 | - Changed blending to Blend Add 241 | - Renamed randomizeDirection to randomizeRotation 242 | - Added a lot of documentation 243 | - Polished examples 244 | 245 | ### v0.0.1 28-3-22 246 | 247 | - Refactored particle rendering to plain three.js geometry and material 248 | - Added a lot of documentation 249 | 250 | ### v0.0.0 24-3-22 251 | 252 | - Initial setup 253 | --------------------------------------------------------------------------------