├── 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 |
51 | )
52 | }
53 |
54 | export default Simple
55 |
--------------------------------------------------------------------------------
/examples/src/favicon.svg:
--------------------------------------------------------------------------------
1 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 | 
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 |
--------------------------------------------------------------------------------