From 4adcf09ccaa61c87181210ff86705a4ae5ca27ab Mon Sep 17 00:00:00 2001 From: Tristan Sommer <43797146+trsommer@users.noreply.github.com> Date: Sat, 22 Nov 2025 15:07:16 +0100 Subject: [PATCH] GPU accelerated maskeditor rendering (#6767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## GPU accelerated brush engine for the mask editor - Full GPU acceleration using TypeGPU and type-safe shaders - Catmull-Rom Spline Smoothing - arc-length equidistant resampling - much improved performance, even for huge images - photoshop like opacity clamping for brush strokes - much improved soft brushes - fallback to CPU fully implemented, much improved CPU rendering features as well ### Tested Browsers - Chrome (fully supported) - Safari 26 (fully supported, prev versions CPU fallback) - Firefox (CPU fallback, flags needed for full support) https://github.com/user-attachments/assets/b7b5cb8a-2290-4a95-ae7d-180e11fccdb0 https://github.com/user-attachments/assets/4297aaa5-f249-499a-9b74-869677f1c73b https://github.com/user-attachments/assets/602b4783-3e2b-489e-bcb9-70534bcaac5e ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6767-GPU-accelerated-maskeditor-rendering-2b16d73d3650818cb294e1fca03f6169) by [Unito](https://www.unito.io) --- package.json | 3 + pnpm-lock.yaml | 86 +- pnpm-workspace.yaml | 3 + src/components/maskeditor/BrushCursor.vue | 24 +- .../maskeditor/BrushSettingsPanel.vue | 12 +- .../maskeditor/MaskEditorContent.vue | 34 +- .../maskeditor/dialog/TopBarHeader.vue | 1 + src/composables/maskeditor/ShiftClick.test.ts | 84 + .../maskeditor/StrokeProcessor.test.ts | 108 ++ src/composables/maskeditor/StrokeProcessor.ts | 115 ++ src/composables/maskeditor/brushUtils.test.ts | 47 + src/composables/maskeditor/brushUtils.ts | 34 + .../maskeditor/gpu/GPUBrushRenderer.ts | 805 +++++++++ .../maskeditor/gpu/brushShaders.ts | 171 ++ src/composables/maskeditor/gpu/gpuSchema.ts | 17 + src/composables/maskeditor/splineUtils.ts | 126 ++ src/composables/maskeditor/useBrushDrawing.ts | 1498 +++++++++++++---- .../maskeditor/useCanvasHistory.ts | 70 +- .../maskeditor/useMaskEditorLoader.ts | 63 +- src/extensions/core/maskeditor/types.ts | 2 +- src/stores/maskEditorStore.ts | 25 +- .../maskeditor/useCanvasHistory.test.ts | 21 +- tsconfig.json | 3 +- vite.config.mts | 2 + 24 files changed, 2945 insertions(+), 409 deletions(-) create mode 100644 src/composables/maskeditor/ShiftClick.test.ts create mode 100644 src/composables/maskeditor/StrokeProcessor.test.ts create mode 100644 src/composables/maskeditor/StrokeProcessor.ts create mode 100644 src/composables/maskeditor/brushUtils.test.ts create mode 100644 src/composables/maskeditor/brushUtils.ts create mode 100644 src/composables/maskeditor/gpu/GPUBrushRenderer.ts create mode 100644 src/composables/maskeditor/gpu/brushShaders.ts create mode 100644 src/composables/maskeditor/gpu/gpuSchema.ts create mode 100644 src/composables/maskeditor/splineUtils.ts diff --git a/package.json b/package.json index 294c9de65..1d00f12f3 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@vitest/coverage-v8": "catalog:", "@vitest/ui": "catalog:", "@vue/test-utils": "catalog:", + "@webgpu/types": "catalog:", "cross-env": "catalog:", "eslint": "catalog:", "eslint-config-prettier": "catalog:", @@ -112,6 +113,7 @@ "typescript": "catalog:", "typescript-eslint": "catalog:", "unplugin-icons": "catalog:", + "unplugin-typegpu": "catalog:", "unplugin-vue-components": "catalog:", "uuid": "^11.1.0", "vite": "catalog:", @@ -176,6 +178,7 @@ "semver": "^7.7.2", "three": "^0.170.0", "tiptap-markdown": "^0.8.10", + "typegpu": "catalog:", "vue": "catalog:", "vue-i18n": "catalog:", "vue-router": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87d1264de..6c7c36f58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ catalogs: '@vueuse/integrations': specifier: ^13.9.0 version: 13.9.0 + '@webgpu/types': + specifier: ^0.1.66 + version: 0.1.66 algoliasearch: specifier: ^5.21.0 version: 5.21.0 @@ -246,6 +249,9 @@ catalogs: tw-animate-css: specifier: ^1.3.8 version: 1.3.8 + typegpu: + specifier: ^0.8.2 + version: 0.8.2 typescript: specifier: ^5.9.2 version: 5.9.2 @@ -255,6 +261,9 @@ catalogs: unplugin-icons: specifier: ^0.22.0 version: 0.22.0 + unplugin-typegpu: + specifier: 0.8.0 + version: 0.8.0 unplugin-vue-components: specifier: ^0.28.0 version: 0.28.0 @@ -464,6 +473,9 @@ importers: tiptap-markdown: specifier: ^0.8.10 version: 0.8.10(@tiptap/core@2.10.4(@tiptap/pm@2.10.4)) + typegpu: + specifier: 'catalog:' + version: 0.8.2 vue: specifier: 'catalog:' version: 3.5.13(typescript@5.9.2) @@ -561,6 +573,9 @@ importers: '@vue/test-utils': specifier: 'catalog:' version: 2.4.6 + '@webgpu/types': + specifier: 'catalog:' + version: 0.1.66 cross-env: specifier: 'catalog:' version: 10.1.0 @@ -672,6 +687,9 @@ importers: unplugin-icons: specifier: 'catalog:' version: 0.22.0(@vue/compiler-sfc@3.5.13) + unplugin-typegpu: + specifier: 'catalog:' + version: 0.8.0(typegpu@0.8.2) unplugin-vue-components: specifier: 'catalog:' version: 0.28.0(@babel/parser@7.28.4)(rollup@4.22.4)(vue@3.5.13(typescript@5.9.2)) @@ -1431,6 +1449,10 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} + '@babel/standalone@7.28.5': + resolution: {integrity: sha512-1DViPYJpRU50irpGMfLBQ9B4kyfQuL6X7SS7pwTeWeZX0mNkjzPi0XFqxCjSdddZXUQy4AhnQnnesA/ZHnvAdw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -3790,8 +3812,8 @@ packages: peerDependencies: vue: ^3.5.0 - '@webgpu/types@0.1.51': - resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==} + '@webgpu/types@0.1.66': + resolution: {integrity: sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==} '@xstate/fsm@1.6.5': resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==} @@ -6038,6 +6060,10 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true + magic-string-ast@1.0.3: + resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==} + engines: {node: '>=20.19.0'} + magic-string@0.30.19: resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} @@ -7411,6 +7437,14 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyest-for-wgsl@0.1.3: + resolution: {integrity: sha512-Wm5ADG1UyDxykf42S1gLYP4U9e1QP/TdtJeovQi6y68zttpiFLKqQGioHmPs9Mjysh7YMSAr/Lpuk0cD2MVdGA==} + engines: {node: '>=12.20.0'} + + tinyest@0.1.2: + resolution: {integrity: sha512-aHRmouyowIq1P5jrTF+YK6pGX+WuvFtSCLbqk91yHnU3SWQRIcNIamZLM5XF6lLqB13AWz0PGPXRff2QGDsxIg==} + engines: {node: '>=12.20.0'} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -7537,6 +7571,13 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} + typed-binary@4.3.2: + resolution: {integrity: sha512-HT3pIBM2njCZUmeczDaQUUErGiM6GXFCqMsHegE12HCoBtvHCkfR10JJni0TeGOTnLilTd6YFyj+YhflqQDrDQ==} + + typegpu@0.8.2: + resolution: {integrity: sha512-wkMJWhJE0pSkw2G/FesjqjbtHkREyOKu1Zmyj19xfmaX5+65YFwgfQNKSK8CxqN4kJkP7JFelLDJTSYY536TYg==} + engines: {node: '>=12.20.0'} + typescript-eslint@8.44.0: resolution: {integrity: sha512-ib7mCkYuIzYonCq9XWF5XNw+fkj2zg629PSa9KNIQ47RXFF763S5BIX4wqz1+FLPogTZoiw8KmCiRPRa8bL3qw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -7641,6 +7682,11 @@ packages: vue-template-es2015-compiler: optional: true + unplugin-typegpu@0.8.0: + resolution: {integrity: sha512-VJHdXSXGOkAx0WhwFczhVUjAI6HyDkrQXk20HnwyuzIE3FdqE5l9sJTCYZzoVGo3z8i/IA5TMHCDzzP0Bc97Cw==} + peerDependencies: + typegpu: ^0.8.0 + unplugin-vue-components@0.28.0: resolution: {integrity: sha512-jiTGtJ3JsRFBjgvyilfrX7yUoGKScFgbdNw+6p6kEXU+Spf/rhxzgvdfuMcvhCcLmflB/dY3pGQshYBVGOUx7Q==} engines: {node: '>=14'} @@ -8969,6 +9015,8 @@ snapshots: '@babel/runtime@7.28.4': {} + '@babel/standalone@7.28.5': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -11016,7 +11064,7 @@ snapshots: '@tweenjs/tween.js': 23.1.3 '@types/stats.js': 0.17.3 '@types/webxr': 0.5.20 - '@webgpu/types': 0.1.51 + '@webgpu/types': 0.1.66 fflate: 0.8.2 meshoptimizer: 0.18.1 @@ -11519,7 +11567,7 @@ snapshots: dependencies: vue: 3.5.13(typescript@5.9.2) - '@webgpu/types@0.1.51': {} + '@webgpu/types@0.1.66': {} '@xstate/fsm@1.6.5': {} @@ -14000,6 +14048,10 @@ snapshots: lz-string@1.5.0: {} + magic-string-ast@1.0.3: + dependencies: + magic-string: 0.30.19 + magic-string@0.30.19: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -15864,6 +15916,12 @@ snapshots: tinybench@2.9.0: {} + tinyest-for-wgsl@0.1.3: + dependencies: + tinyest: 0.1.2 + + tinyest@0.1.2: {} + tinyexec@0.3.2: {} tinyexec@1.0.1: {} @@ -15995,6 +16053,13 @@ snapshots: reflect.getprototypeof: 1.0.10 optional: true + typed-binary@4.3.2: {} + + typegpu@0.8.2: + dependencies: + tinyest: 0.1.2 + typed-binary: 4.3.2 + typescript-eslint@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2): dependencies: '@typescript-eslint/eslint-plugin': 8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2) @@ -16090,6 +16155,19 @@ snapshots: transitivePeerDependencies: - supports-color + unplugin-typegpu@0.8.0(typegpu@0.8.2): + dependencies: + '@babel/standalone': 7.28.5 + defu: 6.1.4 + estree-walker: 3.0.3 + magic-string-ast: 1.0.3 + pathe: 2.0.3 + picomatch: 4.0.3 + tinyest: 0.1.2 + tinyest-for-wgsl: 0.1.3 + typegpu: 0.8.2 + unplugin: 2.3.5 + unplugin-vue-components@0.28.0(@babel/parser@7.28.4)(rollup@4.22.4)(vue@3.5.13(typescript@5.9.2)): dependencies: '@antfu/utils': 0.7.10 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9da20a48a..03b59cd17 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -43,6 +43,7 @@ catalog: '@vue/test-utils': ^2.4.6 '@vueuse/core': ^11.0.0 '@vueuse/integrations': ^13.9.0 + '@webgpu/types': ^0.1.66 algoliasearch: ^5.21.0 axios: ^1.8.2 cross-env: ^10.1.0 @@ -83,9 +84,11 @@ catalog: tailwindcss-primeui: ^0.6.1 tsx: ^4.15.6 tw-animate-css: ^1.3.8 + typegpu: ^0.8.2 typescript: ^5.9.2 typescript-eslint: ^8.44.0 unplugin-icons: ^0.22.0 + unplugin-typegpu: 0.8.0 unplugin-vue-components: ^0.28.0 vite: ^5.4.19 vite-plugin-dts: ^4.5.4 diff --git a/src/components/maskeditor/BrushCursor.vue b/src/components/maskeditor/BrushCursor.vue index 0f9c3463c..f43f69b6d 100644 --- a/src/components/maskeditor/BrushCursor.vue +++ b/src/components/maskeditor/BrushCursor.vue @@ -26,6 +26,10 @@