├── .nvmrc ├── .husky ├── pre-commit └── commit-msg ├── babel.config.json ├── assets └── images │ └── logo-colorful.png ├── .prettierrc ├── .gitattributes ├── typedoc.json ├── .npmignore ├── commitlint.config.cjs ├── .gitignore ├── src ├── js │ └── effects │ │ └── three-particles │ │ ├── index.ts │ │ ├── three-particles-enums.ts │ │ ├── shaders │ │ ├── particle-system-vertex-shader.glsl.ts │ │ └── particle-system-fragment-shader.glsl.ts │ │ ├── three-particles-bezier.ts │ │ ├── three-particles-curves.ts │ │ ├── three-particles-modifiers.ts │ │ ├── three-particles-utils.ts │ │ ├── types.ts │ │ └── three-particles.ts ├── index.ts ├── types │ ├── three-examples.d.ts │ ├── newkrok__three-utils.d.ts │ ├── easing-functions.d.ts │ └── three-noise.d.ts └── __tests__ │ ├── three-particles.test.ts │ ├── three-particles-curves.test.ts │ ├── three-particles-bezier.test.ts │ ├── three-particles-utils.test.ts │ └── three-particles-modifiers.test.ts ├── .github └── workflows │ ├── test.yml │ ├── lint.yml │ ├── codeql-analysis.yml │ ├── circular-dependencies.yml │ ├── bundle-size-check.yml │ ├── typedoc.yml │ └── publish-npm.yml ├── jest.config.js ├── tsconfig.json ├── LICENSE ├── webpack.config.js ├── eslint.config.mjs ├── package.json ├── .windsurfrules └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm test 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit "${1}" 2 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /assets/images/logo-colorful.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NewKrok/three-particles/HEAD/assets/images/logo-colorful.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "es5" 6 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.{cmd,[cC][mM][dD]} text eol=crlf 3 | *.{bat,[bB][aA][tT]} text eol=crlf 4 | *.{ps1,[pP][sS]1} text eol=crlf 5 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["src/js/effects/three-particles/three-particles.ts"], 3 | "out": "docs", 4 | "excludePrivate": true, 5 | "includeVersion": true 6 | } 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/__tests__/ 2 | *.test.ts 3 | 4 | coverage/ 5 | *.log 6 | 7 | .vscode/ 8 | .idea/ 9 | .jest/ 10 | .jest-cache/ 11 | .jest-test-results.json 12 | 13 | jest.config.ts 14 | babel.config.json 15 | 16 | docs/ -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | // commitlint.config.js 2 | module.exports = { 3 | extends: ['@commitlint/config-conventional'], 4 | rules: { 5 | 'header-min-length': [2, 'always', 10], // level: error, applicable: always, value: 10 6 | }, 7 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | 3 | .vscode/ 4 | .idea/ 5 | *.swp 6 | 7 | dist/ 8 | node_modules/ 9 | build/ 10 | out/ 11 | docs/ 12 | 13 | *.log 14 | 15 | .jest/ 16 | .jest-cache/ 17 | .jest-test-results.json 18 | 19 | .prettier-cache/ 20 | .eslintcache 21 | 22 | tmp/ 23 | -------------------------------------------------------------------------------- /src/js/effects/three-particles/index.ts: -------------------------------------------------------------------------------- 1 | export * from './three-particles-bezier.js'; 2 | export * from './three-particles-curves.js'; 3 | export * from './three-particles-enums.js'; 4 | export * from './three-particles-modifiers.js'; 5 | export * from './three-particles-utils.js'; 6 | export * from './three-particles.js'; 7 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: '18' 19 | - run: npm ci 20 | - run: npm run test 21 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './js/effects/three-particles/three-particles-bezier.js'; 2 | export * from './js/effects/three-particles/three-particles-curves.js'; 3 | export * from './js/effects/three-particles/three-particles-enums.js'; 4 | export * from './js/effects/three-particles/three-particles-modifiers.js'; 5 | export * from './js/effects/three-particles/three-particles-utils.js'; 6 | export * from './js/effects/three-particles/three-particles.js'; 7 | -------------------------------------------------------------------------------- /src/types/three-examples.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'three/examples/jsm/misc/Gyroscope.js' { 2 | import { Object3D } from 'three'; 3 | 4 | /** 5 | * Gyroscope class 6 | * Extends Object3D to provide gyroscopic behavior. 7 | */ 8 | export class Gyroscope extends Object3D { 9 | constructor(); 10 | 11 | /** 12 | * Updates the gyroscope's transformation relative to its parent. 13 | */ 14 | updateMatrixWorld(force?: boolean): void; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/__tests__/three-particles.test.ts: -------------------------------------------------------------------------------- 1 | import { updateParticleSystems } from '../js/effects/three-particles/three-particles.js'; 2 | 3 | describe('updateParticleSystems', () => { 4 | it('should not throw error when called with valid parameters', () => { 5 | const mockCycleData = { 6 | now: 1000, 7 | delta: 16, 8 | elapsed: 1000, 9 | }; 10 | 11 | expect(() => { 12 | updateParticleSystems(mockCycleData); 13 | }).not.toThrow(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint Testing 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: 18 18 | - name: Install dependencies 19 | run: npm ci 20 | - name: Run ESLint 21 | run: npm run lint 22 | -------------------------------------------------------------------------------- /src/types/newkrok__three-utils.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@newkrok/three-utils' { 2 | export namespace ObjectUtils { 3 | export function deepMerge( 4 | obj1: T, 5 | obj2: U, 6 | config: object 7 | ): T & U; 8 | 9 | export function clone(obj: T): T; 10 | 11 | export function isEmpty(obj: object): boolean; 12 | 13 | export function patchObject( 14 | target: T, 15 | patch: Partial 16 | ): T; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL Analysis 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | analyze: 10 | name: Analyze 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | - name: Initialize CodeQL 16 | uses: github/codeql-action/init@v3 17 | with: 18 | languages: javascript 19 | - name: Perform CodeQL Analysis 20 | uses: github/codeql-action/analyze@v3 21 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: 'ts-jest/presets/default-esm', 3 | testEnvironment: 'node', 4 | extensionsToTreatAsEsm: ['.ts'], 5 | transform: { 6 | '^.+\\.m?[tj]sx?$': 'babel-jest', 7 | }, 8 | moduleNameMapper: { 9 | '^(.+)\\.js$': '$1', 10 | }, 11 | transformIgnorePatterns: ['node_modules/(?!three-noise|three|@newkrok/three-utils)'], 12 | roots: ['/src'], 13 | moduleFileExtensions: ['ts', 'js'], 14 | testMatch: ['**/__tests__/**/*.test.ts'], 15 | collectCoverage: true, 16 | coverageReporters: ['text', 'lcov'], 17 | maxWorkers: '50%', 18 | }; 19 | -------------------------------------------------------------------------------- /src/js/effects/three-particles/three-particles-enums.ts: -------------------------------------------------------------------------------- 1 | export const enum SimulationSpace { 2 | LOCAL = 'LOCAL', 3 | WORLD = 'WORLD', 4 | } 5 | 6 | export const enum Shape { 7 | SPHERE = 'SPHERE', 8 | CONE = 'CONE', 9 | BOX = 'BOX', 10 | CIRCLE = 'CIRCLE', 11 | RECTANGLE = 'RECTANGLE', 12 | } 13 | 14 | export const enum EmitFrom { 15 | VOLUME = 'VOLUME', 16 | SHELL = 'SHELL', 17 | EDGE = 'EDGE', 18 | } 19 | 20 | export const enum TimeMode { 21 | LIFETIME = 'LIFETIME', 22 | FPS = 'FPS', 23 | } 24 | 25 | export const enum LifeTimeCurve { 26 | BEZIER = 'BEZIER', 27 | EASING = 'EASING', 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "moduleResolution": "node", 5 | "target": "ES2020", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "emitDeclarationOnly": false, 9 | "outDir": "./dist", 10 | "rootDir": "./src", 11 | "strict": true, 12 | "esModuleInterop": true, 13 | "skipLibCheck": true, 14 | "baseUrl": "./", 15 | "paths": { 16 | "@newkrok/three-particles": ["./src/index.ts"] 17 | } 18 | }, 19 | "sourceMap": true, 20 | "include": ["src/**/*"], 21 | "exclude": ["node_modules", "dist", "src/__tests__"] 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/circular-dependencies.yml: -------------------------------------------------------------------------------- 1 | name: Circular Dependencies Testing 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | check-circular: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | actions: read 13 | contents: read 14 | security-events: write 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 18 23 | - name: Install dependencies 24 | run: npm ci 25 | - name: Check circular dependencies 26 | run: npx madge --circular src 27 | -------------------------------------------------------------------------------- /src/types/easing-functions.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'easing-functions' { 2 | /** 3 | * Easing function signature 4 | * @param t - The time or progress value (usually between 0 and 1). 5 | * @returns The eased value (usually between 0 and 1). 6 | */ 7 | export type EasingFunction = (t: number) => number; 8 | 9 | interface EasingType { 10 | None: EasingFunction; 11 | In: EasingFunction; 12 | Out: EasingFunction; 13 | InOut: EasingFunction; 14 | } 15 | 16 | const Easing: { 17 | Linear: EasingType; 18 | Quadratic: EasingType; 19 | Cubic: EasingType; 20 | Quartic: EasingType; 21 | Quintic: EasingType; 22 | Sinusoidal: EasingType; 23 | Exponential: EasingType; 24 | Circular: EasingType; 25 | Back: EasingType; 26 | Elastic: EasingType; 27 | Bounce: EasingType; 28 | }; 29 | 30 | export default Easing; 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/bundle-size-check.yml: -------------------------------------------------------------------------------- 1 | name: Bundle Size Check 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | analyze-bundle: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '18' 20 | 21 | - name: Install dependencies 22 | run: npm ci 23 | 24 | - name: Build project 25 | run: npm run build 26 | 27 | - name: Generate bundle report 28 | run: | 29 | npm run build 30 | cat dist/bundle-report.json 31 | 32 | - name: Post bundle size comment 33 | uses: marocchino/sticky-pull-request-comment@v2 34 | with: 35 | token: ${{ secrets.GITHUB_TOKEN }} 36 | path: dist/bundle-report.json 37 | -------------------------------------------------------------------------------- /.github/workflows/typedoc.yml: -------------------------------------------------------------------------------- 1 | name: Generate and Deploy Typedoc 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Check out repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 18 23 | 24 | - name: Install dependencies 25 | run: npm ci 26 | 27 | - name: Generate Typedoc documentation 28 | run: npx typedoc 29 | 30 | - name: Create docs directory if not exists 31 | run: mkdir -p docs 32 | 33 | - name: Disable Jekyll 34 | run: echo "" > docs/.nojekyll 35 | 36 | - name: Deploy to GitHub Pages 37 | uses: peaceiris/actions-gh-pages@v3 38 | with: 39 | github_token: ${{ secrets.GITHUB_TOKEN }} 40 | publish_dir: ./docs 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Istvan Krisztian Somoracz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/publish-npm.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npm 2 | 3 | on: 4 | release: 5 | types: [published] # Trigger only when a release is published 6 | 7 | jobs: 8 | build-and-publish: 9 | runs-on: ubuntu-latest # Use the latest Ubuntu runner 10 | permissions: 11 | contents: read # Allow reading repository content 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 # Action to check out the repository code 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '18' # Specify your desired Node.js version 20 | registry-url: 'https://registry.npmjs.org/' # Point to the npm registry 21 | 22 | - name: Install dependencies 23 | run: npm ci # Use 'ci' for clean installs in CI 24 | 25 | - name: Build package 26 | run: npm run build # Run your build script 27 | 28 | - name: Publish to npm 29 | run: npm publish --access public # Publish with public access for scoped packages 30 | env: 31 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # Use the secret token for authentication 32 | -------------------------------------------------------------------------------- /src/js/effects/three-particles/shaders/particle-system-vertex-shader.glsl.ts: -------------------------------------------------------------------------------- 1 | const ParticleSystemVertexShader = ` 2 | attribute float size; 3 | attribute float colorR; 4 | attribute float colorG; 5 | attribute float colorB; 6 | attribute float colorA; 7 | attribute float lifetime; 8 | attribute float startLifetime; 9 | attribute float rotation; 10 | attribute float startFrame; 11 | 12 | varying mat4 vPosition; 13 | varying vec4 vColor; 14 | varying float vLifetime; 15 | varying float vStartLifetime; 16 | varying float vRotation; 17 | varying float vStartFrame; 18 | 19 | #include 20 | #include 21 | 22 | void main() 23 | { 24 | vColor = vec4(colorR, colorG, colorB, colorA); 25 | vLifetime = lifetime; 26 | vStartLifetime = startLifetime; 27 | vRotation = rotation; 28 | vStartFrame = startFrame; 29 | 30 | vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); 31 | gl_PointSize = size * (100.0 / length(mvPosition.xyz)); 32 | gl_Position = projectionMatrix * mvPosition; 33 | 34 | #include 35 | } 36 | `; 37 | 38 | export default ParticleSystemVertexShader; 39 | -------------------------------------------------------------------------------- /src/__tests__/three-particles-curves.test.ts: -------------------------------------------------------------------------------- 1 | import Easing from 'easing-functions'; 2 | import { 3 | CurveFunctionId, 4 | getCurveFunction, 5 | } from '../js/effects/three-particles/three-particles-curves.js'; 6 | 7 | describe('getCurveFunction', () => { 8 | it('should return the correct easing function for LINEAR', () => { 9 | const func = getCurveFunction(CurveFunctionId.LINEAR); 10 | expect(func).toBe(Easing.Linear.None); 11 | }); 12 | 13 | it('should return the original function if passed directly', () => { 14 | const customFunc = (t: number) => t * t; 15 | const func = getCurveFunction(customFunc); 16 | expect(func).toBe(customFunc); 17 | }); 18 | 19 | it('should return undefined for an invalid param', () => { 20 | const funcInvalidString = getCurveFunction('INVALID_ID' as any); 21 | expect(funcInvalidString).toBeUndefined(); 22 | 23 | const funcObject = getCurveFunction({} as any); 24 | expect(funcObject).toBeUndefined(); 25 | 26 | const funcNumber = getCurveFunction(1 as any); 27 | expect(funcNumber).toBeUndefined(); 28 | 29 | const funcBool = getCurveFunction(true as any); 30 | expect(funcBool).toBeUndefined(); 31 | 32 | const funcArr = getCurveFunction([1, 2, 3] as any); 33 | expect(funcArr).toBeUndefined(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import TerserPlugin from 'terser-webpack-plugin'; 3 | import { fileURLToPath } from 'url'; 4 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = path.dirname(__filename); 8 | 9 | export default { 10 | entry: './dist/index.js', 11 | output: { 12 | path: path.resolve(__dirname, 'dist'), 13 | filename: 'three-particles.min.js', 14 | module: true, 15 | library: { 16 | type: 'module', 17 | }, 18 | }, 19 | experiments: { 20 | outputModule: true, 21 | }, 22 | mode: 'production', 23 | resolve: { 24 | extensions: ['.ts', '.js'], 25 | mainFields: ['module', 'main'] 26 | }, 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.ts$/, 31 | use: 'ts-loader', 32 | exclude: /node_modules/, 33 | }, 34 | ], 35 | }, 36 | optimization: { 37 | usedExports: true, 38 | minimize: true, 39 | minimizer: [ 40 | new TerserPlugin({ 41 | terserOptions: { 42 | compress: { 43 | drop_console: true, 44 | }, 45 | format: { 46 | comments: false, 47 | }, 48 | }, 49 | }), 50 | ], 51 | }, 52 | plugins: [ 53 | new BundleAnalyzerPlugin({ 54 | analyzerMode: 'json', 55 | reportFilename: 'bundle-report.json', 56 | openAnalyzer: false, 57 | sourceType: 'module', 58 | }), 59 | ], 60 | externals: { 61 | three: 'THREE' 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptPlugin from '@typescript-eslint/eslint-plugin'; 2 | import typescriptParser from '@typescript-eslint/parser'; 3 | import importPlugin from 'eslint-plugin-import'; 4 | import prettierPlugin from 'eslint-plugin-prettier'; 5 | 6 | export default [ 7 | { 8 | files: ['**/*.ts'], 9 | languageOptions: { 10 | parser: typescriptParser, 11 | }, 12 | plugins: { 13 | '@typescript-eslint': typescriptPlugin, 14 | import: importPlugin, 15 | prettier: prettierPlugin, 16 | }, 17 | rules: { 18 | // General ESLint rules 19 | 'no-console': 'warn', // Warn on console usage 20 | 'no-debugger': 'error', // Disallow debugger 21 | 'eqeqeq': ['error', 'always'], // Enforce strict equality 22 | 23 | // TypeScript-specific rules 24 | '@typescript-eslint/no-explicit-any': 'warn', // Warn against using `any` type 25 | '@typescript-eslint/no-empty-function': 'off', // Allow empty functions 26 | 27 | // Import plugin rules 28 | 'import/order': [ 29 | 'error', 30 | { 31 | groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'], 32 | alphabetize: { order: 'asc', caseInsensitive: true }, 33 | }, 34 | ], 35 | // Temporary disabled 36 | 'import/no-unresolved': 'off', // Ensure imports can be resolved 37 | 'import/newline-after-import': 'error', // Enforce newline after import statements 38 | 'prettier/prettier': ['error', { 39 | endOfLine: 'lf' // Use LF line endings consistently 40 | }], // Ensure code is formatted according to Prettier rules 41 | }, 42 | }, 43 | { 44 | files: ['**/*.test.ts', '**/test/**/*.ts'], 45 | rules: { 46 | '@typescript-eslint/no-explicit-any': 'off', // Disable `any` type rule for tests 47 | }, 48 | }, 49 | ]; -------------------------------------------------------------------------------- /src/types/three-noise.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'three-noise/build/three-noise.module.js' { 2 | import { Vector2, Vector3 } from 'three'; 3 | 4 | export class FBM { 5 | /** 6 | * Create an instance of the FBM class. 7 | * Use this instance to generate fBm noise. 8 | * 9 | * @param options - Options for fBm generation. 10 | * @param options.seed - Seed for the noise generation. 11 | * @param options.scale - What distance to view the noise map (controls the "zoom level"). 12 | * @param options.persistence - How much each octave contributes to the overall shape. 13 | * @param options.lacunarity - How much detail is added or removed at each octave. 14 | * @param options.octaves - Number of noise octaves. 15 | * @param options.redistribution - Redistribution value to control flatness. 16 | */ 17 | constructor(options?: { 18 | seed?: number; 19 | scale?: number; 20 | persistence?: number; 21 | lacunarity?: number; 22 | octaves?: number; 23 | redistribution?: number; 24 | }); 25 | 26 | /** 27 | * Generates a 2D noise value at the given position. 28 | * @param position - A 2D vector (x, y) position. 29 | */ 30 | get2(position: Vector2): number; 31 | 32 | /** 33 | * Generates a 3D noise value at the given position. 34 | * @param position - A 3D vector (x, y, z) position. 35 | */ 36 | get3(position: Vector3): number; 37 | } 38 | 39 | export class Perlin { 40 | /** 41 | * Create an instance of the Perlin class. 42 | * Use this instance to generate Perlin noise. 43 | * 44 | * @param seed - Seed for the noise generation. 45 | */ 46 | constructor(seed?: number); 47 | 48 | /** 49 | * Generates a 2D Perlin noise value at the given position. 50 | * @param position - A 2D vector (x, y) position. 51 | */ 52 | get2(position: Vector2): number; 53 | 54 | /** 55 | * Generates a 3D Perlin noise value at the given position. 56 | * @param position - A 3D vector (x, y, z) position. 57 | */ 58 | get3(position: Vector3): number; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/js/effects/three-particles/shaders/particle-system-fragment-shader.glsl.ts: -------------------------------------------------------------------------------- 1 | const ParticleSystemFragmentShader = ` 2 | uniform sampler2D map; 3 | uniform float elapsed; 4 | uniform float fps; 5 | uniform bool useFPSForFrameIndex; 6 | uniform vec2 tiles; 7 | uniform bool discardBackgroundColor; 8 | uniform vec3 backgroundColor; 9 | uniform float backgroundColorTolerance; 10 | 11 | varying vec4 vColor; 12 | varying float vLifetime; 13 | varying float vStartLifetime; 14 | varying float vRotation; 15 | varying float vStartFrame; 16 | 17 | #include 18 | #include 19 | 20 | void main() 21 | { 22 | gl_FragColor = vColor; 23 | float mid = 0.5; 24 | 25 | float frameIndex = round(vStartFrame) + ( 26 | useFPSForFrameIndex == true 27 | ? fps == 0.0 28 | ? 0.0 29 | : max((vLifetime / 1000.0) * fps, 0.0) 30 | : max(min(floor(min(vLifetime / vStartLifetime, 1.0) * (tiles.x * tiles.y)), tiles.x * tiles.y - 1.0), 0.0) 31 | ); 32 | 33 | float spriteXIndex = floor(mod(frameIndex, tiles.x)); 34 | float spriteYIndex = floor(mod(frameIndex / tiles.x, tiles.y)); 35 | 36 | vec2 frameUV = vec2( 37 | gl_PointCoord.x / tiles.x + spriteXIndex / tiles.x, 38 | gl_PointCoord.y / tiles.y + spriteYIndex / tiles.y); 39 | 40 | vec2 center = vec2(0.5, 0.5); 41 | vec2 centeredPoint = gl_PointCoord - center; 42 | 43 | mat2 rotation = mat2( 44 | cos(vRotation), sin(vRotation), 45 | -sin(vRotation), cos(vRotation) 46 | ); 47 | 48 | centeredPoint = rotation * centeredPoint; 49 | vec2 centeredMiddlePoint = vec2( 50 | centeredPoint.x + center.x, 51 | centeredPoint.y + center.y 52 | ); 53 | 54 | float dist = distance(centeredMiddlePoint, center); 55 | if (dist > 0.5) discard; 56 | 57 | vec2 uvPoint = vec2( 58 | centeredMiddlePoint.x / tiles.x + spriteXIndex / tiles.x, 59 | centeredMiddlePoint.y / tiles.y + spriteYIndex / tiles.y 60 | ); 61 | 62 | vec4 rotatedTexture = texture2D(map, uvPoint); 63 | 64 | gl_FragColor = gl_FragColor * rotatedTexture; 65 | 66 | if (discardBackgroundColor && abs(length(rotatedTexture.rgb - backgroundColor.rgb)) < backgroundColorTolerance) discard; 67 | 68 | #include 69 | } 70 | `; 71 | 72 | export default ParticleSystemFragmentShader; 73 | -------------------------------------------------------------------------------- /src/js/effects/three-particles/three-particles-bezier.ts: -------------------------------------------------------------------------------- 1 | import { BezierPoint, CurveFunction } from './types.js'; 2 | 3 | const cache: Array<{ 4 | bezierPoints: Array; 5 | curveFunction: CurveFunction; 6 | referencedBy: Array; 7 | }> = []; 8 | 9 | const nCr = (n: number, k: number) => { 10 | let z = 1; 11 | for (let i = 1; i <= k; i++) z *= (n + 1 - i) / i; 12 | return z; 13 | }; 14 | 15 | export const createBezierCurveFunction = ( 16 | particleSystemId: number, 17 | bezierPoints: Array 18 | ) => { 19 | const cacheEntry = cache.find((item) => item.bezierPoints === bezierPoints); 20 | 21 | if (cacheEntry) { 22 | if (!cacheEntry.referencedBy.includes(particleSystemId)) 23 | cacheEntry.referencedBy.push(particleSystemId); 24 | return cacheEntry.curveFunction; 25 | } 26 | 27 | const entry = { 28 | referencedBy: [particleSystemId], 29 | bezierPoints, 30 | curveFunction: (percentage: number): number => { 31 | if (percentage < 0) return bezierPoints[0].y; 32 | if (percentage > 1) return bezierPoints[bezierPoints.length - 1].y; 33 | 34 | let start = 0; 35 | let stop = bezierPoints.length - 1; 36 | 37 | bezierPoints.find((point, index) => { 38 | const result = percentage < (point.percentage ?? 0); 39 | if (result) stop = index; 40 | else if (point.percentage !== undefined) start = index; 41 | return result; 42 | }); 43 | 44 | const n = stop - start; 45 | const calculatedPercentage = 46 | (percentage - (bezierPoints[start].percentage ?? 0)) / 47 | ((bezierPoints[stop].percentage ?? 1) - 48 | (bezierPoints[start].percentage ?? 0)); 49 | 50 | let value = 0; 51 | for (let i = 0; i <= n; i++) { 52 | const p = bezierPoints[start + i]; 53 | const c = 54 | nCr(n, i) * 55 | Math.pow(1 - calculatedPercentage, n - i) * 56 | Math.pow(calculatedPercentage, i); 57 | value += c * p.y; 58 | } 59 | return value; 60 | }, 61 | }; 62 | 63 | cache.push(entry); 64 | return entry.curveFunction; 65 | }; 66 | 67 | export const removeBezierCurveFunction = (particleSystemId: number) => { 68 | while (true) { 69 | const index = cache.findIndex((item) => 70 | item.referencedBy.includes(particleSystemId) 71 | ); 72 | if (index === -1) break; 73 | const entry = cache[index]; 74 | entry.referencedBy = entry.referencedBy.filter( 75 | (id) => id !== particleSystemId 76 | ); 77 | if (entry.referencedBy.length === 0) cache.splice(index, 1); 78 | } 79 | }; 80 | 81 | export const getBezierCacheSize = () => cache.length; 82 | -------------------------------------------------------------------------------- /src/__tests__/three-particles-bezier.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createBezierCurveFunction, 3 | getBezierCacheSize, 4 | removeBezierCurveFunction, 5 | } from '../js/effects/three-particles/three-particles-bezier.js'; 6 | 7 | describe('createBezierCurveFunction function tests', () => { 8 | it('should return the same curve function for the same bezierPoints array', () => { 9 | const bezierPoints = [ 10 | { x: 0, y: 0 }, 11 | { x: 1, y: 1 }, 12 | ]; 13 | const curveFunc1 = createBezierCurveFunction(1, bezierPoints); 14 | const curveFunc2 = createBezierCurveFunction(2, bezierPoints); 15 | expect(curveFunc1).toBe(curveFunc2); 16 | 17 | removeBezierCurveFunction(1); 18 | removeBezierCurveFunction(2); 19 | }); 20 | 21 | it('should return the y value of the first point at 0% and the last point at 100%', () => { 22 | const bezierPoints = [ 23 | { x: 0, y: 0 }, 24 | { x: 1, y: 1 }, 25 | ]; 26 | const curveFunc = createBezierCurveFunction(1, bezierPoints); 27 | expect(curveFunc(0)).toBe(0); 28 | expect(curveFunc(1)).toBe(1); 29 | 30 | removeBezierCurveFunction(1); 31 | }); 32 | 33 | it('should return interpolated values between points', () => { 34 | const bezierPoints = [ 35 | { x: 0, y: 0 }, 36 | { x: 0.5, y: 0.5 }, 37 | { x: 1, y: 1 }, 38 | ]; 39 | const curveFunc = createBezierCurveFunction(1, bezierPoints); 40 | expect(curveFunc(0.5)).toBeCloseTo(0.5); 41 | 42 | removeBezierCurveFunction(1); 43 | }); 44 | 45 | it('should remove the curve function from cache', () => { 46 | const bezierPoints = [ 47 | { x: 0, y: 0 }, 48 | { x: 1, y: 1 }, 49 | ]; 50 | createBezierCurveFunction(1, bezierPoints); 51 | removeBezierCurveFunction(1); 52 | expect(getBezierCacheSize()).toBe(0); 53 | }); 54 | 55 | it('should not increase cache size for identical bezierPoints arrays', () => { 56 | const bezierPoints = [ 57 | { x: 0, y: 0 }, 58 | { x: 0.5, y: 0.5 }, 59 | { x: 1, y: 1 }, 60 | ]; 61 | 62 | createBezierCurveFunction(1, bezierPoints); 63 | createBezierCurveFunction(2, bezierPoints); 64 | 65 | expect(getBezierCacheSize()).toBe(1); 66 | 67 | removeBezierCurveFunction(2); 68 | expect(getBezierCacheSize()).toBe(1); 69 | 70 | removeBezierCurveFunction(1); 71 | expect(getBezierCacheSize()).toBe(0); 72 | }); 73 | 74 | it('should clamp the percentage to 0% and 100%', () => { 75 | const bezierPoints = [ 76 | { x: 0, y: 0 }, 77 | { x: 1, y: 1 }, 78 | ]; 79 | const curveFunc = createBezierCurveFunction(1, bezierPoints); 80 | expect(curveFunc(-0.1)).toBe(0); 81 | expect(curveFunc(1.1)).toBe(1); 82 | 83 | removeBezierCurveFunction(1); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@newkrok/three-particles", 3 | "version": "2.2.0", 4 | "type": "module", 5 | "description": "Three.js-based high-performance particle system library designed for creating visually stunning particle effects with ease. Perfect for game developers and 3D applications.", 6 | "main": "./dist/index.js", 7 | "module": "./dist/index.js", 8 | "types": "./dist/index.d.ts", 9 | "files": [ 10 | "dist/", 11 | "README.md", 12 | "LICENSE" 13 | ], 14 | "exports": { 15 | ".": { 16 | "import": "./dist/index.js", 17 | "require": "./dist/index.js" 18 | } 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/NewKrok/three-particles.git" 23 | }, 24 | "keywords": [ 25 | "three", 26 | "three.js", 27 | "particles", 28 | "particle system", 29 | "webgl", 30 | "3d", 31 | "visual effects", 32 | "game development", 33 | "3d applications", 34 | "high performance", 35 | "javascript", 36 | "typescript", 37 | "three-particles", 38 | "threejs effects" 39 | ], 40 | "author": "Istvan Krisztian Somoracz", 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/NewKrok/three-particles/issues" 44 | }, 45 | "homepage": "https://github.com/NewKrok/three-particles#readme", 46 | "engines": { 47 | "node": ">=18.0.0" 48 | }, 49 | "scripts": { 50 | "build": "rimraf dist && tsc && webpack --config webpack.config.js", 51 | "prepublishOnly": "npm run build", 52 | "test": "jest", 53 | "test:watch": "jest --watch", 54 | "lint": "eslint src", 55 | "prepare": "husky" 56 | }, 57 | "dependencies": { 58 | "@newkrok/three-utils": "^2.0.1", 59 | "easing-functions": "1.3.0", 60 | "three-noise": "1.1.2" 61 | }, 62 | "peerDependencies": { 63 | "three": "^0.180.0" 64 | }, 65 | "devDependencies": { 66 | "@babel/preset-env": "^7.26.0", 67 | "@babel/preset-typescript": "^7.26.0", 68 | "@commitlint/cli": "^20.1.0", 69 | "@commitlint/config-conventional": "^20.0.0", 70 | "@types/jest": "^30.0.0", 71 | "@types/node": "^24.8.1", 72 | "@types/three": "^0.180.0", 73 | "@typescript-eslint/eslint-plugin": "^8.21.0", 74 | "@typescript-eslint/parser": "^8.21.0", 75 | "babel-jest": "^30.2.0", 76 | "eslint": "^9.18.0", 77 | "eslint-config-prettier": "^10.0.1", 78 | "eslint-plugin-import": "^2.31.0", 79 | "eslint-plugin-prettier": "^5.2.3", 80 | "husky": "^9.1.7", 81 | "jest": "^30.2.0", 82 | "madge": "^8.0.0", 83 | "prettier": "^3.4.2", 84 | "rimraf": "^6.0.1", 85 | "terser-webpack-plugin": "^5.3.11", 86 | "ts-jest": "^29.2.5", 87 | "ts-loader": "^9.5.2", 88 | "ts-node": "^10.9.2", 89 | "typedoc": "^0.28.1", 90 | "typescript": "^5.7.3", 91 | "webpack": "^5.97.1", 92 | "webpack-bundle-analyzer": "^4.10.2", 93 | "webpack-cli": "^6.0.1" 94 | } 95 | } -------------------------------------------------------------------------------- /.windsurfrules: -------------------------------------------------------------------------------- 1 | # Three Particles – Development Rules 2 | 3 | ## General Guidelines 4 | - The project uses the latest version of the `three` npm package. 5 | - Use **TypeScript** for all code, utilizing `type` definitions (avoid `interface` and `class` unless necessary). 6 | - Prefer **TypeScript types** over JSDoc for internal documentation. 7 | - Use **English** for all code and documentation. 8 | - All variables and functions must have explicitly declared types. 9 | - Avoid using `any`. If unavoidable, document the reason. 10 | - Create and reuse proper composite types instead of repeating structures. 11 | - Use **JSDoc** for documenting public classes and methods. 12 | - Use **ESLint** and **Prettier** for code formatting. 13 | - Use **Jest** for unit testing. 14 | - Always create unit tests for **utility functions**. 15 | 16 | ## Code Conventions 17 | - File extensions: `.ts` for TypeScript. 18 | - Use **PascalCase** for classes. 19 | - Use **camelCase** for variables, functions, and methods. 20 | - Use **kebab-case** for file and directory names. 21 | - Use **UPPERCASE** for environment variables. 22 | - Avoid magic numbers — define them as constants. 23 | - Ensure code is formatted using **ESLint** and **Prettier**. 24 | - Use **Husky** and **lint-staged** for pre-commit checks. 25 | 26 | ## Functions & Logic 27 | - Keep functions short and focused — preferably under 20 lines. 28 | - Avoid deeply nested blocks: 29 | - Use early returns. 30 | - Extract logic into utility functions. 31 | - Use higher-order functions like `map`, `filter`, and `reduce` where applicable. 32 | - Prefer arrow functions for simple cases (under 3 instructions), and named functions otherwise. 33 | - Use default parameter values instead of null/undefined checks. 34 | - Use RO-RO pattern (Receive Object, Return Object) for multiple parameters or return values. 35 | - Don’t leave unnecessary blank lines within functions. 36 | 37 | ## Data Handling 38 | - Avoid excessive use of primitive types — encapsulate related data in composite types. 39 | - Avoid inline validation in functions — use types or classes with internal validation logic. 40 | - Prefer immutable data structures: 41 | - Use `readonly` for immutable properties. 42 | - Use `as const` for literals that should never change. 43 | 44 | ## Version Control 45 | - Use **Conventional Commits** for all commit messages (`feat`, `fix`, `refactor`, etc.). 46 | - Each significant change must be in a separate commit. 47 | - The `master` branch contains the latest stable version. 48 | - **NEVER** use `--no-verify` when committing. 49 | - Branch names should follow the pattern: `type/description` (e.g., `feat/particle-effect`, `fix/css-issue`). 50 | - Branch types should match conventional commit types: `feat/`, `fix/`, `docs/`, `refactor/`, etc. 51 | - Long-term development should use `release/next` branch. 52 | - Hotfixes should branch directly from `master` with `fix/` prefix. 53 | 54 | ## Testing 55 | - Write unit tests for utility functions -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | THREE Particles Logo 3 |

4 | 5 | # THREE Particles 6 | [![Run Tests](https://github.com/NewKrok/three-particles/actions/workflows/test.yml/badge.svg)](https://github.com/NewKrok/three-particles/actions/workflows/test.yml) 7 | [![NPM Version](https://img.shields.io/npm/v/@newkrok/three-particles.svg)](https://www.npmjs.com/package/@newkrok/three-particles) 8 | [![NPM Downloads](https://img.shields.io/npm/dw/@newkrok/three-particles.svg)](https://www.npmjs.com/package/@newkrok/three-particles) 9 | [![Bundle Size](https://img.shields.io/bundlephobia/minzip/@newkrok/three-particles)](https://bundlephobia.com/package/@newkrok/three-particles) 10 | 11 | Particle system for ThreeJS. 12 | 13 | # Features 14 | 15 | * Easy integration with Three.js. 16 | * Visual editor for creating and fine-tuning effects: [THREE Particles Editor](https://github.com/NewKrok/three-particles-editor) 17 | * Highly customizable particle properties (position, velocity, size, color, alpha, rotation, etc.). 18 | * Support for various emitter shapes and parameters. 19 | * TypeDoc API documentation available. 20 | 21 | # Live Demo & Examples 22 | 23 | * **Editor & Live Demo:** [https://newkrok.com/three-particles-editor/index.html](https://newkrok.com/three-particles-editor/index.html) 24 | * **CodePen Basic Example:** [https://codepen.io/NewKrok/pen/GgRzEmP](https://codepen.io/NewKrok/pen/GgRzEmP) 25 | * **CodePen Fire Animation:** [https://codepen.io/NewKrok/pen/ByabNRJ](https://codepen.io/NewKrok/pen/ByabNRJ) 26 | * **CodePen Projectile Simulation:** [https://codepen.io/NewKrok/pen/jEEErZy](https://codepen.io/NewKrok/pen/jEEErZy) 27 | * **Video - Projectiles:** [https://youtu.be/Q352JuxON04](https://youtu.be/Q352JuxON04) 28 | * **Video - First Preview:** [https://youtu.be/dtN_bndvoGU](https://youtu.be/dtN_bndvoGU) 29 | 30 | # Installation 31 | 32 | ## NPM 33 | 34 | ```bash 35 | npm install @newkrok/three-particles 36 | ``` 37 | 38 | ## CDN (Browser) 39 | 40 | Include the script directly in your HTML: 41 | 42 | ```html 43 | 44 | 45 | 46 | ``` 47 | 48 | # Usage 49 | 50 | Here's a basic example of how to load and use a particle system: 51 | 52 | ```javascript 53 | // Create a particle system 54 | const effect = { 55 | // Your effect configuration here 56 | // It can be empty to use default settings 57 | }; 58 | const { instance } = createParticleSystem(effect); 59 | scene.add(instance); 60 | 61 | // Update the particle system in your animation loop 62 | // Pass the current time, delta time, and elapsed time 63 | updateParticleSystems({now, delta, elapsed}); 64 | ``` 65 | 66 | # Documentation 67 | 68 | Automatically generated TypeDoc: [https://newkrok.github.io/three-particles/](https://newkrok.github.io/three-particles/) 69 | -------------------------------------------------------------------------------- /src/js/effects/three-particles/three-particles-curves.ts: -------------------------------------------------------------------------------- 1 | import Easing from 'easing-functions'; 2 | import { CurveFunction } from './types.js'; 3 | 4 | export const enum CurveFunctionId { 5 | BEZIER = 'BEZIER', 6 | LINEAR = 'LINEAR', 7 | QUADRATIC_IN = 'QUADRATIC_IN', 8 | QUADRATIC_OUT = 'QUADRATIC_OUT', 9 | QUADRATIC_IN_OUT = 'QUADRATIC_IN_OUT', 10 | CUBIC_IN = 'CUBIC_IN', 11 | CUBIC_OUT = 'CUBIC_OUT', 12 | CUBIC_IN_OUT = 'CUBIC_IN_OUT', 13 | QUARTIC_IN = 'QUARTIC_IN', 14 | QUARTIC_OUT = 'QUARTIC_OUT', 15 | QUARTIC_IN_OUT = 'QUARTIC_IN_OUT', 16 | QUINTIC_IN = 'QUINTIC_IN', 17 | QUINTIC_OUT = 'QUINTIC_OUT', 18 | QUINTIC_IN_OUT = 'QUINTIC_IN_OUT', 19 | SINUSOIDAL_IN = 'SINUSOIDAL_IN', 20 | SINUSOIDAL_OUT = 'SINUSOIDAL_OUT', 21 | SINUSOIDAL_IN_OUT = 'SINUSOIDAL_IN_OUT', 22 | EXPONENTIAL_IN = 'EXPONENTIAL_IN', 23 | EXPONENTIAL_OUT = 'EXPONENTIAL_OUT', 24 | EXPONENTIAL_IN_OUT = 'EXPONENTIAL_IN_OUT', 25 | CIRCULAR_IN = 'CIRCULAR_IN', 26 | CIRCULAR_OUT = 'CIRCULAR_OUT', 27 | CIRCULAR_IN_OUT = 'CIRCULAR_IN_OUT', 28 | ELASTIC_IN = 'ELASTIC_IN', 29 | ELASTIC_OUT = 'ELASTIC_OUT', 30 | ELASTIC_IN_OUT = 'ELASTIC_IN_OUT', 31 | BACK_IN = 'BACK_IN', 32 | BACK_OUT = 'BACK_OUT', 33 | BACK_IN_OUT = 'BACK_IN_OUT', 34 | BOUNCE_IN = 'BOUNCE_IN', 35 | BOUNCE_OUT = 'BOUNCE_OUT', 36 | BOUNCE_IN_OUT = 'BOUNCE_IN_OUT', 37 | } 38 | 39 | const CurveFunctionIdMap: Partial> = { 40 | [CurveFunctionId.LINEAR]: Easing.Linear.None, 41 | [CurveFunctionId.QUADRATIC_IN]: Easing.Quadratic.In, 42 | [CurveFunctionId.QUADRATIC_OUT]: Easing.Quadratic.Out, 43 | [CurveFunctionId.QUADRATIC_IN_OUT]: Easing.Quadratic.InOut, 44 | [CurveFunctionId.CUBIC_IN]: Easing.Cubic.In, 45 | [CurveFunctionId.CUBIC_OUT]: Easing.Cubic.Out, 46 | [CurveFunctionId.CUBIC_IN_OUT]: Easing.Cubic.InOut, 47 | [CurveFunctionId.QUARTIC_IN]: Easing.Quartic.In, 48 | [CurveFunctionId.QUARTIC_OUT]: Easing.Quartic.Out, 49 | [CurveFunctionId.QUARTIC_IN_OUT]: Easing.Quartic.InOut, 50 | [CurveFunctionId.QUINTIC_IN]: Easing.Quintic.In, 51 | [CurveFunctionId.QUINTIC_OUT]: Easing.Quintic.Out, 52 | [CurveFunctionId.QUINTIC_IN_OUT]: Easing.Quintic.InOut, 53 | [CurveFunctionId.SINUSOIDAL_IN]: Easing.Sinusoidal.In, 54 | [CurveFunctionId.SINUSOIDAL_OUT]: Easing.Sinusoidal.Out, 55 | [CurveFunctionId.SINUSOIDAL_IN_OUT]: Easing.Sinusoidal.InOut, 56 | [CurveFunctionId.EXPONENTIAL_IN]: Easing.Exponential.In, 57 | [CurveFunctionId.EXPONENTIAL_OUT]: Easing.Exponential.Out, 58 | [CurveFunctionId.EXPONENTIAL_IN_OUT]: Easing.Exponential.InOut, 59 | [CurveFunctionId.CIRCULAR_IN]: Easing.Circular.In, 60 | [CurveFunctionId.CIRCULAR_OUT]: Easing.Circular.Out, 61 | [CurveFunctionId.CIRCULAR_IN_OUT]: Easing.Circular.InOut, 62 | [CurveFunctionId.ELASTIC_IN]: Easing.Elastic.In, 63 | [CurveFunctionId.ELASTIC_OUT]: Easing.Elastic.Out, 64 | [CurveFunctionId.ELASTIC_IN_OUT]: Easing.Elastic.InOut, 65 | [CurveFunctionId.BACK_IN]: Easing.Back.In, 66 | [CurveFunctionId.BACK_OUT]: Easing.Back.Out, 67 | [CurveFunctionId.BACK_IN_OUT]: Easing.Back.InOut, 68 | [CurveFunctionId.BOUNCE_IN]: Easing.Bounce.In, 69 | [CurveFunctionId.BOUNCE_OUT]: Easing.Bounce.Out, 70 | [CurveFunctionId.BOUNCE_IN_OUT]: Easing.Bounce.InOut, 71 | }; 72 | 73 | export const getCurveFunction = ( 74 | curveFunctionId: CurveFunctionId | CurveFunction 75 | ): CurveFunction => 76 | typeof curveFunctionId === 'function' 77 | ? curveFunctionId 78 | : CurveFunctionIdMap[curveFunctionId]!; 79 | -------------------------------------------------------------------------------- /src/js/effects/three-particles/three-particles-modifiers.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | import { calculateValue } from './three-particles-utils.js'; 4 | import { GeneralData, NormalizedParticleSystemConfig } from './types.js'; 5 | 6 | const noiseInput = new THREE.Vector3(0, 0, 0); 7 | const orbitalEuler = new THREE.Euler(); 8 | 9 | export const applyModifiers = ({ 10 | delta, 11 | generalData, 12 | normalizedConfig, 13 | attributes, 14 | particleLifetimePercentage, 15 | particleIndex, 16 | }: { 17 | delta: number; 18 | generalData: GeneralData; 19 | normalizedConfig: NormalizedParticleSystemConfig; 20 | attributes: THREE.NormalBufferAttributes; 21 | particleLifetimePercentage: number; 22 | particleIndex: number; 23 | }) => { 24 | const { 25 | particleSystemId, 26 | startValues, 27 | lifetimeValues, 28 | linearVelocityData, 29 | orbitalVelocityData, 30 | noise, 31 | } = generalData; 32 | 33 | const positionIndex = particleIndex * 3; 34 | const positionArr = attributes.position.array; 35 | 36 | if (linearVelocityData) { 37 | const { speed, valueModifiers } = linearVelocityData[particleIndex]; 38 | 39 | const normalizedXSpeed = valueModifiers.x 40 | ? valueModifiers.x(particleLifetimePercentage) 41 | : speed.x; 42 | 43 | const normalizedYSpeed = valueModifiers.y 44 | ? valueModifiers.y(particleLifetimePercentage) 45 | : speed.y; 46 | 47 | const normalizedZSpeed = valueModifiers.z 48 | ? valueModifiers.z(particleLifetimePercentage) 49 | : speed.z; 50 | 51 | positionArr[positionIndex] += normalizedXSpeed * delta; 52 | positionArr[positionIndex + 1] += normalizedYSpeed * delta; 53 | positionArr[positionIndex + 2] += normalizedZSpeed * delta; 54 | 55 | attributes.position.needsUpdate = true; 56 | } 57 | 58 | if (orbitalVelocityData) { 59 | const { speed, positionOffset, valueModifiers } = 60 | orbitalVelocityData[particleIndex]; 61 | 62 | positionArr[positionIndex] -= positionOffset.x; 63 | positionArr[positionIndex + 1] -= positionOffset.y; 64 | positionArr[positionIndex + 2] -= positionOffset.z; 65 | 66 | const normalizedXSpeed = valueModifiers.x 67 | ? valueModifiers.x(particleLifetimePercentage) 68 | : speed.x; 69 | 70 | const normalizedYSpeed = valueModifiers.y 71 | ? valueModifiers.y(particleLifetimePercentage) 72 | : speed.y; 73 | 74 | const normalizedZSpeed = valueModifiers.z 75 | ? valueModifiers.z(particleLifetimePercentage) 76 | : speed.z; 77 | 78 | orbitalEuler.set( 79 | normalizedXSpeed * delta, 80 | normalizedZSpeed * delta, 81 | normalizedYSpeed * delta 82 | ); 83 | positionOffset.applyEuler(orbitalEuler); 84 | 85 | positionArr[positionIndex] += positionOffset.x; 86 | positionArr[positionIndex + 1] += positionOffset.y; 87 | positionArr[positionIndex + 2] += positionOffset.z; 88 | 89 | attributes.position.needsUpdate = true; 90 | } 91 | 92 | if (normalizedConfig.sizeOverLifetime.isActive) { 93 | const multiplier = calculateValue( 94 | particleSystemId, 95 | normalizedConfig.sizeOverLifetime.lifetimeCurve, 96 | particleLifetimePercentage 97 | ); 98 | attributes.size.array[particleIndex] = 99 | startValues.startSize[particleIndex] * multiplier; 100 | attributes.size.needsUpdate = true; 101 | } 102 | 103 | if (normalizedConfig.opacityOverLifetime.isActive) { 104 | const multiplier = calculateValue( 105 | particleSystemId, 106 | normalizedConfig.opacityOverLifetime.lifetimeCurve, 107 | particleLifetimePercentage 108 | ); 109 | attributes.colorA.array[particleIndex] = 110 | startValues.startOpacity[particleIndex] * multiplier; 111 | attributes.colorA.needsUpdate = true; 112 | } 113 | 114 | if (lifetimeValues.rotationOverLifetime) { 115 | attributes.rotation.array[particleIndex] += 116 | lifetimeValues.rotationOverLifetime[particleIndex] * delta * 0.02; 117 | attributes.rotation.needsUpdate = true; 118 | } 119 | 120 | if (noise.isActive) { 121 | const { 122 | sampler, 123 | strength, 124 | offsets, 125 | positionAmount, 126 | rotationAmount, 127 | sizeAmount, 128 | } = noise; 129 | let noiseOnPosition; 130 | 131 | const noisePosition = 132 | (particleLifetimePercentage + (offsets ? offsets[particleIndex] : 0)) * 133 | 10 * 134 | strength; 135 | const noisePower = 0.15 * strength; 136 | 137 | noiseInput.set(noisePosition, 0, 0); 138 | noiseOnPosition = sampler!.get3(noiseInput); 139 | positionArr[positionIndex] += noiseOnPosition * noisePower * positionAmount; 140 | 141 | if (rotationAmount !== 0) { 142 | attributes.rotation.array[particleIndex] += 143 | noiseOnPosition * noisePower * rotationAmount; 144 | attributes.rotation.needsUpdate = true; 145 | } 146 | 147 | if (sizeAmount !== 0) { 148 | attributes.size.array[particleIndex] += 149 | noiseOnPosition * noisePower * sizeAmount; 150 | attributes.size.needsUpdate = true; 151 | } 152 | 153 | noiseInput.set(noisePosition, noisePosition, 0); 154 | noiseOnPosition = sampler!.get3(noiseInput); 155 | positionArr[positionIndex + 1] += 156 | noiseOnPosition * noisePower * positionAmount; 157 | 158 | noiseInput.set(noisePosition, noisePosition, noisePosition); 159 | noiseOnPosition = sampler!.get3(noiseInput); 160 | positionArr[positionIndex + 2] += 161 | noiseOnPosition * noisePower * positionAmount; 162 | 163 | attributes.position.needsUpdate = true; 164 | } 165 | }; 166 | -------------------------------------------------------------------------------- /src/__tests__/three-particles-utils.test.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { LifeTimeCurve } from '../js/effects/three-particles/three-particles-enums.js'; 3 | import { 4 | calculateValue, 5 | calculateRandomPositionAndVelocityOnSphere, 6 | getCurveFunctionFromConfig, 7 | isLifeTimeCurve, 8 | } from '../js/effects/three-particles/three-particles-utils.js'; 9 | import { 10 | BezierCurve, 11 | EasingCurve, 12 | Constant, 13 | RandomBetweenTwoConstants, 14 | LifetimeCurve, 15 | } from '../js/effects/three-particles/types.js'; 16 | 17 | describe('calculateRandomPositionAndVelocityOnSphere', () => { 18 | it('should calculate a random position on a sphere surface', () => { 19 | const position = new THREE.Vector3(); 20 | const quaternion = new THREE.Quaternion(); 21 | const velocity = new THREE.Vector3(); 22 | 23 | calculateRandomPositionAndVelocityOnSphere( 24 | position, 25 | quaternion, 26 | velocity, 27 | 2, 28 | { radius: 1, radiusThickness: 0, arc: 360 } 29 | ); 30 | 31 | // The position should be normalized to the sphere radius. 32 | expect(position.length()).toBeCloseTo(1); 33 | }); 34 | 35 | it('should apply radius thickness correctly', () => { 36 | const position = new THREE.Vector3(); 37 | const quaternion = new THREE.Quaternion(); 38 | const velocity = new THREE.Vector3(); 39 | 40 | calculateRandomPositionAndVelocityOnSphere( 41 | position, 42 | quaternion, 43 | velocity, 44 | 2, 45 | { radius: 2, radiusThickness: 0.5, arc: 360 } 46 | ); 47 | 48 | // The position length should be within the expected thickness range. 49 | expect(position.length()).toBeLessThanOrEqual(2); 50 | expect(position.length()).toBeGreaterThanOrEqual(1); 51 | }); 52 | 53 | it('should calculate random velocity proportional to position', () => { 54 | const position = new THREE.Vector3(); 55 | const quaternion = new THREE.Quaternion(); 56 | const velocity = new THREE.Vector3(); 57 | 58 | calculateRandomPositionAndVelocityOnSphere( 59 | position, 60 | quaternion, 61 | velocity, 62 | 2, 63 | { radius: 1, radiusThickness: 0, arc: 360 } 64 | ); 65 | 66 | // The velocity length should match the configured speed. 67 | expect(velocity.length()).toBeCloseTo(2); 68 | }); 69 | }); 70 | 71 | describe('calculateValue function tests', () => { 72 | it('returns the constant value', () => { 73 | const result = calculateValue(1, 5); 74 | expect(result).toBe(5); 75 | }); 76 | 77 | it('returns a random value between min and max', () => { 78 | jest.spyOn(THREE.MathUtils, 'randFloat').mockReturnValue(2.5); 79 | const result = calculateValue(1, { min: 1, max: 4 }); 80 | expect(result).toBe(2.5); 81 | }); 82 | 83 | it('returns correct value for min equals max', () => { 84 | const result = calculateValue(1, { min: 2, max: 2 }); 85 | expect(result).toBe(2); // Should always return 2 when min == max 86 | }); 87 | 88 | it('returns exact Bezier curve value with scaling', () => { 89 | const bezierCurveMock: BezierCurve = { 90 | type: LifeTimeCurve.BEZIER, 91 | bezierPoints: [ 92 | { x: 0, y: 0 }, 93 | { x: 0.5, y: 0.5 }, 94 | { x: 1, y: 1 }, 95 | ], 96 | scale: 2, 97 | }; 98 | 99 | const result = calculateValue(1, bezierCurveMock, 0.5); 100 | expect(result).toBeCloseTo(1); 101 | }); 102 | 103 | it('returns correct value for time = 0 and time = 1', () => { 104 | const bezierCurveMock: BezierCurve = { 105 | type: LifeTimeCurve.BEZIER, 106 | bezierPoints: [ 107 | { x: 0, y: 0 }, 108 | { x: 1, y: 1, percentage: 1 }, 109 | ], 110 | scale: 1, 111 | }; 112 | 113 | expect(calculateValue(1, bezierCurveMock, 0)).toBe(0); // Start of the curve 114 | expect(calculateValue(1, bezierCurveMock, 1)).toBe(1); // End of the curve 115 | }); 116 | 117 | it('throws error for invalid bezierPoints', () => { 118 | const invalidBezierCurve: BezierCurve = { 119 | type: LifeTimeCurve.BEZIER, 120 | bezierPoints: [], 121 | scale: 1, 122 | }; 123 | 124 | expect(() => calculateValue(1, invalidBezierCurve, 0.5)).toThrow(); 125 | }); 126 | 127 | it('returns Easing curve value with scaling', () => { 128 | const easingCurveMock: EasingCurve = { 129 | type: LifeTimeCurve.EASING, 130 | curveFunction: (time: number) => time * 0.5, 131 | scale: 2, 132 | }; 133 | 134 | const result = calculateValue(1, easingCurveMock, 0.8); 135 | expect(result).toBeCloseTo(0.8); 136 | }); 137 | 138 | it('throws an error for unsupported value type', () => { 139 | expect(() => calculateValue(1, {})).toThrow('Unsupported value type'); 140 | }); 141 | }); 142 | 143 | describe('isLifeTimeCurve function tests', () => { 144 | it('returns false for number values', () => { 145 | const result = isLifeTimeCurve(5); 146 | expect(result).toBe(false); 147 | }); 148 | 149 | it('returns false for RandomBetweenTwoConstants objects', () => { 150 | const randomValue: RandomBetweenTwoConstants = { min: 1, max: 5 }; 151 | const result = isLifeTimeCurve(randomValue); 152 | expect(result).toBe(false); 153 | }); 154 | 155 | it('returns true for BezierCurve objects', () => { 156 | const bezierCurve: BezierCurve = { 157 | type: LifeTimeCurve.BEZIER, 158 | bezierPoints: [ 159 | { x: 0, y: 0, percentage: 0 }, 160 | { x: 1, y: 1, percentage: 1 }, 161 | ], 162 | scale: 1, 163 | }; 164 | const result = isLifeTimeCurve(bezierCurve); 165 | expect(result).toBe(true); 166 | }); 167 | 168 | it('returns true for EasingCurve objects', () => { 169 | const easingCurve: EasingCurve = { 170 | type: LifeTimeCurve.EASING, 171 | curveFunction: (time: number) => time, 172 | scale: 1, 173 | }; 174 | const result = isLifeTimeCurve(easingCurve); 175 | expect(result).toBe(true); 176 | }); 177 | }); 178 | 179 | describe('getCurveFunctionFromConfig function tests', () => { 180 | it('returns a Bezier curve function for BEZIER type', () => { 181 | const bezierCurve: BezierCurve = { 182 | type: LifeTimeCurve.BEZIER, 183 | bezierPoints: [ 184 | { x: 0, y: 0, percentage: 0 }, 185 | { x: 1, y: 1, percentage: 1 }, 186 | ], 187 | scale: 1, 188 | }; 189 | 190 | const curveFunction = getCurveFunctionFromConfig(1, bezierCurve); 191 | 192 | // Test the returned function 193 | expect(typeof curveFunction).toBe('function'); 194 | expect(curveFunction(0)).toBeCloseTo(0); 195 | expect(curveFunction(1)).toBeCloseTo(1); 196 | expect(curveFunction(0.5)).toBeCloseTo(0.5); 197 | }); 198 | 199 | it('returns the provided curve function for EASING type', () => { 200 | const testFunction = (time: number) => time * 2; 201 | const easingCurve: EasingCurve = { 202 | type: LifeTimeCurve.EASING, 203 | curveFunction: testFunction, 204 | scale: 1, 205 | }; 206 | 207 | const curveFunction = getCurveFunctionFromConfig(1, easingCurve); 208 | 209 | // Verify it returns the same function 210 | expect(curveFunction).toBe(testFunction); 211 | expect(curveFunction(0.5)).toBe(1); // 0.5 * 2 = 1 212 | }); 213 | 214 | it('throws an error for unsupported curve type', () => { 215 | // Create a curve with an invalid type that will pass TypeScript but fail at runtime 216 | const invalidCurve = { 217 | type: 999 as unknown as LifeTimeCurve, // Invalid enum value 218 | scale: 1, 219 | bezierPoints: [], // Add this to satisfy TypeScript 220 | } as LifetimeCurve; 221 | 222 | expect(() => getCurveFunctionFromConfig(1, invalidCurve)).toThrow( 223 | 'Unsupported value type' 224 | ); 225 | }); 226 | }); 227 | -------------------------------------------------------------------------------- /src/js/effects/three-particles/three-particles-utils.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | import { createBezierCurveFunction } from './three-particles-bezier.js'; 4 | import { EmitFrom, LifeTimeCurve } from './three-particles-enums.js'; 5 | import { 6 | Constant, 7 | LifetimeCurve, 8 | Point3D, 9 | RandomBetweenTwoConstants, 10 | } from './types.js'; 11 | 12 | export const calculateRandomPositionAndVelocityOnSphere = ( 13 | position: THREE.Vector3, 14 | quaternion: THREE.Quaternion, 15 | velocity: THREE.Vector3, 16 | speed: number, 17 | { 18 | radius, 19 | radiusThickness, 20 | arc, 21 | }: { radius: number; radiusThickness: number; arc: number } 22 | ) => { 23 | const u = Math.random() * (arc / 360); 24 | const v = Math.random(); 25 | const randomizedDistanceRatio = Math.random(); 26 | const theta = 2 * Math.PI * u; 27 | const phi = Math.acos(2 * v - 1); 28 | const sinPhi = Math.sin(phi); 29 | 30 | const xDirection = sinPhi * Math.cos(theta); 31 | const yDirection = sinPhi * Math.sin(theta); 32 | const zDirection = Math.cos(phi); 33 | const normalizedThickness = 1 - radiusThickness; 34 | 35 | position.x = 36 | radius * normalizedThickness * xDirection + 37 | radius * radiusThickness * randomizedDistanceRatio * xDirection; 38 | position.y = 39 | radius * normalizedThickness * yDirection + 40 | radius * radiusThickness * randomizedDistanceRatio * yDirection; 41 | position.z = 42 | radius * normalizedThickness * zDirection + 43 | radius * radiusThickness * randomizedDistanceRatio * zDirection; 44 | 45 | position.applyQuaternion(quaternion); 46 | 47 | const speedMultiplierByPosition = 1 / position.length(); 48 | velocity.set( 49 | position.x * speedMultiplierByPosition * speed, 50 | position.y * speedMultiplierByPosition * speed, 51 | position.z * speedMultiplierByPosition * speed 52 | ); 53 | velocity.applyQuaternion(quaternion); 54 | }; 55 | 56 | export const calculateRandomPositionAndVelocityOnCone = ( 57 | position: THREE.Vector3, 58 | quaternion: THREE.Quaternion, 59 | velocity: THREE.Vector3, 60 | speed: number, 61 | { 62 | radius, 63 | radiusThickness, 64 | arc, 65 | angle = 90, 66 | }: { 67 | radius: number; 68 | radiusThickness: number; 69 | arc: number; 70 | angle?: number; 71 | } 72 | ) => { 73 | const theta = 2 * Math.PI * Math.random() * (arc / 360); 74 | const randomizedDistanceRatio = Math.random(); 75 | 76 | const xDirection = Math.cos(theta); 77 | const yDirection = Math.sin(theta); 78 | const normalizedThickness = 1 - radiusThickness; 79 | 80 | position.x = 81 | radius * normalizedThickness * xDirection + 82 | radius * radiusThickness * randomizedDistanceRatio * xDirection; 83 | position.y = 84 | radius * normalizedThickness * yDirection + 85 | radius * radiusThickness * randomizedDistanceRatio * yDirection; 86 | position.z = 0; 87 | 88 | position.applyQuaternion(quaternion); 89 | 90 | const positionLength = position.length(); 91 | const normalizedAngle = Math.abs( 92 | (positionLength / radius) * THREE.MathUtils.degToRad(angle) 93 | ); 94 | const sinNormalizedAngle = Math.sin(normalizedAngle); 95 | 96 | const speedMultiplierByPosition = 1 / positionLength; 97 | velocity.set( 98 | position.x * sinNormalizedAngle * speedMultiplierByPosition * speed, 99 | position.y * sinNormalizedAngle * speedMultiplierByPosition * speed, 100 | Math.cos(normalizedAngle) * speed 101 | ); 102 | velocity.applyQuaternion(quaternion); 103 | }; 104 | 105 | export const calculateRandomPositionAndVelocityOnBox = ( 106 | position: THREE.Vector3, 107 | quaternion: THREE.Quaternion, 108 | velocity: THREE.Vector3, 109 | speed: number, 110 | { scale, emitFrom }: { scale: Point3D; emitFrom: EmitFrom } 111 | ) => { 112 | const _scale = scale as Required; 113 | switch (emitFrom) { 114 | case EmitFrom.VOLUME: 115 | position.x = Math.random() * _scale.x - _scale.x / 2; 116 | position.y = Math.random() * _scale.y - _scale.y / 2; 117 | position.z = Math.random() * _scale.z - _scale.z / 2; 118 | break; 119 | 120 | case EmitFrom.SHELL: 121 | const side = Math.floor(Math.random() * 6); 122 | const perpendicularAxis = side % 3; 123 | const shellResult = []; 124 | shellResult[perpendicularAxis] = side > 2 ? 1 : 0; 125 | shellResult[(perpendicularAxis + 1) % 3] = Math.random(); 126 | shellResult[(perpendicularAxis + 2) % 3] = Math.random(); 127 | position.x = shellResult[0] * _scale.x - _scale.x / 2; 128 | position.y = shellResult[1] * _scale.y - _scale.y / 2; 129 | position.z = shellResult[2] * _scale.z - _scale.z / 2; 130 | break; 131 | 132 | case EmitFrom.EDGE: 133 | const side2 = Math.floor(Math.random() * 6); 134 | const perpendicularAxis2 = side2 % 3; 135 | const edge = Math.floor(Math.random() * 4); 136 | const edgeResult = []; 137 | edgeResult[perpendicularAxis2] = side2 > 2 ? 1 : 0; 138 | edgeResult[(perpendicularAxis2 + 1) % 3] = 139 | edge < 2 ? Math.random() : edge - 2; 140 | edgeResult[(perpendicularAxis2 + 2) % 3] = 141 | edge < 2 ? edge : Math.random(); 142 | position.x = edgeResult[0] * _scale.x - _scale.x / 2; 143 | position.y = edgeResult[1] * _scale.y - _scale.y / 2; 144 | position.z = edgeResult[2] * _scale.z - _scale.z / 2; 145 | break; 146 | } 147 | 148 | position.applyQuaternion(quaternion); 149 | 150 | velocity.set(0, 0, speed); 151 | velocity.applyQuaternion(quaternion); 152 | }; 153 | 154 | export const calculateRandomPositionAndVelocityOnCircle = ( 155 | position: THREE.Vector3, 156 | quaternion: THREE.Quaternion, 157 | velocity: THREE.Vector3, 158 | speed: number, 159 | { 160 | radius, 161 | radiusThickness, 162 | arc, 163 | }: { radius: number; radiusThickness: number; arc: number } 164 | ) => { 165 | const theta = 2 * Math.PI * Math.random() * (arc / 360); 166 | const randomizedDistanceRatio = Math.random(); 167 | 168 | const xDirection = Math.cos(theta); 169 | const yDirection = Math.sin(theta); 170 | const normalizedThickness = 1 - radiusThickness; 171 | 172 | position.x = 173 | radius * normalizedThickness * xDirection + 174 | radius * radiusThickness * randomizedDistanceRatio * xDirection; 175 | position.y = 176 | radius * normalizedThickness * yDirection + 177 | radius * radiusThickness * randomizedDistanceRatio * yDirection; 178 | position.z = 0; 179 | 180 | position.applyQuaternion(quaternion); 181 | 182 | const positionLength = position.length(); 183 | const speedMultiplierByPosition = 1 / positionLength; 184 | velocity.set( 185 | position.x * speedMultiplierByPosition * speed, 186 | position.y * speedMultiplierByPosition * speed, 187 | 0 188 | ); 189 | velocity.applyQuaternion(quaternion); 190 | }; 191 | 192 | export const calculateRandomPositionAndVelocityOnRectangle = ( 193 | position: THREE.Vector3, 194 | quaternion: THREE.Quaternion, 195 | velocity: THREE.Vector3, 196 | speed: number, 197 | { rotation, scale }: { rotation: Point3D; scale: Point3D } 198 | ) => { 199 | const _scale = scale as Required; 200 | const _rotation = rotation as Required; 201 | 202 | const xOffset = Math.random() * _scale.x - _scale.x / 2; 203 | const yOffset = Math.random() * _scale.y - _scale.y / 2; 204 | const rotationX = THREE.MathUtils.degToRad(_rotation.x); 205 | const rotationY = THREE.MathUtils.degToRad(_rotation.y); 206 | position.x = xOffset * Math.cos(rotationY); 207 | position.y = yOffset * Math.cos(rotationX); 208 | position.z = xOffset * Math.sin(rotationY) - yOffset * Math.sin(rotationX); 209 | 210 | position.applyQuaternion(quaternion); 211 | 212 | velocity.set(0, 0, speed); 213 | velocity.applyQuaternion(quaternion); 214 | }; 215 | 216 | /** 217 | * Creates a default white circle texture using CanvasTexture. 218 | * @returns {THREE.CanvasTexture | null} The generated texture or null if context fails. 219 | */ 220 | export const createDefaultParticleTexture = (): THREE.CanvasTexture | null => { 221 | try { 222 | const canvas = document.createElement('canvas'); 223 | const size = 64; 224 | canvas.width = size; 225 | canvas.height = size; 226 | const context = canvas.getContext('2d'); 227 | if (context) { 228 | const centerX = size / 2; 229 | const centerY = size / 2; 230 | const radius = size / 2 - 2; // Small padding 231 | 232 | context.beginPath(); 233 | context.arc(centerX, centerY, radius, 0, 2 * Math.PI, false); 234 | context.fillStyle = 'white'; 235 | context.fill(); 236 | const texture = new THREE.CanvasTexture(canvas); 237 | texture.needsUpdate = true; 238 | return texture; 239 | } else { 240 | console.warn( 241 | 'Could not get 2D context to generate default particle texture.' 242 | ); 243 | return null; 244 | } 245 | } catch (error) { 246 | // Handle potential errors (e.g., document not available in non-browser env) 247 | console.warn('Error creating default particle texture:', error); 248 | return null; 249 | } 250 | }; 251 | 252 | export const isLifeTimeCurve = ( 253 | value: Constant | RandomBetweenTwoConstants | LifetimeCurve 254 | ): value is LifetimeCurve => { 255 | return typeof value !== 'number' && 'type' in value; 256 | }; 257 | 258 | export const getCurveFunctionFromConfig = ( 259 | particleSystemId: number, 260 | lifetimeCurve: LifetimeCurve 261 | ) => { 262 | if (lifetimeCurve.type === LifeTimeCurve.BEZIER) { 263 | return createBezierCurveFunction( 264 | particleSystemId, 265 | lifetimeCurve.bezierPoints 266 | ); // Bézier curve 267 | } 268 | 269 | if (lifetimeCurve.type === LifeTimeCurve.EASING) { 270 | return lifetimeCurve.curveFunction; // Easing curve 271 | } 272 | 273 | throw new Error(`Unsupported value type: ${lifetimeCurve}`); 274 | }; 275 | 276 | export const calculateValue = ( 277 | particleSystemId: number, 278 | value: Constant | RandomBetweenTwoConstants | LifetimeCurve, 279 | time: number = 0 280 | ): number => { 281 | if (typeof value === 'number') { 282 | return value; // Constant value 283 | } 284 | 285 | if ('min' in value && 'max' in value) { 286 | if (value.min === value.max) { 287 | return value.min ?? 0; // Constant value 288 | } 289 | return THREE.MathUtils.randFloat(value.min ?? 0, value.max ?? 1); // Random range 290 | } 291 | 292 | const lifetimeCurve = value as LifetimeCurve; 293 | return ( 294 | getCurveFunctionFromConfig(particleSystemId, lifetimeCurve)(time) * 295 | (lifetimeCurve.scale ?? 1) 296 | ); 297 | }; 298 | -------------------------------------------------------------------------------- /src/__tests__/three-particles-modifiers.test.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { LifeTimeCurve } from '../js/effects/three-particles/three-particles-enums.js'; 3 | import { applyModifiers } from '../js/effects/three-particles/three-particles-modifiers.js'; 4 | import { 5 | GeneralData, 6 | Noise, 7 | NormalizedParticleSystemConfig, 8 | } from '../js/effects/three-particles/types.js'; 9 | 10 | describe('applyModifiers', () => { 11 | let attributes: THREE.NormalBufferAttributes; 12 | let normalizedConfig: NormalizedParticleSystemConfig; 13 | 14 | beforeEach(() => { 15 | attributes = { 16 | position: { array: new Float32Array(3), needsUpdate: false }, 17 | rotation: { array: new Float32Array(1), needsUpdate: false }, 18 | size: { array: new Float32Array(1), needsUpdate: false }, 19 | colorA: { array: new Float32Array(1), needsUpdate: false }, 20 | } as unknown as THREE.NormalBufferAttributes; 21 | 22 | normalizedConfig = { 23 | opacityOverLifetime: { 24 | isActive: false, 25 | lifetimeCurve: { 26 | type: LifeTimeCurve.EASING, 27 | curveFunction: () => 1.5, 28 | scale: 1, 29 | }, 30 | }, 31 | sizeOverLifetime: { 32 | isActive: false, 33 | lifetimeCurve: { 34 | type: LifeTimeCurve.EASING, 35 | curveFunction: () => 2, 36 | scale: 1, 37 | }, 38 | }, 39 | } as unknown as NormalizedParticleSystemConfig; 40 | }); 41 | 42 | test('updates position based on linear velocity', () => { 43 | const linearVelocityData = [ 44 | { 45 | speed: new THREE.Vector3(1, 1, 1), 46 | valueModifiers: { x: undefined, y: undefined, z: undefined }, 47 | }, 48 | ]; 49 | 50 | applyModifiers({ 51 | delta: 1, 52 | generalData: { 53 | noise: { isActive: false } as Noise, 54 | startValues: {}, 55 | lifetimeValues: {}, 56 | linearVelocityData, 57 | } as GeneralData, 58 | normalizedConfig, 59 | attributes, 60 | particleLifetimePercentage: 0.5, 61 | particleIndex: 0, 62 | }); 63 | 64 | expect(attributes.position.array).toEqual(new Float32Array([1, 1, 1])); 65 | expect(attributes.position.needsUpdate).toBe(true); 66 | }); 67 | 68 | test('should apply linear velocity with value modifiers', () => { 69 | const attributes = { 70 | position: { array: new Float32Array([0, 0, 0]), needsUpdate: false }, 71 | }; 72 | 73 | const linearVelocityData = [ 74 | { 75 | speed: new THREE.Vector3(1, 1, 1), 76 | valueModifiers: { 77 | x: (t: number) => t * 2, 78 | y: (t: number) => t * 3, 79 | z: (t: number) => t * 4, 80 | }, 81 | }, 82 | ]; 83 | 84 | applyModifiers({ 85 | delta: 1, 86 | generalData: { 87 | noise: { isActive: false } as Noise, 88 | startValues: {}, 89 | lifetimeValues: {}, 90 | linearVelocityData, 91 | orbitalVelocityData: undefined, 92 | } as GeneralData, 93 | normalizedConfig, 94 | attributes: attributes as any, 95 | particleLifetimePercentage: 0.5, 96 | particleIndex: 0, 97 | }); 98 | 99 | expect(Array.from(attributes.position.array)).toEqual([1, 1.5, 2]); 100 | expect(attributes.position.needsUpdate).toBe(true); 101 | }); 102 | 103 | test('should update position with orbital velocity data', () => { 104 | const orbitalVelocityData = [ 105 | { 106 | speed: new THREE.Vector3(0.5, 0.5, 0.5), 107 | positionOffset: new THREE.Vector3(0, 0, 0), 108 | valueModifiers: {}, 109 | }, 110 | ]; 111 | 112 | applyModifiers({ 113 | delta: 1, 114 | generalData: { 115 | noise: { isActive: false } as Noise, 116 | startValues: {}, 117 | lifetimeValues: {}, 118 | linearVelocityData: undefined, 119 | orbitalVelocityData, 120 | } as GeneralData, 121 | normalizedConfig, 122 | attributes: attributes as any, 123 | particleLifetimePercentage: 0.5, 124 | particleIndex: 0, 125 | }); 126 | 127 | const offset = new THREE.Vector3(0, 0, 0); 128 | const euler = new THREE.Euler(0.5, 0.5, 0.5); 129 | offset.applyEuler(euler); 130 | 131 | expect(Array.from(attributes.position.array)).toEqual([ 132 | offset.x, 133 | offset.y, 134 | offset.z, 135 | ]); 136 | expect(attributes.position.needsUpdate).toBe(true); 137 | }); 138 | 139 | test('should apply orbital velocity with position offset', () => { 140 | const _attributes = { 141 | ...attributes, 142 | position: { array: new Float32Array([1, 1, 1]), needsUpdate: false }, 143 | }; 144 | 145 | const orbitalVelocityData = [ 146 | { 147 | speed: new THREE.Vector3(1, 1, 1), 148 | positionOffset: new THREE.Vector3(1, 1, 1), 149 | valueModifiers: {}, 150 | }, 151 | ]; 152 | 153 | applyModifiers({ 154 | delta: 1, 155 | generalData: { 156 | noise: { isActive: false } as Noise, 157 | startValues: {}, 158 | lifetimeValues: {}, 159 | linearVelocityData: undefined, 160 | orbitalVelocityData, 161 | } as GeneralData, 162 | normalizedConfig, 163 | attributes: _attributes as any, 164 | particleLifetimePercentage: 0.5, 165 | particleIndex: 0, 166 | }); 167 | 168 | const expectedPosition = new THREE.Vector3(); 169 | const offset = new THREE.Vector3(1, 1, 1); 170 | const euler = new THREE.Euler(1, 1, 1); 171 | offset.applyEuler(euler); 172 | expectedPosition.add(offset); 173 | 174 | expect(_attributes.position.array[0]).toBeCloseTo(expectedPosition.x, 5); 175 | expect(_attributes.position.array[1]).toBeCloseTo(expectedPosition.y, 5); 176 | expect(_attributes.position.array[2]).toBeCloseTo(expectedPosition.z, 5); 177 | 178 | expect(_attributes.position.needsUpdate).toBe(true); 179 | }); 180 | 181 | test('should apply orbital velocity using valueModifiers', () => { 182 | const attributes = { 183 | position: { array: new Float32Array([1, 1, 1]), needsUpdate: false }, 184 | }; 185 | 186 | const orbitalVelocityData = [ 187 | { 188 | speed: new THREE.Vector3(1, 1, 1), 189 | positionOffset: new THREE.Vector3(1, 1, 1), 190 | valueModifiers: { 191 | x: (t: number) => t * 2, 192 | y: (t: number) => t * 3, 193 | z: (t: number) => t * 4, 194 | }, 195 | }, 196 | ]; 197 | 198 | applyModifiers({ 199 | delta: 1, 200 | generalData: { 201 | noise: { isActive: false } as Noise, 202 | startValues: {}, 203 | lifetimeValues: {}, 204 | linearVelocityData: undefined, 205 | orbitalVelocityData, 206 | } as GeneralData, 207 | normalizedConfig, 208 | attributes: attributes as any, 209 | particleLifetimePercentage: 0.5, 210 | particleIndex: 0, 211 | }); 212 | 213 | const offset = new THREE.Vector3(1, 1, 1); 214 | const euler = new THREE.Euler(1, 2, 1.5); 215 | offset.applyEuler(euler); 216 | 217 | expect(attributes.position.array[0]).toBeCloseTo(offset.x, 5); 218 | expect(attributes.position.array[1]).toBeCloseTo(offset.y, 5); 219 | expect(attributes.position.array[2]).toBeCloseTo(offset.z, 5); 220 | expect(attributes.position.needsUpdate).toBe(true); 221 | }); 222 | 223 | test('applies curve modifiers when active', () => { 224 | normalizedConfig.opacityOverLifetime.isActive = true; 225 | normalizedConfig.sizeOverLifetime.isActive = true; 226 | 227 | applyModifiers({ 228 | delta: 1, 229 | generalData: { 230 | noise: { isActive: false } as Noise, 231 | startValues: { startOpacity: [1], startSize: [1] }, 232 | lifetimeValues: {}, 233 | linearVelocityData: undefined, 234 | } as unknown as GeneralData, 235 | normalizedConfig, 236 | attributes, 237 | particleLifetimePercentage: 0.5, 238 | particleIndex: 0, 239 | }); 240 | 241 | expect(attributes.colorA.array[0]).toBe(1.5); 242 | expect(attributes.colorA.needsUpdate).toBe(true); 243 | 244 | expect(attributes.size.array[0]).toBe(2); 245 | expect(attributes.size.needsUpdate).toBe(true); 246 | }); 247 | 248 | test('applies noise to attributes', () => { 249 | const noise = { 250 | isActive: true, 251 | strength: 1, 252 | positionAmount: 1, 253 | rotationAmount: 0.5, 254 | sizeAmount: 2, 255 | sampler: { get2: () => 0, get3: () => 0.5 }, 256 | } as Noise; 257 | 258 | applyModifiers({ 259 | delta: 1, 260 | generalData: { 261 | noise, 262 | startValues: {}, 263 | lifetimeValues: {}, 264 | } as GeneralData, 265 | normalizedConfig, 266 | attributes, 267 | particleLifetimePercentage: 0.5, 268 | particleIndex: 0, 269 | }); 270 | 271 | expect(attributes.position.array).toEqual( 272 | new Float32Array([0.075, 0.075, 0.075]) 273 | ); 274 | expect(attributes.position.needsUpdate).toBe(true); 275 | 276 | expect(attributes.rotation.array).toEqual(new Float32Array([0.0375])); 277 | expect(attributes.rotation.needsUpdate).toBe(true); 278 | 279 | expect(attributes.size.array).toEqual(new Float32Array([0.15])); 280 | expect(attributes.size.needsUpdate).toBe(true); 281 | }); 282 | 283 | test('applies noise to position with offset', () => { 284 | const noise = { 285 | isActive: true, 286 | strength: 1, 287 | positionAmount: 1, 288 | rotationAmount: 0, 289 | sizeAmount: 0, 290 | sampler: { get2: () => 0, get3: () => 0.5 }, 291 | offsets: [1], 292 | } as Noise; 293 | 294 | applyModifiers({ 295 | delta: 1, 296 | generalData: { 297 | noise, 298 | startValues: {}, 299 | lifetimeValues: {}, 300 | } as GeneralData, 301 | normalizedConfig, 302 | attributes, 303 | particleLifetimePercentage: 0.5, 304 | particleIndex: 0, 305 | }); 306 | 307 | expect(attributes.position.array).toEqual( 308 | new Float32Array([0.075, 0.075, 0.075]) 309 | ); 310 | expect(attributes.position.needsUpdate).toBe(true); 311 | }); 312 | 313 | test('should update rotation with rotationOverLifetime', () => { 314 | const _attributes = { 315 | ...attributes, 316 | rotation: { array: new Float32Array([0]), needsUpdate: false }, 317 | }; 318 | 319 | const lifetimeValues = { 320 | rotationOverLifetime: [5], 321 | }; 322 | 323 | applyModifiers({ 324 | delta: 1, 325 | generalData: { 326 | noise: { isActive: false } as Noise, 327 | startValues: {}, 328 | lifetimeValues, 329 | linearVelocityData: undefined, 330 | orbitalVelocityData: undefined, 331 | } as unknown as GeneralData, 332 | normalizedConfig, 333 | attributes: _attributes as any, 334 | particleLifetimePercentage: 0.5, 335 | particleIndex: 0, 336 | }); 337 | 338 | expect(_attributes.rotation.array[0]).toBeCloseTo(5 * 1 * 0.02, 5); 339 | expect(_attributes.rotation.needsUpdate).toBe(true); 340 | }); 341 | }); 342 | -------------------------------------------------------------------------------- /src/js/effects/three-particles/types.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { Gyroscope } from 'three/examples/jsm/misc/Gyroscope.js'; 3 | import { FBM } from 'three-noise/build/three-noise.module.js'; 4 | import { 5 | EmitFrom, 6 | LifeTimeCurve, 7 | Shape, 8 | SimulationSpace, 9 | TimeMode, 10 | } from './three-particles-enums.js'; 11 | 12 | /** 13 | * A fixed numerical value. 14 | * Used for properties that require a constant value. 15 | * 16 | * @example 17 | * const delay: Constant = 2; // Fixed delay of 2 seconds. 18 | */ 19 | export type Constant = number; 20 | 21 | /** 22 | * An object that defines a range for random number generation. 23 | * Contains `min` and `max` properties. 24 | * 25 | * @property min - The minimum value for the random range. 26 | * @property max - The maximum value for the random range. 27 | * 28 | * @example 29 | * const randomDelay: RandomBetweenTwoConstants = { min: 0.5, max: 2 }; // Random delay between 0.5 and 2 seconds. 30 | */ 31 | export type RandomBetweenTwoConstants = { 32 | min?: number; 33 | max?: number; 34 | }; 35 | 36 | /** 37 | * Base type for curves, containing common properties. 38 | * @property scale - A scaling factor for the curve. 39 | */ 40 | export type CurveBase = { 41 | scale?: number; 42 | }; 43 | 44 | /** 45 | * A function that defines how the value changes over time. 46 | * @param time - A normalized value between 0 and 1 representing the progress of the curve. 47 | * @returns The corresponding value based on the curve function. 48 | */ 49 | export type CurveFunction = (time: number) => number; 50 | 51 | /** 52 | * A Bézier curve point representing a control point. 53 | * @property x - The time (normalized between 0 and 1). 54 | * @property y - The value at that point. 55 | * @property percentage - (Optional) Normalized position within the curve (for additional flexibility). 56 | */ 57 | export type BezierPoint = { 58 | x: number; // Time (0 to 1) 59 | y: number; // Value 60 | percentage?: number; // Optional normalized position 61 | }; 62 | 63 | /** 64 | * A Bézier curve representation for controlling particle properties. 65 | * @property type - Specifies that this curve is of type `bezier`. 66 | * @property bezierPoints - An array of control points defining the Bézier curve. 67 | * @example 68 | * { 69 | * type: LifeTimeCurve.BEZIER, 70 | * bezierPoints: [ 71 | * { x: 0, y: 0.275, percentage: 0 }, 72 | * { x: 0.1666, y: 0.4416 }, 73 | * { x: 0.5066, y: 0.495, percentage: 0.5066 }, 74 | * { x: 1, y: 1, percentage: 1 } 75 | * ] 76 | * } 77 | */ 78 | export type BezierCurve = CurveBase & { 79 | type: LifeTimeCurve.BEZIER; 80 | bezierPoints: Array; 81 | }; 82 | 83 | /** 84 | * An easing curve representation using a custom function. 85 | * @property type - Specifies that this curve is of type `easing`. 86 | * @property curveFunction - A function defining how the value changes over time. 87 | * @example 88 | * { 89 | * type: LifeTimeCurve.EASING, 90 | * curveFunction: (time) => Math.sin(time * Math.PI) // Simple easing function 91 | * } 92 | */ 93 | export type EasingCurve = CurveBase & { 94 | type: LifeTimeCurve.EASING; 95 | curveFunction: CurveFunction; 96 | }; 97 | 98 | /** 99 | * A flexible curve representation that supports Bézier curves and easing functions. 100 | */ 101 | export type LifetimeCurve = BezierCurve | EasingCurve; 102 | 103 | /** 104 | * Represents a point in 3D space with optional x, y, and z coordinates. 105 | * Each coordinate is a number and is optional, allowing for partial definitions. 106 | * 107 | * @example 108 | * // A point with all coordinates defined 109 | * const point: Point3D = { x: 10, y: 20, z: 30 }; 110 | * 111 | * @example 112 | * // A point with only one coordinate defined 113 | * const point: Point3D = { x: 10 }; 114 | * 115 | * @default 116 | * // Default values are undefined for all coordinates. 117 | * const point: Point3D = {}; 118 | */ 119 | export type Point3D = { 120 | x?: number; 121 | y?: number; 122 | z?: number; 123 | }; 124 | 125 | /** 126 | * Represents a transform in 3D space, including position, rotation, and scale. 127 | * Each property is optional and represented as a THREE.Vector3 instance. 128 | * 129 | * - `position`: Defines the translation of an object in 3D space. 130 | * - `rotation`: Defines the rotation of an object in radians for each axis (x, y, z). 131 | * - `scale`: Defines the scale of an object along each axis. 132 | * 133 | * @example 134 | * // A transform with all properties defined 135 | * const transform: Transform = { 136 | * position: new THREE.Vector3(10, 20, 30), 137 | * rotation: new THREE.Vector3(Math.PI / 2, 0, 0), 138 | * scale: new THREE.Vector3(1, 1, 1), 139 | * }; 140 | * 141 | * @example 142 | * // A transform with only position defined 143 | * const transform: Transform = { 144 | * position: new THREE.Vector3(5, 5, 5), 145 | * }; 146 | * 147 | * @default 148 | * // Default values are undefined for all properties. 149 | * const transform: Transform = {}; 150 | */ 151 | export type Transform = { 152 | position?: THREE.Vector3; 153 | rotation?: THREE.Vector3; 154 | scale?: THREE.Vector3; 155 | }; 156 | 157 | export type Rgb = { 158 | r?: number; 159 | g?: number; 160 | b?: number; 161 | }; 162 | 163 | export type MinMaxColor = { 164 | min?: Rgb; 165 | max?: Rgb; 166 | }; 167 | 168 | /** 169 | * Defines the emission behavior of the particles. 170 | * Supports rates defined over time or distance using constant values, random ranges, or curves (Bézier or easing). 171 | * 172 | * @default 173 | * rateOverTime: 10.0 174 | * rateOverDistance: 0.0 175 | * 176 | * @example 177 | * // Rate over time as a constant value 178 | * rateOverTime: 10; 179 | * 180 | * // Rate over time as a random range 181 | * rateOverTime: { min: 5, max: 15 }; 182 | * 183 | * // Rate over time using a Bézier curve 184 | * rateOverTime: { 185 | * type: 'bezier', 186 | * bezierPoints: [ 187 | * { x: 0, y: 0, percentage: 0 }, 188 | * { x: 0.5, y: 50 }, 189 | * { x: 1, y: 100, percentage: 1 } 190 | * ], 191 | * scale: 1 192 | * }; 193 | * 194 | * // Rate over distance as a constant value 195 | * rateOverDistance: 2; 196 | * 197 | * // Rate over distance as a random range 198 | * rateOverDistance: { min: 1, max: 3 }; 199 | * 200 | * // Rate over distance using an easing curve 201 | * rateOverDistance: { 202 | * type: 'easing', 203 | * curveFunction: (distance) => Math.sin(distance), 204 | * scale: 0.5 205 | * }; 206 | */ 207 | export type Emission = { 208 | rateOverTime?: Constant | RandomBetweenTwoConstants | LifetimeCurve; 209 | rateOverDistance?: Constant | RandomBetweenTwoConstants | LifetimeCurve; 210 | }; 211 | 212 | /** 213 | * Configuration for a sphere shape used in particle systems. 214 | * 215 | * @property radius - The radius of the sphere. 216 | * @property radiusThickness - The thickness of the sphere's shell (0 to 1, where 1 is solid). 217 | * @property arc - The angular arc of the sphere (in radians). 218 | * 219 | * @example 220 | * const sphere: Sphere = { 221 | * radius: 5, 222 | * radiusThickness: 0.8, 223 | * arc: Math.PI, 224 | * }; 225 | */ 226 | export type Sphere = { 227 | radius?: number; 228 | radiusThickness?: number; 229 | arc?: number; 230 | }; 231 | 232 | /** 233 | * Configuration for a cone shape used in particle systems. 234 | * 235 | * @property angle - The angle of the cone (in radians). 236 | * @property radius - The radius of the cone's base. 237 | * @property radiusThickness - The thickness of the cone's base (0 to 1, where 1 is solid). 238 | * @property arc - The angular arc of the cone's base (in radians). 239 | * 240 | * @example 241 | * const cone: Cone = { 242 | * angle: Math.PI / 4, 243 | * radius: 10, 244 | * radiusThickness: 0.5, 245 | * arc: Math.PI * 2, 246 | * }; 247 | */ 248 | export type Cone = { 249 | angle?: number; 250 | radius?: number; 251 | radiusThickness?: number; 252 | arc?: number; 253 | }; 254 | 255 | /** 256 | * Configuration for a circle shape used in particle systems. 257 | * 258 | * @property radius - The radius of the circle. 259 | * @property radiusThickness - The thickness of the circle's shell (0 to 1, where 1 is solid). 260 | * @property arc - The angular arc of the circle (in radians). 261 | * 262 | * @example 263 | * const circle: Circle = { 264 | * radius: 10, 265 | * radiusThickness: 0.5, 266 | * arc: Math.PI, 267 | * }; 268 | */ 269 | export type Circle = { 270 | radius?: number; 271 | radiusThickness?: number; 272 | arc?: number; 273 | }; 274 | 275 | /** 276 | * Configuration for a rectangle shape used in particle systems. 277 | * 278 | * @property rotation - The rotation of the rectangle as a 3D point (in radians for each axis). 279 | * @property scale - The scale of the rectangle as a 3D point. 280 | * 281 | * @example 282 | * const rectangle: Rectangle = { 283 | * rotation: { x: Math.PI / 4, y: 0, z: 0 }, 284 | * scale: { x: 10, y: 5, z: 1 }, 285 | * }; 286 | */ 287 | export type Rectangle = { 288 | rotation?: Point3D; 289 | scale?: Point3D; 290 | }; 291 | 292 | /** 293 | * Configuration for a box shape used in particle systems. 294 | * 295 | * @property scale - The scale of the box as a 3D point. 296 | * @property emitFrom - Specifies where particles are emitted from within the box. 297 | * 298 | * @example 299 | * const box: Box = { 300 | * scale: { x: 10, y: 10, z: 10 }, 301 | * emitFrom: EmitFrom.EDGE, 302 | * }; 303 | */ 304 | export type Box = { 305 | scale?: Point3D; 306 | emitFrom?: EmitFrom; 307 | }; 308 | 309 | /** 310 | * Configuration for defining a 3D shape used in particle systems. 311 | * Specifies the shape type and its parameters, including spheres, cones, circles, rectangles, and boxes. 312 | * 313 | * @property shape - The type of the shape to be used. 314 | * @property sphere - Configuration for a sphere shape. 315 | * @property cone - Configuration for a cone shape. 316 | * @property circle - Configuration for a circle shape. 317 | * @property rectangle - Configuration for a rectangle shape. 318 | * @property box - Configuration for a box shape. 319 | * 320 | * @example 321 | * const shapeConfig: ShapeConfig = { 322 | * shape: Shape.SPHERE, 323 | * sphere: { 324 | * radius: 5, 325 | * radiusThickness: 0.8, 326 | * arc: Math.PI, 327 | * }, 328 | * }; 329 | */ 330 | export type ShapeConfig = { 331 | shape?: Shape; 332 | sphere?: Sphere; 333 | cone?: Cone; 334 | circle?: Circle; 335 | rectangle?: Rectangle; 336 | box?: Box; 337 | }; 338 | 339 | /** 340 | * Defines the texture sheet animation settings for particles. 341 | * Allows configuring the animation frames, timing mode, frames per second, and the starting frame. 342 | * 343 | * @default 344 | * tiles: new THREE.Vector2(1.0, 1.0) 345 | * timeMode: TimeMode.LIFETIME 346 | * fps: 30.0 347 | * startFrame: 0 348 | * 349 | * @example 350 | * // Basic configuration with default values 351 | * textureSheetAnimation: { 352 | * tiles: new THREE.Vector2(1.0, 1.0), 353 | * timeMode: TimeMode.LIFETIME, 354 | * fps: 30.0, 355 | * startFrame: 0 356 | * }; 357 | * 358 | * // Custom configuration 359 | * textureSheetAnimation: { 360 | * tiles: new THREE.Vector2(4, 4), // 4x4 grid of animation tiles 361 | * timeMode: TimeMode.SPEED, 362 | * fps: 60.0, 363 | * startFrame: { min: 0, max: 15 } // Random start frame between 0 and 15 364 | * }; 365 | */ 366 | export type TextureSheetAnimation = { 367 | tiles?: THREE.Vector2; 368 | timeMode?: TimeMode; 369 | fps?: number; 370 | startFrame?: Constant | RandomBetweenTwoConstants; 371 | }; 372 | 373 | /** 374 | * Configuration for the particle system renderer, controlling blending, transparency, depth, and background color behavior. 375 | * 376 | * @property blending - Defines the blending mode for the particle system (e.g., additive blending). 377 | * @property discardBackgroundColor - Whether to discard particles that match the background color. 378 | * @property backgroundColorTolerance - The tolerance for matching the background color when `discardBackgroundColor` is true. 379 | * @property backgroundColor - The background color as an RGB value, used when `discardBackgroundColor` is enabled. 380 | * @property transparent - Whether the particle system uses transparency. 381 | * @property depthTest - Whether to enable depth testing for particles (determines if particles are rendered behind or in front of other objects). 382 | * @property depthWrite - Whether to write depth information for the particles (affects sorting and rendering order). 383 | * 384 | * @example 385 | * // A renderer configuration with additive blending and transparent particles 386 | * const renderer: Renderer = { 387 | * blending: THREE.AdditiveBlending, 388 | * discardBackgroundColor: true, 389 | * backgroundColorTolerance: 0.1, 390 | * backgroundColor: { r: 0, g: 0, b: 0 }, 391 | * transparent: true, 392 | * depthTest: true, 393 | * depthWrite: false, 394 | * }; 395 | * 396 | * @default 397 | * // Default values for the renderer configuration 398 | * const renderer: Renderer = { 399 | * blending: THREE.NormalBlending, 400 | * discardBackgroundColor: false, 401 | * backgroundColorTolerance: 1.0, 402 | * backgroundColor: { r: 0, g: 0, b: 0 }, 403 | * transparent: false, 404 | * depthTest: true, 405 | * depthWrite: true, 406 | * }; 407 | */ 408 | export type Renderer = { 409 | blending: THREE.Blending; 410 | discardBackgroundColor: boolean; 411 | backgroundColorTolerance: number; 412 | backgroundColor: Rgb; 413 | transparent: boolean; 414 | depthTest: boolean; 415 | depthWrite: boolean; 416 | }; 417 | 418 | /** 419 | * Configuration for noise effects applied to particles in a particle system. 420 | * Noise can affect particle position, rotation, and size dynamically. 421 | * 422 | * @property isActive - Whether noise is enabled for the particle system. 423 | * @property strength - The overall strength of the noise effect. 424 | * @property positionAmount - The amount of noise applied to particle positions. 425 | * @property rotationAmount - The amount of noise applied to particle rotations. 426 | * @property sizeAmount - The amount of noise applied to particle sizes. 427 | * @property sampler - An optional noise sampler (e.g., FBM for fractal Brownian motion) to generate noise values. 428 | * @property offsets - An optional array of offsets to randomize noise generation per particle. 429 | * 430 | * @example 431 | * // A noise configuration with position and rotation noise 432 | * const noise: Noise = { 433 | * isActive: true, 434 | * strength: 0.5, 435 | * positionAmount: 1.0, 436 | * rotationAmount: 0.3, 437 | * sizeAmount: 0.0, 438 | * sampler: new FBM(), 439 | * offsets: [0.1, 0.2, 0.3], 440 | * }; 441 | * 442 | * @default 443 | * // Default values for noise configuration 444 | * const noise: Noise = { 445 | * isActive: false, 446 | * strength: 1.0, 447 | * positionAmount: 0.0, 448 | * rotationAmount: 0.0, 449 | * sizeAmount: 0.0, 450 | * sampler: undefined, 451 | * offsets: undefined, 452 | * }; 453 | */ 454 | export type Noise = { 455 | isActive: boolean; 456 | strength: number; 457 | positionAmount: number; 458 | rotationAmount: number; 459 | sizeAmount: number; 460 | sampler?: FBM; 461 | offsets?: Array; 462 | }; 463 | 464 | export type NoiseConfig = { 465 | isActive: boolean; 466 | useRandomOffset: boolean; 467 | strength: number; 468 | frequency: number; 469 | octaves: number; 470 | positionAmount: number; 471 | rotationAmount: number; 472 | sizeAmount: number; 473 | }; 474 | 475 | /** 476 | * Defines the velocity of particles over their lifetime, allowing for linear and orbital velocity (in degrees) adjustments. 477 | * Supports constant values, random ranges, or curves (Bézier or easing) for each axis. 478 | * 479 | * @default 480 | * isActive: false 481 | * linear: { x: 0.0, y: 0.0, z: 0.0 } 482 | * orbital: { x: 0.0, y: 0.0, z: 0.0 } 483 | * 484 | * @example 485 | * // Linear velocity with a constant value 486 | * linear: { x: 1, y: 0, z: -0.5 }; 487 | * 488 | * // Linear velocity with random ranges 489 | * linear: { 490 | * x: { min: -1, max: 1 }, 491 | * y: { min: 0, max: 2 } 492 | * }; 493 | * 494 | * // Linear velocity using a Bézier curve 495 | * linear: { 496 | * z: { 497 | * type: 'bezier', 498 | * bezierPoints: [ 499 | * { x: 0, y: 0, percentage: 0 }, 500 | * { x: 0.5, y: 2 }, 501 | * { x: 1, y: 10, percentage: 1 } 502 | * ], 503 | * scale: 2 504 | * } 505 | * }; 506 | * 507 | * // Orbital velocity with a constant value 508 | * orbital: { x: 3, y: 5, z: 0 }; 509 | * 510 | * // Orbital velocity using an easing curve 511 | * orbital: { 512 | * x: { 513 | * type: 'easing', 514 | * curveFunction: (time) => Math.sin(time * Math.PI), 515 | * scale: 1.5 516 | * } 517 | * }; 518 | */ 519 | export type VelocityOverLifetime = { 520 | isActive: boolean; 521 | linear: { 522 | x?: Constant | RandomBetweenTwoConstants | LifetimeCurve; 523 | y?: Constant | RandomBetweenTwoConstants | LifetimeCurve; 524 | z?: Constant | RandomBetweenTwoConstants | LifetimeCurve; 525 | }; 526 | orbital: { 527 | x?: Constant | RandomBetweenTwoConstants | LifetimeCurve; 528 | y?: Constant | RandomBetweenTwoConstants | LifetimeCurve; 529 | z?: Constant | RandomBetweenTwoConstants | LifetimeCurve; 530 | }; 531 | }; 532 | 533 | /** 534 | * Configuration object for the particle system. 535 | * Defines all aspects of the particle system, including its appearance, behavior, and runtime events. 536 | */ 537 | export type ParticleSystemConfig = { 538 | /** 539 | * Defines the position, rotation, and scale of the particle system. 540 | * 541 | * @see Transform 542 | * @default 543 | * transform: { 544 | * position: new THREE.Vector3(), 545 | * rotation: new THREE.Vector3(), 546 | * scale: new THREE.Vector3(1, 1, 1), 547 | * } 548 | */ 549 | transform?: Transform; 550 | 551 | /** 552 | * Duration of the particle system in seconds. 553 | * Must be a positive value. 554 | * @default 5.0 555 | * @example 556 | * const duration: number = 5; // System runs for 5 seconds. 557 | */ 558 | duration?: number; 559 | 560 | /** 561 | * Indicates whether the system should loop after finishing. 562 | * @default true 563 | * @example 564 | * looping: true; // System loops continuously. 565 | */ 566 | looping?: boolean; 567 | 568 | /** 569 | * Delay before the particle system starts emitting particles. 570 | * Supports a fixed value (`Constant`) or a random range (`RandomBetweenTwoConstants`). 571 | * @default 0.0 572 | * @example 573 | * startDelay: 2; // Fixed 2-second delay. 574 | * startDelay: { min: 0.5, max: 2 }; // Random delay between 0.5 and 2 seconds. 575 | */ 576 | startDelay?: Constant | RandomBetweenTwoConstants; 577 | 578 | /** 579 | * Initial lifetime of the particles. 580 | * Supports constant value, random range, or curves (Bézier or easing). 581 | * @default 5.0 582 | * @example 583 | * // Constant 3 seconds. 584 | * startLifetime: 3; 585 | * 586 | * // Random range between 1 and 4 seconds. 587 | * startLifetime: { min: 1, max: 4 }; 588 | * 589 | * // Bézier curve example with scaling. 590 | * startLifetime: { 591 | * type: LifeTimeCurve.BEZIER, 592 | * bezierPoints: [ 593 | * { x: 0, y: 0.275, percentage: 0 }, 594 | * { x: 0.5, y: 0.5 }, 595 | * { x: 1, y: 1, percentage: 1 } 596 | * ], 597 | * scale: 2 598 | * }; 599 | * 600 | * // Easing curve example with scaling. 601 | * startLifetime: { 602 | * type: LifeTimeCurve.EASING, 603 | * curveFunction: (time) => Math.sin(time * Math.PI), 604 | * scale: 0.5 605 | * }; 606 | */ 607 | startLifetime?: Constant | RandomBetweenTwoConstants | LifetimeCurve; 608 | 609 | /** 610 | * Defines the initial speed of the particles. 611 | * Supports constant values, random ranges, or curves (Bézier or easing). 612 | * @default 1.0 613 | * @example 614 | * // Constant value 615 | * startSpeed: 3; 616 | * 617 | * // Random range 618 | * startSpeed: { min: 1, max: 4 }; 619 | * 620 | * // Bézier curve example with scaling. 621 | * startSpeed: { 622 | * type: 'bezier', 623 | * bezierPoints: [ 624 | * { x: 0, y: 0.275, percentage: 0 }, 625 | * { x: 0.5, y: 0.5 }, 626 | * { x: 1, y: 1, percentage: 1 } 627 | * ], 628 | * scale: 2 629 | * }; 630 | * 631 | * // Easing curve example with scaling. 632 | * startSpeed: { 633 | * type: 'easing', 634 | * curveFunction: (time) => Math.sin(time * Math.PI), 635 | * scale: 1.5 636 | * }; 637 | */ 638 | startSpeed?: Constant | RandomBetweenTwoConstants | LifetimeCurve; 639 | 640 | /** 641 | * Defines the initial size of the particles. 642 | * Supports constant values, random ranges, or curves (Bézier or easing). 643 | * @default 1.0 644 | * @example 645 | * // Constant value 646 | * startSize: 3; 647 | * 648 | * // Random range 649 | * startSize: { min: 1, max: 4 }; 650 | * 651 | * // Bézier curve example with scaling. 652 | * startSize: { 653 | * type: 'bezier', 654 | * bezierPoints: [ 655 | * { x: 0, y: 0.275, percentage: 0 }, 656 | * { x: 0.5, y: 0.5 }, 657 | * { x: 1, y: 1, percentage: 1 } 658 | * ], 659 | * scale: 2 660 | * }; 661 | * 662 | * // Easing curve example with scaling. 663 | * startSize: { 664 | * type: 'easing', 665 | * curveFunction: (time) => Math.sin(time * Math.PI), 666 | * scale: 1.5 667 | * }; 668 | */ 669 | startSize?: Constant | RandomBetweenTwoConstants | LifetimeCurve; 670 | 671 | /** 672 | * Defines the initial opacity of the particles. 673 | * Supports constant values, random ranges, or curves (Bézier or easing). 674 | * @default 1.0 675 | * @example 676 | * // Constant value 677 | * startOpacity: 3; 678 | * 679 | * // Random range 680 | * startOpacity: { min: 1, max: 4 }; 681 | * 682 | * // Bézier curve example with scaling. 683 | * startOpacity: { 684 | * type: 'bezier', 685 | * bezierPoints: [ 686 | * { x: 0, y: 0.275, percentage: 0 }, 687 | * { x: 0.5, y: 0.5 }, 688 | * { x: 1, y: 1, percentage: 1 } 689 | * ], 690 | * scale: 2 691 | * }; 692 | * 693 | * // Easing curve example with scaling. 694 | * startOpacity: { 695 | * type: 'easing', 696 | * curveFunction: (time) => Math.sin(time * Math.PI), 697 | * scale: 1.5 698 | * }; 699 | */ 700 | startOpacity?: Constant | RandomBetweenTwoConstants | LifetimeCurve; 701 | 702 | /** 703 | * Defines the initial rotation of the particles in degrees. 704 | * Supports constant values, random ranges, or curves (Bézier or easing). 705 | * @default 0.0 706 | * @example 707 | * // Constant value 708 | * startRotation: 3; 709 | * 710 | * // Random range 711 | * startRotation: { min: 1, max: 4 }; 712 | * 713 | * // Bézier curve example with scaling. 714 | * startRotation: { 715 | * type: 'bezier', 716 | * bezierPoints: [ 717 | * { x: 0, y: 0.275, percentage: 0 }, 718 | * { x: 0.5, y: 0.5 }, 719 | * { x: 1, y: 1, percentage: 1 } 720 | * ], 721 | * scale: 2 722 | * }; 723 | * 724 | * // Easing curve example with scaling. 725 | * startRotation: { 726 | * type: 'easing', 727 | * curveFunction: (time) => Math.sin(time * Math.PI), 728 | * scale: 1.5 729 | * }; 730 | */ 731 | startRotation?: Constant | RandomBetweenTwoConstants | LifetimeCurve; 732 | 733 | /** 734 | * Initial color of the particles. 735 | * Supports a min-max range for color interpolation. 736 | * 737 | * @default 738 | * startColor: { 739 | * min: { r: 1.0, g: 1.0, b: 1.0 }, 740 | * max: { r: 1.0, g: 1.0, b: 1.0 }, 741 | * } 742 | */ 743 | startColor?: MinMaxColor; 744 | 745 | /** 746 | * Defines the gravity strength applied to particles. 747 | * This value affects the downward acceleration of particles over time. 748 | * 749 | * @default 0.0 750 | * 751 | * @example 752 | * // No gravity 753 | * gravity: 0; 754 | * 755 | * // Moderate gravity 756 | * gravity: 9.8; // Similar to Earth's gravity 757 | * 758 | * // Strong gravity 759 | * gravity: 20.0; 760 | */ 761 | gravity?: Constant; 762 | 763 | /** 764 | * Defines the simulation space in which particles are simulated. 765 | * Determines whether the particles move relative to the local object space or the world space. 766 | * 767 | * @default SimulationSpace.LOCAL 768 | * 769 | * @example 770 | * // Simulate particles in local space (default) 771 | * simulationSpace: SimulationSpace.LOCAL; 772 | * 773 | * // Simulate particles in world space 774 | * simulationSpace: SimulationSpace.WORLD; 775 | */ 776 | simulationSpace?: SimulationSpace; 777 | 778 | /** 779 | * Defines the maximum number of particles allowed in the system. 780 | * This value limits the total number of active particles at any given time. 781 | * 782 | * @default 100.0 783 | * 784 | * @example 785 | * // Default value 786 | * maxParticles: 100.0; 787 | * 788 | * // Increase the maximum number of particles 789 | * maxParticles: 500.0; 790 | * 791 | * // Limit to a small number of particles 792 | * maxParticles: 10.0; 793 | */ 794 | maxParticles?: Constant; 795 | 796 | /** 797 | * Defines the particle emission settings. 798 | * Configures the emission rate over time and distance. 799 | * 800 | * @see Emission 801 | * @default 802 | * emission: { 803 | * rateOverTime: 10.0, 804 | * rateOverDistance: 0.0, 805 | * } 806 | */ 807 | emission?: Emission; 808 | 809 | /** 810 | * Configuration for the emitter shape. 811 | * Determines the shape and parameters for particle emission. 812 | * 813 | * @see ShapeConfig 814 | */ 815 | shape?: ShapeConfig; 816 | 817 | /** 818 | * Defines the texture used for rendering particles. 819 | * This texture is applied to all particles in the system, and can be used to control their appearance. 820 | * 821 | * @default undefined 822 | * 823 | * @example 824 | * // Using a predefined texture 825 | * map: new THREE.TextureLoader().load('path/to/texture.png'); 826 | * 827 | * // No texture (default behavior) 828 | * map: undefined; 829 | */ 830 | map?: THREE.Texture; 831 | 832 | /** 833 | * Renderer configuration for blending, transparency, and depth testing. 834 | * 835 | * @see Renderer 836 | * @default 837 | * renderer: { 838 | * blending: THREE.NormalBlending, 839 | * discardBackgroundColor: false, 840 | * backgroundColorTolerance: 1.0, 841 | * backgroundColor: { r: 1.0, g: 1.0, b: 1.0 }, 842 | * transparent: true, 843 | * depthTest: true, 844 | * depthWrite: false 845 | * } 846 | */ 847 | renderer?: Renderer; 848 | 849 | /** 850 | * Defines the velocity settings of particles over their lifetime. 851 | * Configures both linear and orbital velocity changes. 852 | * 853 | * @see VelocityOverLifetime 854 | * @default 855 | * velocityOverLifetime: { 856 | * isActive: false, 857 | * linear: { 858 | * x: 0, 859 | * y: 0, 860 | * z: 0, 861 | * }, 862 | * orbital: { 863 | * x: 0, 864 | * y: 0, 865 | * z: 0, 866 | * }, 867 | * } 868 | */ 869 | velocityOverLifetime?: VelocityOverLifetime; 870 | 871 | /** 872 | * Controls the size of particles over their lifetime. 873 | * The size can be adjusted using a lifetime curve (Bézier or other supported types). 874 | * 875 | * @default 876 | * sizeOverLifetime: { 877 | * isActive: false, 878 | * lifetimeCurve: { 879 | * type: LifeTimeCurve.BEZIER, 880 | * scale: 1, 881 | * bezierPoints: [ 882 | * { x: 0, y: 0, percentage: 0 }, 883 | * { x: 1, y: 1, percentage: 1 }, 884 | * ], 885 | * }, 886 | * } 887 | */ 888 | sizeOverLifetime?: { 889 | isActive: boolean; 890 | lifetimeCurve: LifetimeCurve; 891 | }; 892 | 893 | /** 894 | * Controls the opacity of particles over their lifetime. 895 | * The opacity can be adjusted using a lifetime curve (Bézier or other supported types). 896 | * 897 | * @default 898 | * opacityOverLifetime: { 899 | * isActive: false, 900 | * lifetimeCurve: { 901 | * type: LifeTimeCurve.BEZIER, 902 | * scale: 1, 903 | * bezierPoints: [ 904 | * { x: 0, y: 0, percentage: 0 }, 905 | * { x: 1, y: 1, percentage: 1 }, 906 | * ], 907 | * }, 908 | * } 909 | */ 910 | opacityOverLifetime?: { 911 | isActive: boolean; 912 | lifetimeCurve: LifetimeCurve; 913 | }; 914 | 915 | /** 916 | * Controls the rotation of particles over their lifetime. 917 | * The rotation can be randomized between two constants, and the feature can be toggled on or off. 918 | * 919 | * @default 920 | * rotationOverLifetime: { 921 | * isActive: false, 922 | * min: 0.0, 923 | * max: 0.0, 924 | * } 925 | */ 926 | rotationOverLifetime?: { isActive: boolean } & RandomBetweenTwoConstants; 927 | 928 | /** 929 | * Noise configuration affecting position, rotation, and size. 930 | * 931 | * @see NoiseConfig 932 | * @default 933 | * noise: { 934 | * isActive: false, 935 | * useRandomOffset: false, 936 | * strength: 1.0, 937 | * frequency: 0.5, 938 | * octaves: 1, 939 | * positionAmount: 1.0, 940 | * rotationAmount: 0.0, 941 | * sizeAmount: 0.0, 942 | * } 943 | */ 944 | noise?: NoiseConfig; 945 | 946 | /** 947 | * Configures the texture sheet animation settings for particles. 948 | * Controls how textures are animated over the lifetime of particles. 949 | * 950 | * @see TextureSheetAnimation 951 | * @default 952 | * textureSheetAnimation: { 953 | * tiles: new THREE.Vector2(1.0, 1.0), 954 | * timeMode: TimeMode.LIFETIME, 955 | * fps: 30.0, 956 | * startFrame: 0, 957 | * } 958 | */ 959 | textureSheetAnimation?: TextureSheetAnimation; 960 | 961 | /** 962 | * Called on every update frame with particle system data. 963 | */ 964 | onUpdate?: (data: { 965 | particleSystem: THREE.Points; 966 | delta: number; 967 | elapsed: number; 968 | lifetime: number; 969 | iterationCount: number; 970 | }) => void; 971 | 972 | /** 973 | * Called when the system completes an iteration. 974 | */ 975 | onComplete?: () => void; 976 | }; 977 | 978 | export type NormalizedParticleSystemConfig = Required; 979 | 980 | export type GeneralData = { 981 | particleSystemId: number; 982 | normalizedLifetimePercentage: number; 983 | creationTimes: Array; 984 | distanceFromLastEmitByDistance: number; 985 | lastWorldPosition: THREE.Vector3; 986 | currentWorldPosition: THREE.Vector3; 987 | worldPositionChange: THREE.Vector3; 988 | wrapperQuaternion: THREE.Quaternion; 989 | lastWorldQuaternion: THREE.Quaternion; 990 | worldQuaternion: THREE.Quaternion; 991 | worldEuler: THREE.Euler; 992 | gravityVelocity: THREE.Vector3; 993 | startValues: Record>; 994 | linearVelocityData?: Array<{ 995 | speed: THREE.Vector3; 996 | valueModifiers: { 997 | x?: CurveFunction; 998 | y?: CurveFunction; 999 | z?: CurveFunction; 1000 | }; 1001 | }>; 1002 | orbitalVelocityData?: Array<{ 1003 | speed: THREE.Vector3; 1004 | positionOffset: THREE.Vector3; 1005 | valueModifiers: { 1006 | x?: CurveFunction; 1007 | y?: CurveFunction; 1008 | z?: CurveFunction; 1009 | }; 1010 | }>; 1011 | lifetimeValues: Record>; 1012 | noise: Noise; 1013 | isEnabled: boolean; 1014 | }; 1015 | 1016 | export type ParticleSystemInstance = { 1017 | particleSystem: THREE.Points; 1018 | wrapper?: Gyroscope; 1019 | generalData: GeneralData; 1020 | onUpdate: (data: { 1021 | particleSystem: THREE.Points; 1022 | delta: number; 1023 | elapsed: number; 1024 | lifetime: number; 1025 | normalizedLifetime: number; 1026 | iterationCount: number; 1027 | }) => void; 1028 | onComplete: (data: { particleSystem: THREE.Points }) => void; 1029 | creationTime: number; 1030 | lastEmissionTime: number; 1031 | duration: number; 1032 | looping: boolean; 1033 | simulationSpace: SimulationSpace; 1034 | gravity: number; 1035 | emission: Emission; 1036 | normalizedConfig: NormalizedParticleSystemConfig; 1037 | iterationCount: number; 1038 | velocities: Array; 1039 | deactivateParticle: (particleIndex: number) => void; 1040 | activateParticle: (data: { 1041 | particleIndex: number; 1042 | activationTime: number; 1043 | position: Required; 1044 | }) => void; 1045 | }; 1046 | 1047 | /** 1048 | * Represents a particle system instance, providing methods to control and manage its lifecycle. 1049 | * 1050 | * @property instance - The underlying Three.js `Points` object or a `Gyroscope` used for particle rendering. 1051 | * @property resumeEmitter - Resumes the particle emitter, allowing particles to be emitted again. 1052 | * @property pauseEmitter - Pauses the particle emitter, stopping any new particles from being emitted. 1053 | * @property dispose - Disposes of the particle system, cleaning up resources to free memory. 1054 | * 1055 | * @example 1056 | * const particleSystem: ParticleSystem = { 1057 | * instance: new THREE.Points(geometry, material), 1058 | * resumeEmitter: () => { /* resume logic * / }, 1059 | * pauseEmitter: () => { /* pause logic * / }, 1060 | * dispose: () => { /* cleanup logic * / }, 1061 | * }; 1062 | * 1063 | * particleSystem.pauseEmitter(); // Stop particle emission 1064 | * particleSystem.resumeEmitter(); // Resume particle emission 1065 | * particleSystem.dispose(); // Cleanup the particle system 1066 | */ 1067 | export type ParticleSystem = { 1068 | instance: THREE.Points | Gyroscope; 1069 | resumeEmitter: () => void; 1070 | pauseEmitter: () => void; 1071 | dispose: () => void; 1072 | }; 1073 | 1074 | /** 1075 | * Data representing the current cycle of the particle system's update loop. 1076 | * 1077 | * @property now - The current timestamp in milliseconds. 1078 | * @property delta - The time elapsed since the last update, in seconds. 1079 | * @property elapsed - The total time elapsed since the particle system started, in seconds. 1080 | * 1081 | * @example 1082 | * const cycleData: CycleData = { 1083 | * now: performance.now(), 1084 | * delta: 0.016, // 16ms frame time 1085 | * elapsed: 1.25, // 1.25 seconds since start 1086 | * }; 1087 | */ 1088 | export type CycleData = { 1089 | now: number; 1090 | delta: number; 1091 | elapsed: number; 1092 | }; 1093 | -------------------------------------------------------------------------------- /src/js/effects/three-particles/three-particles.ts: -------------------------------------------------------------------------------- 1 | import { ObjectUtils } from '@newkrok/three-utils'; 2 | import * as THREE from 'three'; 3 | import { Gyroscope } from 'three/examples/jsm/misc/Gyroscope.js'; 4 | import { FBM } from 'three-noise/build/three-noise.module.js'; 5 | import ParticleSystemFragmentShader from './shaders/particle-system-fragment-shader.glsl.js'; 6 | import ParticleSystemVertexShader from './shaders/particle-system-vertex-shader.glsl.js'; 7 | import { removeBezierCurveFunction } from './three-particles-bezier.js'; 8 | import { 9 | EmitFrom, 10 | LifeTimeCurve, 11 | Shape, 12 | SimulationSpace, 13 | TimeMode, 14 | } from './three-particles-enums'; 15 | import { applyModifiers } from './three-particles-modifiers.js'; 16 | import { 17 | calculateRandomPositionAndVelocityOnBox, 18 | calculateRandomPositionAndVelocityOnCircle, 19 | calculateRandomPositionAndVelocityOnCone, 20 | calculateRandomPositionAndVelocityOnRectangle, 21 | calculateRandomPositionAndVelocityOnSphere, 22 | calculateValue, 23 | getCurveFunctionFromConfig, 24 | isLifeTimeCurve, 25 | createDefaultParticleTexture, 26 | } from './three-particles-utils.js'; 27 | 28 | import { 29 | Constant, 30 | CycleData, 31 | GeneralData, 32 | LifetimeCurve, 33 | NormalizedParticleSystemConfig, 34 | ParticleSystem, 35 | ParticleSystemConfig, 36 | ParticleSystemInstance, 37 | Point3D, 38 | RandomBetweenTwoConstants, 39 | ShapeConfig, 40 | } from './types.js'; 41 | 42 | export * from './types.js'; 43 | 44 | let _particleSystemId = 0; 45 | let createdParticleSystems: Array = []; 46 | 47 | export const blendingMap = { 48 | 'THREE.NoBlending': THREE.NoBlending, 49 | 'THREE.NormalBlending': THREE.NormalBlending, 50 | 'THREE.AdditiveBlending': THREE.AdditiveBlending, 51 | 'THREE.SubtractiveBlending': THREE.SubtractiveBlending, 52 | 'THREE.MultiplyBlending': THREE.MultiplyBlending, 53 | }; 54 | 55 | export const getDefaultParticleSystemConfig = () => 56 | JSON.parse(JSON.stringify(DEFAULT_PARTICLE_SYSTEM_CONFIG)); 57 | 58 | const DEFAULT_PARTICLE_SYSTEM_CONFIG: ParticleSystemConfig = { 59 | transform: { 60 | position: new THREE.Vector3(), 61 | rotation: new THREE.Vector3(), 62 | scale: new THREE.Vector3(1, 1, 1), 63 | }, 64 | duration: 5.0, 65 | looping: true, 66 | startDelay: 0, 67 | startLifetime: 5.0, 68 | startSpeed: 1.0, 69 | startSize: 1.0, 70 | startOpacity: 1.0, 71 | startRotation: 0.0, 72 | startColor: { 73 | min: { r: 1.0, g: 1.0, b: 1.0 }, 74 | max: { r: 1.0, g: 1.0, b: 1.0 }, 75 | }, 76 | gravity: 0.0, 77 | simulationSpace: SimulationSpace.LOCAL, 78 | maxParticles: 100.0, 79 | emission: { 80 | rateOverTime: 10.0, 81 | rateOverDistance: 0.0, 82 | }, 83 | shape: { 84 | shape: Shape.SPHERE, 85 | sphere: { 86 | radius: 1.0, 87 | radiusThickness: 1.0, 88 | arc: 360.0, 89 | }, 90 | cone: { 91 | angle: 25.0, 92 | radius: 1.0, 93 | radiusThickness: 1.0, 94 | arc: 360.0, 95 | }, 96 | circle: { 97 | radius: 1.0, 98 | radiusThickness: 1.0, 99 | arc: 360.0, 100 | }, 101 | rectangle: { 102 | rotation: { x: 0.0, y: 0.0 }, // TODO: add z rotation 103 | scale: { x: 1.0, y: 1.0 }, 104 | }, 105 | box: { 106 | scale: { x: 1.0, y: 1.0, z: 1.0 }, 107 | emitFrom: EmitFrom.VOLUME, 108 | }, 109 | }, 110 | map: undefined, 111 | renderer: { 112 | blending: THREE.NormalBlending, 113 | discardBackgroundColor: false, 114 | backgroundColorTolerance: 1.0, 115 | backgroundColor: { r: 1.0, g: 1.0, b: 1.0 }, 116 | transparent: true, 117 | depthTest: true, 118 | depthWrite: false, 119 | }, 120 | velocityOverLifetime: { 121 | isActive: false, 122 | linear: { 123 | x: 0, 124 | y: 0, 125 | z: 0, 126 | }, 127 | orbital: { 128 | x: 0, 129 | y: 0, 130 | z: 0, 131 | }, 132 | }, 133 | sizeOverLifetime: { 134 | isActive: false, 135 | lifetimeCurve: { 136 | type: LifeTimeCurve.BEZIER, 137 | scale: 1, 138 | bezierPoints: [ 139 | { x: 0, y: 0, percentage: 0 }, 140 | { x: 1, y: 1, percentage: 1 }, 141 | ], 142 | }, 143 | }, 144 | /* colorOverLifetime: { 145 | isActive: false, 146 | lifetimeCurve: { 147 | type: LifeTimeCurve.EASING, 148 | scale: 1, 149 | curveFunction: CurveFunctionId.LINEAR, 150 | }, 151 | }, */ 152 | opacityOverLifetime: { 153 | isActive: false, 154 | lifetimeCurve: { 155 | type: LifeTimeCurve.BEZIER, 156 | scale: 1, 157 | bezierPoints: [ 158 | { x: 0, y: 0, percentage: 0 }, 159 | { x: 1, y: 1, percentage: 1 }, 160 | ], 161 | }, 162 | }, 163 | rotationOverLifetime: { 164 | isActive: false, 165 | min: 0.0, 166 | max: 0.0, 167 | }, 168 | noise: { 169 | isActive: false, 170 | useRandomOffset: false, 171 | strength: 1.0, 172 | frequency: 0.5, 173 | octaves: 1, 174 | positionAmount: 1.0, 175 | rotationAmount: 0.0, 176 | sizeAmount: 0.0, 177 | }, 178 | textureSheetAnimation: { 179 | tiles: new THREE.Vector2(1.0, 1.0), 180 | timeMode: TimeMode.LIFETIME, 181 | fps: 30.0, 182 | startFrame: 0, 183 | }, 184 | }; 185 | 186 | const createFloat32Attributes = ({ 187 | geometry, 188 | propertyName, 189 | maxParticles, 190 | factory, 191 | }: { 192 | geometry: THREE.BufferGeometry; 193 | propertyName: string; 194 | maxParticles: number; 195 | factory: ((value: never, index: number) => number) | number; 196 | }) => { 197 | geometry.setAttribute( 198 | propertyName, 199 | new THREE.BufferAttribute( 200 | new Float32Array( 201 | Array.from( 202 | { length: maxParticles }, 203 | typeof factory === 'function' ? factory : () => factory 204 | ) 205 | ), 206 | 1 207 | ) 208 | ); 209 | }; 210 | 211 | const calculatePositionAndVelocity = ( 212 | generalData: GeneralData, 213 | { shape, sphere, cone, circle, rectangle, box }: ShapeConfig, 214 | startSpeed: Constant | RandomBetweenTwoConstants | LifetimeCurve, 215 | position: THREE.Vector3, 216 | velocity: THREE.Vector3 217 | ) => { 218 | const calculatedStartSpeed = calculateValue( 219 | generalData.particleSystemId, 220 | startSpeed, 221 | generalData.normalizedLifetimePercentage 222 | ); 223 | 224 | switch (shape) { 225 | case Shape.SPHERE: 226 | calculateRandomPositionAndVelocityOnSphere( 227 | position, 228 | generalData.wrapperQuaternion, 229 | velocity, 230 | calculatedStartSpeed, 231 | sphere as Required> 232 | ); 233 | break; 234 | 235 | case Shape.CONE: 236 | calculateRandomPositionAndVelocityOnCone( 237 | position, 238 | generalData.wrapperQuaternion, 239 | velocity, 240 | calculatedStartSpeed, 241 | cone as Required> 242 | ); 243 | break; 244 | 245 | case Shape.CIRCLE: 246 | calculateRandomPositionAndVelocityOnCircle( 247 | position, 248 | generalData.wrapperQuaternion, 249 | velocity, 250 | calculatedStartSpeed, 251 | circle as Required> 252 | ); 253 | break; 254 | 255 | case Shape.RECTANGLE: 256 | calculateRandomPositionAndVelocityOnRectangle( 257 | position, 258 | generalData.wrapperQuaternion, 259 | velocity, 260 | calculatedStartSpeed, 261 | rectangle as Required> 262 | ); 263 | break; 264 | 265 | case Shape.BOX: 266 | calculateRandomPositionAndVelocityOnBox( 267 | position, 268 | generalData.wrapperQuaternion, 269 | velocity, 270 | calculatedStartSpeed, 271 | box as Required> 272 | ); 273 | break; 274 | } 275 | }; 276 | 277 | const destroyParticleSystem = (particleSystem: THREE.Points) => { 278 | createdParticleSystems = createdParticleSystems.filter( 279 | ({ 280 | particleSystem: savedParticleSystem, 281 | wrapper, 282 | generalData: { particleSystemId }, 283 | }) => { 284 | if ( 285 | savedParticleSystem !== particleSystem && 286 | wrapper !== particleSystem 287 | ) { 288 | return true; 289 | } 290 | 291 | removeBezierCurveFunction(particleSystemId); 292 | savedParticleSystem.geometry.dispose(); 293 | if (Array.isArray(savedParticleSystem.material)) 294 | savedParticleSystem.material.forEach((material) => material.dispose()); 295 | else savedParticleSystem.material.dispose(); 296 | 297 | if (savedParticleSystem.parent) 298 | savedParticleSystem.parent.remove(savedParticleSystem); 299 | return false; 300 | } 301 | ); 302 | }; 303 | 304 | export const createParticleSystem = ( 305 | config: ParticleSystemConfig = DEFAULT_PARTICLE_SYSTEM_CONFIG, 306 | externalNow?: number 307 | ): ParticleSystem => { 308 | const now = externalNow || Date.now(); 309 | const generalData: GeneralData = { 310 | particleSystemId: _particleSystemId++, 311 | normalizedLifetimePercentage: 0, 312 | distanceFromLastEmitByDistance: 0, 313 | lastWorldPosition: new THREE.Vector3(-99999), 314 | currentWorldPosition: new THREE.Vector3(-99999), 315 | worldPositionChange: new THREE.Vector3(), 316 | worldQuaternion: new THREE.Quaternion(), 317 | wrapperQuaternion: new THREE.Quaternion(), 318 | lastWorldQuaternion: new THREE.Quaternion(-99999), 319 | worldEuler: new THREE.Euler(), 320 | gravityVelocity: new THREE.Vector3(0, 0, 0), 321 | startValues: {}, 322 | linearVelocityData: undefined, 323 | orbitalVelocityData: undefined, 324 | lifetimeValues: {}, 325 | creationTimes: [], 326 | noise: { 327 | isActive: false, 328 | strength: 0, 329 | positionAmount: 0, 330 | rotationAmount: 0, 331 | sizeAmount: 0, 332 | }, 333 | isEnabled: true, 334 | }; 335 | const normalizedConfig = ObjectUtils.deepMerge( 336 | DEFAULT_PARTICLE_SYSTEM_CONFIG as NormalizedParticleSystemConfig, 337 | config, 338 | { applyToFirstObject: false, skippedProperties: [] } 339 | ) as NormalizedParticleSystemConfig; 340 | let particleMap: THREE.Texture | null = 341 | normalizedConfig.map || createDefaultParticleTexture(); 342 | 343 | const { 344 | transform, 345 | duration, 346 | looping, 347 | startDelay, 348 | startLifetime, 349 | startSpeed, 350 | startSize, 351 | startRotation, 352 | startColor, 353 | startOpacity, 354 | gravity, 355 | simulationSpace, 356 | maxParticles, 357 | emission, 358 | shape, 359 | renderer, 360 | noise, 361 | velocityOverLifetime, 362 | onUpdate, 363 | onComplete, 364 | textureSheetAnimation, 365 | } = normalizedConfig; 366 | 367 | if (typeof renderer?.blending === 'string') 368 | renderer.blending = blendingMap[renderer.blending]; 369 | 370 | const startPositions = Array.from( 371 | { length: maxParticles }, 372 | () => new THREE.Vector3() 373 | ); 374 | const velocities = Array.from( 375 | { length: maxParticles }, 376 | () => new THREE.Vector3() 377 | ); 378 | 379 | generalData.creationTimes = Array.from({ length: maxParticles }, () => 0); 380 | 381 | if (velocityOverLifetime.isActive) { 382 | generalData.linearVelocityData = Array.from( 383 | { length: maxParticles }, 384 | () => ({ 385 | speed: new THREE.Vector3( 386 | velocityOverLifetime.linear.x 387 | ? calculateValue( 388 | generalData.particleSystemId, 389 | velocityOverLifetime.linear.x, 390 | 0 391 | ) 392 | : 0, 393 | velocityOverLifetime.linear.y 394 | ? calculateValue( 395 | generalData.particleSystemId, 396 | velocityOverLifetime.linear.y, 397 | 0 398 | ) 399 | : 0, 400 | velocityOverLifetime.linear.z 401 | ? calculateValue( 402 | generalData.particleSystemId, 403 | velocityOverLifetime.linear.z, 404 | 0 405 | ) 406 | : 0 407 | ), 408 | valueModifiers: { 409 | x: isLifeTimeCurve(velocityOverLifetime.linear.x || 0) 410 | ? getCurveFunctionFromConfig( 411 | generalData.particleSystemId, 412 | velocityOverLifetime.linear.x as LifetimeCurve 413 | ) 414 | : undefined, 415 | y: isLifeTimeCurve(velocityOverLifetime.linear.y || 0) 416 | ? getCurveFunctionFromConfig( 417 | generalData.particleSystemId, 418 | velocityOverLifetime.linear.y as LifetimeCurve 419 | ) 420 | : undefined, 421 | z: isLifeTimeCurve(velocityOverLifetime.linear.z || 0) 422 | ? getCurveFunctionFromConfig( 423 | generalData.particleSystemId, 424 | velocityOverLifetime.linear.z as LifetimeCurve 425 | ) 426 | : undefined, 427 | }, 428 | }) 429 | ); 430 | 431 | generalData.orbitalVelocityData = Array.from( 432 | { length: maxParticles }, 433 | () => ({ 434 | speed: new THREE.Vector3( 435 | velocityOverLifetime.orbital.x 436 | ? calculateValue( 437 | generalData.particleSystemId, 438 | velocityOverLifetime.orbital.x, 439 | 0 440 | ) 441 | : 0, 442 | velocityOverLifetime.orbital.y 443 | ? calculateValue( 444 | generalData.particleSystemId, 445 | velocityOverLifetime.orbital.y, 446 | 0 447 | ) 448 | : 0, 449 | velocityOverLifetime.orbital.z 450 | ? calculateValue( 451 | generalData.particleSystemId, 452 | velocityOverLifetime.orbital.z, 453 | 0 454 | ) 455 | : 0 456 | ), 457 | valueModifiers: { 458 | x: isLifeTimeCurve(velocityOverLifetime.orbital.x || 0) 459 | ? getCurveFunctionFromConfig( 460 | generalData.particleSystemId, 461 | velocityOverLifetime.orbital.x as LifetimeCurve 462 | ) 463 | : undefined, 464 | y: isLifeTimeCurve(velocityOverLifetime.orbital.y || 0) 465 | ? getCurveFunctionFromConfig( 466 | generalData.particleSystemId, 467 | velocityOverLifetime.orbital.y as LifetimeCurve 468 | ) 469 | : undefined, 470 | z: isLifeTimeCurve(velocityOverLifetime.orbital.z || 0) 471 | ? getCurveFunctionFromConfig( 472 | generalData.particleSystemId, 473 | velocityOverLifetime.orbital.z as LifetimeCurve 474 | ) 475 | : undefined, 476 | }, 477 | positionOffset: new THREE.Vector3(), 478 | }) 479 | ); 480 | } 481 | 482 | const startValueKeys: Array = [ 483 | 'startSize', 484 | 'startOpacity', 485 | ]; 486 | startValueKeys.forEach((key) => { 487 | generalData.startValues[key] = Array.from({ length: maxParticles }, () => 488 | calculateValue( 489 | generalData.particleSystemId, 490 | normalizedConfig[key] as 491 | | Constant 492 | | RandomBetweenTwoConstants 493 | | LifetimeCurve, 494 | 0 495 | ) 496 | ); 497 | }); 498 | 499 | const lifetimeValueKeys: Array = [ 500 | 'rotationOverLifetime', 501 | ]; 502 | lifetimeValueKeys.forEach((key) => { 503 | const value = normalizedConfig[key] as { 504 | isActive: boolean; 505 | } & RandomBetweenTwoConstants; 506 | if (value.isActive) 507 | generalData.lifetimeValues[key] = Array.from( 508 | { length: maxParticles }, 509 | () => THREE.MathUtils.randFloat(value.min!, value.max!) 510 | ); 511 | }); 512 | 513 | generalData.noise = { 514 | isActive: noise.isActive, 515 | strength: noise.strength, 516 | positionAmount: noise.positionAmount, 517 | rotationAmount: noise.rotationAmount, 518 | sizeAmount: noise.sizeAmount, 519 | sampler: noise.isActive 520 | ? new FBM({ 521 | seed: Math.random(), 522 | scale: noise.frequency, 523 | octaves: noise.octaves, 524 | }) 525 | : undefined, 526 | offsets: noise.useRandomOffset 527 | ? Array.from({ length: maxParticles }, () => Math.random() * 100) 528 | : undefined, 529 | }; 530 | 531 | const material = new THREE.ShaderMaterial({ 532 | uniforms: { 533 | elapsed: { 534 | value: 0.0, 535 | }, 536 | map: { 537 | value: particleMap, 538 | }, 539 | tiles: { 540 | value: textureSheetAnimation.tiles, 541 | }, 542 | fps: { 543 | value: textureSheetAnimation.fps, 544 | }, 545 | useFPSForFrameIndex: { 546 | value: textureSheetAnimation.timeMode === TimeMode.FPS, 547 | }, 548 | backgroundColor: { 549 | value: renderer.backgroundColor, 550 | }, 551 | discardBackgroundColor: { 552 | value: renderer.discardBackgroundColor, 553 | }, 554 | backgroundColorTolerance: { 555 | value: renderer.backgroundColorTolerance, 556 | }, 557 | }, 558 | vertexShader: ParticleSystemVertexShader, 559 | fragmentShader: ParticleSystemFragmentShader, 560 | transparent: renderer.transparent, 561 | blending: renderer.blending, 562 | depthTest: renderer.depthTest, 563 | depthWrite: renderer.depthWrite, 564 | }); 565 | 566 | const geometry = new THREE.BufferGeometry(); 567 | 568 | for (let i = 0; i < maxParticles; i++) 569 | calculatePositionAndVelocity( 570 | generalData, 571 | shape, 572 | startSpeed, 573 | startPositions[i], 574 | velocities[i] 575 | ); 576 | 577 | geometry.setFromPoints( 578 | Array.from({ length: maxParticles }, (_, index) => 579 | startPositions[index].clone() 580 | ) 581 | ); 582 | 583 | const createFloat32AttributesRequest = ( 584 | propertyName: string, 585 | factory: ((value: never, index: number) => number) | number 586 | ) => { 587 | createFloat32Attributes({ 588 | geometry, 589 | propertyName, 590 | maxParticles, 591 | factory, 592 | }); 593 | }; 594 | 595 | createFloat32AttributesRequest('isActive', 0); 596 | 597 | createFloat32AttributesRequest('lifetime', 0); 598 | 599 | createFloat32AttributesRequest( 600 | 'startLifetime', 601 | () => calculateValue(generalData.particleSystemId, startLifetime, 0) * 1000 602 | ); 603 | 604 | createFloat32AttributesRequest('startFrame', () => 605 | textureSheetAnimation.startFrame 606 | ? calculateValue( 607 | generalData.particleSystemId, 608 | textureSheetAnimation.startFrame, 609 | 0 610 | ) 611 | : 0 612 | ); 613 | 614 | createFloat32AttributesRequest('opacity', () => 615 | calculateValue(generalData.particleSystemId, startOpacity, 0) 616 | ); 617 | 618 | createFloat32AttributesRequest('rotation', () => 619 | calculateValue(generalData.particleSystemId, startRotation, 0) 620 | ); 621 | 622 | createFloat32AttributesRequest( 623 | 'size', 624 | (_, index) => generalData.startValues.startSize[index] 625 | ); 626 | 627 | createFloat32AttributesRequest('rotation', 0); 628 | 629 | const colorRandomRatio = Math.random(); 630 | createFloat32AttributesRequest( 631 | 'colorR', 632 | () => 633 | startColor.min!.r! + 634 | colorRandomRatio * (startColor.max!.r! - startColor.min!.r!) 635 | ); 636 | createFloat32AttributesRequest( 637 | 'colorG', 638 | () => 639 | startColor.min!.g! + 640 | colorRandomRatio * (startColor.max!.g! - startColor.min!.g!) 641 | ); 642 | createFloat32AttributesRequest( 643 | 'colorB', 644 | () => 645 | startColor.min!.b! + 646 | colorRandomRatio * (startColor.max!.b! - startColor.min!.b!) 647 | ); 648 | createFloat32AttributesRequest('colorA', 0); 649 | 650 | const deactivateParticle = (particleIndex: number) => { 651 | geometry.attributes.isActive.array[particleIndex] = 0; 652 | geometry.attributes.colorA.array[particleIndex] = 0; 653 | geometry.attributes.colorA.needsUpdate = true; 654 | }; 655 | 656 | const activateParticle = ({ 657 | particleIndex, 658 | activationTime, 659 | position, 660 | }: { 661 | particleIndex: number; 662 | activationTime: number; 663 | position: Required; 664 | }) => { 665 | geometry.attributes.isActive.array[particleIndex] = 1; 666 | generalData.creationTimes[particleIndex] = activationTime; 667 | 668 | if (generalData.noise.offsets) 669 | generalData.noise.offsets[particleIndex] = Math.random() * 100; 670 | 671 | const colorRandomRatio = Math.random(); 672 | 673 | geometry.attributes.colorR.array[particleIndex] = 674 | startColor.min!.r! + 675 | colorRandomRatio * (startColor.max!.r! - startColor.min!.r!); 676 | geometry.attributes.colorR.needsUpdate = true; 677 | 678 | geometry.attributes.colorG.array[particleIndex] = 679 | startColor.min!.g! + 680 | colorRandomRatio * (startColor.max!.g! - startColor.min!.g!); 681 | geometry.attributes.colorG.needsUpdate = true; 682 | 683 | geometry.attributes.colorB.array[particleIndex] = 684 | startColor.min!.b! + 685 | colorRandomRatio * (startColor.max!.b! - startColor.min!.b!); 686 | geometry.attributes.colorB.needsUpdate = true; 687 | 688 | geometry.attributes.startFrame.array[particleIndex] = 689 | textureSheetAnimation.startFrame 690 | ? calculateValue( 691 | generalData.particleSystemId, 692 | textureSheetAnimation.startFrame, 693 | 0 694 | ) 695 | : 0; 696 | geometry.attributes.startFrame.needsUpdate = true; 697 | 698 | geometry.attributes.startLifetime.array[particleIndex] = 699 | calculateValue( 700 | generalData.particleSystemId, 701 | startLifetime, 702 | generalData.normalizedLifetimePercentage 703 | ) * 1000; 704 | geometry.attributes.startLifetime.needsUpdate = true; 705 | 706 | generalData.startValues.startSize[particleIndex] = calculateValue( 707 | generalData.particleSystemId, 708 | startSize, 709 | generalData.normalizedLifetimePercentage 710 | ); 711 | geometry.attributes.size.array[particleIndex] = 712 | generalData.startValues.startSize[particleIndex]; 713 | geometry.attributes.size.needsUpdate = true; 714 | 715 | generalData.startValues.startOpacity[particleIndex] = calculateValue( 716 | generalData.particleSystemId, 717 | startOpacity, 718 | generalData.normalizedLifetimePercentage 719 | ); 720 | geometry.attributes.colorA.array[particleIndex] = 721 | generalData.startValues.startOpacity[particleIndex]; 722 | geometry.attributes.colorA.needsUpdate = true; 723 | 724 | geometry.attributes.rotation.array[particleIndex] = calculateValue( 725 | generalData.particleSystemId, 726 | startRotation, 727 | generalData.normalizedLifetimePercentage 728 | ); 729 | geometry.attributes.rotation.needsUpdate = true; 730 | 731 | if (normalizedConfig.rotationOverLifetime.isActive) 732 | generalData.lifetimeValues.rotationOverLifetime[particleIndex] = 733 | THREE.MathUtils.randFloat( 734 | normalizedConfig.rotationOverLifetime.min!, 735 | normalizedConfig.rotationOverLifetime.max! 736 | ); 737 | 738 | calculatePositionAndVelocity( 739 | generalData, 740 | shape, 741 | startSpeed, 742 | startPositions[particleIndex], 743 | velocities[particleIndex] 744 | ); 745 | const positionIndex = Math.floor(particleIndex * 3); 746 | geometry.attributes.position.array[positionIndex] = 747 | position.x + startPositions[particleIndex].x; 748 | geometry.attributes.position.array[positionIndex + 1] = 749 | position.y + startPositions[particleIndex].y; 750 | geometry.attributes.position.array[positionIndex + 2] = 751 | position.z + startPositions[particleIndex].z; 752 | geometry.attributes.position.needsUpdate = true; 753 | 754 | if (generalData.linearVelocityData) { 755 | generalData.linearVelocityData[particleIndex].speed.set( 756 | normalizedConfig.velocityOverLifetime.linear.x 757 | ? calculateValue( 758 | generalData.particleSystemId, 759 | normalizedConfig.velocityOverLifetime.linear.x, 760 | 0 761 | ) 762 | : 0, 763 | normalizedConfig.velocityOverLifetime.linear.y 764 | ? calculateValue( 765 | generalData.particleSystemId, 766 | normalizedConfig.velocityOverLifetime.linear.y, 767 | 0 768 | ) 769 | : 0, 770 | normalizedConfig.velocityOverLifetime.linear.z 771 | ? calculateValue( 772 | generalData.particleSystemId, 773 | normalizedConfig.velocityOverLifetime.linear.z, 774 | 0 775 | ) 776 | : 0 777 | ); 778 | } 779 | 780 | if (generalData.orbitalVelocityData) { 781 | generalData.orbitalVelocityData[particleIndex].speed.set( 782 | normalizedConfig.velocityOverLifetime.orbital.x 783 | ? calculateValue( 784 | generalData.particleSystemId, 785 | normalizedConfig.velocityOverLifetime.orbital.x, 786 | 0 787 | ) 788 | : 0, 789 | normalizedConfig.velocityOverLifetime.orbital.y 790 | ? calculateValue( 791 | generalData.particleSystemId, 792 | normalizedConfig.velocityOverLifetime.orbital.y, 793 | 0 794 | ) 795 | : 0, 796 | normalizedConfig.velocityOverLifetime.orbital.z 797 | ? calculateValue( 798 | generalData.particleSystemId, 799 | normalizedConfig.velocityOverLifetime.orbital.z, 800 | 0 801 | ) 802 | : 0 803 | ); 804 | generalData.orbitalVelocityData[particleIndex].positionOffset.set( 805 | startPositions[particleIndex].x, 806 | startPositions[particleIndex].y, 807 | startPositions[particleIndex].z 808 | ); 809 | } 810 | 811 | geometry.attributes.lifetime.array[particleIndex] = 0; 812 | geometry.attributes.lifetime.needsUpdate = true; 813 | 814 | applyModifiers({ 815 | delta: 0, 816 | generalData, 817 | normalizedConfig, 818 | attributes: particleSystem.geometry.attributes, 819 | particleLifetimePercentage: 0, 820 | particleIndex, 821 | }); 822 | }; 823 | 824 | let particleSystem = new THREE.Points(geometry, material); 825 | 826 | particleSystem.position.copy(transform!.position!); 827 | particleSystem.rotation.x = THREE.MathUtils.degToRad(transform.rotation!.x); 828 | particleSystem.rotation.y = THREE.MathUtils.degToRad(transform.rotation!.y); 829 | particleSystem.rotation.z = THREE.MathUtils.degToRad(transform.rotation!.z); 830 | particleSystem.scale.copy(transform.scale!); 831 | 832 | const calculatedCreationTime = 833 | now + calculateValue(generalData.particleSystemId, startDelay) * 1000; 834 | 835 | let wrapper: Gyroscope | undefined; 836 | if (normalizedConfig.simulationSpace === SimulationSpace.WORLD) { 837 | wrapper = new Gyroscope(); 838 | wrapper.add(particleSystem); 839 | } 840 | 841 | createdParticleSystems.push({ 842 | particleSystem, 843 | wrapper, 844 | generalData, 845 | onUpdate, 846 | onComplete, 847 | creationTime: calculatedCreationTime, 848 | lastEmissionTime: calculatedCreationTime, 849 | duration, 850 | looping, 851 | simulationSpace, 852 | gravity, 853 | emission, 854 | normalizedConfig, 855 | iterationCount: 0, 856 | velocities, 857 | deactivateParticle, 858 | activateParticle, 859 | }); 860 | 861 | const resumeEmitter = () => (generalData.isEnabled = true); 862 | const pauseEmitter = () => (generalData.isEnabled = false); 863 | const dispose = () => destroyParticleSystem(particleSystem); 864 | 865 | return { 866 | instance: wrapper || particleSystem, 867 | resumeEmitter, 868 | pauseEmitter, 869 | dispose, 870 | }; 871 | }; 872 | 873 | export const updateParticleSystems = ({ now, delta, elapsed }: CycleData) => { 874 | createdParticleSystems.forEach((props) => { 875 | const { 876 | onUpdate, 877 | generalData, 878 | onComplete, 879 | particleSystem, 880 | wrapper, 881 | creationTime, 882 | lastEmissionTime, 883 | duration, 884 | looping, 885 | emission, 886 | normalizedConfig, 887 | iterationCount, 888 | velocities, 889 | deactivateParticle, 890 | activateParticle, 891 | simulationSpace, 892 | gravity, 893 | } = props; 894 | 895 | const lifetime = now - creationTime; 896 | const normalizedLifetime = lifetime % (duration * 1000); 897 | 898 | generalData.normalizedLifetimePercentage = Math.max( 899 | Math.min(normalizedLifetime / (duration * 1000), 1), 900 | 0 901 | ); 902 | 903 | const { 904 | lastWorldPosition, 905 | currentWorldPosition, 906 | worldPositionChange, 907 | lastWorldQuaternion, 908 | worldQuaternion, 909 | worldEuler, 910 | gravityVelocity, 911 | isEnabled, 912 | } = generalData; 913 | 914 | if (wrapper?.parent) 915 | generalData.wrapperQuaternion.copy(wrapper.parent.quaternion); 916 | 917 | const lastWorldPositionSnapshot = { ...lastWorldPosition }; 918 | 919 | if (Array.isArray(particleSystem.material)) 920 | particleSystem.material.forEach((material) => { 921 | if (material instanceof THREE.ShaderMaterial) 922 | material.uniforms.elapsed.value = elapsed; 923 | }); 924 | else { 925 | if (particleSystem.material instanceof THREE.ShaderMaterial) 926 | particleSystem.material.uniforms.elapsed.value = elapsed; 927 | } 928 | 929 | particleSystem.getWorldPosition(currentWorldPosition); 930 | if (lastWorldPosition.x !== -99999) { 931 | worldPositionChange.set( 932 | currentWorldPosition.x - lastWorldPosition.x, 933 | currentWorldPosition.y - lastWorldPosition.y, 934 | currentWorldPosition.z - lastWorldPosition.z 935 | ); 936 | } 937 | generalData.distanceFromLastEmitByDistance += worldPositionChange.length(); 938 | particleSystem.getWorldPosition(lastWorldPosition); 939 | particleSystem.getWorldQuaternion(worldQuaternion); 940 | if ( 941 | lastWorldQuaternion.x === -99999 || 942 | lastWorldQuaternion.x !== worldQuaternion.x || 943 | lastWorldQuaternion.y !== worldQuaternion.y || 944 | lastWorldQuaternion.z !== worldQuaternion.z 945 | ) { 946 | worldEuler.setFromQuaternion(worldQuaternion); 947 | lastWorldQuaternion.copy(worldQuaternion); 948 | gravityVelocity.set( 949 | lastWorldPosition.x, 950 | lastWorldPosition.y + gravity, 951 | lastWorldPosition.z 952 | ); 953 | particleSystem.worldToLocal(gravityVelocity); 954 | } 955 | 956 | generalData.creationTimes.forEach((entry, index) => { 957 | if (particleSystem.geometry.attributes.isActive.array[index]) { 958 | const particleLifetime = now - entry; 959 | if ( 960 | particleLifetime > 961 | particleSystem.geometry.attributes.startLifetime.array[index] 962 | ) 963 | deactivateParticle(index); 964 | else { 965 | const velocity = velocities[index]; 966 | velocity.x -= gravityVelocity.x * delta; 967 | velocity.y -= gravityVelocity.y * delta; 968 | velocity.z -= gravityVelocity.z * delta; 969 | 970 | if ( 971 | gravity !== 0 || 972 | velocity.x !== 0 || 973 | velocity.y !== 0 || 974 | velocity.z !== 0 || 975 | worldPositionChange.x !== 0 || 976 | worldPositionChange.y !== 0 || 977 | worldPositionChange.z !== 0 978 | ) { 979 | const positionIndex = index * 3; 980 | const positionArr = 981 | particleSystem.geometry.attributes.position.array; 982 | 983 | if (simulationSpace === SimulationSpace.WORLD) { 984 | positionArr[positionIndex] -= worldPositionChange.x; 985 | positionArr[positionIndex + 1] -= worldPositionChange.y; 986 | positionArr[positionIndex + 2] -= worldPositionChange.z; 987 | } 988 | 989 | positionArr[positionIndex] += velocity.x * delta; 990 | positionArr[positionIndex + 1] += velocity.y * delta; 991 | positionArr[positionIndex + 2] += velocity.z * delta; 992 | particleSystem.geometry.attributes.position.needsUpdate = true; 993 | } 994 | 995 | particleSystem.geometry.attributes.lifetime.array[index] = 996 | particleLifetime; 997 | particleSystem.geometry.attributes.lifetime.needsUpdate = true; 998 | 999 | const particleLifetimePercentage = 1000 | particleLifetime / 1001 | particleSystem.geometry.attributes.startLifetime.array[index]; 1002 | applyModifiers({ 1003 | delta, 1004 | generalData, 1005 | normalizedConfig, 1006 | attributes: particleSystem.geometry.attributes, 1007 | particleLifetimePercentage, 1008 | particleIndex: index, 1009 | }); 1010 | } 1011 | } 1012 | }); 1013 | 1014 | if (isEnabled && (looping || lifetime < duration * 1000)) { 1015 | const emissionDelta = now - lastEmissionTime; 1016 | const neededParticlesByTime = emission.rateOverTime 1017 | ? Math.floor( 1018 | calculateValue( 1019 | generalData.particleSystemId, 1020 | emission.rateOverTime, 1021 | generalData.normalizedLifetimePercentage 1022 | ) * 1023 | (emissionDelta / 1000) 1024 | ) 1025 | : 0; 1026 | 1027 | const rateOverDistance = emission.rateOverDistance 1028 | ? calculateValue( 1029 | generalData.particleSystemId, 1030 | emission.rateOverDistance, 1031 | generalData.normalizedLifetimePercentage 1032 | ) 1033 | : 0; 1034 | const neededParticlesByDistance = 1035 | rateOverDistance > 0 && generalData.distanceFromLastEmitByDistance > 0 1036 | ? Math.floor( 1037 | generalData.distanceFromLastEmitByDistance / 1038 | (1 / rateOverDistance!) 1039 | ) 1040 | : 0; 1041 | const distanceStep = 1042 | neededParticlesByDistance > 0 1043 | ? { 1044 | x: 1045 | (currentWorldPosition.x - lastWorldPositionSnapshot.x) / 1046 | neededParticlesByDistance, 1047 | y: 1048 | (currentWorldPosition.y - lastWorldPositionSnapshot.y) / 1049 | neededParticlesByDistance, 1050 | z: 1051 | (currentWorldPosition.z - lastWorldPositionSnapshot.z) / 1052 | neededParticlesByDistance, 1053 | } 1054 | : null; 1055 | const neededParticles = neededParticlesByTime + neededParticlesByDistance; 1056 | 1057 | if (rateOverDistance > 0 && neededParticlesByDistance >= 1) { 1058 | generalData.distanceFromLastEmitByDistance = 0; 1059 | } 1060 | 1061 | if (neededParticles > 0) { 1062 | let generatedParticlesByDistanceNeeds = 0; 1063 | for (let i = 0; i < neededParticles; i++) { 1064 | let particleIndex = -1; 1065 | particleSystem.geometry.attributes.isActive.array.find( 1066 | (isActive, index) => { 1067 | if (!isActive) { 1068 | particleIndex = index; 1069 | return true; 1070 | } 1071 | return false; 1072 | } 1073 | ); 1074 | 1075 | if ( 1076 | particleIndex !== -1 && 1077 | particleIndex < 1078 | particleSystem.geometry.attributes.isActive.array.length 1079 | ) { 1080 | let position: Required = { x: 0, y: 0, z: 0 }; 1081 | if ( 1082 | distanceStep && 1083 | generatedParticlesByDistanceNeeds < neededParticlesByDistance 1084 | ) { 1085 | position = { 1086 | x: distanceStep.x * generatedParticlesByDistanceNeeds, 1087 | y: distanceStep.y * generatedParticlesByDistanceNeeds, 1088 | z: distanceStep.z * generatedParticlesByDistanceNeeds, 1089 | }; 1090 | generatedParticlesByDistanceNeeds++; 1091 | } 1092 | activateParticle({ 1093 | particleIndex, 1094 | activationTime: now, 1095 | position, 1096 | }); 1097 | props.lastEmissionTime = now; 1098 | } 1099 | } 1100 | } 1101 | 1102 | if (onUpdate) 1103 | onUpdate({ 1104 | particleSystem, 1105 | delta, 1106 | elapsed, 1107 | lifetime, 1108 | normalizedLifetime, 1109 | iterationCount: iterationCount + 1, 1110 | }); 1111 | } else if (onComplete) 1112 | onComplete({ 1113 | particleSystem, 1114 | }); 1115 | }); 1116 | }; 1117 | --------------------------------------------------------------------------------