├── .github └── workflows │ └── main.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── eslint.config.mjs ├── package.json ├── prettier.config.js ├── public └── assets │ └── shaders │ ├── basic.frag.wgsl │ ├── basic.vert.wgsl │ ├── particle.comp.wgsl │ ├── particle.frag.wgsl │ └── particle.vert.wgsl ├── src ├── boxgeometry.ts ├── camera.ts ├── components │ ├── App.tsx │ ├── ErrorMessage.tsx │ ├── Gui.tsx │ ├── Renderer.tsx │ ├── Slider.tsx │ ├── Stats.tsx │ └── state.ts ├── crosshairgeometry.ts ├── index.css ├── index.html ├── index.tsx ├── particlegeometry.ts ├── particlerenderer.ts ├── trianglegeometry.ts ├── webgpucomputepipline.ts ├── webgpuentity.ts ├── webgpugeometry.ts ├── webgpugeometrybase.ts ├── webgpuhelpers.ts ├── webgpuinterleavedgeometry.ts ├── webgpumaterial.ts ├── webgpumesh.ts ├── webgpuobjectbase.ts ├── webgpupipelinebase.ts ├── webgpurendercontext.ts ├── webgpurenderer.ts └── webgpurenderpipeline.ts ├── tailwind.config.js ├── tsconfig.json ├── vite.config.mjs └── yarn.lock /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build Web 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | name: Web Build 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v3 16 | 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version-file: '.nvmrc' 21 | 22 | - name: Install node_modules 23 | run: yarn install 24 | 25 | - name: Build Prod 26 | run: yarn run deploy 27 | 28 | - name: Deploy 29 | uses: JamesIves/github-pages-deploy-action@v4 30 | with: 31 | branch: gh-pages 32 | folder: dist 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.14.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dtoplak.vscode-glsllint", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "pwa-node", 9 | "request": "launch", 10 | "name": "esbuild", 11 | "skipFiles": ["/**"], 12 | "program": "${workspaceFolder}/build/esbuild.config.js" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[html]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[javascript]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "[json]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "[jsonc]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode" 13 | }, 14 | "[typescript]": { 15 | "editor.defaultFormatter": "esbenp.prettier-vscode" 16 | }, 17 | "[typescriptreact]": { 18 | "editor.defaultFormatter": "esbenp.prettier-vscode" 19 | }, 20 | "editor.codeActionsOnSave": { 21 | "source.fixAll.eslint": "explicit", 22 | "source.organizeImports": "explicit" 23 | }, 24 | "editor.formatOnSave": true, 25 | "editor.tabSize": 2, 26 | "files.exclude": { 27 | "dist": true, 28 | "node_modules": true 29 | }, 30 | "typescript.tsdk": "node_modules/typescript/lib" 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Daniel Toplak 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebGPU Particles 2 | 3 | Calculate and render particles with [WebGPU](https://github.com/gpuweb/gpuweb) 4 | 5 | [WebGPU](https://github.com/gpuweb/gpuweb) is still under development by browser vendors and not yet enabled by default. 6 | 7 | ## If you want to try this out 8 | 9 | - Use a Browser which is able to run WebGPU (e.g. Chrome-Canary) 10 | - Enable WebGPU in your Browser see [Implemenation-Status](https://github.com/gpuweb/gpuweb/wiki/Implementation-Status) 11 | - Go to 12 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import configPrettier from 'eslint-config-prettier'; 3 | import pluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; 4 | import pluginReact from 'eslint-plugin-react'; 5 | import reactPlugin from 'eslint-plugin-react-hooks'; 6 | import tseslint from 'typescript-eslint'; 7 | 8 | export default tseslint.config( 9 | eslint.configs.recommended, 10 | tseslint.configs.strictTypeChecked, 11 | tseslint.configs.stylisticTypeChecked, 12 | 13 | { 14 | ignores: ['dist/**/*', 'eslint.config.mjs', 'prettier.config.js', 'tailwind.config.js', 'vite.config.mjs'], 15 | }, 16 | { 17 | languageOptions: { 18 | ...pluginReact.configs.flat.recommended.languageOptions, 19 | parserOptions: { 20 | projectService: true, 21 | tsconfigRootDir: import.meta.dirname, 22 | }, 23 | }, 24 | }, 25 | 26 | // eslint-rules 27 | { 28 | rules: { 29 | 'linebreak-style': ['error', 'unix'], 30 | 'no-unused-vars': 'off', 31 | 'no-warning-comments': 'warn', 32 | eqeqeq: 'error', 33 | indent: ['error', 2, { SwitchCase: 1 }], 34 | quotes: ['error', 'single'], 35 | semi: ['error', 'always'], 36 | }, 37 | }, 38 | 39 | // typescript-eslint rules 40 | { 41 | rules: { 42 | '@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'explicit' }], 43 | '@typescript-eslint/interface-name-prefix': 'off', 44 | '@typescript-eslint/no-floating-promises': 'error', 45 | '@typescript-eslint/no-parameter-properties': ['off'], 46 | '@typescript-eslint/no-require-imports': 'warn', 47 | '@typescript-eslint/no-unused-vars': [ 48 | 'warn', 49 | { 50 | args: 'after-used', 51 | argsIgnorePattern: '^_', 52 | ignoreRestSiblings: false, 53 | vars: 'all', 54 | }, 55 | ], 56 | '@typescript-eslint/no-var-requires': 'warn', 57 | '@typescript-eslint/promise-function-async': 'error', 58 | '@typescript-eslint/require-await': 'error', 59 | '@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }], 60 | }, 61 | }, 62 | 63 | // plugin-react and plugin-react-hooks 64 | pluginReact.configs.flat.recommended, 65 | { 66 | plugins: { 67 | react: pluginReact, 68 | 'react-hooks': reactPlugin, 69 | }, 70 | rules: { 71 | 'react/self-closing-comp': ['error', { component: true, html: true }], 72 | 'react-hooks/rules-of-hooks': 'error', 73 | 'react-hooks/exhaustive-deps': 'warn', 74 | }, 75 | settings: { 76 | react: { 77 | version: 'detect', 78 | }, 79 | }, 80 | }, 81 | 82 | // prettier should come last 83 | configPrettier, 84 | pluginPrettierRecommended, 85 | { 86 | rules: { 87 | 'prettier/prettier': 'error', 88 | }, 89 | }, 90 | ); 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webgpu-particles", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build --emptyOutDir", 8 | "deploy": "yarn lint && yarn format:check && yarn build", 9 | "check-types": "tsc --noemit", 10 | "lint:ts": "eslint .", 11 | "lint:fix": "eslint . --fix", 12 | "lint": "yarn check-types && yarn lint:ts", 13 | "format:check": "prettier . --check", 14 | "format:fix": "prettier . --write" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/hsimpson/webgpu-particles.git" 19 | }, 20 | "keywords": [], 21 | "author": "", 22 | "license": "ISC", 23 | "bugs": { 24 | "url": "https://github.com/hsimpson/webgpu-particles/issues" 25 | }, 26 | "homepage": "https://github.com/hsimpson/webgpu-particles#readme", 27 | "devDependencies": { 28 | "@eslint/js": "^9.23.0", 29 | "@tailwindcss/vite": "^4.0.17", 30 | "@types/node": "^22.13.17", 31 | "@types/react": "^19.0.12", 32 | "@types/react-color": "^3.0.13", 33 | "@types/react-dom": "^19.0.4", 34 | "@vitejs/plugin-react": "^4.3.4", 35 | "@webgpu/types": "^0.1.60", 36 | "eslint": "^9.23.0", 37 | "eslint-config-prettier": "^10.1.1", 38 | "eslint-plugin-prettier": "^5.2.5", 39 | "eslint-plugin-react": "^7.37.4", 40 | "eslint-plugin-react-hooks": "^5.2.0", 41 | "prettier": "^3.5.3", 42 | "prettier-plugin-tailwindcss": "^0.6.11", 43 | "tailwindcss": "^4.0.17", 44 | "typescript": "^5.8.2", 45 | "typescript-eslint": "^8.29.0", 46 | "vite": "^6.2.4" 47 | }, 48 | "dependencies": { 49 | "jotai": "^2.12.2", 50 | "react": "^19.1.0", 51 | "react-color": "^2.19.3", 52 | "react-dom": "^19.1.0", 53 | "wgpu-matrix": "^3.4.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | bracketSameLine: true, 4 | bracketSpacing: true, 5 | endOfLine: 'lf', 6 | htmlWhitespaceSensitivity: 'css', 7 | insertPragma: false, 8 | jsxSingleQuote: false, 9 | printWidth: 120, 10 | proseWrap: 'preserve', 11 | quoteProps: 'as-needed', 12 | requirePragma: false, 13 | semi: true, 14 | singleQuote: true, 15 | tabWidth: 2, 16 | trailingComma: 'all', 17 | useTabs: false, 18 | 19 | plugins: ['prettier-plugin-tailwindcss'], 20 | tailwindStylesheet: './src/index.css', 21 | tailwindConfig: './tailwind.config.js', 22 | }; 23 | -------------------------------------------------------------------------------- /public/assets/shaders/basic.frag.wgsl: -------------------------------------------------------------------------------- 1 | 2 | @fragment 3 | fn main(@location(0) fragColor: vec4) -> @location(0) vec4 { 4 | return fragColor; 5 | } 6 | -------------------------------------------------------------------------------- /public/assets/shaders/basic.vert.wgsl: -------------------------------------------------------------------------------- 1 | struct UBOCamera { 2 | viewMatrix: mat4x4, 3 | projMatrix: mat4x4 4 | }; 5 | 6 | struct UBOModel { 7 | modelMatrix: mat4x4 8 | }; 9 | 10 | @group(0) @binding(0) var camera: UBOCamera; 11 | @group(0) @binding(1) var model: UBOModel; 12 | 13 | struct VertexInput { 14 | @location(0) inPosition: vec3, 15 | @location(1) inColor: vec4, 16 | }; 17 | 18 | struct VertexOutput { 19 | @builtin(position) position: vec4, 20 | @location(0) fragColor: vec4, 21 | }; 22 | 23 | 24 | @vertex 25 | fn main(vertexInput: VertexInput) -> VertexOutput { 26 | var vertexOutput: VertexOutput; 27 | vertexOutput.position = camera.projMatrix * camera.viewMatrix * model.modelMatrix * vec4(vertexInput.inPosition, 1.0); 28 | vertexOutput.fragColor = vertexInput.inColor; 29 | return vertexOutput; 30 | } 31 | -------------------------------------------------------------------------------- /public/assets/shaders/particle.comp.wgsl: -------------------------------------------------------------------------------- 1 | struct ComputeParams { 2 | vHalfBounding: vec4, 3 | vForcePos: vec4, 4 | fDeltaTime: f32, 5 | fGravity: f32, 6 | fForce: f32, 7 | fForceOn: f32, 8 | }; 9 | 10 | struct ParticlesA { 11 | pos: array>, 12 | }; 13 | 14 | struct ParticlesB { 15 | vel: array>, 16 | }; 17 | 18 | @group(0) @binding(0) var params: ComputeParams; 19 | @group(0) @binding(1) var positions: ParticlesA; 20 | @group(0) @binding(2) var velocities: ParticlesB; 21 | 22 | const EPSILON: vec3 = vec3(0.0001, 0.0001, 0.0001); 23 | 24 | @compute @workgroup_size(256) 25 | fn main(@builtin(global_invocation_id) globalInvocationID: vec3) { 26 | 27 | var index: u32 = globalInvocationID.x; 28 | 29 | var position: vec4 = positions.pos[index]; 30 | var velocity: vec4 = velocities.vel[index]; 31 | 32 | // Update Particle positions 33 | position = position + (velocity * params.fDeltaTime); 34 | 35 | // Add in fGravity 36 | velocity.y = velocity.y - (params.fGravity * params.fDeltaTime); 37 | 38 | var v3BB_half: vec3 = params.vHalfBounding.xyz - EPSILON; 39 | 40 | // Face collision detection 41 | if (position.x < -v3BB_half.x) { // LEFT 42 | position.x = -2.0 * v3BB_half.x - position.x; 43 | velocity.x = velocity.x * -0.9; 44 | } else if (position.x > v3BB_half.x) { // RIGHT 45 | position.x = 2.0 * v3BB_half.x - position.x; 46 | velocity.x = velocity.x * -0.9; 47 | } 48 | 49 | if (position.y < -v3BB_half.y) { // BOTTOM 50 | position.y = -2.0 * v3BB_half.y - position.y; 51 | if(params.fGravity > 0.0) { 52 | velocity.y = velocity.y * -0.45; // if its on the bottom we extra dampen 53 | } 54 | velocity.y = velocity.y * 0.9; 55 | } else if (position.y > v3BB_half.y) { // TOP 56 | position.y = 2.0 * v3BB_half.y - position.y; 57 | if(params.fGravity < 0.0) { 58 | velocity.y = velocity.y * 0.45; // if its on the top we extra dampen 59 | } 60 | velocity.y = velocity.y * -0.9; 61 | } 62 | 63 | if (position.z < -v3BB_half.z) { // FRONT 64 | position.z = -2.0 * v3BB_half.z - position.z; 65 | velocity.z = velocity.z * -0.9; 66 | } else if (position.z > v3BB_half.z) { // BACK 67 | position.z = 2.0 * v3BB_half.z - position.z; 68 | velocity.z = velocity.z * -0.9; 69 | } 70 | 71 | var force: u32 = u32(params.fForceOn); 72 | if(force != 0u) { 73 | var d: vec4 = params.vForcePos - position; 74 | var dist: f32 = sqrt(d.x * d.x + d.y * d.y + d.z * d.z); 75 | if (dist < 1.0) { 76 | dist = 1.0; // This line prevents anything that is really close from 77 | // getting a huge force 78 | } 79 | 80 | var f: f32 = params.fForce * params.fDeltaTime; 81 | velocity = velocity + (d / vec4(dist, dist, dist, dist) * vec4(f, f, f, f)); 82 | } 83 | 84 | positions.pos[index] = position; 85 | velocities.vel[index] = velocity; 86 | 87 | return; 88 | } 89 | -------------------------------------------------------------------------------- /public/assets/shaders/particle.frag.wgsl: -------------------------------------------------------------------------------- 1 | struct UBOParticle { 2 | color: vec4, 3 | }; 4 | 5 | @group(0) @binding(2) var particles: UBOParticle; 6 | 7 | @fragment 8 | fn main() -> @location(0) vec4 { 9 | return particles.color; 10 | } 11 | -------------------------------------------------------------------------------- /public/assets/shaders/particle.vert.wgsl: -------------------------------------------------------------------------------- 1 | struct UBOCamera { 2 | viewMatrix: mat4x4, 3 | projMatrix: mat4x4, 4 | }; 5 | 6 | struct UBOModel { 7 | modelMatrix: mat4x4, 8 | }; 9 | 10 | @group(0) @binding(0) var camera: UBOCamera; 11 | @group(0) @binding(1) var model: UBOModel; 12 | 13 | @vertex 14 | fn main(@location(0) inPosition: vec3) -> @builtin(position) vec4 { 15 | return camera.projMatrix * camera.viewMatrix * model.modelMatrix * vec4(inPosition, 1.0); 16 | } 17 | -------------------------------------------------------------------------------- /src/boxgeometry.ts: -------------------------------------------------------------------------------- 1 | import { Vec3, Vec4 } from 'wgpu-matrix'; 2 | import WebGPUInterleavedGeometry from './webgpuinterleavedgeometry'; 3 | 4 | /* the cube: 5 | 6 | v5-----------v6 7 | / | / | 8 | / | / | 9 | v2----------v1 | 10 | | | | | 11 | | | | | 12 | | v4--------|--v7 13 | | / | / 14 | |/ | / 15 | v3-----------v0 16 | 17 | */ 18 | 19 | const BoxDimensions: Vec3 = new Float32Array([8, 5, 5]); 20 | const color: Vec4 = new Float32Array([1, 1, 1, 1]); 21 | 22 | const halfx = BoxDimensions[0] / 2; 23 | const halfy = BoxDimensions[1] / 2; 24 | const halfz = BoxDimensions[2] / 2; 25 | 26 | // prettier-ignore 27 | const vertexArray = new Float32Array([ 28 | // front vertices: 29 | halfx, -halfy, -halfz, ...color, 30 | halfx, halfy, -halfz, ...color, 31 | -halfx, halfy, -halfz, ...color, 32 | -halfx, -halfy, -halfz, ...color, 33 | 34 | // back vertices: 35 | -halfx, -halfy, halfz, ...color, 36 | -halfx, halfy, halfz, ...color, 37 | halfx, halfy, halfz, ...color, 38 | halfx, -halfy, halfz, ...color, 39 | ]); 40 | 41 | // prettier-ignore 42 | const indicesArray = new Uint16Array([ 43 | // front 44 | 0, 1, 1, 2, 2, 3, 3, 0, 45 | 46 | // left 47 | 3, 2, 2, 5, 5, 4, 4, 3, 48 | 49 | // right 50 | 7, 6, 6, 1, 1, 0, 0, 7, 51 | 52 | // back 53 | 4, 5, 5, 6, 6, 7, 7, 4 54 | ]); 55 | 56 | const geometry = new WebGPUInterleavedGeometry(); 57 | geometry.setVertices(vertexArray, 7 * Float32Array.BYTES_PER_ELEMENT); 58 | geometry.setIndices(indicesArray); 59 | geometry.addAttribute({ shaderLocation: 0, offset: 0, format: 'float32x3' }); 60 | geometry.addAttribute({ shaderLocation: 1, offset: 3 * Float32Array.BYTES_PER_ELEMENT, format: 'float32x4' }); 61 | 62 | export { BoxDimensions, geometry as BoxGeometry }; 63 | -------------------------------------------------------------------------------- /src/camera.ts: -------------------------------------------------------------------------------- 1 | import { Quat, Vec3, mat4, quat, utils, vec3 } from 'wgpu-matrix'; 2 | import { createBuffer } from './webgpuhelpers'; 3 | import WebGPURenderContext from './webgpurendercontext'; 4 | 5 | export default class Camera { 6 | private _perspectiveMatrix = mat4.identity(); 7 | private _viewMatrix = mat4.identity(); 8 | private _rotation = quat.identity(); 9 | 10 | private _uniformBuffer!: GPUBuffer; 11 | /* 12 | private _uniformBindGroupLayout: GPUBindGroupLayout; 13 | private _uniformBindGroup: GPUBindGroup; 14 | */ 15 | private _initialized = false; 16 | private _context!: WebGPURenderContext; 17 | 18 | public position: Vec3 = vec3.create(0, 0, 0); 19 | public target: Vec3 = vec3.create(0, 0, 0); 20 | public up: Vec3 = vec3.create(0, 1, 0); 21 | public fovY = 45.0; 22 | public aspectRatio = 1.0; 23 | public zNear = 0.1; 24 | public zFar = 1000; 25 | 26 | public constructor(fovY: number, aspectRatio: number, zNear: number, zFar: number) { 27 | this.fovY = fovY; 28 | this.aspectRatio = aspectRatio; 29 | this.zNear = zNear; 30 | this.zFar = zFar; 31 | } 32 | 33 | public initalize(context: WebGPURenderContext): void { 34 | if (this._initialized) { 35 | return; 36 | } 37 | this._initialized = true; 38 | 39 | this._context = context; 40 | 41 | const uboArray = new Float32Array([...this._viewMatrix, ...this._perspectiveMatrix]); 42 | this._uniformBuffer = createBuffer(context.device, uboArray, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST); 43 | this.updateMatrices(); 44 | } 45 | 46 | public get viewMatrix() { 47 | return this._viewMatrix; 48 | } 49 | 50 | public get perspectiveMatrix() { 51 | return this._perspectiveMatrix; 52 | } 53 | 54 | public get uniformBuffer(): GPUBuffer { 55 | return this._uniformBuffer; 56 | } 57 | 58 | public updateMatrices(): void { 59 | this.updateViewMatrix(); 60 | this.updatePerspectiveMatrix(); 61 | } 62 | 63 | private updateViewMatrix(): void { 64 | const translationMatrix = mat4.lookAt(this.position, this.target, this.up); 65 | const rotationMatrix = mat4.fromQuat(this._rotation); 66 | 67 | mat4.multiply(translationMatrix, rotationMatrix, this._viewMatrix); 68 | this.updateUniformBuffer(); 69 | } 70 | 71 | private updatePerspectiveMatrix(): void { 72 | mat4.perspective(utils.degToRad(this.fovY), this.aspectRatio, this.zNear, this.zFar, this._perspectiveMatrix); 73 | this.updateUniformBuffer(); 74 | } 75 | 76 | private updateUniformBuffer(): void { 77 | const uboArray = new Float32Array([...this._viewMatrix, ...this._perspectiveMatrix]); 78 | this._context.queue.writeBuffer(this._uniformBuffer, 0, uboArray.buffer); 79 | } 80 | 81 | public rotateQuat(rotation: Quat): void { 82 | quat.multiply(rotation, this._rotation, this._rotation); 83 | this.updateViewMatrix(); 84 | } 85 | 86 | public rotateEuler(angleX: number, angelY: number, angleZ: number): void { 87 | const tempQuat = quat.fromEuler(angleX, angelY, angleZ, 'xzy'); 88 | this.rotateQuat(tempQuat); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import WebGPURenderer from '../webgpurenderer'; 3 | import ErrorMessage from './ErrorMessage'; 4 | import Renderer from './Renderer'; 5 | 6 | const App = (): React.ReactElement => { 7 | return WebGPURenderer.supportsWebGPU() ? : ; 8 | }; 9 | 10 | export default App; 11 | -------------------------------------------------------------------------------- /src/components/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ErrorMessage = (): React.ReactElement => { 4 | return ( 5 |
6 | Your browser does not support WebGPU yet{' '} 7 | 12 | (Implementation Status) 13 | 14 |
15 | If you want to try this out: 16 |
    17 |
  • In Chrome enable a flag: chrome://flags/#enable-unsafe-webgpu
  • 18 |
19 |
20 | Additional information: 21 | 33 |
34 | ); 35 | }; 36 | 37 | export default ErrorMessage; 38 | -------------------------------------------------------------------------------- /src/components/Gui.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from 'jotai'; 2 | import { useResetAtom } from 'jotai/utils'; 3 | import React from 'react'; 4 | import { ChromePicker, ColorResult } from 'react-color'; 5 | import Slider from './Slider'; 6 | import { computeProperties, particleCounter } from './state'; 7 | 8 | const Gui = (): React.ReactElement => { 9 | const [computePropertiesState, setComputePropertiesState] = useAtom(computeProperties); 10 | const [particleCountState, setParticleCountState] = useAtom(particleCounter); 11 | const resetComputeProperties = useResetAtom(computeProperties); 12 | const resetParticleCount = useResetAtom(particleCounter); 13 | 14 | const onColorChange = (colorResult: ColorResult): void => { 15 | const color = { 16 | r: colorResult.rgb.r, 17 | g: colorResult.rgb.g, 18 | b: colorResult.rgb.b, 19 | a: colorResult.rgb.a ?? 1.0, 20 | }; 21 | 22 | setComputePropertiesState({ ...computePropertiesState, color: color }); 23 | }; 24 | 25 | const onReset = (): void => { 26 | resetComputeProperties(); 27 | resetParticleCount(); 28 | }; 29 | 30 | return ( 31 |
32 | { 38 | setParticleCountState(particleCount); 39 | }} 40 | labelText={`Particle count: ${particleCountState.toLocaleString()}`} 41 | /> 42 | { 48 | setComputePropertiesState({ ...computePropertiesState, gravity }); 49 | }} 50 | labelText={`Gravity: ${computePropertiesState.gravity.toFixed(2)}`} 51 | /> 52 | { 58 | setComputePropertiesState({ ...computePropertiesState, force }); 59 | }} 60 | labelText={`Force: ${computePropertiesState.force.toFixed(2)}`} 61 | /> 62 | 63 | 66 |
67 | Space: activate force 68 |
69 |
70 | W: Move force -Z 71 |
72 |
73 | A: Move force -X 74 |
75 |
76 | S: Move force +Z 77 |
78 |
79 | D: Move force +X 80 |
81 |
82 | Page up: Move force +Y 83 |
84 |
85 | Page down: Move force -Y 86 |
87 |
88 | ); 89 | }; 90 | 91 | export default Gui; 92 | -------------------------------------------------------------------------------- /src/components/Renderer.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from 'jotai'; 2 | import React, { useEffect, useRef, useState } from 'react'; 3 | import Particlerenderer from '../particlerenderer'; 4 | import Gui from './Gui'; 5 | import { computeProperties, particleCounter } from './state'; 6 | import Stats from './Stats'; 7 | 8 | interface FrameStats { 9 | frameTime: number; 10 | cpuTime: number; 11 | } 12 | 13 | const Renderer = (): React.ReactElement => { 14 | const canvasEl = useRef(null); 15 | const [frameStats, setFrameStats] = useState({ frameTime: 0, cpuTime: 0 }); 16 | const [computePropertiesState] = useAtom(computeProperties); 17 | const [particleCount] = useAtom(particleCounter); 18 | 19 | const particlerenderer = useRef(null); 20 | const particleChangeTimer = useRef(0); 21 | 22 | const onFrameTimeChanged = (frameTime: number, cpuTime: number): void => { 23 | setFrameStats({ frameTime, cpuTime }); 24 | }; 25 | 26 | useEffect(() => { 27 | if (particlerenderer.current) { 28 | if (particleChangeTimer.current) { 29 | window.clearTimeout(particleChangeTimer.current); 30 | } 31 | particleChangeTimer.current = window.setTimeout(() => { 32 | void (async () => { 33 | console.log(`update particle count: ${particleCount}`); 34 | await particlerenderer.current?.computePipline.updateParticleCount(particleCount); 35 | })(); 36 | }, 1000); 37 | } else if (canvasEl.current) { 38 | particlerenderer.current = new Particlerenderer(canvasEl.current, particleCount, onFrameTimeChanged); 39 | void particlerenderer.current.start(); 40 | } 41 | }, [particleCount]); 42 | 43 | useEffect(() => { 44 | if (particlerenderer.current) { 45 | particlerenderer.current.computePipline.force = computePropertiesState.force; 46 | particlerenderer.current.computePipline.gravity = computePropertiesState.gravity; 47 | particlerenderer.current.particleMaterial.color = new Float32Array([ 48 | computePropertiesState.color.r / 255, 49 | computePropertiesState.color.g / 255, 50 | computePropertiesState.color.b / 255, 51 | computePropertiesState.color.a, 52 | ]); 53 | } 54 | }, [computePropertiesState]); 55 | 56 | return ( 57 | <> 58 | 59 | 60 | 61 | 62 | ); 63 | }; 64 | 65 | export default Renderer; 66 | -------------------------------------------------------------------------------- /src/components/Slider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface SliderProps { 4 | min: number; 5 | max: number; 6 | step: number; 7 | value: number; 8 | labelText?: string; 9 | onValueChange: (value: number) => void; 10 | } 11 | const Slider = (props: SliderProps): React.ReactElement => { 12 | const onSliderChange = (event: React.ChangeEvent): void => { 13 | const value = parseFloat(event.target.value); 14 | props.onValueChange(value); 15 | }; 16 | return ( 17 |
18 | 31 |
32 | ); 33 | }; 34 | 35 | export default Slider; 36 | -------------------------------------------------------------------------------- /src/components/Stats.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface StatsProps { 4 | frameTime: number; 5 | cpuTime: number; 6 | } 7 | 8 | const Stats = (props: StatsProps): React.ReactElement => { 9 | return ( 10 |
11 | {`Avg frame time: ${props.frameTime.toFixed(3)} ms`} 12 |
13 | {`FPS: ${(1000 / props.frameTime).toFixed(2)}`} 14 |
15 | {`Avg CPU time: ${props.cpuTime.toFixed(3)} ms`} 16 |
17 | ); 18 | }; 19 | 20 | export default Stats; 21 | -------------------------------------------------------------------------------- /src/components/state.ts: -------------------------------------------------------------------------------- 1 | import { atomWithReset } from 'jotai/utils'; 2 | 3 | export const computeProperties = atomWithReset({ 4 | gravity: 9.81, 5 | force: 20, 6 | color: { 7 | r: 255, 8 | g: 0, 9 | b: 0, 10 | a: 0.5, 11 | }, 12 | }); 13 | 14 | export const particleCounter = atomWithReset(100_000); 15 | -------------------------------------------------------------------------------- /src/crosshairgeometry.ts: -------------------------------------------------------------------------------- 1 | import { Vec3, Vec4 } from 'wgpu-matrix'; 2 | import WebGPUInterleavedGeometry from './webgpuinterleavedgeometry'; 3 | 4 | const red: Vec4 = new Float32Array([1, 0, 0, 1]); 5 | const green: Vec4 = new Float32Array([0, 1, 0, 1]); 6 | const blue: Vec4 = new Float32Array([0, 0, 1, 1]); 7 | 8 | const CrossHairDimensions: Vec3 = new Float32Array([1, 1, 1]); 9 | 10 | const halfx = CrossHairDimensions[0] / 2; 11 | const halfy = CrossHairDimensions[1] / 2; 12 | const halfz = CrossHairDimensions[2] / 2; 13 | 14 | // prettier-ignore 15 | const vertexArray = new Float32Array([ 16 | // x axis 17 | -halfx, 0, 0, ...red, 18 | halfx, 0, 0, ...red, 19 | 20 | // y axis 21 | 0, -halfy, 0, ...green, 22 | 0, halfy, 0, ...green, 23 | 24 | // z axis 25 | 0, 0, -halfz, ...blue, 26 | 0, 0, halfz, ...blue, 27 | ]); 28 | 29 | // prettier-ignore 30 | const indicesArray = new Uint16Array([ 31 | // x axis 32 | 0, 1, 33 | 34 | // y axis 35 | 2, 3, 36 | 37 | // z axis 38 | 4, 5 39 | ]); 40 | 41 | const geometry = new WebGPUInterleavedGeometry(); 42 | geometry.setVertices(vertexArray, 7 * Float32Array.BYTES_PER_ELEMENT); 43 | geometry.setIndices(indicesArray); 44 | geometry.addAttribute({ shaderLocation: 0, offset: 0, format: 'float32x3' }); 45 | geometry.addAttribute({ shaderLocation: 1, offset: 3 * Float32Array.BYTES_PER_ELEMENT, format: 'float32x4' }); 46 | 47 | export { geometry as CrossHairGeometry }; 48 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | /* Custom css */ 4 | @layer base { 5 | a { 6 | @apply text-blue-600 underline visited:text-purple-600 hover:text-blue-800; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | WebGPU Particles 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './components/App'; 4 | import './index.css'; 5 | 6 | const container = document.getElementById('root'); 7 | if (container) { 8 | const root = createRoot(container); 9 | root.render(); 10 | } 11 | -------------------------------------------------------------------------------- /src/particlegeometry.ts: -------------------------------------------------------------------------------- 1 | import WebGPUGeometry from './webgpugeometry'; 2 | 3 | import { createRandomParticles } from './webgpuhelpers'; 4 | 5 | export default class ParticleGeometry extends WebGPUGeometry { 6 | public constructor(particleCount: number, elements: 3 | 4) { 7 | super(particleCount); 8 | const positionArray = createRandomParticles(particleCount, elements); 9 | this.addAttribute({ 10 | array: positionArray, 11 | stride: elements * Float32Array.BYTES_PER_ELEMENT, 12 | descriptor: { 13 | shaderLocation: 0, 14 | offset: 0, 15 | format: elements === 3 ? 'float32x3' : 'float32x4', 16 | }, 17 | usage: GPUBufferUsage.VERTEX | GPUBufferUsage.STORAGE, 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/particlerenderer.ts: -------------------------------------------------------------------------------- 1 | import Camera from './camera'; 2 | import WebGPURenderer from './webgpurenderer'; 3 | //import { TriangleGeometry } from './trianglegeometry'; 4 | import { Vec2, vec2 } from 'wgpu-matrix'; 5 | import { BoxDimensions, BoxGeometry } from './boxgeometry'; 6 | import { CrossHairGeometry } from './crosshairgeometry'; 7 | import ParticleGeometry from './particlegeometry'; 8 | import WebGPUComputePipline from './webgpucomputepipline'; 9 | import WebGPUMaterial from './webgpumaterial'; 10 | import WebGPUMesh from './webgpumesh'; 11 | import WebGPURenderPipeline from './webgpurenderpipeline'; 12 | 13 | type FrameCallBackT = (frameTime: number, cpuTime: number) => void; 14 | 15 | export default class ParticleRenderer { 16 | private _canvas: HTMLCanvasElement; 17 | private _renderer: WebGPURenderer; 18 | private _camera: Camera; 19 | private readonly _sampleCount = 4; 20 | 21 | /**/ 22 | private _boxMesh: WebGPUMesh; 23 | private _crossHairMesh: WebGPUMesh; 24 | private _particleMesh: WebGPUMesh; 25 | /**/ 26 | 27 | /*/ 28 | private _triangleMesh1: WebGPUMesh; 29 | private _triangleMesh2: WebGPUMesh; 30 | private _triangleMesh3: WebGPUMesh; 31 | private _triangleMesh4: WebGPUMesh; 32 | private readonly _triangleRotation = 0.05; 33 | /**/ 34 | 35 | private _currentTime = 0; 36 | private _frameCount = 0; 37 | private _frameDurationAvg = 0; 38 | private _cpuDurationAvg = 0; 39 | 40 | private _currentMousePos: Vec2 = vec2.create(); 41 | 42 | private readonly _movementSpeed = 0.25; 43 | 44 | private _computePipeLine: WebGPUComputePipline; 45 | private _frameTimeCallback?: FrameCallBackT; 46 | 47 | private _particleMaterial: WebGPUMaterial; 48 | 49 | public constructor(canvas: HTMLCanvasElement, particleCount: number, frameTimeCallback?: FrameCallBackT) { 50 | this._canvas = canvas; 51 | this._frameTimeCallback = frameTimeCallback; 52 | this._canvas.width = this._canvas.offsetWidth * window.devicePixelRatio; 53 | this._canvas.height = this._canvas.offsetHeight * window.devicePixelRatio; 54 | 55 | this._camera = new Camera(45, this._canvas.width / this._canvas.height, 0.1, 1000); 56 | this._camera.position = new Float32Array([0, 0, 15]); 57 | //this._camera.position = [10, 5, 15]; 58 | 59 | this._renderer = new WebGPURenderer(this._canvas, this._camera, { sampleCount: this._sampleCount }); 60 | 61 | const basicFragmentShaderUrl = './assets/shaders/basic.frag.wgsl'; 62 | const basicVertexShaderUrl = './assets/shaders/basic.vert.wgsl'; 63 | const computeShaderUrl = './assets/shaders/particle.comp.wgsl'; 64 | const particleVertexShaderUrl = './assets/shaders/particle.vert.wgsl'; 65 | const particleFragmenShaderUrl = './assets/shaders/particle.frag.wgsl'; 66 | 67 | const boxPipeline = new WebGPURenderPipeline({ 68 | primitiveTopology: 'line-list', 69 | sampleCount: this._sampleCount, 70 | vertexShaderUrl: basicVertexShaderUrl, 71 | fragmentShaderUrl: basicFragmentShaderUrl, 72 | }); 73 | boxPipeline.name = 'boxPipeline'; 74 | 75 | const crossHairPipeline = new WebGPURenderPipeline({ 76 | primitiveTopology: 'line-list', 77 | sampleCount: this._sampleCount, 78 | vertexShaderUrl: basicVertexShaderUrl, 79 | fragmentShaderUrl: basicFragmentShaderUrl, 80 | }); 81 | crossHairPipeline.name = 'crossHairPipeline'; 82 | 83 | const particlePipeline = new WebGPURenderPipeline({ 84 | primitiveTopology: 'point-list', 85 | sampleCount: this._sampleCount, 86 | vertexShaderUrl: particleVertexShaderUrl, 87 | fragmentShaderUrl: particleFragmenShaderUrl, 88 | }); 89 | particlePipeline.name = 'pointPipeline'; 90 | 91 | /**/ 92 | this._boxMesh = new WebGPUMesh(BoxGeometry, boxPipeline); 93 | this._boxMesh.name = 'BoxMesh'; 94 | this._crossHairMesh = new WebGPUMesh(CrossHairGeometry, crossHairPipeline); 95 | this._crossHairMesh.name = 'CrossHairMesh'; 96 | 97 | this._particleMaterial = new WebGPUMaterial(new Float32Array([1.0, 0.0, 1.0, 0.5])); 98 | this._particleMesh = new WebGPUMesh( 99 | new ParticleGeometry(particleCount, 4), 100 | particlePipeline, 101 | this._particleMaterial, 102 | ); 103 | this._particleMesh.name = 'ParticleMesh'; 104 | 105 | this._renderer.addMesh(this._boxMesh); 106 | this._renderer.addMesh(this._crossHairMesh); 107 | this._renderer.addMesh(this._particleMesh); 108 | /**/ 109 | 110 | this._computePipeLine = new WebGPUComputePipline(this._particleMesh, { 111 | computeShaderUrl, 112 | particleCount: particleCount, 113 | }); 114 | this._computePipeLine.name = 'Compute pipeLine'; 115 | 116 | this._renderer.setComputePipeLine(this._computePipeLine); 117 | 118 | /*/ 119 | const trianglePipeline = new WebGPURenderPipeline(this._camera, { 120 | primitiveTopology: 'triangle-list', 121 | sampleCount: this._sampleCount, 122 | vertexShaderUrl: 'basic.vert.wgsl', 123 | fragmentShaderUrl: 'basic.frag.wgsl', 124 | }); 125 | this._triangleMesh1 = new WebGPUMesh(TriangleGeometry, trianglePipeline); 126 | this._triangleMesh2 = new WebGPUMesh(TriangleGeometry, trianglePipeline); 127 | this._triangleMesh3 = new WebGPUMesh(TriangleGeometry, trianglePipeline); 128 | this._triangleMesh4 = new WebGPUMesh(TriangleGeometry, trianglePipeline); 129 | this._triangleMesh1.translate([-2, 2, 0]); 130 | this._triangleMesh2.translate([2, 2, 0]); 131 | this._triangleMesh3.translate([-2, -2, 0]); 132 | this._triangleMesh4.translate([2, -2, 0]); 133 | 134 | this._renderer.addMesh(this._triangleMesh1); 135 | this._renderer.addMesh(this._triangleMesh2); 136 | this._renderer.addMesh(this._triangleMesh3); 137 | this._renderer.addMesh(this._triangleMesh4); 138 | /**/ 139 | } 140 | 141 | public async start(): Promise { 142 | try { 143 | await this._renderer.start(); 144 | const ro = new ResizeObserver((entries) => { 145 | if (!Array.isArray(entries)) { 146 | return; 147 | } 148 | 149 | const width = entries[0].contentRect.width * window.devicePixelRatio; 150 | const height = entries[0].contentRect.height * window.devicePixelRatio; 151 | this._canvas.width = width; 152 | this._canvas.height = height; 153 | 154 | this._camera.aspectRatio = width / height; 155 | this._camera.updateMatrices(); 156 | this._renderer.resize(width, height); 157 | }); 158 | ro.observe(this._canvas); 159 | 160 | this._canvas.addEventListener('wheel', this.onMouseWheel); 161 | this._canvas.addEventListener('mousemove', this.onMouseMove); 162 | document.addEventListener('keydown', this.onKeyDown); 163 | document.addEventListener('keyup', this.onKeyup); 164 | this._currentTime = performance.now(); 165 | this.render(); 166 | } catch (error: unknown) { 167 | console.error(error); 168 | } 169 | } 170 | 171 | private render = (): void => { 172 | const beginFrameTime = performance.now(); 173 | const duration = beginFrameTime - this._currentTime; 174 | this._currentTime = beginFrameTime; 175 | 176 | if (this._frameTimeCallback) { 177 | this._frameCount++; 178 | 179 | this._frameDurationAvg += duration; 180 | 181 | if (this._frameDurationAvg > 1000) { 182 | const avgFrameTime = this._frameDurationAvg / this._frameCount; 183 | const avgCpuTime = this._cpuDurationAvg / this._frameCount; 184 | this._frameCount = 0; 185 | this._frameDurationAvg = 0; 186 | this._cpuDurationAvg = 0; 187 | this._frameTimeCallback(avgFrameTime, avgCpuTime); 188 | /* 189 | this._frameTimeEl.innerHTML = `Avg frame time: ${avgFrameTime.toFixed(3)} ms
FPS: ${( 190 | 1000 / avgFrameTime 191 | ).toFixed(2)}`; 192 | */ 193 | } 194 | } 195 | 196 | /*/ 197 | this._triangleMesh1.rotateEuler(0, 0, duration * this._triangleRotation); 198 | this._triangleMesh2.rotateEuler(0, 0, duration * this._triangleRotation * -1.5); 199 | this._triangleMesh3.rotateEuler(0, 0, duration * this._triangleRotation * 2); 200 | this._triangleMesh4.rotateEuler(0, 0, duration * this._triangleRotation * -2.5); 201 | /**/ 202 | 203 | this._renderer.render(duration); 204 | 205 | window.requestAnimationFrame(this.render); 206 | const endFrameTime = performance.now(); 207 | this._cpuDurationAvg += endFrameTime - beginFrameTime; 208 | }; 209 | 210 | private onMouseWheel = (event: WheelEvent): void => { 211 | let z = (this._camera.position[2] += event.deltaY * 0.02); 212 | z = Math.max(this._camera.zNear, Math.min(this._camera.zFar, z)); 213 | this._camera.position[2] = z; 214 | this._camera.updateMatrices(); 215 | }; 216 | 217 | private onMouseMove = (event: MouseEvent): void => { 218 | const currentPos: Vec2 = new Float32Array([event.clientX, event.clientY]); 219 | if (event.buttons === 1) { 220 | const offset = vec2.subtract(currentPos, this._currentMousePos); 221 | vec2.scale(offset, 0.0025, offset); 222 | 223 | //console.log(offset); 224 | 225 | this._camera.rotateEuler(offset[1], offset[0], 0.0); 226 | } 227 | this._currentMousePos = currentPos; 228 | }; 229 | 230 | private onKeyDown = (event: KeyboardEvent): void => { 231 | const newPosition = this._crossHairMesh.position; 232 | let x = newPosition[0]; 233 | let y = newPosition[1]; 234 | let z = newPosition[2]; 235 | 236 | switch (event.key) { 237 | case 'a': 238 | x -= this._movementSpeed; 239 | break; 240 | case 'd': 241 | x += this._movementSpeed; 242 | break; 243 | case 'w': 244 | z -= this._movementSpeed; 245 | break; 246 | case 's': 247 | z += this._movementSpeed; 248 | break; 249 | case 'PageUp': 250 | y += this._movementSpeed; 251 | break; 252 | case 'PageDown': 253 | y -= this._movementSpeed; 254 | break; 255 | 256 | case ' ': 257 | this._computePipeLine.turnForceOn(); 258 | return; 259 | 260 | default: 261 | return; 262 | } 263 | 264 | const epsilon = 0.001; 265 | const halfx = BoxDimensions[0] / 2 + epsilon; 266 | const halfy = BoxDimensions[1] / 2 + epsilon; 267 | const halfz = BoxDimensions[2] / 2 + epsilon; 268 | 269 | if (x < -halfx || x > halfx) { 270 | x = this._crossHairMesh.position[0]; 271 | } 272 | if (y < -halfy || y > halfy) { 273 | y = this._crossHairMesh.position[1]; 274 | } 275 | if (z < -halfz || z > halfz) { 276 | z = this._crossHairMesh.position[2]; 277 | } 278 | 279 | this._crossHairMesh.position = new Float32Array([x, y, z]); 280 | this._computePipeLine.forcePostion = new Float32Array([x, y, z]); 281 | }; 282 | 283 | private onKeyup = (event: KeyboardEvent): void => { 284 | switch (event.key) { 285 | case ' ': 286 | this._computePipeLine.turnForceOff(); 287 | break; 288 | 289 | default: 290 | break; 291 | } 292 | }; 293 | 294 | public get computePipline(): WebGPUComputePipline { 295 | return this._computePipeLine; 296 | } 297 | 298 | public get particleMaterial(): WebGPUMaterial { 299 | return this._particleMaterial; 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /src/trianglegeometry.ts: -------------------------------------------------------------------------------- 1 | import WebGPUInterleavedGeometry from './webgpuinterleavedgeometry'; 2 | //import WebGPUGeometry from './webgpugeometry'; 3 | 4 | /* 5 | C 6 | /\ 7 | / \ 8 | / \ 9 | / \ 10 | B /________\ A 11 | */ 12 | 13 | // prettier-ignore 14 | export const vertexArray = new Float32Array([ 15 | 1.0, -1.0, 0.0, 1.0, 0.0, 0.0, 1.0, //A red 16 | 17 | -1.0, -1.0, 0.0, 0.0, 1.0, 0.0, 1.0, //B green 18 | 19 | 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, // C blue 20 | ]); 21 | 22 | // prettier-ignore 23 | export const positionArray = new Float32Array([ 24 | 1.0, -1.0, 0.0, // A 25 | 26 | -1.0, -1.0, 0.0, // B 27 | 28 | 0.0, 1.0, 0.0, // C 29 | ]); 30 | 31 | // prettier-ignore 32 | export const colorArray = new Float32Array([ 33 | 1.0, 0.0, 0.0, 1.0, // red 34 | 35 | 0.0, 1.0, 0.0, 1.0, // green 36 | 37 | 0.0, 0.0, 1.0, 1.0, // blue 38 | ]); 39 | 40 | const indicesArray = new Uint16Array([0, 1, 2]); 41 | 42 | /**/ 43 | const geometry = new WebGPUInterleavedGeometry(); 44 | geometry.setVertices(vertexArray, 7 * Float32Array.BYTES_PER_ELEMENT); 45 | geometry.setIndices(indicesArray); 46 | geometry.addAttribute({ shaderLocation: 0, offset: 0, format: 'float32x3' }); 47 | geometry.addAttribute({ shaderLocation: 1, offset: 3 * Float32Array.BYTES_PER_ELEMENT, format: 'float32x4' }); 48 | /**/ 49 | 50 | /*/ 51 | const geometry = new WebGPUGeometry(3); 52 | geometry.setIndices(indicesArray); 53 | geometry.addAttribute({ 54 | array: positionArray, 55 | stride: 3 * Float32Array.BYTES_PER_ELEMENT, 56 | descriptor: { shaderLocation: 0, offset: 0, format: 'float3' }, 57 | }); 58 | geometry.addAttribute({ 59 | array: colorArray, 60 | stride: 4 * Float32Array.BYTES_PER_ELEMENT, 61 | descriptor: { shaderLocation: 1, offset: 0, format: 'float4' }, 62 | }); 63 | /**/ 64 | 65 | export { geometry as TriangleGeometry }; 66 | -------------------------------------------------------------------------------- /src/webgpucomputepipline.ts: -------------------------------------------------------------------------------- 1 | import { Vec3, Vec4, vec3 } from 'wgpu-matrix'; 2 | import { BoxDimensions } from './boxgeometry'; 3 | import ParticleGeometry from './particlegeometry'; 4 | import { createBuffer } from './webgpuhelpers'; 5 | import WebGPUMesh from './webgpumesh'; 6 | import WebGPUPipelineBase from './webgpupipelinebase'; 7 | import WebGPURenderContext from './webgpurendercontext'; 8 | 9 | interface WebGPUComputePiplineOptions { 10 | computeShaderUrl: string; 11 | particleCount: number; 12 | } 13 | 14 | interface ComputeParams { 15 | vHalfBounding: Vec4; 16 | vForcePos: Vec4; 17 | fDeltaTime: number; 18 | fGravity: number; 19 | fForce: number; 20 | fForceOn: number; 21 | } 22 | 23 | export default class WebGPUComputePipline extends WebGPUPipelineBase { 24 | private _options: WebGPUComputePiplineOptions; 25 | private _bindGroupLayout!: GPUBindGroupLayout; 26 | private _bindGroup!: GPUBindGroup; 27 | 28 | private _computeParamsUniformBuffer!: GPUBuffer; 29 | private _computeParamsUniformBufferSize = 0; 30 | private _posBuffer!: GPUBuffer; 31 | private _velBuffer!: GPUBuffer; 32 | 33 | private _computeParams: ComputeParams; 34 | 35 | private _drawMesh: WebGPUMesh; 36 | 37 | private _context!: WebGPURenderContext; 38 | 39 | public constructor(drawMesh: WebGPUMesh, options: WebGPUComputePiplineOptions) { 40 | super(); 41 | 42 | this._drawMesh = drawMesh; 43 | 44 | this._options = options; 45 | 46 | const halfBounding = vec3.scale(BoxDimensions, 0.5); 47 | 48 | this._computeParams = { 49 | vHalfBounding: new Float32Array([halfBounding[0], halfBounding[1], halfBounding[2], 1.0]), 50 | vForcePos: new Float32Array([0, 0, 0, 1]), 51 | fDeltaTime: 0.001, 52 | fGravity: 9.81, // 9.81 m/s² default earth gravity 53 | fForce: 20, 54 | fForceOn: 0, 55 | }; 56 | } 57 | 58 | public async initialize(context: WebGPURenderContext): Promise { 59 | if (this._initialized) { 60 | return; 61 | } 62 | this._initialized = true; 63 | 64 | this._context = context; 65 | 66 | const velArray = new Float32Array(this._options.particleCount * 4); 67 | 68 | this._posBuffer = this._drawMesh.geometry.vertexBuffers[0]; 69 | 70 | const uniformArray = this.getParamsArray(); 71 | this._computeParamsUniformBufferSize = uniformArray.byteLength; 72 | this._computeParamsUniformBuffer = createBuffer( 73 | context.device, 74 | uniformArray, 75 | GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, 76 | ); 77 | 78 | this._velBuffer = createBuffer(context.device, velArray, GPUBufferUsage.VERTEX | GPUBufferUsage.STORAGE); 79 | 80 | this._bindGroupLayout = context.device.createBindGroupLayout({ 81 | entries: [ 82 | { 83 | binding: 0, 84 | visibility: GPUShaderStage.COMPUTE, 85 | buffer: { 86 | type: 'uniform', 87 | }, 88 | }, 89 | { 90 | binding: 1, 91 | visibility: GPUShaderStage.COMPUTE, 92 | buffer: { 93 | type: 'storage', 94 | }, 95 | }, 96 | { 97 | binding: 2, 98 | visibility: GPUShaderStage.COMPUTE, 99 | buffer: { 100 | type: 'storage', 101 | }, 102 | }, 103 | ], 104 | }); 105 | 106 | await this.createBindGroup(); 107 | } 108 | 109 | private async createBindGroup(): Promise { 110 | this._bindGroup = this._context.device.createBindGroup({ 111 | layout: this._bindGroupLayout, 112 | entries: [ 113 | { 114 | binding: 0, 115 | resource: { 116 | buffer: this._computeParamsUniformBuffer, 117 | offset: 0, 118 | size: this._computeParamsUniformBufferSize, 119 | }, 120 | }, 121 | { 122 | binding: 1, 123 | resource: { 124 | buffer: this._posBuffer, 125 | offset: 0, 126 | size: this._options.particleCount * 4 * 4, 127 | }, 128 | }, 129 | { 130 | binding: 2, 131 | resource: { 132 | buffer: this._velBuffer, 133 | offset: 0, 134 | size: this._options.particleCount * 4 * 4, 135 | }, 136 | }, 137 | ], 138 | }); 139 | this._bindGroup.label = `${this.name}-BindGroup`; 140 | 141 | const layout = this._context.device.createPipelineLayout({ 142 | bindGroupLayouts: [this._bindGroupLayout], 143 | }); 144 | 145 | const computeStage: GPUProgrammableStage = { 146 | module: await this.loadShader(this._context, this._options.computeShaderUrl), 147 | entryPoint: 'main', 148 | }; 149 | 150 | const pipelineDesc: GPUComputePipelineDescriptor = { 151 | layout, 152 | compute: computeStage, 153 | }; 154 | 155 | this._pipeline = this._context.device.createComputePipeline(pipelineDesc); 156 | } 157 | 158 | private getParamsArray(): Float32Array { 159 | const array: number[] = []; 160 | 161 | Object.entries(this._computeParams).forEach(([_, value]) => { 162 | if (Array.isArray(value)) { 163 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 164 | array.push(...value); 165 | } else if (value instanceof Float32Array) { 166 | array.push(...value); 167 | } else { 168 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 169 | array.push(value); 170 | } 171 | }); 172 | 173 | // for (let i = 0; i < keys.length; i++) { 174 | // const val = this._computeParams[keys[i]]; 175 | // if (Array.isArray(val)) { 176 | // array.push(...val); 177 | // } else if (val instanceof Float32Array) { 178 | // array.push(...val); 179 | // } else { 180 | // array.push(val); 181 | // } 182 | // } 183 | return new Float32Array(array); 184 | } 185 | 186 | private updateUniformBuffer(): void { 187 | if (this._initialized) { 188 | const uniformArray = this.getParamsArray(); 189 | this._context.queue.writeBuffer(this._computeParamsUniformBuffer, 0, uniformArray.buffer); 190 | } 191 | } 192 | 193 | public get bindGroup(): GPUBindGroup { 194 | return this._bindGroup; 195 | } 196 | 197 | public get gpuPipeline(): GPUComputePipeline { 198 | return this._pipeline as GPUComputePipeline; 199 | } 200 | 201 | public get particleCount(): number { 202 | return this._options.particleCount; 203 | } 204 | 205 | public async updateParticleCount(count: number): Promise { 206 | if (count !== this._options.particleCount) { 207 | if (count <= 0) { 208 | count = 1; 209 | } 210 | this._options.particleCount = count; 211 | 212 | this._drawMesh.geometry = new ParticleGeometry(this._options.particleCount, 4); 213 | this._drawMesh.geometry.initalize(this._context); 214 | this._initialized = false; 215 | await this.initialize(this._context); 216 | } 217 | } 218 | 219 | public turnForceOn(): void { 220 | this._computeParams.fForceOn = 1; 221 | this.updateUniformBuffer(); 222 | } 223 | 224 | public turnForceOff(): void { 225 | this._computeParams.fForceOn = 0; 226 | this.updateUniformBuffer(); 227 | } 228 | 229 | public deltaTime(deltaTime: number): void { 230 | // because deltaTime is in ms and the compute shader calculation wants a fraction of a second 231 | // divide by 1000 is needed 232 | this._computeParams.fDeltaTime = deltaTime / 1000; 233 | this.updateUniformBuffer(); 234 | } 235 | 236 | public set forcePostion(pos: Vec3) { 237 | this._computeParams.vForcePos = new Float32Array([pos[0], pos[1], pos[2], 1.0]); 238 | this.updateUniformBuffer(); 239 | } 240 | 241 | public set force(force: number) { 242 | this._computeParams.fForce = force; 243 | this.updateUniformBuffer(); 244 | } 245 | 246 | public set gravity(gravity: number) { 247 | this._computeParams.fGravity = gravity; 248 | this.updateUniformBuffer(); 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/webgpuentity.ts: -------------------------------------------------------------------------------- 1 | import { Mat4, Quat, Vec3, mat4, quat, vec3 } from 'wgpu-matrix'; 2 | import WebGPUObjectBase from './webgpuobjectbase'; 3 | 4 | export default abstract class WebGPUEntity extends WebGPUObjectBase { 5 | private _modelMatrix = mat4.identity(); 6 | private _position = vec3.create(); 7 | private _scale: Vec3 = new Float32Array([1.0, 1.0, 1.0]); 8 | private _rotation = quat.identity(); 9 | 10 | public get modelMatrix(): Mat4 { 11 | return this._modelMatrix; 12 | } 13 | 14 | public translate(translation: Vec3): void { 15 | vec3.add(this._position, translation, this._position); 16 | this.updateModelMatrix(); 17 | } 18 | 19 | public rotateQuat(rotation: Quat): void { 20 | quat.multiply(rotation, this._rotation, this._rotation); 21 | this.updateModelMatrix(); 22 | } 23 | 24 | public rotateEuler(angleX: number, angelY: number, angleZ: number): void { 25 | const tempQuat = quat.fromEuler(angleX, angelY, angleZ, 'xzy'); 26 | this.rotateQuat(tempQuat); 27 | } 28 | 29 | public scale(scale: Vec3): void { 30 | vec3.multiply(this._scale, scale, this._scale); 31 | this.updateModelMatrix(); 32 | } 33 | 34 | public get position(): Vec3 { 35 | return this._position; 36 | } 37 | 38 | public set position(newPos: Vec3) { 39 | this._position = newPos; 40 | this.updateModelMatrix(); 41 | } 42 | 43 | protected updateModelMatrix(): void { 44 | const translationMatrix = mat4.translation(this._position); 45 | const rotationMatrix = mat4.fromQuat(this._rotation); 46 | const scaleMatrix = mat4.scaling(this._scale); 47 | 48 | mat4.multiply(translationMatrix, rotationMatrix, this._modelMatrix); 49 | mat4.multiply(this._modelMatrix, scaleMatrix, this._modelMatrix); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/webgpugeometry.ts: -------------------------------------------------------------------------------- 1 | import WebGPUGeometryBase from './webgpugeometrybase'; 2 | import { createBuffer } from './webgpuhelpers'; 3 | import WebGPURenderContext from './webgpurendercontext'; 4 | 5 | interface GeometryAttribute { 6 | array: Float32Array; 7 | descriptor: GPUVertexAttribute; 8 | stride: number; 9 | usage?: GPUBufferUsageFlags; 10 | } 11 | 12 | export default class WebGPUGeometry extends WebGPUGeometryBase { 13 | private _attributes: GeometryAttribute[] = []; 14 | 15 | public constructor(vertexCount: number) { 16 | super(); 17 | this._vertexCount = vertexCount; 18 | } 19 | 20 | public addAttribute(attribute: GeometryAttribute): void { 21 | this._attributes.push(attribute); 22 | } 23 | 24 | public initalize(context: WebGPURenderContext): void { 25 | super.initalize(context); 26 | if (this._initialized) { 27 | return; 28 | } 29 | this._initialized = true; 30 | 31 | for (let i = 0; i < this._attributes.length; i++) { 32 | const attribute = this._attributes[i]; 33 | const buffer = createBuffer(context.device, attribute.array, attribute.usage ?? GPUBufferUsage.VERTEX); 34 | buffer.label = `Buffer-${this.name}-Attribute-${i}`; 35 | this._vertexBuffers.push(buffer); 36 | 37 | this._vertexBufferLayouts.push({ 38 | attributes: [attribute.descriptor], 39 | arrayStride: attribute.stride, 40 | stepMode: 'vertex', 41 | }); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/webgpugeometrybase.ts: -------------------------------------------------------------------------------- 1 | import { createBuffer } from './webgpuhelpers'; 2 | import WebGPUObjectBase from './webgpuobjectbase'; 3 | import WebGPURenderContext from './webgpurendercontext'; 4 | 5 | export default abstract class WebGPUGeometryBase extends WebGPUObjectBase { 6 | private _indicesArray!: Uint16Array; 7 | private _indexBuffer!: GPUBuffer; 8 | protected _vertexBuffers: GPUBuffer[] = []; 9 | protected _initialized = false; 10 | protected _vertexBufferLayouts: GPUVertexBufferLayout[] = []; 11 | protected _indexCount = 0; 12 | protected _vertexCount = 0; 13 | 14 | public setIndices(array: Uint16Array): void { 15 | this._indicesArray = array; 16 | this._indexCount = array.length; 17 | this._initialized = false; 18 | } 19 | 20 | public initalize(context: WebGPURenderContext): void { 21 | if (this._initialized) { 22 | return; 23 | } 24 | 25 | if (this._indexCount > 0) { 26 | this._indexBuffer = createBuffer(context.device, this._indicesArray, GPUBufferUsage.INDEX); 27 | } 28 | } 29 | 30 | public get vertexBufferLayouts(): GPUVertexBufferLayout[] { 31 | return this._vertexBufferLayouts; 32 | } 33 | 34 | public get indexBuffer(): GPUBuffer { 35 | return this._indexBuffer; 36 | } 37 | 38 | public get indexCount(): number { 39 | return this._indexCount; 40 | } 41 | 42 | public get vertexCount(): number { 43 | return this._vertexCount; 44 | } 45 | 46 | public get vertexBuffers(): GPUBuffer[] { 47 | return this._vertexBuffers; 48 | } 49 | 50 | public set vertexBuffers(buffers: GPUBuffer[]) { 51 | this._vertexBuffers = buffers; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/webgpuhelpers.ts: -------------------------------------------------------------------------------- 1 | import { BoxDimensions } from './boxgeometry'; 2 | import { vec3 } from 'wgpu-matrix'; 3 | 4 | export function createBuffer( 5 | device: GPUDevice, 6 | arr: Float32Array | Uint16Array, 7 | usage: GPUBufferUsageFlags, 8 | ): GPUBuffer { 9 | const buffer = device.createBuffer({ 10 | mappedAtCreation: true, 11 | size: arr.byteLength, 12 | usage, 13 | }); 14 | 15 | const bufferMapped = buffer.getMappedRange(); 16 | 17 | const writeArray = arr instanceof Float32Array ? new Float32Array(bufferMapped) : new Uint16Array(bufferMapped); 18 | writeArray.set(arr); 19 | buffer.unmap(); 20 | 21 | return buffer; 22 | } 23 | 24 | export function createRandomParticles(count: number, elements: number): Float32Array { 25 | const randomVector = () => { 26 | return vec3.create( 27 | Math.random() * BoxDimensions[0] - BoxDimensions[0] / 2, 28 | Math.random() * BoxDimensions[1] - BoxDimensions[1] / 2, 29 | Math.random() * BoxDimensions[2] - BoxDimensions[2] / 2, 30 | ); 31 | }; 32 | 33 | // fill with random data 34 | const array = new Float32Array(count * elements); 35 | for (let i = 0; i < count * elements; i += elements) { 36 | const randomPos = randomVector(); 37 | array.set(randomPos, i); 38 | } 39 | 40 | return array; 41 | } 42 | -------------------------------------------------------------------------------- /src/webgpuinterleavedgeometry.ts: -------------------------------------------------------------------------------- 1 | import WebGPUGeometryBase from './webgpugeometrybase'; 2 | import { createBuffer } from './webgpuhelpers'; 3 | import WebGPURenderContext from './webgpurendercontext'; 4 | 5 | export default class WebGPUInterleavedGeometry extends WebGPUGeometryBase { 6 | private _interleavedArray!: Float32Array; 7 | private _stride = 0; 8 | private _attributes: GPUVertexAttribute[] = []; 9 | 10 | public constructor() { 11 | super(); 12 | } 13 | 14 | public setVertices(array: Float32Array, stride: number): void { 15 | this._interleavedArray = array; 16 | this._stride = stride; 17 | this._vertexCount = array.length / (stride / array.BYTES_PER_ELEMENT); 18 | this._initialized = false; 19 | } 20 | 21 | public addAttribute(attribute: GPUVertexAttribute): void { 22 | this._attributes.push(attribute); 23 | } 24 | 25 | public initalize(context: WebGPURenderContext): void { 26 | super.initalize(context); 27 | if (this._initialized) { 28 | return; 29 | } 30 | this._initialized = true; 31 | 32 | this._vertexBuffers.push(createBuffer(context.device, this._interleavedArray, GPUBufferUsage.VERTEX)); 33 | 34 | this._vertexBufferLayouts.push({ 35 | attributes: this._attributes, 36 | arrayStride: this._stride, 37 | stepMode: 'vertex', 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/webgpumaterial.ts: -------------------------------------------------------------------------------- 1 | import { Vec4 } from 'wgpu-matrix'; 2 | import { createBuffer } from './webgpuhelpers'; 3 | import WebGPURenderContext from './webgpurendercontext'; 4 | 5 | export default class WebGPUMaterial { 6 | private _uniformBuffer!: GPUBuffer; 7 | private _bindGroupLayoutEntries: GPUBindGroupLayoutEntry[] = []; 8 | private _bindGroupEntries: GPUBindGroupEntry[] = []; 9 | 10 | private _initialized = false; 11 | private _color: Vec4; 12 | 13 | private _context!: WebGPURenderContext; 14 | 15 | public constructor(color: Vec4) { 16 | this._color = color; 17 | } 18 | 19 | public initalize(context: WebGPURenderContext): void { 20 | if (this._initialized) { 21 | return; 22 | } 23 | this._initialized = true; 24 | 25 | this._context = context; 26 | 27 | const uboArray = new Float32Array([...this._color]); 28 | this._uniformBuffer = createBuffer(context.device, uboArray, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST); 29 | 30 | this._bindGroupLayoutEntries.push({ 31 | binding: 2, 32 | visibility: GPUShaderStage.FRAGMENT, 33 | buffer: { 34 | type: 'uniform', 35 | }, 36 | }); 37 | 38 | this._bindGroupEntries.push({ 39 | binding: 2, 40 | resource: { 41 | buffer: this._uniformBuffer, 42 | }, 43 | }); 44 | } 45 | 46 | private updateUniformBuffer(): void { 47 | if (this._initialized) { 48 | const uboArray = new Float32Array([...this._color]); 49 | this._context.queue.writeBuffer(this._uniformBuffer, 0, uboArray.buffer); 50 | } 51 | } 52 | 53 | public get bindGroupLayoutEntries(): GPUBindGroupLayoutEntry[] { 54 | return this._bindGroupLayoutEntries; 55 | } 56 | 57 | public get bindGroupEntries(): GPUBindGroupEntry[] { 58 | return this._bindGroupEntries; 59 | } 60 | 61 | public set color(color: Vec4) { 62 | this._color = color; 63 | this.updateUniformBuffer(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/webgpumesh.ts: -------------------------------------------------------------------------------- 1 | import Camera from './camera'; 2 | import WebGPUEntity from './webgpuentity'; 3 | import WebGPUGeometry from './webgpugeometry'; 4 | import { createBuffer } from './webgpuhelpers'; 5 | import WebGPUInterleavedGeometry from './webgpuinterleavedgeometry'; 6 | import WebGPUMaterial from './webgpumaterial'; 7 | import WebGPURenderContext from './webgpurendercontext'; 8 | import WebGPURenderPipeline from './webgpurenderpipeline'; 9 | 10 | export default class WebGPUMesh extends WebGPUEntity { 11 | private _geometry: WebGPUInterleavedGeometry | WebGPUGeometry; 12 | private _initialized = false; 13 | private _uniformBuffer: GPUBuffer | undefined; 14 | 15 | private _pipeline: WebGPURenderPipeline; 16 | 17 | private _material: WebGPUMaterial | undefined; 18 | 19 | private _context: WebGPURenderContext | undefined; 20 | 21 | public constructor( 22 | geometry: WebGPUInterleavedGeometry | WebGPUGeometry, 23 | pipeline: WebGPURenderPipeline, 24 | material?: WebGPUMaterial, 25 | ) { 26 | super(); 27 | this._geometry = geometry; 28 | this._pipeline = pipeline; 29 | this._material = material; 30 | } 31 | 32 | public async initalize(context: WebGPURenderContext, camera: Camera): Promise { 33 | if (this._initialized) { 34 | return; 35 | } 36 | this._initialized = true; 37 | this._context = context; 38 | 39 | if (this._material) { 40 | this._material.initalize(context); 41 | } 42 | 43 | this._geometry.initalize(context); 44 | 45 | const uboArray = new Float32Array([...this.modelMatrix]); 46 | this._uniformBuffer = createBuffer(context.device, uboArray, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST); 47 | 48 | const bindGroupLayoutEntries: GPUBindGroupLayoutEntry[] = [ 49 | { 50 | binding: 0, 51 | visibility: GPUShaderStage.VERTEX, 52 | buffer: { 53 | type: 'uniform', 54 | }, 55 | }, 56 | { 57 | binding: 1, 58 | visibility: GPUShaderStage.VERTEX, 59 | buffer: { 60 | type: 'uniform', 61 | }, 62 | }, 63 | ]; 64 | 65 | const bindGroupEntries: GPUBindGroupEntry[] = [ 66 | { 67 | binding: 0, 68 | resource: { 69 | buffer: camera.uniformBuffer, 70 | }, 71 | }, 72 | { 73 | binding: 1, 74 | resource: { 75 | buffer: this._uniformBuffer, 76 | }, 77 | }, 78 | ]; 79 | 80 | if (this._material) { 81 | bindGroupLayoutEntries.push(...this._material.bindGroupLayoutEntries); 82 | bindGroupEntries.push(...this._material.bindGroupEntries); 83 | } 84 | 85 | await this._pipeline.initalize( 86 | context, 87 | this._geometry.vertexBufferLayouts, 88 | bindGroupLayoutEntries, 89 | bindGroupEntries, 90 | ); 91 | } 92 | 93 | private updateUniformBuffer(): void { 94 | if (this._context && this._uniformBuffer) { 95 | const uboArray = new Float32Array([...this.modelMatrix]); 96 | this._context.queue.writeBuffer(this._uniformBuffer, 0, uboArray.buffer); 97 | } 98 | } 99 | 100 | protected updateModelMatrix(): void { 101 | super.updateModelMatrix(); 102 | this.updateUniformBuffer(); 103 | } 104 | 105 | public get geometry(): WebGPUInterleavedGeometry | WebGPUGeometry { 106 | return this._geometry; 107 | } 108 | 109 | public set geometry(geometry: WebGPUInterleavedGeometry | WebGPUGeometry) { 110 | this._geometry = geometry; 111 | } 112 | 113 | public get bindGroup(): GPUBindGroup { 114 | return this._pipeline.bindGroup; 115 | } 116 | 117 | public get gpuPipeline(): GPURenderPipeline { 118 | return this._pipeline.gpuPipeline; 119 | } 120 | 121 | public get material(): WebGPUMaterial | undefined { 122 | return this._material; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/webgpuobjectbase.ts: -------------------------------------------------------------------------------- 1 | export default abstract class WebGPUObjectBase { 2 | private _name = ''; 3 | 4 | public set name(name: string) { 5 | this._name = name; 6 | } 7 | 8 | public get name(): string { 9 | return this._name; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/webgpupipelinebase.ts: -------------------------------------------------------------------------------- 1 | import WebGPUObjectBase from './webgpuobjectbase'; 2 | import WebGPURenderContext from './webgpurendercontext'; 3 | 4 | export default abstract class WebGPUPipelineBase extends WebGPUObjectBase { 5 | protected _initialized = false; 6 | protected _pipeline!: GPURenderPipeline | GPUComputePipeline; 7 | 8 | public constructor() { 9 | super(); 10 | } 11 | 12 | protected async loadShader(context: WebGPURenderContext, shaderUrl: string): Promise { 13 | // let code: Uint32Array | string; 14 | const response = await fetch(shaderUrl); 15 | 16 | const shaderModule = context.device.createShaderModule({ 17 | code: await response.text(), 18 | }); 19 | 20 | return shaderModule; 21 | } 22 | 23 | public get gpuPipeline(): GPURenderPipeline | GPUComputePipeline { 24 | return this._pipeline; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/webgpurendercontext.ts: -------------------------------------------------------------------------------- 1 | export default class WebGPURenderContext { 2 | private _canvas: HTMLCanvasElement; 3 | private _device: GPUDevice; 4 | private _queue: GPUQueue; 5 | 6 | public constructor(canvas: HTMLCanvasElement, device: GPUDevice, queue: GPUQueue) { 7 | this._canvas = canvas; 8 | this._device = device; 9 | this._queue = queue; 10 | } 11 | 12 | public get canvas(): HTMLCanvasElement { 13 | return this._canvas; 14 | } 15 | 16 | public get device(): GPUDevice { 17 | return this._device; 18 | } 19 | 20 | public get queue(): GPUQueue { 21 | return this._queue; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/webgpurenderer.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/triple-slash-reference 2 | /// 3 | 4 | import Camera from './camera'; 5 | import WebGPUComputePipline from './webgpucomputepipline'; 6 | import WebGPUMesh from './webgpumesh'; 7 | import WebGPURenderContext from './webgpurendercontext'; 8 | 9 | interface WebGPURendererOptions { 10 | sampleCount?: number; 11 | } 12 | 13 | export default class WebGPURenderer { 14 | private _canvas: HTMLCanvasElement; 15 | 16 | private _context!: WebGPURenderContext; 17 | 18 | private _meshes: WebGPUMesh[] = []; 19 | 20 | private _presentationContext: GPUCanvasContext | null; 21 | private _presentationSize: GPUExtent3DDict; 22 | private _presentationFormat!: GPUTextureFormat; 23 | 24 | private _renderTarget?: GPUTexture; 25 | private _renderTargetView!: GPUTextureView; 26 | 27 | private _depthTarget!: GPUTexture; 28 | private _depthTargetView!: GPUTextureView; 29 | 30 | private _camera: Camera; 31 | 32 | private _options?: WebGPURendererOptions; 33 | 34 | private _computePipeLine!: WebGPUComputePipline; 35 | 36 | public constructor(canvas: HTMLCanvasElement, camera: Camera, settings?: WebGPURendererOptions) { 37 | this._canvas = canvas; 38 | this._camera = camera; 39 | 40 | this._options = settings; 41 | this._presentationContext = this._canvas.getContext('webgpu'); 42 | 43 | this._presentationSize = { 44 | width: this._canvas.clientWidth * devicePixelRatio, 45 | height: this._canvas.clientHeight * devicePixelRatio, 46 | depthOrArrayLayers: 1, 47 | }; 48 | } 49 | 50 | public static supportsWebGPU(): boolean { 51 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 52 | if (navigator.gpu) { 53 | return true; 54 | } 55 | return false; 56 | } 57 | 58 | private async initialize(): Promise { 59 | const gpu: GPU = navigator.gpu; 60 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 61 | if (!gpu) { 62 | throw new Error('No WebGPU support navigator.gpu not available!'); 63 | } 64 | 65 | const adapter = await gpu.requestAdapter(); 66 | 67 | if (!adapter) { 68 | throw new Error('Could not request Adapter'); 69 | } 70 | console.log(adapter.limits); 71 | 72 | const device = await adapter.requestDevice({ 73 | requiredLimits: { 74 | maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize, 75 | }, 76 | }); 77 | 78 | const queue = device.queue; 79 | 80 | this._context = new WebGPURenderContext(this._canvas, device, queue); 81 | 82 | this._presentationFormat = gpu.getPreferredCanvasFormat(); 83 | 84 | this._presentationContext?.configure({ 85 | device: this._context.device, 86 | format: this._presentationFormat, 87 | alphaMode: 'opaque', 88 | }); 89 | } 90 | 91 | private reCreateSwapChain(): void { 92 | if (this._renderTarget !== undefined) { 93 | this._renderTarget.destroy(); 94 | this._depthTarget.destroy(); 95 | } 96 | 97 | /* render target */ 98 | this._renderTarget = this._context.device.createTexture({ 99 | size: this._presentationSize, 100 | sampleCount: this._options?.sampleCount ?? 1, 101 | format: this._presentationFormat, 102 | usage: GPUTextureUsage.RENDER_ATTACHMENT, 103 | }); 104 | this._renderTargetView = this._renderTarget.createView(); 105 | 106 | /* depth target */ 107 | this._depthTarget = this._context.device.createTexture({ 108 | size: this._presentationSize, 109 | sampleCount: this._options?.sampleCount ?? 1, 110 | format: 'depth24plus-stencil8', 111 | usage: GPUTextureUsage.RENDER_ATTACHMENT, 112 | }); 113 | this._depthTargetView = this._depthTarget.createView(); 114 | } 115 | 116 | private async initializeResources(): Promise { 117 | this._camera.initalize(this._context); 118 | 119 | const meshInitializers: Promise[] = []; 120 | for (const mesh of this._meshes) { 121 | meshInitializers.push(mesh.initalize(this._context, this._camera)); 122 | } 123 | 124 | await Promise.all(meshInitializers); 125 | 126 | await this._computePipeLine.initialize(this._context); 127 | } 128 | 129 | private computePass(deltaTime: number): void { 130 | const commandEncoder = this._context.device.createCommandEncoder(); 131 | 132 | this._computePipeLine.deltaTime(deltaTime); 133 | const passEncoder = commandEncoder.beginComputePass(); 134 | passEncoder.setPipeline(this._computePipeLine.gpuPipeline); 135 | passEncoder.setBindGroup(0, this._computePipeLine.bindGroup); 136 | // passEncoder.dispatch(this._computePipeLine.particleCount, 1, 1); 137 | passEncoder.dispatchWorkgroups(Math.ceil(this._computePipeLine.particleCount / 256)); 138 | passEncoder.end(); 139 | 140 | this._context.queue.submit([commandEncoder.finish()]); 141 | } 142 | 143 | private renderPass(): void { 144 | if (!this._presentationContext) { 145 | throw new Error('No resentationContext given'); 146 | } 147 | const colorAttachment: GPURenderPassColorAttachment = { 148 | view: this._presentationContext.getCurrentTexture().createView(), 149 | loadOp: 'clear', 150 | storeOp: 'store', 151 | }; 152 | 153 | const sampleCount = this._options?.sampleCount ?? 1; 154 | if (sampleCount > 1) { 155 | colorAttachment.view = this._renderTargetView; 156 | colorAttachment.resolveTarget = this._presentationContext.getCurrentTexture().createView(); 157 | } 158 | 159 | const renderPassDesc: GPURenderPassDescriptor = { 160 | colorAttachments: [colorAttachment], 161 | depthStencilAttachment: { 162 | view: this._depthTargetView, 163 | 164 | depthLoadOp: 'clear', 165 | depthClearValue: 1.0, 166 | depthStoreOp: 'store', 167 | 168 | stencilLoadOp: 'clear', 169 | stencilClearValue: 0, 170 | stencilStoreOp: 'store', 171 | }, 172 | }; 173 | 174 | const commandEncoder = this._context.device.createCommandEncoder(); 175 | const passEncoder = commandEncoder.beginRenderPass(renderPassDesc); 176 | 177 | for (const mesh of this._meshes) { 178 | const geometry = mesh.geometry; 179 | passEncoder.setPipeline(mesh.gpuPipeline); 180 | 181 | passEncoder.setBindGroup(0, mesh.bindGroup); 182 | 183 | for (let i = 0; i < geometry.vertexBuffers.length; i++) { 184 | passEncoder.setVertexBuffer(i, geometry.vertexBuffers[i]); 185 | } 186 | if (geometry.indexCount > 0) { 187 | passEncoder.setIndexBuffer(geometry.indexBuffer, 'uint16'); 188 | passEncoder.drawIndexed(geometry.indexCount, 1, 0, 0, 0); 189 | } else { 190 | passEncoder.draw(geometry.vertexCount, 1, 0, 0); 191 | } 192 | } 193 | /**/ 194 | passEncoder.end(); 195 | 196 | this._context.queue.submit([commandEncoder.finish()]); 197 | } 198 | 199 | public resize(width: number, height: number): void { 200 | if (width !== this._presentationSize.width || height !== this._presentationSize.height) { 201 | this._presentationSize = { 202 | width, 203 | height, 204 | depthOrArrayLayers: 1, 205 | }; 206 | this.reCreateSwapChain(); 207 | } 208 | } 209 | 210 | public render = (deltaTime: number): void => { 211 | this.computePass(deltaTime); 212 | this.renderPass(); 213 | }; 214 | 215 | public async start(): Promise { 216 | await this.initialize(); 217 | this.reCreateSwapChain(); 218 | await this.initializeResources(); 219 | } 220 | 221 | public addMesh(mesh: WebGPUMesh): void { 222 | this._meshes.push(mesh); 223 | } 224 | 225 | public setComputePipeLine(pipeline: WebGPUComputePipline): void { 226 | this._computePipeLine = pipeline; 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/webgpurenderpipeline.ts: -------------------------------------------------------------------------------- 1 | import WebGPUPipelineBase from './webgpupipelinebase'; 2 | import WebGPURenderContext from './webgpurendercontext'; 3 | 4 | interface WebGPURenderPipelineOptions { 5 | primitiveTopology?: GPUPrimitiveTopology; 6 | sampleCount?: number; 7 | colorFormat?: GPUTextureFormat; 8 | depthFormat?: GPUTextureFormat; 9 | vertexShaderUrl: string; 10 | fragmentShaderUrl: string; 11 | useWGSL?: boolean; 12 | } 13 | 14 | export default class WebGPURenderPipeline extends WebGPUPipelineBase { 15 | private _options: WebGPURenderPipelineOptions; 16 | private _bindGroup!: GPUBindGroup; 17 | 18 | public constructor(options: WebGPURenderPipelineOptions) { 19 | super(); 20 | const defaultOptions: WebGPURenderPipelineOptions = { 21 | primitiveTopology: 'triangle-list', 22 | sampleCount: 1, 23 | colorFormat: 'bgra8unorm', 24 | depthFormat: 'depth24plus-stencil8', 25 | vertexShaderUrl: '', 26 | fragmentShaderUrl: '', 27 | }; 28 | this._options = { ...defaultOptions, ...options }; 29 | } 30 | 31 | public async initalize( 32 | context: WebGPURenderContext, 33 | vertexBufferLayouts: GPUVertexBufferLayout[], 34 | bindGroupLayoutEntries: GPUBindGroupLayoutEntry[], 35 | bindGroupEntries: GPUBindGroupEntry[], 36 | ): Promise { 37 | if (this._initialized) { 38 | return; 39 | } 40 | this._initialized = true; 41 | 42 | // default undefined, only strip topologies (triangle-strip, line-strip) are allowed to set this 43 | let stripIndexFormat: GPUIndexFormat | undefined = undefined; 44 | if (this._options.primitiveTopology === 'triangle-strip' || this._options.primitiveTopology === 'line-strip') { 45 | stripIndexFormat = 'uint16'; 46 | } 47 | 48 | const primitiveState: GPUPrimitiveState = { 49 | topology: this._options.primitiveTopology, 50 | stripIndexFormat: stripIndexFormat, 51 | frontFace: 'cw', 52 | cullMode: 'none', 53 | }; 54 | 55 | const vertexState: GPUVertexState = { 56 | module: await this.loadShader(context, this._options.vertexShaderUrl), 57 | entryPoint: 'main', 58 | buffers: vertexBufferLayouts, 59 | }; 60 | 61 | const colorState: GPUColorTargetState = { 62 | format: this._options.colorFormat ?? 'bgra8unorm', 63 | blend: { 64 | color: { 65 | srcFactor: 'src-alpha', 66 | dstFactor: 'one-minus-src-alpha', 67 | operation: 'add', 68 | }, 69 | alpha: { 70 | srcFactor: 'src-alpha', 71 | dstFactor: 'one-minus-src-alpha', 72 | operation: 'add', 73 | }, 74 | }, 75 | writeMask: GPUColorWrite.ALL, 76 | }; 77 | 78 | const depthStencilState: GPUDepthStencilState = { 79 | depthWriteEnabled: true, 80 | depthCompare: 'less', 81 | format: this._options.depthFormat ?? 'depth24plus-stencil8', 82 | }; 83 | 84 | const fragmentState: GPUFragmentState = { 85 | module: await this.loadShader(context, this._options.fragmentShaderUrl), 86 | entryPoint: 'main', 87 | targets: [colorState], 88 | }; 89 | 90 | const multiSampleState: GPUMultisampleState = { 91 | count: this._options.sampleCount, 92 | // mask 93 | // alphaToCoverageEnabled: true, // not yet supported 94 | }; 95 | 96 | const bindGroupLayout = context.device.createBindGroupLayout({ 97 | entries: bindGroupLayoutEntries, 98 | }); 99 | 100 | this._bindGroup = context.device.createBindGroup({ 101 | layout: bindGroupLayout, 102 | entries: bindGroupEntries, 103 | }); 104 | 105 | const layout = context.device.createPipelineLayout({ 106 | bindGroupLayouts: [bindGroupLayout], 107 | }); 108 | 109 | const pipelineDesc: GPURenderPipelineDescriptor = { 110 | layout, 111 | vertex: vertexState, 112 | primitive: primitiveState, 113 | fragment: fragmentState, 114 | depthStencil: depthStencilState, 115 | multisample: multiSampleState, 116 | }; 117 | 118 | this._pipeline = context.device.createRenderPipeline(pipelineDesc); 119 | } 120 | 121 | public get gpuPipeline(): GPURenderPipeline { 122 | return this._pipeline as GPURenderPipeline; 123 | } 124 | 125 | public get bindGroup(): GPUBindGroup { 126 | return this._bindGroup; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | mode: 'jit', 3 | content: ['./src/**/*.{html,tsx}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | variants: { 8 | extend: { 9 | textColor: ['visited'], 10 | }, 11 | }, 12 | plugins: [], 13 | }; 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "downlevelIteration": true, 4 | "esModuleInterop": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "isolatedModules": true, 7 | "jsx": "react", 8 | "lib": ["DOM", "ES2022"], 9 | "module": "CommonJS", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "skipLibCheck": true, 13 | "sourceMap": true, 14 | "strict": true, 15 | "target": "ES2022", 16 | "typeRoots": ["./src/@types", "./node_modules/@webgpu/types", "node_modules/@types"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | import tailwindcss from '@tailwindcss/vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import path from 'node:path'; 4 | import process from 'node:process'; 5 | import { defineConfig } from 'vite'; 6 | 7 | export default defineConfig({ 8 | plugins: [react(), tailwindcss()], 9 | server: { port: 8082, host: '0.0.0.0' }, 10 | root: 'src', 11 | publicDir: '../public', 12 | build: { minify: true, outDir: '../dist' }, 13 | resolve: { alias: { '/src': path.resolve(process.cwd(), 'src') } }, 14 | base: '', 15 | }); 16 | --------------------------------------------------------------------------------