diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index c61026be..0552e971 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -17,8 +17,12 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: "https://registry.npmjs.org" + - run: npm install -g npm@latest - run: npm install - run: npm run test @@ -28,7 +32,11 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: "https://registry.npmjs.org" + - run: npm install -g npm@latest - run: npm install - run: npm run test diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b97d39af..b089ac53 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,8 +17,12 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: "https://registry.npmjs.org" + - run: npm install -g npm@latest - run: npm install - run: npm run lint @@ -28,7 +32,11 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: "https://registry.npmjs.org" + - run: npm install -g npm@latest - run: npm install - run: npm run lint \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 37e85efa..95c1aa8f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,11 +12,12 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: - node-version: "22.x" + node-version: 24 registry-url: "https://registry.npmjs.org" + - run: npm install -g npm@latest - run: npm install - run: npm run publish:dist - run: cd ~/work/player/player/dist/src && npm publish --access public diff --git a/.gitignore b/.gitignore index c5695e03..c99f40d8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ build coverage .DS_Store .idea -Thumbs.db \ No newline at end of file +Thumbs.db +.playwright-mcp +playwright-report +test-results \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..f1ad0ab1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,130 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Next2D Player is a WebGL/WebGPU-based 2D graphics rendering engine for creating rich, interactive graphics, games, and cross-platform applications. It uses hardware acceleration for graphics processing and OffscreenCanvas with web workers for multi-threaded rendering performance. + +## Build & Development Commands + +```bash +npm install # Install dependencies +npm start # Start dev server with Vite (opens index.html) +npm test # Run all tests with Vitest +npm run lint # ESLint for src/**/*.ts and packages/**/*.ts +npm run build:vite # Build production bundle +npm run clean # Clean build artifacts +``` + +### Running a Single Test + +Tests use Vitest. To run a specific test file: +```bash +npx vitest packages/webgl/src/Blend/service/BlendAddService.test.ts +``` + +Or run tests matching a pattern: +```bash +npx vitest --testNamePattern "BlendAddService" +``` + +## Architecture + +### Package Structure (Monorepo with npm workspaces) + +The `packages/` directory contains modular packages with strict dependency rules: + +**Core packages (loosely coupled, no cross-imports allowed):** +- `@next2d/events` - Event system +- `@next2d/cache` - Caching utilities +- `@next2d/filters` - Image filters +- `@next2d/geom` - Geometry/matrix utilities +- `@next2d/texture-packer` - Texture atlas packing +- `@next2d/render-queue` - Render command queue + +**Rendering layer:** +- `@next2d/webgl` - WebGL rendering context and operations +- `@next2d/webgpu` - WebGPU rendering (alternative backend) +- `@next2d/renderer` - OffscreenCanvas worker-based renderer (imports only `@next2d/webgl`) + +**Display layer:** +- `@next2d/display` - DisplayObject hierarchy (Shape, MovieClip, Bitmap, etc.) +- `@next2d/text` - TextField rendering +- `@next2d/media` - Audio/Video support +- `@next2d/ui` - UI components +- `@next2d/net` - Network/loading utilities + +**Entry point:** +- `@next2d/core` - Main Next2D class, Player, Canvas (references other packages but cannot be referenced BY other packages) + +### Code Organization Pattern + +Each class follows a `usecase`/`service` pattern for method implementation: + +``` +class => method => service (simple operations) +class => method => usecase => service (complex operations) +``` + +Key rules: +- Logic lives in `usecase` or `service` files, not in class methods +- Services cannot call other services directly +- Usecases orchestrate multiple services +- Methods only set class variables (`private`/`protected`) + +Directory structure example: +``` +packages/webgl/src/ + Context.ts # Main class + Context/ + service/ContextResetService.ts # Simple operations + service/ContextResetService.test.ts + usecase/ContextBindUseCase.ts # Complex operations + usecase/ContextBindUseCase.test.ts +``` + +### Rendering Pipeline + +The player uses a two-thread architecture: +1. **Main thread**: DisplayObject tree management, event handling, animation logic +2. **Worker thread**: WebGL/WebGPU rendering via OffscreenCanvas + +Flow: DisplayObjects -> RenderQueue -> Worker -> WebGL Context -> Canvas + +Key rendering features: +- Texture Atlas with binary tree packing for efficient GPU memory +- Instanced array rendering for batch drawing +- Filter/blend effects rendered to texture cache +- Mask rendering with stencil buffer + +## WebGL/WebGPU Renderer Switching + +The renderer backend is controlled by the `useWebGPU` flag in: +`packages/renderer/src/Command/service/CommandInitializeContextService.ts` + +- `const useWebGPU: boolean = true;` → WebGPU renderer +- `const useWebGPU: boolean = false;` → WebGL renderer + +E2E tests use whichever renderer is set by this flag. To compare WebGL vs WebGPU output: +1. Set `useWebGPU = false`, run e2e tests for WebGL snapshots +2. Set `useWebGPU = true`, run e2e tests for WebGPU snapshots +3. Compare the generated snapshots + +### E2E Tests + +E2E tests use Playwright. Run from the `e2e/` directory: +```bash +cd e2e +npx playwright test tests/sprite.spec.ts --project=webgl --update-snapshots +npx playwright test tests/sprite.spec.ts --project=webgpu --update-snapshots +``` + +Snapshots are saved to: +- `e2e/snapshots/webgl/{spec}-snapshots/` +- `e2e/snapshots/webgpu/{spec}-snapshots/` + +## Requirements + +- Node.js >= v22.x +- TypeScript ES2020 target diff --git a/drawing_flow_chart.svg b/drawing_flow_chart.svg deleted file mode 100644 index 4f7b92b1..00000000 --- a/drawing_flow_chart.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
YES
YES
filter or blend
filter or blend
Shape
(Bitmap or Vector)
Shape...
TextField
(canvas2d)
TextField...
Video
(Video Element)
Video...
NO
NO
NO
NO
YES
YES
Is there a cache?
Is there a cache?
Instanced Arrays
matrix
matrix
colorTransform
colorTransform
Coordinates
Coordinates
matrix
matrix
colorTransform
colorTransform
Coordinates
Coordinates
matrix
matrix
colorTransform
colorTransform
Coordinates
Coordinates
framebuffer
(offscreen rendering)
framebuffer...
60fps
60fps
rendering
rendering
drawArraysInstanced
drawArraysInstanced
main framebuffer
main framebuffer
drawing flow chart
drawing flow chart
filter or blend or mask
filter or blend or mask
Shape
(Bitmap or Vector)
Shape...
TextField
(canvas2d)
TextField...
Video
(Video Element)
Video...
NO
NO
YES
YES
Is there a cache?
Is there a cache?
Array of rendering information
Array of rendering information
Instanced Arrays
matrix
matrix
colorTransform
colorTransform
Coordinates
Coordinates
matrix
matrix
colorTransform
colorTransform
Coordinates
Coordinates
matrix
matrix
colorTransform
colorTransform
Coordinates
Coordinates
framebuffer
(offscreen rendering)
framebuffer...
drawArrays
drawArrays
texture
cache
texture...
filter or blend
filter or blend
rendering
rendering
drawArraysInstanced
drawArraysInstanced
NO
NO
Is there a cache?
Is there a cache?
Texture Atlas
(Drawing with binary trees)
Texture Atlas...
container
container
container
container
Coordinates
{x, y, w, h}
Coordinates...
Array of rendering information
Array of rendering information
NO
NO
YES
YES
Filter or Blend
Filter or Blend
YES
YES
NO
NO
Is there a cache?
Is there a cache?
cache
cache
rendering
rendering
YES
YES
drawArrays
drawArrays
NO
NO
mask rendering
mask rendering
Text is not SVG - cannot display
\ No newline at end of file diff --git a/e2e/fixtures/images/test-asymmetric.png b/e2e/fixtures/images/test-asymmetric.png new file mode 100644 index 00000000..781fd448 Binary files /dev/null and b/e2e/fixtures/images/test-asymmetric.png differ diff --git a/e2e/fixtures/images/test-checker.png b/e2e/fixtures/images/test-checker.png new file mode 100644 index 00000000..981c7948 Binary files /dev/null and b/e2e/fixtures/images/test-checker.png differ diff --git a/e2e/fixtures/images/test-circle.png b/e2e/fixtures/images/test-circle.png new file mode 100644 index 00000000..30a49686 Binary files /dev/null and b/e2e/fixtures/images/test-circle.png differ diff --git a/e2e/fixtures/images/test-gradient.png b/e2e/fixtures/images/test-gradient.png new file mode 100644 index 00000000..6b2b4fe4 Binary files /dev/null and b/e2e/fixtures/images/test-gradient.png differ diff --git a/e2e/fixtures/images/test-star.png b/e2e/fixtures/images/test-star.png new file mode 100644 index 00000000..2b8ff162 Binary files /dev/null and b/e2e/fixtures/images/test-star.png differ diff --git a/e2e/fixtures/images/test-stripe.png b/e2e/fixtures/images/test-stripe.png new file mode 100644 index 00000000..a927ac6c Binary files /dev/null and b/e2e/fixtures/images/test-stripe.png differ diff --git a/e2e/fixtures/videos/movie.mp4 b/e2e/fixtures/videos/movie.mp4 new file mode 100755 index 00000000..c09e412d Binary files /dev/null and b/e2e/fixtures/videos/movie.mp4 differ diff --git a/e2e/pages/blendmode/all-modes.html b/e2e/pages/blendmode/all-modes.html new file mode 100644 index 00000000..6a122354 --- /dev/null +++ b/e2e/pages/blendmode/all-modes.html @@ -0,0 +1,490 @@ + + + + + + Next2D E2E - All BlendModes + + + + + + + diff --git a/e2e/pages/container/child-management.html b/e2e/pages/container/child-management.html new file mode 100644 index 00000000..46b6bcd7 --- /dev/null +++ b/e2e/pages/container/child-management.html @@ -0,0 +1,146 @@ + + + + + + Next2D E2E - Container Child Management + + + + + + + diff --git a/e2e/pages/display-object/color-transform.html b/e2e/pages/display-object/color-transform.html new file mode 100644 index 00000000..2ab40a06 --- /dev/null +++ b/e2e/pages/display-object/color-transform.html @@ -0,0 +1,108 @@ + + + + + + Next2D E2E - ColorTransform + + + + + + + diff --git a/e2e/pages/display-object/nested-transforms.html b/e2e/pages/display-object/nested-transforms.html new file mode 100644 index 00000000..9a71662d --- /dev/null +++ b/e2e/pages/display-object/nested-transforms.html @@ -0,0 +1,102 @@ + + + + + + Next2D E2E - Nested Transforms + + + + + + + diff --git a/e2e/pages/display-object/properties.html b/e2e/pages/display-object/properties.html new file mode 100644 index 00000000..5c6f6da3 --- /dev/null +++ b/e2e/pages/display-object/properties.html @@ -0,0 +1,129 @@ + + + + + + Next2D E2E - DisplayObject Properties + + + + + + + diff --git a/e2e/pages/filter/bevel.html b/e2e/pages/filter/bevel.html new file mode 100644 index 00000000..22306911 --- /dev/null +++ b/e2e/pages/filter/bevel.html @@ -0,0 +1,136 @@ + + + + + + Next2D E2E - BevelFilter + + + + + + + diff --git a/e2e/pages/filter/blur.html b/e2e/pages/filter/blur.html new file mode 100644 index 00000000..2e34cc20 --- /dev/null +++ b/e2e/pages/filter/blur.html @@ -0,0 +1,64 @@ + + + + + + Next2D E2E - BlurFilter + + + + + + + diff --git a/e2e/pages/filter/color-matrix.html b/e2e/pages/filter/color-matrix.html new file mode 100644 index 00000000..2fb8dea3 --- /dev/null +++ b/e2e/pages/filter/color-matrix.html @@ -0,0 +1,289 @@ + + + + + + Next2D E2E - ColorMatrixFilter + + + + + + + diff --git a/e2e/pages/filter/convolution.html b/e2e/pages/filter/convolution.html new file mode 100644 index 00000000..9f145a1c --- /dev/null +++ b/e2e/pages/filter/convolution.html @@ -0,0 +1,86 @@ + + + + + + Next2D E2E - ConvolutionFilter + + + + + + + diff --git a/e2e/pages/filter/displacement.html b/e2e/pages/filter/displacement.html new file mode 100644 index 00000000..90742b0e --- /dev/null +++ b/e2e/pages/filter/displacement.html @@ -0,0 +1,320 @@ + + + + + + Next2D E2E - DisplacementMapFilter + + + + + + + diff --git a/e2e/pages/filter/drop-shadow.html b/e2e/pages/filter/drop-shadow.html new file mode 100644 index 00000000..f7a08dc7 --- /dev/null +++ b/e2e/pages/filter/drop-shadow.html @@ -0,0 +1,223 @@ + + + + + + Next2D E2E - DropShadowFilter + + + + + + + diff --git a/e2e/pages/filter/filter-modes.html b/e2e/pages/filter/filter-modes.html new file mode 100644 index 00000000..694915b9 --- /dev/null +++ b/e2e/pages/filter/filter-modes.html @@ -0,0 +1,156 @@ + + + + + + Next2D E2E - Filter Modes + + + + + + + diff --git a/e2e/pages/filter/filter-quality.html b/e2e/pages/filter/filter-quality.html new file mode 100644 index 00000000..67881fdf --- /dev/null +++ b/e2e/pages/filter/filter-quality.html @@ -0,0 +1,97 @@ + + + + + + Next2D E2E - Filter Quality + + + + + + + diff --git a/e2e/pages/filter/glow.html b/e2e/pages/filter/glow.html new file mode 100644 index 00000000..0aff9883 --- /dev/null +++ b/e2e/pages/filter/glow.html @@ -0,0 +1,187 @@ + + + + + + Next2D E2E - GlowFilter + + + + + + + diff --git a/e2e/pages/filter/gradient-bevel.html b/e2e/pages/filter/gradient-bevel.html new file mode 100644 index 00000000..e7d15427 --- /dev/null +++ b/e2e/pages/filter/gradient-bevel.html @@ -0,0 +1,364 @@ + + + + + + Next2D E2E - GradientBevelFilter + + + + + + + diff --git a/e2e/pages/filter/gradient-glow.html b/e2e/pages/filter/gradient-glow.html new file mode 100644 index 00000000..ae374675 --- /dev/null +++ b/e2e/pages/filter/gradient-glow.html @@ -0,0 +1,434 @@ + + + + + + Next2D E2E - GradientGlowFilter + + + + + + + diff --git a/e2e/pages/filter/multi-filter.html b/e2e/pages/filter/multi-filter.html new file mode 100644 index 00000000..43c14322 --- /dev/null +++ b/e2e/pages/filter/multi-filter.html @@ -0,0 +1,206 @@ + + + + + + Next2D E2E - MultiFilter + + + + + + + diff --git a/e2e/pages/mask/sprite-mask.html b/e2e/pages/mask/sprite-mask.html new file mode 100644 index 00000000..1a970242 --- /dev/null +++ b/e2e/pages/mask/sprite-mask.html @@ -0,0 +1,626 @@ + + + + + + Next2D E2E - Sprite Mask + + + + + + + diff --git a/e2e/pages/shape/fill-bitmap.html b/e2e/pages/shape/fill-bitmap.html new file mode 100644 index 00000000..e73c251f --- /dev/null +++ b/e2e/pages/shape/fill-bitmap.html @@ -0,0 +1,150 @@ + + + + + + Next2D E2E - Fill Bitmap + + + + + + + diff --git a/e2e/pages/shape/fill-gradient.html b/e2e/pages/shape/fill-gradient.html new file mode 100644 index 00000000..c69055ba --- /dev/null +++ b/e2e/pages/shape/fill-gradient.html @@ -0,0 +1,227 @@ + + + + + + Next2D E2E - Fill Gradient + + + + + + + diff --git a/e2e/pages/shape/fill-solid.html b/e2e/pages/shape/fill-solid.html new file mode 100644 index 00000000..eda61092 --- /dev/null +++ b/e2e/pages/shape/fill-solid.html @@ -0,0 +1,102 @@ + + + + + + Next2D E2E - Fill Solid + + + + + + + diff --git a/e2e/pages/shape/graphics-clear.html b/e2e/pages/shape/graphics-clear.html new file mode 100644 index 00000000..985b0898 --- /dev/null +++ b/e2e/pages/shape/graphics-clear.html @@ -0,0 +1,76 @@ + + + + + + Next2D E2E - Graphics Clear + + + + + + + diff --git a/e2e/pages/shape/graphics-clone.html b/e2e/pages/shape/graphics-clone.html new file mode 100644 index 00000000..57e127ab --- /dev/null +++ b/e2e/pages/shape/graphics-clone.html @@ -0,0 +1,86 @@ + + + + + + Next2D E2E - Graphics Clone + + + + + + + diff --git a/e2e/pages/shape/line-bitmap.html b/e2e/pages/shape/line-bitmap.html new file mode 100644 index 00000000..b3a83d2d --- /dev/null +++ b/e2e/pages/shape/line-bitmap.html @@ -0,0 +1,166 @@ + + + + + + Next2D E2E - Line Bitmap + + + + + + + diff --git a/e2e/pages/shape/line-gradient.html b/e2e/pages/shape/line-gradient.html new file mode 100644 index 00000000..b968b35f --- /dev/null +++ b/e2e/pages/shape/line-gradient.html @@ -0,0 +1,190 @@ + + + + + + Next2D E2E - Line Gradient + + + + + + + diff --git a/e2e/pages/shape/line-style.html b/e2e/pages/shape/line-style.html new file mode 100644 index 00000000..a743683f --- /dev/null +++ b/e2e/pages/shape/line-style.html @@ -0,0 +1,272 @@ + + + + + + Next2D E2E - Line Style + + + + + + + diff --git a/e2e/pages/shape/load-image-flip.html b/e2e/pages/shape/load-image-flip.html new file mode 100644 index 00000000..8e8912ae --- /dev/null +++ b/e2e/pages/shape/load-image-flip.html @@ -0,0 +1,101 @@ + + + + + + Next2D E2E - Shape Load Image Flip Test + + + + + + + diff --git a/e2e/pages/shape/load-image.html b/e2e/pages/shape/load-image.html new file mode 100644 index 00000000..ac2a9dbf --- /dev/null +++ b/e2e/pages/shape/load-image.html @@ -0,0 +1,283 @@ + + + + + + Next2D E2E - Shape Load Image + + + + + + + diff --git a/e2e/pages/shape/paths.html b/e2e/pages/shape/paths.html new file mode 100644 index 00000000..725e3a66 --- /dev/null +++ b/e2e/pages/shape/paths.html @@ -0,0 +1,219 @@ + + + + + + Next2D E2E - Paths + + + + + + + diff --git a/e2e/pages/shape/scale9grid.html b/e2e/pages/shape/scale9grid.html new file mode 100644 index 00000000..473d858a --- /dev/null +++ b/e2e/pages/shape/scale9grid.html @@ -0,0 +1,199 @@ + + + + + + Next2D E2E - Scale9Grid + + + + + + + diff --git a/e2e/pages/shape/shapes.html b/e2e/pages/shape/shapes.html new file mode 100644 index 00000000..cf2ab982 --- /dev/null +++ b/e2e/pages/shape/shapes.html @@ -0,0 +1,228 @@ + + + + + + Next2D E2E - Basic Shapes + + + + + + + diff --git a/e2e/pages/sprite/sprite-blend.html b/e2e/pages/sprite/sprite-blend.html new file mode 100644 index 00000000..24e4484e --- /dev/null +++ b/e2e/pages/sprite/sprite-blend.html @@ -0,0 +1,232 @@ + + + + + + Next2D E2E - Sprite BlendMode + + + + + + + diff --git a/e2e/pages/sprite/sprite-filter.html b/e2e/pages/sprite/sprite-filter.html new file mode 100644 index 00000000..9dd43a23 --- /dev/null +++ b/e2e/pages/sprite/sprite-filter.html @@ -0,0 +1,129 @@ + + + + + + Next2D E2E - Sprite Filter + + + + + + + diff --git a/e2e/pages/sprite/sprite-nested-filter.html b/e2e/pages/sprite/sprite-nested-filter.html new file mode 100644 index 00000000..170f2737 --- /dev/null +++ b/e2e/pages/sprite/sprite-nested-filter.html @@ -0,0 +1,582 @@ + + + + + + Next2D E2E - Sprite Nested Filter + ColorTransform + + + + + + + diff --git a/e2e/pages/sprite/video-in-sprite-test.html b/e2e/pages/sprite/video-in-sprite-test.html new file mode 100644 index 00000000..1e66b52f --- /dev/null +++ b/e2e/pages/sprite/video-in-sprite-test.html @@ -0,0 +1,194 @@ + + + + + + Next2D E2E - Video in Sprite Debug + + + + + + + diff --git a/e2e/pages/textfield/auto-font-size.html b/e2e/pages/textfield/auto-font-size.html new file mode 100644 index 00000000..f22684a8 --- /dev/null +++ b/e2e/pages/textfield/auto-font-size.html @@ -0,0 +1,77 @@ + + + + + + Next2D E2E - TextField AutoFontSize + + + + + + + diff --git a/e2e/pages/textfield/basic.html b/e2e/pages/textfield/basic.html new file mode 100644 index 00000000..70465eb3 --- /dev/null +++ b/e2e/pages/textfield/basic.html @@ -0,0 +1,262 @@ + + + + + + Next2D E2E - TextField Basic + + + + + + + diff --git a/e2e/pages/textfield/blendmode.html b/e2e/pages/textfield/blendmode.html new file mode 100644 index 00000000..fe545e5e --- /dev/null +++ b/e2e/pages/textfield/blendmode.html @@ -0,0 +1,510 @@ + + + + + + Next2D E2E - TextField BlendModes + + + + + + + diff --git a/e2e/pages/textfield/filter.html b/e2e/pages/textfield/filter.html new file mode 100644 index 00000000..48b8bec8 --- /dev/null +++ b/e2e/pages/textfield/filter.html @@ -0,0 +1,855 @@ + + + + + + Next2D E2E - TextField Filters + + + + + + + diff --git a/e2e/pages/textfield/format.html b/e2e/pages/textfield/format.html new file mode 100644 index 00000000..74081589 --- /dev/null +++ b/e2e/pages/textfield/format.html @@ -0,0 +1,388 @@ + + + + + + Next2D E2E - TextField Format + + + + + + + diff --git a/e2e/pages/textfield/multiple-textfields.html b/e2e/pages/textfield/multiple-textfields.html new file mode 100644 index 00000000..656f418d --- /dev/null +++ b/e2e/pages/textfield/multiple-textfields.html @@ -0,0 +1,152 @@ + + + + + + Next2D E2E - Multiple TextFields Test + + + + + + + diff --git a/e2e/pages/textfield/scroll.html b/e2e/pages/textfield/scroll.html new file mode 100644 index 00000000..47ad70bc --- /dev/null +++ b/e2e/pages/textfield/scroll.html @@ -0,0 +1,125 @@ + + + + + + Next2D E2E - TextField Scroll + + + + + + + diff --git a/e2e/pages/textfield/thickness.html b/e2e/pages/textfield/thickness.html new file mode 100644 index 00000000..ff1d58f2 --- /dev/null +++ b/e2e/pages/textfield/thickness.html @@ -0,0 +1,115 @@ + + + + + + Next2D E2E - TextField Thickness + + + + + + + diff --git a/e2e/pages/video/blendmode.html b/e2e/pages/video/blendmode.html new file mode 100644 index 00000000..b3775183 --- /dev/null +++ b/e2e/pages/video/blendmode.html @@ -0,0 +1,178 @@ + + + + + + Next2D E2E - Video BlendModes + + + + + + + diff --git a/e2e/pages/video/filter.html b/e2e/pages/video/filter.html new file mode 100644 index 00000000..4a857a23 --- /dev/null +++ b/e2e/pages/video/filter.html @@ -0,0 +1,153 @@ + + + + + + Next2D E2E - Video Filters + + + + + + + diff --git a/e2e/pages/video/playback.html b/e2e/pages/video/playback.html new file mode 100644 index 00000000..836ce31f --- /dev/null +++ b/e2e/pages/video/playback.html @@ -0,0 +1,372 @@ + + + + + + Next2D E2E - Video Playback + + + + + + + diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..b5873ec1 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,80 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * E2Eテスト設定 + * + * WebGL/WebGPUの描画はヘッド付きモードで正確に動作するため、 + * 全テストはローカル環境でヘッド付きモードで実行することを推奨。 + * + * スクリプト: + * - npm run test:e2e:local - 全テスト(WebGL + WebGPU)をヘッド付きで実行 + * - npm run test:e2e:local:webgl - WebGLのみヘッド付きで実行 + * - npm run test:e2e:local:webgpu - WebGPUのみヘッド付きで実行 + * - npm run test:e2e:update - スナップショット更新 + */ + +const isCI = !!process.env.CI; +// ヘッドレスモードを強制しない限り、常にヘッド付きで実行 +const forceHeadless = process.env.HEADLESS === "true"; + +export default defineConfig({ + "testDir": "./tests", + "fullyParallel": true, + "forbidOnly": isCI, + "retries": isCI ? 2 : 0, + "workers": isCI ? 1 : undefined, + "reporter": "html", + "timeout": 60000, + "expect": { + "toHaveScreenshot": { + "maxDiffPixels": 100, + "threshold": 0.1 + } + }, + "use": { + "baseURL": "http://localhost:5173", + "trace": "on-first-retry", + "video": "on-first-retry" + }, + "projects": [ + { + "name": "webgl", + "use": { + ...devices["Desktop Chrome"], + // デフォルトでヘッド付きモード(WebGLの正確な描画のため) + "headless": forceHeadless, + "launchOptions": { + "args": [ + "--use-gl=angle", + "--use-angle=default", + "--autoplay-policy=no-user-gesture-required" + ] + } + }, + "snapshotDir": "./snapshots/webgl" + }, + { + "name": "webgpu", + "use": { + ...devices["Desktop Chrome"], + // WebGPUはヘッド付きモードが必須 + "headless": forceHeadless, + "launchOptions": { + "args": [ + "--enable-unsafe-webgpu", + "--enable-features=Vulkan", + "--autoplay-policy=no-user-gesture-required" + ] + } + }, + "snapshotDir": "./snapshots/webgpu" + } + ], + "webServer": { + "command": "npm start", + "url": "http://localhost:5173", + "reuseExistingServer": !isCI, + "timeout": 120000, + "cwd": ".." + } +}); diff --git a/e2e/snapshots/webgl/blendmode.spec.ts-snapshots/blendmode-all-webgl-darwin.png b/e2e/snapshots/webgl/blendmode.spec.ts-snapshots/blendmode-all-webgl-darwin.png new file mode 100644 index 00000000..edf0131a Binary files /dev/null and b/e2e/snapshots/webgl/blendmode.spec.ts-snapshots/blendmode-all-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/container.spec.ts-snapshots/container-child-management-webgl-darwin.png b/e2e/snapshots/webgl/container.spec.ts-snapshots/container-child-management-webgl-darwin.png new file mode 100644 index 00000000..17ed842f Binary files /dev/null and b/e2e/snapshots/webgl/container.spec.ts-snapshots/container-child-management-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/display-object.spec.ts-snapshots/display-object-color-transform-webgl-darwin.png b/e2e/snapshots/webgl/display-object.spec.ts-snapshots/display-object-color-transform-webgl-darwin.png new file mode 100644 index 00000000..02264b3d Binary files /dev/null and b/e2e/snapshots/webgl/display-object.spec.ts-snapshots/display-object-color-transform-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/display-object.spec.ts-snapshots/display-object-nested-transforms-webgl-darwin.png b/e2e/snapshots/webgl/display-object.spec.ts-snapshots/display-object-nested-transforms-webgl-darwin.png new file mode 100644 index 00000000..532e42cc Binary files /dev/null and b/e2e/snapshots/webgl/display-object.spec.ts-snapshots/display-object-nested-transforms-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/display-object.spec.ts-snapshots/display-object-properties-webgl-darwin.png b/e2e/snapshots/webgl/display-object.spec.ts-snapshots/display-object-properties-webgl-darwin.png new file mode 100644 index 00000000..1cf39ef9 Binary files /dev/null and b/e2e/snapshots/webgl/display-object.spec.ts-snapshots/display-object-properties-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-bevel-webgl-darwin.png b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-bevel-webgl-darwin.png new file mode 100644 index 00000000..3df3bd1f Binary files /dev/null and b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-bevel-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-blur-webgl-darwin.png b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-blur-webgl-darwin.png new file mode 100644 index 00000000..1b821a2f Binary files /dev/null and b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-blur-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-color-matrix-webgl-darwin.png b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-color-matrix-webgl-darwin.png new file mode 100644 index 00000000..e8806027 Binary files /dev/null and b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-color-matrix-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-convolution-webgl-darwin.png b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-convolution-webgl-darwin.png new file mode 100644 index 00000000..25143725 Binary files /dev/null and b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-convolution-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-displacement-webgl-darwin.png b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-displacement-webgl-darwin.png new file mode 100644 index 00000000..61219c55 Binary files /dev/null and b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-displacement-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-drop-shadow-webgl-darwin.png b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-drop-shadow-webgl-darwin.png new file mode 100644 index 00000000..0c35a0e8 Binary files /dev/null and b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-drop-shadow-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-glow-webgl-darwin.png b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-glow-webgl-darwin.png new file mode 100644 index 00000000..57b62d87 Binary files /dev/null and b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-glow-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-gradient-bevel-webgl-darwin.png b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-gradient-bevel-webgl-darwin.png new file mode 100644 index 00000000..ebfd90f3 Binary files /dev/null and b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-gradient-bevel-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-gradient-glow-webgl-darwin.png b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-gradient-glow-webgl-darwin.png new file mode 100644 index 00000000..8ea1c951 Binary files /dev/null and b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-gradient-glow-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-modes-webgl-darwin.png b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-modes-webgl-darwin.png new file mode 100644 index 00000000..b5d64651 Binary files /dev/null and b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-modes-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-multi-filter-webgl-darwin.png b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-multi-filter-webgl-darwin.png new file mode 100644 index 00000000..24b763bf Binary files /dev/null and b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-multi-filter-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-quality-webgl-darwin.png b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-quality-webgl-darwin.png new file mode 100644 index 00000000..f519354e Binary files /dev/null and b/e2e/snapshots/webgl/filter.spec.ts-snapshots/filter-quality-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/mask.spec.ts-snapshots/mask-sprite-webgl-darwin.png b/e2e/snapshots/webgl/mask.spec.ts-snapshots/mask-sprite-webgl-darwin.png new file mode 100644 index 00000000..0f6267b8 Binary files /dev/null and b/e2e/snapshots/webgl/mask.spec.ts-snapshots/mask-sprite-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/shape.spec.ts-snapshots/fill-bitmap-webgl-darwin.png b/e2e/snapshots/webgl/shape.spec.ts-snapshots/fill-bitmap-webgl-darwin.png new file mode 100644 index 00000000..d218c17e Binary files /dev/null and b/e2e/snapshots/webgl/shape.spec.ts-snapshots/fill-bitmap-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/shape.spec.ts-snapshots/fill-gradient-webgl-darwin.png b/e2e/snapshots/webgl/shape.spec.ts-snapshots/fill-gradient-webgl-darwin.png new file mode 100644 index 00000000..d9f769b3 Binary files /dev/null and b/e2e/snapshots/webgl/shape.spec.ts-snapshots/fill-gradient-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/shape.spec.ts-snapshots/fill-solid-webgl-darwin.png b/e2e/snapshots/webgl/shape.spec.ts-snapshots/fill-solid-webgl-darwin.png new file mode 100644 index 00000000..472215ad Binary files /dev/null and b/e2e/snapshots/webgl/shape.spec.ts-snapshots/fill-solid-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/shape.spec.ts-snapshots/graphics-clear-webgl-darwin.png b/e2e/snapshots/webgl/shape.spec.ts-snapshots/graphics-clear-webgl-darwin.png new file mode 100644 index 00000000..89368b62 Binary files /dev/null and b/e2e/snapshots/webgl/shape.spec.ts-snapshots/graphics-clear-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/shape.spec.ts-snapshots/graphics-clone-webgl-darwin.png b/e2e/snapshots/webgl/shape.spec.ts-snapshots/graphics-clone-webgl-darwin.png new file mode 100644 index 00000000..aad34ea1 Binary files /dev/null and b/e2e/snapshots/webgl/shape.spec.ts-snapshots/graphics-clone-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/shape.spec.ts-snapshots/line-bitmap-webgl-darwin.png b/e2e/snapshots/webgl/shape.spec.ts-snapshots/line-bitmap-webgl-darwin.png new file mode 100644 index 00000000..52e6299a Binary files /dev/null and b/e2e/snapshots/webgl/shape.spec.ts-snapshots/line-bitmap-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/shape.spec.ts-snapshots/line-gradient-webgl-darwin.png b/e2e/snapshots/webgl/shape.spec.ts-snapshots/line-gradient-webgl-darwin.png new file mode 100644 index 00000000..01862e98 Binary files /dev/null and b/e2e/snapshots/webgl/shape.spec.ts-snapshots/line-gradient-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/shape.spec.ts-snapshots/line-style-webgl-darwin.png b/e2e/snapshots/webgl/shape.spec.ts-snapshots/line-style-webgl-darwin.png new file mode 100644 index 00000000..59f036f1 Binary files /dev/null and b/e2e/snapshots/webgl/shape.spec.ts-snapshots/line-style-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/shape.spec.ts-snapshots/load-image-flip-webgl-darwin.png b/e2e/snapshots/webgl/shape.spec.ts-snapshots/load-image-flip-webgl-darwin.png new file mode 100644 index 00000000..2cca07b8 Binary files /dev/null and b/e2e/snapshots/webgl/shape.spec.ts-snapshots/load-image-flip-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/shape.spec.ts-snapshots/load-image-webgl-darwin.png b/e2e/snapshots/webgl/shape.spec.ts-snapshots/load-image-webgl-darwin.png new file mode 100644 index 00000000..8222359e Binary files /dev/null and b/e2e/snapshots/webgl/shape.spec.ts-snapshots/load-image-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/shape.spec.ts-snapshots/paths-webgl-darwin.png b/e2e/snapshots/webgl/shape.spec.ts-snapshots/paths-webgl-darwin.png new file mode 100644 index 00000000..c8ad72a8 Binary files /dev/null and b/e2e/snapshots/webgl/shape.spec.ts-snapshots/paths-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/shape.spec.ts-snapshots/scale9grid-webgl-darwin.png b/e2e/snapshots/webgl/shape.spec.ts-snapshots/scale9grid-webgl-darwin.png new file mode 100644 index 00000000..ad5e90bc Binary files /dev/null and b/e2e/snapshots/webgl/shape.spec.ts-snapshots/scale9grid-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/shape.spec.ts-snapshots/shapes-webgl-darwin.png b/e2e/snapshots/webgl/shape.spec.ts-snapshots/shapes-webgl-darwin.png new file mode 100644 index 00000000..e8f91c81 Binary files /dev/null and b/e2e/snapshots/webgl/shape.spec.ts-snapshots/shapes-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/sprite.spec.ts-snapshots/sprite-blend-webgl-darwin.png b/e2e/snapshots/webgl/sprite.spec.ts-snapshots/sprite-blend-webgl-darwin.png new file mode 100644 index 00000000..9c5fb8aa Binary files /dev/null and b/e2e/snapshots/webgl/sprite.spec.ts-snapshots/sprite-blend-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/sprite.spec.ts-snapshots/sprite-filter-webgl-darwin.png b/e2e/snapshots/webgl/sprite.spec.ts-snapshots/sprite-filter-webgl-darwin.png new file mode 100644 index 00000000..238786fa Binary files /dev/null and b/e2e/snapshots/webgl/sprite.spec.ts-snapshots/sprite-filter-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/sprite.spec.ts-snapshots/sprite-nested-filter-webgl-darwin.png b/e2e/snapshots/webgl/sprite.spec.ts-snapshots/sprite-nested-filter-webgl-darwin.png new file mode 100644 index 00000000..b8617507 Binary files /dev/null and b/e2e/snapshots/webgl/sprite.spec.ts-snapshots/sprite-nested-filter-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-auto-font-size-webgl-darwin.png b/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-auto-font-size-webgl-darwin.png new file mode 100644 index 00000000..8f79032a Binary files /dev/null and b/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-auto-font-size-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-basic-webgl-darwin.png b/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-basic-webgl-darwin.png new file mode 100644 index 00000000..89d2d6ac Binary files /dev/null and b/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-basic-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-blendmode-webgl-darwin.png b/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-blendmode-webgl-darwin.png new file mode 100644 index 00000000..6775bc97 Binary files /dev/null and b/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-blendmode-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-filter-webgl-darwin.png b/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-filter-webgl-darwin.png new file mode 100644 index 00000000..79c05297 Binary files /dev/null and b/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-filter-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-format-webgl-darwin.png b/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-format-webgl-darwin.png new file mode 100644 index 00000000..ced9469f Binary files /dev/null and b/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-format-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-multiple-webgl-darwin.png b/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-multiple-webgl-darwin.png new file mode 100644 index 00000000..2ba9c7d4 Binary files /dev/null and b/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-multiple-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-scroll-webgl-darwin.png b/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-scroll-webgl-darwin.png new file mode 100644 index 00000000..1ed897e3 Binary files /dev/null and b/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-scroll-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-thickness-webgl-darwin.png b/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-thickness-webgl-darwin.png new file mode 100644 index 00000000..77b2738f Binary files /dev/null and b/e2e/snapshots/webgl/textfield.spec.ts-snapshots/textfield-thickness-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/video.spec.ts-snapshots/video-blendmode-webgl-darwin.png b/e2e/snapshots/webgl/video.spec.ts-snapshots/video-blendmode-webgl-darwin.png new file mode 100644 index 00000000..d77acea8 Binary files /dev/null and b/e2e/snapshots/webgl/video.spec.ts-snapshots/video-blendmode-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/video.spec.ts-snapshots/video-filter-webgl-darwin.png b/e2e/snapshots/webgl/video.spec.ts-snapshots/video-filter-webgl-darwin.png new file mode 100644 index 00000000..af751db4 Binary files /dev/null and b/e2e/snapshots/webgl/video.spec.ts-snapshots/video-filter-webgl-darwin.png differ diff --git a/e2e/snapshots/webgl/video.spec.ts-snapshots/video-playback-webgl-darwin.png b/e2e/snapshots/webgl/video.spec.ts-snapshots/video-playback-webgl-darwin.png new file mode 100644 index 00000000..74e41f0c Binary files /dev/null and b/e2e/snapshots/webgl/video.spec.ts-snapshots/video-playback-webgl-darwin.png differ diff --git a/e2e/snapshots/webgpu/blendmode.spec.ts-snapshots/blendmode-all-webgpu-darwin.png b/e2e/snapshots/webgpu/blendmode.spec.ts-snapshots/blendmode-all-webgpu-darwin.png new file mode 100644 index 00000000..e10a4087 Binary files /dev/null and b/e2e/snapshots/webgpu/blendmode.spec.ts-snapshots/blendmode-all-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/container.spec.ts-snapshots/container-child-management-webgpu-darwin.png b/e2e/snapshots/webgpu/container.spec.ts-snapshots/container-child-management-webgpu-darwin.png new file mode 100644 index 00000000..17ed842f Binary files /dev/null and b/e2e/snapshots/webgpu/container.spec.ts-snapshots/container-child-management-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/display-object.spec.ts-snapshots/display-object-color-transform-webgpu-darwin.png b/e2e/snapshots/webgpu/display-object.spec.ts-snapshots/display-object-color-transform-webgpu-darwin.png new file mode 100644 index 00000000..02264b3d Binary files /dev/null and b/e2e/snapshots/webgpu/display-object.spec.ts-snapshots/display-object-color-transform-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/display-object.spec.ts-snapshots/display-object-nested-transforms-webgpu-darwin.png b/e2e/snapshots/webgpu/display-object.spec.ts-snapshots/display-object-nested-transforms-webgpu-darwin.png new file mode 100644 index 00000000..532e42cc Binary files /dev/null and b/e2e/snapshots/webgpu/display-object.spec.ts-snapshots/display-object-nested-transforms-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/display-object.spec.ts-snapshots/display-object-properties-webgpu-darwin.png b/e2e/snapshots/webgpu/display-object.spec.ts-snapshots/display-object-properties-webgpu-darwin.png new file mode 100644 index 00000000..1cf39ef9 Binary files /dev/null and b/e2e/snapshots/webgpu/display-object.spec.ts-snapshots/display-object-properties-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-bevel-webgpu-darwin.png b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-bevel-webgpu-darwin.png new file mode 100644 index 00000000..d5c70798 Binary files /dev/null and b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-bevel-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-blur-webgpu-darwin.png b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-blur-webgpu-darwin.png new file mode 100644 index 00000000..c19008ad Binary files /dev/null and b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-blur-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-color-matrix-webgpu-darwin.png b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-color-matrix-webgpu-darwin.png new file mode 100644 index 00000000..4937d63c Binary files /dev/null and b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-color-matrix-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-convolution-webgpu-darwin.png b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-convolution-webgpu-darwin.png new file mode 100644 index 00000000..93a00f90 Binary files /dev/null and b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-convolution-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-displacement-webgpu-darwin.png b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-displacement-webgpu-darwin.png new file mode 100644 index 00000000..3cb8c677 Binary files /dev/null and b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-displacement-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-drop-shadow-webgpu-darwin.png b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-drop-shadow-webgpu-darwin.png new file mode 100644 index 00000000..27354c9d Binary files /dev/null and b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-drop-shadow-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-glow-webgpu-darwin.png b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-glow-webgpu-darwin.png new file mode 100644 index 00000000..5ba2e163 Binary files /dev/null and b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-glow-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-gradient-bevel-webgpu-darwin.png b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-gradient-bevel-webgpu-darwin.png new file mode 100644 index 00000000..5c8ed83d Binary files /dev/null and b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-gradient-bevel-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-gradient-glow-webgpu-darwin.png b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-gradient-glow-webgpu-darwin.png new file mode 100644 index 00000000..e05ddd20 Binary files /dev/null and b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-gradient-glow-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-modes-webgpu-darwin.png b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-modes-webgpu-darwin.png new file mode 100644 index 00000000..0b2cb269 Binary files /dev/null and b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-modes-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-multi-filter-webgpu-darwin.png b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-multi-filter-webgpu-darwin.png new file mode 100644 index 00000000..fc3a910a Binary files /dev/null and b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-multi-filter-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-quality-webgpu-darwin.png b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-quality-webgpu-darwin.png new file mode 100644 index 00000000..8962a4f6 Binary files /dev/null and b/e2e/snapshots/webgpu/filter.spec.ts-snapshots/filter-quality-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/mask.spec.ts-snapshots/mask-sprite-webgpu-darwin.png b/e2e/snapshots/webgpu/mask.spec.ts-snapshots/mask-sprite-webgpu-darwin.png new file mode 100644 index 00000000..5c854810 Binary files /dev/null and b/e2e/snapshots/webgpu/mask.spec.ts-snapshots/mask-sprite-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/shape.spec.ts-snapshots/fill-bitmap-webgpu-darwin.png b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/fill-bitmap-webgpu-darwin.png new file mode 100644 index 00000000..ccac7aa6 Binary files /dev/null and b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/fill-bitmap-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/shape.spec.ts-snapshots/fill-gradient-webgpu-darwin.png b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/fill-gradient-webgpu-darwin.png new file mode 100644 index 00000000..2dd9d515 Binary files /dev/null and b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/fill-gradient-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/shape.spec.ts-snapshots/fill-solid-webgpu-darwin.png b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/fill-solid-webgpu-darwin.png new file mode 100644 index 00000000..472215ad Binary files /dev/null and b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/fill-solid-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/shape.spec.ts-snapshots/graphics-clear-webgpu-darwin.png b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/graphics-clear-webgpu-darwin.png new file mode 100644 index 00000000..a5f32871 Binary files /dev/null and b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/graphics-clear-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/shape.spec.ts-snapshots/graphics-clone-webgpu-darwin.png b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/graphics-clone-webgpu-darwin.png new file mode 100644 index 00000000..01a09176 Binary files /dev/null and b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/graphics-clone-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/shape.spec.ts-snapshots/line-bitmap-webgpu-darwin.png b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/line-bitmap-webgpu-darwin.png new file mode 100644 index 00000000..6517dd8a Binary files /dev/null and b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/line-bitmap-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/shape.spec.ts-snapshots/line-gradient-webgpu-darwin.png b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/line-gradient-webgpu-darwin.png new file mode 100644 index 00000000..6a035ddc Binary files /dev/null and b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/line-gradient-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/shape.spec.ts-snapshots/line-style-webgpu-darwin.png b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/line-style-webgpu-darwin.png new file mode 100644 index 00000000..94f05d8f Binary files /dev/null and b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/line-style-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/shape.spec.ts-snapshots/load-image-flip-webgpu-darwin.png b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/load-image-flip-webgpu-darwin.png new file mode 100644 index 00000000..6370efee Binary files /dev/null and b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/load-image-flip-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/shape.spec.ts-snapshots/load-image-webgpu-darwin.png b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/load-image-webgpu-darwin.png new file mode 100644 index 00000000..73fae52d Binary files /dev/null and b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/load-image-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/shape.spec.ts-snapshots/paths-webgpu-darwin.png b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/paths-webgpu-darwin.png new file mode 100644 index 00000000..fa3d6a19 Binary files /dev/null and b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/paths-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/shape.spec.ts-snapshots/scale9grid-webgpu-darwin.png b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/scale9grid-webgpu-darwin.png new file mode 100644 index 00000000..9a3dcc24 Binary files /dev/null and b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/scale9grid-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/shape.spec.ts-snapshots/shapes-webgpu-darwin.png b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/shapes-webgpu-darwin.png new file mode 100644 index 00000000..8767e8b6 Binary files /dev/null and b/e2e/snapshots/webgpu/shape.spec.ts-snapshots/shapes-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/sprite.spec.ts-snapshots/sprite-blend-webgpu-darwin.png b/e2e/snapshots/webgpu/sprite.spec.ts-snapshots/sprite-blend-webgpu-darwin.png new file mode 100644 index 00000000..02372242 Binary files /dev/null and b/e2e/snapshots/webgpu/sprite.spec.ts-snapshots/sprite-blend-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/sprite.spec.ts-snapshots/sprite-filter-webgpu-darwin.png b/e2e/snapshots/webgpu/sprite.spec.ts-snapshots/sprite-filter-webgpu-darwin.png new file mode 100644 index 00000000..f1fedcf9 Binary files /dev/null and b/e2e/snapshots/webgpu/sprite.spec.ts-snapshots/sprite-filter-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/sprite.spec.ts-snapshots/sprite-nested-filter-webgpu-darwin.png b/e2e/snapshots/webgpu/sprite.spec.ts-snapshots/sprite-nested-filter-webgpu-darwin.png new file mode 100644 index 00000000..0117db0a Binary files /dev/null and b/e2e/snapshots/webgpu/sprite.spec.ts-snapshots/sprite-nested-filter-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-auto-font-size-webgpu-darwin.png b/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-auto-font-size-webgpu-darwin.png new file mode 100644 index 00000000..fe6290b5 Binary files /dev/null and b/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-auto-font-size-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-basic-webgpu-darwin.png b/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-basic-webgpu-darwin.png new file mode 100644 index 00000000..577bb11f Binary files /dev/null and b/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-basic-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-blendmode-webgpu-darwin.png b/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-blendmode-webgpu-darwin.png new file mode 100644 index 00000000..b07792d7 Binary files /dev/null and b/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-blendmode-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-filter-webgpu-darwin.png b/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-filter-webgpu-darwin.png new file mode 100644 index 00000000..0c4560a5 Binary files /dev/null and b/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-filter-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-format-webgpu-darwin.png b/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-format-webgpu-darwin.png new file mode 100644 index 00000000..20623eed Binary files /dev/null and b/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-format-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-multiple-webgpu-darwin.png b/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-multiple-webgpu-darwin.png new file mode 100644 index 00000000..502d992b Binary files /dev/null and b/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-multiple-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-scroll-webgpu-darwin.png b/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-scroll-webgpu-darwin.png new file mode 100644 index 00000000..4448bb0b Binary files /dev/null and b/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-scroll-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-thickness-webgpu-darwin.png b/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-thickness-webgpu-darwin.png new file mode 100644 index 00000000..9113c4bb Binary files /dev/null and b/e2e/snapshots/webgpu/textfield.spec.ts-snapshots/textfield-thickness-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/video.spec.ts-snapshots/video-blendmode-webgpu-darwin.png b/e2e/snapshots/webgpu/video.spec.ts-snapshots/video-blendmode-webgpu-darwin.png new file mode 100644 index 00000000..8af8a8c9 Binary files /dev/null and b/e2e/snapshots/webgpu/video.spec.ts-snapshots/video-blendmode-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/video.spec.ts-snapshots/video-filter-webgpu-darwin.png b/e2e/snapshots/webgpu/video.spec.ts-snapshots/video-filter-webgpu-darwin.png new file mode 100644 index 00000000..9dda5728 Binary files /dev/null and b/e2e/snapshots/webgpu/video.spec.ts-snapshots/video-filter-webgpu-darwin.png differ diff --git a/e2e/snapshots/webgpu/video.spec.ts-snapshots/video-playback-webgpu-darwin.png b/e2e/snapshots/webgpu/video.spec.ts-snapshots/video-playback-webgpu-darwin.png new file mode 100644 index 00000000..07f5c3a4 Binary files /dev/null and b/e2e/snapshots/webgpu/video.spec.ts-snapshots/video-playback-webgpu-darwin.png differ diff --git a/e2e/tests/blendmode.spec.ts b/e2e/tests/blendmode.spec.ts new file mode 100644 index 00000000..bc7d2600 --- /dev/null +++ b/e2e/tests/blendmode.spec.ts @@ -0,0 +1,11 @@ +import { test, expect } from "@playwright/test"; +import { waitForCanvas } from "./helpers/wait-for-render"; + +test.describe("BlendModeテスト", () => { + test("全14種類のBlendMode", async ({ page }) => { + await page.goto("/e2e/pages/blendmode/all-modes.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("blendmode-all.png"); + }); +}); diff --git a/e2e/tests/container.spec.ts b/e2e/tests/container.spec.ts new file mode 100644 index 00000000..51744d3c --- /dev/null +++ b/e2e/tests/container.spec.ts @@ -0,0 +1,11 @@ +import { test, expect } from "@playwright/test"; +import { waitForCanvas } from "./helpers/wait-for-render"; + +test.describe("コンテナテスト", () => { + test("子オブジェクトの管理(z-order, addChildAt, setChildIndex, swap, remove)", async ({ page }) => { + await page.goto("/e2e/pages/container/child-management.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("container-child-management.png"); + }); +}); diff --git a/e2e/tests/display-object.spec.ts b/e2e/tests/display-object.spec.ts new file mode 100644 index 00000000..25dedec2 --- /dev/null +++ b/e2e/tests/display-object.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from "@playwright/test"; +import { waitForCanvas } from "./helpers/wait-for-render"; + +test.describe("DisplayObjectテスト", () => { + test("プロパティ(visible, alpha, rotation, scale, 複合transform)", async ({ page }) => { + await page.goto("/e2e/pages/display-object/properties.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("display-object-properties.png"); + }); + + test("ColorTransform(色乗算、色オフセット、alpha、親子累積)", async ({ page }) => { + await page.goto("/e2e/pages/display-object/color-transform.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("display-object-color-transform.png"); + }); + + test("ネストされたtransform(scale, rotation, position, alphaの累積)", async ({ page }) => { + await page.goto("/e2e/pages/display-object/nested-transforms.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("display-object-nested-transforms.png"); + }); +}); diff --git a/e2e/tests/filter.spec.ts b/e2e/tests/filter.spec.ts new file mode 100644 index 00000000..3aa18e17 --- /dev/null +++ b/e2e/tests/filter.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from "@playwright/test"; +import { waitForCanvas } from "./helpers/wait-for-render"; + +test.describe("Filterテスト", () => { + test("BlurFilter", async ({ page }) => { + await page.goto("/e2e/pages/filter/blur.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("filter-blur.png"); + }); + + test("DropShadowFilter", async ({ page }) => { + await page.goto("/e2e/pages/filter/drop-shadow.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("filter-drop-shadow.png"); + }); + + test("GlowFilter", async ({ page }) => { + await page.goto("/e2e/pages/filter/glow.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("filter-glow.png"); + }); + + test("BevelFilter", async ({ page }) => { + await page.goto("/e2e/pages/filter/bevel.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("filter-bevel.png"); + }); + + test("ColorMatrixFilter", async ({ page }) => { + await page.goto("/e2e/pages/filter/color-matrix.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("filter-color-matrix.png"); + }); + + test("ConvolutionFilter", async ({ page }) => { + await page.goto("/e2e/pages/filter/convolution.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("filter-convolution.png"); + }); + + test("DisplacementMapFilter", async ({ page }) => { + await page.goto("/e2e/pages/filter/displacement.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("filter-displacement.png"); + }); + + test("GradientBevelFilter", async ({ page }) => { + await page.goto("/e2e/pages/filter/gradient-bevel.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("filter-gradient-bevel.png"); + }); + + test("GradientGlowFilter", async ({ page }) => { + await page.goto("/e2e/pages/filter/gradient-glow.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("filter-gradient-glow.png"); + }); + + test("MultiFilter", async ({ page }) => { + await page.goto("/e2e/pages/filter/multi-filter.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("filter-multi-filter.png"); + }); + + test("フィルターモード(inner, knockout, hideObject, type)", async ({ page }) => { + await page.goto("/e2e/pages/filter/filter-modes.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("filter-modes.png"); + }); + + test("フィルター品質(quality 1, 2, 3比較)", async ({ page }) => { + await page.goto("/e2e/pages/filter/filter-quality.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("filter-quality.png"); + }); +}); diff --git a/e2e/tests/helpers/renderer-switcher.ts b/e2e/tests/helpers/renderer-switcher.ts new file mode 100644 index 00000000..ecbd872d --- /dev/null +++ b/e2e/tests/helpers/renderer-switcher.ts @@ -0,0 +1,40 @@ +import { Page, TestInfo } from "@playwright/test"; + +/** + * 現在のテストプロジェクト(レンダラー)を取得 + */ +export function getCurrentRenderer(testInfo: TestInfo): "webgl" | "webgpu" +{ + return testInfo.project.name as "webgl" | "webgpu"; +} + +/** + * レンダラー固有のスナップショット名を生成 + */ +export function getSnapshotName(baseName: string, testInfo: TestInfo): string +{ + const renderer = getCurrentRenderer(testInfo); + return `${baseName}-${renderer}.png`; +} + +/** + * ページにレンダラー設定を注入 + */ +export async function injectRendererConfig(page: Page, testInfo: TestInfo): Promise +{ + const renderer = getCurrentRenderer(testInfo); + + await page.addInitScript((rendererType) => { + (window as any).__NEXT2D_RENDERER__ = rendererType; + }, renderer); +} + +/** + * レンダラータイプに応じたNext2D設定を返す + */ +export function getNext2DConfig(testInfo: TestInfo): { renderer: string } +{ + return { + renderer: getCurrentRenderer(testInfo) + }; +} diff --git a/e2e/tests/helpers/wait-for-render.ts b/e2e/tests/helpers/wait-for-render.ts new file mode 100644 index 00000000..c31317d7 --- /dev/null +++ b/e2e/tests/helpers/wait-for-render.ts @@ -0,0 +1,56 @@ +import { Page } from "@playwright/test"; + +/** + * 描画完了を待機するヘルパー関数 + * Next2Dのレンダリングが完了するまで待機 + */ +export async function waitForRender(page: Page, timeout: number = 10000): Promise +{ + // テストページで設定される __E2E_RENDER_COMPLETE__ フラグを待機 + await page.waitForFunction(() => { + return (window as any).__E2E_RENDER_COMPLETE__ === true; + }, { timeout }); + + // ワーカースレッドでのレンダリング完了を待つため、より長い時間待機 + await page.waitForTimeout(1000); + + // 複数フレーム待機して描画が完全に安定することを確認 + await page.evaluate(() => { + return new Promise((resolve) => { + let frameCount = 0; + const waitFrames = () => { + frameCount++; + if (frameCount >= 5) { + resolve(); + } else { + requestAnimationFrame(waitFrames); + } + }; + requestAnimationFrame(waitFrames); + }); + }); + + // 追加の安定化待機 + await page.waitForTimeout(500); +} + +/** + * 特定の条件で描画完了を待機 + */ +export async function waitForCondition( + page: Page, + condition: () => boolean | Promise, + timeout: number = 5000 +): Promise +{ + await page.waitForFunction(condition, { timeout }); +} + +/** + * Canvasが描画されるまで待機 + */ +export async function waitForCanvas(page: Page, timeout: number = 30000): Promise +{ + await page.waitForSelector("canvas", { timeout }); + await waitForRender(page, timeout); +} diff --git a/e2e/tests/mask.spec.ts b/e2e/tests/mask.spec.ts new file mode 100644 index 00000000..363edab7 --- /dev/null +++ b/e2e/tests/mask.spec.ts @@ -0,0 +1,11 @@ +import { test, expect } from "@playwright/test"; +import { waitForCanvas } from "./helpers/wait-for-render"; + +test.describe("Maskテスト", () => { + test("Sprite.mask(円形、矩形、星形、角丸、楕円、複雑なパス、Video、TextField)", async ({ page }) => { + await page.goto("/e2e/pages/mask/sprite-mask.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("mask-sprite.png"); + }); +}); diff --git a/e2e/tests/shape.spec.ts b/e2e/tests/shape.spec.ts new file mode 100644 index 00000000..59f1a75d --- /dev/null +++ b/e2e/tests/shape.spec.ts @@ -0,0 +1,109 @@ +import { test, expect } from "@playwright/test"; +import { waitForCanvas } from "./helpers/wait-for-render"; + +test.describe("Shape描画テスト", () => { + test.describe("Fill(塗り)", () => { + test("beginFill - 単色塗り", async ({ page }) => { + await page.goto("/e2e/pages/shape/fill-solid.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("fill-solid.png"); + }); + + test("beginGradientFill - グラデーション塗り", async ({ page }) => { + await page.goto("/e2e/pages/shape/fill-gradient.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("fill-gradient.png"); + }); + + test("beginBitmapFill - ビットマップ塗り", async ({ page }) => { + await page.goto("/e2e/pages/shape/fill-bitmap.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("fill-bitmap.png"); + }); + }); + + test.describe("Line(線)", () => { + test("lineStyle - 線のスタイル(太さ、caps、joints)", async ({ page }) => { + await page.goto("/e2e/pages/shape/line-style.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("line-style.png"); + }); + + test("lineGradientStyle - グラデーション線", async ({ page }) => { + await page.goto("/e2e/pages/shape/line-gradient.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("line-gradient.png"); + }); + + test("lineBitmapStyle - ビットマップ線", async ({ page }) => { + await page.goto("/e2e/pages/shape/line-bitmap.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("line-bitmap.png"); + }); + }); + + test.describe("図形", () => { + test("drawRect, drawRoundRect, drawCircle, drawEllipse", async ({ page }) => { + await page.goto("/e2e/pages/shape/shapes.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("shapes.png"); + }); + }); + + test.describe("パス", () => { + test("moveTo, lineTo, curveTo, cubicCurveTo", async ({ page }) => { + await page.goto("/e2e/pages/shape/paths.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("paths.png"); + }); + }); + + test.describe("scale9Grid", () => { + test("9スライススケーリング", async ({ page }) => { + await page.goto("/e2e/pages/shape/scale9grid.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("scale9grid.png"); + }); + }); + + test.describe("画像読み込み", () => { + test("Shape.load - 画像読み込み(スケール、回転、BlendMode、Filter)", async ({ page }) => { + await page.goto("/e2e/pages/shape/load-image.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("load-image.png"); + }); + + test("Shape.load - 複数画像のY軸反転チェック", async ({ page }) => { + await page.goto("/e2e/pages/shape/load-image-flip.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("load-image-flip.png"); + }); + }); + + test.describe("Graphics操作", () => { + test("clear() - クリア後の再描画", async ({ page }) => { + await page.goto("/e2e/pages/shape/graphics-clear.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("graphics-clear.png"); + }); + + test("copyFrom() - Graphicsのコピー", async ({ page }) => { + await page.goto("/e2e/pages/shape/graphics-clone.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("graphics-clone.png"); + }); + }); +}); diff --git a/e2e/tests/sprite.spec.ts b/e2e/tests/sprite.spec.ts new file mode 100644 index 00000000..52637afe --- /dev/null +++ b/e2e/tests/sprite.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from "@playwright/test"; +import { waitForCanvas } from "./helpers/wait-for-render"; + +test.describe("Sprite テスト", () => { + test("Sprite Filter(コンテナフィルター)", async ({ page }) => { + await page.goto("/e2e/pages/sprite/sprite-filter.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("sprite-filter.png"); + }); + + test("Sprite BlendMode(コンテナブレンド)", async ({ page }) => { + await page.goto("/e2e/pages/sprite/sprite-blend.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("sprite-blend.png"); + }); + + test("Sprite ネストフィルター + ColorTransform", async ({ page }) => { + await page.goto("/e2e/pages/sprite/sprite-nested-filter.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("sprite-nested-filter.png", { + maxDiffPixelRatio: 0.025, + maxDiffPixels: 15000 + }); + }); +}); diff --git a/e2e/tests/textfield.spec.ts b/e2e/tests/textfield.spec.ts new file mode 100644 index 00000000..465e3789 --- /dev/null +++ b/e2e/tests/textfield.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from "@playwright/test"; +import { waitForCanvas } from "./helpers/wait-for-render"; + +test.describe("TextFieldテスト", () => { + test("基本テキスト(text, htmlText, autoSize, background, border)", async ({ page }) => { + await page.goto("/e2e/pages/textfield/basic.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("textfield-basic.png"); + }); + + test("TextFormat(font, size, color, bold, italic, underline, align, leading, letterSpacing)", async ({ page }) => { + await page.goto("/e2e/pages/textfield/format.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("textfield-format.png"); + }); + + test("TextFieldBlendMode(全14種類)", async ({ page }) => { + await page.goto("/e2e/pages/textfield/blendmode.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("textfield-blendmode.png"); + }); + + test("TextFieldFilter(全フィルター)", async ({ page }) => { + await page.goto("/e2e/pages/textfield/filter.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("textfield-filter.png"); + }); + + test("複数TextField - 全て表示されることを確認", async ({ page }) => { + await page.goto("/e2e/pages/textfield/multiple-textfields.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("textfield-multiple.png"); + }); + + test("thickness(テキスト輪郭)", async ({ page }) => { + await page.goto("/e2e/pages/textfield/thickness.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("textfield-thickness.png"); + }); + + test("scroll(scrollX, scrollY)", async ({ page }) => { + await page.goto("/e2e/pages/textfield/scroll.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("textfield-scroll.png"); + }); + + test("autoFontSize(テキストサイズ自動調整)", async ({ page }) => { + await page.goto("/e2e/pages/textfield/auto-font-size.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("textfield-auto-font-size.png"); + }); +}); diff --git a/e2e/tests/video.spec.ts b/e2e/tests/video.spec.ts new file mode 100644 index 00000000..04e7f8a7 --- /dev/null +++ b/e2e/tests/video.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from "@playwright/test"; +import { waitForCanvas } from "./helpers/wait-for-render"; + +test.describe("Videoテスト", () => { + test("動画表示(基本)", async ({ page }) => { + await page.goto("/e2e/pages/video/playback.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("video-playback.png"); + }); + + test("動画BlendMode(全14種類)", async ({ page }) => { + await page.goto("/e2e/pages/video/blendmode.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("video-blendmode.png"); + }); + + test("動画Filter(全フィルター)", async ({ page }) => { + await page.goto("/e2e/pages/video/filter.html"); + await waitForCanvas(page); + + await expect(page).toHaveScreenshot("video-filter.png"); + }); +}); diff --git a/index.html b/index.html index a593e24f..32f7d609 100644 --- a/index.html +++ b/index.html @@ -3,186 +3,61 @@ - Next2D + Next2D - BlendMode Test - + - \ No newline at end of file + diff --git a/package-lock.json b/package-lock.json index 894cc2fb..7f837011 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,41 +1,42 @@ { "name": "@next2d/player", - "version": "2.13.1", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@next2d/player", - "version": "2.13.1", + "version": "3.0.0", "license": "MIT", "workspaces": [ "packages/*" ], "dependencies": { "fflate": "^0.8.2", - "htmlparser2": "^10.0.0" + "htmlparser2": "^10.1.0" }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", - "@eslint/js": "^9.39.1", + "@eslint/js": "^9.39.2", + "@playwright/test": "^1.58.1", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.3.0", - "@types/node": "^24.10.1", - "@typescript-eslint/eslint-plugin": "^8.48.0", - "@typescript-eslint/parser": "^8.48.0", - "@vitest/web-worker": "^4.0.14", - "@webgpu/types": "^0.1.66", - "eslint": "^9.39.1", + "@types/node": "^25.2.1", + "@typescript-eslint/eslint-plugin": "^8.54.0", + "@typescript-eslint/parser": "^8.54.0", + "@vitest/web-worker": "^4.0.18", + "@webgpu/types": "^0.1.69", + "eslint": "^9.39.2", "eslint-plugin-unused-imports": "^4.3.0", - "globals": "^16.5.0", - "jsdom": "^27.2.0", - "rollup": "^4.53.3", + "globals": "^17.3.0", + "jsdom": "^28.0.0", + "rollup": "^4.57.1", "tslib": "^2.8.1", "typescript": "^5.9.3", - "vite": "^7.2.4", - "vitest": "^4.0.14", + "vite": "^7.3.1", + "vitest": "^4.0.18", "vitest-webgl-canvas-mock": "^1.1.0", "xml2js": "^0.6.2" }, @@ -62,12 +63,16 @@ } }, "node_modules/@acemir/cssom": { - "version": "0.9.23", + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", "dev": true, "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "4.0.5", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -75,11 +80,13 @@ "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.1" + "lru-cache": "^11.2.4" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.4", + "version": "6.7.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.7.tgz", + "integrity": "sha512-8CO/UQ4tzDd7ula+/CVimJIVWez99UJlbMyIgk8xOnhAVPKLnBZmUFYVgugS441v2ZqUq5EnSh6B0Ua0liSFAA==", "dev": true, "license": "MIT", "dependencies": { @@ -87,16 +94,20 @@ "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.2" + "lru-cache": "^11.2.5" } }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "dev": true, "license": "MIT" }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", "dev": true, "funding": [ { @@ -115,6 +126,8 @@ }, "node_modules/@csstools/css-calc": { "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "dev": true, "funding": [ { @@ -137,6 +150,8 @@ }, "node_modules/@csstools/css-color-parser": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", "dev": true, "funding": [ { @@ -163,6 +178,8 @@ }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", "dev": true, "funding": [ { @@ -183,7 +200,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.16", + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.26.tgz", + "integrity": "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==", "dev": true, "funding": [ { @@ -195,13 +214,12 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } + "license": "MIT-0" }, "node_modules/@csstools/css-tokenizer": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", "dev": true, "funding": [ { @@ -219,9 +237,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", "cpu": [ "ppc64" ], @@ -236,9 +254,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", "cpu": [ "arm" ], @@ -253,9 +271,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", "cpu": [ "arm64" ], @@ -270,9 +288,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", "cpu": [ "x64" ], @@ -287,7 +305,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", "cpu": [ "arm64" ], @@ -302,9 +322,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", "cpu": [ "x64" ], @@ -319,9 +339,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", "cpu": [ "arm64" ], @@ -336,9 +356,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", "cpu": [ "x64" ], @@ -353,9 +373,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", "cpu": [ "arm" ], @@ -370,9 +390,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", "cpu": [ "arm64" ], @@ -387,9 +407,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", "cpu": [ "ia32" ], @@ -404,9 +424,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", "cpu": [ "loong64" ], @@ -421,9 +441,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", "cpu": [ "mips64el" ], @@ -438,9 +458,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", "cpu": [ "ppc64" ], @@ -455,9 +475,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", "cpu": [ "riscv64" ], @@ -472,9 +492,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", "cpu": [ "s390x" ], @@ -489,9 +509,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", "cpu": [ "x64" ], @@ -506,9 +526,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", "cpu": [ "arm64" ], @@ -523,9 +543,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", "cpu": [ "x64" ], @@ -540,9 +560,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", "cpu": [ "arm64" ], @@ -557,9 +577,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", "cpu": [ "x64" ], @@ -574,9 +594,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", "cpu": [ "arm64" ], @@ -591,9 +611,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", "cpu": [ "x64" ], @@ -608,9 +628,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", "cpu": [ "arm64" ], @@ -625,9 +645,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", "cpu": [ "ia32" ], @@ -642,9 +662,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", "cpu": [ "x64" ], @@ -659,7 +679,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -677,6 +699,8 @@ }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -685,6 +709,8 @@ }, "node_modules/@eslint/config-array": { "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -698,6 +724,8 @@ }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -709,6 +737,8 @@ }, "node_modules/@eslint/core": { "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -744,6 +774,8 @@ }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", "engines": { @@ -754,7 +786,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.1", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -766,6 +800,8 @@ }, "node_modules/@eslint/object-schema": { "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -774,6 +810,8 @@ }, "node_modules/@eslint/plugin-kit": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -784,8 +822,28 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.11.0.tgz", + "integrity": "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -794,6 +852,8 @@ }, "node_modules/@humanfs/node": { "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -806,6 +866,8 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -818,6 +880,8 @@ }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -830,6 +894,8 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { @@ -839,6 +905,8 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", "engines": { @@ -847,6 +915,8 @@ }, "node_modules/@jridgewell/source-map": { "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", "dependencies": { @@ -856,11 +926,15 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -928,8 +1002,26 @@ "resolved": "packages/webgpu", "link": true }, + "node_modules/@playwright/test": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz", + "integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/plugin-commonjs": { "version": "29.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.0.tgz", + "integrity": "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ==", "dev": true, "license": "MIT", "dependencies": { @@ -955,6 +1047,8 @@ }, "node_modules/@rollup/plugin-node-resolve": { "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", "dev": true, "license": "MIT", "dependencies": { @@ -978,6 +1072,8 @@ }, "node_modules/@rollup/plugin-terser": { "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", + "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", "dev": true, "license": "MIT", "dependencies": { @@ -999,6 +1095,8 @@ }, "node_modules/@rollup/plugin-typescript": { "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz", + "integrity": "sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==", "dev": true, "license": "MIT", "dependencies": { @@ -1024,6 +1122,8 @@ }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1044,9 +1144,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "cpu": [ "arm" ], @@ -1058,9 +1158,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "cpu": [ "arm64" ], @@ -1072,7 +1172,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "cpu": [ "arm64" ], @@ -1084,9 +1186,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "cpu": [ "x64" ], @@ -1098,9 +1200,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "cpu": [ "arm64" ], @@ -1112,9 +1214,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "cpu": [ "x64" ], @@ -1126,9 +1228,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "cpu": [ "arm" ], @@ -1140,9 +1242,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "cpu": [ "arm" ], @@ -1154,9 +1256,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "cpu": [ "arm64" ], @@ -1168,9 +1270,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "cpu": [ "arm64" ], @@ -1182,9 +1284,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", "cpu": [ "loong64" ], @@ -1196,9 +1312,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "cpu": [ "ppc64" ], @@ -1210,9 +1340,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", "cpu": [ "riscv64" ], @@ -1224,9 +1354,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "cpu": [ "riscv64" ], @@ -1238,9 +1368,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "cpu": [ "s390x" ], @@ -1252,9 +1382,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "cpu": [ "x64" ], @@ -1266,9 +1396,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "cpu": [ "x64" ], @@ -1279,10 +1409,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", "cpu": [ "arm64" ], @@ -1294,9 +1438,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "cpu": [ "arm64" ], @@ -1308,9 +1452,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "cpu": [ "ia32" ], @@ -1322,9 +1466,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", "cpu": [ "x64" ], @@ -1336,9 +1480,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "cpu": [ "x64" ], @@ -1350,12 +1494,16 @@ ] }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, "node_modules/@types/chai": { "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -1365,21 +1513,29 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.1", + "version": "25.2.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz", + "integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==", "dev": true, "license": "MIT", "dependencies": { @@ -1388,23 +1544,26 @@ }, "node_modules/@types/resolve": { "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.48.0", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", + "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/type-utils": "8.48.0", - "@typescript-eslint/utils": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/type-utils": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1414,13 +1573,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.48.0", + "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { @@ -1428,15 +1589,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.48.0", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", + "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1451,13 +1614,15 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.48.0", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", + "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.48.0", - "@typescript-eslint/types": "^8.48.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1471,12 +1636,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.48.0", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0" + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1487,7 +1654,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.48.0", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", + "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", "dev": true, "license": "MIT", "engines": { @@ -1502,15 +1671,17 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.48.0", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", + "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/utils": "8.48.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1525,7 +1696,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.48.0", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", "dev": true, "license": "MIT", "engines": { @@ -1537,19 +1710,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.48.0", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.48.0", - "@typescript-eslint/tsconfig-utils": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1564,6 +1739,8 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1572,6 +1749,8 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "license": "ISC", "dependencies": { @@ -1585,14 +1764,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.48.0", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1607,11 +1788,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.48.0", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -1624,6 +1807,8 @@ }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1634,14 +1819,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.14", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.14", - "@vitest/utils": "4.0.14", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" }, @@ -1650,11 +1837,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.14", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.14", + "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1676,6 +1865,8 @@ }, "node_modules/@vitest/mocker/node_modules/estree-walker": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -1683,7 +1874,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.14", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", "dev": true, "license": "MIT", "dependencies": { @@ -1694,11 +1887,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.14", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.14", + "@vitest/utils": "4.0.18", "pathe": "^2.0.3" }, "funding": { @@ -1706,11 +1901,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.14", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.14", + "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1719,7 +1916,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.14", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", "dev": true, "license": "MIT", "funding": { @@ -1727,11 +1926,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.14", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.14", + "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" }, "funding": { @@ -1739,7 +1940,9 @@ } }, "node_modules/@vitest/web-worker": { - "version": "4.0.14", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/web-worker/-/web-worker-4.0.18.tgz", + "integrity": "sha512-h9MiAI3nQNVeEH8Tn1p9CwJGmXPJPUTGhzcuQalIk+6fqIazqUDVzDi+NUKrpK6sQKgSa4MonhyDThhtZqH+cA==", "dev": true, "license": "MIT", "dependencies": { @@ -1749,16 +1952,20 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.0.14" + "vitest": "4.0.18" } }, "node_modules/@webgpu/types": { - "version": "0.1.66", + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", "dev": true, "license": "BSD-3-Clause" }, "node_modules/acorn": { "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -1770,6 +1977,8 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1778,6 +1987,8 @@ }, "node_modules/agent-base": { "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", "engines": { @@ -1786,6 +1997,8 @@ }, "node_modules/ajv": { "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", "dependencies": { @@ -1801,6 +2014,8 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -1815,11 +2030,15 @@ }, "node_modules/argparse": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, "license": "Python-2.0" }, "node_modules/assertion-error": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -1828,11 +2047,15 @@ }, "node_modules/balanced-match": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/bidi-js": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "dev": true, "license": "MIT", "dependencies": { @@ -1841,6 +2064,8 @@ }, "node_modules/brace-expansion": { "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -1850,11 +2075,15 @@ }, "node_modules/buffer-from": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true, "license": "MIT" }, "node_modules/callsites": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", "engines": { @@ -1862,7 +2091,9 @@ } }, "node_modules/chai": { - "version": "6.2.1", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { @@ -1871,6 +2102,8 @@ }, "node_modules/chalk": { "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { @@ -1886,6 +2119,8 @@ }, "node_modules/color-convert": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1897,26 +2132,36 @@ }, "node_modules/color-name": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, "node_modules/commander": { "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, "license": "MIT" }, "node_modules/commondir": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true, "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -1930,6 +2175,8 @@ }, "node_modules/css-tree": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", "dev": true, "license": "MIT", "dependencies": { @@ -1942,36 +2189,45 @@ }, "node_modules/cssfontparser": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz", + "integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==", "dev": true, "license": "MIT" }, "node_modules/cssstyle": { - "version": "5.3.3", + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^4.0.3", - "@csstools/css-syntax-patches-for-csstree": "^1.0.14", - "css-tree": "^3.1.0" + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" }, "engines": { "node": ">=20" } }, "node_modules/data-urls": { - "version": "6.0.0", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/debug": { "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -1988,16 +2244,22 @@ }, "node_modules/decimal.js": { "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, "license": "MIT" }, "node_modules/deep-is": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, "node_modules/deepmerge": { "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, "license": "MIT", "engines": { @@ -2006,6 +2268,8 @@ }, "node_modules/dom-serializer": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", @@ -2018,6 +2282,8 @@ }, "node_modules/dom-serializer/node_modules/entities": { "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -2028,6 +2294,8 @@ }, "node_modules/domelementtype": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "funding": [ { "type": "github", @@ -2038,6 +2306,8 @@ }, "node_modules/domhandler": { "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" @@ -2051,6 +2321,8 @@ }, "node_modules/domutils": { "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", @@ -2062,7 +2334,9 @@ } }, "node_modules/entities": { - "version": "6.0.1", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -2073,11 +2347,15 @@ }, "node_modules/es-module-lexer": { "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, "node_modules/esbuild": { - "version": "0.25.12", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2088,36 +2366,38 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" } }, "node_modules/escape-string-regexp": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { @@ -2128,7 +2408,9 @@ } }, "node_modules/eslint": { - "version": "9.39.1", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", "dependencies": { @@ -2138,7 +2420,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", + "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -2187,6 +2469,8 @@ }, "node_modules/eslint-plugin-unused-imports": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.3.0.tgz", + "integrity": "sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2201,6 +2485,8 @@ }, "node_modules/eslint-scope": { "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2216,6 +2502,8 @@ }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2227,6 +2515,8 @@ }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2238,6 +2528,8 @@ }, "node_modules/espree": { "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2254,6 +2546,8 @@ }, "node_modules/espree/node_modules/eslint-visitor-keys": { "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2264,7 +2558,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2276,6 +2572,8 @@ }, "node_modules/esrecurse": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2287,6 +2585,8 @@ }, "node_modules/estraverse": { "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -2295,11 +2595,15 @@ }, "node_modules/estree-walker": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, "license": "MIT" }, "node_modules/esutils": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -2307,7 +2611,9 @@ } }, "node_modules/expect-type": { - "version": "1.2.2", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2316,21 +2622,29 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "node_modules/fdir": { "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -2347,10 +2661,14 @@ }, "node_modules/fflate": { "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", "license": "MIT" }, "node_modules/file-entry-cache": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2362,6 +2680,8 @@ }, "node_modules/find-up": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -2377,6 +2697,8 @@ }, "node_modules/flat-cache": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { @@ -2389,12 +2711,17 @@ }, "node_modules/flatted": { "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC" }, "node_modules/fsevents": { - "version": "2.3.3", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -2406,6 +2733,8 @@ }, "node_modules/function-bind": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "license": "MIT", "funding": { @@ -2414,6 +2743,8 @@ }, "node_modules/glob-parent": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { @@ -2424,7 +2755,9 @@ } }, "node_modules/globals": { - "version": "16.5.0", + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", + "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", "dev": true, "license": "MIT", "engines": { @@ -2434,13 +2767,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/graphemer": { - "version": "1.4.0", - "dev": true, - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { @@ -2449,6 +2779,8 @@ }, "node_modules/hasown": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2459,18 +2791,22 @@ } }, "node_modules/html-encoding-sniffer": { - "version": "4.0.0", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-encoding": "^3.1.1" + "@exodus/bytes": "^1.6.0" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/htmlparser2": { - "version": "10.0.0", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -2482,12 +2818,14 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.2.1", - "entities": "^6.0.0" + "domutils": "^3.2.2", + "entities": "^7.0.1" } }, "node_modules/http-proxy-agent": { "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { @@ -2500,6 +2838,8 @@ }, "node_modules/https-proxy-agent": { "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", "dependencies": { @@ -2510,19 +2850,10 @@ "node": ">= 14" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ignore": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -2531,6 +2862,8 @@ }, "node_modules/import-fresh": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2546,6 +2879,8 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", "engines": { @@ -2554,6 +2889,8 @@ }, "node_modules/is-core-module": { "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "license": "MIT", "dependencies": { @@ -2568,6 +2905,8 @@ }, "node_modules/is-extglob": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", "engines": { @@ -2576,6 +2915,8 @@ }, "node_modules/is-glob": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { @@ -2587,16 +2928,22 @@ }, "node_modules/is-module": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", "dev": true, "license": "MIT" }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, "license": "MIT" }, "node_modules/is-reference": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2605,11 +2952,15 @@ }, "node_modules/isexe": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, "license": "ISC" }, "node_modules/js-yaml": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -2620,16 +2971,19 @@ } }, "node_modules/jsdom": { - "version": "27.2.0", + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz", + "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", "dev": true, "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.23", - "@asamuzakjp/dom-selector": "^6.7.4", - "cssstyle": "^5.3.3", - "data-urls": "^6.0.0", + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^5.3.7", + "data-urls": "^7.0.0", "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^4.0.0", + "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", @@ -2637,12 +2991,11 @@ "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", + "undici": "^7.20.0", "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.1.0", - "ws": "^8.18.3", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "engines": { @@ -2659,21 +3012,29 @@ }, "node_modules/json-buffer": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, "license": "MIT" }, "node_modules/keyv": { "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { @@ -2682,6 +3043,8 @@ }, "node_modules/levn": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2694,6 +3057,8 @@ }, "node_modules/locate-path": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -2708,19 +3073,25 @@ }, "node_modules/lodash.merge": { "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.2.2", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, "node_modules/magic-string": { "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2729,11 +3100,15 @@ }, "node_modules/mdn-data": { "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", "dev": true, "license": "CC0-1.0" }, "node_modules/minimatch": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", "dependencies": { @@ -2745,11 +3120,15 @@ }, "node_modules/ms": { "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -2767,11 +3146,15 @@ }, "node_modules/natural-compare": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, "node_modules/obug": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -2781,6 +3164,8 @@ }, "node_modules/optionator": { "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { @@ -2797,6 +3182,8 @@ }, "node_modules/p-limit": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2811,6 +3198,8 @@ }, "node_modules/p-locate": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -2825,6 +3214,8 @@ }, "node_modules/parent-module": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", "dependencies": { @@ -2836,6 +3227,8 @@ }, "node_modules/parse-color": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-color/-/parse-color-1.0.0.tgz", + "integrity": "sha512-fuDHYgFHJGbpGMgw9skY/bj3HL/Jrn4l/5rSspy00DoT4RyLnDcRvPxdZ+r6OFwIsgAuhDh4I09tAId4mI12bw==", "dev": true, "license": "MIT", "dependencies": { @@ -2844,10 +3237,14 @@ }, "node_modules/parse-color/node_modules/color-convert": { "version": "0.5.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-0.5.3.tgz", + "integrity": "sha512-RwBeO/B/vZR3dfKL1ye/vx8MHZ40ugzpyfeVG5GsiuGnrlMWe2o8wxBbLCpw9CsxV+wHuzYlCiWnybrIA0ling==", "dev": true }, "node_modules/parse5": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", "dev": true, "license": "MIT", "dependencies": { @@ -2857,8 +3254,23 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", "engines": { @@ -2867,6 +3279,8 @@ }, "node_modules/path-key": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", "engines": { @@ -2875,21 +3289,29 @@ }, "node_modules/path-parse": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true, "license": "MIT" }, "node_modules/pathe": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -2899,8 +3321,42 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", + "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", + "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/postcss": { "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -2928,6 +3384,8 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", "engines": { @@ -2936,6 +3394,8 @@ }, "node_modules/punycode": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { @@ -2944,6 +3404,8 @@ }, "node_modules/randombytes": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2952,6 +3414,8 @@ }, "node_modules/require-from-string": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", "engines": { @@ -2960,6 +3424,8 @@ }, "node_modules/resolve": { "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2979,6 +3445,8 @@ }, "node_modules/resolve-from": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", "engines": { @@ -2986,7 +3454,9 @@ } }, "node_modules/rollup": { - "version": "4.53.3", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "dev": true, "license": "MIT", "dependencies": { @@ -3000,33 +3470,38 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" } }, "node_modules/safe-buffer": { "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "dev": true, "funding": [ { @@ -3044,18 +3519,20 @@ ], "license": "MIT" }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "dev": true, - "license": "MIT" - }, "node_modules/sax": { - "version": "1.4.3", + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", "dev": true, - "license": "BlueOak-1.0.0" + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } }, "node_modules/saxes": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, "license": "ISC", "dependencies": { @@ -3067,6 +3544,8 @@ }, "node_modules/semver": { "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -3078,6 +3557,8 @@ }, "node_modules/serialize-javascript": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3086,6 +3567,8 @@ }, "node_modules/shebang-command": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", "dependencies": { @@ -3097,6 +3580,8 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", "engines": { @@ -3105,16 +3590,22 @@ }, "node_modules/siginfo": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, "node_modules/smob": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", + "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", "dev": true, "license": "MIT" }, "node_modules/source-map": { "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3123,6 +3614,8 @@ }, "node_modules/source-map-js": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3131,6 +3624,8 @@ }, "node_modules/source-map-support": { "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", "dependencies": { @@ -3140,16 +3635,22 @@ }, "node_modules/stackback": { "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/std-env": { "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, "license": "MIT" }, "node_modules/strip-json-comments": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", "engines": { @@ -3161,6 +3662,8 @@ }, "node_modules/supports-color": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { @@ -3172,6 +3675,8 @@ }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, "license": "MIT", "engines": { @@ -3183,11 +3688,15 @@ }, "node_modules/symbol-tree": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, "license": "MIT" }, "node_modules/terser": { - "version": "5.44.1", + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3205,16 +3714,25 @@ }, "node_modules/tinybench": { "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3230,6 +3748,8 @@ }, "node_modules/tinyrainbow": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", "engines": { @@ -3237,23 +3757,29 @@ } }, "node_modules/tldts": { - "version": "7.0.18", + "version": "7.0.21", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.21.tgz", + "integrity": "sha512-Plu6V8fF/XU6d2k8jPtlQf5F4Xx2hAin4r2C2ca7wR8NK5MbRTo9huLUWRe28f3Uk8bYZfg74tit/dSjc18xnw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.18" + "tldts-core": "^7.0.21" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.18", + "version": "7.0.21", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.21.tgz", + "integrity": "sha512-oVOMdHvgjqyzUZH1rOESgJP1uNe2bVrfK0jUHHmiM2rpEiRbf3j4BrsIc6JigJRbHGanQwuZv/R+LTcHsw+bLA==", "dev": true, "license": "MIT" }, "node_modules/tough-cookie": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3265,6 +3791,8 @@ }, "node_modules/tr46": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, "license": "MIT", "dependencies": { @@ -3275,7 +3803,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -3287,11 +3817,15 @@ }, "node_modules/tslib": { "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { @@ -3303,6 +3837,8 @@ }, "node_modules/typescript": { "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3313,13 +3849,27 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.20.0.tgz", + "integrity": "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, "node_modules/uri-js": { "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3327,11 +3877,13 @@ } }, "node_modules/vite": { - "version": "7.2.4", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -3399,18 +3951,35 @@ } } }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/vitest": { - "version": "4.0.14", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.14", - "@vitest/mocker": "4.0.14", - "@vitest/pretty-format": "4.0.14", - "@vitest/runner": "4.0.14", - "@vitest/snapshot": "4.0.14", - "@vitest/spy": "4.0.14", - "@vitest/utils": "4.0.14", + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", @@ -3419,7 +3988,7 @@ "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", + "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", @@ -3438,10 +4007,10 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.14", - "@vitest/browser-preview": "4.0.14", - "@vitest/browser-webdriverio": "4.0.14", - "@vitest/ui": "4.0.14", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, @@ -3477,6 +4046,8 @@ }, "node_modules/vitest-webgl-canvas-mock": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vitest-webgl-canvas-mock/-/vitest-webgl-canvas-mock-1.1.0.tgz", + "integrity": "sha512-F/5+XvBs7cSZPe41IGQTbSjNimB4NntPnRqv4eWb42voFKQINH8y2xZkibNUxYJCGIuDFsYp1lDQgTvWLahSzA==", "dev": true, "license": "MIT", "dependencies": { @@ -3486,6 +4057,8 @@ }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", "dependencies": { @@ -3496,46 +4069,44 @@ } }, "node_modules/webidl-conversions": { - "version": "8.0.0", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" } }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/whatwg-mimetype": { - "version": "4.0.0", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/whatwg-url": { - "version": "15.1.0", + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", + "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", "dev": true, "license": "MIT", "dependencies": { + "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", - "webidl-conversions": "^8.0.0" + "webidl-conversions": "^8.0.1" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/which": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", "dependencies": { @@ -3550,6 +4121,8 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { @@ -3565,34 +4138,18 @@ }, "node_modules/word-wrap": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/ws": { - "version": "8.18.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xml-name-validator": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3601,6 +4158,8 @@ }, "node_modules/xml2js": { "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", "dev": true, "license": "MIT", "dependencies": { @@ -3613,6 +4172,8 @@ }, "node_modules/xmlbuilder": { "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", "dev": true, "license": "MIT", "engines": { @@ -3621,11 +4182,15 @@ }, "node_modules/xmlchars": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" }, "node_modules/yocto-queue": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 3e304959..b8ae4f49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@next2d/player", - "version": "2.13.1", + "version": "3.0.0", "description": "Experience the fast and beautiful anti-aliased rendering of WebGL. You can create rich, interactive graphics, cross-platform applications and games without worrying about browser or device compatibility.", "author": "Toshiyuki Ienaga (https://github.com/ienaga/)", "license": "MIT", @@ -31,6 +31,13 @@ "start": "vite --host", "lint": "eslint src/**/*.ts packages/**/*.ts", "test": "vitest", + "test:e2e": "playwright test --config=e2e/playwright.config.ts", + "test:e2e:webgl": "playwright test --config=e2e/playwright.config.ts --project=webgl", + "test:e2e:webgpu": "playwright test --config=e2e/playwright.config.ts --project=webgpu", + "test:e2e:ui": "playwright test --config=e2e/playwright.config.ts --ui", + "test:e2e:update": "playwright test --config=e2e/playwright.config.ts --update-snapshots", + "test:e2e:update:webgl": "playwright test --config=e2e/playwright.config.ts --project=webgl --update-snapshots", + "test:e2e:update:webgpu": "playwright test --config=e2e/playwright.config.ts --project=webgpu --update-snapshots", "clean": "node ./scripts/clean.js", "build:worker": "rollup -c rollup.renderer.worker.config.js && rollup -c rollup.unzip.worker.config.js", "build:vite": "node ./scripts/version.js && vite build", @@ -42,29 +49,30 @@ }, "dependencies": { "fflate": "^0.8.2", - "htmlparser2": "^10.0.0" + "htmlparser2": "^10.1.0" }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", - "@eslint/js": "^9.39.1", + "@eslint/js": "^9.39.2", + "@playwright/test": "^1.58.1", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.3.0", - "@types/node": "^24.10.1", - "@typescript-eslint/eslint-plugin": "^8.48.0", - "@typescript-eslint/parser": "^8.48.0", - "@vitest/web-worker": "^4.0.14", - "@webgpu/types": "^0.1.66", - "eslint": "^9.39.1", + "@types/node": "^25.2.1", + "@typescript-eslint/eslint-plugin": "^8.54.0", + "@typescript-eslint/parser": "^8.54.0", + "@vitest/web-worker": "^4.0.18", + "@webgpu/types": "^0.1.69", + "eslint": "^9.39.2", "eslint-plugin-unused-imports": "^4.3.0", - "globals": "^16.5.0", - "jsdom": "^27.2.0", - "rollup": "^4.53.3", + "globals": "^17.3.0", + "jsdom": "^28.0.0", + "rollup": "^4.57.1", "tslib": "^2.8.1", "typescript": "^5.9.3", - "vite": "^7.2.4", - "vitest": "^4.0.14", + "vite": "^7.3.1", + "vitest": "^4.0.18", "vitest-webgl-canvas-mock": "^1.1.0", "xml2js": "^0.6.2" }, @@ -82,6 +90,7 @@ "@next2d/text": "file:packages/text", "@next2d/texture-packer": "file:packages/texture-packer", "@next2d/ui": "file:packages/ui", - "@next2d/webgl": "file:packages/webgl" + "@next2d/webgl": "file:packages/webgl", + "@next2d/webgpu": "file:packages/webgpu" } } diff --git a/packages/cache/README.md b/packages/cache/README.md index d30f7f1a..965a6859 100644 --- a/packages/cache/README.md +++ b/packages/cache/README.md @@ -1,11 +1,226 @@ @next2d/cache ============= -## Installation +**Important**: `@next2d/cache` prohibits importing other packages. This package is a foundational module that must remain independent to avoid circular dependencies. + +**重要**: `@next2d/cache` は他の packages の import を禁止しています。このパッケージは基盤モジュールであり、循環依存を避けるために独立を維持する必要があります。 + +--- + +A package for managing rendering cache in Next2D Player. It caches position information on atlas textures corresponding to DisplayObject rendering results, improving performance during re-rendering. + +Next2Dプレイヤーのレンダリングキャッシュを管理するパッケージです。DisplayObjectの描画結果に対応するアトラステクスチャ上の位置情報をキャッシュし、再描画時のパフォーマンスを向上させます。 + +## Overview / 概要 + +`@next2d/cache` optimizes rendering performance by caching DisplayObject rendering results as position information on atlas textures (`Node` from `@next2d/texture-packer`), enabling reuse of identical rendering content. + +`@next2d/cache`は、DisplayObjectの描画結果をアトラステクスチャ上の位置情報(`@next2d/texture-packer`の`Node`)としてキャッシュし、同じ描画内容を再利用することでレンダリングパフォーマンスを最適化します。 + +Cache is managed on both the main thread and the rendering thread (Worker): + +メインスレッドと描画スレッド(Worker)の両方でキャッシュを管理しています: + +- **Main Thread / メインスレッド**: Manages cache existence with boolean values (sets `true` as value) using the same key +- **Worker Thread (Rendering Thread) / Workerスレッド(描画スレッド)**: Caches `Node` objects from `@next2d/texture-packer`. `Node` contains rectangle coordinate information (index, x, y, w, h) drawn on atlas textures, which is used during Instanced Array rendering to sample the correct region from the atlas texture. + +Cache keys are generated from scale, alpha values, and filter parameters, detecting changes in transformation matrices and filters to update the cache appropriately. + +キャッシュキーはスケール、アルファ値、フィルターパラメータから生成され、変換行列やフィルターの変更を検知して適切にキャッシュを更新します。 + +### How Caching Works / キャッシュの仕組み + +1. **Atlas Registration of Rendering Results / 描画結果のアトラス登録**: DisplayObject rendering results are stored in atlas textures +2. **Node Information Caching / Node情報のキャッシュ**: Rectangle position (x, y, width, height) and index on atlas texture are cached as `Node` +3. **Usage in Instanced Rendering / Instanced描画での利用**: Cached `Node` coordinate information is used for efficient Instanced Array rendering + +## Directory Structure / ディレクトリ構造 + +``` +src/ +├── index.ts # Export definitions / エクスポート定義 +├── CacheStore.ts # Main cache management class / キャッシュ管理メインクラス +├── CacheUtil.ts # Utility functions / ユーティリティ関数 +└── CacheStore/ + └── service/ + ├── CacheStoreDestroyService.ts # Cache destruction / キャッシュ破棄 + ├── CacheStoreGenerateFilterKeysService.ts # Filter key generation / フィルターキー生成 + ├── CacheStoreGenerateKeysService.ts # Cache key generation / キャッシュキー生成 + ├── CacheStoreGetService.ts # Cache retrieval / キャッシュ取得 + ├── CacheStoreHasService.ts # Cache existence check / キャッシュ存在確認 + ├── CacheStoreRemoveByIdService.ts # Delete by ID / ID指定削除 + ├── CacheStoreRemoveService.ts # Cache deletion / キャッシュ削除 + ├── CacheStoreRemoveTimerScheduledCacheService.ts # Timer deletion execution / タイマー削除実行 + ├── CacheStoreRemoveTimerService.ts # Timer deletion registration / タイマー削除登録 + ├── CacheStoreResetService.ts # Reset all caches / 全キャッシュリセット + └── CacheStoreSetService.ts # Cache storage / キャッシュ保存 +``` + +## Key Components / 主要コンポーネント + +### CacheStore +The central class for caching, providing the following features: + +キャッシュの中心となるクラスで、以下の機能を提供します: + +- **Cache Store / キャッシュストア**: Map storing `Node` data with unique_key and cache key pairs +- **Cache Trash / キャッシュトラッシュ**: Temporary storage for caches scheduled for deletion +- **Timer Control / タイマー制御**: Cache lifecycle management through delayed deletion +- **Canvas Pool / Canvasプール**: Reuse pool for temporary rendering HTMLCanvasElements + +### Node (@next2d/texture-packer) +The actual cached data entity, containing the following information: + +キャッシュされるデータの実体で、以下の情報を持ちます: + +- `index`: Atlas texture identification number / アトラステクスチャの識別番号 +- `x`, `y`: Rectangle x,y coordinates on atlas texture / アトラステクスチャ上の矩形のx,y座標 +- `w`, `h`: Rectangle width and height / 矩形の幅と高さ + +## Data Flow / データフロー + +```mermaid +sequenceDiagram + participant DO as DisplayObject + participant CS as CacheStore + participant Atlas as AtlasManager + participant Store as Cache Store (Node) + + DO->>CS: generateKeys(scale, alpha) + CS-->>DO: cacheKey + DO->>CS: has(uniqueKey, cacheKey) + alt Cache exists / キャッシュあり + CS->>Store: get(uniqueKey, cacheKey) + Store-->>DO: cached Node (x, y, w, h, index) + Note over DO: Render with Instanced Array
Sample atlas using Node coordinates
Instanced Arrayで描画
Node座標でアトラスをサンプリング + else Cache not found / キャッシュなし + Note over DO: New rendering process
新規描画処理 + DO->>Atlas: Register rendering result to atlas
アトラスに描画結果を登録 + Atlas-->>DO: Node (coordinate info / 座標情報) + DO->>CS: set(uniqueKey, cacheKey, Node) + CS->>Store: store Node + end +``` + +## Cache and Instanced Rendering / キャッシュとインスタンス描画 + +```mermaid +flowchart TD + subgraph "Cache Registration Flow / キャッシュ登録フロー" + A[DisplayObject Rendering
DisplayObject描画] --> B[Draw to Atlas Texture
アトラステクスチャに描画] + B --> C[Get Node
index, x, y, w, h
Node取得] + C --> D[Save Node to CacheStore
CacheStoreにNodeを保存] + end + + subgraph "Cache Usage Flow / キャッシュ利用フロー" + E[DisplayObject Re-render
DisplayObject再描画] --> F{Cache exists?
キャッシュ存在?} + F -->|Yes| G[Get Node
Nodeを取得] + G --> H[Instanced Array Rendering
Instanced Array描画] + H --> I[Sample Atlas with Node coordinates
Node座標でアトラスをサンプリング] + F -->|No| A + end + + subgraph "Instanced Array Data / Instanced Array データ" + J["Instance Data:
- rect (Node.x, Node.y, Node.w, Node.h)
- atlas index (Node.index)
- transform matrix
- color transform"] + end + + I --> J +``` + +## Cache Lifecycle / キャッシュライフサイクル + +```mermaid +flowchart TD + A[DisplayObject Render Request
DisplayObject描画リクエスト] --> B{Cache exists?
キャッシュ存在?} + B -->|Yes| C[Get Node from Cache
キャッシュからNode取得] + B -->|No| D[New Rendering & Atlas Registration
新規描画・アトラス登録] + D --> E[Save Node info to Cache
Node情報をキャッシュに保存] + C --> G[Render with Instanced Array
Instanced Arrayで描画] + E --> G +``` + +### Deletion Flow (Delayed Deletion Mechanism) / 削除フロー(遅延削除メカニズム) + +Timer-based delayed deletion reduces re-rendering costs for temporarily hidden objects. + +タイマーによる遅延削除で、一時的に非表示になったオブジェクトの再描画コストを削減します。 + +```mermaid +flowchart TD + H[DisplayObject Deletion Request
DisplayObject削除要求] --> I["removeTimer called
Set trash flag
removeTimer呼び出し
trashフラグ設定"] + I --> J["Register to trashStore
Start 1-second timer
trashStoreに登録
1秒タイマー開始"] + J --> K{Re-access during timer?
タイマー中に再アクセス?} + + K -->|Yes| L["get() removes trash flag
get()でtrashフラグ削除"] + L --> M[Deletion cancelled
Continue using cache
削除キャンセル
キャッシュ継続利用] + + K -->|No| N["Timer complete
$removeCache = true
タイマー完了"] + N --> O["removeTimerScheduledCache
Check trash flag
trashフラグ確認"] + O --> P{Trash flag remains?
trashフラグ残存?} + P -->|Yes| Q[Execute removeById
removeById実行] + Q --> R[Complete cache deletion
キャッシュ完全削除] + R --> S[Release Node
Free atlas region
Nodeをrelease
アトラス領域を解放] + P -->|No| T[Skip deletion
削除スキップ] +``` + +### Deletion Flow Details / 削除フローの詳細 + +1. **removeTimer**: Called when DisplayObject is deleted, sets `trash` flag, registers to trashStore, and starts 1-second timer + - DisplayObject削除時に呼び出し、`trash`フラグを設定してtrashStoreに登録、1秒タイマー開始 +2. **Flag removal via get()**: When cache is accessed via `get()` during timer, `data.delete("trash")` removes the flag + - タイマー中に`get()`でキャッシュにアクセスすると、`data.delete("trash")`でフラグが削除される +3. **removeTimerScheduledCache**: After timer completes, only entries with remaining `trash` flag are actually deleted + - タイマー完了後、`trash`フラグが残っているエントリのみを実際に削除 + +## Thread Architecture / スレッドアーキテクチャ + +```mermaid +flowchart LR + subgraph "Main Thread / メインスレッド" + MC[CacheStore] + MB["Boolean Cache
(Existence check / 存在確認用)"] + MC --> MB + end + + subgraph "Worker Thread (Renderer) / Workerスレッド" + WC[CacheStore] + WN["Node Cache
(Coordinate info / 座標情報)"] + WC --> WN + end + + subgraph "Atlas Texture / アトラステクスチャ" + AT[Atlas Texture
アトラステクスチャ
2048x2048] + R1[Region 1
Node.x,y,w,h] + R2[Region 2
Node.x,y,w,h] + RN[Region N...] + AT --> R1 + AT --> R2 + AT --> RN + end + + MB -.->|Same key existence check
同じキーで存在確認| WN + WN -.->|Coordinate reference
座標情報参照| AT +``` + +### Inter-Thread Cache Coordination / スレッド間のキャッシュ連携 + +| Thread / スレッド | Cache Content / キャッシュ内容 | Purpose / 用途 | +|---------|--------------|------| +| Main Thread / メインスレッド | `boolean` (`true`) | Cache existence check, render command generation decision / キャッシュの存在確認、描画コマンド生成の判断 | +| Worker Thread / Workerスレッド | `Node` (index, x, y, w, h) | Atlas coordinate reference during Instanced Array rendering / Instanced Array描画時のアトラス座標参照 | + +Both threads use the same key (unique_key + cacheKey) to maintain cache consistency. + +両スレッドで同じキー(unique_key + cacheKey)を使用することで、キャッシュの整合性を保っています。 + +## Installation / インストール ``` npm install @next2d/cache ``` -## License +## License / ライセンス + This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [LICENSE](LICENSE) file for details. + +このプロジェクトは[MITライセンス](https://opensource.org/licenses/MIT)の下でライセンスされています。詳細は[LICENSE](LICENSE)ファイルを参照してください。 diff --git a/packages/cache/src/CacheStore.ts b/packages/cache/src/CacheStore.ts index dea1c439..2ba82a68 100644 --- a/packages/cache/src/CacheStore.ts +++ b/packages/cache/src/CacheStore.ts @@ -269,11 +269,11 @@ export class CacheStore * @param {number} b * @param {number} c * @param {number} d - * @return {string} + * @return {number} * @method * @public */ - generateFilterKeys (a: number, b: number, c: number, d: number): string + generateFilterKeys (a: number, b: number, c: number, d: number): number { return cacheStoreGenerateFilterKeysService(a, b, c, d); } diff --git a/packages/cache/src/CacheStore/service/CacheStoreGenerateFilterKeysService.test.ts b/packages/cache/src/CacheStore/service/CacheStoreGenerateFilterKeysService.test.ts index 9b2b7517..8997ee55 100644 --- a/packages/cache/src/CacheStore/service/CacheStoreGenerateFilterKeysService.test.ts +++ b/packages/cache/src/CacheStore/service/CacheStoreGenerateFilterKeysService.test.ts @@ -5,11 +5,26 @@ describe("CacheStoreGenerateFilterKeysService.js test", () => { it("test case1", () => { - expect(execute(1, 0, 0, 1)).toBe("1001"); + expect(execute(1, 0, 0, 1)).toBe(11788805); }); it("test case2", () => { - expect(execute(0.25, 0.5, -0.3, 1.25)).toBe("0.250.5-0.31.25"); + expect(execute(0.25, 0.5, -0.3, 1.25)).toBe(845122); + }); + + it("same input should return same hash", () => + { + expect(execute(1, 0, 0, 1)).toBe(execute(1, 0, 0, 1)); + }); + + it("different input should return different hash", () => + { + expect(execute(1, 0, 0, 1)).not.toBe(execute(0.25, 0.5, -0.3, 1.25)); + }); + + it("return type should be number", () => + { + expect(typeof execute(1, 0, 0, 1)).toBe("number"); }); }); \ No newline at end of file diff --git a/packages/cache/src/CacheStore/service/CacheStoreGenerateFilterKeysService.ts b/packages/cache/src/CacheStore/service/CacheStoreGenerateFilterKeysService.ts index 853aaf15..b9ad4928 100644 --- a/packages/cache/src/CacheStore/service/CacheStoreGenerateFilterKeysService.ts +++ b/packages/cache/src/CacheStore/service/CacheStoreGenerateFilterKeysService.ts @@ -1,16 +1,62 @@ /** - * @description キャッシュストアのキーを生成 - * Generate cache store keys + * @description キャッシュストアのフィルターキーを生成 + * Generate cache store filter keys * * @param {number} a * @param {number} b * @param {number} c * @param {number} d - * @return {string} + * @return {number} * @method * @public */ -export const execute = (a: number, b: number, c: number, d: number): string => +export const execute = (a: number, b: number, c: number, d: number): number => { - return `${a}${b}${c}${d}`; + let hash = 2166136261; // FNV-1aオフセット basis + + // a処理 + let num = a * 100 | 0; + hash ^= num & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 8 & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 16 & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 24; + hash = Math.imul(hash, 16777619); + + // b処理 + num = b * 100 | 0; + hash ^= num & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 8 & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 16 & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 24; + hash = Math.imul(hash, 16777619); + + // c処理 + num = c * 100 | 0; + hash ^= num & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 8 & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 16 & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 24; + hash = Math.imul(hash, 16777619); + + // d処理 + num = d * 100 | 0; + hash ^= num & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 8 & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 16 & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 24; + hash = Math.imul(hash, 16777619); + + return (hash >>> 0) % 16777216; }; \ No newline at end of file diff --git a/packages/cache/src/CacheStore/service/CacheStoreGenerateKeysService.test.ts b/packages/cache/src/CacheStore/service/CacheStoreGenerateKeysService.test.ts index ba13a3f8..1fd084d1 100644 --- a/packages/cache/src/CacheStore/service/CacheStoreGenerateKeysService.test.ts +++ b/packages/cache/src/CacheStore/service/CacheStoreGenerateKeysService.test.ts @@ -12,4 +12,9 @@ describe("CacheStoreGenerateKeysService.js test", () => { expect(execute(0.25, 0.5, 0.3)).toBe(763280); }); + + it("test case3", () => + { + expect(execute(Math.PI, 0.21111, 0.3333)).toBe(11162592); + }); }); \ No newline at end of file diff --git a/packages/cache/src/CacheStore/service/CacheStoreGenerateKeysService.ts b/packages/cache/src/CacheStore/service/CacheStoreGenerateKeysService.ts index ecedb6f0..9134fba7 100644 --- a/packages/cache/src/CacheStore/service/CacheStoreGenerateKeysService.ts +++ b/packages/cache/src/CacheStore/service/CacheStoreGenerateKeysService.ts @@ -11,23 +11,41 @@ */ export const execute = (x_scale: number, y_scale: number, alpha: number): number => { - const values = [x_scale * 100, y_scale * 100]; - if (alpha) { - values.push(alpha * 100); - } - let hash = 2166136261; // FNV-1aオフセット basis - for (let idx = 0; idx < values.length; ++idx) { - let num = values[idx] | 0; // 整数として扱う + // x_scale処理 + let num = x_scale * 100 | 0; + hash ^= num & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 8 & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 16 & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 24; + hash = Math.imul(hash, 16777619); - // 32bit整数の各バイトを処理 - for (let i = 0; i < 4; i++) { - const byte = num & 0xff; - hash ^= byte; - hash = Math.imul(hash, 16777619); // FNV-1a の FNV prime - num >>>= 8; - } + // y_scale処理 + num = y_scale * 100 | 0; + hash ^= num & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 8 & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 16 & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 24; + hash = Math.imul(hash, 16777619); + + // alpha処理(alphaが0以外の場合のみ) + if (alpha) { + num = alpha * 100 | 0; + hash ^= num & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 8 & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 16 & 0xff; + hash = Math.imul(hash, 16777619); + hash ^= num >>> 24; + hash = Math.imul(hash, 16777619); } return (hash >>> 0) % 16777216; diff --git a/packages/core/README.md b/packages/core/README.md index 43bffffa..61c01d49 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,11 +1,273 @@ @next2d/core ============= -## Installation +The core package for Next2D Player. It provides central functionality for Next2D including player management, Canvas initialization, event processing, and rendering worker communication. +Next2Dプレイヤーのコアパッケージです。プレイヤー管理、Canvas初期化、イベント処理、レンダリングワーカー通信など、Next2Dの中核機能を提供します。 + +**Important**: `@next2d/core` must not be referenced from other packages. This package is the top-level entry point and depends on other packages, but other packages must not depend on it to avoid circular dependencies. + +**重要**: `@next2d/core` は他の packages からの参照を禁止しています。このパッケージはトップレベルのエントリーポイントであり、他のパッケージに依存しますが、循環依存を避けるため、他のパッケージからの依存は禁止されています。 + +## Overview / 概要 + +`@next2d/core` serves as the main entry point for the Next2D Player, providing the following features: + +`@next2d/core`は、Next2Dプレイヤーのメインエントリーポイントとして以下の機能を提供します: + +- **Next2D**: Application bootstrap, JSON file loading, root MovieClip creation / アプリケーションのブートストラップ、JSONファイルのロード、ルートMovieClipの作成 +- **Player**: Play/stop control, resize handling, event management, render loop control / 再生/停止制御、リサイズ処理、イベント管理、描画ループ制御 +- **Canvas**: Canvas element initialization, pointer/keyboard event processing, OffscreenCanvas support / Canvas要素の初期化、ポインター/キーボードイベント処理、OffscreenCanvas対応 +- **RendererWorker**: Parallelized rendering using WebWorker / WebWorkerを使用した描画処理の並列化 +- **Package Facades**: Unified access to Display, Events, Filters, Geom, Media, Net, Text, UI packages / Display、Events、Filters、Geom、Media、Net、Text、UIパッケージへの統一アクセス + +## Directory Structure / ディレクトリ構造 + +``` +src/ +├── index.ts # Export definitions / エクスポート定義 +├── CoreUtil.ts # Core utility functions / コアユーティリティ関数 +├── RendererWorker.ts # Rendering worker initialization / レンダリングワーカー初期化 +│ +├── Next2D.ts # Next2D main class / Next2Dメインクラス +├── Next2D/ +│ ├── service/ +│ │ └── VideoSyncService.ts # Video sync service / ビデオ同期サービス +│ └── usecase/ +│ ├── LoadUseCase.ts # JSON file loading / JSONファイルロード +│ ├── CreateRootMovieClipUseCase.ts # Root MovieClip creation / ルートMovieClip作成 +│ └── CaptureToCanvasUseCase.ts # Canvas capture / Canvas キャプチャ +│ +├── Player.ts # Player main class / Playerメインクラス +├── Player/ +│ ├── service/ +│ │ ├── PlayerAppendElementService.ts # Canvas element append / Canvas要素追加 +│ │ ├── PlayerApplyContainerElementStyleService.ts # Style application / スタイル適用 +│ │ ├── PlayerCreateContainerElementService.ts # Container element creation / コンテナ要素作成 +│ │ ├── PlayerDoubleClickEventService.ts # Double click event / ダブルクリックイベント +│ │ ├── PlayerKeyDownEventService.ts # Key down event / キー押下イベント +│ │ ├── PlayerKeyUpEventService.ts # Key up event / キー離しイベント +│ │ ├── PlayerLoadingAnimationService.ts # Loading animation / ローディングアニメーション +│ │ ├── PlayerPointerDownEventService.ts # Pointer down event / ポインター押下イベント +│ │ ├── PlayerPointerMoveEventService.ts # Pointer move event / ポインター移動イベント +│ │ ├── PlayerPointerUpEventService.ts # Pointer up event / ポインター離しイベント +│ │ ├── PlayerRemoveCachePostMessageService.ts # Cache removal message / キャッシュ削除メッセージ +│ │ ├── PlayerRemoveLoadingElementService.ts # Loading element removal / ローディング要素削除 +│ │ ├── PlayerRenderingPostMessageService.ts # Rendering message / レンダリングメッセージ +│ │ ├── PlayerResizePostMessageService.ts # Resize message / リサイズメッセージ +│ │ ├── PlayerSetCurrentMousePointService.ts # Mouse coordinate setting / マウス座標設定 +│ │ ├── PlayerStopService.ts # Stop processing / 停止処理 +│ │ └── PlayerTransferCanvasPostMessageService.ts # Canvas transfer message / Canvas転送メッセージ +│ └── usecase/ +│ ├── PlayerBootUseCase.ts # Player boot / Player初期起動 +│ ├── PlayerHitTestUseCase.ts # Hit test / ヒットテスト +│ ├── PlayerPlayUseCase.ts # Play start / 再生開始 +│ ├── PlayerReadyCompleteUseCase.ts # Ready complete / 準備完了処理 +│ ├── PlayerRegisterEventUseCase.ts # Event registration / イベント登録 +│ ├── PlayerResizeEventUseCase.ts # Resize event / リサイズイベント +│ ├── PlayerResizeRegisterUseCase.ts # Resize registration / リサイズ登録 +│ └── PlayerTickerUseCase.ts # Frame ticker / フレームティッカー +│ +├── Canvas.ts # Canvas main module / Canvasメインモジュール +├── Canvas/ +│ ├── service/ +│ │ ├── CanvasBootOffscreenCanvasService.ts # OffscreenCanvas boot / OffscreenCanvas起動 +│ │ ├── CanvasInitializeService.ts # Canvas initialization / Canvas初期化 +│ │ └── CanvasSetPositionService.ts # Canvas position setting / Canvas位置設定 +│ └── usecase/ +│ ├── CanvasPointerDownEventUseCase.ts # Pointer down processing / ポインター押下処理 +│ ├── CanvasPointerLeaveEventUseCase.ts # Pointer leave processing / ポインター離脱処理 +│ ├── CanvasPointerMoveEventUseCase.ts # Pointer move processing / ポインター移動処理 +│ ├── CanvasPointerUpEventUseCase.ts # Pointer up processing / ポインター離し処理 +│ ├── CanvasRegisterEventUseCase.ts # Event registration / イベント登録 +│ └── CanvasWheelEventUseCase.ts # Wheel event processing / ホイールイベント処理 +│ +├── Display.ts # Display package facade / Display パッケージファサード +├── Events.ts # Events package facade / Events パッケージファサード +├── Filters.ts # Filters package facade / Filters パッケージファサード +├── Geom.ts # Geom package facade / Geom パッケージファサード +├── Media.ts # Media package facade / Media パッケージファサード +├── Net.ts # Net package facade / Net パッケージファサード +├── Text.ts # Text package facade / Text パッケージファサード +├── UI.ts # UI package facade / UI パッケージファサード +│ +└── interface/ # Type definitions / 型定義 + ├── ICaptureMessage.ts + ├── ICaptureOptions.ts + ├── IDisplay.ts + ├── IDisplayObject.ts + ├── IEvents.ts + ├── IFilters.ts + ├── IGeom.ts + ├── IMedia.ts + ├── INet.ts + ├── IPlayerHitObject.ts + ├── IPlayerOptions.ts + ├── IRemoveCacheMessage.ts + ├── IRenderMessage.ts + ├── IResizeMessage.ts + ├── IStageData.ts + ├── IText.ts + └── IUI.ts +``` + +## Boot Flow / 起動フロー + +```mermaid +sequenceDiagram + participant User + participant Next2D + participant Player + participant Canvas + participant Worker as RendererWorker + participant Loader + participant Stage + + User->>Next2D: load(url, options) + Next2D->>Player: PlayerBootUseCase + Player->>Player: createContainer + Player->>Player: applyStyle + Player->>Player: showLoading + Player->>Canvas: initialize + Canvas->>Canvas: setupPointerEvents + Canvas->>Worker: bootOffscreenCanvas + Worker-->>Canvas: transferControlToOffscreen + Next2D->>Loader: load JSON file + Loader->>Loader: parse data + Loader-->>Stage: set stageWidth/stageHeight/frameRate + Stage->>Stage: addChild(root) + Player->>Player: resize + Player->>Player: removeLoading + Player->>Canvas: append to DOM + Player->>Player: readyComplete + Player-->>User: Stage ready +``` + +## Render Loop / レンダーループ + +```mermaid +flowchart TD + A[User calls Player.play] --> B[PlayerPlayUseCase] + B --> C[Set stopFlag = false] + C --> D[Calculate FPS interval] + D --> E[requestAnimationFrame] + + E --> F[PlayerTickerUseCase] + F --> G{stopFlag?} + G -->|true| Z[End] + G -->|false| H{time > fps?} + + H -->|No| E + H -->|Yes| I[stage.$ticker] + I --> J[Dispatch ENTER_FRAME event] + J --> K{stage.changed?} + + K -->|Yes| L[PlayerRenderingPostMessageService] + L --> M[Generate render data] + M --> N[Worker.postMessage] + N --> O[RendererWorker renders] + + K -->|No| P{Cache cleanup needed?} + O --> P + + P -->|Yes| Q[removeTimerScheduledCache] + P -->|No| R[PlayerRemoveCachePostMessageService] + Q --> R + + R --> E + + S[User calls Player.stop] --> T[PlayerStopService] + T --> U[Set stopFlag = true] + U --> V[cancelAnimationFrame] +``` + +## Key Components / 主要コンポーネント + +### Next2D Class + +The main entry point for the application. Provides access to all packages and manages initialization. + +アプリケーションのメインエントリーポイント。すべてのパッケージへのアクセスを提供し、初期化を管理します。 + +**Key Methods / 主要メソッド:** +- `load(url, options)`: Load JSON file and initialize player / JSONファイルを読み込み、プレイヤーを初期化 +- `createRootMovieClip(width, height, fps, options)`: Programmatically create root MovieClip / プログラマティックにルートMovieClipを作成 +- `captureToCanvas(displayObject, options)`: Capture DisplayObject to Canvas / DisplayObjectをCanvasにキャプチャ + +### Player Class + +Manages rendering, events, settings, and controls. + +描画、イベント、設定、コントロールを管理します。 + +**Key Methods / 主要メソッド:** +- `play()`: Start render loop / 描画ループを開始 +- `stop()`: Stop render loop / 描画ループを停止 +- `cacheClear()`: Clear all render caches / すべての描画キャッシュをクリア +- `setOptions(options)`: Set player options / プレイヤーオプションを設定 + +**Key Properties / 主要プロパティ:** +- `rendererWidth/rendererHeight`: Render area size including devicePixelRatio / devicePixelRatioを含む描画領域サイズ +- `screenWidth/screenHeight`: Screen display size / 画面表示サイズ +- `fps`: Frame rate interval (milliseconds) / フレームレート間隔(ミリ秒) +- `fullScreen`: Full screen mode setting / フルスクリーンモード設定 + +### Canvas Module + +Manages Canvas element initialization and event processing. + +Canvas要素の初期化とイベント処理を管理します。 + +**Features / 機能:** +- Initialization with devicePixelRatio support / devicePixelRatio対応の初期化 +- Pointer event handling (down/move/up/leave) / ポインターイベント(down/move/up/leave)のハンドリング +- Wheel event processing / ホイールイベント処理 +- OffscreenCanvas support (for WebWorker) / OffscreenCanvas対応(WebWorker用) + +### RendererWorker + +Parallelizes rendering using WebWorker to improve main thread performance. + +WebWorkerを使用して描画処理を並列化し、メインスレッドのパフォーマンスを向上させます。 + +**Communication Contents / 通信内容:** +- Rendering message (render data) / レンダリングメッセージ(render data) +- Resize message (canvas size) / リサイズメッセージ(canvas size) +- Cache removal message (cache IDs) / キャッシュ削除メッセージ(cache IDs) +- Canvas transfer message (OffscreenCanvas) / Canvas転送メッセージ(OffscreenCanvas) + +## Usage Example / 使用例 + +```typescript +import { Next2D } from "@next2d/core"; + +const next2d = new Next2D(); + +// Load from JSON file / JSONファイルからロード +await next2d.load("/path/to/content.json", { + width: 800, + height: 600, + tagId: "app", + bgColor: "#ffffff" +}); + +// Create programmatically / プログラマティックに作成 +const root = await next2d.createRootMovieClip(800, 600, 60); +root.addChild(myDisplayObject); + +// Access Display package / Displayパッケージへのアクセス +const sprite = new next2d.display.Sprite(); +const shape = new next2d.display.Shape(); ``` + +## Installation / インストール + +```bash npm install @next2d/core ``` -## License +## License / ライセンス + This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [LICENSE](LICENSE) file for details. + +このプロジェクトは[MITライセンス](https://opensource.org/licenses/MIT)の下でライセンスされています。詳細は[LICENSE](LICENSE)ファイルを参照してください。 diff --git a/packages/core/src/Canvas/service/CanvasInitializeService.test.ts b/packages/core/src/Canvas/service/CanvasInitializeService.test.ts index 5408faa3..67c0f533 100644 --- a/packages/core/src/Canvas/service/CanvasInitializeService.test.ts +++ b/packages/core/src/Canvas/service/CanvasInitializeService.test.ts @@ -10,7 +10,7 @@ describe("CanvasInitializeService.js test", () => expect(canvas.width).toBe(1); expect(canvas.height).toBe(1); expect(canvas.getAttribute("style")).toBe( - "-webkit-tap-highlight-color: rgba(0,0,0,0);backface-visibility: hidden;touch-action: none;" + "-webkit-tap-highlight-color: rgba(0,0,0,0);backface-visibility: hidden;touch-action: none;user-select: none;-webkit-user-select: none;-webkit-touch-callout: none;" ); }); @@ -21,7 +21,7 @@ describe("CanvasInitializeService.js test", () => expect(canvas.width).toBe(1); expect(canvas.height).toBe(1); expect(canvas.getAttribute("style")).toBe( - "-webkit-tap-highlight-color: rgba(0,0,0,0);backface-visibility: hidden;touch-action: none;transform: scale(0.5);" + "-webkit-tap-highlight-color: rgba(0,0,0,0);backface-visibility: hidden;touch-action: none;user-select: none;-webkit-user-select: none;-webkit-touch-callout: none;transform: scale(0.5);" ); }); }); \ No newline at end of file diff --git a/packages/core/src/Canvas/service/CanvasInitializeService.ts b/packages/core/src/Canvas/service/CanvasInitializeService.ts index 8f82a780..dd936295 100644 --- a/packages/core/src/Canvas/service/CanvasInitializeService.ts +++ b/packages/core/src/Canvas/service/CanvasInitializeService.ts @@ -18,6 +18,9 @@ export const execute = ( style += "-webkit-tap-highlight-color: rgba(0,0,0,0);"; style += "backface-visibility: hidden;"; style += "touch-action: none;"; + style += "user-select: none;"; + style += "-webkit-user-select: none;"; + style += "-webkit-touch-callout: none;"; if (ratio > 1) { style += `transform: scale(${1 / ratio});`; diff --git a/packages/core/src/Player/usecase/PlayerTickerUseCase.ts b/packages/core/src/Player/usecase/PlayerTickerUseCase.ts index 94963474..a4853923 100644 --- a/packages/core/src/Player/usecase/PlayerTickerUseCase.ts +++ b/packages/core/src/Player/usecase/PlayerTickerUseCase.ts @@ -5,6 +5,13 @@ import { $cacheStore } from "@next2d/cache"; import { execute as playerRenderingPostMessageService } from "../service/PlayerRenderingPostMessageService"; import { execute as playerRemoveCachePostMessageService } from "../service/PlayerRemoveCachePostMessageService"; +/** + * @private + * @constant + * @type {Event} + */ +const enterFrameEvent: Event = new Event(Event.ENTER_FRAME); + /** * @description Playerの定期処理 * Regular processing of Player @@ -35,7 +42,7 @@ export const execute = (timestamp: number): void => // enter frame event if (stage.hasEventListener(Event.ENTER_FRAME)) { - stage.dispatchEvent(new Event(Event.ENTER_FRAME)); + stage.dispatchEvent(enterFrameEvent); } // 描画情報を生成してworkerに送る diff --git a/packages/display/README.md b/packages/display/README.md index 49666088..ddca2227 100644 --- a/packages/display/README.md +++ b/packages/display/README.md @@ -1,11 +1,394 @@ -@next2d/display -============= +# @next2d/display -## Installation +Display package for Next2D Player - DisplayObject hierarchy, Graphics drawing API, and content loading capabilities. -``` +Next2D Player の Display パッケージ - DisplayObject 階層、Graphics 描画 API、およびコンテンツ読み込み機能を提供します。 + +## Overview / 概要 + +The `@next2d/display` package provides the core visual object model for Next2D Player. It implements the complete DisplayObject hierarchy, a powerful vector graphics drawing API, and content loading capabilities. + +`@next2d/display` パッケージは Next2D Player のコアとなるビジュアルオブジェクトモデルを提供します。完全な DisplayObject 階層、強力なベクターグラフィックス描画 API、およびコンテンツ読み込み機能を実装しています。 + +### Key Features / 主な機能 + +- **DisplayObject Hierarchy**: Complete implementation of the display list architecture + - **DisplayObject 階層**: ディスプレイリストアーキテクチャの完全な実装 +- **Graphics API**: Vector drawing capabilities with fills, strokes, and gradients + - **Graphics API**: 塗り、線、グラデーションを使ったベクター描画機能 +- **Content Loading**: Dynamic loading of external content and assets + - **コンテンツ読み込み**: 外部コンテンツとアセットの動的読み込み +- **Timeline Support**: MovieClip with frame-based animation and scripting + - **タイムラインサポート**: フレームベースのアニメーションとスクリプティングを持つ MovieClip + +## Installation / インストール + +```bash npm install @next2d/display ``` -## License +## Directory Structure / ディレクトリ構造 + +``` +src/ +├── DisplayObject.ts # Base class for all display objects / すべての表示オブジェクトの基底クラス +├── DisplayObjectContainer.ts # Container for child display objects / 子表示オブジェクトのコンテナ +├── InteractiveObject.ts # Base class for interactive objects / インタラクティブオブジェクトの基底クラス +├── Sprite.ts # Basic display object container with graphics / グラフィックスを持つ基本的な表示オブジェクトコンテナ +├── MovieClip.ts # Timeline-based animation object / タイムラインベースのアニメーションオブジェクト +├── Shape.ts # Lightweight graphics display object / 軽量なグラフィックス表示オブジェクト +├── Stage.ts # Root display object / ルート表示オブジェクト +├── TextField.ts # Text display and input / テキスト表示と入力 +├── Video.ts # Video display object / ビデオ表示オブジェクト +│ +├── Graphics.ts # Vector drawing API / ベクター描画 API +├── Graphics/ # Graphics implementation details / Graphics 実装の詳細 +│ ├── service/ # Graphics service layer / Graphics サービス層 +│ └── usecase/ # Graphics use cases / Graphics ユースケース +│ +├── Loader.ts # External content loader / 外部コンテンツローダー +├── Loader/ # Loader implementation details / Loader 実装の詳細 +│ ├── service/ # Loader service layer / Loader サービス層 +│ ├── usecase/ # Loader use cases / Loader ユースケース +│ └── worker/ # Loader web workers / Loader ウェブワーカー +│ +├── LoaderInfo.ts # Loader information / ローダー情報 +├── BitmapData.ts # Bitmap manipulation / ビットマップ操作 +├── BlendMode.ts # Blend mode constants / ブレンドモード定数 +├── FrameLabel.ts # Frame label for timeline / タイムライン用フレームラベル +├── LoopConfig.ts # Loop configuration / ループ設定 +├── LoopType.ts # Loop type constants / ループタイプ定数 +├── DisplayObjectUtil.ts # Display object utilities / 表示オブジェクトユーティリティ +│ +├── GraphicsBitmapFill.ts # Bitmap fill style / ビットマップ塗りスタイル +├── GraphicsGradientFill.ts # Gradient fill style / グラデーション塗りスタイル +│ +├── interface/ # TypeScript interfaces / TypeScript インターフェース +│ ├── IDisplayObject.ts +│ ├── IBlendMode.ts +│ ├── IBounds.ts +│ ├── ICharacter.ts +│ ├── IMovieClipCharacter.ts +│ ├── IShapeCharacter.ts +│ ├── ITextFieldCharacter.ts +│ ├── IVideoCharacter.ts +│ ├── ILoaderInfoData.ts +│ ├── ILoopConfig.ts +│ ├── ILoopType.ts +│ ├── IFrameLabel.ts +│ ├── IPlaceObject.ts +│ └── ... (40+ interface files) +│ +├── DisplayObject/ # DisplayObject implementation / DisplayObject 実装 +│ ├── service/ # Service layer / サービス層 +│ └── usecase/ # Use case layer / ユースケース層 +│ +├── DisplayObjectContainer/ # DisplayObjectContainer implementation / DisplayObjectContainer 実装 +│ ├── service/ # Service layer / サービス層 +│ └── usecase/ # Use case layer / ユースケース層 +│ +├── MovieClip/ # MovieClip implementation / MovieClip 実装 +│ ├── service/ # Service layer / サービス層 +│ └── usecase/ # Use case layer / ユースケース層 +│ +├── Shape/ # Shape implementation / Shape 実装 +│ └── service/ # Service layer / サービス層 +│ +├── Sprite/ # Sprite implementation / Sprite 実装 +│ └── service/ # Service layer / サービス層 +│ +└── Stage/ # Stage implementation / Stage 実装 + └── usecase/ # Use case layer / ユースケース層 +``` + +## Class Hierarchy / クラス階層 + +The display package implements a hierarchical object model where each class extends its parent's functionality. + +display パッケージは、各クラスが親の機能を拡張する階層的なオブジェクトモデルを実装しています。 + +```mermaid +classDiagram + EventDispatcher <|-- DisplayObject + DisplayObject <|-- InteractiveObject + InteractiveObject <|-- DisplayObjectContainer + DisplayObjectContainer <|-- Sprite + DisplayObjectContainer <|-- Stage + Sprite <|-- MovieClip + DisplayObject <|-- Shape + DisplayObject <|-- TextField + DisplayObject <|-- Video + + class EventDispatcher { + +addEventListener() + +removeEventListener() + +dispatchEvent() + } + + class DisplayObject { + +x: number + +y: number + +width: number + +height: number + +rotation: number + +scaleX: number + +scaleY: number + +alpha: number + +visible: boolean + +blendMode: IBlendMode + +filters: IFilterArray + +parent: DisplayObjectContainer + +stage: Stage + +getBounds() + +hitTestObject() + +hitTestPoint() + +globalToLocal() + +localToGlobal() + } + + class InteractiveObject { + +mouseEnabled: boolean + +doubleClickEnabled: boolean + +tabEnabled: boolean + } + + class DisplayObjectContainer { + +children: DisplayObject[] + +numChildren: number + +mouseChildren: boolean + +addChild() + +addChildAt() + +removeChild() + +removeChildAt() + +removeChildren() + +getChildAt() + +getChildByName() + +getChildIndex() + +setChildIndex() + +swapChildren() + +contains() + } + + class Sprite { + +graphics: Graphics + +buttonMode: boolean + +useHandCursor: boolean + +startDrag() + +stopDrag() + } + + class MovieClip { + +currentFrame: number + +totalFrames: number + +currentLabel: string + +currentLabels: FrameLabel[] + +isTimelineEnabled: boolean + +play() + +stop() + +gotoAndPlay() + +gotoAndStop() + +nextFrame() + +prevFrame() + } + + class Shape { + +graphics: Graphics + } + + class Stage { + +stageWidth: number + +stageHeight: number + +frameRate: number + +color: number + } + + class TextField { + +text: string + +htmlText: string + +textColor: number + +autoSize: string + } + + class Video { + +videoWidth: number + +videoHeight: number + } +``` + +## Graphics API / Graphics API + +The Graphics class provides a drawing API for creating vector shapes programmatically. + +Graphics クラスは、ベクター図形をプログラム的に作成するための描画 API を提供します。 + +### Example Usage / 使用例 + +```typescript +import { Sprite, BlendMode } from "@next2d/display"; + +// Create a sprite with graphics +// グラフィックスを持つスプライトを作成 +const sprite = new Sprite(); + +// Draw a rectangle +// 矩形を描画 +sprite.graphics.beginFill(0xFF0000); +sprite.graphics.drawRect(0, 0, 100, 100); +sprite.graphics.endFill(); + +// Draw a circle with gradient +// グラデーションを使った円を描画 +sprite.graphics.beginGradientFill( + "radial", + [0xFF0000, 0x0000FF], + [1, 1], + [0, 255] +); +sprite.graphics.drawCircle(50, 50, 40); +sprite.graphics.endFill(); +``` + +### Graphics Methods / Graphics メソッド + +- **Line Styles / 線スタイル** + - `lineStyle()` - Set line style / 線スタイルを設定 + - `lineGradientStyle()` - Set gradient line style / グラデーション線スタイルを設定 + +- **Fills / 塗り** + - `beginFill()` - Begin solid fill / 単色塗りを開始 + - `beginGradientFill()` - Begin gradient fill / グラデーション塗りを開始 + - `beginBitmapFill()` - Begin bitmap fill / ビットマップ塗りを開始 + - `endFill()` - End current fill / 現在の塗りを終了 + +- **Drawing / 描画** + - `moveTo()` - Move drawing cursor / 描画カーソルを移動 + - `lineTo()` - Draw line / 直線を描画 + - `curveTo()` - Draw quadratic curve / 二次曲線を描画 + - `cubicCurveTo()` - Draw cubic curve / 三次曲線を描画 + - `drawRect()` - Draw rectangle / 矩形を描画 + - `drawRoundRect()` - Draw rounded rectangle / 角丸矩形を描画 + - `drawCircle()` - Draw circle / 円を描画 + - `drawEllipse()` - Draw ellipse / 楕円を描画 + - `clear()` - Clear all graphics / すべてのグラフィックスをクリア + +## MovieClip Frame Advance Logic / MovieClip フレーム進行ロジック + +MovieClip implements timeline-based animation with frame labels, actions, and sounds. + +MovieClip は、フレームラベル、アクション、サウンドを持つタイムラインベースのアニメーションを実装します。 + +```mermaid +flowchart TD + Start([Start Frame Advance]) --> CheckStop{Stop Flag?} + CheckStop -->|Yes| End([End - No Advance]) + CheckStop -->|No| CheckWait{Wait Flag?} + CheckWait -->|Yes| ClearWait[Clear Wait Flag] + CheckWait -->|No| AdvanceFrame[Increment Current Frame] + + ClearWait --> ExecuteActions + AdvanceFrame --> CheckLoop{Current Frame > Total Frames?} + + CheckLoop -->|Yes| LoopToStart[Current Frame = 1] + CheckLoop -->|No| ExecuteActions + + LoopToStart --> ExecuteActions + + ExecuteActions[Execute Frame Actions] --> PlaySounds[Play Frame Sounds] + PlaySounds --> UpdateChildren[Update Child Display Objects] + UpdateChildren --> CheckLabels{Has Frame Label?} + + CheckLabels -->|Yes| SetLabel[Set Current Label] + CheckLabels -->|No| CheckStop2 + + SetLabel --> CheckStop2{Check Stop in Actions?} + CheckStop2 -->|Stop Called| SetStopFlag[Set Stop Flag] + CheckStop2 -->|Continue| End + + SetStopFlag --> End + + style Start fill:#e1f5ff + style End fill:#e1f5ff + style AdvanceFrame fill:#ffe1e1 + style ExecuteActions fill:#fff4e1 + style PlaySounds fill:#e1ffe1 +``` + +### Frame Control Methods / フレーム制御メソッド + +- `play()` - Start timeline playback / タイムライン再生を開始 +- `stop()` - Stop timeline playback / タイムライン再生を停止 +- `gotoAndPlay(frame)` - Jump to frame and play / フレームにジャンプして再生 +- `gotoAndStop(frame)` - Jump to frame and stop / フレームにジャンプして停止 +- `nextFrame()` - Advance to next frame / 次のフレームへ進む +- `prevFrame()` - Go back to previous frame / 前のフレームへ戻る + +### Timeline Properties / タイムラインプロパティ + +- `currentFrame` - Current frame number (1-based) / 現在のフレーム番号(1始まり) +- `totalFrames` - Total number of frames / フレームの総数 +- `currentLabel` - Current frame label / 現在のフレームラベル +- `currentLabels` - Array of all frame labels / すべてのフレームラベルの配列 +- `isTimelineEnabled` - Whether timeline is enabled / タイムラインが有効かどうか + +## Loader / ローダー + +The Loader class handles loading JSON files exported from Next2D AnimationTool only. It does not support loading images or other media files directly. + +Loader クラスは、Next2D AnimationTool で書き出された JSON ファイルの読み込みのみに対応しています。画像やその他のメディアファイルの直接読み込みには対応していません。 + +**Important / 重要:** +- Only supports JSON files exported from Next2D AnimationTool / Next2D AnimationTool で書き出された JSON ファイルのみ対応 +- Does not support loading images (PNG, JPG, etc.) directly / 画像(PNG、JPGなど)の直接読み込みには非対応 +- Does not support loading videos directly / ビデオの直接読み込みには非対応 + +```typescript +import { Loader } from "@next2d/display"; +import { URLRequest } from "@next2d/net"; + +const loader = new Loader(); +loader.contentLoaderInfo.addEventListener("complete", (event) => { + // JSON content loaded successfully + // JSONコンテンツの読み込みが成功 + console.log("Loaded:", loader.content); +}); + +// Load Next2D AnimationTool exported JSON +// Next2D AnimationTool で書き出した JSON を読み込み +loader.load(new URLRequest("path/to/animation.json")); +``` + +## Architecture / アーキテクチャ + +The package follows a clean architecture pattern with separation of concerns: + +パッケージは、関心の分離を伴うクリーンアーキテクチャパターンに従っています: + +- **Main Classes** - Public API and core logic / パブリック API とコアロジック +- **Service Layer** - Reusable business logic / 再利用可能なビジネスロジック +- **Use Case Layer** - Specific feature implementations / 特定の機能実装 +- **Interface Layer** - TypeScript type definitions / TypeScript 型定義 + +This architecture ensures: +- Code reusability and maintainability / コードの再利用性と保守性 +- Clear separation between public API and implementation / パブリック API と実装の明確な分離 +- Testability of individual components / 個々のコンポーネントのテスト可能性 + +## Related Packages / 関連パッケージ + +- `@next2d/events` - Event system / イベントシステム +- `@next2d/geom` - Geometric primitives / 幾何プリミティブ +- `@next2d/filters` - Display filters / 表示フィルター +- `@next2d/text` - Text rendering / テキストレンダリング +- `@next2d/media` - Media playback / メディア再生 +- `@next2d/net` - Network communication / ネットワーク通信 +- `@next2d/ui` - User interface components / ユーザーインターフェースコンポーネント + +## License / ライセンス + This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [LICENSE](LICENSE) file for details. + +このプロジェクトは [MIT ライセンス](https://opensource.org/licenses/MIT)の下でライセンスされています - 詳細は [LICENSE](LICENSE) ファイルを参照してください。 + +--- + +Copyright (c) 2021 Next2D diff --git a/packages/display/src/BitmapData.ts b/packages/display/src/BitmapData.ts index 96cd6c65..072ec2be 100644 --- a/packages/display/src/BitmapData.ts +++ b/packages/display/src/BitmapData.ts @@ -37,7 +37,7 @@ export class BitmapData /** * @type {Uint8Array | null} - * @private + * @public */ public buffer: Uint8Array | null; @@ -49,25 +49,8 @@ export class BitmapData */ constructor (width: number = 0, height: number = 0) { - /** - * @type {number} - * @default 0 - * @private - */ - this.width = width | 0; - - /** - * @type {number} - * @default 0 - * @private - */ + this.width = width | 0; this.height = height | 0; - - /** - * @type {Uint8Array} - * @default null - * @private - */ this.buffer = null; } diff --git a/packages/display/src/DisplayObject.ts b/packages/display/src/DisplayObject.ts index 472e396c..12a9aea2 100644 --- a/packages/display/src/DisplayObject.ts +++ b/packages/display/src/DisplayObject.ts @@ -767,10 +767,11 @@ export class DisplayObject extends EventDispatcher } set visible (visible: boolean) { + visible = !!visible; if (this._$visible === visible) { return ; } - this._$visible = !!visible; + this._$visible = visible; displayObjectApplyChangesService(this); } diff --git a/packages/display/src/DisplayObject/service/DisplayObjectApplyChangesService.ts b/packages/display/src/DisplayObject/service/DisplayObjectApplyChangesService.ts index 3d1a493e..16431a2f 100644 --- a/packages/display/src/DisplayObject/service/DisplayObjectApplyChangesService.ts +++ b/packages/display/src/DisplayObject/service/DisplayObjectApplyChangesService.ts @@ -13,8 +13,9 @@ export const execute = (display_object: D): void => { display_object.changed = true; - const parent = display_object.parent as unknown as D; - if (parent && !parent.changed) { - execute(parent); + let parent = display_object.parent as D | null; + while (parent && !parent.changed) { + parent.changed = true; + parent = parent.parent as D | null; } }; \ No newline at end of file diff --git a/packages/display/src/DisplayObject/service/DisplayObjectGenerateHashService.test.ts b/packages/display/src/DisplayObject/service/DisplayObjectGenerateHashService.test.ts index 28d3f635..4b41c514 100644 --- a/packages/display/src/DisplayObject/service/DisplayObjectGenerateHashService.test.ts +++ b/packages/display/src/DisplayObject/service/DisplayObjectGenerateHashService.test.ts @@ -3,8 +3,134 @@ import { describe, expect, it } from "vitest"; describe("DisplayObjectGenerateHashService.js test", () => { - it("execute test", () => + it("should generate consistent hash for the same buffer", () => { - expect(execute(new Float32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]))).toBe(9749870); + const buffer = new Float32Array([1.0, 2.0, 3.0]); + const hash1 = execute(buffer); + const hash2 = execute(buffer); + expect(hash1).toBe(hash2); }); -}); \ No newline at end of file + + it("should generate different hashes for different buffers", () => + { + const buffer1 = new Float32Array([1.0, 2.0, 3.0]); + const buffer2 = new Float32Array([3.0, 2.0, 1.0]); + const hash1 = execute(buffer1); + const hash2 = execute(buffer2); + expect(hash1).not.toBe(hash2); + }); + + it("should generate hash for empty buffer", () => + { + const buffer = new Float32Array([]); + const hash = execute(buffer); + expect(typeof hash).toBe("number"); + expect(hash).toBeGreaterThanOrEqual(0); + expect(hash).toBeLessThanOrEqual(0xffffff); + }); + + it("should generate hash for single element buffer", () => + { + const buffer = new Float32Array([1.0]); + const hash = execute(buffer); + expect(typeof hash).toBe("number"); + expect(hash).toBeGreaterThanOrEqual(0); + expect(hash).toBeLessThanOrEqual(0xffffff); + }); + + it("should generate 24-bit hash value", () => + { + const buffer = new Float32Array([1.0, 2.0, 3.0, 4.0, 5.0]); + const hash = execute(buffer); + expect(hash).toBeGreaterThanOrEqual(0); + expect(hash).toBeLessThanOrEqual(0xffffff); + }); + + it("should be sensitive to floating point precision", () => + { + const buffer1 = new Float32Array([1.0000000]); + const buffer2 = new Float32Array([1.0000001]); + const hash1 = execute(buffer1); + const hash2 = execute(buffer2); + // Float32の精度の範囲内で異なる値は異なるハッシュを生成する可能性がある + expect(typeof hash1).toBe("number"); + expect(typeof hash2).toBe("number"); + }); + + it("should handle negative values", () => + { + const buffer = new Float32Array([-1.0, -2.0, -3.0]); + const hash = execute(buffer); + expect(typeof hash).toBe("number"); + expect(hash).toBeGreaterThanOrEqual(0); + expect(hash).toBeLessThanOrEqual(0xffffff); + }); + + it("should handle zero values", () => + { + const buffer = new Float32Array([0.0, 0.0, 0.0]); + const hash = execute(buffer); + expect(typeof hash).toBe("number"); + expect(hash).toBeGreaterThanOrEqual(0); + expect(hash).toBeLessThanOrEqual(0xffffff); + }); + + it("should handle special float values", () => + { + const buffer = new Float32Array([Infinity, -Infinity, NaN]); + const hash = execute(buffer); + expect(typeof hash).toBe("number"); + expect(hash).toBeGreaterThanOrEqual(0); + expect(hash).toBeLessThanOrEqual(0xffffff); + }); + + it("should handle large buffers", () => + { + const buffer = new Float32Array(1000); + for (let i = 0; i < 1000; i++) { + buffer[i] = Math.random(); + } + const hash = execute(buffer); + expect(typeof hash).toBe("number"); + expect(hash).toBeGreaterThanOrEqual(0); + expect(hash).toBeLessThanOrEqual(0xffffff); + }); + + it("should be deterministic for matrix-like data", () => + { + // 変換行列のような典型的なデータをテスト + const matrixBuffer = new Float32Array([ + 1.0, 0.0, 0.0, 1.0, 0.0, 0.0 // identity matrix + ]); + const hash1 = execute(matrixBuffer); + const hash2 = execute(matrixBuffer); + expect(hash1).toBe(hash2); + }); + + it("should differentiate similar buffers", () => + { + const buffer1 = new Float32Array([1.0, 2.0, 3.0, 4.0]); + const buffer2 = new Float32Array([1.0, 2.0, 3.0, 4.1]); + const hash1 = execute(buffer1); + const hash2 = execute(buffer2); + expect(hash1).not.toBe(hash2); + }); + + it("should handle very small values", () => + { + const buffer = new Float32Array([0.000001, 0.000002, 0.000003]); + const hash = execute(buffer); + expect(typeof hash).toBe("number"); + expect(hash).toBeGreaterThanOrEqual(0); + expect(hash).toBeLessThanOrEqual(0xffffff); + }); + + it("should handle very large values", () => + { + const buffer = new Float32Array([1e30, 2e30, 3e30]); + const hash = execute(buffer); + expect(typeof hash).toBe("number"); + expect(hash).toBeGreaterThanOrEqual(0); + expect(hash).toBeLessThanOrEqual(0xffffff); + }); +}); diff --git a/packages/display/src/DisplayObject/service/DisplayObjectGenerateHashService.ts b/packages/display/src/DisplayObject/service/DisplayObjectGenerateHashService.ts index 25238fb1..3e9c211b 100644 --- a/packages/display/src/DisplayObject/service/DisplayObjectGenerateHashService.ts +++ b/packages/display/src/DisplayObject/service/DisplayObjectGenerateHashService.ts @@ -9,20 +9,74 @@ */ export const execute = (buffer: Float32Array): number => { + // Float32ArrayのバッファをUint32Arrayとして直接参照(DataView不要) + const bits = new Uint32Array(buffer.buffer, buffer.byteOffset, buffer.length); + const len = bits.length; + let hash = 2166136261; // FNV-1aオフセット basis - for (let idx = 0; idx < buffer.length; ++idx) { + let idx = 0; + + // 8要素ずつ処理(ループアンローリング) + const unrolledEnd = len & ~7; // len - (len % 8) + while (idx < unrolledEnd) { + let b = bits[idx++]; + hash = Math.imul(hash ^ b & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 8 & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 16 & 0xff, 16777619); + hash = Math.imul(hash ^ b >>> 24, 16777619); + + b = bits[idx++]; + hash = Math.imul(hash ^ b & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 8 & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 16 & 0xff, 16777619); + hash = Math.imul(hash ^ b >>> 24, 16777619); + + b = bits[idx++]; + hash = Math.imul(hash ^ b & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 8 & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 16 & 0xff, 16777619); + hash = Math.imul(hash ^ b >>> 24, 16777619); + + b = bits[idx++]; + hash = Math.imul(hash ^ b & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 8 & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 16 & 0xff, 16777619); + hash = Math.imul(hash ^ b >>> 24, 16777619); - let num = buffer[idx] | 0; // 整数として扱う + b = bits[idx++]; + hash = Math.imul(hash ^ b & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 8 & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 16 & 0xff, 16777619); + hash = Math.imul(hash ^ b >>> 24, 16777619); + + b = bits[idx++]; + hash = Math.imul(hash ^ b & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 8 & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 16 & 0xff, 16777619); + hash = Math.imul(hash ^ b >>> 24, 16777619); + + b = bits[idx++]; + hash = Math.imul(hash ^ b & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 8 & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 16 & 0xff, 16777619); + hash = Math.imul(hash ^ b >>> 24, 16777619); + + b = bits[idx++]; + hash = Math.imul(hash ^ b & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 8 & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 16 & 0xff, 16777619); + hash = Math.imul(hash ^ b >>> 24, 16777619); + } - // 32bit整数の各バイトを処理 - for (let i = 0; i < 4; i++) { - const byte = num & 0xff; - hash ^= byte; - hash = Math.imul(hash, 16777619); // FNV-1a の FNV prime - num >>>= 8; - } + // 残り (0〜7要素) + while (idx < len) { + const b = bits[idx++]; + hash = Math.imul(hash ^ b & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 8 & 0xff, 16777619); + hash = Math.imul(hash ^ b >> 16 & 0xff, 16777619); + hash = Math.imul(hash ^ b >>> 24, 16777619); } - // 32bitの符号なし整数にキャストし、24bitの範囲に収める - return (hash >>> 0) % 16777216; + // 32bitハッシュ値を24bitに圧縮 + return hash >>> 8 ^ hash & 0xff & 0xffffff; }; \ No newline at end of file diff --git a/packages/display/src/DisplayObjectContainer/usecase/DisplayObjectContainerGenerateRenderQueueUseCase.test.ts b/packages/display/src/DisplayObjectContainer/usecase/DisplayObjectContainerGenerateRenderQueueUseCase.test.ts index 5d0d1e3e..cdaa8ed7 100644 --- a/packages/display/src/DisplayObjectContainer/usecase/DisplayObjectContainerGenerateRenderQueueUseCase.test.ts +++ b/packages/display/src/DisplayObjectContainer/usecase/DisplayObjectContainerGenerateRenderQueueUseCase.test.ts @@ -21,17 +21,19 @@ describe("DisplayObjectContainerGenerateRenderQueueUseCase.js test", () => execute( movieClip, [], matrix, colorTransform, - 0, 0, 0, 0 + 0, 0 ); expect(renderQueue.buffer[0]).toBe(1); expect(renderQueue.buffer[1]).toBe($RENDERER_CONTAINER_TYPE); - expect(renderQueue.buffer[2]).toBe(0); - expect(renderQueue.buffer[3]).toBe(movieClip.children.length); - expect(renderQueue.buffer[4]).toBe(-1); - expect(renderQueue.buffer[5]).toBe(0); - expect(renderQueue.buffer[6]).toBe(0); - expect(renderQueue.offset).toBe(7); + expect(renderQueue.buffer[2]).toBe(11); + expect(renderQueue.buffer[3]).toBe(0); // normal blendMode + expect(renderQueue.buffer[4]).toBe(0); + expect(renderQueue.buffer[5]).toBe(movieClip.children.length); + expect(renderQueue.buffer[6]).toBe(-1); + expect(renderQueue.buffer[7]).toBe(0); + expect(renderQueue.buffer[8]).toBe(0); + expect(renderQueue.offset).toBe(9); renderQueue.buffer.fill(0); renderQueue.offset = 0; diff --git a/packages/display/src/DisplayObjectContainer/usecase/DisplayObjectContainerGenerateRenderQueueUseCase.ts b/packages/display/src/DisplayObjectContainer/usecase/DisplayObjectContainerGenerateRenderQueueUseCase.ts index 3f384ad2..8d536d70 100644 --- a/packages/display/src/DisplayObjectContainer/usecase/DisplayObjectContainerGenerateRenderQueueUseCase.ts +++ b/packages/display/src/DisplayObjectContainer/usecase/DisplayObjectContainerGenerateRenderQueueUseCase.ts @@ -11,10 +11,18 @@ import { execute as textFieldGenerateRenderQueueUseCase } from "../../TextField/ import { execute as videoGenerateRenderQueueUseCase } from "../../Video/usecase/VideoGenerateRenderQueueUseCase"; import { execute as displayObjectIsMaskReflectedInDisplayUseCase } from "../../DisplayObject/usecase/DisplayObjectIsMaskReflectedInDisplayUseCase"; import { execute as displayObjectContainerGenerateClipQueueUseCase } from "../../DisplayObjectContainer/usecase/DisplayObjectContainerGenerateClipQueueUseCase"; +import { execute as displayObjectBlendToNumberService } from "../../DisplayObject/service/DisplayObjectBlendToNumberService"; +import { execute as displayObjectContainerGetLayerBoundsUseCase } from "./DisplayObjectContainerGetLayerBoundsUseCase"; +import { execute as displayObjectContainerCalcBoundsMatrixUseCase } from "../../DisplayObjectContainer/usecase/DisplayObjectContainerCalcBoundsMatrixUseCase"; import { renderQueue } from "@next2d/render-queue"; +import { $cacheStore } from "@next2d/cache"; import { $clamp, - $RENDERER_CONTAINER_TYPE + $getBoundsArray, + $poolBoundsArray, + $RENDERER_CONTAINER_TYPE, + $getFloat32Array8, + $getFloat32Array6 } from "../../DisplayObjectUtil"; import { ColorTransform, @@ -51,7 +59,11 @@ export const execute =

( // transformed ColorTransform(tColorTransform) const rawColor = displayObjectGetRawColorTransformUseCase(display_object_container); - const tColorTransform = rawColor + let tColorTransform = rawColor + && (rawColor[0] !== 1 || rawColor[1] !== 1 + || rawColor[2] !== 1 || rawColor[3] !== 1 + || rawColor[4] !== 0 || rawColor[5] !== 0 + || rawColor[6] !== 0 || rawColor[7] !== 0) ? ColorTransform.multiply(color_transform, rawColor) : color_transform; @@ -75,7 +87,10 @@ export const execute =

( // transformed matrix(tMatrix) const rawMatrix = displayObjectGetRawMatrixUseCase(display_object_container); - const tMatrix = rawMatrix + let tMatrix = rawMatrix + && (rawMatrix[0] !== 1 || rawMatrix[1] !== 0 + || rawMatrix[2] !== 0 || rawMatrix[3] !== 1 + || rawMatrix[4] !== 0 || rawMatrix[5] !== 0) ? Matrix.multiply(matrix, rawMatrix) : matrix; @@ -95,6 +110,213 @@ export const execute =

( renderQueue.push(1, $RENDERER_CONTAINER_TYPE); + // blendMode + const blendMode = display_object_container.blendMode; + renderQueue.push(displayObjectBlendToNumberService(blendMode)); + + // filters + const filters = display_object_container.filters; + if (filters) { + const filterKey = $cacheStore.generateFilterKeys( + tMatrix[0], tMatrix[1], tMatrix[2], tMatrix[3] + ); + const filterCache = $cacheStore.get( + `${display_object_container.instanceId}`, + `${filterKey}` + ); + + let updated = false; + const params = []; + const bounds = $getBoundsArray(0, 0, 0, 0); + for (let idx = 0; idx < filters.length; idx++) { + + const filter = filters[idx]; + if (!filter || !filter.canApplyFilter()) { + continue; + } + + // フィルターが更新されたかをチェック + if (filter.$updated) { + updated = true; + } + filter.$updated = false; + + filter.getBounds(bounds); + + const buffer = filter.toNumberArray(); + + for (let idx = 0; idx < buffer.length; idx += 4096) { + params.push(...buffer.subarray(idx, idx + 4096)); + } + } + + const useFilfer = params.length > 0; + if (useFilfer) { + + // 子の変更があった場合は親のフラグが立っているので更新 + if (!updated) { + updated = display_object_container.changed; + } + + const layerBounds = displayObjectContainerGetLayerBoundsUseCase( + display_object_container, matrix + ); + + if (filterCache) { + + // キャッシュがあって、変更がなければキャッシュを使用 + if (!updated) { + renderQueue.push(1, + Math.ceil(Math.abs(layerBounds[2] - layerBounds[0])), + Math.ceil(Math.abs(layerBounds[3] - layerBounds[1])), + 1, 1, display_object_container.instanceId, filterKey, + bounds[0], bounds[1], bounds[2], bounds[3], + tMatrix[0], tMatrix[1], tMatrix[2], tMatrix[3], layerBounds[0], layerBounds[1], + tColorTransform[0], tColorTransform[1], tColorTransform[2], tColorTransform[3], + tColorTransform[4], tColorTransform[5], tColorTransform[6], tColorTransform[7] + ); + + $poolBoundsArray(layerBounds); + $poolBoundsArray(bounds); + + if (tColorTransform !== color_transform) { + ColorTransform.release(tColorTransform); + } + if (tMatrix !== matrix) { + Matrix.release(tMatrix); + } + return ; + } + + // どこかで変更があったので、キャッシュを削除 + $cacheStore.removeById(`${display_object_container.instanceId}`); + } + + renderQueue.push( + 1, + Math.ceil(Math.abs(layerBounds[2] - layerBounds[0])), + Math.ceil(Math.abs(layerBounds[3] - layerBounds[1])), + 1, 0, display_object_container.instanceId, filterKey, + bounds[0], bounds[1], bounds[2], bounds[3], + tMatrix[0], tMatrix[1], tMatrix[2], tMatrix[3], layerBounds[0], layerBounds[1], + tColorTransform[0], tColorTransform[1], tColorTransform[2], tColorTransform[3], + tColorTransform[4], tColorTransform[5], tColorTransform[6], tColorTransform[7], + params.length + ); + renderQueue.set(new Float32Array(params)); + + const fa0 = tMatrix[0]; + const fa1 = tMatrix[1]; + const fa2 = tMatrix[2]; + const fa3 = tMatrix[3]; + const faTx = tMatrix[4] - layerBounds[0]; + const faTy = tMatrix[5] - layerBounds[1]; + + if (tMatrix !== matrix) { + Matrix.release(tMatrix); + } + tMatrix = $getFloat32Array6(fa0, fa1, fa2, fa3, faTx, faTy); + + if (tColorTransform !== color_transform) { + ColorTransform.release(tColorTransform); + } + + tColorTransform = $getFloat32Array8(1, 1, 1, 1, 0, 0, 0, 0); + + $poolBoundsArray(layerBounds); + + $cacheStore.set( + `${display_object_container.instanceId}`, + `${filterKey}`, true + ); + + } else { + if (blendMode === "normal") { + renderQueue.push(0); + } else { + + // ブレンドモードのみのLayerモード + const layerBounds = displayObjectContainerCalcBoundsMatrixUseCase( + display_object_container, + matrix + ); + + const layerXMin = layerBounds[0]; + const layerYMin = layerBounds[1]; + + renderQueue.push( + 1, + Math.ceil(Math.abs(layerBounds[2] - layerXMin)), + Math.ceil(Math.abs(layerBounds[3] - layerYMin)), + 0, // not use filter, + tMatrix[0], tMatrix[1], tMatrix[2], tMatrix[3], layerXMin, layerYMin, + tColorTransform[0], tColorTransform[1], tColorTransform[2], tColorTransform[3], + tColorTransform[4], tColorTransform[5], tColorTransform[6], tColorTransform[7] + ); + + const a0 = tMatrix[0]; + const a1 = tMatrix[1]; + const a2 = tMatrix[2]; + const a3 = tMatrix[3]; + const adjustedTx1 = tMatrix[4] - layerXMin; + const adjustedTy1 = tMatrix[5] - layerYMin; + + if (tMatrix !== matrix) { + Matrix.release(tMatrix); + } + tMatrix = $getFloat32Array6(a0, a1, a2, a3, adjustedTx1, adjustedTy1); + $poolBoundsArray(layerBounds); + + if (tColorTransform !== color_transform) { + ColorTransform.release(tColorTransform); + } + tColorTransform = $getFloat32Array8(1, 1, 1, 1, 0, 0, 0, 0); + } + } + + $poolBoundsArray(bounds); + } else { + if (blendMode === "normal") { + renderQueue.push(0); + } else { + const layerBounds = displayObjectContainerCalcBoundsMatrixUseCase( + display_object_container, + matrix + ); + + const layerXMin2 = layerBounds[0]; + const layerYMin2 = layerBounds[1]; + + renderQueue.push( + 1, + Math.ceil(Math.abs(layerBounds[2] - layerXMin2)), + Math.ceil(Math.abs(layerBounds[3] - layerYMin2)), + 0, // not use filter, + tMatrix[0], tMatrix[1], tMatrix[2], tMatrix[3], layerXMin2, layerYMin2, + tColorTransform[0], tColorTransform[1], tColorTransform[2], tColorTransform[3], + tColorTransform[4], tColorTransform[5], tColorTransform[6], tColorTransform[7] + ); + + const b0 = tMatrix[0]; + const b1 = tMatrix[1]; + const b2 = tMatrix[2]; + const b3 = tMatrix[3]; + const adjustedTx2 = tMatrix[4] - layerXMin2; + const adjustedTy2 = tMatrix[5] - layerYMin2; + + if (tMatrix !== matrix) { + Matrix.release(tMatrix); + } + tMatrix = $getFloat32Array6(b0, b1, b2, b3, adjustedTx2, adjustedTy2); + $poolBoundsArray(layerBounds); + + if (tColorTransform !== color_transform) { + ColorTransform.release(tColorTransform); + } + tColorTransform = $getFloat32Array8(1, 1, 1, 1, 0, 0, 0, 0); + } + } + // mask const maskDisplayObject = display_object_container.mask; if (maskDisplayObject) { @@ -139,6 +361,7 @@ export const execute =

( renderQueue.push(0); } + // children renderQueue.push(children.length); let clipDepth = 0; @@ -146,17 +369,23 @@ export const execute =

( for (let idx = 0; idx < children.length; ++idx) { const child = children[idx] as DisplayObject; + + renderQueue.push(child.placeId, child.clipDepth); + + // マスクオブジェクトは描画しない(hidden=0) if (child.isMask) { + renderQueue.push(0); + child.changed = false; continue; } - renderQueue.push(child.placeId, child.clipDepth); if (clipDepth && child.placeId > clipDepth) { clipDepth = 0; canRenderMask = true; } if (!canRenderMask) { + renderQueue.push(0); child.changed = false; continue; } diff --git a/packages/display/src/DisplayObjectContainer/usecase/DisplayObjectContainerGetLayerBoundsUseCase.test.ts b/packages/display/src/DisplayObjectContainer/usecase/DisplayObjectContainerGetLayerBoundsUseCase.test.ts new file mode 100644 index 00000000..0b033281 --- /dev/null +++ b/packages/display/src/DisplayObjectContainer/usecase/DisplayObjectContainerGetLayerBoundsUseCase.test.ts @@ -0,0 +1,165 @@ +import { execute } from "./DisplayObjectContainerGetLayerBoundsUseCase"; +import { Shape } from "../../Shape"; +import { DisplayObjectContainer } from "../../DisplayObjectContainer"; +import { TextField } from "@next2d/text"; +import { Video } from "@next2d/media"; +import { describe, expect, it } from "vitest"; +import { BlurFilter, GlowFilter } from "@next2d/filters"; + +describe("DisplayObjectContainerGetLayerBoundsUseCase.ts test", () => +{ + it("子要素がない場合は[0,0,0,0]を返す", () => + { + const container = new DisplayObjectContainer(); + const bounds = execute(container); + + expect(bounds[0]).toBe(0); + expect(bounds[1]).toBe(0); + expect(bounds[2]).toBe(0); + expect(bounds[3]).toBe(0); + }); + + it("フィルターなしの子要素のboundsを統合する", () => + { + const container = new DisplayObjectContainer(); + + const shape = new Shape(); + shape.graphics.xMin = 10; + shape.graphics.yMin = 20; + shape.graphics.xMax = 50; + shape.graphics.yMax = 60; + container.addChild(shape); + + const textField = new TextField(); + container.addChild(textField); + + const bounds = execute(container); + // Shape: [10, 20, 50, 60] + // TextField: [0, 0, 100, 100] + expect(bounds[0]).toBe(0); // min(10, 0) + expect(bounds[1]).toBe(0); // min(20, 0) + expect(bounds[2]).toBe(100); // max(50, 100) + expect(bounds[3]).toBe(100); // max(60, 100) + }); + + it("BlurFilterを持つ子要素のboundsがフィルター分拡張される", () => + { + const container = new DisplayObjectContainer(); + + const shape = new Shape(); + shape.graphics.xMin = 10; + shape.graphics.yMin = 20; + shape.graphics.xMax = 50; + shape.graphics.yMax = 60; + // BlurFilter(blurX=4, blurY=4, quality=1) → dx=2, dy=2 + shape.$filters = [new BlurFilter(4, 4, 1)]; + container.addChild(shape); + + const bounds = execute(container); + // Shape layer bounds: [10-2, 20-2, 50+2, 60+2] = [8, 18, 52, 62] + expect(bounds[0]).toBe(8); + expect(bounds[1]).toBe(18); + expect(bounds[2]).toBe(52); + expect(bounds[3]).toBe(62); + }); + + it("フィルターありとなしの子要素が混在する場合のbounds統合", () => + { + const container = new DisplayObjectContainer(); + + // フィルターあり + const shape1 = new Shape(); + shape1.graphics.xMin = 10; + shape1.graphics.yMin = 20; + shape1.graphics.xMax = 50; + shape1.graphics.yMax = 60; + shape1.$filters = [new BlurFilter(4, 4, 1)]; + container.addChild(shape1); + + // フィルターなし + const shape2 = new Shape(); + shape2.graphics.xMin = 100; + shape2.graphics.yMin = 100; + shape2.graphics.xMax = 200; + shape2.graphics.yMax = 200; + container.addChild(shape2); + + const bounds = execute(container); + // shape1 layer bounds: [8, 18, 52, 62] + // shape2 layer bounds: [100, 100, 200, 200] + expect(bounds[0]).toBe(8); // min(8, 100) + expect(bounds[1]).toBe(18); // min(18, 100) + expect(bounds[2]).toBe(200); // max(52, 200) + expect(bounds[3]).toBe(200); // max(62, 200) + }); + + it("GlowFilter(inner=true)の子要素はboundsが拡張されない", () => + { + const container = new DisplayObjectContainer(); + + const shape = new Shape(); + shape.graphics.xMin = 10; + shape.graphics.yMin = 20; + shape.graphics.xMax = 50; + shape.graphics.yMax = 60; + shape.$filters = [new GlowFilter(0, 1, 4, 4, 1, 1, true)]; + container.addChild(shape); + + const bounds = execute(container); + expect(bounds[0]).toBe(10); + expect(bounds[1]).toBe(20); + expect(bounds[2]).toBe(50); + expect(bounds[3]).toBe(60); + }); + + it("複数タイプの子要素(Shape, TextField, Video)のフィルターboundsを統合", () => + { + const container = new DisplayObjectContainer(); + + const shape = new Shape(); + shape.graphics.xMin = -10; + shape.graphics.yMin = -10; + shape.graphics.xMax = 10; + shape.graphics.yMax = 10; + shape.$filters = [new BlurFilter(4, 4, 1)]; + container.addChild(shape); + + const textField = new TextField(); + textField.$filters = [new BlurFilter(4, 4, 1)]; + container.addChild(textField); + + const video = new Video(50, 50); + container.addChild(video); + + const bounds = execute(container); + // Shape layer: [-10-2, -10-2, 10+2, 10+2] = [-12, -12, 12, 12] + // TextField layer: [0-2, 0-2, 100+2, 100+2] = [-2, -2, 102, 102] + // Video layer (no filter): [0, 0, 50, 50] + expect(bounds[0]).toBe(-12); // min(-12, -2, 0) + expect(bounds[1]).toBe(-12); // min(-12, -2, 0) + expect(bounds[2]).toBe(102); // max(12, 102, 50) + expect(bounds[3]).toBe(102); // max(12, 102, 50) + }); + + it("ネストされたコンテナの子要素のフィルターboundsを再帰的に統合", () => + { + const parent = new DisplayObjectContainer(); + const child = new DisplayObjectContainer(); + parent.addChild(child); + + const shape = new Shape(); + shape.graphics.xMin = 0; + shape.graphics.yMin = 0; + shape.graphics.xMax = 100; + shape.graphics.yMax = 100; + shape.$filters = [new BlurFilter(4, 4, 1)]; + child.addChild(shape); + + const bounds = execute(parent); + // Shape layer: [0-2, 0-2, 100+2, 100+2] = [-2, -2, 102, 102] + expect(bounds[0]).toBe(-2); + expect(bounds[1]).toBe(-2); + expect(bounds[2]).toBe(102); + expect(bounds[3]).toBe(102); + }); +}); diff --git a/packages/display/src/DisplayObjectContainer/usecase/DisplayObjectContainerGetLayerBoundsUseCase.ts b/packages/display/src/DisplayObjectContainer/usecase/DisplayObjectContainerGetLayerBoundsUseCase.ts new file mode 100644 index 00000000..07e5ea69 --- /dev/null +++ b/packages/display/src/DisplayObjectContainer/usecase/DisplayObjectContainerGetLayerBoundsUseCase.ts @@ -0,0 +1,92 @@ +import type { Shape } from "../../Shape"; +import type { Video } from "@next2d/media"; +import type { TextField } from "@next2d/text"; +import type { DisplayObject } from "../../DisplayObject"; +import type { DisplayObjectContainer } from "../../DisplayObjectContainer"; +import { Matrix } from "@next2d/geom"; +import { execute as displayObjectGetRawMatrixUseCase } from "../../DisplayObject/usecase/DisplayObjectGetRawMatrixUseCase"; +import { execute as shapeCalcLayerBoundsUseCase } from "../../Shape/usecase/ShapeCalcLayerBoundsUseCase"; +import { execute as videoCalcLayerBoundsUseCase } from "../../Video/usecase/VideoCalcLayerBoundsUseCase"; +import { execute as textFieldCalcLayerBoundsUseCase } from "../../TextField/usecase/TextFieldCalcLayerBoundsUseCase"; +import { + $getBoundsArray, + $poolBoundsArray +} from "../../DisplayObjectUtil"; + +/** + * @description DisplayObjectContainerのレイヤー境界を取得します。 + * 子孫のフィルター適用後のboundsを考慮した境界を返却します。 + * Get the layer bounds of DisplayObjectContainer. + * Returns bounds considering filter-expanded bounds of descendants. + * + * @param {DisplayObjectContainer} display_object_container + * @param {Float32Array | null} matrix + * @return {Float32Array} + * @method + * @public + */ +export const execute =

( + display_object_container: P, + matrix: Float32Array | null = null +): Float32Array => { + + const children = display_object_container.children; + if (!children.length) { + return $getBoundsArray(0, 0, 0, 0); + } + + const rawMatrix = displayObjectGetRawMatrixUseCase(display_object_container); + const tMatrix = rawMatrix + ? matrix + ? Matrix.multiply(matrix, rawMatrix) + : rawMatrix + : matrix; + + const no = Number.MAX_VALUE; + let xMin = no; + let xMax = -no; + let yMin = no; + let yMax = -no; + + for (let idx = 0; idx < children.length; idx++) { + + const child = children[idx] as DisplayObject; + if (!child || !child.visible) { + continue; + } + + let bounds: Float32Array | null = null; + switch (true) { + + case child.isContainerEnabled: + bounds = execute(child as DisplayObjectContainer, tMatrix); + break; + + case child.isShape: + bounds = shapeCalcLayerBoundsUseCase(child as Shape, tMatrix); + break; + + case child.isText: + bounds = textFieldCalcLayerBoundsUseCase(child as TextField, tMatrix); + break; + + case child.isVideo: + bounds = videoCalcLayerBoundsUseCase(child as Video, tMatrix); + break; + + } + + if (!bounds) { + continue; + } + + xMin = Math.min(xMin, bounds[0]); + yMin = Math.min(yMin, bounds[1]); + xMax = Math.max(xMax, bounds[2]); + yMax = Math.max(yMax, bounds[3]); + + $poolBoundsArray(bounds); + } + + return $getBoundsArray(xMin, yMin, xMax, yMax); +}; diff --git a/packages/display/src/Graphics/service/GraphicsToNumberArrayService.test.ts b/packages/display/src/Graphics/service/GraphicsToNumberArrayService.test.ts index c089af4e..f1c64580 100644 --- a/packages/display/src/Graphics/service/GraphicsToNumberArrayService.test.ts +++ b/packages/display/src/Graphics/service/GraphicsToNumberArrayService.test.ts @@ -4,6 +4,68 @@ import { Graphics } from "../../Graphics"; describe("GraphicsToNumberArrayService.js test", () => { + it("execute test case - invalid lineCap/lineJoin should use default values", () => + { + // STROKE_STYLE with invalid lineCap and lineJoin values + const recodes = [ + Graphics.STROKE_STYLE, + 100, "invalid_cap", "invalid_join", 1, 2, 3, 4, 5, + ]; + const array = execute(recodes); + + expect(array[0]).toBe(Graphics.STROKE_STYLE); + expect(array[1]).toBe(100); + expect(array[2]).toBe(0); // default lineCap = "none" = 0 + expect(array[3]).toBe(2); // default lineJoin = "round" = 2 + }); + + it("execute test case - GRADIENT_STROKE with invalid lineCap/lineJoin", () => + { + const object = { + "ratio": 0, + "R": 255, + "G": 100, + "B": 0, + "A": 1 + }; + const recodes = [ + Graphics.GRADIENT_STROKE, + 10, "invalid_cap", "invalid_join", 5, + "linear", [object], new Float32Array([1, 2, 3, 4, 5, 6]), + "pad", "rgb", 0 + ]; + const array = execute(recodes); + + expect(array[0]).toBe(Graphics.GRADIENT_STROKE); + expect(array[1]).toBe(10); // thickness + expect(array[2]).toBe(0); // default lineCap = "none" = 0 + expect(array[3]).toBe(2); // default lineJoin = "round" = 2 + expect(array[4]).toBe(5); // miterLimit + }); + + it("execute test case - BITMAP_STROKE with invalid lineCap/lineJoin", () => + { + // BitmapDataをモックするため、bufferがnullのケースをテスト + const mockBitmapData = { + width: 10, + height: 10, + buffer: null + }; + const recodes = [ + Graphics.BITMAP_STROKE, + 8, "invalid_cap", "invalid_join", 3, + mockBitmapData, null, true, true + ]; + const array = execute(recodes); + + expect(array[0]).toBe(Graphics.BITMAP_STROKE); + expect(array[1]).toBe(8); // thickness + expect(array[2]).toBe(0); // default lineCap = "none" = 0 + expect(array[3]).toBe(2); // default lineJoin = "round" = 2 + expect(array[4]).toBe(3); // miterLimit + // buffer is null, so processing stops after miterLimit + }); + it("execute test case1", () => { const recodes = [ diff --git a/packages/display/src/Graphics/service/GraphicsToNumberArrayService.ts b/packages/display/src/Graphics/service/GraphicsToNumberArrayService.ts index 75f8b322..2dd35dda 100644 --- a/packages/display/src/Graphics/service/GraphicsToNumberArrayService.ts +++ b/packages/display/src/Graphics/service/GraphicsToNumberArrayService.ts @@ -78,6 +78,11 @@ export const execute = (recodes : any[] | null): any[] => array.push(2); break; + default: + // 無効な値の場合はデフォルトで"none"(0)を使用 + array.push(0); + break; + } const lineJoin = recodes[idx++]; @@ -95,6 +100,11 @@ export const execute = (recodes : any[] | null): any[] => array.push(2); break; + default: + // 無効な値の場合はデフォルトで"round"(2)を使用 + array.push(2); + break; + } array.push( @@ -181,6 +191,11 @@ export const execute = (recodes : any[] | null): any[] => array.push(2); break; + default: + // 無効な値の場合はデフォルトで"none"(0)を使用 + array.push(0); + break; + } const lineJoin: IJointStyle = recodes[idx++]; @@ -198,6 +213,11 @@ export const execute = (recodes : any[] | null): any[] => array.push(2); break; + default: + // 無効な値の場合はデフォルトで"round"(2)を使用 + array.push(2); + break; + } // miterLimit @@ -304,6 +324,11 @@ export const execute = (recodes : any[] | null): any[] => array.push(2); break; + default: + // 無効な値の場合はデフォルトで"none"(0)を使用 + array.push(0); + break; + } const lineJoin: IJointStyle = recodes[idx++]; @@ -321,6 +346,11 @@ export const execute = (recodes : any[] | null): any[] => array.push(2); break; + default: + // 無効な値の場合はデフォルトで"round"(2)を使用 + array.push(2); + break; + } // MITER LIMIT @@ -343,9 +373,9 @@ export const execute = (recodes : any[] | null): any[] => array.push(...buffer.subarray(idx, idx + 4096)); } - const matrix: Float32Array = recodes[idx++]; + const matrix: Matrix = recodes[idx++]; if (matrix) { - array.push(...matrix); + array.push(...matrix.rawData); } else { array.push(1, 0, 0, 1, 0, 0); } diff --git a/packages/display/src/Shape.ts b/packages/display/src/Shape.ts index 461e8497..f014e5b0 100644 --- a/packages/display/src/Shape.ts +++ b/packages/display/src/Shape.ts @@ -8,6 +8,7 @@ import { execute as shapeClearBitmapBufferService } from "./Shape/usecase/ShapeC import { execute as shapeSetBitmapBufferUseCase } from "./Shape/usecase/ShapeSetBitmapBufferUseCase"; import { execute as shapeLoadSrcUseCase } from "./Shape/usecase/ShapeLoadSrcUseCase"; import { execute as shapeBuildFromCharacterUseCase } from "./Shape/usecase/ShapeBuildFromCharacterUseCase"; +import { execute as shapeLoadAsyncUseCase } from "./Shape/usecase/ShapeLoadAsyncUseCase"; import { $graphicMap, $getArray @@ -173,6 +174,20 @@ export class Shape extends DisplayObject shapeLoadSrcUseCase(this, src); } + /** + * @description 指定されたURLから画像を非同期で読み込み、Graphicsを生成します + * Asynchronously loads images from the specified URL and generates Graphics. + * + * @param {string} url + * @return {Promise} + * @method + * @public + */ + load (url: string): Promise + { + return shapeLoadAsyncUseCase(this, url); + } + /** * @description ビットマップデータを返します * Returns the bitmap data. diff --git a/packages/display/src/Shape/usecase/ShapeCalcLayerBoundsUseCase.test.ts b/packages/display/src/Shape/usecase/ShapeCalcLayerBoundsUseCase.test.ts new file mode 100644 index 00000000..a311aa47 --- /dev/null +++ b/packages/display/src/Shape/usecase/ShapeCalcLayerBoundsUseCase.test.ts @@ -0,0 +1,146 @@ +import { execute } from "./ShapeCalcLayerBoundsUseCase"; +import { Shape } from "../../Shape"; +import { describe, expect, it } from "vitest"; +import { Matrix } from "@next2d/geom"; +import { BlurFilter, GlowFilter, DropShadowFilter } from "@next2d/filters"; + +describe("ShapeCalcLayerBoundsUseCase.ts test", () => +{ + it("フィルターなしの場合はCalcBoundsMatrixと同じ結果を返す", () => + { + const shape = new Shape(); + shape.graphics.xMin = 1; + shape.graphics.yMin = 2; + shape.graphics.xMax = 3; + shape.graphics.yMax = 4; + + const bounds = execute(shape); + expect(bounds[0]).toBe(1); + expect(bounds[1]).toBe(2); + expect(bounds[2]).toBe(3); + expect(bounds[3]).toBe(4); + }); + + it("BlurFilter適用後のboundsが拡張される", () => + { + const shape = new Shape(); + shape.graphics.xMin = 1; + shape.graphics.yMin = 2; + shape.graphics.xMax = 3; + shape.graphics.yMax = 4; + + // BlurFilter(blurX=4, blurY=4, quality=1) + // step = $STEP[0] = 0.5 + // dx = Math.round(4 * 0.5) = 2, dy = 2 + // filterBounds: [-2, -2, 2, 2] + shape.$filters = [new BlurFilter(4, 4, 1)]; + + const bounds = execute(shape); + expect(bounds[0]).toBe(-1); // 1 + (-2) + expect(bounds[1]).toBe(0); // 2 + (-2) + expect(bounds[2]).toBe(5); // 3 + 2 + expect(bounds[3]).toBe(6); // 4 + 2 + }); + + it("BlurFilter + matrix適用後のboundsが正しく拡張される", () => + { + const shape = new Shape(); + shape.$matrix = new Matrix(1.3, 0.5, 0.2, 1.2, 110, 220); + shape.graphics.xMin = 1; + shape.graphics.yMin = 2; + shape.graphics.xMax = 3; + shape.graphics.yMax = 4; + + shape.$filters = [new BlurFilter(4, 4, 1)]; + + const bounds = execute(shape); + // CalcBoundsMatrix結果: [111.7, 222.9, 114.7, 226.3] + // filterBounds: [-2, -2, 2, 2] + expect(bounds[0]).toBeCloseTo(109.7, 0); + expect(bounds[1]).toBeCloseTo(220.9, 0); + expect(bounds[2]).toBeCloseTo(116.7, 0); + expect(bounds[3]).toBeCloseTo(228.3, 0); + }); + + it("GlowFilter(inner=true)の場合はboundsが拡張されない", () => + { + const shape = new Shape(); + shape.graphics.xMin = 1; + shape.graphics.yMin = 2; + shape.graphics.xMax = 3; + shape.graphics.yMax = 4; + + // inner=trueの場合はboundsを拡張しない + shape.$filters = [new GlowFilter(0, 1, 4, 4, 1, 1, true)]; + + const bounds = execute(shape); + expect(bounds[0]).toBe(1); + expect(bounds[1]).toBe(2); + expect(bounds[2]).toBe(3); + expect(bounds[3]).toBe(4); + }); + + it("DropShadowFilter(inner=true)の場合はboundsが拡張されない", () => + { + const shape = new Shape(); + shape.graphics.xMin = 1; + shape.graphics.yMin = 2; + shape.graphics.xMax = 3; + shape.graphics.yMax = 4; + + // inner=trueの場合はboundsを拡張しない + shape.$filters = [new DropShadowFilter(4, 45, 0, 1, 4, 4, 1, 1, true)]; + + const bounds = execute(shape); + expect(bounds[0]).toBe(1); + expect(bounds[1]).toBe(2); + expect(bounds[2]).toBe(3); + expect(bounds[3]).toBe(4); + }); + + it("複数フィルター適用時にパディングが累積される", () => + { + const shape = new Shape(); + shape.graphics.xMin = 10; + shape.graphics.yMin = 20; + shape.graphics.xMax = 30; + shape.graphics.yMax = 40; + + // 2つのBlurFilterを適用 + // 各々 dx=2, dy=2 → 累積 filterBounds: [-4, -4, 4, 4] + shape.$filters = [ + new BlurFilter(4, 4, 1), + new BlurFilter(4, 4, 1) + ]; + + const bounds = execute(shape); + expect(bounds[0]).toBe(6); // 10 + (-4) + expect(bounds[1]).toBe(16); // 20 + (-4) + expect(bounds[2]).toBe(34); // 30 + 4 + expect(bounds[3]).toBe(44); // 40 + 4 + }); + + it("DropShadowFilter適用後のboundsが影の方向に拡張される", () => + { + const shape = new Shape(); + shape.graphics.xMin = 0; + shape.graphics.yMin = 0; + shape.graphics.xMax = 100; + shape.graphics.yMax = 100; + + // DropShadowFilter(distance=4, angle=45, blurX=4, blurY=4, quality=1) + // blur: dx=2, dy=2 → [-2, -2, 2, 2] + // shadow: x = cos(45°)*4 ≈ 2.828, y = sin(45°)*4 ≈ 2.828 + // bounds[0] = Math.min(-2, 2.828) = -2 + // bounds[2] += 2.828 → 2 + 2.828 ≈ 4.828 + // bounds[1] = Math.min(-2, 2.828) = -2 + // bounds[3] += 2.828 → 2 + 2.828 ≈ 4.828 + shape.$filters = [new DropShadowFilter(4, 45, 0, 1, 4, 4, 1, 1)]; + + const bounds = execute(shape); + expect(bounds[0]).toBeCloseTo(-2, 0); // 0 + (-2) + expect(bounds[1]).toBeCloseTo(-2, 0); // 0 + (-2) + expect(bounds[2]).toBeCloseTo(104.83, 0); // 100 + 4.828 + expect(bounds[3]).toBeCloseTo(104.83, 0); // 100 + 4.828 + }); +}); diff --git a/packages/display/src/Shape/usecase/ShapeCalcLayerBoundsUseCase.ts b/packages/display/src/Shape/usecase/ShapeCalcLayerBoundsUseCase.ts new file mode 100644 index 00000000..323357f9 --- /dev/null +++ b/packages/display/src/Shape/usecase/ShapeCalcLayerBoundsUseCase.ts @@ -0,0 +1,42 @@ +import type { Shape } from "../../Shape"; +import { execute as shapeCalcBoundsMatrixUseCase } from "./ShapeCalcBoundsMatrixUseCase"; +import { + $getBoundsArray, + $poolBoundsArray +} from "../../DisplayObjectUtil"; + +/** + * @description Shapeのフィルター適用後の描画範囲を計算します。 + * Calculate the drawing area of Shape after applying filters. + * + * @param {Shape} shape + * @param {Float32Array | null} [matrix=null] + * @return {Float32Array} + * @method + * @protected + */ +export const execute = (shape: Shape, matrix: Float32Array | null = null): Float32Array => +{ + const bounds = shapeCalcBoundsMatrixUseCase(shape, matrix); + + const filters = shape.filters; + if (filters) { + const filterBounds = $getBoundsArray(0, 0, 0, 0); + for (let idx = 0; idx < filters.length; idx++) { + const filter = filters[idx]; + if (!filter || !filter.canApplyFilter()) { + continue; + } + filter.getBounds(filterBounds); + } + + bounds[0] += filterBounds[0]; + bounds[1] += filterBounds[1]; + bounds[2] += filterBounds[2]; + bounds[3] += filterBounds[3]; + + $poolBoundsArray(filterBounds); + } + + return bounds; +}; diff --git a/packages/display/src/Shape/usecase/ShapeGenerateRenderQueueUseCase.ts b/packages/display/src/Shape/usecase/ShapeGenerateRenderQueueUseCase.ts index 2a9efebb..ccdac919 100644 --- a/packages/display/src/Shape/usecase/ShapeGenerateRenderQueueUseCase.ts +++ b/packages/display/src/Shape/usecase/ShapeGenerateRenderQueueUseCase.ts @@ -61,6 +61,10 @@ export const execute = ( // transformed ColorTransform(tColorTransform) const rawColor = displayObjectGetRawColorTransformUseCase(shape); const tColorTransform = rawColor + && (rawColor[0] !== 1 || rawColor[1] !== 1 + || rawColor[2] !== 1 || rawColor[3] !== 1 + || rawColor[4] !== 0 || rawColor[5] !== 0 + || rawColor[6] !== 0 || rawColor[7] !== 0) ? ColorTransform.multiply(color_transform, rawColor) : color_transform; @@ -76,6 +80,9 @@ export const execute = ( // transformed matrix(tMatrix) const rawMatrix = displayObjectGetRawMatrixUseCase(shape); const tMatrix = rawMatrix + && (rawMatrix[0] !== 1 || rawMatrix[1] !== 0 + || rawMatrix[2] !== 0 || rawMatrix[3] !== 1 + || rawMatrix[4] !== 0 || rawMatrix[5] !== 0) ? Matrix.multiply(matrix, rawMatrix) : matrix; @@ -107,7 +114,6 @@ export const execute = ( if (tMatrix !== matrix) { Matrix.release(tMatrix); } - $poolBoundsArray(bounds); renderQueue.push(0); return; @@ -127,7 +133,6 @@ export const execute = ( if (tMatrix !== matrix) { Matrix.release(tMatrix); } - $poolBoundsArray(bounds); renderQueue.push(0); return; } @@ -138,43 +143,41 @@ export const execute = ( if (!shape.uniqueKey) { if (shape.characterId && shape.loaderInfo) { - const values = $getArray( shape.loaderInfo.id, shape.characterId ); - shape.uniqueKey = `${displayObjectGenerateHashService(new Float32Array(values))}`; $poolArray(values); - } else { - shape.uniqueKey = shape.isBitmap - ? `${shape.instanceId}` + ? `${displayObjectGenerateHashService(new Float32Array((shape.$bitmapBuffer as Uint8Array).buffer))}` : `${displayObjectGenerateHashService(graphics.buffer)}`; - } } - const xScale = Math.round(Math.sqrt( + const xScale = Math.sqrt( tMatrix[0] * tMatrix[0] + tMatrix[1] * tMatrix[1] - ) * 100) / 100; + ); - const yScale = Math.round(Math.sqrt( + const yScale = Math.sqrt( tMatrix[2] * tMatrix[2] + tMatrix[3] * tMatrix[3] - ) * 100) / 100; + ); + + const xScaleRounded = Math.round(xScale * 100) / 100; + const yScaleRounded = Math.round(yScale * 100) / 100; if (!shape.isBitmap && !shape.cacheKey - || shape.cacheParams[0] !== xScale - || shape.cacheParams[1] !== yScale + || shape.cacheParams[0] !== xScaleRounded + || shape.cacheParams[1] !== yScaleRounded || shape.cacheParams[2] !== tColorTransform[7] ) { - shape.cacheKey = $cacheStore.generateKeys(xScale, yScale, tColorTransform[7]); - shape.cacheParams[0] = xScale; - shape.cacheParams[1] = yScale; + shape.cacheKey = $cacheStore.generateKeys(xScaleRounded, yScaleRounded, tColorTransform[7]); + shape.cacheParams[0] = xScaleRounded; + shape.cacheParams[1] = yScaleRounded; shape.cacheParams[2] = tColorTransform[7]; } @@ -183,7 +186,7 @@ export const execute = ( : shape.cacheKey; // rennder on - renderQueue.push( + renderQueue.pushShapeBuffer( 1, $RENDERER_SHAPE_TYPE, tMatrix[0], tMatrix[1], tMatrix[2], tMatrix[3], tMatrix[4], tMatrix[5], tColorTransform[0], tColorTransform[1], tColorTransform[2], tColorTransform[3], @@ -192,7 +195,9 @@ export const execute = ( graphics.xMin, graphics.yMin, graphics.xMax, graphics.yMax, +isGridEnabled, +isDrawable, +shape.isBitmap, - +shape.uniqueKey, cacheKey + +shape.uniqueKey, cacheKey, + xScale, yScale, + shape.instanceId // フィルターキャッシュ用のユニークキー ); if (shape.$cache && !shape.$cache.has(shape.uniqueKey)) { @@ -317,14 +322,15 @@ export const execute = ( displayObjectBlendToNumberService(shape.blendMode) ); - if (shape.filters?.length) { + const filters = shape.filters; + if (filters) { let updated = false; const params = []; const bounds = $getBoundsArray(0, 0, 0, 0); - for (let idx = 0; idx < shape.filters.length; idx++) { + for (let idx = 0; idx < filters.length; idx++) { - const filter = shape.filters[idx]; + const filter = filters[idx]; if (!filter || !filter.canApplyFilter()) { continue; } @@ -352,6 +358,8 @@ export const execute = ( params.length ); renderQueue.set(new Float32Array(params)); + } else { + renderQueue.push(0); } $poolBoundsArray(bounds); diff --git a/packages/display/src/Shape/usecase/ShapeLoadAsyncUseCase.test.ts b/packages/display/src/Shape/usecase/ShapeLoadAsyncUseCase.test.ts new file mode 100644 index 00000000..06a62f67 --- /dev/null +++ b/packages/display/src/Shape/usecase/ShapeLoadAsyncUseCase.test.ts @@ -0,0 +1,179 @@ +import { execute } from "./ShapeLoadAsyncUseCase"; +import { Shape } from "../../Shape"; +import { Event } from "@next2d/events"; +import { describe, expect, it, vi } from "vitest"; + +describe("ShapeLoadAsyncUseCase.js test", () => +{ + it("execute test case1 - returns a Promise", () => + { + const shape = new Shape(); + const url = "https://example.com/image.png"; + + const result = execute(shape, url); + + expect(result).toBeInstanceOf(Promise); + }); + + it("execute test case2 - resolves when COMPLETE event dispatched", async () => + { + const shape = new Shape(); + const url = "https://example.com/image.png"; + + // addEventListener をモック + const addEventListenerSpy = vi.spyOn(shape, "addEventListener"); + + const promise = execute(shape, url); + + // addEventListener が Event.COMPLETE で呼び出されたことを確認 + expect(addEventListenerSpy).toHaveBeenCalledWith(Event.COMPLETE, expect.any(Function)); + + // 手動で COMPLETE イベントをディスパッチ + shape.dispatchEvent(new Event(Event.COMPLETE)); + + // resolve は引数なしで呼ばれるため void を返す + const result = await promise; + expect(result).toBeUndefined(); + }); + + it("execute test case3 - sets src property on shape", async () => + { + const shape = new Shape(); + const url = "https://example.com/test.png"; + + execute(shape, url); + + // src が設定されていることを確認 + expect(shape.src).toBe(url); + + // COMPLETE イベントをディスパッチして解決 + shape.dispatchEvent(new Event(Event.COMPLETE)); + }); + + it("execute test case4 - handles data URL", async () => + { + const shape = new Shape(); + const dataUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + + const promise = execute(shape, dataUrl); + + expect(shape.src).toBe(dataUrl); + + shape.dispatchEvent(new Event(Event.COMPLETE)); + + const result = await promise; + expect(result).toBeUndefined(); + }); + + it("execute test case5 - handles relative URL", async () => + { + const shape = new Shape(); + const relativeUrl = "./images/test.png"; + + const promise = execute(shape, relativeUrl); + + expect(shape.src).toBe(relativeUrl); + + shape.dispatchEvent(new Event(Event.COMPLETE)); + + const result = await promise; + expect(result).toBeUndefined(); + }); + + it("execute test case6 - handles absolute URL", async () => + { + const shape = new Shape(); + const absoluteUrl = "/assets/image.jpg"; + + const promise = execute(shape, absoluteUrl); + + expect(shape.src).toBe(absoluteUrl); + + shape.dispatchEvent(new Event(Event.COMPLETE)); + + const result = await promise; + expect(result).toBeUndefined(); + }); + + it("execute test case7 - handles empty string URL", async () => + { + const shape = new Shape(); + const emptyUrl = ""; + + const promise = execute(shape, emptyUrl); + + expect(shape.src).toBe(emptyUrl); + + shape.dispatchEvent(new Event(Event.COMPLETE)); + + const result = await promise; + expect(result).toBeUndefined(); + }); + + it("execute test case8 - addEventListener called before src assignment", () => + { + const shape = new Shape(); + const url = "https://example.com/image.png"; + + const callOrder: string[] = []; + + // addEventListener をモック + const originalAddEventListener = shape.addEventListener.bind(shape); + vi.spyOn(shape, "addEventListener").mockImplementation((type, listener) => { + callOrder.push("addEventListener"); + return originalAddEventListener(type, listener); + }); + + // src セッターをモック + const originalSrc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(shape), "src"); + Object.defineProperty(shape, "src", { + set: (value: string) => { + callOrder.push("setSrc"); + if (originalSrc && originalSrc.set) { + originalSrc.set.call(shape, value); + } + }, + get: () => { + if (originalSrc && originalSrc.get) { + return originalSrc.get.call(shape); + } + return ""; + } + }); + + execute(shape, url); + + // addEventListener が src 設定より先に呼び出されることを確認 + expect(callOrder[0]).toBe("addEventListener"); + expect(callOrder[1]).toBe("setSrc"); + }); + + it("execute test case9 - preserves shape instance", async () => + { + const shape = new Shape(); + const originalShape = shape; + const url = "https://example.com/test.png"; + + execute(shape, url); + + expect(shape).toBe(originalShape); + + shape.dispatchEvent(new Event(Event.COMPLETE)); + }); + + it("execute test case10 - handles multiple calls sequentially", async () => + { + const shape = new Shape(); + + const promise1 = execute(shape, "https://example.com/image1.png"); + shape.dispatchEvent(new Event(Event.COMPLETE)); + await promise1; + + const promise2 = execute(shape, "https://example.com/image2.png"); + expect(shape.src).toBe("https://example.com/image2.png"); + shape.dispatchEvent(new Event(Event.COMPLETE)); + + const result = await promise2; + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/display/src/Shape/usecase/ShapeLoadAsyncUseCase.ts b/packages/display/src/Shape/usecase/ShapeLoadAsyncUseCase.ts new file mode 100644 index 00000000..0e4466b8 --- /dev/null +++ b/packages/display/src/Shape/usecase/ShapeLoadAsyncUseCase.ts @@ -0,0 +1,26 @@ +import type { Shape } from "../../Shape"; +import { Event } from "@next2d/events"; + +/** + * @description 形状読み込みユースケース + * Shape Load Use Case + * + * @param {Shape} shape + * @param {string} url + * @return {Promise} + * @method + * @public + */ +export const execute = (shape: Shape, url: string): Promise => +{ + return new Promise((resolve): void => + { + const onComplete = (): void => + { + shape.removeEventListener(Event.COMPLETE, onComplete); + resolve(); + }; + shape.addEventListener(Event.COMPLETE, onComplete); + shape.src = url; + }); +}; \ No newline at end of file diff --git a/packages/display/src/Sprite.ts b/packages/display/src/Sprite.ts index a07e9f99..4366a987 100644 --- a/packages/display/src/Sprite.ts +++ b/packages/display/src/Sprite.ts @@ -6,11 +6,8 @@ import { execute as spriteStartDragService } from "./Sprite/service/SpriteStartD import { execute as spriteStopDragService } from "./Sprite/service/SpriteStopDragService"; /** - * @description Sprite クラスは、表示リストの基本的要素です。 - * グラフィックを表示でき、子を持つこともできる表示リストノードです。 - * - * The Sprite class is a basic display list building block: - * a display list node that can display graphics and can also contain children. + * @description Sprite クラスは、表示リストの基本的要素です。子を持つこともできる表示リストノードです。 + * The Sprite class is the basic display list building block: a display list node that can * * @class * @memberOf next2d.display diff --git a/packages/display/src/TextField/usecase/TextFieldCalcLayerBoundsUseCase.test.ts b/packages/display/src/TextField/usecase/TextFieldCalcLayerBoundsUseCase.test.ts new file mode 100644 index 00000000..a5f3e6f7 --- /dev/null +++ b/packages/display/src/TextField/usecase/TextFieldCalcLayerBoundsUseCase.test.ts @@ -0,0 +1,45 @@ +import { execute } from "./TextFieldCalcLayerBoundsUseCase"; +import { TextField } from "@next2d/text"; +import { describe, expect, it } from "vitest"; +import { BlurFilter, GlowFilter } from "@next2d/filters"; + +describe("TextFieldCalcLayerBoundsUseCase.ts test", () => +{ + it("フィルターなしの場合はCalcBoundsMatrixと同じ結果を返す", () => + { + const textField = new TextField(); + const bounds = execute(textField); + + expect(bounds[0]).toBe(0); + expect(bounds[1]).toBe(0); + expect(bounds[2]).toBe(100); + expect(bounds[3]).toBe(100); + }); + + it("BlurFilter適用後のboundsが拡張される", () => + { + const textField = new TextField(); + + // BlurFilter(blurX=4, blurY=4, quality=1) + // dx=2, dy=2 → filterBounds: [-2, -2, 2, 2] + textField.$filters = [new BlurFilter(4, 4, 1)]; + + const bounds = execute(textField); + expect(bounds[0]).toBe(-2); // 0 + (-2) + expect(bounds[1]).toBe(-2); // 0 + (-2) + expect(bounds[2]).toBe(102); // 100 + 2 + expect(bounds[3]).toBe(102); // 100 + 2 + }); + + it("GlowFilter(inner=true)の場合はboundsが拡張されない", () => + { + const textField = new TextField(); + textField.$filters = [new GlowFilter(0, 1, 4, 4, 1, 1, true)]; + + const bounds = execute(textField); + expect(bounds[0]).toBe(0); + expect(bounds[1]).toBe(0); + expect(bounds[2]).toBe(100); + expect(bounds[3]).toBe(100); + }); +}); diff --git a/packages/display/src/TextField/usecase/TextFieldCalcLayerBoundsUseCase.ts b/packages/display/src/TextField/usecase/TextFieldCalcLayerBoundsUseCase.ts new file mode 100644 index 00000000..2e6bc514 --- /dev/null +++ b/packages/display/src/TextField/usecase/TextFieldCalcLayerBoundsUseCase.ts @@ -0,0 +1,42 @@ +import type { TextField } from "@next2d/text"; +import { execute as textFieldCalcBoundsMatrixUseCase } from "./TextFieldCalcBoundsMatrixUseCase"; +import { + $getBoundsArray, + $poolBoundsArray +} from "../../DisplayObjectUtil"; + +/** + * @description TextFieldのフィルター適用後の描画範囲を計算します。 + * Calculate the drawing area of TextField after applying filters. + * + * @param {TextField} text_field + * @param {Float32Array | null} [matrix=null] + * @return {Float32Array} + * @method + * @protected + */ +export const execute = (text_field: TextField, matrix: Float32Array | null = null): Float32Array => +{ + const bounds = textFieldCalcBoundsMatrixUseCase(text_field, matrix); + + const filters = text_field.filters; + if (filters) { + const filterBounds = $getBoundsArray(0, 0, 0, 0); + for (let idx = 0; idx < filters.length; idx++) { + const filter = filters[idx]; + if (!filter || !filter.canApplyFilter()) { + continue; + } + filter.getBounds(filterBounds); + } + + bounds[0] += filterBounds[0]; + bounds[1] += filterBounds[1]; + bounds[2] += filterBounds[2]; + bounds[3] += filterBounds[3]; + + $poolBoundsArray(filterBounds); + } + + return bounds; +}; diff --git a/packages/display/src/TextField/usecase/TextFieldGenerateRenderQueueUseCase.ts b/packages/display/src/TextField/usecase/TextFieldGenerateRenderQueueUseCase.ts index a2555d12..31bacd25 100644 --- a/packages/display/src/TextField/usecase/TextFieldGenerateRenderQueueUseCase.ts +++ b/packages/display/src/TextField/usecase/TextFieldGenerateRenderQueueUseCase.ts @@ -54,6 +54,10 @@ export const execute = ( // transformed ColorTransform(tColorTransform) const rawColor = displayObjectGetRawColorTransformUseCase(text_field as any); const tColorTransform = rawColor + && (rawColor[0] !== 1 || rawColor[1] !== 1 + || rawColor[2] !== 1 || rawColor[3] !== 1 + || rawColor[4] !== 0 || rawColor[5] !== 0 + || rawColor[6] !== 0 || rawColor[7] !== 0) ? ColorTransform.multiply(color_transform, rawColor) : color_transform; @@ -69,6 +73,9 @@ export const execute = ( // transformed matrix(tMatrix) const rawMatrix = displayObjectGetRawMatrixUseCase(text_field as any); const tMatrix = rawMatrix + && (rawMatrix[0] !== 1 || rawMatrix[1] !== 0 + || rawMatrix[2] !== 0 || rawMatrix[3] !== 1 + || rawMatrix[4] !== 0 || rawMatrix[5] !== 0) ? Matrix.multiply(matrix, rawMatrix) : matrix; @@ -100,7 +107,6 @@ export const execute = ( if (tMatrix !== matrix) { Matrix.release(tMatrix); } - $poolBoundsArray(bounds); renderQueue.push(0); return; @@ -120,7 +126,6 @@ export const execute = ( if (tMatrix !== matrix) { Matrix.release(tMatrix); } - $poolBoundsArray(bounds); renderQueue.push(0); return; } @@ -143,32 +148,35 @@ export const execute = ( } } - const xScale = Math.round(Math.sqrt( + const xScale = Math.sqrt( tMatrix[0] * tMatrix[0] + tMatrix[1] * tMatrix[1] - ) * 100) / 100; + ); - const yScale = Math.round(Math.sqrt( + const yScale = Math.sqrt( tMatrix[2] * tMatrix[2] + tMatrix[3] * tMatrix[3] - ) * 100) / 100; + ); + + const xScaleRounded = Math.round(xScale * 100) / 100; + const yScaleRounded = Math.round(yScale * 100) / 100; if (text_field.changed && !text_field.cacheKey - || text_field.cacheParams[0] !== xScale - || text_field.cacheParams[1] !== yScale + || text_field.cacheParams[0] !== xScaleRounded + || text_field.cacheParams[1] !== yScaleRounded || text_field.cacheParams[2] !== tColorTransform[7] ) { - text_field.cacheKey = $cacheStore.generateKeys(xScale, yScale, tColorTransform[7]); - text_field.cacheParams[0] = xScale; - text_field.cacheParams[1] = yScale; + text_field.cacheKey = $cacheStore.generateKeys(xScaleRounded, yScaleRounded, tColorTransform[7]); + text_field.cacheParams[0] = xScaleRounded; + text_field.cacheParams[1] = yScaleRounded; text_field.cacheParams[2] = tColorTransform[7]; } const cacheKey = text_field.cacheKey; // rennder on - renderQueue.push( + renderQueue.pushTextFieldBuffer( 1, $RENDERER_TEXT_TYPE, tMatrix[0], tMatrix[1], tMatrix[2], tMatrix[3], tMatrix[4], tMatrix[5], tColorTransform[0], tColorTransform[1], tColorTransform[2], tColorTransform[3], @@ -176,7 +184,9 @@ export const execute = ( xMin, yMin, xMax, yMax, text_field.xMin, text_field.yMin, text_field.xMax, text_field.yMax, - +text_field.uniqueKey, cacheKey, +text_field.changed + +text_field.uniqueKey, cacheKey, +text_field.changed, + xScale, yScale, + text_field.instanceId // フィルターキャッシュ用のユニークキー ); if (text_field.$cache && !text_field.$cache.has(text_field.uniqueKey)) { @@ -294,6 +304,8 @@ export const execute = ( params.length ); renderQueue.set(new Float32Array(params)); + } else { + renderQueue.push(0); } $poolBoundsArray(bounds); diff --git a/packages/display/src/Video/usecase/VideoCalcLayerBoundsUseCase.test.ts b/packages/display/src/Video/usecase/VideoCalcLayerBoundsUseCase.test.ts new file mode 100644 index 00000000..14a6962c --- /dev/null +++ b/packages/display/src/Video/usecase/VideoCalcLayerBoundsUseCase.test.ts @@ -0,0 +1,45 @@ +import { execute } from "./VideoCalcLayerBoundsUseCase"; +import { Video } from "@next2d/media"; +import { describe, expect, it } from "vitest"; +import { BlurFilter, GlowFilter } from "@next2d/filters"; + +describe("VideoCalcLayerBoundsUseCase.ts test", () => +{ + it("フィルターなしの場合はCalcBoundsMatrixと同じ結果を返す", () => + { + const video = new Video(3, 4); + const bounds = execute(video); + + expect(bounds[0]).toBe(0); + expect(bounds[1]).toBe(0); + expect(bounds[2]).toBe(3); + expect(bounds[3]).toBe(4); + }); + + it("BlurFilter適用後のboundsが拡張される", () => + { + const video = new Video(3, 4); + + // BlurFilter(blurX=4, blurY=4, quality=1) + // dx=2, dy=2 → filterBounds: [-2, -2, 2, 2] + video.$filters = [new BlurFilter(4, 4, 1)]; + + const bounds = execute(video); + expect(bounds[0]).toBe(-2); // 0 + (-2) + expect(bounds[1]).toBe(-2); // 0 + (-2) + expect(bounds[2]).toBe(5); // 3 + 2 + expect(bounds[3]).toBe(6); // 4 + 2 + }); + + it("GlowFilter(inner=true)の場合はboundsが拡張されない", () => + { + const video = new Video(3, 4); + video.$filters = [new GlowFilter(0, 1, 4, 4, 1, 1, true)]; + + const bounds = execute(video); + expect(bounds[0]).toBe(0); + expect(bounds[1]).toBe(0); + expect(bounds[2]).toBe(3); + expect(bounds[3]).toBe(4); + }); +}); diff --git a/packages/display/src/Video/usecase/VideoCalcLayerBoundsUseCase.ts b/packages/display/src/Video/usecase/VideoCalcLayerBoundsUseCase.ts new file mode 100644 index 00000000..85e8a764 --- /dev/null +++ b/packages/display/src/Video/usecase/VideoCalcLayerBoundsUseCase.ts @@ -0,0 +1,42 @@ +import type { Video } from "@next2d/media"; +import { execute as videoCalcBoundsMatrixUseCase } from "./VideoCalcBoundsMatrixUseCase"; +import { + $getBoundsArray, + $poolBoundsArray +} from "../../DisplayObjectUtil"; + +/** + * @description Videoのフィルター適用後の描画範囲を計算します。 + * Calculate the drawing area of Video after applying filters. + * + * @param {Video} video + * @param {Float32Array | null} [matrix=null] + * @return {Float32Array} + * @method + * @protected + */ +export const execute = (video: Video, matrix: Float32Array | null = null): Float32Array => +{ + const bounds = videoCalcBoundsMatrixUseCase(video, matrix); + + const filters = video.filters; + if (filters) { + const filterBounds = $getBoundsArray(0, 0, 0, 0); + for (let idx = 0; idx < filters.length; idx++) { + const filter = filters[idx]; + if (!filter || !filter.canApplyFilter()) { + continue; + } + filter.getBounds(filterBounds); + } + + bounds[0] += filterBounds[0]; + bounds[1] += filterBounds[1]; + bounds[2] += filterBounds[2]; + bounds[3] += filterBounds[3]; + + $poolBoundsArray(filterBounds); + } + + return bounds; +}; diff --git a/packages/display/src/Video/usecase/VideoGenerateRenderQueueUseCase.ts b/packages/display/src/Video/usecase/VideoGenerateRenderQueueUseCase.ts index 588b9b7f..da26cc37 100644 --- a/packages/display/src/Video/usecase/VideoGenerateRenderQueueUseCase.ts +++ b/packages/display/src/Video/usecase/VideoGenerateRenderQueueUseCase.ts @@ -54,6 +54,10 @@ export const execute = ( // transformed ColorTransform(tColorTransform) const rawColor = displayObjectGetRawColorTransformUseCase(video); const tColorTransform = rawColor + && (rawColor[0] !== 1 || rawColor[1] !== 1 + || rawColor[2] !== 1 || rawColor[3] !== 1 + || rawColor[4] !== 0 || rawColor[5] !== 0 + || rawColor[6] !== 0 || rawColor[7] !== 0) ? ColorTransform.multiply(color_transform, rawColor) : color_transform; @@ -69,6 +73,9 @@ export const execute = ( // transformed matrix(tMatrix) const rawMatrix = displayObjectGetRawMatrixUseCase(video); const tMatrix = rawMatrix + && (rawMatrix[0] !== 1 || rawMatrix[1] !== 0 + || rawMatrix[2] !== 0 || rawMatrix[3] !== 1 + || rawMatrix[4] !== 0 || rawMatrix[5] !== 0) ? Matrix.multiply(matrix, rawMatrix) : matrix; @@ -144,14 +151,15 @@ export const execute = ( } // rennder on - renderQueue.push( + renderQueue.pushVideoBuffer( 1, $RENDERER_VIDEO_TYPE, tMatrix[0], tMatrix[1], tMatrix[2], tMatrix[3], tMatrix[4], tMatrix[5], tColorTransform[0], tColorTransform[1], tColorTransform[2], tColorTransform[3], tColorTransform[4], tColorTransform[5], tColorTransform[6], tColorTransform[7], xMin, yMin, xMax, yMax, 0, 0, video.videoWidth, video.videoHeight, - +video.uniqueKey, +video.changed + +video.uniqueKey, +video.changed, + video.instanceId // フィルターキャッシュ用のユニークキー ); if (video.$cache && !video.$cache.has(video.uniqueKey)) { @@ -243,6 +251,8 @@ export const execute = ( params.length ); renderQueue.set(new Float32Array(params)); + } else { + renderQueue.push(0); } $poolBoundsArray(bounds); diff --git a/packages/events/README.md b/packages/events/README.md index 01382045..76e2c9b4 100644 --- a/packages/events/README.md +++ b/packages/events/README.md @@ -1,11 +1,307 @@ -@next2d/events -============= +# @next2d/events -## Installation +**重要**: `@next2d/events` は他の packages の import を禁止しています。このパッケージは基盤モジュールであり、循環依存を避けるために独立を維持する必要があります。 -``` +**Important**: `@next2d/events` prohibits importing other packages. This package is a foundational module that must remain independent to avoid circular dependencies. + +## 概要 / Overview + +`@next2d/events` パッケージは、EventDispatcher パターンに基づく包括的なイベント処理システムを提供します。このパッケージは、イベントのバブリング、キャプチャリング、およびインタラクティブアプリケーションで一般的に使用される様々なイベントタイプをサポートする堅牢なイベントフロー機構を実装しています。 + +The `@next2d/events` package provides a comprehensive event handling system based on the EventDispatcher pattern. This package implements a robust event flow mechanism with support for event bubbling, capturing, and various event types commonly used in interactive applications. + +## インストール / Installation + +```bash npm install @next2d/events ``` -## License +## 特徴 / Features + +- **EventDispatcher パターン**: イベント駆動型アーキテクチャのためのオブザーバーパターンの完全な実装 + - Complete implementation of the observer pattern for event-driven architecture +- **イベントフロー制御**: イベント伝播制御を備えたキャプチャリングフェーズとバブリングフェーズのサポート + - Support for capturing and bubbling phases with event propagation control +- **複数のイベントタイプ**: 一般的なユースケース(ポインター、キーボード、フォーカス、ビデオなど)のための事前定義されたイベントクラス + - Pre-defined event classes for common use cases (pointer, keyboard, focus, video, etc.) +- **型安全性**: 包括的な型定義による完全な TypeScript サポート + - Full TypeScript support with comprehensive type definitions +- **サービスベースアーキテクチャ**: イベントディスパッチャー操作のためのモジュラーサービス層 + - Modular service layer for event dispatcher operations + +## ディレクトリ構造 / Directory Structure + +``` +@next2d/events/ +├── src/ +│ ├── Event.ts # 基本イベントクラス / Base event class +│ ├── EventDispatcher.ts # イベントディスパッチャーの実装 / Event dispatcher implementation +│ ├── EventPhase.ts # イベントフェーズ定数 / Event phase constants +│ ├── EventUtil.ts # イベントユーティリティ関数 / Event utility functions +│ │ +│ ├── EventDispatcher/ +│ │ └── service/ # イベントディスパッチャーサービス層 / Event dispatcher service layer +│ │ ├── EventDispatcherAddEventListenerService.ts +│ │ ├── EventDispatcherDispatchEventService.ts +│ │ ├── EventDispatcherHasEventListenerService.ts +│ │ ├── EventDispatcherRemoveEventListenerService.ts +│ │ ├── EventDispatcherRemoveAllEventListenerService.ts +│ │ └── EventDispatcherWillTriggerService.ts +│ │ +│ ├── interface/ # TypeScript インターフェース / TypeScript interfaces +│ │ ├── IEvent.ts +│ │ ├── IEventDispatcher.ts +│ │ ├── IEventListener.ts +│ │ └── IURLRequestHeader.ts +│ │ +│ └── [イベントタイプ / Event Types] +│ ├── PointerEvent.ts # マウスとタッチイベント / Mouse and touch events +│ ├── KeyboardEvent.ts # キーボード入力イベント / Keyboard input events +│ ├── FocusEvent.ts # フォーカス変更イベント / Focus change events +│ ├── WheelEvent.ts # マウスホイールイベント / Mouse wheel events +│ ├── VideoEvent.ts # ビデオ再生イベント / Video playback events +│ ├── JobEvent.ts # Tween ジョブイベント / Tween job events +│ ├── HTTPStatusEvent.ts # HTTP ステータスイベント / HTTP status events +│ ├── IOErrorEvent.ts # I/O エラーイベント / I/O error events +│ └── ProgressEvent.ts # ロード進捗イベント / Load progress events +``` + +## イベントフロー / Event Flow + +イベントシステムは、W3C DOM イベントモデルと同様の3フェーズイベントフロー機構を実装しています。 + +The event system implements a three-phase event flow mechanism similar to the W3C DOM event model. + +```mermaid +sequenceDiagram + participant Stage as Stage / ステージ + participant Parent as Parent / 親 + participant Target as Target / ターゲット + + Note over Stage,Target: Phase 1: Capturing Phase / キャプチャリングフェーズ + Stage->>Parent: eventPhase = CAPTURING_PHASE (1) + Parent->>Target: eventPhase = CAPTURING_PHASE (1) + + Note over Target: Phase 2: Target Phase / ターゲットフェーズ + Target->>Target: eventPhase = AT_TARGET (2) + + Note over Target,Stage: Phase 3: Bubbling Phase (if bubbles=true) / バブリングフェーズ + Target->>Parent: eventPhase = BUBBLING_PHASE (3) + Parent->>Stage: eventPhase = BUBBLING_PHASE (3) + + Note over Stage,Target: Event Flow Control / イベントフロー制御 + rect rgb(255, 240, 240) + Note over Target: stopPropagation()
Stops further propagation / 以降の伝播を停止 + end + rect rgb(255, 230, 230) + Note over Target: stopImmediatePropagation()
Stops all listeners immediately / すべてのリスナーを即座に停止 + end +``` + +## コアクラス / Core Classes + +### Event + +すべてのイベントの基本クラス。コアイベントプロパティと伝播制御メソッドを提供します。 + +The base class for all events. Provides core event properties and propagation control methods. + +**主要プロパティ / Key Properties:** +- `type`: イベントタイプ識別子 / Event type identifier +- `bubbles`: イベントがバブリングフェーズに参加するかどうか / Whether the event participates in the bubbling phase +- `target`: イベントリスナーを登録したオブジェクト / The object that registered the event listener +- `currentTarget`: 現在イベントを処理しているオブジェクト / The object currently processing the event +- `eventPhase`: イベントフローの現在のフェーズ / Current phase of event flow (CAPTURING_PHASE, AT_TARGET, BUBBLING_PHASE) + +**メソッド / Methods:** +- `stopPropagation()`: 後続ノードでの処理を防止 / Prevents processing in subsequent nodes +- `stopImmediatePropagation()`: 残りのすべてのリスナーの処理を防止 / Prevents processing of all remaining listeners + +### EventDispatcher + +イベントを送出するすべてのクラスの基本クラス。イベントリスナーの登録とイベントの送出を管理します。 + +The base class for all classes that dispatch events. Manages event listener registration and event dispatching. + +**メソッド / Methods:** +- `addEventListener(type, listener, useCapture, priority)`: イベントリスナーを登録 / Register an event listener +- `removeEventListener(type, listener, useCapture)`: イベントリスナーを削除 / Remove an event listener +- `removeAllEventListener(type, useCapture)`: 特定タイプのすべてのリスナーを削除 / Remove all listeners of a specific type +- `dispatchEvent(event)`: イベントをイベントフローに送出 / Dispatch an event into the event flow +- `hasEventListener(type)`: イベントタイプのリスナーが存在するか確認 / Check if a listener exists for an event type +- `willTrigger(type)`: このオブジェクトまたは祖先がイベントタイプのリスナーを持つか確認 / Check if this object or ancestors have listeners for an event type + +### EventPhase + +イベントフローの現在のフェーズを定義する定数。 + +Constants defining the current phase of event flow. + +- `CAPTURING_PHASE = 1`: キャプチャフェーズ / The capture phase +- `AT_TARGET = 2`: ターゲットフェーズ / The target phase +- `BUBBLING_PHASE = 3`: バブリングフェーズ / The bubbling phase + +## イベントタイプ / Event Types + +### PointerEvent + +ポインターデバイスの操作(マウス、ペン、タッチ)を処理します。 + +Handles pointer device interactions (mouse, pen, touch). + +**イベントタイプ / Event Types:** +- `POINTER_DOWN`: ボタンの押下開始 / Button press started +- `POINTER_UP`: ボタンの解放 / Button released +- `POINTER_MOVE`: ポインター座標の変化 / Pointer coordinates changed +- `POINTER_OVER`: ポインターがヒットテスト境界に入った / Pointer entered hit test boundary +- `POINTER_OUT`: ポインターがヒットテスト境界を出た / Pointer left hit test boundary +- `POINTER_LEAVE`: ポインターが要素領域を離れた / Pointer left element area +- `POINTER_CANCEL`: ポインター操作がキャンセルされた / Pointer interaction canceled +- `DOUBLE_CLICK`: ダブルクリック/タップが発生 / Double-click/tap occurred + +### KeyboardEvent + +キーボード入力を処理します。 + +Handles keyboard input. + +**イベントタイプ / Event Types:** +- `KEY_DOWN`: キーが押された / Key pressed +- `KEY_UP`: キーが離された / Key released + +### FocusEvent + +表示オブジェクト間のフォーカス変更を処理します。 + +Handles focus changes between display objects. + +**イベントタイプ / Event Types:** +- `FOCUS_IN`: 要素がフォーカスを受け取った / Element received focus +- `FOCUS_OUT`: 要素がフォーカスを失った / Element lost focus + +### WheelEvent + +マウスホイールの操作を処理します。 + +Handles mouse wheel interactions. + +**イベントタイプ / Event Types:** +- `WHEEL`: マウスホイールが回転した / Mouse wheel rotated + +### VideoEvent + +ビデオ再生の状態変化を処理します。 + +Handles video playback state changes. + +**イベントタイプ / Event Types:** +- `PLAY`: ビデオ再生がリクエストされた / Video play requested +- `PLAYING`: ビデオ再生が開始された / Video playback started +- `PAUSE`: ビデオが一時停止された / Video paused +- `SEEK`: ビデオシーク操作 / Video seek operation + +### JobEvent + +Tween アニメーションイベントを処理します。 + +Handles tween animation events. + +**イベントタイプ / Event Types:** +- `UPDATE`: Tween プロパティが更新された / Tween property updated +- `STOP`: Tween ジョブが停止した / Tween job stopped + +### ProgressEvent + +ファイルやデータのロード進捗を処理します。 + +Handles loading progress for files and data. + +**プロパティ / Properties:** +- `bytesLoaded`: これまでにロードされたバイト数 / Bytes loaded so far +- `bytesTotal`: ロードする合計バイト数 / Total bytes to load + +**イベントタイプ / Event Types:** +- `PROGRESS`: ロード進捗の更新 / Loading progress update + +### HTTPStatusEvent + +HTTP レスポンスステータスを処理します。 + +Handles HTTP response status. + +**プロパティ / Properties:** +- `status`: HTTP ステータスコード / HTTP status code +- `responseURL`: レスポンス URL / Response URL +- `responseHeaders`: レスポンスヘッダーの配列 / Array of response headers + +**イベントタイプ / Event Types:** +- `HTTP_STATUS`: HTTP ステータスを受信 / HTTP status received + +### IOErrorEvent + +I/O 操作エラーを処理します。 + +Handles I/O operation errors. + +**プロパティ / Properties:** +- `text`: エラーメッセージテキスト / Error message text + +**イベントタイプ / Event Types:** +- `IO_ERROR`: I/O エラーが発生 / I/O error occurred + +## 使用例 / Usage Example + +```typescript +import { EventDispatcher, Event, PointerEvent } from '@next2d/events'; + +// イベントディスパッチャーを作成 / Create an event dispatcher +const dispatcher = new EventDispatcher(); + +// イベントリスナーを追加 / Add event listener +dispatcher.addEventListener(PointerEvent.POINTER_DOWN, (event: Event) => { + console.log('ポインターダウン / Pointer down:', event.target); +}); + +// イベントを送出 / Dispatch event +const event = new PointerEvent(PointerEvent.POINTER_DOWN, true); +dispatcher.dispatchEvent(event); + +// イベントリスナーを削除 / Remove event listener +dispatcher.removeEventListener(PointerEvent.POINTER_DOWN, listener); +``` + +### イベントバブリングの例 / Event Bubbling Example + +```typescript +import { EventDispatcher, Event } from '@next2d/events'; + +const stage = new EventDispatcher(); +const parent = new EventDispatcher(); +const child = new EventDispatcher(); + +// 階層を設定(child -> parent -> stage) +// Setup hierarchy (child -> parent -> stage) + +// キャプチャフェーズリスナーを追加 / Add capturing phase listener +stage.addEventListener(Event.ADDED, (e) => { + console.log('ステージ / Stage: Capturing', e.eventPhase); // 1 +}, true); + +// ターゲットフェーズリスナーを追加 / Add target phase listener +child.addEventListener(Event.ADDED, (e) => { + console.log('子 / Child: Target', e.eventPhase); // 2 +}); + +// バブリングフェーズリスナーを追加 / Add bubbling phase listener +parent.addEventListener(Event.ADDED, (e) => { + console.log('親 / Parent: Bubbling', e.eventPhase); // 3 +}); + +// 子からバブリングイベントを送出 / Dispatch bubbling event from child +const event = new Event(Event.ADDED, true); // bubbles = true +child.dispatchEvent(event); +``` + +## ライセンス / License + This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [LICENSE](LICENSE) file for details. diff --git a/packages/filters/README.md b/packages/filters/README.md index c2b33e15..f77f3191 100644 --- a/packages/filters/README.md +++ b/packages/filters/README.md @@ -1,11 +1,255 @@ -@next2d/filters -============= +# @next2d/filters -## Installation +**Important**: `@next2d/filters` prohibits importing other packages. This package is a foundational module that must remain independent to avoid circular dependencies. -``` +**重要**: `@next2d/filters` は他の packages の import を禁止しています。このパッケージは基盤モジュールであり、循環依存を避けるために独立を維持する必要があります。 + +## Overview / 概要 + +**English:** +The `@next2d/filters` package provides GPU-accelerated visual effect filters for DisplayObjects in the Next2D player. This package includes a comprehensive set of bitmap filter effects that can be applied to any display object, offering high-performance image processing capabilities powered by WebGL. + +**日本語:** +`@next2d/filters` パッケージは、Next2D プレイヤーの DisplayObject に対して GPU アクセラレーションによる視覚エフェクトフィルターを提供します。このパッケージには、任意の表示オブジェクトに適用可能なビットマップフィルター効果の包括的なセットが含まれており、WebGL によって高速化された画像処理機能を提供します。 + +## Installation / インストール + +```bash npm install @next2d/filters ``` -## License +## Directory Structure / ディレクトリ構造 + +``` +src/ +├── BitmapFilter.ts # Base class for all filters / すべてのフィルターの基底クラス +├── BlurFilter.ts # Blur effect filter / ぼかし効果フィルター +├── GlowFilter.ts # Glow effect filter / グロー効果フィルター +├── DropShadowFilter.ts # Drop shadow effect filter / ドロップシャドウ効果フィルター +├── BevelFilter.ts # Bevel effect filter / ベベル効果フィルター +├── ColorMatrixFilter.ts # Color matrix transformation filter / カラーマトリックス変換フィルター +├── ConvolutionFilter.ts # Convolution matrix filter / 畳み込みマトリックスフィルター +├── DisplacementMapFilter.ts # Displacement map filter / ディスプレイスメントマップフィルター +├── GradientBevelFilter.ts # Gradient bevel effect filter / グラデーションベベル効果フィルター +├── GradientGlowFilter.ts # Gradient glow effect filter / グラデーショングロー効果フィルター +├── FilterUtil.ts # Utility functions for filters / フィルター用ユーティリティ関数 +│ +├── interface/ # Type definitions / 型定義 +│ ├── IBitmapDataChannel.ts +│ ├── IBitmapFilterType.ts +│ ├── IBounds.ts +│ ├── IDisplacementMapFilterMode.ts +│ └── IFilterQuality.ts +│ +├── BlurFilter/ +│ ├── service/ # Business logic services / ビジネスロジックサービス +│ │ ├── BlurFilterCanApplyFilterService.ts +│ │ ├── BlurFilterToArrayService.ts +│ │ └── BlurFilterToNumberArrayService.ts +│ └── usecase/ # Use case implementations / ユースケース実装 +│ └── BlurFilterGetBoundsUseCase.ts +│ +├── GlowFilter/ +│ ├── service/ +│ │ ├── GlowFilterCanApplyFilterService.ts +│ │ ├── GlowFilterToArrayService.ts +│ │ └── GlowFilterToNumberArrayService.ts +│ └── usecase/ +│ └── GlowFilterGetBoundsUseCase.ts +│ +├── DropShadowFilter/ +│ ├── service/ +│ │ ├── DropShadowFilterCanApplyFilterService.ts +│ │ ├── DropShadowFilterToArrayService.ts +│ │ └── DropShadowFilterToNumberArrayService.ts +│ └── usecase/ +│ └── DropShadowFilterGetBoundsUseCase.ts +│ +├── BevelFilter/ +│ ├── service/ +│ │ ├── BevelFilterCanApplyFilterService.ts +│ │ ├── BevelFilterToArrayService.ts +│ │ └── BevelFilterToNumberArrayService.ts +│ └── usecase/ +│ └── BevelFilterGetBoundsUseCase.ts +│ +├── ColorMatrixFilter/ +│ └── service/ +│ ├── ColorMatrixFilterToArrayService.ts +│ └── ColorMatrixFilterToNumberArrayService.ts +│ +├── ConvolutionFilter/ +│ └── service/ +│ ├── ConvolutionFilterCanApplyFilterService.ts +│ ├── ConvolutionFilterToArrayService.ts +│ └── ConvolutionFilterToNumberArrayService.ts +│ +├── DisplacementMapFilter/ +│ └── service/ +│ ├── DisplacementMapFilterCanApplyFilterService.ts +│ ├── DisplacementMapFilterToArrayService.ts +│ └── DisplacementMapFilterToNumberArrayService.ts +│ +├── GradientBevelFilter/ +│ ├── service/ +│ │ ├── GradientBevelFilterCanApplyFilterService.ts +│ │ ├── GradientBevelFilterToArrayService.ts +│ │ └── GradientBevelFilterToNumberArrayService.ts +│ └── usecase/ +│ └── GradientBevelFilterGetBoundsUseCase.ts +│ +└── GradientGlowFilter/ + ├── service/ + │ ├── GradientGlowFilterCanApplyFilterService.ts + │ ├── GradientGlowFilterToArrayService.ts + │ └── GradientGlowFilterToNumberArrayService.ts + └── usecase/ + └── GradientGlowFilterGetBoundsUseCase.ts +``` + +## Available Filters / 利用可能なフィルター + +### BitmapFilter +**English:** Base class for all image filter effects. All filter classes extend this base class. +**日本語:** すべてのイメージフィルター効果の基底クラス。すべてのフィルタークラスはこの基底クラスを継承します。 + +### BlurFilter +**English:** Applies a blur visual effect to display objects. Softens image details, ranging from a soft focus to a Gaussian blur effect. +**日本語:** 表示オブジェクトにぼかし効果を適用します。ソフトフォーカスからガウスぼかしまで、イメージの細部をぼかします。 + +### GlowFilter +**English:** Applies a glow effect to display objects. Supports inner glow, outer glow, and knockout modes. +**日本語:** 表示オブジェクトにグロー効果を適用します。内側グロー、外側グロー、ノックアウトモードをサポートします。 + +### DropShadowFilter +**English:** Adds a drop shadow effect to display objects. Creates the visual effect of an object casting a shadow. +**日本語:** 表示オブジェクトにドロップシャドウ効果を追加します。オブジェクトが影を落とす視覚効果を作成します。 + +### BevelFilter +**English:** Adds a bevel effect that gives objects a three-dimensional appearance with highlights and shadows. +**日本語:** ハイライトとシャドウを使用してオブジェクトに立体的な外観を与えるベベル効果を追加します。 + +### ColorMatrixFilter +**English:** Applies color transformation using a 4x5 matrix. Enables advanced color manipulation and effects. +**日本語:** 4x5 マトリックスを使用してカラー変換を適用します。高度なカラー操作とエフェクトを可能にします。 + +### ConvolutionFilter +**English:** Applies a convolution matrix filter for custom image processing effects like sharpening, edge detection, and embossing. +**日本語:** 鮮鋭化、エッジ検出、エンボスなどのカスタム画像処理効果のための畳み込みマトリックスフィルターを適用します。 + +### DisplacementMapFilter +**English:** Uses pixel values from a BitmapData object to displace pixels in the filtered object, creating distortion effects. +**日本語:** BitmapData オブジェクトのピクセル値を使用してフィルター適用オブジェクトのピクセルを変位させ、歪み効果を作成します。 + +### GradientBevelFilter +**English:** Produces a bevel effect with gradient color transitions for more sophisticated three-dimensional appearances. +**日本語:** グラデーションカラー遷移を使用したベベル効果を生成し、より洗練された立体的な外観を作成します。 + +### GradientGlowFilter +**English:** Applies a glow effect with gradient color transitions for enhanced visual depth and richness. +**日本語:** グラデーションカラー遷移を使用したグロー効果を適用し、視覚的な深みと豊かさを向上させます。 + +## Filter Application Pipeline / フィルター適用パイプライン + +```mermaid +flowchart TD + Start([DisplayObject with filters]) --> Check{canApplyFilter?} + Check -->|No| Skip[Skip filter] + Check -->|Yes| GetBounds[Calculate filter bounds] + + GetBounds --> ToArray[Convert filter to array] + ToArray --> ToNumberArray[Convert to number array] + + ToNumberArray --> GPU[GPU Processing] + + GPU --> BlurType{Filter Type} + + BlurType -->|BlurFilter| Blur[Apply Blur Shader] + BlurType -->|GlowFilter| Glow[Apply Glow Shader] + BlurType -->|DropShadowFilter| Shadow[Apply Shadow Shader] + BlurType -->|BevelFilter| Bevel[Apply Bevel Shader] + BlurType -->|ColorMatrixFilter| ColorMatrix[Apply Color Matrix Shader] + BlurType -->|ConvolutionFilter| Convolution[Apply Convolution Shader] + BlurType -->|DisplacementMapFilter| Displacement[Apply Displacement Shader] + BlurType -->|GradientBevelFilter| GradientBevel[Apply Gradient Bevel Shader] + BlurType -->|GradientGlowFilter| GradientGlow[Apply Gradient Glow Shader] + + Blur --> Render[Render to texture] + Glow --> Render + Shadow --> Render + Bevel --> Render + ColorMatrix --> Render + Convolution --> Render + Displacement --> Render + GradientBevel --> Render + GradientGlow --> Render + Skip --> End + + Render --> End([Filtered DisplayObject]) + + style Start fill:#e1f5ff + style End fill:#e1f5ff + style GPU fill:#fff4e1 + style Render fill:#f0e1ff + style Check fill:#ffe1e1 +``` + +## Architecture / アーキテクチャ + +**English:** +Each filter follows a clean architecture pattern with separation of concerns: + +- **Filter Class**: Main filter implementation extending `BitmapFilter` +- **Service Layer**: Business logic for filter operations (validation, conversion, serialization) +- **UseCase Layer**: Specific use cases like bounds calculation +- **Interface Layer**: Type definitions and contracts + +This architecture ensures maintainability, testability, and scalability of the filter system. + +**日本語:** +各フィルターは、関心の分離を伴うクリーンアーキテクチャパターンに従っています: + +- **フィルタークラス**: `BitmapFilter` を継承するメインのフィルター実装 +- **サービス層**: フィルター操作のビジネスロジック(検証、変換、シリアライゼーション) +- **ユースケース層**: バウンズ計算などの特定のユースケース +- **インターフェース層**: 型定義と契約 + +このアーキテクチャにより、フィルターシステムの保守性、テスト可能性、スケーラビリティが保証されます。 + +## Usage Example / 使用例 + +```typescript +import { BlurFilter, GlowFilter, DropShadowFilter } from '@next2d/filters'; + +// Create a blur filter / ぼかしフィルターを作成 +const blur = new BlurFilter(4, 4, 3); + +// Create a glow filter / グローフィルターを作成 +const glow = new GlowFilter(0xff0000, 0.8, 10, 10, 2, 3); + +// Create a drop shadow filter / ドロップシャドウフィルターを作成 +const shadow = new DropShadowFilter(4, 45, 0x000000, 0.8, 4, 4, 1, 3); + +// Apply to display object / 表示オブジェクトに適用 +displayObject.filters = [blur, glow, shadow]; +``` + +## Performance Considerations / パフォーマンスに関する考慮事項 + +**English:** +- All filters are GPU-accelerated using WebGL shaders +- Filter quality settings affect performance and visual output +- Multiple filters are processed in sequence +- Bounds calculation is optimized for minimal overhead + +**日本語:** +- すべてのフィルターは WebGL シェーダーを使用して GPU アクセラレーションされています +- フィルターの品質設定はパフォーマンスと視覚出力に影響します +- 複数のフィルターは順番に処理されます +- バウンズ計算は最小限のオーバーヘッドに最適化されています + +## License / ライセンス + This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [LICENSE](LICENSE) file for details. + +このプロジェクトは [MIT ライセンス](https://opensource.org/licenses/MIT)の下でライセンスされています - 詳細については [LICENSE](LICENSE) ファイルを参照してください。 diff --git a/packages/geom/README.md b/packages/geom/README.md index c7695f4d..24e9fa2a 100644 --- a/packages/geom/README.md +++ b/packages/geom/README.md @@ -1,11 +1,279 @@ -@next2d/geom -============= +# @next2d/geom -## Installation +**Important**: `@next2d/geom` prohibits importing other packages. This package is a foundational module that must remain independent to avoid circular dependencies. -``` +**重要**: `@next2d/geom` は他の packages の import を禁止しています。このパッケージは基盤モジュールであり、循環依存を避けるために独立を維持する必要があります。 + +## Overview / 概要 + +**English:** +The `@next2d/geom` package provides geometric primitives and utilities for 2D transformations and color manipulation. This package implements fundamental geometric classes used in graphics programming, including matrices for transformations, points for coordinates, rectangles for bounding boxes, and color transformations for visual effects. + +**Japanese:** +`@next2d/geom` パッケージは、2D変換とカラー操作のための幾何プリミティブとユーティリティを提供します。このパッケージは、グラフィックスプログラミングで使用される基本的な幾何クラスを実装しており、変換用のマトリックス、座標用のポイント、境界ボックス用の矩形、視覚効果用のカラー変換が含まれます。 + +## Installation / インストール + +```bash npm install @next2d/geom ``` +## Directory Structure / ディレクトリ構造 + +``` +src/ +├── Matrix.ts # 2D transformation matrix class +│ └── service/ # Matrix operation services +│ ├── MatirxConcatService.ts # Matrix concatenation +│ ├── MatrixCloneService.ts # Matrix cloning +│ ├── MatrixCopyFromService.ts # Copy matrix data +│ ├── MatrixCreateBoxService.ts # Create transformation box +│ ├── MatrixCreateGradientBoxService.ts # Create gradient box +│ ├── MatrixDeltaTransformPointService.ts # Delta transformation +│ ├── MatrixIdentityService.ts # Identity matrix +│ ├── MatrixInvertService.ts # Matrix inversion +│ ├── MatrixRotateService.ts # Rotation transformation +│ ├── MatrixScaleService.ts # Scaling transformation +│ ├── MatrixSetToService.ts # Set matrix values +│ ├── MatrixTransformPointService.ts # Point transformation +│ └── MatrixTranslateService.ts # Translation transformation +│ +├── Point.ts # 2D point class +│ └── service/ # Point operation services +│ ├── PointAddService.ts # Add points +│ ├── PointCloneService.ts # Clone point +│ ├── PointCopyFromService.ts # Copy point data +│ ├── PointDistanceService.ts # Calculate distance +│ ├── PointEqualsService.ts # Point equality check +│ ├── PointInterpolateService.ts # Point interpolation +│ ├── PointNormalizeService.ts # Normalize point +│ ├── PointOffsetService.ts # Offset point +│ ├── PointPolarService.ts # Polar to Cartesian conversion +│ ├── PointSetToService.ts # Set point values +│ └── PointSubtractService.ts # Subtract points +│ +├── Rectangle.ts # Rectangle class for bounding boxes +│ └── service/ # Rectangle operation services +│ ├── RectangleCloneService.ts # Clone rectangle +│ ├── RectangleContainsService.ts # Contains coordinate check +│ ├── RectangleContainsPointService.ts # Contains point check +│ ├── RectangleContainsRectService.ts # Contains rectangle check +│ ├── RectangleCopyFromService.ts # Copy rectangle data +│ ├── RectangleEqualsService.ts # Rectangle equality check +│ ├── RectangleInflateService.ts # Inflate rectangle +│ ├── RectangleInflatePointService.ts # Inflate by point +│ ├── RectangleIntersectionService.ts # Calculate intersection +│ ├── RectangleIntersectsService.ts # Intersection check +│ ├── RectangleIsEmptyService.ts # Empty check +│ ├── RectangleOffsetService.ts # Offset rectangle +│ ├── RectangleOffsetPointService.ts # Offset by point +│ ├── RectangleSetEmptyService.ts # Set to empty +│ ├── RectangleSetToService.ts # Set rectangle values +│ └── RectangleUnionService.ts # Union of rectangles +│ +├── ColorTransform.ts # Color transformation class +│ └── service/ # ColorTransform operation services +│ └── ColorTransformConcatService.ts # Concatenate color transforms +│ +├── GeomUtil.ts # Utility functions +└── index.ts # Package exports +``` + +## Classes / クラス + +### Matrix + +**English:** +The `Matrix` class represents a 3x2 affine transformation matrix used for 2D transformations. It determines how to map points from one coordinate space to another and supports operations like translation, rotation, scaling, and skewing. + +**Japanese:** +`Matrix` クラスは、2D変換に使用される3x2のアフィン変換行列を表します。ある座標空間から別の座標空間へのポイントのマッピング方法を決定し、平行移動、回転、拡大縮小、傾斜などの操作をサポートします。 + +**Key Properties / 主要プロパティ:** +- `a`, `b`, `c`, `d` - Transformation matrix components / 変換行列の成分 +- `tx`, `ty` - Translation values / 平行移動の値 + +**Common Operations / 一般的な操作:** +- `clone()` - Create a copy of the matrix / 行列のコピーを作成 +- `concat(matrix)` - Combine with another matrix / 別の行列と結合 +- `rotate(angle)` - Apply rotation / 回転を適用 +- `scale(sx, sy)` - Apply scaling / 拡大縮小を適用 +- `translate(dx, dy)` - Apply translation / 平行移動を適用 +- `transformPoint(point)` - Transform a point / ポイントを変換 +- `invert()` - Invert the matrix / 行列を反転 +- `identity()` - Reset to identity matrix / 単位行列にリセット + +### Point + +**English:** +The `Point` class represents a location in a two-dimensional coordinate system, where x represents the horizontal axis and y represents the vertical axis. + +**Japanese:** +`Point` クラスは2次元座標系の位置を表し、xは水平軸、yは垂直軸を表します。 + +**Key Properties / 主要プロパティ:** +- `x` - Horizontal coordinate / 水平座標 +- `y` - Vertical coordinate / 垂直座標 +- `length` - Distance from origin (0,0) / 原点(0,0)からの距離 + +**Common Operations / 一般的な操作:** +- `clone()` - Create a copy of the point / ポイントのコピーを作成 +- `add(point)` - Add coordinates / 座標を加算 +- `subtract(point)` - Subtract coordinates / 座標を減算 +- `offset(dx, dy)` - Offset by specified amounts / 指定量でオフセット +- `normalize(thickness)` - Scale to set length / 指定長さにスケール +- `equals(point)` - Check equality / 等価性をチェック +- `Point.distance(p1, p2)` - Calculate distance between points / 2点間の距離を計算 +- `Point.interpolate(p1, p2, f)` - Interpolate between points / 2点間を補間 +- `Point.polar(length, angle)` - Convert polar to Cartesian / 極座標を直交座標に変換 + +### Rectangle + +**English:** +The `Rectangle` class represents an area defined by its position (top-left corner point) and dimensions (width and height). It is commonly used for bounding boxes and collision detection. + +**Japanese:** +`Rectangle` クラスは、位置(左上隅のポイント)と寸法(幅と高さ)によって定義される領域を表します。バウンディングボックスや衝突検出によく使用されます。 + +**Key Properties / 主要プロパティ:** +- `x`, `y` - Position of top-left corner / 左上隅の位置 +- `width`, `height` - Dimensions / 寸法 +- `left`, `right`, `top`, `bottom` - Edge coordinates / 辺の座標 +- `topLeft`, `bottomRight` - Corner points / 角のポイント +- `size` - Dimensions as Point / Point型の寸法 + +**Common Operations / 一般的な操作:** +- `clone()` - Create a copy / コピーを作成 +- `contains(x, y)` - Check if coordinates are inside / 座標が内部にあるか確認 +- `containsPoint(point)` - Check if point is inside / ポイントが内部にあるか確認 +- `containsRect(rect)` - Check if rectangle is inside / 矩形が内部にあるか確認 +- `intersects(rect)` - Check for intersection / 交差をチェック +- `intersection(rect)` - Get intersection area / 交差領域を取得 +- `union(rect)` - Get union area / 結合領域を取得 +- `inflate(dx, dy)` - Increase size / サイズを増加 +- `offset(dx, dy)` - Move position / 位置を移動 +- `isEmpty()` - Check if empty / 空かどうか確認 +- `setEmpty()` - Set to empty / 空に設定 + +### ColorTransform + +**English:** +The `ColorTransform` class allows you to adjust color values in display objects. Color transformation can be applied to all four channels: red, green, blue, and alpha transparency. Each channel uses the formula: +``` +new_value = (old_value * multiplier) + offset +``` + +**Japanese:** +`ColorTransform` クラスを使用すると、表示オブジェクトのカラー値を調整できます。カラー変換は、赤、緑、青、アルファ透明度の4つのチャンネルすべてに適用できます。各チャンネルは次の式を使用します: +``` +新しい値 = (古い値 * 乗数) + オフセット +``` + +**Key Properties / 主要プロパティ:** +- `redMultiplier`, `greenMultiplier`, `blueMultiplier`, `alphaMultiplier` - Channel multipliers (0-1) / チャンネル乗数(0-1) +- `redOffset`, `greenOffset`, `blueOffset`, `alphaOffset` - Channel offsets (-255 to 255) / チャンネルオフセット(-255~255) + +**Common Operations / 一般的な操作:** +- `clone()` - Create a copy / コピーを作成 +- `concat(colorTransform)` - Combine color transformations / カラー変換を結合 + +### GeomUtil + +**English:** +The `GeomUtil` module provides utility functions for geometry operations, including object pooling for Float32Array instances to optimize memory usage and performance. + +**Japanese:** +`GeomUtil` モジュールは、メモリ使用量とパフォーマンスを最適化するためのFloat32Arrayインスタンスのオブジェクトプーリングを含む、幾何演算用のユーティリティ関数を提供します。 + +**Key Functions / 主要な関数:** +- `$getFloat32Array6()` - Get pooled 6-element array for Matrix / Matrix用の6要素配列をプールから取得 +- `$poolFloat32Array6()` - Return array to pool / 配列をプールに返却 +- `$getFloat32Array8()` - Get pooled 8-element array for ColorTransform / ColorTransform用の8要素配列をプールから取得 +- `$poolFloat32Array8()` - Return array to pool / 配列をプールに返却 +- `$clamp()` - Clamp value between min and max / 値を最小値と最大値の間に制限 + +## Matrix Transformation Pipeline / 行列変換パイプライン + +```mermaid +graph TD + A[Original Coordinates
元の座標] --> B{Transformation Type
変換タイプ} + + B -->|Scale
拡大縮小| C[Matrix.scale sx, sy] + B -->|Rotate
回転| D[Matrix.rotate angle] + B -->|Translate
平行移動| E[Matrix.translate dx, dy] + B -->|Custom
カスタム| F[Matrix.setTo a,b,c,d,tx,ty] + + C --> G{Combine Transforms?
変換を結合?} + D --> G + E --> G + F --> G + + G -->|Yes
はい| H[Matrix.concat otherMatrix] + G -->|No
いいえ| I[Apply Transform
変換を適用] + + H --> I + + I --> J{Transform Type
変換タイプ} + + J -->|Full Transform
完全変換| K[Matrix.transformPoint point] + J -->|Delta Only
差分のみ| L[Matrix.deltaTransformPoint point] + + K --> M[Transformed Coordinates
変換後の座標] + L --> M + + I --> N{Need Inverse?
逆変換が必要?} + N -->|Yes
はい| O[Matrix.invert] + N -->|No
いいえ| P[Continue
続行] + + O --> Q[Reverse Transform
逆変換] + P --> R[End
終了] + Q --> R + + style A fill:#e1f5ff + style M fill:#e1ffe1 + style B fill:#fff5e1 + style G fill:#fff5e1 + style J fill:#fff5e1 + style N fill:#fff5e1 +``` + +## Usage Example / 使用例 + +```typescript +import { Matrix, Point, Rectangle, ColorTransform } from '@next2d/geom'; + +// Matrix transformation +const matrix = new Matrix(); +matrix.translate(100, 100); +matrix.rotate(Math.PI / 4); // 45 degrees +matrix.scale(2, 2); + +const point = new Point(50, 50); +const transformed = matrix.transformPoint(point); + +// Rectangle operations +const rect1 = new Rectangle(0, 0, 100, 100); +const rect2 = new Rectangle(50, 50, 100, 100); + +if (rect1.intersects(rect2)) { + const intersection = rect1.intersection(rect2); + console.log('Intersection:', intersection); +} + +// Color transformation +const colorTransform = new ColorTransform(); +colorTransform.redMultiplier = 0.5; +colorTransform.greenOffset = 128; +``` + +## Performance Optimization / パフォーマンス最適化 + +**English:** +This package uses object pooling for Float32Array instances to minimize memory allocations and improve performance. The `GeomUtil` module provides pooling functions that reuse array instances, reducing garbage collection overhead in performance-critical applications. + +**Japanese:** +このパッケージは、Float32Arrayインスタンスにオブジェクトプーリングを使用して、メモリ割り当てを最小限に抑え、パフォーマンスを向上させます。`GeomUtil` モジュールは、配列インスタンスを再利用するプーリング関数を提供し、パフォーマンスクリティカルなアプリケーションでのガベージコレクションのオーバーヘッドを削減します。 + ## License + This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [LICENSE](LICENSE) file for details. diff --git a/packages/media/README.md b/packages/media/README.md index 14d387bf..97f7fad9 100644 --- a/packages/media/README.md +++ b/packages/media/README.md @@ -1,11 +1,355 @@ -@next2d/renderer -============= +# @next2d/media -## Installation +## Overview / 概要 +The `@next2d/media` package provides audio and video playback management using Web Audio API and HTML5 Video. This package enables Flash-like multimedia experiences in modern web applications. + +`@next2d/media` パッケージは、Web Audio API と HTML5 Video を使用した音声および動画の再生管理を提供します。このパッケージにより、モダンなWebアプリケーションでFlashのようなマルチメディア体験を実現できます。 + +## Features / 機能 + +- **Sound Playback**: MP3/audio file loading and playback with Web Audio API / Web Audio APIを使用したMP3/音声ファイルの読み込みと再生 +- **Video Playback**: HTML5 video element integration with custom rendering pipeline / HTML5動画要素の統合とカスタムレンダリングパイプライン +- **Global Audio Control**: Application-wide volume and playback management / アプリケーション全体の音量と再生管理 +- **Sound Transform**: Volume and loop control for individual sounds / 個別のサウンドの音量とループ制御 + +## Installation / インストール + +```bash +npm install @next2d/media +``` + +## Directory Structure / ディレクトリ構造 + +``` +src/ +├── Sound.ts # Sound class for audio playback / 音声再生用のSoundクラス +├── Sound/ +│ ├── service/ # Sound-related services / Sound関連のサービス +│ │ ├── SoundDecodeService.ts # ArrayBuffer to AudioBuffer decoding / ArrayBufferからAudioBufferへのデコード +│ │ ├── SoundEndedEventService.ts # Sound ended event handling / サウンド終了イベント処理 +│ │ ├── SoundLoadStartEventService.ts # Load start event / 読み込み開始イベント +│ │ └── SoundProgressEventService.ts # Load progress event / 読み込み進捗イベント +│ └── usecase/ # Sound use cases / Soundユースケース +│ ├── SoundBuildFromCharacterUseCase.ts # Build from character data / キャラクターデータからの構築 +│ ├── SoundLoadEndEventUseCase.ts # Load completion handling / 読み込み完了処理 +│ └── SoundLoadUseCase.ts # External sound loading / 外部サウンドの読み込み +│ +├── SoundMixer.ts # Global sound controller / グローバルサウンドコントローラー +├── SoundMixer/ +│ └── service/ # SoundMixer services / SoundMixerサービス +│ ├── SoundMixerStopAllService.ts # Stop all sounds / 全サウンド停止 +│ └── SoundMixerUpdateVolumeService.ts # Update global volume / グローバル音量更新 +│ +├── SoundTransform.ts # Volume and loop properties / 音量とループのプロパティ +│ +├── Video.ts # Video class for video playback / 動画再生用のVideoクラス +├── Video/ +│ ├── service/ # Video-related services / Video関連のサービス +│ │ ├── VideoApplyChangesService.ts # Apply video state changes / 動画状態変更の適用 +│ │ ├── VideoCreateElementService.ts # Create video element / 動画要素の作成 +│ │ ├── VideoEndedEventService.ts # Video ended event / 動画終了イベント +│ │ ├── VideoLoadedmetadataEventService.ts # Metadata loaded event / メタデータ読み込みイベント +│ │ └── VideoProgressEventService.ts # Video progress event / 動画進捗イベント +│ └── usecase/ # Video use cases / Videoユースケース +│ ├── VideoBuildFromCharacterUseCase.ts # Build from character / キャラクターからの構築 +│ ├── VideoCanplaythroughEventUseCase.ts # Can play through event / 再生可能イベント +│ ├── VideoPlayEventUseCase.ts # Play event handling / 再生イベント処理 +│ └── VideoRegisterEventUseCase.ts # Register video events / 動画イベント登録 +│ +├── MediaUtil.ts # Utility functions for media / メディア用ユーティリティ関数 +│ # - AudioContext management / AudioContext管理 +│ # - Volume control / 音量制御 +│ # - Playing sounds/videos tracking / 再生中サウンド/動画の追跡 +│ # - AJAX helper / AJAXヘルパー +│ +└── interface/ # TypeScript interfaces / TypeScript インターフェース + ├── ISoundCharacter.ts # Sound character definition / サウンドキャラクター定義 + ├── IVideoCharacter.ts # Video character definition / 動画キャラクター定義 + ├── IAjaxOption.ts # AJAX options / AJAXオプション + ├── IAjaxEvent.ts # AJAX events / AJAXイベント + ├── IURLRequestMethod.ts # URL request methods / URLリクエストメソッド + ├── IURLRequestHeader.ts # URL request headers / URLリクエストヘッダー + ├── IURLLoaderDataFormat.ts # URL loader data format / URLローダーデータフォーマット + ├── IBlendMode.ts # Blend mode types / ブレンドモード型 + ├── IBounds.ts # Bounds interface / バウンズインターフェース + ├── ICharacter.ts # Character interface / キャラクターインターフェース + ├── IDictionaryTag.ts # Dictionary tag interface / 辞書タグインターフェース + ├── IFilterArray.ts # Filter array interface / フィルター配列インターフェース + ├── IGrid.ts # Grid interface / グリッドインターフェース + ├── ILoopConfig.ts # Loop config interface / ループ設定インターフェース + ├── ILoopType.ts # Loop type interface / ループタイプインターフェース + ├── IMovieClipActionObject.ts # MovieClip action object / MovieClipアクションオブジェクト + ├── IMovieClipCharacter.ts # MovieClip character / MovieClipキャラクター + ├── IMovieClipLabelObject.ts # MovieClip label object / MovieClipラベルオブジェクト + ├── IMovieClipSoundObject.ts # MovieClip sound object / MovieClipサウンドオブジェクト + ├── IPlaceObject.ts # Place object interface / 配置オブジェクトインターフェース + ├── IShapeCharacter.ts # Shape character / Shapeキャラクター + ├── ISoundTag.ts # Sound tag interface / サウンドタグインターフェース + ├── ISurfaceFilter.ts # Surface filter interface / サーフェスフィルターインターフェース + ├── ITextFieldCharacter.ts # TextField character / TextFieldキャラクター + ├── ITextFieldType.ts # TextField type / TextFieldタイプ + └── ITextFormatAlign.ts # TextFormat align / TextFormat配置 +``` + +## API Overview / API概要 + +### Sound Class / Soundクラス + +```typescript +import { Sound } from "@next2d/media"; +import { URLRequest } from "@next2d/net"; + +const sound = new Sound(); +sound.volume = 0.8; +sound.loopCount = 3; // Loop 3 times / 3回ループ + +// Load external MP3 / 外部MP3を読み込み +await sound.load(new URLRequest("path/to/audio.mp3")); + +// Play sound / サウンドを再生 +sound.play(); + +// Stop sound / サウンドを停止 +sound.stop(); +``` + +### SoundMixer Class / SoundMixerクラス + +```typescript +import { SoundMixer } from "@next2d/media"; + +// Global volume control (0.0 - 1.0) / グローバル音量制御 (0.0 - 1.0) +SoundMixer.volume = 0.5; + +// Stop all playing sounds and videos / 全ての再生中サウンドと動画を停止 +SoundMixer.stopAll(); +``` + +### Video Class / Videoクラス + +```typescript +import { Video } from "@next2d/media"; + +const video = new Video(640, 480); +video.smoothing = true; +video.loop = true; +video.autoPlay = true; +video.volume = 0.8; + +// Set video source / 動画ソースを設定 +video.src = "path/to/video.mp4"; + +// Play video / 動画を再生 +await video.play(); + +// Pause video / 動画を一時停止 +video.pause(); + +// Seek to position / 位置をシーク +video.seek(5.0); // Seek to 5 seconds / 5秒の位置にシーク ``` -npm install @next2d/renderer + +### SoundTransform Class / SoundTransformクラス + +```typescript +import { SoundTransform } from "@next2d/media"; + +const transform = new SoundTransform(0.8, 2); +// volume: 0.8, loopCount: 2 ``` +## Sound Loading and Playback Flow / サウンド読み込みと再生フロー + +```mermaid +sequenceDiagram + participant User + participant Sound + participant SoundLoadUseCase + participant MediaUtil + participant SoundLoadEndEventUseCase + participant SoundDecodeService + participant AudioContext + + User->>Sound: load(request) + Sound->>SoundLoadUseCase: execute(sound, request) + SoundLoadUseCase->>MediaUtil: $ajax(options) + MediaUtil->>MediaUtil: XMLHttpRequest + + Note over MediaUtil: loadstart event + MediaUtil-->>Sound: SoundLoadStartEventService + + Note over MediaUtil: progress event + MediaUtil-->>Sound: SoundProgressEventService + + Note over MediaUtil: loadend event + MediaUtil->>SoundLoadEndEventUseCase: execute(sound, event) + SoundLoadEndEventUseCase->>SoundDecodeService: execute(arrayBuffer) + SoundDecodeService->>AudioContext: decodeAudioData(arrayBuffer) + AudioContext-->>SoundDecodeService: AudioBuffer + SoundDecodeService-->>SoundLoadEndEventUseCase: AudioBuffer + SoundLoadEndEventUseCase->>Sound: audioBuffer = result + SoundLoadEndEventUseCase-->>Sound: Dispatch COMPLETE event + Sound-->>User: Load completed + + User->>Sound: play(startTime) + Sound->>MediaUtil: $getAudioContext() + MediaUtil-->>Sound: AudioContext + Sound->>AudioContext: createGain() + AudioContext-->>Sound: GainNode + Sound->>AudioContext: createBufferSource() + AudioContext-->>Sound: AudioBufferSourceNode + Sound->>Sound: Setup source & gain + Sound->>AudioBufferSourceNode: start(startTime) + + Note over AudioBufferSourceNode: Playback in progress + + AudioBufferSourceNode-->>Sound: ended event + Sound->>Sound: SoundEndedEventService + + alt canLoop is true + Sound->>Sound: play() again + else + Sound->>Sound: stop() + end +``` + +## Video Rendering Pipeline / 動画レンダリングパイプライン + +```mermaid +sequenceDiagram + participant User + participant Video + participant VideoCreateElementService + participant VideoRegisterEventUseCase + participant HTMLVideoElement + participant VideoPlayEventUseCase + participant OffscreenCanvas + participant VideoApplyChangesService + + User->>Video: new Video(width, height) + User->>Video: src = "video.mp4" + Video->>VideoCreateElementService: execute() + VideoCreateElementService-->>Video: HTMLVideoElement + Video->>VideoRegisterEventUseCase: execute(videoElement, video) + + VideoRegisterEventUseCase->>HTMLVideoElement: addEventListener("loadedmetadata") + VideoRegisterEventUseCase->>HTMLVideoElement: addEventListener("canplaythrough") + VideoRegisterEventUseCase->>HTMLVideoElement: addEventListener("progress") + VideoRegisterEventUseCase->>HTMLVideoElement: addEventListener("ended") + + Video->>HTMLVideoElement: src = url + Video->>HTMLVideoElement: load() + + HTMLVideoElement-->>Video: loadedmetadata event + Note over Video: VideoLoadedmetadataEventService + Video->>Video: Create OffscreenCanvas + Video->>Video: Get 2D context + Video->>Video: loaded = true + + HTMLVideoElement-->>Video: canplaythrough event + Note over Video: VideoCanplaythroughEventUseCase + + alt autoPlay is true + Video->>Video: play() + end + + User->>Video: play() + Video->>HTMLVideoElement: play() + Video->>VideoPlayEventUseCase: execute(video) + + loop Animation frame loop + VideoPlayEventUseCase->>VideoPlayEventUseCase: requestAnimationFrame + VideoPlayEventUseCase->>VideoApplyChangesService: execute(video) + VideoApplyChangesService->>Video: Update currentTime + VideoApplyChangesService-->>Video: Dispatch UPDATE event + VideoPlayEventUseCase->>OffscreenCanvas: drawImage(videoElement) + Note over OffscreenCanvas: Render current frame + end + + HTMLVideoElement-->>Video: ended event + Note over Video: VideoEndedEventService + Video-->>Video: Dispatch ENDED event + + alt loop is true + Video->>Video: currentTime = 0 + Video->>Video: play() + else + Video->>Video: ended = true + Video->>Video: pause() + end + + User->>Video: pause() + Video->>HTMLVideoElement: pause() + Video->>VideoPlayEventUseCase: cancelAnimationFrame + Video-->>Video: Dispatch PAUSE event +``` + +## Key Components / 主要コンポーネント + +### MediaUtil.ts + +Provides core utility functions for media handling: +メディア処理のためのコアユーティリティ関数を提供します: + +- **AudioContext Management / AudioContext管理**: `$getAudioContext()`, `$bootAudioContext()` +- **Volume Control / 音量制御**: `$getVolume()`, `$setVolume()` +- **Playing Media Tracking / 再生中メディア追跡**: `$getPlayingSounds()`, `$getPlayingVideos()` +- **AJAX Helper / AJAXヘルパー**: `$ajax()` for loading external resources / 外部リソース読み込み用 +- **Utility Functions / ユーティリティ関数**: `$clamp()` for value range limiting / 値の範囲制限用 + +### Service Layer / サービス層 + +Services handle specific operations and events: +サービスは特定の操作とイベントを処理します: + +- **Sound Services**: Decoding, event handling (loadstart, progress, ended) + - **Soundサービス**: デコード、イベント処理 (loadstart, progress, ended) +- **SoundMixer Services**: Global volume updates, stop all functionality + - **SoundMixerサービス**: グローバル音量更新、全停止機能 +- **Video Services**: Element creation, state changes, event handling + - **Videoサービス**: 要素作成、状態変更、イベント処理 + +### Use Case Layer / ユースケース層 + +Use cases orchestrate complex workflows: +ユースケースは複雑なワークフローを調整します: + +- **Sound Use Cases**: Loading external audio, building from character data, load completion + - **Soundユースケース**: 外部音声読み込み、キャラクターデータからの構築、読み込み完了 +- **Video Use Cases**: Event registration, playback management, character building + - **Videoユースケース**: イベント登録、再生管理、キャラクター構築 + +## Architecture / アーキテクチャ + +This package follows a clean architecture pattern: +このパッケージはクリーンアーキテクチャパターンに従っています: + +1. **Domain Layer**: Core classes (`Sound`, `Video`, `SoundMixer`, `SoundTransform`) + - **ドメイン層**: コアクラス (`Sound`, `Video`, `SoundMixer`, `SoundTransform`) + +2. **Use Case Layer**: Business logic orchestration (`usecase/` folders) + - **ユースケース層**: ビジネスロジックの調整 (`usecase/` フォルダ) + +3. **Service Layer**: Specific operations and event handling (`service/` folders) + - **サービス層**: 特定の操作とイベント処理 (`service/` フォルダ) + +4. **Utility Layer**: Shared utilities (`MediaUtil.ts`) + - **ユーティリティ層**: 共有ユーティリティ (`MediaUtil.ts`) + +5. **Interface Layer**: TypeScript type definitions (`interface/` folder) + - **インターフェース層**: TypeScript型定義 (`interface/` フォルダ) + +## Browser Compatibility / ブラウザ互換性 + +This package requires: +このパッケージには以下が必要です: + +- Web Audio API support / Web Audio APIサポート +- HTML5 Video support / HTML5 Videoサポート +- OffscreenCanvas support (for Video) / OffscreenCanvasサポート (Video用) +- XMLHttpRequest / ArrayBuffer support / XMLHttpRequest / ArrayBufferサポート + ## License + This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [LICENSE](LICENSE) file for details. diff --git a/packages/media/src/Video/usecase/VideoPlayEventUseCase.ts b/packages/media/src/Video/usecase/VideoPlayEventUseCase.ts index f05ee655..7c929cf2 100644 --- a/packages/media/src/Video/usecase/VideoPlayEventUseCase.ts +++ b/packages/media/src/Video/usecase/VideoPlayEventUseCase.ts @@ -29,12 +29,6 @@ export const execute = (video: Video): number => playingVideos.push(video); } - if (video.$context && video.$videoElement) { - video.$context.drawImage(video.$videoElement, - 0, 0, video.videoWidth, video.videoHeight - ); - } - return requestAnimationFrame((): void => { execute(video); diff --git a/packages/net/README.md b/packages/net/README.md index 2a9b9c97..fad48e95 100644 --- a/packages/net/README.md +++ b/packages/net/README.md @@ -1,11 +1,93 @@ @next2d/net ============= -## Installation +## Overview / 概要 -``` +The `@next2d/net` package provides network request handling functionality for loading external resources in Next2D applications. It offers a flexible and type-safe way to make HTTP requests with various configurations. + +`@next2d/net` パッケージは、Next2Dアプリケーションで外部リソースを読み込むためのネットワークリクエスト処理機能を提供します。様々な設定で柔軟かつ型安全にHTTPリクエストを行うことができます。 + +## Installation / インストール + +```bash npm install @next2d/net ``` -## License +## Directory Structure / ディレクトリ構成 + +``` +src/ +├── URLRequest.ts # Main request class / メインリクエストクラス +└── interface/ + ├── IURLLoaderDataFormat.ts # Response data format types / レスポンスデータフォーマット型 + ├── IURLRequestHeader.ts # Request header interface / リクエストヘッダーインターフェース + └── IURLRequestMethod.ts # HTTP method types / HTTPメソッド型 +``` + +## URLRequest Class / URLRequestクラス + +The `URLRequest` class manages HTTP requests to external resources. It provides properties to configure URLs, HTTP methods, headers, request data, and response formats. + +`URLRequest` クラスは、外部リソースへのHTTPリクエストを管理します。URL、HTTPメソッド、ヘッダー、リクエストデータ、レスポンスフォーマットを設定するプロパティを提供します。 + +### Usage / 使用方法 + +```typescript +import { URLRequest } from "@next2d/net"; + +// Basic GET request / 基本的なGETリクエスト +const request = new URLRequest("https://api.example.com/data"); + +// POST request with data / データを含むPOSTリクエスト +const postRequest = new URLRequest("https://api.example.com/users"); +postRequest.method = "POST"; +postRequest.contentType = "application/json"; +postRequest.data = { name: "John Doe", email: "john@example.com" }; + +// Custom headers / カスタムヘッダー +postRequest.requestHeaders = [ + { name: "Authorization", value: "Bearer token123" }, + { name: "X-Custom-Header", value: "custom-value" } +]; + +// Response format / レスポンスフォーマット +request.responseDataFormat = "json"; // "json" | "arraybuffer" | "text" +``` + +### Properties / プロパティ + +| Property | Type | Default | Description (EN) | 説明 (JA) | +|----------|------|---------|------------------|-----------| +| `url` | `string` | `""` | The URL to be requested | リクエストするURL | +| `method` | `IURLRequestMethod` | `"GET"` | HTTP method (GET, POST, PUT, DELETE, HEAD, OPTIONS) | HTTPメソッド | +| `contentType` | `string` | `"application/json"` | MIME content type | MIMEコンテンツタイプ | +| `data` | `any` | `null` | Data to be transmitted with the request | リクエストと共に送信されるデータ | +| `requestHeaders` | `IURLRequestHeader[]` | `[]` | Array of custom HTTP request headers | カスタムHTTPリクエストヘッダーの配列 | +| `responseDataFormat` | `IURLLoaderDataFormat` | `"json"` | Expected response data format | 期待されるレスポンスデータフォーマット | +| `withCredentials` | `boolean` | `false` | Include credentials in cross-origin requests | クロスオリジンリクエストに資格情報を含める | + +### Interfaces and Types / インターフェースと型 + +#### IURLRequestMethod +```typescript +type IURLRequestMethod = "DELETE" | "GET" | "HEAD" | "OPTIONS" | "POST" | "PUT"; +``` + +#### IURLRequestHeader +```typescript +interface IURLRequestHeader { + name: string; + value: string; +} +``` + +#### IURLLoaderDataFormat +```typescript +type IURLLoaderDataFormat = "json" | "arraybuffer" | "text"; +``` + +## License / ライセンス + This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [LICENSE](LICENSE) file for details. + +このプロジェクトは[MITライセンス](https://opensource.org/licenses/MIT)の下でライセンスされています。詳細は[LICENSE](LICENSE)ファイルをご覧ください。 diff --git a/packages/render-queue/README.md b/packages/render-queue/README.md index 17901ce5..b7fe4712 100644 --- a/packages/render-queue/README.md +++ b/packages/render-queue/README.md @@ -1,11 +1,275 @@ -@next2d/render-queue -============= +# @next2d/render-queue -## Installation +**Important**: `@next2d/render-queue` prohibits importing other packages. This package is a foundational module that must remain independent to avoid circular dependencies. -``` +**重要**: `@next2d/render-queue` は他の packages の import を禁止しています。このパッケージは基盤モジュールであり、循環依存を避けるために独立を維持する必要があります。 + +## 概要 / Overview + +**日本語:** + +`@next2d/render-queue`は、Next2D Playerのレンダリングパイプラインにおいて、描画データを効率的に管理するためのレンダーキューパッケージです。Float32Array型のバッファを使用し、DisplayObjectsからの描画命令をバッチ処理してWebGL/WebGPUに送信することで、高速なGPUレンダリングを実現します。 + +**English:** + +`@next2d/render-queue` is a render queue package that efficiently manages rendering data in the Next2D Player rendering pipeline. It uses a Float32Array-based buffer to batch rendering commands from DisplayObjects and send them to WebGL/WebGPU for high-performance GPU rendering. + +## 特徴 / Features + +- **Float32Array型バッファ**: GPU最適化された高速なデータ構造 +- **動的リサイズ**: 2の累乗サイズで自動的にバッファを拡張 +- **バッチ処理**: 複数の描画命令をまとめてGPUに送信 +- **シングルトンパターン**: グローバルな`renderQueue`インスタンスで一元管理 + +--- + +- **Float32Array-based buffer**: GPU-optimized high-performance data structure +- **Dynamic resizing**: Automatically expands buffer to power-of-two sizes +- **Batch processing**: Groups multiple rendering commands for GPU submission +- **Singleton pattern**: Centralized management via global `renderQueue` instance + +## インストール / Installation + +```bash npm install @next2d/render-queue ``` -## License +## ディレクトリ構成 / Directory Structure + +``` +packages/render-queue/ +├── src/ +│ ├── RenderQueue.ts # レンダーキュー管理クラス / Render queue management class +│ ├── RenderQueueUtil.ts # ユーティリティ関数 / Utility functions +│ └── index.ts # エクスポート定義 / Export definitions +├── README.md +└── package.json +``` + +### ファイル説明 / File Descriptions + +#### RenderQueue.ts +**日本語:** レンダーキューの主要クラス。Float32Array型のバッファを管理し、描画データの追加とリサイズを処理します。 + +**English:** Main render queue class. Manages Float32Array buffer and handles adding rendering data and resizing. + +**主要メソッド / Key Methods:** +- `push(...args: number[])`: 個別の数値をバッファに追加 / Add individual numbers to buffer +- `set(array: Float32Array | Uint8Array)`: 配列をバッファにセット / Set array to buffer +- `resize(length: number)`: バッファを拡張 / Expand buffer + +#### RenderQueueUtil.ts +**日本語:** バッファサイズの最適化のための2の累乗計算関数を提供します。 + +**English:** Provides power-of-two calculation function for buffer size optimization. + +**主要関数 / Key Functions:** +- `$upperPowerOfTwo(v: number)`: 指定値を2の累乗に切り上げ / Round up to power of two + +## レンダリングパイプラインにおける役割 / Role in Rendering Pipeline + +### 日本語 + +レンダーキューは、Next2D Playerのレンダリングパイプラインにおいて中心的な役割を果たします: + +1. **データ収集フェーズ**: DisplayObjectsのツリー構造を走査し、各オブジェクトの描画に必要なデータ(座標、色、変換行列など)をFloat32Arrayバッファに収集します。 + +2. **バッチ処理**: 複数のDisplayObjectsからのデータを単一のバッファにまとめることで、GPUへの呼び出し回数を削減し、レンダリングパフォーマンスを大幅に向上させます。 + +3. **GPU転送**: 収集されたデータはWebGL/WebGPUのバッファオブジェクトに転送され、シェーダープログラムで処理されます。 + +4. **動的メモリ管理**: 描画データ量に応じて自動的にバッファサイズを調整し、メモリ効率を最適化します。バッファは常に2の累乗サイズで管理されるため、GPUでの処理効率が向上します。 + +### English + +The render queue plays a central role in the Next2D Player rendering pipeline: + +1. **Data Collection Phase**: Traverses the DisplayObjects tree structure and collects data required for rendering each object (coordinates, colors, transformation matrices, etc.) into the Float32Array buffer. + +2. **Batch Processing**: Combines data from multiple DisplayObjects into a single buffer, reducing GPU call count and significantly improving rendering performance. + +3. **GPU Transfer**: Collected data is transferred to WebGL/WebGPU buffer objects and processed by shader programs. + +4. **Dynamic Memory Management**: Automatically adjusts buffer size according to rendering data volume, optimizing memory efficiency. Buffers are always managed in power-of-two sizes, improving GPU processing efficiency. + +## アーキテクチャ図 / Architecture Diagram + +```mermaid +graph TB + subgraph "DisplayObject Layer" + DO1[DisplayObject 1] + DO2[DisplayObject 2] + DO3[DisplayObject 3] + DON[DisplayObject N] + end + + subgraph "Render Queue Layer" + RQ[RenderQueue Instance] + Buffer[Float32Array Buffer] + Push[push method] + Set[set method] + Resize[resize method] + end + + subgraph "GPU Layer" + WebGL[WebGL Buffer] + WebGPU[WebGPU Buffer] + Shader[Shader Programs] + GPU[GPU Rendering] + end + + DO1 -->|Transform Data| Push + DO2 -->|Color Data| Push + DO3 -->|Vertex Data| Set + DON -->|Texture Coords| Set + + Push --> Buffer + Set --> Buffer + Buffer -.->|Capacity Check| Resize + Resize -.->|Expand to Power of 2| Buffer + + Buffer -->|Batch Transfer| WebGL + Buffer -->|Batch Transfer| WebGPU + WebGL --> Shader + WebGPU --> Shader + Shader --> GPU + + style RQ fill:#e1f5ff + style Buffer fill:#fff4e1 + style GPU fill:#ffe1e1 +``` + +### フロー説明 / Flow Description + +**日本語:** + +1. **データ追加**: 各DisplayObjectは、レンダリングに必要なデータ(変換行列、色、頂点座標、テクスチャ座標など)を`push()`または`set()`メソッドを通じてバッファに追加します。 + +2. **容量チェック**: データ追加時、バッファ容量が不足している場合、`resize()`メソッドが自動的に呼び出され、2の累乗サイズにバッファが拡張されます。 + +3. **バッチ転送**: フレームの描画準備が完了すると、蓄積されたバッファデータがWebGL/WebGPUのバッファオブジェクトに一括転送されます。 + +4. **GPU処理**: 転送されたデータはシェーダープログラムで処理され、最終的にGPUでレンダリングされます。 + +**English:** + +1. **Data Addition**: Each DisplayObject adds rendering data (transformation matrices, colors, vertex coordinates, texture coordinates, etc.) to the buffer via `push()` or `set()` methods. + +2. **Capacity Check**: When adding data, if buffer capacity is insufficient, the `resize()` method is automatically called to expand the buffer to a power-of-two size. + +3. **Batch Transfer**: Once frame rendering preparation is complete, accumulated buffer data is batch-transferred to WebGL/WebGPU buffer objects. + +4. **GPU Processing**: Transferred data is processed by shader programs and finally rendered by the GPU. + +## 使用例 / Usage Example + +```typescript +import { renderQueue } from "@next2d/render-queue"; + +// 個別の数値を追加 / Add individual numbers +renderQueue.push(1.0, 0.0, 0.0, 1.0); // 変換行列 / Transformation matrix +renderQueue.push(255, 128, 64, 255); // RGBA色 / RGBA color + +// 配列をセット / Set array +const vertices = new Float32Array([ + 0.0, 0.0, // 頂点1 / Vertex 1 + 1.0, 0.0, // 頂点2 / Vertex 2 + 1.0, 1.0, // 頂点3 / Vertex 3 + 0.0, 1.0 // 頂点4 / Vertex 4 +]); +renderQueue.set(vertices); + +// バッファデータを取得 / Get buffer data +const buffer = renderQueue.buffer; +const offset = renderQueue.offset; + +// フレーム終了後にリセット / Reset after frame +renderQueue.offset = 0; +``` + +## パフォーマンス最適化 / Performance Optimization + +### 日本語 + +1. **2の累乗リサイズ**: バッファサイズは常に2の累乗(256, 512, 1024, ...)で管理され、メモリアライメントとGPU効率が最適化されます。 + +2. **Float32Array使用**: JavaScriptの通常の配列ではなく、型付き配列を使用することで、メモリフットプリントを削減し、GPU転送を高速化します。 + +3. **シングルトンパターン**: グローバルな`renderQueue`インスタンスを使用することで、インスタンス生成のオーバーヘッドを排除します。 + +4. **バッチ処理**: 個別のGPU呼び出しを削減し、ドローコールを最小化することでレンダリング性能を向上させます。 + +### English + +1. **Power-of-Two Resizing**: Buffer sizes are always managed as powers of two (256, 512, 1024, ...), optimizing memory alignment and GPU efficiency. + +2. **Float32Array Usage**: Uses typed arrays instead of regular JavaScript arrays, reducing memory footprint and accelerating GPU transfer. + +3. **Singleton Pattern**: Uses a global `renderQueue` instance, eliminating instance creation overhead. + +4. **Batch Processing**: Improves rendering performance by reducing individual GPU calls and minimizing draw calls. + +## API リファレンス / API Reference + +### RenderQueue Class + +#### Properties + +| Property | Type | Description (日本語) | Description (English) | +|----------|------|---------------------|----------------------| +| `buffer` | `Float32Array` | レンダリングデータを格納するバッファ | Buffer storing rendering data | +| `offset` | `number` | バッファ内の現在の書き込み位置 | Current write position in buffer | + +#### Methods + +##### `push(...args: number[]): void` + +**日本語:** 可変長引数として渡された数値をバッファに追加します。必要に応じて自動的にバッファをリサイズします。 + +**English:** Adds numbers passed as variadic arguments to the buffer. Automatically resizes buffer if necessary. + +**Parameters:** +- `...args: number[]` - 追加する数値 / Numbers to add + +##### `set(array: Float32Array | Uint8Array): void` + +**日本語:** 型付き配列をバッファにセットします。大量のデータを一度に追加する際に`push()`より効率的です。 + +**English:** Sets typed array to buffer. More efficient than `push()` when adding large amounts of data at once. + +**Parameters:** +- `array: Float32Array | Uint8Array` - セットする配列 / Array to set + +##### `resize(length: number): void` + +**日本語:** バッファを指定された長さに対応できるよう、2の累乗サイズにリサイズします。既存のデータは保持されます。 + +**English:** Resizes buffer to power-of-two size to accommodate specified length. Existing data is preserved. + +**Parameters:** +- `length: number` - 必要な追加容量 / Required additional capacity + +### Utility Functions + +#### `$upperPowerOfTwo(v: number): number` + +**日本語:** 指定された値を2の累乗に切り上げます。バッファサイズの最適化に使用されます。 + +**English:** Rounds up specified value to power of two. Used for buffer size optimization. + +**Parameters:** +- `v: number` - 入力値 / Input value + +**Returns:** +- `number` - 2の累乗に切り上げられた値 / Value rounded up to power of two + +**Example:** +```typescript +$upperPowerOfTwo(100); // 128 +$upperPowerOfTwo(256); // 256 +$upperPowerOfTwo(300); // 512 +``` + +## ライセンス / License + This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [LICENSE](LICENSE) file for details. diff --git a/packages/render-queue/src/RenderQueue.ts b/packages/render-queue/src/RenderQueue.ts index d3c2a47f..ba18ffdb 100644 --- a/packages/render-queue/src/RenderQueue.ts +++ b/packages/render-queue/src/RenderQueue.ts @@ -1,51 +1,16 @@ import { $upperPowerOfTwo } from "./RenderQueueUtil"; -/** - * @description レンダーキューの管理クラス - * Management class of the render queue - * - * @class - * @public - */ class RenderQueue { - /** - * @description バッファ - * Buffer - * - * @type {Float32Array} - * @public - */ public buffer: Float32Array; - - /** - * @description オフセット - * Offset - * - * @type {number} - * @public - */ public offset: number; - /** - * @constructor - * @public - */ constructor () { this.buffer = new Float32Array(256); this.offset = 0; } - /** - * @description バッファにデータを追加 - * Add data to the buffer - * - * @param {...number} args - * @return {void} - * @method - * @public - */ push (...args: number[]): void { if (this.buffer.length < this.offset + args.length) { @@ -57,15 +22,209 @@ class RenderQueue } } - /** - * @description バッファをセット - * Set the buffer - * - * @param {Float32Array | Uint8Array} args - * @return {void} - * @method - * @public - */ + pushDisplayObjectBuffer ( + a: number, b: number, c: number, d: number, + e: number, f: number, g: number, h: number, + i: number, j: number, k: number, l: number, + m: number, n: number, o: number, p: number, + q: number, r: number, s: number, t: number, + u: number, v: number + ): void { + if (this.buffer.length < this.offset + 22) { + this.resize(22); + } + + this.buffer[this.offset++] = a; + this.buffer[this.offset++] = b; + this.buffer[this.offset++] = c; + this.buffer[this.offset++] = d; + this.buffer[this.offset++] = e; + this.buffer[this.offset++] = f; + this.buffer[this.offset++] = g; + this.buffer[this.offset++] = h; + this.buffer[this.offset++] = i; + this.buffer[this.offset++] = j; + this.buffer[this.offset++] = k; + this.buffer[this.offset++] = l; + this.buffer[this.offset++] = m; + this.buffer[this.offset++] = n; + this.buffer[this.offset++] = o; + this.buffer[this.offset++] = p; + this.buffer[this.offset++] = q; + this.buffer[this.offset++] = r; + this.buffer[this.offset++] = s; + this.buffer[this.offset++] = t; + this.buffer[this.offset++] = u; + this.buffer[this.offset++] = v; + } + + pushInstanceBuffer ( + a: number, b: number, c: number, d: number, + e: number, f: number, g: number, h: number, + i: number, j: number, k: number, l: number, + m: number, n: number, o: number, p: number, + q: number, r: number, s: number, t: number, + u: number, v: number, w: number, x: number + ): void { + if (this.buffer.length < this.offset + 24) { + this.resize(24); + } + + this.buffer[this.offset++] = a; + this.buffer[this.offset++] = b; + this.buffer[this.offset++] = c; + this.buffer[this.offset++] = d; + this.buffer[this.offset++] = e; + this.buffer[this.offset++] = f; + this.buffer[this.offset++] = g; + this.buffer[this.offset++] = h; + this.buffer[this.offset++] = i; + this.buffer[this.offset++] = j; + this.buffer[this.offset++] = k; + this.buffer[this.offset++] = l; + this.buffer[this.offset++] = m; + this.buffer[this.offset++] = n; + this.buffer[this.offset++] = o; + this.buffer[this.offset++] = p; + this.buffer[this.offset++] = q; + this.buffer[this.offset++] = r; + this.buffer[this.offset++] = s; + this.buffer[this.offset++] = t; + this.buffer[this.offset++] = u; + this.buffer[this.offset++] = v; + this.buffer[this.offset++] = w; + this.buffer[this.offset++] = x; + } + + pushShapeBuffer ( + a: number, b: number, c: number, d: number, e: number, f: number, + g: number, h: number, i: number, j: number, k: number, l: number, + m: number, n: number, o: number, p: number, q: number, r: number, + s: number, t: number, u: number, v: number, w: number, x: number, + y: number, z: number, a1: number, b1: number, c1: number, + d1: number, e1: number, f1: number + ): void { + if (this.buffer.length < this.offset + 32) { + this.resize(32); + } + + this.buffer[this.offset++] = a; + this.buffer[this.offset++] = b; + this.buffer[this.offset++] = c; + this.buffer[this.offset++] = d; + this.buffer[this.offset++] = e; + this.buffer[this.offset++] = f; + this.buffer[this.offset++] = g; + this.buffer[this.offset++] = h; + this.buffer[this.offset++] = i; + this.buffer[this.offset++] = j; + this.buffer[this.offset++] = k; + this.buffer[this.offset++] = l; + this.buffer[this.offset++] = m; + this.buffer[this.offset++] = n; + this.buffer[this.offset++] = o; + this.buffer[this.offset++] = p; + this.buffer[this.offset++] = q; + this.buffer[this.offset++] = r; + this.buffer[this.offset++] = s; + this.buffer[this.offset++] = t; + this.buffer[this.offset++] = u; + this.buffer[this.offset++] = v; + this.buffer[this.offset++] = w; + this.buffer[this.offset++] = x; + this.buffer[this.offset++] = y; + this.buffer[this.offset++] = z; + this.buffer[this.offset++] = a1; + this.buffer[this.offset++] = b1; + this.buffer[this.offset++] = c1; + this.buffer[this.offset++] = d1; + this.buffer[this.offset++] = e1; + this.buffer[this.offset++] = f1; + } + + pushTextFieldBuffer ( + a: number, b: number, c: number, d: number, e: number, f: number, + g: number, h: number, i: number, j: number, k: number, l: number, + m: number, n: number, o: number, p: number, q: number, r: number, + s: number, t: number, u: number, v: number, w: number, x: number, + y: number, z: number, a1: number, b1: number, c1: number, d1: number + ): void { + if (this.buffer.length < this.offset + 30) { + this.resize(30); + } + + this.buffer[this.offset++] = a; + this.buffer[this.offset++] = b; + this.buffer[this.offset++] = c; + this.buffer[this.offset++] = d; + this.buffer[this.offset++] = e; + this.buffer[this.offset++] = f; + this.buffer[this.offset++] = g; + this.buffer[this.offset++] = h; + this.buffer[this.offset++] = i; + this.buffer[this.offset++] = j; + this.buffer[this.offset++] = k; + this.buffer[this.offset++] = l; + this.buffer[this.offset++] = m; + this.buffer[this.offset++] = n; + this.buffer[this.offset++] = o; + this.buffer[this.offset++] = p; + this.buffer[this.offset++] = q; + this.buffer[this.offset++] = r; + this.buffer[this.offset++] = s; + this.buffer[this.offset++] = t; + this.buffer[this.offset++] = u; + this.buffer[this.offset++] = v; + this.buffer[this.offset++] = w; + this.buffer[this.offset++] = x; + this.buffer[this.offset++] = y; + this.buffer[this.offset++] = z; + this.buffer[this.offset++] = a1; + this.buffer[this.offset++] = b1; + this.buffer[this.offset++] = c1; + this.buffer[this.offset++] = d1; + } + + pushVideoBuffer ( + a: number, b: number, c: number, d: number, e: number, f: number, + g: number, h: number, i: number, j: number, k: number, l: number, + m: number, n: number, o: number, p: number, q: number, r: number, + s: number, t: number, u: number, v: number, w: number, x: number, + y: number, z: number, a1: number + ): void { + if (this.buffer.length < this.offset + 27) { + this.resize(27); + } + + this.buffer[this.offset++] = a; + this.buffer[this.offset++] = b; + this.buffer[this.offset++] = c; + this.buffer[this.offset++] = d; + this.buffer[this.offset++] = e; + this.buffer[this.offset++] = f; + this.buffer[this.offset++] = g; + this.buffer[this.offset++] = h; + this.buffer[this.offset++] = i; + this.buffer[this.offset++] = j; + this.buffer[this.offset++] = k; + this.buffer[this.offset++] = l; + this.buffer[this.offset++] = m; + this.buffer[this.offset++] = n; + this.buffer[this.offset++] = o; + this.buffer[this.offset++] = p; + this.buffer[this.offset++] = q; + this.buffer[this.offset++] = r; + this.buffer[this.offset++] = s; + this.buffer[this.offset++] = t; + this.buffer[this.offset++] = u; + this.buffer[this.offset++] = v; + this.buffer[this.offset++] = w; + this.buffer[this.offset++] = x; + this.buffer[this.offset++] = y; + this.buffer[this.offset++] = z; + this.buffer[this.offset++] = a1; + } + set (array: Float32Array | Uint8Array): void { if (this.buffer.length < this.offset + array.length) { @@ -76,15 +235,6 @@ class RenderQueue this.offset += array.length; } - /** - * @description バッファをリサイズ - * Resize the buffer - * - * @param {number} length - * @return {void} - * @method - * @public - */ resize (length: number): void { const newBuffer = new Float32Array( @@ -98,4 +248,4 @@ class RenderQueue } } -export const renderQueue = new RenderQueue(); \ No newline at end of file +export const renderQueue = new RenderQueue(); diff --git a/packages/renderer/README.md b/packages/renderer/README.md index 14d387bf..f14c099c 100644 --- a/packages/renderer/README.md +++ b/packages/renderer/README.md @@ -1,11 +1,198 @@ -@next2d/renderer -============= +# @next2d/renderer -## Installation +## 概要 / Overview -``` +`@next2d/renderer`は、Web Workerで動作するOffscreenCanvasベースのレンダリングエンジンです。Next2Dアプリケーション向けに高性能でノンブロッキングなレンダリングを提供します。レンダリング処理を別スレッドにオフロードすることで、スムーズなUI操作と効率的なグラフィックス処理を実現します。 + +`@next2d/renderer` is an OffscreenCanvas-based rendering engine that runs in a Web Worker, providing high-performance, non-blocking rendering for Next2D applications. By offloading rendering operations to a separate thread, it ensures smooth UI interactions and efficient graphics processing. + +## 主な特徴 / Key Features + +- **Web Workerアーキテクチャ**: ノンブロッキングレンダリングのため完全にWeb Worker内で動作 + - Runs entirely in a Web Worker for non-blocking rendering +- **OffscreenCanvas**: ハードウェアアクセラレーションによるグラフィックス処理にOffscreenCanvas APIを活用 + - Leverages OffscreenCanvas API for hardware-accelerated graphics +- **コマンドキューパターン**: レンダリングコマンドを非同期で効率的に処理 + - Efficiently processes rendering commands asynchronously +- **モジュラー設計**: 各表示オブジェクトタイプごとにサービスとユースケースをクリーンに分離 + - Clean separation of services and use cases for each display object type + +## インストール / Installation + +```bash npm install @next2d/renderer ``` -## License -This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [LICENSE](LICENSE) file for details. +## アーキテクチャ / Architecture + +### ディレクトリ構造 / Directory Structure + +``` +src/ +├── index.ts # Workerエントリポイント / Worker entry point +├── CommandController.ts # メインコマンドキューコントローラー / Main command queue controller +├── RendererUtil.ts # レンダリング用ユーティリティ関数 / Utility functions for rendering +├── Command/ +│ ├── service/ # コマンド処理サービス / Command processing services +│ │ ├── CommandInitializeContextService.ts +│ │ ├── CommandResizeService.ts +│ │ └── CommandRemoveCacheService.ts +│ └── usecase/ # コマンドユースケース / Command use cases +│ ├── CommandRenderUseCase.ts +│ └── CommandCaptureUseCase.ts +├── DisplayObject/ +│ └── service/ # 基本表示オブジェクトサービス / Base display object services +│ └── DisplayObjectGetBlendModeService.ts +├── DisplayObjectContainer/ +│ └── usecase/ # コンテナレンダリングロジック / Container rendering logic +│ ├── DisplayObjectContainerRenderUseCase.ts +│ └── DisplayObjectContainerClipRenderUseCase.ts +├── Shape/ +│ ├── service/ # Shapeコマンド処理 / Shape command processing +│ │ └── ShapeCommandService.ts +│ └── usecase/ # Shapeレンダリングロジック / Shape rendering logic +│ ├── ShapeRenderUseCase.ts +│ └── ShapeClipRenderUseCase.ts +├── TextField/ +│ ├── service/ # テキスト処理サービス / Text processing services +│ │ ├── TextFieldGenerateFontStyleService.ts +│ │ └── TextFiledGetAlignOffsetService.ts +│ └── usecase/ # テキストレンダリングロジック / Text rendering logic +│ ├── TextFieldRenderUseCase.ts +│ └── TextFieldDrawOffscreenCanvasUseCase.ts +├── Video/ +│ └── usecase/ # ビデオレンダリングロジック / Video rendering logic +│ └── VideoRenderUseCase.ts +└── interface/ # TypeScriptインターフェース / TypeScript interfaces + ├── IMessage.ts + ├── INode.ts + ├── IRGBA.ts + ├── IBlendMode.ts + ├── ITextFormat.ts + ├── ITextObject.ts + └── ... +``` + +### コンポーネントの役割 / Component Roles + +- **index.ts**: メッセージイベントリスナーをセットアップするWorkerエントリポイント / Worker entry point that sets up the message event listener +- **CommandController**: コマンドキューを管理し、適切なハンドラーにコマンドをディスパッチ / Manages the command queue and dispatches commands to appropriate handlers +- **Command/service/**: 低レベルのコマンド処理(コンテキスト初期化、リサイズ、キャッシュ削除) / Low-level command processing (context initialization, resize, cache removal) +- **Command/usecase/**: 高レベルのコマンド操作(レンダリング、キャプチャ) / High-level command operations (render, capture) +- **DisplayObject/service/**: すべての表示オブジェクト共通のサービス / Shared services for all display objects +- **DisplayObjectContainer/usecase/**: コンテナオブジェクトのレンダリングロジック / Rendering logic for container objects +- **Shape/service/ & usecase/**: ベクターグラフィックスのレンダリング / Vector graphics rendering +- **TextField/service/ & usecase/**: テキストレンダリングとフォント処理 / Text rendering and font processing +- **Video/usecase/**: ビデオ要素のレンダリング / Video element rendering +- **RendererUtil.ts**: レンダリング操作用の共通ユーティリティ関数 / Common utility functions for rendering operations +- **interface/**: TypeScript型定義とインターフェース / TypeScript type definitions and interfaces + +## メッセージフロー / Message Flow + +```mermaid +sequenceDiagram + participant MT as Main Thread / メインスレッド + participant WW as Web Worker + participant CC as CommandController + participant UC as Use Case / ユースケース + participant Ctx as OffscreenCanvas Context + + MT->>WW: postMessage(command) + WW->>CC: queue.push(message) + + alt state === "deactivate" + CC->>CC: state = "active" + loop while queue.length > 0 + CC->>CC: message = queue.shift() + + alt command === "initialize" + CC->>UC: commandInitializeContextService() + UC->>Ctx: setup OffscreenCanvas context / コンテキストをセットアップ + else command === "resize" + CC->>UC: commandResizeService() + UC->>Ctx: resize canvas / キャンバスをリサイズ + else command === "render" + CC->>UC: commandRenderUseCase() + UC->>Ctx: render display objects / 表示オブジェクトをレンダリング + UC->>CC: rendering complete / レンダリング完了 + CC->>MT: postMessage({ message: "render", buffer }) + else command === "capture" + CC->>UC: commandCaptureUseCase() + UC->>Ctx: capture frame / フレームをキャプチャ + UC->>CC: capture complete / キャプチャ完了 + CC->>MT: postMessage({ message: "capture", imageBitmap }) + else command === "removeCache" + CC->>UC: commandRemoveCacheService() + else command === "cacheClear" + CC->>UC: $cacheStore.reset() + end + end + CC->>CC: state = "deactivate" + end +``` + +## コマンドキューパターン / Command Queue Pattern + +レンダラーは、非同期レンダリング操作を効率的に管理するためにコマンドキューパターンを使用しています。 + +The renderer uses a command queue pattern to efficiently manage asynchronous rendering operations. + +### キュー管理 / Queue Management + +1. **メッセージ受信 / Message Reception**: メインスレッドからメッセージが到着すると、`queue`配列にプッシュされます / When a message arrives from the main thread, it's pushed into the `queue` array +2. **状態チェック / State Check**: Workerが`deactivate`状態(アイドル)の場合、実行を開始します / If the worker is in `deactivate` state (idle), it begins execution +3. **逐次処理 / Sequential Processing**: `queue.shift()`を使用してコマンドを1つずつ処理します / Commands are processed one by one using `queue.shift()` +4. **状態管理 / State Management**: Workerは`active`と`deactivate`状態の間で遷移します / The worker transitions between `active` and `deactivate` states + +### サポートされるコマンド / Supported Commands + +- **initialize**: デバイスピクセル比を使用してOffscreenCanvasコンテキストをセットアップ / Set up OffscreenCanvas context with device pixel ratio +- **resize**: キャンバスの寸法を更新し、必要に応じてキャッシュをクリア / Update canvas dimensions and clear cache if needed +- **render**: 表示オブジェクトのレンダリングパイプラインを実行 / Execute rendering pipeline for display objects +- **capture**: 現在のフレームをImageBitmapとしてキャプチャ / Capture current frame as ImageBitmap +- **removeCache**: 特定のキャッシュアイテムを削除 / Remove specific cached items +- **cacheClear**: キャッシュストア全体をクリア / Clear entire cache store + +### メリット / Benefits + +- **ノンブロッキング / Non-blocking**: レンダリング中もメインスレッドはレスポンシブなまま / Main thread remains responsive during rendering +- **効率的 / Efficient**: コマンドはバッチ処理され、非同期で処理されます / Commands are batched and processed asynchronously +- **スレッドセーフ / Thread-safe**: Workerの分離により競合状態を防止 / Worker isolation prevents race conditions +- **転送可能オブジェクト / Transferable Objects**: ゼロコピーメッセージパッシングのために転送可能オブジェクト(ArrayBuffer、ImageBitmap)を使用 / Uses Transferable objects (ArrayBuffer, ImageBitmap) for zero-copy message passing + +## 使用例 / Usage Example + +```typescript +// メインスレッド / Main thread +const worker = new Worker('renderer.js'); +const canvas = document.getElementById('canvas') as HTMLCanvasElement; +const offscreen = canvas.transferControlToOffscreen(); + +// レンダラーの初期化 / Initialize renderer +worker.postMessage({ + command: 'initialize', + canvas: offscreen, + devicePixelRatio: window.devicePixelRatio +}, [offscreen]); + +// フレームのレンダリング / Render frame +worker.postMessage({ + command: 'render', + buffer: renderDataBuffer, + length: dataLength, + imageBitmaps: bitmaps +}, [renderDataBuffer.buffer, ...bitmaps]); + +// 完了の待機 / Listen for completion +worker.addEventListener('message', (event) => { + if (event.data.message === 'render') { + // レンダリング完了、バッファが返却されました + // Rendering complete, buffer returned + console.log('フレームがレンダリングされました / Frame rendered'); + } +}); +``` + +## ライセンス / License + +This project is licensed under the [MIT License](LICENSE) - see the LICENSE file for details. diff --git a/packages/renderer/package.json b/packages/renderer/package.json index ed0506d2..9d18319f 100644 --- a/packages/renderer/package.json +++ b/packages/renderer/package.json @@ -25,6 +25,7 @@ }, "peerDependencies": { "@next2d/webgl": "file:../webgl", + "@next2d/webgpu": "file:../webgpu", "@next2d/cache": "file:../cache", "@next2d/texture-packer": "file:../texture-packer" } diff --git a/packages/renderer/src/Command/service/CommandInitializeContextService.test.ts b/packages/renderer/src/Command/service/CommandInitializeContextService.test.ts index 0d8475e3..060f232a 100644 --- a/packages/renderer/src/Command/service/CommandInitializeContextService.test.ts +++ b/packages/renderer/src/Command/service/CommandInitializeContextService.test.ts @@ -16,6 +16,8 @@ describe("CommandInitializeContextService.js test", () => return { "clearColor": vi.fn(), + "frontFace": vi.fn(), + "CCW": 1, "getParameter": vi.fn(), "createFramebuffer": vi.fn(), "bindFramebuffer": vi.fn(), diff --git a/packages/renderer/src/Command/service/CommandInitializeContextService.ts b/packages/renderer/src/Command/service/CommandInitializeContextService.ts index 985b49ba..5db254a7 100644 --- a/packages/renderer/src/Command/service/CommandInitializeContextService.ts +++ b/packages/renderer/src/Command/service/CommandInitializeContextService.ts @@ -1,4 +1,5 @@ -import { Context } from "@next2d/webgl"; +import { Context as WebGLContext } from "@next2d/webgl"; +import { Context as WebGPUContext } from "@next2d/webgpu"; import { $setCanvas, $setContext, @@ -6,31 +7,65 @@ import { } from "../../RendererUtil"; /** - * @description OffscreenCanvasからWebGL2のコンテキストを取得 - * Get WebGL2 context from OffscreenCanvas + * @description 開発時用のフラグ + * Flag for development use + * + * @type {boolean} + * @private + */ +const useWebGPU: boolean = true; + +/** + * @description OffscreenCanvasからWebGL2またはWebGPUのコンテキストを取得 + * Get WebGL2 or WebGPU context from OffscreenCanvas * * @param {OffscreenCanvas} canvas * @param {number} device_pixel_ratio - * @return {void} + * @return {Promise} * @method * @public */ -export const execute = (canvas: OffscreenCanvas, device_pixel_ratio: number): void => -{ +export const execute = async ( + canvas: OffscreenCanvas, + device_pixel_ratio: number +): Promise => { + // Set OffscreenCanvas $setCanvas(canvas); - const gl: WebGL2RenderingContext | null = canvas.getContext("webgl2", { - "stencil": true, - "premultipliedAlpha": true, - "antialias": false, - "depth": false - }); + if (useWebGPU && "gpu" in navigator) { + const gpu = navigator.gpu as GPU; + const adapter = await gpu.requestAdapter(); + if (!adapter) { + throw new Error("WebGPU adapter not available"); + } - if (!gl) { - throw new Error("webgl2 is not supported."); - } + const device = await adapter.requestDevice(); + if (!device) { + throw new Error("WebGPU device not available"); + } - // Set CanvasToWebGLContext - $setContext(new Context(gl, $samples, device_pixel_ratio)); + const context = canvas.getContext("webgpu"); + if (!context) { + throw new Error("WebGPU context not available"); + } + + const preferredFormat = gpu.getPreferredCanvasFormat(); + $setContext(new WebGPUContext(device, context, preferredFormat, device_pixel_ratio)); + + } else { + const gl: WebGL2RenderingContext | null = canvas.getContext("webgl2", { + "stencil": true, + "premultipliedAlpha": true, + "antialias": false, + "depth": false + }); + + if (!gl) { + throw new Error("webgl2 is not supported."); + } + + // Set CanvasToWebGLContext + $setContext(new WebGLContext(gl, $samples, device_pixel_ratio)); + } }; \ No newline at end of file diff --git a/packages/renderer/src/Command/usecase/CommandCaptureUseCase.ts b/packages/renderer/src/Command/usecase/CommandCaptureUseCase.ts index 29d9fd17..421d3a67 100644 --- a/packages/renderer/src/Command/usecase/CommandCaptureUseCase.ts +++ b/packages/renderer/src/Command/usecase/CommandCaptureUseCase.ts @@ -35,6 +35,13 @@ export const execute = async ( // reset $context.reset(); $context.setTransform(1, 0, 0, 1, 0, 0); + + // cache current background color + const red = $context.$clearColorR; + const green = $context.$clearColorG; + const blue = $context.$clearColorB; + const alpha = $context.$clearColorA; + $context.updateBackgroundColor( (bg_color >> 16 & 0xff) / 255, (bg_color >> 8 & 0xff) / 255, @@ -79,5 +86,10 @@ export const execute = async ( $context.drawArraysInstanced(); - return await $context.createImageBitmap(width, height); + const imageBitmap = await $context.createImageBitmap(width, height); + + // reset background color + $context.updateBackgroundColor(red, green, blue, alpha); + + return imageBitmap; }; \ No newline at end of file diff --git a/packages/renderer/src/CommandController.ts b/packages/renderer/src/CommandController.ts index 1734caf2..722aa0ee 100644 --- a/packages/renderer/src/CommandController.ts +++ b/packages/renderer/src/CommandController.ts @@ -84,7 +84,7 @@ export class CommandController break; case "initialize": - commandInitializeContextService( + await commandInitializeContextService( object.canvas as OffscreenCanvas, object.devicePixelRatio as number ); diff --git a/packages/renderer/src/DisplayObjectContainer/usecase/DisplayObjectContainerRenderUseCase.ts b/packages/renderer/src/DisplayObjectContainer/usecase/DisplayObjectContainerRenderUseCase.ts index 42391a3b..04e29959 100644 --- a/packages/renderer/src/DisplayObjectContainer/usecase/DisplayObjectContainerRenderUseCase.ts +++ b/packages/renderer/src/DisplayObjectContainer/usecase/DisplayObjectContainerRenderUseCase.ts @@ -4,6 +4,7 @@ import { execute as shapeClipRenderUseCase } from "../../Shape/usecase/ShapeClip import { execute as textFieldRenderUseCase } from "../../TextField/usecase/TextFieldRenderUseCase"; import { execute as videoRenderUseCase } from "../../Video/usecase/VideoRenderUseCase"; import { execute as displayObjectContainerClipRenderUseCase } from "./DisplayObjectContainerClipRenderUseCase"; +import { execute as displayObjectGetBlendModeService } from "../../DisplayObject/service/DisplayObjectGetBlendModeService"; /** * @description DisplayObjectContainerの描画を実行します。 @@ -22,6 +23,82 @@ export const execute = ( image_bitmaps: ImageBitmap[] | null ): number => { + let endClipDepth = 0; + let canRenderMask = true; + + // use layer + const blendMode = displayObjectGetBlendModeService(render_queue[index++]); + const useLayer = Boolean(render_queue[index++]); + + // layer size + let layerWidth = 0; + let layerHeight = 0; + + let useFilter = false; + let uniqueKey = ""; + let filterKey = ""; + let filterBounds: Float32Array | null = null; + let filterParams: Float32Array | null = null; + let matrix: Float32Array | null = null; + let colorTransform: Float32Array | null = null; + if (useLayer) { + + layerWidth = render_queue[index++]; + layerHeight = render_queue[index++]; + + useFilter = Boolean(render_queue[index++]); + + if (useFilter) { + // フィルターパス: filterCache/uniqueKey/filterKey を読む + const filterCache = Boolean(render_queue[index++]); + uniqueKey = `${render_queue[index++]}`; + filterKey = `${render_queue[index++]}`; + if (filterCache) { + filterBounds = render_queue.subarray(index, index + 4); + index += 4; + + matrix = render_queue.subarray(index, index + 6); + index += 6; + + colorTransform = render_queue.subarray(index, index + 8); + index += 8; + + // キャッシュされたフィルターテクスチャを描画 + $context.containerDrawCachedFilter( + blendMode, matrix, colorTransform, + filterBounds, uniqueKey, filterKey + ); + return index; + } + + filterBounds = render_queue.subarray(index, index + 4); + index += 4; + + matrix = render_queue.subarray(index, index + 6); + index += 6; + + colorTransform = render_queue.subarray(index, index + 8); + index += 8; + + const length = render_queue[index++]; + filterParams = render_queue.subarray(index, index + length); + index += length; + + } else { + // ブレンドのみパス: matrix + colorTransform + matrix = render_queue.subarray(index, index + 6); + index += 6; + + colorTransform = render_queue.subarray(index, index + 8); + index += 8; + } + } + + // コンテナのフィルター/ブレンド用にレイヤーを開始 + if (useLayer) { + $context.containerBeginLayer(layerWidth, layerHeight); + } + const useMaskDisplayObject = Boolean(render_queue[index++]); if (useMaskDisplayObject) { @@ -60,9 +137,6 @@ export const execute = ( $context.endMask(); } - let endClipDepth = 0; - let canRenderMask = true; - const length = render_queue[index++]; for (let idx = 0; length > idx; idx++) { @@ -133,7 +207,8 @@ export const execute = ( } // hidden - if (!render_queue[index++]) { + const hidden = render_queue[index++]; + if (!hidden) { continue; } @@ -157,7 +232,6 @@ export const execute = ( break; default: - console.error("unknown type", type); break; } @@ -169,5 +243,14 @@ export const execute = ( $context.leaveMask(); } + // コンテナのフィルター/ブレンド結果をメインに合成 + if (useLayer) { + $context.containerEndLayer( + blendMode, matrix!, colorTransform, + useFilter, filterBounds, filterParams, + uniqueKey, filterKey + ); + } + return index; -}; \ No newline at end of file +}; diff --git a/packages/renderer/src/RendererUtil.ts b/packages/renderer/src/RendererUtil.ts index 46702bda..f5ca0a7a 100644 --- a/packages/renderer/src/RendererUtil.ts +++ b/packages/renderer/src/RendererUtil.ts @@ -1,4 +1,5 @@ -import type { Context } from "@next2d/webgl"; +import type { Context as WebGLContext } from "@next2d/webgl"; +import type { Context as WebGPUContext } from "@next2d/webgpu"; import type { IRGBA } from "./interface/IRGBA"; /** @@ -8,21 +9,21 @@ import type { IRGBA } from "./interface/IRGBA"; export const $samples: number = 4; /** - * @type {Context} + * @type {WebGLContext | WebGPUContext} * @public */ -export let $context: Context; +export let $context: WebGLContext | WebGPUContext; /** - * @description Next2DのWebGLの描画コンテキストを設定 - * Set the drawing context of Next2D's WebGL + * @description Next2Dの描画コンテキストを設定(WebGLまたはWebGPU) + * Set the drawing context of Next2D (WebGL or WebGPU) * - * @param {number} context + * @param {WebGLContext | WebGPUContext} context * @return {void} * @method * @public */ -export const $setContext = (context: Context): void => +export const $setContext = (context: WebGLContext | WebGPUContext): void => { $context = context; }; diff --git a/packages/renderer/src/Shape/service/ShapeCommandService.ts b/packages/renderer/src/Shape/service/ShapeCommandService.ts index 33cfc138..f026b07d 100644 --- a/packages/renderer/src/Shape/service/ShapeCommandService.ts +++ b/packages/renderer/src/Shape/service/ShapeCommandService.ts @@ -214,10 +214,8 @@ export const execute = ( ); } - const matrix = new Float32Array([ - commands[index++], commands[index++], commands[index++], - commands[index++], commands[index++], commands[index++] - ]); + const matrix = commands.subarray(index, index + 6); + index += 6; const spread = commands[index++]; const interpolation = commands[index++]; @@ -250,10 +248,8 @@ export const execute = ( ); index += length; - const matrix = new Float32Array([ - commands[index++], commands[index++], commands[index++], - commands[index++], commands[index++], commands[index++] - ]); + const matrix = commands.subarray(index, index + 6); + index += 6; $context.bitmapFill( buffer, matrix, width, height, @@ -290,10 +286,8 @@ export const execute = ( ); } - const matrix = new Float32Array([ - commands[index++], commands[index++], commands[index++], - commands[index++], commands[index++], commands[index++] - ]); + const matrix = commands.subarray(index, index + 6); + index += 6; const spread = commands[index++]; const interpolation = commands[index++]; @@ -331,10 +325,8 @@ export const execute = ( ); index += length; - const matrix = new Float32Array([ - commands[index++], commands[index++], commands[index++], - commands[index++], commands[index++], commands[index++] - ]); + const matrix = commands.subarray(index, index + 6); + index += 6; $context.bitmapStroke( buffer, matrix, width, height, diff --git a/packages/renderer/src/Shape/usecase/ShapeRenderUseCase.ts b/packages/renderer/src/Shape/usecase/ShapeRenderUseCase.ts index f2e0a84d..923fd353 100644 --- a/packages/renderer/src/Shape/usecase/ShapeRenderUseCase.ts +++ b/packages/renderer/src/Shape/usecase/ShapeRenderUseCase.ts @@ -39,15 +39,11 @@ export const execute = (render_queue: Float32Array, index: number): number => const uniqueKey = `${render_queue[index++]}`; const cacheKey = render_queue[index++]; - const xScale = Math.sqrt( - matrix[0] * matrix[0] - + matrix[1] * matrix[1] - ); + const xScale = render_queue[index++]; + const yScale = render_queue[index++]; - const yScale = Math.sqrt( - matrix[2] * matrix[2] - + matrix[3] * matrix[3] - ); + // フィルターキャッシュ用のユニークキー(instanceId) + const filterKey = `${render_queue[index++]}`; let node: Node; const hasCache = render_queue[index++]; @@ -79,12 +75,15 @@ export const execute = (render_queue: Float32Array, index: number): number => // fixed logic const currentAttachment = $context.currentAttachmentObject; - $context.bind($context.atlasAttachmentObject); + const atlasAttachment = $context.atlasAttachmentObject; + if (atlasAttachment) { + $context.bind(atlasAttachment as any); + } $context.reset(); $context.beginNodeRendering(node); - const offsetY = $context.atlasAttachmentObject.height - node.y - height; + const offsetY = atlasAttachment ? atlasAttachment.height - node.y - height : 0; $context.setTransform(1, 0, 0, 1, node.x, offsetY @@ -100,7 +99,7 @@ export const execute = (render_queue: Float32Array, index: number): number => $context.endNodeRendering(); if (currentAttachment) { - $context.bind(currentAttachment); + $context.bind(currentAttachment as any); } } else { @@ -114,14 +113,17 @@ export const execute = (render_queue: Float32Array, index: number): number => // fixed logic const currentAttachment = $context.currentAttachmentObject; - $context.bind($context.atlasAttachmentObject); + const atlasAttachment = $context.atlasAttachmentObject; + if (atlasAttachment) { + $context.bind(atlasAttachment as any); + } // 初期化して、描画範囲を初期化 $context.reset(); $context.beginNodeRendering(node); // matrix設定 - const offsetY = $context.atlasAttachmentObject.height - node.y - height; + const offsetY = atlasAttachment ? atlasAttachment.height - node.y - height : 0; $context.setTransform( xScale, 0, 0, yScale, -xMin * xScale + node.x, @@ -143,7 +145,7 @@ export const execute = (render_queue: Float32Array, index: number): number => $context.endNodeRendering(); if (currentAttachment) { - $context.bind(currentAttachment); + $context.bind(currentAttachment as any); } } @@ -172,7 +174,7 @@ export const execute = (render_queue: Float32Array, index: number): number => const height = Math.ceil(Math.abs(bounds[3] - bounds[1])); $context.applyFilter( - node, uniqueKey, updated, + node, filterKey, updated, width, height, isBitmap, matrix, colorTransform, displayObjectGetBlendModeService(blendMode), filterBounds, params diff --git a/packages/renderer/src/TextField/usecase/TextFieldRenderUseCase.ts b/packages/renderer/src/TextField/usecase/TextFieldRenderUseCase.ts index 816bfe1c..52cf396f 100644 --- a/packages/renderer/src/TextField/usecase/TextFieldRenderUseCase.ts +++ b/packages/renderer/src/TextField/usecase/TextFieldRenderUseCase.ts @@ -46,15 +46,11 @@ export const execute = (render_queue: Float32Array, index: number): number => // text state const changed = Boolean(render_queue[index++]); - const xScale = Math.sqrt( - matrix[0] * matrix[0] - + matrix[1] * matrix[1] - ); + const xScale = render_queue[index++]; + const yScale = render_queue[index++]; - const yScale = Math.sqrt( - matrix[2] * matrix[2] - + matrix[3] * matrix[3] - ); + // フィルターキャッシュ用のユニークキー(instanceId) + const filterKey = `${render_queue[index++]}`; let node: Node; const hasCache = render_queue[index++]; @@ -131,12 +127,15 @@ export const execute = (render_queue: Float32Array, index: number): number => // fixed logic const currentAttachment = $context.currentAttachmentObject; - $context.bind($context.atlasAttachmentObject); + const atlasAttachment = $context.atlasAttachmentObject; + if (atlasAttachment) { + $context.bind(atlasAttachment as any); + } $context.reset(); $context.beginNodeRendering(node); - const offsetY = $context.atlasAttachmentObject.height - node.y - height; + const offsetY = atlasAttachment ? atlasAttachment.height - node.y - height : 0; $context.setTransform(1, 0, 0, 1, node.x, offsetY @@ -147,7 +146,7 @@ export const execute = (render_queue: Float32Array, index: number): number => $context.endNodeRendering(); if (currentAttachment) { - $context.bind(currentAttachment); + $context.bind(currentAttachment as any); } } else { @@ -173,7 +172,7 @@ export const execute = (render_queue: Float32Array, index: number): number => const height = Math.ceil(Math.abs(bounds[3] - bounds[1])); $context.applyFilter( - node, uniqueKey, Boolean(Math.max(+changed, +updated)), + node, filterKey, Boolean(Math.max(+changed, +updated)), width, height, false, matrix, colorTransform, displayObjectGetBlendModeService(blendMode), filterBounds, params diff --git a/packages/renderer/src/Video/usecase/VideoRenderUseCase.ts b/packages/renderer/src/Video/usecase/VideoRenderUseCase.ts index aee1eaf1..472e874e 100644 --- a/packages/renderer/src/Video/usecase/VideoRenderUseCase.ts +++ b/packages/renderer/src/Video/usecase/VideoRenderUseCase.ts @@ -42,6 +42,9 @@ export const execute = ( // video state const changed = Boolean(render_queue[index++]); + // フィルターキャッシュ用のユニークキー(instanceId) + const filterKey = `${render_queue[index++]}`; + let node: Node; const hasCache = render_queue[index++]; if (!hasCache) { @@ -63,24 +66,28 @@ export const execute = ( // fixed logic const currentAttachment = $context.currentAttachmentObject; - $context.bind($context.atlasAttachmentObject); + const atlasAttachment = $context.atlasAttachmentObject; + if (atlasAttachment) { + $context.bind(atlasAttachment as any); + } $context.reset(); $context.beginNodeRendering(node); - const offsetY = $context.atlasAttachmentObject.height - node.y - height; + const offsetY = atlasAttachment ? atlasAttachment.height - node.y - height : 0; $context.setTransform(1, 0, 0, 1, node.x, offsetY ); const imageBitmap = image_bitmaps.shift() as ImageBitmap; - $context.drawElement(node, imageBitmap); + // Video用にflipY: trueを指定(WebGPUでは画像の座標系変換が必要) + $context.drawElement(node, imageBitmap, true); $context.endNodeRendering(); if (currentAttachment) { - $context.bind(currentAttachment); + $context.bind(currentAttachment as any); } } } else { @@ -106,7 +113,7 @@ export const execute = ( const height = Math.ceil(Math.abs(bounds[3] - bounds[1])); $context.applyFilter( - node, uniqueKey, Boolean(Math.max(+changed, +updated)), + node, filterKey, Boolean(Math.max(+changed, +updated)), width, height, true, matrix, colorTransform, displayObjectGetBlendModeService(blendMode), filterBounds, params diff --git a/packages/text/README.md b/packages/text/README.md index 642a6e32..94e8c26b 100644 --- a/packages/text/README.md +++ b/packages/text/README.md @@ -1,11 +1,382 @@ -@next2d/text -============= +# @next2d/text -## Installation +## Overview / 概要 -``` +The `@next2d/text` package provides comprehensive TextField rendering capabilities with rich HTML text support, interactive input handling, and advanced text formatting. This package is the core text engine for the Next2D player framework. + +`@next2d/text` パッケージは、リッチHTMLテキストサポート、インタラクティブな入力処理、高度なテキストフォーマット機能を備えた包括的なTextFieldレンダリング機能を提供します。このパッケージは、Next2Dプレイヤーフレームワークのコアテキストエンジンです。 + +### Key Features / 主な機能 + +- **TextField Rendering**: Display text with various formatting options +- **HTML Text Support**: Parse and render HTML-formatted text with tags like ``, ``, ``, etc. +- **Text Input Handling**: Interactive text input with composition events (IME support) +- **Text Formatting**: Rich text formatting with TextFormat class +- **Auto-sizing**: Automatic text field resizing based on content +- **Scrolling**: Horizontal and vertical text scrolling + +--- + +- **TextFieldレンダリング**: 様々なフォーマットオプションでテキストを表示 +- **HTMLテキストサポート**: ``, ``, `` などのタグを含むHTML形式のテキストを解析・レンダリング +- **テキスト入力処理**: コンポジションイベント(IMEサポート)を備えたインタラクティブなテキスト入力 +- **テキストフォーマット**: TextFormatクラスによるリッチテキストフォーマット +- **自動サイズ調整**: コンテンツに基づいた自動テキストフィールドリサイズ +- **スクロール**: 水平・垂直テキストスクロール + +## Installation / インストール + +```bash npm install @next2d/text ``` -## License +## Directory Structure / ディレクトリ構造 + +``` +src/ +├── TextField.ts # Main TextField class / メインTextFieldクラス +│ ├── TextField/service/ # TextField services / TextFieldサービス +│ │ ├── TextFieldApplyChangesService.ts +│ │ ├── TextFieldBlinkingClearTimeoutService.ts +│ │ ├── TextFieldCompositionStartService.ts +│ │ └── TextFieldPasteService.ts +│ └── TextField/usecase/ # TextField use cases / TextFieldユースケース +│ ├── TextFieldArrowDownUseCase.ts +│ ├── TextFieldArrowLeftUseCase.ts +│ ├── TextFieldArrowRightUseCase.ts +│ ├── TextFieldArrowUpUseCase.ts +│ ├── TextFieldBlinkingUseCase.ts +│ ├── TextFieldBuildFromCharacterUseCase.ts +│ ├── TextFieldCompositionEndUseCase.ts +│ ├── TextFieldCompositionUpdateUseCase.ts +│ ├── TextFieldCopyUseCase.ts +│ ├── TextFieldDeleteTextUseCase.ts +│ ├── TextFieldGetLineTextUseCase.ts +│ ├── TextFieldGetTextDataUseCase.ts +│ ├── TextFieldHtmlTextToRawTextUseCase.ts +│ ├── TextFieldInsertTextUseCase.ts +│ ├── TextFieldKeyDownEventUseCase.ts +│ ├── TextFieldReloadUseCase.ts +│ ├── TextFieldReplaceTextUseCase.ts +│ ├── TextFieldResetUseCase.ts +│ ├── TextFieldResizeAutoFontSizeUseCase.ts +│ ├── TextFieldResizeUseCase.ts +│ ├── TextFieldSelectAllUseCase.ts +│ ├── TextFieldSelectedFocusMoveUseCase.ts +│ ├── TextFieldSetFocusIndexUseCase.ts +│ ├── TextFieldSetFocusUseCase.ts +│ ├── TextFieldSetScrollXUseCase.ts +│ ├── TextFieldSetScrollYUseCase.ts +│ └── TextFieldUpdateStopIndexUseCase.ts +│ +├── TextFormat.ts # Text formatting class / テキストフォーマットクラス +│ └── TextFormat/service/ # TextFormat services / TextFormatサービス +│ ├── TextFormatGenerateFontStyleService.ts +│ ├── TextFormatGetWidthMarginService.ts +│ ├── TextFormatHtmlTextGenerateStyleService.ts +│ ├── TextFormatIsSameService.ts +│ └── TextFormatSetDefaultService.ts +│ +├── TextData.ts # Text data container / テキストデータコンテナ +│ +├── TextArea/ # Input handling / 入力処理 +│ ├── TextArea/service/ # TextArea services / TextAreaサービス +│ │ └── TextAreaMovePositionService.ts +│ └── TextArea/usecase/ # TextArea use cases / TextAreaユースケース +│ ├── TextAreaCompositionEndUseCase.ts +│ ├── TextAreaCompositionStartUseCase.ts +│ ├── TextAreaCompositionUpdateUseCase.ts +│ ├── TextAreaInputUseCase.ts +│ └── TextAreaRegisterEventUseCase.ts +│ +├── TextParser/ # HTML parsing / HTML解析 +│ ├── TextParser/service/ # TextParser services / TextParserサービス +│ │ ├── TextParserAdjustmentHeightService.ts +│ │ └── TextParserParseStyleService.ts +│ └── TextParser/usecase/ # TextParser use cases / TextParserユースケース +│ ├── TextParserCreateNewLineUseCase.ts +│ ├── TextParserParseHtmlTextUseCase.ts +│ ├── TextParserParseTagUseCase.ts +│ ├── TextParserParseTextUseCase.ts +│ └── TextParserSetAttributesUseCase.ts +│ +├── TextUtil.ts # Utility functions / ユーティリティ関数 +│ +└── interface/ # TypeScript interfaces / TypeScriptインターフェース + ├── IAttributeObject.ts + ├── IBlendMode.ts + ├── IBounds.ts + ├── ICharacter.ts + ├── IDictionaryTag.ts + ├── IElementPosition.ts + ├── IFilterArray.ts + ├── IGrid.ts + ├── ILoopConfig.ts + ├── ILoopType.ts + ├── IMovieClipActionObject.ts + ├── IMovieClipCharacter.ts + ├── IMovieClipLabelObject.ts + ├── IMovieClipSoundObject.ts + ├── IOptions.ts + ├── IPlaceObject.ts + ├── IRGBA.ts + ├── IShapeCharacter.ts + ├── ISoundTag.ts + ├── ISurfaceFilter.ts + ├── ITextFieldAutoSize.ts + ├── ITextFieldCharacter.ts + ├── ITextFieldType.ts + ├── ITextFormatAlign.ts + ├── ITextFormatObject.ts + ├── ITextObject.ts + ├── ITextObjectMode.ts + └── IVideoCharacter.ts +``` + +## Text Processing Flow / テキスト処理フロー + +### HTML Text Rendering Flow / HTMLテキストレンダリングフロー + +```mermaid +flowchart TD + A[HTML Text Input
HTMLテキスト入力] --> B[TextParser] + B --> C{Parse HTML
HTML解析} + C --> D[TextParserParseHtmlTextUseCase] + D --> E[htmlparser2
HTML Document Parse] + E --> F[TextParserParseTagUseCase
タグ解析] + F --> G[TextParserParseTextUseCase
テキスト解析] + G --> H[TextParserSetAttributesUseCase
属性設定] + F --> I[TextFormatHtmlTextGenerateStyleService
スタイル生成] + I --> H + H --> J[TextData] + J --> K[TextParserAdjustmentHeightService
高さ調整] + K --> L[Render to Canvas
Canvasへレンダリング] + + style A fill:#e1f5ff + style J fill:#fff4e1 + style L fill:#e8f5e9 +``` + +### Text Input Handling Flow / テキスト入力処理フロー + +```mermaid +flowchart TD + A[User Input
ユーザー入力] --> B{Input Type
入力タイプ} + + B -->|Keyboard
キーボード| C[TextFieldKeyDownEventUseCase] + B -->|IME Start
IME開始| D[TextAreaCompositionStartUseCase] + B -->|IME Update
IME更新| E[TextAreaCompositionUpdateUseCase] + B -->|IME End
IME確定| F[TextAreaCompositionEndUseCase] + B -->|Paste
貼り付け| G[TextFieldPasteService] + + C --> H{Action
アクション} + H -->|Insert
挿入| I[TextFieldInsertTextUseCase] + H -->|Delete
削除| J[TextFieldDeleteTextUseCase] + H -->|Replace
置換| K[TextFieldReplaceTextUseCase] + H -->|Arrow Keys
矢印キー| L[Arrow UseCase
矢印ユースケース] + H -->|Copy
コピー| M[TextFieldCopyUseCase] + H -->|Select All
全選択| N[TextFieldSelectAllUseCase] + + D --> O[TextFieldCompositionStartService] + E --> P[TextFieldCompositionUpdateUseCase] + F --> Q[TextFieldCompositionEndUseCase] + G --> I + + I --> R[TextFieldApplyChangesService] + J --> R + K --> R + L --> S[TextFieldSelectedFocusMoveUseCase] + + O --> T[Update TextArea Element
TextArea要素更新] + P --> T + Q --> R + + R --> U[TextFieldReloadUseCase
再読み込み] + S --> V[Update Focus Position
フォーカス位置更新] + + U --> W[Re-render TextField
TextField再描画] + V --> W + + style A fill:#e1f5ff + style T fill:#fff4e1 + style W fill:#e8f5e9 +``` + +### Component Relationships / コンポーネント関係図 + +```mermaid +flowchart LR + A[TextField] --> B[TextFormat] + A --> C[TextData] + A --> D[TextParser] + A --> E[TextArea Handler] + + B --> F[TextFormatService
フォーマットサービス] + + D --> G[TextParserParseHtmlTextUseCase] + G --> H[htmlparser2] + G --> C + + E --> I[TextAreaInputUseCase
入力ユースケース] + E --> J[TextAreaCompositionUseCase
コンポジションユースケース] + + I --> A + J --> A + + C --> K[Render Engine
レンダリングエンジン] + + style A fill:#e3f2fd + style C fill:#fff3e0 + style K fill:#e8f5e9 +``` + +## Core Components / コアコンポーネント + +### TextField + +The main class for text display and input handling. TextField manages text rendering, user interactions, scrolling, and selection. + +テキスト表示と入力処理のメインクラス。TextFieldはテキストレンダリング、ユーザーインタラクション、スクロール、選択を管理します。 + +**Key Responsibilities / 主な責務:** +- Text rendering and layout / テキストレンダリングとレイアウト +- User input event handling / ユーザー入力イベント処理 +- Text selection and cursor management / テキスト選択とカーソル管理 +- Scrolling support / スクロールサポート +- Auto-sizing / 自動サイズ調整 + +### TextFormat + +Represents character formatting information including font, size, color, alignment, and other text properties. + +フォント、サイズ、色、配置、その他のテキストプロパティを含む文字フォーマット情報を表します。 + +**Properties / プロパティ:** +- `align`: Text alignment / テキスト配置 +- `bold`: Bold text / 太字 +- `color`: Text color / テキスト色 +- `font`: Font family / フォントファミリー +- `size`: Font size / フォントサイズ +- `italic`: Italic text / 斜体 +- `underline`: Underlined text / 下線 +- `url`: Hyperlink URL / ハイパーリンクURL + +### TextData + +Container for parsed text data with layout information. Stores text objects, dimensions, and line metrics. + +レイアウト情報を含む解析済みテキストデータのコンテナ。テキストオブジェクト、寸法、行メトリクスを格納します。 + +**Data Structure / データ構造:** +- `textTable`: Text objects per line / 行ごとのテキストオブジェクト +- `widthTable`: Width per line / 行ごとの幅 +- `heightTable`: Height per line / 行ごとの高さ +- `ascentTable`: Ascent per line / 行ごとのアセント + +### TextParser + +Parses HTML text and converts it into TextData objects ready for rendering. Supports various HTML tags and CSS-like styling. + +HTMLテキストを解析し、レンダリング準備が整ったTextDataオブジェクトに変換します。様々なHTMLタグとCSSライクなスタイリングをサポートします。 + +**Supported HTML Tags / サポートされるHTMLタグ:** +- ``: Bold / 太字 +- ``: Italic / 斜体 +- ``: Underline / 下線 +- ``: Font properties (color, size, face) / フォントプロパティ +- `

`: Paragraph / 段落 +- `
`: Line break / 改行 +- ``: Hyperlink / ハイパーリンク +- ``: Inline styling / インラインスタイル + +### TextArea Handler + +Manages text input through a hidden HTML textarea element, handling keyboard input, IME composition, and clipboard operations. + +非表示のHTMLテキストエリア要素を介してテキスト入力を管理し、キーボード入力、IMEコンポジション、クリップボード操作を処理します。 + +**Input Event Handling / 入力イベント処理:** +1. **Composition Start**: IME input begins / IME入力開始 +2. **Composition Update**: IME input updates / IME入力更新 +3. **Composition End**: IME input confirmed / IME入力確定 +4. **Input**: Direct text input / 直接テキスト入力 +5. **Paste**: Clipboard paste / クリップボード貼り付け +6. **Copy**: Copy selected text / 選択テキストのコピー + +### TextUtil + +Utility functions for text processing, color conversion, and element positioning. + +テキスト処理、色変換、要素位置決めのためのユーティリティ関数。 + +## Usage Example / 使用例 + +```typescript +import { TextField } from "@next2d/text"; +import { TextFormat } from "@next2d/text"; + +// Create a text field +// テキストフィールドを作成 +const textField = new TextField(); +textField.width = 300; +textField.height = 200; + +// Set text format +// テキストフォーマットを設定 +const format = new TextFormat(); +format.font = "Arial"; +format.size = 24; +format.color = 0x000000; +format.bold = true; + +textField.defaultTextFormat = format; + +// Set HTML text +// HTMLテキストを設定 +textField.htmlText = "

Hello World!

Welcome to Next2D

"; + +// Enable input +// 入力を有効化 +textField.type = "input"; +textField.selectable = true; + +// Handle text changes +// テキスト変更を処理 +textField.addEventListener("change", (event) => { + console.log("Text changed:", textField.text); +}); +``` + +## Testing / テスト + +All major components include comprehensive unit tests with `.test.ts` files. + +すべての主要コンポーネントには、`.test.ts` ファイルによる包括的なユニットテストが含まれています。 + +```bash +npm test +``` + +## Architecture Notes / アーキテクチャノート + +### Service vs UseCase / サービス vs ユースケース + +- **Service**: Low-level, reusable business logic / 低レベルで再利用可能なビジネスロジック +- **UseCase**: High-level application logic orchestrating multiple services / 複数のサービスを統合する高レベルのアプリケーションロジック + +### Clean Architecture / クリーンアーキテクチャ + +The package follows clean architecture principles with clear separation between: + +このパッケージは、以下の間で明確に分離されたクリーンアーキテクチャの原則に従っています: + +- **Domain Layer**: TextField, TextFormat, TextData classes / ドメイン層 +- **Use Case Layer**: Business logic orchestration / ユースケース層 +- **Service Layer**: Reusable utilities and helpers / サービス層 +- **Interface Layer**: TypeScript type definitions / インターフェース層 + +## License / ライセンス + This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [LICENSE](LICENSE) file for details. + +このプロジェクトは [MITライセンス](https://opensource.org/licenses/MIT) の下でライセンスされています。詳細については [LICENSE](LICENSE) ファイルをご覧ください。 diff --git a/packages/text/src/interface/ITextFieldType copy.ts b/packages/text/src/interface/ITextFieldType copy.ts deleted file mode 100644 index 3f92a04b..00000000 --- a/packages/text/src/interface/ITextFieldType copy.ts +++ /dev/null @@ -1 +0,0 @@ -export type ITextFieldType = "input" | "static"; \ No newline at end of file diff --git a/packages/texture-packer/README.md b/packages/texture-packer/README.md index e06461d4..0e04c862 100644 --- a/packages/texture-packer/README.md +++ b/packages/texture-packer/README.md @@ -1,11 +1,244 @@ -@next2d/texture-packer -============= +# @next2d/texture-packer -## Installation +**重要**: `@next2d/texture-packer` は他の packages の import を禁止しています。このパッケージは基盤モジュールであり、循環依存を避けるために独立を維持する必要があります。 -``` +**Important**: `@next2d/texture-packer` prohibits importing other packages. This package is a foundational module that must remain independent to avoid circular dependencies. + +## 概要 / Overview + +`@next2d/texture-packer` は、GPU テクスチャ割り当てに最適化された二分木ベースのパッキングアルゴリズムを実装した、高性能なテクスチャアトラス管理ライブラリです。このパッケージは、固定サイズのアトラス内でテクスチャの配置と削除を効率的に管理し、テクスチャメモリの断片化を最小限に抑え、スペース利用率を最大化します。 + +`@next2d/texture-packer` is a high-performance texture atlas management library that implements a binary tree-based packing algorithm optimized for GPU texture allocation. This package efficiently manages the placement and removal of textures within a fixed-size atlas, minimizing texture memory fragmentation and maximizing space utilization. + +## 主な機能 / Key Features + +- **二分木アルゴリズム**: 最適なテクスチャ配置のための再帰的な二分空間分割アプローチを使用 + - Uses a recursive binary space partitioning approach for optimal texture placement +- **動的な割り当て/解放**: 実行時のテクスチャの挿入と破棄をサポート + - Supports runtime insertion and disposal of textures +- **メモリプール最適化**: オブジェクトプーリングを採用し、ガベージコレクションのオーバーヘッドを削減 + - Employs object pooling to reduce garbage collection overhead +- **GPU 最適化**: WebGL/GPU テクスチャアトラス管理専用に設計 + - Designed specifically for WebGL/GPU texture atlas management +- **TypeScript サポート**: 完全な型定義による優れた開発者体験 + - Fully typed for enhanced developer experience + +## インストール / Installation + +```bash npm install @next2d/texture-packer ``` -## License -This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [LICENSE](LICENSE) file for details. +## ディレクトリ構造 / Directory Structure + +``` +texture-packer/ +├── src/ +│ ├── TexturePacker.ts # メインのテクスチャパッカークラス / Main texture packer class +│ ├── Node.ts # 二分木ノードクラス / Binary tree node class +│ ├── Node/ +│ │ └── service/ +│ │ ├── NodeInsertService.ts # テクスチャ挿入アルゴリズム / Texture insertion algorithm +│ │ └── NodeDisposeService.ts # テクスチャ破棄アルゴリズム / Texture disposal algorithm +│ └── index.ts # パッケージエントリーポイント / Package entry point +└── README.md +``` + +### ファイル説明 / File Descriptions + +- **TexturePacker.ts**: ルートノードを管理し、insert/dispose メソッドを提供するパブリック API / Public API that manages the root node and provides insert/dispose methods +- **Node.ts**: 左右の子ノードとオブジェクトプーリングを持つ二分木ノードの実装 / Binary tree node implementation with left/right children and object pooling +- **NodeInsertService.ts**: 新しいテクスチャのためのスペースを見つけて割り当てるコアアルゴリズム / Core algorithm for finding and allocating space for new textures +- **NodeDisposeService.ts**: 割り当てられたテクスチャスペースを解放し、ノードをマージするコアアルゴリズム / Core algorithm for releasing allocated texture space and merging nodes + +## 二分木構造 / Binary Tree Structure + +テクスチャパッカーは二分木構造を使用し、各ノードはテクスチャアトラス内の矩形領域を表します。テクスチャが挿入されると、ツリーは動的に分割されます。 + +The texture packer uses a binary tree structure where each node represents a rectangular region in the texture atlas. The tree dynamically splits as textures are inserted. + +```mermaid +graph TD + A[Root Node / ルートノード
1024x1024
used: true] --> B[Left Child / 左の子
256x1024
used: true] + A --> C[Right Child / 右の子
767x1024
used: false] + B --> D[Left Child / 左の子
256x256
used: true
TEXTURE A] + B --> E[Right Child / 右の子
256x767
used: false] + + style A fill:#e1f5ff + style B fill:#e1f5ff + style C fill:#f0f0f0 + style D fill:#90ee90 + style E fill:#f0f0f0 +``` + +### ノードのプロパティ / Node Properties + +各ノードには以下が含まれます / Each node contains: +- `x, y`: アトラス内の位置 / Position in the atlas +- `w, h`: 領域の幅と高さ / Width and height of the region +- `index`: アトラステクスチャのインデックス / Atlas texture index +- `left, right`: 子ノード(リーフの場合は null) / Child nodes (null if leaf) +- `used`: このスペースが割り当てられているか / Whether this space is allocated + +## アルゴリズム / Algorithms + +### 挿入アルゴリズム / Insert Algorithm + +挿入アルゴリズムは、利用可能なスペースを見つけるために二分木を再帰的に検索します。 + +The insertion algorithm recursively searches the binary tree for available space. + +```mermaid +flowchart TD + Start([Insert width, height
width, height を挿入]) --> Check1{Is node used?
ノードは使用中?} + Check1 -->|Yes / はい| Recurse[Try left child then right child
左の子を試行、次に右の子] + Recurse --> ReturnResult([Return result
結果を返す]) + + Check1 -->|No / いいえ| Check2{Fits in this node?
このノードに収まる?} + Check2 -->|No / いいえ| ReturnNull([Return null
null を返す]) + + Check2 -->|Yes / はい| Check3{Exact match?
完全に一致?} + Check3 -->|Yes / はい| MarkUsed[Mark node as used
ノードを使用中に設定] + MarkUsed --> ReturnNode([Return this node
このノードを返す]) + + Check3 -->|No / いいえ| CalcDelta[Calculate / 差分を計算
dw = node.w - width
dh = node.h - height] + CalcDelta --> Split{dw > dh?} + + Split -->|Yes / はい| SplitVertical[Vertical Split / 垂直分割
left: width × h
right: dw × h] + Split -->|No / いいえ| SplitHorizontal[Horizontal Split / 水平分割
left: w × height
right: w × dh] + + SplitVertical --> MarkUsed2[Mark node as used
ノードを使用中に設定] + SplitHorizontal --> MarkUsed2 + MarkUsed2 --> InsertLeft[Insert into left child
左の子に挿入] + InsertLeft --> ReturnResult2([Return result
結果を返す]) +``` + +**アルゴリズムのステップ / Algorithm Steps:** + +1. **使用状態の確認 / Check if used**: ノードが既に使用中の場合、左の子、次に右の子を再帰的に試行 / If the node is already used, recursively try left then right children +2. **サイズ検証 / Size validation**: テクスチャが収まらない場合は null を返す / Return null if the texture doesn't fit +3. **完全一致 / Exact match**: サイズが完全に一致する場合、使用中に設定して返す / If dimensions match exactly, mark as used and return +4. **分割方向の決定 / Split decision**: 残りのスペース(dw vs dh)を比較して分割方向を決定 / Compare remaining space (dw vs dh) to decide split direction + - `dw > dh` の場合: 垂直分割 / Split vertically (better for wider remaining space) + - それ以外: 水平分割 / Otherwise: Split horizontally (better for taller remaining space) +5. **子ノードの作成 / Create children**: 左の子は正確なサイズ、右の子は残りのスペースで作成 / Create left child with exact size, right child with remaining space +6. **再帰的挿入 / Recursive insert**: 左の子に挿入(確実に収まる) / Insert into the left child (guaranteed to fit) + +**パディング / Padding**: アルゴリズムはテクスチャのにじみを防ぐために 1 ピクセルのパディング(`requiredWidth = width + 1`)を追加します。 / The algorithm adds 1-pixel padding (`requiredWidth = width + 1`) to prevent texture bleeding. + +### 破棄アルゴリズム / Dispose Algorithm + +破棄アルゴリズムはテクスチャを削除し、隣接する空きスペースをマージします。 + +The disposal algorithm removes a texture and merges adjacent free space. + +```mermaid +flowchart TD + Start([Dispose x, y, w, h
x, y, w, h を破棄]) --> TryLeft{Left child dispose succeeds?
左の子の破棄が成功?} + + TryLeft -->|Yes / はい| CheckMergeL{Both children not used?
両方の子が未使用?} + CheckMergeL -->|Yes / はい| MergeL[Release both children
Set left=right=null
Mark this node unused
両方の子を解放し未使用に設定] + CheckMergeL -->|No / いいえ| ReturnTrueL([Return true
true を返す]) + MergeL --> ReturnTrueL + + TryLeft -->|No / いいえ| TryRight{Right child dispose succeeds?
右の子の破棄が成功?} + + TryRight -->|Yes / はい| CheckMergeR{Both children not used?
両方の子が未使用?} + CheckMergeR -->|Yes / はい| MergeR[Release both children
Set left=right=null
Mark this node unused
両方の子を解放し未使用に設定] + CheckMergeR -->|No / いいえ| ReturnTrueR([Return true
true を返す]) + MergeR --> ReturnTrueR + + TryRight -->|No / いいえ| CheckMatch{Exact position and size match?
位置とサイズが完全一致?} + CheckMatch -->|Yes / はい| MarkUnused[Mark node unused
ノードを未使用に設定] + MarkUnused --> ReturnTrue([Return true
true を返す]) + CheckMatch -->|No / いいえ| ReturnFalse([Return false
false を返す]) +``` + +**アルゴリズムのステップ / Algorithm Steps:** + +1. **再帰的検索 / Recursive search**: 左の子、次に右の子で破棄を試行 / Try to dispose in left child, then right child +2. **一致確認 / Match check**: 見つかった場合、正確な位置とサイズが一致することを確認 / If found, verify exact position and dimensions match +3. **未使用に設定 / Mark unused**: ノードの `used` フラグを false に設定 / Set the node's `used` flag to false +4. **マージ最適化 / Merge optimization**: 両方の子が未使用の場合、それらをオブジェクトプールに解放してマージ / If both children are unused, release them to the object pool and merge +5. **伝播 / Propagation**: マージチェックはツリーを上方向に伝播し、利用可能な連続スペースを最大化 / The merge check propagates up the tree, maximizing available contiguous space + +**メモリ管理 / Memory Management**: 解放されたノードはオブジェクトプールに返却され、再利用されることで割り当てのオーバーヘッドを削減します。 / Released nodes are returned to the object pool for reuse, reducing allocation overhead. + +## 使用例 / Usage Example + +```typescript +import { TexturePacker } from '@next2d/texture-packer'; + +// 1024x1024 のテクスチャアトラスを作成(インデックス 0) +// Create a 1024x1024 texture atlas (index 0) +const packer = new TexturePacker(0, 1024, 1024); + +// 256x256 のテクスチャを挿入 / Insert a 256x256 texture +const node1 = packer.insert(256, 256); +if (node1) { + console.log(`テクスチャは (${node1.x}, ${node1.y}) に割り当てられました`); + // Texture allocated at (${node1.x}, ${node1.y}) + // UV 座標に node1.x, node1.y を使用 / Use node1.x, node1.y for UV coordinates +} + +// 別のテクスチャを挿入 / Insert another texture +const node2 = packer.insert(128, 128); + +// 不要になったテクスチャを破棄 / Dispose a texture when no longer needed +if (node1) { + const success = packer.dispose(node1.x, node1.y, node1.w, node1.h); + console.log(`破棄に${success ? '成功' : '失敗'}しました`); + // Disposal ${success ? 'succeeded' : 'failed'} +} + +// node1 のスペースは再利用可能になりました +// The space from node1 can now be reused +const node3 = packer.insert(200, 200); +``` + +## API リファレンス / API Reference + +### TexturePacker + +#### `constructor(index: number, width: number, height: number)` + +指定されたアトラスサイズで新しいテクスチャパッカーを作成します。 + +Creates a new texture packer with the specified atlas dimensions. + +- `index`: アトラステクスチャのインデックス / Atlas texture index +- `width`: アトラスの幅(ピクセル) / Atlas width in pixels +- `height`: アトラスの高さ(ピクセル) / Atlas height in pixels + +#### `insert(width: number, height: number): Node | null` + +指定されたサイズのテクスチャを挿入しようと試みます。 + +Attempts to insert a texture with the given dimensions. + +- 戻り値 / Returns: 成功した場合は位置を持つ `Node`、スペースがない場合は `null` / `Node` with position if successful, `null` if no space available + +#### `dispose(x: number, y: number, width: number, height: number): boolean` + +指定された位置とサイズのテクスチャを解放します。 + +Releases the texture at the specified position and dimensions. + +- 戻り値 / Returns: 破棄に成功した場合は `true`、それ以外は `false` / `true` if disposal succeeded, `false` otherwise + +### Node + +#### プロパティ / Properties + +- `index: number` - アトラステクスチャのインデックス / Atlas texture index +- `x: number` - アトラス内の X 座標 / X coordinate in atlas +- `y: number` - アトラス内の Y 座標 / Y coordinate in atlas +- `w: number` - 割り当てられた領域の幅 / Width of allocated region +- `h: number` - 割り当てられた領域の高さ / Height of allocated region +- `left: Node | null` - 左の子ノード / Left child node +- `right: Node | null` - 右の子ノード / Right child node +- `used: boolean` - スペースが割り当てられているか / Whether space is allocated + +## ライセンス / License + +This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the LICENSE file for details. diff --git a/packages/ui/README.md b/packages/ui/README.md index f36b909a..d7ad66fa 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -1,11 +1,298 @@ -@next2d/ui -============= +# @next2d/ui -## Installation +Animation utilities package for Next2D Player / Next2Dプレイヤー向けアニメーションユーティリティパッケージ -``` +## Overview / 概要 + +`@next2d/ui` provides a powerful animation system with Tween and comprehensive Easing functions for creating smooth, professional animations in your Next2D applications. + +`@next2d/ui` は、Next2Dアプリケーションでスムーズでプロフェッショナルなアニメーションを作成するための、TweenとEasing機能を備えた強力なアニメーションシステムを提供します。 + +### Key Features / 主な機能 + +- **Tween System**: Easy-to-use tweening API for animating object properties / オブジェクトプロパティをアニメーションするための使いやすいトゥイーンAPI +- **Easing Functions**: 32 built-in easing functions for natural motion / 自然な動きのための32種類のイージング関数 +- **Job Chaining**: Chain multiple animations sequentially / 複数のアニメーションを連続して実行 +- **Event-Driven**: EventDispatcher-based architecture for animation lifecycle events / アニメーションライフサイクルイベント用のEventDispatcherベースアーキテクチャ + +## Installation / インストール + +```bash npm install @next2d/ui ``` -## License +## Usage / 使い方 + +### Basic Tween Animation / 基本的なトゥイーンアニメーション + +```typescript +import { Tween, Easing } from "@next2d/ui"; + +// Create a target object / 対象オブジェクトを作成 +const sprite = { x: 0, y: 0, alpha: 1 }; + +// Create a tween animation / トゥイーンアニメーションを作成 +const job = Tween.add( + sprite, // Target object / 対象オブジェクト + { x: 0, y: 0, alpha: 0 }, // Start values / 開始値 + { x: 100, y: 100, alpha: 1 }, // End values / 終了値 + 0, // Delay (seconds) / 遅延時間(秒) + 2, // Duration (seconds) / 実行時間(秒) + Easing.inOutQuad // Easing function / イージング関数 +); + +// Start the animation / アニメーションを開始 +job.start(); +``` + +### Chaining Animations / アニメーションの連結 + +```typescript +const job1 = Tween.add(sprite, { x: 0 }, { x: 100 }, 0, 1, Easing.outQuad); +const job2 = Tween.add(sprite, { x: 100 }, { x: 200 }, 0, 1, Easing.inQuad); + +// Chain animations / アニメーションを連結 +job1.chain(job2); +job1.start(); +``` + +## Directory Structure / ディレクトリ構造 + +``` +src/ +├── Tween.ts # Tween class / Tweenクラス +├── Job.ts # Job class / Jobクラス +│ ├── service/ # Job service layer / Jobサービスレイヤー +│ │ ├── JobStopService.ts # Stop animation / アニメーション停止 +│ │ ├── JobEntriesService.ts # Property entries management / プロパティエントリ管理 +│ │ ├── JobUpdateFrameService.ts # Frame update processing / フレーム更新処理 +│ │ └── JobUpdatePropertyService.ts # Property update processing / プロパティ更新処理 +│ └── usecase/ # Job use case layer / Jobユースケースレイヤー +│ ├── JobStartUseCase.ts # Start animation / アニメーション開始 +│ └── JobBootUseCase.ts # Boot animation loop / アニメーションループ起動 +├── Easing.ts # Easing class / Easingクラス +│ └── service/ # All easing implementations (32 functions) / 全イージング実装(32関数) +│ ├── EasingLinearService.ts +│ ├── EasingInQuadService.ts / EasingOutQuadService.ts / EasingInOutQuadService.ts +│ ├── EasingInCubicService.ts / EasingOutCubicService.ts / EasingInOutCubicService.ts +│ ├── EasingInQuartService.ts / EasingOutQuartService.ts / EasingInOutQuartService.ts +│ ├── EasingInQuintService.ts / EasingOutQuintService.ts / EasingInOutQuintService.ts +│ ├── EasingInSineService.ts / EasingOutSineService.ts / EasingInOutSineService.ts +│ ├── EasingInExpoService.ts / EasingOutExpoService.ts / EasingInOutExpoService.ts +│ ├── EasingInCircService.ts / EasingOutCircService.ts / EasingInOutCircService.ts +│ ├── EasingInElasticService.ts / EasingOutElasticService.ts / EasingInOutElasticService.ts +│ ├── EasingInBackService.ts / EasingOutBackService.ts / EasingInOutBackService.ts +│ └── EasingInBounceService.ts / EasingOutBounceService.ts / EasingInOutBounceService.ts +├── interface/ # TypeScript interfaces / TypeScriptインターフェース +│ ├── IObject.ts +│ └── IEntriesObject.ts +└── index.ts # Package exports / パッケージエクスポート +``` + +## Available Easing Functions / 利用可能なイージング関数 + +The `Easing` class provides 32 easing functions across 11 easing types with In, Out, and InOut variants. + +`Easing`クラスは、11種類のイージングタイプでIn、Out、InOutのバリエーションを含む32種類のイージング関数を提供します。 + +### Linear / リニア +- `Easing.linear` - Constant speed / 一定速度 + +### Quadratic (Quad) / 二次関数 +- `Easing.inQuad` - Accelerating from zero velocity / ゼロ速度から加速 +- `Easing.outQuad` - Decelerating to zero velocity / ゼロ速度まで減速 +- `Easing.inOutQuad` - Acceleration until halfway, then deceleration / 中間まで加速、その後減速 + +### Cubic / 三次関数 +- `Easing.inCubic` - Accelerating from zero velocity / ゼロ速度から加速 +- `Easing.outCubic` - Decelerating to zero velocity / ゼロ速度まで減速 +- `Easing.inOutCubic` - Acceleration until halfway, then deceleration / 中間まで加速、その後減速 + +### Quartic (Quart) / 四次関数 +- `Easing.inQuart` - Accelerating from zero velocity / ゼロ速度から加速 +- `Easing.outQuart` - Decelerating to zero velocity / ゼロ速度まで減速 +- `Easing.inOutQuart` - Acceleration until halfway, then deceleration / 中間まで加速、その後減速 + +### Quintic (Quint) / 五次関数 +- `Easing.inQuint` - Accelerating from zero velocity / ゼロ速度から加速 +- `Easing.outQuint` - Decelerating to zero velocity / ゼロ速度まで減速 +- `Easing.inOutQuint` - Acceleration until halfway, then deceleration / 中間まで加速、その後減速 + +### Sinusoidal (Sine) / 正弦波 +- `Easing.inSine` - Accelerating from zero velocity / ゼロ速度から加速 +- `Easing.outSine` - Decelerating to zero velocity / ゼロ速度まで減速 +- `Easing.inOutSine` - Acceleration until halfway, then deceleration / 中間まで加速、その後減速 + +### Exponential (Expo) / 指数関数 +- `Easing.inExpo` - Accelerating from zero velocity / ゼロ速度から加速 +- `Easing.outExpo` - Decelerating to zero velocity / ゼロ速度まで減速 +- `Easing.inOutExpo` - Acceleration until halfway, then deceleration / 中間まで加速、その後減速 + +### Circular (Circ) / 円形 +- `Easing.inCirc` - Accelerating from zero velocity / ゼロ速度から加速 +- `Easing.outCirc` - Decelerating to zero velocity / ゼロ速度まで減速 +- `Easing.inOutCirc` - Acceleration until halfway, then deceleration / 中間まで加速、その後減速 + +### Elastic / 弾性 +- `Easing.inElastic` - Elastic effect at the beginning / 開始時に弾性効果 +- `Easing.outElastic` - Elastic effect at the end / 終了時に弾性効果 +- `Easing.inOutElastic` - Elastic effect at both ends / 両端で弾性効果 + +### Back / バック +- `Easing.inBack` - Back up before moving forward / 前進する前に後退 +- `Easing.outBack` - Overshoot and settle / オーバーシュートして落ち着く +- `Easing.inOutBack` - Back up and overshoot / 後退とオーバーシュート + +### Bounce / バウンス +- `Easing.inBounce` - Bounce at the beginning / 開始時にバウンス +- `Easing.outBounce` - Bounce at the end / 終了時にバウンス +- `Easing.inOutBounce` - Bounce at both ends / 両端でバウンス + +### Easing Function Parameters / イージング関数のパラメータ + +All easing functions accept four parameters: / すべてのイージング関数は4つのパラメータを受け取ります: + +```typescript +ease(t: number, b: number, c: number, d: number): number +``` + +- `t`: Current time / 現在の時間 (0 to d) +- `b`: Beginning value / 開始値 +- `c`: Change in value / 変化量 (end value - beginning value) +- `d`: Duration / 継続時間 + +## Animation Flow / アニメーションフロー + +```mermaid +sequenceDiagram + participant User + participant Tween + participant Job + participant JobStartUseCase + participant JobStopService + participant Easing + participant Target + + User->>Tween: Tween.add(target, from, to, delay, duration, ease) + Tween->>Job: new Job(target, from, to, delay, duration, ease) + Job-->>User: Return Job instance + + User->>Job: job.start() + Job->>JobStartUseCase: execute(job) + + alt Has delay + JobStartUseCase->>JobStartUseCase: setTimeout(delay) + JobStartUseCase->>JobStartUseCase: Wait for delay + end + + JobStartUseCase->>JobStartUseCase: Initialize entries + JobStartUseCase->>JobStartUseCase: Set startTime + + loop Animation loop (requestAnimationFrame) + JobStartUseCase->>JobStartUseCase: Calculate currentTime + + alt currentTime < duration + JobStartUseCase->>Easing: Calculate eased value + Easing-->>JobStartUseCase: Return eased value + JobStartUseCase->>Target: Update target properties + JobStartUseCase->>Job: Dispatch "enterFrame" event + else currentTime >= duration + JobStartUseCase->>Target: Set final values + JobStartUseCase->>Job: Dispatch "complete" event + + alt Has nextJob + JobStartUseCase->>Job: Start nextJob + end + + JobStartUseCase->>JobStartUseCase: Exit loop + end + end + + opt User stops animation + User->>Job: job.stop() + Job->>JobStopService: execute(job) + JobStopService->>JobStopService: Clear timer + JobStopService->>JobStopService: Set stopFlag + end +``` + +## API Reference / APIリファレンス + +### Tween Class + +#### `Tween.add(target, from, to, delay, duration, ease): Job` + +Creates and returns a new Job instance for animation. + +アニメーション用の新しいJobインスタンスを作成して返します。 + +**Parameters / パラメータ:** +- `target: any` - Target object to animate / アニメーション対象オブジェクト +- `from: IObject` - Starting property values / 開始プロパティ値 +- `to: IObject` - Ending property values / 終了プロパティ値 +- `delay: number = 0` - Delay before animation starts (seconds) / アニメーション開始前の遅延(秒) +- `duration: number = 1` - Animation duration (seconds) / アニメーション継続時間(秒) +- `ease: Function | null = null` - Easing function (defaults to linear) / イージング関数(デフォルトはlinear) + +**Returns / 戻り値:** +- `Job` - Job instance / Jobインスタンス + +### Job Class + +Job class extends EventDispatcher and manages individual animation jobs. + +JobクラスはEventDispatcherを継承し、個別のアニメーションジョブを管理します。 + +#### `job.start(): void` + +Starts the animation. / アニメーションを開始します。 + +#### `job.stop(): void` + +Stops the animation. / アニメーションを停止します。 + +#### `job.chain(nextJob: Job | null): Job | null` + +Chains another job to start after this one completes. + +このジョブの完了後に別のジョブを連結します。 + +**Parameters / パラメータ:** +- `nextJob: Job | null` - Next job to execute, or null to clear / 実行する次のジョブ、またはクリアするnull + +**Returns / 戻り値:** +- `Job | null` - The chained job / 連結されたジョブ + +#### Properties / プロパティ + +- `target: any` - Target object / 対象オブジェクト +- `from: IObject` - Start values / 開始値 +- `to: IObject` - End values / 終了値 +- `delay: number` - Delay time / 遅延時間 +- `duration: number` - Duration time / 継続時間 +- `ease: Function` - Easing function / イージング関数 +- `currentTime: number` - Current animation time / 現在のアニメーション時間 +- `nextJob: Job | null` - Next chained job / 次の連結ジョブ + +## Events / イベント + +Job class dispatches the following events: / Jobクラスは以下のイベントを発行します: + +- `enterFrame` - Dispatched on each animation frame / 各アニメーションフレームで発行 +- `complete` - Dispatched when animation completes / アニメーション完了時に発行 + +```typescript +job.addEventListener("complete", (event) => { + console.log("Animation completed!"); +}); + +job.addEventListener("enterFrame", (event) => { + console.log("Current time:", job.currentTime); +}); +``` + +## License / ライセンス + This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [LICENSE](LICENSE) file for details. + +このプロジェクトは[MITライセンス](https://opensource.org/licenses/MIT)の下でライセンスされています - 詳細は[LICENSE](LICENSE)ファイルを参照してください。 diff --git a/packages/webgl/README.md b/packages/webgl/README.md index 172b66c7..fd57839c 100644 --- a/packages/webgl/README.md +++ b/packages/webgl/README.md @@ -1,11 +1,212 @@ -@next2d/webgl -============= +# @next2d/webgl -## Installation +WebGL2-based rendering engine for Next2D Player / Next2D Player用のWebGL2ベースレンダリングエンジン -``` +## Overview / 概要 + +The `@next2d/webgl` package is the main rendering backend for Next2D Player, providing a high-performance, shader-based drawing pipeline built on WebGL2. This package handles all graphics rendering operations including vector shapes, bitmaps, filters, and effects. + +`@next2d/webgl`パッケージは、Next2D Playerのメインレンダリングバックエンドであり、WebGL2上に構築された高性能なシェーダーベースの描画パイプラインを提供します。このパッケージは、ベクター図形、ビットマップ、フィルター、エフェクトなど、すべてのグラフィックスレンダリング操作を処理します。 + +### Key Features / 主な特徴 + +- **WebGL2-based rendering** - Hardware-accelerated graphics using modern WebGL2 API / WebGL2 APIを使用したハードウェアアクセラレーション +- **Shader-based pipeline** - Efficient GLSL shader processing for all rendering operations / すべてのレンダリング操作に対する効率的なGLSLシェーダー処理 +- **Texture atlas management** - Optimized texture packing and management / 最適化されたテクスチャパッキングと管理 +- **Advanced filters** - Full support for color matrix, blur, glow, displacement map and more / カラーマトリックス、ブラー、グロー、ディスプレースメントマップなどのフルサポート +- **Blend modes** - Multiple blend mode support with stencil operations / ステンシル操作による複数のブレンドモードサポート +- **Masking system** - Stencil buffer-based masking for complex clipping / 複雑なクリッピングのためのステンシルバッファベースのマスキング + +## Installation / インストール + +```bash npm install @next2d/webgl ``` -## License +## Directory Structure / ディレクトリ構造 + +``` +src/ +├── Context.ts # Main rendering context / メインレンダリングコンテキスト +├── Context/ +│ ├── service/ # Context services / コンテキストサービス +│ └── usecase/ # Context use cases / コンテキストユースケース +├── AtlasManager.ts # Texture atlas management / テクスチャアトラス管理 +├── FrameBufferManager.ts # Framebuffer object management / フレームバッファオブジェクト管理 +├── Blend.ts # Blend mode handling / ブレンドモード処理 +├── Mask.ts # Stencil-based masking / ステンシルベースのマスキング +├── Mesh.ts # Mesh generation for fills and strokes / 塗りと線のメッシュ生成 +├── PathCommand.ts # Path command processing / パスコマンド処理 +├── Filter.ts # Filter base functionality / フィルター基本機能 +├── Filter/ # Filter implementations / フィルター実装 +│ ├── BevelFilter/ # Bevel filter / ベベルフィルター +│ ├── BitmapFilter/ # Bitmap filter / ビットマップフィルター +│ ├── BlurFilter/ # Blur filter / ブラーフィルター +│ ├── ColorMatrixFilter/ # Color matrix filter / カラーマトリックスフィルター +│ ├── ConvolutionFilter/ # Convolution filter / コンボリューションフィルター +│ ├── DisplacementMapFilter/ # Displacement map filter / ディスプレースメントマップフィルター +│ ├── DropShadowFilter/ # Drop shadow filter / ドロップシャドウフィルター +│ ├── GlowFilter/ # Glow filter / グローフィルター +│ ├── GradientBevelFilter/ # Gradient bevel filter / グラデーションベベルフィルター +│ └── GradientGlowFilter/ # Gradient glow filter / グラデーショングローフィルター +├── Shader/ # GLSL shader system / GLSLシェーダーシステム +│ ├── Fragment/ # Fragment shaders / フラグメントシェーダー +│ ├── Vertex/ # Vertex shaders / バーテックスシェーダー +│ ├── ShaderManager.ts # Shader program management / シェーダープログラム管理 +│ ├── ShaderInstancedManager.ts # Instanced rendering manager / インスタンスレンダリング管理 +│ ├── GradientLUTGenerator.ts # Gradient LUT generation / グラデーションLUT生成 +│ └── Variants/ # Shader variants / シェーダーバリアント +├── VertexArrayObject.ts # VAO management / VAO管理 +├── TextureManager.ts # Texture management / テクスチャ管理 +├── ColorBufferObject.ts # Color buffer management / カラーバッファ管理 +├── StencilBufferObject.ts # Stencil buffer management / ステンシルバッファ管理 +├── Stencil.ts # Stencil operations / ステンシル操作 +├── Gradient.ts # Gradient processing / グラデーション処理 +├── Grid.ts # Grid system for rendering / レンダリング用グリッドシステム +├── Bitmap.ts # Bitmap handling / ビットマップ処理 +├── BezierConverter.ts # Bezier curve conversion / ベジェ曲線変換 +├── WebGLUtil.ts # WebGL utility functions / WebGLユーティリティ関数 +└── interface/ # TypeScript interfaces / TypeScript インターフェース +``` + +## Rendering Pipeline / レンダリングパイプライン + +The WebGL package implements a sophisticated rendering pipeline that processes graphics commands through multiple stages: + +WebGLパッケージは、複数のステージを通じてグラフィックスコマンドを処理する洗練されたレンダリングパイプラインを実装しています: + +```mermaid +flowchart TB + Start([Start Rendering]) + Context[Context
レンダリングコンテキスト] + PathCommand[PathCommand
パスコマンド処理] + Mesh[Mesh
メッシュ生成] + Shader[Shader
シェーダー実行] + FrameBuffer[FrameBuffer
フレームバッファ] + Atlas[AtlasManager
テクスチャアトラス] + Filter[Filter
フィルター処理] + Blend[Blend
ブレンド処理] + Mask[Mask
マスク処理] + Output([Output to Canvas]) + + Start --> Context + Context --> PathCommand + PathCommand --> Mesh + Mesh --> Shader + Shader --> FrameBuffer + + Context -.-> Atlas + Atlas -.-> Shader + + FrameBuffer --> Filter + Filter --> Blend + Blend --> Mask + Mask --> Output + + style Context fill:#e1f5ff + style Shader fill:#fff4e1 + style FrameBuffer fill:#f0e1ff + style Atlas fill:#e1ffe1 +``` + +### Pipeline Stages / パイプラインステージ + +1. **Context**: Main rendering context that manages the WebGL state and coordinates all rendering operations + - **コンテキスト**: WebGL状態を管理し、すべてのレンダリング操作を調整するメインレンダリングコンテキスト + +2. **PathCommand**: Processes vector path commands (moveTo, lineTo, curveTo, etc.) into renderable primitives + - **パスコマンド**: ベクターパスコマンド(moveTo、lineTo、curveToなど)をレンダリング可能なプリミティブに処理 + +3. **Mesh**: Generates triangle meshes from path data for fills and strokes + - **メッシュ**: 塗りと線のパスデータから三角形メッシュを生成 + +4. **Shader**: Executes GLSL shaders to render meshes with appropriate materials and effects + - **シェーダー**: 適切なマテリアルとエフェクトでメッシュをレンダリングするGLSLシェーダーを実行 + +5. **FrameBuffer**: Manages render targets and intermediate rendering buffers + - **フレームバッファ**: レンダーターゲットと中間レンダリングバッファを管理 + +6. **AtlasManager**: Optimizes texture usage through texture atlas packing + - **アトラスマネージャー**: テクスチャアトラスパッキングによりテクスチャ使用を最適化 + +7. **Filter**: Applies post-processing effects (blur, glow, color matrix, etc.) + - **フィルター**: ポストプロセスエフェクト(ブラー、グロー、カラーマトリックスなど)を適用 + +8. **Blend**: Handles blend mode operations for compositing + - **ブレンド**: 合成のためのブレンドモード操作を処理 + +9. **Mask**: Implements stencil-based masking for clipping and complex shapes + - **マスク**: クリッピングと複雑な形状のためのステンシルベースのマスキングを実装 + +## Atlas-Based Rendering Approach / アトラスベースのレンダリングアプローチ + +The WebGL package uses a texture atlas system to optimize rendering performance: + +WebGLパッケージは、レンダリングパフォーマンスを最適化するためにテクスチャアトラスシステムを使用しています: + +### Benefits / 利点 + +- **Reduced draw calls**: Multiple textures are packed into a single atlas, reducing the number of texture bindings + - **描画コールの削減**: 複数のテクスチャが単一のアトラスにパックされ、テクスチャバインディングの数が削減されます + +- **Memory efficiency**: Optimal packing algorithm minimizes wasted GPU memory + - **メモリ効率**: 最適なパッキングアルゴリズムにより、無駄なGPUメモリが最小化されます + +- **Cache coherency**: Better GPU cache utilization through spatial locality + - **キャッシュコヒーレンシー**: 空間的局所性による優れたGPUキャッシュ利用 + +### Atlas Management / アトラス管理 + +The `AtlasManager` component uses the `@next2d/texture-packer` package to: + +`AtlasManager`コンポーネントは`@next2d/texture-packer`パッケージを使用して以下を実行します: + +- Dynamically pack textures into optimal atlas layouts + - テクスチャを最適なアトラスレイアウトに動的にパック +- Track texture usage and automatically manage atlas allocation + - テクスチャ使用状況を追跡し、アトラス割り当てを自動管理 +- Support multiple atlas instances when texture requirements exceed single atlas capacity + - テクスチャ要件が単一のアトラス容量を超える場合、複数のアトラスインスタンスをサポート + +### Rendering with Atlases / アトラスを使用したレンダリング + +1. Textures are uploaded to GPU and registered with the atlas manager + - テクスチャがGPUにアップロードされ、アトラスマネージャーに登録されます + +2. The atlas manager assigns UV coordinates for each texture region + - アトラスマネージャーが各テクスチャ領域のUV座標を割り当てます + +3. Shaders use these UV coordinates to sample from the correct atlas region + - シェーダーがこれらのUV座標を使用して正しいアトラス領域からサンプリングします + +4. Multiple objects can be rendered in a single draw call using the same atlas + - 同じアトラスを使用して複数のオブジェクトを単一の描画コールでレンダリング可能 + +## Architecture Patterns / アーキテクチャパターン + +The codebase follows a clean architecture approach with clear separation of concerns: + +コードベースは、関心事の明確な分離を伴うクリーンアーキテクチャアプローチに従っています: + +### Service Layer / サービス層 + +Services contain low-level operations and business logic. They are pure functions that perform specific tasks. + +サービスは低レベルの操作とビジネスロジックを含みます。特定のタスクを実行する純粋な関数です。 + +### Use Case Layer / ユースケース層 + +Use cases orchestrate multiple services to accomplish higher-level operations. They represent application-specific business rules. + +ユースケースは、より高レベルの操作を達成するために複数のサービスを調整します。アプリケーション固有のビジネスルールを表します。 + +## Dependencies / 依存関係 + +- `@next2d/texture-packer`: Texture atlas packing and management / テクスチャアトラスのパッキングと管理 +- `@next2d/render-queue`: Rendering operation queue management / レンダリング操作キュー管理 + +## License / ライセンス + This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [LICENSE](LICENSE) file for details. + +このプロジェクトは[MITライセンス](https://opensource.org/licenses/MIT)の下でライセンスされています - 詳細は[LICENSE](LICENSE)ファイルを参照してください。 diff --git a/packages/webgl/src/AtlasManager.ts b/packages/webgl/src/AtlasManager.ts index a239390a..02b9ae96 100644 --- a/packages/webgl/src/AtlasManager.ts +++ b/packages/webgl/src/AtlasManager.ts @@ -5,97 +5,28 @@ import { execute as textureManagerCreateAtlasTextureUseCase } from "./TextureMan import { execute as frameBufferManagerGetAttachmentObjectUseCase } from "./FrameBufferManager/usecase/FrameBufferManagerGetAttachmentObjectUseCase"; import { $RENDER_MAX_SIZE } from "./WebGLUtil"; -/** - * @description 最大値定数(パフォーマンス最適化のためキャッシュ) - * Maximum value constants (cached for performance optimization) - * - * @type {number} - * @private - * @const - */ const $MAX_VALUE: number = Number.MAX_VALUE; const $MIN_VALUE: number = -Number.MAX_VALUE; -/** - * @description アクティブなアトラスインデックス - * Active atlas index - * - * @type {number} - * @private - */ -let $activeAtlasIndex: number = 0; +export let $activeAtlasIndex: number = 0; -/** - * @description アクティブなアトラスインデックスをセット - * Set the active atlas index - * - * @param {number} index - * @return {void} - * @method - * @protected - */ export const $setActiveAtlasIndex = (index: number): void => { $activeAtlasIndex = index; }; -/** - * @description アクティブなアトラスインデックスを返却 - * Return the active atlas index - * - * @returns {number} - * @method - * @protected - */ -export const $getActiveAtlasIndex = (): number => -{ - return $activeAtlasIndex; -}; - -/** - * @description アトラステクスチャのアタッチメントオブジェクト - * Attachment object of atlas texture - * - * @type {IAttachmentObject[]} - * @private - */ const $atlasAttachmentObjects: IAttachmentObject[] = []; -/** - * @description アトラス専用のフレームバッファ配列 - * Array of frame buffers dedicated to the atlas - * - * @return {IAttachmentObject[]} - * @method - * @protected - */ export const $getAtlasAttachmentObjects = (): IAttachmentObject[] => { return $atlasAttachmentObjects; }; -/** - * @description アトラステクスチャオブジェクトをセット - * Set the atlas texture object - * - * @param {IAttachmentObject} attachment_object - * @return {void} - * @method - * @protected - */ export const $setAtlasAttachmentObject = (attachment_object: IAttachmentObject): void => { $atlasAttachmentObjects[$activeAtlasIndex] = attachment_object; }; -/** - * @description アトラステクスチャオブジェクトを返却 - * Return the atlas texture object - * - * @returns {IAttachmentObject} - * @method - * @protected - */ export const $getAtlasAttachmentObject = (): IAttachmentObject => { if (!($activeAtlasIndex in $atlasAttachmentObjects)) { @@ -106,45 +37,15 @@ export const $getAtlasAttachmentObject = (): IAttachmentObject => return $atlasAttachmentObjects[$activeAtlasIndex]; }; -/** - * @description アトラステクスチャオブジェクトが存在するか - * Does the atlas texture object exist? - * - * @return {boolean} - * @method - * @protected - */ export const $hasAtlasAttachmentObject = (): boolean => { return $activeAtlasIndex in $atlasAttachmentObjects; }; -/** - * @description ルートノードの配列 - * Array of root nodes - * - * @type {TexturePacker[]} - * @protected - */ export const $rootNodes: TexturePacker[] = []; -/** - * @description アトラス専用のテクスチャ - * Texture for atlas only - * - * @type {ITextureObject | null} - * @private - */ export let $atlasTexture: ITextureObject | null = null; -/** - * @description アトラステクスチャオブジェクトを返却 - * Return the atlas texture object - * - * @return {ITextureObject} - * @method - * @protected - */ export const $getAtlasTextureObject = (): ITextureObject => { if (!$atlasTexture) { @@ -153,21 +54,8 @@ export const $getAtlasTextureObject = (): ITextureObject => return $atlasTexture as ITextureObject; }; -/** - * @type {Float32Array[]} - * @private - */ const $transferBounds: Float32Array[] = []; -/** - * @description アトラステクスチャの転送範囲を返却 - * Return the transfer range of the atlas texture - * - * @param {number} index - * @return {Float32Array} - * @method - * @protected - */ export const $getActiveTransferBounds = (index: number): Float32Array => { if (!(index in $transferBounds)) { @@ -181,42 +69,6 @@ export const $getActiveTransferBounds = (index: number): Float32Array => return $transferBounds[index]; }; -/** - * @type {Float32Array[]} - * @private - */ -const $allTransferBounds: Float32Array[] = []; - -/** - * @description アトラステクスチャの切り替え時の転送範囲を返却 - * Return the transfer range when switching the atlas texture - * - * @param {number} index - * @return {Float32Array} - * @method - * @protected - */ -export const $getActiveAllTransferBounds = (index: number): Float32Array => -{ - if (!(index in $allTransferBounds)) { - $allTransferBounds[index] = new Float32Array([ - $MAX_VALUE, - $MAX_VALUE, - $MIN_VALUE, - $MIN_VALUE - ]); - } - return $allTransferBounds[index]; -}; - -/** - * @description アトラステクスチャの転送範囲をクリア - * Clear the transfer range of the atlas texture - * - * @return {void} - * @method - * @protected - */ export const $clearTransferBounds = (): void => { for (let idx = 0; idx < $transferBounds.length; ++idx) { @@ -228,51 +80,11 @@ export const $clearTransferBounds = (): void => bounds[0] = bounds[1] = $MAX_VALUE; bounds[2] = bounds[3] = $MIN_VALUE; } - - for (let idx = 0; idx < $allTransferBounds.length; ++idx) { - const bounds = $allTransferBounds[idx]; - if (!bounds) { - continue; - } - - bounds[0] = bounds[1] = $MAX_VALUE; - bounds[2] = bounds[3] = $MIN_VALUE; - } }; -/** - * @description 現在設定されているアトラスアタッチメントオブジェクトのインデックス値 - * Index value of the currently set atlas attachment object - * - * @type {number} - * @default 0 - * @private - */ -let $currentAtlasIndex: number = 0; +export let $currentAtlasIndex: number = 0; -/** - * @description 現在設定されているアトラスアタッチメントオブジェクトのインデックス値をセット - * Set the index value of the currently set atlas attachment object - * - * @param {number} index - * @return {void} - * @method - * @protected - */ export const $setCurrentAtlasIndex = (index: number): void => { $currentAtlasIndex = index; }; - -/** - * @description 現在設定されているアトラスアタッチメントオブジェクトのインデックス値を返却 - * Returns the index value of the currently set atlas attachment object - * - * @return {number} - * @method - * @protected - */ -export const $getCurrentAtlasIndex = (): number => -{ - return $currentAtlasIndex; -}; \ No newline at end of file diff --git a/packages/webgl/src/AtlasManager/service/AtlasManagerCreateNodeService.ts b/packages/webgl/src/AtlasManager/service/AtlasManagerCreateNodeService.ts index b810be4f..8978ae71 100644 --- a/packages/webgl/src/AtlasManager/service/AtlasManagerCreateNodeService.ts +++ b/packages/webgl/src/AtlasManager/service/AtlasManagerCreateNodeService.ts @@ -3,7 +3,7 @@ import { $rootNodes } from "../../AtlasManager"; import { $RENDER_MAX_SIZE } from "../../WebGLUtil"; import { TexturePacker } from "@next2d/texture-packer"; import { - $getActiveAtlasIndex, + $activeAtlasIndex, $setActiveAtlasIndex } from "../../AtlasManager"; @@ -19,18 +19,35 @@ import { */ export const execute = (width: number, height: number): Node => { - const index = $getActiveAtlasIndex(); + const index = $activeAtlasIndex; if (!$rootNodes[index]) { $rootNodes[index] = new TexturePacker(index, $RENDER_MAX_SIZE, $RENDER_MAX_SIZE); } const rootNode = $rootNodes[index] as NonNullable; const node = rootNode.insert(width, height); + if (node) { + return node; + } + + for (let idx = 0; idx < 10; idx++) { + if (index === idx) { + continue; + } + + $setActiveAtlasIndex(idx); + const rootNode = $rootNodes[idx] as NonNullable; + if (!rootNode) { + return execute(width, height); + } + + const node = rootNode.insert(width, height); + if (!node) { + continue; + } - if (!node) { - $setActiveAtlasIndex(index + 1); - return execute(width, height); + return node; } - return node; + return execute(width, height); }; \ No newline at end of file diff --git a/packages/webgl/src/AtlasManager/usecase/AtlasManagerResetUseCase.test.ts b/packages/webgl/src/AtlasManager/usecase/AtlasManagerResetUseCase.test.ts index 7d35b7e5..028e6382 100644 --- a/packages/webgl/src/AtlasManager/usecase/AtlasManagerResetUseCase.test.ts +++ b/packages/webgl/src/AtlasManager/usecase/AtlasManagerResetUseCase.test.ts @@ -1,6 +1,6 @@ import { execute } from "./AtlasManagerResetUseCase"; import { execute as atlasManagerCreateNodeService } from "../service/AtlasManagerCreateNodeService"; -import { $rootNodes, $getActiveAtlasIndex, $setActiveAtlasIndex } from "../../AtlasManager"; +import { $rootNodes, $activeAtlasIndex, $setActiveAtlasIndex } from "../../AtlasManager"; import { describe, expect, it } from "vitest"; describe("AtlasManagerResetUseCase.js method test", () => @@ -11,13 +11,13 @@ describe("AtlasManagerResetUseCase.js method test", () => atlasManagerCreateNodeService(100, 200); expect($rootNodes.length).toBe(1); - expect($getActiveAtlasIndex()).toBe(0); + expect($activeAtlasIndex).toBe(0); $setActiveAtlasIndex(1); - expect($getActiveAtlasIndex()).toBe(1); + expect($activeAtlasIndex).toBe(1); execute(); - expect($getActiveAtlasIndex()).toBe(0); + expect($activeAtlasIndex).toBe(0); expect($rootNodes.length).toBe(0); }); }); \ No newline at end of file diff --git a/packages/webgl/src/BezierConverter.ts b/packages/webgl/src/BezierConverter.ts index 9f800409..ea28c7bd 100644 --- a/packages/webgl/src/BezierConverter.ts +++ b/packages/webgl/src/BezierConverter.ts @@ -5,4 +5,188 @@ * @type {Float32Array} * @public */ -export const $bezierBuffer: Float32Array = new Float32Array(32); \ No newline at end of file +export const $bezierBuffer: Float32Array = new Float32Array(32); + +/** + * @description 適応的テッセレーション用の動的バッファ + * Dynamic buffer for adaptive tessellation + * + * @type {Float32Array} + * @public + */ +export let $adaptiveBuffer: Float32Array = new Float32Array(64); + +/** + * @description 適応的テッセレーションの結果のセグメント数 + * Number of segments from adaptive tessellation + * + * @type {number} + * @public + */ +export let $adaptiveSegmentCount: number = 0; + +/** + * @description 適応的テッセレーションのフラットネス閾値 + * Flatness threshold for adaptive tessellation + * + * @type {number} + * @const + */ +const FLATNESS_THRESHOLD: number = 0.5; + +/** + * @description 最小分割数 + * Minimum subdivision count + * + * @type {number} + * @const + */ +const MIN_SUBDIVISIONS: number = 2; + +/** + * @description 最大分割数 + * Maximum subdivision count + * + * @type {number} + * @const + */ +const MAX_SUBDIVISIONS: number = 8; + +/** + * @description 3次ベジエ曲線のフラットネス(平坦度)を計算 + * Calculate flatness of a cubic Bezier curve + * + * @param {number} p0x + * @param {number} p0y + * @param {number} p1x + * @param {number} p1y + * @param {number} p2x + * @param {number} p2y + * @param {number} p3x + * @param {number} p3y + * @return {number} + * @method + * @protected + */ +export const $calculateFlatness = ( + p0x: number, p0y: number, + p1x: number, p1y: number, + p2x: number, p2y: number, + p3x: number, p3y: number +): number => { + // 制御点から直線への最大距離を計算 + // Calculate maximum distance from control points to the line P0-P3 + const ux = 3 * p1x - 2 * p0x - p3x; + const uy = 3 * p1y - 2 * p0y - p3y; + const vx = 3 * p2x - 2 * p3x - p0x; + const vy = 3 * p2y - 2 * p3y - p0y; + + return Math.max(ux * ux + uy * uy, vx * vx + vy * vy); +}; + +/** + * @description 曲率に基づいて最適な分割数を決定 + * Determine optimal subdivision count based on curvature + * + * @param {number} p0x + * @param {number} p0y + * @param {number} p1x + * @param {number} p1y + * @param {number} p2x + * @param {number} p2y + * @param {number} p3x + * @param {number} p3y + * @return {number} + * @method + * @protected + */ +export const $getAdaptiveSubdivisionCount = ( + p0x: number, p0y: number, + p1x: number, p1y: number, + p2x: number, p2y: number, + p3x: number, p3y: number +): number => { + const flatness = $calculateFlatness(p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y); + + // フラットネスに基づいて分割数を決定 + // log2(flatness / threshold) で必要な分割レベルを推定 + if (flatness < FLATNESS_THRESHOLD * FLATNESS_THRESHOLD) { + return MIN_SUBDIVISIONS; + } + + const level = Math.ceil(Math.log2(Math.sqrt(flatness) / FLATNESS_THRESHOLD) / 2); + return Math.min(Math.max(level + MIN_SUBDIVISIONS, MIN_SUBDIVISIONS), MAX_SUBDIVISIONS); +}; + +/** + * @description 適応的バッファのサイズを確保 + * Ensure adaptive buffer size + * + * @param {number} size + * @return {void} + * @method + * @protected + */ +export const $ensureAdaptiveBufferSize = (size: number): void => { + if ($adaptiveBuffer.length < size) { + $adaptiveBuffer = new Float32Array(size * 2); + } +}; + +/** + * @description 適応的セグメント数を設定 + * Set adaptive segment count + * + * @param {number} count + * @return {void} + * @method + * @protected + */ +export const $setAdaptiveSegmentCount = (count: number): void => { + $adaptiveSegmentCount = count; +}; + +/** + * @description 分割処理用の再利用可能なFloat32Array(8)バッファプール + * Reusable Float32Array(8) buffer pool for split operations + * + * @type {Float32Array[]} + * @private + */ +const $splitBufferPool: Float32Array[] = []; + +/** + * @description プールの現在の使用インデックス + * Current usage index of the pool + * + * @type {number} + * @private + */ +let $splitBufferIndex: number = 0; + +/** + * @description 分割用バッファプールをリセット + * Reset split buffer pool + * + * @return {void} + * @method + * @protected + */ +export const $resetSplitBufferPool = (): void => { + $splitBufferIndex = 0; +}; + +/** + * @description 分割用Float32Array(8)を取得(プールから再利用) + * Get Float32Array(8) for split (reuse from pool) + * + * @return {Float32Array} + * @method + * @protected + */ +export const $getSplitBuffer = (): Float32Array => { + if ($splitBufferIndex >= $splitBufferPool.length) { + $splitBufferPool.push(new Float32Array(8)); + } + return $splitBufferPool[$splitBufferIndex++]; +}; \ No newline at end of file diff --git a/packages/webgl/src/BezierConverter/usecase/BezierConverterAdaptiveCubicToQuadUseCase.test.ts b/packages/webgl/src/BezierConverter/usecase/BezierConverterAdaptiveCubicToQuadUseCase.test.ts new file mode 100644 index 00000000..ac251d9f --- /dev/null +++ b/packages/webgl/src/BezierConverter/usecase/BezierConverterAdaptiveCubicToQuadUseCase.test.ts @@ -0,0 +1,99 @@ +import { execute } from "./BezierConverterAdaptiveCubicToQuadUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../../BezierConverter.ts", () => { + const buffer = new Float32Array(64); + let segmentCount = 0; + return { + $adaptiveBuffer: buffer, + get $adaptiveSegmentCount() { return segmentCount; }, + $getAdaptiveSubdivisionCount: vi.fn(() => 2), + $ensureAdaptiveBufferSize: vi.fn(), + $setAdaptiveSegmentCount: vi.fn((count: number) => { segmentCount = count; }), + $resetSplitBufferPool: vi.fn(), + $getSplitBuffer: vi.fn(() => new Float32Array(8)) + }; +}); + +import { + $getAdaptiveSubdivisionCount, + $ensureAdaptiveBufferSize, + $setAdaptiveSegmentCount +} from "../../BezierConverter"; + +describe("BezierConverterAdaptiveCubicToQuadUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should convert cubic bezier to quadratic", () => + { + const result = execute( + 0, 0, // from + 25, 50, // control point 1 + 75, 50, // control point 2 + 100, 0 // end + ); + + expect($getAdaptiveSubdivisionCount).toHaveBeenCalled(); + expect($ensureAdaptiveBufferSize).toHaveBeenCalled(); + expect($setAdaptiveSegmentCount).toHaveBeenCalled(); + expect(result).toHaveProperty("buffer"); + expect(result).toHaveProperty("count"); + }); + + it("test case - should return correct structure", () => + { + const result = execute( + 0, 0, + 10, 20, + 30, 20, + 40, 0 + ); + + expect(result.buffer).toBeInstanceOf(Float32Array); + expect(typeof result.count).toBe("number"); + }); + + it("test case - should handle straight line approximation", () => + { + const result = execute( + 0, 0, + 33, 0, + 66, 0, + 100, 0 + ); + + expect(result).toBeDefined(); + expect($ensureAdaptiveBufferSize).toHaveBeenCalled(); + }); + + it("test case - should handle 4 subdivisions", () => + { + vi.mocked($getAdaptiveSubdivisionCount).mockReturnValueOnce(4); + + const result = execute( + 0, 0, + 0, 100, + 100, 100, + 100, 0 + ); + + expect(result).toBeDefined(); + }); + + it("test case - should handle 8 subdivisions for complex curves", () => + { + vi.mocked($getAdaptiveSubdivisionCount).mockReturnValueOnce(8); + + const result = execute( + 0, 0, + -50, 100, + 150, 100, + 100, 0 + ); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/webgl/src/BezierConverter/usecase/BezierConverterAdaptiveCubicToQuadUseCase.ts b/packages/webgl/src/BezierConverter/usecase/BezierConverterAdaptiveCubicToQuadUseCase.ts new file mode 100644 index 00000000..dbc09bf4 --- /dev/null +++ b/packages/webgl/src/BezierConverter/usecase/BezierConverterAdaptiveCubicToQuadUseCase.ts @@ -0,0 +1,248 @@ +import type { ICubicConverterReturnObject } from "../../interface/ICubicConverterReturnObject"; +import { + $adaptiveBuffer, + $adaptiveSegmentCount, + $getAdaptiveSubdivisionCount, + $ensureAdaptiveBufferSize, + $setAdaptiveSegmentCount, + $resetSplitBufferPool, + $getSplitBuffer +} from "../../BezierConverter"; + +/** + * @description De Casteljauアルゴリズムで3次ベジエを分割 + * Split cubic Bezier using De Casteljau algorithm + * + * @param {number} p0x + * @param {number} p0y + * @param {number} p1x + * @param {number} p1y + * @param {number} p2x + * @param {number} p2y + * @param {number} p3x + * @param {number} p3y + * @param {number} t + * @param {Float32Array} left + * @param {Float32Array} right + * @return {void} + * @method + * @private + */ +const splitCubicAt = ( + p0x: number, p0y: number, + p1x: number, p1y: number, + p2x: number, p2y: number, + p3x: number, p3y: number, + t: number, + left: Float32Array, + right: Float32Array +): void => { + const mt = 1 - t; + + // レベル1 + const q0x = mt * p0x + t * p1x; + const q0y = mt * p0y + t * p1y; + const q1x = mt * p1x + t * p2x; + const q1y = mt * p1y + t * p2y; + const q2x = mt * p2x + t * p3x; + const q2y = mt * p2y + t * p3y; + + // レベル2 + const r0x = mt * q0x + t * q1x; + const r0y = mt * q0y + t * q1y; + const r1x = mt * q1x + t * q2x; + const r1y = mt * q1y + t * q2y; + + // レベル3(分割点) + const sx = mt * r0x + t * r1x; + const sy = mt * r0y + t * r1y; + + // 左側のカーブ + left[0] = p0x; left[1] = p0y; + left[2] = q0x; left[3] = q0y; + left[4] = r0x; left[5] = r0y; + left[6] = sx; left[7] = sy; + + // 右側のカーブ + right[0] = sx; right[1] = sy; + right[2] = r1x; right[3] = r1y; + right[4] = q2x; right[5] = q2y; + right[6] = p3x; right[7] = p3y; +}; + +/** + * @description 3次ベジエを2次ベジエに近似 + * Approximate cubic Bezier as quadratic Bezier + * + * @param {number} p0x - 始点X + * @param {number} p0y - 始点Y + * @param {number} p1x - 第1制御点X + * @param {number} p1y - 第1制御点Y + * @param {number} p2x - 第2制御点X + * @param {number} p2y - 第2制御点Y + * @param {number} p3x - 終点X + * @param {number} p3y - 終点Y + * @param {Float32Array} buffer + * @param {number} offset + * @return {void} + * @method + * @private + */ +const cubicToQuad = ( + p0x: number, p0y: number, + p1x: number, p1y: number, + p2x: number, p2y: number, + p3x: number, p3y: number, + buffer: Float32Array, + offset: number +): void => { + // 3次ベジエの制御点から2次ベジエの制御点を近似 + // Q_control = (3*C1 + 3*C2 - P0 - P3) / 4 + const cx = (3 * p1x + 3 * p2x - p0x - p3x) * 0.25; + const cy = (3 * p1y + 3 * p2y - p0y - p3y) * 0.25; + + buffer[offset] = cx; + buffer[offset + 1] = cy; + buffer[offset + 2] = p3x; + buffer[offset + 3] = p3y; +}; + +// 一時バッファ(分割用) +const $tempLeft: Float32Array = new Float32Array(8); +const $tempRight: Float32Array = new Float32Array(8); + +/** + * @description 3次ベジェ曲線を適応的に2次ベジェ曲線に分割 + * Adaptively split cubic Bezier curve into quadratic Bezier curves + * + * @param {number} from_x + * @param {number} from_y + * @param {number} cx1 + * @param {number} cy1 + * @param {number} cx2 + * @param {number} cy2 + * @param {number} x + * @param {number} y + * @return {ICubicConverterReturnObject} + * @method + * @protected + */ +export const execute = ( + from_x: number, from_y: number, + cx1: number, cy1: number, + cx2: number, cy2: number, + x: number, y: number +): ICubicConverterReturnObject => { + + // 曲率に基づいて分割数を決定 + const subdivisions = $getAdaptiveSubdivisionCount( + from_x, from_y, cx1, cy1, cx2, cy2, x, y + ); + + // 各2次ベジエは4floats(cx, cy, x, y) + const requiredSize = subdivisions * 4; + $ensureAdaptiveBufferSize(requiredSize); + + // 分割数に応じてセグメントを生成 + let offset = 0; + + if (subdivisions <= 2) { + // 2分割: t=0.5で分割 + splitCubicAt(from_x, from_y, cx1, cy1, cx2, cy2, x, y, 0.5, $tempLeft, $tempRight); + + cubicToQuad( + $tempLeft[0], $tempLeft[1], $tempLeft[2], $tempLeft[3], $tempLeft[4], $tempLeft[5], $tempLeft[6], $tempLeft[7], + $adaptiveBuffer, offset + ); + offset += 4; + + cubicToQuad( + $tempRight[0], $tempRight[1], $tempRight[2], $tempRight[3], $tempRight[4], $tempRight[5], $tempRight[6], $tempRight[7], + $adaptiveBuffer, offset + ); + offset += 4; + } else if (subdivisions <= 4) { + // 4分割: t=0.25, 0.5, 0.75で分割 + // バッファプールをリセットして再利用 + $resetSplitBufferPool(); + + // 2段階で4分割 + const temp1 = $getSplitBuffer(); + const temp2 = $getSplitBuffer(); + + splitCubicAt(from_x, from_y, cx1, cy1, cx2, cy2, x, y, 0.5, temp1, temp2); + + const left1 = $getSplitBuffer(); + const left2 = $getSplitBuffer(); + const right1 = $getSplitBuffer(); + const right2 = $getSplitBuffer(); + + splitCubicAt(temp1[0], temp1[1], temp1[2], temp1[3], temp1[4], temp1[5], temp1[6], temp1[7], 0.5, left1, left2); + splitCubicAt(temp2[0], temp2[1], temp2[2], temp2[3], temp2[4], temp2[5], temp2[6], temp2[7], 0.5, right1, right2); + + cubicToQuad(left1[0], left1[1], left1[2], left1[3], left1[4], left1[5], left1[6], left1[7], $adaptiveBuffer, offset); + offset += 4; + cubicToQuad(left2[0], left2[1], left2[2], left2[3], left2[4], left2[5], left2[6], left2[7], $adaptiveBuffer, offset); + offset += 4; + cubicToQuad(right1[0], right1[1], right1[2], right1[3], right1[4], right1[5], right1[6], right1[7], $adaptiveBuffer, offset); + offset += 4; + cubicToQuad(right2[0], right2[1], right2[2], right2[3], right2[4], right2[5], right2[6], right2[7], $adaptiveBuffer, offset); + offset += 4; + } else { + // 8分割: 3段階で8分割(既存の方法と同等) + // バッファプールをリセットして再利用 + $resetSplitBufferPool(); + + // 最初の分割 + const a1 = $getSplitBuffer(); + const a2 = $getSplitBuffer(); + splitCubicAt(from_x, from_y, cx1, cy1, cx2, cy2, x, y, 0.5, a1, a2); + + // 2段階目 + const b1 = $getSplitBuffer(); + const b2 = $getSplitBuffer(); + const b3 = $getSplitBuffer(); + const b4 = $getSplitBuffer(); + splitCubicAt(a1[0], a1[1], a1[2], a1[3], a1[4], a1[5], a1[6], a1[7], 0.5, b1, b2); + splitCubicAt(a2[0], a2[1], a2[2], a2[3], a2[4], a2[5], a2[6], a2[7], 0.5, b3, b4); + + // 3段階目 + const c1 = $getSplitBuffer(); + const c2 = $getSplitBuffer(); + const c3 = $getSplitBuffer(); + const c4 = $getSplitBuffer(); + const c5 = $getSplitBuffer(); + const c6 = $getSplitBuffer(); + const c7 = $getSplitBuffer(); + const c8 = $getSplitBuffer(); + + splitCubicAt(b1[0], b1[1], b1[2], b1[3], b1[4], b1[5], b1[6], b1[7], 0.5, c1, c2); + splitCubicAt(b2[0], b2[1], b2[2], b2[3], b2[4], b2[5], b2[6], b2[7], 0.5, c3, c4); + splitCubicAt(b3[0], b3[1], b3[2], b3[3], b3[4], b3[5], b3[6], b3[7], 0.5, c5, c6); + splitCubicAt(b4[0], b4[1], b4[2], b4[3], b4[4], b4[5], b4[6], b4[7], 0.5, c7, c8); + + cubicToQuad(c1[0], c1[1], c1[2], c1[3], c1[4], c1[5], c1[6], c1[7], $adaptiveBuffer, offset); + offset += 4; + cubicToQuad(c2[0], c2[1], c2[2], c2[3], c2[4], c2[5], c2[6], c2[7], $adaptiveBuffer, offset); + offset += 4; + cubicToQuad(c3[0], c3[1], c3[2], c3[3], c3[4], c3[5], c3[6], c3[7], $adaptiveBuffer, offset); + offset += 4; + cubicToQuad(c4[0], c4[1], c4[2], c4[3], c4[4], c4[5], c4[6], c4[7], $adaptiveBuffer, offset); + offset += 4; + cubicToQuad(c5[0], c5[1], c5[2], c5[3], c5[4], c5[5], c5[6], c5[7], $adaptiveBuffer, offset); + offset += 4; + cubicToQuad(c6[0], c6[1], c6[2], c6[3], c6[4], c6[5], c6[6], c6[7], $adaptiveBuffer, offset); + offset += 4; + cubicToQuad(c7[0], c7[1], c7[2], c7[3], c7[4], c7[5], c7[6], c7[7], $adaptiveBuffer, offset); + offset += 4; + cubicToQuad(c8[0], c8[1], c8[2], c8[3], c8[4], c8[5], c8[6], c8[7], $adaptiveBuffer, offset); + offset += 4; + } + + $setAdaptiveSegmentCount(offset / 4); + + return { + "buffer": $adaptiveBuffer, + "count": $adaptiveSegmentCount + }; +}; diff --git a/packages/webgl/src/Blend.ts b/packages/webgl/src/Blend.ts index 0c97c2ac..4bebe96e 100644 --- a/packages/webgl/src/Blend.ts +++ b/packages/webgl/src/Blend.ts @@ -1,75 +1,15 @@ import type { IBlendMode } from "./interface/IBlendMode"; -/** - * @description 現在設定されているブレンドモード - * The currently set blend mode - * - * @type {IBlendMode} - * @default "normal" - * @private - */ -let $currentBlendMode: IBlendMode = "normal"; +export let $currentBlendMode: IBlendMode = "normal"; -/** - * @description ブレンドモード情報を更新 - * Update blend mode information - * - * @param {string} blend_mode - * @return {void} - * @method - * @protected - */ export const $setCurrentBlendMode = (blend_mode: IBlendMode): void => { $currentBlendMode = blend_mode; }; -/** - * @description 現在設定されているブレンドモードを返却 - * Returns the currently set blend mode - * - * @return {IBlendMode} - * @method - * @protected - */ -export const $getCurrentBlendMode = (): IBlendMode => -{ - return $currentBlendMode; -}; - -/** - * @description ブレンドモードの設定コード - * Blend mode setting code - * - * @type {number} - * @default 600 - * @private - */ -let $funcCode: number = 600; +export let $funcCode: number = 600; -/** - * @description ブレンドモードの設定コードを更新 - * Update the blend mode setting code - * - * @param {number} func_code - * @return {void} - * @method - * @protected - */ export const $setFuncCode = (func_code: number): void => { $funcCode = func_code; }; - -/** - * @description ブレンドモードの設定コードを返却 - * Returns the blend mode setting code - * - * @return {number} - * @method - * @protected - */ -export const $getFuncCode = (): number => -{ - return $funcCode; -}; \ No newline at end of file diff --git a/packages/webgl/src/Blend/service/BlendAddService.test.ts b/packages/webgl/src/Blend/service/BlendAddService.test.ts index a661d3ef..bfe72d7b 100644 --- a/packages/webgl/src/Blend/service/BlendAddService.test.ts +++ b/packages/webgl/src/Blend/service/BlendAddService.test.ts @@ -1,8 +1,8 @@ import { execute } from "./BlendAddService"; import { describe, expect, it, vi } from "vitest"; import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; describe("BlendAddService.js method test", () => @@ -26,8 +26,8 @@ describe("BlendAddService.js method test", () => }); $setFuncCode(600); - expect($getFuncCode()).toBe(600); + expect($funcCode).toBe(600); execute(); - expect($getFuncCode()).toBe(611); + expect($funcCode).toBe(611); }); }); \ No newline at end of file diff --git a/packages/webgl/src/Blend/service/BlendAddService.ts b/packages/webgl/src/Blend/service/BlendAddService.ts index a2466c2a..a894e119 100644 --- a/packages/webgl/src/Blend/service/BlendAddService.ts +++ b/packages/webgl/src/Blend/service/BlendAddService.ts @@ -1,6 +1,6 @@ import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; import { $gl } from "../../WebGLUtil"; @@ -14,7 +14,7 @@ import { $gl } from "../../WebGLUtil"; */ export const execute = (): void => { - if ($getFuncCode() !== 611) { + if ($funcCode !== 611) { $setFuncCode(611); $gl.blendFunc($gl.ONE, $gl.ONE); } diff --git a/packages/webgl/src/Blend/service/BlendAlphaService.test.ts b/packages/webgl/src/Blend/service/BlendAlphaService.test.ts index 93002524..321db5d5 100644 --- a/packages/webgl/src/Blend/service/BlendAlphaService.test.ts +++ b/packages/webgl/src/Blend/service/BlendAlphaService.test.ts @@ -1,8 +1,8 @@ import { execute } from "./BlendAlphaService"; import { describe, expect, it, vi } from "vitest"; import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; describe("BlendAlphaService.js method test", () => @@ -27,8 +27,8 @@ describe("BlendAlphaService.js method test", () => }); $setFuncCode(600); - expect($getFuncCode()).toBe(600); + expect($funcCode).toBe(600); execute(); - expect($getFuncCode()).toBe(606); + expect($funcCode).toBe(606); }); }); \ No newline at end of file diff --git a/packages/webgl/src/Blend/service/BlendAlphaService.ts b/packages/webgl/src/Blend/service/BlendAlphaService.ts index fc68d89c..29223d3a 100644 --- a/packages/webgl/src/Blend/service/BlendAlphaService.ts +++ b/packages/webgl/src/Blend/service/BlendAlphaService.ts @@ -1,6 +1,6 @@ import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; import { $gl } from "../../WebGLUtil"; @@ -14,7 +14,7 @@ import { $gl } from "../../WebGLUtil"; */ export const execute = (): void => { - if ($getFuncCode() !== 606) { + if ($funcCode !== 606) { $setFuncCode(606); $gl.blendFunc($gl.ZERO, $gl.SRC_ALPHA); } diff --git a/packages/webgl/src/Blend/service/BlendEraseService.test.ts b/packages/webgl/src/Blend/service/BlendEraseService.test.ts index eb75c01a..f3702c6a 100644 --- a/packages/webgl/src/Blend/service/BlendEraseService.test.ts +++ b/packages/webgl/src/Blend/service/BlendEraseService.test.ts @@ -1,8 +1,8 @@ import { execute } from "./BlendEraseService"; import { describe, expect, it, vi } from "vitest"; import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; describe("BlendEraseService.js method test", () => @@ -27,8 +27,8 @@ describe("BlendEraseService.js method test", () => }); $setFuncCode(600); - expect($getFuncCode()).toBe(600); + expect($funcCode).toBe(600); execute(); - expect($getFuncCode()).toBe(603); + expect($funcCode).toBe(603); }); }); \ No newline at end of file diff --git a/packages/webgl/src/Blend/service/BlendEraseService.ts b/packages/webgl/src/Blend/service/BlendEraseService.ts index dfadff0f..9d00e5aa 100644 --- a/packages/webgl/src/Blend/service/BlendEraseService.ts +++ b/packages/webgl/src/Blend/service/BlendEraseService.ts @@ -1,7 +1,7 @@ import { $gl } from "../../WebGLUtil"; import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; /** @@ -14,7 +14,7 @@ import { */ export const execute = (): void => { - if ($getFuncCode() !== 603) { + if ($funcCode !== 603) { $setFuncCode(603); $gl.blendFunc($gl.ZERO, $gl.ONE_MINUS_SRC_ALPHA); } diff --git a/packages/webgl/src/Blend/service/BlendOneZeroService.test.ts b/packages/webgl/src/Blend/service/BlendOneZeroService.test.ts index 8bd47490..19983847 100644 --- a/packages/webgl/src/Blend/service/BlendOneZeroService.test.ts +++ b/packages/webgl/src/Blend/service/BlendOneZeroService.test.ts @@ -1,8 +1,8 @@ import { execute } from "./BlendOneZeroService"; import { describe, expect, it, vi } from "vitest"; import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; describe("BlendOneZeroService.js method test", () => @@ -27,8 +27,8 @@ describe("BlendOneZeroService.js method test", () => }); $setFuncCode(600); - expect($getFuncCode()).toBe(600); + expect($funcCode).toBe(600); execute(); - expect($getFuncCode()).toBe(610); + expect($funcCode).toBe(610); }); }); \ No newline at end of file diff --git a/packages/webgl/src/Blend/service/BlendOneZeroService.ts b/packages/webgl/src/Blend/service/BlendOneZeroService.ts index 5bf1fe32..65a05b20 100644 --- a/packages/webgl/src/Blend/service/BlendOneZeroService.ts +++ b/packages/webgl/src/Blend/service/BlendOneZeroService.ts @@ -1,7 +1,7 @@ import { $gl } from "../../WebGLUtil"; import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; /** @@ -14,7 +14,7 @@ import { */ export const execute = (): void => { - if ($getFuncCode() !== 610) { + if ($funcCode !== 610) { $setFuncCode(610); $gl.blendFunc($gl.ONE, $gl.ZERO); } diff --git a/packages/webgl/src/Blend/service/BlendResetService.test.ts b/packages/webgl/src/Blend/service/BlendResetService.test.ts index 35be4f11..7b6d3aa1 100644 --- a/packages/webgl/src/Blend/service/BlendResetService.test.ts +++ b/packages/webgl/src/Blend/service/BlendResetService.test.ts @@ -1,8 +1,8 @@ import { execute } from "./BlendResetService"; import { describe, expect, it, vi } from "vitest"; import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; describe("BlendResetService.js method test", () => @@ -27,8 +27,8 @@ describe("BlendResetService.js method test", () => }); $setFuncCode(600); - expect($getFuncCode()).toBe(600); + expect($funcCode).toBe(600); execute(); - expect($getFuncCode()).toBe(613); + expect($funcCode).toBe(613); }); }); \ No newline at end of file diff --git a/packages/webgl/src/Blend/service/BlendResetService.ts b/packages/webgl/src/Blend/service/BlendResetService.ts index ad58ed9b..189da03d 100644 --- a/packages/webgl/src/Blend/service/BlendResetService.ts +++ b/packages/webgl/src/Blend/service/BlendResetService.ts @@ -1,7 +1,7 @@ import { $gl } from "../../WebGLUtil"; import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; /** @@ -14,7 +14,7 @@ import { */ export const execute = (): void => { - if ($getFuncCode() !== 613) { + if ($funcCode !== 613) { $setFuncCode(613); $gl.blendFunc($gl.ONE, $gl.ONE_MINUS_SRC_ALPHA); } diff --git a/packages/webgl/src/Blend/service/BlendScreenService.test.ts b/packages/webgl/src/Blend/service/BlendScreenService.test.ts index ea0c5d51..d7cb0995 100644 --- a/packages/webgl/src/Blend/service/BlendScreenService.test.ts +++ b/packages/webgl/src/Blend/service/BlendScreenService.test.ts @@ -1,8 +1,8 @@ import { execute } from "./BlendScreenService"; import { describe, expect, it, vi } from "vitest"; import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; describe("BlendScreenService.js method test", () => @@ -27,8 +27,8 @@ describe("BlendScreenService.js method test", () => }); $setFuncCode(600); - expect($getFuncCode()).toBe(600); + expect($funcCode).toBe(600); execute(); - expect($getFuncCode()).toBe(641); + expect($funcCode).toBe(641); }); }); \ No newline at end of file diff --git a/packages/webgl/src/Blend/service/BlendScreenService.ts b/packages/webgl/src/Blend/service/BlendScreenService.ts index f17b8850..dffd7f76 100644 --- a/packages/webgl/src/Blend/service/BlendScreenService.ts +++ b/packages/webgl/src/Blend/service/BlendScreenService.ts @@ -1,6 +1,6 @@ import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; import { $gl } from "../../WebGLUtil"; @@ -14,7 +14,7 @@ import { $gl } from "../../WebGLUtil"; */ export const execute = (): void => { - if ($getFuncCode() !== 641) { + if ($funcCode !== 641) { $setFuncCode(641); $gl.blendFunc($gl.ONE_MINUS_DST_COLOR, $gl.ONE); } diff --git a/packages/webgl/src/Blend/service/BlendSourceAtopService.test.ts b/packages/webgl/src/Blend/service/BlendSourceAtopService.test.ts index 17183511..55757f1d 100644 --- a/packages/webgl/src/Blend/service/BlendSourceAtopService.test.ts +++ b/packages/webgl/src/Blend/service/BlendSourceAtopService.test.ts @@ -1,8 +1,8 @@ import { execute } from "./BlendSourceAtopService"; import { describe, expect, it, vi } from "vitest"; import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; describe("BlendSourceAtopService.js method test", () => @@ -27,8 +27,8 @@ describe("BlendSourceAtopService.js method test", () => }); $setFuncCode(600); - expect($getFuncCode()).toBe(600); + expect($funcCode).toBe(600); execute(); - expect($getFuncCode()).toBe(673); + expect($funcCode).toBe(673); }); }); \ No newline at end of file diff --git a/packages/webgl/src/Blend/service/BlendSourceAtopService.ts b/packages/webgl/src/Blend/service/BlendSourceAtopService.ts index 2d977fb0..7c67342a 100644 --- a/packages/webgl/src/Blend/service/BlendSourceAtopService.ts +++ b/packages/webgl/src/Blend/service/BlendSourceAtopService.ts @@ -1,6 +1,6 @@ import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; import { $gl } from "../../WebGLUtil"; @@ -14,7 +14,7 @@ import { $gl } from "../../WebGLUtil"; */ export const execute = (): void => { - if ($getFuncCode() !== 673) { + if ($funcCode !== 673) { $setFuncCode(673); $gl.blendFunc($gl.DST_ALPHA, $gl.ONE_MINUS_SRC_ALPHA); } diff --git a/packages/webgl/src/Blend/service/BlendSourceInService.test.ts b/packages/webgl/src/Blend/service/BlendSourceInService.test.ts index 32a4bbdf..90b91b9b 100644 --- a/packages/webgl/src/Blend/service/BlendSourceInService.test.ts +++ b/packages/webgl/src/Blend/service/BlendSourceInService.test.ts @@ -1,8 +1,8 @@ import { execute } from "./BlendSourceInService"; import { describe, expect, it, vi } from "vitest"; import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; describe("BlendSourceInService.js method test", () => @@ -27,8 +27,8 @@ describe("BlendSourceInService.js method test", () => }); $setFuncCode(600); - expect($getFuncCode()).toBe(600); + expect($funcCode).toBe(600); execute(); - expect($getFuncCode()).toBe(670); + expect($funcCode).toBe(670); }); }); \ No newline at end of file diff --git a/packages/webgl/src/Blend/service/BlendSourceInService.ts b/packages/webgl/src/Blend/service/BlendSourceInService.ts index e16086a0..bea09c8f 100644 --- a/packages/webgl/src/Blend/service/BlendSourceInService.ts +++ b/packages/webgl/src/Blend/service/BlendSourceInService.ts @@ -1,7 +1,7 @@ import { $gl } from "../../WebGLUtil"; import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; /** @@ -14,7 +14,7 @@ import { */ export const execute = (): void => { - if ($getFuncCode() !== 670) { + if ($funcCode !== 670) { $setFuncCode(670); $gl.blendFunc($gl.DST_ALPHA, $gl.ZERO); } diff --git a/packages/webgl/src/Blend/usecase/BlendBootUseCase.test.ts b/packages/webgl/src/Blend/usecase/BlendBootUseCase.test.ts index 06afe020..8ae0d1ff 100644 --- a/packages/webgl/src/Blend/usecase/BlendBootUseCase.test.ts +++ b/packages/webgl/src/Blend/usecase/BlendBootUseCase.test.ts @@ -1,8 +1,8 @@ import { execute } from "./BlendBootUseCase"; import { describe, expect, it, vi } from "vitest"; import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; @@ -34,9 +34,9 @@ describe("BlendBootUseCase.js method test", () => }); $setFuncCode(0); - expect($getFuncCode()).toBe(0); + expect($funcCode).toBe(0); execute(); - expect($getFuncCode()).toBe(613); + expect($funcCode).toBe(613); }); }); \ No newline at end of file diff --git a/packages/webgl/src/Blend/usecase/BlendOperationUseCase.test.ts b/packages/webgl/src/Blend/usecase/BlendOperationUseCase.test.ts index 6b61b7a6..ffc333bb 100644 --- a/packages/webgl/src/Blend/usecase/BlendOperationUseCase.test.ts +++ b/packages/webgl/src/Blend/usecase/BlendOperationUseCase.test.ts @@ -1,8 +1,8 @@ import { execute } from "./BlendOperationUseCase"; import { describe, expect, it, vi } from "vitest"; import { - $setFuncCode, - $getFuncCode + $funcCode, + $setFuncCode } from "../../Blend"; vi.mock("../../WebGLUtil.ts", async (importOriginal) => @@ -26,48 +26,48 @@ describe("BlendOperationUseCase.js method test", () => it("test case add", () => { $setFuncCode(600); - expect($getFuncCode()).toBe(600); + expect($funcCode).toBe(600); execute("add"); - expect($getFuncCode()).toBe(611); + expect($funcCode).toBe(611); }); it("test case screen", () => { $setFuncCode(600); - expect($getFuncCode()).toBe(600); + expect($funcCode).toBe(600); execute("screen"); - expect($getFuncCode()).toBe(641); + expect($funcCode).toBe(641); }); it("test case alpha", () => { $setFuncCode(600); - expect($getFuncCode()).toBe(600); + expect($funcCode).toBe(600); execute("alpha"); - expect($getFuncCode()).toBe(606); + expect($funcCode).toBe(606); }); it("test case erase", () => { $setFuncCode(600); - expect($getFuncCode()).toBe(600); + expect($funcCode).toBe(600); execute("erase"); - expect($getFuncCode()).toBe(603); + expect($funcCode).toBe(603); }); it("test case copy", () => { $setFuncCode(600); - expect($getFuncCode()).toBe(600); + expect($funcCode).toBe(600); execute("copy"); - expect($getFuncCode()).toBe(610); + expect($funcCode).toBe(610); }); it("test case normal", () => { $setFuncCode(600); - expect($getFuncCode()).toBe(600); + expect($funcCode).toBe(600); execute("normal"); - expect($getFuncCode()).toBe(613); + expect($funcCode).toBe(613); }); }); \ No newline at end of file diff --git a/packages/webgl/src/Blend/usecase/BlnedDrawArraysInstancedUseCase.ts b/packages/webgl/src/Blend/usecase/BlnedDrawArraysInstancedUseCase.ts index b00a6865..baa3e292 100644 --- a/packages/webgl/src/Blend/usecase/BlnedDrawArraysInstancedUseCase.ts +++ b/packages/webgl/src/Blend/usecase/BlnedDrawArraysInstancedUseCase.ts @@ -2,7 +2,7 @@ import { execute as variantsBlendInstanceShaderService } from "../../Shader/Vari import { execute as shaderInstancedManagerDrawArraysInstancedUseCase } from "../../Shader/ShaderInstancedManager/usecase/ShaderInstancedManagerDrawArraysInstancedUseCase"; import { execute as blendOperationUseCase } from "../../Blend/usecase/BlendOperationUseCase"; import { execute as frameBufferManagerTransferAtlasTextureService } from "../../FrameBufferManager/service/FrameBufferManagerTransferAtlasTextureService"; -import { $context } from "../../WebGLUtil"; +import { $currentBlendMode } from "../../Blend"; /** * @description インスタンス描画を実行します。 @@ -22,10 +22,10 @@ export const execute = (): void => // Transfer to atlas texture. frameBufferManagerTransferAtlasTextureService(); - blendOperationUseCase($context.globalCompositeOperation); + blendOperationUseCase($currentBlendMode); shaderInstancedManagerDrawArraysInstancedUseCase( shaderInstancedManager ); shaderInstancedManager.clear(); -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/Blend/usecase/BlnedDrawDisplayObjectUseCase.test.ts b/packages/webgl/src/Blend/usecase/BlnedDrawDisplayObjectUseCase.test.ts index 42b6008a..6fda26df 100644 --- a/packages/webgl/src/Blend/usecase/BlnedDrawDisplayObjectUseCase.test.ts +++ b/packages/webgl/src/Blend/usecase/BlnedDrawDisplayObjectUseCase.test.ts @@ -6,6 +6,25 @@ import * as BlendModule from "../../Blend"; import * as AtlasManagerModule from "../../AtlasManager"; import { renderQueue } from "@next2d/render-queue"; +vi.mock("../../Blend", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $currentBlendMode: "normal", + $setCurrentBlendMode: vi.fn(), + }; +}); + +vi.mock("../../AtlasManager", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $currentAtlasIndex: 0, + $setCurrentAtlasIndex: vi.fn(), + $setActiveAtlasIndex: vi.fn(), + }; +}); + vi.mock("../../Shader/Variants/Blend/service/VariantsBlendInstanceShaderService", () => ({ execute: vi.fn(() => ({ count: 0 })) })); @@ -65,8 +84,8 @@ vi.mock("../../WebGLUtil.ts", async (importOriginal) => { setTransform: vi.fn(), reset: vi.fn() }, - $getViewportWidth: vi.fn(() => 800), - $getViewportHeight: vi.fn(() => 600), + $viewportWidth: 800, + $viewportHeight: 600, $getFloat32Array6: vi.fn((...args: number[]) => new Float32Array(args)), $RENDER_MAX_SIZE: 4096 }; @@ -106,14 +125,12 @@ describe("BlnedDrawDisplayObjectUseCase method test", () => { $context.restore = vi.fn(); $context.setTransform = vi.fn(); - vi.spyOn(BlendModule, "$getCurrentBlendMode").mockReturnValue("normal"); - vi.spyOn(BlendModule, "$setCurrentBlendMode").mockImplementation(() => {}); - vi.spyOn(AtlasManagerModule, "$getCurrentAtlasIndex").mockReturnValue(0); - vi.spyOn(AtlasManagerModule, "$setCurrentAtlasIndex").mockImplementation(() => {}); - vi.spyOn(AtlasManagerModule, "$setActiveAtlasIndex").mockImplementation(() => {}); + // Reset mocked module values + (BlendModule as any).$currentBlendMode = "normal"; + (AtlasManagerModule as any).$currentAtlasIndex = 0; renderQueue.length = 0; - renderQueue.push = vi.fn(); + renderQueue.pushDisplayObjectBuffer = vi.fn(); }); it("should handle normal blend mode without switching", () => { @@ -121,7 +138,7 @@ describe("BlnedDrawDisplayObjectUseCase method test", () => { execute(mockNode, 0, 0, 100, 100, mockColorTransform); - expect(renderQueue.push).toHaveBeenCalled(); + expect(renderQueue.pushDisplayObjectBuffer).toHaveBeenCalled(); }); it("should handle layer blend mode", () => { @@ -129,7 +146,7 @@ describe("BlnedDrawDisplayObjectUseCase method test", () => { execute(mockNode, 0, 0, 100, 100, mockColorTransform); - expect(renderQueue.push).toHaveBeenCalled(); + expect(renderQueue.pushDisplayObjectBuffer).toHaveBeenCalled(); }); it("should handle add blend mode", () => { @@ -137,7 +154,7 @@ describe("BlnedDrawDisplayObjectUseCase method test", () => { execute(mockNode, 0, 0, 100, 100, mockColorTransform); - expect(renderQueue.push).toHaveBeenCalled(); + expect(renderQueue.pushDisplayObjectBuffer).toHaveBeenCalled(); }); it("should handle screen blend mode", () => { @@ -145,7 +162,7 @@ describe("BlnedDrawDisplayObjectUseCase method test", () => { execute(mockNode, 0, 0, 100, 100, mockColorTransform); - expect(renderQueue.push).toHaveBeenCalled(); + expect(renderQueue.pushDisplayObjectBuffer).toHaveBeenCalled(); }); it("should handle alpha blend mode", () => { @@ -153,7 +170,7 @@ describe("BlnedDrawDisplayObjectUseCase method test", () => { execute(mockNode, 0, 0, 100, 100, mockColorTransform); - expect(renderQueue.push).toHaveBeenCalled(); + expect(renderQueue.pushDisplayObjectBuffer).toHaveBeenCalled(); }); it("should handle erase blend mode", () => { @@ -161,7 +178,7 @@ describe("BlnedDrawDisplayObjectUseCase method test", () => { execute(mockNode, 0, 0, 100, 100, mockColorTransform); - expect(renderQueue.push).toHaveBeenCalled(); + expect(renderQueue.pushDisplayObjectBuffer).toHaveBeenCalled(); }); it("should handle copy blend mode", () => { @@ -169,11 +186,11 @@ describe("BlnedDrawDisplayObjectUseCase method test", () => { execute(mockNode, 0, 0, 100, 100, mockColorTransform); - expect(renderQueue.push).toHaveBeenCalled(); + expect(renderQueue.pushDisplayObjectBuffer).toHaveBeenCalled(); }); it("should switch blend mode when different from current", () => { - vi.spyOn(BlendModule, "$getCurrentBlendMode").mockReturnValue("multiply"); + (BlendModule as any).$currentBlendMode = "multiply"; $context.globalCompositeOperation = "normal"; execute(mockNode, 0, 0, 100, 100, mockColorTransform); @@ -182,7 +199,7 @@ describe("BlnedDrawDisplayObjectUseCase method test", () => { }); it("should switch atlas index when different from current", () => { - vi.spyOn(AtlasManagerModule, "$getCurrentAtlasIndex").mockReturnValue(1); + (AtlasManagerModule as any).$currentAtlasIndex = 1; mockNode.index = 0; execute(mockNode, 0, 0, 100, 100, mockColorTransform); @@ -206,7 +223,7 @@ describe("BlnedDrawDisplayObjectUseCase method test", () => { execute(mockNode, 0, 0, 100, 100, colorTransformWithOffset); - expect(renderQueue.push).toHaveBeenCalled(); + expect(renderQueue.pushDisplayObjectBuffer).toHaveBeenCalled(); }); it("should handle transformed matrix", () => { @@ -248,6 +265,6 @@ describe("BlnedDrawDisplayObjectUseCase method test", () => { execute(mockNode, 0, 0, 200, 150, mockColorTransform); - expect(renderQueue.push).toHaveBeenCalled(); + expect(renderQueue.pushDisplayObjectBuffer).toHaveBeenCalled(); }); }); diff --git a/packages/webgl/src/Blend/usecase/BlnedDrawDisplayObjectUseCase.ts b/packages/webgl/src/Blend/usecase/BlnedDrawDisplayObjectUseCase.ts index 684e0da2..fb69d2ab 100644 --- a/packages/webgl/src/Blend/usecase/BlnedDrawDisplayObjectUseCase.ts +++ b/packages/webgl/src/Blend/usecase/BlnedDrawDisplayObjectUseCase.ts @@ -18,15 +18,15 @@ import { execute as variantsBlendMatrixTextureShaderService } from "../../Shader import { execute as shaderManagerSetMatrixTextureUniformService } from "../../Shader/ShaderManager/service/ShaderManagerSetMatrixTextureUniformService"; import { $context, - $getViewportHeight, - $getViewportWidth + $viewportHeight, + $viewportWidth } from "../../WebGLUtil"; import { - $getCurrentBlendMode, + $currentBlendMode, $setCurrentBlendMode } from "../../Blend"; import { - $getCurrentAtlasIndex, + $currentAtlasIndex, $setCurrentAtlasIndex } from "../../AtlasManager"; import { renderQueue } from "@next2d/render-queue"; @@ -74,15 +74,17 @@ export const execute = ( case "erase": case "copy": { - if ($getCurrentBlendMode() !== $context.globalCompositeOperation - || $getCurrentAtlasIndex() !== node.index + if ($currentBlendMode !== $context.globalCompositeOperation + || $currentAtlasIndex !== node.index ) { // 異なるフレームバッファになるので、切り替え前にメインバッファに描画を実行 - $setActiveAtlasIndex($getCurrentAtlasIndex()); + $setActiveAtlasIndex($currentAtlasIndex); const currentOperation = $context.globalCompositeOperation; - $context.globalCompositeOperation = $getCurrentBlendMode(); + $context.globalCompositeOperation = $currentBlendMode; + $context.newDrawState = true; $context.drawArraysInstanced(); + $context.newDrawState = true; // ブレンドモードをセット $context.globalCompositeOperation = currentOperation; @@ -96,19 +98,13 @@ export const execute = ( // 描画するまで配列に変数を保持 const shaderInstancedManager = variantsBlendInstanceShaderService(); - renderQueue.push( - // texture rectangle (vec4) - node.x / $RENDER_MAX_SIZE, node.y / $RENDER_MAX_SIZE, - node.w / $RENDER_MAX_SIZE, node.h / $RENDER_MAX_SIZE, - // texture width, height and viewport width, height (vec4) - node.w, node.h, $getViewportWidth(), $getViewportHeight(), - // matrix tx, ty (vec2) + renderQueue.pushDisplayObjectBuffer( + (node.x + 0.5) / $RENDER_MAX_SIZE, (node.y + 0.5) / $RENDER_MAX_SIZE, + (node.w - 1.0) / $RENDER_MAX_SIZE, (node.h - 1.0) / $RENDER_MAX_SIZE, + node.w, node.h, $viewportWidth, $viewportHeight, matrix[6], matrix[7], - // matrix scale0, rotate0, scale1, rotate1 (vec4) matrix[0], matrix[1], matrix[3], matrix[4], - // mulColor (vec4) ct0, ct1, ct2, ct3, - // addColor (vec4) ct4, ct5, ct6, ct7 ); shaderInstancedManager.count++; @@ -249,4 +245,4 @@ export const execute = ( break; } -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/Context.ts b/packages/webgl/src/Context.ts index bc3399e9..058892c7 100644 --- a/packages/webgl/src/Context.ts +++ b/packages/webgl/src/Context.ts @@ -45,8 +45,10 @@ import { execute as contextBitmapFillUseCase } from "./Context/usecase/ContextBi import { execute as contextBitmapStrokeUseCase } from "./Context/usecase/ContextBitmapStrokeUseCase"; import { execute as contextStrokeUseCase } from "./Context/usecase/ContextStrokeUseCase"; import { execute as contextApplyFilterUseCase } from "./Context/usecase/ContextApplyFilterUseCase"; +import { execute as contextContainerBeginLayerUseCase } from "./Context/usecase/ContextContainerBeginLayerUseCase"; +import { execute as contextContainerEndLayerUseCase } from "./Context/usecase/ContextContainerEndLayerUseCase"; +import { execute as contextContainerDrawCachedFilterUseCase } from "./Context/usecase/ContextContainerDrawCachedFilterUseCase"; import { execute as contextUpdateTransferBoundsService } from "./Context/service/ContextUpdateTransferBoundsService"; -import { execute as contextUpdateAllTransferBoundsService } from "./Context/service/ContextUpdateAllTransferBoundsService"; import { execute as contextDrawFillUseCase } from "./Context/usecase/ContextDrawFillUseCase"; import { execute as contextCreateImageBitmapService } from "./Context/service/ContextCreateImageBitmapService"; import { $setGradientLUTGeneratorMaxLength } from "./Shader/GradientLUTGenerator"; @@ -58,7 +60,7 @@ import { import { $setReadFrameBuffer, $setDrawFrameBuffer, - $getCurrentAttachment, + $currentAttachment, $setAtlasFrameBuffer, $setBitmapFrameBuffer } from "./FrameBufferManager"; @@ -72,190 +74,28 @@ import { $setDevicePixelRatio } from "./WebGLUtil"; -/** - * @description WebGL版、Next2Dのコンテキスト - * WebGL version, Next2D context - * - * @class - */ export class Context { - /** - * @description matrixのデータを保持するスタック - * Stack to hold matrix data - * - * @type {Float32Array[]} - * @protected - */ public readonly $stack: Float32Array[]; - - /** - * @description 2D変換行列 - * 2D transformation matrix - * - * @type {Float32Array} - * @protected - */ public readonly $matrix: Float32Array; - - /** - * @description 背景色のR - * Background color R - * - * @type {number} - * @protected - */ public $clearColorR: number; - - /** - * @description 背景色のG - * Background color G - * - * @type {number} - * @protected - */ public $clearColorG: number; - - /** - * @description 背景色のB - * Background color B - * - * @type {number} - * @protected - */ public $clearColorB: number; - - /** - * @description 背景色のA - * Background color A - * - * @type {number} - * @protected - */ public $clearColorA: number; - - /** - * @description メインのアタッチメントオブジェクト - * Main attachment object - * - * @type {IAttachmentObject} - * @protected - */ public $mainAttachmentObject: IAttachmentObject | null; - - /** - * @description アタッチメントオブジェクトを保持するスタック - * Stack to hold attachment objects - * - * @type {IAttachmentObject[]} - * @protected - */ public readonly $stackAttachmentObject: IAttachmentObject[]; - - /** - * @description グローバルアルファ - * Global alpha - * - * @type {number} - * @default 1 - * @public - */ public globalAlpha: number; - - /** - * @description 合成モード - * composite mode - * - * @type {IBlendMode} - * @default "normal" - * @public - */ public globalCompositeOperation: IBlendMode; - - /** - * @description イメージのスムージング設定 - * Image smoothing setting - * - * @type {boolean} - * @default false - * @public - */ public imageSmoothingEnabled: boolean; - - /** - * @description 塗りつぶしのRGBAを保持するFloat32Array - * Float32Array that holds the RGBA of the fill - * - * @type {Float32Array} - * @protected - */ public $fillStyle: Float32Array; - - /** - * @description 線のRGBAを保持するFloat32Array - * Float32Array that holds the RGBA of the line - * - * @type {Float32Array} - * @protected - */ public $strokeStyle: Float32Array; - - /** - * @description マスクの描画範囲 - * Drawing range of the mask - * - * @type {IBounds} - * @protected - */ public readonly maskBounds: IBounds; - - /** - * @description ストロークの太さ - * Stroke thickness - * - * @type {number} - * @default 1 - * @public - */ public thickness: number; - - /** - * @description ストロークのキャップ - * Stroke cap - * - * @type {number} - * @default 1 - * @public - */ public caps: number; - - /** - * @description ストロークのジョイント - * Stroke joint - * - * @type {number} - * @default 2 - * @public - */ public joints: number; - - /** - * @description ストロークのマイターリミット - * Stroke miter limit - * - * @type {number} - * @default 0 - * @public - */ public miterLimit: number; + public newDrawState: boolean = false; - /** - * @param {WebGL2RenderingContext} gl - * @param {number} samples - * @param {number} [device_pixel_ratio=1] - * @constructor - * @public - */ constructor ( gl: WebGL2RenderingContext, samples: number, @@ -279,7 +119,7 @@ export class Context // stroke this.thickness = 1; - this.caps = 1; + this.caps = 0; this.joints = 2; this.miterLimit = 0; @@ -307,6 +147,10 @@ export class Context gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); + // 初期化時に1回だけ設定するステート + gl.clearColor(0, 0, 0, 0); + gl.frontFace(gl.CCW); + // FrameBufferManagerの初期起動 $setReadFrameBuffer(gl); $setDrawFrameBuffer(gl); @@ -326,44 +170,16 @@ export class Context $setContext(this); } - /** - * @description 転送範囲をリセット - * Reset the transfer range - * - * @return {void} - * @method - * @public - */ clearTransferBounds (): void { $clearTransferBounds(); } - /** - * @description 背景色を更新 - * Update background color - * - * @param {number} red - * @param {number} green - * @param {number} blue - * @param {number} alpha - * @return {void} - * @method - * @public - */ updateBackgroundColor (red: number, green: number, blue: number, alpha: number): void { contextUpdateBackgroundColorService(this, red, green, blue, alpha); } - /** - * @description 背景色を指定カラーで塗りつぶす - * Fill the background color with the specified color - * - * @return {void} - * @method - * @public - */ fillBackgroundColor (): void { contextFillBackgroundColorService( @@ -374,93 +190,31 @@ export class Context ); } - /** - * @description メインcanvasのサイズを変更 - * Change the size of the main canvas - * - * @param {number} width - * @param {number} height - * @param {boolean} [cache_clear=true] - * @return {void} - * @method - * @public - */ resize (width: number, height: number, cache_clear: boolean = true): void { contextResizeUseCase(this, width, height, cache_clear); } - /** - * @description 指定範囲をクリアする - * Clear the specified range - * - * @param {number} x - * @param {number} y - * @param {number} w - * @param {number} h - * @return {void} - * @method - * @purotected - */ clearRect (x: number, y: number, w: number, h: number): void { contextClearRectUseCase(x, y, w, h); } - /** - * @description アタッチメントオブジェクトをバインド - * Bind the attachment object - * - * @param {IAttachmentObject} attachment_object - * @return {void} - * @method - * @public - */ bind (attachment_object: IAttachmentObject): void { contextBindUseCase(this, attachment_object); } - /** - * @description 現在の2D変換行列を保存 - * Save the current 2D transformation matrix - * - * @return {void} - * @method - * @public - */ save (): void { contextSaveService(this); } - /** - * @description 2D変換行列を復元 - * Restore 2D transformation matrix - * - * @return {void} - * @method - * @public - */ restore (): void { contextRestoreService(this); } - /** - * @description 2D変換行列を設定 - * Set 2D transformation matrix - * - * @param {number} a - * @param {number} b - * @param {number} c - * @param {number} d - * @param {number} e - * @param {number} f - * @return {void} - * @method - * @public - */ setTransform ( a: number, b: number, c: number, d: number, e: number, f: number @@ -468,20 +222,6 @@ export class Context contextSetTransformService(this.$matrix, a, b, c, d, e, f); } - /** - * @description 現在の2D変換行列に対して乗算を行います。 - * Multiply the current 2D transformation matrix. - * - * @param {number} a - * @param {number} b - * @param {number} c - * @param {number} d - * @param {number} e - * @param {number} f - * @return {void} - * @method - * @public - */ transform ( a: number, b: number, c: number, d: number, e: number, f: number @@ -489,27 +229,11 @@ export class Context contextTransformService(this, a, b, c, d, e, f); } - /** - * @description コンテキストの値を初期化する - * Initialize the values of the context - * - * @return {void} - * @method - * @public - */ reset (): void { contextResetService(this); } - /** - * @description パスを開始 - * Start the path - * - * @return {void} - * @method - * @public - */ beginPath (): void { // reset color style @@ -519,65 +243,21 @@ export class Context beginPath(); } - /** - * @description パスを移動 - * Move the path - * - * @param {number} x - * @param {number} y - * @return {void} - * @method - * @public - */ moveTo (x: number, y: number): void { moveTo(x, y); } - /** - * @description パスを線で結ぶ - * Connect the path with a line - * - * @param {number} x - * @param {number} y - * @return {void} - * @method - * @public - */ lineTo (x: number, y: number): void { lineTo(x, y); } - /** - * @description 二次ベジェ曲線を描画 - * Draw a quadratic Bezier curve - * - * @param {number} cx - * @param {number} cy - * @param {number} x - * @param {number} y - * @return {void} - * @method - * @public - */ quadraticCurveTo (cx: number, cy: number, x: number, y: number): void { quadraticCurveTo(cx, cy, x, y); } - /** - * @description 塗りつぶしスタイルを設定 - * Set fill style - * - * @param {number} red - * @param {number} green - * @param {number} blue - * @param {number} alpha - * @return {void} - * @method - * @public - */ fillStyle (red: number, green: number, blue: number, alpha: number): void { this.$fillStyle[0] = red; @@ -586,18 +266,6 @@ export class Context this.$fillStyle[3] = alpha; } - /** - * @description 線のスタイルを設定 - * Set line style - * - * @param {number} red - * @param {number} green - * @param {number} blue - * @param {number} alpha - * @return {void} - * @method - * @public - */ strokeStyle (red: number, green: number, blue: number, alpha: number): void { this.$strokeStyle[0] = red; @@ -606,81 +274,26 @@ export class Context this.$strokeStyle[3] = alpha; } - /** - * @description パスを閉じる - * Close the path - * - * @return {void} - * @method - * @public - */ closePath (): void { closePath(); } - /** - * @description 円弧を描画 - * Draw an arc - * - * @param {number} x - * @param {number} y - * @param {number} radius - * @return {void} - * @method - * @public - */ arc (x: number, y: number, radius: number): void { arc(x, y, radius); } - /** - * @description 3次ベジェ曲線を描画 - * Draw a cubic Bezier curve - * - * @param {number} cx1 - * @param {number} cy1 - * @param {number} cx2 - * @param {number} cy2 - * @param {number} x - * @param {number} y - * @return {void} - * @method - * @public - */ bezierCurveTo (cx1: number, cy1: number, cx2: number, cy2: number, x: number, y: number): void { bezierCurveTo(cx1, cy1, cx2, cy2, x, y); } - /** - * @description 塗りつぶしを実行 - * Perform fill - * - * @return {void} - * @method - * @public - */ fill (): void { contextFillUseCase("fill"); } - /** - * @description グラデーションの塗りつぶしを実行 - * Perform gradient fill - * - * @param {number} type - * @param {array} stops - * @param {Float32Array} matrix - * @param {number} spread - * @param {number} interpolation - * @param {number} focal - * @return {void} - * @method - * @public - */ gradientFill ( type: number, stops: number[], @@ -695,20 +308,6 @@ export class Context ); } - /** - * @description 塗りのピクセルデータを描画 - * Draw pixel data of the fill - * - * @param {Uint8Array} pixels - * @param {Float32Array} matrix - * @param {number} width - * @param {number} height - * @param {boolean} repeat - * @param {boolean} smooth - * @return {void} - * @method - * @public - */ bitmapFill ( pixels: Uint8Array, matrix: Float32Array, @@ -722,33 +321,11 @@ export class Context ); } - /** - * @description 線の描画を実行 - * Perform line drawing - * - * @return {void} - * @method - * @public - */ stroke (): void { contextStrokeUseCase(); } - /** - * @description 線のグラデーションを実行 - * Perform gradient of the line - * - * @param {number} type - * @param {array} stops - * @param {Float32Array} matrix - * @param {number} spread - * @param {number} interpolation - * @param {number} focal - * @return {void} - * @method - * @public - */ gradientStroke ( type: number, stops: number[], @@ -763,20 +340,6 @@ export class Context ); } - /** - * @description 線のピクセルデータを描画 - * Draw pixel data of the line - * - * @param {Uint8Array} pixels - * @param {Float32Array} matrix - * @param {number} width - * @param {number} height - * @param {boolean} repeat - * @param {boolean} smooth - * @return {void} - * @method - * @public - */ bitmapStroke ( pixels: Uint8Array, matrix: Float32Array, @@ -790,131 +353,48 @@ export class Context ); } - /** - * @description マスク処理を実行 - * Perform mask processing - * - * @return {void} - * @method - * @public - */ clip (): void { contextClipUseCase(); } - /** - * @description 現在のアタッチメントオブジェクトを取得 - * Get the current attachment object - * - * @return {IAttachmentObject | null} - * @readonly - * @public - */ get currentAttachmentObject (): IAttachmentObject | null { - return $getCurrentAttachment(); + return $currentAttachment; } - /** - * @description アトラス専用のアタッチメントオブジェクトを取得 - * Get the attachment object for the atlas - * - * @return {IAttachmentObject} - * @readonly - * @public - */ get atlasAttachmentObject (): IAttachmentObject { return $getAtlasAttachmentObject(); } - /** - * @description キャッシュするポジションのノードを作成 - * Create a node for the position to cache - * - * @param {number} width - * @param {number} height - * @return {Node} - * @method - * @public - */ createNode (width: number, height: number): Node { return atlasManagerCreateNodeService(width, height); } - /** - * @description 指定のノードを削除 - * Remove the specified node - * - * @param {Node} node - * @return {void} - * @method - * @public - */ removeNode (node: Node): void { atlasManagerRemoveNodeService(node); } - /** - * @description 指定のノード範囲で描画を開始 - * Start drawing in the specified node range - * - * @param {Node} node - * @return {void} - * @method - * @public - */ beginNodeRendering (node: Node): void { - // 転送範囲を更新 + this.newDrawState = true; contextUpdateTransferBoundsService(node); - - // ノードの描画を開始 contextBeginNodeRenderingService(node.x, node.y, node.w, node.h); } - /** - * @description 指定のノード範囲で描画を終了 - * End drawing in the specified node range - * - * @return {void} - * @method - * @public - */ endNodeRendering (): void { contextEndNodeRenderingService(); } - /** - * @description 塗りの描画を実行 - * Perform fill drawing - * - * @return {void} - * @method - * @public - */ drawFill (): void { contextDrawFillUseCase(); } - /** - * @description インスタンスを描画 - * Draw an instance - * - * @param {number} x_min - * @param {number} y_min - * @param {number} x_max - * @param {number} y_max - * @param {Float32Array} color_transform - * @return {void} - * @method - * @public - */ drawDisplayObject ( node: Node, x_min: number, @@ -923,106 +403,42 @@ export class Context y_max: number, color_transform: Float32Array ): void { - contextUpdateAllTransferBoundsService(node); + contextUpdateTransferBoundsService(node); blnedDrawDisplayObjectUseCase( node, x_min, y_min, x_max, y_max, color_transform ); } - /** - * @description インスタンス配列を描画 - * Draw an instance array - * - * @return {void} - * @method - * @public - */ drawArraysInstanced (): void { blnedDrawArraysInstancedUseCase(); } - /** - * @description インスタンス配列をクリア - * Clear the instance array - * - * @return {void} - * @method - * @public - */ clearArraysInstanced (): void { blnedClearArraysInstancedUseCase(); } - /** - * @description フレームバッファの描画情報をキャンバスに転送 - * Transfer the drawing information of the frame buffer to the canvas - * - * @return {void} - * @method - * @public - */ transferMainCanvas (): void { frameBufferManagerTransferMainCanvasService(); } - /** - * @description ピクセルバッファをNodeの指定箇所に転送 - * Transfer the pixel buffer to the specified location of the Node - * - * @param {Node} node - * @param {Uint8Array} pixels - * @return {void} - * @method - * @public - */ drawPixels (node: Node, pixels: Uint8Array): void { contextDrawPixelsUseCase(node, pixels); } - /** - * @description OffscreenCanvasをNodeの指定箇所に転送 - * Transfer the OffscreenCanvas to the specified location of the Node - * - * @param {Node} node - * @param {OffscreenCanvas | ImageBitmap} element - * @return {void} - * @method - * @public - */ - drawElement (node: Node, element: OffscreenCanvas | ImageBitmap): void + drawElement (node: Node, element: OffscreenCanvas | ImageBitmap, _flipY: boolean = false): void { contextDrawElementUseCase(node, element); } - /** - * @description マスクを開始準備 - * Prepare to start drawing the mask - * - * @return {void} - * @method - * @public - */ beginMask (): void { maskBeginMaskService(); } - /** - * @description マスクの描画を開始 - * Start drawing the mask - * - * @param {number} x_min - * @param {number} y_min - * @param {number} x_max - * @param {number} y_max - * @return {void} - * @method - * @public - */ setMaskBounds ( x_min: number, y_min: number, @@ -1032,65 +448,22 @@ export class Context maskSetMaskBoundsService(x_min, y_min, x_max, y_max); } - /** - * @description マスクの描画を終了 - * End mask drawing - * - * @return {void} - * @method - * @public - */ endMask (): void { maskEndMaskService(); } - /** - * @description マスクの終了処理 - * Mask end processing - * - * @return {void} - * @method - * @public - */ leaveMask (): void { this.drawArraysInstanced(); maskLeaveMaskUseCase(); } - /** - * @description グリッドの描画データをセット - * Set the grid drawing data - * - * @param {Float32Array} grid_data - * @return {void} - * @method - * @public - */ useGrid (grid_data: Float32Array | null): void { contextUseGridService(grid_data); } - /** - * @description フィルターを適用 - * Apply the filter - * - * @param {Node} node - * @param {string} unique_key - * @param {boolean} updated - * @param {number} width - * @param {number} height - * @param {Float32Array} matrix - * @param {Float32Array} color_transform - * @param {IBlendMode} blend_mode - * @param {Float32Array} bounds - * @param {Float32Array} params - * @return {void} - * @method - * @public - */ applyFilter ( node: Node, unique_key: string, @@ -1113,18 +486,45 @@ export class Context ); } - /** - * @description 現在のメインのframe bufferからImageBitmapを生成 - * Generate an ImageBitmap from the current main frame buffer - * - * @param {number} width - * @param {number} height - * @return {Promise} - * @method - * @public - */ + containerBeginLayer (width: number, height: number): void + { + this.drawArraysInstanced(); + contextContainerBeginLayerUseCase(width, height); + } + + containerEndLayer ( + blend_mode: IBlendMode, + matrix: Float32Array, + color_transform: Float32Array | null, + use_filter: boolean, + filter_bounds: Float32Array | null, + filter_params: Float32Array | null, + unique_key: string, + filter_key: string + ): void { + contextContainerEndLayerUseCase( + blend_mode, matrix, color_transform, + use_filter, filter_bounds, filter_params, + unique_key, filter_key + ); + } + + containerDrawCachedFilter ( + blend_mode: IBlendMode, + matrix: Float32Array, + color_transform: Float32Array, + filter_bounds: Float32Array, + unique_key: string, + filter_key: string + ): void { + contextContainerDrawCachedFilterUseCase( + blend_mode, matrix, color_transform, + filter_bounds, unique_key, filter_key + ); + } + async createImageBitmap (width: number, height: number): Promise { return await contextCreateImageBitmapService(width, height); } -} \ No newline at end of file +} diff --git a/packages/webgl/src/Context/service/ContextBeginNodeRenderingService.test.ts b/packages/webgl/src/Context/service/ContextBeginNodeRenderingService.test.ts index 6d7152f0..653c809c 100644 --- a/packages/webgl/src/Context/service/ContextBeginNodeRenderingService.test.ts +++ b/packages/webgl/src/Context/service/ContextBeginNodeRenderingService.test.ts @@ -1,43 +1,54 @@ import { execute } from "./ContextBeginNodeRenderingService"; import { describe, expect, it, vi } from "vitest"; +let scissorCallCount = 0; + +vi.mock("../../WebGLUtil.ts", async (importOriginal) => +{ + const mod = await importOriginal(); + return { + ...mod, + $enableScissorTest: vi.fn(), + $gl: { + "SCISSOR_TEST": "SCISSOR_TEST", + "COLOR_BUFFER_BIT": 1, + "STENCIL_BUFFER_BIT": 2, + "enable": vi.fn(), + "scissor": vi.fn((x, y, w, h) => { + expect(x).toBe(1); + expect(y).toBe(2); + if (scissorCallCount === 0) { + expect(w).toBe(4); + expect(h).toBe(5); + } else { + expect(w).toBe(3); + expect(h).toBe(4); + } + scissorCallCount++; + }), + "clear": vi.fn((v) => { + expect(v).toBe(3); + }), + "viewport": vi.fn((x, y, w, h) => { + expect(x).toBe(1); + expect(y).toBe(2); + expect(w).toBe(3); + expect(h).toBe(4); + }) + }, + $context: { + "$clearColorR": 0, + "$clearColorG": 0, + "$clearColorB": 0, + "$clearColorA": 0 + } + } +}); + describe("ContextBeginNodeRenderingService.js method test", () => { it("test case", () => { - vi.mock("../../WebGLUtil.ts", async (importOriginal) => - { - const mod = await importOriginal(); - return { - ...mod, - $gl: { - "SCISSOR_TEST": "SCISSOR_TEST", - "enable": vi.fn((v) => { - expect(v).toBe("SCISSOR_TEST"); - }), - "scissor": vi.fn((x, y, w, h) => { - expect(x).toBe(1); - expect(y).toBe(2); - if (w === 3) { - expect(w).toBe(3); - expect(h).toBe(4); - } else { - expect(w).toBe(4); - expect(h).toBe(5); - } - - }), - "clear": vi.fn((v) => { return "clear"; }), - "viewport": vi.fn((x, y, w, h) => { - expect(x).toBe(1); - expect(y).toBe(2); - expect(w).toBe(3); - expect(h).toBe(4); - }) - } - } - }); + execute(1, 2, 3, 4); }); - - execute(1, 2, 3, 4); }); \ No newline at end of file diff --git a/packages/webgl/src/Context/service/ContextBeginNodeRenderingService.ts b/packages/webgl/src/Context/service/ContextBeginNodeRenderingService.ts index a077f308..e498fd82 100644 --- a/packages/webgl/src/Context/service/ContextBeginNodeRenderingService.ts +++ b/packages/webgl/src/Context/service/ContextBeginNodeRenderingService.ts @@ -1,5 +1,8 @@ -import { $gl } from "../../WebGLUtil"; +import { + $gl, + $enableScissorTest +} from "../../WebGLUtil"; /** * @description 描画範囲を設定 @@ -16,10 +19,10 @@ import { $gl } from "../../WebGLUtil"; export const execute = (x: number, y: number, w: number, h: number): void => { // 初期化範囲を設定 - $gl.enable($gl.SCISSOR_TEST); + $enableScissorTest(); $gl.scissor(x, y, w + 1, h + 1); - // 初期化 + // 初期化(clearColorは初期化時に(0,0,0,0)設定済み) $gl.clear($gl.COLOR_BUFFER_BIT | $gl.STENCIL_BUFFER_BIT); // 描画領域をあらためて設定 diff --git a/packages/webgl/src/Context/service/ContextCreateImageBitmapService.test.ts b/packages/webgl/src/Context/service/ContextCreateImageBitmapService.test.ts index b2020532..92c1da33 100644 --- a/packages/webgl/src/Context/service/ContextCreateImageBitmapService.test.ts +++ b/packages/webgl/src/Context/service/ContextCreateImageBitmapService.test.ts @@ -60,7 +60,7 @@ describe("ContextCreateImageBitmapService.js method test", () => vi.mock("../../FrameBufferManager", () => ({ $readFrameBuffer: {}, - $getPixelFrameBuffer: vi.fn(() => ({})) + $pixelFrameBuffer: {} })); global.createImageBitmap = vi.fn(async () => ({}) as any); diff --git a/packages/webgl/src/Context/service/ContextCreateImageBitmapService.ts b/packages/webgl/src/Context/service/ContextCreateImageBitmapService.ts index de26a90d..8d6e1430 100644 --- a/packages/webgl/src/Context/service/ContextCreateImageBitmapService.ts +++ b/packages/webgl/src/Context/service/ContextCreateImageBitmapService.ts @@ -3,7 +3,7 @@ import { execute as textureManagerBind0UseCase } from "../../TextureManager/usec import { execute as textureManagerGetMainTextureFromBoundsUseCase } from "../../TextureManager/usecase/TextureManagerGetMainTextureFromBoundsUseCase"; import { $readFrameBuffer, - $getPixelFrameBuffer + $pixelFrameBuffer } from "../../FrameBufferManager"; import { $context, @@ -45,7 +45,7 @@ export const execute = async (width: number, height: number): Promise { - $gl.disable($gl.SCISSOR_TEST); + $disableScissorTest(); }; diff --git a/packages/webgl/src/Context/service/ContextResetService.test.ts b/packages/webgl/src/Context/service/ContextResetService.test.ts index 9a5ba039..ead1b6e0 100644 --- a/packages/webgl/src/Context/service/ContextResetService.test.ts +++ b/packages/webgl/src/Context/service/ContextResetService.test.ts @@ -17,6 +17,8 @@ describe("ContextResetService.js method test", () => "createFramebuffer": vi.fn(() => "createFramebuffer"), "bindFramebuffer": vi.fn(() => "bindFramebuffer"), "clearColor": vi.fn(() => "clearColor"), + "frontFace": vi.fn(() => "frontFace"), + "CCW": 1, "createRenderbuffer": vi.fn(() => "createRenderbuffer"), "bindRenderbuffer": vi.fn(() => "bindRenderbuffer"), "renderbufferStorageMultisample": vi.fn(() => "renderbufferStorageMultisample"), diff --git a/packages/webgl/src/Context/service/ContextResetStyleService.test.ts b/packages/webgl/src/Context/service/ContextResetStyleService.test.ts index d49d9e37..7f8b7df9 100644 --- a/packages/webgl/src/Context/service/ContextResetStyleService.test.ts +++ b/packages/webgl/src/Context/service/ContextResetStyleService.test.ts @@ -17,6 +17,8 @@ describe("ContextResetStyleService.js method test", () => "createFramebuffer": vi.fn(() => "createFramebuffer"), "bindFramebuffer": vi.fn(() => "bindFramebuffer"), "clearColor": vi.fn(() => "clearColor"), + "frontFace": vi.fn(() => "frontFace"), + "CCW": 1, "createRenderbuffer": vi.fn(() => "createRenderbuffer"), "bindRenderbuffer": vi.fn(() => "bindRenderbuffer"), "renderbufferStorageMultisample": vi.fn(() => "renderbufferStorageMultisample"), diff --git a/packages/webgl/src/Context/service/ContextRestoreService.test.ts b/packages/webgl/src/Context/service/ContextRestoreService.test.ts index b2d4812e..6ad013a8 100644 --- a/packages/webgl/src/Context/service/ContextRestoreService.test.ts +++ b/packages/webgl/src/Context/service/ContextRestoreService.test.ts @@ -17,6 +17,8 @@ describe("ContextRestoreService.js method test", () => "createFramebuffer": vi.fn(() => "createFramebuffer"), "bindFramebuffer": vi.fn(() => "bindFramebuffer"), "clearColor": vi.fn(() => "clearColor"), + "frontFace": vi.fn(() => "frontFace"), + "CCW": 1, "createRenderbuffer": vi.fn(() => "createRenderbuffer"), "bindRenderbuffer": vi.fn(() => "bindRenderbuffer"), "renderbufferStorageMultisample": vi.fn(() => "renderbufferStorageMultisample"), diff --git a/packages/webgl/src/Context/service/ContextSaveService.test.ts b/packages/webgl/src/Context/service/ContextSaveService.test.ts index 469e1de1..547011e0 100644 --- a/packages/webgl/src/Context/service/ContextSaveService.test.ts +++ b/packages/webgl/src/Context/service/ContextSaveService.test.ts @@ -17,6 +17,8 @@ describe("ContextSaveService.js method test", () => "createFramebuffer": vi.fn(() => "createFramebuffer"), "bindFramebuffer": vi.fn(() => "bindFramebuffer"), "clearColor": vi.fn(() => "clearColor"), + "frontFace": vi.fn(() => "frontFace"), + "CCW": 1, "createRenderbuffer": vi.fn(() => "createRenderbuffer"), "bindRenderbuffer": vi.fn(() => "bindRenderbuffer"), "renderbufferStorageMultisample": vi.fn(() => "renderbufferStorageMultisample"), diff --git a/packages/webgl/src/Context/service/ContextTransformService.test.ts b/packages/webgl/src/Context/service/ContextTransformService.test.ts index d0228345..3ef5a674 100644 --- a/packages/webgl/src/Context/service/ContextTransformService.test.ts +++ b/packages/webgl/src/Context/service/ContextTransformService.test.ts @@ -17,6 +17,8 @@ describe("ContextTransformService.js method test", () => "createFramebuffer": vi.fn(() => "createFramebuffer"), "bindFramebuffer": vi.fn(() => "bindFramebuffer"), "clearColor": vi.fn(() => "clearColor"), + "frontFace": vi.fn(() => "frontFace"), + "CCW": 1, "createRenderbuffer": vi.fn(() => "createRenderbuffer"), "bindRenderbuffer": vi.fn(() => "bindRenderbuffer"), "renderbufferStorageMultisample": vi.fn(() => "renderbufferStorageMultisample"), diff --git a/packages/webgl/src/Context/service/ContextUpdateAllTransferBoundsService.test.ts b/packages/webgl/src/Context/service/ContextUpdateAllTransferBoundsService.test.ts deleted file mode 100644 index 581844c9..00000000 --- a/packages/webgl/src/Context/service/ContextUpdateAllTransferBoundsService.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { execute } from "./ContextUpdateAllTransferBoundsService"; -import { describe, expect, it } from "vitest"; -import { Node } from "@next2d/texture-packer"; -import { $getActiveAllTransferBounds } from "../../AtlasManager"; - -describe("ContextUpdateAllTransferBoundsService.js method test", () => -{ - it("test case", () => - { - const node = new Node(0, 10, 20, 100, 200); - const bounds = $getActiveAllTransferBounds(node.index); - - expect(bounds[0]).toBe(Infinity); - expect(bounds[1]).toBe(Infinity); - expect(bounds[2]).toBe(-Infinity); - expect(bounds[3]).toBe(-Infinity); - - execute(node); - - expect(bounds[0]).toBe(10); - expect(bounds[1]).toBe(20); - expect(bounds[2]).toBe(110); - expect(bounds[3]).toBe(220); - }); -}); \ No newline at end of file diff --git a/packages/webgl/src/Context/service/ContextUpdateAllTransferBoundsService.ts b/packages/webgl/src/Context/service/ContextUpdateAllTransferBoundsService.ts deleted file mode 100644 index 108de9e4..00000000 --- a/packages/webgl/src/Context/service/ContextUpdateAllTransferBoundsService.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Node } from "@next2d/texture-packer"; -import { $getActiveAllTransferBounds } from "../../AtlasManager"; - -/** - * @description 切り替え時の転写範囲を更新します。 - * Update the transfer range when switching. - * - * @param {Node} node - * @return {void} - * @method - * @protected - */ -export const execute = (node: Node): void => -{ - const bounds = $getActiveAllTransferBounds(node.index); - const xMin = bounds[0]; - const yMin = bounds[1]; - const xMax = bounds[2]; - const yMax = bounds[3]; - - bounds[0] = Math.min(node.x, xMin); - bounds[1] = Math.min(node.y, yMin); - bounds[2] = Math.max(node.x + node.w, xMax); - bounds[3] = Math.max(node.y + node.h, yMax); -}; \ No newline at end of file diff --git a/packages/webgl/src/Context/service/ContextUpdateBackgroundColorService.test.ts b/packages/webgl/src/Context/service/ContextUpdateBackgroundColorService.test.ts index 0f602be5..1ec98690 100644 --- a/packages/webgl/src/Context/service/ContextUpdateBackgroundColorService.test.ts +++ b/packages/webgl/src/Context/service/ContextUpdateBackgroundColorService.test.ts @@ -17,6 +17,8 @@ describe("ContextUpdateBackgroundColorService.js method test", () => "createFramebuffer": vi.fn(() => "createFramebuffer"), "bindFramebuffer": vi.fn(() => "bindFramebuffer"), "clearColor": vi.fn(() => "clearColor"), + "frontFace": vi.fn(() => "frontFace"), + "CCW": 1, "createRenderbuffer": vi.fn(() => "createRenderbuffer"), "bindRenderbuffer": vi.fn(() => "bindRenderbuffer"), "renderbufferStorageMultisample": vi.fn(() => "renderbufferStorageMultisample"), diff --git a/packages/webgl/src/Context/service/ContextUpdateTransferBoundsService.test.ts b/packages/webgl/src/Context/service/ContextUpdateTransferBoundsService.test.ts index c384df08..cbded80f 100644 --- a/packages/webgl/src/Context/service/ContextUpdateTransferBoundsService.test.ts +++ b/packages/webgl/src/Context/service/ContextUpdateTransferBoundsService.test.ts @@ -19,7 +19,7 @@ describe("ContextUpdateTransferBoundsService.js method test", () => expect(bounds[0]).toBe(10); expect(bounds[1]).toBe(20); - expect(bounds[2]).toBe(110); - expect(bounds[3]).toBe(220); + expect(bounds[2]).toBe(111); + expect(bounds[3]).toBe(221); }); }); \ No newline at end of file diff --git a/packages/webgl/src/Context/service/ContextUpdateTransferBoundsService.ts b/packages/webgl/src/Context/service/ContextUpdateTransferBoundsService.ts index 4ac4112f..61758f01 100644 --- a/packages/webgl/src/Context/service/ContextUpdateTransferBoundsService.ts +++ b/packages/webgl/src/Context/service/ContextUpdateTransferBoundsService.ts @@ -20,6 +20,6 @@ export const execute = (node: Node): void => bounds[0] = Math.min(node.x, xMin); bounds[1] = Math.min(node.y, yMin); - bounds[2] = Math.max(node.x + node.w, xMax); - bounds[3] = Math.max(node.y + node.h, yMax); + bounds[2] = Math.max(node.x + node.w + 1, xMax); + bounds[3] = Math.max(node.y + node.h + 1, yMax); }; \ No newline at end of file diff --git a/packages/webgl/src/Context/usecase/ContextApplyFilterUseCase.test.ts b/packages/webgl/src/Context/usecase/ContextApplyFilterUseCase.test.ts index 2df86a18..df59f9d7 100644 --- a/packages/webgl/src/Context/usecase/ContextApplyFilterUseCase.test.ts +++ b/packages/webgl/src/Context/usecase/ContextApplyFilterUseCase.test.ts @@ -55,7 +55,7 @@ vi.mock("../../WebGLUtil.ts", async (importOriginal) => { globalCompositeOperation: "normal" }, $getFloat32Array6: vi.fn((...args: number[]) => new Float32Array(args)), - $getDevicePixelRatio: vi.fn(() => 1), + $devicePixelRatio: 1, $multiplyMatrices: vi.fn(() => new Float32Array([1, 0, 0, 1, 0, 0])), $poolFloat32Array6: vi.fn() }; diff --git a/packages/webgl/src/Context/usecase/ContextApplyFilterUseCase.ts b/packages/webgl/src/Context/usecase/ContextApplyFilterUseCase.ts index 99963e2b..867cdb20 100644 --- a/packages/webgl/src/Context/usecase/ContextApplyFilterUseCase.ts +++ b/packages/webgl/src/Context/usecase/ContextApplyFilterUseCase.ts @@ -24,11 +24,20 @@ import { $offset } from "../../Filter"; import { $context, $getFloat32Array6, - $getDevicePixelRatio, + $devicePixelRatio, $multiplyMatrices, $poolFloat32Array6 } from "../../WebGLUtil"; +/** + * @description ColorMatrixFilter用の再利用可能なバッファ(GC回避) + * Reusable buffer for ColorMatrixFilter (avoid GC) + * + * @type {Float32Array} + * @private + */ +const $colorMatrixBuffer: Float32Array = new Float32Array(20); + /** * @description フィルターを適用します。 * Apply the filter. @@ -179,15 +188,13 @@ export const execute = ( break; case 2: // ColorMatrixFilter + // 再利用可能なバッファを使用(GC回避) + for (let i = 0; i < 20; ++i) { + $colorMatrixBuffer[i] = params[idx++]; + } textureObject = filterApplyColorMatrixFilterUseCase( textureObject, - new Float32Array([ - params[idx++], params[idx++], params[idx++], params[idx++], - params[idx++], params[idx++], params[idx++], params[idx++], - params[idx++], params[idx++], params[idx++], params[idx++], - params[idx++], params[idx++], params[idx++], params[idx++], - params[idx++], params[idx++], params[idx++], params[idx++] - ]) + $colorMatrixBuffer ); break; @@ -216,7 +223,7 @@ export const execute = ( idx += length; textureObject = filterApplyDisplacementMapFilterUseCase( - textureObject, matrix, buffer, params[idx++], params[idx++], + textureObject, buffer, params[idx++], params[idx++], params[idx++], params[idx++], params[idx++], params[idx++], params[idx++], params[idx++], params[idx++], params[idx++], params[idx++] ); @@ -301,7 +308,7 @@ export const execute = ( if (textureObject) { - const devicePixelRatio = $getDevicePixelRatio(); + const devicePixelRatio = $devicePixelRatio; const xMin = bounds[0] * (scaleX / devicePixelRatio); const yMin = bounds[1] * (scaleY / devicePixelRatio); diff --git a/packages/webgl/src/Context/usecase/ContextBindUseCase.test.ts b/packages/webgl/src/Context/usecase/ContextBindUseCase.test.ts index 61386234..ad873962 100644 --- a/packages/webgl/src/Context/usecase/ContextBindUseCase.test.ts +++ b/packages/webgl/src/Context/usecase/ContextBindUseCase.test.ts @@ -4,7 +4,7 @@ import { execute } from "./ContextBindUseCase"; import { describe, expect, it, vi } from "vitest"; import { $setCurrentAttachment, - $getCurrentAttachment + $currentAttachment } from "../../FrameBufferManager"; describe("ContextBindUseCase.js method test", () => @@ -22,6 +22,8 @@ describe("ContextBindUseCase.js method test", () => "createFramebuffer": vi.fn(() => "createFramebuffer"), "bindFramebuffer": vi.fn(() => "bindFramebuffer"), "clearColor": vi.fn(() => "clearColor"), + "frontFace": vi.fn(() => "frontFace"), + "CCW": 1, "createRenderbuffer": vi.fn(() => "createRenderbuffer"), "bindRenderbuffer": vi.fn(() => "bindRenderbuffer"), "renderbufferStorageMultisample": vi.fn(() => "renderbufferStorageMultisample"), @@ -51,7 +53,7 @@ describe("ContextBindUseCase.js method test", () => } as unknown as WebGL2RenderingContext; $setCurrentAttachment(null); - expect($getCurrentAttachment()).toBe(null); + expect($currentAttachment).toBe(null); const context = new Context(mockGL, 4); const attachment_object: IAttachmentObject = { @@ -78,7 +80,7 @@ describe("ContextBindUseCase.js method test", () => execute(context, attachment_object); - expect($getCurrentAttachment()).toBe(attachment_object); + expect($currentAttachment).toBe(attachment_object); expect(attachment_object.stencil?.dirty).toBe(false); }); }); \ No newline at end of file diff --git a/packages/webgl/src/Context/usecase/ContextBindUseCase.ts b/packages/webgl/src/Context/usecase/ContextBindUseCase.ts index 4e28e5e2..f4f4d7cc 100644 --- a/packages/webgl/src/Context/usecase/ContextBindUseCase.ts +++ b/packages/webgl/src/Context/usecase/ContextBindUseCase.ts @@ -2,7 +2,7 @@ import type { IAttachmentObject } from "../../interface/IAttachmentObject"; import type { IColorBufferObject } from "../../interface/IColorBufferObject"; import type { IStencilBufferObject } from "../../interface/IStencilBufferObject"; import type { Context } from "../../Context"; -import { $getCurrentAttachment } from "../../FrameBufferManager"; +import { $currentAttachment } from "../../FrameBufferManager"; import { execute as frameBufferManagerBindAttachmentObjectService } from "../../FrameBufferManager/service/FrameBufferManagerBindAttachmentObjectService"; import { execute as maskBindUseCase } from "../../Mask/usecase/MaskBindUseCase"; import { @@ -23,7 +23,7 @@ import { export const execute = (context: Context, attachment_object: IAttachmentObject): void => { // fixed logic - const currentAttachment = $getCurrentAttachment(); + const currentAttachment = $currentAttachment; if (currentAttachment && attachment_object.id === currentAttachment.id ) { @@ -47,11 +47,9 @@ export const execute = (context: Context, attachment_object: IAttachmentObject): : attachment_object.stencil as IStencilBufferObject; // 再利用のオブジェクトの場合は、描画情報をクリアする + // clearColorは初期化時に(0,0,0,0)で固定済みのためそのまま使用 if (object.dirty) { - object.dirty = false; - - // 無色透明で初期化 context.clearRect(0, 0, attachment_object.width, attachment_object.height); } diff --git a/packages/webgl/src/Context/usecase/ContextClearRectUseCase.test.ts b/packages/webgl/src/Context/usecase/ContextClearRectUseCase.test.ts index a6f20d78..2fb21630 100644 --- a/packages/webgl/src/Context/usecase/ContextClearRectUseCase.test.ts +++ b/packages/webgl/src/Context/usecase/ContextClearRectUseCase.test.ts @@ -19,9 +19,11 @@ describe("ContextClearRectUseCase.js method test", () => expect(x).toBe(1); expect(y).toBe(2); expect(w).toBe(3); - expect(h).toBe(4); + expect(h).toBe(4); }), - } + }, + $enableScissorTest: vi.fn(), + $disableScissorTest: vi.fn(), } }); diff --git a/packages/webgl/src/Context/usecase/ContextClearRectUseCase.ts b/packages/webgl/src/Context/usecase/ContextClearRectUseCase.ts index 70431621..83ee6658 100644 --- a/packages/webgl/src/Context/usecase/ContextClearRectUseCase.ts +++ b/packages/webgl/src/Context/usecase/ContextClearRectUseCase.ts @@ -1,4 +1,8 @@ -import { $gl } from "../../WebGLUtil"; +import { + $gl, + $enableScissorTest, + $disableScissorTest +} from "../../WebGLUtil"; /** * @description 指定範囲をクリアする @@ -15,8 +19,8 @@ import { $gl } from "../../WebGLUtil"; export const execute = (x: number, y: number, w: number, h: number): void => { // 指定範囲をクリア - $gl.enable($gl.SCISSOR_TEST); + $enableScissorTest(); $gl.scissor(x, y, w, h); $gl.clear($gl.COLOR_BUFFER_BIT | $gl.STENCIL_BUFFER_BIT); - $gl.disable($gl.SCISSOR_TEST); + $disableScissorTest(); }; \ No newline at end of file diff --git a/packages/webgl/src/Context/usecase/ContextClipUseCase.ts b/packages/webgl/src/Context/usecase/ContextClipUseCase.ts index f657bf9b..a0c6aa3f 100644 --- a/packages/webgl/src/Context/usecase/ContextClipUseCase.ts +++ b/packages/webgl/src/Context/usecase/ContextClipUseCase.ts @@ -10,7 +10,9 @@ import { } from "../../Mask"; import { $gl, - $context + $context, + $enableScissorTest, + $disableScissorTest } from "../../WebGLUtil"; import { $fillBufferIndexes, @@ -45,7 +47,7 @@ export const execute = (): void => const width = Math.ceil(Math.abs(xMax - xMin)); const height = Math.ceil(Math.abs(yMax - yMin)); - $gl.enable($gl.SCISSOR_TEST); + $enableScissorTest(); $gl.scissor( xMin, currentAttachmentObject.height - yMin - height, @@ -98,5 +100,5 @@ export const execute = (): void => $clearFillBufferSetting(); $terminateGrid(); - $gl.disable($gl.SCISSOR_TEST); + $disableScissorTest(); }; \ No newline at end of file diff --git a/packages/webgl/src/Context/usecase/ContextContainerBeginLayerUseCase.ts b/packages/webgl/src/Context/usecase/ContextContainerBeginLayerUseCase.ts new file mode 100644 index 00000000..394994f9 --- /dev/null +++ b/packages/webgl/src/Context/usecase/ContextContainerBeginLayerUseCase.ts @@ -0,0 +1,40 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import { execute as frameBufferManagerGetAttachmentObjectUseCase } from "../../FrameBufferManager/usecase/FrameBufferManagerGetAttachmentObjectUseCase"; +import { $context } from "../../WebGLUtil"; + +/** + * @description コンテナレイヤーのスタック + * Container layer stack + * + * @type {IAttachmentObject[]} + * @protected + */ +export const $containerLayerStack: IAttachmentObject[] = []; + +/** + * @description コンテナのフィルター/ブレンド用のレイヤーを開始します。 + * Begin a container layer for filter/blend processing. + * + * @return {void} + * @method + * @protected + */ +export const execute = (width: number, height: number): void => { + + // レイヤー切り替え前に、今のメインFBOに対する未描画のインスタンスをフラッシュ + $context.drawArraysInstanced(); + + const mainAttachment = $context.$mainAttachmentObject as IAttachmentObject; + + // 現在のmainAttachmentObjectをスタックに保存 + $containerLayerStack.push(mainAttachment); + + // メインと同じサイズの一時アタッチメントを作成 + const layerAttachment = frameBufferManagerGetAttachmentObjectUseCase(width, height, false); + + // 一時アタッチメントをmainに設定 + $context.$mainAttachmentObject = layerAttachment; + + // 一時アタッチメントをバインド + $context.bind(layerAttachment); +}; diff --git a/packages/webgl/src/Context/usecase/ContextContainerDrawCachedFilterUseCase.ts b/packages/webgl/src/Context/usecase/ContextContainerDrawCachedFilterUseCase.ts new file mode 100644 index 00000000..d377afc1 --- /dev/null +++ b/packages/webgl/src/Context/usecase/ContextContainerDrawCachedFilterUseCase.ts @@ -0,0 +1,57 @@ +import type { IBlendMode } from "../../interface/IBlendMode"; +import type { ITextureObject } from "../../interface/ITextureObject"; +import { execute as blendDrawFilterToMainUseCase } from "../../Blend/usecase/BlendDrawFilterToMainUseCase"; +import { $cacheStore } from "@next2d/cache"; +import { + $context, + $devicePixelRatio +} from "../../WebGLUtil"; + +/** + * @description キャッシュされたフィルターテクスチャをメインに描画します。 + * Draw a cached filter texture to the main attachment. + * + * @param {IBlendMode} blend_mode + * @param {Float32Array} matrix + * @param {Float32Array} color_transform + * @param {Float32Array} filter_bounds + * @param {string} unique_key + * @param {string} filter_key + * @return {void} + * @method + * @public + */ +export const execute = ( + blend_mode: IBlendMode, + matrix: Float32Array, + color_transform: Float32Array, + filter_bounds: Float32Array, + unique_key: string, + filter_key: string +): void => { + + const cachedKey = $cacheStore.get(unique_key, "fKey"); + if (cachedKey !== filter_key) { + return; + } + + const textureObject = $cacheStore.get(unique_key, "fTexture") as ITextureObject; + if (!textureObject) { + return; + } + + const scaleX = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]); + const scaleY = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]); + const devicePixelRatio = $devicePixelRatio; + const boundsXMin = filter_bounds[0] * (scaleX / devicePixelRatio); + const boundsYMin = filter_bounds[1] * (scaleY / devicePixelRatio); + + $context.drawArraysInstanced(); + $context.reset(); + $context.globalCompositeOperation = blend_mode; + blendDrawFilterToMainUseCase( + textureObject, color_transform, + boundsXMin + matrix[4], + boundsYMin + matrix[5] + ); +}; diff --git a/packages/webgl/src/Context/usecase/ContextContainerEndLayerUseCase.ts b/packages/webgl/src/Context/usecase/ContextContainerEndLayerUseCase.ts new file mode 100644 index 00000000..e215fcdf --- /dev/null +++ b/packages/webgl/src/Context/usecase/ContextContainerEndLayerUseCase.ts @@ -0,0 +1,284 @@ +import type { IBlendMode } from "../../interface/IBlendMode"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { ITextureObject } from "../../interface/ITextureObject"; +import { $containerLayerStack } from "./ContextContainerBeginLayerUseCase"; +import { execute as frameBufferManagerGetTextureFromBoundsUseCase } from "../../FrameBufferManager/usecase/FrameBufferManagerGetTextureFromBoundsUseCase"; +import { execute as frameBufferManagerReleaseAttachmentObjectUseCase } from "../../FrameBufferManager/usecase/FrameBufferManagerReleaseAttachmentObjectUseCase"; +import { execute as blendDrawFilterToMainUseCase } from "../../Blend/usecase/BlendDrawFilterToMainUseCase"; +import { execute as textureManagerReleaseTextureObjectUseCase } from "../../TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase"; +import { execute as filterApplyBlurFilterUseCase } from "../../Filter/BlurFilter/usecase/FilterApplyBlurFilterUseCase"; +import { execute as filterApplyBevelFilterUseCase } from "../../Filter/BevelFilter/usecase/FilterApplyBevelFilterUseCase"; +import { execute as filterApplyColorMatrixFilterUseCase } from "../../Filter/ColorMatrixFilter/usecase/FilterApplyColorMatrixFilterUseCase"; +import { execute as filterApplyConvolutionFilterUseCase } from "../../Filter/ConvolutionFilter/usecase/FilterApplyConvolutionFilterUseCase"; +import { execute as filterApplyDisplacementMapFilterUseCase } from "../../Filter/DisplacementMapFilter/usecase/FilterApplyDisplacementMapFilterUseCase"; +import { execute as filterApplyDropShadowFilterUseCase } from "../../Filter/DropShadowFilter/usecase/FilterApplyDropShadowFilterUseCase"; +import { execute as filterApplyGlowFilterUseCase } from "../../Filter/GlowFilter/usecase/FilterApplyGlowFilterUseCase"; +import { execute as filterApplyGradientBevelFilterUseCase } from "../../Filter/GradientBevelFilter/usecase/FilterApplyGradientBevelFilterUseCase"; +import { execute as filterApplyGradientGlowFilterUseCase } from "../../Filter/GradientGlowFilter/usecase/FilterApplyGradientGlowFilterUseCase"; +import { $offset } from "../../Filter"; +import { $cacheStore } from "@next2d/cache"; +import { + $context, + $devicePixelRatio +} from "../../WebGLUtil"; + +/** + * @description ColorMatrixFilter用の再利用可能なバッファ + * Reusable buffer for ColorMatrixFilter + * + * @type {Float32Array} + * @private + */ +const $colorMatrixBuffer: Float32Array = new Float32Array(20); + +/** + * @description コンテナのフィルター/ブレンド用レイヤーを終了し、結果を元のメインに合成します。 + * End the container layer and composite the result back to the original main. + * + * @param {IBlendMode} blend_mode + * @param {Float32Array} matrix + * @param {Float32Array | null} color_transform + * @param {boolean} use_filter + * @param {Float32Array | null} filter_bounds + * @param {Float32Array | null} filter_params + * @param {string} unique_key + * @param {string} filter_key + * @return {void} + * @method + * @public + */ +export const execute = ( + blend_mode: IBlendMode, + matrix: Float32Array, + color_transform: Float32Array | null, + use_filter: boolean, + filter_bounds: Float32Array | null, + filter_params: Float32Array | null, + unique_key: string, + filter_key: string +): void => { + + // 一時アタッチメントへの描画をフラッシュ + $context.drawArraysInstanced(); + + const layerAttachment = $context.$mainAttachmentObject as IAttachmentObject; + + let textureObject: ITextureObject | null = null; + + if (use_filter && filter_bounds && filter_params) { + + // containerEndLayerが呼ばれる=ディスプレイレイヤーがコンテンツ変更を検出して再レンダリングを要求 + // 常に新鮮なテクスチャを抽出してフィルターを適用する + // (キャッシュはディスプレイレイヤーのcontainerDrawCachedFilterで管理) + + // レイヤーが有効な間にテクスチャを切り出し(リリース前に実行) + textureObject = frameBufferManagerGetTextureFromBoundsUseCase( + 0, 0, layerAttachment.width, layerAttachment.height + ); + + // mainを復元 + $context.$mainAttachmentObject = $containerLayerStack.pop() as IAttachmentObject; + + // 一時アタッチメントを解放 + frameBufferManagerReleaseAttachmentObjectUseCase(layerAttachment); + + // メインのアタッチメントをバインド(currentAttachmentObjectを更新) + $context.bind($context.$mainAttachmentObject as IAttachmentObject); + + // フィルターチェーンを適用 + $offset.x = 0; + $offset.y = 0; + + for (let idx = 0; filter_params.length > idx; ) { + + const type = filter_params[idx++]; + switch (type) { + + case 0: // BevelFilter + textureObject = filterApplyBevelFilterUseCase( + textureObject, matrix, + filter_params[idx++], filter_params[idx++], filter_params[idx++], filter_params[idx++], + filter_params[idx++], filter_params[idx++], filter_params[idx++], filter_params[idx++], + filter_params[idx++], filter_params[idx++], filter_params[idx++], Boolean(filter_params[idx++]) + ); + break; + + case 1: // BlurFilter + textureObject = filterApplyBlurFilterUseCase( + textureObject, matrix, + filter_params[idx++], filter_params[idx++], filter_params[idx++] + ); + break; + + case 2: // ColorMatrixFilter + for (let i = 0; i < 20; ++i) { + $colorMatrixBuffer[i] = filter_params[idx++]; + } + textureObject = filterApplyColorMatrixFilterUseCase( + textureObject, + $colorMatrixBuffer + ); + break; + + case 3: // ConvolutionFilter + { + const matrixX = filter_params[idx++]; + const matrixY = filter_params[idx++]; + const length = matrixX * matrixY; + const convMatrix = filter_params.subarray(idx, idx + length); + idx += length; + + textureObject = filterApplyConvolutionFilterUseCase( + textureObject, matrixX, matrixY, convMatrix, + filter_params[idx++], filter_params[idx++], + Boolean(filter_params[idx++]), Boolean(filter_params[idx++]), + filter_params[idx++], filter_params[idx++] + ); + } + break; + + case 4: // DisplacementMapFilter + { + const length = filter_params[idx++]; + const buffer = new Uint8Array(length); + buffer.set(filter_params.subarray(idx, idx + length)); + idx += length; + + textureObject = filterApplyDisplacementMapFilterUseCase( + textureObject, buffer, + filter_params[idx++], filter_params[idx++], + filter_params[idx++], filter_params[idx++], + filter_params[idx++], filter_params[idx++], + filter_params[idx++], filter_params[idx++], + filter_params[idx++], filter_params[idx++], + filter_params[idx++] + ); + } + break; + + case 5: // DropShadowFilter + textureObject = filterApplyDropShadowFilterUseCase( + textureObject, matrix, + filter_params[idx++], filter_params[idx++], filter_params[idx++], filter_params[idx++], + filter_params[idx++], filter_params[idx++], filter_params[idx++], filter_params[idx++], + Boolean(filter_params[idx++]), Boolean(filter_params[idx++]), Boolean(filter_params[idx++]) + ); + break; + + case 6: // GlowFilter + textureObject = filterApplyGlowFilterUseCase( + textureObject, matrix, + filter_params[idx++], filter_params[idx++], filter_params[idx++], filter_params[idx++], + filter_params[idx++], filter_params[idx++], + Boolean(filter_params[idx++]), Boolean(filter_params[idx++]) + ); + break; + + case 7: // GradientBevelFilter + { + const distance = filter_params[idx++]; + const angle = filter_params[idx++]; + + let length = filter_params[idx++]; + const colors = filter_params.subarray(idx, idx + length); + idx += length; + + length = filter_params[idx++]; + const alphas = filter_params.subarray(idx, idx + length); + idx += length; + + length = filter_params[idx++]; + const ratios = filter_params.subarray(idx, idx + length); + idx += length; + + textureObject = filterApplyGradientBevelFilterUseCase( + textureObject, matrix, + distance, angle, colors, alphas, ratios, + filter_params[idx++], filter_params[idx++], filter_params[idx++], + filter_params[idx++], filter_params[idx++], Boolean(filter_params[idx++]) + ); + } + break; + + case 8: // GradientGlowFilter + { + const distance = filter_params[idx++]; + const angle = filter_params[idx++]; + + let length = filter_params[idx++]; + const colors = filter_params.subarray(idx, idx + length); + idx += length; + + length = filter_params[idx++]; + const alphas = filter_params.subarray(idx, idx + length); + idx += length; + + length = filter_params[idx++]; + const ratios = filter_params.subarray(idx, idx + length); + idx += length; + + textureObject = filterApplyGradientGlowFilterUseCase( + textureObject, matrix, + distance, angle, colors, alphas, ratios, + filter_params[idx++], filter_params[idx++], filter_params[idx++], + filter_params[idx++], filter_params[idx++], Boolean(filter_params[idx++]) + ); + } + break; + } + } + + // キャッシュに保存 + if (unique_key) { + $cacheStore.set(unique_key, "fKey", filter_key); + $cacheStore.set(unique_key, "fTexture", textureObject); + } + + // フィルター結果をメインに描画 + if (textureObject) { + const scaleX = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]); + const scaleY = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]); + const devicePixelRatio = $devicePixelRatio; + const boundsXMin = filter_bounds[0] * (scaleX / devicePixelRatio); + const boundsYMin = filter_bounds[1] * (scaleY / devicePixelRatio); + + $context.reset(); + $context.globalCompositeOperation = blend_mode; + blendDrawFilterToMainUseCase( + textureObject, color_transform as Float32Array, + boundsXMin + matrix[4], + boundsYMin + matrix[5] + ); + } + + } else { + + // ブレンドのみの場合:テクスチャを取得 + textureObject = frameBufferManagerGetTextureFromBoundsUseCase( + 0, 0, layerAttachment.width, layerAttachment.height + ); + + // mainを復元 + $context.$mainAttachmentObject = $containerLayerStack.pop() as IAttachmentObject; + + // 一時アタッチメントを解放 + frameBufferManagerReleaseAttachmentObjectUseCase(layerAttachment); + + // メインのアタッチメントをバインド(currentAttachmentObjectを更新) + $context.bind($context.$mainAttachmentObject as IAttachmentObject); + + if (textureObject) { + $context.reset(); + $context.globalCompositeOperation = blend_mode; + blendDrawFilterToMainUseCase( + textureObject, color_transform as Float32Array, + matrix[4], matrix[5] + ); + + textureManagerReleaseTextureObjectUseCase(textureObject); + } + } + + // メインのアタッチメントをバインド + $context.bind($context.$mainAttachmentObject as IAttachmentObject); +}; diff --git a/packages/webgl/src/Context/usecase/ContextDrawFillUseCase.test.ts b/packages/webgl/src/Context/usecase/ContextDrawFillUseCase.test.ts index 64aba267..09e83aaf 100644 --- a/packages/webgl/src/Context/usecase/ContextDrawFillUseCase.test.ts +++ b/packages/webgl/src/Context/usecase/ContextDrawFillUseCase.test.ts @@ -21,6 +21,9 @@ vi.mock("./ContextRadialGradientFillUseCase", () => ({ vi.mock("./ContextPatternBitmapFillUseCase", () => ({ execute: vi.fn() })); +vi.mock("../../Stencil/service/StencilResetService", () => ({ + execute: vi.fn() +})); vi.mock("../../WebGLUtil.ts", async (importOriginal) => { const mod = await importOriginal(); @@ -33,7 +36,9 @@ vi.mock("../../WebGLUtil.ts", async (importOriginal) => { stencilMask: vi.fn(), STENCIL_TEST: 0, CCW: 1 - } + }, + $enableStencilTest: vi.fn(), + $disableStencilTest: vi.fn() }; }); diff --git a/packages/webgl/src/Context/usecase/ContextDrawFillUseCase.ts b/packages/webgl/src/Context/usecase/ContextDrawFillUseCase.ts index cf0bbf0c..cf1b9dc1 100644 --- a/packages/webgl/src/Context/usecase/ContextDrawFillUseCase.ts +++ b/packages/webgl/src/Context/usecase/ContextDrawFillUseCase.ts @@ -4,7 +4,12 @@ import { execute as contextNormalFillUseCase } from "./ContextNormalFillUseCase" import { execute as contextLinearGradientFillUseCase } from "./ContextLinearGradientFillUseCase"; import { execute as contextRadialGradientFillUseCase } from "./ContextRadialGradientFillUseCase"; import { execute as contextPatternBitmapFillUseCase } from "./ContextPatternBitmapFillUseCase"; -import { $gl } from "../../WebGLUtil"; +import { execute as stencilResetService } from "../../Stencil/service/StencilResetService"; +import { + $gl, + $enableStencilTest, + $disableStencilTest +} from "../../WebGLUtil"; import { $terminateGrid, $gridDataMap @@ -27,11 +32,13 @@ export const execute = (): void => { const fillVertexArrayObject = vertexArrayObjectBindFillMeshUseCase(); - // mask on - $gl.enable($gl.STENCIL_TEST); - $gl.frontFace($gl.CCW); + // mask on(frontFaceはContext初期化時にCCW固定) + $enableStencilTest(); $gl.stencilMask(0xff); + // Reset stencil cache at the start of fill processing + stencilResetService(); + let fillOffset = 0; let gridData: Float32Array | null = null; for (let idx = 0; idx < $fillTypes.length; idx++) { @@ -87,7 +94,10 @@ export const execute = (): void => } // mask off - $gl.disable($gl.STENCIL_TEST); + $disableStencilTest(); + + // Reset stencil cache after disabling stencil test + stencilResetService(); // release vertex array vertexArrayObjectReleaseVertexArrayObjectService(fillVertexArrayObject); diff --git a/packages/webgl/src/Context/usecase/ContextGradientStrokeUseCase.ts b/packages/webgl/src/Context/usecase/ContextGradientStrokeUseCase.ts index 9b86deec..9fbd60f0 100644 --- a/packages/webgl/src/Context/usecase/ContextGradientStrokeUseCase.ts +++ b/packages/webgl/src/Context/usecase/ContextGradientStrokeUseCase.ts @@ -30,9 +30,9 @@ export const execute = ( focal: number ): void => { - const vertices = $getVertices(); + const vertices = $getVertices(true); if (!vertices.length) { - return ; + return; } // 塗りの種類を追加 diff --git a/packages/webgl/src/Context/usecase/ContextLinearGradientFillUseCase.test.ts b/packages/webgl/src/Context/usecase/ContextLinearGradientFillUseCase.test.ts index 63767405..4968e26d 100644 --- a/packages/webgl/src/Context/usecase/ContextLinearGradientFillUseCase.test.ts +++ b/packages/webgl/src/Context/usecase/ContextLinearGradientFillUseCase.test.ts @@ -24,6 +24,21 @@ vi.mock("../../Shader/Variants/Gradient/usecase/VariantsGradientShapeShaderUseCa vi.mock("../../Shader/ShaderManager/service/ShaderManagerSetGradientFillUniformService", () => ({ execute: vi.fn() })); +vi.mock("../../Stencil/service/StencilSetMaskModeService", () => ({ + execute: vi.fn() +})); +vi.mock("../../Stencil/service/StencilSetFillModeService", () => ({ + execute: vi.fn() +})); +vi.mock("../../Stencil/service/StencilEnableSampleAlphaToCoverageService", () => ({ + execute: vi.fn() +})); +vi.mock("../../Stencil/service/StencilDisableSampleAlphaToCoverageService", () => ({ + execute: vi.fn() +})); +vi.mock("../../Stencil/service/StencilResetService", () => ({ + execute: vi.fn() +})); vi.mock("../../WebGLUtil.ts", async (importOriginal) => { const mod = await importOriginal(); @@ -55,7 +70,9 @@ vi.mock("../../WebGLUtil.ts", async (importOriginal) => { $linearGradientXY: vi.fn(() => new Float32Array([0, 0, 1, 1])), $inverseMatrix: vi.fn(() => new Float32Array([1, 0, 0, 1, 0, 0])), $poolFloat32Array4: vi.fn(), - $poolFloat32Array6: vi.fn() + $poolFloat32Array6: vi.fn(), + $enableStencilTest: vi.fn(), + $disableStencilTest: vi.fn() }; }); diff --git a/packages/webgl/src/Context/usecase/ContextLinearGradientFillUseCase.ts b/packages/webgl/src/Context/usecase/ContextLinearGradientFillUseCase.ts index 6b6f679f..d5260f36 100644 --- a/packages/webgl/src/Context/usecase/ContextLinearGradientFillUseCase.ts +++ b/packages/webgl/src/Context/usecase/ContextLinearGradientFillUseCase.ts @@ -6,6 +6,11 @@ import { execute as shaderManagerSetMaskUniformService } from "../../Shader/Shad import { execute as shaderManagerFillUseCase } from "../../Shader/ShaderManager/usecase/ShaderManagerFillUseCase"; import { execute as variantsGradientShapeShaderUseCase } from "../../Shader/Variants/Gradient/usecase/VariantsGradientShapeShaderUseCase"; import { execute as shaderManagerSetGradientFillUniformService } from "../../Shader/ShaderManager/service/ShaderManagerSetGradientFillUniformService"; +import { execute as stencilSetMaskModeService } from "../../Stencil/service/StencilSetMaskModeService"; +import { execute as stencilSetFillModeService } from "../../Stencil/service/StencilSetFillModeService"; +import { execute as stencilEnableSampleAlphaToCoverageService } from "../../Stencil/service/StencilEnableSampleAlphaToCoverageService"; +import { execute as stencilDisableSampleAlphaToCoverageService } from "../../Stencil/service/StencilDisableSampleAlphaToCoverageService"; +import { execute as stencilResetService } from "../../Stencil/service/StencilResetService"; import { $gradientData } from "../../Gradient"; import { $gl, @@ -13,7 +18,9 @@ import { $inverseMatrix, $context, $poolFloat32Array6, - $poolFloat32Array4 + $poolFloat32Array4, + $enableStencilTest, + $disableStencilTest } from "../../WebGLUtil"; /** @@ -40,19 +47,18 @@ export const execute = ( const spread = $gradientData.shift() as number; const interpolation = $gradientData.shift() as number; - $gl.disable($gl.STENCIL_TEST); + // Reset stencil cache before disabling stencil test + stencilResetService(); + + $disableStencilTest(); const textureObject = gradientLUTGenerateShapeTextureUseCase(stops, interpolation); textureManagerBind0UseCase(textureObject); - $gl.enable($gl.STENCIL_TEST); - $gl.frontFace($gl.CCW); + $enableStencilTest(); $gl.stencilMask(0xff); - // mask setting - $gl.stencilFunc($gl.ALWAYS, 0, 0xff); - $gl.stencilOpSeparate($gl.FRONT, $gl.KEEP, $gl.KEEP, $gl.INCR_WRAP); - $gl.stencilOpSeparate($gl.BACK, $gl.KEEP, $gl.KEEP, $gl.DECR_WRAP); - $gl.colorMask(false, false, false, false); + // mask setting (cached) + stencilSetMaskModeService(); const useGrid = !!grid_data; const coverageShader = variantsShapeMaskShaderService(useGrid); @@ -60,15 +66,14 @@ export const execute = ( shaderManagerSetMaskUniformService(coverageShader, grid_data); } - $gl.enable($gl.SAMPLE_ALPHA_TO_COVERAGE); + stencilEnableSampleAlphaToCoverageService(); shaderManagerFillUseCase( coverageShader, vertex_array_object, offset, index_count ); - $gl.disable($gl.SAMPLE_ALPHA_TO_COVERAGE); + stencilDisableSampleAlphaToCoverageService(); - $gl.stencilFunc($gl.NOTEQUAL, 0, 0xff); - $gl.stencilOp($gl.KEEP, $gl.ZERO, $gl.ZERO); - $gl.colorMask(true, true, true, true); + // draw shape setting (cached) + stencilSetFillModeService(); const shaderManager = variantsGradientShapeShaderUseCase( false, false, spread, useGrid diff --git a/packages/webgl/src/Context/usecase/ContextNormalFillUseCase.test.ts b/packages/webgl/src/Context/usecase/ContextNormalFillUseCase.test.ts index 2f3eb28c..fde7953c 100644 --- a/packages/webgl/src/Context/usecase/ContextNormalFillUseCase.test.ts +++ b/packages/webgl/src/Context/usecase/ContextNormalFillUseCase.test.ts @@ -17,29 +17,18 @@ vi.mock("../../Shader/Variants/Shape/service/VariantsShapeSolidColorShaderServic vi.mock("../../Shader/ShaderManager/service/ShaderManagerSetFillUniformService", () => ({ execute: vi.fn() })); - -vi.mock("../../WebGLUtil.ts", async (importOriginal) => { - const mod = await importOriginal(); - return { - ...mod, - $gl: { - stencilFunc: vi.fn(), - stencilOpSeparate: vi.fn(), - colorMask: vi.fn(), - enable: vi.fn(), - disable: vi.fn(), - ALWAYS: 0, - KEEP: 1, - INCR_WRAP: 2, - DECR_WRAP: 3, - FRONT: 4, - BACK: 5, - SAMPLE_ALPHA_TO_COVERAGE: 6, - NOTEQUAL: 7, - stencilOp: vi.fn() - } - }; -}); +vi.mock("../../Stencil/service/StencilSetMaskModeService", () => ({ + execute: vi.fn() +})); +vi.mock("../../Stencil/service/StencilSetFillModeService", () => ({ + execute: vi.fn() +})); +vi.mock("../../Stencil/service/StencilEnableSampleAlphaToCoverageService", () => ({ + execute: vi.fn() +})); +vi.mock("../../Stencil/service/StencilDisableSampleAlphaToCoverageService", () => ({ + execute: vi.fn() +})); describe("ContextNormalFillUseCase.js method test", () => { diff --git a/packages/webgl/src/Context/usecase/ContextNormalFillUseCase.ts b/packages/webgl/src/Context/usecase/ContextNormalFillUseCase.ts index 391ad575..a991d93a 100644 --- a/packages/webgl/src/Context/usecase/ContextNormalFillUseCase.ts +++ b/packages/webgl/src/Context/usecase/ContextNormalFillUseCase.ts @@ -4,7 +4,10 @@ import { execute as shaderManagerSetMaskUniformService } from "../../Shader/Shad import { execute as shaderManagerFillUseCase } from "../../Shader/ShaderManager/usecase/ShaderManagerFillUseCase"; import { execute as variantsShapeSolidColorShaderService } from "../../Shader/Variants/Shape/service/VariantsShapeSolidColorShaderService"; import { execute as shaderManagerSetFillUniformService } from "../../Shader/ShaderManager/service/ShaderManagerSetFillUniformService"; -import { $gl } from "../../WebGLUtil"; +import { execute as stencilSetMaskModeService } from "../../Stencil/service/StencilSetMaskModeService"; +import { execute as stencilSetFillModeService } from "../../Stencil/service/StencilSetFillModeService"; +import { execute as stencilEnableSampleAlphaToCoverageService } from "../../Stencil/service/StencilEnableSampleAlphaToCoverageService"; +import { execute as stencilDisableSampleAlphaToCoverageService } from "../../Stencil/service/StencilDisableSampleAlphaToCoverageService"; /** * @description 塗りのシェーダーを実行します。 @@ -25,11 +28,8 @@ export const execute = ( grid_data: Float32Array | null ): void => { - // mask setting - $gl.stencilFunc($gl.ALWAYS, 0, 0xff); - $gl.stencilOpSeparate($gl.FRONT, $gl.KEEP, $gl.KEEP, $gl.INCR_WRAP); - $gl.stencilOpSeparate($gl.BACK, $gl.KEEP, $gl.KEEP, $gl.DECR_WRAP); - $gl.colorMask(false, false, false, false); + // mask setting (cached) + stencilSetMaskModeService(); const useGrid = !!grid_data; const coverageShader = variantsShapeMaskShaderService(useGrid); @@ -37,16 +37,14 @@ export const execute = ( shaderManagerSetMaskUniformService(coverageShader, grid_data); } - $gl.enable($gl.SAMPLE_ALPHA_TO_COVERAGE); + stencilEnableSampleAlphaToCoverageService(); shaderManagerFillUseCase( coverageShader, vertex_array_object, offset, index_count ); - $gl.disable($gl.SAMPLE_ALPHA_TO_COVERAGE); + stencilDisableSampleAlphaToCoverageService(); - // draw shape setting - $gl.stencilFunc($gl.NOTEQUAL, 0, 0xff); - $gl.stencilOp($gl.KEEP, $gl.ZERO, $gl.ZERO); - $gl.colorMask(true, true, true, true); + // draw shape setting (cached) + stencilSetFillModeService(); const shaderManager = variantsShapeSolidColorShaderService(useGrid); if (grid_data) { diff --git a/packages/webgl/src/Context/usecase/ContextPatternBitmapFillUseCase.test.ts b/packages/webgl/src/Context/usecase/ContextPatternBitmapFillUseCase.test.ts index 82cb509d..50da212f 100644 --- a/packages/webgl/src/Context/usecase/ContextPatternBitmapFillUseCase.test.ts +++ b/packages/webgl/src/Context/usecase/ContextPatternBitmapFillUseCase.test.ts @@ -24,27 +24,23 @@ vi.mock("../../TextureManager/usecase/TextureManagerCreateFromPixelsUseCase", () vi.mock("../../TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase", () => ({ execute: vi.fn() })); +vi.mock("../../Stencil/service/StencilSetMaskModeService", () => ({ + execute: vi.fn() +})); +vi.mock("../../Stencil/service/StencilSetFillModeService", () => ({ + execute: vi.fn() +})); +vi.mock("../../Stencil/service/StencilEnableSampleAlphaToCoverageService", () => ({ + execute: vi.fn() +})); +vi.mock("../../Stencil/service/StencilDisableSampleAlphaToCoverageService", () => ({ + execute: vi.fn() +})); vi.mock("../../WebGLUtil.ts", async (importOriginal) => { const mod = await importOriginal(); return { ...mod, - $gl: { - stencilFunc: vi.fn(), - stencilOpSeparate: vi.fn(), - colorMask: vi.fn(), - enable: vi.fn(), - disable: vi.fn(), - ALWAYS: 0, - KEEP: 1, - INCR_WRAP: 2, - DECR_WRAP: 3, - FRONT: 4, - BACK: 5, - SAMPLE_ALPHA_TO_COVERAGE: 6, - NOTEQUAL: 7, - stencilOp: vi.fn() - }, $context: { save: vi.fn(), restore: vi.fn(), diff --git a/packages/webgl/src/Context/usecase/ContextPatternBitmapFillUseCase.ts b/packages/webgl/src/Context/usecase/ContextPatternBitmapFillUseCase.ts index 803a9e72..6287004d 100644 --- a/packages/webgl/src/Context/usecase/ContextPatternBitmapFillUseCase.ts +++ b/packages/webgl/src/Context/usecase/ContextPatternBitmapFillUseCase.ts @@ -6,15 +6,16 @@ import { execute as variantsBitmapShaderService } from "../../Shader/Variants/Bi import { execute as shaderManagerSetBitmapFillUniformService } from "../../Shader/ShaderManager/service/ShaderManagerSetBitmapFillUniformService"; import { execute as textureManagerCreateFromPixelsUseCase } from "../../TextureManager/usecase/TextureManagerCreateFromPixelsUseCase"; import { execute as textureManagerReleaseTextureObjectUseCase } from "../../TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase"; +import { execute as stencilSetMaskModeService } from "../../Stencil/service/StencilSetMaskModeService"; +import { execute as stencilSetFillModeService } from "../../Stencil/service/StencilSetFillModeService"; +import { execute as stencilEnableSampleAlphaToCoverageService } from "../../Stencil/service/StencilEnableSampleAlphaToCoverageService"; +import { execute as stencilDisableSampleAlphaToCoverageService } from "../../Stencil/service/StencilDisableSampleAlphaToCoverageService"; import { $bitmapData } from "../../Bitmap"; -import { - $gl, - $context -} from "../../WebGLUtil"; +import { $context } from "../../WebGLUtil"; /** - * @description 放射状グラデーションのシェーダーを実行します。 - * Execute the radial gradient shader. + * @description ビットマップパターンのシェーダーを実行します。 + * Execute the bitmap pattern shader. * * @param {IVertexArrayObject} vertex_array_object * @param {number} offset @@ -31,11 +32,8 @@ export const execute = ( grid_data: Float32Array | null ): void => { - // mask setting - $gl.stencilFunc($gl.ALWAYS, 0, 0xff); - $gl.stencilOpSeparate($gl.FRONT, $gl.KEEP, $gl.KEEP, $gl.INCR_WRAP); - $gl.stencilOpSeparate($gl.BACK, $gl.KEEP, $gl.KEEP, $gl.DECR_WRAP); - $gl.colorMask(false, false, false, false); + // mask setting (cached) + stencilSetMaskModeService(); const useGrid = !!grid_data; const coverageShader = variantsShapeMaskShaderService(useGrid); @@ -43,11 +41,11 @@ export const execute = ( shaderManagerSetMaskUniformService(coverageShader, grid_data); } - $gl.enable($gl.SAMPLE_ALPHA_TO_COVERAGE); + stencilEnableSampleAlphaToCoverageService(); shaderManagerFillUseCase( coverageShader, vertex_array_object, offset, index_count ); - $gl.disable($gl.SAMPLE_ALPHA_TO_COVERAGE); + stencilDisableSampleAlphaToCoverageService(); // bitmap setting const pixels = $bitmapData.shift() as Uint8Array; @@ -67,10 +65,8 @@ export const execute = ( matrix[3], matrix[4], matrix[5] ); - // draw shape setting - $gl.stencilFunc($gl.NOTEQUAL, 0, 0xff); - $gl.stencilOp($gl.KEEP, $gl.ZERO, $gl.ZERO); - $gl.colorMask(true, true, true, true); + // draw shape setting (cached) + stencilSetFillModeService(); const shaderManager = variantsBitmapShaderService(repeat, useGrid); shaderManagerSetBitmapFillUniformService( diff --git a/packages/webgl/src/Context/usecase/ContextRadialGradientFillUseCase.test.ts b/packages/webgl/src/Context/usecase/ContextRadialGradientFillUseCase.test.ts index e95b0f02..a36be341 100644 --- a/packages/webgl/src/Context/usecase/ContextRadialGradientFillUseCase.test.ts +++ b/packages/webgl/src/Context/usecase/ContextRadialGradientFillUseCase.test.ts @@ -24,6 +24,21 @@ vi.mock("../../Shader/Variants/Gradient/usecase/VariantsGradientShapeShaderUseCa vi.mock("../../Shader/ShaderManager/service/ShaderManagerSetGradientFillUniformService", () => ({ execute: vi.fn() })); +vi.mock("../../Stencil/service/StencilSetMaskModeService", () => ({ + execute: vi.fn() +})); +vi.mock("../../Stencil/service/StencilSetFillModeService", () => ({ + execute: vi.fn() +})); +vi.mock("../../Stencil/service/StencilEnableSampleAlphaToCoverageService", () => ({ + execute: vi.fn() +})); +vi.mock("../../Stencil/service/StencilDisableSampleAlphaToCoverageService", () => ({ + execute: vi.fn() +})); +vi.mock("../../Stencil/service/StencilResetService", () => ({ + execute: vi.fn() +})); vi.mock("../../WebGLUtil.ts", async (importOriginal) => { const mod = await importOriginal(); @@ -57,7 +72,9 @@ vi.mock("../../WebGLUtil.ts", async (importOriginal) => { transform: vi.fn() }, $inverseMatrix: vi.fn(() => new Float32Array([1, 0, 0, 1, 0, 0])), - $poolFloat32Array6: vi.fn() + $poolFloat32Array6: vi.fn(), + $enableStencilTest: vi.fn(), + $disableStencilTest: vi.fn() }; }); diff --git a/packages/webgl/src/Context/usecase/ContextRadialGradientFillUseCase.ts b/packages/webgl/src/Context/usecase/ContextRadialGradientFillUseCase.ts index ec0df740..55a5df55 100644 --- a/packages/webgl/src/Context/usecase/ContextRadialGradientFillUseCase.ts +++ b/packages/webgl/src/Context/usecase/ContextRadialGradientFillUseCase.ts @@ -6,12 +6,19 @@ import { execute as gradientLUTGenerateShapeTextureUseCase } from "../../Shader/ import { execute as textureManagerBind0UseCase } from "../../TextureManager/usecase/TextureManagerBind0UseCase"; import { execute as variantsGradientShapeShaderUseCase } from "../../Shader/Variants/Gradient/usecase/VariantsGradientShapeShaderUseCase"; import { execute as shaderManagerSetGradientFillUniformService } from "../../Shader/ShaderManager/service/ShaderManagerSetGradientFillUniformService"; +import { execute as stencilSetMaskModeService } from "../../Stencil/service/StencilSetMaskModeService"; +import { execute as stencilSetFillModeService } from "../../Stencil/service/StencilSetFillModeService"; +import { execute as stencilEnableSampleAlphaToCoverageService } from "../../Stencil/service/StencilEnableSampleAlphaToCoverageService"; +import { execute as stencilDisableSampleAlphaToCoverageService } from "../../Stencil/service/StencilDisableSampleAlphaToCoverageService"; +import { execute as stencilResetService } from "../../Stencil/service/StencilResetService"; import { $gradientData } from "../../Gradient"; import { $gl, $inverseMatrix, $context, - $poolFloat32Array6 + $poolFloat32Array6, + $enableStencilTest, + $disableStencilTest } from "../../WebGLUtil"; /** @@ -39,19 +46,18 @@ export const execute = ( const interpolation = $gradientData.shift() as number; const focal = $gradientData.shift() as number; - $gl.disable($gl.STENCIL_TEST); + // Reset stencil cache before disabling stencil test + stencilResetService(); + + $disableStencilTest(); const textureObject = gradientLUTGenerateShapeTextureUseCase(stops, interpolation); textureManagerBind0UseCase(textureObject); - $gl.enable($gl.STENCIL_TEST); - $gl.frontFace($gl.CCW); + $enableStencilTest(); $gl.stencilMask(0xff); - // mask setting - $gl.stencilFunc($gl.ALWAYS, 0, 0xff); - $gl.stencilOpSeparate($gl.FRONT, $gl.KEEP, $gl.KEEP, $gl.INCR_WRAP); - $gl.stencilOpSeparate($gl.BACK, $gl.KEEP, $gl.KEEP, $gl.DECR_WRAP); - $gl.colorMask(false, false, false, false); + // mask setting (cached) + stencilSetMaskModeService(); const useGrid = !!grid_data; const coverageShader = variantsShapeMaskShaderService(useGrid); @@ -59,15 +65,14 @@ export const execute = ( shaderManagerSetMaskUniformService(coverageShader, grid_data); } - $gl.enable($gl.SAMPLE_ALPHA_TO_COVERAGE); + stencilEnableSampleAlphaToCoverageService(); shaderManagerFillUseCase( coverageShader, vertex_array_object, offset, index_count ); - $gl.disable($gl.SAMPLE_ALPHA_TO_COVERAGE); + stencilDisableSampleAlphaToCoverageService(); - $gl.stencilFunc($gl.NOTEQUAL, 0, 0xff); - $gl.stencilOp($gl.KEEP, $gl.ZERO, $gl.ZERO); - $gl.colorMask(true, true, true, true); + // draw shape setting (cached) + stencilSetFillModeService(); $context.save(); $context.transform( diff --git a/packages/webgl/src/Context/usecase/ContextResizeUseCase.test.ts b/packages/webgl/src/Context/usecase/ContextResizeUseCase.test.ts index 9045c364..3159a966 100644 --- a/packages/webgl/src/Context/usecase/ContextResizeUseCase.test.ts +++ b/packages/webgl/src/Context/usecase/ContextResizeUseCase.test.ts @@ -17,6 +17,8 @@ describe("ContextResizeUseCase.js method test", () => "createFramebuffer": vi.fn(() => "createFramebuffer"), "bindFramebuffer": vi.fn(() => "bindFramebuffer"), "clearColor": vi.fn(() => "clearColor"), + "frontFace": vi.fn(() => "frontFace"), + "CCW": 1, "createRenderbuffer": vi.fn(() => "createRenderbuffer"), "bindRenderbuffer": vi.fn(() => "bindRenderbuffer"), "renderbufferStorageMultisample": vi.fn(() => "renderbufferStorageMultisample"), diff --git a/packages/webgl/src/Filter/BevelFilter/usecase/FilterApplyBevelFilterUseCase.test.ts b/packages/webgl/src/Filter/BevelFilter/usecase/FilterApplyBevelFilterUseCase.test.ts new file mode 100644 index 00000000..87e94305 --- /dev/null +++ b/packages/webgl/src/Filter/BevelFilter/usecase/FilterApplyBevelFilterUseCase.test.ts @@ -0,0 +1,240 @@ +import { execute } from "./FilterApplyBevelFilterUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ITextureObject } from "../../../interface/ITextureObject"; + +vi.mock("../../../FrameBufferManager/usecase/FrameBufferManagerGetAttachmentObjectUseCase", () => ({ + execute: vi.fn(() => ({ + id: 1, + width: 100, + height: 100, + texture: { + id: 1, + resource: {}, + width: 100, + height: 100, + area: 10000, + smooth: false + } + })) +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerBind0UseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/ShaderManager/usecase/ShaderManagerDrawTextureUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/Variants/Blend/service/VariantsBlendTextureShaderService", () => ({ + execute: vi.fn(() => ({ + uniform: {} + })) +})); + +vi.mock("../../../Shader/ShaderManager/service/ShaderManagerSetTextureUniformService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Blend/service/BlendEraseService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Blend/service/BlendResetService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../BlurFilter/usecase/FilterApplyBlurFilterUseCase", () => ({ + execute: vi.fn(() => ({ + id: 2, + resource: {}, + width: 120, + height: 120, + area: 14400, + smooth: false + })) +})); + +vi.mock("../../../FrameBufferManager/usecase/FrameBufferManagerReleaseAttachmentObjectUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../BitmapFilter/usecase/FilterApplyBitmapFilterUseCase", () => ({ + execute: vi.fn(() => ({ + id: 3, + resource: {}, + width: 130, + height: 130, + area: 16900, + smooth: false + })) +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Filter", () => ({ + $offset: { x: 0, y: 0 }, + $intToR: vi.fn(() => 1), + $intToG: vi.fn(() => 1), + $intToB: vi.fn(() => 1) +})); + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $context: { + currentAttachmentObject: null, + bind: vi.fn(), + reset: vi.fn(), + setTransform: vi.fn() + }, + $devicePixelRatio: 1 + }; +}); + +describe("FilterApplyBevelFilterUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should apply bevel filter with default parameters", () => + { + const mockTextureObject: ITextureObject = { + id: 1, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute(mockTextureObject, matrix); + + expect(result).toBeDefined(); + expect(result).toHaveProperty("width"); + expect(result).toHaveProperty("height"); + }); + + it("test case - should apply bevel filter with custom distance and angle", () => + { + const mockTextureObject: ITextureObject = { + id: 2, + resource: {} as WebGLTexture, + width: 200, + height: 150, + area: 30000, + smooth: true + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute( + mockTextureObject, matrix, + 8, // distance + 135 // angle + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply bevel filter with custom colors", () => + { + const mockTextureObject: ITextureObject = { + id: 3, + resource: {} as WebGLTexture, + width: 256, + height: 256, + area: 65536, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute( + mockTextureObject, matrix, + 4, 45, + 0xFFFFFF, // highlight_color + 1, // highlight_alpha + 0x000000, // shadow_color + 1 // shadow_alpha + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply inner bevel filter", () => + { + const mockTextureObject: ITextureObject = { + id: 4, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute( + mockTextureObject, matrix, + 4, 45, 0xFFFFFF, 1, 0x000000, 1, + 4, 4, 1, 1, + 1 // type = inner + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply outer bevel filter", () => + { + const mockTextureObject: ITextureObject = { + id: 5, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute( + mockTextureObject, matrix, + 4, 45, 0xFFFFFF, 1, 0x000000, 1, + 4, 4, 1, 1, + 2 // type = outer + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply knockout bevel filter", () => + { + const mockTextureObject: ITextureObject = { + id: 6, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute( + mockTextureObject, matrix, + 4, 45, 0xFFFFFF, 1, 0x000000, 1, + 4, 4, 1, 1, + 0, // type = full + true // knockout + ); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/webgl/src/Filter/BevelFilter/usecase/FilterApplyBevelFilterUseCase.ts b/packages/webgl/src/Filter/BevelFilter/usecase/FilterApplyBevelFilterUseCase.ts index 5918816f..7fb427a0 100644 --- a/packages/webgl/src/Filter/BevelFilter/usecase/FilterApplyBevelFilterUseCase.ts +++ b/packages/webgl/src/Filter/BevelFilter/usecase/FilterApplyBevelFilterUseCase.ts @@ -17,7 +17,7 @@ import { $intToB } from "../../../Filter"; import { - $getDevicePixelRatio, + $devicePixelRatio, $context } from "../../../WebGLUtil"; @@ -73,7 +73,7 @@ export const execute = ( const yScale = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]); // pointer - const devicePixelRatio = $getDevicePixelRatio(); + const devicePixelRatio = $devicePixelRatio; const radian = angle * $Deg2Rad; const x = Math.cos(radian) * distance * (xScale / devicePixelRatio); const y = Math.sin(radian) * distance * (yScale / devicePixelRatio); diff --git a/packages/webgl/src/Filter/BitmapFilter/usecase/FilterApplyBitmapFilterUseCase.test.ts b/packages/webgl/src/Filter/BitmapFilter/usecase/FilterApplyBitmapFilterUseCase.test.ts new file mode 100644 index 00000000..94ce6aef --- /dev/null +++ b/packages/webgl/src/Filter/BitmapFilter/usecase/FilterApplyBitmapFilterUseCase.test.ts @@ -0,0 +1,286 @@ +import { execute } from "./FilterApplyBitmapFilterUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ITextureObject } from "../../../interface/ITextureObject"; + +vi.mock("../../../FrameBufferManager/usecase/FrameBufferManagerGetAttachmentObjectUseCase", () => ({ + execute: vi.fn(() => ({ + id: 1, + width: 100, + height: 100, + texture: { + id: 1, + resource: {}, + width: 100, + height: 100, + area: 10000, + smooth: false + } + })) +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerBind01UseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerBind0UseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerBind02UseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerBind012UseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Blend/service/BlendOneZeroService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Blend/service/BlendSourceInService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Blend/service/BlendSourceAtopService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Blend/service/BlendResetService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/Variants/Filter/service/VariantsBitmapFilterShaderService", () => ({ + execute: vi.fn(() => ({ + uniform: {} + })) +})); + +vi.mock("../../../Shader/ShaderManager/usecase/ShaderManagerDrawTextureUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/ShaderManager/service/ShaderManagerSetBitmapFilterUniformService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../FrameBufferManager/usecase/FrameBufferManagerReleaseAttachmentObjectUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/Variants/Blend/service/VariantsBlendTextureShaderService", () => ({ + execute: vi.fn(() => ({ + uniform: {} + })) +})); + +vi.mock("../../../Shader/ShaderManager/service/ShaderManagerSetTextureUniformService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/GradientLUTGenerator/usecase/GradientLUTGenerateFilterTextureUseCase", () => ({ + execute: vi.fn(() => ({ + id: 10, + resource: {}, + width: 256, + height: 1, + area: 256, + smooth: true + })) +})); + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $context: { + currentAttachmentObject: null, + bind: vi.fn(), + reset: vi.fn(), + setTransform: vi.fn() + } + }; +}); + +describe("FilterApplyBitmapFilterUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should apply outer glow bitmap filter", () => + { + const mockTextureObject: ITextureObject = { + id: 1, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const mockBlurTextureObject: ITextureObject = { + id: 2, + resource: {} as WebGLTexture, + width: 120, + height: 120, + area: 14400, + smooth: false + }; + + const result = execute( + mockTextureObject, mockBlurTextureObject, + 130, 130, + 100, 100, 10, 10, + 120, 120, 0, 0, + true, "outer", false, + 1, null, null, null, + 1, 0, 0, 1, + 0, 0, 0, 0 + ); + + expect(result).toBeDefined(); + expect(result).toHaveProperty("width"); + expect(result).toHaveProperty("height"); + }); + + it("test case - should apply inner glow bitmap filter", () => + { + const mockTextureObject: ITextureObject = { + id: 1, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const mockBlurTextureObject: ITextureObject = { + id: 2, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const result = execute( + mockTextureObject, mockBlurTextureObject, + 100, 100, + 100, 100, 0, 0, + 100, 100, 0, 0, + true, "inner", false, + 1, null, null, null, + 0, 1, 0, 1, + 0, 0, 0, 0 + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply knockout bitmap filter", () => + { + const mockTextureObject: ITextureObject = { + id: 1, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const mockBlurTextureObject: ITextureObject = { + id: 2, + resource: {} as WebGLTexture, + width: 120, + height: 120, + area: 14400, + smooth: false + }; + + const result = execute( + mockTextureObject, mockBlurTextureObject, + 120, 120, + 100, 100, 10, 10, + 120, 120, 0, 0, + true, "outer", true, + 2, null, null, null, + 0, 0, 1, 1, + 0, 0, 0, 0 + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply gradient bitmap filter", () => + { + const mockTextureObject: ITextureObject = { + id: 1, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const mockBlurTextureObject: ITextureObject = { + id: 2, + resource: {} as WebGLTexture, + width: 120, + height: 120, + area: 14400, + smooth: false + }; + + const ratios = new Float32Array([0, 0.5, 1]); + const colors = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const alphas = new Float32Array([1, 1, 1]); + + const result = execute( + mockTextureObject, mockBlurTextureObject, + 120, 120, + 100, 100, 10, 10, + 120, 120, 0, 0, + true, "outer", false, + 1, ratios, colors, alphas, + 0, 0, 0, 0, + 0, 0, 0, 0 + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply bevel filter with full type", () => + { + const mockTextureObject: ITextureObject = { + id: 1, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const mockBlurTextureObject: ITextureObject = { + id: 2, + resource: {} as WebGLTexture, + width: 120, + height: 120, + area: 14400, + smooth: false + }; + + const result = execute( + mockTextureObject, mockBlurTextureObject, + 130, 130, + 100, 100, 15, 15, + 120, 120, 5, 5, + false, "full", false, + 1, null, null, null, + 1, 1, 1, 1, + 0, 0, 0, 1 + ); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/webgl/src/Filter/BlurFilter/usecase/FilterApplyBlurFilterUseCase.test.ts b/packages/webgl/src/Filter/BlurFilter/usecase/FilterApplyBlurFilterUseCase.test.ts new file mode 100644 index 00000000..784d2537 --- /dev/null +++ b/packages/webgl/src/Filter/BlurFilter/usecase/FilterApplyBlurFilterUseCase.test.ts @@ -0,0 +1,174 @@ +import { execute } from "./FilterApplyBlurFilterUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ITextureObject } from "../../../interface/ITextureObject"; + +vi.mock("../../../FrameBufferManager/usecase/FrameBufferManagerGetAttachmentObjectUseCase", () => ({ + execute: vi.fn(() => ({ + id: 1, + width: 100, + height: 100, + texture: { + id: 1, + resource: {}, + width: 100, + height: 100, + area: 10000, + smooth: false + } + })) +})); + +vi.mock("../../../Shader/Variants/Blend/service/VariantsBlendMatrixTextureShaderService", () => ({ + execute: vi.fn(() => ({ + uniform: {} + })) +})); + +vi.mock("../../../Shader/ShaderManager/service/ShaderManagerSetMatrixTextureUniformService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/ShaderManager/usecase/ShaderManagerDrawTextureUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerBind0UseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("./FilterApplyDirectionalBlurFilterUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../FrameBufferManager/usecase/FrameBufferManagerReleaseAttachmentObjectUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Blend/service/BlendOneZeroService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Blend/service/BlendResetService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Filter", () => ({ + $offset: { x: 0, y: 0 } +})); + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $context: { + currentAttachmentObject: null, + bind: vi.fn(), + reset: vi.fn(), + setTransform: vi.fn() + }, + $devicePixelRatio: 1 + }; +}); + +describe("FilterApplyBlurFilterUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should apply blur filter with default parameters", () => + { + const mockTextureObject: ITextureObject = { + id: 1, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute(mockTextureObject, matrix); + + expect(result).toBeDefined(); + expect(result).toHaveProperty("width"); + expect(result).toHaveProperty("height"); + }); + + it("test case - should apply blur filter with custom blur values", () => + { + const mockTextureObject: ITextureObject = { + id: 2, + resource: {} as WebGLTexture, + width: 200, + height: 150, + area: 30000, + smooth: true + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute(mockTextureObject, matrix, 8, 8, 2); + + expect(result).toBeDefined(); + }); + + it("test case - should apply blur filter with high quality", () => + { + const mockTextureObject: ITextureObject = { + id: 3, + resource: {} as WebGLTexture, + width: 256, + height: 256, + area: 65536, + smooth: false + }; + + const matrix = new Float32Array([2, 0, 0, 2, 0, 0]); + + const result = execute(mockTextureObject, matrix, 16, 16, 3); + + expect(result).toBeDefined(); + }); + + it("test case - should handle large blur values with buffer scaling", () => + { + const mockTextureObject: ITextureObject = { + id: 4, + resource: {} as WebGLTexture, + width: 512, + height: 512, + area: 262144, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute(mockTextureObject, matrix, 64, 64, 1); + + expect(result).toBeDefined(); + }); + + it("test case - should not remove source texture when removed is false", () => + { + const mockTextureObject: ITextureObject = { + id: 5, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute(mockTextureObject, matrix, 4, 4, 1, false); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/webgl/src/Filter/BlurFilter/usecase/FilterApplyBlurFilterUseCase.ts b/packages/webgl/src/Filter/BlurFilter/usecase/FilterApplyBlurFilterUseCase.ts index 5166d865..073a4251 100644 --- a/packages/webgl/src/Filter/BlurFilter/usecase/FilterApplyBlurFilterUseCase.ts +++ b/packages/webgl/src/Filter/BlurFilter/usecase/FilterApplyBlurFilterUseCase.ts @@ -13,7 +13,7 @@ import { execute as blendResetService } from "../../../Blend/service/BlendResetS import { $offset } from "../../../Filter"; import { $context, - $getDevicePixelRatio + $devicePixelRatio } from "../../../WebGLUtil"; /** @@ -50,7 +50,7 @@ export const execute = ( const xScale = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]); const yScale = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]); - const devicePixelRatio = $getDevicePixelRatio(); + const devicePixelRatio = $devicePixelRatio; const baseBlurX = blur_x * (xScale / devicePixelRatio); const baseBlurY = blur_y * (yScale / devicePixelRatio); diff --git a/packages/webgl/src/Filter/BlurFilter/usecase/FilterApplyDirectionalBlurFilterUseCase.test.ts b/packages/webgl/src/Filter/BlurFilter/usecase/FilterApplyDirectionalBlurFilterUseCase.test.ts new file mode 100644 index 00000000..3edc0e5c --- /dev/null +++ b/packages/webgl/src/Filter/BlurFilter/usecase/FilterApplyDirectionalBlurFilterUseCase.test.ts @@ -0,0 +1,92 @@ +import { execute } from "./FilterApplyDirectionalBlurFilterUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ITextureObject } from "../../../interface/ITextureObject"; + +vi.mock("../../../TextureManager/usecase/TextureManagerBind0UseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/Variants/Filter/service/VariantsBlurFilterShaderService", () => ({ + execute: vi.fn(() => ({ + uniform: {} + })) +})); + +vi.mock("../../../Shader/ShaderManager/service/ShaderManagerSetBlurFilterUniformService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/ShaderManager/usecase/ShaderManagerDrawTextureUseCase", () => ({ + execute: vi.fn() +})); + +describe("FilterApplyDirectionalBlurFilterUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should apply horizontal blur filter", () => + { + const mockTextureObject: ITextureObject = { + id: 1, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + expect(() => { + execute(mockTextureObject, true, 8); + }).not.toThrow(); + }); + + it("test case - should apply vertical blur filter", () => + { + const mockTextureObject: ITextureObject = { + id: 2, + resource: {} as WebGLTexture, + width: 200, + height: 150, + area: 30000, + smooth: true + }; + + expect(() => { + execute(mockTextureObject, false, 16); + }).not.toThrow(); + }); + + it("test case - should handle small blur values", () => + { + const mockTextureObject: ITextureObject = { + id: 3, + resource: {} as WebGLTexture, + width: 50, + height: 50, + area: 2500, + smooth: false + }; + + expect(() => { + execute(mockTextureObject, true, 1); + }).not.toThrow(); + }); + + it("test case - should handle large blur values", () => + { + const mockTextureObject: ITextureObject = { + id: 4, + resource: {} as WebGLTexture, + width: 512, + height: 512, + area: 262144, + smooth: false + }; + + expect(() => { + execute(mockTextureObject, false, 64); + }).not.toThrow(); + }); +}); diff --git a/packages/webgl/src/Filter/ColorMatrixFilter/usecase/FilterApplyColorMatrixFilterUseCase.test.ts b/packages/webgl/src/Filter/ColorMatrixFilter/usecase/FilterApplyColorMatrixFilterUseCase.test.ts new file mode 100644 index 00000000..d033d98d --- /dev/null +++ b/packages/webgl/src/Filter/ColorMatrixFilter/usecase/FilterApplyColorMatrixFilterUseCase.test.ts @@ -0,0 +1,164 @@ +import { execute } from "./FilterApplyColorMatrixFilterUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ITextureObject } from "../../../interface/ITextureObject"; + +vi.mock("../../../FrameBufferManager/usecase/FrameBufferManagerGetAttachmentObjectUseCase", () => ({ + execute: vi.fn(() => ({ + id: 1, + width: 100, + height: 100, + texture: { + id: 1, + resource: {}, + width: 100, + height: 100, + area: 10000, + smooth: false + } + })) +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerBind0UseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../FrameBufferManager/usecase/FrameBufferManagerReleaseAttachmentObjectUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/Variants/Filter/service/VariantsColorMatrixFilterShaderService", () => ({ + execute: vi.fn(() => ({ + uniform: {} + })) +})); + +vi.mock("../../../Shader/ShaderManager/service/ShaderManagerSetColorMatrixFilterUniformService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/ShaderManager/usecase/ShaderManagerDrawTextureUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Blend/service/BlendResetService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $context: { + currentAttachmentObject: null, + bind: vi.fn(), + reset: vi.fn(), + setTransform: vi.fn() + } + }; +}); + +describe("FilterApplyColorMatrixFilterUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should apply color matrix filter with identity matrix", () => + { + const mockTextureObject: ITextureObject = { + id: 1, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const identityMatrix = new Float32Array([ + 1, 0, 0, 0, 0, + 0, 1, 0, 0, 0, + 0, 0, 1, 0, 0, + 0, 0, 0, 1, 0 + ]); + + const result = execute(mockTextureObject, identityMatrix); + + expect(result).toBeDefined(); + expect(result).toHaveProperty("width"); + expect(result).toHaveProperty("height"); + }); + + it("test case - should apply grayscale color matrix filter", () => + { + const mockTextureObject: ITextureObject = { + id: 2, + resource: {} as WebGLTexture, + width: 200, + height: 150, + area: 30000, + smooth: true + }; + + const grayscaleMatrix = new Float32Array([ + 0.3, 0.3, 0.3, 0, 0, + 0.59, 0.59, 0.59, 0, 0, + 0.11, 0.11, 0.11, 0, 0, + 0, 0, 0, 1, 0 + ]); + + const result = execute(mockTextureObject, grayscaleMatrix); + + expect(result).toBeDefined(); + }); + + it("test case - should apply brightness adjustment matrix", () => + { + const mockTextureObject: ITextureObject = { + id: 3, + resource: {} as WebGLTexture, + width: 256, + height: 256, + area: 65536, + smooth: false + }; + + const brightnessMatrix = new Float32Array([ + 1, 0, 0, 0, 50, + 0, 1, 0, 0, 50, + 0, 0, 1, 0, 50, + 0, 0, 0, 1, 0 + ]); + + const result = execute(mockTextureObject, brightnessMatrix); + + expect(result).toBeDefined(); + }); + + it("test case - should apply contrast adjustment matrix", () => + { + const mockTextureObject: ITextureObject = { + id: 4, + resource: {} as WebGLTexture, + width: 512, + height: 512, + area: 262144, + smooth: false + }; + + const contrast = 1.5; + const contrastMatrix = new Float32Array([ + contrast, 0, 0, 0, 128 * (1 - contrast), + 0, contrast, 0, 0, 128 * (1 - contrast), + 0, 0, contrast, 0, 128 * (1 - contrast), + 0, 0, 0, 1, 0 + ]); + + const result = execute(mockTextureObject, contrastMatrix); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/webgl/src/Filter/ConvolutionFilter/usecase/FilterApplyConvolutionFilterUseCase.test.ts b/packages/webgl/src/Filter/ConvolutionFilter/usecase/FilterApplyConvolutionFilterUseCase.test.ts new file mode 100644 index 00000000..02fad1e4 --- /dev/null +++ b/packages/webgl/src/Filter/ConvolutionFilter/usecase/FilterApplyConvolutionFilterUseCase.test.ts @@ -0,0 +1,187 @@ +import { execute } from "./FilterApplyConvolutionFilterUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ITextureObject } from "../../../interface/ITextureObject"; + +vi.mock("../../../FrameBufferManager/usecase/FrameBufferManagerGetAttachmentObjectUseCase", () => ({ + execute: vi.fn(() => ({ + id: 1, + width: 100, + height: 100, + texture: { + id: 1, + resource: {}, + width: 100, + height: 100, + area: 10000, + smooth: false + } + })) +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerBind0UseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../FrameBufferManager/usecase/FrameBufferManagerReleaseAttachmentObjectUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/Variants/Filter/service/VariantsConvolutionFilterShaderService", () => ({ + execute: vi.fn(() => ({ + uniform: {} + })) +})); + +vi.mock("../../../Shader/ShaderManager/service/ShaderManagerSetConvolutionFilterUniformService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/ShaderManager/usecase/ShaderManagerDrawTextureUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Blend/service/BlendResetService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Filter", () => ({ + $intToR: vi.fn(() => 0), + $intToG: vi.fn(() => 0), + $intToB: vi.fn(() => 0) +})); + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $context: { + currentAttachmentObject: null, + bind: vi.fn(), + reset: vi.fn(), + setTransform: vi.fn() + } + }; +}); + +describe("FilterApplyConvolutionFilterUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should apply 3x3 identity convolution filter", () => + { + const mockTextureObject: ITextureObject = { + id: 1, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const identityMatrix = new Float32Array([ + 0, 0, 0, + 0, 1, 0, + 0, 0, 0 + ]); + + const result = execute(mockTextureObject, 3, 3, identityMatrix); + + expect(result).toBeDefined(); + expect(result).toHaveProperty("width"); + expect(result).toHaveProperty("height"); + }); + + it("test case - should apply edge detection convolution filter", () => + { + const mockTextureObject: ITextureObject = { + id: 2, + resource: {} as WebGLTexture, + width: 200, + height: 150, + area: 30000, + smooth: true + }; + + const edgeDetectionMatrix = new Float32Array([ + -1, -1, -1, + -1, 8, -1, + -1, -1, -1 + ]); + + const result = execute(mockTextureObject, 3, 3, edgeDetectionMatrix, 1, 0, true, true, 0, 0); + + expect(result).toBeDefined(); + }); + + it("test case - should apply sharpen convolution filter", () => + { + const mockTextureObject: ITextureObject = { + id: 3, + resource: {} as WebGLTexture, + width: 256, + height: 256, + area: 65536, + smooth: false + }; + + const sharpenMatrix = new Float32Array([ + 0, -1, 0, + -1, 5, -1, + 0, -1, 0 + ]); + + const result = execute(mockTextureObject, 3, 3, sharpenMatrix, 1, 0, true, true, 0, 0); + + expect(result).toBeDefined(); + }); + + it("test case - should apply emboss convolution filter", () => + { + const mockTextureObject: ITextureObject = { + id: 4, + resource: {} as WebGLTexture, + width: 512, + height: 512, + area: 262144, + smooth: false + }; + + const embossMatrix = new Float32Array([ + -2, -1, 0, + -1, 1, 1, + 0, 1, 2 + ]); + + const result = execute(mockTextureObject, 3, 3, embossMatrix, 1, 128, false, false, 0x808080, 1); + + expect(result).toBeDefined(); + }); + + it("test case - should apply box blur convolution filter with divisor", () => + { + const mockTextureObject: ITextureObject = { + id: 5, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const boxBlurMatrix = new Float32Array([ + 1, 1, 1, + 1, 1, 1, + 1, 1, 1 + ]); + + const result = execute(mockTextureObject, 3, 3, boxBlurMatrix, 9, 0, true, true, 0, 0); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/webgl/src/Filter/DisplacementMapFilter/usecase/FilterApplyDisplacementMapFilterUseCase.test.ts b/packages/webgl/src/Filter/DisplacementMapFilter/usecase/FilterApplyDisplacementMapFilterUseCase.test.ts new file mode 100644 index 00000000..520e6154 --- /dev/null +++ b/packages/webgl/src/Filter/DisplacementMapFilter/usecase/FilterApplyDisplacementMapFilterUseCase.test.ts @@ -0,0 +1,254 @@ +import { execute } from "./FilterApplyDisplacementMapFilterUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ITextureObject } from "../../../interface/ITextureObject"; + +vi.mock("../../../FrameBufferManager/usecase/FrameBufferManagerGetAttachmentObjectUseCase", () => ({ + execute: vi.fn(() => ({ + id: 1, + width: 100, + height: 100, + texture: { + id: 1, + resource: {}, + width: 100, + height: 100, + area: 10000, + smooth: false + } + })) +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerCreateFromPixelsUseCase", () => ({ + execute: vi.fn(() => ({ + id: 2, + resource: {}, + width: 50, + height: 50, + area: 2500, + smooth: false + })) +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerBind01UseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/Variants/Filter/service/VariantsDisplacementMapFilterShaderService", () => ({ + execute: vi.fn(() => ({ + uniform: {} + })) +})); + +vi.mock("../../../FrameBufferManager/usecase/FrameBufferManagerReleaseAttachmentObjectUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Blend/service/BlendResetService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/ShaderManager/usecase/ShaderManagerDrawTextureUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/ShaderManager/service/ShaderManagerSetDisplacementMapFilterUniformService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Filter", () => ({ + $intToR: vi.fn(() => 0), + $intToG: vi.fn(() => 0), + $intToB: vi.fn(() => 0) +})); + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $context: { + currentAttachmentObject: null, + bind: vi.fn() + }, + $devicePixelRatio: 1 + }; +}); + +describe("FilterApplyDisplacementMapFilterUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should apply displacement map filter with default parameters", () => + { + const mockTextureObject: ITextureObject = { + id: 1, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(50 * 50 * 4); + + const result = execute(mockTextureObject, matrix, bitmapBuffer); + + expect(result).toBeDefined(); + expect(result).toHaveProperty("width"); + expect(result).toHaveProperty("height"); + }); + + it("test case - should apply displacement map filter with custom bitmap dimensions", () => + { + const mockTextureObject: ITextureObject = { + id: 2, + resource: {} as WebGLTexture, + width: 200, + height: 150, + area: 30000, + smooth: true + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(100 * 100 * 4); + + const result = execute( + mockTextureObject, matrix, + bitmapBuffer, + 100, // bitmap_width + 100 // bitmap_height + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply displacement map filter with custom map point", () => + { + const mockTextureObject: ITextureObject = { + id: 3, + resource: {} as WebGLTexture, + width: 256, + height: 256, + area: 65536, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + + const result = execute( + mockTextureObject, matrix, + bitmapBuffer, + 64, 64, + 50, // map_point_x + 50 // map_point_y + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply displacement map filter with component channels", () => + { + const mockTextureObject: ITextureObject = { + id: 4, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(50 * 50 * 4); + + const result = execute( + mockTextureObject, matrix, + bitmapBuffer, + 50, 50, 0, 0, + 1, // component_x (green channel) + 2 // component_y (blue channel) + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply displacement map filter with scale values", () => + { + const mockTextureObject: ITextureObject = { + id: 5, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(50 * 50 * 4); + + const result = execute( + mockTextureObject, matrix, + bitmapBuffer, + 50, 50, 0, 0, 0, 0, + 20, // scale_x + 20 // scale_y + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply displacement map filter with wrap mode", () => + { + const mockTextureObject: ITextureObject = { + id: 6, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(50 * 50 * 4); + + const result = execute( + mockTextureObject, matrix, + bitmapBuffer, + 50, 50, 0, 0, 0, 0, 10, 10, + 1 // mode = wrap + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply displacement map filter with color fill", () => + { + const mockTextureObject: ITextureObject = { + id: 7, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(50 * 50 * 4); + + const result = execute( + mockTextureObject, matrix, + bitmapBuffer, + 50, 50, 0, 0, 0, 0, 10, 10, 0, + 0xFF0000, // color + 1 // alpha + ); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/webgl/src/Filter/DisplacementMapFilter/usecase/FilterApplyDisplacementMapFilterUseCase.ts b/packages/webgl/src/Filter/DisplacementMapFilter/usecase/FilterApplyDisplacementMapFilterUseCase.ts index 2691a75e..1b6810a6 100644 --- a/packages/webgl/src/Filter/DisplacementMapFilter/usecase/FilterApplyDisplacementMapFilterUseCase.ts +++ b/packages/webgl/src/Filter/DisplacementMapFilter/usecase/FilterApplyDisplacementMapFilterUseCase.ts @@ -8,10 +8,7 @@ import { execute as blendResetService } from "../../../Blend/service/BlendResetS import { execute as shaderManagerDrawTextureUseCase } from "../../../Shader/ShaderManager/usecase/ShaderManagerDrawTextureUseCase"; import { execute as shaderManagerSetDisplacementMapFilterUniformService } from "../../../Shader/ShaderManager/service/ShaderManagerSetDisplacementMapFilterUniformService"; import { execute as textureManagerReleaseTextureObjectUseCase } from "../../../TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase"; -import { - $context, - $getDevicePixelRatio -} from "../../../WebGLUtil"; +import { $context } from "../../../WebGLUtil"; import { $intToR, $intToG, @@ -23,7 +20,6 @@ import { * Apply displacement map filter * * @param {ITextureObject} texture_object - * @param {Float32Array} matrix * @param {Uint8Array} bitmap_buffer * @param {number} bitmap_width * @param {number} bitmap_height @@ -42,7 +38,6 @@ import { */ export const execute = ( texture_object: ITextureObject, - matrix: Float32Array, bitmap_buffer: Uint8Array, bitmap_width: number = 0, bitmap_height: number = 0, @@ -59,16 +54,9 @@ export const execute = ( const currentAttachmentObject = $context.currentAttachmentObject; - const devicePixelRatio = $getDevicePixelRatio(); - const xScale = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]) / devicePixelRatio; - const yScale = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]) / devicePixelRatio; - const width = texture_object.width; const height = texture_object.height; - const baseWidth = width / xScale; - const baseHeight = height / yScale; - const attachmentObject = frameBufferManagerGetAttachmentObjectUseCase( width, height, false ); @@ -89,7 +77,7 @@ export const execute = ( shaderManagerSetDisplacementMapFilterUniformService( shaderManager, - width, height, baseWidth, baseHeight, + bitmap_width, bitmap_height, bitmap_width, bitmap_height, map_point_x, map_point_y, scale_x, scale_y, mode, $intToR(color, alpha, true), $intToG(color, alpha, true), diff --git a/packages/webgl/src/Filter/DropShadowFilter/usecase/FilterApplyDropShadowFilterUseCase.test.ts b/packages/webgl/src/Filter/DropShadowFilter/usecase/FilterApplyDropShadowFilterUseCase.test.ts new file mode 100644 index 00000000..d74d37e9 --- /dev/null +++ b/packages/webgl/src/Filter/DropShadowFilter/usecase/FilterApplyDropShadowFilterUseCase.test.ts @@ -0,0 +1,198 @@ +import { execute } from "./FilterApplyDropShadowFilterUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ITextureObject } from "../../../interface/ITextureObject"; + +vi.mock("../../BlurFilter/usecase/FilterApplyBlurFilterUseCase", () => ({ + execute: vi.fn(() => ({ + id: 2, + resource: {}, + width: 120, + height: 120, + area: 14400, + smooth: false + })) +})); + +vi.mock("../../BitmapFilter/usecase/FilterApplyBitmapFilterUseCase", () => ({ + execute: vi.fn(() => ({ + id: 3, + resource: {}, + width: 130, + height: 130, + area: 16900, + smooth: false + })) +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Filter", () => ({ + $offset: { x: 0, y: 0 }, + $intToR: vi.fn(() => 0), + $intToG: vi.fn(() => 0), + $intToB: vi.fn(() => 0) +})); + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $context: { + currentAttachmentObject: null, + bind: vi.fn() + }, + $devicePixelRatio: 1 + }; +}); + +describe("FilterApplyDropShadowFilterUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should apply drop shadow filter with default parameters", () => + { + const mockTextureObject: ITextureObject = { + id: 1, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute(mockTextureObject, matrix); + + expect(result).toBeDefined(); + expect(result).toHaveProperty("width"); + expect(result).toHaveProperty("height"); + }); + + it("test case - should apply drop shadow with custom distance and angle", () => + { + const mockTextureObject: ITextureObject = { + id: 2, + resource: {} as WebGLTexture, + width: 200, + height: 150, + area: 30000, + smooth: true + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute( + mockTextureObject, matrix, + 8, // distance + 90, // angle + 0x000000, // color + 0.8 // alpha + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply drop shadow with custom blur and strength", () => + { + const mockTextureObject: ITextureObject = { + id: 3, + resource: {} as WebGLTexture, + width: 256, + height: 256, + area: 65536, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute( + mockTextureObject, matrix, + 4, // distance + 45, // angle + 0x0000FF, // color (blue) + 1, // alpha + 16, // blur_x + 16, // blur_y + 2 // strength + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply inner drop shadow", () => + { + const mockTextureObject: ITextureObject = { + id: 4, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute( + mockTextureObject, matrix, + 4, 45, 0, 1, 4, 4, 1, 1, + true, // inner + false, // knockout + false // hide_object + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply knockout drop shadow", () => + { + const mockTextureObject: ITextureObject = { + id: 5, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute( + mockTextureObject, matrix, + 4, 45, 0, 1, 4, 4, 1, 1, + false, // inner + true, // knockout + false // hide_object + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply drop shadow with hide object", () => + { + const mockTextureObject: ITextureObject = { + id: 6, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute( + mockTextureObject, matrix, + 4, 45, 0, 1, 4, 4, 1, 1, + false, // inner + false, // knockout + true // hide_object + ); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/webgl/src/Filter/DropShadowFilter/usecase/FilterApplyDropShadowFilterUseCase.ts b/packages/webgl/src/Filter/DropShadowFilter/usecase/FilterApplyDropShadowFilterUseCase.ts index 39612aa7..31e11918 100644 --- a/packages/webgl/src/Filter/DropShadowFilter/usecase/FilterApplyDropShadowFilterUseCase.ts +++ b/packages/webgl/src/Filter/DropShadowFilter/usecase/FilterApplyDropShadowFilterUseCase.ts @@ -3,7 +3,7 @@ import { execute as filterApplyBlurFilterUseCase } from "../../BlurFilter/usecas import { execute as filterApplyBitmapFilterUseCase } from "../../BitmapFilter/usecase/FilterApplyBitmapFilterUseCase"; import { execute as textureManagerReleaseTextureObjectUseCase } from "../../../TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase"; import { - $getDevicePixelRatio, + $devicePixelRatio, $context } from "../../../WebGLUtil"; import { @@ -79,7 +79,7 @@ export const execute = ( const yScale = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]); // shadow point - const devicePixelRatio = $getDevicePixelRatio(); + const devicePixelRatio = $devicePixelRatio; const radian = angle * $Deg2Rad; const x = Math.cos(radian) * distance * (xScale / devicePixelRatio); const y = Math.sin(radian) * distance * (yScale / devicePixelRatio); diff --git a/packages/webgl/src/Filter/GlowFilter/usecase/FilterApplyGlowFilterUseCase.test.ts b/packages/webgl/src/Filter/GlowFilter/usecase/FilterApplyGlowFilterUseCase.test.ts new file mode 100644 index 00000000..c02b8e19 --- /dev/null +++ b/packages/webgl/src/Filter/GlowFilter/usecase/FilterApplyGlowFilterUseCase.test.ts @@ -0,0 +1,195 @@ +import { execute } from "./FilterApplyGlowFilterUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ITextureObject } from "../../../interface/ITextureObject"; + +vi.mock("../../BlurFilter/usecase/FilterApplyBlurFilterUseCase", () => ({ + execute: vi.fn(() => ({ + id: 2, + resource: {}, + width: 120, + height: 120, + area: 14400, + smooth: false + })) +})); + +vi.mock("../../BitmapFilter/usecase/FilterApplyBitmapFilterUseCase", () => ({ + execute: vi.fn(() => ({ + id: 3, + resource: {}, + width: 130, + height: 130, + area: 16900, + smooth: false + })) +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Filter", () => ({ + $offset: { x: 0, y: 0 }, + $intToR: vi.fn(() => 1), + $intToG: vi.fn(() => 0), + $intToB: vi.fn(() => 0) +})); + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $context: { + currentAttachmentObject: null, + bind: vi.fn() + } + }; +}); + +describe("FilterApplyGlowFilterUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should apply glow filter with default parameters", () => + { + const mockTextureObject: ITextureObject = { + id: 1, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute(mockTextureObject, matrix); + + expect(result).toBeDefined(); + expect(result).toHaveProperty("width"); + expect(result).toHaveProperty("height"); + }); + + it("test case - should apply glow filter with custom color", () => + { + const mockTextureObject: ITextureObject = { + id: 2, + resource: {} as WebGLTexture, + width: 200, + height: 150, + area: 30000, + smooth: true + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute( + mockTextureObject, matrix, + 0xFF0000, // color (red) + 1 // alpha + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply glow filter with custom blur values", () => + { + const mockTextureObject: ITextureObject = { + id: 3, + resource: {} as WebGLTexture, + width: 256, + height: 256, + area: 65536, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute( + mockTextureObject, matrix, + 0x00FF00, // color (green) + 0.8, // alpha + 16, // blur_x + 16, // blur_y + 2 // strength + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply inner glow filter", () => + { + const mockTextureObject: ITextureObject = { + id: 4, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute( + mockTextureObject, matrix, + 0x0000FF, // color (blue) + 1, + 8, 8, 1, 2, + true // inner + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply knockout glow filter", () => + { + const mockTextureObject: ITextureObject = { + id: 5, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute( + mockTextureObject, matrix, + 0xFFFF00, // color (yellow) + 1, + 4, 4, 1, 1, + false, // inner + true // knockout + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply glow filter with high quality", () => + { + const mockTextureObject: ITextureObject = { + id: 6, + resource: {} as WebGLTexture, + width: 512, + height: 512, + area: 262144, + smooth: false + }; + + const matrix = new Float32Array([2, 0, 0, 2, 0, 0]); + + const result = execute( + mockTextureObject, matrix, + 0xFFFFFF, // color (white) + 1, + 8, 8, + 3, // strength + 3 // quality + ); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/webgl/src/Filter/GradientBevelFilter/usecase/FilterApplyGradientBevelFilterUseCase.test.ts b/packages/webgl/src/Filter/GradientBevelFilter/usecase/FilterApplyGradientBevelFilterUseCase.test.ts new file mode 100644 index 00000000..e285cccb --- /dev/null +++ b/packages/webgl/src/Filter/GradientBevelFilter/usecase/FilterApplyGradientBevelFilterUseCase.test.ts @@ -0,0 +1,234 @@ +import { execute } from "./FilterApplyGradientBevelFilterUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ITextureObject } from "../../../interface/ITextureObject"; + +vi.mock("../../../FrameBufferManager/usecase/FrameBufferManagerGetAttachmentObjectUseCase", () => ({ + execute: vi.fn(() => ({ + id: 1, + width: 100, + height: 100, + texture: { + id: 1, + resource: {}, + width: 100, + height: 100, + area: 10000, + smooth: false + } + })) +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerBind0UseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/ShaderManager/usecase/ShaderManagerDrawTextureUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Shader/Variants/Blend/service/VariantsBlendTextureShaderService", () => ({ + execute: vi.fn(() => ({ + uniform: {} + })) +})); + +vi.mock("../../../Shader/ShaderManager/service/ShaderManagerSetTextureUniformService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Blend/service/BlendEraseService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Blend/service/BlendResetService", () => ({ + execute: vi.fn() +})); + +vi.mock("../../BlurFilter/usecase/FilterApplyBlurFilterUseCase", () => ({ + execute: vi.fn(() => ({ + id: 2, + resource: {}, + width: 120, + height: 120, + area: 14400, + smooth: false + })) +})); + +vi.mock("../../../FrameBufferManager/usecase/FrameBufferManagerReleaseAttachmentObjectUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../BitmapFilter/usecase/FilterApplyBitmapFilterUseCase", () => ({ + execute: vi.fn(() => ({ + id: 3, + resource: {}, + width: 130, + height: 130, + area: 16900, + smooth: false + })) +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Filter", () => ({ + $offset: { x: 0, y: 0 } +})); + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $context: { + currentAttachmentObject: null, + bind: vi.fn(), + reset: vi.fn(), + setTransform: vi.fn() + }, + $devicePixelRatio: 1 + }; +}); + +describe("FilterApplyGradientBevelFilterUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should apply gradient bevel filter with basic parameters", () => + { + const mockTextureObject: ITextureObject = { + id: 1, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([1, 1, 1, 0, 0, 0]); + const alphas = new Float32Array([1, 1]); + const ratios = new Float32Array([0, 1]); + + const result = execute( + mockTextureObject, matrix, + 4, 45, + colors, alphas, ratios + ); + + expect(result).toBeDefined(); + expect(result).toHaveProperty("width"); + expect(result).toHaveProperty("height"); + }); + + it("test case - should apply gradient bevel filter with custom distance and angle", () => + { + const mockTextureObject: ITextureObject = { + id: 2, + resource: {} as WebGLTexture, + width: 200, + height: 150, + area: 30000, + smooth: true + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const alphas = new Float32Array([1, 1, 1]); + const ratios = new Float32Array([0, 0.5, 1]); + + const result = execute( + mockTextureObject, matrix, + 8, 135, + colors, alphas, ratios + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply inner gradient bevel filter", () => + { + const mockTextureObject: ITextureObject = { + id: 3, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([1, 1, 1, 0, 0, 0]); + const alphas = new Float32Array([1, 1]); + const ratios = new Float32Array([0, 1]); + + const result = execute( + mockTextureObject, matrix, + 4, 45, + colors, alphas, ratios, + 4, 4, 1, 1, + 1 // type = inner + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply outer gradient bevel filter", () => + { + const mockTextureObject: ITextureObject = { + id: 4, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([1, 1, 1, 0, 0, 0]); + const alphas = new Float32Array([1, 1]); + const ratios = new Float32Array([0, 1]); + + const result = execute( + mockTextureObject, matrix, + 4, 45, + colors, alphas, ratios, + 4, 4, 1, 1, + 2 // type = outer + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply knockout gradient bevel filter", () => + { + const mockTextureObject: ITextureObject = { + id: 5, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([1, 1, 1, 0.5, 0.5, 0.5, 0, 0, 0]); + const alphas = new Float32Array([1, 1, 1]); + const ratios = new Float32Array([0, 0.5, 1]); + + const result = execute( + mockTextureObject, matrix, + 4, 45, + colors, alphas, ratios, + 8, 8, 2, 2, + 0, // type = full + true // knockout + ); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/webgl/src/Filter/GradientBevelFilter/usecase/FilterApplyGradientBevelFilterUseCase.ts b/packages/webgl/src/Filter/GradientBevelFilter/usecase/FilterApplyGradientBevelFilterUseCase.ts index e84c6dc9..28d4b326 100644 --- a/packages/webgl/src/Filter/GradientBevelFilter/usecase/FilterApplyGradientBevelFilterUseCase.ts +++ b/packages/webgl/src/Filter/GradientBevelFilter/usecase/FilterApplyGradientBevelFilterUseCase.ts @@ -12,7 +12,7 @@ import { execute as filterApplyBitmapFilterUseCase } from "../../BitmapFilter/us import { execute as textureManagerReleaseTextureObjectUseCase } from "../../../TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase"; import { $offset } from "../../../Filter"; import { - $getDevicePixelRatio, + $devicePixelRatio, $context } from "../../../WebGLUtil"; @@ -67,7 +67,7 @@ export const execute = ( const xScale = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]); const yScale = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]); - const devicePixelRatio = $getDevicePixelRatio(); + const devicePixelRatio = $devicePixelRatio; const radian = angle * $Deg2Rad; const x = Math.cos(radian) * distance * (xScale / devicePixelRatio); const y = Math.sin(radian) * distance * (yScale / devicePixelRatio); diff --git a/packages/webgl/src/Filter/GradientGlowFilter/usecase/FilterApplyGradientGlowFilterUseCase.test.ts b/packages/webgl/src/Filter/GradientGlowFilter/usecase/FilterApplyGradientGlowFilterUseCase.test.ts new file mode 100644 index 00000000..1246b619 --- /dev/null +++ b/packages/webgl/src/Filter/GradientGlowFilter/usecase/FilterApplyGradientGlowFilterUseCase.test.ts @@ -0,0 +1,210 @@ +import { execute } from "./FilterApplyGradientGlowFilterUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ITextureObject } from "../../../interface/ITextureObject"; + +vi.mock("../../BlurFilter/usecase/FilterApplyBlurFilterUseCase", () => ({ + execute: vi.fn(() => ({ + id: 2, + resource: {}, + width: 120, + height: 120, + area: 14400, + smooth: false + })) +})); + +vi.mock("../../BitmapFilter/usecase/FilterApplyBitmapFilterUseCase", () => ({ + execute: vi.fn(() => ({ + id: 3, + resource: {}, + width: 130, + height: 130, + area: 16900, + smooth: false + })) +})); + +vi.mock("../../../TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Filter", () => ({ + $offset: { x: 0, y: 0 } +})); + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $devicePixelRatio: 1 + }; +}); + +describe("FilterApplyGradientGlowFilterUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should apply gradient glow filter with basic parameters", () => + { + const mockTextureObject: ITextureObject = { + id: 1, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([1, 0, 0, 0, 0, 1]); + const alphas = new Float32Array([1, 1]); + const ratios = new Float32Array([0, 1]); + + const result = execute( + mockTextureObject, matrix, + 4, 45, + colors, alphas, ratios + ); + + expect(result).toBeDefined(); + expect(result).toHaveProperty("width"); + expect(result).toHaveProperty("height"); + }); + + it("test case - should apply gradient glow filter with custom distance and angle", () => + { + const mockTextureObject: ITextureObject = { + id: 2, + resource: {} as WebGLTexture, + width: 200, + height: 150, + area: 30000, + smooth: true + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const alphas = new Float32Array([1, 0.5, 1]); + const ratios = new Float32Array([0, 0.5, 1]); + + const result = execute( + mockTextureObject, matrix, + 8, 90, + colors, alphas, ratios + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply inner gradient glow filter", () => + { + const mockTextureObject: ITextureObject = { + id: 3, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([1, 1, 0, 1, 0, 0]); + const alphas = new Float32Array([1, 1]); + const ratios = new Float32Array([0, 1]); + + const result = execute( + mockTextureObject, matrix, + 4, 45, + colors, alphas, ratios, + 4, 4, 1, 1, + 1 // type = inner + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply outer gradient glow filter", () => + { + const mockTextureObject: ITextureObject = { + id: 4, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0, 1, 1, 1, 0, 1]); + const alphas = new Float32Array([1, 1]); + const ratios = new Float32Array([0, 1]); + + const result = execute( + mockTextureObject, matrix, + 4, 45, + colors, alphas, ratios, + 8, 8, 1, 1, + 2 // type = outer + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply full gradient glow filter with knockout", () => + { + const mockTextureObject: ITextureObject = { + id: 5, + resource: {} as WebGLTexture, + width: 100, + height: 100, + area: 10000, + smooth: false + }; + + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([1, 1, 1, 0.5, 0.5, 0.5, 0, 0, 0]); + const alphas = new Float32Array([1, 0.8, 0.6]); + const ratios = new Float32Array([0, 0.5, 1]); + + const result = execute( + mockTextureObject, matrix, + 6, 135, + colors, alphas, ratios, + 12, 12, 2, 2, + 0, // type = full + true // knockout + ); + + expect(result).toBeDefined(); + }); + + it("test case - should apply gradient glow filter with high quality", () => + { + const mockTextureObject: ITextureObject = { + id: 6, + resource: {} as WebGLTexture, + width: 256, + height: 256, + area: 65536, + smooth: false + }; + + const matrix = new Float32Array([2, 0, 0, 2, 0, 0]); + const colors = new Float32Array([1, 0.5, 0, 0.5, 0, 1]); + const alphas = new Float32Array([1, 1]); + const ratios = new Float32Array([0, 1]); + + const result = execute( + mockTextureObject, matrix, + 4, 45, + colors, alphas, ratios, + 8, 8, + 3, // strength + 3 // quality + ); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/webgl/src/Filter/GradientGlowFilter/usecase/FilterApplyGradientGlowFilterUseCase.ts b/packages/webgl/src/Filter/GradientGlowFilter/usecase/FilterApplyGradientGlowFilterUseCase.ts index a4aa0723..0e4ec6bc 100644 --- a/packages/webgl/src/Filter/GradientGlowFilter/usecase/FilterApplyGradientGlowFilterUseCase.ts +++ b/packages/webgl/src/Filter/GradientGlowFilter/usecase/FilterApplyGradientGlowFilterUseCase.ts @@ -3,7 +3,7 @@ import { execute as filterApplyBlurFilterUseCase } from "../../BlurFilter/usecas import { execute as filterApplyBitmapFilterUseCase } from "../../BitmapFilter/usecase/FilterApplyBitmapFilterUseCase"; import { execute as textureManagerReleaseTextureObjectUseCase } from "../../../TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase"; import { $offset } from "../../../Filter"; -import { $getDevicePixelRatio } from "../../../WebGLUtil"; +import { $devicePixelRatio } from "../../../WebGLUtil"; /** * @type {number} @@ -69,7 +69,7 @@ export const execute = ( const yScale = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]); // shadow point - const devicePixelRatio = $getDevicePixelRatio(); + const devicePixelRatio = $devicePixelRatio; const radian = angle * $Deg2Rad; const x = Math.cos(radian) * distance * (xScale / devicePixelRatio); const y = Math.sin(radian) * distance * (yScale / devicePixelRatio); diff --git a/packages/webgl/src/FrameBufferManager.ts b/packages/webgl/src/FrameBufferManager.ts index 00ae3572..245e767a 100644 --- a/packages/webgl/src/FrameBufferManager.ts +++ b/packages/webgl/src/FrameBufferManager.ts @@ -1,80 +1,24 @@ import type { IAttachmentObject } from "./interface/IAttachmentObject"; import type { ITextureObject } from "./interface/ITextureObject"; -/** - * @description 生成したFrameBufferの管理オブジェクトを配列にプールして再利用します。 - * Pool the management object of the generated FrameBuffer in an array and reuse it. - * - * @type {array} - * @protected - */ export const $objectPool: IAttachmentObject[] = []; -/** - * @description READ_FRAMEBUFFER専用のFrameBufferオブジェクト - * FrameBuffer object for READ_FRAMEBUFFER only - * - * @class - * @public - */ export let $readFrameBuffer: WebGLFramebuffer; -/** - * @description READ_FRAMEBUFFER専用のFrameBufferオブジェクトを設定 - * Set the FrameBuffer object for READ_FRAMEBUFFER only - * - * @param {WebGL2RenderingContext} gl - * @return {void} - * @method - * @protected - */ export const $setReadFrameBuffer = (gl: WebGL2RenderingContext): void => { $readFrameBuffer = gl.createFramebuffer() as NonNullable; }; -/** - * @description DRAW_FRAMEBUFFER専用のFrameBufferオブジェクト - * FrameBuffer object for DRAW_FRAMEBUFFER only - * - * @class - * @public - */ export let $drawFrameBuffer: WebGLFramebuffer | null = null; -/** - * @description DRAW_FRAMEBUFFER専用のFrameBufferオブジェクトを設定 - * Set the FrameBuffer object for DRAW_FRAMEBUFFER only - * - * @param {WebGL2RenderingContext} gl - * @return {void} - * @method - * @protected - */ export const $setDrawFrameBuffer = (gl: WebGL2RenderingContext): void => { $drawFrameBuffer = gl.createFramebuffer() as NonNullable; }; -/** - * @description アトラス専用のFrameBufferオブジェクト - * FrameBuffer object for atlas only - * - * @class - * @public - */ export let $atlasFrameBuffer: WebGLFramebuffer | null = null; -/** - * @description アトラス専用のFrameBufferオブジェクトを設定 - * Set the FrameBuffer object for atlas only - * - * @param {WebGL2RenderingContext} gl - * @param {ITextureObject} texture_object - * @return {void} - * @method - * @protected - */ export const $setAtlasFrameBuffer = ( gl: WebGL2RenderingContext, texture_object: ITextureObject @@ -92,122 +36,28 @@ export const $setAtlasFrameBuffer = ( gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, $atlasFrameBuffer); }; -/** - * @description 現在アタッチされてるAttachmentObject - * Currently attached AttachmentObject - * - * @type {IAttachmentObject|null} - * @private - */ -let $currentAttachment: IAttachmentObject | null = null; +export let $currentAttachment: IAttachmentObject | null = null; -/** - * @description 現在アタッチされてるAttachmentObjectを設定 - * Set the currently attached AttachmentObject - * - * @param {IAttachmentObject | null} attachment_object - * @return {void} - * @method - * @protected - */ export const $setCurrentAttachment = (attachment_object: IAttachmentObject | null): void => { $currentAttachment = attachment_object; }; -/** - * @description 現在アタッチされてるAttachmentObjectを返却 - * Returns the currently attached AttachmentObject - * - * @return {IAttachmentObject | null} - * @method - * @protected - */ -export const $getCurrentAttachment = (): IAttachmentObject | null => -{ - return $currentAttachment; -}; - -/** - * @description FrameBufferがバインドされているかどうかのフラグ - * Flag to check if FrameBuffer is bound - * - * @type {boolean} - * @protected - */ -let $isFramebufferBound: boolean = false; +export let $isFramebufferBound: boolean = false; -/** - * @description FrameBufferがバインドされているかどうかのフラグの値を更新 - * Update the value of the flag to check if FrameBuffer is bound - * - * @param {boolean} state - * @return {void} - * @method - * @protected - */ export const $setFramebufferBound = (state: boolean): void => { $isFramebufferBound = state; }; -/** - * @description FrameBufferがバインドされているかどうかのフラグの値を返却 - * Returns the value of the flag to check if FrameBuffer is bound - * - * @return {boolean} - * @method - * @protected - */ -export const $useFramebufferBound = (): boolean => -{ - return $isFramebufferBound; -}; +export let $readBitmapFramebuffer: WebGLFramebuffer | null = null; -/** - * @description ビットマップの読み込み専用のFrameBufferオブジェクト - * FrameBuffer object for reading bitmaps only - * - * @type {WebGLFramebuffer|null} - * @default null - * @private - */ -let $readBitmapFramebuffer: WebGLFramebuffer | null = null; +export let $drawBitmapFramebuffer: WebGLFramebuffer | null = null; -/** - * @description ビットマップの書き込み専用のFrameBufferオブジェクト - * FrameBuffer object for writing bitmaps only - * - * @type {WebGLFramebuffer|null} - * @default null - * @private - */ -let $drawBitmapFramebuffer: WebGLFramebuffer | null = null; +export let $pixelFrameBuffer: WebGLFramebuffer | null = null; -/** - * @type {WebGLFramebuffer} - * @private - */ -let $pixelFrameBuffer: WebGLFramebuffer | null = null; +export let $pixelBufferObject: WebGLBuffer | null = null; -/** - * @description PBO - * Pixel Buffer Object - * - * @type {WebGLBuffer} - * @private - */ -let $pixelBufferObject: WebGLBuffer | null = null; - -/** - * @description ビットマップの読み込み専用のFrameBufferオブジェクトを設定 - * Set the FrameBuffer object for reading bitmaps only - * - * @param {WebGL2RenderingContext} gl - * @return {void} - * @method - * @protected - */ export const $setBitmapFrameBuffer = (gl: WebGL2RenderingContext): void => { // blitFramebuffer @@ -219,55 +69,3 @@ export const $setBitmapFrameBuffer = (gl: WebGL2RenderingContext): void => $pixelBufferObject = gl.createBuffer(); gl.bindBuffer(gl.PIXEL_PACK_BUFFER, $pixelBufferObject); }; - -/** - * @description ビットマップの読み込み専用のFrameBufferオブジェクトを返却 - * Returns the FrameBuffer object for reading bitmaps only - * - * @return {WebGLFramebuffer} - * @method - * @protected - */ -export const $getReadBitmapFrameBuffer = (): WebGLFramebuffer => -{ - return $readBitmapFramebuffer as NonNullable; -}; - -/** - * @description ビットマップの書き込み専用のFrameBufferオブジェクトを返却 - * Returns the FrameBuffer object for writing bitmaps only - * - * @return {WebGLFramebuffer} - * @method - * @protected - */ -export const $getDrawBitmapFrameBuffer = (): WebGLFramebuffer => -{ - return $drawBitmapFramebuffer as NonNullable; -}; - -/** - * @description PBO用のFrameBufferオブジェクトを返却 - * Returns the FrameBuffer object for PBO - * - * @return {WebGLFramebuffer} - * @method - * @protected - */ -export const $getPixelFrameBuffer = (): WebGLFramebuffer => -{ - return $pixelFrameBuffer as NonNullable; -}; - -/** - * @description PBO - * Pixel Buffer Object - * - * @return {WebGLBuffer} - * @method - * @protected - */ -export const $getPixelBufferObject = (): WebGLBuffer => -{ - return $pixelBufferObject as NonNullable; -}; \ No newline at end of file diff --git a/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerBindAttachmentObjectService.test.ts b/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerBindAttachmentObjectService.test.ts index 5543698f..470a0ae9 100644 --- a/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerBindAttachmentObjectService.test.ts +++ b/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerBindAttachmentObjectService.test.ts @@ -2,9 +2,9 @@ import type { IAttachmentObject } from "../../interface/IAttachmentObject"; import type { IColorBufferObject } from "../../interface/IColorBufferObject"; import { $setCurrentAttachment, - $getCurrentAttachment, + $currentAttachment, $setFramebufferBound, - $useFramebufferBound + $isFramebufferBound } from "../../FrameBufferManager"; import { $activeTextureUnit, @@ -64,16 +64,16 @@ describe("FrameBufferManagerBindAttachmentObjectService.js method test", () => }; $setFramebufferBound(false); - expect($useFramebufferBound()).toBe(false); + expect($isFramebufferBound).toBe(false); $setCurrentAttachment(null); - expect($getCurrentAttachment()).toBe(null); + expect($currentAttachment).toBe(null); $setActiveTextureUnit(-1); expect($activeTextureUnit).toBe(-1); execute(attachmentObject); - expect($getCurrentAttachment()).toBe(attachmentObject); - expect($useFramebufferBound()).toBe(true); + expect($currentAttachment).toBe(attachmentObject); + expect($isFramebufferBound).toBe(true); expect($activeTextureUnit).toBe(0); }); @@ -127,16 +127,16 @@ describe("FrameBufferManagerBindAttachmentObjectService.js method test", () => }; $setFramebufferBound(false); - expect($useFramebufferBound()).toBe(false); + expect($isFramebufferBound).toBe(false); $setCurrentAttachment(null); - expect($getCurrentAttachment()).toBe(null); + expect($currentAttachment).toBe(null); $setActiveTextureUnit(-1); expect($activeTextureUnit).toBe(-1); execute(attachmentObject); - expect($getCurrentAttachment()).toBe(attachmentObject); - expect($useFramebufferBound()).toBe(true); + expect($currentAttachment).toBe(attachmentObject); + expect($isFramebufferBound).toBe(true); expect($activeTextureUnit).toBe(-1); }); }); \ No newline at end of file diff --git a/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerBindAttachmentObjectService.ts b/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerBindAttachmentObjectService.ts index 558f0547..3cee2556 100644 --- a/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerBindAttachmentObjectService.ts +++ b/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerBindAttachmentObjectService.ts @@ -4,11 +4,12 @@ import type { ITextureObject } from "../../interface/ITextureObject"; import type { IStencilBufferObject } from "../../interface/IStencilBufferObject"; import { $gl } from "../../WebGLUtil"; import { execute as textureManagerBind0UseCase } from "../../TextureManager/usecase/TextureManagerBind0UseCase"; +import { execute as textureManagerBindService } from "../../TextureManager/service/TextureManagerBindService"; import { $setFramebufferBound, $setCurrentAttachment, $readFrameBuffer, - $useFramebufferBound + $isFramebufferBound } from "../../FrameBufferManager"; /** @@ -24,7 +25,7 @@ export const execute = (attachment_object: IAttachmentObject): void => { $setCurrentAttachment(attachment_object); - if (!$useFramebufferBound()) { + if (!$isFramebufferBound) { $setFramebufferBound(true); $gl.bindFramebuffer($gl.FRAMEBUFFER, $readFrameBuffer); } @@ -42,6 +43,12 @@ export const execute = (attachment_object: IAttachmentObject): void => $gl.FRAMEBUFFER, $gl.COLOR_ATTACHMENT0, $gl.TEXTURE_2D, (attachment_object.texture as ITextureObject).resource, 0 ); + + // テクスチャフィードバックループを防止: + // WebGL2ではテクスチャユニットにバインドされたテクスチャが描画フレームバッファにも + // アタッチされている場合、drawArrays等がINVALID_OPERATIONを生成する。 + // framebufferTexture2Dにはテクスチャユニットへのバインドは不要なため、アンバインドする。 + textureManagerBindService(0, $gl.TEXTURE0, null); } $gl.bindRenderbuffer($gl.RENDERBUFFER, (attachment_object.stencil as IStencilBufferObject).resource); diff --git a/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerTransferAtlasTextureService.test.ts b/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerTransferAtlasTextureService.test.ts index bc06b337..490cc962 100644 --- a/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerTransferAtlasTextureService.test.ts +++ b/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerTransferAtlasTextureService.test.ts @@ -37,7 +37,7 @@ describe("FrameBufferManagerTransferAtlasTextureService.ts test", () => const mod = await importOriginal(); return { ...mod, - "$getActiveAtlasIndex": vi.fn(() => 0), + "$activeAtlasIndex": 0, "$getActiveTransferBounds": vi.fn(() => [0, 0, 100, 100]), "$getActiveAllTransferBounds": vi.fn(() => [0, 0, 200, 200]), } diff --git a/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerTransferAtlasTextureService.ts b/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerTransferAtlasTextureService.ts index 71a66091..a182d077 100644 --- a/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerTransferAtlasTextureService.ts +++ b/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerTransferAtlasTextureService.ts @@ -1,23 +1,18 @@ import { $getActiveTransferBounds, - $getActiveAtlasIndex, - $getActiveAllTransferBounds + $activeAtlasIndex } from "../../AtlasManager"; import { $gl, - $context + $context, + $enableScissorTest, + $disableScissorTest } from "../../WebGLUtil"; import { $atlasFrameBuffer, $setFramebufferBound } from "../../FrameBufferManager"; -/** - * @type {number} - * @private - */ -let $currentIndex: number = 0; - /** * @description アトラステクスチャに転写します。 * Transfer to atlas texture. @@ -28,6 +23,10 @@ let $currentIndex: number = 0; */ export const execute = (): void => { + if (!$context.newDrawState) { + return ; + } + const currentAttachmentObject = $context.currentAttachmentObject; const atlasAttachmentObject = $context.atlasAttachmentObject; @@ -39,46 +38,26 @@ export const execute = (): void => ); $setFramebufferBound(false); - if ($currentIndex === $getActiveAtlasIndex()) { - const bounds = $getActiveTransferBounds($getActiveAtlasIndex()); - if (bounds[2] !== -Number.MAX_VALUE - && bounds[3] !== -Number.MAX_VALUE - ) { - $gl.enable($gl.SCISSOR_TEST); - $gl.scissor( - bounds[0], bounds[1], - bounds[2], bounds[3] - ); - - $gl.blitFramebuffer( - 0, 0, atlasAttachmentObject.width, atlasAttachmentObject.height, - 0, 0, atlasAttachmentObject.width, atlasAttachmentObject.height, - $gl.COLOR_BUFFER_BIT, - $gl.NEAREST - ); - $gl.disable($gl.SCISSOR_TEST); - } - } else { - const bounds = $getActiveAllTransferBounds($getActiveAtlasIndex()); + const atlasIdx = $activeAtlasIndex; + const bounds = $getActiveTransferBounds(atlasIdx); - $gl.enable($gl.SCISSOR_TEST); - $gl.scissor( - bounds[0], bounds[1], - bounds[2], bounds[3] - ); - - $gl.blitFramebuffer( - 0, 0, atlasAttachmentObject.width, atlasAttachmentObject.height, - 0, 0, atlasAttachmentObject.width, atlasAttachmentObject.height, - $gl.COLOR_BUFFER_BIT, - $gl.NEAREST - ); - $gl.disable($gl.SCISSOR_TEST); + $enableScissorTest(); + $gl.scissor( + bounds[0], bounds[1], + bounds[2] - bounds[0], bounds[3] - bounds[1] + ); - $currentIndex = $getActiveAtlasIndex(); - } + $gl.blitFramebuffer( + 0, 0, atlasAttachmentObject.width, atlasAttachmentObject.height, + 0, 0, atlasAttachmentObject.width, atlasAttachmentObject.height, + $gl.COLOR_BUFFER_BIT, + $gl.NEAREST + ); + $disableScissorTest(); if (currentAttachmentObject) { $context.bind(currentAttachmentObject); } -}; \ No newline at end of file + + $context.newDrawState = false; +}; diff --git a/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerUnBindAttachmentObjectService.test.ts b/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerUnBindAttachmentObjectService.test.ts index e46b2206..c13bc1e7 100644 --- a/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerUnBindAttachmentObjectService.test.ts +++ b/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerUnBindAttachmentObjectService.test.ts @@ -3,8 +3,8 @@ import { execute } from "./FrameBufferManagerUnBindAttachmentObjectService.ts"; import { describe, expect, it, vi } from "vitest"; import { $setCurrentAttachment, - $getCurrentAttachment, - $useFramebufferBound, + $currentAttachment, + $isFramebufferBound, $setFramebufferBound } from "../../FrameBufferManager.ts"; @@ -49,13 +49,13 @@ describe("FrameBufferManagerUnBindAttachmentObjectService.js method test", () => } as unknown as IAttachmentObject; $setCurrentAttachment(attachmentObject); - expect($getCurrentAttachment()).toBe(attachmentObject); + expect($currentAttachment).toBe(attachmentObject); $setFramebufferBound(true); - expect($useFramebufferBound()).toBe(true); + expect($isFramebufferBound).toBe(true); execute(); - expect($useFramebufferBound()).toBe(false); - expect($getCurrentAttachment()).toBe(null); + expect($isFramebufferBound).toBe(false); + expect($currentAttachment).toBe(null); }); }); \ No newline at end of file diff --git a/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerUnBindAttachmentObjectService.ts b/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerUnBindAttachmentObjectService.ts index 19a163fa..d5952687 100644 --- a/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerUnBindAttachmentObjectService.ts +++ b/packages/webgl/src/FrameBufferManager/service/FrameBufferManagerUnBindAttachmentObjectService.ts @@ -1,7 +1,7 @@ import { $gl } from "../../WebGLUtil"; import { $setCurrentAttachment, - $useFramebufferBound, + $isFramebufferBound, $setFramebufferBound } from "../../FrameBufferManager"; @@ -17,7 +17,7 @@ export const execute = (): void => { $setCurrentAttachment(null); - if ($useFramebufferBound()) { + if ($isFramebufferBound) { $setFramebufferBound(false); $gl.bindFramebuffer($gl.FRAMEBUFFER, null); } diff --git a/packages/webgl/src/FrameBufferManager/usecase/FrameBufferManagerGetAttachmentObjectUseCase.ts b/packages/webgl/src/FrameBufferManager/usecase/FrameBufferManagerGetAttachmentObjectUseCase.ts index 38d00570..aaff3527 100644 --- a/packages/webgl/src/FrameBufferManager/usecase/FrameBufferManagerGetAttachmentObjectUseCase.ts +++ b/packages/webgl/src/FrameBufferManager/usecase/FrameBufferManagerGetAttachmentObjectUseCase.ts @@ -23,9 +23,9 @@ export const execute = ( multisample: boolean = false ): IAttachmentObject => { - // キャッシュがあれば再利用する + // キャッシュがあれば再利用する(pop()はO(1)、shift()はO(n)) const attachmentObject = $objectPool.length - ? $objectPool.shift() as IAttachmentObject + ? $objectPool.pop() as IAttachmentObject : frameBufferManagerCreateAttachmentObjectService(); // テクスチャを取得 diff --git a/packages/webgl/src/FrameBufferManager/usecase/FrameBufferManagerGetTextureFromNodeUseCase.test.ts b/packages/webgl/src/FrameBufferManager/usecase/FrameBufferManagerGetTextureFromNodeUseCase.test.ts index d3f20abc..f09b8360 100644 --- a/packages/webgl/src/FrameBufferManager/usecase/FrameBufferManagerGetTextureFromNodeUseCase.test.ts +++ b/packages/webgl/src/FrameBufferManager/usecase/FrameBufferManagerGetTextureFromNodeUseCase.test.ts @@ -27,13 +27,13 @@ describe("FrameBufferManagerGetTextureFromNodeUseCase.js method test", () => }); vi.mock("../../FrameBufferManager", () => ({ - $getDrawBitmapFrameBuffer: vi.fn(() => "drawFrameBuffer"), - $getReadBitmapFrameBuffer: vi.fn(() => "readFrameBuffer"), + $drawBitmapFramebuffer: "drawFrameBuffer", + $readBitmapFramebuffer: "readFrameBuffer", $readFrameBuffer: "readFrameBuffer" })); vi.mock("../../AtlasManager", () => ({ - $getActiveAtlasIndex: vi.fn(() => 0), + $activeAtlasIndex: 0, $setActiveAtlasIndex: vi.fn(), $getAtlasTextureObject: vi.fn(() => ({ resource: "atlasTexture" diff --git a/packages/webgl/src/FrameBufferManager/usecase/FrameBufferManagerGetTextureFromNodeUseCase.ts b/packages/webgl/src/FrameBufferManager/usecase/FrameBufferManagerGetTextureFromNodeUseCase.ts index 2daeab79..6183d1ac 100644 --- a/packages/webgl/src/FrameBufferManager/usecase/FrameBufferManagerGetTextureFromNodeUseCase.ts +++ b/packages/webgl/src/FrameBufferManager/usecase/FrameBufferManagerGetTextureFromNodeUseCase.ts @@ -3,13 +3,13 @@ import type { ITextureObject } from "../../interface/ITextureObject"; import { execute as textureManagerGetTextureUseCase } from "../../TextureManager/usecase/TextureManagerGetTextureUseCase"; import { execute as frameBufferManagerTransferAtlasTextureService } from "../../FrameBufferManager/service/FrameBufferManagerTransferAtlasTextureService"; import { - $getDrawBitmapFrameBuffer, - $getReadBitmapFrameBuffer, + $drawBitmapFramebuffer, + $readBitmapFramebuffer, $readFrameBuffer } from "../../FrameBufferManager"; import { $gl } from "../../WebGLUtil"; import { - $getActiveAtlasIndex, + $activeAtlasIndex, $setActiveAtlasIndex, $getAtlasTextureObject } from "../../AtlasManager"; @@ -26,12 +26,11 @@ import { export const execute = (node: Node): ITextureObject => { // 指定されたindexのフレームバッファの描画をアトラステクスチャに転送 - const activeIndex = $getActiveAtlasIndex(); + const activeIndex = $activeAtlasIndex; $setActiveAtlasIndex(node.index); frameBufferManagerTransferAtlasTextureService(); - const readBitmapFrameBuffer = $getReadBitmapFrameBuffer(); - $gl.bindFramebuffer($gl.FRAMEBUFFER, readBitmapFrameBuffer); + $gl.bindFramebuffer($gl.FRAMEBUFFER, $readBitmapFramebuffer); const atlasTextureObject = $getAtlasTextureObject(); $gl.framebufferTexture2D( @@ -39,8 +38,7 @@ export const execute = (node: Node): ITextureObject => $gl.TEXTURE_2D, atlasTextureObject.resource, 0 ); - const drawBitmapFrameBuffer = $getDrawBitmapFrameBuffer(); - $gl.bindFramebuffer($gl.FRAMEBUFFER, drawBitmapFrameBuffer); + $gl.bindFramebuffer($gl.FRAMEBUFFER, $drawBitmapFramebuffer); const textureObject = textureManagerGetTextureUseCase(node.w, node.h); $gl.framebufferTexture2D( @@ -49,8 +47,8 @@ export const execute = (node: Node): ITextureObject => ); $gl.bindFramebuffer($gl.FRAMEBUFFER, null); - $gl.bindFramebuffer($gl.READ_FRAMEBUFFER, readBitmapFrameBuffer); - $gl.bindFramebuffer($gl.DRAW_FRAMEBUFFER, drawBitmapFrameBuffer); + $gl.bindFramebuffer($gl.READ_FRAMEBUFFER, $readBitmapFramebuffer); + $gl.bindFramebuffer($gl.DRAW_FRAMEBUFFER, $drawBitmapFramebuffer); // execute $gl.blitFramebuffer( diff --git a/packages/webgl/src/Mask/service/MaskBeginMaskService.test.ts b/packages/webgl/src/Mask/service/MaskBeginMaskService.test.ts index 10fc573c..32fb04c7 100644 --- a/packages/webgl/src/Mask/service/MaskBeginMaskService.test.ts +++ b/packages/webgl/src/Mask/service/MaskBeginMaskService.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { IAttachmentObject } from "../../interface/IAttachmentObject.ts"; import { $setCurrentAttachment, - $getCurrentAttachment + $currentAttachment } from "../../FrameBufferManager.ts"; import { $isMaskDrawing, @@ -64,9 +64,10 @@ describe("MaskBeginMaskService.js method test", () => "ZERO": "ZERO", "INVERT": "INVERT", }, + $enableStencilTest: vi.fn(), "$context": { get currentAttachmentObject() { - return $getCurrentAttachment(); + return $currentAttachment; } } } diff --git a/packages/webgl/src/Mask/service/MaskBeginMaskService.ts b/packages/webgl/src/Mask/service/MaskBeginMaskService.ts index da083f5a..30c99288 100644 --- a/packages/webgl/src/Mask/service/MaskBeginMaskService.ts +++ b/packages/webgl/src/Mask/service/MaskBeginMaskService.ts @@ -4,8 +4,9 @@ import { $clipLevels } from "../../Mask"; import { + $gl, $context, - $gl + $enableStencilTest } from "../../WebGLUtil"; /** @@ -33,7 +34,7 @@ export const execute = (): void => if (!$isMaskDrawing()) { $setMaskDrawing(true); - $gl.enable($gl.STENCIL_TEST); + $enableStencilTest(); $gl.enable($gl.SAMPLE_ALPHA_TO_COVERAGE); $gl.stencilFunc($gl.ALWAYS, 0, 0xff); diff --git a/packages/webgl/src/Mask/service/MaskEndMaskService.test.ts b/packages/webgl/src/Mask/service/MaskEndMaskService.test.ts index 0fb0cb59..cb578bcd 100644 --- a/packages/webgl/src/Mask/service/MaskEndMaskService.test.ts +++ b/packages/webgl/src/Mask/service/MaskEndMaskService.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { IAttachmentObject } from "../../interface/IAttachmentObject.ts"; import { $setCurrentAttachment, - $getCurrentAttachment + $currentAttachment } from "../../FrameBufferManager.ts"; describe("MaskEndMaskService.js method test", () => @@ -68,7 +68,7 @@ describe("MaskEndMaskService.js method test", () => }, "$context": { get currentAttachmentObject() { - return $getCurrentAttachment(); + return $currentAttachment; } } } diff --git a/packages/webgl/src/Mask/service/MaskEndMaskService.ts b/packages/webgl/src/Mask/service/MaskEndMaskService.ts index 062580f9..f0648659 100644 --- a/packages/webgl/src/Mask/service/MaskEndMaskService.ts +++ b/packages/webgl/src/Mask/service/MaskEndMaskService.ts @@ -1,6 +1,7 @@ import { $gl, - $context + $context, + $disableScissorTest } from "../../WebGLUtil"; /** @@ -31,5 +32,5 @@ export const execute = (): void => $gl.colorMask(true, true, true, true); $gl.disable($gl.SAMPLE_ALPHA_TO_COVERAGE); - $gl.disable($gl.SCISSOR_TEST); + $disableScissorTest(); }; \ No newline at end of file diff --git a/packages/webgl/src/Mask/service/MaskSetMaskBoundsService.test.ts b/packages/webgl/src/Mask/service/MaskSetMaskBoundsService.test.ts index 84afdaa8..0d53fb9b 100644 --- a/packages/webgl/src/Mask/service/MaskSetMaskBoundsService.test.ts +++ b/packages/webgl/src/Mask/service/MaskSetMaskBoundsService.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { IAttachmentObject } from "../../interface/IAttachmentObject.ts"; import { $setCurrentAttachment, - $getCurrentAttachment + $currentAttachment } from "../../FrameBufferManager.ts"; import { $clipBounds } from "../../Mask"; @@ -37,9 +37,10 @@ describe("MaskSetMaskBoundsService.js method test", () => }), "SCISSOR_TEST": "SCISSOR_TEST", }, + $enableScissorTest: vi.fn(), "$context": { get currentAttachmentObject() { - return $getCurrentAttachment(); + return $currentAttachment; } } } diff --git a/packages/webgl/src/Mask/service/MaskSetMaskBoundsService.ts b/packages/webgl/src/Mask/service/MaskSetMaskBoundsService.ts index b32fb5dc..dcc6a4c6 100644 --- a/packages/webgl/src/Mask/service/MaskSetMaskBoundsService.ts +++ b/packages/webgl/src/Mask/service/MaskSetMaskBoundsService.ts @@ -2,7 +2,8 @@ import { $clipBounds } from "../../Mask"; import { $gl, $context, - $getFloat32Array4 + $getFloat32Array4, + $enableScissorTest } from "../../WebGLUtil"; /** @@ -37,7 +38,7 @@ export const execute = ( const width = Math.ceil(Math.abs(x_max - x_min)); const height = Math.ceil(Math.abs(y_max - y_min)); - $gl.enable($gl.SCISSOR_TEST); + $enableScissorTest(); $gl.scissor( x_min, currentAttachmentObject.height - y_min - height, diff --git a/packages/webgl/src/Mask/service/MaskUnionMaskService.test.ts b/packages/webgl/src/Mask/service/MaskUnionMaskService.test.ts index 5433b806..99470187 100644 --- a/packages/webgl/src/Mask/service/MaskUnionMaskService.test.ts +++ b/packages/webgl/src/Mask/service/MaskUnionMaskService.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { IAttachmentObject } from "../../interface/IAttachmentObject.ts"; import { $setCurrentAttachment, - $getCurrentAttachment + $currentAttachment } from "../../FrameBufferManager.ts"; describe("MaskUnionMaskService.js method test", () => @@ -139,7 +139,7 @@ describe("MaskUnionMaskService.js method test", () => }, "$context": { get currentAttachmentObject() { - return $getCurrentAttachment(); + return $currentAttachment; } } } diff --git a/packages/webgl/src/Mask/usecase/MaskBindUseCase.test.ts b/packages/webgl/src/Mask/usecase/MaskBindUseCase.test.ts index e49eaff2..8052c815 100644 --- a/packages/webgl/src/Mask/usecase/MaskBindUseCase.test.ts +++ b/packages/webgl/src/Mask/usecase/MaskBindUseCase.test.ts @@ -15,12 +15,11 @@ describe("MaskBindUseCase.js method test", () => return { ...mod, "$gl": { - "enable": vi.fn((cap) => - { - expect(cap).toBe("STENCIL_TEST"); - }), + "enable": vi.fn(), "STENCIL_TEST": "STENCIL_TEST", }, + $enableStencilTest: vi.fn(), + $disableStencilTest: vi.fn(), "$context": { get currentAttachmentObject() { return null; @@ -45,20 +44,14 @@ describe("MaskBindUseCase.js method test", () => return { ...mod, "$gl": { - "disable": vi.fn((cap) => - { - switch (cap) { - case "STENCIL_TEST": - case "SCISSOR_TEST": - return; - default: - throw new Error("Invalid cap"); - } - }), + "disable": vi.fn(), "enable": vi.fn(), "STENCIL_TEST": "STENCIL_TEST", "SCISSOR_TEST": "SCISSOR_TEST", }, + $enableStencilTest: vi.fn(), + $disableStencilTest: vi.fn(), + $disableScissorTest: vi.fn(), "$context": { get currentAttachmentObject() { return null; diff --git a/packages/webgl/src/Mask/usecase/MaskBindUseCase.ts b/packages/webgl/src/Mask/usecase/MaskBindUseCase.ts index 623cb397..d64ba4d3 100644 --- a/packages/webgl/src/Mask/usecase/MaskBindUseCase.ts +++ b/packages/webgl/src/Mask/usecase/MaskBindUseCase.ts @@ -1,6 +1,10 @@ import { execute as maskEndMaskService } from "../service/MaskEndMaskService"; -import { $gl } from "../../WebGLUtil"; +import { + $enableStencilTest, + $disableStencilTest, + $disableScissorTest +} from "../../WebGLUtil"; import { $isMaskDrawing, $setMaskDrawing @@ -20,13 +24,13 @@ export const execute = (mask: boolean): void => if (!mask && $isMaskDrawing()) { $setMaskDrawing(false); - $gl.disable($gl.STENCIL_TEST); - $gl.disable($gl.SCISSOR_TEST); + $disableStencilTest(); + $disableScissorTest(); } else if (mask && !$isMaskDrawing()) { $setMaskDrawing(true); // キャッシュ作成後は、マスクの状態を復元する - $gl.enable($gl.STENCIL_TEST); + $enableStencilTest(); maskEndMaskService(); } }; \ No newline at end of file diff --git a/packages/webgl/src/Mask/usecase/MaskLeaveMaskUseCase.test.ts b/packages/webgl/src/Mask/usecase/MaskLeaveMaskUseCase.test.ts index 58dad1f7..3ef9b4d1 100644 --- a/packages/webgl/src/Mask/usecase/MaskLeaveMaskUseCase.test.ts +++ b/packages/webgl/src/Mask/usecase/MaskLeaveMaskUseCase.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { IAttachmentObject } from "../../interface/IAttachmentObject.ts"; import { $setCurrentAttachment, - $getCurrentAttachment + $currentAttachment } from "../../FrameBufferManager.ts"; import { $setMaskDrawing, @@ -102,9 +102,12 @@ describe("MaskLeaveMaskUseCase.js method test", () => "SCISSOR_TEST": "SCISSOR_TEST", "STENCIL_BUFFER_BIT": "STENCIL_BUFFER_BIT", }, + $enableScissorTest: vi.fn(), + $disableScissorTest: vi.fn(), + $disableStencilTest: vi.fn(), "$context": { get currentAttachmentObject() { - return $getCurrentAttachment(); + return $currentAttachment; } } } @@ -266,9 +269,12 @@ describe("MaskLeaveMaskUseCase.js method test", () => "SCISSOR_TEST": "SCISSOR_TEST", "STENCIL_BUFFER_BIT": "STENCIL_BUFFER_BIT", }, + $enableScissorTest: vi.fn(), + $disableScissorTest: vi.fn(), + $disableStencilTest: vi.fn(), "$context": { get currentAttachmentObject() { - return $getCurrentAttachment(); + return $currentAttachment; } } } diff --git a/packages/webgl/src/Mask/usecase/MaskLeaveMaskUseCase.ts b/packages/webgl/src/Mask/usecase/MaskLeaveMaskUseCase.ts index 28a50ff1..db2f231a 100644 --- a/packages/webgl/src/Mask/usecase/MaskLeaveMaskUseCase.ts +++ b/packages/webgl/src/Mask/usecase/MaskLeaveMaskUseCase.ts @@ -10,7 +10,10 @@ import { import { $gl, $context, - $poolFloat32Array4 + $poolFloat32Array4, + $enableScissorTest, + $disableScissorTest, + $disableStencilTest } from "../../WebGLUtil"; /** @@ -42,7 +45,7 @@ export const execute = (): void => const width = Math.ceil(Math.abs(xMax - xMin)); const height = Math.ceil(Math.abs(yMax - yMin)); - $gl.enable($gl.SCISSOR_TEST); + $enableScissorTest(); $gl.scissor( xMin, currentAttachmentObject.height - yMin - height, @@ -57,8 +60,8 @@ export const execute = (): void => $setMaskDrawing(false); $gl.clear($gl.STENCIL_BUFFER_BIT); - $gl.disable($gl.STENCIL_TEST); - $gl.disable($gl.SCISSOR_TEST); + $disableStencilTest(); + $disableScissorTest(); $clipLevels.clear(); $clipBounds.clear(); diff --git a/packages/webgl/src/Mesh.ts b/packages/webgl/src/Mesh.ts index 3b3dd89d..07629b0f 100644 --- a/packages/webgl/src/Mesh.ts +++ b/packages/webgl/src/Mesh.ts @@ -117,4 +117,30 @@ export const $clearFillBufferSetting = (): void => // fill $fillBufferOffset = 0; $fillBufferIndexes.length = 0; +}; + +/** + * @description メッシュ生成用の再利用可能な一時バッファ(GC回避) + * Reusable temporary buffer for mesh generation (avoid GC) + * + * @type {Float32Array} + * @private + */ +let $meshTempBuffer: Float32Array = new Float32Array(32); + +/** + * @description 必要なサイズの一時バッファを取得(必要に応じて拡張) + * Get temporary buffer of required size (expand if necessary) + * + * @param {number} size + * @return {Float32Array} + * @method + * @protected + */ +export const $getMeshTempBuffer = (size: number): Float32Array => +{ + if ($meshTempBuffer.length < size) { + $meshTempBuffer = new Float32Array($upperPowerOfTwo(size)); + } + return $meshTempBuffer; }; \ No newline at end of file diff --git a/packages/webgl/src/Mesh/service/MeshFindOverlappingPathsService.test.ts b/packages/webgl/src/Mesh/service/MeshFindOverlappingPathsService.test.ts index 9d292b9c..18a9886a 100644 --- a/packages/webgl/src/Mesh/service/MeshFindOverlappingPathsService.test.ts +++ b/packages/webgl/src/Mesh/service/MeshFindOverlappingPathsService.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; describe("MeshFindOverlappingPathsService.js method test", () => { - it("test case", () => + it("test case - returns points at exact distance r", () => { const paths = [ 0, 0, false, @@ -21,7 +21,23 @@ describe("MeshFindOverlappingPathsService.js method test", () => 120, 0, false, 130, 0, false ]; + // Only returns point (0,0) which is exactly at distance 0 from center (0,0) const points = execute(0, 0, 0, paths); expect(points.length).toBe(2); + expect(points[0]).toBe(0); + expect(points[1]).toBe(0); + }); + + it("test case - returns points at specified radius", () => + { + const paths = [ + 5, 0, false, // distance 5 from origin + -5, 0, false, // distance 5 from origin + 0, 5, false, // distance 5 from origin + 10, 0, false, // distance 10 from origin + ]; + // Returns points at distance 5 (within epsilon 0.01) + const points = execute(0, 0, 5, paths); + expect(points.length).toBe(6); // 3 points * 2 (x,y) }); }); \ No newline at end of file diff --git a/packages/webgl/src/Mesh/service/MeshFindOverlappingPathsService.ts b/packages/webgl/src/Mesh/service/MeshFindOverlappingPathsService.ts index 79f57c0e..92965cc5 100644 --- a/packages/webgl/src/Mesh/service/MeshFindOverlappingPathsService.ts +++ b/packages/webgl/src/Mesh/service/MeshFindOverlappingPathsService.ts @@ -2,10 +2,11 @@ import type { IPath } from "../../interface/IPath"; /** * @description メッシュのパスの中で指定座標が含まれる線を探す - * Find lines in the path of the mesh that contain the specified coordinates + * Find the lines containing the specified coordinates in the mesh path * * @param {number} x * @param {number} y + * @param {number} r * @param {IPath} paths * @return {number[]} * @method @@ -19,21 +20,25 @@ export const execute = ( ): number[] => { const points: number[] = []; + // 浮動小数点誤差を考慮した許容範囲 + const epsilon = 0.0001; + for (let idx = 0; idx < paths.length; idx += 3) { - // カーブのコントロール座標なら終了 + // カーブのコントロール座標ならスキップ if (paths[idx + 2] as boolean) { continue; } - const dx = paths[idx ] as number; + const dx = paths[idx] as number; const dy = paths[idx + 1] as number; const distance = Math.sqrt( Math.pow(dx - x, 2) + Math.pow(dy - y, 2) ); - if (distance !== r) { + // 浮動小数点誤差を考慮した比較 + if (Math.abs(distance - r) > epsilon) { continue; } diff --git a/packages/webgl/src/Mesh/service/MeshIsPointInsideRectangleService.test.ts b/packages/webgl/src/Mesh/service/MeshIsPointInsideRectangleService.test.ts new file mode 100644 index 00000000..984b82f0 --- /dev/null +++ b/packages/webgl/src/Mesh/service/MeshIsPointInsideRectangleService.test.ts @@ -0,0 +1,76 @@ +import { execute } from "./MeshIsPointInsideRectangleService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { IPath } from "../../interface/IPath"; + +describe("MeshIsPointInsideRectangleService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should return array or null", () => + { + // Simple rectangle path + const rectangle: IPath = [ + 0, 0, false, + 100, 0, false, + 100, 100, false, + 0, 100, false + ]; + + const points = [50, 50]; + const result = execute(points, rectangle); + + // Result should be either null or an array of [x, y] + expect(result === null || (Array.isArray(result) && result.length === 2)).toBe(true); + }); + + it("test case - should handle empty points array", () => + { + const rectangle: IPath = [ + 0, 0, false, + 100, 0, false, + 100, 100, false, + 0, 100, false + ]; + + const points: number[] = []; + const result = execute(points, rectangle); + + expect(result).toBeNull(); + }); + + it("test case - should process multiple points", () => + { + const rectangle: IPath = [ + 0, 0, false, + 100, 0, false, + 100, 100, false, + 0, 100, false + ]; + + const points = [10, 10, 20, 20, 30, 30]; + const result = execute(points, rectangle); + + // Result should be either null or [x, y] array + expect(result === null || (Array.isArray(result) && result.length === 2)).toBe(true); + }); + + it("test case - should handle rectangle with quadratic curves", () => + { + // Rectangle with a curve segment + const rectangle: IPath = [ + 0, 0, false, + 50, 0, true, // control point for curve + 100, 0, false, + 100, 100, false, + 0, 100, false + ]; + + const points = [50, 50]; + const result = execute(points, rectangle); + + // Should not throw and return valid result + expect(result === null || Array.isArray(result)).toBe(true); + }); +}); diff --git a/packages/webgl/src/Mesh/service/MeshIsPointInsideRectangleService.ts b/packages/webgl/src/Mesh/service/MeshIsPointInsideRectangleService.ts index b238fdfd..be836689 100644 --- a/packages/webgl/src/Mesh/service/MeshIsPointInsideRectangleService.ts +++ b/packages/webgl/src/Mesh/service/MeshIsPointInsideRectangleService.ts @@ -1,7 +1,10 @@ import type { IPath } from "../../interface/IPath"; -const canvas = new OffscreenCanvas(1, 1); -const $context = canvas.getContext("2d") as OffscreenCanvasRenderingContext2D; +/** + * @description Canvas 2Dコンテキスト(点が矩形内にあるか判定用) + */ +const $canvas = new OffscreenCanvas(1, 1); +const $canvasContext = $canvas.getContext("2d") as OffscreenCanvasRenderingContext2D; /** * @description 矩形内に含まれてない座標を返却 @@ -18,42 +21,41 @@ export const execute = ( rectangle: IPath ): number[] | null => { - $context.beginPath(); - $context.moveTo( + $canvasContext.beginPath(); + $canvasContext.moveTo( rectangle[0] as number, rectangle[1] as number ); for (let idx = 3; idx < rectangle.length; idx += 3) { if (rectangle[idx + 2] as boolean) { - $context.quadraticCurveTo( - rectangle[idx ] as number, + $canvasContext.quadraticCurveTo( + rectangle[idx] as number, rectangle[idx + 1] as number, rectangle[idx + 3] as number, rectangle[idx + 4] as number ); idx += 3; } else { - $context.lineTo( - rectangle[idx ] as number, + $canvasContext.lineTo( + rectangle[idx] as number, rectangle[idx + 1] as number ); } } - $context.closePath(); + $canvasContext.closePath(); for (let idx = 0; idx < points.length; idx += 2) { + const px = points[idx]; + const py = points[idx + 1]; - const x = points[idx]; - const y = points[idx + 1]; - - if ($context.isPointInPath(x, y)) { + if ($canvasContext.isPointInPath(px, py)) { continue; } - return [x, y]; + return [px, py]; } return null; -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/Mesh/usecase/MeshFillGenerateUseCase.test.ts b/packages/webgl/src/Mesh/usecase/MeshFillGenerateUseCase.test.ts index 6430fb56..2dec1423 100644 --- a/packages/webgl/src/Mesh/usecase/MeshFillGenerateUseCase.test.ts +++ b/packages/webgl/src/Mesh/usecase/MeshFillGenerateUseCase.test.ts @@ -1,11 +1,15 @@ import { execute } from "./MeshFillGenerateUseCase"; import { describe, expect, it, vi } from "vitest"; +vi.mock("../../Mesh.ts", () => ({ + "$getMeshTempBuffer": (size: number) => new Float32Array(size) +})); + describe("MeshFillGenerateUseCase.js method test", () => { it("test case", async () => { - vi.mock("../../WebGLUtil.ts", async (importOriginal) => + vi.mock("../../WebGLUtil.ts", async (importOriginal) => { const mod = await importOriginal(); return { diff --git a/packages/webgl/src/Mesh/usecase/MeshFillGenerateUseCase.ts b/packages/webgl/src/Mesh/usecase/MeshFillGenerateUseCase.ts index 74c80ce5..d225c7ad 100644 --- a/packages/webgl/src/Mesh/usecase/MeshFillGenerateUseCase.ts +++ b/packages/webgl/src/Mesh/usecase/MeshFillGenerateUseCase.ts @@ -1,10 +1,11 @@ import type { IPath } from "../../interface/IPath"; import type { IFillMesh } from "../../interface/IFillMesh"; import { execute as meshFillGenerateService } from "../service/MeshFillGenerateService"; +import { $getMeshTempBuffer } from "../../Mesh"; import { $context, - $getViewportWidth, - $getViewportHeight + $viewportWidth, + $viewportHeight } from "../../WebGLUtil"; /** @@ -31,8 +32,8 @@ export const execute = ( const alpha = colorStyle[3]; const matrix = $context.$matrix; - const width = $getViewportWidth(); - const height = $getViewportHeight(); + const width = $viewportWidth; + const height = $viewportHeight; const a = matrix[0] / width; const c = matrix[3] / width; @@ -46,7 +47,8 @@ export const execute = ( length += (vertices[idx].length / 3 - 2) * 51; } - const buffer = new Float32Array(length); + // 再利用可能なバッファを取得(GC回避) + const buffer = $getMeshTempBuffer(length); let currentIndex = 0; for (let idx = 0; idx < vertices.length; ++idx) { @@ -58,7 +60,7 @@ export const execute = ( } return { - "buffer": buffer, + "buffer": buffer.subarray(0, length), "indexCount": currentIndex }; }; \ No newline at end of file diff --git a/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateBevelJoinUseCase.test.ts b/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateBevelJoinUseCase.test.ts index 5c6d5e26..545689dd 100644 --- a/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateBevelJoinUseCase.test.ts +++ b/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateBevelJoinUseCase.test.ts @@ -39,4 +39,46 @@ describe("MeshGenerateCalculateBevelJoinUseCase.js method test", () => execute(20, 10, 5, rectangles); expect(rectangles.length).toBe(initialLength); }); + + it("test case - curve-to-curve join should be skipped", () => + { + // 曲線矩形は15要素より長い(曲線の制御点を含む) + // 曲線同士の接合ではjoinジオメトリを追加しない + const curveRectangle1 = [ + 0, 0, false, 5, 0, true, 10, 5, false, 10, 10, false, + 5, 10, false, 0, 5, false, 0, 0, false + ]; // 21 elements (> 15) + const curveRectangle2 = [ + 10, 10, false, 15, 10, true, 20, 15, false, 20, 20, false, + 15, 20, false, 10, 15, false, 10, 10, false + ]; // 21 elements (> 15) + + const rectangles = [curveRectangle1, curveRectangle2]; + const initialLength = rectangles.length; + + execute(10, 10, 5, rectangles); + + // 曲線同士の接合なのでjoinは追加されない + expect(rectangles.length).toBe(initialLength); + }); + + it("test case - line-to-curve join should NOT be skipped", () => + { + // 線矩形(15要素)と曲線矩形の接合 + const lineRectangle = [ + 0, 0, false, 10, 0, false, 10, 5, false, 0, 5, false, 0, 0, false + ]; // 15 elements + const curveRectangle = [ + 10, 0, false, 15, 0, true, 20, 5, false, 20, 10, false, + 15, 10, false, 10, 5, false, 10, 0, false + ]; // 21 elements (> 15) + + const rectangles = [lineRectangle, curveRectangle]; + const initialLength = rectangles.length; + + execute(10, 0, 5, rectangles); + + // 線と曲線の接合は処理される(結果はジオメトリに依存) + expect(rectangles.length).toBeGreaterThanOrEqual(initialLength); + }); }); diff --git a/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateBevelJoinUseCase.ts b/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateBevelJoinUseCase.ts index 8c0ca083..ac47724f 100644 --- a/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateBevelJoinUseCase.ts +++ b/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateBevelJoinUseCase.ts @@ -1,5 +1,5 @@ import type { IPath } from "../../interface/IPath"; -import { execute as meshFindOverlappingPathsUseCase } from "../service/MeshFindOverlappingPathsService"; +import { execute as meshFindOverlappingPathsService } from "../service/MeshFindOverlappingPathsService"; import { execute as meshIsPointInsideRectangleService } from "../service/MeshIsPointInsideRectangleService"; /** @@ -25,36 +25,44 @@ export const execute = ( const indexA = is_last ? 0 : rectangles.length - 1; const indexB = is_last ? rectangles.length - 1 : rectangles.length - 2; - const pathsA = meshFindOverlappingPathsUseCase(x, y, r, rectangles[indexA]); - const pathsB = meshFindOverlappingPathsUseCase(x, y, r, rectangles[indexB]); + + // 曲線矩形同士の接合ではjoinを追加しない + // 曲線は滑らかに接続されているため、joinジオメトリは不要 + const isRectACurve = rectangles[indexA].length > 15; + const isRectBCurve = rectangles[indexB].length > 15; + if (isRectACurve && isRectBCurve) { + return; + } + + const pathsA = meshFindOverlappingPathsService(x, y, r, rectangles[indexA]); + const pathsB = meshFindOverlappingPathsService(x, y, r, rectangles[indexB]); // パスが並行であれば終了 if (pathsA[0] === pathsB[0] && pathsA[1] === pathsB[1] || pathsA[0] === pathsB[2] && pathsA[1] === pathsB[3] ) { - return ; + return; } - const pointA = meshIsPointInsideRectangleService( - pathsA, rectangles[indexB] - ); - + // 矩形Aの点が矩形Bの外にあるか確認 + const pointA = meshIsPointInsideRectangleService(pathsA, rectangles[indexB]); if (!pointA) { - return ; + // 全ての点が矩形B内にある = 隙間がない + return; } - const pointB = meshIsPointInsideRectangleService( - pathsB, rectangles[indexA] - ); - + // 矩形Bの点が矩形Aの外にあるか確認 + const pointB = meshIsPointInsideRectangleService(pathsB, rectangles[indexA]); if (!pointB) { - return ; + // 全ての点が矩形A内にある = 隙間がない + return; } + // 隙間がある場合のみbevel joinを追加 rectangles.splice(-1, 0, [ x, y, false, pointA[0], pointA[1], false, pointB[0], pointB[1], false, x, y, false ]); -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateMiterJoinUseCase.test.ts b/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateMiterJoinUseCase.test.ts index f594ea83..bd20c98b 100644 --- a/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateMiterJoinUseCase.test.ts +++ b/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateMiterJoinUseCase.test.ts @@ -51,4 +51,54 @@ describe("MeshGenerateCalculateMiterJoinUseCase.js method test", () => execute(startPoint, endPoint, prevPoint, 5, rectangles); expect(rectangles.length).toBe(initialLength); }); + + it("test case - curve-to-curve join should be skipped", () => + { + // 曲線矩形は15要素より長い(曲線の制御点を含む) + // 曲線同士の接合ではjoinジオメトリを追加しない + const curveRectangle1 = [ + 0, 0, false, 5, 0, true, 10, 5, false, 10, 10, false, + 5, 10, false, 0, 5, false, 0, 0, false + ]; // 21 elements (> 15) + const curveRectangle2 = [ + 10, 10, false, 15, 10, true, 20, 15, false, 20, 20, false, + 15, 20, false, 10, 15, false, 10, 10, false + ]; // 21 elements (> 15) + + const rectangles = [curveRectangle1, curveRectangle2]; + const initialLength = rectangles.length; + + const startPoint = { x: 10, y: 10 }; + const endPoint = { x: 20, y: 20 }; + const prevPoint = { x: 0, y: 0 }; + + execute(startPoint, endPoint, prevPoint, 5, rectangles); + + // 曲線同士の接合なのでjoinは追加されない + expect(rectangles.length).toBe(initialLength); + }); + + it("test case - line-to-curve join should NOT be skipped", () => + { + // 線矩形(15要素)と曲線矩形の接合 + const lineRectangle = [ + 0, 0, false, 10, 0, false, 10, 5, false, 0, 5, false, 0, 0, false + ]; // 15 elements + const curveRectangle = [ + 10, 0, false, 15, 0, true, 20, 5, false, 20, 10, false, + 15, 10, false, 10, 5, false, 10, 0, false + ]; // 21 elements (> 15) + + const rectangles = [lineRectangle, curveRectangle]; + const initialLength = rectangles.length; + + const startPoint = { x: 10, y: 0 }; + const endPoint = { x: 20, y: 10 }; + const prevPoint = { x: 0, y: 0 }; + + execute(startPoint, endPoint, prevPoint, 5, rectangles); + + // 線と曲線の接合は処理される(結果はジオメトリに依存) + expect(rectangles.length).toBeGreaterThanOrEqual(initialLength); + }); }); diff --git a/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateMiterJoinUseCase.ts b/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateMiterJoinUseCase.ts index 812b707b..cd94435b 100644 --- a/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateMiterJoinUseCase.ts +++ b/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateMiterJoinUseCase.ts @@ -1,6 +1,6 @@ import type { IPath } from "../../interface/IPath"; import type { IPoint } from "../../interface/IPoint"; -import { execute as meshFindOverlappingPathsUseCase } from "../service/MeshFindOverlappingPathsService"; +import { execute as meshFindOverlappingPathsService } from "../service/MeshFindOverlappingPathsService"; import { execute as meshIsPointInsideRectangleService } from "../service/MeshIsPointInsideRectangleService"; /** @@ -28,32 +28,40 @@ export const execute = ( const indexA = is_last ? 0 : rectangles.length - 1; const indexB = is_last ? rectangles.length - 1 : rectangles.length - 2; - const pathsA = meshFindOverlappingPathsUseCase(start_point.x, start_point.y, r, rectangles[indexA]); - const pathsB = meshFindOverlappingPathsUseCase(start_point.x, start_point.y, r, rectangles[indexB]); + + // 曲線矩形同士の接合ではjoinを追加しない + // 曲線は滑らかに接続されているため、joinジオメトリは不要 + const isRectACurve = rectangles[indexA].length > 15; + const isRectBCurve = rectangles[indexB].length > 15; + if (isRectACurve && isRectBCurve) { + return; + } + + const pathsA = meshFindOverlappingPathsService(start_point.x, start_point.y, r, rectangles[indexA]); + const pathsB = meshFindOverlappingPathsService(start_point.x, start_point.y, r, rectangles[indexB]); // パスが並行であれば終了 if (pathsA[0] === pathsB[0] && pathsA[1] === pathsB[1] || pathsA[0] === pathsB[2] && pathsA[1] === pathsB[3] ) { - return ; + return; } - const pointA = meshIsPointInsideRectangleService( - pathsA, rectangles[indexB] - ); - + // 矩形Aの点が矩形Bの外にあるか確認 + const pointA = meshIsPointInsideRectangleService(pathsA, rectangles[indexB]); if (!pointA) { - return ; + // 全ての点が矩形B内にある = 隙間がない + return; } - const pointB = meshIsPointInsideRectangleService( - pathsB, rectangles[indexA] - ); - + // 矩形Bの点が矩形Aの外にあるか確認 + const pointB = meshIsPointInsideRectangleService(pathsB, rectangles[indexA]); if (!pointB) { - return ; + // 全ての点が矩形A内にある = 隙間がない + return; } + // Miter join: 外側の点を延長して交点を求める const aVx = end_point.x - start_point.x; const aVy = end_point.y - start_point.y; const lengthA = Math.hypot(aVx, aVy); @@ -75,11 +83,13 @@ export const execute = ( const denom = d1x * d2y - d1y * d2x; if (denom === 0) { + // 平行な場合は単純な三角形で接続 rectangles.splice(-1, 0, [ start_point.x, start_point.y, false, pointA[0], pointA[1], false, pointB[0], pointB[1], false ]); + return; } const t = ((pointB[0] - pointA[0]) * d2y - (pointB[1] - pointA[1]) * d2x) / denom; @@ -95,4 +105,4 @@ export const execute = ( pointB[0], pointB[1], false, ix, iy, false ]); -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateRoundJoinUseCase.test.ts b/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateRoundJoinUseCase.test.ts index 355e8c8c..2f602c61 100644 --- a/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateRoundJoinUseCase.test.ts +++ b/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateRoundJoinUseCase.test.ts @@ -3,15 +3,83 @@ import { describe, expect, it } from "vitest"; describe("MeshGenerateCalculateRoundJoinUseCase.js method test", () => { - it("test case - basic round join returns without error when points not inside", () => + it("test case - round join adds geometry when points are found outside other rectangle", () => { + // Two rectangles meeting at point (50, 50) with stroke width 5 + // Rectangle 1: horizontal line going right from (50, 50) + // Rectangle 2: vertical line going down from (50, 50) + const rectangles = [ + // Rect 1: from (50,50) going right, with perpendicular offset of 5 + [50, 45, false, 100, 45, false, 100, 55, false, 50, 55, false, 50, 45, false], + // Rect 2: from (50,50) going down, with perpendicular offset of 5 + [45, 50, false, 55, 50, false, 55, 100, false, 45, 100, false, 45, 50, false] + ]; + + const initialLength = rectangles.length; + execute(50, 50, 5, rectangles); + + // If points at distance 5 from (50,50) are found in both rectangles + // and at least one point from each rectangle is outside the other, + // join geometry will be added + // Note: With this configuration, the function may or may not add join geometry + // depending on whether points are found at exact distance r + expect(rectangles.length).toBeGreaterThanOrEqual(initialLength); + }); + + it("test case - round join skips when no points found at radius r", () => + { + // Two rectangles far from the center point const rectangles = [ [0, 0, false, 10, 0, false, 10, 5, false, 0, 5, false, 0, 0, false], [100, 100, false, 110, 100, false, 110, 105, false, 100, 105, false, 100, 100, false] ]; const initialLength = rectangles.length; + // Center at (50, 50) with radius 5 - no rectangle points are at this distance execute(50, 50, 5, rectangles); + // No join geometry added because no points found at distance 5 from center + expect(rectangles.length).toBe(initialLength); + }); + + it("test case - curve-to-curve join should be skipped", () => + { + // 曲線矩形は15要素より長い(曲線の制御点を含む) + // 曲線同士の接合ではjoinジオメトリを追加しない + const curveRectangle1 = [ + 0, 0, false, 5, 0, true, 10, 5, false, 10, 10, false, + 5, 10, false, 0, 5, false, 0, 0, false + ]; // 21 elements (> 15) + const curveRectangle2 = [ + 10, 10, false, 15, 10, true, 20, 15, false, 20, 20, false, + 15, 20, false, 10, 15, false, 10, 10, false + ]; // 21 elements (> 15) + + const rectangles = [curveRectangle1, curveRectangle2]; + const initialLength = rectangles.length; + + execute(10, 10, 5, rectangles); + + // 曲線同士の接合なのでjoinは追加されない expect(rectangles.length).toBe(initialLength); }); + + it("test case - line-to-curve join should NOT be skipped", () => + { + // 線矩形(15要素)と曲線矩形の接合 + const lineRectangle = [ + 0, 0, false, 10, 0, false, 10, 5, false, 0, 5, false, 0, 0, false + ]; // 15 elements + const curveRectangle = [ + 10, 0, false, 15, 0, true, 20, 5, false, 20, 10, false, + 15, 10, false, 10, 5, false, 10, 0, false + ]; // 21 elements (> 15) + + const rectangles = [lineRectangle, curveRectangle]; + const initialLength = rectangles.length; + + execute(10, 0, 5, rectangles); + + // 線と曲線の接合は処理される(結果はジオメトリに依存) + expect(rectangles.length).toBeGreaterThanOrEqual(initialLength); + }); }); diff --git a/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateRoundJoinUseCase.ts b/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateRoundJoinUseCase.ts index 8724f439..963cc9cc 100644 --- a/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateRoundJoinUseCase.ts +++ b/packages/webgl/src/Mesh/usecase/MeshGenerateCalculateRoundJoinUseCase.ts @@ -25,25 +25,26 @@ export const execute = ( const indexA = is_last ? 0 : rectangles.length - 1; const indexB = is_last ? rectangles.length - 1 : rectangles.length - 2; + + // 曲線矩形同士の接合ではjoinを追加しない + // 曲線は滑らかに接続されているため、joinジオメトリは不要 + const isRectACurve = rectangles[indexA].length > 15; + const isRectBCurve = rectangles[indexB].length > 15; + if (isRectACurve && isRectBCurve) { + return; + } + const pathsA = meshFindOverlappingPathsService(x, y, r, rectangles[indexA]); const pathsB = meshFindOverlappingPathsService(x, y, r, rectangles[indexB]); - const pointA = meshIsPointInsideRectangleService( - pathsA, rectangles[indexB] - ); - - // 接続点が矩形の内部にある場合は終了 + const pointA = meshIsPointInsideRectangleService(pathsA, rectangles[indexB]); if (!pointA) { - return ; + return; } - const pointB = meshIsPointInsideRectangleService( - pathsB, rectangles[indexA] - ); - - // 接続点が矩形の内部にある場合は終了 + const pointB = meshIsPointInsideRectangleService(pathsB, rectangles[indexA]); if (!pointB) { - return ; + return; } const angleA = Math.atan2(pointA[1] - y, pointA[0] - x); @@ -57,6 +58,14 @@ export const execute = ( angleDiff += 2 * Math.PI; } + // 角度差が小さい場合または180度に近い場合はスキップ + // 小さい角度差: 曲線の閉じたパスでは不要 + // 180度に近い場合: 外側と内側の点を間違えて選んでいる + const absAngleDiff = Math.abs(angleDiff); + if (absAngleDiff < 0.1 || absAngleDiff > Math.PI - 0.1) { + return; + } + const segment = 8; const step = angleDiff / segment; @@ -69,4 +78,4 @@ export const execute = ( } rectangles.splice(-1, 0, points); -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/Mesh/usecase/MeshGenerateStrokeOutlineUseCase.ts b/packages/webgl/src/Mesh/usecase/MeshGenerateStrokeOutlineUseCase.ts index d0d38e07..0a5f9aa2 100644 --- a/packages/webgl/src/Mesh/usecase/MeshGenerateStrokeOutlineUseCase.ts +++ b/packages/webgl/src/Mesh/usecase/MeshGenerateStrokeOutlineUseCase.ts @@ -9,6 +9,18 @@ import { execute as meshGenerateCalculateSquareCapService } from "../service/Mes import { execute as meshGenerateCalculateMiterJoinUseCase } from "../usecase/MeshGenerateCalculateMiterJoinUseCase"; import { $context } from "../../WebGLUtil"; +/** + * @description 再利用可能なPointオブジェクト(GC回避) + * Reusable Point objects (avoid GC) + * + * @type {IPoint} + * @private + */ +const $startPoint: IPoint = { "x": 0, "y": 0 }; +const $controlPoint: IPoint = { "x": 0, "y": 0 }; +const $endPoint: IPoint = { "x": 0, "y": 0 }; +const $prevPoint: IPoint = { "x": 0, "y": 0 }; + /** * @description 線の外周を算出して塗りのフォーマットで返却 * Calculate the outer circumference of the line and return it in the format of the fill @@ -21,25 +33,22 @@ import { $context } from "../../WebGLUtil"; */ export const execute = (vertices: IPath, thickness: number): IPath[] => { - const startPoint: IPoint = { - "x": vertices[0] as number, - "y": vertices[1] as number - }; - - const controlPoint: IPoint = { - "x": 0, - "y": 0 - }; - - const endPoint: IPoint = { - "x": 0, - "y": 0 - }; - - const prevPoint: IPoint = { - "x": 0, - "y": 0 - }; + // 再利用可能なオブジェクトを使用 + const startPoint = $startPoint; + startPoint.x = vertices[0] as number; + startPoint.y = vertices[1] as number; + + const controlPoint = $controlPoint; + controlPoint.x = 0; + controlPoint.y = 0; + + const endPoint = $endPoint; + endPoint.x = 0; + endPoint.y = 0; + + const prevPoint = $prevPoint; + prevPoint.x = 0; + prevPoint.y = 0; const rectangles: IPath[] = []; for (let idx = 3; idx < vertices.length; idx += 3) { @@ -96,22 +105,30 @@ export const execute = (vertices: IPath, thickness: number): IPath[] => startPoint.y = endPoint.y; } - if (vertices[0] === vertices[vertices.length - 3] - && vertices[1] === vertices[vertices.length - 2] - ) { + // 始点と終点が繋がっているかどうかをチェック(浮動小数点誤差を考慮) + const closedStartX = vertices[0] as number; + const closedStartY = vertices[1] as number; + const closedEndX = vertices[vertices.length - 3] as number; + const closedEndY = vertices[vertices.length - 2] as number; + const closedEpsilon = 0.0001; + const isClosed = Math.abs(closedStartX - closedEndX) < closedEpsilon + && Math.abs(closedStartY - closedEndY) < closedEpsilon + && rectangles.length > 1; + + if (isClosed) { // 始点と終点が繋がっている時はjointsの設定を適用 switch ($context.joints) { case 0: // bevel meshGenerateCalculateBevelJoinUseCase( - startPoint.x, startPoint.y, thickness, rectangles, true + closedStartX, closedStartY, thickness, rectangles, true ); break; case 1: // miter - startPoint.x = vertices[0] as number; - startPoint.y = vertices[1] as number; + startPoint.x = closedStartX; + startPoint.y = closedStartY; endPoint.x = vertices[3] as number; endPoint.y = vertices[4] as number; prevPoint.x = vertices[vertices.length - 6] as number; @@ -124,7 +141,7 @@ export const execute = (vertices: IPath, thickness: number): IPath[] => case 2: // round meshGenerateCalculateRoundJoinUseCase( - startPoint.x, startPoint.y, thickness, rectangles, true + closedStartX, closedStartY, thickness, rectangles, true ); break; @@ -143,7 +160,7 @@ export const execute = (vertices: IPath, thickness: number): IPath[] => ); break; - case 2: // square: + case 2: // square meshGenerateCalculateSquareCapService( vertices, thickness, rectangles ); diff --git a/packages/webgl/src/Mesh/usecase/MeshStrokeGenerateUseCase.test.ts b/packages/webgl/src/Mesh/usecase/MeshStrokeGenerateUseCase.test.ts index 66f7ddc4..edeb7aeb 100644 --- a/packages/webgl/src/Mesh/usecase/MeshStrokeGenerateUseCase.test.ts +++ b/packages/webgl/src/Mesh/usecase/MeshStrokeGenerateUseCase.test.ts @@ -10,8 +10,14 @@ vi.mock("../../WebGLUtil.ts", () => ({ "$strokeStyle": new Float32Array([0, 0, 0, 1]), "$matrix": new Float32Array([1, 0, 0, 1, 0, 0, 0, 0, 1]) }, - "$getViewportWidth": () => 1024, - "$getViewportHeight": () => 1024 + "$viewportWidth": 1024, + "$viewportHeight": 1024, + "$getArray": () => [], + "$poolArray": () => {} +})); + +vi.mock("../../Mesh.ts", () => ({ + "$getMeshTempBuffer": (size: number) => new Float32Array(size) })); describe("MeshStrokeGenerateUseCase.js method test", () => diff --git a/packages/webgl/src/Mesh/usecase/MeshStrokeGenerateUseCase.ts b/packages/webgl/src/Mesh/usecase/MeshStrokeGenerateUseCase.ts index 1ad6b7ee..7be6c932 100644 --- a/packages/webgl/src/Mesh/usecase/MeshStrokeGenerateUseCase.ts +++ b/packages/webgl/src/Mesh/usecase/MeshStrokeGenerateUseCase.ts @@ -2,7 +2,7 @@ import type { IFillMesh } from "../../interface/IFillMesh"; import type { IPath } from "../../interface/IPath"; import { execute as meshGenerateStrokeOutlineUseCase } from "./MeshGenerateStrokeOutlineUseCase"; import { execute as meshFillGenerateUseCase } from "./MeshFillGenerateUseCase"; -import { $context } from "../../WebGLUtil"; +import { $context, $getArray, $poolArray } from "../../WebGLUtil"; /** * @description ストロークのメッシュを生成 @@ -17,11 +17,21 @@ export const execute = (vertices: IPath[]): IFillMesh => { const thickness = $context.thickness / 2; - const fillVertices: IPath[] = []; + // プールから配列を取得して再利用 + const fillVertices: IPath[] = $getArray(); for (let idx = 0; idx < vertices.length; ++idx) { const vertex = meshGenerateStrokeOutlineUseCase(vertices[idx], thickness); - fillVertices.push(...vertex); + // スプレッド演算子の代わりにループで追加(より効率的) + for (let i = 0; i < vertex.length; ++i) { + fillVertices.push(vertex[i]); + } } - return meshFillGenerateUseCase(fillVertices, "stroke"); + const result = meshFillGenerateUseCase(fillVertices, "stroke"); + + // 配列をプールに戻す + fillVertices.length = 0; + $poolArray(fillVertices); + + return result; }; \ No newline at end of file diff --git a/packages/webgl/src/PathCommand.ts b/packages/webgl/src/PathCommand.ts index 2bc576b3..5a0c3d9f 100644 --- a/packages/webgl/src/PathCommand.ts +++ b/packages/webgl/src/PathCommand.ts @@ -30,15 +30,26 @@ export const $vertices: IPath[] = $getArray(); */ export const $getVertices = (stroke: boolean = false): IPath[] => { - const minVertices = stroke ? 4 : 10; - if ($currentPath.length < minVertices) { - $currentPath.length = 0; - } + // stroke: 最低2頂点(6要素)が必要、fill: 最低3頂点(9要素)が必要 + const minVertices = stroke ? 6 : 9; - if ($currentPath.length) { + // 現在のパスをverticesに追加 + if ($currentPath.length >= minVertices) { $vertices.push($currentPath.slice(0)); - $currentPath.length = 0; } + $currentPath.length = 0; + + // minVertices未満のパスを除外した配列を返す + const result: IPath[] = []; + for (let idx = 0; idx < $vertices.length; idx++) { + const path = $vertices[idx]; + if (path.length >= minVertices) { + result.push(path); + } + } + + // $verticesをクリア(beginPathで再利用される) + $vertices.length = 0; - return $vertices; + return result; }; \ No newline at end of file diff --git a/packages/webgl/src/PathCommand/service/PathCommandPushCurrentPathToVerticesService.ts b/packages/webgl/src/PathCommand/service/PathCommandPushCurrentPathToVerticesService.ts index ef05ad63..86d9a74d 100644 --- a/packages/webgl/src/PathCommand/service/PathCommandPushCurrentPathToVerticesService.ts +++ b/packages/webgl/src/PathCommand/service/PathCommandPushCurrentPathToVerticesService.ts @@ -7,15 +7,14 @@ import { * @description 現在操作中のパス配列を全てverticesに統合します * Integrate all path arrays currently being operated into vertices * - * @param {boolean} [stroke=false] * @return {void} * @method * @protected */ -export const execute = (stroke: boolean = false): void => +export const execute = (): void => { - const minVertices = stroke ? 4 : 10; - if ($currentPath.length < minVertices) { + // 最低1頂点(3要素)が必要(フィルタリングは$getVerticesで行う) + if ($currentPath.length < 3) { $currentPath.length = 0; return ; } diff --git a/packages/webgl/src/PathCommand/usecase/PathCommandArcUseCase.test.ts b/packages/webgl/src/PathCommand/usecase/PathCommandArcUseCase.test.ts index 56ce5eb1..07e81e20 100644 --- a/packages/webgl/src/PathCommand/usecase/PathCommandArcUseCase.test.ts +++ b/packages/webgl/src/PathCommand/usecase/PathCommandArcUseCase.test.ts @@ -7,69 +7,66 @@ import { describe("PathCommandArcUseCase.js method test", () => { - it("test case", () => + it("test case - arc generates path with adaptive tessellation", () => { $currentPath.length = 0; $vertices.length = 0; - + expect($currentPath.length).toBe(0); expect($vertices.length).toBe(0); + // execute(cx, cy, radius) - 円を描画 + // Arc at center (10,10) with radius 20 + // 4つのベジエ曲線で完全な円を描画: + // 1. (x+r, y) → (x, y+r) 右から上 + // 2. (x, y+r) → (x-r, y) 上から左 + // 3. (x-r, y) → (x, y-r) 左から下 + // 4. (x, y-r) → (x+r, y) 下から右 + // 最終点は開始点と同じ (cx+r, cy) = (30, 10) execute(10, 10, 20); - expect($currentPath.length).toBe(195); + // 適応的テッセレーションにより、セグメント数は曲率に応じて変化 + // Adaptive tessellation varies segment count based on curvature + // 円弧は曲率が高いため、多くのセグメントが生成される + expect($currentPath.length).toBeGreaterThan(0); expect($vertices.length).toBe(0); + // 始点の確認 (moveTo(0,0) がない場合、自動的に (0,0) から開始) + // Start point verification (auto-starts from (0,0) if no moveTo) expect($currentPath[0]).toBe(0); expect($currentPath[1]).toBe(0); expect($currentPath[2]).toBe(false); - expect($currentPath[3]).toBe(5.625); - expect($currentPath[4]).toBe(3.9460678100585938); - expect($currentPath[5]).toBe(true); - expect($currentPath[6]).toBe(9.496014595031738); - expect($currentPath[7]).toBe(7.331478595733643); - expect($currentPath[8]).toBe(false); - expect($currentPath[9]).toBe(13.367029190063477); - expect($currentPath[10]).toBe(10.716890335083008); - expect($currentPath[11]).toBe(true); - expect($currentPath[12]).toBe(15.772050857543945); - expect($currentPath[13]).toBe(13.566152572631836); - expect($currentPath[14]).toBe(false); - expect($currentPath[15]).toBe(18.17707061767578); - expect($currentPath[16]).toBe(16.415414810180664); - expect($currentPath[17]).toBe(true); - expect($currentPath[18]).toBe(19.260093688964844); - expect($currentPath[19]).toBe(18.74078369140625); - expect($currentPath[20]).toBe(false); - expect($currentPath[21]).toBe(20.343116760253906); - expect($currentPath[22]).toBe(21.06615447998047); - expect($currentPath[23]).toBe(true); - expect($currentPath[24]).toBe(20.392135620117188); - expect($currentPath[25]).toBe(22.892135620117188); - expect($currentPath[26]).toBe(false); - expect($currentPath[27]).toBe(20.44115447998047); - expect($currentPath[28]).toBe(24.718116760253906); - expect($currentPath[29]).toBe(true); - expect($currentPath[30]).toBe(19.60015869140625); - expect($currentPath[31]).toBe(26.056968688964844); - expect($currentPath[32]).toBe(false); - expect($currentPath[33]).toBe(18.759164810180664); - expect($currentPath[34]).toBe(27.39582061767578); - expect($currentPath[35]).toBe(true); - expect($currentPath[36]).toBe(17.316152572631836); - expect($currentPath[37]).toBe(28.272050857543945); - expect($currentPath[38]).toBe(false); - expect($currentPath[39]).toBe(15.873140335083008); - expect($currentPath[40]).toBe(29.148279190063477); - expect($currentPath[41]).toBe(true); - expect($currentPath[42]).toBe(13.972103118896484); - expect($currentPath[43]).toBe(29.574138641357422); - expect($currentPath[44]).toBe(false); - expect($currentPath[45]).toBe(12.071067810058594); - expect($currentPath[46]).toBe(30); - expect($currentPath[47]).toBe(true); - expect($currentPath[48]).toBe(10); - expect($currentPath[49]).toBe(30); - expect($currentPath[50]).toBe(false); + + // 終点の確認(円の右端に戻る: cx + radius, cy = 30, 10) + // End point verification (returns to right edge: cx + radius, cy = 30, 10) + const lastX = $currentPath[$currentPath.length - 3]; + const lastY = $currentPath[$currentPath.length - 2]; + const lastFlag = $currentPath[$currentPath.length - 1]; + + expect(lastX).toBe(30); // cx + radius = 10 + 20 + expect(lastY).toBe(10); // cy + expect(lastFlag).toBe(false); + }); + + it("test case - full circle arc", () => + { + $currentPath.length = 0; + $vertices.length = 0; + + // 小さい円は曲率が高いので、より多くのセグメントが必要 + // Small circle has higher curvature, requiring more segments + execute(50, 50, 10); + + // パスが生成されたことを確認 + expect($currentPath.length).toBeGreaterThan(0); + + // 最終点が円の右端に戻ることを確認 (cx + radius, cy) + const lastX = $currentPath[$currentPath.length - 3]; + const lastY = $currentPath[$currentPath.length - 2]; + + // lastX should be 60 (cx + radius = 50 + 10) + // lastY should be 50 (cy) + expect(lastX).toBe(60); + expect(lastY).toBe(50); }); }); \ No newline at end of file diff --git a/packages/webgl/src/PathCommand/usecase/PathCommandBezierCurveToUseCase.test.ts b/packages/webgl/src/PathCommand/usecase/PathCommandBezierCurveToUseCase.test.ts index 5565f688..5363fb76 100644 --- a/packages/webgl/src/PathCommand/usecase/PathCommandBezierCurveToUseCase.test.ts +++ b/packages/webgl/src/PathCommand/usecase/PathCommandBezierCurveToUseCase.test.ts @@ -7,67 +7,57 @@ import { describe("PathCommandBezierCurveToUseCase.js method test", () => { - it("test case", () => + it("test case - flat curve uses minimal segments", () => { $currentPath.length = 0; $vertices.length = 0; - + expect($currentPath.length).toBe(0); expect($vertices.length).toBe(0); + // 直線的なベジエ曲線(フラット): 適応的テッセレーションで最小2セグメント + // Flat bezier curve: adaptive tessellation uses minimum 2 segments execute(10, 10, 20, 20, 30, 30); - expect($currentPath.length).toBe(51); + + // 適応的テッセレーション: フラットな曲線は2セグメント = 15要素 + // Adaptive tessellation: flat curves use 2 segments = 15 elements + // (moveTo: 3) + (2 quadratics: 2 * 6) = 3 + 12 = 15 + expect($currentPath.length).toBe(15); expect($vertices.length).toBe(0); + + // 始点 expect($currentPath[0]).toBe(0); expect($currentPath[1]).toBe(0); expect($currentPath[2]).toBe(false); - expect($currentPath[3]).toBe(1.875); - expect($currentPath[4]).toBe(1.875); - expect($currentPath[5]).toBe(true); - expect($currentPath[6]).toBe(3.75); - expect($currentPath[7]).toBe(3.75); - expect($currentPath[8]).toBe(false); - expect($currentPath[9]).toBe(5.625); - expect($currentPath[10]).toBe(5.625); - expect($currentPath[11]).toBe(true); - expect($currentPath[12]).toBe(7.5); - expect($currentPath[13]).toBe(7.5); - expect($currentPath[14]).toBe(false); - expect($currentPath[15]).toBe(9.375); - expect($currentPath[16]).toBe(9.375); - expect($currentPath[17]).toBe(true); - expect($currentPath[18]).toBe(11.25); - expect($currentPath[19]).toBe(11.25); - expect($currentPath[20]).toBe(false); - expect($currentPath[21]).toBe(13.125); - expect($currentPath[22]).toBe(13.125); - expect($currentPath[23]).toBe(true); - expect($currentPath[24]).toBe(15); - expect($currentPath[25]).toBe(15); - expect($currentPath[26]).toBe(false); - expect($currentPath[27]).toBe(16.875); - expect($currentPath[28]).toBe(16.875); - expect($currentPath[29]).toBe(true); - expect($currentPath[30]).toBe(18.75); - expect($currentPath[31]).toBe(18.75); - expect($currentPath[32]).toBe(false); - expect($currentPath[33]).toBe(20.625); - expect($currentPath[34]).toBe(20.625); - expect($currentPath[35]).toBe(true); - expect($currentPath[36]).toBe(22.5); - expect($currentPath[37]).toBe(22.5); - expect($currentPath[38]).toBe(false); - expect($currentPath[39]).toBe(24.375); - expect($currentPath[40]).toBe(24.375); - expect($currentPath[41]).toBe(true); - expect($currentPath[42]).toBe(26.25); - expect($currentPath[43]).toBe(26.25); - expect($currentPath[44]).toBe(false); - expect($currentPath[45]).toBe(28.125); - expect($currentPath[46]).toBe(28.125); - expect($currentPath[47]).toBe(true); - expect($currentPath[48]).toBe(30); - expect($currentPath[49]).toBe(30); - expect($currentPath[50]).toBe(false); + + // 最終点が正しいことを確認 + expect($currentPath[$currentPath.length - 3]).toBe(30); + expect($currentPath[$currentPath.length - 2]).toBe(30); + expect($currentPath[$currentPath.length - 1]).toBe(false); + }); + + it("test case - curved path uses more segments", () => + { + $currentPath.length = 0; + $vertices.length = 0; + + // 曲率のあるベジエ曲線: より多くのセグメントが必要 + // Curved bezier: requires more segments + execute(0, 100, 100, 0, 100, 100); + + // 曲率が高いので4セグメント以上 = 27要素以上 + // Higher curvature requires 4+ segments = 27+ elements + expect($currentPath.length).toBeGreaterThanOrEqual(27); + expect($vertices.length).toBe(0); + + // 始点 + expect($currentPath[0]).toBe(0); + expect($currentPath[1]).toBe(0); + expect($currentPath[2]).toBe(false); + + // 最終点 + expect($currentPath[$currentPath.length - 3]).toBe(100); + expect($currentPath[$currentPath.length - 2]).toBe(100); + expect($currentPath[$currentPath.length - 1]).toBe(false); }); }); \ No newline at end of file diff --git a/packages/webgl/src/PathCommand/usecase/PathCommandBezierCurveToUseCase.ts b/packages/webgl/src/PathCommand/usecase/PathCommandBezierCurveToUseCase.ts index 3f8b8a4e..e0719cf3 100644 --- a/packages/webgl/src/PathCommand/usecase/PathCommandBezierCurveToUseCase.ts +++ b/packages/webgl/src/PathCommand/usecase/PathCommandBezierCurveToUseCase.ts @@ -1,12 +1,12 @@ import { execute as pathCommandMoveToUseCase } from "./PathCommandMoveToUseCase"; import { execute as pathCommandEqualsToLastPointService } from "../service/PathCommandEqualsToLastPointService"; import { execute as pathCommandQuadraticCurveToUseCase } from "./PathCommandQuadraticCurveToUseCase"; -import { execute as bezierConverterCubicToQuadUseCase } from "../../BezierConverter/usecase/BezierConverterCubicToQuadUseCase"; +import { execute as bezierConverterAdaptiveCubicToQuadUseCase } from "../../BezierConverter/usecase/BezierConverterAdaptiveCubicToQuadUseCase"; import { $currentPath } from "../../PathCommand"; /** - * @description 3次ベジェ曲線を描画します。 - * Draw a cubic Bezier curve. + * @description 3次ベジェ曲線を描画します。適応的テッセレーションにより曲率に応じた分割を行います。 + * Draw a cubic Bezier curve. Uses adaptive tessellation based on curvature. * * @param {number} cx1 * @param {number} cy1 @@ -36,8 +36,13 @@ export const execute = ( const fromX: number = +$currentPath[length - 3]; const fromY: number = +$currentPath[length - 2]; - const buffer = bezierConverterCubicToQuadUseCase(fromX, fromY, cx1, cy1, cx2, cy2, x, y); - for (let idx = 0; 32 > idx; ) { + // 適応的テッセレーション: 曲率に基づいて分割数を動的に決定 + // Adaptive tessellation: dynamically determine subdivision count based on curvature + const result = bezierConverterAdaptiveCubicToQuadUseCase(fromX, fromY, cx1, cy1, cx2, cy2, x, y); + const buffer = result.buffer; + const count = result.count; + + for (let idx = 0; idx < count * 4; ) { pathCommandQuadraticCurveToUseCase( buffer[idx++], buffer[idx++], diff --git a/packages/webgl/src/PathCommand/usecase/PathCommandMoveToUseCase.test.ts b/packages/webgl/src/PathCommand/usecase/PathCommandMoveToUseCase.test.ts index 36cdd556..a432c29f 100644 --- a/packages/webgl/src/PathCommand/usecase/PathCommandMoveToUseCase.test.ts +++ b/packages/webgl/src/PathCommand/usecase/PathCommandMoveToUseCase.test.ts @@ -37,6 +37,7 @@ describe("PathCommandMoveToUseCase.js method test", () => expect($currentPath[0]).toBe(10); expect($currentPath[1]).toBe(10); expect($currentPath[2]).toBe(false); - expect($vertices.length).toBe(0); + // 新しい動作: パスは$verticesに追加され、$getVertices()でフィルタリングされる + expect($vertices.length).toBe(1); }); }); \ No newline at end of file diff --git a/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceBlurFilter.ts b/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceBlurFilter.ts index 670a695c..cec97fb8 100644 --- a/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceBlurFilter.ts +++ b/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceBlurFilter.ts @@ -1,9 +1,3 @@ -/** - * @param {number} half_blur - * @return {string} - * @method - * @static - */ export const BLUR_FILTER_TEMPLATE = (half_blur: number): string => { const halfBlurFixed = half_blur.toFixed(1); diff --git a/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceColorMatrixFilter.ts b/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceColorMatrixFilter.ts index c9549645..54fac7d8 100644 --- a/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceColorMatrixFilter.ts +++ b/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceColorMatrixFilter.ts @@ -1,8 +1,3 @@ -/** - * @return {string} - * @method - * @static - */ export const COLOR_MATRIX_FILTER_TEMPLATE = (): string => { return `#version 300 es diff --git a/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceConvolutionFilter.ts b/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceConvolutionFilter.ts index 00091943..2dca16d9 100644 --- a/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceConvolutionFilter.ts +++ b/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceConvolutionFilter.ts @@ -1,15 +1,5 @@ import { FUNCTION_IS_INSIDE } from "../FragmentShaderLibrary"; -/** - * @param {number} mediump_length - * @param {number} x - * @param {number} y - * @param {boolean} preserve_alpha - * @param {boolean} clamp - * @return {string} - * @method - * @static - */ export const CONVOLUTION_FILTER_TEMPLATE = ( mediump_length: number, x: number, diff --git a/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceDisplacementMapFilter.ts b/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceDisplacementMapFilter.ts index b7c7e7e0..e4990676 100644 --- a/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceDisplacementMapFilter.ts +++ b/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceDisplacementMapFilter.ts @@ -1,14 +1,5 @@ import { FUNCTION_IS_INSIDE } from "../FragmentShaderLibrary"; -/** - * @param {number} mediump_length - * @param {number} component_x - * @param {number} component_y - * @param {number} mode - * @return {string} - * @method - * @static - */ export const DISPLACEMENT_MAP_FILTER_TEMPLATE = ( mediump_length: number, component_x: number, @@ -22,19 +13,19 @@ export const DISPLACEMENT_MAP_FILTER_TEMPLATE = ( switch (component_x) { - case 1: // BitmapDataChannel.RED + case 1: cx = "map_color.r"; break; - case 2: // BitmapDataChannel.GREEN + case 2: cx = "map_color.g"; break; - case 4: // BitmapDataChannel.BLUE + case 4: cx = "map_color.b"; break; - case 8: // BitmapDataChannel.ALPHA + case 8: cx = "map_color.a"; break; @@ -46,19 +37,19 @@ export const DISPLACEMENT_MAP_FILTER_TEMPLATE = ( switch (component_y) { - case 1: // BitmapDataChannel.RED + case 1: cy = "map_color.r"; break; - case 2: // BitmapDataChannel.GREEN + case 2: cy = "map_color.g"; break; - case 4: // BitmapDataChannel.BLUE + case 4: cy = "map_color.b"; break; - case 8: // BitmapDataChannel.ALPHA + case 8: cy = "map_color.a"; break; @@ -77,7 +68,6 @@ export const DISPLACEMENT_MAP_FILTER_TEMPLATE = ( break; case 3: - // 置き換え後の座標が範囲外なら、置き換え前の座標をとる(x軸とy軸を別々に判定する) modeStatement = ` vec4 source_color =texture(u_textures[0], mix(v_coord, uv, step(abs(uv - vec2(0.5)), vec2(0.5)))); `; diff --git a/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceFilter.ts b/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceFilter.ts index 862d4617..63187f07 100644 --- a/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceFilter.ts +++ b/packages/webgl/src/Shader/Fragment/Filter/FragmentShaderSourceFilter.ts @@ -1,11 +1,5 @@ import { FUNCTION_IS_INSIDE } from "../FragmentShaderLibrary"; -/** - * @param {number} index - * @return {string} - * @method - * @private - */ const STATEMENT_BASE_TEXTURE_TRANSFORM = (index: number): string => { return ` @@ -17,11 +11,6 @@ const STATEMENT_BASE_TEXTURE_TRANSFORM = (index: number): string => `; }; -/** - * @return {string} - * @method - * @private - */ const STATEMENT_BLUR_TEXTURE = (): string => { return ` @@ -29,12 +18,6 @@ const STATEMENT_BLUR_TEXTURE = (): string => `; }; -/** - * @param {number} index - * @return {string} - * @method - * @private - */ const STATEMENT_BLUR_TEXTURE_TRANSFORM = (index: number): string => { return ` @@ -46,12 +29,6 @@ const STATEMENT_BLUR_TEXTURE_TRANSFORM = (index: number): string => `; }; -/** - * @param {number} offset - * @return {string} - * @method - * @private - */ const STATEMENT_GLOW_STRENGTH = (offset: number): string => { const index = Math.floor(offset / 4); @@ -62,12 +39,6 @@ const STATEMENT_GLOW_STRENGTH = (offset: number): string => `; }; -/** - * @param {number} index - * @return {string} - * @method - * @static - */ const STATEMENT_GLOW_SOLID_COLOR = (index: number): string => { return ` @@ -76,12 +47,6 @@ const STATEMENT_GLOW_SOLID_COLOR = (index: number): string => `; }; -/** - * @param {boolean} transforms_base - * @return {string} - * @method - * @static - */ const STATEMENT_GLOW_GRADIENT_COLOR = (transforms_base: boolean): string => { return ` @@ -89,17 +54,6 @@ const STATEMENT_GLOW_GRADIENT_COLOR = (transforms_base: boolean): string => `; }; -/** - * @param {boolean} is_inner - * @param {boolean} transforms_base - * @param {boolean} applies_strength - * @param {boolean} is_gradient - * @param {number} color_index - * @param {number} strength_offset - * @return {string} - * @method - * @private - */ const STATEMENT_GLOW = ( is_inner: boolean, transforms_base: boolean, @@ -128,11 +82,6 @@ const STATEMENT_GLOW = ( `; }; -/** - * @return {string} - * @method - * @static - */ const STATEMENT_BLUR_TEXTURE_2 = (): string => { return ` @@ -140,11 +89,6 @@ const STATEMENT_BLUR_TEXTURE_2 = (): string => `; }; -/** - * @return {string} - * @method - * @static - */ const STATEMENT_BLUR_TEXTURE_TRANSFORM_2 = (): string => { return ` @@ -153,12 +97,6 @@ const STATEMENT_BLUR_TEXTURE_TRANSFORM_2 = (): string => `; }; -/** - * @param {boolean} offset - * @return {string} - * @method - * @static - */ const STATEMENT_BEVEL_STRENGTH = (offset: number): string => { const index = Math.floor(offset / 4); @@ -171,12 +109,6 @@ const STATEMENT_BEVEL_STRENGTH = (offset: number): string => `; }; -/** - * @param {number} index - * @return {string} - * @method - * @static - */ const STATEMENT_BEVEL_SOLID_COLOR = (index: number): string => { return ` @@ -186,12 +118,6 @@ const STATEMENT_BEVEL_SOLID_COLOR = (index: number): string => `; }; -/** - * @param {boolean} transforms_base - * @return {string} - * @method - * @static - */ const STATEMENT_BEVEL_GRADIENT_COLOR = (transforms_base: boolean): string => { return ` @@ -202,20 +128,6 @@ const STATEMENT_BEVEL_GRADIENT_COLOR = (transforms_base: boolean): string => `; }; -/** - * @param {number} textures_length - * @param {number} mediump_length - * @param {boolean} transforms_base - * @param {boolean} transforms_blur - * @param {boolean} is_glow - * @param {string} type - * @param {boolean} knockout - * @param {boolean} applies_strength - * @param {boolean} is_gradient - * @return {string} - * @method - * @static - */ export const BITMAP_FILTER_TEMPLATE = ( textures_length: number, mediump_length: number, @@ -303,17 +215,6 @@ void main() { }`; }; -/** - * @param {string} transforms_base - * @param {boolean} transforms_blur - * @param {boolean} applies_strength - * @param {boolean} is_gradient - * @param {number} color_index - * @param {number} strength_offset - * @return {string} - * @method - * @static - */ const STATEMENT_BEVEL = ( transforms_base: boolean, transforms_blur: boolean, @@ -344,4 +245,4 @@ const STATEMENT_BEVEL = ( shadow_alpha = clamp(shadow_alpha, 0.0, 1.0); ${colorStatement} `; -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/Shader/Fragment/FragmentShaderLibrary.ts b/packages/webgl/src/Shader/Fragment/FragmentShaderLibrary.ts index 9060b3ef..a309728f 100644 --- a/packages/webgl/src/Shader/Fragment/FragmentShaderLibrary.ts +++ b/packages/webgl/src/Shader/Fragment/FragmentShaderLibrary.ts @@ -1,8 +1,3 @@ -/** - * @return {string} - * @method - * @static - */ export const FUNCTION_IS_INSIDE = (): string => { return ` @@ -11,12 +6,6 @@ float isInside(in vec2 uv) { }`; }; -/** - * @param {number} mediump_index - * @return {string} - * @method - * @static - */ export const STATEMENT_COLOR_TRANSFORM_ON = (mediump_index: number): string => { return ` @@ -31,4 +20,4 @@ export const STATEMENT_COLOR_TRANSFORM_ON = (mediump_index: number): string => src.rgb *= src.a; } `; -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/Shader/Fragment/FragmentShaderSource.ts b/packages/webgl/src/Shader/Fragment/FragmentShaderSource.ts index fc2e9910..d054ff22 100644 --- a/packages/webgl/src/Shader/Fragment/FragmentShaderSource.ts +++ b/packages/webgl/src/Shader/Fragment/FragmentShaderSource.ts @@ -1,11 +1,3 @@ -/** - * @description 頂点シェーダから受け取ったカラー情報をそのまま出力。 - * Outputs the color information received from the vertex shader as it is. - * - * @return {string} - * @method - * @static - */ export const SOLID_FILL_COLOR = (): string => { return `#version 300 es @@ -19,14 +11,6 @@ void main() { }`; }; -/** - * @description ビットマップの繰り返しではない場合の塗りつぶし。 - * Filling when the bitmap is not repeated. - * - * @return {string} - * @method - * @static - */ export const BITMAP_CLIPPED = (): string => { return `#version 300 es @@ -46,14 +30,6 @@ void main() { }`; }; -/** - * @description ビットマップの繰り返しの場合の塗りつぶし。 - * Filling in the case of repeating the bitmap. - * - * @return {string} - * @method - * @static - */ export const BITMAP_PATTERN = (): string => { return `#version 300 es @@ -67,20 +43,12 @@ out vec4 o_color; void main() { vec2 uv = fract(vec2(v_uv.x, -v_uv.y) / u_mediump[0].xy); - + vec4 src = texture(u_texture, uv); o_color = src; }`; }; -/** - * @description マスク専用のシェーダ。 - * Shader dedicated to masks. - * - * @return {string} - * @method - * @static - */ export const MASK = (): string => { return `#version 300 es @@ -90,28 +58,22 @@ in vec2 v_bezier; out vec4 o_color; void main() { - vec2 px = dFdx(v_bezier); - vec2 py = dFdy(v_bezier); + float f_val = v_bezier.x * v_bezier.x - v_bezier.y; + + float dx = dFdx(f_val); + float dy = dFdy(f_val); - vec2 f = (2.0 * v_bezier.x) * vec2(px.x, py.x) - vec2(px.y, py.y); - float alpha = 0.5 - (v_bezier.x * v_bezier.x - v_bezier.y) / length(f); + float dist = f_val / length(vec2(dx, dy)); + float alpha = smoothstep(0.5, -0.5, dist); - if (alpha > 0.0) { + if (alpha > 0.001) { o_color = vec4(min(alpha, 1.0)); } else { discard; - } + } }`; }; -/** - * @description 矩形の塗りつぶし、カラーは固定。 - * Fill the rectangle, the color is fixed. - * - * @return {string} - * @method - * @static - */ export const FILL_RECT_COLOR = (): string => { return `#version 300 es @@ -120,4 +82,4 @@ out vec4 o_color; void main() { o_color = vec4(1.0); }`; -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/Shader/Fragment/FragmentShaderSourceBlend.ts b/packages/webgl/src/Shader/Fragment/FragmentShaderSourceBlend.ts index 0eefc424..84933f69 100644 --- a/packages/webgl/src/Shader/Fragment/FragmentShaderSourceBlend.ts +++ b/packages/webgl/src/Shader/Fragment/FragmentShaderSourceBlend.ts @@ -1,10 +1,5 @@ import { STATEMENT_COLOR_TRANSFORM_ON } from "./FragmentShaderLibrary"; -/** - * @return {string} - * @method - * @static - */ const FUNCTION_NORMAL = (): string => { return ` @@ -13,27 +8,8 @@ vec4 blend (in vec4 src, in vec4 dst) { }`; }; -// 各ブレンド式は、前景と背景の両方のアルファを考慮する必要がある -// https://odashi.hatenablog.com/entry/20110921/1316610121 -// https://hakuhin.jp/as3/blend.html -// -// [基本計算式] -// ・色(rgb)はストレートアルファ -// ・アルファ(a)が0の場合は例外処理をする -// 前景色 a: src.rgb * (src.a * (1.0 - dst.a)) -// 背景色 b: dst.rgb * (dst.a * (1.0 - src.a)) -// 合成色 c: mix.rgb * (src.a * dst.a) -// 最終結果: a + b + c - -/** - * @return {string} - * @method - * @static - */ const FUNCTION_SUBTRACT = (): string => { - // [合成色計算式] - // dst - src return ` vec4 blend (in vec4 src, in vec4 dst) { if (src.a == 0.0) { return dst; } @@ -52,15 +28,8 @@ vec4 blend (in vec4 src, in vec4 dst) { }`; }; -/** - * @return {string} - * @method - * @static - */ const FUNCTION_MULTIPLY = (): string => { - // [合成色計算式] - // src * dst return ` vec4 blend (in vec4 src, in vec4 dst) { vec4 a = src - src * dst.a; @@ -71,15 +40,8 @@ vec4 blend (in vec4 src, in vec4 dst) { }`; }; -/** - * @return {string} - * @method - * @static - */ const FUNCTION_LIGHTEN = (): string => { - // [合成色計算式] - // (src > dst) ? src : dst return ` vec4 blend (in vec4 src, in vec4 dst) { if (src.a == 0.0) { return dst; } @@ -98,15 +60,8 @@ vec4 blend (in vec4 src, in vec4 dst) { }`; }; -/** - * @return {string} - * @method - * @static - */ const FUNCTION_DARKEN = (): string => { - // [合成色計算式] - // (src < dst) ? src : dst return ` vec4 blend (in vec4 src, in vec4 dst) { if (src.a == 0.0) { return dst; } @@ -125,19 +80,8 @@ vec4 blend (in vec4 src, in vec4 dst) { }`; }; -/** - * @return {string} - * @method - * @static - */ const FUNCTION_OVERLAY = (): string => { - // [合成色計算式] - // if (dst < 0.5) { - // return 2.0 * src * dst - // } else { - // return 1.0 - 2.0 * (1.0 - src) * (1.0 - dst) - // } return ` vec4 blend (in vec4 src, in vec4 dst) { if (src.a == 0.0) { return dst; } @@ -159,19 +103,8 @@ vec4 blend (in vec4 src, in vec4 dst) { }`; }; -/** - * @return {string} - * @method - * @static - */ const FUNCTION_HARDLIGHT = (): string => { - // [合成色計算式] - // if (src < 0.5) { - // return 2.0 * src * dst - // } else { - // return 1.0 - 2.0 * (1.0 - src) * (1.0 - dst) - // } return ` vec4 blend (in vec4 src, in vec4 dst) { if (src.a == 0.0) { return dst; } @@ -193,15 +126,8 @@ vec4 blend (in vec4 src, in vec4 dst) { }`; }; -/** - * @return {string} - * @method - * @static - */ const FUNCTION_DIFFERENCE = (): string => { - // [合成色計算式] - // abs(src - dst) return ` vec4 blend (in vec4 src, in vec4 dst) { if (src.a == 0.0) { return dst; } @@ -220,15 +146,8 @@ vec4 blend (in vec4 src, in vec4 dst) { }`; }; -/** - * @return {string} - * @method - * @static - */ const FUNCTION_INVERT = (): string => { - // [基本計算式] - // ((1.0 - dst) * src.a) + (dst * (1.0 - src.a)) return ` vec4 blend (in vec4 src, in vec4 dst) { if (src.a == 0.0) { return dst; } @@ -241,13 +160,6 @@ vec4 blend (in vec4 src, in vec4 dst) { }`; }; -/** - * @param {string} operation - * @param {boolean} with_color_transform - * @return {string} - * @method - * @static - */ export const BLEND_TEMPLATE = (operation: string, with_color_transform: boolean): string => { let blendFunction: string; @@ -316,4 +228,4 @@ void main() { ${colorTransformStatement} o_color = blend(src, dst); }`; -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/Shader/Fragment/FragmentShaderSourceGradient.ts b/packages/webgl/src/Shader/Fragment/FragmentShaderSourceGradient.ts index 1a66d2f1..20f36e8a 100644 --- a/packages/webgl/src/Shader/Fragment/FragmentShaderSourceGradient.ts +++ b/packages/webgl/src/Shader/Fragment/FragmentShaderSourceGradient.ts @@ -1,41 +1,23 @@ -/** - * @param {number} index - * @return {string} - * @method - * @private - */ const STATEMENT_FOCAL_POINT_ON = (index: number): string => { return ` vec2 focal = vec2(u_highp[${index}][1], 0.0); - vec2 dir = normalize(coord - focal); - - float a = dot(dir, dir); + vec2 diff = coord - focal; + float lenDiff = length(diff); + vec2 dir = diff / lenDiff; float b = 2.0 * dot(dir, focal); float c = dot(focal, focal) - 1.0; - float x = (-b + sqrt(b * b - 4.0 * a * c)) / (2.0 * a); + float x = (-b + sqrt(max(b * b - 4.0 * c, 0.0))) * 0.5; - float t = distance(focal, coord) / distance(focal, focal + dir * x);`; + float t = lenDiff / abs(x);`; }; -/** - * @return {string} - * @method - * @private - */ const STATEMENT_FOCAL_POINT_OFF = (): string => { return "float t = length(coord);"; }; -/** - * @param {number} index - * @param {boolean} has_focal_point - * @return {string} - * @method - * @private - */ const STATEMENT_GRADIENT_TYPE_RADIAL = (index: number, has_focal_point: boolean): string => { const focalPointStatement = has_focal_point @@ -49,12 +31,6 @@ const STATEMENT_GRADIENT_TYPE_RADIAL = (index: number, has_focal_point: boolean) `; }; -/** - * @param {number} index - * @return {string} - * @method - * @private - */ const STATEMENT_GRADIENT_TYPE_LINEAR = (index: number): string => { return ` @@ -67,16 +43,6 @@ const STATEMENT_GRADIENT_TYPE_LINEAR = (index: number): string => float t = dot(ab, ap) / dot(ab, ab);`; }; -/** - * @param {number} highp_length - * @param {number} fragment_index - * @param {boolean} is_radial - * @param {boolean} has_focal_point - * @param {number} spread_method - * @return {string} - * @method - * @private - */ export const GRADIENT_TEMPLATE = ( highp_length: number, fragment_index: number, @@ -111,10 +77,10 @@ precision highp float; uniform sampler2D u_texture; uniform vec4 u_highp[${highp_length}]; - + in vec2 v_uv; out vec4 o_color; - + void main() { vec2 p = v_uv; ${gradientTypeStatement} diff --git a/packages/webgl/src/Shader/Fragment/FragmentShaderSourceGradientLUT.ts b/packages/webgl/src/Shader/Fragment/FragmentShaderSourceGradientLUT.ts index 9d6c6d35..b31a8434 100644 --- a/packages/webgl/src/Shader/Fragment/FragmentShaderSourceGradientLUT.ts +++ b/packages/webgl/src/Shader/Fragment/FragmentShaderSourceGradientLUT.ts @@ -1,14 +1,3 @@ -/** - * @description グラデーションのLUTのフラグメントシェーダーソース - * Fragment shader source of gradient LUT - * - * @param {number} mediump_length - * @param {number} stops_length - * @param {number} is_linear_space - * @return {string} - * @method - * @protected - */ export const GRADIENT_LUT_TEMPLATE = ( mediump_length: number, stops_length: number, @@ -59,4 +48,4 @@ void main() { o_color = color; }`; -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/Shader/Fragment/FragmentShaderSourceTexture.ts b/packages/webgl/src/Shader/Fragment/FragmentShaderSourceTexture.ts index c76e4562..0032c1ee 100644 --- a/packages/webgl/src/Shader/Fragment/FragmentShaderSourceTexture.ts +++ b/packages/webgl/src/Shader/Fragment/FragmentShaderSourceTexture.ts @@ -1,11 +1,5 @@ import { STATEMENT_COLOR_TRANSFORM_ON } from "./FragmentShaderLibrary"; -/** - * @param {boolean} with_color_transform - * @return {string} - * @method - * @static - */ export const TEXTURE = (with_color_transform: boolean): string => { const colorTransformUniform = with_color_transform @@ -32,11 +26,6 @@ void main() { }`; }; -/** - * @return {string} - * @method - * @static - */ export const INSTANCE_TEXTURE = (): string => { return `#version 300 es @@ -62,4 +51,4 @@ void main() { o_color = src; }`; -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/Shader/GradientLUTGenerator.ts b/packages/webgl/src/Shader/GradientLUTGenerator.ts index a5f20365..361a89a5 100644 --- a/packages/webgl/src/Shader/GradientLUTGenerator.ts +++ b/packages/webgl/src/Shader/GradientLUTGenerator.ts @@ -2,22 +2,63 @@ import type { IAttachmentObject } from "../interface/IAttachmentObject"; import { execute as frameBufferManagerGetAttachmentObjectUseCase } from "../FrameBufferManager/usecase/FrameBufferManagerGetAttachmentObjectUseCase"; /** - * @type {IAttachmentObject | null} + * @description 解像度別のAttachmentObjectキャッシュ + * Attachment object cache by resolution + * + * @type {Map} * @private */ -let $gradientAttachmentObject: IAttachmentObject | null = null; +const $gradientAttachmentObjects: Map = new Map(); + +/** + * @description ストップ数に応じた適応的な解像度を返却 + * Returns adaptive resolution based on stop count + * + * @param {number} stopsLength + * @return {number} + * @method + * @protected + */ +export const $getAdaptiveResolution = (stopsLength: number): number => +{ + if (stopsLength <= 4) { + return 256; + } + if (stopsLength <= 8) { + return 512; + } + return 1024; +}; + +/** + * @description 指定解像度のAttachmentObjectを返却 + * Returns AttachmentObject with specified resolution + * + * @param {number} resolution + * @return {IAttachmentObject} + * @method + * @protected + */ +export const $getGradientAttachmentObjectWithResolution = (resolution: number): IAttachmentObject => +{ + if (!$gradientAttachmentObjects.has(resolution)) { + const attachment = frameBufferManagerGetAttachmentObjectUseCase(resolution, 1, false); + $gradientAttachmentObjects.set(resolution, attachment); + } + return $gradientAttachmentObjects.get(resolution) as NonNullable; +}; /** + * @description デフォルトの512解像度のAttachmentObjectを返却(後方互換性) + * Returns default 512 resolution AttachmentObject (backward compatibility) + * * @return {IAttachmentObject} * @method * @protected */ export const $getGradientAttachmentObject = (): IAttachmentObject => { - if (!$gradientAttachmentObject) { - $gradientAttachmentObject = frameBufferManagerGetAttachmentObjectUseCase(512, 1, false); - } - return $gradientAttachmentObject as NonNullable; + return $getGradientAttachmentObjectWithResolution(512); }; /** diff --git a/packages/webgl/src/Shader/GradientLUTGenerator/service/GradientLUTSetFilterUniformService.test.ts b/packages/webgl/src/Shader/GradientLUTGenerator/service/GradientLUTSetFilterUniformService.test.ts new file mode 100644 index 00000000..bc4f8337 --- /dev/null +++ b/packages/webgl/src/Shader/GradientLUTGenerator/service/GradientLUTSetFilterUniformService.test.ts @@ -0,0 +1,89 @@ +import { execute } from "./GradientLUTSetFilterUniformService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +describe("GradientLUTSetFilterUniformService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should set gradient filter uniform with single stop", () => + { + const mediump = new Float32Array(32); + const mockShaderManager = { + mediump: mediump + } as unknown as ShaderManager; + + const ratios = new Float32Array([0, 255]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); // red to blue + const alphas = new Float32Array([1, 1]); + + execute(mockShaderManager, ratios, colors, alphas, 0, 2); + + // Check first color (red) + expect(mediump[0]).toBeCloseTo(1, 5); // r + expect(mediump[1]).toBeCloseTo(0, 5); // g + expect(mediump[2]).toBeCloseTo(0, 5); // b + expect(mediump[3]).toBe(1); // a + + // Check second color (blue) + expect(mediump[4]).toBeCloseTo(0, 5); // r + expect(mediump[5]).toBeCloseTo(0, 5); // g + expect(mediump[6]).toBeCloseTo(1, 5); // b + expect(mediump[7]).toBe(1); // a + + // Check ratios (u_gradient_t) + expect(mediump[8]).toBeCloseTo(0, 5); // 0 / 255 + expect(mediump[9]).toBeCloseTo(1, 5); // 255 / 255 + }); + + it("test case - should handle partial range with begin and end", () => + { + const mediump = new Float32Array(32); + const mockShaderManager = { + mediump: mediump + } as unknown as ShaderManager; + + const ratios = new Float32Array([0, 127, 255]); + const colors = new Float32Array([0xFF0000, 0x00FF00, 0x0000FF]); + const alphas = new Float32Array([1, 0.5, 0]); + + execute(mockShaderManager, ratios, colors, alphas, 1, 3); + + // Check first color in range (green at index 1) + expect(mediump[0]).toBeCloseTo(0, 5); // r + expect(mediump[1]).toBeCloseTo(1, 5); // g + expect(mediump[2]).toBeCloseTo(0, 5); // b + expect(mediump[3]).toBe(0.5); // a + + // Check second color in range (blue at index 2) + expect(mediump[4]).toBeCloseTo(0, 5); // r + expect(mediump[5]).toBeCloseTo(0, 5); // g + expect(mediump[6]).toBeCloseTo(1, 5); // b + expect(mediump[7]).toBe(0); // a + }); + + it("test case - should handle mixed colors correctly", () => + { + const mediump = new Float32Array(32); + const mockShaderManager = { + mediump: mediump + } as unknown as ShaderManager; + + // Mixed color: 0x80C0E0 = R:128, G:192, B:224 + const ratios = new Float32Array([128]); + const colors = new Float32Array([0x80C0E0]); + const alphas = new Float32Array([0.75]); + + execute(mockShaderManager, ratios, colors, alphas, 0, 1); + + expect(mediump[0]).toBeCloseTo(128 / 255, 5); // r + expect(mediump[1]).toBeCloseTo(192 / 255, 5); // g + expect(mediump[2]).toBeCloseTo(224 / 255, 5); // b + expect(mediump[3]).toBe(0.75); // a + + // Check ratio + expect(mediump[4]).toBeCloseTo(128 / 255, 5); + }); +}); diff --git a/packages/webgl/src/Shader/GradientLUTGenerator/service/GradientLUTSetUniformService.test.ts b/packages/webgl/src/Shader/GradientLUTGenerator/service/GradientLUTSetUniformService.test.ts new file mode 100644 index 00000000..66547c8b --- /dev/null +++ b/packages/webgl/src/Shader/GradientLUTGenerator/service/GradientLUTSetUniformService.test.ts @@ -0,0 +1,99 @@ +import { execute } from "./GradientLUTSetUniformService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +describe("GradientLUTSetUniformService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should set gradient uniform from stops", () => + { + const mediump = new Float32Array(32); + const mockShaderManager = { + mediump: mediump + } as unknown as ShaderManager; + + // stops format: [ratio, r_idx, g_idx, b_idx, a_idx, ...] + const stops = [ + 0, 0, 1, 2, 3, // first stop + 255, 4, 5, 6, 7 // second stop + ]; + + // table with color values + const table = new Float32Array([ + 1, 0, 0, 1, // RGBA at indices 0-3 + 0, 0, 1, 0.5 // RGBA at indices 4-7 + ]); + + execute(mockShaderManager, stops, 0, 2, table); + + // Check first color (from table indices 0,1,2,3) + expect(mediump[0]).toBe(1); // table[0] + expect(mediump[1]).toBe(0); // table[1] + expect(mediump[2]).toBe(0); // table[2] + expect(mediump[3]).toBe(1); // table[3] + + // Check second color (from table indices 4,5,6,7) + expect(mediump[4]).toBe(0); // table[4] + expect(mediump[5]).toBe(0); // table[5] + expect(mediump[6]).toBe(1); // table[6] + expect(mediump[7]).toBe(0.5); // table[7] + + // Check ratios (u_gradient_t) + expect(mediump[8]).toBe(0); // stops[0] + expect(mediump[9]).toBe(255); // stops[5] + }); + + it("test case - should handle partial range", () => + { + const mediump = new Float32Array(32); + const mockShaderManager = { + mediump: mediump + } as unknown as ShaderManager; + + const stops = [ + 0, 0, 1, 2, 3, + 127, 4, 5, 6, 7, + 255, 8, 9, 10, 11 + ]; + + const table = new Float32Array([ + 1, 0, 0, 1, + 0, 1, 0, 1, + 0, 0, 1, 1 + ]); + + execute(mockShaderManager, stops, 1, 3, table); + + // First color in range should be from stops[1] + expect(mediump[0]).toBe(0); // table[4] + expect(mediump[1]).toBe(1); // table[5] + expect(mediump[2]).toBe(0); // table[6] + expect(mediump[3]).toBe(1); // table[7] + + // Check ratios + expect(mediump[8]).toBe(127); // stops[5] (second stop ratio) + expect(mediump[9]).toBe(255); // stops[10] (third stop ratio) + }); + + it("test case - should process single stop", () => + { + const mediump = new Float32Array(16); + const mockShaderManager = { + mediump: mediump + } as unknown as ShaderManager; + + const stops = [128, 0, 1, 2, 3]; + const table = new Float32Array([0.5, 0.6, 0.7, 0.8]); + + execute(mockShaderManager, stops, 0, 1, table); + + expect(mediump[0]).toBeCloseTo(0.5, 5); + expect(mediump[1]).toBeCloseTo(0.6, 5); + expect(mediump[2]).toBeCloseTo(0.7, 5); + expect(mediump[3]).toBeCloseTo(0.8, 5); + expect(mediump[4]).toBe(128); // ratio + }); +}); diff --git a/packages/webgl/src/Shader/GradientLUTGenerator/usecase/GradientLUTGenerateFilterTextureUseCase.test.ts b/packages/webgl/src/Shader/GradientLUTGenerator/usecase/GradientLUTGenerateFilterTextureUseCase.test.ts new file mode 100644 index 00000000..79b6c19c --- /dev/null +++ b/packages/webgl/src/Shader/GradientLUTGenerator/usecase/GradientLUTGenerateFilterTextureUseCase.test.ts @@ -0,0 +1,102 @@ +import { execute } from "./GradientLUTGenerateFilterTextureUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockTextureObject = { id: "gradient-texture" }; +const mockAttachmentObject = { texture: mockTextureObject }; + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $context: { + currentAttachmentObject: null, + bind: vi.fn() + } + }; +}); + +vi.mock("../../../Blend/service/BlendOneZeroService.ts", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Blend/service/BlendResetService.ts", () => ({ + execute: vi.fn() +})); + +vi.mock("../../Variants/GradientLUT/service/VariantsGradientLUTShaderService.ts", () => ({ + execute: vi.fn(() => ({ + mediump: new Float32Array(64), + useProgram: vi.fn(), + bindUniform: vi.fn() + })) +})); + +vi.mock("../service/GradientLUTSetFilterUniformService.ts", () => ({ + execute: vi.fn() +})); + +vi.mock("./GradientLUTGeneratorFillTextureUseCase.ts", () => ({ + execute: vi.fn() +})); + +vi.mock("../../GradientLUTGenerator.ts", () => ({ + $getGradientAttachmentObject: vi.fn(() => ({ + texture: { id: "gradient-texture" } + })), + $getGradientLUTGeneratorMaxLength: () => 16 +})); + +import { $context } from "../../../WebGLUtil"; +import { execute as blendOneZeroService } from "../../../Blend/service/BlendOneZeroService"; +import { execute as blendResetService } from "../../../Blend/service/BlendResetService"; +import { execute as variantsGradientLUTShaderService } from "../../Variants/GradientLUT/service/VariantsGradientLUTShaderService"; +import { execute as gradientLUTSetFilterUniformService } from "../service/GradientLUTSetFilterUniformService"; +import { execute as gradientLUTGeneratorFillTextureUseCase } from "./GradientLUTGeneratorFillTextureUseCase"; +import { $getGradientAttachmentObject } from "../../GradientLUTGenerator"; + +describe("GradientLUTGenerateFilterTextureUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should generate filter texture", () => + { + const ratios = new Float32Array([0, 255]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1, 1]); + + const result = execute(ratios, colors, alphas); + + expect($context.bind).toHaveBeenCalled(); + expect(blendOneZeroService).toHaveBeenCalled(); + expect(variantsGradientLUTShaderService).toHaveBeenCalledWith(2, false); + expect(gradientLUTSetFilterUniformService).toHaveBeenCalled(); + expect(gradientLUTGeneratorFillTextureUseCase).toHaveBeenCalled(); + expect(blendResetService).toHaveBeenCalled(); + expect(result).toEqual({ id: "gradient-texture" }); + }); + + it("test case - should handle multiple stops within max length", () => + { + const ratios = new Float32Array([0, 64, 128, 192, 255]); + const colors = new Float32Array([0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0x00FFFF]); + const alphas = new Float32Array([1, 1, 1, 1, 1]); + + execute(ratios, colors, alphas); + + expect(variantsGradientLUTShaderService).toHaveBeenCalledWith(5, false); + }); + + it("test case - should call blend operations correctly", () => + { + const ratios = new Float32Array([0, 255]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1, 1]); + + execute(ratios, colors, alphas); + + expect(blendOneZeroService).toHaveBeenCalledTimes(1); + expect(blendResetService).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/webgl/src/Shader/GradientLUTGenerator/usecase/GradientLUTGenerateShapeTextureUseCase.test.ts b/packages/webgl/src/Shader/GradientLUTGenerator/usecase/GradientLUTGenerateShapeTextureUseCase.test.ts index 82689930..85a84824 100644 --- a/packages/webgl/src/Shader/GradientLUTGenerator/usecase/GradientLUTGenerateShapeTextureUseCase.test.ts +++ b/packages/webgl/src/Shader/GradientLUTGenerator/usecase/GradientLUTGenerateShapeTextureUseCase.test.ts @@ -48,6 +48,16 @@ vi.mock("../../../WebGLUtil.ts", async (importOriginal) => scissorCalls.push([x, y, w, h]); }) }, + $enableScissorTest: vi.fn((cap?: any) => + { + enableCalls.push("SCISSOR_TEST"); + scissorTestEnabled = true; + }), + $disableScissorTest: vi.fn((cap?: any) => + { + disableCalls.push("SCISSOR_TEST"); + scissorTestEnabled = false; + }), $context: { get currentAttachmentObject() { return currentAttachment; @@ -60,12 +70,14 @@ vi.mock("../../../WebGLUtil.ts", async (importOriginal) => }; }); -vi.mock("../../GradientLUTGenerator.ts", async (importOriginal) => +vi.mock("../../GradientLUTGenerator.ts", async (importOriginal) => { const mod = await importOriginal(); return { ...mod, $getGradientAttachmentObject: vi.fn(() => mockAttachmentObject), + $getGradientAttachmentObjectWithResolution: vi.fn(() => mockAttachmentObject), + $getAdaptiveResolution: vi.fn(() => 512), $getGradientLUTGeneratorMaxLength: vi.fn(() => 10), $rgbToLinearTable: new Float32Array(256), $rgbIdentityTable: new Float32Array(256) diff --git a/packages/webgl/src/Shader/GradientLUTGenerator/usecase/GradientLUTGenerateShapeTextureUseCase.ts b/packages/webgl/src/Shader/GradientLUTGenerator/usecase/GradientLUTGenerateShapeTextureUseCase.ts index dcb51b3b..cd6f7ee2 100644 --- a/packages/webgl/src/Shader/GradientLUTGenerator/usecase/GradientLUTGenerateShapeTextureUseCase.ts +++ b/packages/webgl/src/Shader/GradientLUTGenerator/usecase/GradientLUTGenerateShapeTextureUseCase.ts @@ -6,10 +6,13 @@ import { execute as gradientLUTSetUniformService } from "../service/GradientLUTS import { execute as gradientLUTGeneratorFillTextureUseCase } from "./GradientLUTGeneratorFillTextureUseCase"; import { $gl, - $context + $context, + $enableScissorTest, + $disableScissorTest } from "../../../WebGLUtil"; import { - $getGradientAttachmentObject, + $getGradientAttachmentObjectWithResolution, + $getAdaptiveResolution, $getGradientLUTGeneratorMaxLength, $rgbIdentityTable, $rgbToLinearTable @@ -18,6 +21,10 @@ import { /** * @description グラデーションのテクスチャを生成します。 * Generates a texture of the gradient. + * 注意: グラデーションLUTは共有テクスチャに描画されるため、 + * キャッシュは使用しません。各フレームで再描画が必要です。 + * Note: Gradient LUT is drawn to a shared texture, so caching + * is not used. Re-drawing is required each frame. * * @param {array} stops * @param {number} interpolation @@ -29,14 +36,16 @@ export const execute = (stops: number[], interpolation: number): ITextureObject { const currentAttachment = $context.currentAttachmentObject; const scissorBox = $gl.getParameter($gl.SCISSOR_BOX); - $gl.disable($gl.SCISSOR_TEST); - - const gradientAttachmentObject = $getGradientAttachmentObject(); - $context.bind(gradientAttachmentObject); + $disableScissorTest(); const isLinearSpace = interpolation === 0; const stopsLength = stops.length / 5; + // 適応的解像度を使用 + const resolution = $getAdaptiveResolution(stopsLength); + const gradientAttachmentObject = $getGradientAttachmentObjectWithResolution(resolution); + $context.bind(gradientAttachmentObject); + const table: Float32Array = isLinearSpace ? $rgbToLinearTable : $rgbIdentityTable; @@ -69,7 +78,7 @@ export const execute = (stops: number[], interpolation: number): ITextureObject } // bugfix: @see https://github.com/Next2D/player/issues/234 - $gl.enable($gl.SCISSOR_TEST); + $enableScissorTest(); $gl.scissor(scissorBox[0], scissorBox[1], scissorBox[2], scissorBox[3]); return gradientAttachmentObject.texture as NonNullable; diff --git a/packages/webgl/src/Shader/GradientLUTGenerator/usecase/GradientLUTGeneratorFillTextureUseCase.test.ts b/packages/webgl/src/Shader/GradientLUTGenerator/usecase/GradientLUTGeneratorFillTextureUseCase.test.ts new file mode 100644 index 00000000..00c8def9 --- /dev/null +++ b/packages/webgl/src/Shader/GradientLUTGenerator/usecase/GradientLUTGeneratorFillTextureUseCase.test.ts @@ -0,0 +1,84 @@ +import { execute } from "./GradientLUTGeneratorFillTextureUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $gl: { + TRIANGLE_STRIP: 5, + drawArrays: vi.fn() + } + }; +}); + +vi.mock("../../../VertexArrayObject/usecase/VertexArrayObjectGetGradientObjectUseCase.ts", () => ({ + execute: vi.fn(() => ({ id: "gradient-vao" })) +})); + +vi.mock("../../../VertexArrayObject/service/VertexArrayObjectBindService.ts", () => ({ + execute: vi.fn() +})); + +import { $gl } from "../../../WebGLUtil"; +import { execute as getGradientObjectUseCase } from "../../../VertexArrayObject/usecase/VertexArrayObjectGetGradientObjectUseCase"; +import { execute as bindService } from "../../../VertexArrayObject/service/VertexArrayObjectBindService"; + +describe("GradientLUTGeneratorFillTextureUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should execute gradient texture fill", () => + { + const mockUseProgram = vi.fn(); + const mockBindUniform = vi.fn(); + const mockShaderManager = { + useProgram: mockUseProgram, + bindUniform: mockBindUniform + } as unknown as ShaderManager; + + execute(mockShaderManager, 0, 1); + + expect(mockUseProgram).toHaveBeenCalledTimes(1); + expect(mockBindUniform).toHaveBeenCalledTimes(1); + expect(getGradientObjectUseCase).toHaveBeenCalledWith(0, 1); + expect(bindService).toHaveBeenCalled(); + expect($gl.drawArrays).toHaveBeenCalledWith(5, 0, 4); + }); + + it("test case - should pass begin and end to gradient object usecase", () => + { + const mockShaderManager = { + useProgram: vi.fn(), + bindUniform: vi.fn() + } as unknown as ShaderManager; + + execute(mockShaderManager, 0.25, 0.75); + + expect(getGradientObjectUseCase).toHaveBeenCalledWith(0.25, 0.75); + }); + + it("test case - should call operations in correct order", () => + { + const callOrder: string[] = []; + const mockShaderManager = { + useProgram: vi.fn(() => callOrder.push("useProgram")), + bindUniform: vi.fn(() => callOrder.push("bindUniform")) + } as unknown as ShaderManager; + + vi.mocked(bindService).mockImplementation(() => callOrder.push("bindVao")); + vi.mocked($gl.drawArrays).mockImplementation(() => callOrder.push("drawArrays")); + + execute(mockShaderManager, 0, 1); + + expect(callOrder).toEqual([ + "useProgram", + "bindUniform", + "bindVao", + "drawArrays" + ]); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderInstancedManager.ts b/packages/webgl/src/Shader/ShaderInstancedManager.ts index 69faf345..ad416ac3 100644 --- a/packages/webgl/src/Shader/ShaderInstancedManager.ts +++ b/packages/webgl/src/Shader/ShaderInstancedManager.ts @@ -1,44 +1,18 @@ import { ShaderManager } from "./ShaderManager"; import { renderQueue } from "@next2d/render-queue"; -/** - * @class - * @extends ShaderManager - */ export class ShaderInstancedManager extends ShaderManager { - /** - * @description attribute変数の数 - * Number of attribute variables - * - * @type {number} - * @public - */ public count: number; - /** - * @param {string} vertex_source - * @param {string} fragment_source - * @param {boolean} [atlas=true] - * @constructor - * @public - */ constructor (vertex_source: string, fragment_source: string, atlas: boolean = true) { super(vertex_source, fragment_source, atlas); this.count = 0; } - /** - * @description attributeの配列を初期化します。 - * Initialize the array of attributes. - * - * @return {void} - * @method - * @public - */ clear (): void { this.count = renderQueue.offset = 0; } -} \ No newline at end of file +} diff --git a/packages/webgl/src/Shader/ShaderInstancedManager/usecase/ShaderInstancedManagerDrawArraysInstancedUseCase.test.ts b/packages/webgl/src/Shader/ShaderInstancedManager/usecase/ShaderInstancedManagerDrawArraysInstancedUseCase.test.ts new file mode 100644 index 00000000..bf40357a --- /dev/null +++ b/packages/webgl/src/Shader/ShaderInstancedManager/usecase/ShaderInstancedManagerDrawArraysInstancedUseCase.test.ts @@ -0,0 +1,59 @@ +import { execute } from "./ShaderInstancedManagerDrawArraysInstancedUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderInstancedManager } from "../../ShaderInstancedManager"; + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $gl: { + TRIANGLES: 4, + drawArraysInstanced: vi.fn() + } + }; +}); + +vi.mock("../../../VertexArrayObject/usecase/VertexArrayObjectBindAttributeUseCase.ts", () => ({ + execute: vi.fn() +})); + +import { $gl } from "../../../WebGLUtil"; +import { execute as bindAttributeUseCase } from "../../../VertexArrayObject/usecase/VertexArrayObjectBindAttributeUseCase"; + +describe("ShaderInstancedManagerDrawArraysInstancedUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should execute instanced drawing", () => + { + const mockUseProgram = vi.fn(); + const mockBindUniform = vi.fn(); + const mockShaderInstancedManager = { + useProgram: mockUseProgram, + bindUniform: mockBindUniform, + count: 10 + } as unknown as ShaderInstancedManager; + + execute(mockShaderInstancedManager); + + expect(mockUseProgram).toHaveBeenCalledTimes(1); + expect(mockBindUniform).toHaveBeenCalledTimes(1); + expect(bindAttributeUseCase).toHaveBeenCalledTimes(1); + expect($gl.drawArraysInstanced).toHaveBeenCalledWith(4, 0, 6, 10); + }); + + it("test case - should use correct instance count", () => + { + const mockShaderInstancedManager = { + useProgram: vi.fn(), + bindUniform: vi.fn(), + count: 50 + } as unknown as ShaderInstancedManager; + + execute(mockShaderInstancedManager); + + expect($gl.drawArraysInstanced).toHaveBeenCalledWith(4, 0, 6, 50); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager.ts b/packages/webgl/src/Shader/ShaderManager.ts index 4cd8338d..102fa075 100644 --- a/packages/webgl/src/Shader/ShaderManager.ts +++ b/packages/webgl/src/Shader/ShaderManager.ts @@ -5,40 +5,14 @@ import { execute as shaderManagerInitializeUniformService } from "./ShaderManage import { execute as shaderManagerUseProgramService } from "./ShaderManager/service/ShaderManagerUseProgramService"; import { execute as shaderManagerBindUniformService } from "./ShaderManager/service/ShaderManagerBindUniformService"; -/** - * @description 利用用途に合わせたシェーダークラス - * Shader class tailored to the intended use - * - * @class - * @public - */ export class ShaderManager { - /** - * @description WebGLプログラムオブジェクト - * WebGL program object - * - * @type {IProgramObject} - * @public - */ private readonly _$programObject: IProgramObject; - - /** - * @description uniform変数のマップ - * Map of uniform variables - * - * @type {Map} - * @private - */ private readonly _$uniformMap: Map; + public readonly highp: Int32Array | Float32Array; + public readonly mediump: Int32Array | Float32Array; + public readonly textures: Int32Array | Float32Array; - /** - * @param {string} vertex_source - * @param {string} fragment_source - * @param {boolean} [atlas=false] - * @constructor - * @public - */ constructor (vertex_source: string, fragment_source: string, atlas: boolean = false) { this._$programObject = shaderManagerCreateProgramService(vertex_source, fragment_source); @@ -49,73 +23,20 @@ export class ShaderManager this._$uniformMap, atlas ); + + const emptyArray = new Float32Array(0); + this.highp = this._$uniformMap.get("u_highp")?.array as Int32Array | Float32Array ?? emptyArray; + this.mediump = this._$uniformMap.get("u_mediump")?.array as Int32Array | Float32Array ?? emptyArray; + this.textures = this._$uniformMap.get("u_textures")?.array as Int32Array | Float32Array ?? emptyArray; } - /** - * @description 生成したプログラムを利用します。 - * Use the generated program. - * - * @return {void} - * @method - * @public - */ useProgram (): void { shaderManagerUseProgramService(this._$programObject); } - /** - * @description uniform変数をバインドします。 - * Bind uniform variables. - * - * @return {void} - * @method - * @public - */ bindUniform (): void { shaderManagerBindUniformService(this._$uniformMap); } - - /** - * @description highp uniform変数 - * highp uniform variable - * - * @type {Int32Array | Float32Array} - * @readonly - * @public - */ - get highp (): Int32Array | Float32Array - { - const data = this._$uniformMap.get("u_highp") as NonNullable; - return data.array as Int32Array | Float32Array; - } - - /** - * @description mediump uniform変数 - * mediump uniform variable - * - * @type {Int32Array | Float32Array} - * @readonly - * @public - */ - get mediump (): Int32Array | Float32Array - { - const data = this._$uniformMap.get("u_mediump") as NonNullable; - return data.array as Int32Array | Float32Array; - } - - /** - * @description texture uniform変数 - * texture uniform variable - * - * @type {Int32Array | Float32Array} - * @readonly - * @public - */ - get textures (): Int32Array | Float32Array - { - const data = this._$uniformMap.get("u_textures") as NonNullable; - return data.array as Int32Array | Float32Array; - } -} \ No newline at end of file +} diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerBindUniformService.test.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerBindUniformService.test.ts new file mode 100644 index 00000000..e807065c --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerBindUniformService.test.ts @@ -0,0 +1,107 @@ +import { execute } from "./ShaderManagerBindUniformService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { IUniformData } from "../../../interface/IUniformData"; + +describe("ShaderManagerBindUniformService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should bind uniform with negative assign value", () => + { + const mockMethod = vi.fn(); + const uniformMap = new Map(); + uniformMap.set("u_test", { + method: mockMethod, + assign: -1, + array: new Float32Array([1, 2, 3]) + }); + + execute(uniformMap); + + expect(mockMethod).toHaveBeenCalledTimes(1); + expect(mockMethod).toHaveBeenCalledWith(new Float32Array([1, 2, 3])); + }); + + it("test case - should bind uniform with positive assign value", () => + { + const mockMethod = vi.fn(); + const uniformData: IUniformData = { + method: mockMethod, + assign: 2, + array: new Float32Array([4, 5, 6]) + }; + const uniformMap = new Map(); + uniformMap.set("u_test", uniformData); + + execute(uniformMap); + + expect(mockMethod).toHaveBeenCalledTimes(1); + expect(uniformData.assign).toBe(1); + }); + + it("test case - should not bind uniform with zero assign value", () => + { + const mockMethod = vi.fn(); + const uniformMap = new Map(); + uniformMap.set("u_test", { + method: mockMethod, + assign: 0, + array: new Float32Array([7, 8, 9]) + }); + + execute(uniformMap); + + expect(mockMethod).not.toHaveBeenCalled(); + }); + + it("test case - should skip uniform without method", () => + { + const uniformMap = new Map(); + uniformMap.set("u_test", { + method: undefined, + assign: -1, + array: new Float32Array([1, 2, 3]) + }); + + expect(() => execute(uniformMap)).not.toThrow(); + }); + + it("test case - should skip uniform without assign", () => + { + const mockMethod = vi.fn(); + const uniformMap = new Map(); + uniformMap.set("u_test", { + method: mockMethod, + assign: undefined, + array: new Float32Array([1, 2, 3]) + }); + + execute(uniformMap); + + expect(mockMethod).not.toHaveBeenCalled(); + }); + + it("test case - should bind multiple uniforms", () => + { + const mockMethod1 = vi.fn(); + const mockMethod2 = vi.fn(); + const uniformMap = new Map(); + uniformMap.set("u_test1", { + method: mockMethod1, + assign: -1, + array: new Float32Array([1, 2]) + }); + uniformMap.set("u_test2", { + method: mockMethod2, + assign: -1, + array: new Float32Array([3, 4]) + }); + + execute(uniformMap); + + expect(mockMethod1).toHaveBeenCalledTimes(1); + expect(mockMethod2).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBitmapFillUniformService.test.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBitmapFillUniformService.test.ts new file mode 100644 index 00000000..7fdf38ea --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBitmapFillUniformService.test.ts @@ -0,0 +1,81 @@ +import { execute } from "./ShaderManagerSetBitmapFillUniformService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $context: { + $matrix: new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + $stack: [new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1])] + }, + $inverseMatrix: vi.fn(() => new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1])), + $viewportWidth: 800, + $viewportHeight: 600 + }; +}); + +describe("ShaderManagerSetBitmapFillUniformService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should set bitmap fill uniform without grid data", () => + { + const highp = new Float32Array(64); + const mediump = new Float32Array(8); + const mockShaderManager = { + highp: highp, + mediump: mediump + } as unknown as ShaderManager; + + execute(mockShaderManager, 100, 100, null); + + // Check viewport + expect(highp[3]).toBe(800); + expect(highp[7]).toBe(600); + + // Check uv + expect(mediump[0]).toBe(100); + expect(mediump[1]).toBe(100); + }); + + it("test case - should set bitmap fill uniform with grid data", () => + { + const highp = new Float32Array(64); + const mediump = new Float32Array(8); + const mockShaderManager = { + highp: highp, + mediump: mediump + } as unknown as ShaderManager; + + const gridData = new Float32Array([ + 1, 0, 0, 1, 0, 0, // parent matrix (0-5) + 1, 0, 0, 1, 0, 0, // ancestor matrix (6-11) + 100, 100, 200, 200, // parent viewport (12-15) + 10, 20, 30, 40, // grid min (16-19) + 50, 60, 70, 80, // grid max (20-23) + 5, 10 // offset (24-25) + ]); + + execute(mockShaderManager, 200, 150, gridData); + + // Check grid min + expect(highp[44]).toBe(10); + expect(highp[45]).toBe(20); + + // Check grid max + expect(highp[48]).toBe(50); + expect(highp[49]).toBe(60); + + // Check offset + expect(highp[52]).toBe(5); + expect(highp[53]).toBe(10); + + // Check uv + expect(mediump[0]).toBe(200); + expect(mediump[1]).toBe(150); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBitmapFillUniformService.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBitmapFillUniformService.ts index 3580e725..db5da71a 100644 --- a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBitmapFillUniformService.ts +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBitmapFillUniformService.ts @@ -2,8 +2,8 @@ import type { ShaderManager } from "../../ShaderManager"; import { $context, $inverseMatrix, - $getViewportWidth, - $getViewportHeight + $viewportWidth, + $viewportHeight } from "../../../WebGLUtil"; /** @@ -56,8 +56,8 @@ export const execute = ( highp[19] = inverseMatrix[8]; // vertex: u_viewport - highp[3] = $getViewportWidth(); - highp[7] = $getViewportHeight(); + highp[3] = $viewportWidth; + highp[7] = $viewportHeight; if (grid_data) { // vertex: u_parent_matrix diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBitmapFilterUniformService.test.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBitmapFilterUniformService.test.ts new file mode 100644 index 00000000..74718871 --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBitmapFilterUniformService.test.ts @@ -0,0 +1,130 @@ +import { execute } from "./ShaderManagerSetBitmapFilterUniformService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +describe("ShaderManagerSetBitmapFilterUniformService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should set bitmap filter uniform for glow filter", () => + { + const textures = new Int32Array(4); + const mediump = new Float32Array(24); + const mockShaderManager = { + textures: textures, + mediump: mediump + } as unknown as ShaderManager; + + execute( + mockShaderManager, + 130, 130, // width, height + 100, 100, // base_width, base_height + 10, 10, // base_offset_x, base_offset_y + 120, 120, // blur_width, blur_height + 0, 0, // blur_offset_x, blur_offset_y + true, // is_glow + 1, // strength + 1, 0, 0, 1, // color1 (red) + 0, 0, 0, 0, // color2 + true, // transforms_base + true, // transforms_blur + false, // applies_strength + false // is_gradient + ); + + expect(textures[0]).toBe(0); + expect(textures[1]).toBe(1); + }); + + it("test case - should set bitmap filter uniform for bevel filter", () => + { + const textures = new Int32Array(4); + const mediump = new Float32Array(32); + const mockShaderManager = { + textures: textures, + mediump: mediump + } as unknown as ShaderManager; + + execute( + mockShaderManager, + 130, 130, + 100, 100, + 15, 15, + 120, 120, + 5, 5, + false, // is_glow (bevel) + 1, + 1, 1, 1, 1, // highlight color (white) + 0, 0, 0, 1, // shadow color (black) + true, + true, + false, + false + ); + + // Verify textures are set + expect(textures[0]).toBe(0); + expect(textures[1]).toBe(1); + }); + + it("test case - should set bitmap filter uniform with gradient", () => + { + const textures = new Int32Array(4); + const mediump = new Float32Array(16); + const mockShaderManager = { + textures: textures, + mediump: mediump + } as unknown as ShaderManager; + + execute( + mockShaderManager, + 100, 100, + 100, 100, + 0, 0, + 100, 100, + 0, 0, + true, + 1, + 0, 0, 0, 0, + 0, 0, 0, 0, + true, + false, + false, + true // is_gradient + ); + + expect(textures[2]).toBe(2); + }); + + it("test case - should set bitmap filter uniform with strength", () => + { + const textures = new Int32Array(4); + const mediump = new Float32Array(24); + const mockShaderManager = { + textures: textures, + mediump: mediump + } as unknown as ShaderManager; + + execute( + mockShaderManager, + 100, 100, + 100, 100, + 0, 0, + 100, 100, + 0, 0, + true, + 2.5, // strength + 1, 0, 0, 1, + 0, 0, 0, 0, + false, + false, + true, // applies_strength + false + ); + + // Last value should be strength + expect(mediump[4]).toBe(2.5); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBlendUniformService.test.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBlendUniformService.test.ts new file mode 100644 index 00000000..206e7e4a --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBlendUniformService.test.ts @@ -0,0 +1,36 @@ +import { execute } from "./ShaderManagerSetBlendUniformService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +describe("ShaderManagerSetBlendUniformService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should set blend uniform variables", () => + { + const textures = new Int32Array(4); + const mockShaderManager = { + textures: textures + } as unknown as ShaderManager; + + execute(mockShaderManager); + + expect(textures[0]).toBe(0); + expect(textures[1]).toBe(1); + }); + + it("test case - should work with Float32Array textures", () => + { + const textures = new Float32Array(4); + const mockShaderManager = { + textures: textures + } as unknown as ShaderManager; + + execute(mockShaderManager); + + expect(textures[0]).toBe(0); + expect(textures[1]).toBe(1); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBlendWithColorTransformUniformService.test.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBlendWithColorTransformUniformService.test.ts new file mode 100644 index 00000000..fc8040af --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBlendWithColorTransformUniformService.test.ts @@ -0,0 +1,61 @@ +import { execute } from "./ShaderManagerSetBlendWithColorTransformUniformService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +describe("ShaderManagerSetBlendWithColorTransformUniformService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should set blend with color transform uniform", () => + { + const textures = new Int32Array(4); + const mediump = new Float32Array(16); + const mockShaderManager = { + textures: textures, + mediump: mediump + } as unknown as ShaderManager; + + execute( + mockShaderManager, + 1, 1, 1, 1, // color transform multiply + 0, 0, 0, 0 // color transform add + ); + + // Check textures + expect(textures[0]).toBe(0); + expect(textures[1]).toBe(1); + + // Check color transform multiply + expect(mediump[0]).toBe(1); + expect(mediump[1]).toBe(1); + expect(mediump[2]).toBe(1); + expect(mediump[3]).toBe(1); + + // Check color transform add + expect(mediump[4]).toBe(0); + expect(mediump[5]).toBe(0); + expect(mediump[6]).toBe(0); + expect(mediump[7]).toBe(0); + }); + + it("test case - should set blend with tint color transform", () => + { + const textures = new Int32Array(4); + const mediump = new Float32Array(16); + const mockShaderManager = { + textures: textures, + mediump: mediump + } as unknown as ShaderManager; + + execute( + mockShaderManager, + 0.5, 0.5, 0.5, 1, // multiply by 50% + 0.5, 0, 0, 0 // add red tint + ); + + expect(mediump[0]).toBe(0.5); + expect(mediump[4]).toBe(0.5); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBlurFilterUniformService.test.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBlurFilterUniformService.test.ts new file mode 100644 index 00000000..3e35d2ed --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetBlurFilterUniformService.test.ts @@ -0,0 +1,55 @@ +import { execute } from "./ShaderManagerSetBlurFilterUniformService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +describe("ShaderManagerSetBlurFilterUniformService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should set horizontal blur filter uniform", () => + { + const mediump = new Float32Array(8); + const mockShaderManager = { + mediump: mediump + } as unknown as ShaderManager; + + execute(mockShaderManager, 100, 100, true, 0.5, 8); + + expect(mediump[0]).toBeCloseTo(1 / 100, 6); // u_offset.x + expect(mediump[1]).toBe(0); // u_offset.y + expect(mediump[2]).toBe(0.5); // u_fraction + expect(mediump[3]).toBe(8); // u_samples + }); + + it("test case - should set vertical blur filter uniform", () => + { + const mediump = new Float32Array(8); + const mockShaderManager = { + mediump: mediump + } as unknown as ShaderManager; + + execute(mockShaderManager, 100, 200, false, 0.75, 16); + + expect(mediump[0]).toBe(0); // u_offset.x + expect(mediump[1]).toBeCloseTo(1 / 200, 6); // u_offset.y + expect(mediump[2]).toBe(0.75); // u_fraction + expect(mediump[3]).toBe(16); // u_samples + }); + + it("test case - should handle different texture sizes", () => + { + const mediump = new Float32Array(8); + const mockShaderManager = { + mediump: mediump + } as unknown as ShaderManager; + + execute(mockShaderManager, 512, 256, true, 1, 32); + + expect(mediump[0]).toBeCloseTo(1 / 512, 6); + expect(mediump[1]).toBe(0); + expect(mediump[2]).toBe(1); + expect(mediump[3]).toBe(32); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetColorMatrixFilterUniformService.test.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetColorMatrixFilterUniformService.test.ts new file mode 100644 index 00000000..c965dbeb --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetColorMatrixFilterUniformService.test.ts @@ -0,0 +1,80 @@ +import { execute } from "./ShaderManagerSetColorMatrixFilterUniformService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +describe("ShaderManagerSetColorMatrixFilterUniformService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should set color matrix filter uniform with identity matrix", () => + { + const mediump = new Float32Array(24); + const mockShaderManager = { + mediump: mediump + } as unknown as ShaderManager; + + const identityMatrix = new Float32Array([ + 1, 0, 0, 0, 0, + 0, 1, 0, 0, 0, + 0, 0, 1, 0, 0, + 0, 0, 0, 1, 0 + ]); + + execute(mockShaderManager, identityMatrix); + + // Check u_mul matrix + expect(mediump[0]).toBe(1); // matrix[0] + expect(mediump[5]).toBe(1); // matrix[6] + expect(mediump[10]).toBe(1); // matrix[12] + expect(mediump[15]).toBe(1); // matrix[18] + + // Check u_add (all zeros for identity) + expect(mediump[16]).toBe(0); + expect(mediump[17]).toBe(0); + expect(mediump[18]).toBe(0); + expect(mediump[19]).toBe(0); + }); + + it("test case - should set color matrix filter uniform with grayscale matrix", () => + { + const mediump = new Float32Array(24); + const mockShaderManager = { + mediump: mediump + } as unknown as ShaderManager; + + const grayscaleMatrix = new Float32Array([ + 0.3, 0.3, 0.3, 0, 0, + 0.59, 0.59, 0.59, 0, 0, + 0.11, 0.11, 0.11, 0, 0, + 0, 0, 0, 1, 0 + ]); + + execute(mockShaderManager, grayscaleMatrix); + + expect(mediump[0]).toBeCloseTo(0.3, 5); + }); + + it("test case - should set color matrix filter uniform with brightness adjustment", () => + { + const mediump = new Float32Array(24); + const mockShaderManager = { + mediump: mediump + } as unknown as ShaderManager; + + const brightnessMatrix = new Float32Array([ + 1, 0, 0, 0, 50, + 0, 1, 0, 0, 50, + 0, 0, 1, 0, 50, + 0, 0, 0, 1, 0 + ]); + + execute(mockShaderManager, brightnessMatrix); + + // Check u_add values (divided by 255) + expect(mediump[16]).toBeCloseTo(50 / 255, 6); + expect(mediump[17]).toBeCloseTo(50 / 255, 6); + expect(mediump[18]).toBeCloseTo(50 / 255, 6); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetConvolutionFilterUniformService.test.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetConvolutionFilterUniformService.test.ts new file mode 100644 index 00000000..369c0f57 --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetConvolutionFilterUniformService.test.ts @@ -0,0 +1,106 @@ +import { execute } from "./ShaderManagerSetConvolutionFilterUniformService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +describe("ShaderManagerSetConvolutionFilterUniformService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should set convolution filter uniform with clamp", () => + { + const mediump = new Float32Array(24); + const mockShaderManager = { + mediump: mediump + } as unknown as ShaderManager; + + const matrix = new Float32Array([ + 0, -1, 0, + -1, 5, -1, + 0, -1, 0 + ]); + + execute( + mockShaderManager, + 100, 100, + matrix, + 1, // divisor + 0, // bias + true, // clamp + 0, 0, 0, 0 // color + ); + + // Check rcp_size + expect(mediump[0]).toBeCloseTo(1 / 100, 6); + expect(mediump[1]).toBeCloseTo(1 / 100, 6); + + // Check rcp_divisor + expect(mediump[2]).toBe(1); + + // Check bias + expect(mediump[3]).toBe(0); + }); + + it("test case - should set convolution filter uniform without clamp", () => + { + const mediump = new Float32Array(24); + const mockShaderManager = { + mediump: mediump + } as unknown as ShaderManager; + + const matrix = new Float32Array([ + -1, -1, -1, + -1, 8, -1, + -1, -1, -1 + ]); + + execute( + mockShaderManager, + 200, 150, + matrix, + 1, + 128, // bias + false, // clamp + 1, 0, 0, 1 // substitute color (red) + ); + + // Check bias (128 / 255) + expect(mediump[3]).toBeCloseTo(128 / 255, 6); + + // Check substitute color (index 4-7) + expect(mediump[4]).toBe(1); // r + expect(mediump[5]).toBe(0); // g + expect(mediump[6]).toBe(0); // b + expect(mediump[7]).toBe(1); // a + + // Matrix starts at index 8 + expect(mediump[8]).toBe(-1); + }); + + it("test case - should handle different divisors", () => + { + const mediump = new Float32Array(24); + const mockShaderManager = { + mediump: mediump + } as unknown as ShaderManager; + + const matrix = new Float32Array([ + 1, 1, 1, + 1, 1, 1, + 1, 1, 1 + ]); + + execute( + mockShaderManager, + 100, 100, + matrix, + 9, // divisor for box blur + 0, + true, + 0, 0, 0, 0 + ); + + expect(mediump[2]).toBeCloseTo(1 / 9, 6); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetDisplacementMapFilterUniformService.test.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetDisplacementMapFilterUniformService.test.ts new file mode 100644 index 00000000..0827aafa --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetDisplacementMapFilterUniformService.test.ts @@ -0,0 +1,88 @@ +import { execute } from "./ShaderManagerSetDisplacementMapFilterUniformService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +describe("ShaderManagerSetDisplacementMapFilterUniformService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should set displacement map filter uniform", () => + { + const textures = new Int32Array(4); + const mediump = new Float32Array(16); + const mockShaderManager = { + textures: textures, + mediump: mediump + } as unknown as ShaderManager; + + execute( + mockShaderManager, + 100, 100, // map size + 200, 200, // base size + 0, 0, // point + 10, 10, // scale + 0, // mode (wrap) + 0, 0, 0, 0 // color + ); + + // Check textures + expect(textures[0]).toBe(0); + expect(textures[1]).toBe(1); + + // Check uv_to_st_scale + expect(mediump[0]).toBe(2); + expect(mediump[1]).toBe(2); + }); + + it("test case - should set displacement map filter uniform with color mode", () => + { + const textures = new Int32Array(4); + const mediump = new Float32Array(16); + const mockShaderManager = { + textures: textures, + mediump: mediump + } as unknown as ShaderManager; + + execute( + mockShaderManager, + 50, 50, + 100, 100, + 10, 10, + 20, 20, + 1, // mode (color) + 1, 0, 0, 1 // substitute color (red) + ); + + // Check substitute color + expect(mediump[8]).toBe(1); // r + expect(mediump[9]).toBe(0); // g + expect(mediump[10]).toBe(0); // b + expect(mediump[11]).toBe(1); // a + }); + + it("test case - should handle point offset", () => + { + const textures = new Int32Array(4); + const mediump = new Float32Array(16); + const mockShaderManager = { + textures: textures, + mediump: mediump + } as unknown as ShaderManager; + + execute( + mockShaderManager, + 100, 100, + 200, 200, + 50, 50, // point offset + 10, 10, + 0, + 0, 0, 0, 0 + ); + + // Check uv_to_st_offset + expect(mediump[2]).toBe(50 / 100); + expect(mediump[3]).toBe((200 - 100 - 50) / 100); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetFillUniformService.test.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetFillUniformService.test.ts new file mode 100644 index 00000000..bfa061c2 --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetFillUniformService.test.ts @@ -0,0 +1,66 @@ +import { execute } from "./ShaderManagerSetFillUniformService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $viewportWidth: 800, + $viewportHeight: 600 + }; +}); + +describe("ShaderManagerSetFillUniformService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should set fill uniform variables", () => + { + const highp = new Float32Array(40); + const mockShaderManager = { + highp: highp + } as unknown as ShaderManager; + + const gridData = new Float32Array([ + 1, 0, 0, 1, 0, 0, // parent matrix (0-5) + 1, 0, 0, 1, 0, 0, // ancestor matrix (6-11) + 100, 100, 200, 200, // parent viewport (12-15) + 10, 20, 30, 40, // grid min (16-19) + 50, 60, 70, 80, // grid max (20-23) + 5, 10 // offset (24-25) + ]); + + execute(mockShaderManager, gridData); + + // Check parent matrix + expect(highp[0]).toBe(1); + expect(highp[1]).toBe(0); + expect(highp[4]).toBe(0); + expect(highp[5]).toBe(1); + expect(highp[8]).toBe(0); + expect(highp[9]).toBe(0); + + // Check viewport + expect(highp[3]).toBe(800); + expect(highp[7]).toBe(600); + + // Check grid min + expect(highp[24]).toBe(10); + expect(highp[25]).toBe(20); + expect(highp[26]).toBe(30); + expect(highp[27]).toBe(40); + + // Check grid max + expect(highp[28]).toBe(50); + expect(highp[29]).toBe(60); + expect(highp[30]).toBe(70); + expect(highp[31]).toBe(80); + + // Check offset + expect(highp[32]).toBe(5); + expect(highp[33]).toBe(10); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetFillUniformService.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetFillUniformService.ts index 1fb30f4f..6806982e 100644 --- a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetFillUniformService.ts +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetFillUniformService.ts @@ -1,7 +1,7 @@ import type { ShaderManager } from "../../ShaderManager"; import { - $getViewportWidth, - $getViewportHeight + $viewportWidth, + $viewportHeight } from "../../../WebGLUtil"; /** @@ -48,8 +48,8 @@ export const execute = ( highp[22] = 1; // vertex: u_viewport - highp[3] = $getViewportWidth(); - highp[7] = $getViewportHeight(); + highp[3] = $viewportWidth; + highp[7] = $viewportHeight; // vertex: u_parent_viewport highp[11] = grid_data[12]; diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetGradientFillUniformService.test.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetGradientFillUniformService.test.ts new file mode 100644 index 00000000..e60ba6ce --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetGradientFillUniformService.test.ts @@ -0,0 +1,136 @@ +import { execute } from "./ShaderManagerSetGradientFillUniformService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $viewportWidth: 800, + $viewportHeight: 600, + $clamp: vi.fn((value, min, max, defaultValue) => { + if (value < min) return min; + if (value > max) return max; + return value; + }) + }; +}); + +describe("ShaderManagerSetGradientFillUniformService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should set linear gradient fill uniform", () => + { + const highp = new Float32Array(64); + const mockShaderManager = { + highp: highp + } as unknown as ShaderManager; + + const matrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const inverseMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const points = new Float32Array([0, 0, 100, 0]); + + execute( + mockShaderManager, + 0, // type = linear + matrix, + inverseMatrix, + 0, + points + ); + + // Check viewport + expect(highp[3]).toBe(800); + expect(highp[7]).toBe(600); + + // Check linear points + expect(highp[20]).toBe(0); + expect(highp[21]).toBe(0); + expect(highp[22]).toBe(100); + expect(highp[23]).toBe(0); + }); + + it("test case - should set radial gradient fill uniform", () => + { + const highp = new Float32Array(64); + const mockShaderManager = { + highp: highp + } as unknown as ShaderManager; + + const matrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const inverseMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + execute( + mockShaderManager, + 1, // type = radial + matrix, + inverseMatrix, + 0.5 // focal_point_ratio + ); + + // Check radial point + expect(highp[20]).toBeCloseTo(819.2, 1); + }); + + it("test case - should handle focal point ratio", () => + { + const highp = new Float32Array(64); + const mockShaderManager = { + highp: highp + } as unknown as ShaderManager; + + const matrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const inverseMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + execute( + mockShaderManager, + 1, + matrix, + inverseMatrix, + 0.5 + ); + + // Should not throw + expect(highp[20]).toBeCloseTo(819.2, 1); + }); + + it("test case - should set gradient fill uniform with grid data", () => + { + const highp = new Float32Array(64); + const mockShaderManager = { + highp: highp + } as unknown as ShaderManager; + + const matrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const inverseMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const gridData = new Float32Array([ + 1, 0, 0, 1, 0, 0, // parent matrix (0-5) + 1, 0, 0, 1, 0, 0, // ancestor matrix (6-11) + 100, 100, 200, 200, // parent viewport (12-15) + 10, 20, 30, 40, // grid min (16-19) + 50, 60, 70, 80, // grid max (20-23) + 5, 10 // offset (24-25) + ]); + + execute( + mockShaderManager, + 1, + matrix, + inverseMatrix, + 0, + null, + gridData + ); + + // Check grid min (index 44-47 with grid data) + expect(highp[44]).toBe(10); + expect(highp[45]).toBe(20); + + // Check offset (index 52-53 with grid data) + expect(highp[52]).toBe(5); + expect(highp[53]).toBe(10); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetGradientFillUniformService.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetGradientFillUniformService.ts index dcdc966f..f9f2f8dc 100644 --- a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetGradientFillUniformService.ts +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetGradientFillUniformService.ts @@ -1,7 +1,7 @@ import type { ShaderManager } from "../../ShaderManager"; import { - $getViewportWidth, - $getViewportHeight, + $viewportWidth, + $viewportHeight, $clamp } from "../../../WebGLUtil"; @@ -59,8 +59,8 @@ export const execute = ( highp[19] = inverse_matrix[8]; // vertex: u_viewport - highp[3] = $getViewportWidth(); - highp[7] = $getViewportHeight(); + highp[3] = $viewportWidth; + highp[7] = $viewportHeight; let index = 20; if (grid_data) { diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMaskUniformService.test.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMaskUniformService.test.ts new file mode 100644 index 00000000..8c9cad7a --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMaskUniformService.test.ts @@ -0,0 +1,58 @@ +import { execute } from "./ShaderManagerSetMaskUniformService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $viewportWidth: 800, + $viewportHeight: 600 + }; +}); + +describe("ShaderManagerSetMaskUniformService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should set mask uniform variables", () => + { + const highp = new Float32Array(40); + const mockShaderManager = { + highp: highp + } as unknown as ShaderManager; + + const gridData = new Float32Array([ + 1, 0, 0, 1, 0, 0, // parent matrix (0-5) + 2, 0, 0, 2, 0, 0, // ancestor matrix (6-11) + 100, 100, 200, 200, // parent viewport (12-15) + 10, 20, 30, 40, // grid min (16-19) + 50, 60, 70, 80, // grid max (20-23) + 15, 25 // offset (24-25) + ]); + + execute(mockShaderManager, gridData); + + // Check parent matrix + expect(highp[0]).toBe(1); + expect(highp[1]).toBe(0); + expect(highp[4]).toBe(0); + expect(highp[5]).toBe(1); + + // Check ancestor matrix + expect(highp[12]).toBe(2); + expect(highp[13]).toBe(0); + expect(highp[16]).toBe(0); + expect(highp[17]).toBe(2); + + // Check viewport + expect(highp[3]).toBe(800); + expect(highp[7]).toBe(600); + + // Check offset + expect(highp[32]).toBe(15); + expect(highp[33]).toBe(25); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMaskUniformService.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMaskUniformService.ts index 2067b011..aa7a7de1 100644 --- a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMaskUniformService.ts +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMaskUniformService.ts @@ -1,7 +1,7 @@ import type { ShaderManager } from "../../ShaderManager"; import { - $getViewportWidth, - $getViewportHeight + $viewportWidth, + $viewportHeight } from "../../../WebGLUtil"; /** @@ -47,8 +47,8 @@ export const execute = ( highp[22] = 1; // vertex: u_viewport - highp[3] = $getViewportWidth(); - highp[7] = $getViewportHeight(); + highp[3] = $viewportWidth; + highp[7] = $viewportHeight; // vertex: u_parent_viewport highp[11] = grid_data[12]; diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMatrixTextureUniformService.test.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMatrixTextureUniformService.test.ts new file mode 100644 index 00000000..e94c63d5 --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMatrixTextureUniformService.test.ts @@ -0,0 +1,61 @@ +import { execute } from "./ShaderManagerSetMatrixTextureUniformService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $context: { + $matrix: new Float32Array([1, 0, 0, 0, 1, 0, 10, 20, 1]) + }, + $viewportWidth: 800, + $viewportHeight: 600 + }; +}); + +describe("ShaderManagerSetMatrixTextureUniformService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should set matrix texture uniform variables", () => + { + const highp = new Float32Array(16); + const mockShaderManager = { + highp: highp + } as unknown as ShaderManager; + + execute(mockShaderManager, 100, 100); + + // Check matrix components + expect(highp[0]).toBe(1); // a + expect(highp[1]).toBe(0); // b + expect(highp[2]).toBe(0); // c + expect(highp[3]).toBe(1); // d + expect(highp[4]).toBe(10); // tx + expect(highp[5]).toBe(20); // ty + + // Check size + expect(highp[6]).toBe(100); // width + expect(highp[7]).toBe(100); // height + + // Check viewport + expect(highp[8]).toBe(800); // viewport width + expect(highp[9]).toBe(600); // viewport height + }); + + it("test case - should handle different dimensions", () => + { + const highp = new Float32Array(16); + const mockShaderManager = { + highp: highp + } as unknown as ShaderManager; + + execute(mockShaderManager, 256, 512); + + expect(highp[6]).toBe(256); + expect(highp[7]).toBe(512); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMatrixTextureUniformService.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMatrixTextureUniformService.ts index 71542237..d5113d41 100644 --- a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMatrixTextureUniformService.ts +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMatrixTextureUniformService.ts @@ -1,8 +1,8 @@ import type { ShaderManager } from "../../ShaderManager"; import { $context, - $getViewportHeight, - $getViewportWidth + $viewportHeight, + $viewportWidth } from "../../../WebGLUtil"; /** @@ -37,6 +37,6 @@ export const execute = ( highp[7] = height; // vertex: u_viewport - highp[8] = $getViewportWidth(); - highp[9] = $getViewportHeight(); + highp[8] = $viewportWidth; + highp[9] = $viewportHeight; }; \ No newline at end of file diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMatrixTextureWithColorTransformUniformService.test.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMatrixTextureWithColorTransformUniformService.test.ts new file mode 100644 index 00000000..0bf09578 --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMatrixTextureWithColorTransformUniformService.test.ts @@ -0,0 +1,88 @@ +import { execute } from "./ShaderManagerSetMatrixTextureWithColorTransformUniformService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $context: { + $matrix: new Float32Array([1, 0, 0, 0, 1, 0, 10, 20, 1]) + }, + $viewportWidth: 800, + $viewportHeight: 600 + }; +}); + +describe("ShaderManagerSetMatrixTextureWithColorTransformUniformService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should set matrix texture with color transform uniform", () => + { + const highp = new Float32Array(16); + const mediump = new Float32Array(16); + const mockShaderManager = { + highp: highp, + mediump: mediump + } as unknown as ShaderManager; + + const colorTransform = new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]); + + execute(mockShaderManager, colorTransform, 100, 100); + + // Check matrix components + expect(highp[0]).toBe(1); // a + expect(highp[1]).toBe(0); // b + expect(highp[2]).toBe(0); // c + expect(highp[3]).toBe(1); // d + expect(highp[4]).toBe(10); // tx + expect(highp[5]).toBe(20); // ty + + // Check size + expect(highp[6]).toBe(100); + expect(highp[7]).toBe(100); + + // Check viewport + expect(highp[8]).toBe(800); + expect(highp[9]).toBe(600); + + // Check color transform + expect(mediump[0]).toBe(1); + expect(mediump[1]).toBe(1); + expect(mediump[2]).toBe(1); + expect(mediump[3]).toBe(1); + expect(mediump[4]).toBe(0); + expect(mediump[5]).toBe(0); + expect(mediump[6]).toBe(0); + expect(mediump[7]).toBe(0); + }); + + it("test case - should handle tinted color transform", () => + { + const highp = new Float32Array(16); + const mediump = new Float32Array(16); + const mockShaderManager = { + highp: highp, + mediump: mediump + } as unknown as ShaderManager; + + const colorTransform = new Float32Array([0.5, 0.5, 0.5, 1, 0.5, 0, 0, 0]); + + execute(mockShaderManager, colorTransform, 200, 150); + + // Check color transform multiply + expect(mediump[0]).toBe(0.5); + expect(mediump[1]).toBe(0.5); + expect(mediump[2]).toBe(0.5); + expect(mediump[3]).toBe(1); + + // Check color transform add + expect(mediump[4]).toBe(0.5); + expect(mediump[5]).toBe(0); + expect(mediump[6]).toBe(0); + expect(mediump[7]).toBe(0); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMatrixTextureWithColorTransformUniformService.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMatrixTextureWithColorTransformUniformService.ts index 5f927dac..278c260e 100644 --- a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMatrixTextureWithColorTransformUniformService.ts +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetMatrixTextureWithColorTransformUniformService.ts @@ -1,8 +1,8 @@ import type { ShaderManager } from "../../ShaderManager"; import { $context, - $getViewportHeight, - $getViewportWidth + $viewportHeight, + $viewportWidth } from "../../../WebGLUtil"; /** @@ -38,8 +38,8 @@ export const execute = ( highp[7] = height; // vertex: u_viewport - highp[8] = $getViewportWidth(); - highp[9] = $getViewportHeight(); + highp[8] = $viewportWidth; + highp[9] = $viewportHeight; const mediump: Int32Array | Float32Array = shader_manager.mediump; mediump[0] = color_transform[0]; diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetTextureUniformService.test.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetTextureUniformService.test.ts new file mode 100644 index 00000000..b2c4fa41 --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetTextureUniformService.test.ts @@ -0,0 +1,51 @@ +import { execute } from "./ShaderManagerSetTextureUniformService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $context: { + $matrix: new Float32Array([1, 0, 0, 1, 10, 20, 0, 0, 1]) + }, + $viewportWidth: 800, + $viewportHeight: 600 + }; +}); + +describe("ShaderManagerSetTextureUniformService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should set texture uniform variables", () => + { + const highp = new Float32Array(16); + const mockShaderManager = { + highp: highp + } as unknown as ShaderManager; + + execute(mockShaderManager, 100, 100); + + // Check size + expect(highp[2]).toBe(100); // width + expect(highp[3]).toBe(100); // height + expect(highp[4]).toBe(800); // viewport width + expect(highp[5]).toBe(600); // viewport height + }); + + it("test case - should handle different dimensions", () => + { + const highp = new Float32Array(16); + const mockShaderManager = { + highp: highp + } as unknown as ShaderManager; + + execute(mockShaderManager, 256, 512); + + expect(highp[2]).toBe(256); + expect(highp[3]).toBe(512); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetTextureUniformService.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetTextureUniformService.ts index db47b24e..c279fb97 100644 --- a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetTextureUniformService.ts +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerSetTextureUniformService.ts @@ -1,8 +1,8 @@ import type { ShaderManager } from "../../ShaderManager"; import { $context, - $getViewportHeight, - $getViewportWidth + $viewportHeight, + $viewportWidth } from "../../../WebGLUtil"; /** @@ -30,6 +30,6 @@ export const execute = (shader_manager: ShaderManager, width: number, height: nu highp[3] = height; // vertex: u_viewport - highp[4] = $getViewportWidth(); - highp[5] = $getViewportHeight(); + highp[4] = $viewportWidth; + highp[5] = $viewportHeight; }; \ No newline at end of file diff --git a/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerUseProgramService.test.ts b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerUseProgramService.test.ts new file mode 100644 index 00000000..e4bc6e6f --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/service/ShaderManagerUseProgramService.test.ts @@ -0,0 +1,58 @@ +import { execute } from "./ShaderManagerUseProgramService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { IProgramObject } from "../../../interface/IProgramObject"; + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $gl: { + useProgram: vi.fn() + } + }; +}); + +describe("ShaderManagerUseProgramService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should use program when program id is different", () => + { + const mockProgramObject: IProgramObject = { + id: 100, + resource: {} as WebGLProgram + }; + + expect(() => execute(mockProgramObject)).not.toThrow(); + }); + + it("test case - should not throw when same program id", () => + { + const mockProgramObject: IProgramObject = { + id: 101, + resource: {} as WebGLProgram + }; + + execute(mockProgramObject); + expect(() => execute(mockProgramObject)).not.toThrow(); + }); + + it("test case - should switch to different program", () => + { + const mockProgramObject1: IProgramObject = { + id: 102, + resource: { name: "program1" } as unknown as WebGLProgram + }; + const mockProgramObject2: IProgramObject = { + id: 103, + resource: { name: "program2" } as unknown as WebGLProgram + }; + + expect(() => { + execute(mockProgramObject1); + execute(mockProgramObject2); + }).not.toThrow(); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager/usecase/ShaderManagerDrawTextureUseCase.test.ts b/packages/webgl/src/Shader/ShaderManager/usecase/ShaderManagerDrawTextureUseCase.test.ts new file mode 100644 index 00000000..df4a20b3 --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/usecase/ShaderManagerDrawTextureUseCase.test.ts @@ -0,0 +1,72 @@ +import { execute } from "./ShaderManagerDrawTextureUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; + +const mockVao = { id: "rect-vao" }; + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $gl: { + TRIANGLES: 4, + drawArrays: vi.fn() + } + }; +}); + +vi.mock("../../../VertexArrayObject/service/VertexArrayObjectBindService.ts", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../VertexArrayObject.ts", () => ({ + $getRectVertexArrayObject: () => ({ id: "rect-vao" }) +})); + +import { $gl } from "../../../WebGLUtil"; +import { execute as bindService } from "../../../VertexArrayObject/service/VertexArrayObjectBindService"; + +describe("ShaderManagerDrawTextureUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should execute draw texture usecase", () => + { + const mockUseProgram = vi.fn(); + const mockBindUniform = vi.fn(); + const mockShaderManager = { + useProgram: mockUseProgram, + bindUniform: mockBindUniform + } as unknown as ShaderManager; + + execute(mockShaderManager); + + expect(mockUseProgram).toHaveBeenCalledTimes(1); + expect(mockBindUniform).toHaveBeenCalledTimes(1); + expect(bindService).toHaveBeenCalled(); + expect($gl.drawArrays).toHaveBeenCalledWith(4, 0, 6); + }); + + it("test case - should call operations in correct order", () => + { + const callOrder: string[] = []; + const mockShaderManager = { + useProgram: vi.fn(() => callOrder.push("useProgram")), + bindUniform: vi.fn(() => callOrder.push("bindUniform")) + } as unknown as ShaderManager; + + vi.mocked(bindService).mockImplementation(() => callOrder.push("bindVao")); + vi.mocked($gl.drawArrays).mockImplementation(() => callOrder.push("drawArrays")); + + execute(mockShaderManager); + + expect(callOrder).toEqual([ + "useProgram", + "bindUniform", + "bindVao", + "drawArrays" + ]); + }); +}); diff --git a/packages/webgl/src/Shader/ShaderManager/usecase/ShaderManagerFillUseCase.test.ts b/packages/webgl/src/Shader/ShaderManager/usecase/ShaderManagerFillUseCase.test.ts new file mode 100644 index 00000000..0d31dd0f --- /dev/null +++ b/packages/webgl/src/Shader/ShaderManager/usecase/ShaderManagerFillUseCase.test.ts @@ -0,0 +1,93 @@ +import { execute } from "./ShaderManagerFillUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ShaderManager } from "../../ShaderManager"; +import type { IVertexArrayObject } from "../../../interface/IVertexArrayObject"; + +vi.mock("../../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $gl: { + TRIANGLES: 4, + drawArrays: vi.fn() + } + }; +}); + +vi.mock("../../../VertexArrayObject/service/VertexArrayObjectBindService.ts", () => ({ + execute: vi.fn() +})); + +vi.mock("../../../Blend/service/BlendResetService.ts", () => ({ + execute: vi.fn() +})); + +import { $gl } from "../../../WebGLUtil"; +import { execute as bindService } from "../../../VertexArrayObject/service/VertexArrayObjectBindService"; +import { execute as blendResetService } from "../../../Blend/service/BlendResetService"; + +describe("ShaderManagerFillUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should execute fill usecase", () => + { + const mockUseProgram = vi.fn(); + const mockBindUniform = vi.fn(); + const mockShaderManager = { + useProgram: mockUseProgram, + bindUniform: mockBindUniform + } as unknown as ShaderManager; + + const mockVao = { id: "test-vao" } as unknown as IVertexArrayObject; + + execute(mockShaderManager, mockVao, 0, 12); + + expect(mockUseProgram).toHaveBeenCalledTimes(1); + expect(mockBindUniform).toHaveBeenCalledTimes(1); + expect(blendResetService).toHaveBeenCalledTimes(1); + expect(bindService).toHaveBeenCalledWith(mockVao); + expect($gl.drawArrays).toHaveBeenCalledWith(4, 0, 12); + }); + + it("test case - should handle offset and count parameters", () => + { + const mockShaderManager = { + useProgram: vi.fn(), + bindUniform: vi.fn() + } as unknown as ShaderManager; + + const mockVao = { id: "test-vao" } as unknown as IVertexArrayObject; + + execute(mockShaderManager, mockVao, 6, 18); + + expect($gl.drawArrays).toHaveBeenCalledWith(4, 6, 18); + }); + + it("test case - should call operations in correct order", () => + { + const callOrder: string[] = []; + const mockShaderManager = { + useProgram: vi.fn(() => callOrder.push("useProgram")), + bindUniform: vi.fn(() => callOrder.push("bindUniform")) + } as unknown as ShaderManager; + + const mockVao = { id: "test-vao" } as unknown as IVertexArrayObject; + + vi.mocked(blendResetService).mockImplementation(() => callOrder.push("blendReset")); + vi.mocked(bindService).mockImplementation(() => callOrder.push("bindVao")); + vi.mocked($gl.drawArrays).mockImplementation(() => callOrder.push("drawArrays")); + + execute(mockShaderManager, mockVao, 0, 6); + + expect(callOrder).toEqual([ + "useProgram", + "bindUniform", + "blendReset", + "bindVao", + "drawArrays" + ]); + }); +}); diff --git a/packages/webgl/src/Shader/Variants/Bitmap/service/VariantsBitmapShaderService.test.ts b/packages/webgl/src/Shader/Variants/Bitmap/service/VariantsBitmapShaderService.test.ts new file mode 100644 index 00000000..f78ae3e7 --- /dev/null +++ b/packages/webgl/src/Shader/Variants/Bitmap/service/VariantsBitmapShaderService.test.ts @@ -0,0 +1,65 @@ +import { execute } from "./VariantsBitmapShaderService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../../BitmapVariants.ts", () => ({ + $collection: new Map() +})); + +vi.mock("../../../ShaderManager.ts", () => { + const MockShaderManager = vi.fn(function(this: { id: string }) { + this.id = "mock-shader"; + }); + return { ShaderManager: MockShaderManager }; +}); + +vi.mock("../../../Vertex/VertexShaderSourceFill.ts", () => ({ + FILL_TEMPLATE: vi.fn(() => "vertex-source") +})); + +vi.mock("../../../Fragment/FragmentShaderSource.ts", () => ({ + BITMAP_PATTERN: vi.fn(() => "pattern-source"), + BITMAP_CLIPPED: vi.fn(() => "clipped-source") +})); + +import { $collection } from "../../BitmapVariants"; +import { ShaderManager } from "../../../ShaderManager"; + +describe("VariantsBitmapShaderService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + $collection.clear(); + }); + + it("test case - should create bitmap shader with repeat", () => + { + const result = execute(true, false); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should create bitmap shader without repeat", () => + { + const result = execute(false, false); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should cache shader", () => + { + execute(true, false); + execute(true, false); + + expect(ShaderManager).toHaveBeenCalledTimes(1); + }); + + it("test case - should create different shaders for different params", () => + { + execute(true, false); + execute(false, false); + + expect(ShaderManager).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/webgl/src/Shader/Variants/Blend/service/VariantsBlendDrawShaderService.test.ts b/packages/webgl/src/Shader/Variants/Blend/service/VariantsBlendDrawShaderService.test.ts new file mode 100644 index 00000000..c76fb606 --- /dev/null +++ b/packages/webgl/src/Shader/Variants/Blend/service/VariantsBlendDrawShaderService.test.ts @@ -0,0 +1,56 @@ +import { execute } from "./VariantsBlendDrawShaderService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../../BlendVariants.ts", () => ({ + $collection: new Map() +})); + +vi.mock("../../../ShaderManager.ts", () => { + const MockShaderManager = vi.fn(function(this: { id: string }) { + this.id = "mock-shader"; + }); + return { ShaderManager: MockShaderManager }; +}); + +vi.mock("../../../Vertex/VertexShaderSource.ts", () => ({ + TEXTURE_TEMPLATE: vi.fn(() => "vertex-source") +})); + +vi.mock("../../../Fragment/FragmentShaderSourceBlend.ts", () => ({ + BLEND_TEMPLATE: vi.fn(() => "blend-source") +})); + +import { $collection } from "../../BlendVariants"; +import { ShaderManager } from "../../../ShaderManager"; + +describe("VariantsBlendDrawShaderService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + $collection.clear(); + }); + + it("test case - should create blend shader", () => + { + const result = execute("normal", false); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should create blend shader with color transform", () => + { + const result = execute("multiply", true); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should cache shader", () => + { + execute("normal", false); + execute("normal", false); + + expect(ShaderManager).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/webgl/src/Shader/Variants/Blend/service/VariantsBlendInstanceShaderService.test.ts b/packages/webgl/src/Shader/Variants/Blend/service/VariantsBlendInstanceShaderService.test.ts new file mode 100644 index 00000000..62bca7ff --- /dev/null +++ b/packages/webgl/src/Shader/Variants/Blend/service/VariantsBlendInstanceShaderService.test.ts @@ -0,0 +1,48 @@ +import { execute } from "./VariantsBlendInstanceShaderService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../../BlendVariants.ts", () => ({ + $collection: new Map() +})); + +vi.mock("../../../ShaderInstancedManager.ts", () => { + const MockShaderInstancedManager = vi.fn(function(this: { id: string }) { + this.id = "mock-instanced-shader"; + }); + return { ShaderInstancedManager: MockShaderInstancedManager }; +}); + +vi.mock("../../../Vertex/VertexShaderSource.ts", () => ({ + INSTANCE_TEMPLATE: vi.fn(() => "instance-vertex-source") +})); + +vi.mock("../../../Fragment/FragmentShaderSourceTexture.ts", () => ({ + INSTANCE_TEXTURE: vi.fn(() => "instance-texture-source") +})); + +import { $collection } from "../../BlendVariants"; +import { ShaderInstancedManager } from "../../../ShaderInstancedManager"; + +describe("VariantsBlendInstanceShaderService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + $collection.clear(); + }); + + it("test case - should create instanced shader", () => + { + const result = execute(); + + expect(ShaderInstancedManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should cache shader", () => + { + execute(); + execute(); + + expect(ShaderInstancedManager).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/webgl/src/Shader/Variants/Blend/service/VariantsBlendMatrixTextureShaderService.test.ts b/packages/webgl/src/Shader/Variants/Blend/service/VariantsBlendMatrixTextureShaderService.test.ts new file mode 100644 index 00000000..1c1d7e0f --- /dev/null +++ b/packages/webgl/src/Shader/Variants/Blend/service/VariantsBlendMatrixTextureShaderService.test.ts @@ -0,0 +1,64 @@ +import { execute } from "./VariantsBlendMatrixTextureShaderService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../../BlendVariants.ts", () => ({ + $collection: new Map() +})); + +vi.mock("../../../ShaderManager.ts", () => { + const MockShaderManager = vi.fn(function(this: { id: string }) { + this.id = "mock-shader"; + }); + return { ShaderManager: MockShaderManager }; +}); + +vi.mock("../../../Vertex/VertexShaderSource.ts", () => ({ + BLEND_MATRIX_TEMPLATE: vi.fn(() => "matrix-vertex-source") +})); + +vi.mock("../../../Fragment/FragmentShaderSourceTexture.ts", () => ({ + TEXTURE: vi.fn(() => "texture-source") +})); + +import { $collection } from "../../BlendVariants"; +import { ShaderManager } from "../../../ShaderManager"; + +describe("VariantsBlendMatrixTextureShaderService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + $collection.clear(); + }); + + it("test case - should create matrix texture shader", () => + { + const result = execute(); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should create shader with color transform", () => + { + const result = execute(true); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should cache shader", () => + { + execute(false); + execute(false); + + expect(ShaderManager).toHaveBeenCalledTimes(1); + }); + + it("test case - should create different shaders for different params", () => + { + execute(false); + execute(true); + + expect(ShaderManager).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/webgl/src/Shader/Variants/Blend/service/VariantsBlendTextureShaderService.test.ts b/packages/webgl/src/Shader/Variants/Blend/service/VariantsBlendTextureShaderService.test.ts new file mode 100644 index 00000000..61badd66 --- /dev/null +++ b/packages/webgl/src/Shader/Variants/Blend/service/VariantsBlendTextureShaderService.test.ts @@ -0,0 +1,48 @@ +import { execute } from "./VariantsBlendTextureShaderService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../../BlendVariants.ts", () => ({ + $collection: new Map() +})); + +vi.mock("../../../ShaderManager.ts", () => { + const MockShaderManager = vi.fn(function(this: { id: string }) { + this.id = "mock-shader"; + }); + return { ShaderManager: MockShaderManager }; +}); + +vi.mock("../../../Vertex/VertexShaderSource.ts", () => ({ + BLEND_TEMPLATE: vi.fn(() => "blend-vertex-source") +})); + +vi.mock("../../../Fragment/FragmentShaderSourceTexture.ts", () => ({ + TEXTURE: vi.fn(() => "texture-source") +})); + +import { $collection } from "../../BlendVariants"; +import { ShaderManager } from "../../../ShaderManager"; + +describe("VariantsBlendTextureShaderService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + $collection.clear(); + }); + + it("test case - should create texture shader", () => + { + const result = execute(); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should cache shader", () => + { + execute(); + execute(); + + expect(ShaderManager).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/webgl/src/Shader/Variants/Filter/service/VariantsBitmapFilterShaderService.test.ts b/packages/webgl/src/Shader/Variants/Filter/service/VariantsBitmapFilterShaderService.test.ts new file mode 100644 index 00000000..d4577366 --- /dev/null +++ b/packages/webgl/src/Shader/Variants/Filter/service/VariantsBitmapFilterShaderService.test.ts @@ -0,0 +1,74 @@ +import { execute } from "./VariantsBitmapFilterShaderService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../../FilterVariants.ts", () => ({ + $collection: new Map() +})); + +vi.mock("../../../ShaderManager.ts", () => { + const MockShaderManager = vi.fn(function(this: { id: string }) { + this.id = "mock-shader"; + }); + return { ShaderManager: MockShaderManager }; +}); + +vi.mock("../../../Vertex/VertexShaderSource.ts", () => ({ + TEXTURE_TEMPLATE: vi.fn(() => "texture-vertex-source") +})); + +vi.mock("../../../Fragment/Filter/FragmentShaderSourceFilter.ts", () => ({ + BITMAP_FILTER_TEMPLATE: vi.fn(() => "bitmap-filter-source") +})); + +import { $collection } from "../../FilterVariants"; +import { ShaderManager } from "../../../ShaderManager"; + +describe("VariantsBitmapFilterShaderService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + $collection.clear(); + }); + + it("test case - should create bitmap filter shader", () => + { + const result = execute( + true, // transforms_base + true, // transforms_blur + false, // is_glow + "i", // type + false, // knockout + true, // applies_strength + false // is_gradient + ); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should create glow filter shader", () => + { + const result = execute( + false, false, true, "o", false, true, false + ); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should cache shader", () => + { + execute(true, true, false, "i", false, true, false); + execute(true, true, false, "i", false, true, false); + + expect(ShaderManager).toHaveBeenCalledTimes(1); + }); + + it("test case - should create different shaders for different params", () => + { + execute(true, true, false, "i", false, true, false); + execute(false, false, true, "o", true, false, true); + + expect(ShaderManager).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/webgl/src/Shader/Variants/Filter/service/VariantsBitmapFilterShaderService.ts b/packages/webgl/src/Shader/Variants/Filter/service/VariantsBitmapFilterShaderService.ts index 88038fea..0da7f8d8 100644 --- a/packages/webgl/src/Shader/Variants/Filter/service/VariantsBitmapFilterShaderService.ts +++ b/packages/webgl/src/Shader/Variants/Filter/service/VariantsBitmapFilterShaderService.ts @@ -34,7 +34,8 @@ export const execute = ( const key3 = is_glow ? "y" : "n"; const key4 = knockout ? "y" : "n"; const key5 = applies_strength ? "y" : "n"; - const key = `f${key1}${key2}${key3}${type}${key4}${key5}`; + const key6 = is_gradient ? "y" : "n"; + const key = `f${key1}${key2}${key3}${type}${key4}${key5}${key6}`; if ($collection.has(key)) { return $collection.get(key) as NonNullable; diff --git a/packages/webgl/src/Shader/Variants/Filter/service/VariantsBlurFilterShaderService.test.ts b/packages/webgl/src/Shader/Variants/Filter/service/VariantsBlurFilterShaderService.test.ts new file mode 100644 index 00000000..89e1a23c --- /dev/null +++ b/packages/webgl/src/Shader/Variants/Filter/service/VariantsBlurFilterShaderService.test.ts @@ -0,0 +1,57 @@ +import { execute } from "./VariantsBlurFilterShaderService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../../FilterVariants.ts", () => ({ + $collection: new Map() +})); + +vi.mock("../../../ShaderManager.ts", () => { + const MockShaderManager = vi.fn(function(this: { id: string }) { + this.id = "mock-shader"; + }); + return { ShaderManager: MockShaderManager }; +}); + +vi.mock("../../../Vertex/VertexShaderSource.ts", () => ({ + TEXTURE_TEMPLATE: vi.fn(() => "texture-vertex-source") +})); + +vi.mock("../../../Fragment/Filter/FragmentShaderSourceBlurFilter.ts", () => ({ + BLUR_FILTER_TEMPLATE: vi.fn(() => "blur-filter-source") +})); + +import { $collection } from "../../FilterVariants"; +import { ShaderManager } from "../../../ShaderManager"; + +describe("VariantsBlurFilterShaderService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + $collection.clear(); + }); + + it("test case - should create blur filter shader", () => + { + const result = execute(4); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should cache shader", () => + { + execute(4); + execute(4); + + expect(ShaderManager).toHaveBeenCalledTimes(1); + }); + + it("test case - should create different shaders for different blur sizes", () => + { + execute(4); + execute(8); + execute(16); + + expect(ShaderManager).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/webgl/src/Shader/Variants/Filter/service/VariantsColorMatrixFilterShaderService.test.ts b/packages/webgl/src/Shader/Variants/Filter/service/VariantsColorMatrixFilterShaderService.test.ts new file mode 100644 index 00000000..e1b65198 --- /dev/null +++ b/packages/webgl/src/Shader/Variants/Filter/service/VariantsColorMatrixFilterShaderService.test.ts @@ -0,0 +1,48 @@ +import { execute } from "./VariantsColorMatrixFilterShaderService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../../FilterVariants.ts", () => ({ + $collection: new Map() +})); + +vi.mock("../../../ShaderManager.ts", () => { + const MockShaderManager = vi.fn(function(this: { id: string }) { + this.id = "mock-shader"; + }); + return { ShaderManager: MockShaderManager }; +}); + +vi.mock("../../../Vertex/VertexShaderSource.ts", () => ({ + TEXTURE_TEMPLATE: vi.fn(() => "texture-vertex-source") +})); + +vi.mock("../../../Fragment/Filter/FragmentShaderSourceColorMatrixFilter.ts", () => ({ + COLOR_MATRIX_FILTER_TEMPLATE: vi.fn(() => "color-matrix-filter-source") +})); + +import { $collection } from "../../FilterVariants"; +import { ShaderManager } from "../../../ShaderManager"; + +describe("VariantsColorMatrixFilterShaderService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + $collection.clear(); + }); + + it("test case - should create color matrix filter shader", () => + { + const result = execute(); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should cache shader", () => + { + execute(); + execute(); + + expect(ShaderManager).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/webgl/src/Shader/Variants/Filter/service/VariantsConvolutionFilterShaderService.test.ts b/packages/webgl/src/Shader/Variants/Filter/service/VariantsConvolutionFilterShaderService.test.ts new file mode 100644 index 00000000..57b4f1a9 --- /dev/null +++ b/packages/webgl/src/Shader/Variants/Filter/service/VariantsConvolutionFilterShaderService.test.ts @@ -0,0 +1,64 @@ +import { execute } from "./VariantsConvolutionFilterShaderService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../../FilterVariants.ts", () => ({ + $collection: new Map() +})); + +vi.mock("../../../ShaderManager.ts", () => { + const MockShaderManager = vi.fn(function(this: { id: string }) { + this.id = "mock-shader"; + }); + return { ShaderManager: MockShaderManager }; +}); + +vi.mock("../../../Vertex/VertexShaderSource.ts", () => ({ + TEXTURE_TEMPLATE: vi.fn(() => "texture-vertex-source") +})); + +vi.mock("../../../Fragment/Filter/FragmentShaderSourceConvolutionFilter.ts", () => ({ + CONVOLUTION_FILTER_TEMPLATE: vi.fn(() => "convolution-filter-source") +})); + +import { $collection } from "../../FilterVariants"; +import { ShaderManager } from "../../../ShaderManager"; + +describe("VariantsConvolutionFilterShaderService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + $collection.clear(); + }); + + it("test case - should create convolution filter shader", () => + { + const result = execute(3, 3, true, true); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should create shader without preserve alpha", () => + { + const result = execute(5, 5, false, false); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should cache shader", () => + { + execute(3, 3, true, true); + execute(3, 3, true, true); + + expect(ShaderManager).toHaveBeenCalledTimes(1); + }); + + it("test case - should create different shaders for different params", () => + { + execute(3, 3, true, true); + execute(5, 5, false, false); + + expect(ShaderManager).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/webgl/src/Shader/Variants/Filter/service/VariantsDisplacementMapFilterShaderService.test.ts b/packages/webgl/src/Shader/Variants/Filter/service/VariantsDisplacementMapFilterShaderService.test.ts new file mode 100644 index 00000000..dfe59741 --- /dev/null +++ b/packages/webgl/src/Shader/Variants/Filter/service/VariantsDisplacementMapFilterShaderService.test.ts @@ -0,0 +1,64 @@ +import { execute } from "./VariantsDisplacementMapFilterShaderService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../../FilterVariants.ts", () => ({ + $collection: new Map() +})); + +vi.mock("../../../ShaderManager.ts", () => { + const MockShaderManager = vi.fn(function(this: { id: string }) { + this.id = "mock-shader"; + }); + return { ShaderManager: MockShaderManager }; +}); + +vi.mock("../../../Vertex/VertexShaderSource.ts", () => ({ + TEXTURE_TEMPLATE: vi.fn(() => "texture-vertex-source") +})); + +vi.mock("../../../Fragment/Filter/FragmentShaderSourceDisplacementMapFilter.ts", () => ({ + DISPLACEMENT_MAP_FILTER_TEMPLATE: vi.fn(() => "displacement-filter-source") +})); + +import { $collection } from "../../FilterVariants"; +import { ShaderManager } from "../../../ShaderManager"; + +describe("VariantsDisplacementMapFilterShaderService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + $collection.clear(); + }); + + it("test case - should create displacement map filter shader", () => + { + const result = execute(1, 2, 0); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should create shader with color mode", () => + { + const result = execute(1, 2, 1); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should cache shader", () => + { + execute(1, 2, 0); + execute(1, 2, 0); + + expect(ShaderManager).toHaveBeenCalledTimes(1); + }); + + it("test case - should create different shaders for different params", () => + { + execute(1, 2, 0); + execute(3, 4, 1); + + expect(ShaderManager).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/webgl/src/Shader/Variants/Gradient/usecase/VariantsGradientShapeShaderUseCase.test.ts b/packages/webgl/src/Shader/Variants/Gradient/usecase/VariantsGradientShapeShaderUseCase.test.ts new file mode 100644 index 00000000..cea3877d --- /dev/null +++ b/packages/webgl/src/Shader/Variants/Gradient/usecase/VariantsGradientShapeShaderUseCase.test.ts @@ -0,0 +1,77 @@ +import { execute } from "./VariantsGradientShapeShaderUseCase"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../../GradientVariants.ts", () => ({ + $collection: new Map() +})); + +vi.mock("../../../ShaderManager.ts", () => { + const MockShaderManager = vi.fn(function(this: { id: string }) { + this.id = "mock-shader"; + }); + return { ShaderManager: MockShaderManager }; +}); + +vi.mock("../service/VariantsGradientCreateCollectionKeyService.ts", () => ({ + execute: vi.fn((useGrid, isRadial, hasFocal, spread) => + `g${useGrid ? "y" : "n"}${isRadial ? "r" : "l"}${hasFocal ? "f" : "n"}${spread}`) +})); + +vi.mock("../../../Vertex/VertexShaderSourceFill.ts", () => ({ + FILL_TEMPLATE: vi.fn(() => "fill-vertex-source") +})); + +vi.mock("../../../Fragment/FragmentShaderSourceGradient.ts", () => ({ + GRADIENT_TEMPLATE: vi.fn(() => "gradient-source") +})); + +import { $collection } from "../../GradientVariants"; +import { ShaderManager } from "../../../ShaderManager"; + +describe("VariantsGradientShapeShaderUseCase.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + $collection.clear(); + }); + + it("test case - should create linear gradient shader", () => + { + const result = execute(false, false, 0, false); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should create radial gradient shader", () => + { + const result = execute(true, false, 0, false); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should create radial gradient shader with focal point", () => + { + const result = execute(true, true, 0, false); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should cache shader", () => + { + execute(false, false, 0, false); + execute(false, false, 0, false); + + expect(ShaderManager).toHaveBeenCalledTimes(1); + }); + + it("test case - should create different shaders for different params", () => + { + execute(false, false, 0, false); + execute(true, true, 1, true); + + expect(ShaderManager).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/webgl/src/Shader/Variants/GradientLUT/service/VariantsGradientLUTShaderService.test.ts b/packages/webgl/src/Shader/Variants/GradientLUT/service/VariantsGradientLUTShaderService.test.ts new file mode 100644 index 00000000..101155c2 --- /dev/null +++ b/packages/webgl/src/Shader/Variants/GradientLUT/service/VariantsGradientLUTShaderService.test.ts @@ -0,0 +1,60 @@ +import { execute } from "./VariantsGradientLUTShaderService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../../GradientLUTVariants.ts", () => { + const cache = new Map(); + return { + $getFromCache: vi.fn((key: string) => cache.get(key)), + $addToCache: vi.fn((key: string, shader: unknown) => cache.set(key, shader)) + }; +}); + +vi.mock("../../../ShaderManager.ts", () => { + const MockShaderManager = vi.fn(function(this: { id: string }) { + this.id = "mock-shader"; + }); + return { ShaderManager: MockShaderManager }; +}); + +vi.mock("../../../Vertex/VertexShaderSource.ts", () => ({ + TEXTURE_TEMPLATE: vi.fn(() => "texture-vertex-source") +})); + +vi.mock("../../../Fragment/FragmentShaderSourceGradientLUT.ts", () => ({ + GRADIENT_LUT_TEMPLATE: vi.fn(() => "gradient-lut-source") +})); + +import { $getFromCache, $addToCache } from "../../GradientLUTVariants"; +import { ShaderManager } from "../../../ShaderManager"; + +describe("VariantsGradientLUTShaderService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should create gradient LUT shader", () => + { + const result = execute(4, false); + + expect(ShaderManager).toHaveBeenCalled(); + expect($addToCache).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should create shader with linear space", () => + { + const result = execute(8, true); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should use cached shader", () => + { + execute(4, false); + execute(4, false); + + expect($getFromCache).toHaveBeenCalled(); + }); +}); diff --git a/packages/webgl/src/Shader/Variants/GradientLUT/service/VariantsGradientLUTShaderService.ts b/packages/webgl/src/Shader/Variants/GradientLUT/service/VariantsGradientLUTShaderService.ts index d6429127..47e362d3 100644 --- a/packages/webgl/src/Shader/Variants/GradientLUT/service/VariantsGradientLUTShaderService.ts +++ b/packages/webgl/src/Shader/Variants/GradientLUT/service/VariantsGradientLUTShaderService.ts @@ -1,11 +1,14 @@ -import { $collection } from "../../GradientLUTVariants"; +import { + $getFromCache, + $addToCache +} from "../../GradientLUTVariants"; import { ShaderManager } from "../../../ShaderManager"; import { TEXTURE_TEMPLATE } from "../../../Vertex/VertexShaderSource"; import { GRADIENT_LUT_TEMPLATE } from "../../../Fragment/FragmentShaderSourceGradientLUT"; /** - * @description グラデーションLUTのシェーダーを返却 - * Returns the shader of the gradient LUT + * @description グラデーションLUTのシェーダーを返却(LRUキャッシュ使用) + * Returns the shader of the gradient LUT (using LRU cache) * * @param {number} stops_length * @param {boolean} is_linear_space @@ -22,8 +25,10 @@ export const execute = ( const key2 = is_linear_space ? "y" : "n"; const key = `l${key1}${key2}`; - if ($collection.has(key)) { - return $collection.get(key) as NonNullable; + // LRUキャッシュから取得を試みる + const cachedShader = $getFromCache(key); + if (cachedShader) { + return cachedShader; } const mediumpLength: number = Math.ceil(stops_length * 5 / 4); @@ -33,7 +38,8 @@ export const execute = ( GRADIENT_LUT_TEMPLATE(mediumpLength, stops_length, is_linear_space) ); - $collection.set(key, shader); + // LRUキャッシュに追加 + $addToCache(key, shader); return shader; }; \ No newline at end of file diff --git a/packages/webgl/src/Shader/Variants/GradientLUTVariants.ts b/packages/webgl/src/Shader/Variants/GradientLUTVariants.ts index 0dc2f5c9..d8249ef6 100644 --- a/packages/webgl/src/Shader/Variants/GradientLUTVariants.ts +++ b/packages/webgl/src/Shader/Variants/GradientLUTVariants.ts @@ -1,5 +1,14 @@ import type { ShaderManager } from "../ShaderManager"; +/** + * @description シェーダーキャッシュの最大サイズ + * Maximum shader cache size + * + * @type {number} + * @const + */ +const MAX_SHADER_CACHE_SIZE: number = 16; + /** * @description グラデーションLUTのシェーダー管理クラスのコレクション * Collection of gradient LUT shader management classes @@ -7,4 +16,66 @@ import type { ShaderManager } from "../ShaderManager"; * @type {Map} * @public */ -export const $collection: Map = new Map(); \ No newline at end of file +export const $collection: Map = new Map(); + +/** + * @description 使用順序を追跡するためのキュー + * Queue for tracking usage order (LRU) + * + * @type {string[]} + * @private + */ +const $usageOrder: string[] = []; + +/** + * @description シェーダーをキャッシュに追加(LRU方式) + * Add shader to cache (LRU method) + * + * @param {string} key + * @param {ShaderManager} shader + * @return {void} + * @method + * @protected + */ +export const $addToCache = (key: string, shader: ShaderManager): void => +{ + // すでに存在する場合は使用順序を更新 + const existingIndex = $usageOrder.indexOf(key); + if (existingIndex !== -1) { + $usageOrder.splice(existingIndex, 1); + } + + // キャッシュサイズ制限を超える場合、最も古いエントリを削除 + while ($collection.size >= MAX_SHADER_CACHE_SIZE && $usageOrder.length > 0) { + const oldestKey = $usageOrder.shift(); + if (oldestKey) { + $collection.delete(oldestKey); + } + } + + $collection.set(key, shader); + $usageOrder.push(key); +}; + +/** + * @description キャッシュからシェーダーを取得(使用順序を更新) + * Get shader from cache (update usage order) + * + * @param {string} key + * @return {ShaderManager | undefined} + * @method + * @protected + */ +export const $getFromCache = (key: string): ShaderManager | undefined => +{ + const shader = $collection.get(key); + if (shader) { + // 使用順序を更新 + const index = $usageOrder.indexOf(key); + if (index !== -1) { + $usageOrder.splice(index, 1); + $usageOrder.push(key); + } + } + return shader; +}; \ No newline at end of file diff --git a/packages/webgl/src/Shader/Variants/Shape/service/VariantsShapeMaskShaderService.test.ts b/packages/webgl/src/Shader/Variants/Shape/service/VariantsShapeMaskShaderService.test.ts new file mode 100644 index 00000000..2da81d41 --- /dev/null +++ b/packages/webgl/src/Shader/Variants/Shape/service/VariantsShapeMaskShaderService.test.ts @@ -0,0 +1,64 @@ +import { execute } from "./VariantsShapeMaskShaderService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../../ShapeVariants.ts", () => ({ + $collection: new Map() +})); + +vi.mock("../../../ShaderManager.ts", () => { + const MockShaderManager = vi.fn(function(this: { id: string }) { + this.id = "mock-shader"; + }); + return { ShaderManager: MockShaderManager }; +}); + +vi.mock("../../../Vertex/VertexShaderSourceFill.ts", () => ({ + FILL_TEMPLATE: vi.fn(() => "fill-vertex-source") +})); + +vi.mock("../../../Fragment/FragmentShaderSource.ts", () => ({ + MASK: vi.fn(() => "mask-source") +})); + +import { $collection } from "../../ShapeVariants"; +import { ShaderManager } from "../../../ShaderManager"; + +describe("VariantsShapeMaskShaderService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + $collection.clear(); + }); + + it("test case - should create mask shader without grid", () => + { + const result = execute(false); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should create mask shader with grid", () => + { + const result = execute(true); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should cache shader", () => + { + execute(false); + execute(false); + + expect(ShaderManager).toHaveBeenCalledTimes(1); + }); + + it("test case - should create different shaders for different params", () => + { + execute(false); + execute(true); + + expect(ShaderManager).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/webgl/src/Shader/Variants/Shape/service/VariantsShapeRectShaderService.test.ts b/packages/webgl/src/Shader/Variants/Shape/service/VariantsShapeRectShaderService.test.ts new file mode 100644 index 00000000..ca117c92 --- /dev/null +++ b/packages/webgl/src/Shader/Variants/Shape/service/VariantsShapeRectShaderService.test.ts @@ -0,0 +1,48 @@ +import { execute } from "./VariantsShapeRectShaderService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../../ShapeVariants.ts", () => ({ + $collection: new Map() +})); + +vi.mock("../../../ShaderManager.ts", () => { + const MockShaderManager = vi.fn(function(this: { id: string }) { + this.id = "mock-shader"; + }); + return { ShaderManager: MockShaderManager }; +}); + +vi.mock("../../../Vertex/VertexShaderSourceFill.ts", () => ({ + FILL_RECT_TEMPLATE: vi.fn(() => "fill-rect-vertex-source") +})); + +vi.mock("../../../Fragment/FragmentShaderSource.ts", () => ({ + FILL_RECT_COLOR: vi.fn(() => "fill-rect-color-source") +})); + +import { $collection } from "../../ShapeVariants"; +import { ShaderManager } from "../../../ShaderManager"; + +describe("VariantsShapeRectShaderService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + $collection.clear(); + }); + + it("test case - should create rect shader", () => + { + const result = execute(); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should cache shader", () => + { + execute(); + execute(); + + expect(ShaderManager).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/webgl/src/Shader/Variants/Shape/service/VariantsShapeSolidColorShaderService.test.ts b/packages/webgl/src/Shader/Variants/Shape/service/VariantsShapeSolidColorShaderService.test.ts new file mode 100644 index 00000000..ee8cd73b --- /dev/null +++ b/packages/webgl/src/Shader/Variants/Shape/service/VariantsShapeSolidColorShaderService.test.ts @@ -0,0 +1,64 @@ +import { execute } from "./VariantsShapeSolidColorShaderService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../../ShapeVariants.ts", () => ({ + $collection: new Map() +})); + +vi.mock("../../../ShaderManager.ts", () => { + const MockShaderManager = vi.fn(function(this: { id: string }) { + this.id = "mock-shader"; + }); + return { ShaderManager: MockShaderManager }; +}); + +vi.mock("../../../Vertex/VertexShaderSourceFill.ts", () => ({ + FILL_TEMPLATE: vi.fn(() => "fill-vertex-source") +})); + +vi.mock("../../../Fragment/FragmentShaderSource.ts", () => ({ + SOLID_FILL_COLOR: vi.fn(() => "solid-fill-color-source") +})); + +import { $collection } from "../../ShapeVariants"; +import { ShaderManager } from "../../../ShaderManager"; + +describe("VariantsShapeSolidColorShaderService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + $collection.clear(); + }); + + it("test case - should create solid color shader without grid", () => + { + const result = execute(false); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should create solid color shader with grid", () => + { + const result = execute(true); + + expect(ShaderManager).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it("test case - should cache shader", () => + { + execute(false); + execute(false); + + expect(ShaderManager).toHaveBeenCalledTimes(1); + }); + + it("test case - should create different shaders for different params", () => + { + execute(false); + execute(true); + + expect(ShaderManager).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/webgl/src/Shader/Vertex/VertexShaderLibrary.ts b/packages/webgl/src/Shader/Vertex/VertexShaderLibrary.ts index d3a44160..20a22dc7 100644 --- a/packages/webgl/src/Shader/Vertex/VertexShaderLibrary.ts +++ b/packages/webgl/src/Shader/Vertex/VertexShaderLibrary.ts @@ -1,11 +1,3 @@ -/** - * @description グリッドがオフの場合の頂点シェーダー - * Vertex shader when grid is off - * - * @return {string} - * @method - * @static - */ export const FUNCTION_GRID_OFF = (): string => { return ` @@ -15,15 +7,6 @@ vec2 applyMatrix(in vec2 vertex) { }`; }; -/** - * @description グリッドがオンの場合の頂点シェーダー - * Vertex shader when grid is on - * - * @param {number} index - * @return {STRing} - * @method - * @static - */ export const FUNCTION_GRID_ON = (index: number): string => { return ` @@ -43,7 +26,7 @@ vec2 applyMatrix(in vec2 vertex) { vec2 parent_size = vec2(u_highp[${index + 4}].w, u_highp[${index + 5}].w); vec4 grid_min = u_highp[${index + 6}]; vec4 grid_max = u_highp[${index + 7}]; - + vec2 position = (parent_matrix * vec3(vertex, 1.0)).xy; position = (position - parent_offset) / parent_size; @@ -65,4 +48,4 @@ vec2 applyMatrix(in vec2 vertex) { position = position + vec2(u_highp[${index + 8}].x, u_highp[${index + 8}].y); return position / vec2(u_highp[0].w, u_highp[1].w); }`; -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/Shader/Vertex/VertexShaderSource.ts b/packages/webgl/src/Shader/Vertex/VertexShaderSource.ts index 5e420f1f..951dec53 100644 --- a/packages/webgl/src/Shader/Vertex/VertexShaderSource.ts +++ b/packages/webgl/src/Shader/Vertex/VertexShaderSource.ts @@ -1,8 +1,3 @@ -/** - * @return {string} - * @method - * @static - */ export const TEXTURE_TEMPLATE = (): string => { return `#version 300 es @@ -19,11 +14,6 @@ void main() { }`; }; -/** - * @return {string} - * @method - * @static - */ export const VECTOR_TEMPLATE = (): string => { return `#version 300 es @@ -40,11 +30,6 @@ void main() { }`; }; -/** - * @return {string} - * @method - * @static - */ export const BLEND_MATRIX_TEMPLATE = (): string => { return `#version 300 es @@ -76,11 +61,6 @@ void main() { }`; }; -/** - * @return {string} - * @method - * @static - */ export const BLEND_TEMPLATE = (): string => { return `#version 300 es @@ -106,11 +86,6 @@ void main() { }`; }; -/** - * @return {string} - * @method - * @static - */ export const INSTANCE_TEMPLATE = (): string => { return `#version 300 es @@ -147,4 +122,4 @@ void main() { position = position * 2.0 - 1.0; gl_Position = vec4(position.x, -position.y, 0.0, 1.0); }`; -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/Shader/Vertex/VertexShaderSourceFill.ts b/packages/webgl/src/Shader/Vertex/VertexShaderSourceFill.ts index f217b857..45b87875 100644 --- a/packages/webgl/src/Shader/Vertex/VertexShaderSourceFill.ts +++ b/packages/webgl/src/Shader/Vertex/VertexShaderSourceFill.ts @@ -3,21 +3,11 @@ import { FUNCTION_GRID_ON } from "./VertexShaderLibrary"; -/** - * @return {string} - * @method - * @static - */ export const ATTRIBUTE_BEZIER_ON = (): string => { return "layout (location = 1) in vec2 a_bezier;"; }; -/** - * @returns {string} - * @method - * @static - */ export const ATTRIBUTE_MATRIX_ON = (): string => { return `layout (location = 3) in vec3 a_matrix0; @@ -25,31 +15,16 @@ layout (location = 4) in vec3 a_matrix1; layout (location = 5) in vec3 a_matrix2;`; }; -/** - * @return {string} - * @method - * @static - */ export const VARYING_UV_ON = (): string => { return "out vec2 v_uv;"; }; -/** - * @return {string} - * @method - * @static - */ export const VARYING_BEZIER_ON = (): string => { return "out vec2 v_bezier;"; }; -/** - * @return {string} - * @method - * @static - */ export const STATEMENT_UV_ON = (): string => { return ` @@ -66,58 +41,26 @@ export const STATEMENT_UV_ON = (): string => v_uv = (inverse_matrix * uv_matrix * vec3(a_vertex, 1.0)).xy;`; }; -/** - * @return {string} - * @method - * @static - */ export const STATEMENT_BEZIER_ON = (): string => { return "v_bezier = a_bezier;"; }; -/** - * @return {string} - * @method - * @static - */ export const ATTRIBUTE_COLOR_ON = (): string => { return "layout (location = 2) in vec4 a_color;"; }; -/** - * @return {string} - * @method - * @static - */ export const VARYING_COLOR_ON = (): string => { return "out vec4 v_color;"; }; -/** - * @return {string} - * @method - * @static - */ export const STATEMENT_COLOR_ON = (): string => { return "v_color = a_color;"; }; -/** - * @description 各種、塗りのシェーダのテンプレートを返却 - * Returns a template for various fill shaders - * - * @param {number} highp_length - * @param {boolean} with_uv - * @param {boolean} for_mask - * @param {boolean} is_grid_enabled - * @return {string} - * @method - * @static - */ export const FILL_TEMPLATE = ( highp_length: number, with_uv: boolean, @@ -186,14 +129,6 @@ void main() { }`; }; -/** - * @description 矩形の塗りのシェーダのテンプレートを返却 - * Returns a template for filling rectangles - * - * @return {string} - * @method - * @static - */ export const FILL_RECT_TEMPLATE = (): string => { return `#version 300 es @@ -202,4 +137,4 @@ void main() { vec2 pos = a_vertex * 2.0 - 1.0; gl_Position = vec4(pos.x, -pos.y, 0.0, 1.0); }`; -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/Stencil.ts b/packages/webgl/src/Stencil.ts new file mode 100644 index 00000000..aa697c66 --- /dev/null +++ b/packages/webgl/src/Stencil.ts @@ -0,0 +1,27 @@ +export let $currentStencilMode: number = 0; +export const STENCIL_MODE_MASK: number = 1; +export const STENCIL_MODE_FILL: number = 2; + +export const $setStencilMode = (mode: number): void => +{ + $currentStencilMode = mode; +}; + +export const $resetStencilMode = (): void => +{ + $currentStencilMode = 0; +}; + +export let $colorMaskEnabled: boolean = true; + +export const $setColorMaskEnabled = (enabled: boolean): void => +{ + $colorMaskEnabled = enabled; +}; + +export let $sampleAlphaToCoverageEnabled: boolean = false; + +export const $setSampleAlphaToCoverageEnabled = (enabled: boolean): void => +{ + $sampleAlphaToCoverageEnabled = enabled; +}; diff --git a/packages/webgl/src/Stencil/service/StencilDisableSampleAlphaToCoverageService.test.ts b/packages/webgl/src/Stencil/service/StencilDisableSampleAlphaToCoverageService.test.ts new file mode 100644 index 00000000..68bf707c --- /dev/null +++ b/packages/webgl/src/Stencil/service/StencilDisableSampleAlphaToCoverageService.test.ts @@ -0,0 +1,51 @@ +import { execute } from "./StencilDisableSampleAlphaToCoverageService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +let mockEnabled = true; + +vi.mock("../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $gl: { + SAMPLE_ALPHA_TO_COVERAGE: 0x809E, + disable: vi.fn() + } + }; +}); + +vi.mock("../../Stencil.ts", () => ({ + get $sampleAlphaToCoverageEnabled() { return mockEnabled; }, + $setSampleAlphaToCoverageEnabled: vi.fn((value: boolean) => { mockEnabled = value; }) +})); + +import { $gl } from "../../WebGLUtil"; +import { $setSampleAlphaToCoverageEnabled } from "../../Stencil"; + +describe("StencilDisableSampleAlphaToCoverageService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + mockEnabled = true; + }); + + it("test case - should disable sample alpha to coverage when enabled", () => + { + mockEnabled = true; + + execute(); + + expect($setSampleAlphaToCoverageEnabled).toHaveBeenCalledWith(false); + expect($gl.disable).toHaveBeenCalledWith(0x809E); + }); + + it("test case - should not disable when already disabled", () => + { + mockEnabled = false; + + execute(); + + expect($setSampleAlphaToCoverageEnabled).not.toHaveBeenCalled(); + expect($gl.disable).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/webgl/src/Stencil/service/StencilDisableSampleAlphaToCoverageService.ts b/packages/webgl/src/Stencil/service/StencilDisableSampleAlphaToCoverageService.ts new file mode 100644 index 00000000..4925b9b0 --- /dev/null +++ b/packages/webgl/src/Stencil/service/StencilDisableSampleAlphaToCoverageService.ts @@ -0,0 +1,13 @@ +import { $gl } from "../../WebGLUtil"; +import { + $sampleAlphaToCoverageEnabled, + $setSampleAlphaToCoverageEnabled +} from "../../Stencil"; + +export const execute = (): void => +{ + if ($sampleAlphaToCoverageEnabled) { + $setSampleAlphaToCoverageEnabled(false); + $gl.disable($gl.SAMPLE_ALPHA_TO_COVERAGE); + } +}; diff --git a/packages/webgl/src/Stencil/service/StencilEnableSampleAlphaToCoverageService.test.ts b/packages/webgl/src/Stencil/service/StencilEnableSampleAlphaToCoverageService.test.ts new file mode 100644 index 00000000..7aaf81b1 --- /dev/null +++ b/packages/webgl/src/Stencil/service/StencilEnableSampleAlphaToCoverageService.test.ts @@ -0,0 +1,51 @@ +import { execute } from "./StencilEnableSampleAlphaToCoverageService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +let mockEnabled = false; + +vi.mock("../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $gl: { + SAMPLE_ALPHA_TO_COVERAGE: 0x809E, + enable: vi.fn() + } + }; +}); + +vi.mock("../../Stencil.ts", () => ({ + get $sampleAlphaToCoverageEnabled() { return mockEnabled; }, + $setSampleAlphaToCoverageEnabled: vi.fn((value: boolean) => { mockEnabled = value; }) +})); + +import { $gl } from "../../WebGLUtil"; +import { $setSampleAlphaToCoverageEnabled } from "../../Stencil"; + +describe("StencilEnableSampleAlphaToCoverageService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + mockEnabled = false; + }); + + it("test case - should enable sample alpha to coverage when disabled", () => + { + mockEnabled = false; + + execute(); + + expect($setSampleAlphaToCoverageEnabled).toHaveBeenCalledWith(true); + expect($gl.enable).toHaveBeenCalledWith(0x809E); + }); + + it("test case - should not enable when already enabled", () => + { + mockEnabled = true; + + execute(); + + expect($setSampleAlphaToCoverageEnabled).not.toHaveBeenCalled(); + expect($gl.enable).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/webgl/src/Stencil/service/StencilEnableSampleAlphaToCoverageService.ts b/packages/webgl/src/Stencil/service/StencilEnableSampleAlphaToCoverageService.ts new file mode 100644 index 00000000..dbe2d67f --- /dev/null +++ b/packages/webgl/src/Stencil/service/StencilEnableSampleAlphaToCoverageService.ts @@ -0,0 +1,13 @@ +import { $gl } from "../../WebGLUtil"; +import { + $sampleAlphaToCoverageEnabled, + $setSampleAlphaToCoverageEnabled +} from "../../Stencil"; + +export const execute = (): void => +{ + if (!$sampleAlphaToCoverageEnabled) { + $setSampleAlphaToCoverageEnabled(true); + $gl.enable($gl.SAMPLE_ALPHA_TO_COVERAGE); + } +}; diff --git a/packages/webgl/src/Stencil/service/StencilResetService.test.ts b/packages/webgl/src/Stencil/service/StencilResetService.test.ts new file mode 100644 index 00000000..ee010dee --- /dev/null +++ b/packages/webgl/src/Stencil/service/StencilResetService.test.ts @@ -0,0 +1,47 @@ +import { execute } from "./StencilResetService"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../../Stencil.ts", () => ({ + $resetStencilMode: vi.fn(), + $setColorMaskEnabled: vi.fn(), + $setSampleAlphaToCoverageEnabled: vi.fn() +})); + +import { + $resetStencilMode, + $setColorMaskEnabled, + $setSampleAlphaToCoverageEnabled +} from "../../Stencil"; + +describe("StencilResetService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test case - should reset all stencil states", () => + { + execute(); + + expect($resetStencilMode).toHaveBeenCalledTimes(1); + expect($setColorMaskEnabled).toHaveBeenCalledWith(true); + expect($setSampleAlphaToCoverageEnabled).toHaveBeenCalledWith(false); + }); + + it("test case - should call functions in correct order", () => + { + const callOrder: string[] = []; + + vi.mocked($resetStencilMode).mockImplementation(() => callOrder.push("resetStencilMode")); + vi.mocked($setColorMaskEnabled).mockImplementation(() => callOrder.push("setColorMaskEnabled")); + vi.mocked($setSampleAlphaToCoverageEnabled).mockImplementation(() => callOrder.push("setSampleAlphaToCoverageEnabled")); + + execute(); + + expect(callOrder).toEqual([ + "resetStencilMode", + "setColorMaskEnabled", + "setSampleAlphaToCoverageEnabled" + ]); + }); +}); diff --git a/packages/webgl/src/Stencil/service/StencilResetService.ts b/packages/webgl/src/Stencil/service/StencilResetService.ts new file mode 100644 index 00000000..15a0a254 --- /dev/null +++ b/packages/webgl/src/Stencil/service/StencilResetService.ts @@ -0,0 +1,20 @@ +import { + $resetStencilMode, + $setColorMaskEnabled, + $setSampleAlphaToCoverageEnabled +} from "../../Stencil"; + +/** + * @description ステンシル状態キャッシュをリセット + * Reset stencil state cache + * + * @return {void} + * @method + * @protected + */ +export const execute = (): void => +{ + $resetStencilMode(); + $setColorMaskEnabled(true); + $setSampleAlphaToCoverageEnabled(false); +}; diff --git a/packages/webgl/src/Stencil/service/StencilSetFillModeService.test.ts b/packages/webgl/src/Stencil/service/StencilSetFillModeService.test.ts new file mode 100644 index 00000000..a8457532 --- /dev/null +++ b/packages/webgl/src/Stencil/service/StencilSetFillModeService.test.ts @@ -0,0 +1,45 @@ +import { execute } from "./StencilSetFillModeService"; +import { describe, expect, it, beforeEach, vi } from "vitest"; +import * as StencilModule from "../../Stencil"; + +vi.mock("../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $gl: { + stencilFunc: vi.fn(), + stencilOp: vi.fn(), + colorMask: vi.fn(), + NOTEQUAL: 0x0205, + KEEP: 0x1E00, + ZERO: 0 + } + }; +}); + +describe("StencilSetFillModeService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + StencilModule.$resetStencilMode(); + StencilModule.$setColorMaskEnabled(false); + }); + + it("test case - should set fill mode on first call", () => + { + expect(() => { + execute(); + }).not.toThrow(); + + expect(StencilModule.$currentStencilMode).toBe(StencilModule.STENCIL_MODE_FILL); + expect(StencilModule.$colorMaskEnabled).toBe(true); + }); + + it("test case - should not change state on subsequent calls", () => + { + execute(); + execute(); + + expect(StencilModule.$currentStencilMode).toBe(StencilModule.STENCIL_MODE_FILL); + }); +}); diff --git a/packages/webgl/src/Stencil/service/StencilSetFillModeService.ts b/packages/webgl/src/Stencil/service/StencilSetFillModeService.ts new file mode 100644 index 00000000..e73f07d4 --- /dev/null +++ b/packages/webgl/src/Stencil/service/StencilSetFillModeService.ts @@ -0,0 +1,23 @@ +import { $gl } from "../../WebGLUtil"; +import { + $currentStencilMode, + $setStencilMode, + $colorMaskEnabled, + $setColorMaskEnabled, + STENCIL_MODE_FILL +} from "../../Stencil"; + +export const execute = (): void => +{ + if ($currentStencilMode !== STENCIL_MODE_FILL) { + $setStencilMode(STENCIL_MODE_FILL); + + $gl.stencilFunc($gl.NOTEQUAL, 0, 0xff); + $gl.stencilOp($gl.KEEP, $gl.ZERO, $gl.ZERO); + } + + if (!$colorMaskEnabled) { + $setColorMaskEnabled(true); + $gl.colorMask(true, true, true, true); + } +}; diff --git a/packages/webgl/src/Stencil/service/StencilSetMaskModeService.test.ts b/packages/webgl/src/Stencil/service/StencilSetMaskModeService.test.ts new file mode 100644 index 00000000..deab742c --- /dev/null +++ b/packages/webgl/src/Stencil/service/StencilSetMaskModeService.test.ts @@ -0,0 +1,48 @@ +import { execute } from "./StencilSetMaskModeService"; +import { describe, expect, it, beforeEach, vi } from "vitest"; +import * as StencilModule from "../../Stencil"; + +vi.mock("../../WebGLUtil.ts", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + $gl: { + stencilFunc: vi.fn(), + stencilOpSeparate: vi.fn(), + colorMask: vi.fn(), + ALWAYS: 0x0207, + KEEP: 0x1E00, + INCR_WRAP: 0x8507, + DECR_WRAP: 0x8508, + FRONT: 0x0404, + BACK: 0x0405 + } + }; +}); + +describe("StencilSetMaskModeService.ts method test", () => +{ + beforeEach(() => { + vi.clearAllMocks(); + StencilModule.$resetStencilMode(); + StencilModule.$setColorMaskEnabled(true); + }); + + it("test case - should set mask mode on first call", () => + { + expect(() => { + execute(); + }).not.toThrow(); + + expect(StencilModule.$currentStencilMode).toBe(StencilModule.STENCIL_MODE_MASK); + expect(StencilModule.$colorMaskEnabled).toBe(false); + }); + + it("test case - should not change state on subsequent calls", () => + { + execute(); + execute(); + + expect(StencilModule.$currentStencilMode).toBe(StencilModule.STENCIL_MODE_MASK); + }); +}); diff --git a/packages/webgl/src/Stencil/service/StencilSetMaskModeService.ts b/packages/webgl/src/Stencil/service/StencilSetMaskModeService.ts new file mode 100644 index 00000000..7d293aa4 --- /dev/null +++ b/packages/webgl/src/Stencil/service/StencilSetMaskModeService.ts @@ -0,0 +1,24 @@ +import { $gl } from "../../WebGLUtil"; +import { + $currentStencilMode, + $setStencilMode, + $colorMaskEnabled, + $setColorMaskEnabled, + STENCIL_MODE_MASK +} from "../../Stencil"; + +export const execute = (): void => +{ + if ($currentStencilMode !== STENCIL_MODE_MASK) { + $setStencilMode(STENCIL_MODE_MASK); + + $gl.stencilFunc($gl.ALWAYS, 0, 0xff); + $gl.stencilOpSeparate($gl.FRONT, $gl.KEEP, $gl.KEEP, $gl.INCR_WRAP); + $gl.stencilOpSeparate($gl.BACK, $gl.KEEP, $gl.KEEP, $gl.DECR_WRAP); + } + + if ($colorMaskEnabled) { + $setColorMaskEnabled(false); + $gl.colorMask(false, false, false, false); + } +}; diff --git a/packages/webgl/src/StencilBufferObject/usecase/StencilBufferObjectAcquireObjectUseCase.ts b/packages/webgl/src/StencilBufferObject/usecase/StencilBufferObjectAcquireObjectUseCase.ts index 2184ddee..1682e866 100644 --- a/packages/webgl/src/StencilBufferObject/usecase/StencilBufferObjectAcquireObjectUseCase.ts +++ b/packages/webgl/src/StencilBufferObject/usecase/StencilBufferObjectAcquireObjectUseCase.ts @@ -19,18 +19,25 @@ export const execute = (width: number, height: number): IStencilBufferObject => return stencilBufferObjectCreateService(); } - for (let idx: number = 0; idx < $objectPool.length; ++idx) { - const stencilBufferObject = $objectPool[idx]; + let bestIdx = -1; + let bestArea = Infinity; - if (stencilBufferObject.width !== width - || stencilBufferObject.height !== height - ) { - continue; + for (let idx = 0; idx < $objectPool.length; ++idx) { + const obj = $objectPool[idx]; + if (obj.width >= width && obj.height >= height) { + if (obj.area < bestArea) { + bestArea = obj.area; + bestIdx = idx; + } } + } - $objectPool.splice(idx, 1); - return stencilBufferObject; + if (bestIdx !== -1) { + const obj = $objectPool[bestIdx]; + $objectPool[bestIdx] = $objectPool[$objectPool.length - 1]; + $objectPool.pop(); + return obj; } - return $objectPool.shift() as NonNullable; + return $objectPool.pop() as NonNullable; }; \ No newline at end of file diff --git a/packages/webgl/src/StencilBufferObject/usecase/StencilBufferObjectGetStencilBufferObjectUseCase.test.ts b/packages/webgl/src/StencilBufferObject/usecase/StencilBufferObjectGetStencilBufferObjectUseCase.test.ts index 2033b60c..4001ef12 100644 --- a/packages/webgl/src/StencilBufferObject/usecase/StencilBufferObjectGetStencilBufferObjectUseCase.test.ts +++ b/packages/webgl/src/StencilBufferObject/usecase/StencilBufferObjectGetStencilBufferObjectUseCase.test.ts @@ -59,7 +59,7 @@ describe("StencilBufferObjectGetStencilBufferObjectUseCase.js method test", () = expect(cacheStencilBufferObject.height).toBe(256); expect(cacheStencilBufferObject.area).toBe(256 * 256); - // no hit + // no hit (best-fit: 512x512, then re-allocated to 320x240) const newStencilBufferObject = execute(320, 240); expect($objectPool.length).toBe(2); expect(newStencilBufferObject.width).toBe(320); diff --git a/packages/webgl/src/StencilBufferObject/usecase/StencilBufferObjectGetStencilBufferObjectUseCase.ts b/packages/webgl/src/StencilBufferObject/usecase/StencilBufferObjectGetStencilBufferObjectUseCase.ts index 74949ac1..d40745b7 100644 --- a/packages/webgl/src/StencilBufferObject/usecase/StencilBufferObjectGetStencilBufferObjectUseCase.ts +++ b/packages/webgl/src/StencilBufferObject/usecase/StencilBufferObjectGetStencilBufferObjectUseCase.ts @@ -14,6 +14,7 @@ import { $gl } from "../../WebGLUtil"; */ export const execute = (width: number, height: number): IStencilBufferObject => { + const stencilBufferObject = stencilBufferObjectAcquireObjectUseCase(width, height); if (stencilBufferObject.width !== width diff --git a/packages/webgl/src/TextureManager/usecase/TextureManagerGetMainTextureFromBoundsUseCase.test.ts b/packages/webgl/src/TextureManager/usecase/TextureManagerGetMainTextureFromBoundsUseCase.test.ts index 142d93f1..2a9c8d68 100644 --- a/packages/webgl/src/TextureManager/usecase/TextureManagerGetMainTextureFromBoundsUseCase.test.ts +++ b/packages/webgl/src/TextureManager/usecase/TextureManagerGetMainTextureFromBoundsUseCase.test.ts @@ -29,6 +29,8 @@ vi.mock("../../WebGLUtil.ts", async (importOriginal) => "COLOR_BUFFER_BIT": 16384, "NEAREST": 9728, }, + $enableScissorTest: vi.fn(), + $disableScissorTest: vi.fn(), $context: { currentAttachmentObject: mockAttachment, $mainAttachmentObject: mockAttachment, @@ -38,7 +40,7 @@ vi.mock("../../WebGLUtil.ts", async (importOriginal) => }); vi.mock("../../FrameBufferManager.ts", () => ({ - $getDrawBitmapFrameBuffer: vi.fn(() => "drawBitmapFrameBuffer"), + $drawBitmapFramebuffer: "drawBitmapFrameBuffer", $readFrameBuffer: "readFrameBuffer" })); diff --git a/packages/webgl/src/TextureManager/usecase/TextureManagerGetMainTextureFromBoundsUseCase.ts b/packages/webgl/src/TextureManager/usecase/TextureManagerGetMainTextureFromBoundsUseCase.ts index d90c6079..dcbc1f61 100644 --- a/packages/webgl/src/TextureManager/usecase/TextureManagerGetMainTextureFromBoundsUseCase.ts +++ b/packages/webgl/src/TextureManager/usecase/TextureManagerGetMainTextureFromBoundsUseCase.ts @@ -4,10 +4,12 @@ import { execute as textureManagerGetTextureUseCase } from "./TextureManagerGetT import { execute as textureManagerBind0UseCase } from "./TextureManagerBind0UseCase"; import { $context, - $gl + $gl, + $enableScissorTest, + $disableScissorTest } from "../../WebGLUtil"; import { - $getDrawBitmapFrameBuffer, + $drawBitmapFramebuffer, $readFrameBuffer } from "../../FrameBufferManager"; @@ -42,8 +44,7 @@ export const execute = ( const mainAttachmentObject = $context.$mainAttachmentObject as IAttachmentObject; $context.bind(mainAttachmentObject); - const drawBitmapFrameBuffer = $getDrawBitmapFrameBuffer(); - $gl.bindFramebuffer($gl.FRAMEBUFFER, drawBitmapFrameBuffer); + $gl.bindFramebuffer($gl.FRAMEBUFFER, $drawBitmapFramebuffer); // 描画先のテクスチャを更新 if (!$mainTextureObject @@ -63,9 +64,9 @@ export const execute = ( $gl.bindFramebuffer($gl.FRAMEBUFFER, null); $gl.bindFramebuffer($gl.READ_FRAMEBUFFER, $readFrameBuffer); - $gl.bindFramebuffer($gl.DRAW_FRAMEBUFFER, drawBitmapFrameBuffer); + $gl.bindFramebuffer($gl.DRAW_FRAMEBUFFER, $drawBitmapFramebuffer); - $gl.enable($gl.SCISSOR_TEST); + $enableScissorTest(); $gl.scissor( x, mainAttachmentObject.height - y - height, @@ -80,7 +81,7 @@ export const execute = ( $gl.COLOR_BUFFER_BIT, $gl.NEAREST ); - $gl.disable($gl.SCISSOR_TEST); + $disableScissorTest(); $gl.bindFramebuffer($gl.FRAMEBUFFER, $readFrameBuffer); @@ -89,4 +90,4 @@ export const execute = ( } return $mainTextureObject; -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/TextureManager/usecase/TextureManagerGetTextureUseCase.ts b/packages/webgl/src/TextureManager/usecase/TextureManagerGetTextureUseCase.ts index bd96d0ad..91086013 100644 --- a/packages/webgl/src/TextureManager/usecase/TextureManagerGetTextureUseCase.ts +++ b/packages/webgl/src/TextureManager/usecase/TextureManagerGetTextureUseCase.ts @@ -19,4 +19,4 @@ export const execute = (width: number, height: number, smooth: boolean = false): const textureObject = textureManagerCreateTextureObjectService(width, height); textureManagerInitializeBindService(textureObject, smooth); return textureObject; -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase.test.ts b/packages/webgl/src/TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase.test.ts index 8e1bdf21..f2dc2873 100644 --- a/packages/webgl/src/TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase.test.ts +++ b/packages/webgl/src/TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase.test.ts @@ -32,4 +32,4 @@ describe("TextureManagerReleaseTextureObjectUseCase.js method test", () => // @ts-ignore expect(textureObject.resource.delete).toBe(true); }); -}); \ No newline at end of file +}); diff --git a/packages/webgl/src/TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase.ts b/packages/webgl/src/TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase.ts index 17c88f35..f3ec47e7 100644 --- a/packages/webgl/src/TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase.ts +++ b/packages/webgl/src/TextureManager/usecase/TextureManagerReleaseTextureObjectUseCase.ts @@ -13,4 +13,4 @@ import { $gl } from "../../WebGLUtil"; export const execute = (texture_object: ITextureObject): void => { $gl.deleteTexture(texture_object.resource); -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/VertexArrayObject/usecase/VertexArrayObjectBindAttributeUseCase.ts b/packages/webgl/src/VertexArrayObject/usecase/VertexArrayObjectBindAttributeUseCase.ts index e2aacf29..4e2cc8a1 100644 --- a/packages/webgl/src/VertexArrayObject/usecase/VertexArrayObjectBindAttributeUseCase.ts +++ b/packages/webgl/src/VertexArrayObject/usecase/VertexArrayObjectBindAttributeUseCase.ts @@ -30,15 +30,17 @@ export const execute = (): void => $attributeBufferLength = renderQueue.buffer.length; + // STREAM_DRAW: 毎フレーム更新されるデータに最適 + // STREAM_DRAW: Optimal for data updated every frame $gl.bufferData( $gl.ARRAY_BUFFER, $attributeBufferLength * 4, // renderQueue.buffer.byteLength - $gl.DYNAMIC_DRAW + $gl.STREAM_DRAW ); } $gl.bufferSubData( $gl.ARRAY_BUFFER, 0, - renderQueue.buffer.subarray(0, renderQueue.offset) + renderQueue.buffer, 0, renderQueue.offset ); }; \ No newline at end of file diff --git a/packages/webgl/src/VertexArrayObject/usecase/VertexArrayObjectBindFillMeshUseCase.ts b/packages/webgl/src/VertexArrayObject/usecase/VertexArrayObjectBindFillMeshUseCase.ts index 48d1acd3..3c16a8b4 100644 --- a/packages/webgl/src/VertexArrayObject/usecase/VertexArrayObjectBindFillMeshUseCase.ts +++ b/packages/webgl/src/VertexArrayObject/usecase/VertexArrayObjectBindFillMeshUseCase.ts @@ -29,7 +29,9 @@ export const execute = (): IVertexArrayObject => $gl.bindBuffer($gl.ARRAY_BUFFER, vertexArrayObject.vertexBuffer); if (vertexArrayObject.vertexLength < buffer.length) { vertexArrayObject.vertexLength = $upperPowerOfTwo(buffer.length); - $gl.bufferData($gl.ARRAY_BUFFER, vertexArrayObject.vertexLength * 4, $gl.DYNAMIC_DRAW); + // STREAM_DRAW: 毎フレーム更新されるデータに最適 + // STREAM_DRAW: Optimal for data updated every frame + $gl.bufferData($gl.ARRAY_BUFFER, vertexArrayObject.vertexLength * 4, $gl.STREAM_DRAW); } $gl.bufferSubData($gl.ARRAY_BUFFER, 0, buffer.subarray(0, offset)); diff --git a/packages/webgl/src/VertexArrayObject/usecase/VertexArrayObjectCreateInstancedVertexArrayObjectUseCase.ts b/packages/webgl/src/VertexArrayObject/usecase/VertexArrayObjectCreateInstancedVertexArrayObjectUseCase.ts index cd08f7e8..bb81e3c6 100644 --- a/packages/webgl/src/VertexArrayObject/usecase/VertexArrayObjectCreateInstancedVertexArrayObjectUseCase.ts +++ b/packages/webgl/src/VertexArrayObject/usecase/VertexArrayObjectCreateInstancedVertexArrayObjectUseCase.ts @@ -28,7 +28,9 @@ export const execute = (): IVertexArrayObject => $gl.vertexAttribPointer(0, 2, $gl.FLOAT, false, 0, 0); $gl.bindBuffer($gl.ARRAY_BUFFER, $attributeWebGLBuffer); - $gl.bufferData($gl.ARRAY_BUFFER, renderQueue.buffer.length, $gl.DYNAMIC_DRAW); + // STREAM_DRAW: 毎フレーム更新されるインスタンスデータに最適 + // STREAM_DRAW: Optimal for instance data updated every frame + $gl.bufferData($gl.ARRAY_BUFFER, renderQueue.buffer.length, $gl.STREAM_DRAW); // texture rectangle $gl.enableVertexAttribArray(1); diff --git a/packages/webgl/src/WebGLUtil.ts b/packages/webgl/src/WebGLUtil.ts index 06cefc2f..27d61e70 100644 --- a/packages/webgl/src/WebGLUtil.ts +++ b/packages/webgl/src/WebGLUtil.ts @@ -1,104 +1,79 @@ import type { Context } from "./Context"; -/** - * @description 描画の最大サイズ - * Maximum size of drawing - * - * @type {number} - * @protected - */ export let $RENDER_MAX_SIZE: number = 2048; -/** - * @description 描画の最大サイズを変更 - * Change the maximum size of drawing - * - * @param {number} size - * @return {void} - * @method - * @protected - */ export const $setRenderMaxSize = (size: number): void => { - $RENDER_MAX_SIZE = Math.min(4096, size / 2); + $RENDER_MAX_SIZE = Math.max(2048, size / 2); }; -/** - * @description 描画のサンプリング数 - * Number of samples for drawing - * - * @type {number} - * @default 4 - * @protected - */ export let $samples: number = 4; -/** - * @description 描画のサンプリング数を変更 - * Change the number of samples for drawing - * - * @param {number} samples - * @return {void} - * @method - * @protected - */ export const $setSamples = (samples: number): void => { $samples = samples; }; -/** - * @type {WebGL2RenderingContext} - * @protected - */ export let $gl: WebGL2RenderingContext; -/** - * @description WebGL2のコンテキストをセット - * Set WebGL2 context - * - * @param {WebGL2RenderingContext} gl - * @return {void} - * @method - * @protected - */ export const $setWebGL2RenderingContext = (gl: WebGL2RenderingContext): void => { $gl = gl; }; -/** - * @type {Context} - * @public - */ export let $context: Context; -/** - * @description 起動したコンテキストをセット - * Set the context that started - * - * @param {Context} context - * @return {void} - * @method - * @protected - */ export const $setContext = (context: Context): void => { $context = context; }; -/** - * @description 指定された値を範囲内にクランプします。 - * Clamps the specified value within the range. - * - * @param {number} value - * @param {number} min - * @param {number} max - * @param {number} [default_value=null] - * @return {number} - * @method - * @protected - */ +let $scissorEnabled: boolean = false; + +export const $enableScissorTest = (): void => +{ + if (!$scissorEnabled) { + $scissorEnabled = true; + $gl.enable($gl.SCISSOR_TEST); + } +}; + +export const $disableScissorTest = (): void => +{ + if ($scissorEnabled) { + $scissorEnabled = false; + $gl.disable($gl.SCISSOR_TEST); + } +}; + +export const $resetScissorState = (): void => +{ + $scissorEnabled = false; +}; + +let $stencilTestEnabled: boolean = false; + +export const $enableStencilTest = (): void => +{ + if (!$stencilTestEnabled) { + $stencilTestEnabled = true; + $gl.enable($gl.STENCIL_TEST); + } +}; + +export const $disableStencilTest = (): void => +{ + if ($stencilTestEnabled) { + $stencilTestEnabled = false; + $gl.disable($gl.STENCIL_TEST); + } +}; + +export const $resetStencilTestState = (): void => +{ + $stencilTestEnabled = false; +}; + export const $clamp = ( value: number, min: number, max: number, @@ -112,21 +87,8 @@ export const $clamp = ( : Math.min(Math.max(min, isNaN(number) ? 0 : number), max); }; -/** - * @type {array} - * @private - */ const $arrays: any[] = []; -/** - * @description プールした配列があれば再利用、なければ新規作成 - * Reuse the pooled array if available, otherwise create a new one. - * - * @param {array} args - * @return {array} - * @method - * @protected - */ export const $getArray = (...args: any[]): any[] => { const array: any[] = $arrays.pop() || []; @@ -136,15 +98,6 @@ export const $getArray = (...args: any[]): any[] => return array; }; -/** - * @description 使用済みの配列をプールに保管 - * Store the used array in the pool. - * - * @param {array} array - * @return {void} - * @method - * @protected - */ export const $poolArray = (array: any[] | null = null): void => { if (!array) { @@ -158,15 +111,6 @@ export const $poolArray = (array: any[] | null = null): void => $arrays.push(array); }; -/** - * @description 指定された値を2の累乗に切り上げます。 - * Rounds the specified value up to a power of two. - * - * @param {number} v - * @return {number} - * @method - * @protected - */ export const $upperPowerOfTwo = (v: number): number => { v--; @@ -179,24 +123,8 @@ export const $upperPowerOfTwo = (v: number): number => return v; }; -/** - * @type {Float32Array[]} - * @private - */ const $float32Array4: Float32Array[] = []; -/** - * @description プールしたFloat32Arrayがあれば再利用、なければ新規作成 - * Reuse the pooled Float32Array if available, otherwise create a new one. - * - * @param {number} [f0=0] - * @param {number} [f1=0] - * @param {number} [f2=0] - * @param {number} [f3=0] - * @return {Float32Array} - * @method - * @protected - */ export const $getFloat32Array4 = ( f0: number = 0, f1: number = 0, f2: number = 0, f3: number = 0 @@ -212,43 +140,13 @@ export const $getFloat32Array4 = ( return array; }; -/** - * @description 使用済みのFloat32Arrayをプールに保管 - * Store the used Float32Array in the pool. - * - * @param {Float32Array} array - * @return {void} - * @method - * @protected - */ export const $poolFloat32Array4 = (array: Float32Array): void => { $float32Array4.push(array); }; -/** - * @type {Float32Array[]} - * @private - */ const $float32Array9: Float32Array[] = []; -/** - * @description プールしたFloat32Arrayがあれば再利用、なければ新規作成 - * Reuse the pooled Float32Array if available, otherwise create a new one. - * - * @param {number} [f0=0] - * @param {number} [f1=0] - * @param {number} [f2=0] - * @param {number} [f3=0] - * @param {number} [f4=0] - * @param {number} [f5=0] - * @param {number} [f6=0] - * @param {number} [f7=0] - * @param {number} [f8=0] - * @return {Float32Array} - * @method - * @protected - */ export const $getFloat32Array9 = ( f0: number = 0, f1: number = 0, f2: number = 0, f3: number = 0, f4: number = 0, f5: number = 0, @@ -270,40 +168,13 @@ export const $getFloat32Array9 = ( return array; }; -/** - * @description 使用済みのFloat32Arrayをプールに保管 - * Store the used Float32Array in the pool. - * - * @param {Float32Array} array - * @return {void} - * @method - * @protected - */ export const $poolFloat32Array9 = (array: Float32Array): void => { $float32Array9.push(array); }; -/** - * @type {Float32Array[]} - * @private - */ const $float32Array6: Float32Array[] = []; -/** - * @description プールしたFloat32Arrayがあれば再利用、なければ新規作成 - * Reuse the pooled Float32Array if available, otherwise create a new one. - * - * @param {number} [f0=0] - * @param {number} [f1=0] - * @param {number} [f2=0] - * @param {number} [f3=0] - * @param {number} [f4=0] - * @param {number} [f5=0] - * @return {Float32Array} - * @method - * @protected - */ export const $getFloat32Array6 = ( f0: number = 0, f1: number = 0, f2: number = 0, f3: number = 0, @@ -322,73 +193,11 @@ export const $getFloat32Array6 = ( return array; }; -/** - * @param {Float32Array} array - * @return {void} - * @method - * @protected - */ export const $poolFloat32Array6 = (array: Float32Array): void => { $float32Array6.push(array); }; -/** - * @type {Int32Array[]} - * @private - */ -const $int32Array4: Int32Array[] = []; - -/** - * @description プールしたInt32Arrayがあれば再利用、なければ新規作成 - * Reuse the pooled Int32Array if available, otherwise create a new one. - * - * @param {number} [f0=0] - * @param {number} [f1=0] - * @param {number} [f2=0] - * @param {number} [f3=0] - * @return {Float32Array} - * @method - * @protected - */ -export const $getInt32Array4 = ( - f0: number = 0, f1: number = 0, - f2: number = 0, f3: number = 0 -): Int32Array => { - - const array: Int32Array = $int32Array4.pop() || new Int32Array(4); - - array[0] = f0; - array[1] = f1; - array[2] = f2; - array[3] = f3; - - return array; -}; - -/** - * @description 使用済みのInt32Arrayをプールに保管 - * Store the used Int32Array in the pool. - * - * @param {Float32Array} array - * @return {void} - * @method - * @protected - */ -export const $poolInt32Array4 = (array: Int32Array): void => -{ - $int32Array4.push(array); -}; - -/** - * @description 逆行列を取得 - * Get the inverse matrix - * - * @param {Float32Array} m - * @returns {Float32Array} - * @method - * @protected - */ export const $inverseMatrix = (m: Float32Array): Float32Array => { const rdet: number = 1 / (m[0] * m[4] - m[3] * m[1]); @@ -402,71 +211,16 @@ export const $inverseMatrix = (m: Float32Array): Float32Array => ); }; -/** - * @type {number} - * @default 0 - * @private - */ -let $viewportWidth: number = 0; - -/** - * @type {number} - * @default 0 - * @private - */ -let $viewportHeight: number = 0; - -/** - * @description ビューポートの幅を取得 - * Get the width of the viewport - * - * @returns {number} - * @method - * @protected - */ -export const $getViewportWidth = (): number => -{ - return $viewportWidth; -}; +export let $viewportWidth: number = 0; -/** - * @description ビューポートの高さを取得 - * Get the height of the viewport - * - * @returns {number} - * @method - * @protected - */ -export const $getViewportHeight = (): number => -{ - return $viewportHeight; -}; +export let $viewportHeight: number = 0; -/** - * @description ビューポートのサイズをセット - * Set the size of the viewport - * - * @param {number} viewport_width - * @param {number} viewport_height - * @return {void} - * @method - * @protected - */ export const $setViewportSize = (viewport_width: number, viewport_height: number): void => { $viewportWidth = viewport_width; $viewportHeight = viewport_height; }; -/** - * @description ビューポートのサイズを取得 - * Get the size of the viewport - * - * @param {Float32Array} matrix - * @return {Float32Array} - * @method - * @protected - */ export const $linearGradientXY = (matrix: Float32Array): Float32Array => { const x0: number = -819.2 * matrix[0] - 819.2 * matrix[2] + matrix[4]; @@ -493,49 +247,13 @@ export const $linearGradientXY = (matrix: Float32Array): Float32Array => return $getFloat32Array4(x0 + r2 * vx2, y0 + r2 * vy2, x1, y1); }; -/** - * @type {number} - * @public - */ -let $devicePixelRatio: number = 1; - -/** - * @description デバイスのピクセル比率を設定 - * Set the device's pixel ratio - * - * @param {number} device_pixel_ratio - * @return {void} - * @method - * @public - */ +export let $devicePixelRatio: number = 1; + export const $setDevicePixelRatio = (device_pixel_ratio: number): void => { $devicePixelRatio = device_pixel_ratio; }; -/** - * @description デバイスのピクセル比率を取得 - * Get the device's pixel ratio - * - * @return {number} - * @method - * @public - */ -export const $getDevicePixelRatio = (): number => -{ - return $devicePixelRatio; -}; - -/** - * @description 2つの行列を乗算 - * Multiply two matrices - * - * @param {Float32Array} a - * @param {Float32Array} b - * @return {Float32Array} - * @method - * @protected - */ export const $multiplyMatrices = (a: Float32Array, b: Float32Array): Float32Array => { const a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3], a4 = a[4], a5 = a[5]; @@ -551,14 +269,6 @@ export const $multiplyMatrices = (a: Float32Array, b: Float32Array): Float32Arra ); }; -/** - * @description HTTPS環境外でもUUIDを取得 - * Get UUID even outside HTTPS environment - * - * @return {string} - * @method - * @public - */ export const $getUUID = (): string => { return typeof crypto?.randomUUID === "function" @@ -569,4 +279,4 @@ export const $getUUID = (): string => const v = c === "x" ? r : r & 0x3 | 0x8; return v.toString(16); }); -}; \ No newline at end of file +}; diff --git a/packages/webgl/src/interface/ICubicConverterReturnObject.ts b/packages/webgl/src/interface/ICubicConverterReturnObject.ts new file mode 100644 index 00000000..830924d5 --- /dev/null +++ b/packages/webgl/src/interface/ICubicConverterReturnObject.ts @@ -0,0 +1,4 @@ +export interface ICubicConverterReturnObject { + buffer: Float32Array; + count: number; +} \ No newline at end of file diff --git a/packages/webgpu/LICENSE b/packages/webgpu/LICENSE new file mode 100644 index 00000000..a536abed --- /dev/null +++ b/packages/webgpu/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Next2D + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/webgpu/README.md b/packages/webgpu/README.md new file mode 100644 index 00000000..06d108f8 --- /dev/null +++ b/packages/webgpu/README.md @@ -0,0 +1,574 @@ +# @next2d/webgpu + +WebGPU-based rendering engine for Next2D (Experimental / Work in Progress) + +WebGPU ベースのレンダリングエンジン(実験的 / 開発中) + +--- + +## ⚠️ Warning / 警告 + +**This package is currently under development and NOT production-ready.** + +**本パッケージは現在開発中であり、本番環境での使用には対応していません。** + +- Many features are incomplete or placeholder implementations +- APIs may change without notice +- Performance has not been optimized +- Testing is incomplete + +--- + +## Overview / 概要 + +This package provides a WebGPU-based rendering backend for Next2D Player, designed as an alternative to the existing WebGL implementation. WebGPU is a modern graphics API that offers better performance and more control over GPU resources. + +本パッケージは Next2D Player 向けの WebGPU ベースのレンダリングバックエンドを提供します。既存の WebGL 実装の代替として設計されており、WebGPU は優れたパフォーマンスと GPU リソースに対するより細かい制御を提供する最新のグラフィックス API です。 + +### Key Features / 主な機能 + +- WGSL (WebGPU Shading Language) shader implementations +- Texture atlas management for efficient rendering +- Instance-based batch rendering +- Blend mode support +- Mask rendering capabilities +- Filter effects (Blur, Glow, Drop Shadow, Color Matrix) + +--- + +## Directory Structure / ディレクトリ構造 + +``` +src/ +├── Context.ts # Main rendering context (WebGPU版のメインコンテキスト) +├── WebGPUUtil.ts # Utility functions and global state management +│ +├── Managers / マネージャー +│ ├── AtlasManager.ts # Atlas texture management (アトラステクスチャ管理) +│ ├── AttachmentManager.ts # Offscreen attachment/FBO management (オフスクリーンアタッチメント管理) +│ ├── BufferManager.ts # Vertex/Uniform buffer management (バッファ管理) +│ ├── DrawManager.ts # Drawing operations helper (描画操作ヘルパー) +│ ├── FrameBufferManager.ts # Framebuffer management (フレームバッファ管理) +│ └── TextureManager.ts # Texture and sampler management (テクスチャ/サンプラー管理) +│ +├── Core Components / コアコンポーネント +│ ├── PathCommand.ts # Path drawing commands (moveTo, lineTo, bezierCurveTo, etc.) +│ │ ├── PathCommandState.ts # Path command state management +│ │ ├── service/ +│ │ │ ├── PathCommandBeginPathService.ts +│ │ │ ├── PathCommandEqualsToLastPointService.ts +│ │ │ ├── PathCommandPushCurrentPathToVerticesService.ts +│ │ │ └── PathCommandPushPointToCurrentPathService.ts +│ │ └── usecase/ +│ │ ├── PathCommandArcUseCase.ts +│ │ ├── PathCommandBezierCurveToUseCase.ts +│ │ ├── PathCommandClosePathUseCase.ts +│ │ ├── PathCommandLineToUseCase.ts +│ │ ├── PathCommandMoveToUseCase.ts +│ │ └── PathCommandQuadraticCurveToUseCase.ts +│ ├── Mesh.ts # Mesh data structures and utilities +│ │ ├── service/ +│ │ │ ├── MeshCalculateNormalVectorService.ts +│ │ │ ├── MeshFillGenerateService.ts +│ │ │ ├── MeshGetQuadraticBezierPointService.ts +│ │ │ ├── MeshGetQuadraticBezierTangentService.ts +│ │ │ └── MeshLerpService.ts +│ │ └── usecase/ +│ │ ├── MeshBitmapStrokeGenerateUseCase.ts +│ │ ├── MeshFillGenerateUseCase.ts +│ │ ├── MeshGradientStrokeGenerateUseCase.ts +│ │ ├── MeshSplitQuadraticBezierUseCase.ts +│ │ └── MeshStrokeGenerateUseCase.ts +│ ├── BezierConverter.ts # Bezier curve conversion utilities +│ │ ├── service/ +│ │ │ ├── BezierConverterCubicToQuadService.ts +│ │ │ └── BezierConverterSplitCubicService.ts +│ │ └── usecase/ +│ │ └── BezierConverterAdaptiveCubicToQuadUseCase.ts +│ ├── Blend.ts # Blend mode state management +│ ├── Mask.ts # Mask rendering state management +│ └── Grid.ts # Grid/9-slice system +│ +├── Shader/ シェーダー関連 +│ ├── ShaderSource.ts # WGSL shader source code +│ ├── PipelineManager.ts # Render pipeline management +│ ├── ShaderInstancedManager.ts # Instance rendering shader management +│ ├── BlendModeShader.ts # Blend mode shader implementations +│ └── GradientLUTGenerator.ts # Gradient lookup table generation +│ ├── service/ +│ │ ├── GradientLUTCalculateResolutionService.ts +│ │ ├── GradientLUTGeneratePixelsService.ts +│ │ ├── GradientLUTInterpolateColorService.ts +│ │ └── GradientLUTParseStopsService.ts +│ └── usecase/ +│ └── GradientLUTGenerateDataUseCase.ts +│ +├── Gradient/ グラデーション関連 +│ ├── GradientLUTCache.ts # Gradient LUT cache management +│ └── GradientLUTGenerator.ts # Gradient LUT generation +│ +├── Filter/ フィルター実装 +│ ├── index.ts # Filter exports +│ ├── BlurFilterShader.ts # Blur filter implementation +│ ├── BlurFilterUseCase.ts # Blur filter use case +│ ├── GlowFilterShader.ts # Glow filter implementation +│ ├── DropShadowFilterShader.ts # Drop shadow filter implementation +│ ├── ColorMatrixFilterShader.ts # Color matrix filter implementation +│ ├── BevelFilter/ +│ │ └── FilterApplyBevelFilterUseCase.ts +│ ├── BlurFilter/ +│ │ └── FilterApplyBlurFilterUseCase.ts +│ ├── ColorMatrixFilter/ +│ │ └── FilterApplyColorMatrixFilterUseCase.ts +│ ├── ConvolutionFilter/ +│ │ └── FilterApplyConvolutionFilterUseCase.ts +│ ├── DisplacementMapFilter/ +│ │ └── FilterApplyDisplacementMapFilterUseCase.ts +│ ├── DropShadowFilter/ +│ │ └── FilterApplyDropShadowFilterUseCase.ts +│ ├── GlowFilter/ +│ │ └── FilterApplyGlowFilterUseCase.ts +│ ├── GradientBevelFilter/ +│ │ └── FilterApplyGradientBevelFilterUseCase.ts +│ └── GradientGlowFilter/ +│ └── FilterApplyGradientGlowFilterUseCase.ts +│ +├── Blend/ ブレンド関連 +│ ├── BlendInstancedManager.ts # Instance-based blend rendering +│ ├── service/ +│ │ ├── BlendAddService.ts +│ │ ├── BlendAlphaService.ts +│ │ ├── BlendEraseService.ts +│ │ ├── BlendGetStateService.ts +│ │ ├── BlendOneZeroService.ts +│ │ ├── BlendResetService.ts +│ │ ├── BlendScreenService.ts +│ │ └── BlendSetModeService.ts +│ └── usecase/ +│ ├── BlendApplyComplexBlendUseCase.ts +│ └── BlendOperationUseCase.ts +│ +├── Mask/ マスク関連 +│ ├── service/ +│ │ ├── MaskBeginMaskService.ts +│ │ ├── MaskEndMaskService.ts +│ │ ├── MaskSetMaskBoundsService.ts +│ │ └── MaskUnionMaskService.ts +│ └── usecase/ +│ ├── MaskLeaveMaskUseCase.ts +│ └── MaskBindUseCase.ts +│ +└── interface/ 型定義 + ├── IAttachmentObject.ts # Attachment object interface + ├── IBlendMode.ts # Blend mode types + ├── IBounds.ts # Bounds rectangle interface + ├── IFillType.ts # Fill type definitions + ├── IPath.ts # Path interface + ├── IPoint.ts # Point interface + └── ITextureObject.ts # Texture object interface +``` + +--- + +## Implementation Status / 実装状況 + +### ✅ Implemented / 実装済み + +#### Core Rendering / コア描画機能 +- ✅ Basic initialization and device setup (基本的な初期化とデバイスセットアップ) +- ✅ Canvas context configuration (キャンバスコンテキストの設定) +- ✅ Frame lifecycle management (beginFrame/endFrame) (フレームライフサイクル管理) +- ✅ Transform matrix operations (save/restore/setTransform/transform) (変換行列操作) +- ✅ Background color fill (背景色の塗りつぶし) +- ✅ Resize handling (リサイズ処理) + +#### Path Drawing / パス描画 +- ✅ Path commands (beginPath, moveTo, lineTo, closePath) (パスコマンド) +- ✅ Bezier curves (quadraticCurveTo, bezierCurveTo) (ベジェ曲線) +- ✅ Arc drawing (円弧描画) +- ✅ Fill operations with solid colors (単色塗りつぶし) +- ✅ Stroke operations with mesh generation (ストローク描画とメッシュ生成) +- ✅ Vertex triangulation for path filling (パス塗りつぶし用の頂点三角形分割) + +#### Texture & Atlas Management / テクスチャ・アトラス管理 +- ✅ Atlas texture creation (4096x4096) (アトラステクスチャ作成) +- ✅ Node allocation in texture atlas (テクスチャアトラスのノード割り当て) +- ✅ Texture from pixels/ImageBitmap (ピクセル/ImageBitmapからのテクスチャ作成) +- ✅ Sampler creation (linear, nearest, repeat) (サンプラー作成) +- ✅ Texture pool management (テクスチャプール管理) + +#### Buffer Management / バッファ管理 +- ✅ Vertex buffer creation and management (頂点バッファ作成と管理) +- ✅ Uniform buffer creation and updates (Uniformバッファ作成と更新) +- ✅ Rectangle vertex generation (矩形頂点生成) + +#### Offscreen Rendering / オフスクリーンレンダリング +- ✅ Attachment object pool (アタッチメントオブジェクトプール) +- ✅ Bind/unbind attachment operations (アタッチメントのバインド/アンバインド) +- ✅ Render target switching (レンダーターゲットの切り替え) +- ✅ Stencil texture creation (ステンシルテクスチャ作成) + +#### Shader Pipelines / シェーダーパイプライン +- ✅ Fill pipeline (solid color) (単色塗りつぶしパイプライン) +- ✅ Mask pipeline (Bezier curve anti-aliasing) (マスクパイプライン - ベジェ曲線アンチエイリアス) +- ✅ Basic pipeline (基本パイプライン) +- ✅ Texture pipeline (テクスチャパイプライン) +- ✅ Instanced rendering pipeline (インスタンス描画パイプライン) +- ✅ Gradient pipeline structure (グラデーションパイプライン構造) +- ✅ Blend mode pipeline structure (ブレンドモードパイプライン構造) + +#### Instance Rendering / インスタンス描画 +- ✅ Instance data management (インスタンスデータ管理) +- ✅ Display object to instance array conversion (表示オブジェクトのインスタンス配列変換) +- ✅ Batch rendering with instancing (インスタンシングによるバッチ描画) +- ✅ Color transform (multiply/add) (カラー変換 - 乗算/加算) + +#### Image Operations / 画像操作 +- ✅ Draw pixels to atlas node (アトラスノードへのピクセル描画) +- ✅ Draw OffscreenCanvas/ImageBitmap to atlas (OffscreenCanvas/ImageBitmapのアトラス描画) +- ✅ Create ImageBitmap from GPU texture (GPUテクスチャからのImageBitmap作成) +- ✅ Premultiplied alpha conversion (プリマルチプライドアルファ変換) + +### 🚧 Partially Implemented / 部分的に実装 + +#### Drawing Operations / 描画操作 +- 🚧 Gradient fill (gradientFill) - Placeholder, falls back to solid fill + - グラデーション塗りつぶし - プレースホルダー実装、単色塗りつぶしにフォールバック +- 🚧 Bitmap fill (bitmapFill) - Texture creation works, shader integration pending + - ビットマップ塗りつぶし - テクスチャ作成は動作、シェーダー統合は保留 +- 🚧 Gradient stroke (gradientStroke) - Placeholder + - グラデーションストローク - プレースホルダー実装 +- 🚧 Bitmap stroke (bitmapStroke) - Placeholder + - ビットマップストローク - プレースホルダー実装 + +#### Masking / マスク処理 +- 🚧 Clip operations (clip) - Basic structure, stencil buffer integration needed + - クリッピング操作 - 基本構造はあるが、ステンシルバッファ統合が必要 +- 🚧 Mask begin/end (beginMask, endMask, setMaskBounds, leaveMask) - Service layer exists + - マスク開始/終了 - サービス層は存在 + +#### Filters / フィルター +- 🚧 applyFilter - Framework exists, filter shaders created but not integrated + - フィルター適用 - フレームワークは存在、フィルターシェーダーは作成済みだが統合されていない + - BlurFilterShader, GlowFilterShader, DropShadowFilterShader, ColorMatrixFilterShader + +### ❌ TODO / 未実装 + +#### Core Features / コア機能 +- ❌ Cache clearing implementation (resize時のキャッシュクリア) +- ❌ clearRect with scissor/clear operations (シザー/クリア操作によるclearRect) +- ❌ 9-slice grid transformation (useGrid) (9スライスグリッド変換) + +#### Advanced Rendering / 高度なレンダリング +- ❌ Complete gradient LUT texture generation (完全なグラデーションLUTテクスチャ生成) +- ❌ Gradient shader parameter passing (グラデーションシェーダーのパラメータ渡し) +- ❌ Bitmap fill/stroke shader integration (ビットマップ塗りつぶし/ストロークシェーダー統合) +- ❌ Stencil buffer-based clipping (ステンシルバッファベースのクリッピング) +- ❌ Two-pass rendering for masks (マスク用の2パスレンダリング) + +#### Blend Modes / ブレンドモード +- ❌ Full blend mode integration (multiply, screen, add, etc.) + - 完全なブレンドモード統合(乗算、スクリーン、加算など) +- ❌ Advanced blend modes (overlay, hard-light, soft-light, etc.) + - 高度なブレンドモード(オーバーレイ、ハードライト、ソフトライトなど) + +#### Filters / フィルター +- ❌ Filter parameter binding and execution (フィルターパラメータバインディングと実行) +- ❌ Multi-pass filter rendering (複数パスフィルターレンダリング) +- ❌ Convolution filter (コンボリューションフィルター) +- ❌ Displacement map filter (ディスプレイスメントマップフィルター) + +#### Optimization / 最適化 +- ❌ Buffer reuse and pooling optimization (バッファ再利用とプール最適化) +- ❌ Command encoder reuse (コマンドエンコーダー再利用) +- ❌ Pipeline state caching (パイプライン状態キャッシング) +- ❌ Batch draw call optimization (バッチ描画コール最適化) + +#### Testing & Documentation / テストとドキュメント +- ❌ Unit tests (ユニットテスト) +- ❌ Integration tests (統合テスト) +- ❌ Performance benchmarks (パフォーマンスベンチマーク) +- ❌ API documentation (API ドキュメント) + +--- + +## Context.ts - Implementation Analysis / Context.ts 実装分析 + +The `Context.ts` file is the main entry point for the WebGPU rendering engine. Here's a detailed breakdown of its implementation status: + +`Context.ts` ファイルは WebGPU レンダリングエンジンのメインエントリーポイントです。実装状況の詳細な内訳は以下の通りです: + +### Fully Implemented Methods / 完全実装済みメソッド + +| Method | Status | Notes | +|--------|--------|-------| +| `constructor` | ✅ Complete | Device, context, format initialization | +| `save` / `restore` | ✅ Complete | Matrix stack operations | +| `setTransform` / `transform` | ✅ Complete | 2D transformation matrix | +| `reset` | ✅ Complete | Reset context state | +| `beginPath` / `moveTo` / `lineTo` | ✅ Complete | Path command delegation | +| `quadraticCurveTo` / `bezierCurveTo` | ✅ Complete | Bezier curve support | +| `arc` / `closePath` | ✅ Complete | Path operations | +| `fillStyle` / `strokeStyle` | ✅ Complete | Color style setters | +| `fill` | ✅ Complete | Solid color fill with pipeline | +| `stroke` | ✅ Complete | Stroke with mesh generation | +| `updateBackgroundColor` | ✅ Complete | Background color update | +| `fillBackgroundColor` | ✅ Complete | Clear with background color | +| `resize` | ✅ Complete | Canvas resize (cache clear TODO) | +| `beginFrame` / `endFrame` | ✅ Complete | Frame lifecycle management | +| `bindAttachment` / `unbindAttachment` | ✅ Complete | Offscreen rendering | +| `getAttachmentObject` / `releaseAttachment` | ✅ Complete | Attachment management | +| `createNode` / `removeNode` | ✅ Complete | Atlas node management | +| `drawPixels` / `drawElement` | ✅ Complete | Pixel/element to atlas | +| `drawDisplayObject` | ✅ Complete | Instance array addition | +| `drawArraysInstanced` | ✅ Complete | Batch instance rendering | +| `clearArraysInstanced` | ✅ Complete | Clear instance data | +| `createImageBitmap` | ✅ Complete | GPU→ImageBitmap conversion | +| `beginMask` / `setMaskBounds` / `endMask` / `leaveMask` | ✅ Complete | Mask service delegation | + +### Placeholder / Incomplete Methods / プレースホルダー/不完全なメソッド + +| Method | Status | Notes | +|--------|--------|-------| +| `clearRect` | 🚧 Partial | Has console.log, needs scissor+clear implementation | +| `gradientFill` | 🚧 Placeholder | console.log + falls back to fill() | +| `bitmapFill` | 🚧 Partial | Creates texture but falls back to fill() | +| `gradientStroke` | 🚧 Placeholder | console.log + falls back to stroke() | +| `bitmapStroke` | 🚧 Placeholder | console.log + falls back to stroke() | +| `clip` | 🚧 Placeholder | console.log + falls back to fill() | +| `useGrid` | 🚧 Placeholder | console.log, 9-slice not implemented | +| `applyFilter` | 🚧 Placeholder | console.log, filter shaders not integrated | + +### Debug Markers / デバッグマーカー + +The code contains multiple `console.log` statements indicating work-in-progress areas: + +コードには開発中の領域を示す複数の `console.log` 文が含まれています: + +- Line 250: `clearRect()` - "TODO: シザーとクリアを使用した実装" +- Line 228: `resize()` - "TODO: キャッシュクリア実装" +- Line 270: `clearRect()` - "TODO: シザーとクリアを使用した実装" +- Line 781: `gradientFill()` - "TODO: グラデーションLUTテクスチャを生成" +- Line 790: `gradientFill()` - "TODO: グラデーション用のシェーダーを使用" +- Line 847: `bitmapFill()` - "TODO: ビットマップ塗りつぶし用のシェーダーを使用" +- Line 876: `gradientStroke()` - "TODO: グラデーションストローク実装" +- Line 901: `bitmapStroke()` - "TODO: ビットマップストローク実装" +- Line 918: `clip()` - "TODO: ステンシルバッファを使用したクリッピング実装" +- Line 962: `useGrid()` - "TODO: Grid/9-slice transformation implementation" +- Line 1312-1320: `applyFilter()` - Multiple filter TODOs +- Line 1660: `leaveMask()` - "TODO: WebGPU版のインスタンス描画を実装後に追加" + +--- + +## Shader Implementation / シェーダー実装 + +### WGSL Shaders in ShaderSource.ts / ShaderSource.ts の WGSL シェーダー + +The package includes complete WGSL shader implementations for: + +パッケージには以下の完全な WGSL シェーダー実装が含まれています: + +1. **Fill Shader** (単色塗りつぶし) + - WebGL-compatible vertex transformation + - Premultiplied alpha blending + - Viewport normalization + +2. **Mask Shader** (マスク) + - Bezier curve rendering with anti-aliasing + - Partial derivative-based edge smoothing + +3. **Texture Shader** (テクスチャ) + - Sampled texture rendering + - Color modulation + +4. **Instanced Shader** (インスタンス描画) + - Per-instance transformation matrices + - Color transform (multiply + add) + - Atlas texture sampling + - Unpremultiply → transform → premultiply workflow + +5. **Gradient Shader** (グラデーション) - Structure only + - Linear/Radial gradient support + - LUT-based color lookup + +6. **Blend Shader** (ブレンド) - Structure only + - Normal, Multiply, Screen, Add modes + - Dual texture sampling + +--- + +## Pipeline Architecture / パイプラインアーキテクチャ + +The `PipelineManager` creates and manages 6 render pipelines: + +`PipelineManager` は 6 つのレンダーパイプラインを作成・管理します: + +1. **fill** - Solid color fill (単色塗りつぶし) +2. **mask** - Stencil/clip operations (ステンシル/クリップ操作) +3. **basic** - Simple color rendering (シンプルカラーレンダリング) +4. **texture** - Textured quad rendering (テクスチャ付き矩形レンダリング) +5. **instanced** - Batch instance rendering (バッチインスタンス描画) +6. **gradient** - Gradient fill (グラデーション塗りつぶし) - Not yet integrated +7. **blend** - Blend mode operations (ブレンドモード操作) - Not yet integrated + +All pipelines use: +- Premultiplied alpha blending +- Triangle list topology +- No backface culling + +すべてのパイプラインは以下を使用: +- プリマルチプライドアルファブレンディング +- トライアングルリストトポロジー +- バックフェースカリング無効 + +--- + +## Known Limitations / 既知の制限事項 + +1. **Stencil Operations** - Depth-stencil attachment configuration incomplete + - ステンシル操作 - Depth-stencilアタッチメント設定が不完全 + +2. **Filter Effects** - Shader code exists but parameter passing not implemented + - フィルター効果 - シェーダーコードは存在するがパラメータ渡しが未実装 + +3. **Blend Modes** - Only normal blend mode fully functional + - ブレンドモード - ノーマルブレンドモードのみ完全に機能 + +4. **Gradient Rendering** - LUT generation incomplete + - グラデーションレンダリング - LUT生成が不完全 + +5. **Performance** - No optimization for buffer reuse, pipeline caching + - パフォーマンス - バッファ再利用、パイプラインキャッシングの最適化なし + +6. **Error Handling** - Limited validation and error recovery + - エラーハンドリング - 検証とエラー回復が制限的 + +--- + +## Development Notes / 開発ノート + +### Architecture / アーキテクチャ + +The package follows a manager-based architecture similar to the WebGL implementation: + +パッケージは WebGL 実装と同様のマネージャーベースアーキテクチャに従います: + +- **Context**: Main rendering interface (メインレンダリングインターフェース) +- **Managers**: Resource lifecycle management (リソースライフサイクル管理) +- **Services/UseCases**: Business logic separation (ビジネスロジック分離) +- **Shaders**: WGSL source and pipeline configuration (WGSL ソースとパイプライン設定) + +### Frame Lifecycle / フレームライフサイクル + +``` +clearTransferBounds() + → beginFrame() + → [drawing operations] + → endFrame()/transferMainCanvas() +``` + +### Rendering Flow / レンダリングフロー + +1. Acquire canvas texture (once per frame) (キャンバステクスチャ取得 - フレーム毎に1回) +2. Create command encoder (コマンドエンコーダー作成) +3. Begin render pass with load/clear (ロード/クリアでレンダーパス開始) +4. Set pipeline and bind resources (パイプライン設定とリソースバインド) +5. Draw commands (描画コマンド) +6. End render pass (レンダーパス終了) +7. Submit commands to queue (コマンドをキューに送信) + +--- + +## Browser Compatibility / ブラウザ互換性 + +WebGPU support is required. As of 2024: + +WebGPU サポートが必要です。2024年時点: + +- ✅ Chrome/Edge 113+ +- ✅ Firefox 131+ (experimental) +- ✅ Safari 18+ (experimental) +- ❌ Older browsers (need WebGL fallback) + +--- + +## Usage / 使用方法 + +```typescript +import { Context } from "@next2d/webgpu"; + +// Get WebGPU adapter and device +const adapter = await navigator.gpu.requestAdapter(); +const device = await adapter.requestDevice(); + +// Get canvas context +const canvas = document.getElementById("canvas") as HTMLCanvasElement; +const context = canvas.getContext("webgpu") as GPUCanvasContext; + +// Get preferred format +const format = navigator.gpu.getPreferredCanvasFormat(); + +// Create rendering context +const ctx = new Context(device, context, format); + +// Rendering +ctx.clearTransferBounds(); // Begin frame +ctx.fillStyle(1, 0, 0, 1); // Red +ctx.beginPath(); +ctx.arc(100, 100, 50); +ctx.fill(); +ctx.transferMainCanvas(); // End frame and submit +``` + +--- + +## Contributing / 貢献 + +As this package is work in progress, contributions are welcome! Priority areas: + +このパッケージは開発中のため、貢献を歓迎します!優先領域: + +1. Completing gradient and bitmap fill/stroke shaders +2. Implementing filter parameter binding +3. Stencil-based masking operations +4. Performance optimization (buffer pooling, pipeline caching) +5. Comprehensive testing + +--- + +## License / ライセンス + +MIT License + +Copyright (c) 2021 Next2D + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +## Related Packages / 関連パッケージ + +- `@next2d/texture-packer` - Texture atlas management +- `@next2d/render-queue` - Render queue for batch operations + +--- + +**Last Updated**: 2024-12-08 + +**Status**: 🚧 Experimental - Active Development diff --git a/packages/webgpu/package.json b/packages/webgpu/package.json new file mode 100644 index 00000000..085e8927 --- /dev/null +++ b/packages/webgpu/package.json @@ -0,0 +1,30 @@ +{ + "name": "@next2d/webgpu", + "version": "*", + "description": "Next2D WebGPU Package", + "author": "Toshiyuki Ienaga (https://github.com/ienaga/)", + "license": "MIT", + "homepage": "https://next2d.app", + "bugs": "https://github.com/Next2D/Player/issues", + "main": "src/index.js", + "types": "src/index.d.ts", + "type": "module", + "exports": { + ".": { + "import": "./src/index.js", + "require": "./src/index.js" + } + }, + "keywords": [ + "Next2D", + "Next2D WebGPU" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/Next2D/Player.git" + }, + "peerDependencies": { + "@next2d/texture-packer": "file:../texture-packer", + "@next2d/render-queue": "file:../render-queue" + } +} diff --git a/packages/webgpu/src/AtlasManager.test.ts b/packages/webgpu/src/AtlasManager.test.ts new file mode 100644 index 00000000..daa5c803 --- /dev/null +++ b/packages/webgpu/src/AtlasManager.test.ts @@ -0,0 +1,393 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + $setActiveAtlasIndex, + $getActiveAtlasIndex, + $getAtlasAttachmentObjects, + $setAtlasAttachmentObject, + $getAtlasAttachmentObject, + $hasAtlasAttachmentObject, + $rootNodes, + $setAtlasTexture, + $getAtlasTexture, + $getActiveTransferBounds, + $getActiveAllTransferBounds, + $clearTransferBounds, + $setCurrentAtlasIndex, + $getCurrentAtlasIndex, + $resetAtlas +} from "./AtlasManager"; +import type { IAttachmentObject } from "./interface/IAttachmentObject"; + +describe("AtlasManager", () => +{ + beforeEach(() => + { + // Reset global state before each test + $resetAtlas(); + }); + + describe("$setActiveAtlasIndex / $getActiveAtlasIndex", () => + { + it("should set and get active atlas index", () => + { + $setActiveAtlasIndex(5); + expect($getActiveAtlasIndex()).toBe(5); + }); + + it("should start at 0 after reset", () => + { + expect($getActiveAtlasIndex()).toBe(0); + }); + }); + + describe("$getAtlasAttachmentObjects", () => + { + it("should return empty array after reset", () => + { + const objects = $getAtlasAttachmentObjects(); + expect(objects).toEqual([]); + }); + + it("should return array reference", () => + { + const objects1 = $getAtlasAttachmentObjects(); + const objects2 = $getAtlasAttachmentObjects(); + expect(objects1).toBe(objects2); + }); + }); + + describe("$setAtlasAttachmentObject / $getAtlasAttachmentObject", () => + { + it("should set attachment object at active index", () => + { + const mockAttachment = createMockAttachment(1, 512, 512); + $setAtlasAttachmentObject(mockAttachment); + + expect($getAtlasAttachmentObject()).toBe(mockAttachment); + }); + + it("should return null when no attachment at active index", () => + { + $setActiveAtlasIndex(10); + expect($getAtlasAttachmentObject()).toBeNull(); + }); + + it("should set at different indices", () => + { + const attachment0 = createMockAttachment(1, 256, 256); + const attachment1 = createMockAttachment(2, 512, 512); + + $setActiveAtlasIndex(0); + $setAtlasAttachmentObject(attachment0); + + $setActiveAtlasIndex(1); + $setAtlasAttachmentObject(attachment1); + + $setActiveAtlasIndex(0); + expect($getAtlasAttachmentObject()).toBe(attachment0); + + $setActiveAtlasIndex(1); + expect($getAtlasAttachmentObject()).toBe(attachment1); + }); + }); + + describe("$hasAtlasAttachmentObject", () => + { + it("should return false when no attachment exists", () => + { + expect($hasAtlasAttachmentObject()).toBe(false); + }); + + it("should return true when attachment exists", () => + { + const mockAttachment = createMockAttachment(1, 512, 512); + $setAtlasAttachmentObject(mockAttachment); + + expect($hasAtlasAttachmentObject()).toBe(true); + }); + + it("should return false at non-existing index", () => + { + const mockAttachment = createMockAttachment(1, 512, 512); + $setAtlasAttachmentObject(mockAttachment); + + $setActiveAtlasIndex(5); + expect($hasAtlasAttachmentObject()).toBe(false); + }); + }); + + describe("$rootNodes", () => + { + it("should be empty array after reset", () => + { + expect($rootNodes).toEqual([]); + }); + + it("should allow modification", () => + { + const mockNode = {} as any; + $rootNodes.push(mockNode); + + expect($rootNodes.length).toBe(1); + expect($rootNodes[0]).toBe(mockNode); + }); + }); + + describe("$setAtlasTexture / $getAtlasTexture", () => + { + it("should set and get atlas texture", () => + { + const mockTexture = { + "id": 1, + "width": 4096, + "height": 4096, + "area": 4096 * 4096, + "smooth": true, + "resource": {}, + "view": {} + } as any; + + $setAtlasTexture(mockTexture); + + expect($getAtlasTexture()).toBe(mockTexture); + }); + + it("should allow setting to null", () => + { + const mockTexture = { + "id": 1, + "width": 4096, + "height": 4096, + "area": 4096 * 4096, + "smooth": true, + "resource": {}, + "view": {} + } as any; + + $setAtlasTexture(mockTexture); + $setAtlasTexture(null); + + expect($getAtlasTexture()).toBeNull(); + }); + }); + + describe("$getActiveTransferBounds", () => + { + it("should create transfer bounds at index", () => + { + const bounds = $getActiveTransferBounds(0); + + expect(bounds).toBeInstanceOf(Float32Array); + expect(bounds.length).toBe(4); + }); + + it("should initialize with max/min values", () => + { + const bounds = $getActiveTransferBounds(0); + + // Initial values: Float32Array stores Number.MAX_VALUE as Infinity + expect(bounds[0]).toBe(Infinity); + expect(bounds[1]).toBe(Infinity); + expect(bounds[2]).toBe(-Infinity); + expect(bounds[3]).toBe(-Infinity); + }); + + it("should return same bounds for same index", () => + { + const bounds1 = $getActiveTransferBounds(0); + const bounds2 = $getActiveTransferBounds(0); + + expect(bounds1).toBe(bounds2); + }); + + it("should return different bounds for different indices", () => + { + const bounds0 = $getActiveTransferBounds(0); + const bounds1 = $getActiveTransferBounds(1); + + expect(bounds0).not.toBe(bounds1); + }); + }); + + describe("$getActiveAllTransferBounds", () => + { + it("should create all transfer bounds at index", () => + { + const bounds = $getActiveAllTransferBounds(0); + + expect(bounds).toBeInstanceOf(Float32Array); + expect(bounds.length).toBe(4); + }); + + it("should initialize with max/min values", () => + { + const bounds = $getActiveAllTransferBounds(0); + + // Float32Array stores Number.MAX_VALUE as Infinity + expect(bounds[0]).toBe(Infinity); + expect(bounds[1]).toBe(Infinity); + expect(bounds[2]).toBe(-Infinity); + expect(bounds[3]).toBe(-Infinity); + }); + }); + + describe("$clearTransferBounds", () => + { + it("should reset transfer bounds to initial values", () => + { + const bounds = $getActiveTransferBounds(0); + bounds[0] = 10; + bounds[1] = 20; + bounds[2] = 100; + bounds[3] = 200; + + $clearTransferBounds(); + + // Float32Array stores Number.MAX_VALUE as Infinity + expect(bounds[0]).toBe(Infinity); + expect(bounds[1]).toBe(Infinity); + expect(bounds[2]).toBe(-Infinity); + expect(bounds[3]).toBe(-Infinity); + }); + + it("should reset all transfer bounds arrays", () => + { + const bounds0 = $getActiveTransferBounds(0); + const bounds1 = $getActiveTransferBounds(1); + const allBounds0 = $getActiveAllTransferBounds(0); + + bounds0[0] = 50; + bounds1[0] = 60; + allBounds0[0] = 70; + + $clearTransferBounds(); + + // Float32Array stores Number.MAX_VALUE as Infinity + expect(bounds0[0]).toBe(Infinity); + expect(bounds1[0]).toBe(Infinity); + expect(allBounds0[0]).toBe(Infinity); + }); + }); + + describe("$setCurrentAtlasIndex / $getCurrentAtlasIndex", () => + { + it("should set and get current atlas index", () => + { + $setCurrentAtlasIndex(3); + expect($getCurrentAtlasIndex()).toBe(3); + }); + + it("should start at 0 after reset", () => + { + expect($getCurrentAtlasIndex()).toBe(0); + }); + }); + + describe("$resetAtlas", () => + { + it("should reset root nodes", () => + { + $rootNodes.push({} as any); + $resetAtlas(); + + expect($rootNodes.length).toBe(0); + }); + + it("should reset active atlas index to 0", () => + { + $setActiveAtlasIndex(5); + $resetAtlas(); + + expect($getActiveAtlasIndex()).toBe(0); + }); + + it("should reset current atlas index to 0", () => + { + $setCurrentAtlasIndex(3); + $resetAtlas(); + + expect($getCurrentAtlasIndex()).toBe(0); + }); + + it("should clear atlas attachment objects", () => + { + const mockAttachment = createMockAttachment(1, 512, 512); + $setAtlasAttachmentObject(mockAttachment); + + $resetAtlas(); + + expect($getAtlasAttachmentObjects().length).toBe(0); + }); + + it("should destroy texture resources", () => + { + const mockDestroy = { "destroy": () => {} }; + const mockAttachment: IAttachmentObject = { + "id": 1, + "width": 512, + "height": 512, + "clipLevel": 0, + "msaa": false, + "mask": false, + "color": null, + "texture": { + "id": 1, + "width": 512, + "height": 512, + "area": 512 * 512, + "smooth": true, + "resource": mockDestroy as any, + "view": {} + }, + "stencil": null, + "msaaTexture": null, + "msaaStencil": null + }; + + $setAtlasAttachmentObject(mockAttachment); + $resetAtlas(); + + // Attachment should be removed + expect($hasAtlasAttachmentObject()).toBe(false); + }); + + it("should clear transfer bounds", () => + { + const bounds = $getActiveTransferBounds(0); + bounds[0] = 100; + + $resetAtlas(); + + // Get bounds again (will be recreated with initial values) + // Float32Array stores Number.MAX_VALUE as Infinity + const newBounds = $getActiveTransferBounds(0); + expect(newBounds[0]).toBe(Infinity); + }); + }); + + // Helper function to create mock attachment + function createMockAttachment(id: number, width: number, height: number): IAttachmentObject + { + return { + "id": id, + "width": width, + "height": height, + "clipLevel": 0, + "msaa": false, + "mask": false, + "color": null, + "texture": { + "id": id, + "width": width, + "height": height, + "area": width * height, + "smooth": true, + "resource": { "destroy": () => {} } as any, + "view": {} + }, + "stencil": null, + "msaaTexture": null, + "msaaStencil": null + }; + } +}); diff --git a/packages/webgpu/src/AtlasManager.ts b/packages/webgpu/src/AtlasManager.ts new file mode 100644 index 00000000..9bfd2cb1 --- /dev/null +++ b/packages/webgpu/src/AtlasManager.ts @@ -0,0 +1,176 @@ +import type { IAttachmentObject } from "./interface/IAttachmentObject"; +import type { ITextureObject } from "./interface/ITextureObject"; +import type { TexturePacker } from "@next2d/texture-packer"; + +const $MAX_VALUE: number = Number.MAX_VALUE; +const $MIN_VALUE: number = -Number.MAX_VALUE; + +let $activeAtlasIndex: number = 0; + +export const $setActiveAtlasIndex = (index: number): void => +{ + $activeAtlasIndex = index; +}; + +export const $getActiveAtlasIndex = (): number => +{ + return $activeAtlasIndex; +}; + +const $atlasAttachmentObjects: IAttachmentObject[] = []; + +export const $getAtlasAttachmentObjects = (): IAttachmentObject[] => +{ + return $atlasAttachmentObjects; +}; + +export const $setAtlasAttachmentObject = (attachment_object: IAttachmentObject): void => +{ + $atlasAttachmentObjects[$activeAtlasIndex] = attachment_object; +}; + +type AtlasCreator = (index: number) => IAttachmentObject; + +let $atlasCreator: AtlasCreator | null = null; + +export const $setAtlasCreator = (creator: AtlasCreator): void => +{ + $atlasCreator = creator; +}; + +export const $getAtlasAttachmentObject = (): IAttachmentObject | null => +{ + if (!($activeAtlasIndex in $atlasAttachmentObjects)) { + if ($atlasCreator) { + const attachment = $atlasCreator($activeAtlasIndex); + $setAtlasAttachmentObject(attachment); + } else { + return null; + } + } + return $atlasAttachmentObjects[$activeAtlasIndex]; +}; + +export const $getAtlasAttachmentObjectByIndex = (index: number): IAttachmentObject | null => +{ + if (!(index in $atlasAttachmentObjects)) { + return null; + } + return $atlasAttachmentObjects[index]; +}; + +export const $hasAtlasAttachmentObject = (): boolean => +{ + return $activeAtlasIndex in $atlasAttachmentObjects; +}; + +export const $rootNodes: TexturePacker[] = []; + +export let $atlasTexture: ITextureObject | null = null; + +export const $setAtlasTexture = (texture_object: ITextureObject | null): void => +{ + $atlasTexture = texture_object; +}; + +export const $getAtlasTexture = (): ITextureObject | null => +{ + return $atlasTexture; +}; + +const $transferBounds: Float32Array[] = []; + +export const $getActiveTransferBounds = (index: number): Float32Array => +{ + if (!(index in $transferBounds)) { + $transferBounds[index] = new Float32Array([ + $MAX_VALUE, + $MAX_VALUE, + $MIN_VALUE, + $MIN_VALUE + ]); + } + return $transferBounds[index]; +}; + +const $allTransferBounds: Float32Array[] = []; + +export const $getActiveAllTransferBounds = (index: number): Float32Array => +{ + if (!(index in $allTransferBounds)) { + $allTransferBounds[index] = new Float32Array([ + $MAX_VALUE, + $MAX_VALUE, + $MIN_VALUE, + $MIN_VALUE + ]); + } + return $allTransferBounds[index]; +}; + +export const $clearTransferBounds = (): void => +{ + for (let idx = 0; idx < $transferBounds.length; ++idx) { + const bounds = $transferBounds[idx]; + if (!bounds) { + continue; + } + + bounds[0] = bounds[1] = $MAX_VALUE; + bounds[2] = bounds[3] = $MIN_VALUE; + } + + for (let idx = 0; idx < $allTransferBounds.length; ++idx) { + const bounds = $allTransferBounds[idx]; + if (!bounds) { + continue; + } + + bounds[0] = bounds[1] = $MAX_VALUE; + bounds[2] = bounds[3] = $MIN_VALUE; + } +}; + +let $currentAtlasIndex: number = 0; + +export const $setCurrentAtlasIndex = (index: number): void => +{ + $currentAtlasIndex = index; +}; + +export const $getCurrentAtlasIndex = (): number => +{ + return $currentAtlasIndex; +}; + +export const $resetAtlas = (): void => +{ + $rootNodes.length = 0; + + $setActiveAtlasIndex(0); + + for (let idx = 0; idx < $atlasAttachmentObjects.length; idx++) { + const attachment = $atlasAttachmentObjects[idx]; + if (!attachment) { + continue; + } + if (attachment.texture) { + attachment.texture.resource.destroy(); + } + if (attachment.stencil) { + attachment.stencil.resource.destroy(); + } + if (attachment.msaaTexture) { + attachment.msaaTexture.resource.destroy(); + } + if (attachment.msaaStencil) { + attachment.msaaStencil.resource.destroy(); + } + } + + $atlasAttachmentObjects.length = 0; + + $clearTransferBounds(); + + $setCurrentAtlasIndex(0); +}; diff --git a/packages/webgpu/src/AttachmentManager.test.ts b/packages/webgpu/src/AttachmentManager.test.ts new file mode 100644 index 00000000..2db615c6 --- /dev/null +++ b/packages/webgpu/src/AttachmentManager.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { AttachmentManager } from "./AttachmentManager"; +import type { IAttachmentObject } from "./interface/IAttachmentObject"; + +// Mock usecase and service modules +vi.mock("./AttachmentManager/usecase/AttachmentManagerGetAttachmentObjectUseCase", () => ({ + "execute": vi.fn((device, attachmentPool, texturePool, colorBufferPool, stencilBufferPool, width, height, msaa, idCounter) => { + idCounter.attachmentId++; + return { + "id": idCounter.attachmentId, + "width": width, + "height": height, + "clipLevel": 0, + "msaa": msaa, + "mask": false, + "color": null, + "texture": { + "resource": { "destroy": vi.fn() }, + "view": {} + }, + "stencil": null + } as unknown as IAttachmentObject; + }) +})); + +vi.mock("./AttachmentManager/usecase/AttachmentManagerReleaseAttachmentUseCase", () => ({ + "execute": vi.fn() +})); + +vi.mock("./AttachmentManager/service/AttachmentManagerCreateRenderPassDescriptorService", () => ({ + "execute": vi.fn((attachment, r, g, b, a, loadOp) => ({ + "colorAttachments": [{ + "view": attachment.texture?.view, + "clearValue": { r, g, b, a }, + "loadOp": loadOp, + "storeOp": "store" + }] + })) +})); + +describe("AttachmentManager", () => +{ + const createMockDevice = (): GPUDevice => + { + return { + "createTexture": vi.fn(() => ({ + "createView": vi.fn(), + "destroy": vi.fn() + })) + } as unknown as GPUDevice; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + }); + + describe("constructor", () => + { + it("should create instance with device", () => + { + const device = createMockDevice(); + const manager = new AttachmentManager(device); + + expect(manager).toBeDefined(); + }); + + it("should initialize with null current attachment", () => + { + const device = createMockDevice(); + const manager = new AttachmentManager(device); + + expect(manager.getCurrentAttachment()).toBeNull(); + }); + }); + + describe("getAttachmentObject", () => + { + it("should return attachment with specified dimensions", () => + { + const device = createMockDevice(); + const manager = new AttachmentManager(device); + + const attachment = manager.getAttachmentObject(200, 150); + + expect(attachment.width).toBe(200); + expect(attachment.height).toBe(150); + }); + + it("should return attachment without msaa by default", () => + { + const device = createMockDevice(); + const manager = new AttachmentManager(device); + + const attachment = manager.getAttachmentObject(100, 100); + + expect(attachment.msaa).toBe(false); + }); + + it("should return attachment with msaa when specified", () => + { + const device = createMockDevice(); + const manager = new AttachmentManager(device); + + const attachment = manager.getAttachmentObject(100, 100, true); + + expect(attachment.msaa).toBe(true); + }); + + it("should increment attachment id for each call", () => + { + const device = createMockDevice(); + const manager = new AttachmentManager(device); + + const attachment1 = manager.getAttachmentObject(100, 100); + const attachment2 = manager.getAttachmentObject(100, 100); + + expect(attachment2.id).toBeGreaterThan(attachment1.id); + }); + }); + + describe("bindAttachment", () => + { + it("should set current attachment", () => + { + const device = createMockDevice(); + const manager = new AttachmentManager(device); + const attachment = manager.getAttachmentObject(100, 100); + + manager.bindAttachment(attachment); + + expect(manager.getCurrentAttachment()).toBe(attachment); + }); + }); + + describe("getCurrentAttachment", () => + { + it("should return null before binding", () => + { + const device = createMockDevice(); + const manager = new AttachmentManager(device); + + expect(manager.getCurrentAttachment()).toBeNull(); + }); + + it("should return bound attachment", () => + { + const device = createMockDevice(); + const manager = new AttachmentManager(device); + const attachment = manager.getAttachmentObject(100, 100); + + manager.bindAttachment(attachment); + + expect(manager.getCurrentAttachment()).toBe(attachment); + }); + }); + + describe("currentAttachmentObject", () => + { + it("should return same as getCurrentAttachment", () => + { + const device = createMockDevice(); + const manager = new AttachmentManager(device); + const attachment = manager.getAttachmentObject(100, 100); + + manager.bindAttachment(attachment); + + expect(manager.currentAttachmentObject).toBe(manager.getCurrentAttachment()); + }); + }); + + describe("unbindAttachment", () => + { + it("should set current attachment to null", () => + { + const device = createMockDevice(); + const manager = new AttachmentManager(device); + const attachment = manager.getAttachmentObject(100, 100); + + manager.bindAttachment(attachment); + manager.unbindAttachment(); + + expect(manager.getCurrentAttachment()).toBeNull(); + }); + }); + + describe("releaseAttachment", () => + { + it("should release attachment back to pool", () => + { + const device = createMockDevice(); + const manager = new AttachmentManager(device); + const attachment = manager.getAttachmentObject(100, 100); + + expect(() => manager.releaseAttachment(attachment)).not.toThrow(); + }); + }); + + describe("createRenderPassDescriptor", () => + { + it("should create descriptor with clear color", () => + { + const device = createMockDevice(); + const manager = new AttachmentManager(device); + const attachment = manager.getAttachmentObject(100, 100); + + const descriptor = manager.createRenderPassDescriptor( + attachment, 0.5, 0.5, 0.5, 1.0, "clear" + ); + + expect(descriptor.colorAttachments).toBeDefined(); + expect((descriptor.colorAttachments as any)[0].clearValue).toEqual({ + "r": 0.5, "g": 0.5, "b": 0.5, "a": 1.0 + }); + }); + + it("should use clear as default loadOp", () => + { + const device = createMockDevice(); + const manager = new AttachmentManager(device); + const attachment = manager.getAttachmentObject(100, 100); + + const descriptor = manager.createRenderPassDescriptor( + attachment, 0, 0, 0, 0 + ); + + expect((descriptor.colorAttachments as any)[0].loadOp).toBe("clear"); + }); + + it("should accept load as loadOp", () => + { + const device = createMockDevice(); + const manager = new AttachmentManager(device); + const attachment = manager.getAttachmentObject(100, 100); + + const descriptor = manager.createRenderPassDescriptor( + attachment, 0, 0, 0, 0, "load" + ); + + expect((descriptor.colorAttachments as any)[0].loadOp).toBe("load"); + }); + }); + + describe("dispose", () => + { + it("should not throw when disposing empty manager", () => + { + const device = createMockDevice(); + const manager = new AttachmentManager(device); + + expect(() => manager.dispose()).not.toThrow(); + }); + + it("should clear all pools after disposal", () => + { + const device = createMockDevice(); + const manager = new AttachmentManager(device); + + manager.getAttachmentObject(100, 100); + manager.dispose(); + + // After dispose, getting new attachment should work (fresh state) + expect(() => manager.getAttachmentObject(100, 100)).not.toThrow(); + }); + }); +}); diff --git a/packages/webgpu/src/AttachmentManager.ts b/packages/webgpu/src/AttachmentManager.ts new file mode 100644 index 00000000..107ab1ba --- /dev/null +++ b/packages/webgpu/src/AttachmentManager.ts @@ -0,0 +1,131 @@ +import type { IAttachmentObject } from "./interface/IAttachmentObject"; +import type { ITextureObject } from "./interface/ITextureObject"; +import type { IColorBufferObject } from "./interface/IColorBufferObject"; +import type { IStencilBufferObject } from "./interface/IStencilBufferObject"; +import { execute as attachmentManagerGetAttachmentObjectUseCase } from "./AttachmentManager/usecase/AttachmentManagerGetAttachmentObjectUseCase"; +import { execute as attachmentManagerReleaseAttachmentUseCase } from "./AttachmentManager/usecase/AttachmentManagerReleaseAttachmentUseCase"; +import { execute as attachmentManagerCreateRenderPassDescriptorService } from "./AttachmentManager/service/AttachmentManagerCreateRenderPassDescriptorService"; + +export class AttachmentManager +{ + private device: GPUDevice; + private attachmentPool: IAttachmentObject[]; + private texturePool: Map; + private colorBufferPool: IColorBufferObject[]; + private stencilBufferPool: IStencilBufferObject[]; + private idCounter: { attachmentId: number; textureId: number; stencilId: number }; + private currentAttachment: IAttachmentObject | null; + + constructor(device: GPUDevice) + { + this.device = device; + this.attachmentPool = []; + this.texturePool = new Map(); + this.colorBufferPool = []; + this.stencilBufferPool = []; + this.idCounter = { "attachmentId": 0, "textureId": 0, "stencilId": 0 }; + this.currentAttachment = null; + } + + getAttachmentObject( + width: number, + height: number, + msaa: boolean = false + ): IAttachmentObject + { + return attachmentManagerGetAttachmentObjectUseCase( + this.device, + this.attachmentPool, + this.texturePool, + this.colorBufferPool, + this.stencilBufferPool, + width, + height, + msaa, + this.idCounter + ); + } + + bindAttachment(attachment: IAttachmentObject): void + { + this.currentAttachment = attachment; + } + + getCurrentAttachment(): IAttachmentObject | null + { + return this.currentAttachment; + } + + get currentAttachmentObject(): IAttachmentObject | null + { + return this.currentAttachment; + } + + unbindAttachment(): void + { + this.currentAttachment = null; + } + + releaseAttachment(attachment: IAttachmentObject): void + { + attachmentManagerReleaseAttachmentUseCase( + this.attachmentPool, + this.texturePool, + this.colorBufferPool, + this.stencilBufferPool, + attachment + ); + } + + createRenderPassDescriptor( + attachment: IAttachmentObject, + r: number, + g: number, + b: number, + a: number, + loadOp: GPULoadOp = "clear" + ): GPURenderPassDescriptor + { + return attachmentManagerCreateRenderPassDescriptorService( + attachment, + r, + g, + b, + a, + loadOp + ); + } + + dispose(): void + { + for (const pool of this.texturePool.values()) { + for (const textureObj of pool) { + textureObj.resource.destroy(); + } + } + this.texturePool.clear(); + + for (const color of this.colorBufferPool) { + color.resource.destroy(); + } + this.colorBufferPool = []; + + for (const stencil of this.stencilBufferPool) { + stencil.resource.destroy(); + } + this.stencilBufferPool = []; + + for (const attachment of this.attachmentPool) { + if (attachment.texture) { + attachment.texture.resource.destroy(); + } + if (attachment.color) { + attachment.color.resource.destroy(); + } + if (attachment.stencil) { + attachment.stencil.resource.destroy(); + } + } + this.attachmentPool = []; + } +} diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateAttachmentObjectService.test.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateAttachmentObjectService.test.ts new file mode 100644 index 00000000..1982eee4 --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateAttachmentObjectService.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import { execute } from "./AttachmentManagerCreateAttachmentObjectService"; + +describe("AttachmentManagerCreateAttachmentObjectService", () => +{ + describe("basic creation", () => + { + it("should create attachment object with incremented id", () => + { + const idCounter = { attachmentId: 1 }; + + const result = execute(idCounter); + + expect(result.id).toBe(1); + expect(idCounter.attachmentId).toBe(2); + }); + + it("should increment id for each creation", () => + { + const idCounter = { attachmentId: 5 }; + + const result1 = execute(idCounter); + const result2 = execute(idCounter); + const result3 = execute(idCounter); + + expect(result1.id).toBe(5); + expect(result2.id).toBe(6); + expect(result3.id).toBe(7); + expect(idCounter.attachmentId).toBe(8); + }); + }); + + describe("default values", () => + { + it("should initialize width to 0", () => + { + const idCounter = { attachmentId: 1 }; + + const result = execute(idCounter); + + expect(result.width).toBe(0); + }); + + it("should initialize height to 0", () => + { + const idCounter = { attachmentId: 1 }; + + const result = execute(idCounter); + + expect(result.height).toBe(0); + }); + + it("should initialize clipLevel to 0", () => + { + const idCounter = { attachmentId: 1 }; + + const result = execute(idCounter); + + expect(result.clipLevel).toBe(0); + }); + + it("should initialize msaa to false", () => + { + const idCounter = { attachmentId: 1 }; + + const result = execute(idCounter); + + expect(result.msaa).toBe(false); + }); + + it("should initialize mask to false", () => + { + const idCounter = { attachmentId: 1 }; + + const result = execute(idCounter); + + expect(result.mask).toBe(false); + }); + + it("should initialize color to null", () => + { + const idCounter = { attachmentId: 1 }; + + const result = execute(idCounter); + + expect(result.color).toBeNull(); + }); + + it("should initialize texture to null", () => + { + const idCounter = { attachmentId: 1 }; + + const result = execute(idCounter); + + expect(result.texture).toBeNull(); + }); + + it("should initialize stencil to null", () => + { + const idCounter = { attachmentId: 1 }; + + const result = execute(idCounter); + + expect(result.stencil).toBeNull(); + }); + + it("should initialize msaaTexture to null", () => + { + const idCounter = { attachmentId: 1 }; + + const result = execute(idCounter); + + expect(result.msaaTexture).toBeNull(); + }); + + it("should initialize msaaStencil to null", () => + { + const idCounter = { attachmentId: 1 }; + + const result = execute(idCounter); + + expect(result.msaaStencil).toBeNull(); + }); + }); + + describe("object independence", () => + { + it("should create independent objects", () => + { + const idCounter = { attachmentId: 1 }; + + const result1 = execute(idCounter); + const result2 = execute(idCounter); + + result1.width = 100; + result1.height = 200; + + expect(result2.width).toBe(0); + expect(result2.height).toBe(0); + }); + }); +}); diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateAttachmentObjectService.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateAttachmentObjectService.ts new file mode 100644 index 00000000..5ca9deca --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateAttachmentObjectService.ts @@ -0,0 +1,28 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; + +/** + * @description 新しいアタッチメントオブジェクトを作成 + * Create a new attachment object + * + * @param {{ attachmentId: number }} idCounter + * @return {IAttachmentObject} + * @method + * @protected + */ +export const execute = ( + idCounter: { attachmentId: number } +): IAttachmentObject => { + return { + "id": idCounter.attachmentId++, + "width": 0, + "height": 0, + "clipLevel": 0, + "msaa": false, + "mask": false, + "color": null, + "texture": null, + "stencil": null, + "msaaTexture": null, + "msaaStencil": null + }; +}; diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateColorBufferService.test.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateColorBufferService.test.ts new file mode 100644 index 00000000..a2933b33 --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateColorBufferService.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IStencilBufferObject } from "../../interface/IStencilBufferObject"; +import { execute } from "./AttachmentManagerCreateColorBufferService"; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + RENDER_ATTACHMENT: 0x10, + TEXTURE_BINDING: 0x04, + COPY_SRC: 0x01, + COPY_DST: 0x02 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +describe("AttachmentManagerCreateColorBufferService", () => +{ + let mockStencil: IStencilBufferObject; + + const createMockDevice = () => + { + const mockView = { "label": "mockView" }; + const mockTexture = { + "createView": vi.fn(() => mockView), + "width": 0, + "height": 0 + }; + return { + "createTexture": vi.fn((descriptor) => { + mockTexture.width = descriptor.size.width; + mockTexture.height = descriptor.size.height; + return mockTexture; + }), + "_mockTexture": mockTexture, + "_mockView": mockView + } as unknown as GPUDevice & { _mockTexture: any; _mockView: any }; + }; + + beforeEach(() => + { + mockStencil = { "id": 1 } as IStencilBufferObject; + }); + + it("should create texture with correct dimensions", () => + { + const device = createMockDevice(); + + execute(device, 256, 512, mockStencil); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "size": { "width": 256, "height": 512 } + }) + ); + }); + + it("should create texture with rgba8unorm format", () => + { + const device = createMockDevice(); + + execute(device, 256, 256, mockStencil); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "format": "rgba8unorm" + }) + ); + }); + + it("should create texture with correct usage flags", () => + { + const device = createMockDevice(); + const expectedUsage = GPUTextureUsage.RENDER_ATTACHMENT | + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_SRC | + GPUTextureUsage.COPY_DST; + + execute(device, 256, 256, mockStencil); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "usage": expectedUsage + }) + ); + }); + + it("should return object with resource property", () => + { + const device = createMockDevice(); + + const result = execute(device, 256, 256, mockStencil); + + expect(result.resource).toBeDefined(); + }); + + it("should return object with view from createView", () => + { + const device = createMockDevice(); + + const result = execute(device, 256, 256, mockStencil); + + expect(result.view).toBe((device as any)._mockView); + }); + + it("should return object with correct dimensions", () => + { + const device = createMockDevice(); + + const result = execute(device, 1024, 768, mockStencil); + + expect(result.width).toBe(1024); + expect(result.height).toBe(768); + }); + + it("should return object with calculated area", () => + { + const device = createMockDevice(); + + const result = execute(device, 100, 200, mockStencil); + + expect(result.area).toBe(20000); + }); + + it("should return object with stencil reference", () => + { + const device = createMockDevice(); + + const result = execute(device, 256, 256, mockStencil); + + expect(result.stencil).toBe(mockStencil); + }); + + it("should return object with dirty set to false", () => + { + const device = createMockDevice(); + + const result = execute(device, 256, 256, mockStencil); + + expect(result.dirty).toBe(false); + }); + + it("should call createView on texture", () => + { + const device = createMockDevice(); + + execute(device, 256, 256, mockStencil); + + expect((device as any)._mockTexture.createView).toHaveBeenCalled(); + }); +}); diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateColorBufferService.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateColorBufferService.ts new file mode 100644 index 00000000..38cc6932 --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateColorBufferService.ts @@ -0,0 +1,40 @@ +import type { IColorBufferObject } from "../../interface/IColorBufferObject"; +import type { IStencilBufferObject } from "../../interface/IStencilBufferObject"; + +/** + * @description カラーバッファを新規作成 + * Create a new color buffer + * + * @param {GPUDevice} device + * @param {number} width + * @param {number} height + * @param {IStencilBufferObject} stencil + * @return {IColorBufferObject} + * @method + * @protected + */ +export const execute = ( + device: GPUDevice, + width: number, + height: number, + stencil: IStencilBufferObject +): IColorBufferObject => { + const texture = device.createTexture({ + "size": { width, height }, + "format": "rgba8unorm", + "usage": GPUTextureUsage.RENDER_ATTACHMENT | + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_SRC | + GPUTextureUsage.COPY_DST + }); + + return { + "resource": texture, + "view": texture.createView(), + stencil, + width, + height, + "area": width * height, + "dirty": false + }; +}; diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateRenderPassDescriptorService.test.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateRenderPassDescriptorService.test.ts new file mode 100644 index 00000000..7cc3038c --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateRenderPassDescriptorService.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import { execute } from "./AttachmentManagerCreateRenderPassDescriptorService"; + +describe("AttachmentManagerCreateRenderPassDescriptorService", () => +{ + const createMockAttachment = ( + hasColorView: boolean = true, + hasTextureView: boolean = false, + hasStencil: boolean = false + ): IAttachmentObject => ({ + "id": 1, + "width": 256, + "height": 256, + "color": hasColorView ? { "view": { "label": "colorView" } } : null, + "texture": hasTextureView ? { "view": { "label": "textureView" } } : null, + "stencil": hasStencil ? { "view": { "label": "stencilView" } } : null + } as unknown as IAttachmentObject); + + describe("color attachments", () => + { + it("should use color.view when available", () => + { + const attachment = createMockAttachment(true, false, false); + + const result = execute(attachment, 0, 0, 0, 1, "clear"); + + expect(result.colorAttachments[0].view).toEqual({ "label": "colorView" }); + }); + + it("should fallback to texture.view when color.view is not available", () => + { + const attachment = createMockAttachment(false, true, false); + + const result = execute(attachment, 0, 0, 0, 1, "clear"); + + expect(result.colorAttachments[0].view).toEqual({ "label": "textureView" }); + }); + + it("should throw error when no color view available", () => + { + const attachment = createMockAttachment(false, false, false); + + expect(() => execute(attachment, 0, 0, 0, 1, "clear")).toThrow( + "No color view available for render pass" + ); + }); + + it("should set clearValue with provided RGBA values", () => + { + const attachment = createMockAttachment(true); + + const result = execute(attachment, 0.5, 0.6, 0.7, 0.8, "clear"); + + expect(result.colorAttachments[0].clearValue).toEqual({ + "r": 0.5, + "g": 0.6, + "b": 0.7, + "a": 0.8 + }); + }); + + it("should set loadOp to provided value", () => + { + const attachment = createMockAttachment(true); + + const result = execute(attachment, 0, 0, 0, 1, "load"); + + expect(result.colorAttachments[0].loadOp).toBe("load"); + }); + + it("should default loadOp to clear", () => + { + const attachment = createMockAttachment(true); + + const result = execute(attachment, 0, 0, 0, 1); + + expect(result.colorAttachments[0].loadOp).toBe("clear"); + }); + + it("should set storeOp to store", () => + { + const attachment = createMockAttachment(true); + + const result = execute(attachment, 0, 0, 0, 1, "clear"); + + expect(result.colorAttachments[0].storeOp).toBe("store"); + }); + }); + + describe("depth stencil attachment", () => + { + it("should include depthStencilAttachment when stencil is available", () => + { + const attachment = createMockAttachment(true, false, true); + + const result = execute(attachment, 0, 0, 0, 1, "clear"); + + expect(result.depthStencilAttachment).toBeDefined(); + }); + + it("should not include depthStencilAttachment when stencil is not available", () => + { + const attachment = createMockAttachment(true, false, false); + + const result = execute(attachment, 0, 0, 0, 1, "clear"); + + expect(result.depthStencilAttachment).toBeUndefined(); + }); + + it("should set stencil view correctly", () => + { + const attachment = createMockAttachment(true, false, true); + + const result = execute(attachment, 0, 0, 0, 1, "clear"); + + expect(result.depthStencilAttachment?.view).toEqual({ "label": "stencilView" }); + }); + + it("should set depth clear value to 1.0", () => + { + const attachment = createMockAttachment(true, false, true); + + const result = execute(attachment, 0, 0, 0, 1, "clear"); + + expect(result.depthStencilAttachment?.depthClearValue).toBe(1.0); + }); + + it("should set stencil clear value to 0", () => + { + const attachment = createMockAttachment(true, false, true); + + const result = execute(attachment, 0, 0, 0, 1, "clear"); + + expect(result.depthStencilAttachment?.stencilClearValue).toBe(0); + }); + + it("should set depthLoadOp to clear", () => + { + const attachment = createMockAttachment(true, false, true); + + const result = execute(attachment, 0, 0, 0, 1, "clear"); + + expect(result.depthStencilAttachment?.depthLoadOp).toBe("clear"); + }); + + it("should set depthStoreOp to store", () => + { + const attachment = createMockAttachment(true, false, true); + + const result = execute(attachment, 0, 0, 0, 1, "clear"); + + expect(result.depthStencilAttachment?.depthStoreOp).toBe("store"); + }); + + it("should set stencilLoadOp to clear", () => + { + const attachment = createMockAttachment(true, false, true); + + const result = execute(attachment, 0, 0, 0, 1, "clear"); + + expect(result.depthStencilAttachment?.stencilLoadOp).toBe("clear"); + }); + + it("should set stencilStoreOp to store", () => + { + const attachment = createMockAttachment(true, false, true); + + const result = execute(attachment, 0, 0, 0, 1, "clear"); + + expect(result.depthStencilAttachment?.stencilStoreOp).toBe("store"); + }); + }); +}); diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateRenderPassDescriptorService.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateRenderPassDescriptorService.ts new file mode 100644 index 00000000..77628233 --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateRenderPassDescriptorService.ts @@ -0,0 +1,51 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; + +const $clearValue: GPUColorDict = { "r": 0, "g": 0, "b": 0, "a": 0 }; +const $colorAttachment: GPURenderPassColorAttachment = { + "view": null as unknown as GPUTextureView, + "loadOp": "clear", + "storeOp": "store", + "clearValue": $clearValue +}; +const $depthStencilAttachment: GPURenderPassDepthStencilAttachment = { + "view": null as unknown as GPUTextureView, + "depthLoadOp": "clear", + "depthStoreOp": "store", + "depthClearValue": 1.0, + "stencilLoadOp": "clear", + "stencilStoreOp": "store", + "stencilClearValue": 0 +}; +const $descriptor: GPURenderPassDescriptor = { + "colorAttachments": [$colorAttachment] +}; + +/** + * @description レンダーパスディスクリプタを作成(プリアロケート再利用) + */ +export const execute = ( + attachment: IAttachmentObject, + r: number, + g: number, + b: number, + a: number, + loadOp: GPULoadOp = "clear" +): GPURenderPassDescriptor => { + const colorView = attachment.color?.view ?? attachment.texture?.view; + if (!colorView) { + throw new Error("No color view available for render pass"); + } + $colorAttachment.view = colorView; + $colorAttachment.loadOp = loadOp; + $clearValue.r = r; + $clearValue.g = g; + $clearValue.b = b; + $clearValue.a = a; + if (attachment.stencil?.view) { + $depthStencilAttachment.view = attachment.stencil.view; + $descriptor.depthStencilAttachment = $depthStencilAttachment; + } else { + $descriptor.depthStencilAttachment = undefined; + } + return $descriptor; +}; diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateStencilBufferService.test.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateStencilBufferService.test.ts new file mode 100644 index 00000000..83a6cea0 --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateStencilBufferService.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { execute } from "./AttachmentManagerCreateStencilBufferService"; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + RENDER_ATTACHMENT: 0x10 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +describe("AttachmentManagerCreateStencilBufferService", () => +{ + let idCounter: { stencilId: number }; + + const createMockDevice = () => + { + const mockView = { "label": "mockView" }; + const mockTexture = { + "createView": vi.fn(() => mockView) + }; + return { + "createTexture": vi.fn(() => mockTexture), + "_mockTexture": mockTexture, + "_mockView": mockView + } as unknown as GPUDevice & { _mockTexture: any; _mockView: any }; + }; + + beforeEach(() => + { + idCounter = { "stencilId": 0 }; + }); + + it("should create texture with correct dimensions", () => + { + const device = createMockDevice(); + + execute(device, 256, 512, idCounter); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "size": { "width": 256, "height": 512 } + }) + ); + }); + + it("should create texture with depth24plus-stencil8 format", () => + { + const device = createMockDevice(); + + execute(device, 256, 256, idCounter); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "format": "depth24plus-stencil8" + }) + ); + }); + + it("should create texture with RENDER_ATTACHMENT usage", () => + { + const device = createMockDevice(); + + execute(device, 256, 256, idCounter); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "usage": GPUTextureUsage.RENDER_ATTACHMENT + }) + ); + }); + + it("should return object with incremented id", () => + { + const device = createMockDevice(); + idCounter.stencilId = 5; + + const result = execute(device, 256, 256, idCounter); + + expect(result.id).toBe(5); + expect(idCounter.stencilId).toBe(6); + }); + + it("should increment id counter for each call", () => + { + const device = createMockDevice(); + + const result1 = execute(device, 256, 256, idCounter); + const result2 = execute(device, 256, 256, idCounter); + const result3 = execute(device, 256, 256, idCounter); + + expect(result1.id).toBe(0); + expect(result2.id).toBe(1); + expect(result3.id).toBe(2); + }); + + it("should return object with resource property", () => + { + const device = createMockDevice(); + + const result = execute(device, 256, 256, idCounter); + + expect(result.resource).toBeDefined(); + }); + + it("should return object with view from createView", () => + { + const device = createMockDevice(); + + const result = execute(device, 256, 256, idCounter); + + expect(result.view).toBe((device as any)._mockView); + }); + + it("should return object with correct dimensions", () => + { + const device = createMockDevice(); + + const result = execute(device, 800, 600, idCounter); + + expect(result.width).toBe(800); + expect(result.height).toBe(600); + }); + + it("should return object with calculated area", () => + { + const device = createMockDevice(); + + const result = execute(device, 100, 50, idCounter); + + expect(result.area).toBe(5000); + }); + + it("should return object with dirty set to false", () => + { + const device = createMockDevice(); + + const result = execute(device, 256, 256, idCounter); + + expect(result.dirty).toBe(false); + }); +}); diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateStencilBufferService.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateStencilBufferService.ts new file mode 100644 index 00000000..367d1639 --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateStencilBufferService.ts @@ -0,0 +1,36 @@ +import type { IStencilBufferObject } from "../../interface/IStencilBufferObject"; + +/** + * @description ステンシルバッファを新規作成 + * Create a new stencil buffer + * + * @param {GPUDevice} device + * @param {number} width + * @param {number} height + * @param {{ stencilId: number }} idCounter + * @return {IStencilBufferObject} + * @method + * @protected + */ +export const execute = ( + device: GPUDevice, + width: number, + height: number, + idCounter: { stencilId: number } +): IStencilBufferObject => { + const texture = device.createTexture({ + "size": { width, height }, + "format": "depth24plus-stencil8", + "usage": GPUTextureUsage.RENDER_ATTACHMENT + }); + + return { + "id": idCounter.stencilId++, + "resource": texture, + "view": texture.createView(), + width, + height, + "area": width * height, + "dirty": false + }; +}; diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateTextureObjectService.test.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateTextureObjectService.test.ts new file mode 100644 index 00000000..5599b314 --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateTextureObjectService.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi } from "vitest"; +import { execute } from "./AttachmentManagerCreateTextureObjectService"; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + RENDER_ATTACHMENT: 0x10, + TEXTURE_BINDING: 0x04, + COPY_SRC: 0x01, + COPY_DST: 0x02 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +describe("AttachmentManagerCreateTextureObjectService", () => +{ + const createMockDevice = () => + { + const mockView = { "label": "mockView" }; + const mockTexture = { + "createView": vi.fn(() => mockView) + }; + + return { + "createTexture": vi.fn(() => mockTexture), + "_mockTexture": mockTexture, + "_mockView": mockView + } as unknown as GPUDevice & { _mockTexture: any; _mockView: any }; + }; + + describe("texture creation", () => + { + it("should create texture with correct size", () => + { + const device = createMockDevice(); + const idCounter = { textureId: 1 }; + + execute(device, 256, 128, true, idCounter); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "size": { "width": 256, "height": 128 } + }) + ); + }); + + it("should create texture with rgba8unorm format", () => + { + const device = createMockDevice(); + const idCounter = { textureId: 1 }; + + execute(device, 256, 128, true, idCounter); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "format": "rgba8unorm" + }) + ); + }); + + it("should create texture with correct usage flags", () => + { + const device = createMockDevice(); + const idCounter = { textureId: 1 }; + + execute(device, 256, 128, true, idCounter); + + const expectedUsage = + GPUTextureUsage.RENDER_ATTACHMENT | + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_SRC | + GPUTextureUsage.COPY_DST; + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "usage": expectedUsage + }) + ); + }); + + it("should create view from texture", () => + { + const device = createMockDevice(); + const idCounter = { textureId: 1 }; + + execute(device, 256, 128, true, idCounter); + + expect((device as any)._mockTexture.createView).toHaveBeenCalled(); + }); + }); + + describe("returned texture object", () => + { + it("should return object with incremented id", () => + { + const device = createMockDevice(); + const idCounter = { textureId: 5 }; + + const result = execute(device, 256, 128, true, idCounter); + + expect(result.id).toBe(5); + expect(idCounter.textureId).toBe(6); + }); + + it("should return object with texture resource", () => + { + const device = createMockDevice(); + const idCounter = { textureId: 1 }; + + const result = execute(device, 256, 128, true, idCounter); + + expect(result.resource).toBe((device as any)._mockTexture); + }); + + it("should return object with view", () => + { + const device = createMockDevice(); + const idCounter = { textureId: 1 }; + + const result = execute(device, 256, 128, true, idCounter); + + expect(result.view).toBe((device as any)._mockView); + }); + + it("should return object with correct width", () => + { + const device = createMockDevice(); + const idCounter = { textureId: 1 }; + + const result = execute(device, 512, 256, true, idCounter); + + expect(result.width).toBe(512); + }); + + it("should return object with correct height", () => + { + const device = createMockDevice(); + const idCounter = { textureId: 1 }; + + const result = execute(device, 512, 256, true, idCounter); + + expect(result.height).toBe(256); + }); + + it("should return object with calculated area", () => + { + const device = createMockDevice(); + const idCounter = { textureId: 1 }; + + const result = execute(device, 100, 50, true, idCounter); + + expect(result.area).toBe(5000); // 100 * 50 + }); + + it("should return object with smooth flag when true", () => + { + const device = createMockDevice(); + const idCounter = { textureId: 1 }; + + const result = execute(device, 256, 128, true, idCounter); + + expect(result.smooth).toBe(true); + }); + + it("should return object with smooth flag when false", () => + { + const device = createMockDevice(); + const idCounter = { textureId: 1 }; + + const result = execute(device, 256, 128, false, idCounter); + + expect(result.smooth).toBe(false); + }); + }); + + describe("id counter behavior", () => + { + it("should increment id for each call", () => + { + const device = createMockDevice(); + const idCounter = { textureId: 10 }; + + const result1 = execute(device, 256, 128, true, idCounter); + const result2 = execute(device, 256, 128, true, idCounter); + const result3 = execute(device, 256, 128, true, idCounter); + + expect(result1.id).toBe(10); + expect(result2.id).toBe(11); + expect(result3.id).toBe(12); + expect(idCounter.textureId).toBe(13); + }); + }); +}); diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateTextureObjectService.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateTextureObjectService.ts new file mode 100644 index 00000000..5b03d7d7 --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerCreateTextureObjectService.ts @@ -0,0 +1,43 @@ +import type { ITextureObject } from "../../interface/ITextureObject"; + +/** + * @description テクスチャオブジェクトを新規作成 + * Create a new texture object + * + * @param {GPUDevice} device + * @param {number} width + * @param {number} height + * @param {boolean} smooth + * @param {{ textureId: number }} idCounter + * @return {ITextureObject} + * @method + * @protected + */ +export const execute = ( + device: GPUDevice, + width: number, + height: number, + smooth: boolean, + idCounter: { textureId: number } +): ITextureObject => { + const texture = device.createTexture({ + "size": { width, height }, + "format": "rgba8unorm", + "usage": GPUTextureUsage.RENDER_ATTACHMENT | + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_SRC | + GPUTextureUsage.COPY_DST + }); + + const view = texture.createView(); + + return { + "id": idCounter.textureId++, + "resource": texture, + view, + width, + height, + "area": width * height, + smooth + }; +}; diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetColorBufferService.test.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetColorBufferService.test.ts new file mode 100644 index 00000000..cedbe1de --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetColorBufferService.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IColorBufferObject } from "../../interface/IColorBufferObject"; +import type { IStencilBufferObject } from "../../interface/IStencilBufferObject"; +import { execute } from "./AttachmentManagerGetColorBufferService"; + +// Mock the create service +vi.mock("./AttachmentManagerCreateColorBufferService", () => ({ + "execute": vi.fn((device, width, height, stencil) => ({ + "id": Math.random(), + width, + height, + stencil, + "dirty": false, + "resource": {}, + "view": {} + })) +})); + +describe("AttachmentManagerGetColorBufferService", () => +{ + let colorBufferPool: IColorBufferObject[]; + let mockStencil: IStencilBufferObject; + + const createMockDevice = () => + { + return {} as GPUDevice; + }; + + const createPoolEntry = ( + width: number, + height: number + ): IColorBufferObject => ({ + "id": Math.random(), + width, + height, + "stencil": null as unknown as IStencilBufferObject, + "dirty": true, + "resource": {} as GPUTexture, + "view": {} as GPUTextureView + }); + + beforeEach(() => + { + colorBufferPool = []; + mockStencil = { "id": 1 } as IStencilBufferObject; + vi.clearAllMocks(); + }); + + it("should return buffer from pool if size matches", () => + { + const entry = createPoolEntry(256, 256); + colorBufferPool.push(entry); + const device = createMockDevice(); + + const result = execute(device, colorBufferPool, 256, 256, mockStencil); + + expect(result).toBe(entry); + expect(colorBufferPool.length).toBe(0); // Removed from pool + }); + + it("should update stencil reference when acquiring from pool", () => + { + const entry = createPoolEntry(256, 256); + colorBufferPool.push(entry); + const device = createMockDevice(); + + const result = execute(device, colorBufferPool, 256, 256, mockStencil); + + expect(result.stencil).toBe(mockStencil); + }); + + it("should reset dirty flag when acquiring from pool", () => + { + const entry = createPoolEntry(256, 256); + entry.dirty = true; + colorBufferPool.push(entry); + const device = createMockDevice(); + + const result = execute(device, colorBufferPool, 256, 256, mockStencil); + + expect(result.dirty).toBe(false); + }); + + it("should accept larger buffer from pool", () => + { + const largerEntry = createPoolEntry(512, 512); + colorBufferPool.push(largerEntry); + const device = createMockDevice(); + + const result = execute(device, colorBufferPool, 256, 256, mockStencil); + + expect(result).toBe(largerEntry); + }); + + it("should skip smaller buffer in pool", () => + { + const smallEntry = createPoolEntry(128, 128); + colorBufferPool.push(smallEntry); + const device = createMockDevice(); + + const result = execute(device, colorBufferPool, 256, 256, mockStencil); + + expect(result).not.toBe(smallEntry); + expect(colorBufferPool.length).toBe(1); // Small entry still in pool + }); + + it("should pick first suitable buffer", () => + { + const entry1 = createPoolEntry(512, 512); + const entry2 = createPoolEntry(256, 256); + colorBufferPool.push(entry1, entry2); + const device = createMockDevice(); + + const result = execute(device, colorBufferPool, 256, 256, mockStencil); + + expect(result).toBe(entry1); // First one that fits + expect(colorBufferPool.length).toBe(1); + }); + + it("should create new buffer when pool is empty", () => + { + const device = createMockDevice(); + + const result = execute(device, colorBufferPool, 256, 256, mockStencil); + + expect(result.width).toBe(256); + expect(result.height).toBe(256); + }); + + it("should handle non-square dimensions", () => + { + const entry = createPoolEntry(1024, 512); + colorBufferPool.push(entry); + const device = createMockDevice(); + + const result = execute(device, colorBufferPool, 800, 400, mockStencil); + + expect(result).toBe(entry); + }); + + it("should reject buffer with insufficient width", () => + { + const entry = createPoolEntry(200, 512); + colorBufferPool.push(entry); + const device = createMockDevice(); + + const result = execute(device, colorBufferPool, 256, 256, mockStencil); + + expect(result).not.toBe(entry); + }); + + it("should reject buffer with insufficient height", () => + { + const entry = createPoolEntry(512, 200); + colorBufferPool.push(entry); + const device = createMockDevice(); + + const result = execute(device, colorBufferPool, 256, 256, mockStencil); + + expect(result).not.toBe(entry); + }); +}); diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetColorBufferService.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetColorBufferService.ts new file mode 100644 index 00000000..d43ed5c0 --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetColorBufferService.ts @@ -0,0 +1,38 @@ +import type { IColorBufferObject } from "../../interface/IColorBufferObject"; +import type { IStencilBufferObject } from "../../interface/IStencilBufferObject"; +import { execute as attachmentManagerCreateColorBufferService } from "./AttachmentManagerCreateColorBufferService"; + +/** + * @description カラーバッファを取得(プールから再利用または新規作成) + * Get color buffer from pool or create new one + * + * @param {GPUDevice} device + * @param {IColorBufferObject[]} colorBufferPool + * @param {number} width + * @param {number} height + * @param {IStencilBufferObject} stencil + * @return {IColorBufferObject} + * @method + * @protected + */ +export const execute = ( + device: GPUDevice, + colorBufferPool: IColorBufferObject[], + width: number, + height: number, + stencil: IStencilBufferObject +): IColorBufferObject => { + // プールから適切なサイズのものを検索 + for (let i = 0; i < colorBufferPool.length; i++) { + const buffer = colorBufferPool[i]; + if (buffer.width >= width && buffer.height >= height) { + colorBufferPool.splice(i, 1); + buffer.stencil = stencil; + buffer.dirty = false; + return buffer; + } + } + + // 新規作成 + return attachmentManagerCreateColorBufferService(device, width, height, stencil); +}; diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetStencilBufferService.test.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetStencilBufferService.test.ts new file mode 100644 index 00000000..19c4d1d2 --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetStencilBufferService.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IStencilBufferObject } from "../../interface/IStencilBufferObject"; +import { execute } from "./AttachmentManagerGetStencilBufferService"; + +// Mock the create service +vi.mock("./AttachmentManagerCreateStencilBufferService", () => ({ + "execute": vi.fn((device, width, height, idCounter) => { + const id = ++idCounter.stencilId; + return { + id, + width, + height, + "dirty": false, + "resource": {}, + "view": {} + }; + }) +})); + +describe("AttachmentManagerGetStencilBufferService", () => +{ + let stencilBufferPool: IStencilBufferObject[]; + let idCounter: { stencilId: number }; + + const createMockDevice = () => + { + return {} as GPUDevice; + }; + + const createPoolEntry = ( + width: number, + height: number + ): IStencilBufferObject => ({ + "id": Math.random(), + width, + height, + "dirty": true, + "resource": {} as GPUTexture, + "view": {} as GPUTextureView + }); + + beforeEach(() => + { + stencilBufferPool = []; + idCounter = { "stencilId": 0 }; + vi.clearAllMocks(); + }); + + it("should return buffer from pool if size matches", () => + { + const entry = createPoolEntry(256, 256); + stencilBufferPool.push(entry); + const device = createMockDevice(); + + const result = execute(device, stencilBufferPool, 256, 256, idCounter); + + expect(result).toBe(entry); + expect(stencilBufferPool.length).toBe(0); + }); + + it("should reset dirty flag when acquiring from pool", () => + { + const entry = createPoolEntry(256, 256); + entry.dirty = true; + stencilBufferPool.push(entry); + const device = createMockDevice(); + + const result = execute(device, stencilBufferPool, 256, 256, idCounter); + + expect(result.dirty).toBe(false); + }); + + it("should accept larger buffer from pool", () => + { + const largerEntry = createPoolEntry(512, 512); + stencilBufferPool.push(largerEntry); + const device = createMockDevice(); + + const result = execute(device, stencilBufferPool, 256, 256, idCounter); + + expect(result).toBe(largerEntry); + }); + + it("should skip smaller buffer in pool", () => + { + const smallEntry = createPoolEntry(128, 128); + stencilBufferPool.push(smallEntry); + const device = createMockDevice(); + + const result = execute(device, stencilBufferPool, 256, 256, idCounter); + + expect(result).not.toBe(smallEntry); + expect(stencilBufferPool.length).toBe(1); + }); + + it("should create new buffer when pool is empty", () => + { + const device = createMockDevice(); + + const result = execute(device, stencilBufferPool, 256, 256, idCounter); + + expect(result.width).toBe(256); + expect(result.height).toBe(256); + }); + + it("should increment id counter when creating new", () => + { + const device = createMockDevice(); + + execute(device, stencilBufferPool, 256, 256, idCounter); + + expect(idCounter.stencilId).toBe(1); + }); + + it("should pick first suitable buffer", () => + { + const entry1 = createPoolEntry(512, 512); + const entry2 = createPoolEntry(256, 256); + stencilBufferPool.push(entry1, entry2); + const device = createMockDevice(); + + const result = execute(device, stencilBufferPool, 256, 256, idCounter); + + expect(result).toBe(entry1); + }); + + it("should handle non-square dimensions", () => + { + const entry = createPoolEntry(1024, 512); + stencilBufferPool.push(entry); + const device = createMockDevice(); + + const result = execute(device, stencilBufferPool, 800, 400, idCounter); + + expect(result).toBe(entry); + }); +}); diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetStencilBufferService.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetStencilBufferService.ts new file mode 100644 index 00000000..b49cc1c4 --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetStencilBufferService.ts @@ -0,0 +1,36 @@ +import type { IStencilBufferObject } from "../../interface/IStencilBufferObject"; +import { execute as attachmentManagerCreateStencilBufferService } from "./AttachmentManagerCreateStencilBufferService"; + +/** + * @description ステンシルバッファを取得(プールから再利用または新規作成) + * Get stencil buffer from pool or create new one + * + * @param {GPUDevice} device + * @param {IStencilBufferObject[]} stencilBufferPool + * @param {number} width + * @param {number} height + * @param {{ stencilId: number }} idCounter + * @return {IStencilBufferObject} + * @method + * @protected + */ +export const execute = ( + device: GPUDevice, + stencilBufferPool: IStencilBufferObject[], + width: number, + height: number, + idCounter: { stencilId: number } +): IStencilBufferObject => { + // プールから適切なサイズのものを検索 + for (let i = 0; i < stencilBufferPool.length; i++) { + const buffer = stencilBufferPool[i]; + if (buffer.width >= width && buffer.height >= height) { + stencilBufferPool.splice(i, 1); + buffer.dirty = false; + return buffer; + } + } + + // 新規作成 + return attachmentManagerCreateStencilBufferService(device, width, height, idCounter); +}; diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetTextureService.test.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetTextureService.test.ts new file mode 100644 index 00000000..c4a33f57 --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetTextureService.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ITextureObject } from "../../interface/ITextureObject"; +import { execute } from "./AttachmentManagerGetTextureService"; + +// Mock the create service +vi.mock("./AttachmentManagerCreateTextureObjectService", () => ({ + "execute": vi.fn((device, width, height, smooth, idCounter) => { + const id = ++idCounter.textureId; + return { + id, + width, + height, + smooth, + "resource": {}, + "view": {} + }; + }) +})); + +describe("AttachmentManagerGetTextureService", () => +{ + let texturePool: Map; + let idCounter: { textureId: number }; + + const createMockDevice = () => + { + return {} as GPUDevice; + }; + + const createPoolEntry = ( + width: number, + height: number, + smooth: boolean + ): ITextureObject => ({ + "id": Math.random(), + width, + height, + smooth, + "resource": {} as GPUTexture, + "view": {} as GPUTextureView + }); + + beforeEach(() => + { + texturePool = new Map(); + idCounter = { "textureId": 0 }; + vi.clearAllMocks(); + }); + + it("should return texture from pool if exact match exists", () => + { + const entry = createPoolEntry(256, 256, true); + texturePool.set("256x256_smooth", [entry]); + const device = createMockDevice(); + + const result = execute(device, texturePool, 256, 256, true, idCounter); + + expect(result).toBe(entry); + expect(texturePool.get("256x256_smooth")).toHaveLength(0); + }); + + it("should generate correct key for smooth textures", () => + { + const entry = createPoolEntry(512, 512, true); + texturePool.set("512x512_smooth", [entry]); + const device = createMockDevice(); + + const result = execute(device, texturePool, 512, 512, true, idCounter); + + expect(result).toBe(entry); + }); + + it("should generate correct key for nearest textures", () => + { + const entry = createPoolEntry(256, 256, false); + texturePool.set("256x256_nearest", [entry]); + const device = createMockDevice(); + + const result = execute(device, texturePool, 256, 256, false, idCounter); + + expect(result).toBe(entry); + }); + + it("should not return smooth texture for nearest request", () => + { + const smoothEntry = createPoolEntry(256, 256, true); + texturePool.set("256x256_smooth", [smoothEntry]); + const device = createMockDevice(); + + const result = execute(device, texturePool, 256, 256, false, idCounter); + + expect(result).not.toBe(smoothEntry); + }); + + it("should create new texture when pool is empty", () => + { + const device = createMockDevice(); + + const result = execute(device, texturePool, 256, 256, true, idCounter); + + expect(result.width).toBe(256); + expect(result.height).toBe(256); + expect(result.smooth).toBe(true); + }); + + it("should create new texture when key doesn't exist", () => + { + texturePool.set("128x128_smooth", [createPoolEntry(128, 128, true)]); + const device = createMockDevice(); + + const result = execute(device, texturePool, 256, 256, true, idCounter); + + expect(result.width).toBe(256); + }); + + it("should increment id counter when creating new", () => + { + const device = createMockDevice(); + + execute(device, texturePool, 256, 256, true, idCounter); + + expect(idCounter.textureId).toBe(1); + }); + + it("should pop last entry from pool (LIFO)", () => + { + const entry1 = createPoolEntry(256, 256, true); + const entry2 = createPoolEntry(256, 256, true); + texturePool.set("256x256_smooth", [entry1, entry2]); + const device = createMockDevice(); + + const result = execute(device, texturePool, 256, 256, true, idCounter); + + expect(result).toBe(entry2); // Last one + expect(texturePool.get("256x256_smooth")).toContain(entry1); + }); + + it("should handle non-square dimensions", () => + { + const entry = createPoolEntry(1024, 512, false); + texturePool.set("1024x512_nearest", [entry]); + const device = createMockDevice(); + + const result = execute(device, texturePool, 1024, 512, false, idCounter); + + expect(result).toBe(entry); + }); + + it("should not reuse texture with different dimensions", () => + { + const entry = createPoolEntry(256, 256, true); + texturePool.set("256x256_smooth", [entry]); + const device = createMockDevice(); + + const result = execute(device, texturePool, 512, 512, true, idCounter); + + expect(result).not.toBe(entry); + expect(result.width).toBe(512); + }); +}); diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetTextureService.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetTextureService.ts new file mode 100644 index 00000000..300464bf --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerGetTextureService.ts @@ -0,0 +1,38 @@ +import type { ITextureObject } from "../../interface/ITextureObject"; +import { execute as attachmentManagerCreateTextureObjectService } from "./AttachmentManagerCreateTextureObjectService"; + +/** + * @description テクスチャオブジェクトを取得(プールから再利用または新規作成) + * Get texture object from pool or create new one + * + * @param {GPUDevice} device + * @param {Map} texturePool + * @param {number} width + * @param {number} height + * @param {boolean} smooth + * @param {{ textureId: number }} idCounter + * @return {ITextureObject} + * @method + * @protected + */ +export const execute = ( + device: GPUDevice, + texturePool: Map, + width: number, + height: number, + smooth: boolean, + idCounter: { textureId: number } +): ITextureObject => { + const key = `${width}x${height}_${smooth ? "smooth" : "nearest"}`; + + // プールから再利用 + if (texturePool.has(key)) { + const pool = texturePool.get(key)!; + if (pool.length > 0) { + return pool.pop()!; + } + } + + // 新規作成 + return attachmentManagerCreateTextureObjectService(device, width, height, smooth, idCounter); +}; diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerReleaseTextureService.test.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerReleaseTextureService.test.ts new file mode 100644 index 00000000..73618e5b --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerReleaseTextureService.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { ITextureObject } from "../../interface/ITextureObject"; +import { execute } from "./AttachmentManagerReleaseTextureService"; + +describe("AttachmentManagerReleaseTextureService", () => +{ + let texturePool: Map; + + const createMockTexture = ( + width: number, + height: number, + smooth: boolean + ): ITextureObject => ({ + "id": Math.random(), + width, + height, + smooth, + "resource": {} as GPUTexture, + "view": {} as GPUTextureView + }); + + beforeEach(() => + { + texturePool = new Map(); + }); + + it("should add texture to pool with correct key", () => + { + const texture = createMockTexture(256, 256, true); + + execute(texturePool, texture); + + expect(texturePool.has("256x256_smooth")).toBe(true); + expect(texturePool.get("256x256_smooth")).toContain(texture); + }); + + it("should use 'nearest' key for non-smooth textures", () => + { + const texture = createMockTexture(512, 512, false); + + execute(texturePool, texture); + + expect(texturePool.has("512x512_nearest")).toBe(true); + }); + + it("should create new array if key doesn't exist", () => + { + const texture = createMockTexture(128, 128, true); + + execute(texturePool, texture); + + expect(texturePool.get("128x128_smooth")).toHaveLength(1); + }); + + it("should append to existing array", () => + { + const texture1 = createMockTexture(256, 256, true); + const texture2 = createMockTexture(256, 256, true); + + execute(texturePool, texture1); + execute(texturePool, texture2); + + const pool = texturePool.get("256x256_smooth"); + expect(pool).toHaveLength(2); + expect(pool).toContain(texture1); + expect(pool).toContain(texture2); + }); + + it("should handle different sizes separately", () => + { + const texture1 = createMockTexture(256, 256, true); + const texture2 = createMockTexture(512, 512, true); + + execute(texturePool, texture1); + execute(texturePool, texture2); + + expect(texturePool.size).toBe(2); + expect(texturePool.get("256x256_smooth")).toHaveLength(1); + expect(texturePool.get("512x512_smooth")).toHaveLength(1); + }); + + it("should handle same size but different smooth settings", () => + { + const textureSmooth = createMockTexture(256, 256, true); + const textureNearest = createMockTexture(256, 256, false); + + execute(texturePool, textureSmooth); + execute(texturePool, textureNearest); + + expect(texturePool.size).toBe(2); + expect(texturePool.has("256x256_smooth")).toBe(true); + expect(texturePool.has("256x256_nearest")).toBe(true); + }); + + it("should handle non-square textures", () => + { + const texture = createMockTexture(1024, 512, false); + + execute(texturePool, texture); + + expect(texturePool.has("1024x512_nearest")).toBe(true); + }); + + it("should preserve texture reference in pool", () => + { + const texture = createMockTexture(256, 256, true); + const originalId = texture.id; + + execute(texturePool, texture); + + const pooledTexture = texturePool.get("256x256_smooth")![0]; + expect(pooledTexture.id).toBe(originalId); + }); +}); diff --git a/packages/webgpu/src/AttachmentManager/service/AttachmentManagerReleaseTextureService.ts b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerReleaseTextureService.ts new file mode 100644 index 00000000..f202edda --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/service/AttachmentManagerReleaseTextureService.ts @@ -0,0 +1,24 @@ +import type { ITextureObject } from "../../interface/ITextureObject"; + +/** + * @description テクスチャをプールに返却 + * Release texture back to pool + * + * @param {Map} texturePool + * @param {ITextureObject} textureObject + * @return {void} + * @method + * @protected + */ +export const execute = ( + texturePool: Map, + textureObject: ITextureObject +): void => { + const key = `${textureObject.width}x${textureObject.height}_${textureObject.smooth ? "smooth" : "nearest"}`; + + if (!texturePool.has(key)) { + texturePool.set(key, []); + } + + texturePool.get(key)!.push(textureObject); +}; diff --git a/packages/webgpu/src/AttachmentManager/usecase/AttachmentManagerGetAttachmentObjectUseCase.test.ts b/packages/webgpu/src/AttachmentManager/usecase/AttachmentManagerGetAttachmentObjectUseCase.test.ts new file mode 100644 index 00000000..aae584d6 --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/usecase/AttachmentManagerGetAttachmentObjectUseCase.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { ITextureObject } from "../../interface/ITextureObject"; +import type { IColorBufferObject } from "../../interface/IColorBufferObject"; +import type { IStencilBufferObject } from "../../interface/IStencilBufferObject"; +import { execute } from "./AttachmentManagerGetAttachmentObjectUseCase"; + +// Mock the services +vi.mock("../service/AttachmentManagerCreateAttachmentObjectService", () => ({ + "execute": vi.fn((idCounter) => ({ + "id": idCounter.attachmentId++, + "width": 0, + "height": 0, + "clipLevel": 0, + "msaa": false, + "mask": false, + "color": null, + "texture": null, + "stencil": null, + "msaaTexture": null, + "msaaStencil": null + })) +})); + +vi.mock("../service/AttachmentManagerGetStencilBufferService", () => ({ + "execute": vi.fn(() => ({ "id": 1, "resource": {} } as IStencilBufferObject)) +})); + +vi.mock("../service/AttachmentManagerGetColorBufferService", () => ({ + "execute": vi.fn(() => ({ "id": 1, "view": {} } as IColorBufferObject)) +})); + +vi.mock("../service/AttachmentManagerGetTextureService", () => ({ + "execute": vi.fn(() => ({ + "id": 1, + "width": 256, + "height": 256, + "smooth": true + } as ITextureObject)) +})); + +import { execute as mockCreateAttachment } from "../service/AttachmentManagerCreateAttachmentObjectService"; +import { execute as mockGetStencilBuffer } from "../service/AttachmentManagerGetStencilBufferService"; +import { execute as mockGetColorBuffer } from "../service/AttachmentManagerGetColorBufferService"; +import { execute as mockGetTexture } from "../service/AttachmentManagerGetTextureService"; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + RENDER_ATTACHMENT: 0x10, + TEXTURE_BINDING: 0x04, + COPY_SRC: 0x01, + COPY_DST: 0x02 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +describe("AttachmentManagerGetAttachmentObjectUseCase", () => +{ + let attachmentPool: IAttachmentObject[]; + let texturePool: Map; + let colorBufferPool: IColorBufferObject[]; + let stencilBufferPool: IStencilBufferObject[]; + let idCounter: { attachmentId: number; textureId: number; stencilId: number }; + + const createMockDevice = () => + { + return {} as GPUDevice; + }; + + beforeEach(() => + { + attachmentPool = []; + texturePool = new Map(); + colorBufferPool = []; + stencilBufferPool = []; + idCounter = { attachmentId: 1, textureId: 1, stencilId: 1 }; + vi.clearAllMocks(); + }); + + describe("pool reuse", () => + { + it("should reuse attachment from pool when available", () => + { + const device = createMockDevice(); + const existingAttachment: IAttachmentObject = { + "id": 99, + "width": 100, + "height": 100, + "clipLevel": 5, + "msaa": true, + "mask": true, + "color": null, + "texture": null, + "stencil": null, + "msaaTexture": null, + "msaaStencil": null + }; + attachmentPool.push(existingAttachment); + + const result = execute( + device, attachmentPool, texturePool, colorBufferPool, + stencilBufferPool, 256, 256, false, idCounter + ); + + expect(result.id).toBe(99); + expect(mockCreateAttachment).not.toHaveBeenCalled(); + }); + + it("should create new attachment when pool is empty", () => + { + const device = createMockDevice(); + + execute( + device, attachmentPool, texturePool, colorBufferPool, + stencilBufferPool, 256, 256, false, idCounter + ); + + expect(mockCreateAttachment).toHaveBeenCalledWith(idCounter); + }); + + it("should remove attachment from pool when reusing", () => + { + const device = createMockDevice(); + const existingAttachment: IAttachmentObject = { + "id": 1, + "width": 0, + "height": 0, + "clipLevel": 0, + "msaa": false, + "mask": false, + "color": null, + "texture": null, + "stencil": null, + "msaaTexture": null, + "msaaStencil": null + }; + attachmentPool.push(existingAttachment); + expect(attachmentPool.length).toBe(1); + + execute( + device, attachmentPool, texturePool, colorBufferPool, + stencilBufferPool, 256, 256, false, idCounter + ); + + expect(attachmentPool.length).toBe(0); + }); + }); + + describe("property updates", () => + { + it("should update width", () => + { + const device = createMockDevice(); + + const result = execute( + device, attachmentPool, texturePool, colorBufferPool, + stencilBufferPool, 512, 256, false, idCounter + ); + + expect(result.width).toBe(512); + }); + + it("should update height", () => + { + const device = createMockDevice(); + + const result = execute( + device, attachmentPool, texturePool, colorBufferPool, + stencilBufferPool, 256, 512, false, idCounter + ); + + expect(result.height).toBe(512); + }); + + it("should update msaa flag to true", () => + { + const device = createMockDevice(); + + const result = execute( + device, attachmentPool, texturePool, colorBufferPool, + stencilBufferPool, 256, 256, true, idCounter + ); + + expect(result.msaa).toBe(true); + }); + + it("should update msaa flag to false", () => + { + const device = createMockDevice(); + + const result = execute( + device, attachmentPool, texturePool, colorBufferPool, + stencilBufferPool, 256, 256, false, idCounter + ); + + expect(result.msaa).toBe(false); + }); + + it("should reset mask to false", () => + { + const device = createMockDevice(); + const existingAttachment: IAttachmentObject = { + "id": 1, + "width": 0, + "height": 0, + "clipLevel": 0, + "msaa": false, + "mask": true, // previously had mask + "color": null, + "texture": null, + "stencil": null, + "msaaTexture": null, + "msaaStencil": null + }; + attachmentPool.push(existingAttachment); + + const result = execute( + device, attachmentPool, texturePool, colorBufferPool, + stencilBufferPool, 256, 256, false, idCounter + ); + + expect(result.mask).toBe(false); + }); + + it("should reset clipLevel to 0", () => + { + const device = createMockDevice(); + const existingAttachment: IAttachmentObject = { + "id": 1, + "width": 0, + "height": 0, + "clipLevel": 5, // previously had clipLevel + "msaa": false, + "mask": false, + "color": null, + "texture": null, + "stencil": null, + "msaaTexture": null, + "msaaStencil": null + }; + attachmentPool.push(existingAttachment); + + const result = execute( + device, attachmentPool, texturePool, colorBufferPool, + stencilBufferPool, 256, 256, false, idCounter + ); + + expect(result.clipLevel).toBe(0); + }); + }); + + describe("resource acquisition", () => + { + it("should acquire stencil buffer", () => + { + const device = createMockDevice(); + + execute( + device, attachmentPool, texturePool, colorBufferPool, + stencilBufferPool, 256, 256, false, idCounter + ); + + expect(mockGetStencilBuffer).toHaveBeenCalledWith( + device, stencilBufferPool, 256, 256, idCounter + ); + }); + + it("should acquire color buffer with stencil reference", () => + { + const device = createMockDevice(); + + execute( + device, attachmentPool, texturePool, colorBufferPool, + stencilBufferPool, 256, 256, false, idCounter + ); + + expect(mockGetColorBuffer).toHaveBeenCalledWith( + device, colorBufferPool, 256, 256, expect.any(Object) + ); + }); + + it("should acquire texture", () => + { + const device = createMockDevice(); + + execute( + device, attachmentPool, texturePool, colorBufferPool, + stencilBufferPool, 256, 256, false, idCounter + ); + + expect(mockGetTexture).toHaveBeenCalledWith( + device, texturePool, 256, 256, true, idCounter + ); + }); + + it("should assign acquired resources to attachment", () => + { + const device = createMockDevice(); + + const result = execute( + device, attachmentPool, texturePool, colorBufferPool, + stencilBufferPool, 256, 256, false, idCounter + ); + + expect(result.color).not.toBeNull(); + expect(result.stencil).not.toBeNull(); + expect(result.texture).not.toBeNull(); + }); + }); +}); diff --git a/packages/webgpu/src/AttachmentManager/usecase/AttachmentManagerGetAttachmentObjectUseCase.ts b/packages/webgpu/src/AttachmentManager/usecase/AttachmentManagerGetAttachmentObjectUseCase.ts new file mode 100644 index 00000000..a6fb60ae --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/usecase/AttachmentManagerGetAttachmentObjectUseCase.ts @@ -0,0 +1,82 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { ITextureObject } from "../../interface/ITextureObject"; +import type { IColorBufferObject } from "../../interface/IColorBufferObject"; +import type { IStencilBufferObject } from "../../interface/IStencilBufferObject"; +import { execute as attachmentManagerCreateAttachmentObjectService } from "../service/AttachmentManagerCreateAttachmentObjectService"; +import { execute as attachmentManagerGetStencilBufferService } from "../service/AttachmentManagerGetStencilBufferService"; +import { execute as attachmentManagerGetColorBufferService } from "../service/AttachmentManagerGetColorBufferService"; +import { execute as attachmentManagerGetTextureService } from "../service/AttachmentManagerGetTextureService"; + +/** + * @description アタッチメントオブジェクトを取得 + * Get attachment object + * + * @param {GPUDevice} device + * @param {IAttachmentObject[]} attachmentPool + * @param {Map} texturePool + * @param {IColorBufferObject[]} colorBufferPool + * @param {IStencilBufferObject[]} stencilBufferPool + * @param {number} width + * @param {number} height + * @param {boolean} msaa + * @param {{ attachmentId: number, textureId: number, stencilId: number }} idCounter + * @return {IAttachmentObject} + * @method + * @protected + */ +export const execute = ( + device: GPUDevice, + attachmentPool: IAttachmentObject[], + texturePool: Map, + colorBufferPool: IColorBufferObject[], + stencilBufferPool: IStencilBufferObject[], + width: number, + height: number, + msaa: boolean, + idCounter: { attachmentId: number; textureId: number; stencilId: number } +): IAttachmentObject => { + // プールから再利用 + const attachment = attachmentPool.length > 0 + ? attachmentPool.pop()! + : attachmentManagerCreateAttachmentObjectService(idCounter); + + // サイズとフラグを更新 + attachment.width = width; + attachment.height = height; + attachment.msaa = msaa; + attachment.mask = false; + attachment.clipLevel = 0; + + // ステンシルバッファを取得または作成 + const stencil = attachmentManagerGetStencilBufferService( + device, + stencilBufferPool, + width, + height, + idCounter + ); + + // カラーバッファを取得または作成(ステンシルを参照) + const color = attachmentManagerGetColorBufferService( + device, + colorBufferPool, + width, + height, + stencil + ); + attachment.color = color; + attachment.stencil = stencil; + + // テクスチャを取得 + const texture = attachmentManagerGetTextureService( + device, + texturePool, + width, + height, + true, + idCounter + ); + attachment.texture = texture; + + return attachment; +}; diff --git a/packages/webgpu/src/AttachmentManager/usecase/AttachmentManagerReleaseAttachmentUseCase.test.ts b/packages/webgpu/src/AttachmentManager/usecase/AttachmentManagerReleaseAttachmentUseCase.test.ts new file mode 100644 index 00000000..a7adea7e --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/usecase/AttachmentManagerReleaseAttachmentUseCase.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { ITextureObject } from "../../interface/ITextureObject"; +import type { IColorBufferObject } from "../../interface/IColorBufferObject"; +import type { IStencilBufferObject } from "../../interface/IStencilBufferObject"; +import { execute } from "./AttachmentManagerReleaseAttachmentUseCase"; + +// Mock the service +vi.mock("../service/AttachmentManagerReleaseTextureService", () => ({ + "execute": vi.fn() +})); + +import { execute as mockReleaseTexture } from "../service/AttachmentManagerReleaseTextureService"; + +describe("AttachmentManagerReleaseAttachmentUseCase", () => +{ + let attachmentPool: IAttachmentObject[]; + let texturePool: Map; + let colorBufferPool: IColorBufferObject[]; + let stencilBufferPool: IStencilBufferObject[]; + + const createMockAttachment = ( + hasTexture: boolean = false, + hasColor: boolean = false, + hasStencil: boolean = false + ): IAttachmentObject => ({ + "id": 1, + "width": 256, + "height": 256, + "texture": hasTexture ? { "id": 1, "width": 256, "height": 256, "smooth": true } as ITextureObject : null, + "color": hasColor ? { "id": 1 } as IColorBufferObject : null, + "stencil": hasStencil ? { "id": 1 } as IStencilBufferObject : null + } as IAttachmentObject); + + beforeEach(() => + { + attachmentPool = []; + texturePool = new Map(); + colorBufferPool = []; + stencilBufferPool = []; + vi.clearAllMocks(); + }); + + it("should add attachment to pool", () => + { + const attachment = createMockAttachment(); + + execute(attachmentPool, texturePool, colorBufferPool, stencilBufferPool, attachment); + + expect(attachmentPool).toContain(attachment); + }); + + it("should release texture to texture pool", () => + { + const attachment = createMockAttachment(true, false, false); + const texture = attachment.texture; + + execute(attachmentPool, texturePool, colorBufferPool, stencilBufferPool, attachment); + + expect(mockReleaseTexture).toHaveBeenCalledWith(texturePool, texture); + }); + + it("should set texture to null after release", () => + { + const attachment = createMockAttachment(true, false, false); + + execute(attachmentPool, texturePool, colorBufferPool, stencilBufferPool, attachment); + + expect(attachment.texture).toBeNull(); + }); + + it("should release color buffer to pool", () => + { + const attachment = createMockAttachment(false, true, false); + const colorBuffer = attachment.color; + + execute(attachmentPool, texturePool, colorBufferPool, stencilBufferPool, attachment); + + expect(colorBufferPool).toContain(colorBuffer); + }); + + it("should set color to null after release", () => + { + const attachment = createMockAttachment(false, true, false); + + execute(attachmentPool, texturePool, colorBufferPool, stencilBufferPool, attachment); + + expect(attachment.color).toBeNull(); + }); + + it("should release stencil buffer to pool", () => + { + const attachment = createMockAttachment(false, false, true); + const stencilBuffer = attachment.stencil; + + execute(attachmentPool, texturePool, colorBufferPool, stencilBufferPool, attachment); + + expect(stencilBufferPool).toContain(stencilBuffer); + }); + + it("should set stencil to null after release", () => + { + const attachment = createMockAttachment(false, false, true); + + execute(attachmentPool, texturePool, colorBufferPool, stencilBufferPool, attachment); + + expect(attachment.stencil).toBeNull(); + }); + + it("should release all resources when all present", () => + { + const attachment = createMockAttachment(true, true, true); + + execute(attachmentPool, texturePool, colorBufferPool, stencilBufferPool, attachment); + + expect(mockReleaseTexture).toHaveBeenCalled(); + expect(colorBufferPool.length).toBe(1); + expect(stencilBufferPool.length).toBe(1); + expect(attachment.texture).toBeNull(); + expect(attachment.color).toBeNull(); + expect(attachment.stencil).toBeNull(); + }); + + it("should handle attachment with no resources", () => + { + const attachment = createMockAttachment(false, false, false); + + expect(() => + execute(attachmentPool, texturePool, colorBufferPool, stencilBufferPool, attachment) + ).not.toThrow(); + + expect(attachmentPool).toContain(attachment); + }); + + it("should not call releaseTexture when no texture", () => + { + const attachment = createMockAttachment(false, true, true); + + execute(attachmentPool, texturePool, colorBufferPool, stencilBufferPool, attachment); + + expect(mockReleaseTexture).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/webgpu/src/AttachmentManager/usecase/AttachmentManagerReleaseAttachmentUseCase.ts b/packages/webgpu/src/AttachmentManager/usecase/AttachmentManagerReleaseAttachmentUseCase.ts new file mode 100644 index 00000000..6987a867 --- /dev/null +++ b/packages/webgpu/src/AttachmentManager/usecase/AttachmentManagerReleaseAttachmentUseCase.ts @@ -0,0 +1,47 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { ITextureObject } from "../../interface/ITextureObject"; +import type { IColorBufferObject } from "../../interface/IColorBufferObject"; +import type { IStencilBufferObject } from "../../interface/IStencilBufferObject"; +import { execute as attachmentManagerReleaseTextureService } from "../service/AttachmentManagerReleaseTextureService"; + +/** + * @description アタッチメントを解放してプールに返却 + * Release attachment and return to pool + * + * @param {IAttachmentObject[]} attachmentPool + * @param {Map} texturePool + * @param {IColorBufferObject[]} colorBufferPool + * @param {IStencilBufferObject[]} stencilBufferPool + * @param {IAttachmentObject} attachment + * @return {void} + * @method + * @protected + */ +export const execute = ( + attachmentPool: IAttachmentObject[], + texturePool: Map, + colorBufferPool: IColorBufferObject[], + stencilBufferPool: IStencilBufferObject[], + attachment: IAttachmentObject +): void => { + // テクスチャをプールに返却 + if (attachment.texture) { + attachmentManagerReleaseTextureService(texturePool, attachment.texture); + attachment.texture = null; + } + + // カラーバッファをプールに返却 + if (attachment.color) { + colorBufferPool.push(attachment.color); + attachment.color = null; + } + + // ステンシルバッファをプールに返却 + if (attachment.stencil) { + stencilBufferPool.push(attachment.stencil); + attachment.stencil = null; + } + + // アタッチメントをプールに返却 + attachmentPool.push(attachment); +}; diff --git a/packages/webgpu/src/BezierConverter/BezierConverter.ts b/packages/webgpu/src/BezierConverter/BezierConverter.ts new file mode 100644 index 00000000..80c670a4 --- /dev/null +++ b/packages/webgpu/src/BezierConverter/BezierConverter.ts @@ -0,0 +1,20 @@ +/** + * @description 適応的ベジェ曲線テッセレーション + * Adaptive Bezier Curve Tessellation + * + * 三次ベジェ曲線を二次ベジェ曲線に変換する際、 + * フラットネス(平坦度)に基づいて動的に分割数を決定。 + * + * - 単純な曲線: 2分割程度 + * - 複雑な曲線: 最大8分割 + * + * これにより品質を維持しながら不要な計算を削減。 + */ +export { + execute as adaptiveCubicToQuad, + calculateAdaptiveThreshold +} from "./usecase/BezierConverterAdaptiveCubicToQuadUseCase"; +export type { IQuadraticSegment } from "../interface/IQuadraticSegment"; + +export { execute as calculateFlatness } from "./service/BezierConverterCalculateFlatnessService"; +export { execute as splitCubic } from "./service/BezierConverterSplitCubicService"; diff --git a/packages/webgpu/src/BezierConverter/service/BezierConverterCalculateFlatnessService.test.ts b/packages/webgpu/src/BezierConverter/service/BezierConverterCalculateFlatnessService.test.ts new file mode 100644 index 00000000..8d6cfc96 --- /dev/null +++ b/packages/webgpu/src/BezierConverter/service/BezierConverterCalculateFlatnessService.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import { execute } from "./BezierConverterCalculateFlatnessService"; + +describe("BezierConverterCalculateFlatnessService", () => +{ + it("should return 0 for a straight line (control points on line)", () => + { + // 直線の場合はフラットネス0 + const p0 = { x: 0, y: 0 }; + const p1 = { x: 1, y: 1 }; + const p2 = { x: 2, y: 2 }; + const p3 = { x: 3, y: 3 }; + + const flatness = execute(p0, p1, p2, p3); + expect(flatness).toBeCloseTo(0, 5); + }); + + it("should return positive value for curved bezier", () => + { + // 曲線の場合は正のフラットネス + const p0 = { x: 0, y: 0 }; + const p1 = { x: 0, y: 10 }; // 制御点が大きく外れている + const p2 = { x: 10, y: 10 }; + const p3 = { x: 10, y: 0 }; + + const flatness = execute(p0, p1, p2, p3); + expect(flatness).toBeGreaterThan(0); + }); + + it("should return larger value for more curved bezier", () => + { + // より曲がった曲線はより大きなフラットネス + const p0 = { x: 0, y: 0 }; + const p3 = { x: 10, y: 0 }; + + // 少し曲がった曲線 + const p1a = { x: 3, y: 2 }; + const p2a = { x: 7, y: 2 }; + const flatness1 = execute(p0, p1a, p2a, p3); + + // 大きく曲がった曲線 + const p1b = { x: 3, y: 10 }; + const p2b = { x: 7, y: 10 }; + const flatness2 = execute(p0, p1b, p2b, p3); + + expect(flatness2).toBeGreaterThan(flatness1); + }); + + it("should handle degenerate case where start equals end", () => + { + // 始点と終点が同じ場合 + const p0 = { x: 5, y: 5 }; + const p1 = { x: 0, y: 0 }; + const p2 = { x: 10, y: 10 }; + const p3 = { x: 5, y: 5 }; + + const flatness = execute(p0, p1, p2, p3); + expect(flatness).toBeGreaterThan(0); + // 制御点からの距離の2乗が返される + expect(flatness).toEqual(50); // max((5-0)^2 + (5-0)^2, (5-10)^2 + (5-10)^2) = 50 + }); + + it("should be symmetric for symmetric control points", () => + { + // 対称な制御点では対称なフラットネス + const p0 = { x: 0, y: 0 }; + const p3 = { x: 10, y: 0 }; + + const p1a = { x: 2, y: 5 }; + const p2a = { x: 8, y: 5 }; + const flatness1 = execute(p0, p1a, p2a, p3); + + const p1b = { x: 8, y: 5 }; + const p2b = { x: 2, y: 5 }; + const flatness2 = execute(p0, p1b, p2b, p3); + + expect(flatness1).toBeCloseTo(flatness2, 5); + }); +}); diff --git a/packages/webgpu/src/BezierConverter/service/BezierConverterCalculateFlatnessService.ts b/packages/webgpu/src/BezierConverter/service/BezierConverterCalculateFlatnessService.ts new file mode 100644 index 00000000..3c95a300 --- /dev/null +++ b/packages/webgpu/src/BezierConverter/service/BezierConverterCalculateFlatnessService.ts @@ -0,0 +1,51 @@ +import type { IPoint } from "../../interface/IPoint"; + +/** + * @description 三次ベジェ曲線のフラットネス(平坦度)を計算 + * Calculate flatness of cubic bezier curve + * + * フラットネスは曲線がどれだけ直線に近いかを示す値。 + * 制御点から始点-終点を結ぶ直線までの最大距離を計算。 + * + * @param {IPoint} p0 - 始点 + * @param {IPoint} p1 - 制御点1 + * @param {IPoint} p2 - 制御点2 + * @param {IPoint} p3 - 終点 + * @return {number} フラットネス値(距離の2乗) + */ +export const execute = ( + p0: IPoint, + p1: IPoint, + p2: IPoint, + p3: IPoint +): number => { + + // 始点から終点へのベクトル + const dx = p3.x - p0.x; + const dy = p3.y - p0.y; + + // ベクトルの長さの2乗 + const lengthSq = dx * dx + dy * dy; + + // 始点と終点が同じ場合、制御点からの最大距離を返す + if (lengthSq < 1e-10) { + const d1 = (p1.x - p0.x) * (p1.x - p0.x) + (p1.y - p0.y) * (p1.y - p0.y); + const d2 = (p2.x - p0.x) * (p2.x - p0.x) + (p2.y - p0.y) * (p2.y - p0.y); + return Math.max(d1, d2); + } + + // 制御点から直線への距離を計算(クロス積を使用) + // 距離 = |cross product| / |line length| + // 距離の2乗を計算して sqrt を避ける + + // 制御点1から直線への距離の2乗 + const cross1 = (p1.x - p0.x) * dy - (p1.y - p0.y) * dx; + const d1 = cross1 * cross1 / lengthSq; + + // 制御点2から直線への距離の2乗 + const cross2 = (p2.x - p0.x) * dy - (p2.y - p0.y) * dx; + const d2 = cross2 * cross2 / lengthSq; + + // 最大距離を返す + return Math.max(d1, d2); +}; diff --git a/packages/webgpu/src/BezierConverter/service/BezierConverterSplitCubicService.test.ts b/packages/webgpu/src/BezierConverter/service/BezierConverterSplitCubicService.test.ts new file mode 100644 index 00000000..a2b9b3a0 --- /dev/null +++ b/packages/webgpu/src/BezierConverter/service/BezierConverterSplitCubicService.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { execute } from "./BezierConverterSplitCubicService"; + +describe("BezierConverterSplitCubicService", () => +{ + it("should split a straight line correctly", () => + { + const p0 = { "x": 0, "y": 0 }; + const p1 = { "x": 10, "y": 0 }; + const p2 = { "x": 20, "y": 0 }; + const p3 = { "x": 30, "y": 0 }; + + const result = execute(p0, p1, p2, p3); + + // 分割点は中央にあるはず + expect(result.first[3].x).toBeCloseTo(15, 5); + expect(result.first[3].y).toBeCloseTo(0, 5); + expect(result.second[0].x).toBeCloseTo(15, 5); + expect(result.second[0].y).toBeCloseTo(0, 5); + }); + + it("should preserve start and end points", () => + { + const p0 = { "x": 0, "y": 0 }; + const p1 = { "x": 10, "y": 20 }; + const p2 = { "x": 20, "y": 20 }; + const p3 = { "x": 30, "y": 0 }; + + const result = execute(p0, p1, p2, p3); + + // 始点は保持 + expect(result.first[0]).toEqual(p0); + // 終点は保持 + expect(result.second[3]).toEqual(p3); + }); + + it("should have continuous split point", () => + { + const p0 = { "x": 0, "y": 0 }; + const p1 = { "x": 5, "y": 10 }; + const p2 = { "x": 15, "y": 10 }; + const p3 = { "x": 20, "y": 0 }; + + const result = execute(p0, p1, p2, p3); + + // 前半の終点と後半の始点は一致 + expect(result.first[3].x).toBeCloseTo(result.second[0].x, 10); + expect(result.first[3].y).toBeCloseTo(result.second[0].y, 10); + }); + + it("should return 4 points for each half", () => + { + const p0 = { "x": 0, "y": 0 }; + const p1 = { "x": 1, "y": 2 }; + const p2 = { "x": 3, "y": 2 }; + const p3 = { "x": 4, "y": 0 }; + + const result = execute(p0, p1, p2, p3); + + expect(result.first.length).toBe(4); + expect(result.second.length).toBe(4); + }); + + it("should handle symmetric curve", () => + { + const p0 = { "x": 0, "y": 0 }; + const p1 = { "x": 0, "y": 10 }; + const p2 = { "x": 10, "y": 10 }; + const p3 = { "x": 10, "y": 0 }; + + const result = execute(p0, p1, p2, p3); + + // 対称な曲線なので分割点はx=5にあるはず + expect(result.first[3].x).toBeCloseTo(5, 5); + }); + + it("should calculate midpoints correctly using De Casteljau", () => + { + const p0 = { "x": 0, "y": 0 }; + const p1 = { "x": 0, "y": 4 }; + const p2 = { "x": 4, "y": 4 }; + const p3 = { "x": 4, "y": 0 }; + + const result = execute(p0, p1, p2, p3); + + // Level 1 midpoints + const p01 = { "x": 0, "y": 2 }; + const p12 = { "x": 2, "y": 4 }; + const p23 = { "x": 4, "y": 2 }; + + // Check first curve control points + expect(result.first[1].x).toBeCloseTo(p01.x, 5); + expect(result.first[1].y).toBeCloseTo(p01.y, 5); + + // Check second curve control points + expect(result.second[2].x).toBeCloseTo(p23.x, 5); + expect(result.second[2].y).toBeCloseTo(p23.y, 5); + }); +}); diff --git a/packages/webgpu/src/BezierConverter/service/BezierConverterSplitCubicService.ts b/packages/webgpu/src/BezierConverter/service/BezierConverterSplitCubicService.ts new file mode 100644 index 00000000..c411fb1e --- /dev/null +++ b/packages/webgpu/src/BezierConverter/service/BezierConverterSplitCubicService.ts @@ -0,0 +1,59 @@ +import type { IPoint } from "../../interface/IPoint"; + +/** + * @description 三次ベジェ曲線をパラメータt=0.5で2分割 + * Split cubic bezier curve at t=0.5 using De Casteljau algorithm + * + * @param {IPoint} p0 - 始点 + * @param {IPoint} p1 - 制御点1 + * @param {IPoint} p2 - 制御点2 + * @param {IPoint} p3 - 終点 + * @return {{ first: IPoint[], second: IPoint[] }} 分割された2つの曲線 + */ +export const execute = ( + p0: IPoint, + p1: IPoint, + p2: IPoint, + p3: IPoint +): { first: IPoint[], second: IPoint[] } => { + + // De Casteljau algorithm at t = 0.5 + // 中点を計算することで精度を保ちながら分割 + + // レベル1: 各辺の中点 + const p01: IPoint = { + "x": (p0.x + p1.x) * 0.5, + "y": (p0.y + p1.y) * 0.5 + }; + const p12: IPoint = { + "x": (p1.x + p2.x) * 0.5, + "y": (p1.y + p2.y) * 0.5 + }; + const p23: IPoint = { + "x": (p2.x + p3.x) * 0.5, + "y": (p2.y + p3.y) * 0.5 + }; + + // レベル2: レベル1の中点 + const p012: IPoint = { + "x": (p01.x + p12.x) * 0.5, + "y": (p01.y + p12.y) * 0.5 + }; + const p123: IPoint = { + "x": (p12.x + p23.x) * 0.5, + "y": (p12.y + p23.y) * 0.5 + }; + + // レベル3: 分割点 + const p0123: IPoint = { + "x": (p012.x + p123.x) * 0.5, + "y": (p012.y + p123.y) * 0.5 + }; + + return { + // 前半の曲線: p0 -> p01 -> p012 -> p0123 + "first": [p0, p01, p012, p0123], + // 後半の曲線: p0123 -> p123 -> p23 -> p3 + "second": [p0123, p123, p23, p3] + }; +}; diff --git a/packages/webgpu/src/BezierConverter/usecase/BezierConverterAdaptiveCubicToQuadUseCase.test.ts b/packages/webgpu/src/BezierConverter/usecase/BezierConverterAdaptiveCubicToQuadUseCase.test.ts new file mode 100644 index 00000000..c31e4d84 --- /dev/null +++ b/packages/webgpu/src/BezierConverter/usecase/BezierConverterAdaptiveCubicToQuadUseCase.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; +import { execute, calculateAdaptiveThreshold } from "./BezierConverterAdaptiveCubicToQuadUseCase"; + +describe("BezierConverterAdaptiveCubicToQuadUseCase", () => +{ + it("should convert straight line to single segment", () => + { + // 直線に近い曲線は1セグメントに + const p0 = { x: 0, y: 0 }; + const p1 = { x: 1, y: 0.1 }; // ほぼ直線 + const p2 = { x: 2, y: 0.1 }; + const p3 = { x: 3, y: 0 }; + + const segments = execute(p0, p1, p2, p3, 4.0); + + // 直線に近いので少ないセグメント + expect(segments.length).toBeLessThanOrEqual(2); + expect(segments.length).toBeGreaterThanOrEqual(1); + }); + + it("should create more segments for complex curve", () => + { + // 複雑な曲線は多いセグメントに + const p0 = { x: 0, y: 0 }; + const p1 = { x: 0, y: 100 }; // 大きく曲がる + const p2 = { x: 100, y: 100 }; + const p3 = { x: 100, y: 0 }; + + const segments = execute(p0, p1, p2, p3, 4.0); + + // 複雑なので多いセグメント + expect(segments.length).toBeGreaterThan(1); + }); + + it("should respect flatness threshold", () => + { + const p0 = { x: 0, y: 0 }; + const p1 = { x: 0, y: 50 }; + const p2 = { x: 100, y: 50 }; + const p3 = { x: 100, y: 0 }; + + // 低い閾値 = 高品質 = 多いセグメント + const highQuality = execute(p0, p1, p2, p3, 1.0); + + // 高い閾値 = 低品質 = 少ないセグメント + const lowQuality = execute(p0, p1, p2, p3, 100.0); + + expect(highQuality.length).toBeGreaterThanOrEqual(lowQuality.length); + }); + + it("should end at correct point", () => + { + const p0 = { x: 10, y: 20 }; + const p1 = { x: 30, y: 40 }; + const p2 = { x: 50, y: 60 }; + const p3 = { x: 70, y: 80 }; + + const segments = execute(p0, p1, p2, p3); + + // 最後のセグメントの終点が元の終点と一致 + const lastSegment = segments[segments.length - 1]; + expect(lastSegment.end.x).toBeCloseTo(p3.x, 5); + expect(lastSegment.end.y).toBeCloseTo(p3.y, 5); + }); + + it("should create valid quadratic bezier segments", () => + { + const p0 = { x: 0, y: 0 }; + const p1 = { x: 25, y: 50 }; + const p2 = { x: 75, y: 50 }; + const p3 = { x: 100, y: 0 }; + + const segments = execute(p0, p1, p2, p3); + + // 各セグメントが有効な構造を持つ + for (const segment of segments) { + expect(segment).toHaveProperty("ctrl"); + expect(segment).toHaveProperty("end"); + expect(typeof segment.ctrl.x).toBe("number"); + expect(typeof segment.ctrl.y).toBe("number"); + expect(typeof segment.end.x).toBe("number"); + expect(typeof segment.end.y).toBe("number"); + } + }); +}); + +describe("calculateAdaptiveThreshold", () => +{ + it("should return smaller threshold for larger scale", () => + { + const threshold1 = calculateAdaptiveThreshold(1.0); + const threshold2 = calculateAdaptiveThreshold(2.0); + + expect(threshold2).toBeLessThan(threshold1); + }); + + it("should return larger threshold for smaller scale", () => + { + const threshold1 = calculateAdaptiveThreshold(1.0); + const threshold2 = calculateAdaptiveThreshold(0.5); + + expect(threshold2).toBeGreaterThan(threshold1); + }); + + it("should clamp to minimum threshold", () => + { + // 非常に大きなスケールでも最小値を下回らない + // 最小値は0.0625(0.25px squared) + const threshold = calculateAdaptiveThreshold(100.0); + expect(threshold).toBeGreaterThanOrEqual(0.0625); + }); + + it("should clamp to maximum threshold", () => + { + // 非常に小さなスケールでも最大値を超えない + // 最大値は4.0(2px squared) + const threshold = calculateAdaptiveThreshold(0.01); + expect(threshold).toBeLessThanOrEqual(4.0); + }); +}); diff --git a/packages/webgpu/src/BezierConverter/usecase/BezierConverterAdaptiveCubicToQuadUseCase.ts b/packages/webgpu/src/BezierConverter/usecase/BezierConverterAdaptiveCubicToQuadUseCase.ts new file mode 100644 index 00000000..0072c066 --- /dev/null +++ b/packages/webgpu/src/BezierConverter/usecase/BezierConverterAdaptiveCubicToQuadUseCase.ts @@ -0,0 +1,125 @@ +import type { IPoint } from "../../interface/IPoint"; +import type { IQuadraticSegment } from "../../interface/IQuadraticSegment"; +import { execute as calculateFlatness } from "../service/BezierConverterCalculateFlatnessService"; +import { execute as splitCubic } from "../service/BezierConverterSplitCubicService"; + +export type { IQuadraticSegment }; + +/** + * @description フラットネス閾値のデフォルト値 + * Default flatness threshold (squared distance) + * + * この値は曲線がどの程度「平坦」であれば直接近似するかを決定。 + * 値が小さいほど高品質だが分割数が増加。 + * 0.5ピクセル相当の値をデフォルトとする(滑らかなストローク描画用)。 + */ +const DEFAULT_FLATNESS_THRESHOLD = 0.25; // 0.5px squared + +/** + * @description 最大再帰深度 + * Maximum recursion depth to prevent infinite loops + */ +const MAX_RECURSION_DEPTH = 8; + +/** + * @description 三次ベジェ曲線を適応的に二次ベジェ曲線群に変換 + * Adaptively convert cubic bezier to quadratic bezier segments + * + * フラットネス(平坦度)に基づいて動的に分割数を決定。 + * 単純な曲線は少ない分割、複雑な曲線は多い分割を行う。 + * + * @param {IPoint} p0 - 始点 + * @param {IPoint} p1 - 制御点1 + * @param {IPoint} p2 - 制御点2 + * @param {IPoint} p3 - 終点 + * @param {number} flatnessThreshold - フラットネス閾値(オプション) + * @return {IQuadraticSegment[]} 二次ベジェ曲線のセグメント配列 + */ +export const execute = ( + p0: IPoint, + p1: IPoint, + p2: IPoint, + p3: IPoint, + flatnessThreshold: number = DEFAULT_FLATNESS_THRESHOLD +): IQuadraticSegment[] => { + + const result: IQuadraticSegment[] = []; + + // 再帰的に分割を行う内部関数 + const subdivide = ( + start: IPoint, + ctrl1: IPoint, + ctrl2: IPoint, + end: IPoint, + depth: number + ): void => { + + // フラットネスを計算 + const flatness = calculateFlatness(start, ctrl1, ctrl2, end); + + // フラットネスが閾値以下、または最大深度に達した場合は近似 + if (flatness <= flatnessThreshold || depth >= MAX_RECURSION_DEPTH) { + // 三次ベジェを二次ベジェに近似 + // WebGL版と同じ: 分割後は単純に2つの制御点の中点を使用 + const ctrl: IPoint = { + "x": (ctrl1.x + ctrl2.x) * 0.5, + "y": (ctrl1.y + ctrl2.y) * 0.5 + }; + + result.push({ + "ctrl": ctrl, + "end": end + }); + + return; + } + + // 曲線を2分割してそれぞれを再帰処理 + const split = splitCubic(start, ctrl1, ctrl2, end); + + subdivide( + split.first[0], + split.first[1], + split.first[2], + split.first[3], + depth + 1 + ); + + subdivide( + split.second[0], + split.second[1], + split.second[2], + split.second[3], + depth + 1 + ); + }; + + // 分割を開始 + subdivide(p0, p1, p2, p3, 0); + + return result; +}; + +/** + * @description スケールに応じたフラットネス閾値を計算 + * Calculate flatness threshold based on scale + * + * ズームレベルが高い場合は高品質な近似が必要。 + * スケール = sqrt(matrix[0]^2 + matrix[1]^2) などで計算可能。 + * + * @param {number} scale - 現在のスケール + * @return {number} 調整されたフラットネス閾値 + */ +export const calculateAdaptiveThreshold = (scale: number): number => { + // スケールが大きい場合は閾値を小さくして高品質に + // スケールが小さい場合は閾値を大きくしてパフォーマンス優先 + const baseThreshold = DEFAULT_FLATNESS_THRESHOLD; + + // スケールの逆数に比例した閾値 + // 最小値と最大値を設定して極端な値を防ぐ + const adjustedThreshold = baseThreshold / (scale * scale); + + // 閾値の範囲を制限(0.0625〜4.0) + // 0.0625 = 0.25px squared, 4.0 = 2px squared + return Math.max(0.0625, Math.min(4.0, adjustedThreshold)); +}; diff --git a/packages/webgpu/src/Blend.test.ts b/packages/webgpu/src/Blend.test.ts new file mode 100644 index 00000000..53359038 --- /dev/null +++ b/packages/webgpu/src/Blend.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + $setCurrentBlendMode, + $currentBlendMode, + $setFuncCode, + $funcCode, + $getBlendState +} from "./Blend"; + +describe("Blend", () => +{ + beforeEach(() => + { + $setCurrentBlendMode("normal"); + $setFuncCode(0); + }); + + describe("blend mode", () => + { + it("should default to normal", () => + { + $setCurrentBlendMode("normal"); + expect($currentBlendMode).toBe("normal"); + }); + + it("should set and get blend mode", () => + { + $setCurrentBlendMode("add"); + expect($currentBlendMode).toBe("add"); + + $setCurrentBlendMode("screen"); + expect($currentBlendMode).toBe("screen"); + + $setCurrentBlendMode("alpha"); + expect($currentBlendMode).toBe("alpha"); + + $setCurrentBlendMode("erase"); + expect($currentBlendMode).toBe("erase"); + + $setCurrentBlendMode("copy"); + expect($currentBlendMode).toBe("copy"); + }); + }); + + describe("func code", () => + { + it("should default to 0", () => + { + $setFuncCode(0); + expect($funcCode).toBe(0); + }); + + it("should set and get func code", () => + { + $setFuncCode(123); + expect($funcCode).toBe(123); + + $setFuncCode(456); + expect($funcCode).toBe(456); + }); + }); + + describe("$getBlendState", () => + { + it("should return normal blend state", () => + { + const state = $getBlendState("normal"); + + expect(state.color.srcFactor).toBe("one"); + expect(state.color.dstFactor).toBe("one-minus-src-alpha"); + expect(state.color.operation).toBe("add"); + }); + + it("should return add blend state", () => + { + const state = $getBlendState("add"); + + expect(state.color.srcFactor).toBe("one"); + expect(state.color.dstFactor).toBe("one"); + expect(state.color.operation).toBe("add"); + }); + + it("should return screen blend state", () => + { + const state = $getBlendState("screen"); + + expect(state.color.srcFactor).toBe("one-minus-dst"); + expect(state.color.dstFactor).toBe("one"); + expect(state.color.operation).toBe("add"); + }); + + it("should return alpha blend state", () => + { + const state = $getBlendState("alpha"); + + expect(state.color.srcFactor).toBe("zero"); + expect(state.color.dstFactor).toBe("src-alpha"); + expect(state.color.operation).toBe("add"); + }); + + it("should return erase blend state", () => + { + const state = $getBlendState("erase"); + + expect(state.color.srcFactor).toBe("zero"); + expect(state.color.dstFactor).toBe("one-minus-src-alpha"); + expect(state.color.operation).toBe("add"); + }); + + it("should return copy blend state", () => + { + const state = $getBlendState("copy"); + + expect(state.color.srcFactor).toBe("one"); + expect(state.color.dstFactor).toBe("zero"); + expect(state.color.operation).toBe("add"); + }); + + it("should return default for unknown mode", () => + { + const state = $getBlendState("unknown" as any); + + expect(state.color.srcFactor).toBe("one"); + expect(state.color.dstFactor).toBe("one-minus-src-alpha"); + }); + + it("should have consistent alpha blend settings", () => + { + const normalState = $getBlendState("normal"); + const addState = $getBlendState("add"); + + // Normal alpha + expect(normalState.alpha.srcFactor).toBe("one"); + expect(normalState.alpha.dstFactor).toBe("one-minus-src-alpha"); + + // Add alpha + expect(addState.alpha.srcFactor).toBe("one"); + expect(addState.alpha.dstFactor).toBe("one-minus-src-alpha"); + }); + }); +}); diff --git a/packages/webgpu/src/Blend.ts b/packages/webgpu/src/Blend.ts new file mode 100644 index 00000000..2dfe18c8 --- /dev/null +++ b/packages/webgpu/src/Blend.ts @@ -0,0 +1,108 @@ +import type { IBlendMode } from "./interface/IBlendMode"; +import type { IBlendState } from "./interface/IBlendState"; + +export type { IBlendState }; + +export let $currentBlendMode: IBlendMode = "normal"; + +export let $funcCode: number = 0; + +export const $setCurrentBlendMode = (blend_mode: IBlendMode): void => +{ + $currentBlendMode = blend_mode; +}; + +export const $setFuncCode = (code: number): void => +{ + $funcCode = code; +}; + +export const $getBlendState = (mode: IBlendMode): IBlendState => +{ + switch (mode) { + case "add": + return { + "color": { + "srcFactor": "one", + "dstFactor": "one", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + } + }; + + case "screen": + return { + "color": { + "srcFactor": "one-minus-dst", + "dstFactor": "one", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + } + }; + + case "alpha": + return { + "color": { + "srcFactor": "zero", + "dstFactor": "src-alpha", + "operation": "add" + }, + "alpha": { + "srcFactor": "zero", + "dstFactor": "src-alpha", + "operation": "add" + } + }; + + case "erase": + return { + "color": { + "srcFactor": "zero", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + }, + "alpha": { + "srcFactor": "zero", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + } + }; + + case "copy": + return { + "color": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + } + }; + + // normal and default + default: + return { + "color": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + } + }; + } +}; diff --git a/packages/webgpu/src/Blend/BlendInstancedManager.test.ts b/packages/webgpu/src/Blend/BlendInstancedManager.test.ts new file mode 100644 index 00000000..2a470493 --- /dev/null +++ b/packages/webgpu/src/Blend/BlendInstancedManager.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getComplexBlendQueue, + clearComplexBlendQueue, + getInstancedShaderManager +} from "./BlendInstancedManager"; + +describe("BlendInstancedManager", () => +{ + beforeEach(() => + { + clearComplexBlendQueue(); + }); + + describe("complex blend queue", () => + { + it("should return empty array initially", () => + { + const queue = getComplexBlendQueue(); + expect(queue).toBeInstanceOf(Array); + expect(queue.length).toBe(0); + }); + + it("should clear the queue", () => + { + const queue = getComplexBlendQueue(); + // Manually add item for testing + queue.push({} as any); + + clearComplexBlendQueue(); + + expect(getComplexBlendQueue().length).toBe(0); + }); + + it("should return same array reference before clear", () => + { + const queue1 = getComplexBlendQueue(); + const queue2 = getComplexBlendQueue(); + + expect(queue1).toBe(queue2); + }); + + it("should return same array after clear (reused)", () => + { + const queue1 = getComplexBlendQueue(); + clearComplexBlendQueue(); + const queue2 = getComplexBlendQueue(); + + expect(queue1).toBe(queue2); + expect(queue2.length).toBe(0); + }); + }); + + describe("instanced shader manager", () => + { + it("should return shader manager instance", () => + { + const manager = getInstancedShaderManager(); + + expect(manager).toBeDefined(); + expect(typeof manager.count).toBe("number"); + }); + + it("should return same instance on multiple calls", () => + { + const manager1 = getInstancedShaderManager(); + const manager2 = getInstancedShaderManager(); + + expect(manager1).toBe(manager2); + }); + + it("should have clear method", () => + { + const manager = getInstancedShaderManager(); + + expect(typeof manager.clear).toBe("function"); + }); + + it("should track count", () => + { + const manager = getInstancedShaderManager(); + manager.clear(); + + expect(manager.count).toBe(0); + + manager.count++; + expect(manager.count).toBe(1); + + manager.count += 5; + expect(manager.count).toBe(6); + + manager.clear(); + expect(manager.count).toBe(0); + }); + }); +}); diff --git a/packages/webgpu/src/Blend/BlendInstancedManager.ts b/packages/webgpu/src/Blend/BlendInstancedManager.ts new file mode 100644 index 00000000..8d9438d7 --- /dev/null +++ b/packages/webgpu/src/Blend/BlendInstancedManager.ts @@ -0,0 +1,183 @@ +import type { Node } from "@next2d/texture-packer"; +import type { IComplexBlendItem } from "../interface/IComplexBlendItem"; +import { ShaderInstancedManager } from "../Shader/ShaderInstancedManager"; +import { $currentBlendMode, $setCurrentBlendMode } from "../Blend"; +import { $getCurrentAtlasIndex, $setCurrentAtlasIndex, $setActiveAtlasIndex } from "../AtlasManager"; +import { renderQueue } from "@next2d/render-queue"; +import { $context } from "../WebGPUUtil"; + +/** + * @description シンプルなブレンドモード(インスタンス描画可能) + */ +const SIMPLE_BLEND_MODES: ReadonlySet = new Set([ + "normal", "layer", "add", "screen", "alpha", "erase", "copy" +]); + +/** + * @description 複雑なブレンドモード描画キュー + */ +const $complexBlendQueue: IComplexBlendItem[] = []; + +/** + * @description Float32Array(8) プール(color_transform 用) + */ +const $ct8Pool: Float32Array[] = []; + +/** + * @description Float32Array(9) プール(matrix 用) + */ +const $m9Pool: Float32Array[] = []; + +/** + * @description 複雑なブレンドモードの描画キューを取得 + * @return {IComplexBlendItem[]} + */ +export const getComplexBlendQueue = (): IComplexBlendItem[] => +{ + return $complexBlendQueue; +}; + +/** + * @description 複雑なブレンドモードの描画キューをクリア + * @return {void} + */ +export const clearComplexBlendQueue = (): void => +{ + // プールに返却してから配列をクリア + for (let i = 0; i < $complexBlendQueue.length; i++) { + const item = $complexBlendQueue[i]; + $ct8Pool.push(item.color_transform as Float32Array); + $m9Pool.push(item.matrix as Float32Array); + } + $complexBlendQueue.length = 0; +}; + +/** + * @description インスタンスシェーダーマネージャーのキャッシュ + * @private + */ +const shaderManagers = new Map(); + +/** + * @description インスタンスシェーダーマネージャーを取得 + * @return {ShaderInstancedManager} + */ +export const getInstancedShaderManager = (): ShaderInstancedManager => +{ + const key = "blend_instanced"; + if (!shaderManagers.has(key)) { + shaderManagers.set(key, new ShaderInstancedManager()); + } + return shaderManagers.get(key)!; +}; + +/** + * @description DisplayObject単体の描画をインスタンス配列に追加 + * @param {Node} node + * @param {number} x_min + * @param {number} y_min + * @param {number} x_max + * @param {number} y_max + * @param {Float32Array} color_transform + * @param {Float32Array} matrix + * @param {string} blend_mode + * @param {number} viewport_width + * @param {number} viewport_height + * @param {number} render_max_size + * @param {number} global_alpha + * @return {void} + */ +export const addDisplayObjectToInstanceArray = ( + node: Node, + x_min: number, + y_min: number, + x_max: number, + y_max: number, + color_transform: Float32Array, + matrix: Float32Array, + blend_mode: string, + viewport_width: number, + viewport_height: number, + render_max_size: number, + global_alpha: number +): void => { + + // WebGL版と同じ: mulColor.a には globalAlpha を使用 + const ct0 = color_transform[0]; + const ct1 = color_transform[1]; + const ct2 = color_transform[2]; + const ct3 = global_alpha; // WebGL: $context.globalAlpha + const ct4 = color_transform[4] / 255; + const ct5 = color_transform[5] / 255; + const ct6 = color_transform[6] / 255; + const ct7 = 0; + + if (SIMPLE_BLEND_MODES.has(blend_mode)) { + // ブレンドモードまたはアトラスインデックスが変わった場合 + if ($currentBlendMode !== blend_mode || $getCurrentAtlasIndex() !== node.index) { + // 異なるブレンドモード/アトラスになるので、切り替え前にバッチを描画 + if ($context) { + $setActiveAtlasIndex($getCurrentAtlasIndex()); + $context.drawArraysInstanced(); + } + + // 新しいブレンドモードとアトラスインデックスをセット + $setCurrentBlendMode(blend_mode as any); + $setCurrentAtlasIndex(node.index); + $setActiveAtlasIndex(node.index); + } + + // インスタンスデータを配列に追加 + const shaderManager = getInstancedShaderManager(); + + renderQueue.pushInstanceBuffer( + // texture rectangle (vec4) - normalized coordinates (half-pixel inset) + (node.x + 0.5) / render_max_size, (node.y + 0.5) / render_max_size, + (node.w - 1.0) / render_max_size, (node.h - 1.0) / render_max_size, + // texture width, height and viewport width, height (vec4) + node.w, node.h, viewport_width, viewport_height, + // matrix tx, ty (vec2) + padding (vec2) + matrix[6], matrix[7], 0, 0, + // matrix scale0, rotate0, scale1, rotate1 (vec4) + matrix[0], matrix[1], matrix[3], matrix[4], + // mulColor (vec4) + ct0, ct1, ct2, ct3, + // addColor (vec4) + ct4, ct5, ct6, ct7 + ); + + shaderManager.count++; + } else { + // 複雑なブレンドモード(個別描画が必要) + // 先に現在のインスタンス配列を描画 + if ($context) { + $setActiveAtlasIndex($getCurrentAtlasIndex()); + $context.drawArraysInstanced(); + } + + // キューに追加して後で処理(プールからFloat32Arrayを再利用) + const ct = $ct8Pool.length > 0 ? $ct8Pool.pop()! : new Float32Array(8); + ct.set(color_transform); + const m = $m9Pool.length > 0 ? $m9Pool.pop()! : new Float32Array(9); + m.set(matrix); + $complexBlendQueue.push({ + node, + x_min, + y_min, + x_max, + y_max, + "color_transform": ct, + "matrix": m, + blend_mode, + viewport_width, + viewport_height, + render_max_size, + global_alpha + }); + + // ブレンドモードをセット + $setCurrentBlendMode(blend_mode as any); + $setCurrentAtlasIndex(node.index); + $setActiveAtlasIndex(node.index); + } +}; diff --git a/packages/webgpu/src/Blend/service/BlendAddService.test.ts b/packages/webgpu/src/Blend/service/BlendAddService.test.ts new file mode 100644 index 00000000..02261765 --- /dev/null +++ b/packages/webgpu/src/Blend/service/BlendAddService.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { execute } from "./BlendAddService"; +import { $setFuncCode, $funcCode } from "../../Blend"; + +describe("BlendAddService", () => +{ + beforeEach(() => + { + // Reset to non-add state + $setFuncCode(0); + }); + + it("should return true when func code is different", () => + { + $setFuncCode(0); + + const result = execute(); + + expect(result).toBe(true); + }); + + it("should set func code to 101 (add)", () => + { + $setFuncCode(0); + + execute(); + + expect($funcCode).toBe(101); + }); + + it("should return false when already set to add (101)", () => + { + $setFuncCode(101); + + const result = execute(); + + expect(result).toBe(false); + }); + + it("should not change func code when already 101", () => + { + $setFuncCode(101); + + execute(); + + expect($funcCode).toBe(101); + }); + + it("should return true when changing from another mode", () => + { + $setFuncCode(301); // screen mode + + const result = execute(); + + expect(result).toBe(true); + expect($funcCode).toBe(101); + }); + + it("should return true when changing from normal mode", () => + { + $setFuncCode(613); // normal mode + + const result = execute(); + + expect(result).toBe(true); + }); +}); diff --git a/packages/webgpu/src/Blend/service/BlendAddService.ts b/packages/webgpu/src/Blend/service/BlendAddService.ts new file mode 100644 index 00000000..07affca6 --- /dev/null +++ b/packages/webgpu/src/Blend/service/BlendAddService.ts @@ -0,0 +1,13 @@ +import { + $setFuncCode, + $funcCode +} from "../../Blend"; + +export const execute = (): boolean => +{ + if ($funcCode !== 101) { + $setFuncCode(101); + return true; + } + return false; +}; diff --git a/packages/webgpu/src/Blend/service/BlendAlphaService.test.ts b/packages/webgpu/src/Blend/service/BlendAlphaService.test.ts new file mode 100644 index 00000000..56edb947 --- /dev/null +++ b/packages/webgpu/src/Blend/service/BlendAlphaService.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { execute } from "./BlendAlphaService"; +import { $setFuncCode, $funcCode } from "../../Blend"; + +describe("BlendAlphaService", () => +{ + beforeEach(() => + { + $setFuncCode(0); + }); + + it("should return true when func code is different", () => + { + $setFuncCode(0); + + const result = execute(); + + expect(result).toBe(true); + }); + + it("should set func code to 401 (alpha)", () => + { + $setFuncCode(0); + + execute(); + + expect($funcCode).toBe(401); + }); + + it("should return false when already set to alpha (401)", () => + { + $setFuncCode(401); + + const result = execute(); + + expect(result).toBe(false); + }); + + it("should not change func code when already 401", () => + { + $setFuncCode(401); + + execute(); + + expect($funcCode).toBe(401); + }); + + it("should return true when changing from normal mode", () => + { + $setFuncCode(613); // normal mode + + const result = execute(); + + expect(result).toBe(true); + expect($funcCode).toBe(401); + }); +}); diff --git a/packages/webgpu/src/Blend/service/BlendAlphaService.ts b/packages/webgpu/src/Blend/service/BlendAlphaService.ts new file mode 100644 index 00000000..7f5617aa --- /dev/null +++ b/packages/webgpu/src/Blend/service/BlendAlphaService.ts @@ -0,0 +1,13 @@ +import { + $setFuncCode, + $funcCode +} from "../../Blend"; + +export const execute = (): boolean => +{ + if ($funcCode !== 401) { + $setFuncCode(401); + return true; + } + return false; +}; diff --git a/packages/webgpu/src/Blend/service/BlendEraseService.test.ts b/packages/webgpu/src/Blend/service/BlendEraseService.test.ts new file mode 100644 index 00000000..196dd414 --- /dev/null +++ b/packages/webgpu/src/Blend/service/BlendEraseService.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { execute } from "./BlendEraseService"; +import { $setFuncCode, $funcCode } from "../../Blend"; + +describe("BlendEraseService", () => +{ + beforeEach(() => + { + $setFuncCode(0); + }); + + it("should return true when func code is different", () => + { + $setFuncCode(0); + + const result = execute(); + + expect(result).toBe(true); + }); + + it("should set func code to 501 (erase)", () => + { + $setFuncCode(0); + + execute(); + + expect($funcCode).toBe(501); + }); + + it("should return false when already set to erase (501)", () => + { + $setFuncCode(501); + + const result = execute(); + + expect(result).toBe(false); + }); + + it("should not change func code when already 501", () => + { + $setFuncCode(501); + + execute(); + + expect($funcCode).toBe(501); + }); + + it("should return true when changing from screen mode", () => + { + $setFuncCode(301); // screen mode + + const result = execute(); + + expect(result).toBe(true); + expect($funcCode).toBe(501); + }); +}); diff --git a/packages/webgpu/src/Blend/service/BlendEraseService.ts b/packages/webgpu/src/Blend/service/BlendEraseService.ts new file mode 100644 index 00000000..86ee87fd --- /dev/null +++ b/packages/webgpu/src/Blend/service/BlendEraseService.ts @@ -0,0 +1,13 @@ +import { + $setFuncCode, + $funcCode +} from "../../Blend"; + +export const execute = (): boolean => +{ + if ($funcCode !== 501) { + $setFuncCode(501); + return true; + } + return false; +}; diff --git a/packages/webgpu/src/Blend/service/BlendGetStateService.test.ts b/packages/webgpu/src/Blend/service/BlendGetStateService.test.ts new file mode 100644 index 00000000..dde24480 --- /dev/null +++ b/packages/webgpu/src/Blend/service/BlendGetStateService.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { execute } from "./BlendGetStateService"; + +describe("BlendGetStateService", () => +{ + describe("normal blend mode", () => + { + it("should return correct blend state for normal mode", () => + { + const result = execute("normal"); + + expect(result.color.srcFactor).toBe("one"); + expect(result.color.dstFactor).toBe("one-minus-src-alpha"); + expect(result.color.operation).toBe("add"); + expect(result.alpha.srcFactor).toBe("one"); + expect(result.alpha.dstFactor).toBe("one-minus-src-alpha"); + expect(result.alpha.operation).toBe("add"); + }); + }); + + describe("add blend mode", () => + { + it("should return correct blend state for add mode", () => + { + const result = execute("add"); + + expect(result.color.srcFactor).toBe("one"); + expect(result.color.dstFactor).toBe("one"); + expect(result.color.operation).toBe("add"); + expect(result.alpha.srcFactor).toBe("one"); + expect(result.alpha.dstFactor).toBe("one-minus-src-alpha"); + }); + }); + + describe("screen blend mode", () => + { + it("should return correct blend state for screen mode", () => + { + const result = execute("screen"); + + expect(result.color.srcFactor).toBe("one-minus-dst"); + expect(result.color.dstFactor).toBe("one"); + expect(result.color.operation).toBe("add"); + }); + }); + + describe("alpha blend mode", () => + { + it("should return correct blend state for alpha mode", () => + { + const result = execute("alpha"); + + expect(result.color.srcFactor).toBe("zero"); + expect(result.color.dstFactor).toBe("src-alpha"); + expect(result.color.operation).toBe("add"); + expect(result.alpha.srcFactor).toBe("zero"); + expect(result.alpha.dstFactor).toBe("src-alpha"); + }); + }); + + describe("erase blend mode", () => + { + it("should return correct blend state for erase mode", () => + { + const result = execute("erase"); + + expect(result.color.srcFactor).toBe("zero"); + expect(result.color.dstFactor).toBe("one-minus-src-alpha"); + expect(result.color.operation).toBe("add"); + expect(result.alpha.srcFactor).toBe("zero"); + expect(result.alpha.dstFactor).toBe("one-minus-src-alpha"); + }); + }); + + describe("copy blend mode", () => + { + it("should return correct blend state for copy mode", () => + { + const result = execute("copy"); + + expect(result.color.srcFactor).toBe("one"); + expect(result.color.dstFactor).toBe("zero"); + expect(result.color.operation).toBe("add"); + expect(result.alpha.srcFactor).toBe("one"); + expect(result.alpha.dstFactor).toBe("zero"); + }); + }); + + describe("default behavior", () => + { + it("should return normal state for unknown mode", () => + { + const result = execute("unknown" as any); + + expect(result.color.srcFactor).toBe("one"); + expect(result.color.dstFactor).toBe("one-minus-src-alpha"); + }); + }); +}); diff --git a/packages/webgpu/src/Blend/service/BlendGetStateService.ts b/packages/webgpu/src/Blend/service/BlendGetStateService.ts new file mode 100644 index 00000000..5f8535eb --- /dev/null +++ b/packages/webgpu/src/Blend/service/BlendGetStateService.ts @@ -0,0 +1,17 @@ +import type { IBlendMode } from "../../interface/IBlendMode"; +import type { IBlendState } from "../../Blend"; +import { $getBlendState } from "../../Blend"; + +/** + * @description ブレンドモードからWebGPUブレンドステートを取得するサービス + * Service to get WebGPU blend state from blend mode + * + * @param {IBlendMode} mode + * @return {IBlendState} + * @method + * @protected + */ +export const execute = (mode: IBlendMode): IBlendState => +{ + return $getBlendState(mode); +}; diff --git a/packages/webgpu/src/Blend/service/BlendOneZeroService.test.ts b/packages/webgpu/src/Blend/service/BlendOneZeroService.test.ts new file mode 100644 index 00000000..4b4083fe --- /dev/null +++ b/packages/webgpu/src/Blend/service/BlendOneZeroService.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { execute } from "./BlendOneZeroService"; +import { $setFuncCode, $funcCode } from "../../Blend"; + +describe("BlendOneZeroService", () => +{ + beforeEach(() => + { + $setFuncCode(0); + }); + + it("should return true when func code is different", () => + { + $setFuncCode(100); + + const result = execute(); + + expect(result).toBe(true); + }); + + it("should set func code to 10 (copy/one-zero)", () => + { + $setFuncCode(100); + + execute(); + + expect($funcCode).toBe(10); + }); + + it("should return false when already set to one-zero (10)", () => + { + $setFuncCode(10); + + const result = execute(); + + expect(result).toBe(false); + }); + + it("should not change func code when already 10", () => + { + $setFuncCode(10); + + execute(); + + expect($funcCode).toBe(10); + }); + + it("should return true when changing from normal mode", () => + { + $setFuncCode(613); // normal mode + + const result = execute(); + + expect(result).toBe(true); + expect($funcCode).toBe(10); + }); +}); diff --git a/packages/webgpu/src/Blend/service/BlendOneZeroService.ts b/packages/webgpu/src/Blend/service/BlendOneZeroService.ts new file mode 100644 index 00000000..108cac13 --- /dev/null +++ b/packages/webgpu/src/Blend/service/BlendOneZeroService.ts @@ -0,0 +1,13 @@ +import { + $setFuncCode, + $funcCode +} from "../../Blend"; + +export const execute = (): boolean => +{ + if ($funcCode !== 10) { + $setFuncCode(10); + return true; + } + return false; +}; diff --git a/packages/webgpu/src/Blend/service/BlendResetService.test.ts b/packages/webgpu/src/Blend/service/BlendResetService.test.ts new file mode 100644 index 00000000..c9ccf537 --- /dev/null +++ b/packages/webgpu/src/Blend/service/BlendResetService.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { execute } from "./BlendResetService"; +import { $setFuncCode, $funcCode } from "../../Blend"; + +describe("BlendResetService", () => +{ + beforeEach(() => + { + $setFuncCode(0); + }); + + it("should return true when func code is not 613 (normal)", () => + { + $setFuncCode(101); // add mode + + const result = execute(); + + expect(result).toBe(true); + }); + + it("should set func code to 613 (normal)", () => + { + $setFuncCode(101); // add mode + + execute(); + + expect($funcCode).toBe(613); + }); + + it("should return false when already set to normal (613)", () => + { + $setFuncCode(613); + + const result = execute(); + + expect(result).toBe(false); + }); + + it("should not change func code when already 613", () => + { + $setFuncCode(613); + + execute(); + + expect($funcCode).toBe(613); + }); + + it("should return true when resetting from screen mode", () => + { + $setFuncCode(301); // screen mode + + const result = execute(); + + expect(result).toBe(true); + expect($funcCode).toBe(613); + }); + + it("should return true when resetting from erase mode", () => + { + $setFuncCode(501); // erase mode + + const result = execute(); + + expect(result).toBe(true); + expect($funcCode).toBe(613); + }); + + it("should return true when resetting from one-zero mode", () => + { + $setFuncCode(10); // one-zero (copy) mode + + const result = execute(); + + expect(result).toBe(true); + expect($funcCode).toBe(613); + }); +}); diff --git a/packages/webgpu/src/Blend/service/BlendResetService.ts b/packages/webgpu/src/Blend/service/BlendResetService.ts new file mode 100644 index 00000000..aff3f081 --- /dev/null +++ b/packages/webgpu/src/Blend/service/BlendResetService.ts @@ -0,0 +1,13 @@ +import { + $setFuncCode, + $funcCode +} from "../../Blend"; + +export const execute = (): boolean => +{ + if ($funcCode !== 613) { + $setFuncCode(613); + return true; + } + return false; +}; diff --git a/packages/webgpu/src/Blend/service/BlendScreenService.test.ts b/packages/webgpu/src/Blend/service/BlendScreenService.test.ts new file mode 100644 index 00000000..ad266f30 --- /dev/null +++ b/packages/webgpu/src/Blend/service/BlendScreenService.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { execute } from "./BlendScreenService"; +import { $setFuncCode, $funcCode } from "../../Blend"; + +describe("BlendScreenService", () => +{ + beforeEach(() => + { + $setFuncCode(0); + }); + + it("should return true when func code is different", () => + { + $setFuncCode(0); + + const result = execute(); + + expect(result).toBe(true); + }); + + it("should set func code to 301 (screen)", () => + { + $setFuncCode(0); + + execute(); + + expect($funcCode).toBe(301); + }); + + it("should return false when already set to screen (301)", () => + { + $setFuncCode(301); + + const result = execute(); + + expect(result).toBe(false); + }); + + it("should not change func code when already 301", () => + { + $setFuncCode(301); + + execute(); + + expect($funcCode).toBe(301); + }); + + it("should return true when changing from add mode", () => + { + $setFuncCode(101); // add mode + + const result = execute(); + + expect(result).toBe(true); + expect($funcCode).toBe(301); + }); +}); diff --git a/packages/webgpu/src/Blend/service/BlendScreenService.ts b/packages/webgpu/src/Blend/service/BlendScreenService.ts new file mode 100644 index 00000000..53acf82c --- /dev/null +++ b/packages/webgpu/src/Blend/service/BlendScreenService.ts @@ -0,0 +1,13 @@ +import { + $setFuncCode, + $funcCode +} from "../../Blend"; + +export const execute = (): boolean => +{ + if ($funcCode !== 301) { + $setFuncCode(301); + return true; + } + return false; +}; diff --git a/packages/webgpu/src/Blend/service/BlendSetModeService.test.ts b/packages/webgpu/src/Blend/service/BlendSetModeService.test.ts new file mode 100644 index 00000000..b8b0d3f5 --- /dev/null +++ b/packages/webgpu/src/Blend/service/BlendSetModeService.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { execute } from "./BlendSetModeService"; +import { $currentBlendMode, $setCurrentBlendMode } from "../../Blend"; + +describe("BlendSetModeService", () => +{ + beforeEach(() => + { + $setCurrentBlendMode("normal"); + }); + + it("should set blend mode to normal", () => + { + execute("normal"); + + expect($currentBlendMode).toBe("normal"); + }); + + it("should set blend mode to add", () => + { + execute("add"); + + expect($currentBlendMode).toBe("add"); + }); + + it("should set blend mode to screen", () => + { + execute("screen"); + + expect($currentBlendMode).toBe("screen"); + }); + + it("should set blend mode to alpha", () => + { + execute("alpha"); + + expect($currentBlendMode).toBe("alpha"); + }); + + it("should set blend mode to erase", () => + { + execute("erase"); + + expect($currentBlendMode).toBe("erase"); + }); + + it("should set blend mode to copy", () => + { + execute("copy"); + + expect($currentBlendMode).toBe("copy"); + }); + + it("should change mode from one to another", () => + { + execute("add"); + expect($currentBlendMode).toBe("add"); + + execute("screen"); + expect($currentBlendMode).toBe("screen"); + + execute("normal"); + expect($currentBlendMode).toBe("normal"); + }); +}); diff --git a/packages/webgpu/src/Blend/service/BlendSetModeService.ts b/packages/webgpu/src/Blend/service/BlendSetModeService.ts new file mode 100644 index 00000000..3162d9f5 --- /dev/null +++ b/packages/webgpu/src/Blend/service/BlendSetModeService.ts @@ -0,0 +1,7 @@ +import type { IBlendMode } from "../../interface/IBlendMode"; +import { $setCurrentBlendMode } from "../../Blend"; + +export const execute = (mode: IBlendMode): void => +{ + $setCurrentBlendMode(mode); +}; diff --git a/packages/webgpu/src/Blend/usecase/BlendApplyComplexBlendUseCase.test.ts b/packages/webgpu/src/Blend/usecase/BlendApplyComplexBlendUseCase.test.ts new file mode 100644 index 00000000..06465f80 --- /dev/null +++ b/packages/webgpu/src/Blend/usecase/BlendApplyComplexBlendUseCase.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { execute } from "./BlendApplyComplexBlendUseCase"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + UNIFORM: 0x0040, + COPY_DST: 0x0008 +}; +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +describe("BlendApplyComplexBlendUseCase", () => +{ + const createMockAttachment = (width: number, height: number): IAttachmentObject => + { + return { + "id": 1, + "width": width, + "height": height, + "clipLevel": 0, + "msaa": false, + "mask": false, + "color": null, + "texture": { + "id": 1, + "width": width, + "height": height, + "area": width * height, + "smooth": true, + "resource": {} as GPUTexture, + "view": { "label": "mockView" } as unknown as GPUTextureView + }, + "stencil": null, + "msaaTexture": null, + "msaaStencil": null + }; + }; + + const createMockConfig = (hasPipeline: boolean = true): IFilterConfig => + { + const mockBuffer = { "label": "mockBuffer" }; + const mockPassEncoder = { + "setPipeline": vi.fn(), + "setBindGroup": vi.fn(), + "draw": vi.fn(), + "end": vi.fn() + }; + + const mockDevice = { + "createBuffer": vi.fn(() => mockBuffer), + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })), + "queue": { + "writeBuffer": vi.fn() + } + } as unknown as GPUDevice; + + const mockCommandEncoder = { + "beginRenderPass": vi.fn(() => mockPassEncoder) + } as unknown as GPUCommandEncoder; + + const destAttachment = createMockAttachment(256, 256); + + const mockFrameBufferManager = { + "createTemporaryAttachment": vi.fn(() => destAttachment), + "createRenderPassDescriptor": vi.fn(() => ({ + "colorAttachments": [{ "view": destAttachment.texture!.view }] + })) + }; + + const mockPipelineManager = { + "getPipeline": vi.fn(() => hasPipeline ? { "label": "mockPipeline" } : null), + "getBindGroupLayout": vi.fn(() => hasPipeline ? { "label": "mockLayout" } : null) + }; + + const mockTextureManager = { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })) + }; + + const mockBufferManager = { + "acquireUniformBuffer": vi.fn(() => mockBuffer), + "acquireAndWriteUniformBuffer": vi.fn(() => mockBuffer) + }; + + return { + "device": mockDevice, + "commandEncoder": mockCommandEncoder, + "frameBufferManager": mockFrameBufferManager, + "pipelineManager": mockPipelineManager, + "textureManager": mockTextureManager, + "bufferManager": mockBufferManager, + "frameTextures": [] + } as unknown as IFilterConfig; + }; + + beforeEach(() => + { + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("output size", () => + { + it("should use max width of source and destination", () => + { + const srcAttachment = createMockAttachment(200, 256); + const dstAttachment = createMockAttachment(300, 256); + const config = createMockConfig(); + const colorTransform = new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]); + + execute(srcAttachment, dstAttachment, "multiply", colorTransform, config); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalledWith( + 300, // max(200, 300) + 256 + ); + }); + + it("should use max height of source and destination", () => + { + const srcAttachment = createMockAttachment(256, 100); + const dstAttachment = createMockAttachment(256, 200); + const config = createMockConfig(); + const colorTransform = new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]); + + execute(srcAttachment, dstAttachment, "multiply", colorTransform, config); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalledWith( + 256, + 200 // max(100, 200) + ); + }); + }); + + describe("pipeline selection", () => + { + it("should request pipeline for blend mode", () => + { + const srcAttachment = createMockAttachment(256, 256); + const dstAttachment = createMockAttachment(256, 256); + const config = createMockConfig(); + const colorTransform = new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]); + + execute(srcAttachment, dstAttachment, "multiply", colorTransform, config); + + expect(config.pipelineManager.getPipeline).toHaveBeenCalledWith("complex_blend"); + }); + + it("should return source when pipeline not found", () => + { + const srcAttachment = createMockAttachment(256, 256); + const dstAttachment = createMockAttachment(256, 256); + const config = createMockConfig(false); + const colorTransform = new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]); + + const result = execute(srcAttachment, dstAttachment, "unknown_mode", colorTransform, config); + + expect(console.error).toHaveBeenCalled(); + expect(result).toBe(srcAttachment); + }); + }); + + describe("sampler creation", () => + { + it("should create sampler with smooth setting", () => + { + const srcAttachment = createMockAttachment(256, 256); + const dstAttachment = createMockAttachment(256, 256); + const config = createMockConfig(); + const colorTransform = new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]); + + execute(srcAttachment, dstAttachment, "multiply", colorTransform, config); + + expect(config.textureManager.createSampler).toHaveBeenCalledWith("complex_blend_sampler", true); + }); + }); + + describe("uniform buffer", () => + { + it("should create uniform buffer", () => + { + const srcAttachment = createMockAttachment(256, 256); + const dstAttachment = createMockAttachment(256, 256); + const config = createMockConfig(); + const colorTransform = new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]); + + execute(srcAttachment, dstAttachment, "multiply", colorTransform, config); + + expect(config.bufferManager.acquireAndWriteUniformBuffer).toHaveBeenCalled(); + }); + + it("should write color transform to buffer", () => + { + const srcAttachment = createMockAttachment(256, 256); + const dstAttachment = createMockAttachment(256, 256); + const config = createMockConfig(); + const colorTransform = new Float32Array([0.5, 0.6, 0.7, 0.8, 0.1, 0.2, 0.3, 0.4]); + + execute(srcAttachment, dstAttachment, "multiply", colorTransform, config); + + expect(config.bufferManager.acquireAndWriteUniformBuffer).toHaveBeenCalled(); + }); + }); + + describe("bind group", () => + { + it("should request complex_blend bind group layout", () => + { + const srcAttachment = createMockAttachment(256, 256); + const dstAttachment = createMockAttachment(256, 256); + const config = createMockConfig(); + const colorTransform = new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]); + + execute(srcAttachment, dstAttachment, "multiply", colorTransform, config); + + expect(config.pipelineManager.getBindGroupLayout).toHaveBeenCalledWith("complex_blend"); + }); + + it("should create bind group", () => + { + const srcAttachment = createMockAttachment(256, 256); + const dstAttachment = createMockAttachment(256, 256); + const config = createMockConfig(); + const colorTransform = new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]); + + execute(srcAttachment, dstAttachment, "multiply", colorTransform, config); + + expect(config.device.createBindGroup).toHaveBeenCalled(); + }); + }); + + describe("render pass", () => + { + it("should create render pass descriptor with clear", () => + { + const srcAttachment = createMockAttachment(256, 256); + const dstAttachment = createMockAttachment(256, 256); + const config = createMockConfig(); + const colorTransform = new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]); + + execute(srcAttachment, dstAttachment, "multiply", colorTransform, config); + + expect(config.frameBufferManager.createRenderPassDescriptor).toHaveBeenCalledWith( + expect.anything(), + 0, 0, 0, 0, + "clear" + ); + }); + + it("should draw 6 vertices (2 triangles)", () => + { + const srcAttachment = createMockAttachment(256, 256); + const dstAttachment = createMockAttachment(256, 256); + const config = createMockConfig(); + const colorTransform = new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]); + + execute(srcAttachment, dstAttachment, "multiply", colorTransform, config); + + const mockPassEncoder = (config.commandEncoder.beginRenderPass as ReturnType).mock.results[0].value; + expect(mockPassEncoder.draw).toHaveBeenCalledWith(6, 1, 0, 0); + }); + + it("should end render pass", () => + { + const srcAttachment = createMockAttachment(256, 256); + const dstAttachment = createMockAttachment(256, 256); + const config = createMockConfig(); + const colorTransform = new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]); + + execute(srcAttachment, dstAttachment, "multiply", colorTransform, config); + + const mockPassEncoder = (config.commandEncoder.beginRenderPass as ReturnType).mock.results[0].value; + expect(mockPassEncoder.end).toHaveBeenCalled(); + }); + }); + + describe("result", () => + { + it("should return destination attachment", () => + { + const srcAttachment = createMockAttachment(256, 256); + const dstAttachment = createMockAttachment(256, 256); + const config = createMockConfig(); + const colorTransform = new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]); + + const result = execute(srcAttachment, dstAttachment, "multiply", colorTransform, config); + + expect(result).not.toBe(srcAttachment); + expect(result).not.toBe(dstAttachment); + expect(result.width).toBe(256); + expect(result.height).toBe(256); + }); + }); +}); diff --git a/packages/webgpu/src/Blend/usecase/BlendApplyComplexBlendUseCase.ts b/packages/webgpu/src/Blend/usecase/BlendApplyComplexBlendUseCase.ts new file mode 100644 index 00000000..8c7e5c5f --- /dev/null +++ b/packages/webgpu/src/Blend/usecase/BlendApplyComplexBlendUseCase.ts @@ -0,0 +1,104 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { ShaderSource } from "../../Shader/ShaderSource"; + +/** + * @description プリアロケートされた uniform データ (12 floats = 48 bytes) + */ +const $uniform12 = new Float32Array(12); + +/** + * @description プリアロケートされた BindGroupEntry 配列 (4 bindings) + */ +const $entries4: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView }, + { "binding": 3, "resource": null as unknown as GPUTextureView } +]; + +/** + * @description 複雑なブレンドモードを適用 + */ +export const execute = ( + srcAttachment: IAttachmentObject, + dstAttachment: IAttachmentObject, + blendMode: string, + colorTransform: Float32Array, + config: IFilterConfig +): IAttachmentObject => { + + const { device, commandEncoder, frameBufferManager, pipelineManager, textureManager } = config; + + // 出力サイズは両方の大きい方を使用 + const width = Math.max(srcAttachment.width, dstAttachment.width); + const height = Math.max(srcAttachment.height, dstAttachment.height); + + // 出力アタッチメントを作成 + const destAttachment = frameBufferManager.createTemporaryAttachment(width, height); + + // 統一パイプラインを使用 + const pipeline = pipelineManager.getPipeline("complex_blend"); + const bindGroupLayout = pipelineManager.getBindGroupLayout("complex_blend"); + + if (!pipeline || !bindGroupLayout) { + console.error(`[WebGPU ComplexBlend] Pipeline not found for blend mode: ${blendMode}`); + // フォールバック: srcをそのまま返す + return srcAttachment; + } + + // サンプラーを作成 + const sampler = textureManager.createSampler("complex_blend_sampler", true); + + // ユニフォームバッファを作成 + // mulColor: vec4 (16 bytes) + // addColor: vec4 (16 bytes) + // blendMode: f32 + padding: vec3 (16 bytes) + // Total: 48 bytes + const blendModeIndex = ShaderSource.getBlendModeIndex(blendMode); + $uniform12[0] = colorTransform[0]; + $uniform12[1] = colorTransform[1]; + $uniform12[2] = colorTransform[2]; + $uniform12[3] = colorTransform[3]; + $uniform12[4] = colorTransform[4]; + $uniform12[5] = colorTransform[5]; + $uniform12[6] = colorTransform[6]; + $uniform12[7] = colorTransform[7]; + $uniform12[8] = blendModeIndex; + $uniform12[9] = 0; + $uniform12[10] = 0; + $uniform12[11] = 0; + + const uniformBuffer = config.bufferManager + ? config.bufferManager.acquireAndWriteUniformBuffer($uniform12) + : device.createBuffer({ + "size": 48, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + if (!config.bufferManager) { + device.queue.writeBuffer(uniformBuffer, 0, $uniform12); + } + + // バインドグループを作成 + ($entries4[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries4[1].resource = sampler; + $entries4[2].resource = dstAttachment.texture!.view; + $entries4[3].resource = srcAttachment.texture!.view; + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries4 + }); + + // レンダーパスを実行 + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + destAttachment.texture!.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + return destAttachment; +}; diff --git a/packages/webgpu/src/Blend/usecase/BlendOperationUseCase.test.ts b/packages/webgpu/src/Blend/usecase/BlendOperationUseCase.test.ts new file mode 100644 index 00000000..3d74804c --- /dev/null +++ b/packages/webgpu/src/Blend/usecase/BlendOperationUseCase.test.ts @@ -0,0 +1,77 @@ +import { execute } from "./BlendOperationUseCase"; +import { describe, expect, it, beforeEach } from "vitest"; +import { $setFuncCode } from "../../Blend"; + +describe("BlendOperationUseCase.ts method test", () => +{ + beforeEach(() => + { + // Reset func code before each test + $setFuncCode(0); + }); + + it("test case - add blend mode", () => + { + const changed = execute("add"); + expect(changed).toBe(true); + + // Second call should return false (no change) + const changed2 = execute("add"); + expect(changed2).toBe(false); + }); + + it("test case - screen blend mode", () => + { + const changed = execute("screen"); + expect(changed).toBe(true); + + const changed2 = execute("screen"); + expect(changed2).toBe(false); + }); + + it("test case - alpha blend mode", () => + { + const changed = execute("alpha"); + expect(changed).toBe(true); + + const changed2 = execute("alpha"); + expect(changed2).toBe(false); + }); + + it("test case - erase blend mode", () => + { + const changed = execute("erase"); + expect(changed).toBe(true); + + const changed2 = execute("erase"); + expect(changed2).toBe(false); + }); + + it("test case - copy blend mode", () => + { + const changed = execute("copy"); + expect(changed).toBe(true); + + const changed2 = execute("copy"); + expect(changed2).toBe(false); + }); + + it("test case - normal blend mode (default)", () => + { + const changed = execute("normal"); + expect(changed).toBe(true); + + const changed2 = execute("normal"); + expect(changed2).toBe(false); + }); + + it("test case - switching between modes", () => + { + execute("add"); + const changed = execute("screen"); + expect(changed).toBe(true); + + const changed2 = execute("normal"); + expect(changed2).toBe(true); + }); +}); diff --git a/packages/webgpu/src/Blend/usecase/BlendOperationUseCase.ts b/packages/webgpu/src/Blend/usecase/BlendOperationUseCase.ts new file mode 100644 index 00000000..d7101eb0 --- /dev/null +++ b/packages/webgpu/src/Blend/usecase/BlendOperationUseCase.ts @@ -0,0 +1,41 @@ +import type { IBlendMode } from "../../interface/IBlendMode"; +import { execute as blendAddService } from "../service/BlendAddService"; +import { execute as blendResetService } from "../service/BlendResetService"; +import { execute as blendScreenService } from "../service/BlendScreenService"; +import { execute as blendAlphaService } from "../service/BlendAlphaService"; +import { execute as blendEraseService } from "../service/BlendEraseService"; +import { execute as blendOneZeroService } from "../service/BlendOneZeroService"; + +/** + * @description 設定されたブレンドモードへ切り替える + * Switch to the set blend mode + * + * @param {IBlendMode} operation + * @return {boolean} ブレンドモードが変更されたかどうか + * @method + * @protected + */ +export const execute = (operation: IBlendMode): boolean => +{ + switch (operation) { + + case "add": + return blendAddService(); + + case "screen": + return blendScreenService(); + + case "alpha": + return blendAlphaService(); + + case "erase": + return blendEraseService(); + + case "copy": + return blendOneZeroService(); + + default: + return blendResetService(); + + } +}; diff --git a/packages/webgpu/src/BufferManager.test.ts b/packages/webgpu/src/BufferManager.test.ts new file mode 100644 index 00000000..a557244a --- /dev/null +++ b/packages/webgpu/src/BufferManager.test.ts @@ -0,0 +1,532 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { BufferManager } from "./BufferManager"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + VERTEX: 0x20, + UNIFORM: 0x40, + COPY_DST: 0x08, + STORAGE: 0x80, + INDIRECT: 0x100 +}; +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +// Mock service and usecase modules +vi.mock("./BufferManager/service/BufferManagerCreateRectVerticesService", () => ({ + "execute": vi.fn((x, y, width, height) => { + return new Float32Array([ + x, y, x + width, y, x, y + height, + x + width, y, x + width, y + height, x, y + height + ]); + }) +})); + +vi.mock("./BufferManager/usecase/BufferManagerAcquireVertexBufferUseCase", () => ({ + "execute": vi.fn((device, pool, requiredSize, data) => { + const buffer = { "size": requiredSize, "destroy": vi.fn(), "label": "pooledVertex" }; + if (!pool.has(requiredSize)) { + pool.set(requiredSize, []); + } + pool.get(requiredSize).push(buffer); + return buffer; + }) +})); + +vi.mock("./BufferManager/usecase/BufferManagerAcquireUniformBufferUseCase", () => ({ + "execute": vi.fn((device, pool, requiredSize) => { + const buffer = { "size": requiredSize, "destroy": vi.fn(), "label": "pooledUniform" }; + if (!pool.has(requiredSize)) { + pool.set(requiredSize, []); + } + pool.get(requiredSize).push(buffer); + return buffer; + }) +})); + +vi.mock("./BufferManager/service/BufferManagerReleaseVertexBufferService", () => ({ + "execute": vi.fn() +})); + +vi.mock("./BufferManager/service/BufferManagerReleaseUniformBufferService", () => ({ + "execute": vi.fn() +})); + +vi.mock("./BufferManager/usecase/BufferManagerAcquireStorageBufferUseCase", () => ({ + "execute": vi.fn((device, pool, requiredSize, frameNumber) => { + const buffer = { "size": requiredSize, "destroy": vi.fn(), "label": "storageBuffer" }; + pool.push({ buffer, "size": requiredSize, "inUse": true, "lastUsedFrame": frameNumber }); + return buffer; + }) +})); + +vi.mock("./BufferManager/usecase/BufferManagerReleaseStorageBufferUseCase", () => ({ + "execute": vi.fn((pool, buffer) => { + const entry = pool.find((e: any) => e.buffer === buffer); + if (entry) entry.inUse = false; + }) +})); + +vi.mock("./BufferManager/usecase/BufferManagerCleanupStorageBuffersUseCase", () => ({ + "execute": vi.fn() +})); + +vi.mock("./BufferManager/service/BufferManagerCreateIndirectBufferService", () => ({ + "execute": vi.fn(() => ({ "label": "indirectBuffer", "destroy": vi.fn() })) +})); + +vi.mock("./BufferManager/service/BufferManagerUpdateIndirectBufferService", () => ({ + "execute": vi.fn() +})); + +describe("BufferManager", () => +{ + const createMockDevice = (): GPUDevice => + { + return { + "createBuffer": vi.fn((descriptor) => ({ + "size": descriptor.size, + "destroy": vi.fn(), + "getMappedRange": vi.fn(() => new ArrayBuffer(descriptor.size)), + "unmap": vi.fn() + })), + "queue": { + "writeBuffer": vi.fn() + } + } as unknown as GPUDevice; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + }); + + describe("constructor", () => + { + it("should create instance with device", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + expect(manager).toBeDefined(); + }); + + it("should initialize with zero frame number", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + expect(manager.getFrameNumber()).toBe(0); + }); + + it("should initialize with empty pool stats", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + const stats = manager.getPoolStats(); + expect(stats.vertexPoolSize).toBe(0); + expect(stats.uniformPoolSize).toBe(0); + }); + }); + + describe("createVertexBuffer", () => + { + it("should create vertex buffer with data", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + const data = new Float32Array([1, 2, 3, 4]); + + const buffer = manager.createVertexBuffer("test", data); + + expect(buffer).toBeDefined(); + expect(device.createBuffer).toHaveBeenCalled(); + }); + + it("should store buffer by name", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + const data = new Float32Array([1, 2, 3]); + + manager.createVertexBuffer("myBuffer", data); + const retrieved = manager.getVertexBuffer("myBuffer"); + + expect(retrieved).toBeDefined(); + }); + }); + + describe("createUniformBuffer", () => + { + it("should create uniform buffer with specified size", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + const buffer = manager.createUniformBuffer("uniforms", 64); + + expect(buffer).toBeDefined(); + expect(device.createBuffer).toHaveBeenCalled(); + }); + + it("should store buffer by name", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + manager.createUniformBuffer("myUniforms", 128); + const retrieved = manager.getUniformBuffer("myUniforms"); + + expect(retrieved).toBeDefined(); + }); + }); + + describe("updateUniformBuffer", () => + { + it("should write data to existing buffer", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + const data = new Float32Array([1, 2, 3, 4]); + + manager.createUniformBuffer("uniforms", 64); + manager.updateUniformBuffer("uniforms", data); + + expect(device.queue.writeBuffer).toHaveBeenCalled(); + }); + + it("should not throw when buffer does not exist", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + const data = new Float32Array([1, 2, 3, 4]); + + expect(() => manager.updateUniformBuffer("nonexistent", data)).not.toThrow(); + }); + }); + + describe("getVertexBuffer", () => + { + it("should return undefined for non-existent buffer", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + expect(manager.getVertexBuffer("nonexistent")).toBeUndefined(); + }); + }); + + describe("getUniformBuffer", () => + { + it("should return undefined for non-existent buffer", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + expect(manager.getUniformBuffer("nonexistent")).toBeUndefined(); + }); + }); + + describe("createRectVertices", () => + { + it("should create rectangle vertices", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + const vertices = manager.createRectVertices(0, 0, 100, 100); + + expect(vertices).toBeInstanceOf(Float32Array); + expect(vertices.length).toBe(12); // 6 vertices * 2 coords + }); + }); + + describe("acquireVertexBuffer", () => + { + it("should acquire buffer from pool or create new", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + const buffer = manager.acquireVertexBuffer(256); + + expect(buffer).toBeDefined(); + }); + + it("should update pool stats", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + manager.acquireVertexBuffer(256); + + const stats = manager.getPoolStats(); + expect(stats.vertexPoolSize).toBe(1); + }); + }); + + describe("releaseVertexBuffer", () => + { + it("should release buffer back to pool", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + const buffer = manager.acquireVertexBuffer(256); + + expect(() => manager.releaseVertexBuffer(buffer)).not.toThrow(); + }); + }); + + describe("acquireUniformBuffer", () => + { + it("should acquire buffer from pool or create new", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + const buffer = manager.acquireUniformBuffer(64); + + expect(buffer).toBeDefined(); + }); + + it("should update pool stats", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + manager.acquireUniformBuffer(64); + + const stats = manager.getPoolStats(); + expect(stats.uniformPoolSize).toBe(1); + }); + }); + + describe("releaseUniformBuffer", () => + { + it("should release buffer back to pool", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + const buffer = manager.acquireUniformBuffer(64); + + expect(() => manager.releaseUniformBuffer(buffer)).not.toThrow(); + }); + }); + + describe("destroyBuffer", () => + { + it("should destroy vertex buffer by name", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + manager.createVertexBuffer("test", new Float32Array([1, 2, 3])); + manager.destroyBuffer("test"); + + expect(manager.getVertexBuffer("test")).toBeUndefined(); + }); + + it("should destroy uniform buffer by name", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + manager.createUniformBuffer("test", 64); + manager.destroyBuffer("test"); + + expect(manager.getUniformBuffer("test")).toBeUndefined(); + }); + }); + + describe("dispose", () => + { + it("should clear all buffers", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + manager.createVertexBuffer("v1", new Float32Array([1, 2, 3])); + manager.createUniformBuffer("u1", 64); + + manager.dispose(); + + expect(manager.getVertexBuffer("v1")).toBeUndefined(); + expect(manager.getUniformBuffer("u1")).toBeUndefined(); + }); + + it("should reset pool stats", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + manager.acquireVertexBuffer(256); + manager.acquireUniformBuffer(64); + + manager.dispose(); + + const stats = manager.getPoolStats(); + expect(stats.vertexPoolSize).toBe(0); + expect(stats.uniformPoolSize).toBe(0); + }); + }); + + describe("acquireStorageBuffer", () => + { + it("should acquire storage buffer", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + const buffer = manager.acquireStorageBuffer(1024); + + expect(buffer).toBeDefined(); + }); + + it("should update storage pool stats", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + manager.acquireStorageBuffer(1024); + + const stats = manager.getStoragePoolStats(); + expect(stats.storagePoolSize).toBe(1); + expect(stats.storagePoolInUse).toBe(1); + }); + }); + + describe("releaseStorageBuffer", () => + { + it("should release storage buffer", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + const buffer = manager.acquireStorageBuffer(1024); + + expect(() => manager.releaseStorageBuffer(buffer)).not.toThrow(); + }); + }); + + describe("writeStorageBuffer", () => + { + it("should write data to storage buffer", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + const buffer = manager.acquireStorageBuffer(256); + const data = new Float32Array([1, 2, 3, 4]); + + manager.writeStorageBuffer(buffer, data); + + expect(device.queue.writeBuffer).toHaveBeenCalled(); + }); + }); + + describe("releaseAllStorageBuffers", () => + { + it("should mark all storage buffers as not in use", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + manager.acquireStorageBuffer(256); + manager.acquireStorageBuffer(512); + + manager.releaseAllStorageBuffers(); + + const stats = manager.getStoragePoolStats(); + expect(stats.storagePoolInUse).toBe(0); + }); + }); + + describe("clearFrameBuffers", () => + { + it("should clear named buffers", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + manager.createVertexBuffer("frame", new Float32Array([1, 2, 3])); + manager.clearFrameBuffers(); + + expect(manager.getVertexBuffer("frame")).toBeUndefined(); + }); + + it("should increment frame number", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + const beforeFrame = manager.getFrameNumber(); + manager.clearFrameBuffers(); + + expect(manager.getFrameNumber()).toBe(beforeFrame + 1); + }); + + it("should release all storage buffers", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + manager.acquireStorageBuffer(256); + manager.clearFrameBuffers(); + + const stats = manager.getStoragePoolStats(); + expect(stats.storagePoolInUse).toBe(0); + }); + }); + + describe("getOrCreateIndirectBuffer", () => + { + it("should create indirect buffer on first call", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + const buffer = manager.getOrCreateIndirectBuffer(6, 10); + + expect(buffer).toBeDefined(); + }); + + it("should return same buffer on subsequent calls", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + const buffer1 = manager.getOrCreateIndirectBuffer(6, 10); + const buffer2 = manager.getOrCreateIndirectBuffer(6, 20); + + expect(buffer1).toBe(buffer2); + }); + }); + + describe("createIndirectBuffer", () => + { + it("should create new indirect buffer each time", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + const buffer1 = manager.createIndirectBuffer(6, 10); + const buffer2 = manager.createIndirectBuffer(6, 20); + + expect(buffer1).toBeDefined(); + expect(buffer2).toBeDefined(); + }); + }); + + describe("getFrameNumber", () => + { + it("should track frame count", () => + { + const device = createMockDevice(); + const manager = new BufferManager(device); + + manager.clearFrameBuffers(); + manager.clearFrameBuffers(); + manager.clearFrameBuffers(); + + expect(manager.getFrameNumber()).toBe(3); + }); + }); +}); diff --git a/packages/webgpu/src/BufferManager.ts b/packages/webgpu/src/BufferManager.ts new file mode 100644 index 00000000..4b78913d --- /dev/null +++ b/packages/webgpu/src/BufferManager.ts @@ -0,0 +1,517 @@ +import type { IPooledStorageBuffer } from "./interface/IStorageBufferConfig"; +import { execute as bufferManagerCreateRectVerticesService } from "./BufferManager/service/BufferManagerCreateRectVerticesService"; +import { execute as bufferManagerAcquireVertexBufferUseCase } from "./BufferManager/usecase/BufferManagerAcquireVertexBufferUseCase"; +import { execute as bufferManagerAcquireUniformBufferUseCase } from "./BufferManager/usecase/BufferManagerAcquireUniformBufferUseCase"; +import { execute as bufferManagerReleaseVertexBufferService } from "./BufferManager/service/BufferManagerReleaseVertexBufferService"; +import { execute as bufferManagerReleaseUniformBufferService } from "./BufferManager/service/BufferManagerReleaseUniformBufferService"; +import { execute as bufferManagerAcquireStorageBufferUseCase } from "./BufferManager/usecase/BufferManagerAcquireStorageBufferUseCase"; +import { execute as releaseStorageBufferUseCase } from "./BufferManager/usecase/BufferManagerReleaseStorageBufferUseCase"; +import { execute as cleanupStorageBuffersUseCase } from "./BufferManager/usecase/BufferManagerCleanupStorageBuffersUseCase"; +import { execute as bufferManagerCreateIndirectBufferService } from "./BufferManager/service/BufferManagerCreateIndirectBufferService"; +import { execute as updateIndirectBuffer } from "./BufferManager/service/BufferManagerUpdateIndirectBufferService"; + +/** + * @description Dynamic Uniform Buffer Allocator + * 1フレーム内の全fill uniform データを1本の大バッファにサブアロケートし、 + * BindGroup作成を1回に削減する。 + */ +export class DynamicUniformAllocator +{ + private device: GPUDevice; + private buffer: GPUBuffer | null = null; + private offset: number = 0; + private capacity: number; + readonly alignment: number = 256; + private pendingDestroyBuffers: GPUBuffer[] = []; + private stagingBuffer: ArrayBuffer; + private stagingFloat32: Float32Array; + private dirtyEnd: number = 0; + + constructor (device: GPUDevice, capacity: number = 65536) + { + this.device = device; + this.capacity = capacity; + this.stagingBuffer = new ArrayBuffer(capacity); + this.stagingFloat32 = new Float32Array(this.stagingBuffer); + } + + /** + * @description フレーム開始時にオフセットをリセット + * 前フレームの旧バッファを安全に破棄(submit済みのため) + */ + resetFrame (): void + { + this.offset = 0; + this.dirtyEnd = 0; + + for (const buf of this.pendingDestroyBuffers) { + buf.destroy(); + } + this.pendingDestroyBuffers.length = 0; + } + + /** + * @description バッファを取得(遅延生成) + */ + getBuffer (): GPUBuffer + { + if (!this.buffer) { + this.buffer = this.device.createBuffer({ + "size": this.capacity, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + } + return this.buffer; + } + + /** + * @description uniform データをCPUステージングバッファにコピーし、アライメント済みオフセットを返す + * 実際のGPU書き込みはflush()で一括実行される + * @param data - 書き込むデータ + * @return アライメント済みオフセット(バイト単位) + */ + allocate (data: Float32Array): number + { + // バッファの遅延生成 + if (!this.buffer) { + this.getBuffer(); + } + + const alignedOffset = this.offset; + const dataSize = data.byteLength; + + if (alignedOffset + dataSize > this.capacity) { + // 旧バッファにステージングデータをフラッシュ + this.flush(); + + // バッファが足りない場合は容量を倍増して再作成 + this.capacity *= 2; + const oldBuffer = this.buffer; + this.buffer = this.device.createBuffer({ + "size": this.capacity, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + if (oldBuffer) { + // 旧バッファは即座に破棄しない — コマンドエンコーダーに記録済みの + // コマンドが旧バッファを参照している可能性があるため、 + // フレーム終了後のresetFrame()で安全に破棄する + this.pendingDestroyBuffers.push(oldBuffer); + } + + // ステージングバッファも拡張 + this.stagingBuffer = new ArrayBuffer(this.capacity); + this.stagingFloat32 = new Float32Array(this.stagingBuffer); + } + + // CPUステージングバッファにコピー(writeBuffer呼ばない) + this.stagingFloat32.set(data, alignedOffset / 4); + + const end = alignedOffset + dataSize; + if (end > this.dirtyEnd) { + this.dirtyEnd = end; + } + + // 次のアロケーションは256バイトアライメント + this.offset = alignedOffset + Math.ceil(dataSize / this.alignment) * this.alignment; + + return alignedOffset; + } + + /** + * @description ステージングバッファの内容をGPUバッファに一括書き込み + * submit前に1回だけ呼び出す + */ + flush (): void + { + if (this.dirtyEnd > 0 && this.buffer) { + this.device.queue.writeBuffer(this.buffer, 0, this.stagingBuffer, 0, this.dirtyEnd); + this.dirtyEnd = 0; + } + } + + dispose (): void + { + if (this.buffer) { + this.buffer.destroy(); + this.buffer = null; + } + + for (const buf of this.pendingDestroyBuffers) { + buf.destroy(); + } + this.pendingDestroyBuffers.length = 0; + } +} + +export class BufferManager +{ + private device: GPUDevice; + private vertexBuffers: Map; + private uniformBuffers: Map; + private vertexBufferBuckets: Map; + private uniformBufferBuckets: Map; + private storageBufferPool: IPooledStorageBuffer[]; + private indirectBuffer: GPUBuffer | null; + private indirectBufferPool: GPUBuffer[]; + private frameIndirectBuffers: GPUBuffer[]; + private frameNumber: number; + private unitRectBuffer: GPUBuffer | null; + private frameVertexPoolBuffers: GPUBuffer[]; + private frameUniformPoolBuffers: GPUBuffer[]; + readonly dynamicUniform: DynamicUniformAllocator; + + constructor (device: GPUDevice) + { + this.device = device; + this.vertexBuffers = new Map(); + this.uniformBuffers = new Map(); + this.vertexBufferBuckets = new Map(); + this.uniformBufferBuckets = new Map(); + this.storageBufferPool = []; + this.indirectBuffer = null; + this.indirectBufferPool = []; + this.frameIndirectBuffers = []; + this.frameNumber = 0; + this.unitRectBuffer = null; + this.frameVertexPoolBuffers = []; + this.frameUniformPoolBuffers = []; + this.dynamicUniform = new DynamicUniformAllocator(device); + } + + createVertexBuffer (name: string, data: Float32Array): GPUBuffer + { + const buffer = this.device.createBuffer({ + "size": data.byteLength, + "usage": GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + "mappedAtCreation": true + }); + + new Float32Array(buffer.getMappedRange()).set(data); + buffer.unmap(); + + this.vertexBuffers.set(name, buffer); + return buffer; + } + + createUniformBuffer (name: string, size: number): GPUBuffer + { + const buffer = this.device.createBuffer({ + "size": size, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + + this.uniformBuffers.set(name, buffer); + return buffer; + } + + updateUniformBuffer (name: string, data: Float32Array): void + { + const buffer = this.uniformBuffers.get(name); + if (buffer) { + this.device.queue.writeBuffer(buffer, 0, data.buffer, data.byteOffset, data.byteLength); + } + } + + getVertexBuffer (name: string): GPUBuffer | undefined + { + return this.vertexBuffers.get(name); + } + + getUniformBuffer (name: string): GPUBuffer | undefined + { + return this.uniformBuffers.get(name); + } + + createRectVertices (x: number, y: number, width: number, height: number): Float32Array + { + return bufferManagerCreateRectVerticesService(x, y, width, height); + } + + acquireVertexBuffer (requiredSize: number, data?: Float32Array): GPUBuffer + { + const buffer = bufferManagerAcquireVertexBufferUseCase( + this.device, + this.vertexBufferBuckets, + requiredSize, + data + ); + this.frameVertexPoolBuffers.push(buffer); + return buffer; + } + + releaseVertexBuffer (buffer: GPUBuffer): void + { + bufferManagerReleaseVertexBufferService(this.vertexBufferBuckets, buffer); + } + + acquireUniformBuffer (requiredSize: number): GPUBuffer + { + const buffer = bufferManagerAcquireUniformBufferUseCase( + this.device, + this.uniformBufferBuckets, + requiredSize + ); + this.frameUniformPoolBuffers.push(buffer); + return buffer; + } + + /** + * @description Uniform Bufferの取得と書き込みを一括で行うヘルパー + * acquireUniformBuffer + writeBuffer の2ステップを1呼び出しに統合 + * @param data - 書き込むデータ + * @param byteLength - 書き込みバイト数(省略時はdata.byteLength) + * @return GPUBuffer + */ + acquireAndWriteUniformBuffer (data: Float32Array, byteLength?: number): GPUBuffer + { + const writeBytes = byteLength ?? data.byteLength; + const buffer = this.acquireUniformBuffer(writeBytes); + this.device.queue.writeBuffer(buffer, 0, data.buffer, data.byteOffset, writeBytes); + return buffer; + } + + releaseUniformBuffer (buffer: GPUBuffer): void + { + bufferManagerReleaseUniformBufferService(this.uniformBufferBuckets, buffer); + } + + destroyBuffer (name: string): void + { + const vertexBuffer = this.vertexBuffers.get(name); + if (vertexBuffer) { + vertexBuffer.destroy(); + this.vertexBuffers.delete(name); + } + + const uniformBuffer = this.uniformBuffers.get(name); + if (uniformBuffer) { + uniformBuffer.destroy(); + this.uniformBuffers.delete(name); + } + } + + dispose (): void + { + for (const buffer of this.vertexBuffers.values()) { + buffer.destroy(); + } + this.vertexBuffers.clear(); + + for (const buffer of this.uniformBuffers.values()) { + buffer.destroy(); + } + this.uniformBuffers.clear(); + + for (const bucket of this.vertexBufferBuckets.values()) { + for (const buffer of bucket) { + buffer.destroy(); + } + } + this.vertexBufferBuckets.clear(); + + for (const bucket of this.uniformBufferBuckets.values()) { + for (const buffer of bucket) { + buffer.destroy(); + } + } + this.uniformBufferBuckets.clear(); + + for (const entry of this.storageBufferPool) { + entry.buffer.destroy(); + } + this.storageBufferPool = []; + + if (this.indirectBuffer) { + this.indirectBuffer.destroy(); + this.indirectBuffer = null; + } + + for (const buffer of this.indirectBufferPool) { + buffer.destroy(); + } + this.indirectBufferPool = []; + + for (const buffer of this.frameIndirectBuffers) { + buffer.destroy(); + } + this.frameIndirectBuffers = []; + + if (this.unitRectBuffer) { + this.unitRectBuffer.destroy(); + this.unitRectBuffer = null; + } + + this.frameVertexPoolBuffers.length = 0; + this.frameUniformPoolBuffers.length = 0; + + this.dynamicUniform.dispose(); + } + + getPoolStats (): { vertexPoolSize: number; uniformPoolSize: number } + { + let vertexCount = 0; + for (const bucket of this.vertexBufferBuckets.values()) { + vertexCount += bucket.length; + } + let uniformCount = 0; + for (const bucket of this.uniformBufferBuckets.values()) { + uniformCount += bucket.length; + } + return { + "vertexPoolSize": vertexCount, + "uniformPoolSize": uniformCount + }; + } + + clearFrameBuffers (): void + { + for (const buffer of this.vertexBuffers.values()) { + buffer.destroy(); + } + this.vertexBuffers.clear(); + + for (const buffer of this.uniformBuffers.values()) { + buffer.destroy(); + } + this.uniformBuffers.clear(); + + // フレーム内で取得したプールバッファをプールに返却 + for (const buffer of this.frameVertexPoolBuffers) { + bufferManagerReleaseVertexBufferService(this.vertexBufferBuckets, buffer); + } + this.frameVertexPoolBuffers.length = 0; + + for (const buffer of this.frameUniformPoolBuffers) { + bufferManagerReleaseUniformBufferService(this.uniformBufferBuckets, buffer); + } + this.frameUniformPoolBuffers.length = 0; + + // フレーム内で使用したIndirect Bufferをプールに返却 + for (const buffer of this.frameIndirectBuffers) { + this.indirectBufferPool.push(buffer); + } + this.frameIndirectBuffers.length = 0; + + this.releaseAllStorageBuffers(); + + this.dynamicUniform.resetFrame(); + + this.frameNumber++; + + if (this.frameNumber % 60 === 0) { + cleanupStorageBuffersUseCase(this.storageBufferPool, this.frameNumber); + } + } + + releaseAllStorageBuffers (): void + { + for (const entry of this.storageBufferPool) { + entry.inUse = false; + } + } + + acquireStorageBuffer (requiredSize: number): GPUBuffer + { + return bufferManagerAcquireStorageBufferUseCase( + this.device, + this.storageBufferPool, + requiredSize, + this.frameNumber + ); + } + + releaseStorageBuffer (buffer: GPUBuffer): void + { + releaseStorageBufferUseCase(this.storageBufferPool, buffer); + } + + writeStorageBuffer (buffer: GPUBuffer, data: Float32Array | Uint32Array): void + { + this.device.queue.writeBuffer(buffer, 0, data.buffer, data.byteOffset, data.byteLength); + } + + getOrCreateIndirectBuffer ( + vertexCount: number, + instanceCount: number, + firstVertex: number = 0, + firstInstance: number = 0 + ): GPUBuffer { + if (!this.indirectBuffer) { + this.indirectBuffer = bufferManagerCreateIndirectBufferService( + this.device, + vertexCount, + instanceCount, + firstVertex, + firstInstance + ); + } else { + updateIndirectBuffer( + this.device, + this.indirectBuffer, + vertexCount, + instanceCount, + firstVertex, + firstInstance + ); + } + return this.indirectBuffer; + } + + createIndirectBuffer ( + vertexCount: number, + instanceCount: number, + firstVertex: number = 0, + firstInstance: number = 0 + ): GPUBuffer { + let buffer = this.indirectBufferPool.pop(); + if (buffer) { + updateIndirectBuffer( + this.device, + buffer, + vertexCount, + instanceCount, + firstVertex, + firstInstance + ); + } else { + buffer = bufferManagerCreateIndirectBufferService( + this.device, + vertexCount, + instanceCount, + firstVertex, + firstInstance + ); + } + this.frameIndirectBuffers.push(buffer); + return buffer; + } + + getUnitRectBuffer (): GPUBuffer + { + if (!this.unitRectBuffer) { + const vertices = this.createRectVertices(0, 0, 1, 1); + this.unitRectBuffer = this.device.createBuffer({ + "size": vertices.byteLength, + "usage": GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + "mappedAtCreation": true + }); + new Float32Array(this.unitRectBuffer.getMappedRange()).set(vertices); + this.unitRectBuffer.unmap(); + } + return this.unitRectBuffer; + } + + getFrameNumber (): number + { + return this.frameNumber; + } + + getStoragePoolStats (): { storagePoolSize: number; storagePoolInUse: number } + { + const inUse = this.storageBufferPool.filter((e) => e.inUse).length; + return { + "storagePoolSize": this.storageBufferPool.length, + "storagePoolInUse": inUse + }; + } +} diff --git a/packages/webgpu/src/BufferManager/service/BufferManagerCreateIndirectBufferService.test.ts b/packages/webgpu/src/BufferManager/service/BufferManagerCreateIndirectBufferService.test.ts new file mode 100644 index 00000000..26869166 --- /dev/null +++ b/packages/webgpu/src/BufferManager/service/BufferManagerCreateIndirectBufferService.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + VERTEX: 0x0020, + INDEX: 0x0010, + UNIFORM: 0x0040, + STORAGE: 0x0080, + INDIRECT: 0x0100, + COPY_SRC: 0x0004, + COPY_DST: 0x0008 +}; + +// Set global +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +// Mock GPUDevice +const mockBuffer = { + getMappedRange: vi.fn(() => new ArrayBuffer(16)), + unmap: vi.fn() +}; + +const mockDevice = { + createBuffer: vi.fn(() => mockBuffer), + queue: { + writeBuffer: vi.fn() + } +}; + +// Import after mocking +import { execute } from "./BufferManagerCreateIndirectBufferService"; +import { execute as update } from "./BufferManagerUpdateIndirectBufferService"; + +describe("BufferManagerCreateIndirectBufferService", () => +{ + beforeEach(() => + { + vi.clearAllMocks(); + }); + + describe("execute", () => + { + it("should create buffer with correct size", () => + { + execute(mockDevice as unknown as GPUDevice, 6, 100, 0, 0); + + expect(mockDevice.createBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + "size": 16, // 4 Uint32 values = 16 bytes + "usage": GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST, + "mappedAtCreation": true, + "label": "indirect_buffer" + }) + ); + }); + + it("should unmap buffer after writing", () => + { + execute(mockDevice as unknown as GPUDevice, 6, 50, 0, 0); + + expect(mockBuffer.unmap).toHaveBeenCalled(); + }); + + it("should return the created buffer", () => + { + const result = execute(mockDevice as unknown as GPUDevice, 6, 100, 0, 0); + expect(result).toBe(mockBuffer); + }); + + it("should handle default parameters", () => + { + execute(mockDevice as unknown as GPUDevice, 6, 100); + + expect(mockDevice.createBuffer).toHaveBeenCalled(); + }); + }); + + describe("update", () => + { + it("should write buffer with correct data", () => + { + update(mockDevice as unknown as GPUDevice, mockBuffer as unknown as GPUBuffer, 6, 200, 0, 0); + + expect(mockDevice.queue.writeBuffer).toHaveBeenCalledWith( + mockBuffer, + 0, + expect.any(Uint32Array) + ); + }); + + it("should handle different vertex and instance counts", () => + { + update(mockDevice as unknown as GPUDevice, mockBuffer as unknown as GPUBuffer, 3, 500, 1, 2); + + expect(mockDevice.queue.writeBuffer).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/webgpu/src/BufferManager/service/BufferManagerCreateIndirectBufferService.ts b/packages/webgpu/src/BufferManager/service/BufferManagerCreateIndirectBufferService.ts new file mode 100644 index 00000000..a50f719b --- /dev/null +++ b/packages/webgpu/src/BufferManager/service/BufferManagerCreateIndirectBufferService.ts @@ -0,0 +1,48 @@ +/** + * @description Indirect Bufferを作成 + * Create Indirect Buffer for draw indirect commands + * + * Indirect Drawingにより、CPUからのdraw呼び出しオーバーヘッドを削減。 + * GPU側でドローコールのパラメータを決定可能。 + * + * @param {GPUDevice} device - WebGPU device + * @param {number} vertex_count - 頂点数 + * @param {number} instance_count - インスタンス数 + * @param {number} first_vertex - 開始頂点インデックス + * @param {number} first_instance - 開始インスタンスインデックス + * @return {GPUBuffer} 作成されたIndirect Buffer + */ +export const execute = ( + device: GPUDevice, + vertex_count: number, + instance_count: number, + first_vertex: number = 0, + first_instance: number = 0 +): GPUBuffer => { + + // Indirect bufferのフォーマット(非インデックス描画用): + // - vertexCount: u32 + // - instanceCount: u32 + // - firstVertex: u32 + // - firstInstance: u32 + // 合計16バイト + + const indirectData = new Uint32Array([ + vertex_count, + instance_count, + first_vertex, + first_instance + ]); + + const buffer = device.createBuffer({ + "size": indirectData.byteLength, + "usage": GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST, + "mappedAtCreation": true, + "label": "indirect_buffer" + }); + + new Uint32Array(buffer.getMappedRange()).set(indirectData); + buffer.unmap(); + + return buffer; +}; diff --git a/packages/webgpu/src/BufferManager/service/BufferManagerCreateRectVerticesService.test.ts b/packages/webgpu/src/BufferManager/service/BufferManagerCreateRectVerticesService.test.ts new file mode 100644 index 00000000..43ea50ec --- /dev/null +++ b/packages/webgpu/src/BufferManager/service/BufferManagerCreateRectVerticesService.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { execute } from "./BufferManagerCreateRectVerticesService"; + +describe("BufferManagerCreateRectVerticesService", () => +{ + it("should create vertices for unit rectangle", () => + { + const result = execute(0, 0, 1, 1); + + expect(result).toBeInstanceOf(Float32Array); + // 6 vertices * 4 components (x, y, u, v) = 24 + expect(result.length).toBe(24); + }); + + it("should have correct positions for origin rectangle", () => + { + const result = execute(0, 0, 10, 20); + + // First triangle: (0,0), (10,0), (0,20) + expect(result[0]).toBe(0); // x + expect(result[1]).toBe(0); // y + expect(result[4]).toBe(10); // x + expect(result[5]).toBe(0); // y + expect(result[8]).toBe(0); // x + expect(result[9]).toBe(20); // y + + // Second triangle: (10,0), (10,20), (0,20) + expect(result[12]).toBe(10); // x + expect(result[13]).toBe(0); // y + expect(result[16]).toBe(10); // x + expect(result[17]).toBe(20); // y + expect(result[20]).toBe(0); // x + expect(result[21]).toBe(20); // y + }); + + it("should have correct texture coordinates", () => + { + const result = execute(0, 0, 1, 1); + + // First triangle tex coords + expect(result[2]).toBe(0); // u (top-left) + expect(result[3]).toBe(0); // v + expect(result[6]).toBe(1); // u (top-right) + expect(result[7]).toBe(0); // v + expect(result[10]).toBe(0); // u (bottom-left) + expect(result[11]).toBe(1); // v + + // Second triangle tex coords + expect(result[14]).toBe(1); // u (top-right) + expect(result[15]).toBe(0); // v + expect(result[18]).toBe(1); // u (bottom-right) + expect(result[19]).toBe(1); // v + expect(result[22]).toBe(0); // u (bottom-left) + expect(result[23]).toBe(1); // v + }); + + it("should handle offset position", () => + { + const result = execute(100, 200, 50, 30); + + // Check first vertex position + expect(result[0]).toBe(100); + expect(result[1]).toBe(200); + + // Check corner positions + expect(result[4]).toBe(150); // x + width + expect(result[5]).toBe(200); // y + expect(result[16]).toBe(150); // x + width + expect(result[17]).toBe(230); // y + height + }); + + it("should handle negative positions", () => + { + const result = execute(-10, -20, 30, 40); + + expect(result[0]).toBe(-10); + expect(result[1]).toBe(-20); + expect(result[4]).toBe(20); // -10 + 30 + expect(result[9]).toBe(20); // -20 + 40 + }); + + it("should handle zero dimensions", () => + { + const result = execute(5, 5, 0, 0); + + expect(result).toBeInstanceOf(Float32Array); + expect(result.length).toBe(24); + // All position x should be 5 + expect(result[0]).toBe(5); + expect(result[4]).toBe(5); + }); +}); diff --git a/packages/webgpu/src/BufferManager/service/BufferManagerCreateRectVerticesService.ts b/packages/webgpu/src/BufferManager/service/BufferManagerCreateRectVerticesService.ts new file mode 100644 index 00000000..49950224 --- /dev/null +++ b/packages/webgpu/src/BufferManager/service/BufferManagerCreateRectVerticesService.ts @@ -0,0 +1,29 @@ +/** + * @description 矩形の頂点データを作成 + * Create rect vertices data + * + * @param {number} x + * @param {number} y + * @param {number} width + * @param {number} height + * @return {Float32Array} + * @method + * @protected + */ +export const execute = ( + x: number, + y: number, + width: number, + height: number +): Float32Array => { + return new Float32Array([ + // Position (x, y), TexCoord (u, v) + x, y, 0.0, 0.0, + x + width, y, 1.0, 0.0, + x, y + height, 0.0, 1.0, + + x + width, y, 1.0, 0.0, + x + width, y + height, 1.0, 1.0, + x, y + height, 0.0, 1.0 + ]); +}; diff --git a/packages/webgpu/src/BufferManager/service/BufferManagerCreateStorageBufferService.test.ts b/packages/webgpu/src/BufferManager/service/BufferManagerCreateStorageBufferService.test.ts new file mode 100644 index 00000000..7975317e --- /dev/null +++ b/packages/webgpu/src/BufferManager/service/BufferManagerCreateStorageBufferService.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + VERTEX: 0x0020, + INDEX: 0x0010, + UNIFORM: 0x0040, + STORAGE: 0x0080, + INDIRECT: 0x0100, + COPY_SRC: 0x0004, + COPY_DST: 0x0008 +}; + +// Set global +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +// Mock GPUDevice +const mockBuffer = {}; + +const mockDevice = { + createBuffer: vi.fn(() => mockBuffer) +}; + +// Import after mocking +import { execute } from "./BufferManagerCreateStorageBufferService"; + +describe("BufferManagerCreateStorageBufferService", () => +{ + beforeEach(() => + { + vi.clearAllMocks(); + }); + + describe("execute", () => + { + it("should create buffer with correct usage flags", () => + { + const config = { + "size": 1024, + "usage": 0, + "label": "test_storage" + }; + + execute(mockDevice as unknown as GPUDevice, config); + + expect(mockDevice.createBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + "size": 1024, + "usage": GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.VERTEX, + "label": "test_storage" + }) + ); + }); + + it("should include VERTEX flag for setVertexBuffer compatibility", () => + { + const config = { + "size": 2048, + "usage": GPUBufferUsage.STORAGE + }; + + execute(mockDevice as unknown as GPUDevice, config); + + const callArgs = mockDevice.createBuffer.mock.calls[0][0]; + expect(callArgs.usage & GPUBufferUsage.VERTEX).toBeTruthy(); + }); + + it("should use default label if not provided", () => + { + const config = { + "size": 512, + "usage": 0 + }; + + execute(mockDevice as unknown as GPUDevice, config); + + expect(mockDevice.createBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + "label": "storage_buffer" + }) + ); + }); + + it("should return the created buffer", () => + { + const config = { + "size": 256, + "usage": 0 + }; + + const result = execute(mockDevice as unknown as GPUDevice, config); + expect(result).toBe(mockBuffer); + }); + + it("should combine provided usage with required flags", () => + { + const additionalUsage = GPUBufferUsage.UNIFORM; + const config = { + "size": 1024, + "usage": additionalUsage + }; + + execute(mockDevice as unknown as GPUDevice, config); + + const callArgs = mockDevice.createBuffer.mock.calls[0][0]; + expect(callArgs.usage & additionalUsage).toBeTruthy(); + expect(callArgs.usage & GPUBufferUsage.STORAGE).toBeTruthy(); + expect(callArgs.usage & GPUBufferUsage.COPY_DST).toBeTruthy(); + expect(callArgs.usage & GPUBufferUsage.VERTEX).toBeTruthy(); + }); + }); +}); diff --git a/packages/webgpu/src/BufferManager/service/BufferManagerCreateStorageBufferService.ts b/packages/webgpu/src/BufferManager/service/BufferManagerCreateStorageBufferService.ts new file mode 100644 index 00000000..8b413675 --- /dev/null +++ b/packages/webgpu/src/BufferManager/service/BufferManagerCreateStorageBufferService.ts @@ -0,0 +1,30 @@ +import type { IStorageBufferConfig } from "../../interface/IStorageBufferConfig"; + +/** + * @description Storage Bufferを作成 + * Create Storage Buffer for efficient instance data + * + * Storage Bufferは大きなデータの動的更新に最適。 + * Vertex Bufferと比較して: + * - より大きなサイズをサポート + * - 動的更新が効率的 + * - Compute Shaderでも使用可能 + * - Vertex Bufferとしても使用可能(VERTEXフラグ付き) + * + * @param {GPUDevice} device - WebGPU device + * @param {IStorageBufferConfig} config - バッファ設定 + * @return {GPUBuffer} 作成されたStorage Buffer + */ +export const execute = ( + device: GPUDevice, + config: IStorageBufferConfig +): GPUBuffer => { + + const buffer = device.createBuffer({ + "size": config.size, + "usage": config.usage | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.VERTEX, + "label": config.label || "storage_buffer" + }); + + return buffer; +}; diff --git a/packages/webgpu/src/BufferManager/service/BufferManagerReleaseUniformBufferService.test.ts b/packages/webgpu/src/BufferManager/service/BufferManagerReleaseUniformBufferService.test.ts new file mode 100644 index 00000000..32356895 --- /dev/null +++ b/packages/webgpu/src/BufferManager/service/BufferManagerReleaseUniformBufferService.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { execute } from "./BufferManagerReleaseUniformBufferService"; + +describe("BufferManagerReleaseUniformBufferService", () => +{ + let buckets: Map; + + const createMockBuffer = (size: number): GPUBuffer => ({ + "size": size, + "destroy": vi.fn() + } as unknown as GPUBuffer); + + beforeEach(() => + { + buckets = new Map(); + }); + + it("should add buffer to correct bucket", () => + { + const buffer = createMockBuffer(256); + + execute(buckets, buffer); + + expect(buckets.has(256)).toBe(true); + expect(buckets.get(256)!.length).toBe(1); + expect(buckets.get(256)![0]).toBe(buffer); + }); + + it("should add multiple uniform buffers to same bucket", () => + { + const buffer1 = createMockBuffer(256); + const buffer2 = createMockBuffer(256); + + execute(buckets, buffer1); + execute(buckets, buffer2); + + expect(buckets.get(256)!.length).toBe(2); + }); + + it("should destroy buffer when bucket is full", () => + { + // Fill bucket to max (32) + for (let i = 0; i < 32; i++) { + const buf = createMockBuffer(256); + execute(buckets, buf); + } + + const newBuffer = createMockBuffer(256); + execute(buckets, newBuffer); + + // Bucket stays at 32, new buffer destroyed + expect(buckets.get(256)!.length).toBe(32); + expect(newBuffer.destroy).toHaveBeenCalled(); + }); + + it("should not destroy buffer when bucket has space", () => + { + const buffer = createMockBuffer(256); + + execute(buckets, buffer); + + expect(buffer.destroy).not.toHaveBeenCalled(); + }); + + it("should handle empty buckets map", () => + { + const buffer = createMockBuffer(256); + + expect(() => execute(buckets, buffer)).not.toThrow(); + expect(buckets.get(256)!.length).toBe(1); + }); + + it("should store buffer with correct size key", () => + { + const buffer = createMockBuffer(1024); + + execute(buckets, buffer); + + expect(buckets.has(1024)).toBe(true); + expect(buckets.get(1024)![0]).toBe(buffer); + }); +}); diff --git a/packages/webgpu/src/BufferManager/service/BufferManagerReleaseUniformBufferService.ts b/packages/webgpu/src/BufferManager/service/BufferManagerReleaseUniformBufferService.ts new file mode 100644 index 00000000..ea30eb88 --- /dev/null +++ b/packages/webgpu/src/BufferManager/service/BufferManagerReleaseUniformBufferService.ts @@ -0,0 +1,39 @@ +/** + * @description バケットあたりの最大プールサイズ + * Maximum pool size per bucket + * @type {number} + * @const + */ +const MAX_BUCKET_SIZE: number = 32; + +/** + * @description ユニフォームバッファをプールに返却 + * Release uniform buffer back to pool + * バケット化されたMapにO(1)で返却 + * + * @param {Map} buckets - サイズ別バケットMap + * @param {GPUBuffer} buffer + * @return {void} + * @method + * @protected + */ +export const execute = ( + buckets: Map, + buffer: GPUBuffer +): void => { + const size = buffer.size; + let bucket = buckets.get(size); + + if (!bucket) { + bucket = []; + buckets.set(size, bucket); + } + + if (bucket.length >= MAX_BUCKET_SIZE) { + // バケットが満杯の場合、このバッファを破棄 + buffer.destroy(); + return; + } + + bucket.push(buffer); +}; diff --git a/packages/webgpu/src/BufferManager/service/BufferManagerReleaseVertexBufferService.test.ts b/packages/webgpu/src/BufferManager/service/BufferManagerReleaseVertexBufferService.test.ts new file mode 100644 index 00000000..1dfe80f1 --- /dev/null +++ b/packages/webgpu/src/BufferManager/service/BufferManagerReleaseVertexBufferService.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { execute } from "./BufferManagerReleaseVertexBufferService"; + +describe("BufferManagerReleaseVertexBufferService", () => +{ + let buckets: Map; + + const createMockBuffer = (size: number): GPUBuffer => ({ + "size": size, + "destroy": vi.fn() + } as unknown as GPUBuffer); + + beforeEach(() => + { + buckets = new Map(); + }); + + it("should add buffer to correct bucket", () => + { + const buffer = createMockBuffer(1024); + + execute(buckets, buffer); + + expect(buckets.has(1024)).toBe(true); + expect(buckets.get(1024)!.length).toBe(1); + expect(buckets.get(1024)![0]).toBe(buffer); + }); + + it("should add multiple buffers to same bucket", () => + { + const buffer1 = createMockBuffer(512); + const buffer2 = createMockBuffer(512); + + execute(buckets, buffer1); + execute(buckets, buffer2); + + expect(buckets.get(512)!.length).toBe(2); + }); + + it("should add buffers to different buckets", () => + { + const buffer1 = createMockBuffer(256); + const buffer2 = createMockBuffer(512); + + execute(buckets, buffer1); + execute(buckets, buffer2); + + expect(buckets.get(256)!.length).toBe(1); + expect(buckets.get(512)!.length).toBe(1); + }); + + it("should destroy buffer when bucket is full", () => + { + // Fill bucket to max (32) + for (let i = 0; i < 32; i++) { + const buf = createMockBuffer(1024); + execute(buckets, buf); + } + + const newBuffer = createMockBuffer(1024); + execute(buckets, newBuffer); + + // Bucket stays at 32, new buffer destroyed + expect(buckets.get(1024)!.length).toBe(32); + expect(newBuffer.destroy).toHaveBeenCalled(); + }); + + it("should not destroy buffer when bucket has space", () => + { + const buffer = createMockBuffer(1024); + + execute(buckets, buffer); + + expect(buffer.destroy).not.toHaveBeenCalled(); + expect(buckets.get(1024)!.length).toBe(1); + }); + + it("should handle empty buckets map", () => + { + const buffer = createMockBuffer(256); + + expect(() => execute(buckets, buffer)).not.toThrow(); + expect(buckets.get(256)!.length).toBe(1); + }); +}); diff --git a/packages/webgpu/src/BufferManager/service/BufferManagerReleaseVertexBufferService.ts b/packages/webgpu/src/BufferManager/service/BufferManagerReleaseVertexBufferService.ts new file mode 100644 index 00000000..359e5922 --- /dev/null +++ b/packages/webgpu/src/BufferManager/service/BufferManagerReleaseVertexBufferService.ts @@ -0,0 +1,39 @@ +/** + * @description バケットあたりの最大プールサイズ + * Maximum pool size per bucket + * @type {number} + * @const + */ +const MAX_BUCKET_SIZE: number = 32; + +/** + * @description 頂点バッファをプールに返却 + * Release vertex buffer back to pool + * バケット化されたMapにO(1)で返却 + * + * @param {Map} buckets - サイズ別バケットMap + * @param {GPUBuffer} buffer + * @return {void} + * @method + * @protected + */ +export const execute = ( + buckets: Map, + buffer: GPUBuffer +): void => { + const size = buffer.size; + let bucket = buckets.get(size); + + if (!bucket) { + bucket = []; + buckets.set(size, bucket); + } + + if (bucket.length >= MAX_BUCKET_SIZE) { + // バケットが満杯の場合、このバッファを破棄 + buffer.destroy(); + return; + } + + bucket.push(buffer); +}; diff --git a/packages/webgpu/src/BufferManager/service/BufferManagerUpdateIndirectBufferService.ts b/packages/webgpu/src/BufferManager/service/BufferManagerUpdateIndirectBufferService.ts new file mode 100644 index 00000000..1ddec2ad --- /dev/null +++ b/packages/webgpu/src/BufferManager/service/BufferManagerUpdateIndirectBufferService.ts @@ -0,0 +1,29 @@ +/** + * @description Indirect Bufferを更新 + * Update Indirect Buffer with new parameters + * + * @param {GPUDevice} device - WebGPU device + * @param {GPUBuffer} buffer - 更新するバッファ + * @param {number} vertex_count - 頂点数 + * @param {number} instance_count - インスタンス数 + * @param {number} first_vertex - 開始頂点インデックス + * @param {number} first_instance - 開始インスタンスインデックス + */ +export const execute = ( + device: GPUDevice, + buffer: GPUBuffer, + vertex_count: number, + instance_count: number, + first_vertex: number = 0, + first_instance: number = 0 +): void => { + + const indirectData = new Uint32Array([ + vertex_count, + instance_count, + first_vertex, + first_instance + ]); + + device.queue.writeBuffer(buffer, 0, indirectData); +}; diff --git a/packages/webgpu/src/BufferManager/service/BufferManagerUpperPowerOfTwoService.test.ts b/packages/webgpu/src/BufferManager/service/BufferManagerUpperPowerOfTwoService.test.ts new file mode 100644 index 00000000..6e0af6b8 --- /dev/null +++ b/packages/webgpu/src/BufferManager/service/BufferManagerUpperPowerOfTwoService.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { execute } from "./BufferManagerUpperPowerOfTwoService"; + +describe("BufferManagerUpperPowerOfTwoService", () => +{ + it("should return 1 for input 1", () => + { + expect(execute(1)).toBe(1); + }); + + it("should return same value for power of two inputs", () => + { + expect(execute(2)).toBe(2); + expect(execute(4)).toBe(4); + expect(execute(8)).toBe(8); + expect(execute(16)).toBe(16); + expect(execute(32)).toBe(32); + expect(execute(64)).toBe(64); + expect(execute(128)).toBe(128); + expect(execute(256)).toBe(256); + expect(execute(512)).toBe(512); + expect(execute(1024)).toBe(1024); + }); + + it("should round up non-power of two values", () => + { + expect(execute(3)).toBe(4); + expect(execute(5)).toBe(8); + expect(execute(6)).toBe(8); + expect(execute(7)).toBe(8); + expect(execute(9)).toBe(16); + expect(execute(15)).toBe(16); + expect(execute(17)).toBe(32); + expect(execute(100)).toBe(128); + expect(execute(200)).toBe(256); + expect(execute(500)).toBe(512); + expect(execute(1000)).toBe(1024); + }); + + it("should handle large values", () => + { + expect(execute(2048)).toBe(2048); + expect(execute(2049)).toBe(4096); + expect(execute(4096)).toBe(4096); + expect(execute(4097)).toBe(8192); + }); + + it("should handle edge cases", () => + { + // 0 -> 0 (edge case behavior) + expect(execute(0)).toBe(0); + }); + + it("should handle typical texture sizes", () => + { + // Common texture dimension patterns + expect(execute(640)).toBe(1024); + expect(execute(480)).toBe(512); + expect(execute(1920)).toBe(2048); + expect(execute(1080)).toBe(2048); + }); +}); diff --git a/packages/webgpu/src/BufferManager/service/BufferManagerUpperPowerOfTwoService.ts b/packages/webgpu/src/BufferManager/service/BufferManagerUpperPowerOfTwoService.ts new file mode 100644 index 00000000..14029bc4 --- /dev/null +++ b/packages/webgpu/src/BufferManager/service/BufferManagerUpperPowerOfTwoService.ts @@ -0,0 +1,19 @@ +/** + * @description 2のべき乗に切り上げ + * Round up to the next power of two + * + * @param {number} v + * @return {number} + * @method + * @protected + */ +export const execute = (v: number): number => +{ + v--; + v |= v >> 1; + v |= v >> 2; + v |= v >> 4; + v |= v >> 8; + v |= v >> 16; + return v + 1; +}; diff --git a/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireStorageBufferUseCase.test.ts b/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireStorageBufferUseCase.test.ts new file mode 100644 index 00000000..2801ffc0 --- /dev/null +++ b/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireStorageBufferUseCase.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IPooledStorageBuffer } from "../../interface/IStorageBufferConfig"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + VERTEX: 0x0020, + INDEX: 0x0010, + UNIFORM: 0x0040, + STORAGE: 0x0080, + INDIRECT: 0x0100, + COPY_SRC: 0x0004, + COPY_DST: 0x0008 +}; + +// Set global +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +// Mock the service +vi.mock("../service/BufferManagerCreateStorageBufferService", () => ({ + execute: vi.fn((device, config) => ({ + "size": config.size, + "destroy": vi.fn() + })) +})); + +import { execute } from "./BufferManagerAcquireStorageBufferUseCase"; +import { execute as releaseStorageBuffer } from "./BufferManagerReleaseStorageBufferUseCase"; +import { execute as cleanupStorageBuffers } from "./BufferManagerCleanupStorageBuffersUseCase"; + +describe("BufferManagerAcquireStorageBufferUseCase", () => +{ + let mockDevice: GPUDevice; + let pool: IPooledStorageBuffer[]; + + beforeEach(() => + { + mockDevice = {} as GPUDevice; + pool = []; + vi.clearAllMocks(); + }); + + describe("execute", () => + { + it("should create new buffer when pool is empty", () => + { + const buffer = execute(mockDevice, pool, 1024, 0); + + expect(buffer).toBeDefined(); + expect(pool.length).toBe(1); + expect(pool[0].inUse).toBe(true); + }); + + it("should reuse buffer from pool if size matches", () => + { + // First allocation + const buffer1 = execute(mockDevice, pool, 1024, 0); + releaseStorageBuffer(pool, buffer1); + + // Second allocation should reuse + const buffer2 = execute(mockDevice, pool, 512, 1); + + expect(buffer2).toBe(buffer1); + expect(pool.length).toBe(1); + }); + + it("should align size to 256 bytes", () => + { + execute(mockDevice, pool, 100, 0); + + // 100 bytes should be aligned to 256 + expect(pool[0].size).toBeGreaterThanOrEqual(256); + }); + + it("should select best fit buffer", () => + { + // Create buffers of different sizes + const small = execute(mockDevice, pool, 256, 0); + const large = execute(mockDevice, pool, 4096, 0); + + releaseStorageBuffer(pool, small); + releaseStorageBuffer(pool, large); + + // Request medium size - should get small (closest fit) + const result = execute(mockDevice, pool, 256, 1); + + expect(result).toBe(small); + }); + + it("should set lastUsedFrame correctly", () => + { + const frameNumber = 42; + execute(mockDevice, pool, 1024, frameNumber); + + expect(pool[0].lastUsedFrame).toBe(frameNumber); + }); + }); + + describe("releaseStorageBuffer", () => + { + it("should mark buffer as not in use", () => + { + const buffer = execute(mockDevice, pool, 1024, 0); + expect(pool[0].inUse).toBe(true); + + releaseStorageBuffer(pool, buffer); + expect(pool[0].inUse).toBe(false); + }); + + it("should handle buffer not in pool", () => + { + const fakeBuffer = {} as GPUBuffer; + + // Should not throw + expect(() => releaseStorageBuffer(pool, fakeBuffer)).not.toThrow(); + }); + }); + + describe("cleanupStorageBuffers", () => + { + it("should remove old unused buffers", () => + { + // Create and release a buffer at frame 0 + const oldBuffer = execute(mockDevice, pool, 1024, 0); + releaseStorageBuffer(pool, oldBuffer); + + // Cleanup at frame 100 with maxAge 60 + cleanupStorageBuffers(pool, 100, 60); + + expect(pool.length).toBe(0); + }); + + it("should keep recently used buffers", () => + { + // Create and release at frame 50 + const buffer = execute(mockDevice, pool, 1024, 50); + releaseStorageBuffer(pool, buffer); + + // Cleanup at frame 100 with maxAge 60 - buffer is only 50 frames old + cleanupStorageBuffers(pool, 100, 60); + + expect(pool.length).toBe(1); + }); + + it("should keep buffers that are in use", () => + { + // Create buffer but don't release + execute(mockDevice, pool, 1024, 0); + + // Cleanup at frame 100 + cleanupStorageBuffers(pool, 100, 60); + + expect(pool.length).toBe(1); + }); + + it("should use default maxAge of 60", () => + { + const buffer = execute(mockDevice, pool, 1024, 0); + releaseStorageBuffer(pool, buffer); + + // Frame 61 - just over the default maxAge + cleanupStorageBuffers(pool, 61); + + expect(pool.length).toBe(0); + }); + }); +}); diff --git a/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireStorageBufferUseCase.ts b/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireStorageBufferUseCase.ts new file mode 100644 index 00000000..44039af5 --- /dev/null +++ b/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireStorageBufferUseCase.ts @@ -0,0 +1,66 @@ +import type { IPooledStorageBuffer } from "../../interface/IStorageBufferConfig"; +import { execute as createStorageBufferService } from "../service/BufferManagerCreateStorageBufferService"; + +/** + * @description プールからStorage Bufferを取得(または新規作成) + * Acquire Storage Buffer from pool or create new one + * + * メモリアロケーションを最小化するため、 + * 使用済みのバッファを再利用する。 + * + * @param {GPUDevice} device - WebGPU device + * @param {IPooledStorageBuffer[]} pool - Storage Bufferプール + * @param {number} required_size - 必要なサイズ(バイト) + * @param {number} current_frame - 現在のフレーム番号 + * @return {GPUBuffer} 取得されたStorage Buffer + */ +export const execute = ( + device: GPUDevice, + pool: IPooledStorageBuffer[], + required_size: number, + current_frame: number +): GPUBuffer => { + + // アライメントを考慮(256バイト境界) + const alignedSize = Math.ceil(required_size / 256) * 256; + + // プールから適切なサイズの未使用バッファを検索 + // 最もサイズが近いバッファを選択(メモリ効率) + let bestMatch: IPooledStorageBuffer | null = null; + let bestSizeDiff = Infinity; + + for (const entry of pool) { + if (!entry.inUse && entry.size >= alignedSize) { + const sizeDiff = entry.size - alignedSize; + if (sizeDiff < bestSizeDiff) { + bestMatch = entry; + bestSizeDiff = sizeDiff; + } + } + } + + if (bestMatch) { + bestMatch.inUse = true; + bestMatch.lastUsedFrame = current_frame; + return bestMatch.buffer; + } + + // 適切なバッファがない場合は新規作成 + // 将来の再利用のために少し大きめに確保 + const createSize = Math.max(alignedSize, 16384); // 最小16KB + + const buffer = createStorageBufferService(device, { + "size": createSize, + "usage": GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.VERTEX, + "label": `storage_buffer_${pool.length}` + }); + + pool.push({ + "buffer": buffer, + "size": createSize, + "inUse": true, + "lastUsedFrame": current_frame + }); + + return buffer; +}; diff --git a/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireUniformBufferUseCase.test.ts b/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireUniformBufferUseCase.test.ts new file mode 100644 index 00000000..464e3185 --- /dev/null +++ b/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireUniformBufferUseCase.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { execute } from "./BufferManagerAcquireUniformBufferUseCase"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + UNIFORM: 0x0040, + COPY_DST: 0x0008 +}; +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +describe("BufferManagerAcquireUniformBufferUseCase", () => +{ + let buckets: Map; + + const createMockDevice = () => + { + let bufferId = 0; + return { + "createBuffer": vi.fn((descriptor) => ({ + "id": ++bufferId, + "size": descriptor.size, + "usage": descriptor.usage + })) + } as unknown as GPUDevice; + }; + + const createMockBuffer = (size: number): GPUBuffer => ({ + "size": size, + "id": Math.random() + } as unknown as GPUBuffer); + + beforeEach(() => + { + buckets = new Map(); + }); + + it("should return buffer from bucket if size matches", () => + { + const buffer = createMockBuffer(256); + buckets.set(256, [buffer]); + const device = createMockDevice(); + + const result = execute(device, buckets, 256); + + expect(result).toBe(buffer); + expect(buckets.get(256)!.length).toBe(0); + expect(device.createBuffer).not.toHaveBeenCalled(); + }); + + it("should create new buffer if buckets are empty", () => + { + const device = createMockDevice(); + + const result = execute(device, buckets, 256); + + expect(device.createBuffer).toHaveBeenCalledWith({ + "size": 256, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + }); + + it("should align size to 16 bytes", () => + { + const device = createMockDevice(); + + execute(device, buckets, 100); // Should align to 112 (ceil(100/16)*16) + + expect(device.createBuffer).toHaveBeenCalledWith( + expect.objectContaining({ "size": 112 }) + ); + }); + + it("should align size when requesting exact multiple of 16", () => + { + const device = createMockDevice(); + + execute(device, buckets, 64); // Already aligned + + expect(device.createBuffer).toHaveBeenCalledWith( + expect.objectContaining({ "size": 64 }) + ); + }); + + it("should return buffer from bucket with aligned size", () => + { + // 100 aligns to 112 + const buffer = createMockBuffer(112); + buckets.set(112, [buffer]); + const device = createMockDevice(); + + const result = execute(device, buckets, 100); + + expect(result).toBe(buffer); + expect(device.createBuffer).not.toHaveBeenCalled(); + }); + + it("should handle very small sizes", () => + { + const device = createMockDevice(); + + execute(device, buckets, 1); + + // Should align to 16 + expect(device.createBuffer).toHaveBeenCalledWith( + expect.objectContaining({ "size": 16 }) + ); + }); + + it("should pop last buffer from bucket (LIFO)", () => + { + const buffer1 = createMockBuffer(256); + const buffer2 = createMockBuffer(256); + buckets.set(256, [buffer1, buffer2]); + const device = createMockDevice(); + + const result = execute(device, buckets, 256); + + expect(result).toBe(buffer2); // LIFO: last in, first out + expect(buckets.get(256)!.length).toBe(1); + expect(buckets.get(256)![0]).toBe(buffer1); + }); +}); diff --git a/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireUniformBufferUseCase.ts b/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireUniformBufferUseCase.ts new file mode 100644 index 00000000..a85c29d1 --- /dev/null +++ b/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireUniformBufferUseCase.ts @@ -0,0 +1,33 @@ +/** + * @description プールからユニフォームバッファを取得(または新規作成) + * Acquire uniform buffer from pool (or create new) + * バケット化されたMapからO(1)で取得 + * + * @param {GPUDevice} device + * @param {Map} buckets - サイズ別バケットMap + * @param {number} required_size - 必要なバイトサイズ + * @return {GPUBuffer} + * @method + * @protected + */ +export const execute = ( + device: GPUDevice, + buckets: Map, + required_size: number +): GPUBuffer => { + // 16バイトアライメント + const alignedSize = Math.ceil(required_size / 16) * 16; + + // バケットからバッファを取得(O(1)) + const bucket = buckets.get(alignedSize); + + if (bucket && bucket.length > 0) { + return bucket.pop()!; + } + + // 新規作成 + return device.createBuffer({ + "size": alignedSize, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); +}; diff --git a/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireVertexBufferUseCase.test.ts b/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireVertexBufferUseCase.test.ts new file mode 100644 index 00000000..52bc251c --- /dev/null +++ b/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireVertexBufferUseCase.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { execute } from "./BufferManagerAcquireVertexBufferUseCase"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + VERTEX: 0x0020, + COPY_DST: 0x0008 +}; +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +describe("BufferManagerAcquireVertexBufferUseCase", () => +{ + let buckets: Map; + + const createMockDevice = () => + { + let bufferId = 0; + return { + "createBuffer": vi.fn((descriptor) => ({ + "id": ++bufferId, + "size": descriptor.size, + "usage": descriptor.usage, + "getMappedRange": vi.fn(() => new ArrayBuffer(descriptor.size)), + "unmap": vi.fn() + })), + "queue": { + "writeBuffer": vi.fn() + } + } as unknown as GPUDevice; + }; + + const createMockBuffer = (size: number): GPUBuffer => ({ + "size": size, + "id": Math.random() + } as unknown as GPUBuffer); + + beforeEach(() => + { + buckets = new Map(); + }); + + it("should return buffer from bucket if size matches", () => + { + const buffer = createMockBuffer(256); + buckets.set(256, [buffer]); + const device = createMockDevice(); + + const result = execute(device, buckets, 256); + + expect(result).toBe(buffer); + expect(buckets.get(256)!.length).toBe(0); + expect(device.createBuffer).not.toHaveBeenCalled(); + }); + + it("should create new buffer if buckets are empty", () => + { + const device = createMockDevice(); + + execute(device, buckets, 256); + + expect(device.createBuffer).toHaveBeenCalled(); + }); + + it("should create buffer with power of two size", () => + { + const device = createMockDevice(); + + execute(device, buckets, 100); // Should round up to 128 + + expect(device.createBuffer).toHaveBeenCalledWith( + expect.objectContaining({ "size": 128 }) + ); + }); + + it("should create buffer with correct usage flags", () => + { + const device = createMockDevice(); + + execute(device, buckets, 256); + + expect(device.createBuffer).toHaveBeenCalledWith({ + "size": 256, + "usage": GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST + }); + }); + + it("should return buffer from correct bucket for non-power-of-two size", () => + { + // 100 rounds to 128 + const buffer = createMockBuffer(128); + buckets.set(128, [buffer]); + const device = createMockDevice(); + + const result = execute(device, buckets, 100); + + expect(result).toBe(buffer); + expect(device.createBuffer).not.toHaveBeenCalled(); + }); + + it("should write data to buffer if provided", () => + { + const device = createMockDevice(); + const data = new Float32Array([1, 2, 3, 4]); + + const result = execute(device, buckets, 256, data); + + // 新規バッファ + data有り: mappedAtCreation方式 (writeBufferは呼ばれない) + expect(device.createBuffer).toHaveBeenCalledWith( + expect.objectContaining({ "mappedAtCreation": true }) + ); + expect((result as any).getMappedRange).toHaveBeenCalled(); + expect((result as any).unmap).toHaveBeenCalled(); + }); + + it("should not write data if not provided", () => + { + const device = createMockDevice(); + + execute(device, buckets, 256); + + expect(device.queue.writeBuffer).not.toHaveBeenCalled(); + }); + + it("should write data to pooled buffer", () => + { + const buffer = createMockBuffer(256); + buckets.set(256, [buffer]); + const device = createMockDevice(); + const data = new Float32Array([1, 2, 3, 4]); + + const result = execute(device, buckets, 256, data); + + expect(result).toBe(buffer); + expect(device.queue.writeBuffer).toHaveBeenCalledWith( + buffer, + 0, + data.buffer, + data.byteOffset, + data.byteLength + ); + }); + + it("should handle data with byte offset", () => + { + const device = createMockDevice(); + const arrayBuffer = new ArrayBuffer(64); + const data = new Float32Array(arrayBuffer, 16, 4); // Offset of 16 bytes + + const result = execute(device, buckets, 256, data); + + // 新規バッファ + data有り: mappedAtCreation方式 + expect(device.createBuffer).toHaveBeenCalledWith( + expect.objectContaining({ "mappedAtCreation": true }) + ); + expect((result as any).getMappedRange).toHaveBeenCalled(); + expect((result as any).unmap).toHaveBeenCalled(); + }); + + it("should pop last buffer from bucket (LIFO)", () => + { + const buffer1 = createMockBuffer(256); + const buffer2 = createMockBuffer(256); + buckets.set(256, [buffer1, buffer2]); + const device = createMockDevice(); + + const result = execute(device, buckets, 256); + + expect(result).toBe(buffer2); // LIFO: last in, first out + expect(buckets.get(256)!.length).toBe(1); + expect(buckets.get(256)![0]).toBe(buffer1); + }); +}); diff --git a/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireVertexBufferUseCase.ts b/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireVertexBufferUseCase.ts new file mode 100644 index 00000000..726fcc71 --- /dev/null +++ b/packages/webgpu/src/BufferManager/usecase/BufferManagerAcquireVertexBufferUseCase.ts @@ -0,0 +1,53 @@ +import { execute as bufferManagerUpperPowerOfTwoService } from "../service/BufferManagerUpperPowerOfTwoService"; + +/** + * @description プールから頂点バッファを取得(または新規作成) + * Acquire vertex buffer from pool (or create new) + * バケット化されたMapからO(1)で取得 + * + * @param {GPUDevice} device + * @param {Map} buckets - サイズ別バケットMap + * @param {number} required_size - 必要なバイトサイズ + * @param {Float32Array} [data] - 初期データ + * @return {GPUBuffer} + * @method + * @protected + */ +export const execute = ( + device: GPUDevice, + buckets: Map, + required_size: number, + data?: Float32Array +): GPUBuffer => { + // 2のべき乗に切り上げてバケットキーとする + const bucketSize = bufferManagerUpperPowerOfTwoService(required_size); + + // バケットからバッファを取得(O(1)) + const bucket = buckets.get(bucketSize); + let buffer: GPUBuffer; + + if (bucket && bucket.length > 0) { + buffer = bucket.pop()!; + // プールヒット: writeBufferで更新 + if (data) { + device.queue.writeBuffer(buffer, 0, data.buffer, data.byteOffset, data.byteLength); + } + } else if (data) { + // 新規作成 + データあり: mappedAtCreationで1コールに統合 + buffer = device.createBuffer({ + "size": bucketSize, + "usage": GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + "mappedAtCreation": true + }); + new Float32Array(buffer.getMappedRange()).set(data); + buffer.unmap(); + } else { + // 新規作成 + データなし + buffer = device.createBuffer({ + "size": bucketSize, + "usage": GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST + }); + } + + return buffer; +}; diff --git a/packages/webgpu/src/BufferManager/usecase/BufferManagerCleanupStorageBuffersUseCase.ts b/packages/webgpu/src/BufferManager/usecase/BufferManagerCleanupStorageBuffersUseCase.ts new file mode 100644 index 00000000..6304374c --- /dev/null +++ b/packages/webgpu/src/BufferManager/usecase/BufferManagerCleanupStorageBuffersUseCase.ts @@ -0,0 +1,27 @@ +import type { IPooledStorageBuffer } from "../../interface/IStorageBufferConfig"; + +/** + * @description 古いStorage Bufferをクリーンアップ + * Cleanup old Storage Buffers + * + * 一定フレーム数使用されていないバッファを解放。 + * + * @param {IPooledStorageBuffer[]} pool - Storage Bufferプール + * @param {number} current_frame - 現在のフレーム番号 + * @param {number} max_age - 最大保持フレーム数 + */ +export const execute = ( + pool: IPooledStorageBuffer[], + current_frame: number, + max_age: number = 60 +): void => { + + // 古いバッファを削除 + for (let i = pool.length - 1; i >= 0; i--) { + const entry = pool[i]; + if (!entry.inUse && current_frame - entry.lastUsedFrame > max_age) { + entry.buffer.destroy(); + pool.splice(i, 1); + } + } +}; diff --git a/packages/webgpu/src/BufferManager/usecase/BufferManagerReleaseStorageBufferUseCase.ts b/packages/webgpu/src/BufferManager/usecase/BufferManagerReleaseStorageBufferUseCase.ts new file mode 100644 index 00000000..da94118e --- /dev/null +++ b/packages/webgpu/src/BufferManager/usecase/BufferManagerReleaseStorageBufferUseCase.ts @@ -0,0 +1,21 @@ +import type { IPooledStorageBuffer } from "../../interface/IStorageBufferConfig"; + +/** + * @description Storage Bufferをプールに返却 + * Release Storage Buffer back to pool + * + * @param {IPooledStorageBuffer[]} pool - Storage Bufferプール + * @param {GPUBuffer} buffer - 返却するバッファ + */ +export const execute = ( + pool: IPooledStorageBuffer[], + buffer: GPUBuffer +): void => { + + for (const entry of pool) { + if (entry.buffer === buffer) { + entry.inUse = false; + return; + } + } +}; diff --git a/packages/webgpu/src/Compute/ComputePipelineManager.test.ts b/packages/webgpu/src/Compute/ComputePipelineManager.test.ts new file mode 100644 index 00000000..62ba9cda --- /dev/null +++ b/packages/webgpu/src/Compute/ComputePipelineManager.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock GPUShaderStage +const GPUShaderStage = { + COMPUTE: 0x04 +}; +(globalThis as any).GPUShaderStage = GPUShaderStage; + +describe("ComputePipelineManager", () => +{ + // Create a mock implementation for testing without the actual class + class MockComputePipelineManager + { + private pipelines: Map; + private bindGroupLayouts: Map; + + constructor (_device: GPUDevice) + { + this.pipelines = new Map(); + this.bindGroupLayouts = new Map(); + + // Initialize mock pipelines + this.pipelines.set("blur_compute_horizontal", { "label": "blur_compute_horizontal" }); + this.pipelines.set("blur_compute_vertical", { "label": "blur_compute_vertical" }); + + // Initialize mock bind group layouts + this.bindGroupLayouts.set("blur_compute", { "label": "blur_compute_bind_group_layout" }); + } + + getPipeline (name: string): any + { + return this.pipelines.get(name); + } + + getBindGroupLayout (name: string): any + { + return this.bindGroupLayouts.get(name); + } + + destroy (): void + { + this.pipelines.clear(); + this.bindGroupLayouts.clear(); + } + } + + const createMockDevice = (): GPUDevice => + { + return { + "createShaderModule": vi.fn(() => ({ "label": "mockShaderModule" })), + "createBindGroupLayout": vi.fn(() => ({ "label": "mockBindGroupLayout" })), + "createPipelineLayout": vi.fn(() => ({ "label": "mockPipelineLayout" })), + "createComputePipeline": vi.fn(() => ({ "label": "mockComputePipeline" })) + } as unknown as GPUDevice; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + }); + + describe("constructor", () => + { + it("should create instance with device", () => + { + const device = createMockDevice(); + const manager = new MockComputePipelineManager(device); + + expect(manager).toBeDefined(); + }); + + it("should initialize blur pipelines", () => + { + const device = createMockDevice(); + const manager = new MockComputePipelineManager(device); + + expect(manager.getPipeline("blur_compute_horizontal")).toBeDefined(); + expect(manager.getPipeline("blur_compute_vertical")).toBeDefined(); + }); + }); + + describe("getPipeline", () => + { + it("should return horizontal blur pipeline", () => + { + const device = createMockDevice(); + const manager = new MockComputePipelineManager(device); + + const pipeline = manager.getPipeline("blur_compute_horizontal"); + + expect(pipeline).toBeDefined(); + expect(pipeline.label).toBe("blur_compute_horizontal"); + }); + + it("should return vertical blur pipeline", () => + { + const device = createMockDevice(); + const manager = new MockComputePipelineManager(device); + + const pipeline = manager.getPipeline("blur_compute_vertical"); + + expect(pipeline).toBeDefined(); + expect(pipeline.label).toBe("blur_compute_vertical"); + }); + + it("should return undefined for non-existent pipeline", () => + { + const device = createMockDevice(); + const manager = new MockComputePipelineManager(device); + + expect(manager.getPipeline("nonexistent")).toBeUndefined(); + }); + }); + + describe("getBindGroupLayout", () => + { + it("should return blur compute bind group layout", () => + { + const device = createMockDevice(); + const manager = new MockComputePipelineManager(device); + + const layout = manager.getBindGroupLayout("blur_compute"); + + expect(layout).toBeDefined(); + }); + + it("should return undefined for non-existent layout", () => + { + const device = createMockDevice(); + const manager = new MockComputePipelineManager(device); + + expect(manager.getBindGroupLayout("nonexistent")).toBeUndefined(); + }); + }); + + describe("destroy", () => + { + it("should clear pipelines", () => + { + const device = createMockDevice(); + const manager = new MockComputePipelineManager(device); + + manager.destroy(); + + expect(manager.getPipeline("blur_compute_horizontal")).toBeUndefined(); + expect(manager.getPipeline("blur_compute_vertical")).toBeUndefined(); + }); + + it("should clear bind group layouts", () => + { + const device = createMockDevice(); + const manager = new MockComputePipelineManager(device); + + manager.destroy(); + + expect(manager.getBindGroupLayout("blur_compute")).toBeUndefined(); + }); + }); +}); diff --git a/packages/webgpu/src/Compute/ComputePipelineManager.ts b/packages/webgpu/src/Compute/ComputePipelineManager.ts new file mode 100644 index 00000000..46a873d9 --- /dev/null +++ b/packages/webgpu/src/Compute/ComputePipelineManager.ts @@ -0,0 +1,322 @@ +/** + * @description Compute Pipeline Manager + * Compute Shaderパイプラインの管理 + * + * Compute Shaderは並列処理に最適で、フィルター処理を高速化。 + * 特に大きなブラー半径(64+)の場合、20-35%の高速化が期待できる。 + * + * @class + */ +export class ComputePipelineManager +{ + private device: GPUDevice; + private pipelines: Map; + private bindGroupLayouts: Map; + + /** + * @constructor + * @param {GPUDevice} device - WebGPU device + */ + constructor (device: GPUDevice) + { + this.device = device; + this.pipelines = new Map(); + this.bindGroupLayouts = new Map(); + + this.initializeBlurPipelines(); + } + + /** + * @description ブラー用Compute Pipelineを初期化 + * @private + */ + private initializeBlurPipelines (): void + { + // ブラーCompute Shader用のBindGroupLayoutを作成 + const blurBindGroupLayout = this.device.createBindGroupLayout({ + "label": "blur_compute_bind_group_layout", + "entries": [ + { + // 入力テクスチャ + "binding": 0, + "visibility": GPUShaderStage.COMPUTE, + "texture": { + "sampleType": "float" + } + }, + { + // 出力テクスチャ(Storage Texture) + "binding": 1, + "visibility": GPUShaderStage.COMPUTE, + "storageTexture": { + "access": "write-only", + "format": "rgba8unorm" + } + }, + { + // パラメータ(方向、ブラー半径など) + "binding": 2, + "visibility": GPUShaderStage.COMPUTE, + "buffer": { + "type": "uniform" + } + } + ] + }); + + this.bindGroupLayouts.set("blur_compute", blurBindGroupLayout); + + // 水平/垂直ブラーパイプラインを作成 + // 同じシェーダーを使用し、方向はuniformで制御 + this.createBlurComputePipeline("blur_compute_horizontal"); + this.createBlurComputePipeline("blur_compute_vertical"); + + // 共有メモリ版(大半径用) + this.createBlurComputePipeline("blur_compute_shared_horizontal", true); + this.createBlurComputePipeline("blur_compute_shared_vertical", true); + } + + /** + * @description ブラーCompute Pipelineを作成 + * @param {string} name - パイプライン名 + * @private + */ + private createBlurComputePipeline (name: string, useSharedMemory: boolean = false): void + { + const shaderModule = this.device.createShaderModule({ + "label": `${name}_shader`, + "code": useSharedMemory ? this.getSharedBlurComputeShaderCode() : this.getBlurComputeShaderCode() + }); + + const pipelineLayout = this.device.createPipelineLayout({ + "label": `${name}_layout`, + "bindGroupLayouts": [this.bindGroupLayouts.get("blur_compute")!] + }); + + const pipeline = this.device.createComputePipeline({ + "label": name, + "layout": pipelineLayout, + "compute": { + "module": shaderModule, + "entryPoint": "main" + } + }); + + this.pipelines.set(name, pipeline); + } + + /** + * @description ブラーCompute Shaderコードを生成 + * ボックスブラー(均一加重平均)を使用。Fragment Shaderと同一出力。 + * @return {string} WGSLシェーダーコード + * @private + */ + private getBlurComputeShaderCode (): string + { + return ` +struct BlurParams { + direction: vec2, // (1,0) or (0,1) + radius: f32, // ブラー半径 + fraction: f32, // 端ピクセルのブレンド割合 + texSize: vec2, // テクスチャサイズ + samples: f32, // サンプル数 + padding: f32, // パディング(16バイトアライメント) +} + +@group(0) @binding(0) var inputTexture: texture_2d; +@group(0) @binding(1) var outputTexture: texture_storage_2d; +@group(0) @binding(2) var params: BlurParams; + +const WORKGROUP_SIZE: u32 = 16u; + +@compute @workgroup_size(16, 16, 1) +fn main( + @builtin(global_invocation_id) globalId: vec3 +) { + let texSize = vec2(u32(params.texSize.x), u32(params.texSize.y)); + let radius = i32(params.radius); + + let outCoord = globalId.xy; + + if (outCoord.x >= texSize.x || outCoord.y >= texSize.y) { + return; + } + + let direction = vec2(i32(params.direction.x), i32(params.direction.y)); + let samples = params.samples; + let fraction = params.fraction; + + var color = vec4(0.0); + + for (var i = -radius; i <= radius; i = i + 1) { + var sampleCoord = vec2(outCoord) + direction * i; + + sampleCoord.x = clamp(sampleCoord.x, 0, i32(texSize.x) - 1); + sampleCoord.y = clamp(sampleCoord.y, 0, i32(texSize.y) - 1); + + let sample = textureLoad(inputTexture, vec2(sampleCoord), 0); + + // 端ピクセルにfraction重みを適用(Fragment Shaderと同じロジック) + if (i == -radius || i == radius) { + color = color + sample * fraction; + } else { + color = color + sample; + } + } + + color = color / samples; + + textureStore(outputTexture, outCoord, color); +} +`; + } + + /** + * @description 共有メモリ版ブラーCompute Shaderコードを生成 + * ワークグループ共有メモリでテクスチャ読み込みの重複を排除。 + * radius >= 8 で通常版より高速。 + * @return {string} WGSLシェーダーコード + * @private + */ + private getSharedBlurComputeShaderCode (): string + { + return ` +struct BlurParams { + direction: vec2, + radius: f32, + fraction: f32, + texSize: vec2, + samples: f32, + padding: f32, +} + +@group(0) @binding(0) var inputTexture: texture_2d; +@group(0) @binding(1) var outputTexture: texture_storage_2d; +@group(0) @binding(2) var params: BlurParams; + +const TILE: u32 = 16u; +const MAX_APRON: u32 = 24u; +const SHARED_W: u32 = TILE + 2u * MAX_APRON; + +var tile: array, ${(16 + 2 * 24) * 16}>; + +@compute @workgroup_size(16, 16, 1) +fn main( + @builtin(global_invocation_id) globalId: vec3, + @builtin(local_invocation_id) localId: vec3, + @builtin(workgroup_id) workgroupId: vec3 +) { + let texSize = vec2(u32(params.texSize.x), u32(params.texSize.y)); + let radius = u32(params.radius); + let apron = min(radius, MAX_APRON); + let isHorizontal = params.direction.x > 0.5; + let fraction = params.fraction; + let samples = params.samples; + + let threadIdx = localId.x + localId.y * TILE; + let totalThreads = TILE * TILE; + + if (isHorizontal) { + let sharedWidth = TILE + 2u * apron; + let baseX = workgroupId.x * TILE; + let y = globalId.y; + let clampedY = clamp(y, 0u, max(texSize.y, 1u) - 1u); + + // 全スレッドが協調ロード(範囲外スレッドもclampされた座標でロード) + var idx = threadIdx; + loop { + if (idx >= sharedWidth) { break; } + let gx = i32(baseX) + i32(idx) - i32(apron); + let cx = u32(clamp(gx, 0, i32(max(texSize.x, 1u)) - 1)); + tile[localId.y * SHARED_W + idx] = textureLoad(inputTexture, vec2(cx, clampedY), 0); + idx += totalThreads; + } + + // 全スレッドがバリアに到達(早期returnなし) + workgroupBarrier(); + + // 範囲内のスレッドのみ出力 + let outX = globalId.x; + if (outX < texSize.x && y < texSize.y) { + let iRadius = i32(radius); + var color = vec4(0.0); + for (var i = -iRadius; i <= iRadius; i = i + 1) { + let tileIdx = i32(localId.x) + i32(apron) + i; + let s = tile[localId.y * SHARED_W + u32(clamp(tileIdx, 0, i32(sharedWidth) - 1))]; + if (i == -iRadius || i == iRadius) { + color += s * fraction; + } else { + color += s; + } + } + textureStore(outputTexture, vec2(outX, y), color / samples); + } + } else { + let sharedHeight = TILE + 2u * apron; + let baseY = workgroupId.y * TILE; + let x = globalId.x; + let clampedX = clamp(x, 0u, max(texSize.x, 1u) - 1u); + + // 全スレッドが協調ロード(範囲外スレッドもclampされた座標でロード) + var idx = threadIdx; + loop { + if (idx >= sharedHeight) { break; } + let gy = i32(baseY) + i32(idx) - i32(apron); + let cy = u32(clamp(gy, 0, i32(max(texSize.y, 1u)) - 1)); + tile[idx * TILE + localId.x] = textureLoad(inputTexture, vec2(clampedX, cy), 0); + idx += totalThreads; + } + + // 全スレッドがバリアに到達(早期returnなし) + workgroupBarrier(); + + // 範囲内のスレッドのみ出力 + let outY = globalId.y; + if (x < texSize.x && outY < texSize.y) { + let iRadius = i32(radius); + var color = vec4(0.0); + for (var i = -iRadius; i <= iRadius; i = i + 1) { + let tileIdx = i32(localId.y) + i32(apron) + i; + let s = tile[u32(clamp(tileIdx, 0, i32(sharedHeight) - 1)) * TILE + localId.x]; + if (i == -iRadius || i == iRadius) { + color += s * fraction; + } else { + color += s; + } + } + textureStore(outputTexture, vec2(x, outY), color / samples); + } + } +} +`; + } + + /** + * @description パイプラインを取得 + * @param {string} name - パイプライン名 + * @return {GPUComputePipeline | undefined} + */ + getPipeline (name: string): GPUComputePipeline | undefined + { + return this.pipelines.get(name); + } + + /** + * @description BindGroupLayoutを取得 + * @param {string} name - レイアウト名 + * @return {GPUBindGroupLayout | undefined} + */ + getBindGroupLayout (name: string): GPUBindGroupLayout | undefined + { + return this.bindGroupLayouts.get(name); + } + + /** + * @description リソースを破棄 + */ + destroy (): void + { + this.pipelines.clear(); + this.bindGroupLayouts.clear(); + } +} diff --git a/packages/webgpu/src/Compute/service/ComputeExecuteBlurService.test.ts b/packages/webgpu/src/Compute/service/ComputeExecuteBlurService.test.ts new file mode 100644 index 00000000..dd859f05 --- /dev/null +++ b/packages/webgpu/src/Compute/service/ComputeExecuteBlurService.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { ComputePipelineManager } from "../ComputePipelineManager"; +import { execute } from "./ComputeExecuteBlurService"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + UNIFORM: 0x0040, + COPY_DST: 0x0008 +}; +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +describe("ComputeExecuteBlurService", () => +{ + const createMockDevice = () => + { + const mockBuffer = { "label": "mockBuffer" }; + return { + "createBuffer": vi.fn(() => mockBuffer), + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })), + "queue": { + "writeBuffer": vi.fn() + } + } as unknown as GPUDevice; + }; + + const createMockCommandEncoder = () => + { + const mockComputePass = { + "setPipeline": vi.fn(), + "setBindGroup": vi.fn(), + "dispatchWorkgroups": vi.fn(), + "end": vi.fn() + }; + return { + "beginComputePass": vi.fn(() => mockComputePass), + "_mockComputePass": mockComputePass + } as unknown as GPUCommandEncoder & { _mockComputePass: any }; + }; + + const createMockComputePipelineManager = (hasPipeline: boolean = true) => + { + return { + "getPipeline": vi.fn(() => hasPipeline ? { "label": "mockPipeline" } : null), + "getBindGroupLayout": vi.fn(() => hasPipeline ? { "label": "mockLayout" } : null) + } as unknown as ComputePipelineManager; + }; + + const createMockAttachment = (width: number, height: number): IAttachmentObject => + { + return { + "id": 1, + "width": width, + "height": height, + "clipLevel": 0, + "msaa": false, + "mask": false, + "color": null, + "texture": { + "id": 1, + "width": width, + "height": height, + "area": width * height, + "smooth": true, + "resource": {} as GPUTexture, + "view": { "label": "mockView" } as unknown as GPUTextureView + }, + "stencil": null, + "msaaTexture": null, + "msaaStencil": null + }; + }; + + beforeEach(() => + { + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("pipeline selection", () => + { + it("should use horizontal pipeline when isHorizontal is true", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const pipelineManager = createMockComputePipelineManager(); + const source = createMockAttachment(256, 256); + const dest = createMockAttachment(256, 256); + + execute(device, commandEncoder, pipelineManager, source, dest, true, 8); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("blur_compute_horizontal"); + }); + + it("should use vertical pipeline when isHorizontal is false", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const pipelineManager = createMockComputePipelineManager(); + const source = createMockAttachment(256, 256); + const dest = createMockAttachment(256, 256); + + execute(device, commandEncoder, pipelineManager, source, dest, false, 8); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("blur_compute_vertical"); + }); + + it("should return early when pipeline not found", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const pipelineManager = createMockComputePipelineManager(false); + const source = createMockAttachment(256, 256); + const dest = createMockAttachment(256, 256); + + execute(device, commandEncoder, pipelineManager, source, dest, true, 8); + + expect(commandEncoder.beginComputePass).not.toHaveBeenCalled(); + }); + }); + + describe("parameter buffer", () => + { + it("should create uniform buffer with correct usage", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const pipelineManager = createMockComputePipelineManager(); + const source = createMockAttachment(256, 256); + const dest = createMockAttachment(256, 256); + + execute(device, commandEncoder, pipelineManager, source, dest, true, 8); + + expect(device.createBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }) + ); + }); + + it("should write parameter data to buffer", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const pipelineManager = createMockComputePipelineManager(); + const source = createMockAttachment(256, 256); + const dest = createMockAttachment(256, 256); + + execute(device, commandEncoder, pipelineManager, source, dest, true, 8); + + expect(device.queue.writeBuffer).toHaveBeenCalled(); + }); + + it("should set horizontal direction vector when isHorizontal is true", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const pipelineManager = createMockComputePipelineManager(); + const source = createMockAttachment(256, 256); + const dest = createMockAttachment(256, 256); + + execute(device, commandEncoder, pipelineManager, source, dest, true, 8); + + // Check the params passed to writeBuffer + const writeBufferCall = (device.queue.writeBuffer as ReturnType).mock.calls[0]; + const params = writeBufferCall[2] as Float32Array; + expect(params[0]).toBe(1.0); // direction.x + expect(params[1]).toBe(0.0); // direction.y + }); + + it("should set vertical direction vector when isHorizontal is false", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const pipelineManager = createMockComputePipelineManager(); + const source = createMockAttachment(256, 256); + const dest = createMockAttachment(256, 256); + + execute(device, commandEncoder, pipelineManager, source, dest, false, 8); + + const writeBufferCall = (device.queue.writeBuffer as ReturnType).mock.calls[0]; + const params = writeBufferCall[2] as Float32Array; + expect(params[0]).toBe(0.0); // direction.x + expect(params[1]).toBe(1.0); // direction.y + }); + }); + + describe("bind group", () => + { + it("should create bind group with correct layout", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const pipelineManager = createMockComputePipelineManager(); + const source = createMockAttachment(256, 256); + const dest = createMockAttachment(256, 256); + + execute(device, commandEncoder, pipelineManager, source, dest, true, 8); + + expect(pipelineManager.getBindGroupLayout).toHaveBeenCalledWith("blur_compute"); + }); + + it("should create bind group with source and dest textures", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const pipelineManager = createMockComputePipelineManager(); + const source = createMockAttachment(256, 256); + const dest = createMockAttachment(256, 256); + + execute(device, commandEncoder, pipelineManager, source, dest, true, 8); + + expect(device.createBindGroup).toHaveBeenCalled(); + }); + }); + + describe("compute pass", () => + { + it("should begin compute pass with correct label for horizontal", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const pipelineManager = createMockComputePipelineManager(); + const source = createMockAttachment(256, 256); + const dest = createMockAttachment(256, 256); + + execute(device, commandEncoder, pipelineManager, source, dest, true, 8); + + expect(commandEncoder.beginComputePass).toHaveBeenCalledWith({ + "label": "blur_compute_pass_h" + }); + }); + + it("should begin compute pass with correct label for vertical", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const pipelineManager = createMockComputePipelineManager(); + const source = createMockAttachment(256, 256); + const dest = createMockAttachment(256, 256); + + execute(device, commandEncoder, pipelineManager, source, dest, false, 8); + + expect(commandEncoder.beginComputePass).toHaveBeenCalledWith({ + "label": "blur_compute_pass_v" + }); + }); + + it("should set pipeline", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const pipelineManager = createMockComputePipelineManager(); + const source = createMockAttachment(256, 256); + const dest = createMockAttachment(256, 256); + + execute(device, commandEncoder, pipelineManager, source, dest, true, 8); + + expect(commandEncoder._mockComputePass.setPipeline).toHaveBeenCalled(); + }); + + it("should set bind group", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const pipelineManager = createMockComputePipelineManager(); + const source = createMockAttachment(256, 256); + const dest = createMockAttachment(256, 256); + + execute(device, commandEncoder, pipelineManager, source, dest, true, 8); + + expect(commandEncoder._mockComputePass.setBindGroup).toHaveBeenCalledWith(0, expect.anything()); + }); + + it("should end compute pass", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const pipelineManager = createMockComputePipelineManager(); + const source = createMockAttachment(256, 256); + const dest = createMockAttachment(256, 256); + + execute(device, commandEncoder, pipelineManager, source, dest, true, 8); + + expect(commandEncoder._mockComputePass.end).toHaveBeenCalled(); + }); + }); + + describe("workgroup dispatch", () => + { + it("should calculate correct workgroup count for 256x256", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const pipelineManager = createMockComputePipelineManager(); + const source = createMockAttachment(256, 256); + const dest = createMockAttachment(256, 256); + + execute(device, commandEncoder, pipelineManager, source, dest, true, 8); + + // 256 / 16 = 16 workgroups in each dimension + expect(commandEncoder._mockComputePass.dispatchWorkgroups).toHaveBeenCalledWith(16, 16, 1); + }); + + it("should round up workgroup count for non-aligned size", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const pipelineManager = createMockComputePipelineManager(); + const source = createMockAttachment(100, 100); + const dest = createMockAttachment(100, 100); + + execute(device, commandEncoder, pipelineManager, source, dest, true, 8); + + // ceil(100 / 16) = 7 workgroups in each dimension + expect(commandEncoder._mockComputePass.dispatchWorkgroups).toHaveBeenCalledWith(7, 7, 1); + }); + }); +}); diff --git a/packages/webgpu/src/Compute/service/ComputeExecuteBlurService.ts b/packages/webgpu/src/Compute/service/ComputeExecuteBlurService.ts new file mode 100644 index 00000000..1026baa3 --- /dev/null +++ b/packages/webgpu/src/Compute/service/ComputeExecuteBlurService.ts @@ -0,0 +1,106 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { ComputePipelineManager } from "../ComputePipelineManager"; + +/** + * @description プリアロケートされたFloat32Array (サイズ8) + */ +const $params8 = new Float32Array(8); + +/** + * @description プリアロケートされたBindGroupEntry配列 (バインディング3つ) + */ +const $computeEntries3: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": null as unknown as GPUTextureView }, + { "binding": 1, "resource": null as unknown as GPUTextureView }, + { "binding": 2, "resource": { "buffer": null as unknown as GPUBuffer } } +]; + +/** + * @description プリアロケートされたComputePassDescriptor + */ +const $labelH: GPUComputePassDescriptor = { "label": "blur_compute_pass_h" }; +const $labelV: GPUComputePassDescriptor = { "label": "blur_compute_pass_v" }; + +/** + * @description Compute Shaderでブラーを実行(ボックスブラー) + * Execute box blur using Compute Shader + * + * Fragment Shaderと同一のボックスブラーアルゴリズムを使用。 + * + * @param {GPUDevice} device - WebGPU device + * @param {GPUCommandEncoder} commandEncoder - コマンドエンコーダー + * @param {ComputePipelineManager} computePipelineManager - Compute Pipeline Manager + * @param {IAttachmentObject} source - 入力アタッチメント + * @param {IAttachmentObject} dest - 出力アタッチメント + * @param {boolean} isHorizontal - 水平ブラーかどうか + * @param {number} blur - ブラー量(bufferBlurX/Y相当) + * @param {object} [bufferManager] - バッファマネージャー(プール化用) + * @return {void} + */ +export const execute = ( + device: GPUDevice, + commandEncoder: GPUCommandEncoder, + computePipelineManager: ComputePipelineManager, + source: IAttachmentObject, + dest: IAttachmentObject, + isHorizontal: boolean, + blur: number, + bufferManager?: { acquireAndWriteUniformBuffer(data: Float32Array, byteLength?: number): GPUBuffer } +): void => { + + // radius 8~24 の場合は共有メモリ版を使用(MAX_APRON=24の制限) + const halfBlur = Math.ceil(blur * 0.5); + const useShared = halfBlur >= 8 && halfBlur <= 24; + const pipelineName = useShared + ? isHorizontal ? "blur_compute_shared_horizontal" : "blur_compute_shared_vertical" + : isHorizontal ? "blur_compute_horizontal" : "blur_compute_vertical"; + const pipeline = computePipelineManager.getPipeline(pipelineName); + const bindGroupLayout = computePipelineManager.getBindGroupLayout("blur_compute"); + + if (!pipeline || !bindGroupLayout) { + return; + } + + // ボックスブラーパラメータ(Fragment ShaderのcalculateDirectionalBlurParamsと同一ロジック) + const fraction = 1 - (halfBlur - blur * 0.5); + const samples = 1 + blur; + + $params8[0] = isHorizontal ? 1.0 : 0.0; // direction.x + $params8[1] = isHorizontal ? 0.0 : 1.0; // direction.y + $params8[2] = halfBlur; // radius (halfBlur) + $params8[3] = fraction; // fraction + $params8[4] = source.width; // texSize.x + $params8[5] = source.height; // texSize.y + $params8[6] = samples; // samples + $params8[7] = 0.0; // padding + + const paramsBuffer = bufferManager + ? bufferManager.acquireAndWriteUniformBuffer($params8) + : (() => { + const buf = device.createBuffer({ + "size": $params8.byteLength, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + device.queue.writeBuffer(buf, 0, $params8); + return buf; + })(); + + $computeEntries3[0].resource = source.texture!.view; + $computeEntries3[1].resource = dest.texture!.view; + ($computeEntries3[2].resource as GPUBufferBinding).buffer = paramsBuffer; + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $computeEntries3 + }); + + const computePass = commandEncoder.beginComputePass(isHorizontal ? $labelH : $labelV); + + computePass.setPipeline(pipeline); + computePass.setBindGroup(0, bindGroup); + + const workgroupsX = Math.ceil(dest.width / 16); + const workgroupsY = Math.ceil(dest.height / 16); + + computePass.dispatchWorkgroups(workgroupsX, workgroupsY, 1); + computePass.end(); +}; diff --git a/packages/webgpu/src/Context.test.ts b/packages/webgpu/src/Context.test.ts new file mode 100644 index 00000000..cf0ec284 --- /dev/null +++ b/packages/webgpu/src/Context.test.ts @@ -0,0 +1,597 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Context } from "./Context"; + +// Mock WebGPU globals +const mockTexture = { + createView: vi.fn().mockReturnValue({}), + width: 1024, + height: 768, + destroy: vi.fn() +}; + +const mockBuffer = { + destroy: vi.fn(), + mapAsync: vi.fn().mockResolvedValue(undefined), + getMappedRange: vi.fn().mockReturnValue(new ArrayBuffer(256)), + unmap: vi.fn() +}; + +const mockRenderPassEncoder = { + setPipeline: vi.fn(), + setVertexBuffer: vi.fn(), + setBindGroup: vi.fn(), + draw: vi.fn(), + drawIndexed: vi.fn(), + end: vi.fn(), + setStencilReference: vi.fn(), + setScissorRect: vi.fn(), + setViewport: vi.fn(), + executeBundles: vi.fn() +}; + +const mockCommandEncoder = { + beginRenderPass: vi.fn().mockReturnValue(mockRenderPassEncoder), + copyTextureToTexture: vi.fn(), + copyBufferToBuffer: vi.fn(), + finish: vi.fn().mockReturnValue({}) +}; + +const mockQueue = { + submit: vi.fn(), + writeBuffer: vi.fn(), + writeTexture: vi.fn() +}; + +const mockDevice = { + createTexture: vi.fn().mockReturnValue(mockTexture), + createBuffer: vi.fn().mockReturnValue(mockBuffer), + createCommandEncoder: vi.fn().mockReturnValue(mockCommandEncoder), + createRenderPipeline: vi.fn().mockReturnValue({}), + createBindGroup: vi.fn().mockReturnValue({}), + createBindGroupLayout: vi.fn().mockReturnValue({}), + createPipelineLayout: vi.fn().mockReturnValue({}), + createSampler: vi.fn().mockReturnValue({}), + createShaderModule: vi.fn().mockReturnValue({}), + createComputePipeline: vi.fn().mockReturnValue({}), + createQuerySet: vi.fn().mockReturnValue({ + destroy: vi.fn() + }), + queue: mockQueue, + features: new Set(["timestamp-query"]), + limits: { + maxTextureDimension2D: 8192 + }, + destroy: vi.fn() +}; + +const mockCanvasContext = { + configure: vi.fn(), + getCurrentTexture: vi.fn().mockReturnValue(mockTexture), + canvas: { + width: 1024, + height: 768 + } +}; + +// Mock GPU constants +vi.stubGlobal("GPUBufferUsage", { + VERTEX: 32, + INDEX: 16, + UNIFORM: 64, + STORAGE: 128, + COPY_DST: 8, + COPY_SRC: 4, + MAP_READ: 1 +}); + +vi.stubGlobal("GPUTextureUsage", { + RENDER_ATTACHMENT: 16, + TEXTURE_BINDING: 4, + COPY_SRC: 1, + COPY_DST: 2, + STORAGE_BINDING: 8 +}); + +vi.stubGlobal("GPUShaderStage", { + VERTEX: 1, + FRAGMENT: 2, + COMPUTE: 4 +}); + +vi.stubGlobal("GPUColorWrite", { + RED: 1, + GREEN: 2, + BLUE: 4, + ALPHA: 8, + ALL: 15 +}); + +vi.stubGlobal("GPUMapMode", { + READ: 1, + WRITE: 2 +}); + +describe("Context", () => +{ + let context: Context; + + beforeEach(() => + { + vi.clearAllMocks(); + context = new Context( + mockDevice as unknown as GPUDevice, + mockCanvasContext as unknown as GPUCanvasContext, + "bgra8unorm" as GPUTextureFormat + ); + }); + + describe("constructor", () => + { + it("should initialize with default values", () => + { + expect(context).toBeDefined(); + expect(context.$stack).toBeInstanceOf(Array); + expect(context.$matrix).toBeInstanceOf(Float32Array); + }); + + it("should initialize matrix with identity (3x3)", () => + { + // 3x3 matrix: [1, 0, 0, 0, 1, 0, 0, 0, 1] + expect(context.$matrix[0]).toBe(1); // [0][0] + expect(context.$matrix[1]).toBe(0); // [0][1] + expect(context.$matrix[2]).toBe(0); // [0][2] + expect(context.$matrix[3]).toBe(0); // [1][0] + expect(context.$matrix[4]).toBe(1); // [1][1] + expect(context.$matrix[5]).toBe(0); // [1][2] + expect(context.$matrix[6]).toBe(0); // [2][0] + expect(context.$matrix[7]).toBe(0); // [2][1] + expect(context.$matrix[8]).toBe(1); // [2][2] + }); + + it("should initialize clear color to black transparent", () => + { + expect(context.$clearColorR).toBe(0); + expect(context.$clearColorG).toBe(0); + expect(context.$clearColorB).toBe(0); + expect(context.$clearColorA).toBe(0); + }); + + it("should initialize globalAlpha to 1", () => + { + expect(context.globalAlpha).toBe(1); + }); + + it("should initialize globalCompositeOperation to normal", () => + { + expect(context.globalCompositeOperation).toBe("normal"); + }); + + it("should initialize imageSmoothingEnabled to false", () => + { + expect(context.imageSmoothingEnabled).toBe(false); + }); + + it("should initialize stroke properties", () => + { + expect(context.thickness).toBe(1); + expect(context.caps).toBe(0); // none + expect(context.joints).toBe(2); // miter + expect(context.miterLimit).toBe(0); + }); + + it("should initialize fill and stroke styles", () => + { + expect(context.$fillStyle).toBeInstanceOf(Float32Array); + expect(context.$strokeStyle).toBeInstanceOf(Float32Array); + }); + + it("should initialize mask bounds", () => + { + expect(context.maskBounds).toBeDefined(); + expect(context.maskBounds.xMin).toBe(0); + expect(context.maskBounds.yMin).toBe(0); + expect(context.maskBounds.xMax).toBe(0); + expect(context.maskBounds.yMax).toBe(0); + }); + }); + + describe("setTransform", () => + { + it("should update matrix values (3x3 format)", () => + { + context.setTransform(2, 0.5, -0.5, 2, 100, 200); + + // 3x3 matrix layout: [a, b, 0, c, d, 0, e, f, 1] + expect(context.$matrix[0]).toBe(2); // a (scale x) + expect(context.$matrix[1]).toBe(0.5); // b (skew y) + expect(context.$matrix[3]).toBe(-0.5); // c (skew x) + expect(context.$matrix[4]).toBe(2); // d (scale y) + expect(context.$matrix[6]).toBe(100); // e (translate x) + expect(context.$matrix[7]).toBe(200); // f (translate y) + }); + }); + + describe("clearRect", () => + { + it("should be a no-op in WebGPU (clear happens at render pass start)", () => + { + // clearRect does nothing in WebGPU - clear is done at render pass start + expect(() => context.clearRect(0, 0, 100, 100)).not.toThrow(); + }); + }); + + describe("fillStyle", () => + { + it("should update fill style when set", () => + { + context.$fillStyle = new Float32Array([1, 0, 0, 1]); + + expect(context.$fillStyle[0]).toBe(1); + expect(context.$fillStyle[1]).toBe(0); + expect(context.$fillStyle[2]).toBe(0); + expect(context.$fillStyle[3]).toBe(1); + }); + }); + + describe("strokeStyle", () => + { + it("should update stroke style when set", () => + { + context.$strokeStyle = new Float32Array([0, 1, 0, 1]); + + expect(context.$strokeStyle[0]).toBe(0); + expect(context.$strokeStyle[1]).toBe(1); + expect(context.$strokeStyle[2]).toBe(0); + expect(context.$strokeStyle[3]).toBe(1); + }); + }); + + describe("save and restore", () => + { + it("should save current matrix to stack", () => + { + context.setTransform(2, 0, 0, 2, 10, 20); + context.save(); + + expect(context.$stack.length).toBe(1); + expect(context.$stack[0][0]).toBe(2); // a + expect(context.$stack[0][6]).toBe(10); // e (translate x in 3x3) + }); + + it("should restore matrix from stack", () => + { + context.setTransform(2, 0, 0, 2, 10, 20); + context.save(); + context.setTransform(1, 0, 0, 1, 0, 0); + context.restore(); + + expect(context.$matrix[0]).toBe(2); + expect(context.$matrix[6]).toBe(10); // translate x in 3x3 + expect(context.$stack.length).toBe(0); + }); + + it("should handle multiple save/restore cycles", () => + { + context.setTransform(1, 0, 0, 1, 0, 0); + context.save(); + context.setTransform(2, 0, 0, 2, 0, 0); + context.save(); + context.setTransform(3, 0, 0, 3, 0, 0); + + expect(context.$matrix[0]).toBe(3); + expect(context.$stack.length).toBe(2); + + context.restore(); + expect(context.$matrix[0]).toBe(2); + expect(context.$stack.length).toBe(1); + + context.restore(); + expect(context.$matrix[0]).toBe(1); + expect(context.$stack.length).toBe(0); + }); + }); + + describe("attachment objects", () => + { + it("should return null for currentAttachmentObject when stack is empty", () => + { + expect(context.currentAttachmentObject).toBeNull(); + }); + + it("should return atlas attachment object from frame buffer manager", () => + { + // Atlas attachment is created by FrameBufferManager during initialization + const atlas = context.atlasAttachmentObject; + expect(atlas).toBeDefined(); + }); + }); + + describe("globalAlpha", () => + { + it("should accept values between 0 and 1", () => + { + context.globalAlpha = 0.5; + expect(context.globalAlpha).toBe(0.5); + + context.globalAlpha = 0; + expect(context.globalAlpha).toBe(0); + + context.globalAlpha = 1; + expect(context.globalAlpha).toBe(1); + }); + }); + + describe("globalCompositeOperation", () => + { + it("should accept valid blend modes", () => + { + const blendModes = [ + "normal", "add", "multiply", "screen", + "overlay", "hardlight", "darken", "lighten", + "difference", "subtract", "invert", "alpha", "erase" + ]; + + blendModes.forEach(mode => + { + context.globalCompositeOperation = mode as any; + expect(context.globalCompositeOperation).toBe(mode); + }); + }); + }); + + describe("thickness", () => + { + it("should set stroke thickness", () => + { + context.thickness = 5; + expect(context.thickness).toBe(5); + + context.thickness = 0.5; + expect(context.thickness).toBe(0.5); + }); + }); + + describe("caps", () => + { + it("should set line cap style", () => + { + context.caps = 0; // none + expect(context.caps).toBe(0); + + context.caps = 1; // round + expect(context.caps).toBe(1); + + context.caps = 2; // square + expect(context.caps).toBe(2); + }); + }); + + describe("joints", () => + { + it("should set line join style", () => + { + context.joints = 0; // round + expect(context.joints).toBe(0); + + context.joints = 1; // bevel + expect(context.joints).toBe(1); + + context.joints = 2; // miter + expect(context.joints).toBe(2); + }); + }); + + describe("miterLimit", () => + { + it("should set miter limit value", () => + { + context.miterLimit = 20; + expect(context.miterLimit).toBe(20); + + context.miterLimit = 1; + expect(context.miterLimit).toBe(1); + }); + }); + + describe("beginNodeRendering", () => + { + it("should set scissor with +1px extension for clearing (WebGL compatible)", () => + { + // Prepare mock attachment with stencil + const mockAttachment = { + "texture": { + "view": {}, + "resource": mockTexture + }, + "stencil": { + "view": {} + }, + "width": 4096, + "height": 4096, + "msaa": false + }; + + // Mock getAttachment to return atlas + vi.spyOn(context["frameBufferManager"], "getAttachment").mockReturnValue(mockAttachment); + vi.spyOn(context["frameBufferManager"], "createStencilRenderPassDescriptor").mockReturnValue({ + "colorAttachments": [{ "view": {}, "loadOp": "load", "storeOp": "store" }], + "depthStencilAttachment": { "view": {}, "stencilLoadOp": "clear", "stencilStoreOp": "store" } + } as unknown as GPURenderPassDescriptor); + + // Mock pipeline manager + vi.spyOn(context["pipelineManager"], "getPipeline").mockReturnValue({} as GPURenderPipeline); + + // Mock buffer manager + vi.spyOn(context["bufferManager"], "createVertexBuffer").mockReturnValue(mockBuffer as unknown as GPUBuffer); + + const mockNode = { "x": 100, "y": 200, "w": 50, "h": 30 }; + + // Begin frame first + context.beginFrame(); + + // Clear mocks to track calls during beginNodeRendering + mockRenderPassEncoder.setScissorRect.mockClear(); + + // Call beginNodeRendering + context.beginNodeRendering(mockNode as any); + + // Verify scissor was set with +1px extension for clearing (WebGL compatible) + // Scissor should be: (x, y, w+1, h+1) = (100, 200, 51, 31) + expect(mockRenderPassEncoder.setScissorRect).toHaveBeenCalledWith(100, 200, 51, 31); + + // Verify currentNodeScissor is stored for later reset + expect(context["currentNodeScissor"]).toEqual({ "x": 100, "y": 200, "w": 50, "h": 30 }); + }); + + it("should store node scissor info for clear reset", () => + { + const mockAttachment = { + "texture": { + "view": {}, + "resource": mockTexture + }, + "stencil": { + "view": {} + }, + "width": 4096, + "height": 4096, + "msaa": false + }; + + vi.spyOn(context["frameBufferManager"], "getAttachment").mockReturnValue(mockAttachment); + vi.spyOn(context["frameBufferManager"], "createStencilRenderPassDescriptor").mockReturnValue({ + "colorAttachments": [{ "view": {}, "loadOp": "load", "storeOp": "store" }], + "depthStencilAttachment": { "view": {}, "stencilLoadOp": "clear", "stencilStoreOp": "store" } + } as unknown as GPURenderPassDescriptor); + vi.spyOn(context["pipelineManager"], "getPipeline").mockReturnValue({} as GPURenderPipeline); + vi.spyOn(context["bufferManager"], "createVertexBuffer").mockReturnValue(mockBuffer as unknown as GPUBuffer); + + const mockNode = { "x": 0, "y": 0, "w": 100, "h": 100 }; + + context.beginFrame(); + context.beginNodeRendering(mockNode as any); + + // Verify node scissor is stored + expect(context["currentNodeScissor"]).toEqual({ "x": 0, "y": 0, "w": 100, "h": 100 }); + + // nodeAreaCleared should be false (lazy clear) + expect(context["nodeAreaCleared"]).toBe(false); + }); + }); + + describe("drawPixels", () => + { + it("should clear node area and end render pass before writing texture", () => + { + const mockAttachment = { + "texture": { + "view": {}, + "resource": mockTexture + }, + "stencil": { + "view": {} + }, + "width": 4096, + "height": 4096, + "msaa": false + }; + + vi.spyOn(context["frameBufferManager"], "getAttachment").mockReturnValue(mockAttachment); + vi.spyOn(context["frameBufferManager"], "createStencilRenderPassDescriptor").mockReturnValue({ + "colorAttachments": [{ "view": {}, "loadOp": "load", "storeOp": "store" }], + "depthStencilAttachment": { "view": {}, "stencilLoadOp": "clear", "stencilStoreOp": "store" } + } as unknown as GPURenderPassDescriptor); + vi.spyOn(context["pipelineManager"], "getPipeline").mockReturnValue({} as GPURenderPipeline); + vi.spyOn(context["bufferManager"], "createVertexBuffer").mockReturnValue(mockBuffer as unknown as GPUBuffer); + + const mockNode = { "x": 0, "y": 0, "w": 10, "h": 10 }; + const mockPixels = new Uint8Array(10 * 10 * 4); + + context.beginFrame(); + context.beginNodeRendering(mockNode as any); + + // Verify render pass is active and node area not cleared yet + expect(context["renderPassEncoder"]).not.toBeNull(); + expect(context["nodeAreaCleared"]).toBe(false); + + // Clear mocks + mockRenderPassEncoder.end.mockClear(); + mockRenderPassEncoder.draw.mockClear(); + mockQueue.submit.mockClear(); + mockQueue.writeTexture.mockClear(); + + // Call drawPixels + context.drawPixels(mockNode as any, mockPixels); + + // Verify node area was cleared (draw(6) for quad) + expect(mockRenderPassEncoder.draw).toHaveBeenCalledWith(6); + + // Verify render pass was ended after clearing + expect(mockRenderPassEncoder.end).toHaveBeenCalled(); + // submit is no longer called here — commandEncoder is reused by drawPixelsToMsaa + expect(mockQueue.writeTexture).toHaveBeenCalled(); + + // Verify render pass is now null + expect(context["renderPassEncoder"]).toBeNull(); + }); + }); + + describe("drawElement", () => + { + it("should clear node area and end render pass before copying external image", () => + { + const mockAttachment = { + "texture": { + "view": {}, + "resource": mockTexture + }, + "stencil": { + "view": {} + }, + "width": 4096, + "height": 4096, + "msaa": false + }; + + vi.spyOn(context["frameBufferManager"], "getAttachment").mockReturnValue(mockAttachment); + vi.spyOn(context["frameBufferManager"], "createStencilRenderPassDescriptor").mockReturnValue({ + "colorAttachments": [{ "view": {}, "loadOp": "load", "storeOp": "store" }], + "depthStencilAttachment": { "view": {}, "stencilLoadOp": "clear", "stencilStoreOp": "store" } + } as unknown as GPURenderPassDescriptor); + vi.spyOn(context["pipelineManager"], "getPipeline").mockReturnValue({} as GPURenderPipeline); + vi.spyOn(context["bufferManager"], "createVertexBuffer").mockReturnValue(mockBuffer as unknown as GPUBuffer); + + // Mock copyExternalImageToTexture + mockQueue.copyExternalImageToTexture = vi.fn(); + + const mockNode = { "x": 0, "y": 0, "w": 10, "h": 10 }; + const mockImageBitmap = { "width": 10, "height": 10 } as ImageBitmap; + + context.beginFrame(); + context.beginNodeRendering(mockNode as any); + + // Verify render pass is active and node area not cleared yet + expect(context["renderPassEncoder"]).not.toBeNull(); + expect(context["nodeAreaCleared"]).toBe(false); + + // Clear mocks + mockRenderPassEncoder.end.mockClear(); + mockRenderPassEncoder.draw.mockClear(); + mockQueue.submit.mockClear(); + + // Call drawElement + context.drawElement(mockNode as any, mockImageBitmap); + + // Verify node area was cleared (draw(6) for quad) + expect(mockRenderPassEncoder.draw).toHaveBeenCalledWith(6); + + // Verify render pass was ended after clearing + expect(mockRenderPassEncoder.end).toHaveBeenCalled(); + // submit is no longer called here — commandEncoder is reused by drawElementToMsaa/drawElementToTexture + expect(mockQueue.copyExternalImageToTexture).toHaveBeenCalled(); + + // Verify render pass is now null + expect(context["renderPassEncoder"]).toBeNull(); + }); + }); +}); diff --git a/packages/webgpu/src/Context.ts b/packages/webgpu/src/Context.ts new file mode 100644 index 00000000..62af5622 --- /dev/null +++ b/packages/webgpu/src/Context.ts @@ -0,0 +1,3039 @@ +import type { IAttachmentObject } from "./interface/IAttachmentObject"; +import type { IBlendMode } from "./interface/IBlendMode"; +import type { IBounds } from "./interface/IBounds"; +import type { Node } from "@next2d/texture-packer"; +import { TexturePacker } from "@next2d/texture-packer"; +import { $cacheStore } from "@next2d/cache"; +import { WebGPUUtil, $setContext } from "./WebGPUUtil"; +import { PathCommand } from "./PathCommand"; +import { BufferManager } from "./BufferManager"; +import { TextureManager } from "./TextureManager"; +import { FrameBufferManager } from "./FrameBufferManager"; +import { AttachmentManager } from "./AttachmentManager"; +import { PipelineManager } from "./Shader/PipelineManager"; +import { ComputePipelineManager } from "./Compute/ComputePipelineManager"; +import { + $rootNodes, + $resetAtlas, + $getActiveAtlasIndex, + $setActiveAtlasIndex, + $setAtlasCreator, + $getAtlasAttachmentObject, + $getAtlasAttachmentObjectByIndex +} from "./AtlasManager"; +import { addDisplayObjectToInstanceArray, getInstancedShaderManager } from "./Blend/BlendInstancedManager"; +import { execute as maskBeginMaskService } from "./Mask/service/MaskBeginMaskService"; +import { execute as maskSetMaskBoundsService } from "./Mask/service/MaskSetMaskBoundsService"; +import { execute as maskEndMaskService } from "./Mask/service/MaskEndMaskService"; +import { execute as maskLeaveMaskUseCase } from "./Mask/usecase/MaskLeaveMaskUseCase"; +import { + $isMaskTestEnabled, + $isMaskDrawing, + $getMaskStencilReference, + $resetMaskState +} from "./Mask"; +import { execute as meshFillGenerateUseCase } from "./Mesh/usecase/MeshFillGenerateUseCase"; +import { generateStrokeMesh } from "./Mesh/usecase/MeshStrokeGenerateUseCase"; +import { + $gridDataMap, + $fillBufferIndex, + $terminateGrid +} from "./Grid"; +import { + $setGradientLUTDevice, + $clearGradientAttachmentObjects, + $cleanupLUTCache, + $clearLUTCache +} from "./Gradient/GradientLUTCache"; +import { + $releaseFillTexture, + $acquireRenderTexture, + $releaseRenderTexture, + $clearFillTexturePool, + $getOrCreateView +} from "./FillTexturePool"; +import { + $setFilterGradientLUTDevice, + $clearFilterGradientAttachment +} from "./Filter/FilterGradientLUTCache"; + +// Context services +import { execute as contextFillWithStencilService } from "./Context/service/ContextFillWithStencilService"; +import { execute as contextFillWithStencilMainService } from "./Context/service/ContextFillWithStencilMainService"; +import { execute as contextFillSimpleService } from "./Context/service/ContextFillSimpleService"; + +// Context usecases +import { execute as contextGradientFillUseCase } from "./Context/usecase/ContextGradientFillUseCase"; +import { execute as contextBitmapFillUseCase } from "./Context/usecase/ContextBitmapFillUseCase"; +import { execute as contextGradientStrokeUseCase } from "./Context/usecase/ContextGradientStrokeUseCase"; +import { execute as contextBitmapStrokeUseCase } from "./Context/usecase/ContextBitmapStrokeUseCase"; +import { execute as contextClipUseCase } from "./Context/usecase/ContextClipUseCase"; +import { execute as contextDrawArraysInstancedUseCase } from "./Context/usecase/ContextDrawArraysInstancedUseCase"; +import { execute as contextDrawIndirectUseCase } from "./Context/usecase/ContextDrawIndirectUseCase"; +import { execute as contextProcessComplexBlendQueueUseCase } from "./Context/usecase/ContextProcessComplexBlendQueueUseCase"; +import { execute as contextApplyFilterUseCase } from "./Context/usecase/ContextApplyFilterUseCase"; +import { execute as contextContainerEndLayerUseCase } from "./Context/usecase/ContextContainerEndLayerUseCase"; + +/** + * @description スワップチェーン転送用のIdentity UV定数: scale=(1,1), offset=(0,0) + */ +const $IDENTITY_UV = new Float32Array([1.0, 1.0, 0.0, 0.0]); + +/** + * @description save()/restore()用の Float32Array プール + */ +const $matrixPool: Float32Array[] = []; + +/** + * @description leaveMask() 用フルスクリーンメッシュ定数 + */ +const $FULLSCREEN_MESH = new Float32Array([ + // Triangle 1: (0,0), (1,0), (0,1) + 0, 0, 0.5, 0.5, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, + 1, 0, 0.5, 0.5, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, + 0, 1, 0.5, 0.5, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, + // Triangle 2: (1,0), (1,1), (0,1) + 1, 0, 0.5, 0.5, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, + 1, 1, 0.5, 0.5, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, + 0, 1, 0.5, 0.5, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1 +]); + +/** + * @description clearNodeArea() 用クワッド頂点定数 + */ +const $QUAD_VERTICES = new Float32Array([ + 0, 0, // 左上 + 1, 0, // 右上 + 0, 1, // 左下 + 1, 0, // 右上 + 1, 1, // 右下 + 0, 1 // 左下 +]); + +/** + * @description containerDrawCachedFilter() 用 CT uniform プリアロケート + */ +const $ctUniform8 = new Float32Array(8); + +/** + * @description fill() 用 uniform プリアロケート (color + matrix = 16 floats = 64 bytes) + */ +const $fillUniform16 = new Float32Array(16); + +// present() 用 Static BindGroup キャッシュ +let $presentBindGroup: GPUBindGroup | null = null; +let $presentBindGroupView: GPUTextureView | null = null; +let $presentUniformBuffer: GPUBuffer | null = null; + +// present() 用 RenderPassDescriptor プリアロケート +const $presentClearValue: GPUColorDict = { "r": 0, "g": 0, "b": 0, "a": 0 }; +const $presentColorAttachment: GPURenderPassColorAttachment = { + "view": null as unknown as GPUTextureView, + "clearValue": $presentClearValue, + "loadOp": "clear" as GPULoadOp, + "storeOp": "store" as GPUStoreOp +}; +const $presentDescriptor: GPURenderPassDescriptor = { + "colorAttachments": [$presentColorAttachment] +}; + +// BindGroup entries 事前割り当て +const $entries3: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView } +]; + +// fillBackgroundColor() 用 RenderPassDescriptor プリアロケート +const $bgClearValue: GPUColorDict = { "r": 0, "g": 0, "b": 0, "a": 0 }; +const $bgColorAttachment: GPURenderPassColorAttachment = { + "view": null as unknown as GPUTextureView, + "clearValue": $bgClearValue, + "loadOp": "clear" as GPULoadOp, + "storeOp": "store" as GPUStoreOp, + "resolveTarget": undefined +}; +const $bgStencilAttachment: GPURenderPassDepthStencilAttachment = { + "view": null as unknown as GPUTextureView, + "stencilClearValue": 0, + "stencilLoadOp": "clear" as GPULoadOp, + "stencilStoreOp": "store" as GPUStoreOp +}; +const $bgDescriptor: GPURenderPassDescriptor = { + "colorAttachments": [$bgColorAttachment] +}; + +// MSAA描画用 RenderPassDescriptor プリアロケート +const $msaaColorAttachment: GPURenderPassColorAttachment = { + "view": null as unknown as GPUTextureView, + "resolveTarget": undefined, + "loadOp": "load" as GPULoadOp, + "storeOp": "store" as GPUStoreOp +}; +const $msaaStencilAttachment: GPURenderPassDepthStencilAttachment = { + "view": null as unknown as GPUTextureView, + "stencilLoadOp": "load" as GPULoadOp, + "stencilStoreOp": "store" as GPUStoreOp +}; +const $msaaDescriptor: GPURenderPassDescriptor = { + "colorAttachments": [$msaaColorAttachment] +}; + +/** + * @description WebGPU版、Next2Dのコンテキスト + * WebGPU version, Next2D context + * + * @class + */ +export class Context +{ + public readonly $stack: Float32Array[]; + public readonly $matrix: Float32Array; + public $clearColorR: number; + public $clearColorG: number; + public $clearColorB: number; + public $clearColorA: number; + public $mainAttachmentObject: IAttachmentObject | null; + public readonly $stackAttachmentObject: IAttachmentObject[]; + public globalAlpha: number; + public globalCompositeOperation: IBlendMode; + public imageSmoothingEnabled: boolean; + public $fillStyle: Float32Array; + public $strokeStyle: Float32Array; + public readonly maskBounds: IBounds; + public thickness: number; + public caps: number; + public joints: number; + public miterLimit: number; + + private device: GPUDevice; + private canvasContext: GPUCanvasContext; + private preferredFormat: GPUTextureFormat; + private commandEncoder: GPUCommandEncoder | null = null; + private renderPassEncoder: GPURenderPassEncoder | null = null; + + // Main canvas texture (for final display) - acquired once per frame + private mainTexture: GPUTexture | null = null; + private mainTextureView: GPUTextureView | null = null; + private frameStarted: boolean = false; + + // フレームごとの一時テクスチャ(endFrame()でdestroy) + private frameTextures: GPUTexture[] = []; + + // フレームごとのプール管理テクスチャ(endFrame()でプールに返却) + private pooledTextures: GPUTexture[] = []; + + // フレームごとのレンダーテクスチャプール管理(endFrame()でプールに返却) + private pooledRenderTextures: GPUTexture[] = []; + + // Current rendering target (could be main or atlas) + private currentRenderTarget: GPUTextureView | null = null; + + // Current viewport size (WebGL版と同じ: アトラス描画時はアトラスサイズを使用) + private viewportWidth: number = 0; + private viewportHeight: number = 0; + + private pathCommand: PathCommand; + private bufferManager: BufferManager; + private textureManager: TextureManager; + private frameBufferManager: FrameBufferManager; + private pipelineManager: PipelineManager; + private computePipelineManager: ComputePipelineManager; + private attachmentManager: AttachmentManager; + + public newDrawState: boolean = false; + + // コンテナレイヤースタック(フィルター/ブレンド用) + private readonly $containerLayerStack: IAttachmentObject[] = []; + private containerLayerContentSizes: { width: number; height: number }[] = []; + + // マスク描画モードフラグ(beginMask〜endMask間でtrue) + private inMaskMode: boolean = false; + + // ノード領域クリア済みフラグ(beginNodeRendering〜endNodeRendering間で使用) + private nodeAreaCleared: boolean = false; + + // 現在のノードのシザー範囲(クリア後に戻すため) + private currentNodeScissor: { x: number; y: number; w: number; h: number } | null = null; + + // アトラスレンダーパス統合: 同一アトラスへの連続描画でパスを再利用 + private nodeRenderPassAtlasIndex: number = -1; + + // Dynamic Uniform BindGroup(fill/stencilパイプライン共有、フレームごとに1回作成) + private fillDynamicBindGroup: GPUBindGroup | null = null; + private fillDynamicBindGroupBuffer: GPUBuffer | null = null; + + // clearNodeArea() 用頂点バッファキャッシュ + private nodeClearQuadBuffer: GPUBuffer | null = null; + + // Storage Buffer + Indirect Drawing を使用するかどうか + private useOptimizedInstancing: boolean = true; + + // Hot Path 用の事前割り当てバッファ + private readonly $uniformData8 = new Float32Array(8); + private readonly $scissorRect: { "x": number; "y": number; "w": number; "h": number } = { "x": 0, "y": 0, "w": 0, "h": 0 }; + + // フィルター/コンテナレイヤー用のプリアロケートされた設定オブジェクト + private readonly $filterConfig: { + device: GPUDevice; + commandEncoder: GPUCommandEncoder; + bufferManager: BufferManager; + frameBufferManager: FrameBufferManager; + pipelineManager: PipelineManager; + textureManager: TextureManager; + mainAttachment?: IAttachmentObject; + computePipelineManager: ComputePipelineManager; + frameTextures: GPUTexture[]; + }; + + constructor ( + device: GPUDevice, + canvas_context: GPUCanvasContext, + preferred_format: GPUTextureFormat, + device_pixel_ratio: number = 1 + ) { + this.device = device; + this.canvasContext = canvas_context; + this.preferredFormat = preferred_format; + + WebGPUUtil.setDevice(device); + WebGPUUtil.setDevicePixelRatio(device_pixel_ratio); + + // Set render max size same as WebGL (half of max texture size, minimum 2048) + const maxTextureSize = device.limits.maxTextureDimension2D; + const renderMaxSize = Math.max(2048, maxTextureSize / 2); + WebGPUUtil.setRenderMaxSize(renderMaxSize); + + this.$stack = WebGPUUtil.createArray(); + this.$stackAttachmentObject = WebGPUUtil.createArray(); + this.$matrix = WebGPUUtil.createFloat32Array(9); + this.$matrix.set([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + this.$clearColorR = 0; + this.$clearColorG = 0; + this.$clearColorB = 0; + this.$clearColorA = 0; + + this.thickness = 1; + this.caps = 0; + this.joints = 2; + this.miterLimit = 0; + + this.$mainAttachmentObject = null; + + this.globalAlpha = 1; + this.globalCompositeOperation = "normal"; + this.imageSmoothingEnabled = false; + + this.$fillStyle = new Float32Array([1, 1, 1, 1]); + this.$strokeStyle = new Float32Array([1, 1, 1, 1]); + + this.maskBounds = { + "xMin": 0, + "yMin": 0, + "xMax": 0, + "yMax": 0 + }; + + canvas_context.configure({ + "device": device, + "format": preferred_format, + "alphaMode": "premultiplied" + }); + + // 初期ビューポートサイズをキャンバスサイズに設定 + this.viewportWidth = canvas_context.canvas.width; + this.viewportHeight = canvas_context.canvas.height; + + this.pathCommand = new PathCommand(); + this.bufferManager = new BufferManager(device); + this.textureManager = new TextureManager(device); + this.frameBufferManager = new FrameBufferManager(device, preferred_format); + this.pipelineManager = new PipelineManager(device, preferred_format); + // 遅延パイプライン群を即座に先行作成(初回アクセス時のレイテンシ解消) + this.pipelineManager.preloadLazyGroups(); + this.computePipelineManager = new ComputePipelineManager(device); + this.attachmentManager = new AttachmentManager(device); + + // グラデーションLUT共有アタッチメントにGPUDeviceを設定 + $setGradientLUTDevice(device); + $setFilterGradientLUTDevice(device); + + // アトラス生成関数を登録(複数アトラス対応) + $setAtlasCreator((index: number): IAttachmentObject => { + const maxSize = WebGPUUtil.getRenderMaxSize(); + return this.frameBufferManager.createAttachment( + `atlas_${index}`, + maxSize, + maxSize, + false, + true + ); + }); + + // フィルター/コンテナレイヤー用の設定オブジェクトを事前割り当て + this.$filterConfig = { + "device": this.device, + "commandEncoder": null as unknown as GPUCommandEncoder, + "bufferManager": this.bufferManager, + "frameBufferManager": this.frameBufferManager, + "pipelineManager": this.pipelineManager, + "textureManager": this.textureManager, + "computePipelineManager": this.computePipelineManager, + "frameTextures": this.frameTextures + }; + + // コンテキストをグローバル変数にセット + $setContext(this); + } + + /** + * @description 転送範囲をリセット(フレーム開始) + */ + clearTransferBounds (): void + { + // フレーム開始時に呼ばれる + // テクスチャを取得してフレームを開始 + this.beginFrame(); + } + + /** + * @description 背景色を更新 + */ + updateBackgroundColor (red: number, green: number, blue: number, alpha: number): void + { + this.$clearColorR = red; + this.$clearColorG = green; + this.$clearColorB = blue; + this.$clearColorA = alpha; + } + + /** + * @description 背景色で塗りつぶす(メインアタッチメント) + */ + fillBackgroundColor (): void + { + // メインアタッチメントがない場合はスキップ + if (!this.$mainAttachmentObject || !this.$mainAttachmentObject.texture) { + return; + } + + // フレームが開始されていない場合は開始 + if (!this.frameStarted) { + this.beginFrame(); + } + + // 既存のレンダーパスを終了 + if (this.renderPassEncoder) { + this.renderPassEncoder.end(); + this.renderPassEncoder = null; + } + + // コマンドエンコーダーを確保 + this.ensureCommandEncoder(); + + // メインアタッチメントにステンシルがある場合はステンシル付きレンダーパスを使用 + // MSAA有効時はmsaaTextureをクリアしresolveTargetで非MSAAテクスチャにも反映 + const clearUseMsaa = this.$mainAttachmentObject.msaa && this.$mainAttachmentObject.msaaTexture?.view; + + $bgColorAttachment.view = clearUseMsaa + ? this.$mainAttachmentObject.msaaTexture!.view + : this.$mainAttachmentObject.texture.view; + $bgColorAttachment.resolveTarget = clearUseMsaa + ? this.$mainAttachmentObject.texture.view : undefined; + $bgClearValue.r = this.$clearColorR; + $bgClearValue.g = this.$clearColorG; + $bgClearValue.b = this.$clearColorB; + $bgClearValue.a = this.$clearColorA; + + // ステンシルバッファもクリア + const clearStencilView = clearUseMsaa && this.$mainAttachmentObject.msaaStencil?.view + ? this.$mainAttachmentObject.msaaStencil.view + : this.$mainAttachmentObject.stencil?.view; + if (clearStencilView) { + $bgStencilAttachment.view = clearStencilView; + $bgDescriptor.depthStencilAttachment = $bgStencilAttachment; + } else { + $bgDescriptor.depthStencilAttachment = undefined; + } + + // 背景クリア用のレンダーパスを開始して即座に終了 + this.renderPassEncoder = this.commandEncoder!.beginRenderPass($bgDescriptor); + this.renderPassEncoder.end(); + this.renderPassEncoder = null; + } + + /** + * @description メインcanvasのサイズを変更 + */ + resize (width: number, height: number, cache_clear: boolean = true): void + { + // インスタンス配列をクリア(WebGL版と同じ) + this.clearArraysInstanced(); + + // フレームごとの一時テクスチャをクリア + for (const texture of this.frameTextures) { + texture.destroy(); + } + this.frameTextures.length = 0; + + // プール管理テクスチャをプールに返却し、プール自体もクリア + for (const texture of this.pooledTextures) { + $releaseFillTexture(texture); + } + this.pooledTextures.length = 0; + for (const texture of this.pooledRenderTextures) { + $releaseRenderTexture(texture); + } + this.pooledRenderTextures.length = 0; + $clearFillTexturePool(); + + // フレーム状態をリセット(リサイズ中は新しいフレームを開始できるようにする) + this.frameStarted = false; + this.commandEncoder = null; + this.renderPassEncoder = null; + this.currentRenderTarget = null; + + // マスク状態をリセット + $resetMaskState(); + + // キャンバスのサイズを更新 + const canvas = this.canvasContext.canvas; + + // 型チェックを安全に実行(Worker環境対応) + if (canvas && "width" in canvas && "height" in canvas) { + (canvas as any).width = width; + (canvas as any).height = height; + } + + // WebGL版と同じ: スタックにあるアタッチメントも解放 + if (this.$stackAttachmentObject.length) { + for (let idx = 0; idx < this.$stackAttachmentObject.length; ++idx) { + const attachmentObject = this.$stackAttachmentObject[idx]; + if (!attachmentObject) { + continue; + } + // アタッチメントのリソースを直接解放 + // Note: スタック内のアタッチメントは名前で管理されていないため、 + // リソースを直接破棄する + if (attachmentObject.texture) { + attachmentObject.texture.resource.destroy(); + } + if (attachmentObject.stencil) { + attachmentObject.stencil.resource.destroy(); + } + if (attachmentObject.msaaTexture) { + attachmentObject.msaaTexture.resource.destroy(); + } + if (attachmentObject.msaaStencil) { + attachmentObject.msaaStencil.resource.destroy(); + } + } + this.$stackAttachmentObject.length = 0; + } + + // 既存のメインアタッチメントを破棄 + if (this.$mainAttachmentObject) { + this.frameBufferManager.destroyAttachment("main"); + } + + // 共有アタッチメントをクリア + if (cache_clear) { + $clearGradientAttachmentObjects(); + $clearLUTCache(); + $clearFilterGradientAttachment(); + // アトラスのパッキングデータをリセット(WebGL版と同じ) + $resetAtlas(); + // FrameBufferManagerのアトラステクスチャを再作成(古いコンテンツをクリア) + // ステンシルバッファを有効にする(2パスステンシルフィル用) + this.frameBufferManager.destroyAttachment("atlas"); + this.frameBufferManager.createAttachment("atlas", 4096, 4096, false, true); + } + + // アンバインド(WebGL版と同じ) + this.frameBufferManager.setCurrentAttachment(null); + + // canvasContextを再設定 + this.canvasContext.configure({ + "device": this.device, + "format": this.preferredFormat, + "alphaMode": "premultiplied" + }); + + // リサイズ時にスワップチェーンテクスチャをリセット + // 古いテクスチャ参照を解放して、次のフレームで新しいサイズのテクスチャを取得 + this.mainTexture = null; + this.mainTextureView = null; + + // メインアタッチメントを作成(MSAA + ステンシル付き、マスク描画用) + // WebGL版と同じ: $mainAttachmentObject = frameBufferManagerGetAttachmentObjectUseCase(width, height, true) + // msaa=true でMSAAを有効化(曲線のアンチエイリアス品質向上のため) + // mask=true でステンシルバッファを有効化 + // グラデーション塗りつぶしの中抜き描画(hollow shape)にも必要 + this.$mainAttachmentObject = this.frameBufferManager.createAttachment( + "main", width, height, true, true + ); + + // メインアタッチメントをバインド + this.bind(this.$mainAttachmentObject); + } + + /** + * @description 指定範囲をクリアする + */ + clearRect (_x: number, _y: number, _w: number, _h: number): void + { + // WebGPU clear rect implementation + // WebGPUではclearはレンダーパス開始時に行うため、ここでは何もしない + // 実際のクリアはbeginNodeRenderingやbeginFrameで行われる + } + + /** + * @description 現在の2D変換行列を保存 + */ + save (): void + { + const matrix = $matrixPool.length > 0 ? $matrixPool.pop()! : new Float32Array(9); + matrix.set(this.$matrix); + this.$stack.push(matrix); + } + + /** + * @description 2D変換行列を復元 + */ + restore (): void + { + const matrix = this.$stack.pop(); + if (matrix) { + this.$matrix.set(matrix); + $matrixPool.push(matrix); + } + } + + /** + * @description 2D変換行列を設定 + */ + setTransform ( + a: number, b: number, c: number, + d: number, e: number, f: number + ): void { + this.$matrix[0] = a; + this.$matrix[1] = b; + this.$matrix[3] = c; + this.$matrix[4] = d; + this.$matrix[6] = e; + this.$matrix[7] = f; + } + + /** + * @description 現在の2D変換行列に対して乗算を行います + */ + transform ( + a: number, b: number, c: number, + d: number, e: number, f: number + ): void { + const m = this.$matrix; + const m0 = m[0], m1 = m[1], m3 = m[3], m4 = m[4], m6 = m[6], m7 = m[7]; + + m[0] = a * m0 + b * m3; + m[1] = a * m1 + b * m4; + m[3] = c * m0 + d * m3; + m[4] = c * m1 + d * m4; + m[6] = e * m0 + f * m3 + m6; + m[7] = e * m1 + f * m4 + m7; + } + + /** + * @description コンテキストの値を初期化する + */ + reset (): void + { + this.$matrix.set([1, 0, 0, 0, 1, 0, 0, 0, 1]); + this.$stack.length = 0; + this.$stackAttachmentObject.length = 0; + this.globalAlpha = 1; + this.globalCompositeOperation = "normal"; + this.imageSmoothingEnabled = false; + } + + /** + * @description パスを開始 + */ + beginPath (): void + { + this.pathCommand.beginPath(); + } + + /** + * @description パスを移動 + */ + moveTo (x: number, y: number): void + { + this.pathCommand.moveTo(x, y); + } + + /** + * @description パスを線で結ぶ + */ + lineTo (x: number, y: number): void + { + this.pathCommand.lineTo(x, y); + } + + /** + * @description 二次ベジェ曲線を描画 + */ + quadraticCurveTo (cx: number, cy: number, x: number, y: number): void + { + this.pathCommand.quadraticCurveTo(cx, cy, x, y); + } + + /** + * @description 塗りつぶしスタイルを設定 + */ + fillStyle (red: number, green: number, blue: number, alpha: number): void + { + this.$fillStyle[0] = red; + this.$fillStyle[1] = green; + this.$fillStyle[2] = blue; + this.$fillStyle[3] = alpha; + } + + /** + * @description 線のスタイルを設定 + */ + strokeStyle (red: number, green: number, blue: number, alpha: number): void + { + this.$strokeStyle[0] = red; + this.$strokeStyle[1] = green; + this.$strokeStyle[2] = blue; + this.$strokeStyle[3] = alpha; + } + + /** + * @description パスを閉じる + */ + closePath (): void + { + this.pathCommand.closePath(); + } + + /** + * @description 円弧を描画 + */ + arc (x: number, y: number, radius: number): void + { + this.pathCommand.arc(x, y, radius); + } + + /** + * @description 3次ベジェ曲線を描画 + */ + bezierCurveTo (cx1: number, cy1: number, cx2: number, cy2: number, x: number, y: number): void + { + this.pathCommand.bezierCurveTo(cx1, cy1, cx2, cy2, x, y); + } + + /** + * @description 描画メソッド共通: レンダーパスの確保とノード領域クリア + * fill(), stroke(), gradientFill(), bitmapFill(), gradientStroke(), bitmapStroke() で使用 + */ + private ensureFillRenderPass (): void + { + // フレームが開始されていない場合は開始 + if (!this.frameStarted) { + this.beginFrame(); + } + + // コマンドエンコーダーを確保 + this.ensureCommandEncoder(); + + // 既存のレンダーパスがある場合はearlyリターン(ノード領域クリアのみ確認) + if (this.renderPassEncoder) { + if (this.currentRenderTarget) { + this.ensureNodeAreaCleared(); + } + return; + } + + // レンダーパスがない場合のみ新規作成 + { + // 現在のレンダーターゲットを取得(メインまたはオフスクリーン) + const textureView = this.getCurrentTextureView(); + const attachment = $getAtlasAttachmentObject(); + + // MSAAテクスチャを使用するかどうか + const useMsaa = attachment?.msaa && attachment?.msaaTexture?.view; + const colorView = useMsaa ? attachment!.msaaTexture!.view : textureView; + const resolveTarget = useMsaa ? textureView : null; + + // アトラスへの描画でステンシルが必要な場合はステンシル付きレンダーパスを作成 + if (this.currentRenderTarget && attachment?.stencil?.view) { + // MSAAステンシルテクスチャを使用 + const stencilView = useMsaa && attachment?.msaaStencil?.view + ? attachment.msaaStencil.view + : attachment.stencil.view; + + // ステンシルは常にクリア(2パスフィル描画のため) + // 各描画ごとにステンシルを0からスタートする必要がある + const renderPassDescriptor = this.frameBufferManager.createStencilRenderPassDescriptor( + colorView, + stencilView, + "load", + "clear", // ステンシルをクリア + resolveTarget + ); + this.renderPassEncoder = this.commandEncoder!.beginRenderPass(renderPassDescriptor); + } else if (!this.currentRenderTarget && ($isMaskTestEnabled() || $isMaskDrawing()) && this.$mainAttachmentObject?.stencil?.view) { + // マスク描画時またはマスクテスト有効時のメインアタッチメントへの描画(ステンシル付き) + // マスク描画時: ステンシルバッファにマスク形状を書き込む + // マスクテスト時: ステンシル値をテストしてマスク領域のみ描画 + const mainUseMsaa = this.$mainAttachmentObject.msaa && this.$mainAttachmentObject.msaaTexture?.view; + const mainColorView = mainUseMsaa ? this.$mainAttachmentObject.msaaTexture!.view : this.$mainAttachmentObject.texture!.view; + const mainStencilView = mainUseMsaa && this.$mainAttachmentObject.msaaStencil?.view + ? this.$mainAttachmentObject.msaaStencil.view + : this.$mainAttachmentObject.stencil.view; + const mainResolveTarget = mainUseMsaa ? this.$mainAttachmentObject.texture!.view : null; + + const renderPassDescriptor = this.frameBufferManager.createStencilRenderPassDescriptor( + mainColorView, + mainStencilView, + "load", + "load", // ステンシルは既存の値を保持(マスク情報) + mainResolveTarget + ); + this.renderPassEncoder = this.commandEncoder!.beginRenderPass(renderPassDescriptor); + // マスクテスト時はステンシル参照値を設定 + if ($isMaskTestEnabled()) { + this.renderPassEncoder.setStencilReference($getMaskStencilReference()); + } + } else if (!this.currentRenderTarget && this.$mainAttachmentObject) { + // メインアタッチメントへの通常描画(MSAA対応) + // 2パスステンシルフィルを使用するため、常にステンシル付きレンダーパスを作成 + const mainUseMsaa = this.$mainAttachmentObject.msaa && this.$mainAttachmentObject.msaaTexture?.view; + const mainColorView = mainUseMsaa ? this.$mainAttachmentObject.msaaTexture!.view : this.$mainAttachmentObject.texture!.view; + const mainResolveTarget = mainUseMsaa ? this.$mainAttachmentObject.texture!.view : null; + + if (this.$mainAttachmentObject.stencil?.view) { + // ステンシル付きレンダーパス(2パスステンシルフィル用) + const mainStencilView = mainUseMsaa && this.$mainAttachmentObject.msaaStencil?.view + ? this.$mainAttachmentObject.msaaStencil.view + : this.$mainAttachmentObject.stencil.view; + + const renderPassDescriptor = this.frameBufferManager.createStencilRenderPassDescriptor( + mainColorView, + mainStencilView, + "load", + "clear", // ステンシルをクリア(各描画でステンシルを0からスタート) + mainResolveTarget + ); + this.renderPassEncoder = this.commandEncoder!.beginRenderPass(renderPassDescriptor); + } else { + // ステンシルなし(フォールバック) + const renderPassDescriptor = this.frameBufferManager.createRenderPassDescriptor( + mainColorView, + 0, 0, 0, 0, + "load", + mainResolveTarget + ); + this.renderPassEncoder = this.commandEncoder!.beginRenderPass(renderPassDescriptor); + } + } else { + const renderPassDescriptor = this.frameBufferManager.createRenderPassDescriptor( + colorView, + 0, 0, 0, 0, + "load", + resolveTarget + ); + this.renderPassEncoder = this.commandEncoder!.beginRenderPass(renderPassDescriptor); + } + } + + // ノードレンダリング中の場合、最初の描画前にノード領域をクリア + // renderPassEncoder作成後に呼び出す必要がある + if (this.currentRenderTarget) { + this.ensureNodeAreaCleared(); + } + } + + /** + * @description 塗りつぶしを実行(Loop-Blinn方式対応) + */ + fill (): void + { + const pathVertices = this.pathCommand.$getVertices; + if (pathVertices.length === 0) { return } + + this.ensureFillRenderPass(); + + // WebGL版と同じ: 現在のビューポートサイズを使用(アトラス描画時はアトラスサイズ) + const viewportWidth = this.viewportWidth; + const viewportHeight = this.viewportHeight; + + // MeshFillGenerateUseCaseで頂点データを生成(4 floats/vertex: position + bezier) + const mesh = meshFillGenerateUseCase(pathVertices); + + if (mesh.indexCount === 0) { + return; + } + + // 頂点バッファを取得(プールから再利用) + const vertexBuffer = this.bufferManager.acquireVertexBuffer(mesh.buffer.byteLength, mesh.buffer); + + // color/matrixをDynamic Uniform Bufferに書き込み + const uniformOffset = this.writeFillUniform( + this.$fillStyle[0], this.$fillStyle[1], this.$fillStyle[2], this.$fillStyle[3], + this.$matrix[0], this.$matrix[1], this.$matrix[3], this.$matrix[4], + this.$matrix[6], this.$matrix[7], + viewportWidth, viewportHeight + ); + const bindGroup = this.getOrCreateFillDynamicBindGroup(); + + // アトラスへの描画(ステンシルあり)の場合は2パスステンシルフィル + const attachment = $getAtlasAttachmentObject(); + if (this.currentRenderTarget && attachment?.stencil?.view) { + this.fillWithStencil(vertexBuffer, mesh.indexCount, bindGroup, uniformOffset); + } else if (!this.currentRenderTarget && !this.inMaskMode && !$isMaskTestEnabled() && this.$mainAttachmentObject?.stencil?.view) { + this.fillWithStencilMain(vertexBuffer, mesh.indexCount, bindGroup, uniformOffset); + } else { + const useStencilPipeline = (this.inMaskMode || $isMaskTestEnabled()) && !!this.$mainAttachmentObject?.stencil?.view && !this.currentRenderTarget; + this.fillSimple(vertexBuffer, mesh.indexCount, useStencilPipeline, bindGroup, uniformOffset); + } + + // レンダーパスは終了しない(drawFill()またはendNodeRendering()で終了する) + } + + /** + * @description Dynamic Uniform BindGroupを取得(フレーム内で初回呼び出し時に作成) + */ + private getOrCreateFillDynamicBindGroup(): GPUBindGroup + { + const currentBuffer = this.bufferManager.dynamicUniform.getBuffer(); + if (!this.fillDynamicBindGroup || this.fillDynamicBindGroupBuffer !== currentBuffer) { + const layout = this.pipelineManager.getBindGroupLayout("fill_dynamic"); + if (!layout) { + throw new Error("[WebGPU] fill_dynamic bind group layout not found"); + } + this.fillDynamicBindGroup = this.device.createBindGroup({ + "layout": layout, + "entries": [{ + "binding": 0, + "resource": { + "buffer": currentBuffer, + "size": 256 + } + }] + }); + this.fillDynamicBindGroupBuffer = currentBuffer; + } + return this.fillDynamicBindGroup; + } + + /** + * @description fill/stroke用のcolor/matrix uniformを書き込む + * FillUniforms構造体: color(vec4) + matrix0(vec4) + matrix1(vec4) + matrix2(vec4) = 64 bytes + * @return Dynamic Uniform Buffer内のアライメント済みオフセット + */ + private writeFillUniform( + red: number, green: number, blue: number, alpha: number, + a: number, b: number, c: number, d: number, + tx: number, ty: number, + viewportWidth: number, viewportHeight: number + ): number + { + // color + $fillUniform16[0] = red; + $fillUniform16[1] = green; + $fillUniform16[2] = blue; + $fillUniform16[3] = alpha; + // matrix0 (a, b, 0, pad) — ビューポート正規化 + $fillUniform16[4] = a / viewportWidth; + $fillUniform16[5] = b / viewportHeight; + $fillUniform16[6] = 0; + $fillUniform16[7] = 0; + // matrix1 (c, d, 0, pad) + $fillUniform16[8] = c / viewportWidth; + $fillUniform16[9] = d / viewportHeight; + $fillUniform16[10] = 0; + $fillUniform16[11] = 0; + // matrix2 (tx, ty, 1, pad) + $fillUniform16[12] = tx / viewportWidth; + $fillUniform16[13] = ty / viewportHeight; + $fillUniform16[14] = 1; + $fillUniform16[15] = 0; + + return this.bufferManager.dynamicUniform.allocate($fillUniform16); + } + + /** + * @description 2パスステンシルフィル(アトラス用) + */ + private fillWithStencil( + vertexBuffer: GPUBuffer, + vertexCount: number, + bindGroup: GPUBindGroup, + uniformOffset: number + ): void + { + contextFillWithStencilService( + this.renderPassEncoder!, + this.pipelineManager, + vertexBuffer, + vertexCount, + bindGroup, + uniformOffset + ); + } + + /** + * @description 2パスステンシルフィル(メインキャンバス用) + */ + private fillWithStencilMain( + vertexBuffer: GPUBuffer, + vertexCount: number, + bindGroup: GPUBindGroup, + uniformOffset: number + ): void + { + contextFillWithStencilMainService( + this.renderPassEncoder!, + this.pipelineManager, + vertexBuffer, + vertexCount, + bindGroup, + uniformOffset + ); + } + + /** + * @description 単純なフィル(ステンシルなし、キャンバス描画用) + */ + private fillSimple( + vertexBuffer: GPUBuffer, + vertexCount: number, + useStencilPipeline: boolean, + bindGroup: GPUBindGroup, + uniformOffset: number + ): void + { + const clipLevel = this.$mainAttachmentObject?.clipLevel ?? 1; + + contextFillSimpleService( + this.renderPassEncoder!, + this.pipelineManager, + vertexBuffer, + vertexCount, + bindGroup, + uniformOffset, + !!this.currentRenderTarget, + useStencilPipeline, + clipLevel + ); + } + + /** + * @description オフスクリーンアタッチメントにバインド + * WebGL: FrameBufferManagerBindAttachmentObjectService + */ + bindAttachment(attachment: IAttachmentObject): void + { + this.attachmentManager.bindAttachment(attachment); + + // 現在のレンダーターゲットをオフスクリーンに切り替え + // color?.view または texture?.view を使用 + const view = attachment.color?.view ?? attachment.texture?.view; + if (view) { + this.currentRenderTarget = view; + } + } + + /** + * @description メインキャンバスにバインド + * WebGL: FrameBufferManagerUnBindAttachmentObjectService + */ + unbindAttachment(): void + { + this.attachmentManager.unbindAttachment(); + this.currentRenderTarget = null; + } + + /** + * @description アタッチメントオブジェクトを取得 + * WebGL: FrameBufferManagerGetAttachmentObjectUseCase + */ + getAttachmentObject(width: number, height: number, msaa: boolean = false): IAttachmentObject + { + return this.attachmentManager.getAttachmentObject(width, height, msaa); + } + + /** + * @description アタッチメントオブジェクトを解放 + * WebGL: FrameBufferManagerReleaseAttachmentObjectUseCase + */ + releaseAttachment(attachment: IAttachmentObject): void + { + this.attachmentManager.releaseAttachment(attachment); + } + + /** + * @description 線の描画を実行(WebGL版と同じ仕様) + * WebGL版と同様に、ストロークを塗りとして描画する + */ + stroke (): void + { + // WebGL版と同じ: IPath[]形式で頂点を取得 + const vertices = this.pathCommand.getVerticesForStroke(); + if (vertices.length === 0) { return } + + this.ensureFillRenderPass(); + + const viewportWidth = this.viewportWidth; + const viewportHeight = this.viewportHeight; + + const strokeOutlines = generateStrokeMesh(vertices, this.thickness); + if (strokeOutlines.length === 0) { return } + + // ストロークもmeshFillGenerateUseCaseを使用(4 floats/vertex) + const mesh = meshFillGenerateUseCase(strokeOutlines); + + if (mesh.indexCount === 0) { + return; + } + + const vertexBuffer = this.bufferManager.acquireVertexBuffer(mesh.buffer.byteLength, mesh.buffer); + + // color/matrixをDynamic Uniform Bufferに書き込み + const uniformOffset = this.writeFillUniform( + this.$strokeStyle[0], this.$strokeStyle[1], this.$strokeStyle[2], this.$strokeStyle[3], + this.$matrix[0], this.$matrix[1], this.$matrix[3], this.$matrix[4], + this.$matrix[6], this.$matrix[7], + viewportWidth, viewportHeight + ); + const bindGroup = this.getOrCreateFillDynamicBindGroup(); + + const attachment = $getAtlasAttachmentObject(); + if (this.currentRenderTarget && attachment?.stencil?.view) { + this.fillWithStencil(vertexBuffer, mesh.indexCount, bindGroup, uniformOffset); + } else if (!this.currentRenderTarget && !this.inMaskMode && !$isMaskTestEnabled() && this.$mainAttachmentObject?.stencil?.view) { + this.fillWithStencilMain(vertexBuffer, mesh.indexCount, bindGroup, uniformOffset); + } else { + const useStencilPipeline = (this.inMaskMode || $isMaskTestEnabled()) && !!this.$mainAttachmentObject?.stencil?.view && !this.currentRenderTarget; + this.fillSimple(vertexBuffer, mesh.indexCount, useStencilPipeline, bindGroup, uniformOffset); + } + + // ストローク描画後はpathCommandをクリアする + // 理由: drawFill()がfill()を呼び出すため、クリアしないと同じパスが白で塗りつぶされる + this.pathCommand.reset(); + } + + /** + * @description グラデーションの塗りつぶしを実行 + */ + gradientFill ( + type: number, + stops: number[], + matrix: Float32Array, + spread: number, + interpolation: number, + focal: number + ): void { + const pathVertices = this.pathCommand.$getVertices; + if (pathVertices.length === 0) { return } + + this.ensureFillRenderPass(); + + // WebGL版と同じ: ビューポートサイズ + const viewportWidth = this.viewportWidth; + const viewportHeight = this.viewportHeight; + + // ステンシル付きパイプラインを使用するかどうかを判定 + // グラデーション塗りつぶしでは、マスクモード時のみステンシルテストが必要 + const useMainStencil = !!((this.inMaskMode || $isMaskTestEnabled()) && this.$mainAttachmentObject?.stencil?.view && !this.currentRenderTarget); + const useStencilPipeline = useMainStencil; + + // アトラスへの描画かどうか + const useAtlasTarget = !!this.currentRenderTarget; + + const lutTexture = contextGradientFillUseCase( + this.device, + this.renderPassEncoder!, + this.bufferManager, + this.pipelineManager, + pathVertices, + this.$matrix, + this.$fillStyle, + type, + stops, + matrix, + spread, + interpolation, + focal, + viewportWidth, + viewportHeight, + useAtlasTarget, + useStencilPipeline, + this.$mainAttachmentObject?.clipLevel ?? 1 + ); + if (lutTexture) { + this.addFrameTexture(lutTexture); + } + + // グラデーション描画後にパスをクリア + // これにより、後続のfill()呼び出しで同じパスが再描画されるのを防ぐ + this.beginPath(); + } + + /** + * @description ビットマップの塗りつぶしを実行 + */ + bitmapFill ( + pixels: Uint8Array, + matrix: Float32Array, + width: number, + height: number, + repeat: boolean, + smooth: boolean + ): void { + const pathVertices = this.pathCommand.$getVertices; + if (pathVertices.length === 0) { return } + + this.ensureFillRenderPass(); + + // アトラスのアタッチメントを取得(ステンシル判定で使用) + const atlasAttachment = $getAtlasAttachmentObject(); + + // ステンシル付きレンダーパスかどうかを判定 + // - マスクモード時またはマスクテスト有効時(メインアタッチメントへの描画) + // - アトラス描画時(ステンシル付きレンダーパス) + const useAtlasStencil = !!(this.currentRenderTarget && atlasAttachment?.stencil?.view); + const useMainStencil = !!((this.inMaskMode || $isMaskTestEnabled()) && this.$mainAttachmentObject?.stencil?.view && !this.currentRenderTarget); + const useStencilPipeline = useAtlasStencil || useMainStencil; + + // マスク描画時のクリップレベルを取得 + const clipLevel = this.$mainAttachmentObject?.clipLevel ?? 1; + + const bitmapTexture = contextBitmapFillUseCase( + this.device, + this.renderPassEncoder!, + this.bufferManager, + this.pipelineManager, + pathVertices, + this.$matrix, + this.$fillStyle, + pixels, + matrix, + width, + height, + repeat, + smooth, + this.viewportWidth, + this.viewportHeight, + !!this.currentRenderTarget, + !!useStencilPipeline, + clipLevel + ); + + // ビットマップテクスチャをフレーム終了時に解放するリストに追加 + if (bitmapTexture) { + this.addFrameTexture(bitmapTexture); + } + + // ビットマップ描画後にパスをクリア + // これにより、後続のfill()呼び出しで同じパスが再描画されるのを防ぐ + this.beginPath(); + } + + /** + * @description グラデーション線の描画を実行 + */ + gradientStroke ( + type: number, + stops: number[], + matrix: Float32Array, + spread: number, + interpolation: number, + focal: number + ): void { + // WebGL版と同じ: IPath[]形式で頂点を取得 + const vertices = this.pathCommand.getVerticesForStroke(); + if (vertices.length === 0) { return } + + this.ensureFillRenderPass(); + + // アトラスのアタッチメントを取得(ステンシル判定で使用) + const atlasAttachment = $getAtlasAttachmentObject(); + + // レンダーパスがステンシルアタッチメントを持つ場合はステンシル互換パイプラインを使用 + const useAtlasStencil = !!(this.currentRenderTarget && atlasAttachment?.stencil?.view); + const useMainStencil = !!(!this.currentRenderTarget && this.$mainAttachmentObject?.stencil?.view); + const useStencilPipeline = useAtlasStencil || useMainStencil; + + // WebGL版と同じ: thicknessをそのまま渡し、内部で/2される + const lutTexture = contextGradientStrokeUseCase( + this.device, + this.renderPassEncoder!, + this.bufferManager, + this.pipelineManager, + vertices, + this.thickness, + this.$matrix, + this.$strokeStyle, + type, + stops, + matrix, + spread, + interpolation, + focal, + this.viewportWidth, + this.viewportHeight, + !!this.currentRenderTarget, + useStencilPipeline + ); + + // LUTテクスチャをフレーム終了時に解放するリストに追加 + if (lutTexture) { + this.addFrameTexture(lutTexture); + } + + // ストローク描画後はpathCommandをクリアする + // 理由: drawFill()がfill()を呼び出すため、クリアしないと同じパスが白で塗りつぶされる + this.pathCommand.reset(); + } + + /** + * @description ビットマップ線の描画を実行 + */ + bitmapStroke ( + pixels: Uint8Array, + matrix: Float32Array, + width: number, + height: number, + repeat: boolean, + smooth: boolean + ): void { + // WebGL版と同じ: IPath[]形式で頂点を取得 + const vertices = this.pathCommand.getVerticesForStroke(); + if (vertices.length === 0) { return } + + this.ensureFillRenderPass(); + + // アトラスのアタッチメントを取得(ステンシル判定で使用) + const atlasAttachment = $getAtlasAttachmentObject(); + + // レンダーパスがステンシルアタッチメントを持つ場合はステンシル互換パイプラインを使用 + const useAtlasStencil = !!(this.currentRenderTarget && atlasAttachment?.stencil?.view); + const useMainStencil = !!(!this.currentRenderTarget && this.$mainAttachmentObject?.stencil?.view); + const useStencilPipeline = useAtlasStencil || useMainStencil; + + // WebGL版と同じ: thicknessをそのまま渡し、内部で/2される + const bitmapTexture = contextBitmapStrokeUseCase( + this.device, + this.renderPassEncoder!, + this.bufferManager, + this.pipelineManager, + vertices, + this.thickness, + this.$matrix, + this.$strokeStyle, + pixels, + matrix, + width, + height, + repeat, + smooth, + this.viewportWidth, + this.viewportHeight, + !!this.currentRenderTarget, + useStencilPipeline + ); + + // ビットマップテクスチャをフレーム終了時に解放するリストに追加 + if (bitmapTexture) { + this.addFrameTexture(bitmapTexture); + } + + // ストローク描画後はpathCommandをクリアする + // 理由: drawFill()がfill()を呼び出すため、クリアしないと同じパスが白で塗りつぶされる + this.pathCommand.reset(); + } + + /** + * @description マスク処理を実行 + * WebGL版と同様にステンシルバッファを使用したクリッピング + * メインアタッチメントとアトラス両方でマスク処理をサポート + */ + clip (): void + { + // メインアタッチメントまたはアトラスのいずれかを使用 + // currentAttachmentがない場合はメインアタッチメントを使用(メインキャンバスでのマスク処理用) + let currentAttachment = this.frameBufferManager.getCurrentAttachment(); + const isMainAttachment = !currentAttachment || currentAttachment === this.$mainAttachmentObject; + + if (!currentAttachment && this.$mainAttachmentObject) { + currentAttachment = this.$mainAttachmentObject; + } + + if (!currentAttachment) { + return; + } + + // ステンシルバッファがない場合はスキップ + if (!currentAttachment.stencil) { + return; + } + + const pathVertices = this.pathCommand.$getVertices; + if (pathVertices.length === 0) { + return; + } + + // レンダーパスがない場合は作成 + if (!this.renderPassEncoder) { + this.ensureCommandEncoder(); + + // メインアタッチメントの場合はステンシル付きレンダーパスを作成(MSAA対応) + if (isMainAttachment && this.$mainAttachmentObject?.stencil?.view) { + const clipUseMsaa = this.$mainAttachmentObject.msaa && this.$mainAttachmentObject.msaaTexture?.view; + const clipColorView = clipUseMsaa + ? this.$mainAttachmentObject.msaaTexture!.view + : this.$mainAttachmentObject.texture!.view; + const clipStencilView = clipUseMsaa && this.$mainAttachmentObject.msaaStencil?.view + ? this.$mainAttachmentObject.msaaStencil.view + : this.$mainAttachmentObject.stencil.view; + // resolveTargetなし: clip()はwriteMask=0でcolorを変更しない + const renderPassDescriptor = this.frameBufferManager.createStencilRenderPassDescriptor( + clipColorView, + clipStencilView, + "load", + "load" + ); + this.renderPassEncoder = this.commandEncoder!.beginRenderPass(renderPassDescriptor); + } else { + return; + } + } + + contextClipUseCase( + this.device, + this.renderPassEncoder, + this.bufferManager, + this.pipelineManager, + currentAttachment, + pathVertices, + this.$matrix, + this.$fillStyle, + this.globalAlpha, + isMainAttachment + ); + } + + /** + * @description アタッチメントオブジェクトをバインド + */ + bind (attachment_object: IAttachmentObject): void + { + this.frameBufferManager.setCurrentAttachment(attachment_object); + + // WebGL版と同じ: ビューポートサイズをアタッチメントのサイズに設定 + this.viewportWidth = attachment_object.width; + this.viewportHeight = attachment_object.height; + } + + /** + * @description 現在のアタッチメントオブジェクトを取得 + * アトラスがバインドされていない場合はメインアタッチメントを返す + * When no atlas is bound, returns the main attachment + */ + get currentAttachmentObject (): IAttachmentObject | null + { + // WebGL版と同じ: currentAttachmentがない場合はmainAttachmentを返す + // これによりマスク操作がメインキャンバスでも正しく動作する + const current = this.frameBufferManager.getCurrentAttachment(); + return current || this.$mainAttachmentObject; + } + + /** + * @description アトラス専用のアタッチメントオブジェクトを取得 + */ + get atlasAttachmentObject (): IAttachmentObject | null + { + return $getAtlasAttachmentObject(); + } + + /** + * @description グリッドの描画データをセット + */ + useGrid (grid_data: Float32Array | null): void + { + $gridDataMap.set($fillBufferIndex, grid_data); + } + + /** + * @description 指定のノード範囲で描画を開始(アトラステクスチャへの描画) + * 2パスステンシルフィル対応: ステンシルバッファ付きレンダーパスを使用 + */ + beginNodeRendering (node: Node): void + { + // ノード領域クリアフラグをリセット + this.nodeAreaCleared = false; + + // フレームが開始されていない場合は開始 + if (!this.frameStarted) { + this.beginFrame(); + } + + // アトラステクスチャの該当箇所をレンダーターゲットに設定 + const attachment = $getAtlasAttachmentObjectByIndex(node.index) || $getAtlasAttachmentObject(); + if (attachment && attachment.texture) { + + // 同一アトラスへの連続描画ならレンダーパスを再利用 + if (this.renderPassEncoder && this.nodeRenderPassAtlasIndex === node.index) { + // レンダーパスを再利用 — シザーレクトのみ更新 + this.currentRenderTarget = attachment.texture.view; + this.viewportWidth = attachment.width; + this.viewportHeight = attachment.height; + } else { + // アトラスが変わった or パスがない → 新規作成 + if (this.renderPassEncoder) { + this.renderPassEncoder.end(); + this.renderPassEncoder = null; + } + + this.currentRenderTarget = attachment.texture.view; + this.viewportWidth = attachment.width; + this.viewportHeight = attachment.height; + + this.ensureCommandEncoder(); + + const useMsaa = attachment.msaa && attachment.msaaTexture?.view; + const colorView = useMsaa ? attachment.msaaTexture!.view : attachment.texture.view; + const resolveTarget = useMsaa ? attachment.texture.view : null; + + if (attachment.stencil?.view) { + const stencilView = useMsaa && attachment.msaaStencil?.view + ? attachment.msaaStencil.view + : attachment.stencil.view; + + const renderPassDescriptor = this.frameBufferManager.createStencilRenderPassDescriptor( + colorView, + stencilView, + "load", + "load", + resolveTarget + ); + this.renderPassEncoder = this.commandEncoder!.beginRenderPass(renderPassDescriptor); + } else { + const renderPassDescriptor = this.frameBufferManager.createRenderPassDescriptor( + colorView, + 0, 0, 0, 0, + "load", + resolveTarget + ); + this.renderPassEncoder = this.commandEncoder!.beginRenderPass(renderPassDescriptor); + } + + this.nodeRenderPassAtlasIndex = node.index; + } + + // シザーレクトで描画範囲を制限 + let scissorX = Math.max(0, node.x); + let scissorY = Math.max(0, node.y); + let scissorW = Math.min(node.w, attachment.width - scissorX); + let scissorH = Math.min(node.h, attachment.height - scissorY); + + scissorX = Math.min(scissorX, attachment.width); + scissorY = Math.min(scissorY, attachment.height); + scissorW = Math.max(0, Math.min(scissorW, attachment.width - scissorX)); + scissorH = Math.max(0, Math.min(scissorH, attachment.height - scissorY)); + + this.$scissorRect.x = scissorX; + this.$scissorRect.y = scissorY; + this.$scissorRect.w = scissorW; + this.$scissorRect.h = scissorH; + this.currentNodeScissor = this.$scissorRect; + + if (scissorW > 0 && scissorH > 0) { + const clearW = Math.min(scissorW + 1, attachment.width - scissorX); + const clearH = Math.min(scissorH + 1, attachment.height - scissorY); + this.renderPassEncoder.setScissorRect(scissorX, scissorY, clearW, clearH); + } + } + } + + /** + * @description ノード領域がまだクリアされていない場合にクリアを実行 + * 最初の描画操作(fill, gradientFill, gradientStroke等)で呼び出される + */ + private ensureNodeAreaCleared (): void + { + if (this.nodeAreaCleared) { return } + this.nodeAreaCleared = true; + this.clearNodeArea(); + } + + /** + * @description ノード領域をクリア(透明色 + ステンシル=0) + * WebGL版の gl.clear(COLOR_BUFFER_BIT | STENCIL_BUFFER_BIT) と同等 + */ + private clearNodeArea (): void + { + if (!this.renderPassEncoder) { + return; + } + + // ノードクリア用パイプラインを取得 + const clearPipeline = this.pipelineManager.getPipeline("node_clear_atlas"); + if (!clearPipeline) { + return; + } + + // 初回のみ頂点バッファを作成、以降はキャッシュを再利用 + // clearFrameBuffers()で破棄されないよう、BufferManagerのMapに登録せず直接作成 + if (!this.nodeClearQuadBuffer) { + const buf = this.device.createBuffer({ + "size": $QUAD_VERTICES.byteLength, + "usage": GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + "mappedAtCreation": true + }); + new Float32Array(buf.getMappedRange()).set($QUAD_VERTICES); + buf.unmap(); + this.nodeClearQuadBuffer = buf; + } + const vertexBuffer = this.nodeClearQuadBuffer; + + // クリア描画を実行(シザーは+1pxで設定済み) + this.renderPassEncoder.setPipeline(clearPipeline); + this.renderPassEncoder.setVertexBuffer(0, vertexBuffer); + this.renderPassEncoder.draw(6); + + // WebGL版と同じ: クリア後にシザーを元のサイズに戻す + if (this.currentNodeScissor) { + this.renderPassEncoder.setScissorRect( + this.currentNodeScissor.x, + this.currentNodeScissor.y, + this.currentNodeScissor.w, + this.currentNodeScissor.h + ); + } + } + + /** + * @description 指定のノード範囲で描画を終了 + * レンダーパスは終了しない(次のbeginNodeRenderingで再利用するため) + */ + endNodeRendering (): void + { + // レンダーパスは終了しない(次のbeginNodeRenderingで同一アトラスなら再利用) + + // メインテクスチャに戻す + this.currentRenderTarget = null; + + // ノードシザー範囲をクリア + this.currentNodeScissor = null; + + // ビューポートをキャンバスサイズに戻す + this.viewportWidth = this.canvasContext.canvas.width; + this.viewportHeight = this.canvasContext.canvas.height; + } + + /** + * @description 塗りの描画を実行 + */ + drawFill (): void + { + // WebGL版ではfill()がバッファに蓄積し、drawFill()がまとめてGPU描画する + // WebGPU版ではfill()が直接GPU描画するため、ここでfill()を再呼び出しする必要はない + // (END_FILLコマンドからfill()は既に呼ばれている) + + // レンダーパスは終了しない(アトラスレンダーパス統合で次のノードと共有する) + // stencil_fillパイプラインのpassOp: "zero"でステンシルは自動リセット済み + + // グリッドデータをクリア + $terminateGrid(); + } + + /** + * @description インスタンスを描画 + */ + drawDisplayObject ( + node: Node, + x_min: number, + y_min: number, + x_max: number, + y_max: number, + color_transform: Float32Array + ): void { + // WebGPU display object drawing + // インスタンス配列に追加 + // WebGL版と同じ: ビューポートサイズを使用(コンテナレイヤー時はレイヤーサイズ) + const renderMaxSize = WebGPUUtil.getRenderMaxSize(); + + addDisplayObjectToInstanceArray( + node, + x_min, y_min, x_max, y_max, + color_transform, + this.$matrix, + this.globalCompositeOperation, + this.viewportWidth, + this.viewportHeight, + renderMaxSize, + this.globalAlpha // WebGL版と同じ: globalAlphaを渡す + ); + } + + /** + * @description インスタンス配列を描画 + * Draw instanced arrays + * + * useOptimizedInstancingがtrueの場合、Storage BufferとIndirect Drawingを使用。 + * - Storage Buffer: メモリアロケーション削減、CPU負荷15-25%軽減 + * - Indirect Drawing: CPU-GPUオーバーヘッド5-15%削減 + * + */ + drawArraysInstanced (): void + { + // フレームが開始されていない場合は開始 + if (!this.frameStarted) { + this.beginFrame(); + } + + // 既存のレンダーパスを終了(アトラスパス統合をリセット) + if (this.renderPassEncoder) { + this.renderPassEncoder.end(); + this.renderPassEncoder = null; + } + this.nodeRenderPassAtlasIndex = -1; + + // コマンドエンコーダーを確保 + this.ensureCommandEncoder(); + + // UseCaseでインスタンス描画を実行 + // メインアタッチメントがない場合は初期化が必要 + if (!this.$mainAttachmentObject) { + return; + } + + if (this.useOptimizedInstancing) { + // 最適化版: Storage Buffer + Indirect Drawing + this.renderPassEncoder = contextDrawIndirectUseCase( + this.device, + this.commandEncoder!, + this.renderPassEncoder, + this.$mainAttachmentObject, + this.bufferManager, + this.frameBufferManager, + this.textureManager, + this.pipelineManager, + true, // useIndirect + true // useStorageBuffer + ); + } else { + // 従来版: 毎フレームVertex Buffer新規生成 + this.renderPassEncoder = contextDrawArraysInstancedUseCase( + this.device, + this.commandEncoder!, + this.renderPassEncoder, + this.$mainAttachmentObject, + this.bufferManager, + this.frameBufferManager, + this.textureManager, + this.pipelineManager + ); + } + + // 複雑なブレンドモードの処理 + this.processComplexBlendQueue(); + } + + /** + * @description 最適化インスタンス描画の有効/無効を設定 + * Enable or disable optimized instancing + * + */ + setOptimizedInstancing (enabled: boolean): void + { + this.useOptimizedInstancing = enabled; + } + + /** + * @description 最適化インスタンス描画が有効かどうか + * Whether optimized instancing is enabled + * + */ + isOptimizedInstancingEnabled (): boolean + { + return this.useOptimizedInstancing; + } + + /** + * @description 複雑なブレンドモードのキューを処理 + */ + private processComplexBlendQueue (): void + { + // コマンドエンコーダーを確保 + this.ensureCommandEncoder(); + + // $mainAttachmentObjectを渡す(レンダーパスベースのコピーに必要) + contextProcessComplexBlendQueueUseCase( + this.device, + this.commandEncoder!, + this.$mainAttachmentObject, + this.frameBufferManager, + this.textureManager, + this.pipelineManager, + this.bufferManager + ); + } + + /** + * @description インスタンス配列をクリア + */ + clearArraysInstanced (): void + { + // WebGPU clear instanced arrays + const shaderManager = getInstancedShaderManager(); + shaderManager.clear(); + } + + /** + * @description ピクセルバッファをNodeの指定箇所に転送 + * WebGPUでは、Shapeのシェーダーが-ndc.yでY軸反転しているため、 + * Bitmapも同じ方向になるよう画像を上下反転して書き込む + */ + drawPixels (node: Node, pixels: Uint8Array): void + { + // WebGPU draw pixels + // アトラステクスチャの指定位置にピクセルデータを描画 + // ノードのインデックスを使用して正しいアトラスを取得 + const attachment = $getAtlasAttachmentObjectByIndex(node.index) || $getAtlasAttachmentObject(); + if (!attachment || !attachment.texture) { return } + + const width = node.w; + const height = node.h; + + // レンダーパスがアクティブな場合はマージンクリアしてから終了 + if (this.renderPassEncoder) { + this.ensureNodeAreaCleared(); + this.renderPassEncoder.end(); + this.renderPassEncoder = null; + } + // commandEncoderはsubmitしない — drawPixelsToMsaa()内で同じエンコーダを再利用 + // writeTexture()はキュー操作でありエンコーダ不要 + this.nodeRenderPassAtlasIndex = -1; + + // MSAAが有効な場合は一時テクスチャ経由でMSAAテクスチャに直接描画 + // MSAAが無効な場合は従来通りresolve targetに直接書き込み + if (attachment.msaa && attachment.msaaTexture?.view) { + this.drawPixelsToMsaa(attachment, node, pixels, width, height); + } else { + const rowBytes = width * 4; + this.device.queue.writeTexture( + { + "texture": attachment.texture.resource, + "origin": { "x": node.x, "y": node.y, "z": 0 } + }, + pixels as unknown as ArrayBufferView, + { + "bytesPerRow": rowBytes, + "rowsPerImage": height, + "offset": 0 + }, + { + "width": width, + "height": height, + "depthOrArrayLayers": 1 + } + ); + } + } + + /** + * @description 一時テクスチャ経由でピクセルデータをMSAAテクスチャに描画 + */ + private drawPixelsToMsaa ( + attachment: IAttachmentObject, + node: Node, + pixels: Uint8Array, + width: number, + height: number + ): void + { + // 一時テクスチャをプールから取得 + const tempTexture = $acquireRenderTexture(this.device, width, height); + + // ピクセルデータを一時テクスチャに書き込む + const rowBytes = width * 4; + this.device.queue.writeTexture( + { "texture": tempTexture }, + pixels as unknown as ArrayBufferView, + { + "bytesPerRow": rowBytes, + "rowsPerImage": height + }, + { + "width": width, + "height": height + } + ); + + const pipeline = this.pipelineManager.getPipeline("bitmap_render_msaa"); + if (!pipeline) { + $releaseRenderTexture(tempTexture); + return; + } + + const bindGroupLayout = this.pipelineManager.getBindGroupLayout("positioned_texture"); + if (!bindGroupLayout) { + $releaseRenderTexture(tempTexture); + return; + } + + const uniformData = this.$uniformData8; + uniformData[0] = node.x; + uniformData[1] = node.y; + uniformData[2] = width; + uniformData[3] = height; + uniformData[4] = attachment.width; + uniformData[5] = attachment.height; + uniformData[6] = 0.0; + uniformData[7] = 0.0; + const uniformBuffer = this.bufferManager.acquireAndWriteUniformBuffer(uniformData); + + const sampler = this.textureManager.createSampler("linear_sampler", true); + const tempTextureView = $getOrCreateView(tempTexture); + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = tempTextureView; + const bindGroup = this.device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + // フレームエンコーダーを使用してMSAAテクスチャに描画 + this.ensureCommandEncoder(); + $msaaColorAttachment.view = attachment.msaaTexture!.view; + $msaaColorAttachment.resolveTarget = attachment.texture!.view; + + const stencilView = attachment.msaaStencil?.view; + if (stencilView) { + $msaaStencilAttachment.view = stencilView; + $msaaDescriptor.depthStencilAttachment = $msaaStencilAttachment; + } else { + $msaaDescriptor.depthStencilAttachment = undefined; + } + + const renderPass = this.commandEncoder!.beginRenderPass($msaaDescriptor); + renderPass.setViewport(0, 0, attachment.width, attachment.height, 0, 1); + renderPass.setScissorRect(node.x, node.y, width, height); + renderPass.setPipeline(pipeline); + renderPass.setBindGroup(0, bindGroup); + renderPass.draw(6); + renderPass.end(); + + // endFrame()でプールに返却 + this.pooledRenderTextures.push(tempTexture); + } + + /** + * @description OffscreenCanvasをNodeの指定箇所に転送 + * WebGPUでは、Shapeのシェーダーが-ndc.yでY軸反転しているため、 + * Bitmapも同じ方向になるよう画像を上下反転して書き込む + */ + drawElement (node: Node, element: OffscreenCanvas | ImageBitmap, flipY: boolean = false): void + { + // WebGPU draw element + // OffscreenCanvasまたはImageBitmapをアトラステクスチャに描画 + // ノードのインデックスを使用して正しいアトラスを取得 + const attachment = $getAtlasAttachmentObjectByIndex(node.index) || $getAtlasAttachmentObject(); + if (!attachment || !attachment.texture) { return } + + const width = node.w; + const height = node.h; + + // レンダーパスがアクティブな場合は終了 + if (this.renderPassEncoder) { + this.ensureNodeAreaCleared(); + this.renderPassEncoder.end(); + this.renderPassEncoder = null; + } + // commandEncoderはsubmitしない — drawElementToMsaa()/drawElementToTexture()内で同じエンコーダを再利用 + // copyExternalImageToTexture()はキュー操作でありエンコーダ不要 + this.nodeRenderPassAtlasIndex = -1; + + // MSAAが有効な場合は一時テクスチャ経由でMSAAテクスチャに直接描画 + // MSAAが無効な場合もシェーダー経由で描画(WebGLと同じ処理フロー) + if (attachment.msaa && attachment.msaaTexture?.view) { + this.drawElementToMsaa(attachment, node, element, width, height, flipY); + } else { + this.drawElementToTexture(attachment, node, element, width, height, flipY); + } + } + + /** + * @description 一時テクスチャ経由でMSAAテクスチャに直接描画 + */ + private drawElementToMsaa ( + attachment: IAttachmentObject, + node: Node, + element: OffscreenCanvas | ImageBitmap, + width: number, + height: number, + flipY: boolean + ): void + { + // 一時テクスチャをプールから取得 + const tempTexture = $acquireRenderTexture(this.device, width, height); + + this.device.queue.copyExternalImageToTexture( + { + "source": element as ImageBitmap, + "flipY": flipY + }, + { + "texture": tempTexture, + "premultipliedAlpha": true + }, + { + "width": width, + "height": height + } + ); + + const pipeline = this.pipelineManager.getPipeline("bitmap_render_msaa"); + if (!pipeline) { + $releaseRenderTexture(tempTexture); + return; + } + + const bindGroupLayout = this.pipelineManager.getBindGroupLayout("positioned_texture"); + if (!bindGroupLayout) { + $releaseRenderTexture(tempTexture); + return; + } + + const uniformData = this.$uniformData8; + uniformData[0] = node.x; + uniformData[1] = node.y; + uniformData[2] = width; + uniformData[3] = height; + uniformData[4] = attachment.width; + uniformData[5] = attachment.height; + uniformData[6] = 0.0; + uniformData[7] = 0.0; + const uniformBuffer = this.bufferManager.acquireAndWriteUniformBuffer(uniformData); + + const sampler = this.textureManager.createSampler("linear_sampler", true); + const tempTextureView = $getOrCreateView(tempTexture); + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = tempTextureView; + const bindGroup = this.device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + // フレームエンコーダーを使用してMSAAテクスチャに描画 + this.ensureCommandEncoder(); + $msaaColorAttachment.view = attachment.msaaTexture!.view; + $msaaColorAttachment.resolveTarget = attachment.texture!.view; + + const stencilView = attachment.msaaStencil?.view; + if (stencilView) { + $msaaStencilAttachment.view = stencilView; + $msaaDescriptor.depthStencilAttachment = $msaaStencilAttachment; + } else { + $msaaDescriptor.depthStencilAttachment = undefined; + } + + const renderPass = this.commandEncoder!.beginRenderPass($msaaDescriptor); + renderPass.setViewport(0, 0, attachment.width, attachment.height, 0, 1); + renderPass.setScissorRect(node.x, node.y, width, height); + renderPass.setPipeline(pipeline); + renderPass.setBindGroup(0, bindGroup); + renderPass.draw(6); + renderPass.end(); + + // endFrame()でプールに返却 + this.pooledRenderTextures.push(tempTexture); + } + + /** + * @description 一時テクスチャ経由で通常テクスチャに描画(非MSAA版) + */ + private drawElementToTexture ( + attachment: IAttachmentObject, + node: Node, + element: OffscreenCanvas | ImageBitmap, + width: number, + height: number, + flipY: boolean + ): void + { + // 一時テクスチャをプールから取得 + const tempTexture = $acquireRenderTexture(this.device, width, height); + + // ImageBitmapを一時テクスチャにコピー + // flipYパラメータで画像の上下反転を制御(Videoはtrue、TextFieldはfalse) + this.device.queue.copyExternalImageToTexture( + { + "source": element as ImageBitmap, + "flipY": flipY + }, + { + "texture": tempTexture, + "premultipliedAlpha": true + }, + { + "width": width, + "height": height + } + ); + + const pipeline = this.pipelineManager.getPipeline("bitmap_render"); + if (!pipeline) { + $releaseRenderTexture(tempTexture); + return; + } + + const bindGroupLayout = this.pipelineManager.getBindGroupLayout("positioned_texture"); + if (!bindGroupLayout) { + $releaseRenderTexture(tempTexture); + return; + } + + const uniformData = this.$uniformData8; + uniformData[0] = node.x; + uniformData[1] = node.y; + uniformData[2] = width; + uniformData[3] = height; + uniformData[4] = attachment.width; + uniformData[5] = attachment.height; + uniformData[6] = 0.0; + uniformData[7] = 0.0; + const uniformBuffer = this.bufferManager.acquireAndWriteUniformBuffer(uniformData); + + const sampler = this.textureManager.createSampler("linear_sampler", true); + const tempTextureView = $getOrCreateView(tempTexture); + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = tempTextureView; + const bindGroup = this.device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + // フレームエンコーダーを使用して通常テクスチャに描画 + this.ensureCommandEncoder(); + $msaaColorAttachment.view = attachment.texture!.view; + $msaaColorAttachment.resolveTarget = undefined; + + const stencilView = attachment.stencil?.view; + if (stencilView) { + $msaaStencilAttachment.view = stencilView; + $msaaDescriptor.depthStencilAttachment = $msaaStencilAttachment; + } else { + $msaaDescriptor.depthStencilAttachment = undefined; + } + + const renderPass = this.commandEncoder!.beginRenderPass($msaaDescriptor); + renderPass.setViewport(0, 0, attachment.width, attachment.height, 0, 1); + renderPass.setScissorRect(node.x, node.y, width, height); + renderPass.setPipeline(pipeline); + renderPass.setBindGroup(0, bindGroup); + renderPass.draw(6); + renderPass.end(); + + // endFrame()でプールに返却 + this.pooledRenderTextures.push(tempTexture); + } + + /** + * @description フィルターを適用 + */ + applyFilter ( + node: Node, + _unique_key: string, + _updated: boolean, + width: number, + height: number, + _is_bitmap: boolean, + matrix: Float32Array, + color_transform: Float32Array, + blend_mode: IBlendMode, + bounds: Float32Array, + params: Float32Array + ): void { + // インスタンス配列を先に描画 + this.drawArraysInstanced(); + + // フレームが開始されていない場合は開始 + if (!this.frameStarted) { + this.beginFrame(); + } + + // 既存のレンダーパスを終了(アトラスパス統合をリセット) + if (this.renderPassEncoder) { + this.renderPassEncoder.end(); + this.renderPassEncoder = null; + } + this.nodeRenderPassAtlasIndex = -1; + + // コマンドエンコーダーを確保 + this.ensureCommandEncoder(); + + this.$filterConfig.commandEncoder = this.commandEncoder!; + this.$filterConfig.mainAttachment = this.$mainAttachmentObject as IAttachmentObject; + + contextApplyFilterUseCase( + node, + width, + height, + _is_bitmap, + matrix, + color_transform, + blend_mode, + bounds, + params, + this.$filterConfig, + this.mainTextureView!, + this.bufferManager + ); + } + + /** + * @description コンテナのフィルター/ブレンド用のレイヤーを開始 + * Begin a container layer for filter/blend processing + * + */ + containerBeginLayer (width: number, height: number): void + { + this.drawArraysInstanced(); + + // フレームが開始されていない場合は開始 + if (!this.frameStarted) { + this.beginFrame(); + } + + // 既存のレンダーパスを終了 + if (this.renderPassEncoder) { + this.renderPassEncoder.end(); + this.renderPassEncoder = null; + } + + const mainAttachment = this.$mainAttachmentObject as IAttachmentObject; + this.$containerLayerStack.push(mainAttachment); + + // コンテナのコンテンツサイズを保存(containerEndLayerでの抽出範囲計算に使用) + this.containerLayerContentSizes.push({ width, height }); + + // WebGL版と同じ: コンテンツサイズのbgra8unormアタッチメントを作成(mask=trueでステンシル付き) + // children の transform は layerBounds で相対化されるため、コンテンツはレイヤー内の (0,0) から描画される + const tempAttachment = this.frameBufferManager.createAttachment( + "container_layer", + width, + height, + mainAttachment.msaa, + true + ); + + this.$mainAttachmentObject = tempAttachment; + this.bind(tempAttachment); + } + + /** + * @description コンテナのフィルター/ブレンド用レイヤーを終了し、結果を元のメインに合成 + * End the container layer and composite the result back to the original main + * + * @param {IBlendMode} blend_mode + * @param {Float32Array} matrix + * @param {Float32Array | null} color_transform + * @param {boolean} use_filter + * @param {Float32Array | null} filter_bounds + * @param {Float32Array | null} filter_params + * @param {string} unique_key + * @param {string} filter_key + */ + containerEndLayer ( + blend_mode: IBlendMode, + matrix: Float32Array, + color_transform: Float32Array | null, + use_filter: boolean, + filter_bounds: Float32Array | null, + filter_params: Float32Array | null, + unique_key: string, + filter_key: string + ): void { + this.drawArraysInstanced(); + + // 既存のレンダーパスを終了 + if (this.renderPassEncoder) { + this.renderPassEncoder.end(); + this.renderPassEncoder = null; + } + + this.ensureCommandEncoder(); + + const tempAttachment = this.$mainAttachmentObject as IAttachmentObject; + const contentSize = this.containerLayerContentSizes.pop() || { "width": tempAttachment.width, "height": tempAttachment.height }; + + // mainを復元 + this.$mainAttachmentObject = this.$containerLayerStack.pop() as IAttachmentObject; + + this.$filterConfig.commandEncoder = this.commandEncoder!; + this.$filterConfig.mainAttachment = undefined; + + contextContainerEndLayerUseCase( + tempAttachment, + this.$mainAttachmentObject, + "container_layer", + blend_mode, + matrix, + color_transform, + use_filter, + filter_bounds, + filter_params, + unique_key, + filter_key, + contentSize.width, + contentSize.height, + this.$filterConfig, + this.bufferManager + ); + + // メインのアタッチメントをバインド + this.bind(this.$mainAttachmentObject); + } + + /** + * @description キャッシュされたコンテナフィルターテクスチャをメインに描画 + * Draw a cached container filter texture to the main attachment + * + * @param {IBlendMode} blend_mode + * @param {Float32Array} matrix + * @param {Float32Array} color_transform + * @param {Float32Array} filter_bounds + * @param {string} unique_key + * @param {string} filter_key + */ + containerDrawCachedFilter ( + blend_mode: IBlendMode, + matrix: Float32Array, + color_transform: Float32Array, + filter_bounds: Float32Array, + unique_key: string, + filter_key: string + ): void { + const cachedKey = $cacheStore.get(unique_key, "fKey"); + if (cachedKey !== filter_key) { + return; + } + + const cachedAttachment = $cacheStore.get(unique_key, "fTexture") as IAttachmentObject; + if (!cachedAttachment || !cachedAttachment.texture) { + return; + } + + this.drawArraysInstanced(); + + if (!this.frameStarted) { + this.beginFrame(); + } + + if (this.renderPassEncoder) { + this.renderPassEncoder.end(); + this.renderPassEncoder = null; + } + + this.ensureCommandEncoder(); + + const mainAttachment = this.$mainAttachmentObject as IAttachmentObject; + if (!mainAttachment || !mainAttachment.texture) { + return; + } + + // ColorTransformが恒等変換でない場合、キャッシュのコピーにCTを適用 + let drawAttachment = cachedAttachment; + let ctAttachment: IAttachmentObject | null = null; + const isIdentityCt = color_transform[0] === 1 && color_transform[1] === 1 + && color_transform[2] === 1 && color_transform[3] === 1 + && color_transform[4] === 0 && color_transform[5] === 0 + && color_transform[6] === 0 && color_transform[7] === 0; + if (!isIdentityCt) { + ctAttachment = this.frameBufferManager.createTemporaryAttachment( + cachedAttachment.width, cachedAttachment.height + ); + const ctPipeline = this.pipelineManager.getPipeline("color_transform"); + const ctBindGroupLayout = this.pipelineManager.getBindGroupLayout("texture_copy"); + if (ctPipeline && ctBindGroupLayout && ctAttachment.texture) { + $ctUniform8[0] = color_transform[0]; + $ctUniform8[1] = color_transform[1]; + $ctUniform8[2] = color_transform[2]; + $ctUniform8[3] = color_transform[3]; + $ctUniform8[4] = color_transform[4]; + $ctUniform8[5] = color_transform[5]; + $ctUniform8[6] = color_transform[6]; + $ctUniform8[7] = 0; + const ctUniformData = $ctUniform8; + const ctUniformBuffer = this.bufferManager.acquireAndWriteUniformBuffer(ctUniformData); + const ctSampler = this.textureManager.createSampler("cached_ct_sampler", false); + ($entries3[0].resource as GPUBufferBinding).buffer = ctUniformBuffer; + $entries3[1].resource = ctSampler; + $entries3[2].resource = cachedAttachment.texture.view; + const ctBindGroup = this.device.createBindGroup({ + "layout": ctBindGroupLayout, + "entries": $entries3 + }); + const ctRenderPass = this.frameBufferManager.createRenderPassDescriptor( + ctAttachment.texture.view, 0, 0, 0, 0, "clear" + ); + const ctPass = this.commandEncoder!.beginRenderPass(ctRenderPass); + ctPass.setPipeline(ctPipeline); + ctPass.setBindGroup(0, ctBindGroup); + ctPass.draw(6, 1, 0, 0); + ctPass.end(); + drawAttachment = ctAttachment; + } + } + + const devicePixelRatio = WebGPUUtil.getDevicePixelRatio(); + const scaleX = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]); + const scaleY = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]); + const boundsXMin = filter_bounds[0] * (scaleX / devicePixelRatio); + const boundsYMin = filter_bounds[1] * (scaleY / devicePixelRatio); + + // WebGL版と同じ: boundsXMin + matrix[4] で絶対位置($offsetは使わない) + const drawX = Math.floor(boundsXMin + matrix[4]); + const drawY = Math.floor(boundsYMin + matrix[5]); + + // シンプルなブレンドモード判定 + const useMsaa = mainAttachment.msaa && mainAttachment.msaaTexture?.view; + let pipelineName: string; + switch (blend_mode) { + case "add": + pipelineName = useMsaa ? "filter_output_add_msaa" : "filter_output_add"; + break; + case "screen": + pipelineName = useMsaa ? "filter_output_screen_msaa" : "filter_output_screen"; + break; + case "alpha": + pipelineName = useMsaa ? "filter_output_alpha_msaa" : "filter_output_alpha"; + break; + case "erase": + pipelineName = useMsaa ? "filter_output_erase_msaa" : "filter_output_erase"; + break; + default: + pipelineName = useMsaa ? "filter_output_msaa" : "filter_output"; + break; + } + + const pipeline = this.pipelineManager.getPipeline(pipelineName); + const bindGroupLayout = this.pipelineManager.getBindGroupLayout("texture_copy"); + if (!pipeline || !bindGroupLayout) { + return; + } + + const sampler = this.textureManager.createSampler("cached_filter_sampler", true); + const uniformBuffer = this.bufferManager.acquireAndWriteUniformBuffer($IDENTITY_UV); + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = drawAttachment.texture!.view; + const bindGroup = this.device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + const colorView = useMsaa ? mainAttachment.msaaTexture!.view : mainAttachment.texture.view; + const resolveTarget = useMsaa ? mainAttachment.texture.view : null; + const renderPassDescriptor = this.frameBufferManager.createRenderPassDescriptor( + colorView, 0, 0, 0, 0, "load", resolveTarget + ); + + const vpX = Math.max(0, drawX); + const vpY = Math.max(0, drawY); + const vpW = Math.max(1, drawAttachment.width); + const vpH = Math.max(1, drawAttachment.height); + const mainWidth = mainAttachment.width; + const mainHeight = mainAttachment.height; + const scissorW = Math.max(1, Math.min(vpW, mainWidth - vpX)); + const scissorH = Math.max(1, Math.min(vpH, mainHeight - vpY)); + + if (scissorW <= 0 || scissorH <= 0 || vpX >= mainWidth || vpY >= mainHeight) { + if (ctAttachment) { + this.frameBufferManager.releaseTemporaryAttachment(ctAttachment); + } + return; + } + + const passEncoder = this.commandEncoder!.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.setViewport(vpX, vpY, vpW, vpH, 0, 1); + passEncoder.setScissorRect(vpX, vpY, scissorW, scissorH); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + // CT一時アタッチメントを解放 + if (ctAttachment) { + this.frameBufferManager.releaseTemporaryAttachment(ctAttachment); + } + + this.bind(mainAttachment); + } + + /** + * @description メインテクスチャを確保(フレーム開始時に一度だけgetCurrentTexture呼び出し) + */ + private ensureMainTexture(): void + { + if (!this.mainTexture) { + this.mainTexture = this.canvasContext.getCurrentTexture(); + this.mainTextureView = this.mainTexture.createView(); + } + } + + /** + * @description 現在の描画ターゲットのテクスチャビューを取得 + */ + private getCurrentTextureView(): GPUTextureView + { + // アトラステクスチャへのレンダリング中の場合 + if (this.currentRenderTarget) { + return this.currentRenderTarget; + } + + // メインキャンバステクスチャを確保 + this.ensureMainTexture(); + return this.mainTextureView!; + } + + /** + * @description コマンドエンコーダーが存在することを保証 + */ + private ensureCommandEncoder(): void + { + // Note: RenderPassEncoderの終了はここでは行わない + // 呼び出し側で適切に管理すること + + if (!this.commandEncoder) { + this.commandEncoder = this.device.createCommandEncoder(); + } + } + + /** + * @description フレーム開始(レンダリング開始前に呼ぶ) + */ + beginFrame(): void + { + if (!this.frameStarted) { + this.ensureMainTexture(); + this.ensureCommandEncoder(); + this.frameStarted = true; + this.frameBufferManager.beginFrame(); + + // 注意: グラデーションLUTは共有テクスチャに描画されるため、 + // キャッシュは使用しません。各フレームで再描画が必要です。 + } + } + + /** + * @description フレームごとのプール管理テクスチャを追加(endFrame()でプールに返却) + */ + addFrameTexture (texture: GPUTexture): void + { + this.pooledTextures.push(texture); + } + + /** + * @description フレーム終了とコマンド送信(レンダリング完了後に呼ぶ) + */ + endFrame(): void + { + if (!this.frameStarted) { + return; + } + + // 開いているRenderPassEncoderがあれば終了 + if (this.renderPassEncoder) { + this.renderPassEncoder.end(); + this.renderPassEncoder = null; + } + + // DynamicUniformAllocatorのステージングバッファをGPUに一括書き込み + this.bufferManager.dynamicUniform.flush(); + + // コマンドをsubmit + if (this.commandEncoder) { + try { + const commandBuffer = this.commandEncoder.finish(); + this.device.queue.submit([commandBuffer]); + } catch (e) { + console.error("Failed to submit frame commands:", e); + } + } + + // submit後に一時テクスチャを解放 + this.frameBufferManager.flushPendingReleases(); + + // フレームごとの一時バッファを解放 + this.bufferManager.clearFrameBuffers(); + + // フレームごとの一時テクスチャを解放 + for (const texture of this.frameTextures) { + texture.destroy(); + } + this.frameTextures.length = 0; + + // プール管理テクスチャをプールに返却 + for (const texture of this.pooledTextures) { + $releaseFillTexture(texture); + } + this.pooledTextures.length = 0; + + // レンダーテクスチャをプールに返却 + for (const texture of this.pooledRenderTextures) { + $releaseRenderTexture(texture); + } + this.pooledRenderTextures.length = 0; + + // Gradient LUTキャッシュのTTL超過エントリを解放 + $cleanupLUTCache(); + + // Dynamic Uniform BindGroupをリセット(バッファオフセットがリセットされるため) + this.fillDynamicBindGroup = null; + this.fillDynamicBindGroupBuffer = null; + + // 次のフレーム用にクリア + this.commandEncoder = null; + this.renderPassEncoder = null; + this.currentRenderTarget = null; + this.nodeRenderPassAtlasIndex = -1; + + // テクスチャ参照をクリア(次フレームで新しく取得) + this.mainTexture = null; + this.mainTextureView = null; + this.frameStarted = false; + } + + /** + * @description コマンドを送信(後方互換性のため残す) + */ + submit (): void + { + this.endFrame(); + } + + /** + * @description ノードを作成 + * アトラスがいっぱいの場合は新しいアトラスを作成して再試行 + */ + createNode (width: number, height: number): Node + { + // WebGPU node creation implementation using texture-packer + const index = $getActiveAtlasIndex(); + + if (!$rootNodes[index]) { + const maxSize = WebGPUUtil.getRenderMaxSize(); + $rootNodes[index] = new TexturePacker(index, maxSize, maxSize); + } + + const rootNode = $rootNodes[index]; + const node = rootNode.insert(width, height); + + if (!node) { + // アトラスがいっぱいの場合、新しいアトラスインデックスに切り替えて再試行 + $setActiveAtlasIndex(index + 1); + return this.createNode(width, height); + } + + return node; + } + + /** + * @description ノードを削除 + */ + removeNode (node: Node): void + { + // WebGPU node removal implementation + const index = node.index; + const rootNode = $rootNodes[index]; + + if (rootNode) { + rootNode.dispose(node.x, node.y, node.w, node.h); + } + } + + /** + * @description フレームバッファの描画情報をキャンバスに転送 + * スワップチェーンはCopyDstをサポートしないため、レンダーパスでブリット + */ + transferMainCanvas (): void + { + // メインアタッチメントの内容をスワップチェーン(キャンバス)にコピー + if (!this.$mainAttachmentObject || !this.$mainAttachmentObject.texture) { + this.endFrame(); + return; + } + + // 既存のレンダーパスを終了 + if (this.renderPassEncoder) { + this.renderPassEncoder.end(); + this.renderPassEncoder = null; + } + + // コマンドエンコーダーを確保 + this.ensureCommandEncoder(); + + // メインテクスチャビューを確保 + this.ensureMainTexture(); + + // スワップチェーンはCopyDstをサポートしないため、レンダーパスでブリット + const pipeline = this.pipelineManager.getPipeline("texture_copy_bgra"); + const bindGroupLayout = this.pipelineManager.getBindGroupLayout("texture_copy"); + + if (!pipeline || !bindGroupLayout) { + console.error("[WebGPU] texture_copy_bgra pipeline not found"); + this.endFrame(); + return; + } + + // Static BindGroup キャッシュ: mainAttachment.texture.viewが同じ間は再利用 + const currentView = this.$mainAttachmentObject.texture.view; + if (!$presentBindGroup || $presentBindGroupView !== currentView) { + if (!$presentUniformBuffer) { + $presentUniformBuffer = this.device.createBuffer({ + "size": $IDENTITY_UV.byteLength, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + this.device.queue.writeBuffer($presentUniformBuffer, 0, $IDENTITY_UV.buffer, $IDENTITY_UV.byteOffset, $IDENTITY_UV.byteLength); + } + const sampler = this.textureManager.createSampler("transfer_sampler", false); + $entries3[0].resource = { "buffer": $presentUniformBuffer }; + $entries3[1].resource = sampler; + $entries3[2].resource = currentView; + $presentBindGroup = this.device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + $presentBindGroupView = currentView; + } + const bindGroup = $presentBindGroup; + + // スワップチェーンへのレンダーパスを作成(プリアロケート版) + $presentColorAttachment.view = this.mainTextureView!; + + const passEncoder = this.commandEncoder!.beginRenderPass($presentDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); // フルスクリーンクワッド(6頂点) + passEncoder.end(); + + // endFrame()でsubmitされる + this.endFrame(); + } + + /** + * @description ImageBitmapを生成 + */ + async createImageBitmap (width: number, height: number): Promise + { + // アトラステクスチャから現在の描画内容を取得 + const attachment = $getAtlasAttachmentObject(); + if (!attachment) { + throw new Error("[WebGPU] Atlas attachment not found"); + } + + // 描画を完了 + if (this.renderPassEncoder) { + this.renderPassEncoder.end(); + this.renderPassEncoder = null; + } + + // GPUバッファにピクセルデータを読み込み + const bytesPerPixel = 4; + const bytesPerRow = Math.ceil(width * bytesPerPixel / 256) * 256; // 256バイトアライメント + const bufferSize = bytesPerRow * height; + + // ピクセルバッファを作成 + const pixelBuffer = this.device.createBuffer({ + "size": bufferSize, + "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ + }); + + // コマンドエンコーダーを作成 + const commandEncoder = this.device.createCommandEncoder(); + + // アトラステクスチャからピクセルバッファにコピー + if (!attachment.texture) { + throw new Error("Attachment texture is null"); + } + + commandEncoder.copyTextureToBuffer( + { + "texture": attachment.texture.resource, + "mipLevel": 0, + "origin": { "x": 0, "y": 0, "z": 0 } + }, + { + "buffer": pixelBuffer, + "bytesPerRow": bytesPerRow, + "rowsPerImage": height + }, + { + "width": width, + "height": height, + "depthOrArrayLayers": 1 + } + ); + + // コマンドを送信 + this.device.queue.submit([commandEncoder.finish()]); + + // バッファをマップして読み込み + await pixelBuffer.mapAsync(GPUMapMode.READ); + const mappedRange = pixelBuffer.getMappedRange(); + const pixels = new Uint8Array(mappedRange); + + // ピクセルデータをコピー(アライメントを考慮) + const resultPixels = new Uint8Array(width * height * 4); + for (let y = 0; y < height; y++) { + const srcOffset = y * bytesPerRow; + const dstOffset = y * width * 4; + resultPixels.set( + pixels.subarray(srcOffset, srcOffset + width * 4), + dstOffset + ); + } + + pixelBuffer.unmap(); + pixelBuffer.destroy(); + + // プリマルチプライドアルファをストレートアルファに変換 + const inv = new Float32Array(256); + for (let a = 1; a < 256; a++) { + inv[a] = 255 / a; + } + + for (let idx = 0; idx < resultPixels.length; idx += 4) { + const alpha = resultPixels[idx + 3]; + + if (alpha === 0 || alpha === 255) { + continue; + } + + const f = inv[alpha]; + resultPixels[idx ] = Math.min(255, Math.round(resultPixels[idx ] * f)); + resultPixels[idx + 1] = Math.min(255, Math.round(resultPixels[idx + 1] * f)); + resultPixels[idx + 2] = Math.min(255, Math.round(resultPixels[idx + 2] * f)); + } + + // ImageBitmapを作成 + const imageData = new ImageData(new Uint8ClampedArray(resultPixels), width, height); + + // グローバルのcreateBitmapが存在するかチェック + if (typeof createImageBitmap !== "undefined") { + return await createImageBitmap(imageData, { + "premultiplyAlpha": "none", + "colorSpaceConversion": "none" + }); + } + // Fallback: createImageBitmapがない環境用 + throw new Error("[WebGPU] createImageBitmap not available in this environment"); + + } + + /** + * @description マスク描画の開始準備 + * Prepare to start drawing the mask + * + */ + beginMask(): void + { + // メインアタッチメントをバインド(マスクはメインアタッチメントのステンシルに書き込む) + if (this.$mainAttachmentObject) { + this.bind(this.$mainAttachmentObject); + } + + // マスクモードではメインアタッチメントに描画するため、currentRenderTargetをnullに設定 + // これにより、fill()等がメイン用シェーダー(Y反転あり)を使用する + this.currentRenderTarget = null; + + // 既存のレンダーパスを終了 + if (this.renderPassEncoder) { + this.renderPassEncoder.end(); + this.renderPassEncoder = null; + } + + // フレームが開始されていない場合は開始 + if (!this.frameStarted) { + this.beginFrame(); + } + + // コマンドエンコーダーを確保 + this.ensureCommandEncoder(); + + // ステンシル付きレンダーパスを開始(マスク描画用) + if (this.$mainAttachmentObject?.texture && this.$mainAttachmentObject?.stencil?.view) { + // 最初のマスク(clipLevel == 0)の場合はステンシルをクリア + // ネストされたマスクの場合は既存のステンシル値を保持 + const isFirstMask = this.$mainAttachmentObject.clipLevel === 0; + const stencilLoadOp = isFirstMask ? "clear" : "load"; + + // MSAA有効時はmsaaTexture/msaaStencilを使用(sampleCount一致が必要) + // resolveTargetは設定しない: clip()はwriteMask=0でcolorを変更しないため、 + // resolveでtexture.viewを上書きすると、先にtexture.viewに描画された内容が消える + const mainUseMsaa = this.$mainAttachmentObject.msaa && this.$mainAttachmentObject.msaaTexture?.view; + const colorView = mainUseMsaa + ? this.$mainAttachmentObject.msaaTexture!.view + : this.$mainAttachmentObject.texture.view; + const stencilView = mainUseMsaa && this.$mainAttachmentObject.msaaStencil?.view + ? this.$mainAttachmentObject.msaaStencil.view + : this.$mainAttachmentObject.stencil.view; + + const renderPassDescriptor = this.frameBufferManager.createStencilRenderPassDescriptor( + colorView, + stencilView, + "load", // カラーは既存の内容を保持 + stencilLoadOp // 最初のマスク: クリア、ネスト: 保持 + ); + this.renderPassEncoder = this.commandEncoder!.beginRenderPass(renderPassDescriptor); + + // ビューポートサイズを更新 + this.viewportWidth = this.$mainAttachmentObject.width; + this.viewportHeight = this.$mainAttachmentObject.height; + } + + maskBeginMaskService(); + + // マスクモードフラグを設定 + this.inMaskMode = true; + } + + /** + * @description マスクの描画範囲を設定 + * Set the mask drawing bounds + * + * @param {number} x_min + * @param {number} y_min + * @param {number} x_max + * @param {number} y_max + */ + setMaskBounds( + x_min: number, + y_min: number, + x_max: number, + y_max: number + ): void { + maskSetMaskBoundsService(x_min, y_min, x_max, y_max); + } + + /** + * @description マスクの描画を終了 + * End mask drawing + * + */ + endMask(): void + { + // マスク描画用のレンダーパスを終了 + if (this.renderPassEncoder) { + this.renderPassEncoder.end(); + this.renderPassEncoder = null; + } + + maskEndMaskService(); + + // マスクモードフラグをクリア + this.inMaskMode = false; + } + + /** + * @description マスクの終了処理 + * Mask end processing + * + */ + leaveMask(): void + { + this.drawArraysInstanced(); + + // 現在のclipLevelを保存(leaveMaskUseCase内でデクリメントされる) + const currentAttachment = this.frameBufferManager.getCurrentAttachment(); + const currentClipLevel = currentAttachment?.clipLevel ?? 0; + const wasLastMask = currentClipLevel === 1; + + maskLeaveMaskUseCase(); + + // 現在のレンダーパスを終了 + if (this.renderPassEncoder) { + this.renderPassEncoder.end(); + this.renderPassEncoder = null; + } + + // コマンドエンコーダーを確保 + this.ensureCommandEncoder(); + + // MSAA有効時はmsaaTexture/msaaStencilを使用 + const leaveMsaa = this.$mainAttachmentObject?.msaa && this.$mainAttachmentObject?.msaaTexture?.view; + const leaveColorView = leaveMsaa + ? this.$mainAttachmentObject!.msaaTexture!.view + : this.$mainAttachmentObject?.texture!.view; + const leaveStencilView = leaveMsaa && this.$mainAttachmentObject?.msaaStencil?.view + ? this.$mainAttachmentObject!.msaaStencil!.view + : this.$mainAttachmentObject?.stencil?.view; + if (wasLastMask && leaveStencilView) { + // 単体マスク(最後のマスク)の場合、ステンシルバッファをクリア + // WebGL: gl.clear(STENCIL_BUFFER_BIT) + // resolveTargetは設定しない(ステンシルクリアのみが目的で、カラーの上書きを防ぐ) + const clearPassDescriptor: GPURenderPassDescriptor = { + "colorAttachments": [{ + "view": leaveColorView!, + "loadOp": "load" as GPULoadOp, + "storeOp": "store" as GPUStoreOp + }], + "depthStencilAttachment": { + "view": leaveStencilView, + "stencilLoadOp": "clear", // ステンシルをクリア + "stencilStoreOp": "store", + "stencilClearValue": 0 + } + }; + const clearPass = this.commandEncoder!.beginRenderPass(clearPassDescriptor); + clearPass.end(); + } else if (currentClipLevel > 1 && leaveStencilView) { + // ネストされたマスクの場合、上位レベルのステンシルビットをクリア + // WebGL: stencilMask(1 << clipLevel), stencilOp(REPLACE, REPLACE, REPLACE) + // 全画面矩形を描画してステンシルビットをクリア + const clearLevel = currentClipLevel; // デクリメント前のレベル + const clampedLevel = Math.min(8, Math.max(1, clearLevel)); + const pipelineName = `clip_clear_main_${clampedLevel}`; + const pipeline = this.pipelineManager.getPipeline(pipelineName); + + if (pipeline) { + // ステンシル付きレンダーパスを開始 + // resolveTargetなし: clip_clear_mainはwriteMask=0でcolorを変更しない + const renderPassDescriptor = this.frameBufferManager.createStencilRenderPassDescriptor( + leaveColorView!, + leaveStencilView, + "load", // カラーは保持 + "load" // ステンシルは保持(特定のビットのみクリア) + ); + const passEncoder = this.commandEncoder!.beginRenderPass(renderPassDescriptor); + + // 全画面矩形を描画(ステンシルビットをクリア) + // 17-float vertex buffer format for clip pipelines + // Format: position(2) + bezier(2) + color(4) + matrix(9) = 17 floats + // Matrix is identity: row0=(1,0,0), row1=(0,1,0), row2=(0,0,1) + const meshBuffer = this.bufferManager.acquireVertexBuffer($FULLSCREEN_MESH.byteLength, $FULLSCREEN_MESH); + + passEncoder.setPipeline(pipeline); + passEncoder.setStencilReference(0); // 参照値0でREPLACE + passEncoder.setVertexBuffer(0, meshBuffer); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + } + } + } + +} diff --git a/packages/webgpu/src/Context/service/ContextComputeBitmapMatrixService.test.ts b/packages/webgpu/src/Context/service/ContextComputeBitmapMatrixService.test.ts new file mode 100644 index 00000000..5cf98cb1 --- /dev/null +++ b/packages/webgpu/src/Context/service/ContextComputeBitmapMatrixService.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect } from "vitest"; +import { execute } from "./ContextComputeBitmapMatrixService"; + +describe("ContextComputeBitmapMatrixService", () => +{ + // identity context matrix [a, b, 0, c, d, 0, tx, ty, 1] + const identityContext = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + it("should compute inverse of identity matrix", () => + { + const bitmapMatrix = new Float32Array([1, 0, 0, 1, 0, 0]); // identity + + const result = execute(bitmapMatrix, identityContext); + + // Inverse of identity is identity + expect(result[0]).toBeCloseTo(1, 5); // ia + expect(result[1]).toBeCloseTo(0, 5); // ib + expect(result[2]).toBeCloseTo(0, 5); + expect(result[3]).toBeCloseTo(0, 5); // ic + expect(result[4]).toBeCloseTo(1, 5); // id + expect(result[5]).toBeCloseTo(0, 5); + expect(result[6]).toBeCloseTo(0, 5); // itx + expect(result[7]).toBeCloseTo(0, 5); // ity + expect(result[8]).toBeCloseTo(1, 5); + }); + + it("should compute inverse of scale matrix", () => + { + const bitmapMatrix = new Float32Array([2, 0, 0, 3, 0, 0]); // scale(2, 3) + + const result = execute(bitmapMatrix, identityContext); + + // Inverse of scale(2,3) is scale(0.5, 1/3) + expect(result[0]).toBeCloseTo(0.5, 5); // ia = d / det = 3 / 6 = 0.5 + expect(result[1]).toBeCloseTo(0, 5); // ib + expect(result[3]).toBeCloseTo(0, 5); // ic + expect(result[4]).toBeCloseTo(1 / 3, 5); // id = a / det = 2 / 6 + }); + + it("should compute inverse of translation matrix", () => + { + const bitmapMatrix = new Float32Array([1, 0, 0, 1, 100, 50]); // translate(100, 50) + + const result = execute(bitmapMatrix, identityContext); + + // Inverse of translate(100, 50) is translate(-100, -50) + expect(result[0]).toBeCloseTo(1, 5); + expect(result[4]).toBeCloseTo(1, 5); + expect(result[6]).toBeCloseTo(-100, 5); // itx + expect(result[7]).toBeCloseTo(-50, 5); // ity + }); + + it("should compute inverse of rotation matrix", () => + { + const angle = Math.PI / 4; // 45 degrees + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const bitmapMatrix = new Float32Array([cos, sin, -sin, cos, 0, 0]); + + const result = execute(bitmapMatrix, identityContext); + + // Inverse of rotation is rotation by negative angle + // Output is column-major: [a, b, 0, c, d, 0, tx, ty, 1] + // Inverse rotation matrix: a=cos, b=-sin, c=sin, d=cos + expect(result[0]).toBeCloseTo(cos, 5); // a + expect(result[1]).toBeCloseTo(-sin, 5); // b + expect(result[3]).toBeCloseTo(sin, 5); // c + expect(result[4]).toBeCloseTo(cos, 5); // d + }); + + it("should compute inverse of combined transformation", () => + { + // scale(2, 2) + translate(10, 20) + const bitmapMatrix = new Float32Array([2, 0, 0, 2, 10, 20]); + + const result = execute(bitmapMatrix, identityContext); + + // Verify by checking that M * M^-1 = I conceptually + // Inverse: scale(0.5) then translate(-5, -10) + expect(result[0]).toBeCloseTo(0.5, 5); + expect(result[4]).toBeCloseTo(0.5, 5); + expect(result[6]).toBeCloseTo(-5, 5); // itx = (c*ty - d*tx) / det = (0*20 - 2*10) / 4 = -5 + expect(result[7]).toBeCloseTo(-10, 5); // ity = (b*tx - a*ty) / det = (0*10 - 2*20) / 4 = -10 + }); + + it("should return identity for singular matrix", () => + { + // Singular matrix (determinant = 0) + const bitmapMatrix = new Float32Array([1, 2, 2, 4, 0, 0]); // a*d - b*c = 4 - 4 = 0 + + const result = execute(bitmapMatrix, identityContext); + + // Should return identity matrix + expect(result[0]).toBe(1); + expect(result[1]).toBe(0); + expect(result[2]).toBe(0); + expect(result[3]).toBe(0); + expect(result[4]).toBe(1); + expect(result[5]).toBe(0); + expect(result[6]).toBe(0); + expect(result[7]).toBe(0); + expect(result[8]).toBe(1); + }); + + it("should return identity for near-singular matrix", () => + { + // Near-singular matrix (determinant very close to 0) + const bitmapMatrix = new Float32Array([1e-11, 0, 0, 1e-11, 0, 0]); + + const result = execute(bitmapMatrix, identityContext); + + // Should return identity matrix (det < 1e-10) + expect(result[0]).toBe(1); + expect(result[4]).toBe(1); + expect(result[8]).toBe(1); + }); + + it("should return Float32Array with 9 elements", () => + { + const bitmapMatrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute(bitmapMatrix, identityContext); + + expect(result).toBeInstanceOf(Float32Array); + expect(result.length).toBe(9); + }); + + it("should handle negative scale", () => + { + const bitmapMatrix = new Float32Array([-2, 0, 0, -2, 0, 0]); // scale(-2, -2) + + const result = execute(bitmapMatrix, identityContext); + + expect(result[0]).toBeCloseTo(-0.5, 5); // ia = d / det = -2 / 4 = -0.5 + expect(result[4]).toBeCloseTo(-0.5, 5); // id = a / det = -2 / 4 = -0.5 + }); + + it("should handle skew transformation", () => + { + // Skew matrix: [1, 0.5, 0.5, 1, 0, 0] + const bitmapMatrix = new Float32Array([1, 0.5, 0.5, 1, 0, 0]); + + const result = execute(bitmapMatrix, identityContext); + + // det = 1*1 - 0.5*0.5 = 0.75 + const det = 0.75; + expect(result[0]).toBeCloseTo(1 / det, 5); // ia = d / det + expect(result[1]).toBeCloseTo(-0.5 / det, 5); // ib = -b / det + expect(result[3]).toBeCloseTo(-0.5 / det, 5); // ic = -c / det + expect(result[4]).toBeCloseTo(1 / det, 5); // id = a / det + }); + + it("should compute correct 3x3 matrix format", () => + { + const bitmapMatrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + const result = execute(bitmapMatrix, identityContext); + + // Verify 3x3 matrix structure: + // [ia, ib, 0] + // [ic, id, 0] + // [itx, ity, 1] + expect(result[2]).toBe(0); // row 1, col 3 + expect(result[5]).toBe(0); // row 2, col 3 + expect(result[8]).toBe(1); // row 3, col 3 + }); + + it("should compute correct matrix when bitmap is identity and context has transform", () => + { + // When bitmap is identity, result should be identity: + // inverse(context × identity) × context = inverse(context) × context = identity + const bitmapMatrix = new Float32Array([1, 0, 0, 1, 0, 0]); // identity + const contextMatrix = new Float32Array([2, 0, 0, 0, 2, 0, 50, 100, 1]); // scale(2) + translate(50, 100) + + const result = execute(bitmapMatrix, contextMatrix); + + // Result should be identity + expect(result[0]).toBeCloseTo(1, 5); + expect(result[1]).toBeCloseTo(0, 5); + expect(result[3]).toBeCloseTo(0, 5); + expect(result[4]).toBeCloseTo(1, 5); + expect(result[6]).toBeCloseTo(0, 5); + expect(result[7]).toBeCloseTo(0, 5); + }); + + it("should compute correct matrix for context and bitmap combination", () => + { + // Test: context = scale(2), bitmap = translate(10, 20) + // Formula: inverse(context × bitmap) × context (WebGL's transform order) + // context × bitmap = scale(2) × translate(10, 20) = [2, 0, 0, 2, 20, 40] + // inverse([2, 0, 0, 2, 20, 40]) = [0.5, 0, 0, 0.5, -10, -20] + // inverse × context = [0.5, 0, 0, 0.5, -10, -20] × [2, 0, 0, 2, 0, 0] = [1, 0, 0, 1, -10, -20] + const bitmapMatrix = new Float32Array([1, 0, 0, 1, 10, 20]); // translate(10, 20) + const contextMatrix = new Float32Array([2, 0, 0, 0, 2, 0, 0, 0, 1]); // scale(2) + + const result = execute(bitmapMatrix, contextMatrix); + + // Expected: [1, 0, 0, 1, -10, -20] in 3x3 column-major format + expect(result[0]).toBeCloseTo(1, 5); // a + expect(result[1]).toBeCloseTo(0, 5); // b + expect(result[3]).toBeCloseTo(0, 5); // c + expect(result[4]).toBeCloseTo(1, 5); // d + expect(result[6]).toBeCloseTo(-10, 5); // tx + expect(result[7]).toBeCloseTo(-20, 5); // ty + }); +}); diff --git a/packages/webgpu/src/Context/service/ContextComputeBitmapMatrixService.ts b/packages/webgpu/src/Context/service/ContextComputeBitmapMatrixService.ts new file mode 100644 index 00000000..1d0f48b2 --- /dev/null +++ b/packages/webgpu/src/Context/service/ContextComputeBitmapMatrixService.ts @@ -0,0 +1,81 @@ +export const execute = (bitmap_matrix: Float32Array, context_matrix: Float32Array): Float32Array => { + // ビットマップ行列 [a, b, c, d, tx, ty] + const ba = bitmap_matrix[0]; + const bb = bitmap_matrix[1]; + const bc = bitmap_matrix[2]; + const bd = bitmap_matrix[3]; + const btx = bitmap_matrix[4]; + const bty = bitmap_matrix[5]; + + // コンテキスト行列 [a, b, 0, c, d, 0, tx, ty, 1] + const ca = context_matrix[0]; + const cb = context_matrix[1]; + const cc = context_matrix[3]; + const cd = context_matrix[4]; + const ctx = context_matrix[6]; + const cty = context_matrix[7]; + + // Step 1: コンテキスト行列 × ビットマップ行列 を計算 + // WebGLの$context.transform()と同じ順序: new = context × bitmap + // Flash Matrix乗算: C × B where + // C = [ca, cb, cc, cd, ctx, cty], B = [ba, bb, bc, bd, btx, bty] + // 結果: + // ma = ca*ba + cc*bb (x'のxからの係数) + // mb = ca*bc + cc*bd (x'のyからの係数) + // mc = cb*ba + cd*bb (y'のxからの係数) + // md = cb*bc + cd*bd (y'のyからの係数) + const ma = ca * ba + cc * bb; + const mb = ca * bc + cc * bd; + const mc = cb * ba + cd * bb; + const md = cb * bc + cd * bd; + const mtx = ca * btx + cc * bty + ctx; + const mty = cb * btx + cd * bty + cty; + + // Step 2: 合成行列の逆行列を計算 + const det = ma * md - mb * mc; + if (Math.abs(det) < 1e-10) { + // 特異行列の場合は単位行列を返す + return new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + } + + const invDet = 1 / det; + const ia = md * invDet; + const ib = -mb * invDet; + const ic = -mc * invDet; + const id = ma * invDet; + // Flash Matrix inverse translation: + // inv_tx = (c*ty - d*tx)/det = (mb*mty - md*mtx)/det + // inv_ty = (b*tx - a*ty)/det = (mc*mtx - ma*mty)/det + const itx = (mb * mty - md * mtx) * invDet; + const ity = (mc * mtx - ma * mty) * invDet; + + // Step 3: 逆行列 × コンテキスト行列 を計算 + // 逆行列変換: x' = ia*x + ib*y + itx, y' = ic*x + id*y + ity + // コンテキスト変換: x' = ca*x + cc*y + ctx, y' = cb*x + cd*y + cty + // 合成結果: + // ra = ia*ca + ib*cb (x'のxからの係数) + // rb = ia*cc + ib*cd (x'のyからの係数) + // rc = ic*ca + id*cb (y'のxからの係数) + // rd = ic*cc + id*cd (y'のyからの係数) + const ra = ia * ca + ib * cb; + const rb = ia * cc + ib * cd; + const rc = ic * ca + id * cb; + const rd = ic * cc + id * cd; + const rtx = ia * ctx + ib * cty + itx; + const rty = ic * ctx + id * cty + ity; + + // 結果をFlash Matrix形式に変換 + // ra = x'のxからの係数 = Flash a + // rb = x'のyからの係数 = Flash c + // rc = y'のxからの係数 = Flash b + // rd = y'のyからの係数 = Flash d + + // 列優先形式で出力: col0=(a,b,0), col1=(c,d,0), col2=(tx,ty,1) + // WGSLのmat3x3は列優先で、各列が連続して格納される + // Flash変換: x' = a*x + c*y + tx, y' = b*x + d*y + ty + return new Float32Array([ + ra, rc, 0, // col0: (Flash_a, Flash_b, 0) + rb, rd, 0, // col1: (Flash_c, Flash_d, 0) + rtx, rty, 1 // col2: (tx, ty, 1) + ]); +}; diff --git a/packages/webgpu/src/Context/service/ContextComputeGradientMatrixService.test.ts b/packages/webgpu/src/Context/service/ContextComputeGradientMatrixService.test.ts new file mode 100644 index 00000000..b580d15d --- /dev/null +++ b/packages/webgpu/src/Context/service/ContextComputeGradientMatrixService.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect } from "vitest"; +import { execute } from "./ContextComputeGradientMatrixService"; + +describe("ContextComputeGradientMatrixService", () => +{ + describe("Linear gradient (type = 0)", () => + { + it("should return identity inverse matrix for linear gradient", () => + { + const gradientMatrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 0); + + // For linear gradient, inverseMatrix is always identity + expect(result.inverseMatrix[0]).toBe(1); + expect(result.inverseMatrix[1]).toBe(0); + expect(result.inverseMatrix[2]).toBe(0); + expect(result.inverseMatrix[3]).toBe(0); + expect(result.inverseMatrix[4]).toBe(1); + expect(result.inverseMatrix[5]).toBe(0); + expect(result.inverseMatrix[6]).toBe(0); + expect(result.inverseMatrix[7]).toBe(0); + expect(result.inverseMatrix[8]).toBe(1); + }); + + it("should compute linear points for identity gradient matrix", () => + { + const gradientMatrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 0); + + expect(result.linearPoints).not.toBeNull(); + expect(result.linearPoints).toBeInstanceOf(Float32Array); + expect(result.linearPoints!.length).toBe(4); + }); + + it("should compute linear points with scaled gradient matrix", () => + { + const gradientMatrix = new Float32Array([2, 0, 0, 2, 0, 0]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 0); + + expect(result.linearPoints).not.toBeNull(); + // Points are scaled by factor of 2 + // x0 = -819.2 * 2 - 819.2 * 0 + 0 = -1638.4 + // y0 = -819.2 * 0 - 819.2 * 2 + 0 = -1638.4 + }); + + it("should compute linear points with translated gradient matrix", () => + { + const gradientMatrix = new Float32Array([1, 0, 0, 1, 100, 200]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 0); + + expect(result.linearPoints).not.toBeNull(); + // Translation affects the computed points + }); + + it("should handle rotated gradient matrix for linear", () => + { + const angle = Math.PI / 4; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const gradientMatrix = new Float32Array([cos, sin, -sin, cos, 0, 0]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 0); + + expect(result.linearPoints).not.toBeNull(); + expect(result.inverseMatrix).toBeInstanceOf(Float32Array); + }); + }); + + describe("Radial gradient (type = 1)", () => + { + it("should compute inverse gradient matrix for radial", () => + { + const gradientMatrix = new Float32Array([2, 0, 0, 2, 0, 0]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 1); + + // Inverse of scale(2, 2): scale(0.5, 0.5) + expect(result.inverseMatrix[0]).toBeCloseTo(0.5, 5); // invA = d / det = 2 / 4 + expect(result.inverseMatrix[1]).toBeCloseTo(0, 5); // invB + expect(result.inverseMatrix[3]).toBeCloseTo(0, 5); // invC + expect(result.inverseMatrix[4]).toBeCloseTo(0.5, 5); // invD = a / det = 2 / 4 + expect(result.linearPoints).toBeNull(); + }); + + it("should compute inverse for translated gradient", () => + { + const gradientMatrix = new Float32Array([1, 0, 0, 1, 100, 200]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 1); + + // det = 1 + expect(result.inverseMatrix[0]).toBeCloseTo(1, 5); + expect(result.inverseMatrix[4]).toBeCloseTo(1, 5); + // invTx = (c * ty - d * tx) / det = (0 * 200 - 1 * 100) / 1 = -100 + expect(result.inverseMatrix[6]).toBeCloseTo(-100, 5); + // invTy = (b * tx - a * ty) / det = (0 * 100 - 1 * 200) / 1 = -200 + expect(result.inverseMatrix[7]).toBeCloseTo(-200, 5); + expect(result.linearPoints).toBeNull(); + }); + + it("should return identity for singular gradient matrix", () => + { + const gradientMatrix = new Float32Array([1, 2, 2, 4, 0, 0]); // det = 0 + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 1); + + // Returns identity for singular matrix + expect(result.inverseMatrix[0]).toBe(1); + expect(result.inverseMatrix[1]).toBe(0); + expect(result.inverseMatrix[2]).toBe(0); + expect(result.inverseMatrix[3]).toBe(0); + expect(result.inverseMatrix[4]).toBe(1); + expect(result.inverseMatrix[5]).toBe(0); + expect(result.inverseMatrix[6]).toBe(0); + expect(result.inverseMatrix[7]).toBe(0); + expect(result.inverseMatrix[8]).toBe(1); + expect(result.linearPoints).toBeNull(); + }); + + it("should return identity for near-singular gradient matrix", () => + { + const gradientMatrix = new Float32Array([1e-11, 0, 0, 1e-11, 0, 0]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 1); + + expect(result.inverseMatrix[0]).toBe(1); + expect(result.inverseMatrix[4]).toBe(1); + expect(result.inverseMatrix[8]).toBe(1); + }); + + it("should handle rotated gradient matrix for radial", () => + { + const angle = Math.PI / 2; // 90 degrees + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const gradientMatrix = new Float32Array([cos, sin, -sin, cos, 0, 0]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 1); + + // det = cos^2 + sin^2 = 1 + // Inverse of rotation by 90° is rotation by -90° + expect(result.inverseMatrix[0]).toBeCloseTo(cos, 5); // invA = d / det + expect(result.inverseMatrix[1]).toBeCloseTo(-sin, 5); // invB = -b / det + expect(result.inverseMatrix[3]).toBeCloseTo(sin, 5); // invC = -c / det + expect(result.inverseMatrix[4]).toBeCloseTo(cos, 5); // invD = a / det + }); + + it("should compute inverse for combined scale and translation", () => + { + const gradientMatrix = new Float32Array([2, 0, 0, 3, 10, 20]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 1); + + // det = 2 * 3 - 0 * 0 = 6 + expect(result.inverseMatrix[0]).toBeCloseTo(3 / 6, 5); // invA = d / det = 0.5 + expect(result.inverseMatrix[4]).toBeCloseTo(2 / 6, 5); // invD = a / det = 1/3 + // invTx = (c * ty - d * tx) / det = (0 * 20 - 3 * 10) / 6 = -5 + expect(result.inverseMatrix[6]).toBeCloseTo(-5, 5); + // invTy = (b * tx - a * ty) / det = (0 * 10 - 2 * 20) / 6 = -40/6 + expect(result.inverseMatrix[7]).toBeCloseTo(-40 / 6, 5); + }); + }); + + describe("Return type validation", () => + { + it("should return object with correct properties for linear", () => + { + const gradientMatrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 0); + + expect(result).toHaveProperty("inverseMatrix"); + expect(result).toHaveProperty("linearPoints"); + expect(result.inverseMatrix).toBeInstanceOf(Float32Array); + expect(result.linearPoints).toBeInstanceOf(Float32Array); + }); + + it("should return object with correct properties for radial", () => + { + const gradientMatrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 1); + + expect(result).toHaveProperty("inverseMatrix"); + expect(result).toHaveProperty("linearPoints"); + expect(result.inverseMatrix).toBeInstanceOf(Float32Array); + expect(result.linearPoints).toBeNull(); + }); + + it("should return 9-element inverse matrix", () => + { + const gradientMatrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 1); + + expect(result.inverseMatrix.length).toBe(9); + }); + + it("should return 4-element linear points for linear gradient", () => + { + const gradientMatrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 0); + + expect(result.linearPoints!.length).toBe(4); + }); + }); + + describe("Edge cases", () => + { + it("should handle very large gradient values", () => + { + const gradientMatrix = new Float32Array([1000, 0, 0, 1000, 10000, 10000]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 1); + + expect(result.inverseMatrix).toBeInstanceOf(Float32Array); + expect(result.inverseMatrix[0]).toBeCloseTo(0.001, 5); + }); + + it("should handle very small non-singular gradient values", () => + { + const gradientMatrix = new Float32Array([0.001, 0, 0, 0.001, 0, 0]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 1); + + expect(result.inverseMatrix[0]).toBeCloseTo(1000, 1); + }); + + it("should handle negative gradient scale", () => + { + const gradientMatrix = new Float32Array([-1, 0, 0, -1, 0, 0]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + const result = execute(gradientMatrix, contextMatrix, 1); + + // det = (-1) * (-1) - 0 * 0 = 1 + expect(result.inverseMatrix[0]).toBeCloseTo(-1, 5); + expect(result.inverseMatrix[4]).toBeCloseTo(-1, 5); + }); + + it("should handle zero vector normalization in linear gradient", () => + { + // When vx2 and vy2 are both 0, normalization sets them to 0 + // This happens when x2 = x0 and y2 = y0 + // With gc = 0 and gd = 0, the calculation becomes: + // x2 - x0 = (-819.2 * ga + 819.2 * 0) - (-819.2 * ga - 819.2 * 0) = 0 + // y2 - y0 = (-819.2 * gb + 819.2 * 0) - (-819.2 * gb - 819.2 * 0) = 0 + const gradientMatrix = new Float32Array([1, 0, 0, 0, 0, 0]); + const contextMatrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + expect(() => execute(gradientMatrix, contextMatrix, 0)).not.toThrow(); + }); + }); +}); diff --git a/packages/webgpu/src/Context/service/ContextComputeGradientMatrixService.ts b/packages/webgpu/src/Context/service/ContextComputeGradientMatrixService.ts new file mode 100644 index 00000000..fb90e4e3 --- /dev/null +++ b/packages/webgpu/src/Context/service/ContextComputeGradientMatrixService.ts @@ -0,0 +1,103 @@ +export const execute = ( + gradientMatrix: Float32Array, + _contextMatrix: Float32Array, + type: number +): { inverseMatrix: Float32Array; linearPoints: Float32Array | null } => { + // グラデーション行列 + const ga = gradientMatrix[0]; + const gb = gradientMatrix[1]; + const gc = gradientMatrix[2]; + const gd = gradientMatrix[3]; + const gtx = gradientMatrix[4]; + const gty = gradientMatrix[5]; + + if (type === 0) { + // === Linear gradient === + // WebGL版と同じ: $linearGradientXY(matrix)で点a, bを計算 + // 点を計算(グラデーション行列を使って) + // x0, y0: (-819.2, -819.2)を変換 + // x1, y1: (819.2, -819.2)を変換 + // x2, y2: (-819.2, 819.2)を変換 + const x0 = -819.2 * ga - 819.2 * gc + gtx; + const x1 = 819.2 * ga - 819.2 * gc + gtx; + const x2 = -819.2 * ga + 819.2 * gc + gtx; + const y0 = -819.2 * gb - 819.2 * gd + gty; + const y1 = 819.2 * gb - 819.2 * gd + gty; + const y2 = -819.2 * gb + 819.2 * gd + gty; + + let vx2 = x2 - x0; + let vy2 = y2 - y0; + + const r1 = Math.sqrt(vx2 * vx2 + vy2 * vy2); + if (r1) { + vx2 = vx2 / r1; + vy2 = vy2 / r1; + } else { + vx2 = 0; + vy2 = 0; + } + + const r2 = (x1 - x0) * vx2 + (y1 - y0) * vy2; + + // 点a, b(グラデーションの始点と終点) + // これらはグラデーション行列で変換された座標空間にある + const linearPoints = new Float32Array([ + x0 + r2 * vx2, y0 + r2 * vy2, // 点a + x1, y1 // 点b + ]); + + // WebGL版と同じ: + // v_uv = (inverse_matrix * uv_matrix * position).xy + // ここで inverse_matrix = inverse(context), uv_matrix = context + // つまり v_uv = inverse(context) * context * position = position (生の頂点座標) + // + // linearPointsはグラデーション行列変換後の座標空間にあり、 + // フラグメントシェーダーで p = v_uv (生の頂点座標) と linearPoints を使って + // t = dot(ab, ap) / dot(ab, ab) を計算する + // + // シェーダーでは v_uv = inverseMatrix * position なので、 + // inverseMatrix = 単位行列 にすることで v_uv = position になる + const inverseMatrix = new Float32Array([ + 1, 0, 0, + 0, 1, 0, + 0, 0, 1 + ]); + + return { inverseMatrix, linearPoints }; + } + // === Radial gradient === + // WebGPU版: グラデーション行列の逆行列のみを使用 + // シェーダーでは v_uv = inverse(gradient) * position + // これにより、ローカル座標をグラデーション空間(-819.2 to 819.2)に変換 + // + // 注意: WebGL版とは異なり、contextMatrixにはアトラスオフセットが含まれているため、 + // contextMatrixを使った合成は行わない + + // グラデーション行列の行列式 + const det = ga * gd - gb * gc; + if (Math.abs(det) < 1e-10) { + return { + "inverseMatrix": new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + "linearPoints": null + }; + } + + const invDet = 1 / det; + + // inverse(gradient) を計算 + const invA = gd * invDet; + const invB = -gb * invDet; + const invC = -gc * invDet; + const invD = ga * invDet; + const invTx = (gc * gty - gd * gtx) * invDet; + const invTy = (gb * gtx - ga * gty) * invDet; + + // 逆行列(シェーダーでは v_uv = inverseMatrix * position) + const inverseMatrix = new Float32Array([ + invA, invB, 0, + invC, invD, 0, + invTx, invTy, 1 + ]); + + return { inverseMatrix, "linearPoints": null }; +}; diff --git a/packages/webgpu/src/Context/service/ContextFillSimpleService.test.ts b/packages/webgpu/src/Context/service/ContextFillSimpleService.test.ts new file mode 100644 index 00000000..3c771fb6 --- /dev/null +++ b/packages/webgpu/src/Context/service/ContextFillSimpleService.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import { execute } from "./ContextFillSimpleService"; + +// Mock Mask module +vi.mock("../../Mask", () => ({ + "$isMaskDrawing": vi.fn(() => false), + "$getMaskStencilReference": vi.fn(() => 5) +})); + +import { $isMaskDrawing, $getMaskStencilReference } from "../../Mask"; + +describe("ContextFillSimpleService", () => +{ + const createMockRenderPassEncoder = () => + { + return { + "setPipeline": vi.fn(), + "setVertexBuffer": vi.fn(), + "setBindGroup": vi.fn(), + "setStencilReference": vi.fn(), + "draw": vi.fn() + } as unknown as GPURenderPassEncoder; + }; + + const createMockPipelineManager = (hasPipeline: boolean = true) => + { + return { + "getPipeline": vi.fn(() => hasPipeline ? { "label": "mockPipeline" } : null), + "getBindGroupLayout": vi.fn(() => ({ "label": "mockLayout" })) + } as unknown as PipelineManager; + }; + + const createMockVertexBuffer = () => + { + return { "label": "mockVertexBuffer" } as unknown as GPUBuffer; + }; + + const createMockBindGroup = () => + { + return { "label": "mockBindGroup" } as unknown as GPUBindGroup; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + (($isMaskDrawing as unknown) as ReturnType).mockReturnValue(false); + }); + + describe("pipeline selection", () => + { + it("should use fill pipeline for atlas target", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0, true); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("fill"); + }); + + it("should use fill_bgra pipeline for canvas target", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0, false); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("fill_bgra"); + }); + + it("should use fill_bgra_stencil pipeline when useStencilPipeline and not mask drawing", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + (($isMaskDrawing as unknown) as ReturnType).mockReturnValue(false); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0, false, true); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("fill_bgra_stencil"); + }); + + it("should return early when mask drawing mode and useStencilPipeline", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + (($isMaskDrawing as unknown) as ReturnType).mockReturnValue(true); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0, false, true); + + expect(renderPassEncoder.draw).not.toHaveBeenCalled(); + }); + + it("should return early when pipeline not found", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(false); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0, true); + + expect(console.error).toHaveBeenCalled(); + expect(renderPassEncoder.draw).not.toHaveBeenCalled(); + }); + }); + + describe("drawing", () => + { + it("should set pipeline", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0, true); + + expect(renderPassEncoder.setPipeline).toHaveBeenCalled(); + }); + + it("should set vertex buffer", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0, true); + + expect(renderPassEncoder.setVertexBuffer).toHaveBeenCalledWith(0, vertexBuffer); + }); + + it("should set bind group with dynamic offset", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 256, true); + + expect(renderPassEncoder.setBindGroup).toHaveBeenCalledWith(0, bindGroup, [256]); + }); + + it("should draw with correct vertex count", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 24, bindGroup, 0, true); + + expect(renderPassEncoder.draw).toHaveBeenCalledWith(24, 1, 0, 0); + }); + }); + + describe("stencil reference", () => + { + it("should set stencil reference for stencil pipeline mode", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + (($isMaskDrawing as unknown) as ReturnType).mockReturnValue(false); + (($getMaskStencilReference as unknown) as ReturnType).mockReturnValue(7); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0, false, true); + + expect(renderPassEncoder.setStencilReference).toHaveBeenCalledWith(7); + }); + + it("should not set stencil reference for atlas target", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0, true, true); + + expect(renderPassEncoder.setStencilReference).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/webgpu/src/Context/service/ContextFillSimpleService.ts b/packages/webgpu/src/Context/service/ContextFillSimpleService.ts new file mode 100644 index 00000000..b6c48365 --- /dev/null +++ b/packages/webgpu/src/Context/service/ContextFillSimpleService.ts @@ -0,0 +1,42 @@ +import type { PipelineManager } from "../../Shader/PipelineManager"; +import { $isMaskDrawing, $getMaskStencilReference } from "../../Mask"; + +export const execute = ( + render_pass_encoder: GPURenderPassEncoder, + pipeline_manager: PipelineManager, + vertex_buffer: GPUBuffer, + vertex_count: number, + bind_group: GPUBindGroup, + uniform_offset: number, + use_atlas_target: boolean, + use_stencil_pipeline: boolean = false, + _clip_level: number = 1 +): void => { + + let pipelineName: string; + if (use_atlas_target) { + pipelineName = "fill"; + } else if (use_stencil_pipeline) { + if ($isMaskDrawing()) { + return; + } + pipelineName = "fill_bgra_stencil"; + } else { + pipelineName = "fill_bgra"; + } + const pipeline = pipeline_manager.getPipeline(pipelineName); + if (!pipeline) { + console.error(`[WebGPU] ${pipelineName} pipeline not found`); + return; + } + + render_pass_encoder.setPipeline(pipeline); + render_pass_encoder.setVertexBuffer(0, vertex_buffer); + render_pass_encoder.setBindGroup(0, bind_group, [uniform_offset]); + + if (use_stencil_pipeline && !use_atlas_target && !$isMaskDrawing()) { + render_pass_encoder.setStencilReference($getMaskStencilReference()); + } + + render_pass_encoder.draw(vertex_count, 1, 0, 0); +}; diff --git a/packages/webgpu/src/Context/service/ContextFillWithStencilMainService.ts b/packages/webgpu/src/Context/service/ContextFillWithStencilMainService.ts new file mode 100644 index 00000000..ce39f071 --- /dev/null +++ b/packages/webgpu/src/Context/service/ContextFillWithStencilMainService.ts @@ -0,0 +1,29 @@ +import type { PipelineManager } from "../../Shader/PipelineManager"; + +export const execute = ( + render_pass_encoder: GPURenderPassEncoder, + pipeline_manager: PipelineManager, + vertex_buffer: GPUBuffer, + vertex_count: number, + bind_group: GPUBindGroup, + uniform_offset: number +): void => { + // === Pass 1: ステンシル書き込み(両面を1回で処理) === + const stencilWritePipeline = pipeline_manager.getPipeline("stencil_write_main"); + if (stencilWritePipeline) { + render_pass_encoder.setPipeline(stencilWritePipeline); + render_pass_encoder.setStencilReference(0); + render_pass_encoder.setVertexBuffer(0, vertex_buffer); + render_pass_encoder.setBindGroup(0, bind_group, [uniform_offset]); + render_pass_encoder.draw(vertex_count, 1, 0, 0); + } + + // === Pass 2: ステンシルフィル(色描画) === + const fillPipeline = pipeline_manager.getPipeline("stencil_fill_main"); + if (fillPipeline) { + render_pass_encoder.setPipeline(fillPipeline); + render_pass_encoder.setStencilReference(0); + render_pass_encoder.setBindGroup(0, bind_group, [uniform_offset]); + render_pass_encoder.draw(vertex_count, 1, 0, 0); + } +}; diff --git a/packages/webgpu/src/Context/service/ContextFillWithStencilService.test.ts b/packages/webgpu/src/Context/service/ContextFillWithStencilService.test.ts new file mode 100644 index 00000000..1fd885e8 --- /dev/null +++ b/packages/webgpu/src/Context/service/ContextFillWithStencilService.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import { execute } from "./ContextFillWithStencilService"; + +describe("ContextFillWithStencilService", () => +{ + const createMockRenderPassEncoder = () => + { + return { + "setPipeline": vi.fn(), + "setVertexBuffer": vi.fn(), + "setStencilReference": vi.fn(), + "setBindGroup": vi.fn(), + "draw": vi.fn() + } as unknown as GPURenderPassEncoder; + }; + + const createMockPipelineManager = ( + hasStencilWrite: boolean = true, + hasStencilFill: boolean = true + ) => + { + return { + "getPipeline": vi.fn((name: string) => { + if (name === "stencil_write_atlas" && hasStencilWrite) { + return { "label": "stencil_write_atlas" }; + } + if (name === "stencil_fill_atlas" && hasStencilFill) { + return { "label": "stencil_fill_atlas" }; + } + return null; + }) + } as unknown as PipelineManager; + }; + + const createMockVertexBuffer = () => + { + return { "label": "mockVertexBuffer" } as unknown as GPUBuffer; + }; + + const createMockBindGroup = () => + { + return { "label": "mockBindGroup" } as unknown as GPUBindGroup; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + }); + + describe("pass 1: stencil write", () => + { + it("should get stencil_write_atlas pipeline", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("stencil_write_atlas"); + }); + + it("should set stencil write pipeline", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0); + + const setPipelineCalls = (renderPassEncoder.setPipeline as ReturnType).mock.calls; + expect(setPipelineCalls[0][0]).toHaveProperty("label", "stencil_write_atlas"); + }); + + it("should set stencil reference to 0 for write pass", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0); + + expect(renderPassEncoder.setStencilReference).toHaveBeenCalledWith(0); + }); + + it("should set vertex buffer for write pass", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0); + + expect(renderPassEncoder.setVertexBuffer).toHaveBeenCalledWith(0, vertexBuffer); + }); + + it("should set bind group with dynamic offset for write pass", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 512); + + expect(renderPassEncoder.setBindGroup).toHaveBeenCalledWith(0, bindGroup, [512]); + }); + + it("should draw with correct vertex count for write pass", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 24, bindGroup, 0); + + expect(renderPassEncoder.draw).toHaveBeenCalledWith(24, 1, 0, 0); + }); + + it("should skip write pass when pipeline not found", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(false, true); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0); + + // setPipeline should only be called once (for fill pass) + expect(renderPassEncoder.setPipeline).toHaveBeenCalledTimes(1); + }); + }); + + describe("pass 2: stencil fill", () => + { + it("should get stencil_fill_atlas pipeline", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("stencil_fill_atlas"); + }); + + it("should set stencil fill pipeline", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0); + + const setPipelineCalls = (renderPassEncoder.setPipeline as ReturnType).mock.calls; + expect(setPipelineCalls[1][0]).toHaveProperty("label", "stencil_fill_atlas"); + }); + + it("should set stencil reference to 0 for fill pass", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0); + + expect(renderPassEncoder.setStencilReference).toHaveBeenCalledWith(0); + expect(renderPassEncoder.setStencilReference).toHaveBeenCalledTimes(2); + }); + + it("should skip fill pass when pipeline not found", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(true, false); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0); + + expect(renderPassEncoder.setPipeline).toHaveBeenCalledTimes(1); + }); + }); + + describe("both passes", () => + { + it("should execute both passes in order", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0); + + expect(pipelineManager.getPipeline).toHaveBeenCalledTimes(2); + expect(renderPassEncoder.setPipeline).toHaveBeenCalledTimes(2); + expect(renderPassEncoder.setVertexBuffer).toHaveBeenCalledTimes(1); + expect(renderPassEncoder.draw).toHaveBeenCalledTimes(2); + }); + + it("should draw same vertex count for both passes", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 36, bindGroup, 0); + + const drawCalls = (renderPassEncoder.draw as ReturnType).mock.calls; + expect(drawCalls[0][0]).toBe(36); + expect(drawCalls[1][0]).toBe(36); + }); + }); + + describe("edge cases", () => + { + it("should handle zero vertex count", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 0, bindGroup, 0); + + expect(renderPassEncoder.draw).toHaveBeenCalledWith(0, 1, 0, 0); + }); + + it("should continue when only one pipeline exists", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(true, false); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0); + + expect(renderPassEncoder.draw).toHaveBeenCalledTimes(1); + }); + + it("should do nothing when no pipelines exist", () => + { + const renderPassEncoder = createMockRenderPassEncoder(); + const pipelineManager = createMockPipelineManager(false, false); + const vertexBuffer = createMockVertexBuffer(); + const bindGroup = createMockBindGroup(); + + execute(renderPassEncoder, pipelineManager, vertexBuffer, 12, bindGroup, 0); + + expect(renderPassEncoder.draw).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/webgpu/src/Context/service/ContextFillWithStencilService.ts b/packages/webgpu/src/Context/service/ContextFillWithStencilService.ts new file mode 100644 index 00000000..d6a68ae9 --- /dev/null +++ b/packages/webgpu/src/Context/service/ContextFillWithStencilService.ts @@ -0,0 +1,29 @@ +import type { PipelineManager } from "../../Shader/PipelineManager"; + +export const execute = ( + render_pass_encoder: GPURenderPassEncoder, + pipeline_manager: PipelineManager, + vertex_buffer: GPUBuffer, + vertex_count: number, + bind_group: GPUBindGroup, + uniform_offset: number +): void => { + // === Pass 1: ステンシル書き込み(両面を1回で処理) === + const stencilWritePipeline = pipeline_manager.getPipeline("stencil_write_atlas"); + if (stencilWritePipeline) { + render_pass_encoder.setPipeline(stencilWritePipeline); + render_pass_encoder.setStencilReference(0); + render_pass_encoder.setVertexBuffer(0, vertex_buffer); + render_pass_encoder.setBindGroup(0, bind_group, [uniform_offset]); + render_pass_encoder.draw(vertex_count, 1, 0, 0); + } + + // === Pass 2: ステンシルフィル(色描画) === + const fillPipeline = pipeline_manager.getPipeline("stencil_fill_atlas"); + if (fillPipeline) { + render_pass_encoder.setPipeline(fillPipeline); + render_pass_encoder.setStencilReference(0); + render_pass_encoder.setBindGroup(0, bind_group, [uniform_offset]); + render_pass_encoder.draw(vertex_count, 1, 0, 0); + } +}; diff --git a/packages/webgpu/src/Context/usecase/ContextApplyFilterUseCase.test.ts b/packages/webgpu/src/Context/usecase/ContextApplyFilterUseCase.test.ts new file mode 100644 index 00000000..15412bc1 --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextApplyFilterUseCase.test.ts @@ -0,0 +1,557 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Node } from "@next2d/texture-packer"; +import type { FrameBufferManager } from "../../FrameBufferManager"; +import type { TextureManager } from "../../TextureManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import type { BufferManager } from "../../BufferManager"; +import { execute } from "./ContextApplyFilterUseCase"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + UNIFORM: 0x40, + COPY_DST: 0x08 +}; +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +// Mock Filter offset +vi.mock("../../Filter/FilterOffset", () => ({ + "$offset": { "x": 0, "y": 0 } +})); + +// Mock WebGPUUtil +vi.mock("../../WebGPUUtil", () => ({ + "WebGPUUtil": { + "getDevicePixelRatio": vi.fn(() => 1) + } +})); + +// Mock AtlasManager +vi.mock("../../AtlasManager", () => ({ + "$getAtlasAttachmentObject": vi.fn(() => null) +})); + +// Mock BlendApplyComplexBlendUseCase +vi.mock("../../Blend/usecase/BlendApplyComplexBlendUseCase", () => ({ + "execute": vi.fn((attachment) => attachment) +})); + +// Mock all filter usecases +vi.mock("../../Filter/BlurFilter/FilterApplyBlurFilterUseCase", () => ({ + "execute": vi.fn((attachment) => attachment) +})); + +vi.mock("../../Filter/ColorMatrixFilter/FilterApplyColorMatrixFilterUseCase", () => ({ + "execute": vi.fn((attachment) => attachment) +})); + +vi.mock("../../Filter/GlowFilter/FilterApplyGlowFilterUseCase", () => ({ + "execute": vi.fn((attachment) => attachment) +})); + +vi.mock("../../Filter/DropShadowFilter/FilterApplyDropShadowFilterUseCase", () => ({ + "execute": vi.fn((attachment) => attachment) +})); + +vi.mock("../../Filter/BevelFilter/FilterApplyBevelFilterUseCase", () => ({ + "execute": vi.fn((attachment) => attachment) +})); + +vi.mock("../../Filter/ConvolutionFilter/FilterApplyConvolutionFilterUseCase", () => ({ + "execute": vi.fn((attachment) => attachment) +})); + +vi.mock("../../Filter/GradientBevelFilter/FilterApplyGradientBevelFilterUseCase", () => ({ + "execute": vi.fn((attachment) => attachment) +})); + +vi.mock("../../Filter/GradientGlowFilter/FilterApplyGradientGlowFilterUseCase", () => ({ + "execute": vi.fn((attachment) => attachment) +})); + +vi.mock("../../Filter/DisplacementMapFilter/FilterApplyDisplacementMapFilterUseCase", () => ({ + "execute": vi.fn((attachment) => attachment) +})); + +import { execute as filterApplyBlurFilterUseCase } from "../../Filter/BlurFilter/FilterApplyBlurFilterUseCase"; +import { execute as filterApplyColorMatrixFilterUseCase } from "../../Filter/ColorMatrixFilter/FilterApplyColorMatrixFilterUseCase"; +import { execute as filterApplyGlowFilterUseCase } from "../../Filter/GlowFilter/FilterApplyGlowFilterUseCase"; +import { execute as filterApplyDropShadowFilterUseCase } from "../../Filter/DropShadowFilter/FilterApplyDropShadowFilterUseCase"; +import { execute as filterApplyBevelFilterUseCase } from "../../Filter/BevelFilter/FilterApplyBevelFilterUseCase"; + +describe("ContextApplyFilterUseCase", () => +{ + const createMockNode = (): Node => + { + return { + "x": 10, + "y": 20, + "w": 100, + "h": 80 + } as Node; + }; + + const createMockDevice = () => + { + return { + "createBuffer": vi.fn(() => ({ "label": "mockBuffer" })), + "queue": { + "writeBuffer": vi.fn() + }, + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })) + } as unknown as GPUDevice; + }; + + const createMockCommandEncoder = () => + { + const mockPassEncoder = { + "setPipeline": vi.fn(), + "setBindGroup": vi.fn(), + "setViewport": vi.fn(), + "setScissorRect": vi.fn(), + "draw": vi.fn(), + "end": vi.fn() + }; + return { + "copyTextureToTexture": vi.fn(), + "beginRenderPass": vi.fn(() => mockPassEncoder), + "_mockPassEncoder": mockPassEncoder + } as unknown as GPUCommandEncoder & { _mockPassEncoder: any }; + }; + + const createMockFrameBufferManager = () => + { + const mockAttachment = { + "id": 1, + "width": 100, + "height": 80, + "texture": { + "resource": { "label": "tempTexture" } as unknown as GPUTexture, + "view": { "label": "tempTextureView" } as unknown as GPUTextureView + } + }; + return { + "createTemporaryAttachment": vi.fn(() => mockAttachment), + "releaseTemporaryAttachment": vi.fn(), + "getAttachment": vi.fn((name: string) => { + if (name === "atlas") { + return { + "texture": { + "resource": { "label": "atlasTexture" } as unknown as GPUTexture, + "view": { "label": "atlasTextureView" } as unknown as GPUTextureView + } + }; + } + if (name === "main") { + return { + "width": 800, + "height": 600, + "texture": { + "resource": { "label": "mainTexture" } as unknown as GPUTexture, + "view": { "label": "mainTextureView" } as unknown as GPUTextureView + } + }; + } + return null; + }), + "createRenderPassDescriptor": vi.fn(() => ({ "label": "mockDescriptor" })) + } as unknown as FrameBufferManager; + }; + + const createMockPipelineManager = () => + { + return { + "getPipeline": vi.fn(() => ({ "label": "mockPipeline" })), + "getBindGroupLayout": vi.fn(() => ({ "label": "mockLayout" })) + } as unknown as PipelineManager; + }; + + const createMockTextureManager = () => + { + return { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })) + } as unknown as TextureManager; + }; + + const createMockBufferManager = () => + { + return { + "acquireUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })), + "acquireAndWriteUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })) + } as unknown as BufferManager; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("filter type dispatch", () => + { + it("should apply blur filter (type 1)", () => + { + const node = createMockNode(); + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const frameBufferManager = createMockFrameBufferManager(); + const pipelineManager = createMockPipelineManager(); + const textureManager = createMockTextureManager(); + const bufferManager = createMockBufferManager(); + + const config = { + device, + commandEncoder, + bufferManager, + frameBufferManager, + pipelineManager, + textureManager + }; + + // Type 1 = Blur filter, blurX, blurY, quality + const params = new Float32Array([1, 5, 5, 2]); + + execute( + node, + 100, 80, + false, + new Float32Array([1, 0, 0, 1, 0, 0]), + new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]), + "normal", + new Float32Array([0, 0, 100, 80]), + params, + config, + { "label": "mainTextureView" } as unknown as GPUTextureView, + bufferManager + ); + + expect(filterApplyBlurFilterUseCase).toHaveBeenCalled(); + }); + + it("should apply color matrix filter (type 2)", () => + { + const node = createMockNode(); + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const frameBufferManager = createMockFrameBufferManager(); + const pipelineManager = createMockPipelineManager(); + const textureManager = createMockTextureManager(); + const bufferManager = createMockBufferManager(); + + const config = { + device, + commandEncoder, + bufferManager, + frameBufferManager, + pipelineManager, + textureManager + }; + + // Type 2 = ColorMatrix filter, 20 matrix values + const params = new Float32Array([ + 2, // type + 1, 0, 0, 0, 0, // row 1 + 0, 1, 0, 0, 0, // row 2 + 0, 0, 1, 0, 0, // row 3 + 0, 0, 0, 1, 0 // row 4 + ]); + + execute( + node, + 100, 80, + false, + new Float32Array([1, 0, 0, 1, 0, 0]), + new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]), + "normal", + new Float32Array([0, 0, 100, 80]), + params, + config, + { "label": "mainTextureView" } as unknown as GPUTextureView, + bufferManager + ); + + expect(filterApplyColorMatrixFilterUseCase).toHaveBeenCalled(); + }); + + it("should apply glow filter (type 6)", () => + { + const node = createMockNode(); + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const frameBufferManager = createMockFrameBufferManager(); + const pipelineManager = createMockPipelineManager(); + const textureManager = createMockTextureManager(); + const bufferManager = createMockBufferManager(); + + const config = { + device, + commandEncoder, + bufferManager, + frameBufferManager, + pipelineManager, + textureManager + }; + + // Type 6 = Glow filter + // color, alpha, blurX, blurY, strength, quality, inner, knockout + const params = new Float32Array([6, 0xFF0000, 1, 5, 5, 2, 2, 0, 0]); + + execute( + node, + 100, 80, + false, + new Float32Array([1, 0, 0, 1, 0, 0]), + new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]), + "normal", + new Float32Array([0, 0, 100, 80]), + params, + config, + { "label": "mainTextureView" } as unknown as GPUTextureView, + bufferManager + ); + + expect(filterApplyGlowFilterUseCase).toHaveBeenCalled(); + }); + + it("should apply drop shadow filter (type 5)", () => + { + const node = createMockNode(); + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const frameBufferManager = createMockFrameBufferManager(); + const pipelineManager = createMockPipelineManager(); + const textureManager = createMockTextureManager(); + const bufferManager = createMockBufferManager(); + + const config = { + device, + commandEncoder, + bufferManager, + frameBufferManager, + pipelineManager, + textureManager + }; + + // Type 5 = DropShadow filter + // distance, angle, color, alpha, blurX, blurY, strength, quality, inner, knockout, hideObject + const params = new Float32Array([5, 4, 45, 0x000000, 1, 5, 5, 1, 2, 0, 0, 0]); + + execute( + node, + 100, 80, + false, + new Float32Array([1, 0, 0, 1, 0, 0]), + new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]), + "normal", + new Float32Array([0, 0, 100, 80]), + params, + config, + { "label": "mainTextureView" } as unknown as GPUTextureView, + bufferManager + ); + + expect(filterApplyDropShadowFilterUseCase).toHaveBeenCalled(); + }); + + it("should apply bevel filter (type 0)", () => + { + const node = createMockNode(); + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const frameBufferManager = createMockFrameBufferManager(); + const pipelineManager = createMockPipelineManager(); + const textureManager = createMockTextureManager(); + const bufferManager = createMockBufferManager(); + + const config = { + device, + commandEncoder, + bufferManager, + frameBufferManager, + pipelineManager, + textureManager + }; + + // Type 0 = Bevel filter + // distance, angle, highlightColor, highlightAlpha, shadowColor, shadowAlpha, blurX, blurY, strength, quality, type, knockout + const params = new Float32Array([0, 4, 45, 0xFFFFFF, 1, 0x000000, 1, 4, 4, 1, 2, 0, 0]); + + execute( + node, + 100, 80, + false, + new Float32Array([1, 0, 0, 1, 0, 0]), + new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]), + "normal", + new Float32Array([0, 0, 100, 80]), + params, + config, + { "label": "mainTextureView" } as unknown as GPUTextureView, + bufferManager + ); + + expect(filterApplyBevelFilterUseCase).toHaveBeenCalled(); + }); + }); + + describe("texture handling", () => + { + it("should create temporary attachment from node", () => + { + const node = createMockNode(); + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const frameBufferManager = createMockFrameBufferManager(); + const pipelineManager = createMockPipelineManager(); + const textureManager = createMockTextureManager(); + const bufferManager = createMockBufferManager(); + + const config = { + device, + commandEncoder, + bufferManager, + frameBufferManager, + pipelineManager, + textureManager + }; + + execute( + node, + 100, 80, + false, + new Float32Array([1, 0, 0, 1, 0, 0]), + new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]), + "normal", + new Float32Array([0, 0, 100, 80]), + new Float32Array([1, 5, 5, 2]), // blur filter + config, + { "label": "mainTextureView" } as unknown as GPUTextureView, + bufferManager + ); + + expect(frameBufferManager.createTemporaryAttachment).toHaveBeenCalledWith(100, 80); + }); + + it("should copy texture from atlas", () => + { + const node = createMockNode(); + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const frameBufferManager = createMockFrameBufferManager(); + const pipelineManager = createMockPipelineManager(); + const textureManager = createMockTextureManager(); + const bufferManager = createMockBufferManager(); + + const config = { + device, + commandEncoder, + bufferManager, + frameBufferManager, + pipelineManager, + textureManager + }; + + execute( + node, + 100, 80, + false, + new Float32Array([1, 0, 0, 1, 0, 0]), + new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]), + "normal", + new Float32Array([0, 0, 100, 80]), + new Float32Array([1, 5, 5, 2]), + config, + { "label": "mainTextureView" } as unknown as GPUTextureView, + bufferManager + ); + + expect(commandEncoder.copyTextureToTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "origin": { "x": 10, "y": 20, "z": 0 } + }), + expect.objectContaining({ + "origin": { "x": 0, "y": 0, "z": 0 } + }), + expect.objectContaining({ + "width": 100, + "height": 80 + }) + ); + }); + + it("should release temporary attachment after use", () => + { + const node = createMockNode(); + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const frameBufferManager = createMockFrameBufferManager(); + const pipelineManager = createMockPipelineManager(); + const textureManager = createMockTextureManager(); + const bufferManager = createMockBufferManager(); + + const config = { + device, + commandEncoder, + bufferManager, + frameBufferManager, + pipelineManager, + textureManager + }; + + execute( + node, + 100, 80, + false, + new Float32Array([1, 0, 0, 1, 0, 0]), + new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]), + "normal", + new Float32Array([0, 0, 100, 80]), + new Float32Array([1, 5, 5, 2]), + config, + { "label": "mainTextureView" } as unknown as GPUTextureView, + bufferManager + ); + + expect(frameBufferManager.releaseTemporaryAttachment).toHaveBeenCalled(); + }); + }); + + describe("drawing to main", () => + { + it("should draw filter result to main attachment", () => + { + const node = createMockNode(); + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const frameBufferManager = createMockFrameBufferManager(); + const pipelineManager = createMockPipelineManager(); + const textureManager = createMockTextureManager(); + const bufferManager = createMockBufferManager(); + + const config = { + device, + commandEncoder, + bufferManager, + frameBufferManager, + pipelineManager, + textureManager + }; + + execute( + node, + 100, 80, + false, + new Float32Array([1, 0, 0, 1, 0, 0]), + new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]), + "normal", + new Float32Array([0, 0, 100, 80]), + new Float32Array([1, 5, 5, 2]), + config, + { "label": "mainTextureView" } as unknown as GPUTextureView, + bufferManager + ); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("filter_output"); + expect(commandEncoder._mockPassEncoder.draw).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/webgpu/src/Context/usecase/ContextApplyFilterUseCase.ts b/packages/webgpu/src/Context/usecase/ContextApplyFilterUseCase.ts new file mode 100644 index 00000000..37b789c3 --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextApplyFilterUseCase.ts @@ -0,0 +1,937 @@ +import type { Node } from "@next2d/texture-packer"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IBlendMode } from "../../interface/IBlendMode"; +import type { ILocalFilterConfig } from "../../interface/ILocalFilterConfig"; +import type { BufferManager } from "../../BufferManager"; +import type { FrameBufferManager } from "../../FrameBufferManager"; +import { $getAtlasAttachmentObject } from "../../AtlasManager"; +import { $offset } from "../../Filter/FilterOffset"; +import { WebGPUUtil } from "../../WebGPUUtil"; +import { execute as filterApplyBlurFilterUseCase } from "../../Filter/BlurFilter/FilterApplyBlurFilterUseCase"; +import { execute as filterApplyColorMatrixFilterUseCase } from "../../Filter/ColorMatrixFilter/FilterApplyColorMatrixFilterUseCase"; +import { execute as filterApplyGlowFilterUseCase } from "../../Filter/GlowFilter/FilterApplyGlowFilterUseCase"; +import { execute as filterApplyDropShadowFilterUseCase } from "../../Filter/DropShadowFilter/FilterApplyDropShadowFilterUseCase"; +import { execute as filterApplyBevelFilterUseCase } from "../../Filter/BevelFilter/FilterApplyBevelFilterUseCase"; +import { execute as filterApplyConvolutionFilterUseCase } from "../../Filter/ConvolutionFilter/FilterApplyConvolutionFilterUseCase"; +import { execute as filterApplyGradientBevelFilterUseCase } from "../../Filter/GradientBevelFilter/FilterApplyGradientBevelFilterUseCase"; +import { execute as filterApplyGradientGlowFilterUseCase } from "../../Filter/GradientGlowFilter/FilterApplyGradientGlowFilterUseCase"; +import { execute as filterApplyDisplacementMapFilterUseCase } from "../../Filter/DisplacementMapFilter/FilterApplyDisplacementMapFilterUseCase"; +import { execute as blendApplyComplexBlendUseCase } from "../../Blend/usecase/BlendApplyComplexBlendUseCase"; + +const $uniform4 = new Float32Array(4); +const $uniform6a = new Float32Array(6); +const $uniform6b = new Float32Array(6); +const $uniform8 = new Float32Array(8); +const $uniform12 = new Float32Array(12); +const $uniform20 = new Float32Array(20); + +// プリアロケート BindGroup Entry 配列 +const $entries3: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView } +]; + +const SIMPLE_BLEND_MODES: ReadonlySet = new Set([ + "normal", "layer", "add", "screen", "alpha", "erase", "copy" +] as IBlendMode[]); + +const Y_FLIP_UNIFORM = new Float32Array([1, -1, 0, 1]); + +const isIdentityColorTransform = (ct: Float32Array): boolean => { + return ct[0] === 1 && ct[1] === 1 && ct[2] === 1 && ct[3] === 1 + && ct[4] === 0 && ct[5] === 0 && ct[6] === 0 && ct[7] === 0; +}; + +const applyColorTransform = ( + config: ILocalFilterConfig, + attachment: IAttachmentObject, + colorTransform: Float32Array +): IAttachmentObject => { + const ctAttachment = config.frameBufferManager.createTemporaryAttachment( + attachment.width, attachment.height + ); + + const pipeline = config.pipelineManager.getPipeline("color_transform"); + const bindGroupLayout = config.pipelineManager.getBindGroupLayout("texture_copy"); + + if (!pipeline || !bindGroupLayout || !attachment.texture || !ctAttachment.texture) { + return attachment; + } + + // uniform: mul(vec4) + add(vec4) = 32 bytes + // add値は0-255スケールの生値をそのまま渡す(WebGLのフィルターCTパスと同じ) + $uniform8[0] = colorTransform[0]; + $uniform8[1] = colorTransform[1]; + $uniform8[2] = colorTransform[2]; + $uniform8[3] = colorTransform[3]; + $uniform8[4] = colorTransform[4]; + $uniform8[5] = colorTransform[5]; + $uniform8[6] = colorTransform[6]; + $uniform8[7] = 0; + const uniformBuffer = config.bufferManager.acquireAndWriteUniformBuffer($uniform8); + + const sampler = config.textureManager.createSampler("color_transform_sampler", false); + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = attachment.texture.view; + const bindGroup = config.device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + const renderPassDescriptor = config.frameBufferManager.createRenderPassDescriptor( + ctAttachment.texture.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = config.commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + return ctAttachment; +}; + +const getTextureFromNode = ( + node: Node, + command_encoder: GPUCommandEncoder, + frame_buffer_manager: FrameBufferManager +): IAttachmentObject => { + // 一時アタッチメントを作成(ノードのサイズを使用) + const attachment = frame_buffer_manager.createTemporaryAttachment(node.w, node.h); + + // アトラステクスチャから該当部分をコピー(複数アトラス対応) + // AtlasManagerから取得、フォールバックとしてFrameBufferManagerから取得 + const atlasAttachment = $getAtlasAttachmentObject() || frame_buffer_manager.getAttachment("atlas"); + if (atlasAttachment && atlasAttachment.texture && attachment.texture) { + // command_encoderを使ってコピー + command_encoder.copyTextureToTexture( + { + "texture": atlasAttachment.texture.resource, + "origin": { "x": node.x, "y": node.y, "z": 0 } + }, + { + "texture": attachment.texture.resource, + "origin": { "x": 0, "y": 0, "z": 0 } + }, + { + "width": node.w, + "height": node.h + } + ); + } else { + console.error("[WebGPU Filter] getTextureFromNode: FAILED - missing atlas or textures"); + } + + return attachment; +}; + +const isSimpleBlendMode = (blendMode: IBlendMode): boolean => { + return SIMPLE_BLEND_MODES.has(blendMode); +}; + +const copyMainAttachmentRegion = ( + config: ILocalFilterConfig, + mainAttachment: IAttachmentObject, + x: number, + y: number, + width: number, + height: number +): IAttachmentObject => { + // 一時アタッチメントを作成 + const dstAttachment = config.frameBufferManager.createTemporaryAttachment(width, height); + + // レンダーパスでコピー(フォーマット変換: bgra8unorm -> rgba8unorm) + const pipeline = config.pipelineManager.getPipeline("complex_blend_copy"); + const bindGroupLayout = config.pipelineManager.getBindGroupLayout("texture_copy"); + + if (!pipeline || !bindGroupLayout || !mainAttachment.texture || !dstAttachment.texture) { + return dstAttachment; + } + + // ユニフォームバッファ: scale (vec2) + offset (vec2) + const scaleX = width / mainAttachment.width; + const scaleY = height / mainAttachment.height; + const offsetX = x / mainAttachment.width; + const offsetY = y / mainAttachment.height; + + $uniform4[0] = scaleX; + $uniform4[1] = scaleY; + $uniform4[2] = offsetX; + $uniform4[3] = offsetY; + const uniformBuffer = config.bufferManager.acquireAndWriteUniformBuffer($uniform4); + + const sampler = config.textureManager.createSampler("filter_copy_sampler", false); + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = mainAttachment.texture.view; + const bindGroup = config.device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + const renderPassDescriptor = config.frameBufferManager.createRenderPassDescriptor( + dstAttachment.texture.view, + 0, 0, 0, 0, + "clear" + ); + + const passEncoder = config.commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + return dstAttachment; +}; + +const drawBlendResultToMain = ( + config: ILocalFilterConfig, + srcAttachment: IAttachmentObject, + mainAttachment: IAttachmentObject, + x: number, + y: number +): void => { + // フィルター+複雑なブレンド用のパイプライン(Y軸反転あり)を使用 + // MSAA有効時はMSAA版パイプラインを使用してmsaaTextureに描画→texture.viewにresolve + const useMsaa = mainAttachment.msaa && mainAttachment.msaaTexture?.view; + const pipelineName = useMsaa ? "filter_complex_blend_output_msaa" : "filter_complex_blend_output"; + const pipeline = config.pipelineManager.getPipeline(pipelineName); + const bindGroupLayout = config.pipelineManager.getBindGroupLayout("positioned_texture"); + + if (!pipeline || !bindGroupLayout || !srcAttachment.texture || !mainAttachment.texture) { + return; + } + + // ユニフォームデータ: offset, size, viewport, padding + $uniform8[0] = x; + $uniform8[1] = y; + $uniform8[2] = srcAttachment.width; + $uniform8[3] = srcAttachment.height; + $uniform8[4] = mainAttachment.width; + $uniform8[5] = mainAttachment.height; + $uniform8[6] = 0; + $uniform8[7] = 0; + const uniformBuffer = config.bufferManager.acquireAndWriteUniformBuffer($uniform8); + + const sampler = config.textureManager.createSampler("filter_blend_output_sampler", false); + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = srcAttachment.texture.view; + const bindGroup = config.device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + // メインアタッチメントへの描画(loadで既存内容を保持) + // MSAA有効時はmsaaTextureに描画してtexture.viewにresolve + const colorView = useMsaa ? mainAttachment.msaaTexture!.view : mainAttachment.texture.view; + const resolveTarget = useMsaa ? mainAttachment.texture.view : null; + const renderPassDescriptor = config.frameBufferManager.createRenderPassDescriptor( + colorView, + 0, 0, 0, 0, + "load", + resolveTarget + ); + + const passEncoder = config.commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); +}; + +const drawFilterToMain = ( + config: ILocalFilterConfig, + filter_attachment: IAttachmentObject, + color_transform: Float32Array, + blend_mode: IBlendMode, + x: number, + y: number, + _main_texture_view: GPUTextureView, + _buffer_manager: BufferManager +): void => { + // メインアタッチメントに描画 + // コンテナレイヤー内ではconfig.mainAttachmentがコンテナのテンポラリアタッチメントを指す + const mainAttachment = config.mainAttachment || config.frameBufferManager.getAttachment("main"); + if (!mainAttachment || !mainAttachment.texture || !filter_attachment.texture) { + return; + } + + // 描画位置とサイズを計算 + // WebGLと同じサブピクセル精度を維持するため、Math.floorを使用しない + // Math.floorを使うと -0.012 → -1 になり、1ピクセル余分にクリップされてしまう + let drawX = x; + let drawY = y; + let drawWidth = filter_attachment.width; + let drawHeight = filter_attachment.height; + + // 負の描画位置を処理(画面外の部分をクリップ) + let uvOffsetX = 0; + let uvOffsetY = 0; + if (drawX < 0) { + uvOffsetX = -drawX / filter_attachment.width; + drawWidth += drawX; + drawX = 0; + } + if (drawY < 0) { + uvOffsetY = -drawY / filter_attachment.height; + drawHeight += drawY; + drawY = 0; + } + + // 描画サイズが0以下なら描画しない + if (drawWidth <= 0 || drawHeight <= 0) { + return; + } + + // メインアタッチメントの範囲内にクランプ + const mainWidth = mainAttachment.width; + const mainHeight = mainAttachment.height; + if (drawX + drawWidth > mainWidth) { + drawWidth = mainWidth - drawX; + } + if (drawY + drawHeight > mainHeight) { + drawHeight = mainHeight - drawY; + } + + // シンプルなブレンドモードの場合 + if (isSimpleBlendMode(blend_mode)) { + // MSAA有効時はMSAA版パイプラインを使用してmsaaTextureに描画→texture.viewにresolve + const useMsaa = mainAttachment.msaa && mainAttachment.msaaTexture?.view; + + // ブレンドモードに応じたパイプラインを選択 + let pipelineName: string; + switch (blend_mode) { + case "add": + pipelineName = useMsaa ? "filter_output_add_msaa" : "filter_output_add"; + break; + case "screen": + pipelineName = useMsaa ? "filter_output_screen_msaa" : "filter_output_screen"; + break; + case "alpha": + pipelineName = useMsaa ? "filter_output_alpha_msaa" : "filter_output_alpha"; + break; + case "erase": + pipelineName = useMsaa ? "filter_output_erase_msaa" : "filter_output_erase"; + break; + case "copy": + pipelineName = useMsaa ? "texture_copy_bgra_msaa" : "texture_copy_bgra"; + break; + default: + // normal, layer + pipelineName = useMsaa ? "filter_output_msaa" : "filter_output"; + break; + } + + let pipeline = config.pipelineManager.getPipeline(pipelineName); + let bindGroupLayout = config.pipelineManager.getBindGroupLayout("texture_copy"); + + if (!pipeline || !bindGroupLayout) { + // フォールバック + pipelineName = useMsaa ? "filter_output_msaa" : "filter_output"; + pipeline = config.pipelineManager.getPipeline(pipelineName); + bindGroupLayout = config.pipelineManager.getBindGroupLayout("texture_copy"); + if (!pipeline || !bindGroupLayout) { + return; + } + } + + const sampler = config.textureManager.createSampler("filter_output_sampler", true); + + // UV座標の設定 + // viewportをdrawWidth/drawHeightに合わせるため、uvScaleでテクスチャの表示範囲を制御 + // uv = texCoord * scale + offset で texCoord[0,1] → uv[uvOffset, uvOffset+uvScale] + const uvScaleX = drawWidth / filter_attachment.width; + const uvScaleY = drawHeight / filter_attachment.height; + $uniform4[0] = uvScaleX; + $uniform4[1] = uvScaleY; + $uniform4[2] = uvOffsetX; + $uniform4[3] = uvOffsetY; + const uniformBuffer = config.bufferManager.acquireAndWriteUniformBuffer($uniform4); + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = filter_attachment.texture.view; + const bindGroup = config.device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + // MSAA有効時はmsaaTextureに描画してtexture.viewにresolve + const colorView = useMsaa ? mainAttachment.msaaTexture!.view : mainAttachment.texture.view; + const resolveTarget = useMsaa ? mainAttachment.texture.view : null; + const renderPassDescriptor = config.frameBufferManager.createRenderPassDescriptor( + colorView, + 0, 0, 0, 0, + "load", + resolveTarget + ); + + // Viewportはfloat値でサブピクセル精度を維持(WebGLのsetTransform相当) + // ScissorはGPUIntegerCoordinate必須のため整数化し、viewport領域を包含する + const vpX = Math.max(0, drawX); + const vpY = Math.max(0, drawY); + const vpW = Math.max(1, drawWidth); + const vpH = Math.max(1, drawHeight); + const scissorX = Math.max(0, Math.floor(vpX)); + const scissorY = Math.max(0, Math.floor(vpY)); + const scissorW = Math.max(1, Math.min(Math.ceil(vpX + vpW) - scissorX, mainWidth - scissorX)); + const scissorH = Math.max(1, Math.min(Math.ceil(vpY + vpH) - scissorY, mainHeight - scissorY)); + + // 描画が有効な範囲内でのみ実行 + if (scissorW <= 0 || scissorH <= 0 || scissorX >= mainWidth || scissorY >= mainHeight) { + return; + } + + const passEncoder = config.commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.setViewport(vpX, vpY, vpW, vpH, 0, 1); + passEncoder.setScissorRect(scissorX, scissorY, scissorW, scissorH); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + } else { + // 複雑なブレンドモード(multiply, overlay, darken, lighten, hardlight等) + // 1. メインアタッチメントから描画先の矩形をコピー + const dstAttachment = copyMainAttachmentRegion( + config, mainAttachment, + drawX, drawY, drawWidth, drawHeight + ); + + // 2. カラートランスフォームを準備(WebGL版と同じ:add値は生値) + $uniform8[0] = color_transform[0]; // mulR + $uniform8[1] = color_transform[1]; // mulG + $uniform8[2] = color_transform[2]; // mulB + $uniform8[3] = color_transform[3]; // mulA (globalAlpha) + $uniform8[4] = color_transform[4]; // addR + $uniform8[5] = color_transform[5]; // addG + $uniform8[6] = color_transform[6]; // addB + $uniform8[7] = 0; // addA + + // 3. 複雑なブレンドを適用 + const blendedAttachment = blendApplyComplexBlendUseCase( + filter_attachment, + dstAttachment, + blend_mode, + $uniform8, + { + "device": config.device, + "commandEncoder": config.commandEncoder, + "bufferManager": config.bufferManager, + "frameBufferManager": config.frameBufferManager, + "pipelineManager": config.pipelineManager, + "textureManager": config.textureManager, + "frameTextures": config.frameTextures + } + ); + + // 4. 結果をメインアタッチメントに描画 + drawBlendResultToMain( + config, + blendedAttachment, + mainAttachment, + drawX, + drawY + ); + + // 5. 一時テクスチャを解放 + config.frameBufferManager.releaseTemporaryAttachment(dstAttachment); + config.frameBufferManager.releaseTemporaryAttachment(blendedAttachment); + } +}; + +export const execute = ( + node: Node, + width: number, + height: number, + is_bitmap: boolean, + matrix: Float32Array, + color_transform: Float32Array, + blend_mode: IBlendMode, + bounds: Float32Array, + params: Float32Array, + config: ILocalFilterConfig, + main_texture_view: GPUTextureView, + buffer_manager: BufferManager +): void => { + // オフセットを初期化 + $offset.x = 0; + $offset.y = 0; + + // ノードからテクスチャを取得 + let filterAttachment = getTextureFromNode(node, config.commandEncoder, config.frameBufferManager); + + // アトラスのY反転を補正 + // WebGPUではアトラスに描画する際にY軸が反転して格納される: + // - Shape: FillVertexがndc.yを反転なしで使用するため、WebGPUのNDC→ピクセル変換でY反転 + // - Bitmap/TextField: PositionedTextureVertexのtexCoord Y反転 + position.y反転 + // どちらも同じ方向にY反転しているため、フィルタ処理前に全コンテンツを補正する + if (filterAttachment.texture) { + const flippedAttachment = config.frameBufferManager.createTemporaryAttachment(node.w, node.h); + const flipPipeline = config.pipelineManager.getPipeline("texture_copy_rgba8"); + const flipBindGroupLayout = config.pipelineManager.getBindGroupLayout("texture_copy"); + + if (flipPipeline && flipBindGroupLayout && flippedAttachment.texture) { + const sampler = config.textureManager.createSampler("filter_flip_sampler", false); + + // scale=(1, -1), offset=(0, 1) で UV.y = texCoord.y * (-1) + 1 = 1 - texCoord.y + const uniformBuffer = config.bufferManager.acquireAndWriteUniformBuffer(Y_FLIP_UNIFORM); + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = filterAttachment.texture.view; + const bindGroup = config.device.createBindGroup({ + "layout": flipBindGroupLayout, + "entries": $entries3 + }); + + const renderPassDescriptor = config.frameBufferManager.createRenderPassDescriptor( + flippedAttachment.texture.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = config.commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(flipPipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + filterAttachment = flippedAttachment; + } + } + + const devicePixelRatio = WebGPUUtil.getDevicePixelRatio(); + + // スケール・回転が適用されているかチェック(WebGL版と同じロジック) + const scaleX = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]); + const scaleY = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]); + const radianX = Math.atan2(matrix[1], matrix[0]); + const radianY = Math.atan2(-matrix[2], matrix[3]); + + // is_bitmap=true(Video/Bitmap)の場合はスケールを適用、false(Shape)の場合はスケールなし + const a0 = is_bitmap ? scaleX * Math.cos(radianX) : Math.cos(radianX); + const b1 = is_bitmap ? scaleX * Math.sin(radianX) : Math.sin(radianX); + const c2 = is_bitmap ? -scaleY * Math.sin(radianY) : -Math.sin(radianY); + const d3 = is_bitmap ? scaleY * Math.cos(radianY) : Math.cos(radianY); + + // 変換行列を計算(WebGL版と同じ) + $uniform6a[0] = a0; + $uniform6a[1] = b1; + $uniform6a[2] = c2; + $uniform6a[3] = d3; + $uniform6a[4] = width / 2; + $uniform6a[5] = height / 2; + + $uniform6b[0] = 1; + $uniform6b[1] = 0; + $uniform6b[2] = 0; + $uniform6b[3] = 1; + $uniform6b[4] = -node.w / 2; + $uniform6b[5] = -node.h / 2; + + // 行列乗算: a * b + const tMatrix0 = $uniform6a[0] * $uniform6b[0] + $uniform6a[2] * $uniform6b[1]; + const tMatrix1 = $uniform6a[1] * $uniform6b[0] + $uniform6a[3] * $uniform6b[1]; + const tMatrix2 = $uniform6a[0] * $uniform6b[2] + $uniform6a[2] * $uniform6b[3]; + const tMatrix3 = $uniform6a[1] * $uniform6b[2] + $uniform6a[3] * $uniform6b[3]; + const tMatrix4 = $uniform6a[0] * $uniform6b[4] + $uniform6a[2] * $uniform6b[5] + $uniform6a[4]; + const tMatrix5 = $uniform6a[1] * $uniform6b[4] + $uniform6a[3] * $uniform6b[5] + $uniform6a[5]; + + let offsetX = 0; + let offsetY = 0; + + // スケール・回転変換が必要な場合(WebGL版と同じ条件) + if (tMatrix0 !== 1 || tMatrix1 !== 0 || tMatrix2 !== 0 || tMatrix3 !== 1) { + // スケール変換用のアタッチメントを作成 + const scaledAttachment = config.frameBufferManager.createTemporaryAttachment(width, height); + + // スケール変換用パイプラインを使用 + // ビットマップ/TextFieldのY反転はフィルタ処理前に補正済みのため、常にtexture_scaleを使用 + const scalePipelineName = "texture_scale"; + const scalePipeline = config.pipelineManager.getPipeline(scalePipelineName); + const scaleBindGroupLayout = config.pipelineManager.getBindGroupLayout("texture_scale"); + + if (scalePipeline && scaleBindGroupLayout) { + // ユニフォームデータ: matrix (6 floats) + srcSize (2 floats) + dstSize (2 floats) + padding (2 floats) + $uniform12[0] = tMatrix0; + $uniform12[1] = tMatrix1; + $uniform12[2] = tMatrix2; + $uniform12[3] = tMatrix3; + $uniform12[4] = tMatrix4; + $uniform12[5] = tMatrix5; + $uniform12[6] = node.w; + $uniform12[7] = node.h; + $uniform12[8] = width; + $uniform12[9] = height; + $uniform12[10] = 0; + $uniform12[11] = 0; + const uniformBuffer = config.bufferManager.acquireAndWriteUniformBuffer($uniform12, 48); + + const sampler = config.textureManager.createSampler("filter_scale_sampler", true); + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = filterAttachment.texture!.view; + const bindGroup = config.device.createBindGroup({ + "layout": scaleBindGroupLayout, + "entries": $entries3 + }); + + const renderPassDescriptor = config.frameBufferManager.createRenderPassDescriptor( + scaledAttachment.texture!.view, + 0, 0, 0, 0, + "clear" + ); + + const passEncoder = config.commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(scalePipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + offsetX = tMatrix4; + offsetY = tMatrix5; + + // 元のアタッチメントを解放してスケール済みアタッチメントを使用 + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + filterAttachment = scaledAttachment; + } + } + + // フィルターを適用 + for (let idx = 0; params.length > idx; ) { + const type = params[idx++]; + + switch (type) { + case 0: // BevelFilter + { + const bevelDistance = params[idx++]; + const bevelAngle = params[idx++]; + const bevelHighlightColor = params[idx++]; + const bevelHighlightAlpha = params[idx++]; + const bevelShadowColor = params[idx++]; + const bevelShadowAlpha = params[idx++]; + const bevelBlurX = params[idx++]; + const bevelBlurY = params[idx++]; + const bevelStrength = params[idx++]; + const bevelQuality = params[idx++]; + const bevelType = params[idx++]; + const bevelKnockout = Boolean(params[idx++]); + + const newAttachment = filterApplyBevelFilterUseCase( + filterAttachment, matrix, + bevelDistance, bevelAngle, + bevelHighlightColor, bevelHighlightAlpha, + bevelShadowColor, bevelShadowAlpha, + bevelBlurX, bevelBlurY, bevelStrength, bevelQuality, + bevelType, bevelKnockout, + devicePixelRatio, config + ); + + if (filterAttachment !== newAttachment) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAttachment; + } + break; + + case 1: // BlurFilter + { + const blurX = params[idx++]; + const blurY = params[idx++]; + const quality = params[idx++]; + + const newAttachment = filterApplyBlurFilterUseCase( + filterAttachment, matrix, + blurX, blurY, quality, + devicePixelRatio, config + ); + + if (filterAttachment !== newAttachment) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAttachment; + } + break; + + case 2: // ColorMatrixFilter + { + $uniform20[0] = params[idx++]; + $uniform20[1] = params[idx++]; + $uniform20[2] = params[idx++]; + $uniform20[3] = params[idx++]; + $uniform20[4] = params[idx++]; + $uniform20[5] = params[idx++]; + $uniform20[6] = params[idx++]; + $uniform20[7] = params[idx++]; + $uniform20[8] = params[idx++]; + $uniform20[9] = params[idx++]; + $uniform20[10] = params[idx++]; + $uniform20[11] = params[idx++]; + $uniform20[12] = params[idx++]; + $uniform20[13] = params[idx++]; + $uniform20[14] = params[idx++]; + $uniform20[15] = params[idx++]; + $uniform20[16] = params[idx++]; + $uniform20[17] = params[idx++]; + $uniform20[18] = params[idx++]; + $uniform20[19] = params[idx++]; + + const newAttachment = filterApplyColorMatrixFilterUseCase( + filterAttachment, $uniform20, config + ); + + if (filterAttachment !== newAttachment) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAttachment; + } + break; + + case 3: // ConvolutionFilter + { + const convMatrixX = params[idx++]; + const convMatrixY = params[idx++]; + const convLength = convMatrixX * convMatrixY; + const convMatrix = new Float32Array(convLength); + for (let i = 0; i < convLength; i++) { + convMatrix[i] = params[idx++]; + } + const convDivisor = params[idx++]; + const convBias = params[idx++]; + const convPreserveAlpha = Boolean(params[idx++]); + const convClamp = Boolean(params[idx++]); + const convColor = params[idx++]; + const convAlpha = params[idx++]; + + const newAttachment = filterApplyConvolutionFilterUseCase( + filterAttachment, + convMatrixX, convMatrixY, convMatrix, + convDivisor, convBias, convPreserveAlpha, convClamp, + convColor, convAlpha, + config + ); + + if (filterAttachment !== newAttachment) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAttachment; + } + break; + + case 4: // DisplacementMapFilter + { + const dmBufferLength = params[idx++]; + const dmBuffer = new Uint8Array(dmBufferLength); + for (let i = 0; i < dmBufferLength; i++) { + dmBuffer[i] = params[idx++]; + } + + const dmBitmapWidth = params[idx++]; + const dmBitmapHeight = params[idx++]; + const dmMapPointX = params[idx++]; + const dmMapPointY = params[idx++]; + const dmComponentX = params[idx++]; + const dmComponentY = params[idx++]; + const dmScaleX = params[idx++]; + const dmScaleY = params[idx++]; + const dmMode = params[idx++]; + const dmColor = params[idx++]; + const dmAlpha = params[idx++]; + + const newAttachment = filterApplyDisplacementMapFilterUseCase( + filterAttachment, matrix, + dmBuffer, dmBitmapWidth, dmBitmapHeight, + dmMapPointX, dmMapPointY, + dmComponentX, dmComponentY, + dmScaleX, dmScaleY, + dmMode, dmColor, dmAlpha, + devicePixelRatio, config + ); + + if (filterAttachment !== newAttachment) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAttachment; + } + break; + + case 5: // DropShadowFilter + { + const dsDistance = params[idx++]; + const dsAngle = params[idx++]; + const dsColor = params[idx++]; + const dsAlpha = params[idx++]; + const dsBlurX = params[idx++]; + const dsBlurY = params[idx++]; + const dsStrength = params[idx++]; + const dsQuality = params[idx++]; + const dsInner = Boolean(params[idx++]); + const dsKnockout = Boolean(params[idx++]); + const dsHideObject = Boolean(params[idx++]); + + const newAttachment = filterApplyDropShadowFilterUseCase( + filterAttachment, matrix, + dsDistance, dsAngle, dsColor, dsAlpha, + dsBlurX, dsBlurY, dsStrength, dsQuality, + dsInner, dsKnockout, dsHideObject, + devicePixelRatio, config + ); + + if (filterAttachment !== newAttachment) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAttachment; + } + break; + + case 6: // GlowFilter + { + const glowColor = params[idx++]; + const glowAlpha = params[idx++]; + const glowBlurX = params[idx++]; + const glowBlurY = params[idx++]; + const glowStrength = params[idx++]; + const glowQuality = params[idx++]; + const glowInner = Boolean(params[idx++]); + const glowKnockout = Boolean(params[idx++]); + + const newAttachment = filterApplyGlowFilterUseCase( + filterAttachment, matrix, + glowColor, glowAlpha, glowBlurX, glowBlurY, + glowStrength, glowQuality, glowInner, glowKnockout, + devicePixelRatio, config + ); + + if (filterAttachment !== newAttachment) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAttachment; + } + break; + + case 7: // GradientBevelFilter + { + const gbDistance = params[idx++]; + const gbAngle = params[idx++]; + + const gbColorsLen = params[idx++]; + const gbColors = new Float32Array(gbColorsLen); + for (let i = 0; i < gbColorsLen; i++) { + gbColors[i] = params[idx++]; + } + + const gbAlphasLen = params[idx++]; + const gbAlphas = new Float32Array(gbAlphasLen); + for (let i = 0; i < gbAlphasLen; i++) { + gbAlphas[i] = params[idx++]; + } + + const gbRatiosLen = params[idx++]; + const gbRatios = new Float32Array(gbRatiosLen); + for (let i = 0; i < gbRatiosLen; i++) { + gbRatios[i] = params[idx++]; + } + + const gbBlurX = params[idx++]; + const gbBlurY = params[idx++]; + const gbStrength = params[idx++]; + const gbQuality = params[idx++]; + const gbType = params[idx++]; + const gbKnockout = Boolean(params[idx++]); + + const newAttachment = filterApplyGradientBevelFilterUseCase( + filterAttachment, matrix, + gbDistance, gbAngle, gbColors, gbAlphas, gbRatios, + gbBlurX, gbBlurY, gbStrength, gbQuality, gbType, gbKnockout, + devicePixelRatio, config + ); + + if (filterAttachment !== newAttachment) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAttachment; + } + break; + + case 8: // GradientGlowFilter + { + const ggDistance = params[idx++]; + const ggAngle = params[idx++]; + + const ggColorsLen = params[idx++]; + const ggColors = new Float32Array(ggColorsLen); + for (let i = 0; i < ggColorsLen; i++) { + ggColors[i] = params[idx++]; + } + + const ggAlphasLen = params[idx++]; + const ggAlphas = new Float32Array(ggAlphasLen); + for (let i = 0; i < ggAlphasLen; i++) { + ggAlphas[i] = params[idx++]; + } + + const ggRatiosLen = params[idx++]; + const ggRatios = new Float32Array(ggRatiosLen); + for (let i = 0; i < ggRatiosLen; i++) { + ggRatios[i] = params[idx++]; + } + + const ggBlurX = params[idx++]; + const ggBlurY = params[idx++]; + const ggStrength = params[idx++]; + const ggQuality = params[idx++]; + const ggType = params[idx++]; + const ggKnockout = Boolean(params[idx++]); + + const newAttachment = filterApplyGradientGlowFilterUseCase( + filterAttachment, matrix, + ggDistance, ggAngle, ggColors, ggAlphas, ggRatios, + ggBlurX, ggBlurY, ggStrength, ggQuality, ggType, ggKnockout, + devicePixelRatio, config + ); + + if (filterAttachment !== newAttachment) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAttachment; + } + break; + } + } + + // ColorTransformが恒等変換でない場合、フィルター結果に適用 + // WebGL版と同じ: フィルターチェーン適用後、メイン描画前にColorTransformを適用 + if (!isIdentityColorTransform(color_transform)) { + const ctAttachment = applyColorTransform(config, filterAttachment, color_transform); + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + filterAttachment = ctAttachment; + } + + // フィルター適用後のテクスチャをメインキャンバスに描画 + // scaleX, scaleYは上部で計算済み + const xMin = bounds[0] * (scaleX / devicePixelRatio); + const yMin = bounds[1] * (scaleY / devicePixelRatio); + + // WebGL版と同じ: スケール変換のオフセットを考慮($offsetはフィルターチェーン内部用で最終位置には不要) + const drawX = -offsetX + xMin + matrix[4]; + const drawY = -offsetY + yMin + matrix[5]; + + drawFilterToMain( + config, + filterAttachment, + color_transform, + blend_mode, + drawX, + drawY, + main_texture_view, + buffer_manager + ); + + // フィルター用アタッチメントを解放 + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); +}; diff --git a/packages/webgpu/src/Context/usecase/ContextBitmapFillUseCase.test.ts b/packages/webgpu/src/Context/usecase/ContextBitmapFillUseCase.test.ts new file mode 100644 index 00000000..390d91ae --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextBitmapFillUseCase.test.ts @@ -0,0 +1,382 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { BufferManager } from "../../BufferManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import type { IPath } from "../../interface/IPath"; +import { execute } from "./ContextBitmapFillUseCase"; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + TEXTURE_BINDING: 0x04, + COPY_DST: 0x02 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +// Mock MeshFillGenerateUseCase +vi.mock("../../Mesh/usecase/MeshFillGenerateUseCase", () => ({ + "execute": vi.fn(() => ({ + "buffer": new Float32Array([0, 0, 1, 1, 2, 2]), + "indexCount": 6 + })) +})); + +// Mock ContextComputeBitmapMatrixService +vi.mock("../service/ContextComputeBitmapMatrixService", () => ({ + "execute": vi.fn(() => new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1])) +})); + +// Mock Mask module +vi.mock("../../Mask", () => ({ + "$isMaskDrawing": vi.fn(() => false), + "$getMaskStencilReference": vi.fn(() => 5) +})); + +import { $isMaskDrawing } from "../../Mask"; +import { execute as meshFillGenerateUseCase } from "../../Mesh/usecase/MeshFillGenerateUseCase"; + +describe("ContextBitmapFillUseCase", () => +{ + const createMockDevice = () => + { + const mockTexture = { + "label": "mockBitmapTexture", + "createView": vi.fn(() => ({ "label": "mockView" })), + "destroy": vi.fn() + }; + return { + "createTexture": vi.fn(() => mockTexture), + "queue": { + "writeTexture": vi.fn(), + "writeBuffer": vi.fn() + }, + "createSampler": vi.fn(() => ({ "label": "mockSampler" })), + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })), + "_mockTexture": mockTexture + } as unknown as GPUDevice & { _mockTexture: any }; + }; + + const createMockRenderPassEncoder = () => + { + return { + "setPipeline": vi.fn(), + "setVertexBuffer": vi.fn(), + "setBindGroup": vi.fn(), + "setStencilReference": vi.fn(), + "draw": vi.fn() + } as unknown as GPURenderPassEncoder; + }; + + const createMockBufferManager = () => + { + return { + "createVertexBuffer": vi.fn(() => ({ "label": "mockVertexBuffer" })), + "createUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })), + "acquireVertexBuffer": vi.fn(() => ({ "label": "mockVertexBuffer" })), + "acquireUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })), + "acquireAndWriteUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })) + } as unknown as BufferManager; + }; + + const createMockPipelineManager = (hasPipeline: boolean = true, hasLayout: boolean = true) => + { + return { + "getPipeline": vi.fn(() => hasPipeline ? { "label": "mockPipeline" } : null), + "getBindGroupLayout": vi.fn(() => hasLayout ? { "label": "mockLayout" } : null) + } as unknown as PipelineManager; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + (($isMaskDrawing as unknown) as ReturnType).mockReturnValue(false); + }); + + describe("basic bitmap fill", () => + { + it("should return null when mesh indexCount is 0", () => + { + vi.mocked(meshFillGenerateUseCase).mockReturnValueOnce({ + "buffer": new Float32Array(0), + "indexCount": 0 + }); + + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false]]; + const pixels = new Uint8Array(4); + + const result = execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + false, true, + 800, 600, + true + ); + + expect(result).toBe(null); + }); + + it("should create bitmap texture with correct dimensions", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(64 * 64 * 4); + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 64, 64, // width, height + false, true, + 800, 600, + true + ); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "size": { "width": 64, "height": 64 }, + "format": "rgba8unorm" + }) + ); + }); + + it("should return bitmap texture after drawing", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(4); + + const result = execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + false, true, + 800, 600, + true + ); + + expect(result).toBe(device._mockTexture); + }); + }); + + describe("pipeline selection", () => + { + it("should use bitmap_fill pipeline for atlas target", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(4); + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + false, true, + 800, 600, + true, // useAtlasTarget + false // useStencilPipeline + ); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("bitmap_fill"); + }); + + it("should use bitmap_fill_bgra pipeline for canvas target", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(4); + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + false, true, + 800, 600, + false, // useAtlasTarget + false // useStencilPipeline + ); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("bitmap_fill_bgra"); + }); + + it("should return null when mask drawing and stencil pipeline", () => + { + (($isMaskDrawing as unknown) as ReturnType).mockReturnValue(true); + + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(4); + + const result = execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + false, true, + 800, 600, + false, // useAtlasTarget + true // useStencilPipeline + ); + + expect(result).toBe(null); + expect(device._mockTexture.destroy).not.toHaveBeenCalled(); + }); + }); + + describe("sampler configuration", () => + { + it("should render with smooth sampler when smooth is true", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(4); + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + false, // repeat + true, // smooth + 800, 600, + true + ); + + // Sampler may be cached from previous tests (module-level cache) + // Verify rendering proceeds correctly + expect(renderPassEncoder.draw).toHaveBeenCalled(); + expect(device.createBindGroup).toHaveBeenCalled(); + }); + + it("should create sampler with repeat address mode when repeat is true", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(4); + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + true, // repeat + false, // smooth + 800, 600, + true + ); + + // Sampler may be cached from previous tests (module-level cache) + // Verify rendering proceeds correctly + expect(renderPassEncoder.draw).toHaveBeenCalled(); + expect(device.createBindGroup).toHaveBeenCalled(); + }); + }); + + describe("bind group", () => + { + it("should return null when bind group layout not found", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(true, false); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(4); + + const result = execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + false, true, + 800, 600, + true + ); + + expect(result).toBe(null); + expect(console.error).toHaveBeenCalledWith("[WebGPU] bitmap_fill bind group layout not found"); + }); + }); +}); diff --git a/packages/webgpu/src/Context/usecase/ContextBitmapFillUseCase.ts b/packages/webgpu/src/Context/usecase/ContextBitmapFillUseCase.ts new file mode 100644 index 00000000..a92aaa51 --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextBitmapFillUseCase.ts @@ -0,0 +1,261 @@ +import type { IPath } from "../../interface/IPath"; +import type { BufferManager } from "../../BufferManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import { execute as meshFillGenerateUseCase } from "../../Mesh/usecase/MeshFillGenerateUseCase"; +import { execute as contextComputeBitmapMatrixService } from "../service/ContextComputeBitmapMatrixService"; +import { $acquireFillTexture, $releaseFillTexture } from "../../FillTexturePool"; +import { + $isMaskDrawing, + $getMaskStencilReference +} from "../../Mask"; + +const $bitmapSamplerCache = new Map(); + +const $uniformData32 = new Float32Array(32); +const $stencilData16 = new Float32Array(16); + +let $stencilDynamicBindGroup: GPUBindGroup | null = null; +let $stencilDynamicBuffer: GPUBuffer | null = null; + +const $entries3: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView } +]; + +export const execute = ( + device: GPUDevice, + render_pass_encoder: GPURenderPassEncoder, + buffer_manager: BufferManager, + pipeline_manager: PipelineManager, + path_vertices: IPath[], + context_matrix: Float32Array, + fill_style: Float32Array, + pixels: Uint8Array, + bitmap_matrix: Float32Array, + width: number, + height: number, + repeat: boolean, + smooth: boolean, + viewport_width: number, + viewport_height: number, + use_atlas_target: boolean, + use_stencil_pipeline: boolean = false, + _clip_level: number = 1 +): GPUTexture | null => { + // MeshFillGenerateUseCaseで頂点データを生成(4 floats/vertex: position + bezier) + const mesh = meshFillGenerateUseCase(path_vertices); + + if (mesh.indexCount === 0) { + return null; + } + + // 頂点バッファを取得(プールから再利用) + const vertexBuffer = buffer_manager.acquireVertexBuffer(mesh.buffer.byteLength, mesh.buffer); + + // ビットマップテクスチャをプールから取得 + const bitmapTexture = $acquireFillTexture(device, width, height); + + // ピクセルデータをテクスチャに転送 + device.queue.writeTexture( + { "texture": bitmapTexture }, + pixels.buffer, + { "bytesPerRow": width * 4, "rowsPerImage": height, "offset": pixels.byteOffset }, + { width, height } + ); + + // ビットマップ変換行列を計算(コンテキスト行列と合成して逆行列) + const computedBitmapMatrix = contextComputeBitmapMatrixService(bitmap_matrix, context_matrix); + + // 色とmatrix + const red = fill_style[0]; + const green = fill_style[1]; + const blue = fill_style[2]; + const alpha = fill_style[3]; + const a = context_matrix[0]; + const b = context_matrix[1]; + const c = context_matrix[3]; + const d = context_matrix[4]; + const tx = context_matrix[6]; + const ty = context_matrix[7]; + + // Uniformバッファを作成(BitmapUniforms: 28 floats = 112 bytes) + // bitmapMatrix: mat3x3 (48 bytes) + $uniformData32[0] = computedBitmapMatrix[0]; // col0.x = a + $uniformData32[1] = computedBitmapMatrix[1]; // col0.y = c + $uniformData32[2] = computedBitmapMatrix[2]; // col0.z = 0 + $uniformData32[3] = 0; // padding + $uniformData32[4] = computedBitmapMatrix[3]; // col1.x = b + $uniformData32[5] = computedBitmapMatrix[4]; // col1.y = d + $uniformData32[6] = computedBitmapMatrix[5]; // col1.z = 0 + $uniformData32[7] = 0; // padding + $uniformData32[8] = computedBitmapMatrix[6]; // col2.x = tx + $uniformData32[9] = computedBitmapMatrix[7]; // col2.y = ty + $uniformData32[10] = computedBitmapMatrix[8]; // col2.z = 1 + $uniformData32[11] = 0; // padding + // ビットマップパラメータ + $uniformData32[12] = width; + $uniformData32[13] = height; + $uniformData32[14] = repeat ? 1.0 : 0.0; + $uniformData32[15] = 0; // padding + // color + $uniformData32[16] = red; + $uniformData32[17] = green; + $uniformData32[18] = blue; + $uniformData32[19] = alpha; + // contextMatrix(viewport正規化済み) + $uniformData32[20] = a / viewport_width; + $uniformData32[21] = b / viewport_height; + $uniformData32[22] = 0; + $uniformData32[23] = 0; + $uniformData32[24] = c / viewport_width; + $uniformData32[25] = d / viewport_height; + $uniformData32[26] = 0; + $uniformData32[27] = 0; + // contextMatrix2 + $uniformData32[28] = tx / viewport_width; + $uniformData32[29] = ty / viewport_height; + $uniformData32[30] = 1; + $uniformData32[31] = 0; + + const uniformBuffer = buffer_manager.acquireAndWriteUniformBuffer($uniformData32); + + // サンプラーを取得(キャッシュ済み) + const samplerKey = `bitmap_${smooth ? "s" : "n"}_${repeat ? "r" : "c"}`; + let sampler = $bitmapSamplerCache.get(samplerKey); + if (!sampler) { + sampler = device.createSampler({ + "magFilter": smooth ? "linear" : "nearest", + "minFilter": smooth ? "linear" : "nearest", + "addressModeU": repeat ? "repeat" : "clamp-to-edge", + "addressModeV": repeat ? "repeat" : "clamp-to-edge" + }); + $bitmapSamplerCache.set(samplerKey, sampler); + } + + // バインドグループを作成 + const bindGroupLayout = pipeline_manager.getBindGroupLayout("bitmap_fill"); + if (!bindGroupLayout) { + console.error("[WebGPU] bitmap_fill bind group layout not found"); + $releaseFillTexture(bitmapTexture); + return null; + } + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = bitmapTexture.createView(); + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + // ステンシル書き込みパス用のDynamic BindGroup + offset作成ヘルパー + const createStencilDynamic = (): { bindGroup: GPUBindGroup; offset: number } | null => { + const dynamicLayout = pipeline_manager.getBindGroupLayout("fill_dynamic"); + if (!dynamicLayout) { + return null; + } + // FillUniforms: color(16) + matrix0(16) + matrix1(16) + matrix2(16) = 64 bytes + $stencilData16[0] = red; + $stencilData16[1] = green; + $stencilData16[2] = blue; + $stencilData16[3] = alpha; + $stencilData16[4] = a / viewport_width; + $stencilData16[5] = b / viewport_height; + $stencilData16[6] = 0; + $stencilData16[7] = 0; + $stencilData16[8] = c / viewport_width; + $stencilData16[9] = d / viewport_height; + $stencilData16[10] = 0; + $stencilData16[11] = 0; + $stencilData16[12] = tx / viewport_width; + $stencilData16[13] = ty / viewport_height; + $stencilData16[14] = 1; + $stencilData16[15] = 0; + const offset = buffer_manager.dynamicUniform.allocate($stencilData16); + const currentBuffer = buffer_manager.dynamicUniform.getBuffer(); + if (!$stencilDynamicBindGroup || $stencilDynamicBuffer !== currentBuffer) { + $stencilDynamicBindGroup = device.createBindGroup({ + "layout": dynamicLayout, + "entries": [{ + "binding": 0, + "resource": { + "buffer": currentBuffer, + "size": 256 + } + }] + }); + $stencilDynamicBuffer = currentBuffer; + } + return { "bindGroup": $stencilDynamicBindGroup, "offset": offset }; + }; + + // アトラス描画時は2パスステンシル処理を使用(WebGL版と同じ) + if (use_atlas_target && use_stencil_pipeline) { + // === Pass 1: ステンシル書き込み(カラー書き込みなし) === + const stencilWritePipeline = pipeline_manager.getPipeline("stencil_write"); + if (stencilWritePipeline) { + const stencilDynamic = createStencilDynamic(); + if (stencilDynamic) { + render_pass_encoder.setPipeline(stencilWritePipeline); + render_pass_encoder.setStencilReference(0); + render_pass_encoder.setVertexBuffer(0, vertexBuffer); + render_pass_encoder.setBindGroup(0, stencilDynamic.bindGroup, [stencilDynamic.offset]); + render_pass_encoder.draw(mesh.indexCount, 1, 0, 0); + } + } + + // === Pass 2: ビットマップ描画(NOT_EQUAL 0) === + const bitmapPipeline = pipeline_manager.getPipeline("bitmap_fill_stencil"); + if (bitmapPipeline) { + render_pass_encoder.setPipeline(bitmapPipeline); + render_pass_encoder.setStencilReference(0); + render_pass_encoder.setBindGroup(0, bindGroup); + render_pass_encoder.draw(mesh.indexCount, 1, 0, 0); + } + } else { + // パイプラインを取得 + // - アトラス用: "bitmap_fill" (rgba8unorm, ステンシルなし) + // - キャンバス用: "bitmap_fill_bgra" (bgra8unorm, ステンシルなし) + // - マスク描画モード時: 何もしない(clip()でステンシル書き込み) + // - マスクテストモード時: "bitmap_fill_bgra_stencil" (bgra8unorm, ステンシルテストあり) + let pipelineName: string; + if (use_atlas_target) { + pipelineName = "bitmap_fill"; + } else if (use_stencil_pipeline) { + if ($isMaskDrawing()) { + // マスク描画モード: ステンシルへの書き込みはclip()で行うため、ここでは何もしない + // clip()がINVERTでステンシルを書き込み、重複書き込みによるINVERT打ち消しを防止 + // ビットマップテクスチャは解放してnullを返す + $releaseFillTexture(bitmapTexture); + return null; + } + // マスクテストモード: ステンシル値をテストしてビットマップを描画 + pipelineName = "bitmap_fill_bgra_stencil"; + + } else { + pipelineName = "bitmap_fill_bgra"; + } + const pipeline = pipeline_manager.getPipeline(pipelineName); + if (!pipeline) { + console.error(`[WebGPU] ${pipelineName} pipeline not found`); + $releaseFillTexture(bitmapTexture); + return null; + } + + // 描画 + render_pass_encoder.setPipeline(pipeline); + render_pass_encoder.setVertexBuffer(0, vertexBuffer); + render_pass_encoder.setBindGroup(0, bindGroup); + + // マスクテストモード時はステンシル参照値を設定 + if (use_stencil_pipeline && !use_atlas_target && !$isMaskDrawing()) { + render_pass_encoder.setStencilReference($getMaskStencilReference()); + } + + render_pass_encoder.draw(mesh.indexCount, 1, 0, 0); + } + + // ビットマップテクスチャを返す(Context.tsでフレーム終了時に解放) + return bitmapTexture; +}; diff --git a/packages/webgpu/src/Context/usecase/ContextBitmapStrokeUseCase.test.ts b/packages/webgpu/src/Context/usecase/ContextBitmapStrokeUseCase.test.ts new file mode 100644 index 00000000..6735b59f --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextBitmapStrokeUseCase.test.ts @@ -0,0 +1,442 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { BufferManager } from "../../BufferManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import type { IPath } from "../../interface/IPath"; +import { execute } from "./ContextBitmapStrokeUseCase"; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + TEXTURE_BINDING: 0x04, + COPY_DST: 0x02 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +// Mock MeshBitmapStrokeGenerateUseCase +vi.mock("../../Mesh/usecase/MeshBitmapStrokeGenerateUseCase", () => ({ + "execute": vi.fn(() => ({ + "buffer": new Float32Array([0, 0, 1, 1, 2, 2]), + "indexCount": 6 + })) +})); + +// Mock ContextComputeBitmapMatrixService +vi.mock("../service/ContextComputeBitmapMatrixService", () => ({ + "execute": vi.fn(() => new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1])) +})); + +import { execute as meshBitmapStrokeGenerateUseCase } from "../../Mesh/usecase/MeshBitmapStrokeGenerateUseCase"; + +describe("ContextBitmapStrokeUseCase", () => +{ + const createMockDevice = () => + { + const mockTexture = { + "label": "mockBitmapTexture", + "createView": vi.fn(() => ({ "label": "mockView" })), + "destroy": vi.fn() + }; + return { + "createTexture": vi.fn(() => mockTexture), + "queue": { + "writeTexture": vi.fn(), + "writeBuffer": vi.fn() + }, + "createSampler": vi.fn(() => ({ "label": "mockSampler" })), + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })), + "_mockTexture": mockTexture + } as unknown as GPUDevice & { _mockTexture: any }; + }; + + const createMockRenderPassEncoder = () => + { + return { + "setPipeline": vi.fn(), + "setVertexBuffer": vi.fn(), + "setBindGroup": vi.fn(), + "draw": vi.fn() + } as unknown as GPURenderPassEncoder; + }; + + const createMockBufferManager = () => + { + return { + "createVertexBuffer": vi.fn(() => ({ "label": "mockVertexBuffer" })), + "createUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })), + "acquireVertexBuffer": vi.fn(() => ({ "label": "mockVertexBuffer" })), + "acquireUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })), + "acquireAndWriteUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })) + } as unknown as BufferManager; + }; + + const createMockPipelineManager = (hasPipeline: boolean = true, hasLayout: boolean = true) => + { + return { + "getPipeline": vi.fn(() => hasPipeline ? { "label": "mockPipeline" } : null), + "getBindGroupLayout": vi.fn(() => hasLayout ? { "label": "mockLayout" } : null) + } as unknown as PipelineManager; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("basic bitmap stroke", () => + { + it("should return null when mesh indexCount is 0", () => + { + vi.mocked(meshBitmapStrokeGenerateUseCase).mockReturnValueOnce({ + "buffer": new Float32Array(0), + "indexCount": 0 + }); + + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const vertices: IPath[] = [[0, 0, false]]; + const pixels = new Uint8Array(4); + + const result = execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + false, true, + 800, 600, + true + ); + + expect(result).toBe(null); + }); + + it("should create bitmap texture with correct dimensions", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(64 * 64 * 4); + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 64, 64, + false, true, + 800, 600, + true + ); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "size": { "width": 64, "height": 64 }, + "format": "rgba8unorm" + }) + ); + }); + + it("should return bitmap texture after drawing", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(4); + + const result = execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + false, true, + 800, 600, + true + ); + + expect(result).toBe(device._mockTexture); + }); + }); + + describe("pipeline selection", () => + { + it("should use bitmap_fill pipeline for atlas target", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(4); + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + false, true, + 800, 600, + true // useAtlasTarget + ); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("bitmap_fill"); + }); + + it("should use bitmap_fill_bgra pipeline for canvas target", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(4); + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + false, true, + 800, 600, + false // useAtlasTarget + ); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("bitmap_fill_bgra"); + }); + }); + + describe("sampler configuration", () => + { + it("should render with smooth sampler when smooth is true", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(4); + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + false, // repeat + true, // smooth + 800, 600, + true + ); + + // Sampler may be cached from previous tests (module-level cache) + // Verify rendering proceeds correctly + expect(renderPassEncoder.draw).toHaveBeenCalled(); + expect(device.createBindGroup).toHaveBeenCalled(); + }); + + it("should render with nearest sampler when smooth is false", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(4); + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + false, // repeat + false, // smooth + 800, 600, + true + ); + + // Sampler may be cached from previous tests (module-level cache) + // Verify rendering proceeds correctly + expect(renderPassEncoder.draw).toHaveBeenCalled(); + expect(device.createBindGroup).toHaveBeenCalled(); + }); + + it("should render with repeat sampler when repeat is true", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(4); + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + true, // repeat + false, + 800, 600, + true + ); + + // Sampler may be cached from previous tests (module-level cache) + // Verify rendering proceeds correctly + expect(renderPassEncoder.draw).toHaveBeenCalled(); + expect(device.createBindGroup).toHaveBeenCalled(); + }); + }); + + describe("error handling", () => + { + it("should return null when bind group layout not found", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(true, false); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(4); + + const result = execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + false, true, + 800, 600, + true + ); + + expect(result).toBe(null); + expect(device._mockTexture.destroy).not.toHaveBeenCalled(); + }); + + it("should return null when pipeline not found", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(false, true); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(4); + + const result = execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + false, true, + 800, 600, + true + ); + + expect(result).toBe(null); + expect(device._mockTexture.destroy).not.toHaveBeenCalled(); + }); + }); + + describe("drawing", () => + { + it("should draw with correct vertex count", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const pixels = new Uint8Array(4); + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + pixels, + new Float32Array([1, 0, 0, 1, 0, 0]), + 1, 1, + false, true, + 800, 600, + true + ); + + expect(renderPassEncoder.draw).toHaveBeenCalledWith(6, 1, 0, 0); + }); + }); +}); diff --git a/packages/webgpu/src/Context/usecase/ContextBitmapStrokeUseCase.ts b/packages/webgpu/src/Context/usecase/ContextBitmapStrokeUseCase.ts new file mode 100644 index 00000000..eaed4048 --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextBitmapStrokeUseCase.ts @@ -0,0 +1,164 @@ +import type { IPath } from "../../interface/IPath"; +import type { BufferManager } from "../../BufferManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import { execute as meshBitmapStrokeGenerateUseCase } from "../../Mesh/usecase/MeshBitmapStrokeGenerateUseCase"; +import { execute as contextComputeBitmapMatrixService } from "../service/ContextComputeBitmapMatrixService"; +import { $acquireFillTexture, $releaseFillTexture } from "../../FillTexturePool"; + +const $bitmapSamplerCache = new Map(); + +const $uniformData32 = new Float32Array(32); + +const $entries3: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView } +]; + +export const execute = ( + device: GPUDevice, + render_pass_encoder: GPURenderPassEncoder, + buffer_manager: BufferManager, + pipeline_manager: PipelineManager, + vertices: IPath[], + thickness: number, + context_matrix: Float32Array, + stroke_style: Float32Array, + pixels: Uint8Array, + bitmap_matrix: Float32Array, + width: number, + height: number, + repeat: boolean, + smooth: boolean, + viewport_width: number, + viewport_height: number, + use_atlas_target: boolean, + use_stencil_pipeline: boolean +): GPUTexture | null => { + // ビットマップストローク用メッシュを生成(4 floats/vertex: position + bezier) + const mesh = meshBitmapStrokeGenerateUseCase(vertices, thickness); + + if (mesh.indexCount === 0) { + return null; + } + + // 頂点バッファを取得(プールから再利用) + const vertexBuffer = buffer_manager.acquireVertexBuffer(mesh.buffer.byteLength, mesh.buffer); + + // ビットマップテクスチャをプールから取得 + const bitmapTexture = $acquireFillTexture(device, width, height); + + // ピクセルデータをテクスチャに転送 + device.queue.writeTexture( + { "texture": bitmapTexture }, + pixels.buffer, + { "bytesPerRow": width * 4, "rowsPerImage": height, "offset": pixels.byteOffset }, + { width, height } + ); + + // ビットマップ変換行列を計算(コンテキスト行列と合成して逆行列) + const computedBitmapMatrix = contextComputeBitmapMatrixService(bitmap_matrix, context_matrix); + + // 色とmatrix + const red = 1; + const green = 1; + const blue = 1; + const alpha = stroke_style[3] > 0 ? stroke_style[3] : 1; + const a = context_matrix[0]; + const b = context_matrix[1]; + const c = context_matrix[3]; + const d = context_matrix[4]; + const tx = context_matrix[6]; + const ty = context_matrix[7]; + + // Uniformバッファを作成(BitmapUniforms: 32 floats = 128 bytes) + $uniformData32[0] = computedBitmapMatrix[0]; // col0.x = a + $uniformData32[1] = computedBitmapMatrix[1]; // col0.y = c + $uniformData32[2] = computedBitmapMatrix[2]; // col0.z = 0 + $uniformData32[3] = 0; // padding + $uniformData32[4] = computedBitmapMatrix[3]; // col1.x = b + $uniformData32[5] = computedBitmapMatrix[4]; // col1.y = d + $uniformData32[6] = computedBitmapMatrix[5]; // col1.z = 0 + $uniformData32[7] = 0; // padding + $uniformData32[8] = computedBitmapMatrix[6]; // col2.x = tx + $uniformData32[9] = computedBitmapMatrix[7]; // col2.y = ty + $uniformData32[10] = computedBitmapMatrix[8]; // col2.z = 1 + $uniformData32[11] = 0; // padding + // ビットマップパラメータ + $uniformData32[12] = width; + $uniformData32[13] = height; + $uniformData32[14] = repeat ? 1.0 : 0.0; + $uniformData32[15] = 0; // padding + // color + $uniformData32[16] = red; + $uniformData32[17] = green; + $uniformData32[18] = blue; + $uniformData32[19] = alpha; + // contextMatrix(viewport正規化済み) + $uniformData32[20] = a / viewport_width; + $uniformData32[21] = b / viewport_height; + $uniformData32[22] = 0; + $uniformData32[23] = 0; + $uniformData32[24] = c / viewport_width; + $uniformData32[25] = d / viewport_height; + $uniformData32[26] = 0; + $uniformData32[27] = 0; + $uniformData32[28] = tx / viewport_width; + $uniformData32[29] = ty / viewport_height; + $uniformData32[30] = 1; + $uniformData32[31] = 0; + + const uniformBuffer = buffer_manager.acquireAndWriteUniformBuffer($uniformData32); + + // サンプラーを取得(キャッシュ済み) + const samplerKey = `bitmap_${smooth ? "s" : "n"}_${repeat ? "r" : "c"}`; + let sampler = $bitmapSamplerCache.get(samplerKey); + if (!sampler) { + sampler = device.createSampler({ + "magFilter": smooth ? "linear" : "nearest", + "minFilter": smooth ? "linear" : "nearest", + "addressModeU": repeat ? "repeat" : "clamp-to-edge", + "addressModeV": repeat ? "repeat" : "clamp-to-edge" + }); + $bitmapSamplerCache.set(samplerKey, sampler); + } + + // バインドグループを作成 + const bindGroupLayout = pipeline_manager.getBindGroupLayout("bitmap_fill"); + if (!bindGroupLayout) { + console.error("[WebGPU] bitmap_fill bind group layout not found"); + $releaseFillTexture(bitmapTexture); + return null; + } + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = bitmapTexture.createView(); + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + // パイプラインを取得 + // ストロークのメッシュは各セグメントが独立した凸多角形のため、 + // フィルのような2パスステンシル描画は不要で、直接描画で正しく描画される。 + // ステンシル付きレンダーパスの場合はステンシル互換パイプライン(compare: always)を使用する。 + const pipelineName = use_stencil_pipeline + ? use_atlas_target ? "bitmap_stroke_atlas" : "bitmap_stroke_bgra" + : use_atlas_target ? "bitmap_fill" : "bitmap_fill_bgra"; + const pipeline = pipeline_manager.getPipeline(pipelineName); + if (!pipeline) { + console.error(`[WebGPU] ${pipelineName} pipeline not found`); + $releaseFillTexture(bitmapTexture); + return null; + } + + // 描画 + render_pass_encoder.setPipeline(pipeline); + render_pass_encoder.setVertexBuffer(0, vertexBuffer); + render_pass_encoder.setBindGroup(0, bindGroup); + render_pass_encoder.draw(mesh.indexCount, 1, 0, 0); + + // ビットマップテクスチャを返す(Context.tsでフレーム終了時に解放) + return bitmapTexture; +}; diff --git a/packages/webgpu/src/Context/usecase/ContextClipUseCase.test.ts b/packages/webgpu/src/Context/usecase/ContextClipUseCase.test.ts new file mode 100644 index 00000000..5103f1ef --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextClipUseCase.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { BufferManager } from "../../BufferManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import type { IPath } from "../../interface/IPath"; +import { execute } from "./ContextClipUseCase"; + +// Mock modules +vi.mock("../../Mesh/usecase/MeshFillGenerateUseCase", () => ({ + "execute": vi.fn(() => ({ + "buffer": new Float32Array([0, 0, 1, 1, 2, 2]), + "indexCount": 6 + })) +})); + +vi.mock("../../Mask/service/MaskUnionMaskService", () => ({ + "execute": vi.fn() +})); + +vi.mock("../../Mask", () => ({ + "$clipLevels": new Map([[1, 1], [2, 2]]) +})); + +import { $clipLevels } from "../../Mask"; + +describe("ContextClipUseCase", () => +{ + const createMockDevice = () => + { + return { + "queue": { + "writeBuffer": vi.fn() + }, + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })) + } as unknown as GPUDevice; + }; + + const createMockRenderPassEncoder = () => + { + return { + "setPipeline": vi.fn(), + "setStencilReference": vi.fn(), + "setVertexBuffer": vi.fn(), + "setBindGroup": vi.fn(), + "draw": vi.fn() + } as unknown as GPURenderPassEncoder; + }; + + const createMockBufferManager = () => + { + return { + "createVertexBuffer": vi.fn(() => ({ "label": "mockVertexBuffer" })), + "acquireVertexBuffer": vi.fn(() => ({ "label": "mockVertexBuffer" })), + "acquireUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })), + "acquireAndWriteUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })), + "dynamicUniform": { + "allocate": vi.fn(() => 0), + "getBuffer": vi.fn(() => ({ "label": "mockDynamicBuffer" })) + } + } as unknown as BufferManager; + }; + + const createMockPipelineManager = (hasPipeline: boolean = true) => + { + return { + "getPipeline": vi.fn(() => hasPipeline ? { + "label": "mockPipeline", + "getBindGroupLayout": vi.fn(() => ({ "label": "mockLayout" })) + } : null), + "getBindGroupLayout": vi.fn(() => ({ "label": "mockDynamicLayout" })) + } as unknown as PipelineManager; + }; + + const createMockAttachment = (clipLevel: number = 1): IAttachmentObject => + { + return { + "id": 1, + "width": 800, + "height": 600, + "clipLevel": clipLevel, + "texture": { + "resource": { "label": "mockTexture" } as unknown as GPUTexture, + "view": { "label": "mockTextureView" } as unknown as GPUTextureView + }, + "stencil": { + "resource": { "label": "mockStencil" } as unknown as GPUTexture, + "view": { "label": "mockStencilView" } as unknown as GPUTextureView + } + }; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + // Reset mocked Maps to initial state + ($clipLevels as Map).clear(); + ($clipLevels as Map).set(1, 1); + ($clipLevels as Map).set(2, 2); + }); + + describe("early exit conditions", () => + { + it("should proceed with unknown clip level using fallback", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const attachment = createMockAttachment(999); // Unknown clip level + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + attachment, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 1, + false + ); + + // Source no longer checks $clipBounds, uses clipLevel as fallback + expect(renderPassEncoder.draw).toHaveBeenCalled(); + }); + + it("should return early when path vertices is empty", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const attachment = createMockAttachment(1); + const pathVertices: IPath[] = []; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + attachment, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 1, + false + ); + + expect(renderPassEncoder.draw).not.toHaveBeenCalled(); + }); + }); + + describe("pipeline selection", () => + { + it("should use clip_write pipeline for atlas attachment", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const attachment = createMockAttachment(1); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + attachment, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 1, + false // isMainAttachment = false + ); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("clip_write"); + }); + + it("should use clip_write_main_N pipeline for main attachment", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const attachment = createMockAttachment(1); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + attachment, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 1, + true // isMainAttachment = true + ); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("clip_write_main_1"); + }); + + it("should return early when pipeline not found", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(false); + const attachment = createMockAttachment(1); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + attachment, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 1, + false + ); + + expect(console.error).toHaveBeenCalled(); + expect(renderPassEncoder.draw).not.toHaveBeenCalled(); + }); + }); + + describe("drawing", () => + { + it("should create vertex buffer and draw", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const attachment = createMockAttachment(1); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + attachment, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 1, + false + ); + + expect(bufferManager.acquireVertexBuffer).toHaveBeenCalled(); + expect(bufferManager.dynamicUniform.allocate).toHaveBeenCalled(); + expect(renderPassEncoder.setPipeline).toHaveBeenCalled(); + expect(renderPassEncoder.setStencilReference).toHaveBeenCalledWith(0); + expect(renderPassEncoder.setVertexBuffer).toHaveBeenCalledWith(0, expect.anything()); + expect(renderPassEncoder.setBindGroup).toHaveBeenCalledWith(0, expect.anything(), [0]); + expect(renderPassEncoder.draw).toHaveBeenCalledWith(6, 1, 0, 0); + }); + }); + + describe("clip level clamping", () => + { + it("should clamp level to maximum 8 for main attachment", () => + { + // Set up a high level + ($clipLevels as Map).set(10, 10); + + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const attachment = createMockAttachment(10); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + attachment, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 1, + true + ); + + // Level 10 should be clamped to 8 + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("clip_write_main_8"); + }); + }); +}); diff --git a/packages/webgpu/src/Context/usecase/ContextClipUseCase.ts b/packages/webgpu/src/Context/usecase/ContextClipUseCase.ts new file mode 100644 index 00000000..c86eb906 --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextClipUseCase.ts @@ -0,0 +1,140 @@ +import type { IPath } from "../../interface/IPath"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { BufferManager } from "../../BufferManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import { execute as meshFillGenerateUseCase } from "../../Mesh/usecase/MeshFillGenerateUseCase"; +import { execute as maskUnionMaskService } from "../../Mask/service/MaskUnionMaskService"; +import { $clipLevels } from "../../Mask"; + +const $clipUniform16 = new Float32Array(16); + +export const execute = ( + device: GPUDevice, + render_pass_encoder: GPURenderPassEncoder, + buffer_manager: BufferManager, + pipeline_manager: PipelineManager, + current_attachment: IAttachmentObject, + path_vertices: IPath[], + context_matrix: Float32Array, + fill_style: Float32Array, + global_alpha: number, + is_main_attachment: boolean = false +): void => { + // クリップ境界を取得 + const clipLevel = current_attachment.clipLevel; + + // メッシュを生成 + const viewportWidth = current_attachment.width; + const viewportHeight = current_attachment.height; + + const red = fill_style[0]; + const green = fill_style[1]; + const blue = fill_style[2]; + const alpha = fill_style[3] * global_alpha; + + if (path_vertices.length === 0) { + return; + } + + const mesh = meshFillGenerateUseCase(path_vertices); + + if (mesh.indexCount === 0) { + return; + } + + // 頂点バッファを取得(プールから再利用) + const vertexBuffer = buffer_manager.acquireVertexBuffer(mesh.buffer.byteLength, mesh.buffer); + + // Uniform bufferにcolor/matrixを書き込み + const a = context_matrix[0]; + const b = context_matrix[1]; + const c = context_matrix[3]; + const d = context_matrix[4]; + const tx = context_matrix[6]; + const ty = context_matrix[7]; + + $clipUniform16[0] = red; + $clipUniform16[1] = green; + $clipUniform16[2] = blue; + $clipUniform16[3] = alpha; + $clipUniform16[4] = a / viewportWidth; + $clipUniform16[5] = b / viewportHeight; + $clipUniform16[6] = 0; + $clipUniform16[7] = 0; + $clipUniform16[8] = c / viewportWidth; + $clipUniform16[9] = d / viewportHeight; + $clipUniform16[10] = 0; + $clipUniform16[11] = 0; + $clipUniform16[12] = tx / viewportWidth; + $clipUniform16[13] = ty / viewportHeight; + $clipUniform16[14] = 1; + $clipUniform16[15] = 0; + + // Dynamic Uniform Bufferにデータを書き込み + const uniformOffset = buffer_manager.dynamicUniform.allocate($clipUniform16); + + // 現在のマスクレベルを取得(WebGL版: $clipLevels.get(clipLevel)) + // レベルは1から始まり、各パスごとにインクリメントされる + let level = $clipLevels.get(clipLevel) ?? clipLevel; + + // ステンシルパイプラインを取得 + // メインアタッチメント: レベルに対応するパイプライン(stencilWriteMask = 1 << (level - 1)) + // アトラス: 通常のクリップパイプライン + let pipelineName: string; + if (is_main_attachment) { + // レベルを1-8の範囲にクランプ(8レベルまでサポート) + const clampedLevel = Math.min(8, Math.max(1, level)); + pipelineName = `clip_write_main_${clampedLevel}`; + } else { + pipelineName = "clip_write"; + } + + const pipeline = pipeline_manager.getPipeline(pipelineName); + if (!pipeline) { + console.error(`[WebGPU] ${pipelineName} pipeline not found`); + return; + } + + // Dynamic BindGroupを取得 + const layout = pipeline_manager.getBindGroupLayout("fill_dynamic"); + if (!layout) { + return; + } + const bindGroup = device.createBindGroup({ + "layout": layout, + "entries": [{ + "binding": 0, + "resource": { + "buffer": buffer_manager.dynamicUniform.getBuffer(), + "size": 256 + } + }] + }); + + // INVERT操作でマスク形状を描画 + // カバーされたピクセルのステンシル値が反転される(ビット単位) + // 奇数回カバーされたピクセルのみがマスク領域となる + render_pass_encoder.setPipeline(pipeline); + render_pass_encoder.setStencilReference(0); // INVERT操作では参照値は使用されないが、設定は必要 + render_pass_encoder.setVertexBuffer(0, vertexBuffer); + render_pass_encoder.setBindGroup(0, bindGroup, [uniformOffset]); + render_pass_encoder.draw(mesh.indexCount, 1, 0, 0); + + // レベルをインクリメント(WebGL版: ++level) + level++; + + // レベルが8を超えた場合、ステンシルビットをマージして再利用 + // WebGL版: if (level > 7) { maskUnionMaskService(); level = clipLevel + 1; } + if (level > 8) { + maskUnionMaskService( + device, + render_pass_encoder, + buffer_manager, + pipeline_manager, + current_attachment + ); + level = clipLevel + 1; + } + + $clipLevels.set(clipLevel, level); +}; diff --git a/packages/webgpu/src/Context/usecase/ContextContainerEndLayerUseCase.ts b/packages/webgpu/src/Context/usecase/ContextContainerEndLayerUseCase.ts new file mode 100644 index 00000000..e33c8475 --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextContainerEndLayerUseCase.ts @@ -0,0 +1,683 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IBlendMode } from "../../interface/IBlendMode"; +import type { ILocalFilterConfig } from "../../interface/ILocalFilterConfig"; +import type { BufferManager } from "../../BufferManager"; +import { $offset } from "../../Filter/FilterOffset"; +import { WebGPUUtil } from "../../WebGPUUtil"; +import { $cacheStore } from "@next2d/cache"; +import { execute as filterApplyBlurFilterUseCase } from "../../Filter/BlurFilter/FilterApplyBlurFilterUseCase"; +import { execute as filterApplyColorMatrixFilterUseCase } from "../../Filter/ColorMatrixFilter/FilterApplyColorMatrixFilterUseCase"; +import { execute as filterApplyGlowFilterUseCase } from "../../Filter/GlowFilter/FilterApplyGlowFilterUseCase"; +import { execute as filterApplyDropShadowFilterUseCase } from "../../Filter/DropShadowFilter/FilterApplyDropShadowFilterUseCase"; +import { execute as filterApplyBevelFilterUseCase } from "../../Filter/BevelFilter/FilterApplyBevelFilterUseCase"; +import { execute as filterApplyConvolutionFilterUseCase } from "../../Filter/ConvolutionFilter/FilterApplyConvolutionFilterUseCase"; +import { execute as filterApplyGradientBevelFilterUseCase } from "../../Filter/GradientBevelFilter/FilterApplyGradientBevelFilterUseCase"; +import { execute as filterApplyGradientGlowFilterUseCase } from "../../Filter/GradientGlowFilter/FilterApplyGradientGlowFilterUseCase"; +import { execute as filterApplyDisplacementMapFilterUseCase } from "../../Filter/DisplacementMapFilter/FilterApplyDisplacementMapFilterUseCase"; +import { execute as blendApplyComplexBlendUseCase } from "../../Blend/usecase/BlendApplyComplexBlendUseCase"; + +const $uniform4 = new Float32Array(4); +const $uniform8 = new Float32Array(8); +const $uniform20 = new Float32Array(20); + +// プリアロケート BindGroup Entry 配列 +const $entries3: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView } +]; + +const SIMPLE_BLEND_MODES: ReadonlySet = new Set([ + "normal", "layer", "add", "screen", "alpha", "erase", "copy" +] as IBlendMode[]); + +const $identityColorTransform = new Float32Array([1, 1, 1, 1, 0, 0, 0, 0]); + +const isIdentityColorTransform = (ct: Float32Array | null): boolean => { + if (!ct) { + return true; + } + return ct[0] === 1 && ct[1] === 1 && ct[2] === 1 && ct[3] === 1 + && ct[4] === 0 && ct[5] === 0 && ct[6] === 0 && ct[7] === 0; +}; + +const applyColorTransform = ( + config: ILocalFilterConfig, + attachment: IAttachmentObject, + colorTransform: Float32Array +): IAttachmentObject => { + const ctAttachment = config.frameBufferManager.createTemporaryAttachment( + attachment.width, attachment.height + ); + + const pipeline = config.pipelineManager.getPipeline("color_transform"); + const bindGroupLayout = config.pipelineManager.getBindGroupLayout("texture_copy"); + + if (!pipeline || !bindGroupLayout || !attachment.texture || !ctAttachment.texture) { + return attachment; + } + + $uniform8[0] = colorTransform[0]; + $uniform8[1] = colorTransform[1]; + $uniform8[2] = colorTransform[2]; + $uniform8[3] = colorTransform[3]; + $uniform8[4] = colorTransform[4]; + $uniform8[5] = colorTransform[5]; + $uniform8[6] = colorTransform[6]; + $uniform8[7] = 0; + const uniformBuffer = config.bufferManager.acquireAndWriteUniformBuffer($uniform8); + + const sampler = config.textureManager.createSampler("container_ct_sampler", false); + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = attachment.texture.view; + const bindGroup = config.device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + const renderPassDescriptor = config.frameBufferManager.createRenderPassDescriptor( + ctAttachment.texture.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = config.commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + return ctAttachment; +}; + +const copyRegionToFilterAttachment = ( + config: ILocalFilterConfig, + srcAttachment: IAttachmentObject, + x: number, + y: number, + width: number, + height: number +): IAttachmentObject => { + + const dstAttachment = config.frameBufferManager.createTemporaryAttachment(width, height); + + const pipeline = config.pipelineManager.getPipeline("complex_blend_copy"); + const bindGroupLayout = config.pipelineManager.getBindGroupLayout("texture_copy"); + + if (!pipeline || !bindGroupLayout || !srcAttachment.texture || !dstAttachment.texture) { + return dstAttachment; + } + + const scaleX = width / srcAttachment.width; + const offsetX = x / srcAttachment.width; + + // ComplexBlendCopyVertexはOpenGL座標系のtexCoord(Y軸反転)を使用するため、 + // UV uniformでY反転を補正して正しい向きの出力を得る + // texCoord.y=1(fb上端) → uv.y=y/H(ソース上端), texCoord.y=0(fb下端) → uv.y=(y+h)/H(ソース下端) + const scaleY = -(height / srcAttachment.height); + const offsetY = (y + height) / srcAttachment.height; + + $uniform4[0] = scaleX; + $uniform4[1] = scaleY; + $uniform4[2] = offsetX; + $uniform4[3] = offsetY; + const uniformBuffer = config.bufferManager.acquireAndWriteUniformBuffer($uniform4); + + const sampler = config.textureManager.createSampler("container_copy_sampler", false); + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = srcAttachment.texture.view; + const bindGroup = config.device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + const renderPassDescriptor = config.frameBufferManager.createRenderPassDescriptor( + dstAttachment.texture.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = config.commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + return dstAttachment; +}; + +const drawFilterResultToMain = ( + config: ILocalFilterConfig, + filterAttachment: IAttachmentObject, + mainAttachment: IAttachmentObject, + blendMode: IBlendMode, + x: number, + y: number, + bufferManager: BufferManager +): void => { + + if (!mainAttachment.texture || !filterAttachment.texture) { + return; + } + + // WebGLと同じサブピクセル精度を維持するため、Math.floorを使用しない + let drawX = x; + let drawY = y; + let drawWidth = filterAttachment.width; + let drawHeight = filterAttachment.height; + + let uvOffsetX = 0; + let uvOffsetY = 0; + if (drawX < 0) { + uvOffsetX = -drawX / filterAttachment.width; + drawWidth += drawX; + drawX = 0; + } + if (drawY < 0) { + uvOffsetY = -drawY / filterAttachment.height; + drawHeight += drawY; + drawY = 0; + } + + if (drawWidth <= 0 || drawHeight <= 0) { + return; + } + + const mainWidth = mainAttachment.width; + const mainHeight = mainAttachment.height; + if (drawX + drawWidth > mainWidth) { + drawWidth = mainWidth - drawX; + } + if (drawY + drawHeight > mainHeight) { + drawHeight = mainHeight - drawY; + } + + if (SIMPLE_BLEND_MODES.has(blendMode)) { + + const useMsaa = mainAttachment.msaa && mainAttachment.msaaTexture?.view; + + let pipelineName: string; + switch (blendMode) { + case "add": + pipelineName = useMsaa ? "filter_output_add_msaa" : "filter_output_add"; + break; + case "screen": + pipelineName = useMsaa ? "filter_output_screen_msaa" : "filter_output_screen"; + break; + case "alpha": + pipelineName = useMsaa ? "filter_output_alpha_msaa" : "filter_output_alpha"; + break; + case "erase": + pipelineName = useMsaa ? "filter_output_erase_msaa" : "filter_output_erase"; + break; + case "copy": + pipelineName = useMsaa ? "texture_copy_bgra_msaa" : "texture_copy_bgra"; + break; + default: + pipelineName = useMsaa ? "filter_output_msaa" : "filter_output"; + break; + } + + const pipeline = config.pipelineManager.getPipeline(pipelineName); + const bindGroupLayout = config.pipelineManager.getBindGroupLayout("texture_copy"); + + if (!pipeline || !bindGroupLayout) { + return; + } + + const sampler = config.textureManager.createSampler("container_output_sampler", true); + + const uvScaleX = drawWidth / filterAttachment.width; + const uvScaleY = drawHeight / filterAttachment.height; + $uniform4[0] = uvScaleX; + $uniform4[1] = uvScaleY; + $uniform4[2] = uvOffsetX; + $uniform4[3] = uvOffsetY; + const uniformBuffer = bufferManager.acquireAndWriteUniformBuffer($uniform4); + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = filterAttachment.texture.view; + const bindGroup = config.device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + const colorView = useMsaa ? mainAttachment.msaaTexture!.view : mainAttachment.texture.view; + const resolveTarget = useMsaa ? mainAttachment.texture.view : null; + const renderPassDescriptor = config.frameBufferManager.createRenderPassDescriptor( + colorView, 0, 0, 0, 0, "load", resolveTarget + ); + + // Viewportはfloat値でサブピクセル精度を維持(WebGLのsetTransform相当) + const vpX = Math.max(0, drawX); + const vpY = Math.max(0, drawY); + const vpW = Math.max(1, drawWidth); + const vpH = Math.max(1, drawHeight); + const scissorX = Math.max(0, Math.floor(vpX)); + const scissorY = Math.max(0, Math.floor(vpY)); + const scissorW = Math.max(1, Math.min(Math.ceil(vpX + vpW) - scissorX, mainWidth - scissorX)); + const scissorH = Math.max(1, Math.min(Math.ceil(vpY + vpH) - scissorY, mainHeight - scissorY)); + + if (scissorW <= 0 || scissorH <= 0 || scissorX >= mainWidth || scissorY >= mainHeight) { + return; + } + + const passEncoder = config.commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.setViewport(vpX, vpY, vpW, vpH, 0, 1); + passEncoder.setScissorRect(scissorX, scissorY, scissorW, scissorH); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + } else { + + // 複雑なブレンドモード + const dstAttachment = copyRegionToFilterAttachment( + config, mainAttachment, drawX, drawY, drawWidth, drawHeight + ); + + $uniform8[0] = $identityColorTransform[0]; + $uniform8[1] = $identityColorTransform[1]; + $uniform8[2] = $identityColorTransform[2]; + $uniform8[3] = $identityColorTransform[3]; + $uniform8[4] = $identityColorTransform[4] / 255; + $uniform8[5] = $identityColorTransform[5] / 255; + $uniform8[6] = $identityColorTransform[6] / 255; + $uniform8[7] = 0; + + const blendedAttachment = blendApplyComplexBlendUseCase( + filterAttachment, dstAttachment, blendMode, $uniform8, { + "device": config.device, + "commandEncoder": config.commandEncoder, + "bufferManager": config.bufferManager, + "frameBufferManager": config.frameBufferManager, + "pipelineManager": config.pipelineManager, + "textureManager": config.textureManager, + "frameTextures": config.frameTextures + } + ); + + // 結果をメインに描画 + const useMsaa = mainAttachment.msaa && mainAttachment.msaaTexture?.view; + const resultPipelineName = useMsaa ? "filter_complex_blend_output_msaa" : "filter_complex_blend_output"; + const resultPipeline = config.pipelineManager.getPipeline(resultPipelineName); + const resultLayout = config.pipelineManager.getBindGroupLayout("positioned_texture"); + + if (resultPipeline && resultLayout && blendedAttachment.texture && mainAttachment.texture) { + + $uniform8[0] = drawX; + $uniform8[1] = drawY; + $uniform8[2] = blendedAttachment.width; + $uniform8[3] = blendedAttachment.height; + $uniform8[4] = mainAttachment.width; + $uniform8[5] = mainAttachment.height; + $uniform8[6] = 0; + $uniform8[7] = 0; + const uniformBuffer = bufferManager.acquireAndWriteUniformBuffer($uniform8); + + const sampler = config.textureManager.createSampler("container_blend_output_sampler", false); + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = blendedAttachment.texture.view; + const bindGroup = config.device.createBindGroup({ + "layout": resultLayout, + "entries": $entries3 + }); + + const colorView = useMsaa ? mainAttachment.msaaTexture!.view : mainAttachment.texture.view; + const resolveTarget = useMsaa ? mainAttachment.texture.view : null; + const renderPassDescriptor = config.frameBufferManager.createRenderPassDescriptor( + colorView, 0, 0, 0, 0, "load", resolveTarget + ); + + const passEncoder = config.commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(resultPipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + } + + config.frameBufferManager.releaseTemporaryAttachment(dstAttachment); + config.frameBufferManager.releaseTemporaryAttachment(blendedAttachment); + } +}; + +const applyFilterChain = ( + filterAttachment: IAttachmentObject, + matrix: Float32Array, + params: Float32Array, + devicePixelRatio: number, + config: ILocalFilterConfig +): IAttachmentObject => { + + $offset.x = 0; + $offset.y = 0; + + for (let idx = 0; params.length > idx; ) { + const type = params[idx++]; + + switch (type) { + + case 0: // BevelFilter + { + const newAtt = filterApplyBevelFilterUseCase( + filterAttachment, matrix, + params[idx++], params[idx++], params[idx++], params[idx++], + params[idx++], params[idx++], params[idx++], params[idx++], + params[idx++], params[idx++], params[idx++], Boolean(params[idx++]), + devicePixelRatio, config + ); + if (filterAttachment !== newAtt) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAtt; + } + break; + + case 1: // BlurFilter + { + const newAtt = filterApplyBlurFilterUseCase( + filterAttachment, matrix, + params[idx++], params[idx++], params[idx++], + devicePixelRatio, config + ); + if (filterAttachment !== newAtt) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAtt; + } + break; + + case 2: // ColorMatrixFilter + { + $uniform20[0] = params[idx++]; + $uniform20[1] = params[idx++]; + $uniform20[2] = params[idx++]; + $uniform20[3] = params[idx++]; + $uniform20[4] = params[idx++]; + $uniform20[5] = params[idx++]; + $uniform20[6] = params[idx++]; + $uniform20[7] = params[idx++]; + $uniform20[8] = params[idx++]; + $uniform20[9] = params[idx++]; + $uniform20[10] = params[idx++]; + $uniform20[11] = params[idx++]; + $uniform20[12] = params[idx++]; + $uniform20[13] = params[idx++]; + $uniform20[14] = params[idx++]; + $uniform20[15] = params[idx++]; + $uniform20[16] = params[idx++]; + $uniform20[17] = params[idx++]; + $uniform20[18] = params[idx++]; + $uniform20[19] = params[idx++]; + const newAtt = filterApplyColorMatrixFilterUseCase( + filterAttachment, $uniform20, config + ); + if (filterAttachment !== newAtt) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAtt; + } + break; + + case 3: // ConvolutionFilter + { + const matrixX = params[idx++]; + const matrixY = params[idx++]; + const length = matrixX * matrixY; + const convMatrix = new Float32Array(length); + for (let i = 0; i < length; i++) { + convMatrix[i] = params[idx++]; + } + + const newAtt = filterApplyConvolutionFilterUseCase( + filterAttachment, + matrixX, matrixY, convMatrix, + params[idx++], params[idx++], + Boolean(params[idx++]), Boolean(params[idx++]), + params[idx++], params[idx++], + config + ); + if (filterAttachment !== newAtt) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAtt; + } + break; + + case 4: // DisplacementMapFilter + { + const dmLen = params[idx++]; + const dmBuffer = new Uint8Array(dmLen); + for (let i = 0; i < dmLen; i++) { + dmBuffer[i] = params[idx++]; + } + + const newAtt = filterApplyDisplacementMapFilterUseCase( + filterAttachment, matrix, + dmBuffer, params[idx++], params[idx++], + params[idx++], params[idx++], + params[idx++], params[idx++], + params[idx++], params[idx++], + params[idx++], params[idx++], params[idx++], + devicePixelRatio, config + ); + if (filterAttachment !== newAtt) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAtt; + } + break; + + case 5: // DropShadowFilter + { + const newAtt = filterApplyDropShadowFilterUseCase( + filterAttachment, matrix, + params[idx++], params[idx++], params[idx++], params[idx++], + params[idx++], params[idx++], params[idx++], params[idx++], + Boolean(params[idx++]), Boolean(params[idx++]), Boolean(params[idx++]), + devicePixelRatio, config + ); + if (filterAttachment !== newAtt) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAtt; + } + break; + + case 6: // GlowFilter + { + const newAtt = filterApplyGlowFilterUseCase( + filterAttachment, matrix, + params[idx++], params[idx++], params[idx++], params[idx++], + params[idx++], params[idx++], + Boolean(params[idx++]), Boolean(params[idx++]), + devicePixelRatio, config + ); + if (filterAttachment !== newAtt) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAtt; + } + break; + + case 7: // GradientBevelFilter + { + const gbDist = params[idx++]; + const gbAngle = params[idx++]; + + const gbColorsLen = params[idx++]; + const gbColors = new Float32Array(gbColorsLen); + for (let i = 0; i < gbColorsLen; i++) { gbColors[i] = params[idx++] } + + const gbAlphasLen = params[idx++]; + const gbAlphas = new Float32Array(gbAlphasLen); + for (let i = 0; i < gbAlphasLen; i++) { gbAlphas[i] = params[idx++] } + + const gbRatiosLen = params[idx++]; + const gbRatios = new Float32Array(gbRatiosLen); + for (let i = 0; i < gbRatiosLen; i++) { gbRatios[i] = params[idx++] } + + const newAtt = filterApplyGradientBevelFilterUseCase( + filterAttachment, matrix, + gbDist, gbAngle, gbColors, gbAlphas, gbRatios, + params[idx++], params[idx++], params[idx++], + params[idx++], params[idx++], Boolean(params[idx++]), + devicePixelRatio, config + ); + if (filterAttachment !== newAtt) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAtt; + } + break; + + case 8: // GradientGlowFilter + { + const ggDist = params[idx++]; + const ggAngle = params[idx++]; + + const ggColorsLen = params[idx++]; + const ggColors = new Float32Array(ggColorsLen); + for (let i = 0; i < ggColorsLen; i++) { ggColors[i] = params[idx++] } + + const ggAlphasLen = params[idx++]; + const ggAlphas = new Float32Array(ggAlphasLen); + for (let i = 0; i < ggAlphasLen; i++) { ggAlphas[i] = params[idx++] } + + const ggRatiosLen = params[idx++]; + const ggRatios = new Float32Array(ggRatiosLen); + for (let i = 0; i < ggRatiosLen; i++) { ggRatios[i] = params[idx++] } + + const newAtt = filterApplyGradientGlowFilterUseCase( + filterAttachment, matrix, + ggDist, ggAngle, ggColors, ggAlphas, ggRatios, + params[idx++], params[idx++], params[idx++], + params[idx++], params[idx++], Boolean(params[idx++]), + devicePixelRatio, config + ); + if (filterAttachment !== newAtt) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + filterAttachment = newAtt; + } + break; + } + } + + return filterAttachment; +}; + +export const execute = ( + tempAttachment: IAttachmentObject, + mainAttachment: IAttachmentObject, + _tempName: string, + blendMode: IBlendMode, + matrix: Float32Array, + colorTransform: Float32Array | null, + useFilter: boolean, + filterBounds: Float32Array | null, + params: Float32Array | null, + uniqueKey: string, + filterKey: string, + _contentWidth: number, + _contentHeight: number, + config: ILocalFilterConfig, + bufferManager: BufferManager +): void => { + + if (useFilter && matrix && filterBounds && params) { + + // containerEndLayerが呼ばれる=ディスプレイレイヤーがコンテンツ変更を検出して再レンダリングを要求 + // 常に新鮮なテクスチャを抽出してフィルターを適用する + // (キャッシュはディスプレイレイヤーのcontainerDrawCachedFilterで管理) + + // WebGL版と同じ: レイヤー全体をフィルター用にコピー + // レイヤーはコンテンツサイズで作成され、childrenは相対座標で描画されているため + // (0, 0, layerWidth, layerHeight) = コンテンツ全体 + let filterAttachment = copyRegionToFilterAttachment( + config, tempAttachment, + 0, 0, tempAttachment.width, tempAttachment.height + ); + + // 一時アタッチメントを遅延解放(コマンドバッファsubmit後に解放) + // destroyAttachmentは即座にGPUテクスチャを破棄するため、 + // コマンドエンコーダに記録済みのレンダーパスが参照するテクスチャが無効になる + config.frameBufferManager.releaseTemporaryAttachment(tempAttachment); + + // フィルターチェーンを適用 + const devicePixelRatio = WebGPUUtil.getDevicePixelRatio(); + filterAttachment = applyFilterChain( + filterAttachment, matrix, params, devicePixelRatio, config + ); + + // キャッシュに保存 + if (uniqueKey) { + $cacheStore.set(uniqueKey, "fKey", filterKey); + $cacheStore.set(uniqueKey, "fTexture", filterAttachment); + } + + // フィルター結果をメインに描画 + if (filterAttachment) { + // ColorTransformが恒等変換でない場合、描画用に一時コピーを作成してCTを適用 + // キャッシュにはフィルター結果のみ保存(CTは毎フレーム適用する) + let drawAttachment = filterAttachment; + let ctAttachment: IAttachmentObject | null = null; + if (!isIdentityColorTransform(colorTransform)) { + ctAttachment = applyColorTransform(config, filterAttachment, colorTransform!); + drawAttachment = ctAttachment; + } + + const scaleX = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]); + const scaleY = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]); + const boundsXMin = filterBounds[0] * (scaleX / devicePixelRatio); + const boundsYMin = filterBounds[1] * (scaleY / devicePixelRatio); + + // WebGL版と同じ: boundsXMin + matrix[4] で絶対位置 + const drawX = boundsXMin + matrix[4]; + const drawY = boundsYMin + matrix[5]; + + drawFilterResultToMain( + config, drawAttachment, mainAttachment, + blendMode, drawX, drawY, bufferManager + ); + + // CT一時アタッチメントを解放 + if (ctAttachment) { + config.frameBufferManager.releaseTemporaryAttachment(ctAttachment); + } + // キャッシュされていないフィルター結果のみ解放 + if (!uniqueKey) { + config.frameBufferManager.releaseTemporaryAttachment(filterAttachment); + } + } + + } else { + + // ブレンドのみ:レイヤー全体をフィルター用にコピーしてメインに描画 + let fullAttachment = copyRegionToFilterAttachment( + config, tempAttachment, + 0, 0, tempAttachment.width, tempAttachment.height + ); + + // 一時アタッチメントを遅延解放(コマンドバッファsubmit後に解放) + config.frameBufferManager.releaseTemporaryAttachment(tempAttachment); + + // ColorTransformが恒等変換でない場合、適用 + if (!isIdentityColorTransform(colorTransform)) { + const ctAttachment = applyColorTransform(config, fullAttachment, colorTransform!); + config.frameBufferManager.releaseTemporaryAttachment(fullAttachment); + fullAttachment = ctAttachment; + } + + // WebGL版と同じ: matrix[4], matrix[5] = layerBounds の絶対位置に描画 + drawFilterResultToMain( + config, fullAttachment, mainAttachment, + blendMode, matrix[4], matrix[5], bufferManager + ); + + config.frameBufferManager.releaseTemporaryAttachment(fullAttachment); + } +}; diff --git a/packages/webgpu/src/Context/usecase/ContextDrawArraysInstancedUseCase.test.ts b/packages/webgpu/src/Context/usecase/ContextDrawArraysInstancedUseCase.test.ts new file mode 100644 index 00000000..b89dccb2 --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextDrawArraysInstancedUseCase.test.ts @@ -0,0 +1,462 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { BufferManager } from "../../BufferManager"; +import type { FrameBufferManager } from "../../FrameBufferManager"; +import type { TextureManager } from "../../TextureManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import { execute } from "./ContextDrawArraysInstancedUseCase"; + +// Mock modules +vi.mock("../../Blend/BlendInstancedManager", () => ({ + "getInstancedShaderManager": vi.fn(() => ({ + "count": 10, + "clear": vi.fn() + })) +})); + +vi.mock("../../Blend", () => ({ + "$currentBlendMode": "normal" +})); + +vi.mock("@next2d/render-queue", () => ({ + "renderQueue": { + "buffer": new Float32Array(100), + "offset": 50 + } +})); + +vi.mock("../../Mask", () => ({ + "$isMaskTestEnabled": vi.fn(() => false), + "$getMaskStencilReference": vi.fn(() => 0) +})); + +vi.mock("../../AtlasManager", () => ({ + "$getAtlasAttachmentObject": vi.fn(() => ({ + "texture": { + "resource": { "label": "atlasTexture" }, + "view": { "label": "atlasTextureView" } + } + })) +})); + +import { getInstancedShaderManager } from "../../Blend/BlendInstancedManager"; +import * as BlendModule from "../../Blend"; +import { $isMaskTestEnabled } from "../../Mask"; +import { $getAtlasAttachmentObject } from "../../AtlasManager"; + +describe("ContextDrawArraysInstancedUseCase", () => +{ + const createMockDevice = () => + { + return { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })), + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })) + } as unknown as GPUDevice; + }; + + const createMockCommandEncoder = () => + { + const mockPassEncoder = { + "setPipeline": vi.fn(), + "setStencilReference": vi.fn(), + "setVertexBuffer": vi.fn(), + "setBindGroup": vi.fn(), + "draw": vi.fn(), + "end": vi.fn() + }; + return { + "beginRenderPass": vi.fn(() => mockPassEncoder), + "_mockPassEncoder": mockPassEncoder + } as unknown as GPUCommandEncoder & { _mockPassEncoder: any }; + }; + + const createMockRenderPassEncoder = () => + { + return { + "end": vi.fn() + } as unknown as GPURenderPassEncoder; + }; + + const createMockAttachment = (): IAttachmentObject => + { + return { + "id": 1, + "width": 800, + "height": 600, + "texture": { + "resource": { "label": "mockTexture" } as unknown as GPUTexture, + "view": { "label": "mockTextureView" } as unknown as GPUTextureView + }, + "stencil": { + "resource": { "label": "mockStencil" } as unknown as GPUTexture, + "view": { "label": "mockStencilView" } as unknown as GPUTextureView + } + }; + }; + + const createMockBufferManager = () => + { + return { + "createVertexBuffer": vi.fn(() => ({ "label": "mockVertexBuffer" })), + "acquireVertexBuffer": vi.fn(() => ({ "label": "mockVertexBuffer" })), + "createRectVertices": vi.fn(() => new Float32Array([0, 0, 1, 0, 1, 1, 0, 1])), + "getUnitRectBuffer": vi.fn(() => ({ "label": "mockUnitRectBuffer" })) + } as unknown as BufferManager; + }; + + const createMockFrameBufferManager = () => + { + return { + "createRenderPassDescriptor": vi.fn(() => ({ "label": "mockDescriptor" })), + "createStencilRenderPassDescriptor": vi.fn(() => ({ "label": "mockStencilDescriptor" })), + "getAttachment": vi.fn(() => ({ + "texture": { + "resource": { "label": "atlasTexture" }, + "view": { "label": "atlasTextureView" } + } + })) + } as unknown as FrameBufferManager; + }; + + const createMockTextureManager = () => + { + return { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })) + } as unknown as TextureManager; + }; + + const createMockPipelineManager = (hasPipeline: boolean = true, hasLayout: boolean = true) => + { + return { + "getPipeline": vi.fn(() => hasPipeline ? { "label": "mockPipeline" } : null), + "getBindGroupLayout": vi.fn(() => hasLayout ? { "label": "mockLayout" } : null) + } as unknown as PipelineManager; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + (getInstancedShaderManager as ReturnType).mockReturnValue({ + "count": 10, + "clear": vi.fn() + }); + (BlendModule as any).$currentBlendMode = "normal"; + ($isMaskTestEnabled as ReturnType).mockReturnValue(false); + }); + + describe("early exit conditions", () => + { + it("should return render pass encoder when count is 0", () => + { + (getInstancedShaderManager as ReturnType).mockReturnValue({ + "count": 0, + "clear": vi.fn() + }); + + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const renderPassEncoder = createMockRenderPassEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + const result = execute( + device, + commandEncoder, + renderPassEncoder, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager + ); + + expect(result).toBe(renderPassEncoder); + expect(commandEncoder.beginRenderPass).not.toHaveBeenCalled(); + }); + }); + + describe("render pass handling", () => + { + it("should end existing render pass before creating new one", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const renderPassEncoder = createMockRenderPassEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + execute( + device, + commandEncoder, + renderPassEncoder, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager + ); + + expect(renderPassEncoder.end).toHaveBeenCalled(); + }); + }); + + describe("pipeline selection based on blend mode", () => + { + it("should use instanced for normal blend mode", () => + { + (BlendModule as any).$currentBlendMode = ("normal"); + + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + execute( + device, + commandEncoder, + null, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager + ); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("instanced"); + }); + + it("should use instanced_add for add blend mode", () => + { + (BlendModule as any).$currentBlendMode = ("add"); + + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + execute( + device, + commandEncoder, + null, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager + ); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("instanced_add"); + }); + + it("should use instanced_screen for screen blend mode", () => + { + (BlendModule as any).$currentBlendMode = ("screen"); + + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + execute( + device, + commandEncoder, + null, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager + ); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("instanced_screen"); + }); + }); + + describe("atlas attachment handling", () => + { + it("should use atlas attachment from AtlasManager", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + execute( + device, + commandEncoder, + null, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager + ); + + // AtlasManagerから取得するため、frameBufferManager.getAttachmentは呼ばれない + expect($getAtlasAttachmentObject).toHaveBeenCalled(); + expect(commandEncoder._mockPassEncoder.draw).toHaveBeenCalled(); + }); + + it("should return null when atlas attachment not found", () => + { + // AtlasManagerとFrameBufferManager両方からnullを返す + ($getAtlasAttachmentObject as ReturnType).mockReturnValueOnce(null); + + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = { + ...createMockFrameBufferManager(), + "getAttachment": vi.fn(() => null) + } as unknown as FrameBufferManager; + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + const result = execute( + device, + commandEncoder, + null, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager + ); + + expect(result).toBe(null); + expect(console.error).toHaveBeenCalledWith("[WebGPU] Atlas attachment not found"); + }); + }); + + describe("drawing", () => + { + it("should draw with correct instance count", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + execute( + device, + commandEncoder, + null, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager + ); + + expect(commandEncoder._mockPassEncoder.draw).toHaveBeenCalledWith(6, 10, 0, 0); + }); + + it("should clear shader manager after drawing", () => + { + const mockClear = vi.fn(); + (getInstancedShaderManager as ReturnType).mockReturnValue({ + "count": 10, + "clear": mockClear + }); + + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + execute( + device, + commandEncoder, + null, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager + ); + + expect(mockClear).toHaveBeenCalled(); + }); + }); + + describe("mask handling", () => + { + it("should use masked pipeline when mask is enabled", () => + { + ($isMaskTestEnabled as ReturnType).mockReturnValue(true); + + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + execute( + device, + commandEncoder, + null, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager + ); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("instanced_masked"); + }); + + it("should create stencil render pass descriptor when mask enabled", () => + { + ($isMaskTestEnabled as ReturnType).mockReturnValue(true); + + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + execute( + device, + commandEncoder, + null, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager + ); + + expect(frameBufferManager.createStencilRenderPassDescriptor).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/webgpu/src/Context/usecase/ContextDrawArraysInstancedUseCase.ts b/packages/webgpu/src/Context/usecase/ContextDrawArraysInstancedUseCase.ts new file mode 100644 index 00000000..74b4980c --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextDrawArraysInstancedUseCase.ts @@ -0,0 +1,190 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IBlendMode } from "../../interface/IBlendMode"; +import type { BufferManager } from "../../BufferManager"; +import type { FrameBufferManager } from "../../FrameBufferManager"; +import type { TextureManager } from "../../TextureManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import { getInstancedShaderManager } from "../../Blend/BlendInstancedManager"; +import { $currentBlendMode } from "../../Blend"; +import { renderQueue } from "@next2d/render-queue"; +import { + $isMaskTestEnabled, + $getMaskStencilReference +} from "../../Mask"; +import { $getAtlasAttachmentObject } from "../../AtlasManager"; + +let $cachedBindGroup: GPUBindGroup | null = null; +let $cachedAtlasView: GPUTextureView | null = null; + +export const execute = ( + device: GPUDevice, + command_encoder: GPUCommandEncoder, + render_pass_encoder: GPURenderPassEncoder | null, + main_attachment: IAttachmentObject, + buffer_manager: BufferManager, + frame_buffer_manager: FrameBufferManager, + texture_manager: TextureManager, + pipeline_manager: PipelineManager +): GPURenderPassEncoder | null => { + const shaderManager = getInstancedShaderManager(); + + if (shaderManager.count === 0) { + return render_pass_encoder; + } + + // 既存のレンダーパスを終了 + if (render_pass_encoder) { + render_pass_encoder.end(); + render_pass_encoder = null; + } + + const isMasked = $isMaskTestEnabled(); + const maskReference = $getMaskStencilReference(); + + // 現在のブレンドモードを取得 + const blendMode: IBlendMode = $currentBlendMode; + + // ブレンドモードに応じたパイプライン名を生成 + // simpleBlendModes: normal, layer, add, screen, alpha, erase, copy + const getPipelineName = (mode: IBlendMode): string => { + switch (mode) { + case "add": + return "instanced_add"; + case "screen": + return "instanced_screen"; + case "alpha": + return "instanced_alpha"; + case "erase": + return "instanced_erase"; + case "copy": + return "instanced_copy"; + default: + // normal, layer + return "instanced"; + } + }; + + const pipelineName = getPipelineName(blendMode); + const normalPipeline = pipeline_manager.getPipeline(pipelineName); + const maskedPipeline = pipeline_manager.getPipeline("instanced_masked"); + + // 実際にマスクを使用するか判定 + // maskedパイプラインが存在し、マスクが有効で、ステンシルがある場合のみ + const useStencil = isMasked && maskedPipeline + && (main_attachment.msaaStencil?.view || main_attachment.stencil?.view); + + const pipeline = useStencil ? maskedPipeline : normalPipeline; + + if (!pipeline) { + console.error("[WebGPU] Instanced pipeline not found"); + return null; + } + + // レンダーパスを作成(パイプラインに合わせてステンシルの有無を決定) + let passEncoder: GPURenderPassEncoder; + + if (useStencil) { + // ステンシル付きレンダーパス(マスク用)- MSAA対応 + const useMsaa = main_attachment.msaa && main_attachment.msaaTexture?.view; + const colorView = useMsaa ? main_attachment.msaaTexture!.view : main_attachment.texture!.view; + const stencilView = useMsaa && main_attachment.msaaStencil?.view + ? main_attachment.msaaStencil.view : main_attachment.stencil!.view; + const resolveTarget = useMsaa ? main_attachment.texture!.view : null; + + const renderPassDescriptor = frame_buffer_manager.createStencilRenderPassDescriptor( + colorView, + stencilView, + "load", // カラーは既存の内容を保持 + "load", // ステンシルも既存の内容を保持(マスク情報) + resolveTarget + ); + passEncoder = command_encoder.beginRenderPass(renderPassDescriptor); + } else { + // 通常のレンダーパス(MSAA対応) + const useMsaa = main_attachment.msaa && main_attachment.msaaTexture?.view; + const colorView = useMsaa ? main_attachment.msaaTexture!.view : main_attachment.texture!.view; + const resolveTarget = useMsaa ? main_attachment.texture!.view : null; + const renderPassDescriptor = frame_buffer_manager.createRenderPassDescriptor( + colorView, + 0, 0, 0, 0, + "load", // 既存の内容を保持 + resolveTarget + ); + passEncoder = command_encoder.beginRenderPass(renderPassDescriptor); + } + + passEncoder.setPipeline(pipeline); + + // マスク有効時はステンシル参照値を設定 + if (useStencil) { + passEncoder.setStencilReference(maskReference); + } + + // インスタンスバッファを作成 + // renderQueue.offsetは配列のインデックスなので、そのまま使用 + const instanceData = new Float32Array( + renderQueue.buffer.buffer, + renderQueue.buffer.byteOffset, + renderQueue.offset // 要素数 + ); + + const instanceBuffer = buffer_manager.acquireVertexBuffer(instanceData.byteLength, instanceData); + + // 頂点バッファ(矩形)を取得(キャッシュ済み) + const vertexBuffer = buffer_manager.getUnitRectBuffer(); + + // アトラステクスチャをバインド(複数アトラス対応) + // AtlasManagerから取得、フォールバックとしてFrameBufferManagerから取得 + const atlasAttachment = $getAtlasAttachmentObject() || frame_buffer_manager.getAttachment("atlas"); + if (!atlasAttachment) { + console.error("[WebGPU] Atlas attachment not found"); + passEncoder.end(); + return null; + } + + // アトラス用サンプラーを取得(キャッシュ済み) + // MIN_FILTER: linear(縮小時・回転時にスムーズ) + // MAG_FILTER: nearest(拡大時にシャープ) + const sampler = texture_manager.createSampler("atlas_instanced_sampler", false); + + // バインドグループを作成 + const bindGroupLayout = pipeline_manager.getBindGroupLayout("instanced"); + if (!bindGroupLayout) { + console.error("[WebGPU] Instanced bind group layout not found"); + passEncoder.end(); + return null; + } + + // BindGroupキャッシュ: アトラスのテクスチャビューが同じなら再利用 + const atlasView = atlasAttachment.texture!.view; + if (!$cachedBindGroup || $cachedAtlasView !== atlasView) { + $cachedBindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": [ + { + "binding": 0, + "resource": sampler + }, + { + "binding": 1, + "resource": atlasView + } + ] + }); + $cachedAtlasView = atlasView; + } + + // 描画 + passEncoder.setVertexBuffer(0, vertexBuffer); + passEncoder.setVertexBuffer(1, instanceBuffer); + passEncoder.setBindGroup(0, $cachedBindGroup); + passEncoder.draw(6, shaderManager.count, 0, 0); + + // レンダーパスを終了 + passEncoder.end(); + + // インスタンスデータをクリア + shaderManager.clear(); + + return null; +}; diff --git a/packages/webgpu/src/Context/usecase/ContextDrawIndirectUseCase.test.ts b/packages/webgpu/src/Context/usecase/ContextDrawIndirectUseCase.test.ts new file mode 100644 index 00000000..cf719135 --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextDrawIndirectUseCase.test.ts @@ -0,0 +1,394 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { BufferManager } from "../../BufferManager"; +import type { FrameBufferManager } from "../../FrameBufferManager"; +import type { TextureManager } from "../../TextureManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import { execute } from "./ContextDrawIndirectUseCase"; + +// Mock modules +vi.mock("../../Blend/BlendInstancedManager", () => ({ + "getInstancedShaderManager": vi.fn(() => ({ + "count": 10, + "clear": vi.fn() + })) +})); + +vi.mock("../../Blend", () => ({ + "$currentBlendMode": "normal" +})); + +vi.mock("@next2d/render-queue", () => ({ + "renderQueue": { + "buffer": new Float32Array(100), + "offset": 50 + } +})); + +vi.mock("../../Mask", () => ({ + "$isMaskTestEnabled": vi.fn(() => false), + "$getMaskStencilReference": vi.fn(() => 0) +})); + +vi.mock("../../AtlasManager", () => ({ + "$getAtlasAttachmentObject": vi.fn(() => ({ + "texture": { + "resource": { "label": "atlasTexture" }, + "view": { "label": "atlasTextureView" } + } + })) +})); + +import { getInstancedShaderManager } from "../../Blend/BlendInstancedManager"; +import * as BlendModule from "../../Blend"; + +describe("ContextDrawIndirectUseCase", () => +{ + const createMockDevice = () => + { + return { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })), + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })) + } as unknown as GPUDevice; + }; + + const createMockCommandEncoder = () => + { + const mockPassEncoder = { + "setPipeline": vi.fn(), + "setStencilReference": vi.fn(), + "setVertexBuffer": vi.fn(), + "setBindGroup": vi.fn(), + "draw": vi.fn(), + "drawIndirect": vi.fn(), + "end": vi.fn() + }; + return { + "beginRenderPass": vi.fn(() => mockPassEncoder), + "_mockPassEncoder": mockPassEncoder + } as unknown as GPUCommandEncoder & { _mockPassEncoder: any }; + }; + + const createMockRenderPassEncoder = () => + { + return { + "end": vi.fn() + } as unknown as GPURenderPassEncoder; + }; + + const createMockAttachment = (): IAttachmentObject => + { + return { + "id": 1, + "width": 800, + "height": 600, + "texture": { + "resource": { "label": "mockTexture" } as unknown as GPUTexture, + "view": { "label": "mockTextureView" } as unknown as GPUTextureView + }, + "stencil": { + "resource": { "label": "mockStencil" } as unknown as GPUTexture, + "view": { "label": "mockStencilView" } as unknown as GPUTextureView + } + }; + }; + + const createMockBufferManager = () => + { + return { + "createVertexBuffer": vi.fn(() => ({ "label": "mockVertexBuffer" })), + "acquireVertexBuffer": vi.fn(() => ({ "label": "mockVertexBuffer" })), + "createRectVertices": vi.fn(() => new Float32Array([0, 0, 1, 0, 1, 1, 0, 1])), + "acquireStorageBuffer": vi.fn(() => ({ "label": "mockStorageBuffer" })), + "writeStorageBuffer": vi.fn(), + "createIndirectBuffer": vi.fn(() => ({ "label": "mockIndirectBuffer" })), + "getUnitRectBuffer": vi.fn(() => ({ "label": "mockUnitRectBuffer" })) + } as unknown as BufferManager; + }; + + const createMockFrameBufferManager = () => + { + return { + "createRenderPassDescriptor": vi.fn(() => ({ "label": "mockDescriptor" })), + "createStencilRenderPassDescriptor": vi.fn(() => ({ "label": "mockStencilDescriptor" })), + "getAttachment": vi.fn(() => ({ + "texture": { + "resource": { "label": "atlasTexture" }, + "view": { "label": "atlasTextureView" } + } + })) + } as unknown as FrameBufferManager; + }; + + const createMockTextureManager = () => + { + return { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })) + } as unknown as TextureManager; + }; + + const createMockPipelineManager = (hasPipeline: boolean = true, hasLayout: boolean = true) => + { + return { + "getPipeline": vi.fn(() => hasPipeline ? { "label": "mockPipeline" } : null), + "getBindGroupLayout": vi.fn(() => hasLayout ? { "label": "mockLayout" } : null) + } as unknown as PipelineManager; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + (getInstancedShaderManager as ReturnType).mockReturnValue({ + "count": 10, + "clear": vi.fn() + }); + (BlendModule as any).$currentBlendMode = "normal"; + }); + + describe("early exit conditions", () => + { + it("should return render pass encoder when count is 0", () => + { + (getInstancedShaderManager as ReturnType).mockReturnValue({ + "count": 0, + "clear": vi.fn() + }); + + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const renderPassEncoder = createMockRenderPassEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + const result = execute( + device, + commandEncoder, + renderPassEncoder, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager + ); + + expect(result).toBe(renderPassEncoder); + expect(commandEncoder.beginRenderPass).not.toHaveBeenCalled(); + }); + }); + + describe("storage buffer handling", () => + { + it("should use storage buffer when useStorageBuffer is true (default)", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + execute( + device, + commandEncoder, + null, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager, + true, // useIndirect + true // useStorageBuffer (default) + ); + + expect(bufferManager.acquireStorageBuffer).toHaveBeenCalled(); + expect(bufferManager.writeStorageBuffer).toHaveBeenCalled(); + }); + + it("should use vertex buffer when useStorageBuffer is false", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + execute( + device, + commandEncoder, + null, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager, + true, // useIndirect + false // useStorageBuffer + ); + + expect(bufferManager.acquireStorageBuffer).not.toHaveBeenCalled(); + // acquireVertexBufferはサイズとデータを引数に取る + expect(bufferManager.acquireVertexBuffer).toHaveBeenCalled(); + }); + }); + + describe("indirect drawing", () => + { + it("should use drawIndirect when useIndirect is true (default)", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + execute( + device, + commandEncoder, + null, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager, + true, // useIndirect (default) + true + ); + + expect(bufferManager.createIndirectBuffer).toHaveBeenCalledWith(6, 10, 0, 0); + expect(commandEncoder._mockPassEncoder.drawIndirect).toHaveBeenCalled(); + expect(commandEncoder._mockPassEncoder.draw).not.toHaveBeenCalled(); + }); + + it("should use regular draw when useIndirect is false", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + execute( + device, + commandEncoder, + null, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager, + false, // useIndirect + true + ); + + expect(bufferManager.createIndirectBuffer).not.toHaveBeenCalled(); + expect(commandEncoder._mockPassEncoder.draw).toHaveBeenCalledWith(6, 10, 0, 0); + expect(commandEncoder._mockPassEncoder.drawIndirect).not.toHaveBeenCalled(); + }); + }); + + describe("blend mode pipeline selection", () => + { + const blendModes = [ + { "mode": "add", "expected": "instanced_add" }, + { "mode": "screen", "expected": "instanced_screen" }, + { "mode": "alpha", "expected": "instanced_alpha" }, + { "mode": "erase", "expected": "instanced_erase" }, + { "mode": "copy", "expected": "instanced_copy" }, + { "mode": "layer", "expected": "instanced" }, + { "mode": "normal", "expected": "instanced" } + ]; + + blendModes.forEach(({ mode, expected }) => + { + it(`should use ${expected} pipeline for ${mode} blend mode`, () => + { + (BlendModule as any).$currentBlendMode = mode; + + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + execute( + device, + commandEncoder, + null, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager + ); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith(expected); + }); + }); + }); + + describe("error handling", () => + { + it("should return null when pipeline not found", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(false, true); + + const result = execute( + device, + commandEncoder, + null, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager + ); + + expect(result).toBe(null); + expect(console.error).toHaveBeenCalledWith("[WebGPU] Instanced pipeline not found"); + }); + + it("should return null when bind group layout not found", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const attachment = createMockAttachment(); + const bufferManager = createMockBufferManager(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(true, false); + + const result = execute( + device, + commandEncoder, + null, + attachment, + bufferManager, + frameBufferManager, + textureManager, + pipelineManager + ); + + expect(result).toBe(null); + expect(console.error).toHaveBeenCalledWith("[WebGPU] Instanced bind group layout not found"); + }); + }); +}); diff --git a/packages/webgpu/src/Context/usecase/ContextDrawIndirectUseCase.ts b/packages/webgpu/src/Context/usecase/ContextDrawIndirectUseCase.ts new file mode 100644 index 00000000..b51c01b6 --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextDrawIndirectUseCase.ts @@ -0,0 +1,216 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IBlendMode } from "../../interface/IBlendMode"; +import type { BufferManager } from "../../BufferManager"; +import type { FrameBufferManager } from "../../FrameBufferManager"; +import type { TextureManager } from "../../TextureManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import { getInstancedShaderManager } from "../../Blend/BlendInstancedManager"; +import { $currentBlendMode } from "../../Blend"; +import { renderQueue } from "@next2d/render-queue"; +import { + $isMaskTestEnabled, + $getMaskStencilReference +} from "../../Mask"; +import { $getAtlasAttachmentObject } from "../../AtlasManager"; + +let $cachedBindGroup: GPUBindGroup | null = null; +let $cachedAtlasView: GPUTextureView | null = null; + +export const execute = ( + device: GPUDevice, + commandEncoder: GPUCommandEncoder, + renderPassEncoder: GPURenderPassEncoder | null, + mainAttachment: IAttachmentObject, + bufferManager: BufferManager, + frameBufferManager: FrameBufferManager, + textureManager: TextureManager, + pipelineManager: PipelineManager, + useIndirect: boolean = true, + useStorageBuffer: boolean = true +): GPURenderPassEncoder | null => { + const shaderManager = getInstancedShaderManager(); + + if (shaderManager.count === 0) { + return renderPassEncoder; + } + + // 既存のレンダーパスを終了 + if (renderPassEncoder) { + renderPassEncoder.end(); + renderPassEncoder = null; + } + + const isMasked = $isMaskTestEnabled(); + const maskReference = $getMaskStencilReference(); + + // 現在のブレンドモードを取得 + const blendMode: IBlendMode = $currentBlendMode; + + // ブレンドモードに応じたパイプライン名を生成 + const getPipelineName = (mode: IBlendMode): string => { + switch (mode) { + case "add": + return "instanced_add"; + case "screen": + return "instanced_screen"; + case "alpha": + return "instanced_alpha"; + case "erase": + return "instanced_erase"; + case "copy": + return "instanced_copy"; + default: + // normal, layer + return "instanced"; + } + }; + + const pipelineName = getPipelineName(blendMode); + const normalPipeline = pipelineManager.getPipeline(pipelineName); + const maskedPipeline = pipelineManager.getPipeline("instanced_masked"); + + const useStencil = isMasked && maskedPipeline + && (mainAttachment.msaaStencil?.view || mainAttachment.stencil?.view); + + const pipeline = useStencil ? maskedPipeline : normalPipeline; + + if (!pipeline) { + console.error("[WebGPU] Instanced pipeline not found"); + return null; + } + + // レンダーパスを作成 + let passEncoder: GPURenderPassEncoder; + + if (useStencil) { + // MSAA対応 + const useMsaa = mainAttachment.msaa && mainAttachment.msaaTexture?.view; + const colorView = useMsaa ? mainAttachment.msaaTexture!.view : mainAttachment.texture!.view; + const stencilView = useMsaa && mainAttachment.msaaStencil?.view + ? mainAttachment.msaaStencil.view : mainAttachment.stencil!.view; + const resolveTarget = useMsaa ? mainAttachment.texture!.view : null; + + const renderPassDescriptor = frameBufferManager.createStencilRenderPassDescriptor( + colorView, + stencilView, + "load", + "load", + resolveTarget + ); + passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + } else { + // 通常のレンダーパス(MSAA対応) + const useMsaa = mainAttachment.msaa && mainAttachment.msaaTexture?.view; + const colorView = useMsaa ? mainAttachment.msaaTexture!.view : mainAttachment.texture!.view; + const resolveTarget = useMsaa ? mainAttachment.texture!.view : null; + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + colorView, + 0, 0, 0, 0, + "load", + resolveTarget + ); + passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + } + + passEncoder.setPipeline(pipeline); + + if (useStencil) { + passEncoder.setStencilReference(maskReference); + } + + // インスタンスデータを準備 + const instanceData = new Float32Array( + renderQueue.buffer.buffer, + renderQueue.buffer.byteOffset, + renderQueue.offset + ); + + // インスタンスバッファを作成または取得 + let instanceBuffer: GPUBuffer; + if (useStorageBuffer) { + // Storage Buffer最適化: プールから再利用してメモリアロケーション削減 + // Storage BufferはVERTEXフラグ付きで作成されているため、setVertexBufferで使用可能 + instanceBuffer = bufferManager.acquireStorageBuffer(instanceData.byteLength); + bufferManager.writeStorageBuffer(instanceBuffer, instanceData); + } else { + // 従来方式: プールから再利用 + instanceBuffer = bufferManager.acquireVertexBuffer(instanceData.byteLength, instanceData); + } + + // 頂点バッファ(矩形)を取得(キャッシュ済み) + const vertexBuffer = bufferManager.getUnitRectBuffer(); + + // アトラステクスチャをバインド(複数アトラス対応) + // AtlasManagerから取得、フォールバックとしてFrameBufferManagerから取得 + const atlasAttachment = $getAtlasAttachmentObject() || frameBufferManager.getAttachment("atlas"); + if (!atlasAttachment) { + console.error("[WebGPU] Atlas attachment not found"); + passEncoder.end(); + return null; + } + + // アトラス用サンプラーを取得(キャッシュ済み) + const sampler = textureManager.createSampler("atlas_instanced_sampler", false); + + const bindGroupLayout = pipelineManager.getBindGroupLayout("instanced"); + if (!bindGroupLayout) { + console.error("[WebGPU] Instanced bind group layout not found"); + passEncoder.end(); + return null; + } + + // BindGroupキャッシュ: アトラスのテクスチャビューが同じなら再利用 + const atlasView = atlasAttachment.texture!.view; + if (!$cachedBindGroup || $cachedAtlasView !== atlasView) { + $cachedBindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": [ + { + "binding": 0, + "resource": sampler + }, + { + "binding": 1, + "resource": atlasView + } + ] + }); + $cachedAtlasView = atlasView; + } + + // 描画 + passEncoder.setVertexBuffer(0, vertexBuffer); + passEncoder.setVertexBuffer(1, instanceBuffer); + passEncoder.setBindGroup(0, $cachedBindGroup); + + if (useIndirect) { + // Indirect Drawing: CPU-GPU間のオーバーヘッドを削減 + // 注意: 1フレーム内で複数回呼び出される場合があるため、 + // 毎回新しいIndirect Bufferを作成する必要がある + // (共有バッファを使うとqueue.writeBufferの更新が全てGPU実行前に行われ、 + // 全てのdrawIndirectが最後の更新値を使用してしまう) + const indirectBuffer = bufferManager.createIndirectBuffer( + 6, // vertexCount (2 triangles = 6 vertices) + shaderManager.count, // instanceCount + 0, // firstVertex + 0 // firstInstance + ); + passEncoder.drawIndirect(indirectBuffer, 0); + } else { + // 通常の描画 + passEncoder.draw(6, shaderManager.count, 0, 0); + } + + // レンダーパスを終了 + passEncoder.end(); + + // 注意: Storage Bufferはここで解放しない + // GPUがまだ描画を実行していないため、同一フレーム内で再利用されると + // データが上書きされてしまう。 + // フレーム終了時(clearFrameBuffers)でまとめて解放される。 + + // インスタンスデータをクリア + shaderManager.clear(); + + return null; +}; diff --git a/packages/webgpu/src/Context/usecase/ContextGradientFillUseCase.test.ts b/packages/webgpu/src/Context/usecase/ContextGradientFillUseCase.test.ts new file mode 100644 index 00000000..da753566 --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextGradientFillUseCase.test.ts @@ -0,0 +1,496 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { BufferManager } from "../../BufferManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import type { IPath } from "../../interface/IPath"; +import { execute } from "./ContextGradientFillUseCase"; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + TEXTURE_BINDING: 0x04, + COPY_DST: 0x02 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +// Mock MeshFillGenerateUseCase - now only takes (path_vertices) +vi.mock("../../Mesh/usecase/MeshFillGenerateUseCase", () => ({ + "execute": vi.fn(() => ({ + "buffer": new Float32Array([0, 0, 1, 1, 2, 2]), + "indexCount": 6 + })) +})); + +// Mock GradientLUTGenerator +vi.mock("../../Gradient/GradientLUTGenerator", () => ({ + "generateGradientLUT": vi.fn(() => new Uint8Array(256 * 4)), + "getAdaptiveResolution": vi.fn(() => 256) +})); + +// Mock ContextComputeGradientMatrixService +vi.mock("../service/ContextComputeGradientMatrixService", () => ({ + "execute": vi.fn(() => ({ + "inverseMatrix": new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + "linearPoints": new Float32Array([0, 0, 1, 0]) + })) +})); + +// Mock Mask module +vi.mock("../../Mask", () => ({ + "$isMaskDrawing": vi.fn(() => false), + "$getMaskStencilReference": vi.fn(() => 5) +})); + +// Mock GradientLUTCache +vi.mock("../../Gradient/GradientLUTCache", () => ({ + "$getLUTFromCache": vi.fn(() => null), + "$putLUTToCache": vi.fn() +})); + +// Mock FillTexturePool +const mockFillTexture = { + "label": "mockFillTexture", + "createView": vi.fn(() => ({ "label": "mockView" })), + "destroy": vi.fn() +}; +vi.mock("../../FillTexturePool", () => ({ + "$acquireFillTexture": vi.fn(() => mockFillTexture) +})); + +import { $isMaskDrawing } from "../../Mask"; +import { execute as meshFillGenerateUseCase } from "../../Mesh/usecase/MeshFillGenerateUseCase"; + +describe("ContextGradientFillUseCase", () => +{ + const createMockDevice = () => + { + return { + "createTexture": vi.fn(), + "queue": { + "writeTexture": vi.fn(), + "writeBuffer": vi.fn() + }, + "createSampler": vi.fn(() => ({ "label": "mockSampler" })), + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })) + } as unknown as GPUDevice; + }; + + const createMockRenderPassEncoder = () => + { + return { + "setPipeline": vi.fn(), + "setVertexBuffer": vi.fn(), + "setBindGroup": vi.fn(), + "setStencilReference": vi.fn(), + "draw": vi.fn() + } as unknown as GPURenderPassEncoder; + }; + + const createMockBufferManager = () => + { + return { + "createVertexBuffer": vi.fn(() => ({ "label": "mockVertexBuffer" })), + "acquireVertexBuffer": vi.fn(() => ({ "label": "mockVertexBuffer" })), + "acquireUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })), + "acquireAndWriteUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })), + "createUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })), + "dynamicUniform": { + "allocate": vi.fn(() => 0), + "getBuffer": vi.fn(() => ({ "label": "mockDynamicBuffer" })) + } + } as unknown as BufferManager; + }; + + const createMockPipelineManager = (hasPipeline: boolean = true, hasLayout: boolean = true) => + { + const mockPipeline = hasPipeline ? { + "label": "mockPipeline", + "getBindGroupLayout": vi.fn(() => ({ "label": "mockLayout" })) + } : null; + return { + "getPipeline": vi.fn(() => mockPipeline), + "getGradientPipeline": vi.fn(() => mockPipeline), + "getBindGroupLayout": vi.fn(() => hasLayout ? { "label": "mockLayout" } : null) + } as unknown as PipelineManager; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + (($isMaskDrawing as unknown) as ReturnType).mockReturnValue(false); + }); + + describe("basic gradient fill", () => + { + it("should return null when mesh indexCount is 0", () => + { + vi.mocked(meshFillGenerateUseCase).mockReturnValueOnce({ + "buffer": new Float32Array(0), + "indexCount": 0 + }); + + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; // 2 stops + + const result = execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, // linear + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, // spread: reflect + 0, // interpolation: RGB + 0, // focal + 800, 600, + true + ); + + expect(result).toBe(null); + }); + + it("should call meshFillGenerateUseCase with only path_vertices", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + true + ); + + // meshFillGenerateUseCase now only takes path_vertices + expect(meshFillGenerateUseCase).toHaveBeenCalledWith(pathVertices); + }); + + it("should return null after drawing (LUT is cached, not returned)", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + const result = execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + true + ); + + // Source always returns null (LUT is cached via $putLUTToCache) + expect(result).toBe(null); + }); + }); + + describe("atlas target (2-pass stencil)", () => + { + it("should use stencil_write_atlas pipeline for pass 1", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + true // useAtlasTarget + ); + + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("stencil_write_atlas"); + expect(pipelineManager.getGradientPipeline).toHaveBeenCalledWith("gradient_fill_stencil_atlas", 0, 0); + }); + + it("should draw twice for atlas target (2-pass)", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + true + ); + + expect(renderPassEncoder.draw).toHaveBeenCalledTimes(2); + }); + }); + + describe("canvas target (2-pass stencil)", () => + { + it("should use 2-pass stencil fill for canvas without mask", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + false, // useAtlasTarget + false // useStencilPipeline + ); + + // Pass 1: ステンシル書き込み + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("stencil_write_main"); + // Pass 2: グラデーション描画 + expect(pipelineManager.getGradientPipeline).toHaveBeenCalledWith("gradient_fill_stencil_main", 0, 0); + }); + + it("should draw twice for canvas target (2-pass stencil fill)", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + false, // useAtlasTarget + false // useStencilPipeline + ); + + // 2パス: ステンシル書き込み + グラデーション描画 + expect(renderPassEncoder.draw).toHaveBeenCalledTimes(2); + }); + + it("should use gradient_fill_bgra_stencil_masked pipeline for mask test mode", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + false, // useAtlasTarget + true // useStencilPipeline + ); + + // Pass 1: ステンシル書き込み + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("stencil_write_main"); + // Pass 2: マスクテスト付きグラデーション描画 + expect(pipelineManager.getGradientPipeline).toHaveBeenCalledWith("gradient_fill_bgra_stencil_masked", 0, 0); + }); + + it("should return null when mask drawing and stencil pipeline", () => + { + (($isMaskDrawing as unknown) as ReturnType).mockReturnValue(true); + + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + const result = execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + false, + true + ); + + expect(result).toBe(null); + }); + }); + + describe("bind group", () => + { + it("should return null when bind group layout not found", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(true, false); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + const result = execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + true + ); + + expect(result).toBe(null); + expect(console.error).toHaveBeenCalledWith("[WebGPU] gradient_fill bind group layout not found"); + }); + }); + + describe("gradient types", () => + { + it("should pass type parameter to uniform buffer for linear gradient", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, // linear + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + true + ); + + expect(bufferManager.acquireAndWriteUniformBuffer).toHaveBeenCalled(); + }); + + it("should pass type parameter to uniform buffer for radial gradient", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const pathVertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + pathVertices, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 1, // radial + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0.5, // focal = 0.5 + 800, 600, + true + ); + + expect(bufferManager.acquireAndWriteUniformBuffer).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/webgpu/src/Context/usecase/ContextGradientFillUseCase.ts b/packages/webgpu/src/Context/usecase/ContextGradientFillUseCase.ts new file mode 100644 index 00000000..ccb5073c --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextGradientFillUseCase.ts @@ -0,0 +1,288 @@ +import type { IPath } from "../../interface/IPath"; +import type { BufferManager } from "../../BufferManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import { execute as meshFillGenerateUseCase } from "../../Mesh/usecase/MeshFillGenerateUseCase"; +import { generateGradientLUT, getAdaptiveResolution } from "../../Gradient/GradientLUTGenerator"; +import { execute as contextComputeGradientMatrixService } from "../service/ContextComputeGradientMatrixService"; +import { $getLUTFromCache, $putLUTToCache } from "../../Gradient/GradientLUTCache"; +import { $acquireFillTexture } from "../../FillTexturePool"; +import { + $isMaskDrawing, + $getMaskStencilReference +} from "../../Mask"; + +let $gradientSampler: GPUSampler | null = null; + +const $uniformData36 = new Float32Array(36); +const $stencilData16 = new Float32Array(16); + +let $stencilDynamicBindGroup: GPUBindGroup | null = null; +let $stencilDynamicBuffer: GPUBuffer | null = null; + +const $entries3: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView } +]; + +export const execute = ( + device: GPUDevice, + render_pass_encoder: GPURenderPassEncoder, + buffer_manager: BufferManager, + pipeline_manager: PipelineManager, + path_vertices: IPath[], + context_matrix: Float32Array, + fill_style: Float32Array, + type: number, + stops: number[], + gradient_matrix: Float32Array, + spread: number, + interpolation: number, + focal: number, + viewport_width: number, + viewport_height: number, + use_atlas_target: boolean, + use_stencil_pipeline: boolean = false, + _clip_level: number = 1 +): GPUTexture | null => { + // MeshFillGenerateUseCaseで頂点データを生成(4 floats/vertex: position + bezier) + const mesh = meshFillGenerateUseCase(path_vertices); + + if (mesh.indexCount === 0) { + return null; + } + + // 頂点バッファを取得(プールから再利用) + const vertexBuffer = buffer_manager.acquireVertexBuffer(mesh.buffer.byteLength, mesh.buffer); + + // グラデーションLUTテクスチャを取得(キャッシュ優先) + let lutTexture: GPUTexture; + let lutView: GPUTextureView; + + const cachedLUT = $getLUTFromCache(stops, spread, interpolation); + if (cachedLUT) { + lutTexture = cachedLUT.texture; + lutView = cachedLUT.view; + } else { + const lutData = generateGradientLUT(stops, spread, interpolation); + const stopsLength = stops.length / 5; + const lutResolution = getAdaptiveResolution(stopsLength); + + lutTexture = $acquireFillTexture(device, lutResolution, 1); + + device.queue.writeTexture( + { "texture": lutTexture }, + lutData as unknown as ArrayBufferView, + { "bytesPerRow": lutResolution * 4, "rowsPerImage": 1 }, + { "width": lutResolution, "height": 1 } + ); + + lutView = lutTexture.createView(); + $putLUTToCache(stops, spread, interpolation, lutTexture, lutView); + } + + // WebGL版と同じ計算でグラデーション変換データを取得 + const gradientData = contextComputeGradientMatrixService(gradient_matrix, context_matrix, type); + + // グラデーション描画では色は白(1, 1, 1, alpha)を使用 + const alpha = fill_style[3] > 0 ? fill_style[3] : 1; + + // 行列を取得 + const a = context_matrix[0]; + const b = context_matrix[1]; + const c = context_matrix[3]; + const d = context_matrix[4]; + const tx = context_matrix[6]; + const ty = context_matrix[7]; + + // Uniformバッファを作成 + // GradientUniforms構造体: + // - inverseMatrix: mat3x3 (各列がvec4にパディング = 48 bytes) + // - gradientType, focal, spread, radius (16 bytes) + // - linearPoints: vec4 (16 bytes) + // - color: vec4 (16 bytes) + // - contextMatrix0/1/2: vec4 x3 (48 bytes) + // 合計: 144 bytes = 36 floats → 使用する配列は32 floats(パディング込み) + $uniformData36[0] = gradientData.inverseMatrix[0]; // column 0, row 0 (a) + $uniformData36[1] = gradientData.inverseMatrix[3]; // column 0, row 1 (c) + $uniformData36[2] = 0; // column 0, row 2 (0) + $uniformData36[3] = 0; // padding + $uniformData36[4] = gradientData.inverseMatrix[1]; // column 1, row 0 (b) + $uniformData36[5] = gradientData.inverseMatrix[4]; // column 1, row 1 (d) + $uniformData36[6] = 0; // column 1, row 2 (0) + $uniformData36[7] = 0; // padding + $uniformData36[8] = gradientData.inverseMatrix[6]; // column 2, row 0 (tx) + $uniformData36[9] = gradientData.inverseMatrix[7]; // column 2, row 1 (ty) + $uniformData36[10] = 1; // column 2, row 2 (1) + $uniformData36[11] = 0; // padding + // グラデーションパラメータ + $uniformData36[12] = type; // gradientType + $uniformData36[13] = Math.max(-0.975, Math.min(0.975, focal)); // focal + $uniformData36[14] = spread; // spread (0: reflect, 1: repeat, 2: pad) + $uniformData36[15] = 819.2; // radius(Radial用、WebGL版と同じ定数) + // Linear用の点a, b + if (gradientData.linearPoints) { + $uniformData36[16] = gradientData.linearPoints[0]; // a.x + $uniformData36[17] = gradientData.linearPoints[1]; // a.y + $uniformData36[18] = gradientData.linearPoints[2]; // b.x + $uniformData36[19] = gradientData.linearPoints[3]; // b.y + } else { + $uniformData36[16] = 0; + $uniformData36[17] = 0; + $uniformData36[18] = 0; + $uniformData36[19] = 0; + } + // color (白 + alpha) + $uniformData36[20] = 1; // red + $uniformData36[21] = 1; // green + $uniformData36[22] = 1; // blue + $uniformData36[23] = alpha; + // contextMatrix(viewport正規化済み) + $uniformData36[24] = a / viewport_width; + $uniformData36[25] = b / viewport_height; + $uniformData36[26] = 0; + $uniformData36[27] = 0; + $uniformData36[28] = c / viewport_width; + $uniformData36[29] = d / viewport_height; + $uniformData36[30] = 0; + $uniformData36[31] = 0; + // contextMatrix2 + $uniformData36[32] = tx / viewport_width; + $uniformData36[33] = ty / viewport_height; + $uniformData36[34] = 1; + $uniformData36[35] = 0; + + const uniformBuffer = buffer_manager.acquireAndWriteUniformBuffer($uniformData36); + + // サンプラーを作成(キャッシュ済み) + if (!$gradientSampler) { + $gradientSampler = device.createSampler({ + "magFilter": "linear", + "minFilter": "linear", + "addressModeU": "clamp-to-edge", + "addressModeV": "clamp-to-edge" + }); + } + const sampler = $gradientSampler; + + // バインドグループを作成 + const bindGroupLayout = pipeline_manager.getBindGroupLayout("gradient_fill"); + if (!bindGroupLayout) { + console.error("[WebGPU] gradient_fill bind group layout not found"); + return null; + } + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = lutView; + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + // ステンシル書き込みパス用のDynamic BindGroup + offsetを作成 + // stencil_write_atlas / stencil_write_main はFillUniforms(color+matrix)を期待する + const createStencilDynamic = (): { bindGroup: GPUBindGroup; offset: number } | null => { + const dynamicLayout = pipeline_manager.getBindGroupLayout("fill_dynamic"); + if (!dynamicLayout) { + return null; + } + // FillUniformsと同じレイアウト: color(16) + matrix0(16) + matrix1(16) + matrix2(16) = 64 bytes + $stencilData16[0] = 1; // red + $stencilData16[1] = 1; // green + $stencilData16[2] = 1; // blue + $stencilData16[3] = alpha; + $stencilData16[4] = a / viewport_width; + $stencilData16[5] = b / viewport_height; + $stencilData16[6] = 0; + $stencilData16[7] = 0; + $stencilData16[8] = c / viewport_width; + $stencilData16[9] = d / viewport_height; + $stencilData16[10] = 0; + $stencilData16[11] = 0; + $stencilData16[12] = tx / viewport_width; + $stencilData16[13] = ty / viewport_height; + $stencilData16[14] = 1; + $stencilData16[15] = 0; + + const offset = buffer_manager.dynamicUniform.allocate($stencilData16); + const currentBuffer = buffer_manager.dynamicUniform.getBuffer(); + if (!$stencilDynamicBindGroup || $stencilDynamicBuffer !== currentBuffer) { + $stencilDynamicBindGroup = device.createBindGroup({ + "layout": dynamicLayout, + "entries": [{ + "binding": 0, + "resource": { + "buffer": currentBuffer, + "size": 256 + } + }] + }); + $stencilDynamicBuffer = currentBuffer; + } + return { "bindGroup": $stencilDynamicBindGroup, "offset": offset }; + }; + + // アトラス描画時:2パスステンシルフィル(WebGL版と同じアルゴリズム) + // これにより中抜き描画(hollow shape)が正しく機能する + if (use_atlas_target) { + // === Pass 1: ステンシル書き込み === + const stencilWritePipeline = pipeline_manager.getPipeline("stencil_write_atlas"); + if (stencilWritePipeline) { + const stencilDynamic = createStencilDynamic(); + render_pass_encoder.setPipeline(stencilWritePipeline); + render_pass_encoder.setStencilReference(0); + render_pass_encoder.setVertexBuffer(0, vertexBuffer); + if (stencilDynamic) { + render_pass_encoder.setBindGroup(0, stencilDynamic.bindGroup, [stencilDynamic.offset]); + } + render_pass_encoder.draw(mesh.indexCount, 1, 0, 0); + } + + // === Pass 2: グラデーション描画(ステンシルテスト付き) === + const gradientPipeline = pipeline_manager.getGradientPipeline("gradient_fill_stencil_atlas", type, spread); + if (gradientPipeline) { + render_pass_encoder.setPipeline(gradientPipeline); + render_pass_encoder.setStencilReference(0); + render_pass_encoder.setBindGroup(0, bindGroup); + render_pass_encoder.draw(mesh.indexCount, 1, 0, 0); + } + return null; + } + + // キャンバスへの直接描画 + if (use_stencil_pipeline && $isMaskDrawing()) { + return null; + } + + // === メインキャンバス直接描画: 2パスステンシルフィル === + + // === Pass 1: ステンシル書き込み === + const stencilWritePipeline = pipeline_manager.getPipeline("stencil_write_main"); + if (stencilWritePipeline) { + const stencilDynamic = createStencilDynamic(); + render_pass_encoder.setPipeline(stencilWritePipeline); + render_pass_encoder.setStencilReference(0); + render_pass_encoder.setVertexBuffer(0, vertexBuffer); + if (stencilDynamic) { + render_pass_encoder.setBindGroup(0, stencilDynamic.bindGroup, [stencilDynamic.offset]); + } + render_pass_encoder.draw(mesh.indexCount, 1, 0, 0); + } + + // === Pass 2: グラデーション描画(ステンシルテスト付き) === + const pipelineName = use_stencil_pipeline + ? "gradient_fill_bgra_stencil_masked" + : "gradient_fill_stencil_main"; + + const gradientPipeline = pipeline_manager.getGradientPipeline(pipelineName, type, spread); + if (gradientPipeline) { + render_pass_encoder.setPipeline(gradientPipeline); + render_pass_encoder.setStencilReference(use_stencil_pipeline ? $getMaskStencilReference() : 0); + render_pass_encoder.setBindGroup(0, bindGroup); + render_pass_encoder.draw(mesh.indexCount, 1, 0, 0); + } + + // LUTテクスチャはキャッシュ管理 + return null; +}; diff --git a/packages/webgpu/src/Context/usecase/ContextGradientStrokeUseCase.test.ts b/packages/webgpu/src/Context/usecase/ContextGradientStrokeUseCase.test.ts new file mode 100644 index 00000000..04bf2678 --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextGradientStrokeUseCase.test.ts @@ -0,0 +1,394 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { BufferManager } from "../../BufferManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import type { IPath } from "../../interface/IPath"; +import { execute } from "./ContextGradientStrokeUseCase"; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + TEXTURE_BINDING: 0x04, + COPY_DST: 0x02 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +// Mock MeshGradientStrokeGenerateUseCase - now only takes (vertices, thickness) +vi.mock("../../Mesh/usecase/MeshGradientStrokeGenerateUseCase", () => ({ + "execute": vi.fn(() => ({ + "buffer": new Float32Array([0, 0, 1, 1, 2, 2]), + "indexCount": 6 + })) +})); + +// Mock GradientLUTGenerator +vi.mock("../../Gradient/GradientLUTGenerator", () => ({ + "generateGradientLUT": vi.fn(() => new Uint8Array(256 * 4)), + "getAdaptiveResolution": vi.fn(() => 256) +})); + +// Mock ContextComputeGradientMatrixService +vi.mock("../service/ContextComputeGradientMatrixService", () => ({ + "execute": vi.fn(() => ({ + "inverseMatrix": new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + "linearPoints": new Float32Array([0, 0, 1, 0]) + })) +})); + +// Mock GradientLUTCache +vi.mock("../../Gradient/GradientLUTCache", () => ({ + "$getLUTFromCache": vi.fn(() => null), + "$putLUTToCache": vi.fn() +})); + +// Mock FillTexturePool +const mockFillTexture = { + "label": "mockFillTexture", + "createView": vi.fn(() => ({ "label": "mockView" })), + "destroy": vi.fn() +}; +vi.mock("../../FillTexturePool", () => ({ + "$acquireFillTexture": vi.fn(() => mockFillTexture) +})); + +import { execute as meshGradientStrokeGenerateUseCase } from "../../Mesh/usecase/MeshGradientStrokeGenerateUseCase"; + +describe("ContextGradientStrokeUseCase", () => +{ + const createMockDevice = () => + { + return { + "createTexture": vi.fn(), + "queue": { + "writeTexture": vi.fn(), + "writeBuffer": vi.fn() + }, + "createSampler": vi.fn(() => ({ "label": "mockSampler" })), + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })) + } as unknown as GPUDevice; + }; + + const createMockRenderPassEncoder = () => + { + return { + "setPipeline": vi.fn(), + "setVertexBuffer": vi.fn(), + "setBindGroup": vi.fn(), + "draw": vi.fn() + } as unknown as GPURenderPassEncoder; + }; + + const createMockBufferManager = () => + { + return { + "createVertexBuffer": vi.fn(() => ({ "label": "mockVertexBuffer" })), + "createUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })), + "acquireVertexBuffer": vi.fn(() => ({ "label": "mockVertexBuffer" })), + "acquireUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })), + "acquireAndWriteUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })) + } as unknown as BufferManager; + }; + + const createMockPipelineManager = (hasPipeline: boolean = true, hasLayout: boolean = true) => + { + const mockPipeline = hasPipeline ? { "label": "mockPipeline" } : null; + return { + "getPipeline": vi.fn(() => mockPipeline), + "getGradientPipeline": vi.fn(() => mockPipeline), + "getBindGroupLayout": vi.fn(() => hasLayout ? { "label": "mockLayout" } : null) + } as unknown as PipelineManager; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("basic gradient stroke", () => + { + it("should return null when mesh indexCount is 0", () => + { + vi.mocked(meshGradientStrokeGenerateUseCase).mockReturnValueOnce({ + "buffer": new Float32Array(0), + "indexCount": 0 + }); + + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const vertices: IPath[] = [[0, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + const result = execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, // linear + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + true, + false + ); + + expect(result).toBe(null); + }); + + it("should call meshGradientStrokeGenerateUseCase with only (vertices, thickness)", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + true, + false + ); + + // meshGradientStrokeGenerateUseCase now only takes (vertices, thickness) + expect(meshGradientStrokeGenerateUseCase).toHaveBeenCalledWith(vertices, 10); + }); + + it("should return null after drawing (LUT is cached, not returned)", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + const result = execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + true, + false + ); + + // Source always returns null (LUT is cached via $putLUTToCache) + expect(result).toBe(null); + }); + }); + + describe("pipeline selection", () => + { + it("should use gradient_fill pipeline for atlas target without stencil", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + true, // useAtlasTarget + false // useStencilPipeline + ); + + expect(pipelineManager.getGradientPipeline).toHaveBeenCalledWith("gradient_fill", 0, 0); + }); + + it("should use gradient_fill_bgra pipeline for canvas target without stencil", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + false, // useAtlasTarget + false // useStencilPipeline + ); + + expect(pipelineManager.getGradientPipeline).toHaveBeenCalledWith("gradient_fill_bgra", 0, 0); + }); + + it("should use gradient_stroke_atlas pipeline for atlas target with stencil", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + true, // useAtlasTarget + true // useStencilPipeline + ); + + expect(pipelineManager.getGradientPipeline).toHaveBeenCalledWith("gradient_stroke_atlas", 0, 0); + }); + }); + + describe("error handling", () => + { + it("should return null when bind group layout not found", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(true, false); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + const result = execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + true, + false + ); + + expect(result).toBe(null); + }); + + it("should return null when pipeline not found", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(false, true); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + const result = execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + true, + false + ); + + expect(result).toBe(null); + }); + }); + + describe("drawing", () => + { + it("should draw with correct vertex count", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + const stops = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1]; + + execute( + device, + renderPassEncoder, + bufferManager, + pipelineManager, + vertices, + 10, + new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), + new Float32Array([1, 1, 1, 1]), + 0, + stops, + new Float32Array([1, 0, 0, 1, 0, 0]), + 0, 0, 0, + 800, 600, + true, + false + ); + + expect(renderPassEncoder.draw).toHaveBeenCalledWith(6, 1, 0, 0); + }); + }); +}); diff --git a/packages/webgpu/src/Context/usecase/ContextGradientStrokeUseCase.ts b/packages/webgpu/src/Context/usecase/ContextGradientStrokeUseCase.ts new file mode 100644 index 00000000..f4a35384 --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextGradientStrokeUseCase.ts @@ -0,0 +1,184 @@ +import type { IPath } from "../../interface/IPath"; +import type { BufferManager } from "../../BufferManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import { execute as meshGradientStrokeGenerateUseCase } from "../../Mesh/usecase/MeshGradientStrokeGenerateUseCase"; +import { generateGradientLUT, getAdaptiveResolution } from "../../Gradient/GradientLUTGenerator"; +import { execute as contextComputeGradientMatrixService } from "../service/ContextComputeGradientMatrixService"; +import { $getLUTFromCache, $putLUTToCache } from "../../Gradient/GradientLUTCache"; +import { $acquireFillTexture } from "../../FillTexturePool"; + +let $gradientSampler: GPUSampler | null = null; + +const $uniformData36 = new Float32Array(36); + +const $entries3: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView } +]; + +export const execute = ( + device: GPUDevice, + render_pass_encoder: GPURenderPassEncoder, + buffer_manager: BufferManager, + pipeline_manager: PipelineManager, + vertices: IPath[], + thickness: number, + context_matrix: Float32Array, + stroke_style: Float32Array, + type: number, + stops: number[], + gradient_matrix: Float32Array, + spread: number, + interpolation: number, + focal: number, + viewport_width: number, + viewport_height: number, + use_atlas_target: boolean, + use_stencil_pipeline: boolean +): GPUTexture | null => { + // グラデーションストローク用メッシュを生成(4 floats/vertex: position + bezier) + const mesh = meshGradientStrokeGenerateUseCase(vertices, thickness); + + if (mesh.indexCount === 0) { + return null; + } + + // 頂点バッファを取得(プールから再利用) + const vertexBuffer = buffer_manager.acquireVertexBuffer(mesh.buffer.byteLength, mesh.buffer); + + // グラデーションLUTテクスチャを取得(キャッシュ優先) + let lutTexture: GPUTexture; + let lutView: GPUTextureView; + + const cachedLUT = $getLUTFromCache(stops, spread, interpolation); + if (cachedLUT) { + lutTexture = cachedLUT.texture; + lutView = cachedLUT.view; + } else { + const lutData = generateGradientLUT(stops, spread, interpolation); + const stopsLength = stops.length / 5; + const lutResolution = getAdaptiveResolution(stopsLength); + + lutTexture = $acquireFillTexture(device, lutResolution, 1); + + device.queue.writeTexture( + { "texture": lutTexture }, + lutData as unknown as ArrayBufferView, + { "bytesPerRow": lutResolution * 4, "rowsPerImage": 1 }, + { "width": lutResolution, "height": 1 } + ); + + lutView = lutTexture.createView(); + $putLUTToCache(stops, spread, interpolation, lutTexture, lutView); + } + + // WebGL版と同じ計算でグラデーション変換データを取得 + const gradientData = contextComputeGradientMatrixService(gradient_matrix, context_matrix, type); + + // 色とmatrix + const alpha = stroke_style[3] > 0 ? stroke_style[3] : 1; + const a = context_matrix[0]; + const b = context_matrix[1]; + const c = context_matrix[3]; + const d = context_matrix[4]; + const tx = context_matrix[6]; + const ty = context_matrix[7]; + + // Uniformバッファを作成(GradientUniforms: 36 floats = 144 bytes) + $uniformData36[0] = gradientData.inverseMatrix[0]; // column 0, row 0 (a) + $uniformData36[1] = gradientData.inverseMatrix[3]; // column 0, row 1 (c) + $uniformData36[2] = 0; // column 0, row 2 (0) + $uniformData36[3] = 0; // padding + $uniformData36[4] = gradientData.inverseMatrix[1]; // column 1, row 0 (b) + $uniformData36[5] = gradientData.inverseMatrix[4]; // column 1, row 1 (d) + $uniformData36[6] = 0; // column 1, row 2 (0) + $uniformData36[7] = 0; // padding + $uniformData36[8] = gradientData.inverseMatrix[6]; // column 2, row 0 (tx) + $uniformData36[9] = gradientData.inverseMatrix[7]; // column 2, row 1 (ty) + $uniformData36[10] = 1; // column 2, row 2 (1) + $uniformData36[11] = 0; // padding + // グラデーションパラメータ + $uniformData36[12] = type; // gradientType + $uniformData36[13] = Math.max(-0.975, Math.min(0.975, focal)); // focal + $uniformData36[14] = spread; // spread (0: reflect, 1: repeat, 2: pad) + $uniformData36[15] = 819.2; // radius + // Linear用の点a, b + if (gradientData.linearPoints) { + $uniformData36[16] = gradientData.linearPoints[0]; // a.x + $uniformData36[17] = gradientData.linearPoints[1]; // a.y + $uniformData36[18] = gradientData.linearPoints[2]; // b.x + $uniformData36[19] = gradientData.linearPoints[3]; // b.y + } else { + $uniformData36[16] = 0; + $uniformData36[17] = 0; + $uniformData36[18] = 0; + $uniformData36[19] = 0; + } + // color (白 + alpha) + $uniformData36[20] = 1; + $uniformData36[21] = 1; + $uniformData36[22] = 1; + $uniformData36[23] = alpha; + // contextMatrix(viewport正規化済み) + $uniformData36[24] = a / viewport_width; + $uniformData36[25] = b / viewport_height; + $uniformData36[26] = 0; + $uniformData36[27] = 0; + $uniformData36[28] = c / viewport_width; + $uniformData36[29] = d / viewport_height; + $uniformData36[30] = 0; + $uniformData36[31] = 0; + $uniformData36[32] = tx / viewport_width; + $uniformData36[33] = ty / viewport_height; + $uniformData36[34] = 1; + $uniformData36[35] = 0; + + const uniformBuffer = buffer_manager.acquireAndWriteUniformBuffer($uniformData36); + + // サンプラーを取得(キャッシュ済み) + if (!$gradientSampler) { + $gradientSampler = device.createSampler({ + "magFilter": "linear", + "minFilter": "linear", + "addressModeU": "clamp-to-edge", + "addressModeV": "clamp-to-edge" + }); + } + const sampler = $gradientSampler; + + // バインドグループを作成 + const bindGroupLayout = pipeline_manager.getBindGroupLayout("gradient_fill"); + if (!bindGroupLayout) { + console.error("[WebGPU] gradient_fill bind group layout not found"); + return null; + } + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = lutView; + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + // ストロークのメッシュは各セグメントが独立した凸多角形のため、 + // フィルのような2パスステンシル描画は不要で、直接描画で正しく描画される。 + // ステンシル付きレンダーパスの場合はステンシル互換パイプライン(compare: always)を使用する。 + const pipelineName = use_stencil_pipeline + ? use_atlas_target ? "gradient_stroke_atlas" : "gradient_stroke_bgra" + : use_atlas_target ? "gradient_fill" : "gradient_fill_bgra"; + const pipeline = pipeline_manager.getGradientPipeline(pipelineName, type, spread); + if (!pipeline) { + console.error(`[WebGPU] ${pipelineName} pipeline not found`); + return null; + } + + render_pass_encoder.setPipeline(pipeline); + render_pass_encoder.setVertexBuffer(0, vertexBuffer); + render_pass_encoder.setBindGroup(0, bindGroup); + render_pass_encoder.draw(mesh.indexCount, 1, 0, 0); + + // LUTテクスチャはキャッシュ管理 + return null; +}; diff --git a/packages/webgpu/src/Context/usecase/ContextProcessComplexBlendQueueUseCase.test.ts b/packages/webgpu/src/Context/usecase/ContextProcessComplexBlendQueueUseCase.test.ts new file mode 100644 index 00000000..d565f53e --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextProcessComplexBlendQueueUseCase.test.ts @@ -0,0 +1,370 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { BufferManager } from "../../BufferManager"; +import type { FrameBufferManager } from "../../FrameBufferManager"; +import type { TextureManager } from "../../TextureManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import { execute } from "./ContextProcessComplexBlendQueueUseCase"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + UNIFORM: 0x40, + COPY_DST: 0x08 +}; +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +// Mock BlendInstancedManager +const mockQueue: any[] = []; +const mockGetComplexBlendQueue = vi.fn(() => mockQueue); +const mockClearComplexBlendQueue = vi.fn(); + +vi.mock("../../Blend/BlendInstancedManager", () => ({ + "getComplexBlendQueue": () => mockGetComplexBlendQueue(), + "clearComplexBlendQueue": () => mockClearComplexBlendQueue() +})); + +// Mock BlendApplyComplexBlendUseCase +const mockBlendApplyComplexBlendUseCase = vi.fn(); +vi.mock("../../Blend/usecase/BlendApplyComplexBlendUseCase", () => ({ + "execute": (...args: any[]) => mockBlendApplyComplexBlendUseCase(...args) +})); + +vi.mock("../../AtlasManager", () => ({ + "$getAtlasAttachmentObject": vi.fn(() => null) +})); + +describe("ContextProcessComplexBlendQueueUseCase", () => +{ + const createMockDevice = () => + { + return { + "createBuffer": vi.fn(() => ({ "label": "mockBuffer" })), + "queue": { + "writeBuffer": vi.fn() + }, + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })) + } as unknown as GPUDevice; + }; + + const createMockCommandEncoder = () => + { + const mockPassEncoder = { + "setPipeline": vi.fn(), + "setBindGroup": vi.fn(), + "draw": vi.fn(), + "end": vi.fn() + }; + return { + "beginRenderPass": vi.fn(() => mockPassEncoder), + "copyTextureToTexture": vi.fn(), + "_mockPassEncoder": mockPassEncoder + } as unknown as GPUCommandEncoder & { _mockPassEncoder: any }; + }; + + const createMockAttachment = (id: number = 1, hasTexture: boolean = true): IAttachmentObject => + { + const mockTexture = hasTexture ? { + "resource": { "label": "mockTexture" } as unknown as GPUTexture, + "view": { "label": "mockTextureView" } as unknown as GPUTextureView + } : null; + + return { + "id": id, + "width": 800, + "height": 600, + "clipLevel": 0, + "texture": mockTexture + } as IAttachmentObject; + }; + + const createMockFrameBufferManager = () => + { + const tempAttachments: IAttachmentObject[] = []; + return { + "getAttachment": vi.fn((name: string) => { + if (name === "atlas") { + return createMockAttachment(999); + } + return null; + }), + "createTemporaryAttachment": vi.fn((w: number, h: number) => { + const att = createMockAttachment(tempAttachments.length + 100); + att.width = w; + att.height = h; + tempAttachments.push(att); + return att; + }), + "releaseTemporaryAttachment": vi.fn(), + "createRenderPassDescriptor": vi.fn(() => ({ + "colorAttachments": [{ "view": {}, "loadOp": "clear", "storeOp": "store" }] + })) + } as unknown as FrameBufferManager; + }; + + const createMockTextureManager = () => + { + return { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })) + } as unknown as TextureManager; + }; + + const createMockPipelineManager = (hasPipelines: boolean = true) => + { + return { + "getPipeline": vi.fn(() => hasPipelines ? { "label": "mockPipeline" } : null), + "getBindGroupLayout": vi.fn(() => hasPipelines ? { "label": "mockLayout" } : null) + } as unknown as PipelineManager; + }; + + const createMockBufferManager = () => + { + return { + "acquireUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })), + "acquireAndWriteUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })) + } as unknown as BufferManager; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + mockQueue.length = 0; + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("empty queue", () => + { + it("should return early when queue is empty", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const mainAttachment = createMockAttachment(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + const bufferManager = createMockBufferManager(); + + execute( + device, + commandEncoder, + mainAttachment, + frameBufferManager, + textureManager, + pipelineManager, + bufferManager + ); + + expect(commandEncoder.copyTextureToTexture).not.toHaveBeenCalled(); + expect(mockClearComplexBlendQueue).not.toHaveBeenCalled(); + }); + }); + + describe("missing attachments", () => + { + it("should clear queue when main attachment is null", () => + { + mockQueue.push({ + "node": { "x": 0, "y": 0, "w": 100, "h": 100 }, + "x_min": 0, "y_min": 0, "x_max": 100, "y_max": 100, + "color_transform": [1, 1, 1, 1, 0, 0, 0, 0], + "matrix": [1, 0, 0, 0, 1, 0, 0, 0], + "blend_mode": 2, + "global_alpha": 1 + }); + + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + const bufferManager = createMockBufferManager(); + + execute( + device, + commandEncoder, + null, + frameBufferManager, + textureManager, + pipelineManager, + bufferManager + ); + + expect(mockClearComplexBlendQueue).toHaveBeenCalled(); + }); + + it("should clear queue when main attachment has no texture", () => + { + mockQueue.push({ + "node": { "x": 0, "y": 0, "w": 100, "h": 100 }, + "x_min": 0, "y_min": 0, "x_max": 100, "y_max": 100, + "color_transform": [1, 1, 1, 1, 0, 0, 0, 0], + "matrix": [1, 0, 0, 0, 1, 0, 0, 0], + "blend_mode": 2, + "global_alpha": 1 + }); + + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const mainAttachment = createMockAttachment(1, false); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + const bufferManager = createMockBufferManager(); + + execute( + device, + commandEncoder, + mainAttachment, + frameBufferManager, + textureManager, + pipelineManager, + bufferManager + ); + + expect(mockClearComplexBlendQueue).toHaveBeenCalled(); + }); + + it("should clear queue when atlas attachment not available", () => + { + mockQueue.push({ + "node": { "x": 0, "y": 0, "w": 100, "h": 100 }, + "x_min": 0, "y_min": 0, "x_max": 100, "y_max": 100, + "color_transform": [1, 1, 1, 1, 0, 0, 0, 0], + "matrix": [1, 0, 0, 0, 1, 0, 0, 0], + "blend_mode": 2, + "global_alpha": 1 + }); + + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const mainAttachment = createMockAttachment(); + const frameBufferManager = createMockFrameBufferManager(); + (frameBufferManager.getAttachment as ReturnType).mockReturnValue(null); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + const bufferManager = createMockBufferManager(); + + execute( + device, + commandEncoder, + mainAttachment, + frameBufferManager, + textureManager, + pipelineManager, + bufferManager + ); + + expect(mockClearComplexBlendQueue).toHaveBeenCalled(); + }); + }); + + describe("queue processing", () => + { + it("should skip items with zero dimensions", () => + { + mockQueue.push({ + "node": { "x": 0, "y": 0, "w": 100, "h": 100 }, + "x_min": 0, "y_min": 0, "x_max": 0, "y_max": 0, // zero dimensions + "color_transform": [1, 1, 1, 1, 0, 0, 0, 0], + "matrix": [1, 0, 0, 0, 1, 0, 0, 0], + "blend_mode": 2, + "global_alpha": 1 + }); + + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const mainAttachment = createMockAttachment(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + const bufferManager = createMockBufferManager(); + + execute( + device, + commandEncoder, + mainAttachment, + frameBufferManager, + textureManager, + pipelineManager, + bufferManager + ); + + expect(frameBufferManager.createTemporaryAttachment).not.toHaveBeenCalled(); + expect(mockClearComplexBlendQueue).toHaveBeenCalled(); + }); + + it("should skip items outside main attachment bounds", () => + { + mockQueue.push({ + "node": { "x": 0, "y": 0, "w": 100, "h": 100 }, + "x_min": 0, "y_min": 0, "x_max": 100, "y_max": 100, + "color_transform": [1, 1, 1, 1, 0, 0, 0, 0], + "matrix": [1, 0, 0, 0, 1, 0, 1000, 1000], // position outside 800x600 + "blend_mode": 2, + "global_alpha": 1 + }); + + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const mainAttachment = createMockAttachment(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + const bufferManager = createMockBufferManager(); + + execute( + device, + commandEncoder, + mainAttachment, + frameBufferManager, + textureManager, + pipelineManager, + bufferManager + ); + + expect(frameBufferManager.createTemporaryAttachment).not.toHaveBeenCalled(); + expect(mockClearComplexBlendQueue).toHaveBeenCalled(); + }); + + it("should clear queue after processing", () => + { + mockQueue.push({ + "node": { "x": 0, "y": 0, "w": 100, "h": 100 }, + "x_min": 0, "y_min": 0, "x_max": 100, "y_max": 100, + "color_transform": [1, 1, 1, 1, 0, 0, 0, 0], + "matrix": [1, 0, 0, 0, 1, 0, 50, 50], + "blend_mode": 2, + "global_alpha": 1 + }); + + mockBlendApplyComplexBlendUseCase.mockReturnValue(createMockAttachment(200)); + + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const mainAttachment = createMockAttachment(); + const frameBufferManager = createMockFrameBufferManager(); + const textureManager = createMockTextureManager(); + const pipelineManager = createMockPipelineManager(); + + const bufferManager = createMockBufferManager(); + + execute( + device, + commandEncoder, + mainAttachment, + frameBufferManager, + textureManager, + pipelineManager, + bufferManager + ); + + expect(mockClearComplexBlendQueue).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/webgpu/src/Context/usecase/ContextProcessComplexBlendQueueUseCase.ts b/packages/webgpu/src/Context/usecase/ContextProcessComplexBlendQueueUseCase.ts new file mode 100644 index 00000000..c3717047 --- /dev/null +++ b/packages/webgpu/src/Context/usecase/ContextProcessComplexBlendQueueUseCase.ts @@ -0,0 +1,353 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { BufferManager } from "../../BufferManager"; +import type { FrameBufferManager } from "../../FrameBufferManager"; +import type { TextureManager } from "../../TextureManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import { getComplexBlendQueue, clearComplexBlendQueue } from "../../Blend/BlendInstancedManager"; +import { execute as blendApplyComplexBlendUseCase } from "../../Blend/usecase/BlendApplyComplexBlendUseCase"; +import { $getAtlasAttachmentObject } from "../../AtlasManager"; + +// プリアロケート配列 +const $uniform4 = new Float32Array(4); +const $uniform6 = new Float32Array(6); +const $uniform8 = new Float32Array(8); +const $uniform12 = new Float32Array(12); + +// プリアロケート BindGroup Entry 配列 +const $entries3: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView } +]; + +const copyTextureRegionViaRenderPass = ( + device: GPUDevice, + commandEncoder: GPUCommandEncoder, + srcView: GPUTextureView, + dstAttachment: IAttachmentObject, + srcX: number, + srcY: number, + srcWidth: number, + srcHeight: number, + copyWidth: number, + copyHeight: number, + frameBufferManager: FrameBufferManager, + textureManager: TextureManager, + pipelineManager: PipelineManager, + bufferManager: BufferManager +): void => { + const pipeline = pipelineManager.getPipeline("complex_blend_copy"); + const bindGroupLayout = pipelineManager.getBindGroupLayout("texture_copy"); + + if (!pipeline || !bindGroupLayout) { + return; + } + + $uniform4[0] = copyWidth / srcWidth; + $uniform4[1] = copyHeight / srcHeight; + $uniform4[2] = srcX / srcWidth; + $uniform4[3] = srcY / srcHeight; + const uniformBuffer = bufferManager.acquireAndWriteUniformBuffer($uniform4); + + const sampler = textureManager.createSampler("complex_blend_copy_sampler", false); + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = srcView; + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + dstAttachment.texture!.view, + 0, 0, 0, 0, + "clear" + ); + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); +}; + +const drawToMainAttachment = ( + device: GPUDevice, + commandEncoder: GPUCommandEncoder, + srcAttachment: IAttachmentObject, + mainAttachment: IAttachmentObject, + dstX: number, + dstY: number, + frameBufferManager: FrameBufferManager, + textureManager: TextureManager, + pipelineManager: PipelineManager, + bufferManager: BufferManager +): void => { + const useMsaa = mainAttachment.msaa && mainAttachment.msaaTexture?.view; + const pipelineName = useMsaa ? "complex_blend_output_msaa" : "complex_blend_output"; + const pipeline = pipelineManager.getPipeline(pipelineName); + const bindGroupLayout = pipelineManager.getBindGroupLayout("positioned_texture"); + + if (!pipeline || !bindGroupLayout) { + return; + } + + $uniform8[0] = dstX; + $uniform8[1] = dstY; + $uniform8[2] = srcAttachment.width; + $uniform8[3] = srcAttachment.height; + $uniform8[4] = mainAttachment.width; + $uniform8[5] = mainAttachment.height; + $uniform8[6] = 0; + $uniform8[7] = 0; + const uniformBuffer = bufferManager.acquireAndWriteUniformBuffer($uniform8); + + const sampler = textureManager.createSampler("complex_blend_output_sampler", false); + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = srcAttachment.texture!.view; + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + const colorView = useMsaa ? mainAttachment.msaaTexture!.view : mainAttachment.texture!.view; + const resolveTarget = useMsaa ? mainAttachment.texture!.view : null; + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + colorView, + 0, 0, 0, 0, + "load", + resolveTarget + ); + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); +}; + +export const execute = ( + device: GPUDevice, + commandEncoder: GPUCommandEncoder, + mainAttachment: IAttachmentObject | null, + frameBufferManager: FrameBufferManager, + textureManager: TextureManager, + pipelineManager: PipelineManager, + bufferManager: BufferManager +): void => { + const queue = getComplexBlendQueue(); + + if (queue.length === 0) { + return; + } + + if (!mainAttachment || !mainAttachment.texture) { + clearComplexBlendQueue(); + return; + } + + const atlasAttachment = $getAtlasAttachmentObject() || frameBufferManager.getAttachment("atlas"); + if (!atlasAttachment || !atlasAttachment.texture) { + clearComplexBlendQueue(); + return; + } + + for (const item of queue) { + const { node, x_min, y_min, x_max, y_max, color_transform, matrix, blend_mode, global_alpha } = item; + + const width = Math.ceil(Math.abs(x_max - x_min)); + const height = Math.ceil(Math.abs(y_max - y_min)); + + if (width <= 0 || height <= 0) { + continue; + } + + const dstX = Math.max(0, Math.floor(matrix[6])); + const dstY = Math.max(0, Math.floor(matrix[7])); + + if (dstX >= mainAttachment.width || dstY >= mainAttachment.height) { + continue; + } + + const hasScale = matrix[0] !== 1 || matrix[1] !== 0 || matrix[3] !== 0 || matrix[4] !== 1; + + const blendWidth = hasScale ? width : node.w; + const blendHeight = hasScale ? height : node.h; + + const clippedWidth = Math.min(blendWidth, mainAttachment.width - dstX); + const clippedHeight = Math.min(blendHeight, mainAttachment.height - dstY); + if (clippedWidth <= 0 || clippedHeight <= 0) { + continue; + } + + // 1. ソーステクスチャを作成 + let srcAttachment: IAttachmentObject; + + if (hasScale) { + srcAttachment = frameBufferManager.createTemporaryAttachment(blendWidth, blendHeight); + + const scalePipeline = pipelineManager.getPipeline("complex_blend_scale"); + const scaleBindGroupLayout = pipelineManager.getBindGroupLayout("texture_scale"); + + if (scalePipeline && scaleBindGroupLayout) { + const halfW = blendWidth / 2; + const halfH = blendHeight / 2; + const halfNodeW = node.w / 2; + const halfNodeH = node.h / 2; + + $uniform6[0] = matrix[0]; + $uniform6[1] = matrix[1]; + $uniform6[2] = matrix[3]; + $uniform6[3] = matrix[4]; + $uniform6[4] = -halfNodeW * matrix[0] - halfNodeH * matrix[3] + halfW; + $uniform6[5] = -halfNodeW * matrix[1] - halfNodeH * matrix[4] + halfH; + + const originalAttachment = frameBufferManager.createTemporaryAttachment(node.w, node.h); + commandEncoder.copyTextureToTexture( + { + "texture": atlasAttachment.texture.resource, + "origin": { "x": node.x, "y": node.y, "z": 0 } + }, + { + "texture": originalAttachment.texture!.resource, + "origin": { "x": 0, "y": 0, "z": 0 } + }, + { "width": node.w, "height": node.h } + ); + + $uniform12[0] = $uniform6[0]; + $uniform12[1] = $uniform6[1]; + $uniform12[2] = $uniform6[2]; + $uniform12[3] = $uniform6[3]; + $uniform12[4] = $uniform6[4]; + $uniform12[5] = $uniform6[5]; + $uniform12[6] = node.w; + $uniform12[7] = node.h; + $uniform12[8] = blendWidth; + $uniform12[9] = blendHeight; + $uniform12[10] = 0; + $uniform12[11] = 0; + const uniformBuffer = bufferManager.acquireAndWriteUniformBuffer($uniform12, 48); + + const sampler = textureManager.createSampler("scale_sampler", true); + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = originalAttachment.texture!.view; + const bindGroup = device.createBindGroup({ + "layout": scaleBindGroupLayout, + "entries": $entries3 + }); + + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + srcAttachment.texture!.view, + 0, 0, 0, 0, + "clear" + ); + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(scalePipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + frameBufferManager.releaseTemporaryAttachment(originalAttachment); + } else { + commandEncoder.copyTextureToTexture( + { + "texture": atlasAttachment.texture.resource, + "origin": { "x": node.x, "y": node.y, "z": 0 } + }, + { + "texture": srcAttachment.texture!.resource, + "origin": { "x": 0, "y": 0, "z": 0 } + }, + { "width": Math.min(node.w, blendWidth), "height": Math.min(node.h, blendHeight) } + ); + } + } else { + srcAttachment = frameBufferManager.createTemporaryAttachment(blendWidth, blendHeight); + commandEncoder.copyTextureToTexture( + { + "texture": atlasAttachment.texture.resource, + "origin": { "x": node.x, "y": node.y, "z": 0 } + }, + { + "texture": srcAttachment.texture!.resource, + "origin": { "x": 0, "y": 0, "z": 0 } + }, + { "width": blendWidth, "height": blendHeight } + ); + } + + // 2. デスティネーションテクスチャを作成(メインからレンダーパスでコピー) + const dstAttachment = frameBufferManager.createTemporaryAttachment(blendWidth, blendHeight); + + copyTextureRegionViaRenderPass( + device, + commandEncoder, + mainAttachment.texture.view, + dstAttachment, + dstX, + dstY, + mainAttachment.width, + mainAttachment.height, + blendWidth, + blendHeight, + frameBufferManager, + textureManager, + pipelineManager, + bufferManager + ); + + // 3. カラートランスフォームを準備(add値は生値) + $uniform8[0] = color_transform[0]; + $uniform8[1] = color_transform[1]; + $uniform8[2] = color_transform[2]; + $uniform8[3] = global_alpha; + $uniform8[4] = color_transform[4]; + $uniform8[5] = color_transform[5]; + $uniform8[6] = color_transform[6]; + $uniform8[7] = 0; + + // 4. 複雑なブレンドを適用 + const blendedAttachment = blendApplyComplexBlendUseCase( + srcAttachment, + dstAttachment, + blend_mode, + $uniform8, + { + device, + commandEncoder, + bufferManager, + frameBufferManager, + pipelineManager, + textureManager, + "frameTextures": [] + } + ); + + // 5. 結果をメインアタッチメントに描画 + drawToMainAttachment( + device, + commandEncoder, + blendedAttachment, + mainAttachment, + dstX, + dstY, + frameBufferManager, + textureManager, + pipelineManager, + bufferManager + ); + + // 6. 一時テクスチャを解放 + frameBufferManager.releaseTemporaryAttachment(srcAttachment); + frameBufferManager.releaseTemporaryAttachment(dstAttachment); + frameBufferManager.releaseTemporaryAttachment(blendedAttachment); + } + + clearComplexBlendQueue(); +}; diff --git a/packages/webgpu/src/FillTexturePool.ts b/packages/webgpu/src/FillTexturePool.ts new file mode 100644 index 00000000..75e75797 --- /dev/null +++ b/packages/webgpu/src/FillTexturePool.ts @@ -0,0 +1,87 @@ +// GPUTexture → GPUTextureView キャッシュ(createView()呼び出し削減) +const $viewCache = new WeakMap(); + +export const $getOrCreateView = (texture: GPUTexture): GPUTextureView => { + let view = $viewCache.get(texture); + if (!view) { + view = texture.createView(); + $viewCache.set(texture, view); + } + return view; +}; + +// GPUTextureUsage.TEXTURE_BINDING(0x04) | GPUTextureUsage.COPY_DST(0x02) = 0x06 +const FILL_TEXTURE_USAGE = 0x06; + +// GPUTextureUsage.TEXTURE_BINDING(0x04) | GPUTextureUsage.COPY_DST(0x02) | GPUTextureUsage.RENDER_ATTACHMENT(0x10) = 0x16 +const RENDER_TEXTURE_USAGE = 0x16; + +const $pool: Map = new Map(); +const $renderPool: Map = new Map(); + +export const $acquireFillTexture = (device: GPUDevice, width: number, height: number): GPUTexture => +{ + const key = `${width}_${height}`; + const list = $pool.get(key); + if (list && list.length > 0) { + return list.pop()!; + } + return device.createTexture({ + "size": { width, height }, + "format": "rgba8unorm", + "usage": FILL_TEXTURE_USAGE + }); +}; + +export const $releaseFillTexture = (texture: GPUTexture): void => +{ + const key = `${texture.width}_${texture.height}`; + let list = $pool.get(key); + if (!list) { + list = []; + $pool.set(key, list); + } + list.push(texture); +}; + +export const $acquireRenderTexture = (device: GPUDevice, width: number, height: number): GPUTexture => +{ + const key = `${width}_${height}`; + const list = $renderPool.get(key); + if (list && list.length > 0) { + return list.pop()!; + } + return device.createTexture({ + "size": { width, height }, + "format": "rgba8unorm", + "usage": RENDER_TEXTURE_USAGE + }); +}; + +export const $releaseRenderTexture = (texture: GPUTexture): void => +{ + const key = `${texture.width}_${texture.height}`; + let list = $renderPool.get(key); + if (!list) { + list = []; + $renderPool.set(key, list); + } + list.push(texture); +}; + +export const $clearFillTexturePool = (): void => +{ + for (const [, list] of $pool) { + for (const texture of list) { + texture.destroy(); + } + } + $pool.clear(); + + for (const [, list] of $renderPool) { + for (const texture of list) { + texture.destroy(); + } + } + $renderPool.clear(); +}; diff --git a/packages/webgpu/src/Filter/BevelFilter/FilterApplyBevelFilterUseCase.test.ts b/packages/webgpu/src/Filter/BevelFilter/FilterApplyBevelFilterUseCase.test.ts new file mode 100644 index 00000000..76e39a81 --- /dev/null +++ b/packages/webgpu/src/Filter/BevelFilter/FilterApplyBevelFilterUseCase.test.ts @@ -0,0 +1,439 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { execute } from "./FilterApplyBevelFilterUseCase"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + UNIFORM: 0x40, + COPY_DST: 0x08 +}; +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +// Mock offset +vi.mock("../index", () => ({ + "$offset": { "x": 0, "y": 0 } +})); + +import { $offset } from "../FilterOffset"; + +// Mock FilterApplyBlurFilterUseCase +vi.mock("../BlurFilter/FilterApplyBlurFilterUseCase", () => ({ + "execute": vi.fn((source: IAttachmentObject) => ({ + ...source, + "width": source.width + 40, + "height": source.height + 40 + })) +})); + +describe("FilterApplyBevelFilterUseCase", () => +{ + const createMockAttachment = (width: number = 100, height: number = 100): IAttachmentObject => + { + return { + "id": 1, + "width": width, + "height": height, + "clipLevel": 0, + "texture": { + "resource": { "label": "mockTexture" } as unknown as GPUTexture, + "view": { "label": "mockTextureView" } as unknown as GPUTextureView + } + } as IAttachmentObject; + }; + + const createMockConfig = (): IFilterConfig => + { + const mockPassEncoder = { + "setPipeline": vi.fn(), + "setBindGroup": vi.fn(), + "draw": vi.fn(), + "end": vi.fn() + }; + + return { + "device": { + "createBuffer": vi.fn(() => ({ "label": "mockBuffer" })), + "queue": { "writeBuffer": vi.fn() }, + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })) + } as unknown as GPUDevice, + "commandEncoder": { + "beginRenderPass": vi.fn(() => mockPassEncoder), + "copyTextureToTexture": vi.fn() + } as unknown as GPUCommandEncoder, + "frameBufferManager": { + "createTemporaryAttachment": vi.fn((w: number, h: number) => createMockAttachment(w, h)), + "releaseTemporaryAttachment": vi.fn(), + "createRenderPassDescriptor": vi.fn(() => ({ + "colorAttachments": [{ "view": {}, "loadOp": "clear", "storeOp": "store" }] + })) + }, + "pipelineManager": { + "getPipeline": vi.fn(() => ({ "label": "mockPipeline" })), + "getFilterPipeline": vi.fn(() => ({ "label": "mockPipeline" })), + "getBindGroupLayout": vi.fn(() => ({ "label": "mockLayout" })) + }, + "textureManager": { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })) + } + } as unknown as IFilterConfig; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + $offset.x = 0; + $offset.y = 0; + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("basic bevel execution", () => + { + it("should apply blur filter", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, // distance + 45, // angle (degrees) + 0xFFFFFF, // highlightColor + 1.0, // highlightAlpha + 0x000000, // shadowColor + 1.0, // shadowAlpha + 10, // blurX + 10, // blurY + 1.0, // strength + 1, // quality + 0, // type (full) + false, // knockout + 1, // devicePixelRatio + config + ); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalled(); + }); + + it("should create uniform buffer with bevel parameters", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + 0xFFFFFF, 0.8, + 0x000000, 0.8, + 10, 10, + 2.0, 1, 0, false, 1, + config + ); + + expect(config.device.createBuffer).toHaveBeenCalled(); + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + + it("should copy textures for compositing", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + 0xFFFFFF, 1.0, + 0x000000, 1.0, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(config.commandEncoder.copyTextureToTexture).toHaveBeenCalled(); + }); + + it("should return result attachment", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + 4, 45, + 0xFFFFFF, 1.0, + 0x000000, 1.0, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(result).toBeDefined(); + expect(result.texture).toBeDefined(); + }); + }); + + describe("bevel angle calculation", () => + { + it("should calculate bevel position based on angle 0", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 0, // 0 degrees + 0xFFFFFF, 1.0, + 0x000000, 1.0, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(config.commandEncoder.copyTextureToTexture).toHaveBeenCalled(); + }); + + it("should calculate bevel position based on angle 90", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 90, // 90 degrees + 0xFFFFFF, 1.0, + 0x000000, 1.0, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(config.commandEncoder.copyTextureToTexture).toHaveBeenCalled(); + }); + + it("should calculate bevel position based on angle 180", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 180, // 180 degrees + 0xFFFFFF, 1.0, + 0x000000, 1.0, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(config.commandEncoder.copyTextureToTexture).toHaveBeenCalled(); + }); + }); + + describe("bevel type modes", () => + { + it("should handle full bevel type (type 0)", () => + { + const sourceAttachment = createMockAttachment(100, 100); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + 0xFFFFFF, 1.0, + 0x000000, 1.0, + 10, 10, + 1.0, 1, + 0, // full + false, 1, + config + ); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalled(); + }); + + it("should handle inner bevel type (type 1)", () => + { + const sourceAttachment = createMockAttachment(100, 100); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + 0xFFFFFF, 1.0, + 0x000000, 1.0, + 10, 10, + 1.0, 1, + 1, // inner + false, 1, + config + ); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalled(); + }); + + it("should handle outer bevel type (type 2)", () => + { + const sourceAttachment = createMockAttachment(100, 100); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + 0xFFFFFF, 1.0, + 0x000000, 1.0, + 10, 10, + 1.0, 1, + 2, // outer + false, 1, + config + ); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalled(); + }); + + it("should restore offset for inner bevel", () => + { + $offset.x = 5; + $offset.y = 5; + const baseOffsetX = $offset.x; + const baseOffsetY = $offset.y; + + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + 0xFFFFFF, 1.0, + 0x000000, 1.0, + 10, 10, + 1.0, 1, + 1, // inner + false, 1, + config + ); + + expect($offset.x).toBe(baseOffsetX); + expect($offset.y).toBe(baseOffsetY); + }); + }); + + describe("knockout mode", () => + { + it("should pass knockout flag to uniform buffer", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + 0xFFFFFF, 1.0, + 0x000000, 1.0, + 10, 10, + 1.0, 1, 0, + true, // knockout = true + 1, + config + ); + + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + }); + + describe("pipeline error handling", () => + { + it("should return source attachment when pipeline not found", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + (config.pipelineManager.getFilterPipeline as ReturnType).mockReturnValue(null); + + const result = execute( + sourceAttachment, + matrix, + 4, 45, + 0xFFFFFF, 1.0, + 0x000000, 1.0, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(console.error).toHaveBeenCalledWith("[WebGPU BevelFilter] Pipeline not found"); + expect(result).toBe(sourceAttachment); + }); + }); + + describe("cleanup", () => + { + it("should release temporary attachments after processing", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + 0xFFFFFF, 1.0, + 0x000000, 1.0, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + // Should release erase and blur attachments (UV変換方式により一時テクスチャ不要) + expect(config.frameBufferManager.releaseTemporaryAttachment).toHaveBeenCalledTimes(2); + }); + }); + + describe("matrix scale handling", () => + { + it("should apply matrix scale to bevel offset", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([2, 0, 0, 2, 0, 0]); // 2x scale + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + 0xFFFFFF, 1.0, + 0x000000, 1.0, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(config.commandEncoder.copyTextureToTexture).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/BevelFilter/FilterApplyBevelFilterUseCase.ts b/packages/webgpu/src/Filter/BevelFilter/FilterApplyBevelFilterUseCase.ts new file mode 100644 index 00000000..845fc0b6 --- /dev/null +++ b/packages/webgpu/src/Filter/BevelFilter/FilterApplyBevelFilterUseCase.ts @@ -0,0 +1,286 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { $offset } from "../FilterOffset"; +import { execute as filterApplyBlurFilterUseCase } from "../BlurFilter/FilterApplyBlurFilterUseCase"; + +/** + * @description 度からラジアンへの変換係数 + */ +const DEG_TO_RAD: number = Math.PI / 180; + +/** + * @description プリアロケートされたFloat32Array + */ +const $uniform8 = new Float32Array(8); +const $uniform20 = new Float32Array(20); + +/** + * @description プリアロケートされたBindGroupEntry配列 (バインディング3つ) + */ +const $entries3: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView } +]; + +/** + * @description プリアロケートされたBindGroupEntry配列 (バインディング4つ) + */ +const $entries4: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView }, + { "binding": 3, "resource": null as unknown as GPUTextureView } +]; + +/** + * @description 32bit整数からRGB値を抽出(プリマルチプライドアルファ対応) + */ +const intToRGBA = (color: number, alpha: number): [number, number, number, number] => { + const r = (color >> 16 & 0xFF) / 255 * alpha; + const g = (color >> 8 & 0xFF) / 255 * alpha; + const b = (color & 0xFF) / 255 * alpha; + return [r, g, b, alpha]; +}; + +/** + * @description ベベルフィルターを適用 + * WebGL版と同様に、erase前処理で差分テクスチャを作成してからブラーを適用 + * + * UV変換方式で元テクスチャとブラーテクスチャを直接サンプリング。 + * 合成時のcopyTextureToTextureと一時テクスチャを使用しない最適化版。 + */ +export const execute = ( + sourceAttachment: IAttachmentObject, + matrix: Float32Array, + distance: number, + angle: number, + highlightColor: number, + highlightAlpha: number, + shadowColor: number, + shadowAlpha: number, + blurX: number, + blurY: number, + strength: number, + quality: number, + type: number, + knockout: boolean, + devicePixelRatio: number, + config: IFilterConfig +): IAttachmentObject => { + + const { device, commandEncoder, frameBufferManager, pipelineManager, textureManager } = config; + + // 元のオフセットを保存 + const baseOffsetX = $offset.x; + const baseOffsetY = $offset.y; + const baseWidth = sourceAttachment.width; + const baseHeight = sourceAttachment.height; + + // スケールを計算 + const xScale = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]); + const yScale = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]); + + // オフセットを計算(WebGL版と同じ) + const radian = angle * DEG_TO_RAD; + const x = Math.cos(radian) * distance * (xScale / devicePixelRatio); + const y = Math.sin(radian) * distance * (yScale / devicePixelRatio); + + // === Erase前処理:差分テクスチャを作成 === + const eraseAttachment = frameBufferManager.createTemporaryAttachment(baseWidth, baseHeight); + + // Step 1: ソーステクスチャを元の位置にコピー(erase前処理のcopyTextureToTextureは残す) + commandEncoder.copyTextureToTexture( + { + "texture": sourceAttachment.texture!.resource, + "origin": { "x": 0, "y": 0, "z": 0 } + }, + { + "texture": eraseAttachment.texture!.resource, + "origin": { "x": 0, "y": 0, "z": 0 } + }, + { + "width": baseWidth, + "height": baseHeight + } + ); + + // Step 2: オフセット位置からサンプルしてerase描画 + const erasePipeline = pipelineManager.getPipeline("texture_erase"); + const eraseBindGroupLayout = pipelineManager.getBindGroupLayout("texture_copy"); + + if (erasePipeline && eraseBindGroupLayout) { + const eraseSampler = textureManager.createSampler("erase_sampler", true); + + const offsetX = x * 2 / baseWidth; + const offsetY = y * 2 / baseHeight; + + $uniform8[0] = 1.0; + $uniform8[1] = 1.0; + $uniform8[2] = offsetX; + $uniform8[3] = offsetY; + $uniform8[4] = 0; + $uniform8[5] = 0; + $uniform8[6] = 0; + $uniform8[7] = 0; + + const eraseUniformBuffer = config.bufferManager + ? config.bufferManager.acquireAndWriteUniformBuffer($uniform8) + : device.createBuffer({ + "size": $uniform8.byteLength, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + if (!config.bufferManager) { + device.queue.writeBuffer(eraseUniformBuffer, 0, $uniform8); + } + + ($entries3[0].resource as GPUBufferBinding).buffer = eraseUniformBuffer; + $entries3[1].resource = eraseSampler; + $entries3[2].resource = sourceAttachment.texture!.view; + const eraseBindGroup = device.createBindGroup({ + "layout": eraseBindGroupLayout, + "entries": $entries3 + }); + + const erasePassDescriptor = frameBufferManager.createRenderPassDescriptor( + eraseAttachment.texture!.view, 0, 0, 0, 0, "load" + ); + + const erasePassEncoder = commandEncoder.beginRenderPass(erasePassDescriptor); + erasePassEncoder.setPipeline(erasePipeline); + erasePassEncoder.setBindGroup(0, eraseBindGroup); + erasePassEncoder.draw(6, 1, 0, 0); + erasePassEncoder.end(); + } + + // === 差分テクスチャにブラーを適用 === + const blurAttachment = filterApplyBlurFilterUseCase( + eraseAttachment, matrix, + blurX, blurY, quality, + devicePixelRatio, config + ); + + // eraseアタッチメントを解放 + frameBufferManager.releaseTemporaryAttachment(eraseAttachment); + + const blurWidth = blurAttachment.width; + const blurHeight = blurAttachment.height; + + // 出力サイズを計算 + const absX = Math.abs(x); + const absY = Math.abs(y); + const isInner = type === 1; + const bevelWidth = Math.ceil(blurWidth + absX * 2); + const bevelHeight = Math.ceil(blurHeight + absY * 2); + const width = isInner ? baseWidth : bevelWidth; + const height = isInner ? baseHeight : bevelHeight; + + // オフセット差分を計算 + const blurOffsetFromBase = (blurWidth - baseWidth) / 2; + const blurOffsetFromBaseY = (blurHeight - baseHeight) / 2; + + // UV変換パラメータ計算(GradientBevelFilterと同じパターン) + const baseTextureX = isInner ? 0 : Math.floor(absX + blurOffsetFromBase); + const baseTextureY = isInner ? 0 : Math.floor(absY + blurOffsetFromBaseY); + const blurTextureX = isInner ? Math.floor(-blurOffsetFromBase - x) : Math.floor(absX - x); + const blurTextureY = isInner ? Math.floor(-blurOffsetFromBaseY - y) : Math.floor(absY - y); + + const baseScaleX = width / baseWidth; + const baseScaleY = height / baseHeight; + const baseOffsetUVX = baseTextureX / baseWidth; + const baseOffsetUVY = baseTextureY / baseHeight; + + const blurScaleX = width / blurWidth; + const blurScaleY = height / blurHeight; + const blurOffsetUVX = blurTextureX / blurWidth; + const blurOffsetUVY = blurTextureY / blurHeight; + + // 出力アタッチメントを作成 + const destAttachment = frameBufferManager.createTemporaryAttachment(width, height); + + const pipeline = pipelineManager.getFilterPipeline("bevel_filter", { + "BEVEL_TYPE": type, + "IS_KNOCKOUT": knockout ? 1 : 0 + }); + const bindGroupLayout = pipelineManager.getBindGroupLayout("bevel_filter"); + + if (!pipeline || !bindGroupLayout) { + console.error("[WebGPU BevelFilter] Pipeline not found"); + frameBufferManager.releaseTemporaryAttachment(blurAttachment); + return sourceAttachment; + } + + // サンプラーを作成 + const sampler = textureManager.createSampler("bevel_sampler", true); + + // ユニフォームバッファを作成 + // highlightColor: vec4 (16 bytes) + // shadowColor: vec4 (16 bytes) + // strength, inner, knockout, bevelType (16 bytes) + // baseScale, baseOffset (16 bytes) + // blurScale, blurOffset (16 bytes) + // Total: 80 bytes → 16 floats + 4 floats = 20 floats (80 bytes) + const [hr, hg, hb, ha] = intToRGBA(highlightColor, highlightAlpha); + const [sr, sg, sb, sa] = intToRGBA(shadowColor, shadowAlpha); + + $uniform20[0] = hr; + $uniform20[1] = hg; + $uniform20[2] = hb; + $uniform20[3] = ha; + $uniform20[4] = sr; + $uniform20[5] = sg; + $uniform20[6] = sb; + $uniform20[7] = sa; + $uniform20[8] = strength; + $uniform20[9] = isInner ? 1.0 : 0.0; + $uniform20[10] = knockout ? 1.0 : 0.0; + $uniform20[11] = type; + $uniform20[12] = baseScaleX; + $uniform20[13] = baseScaleY; + $uniform20[14] = baseOffsetUVX; + $uniform20[15] = baseOffsetUVY; + $uniform20[16] = blurScaleX; + $uniform20[17] = blurScaleY; + $uniform20[18] = blurOffsetUVX; + $uniform20[19] = blurOffsetUVY; + + const uniformBuffer = config.bufferManager + ? config.bufferManager.acquireAndWriteUniformBuffer($uniform20) + : device.createBuffer({ + "size": $uniform20.byteLength, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + if (!config.bufferManager) { + device.queue.writeBuffer(uniformBuffer, 0, $uniform20); + } + + // バインドグループを作成(元テクスチャとブラーテクスチャを直接バインド) + ($entries4[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries4[1].resource = sampler; + $entries4[2].resource = blurAttachment.texture!.view; + $entries4[3].resource = sourceAttachment.texture!.view; + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries4 + }); + + // レンダーパスを実行 + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + destAttachment.texture!.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + // クリーンアップ + frameBufferManager.releaseTemporaryAttachment(blurAttachment); + + // オフセットを更新(WebGL版と同じ: 常にbaseOffset+baseTextureXYに設定) + $offset.x = baseOffsetX + baseTextureX; + $offset.y = baseOffsetY + baseTextureY; + + return destAttachment; +}; diff --git a/packages/webgpu/src/Filter/BevelFilterShader.test.ts b/packages/webgpu/src/Filter/BevelFilterShader.test.ts new file mode 100644 index 00000000..ec27301e --- /dev/null +++ b/packages/webgpu/src/Filter/BevelFilterShader.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { getBevelFilterFragmentShader, getBevelFilterShaderKey } from "./BevelFilterShader"; + +describe("BevelFilterShader", () => +{ + describe("getBevelFilterFragmentShader", () => + { + it("should return a valid WGSL shader string for full type", () => + { + const shader = getBevelFilterFragmentShader("full", false, false); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should return a valid shader for inner type", () => + { + const shader = getBevelFilterFragmentShader("inner", false, false); + + expect(shader).toContain("filterColor * baseAlpha"); + }); + + it("should return a valid shader for outer type", () => + { + const shader = getBevelFilterFragmentShader("outer", false, false); + + expect(shader).toContain("filterColor * (1.0 - baseAlpha)"); + }); + + it("should contain @vertex attribute", () => + { + const shader = getBevelFilterFragmentShader("full", false, false); + + expect(shader).toContain("@vertex"); + }); + + it("should contain @fragment attribute", () => + { + const shader = getBevelFilterFragmentShader("full", false, false); + + expect(shader).toContain("@fragment"); + }); + + it("should define BevelUniforms struct", () => + { + const shader = getBevelFilterFragmentShader("full", false, false); + + expect(shader).toContain("struct BevelUniforms"); + }); + + it("should include highlight and shadow colors", () => + { + const shader = getBevelFilterFragmentShader("full", false, false); + + expect(shader).toContain("highlightColor"); + expect(shader).toContain("shadowColor"); + }); + + it("should handle knockout mode", () => + { + const shader = getBevelFilterFragmentShader("full", true, false); + + expect(shader).toContain("finalColor"); + }); + + it("should include gradient texture binding when isGradient is true", () => + { + const shader = getBevelFilterFragmentShader("full", false, true); + + expect(shader).toContain("gradientTexture"); + }); + + it("should not include gradient texture binding when isGradient is false", () => + { + const shader = getBevelFilterFragmentShader("full", false, false); + + expect(shader).not.toContain("gradientTexture"); + }); + + it("should use gradient LUT when isGradient is true", () => + { + const shader = getBevelFilterFragmentShader("full", false, true); + + expect(shader).toContain("gradientCoord"); + }); + }); + + describe("getBevelFilterShaderKey", () => + { + it("should generate unique key for full type", () => + { + const key = getBevelFilterShaderKey("full", false, false); + + expect(key).toBe("bevel_full_nko_ng"); + }); + + it("should generate unique key for inner type with knockout", () => + { + const key = getBevelFilterShaderKey("inner", true, false); + + expect(key).toBe("bevel_inner_ko_ng"); + }); + + it("should generate unique key for outer type with gradient", () => + { + const key = getBevelFilterShaderKey("outer", false, true); + + expect(key).toBe("bevel_outer_nko_g"); + }); + + it("should generate different keys for different configurations", () => + { + const key1 = getBevelFilterShaderKey("full", false, false); + const key2 = getBevelFilterShaderKey("full", true, false); + const key3 = getBevelFilterShaderKey("full", false, true); + + expect(key1).not.toBe(key2); + expect(key1).not.toBe(key3); + expect(key2).not.toBe(key3); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/BevelFilterShader.ts b/packages/webgpu/src/Filter/BevelFilterShader.ts new file mode 100644 index 00000000..5c390985 --- /dev/null +++ b/packages/webgpu/src/Filter/BevelFilterShader.ts @@ -0,0 +1,118 @@ +export const getBevelFilterFragmentShader = ( + type: string, + knockout: boolean, + isGradient: boolean +): string => { + const isInner = type === "inner"; + const isOuter = type === "outer"; + + const gradientBinding = isGradient ? ` +@group(0) @binding(4) var gradientTexture: texture_2d;` : ""; + + const colorCalculation = isGradient ? ` + let gradientCoord = vec2(blurAlpha, 0.5); + var filterColor = textureSample(gradientTexture, sourceSampler, gradientCoord); +` : ` + let highlightWeight = clamp(blurAlpha * 2.0, 0.0, 1.0); + let shadowWeight = clamp((1.0 - blurAlpha) * 2.0, 0.0, 1.0); + var filterColor = uniforms.highlightColor * highlightWeight + uniforms.shadowColor * shadowWeight; +`; + + let typeProcessing = ""; + if (isInner) { + typeProcessing = ` + let baseAlpha = textureSample(baseTexture, sourceSampler, baseTexCoord).a; + filterColor = filterColor * baseAlpha; + ${knockout ? "let finalColor = filterColor;" : "let finalColor = mix(baseColor, filterColor, filterColor.a);"} +`; + } else if (isOuter) { + typeProcessing = ` + let baseAlpha = textureSample(baseTexture, sourceSampler, baseTexCoord).a; + filterColor = filterColor * (1.0 - baseAlpha); + ${knockout ? "let finalColor = filterColor;" : "let finalColor = filterColor + baseColor * (1.0 - filterColor.a);"} +`; + } else { + typeProcessing = knockout ? ` + let finalColor = filterColor; +` : ` + let finalColor = filterColor + baseColor * (1.0 - filterColor.a); +`; + } + + return ` +struct BevelUniforms { + blurTexCoordScale: vec2, + blurTexCoordOffset: vec2, + baseTexCoordScale: vec2, + baseTexCoordOffset: vec2, + strength: f32, + _pad1: f32, + _pad2: f32, + _pad3: f32, + highlightColor: vec4, + shadowColor: vec4, +} + +@group(0) @binding(0) var uniforms: BevelUniforms; +@group(0) @binding(1) var sourceSampler: sampler; +@group(0) @binding(2) var blurTexture: texture_2d; +@group(0) @binding(3) var baseTexture: texture_2d; +${gradientBinding} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, +} + +@vertex +fn vs_main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { + var positions = array, 6>( + vec2(-1.0, -1.0), + vec2(1.0, -1.0), + vec2(-1.0, 1.0), + vec2(-1.0, 1.0), + vec2(1.0, -1.0), + vec2(1.0, 1.0) + ); + + var texCoords = array, 6>( + vec2(0.0, 1.0), + vec2(1.0, 1.0), + vec2(0.0, 0.0), + vec2(0.0, 0.0), + vec2(1.0, 1.0), + vec2(1.0, 0.0) + ); + + var output: VertexOutput; + output.position = vec4(positions[vertexIndex], 0.0, 1.0); + output.texCoord = texCoords[vertexIndex]; + return output; +} + +@fragment +fn fs_main(input: VertexOutput) -> @location(0) vec4 { + let blurTexCoord = input.texCoord * uniforms.blurTexCoordScale + uniforms.blurTexCoordOffset; + let baseTexCoord = input.texCoord * uniforms.baseTexCoordScale + uniforms.baseTexCoordOffset; + + let blurColor = textureSample(blurTexture, sourceSampler, blurTexCoord); + var blurAlpha = blurColor.a * uniforms.strength; + blurAlpha = clamp(blurAlpha, 0.0, 1.0); + + let baseColor = textureSample(baseTexture, sourceSampler, baseTexCoord); + + ${colorCalculation} + ${typeProcessing} + + return finalColor; +} +`; +}; + +export const getBevelFilterShaderKey = ( + type: string, + knockout: boolean, + isGradient: boolean +): string => { + return `bevel_${type}_${knockout ? "ko" : "nko"}_${isGradient ? "g" : "ng"}`; +}; diff --git a/packages/webgpu/src/Filter/BitmapFilterShader.test.ts b/packages/webgpu/src/Filter/BitmapFilterShader.test.ts new file mode 100644 index 00000000..332c8f13 --- /dev/null +++ b/packages/webgpu/src/Filter/BitmapFilterShader.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from "vitest"; +import { getBitmapFilterFragmentShader, getBitmapFilterShaderKey } from "./BitmapFilterShader"; + +describe("BitmapFilterShader", () => +{ + describe("getBitmapFilterFragmentShader", () => + { + it("should return a valid WGSL shader string", () => + { + const shader = getBitmapFilterFragmentShader(true, true, true, "full", false, true, false); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = getBitmapFilterFragmentShader(true, true, true, "full", false, true, false); + + expect(shader).toContain("@vertex"); + }); + + it("should contain @fragment attribute", () => + { + const shader = getBitmapFilterFragmentShader(true, true, true, "full", false, true, false); + + expect(shader).toContain("@fragment"); + }); + + it("should define BitmapFilterUniforms struct", () => + { + const shader = getBitmapFilterFragmentShader(true, true, true, "full", false, true, false); + + expect(shader).toContain("struct BitmapFilterUniforms"); + }); + + it("should include base scale/offset when transformsBase is true", () => + { + const shader = getBitmapFilterFragmentShader(true, false, true, "full", false, false, false); + + expect(shader).toContain("baseScale"); + expect(shader).toContain("baseOffset"); + }); + + it("should include blur scale/offset when transformsBlur is true", () => + { + const shader = getBitmapFilterFragmentShader(false, true, true, "full", false, false, false); + + expect(shader).toContain("blurScale"); + expect(shader).toContain("blurOffset"); + }); + + it("should include strength when appliesStrength is true", () => + { + const shader = getBitmapFilterFragmentShader(false, false, true, "full", false, true, false); + + expect(shader).toContain("strength"); + }); + + it("should include color for glow mode", () => + { + const shader = getBitmapFilterFragmentShader(false, false, true, "full", false, false, false); + + expect(shader).toContain("color"); + }); + + it("should include highlight/shadow colors for bevel mode", () => + { + const shader = getBitmapFilterFragmentShader(false, false, false, "full", false, false, false); + + expect(shader).toContain("highlightColor"); + expect(shader).toContain("shadowColor"); + }); + + it("should include gradient texture when isGradient is true", () => + { + const shader = getBitmapFilterFragmentShader(false, false, true, "full", false, false, true); + + expect(shader).toContain("gradientTexture"); + }); + + it("should handle inner type", () => + { + const shader = getBitmapFilterFragmentShader(true, true, true, "inner", false, true, false); + + expect(shader).toContain("blur"); + }); + + it("should handle outer type", () => + { + const shader = getBitmapFilterFragmentShader(true, true, true, "outer", false, true, false); + + expect(shader).toContain("blur"); + }); + + it("should handle knockout mode", () => + { + const shader = getBitmapFilterFragmentShader(true, true, true, "full", true, true, false); + + expect(shader).toBeDefined(); + }); + + it("should include isInside helper function", () => + { + const shader = getBitmapFilterFragmentShader(true, true, true, "full", false, true, false); + + expect(shader).toContain("fn isInside"); + }); + }); + + describe("getBitmapFilterShaderKey", () => + { + it("should generate unique key for glow configuration", () => + { + const key = getBitmapFilterShaderKey(true, true, true, "full", false, true, false); + + expect(key).toBe("bitmap_yygfullnsso"); + }); + + it("should generate unique key for bevel configuration", () => + { + const key = getBitmapFilterShaderKey(true, true, false, "full", false, true, false); + + expect(key).toBe("bitmap_yybfullnsso"); + }); + + it("should generate different keys for different configurations", () => + { + const key1 = getBitmapFilterShaderKey(true, true, true, "full", false, true, false); + const key2 = getBitmapFilterShaderKey(true, true, true, "inner", false, true, false); + const key3 = getBitmapFilterShaderKey(true, true, true, "full", true, true, false); + + expect(key1).not.toBe(key2); + expect(key1).not.toBe(key3); + }); + + it("should include gradient flag in key", () => + { + const keyNonGradient = getBitmapFilterShaderKey(true, true, true, "full", false, true, false); + const keyGradient = getBitmapFilterShaderKey(true, true, true, "full", false, true, true); + + expect(keyNonGradient).toContain("so"); + expect(keyGradient).toContain("gr"); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/BitmapFilterShader.ts b/packages/webgpu/src/Filter/BitmapFilterShader.ts new file mode 100644 index 00000000..2e6fe223 --- /dev/null +++ b/packages/webgpu/src/Filter/BitmapFilterShader.ts @@ -0,0 +1,231 @@ +export const getBitmapFilterFragmentShader = ( + transformsBase: boolean, + transformsBlur: boolean, + isGlow: boolean, + type: string, + knockout: boolean, + appliesStrength: boolean, + isGradient: boolean +): string => { + const isInner = type === "inner"; + + let textureBindingIndex = 2; + const blurTextureBinding = textureBindingIndex++; + const baseTextureBinding = transformsBase ? textureBindingIndex++ : -1; + const gradientTextureBinding = isGradient ? textureBindingIndex++ : -1; + + let uniformsStruct = `struct BitmapFilterUniforms { +`; + if (transformsBase) { + uniformsStruct += ` baseScale: vec2, + baseOffset: vec2, +`; + } + if (transformsBlur) { + uniformsStruct += ` blurScale: vec2, + blurOffset: vec2, +`; + } + if (appliesStrength) { + uniformsStruct += ` strength: f32, + _padStrength: vec3, +`; + } + if (!isGradient) { + if (isGlow) { + uniformsStruct += ` color: vec4, +`; + } else { + uniformsStruct += ` highlightColor: vec4, + shadowColor: vec4, +`; + } + } + uniformsStruct += "}"; + + let textureBindings = ` +@group(0) @binding(0) var uniforms: BitmapFilterUniforms; +@group(0) @binding(1) var sourceSampler: sampler; +@group(0) @binding(${blurTextureBinding}) var blurTexture: texture_2d;`; + + if (transformsBase) { + textureBindings += ` +@group(0) @binding(${baseTextureBinding}) var baseTexture: texture_2d;`; + } + if (isGradient) { + textureBindings += ` +@group(0) @binding(${gradientTextureBinding}) var gradientTexture: texture_2d;`; + } + + let baseStatement = ""; + if (transformsBase) { + baseStatement = ` + let baseScale = uniforms.baseScale; + let baseOffset = uniforms.baseOffset; + let uv = input.texCoord * baseScale - baseOffset; + let base = mix(vec4(0.0), textureSample(baseTexture, sourceSampler, uv), isInside(uv));`; + } + + let blurStatement = ""; + if (transformsBlur) { + blurStatement = ` + let blurScale = uniforms.blurScale; + let blurOffset = uniforms.blurOffset; + let st = input.texCoord * blurScale - blurOffset; + var blur = mix(vec4(0.0), textureSample(blurTexture, sourceSampler, st), isInside(st));`; + } else { + blurStatement = ` + var blur = textureSample(blurTexture, sourceSampler, input.texCoord);`; + } + + let colorStatement = ""; + if (isGlow) { + if (isInner) { + colorStatement += ` + blur.a = 1.0 - blur.a;`; + } + if (appliesStrength) { + colorStatement += ` + let strength = uniforms.strength; + blur.a = clamp(blur.a * strength, 0.0, 1.0);`; + } + if (isGradient) { + colorStatement += ` + blur = textureSample(gradientTexture, sourceSampler, vec2(blur.a, 0.5));`; + } else { + colorStatement += ` + let color = uniforms.color; + blur = color * blur.a;`; + } + } else { + if (transformsBlur) { + colorStatement += ` + let pq = (vec2(1.0) - input.texCoord) * blurScale - blurOffset; + let blur2 = mix(vec4(0.0), textureSample(blurTexture, sourceSampler, pq), isInside(pq));`; + } else { + colorStatement += ` + let blur2 = textureSample(blurTexture, sourceSampler, vec2(1.0) - input.texCoord);`; + } + colorStatement += ` + var highlightAlpha = blur.a - blur2.a; + var shadowAlpha = blur2.a - blur.a;`; + + if (appliesStrength) { + colorStatement += ` + let strength = uniforms.strength; + highlightAlpha = highlightAlpha * strength; + shadowAlpha = shadowAlpha * strength;`; + } + + colorStatement += ` + highlightAlpha = clamp(highlightAlpha, 0.0, 1.0); + shadowAlpha = clamp(shadowAlpha, 0.0, 1.0);`; + + if (isGradient) { + colorStatement += ` + blur = textureSample(gradientTexture, sourceSampler, vec2( + 0.5019607843137255 - 0.5019607843137255 * shadowAlpha + 0.4980392156862745 * highlightAlpha, + 0.5 + ));`; + } else { + colorStatement += ` + let highlightColor = uniforms.highlightColor; + let shadowColor = uniforms.shadowColor; + blur = highlightColor * highlightAlpha + shadowColor * shadowAlpha;`; + } + } + + let modeExpression = ""; + switch (type) { + case "outer": + modeExpression = knockout + ? "blur - blur * base.a" + : "base + blur - blur * base.a"; + break; + case "full": + modeExpression = knockout + ? "blur" + : "base - base * blur.a + blur"; + break; + case "inner": + default: + modeExpression = "blur"; + break; + } + + const needsBase = transformsBase || (type === "outer" || type === "full" && !knockout); + let baseDecl = ""; + if (needsBase && !transformsBase) { + baseDecl = ` + let base = vec4(0.0);`; + } + + return ` +${uniformsStruct} +${textureBindings} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, +} + +fn isInside(uv: vec2) -> f32 { + let inside = step(vec2(0.0), uv) * step(uv, vec2(1.0)); + return inside.x * inside.y; +} + +@vertex +fn vs_main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { + var positions = array, 6>( + vec2(-1.0, -1.0), + vec2(1.0, -1.0), + vec2(-1.0, 1.0), + vec2(-1.0, 1.0), + vec2(1.0, -1.0), + vec2(1.0, 1.0) + ); + + var texCoords = array, 6>( + vec2(0.0, 1.0), + vec2(1.0, 1.0), + vec2(0.0, 0.0), + vec2(0.0, 0.0), + vec2(1.0, 1.0), + vec2(1.0, 0.0) + ); + + var output: VertexOutput; + output.position = vec4(positions[vertexIndex], 0.0, 1.0); + output.texCoord = texCoords[vertexIndex]; + return output; +} + +@fragment +fn fs_main(input: VertexOutput) -> @location(0) vec4 { + ${baseDecl} + ${baseStatement} + ${blurStatement} + ${colorStatement} + + return ${modeExpression}; +} +`; +}; + +export const getBitmapFilterShaderKey = ( + transformsBase: boolean, + transformsBlur: boolean, + isGlow: boolean, + type: string, + knockout: boolean, + appliesStrength: boolean, + isGradient: boolean +): string => { + const key1 = transformsBase ? "y" : "n"; + const key2 = transformsBlur ? "y" : "n"; + const key3 = isGlow ? "g" : "b"; + const key4 = knockout ? "k" : "n"; + const key5 = appliesStrength ? "s" : "n"; + const key6 = isGradient ? "gr" : "so"; + return `bitmap_${key1}${key2}${key3}${type}${key4}${key5}${key6}`; +}; diff --git a/packages/webgpu/src/Filter/BlurFilter/FilterApplyBlurFilterUseCase.test.ts b/packages/webgpu/src/Filter/BlurFilter/FilterApplyBlurFilterUseCase.test.ts new file mode 100644 index 00000000..fd12819d --- /dev/null +++ b/packages/webgpu/src/Filter/BlurFilter/FilterApplyBlurFilterUseCase.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { execute } from "./FilterApplyBlurFilterUseCase"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + UNIFORM: 0x40, + COPY_DST: 0x08 +}; +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +// Mock offset +vi.mock("../index", () => ({ + "$offset": { "x": 0, "y": 0 } +})); + +import { $offset } from "../FilterOffset"; + +// Mock BlurFilterUseCase +vi.mock("../BlurFilterUseCase", () => ({ + "calculateBlurParams": vi.fn(() => ({ + "baseBlurX": 10, + "baseBlurY": 10, + "offsetX": 20, + "offsetY": 20, + "bufferScaleX": 1, + "bufferScaleY": 1 + })), + "calculateDirectionalBlurParams": vi.fn(() => ({ + "offsetX": 0.01, + "offsetY": 0, + "fraction": 1, + "samples": 11, + "halfBlur": 5 + })) +})); + +describe("FilterApplyBlurFilterUseCase", () => +{ + const createMockAttachment = (width: number = 100, height: number = 100): IAttachmentObject => + { + return { + "id": 1, + "width": width, + "height": height, + "clipLevel": 0, + "texture": { + "resource": { "label": "mockTexture" } as unknown as GPUTexture, + "view": { "label": "mockTextureView" } as unknown as GPUTextureView + } + } as IAttachmentObject; + }; + + const createMockConfig = (): IFilterConfig => + { + const mockPassEncoder = { + "setPipeline": vi.fn(), + "setBindGroup": vi.fn(), + "setViewport": vi.fn(), + "setScissorRect": vi.fn(), + "draw": vi.fn(), + "end": vi.fn() + }; + + return { + "device": { + "createBuffer": vi.fn(() => ({ "label": "mockBuffer" })), + "queue": { "writeBuffer": vi.fn() }, + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })) + } as unknown as GPUDevice, + "commandEncoder": { + "beginRenderPass": vi.fn(() => mockPassEncoder) + } as unknown as GPUCommandEncoder, + "frameBufferManager": { + "createTemporaryAttachment": vi.fn((w: number, h: number) => createMockAttachment(w, h)), + "releaseTemporaryAttachment": vi.fn(), + "createRenderPassDescriptor": vi.fn(() => ({ + "colorAttachments": [{ "view": {}, "loadOp": "clear", "storeOp": "store" }] + })) + }, + "pipelineManager": { + "getPipeline": vi.fn(() => ({ "label": "mockPipeline" })), + "getBindGroupLayout": vi.fn(() => ({ "label": "mockLayout" })) + }, + "textureManager": { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })) + } + } as unknown as IFilterConfig; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + $offset.x = 0; + $offset.y = 0; + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("basic blur execution", () => + { + it("should create temporary attachments for ping-pong buffer", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute(sourceAttachment, matrix, 10, 10, 1, 1, config); + + // Should create 2 temporary attachments for ping-pong + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalledTimes(2); + }); + + it("should create sampler with linear filtering", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute(sourceAttachment, matrix, 10, 10, 1, 1, config); + + expect(config.textureManager.createSampler).toHaveBeenCalledWith("blur_sampler", true); + }); + + it("should update offset based on blur parameters", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute(sourceAttachment, matrix, 10, 10, 1, 1, config); + + expect($offset.x).toBe(20); + expect($offset.y).toBe(20); + }); + + it("should return result attachment", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + const result = execute(sourceAttachment, matrix, 10, 10, 1, 1, config); + + expect(result).toBeDefined(); + expect(result.texture).toBeDefined(); + }); + }); + + describe("multi-pass blur", () => + { + it("should perform blur passes based on quality", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute(sourceAttachment, matrix, 10, 10, 3, 1, config); + + // Quality 3 = 3 iterations * 2 directions (H+V) + 1 initial copy = 7 render passes + // Actually: 1 copy + (3 * 2 blur passes) = 7 + expect(config.commandEncoder.beginRenderPass).toHaveBeenCalled(); + }); + + it("should skip horizontal pass when blurX is 0", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute(sourceAttachment, matrix, 0, 10, 1, 1, config); + + // Should still work, just fewer passes + expect(config.commandEncoder.beginRenderPass).toHaveBeenCalled(); + }); + + it("should skip vertical pass when blurY is 0", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute(sourceAttachment, matrix, 10, 0, 1, 1, config); + + // Should still work, just fewer passes + expect(config.commandEncoder.beginRenderPass).toHaveBeenCalled(); + }); + }); + + describe("buffer management", () => + { + it("should release unused buffer after processing", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute(sourceAttachment, matrix, 10, 10, 1, 1, config); + + // Should release at least one temporary attachment + expect(config.frameBufferManager.releaseTemporaryAttachment).toHaveBeenCalled(); + }); + }); + + describe("pipeline error handling", () => + { + it("should log error when pipeline not found", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + (config.pipelineManager.getPipeline as ReturnType).mockReturnValue(null); + + execute(sourceAttachment, matrix, 10, 10, 1, 1, config); + + expect(console.error).toHaveBeenCalled(); + }); + + it("should log error when bind group layout not found", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + (config.pipelineManager.getBindGroupLayout as ReturnType).mockReturnValue(null); + + execute(sourceAttachment, matrix, 10, 10, 1, 1, config); + + expect(console.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/BlurFilter/FilterApplyBlurFilterUseCase.ts b/packages/webgpu/src/Filter/BlurFilter/FilterApplyBlurFilterUseCase.ts new file mode 100644 index 00000000..84f7b231 --- /dev/null +++ b/packages/webgpu/src/Filter/BlurFilter/FilterApplyBlurFilterUseCase.ts @@ -0,0 +1,375 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { $offset } from "../FilterOffset"; +import { calculateBlurParams, calculateDirectionalBlurParams } from "../BlurFilterUseCase"; +import { shouldUseComputeShader } from "./service/BlurFilterComputeShaderService"; +import { execute as executeBlurCompute } from "../../Compute/service/ComputeExecuteBlurService"; + +/** + * @description プリアロケートされたFloat32Array (サイズ4) + */ +const $uniform4 = new Float32Array(4); + +/** + * @description プリアロケートされたBindGroupEntry配列 (バインディング3つ) + */ +const $entries3: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView } +]; + +/** + * @description ブラーフィルターを適用 + * Apply blur filter + * + * @param {IAttachmentObject} sourceAttachment - 入力テクスチャ(アタッチメント) + * @param {Float32Array} matrix - 変換行列 + * @param {number} blurX - X方向のブラー量 + * @param {number} blurY - Y方向のブラー量 + * @param {number} quality - クオリティ (1-15) + * @param {number} devicePixelRatio - デバイスピクセル比 + * @param {IFilterConfig} config - WebGPUリソース設定 + * @return {IAttachmentObject} - フィルター適用後のアタッチメント + */ +export const execute = ( + sourceAttachment: IAttachmentObject, + matrix: Float32Array, + blurX: number, + blurY: number, + quality: number, + devicePixelRatio: number, + config: IFilterConfig +): IAttachmentObject => { + + const { device, commandEncoder, frameBufferManager, pipelineManager, textureManager } = config; + + // ブラーパラメータを計算 + const blurParams = calculateBlurParams(matrix, blurX, blurY, quality, devicePixelRatio); + const { baseBlurX, baseBlurY, offsetX, offsetY, bufferScaleX, bufferScaleY } = blurParams; + + // オフセットを更新 + $offset.x += offsetX; + $offset.y += offsetY; + + // ブラー用バッファサイズを計算 + const width = sourceAttachment.width + offsetX * 2; + const height = sourceAttachment.height + offsetY * 2; + const bufferWidth = Math.ceil(width * bufferScaleX); + const bufferHeight = Math.ceil(height * bufferScaleY); + + // ピンポンバッファ用の一時アタッチメントを作成 + const attachment0 = frameBufferManager.createTemporaryAttachment(bufferWidth, bufferHeight); + const attachment1 = frameBufferManager.createTemporaryAttachment(bufferWidth, bufferHeight); + + // サンプラーを作成(線形補間) + const sampler = textureManager.createSampler("blur_sampler", true); + + // ソーステクスチャをattachment0にコピー(スケーリング付き) + copyTextureToAttachment( + device, commandEncoder, frameBufferManager, pipelineManager, + sourceAttachment, attachment0, sampler, + bufferScaleX, bufferScaleY, + offsetX * bufferScaleX, offsetY * bufferScaleY, + config.bufferManager + ); + + // バッファスケールを考慮したブラー値 + const bufferBlurX = baseBlurX * bufferScaleX; + const bufferBlurY = baseBlurY * bufferScaleY; + + // Compute Shaderを使用すべきか判定 + const useCompute = config.computePipelineManager + && shouldUseComputeShader(baseBlurX, baseBlurY, bufferWidth, bufferHeight); + + // ブラーパスを実行 + const attachments = [attachment0, attachment1]; + let attachmentIndex = 0; + + for (let q = 0; q < quality; ++q) { + // 水平ブラー + if (blurX > 0) { + const srcIndex = attachmentIndex; + attachmentIndex = (attachmentIndex + 1) % 2; + + if (useCompute) { + executeBlurCompute( + device, commandEncoder, config.computePipelineManager!, + attachments[srcIndex], attachments[attachmentIndex], + true, bufferBlurX, config.bufferManager + ); + } else { + applyDirectionalBlur( + device, commandEncoder, frameBufferManager, pipelineManager, + attachments[srcIndex], attachments[attachmentIndex], sampler, + true, bufferBlurX, config.bufferManager + ); + } + } + + // 垂直ブラー + if (blurY > 0) { + const srcIndex = attachmentIndex; + attachmentIndex = (attachmentIndex + 1) % 2; + + if (useCompute) { + executeBlurCompute( + device, commandEncoder, config.computePipelineManager!, + attachments[srcIndex], attachments[attachmentIndex], + false, bufferBlurY, config.bufferManager + ); + } else { + applyDirectionalBlur( + device, commandEncoder, frameBufferManager, pipelineManager, + attachments[srcIndex], attachments[attachmentIndex], sampler, + false, bufferBlurY, config.bufferManager + ); + } + } + } + + // 結果のアタッチメント + let resultAttachment = attachments[attachmentIndex]; + + // バッファスケールが1でない場合は元のサイズにアップスケール + if (bufferScaleX !== 1 || bufferScaleY !== 1) { + const finalAttachment = frameBufferManager.createTemporaryAttachment(width, height); + + upscaleTexture( + device, commandEncoder, frameBufferManager, pipelineManager, + resultAttachment, finalAttachment, sampler, + 1 / bufferScaleX, 1 / bufferScaleY, + config.bufferManager + ); + + // ピンポンバッファを解放 + frameBufferManager.releaseTemporaryAttachment(attachment0); + frameBufferManager.releaseTemporaryAttachment(attachment1); + + resultAttachment = finalAttachment; + } else { + // 使わなかったバッファを解放 + const unusedIndex = (attachmentIndex + 1) % 2; + frameBufferManager.releaseTemporaryAttachment(attachments[unusedIndex]); + } + + return resultAttachment; +}; + +/** + * @description テクスチャをアタッチメントにコピー(オフセット位置に配置、スケーリング対応) + * + * @param source - ソーステクスチャ + * @param dest - デストテクスチャ(ソースより大きい) + * @param bufferScaleX - X方向のバッファスケール + * @param bufferScaleY - Y方向のバッファスケール + * @param pixelOffsetX - デスト内でのX方向オフセット(ピクセル単位、スケーリング済み) + * @param pixelOffsetY - デスト内でのY方向オフセット(ピクセル単位、スケーリング済み) + */ +const copyTextureToAttachment = ( + device: GPUDevice, + commandEncoder: GPUCommandEncoder, + frameBufferManager: IFilterConfig["frameBufferManager"], + pipelineManager: IFilterConfig["pipelineManager"], + source: IAttachmentObject, + dest: IAttachmentObject, + sampler: GPUSampler, + bufferScaleX: number, + bufferScaleY: number, + pixelOffsetX: number, + pixelOffsetY: number, + bufferManager?: IFilterConfig["bufferManager"] +): void => { + // texture_copy_rgba8を使用し、ビューポートでオフセットを制御 + const pipeline = pipelineManager.getPipeline("texture_copy_rgba8"); + const bindGroupLayout = pipelineManager.getBindGroupLayout("texture_copy"); + + if (!pipeline || !bindGroupLayout) { + console.error("[WebGPU BlurFilter] texture_copy_rgba8 pipeline not found"); + return; + } + + // デスト内でのソース描画サイズ(スケーリング後) + const scaledSourceWidth = source.width * bufferScaleX; + const scaledSourceHeight = source.height * bufferScaleY; + + // シェーダー: uv = texCoord * scale + offset + // ソース全体をサンプリングするので scale = 1, offset = 0 + const scaleX = 1; + const scaleY = 1; + const offsetX = 0; + const offsetY = 0; + + // ユニフォームバッファ: scale(2) + offset(2) + $uniform4[0] = scaleX; + $uniform4[1] = scaleY; + $uniform4[2] = offsetX; + $uniform4[3] = offsetY; + const uniformBuffer = bufferManager + ? bufferManager.acquireAndWriteUniformBuffer($uniform4) + : device.createBuffer({ + "size": $uniform4.byteLength, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + if (!bufferManager) { + device.queue.writeBuffer(uniformBuffer, 0, $uniform4); + } + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = source.texture!.view; + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + dest.texture!.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + + // ビューポートを設定してオフセット位置に描画 + passEncoder.setViewport( + pixelOffsetX, pixelOffsetY, + scaledSourceWidth, scaledSourceHeight, + 0, 1 + ); + passEncoder.setScissorRect( + Math.floor(pixelOffsetX), Math.floor(pixelOffsetY), + Math.ceil(scaledSourceWidth), Math.ceil(scaledSourceHeight) + ); + + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + // Note: uniformBuffer is not destroyed here - it will be garbage collected after GPU submission +}; + +/** + * @description 方向ブラーを適用 + */ +const applyDirectionalBlur = ( + device: GPUDevice, + commandEncoder: GPUCommandEncoder, + frameBufferManager: IFilterConfig["frameBufferManager"], + pipelineManager: IFilterConfig["pipelineManager"], + source: IAttachmentObject, + dest: IAttachmentObject, + sampler: GPUSampler, + isHorizontal: boolean, + blur: number, + bufferManager?: IFilterConfig["bufferManager"] +): void => { + const params = calculateDirectionalBlurParams( + isHorizontal, blur, + source.width, source.height + ); + + const { offsetX, offsetY, fraction, samples, halfBlur } = params; + + // halfBlurに対応するパイプラインを取得(1〜16の範囲でクランプ) + const clampedHalfBlur = Math.max(1, Math.min(16, halfBlur)); + const pipeline = pipelineManager.getPipeline(`blur_filter_${clampedHalfBlur}`); + const bindGroupLayout = pipelineManager.getBindGroupLayout("blur_filter"); + + if (!pipeline || !bindGroupLayout) { + console.error(`[WebGPU BlurFilter] blur_filter_${clampedHalfBlur} pipeline not found`); + return; + } + + // ユニフォームバッファ: offset(2) + fraction + samples + $uniform4[0] = offsetX; + $uniform4[1] = offsetY; + $uniform4[2] = fraction; + $uniform4[3] = samples; + const uniformBuffer = bufferManager + ? bufferManager.acquireAndWriteUniformBuffer($uniform4) + : device.createBuffer({ + "size": $uniform4.byteLength, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + if (!bufferManager) { + device.queue.writeBuffer(uniformBuffer, 0, $uniform4); + } + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = source.texture!.view; + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + dest.texture!.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + // Note: uniformBuffer is not destroyed here - it will be garbage collected after GPU submission +}; + +/** + * @description テクスチャをアップスケール(ソース全体をデスト全体にマッピング) + */ +const upscaleTexture = ( + device: GPUDevice, + commandEncoder: GPUCommandEncoder, + frameBufferManager: IFilterConfig["frameBufferManager"], + pipelineManager: IFilterConfig["pipelineManager"], + source: IAttachmentObject, + dest: IAttachmentObject, + sampler: GPUSampler, + _scaleX: number, + _scaleY: number, + bufferManager?: IFilterConfig["bufferManager"] +): void => { + // temp_アタッチメントはrgba8unormフォーマットなので、texture_copy_rgba8パイプラインを使用 + const pipeline = pipelineManager.getPipeline("texture_copy_rgba8"); + const bindGroupLayout = pipelineManager.getBindGroupLayout("texture_copy"); + + if (!pipeline || !bindGroupLayout) { + console.error("[WebGPU BlurFilter] texture_copy_rgba8 pipeline not found"); + return; + } + + // アップスケールではソース全体をデスト全体にマッピング + // シェーダー: uv = (texCoord - offset) * scale + // scale = 1, offset = 0 で uv = texCoord となり、ソース全体がデスト全体にマッピングされる + $uniform4[0] = 1; + $uniform4[1] = 1; + $uniform4[2] = 0; + $uniform4[3] = 0; + const uniformBuffer = bufferManager + ? bufferManager.acquireAndWriteUniformBuffer($uniform4) + : device.createBuffer({ + "size": $uniform4.byteLength, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + if (!bufferManager) { + device.queue.writeBuffer(uniformBuffer, 0, $uniform4); + } + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = source.texture!.view; + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + dest.texture!.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); +}; diff --git a/packages/webgpu/src/Filter/BlurFilter/service/BlurFilterComputeShaderService.test.ts b/packages/webgpu/src/Filter/BlurFilter/service/BlurFilterComputeShaderService.test.ts new file mode 100644 index 00000000..073925d7 --- /dev/null +++ b/packages/webgpu/src/Filter/BlurFilter/service/BlurFilterComputeShaderService.test.ts @@ -0,0 +1,289 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../../interface/IFilterConfig"; +import type { ComputePipelineManager } from "../../../Compute/ComputePipelineManager"; +import { execute, shouldUseComputeShader } from "./BlurFilterComputeShaderService"; + +// Mock the compute blur service +vi.mock("../../../Compute/service/ComputeExecuteBlurService", () => ({ + "execute": vi.fn() +})); + +import { execute as mockExecuteBlurCompute } from "../../../Compute/service/ComputeExecuteBlurService"; + +describe("BlurFilterComputeShaderService", () => +{ + const createMockAttachment = (width: number = 256, height: number = 256): IAttachmentObject => + { + return { + "id": 1, + "width": width, + "height": height, + "clipLevel": 0, + "msaa": false, + "mask": false, + "color": null, + "texture": { + "id": 1, + "width": width, + "height": height, + "area": width * height, + "smooth": true, + "resource": {} as GPUTexture, + "view": {} as GPUTextureView + }, + "stencil": null, + "msaaTexture": null, + "msaaStencil": null + }; + }; + + const createMockDevice = () => + { + return {} as GPUDevice; + }; + + const createMockCommandEncoder = () => + { + return {} as GPUCommandEncoder; + }; + + const createMockComputePipelineManager = () => + { + return {} as ComputePipelineManager; + }; + + const createMockConfig = () => + { + return {} as IFilterConfig; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + }); + + describe("execute", () => + { + it("should call executeBlurCompute with correct parameters", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const computePipelineManager = createMockComputePipelineManager(); + const config = createMockConfig(); + const source = createMockAttachment(); + const dest = createMockAttachment(); + + execute(device, commandEncoder, computePipelineManager, config, source, dest, true, 16); + + expect(mockExecuteBlurCompute).toHaveBeenCalledWith( + device, + commandEncoder, + computePipelineManager, + source, + dest, + true, + 8 // radius = ceil(16 / 2) = 8 + ); + }); + + it("should calculate radius as ceil of blur / 2", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const computePipelineManager = createMockComputePipelineManager(); + const config = createMockConfig(); + const source = createMockAttachment(); + const dest = createMockAttachment(); + + execute(device, commandEncoder, computePipelineManager, config, source, dest, false, 15); + + expect(mockExecuteBlurCompute).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + false, + 8 // radius = ceil(15 / 2) = 8 + ); + }); + + it("should pass isHorizontal = true for horizontal blur", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const computePipelineManager = createMockComputePipelineManager(); + const config = createMockConfig(); + const source = createMockAttachment(); + const dest = createMockAttachment(); + + execute(device, commandEncoder, computePipelineManager, config, source, dest, true, 10); + + expect(mockExecuteBlurCompute).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + true, + expect.any(Number) + ); + }); + + it("should pass isHorizontal = false for vertical blur", () => + { + const device = createMockDevice(); + const commandEncoder = createMockCommandEncoder(); + const computePipelineManager = createMockComputePipelineManager(); + const config = createMockConfig(); + const source = createMockAttachment(); + const dest = createMockAttachment(); + + execute(device, commandEncoder, computePipelineManager, config, source, dest, false, 10); + + expect(mockExecuteBlurCompute).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + false, + expect.any(Number) + ); + }); + }); + + describe("shouldUseComputeShader", () => + { + describe("blur threshold", () => + { + it("should return true when blur >= 4 and size >= 128", () => + { + const result = shouldUseComputeShader(4, 4, 128, 128); + + expect(result).toBe(true); + }); + + it("should return true when blurX >= 4 (using max)", () => + { + const result = shouldUseComputeShader(5, 2, 128, 128); + + expect(result).toBe(true); + }); + + it("should return true when blurY >= 4 (using max)", () => + { + const result = shouldUseComputeShader(2, 5, 128, 128); + + expect(result).toBe(true); + }); + + it("should return false when both blurs < 4", () => + { + const result = shouldUseComputeShader(3, 3, 128, 128); + + expect(result).toBe(false); + }); + + it("should return false when max blur < 4", () => + { + const result = shouldUseComputeShader(2, 3, 512, 512); + + expect(result).toBe(false); + }); + }); + + describe("size threshold", () => + { + it("should return true when min size >= 128", () => + { + const result = shouldUseComputeShader(10, 10, 128, 128); + + expect(result).toBe(true); + }); + + it("should return true when width >= 128 and height > 128", () => + { + const result = shouldUseComputeShader(10, 10, 128, 512); + + expect(result).toBe(true); + }); + + it("should return true when height >= 128 and width > 128", () => + { + const result = shouldUseComputeShader(10, 10, 512, 128); + + expect(result).toBe(true); + }); + + it("should return false when width < 128", () => + { + const result = shouldUseComputeShader(10, 10, 100, 512); + + expect(result).toBe(false); + }); + + it("should return false when height < 128", () => + { + const result = shouldUseComputeShader(10, 10, 512, 100); + + expect(result).toBe(false); + }); + + it("should return false when both dimensions < 128", () => + { + const result = shouldUseComputeShader(20, 20, 100, 100); + + expect(result).toBe(false); + }); + }); + + describe("edge cases", () => + { + it("should return true at exact thresholds", () => + { + const result = shouldUseComputeShader(4, 0, 128, 128); + + expect(result).toBe(true); + }); + + it("should return false just below blur threshold", () => + { + const result = shouldUseComputeShader(3.9, 3.9, 128, 128); + + expect(result).toBe(false); + }); + + it("should return false just below size threshold", () => + { + const result = shouldUseComputeShader(10, 10, 127, 127); + + expect(result).toBe(false); + }); + + it("should return false when only one condition is met", () => + { + // Large blur but small size + expect(shouldUseComputeShader(20, 20, 100, 100)).toBe(false); + + // Large size but small blur + expect(shouldUseComputeShader(3, 3, 1024, 1024)).toBe(false); + }); + + it("should handle zero blur values", () => + { + const result = shouldUseComputeShader(0, 0, 512, 512); + + expect(result).toBe(false); + }); + + it("should handle large values", () => + { + const result = shouldUseComputeShader(100, 100, 4096, 4096); + + expect(result).toBe(true); + }); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/BlurFilter/service/BlurFilterComputeShaderService.ts b/packages/webgpu/src/Filter/BlurFilter/service/BlurFilterComputeShaderService.ts new file mode 100644 index 00000000..7cfbace5 --- /dev/null +++ b/packages/webgpu/src/Filter/BlurFilter/service/BlurFilterComputeShaderService.ts @@ -0,0 +1,84 @@ +import type { IAttachmentObject } from "../../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../../interface/IFilterConfig"; +import type { ComputePipelineManager } from "../../../Compute/ComputePipelineManager"; +import { execute as executeBlurCompute } from "../../../Compute/service/ComputeExecuteBlurService"; + +/** + * @description Compute Shaderでブラーパスを実行 + * Apply blur pass using Compute Shader + * + * Fragment Shaderベースの従来実装と比較して: + * - 並列処理による高速化(大きな半径で20-35%) + * - 共有メモリを活用したメモリアクセス最適化 + * - ワークグループ内でのデータ共有 + * + * @param {GPUDevice} device - WebGPU device + * @param {GPUCommandEncoder} commandEncoder - コマンドエンコーダー + * @param {ComputePipelineManager} computePipelineManager - Compute Pipeline Manager + * @param {IFilterConfig} config - フィルター設定 + * @param {IAttachmentObject} source - 入力アタッチメント + * @param {IAttachmentObject} dest - 出力アタッチメント + * @param {boolean} isHorizontal - 水平ブラーかどうか + * @param {number} blur - ブラー量 + * @return {void} + */ +export const execute = ( + device: GPUDevice, + commandEncoder: GPUCommandEncoder, + computePipelineManager: ComputePipelineManager, + _config: IFilterConfig, + source: IAttachmentObject, + dest: IAttachmentObject, + isHorizontal: boolean, + blur: number +): void => { + + // ブラー半径を計算(ブラー量の半分) + const radius = Math.ceil(blur / 2); + + // Compute Shaderでブラーを実行 + executeBlurCompute( + device, + commandEncoder, + computePipelineManager, + source, + dest, + isHorizontal, + radius + ); +}; + +/** + * @description Compute Shaderを使用すべきかどうか判定 + * Determine whether to use Compute Shader + * + * 以下の条件でCompute Shaderを使用: + * - ブラー半径が大きい(8以上) + * - テクスチャサイズが十分大きい(256x256以上) + * + * 小さなブラー半径では Fragment Shader の方が効率的な場合がある。 + * + * @param {number} blurX - X方向のブラー量 + * @param {number} blurY - Y方向のブラー量 + * @param {number} width - テクスチャ幅 + * @param {number} height - テクスチャ高さ + * @return {boolean} Compute Shaderを使用すべきかどうか + */ +export const shouldUseComputeShader = ( + blurX: number, + blurY: number, + width: number, + height: number +): boolean => { + + // ブラー半径のしきい値 + const BLUR_THRESHOLD = 4; + + // テクスチャサイズのしきい値 + const SIZE_THRESHOLD = 128; + + const maxBlur = Math.max(blurX, blurY); + const minSize = Math.min(width, height); + + return maxBlur >= BLUR_THRESHOLD && minSize >= SIZE_THRESHOLD; +}; diff --git a/packages/webgpu/src/Filter/BlurFilter/usecase/FilterApplyBlurComputeUseCase.test.ts b/packages/webgpu/src/Filter/BlurFilter/usecase/FilterApplyBlurComputeUseCase.test.ts new file mode 100644 index 00000000..9d8d9053 --- /dev/null +++ b/packages/webgpu/src/Filter/BlurFilter/usecase/FilterApplyBlurComputeUseCase.test.ts @@ -0,0 +1,437 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../../interface/IFilterConfig"; +import type { ComputePipelineManager } from "../../../Compute/ComputePipelineManager"; +import { execute } from "./FilterApplyBlurComputeUseCase"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + UNIFORM: 0x40, + COPY_DST: 0x08 +}; +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +// Mock offset - use object that will be imported +vi.mock("../../FilterOffset", () => ({ + "$offset": { "x": 0, "y": 0 } +})); + +import { $offset } from "../../FilterOffset"; + +// Mock calculateBlurParams +const mockCalculateBlurParams = vi.fn(); +vi.mock("../../BlurFilterUseCase", () => ({ + "calculateBlurParams": (...args: any[]) => mockCalculateBlurParams(...args) +})); + +// Mock BlurFilterComputeShaderService +const mockBlurComputeService = vi.fn(); +const mockShouldUseComputeShader = vi.fn(); +vi.mock("../service/BlurFilterComputeShaderService", () => ({ + "execute": (...args: any[]) => mockBlurComputeService(...args), + "shouldUseComputeShader": (...args: any[]) => mockShouldUseComputeShader(...args) +})); + +// Mock FilterApplyBlurFilterUseCase (fragment fallback) +const mockExecuteFragmentBlur = vi.fn(); +vi.mock("../FilterApplyBlurFilterUseCase", () => ({ + "execute": (...args: any[]) => mockExecuteFragmentBlur(...args) +})); + +describe("FilterApplyBlurComputeUseCase", () => +{ + const createMockAttachment = (width: number = 100, height: number = 100): IAttachmentObject => + { + return { + "id": 1, + "width": width, + "height": height, + "clipLevel": 0, + "texture": { + "resource": { "label": "mockTexture" } as unknown as GPUTexture, + "view": { "label": "mockTextureView" } as unknown as GPUTextureView + } + } as IAttachmentObject; + }; + + const createMockConfig = (): IFilterConfig => + { + const mockPassEncoder = { + "setPipeline": vi.fn(), + "setBindGroup": vi.fn(), + "setViewport": vi.fn(), + "setScissorRect": vi.fn(), + "draw": vi.fn(), + "end": vi.fn() + }; + + return { + "device": { + "createBuffer": vi.fn(() => ({ "label": "mockBuffer" })), + "queue": { "writeBuffer": vi.fn() }, + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })) + } as unknown as GPUDevice, + "commandEncoder": { + "beginRenderPass": vi.fn(() => mockPassEncoder) + } as unknown as GPUCommandEncoder, + "frameBufferManager": { + "createTemporaryAttachment": vi.fn((w: number, h: number) => createMockAttachment(w, h)), + "releaseTemporaryAttachment": vi.fn(), + "createRenderPassDescriptor": vi.fn(() => ({ + "colorAttachments": [{ "view": {}, "loadOp": "clear", "storeOp": "store" }] + })) + }, + "pipelineManager": { + "getPipeline": vi.fn(() => ({ "label": "mockPipeline" })), + "getBindGroupLayout": vi.fn(() => ({ "label": "mockLayout" })) + }, + "textureManager": { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })) + } + } as unknown as IFilterConfig; + }; + + const createMockComputePipelineManager = (): ComputePipelineManager => + { + return { + "getPipeline": vi.fn(() => ({ "label": "mockComputePipeline" })), + "getBindGroupLayout": vi.fn(() => ({ "label": "mockComputeLayout" })) + } as unknown as ComputePipelineManager; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + $offset.x = 0; + $offset.y = 0; + vi.spyOn(console, "error").mockImplementation(() => {}); + + // Default mock implementations + mockCalculateBlurParams.mockReturnValue({ + "baseBlurX": 16, + "baseBlurY": 16, + "offsetX": 32, + "offsetY": 32, + "bufferScaleX": 1, + "bufferScaleY": 1 + }); + }); + + describe("compute shader decision", () => + { + it("should use fragment shader when compute is not appropriate", () => + { + mockShouldUseComputeShader.mockReturnValue(false); + const expectedResult = createMockAttachment(200, 200); + mockExecuteFragmentBlur.mockReturnValue(expectedResult); + + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const config = createMockConfig(); + const computePipelineManager = createMockComputePipelineManager(); + + const result = execute( + sourceAttachment, + matrix, + 4, // small blurX + 4, // small blurY + 1, + 1, + config, + computePipelineManager + ); + + expect(mockShouldUseComputeShader).toHaveBeenCalled(); + expect(mockExecuteFragmentBlur).toHaveBeenCalled(); + expect(result).toBe(expectedResult); + }); + + it("should use compute shader when appropriate", () => + { + mockShouldUseComputeShader.mockReturnValue(true); + mockCalculateBlurParams.mockReturnValue({ + "baseBlurX": 32, + "baseBlurY": 32, + "offsetX": 64, + "offsetY": 64, + "bufferScaleX": 1, + "bufferScaleY": 1 + }); + + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const config = createMockConfig(); + const computePipelineManager = createMockComputePipelineManager(); + + execute( + sourceAttachment, + matrix, + 32, + 32, + 1, + 1, + config, + computePipelineManager + ); + + expect(mockShouldUseComputeShader).toHaveBeenCalled(); + expect(mockExecuteFragmentBlur).not.toHaveBeenCalled(); + expect(mockBlurComputeService).toHaveBeenCalled(); + }); + }); + + describe("blur parameters", () => + { + it("should calculate blur parameters correctly", () => + { + mockShouldUseComputeShader.mockReturnValue(true); + + const sourceAttachment = createMockAttachment(100, 100); + const matrix = new Float32Array([2, 0, 0, 0, 2, 0, 0, 0, 1]); + const config = createMockConfig(); + const computePipelineManager = createMockComputePipelineManager(); + + execute( + sourceAttachment, + matrix, + 16, + 16, + 3, + 2, + config, + computePipelineManager + ); + + expect(mockCalculateBlurParams).toHaveBeenCalledWith( + matrix, + 16, + 16, + 3, + 2 + ); + }); + + it("should update offset based on blur parameters", () => + { + mockShouldUseComputeShader.mockReturnValue(true); + mockCalculateBlurParams.mockReturnValue({ + "baseBlurX": 16, + "baseBlurY": 16, + "offsetX": 20, + "offsetY": 25, + "bufferScaleX": 1, + "bufferScaleY": 1 + }); + + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const config = createMockConfig(); + const computePipelineManager = createMockComputePipelineManager(); + + execute( + sourceAttachment, + matrix, + 16, + 16, + 1, + 1, + config, + computePipelineManager + ); + + expect($offset.x).toBe(20); + expect($offset.y).toBe(25); + }); + }); + + describe("multi-pass blur", () => + { + it("should perform horizontal and vertical passes for each quality level", () => + { + mockShouldUseComputeShader.mockReturnValue(true); + + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const config = createMockConfig(); + const computePipelineManager = createMockComputePipelineManager(); + + execute( + sourceAttachment, + matrix, + 16, + 16, + 3, // quality = 3 + 1, + config, + computePipelineManager + ); + + // 3 quality passes * 2 directions (horizontal + vertical) = 6 calls + expect(mockBlurComputeService).toHaveBeenCalledTimes(6); + }); + + it("should skip horizontal pass when blurX is 0", () => + { + mockShouldUseComputeShader.mockReturnValue(true); + mockCalculateBlurParams.mockReturnValue({ + "baseBlurX": 0, + "baseBlurY": 16, + "offsetX": 0, + "offsetY": 32, + "bufferScaleX": 1, + "bufferScaleY": 1 + }); + + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const config = createMockConfig(); + const computePipelineManager = createMockComputePipelineManager(); + + execute( + sourceAttachment, + matrix, + 0, + 16, + 1, + 1, + config, + computePipelineManager + ); + + // Only vertical passes + expect(mockBlurComputeService).toHaveBeenCalledTimes(1); + // Should be called with horizontal=false + expect(mockBlurComputeService).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + false, // horizontal = false (vertical pass) + expect.any(Number) + ); + }); + + it("should skip vertical pass when blurY is 0", () => + { + mockShouldUseComputeShader.mockReturnValue(true); + mockCalculateBlurParams.mockReturnValue({ + "baseBlurX": 16, + "baseBlurY": 0, + "offsetX": 32, + "offsetY": 0, + "bufferScaleX": 1, + "bufferScaleY": 1 + }); + + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const config = createMockConfig(); + const computePipelineManager = createMockComputePipelineManager(); + + execute( + sourceAttachment, + matrix, + 16, + 0, + 1, + 1, + config, + computePipelineManager + ); + + // Only horizontal passes + expect(mockBlurComputeService).toHaveBeenCalledTimes(1); + // Should be called with horizontal=true + expect(mockBlurComputeService).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + true, // horizontal = true + expect.any(Number) + ); + }); + }); + + describe("buffer management", () => + { + it("should create temporary attachments for ping-pong buffer", () => + { + mockShouldUseComputeShader.mockReturnValue(true); + + const sourceAttachment = createMockAttachment(100, 100); + const matrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const config = createMockConfig(); + const computePipelineManager = createMockComputePipelineManager(); + + execute( + sourceAttachment, + matrix, + 16, + 16, + 1, + 1, + config, + computePipelineManager + ); + + // Should create 2 temporary attachments for ping-pong + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalledTimes(2); + }); + + it("should release unused buffer after processing", () => + { + mockShouldUseComputeShader.mockReturnValue(true); + + const sourceAttachment = createMockAttachment(100, 100); + const matrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const config = createMockConfig(); + const computePipelineManager = createMockComputePipelineManager(); + + execute( + sourceAttachment, + matrix, + 16, + 16, + 1, + 1, + config, + computePipelineManager + ); + + // Should release the unused ping-pong buffer + expect(config.frameBufferManager.releaseTemporaryAttachment).toHaveBeenCalled(); + }); + }); + + describe("return value", () => + { + it("should return result attachment after compute blur", () => + { + mockShouldUseComputeShader.mockReturnValue(true); + + const sourceAttachment = createMockAttachment(100, 100); + const matrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + const config = createMockConfig(); + const computePipelineManager = createMockComputePipelineManager(); + + const result = execute( + sourceAttachment, + matrix, + 16, + 16, + 1, + 1, + config, + computePipelineManager + ); + + expect(result).toBeDefined(); + expect(result.texture).toBeDefined(); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/BlurFilter/usecase/FilterApplyBlurComputeUseCase.ts b/packages/webgpu/src/Filter/BlurFilter/usecase/FilterApplyBlurComputeUseCase.ts new file mode 100644 index 00000000..9639a3a8 --- /dev/null +++ b/packages/webgpu/src/Filter/BlurFilter/usecase/FilterApplyBlurComputeUseCase.ts @@ -0,0 +1,292 @@ +import type { IAttachmentObject } from "../../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../../interface/IFilterConfig"; +import type { ComputePipelineManager } from "../../../Compute/ComputePipelineManager"; +import { $offset } from "../../FilterOffset"; +import { calculateBlurParams } from "../../BlurFilterUseCase"; +import { + execute as blurComputeService, + shouldUseComputeShader +} from "../service/BlurFilterComputeShaderService"; +import { execute as executeFragmentBlur } from "../FilterApplyBlurFilterUseCase"; + +/** + * @description プリアロケートされたFloat32Array (サイズ4) + */ +const $uniform4 = new Float32Array(4); + +/** + * @description プリアロケートされたBindGroupEntry配列 (バインディング3つ) + */ +const $entries3: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView } +]; + +/** + * @description Compute Shaderを使用したブラーフィルター + * Apply blur filter using Compute Shader + * + * Fragment Shaderベースの従来実装と比較して: + * - 大きなブラー半径で20-35%高速化 + * - 並列処理による効率的なテクスチャサンプリング + * - 共有メモリを活用したメモリアクセス最適化 + * + * 小さなブラー半径(8未満)では従来のFragment Shaderを使用。 + * + * @param {IAttachmentObject} sourceAttachment - 入力テクスチャ + * @param {Float32Array} matrix - 変換行列 + * @param {number} blurX - X方向のブラー量 + * @param {number} blurY - Y方向のブラー量 + * @param {number} quality - クオリティ (1-15) + * @param {number} devicePixelRatio - デバイスピクセル比 + * @param {IFilterConfig} config - WebGPUリソース設定 + * @param {ComputePipelineManager} computePipelineManager - Compute Pipeline Manager + * @return {IAttachmentObject} - フィルター適用後のアタッチメント + */ +export const execute = ( + sourceAttachment: IAttachmentObject, + matrix: Float32Array, + blurX: number, + blurY: number, + quality: number, + devicePixelRatio: number, + config: IFilterConfig, + computePipelineManager: ComputePipelineManager +): IAttachmentObject => { + + const { device, commandEncoder, frameBufferManager, pipelineManager, textureManager } = config; + + // ブラーパラメータを計算 + const blurParams = calculateBlurParams(matrix, blurX, blurY, quality, devicePixelRatio); + const { baseBlurX, baseBlurY, offsetX, offsetY, bufferScaleX, bufferScaleY } = blurParams; + + // オフセットを更新 + $offset.x += offsetX; + $offset.y += offsetY; + + // ブラー用バッファサイズを計算 + const width = sourceAttachment.width + offsetX * 2; + const height = sourceAttachment.height + offsetY * 2; + const bufferWidth = Math.ceil(width * bufferScaleX); + const bufferHeight = Math.ceil(height * bufferScaleY); + + // Compute Shaderを使用すべきか判定 + const useCompute = shouldUseComputeShader(baseBlurX, baseBlurY, bufferWidth, bufferHeight); + + if (!useCompute) { + // 小さなブラーは従来のFragment Shaderを使用 + // FilterApplyBlurFilterUseCaseにフォールバック + return executeFragmentBlur( + sourceAttachment, + matrix, + blurX, + blurY, + quality, + devicePixelRatio, + config + ); + } + + // ピンポンバッファ用の一時アタッチメントを作成 + const attachment0 = frameBufferManager.createTemporaryAttachment(bufferWidth, bufferHeight); + const attachment1 = frameBufferManager.createTemporaryAttachment(bufferWidth, bufferHeight); + + // サンプラーを作成(線形補間) + const sampler = textureManager.createSampler("blur_compute_sampler", true); + + // ソーステクスチャをattachment0にコピー + copyTextureToAttachment( + device, commandEncoder, frameBufferManager, pipelineManager, + sourceAttachment, attachment0, sampler, + bufferScaleX, bufferScaleY, + offsetX * bufferScaleX, offsetY * bufferScaleY, + config.bufferManager + ); + + // バッファスケールを考慮したブラー値 + const bufferBlurX = baseBlurX * bufferScaleX; + const bufferBlurY = baseBlurY * bufferScaleY; + + // Compute Shaderでブラーパスを実行 + const attachments = [attachment0, attachment1]; + let attachmentIndex = 0; + + for (let q = 0; q < quality; ++q) { + // 水平ブラー + if (blurX > 0) { + const srcIndex = attachmentIndex; + attachmentIndex = (attachmentIndex + 1) % 2; + + blurComputeService( + device, commandEncoder, computePipelineManager, config, + attachments[srcIndex], attachments[attachmentIndex], + true, bufferBlurX + ); + } + + // 垂直ブラー + if (blurY > 0) { + const srcIndex = attachmentIndex; + attachmentIndex = (attachmentIndex + 1) % 2; + + blurComputeService( + device, commandEncoder, computePipelineManager, config, + attachments[srcIndex], attachments[attachmentIndex], + false, bufferBlurY + ); + } + } + + // 結果のアタッチメント + let resultAttachment = attachments[attachmentIndex]; + + // バッファスケールが1でない場合は元のサイズにアップスケール + if (bufferScaleX !== 1 || bufferScaleY !== 1) { + const finalAttachment = frameBufferManager.createTemporaryAttachment(width, height); + + upscaleTexture( + device, commandEncoder, frameBufferManager, pipelineManager, + resultAttachment, finalAttachment, sampler, + config.bufferManager + ); + + // ピンポンバッファを解放 + frameBufferManager.releaseTemporaryAttachment(attachment0); + frameBufferManager.releaseTemporaryAttachment(attachment1); + + resultAttachment = finalAttachment; + } else { + // 使わなかったバッファを解放 + const unusedIndex = (attachmentIndex + 1) % 2; + frameBufferManager.releaseTemporaryAttachment(attachments[unusedIndex]); + } + + return resultAttachment; +}; + +/** + * @description テクスチャをアタッチメントにコピー + */ +const copyTextureToAttachment = ( + device: GPUDevice, + commandEncoder: GPUCommandEncoder, + frameBufferManager: IFilterConfig["frameBufferManager"], + pipelineManager: IFilterConfig["pipelineManager"], + source: IAttachmentObject, + dest: IAttachmentObject, + sampler: GPUSampler, + bufferScaleX: number, + bufferScaleY: number, + pixelOffsetX: number, + pixelOffsetY: number, + bufferManager?: IFilterConfig["bufferManager"] +): void => { + const pipeline = pipelineManager.getPipeline("texture_copy_rgba8"); + const bindGroupLayout = pipelineManager.getBindGroupLayout("texture_copy"); + + if (!pipeline || !bindGroupLayout) { + console.error("[WebGPU BlurCompute] texture_copy_rgba8 pipeline not found"); + return; + } + + const scaledSourceWidth = source.width * bufferScaleX; + const scaledSourceHeight = source.height * bufferScaleY; + + $uniform4[0] = 1; + $uniform4[1] = 1; + $uniform4[2] = 0; + $uniform4[3] = 0; + const uniformBuffer = bufferManager + ? bufferManager.acquireAndWriteUniformBuffer($uniform4) + : device.createBuffer({ + "size": $uniform4.byteLength, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + if (!bufferManager) { + device.queue.writeBuffer(uniformBuffer, 0, $uniform4); + } + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = source.texture!.view; + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + dest.texture!.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + + passEncoder.setViewport( + pixelOffsetX, pixelOffsetY, + scaledSourceWidth, scaledSourceHeight, + 0, 1 + ); + passEncoder.setScissorRect( + Math.floor(pixelOffsetX), Math.floor(pixelOffsetY), + Math.ceil(scaledSourceWidth), Math.ceil(scaledSourceHeight) + ); + + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); +}; + +/** + * @description テクスチャをアップスケール + */ +const upscaleTexture = ( + device: GPUDevice, + commandEncoder: GPUCommandEncoder, + frameBufferManager: IFilterConfig["frameBufferManager"], + pipelineManager: IFilterConfig["pipelineManager"], + source: IAttachmentObject, + dest: IAttachmentObject, + sampler: GPUSampler, + bufferManager?: IFilterConfig["bufferManager"] +): void => { + const pipeline = pipelineManager.getPipeline("texture_copy_rgba8"); + const bindGroupLayout = pipelineManager.getBindGroupLayout("texture_copy"); + + if (!pipeline || !bindGroupLayout) { + console.error("[WebGPU BlurCompute] texture_copy_rgba8 pipeline not found"); + return; + } + + $uniform4[0] = 1; + $uniform4[1] = 1; + $uniform4[2] = 0; + $uniform4[3] = 0; + const uniformBuffer = bufferManager + ? bufferManager.acquireAndWriteUniformBuffer($uniform4) + : device.createBuffer({ + "size": $uniform4.byteLength, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + if (!bufferManager) { + device.queue.writeBuffer(uniformBuffer, 0, $uniform4); + } + + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = source.texture!.view; + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + dest.texture!.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); +}; diff --git a/packages/webgpu/src/Filter/BlurFilterShader.test.ts b/packages/webgpu/src/Filter/BlurFilterShader.test.ts new file mode 100644 index 00000000..09e1a83c --- /dev/null +++ b/packages/webgpu/src/Filter/BlurFilterShader.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from "vitest"; +import { BlurFilterShader } from "./BlurFilterShader"; + +describe("BlurFilterShader", () => +{ + describe("getVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = BlurFilterShader.getVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = BlurFilterShader.getVertexShader(); + + expect(shader).toContain("@vertex"); + }); + + it("should contain main function", () => + { + const shader = BlurFilterShader.getVertexShader(); + + expect(shader).toContain("fn main"); + }); + + it("should define VertexInput struct", () => + { + const shader = BlurFilterShader.getVertexShader(); + + expect(shader).toContain("struct VertexInput"); + }); + + it("should define VertexOutput struct", () => + { + const shader = BlurFilterShader.getVertexShader(); + + expect(shader).toContain("struct VertexOutput"); + }); + + it("should include position and texCoord attributes", () => + { + const shader = BlurFilterShader.getVertexShader(); + + expect(shader).toContain("@location(0) position"); + expect(shader).toContain("@location(1) texCoord"); + }); + }); + + describe("getHorizontalBlurShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = BlurFilterShader.getHorizontalBlurShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = BlurFilterShader.getHorizontalBlurShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should define BlurUniforms struct", () => + { + const shader = BlurFilterShader.getHorizontalBlurShader(); + + expect(shader).toContain("struct BlurUniforms"); + }); + + it("should include blur parameters", () => + { + const shader = BlurFilterShader.getHorizontalBlurShader(); + + expect(shader).toContain("blurSize"); + expect(shader).toContain("textureWidth"); + }); + + it("should use textureWidth for horizontal sampling", () => + { + const shader = BlurFilterShader.getHorizontalBlurShader(); + + expect(shader).toContain("1.0 / uniforms.textureWidth"); + }); + + it("should include texture sampling", () => + { + const shader = BlurFilterShader.getHorizontalBlurShader(); + + expect(shader).toContain("textureSample"); + }); + }); + + describe("getVerticalBlurShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = BlurFilterShader.getVerticalBlurShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = BlurFilterShader.getVerticalBlurShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should define BlurUniforms struct", () => + { + const shader = BlurFilterShader.getVerticalBlurShader(); + + expect(shader).toContain("struct BlurUniforms"); + }); + + it("should use textureHeight for vertical sampling", () => + { + const shader = BlurFilterShader.getVerticalBlurShader(); + + expect(shader).toContain("1.0 / uniforms.textureHeight"); + }); + + it("should include y-axis offset calculation", () => + { + const shader = BlurFilterShader.getVerticalBlurShader(); + + expect(shader).toContain("input.texCoord.y + offset"); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/BlurFilterShader.ts b/packages/webgpu/src/Filter/BlurFilterShader.ts new file mode 100644 index 00000000..c61ba3aa --- /dev/null +++ b/packages/webgpu/src/Filter/BlurFilterShader.ts @@ -0,0 +1,115 @@ +export class BlurFilterShader +{ + static getVertexShader(): string + { + return /* wgsl */` + struct VertexInput { + @location(0) position: vec2, + @location(1) texCoord: vec2, + } + + struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, + } + + @vertex + fn main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + output.position = vec4(input.position, 0.0, 1.0); + output.texCoord = input.texCoord; + return output; + } + `; + } + + static getHorizontalBlurShader(): string + { + return /* wgsl */` + struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, + } + + struct BlurUniforms { + blurSize: f32, + textureWidth: f32, + textureHeight: f32, + _padding: f32, + } + + @group(0) @binding(0) var uniforms: BlurUniforms; + @group(0) @binding(1) var textureSampler: sampler; + @group(0) @binding(2) var textureData: texture_2d; + + @fragment + fn main(input: VertexOutput) -> @location(0) vec4 { + let texelSize = 1.0 / uniforms.textureWidth; + var color = vec4(0.0); + let blurRadius = i32(uniforms.blurSize); + + var totalWeight = 0.0; + + for (var i = -blurRadius; i <= blurRadius; i++) { + let offset = f32(i) * texelSize; + let weight = 1.0 - abs(f32(i)) / f32(blurRadius + 1); + + let sampleCoord = vec2( + input.texCoord.x + offset, + input.texCoord.y + ); + + color += textureSample(textureData, textureSampler, sampleCoord) * weight; + totalWeight += weight; + } + + return color / totalWeight; + } + `; + } + + static getVerticalBlurShader(): string + { + return /* wgsl */` + struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, + } + + struct BlurUniforms { + blurSize: f32, + textureWidth: f32, + textureHeight: f32, + _padding: f32, + } + + @group(0) @binding(0) var uniforms: BlurUniforms; + @group(0) @binding(1) var textureSampler: sampler; + @group(0) @binding(2) var textureData: texture_2d; + + @fragment + fn main(input: VertexOutput) -> @location(0) vec4 { + let texelSize = 1.0 / uniforms.textureHeight; + var color = vec4(0.0); + let blurRadius = i32(uniforms.blurSize); + + var totalWeight = 0.0; + + for (var i = -blurRadius; i <= blurRadius; i++) { + let offset = f32(i) * texelSize; + let weight = 1.0 - abs(f32(i)) / f32(blurRadius + 1); + + let sampleCoord = vec2( + input.texCoord.x, + input.texCoord.y + offset + ); + + color += textureSample(textureData, textureSampler, sampleCoord) * weight; + totalWeight += weight; + } + + return color / totalWeight; + } + `; + } +} diff --git a/packages/webgpu/src/Filter/BlurFilterUseCase.test.ts b/packages/webgpu/src/Filter/BlurFilterUseCase.test.ts new file mode 100644 index 00000000..de0f0c69 --- /dev/null +++ b/packages/webgpu/src/Filter/BlurFilterUseCase.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from "vitest"; +import { calculateBlurParams, calculateDirectionalBlurParams } from "./BlurFilterUseCase"; + +describe("BlurFilterUseCase", () => +{ + describe("calculateBlurParams", () => + { + it("should calculate blur parameters with identity matrix", () => + { + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const result = calculateBlurParams(matrix, 10, 10, 1, 1); + + expect(result.baseBlurX).toBe(10); + expect(result.baseBlurY).toBe(10); + expect(result.bufferScaleX).toBe(1); + expect(result.bufferScaleY).toBe(1); + }); + + it("should apply matrix scale to blur values", () => + { + const matrix = new Float32Array([2, 0, 0, 2, 0, 0]); + const result = calculateBlurParams(matrix, 10, 10, 1, 1); + + expect(result.baseBlurX).toBe(20); + expect(result.baseBlurY).toBe(20); + }); + + it("should apply device pixel ratio", () => + { + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const result = calculateBlurParams(matrix, 10, 10, 1, 2); + + expect(result.baseBlurX).toBe(5); + expect(result.baseBlurY).toBe(5); + }); + + it("should calculate offset based on quality and blur", () => + { + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const result1 = calculateBlurParams(matrix, 10, 10, 1, 1); + const result2 = calculateBlurParams(matrix, 10, 10, 5, 1); + + // Higher quality should result in different offset + expect(result1.offsetX).not.toBe(result2.offsetX); + }); + + it("should use bufferScaleX 0.5 for blur > 16", () => + { + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const result = calculateBlurParams(matrix, 20, 20, 1, 1); + + expect(result.bufferScaleX).toBe(0.5); + expect(result.bufferScaleY).toBe(0.5); + }); + + it("should use bufferScaleX 0.25 for blur > 32", () => + { + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const result = calculateBlurParams(matrix, 40, 40, 1, 1); + + expect(result.bufferScaleX).toBe(0.25); + expect(result.bufferScaleY).toBe(0.25); + }); + + it("should use bufferScaleX 0.125 for blur > 64", () => + { + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const result = calculateBlurParams(matrix, 80, 80, 1, 1); + + expect(result.bufferScaleX).toBe(0.125); + expect(result.bufferScaleY).toBe(0.125); + }); + + it("should use bufferScaleX 0.0625 for blur > 128", () => + { + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const result = calculateBlurParams(matrix, 150, 150, 1, 1); + + expect(result.bufferScaleX).toBe(0.0625); + expect(result.bufferScaleY).toBe(0.0625); + }); + + it("should handle rotated matrix", () => + { + // 45 degree rotation matrix + const angle = Math.PI / 4; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const matrix = new Float32Array([cos, sin, -sin, cos, 0, 0]); + const result = calculateBlurParams(matrix, 10, 10, 1, 1); + + // Scale should be 1 (rotation doesn't change scale) + expect(result.baseBlurX).toBeCloseTo(10, 5); + expect(result.baseBlurY).toBeCloseTo(10, 5); + }); + + it("should handle asymmetric blur", () => + { + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const result = calculateBlurParams(matrix, 5, 40, 1, 1); + + expect(result.baseBlurX).toBe(5); + expect(result.baseBlurY).toBe(40); + expect(result.bufferScaleX).toBe(1); + expect(result.bufferScaleY).toBe(0.25); + }); + + it("should clamp quality to valid BLUR_STEP index", () => + { + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + + // Quality 1 (minimum) + const result1 = calculateBlurParams(matrix, 10, 10, 1, 1); + + // Quality 15 (maximum) + const result2 = calculateBlurParams(matrix, 10, 10, 15, 1); + + // Quality 20 (should clamp to 15) + const result3 = calculateBlurParams(matrix, 10, 10, 20, 1); + + expect(result2.offsetX).toBe(result3.offsetX); + expect(result1.offsetX).not.toBe(result2.offsetX); + }); + }); + + describe("calculateDirectionalBlurParams", () => + { + it("should calculate horizontal blur offset", () => + { + const result = calculateDirectionalBlurParams(true, 10, 100, 100); + + expect(result.offsetX).toBe(1 / 100); + expect(result.offsetY).toBe(0); + }); + + it("should calculate vertical blur offset", () => + { + const result = calculateDirectionalBlurParams(false, 10, 100, 100); + + expect(result.offsetX).toBe(0); + expect(result.offsetY).toBe(1 / 100); + }); + + it("should calculate halfBlur correctly", () => + { + const result1 = calculateDirectionalBlurParams(true, 10, 100, 100); + expect(result1.halfBlur).toBe(5); + + const result2 = calculateDirectionalBlurParams(true, 11, 100, 100); + expect(result2.halfBlur).toBe(6); // ceil(11 * 0.5) = ceil(5.5) = 6 + }); + + it("should calculate samples as 1 + blur", () => + { + const result = calculateDirectionalBlurParams(true, 10, 100, 100); + expect(result.samples).toBe(11); + }); + + it("should calculate fraction correctly", () => + { + const result1 = calculateDirectionalBlurParams(true, 10, 100, 100); + // halfBlur = 5, blur * 0.5 = 5, fraction = 1 - (5 - 5) = 1 + expect(result1.fraction).toBe(1); + + const result2 = calculateDirectionalBlurParams(true, 11, 100, 100); + // halfBlur = 6, blur * 0.5 = 5.5, fraction = 1 - (6 - 5.5) = 0.5 + expect(result2.fraction).toBe(0.5); + }); + + it("should scale offset based on texture dimensions", () => + { + const result1 = calculateDirectionalBlurParams(true, 10, 200, 100); + const result2 = calculateDirectionalBlurParams(true, 10, 100, 100); + + expect(result1.offsetX).toBe(1 / 200); + expect(result2.offsetX).toBe(1 / 100); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/BlurFilterUseCase.ts b/packages/webgpu/src/Filter/BlurFilterUseCase.ts new file mode 100644 index 00000000..2954d3d1 --- /dev/null +++ b/packages/webgpu/src/Filter/BlurFilterUseCase.ts @@ -0,0 +1,114 @@ +/** + * @description ブラーフィルター適用のユースケース + * Apply blur filter use case + */ + +/** + * @description ブラー計算用のステップ値 + * Step values for blur calculation + */ +const BLUR_STEP: number[] = [0.5, 1.05, 1.4, 1.55, 1.75, 1.9, 2, 2.15, 2.2, 2.3, 2.5, 3, 3, 3.5, 3.5]; + +/** + * @description ブラーフィルターパラメータを計算 + * @param {Float32Array} matrix - 変換行列 + * @param {number} blurX - X方向のブラー量 + * @param {number} blurY - Y方向のブラー量 + * @param {number} quality - クオリティ (1-15) + * @param {number} devicePixelRatio - デバイスピクセル比 + * @return {object} + */ +export const calculateBlurParams = ( + matrix: Float32Array, + blurX: number, + blurY: number, + quality: number, + devicePixelRatio: number +): { + baseBlurX: number; + baseBlurY: number; + offsetX: number; + offsetY: number; + bufferScaleX: number; + bufferScaleY: number; +} => { + const xScale = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]); + const yScale = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]); + + const baseBlurX = blurX * (xScale / devicePixelRatio); + const baseBlurY = blurY * (yScale / devicePixelRatio); + + const step = BLUR_STEP[Math.min(quality - 1, BLUR_STEP.length - 1)]; + const offsetX = Math.round(baseBlurX * step); + const offsetY = Math.round(baseBlurY * step); + + // バッファスケールを計算(大きなブラーの場合はダウンスケール) + let bufferScaleX = 1; + let bufferScaleY = 1; + + if (baseBlurX > 128) { + bufferScaleX = 0.0625; + } else if (baseBlurX > 64) { + bufferScaleX = 0.125; + } else if (baseBlurX > 32) { + bufferScaleX = 0.25; + } else if (baseBlurX > 16) { + bufferScaleX = 0.5; + } + + if (baseBlurY > 128) { + bufferScaleY = 0.0625; + } else if (baseBlurY > 64) { + bufferScaleY = 0.125; + } else if (baseBlurY > 32) { + bufferScaleY = 0.25; + } else if (baseBlurY > 16) { + bufferScaleY = 0.5; + } + + return { + baseBlurX, + baseBlurY, + offsetX, + offsetY, + bufferScaleX, + bufferScaleY + }; +}; + +/** + * @description 方向ブラーのパラメータを計算 + * @param {boolean} isHorizontal - 水平方向かどうか + * @param {number} blur - ブラー量 + * @param {number} textureWidth - テクスチャ幅 + * @param {number} textureHeight - テクスチャ高さ + * @return {object} + */ +export const calculateDirectionalBlurParams = ( + isHorizontal: boolean, + blur: number, + textureWidth: number, + textureHeight: number +): { + offsetX: number; + offsetY: number; + fraction: number; + samples: number; + halfBlur: number; +} => { + const halfBlur = Math.ceil(blur * 0.5); + const fraction = 1 - (halfBlur - blur * 0.5); + const samples = 1 + blur; + + // テクセルオフセットを計算 + const offsetX = isHorizontal ? 1 / textureWidth : 0; + const offsetY = isHorizontal ? 0 : 1 / textureHeight; + + return { + offsetX, + offsetY, + fraction, + samples, + halfBlur + }; +}; diff --git a/packages/webgpu/src/Filter/ColorMatrixFilter/FilterApplyColorMatrixFilterUseCase.test.ts b/packages/webgpu/src/Filter/ColorMatrixFilter/FilterApplyColorMatrixFilterUseCase.test.ts new file mode 100644 index 00000000..e953dcfc --- /dev/null +++ b/packages/webgpu/src/Filter/ColorMatrixFilter/FilterApplyColorMatrixFilterUseCase.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { execute } from "./FilterApplyColorMatrixFilterUseCase"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + UNIFORM: 0x40, + COPY_DST: 0x08 +}; +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +describe("FilterApplyColorMatrixFilterUseCase", () => +{ + const createMockAttachment = (width: number = 100, height: number = 100): IAttachmentObject => + { + return { + "id": 1, + "width": width, + "height": height, + "clipLevel": 0, + "texture": { + "resource": { "label": "mockTexture" } as unknown as GPUTexture, + "view": { "label": "mockTextureView" } as unknown as GPUTextureView + } + } as IAttachmentObject; + }; + + const createMockConfig = (): IFilterConfig => + { + const mockPassEncoder = { + "setPipeline": vi.fn(), + "setBindGroup": vi.fn(), + "draw": vi.fn(), + "end": vi.fn() + }; + + return { + "device": { + "createBuffer": vi.fn(() => ({ "label": "mockBuffer" })), + "queue": { "writeBuffer": vi.fn() }, + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })) + } as unknown as GPUDevice, + "commandEncoder": { + "beginRenderPass": vi.fn(() => mockPassEncoder) + } as unknown as GPUCommandEncoder, + "frameBufferManager": { + "createTemporaryAttachment": vi.fn((w: number, h: number) => createMockAttachment(w, h)), + "releaseTemporaryAttachment": vi.fn(), + "createRenderPassDescriptor": vi.fn(() => ({ + "colorAttachments": [{ "view": {}, "loadOp": "clear", "storeOp": "store" }] + })) + }, + "pipelineManager": { + "getPipeline": vi.fn(() => ({ "label": "mockPipeline" })), + "getBindGroupLayout": vi.fn(() => ({ "label": "mockLayout" })) + }, + "textureManager": { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })) + } + } as unknown as IFilterConfig; + }; + + // Identity color matrix (no change) + const createIdentityMatrix = (): Float32Array => + { + return new Float32Array([ + 1, 0, 0, 0, 0, // Red row + 0, 1, 0, 0, 0, // Green row + 0, 0, 1, 0, 0, // Blue row + 0, 0, 0, 1, 0 // Alpha row + ]); + }; + + beforeEach(() => + { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("basic color matrix execution", () => + { + it("should create output attachment with same dimensions", () => + { + const sourceAttachment = createMockAttachment(200, 150); + const matrix = createIdentityMatrix(); + const config = createMockConfig(); + + execute(sourceAttachment, matrix, config); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalledWith(200, 150); + }); + + it("should get color matrix pipeline", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = createIdentityMatrix(); + const config = createMockConfig(); + + execute(sourceAttachment, matrix, config); + + expect(config.pipelineManager.getPipeline).toHaveBeenCalledWith("color_matrix_filter"); + expect(config.pipelineManager.getBindGroupLayout).toHaveBeenCalledWith("color_matrix_filter"); + }); + + it("should create sampler with linear filtering", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = createIdentityMatrix(); + const config = createMockConfig(); + + execute(sourceAttachment, matrix, config); + + expect(config.textureManager.createSampler).toHaveBeenCalledWith("color_matrix_sampler", true); + }); + + it("should create uniform buffer", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = createIdentityMatrix(); + const config = createMockConfig(); + + execute(sourceAttachment, matrix, config); + + expect(config.device.createBuffer).toHaveBeenCalled(); + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + + it("should create bind group with correct entries", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = createIdentityMatrix(); + const config = createMockConfig(); + + execute(sourceAttachment, matrix, config); + + expect(config.device.createBindGroup).toHaveBeenCalledWith( + expect.objectContaining({ + "layout": expect.anything(), + "entries": expect.arrayContaining([ + expect.objectContaining({ "binding": 0 }), + expect.objectContaining({ "binding": 1 }), + expect.objectContaining({ "binding": 2 }) + ]) + }) + ); + }); + + it("should execute render pass", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = createIdentityMatrix(); + const config = createMockConfig(); + + execute(sourceAttachment, matrix, config); + + expect(config.commandEncoder.beginRenderPass).toHaveBeenCalled(); + }); + + it("should return destination attachment", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = createIdentityMatrix(); + const config = createMockConfig(); + + const result = execute(sourceAttachment, matrix, config); + + expect(result).toBeDefined(); + expect(result.texture).toBeDefined(); + }); + }); + + describe("matrix transformation", () => + { + it("should handle grayscale matrix", () => + { + const sourceAttachment = createMockAttachment(); + const grayscaleMatrix = new Float32Array([ + 0.33, 0.33, 0.33, 0, 0, + 0.33, 0.33, 0.33, 0, 0, + 0.33, 0.33, 0.33, 0, 0, + 0, 0, 0, 1, 0 + ]); + const config = createMockConfig(); + + const result = execute(sourceAttachment, grayscaleMatrix, config); + + expect(result).toBeDefined(); + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + + it("should handle invert matrix", () => + { + const sourceAttachment = createMockAttachment(); + const invertMatrix = new Float32Array([ + -1, 0, 0, 0, 255, + 0, -1, 0, 0, 255, + 0, 0, -1, 0, 255, + 0, 0, 0, 1, 0 + ]); + const config = createMockConfig(); + + const result = execute(sourceAttachment, invertMatrix, config); + + expect(result).toBeDefined(); + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + + it("should handle brightness adjustment matrix", () => + { + const sourceAttachment = createMockAttachment(); + const brightnessMatrix = new Float32Array([ + 1, 0, 0, 0, 50, // Add 50 to red + 0, 1, 0, 0, 50, // Add 50 to green + 0, 0, 1, 0, 50, // Add 50 to blue + 0, 0, 0, 1, 0 + ]); + const config = createMockConfig(); + + const result = execute(sourceAttachment, brightnessMatrix, config); + + expect(result).toBeDefined(); + }); + + it("should handle saturation matrix", () => + { + const sourceAttachment = createMockAttachment(); + const saturation = 0.5; + const sr = (1 - saturation) * 0.3086; + const sg = (1 - saturation) * 0.6094; + const sb = (1 - saturation) * 0.0820; + const saturationMatrix = new Float32Array([ + sr + saturation, sg, sb, 0, 0, + sr, sg + saturation, sb, 0, 0, + sr, sg, sb + saturation, 0, 0, + 0, 0, 0, 1, 0 + ]); + const config = createMockConfig(); + + const result = execute(sourceAttachment, saturationMatrix, config); + + expect(result).toBeDefined(); + }); + }); + + describe("pipeline error handling", () => + { + it("should return source attachment when pipeline not found", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = createIdentityMatrix(); + const config = createMockConfig(); + (config.pipelineManager.getPipeline as ReturnType).mockReturnValue(null); + + const result = execute(sourceAttachment, matrix, config); + + expect(console.error).toHaveBeenCalledWith("[WebGPU ColorMatrixFilter] Pipeline not found"); + expect(result).toBe(sourceAttachment); + }); + + it("should return source attachment when bind group layout not found", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = createIdentityMatrix(); + const config = createMockConfig(); + (config.pipelineManager.getBindGroupLayout as ReturnType).mockReturnValue(null); + + const result = execute(sourceAttachment, matrix, config); + + expect(console.error).toHaveBeenCalled(); + expect(result).toBe(sourceAttachment); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/ColorMatrixFilter/FilterApplyColorMatrixFilterUseCase.ts b/packages/webgpu/src/Filter/ColorMatrixFilter/FilterApplyColorMatrixFilterUseCase.ts new file mode 100644 index 00000000..b9ef9282 --- /dev/null +++ b/packages/webgpu/src/Filter/ColorMatrixFilter/FilterApplyColorMatrixFilterUseCase.ts @@ -0,0 +1,116 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; + +/** + * @description プリアロケートされたFloat32Array + */ +const $uniform20 = new Float32Array(20); + +/** + * @description プリアロケートされたBindGroupEntry配列 (バインディング3つ) + */ +const $entries3: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView } +]; + +/** + * @description カラーマトリックスフィルターを適用 + * Apply color matrix filter + * + * @param {IAttachmentObject} sourceAttachment - 入力テクスチャ(アタッチメント) + * @param {Float32Array} matrix - 4x5カラーマトリックス (20 floats) + * @param {IFilterConfig} config - WebGPUリソース設定 + * @return {IAttachmentObject} - フィルター適用後のアタッチメント + */ +export const execute = ( + sourceAttachment: IAttachmentObject, + matrix: Float32Array, + config: IFilterConfig +): IAttachmentObject => { + + const { device, commandEncoder, frameBufferManager, pipelineManager, textureManager } = config; + + // 出力アタッチメントを作成 + const destAttachment = frameBufferManager.createTemporaryAttachment( + sourceAttachment.width, + sourceAttachment.height + ); + + const pipeline = pipelineManager.getPipeline("color_matrix_filter"); + const bindGroupLayout = pipelineManager.getBindGroupLayout("color_matrix_filter"); + + if (!pipeline || !bindGroupLayout) { + console.error("[WebGPU ColorMatrixFilter] Pipeline not found"); + return sourceAttachment; + } + + // サンプラーを作成 + const sampler = textureManager.createSampler("color_matrix_sampler", true); + + // ユニフォームバッファを作成 + // 4x4 matrix (64 bytes) + offset vec4 (16 bytes) = 80 bytes + // WebGPUのmat4x4は列優先なので、入力の4x5行列を変換 + // 入力: [r0, r1, r2, r3, r4, g0, g1, g2, g3, g4, b0, b1, b2, b3, b4, a0, a1, a2, a3, a4] + // 出力: mat4x4 (row-wise to column-wise) + offset vec4 + // Column 0: R coefficients + $uniform20[0] = matrix[0]; + $uniform20[1] = matrix[5]; + $uniform20[2] = matrix[10]; + $uniform20[3] = matrix[15]; + // Column 1: G coefficients + $uniform20[4] = matrix[1]; + $uniform20[5] = matrix[6]; + $uniform20[6] = matrix[11]; + $uniform20[7] = matrix[16]; + // Column 2: B coefficients + $uniform20[8] = matrix[2]; + $uniform20[9] = matrix[7]; + $uniform20[10] = matrix[12]; + $uniform20[11] = matrix[17]; + // Column 3: A coefficients + $uniform20[12] = matrix[3]; + $uniform20[13] = matrix[8]; + $uniform20[14] = matrix[13]; + $uniform20[15] = matrix[18]; + // Offset values (R, G, B, A) - normalized to 0-1 range (input is 0-255) + $uniform20[16] = matrix[4] / 255; + $uniform20[17] = matrix[9] / 255; + $uniform20[18] = matrix[14] / 255; + $uniform20[19] = matrix[19] / 255; + + const uniformBuffer = config.bufferManager + ? config.bufferManager.acquireAndWriteUniformBuffer($uniform20) + : device.createBuffer({ + "size": $uniform20.byteLength, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + if (!config.bufferManager) { + device.queue.writeBuffer(uniformBuffer, 0, $uniform20); + } + + // バインドグループを作成 + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = sourceAttachment.texture!.view; + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries3 + }); + + // レンダーパスを実行 + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + destAttachment.texture!.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + // Note: uniformBuffer is not destroyed here - it will be garbage collected after GPU submission + + return destAttachment; +}; diff --git a/packages/webgpu/src/Filter/ColorMatrixFilterShader.test.ts b/packages/webgpu/src/Filter/ColorMatrixFilterShader.test.ts new file mode 100644 index 00000000..672e57af --- /dev/null +++ b/packages/webgpu/src/Filter/ColorMatrixFilterShader.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { ColorMatrixFilterShader } from "./ColorMatrixFilterShader"; + +describe("ColorMatrixFilterShader", () => +{ + describe("getVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = ColorMatrixFilterShader.getVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = ColorMatrixFilterShader.getVertexShader(); + + expect(shader).toContain("@vertex"); + }); + + it("should define VertexInput struct", () => + { + const shader = ColorMatrixFilterShader.getVertexShader(); + + expect(shader).toContain("struct VertexInput"); + }); + + it("should define VertexOutput struct", () => + { + const shader = ColorMatrixFilterShader.getVertexShader(); + + expect(shader).toContain("struct VertexOutput"); + }); + }); + + describe("getFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ColorMatrixFilterShader.getFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ColorMatrixFilterShader.getFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should define ColorMatrixUniforms struct", () => + { + const shader = ColorMatrixFilterShader.getFragmentShader(); + + expect(shader).toContain("struct ColorMatrixUniforms"); + }); + + it("should include matrix uniform", () => + { + const shader = ColorMatrixFilterShader.getFragmentShader(); + + expect(shader).toContain("matrix: mat4x4"); + }); + + it("should include offset uniform", () => + { + const shader = ColorMatrixFilterShader.getFragmentShader(); + + expect(shader).toContain("offset: vec4"); + }); + + it("should apply matrix transformation", () => + { + const shader = ColorMatrixFilterShader.getFragmentShader(); + + expect(shader).toContain("uniforms.matrix * color"); + }); + + it("should apply offset", () => + { + const shader = ColorMatrixFilterShader.getFragmentShader(); + + expect(shader).toContain("+ uniforms.offset"); + }); + + it("should clamp result to 0-1 range", () => + { + const shader = ColorMatrixFilterShader.getFragmentShader(); + + expect(shader).toContain("clamp"); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/ColorMatrixFilterShader.ts b/packages/webgpu/src/Filter/ColorMatrixFilterShader.ts new file mode 100644 index 00000000..ed0108c2 --- /dev/null +++ b/packages/webgpu/src/Filter/ColorMatrixFilterShader.ts @@ -0,0 +1,55 @@ +export class ColorMatrixFilterShader +{ + static getFragmentShader(): string + { + return /* wgsl */` + struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, + } + + struct ColorMatrixUniforms { + matrix: mat4x4, + offset: vec4, + } + + @group(0) @binding(0) var uniforms: ColorMatrixUniforms; + @group(0) @binding(1) var textureSampler: sampler; + @group(0) @binding(2) var textureData: texture_2d; + + @fragment + fn main(input: VertexOutput) -> @location(0) vec4 { + var color = textureSample(textureData, textureSampler, input.texCoord); + + var result = uniforms.matrix * color + uniforms.offset; + + result = clamp(result, vec4(0.0), vec4(1.0)); + + return result; + } + `; + } + + static getVertexShader(): string + { + return /* wgsl */` + struct VertexInput { + @location(0) position: vec2, + @location(1) texCoord: vec2, + } + + struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, + } + + @vertex + fn main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + output.position = vec4(input.position, 0.0, 1.0); + output.texCoord = input.texCoord; + return output; + } + `; + } +} diff --git a/packages/webgpu/src/Filter/ConvolutionFilter/FilterApplyConvolutionFilterUseCase.test.ts b/packages/webgpu/src/Filter/ConvolutionFilter/FilterApplyConvolutionFilterUseCase.test.ts new file mode 100644 index 00000000..796c351f --- /dev/null +++ b/packages/webgpu/src/Filter/ConvolutionFilter/FilterApplyConvolutionFilterUseCase.test.ts @@ -0,0 +1,524 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { execute } from "./FilterApplyConvolutionFilterUseCase"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + UNIFORM: 0x40, + COPY_DST: 0x08 +}; +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +// Mock GPUShaderStage +const GPUShaderStage = { + VERTEX: 0x1, + FRAGMENT: 0x2 +}; +(globalThis as any).GPUShaderStage = GPUShaderStage; + +// Mock ShaderSource +vi.mock("../../Shader/ShaderSource", () => ({ + "ShaderSource": { + "getConvolutionFilterFragmentShader": vi.fn(() => "/* mock fragment shader */"), + "getBlurFilterVertexShader": vi.fn(() => "/* mock vertex shader */") + } +})); + +describe("FilterApplyConvolutionFilterUseCase", () => +{ + const createMockAttachment = (width: number = 100, height: number = 100): IAttachmentObject => + { + return { + "id": 1, + "width": width, + "height": height, + "clipLevel": 0, + "texture": { + "resource": { "label": "mockTexture" } as unknown as GPUTexture, + "view": { "label": "mockTextureView" } as unknown as GPUTextureView + } + } as IAttachmentObject; + }; + + const createMockConfig = (): IFilterConfig => + { + const mockPassEncoder = { + "setPipeline": vi.fn(), + "setBindGroup": vi.fn(), + "draw": vi.fn(), + "end": vi.fn() + }; + + const mockBindGroupLayout = { "label": "mockBindGroupLayout" }; + const mockPipelineLayout = { "label": "mockPipelineLayout" }; + const mockPipeline = { "label": "mockPipeline" }; + const mockShaderModule = { "label": "mockShaderModule" }; + + return { + "device": { + "createBuffer": vi.fn(() => ({ "label": "mockBuffer" })), + "queue": { "writeBuffer": vi.fn() }, + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })), + "createBindGroupLayout": vi.fn(() => mockBindGroupLayout), + "createPipelineLayout": vi.fn(() => mockPipelineLayout), + "createRenderPipeline": vi.fn(() => mockPipeline), + "createShaderModule": vi.fn(() => mockShaderModule) + } as unknown as GPUDevice, + "commandEncoder": { + "beginRenderPass": vi.fn(() => mockPassEncoder) + } as unknown as GPUCommandEncoder, + "frameBufferManager": { + "createTemporaryAttachment": vi.fn((w: number, h: number) => createMockAttachment(w, h)), + "releaseTemporaryAttachment": vi.fn(), + "createRenderPassDescriptor": vi.fn(() => ({ + "colorAttachments": [{ "view": {}, "loadOp": "clear", "storeOp": "store" }] + })) + }, + "pipelineManager": { + "getPipeline": vi.fn(() => ({ "label": "mockPipeline" })), + "getBindGroupLayout": vi.fn(() => ({ "label": "mockLayout" })) + }, + "textureManager": { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })) + } + } as unknown as IFilterConfig; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + }); + + describe("basic convolution execution", () => + { + it("should create output attachment with same dimensions", () => + { + const sourceAttachment = createMockAttachment(200, 150); + const matrix = new Float32Array([0, -1, 0, -1, 5, -1, 0, -1, 0]); // sharpen + const config = createMockConfig(); + + execute( + sourceAttachment, + 3, 3, // matrixX, matrixY + matrix, + 1, // divisor + 0, // bias + true, // preserveAlpha + true, // clamp + 0x000000, // color + 1.0, // alpha + config + ); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalledWith(200, 150); + }); + + it("should create shader modules on first call (cached on subsequent)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([0, -1, 0, -1, 5, -1, 0, -1, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + 3, 3, + matrix, + 1, 0, + true, false, // unique: clamp=false + 0x000000, 1.0, + config + ); + + // 1 shader module: combined vertex and fragment + expect(config.device.createShaderModule).toHaveBeenCalledTimes(1); + }); + + it("should create bind group layout on first call (cached on subsequent)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([0, -1, 0, -1, 5, -1, 0, -1, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + 5, 5, // unique: matrixX=5, matrixY=5 + matrix, + 1, 0, + true, true, + 0x000000, 1.0, + config + ); + + expect(config.device.createBindGroupLayout).toHaveBeenCalledWith( + expect.objectContaining({ + "entries": expect.arrayContaining([ + expect.objectContaining({ "binding": 0 }), + expect.objectContaining({ "binding": 1 }), + expect.objectContaining({ "binding": 2 }) + ]) + }) + ); + }); + + it("should create render pipeline on first call (cached on subsequent)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([0, -1, 0, -1, 5, -1, 0, -1, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + 7, 7, // unique: matrixX=7, matrixY=7 + matrix, + 1, 0, + false, true, // unique: preserveAlpha=false + 0x000000, 1.0, + config + ); + + expect(config.device.createRenderPipeline).toHaveBeenCalled(); + }); + + it("should create sampler", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([0, -1, 0, -1, 5, -1, 0, -1, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + 3, 3, + matrix, + 1, 0, + true, true, + 0x000000, 1.0, + config + ); + + expect(config.textureManager.createSampler).toHaveBeenCalledWith("convolution_sampler", true); + }); + + it("should return destination attachment", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([0, -1, 0, -1, 5, -1, 0, -1, 0]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + 3, 3, + matrix, + 1, 0, + true, true, + 0x000000, 1.0, + config + ); + + expect(result).toBeDefined(); + expect(result.texture).toBeDefined(); + }); + }); + + describe("matrix size variations", () => + { + it("should handle 3x3 matrix", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array(9).fill(1); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + 3, 3, + matrix, + 9, 0, + true, true, + 0x000000, 1.0, + config + ); + + expect(result).toBeDefined(); + }); + + it("should handle 5x5 matrix", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array(25).fill(1); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + 5, 5, + matrix, + 25, 0, + true, true, + 0x000000, 1.0, + config + ); + + expect(result).toBeDefined(); + }); + + it("should handle non-square matrix (3x5)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array(15).fill(1); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + 3, 5, + matrix, + 15, 0, + true, true, + 0x000000, 1.0, + config + ); + + expect(result).toBeDefined(); + }); + }); + + describe("filter parameters", () => + { + it("should handle divisor of 0 (auto-calculate)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([0, -1, 0, -1, 5, -1, 0, -1, 0]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + 3, 3, + matrix, + 0, 0, // divisor = 0 + true, true, + 0x000000, 1.0, + config + ); + + expect(result).toBeDefined(); + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + + it("should handle bias value", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([0, -1, 0, -1, 5, -1, 0, -1, 0]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + 3, 3, + matrix, + 1, + 128, // bias = 128 + true, true, + 0x000000, 1.0, + config + ); + + expect(result).toBeDefined(); + }); + + it("should handle preserveAlpha = false", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([0, -1, 0, -1, 5, -1, 0, -1, 0]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + 3, 3, + matrix, + 1, 0, + false, // preserveAlpha = false + true, + 0x000000, 1.0, + config + ); + + expect(result).toBeDefined(); + }); + + it("should handle clamp = false", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([0, -1, 0, -1, 5, -1, 0, -1, 0]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + 3, 3, + matrix, + 1, 0, + true, + false, // clamp = false + 0xFF0000, // substitute color + 0.5, // substitute alpha + config + ); + + expect(result).toBeDefined(); + }); + + it("should handle substitute color", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([0, -1, 0, -1, 5, -1, 0, -1, 0]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + 3, 3, + matrix, + 1, 0, + true, false, + 0xFF00FF, // magenta + 0.75, + config + ); + + expect(result).toBeDefined(); + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + }); + + describe("common convolution filters", () => + { + it("should apply sharpen filter", () => + { + const sourceAttachment = createMockAttachment(); + const sharpenMatrix = new Float32Array([ + 0, -1, 0, + -1, 5, -1, + 0, -1, 0 + ]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + 3, 3, + sharpenMatrix, + 1, 0, + true, true, + 0x000000, 1.0, + config + ); + + expect(result).toBeDefined(); + }); + + it("should apply blur filter", () => + { + const sourceAttachment = createMockAttachment(); + const blurMatrix = new Float32Array([ + 1, 1, 1, + 1, 1, 1, + 1, 1, 1 + ]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + 3, 3, + blurMatrix, + 9, 0, // divisor = 9 + true, true, + 0x000000, 1.0, + config + ); + + expect(result).toBeDefined(); + }); + + it("should apply edge detection filter", () => + { + const sourceAttachment = createMockAttachment(); + const edgeMatrix = new Float32Array([ + -1, -1, -1, + -1, 8, -1, + -1, -1, -1 + ]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + 3, 3, + edgeMatrix, + 1, 0, + true, true, + 0x000000, 1.0, + config + ); + + expect(result).toBeDefined(); + }); + + it("should apply emboss filter", () => + { + const sourceAttachment = createMockAttachment(); + const embossMatrix = new Float32Array([ + -2, -1, 0, + -1, 1, 1, + 0, 1, 2 + ]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + 3, 3, + embossMatrix, + 1, 128, // bias = 128 for emboss + true, true, + 0x000000, 1.0, + config + ); + + expect(result).toBeDefined(); + }); + }); + + describe("render pass execution", () => + { + it("should begin render pass with correct descriptor", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([0, -1, 0, -1, 5, -1, 0, -1, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + 3, 3, + matrix, + 1, 0, + true, true, + 0x000000, 1.0, + config + ); + + expect(config.commandEncoder.beginRenderPass).toHaveBeenCalled(); + }); + + it("should draw 6 vertices (2 triangles for fullscreen quad)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([0, -1, 0, -1, 5, -1, 0, -1, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + 3, 3, + matrix, + 1, 0, + true, true, + 0x000000, 1.0, + config + ); + + const mockPassEncoder = (config.commandEncoder.beginRenderPass as ReturnType).mock.results[0].value; + expect(mockPassEncoder.draw).toHaveBeenCalledWith(6, 1, 0, 0); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/ConvolutionFilter/FilterApplyConvolutionFilterUseCase.ts b/packages/webgpu/src/Filter/ConvolutionFilter/FilterApplyConvolutionFilterUseCase.ts new file mode 100644 index 00000000..57bee8ff --- /dev/null +++ b/packages/webgpu/src/Filter/ConvolutionFilter/FilterApplyConvolutionFilterUseCase.ts @@ -0,0 +1,187 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { ShaderSource } from "../../Shader/ShaderSource"; + +/** + * @description プリアロケートされたBindGroupEntry配列 (バインディング3つ) + */ +const $entries3: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView } +]; + +/** + * @description 32bit整数からRGB値を抽出 + */ +const intToRGBA = (color: number, alpha: number): [number, number, number, number] => { + const r = (color >> 16 & 0xFF) / 255; + const g = (color >> 8 & 0xFF) / 255; + const b = (color & 0xFF) / 255; + return [r, g, b, alpha]; +}; + +/** + * @description パイプラインキャッシュ(キー: matrixX,matrixY,preserveAlpha,clamp) + */ +const $pipelineCache = new Map(); + +/** + * @description コンボリューションフィルターを適用 + */ +export const execute = ( + sourceAttachment: IAttachmentObject, + matrixX: number, + matrixY: number, + matrix: Float32Array, + divisor: number, + bias: number, + preserveAlpha: boolean, + clamp: boolean, + color: number, + alpha: number, + config: IFilterConfig +): IAttachmentObject => { + + const { device, commandEncoder, frameBufferManager, textureManager } = config; + + const width = sourceAttachment.width; + const height = sourceAttachment.height; + + // 出力アタッチメントを作成 + const destAttachment = frameBufferManager.createTemporaryAttachment(width, height); + + // パイプラインをキャッシュから取得または作成 + const cacheKey = `${matrixX},${matrixY},${preserveAlpha},${clamp}`; + let cached = $pipelineCache.get(cacheKey); + if (!cached) { + const shaderCode = ShaderSource.getConvolutionFilterFragmentShader( + matrixX, matrixY, preserveAlpha, clamp + ); + + const shaderModule = device.createShaderModule({ "code": shaderCode }); + + const bindGroupLayout = device.createBindGroupLayout({ + "entries": [ + { + "binding": 0, + "visibility": GPUShaderStage.FRAGMENT, + "buffer": { "type": "uniform" } + }, + { + "binding": 1, + "visibility": GPUShaderStage.FRAGMENT, + "sampler": {} + }, + { + "binding": 2, + "visibility": GPUShaderStage.FRAGMENT, + "texture": {} + } + ] + }); + + const pipelineLayout = device.createPipelineLayout({ + "bindGroupLayouts": [bindGroupLayout] + }); + + const pipeline = device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": shaderModule, + "entryPoint": "vs_main", + "buffers": [] + }, + "fragment": { + "module": shaderModule, + "entryPoint": "fs_main", + "targets": [{ + "format": "rgba8unorm", + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + } + }); + + cached = { pipeline, bindGroupLayout }; + $pipelineCache.set(cacheKey, cached); + } + + // サンプラーを作成 + const sampler = textureManager.createSampler("convolution_sampler", true); + + // ユニフォームバッファを作成 + const matrixSize = matrixX * matrixY; + const matrixArraySize = Math.ceil(matrixSize / 4); + const [r, g, b, a] = intToRGBA(color, alpha); + + // マトリクスを4要素ごとにまとめる + const paddedMatrix = new Float32Array(matrixArraySize * 4); + for (let i = 0; i < matrixSize; i++) { + paddedMatrix[i] = matrix[i]; + } + + const uniformSize = 32 + matrixArraySize * 16; + const uniformData = new Float32Array(uniformSize / 4); + uniformData[0] = 1 / width; // rcpSize.x + uniformData[1] = 1 / height; // rcpSize.y + uniformData[2] = divisor !== 0 ? 1 / divisor : 1; // rcpDivisor + uniformData[3] = bias / 255; // bias (normalize to 0-1) + uniformData[4] = r; // substituteColor.r + uniformData[5] = g; // substituteColor.g + uniformData[6] = b; // substituteColor.b + uniformData[7] = a; // substituteColor.a + // matrix array starts at index 8 + for (let i = 0; i < paddedMatrix.length; i++) { + uniformData[8 + i] = paddedMatrix[i]; + } + + const uniformBuffer = config.bufferManager + ? config.bufferManager.acquireAndWriteUniformBuffer(uniformData) + : device.createBuffer({ + "size": uniformData.byteLength, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + if (!config.bufferManager) { + device.queue.writeBuffer(uniformBuffer, 0, uniformData); + } + + // バインドグループを作成 + ($entries3[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries3[1].resource = sampler; + $entries3[2].resource = sourceAttachment.texture!.view; + const bindGroup = device.createBindGroup({ + "layout": cached.bindGroupLayout, + "entries": $entries3 + }); + + // レンダーパスを実行 + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + destAttachment.texture!.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(cached.pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + return destAttachment; +}; diff --git a/packages/webgpu/src/Filter/ConvolutionFilterShader.test.ts b/packages/webgpu/src/Filter/ConvolutionFilterShader.test.ts new file mode 100644 index 00000000..68d86687 --- /dev/null +++ b/packages/webgpu/src/Filter/ConvolutionFilterShader.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect } from "vitest"; +import { getConvolutionFilterFragmentShader, getConvolutionFilterShaderKey } from "./ConvolutionFilterShader"; + +describe("ConvolutionFilterShader", () => +{ + describe("getConvolutionFilterFragmentShader", () => + { + it("should return a valid WGSL shader string", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + + expect(shader).toContain("@vertex"); + }); + + it("should contain @fragment attribute", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + + expect(shader).toContain("@fragment"); + }); + + it("should define ConvolutionUniforms struct", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + + expect(shader).toContain("struct ConvolutionUniforms"); + }); + + it("should include rcpSize uniform", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + + expect(shader).toContain("rcpSize: vec2"); + }); + + it("should include rcpDivisor uniform", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + + expect(shader).toContain("rcpDivisor: f32"); + }); + + it("should include bias uniform", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + + expect(shader).toContain("bias: f32"); + }); + + it("should include substituteColor uniform", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + + expect(shader).toContain("substituteColor: vec4"); + }); + + it("should include matrix array", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + + expect(shader).toContain("matrix: array"); + }); + + it("should generate correct matrix size for 3x3", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + // 3x3 = 9 elements, ceil(9/4) = 3 + expect(shader).toContain("array, 3>"); + }); + + it("should generate correct matrix size for 5x5", () => + { + const shader = getConvolutionFilterFragmentShader(5, 5, true, true); + // 5x5 = 25 elements, ceil(25/4) = 7 + expect(shader).toContain("array, 7>"); + }); + + it("should include isInside helper function", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + + expect(shader).toContain("fn isInside"); + }); + + it("should include getMatrixWeight helper function", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + + expect(shader).toContain("fn getMatrixWeight"); + }); + + it("should include getWeightedColor helper function", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + + expect(shader).toContain("fn getWeightedColor"); + }); + + it("should preserve alpha when preserveAlpha is true", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + + expect(shader).toContain("result.a = textureSample(sourceTexture, sourceSampler, input.texCoord).a"); + }); + + it("should not preserve alpha when preserveAlpha is false", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, false, true); + + expect(shader).not.toContain("result.a = textureSample(sourceTexture, sourceSampler, input.texCoord).a"); + }); + + it("should include substituteColor handling when clamp is false", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, false); + + expect(shader).toContain("substituteColor"); + expect(shader).toContain("mix(substituteColor, color, isInside(uv))"); + }); + + it("should not include substituteColor handling when clamp is true", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + // Should still have substituteColor in uniforms but not the mix statement + expect(shader).not.toContain("mix(substituteColor, color, isInside(uv))"); + }); + + it("should clamp result values", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + + expect(shader).toContain("clamp(result * rcpDivisor + bias"); + }); + + it("should premultiply result", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + + expect(shader).toContain("result.rgb * result.a"); + }); + + it("should unpremultiply color for processing", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + + expect(shader).toContain("color.rgb / max(0.0001, color.a)"); + }); + + it("should generate 9 getWeightedColor calls for 3x3 matrix", () => + { + const shader = getConvolutionFilterFragmentShader(3, 3, true, true); + + let count = 0; + for (let i = 0; i < 9; i++) { + if (shader.includes(`getWeightedColor(${i}`)) { + count++; + } + } + + expect(count).toBe(9); + }); + + it("should handle asymmetric matrix sizes", () => + { + const shader = getConvolutionFilterFragmentShader(5, 3, true, true); + // 5x3 = 15 elements, ceil(15/4) = 4 + expect(shader).toContain("array, 4>"); + }); + }); + + describe("getConvolutionFilterShaderKey", () => + { + it("should generate unique key for 3x3 with preserveAlpha and clamp", () => + { + const key = getConvolutionFilterShaderKey(3, 3, true, true); + + expect(key).toBe("convolution_3x3_pa_c"); + }); + + it("should generate unique key for 5x5 without preserveAlpha and without clamp", () => + { + const key = getConvolutionFilterShaderKey(5, 5, false, false); + + expect(key).toBe("convolution_5x5_npa_nc"); + }); + + it("should include matrix dimensions in key", () => + { + const key = getConvolutionFilterShaderKey(7, 3, true, true); + + expect(key).toContain("7x3"); + }); + + it("should include preserveAlpha flag in key", () => + { + const keyWithPA = getConvolutionFilterShaderKey(3, 3, true, true); + const keyWithoutPA = getConvolutionFilterShaderKey(3, 3, false, true); + + expect(keyWithPA).toContain("_pa_"); + expect(keyWithoutPA).toContain("_npa_"); + }); + + it("should include clamp flag in key", () => + { + const keyWithClamp = getConvolutionFilterShaderKey(3, 3, true, true); + const keyWithoutClamp = getConvolutionFilterShaderKey(3, 3, true, false); + + expect(keyWithClamp).toContain("_c"); + expect(keyWithoutClamp).toContain("_nc"); + }); + + it("should generate different keys for different configurations", () => + { + const key1 = getConvolutionFilterShaderKey(3, 3, true, true); + const key2 = getConvolutionFilterShaderKey(3, 3, false, true); + const key3 = getConvolutionFilterShaderKey(3, 3, true, false); + const key4 = getConvolutionFilterShaderKey(5, 5, true, true); + + expect(key1).not.toBe(key2); + expect(key1).not.toBe(key3); + expect(key1).not.toBe(key4); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/ConvolutionFilterShader.ts b/packages/webgpu/src/Filter/ConvolutionFilterShader.ts new file mode 100644 index 00000000..e112bb40 --- /dev/null +++ b/packages/webgpu/src/Filter/ConvolutionFilterShader.ts @@ -0,0 +1,130 @@ +export const getConvolutionFilterFragmentShader = ( + matrixX: number, + matrixY: number, + preserveAlpha: boolean, + clamp: boolean +): string => { + const halfX = Math.floor(matrixX * 0.5); + const halfY = Math.floor(matrixY * 0.5); + const size = matrixX * matrixY; + + let matrixStatement = ""; + for (let idx = 0; idx < size; idx++) { + matrixStatement += ` + result = result + getWeightedColor(${idx}, getMatrixWeight(${idx}));`; + } + + const preserveAlphaStatement = preserveAlpha + ? "result.a = textureSample(sourceTexture, sourceSampler, input.texCoord).a;" + : ""; + + const clampStatement = clamp + ? "" + : ` + let substituteColor = uniforms.substituteColor; + color = mix(substituteColor, color, isInside(uv));`; + + return ` +struct ConvolutionUniforms { + rcpSize: vec2, + rcpDivisor: f32, + bias: f32, + substituteColor: vec4, + matrix: array, ${Math.ceil(size / 4)}>, +} + +@group(0) @binding(0) var uniforms: ConvolutionUniforms; +@group(0) @binding(1) var sourceSampler: sampler; +@group(0) @binding(2) var sourceTexture: texture_2d; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, +} + +fn isInside(uv: vec2) -> f32 { + let inside = step(vec2(0.0), uv) * step(uv, vec2(1.0)); + return inside.x * inside.y; +} + +fn getMatrixWeight(index: i32) -> f32 { + let vecIndex = index / 4; + let component = index % 4; + let vec = uniforms.matrix[vecIndex]; + + if (component == 0) { return vec.x; } + else if (component == 1) { return vec.y; } + else if (component == 2) { return vec.z; } + else { return vec.w; } +} + +fn getWeightedColor(i: i32, weight: f32) -> vec4 { + let rcpSize = uniforms.rcpSize; + + let iDivX = i / ${matrixX}; + let iModX = i - ${matrixX} * iDivX; + let offset = vec2(f32(iModX - ${halfX}), f32(${halfY} - iDivX)); + var uv = input.texCoord + offset * rcpSize; + + var color = textureSample(sourceTexture, sourceSampler, uv); + color = vec4(color.rgb / max(0.0001, color.a), color.a); + ${clampStatement} + + return color * weight; +} + +var input: VertexOutput; + +@vertex +fn vs_main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { + var positions = array, 6>( + vec2(-1.0, -1.0), + vec2(1.0, -1.0), + vec2(-1.0, 1.0), + vec2(-1.0, 1.0), + vec2(1.0, -1.0), + vec2(1.0, 1.0) + ); + + var texCoords = array, 6>( + vec2(0.0, 1.0), + vec2(1.0, 1.0), + vec2(0.0, 0.0), + vec2(0.0, 0.0), + vec2(1.0, 1.0), + vec2(1.0, 0.0) + ); + + var output: VertexOutput; + output.position = vec4(positions[vertexIndex], 0.0, 1.0); + output.texCoord = texCoords[vertexIndex]; + return output; +} + +@fragment +fn fs_main(fragInput: VertexOutput) -> @location(0) vec4 { + input = fragInput; + + let rcpDivisor = uniforms.rcpDivisor; + let bias = uniforms.bias; + + var result = vec4(0.0); + ${matrixStatement} + + result = clamp(result * rcpDivisor + bias, vec4(0.0), vec4(1.0)); + ${preserveAlphaStatement} + + result = vec4(result.rgb * result.a, result.a); + return result; +} +`; +}; + +export const getConvolutionFilterShaderKey = ( + matrixX: number, + matrixY: number, + preserveAlpha: boolean, + clamp: boolean +): string => { + return `convolution_${matrixX}x${matrixY}_${preserveAlpha ? "pa" : "npa"}_${clamp ? "c" : "nc"}`; +}; diff --git a/packages/webgpu/src/Filter/DisplacementMapFilter/FilterApplyDisplacementMapFilterUseCase.test.ts b/packages/webgpu/src/Filter/DisplacementMapFilter/FilterApplyDisplacementMapFilterUseCase.test.ts new file mode 100644 index 00000000..3daa4950 --- /dev/null +++ b/packages/webgpu/src/Filter/DisplacementMapFilter/FilterApplyDisplacementMapFilterUseCase.test.ts @@ -0,0 +1,567 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { execute } from "./FilterApplyDisplacementMapFilterUseCase"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + UNIFORM: 0x40, + COPY_DST: 0x08 +}; +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + TEXTURE_BINDING: 0x04, + COPY_DST: 0x02 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +// Mock GPUShaderStage +const GPUShaderStage = { + VERTEX: 0x1, + FRAGMENT: 0x2 +}; +(globalThis as any).GPUShaderStage = GPUShaderStage; + +// Mock ShaderSource +vi.mock("../../Shader/ShaderSource", () => ({ + "ShaderSource": { + "getDisplacementMapFilterFragmentShader": vi.fn(() => "/* mock fragment shader */"), + "getBlurFilterVertexShader": vi.fn(() => "/* mock vertex shader */") + } +})); + +describe("FilterApplyDisplacementMapFilterUseCase", () => +{ + const createMockAttachment = (width: number = 100, height: number = 100): IAttachmentObject => + { + return { + "id": 1, + "width": width, + "height": height, + "clipLevel": 0, + "texture": { + "resource": { "label": "mockTexture" } as unknown as GPUTexture, + "view": { "label": "mockTextureView" } as unknown as GPUTextureView + } + } as IAttachmentObject; + }; + + const createMockConfig = (): IFilterConfig => + { + const mockPassEncoder = { + "setPipeline": vi.fn(), + "setBindGroup": vi.fn(), + "draw": vi.fn(), + "end": vi.fn() + }; + + const mockTexture = { + "createView": vi.fn(() => ({ "label": "mockMapTextureView" })), + "destroy": vi.fn() + }; + + return { + "device": { + "createBuffer": vi.fn(() => ({ "label": "mockBuffer" })), + "createTexture": vi.fn(() => mockTexture), + "queue": { + "writeBuffer": vi.fn(), + "writeTexture": vi.fn() + }, + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })), + "createBindGroupLayout": vi.fn(() => ({ "label": "mockBindGroupLayout" })), + "createPipelineLayout": vi.fn(() => ({ "label": "mockPipelineLayout" })), + "createRenderPipeline": vi.fn(() => ({ "label": "mockPipeline" })), + "createShaderModule": vi.fn(() => ({ "label": "mockShaderModule" })) + } as unknown as GPUDevice, + "commandEncoder": { + "beginRenderPass": vi.fn(() => mockPassEncoder) + } as unknown as GPUCommandEncoder, + "frameBufferManager": { + "createTemporaryAttachment": vi.fn((w: number, h: number) => createMockAttachment(w, h)), + "releaseTemporaryAttachment": vi.fn(), + "createRenderPassDescriptor": vi.fn(() => ({ + "colorAttachments": [{ "view": {}, "loadOp": "clear", "storeOp": "store" }] + })) + }, + "pipelineManager": { + "getPipeline": vi.fn(() => ({ "label": "mockPipeline" })), + "getBindGroupLayout": vi.fn(() => ({ "label": "mockLayout" })) + }, + "textureManager": { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })) + }, + "frameTextures": [] + } as unknown as IFilterConfig; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + }); + + describe("basic displacement map execution", () => + { + it("should create output attachment with same dimensions", () => + { + const sourceAttachment = createMockAttachment(200, 150); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, // bitmap dimensions + 0, 0, // mapPoint + 1, 2, // componentX (RED), componentY (GREEN) + 10, 10, // scale + 0, // mode (clamp) + 0x000000, 1.0, // color, alpha + 1, // devicePixelRatio + config + ); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalledWith(200, 150); + }); + + it("should create map texture from bitmap buffer", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, + 0, 0, + 1, 2, + 10, 10, + 0, + 0x000000, 1.0, + 1, + config + ); + + expect(config.device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "size": { "width": 64, "height": 64 }, + "format": "rgba8unorm" + }) + ); + expect(config.device.queue.writeTexture).toHaveBeenCalled(); + }); + + it("should create shader modules on first call (cached on subsequent)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + // 異なるパラメータでキャッシュミスを発生させる + execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, + 0, 0, + 4, 8, // unique componentX, componentY + 10, 10, + 2, // unique mode + 0x000000, 1.0, + 1, + config + ); + + // 2 shader modules: vertex and fragment + expect(config.device.createShaderModule).toHaveBeenCalledTimes(2); + }); + + it("should create render pipeline on first call (cached on subsequent)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + // 異なるパラメータでキャッシュミスを発生させる + execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, + 0, 0, + 2, 4, // unique componentX, componentY + 10, 10, + 3, // unique mode + 0x000000, 1.0, + 1, + config + ); + + expect(config.device.createRenderPipeline).toHaveBeenCalled(); + }); + + it("should return destination attachment", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, + 0, 0, + 1, 2, + 10, 10, + 0, + 0x000000, 1.0, + 1, + config + ); + + expect(result).toBeDefined(); + expect(result.texture).toBeDefined(); + }); + }); + + describe("component channels", () => + { + it("should handle RED channel for X (componentX = 1)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, + 0, 0, + 1, 0, // RED for X, none for Y + 10, 10, + 0, + 0x000000, 1.0, + 1, + config + ); + + expect(result).toBeDefined(); + }); + + it("should handle GREEN channel for Y (componentY = 2)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, + 0, 0, + 0, 2, // none for X, GREEN for Y + 10, 10, + 0, + 0x000000, 1.0, + 1, + config + ); + + expect(result).toBeDefined(); + }); + + it("should handle BLUE channel (componentX = 4)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, + 0, 0, + 4, 4, // BLUE for both + 10, 10, + 0, + 0x000000, 1.0, + 1, + config + ); + + expect(result).toBeDefined(); + }); + + it("should handle ALPHA channel (componentX = 8)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, + 0, 0, + 8, 8, // ALPHA for both + 10, 10, + 0, + 0x000000, 1.0, + 1, + config + ); + + expect(result).toBeDefined(); + }); + }); + + describe("displacement modes", () => + { + it("should handle clamp mode (mode = 0)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, + 0, 0, + 1, 2, + 10, 10, + 0, // clamp + 0x000000, 1.0, + 1, + config + ); + + expect(result).toBeDefined(); + }); + + it("should handle color mode (mode = 1)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, + 0, 0, + 1, 2, + 10, 10, + 1, // color + 0xFF0000, 0.5, // red with 50% alpha + 1, + config + ); + + expect(result).toBeDefined(); + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + + it("should handle wrap mode (mode = 2)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, + 0, 0, + 1, 2, + 10, 10, + 2, // wrap + 0x000000, 1.0, + 1, + config + ); + + expect(result).toBeDefined(); + }); + + it("should handle ignore mode (mode = 3)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, + 0, 0, + 1, 2, + 10, 10, + 3, // ignore + 0x000000, 1.0, + 1, + config + ); + + expect(result).toBeDefined(); + }); + }); + + describe("map point offset", () => + { + it("should handle mapPoint X offset", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, + 20, 0, // mapPoint X = 20 + 1, 2, + 10, 10, + 0, + 0x000000, 1.0, + 1, + config + ); + + expect(result).toBeDefined(); + }); + + it("should handle mapPoint Y offset", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, + 0, 20, // mapPoint Y = 20 + 1, 2, + 10, 10, + 0, + 0x000000, 1.0, + 1, + config + ); + + expect(result).toBeDefined(); + }); + }); + + describe("scale parameters", () => + { + it("should handle different scale values", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, + 0, 0, + 1, 2, + 50, 30, // different X and Y scales + 0, + 0x000000, 1.0, + 1, + config + ); + + expect(result).toBeDefined(); + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + + it("should handle negative scale values", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, + 0, 0, + 1, 2, + -20, -20, // negative scales + 0, + 0x000000, 1.0, + 1, + config + ); + + expect(result).toBeDefined(); + }); + }); + + describe("render pass execution", () => + { + it("should draw 6 vertices", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const bitmapBuffer = new Uint8Array(64 * 64 * 4); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + bitmapBuffer, + 64, 64, + 0, 0, + 1, 2, + 10, 10, + 0, + 0x000000, 1.0, + 1, + config + ); + + const mockPassEncoder = (config.commandEncoder.beginRenderPass as ReturnType).mock.results[0].value; + expect(mockPassEncoder.draw).toHaveBeenCalledWith(6, 1, 0, 0); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/DisplacementMapFilter/FilterApplyDisplacementMapFilterUseCase.ts b/packages/webgpu/src/Filter/DisplacementMapFilter/FilterApplyDisplacementMapFilterUseCase.ts new file mode 100644 index 00000000..2f44d640 --- /dev/null +++ b/packages/webgpu/src/Filter/DisplacementMapFilter/FilterApplyDisplacementMapFilterUseCase.ts @@ -0,0 +1,233 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { ShaderSource } from "../../Shader/ShaderSource"; + +/** + * @description プリアロケートされたFloat32Array (サイズ12: 最大48バイト) + */ +const $uniform12 = new Float32Array(12); + +/** + * @description プリアロケートされたBindGroupEntry配列 (バインディング4つ) + */ +const $entries4: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView }, + { "binding": 3, "resource": null as unknown as GPUTextureView } +]; + +/** + * @description 32bit整数からRGB値を抽出(プリマルチプライドアルファ) + */ +const intToRGBA = (color: number, alpha: number): [number, number, number, number] => { + const r = (color >> 16 & 0xFF) / 255 * alpha; + const g = (color >> 8 & 0xFF) / 255 * alpha; + const b = (color & 0xFF) / 255 * alpha; + return [r, g, b, alpha]; +}; + +/** + * @description パイプラインキャッシュ(キー: componentX,componentY,mode) + */ +const $pipelineCache = new Map(); + +/** + * @description ディスプレイスメントマップフィルターを適用 + */ +export const execute = ( + sourceAttachment: IAttachmentObject, + _matrix: Float32Array, + bitmapBuffer: Uint8Array, + bitmapWidth: number, + bitmapHeight: number, + mapPointX: number, + mapPointY: number, + componentX: number, + componentY: number, + scaleX: number, + scaleY: number, + mode: number, + color: number, + alpha: number, + _devicePixelRatio: number, + config: IFilterConfig +): IAttachmentObject => { + + const { device, commandEncoder, frameBufferManager, textureManager } = config; + + const width = sourceAttachment.width; + const height = sourceAttachment.height; + + // WebGL版と同じ: baseWidth/baseHeightはビットマップサイズを使用 + const baseWidth = bitmapWidth; + const baseHeight = bitmapHeight; + + // 出力アタッチメントを作成 + const destAttachment = frameBufferManager.createTemporaryAttachment(width, height); + + // マップテクスチャを作成 + const mapTexture = device.createTexture({ + "size": { "width": bitmapWidth, "height": bitmapHeight }, + "format": "rgba8unorm", + "usage": GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST + }); + device.queue.writeTexture( + { "texture": mapTexture }, + bitmapBuffer.buffer, + { "bytesPerRow": bitmapWidth * 4, "offset": bitmapBuffer.byteOffset }, + { "width": bitmapWidth, "height": bitmapHeight } + ); + + // パイプラインをキャッシュから取得または作成 + const cacheKey = `${componentX},${componentY},${mode}`; + let cached = $pipelineCache.get(cacheKey); + if (!cached) { + const fragmentShaderCode = ShaderSource.getDisplacementMapFilterFragmentShader( + componentX, componentY, mode + ); + + const vertexShaderModule = device.createShaderModule({ + "code": ShaderSource.getBlurFilterVertexShader() + }); + + const fragmentShaderModule = device.createShaderModule({ + "code": fragmentShaderCode + }); + + const bindGroupLayout = device.createBindGroupLayout({ + "entries": [ + { + "binding": 0, + "visibility": GPUShaderStage.FRAGMENT, + "buffer": { "type": "uniform" } + }, + { + "binding": 1, + "visibility": GPUShaderStage.FRAGMENT, + "sampler": {} + }, + { + "binding": 2, + "visibility": GPUShaderStage.FRAGMENT, + "texture": {} + }, + { + "binding": 3, + "visibility": GPUShaderStage.FRAGMENT, + "texture": {} + } + ] + }); + + const pipelineLayout = device.createPipelineLayout({ + "bindGroupLayouts": [bindGroupLayout] + }); + + const pipeline = device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + } + }); + + cached = { pipeline, bindGroupLayout }; + $pipelineCache.set(cacheKey, cached); + } + + // サンプラーを作成 + const sampler = textureManager.createSampler("displacement_sampler", true); + + // ユニフォームバッファを作成 + const needsSubstituteColor = mode === 1; + const uniformSize = needsSubstituteColor ? 48 : 32; + + // uvToStScale + $uniform12[0] = baseWidth / bitmapWidth; + $uniform12[1] = baseHeight / bitmapHeight; + + // uvToStOffset + $uniform12[2] = mapPointX / bitmapWidth; + $uniform12[3] = (baseHeight - bitmapHeight - mapPointY) / bitmapHeight; + + // scale + $uniform12[4] = scaleX / baseWidth; + $uniform12[5] = scaleY / baseHeight; + + // padding + $uniform12[6] = 0; + $uniform12[7] = 0; + + // substituteColor (mode === 1 の場合) + if (needsSubstituteColor) { + const [r, g, b, a] = intToRGBA(color, alpha); + $uniform12[8] = r; + $uniform12[9] = g; + $uniform12[10] = b; + $uniform12[11] = a; + } + + const uniformBuffer = config.bufferManager + ? config.bufferManager.acquireAndWriteUniformBuffer($uniform12, uniformSize) + : device.createBuffer({ + "size": uniformSize, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + if (!config.bufferManager) { + device.queue.writeBuffer(uniformBuffer, 0, $uniform12, 0, uniformSize / 4); + } + + // バインドグループを作成 + ($entries4[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries4[1].resource = sampler; + $entries4[2].resource = sourceAttachment.texture!.view; + $entries4[3].resource = mapTexture.createView(); + const bindGroup = device.createBindGroup({ + "layout": cached.bindGroupLayout, + "entries": $entries4 + }); + + // レンダーパスを実行 + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + destAttachment.texture!.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(cached.pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + // クリーンアップ(mapTextureはsubmit後に遅延破棄) + config.frameTextures.push(mapTexture); + + return destAttachment; +}; diff --git a/packages/webgpu/src/Filter/DisplacementMapFilterShader.test.ts b/packages/webgpu/src/Filter/DisplacementMapFilterShader.test.ts new file mode 100644 index 00000000..0400ad10 --- /dev/null +++ b/packages/webgpu/src/Filter/DisplacementMapFilterShader.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from "vitest"; +import { getDisplacementMapFilterFragmentShader, getDisplacementMapFilterShaderKey } from "./DisplacementMapFilterShader"; + +describe("DisplacementMapFilterShader", () => +{ + describe("getDisplacementMapFilterFragmentShader", () => + { + it("should return a valid WGSL shader string", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(shader).toContain("@vertex"); + }); + + it("should contain @fragment attribute", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(shader).toContain("@fragment"); + }); + + it("should define DisplacementMapUniforms struct", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(shader).toContain("struct DisplacementMapUniforms"); + }); + + it("should include uvToStScale uniform", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(shader).toContain("uvToStScale: vec2"); + }); + + it("should include uvToStOffset uniform", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(shader).toContain("uvToStOffset: vec2"); + }); + + it("should include scale uniform", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(shader).toContain("scale: vec2"); + }); + + it("should include mapTexture binding", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(shader).toContain("var mapTexture: texture_2d"); + }); + + it("should include sourceTexture binding", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(shader).toContain("var sourceTexture: texture_2d"); + }); + + it("should include isInside helper function", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(shader).toContain("fn isInside"); + }); + + // Component channel tests + it("should use mapColor.r for componentX = 1 (RED)", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(shader).toContain("mapColor.r"); + }); + + it("should use mapColor.g for componentX = 2 (GREEN)", () => + { + const shader = getDisplacementMapFilterFragmentShader(2, 1, 0); + + expect(shader).toContain("vec2(mapColor.g, mapColor.r)"); + }); + + it("should use mapColor.b for componentX = 4 (BLUE)", () => + { + const shader = getDisplacementMapFilterFragmentShader(4, 1, 0); + + expect(shader).toContain("vec2(mapColor.b, mapColor.r)"); + }); + + it("should use mapColor.a for componentX = 8 (ALPHA)", () => + { + const shader = getDisplacementMapFilterFragmentShader(8, 1, 0); + + expect(shader).toContain("vec2(mapColor.a, mapColor.r)"); + }); + + it("should use 0.5 for unknown component value", () => + { + const shader = getDisplacementMapFilterFragmentShader(99, 99, 0); + + expect(shader).toContain("vec2(0.5, 0.5)"); + }); + + // Mode tests + it("should handle mode 0 (direct sampling)", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(shader).toContain("let sourceColor = textureSample(sourceTexture, sourceSampler, uv)"); + expect(shader).not.toContain("substituteColor"); + }); + + it("should include substituteColor for mode 1", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 1); + + expect(shader).toContain("substituteColor: vec4"); + expect(shader).toContain("mix(substituteColor"); + }); + + it("should handle mode 2 (wrap/repeat)", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 2); + + expect(shader).toContain("fract(uv)"); + }); + + it("should handle mode 3 (axis fallback)", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 3); + + expect(shader).toContain("fallbackUv"); + }); + + it("should calculate offset from map color", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(shader).toContain("let offset = vec2"); + expect(shader).toContain("- 0.5"); + }); + + it("should calculate displaced UV", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(shader).toContain("let uv = input.texCoord + offset * scale"); + }); + + it("should mix original and displaced color based on map bounds", () => + { + const shader = getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(shader).toContain("mix(originalColor, sourceColor, isInside(st))"); + }); + }); + + describe("getDisplacementMapFilterShaderKey", () => + { + it("should generate unique key for component combination", () => + { + const key = getDisplacementMapFilterShaderKey(1, 2, 0); + + expect(key).toBe("displacement_1_2_0"); + }); + + it("should include all component and mode values", () => + { + const key = getDisplacementMapFilterShaderKey(4, 8, 2); + + expect(key).toBe("displacement_4_8_2"); + }); + + it("should generate different keys for different configurations", () => + { + const key1 = getDisplacementMapFilterShaderKey(1, 2, 0); + const key2 = getDisplacementMapFilterShaderKey(2, 1, 0); + const key3 = getDisplacementMapFilterShaderKey(1, 2, 1); + const key4 = getDisplacementMapFilterShaderKey(4, 8, 3); + + expect(key1).not.toBe(key2); + expect(key1).not.toBe(key3); + expect(key1).not.toBe(key4); + }); + + it("should include componentX in key", () => + { + const key = getDisplacementMapFilterShaderKey(4, 2, 0); + + expect(key).toContain("_4_"); + }); + + it("should include componentY in key", () => + { + const key = getDisplacementMapFilterShaderKey(1, 8, 0); + + expect(key).toContain("_8_"); + }); + + it("should include mode in key", () => + { + const key = getDisplacementMapFilterShaderKey(1, 2, 3); + + expect(key).toContain("_3"); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/DisplacementMapFilterShader.ts b/packages/webgpu/src/Filter/DisplacementMapFilterShader.ts new file mode 100644 index 00000000..3a3f51d3 --- /dev/null +++ b/packages/webgpu/src/Filter/DisplacementMapFilterShader.ts @@ -0,0 +1,130 @@ +const getComponentExpression = (component: number): string => { + switch (component) { + case 1: + return "mapColor.r"; + case 2: + return "mapColor.g"; + case 4: + return "mapColor.b"; + case 8: + return "mapColor.a"; + default: + return "0.5"; + } +}; + +const getModeStatement = (mode: number): string => { + switch (mode) { + case 0: + return ` + let sourceColor = textureSample(sourceTexture, sourceSampler, uv);`; + + case 1: + return ` + let substituteColor = uniforms.substituteColor; + let sourceColor = mix(substituteColor, textureSample(sourceTexture, sourceSampler, uv), isInside(uv));`; + + case 3: + return ` + let fallbackUv = mix(input.texCoord, uv, step(abs(uv - vec2(0.5)), vec2(0.5))); + let sourceColor = textureSample(sourceTexture, sourceSampler, fallbackUv);`; + + case 2: + default: + return ` + let sourceColor = textureSample(sourceTexture, sourceSampler, fract(uv));`; + } +}; + +export const getDisplacementMapFilterFragmentShader = ( + componentX: number, + componentY: number, + mode: number +): string => { + const cx = getComponentExpression(componentX); + const cy = getComponentExpression(componentY); + const modeStatement = getModeStatement(mode); + + const hasSubstituteColor = mode === 1; + + return ` +struct DisplacementMapUniforms { + uvToStScale: vec2, + uvToStOffset: vec2, + scale: vec2, + _pad: vec2, +${hasSubstituteColor ? " substituteColor: vec4," : ""} +} + +@group(0) @binding(0) var uniforms: DisplacementMapUniforms; +@group(0) @binding(1) var sourceSampler: sampler; +@group(0) @binding(2) var sourceTexture: texture_2d; +@group(0) @binding(3) var mapTexture: texture_2d; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, +} + +fn isInside(uv: vec2) -> f32 { + let inside = step(vec2(0.0), uv) * step(uv, vec2(1.0)); + return inside.x * inside.y; +} + +var input: VertexOutput; + +@vertex +fn vs_main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { + var positions = array, 6>( + vec2(-1.0, -1.0), + vec2(1.0, -1.0), + vec2(-1.0, 1.0), + vec2(-1.0, 1.0), + vec2(1.0, -1.0), + vec2(1.0, 1.0) + ); + + var texCoords = array, 6>( + vec2(0.0, 1.0), + vec2(1.0, 1.0), + vec2(0.0, 0.0), + vec2(0.0, 0.0), + vec2(1.0, 1.0), + vec2(1.0, 0.0) + ); + + var output: VertexOutput; + output.position = vec4(positions[vertexIndex], 0.0, 1.0); + output.texCoord = texCoords[vertexIndex]; + return output; +} + +@fragment +fn fs_main(fragInput: VertexOutput) -> @location(0) vec4 { + input = fragInput; + + let uvToStScale = uniforms.uvToStScale; + let uvToStOffset = uniforms.uvToStOffset; + let scale = uniforms.scale; + + let st = input.texCoord * uvToStScale - uvToStOffset; + let mapColor = textureSample(mapTexture, sourceSampler, st); + + let offset = vec2(${cx}, ${cy}) - 0.5; + let uv = input.texCoord + offset * scale; + + ${modeStatement} + + let originalColor = textureSample(sourceTexture, sourceSampler, input.texCoord); + return mix(originalColor, sourceColor, isInside(st)); +} +`; +}; + +export const getDisplacementMapFilterShaderKey = ( + componentX: number, + componentY: number, + mode: number +): string => { + return `displacement_${componentX}_${componentY}_${mode}`; +}; diff --git a/packages/webgpu/src/Filter/DropShadowFilter/FilterApplyDropShadowFilterUseCase.test.ts b/packages/webgpu/src/Filter/DropShadowFilter/FilterApplyDropShadowFilterUseCase.test.ts new file mode 100644 index 00000000..2fa2c427 --- /dev/null +++ b/packages/webgpu/src/Filter/DropShadowFilter/FilterApplyDropShadowFilterUseCase.test.ts @@ -0,0 +1,477 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { execute } from "./FilterApplyDropShadowFilterUseCase"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + UNIFORM: 0x40, + COPY_DST: 0x08 +}; +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +// Mock offset +vi.mock("../index", () => ({ + "$offset": { "x": 0, "y": 0 } +})); + +import { $offset } from "../FilterOffset"; + +// Mock FilterApplyBlurFilterUseCase +vi.mock("../BlurFilter/FilterApplyBlurFilterUseCase", () => ({ + "execute": vi.fn((source: IAttachmentObject) => ({ + ...source, + "width": source.width + 40, + "height": source.height + 40 + })) +})); + +describe("FilterApplyDropShadowFilterUseCase", () => +{ + const createMockAttachment = (width: number = 100, height: number = 100): IAttachmentObject => + { + return { + "id": 1, + "width": width, + "height": height, + "clipLevel": 0, + "texture": { + "resource": { "label": "mockTexture" } as unknown as GPUTexture, + "view": { "label": "mockTextureView" } as unknown as GPUTextureView + } + } as IAttachmentObject; + }; + + const createMockConfig = (): IFilterConfig => + { + const mockPassEncoder = { + "setPipeline": vi.fn(), + "setBindGroup": vi.fn(), + "draw": vi.fn(), + "end": vi.fn() + }; + + return { + "device": { + "createBuffer": vi.fn(() => ({ "label": "mockBuffer" })), + "queue": { "writeBuffer": vi.fn() }, + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })) + } as unknown as GPUDevice, + "commandEncoder": { + "beginRenderPass": vi.fn(() => mockPassEncoder), + "copyTextureToTexture": vi.fn() + } as unknown as GPUCommandEncoder, + "frameBufferManager": { + "createTemporaryAttachment": vi.fn((w: number, h: number) => createMockAttachment(w, h)), + "releaseTemporaryAttachment": vi.fn(), + "createRenderPassDescriptor": vi.fn(() => ({ + "colorAttachments": [{ "view": {}, "loadOp": "clear", "storeOp": "store" }] + })) + }, + "pipelineManager": { + "getPipeline": vi.fn(() => ({ "label": "mockPipeline" })), + "getFilterPipeline": vi.fn(() => ({ "label": "mockPipeline" })), + "getBindGroupLayout": vi.fn(() => ({ "label": "mockLayout" })) + }, + "textureManager": { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })) + } + } as unknown as IFilterConfig; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + $offset.x = 0; + $offset.y = 0; + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("basic drop shadow execution", () => + { + it("should apply blur filter", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 10, // distance + 45, // angle (degrees) + 0x000000, // color (black) + 1.0, // alpha + 10, // blurX + 10, // blurY + 1.0, // strength + 1, // quality + false, // inner + false, // knockout + false, // hideObject + 1, // devicePixelRatio + config + ); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalled(); + }); + + it("should create uniform buffer with shadow parameters", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 10, + 45, + 0xFF0000, + 0.8, + 10, + 10, + 2.0, + 1, + false, + false, + false, + 1, + config + ); + + expect(config.device.createBuffer).toHaveBeenCalled(); + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + + it("should return result attachment", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + 10, + 45, + 0x000000, + 1.0, + 10, + 10, + 1.0, + 1, + false, + false, + false, + 1, + config + ); + + expect(result).toBeDefined(); + expect(result.texture).toBeDefined(); + }); + }); + + describe("shadow angle calculation", () => + { + it("should calculate shadow position based on angle 0", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 10, + 0, // 0 degrees - shadow to the right + 0x000000, + 1.0, + 10, + 10, + 1.0, + 1, + false, + false, + false, + 1, + config + ); + + // UV変換方式: レンダーパスで直接描画 + expect(config.commandEncoder.beginRenderPass).toHaveBeenCalled(); + }); + + it("should calculate shadow position based on angle 90", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 10, + 90, // 90 degrees - shadow downward + 0x000000, + 1.0, + 10, + 10, + 1.0, + 1, + false, + false, + false, + 1, + config + ); + + expect(config.commandEncoder.beginRenderPass).toHaveBeenCalled(); + }); + + it("should calculate shadow position based on angle 180", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 10, + 180, // 180 degrees - shadow to the left + 0x000000, + 1.0, + 10, + 10, + 1.0, + 1, + false, + false, + false, + 1, + config + ); + + expect(config.commandEncoder.beginRenderPass).toHaveBeenCalled(); + }); + }); + + describe("inner shadow mode", () => + { + it("should use base size for inner shadow", () => + { + const sourceAttachment = createMockAttachment(100, 100); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 10, + 45, + 0x000000, + 1.0, + 10, + 10, + 1.0, + 1, + true, // inner = true + false, + false, + 1, + config + ); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalled(); + }); + + it("should restore offset for inner shadow", () => + { + $offset.x = 5; + $offset.y = 5; + const baseOffsetX = $offset.x; + const baseOffsetY = $offset.y; + + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 10, + 45, + 0x000000, + 1.0, + 10, + 10, + 1.0, + 1, + true, // inner + false, + false, + 1, + config + ); + + expect($offset.x).toBe(baseOffsetX); + expect($offset.y).toBe(baseOffsetY); + }); + }); + + describe("knockout mode", () => + { + it("should pass knockout flag to uniform buffer", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 10, + 45, + 0x000000, + 1.0, + 10, + 10, + 1.0, + 1, + false, + true, // knockout = true + false, + 1, + config + ); + + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + }); + + describe("hideObject mode", () => + { + it("should hide object when hideObject is true", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 10, + 45, + 0x000000, + 1.0, + 10, + 10, + 1.0, + 1, + false, + false, + true, // hideObject = true + 1, + config + ); + + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + }); + + describe("pipeline error handling", () => + { + it("should return source attachment when pipeline not found", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + (config.pipelineManager.getFilterPipeline as ReturnType).mockReturnValue(null); + + const result = execute( + sourceAttachment, + matrix, + 10, + 45, + 0x000000, + 1.0, + 10, + 10, + 1.0, + 1, + false, + false, + false, + 1, + config + ); + + expect(console.error).toHaveBeenCalled(); + expect(result).toBe(sourceAttachment); + }); + }); + + describe("cleanup", () => + { + it("should release temporary attachments after processing", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 10, + 45, + 0x000000, + 1.0, + 10, + 10, + 1.0, + 1, + false, + false, + false, + 1, + config + ); + + // UV変換方式: blurAttachmentのみ解放(コンポジットテクスチャは不要) + expect(config.frameBufferManager.releaseTemporaryAttachment).toHaveBeenCalledTimes(1); + }); + }); + + describe("matrix scale handling", () => + { + it("should apply matrix scale to shadow offset", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([2, 0, 0, 2, 0, 0]); // 2x scale + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 10, + 45, + 0x000000, + 1.0, + 10, + 10, + 1.0, + 1, + false, + false, + false, + 1, + config + ); + + // UV変換方式: レンダーパスで直接描画 + expect(config.commandEncoder.beginRenderPass).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/DropShadowFilter/FilterApplyDropShadowFilterUseCase.ts b/packages/webgpu/src/Filter/DropShadowFilter/FilterApplyDropShadowFilterUseCase.ts new file mode 100644 index 00000000..b6a73dab --- /dev/null +++ b/packages/webgpu/src/Filter/DropShadowFilter/FilterApplyDropShadowFilterUseCase.ts @@ -0,0 +1,232 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { $offset } from "../FilterOffset"; +import { execute as filterApplyBlurFilterUseCase } from "../BlurFilter/FilterApplyBlurFilterUseCase"; + +/** + * @description 度からラジアンへの変換係数 + */ +const DEG_TO_RAD: number = Math.PI / 180; + +/** + * @description プリアロケートされたFloat32Array (サイズ16) + */ +const $uniform16 = new Float32Array(16); + +/** + * @description プリアロケートされたBindGroupEntry配列 (バインディング4つ) + */ +const $entries4: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView }, + { "binding": 3, "resource": null as unknown as GPUTextureView } +]; + +/** + * @description 32bit整数からRGB値を抽出(プリマルチプライドアルファ対応) + */ +const intToRGBA = (color: number, alpha: number): [number, number, number, number] => { + const r = (color >> 16 & 0xFF) / 255 * alpha; + const g = (color >> 8 & 0xFF) / 255 * alpha; + const b = (color & 0xFF) / 255 * alpha; + return [r, g, b, alpha]; +}; + +/** + * @description ドロップシャドウフィルターを適用 + * Apply drop shadow filter + * + * @param {IAttachmentObject} sourceAttachment - 入力テクスチャ + * @param {Float32Array} matrix - 変換行列 + * @param {number} distance - シャドウの距離 + * @param {number} angle - シャドウの角度(度) + * @param {number} color - シャドウ色 (32bit整数) + * @param {number} alpha - アルファ + * @param {number} blurX - X方向ブラー量 + * @param {number} blurY - Y方向ブラー量 + * @param {number} strength - シャドウ強度 + * @param {number} quality - クオリティ + * @param {boolean} inner - インナーシャドウ + * @param {boolean} knockout - ノックアウトモード + * @param {boolean} hideObject - 元オブジェクトを隠す + * @param {number} devicePixelRatio - デバイスピクセル比 + * @param {IDropShadowConfig} config - WebGPUリソース設定 + * @return {IAttachmentObject} - フィルター適用後のアタッチメント + */ +export const execute = ( + sourceAttachment: IAttachmentObject, + matrix: Float32Array, + distance: number, + angle: number, + color: number, + alpha: number, + blurX: number, + blurY: number, + strength: number, + quality: number, + inner: boolean, + knockout: boolean, + hideObject: boolean, + devicePixelRatio: number, + config: IFilterConfig +): IAttachmentObject => { + + const { device, commandEncoder, frameBufferManager, pipelineManager, textureManager } = config; + + // 元のオフセットを保存 + const baseOffsetX = $offset.x; + const baseOffsetY = $offset.y; + const baseWidth = sourceAttachment.width; + const baseHeight = sourceAttachment.height; + + // ブラーフィルターを適用 + const blurAttachment = filterApplyBlurFilterUseCase( + sourceAttachment, matrix, + blurX, blurY, quality, + devicePixelRatio, config + ); + + const blurWidth = blurAttachment.width; + const blurHeight = blurAttachment.height; + const blurOffsetX = $offset.x; + const blurOffsetY = $offset.y; + + const offsetDiffX = blurOffsetX - baseOffsetX; + const offsetDiffY = blurOffsetY - baseOffsetY; + + // 変換行列からスケールを取得 + const xScale = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]); + const yScale = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]); + + // シャドウのオフセットを計算 + const radian = angle * DEG_TO_RAD; + const shadowX = Math.cos(radian) * distance * (xScale / devicePixelRatio); + const shadowY = Math.sin(radian) * distance * (yScale / devicePixelRatio); + + // 出力キャンバスのサイズを計算 + const w = inner ? baseWidth : blurWidth + Math.max(0, Math.abs(shadowX) - offsetDiffX); + const h = inner ? baseHeight : blurHeight + Math.max(0, Math.abs(shadowY) - offsetDiffY); + const width = Math.ceil(w); + const height = Math.ceil(h); + const fractionX = (width - w) / 2; + const fractionY = (height - h) / 2; + + // テクスチャの位置を計算(WebGL版と同じ) + const baseTextureX = inner ? 0 : Math.max(0, offsetDiffX - shadowX) + fractionX; + const baseTextureY = inner ? 0 : Math.max(0, offsetDiffY - shadowY) + fractionY; + const blurTextureX = inner ? shadowX - blurOffsetX : (shadowX > 0 ? Math.max(0, shadowX - offsetDiffX) : 0) + fractionX; + const blurTextureY = inner ? shadowY - blurOffsetY : (shadowY > 0 ? Math.max(0, shadowY - offsetDiffY) : 0) + fractionY; + + // 出力アタッチメントを作成 + const destAttachment = frameBufferManager.createTemporaryAttachment(width, height); + + // タイプとノックアウト状態を決定 + const isInner = inner; + let isKnockout = knockout; + let isHideObject = hideObject; + + if (inner) { + isKnockout = knockout || hideObject; + } else if (!knockout && hideObject) { + // フルモード(シャドウのみ表示) + isKnockout = true; + isHideObject = true; + } + + const pipeline = pipelineManager.getFilterPipeline("drop_shadow_filter", { + "IS_INNER": isInner ? 1 : 0, + "IS_KNOCKOUT": isKnockout ? 1 : 0, + "IS_HIDE_OBJECT": isHideObject ? 1 : 0 + }); + const bindGroupLayout = pipelineManager.getBindGroupLayout("drop_shadow_filter"); + + if (!pipeline || !bindGroupLayout) { + console.error("[WebGPU DropShadowFilter] Pipeline not found"); + frameBufferManager.releaseTemporaryAttachment(blurAttachment); + return sourceAttachment; + } + + // サンプラーを作成 + const sampler = textureManager.createSampler("drop_shadow_sampler", true); + + // ユニフォームバッファを作成 + // color: vec4 (16 bytes) + // baseScale: vec2 (8 bytes) + // baseOffset: vec2 (8 bytes) + // blurScale: vec2 (8 bytes) + // blurOffset: vec2 (8 bytes) + // strength: f32 (4 bytes) + // inner: f32 (4 bytes) + // knockout: f32 (4 bytes) + // hideObject: f32 (4 bytes) + // Total: 64 bytes + const [r, g, b, a] = intToRGBA(color, alpha); + + // WebGL版と同じUV変換方式: + // uv = texCoord * scale - offset + // WebGPU: texCoord.y=0がトップ、テクスチャY=0がトップ(Y-flip補正済み) + // → offset_y = textureY / textureHeight(WebGLのY反転不要) + const baseScaleX = width / baseWidth; + const baseScaleY = height / baseHeight; + const baseOffsetUVX = baseTextureX / baseWidth; + const baseOffsetUVY = baseTextureY / baseHeight; + + const blurScaleX = width / blurWidth; + const blurScaleY = height / blurHeight; + const blurOffsetUVX = blurTextureX / blurWidth; + const blurOffsetUVY = blurTextureY / blurHeight; + + $uniform16[0] = r; + $uniform16[1] = g; + $uniform16[2] = b; + $uniform16[3] = a; + $uniform16[4] = baseScaleX; + $uniform16[5] = baseScaleY; + $uniform16[6] = baseOffsetUVX; + $uniform16[7] = baseOffsetUVY; + $uniform16[8] = blurScaleX; + $uniform16[9] = blurScaleY; + $uniform16[10] = blurOffsetUVX; + $uniform16[11] = blurOffsetUVY; + $uniform16[12] = strength; + $uniform16[13] = isInner ? 1.0 : 0.0; + $uniform16[14] = isKnockout ? 1.0 : 0.0; + $uniform16[15] = isHideObject ? 1.0 : 0.0; + + const uniformBuffer = config.bufferManager + ? config.bufferManager.acquireAndWriteUniformBuffer($uniform16) + : device.createBuffer({ + "size": $uniform16.byteLength, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + if (!config.bufferManager) { + device.queue.writeBuffer(uniformBuffer, 0, $uniform16); + } + + // バインドグループを作成(オリジナルテクスチャを直接使用) + ($entries4[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries4[1].resource = sampler; + $entries4[2].resource = blurAttachment.texture!.view; + $entries4[3].resource = sourceAttachment.texture!.view; + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries4 + }); + + // レンダーパスを実行 + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + destAttachment.texture!.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + // クリーンアップ + frameBufferManager.releaseTemporaryAttachment(blurAttachment); + + return destAttachment; +}; diff --git a/packages/webgpu/src/Filter/DropShadowFilterShader.test.ts b/packages/webgpu/src/Filter/DropShadowFilterShader.test.ts new file mode 100644 index 00000000..9604fb9b --- /dev/null +++ b/packages/webgpu/src/Filter/DropShadowFilterShader.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { DropShadowFilterShader } from "./DropShadowFilterShader"; + +describe("DropShadowFilterShader", () => +{ + describe("getVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = DropShadowFilterShader.getVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = DropShadowFilterShader.getVertexShader(); + + expect(shader).toContain("@vertex"); + }); + + it("should define VertexInput and VertexOutput structs", () => + { + const shader = DropShadowFilterShader.getVertexShader(); + + expect(shader).toContain("struct VertexInput"); + expect(shader).toContain("struct VertexOutput"); + }); + }); + + describe("getFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = DropShadowFilterShader.getFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = DropShadowFilterShader.getFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should define DropShadowUniforms struct", () => + { + const shader = DropShadowFilterShader.getFragmentShader(); + + expect(shader).toContain("struct DropShadowUniforms"); + }); + + it("should include shadow parameters", () => + { + const shader = DropShadowFilterShader.getFragmentShader(); + + expect(shader).toContain("shadowColor"); + expect(shader).toContain("distance"); + expect(shader).toContain("angle"); + expect(shader).toContain("strength"); + }); + + it("should include inner shadow option", () => + { + const shader = DropShadowFilterShader.getFragmentShader(); + + expect(shader).toContain("inner"); + }); + + it("should include knockout option", () => + { + const shader = DropShadowFilterShader.getFragmentShader(); + + expect(shader).toContain("knockout"); + }); + + it("should include hideObject option", () => + { + const shader = DropShadowFilterShader.getFragmentShader(); + + expect(shader).toContain("hideObject"); + }); + + it("should calculate shadow offset using angle", () => + { + const shader = DropShadowFilterShader.getFragmentShader(); + + expect(shader).toContain("cos(radian)"); + expect(shader).toContain("sin(radian)"); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/DropShadowFilterShader.ts b/packages/webgpu/src/Filter/DropShadowFilterShader.ts new file mode 100644 index 00000000..14160fa7 --- /dev/null +++ b/packages/webgpu/src/Filter/DropShadowFilterShader.ts @@ -0,0 +1,97 @@ +export class DropShadowFilterShader +{ + static getFragmentShader(): string + { + return /* wgsl */` + struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, + } + + struct DropShadowUniforms { + shadowColor: vec4, + offset: vec2, + distance: f32, + angle: f32, + strength: f32, + inner: f32, + knockout: f32, + hideObject: f32, + } + + @group(0) @binding(0) var uniforms: DropShadowUniforms; + @group(0) @binding(1) var textureSampler: sampler; + @group(0) @binding(2) var textureData: texture_2d; + + @fragment + fn main(input: VertexOutput) -> @location(0) vec4 { + var originalColor = textureSample(textureData, textureSampler, input.texCoord); + + let radian = uniforms.angle * 3.14159265 / 180.0; + let offsetX = cos(radian) * uniforms.distance / 100.0; + let offsetY = sin(radian) * uniforms.distance / 100.0; + + let shadowCoord = vec2( + input.texCoord.x + offsetX, + input.texCoord.y + offsetY + ); + + var shadowAlpha = textureSample(textureData, textureSampler, shadowCoord).a; + + var shadowColor = vec4( + uniforms.shadowColor.rgb, + shadowAlpha * uniforms.shadowColor.a * uniforms.strength + ); + + if (uniforms.inner > 0.5) { + let alpha = originalColor.a; + shadowColor.a *= alpha; + + if (uniforms.knockout > 0.5) { + return shadowColor; + } else { + return mix(shadowColor, originalColor, alpha); + } + } else { + if (uniforms.hideObject > 0.5) { + return shadowColor * (1.0 - originalColor.a); + } else if (uniforms.knockout > 0.5) { + return shadowColor; + } else { + let combinedAlpha = originalColor.a + shadowColor.a * (1.0 - originalColor.a); + if (combinedAlpha > 0.0) { + let rgb = (originalColor.rgb * originalColor.a + + shadowColor.rgb * shadowColor.a * (1.0 - originalColor.a)) / combinedAlpha; + return vec4(rgb, combinedAlpha); + } else { + return vec4(0.0); + } + } + } + } + `; + } + + static getVertexShader(): string + { + return /* wgsl */` + struct VertexInput { + @location(0) position: vec2, + @location(1) texCoord: vec2, + } + + struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, + } + + @vertex + fn main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + output.position = vec4(input.position, 0.0, 1.0); + output.texCoord = input.texCoord; + return output; + } + `; + } +} diff --git a/packages/webgpu/src/Filter/FilterGradientLUTCache.test.ts b/packages/webgpu/src/Filter/FilterGradientLUTCache.test.ts new file mode 100644 index 00000000..e49d63ae --- /dev/null +++ b/packages/webgpu/src/Filter/FilterGradientLUTCache.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + $setFilterGradientLUTDevice, + $getFilterGradientAttachmentObject, + $clearFilterGradientAttachment +} from "./FilterGradientLUTCache"; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + TEXTURE_BINDING: 0x04, + COPY_DST: 0x02, + RENDER_ATTACHMENT: 0x10 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +describe("FilterGradientLUTCache", () => +{ + const createMockDevice = () => + { + const mockTexture = { + "createView": vi.fn(() => ({ "label": "mockView" })), + "destroy": vi.fn() + }; + return { + "createTexture": vi.fn(() => mockTexture), + "_mockTexture": mockTexture + } as unknown as GPUDevice & { _mockTexture: any }; + }; + + beforeEach(() => + { + // Clear cache before each test + $clearFilterGradientAttachment(); + }); + + describe("$setFilterGradientLUTDevice", () => + { + it("should set the device for texture creation", () => + { + const device = createMockDevice(); + $setFilterGradientLUTDevice(device); + + // Getting an attachment should now use the device + const attachment = $getFilterGradientAttachmentObject(); + + expect(device.createTexture).toHaveBeenCalled(); + expect(attachment).toBeDefined(); + }); + }); + + describe("$getFilterGradientAttachmentObject", () => + { + it("should create attachment with 256x1 dimensions", () => + { + const device = createMockDevice(); + $setFilterGradientLUTDevice(device); + + const attachment = $getFilterGradientAttachmentObject(); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "size": { "width": 256, "height": 1 }, + "format": "rgba8unorm" + }) + ); + expect(attachment.width).toBe(256); + expect(attachment.height).toBe(1); + }); + + it("should cache attachment and return same instance", () => + { + const device = createMockDevice(); + $setFilterGradientLUTDevice(device); + + const attachment1 = $getFilterGradientAttachmentObject(); + const attachment2 = $getFilterGradientAttachmentObject(); + + // Should return the same attachment + expect(attachment1).toBe(attachment2); + // Should only create texture once + expect(device.createTexture).toHaveBeenCalledTimes(1); + }); + + it("should set correct attachment properties", () => + { + const device = createMockDevice(); + $setFilterGradientLUTDevice(device); + + const attachment = $getFilterGradientAttachmentObject(); + + expect(attachment.id).toBe(-256); // Negative ID for filter + expect(attachment.width).toBe(256); + expect(attachment.height).toBe(1); + expect(attachment.clipLevel).toBe(0); + expect(attachment.msaa).toBe(false); + expect(attachment.mask).toBe(false); + expect(attachment.color).toBe(null); + expect(attachment.texture).toBeDefined(); + expect(attachment.stencil).toBe(null); + expect(attachment.msaaTexture).toBe(null); + expect(attachment.msaaStencil).toBe(null); + }); + + it("should set correct texture properties", () => + { + const device = createMockDevice(); + $setFilterGradientLUTDevice(device); + + const attachment = $getFilterGradientAttachmentObject(); + + expect(attachment.texture!.id).toBe(-256); + expect(attachment.texture!.width).toBe(256); + expect(attachment.texture!.height).toBe(1); + expect(attachment.texture!.area).toBe(256); + expect(attachment.texture!.smooth).toBe(true); + }); + }); + + describe("$clearFilterGradientAttachment", () => + { + it("should destroy cached texture", () => + { + const device = createMockDevice(); + $setFilterGradientLUTDevice(device); + + // Create an attachment + $getFilterGradientAttachmentObject(); + + // Clear should destroy texture + $clearFilterGradientAttachment(); + + expect(device._mockTexture.destroy).toHaveBeenCalled(); + }); + + it("should clear cache so new attachment is created on next call", () => + { + const device = createMockDevice(); + $setFilterGradientLUTDevice(device); + + $getFilterGradientAttachmentObject(); + expect(device.createTexture).toHaveBeenCalledTimes(1); + + $clearFilterGradientAttachment(); + + $getFilterGradientAttachmentObject(); + expect(device.createTexture).toHaveBeenCalledTimes(2); + }); + + it("should not throw when attachment is null", () => + { + expect(() => $clearFilterGradientAttachment()).not.toThrow(); + }); + }); + + describe("texture usage flags", () => + { + it("should create texture with correct usage flags", () => + { + const device = createMockDevice(); + $setFilterGradientLUTDevice(device); + + $getFilterGradientAttachmentObject(); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "usage": GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT + }) + ); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/FilterGradientLUTCache.ts b/packages/webgpu/src/Filter/FilterGradientLUTCache.ts new file mode 100644 index 00000000..f2fd19d7 --- /dev/null +++ b/packages/webgpu/src/Filter/FilterGradientLUTCache.ts @@ -0,0 +1,96 @@ +import type { IAttachmentObject } from "../interface/IAttachmentObject"; + +/** + * @description フィルター用グラデーションLUTの共有アタッチメント + * Shared attachment for filter gradient LUT + * 注意: グラデーションLUTは共有テクスチャに描画されるため、 + * キャッシュは使用しません。各フレームで再描画が必要です。 + * Note: Gradient LUT is drawn to a shared texture, so caching + * is not used. Re-drawing is required each frame. + * + * @type {IAttachmentObject | null} + * @private + */ +let $filterGradientAttachment: IAttachmentObject | null = null; + +/** + * @description GPUDeviceの参照 + * @private + */ +let $device: GPUDevice | null = null; + +/** + * @description GPUDeviceを設定 + * Set GPUDevice + * + * @param {GPUDevice} device + * @return {void} + * @method + * @protected + */ +export const $setFilterGradientLUTDevice = (device: GPUDevice): void => +{ + $device = device; +}; + +/** + * @description フィルター用グラデーションLUTのAttachmentObjectを返却 + * Returns AttachmentObject for filter gradient LUT + * + * @return {IAttachmentObject} + * @method + * @protected + */ +export const $getFilterGradientAttachmentObject = (): IAttachmentObject => +{ + if (!$filterGradientAttachment && $device) { + const resolution = 256; + + // 1xN テクスチャを作成 + const texture = $device.createTexture({ + "size": { "width": resolution, "height": 1 }, + "format": "rgba8unorm", + "usage": GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT + }); + + $filterGradientAttachment = { + "id": -256, // フィルター用に負のIDを使用 + "width": resolution, + "height": 1, + "clipLevel": 0, + "msaa": false, + "mask": false, + "color": null, + "texture": { + "id": -256, + "resource": texture, + "view": texture.createView(), + "width": resolution, + "height": 1, + "area": resolution, + "smooth": true + }, + "stencil": null, + "msaaTexture": null, + "msaaStencil": null + }; + } + + return $filterGradientAttachment as NonNullable; +}; + +/** + * @description フィルター用グラデーションLUTの共有アタッチメントを破棄してクリア + * Destroy and clear filter gradient LUT shared attachment + * + * @return {void} + * @method + * @protected + */ +export const $clearFilterGradientAttachment = (): void => +{ + if ($filterGradientAttachment?.texture?.resource) { + $filterGradientAttachment.texture.resource.destroy(); + } + $filterGradientAttachment = null; +}; diff --git a/packages/webgpu/src/Filter/FilterOffset.ts b/packages/webgpu/src/Filter/FilterOffset.ts new file mode 100644 index 00000000..9c464a87 --- /dev/null +++ b/packages/webgpu/src/Filter/FilterOffset.ts @@ -0,0 +1,12 @@ +import type { IPoint } from "../interface/IPoint"; + +/** + * @description フィルター処理のオフセット値 + * Offset values for filter processing + * @type {IPoint} + * @protected + */ +export const $offset: IPoint = { + "x": 0, + "y": 0 +}; diff --git a/packages/webgpu/src/Filter/GlowFilter/FilterApplyGlowFilterUseCase.test.ts b/packages/webgpu/src/Filter/GlowFilter/FilterApplyGlowFilterUseCase.test.ts new file mode 100644 index 00000000..e00ef89f --- /dev/null +++ b/packages/webgpu/src/Filter/GlowFilter/FilterApplyGlowFilterUseCase.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { execute } from "./FilterApplyGlowFilterUseCase"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + UNIFORM: 0x40, + COPY_DST: 0x08 +}; +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +// Mock offset +vi.mock("../index", () => ({ + "$offset": { "x": 0, "y": 0 } +})); + +import { $offset } from "../FilterOffset"; + +// Mock FilterApplyBlurFilterUseCase +vi.mock("../BlurFilter/FilterApplyBlurFilterUseCase", () => ({ + "execute": vi.fn((source: IAttachmentObject) => ({ + ...source, + "width": source.width + 40, + "height": source.height + 40 + })) +})); + +describe("FilterApplyGlowFilterUseCase", () => +{ + const createMockAttachment = (width: number = 100, height: number = 100): IAttachmentObject => + { + return { + "id": 1, + "width": width, + "height": height, + "clipLevel": 0, + "texture": { + "resource": { "label": "mockTexture" } as unknown as GPUTexture, + "view": { "label": "mockTextureView" } as unknown as GPUTextureView + } + } as IAttachmentObject; + }; + + const createMockConfig = (): IFilterConfig => + { + const mockPassEncoder = { + "setPipeline": vi.fn(), + "setBindGroup": vi.fn(), + "draw": vi.fn(), + "end": vi.fn() + }; + + return { + "device": { + "createBuffer": vi.fn(() => ({ "label": "mockBuffer" })), + "queue": { "writeBuffer": vi.fn() }, + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })) + } as unknown as GPUDevice, + "commandEncoder": { + "beginRenderPass": vi.fn(() => mockPassEncoder), + "copyTextureToTexture": vi.fn() + } as unknown as GPUCommandEncoder, + "frameBufferManager": { + "createTemporaryAttachment": vi.fn((w: number, h: number) => createMockAttachment(w, h)), + "releaseTemporaryAttachment": vi.fn(), + "createRenderPassDescriptor": vi.fn(() => ({ + "colorAttachments": [{ "view": {}, "loadOp": "clear", "storeOp": "store" }] + })) + }, + "pipelineManager": { + "getPipeline": vi.fn(() => ({ "label": "mockPipeline" })), + "getFilterPipeline": vi.fn(() => ({ "label": "mockPipeline" })), + "getBindGroupLayout": vi.fn(() => ({ "label": "mockLayout" })) + }, + "textureManager": { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })) + } + } as unknown as IFilterConfig; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + $offset.x = 0; + $offset.y = 0; + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("basic glow execution", () => + { + it("should apply blur filter first", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 0xFF0000, // red color + 1.0, // alpha + 10, // blurX + 10, // blurY + 1.0, // strength + 1, // quality + false, // inner + false, // knockout + 1, // devicePixelRatio + config + ); + + // Blur filter should have been applied (via mock) + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalled(); + }); + + it("should create uniform buffer with color data", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 0xFF0000, // red color + 0.5, // alpha + 10, + 10, + 1.0, + 1, + false, + false, + 1, + config + ); + + expect(config.device.createBuffer).toHaveBeenCalled(); + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + + it("should return result attachment", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + 0xFF0000, + 1.0, + 10, + 10, + 1.0, + 1, + false, + false, + 1, + config + ); + + expect(result).toBeDefined(); + expect(result.texture).toBeDefined(); + }); + }); + + describe("inner glow mode", () => + { + it("should use base size for inner glow", () => + { + const sourceAttachment = createMockAttachment(100, 100); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 0xFF0000, + 1.0, + 10, + 10, + 1.0, + 1, + true, // inner = true + false, + 1, + config + ); + + // For inner glow, output should match base size + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalled(); + }); + + it("should restore offset for inner glow", () => + { + $offset.x = 5; + $offset.y = 5; + const baseOffsetX = $offset.x; + const baseOffsetY = $offset.y; + + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 0xFF0000, + 1.0, + 10, + 10, + 1.0, + 1, + true, // inner + false, + 1, + config + ); + + expect($offset.x).toBe(baseOffsetX); + expect($offset.y).toBe(baseOffsetY); + }); + }); + + describe("knockout mode", () => + { + it("should pass knockout flag to uniform buffer", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 0xFF0000, + 1.0, + 10, + 10, + 1.0, + 1, + false, + true, // knockout = true + 1, + config + ); + + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + }); + + describe("pipeline error handling", () => + { + it("should return source attachment when pipeline not found", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + (config.pipelineManager.getFilterPipeline as ReturnType).mockReturnValue(null); + + const result = execute( + sourceAttachment, + matrix, + 0xFF0000, + 1.0, + 10, + 10, + 1.0, + 1, + false, + false, + 1, + config + ); + + expect(console.error).toHaveBeenCalled(); + expect(result).toBe(sourceAttachment); + }); + }); + + describe("cleanup", () => + { + it("should release temporary attachments after processing", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 0xFF0000, + 1.0, + 10, + 10, + 1.0, + 1, + false, + false, + 1, + config + ); + + // Should release blur attachment (UV変換方式により一時テクスチャ不要) + expect(config.frameBufferManager.releaseTemporaryAttachment).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/GlowFilter/FilterApplyGlowFilterUseCase.ts b/packages/webgpu/src/Filter/GlowFilter/FilterApplyGlowFilterUseCase.ts new file mode 100644 index 00000000..fb27e5f6 --- /dev/null +++ b/packages/webgpu/src/Filter/GlowFilter/FilterApplyGlowFilterUseCase.ts @@ -0,0 +1,188 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { $offset } from "../FilterOffset"; +import { execute as filterApplyBlurFilterUseCase } from "../BlurFilter/FilterApplyBlurFilterUseCase"; + +/** + * @description プリアロケートされたFloat32Array (サイズ16) + */ +const $uniform16 = new Float32Array(16); + +/** + * @description プリアロケートされたBindGroupEntry配列 (バインディング4つ) + */ +const $entries4: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView }, + { "binding": 3, "resource": null as unknown as GPUTextureView } +]; + +/** + * @description 32bit整数からRGB値を抽出(プリマルチプライドアルファ対応) + */ +const intToRGBA = (color: number, alpha: number): [number, number, number, number] => { + const r = (color >> 16 & 0xFF) / 255 * alpha; + const g = (color >> 8 & 0xFF) / 255 * alpha; + const b = (color & 0xFF) / 255 * alpha; + return [r, g, b, alpha]; +}; + +/** + * @description グローフィルターを適用 + * Apply glow filter + * + * UV変換方式で元テクスチャとブラーテクスチャを直接サンプリング。 + * copyTextureToTextureと一時テクスチャを使用しない最適化版。 + * + * @param {IAttachmentObject} sourceAttachment - 入力テクスチャ + * @param {Float32Array} matrix - 変換行列 + * @param {number} color - グロー色 (32bit整数) + * @param {number} alpha - アルファ + * @param {number} blurX - X方向ブラー量 + * @param {number} blurY - Y方向ブラー量 + * @param {number} strength - グロー強度 + * @param {number} quality - クオリティ + * @param {boolean} inner - インナーグロー + * @param {boolean} knockout - ノックアウトモード + * @param {number} devicePixelRatio - デバイスピクセル比 + * @param {IFilterConfig} config - WebGPUリソース設定 + * @return {IAttachmentObject} - フィルター適用後のアタッチメント + */ +export const execute = ( + sourceAttachment: IAttachmentObject, + matrix: Float32Array, + color: number, + alpha: number, + blurX: number, + blurY: number, + strength: number, + quality: number, + inner: boolean, + knockout: boolean, + devicePixelRatio: number, + config: IFilterConfig +): IAttachmentObject => { + + const { device, commandEncoder, frameBufferManager, pipelineManager, textureManager } = config; + + // 元のオフセットを保存 + const baseOffsetX = $offset.x; + const baseOffsetY = $offset.y; + const baseWidth = sourceAttachment.width; + const baseHeight = sourceAttachment.height; + + // ブラーフィルターを適用(元テクスチャを保持) + const blurAttachment = filterApplyBlurFilterUseCase( + sourceAttachment, matrix, + blurX, blurY, quality, + devicePixelRatio, config + ); + + const blurWidth = blurAttachment.width; + const blurHeight = blurAttachment.height; + const blurOffsetX = $offset.x; + const blurOffsetY = $offset.y; + + // 出力サイズを決定 + const width = inner ? baseWidth : blurWidth; + const height = inner ? baseHeight : blurHeight; + + // オフセット差分を計算 + const offsetDiffX = blurOffsetX - baseOffsetX; + const offsetDiffY = blurOffsetY - baseOffsetY; + + // UV変換パラメータ計算(GradientGlowFilterと同じパターン) + const baseTextureX = inner ? 0 : offsetDiffX; + const baseTextureY = inner ? 0 : offsetDiffY; + const blurTextureX = inner ? -offsetDiffX : 0; + const blurTextureY = inner ? -offsetDiffY : 0; + + const baseScaleX = width / baseWidth; + const baseScaleY = height / baseHeight; + const baseOffsetUVX = baseTextureX / baseWidth; + const baseOffsetUVY = baseTextureY / baseHeight; + + const blurScaleX = width / blurWidth; + const blurScaleY = height / blurHeight; + const blurOffsetUVX = blurTextureX / blurWidth; + const blurOffsetUVY = blurTextureY / blurHeight; + + // 出力アタッチメントを作成 + const destAttachment = frameBufferManager.createTemporaryAttachment(width, height); + + const pipeline = pipelineManager.getFilterPipeline("glow_filter", { + "IS_INNER": inner ? 1 : 0, + "IS_KNOCKOUT": knockout ? 1 : 0 + }); + const bindGroupLayout = pipelineManager.getBindGroupLayout("glow_filter"); + + if (!pipeline || !bindGroupLayout) { + console.error("[WebGPU GlowFilter] Pipeline not found"); + frameBufferManager.releaseTemporaryAttachment(blurAttachment); + return sourceAttachment; + } + + // サンプラーを作成 + const sampler = textureManager.createSampler("glow_sampler", true); + + // ユニフォームバッファを作成 + // color: vec4 (16 bytes) + // baseScale: vec2, baseOffset: vec2 (16 bytes) + // blurScale: vec2, blurOffset: vec2 (16 bytes) + // strength: f32, inner: f32, knockout: f32, _padding: f32 (16 bytes) + // Total: 64 bytes + const [r, g, b, a] = intToRGBA(color, alpha); + $uniform16[0] = r; + $uniform16[1] = g; + $uniform16[2] = b; + $uniform16[3] = a; + $uniform16[4] = baseScaleX; + $uniform16[5] = baseScaleY; + $uniform16[6] = baseOffsetUVX; + $uniform16[7] = baseOffsetUVY; + $uniform16[8] = blurScaleX; + $uniform16[9] = blurScaleY; + $uniform16[10] = blurOffsetUVX; + $uniform16[11] = blurOffsetUVY; + $uniform16[12] = strength; + $uniform16[13] = inner ? 1.0 : 0.0; + $uniform16[14] = knockout ? 1.0 : 0.0; + $uniform16[15] = 0.0; + + const uniformBuffer = config.bufferManager + ? config.bufferManager.acquireAndWriteUniformBuffer($uniform16) + : device.createBuffer({ + "size": $uniform16.byteLength, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + if (!config.bufferManager) { + device.queue.writeBuffer(uniformBuffer, 0, $uniform16); + } + + // バインドグループを作成(元テクスチャとブラーテクスチャを直接バインド) + ($entries4[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries4[1].resource = sampler; + $entries4[2].resource = blurAttachment.texture!.view; + $entries4[3].resource = sourceAttachment.texture!.view; + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries4 + }); + + // レンダーパスを実行 + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + destAttachment.texture!.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + // クリーンアップ + frameBufferManager.releaseTemporaryAttachment(blurAttachment); + + return destAttachment; +}; diff --git a/packages/webgpu/src/Filter/GlowFilterShader.test.ts b/packages/webgpu/src/Filter/GlowFilterShader.test.ts new file mode 100644 index 00000000..92067859 --- /dev/null +++ b/packages/webgpu/src/Filter/GlowFilterShader.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { GlowFilterShader } from "./GlowFilterShader"; + +describe("GlowFilterShader", () => +{ + describe("getVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = GlowFilterShader.getVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = GlowFilterShader.getVertexShader(); + + expect(shader).toContain("@vertex"); + }); + + it("should define VertexInput struct", () => + { + const shader = GlowFilterShader.getVertexShader(); + + expect(shader).toContain("struct VertexInput"); + }); + + it("should define VertexOutput struct", () => + { + const shader = GlowFilterShader.getVertexShader(); + + expect(shader).toContain("struct VertexOutput"); + }); + }); + + describe("getFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = GlowFilterShader.getFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = GlowFilterShader.getFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should define GlowUniforms struct", () => + { + const shader = GlowFilterShader.getFragmentShader(); + + expect(shader).toContain("struct GlowUniforms"); + }); + + it("should include glow parameters", () => + { + const shader = GlowFilterShader.getFragmentShader(); + + expect(shader).toContain("glowColor"); + expect(shader).toContain("strength"); + expect(shader).toContain("inner"); + expect(shader).toContain("knockout"); + }); + + it("should handle inner glow mode", () => + { + const shader = GlowFilterShader.getFragmentShader(); + + expect(shader).toContain("uniforms.inner"); + }); + + it("should handle knockout mode", () => + { + const shader = GlowFilterShader.getFragmentShader(); + + expect(shader).toContain("uniforms.knockout"); + }); + + it("should include texture sampling", () => + { + const shader = GlowFilterShader.getFragmentShader(); + + expect(shader).toContain("textureSample"); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/GlowFilterShader.ts b/packages/webgpu/src/Filter/GlowFilterShader.ts new file mode 100644 index 00000000..6a19f62a --- /dev/null +++ b/packages/webgpu/src/Filter/GlowFilterShader.ts @@ -0,0 +1,70 @@ +export class GlowFilterShader +{ + static getFragmentShader(): string + { + return /* wgsl */` + struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, + } + + struct GlowUniforms { + glowColor: vec4, + strength: f32, + inner: f32, + knockout: f32, + _padding: f32, + } + + @group(0) @binding(0) var uniforms: GlowUniforms; + @group(0) @binding(1) var textureSampler: sampler; + @group(0) @binding(2) var textureData: texture_2d; + + @fragment + fn main(input: VertexOutput) -> @location(0) vec4 { + var originalColor = textureSample(textureData, textureSampler, input.texCoord); + + let alpha = originalColor.a; + + var glowColor = uniforms.glowColor * uniforms.strength * alpha; + + if (uniforms.inner > 0.5) { + if (uniforms.knockout > 0.5) { + return glowColor; + } else { + return mix(originalColor, glowColor, alpha); + } + } else { + if (uniforms.knockout > 0.5) { + return vec4(glowColor.rgb, glowColor.a * (1.0 - alpha)); + } else { + return originalColor + glowColor; + } + } + } + `; + } + + static getVertexShader(): string + { + return /* wgsl */` + struct VertexInput { + @location(0) position: vec2, + @location(1) texCoord: vec2, + } + + struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, + } + + @vertex + fn main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + output.position = vec4(input.position, 0.0, 1.0); + output.texCoord = input.texCoord; + return output; + } + `; + } +} diff --git a/packages/webgpu/src/Filter/GradientBevelFilter/FilterApplyGradientBevelFilterUseCase.test.ts b/packages/webgpu/src/Filter/GradientBevelFilter/FilterApplyGradientBevelFilterUseCase.test.ts new file mode 100644 index 00000000..b18850cd --- /dev/null +++ b/packages/webgpu/src/Filter/GradientBevelFilter/FilterApplyGradientBevelFilterUseCase.test.ts @@ -0,0 +1,438 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { execute } from "./FilterApplyGradientBevelFilterUseCase"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + UNIFORM: 0x40, + COPY_DST: 0x08 +}; +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + TEXTURE_BINDING: 0x04, + COPY_DST: 0x08 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +// Mock offset +vi.mock("../index", () => ({ + "$offset": { "x": 0, "y": 0 } +})); + +import { $offset } from "../FilterOffset"; + +// Mock FilterApplyBlurFilterUseCase +vi.mock("../BlurFilter/FilterApplyBlurFilterUseCase", () => ({ + "execute": vi.fn((source: IAttachmentObject) => ({ + ...source, + "width": source.width + 40, + "height": source.height + 40 + })) +})); + +// Mock GradientLUTGenerator +vi.mock("../../Gradient/GradientLUTGenerator", () => ({ + "generateFilterGradientLUT": vi.fn(() => new Uint8Array(256 * 4)) +})); + +// Note: FilterGradientLUTCache is no longer used (per-invocation LUT textures instead) + +describe("FilterApplyGradientBevelFilterUseCase", () => +{ + const createMockAttachment = (width: number = 100, height: number = 100): IAttachmentObject => + { + return { + "id": 1, + "width": width, + "height": height, + "clipLevel": 0, + "texture": { + "resource": { "label": "mockTexture" } as unknown as GPUTexture, + "view": { "label": "mockTextureView" } as unknown as GPUTextureView + } + } as IAttachmentObject; + }; + + const createMockConfig = (): IFilterConfig => + { + const mockPassEncoder = { + "setPipeline": vi.fn(), + "setBindGroup": vi.fn(), + "draw": vi.fn(), + "end": vi.fn() + }; + + return { + "device": { + "createBuffer": vi.fn(() => ({ "label": "mockBuffer" })), + "createTexture": vi.fn(() => ({ + "label": "mockLUTTexture", + "createView": vi.fn(() => ({ "label": "mockLUTTextureView" })), + "destroy": vi.fn() + })), + "queue": { + "writeBuffer": vi.fn(), + "writeTexture": vi.fn() + }, + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })) + } as unknown as GPUDevice, + "commandEncoder": { + "beginRenderPass": vi.fn(() => mockPassEncoder), + "copyTextureToTexture": vi.fn() + } as unknown as GPUCommandEncoder, + "frameBufferManager": { + "createTemporaryAttachment": vi.fn((w: number, h: number) => createMockAttachment(w, h)), + "releaseTemporaryAttachment": vi.fn(), + "createRenderPassDescriptor": vi.fn(() => ({ + "colorAttachments": [{ "view": {}, "loadOp": "clear", "storeOp": "store" }] + })) + }, + "pipelineManager": { + "getPipeline": vi.fn(() => ({ "label": "mockPipeline" })), + "getFilterPipeline": vi.fn(() => ({ "label": "mockPipeline" })), + "getBindGroupLayout": vi.fn(() => ({ "label": "mockLayout" })) + }, + "textureManager": { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })) + }, + "frameTextures": [] + } as unknown as IFilterConfig; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + $offset.x = 0; + $offset.y = 0; + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("basic gradient bevel execution", () => + { + it("should apply blur filter", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0xFFFFFF, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0, 1.0]); + const ratios = new Float32Array([0, 128, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, // distance + 45, // angle (degrees) + colors, + alphas, + ratios, + 10, // blurX + 10, // blurY + 1.0, // strength + 1, // quality + 0, // type (full) + false, // knockout + 1, // devicePixelRatio + config + ); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalled(); + }); + + it("should write gradient LUT to texture", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(config.device.queue.writeTexture).toHaveBeenCalled(); + }); + + it("should create uniform buffer with bevel parameters", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 2.0, 1, 0, false, 1, + config + ); + + expect(config.device.createBuffer).toHaveBeenCalled(); + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + + it("should use render passes for compositing", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + // UV変換方式: bevel_baseパス + 最終合成パス + expect(config.commandEncoder.beginRenderPass).toHaveBeenCalled(); + }); + + it("should return result attachment", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(result).toBeDefined(); + expect(result.texture).toBeDefined(); + }); + }); + + describe("bevel type modes", () => + { + it("should handle full bevel type (type 0)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, + 0, // full + false, 1, + config + ); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalled(); + }); + + it("should handle inner bevel type (type 1)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, + 1, // inner + false, 1, + config + ); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalled(); + }); + + it("should handle outer bevel type (type 2)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, + 2, // outer + false, 1, + config + ); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalled(); + }); + }); + + describe("knockout mode", () => + { + it("should pass knockout flag to uniform buffer", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, + true, // knockout = true + 1, + config + ); + + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + }); + + describe("pipeline error handling", () => + { + it("should return source attachment when pipeline not found", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + (config.pipelineManager.getFilterPipeline as ReturnType).mockReturnValue(null); + + const result = execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(console.error).toHaveBeenCalledWith("[WebGPU GradientBevelFilter] Pipeline not found"); + expect(result).toBe(sourceAttachment); + }); + }); + + describe("cleanup", () => + { + it("should release temporary attachments after processing", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + // bevelBaseAttachment + blurAttachment の2つを解放 + expect(config.frameBufferManager.releaseTemporaryAttachment).toHaveBeenCalledTimes(2); + }); + }); + + describe("gradient colors", () => + { + it("should handle multi-color gradient", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00]); + const alphas = new Float32Array([1.0, 1.0, 1.0, 1.0]); + const ratios = new Float32Array([0, 85, 170, 255]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(result).toBeDefined(); + }); + + it("should handle gradient with varying alphas", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFFFFFF, 0x000000]); + const alphas = new Float32Array([1.0, 0.5]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(result).toBeDefined(); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/GradientBevelFilter/FilterApplyGradientBevelFilterUseCase.ts b/packages/webgpu/src/Filter/GradientBevelFilter/FilterApplyGradientBevelFilterUseCase.ts new file mode 100644 index 00000000..4096a0b5 --- /dev/null +++ b/packages/webgpu/src/Filter/GradientBevelFilter/FilterApplyGradientBevelFilterUseCase.ts @@ -0,0 +1,286 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { $offset } from "../FilterOffset"; +import { execute as filterApplyBlurFilterUseCase } from "../BlurFilter/FilterApplyBlurFilterUseCase"; +import { generateFilterGradientLUT } from "../../Gradient/GradientLUTGenerator"; + +/** + * @description 度からラジアンへの変換係数 + */ +const DEG_TO_RAD: number = Math.PI / 180; + +/** + * @description プリアロケートされたFloat32Array + */ +const $uniform4 = new Float32Array(4); +const $uniform12 = new Float32Array(12); + +/** + * @description プリアロケートされたBindGroupEntry配列 (バインディング3つ) + */ +const $entries3: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView } +]; + +/** + * @description プリアロケートされたBindGroupEntry配列 (バインディング5つ) + */ +const $entries5: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView }, + { "binding": 3, "resource": null as unknown as GPUTextureView }, + { "binding": 4, "resource": null as unknown as GPUTextureView } +]; + +/** + * @description グラデーションベベルフィルターを適用 + * Apply gradient bevel filter + * + * WebGL版と同じフロー: + * 1. ベベルベーステクスチャ作成: original * (1 - shifted.a) + * 2. ベベルベースにブラー適用 + * 3. UV変換方式で最終合成(isInsideでハード境界クリッピング) + * + * @param {IAttachmentObject} sourceAttachment - 入力テクスチャ + * @param {Float32Array} matrix - 変換行列 + * @param {number} distance - ベベルの距離 + * @param {number} angle - ベベルの角度(度) + * @param {Float32Array} colors - 色配列 + * @param {Float32Array} alphas - アルファ配列 + * @param {Float32Array} ratios - 比率配列 + * @param {number} blurX - X方向ブラー量 + * @param {number} blurY - Y方向ブラー量 + * @param {number} strength - ベベル強度 + * @param {number} quality - クオリティ + * @param {number} type - タイプ (0: full, 1: inner, 2: outer) + * @param {boolean} knockout - ノックアウトモード + * @param {number} devicePixelRatio - デバイスピクセル比 + * @param {IGradientBevelConfig} config - WebGPUリソース設定 + * @return {IAttachmentObject} - フィルター適用後のアタッチメント + */ +export const execute = ( + sourceAttachment: IAttachmentObject, + matrix: Float32Array, + distance: number, + angle: number, + colors: Float32Array, + alphas: Float32Array, + ratios: Float32Array, + blurX: number, + blurY: number, + strength: number, + quality: number, + type: number, + knockout: boolean, + devicePixelRatio: number, + config: IFilterConfig +): IAttachmentObject => { + + const { device, commandEncoder, frameBufferManager, pipelineManager, textureManager } = config; + + // 元のオフセットを保存 + const baseOffsetX = $offset.x; + const baseOffsetY = $offset.y; + const baseWidth = sourceAttachment.width; + const baseHeight = sourceAttachment.height; + + // 変換行列からスケールを取得 + const xScale = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]); + const yScale = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]); + + // ベベルのオフセットを計算 + const radian = angle * DEG_TO_RAD; + const x = Math.cos(radian) * distance * (xScale / devicePixelRatio); + const y = Math.sin(radian) * distance * (yScale / devicePixelRatio); + + // ===== Step 1: ベベルベーステクスチャ作成 ===== + // WebGL版と同じ: original * (1 - shifted_original.a) + // shifted = original を (2x, 2y) ピクセル分シフトしたもの + const bevelBasePipeline = pipelineManager.getPipeline("bevel_base"); + const bevelBaseLayout = pipelineManager.getBindGroupLayout("bevel_base"); + + if (!bevelBasePipeline || !bevelBaseLayout) { + console.error("[WebGPU GradientBevelFilter] bevel_base pipeline not found"); + return sourceAttachment; + } + + const bevelBaseAttachment = frameBufferManager.createTemporaryAttachment(baseWidth, baseHeight); + const bevelBaseSampler = textureManager.createSampler("bevel_base_sampler", true); + + // UV空間でのオフセット: (2x / baseWidth, 2y / baseHeight) + $uniform4[0] = 2 * x / baseWidth; + $uniform4[1] = 2 * y / baseHeight; + $uniform4[2] = 0.0; + $uniform4[3] = 0.0; + + const bevelBaseUniformBuffer = config.bufferManager + ? config.bufferManager.acquireAndWriteUniformBuffer($uniform4) + : device.createBuffer({ + "size": $uniform4.byteLength, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + if (!config.bufferManager) { + device.queue.writeBuffer(bevelBaseUniformBuffer, 0, $uniform4); + } + + ($entries3[0].resource as GPUBufferBinding).buffer = bevelBaseUniformBuffer; + $entries3[1].resource = bevelBaseSampler; + $entries3[2].resource = sourceAttachment.texture!.view; + const bevelBaseBindGroup = device.createBindGroup({ + "layout": bevelBaseLayout, + "entries": $entries3 + }); + + const bevelBaseRenderPass = frameBufferManager.createRenderPassDescriptor( + bevelBaseAttachment.texture!.view, 0, 0, 0, 0, "clear" + ); + + const bevelBaseEncoder = commandEncoder.beginRenderPass(bevelBaseRenderPass); + bevelBaseEncoder.setPipeline(bevelBasePipeline); + bevelBaseEncoder.setBindGroup(0, bevelBaseBindGroup); + bevelBaseEncoder.draw(6, 1, 0, 0); + bevelBaseEncoder.end(); + + // ===== Step 2: ベベルベースにブラー適用 ===== + // WebGL版と同じ: bevelBaseをブラーする(元テクスチャではなく) + const blurAttachment = filterApplyBlurFilterUseCase( + bevelBaseAttachment, matrix, + blurX, blurY, quality, + devicePixelRatio, config + ); + + // ベベルベースは不要になったので解放 + frameBufferManager.releaseTemporaryAttachment(bevelBaseAttachment); + + const blurWidth = blurAttachment.width; + const blurHeight = blurAttachment.height; + + // ===== Step 3: WebGL版と同じサイズ・位置計算 ===== + const isInner = type === 1; + const absX = Math.abs(x); + const absY = Math.abs(y); + const blurOffsetX = (blurWidth - baseWidth) / 2; + const blurOffsetY = (blurHeight - baseHeight) / 2; + + // WebGL版と同じ: bevelWidth/bevelHeight + const bevelWidth = Math.ceil(blurWidth + absX * 2); + const bevelHeight = Math.ceil(blurHeight + absY * 2); + + const width = isInner ? baseWidth : bevelWidth; + const height = isInner ? baseHeight : bevelHeight; + + // WebGL版と同じテクスチャ位置計算 + const baseTextureX = isInner ? 0 : absX + blurOffsetX; + const baseTextureY = isInner ? 0 : absY + blurOffsetY; + const blurTextureX = isInner ? -blurOffsetX - x : absX - x; + const blurTextureY = isInner ? -blurOffsetY - y : absY - y; + + // ===== Step 4: グラデーションLUT生成 ===== + // 注意: 共有テクスチャ+queue.writeTextureは使用しない。 + // queue.writeTextureはcommandEncoder外で即座に実行されるため、 + // 同一フレーム内の複数GradientBevelFilter適用時に最後の書き込みで上書きされる。 + // 各呼び出しで専用テクスチャを作成してこのタイミング問題を回避する。 + const lutData = generateFilterGradientLUT(ratios, colors, alphas); + const lutTexture = device.createTexture({ + "size": { "width": 256, "height": 1 }, + "format": "rgba8unorm", + "usage": GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST + }); + device.queue.writeTexture( + { "texture": lutTexture }, + lutData.buffer, + { "bytesPerRow": 256 * 4, "offset": lutData.byteOffset }, + { "width": 256, "height": 1 } + ); + const lutView = lutTexture.createView(); + + // ===== Step 5: UV変換パラメータ計算 ===== + // WebGL版と同じ: uv = v_coord * scale - offset + // WebGPU: texCoord.y=0がトップ(Y-flip補正済み) + // → offset_y = textureY / textureHeight(WebGLのY反転不要) + const baseScaleX = width / baseWidth; + const baseScaleY = height / baseHeight; + const baseOffsetUVX = baseTextureX / baseWidth; + const baseOffsetUVY = baseTextureY / baseHeight; + + const blurScaleX = width / blurWidth; + const blurScaleY = height / blurHeight; + const blurOffsetUVX = blurTextureX / blurWidth; + const blurOffsetUVY = blurTextureY / blurHeight; + + // ===== Step 6: 最終合成パス ===== + const destAttachment = frameBufferManager.createTemporaryAttachment(width, height); + + const pipeline = pipelineManager.getFilterPipeline("gradient_bevel_filter", { + "BEVEL_TYPE": type, + "IS_KNOCKOUT": knockout ? 1 : 0 + }); + const bindGroupLayout = pipelineManager.getBindGroupLayout("gradient_bevel_filter"); + + if (!pipeline || !bindGroupLayout) { + console.error("[WebGPU GradientBevelFilter] Pipeline not found"); + frameBufferManager.releaseTemporaryAttachment(blurAttachment); + return sourceAttachment; + } + + const sampler = textureManager.createSampler("gradient_bevel_sampler", true); + + // ユニフォームバッファ: 12 floats = 48 bytes + $uniform12[0] = strength; + $uniform12[1] = isInner ? 1.0 : 0.0; + $uniform12[2] = knockout ? 1.0 : 0.0; + $uniform12[3] = type; + $uniform12[4] = baseScaleX; + $uniform12[5] = baseScaleY; + $uniform12[6] = baseOffsetUVX; + $uniform12[7] = baseOffsetUVY; + $uniform12[8] = blurScaleX; + $uniform12[9] = blurScaleY; + $uniform12[10] = blurOffsetUVX; + $uniform12[11] = blurOffsetUVY; + + const uniformBuffer = config.bufferManager + ? config.bufferManager.acquireAndWriteUniformBuffer($uniform12) + : device.createBuffer({ + "size": $uniform12.byteLength, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + if (!config.bufferManager) { + device.queue.writeBuffer(uniformBuffer, 0, $uniform12); + } + + // バインドグループを作成(オリジナルテクスチャを直接使用) + ($entries5[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries5[1].resource = sampler; + $entries5[2].resource = blurAttachment.texture!.view; + $entries5[3].resource = sourceAttachment.texture!.view; + $entries5[4].resource = lutView; + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries5 + }); + + // レンダーパスを実行 + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + destAttachment.texture!.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + // クリーンアップ(lutTextureはsubmit後に遅延破棄) + config.frameTextures.push(lutTexture); + frameBufferManager.releaseTemporaryAttachment(blurAttachment); + + // WebGL版と同じオフセット更新 + $offset.x = baseOffsetX + baseTextureX; + $offset.y = baseOffsetY + baseTextureY; + + return destAttachment; +}; diff --git a/packages/webgpu/src/Filter/GradientGlowFilter/FilterApplyGradientGlowFilterUseCase.test.ts b/packages/webgpu/src/Filter/GradientGlowFilter/FilterApplyGradientGlowFilterUseCase.test.ts new file mode 100644 index 00000000..06bb625f --- /dev/null +++ b/packages/webgpu/src/Filter/GradientGlowFilter/FilterApplyGradientGlowFilterUseCase.test.ts @@ -0,0 +1,514 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { execute } from "./FilterApplyGradientGlowFilterUseCase"; + +// Mock GPUBufferUsage +const GPUBufferUsage = { + UNIFORM: 0x40, + COPY_DST: 0x08 +}; +(globalThis as any).GPUBufferUsage = GPUBufferUsage; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + TEXTURE_BINDING: 0x04, + COPY_DST: 0x08 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +// Mock offset +vi.mock("../index", () => ({ + "$offset": { "x": 0, "y": 0 } +})); + +import { $offset } from "../FilterOffset"; + +// Mock FilterApplyBlurFilterUseCase +vi.mock("../BlurFilter/FilterApplyBlurFilterUseCase", () => ({ + "execute": vi.fn((source: IAttachmentObject) => ({ + ...source, + "width": source.width + 40, + "height": source.height + 40 + })) +})); + +// Mock GradientLUTGenerator +vi.mock("../../Gradient/GradientLUTGenerator", () => ({ + "generateFilterGradientLUT": vi.fn(() => new Uint8Array(256 * 4)) +})); + +// Note: FilterGradientLUTCache is no longer used (per-invocation LUT textures instead) + +describe("FilterApplyGradientGlowFilterUseCase", () => +{ + const createMockAttachment = (width: number = 100, height: number = 100): IAttachmentObject => + { + return { + "id": 1, + "width": width, + "height": height, + "clipLevel": 0, + "texture": { + "resource": { "label": "mockTexture" } as unknown as GPUTexture, + "view": { "label": "mockTextureView" } as unknown as GPUTextureView + } + } as IAttachmentObject; + }; + + const createMockConfig = (): IFilterConfig => + { + const mockPassEncoder = { + "setPipeline": vi.fn(), + "setBindGroup": vi.fn(), + "draw": vi.fn(), + "end": vi.fn() + }; + + return { + "device": { + "createBuffer": vi.fn(() => ({ "label": "mockBuffer" })), + "createTexture": vi.fn(() => ({ + "label": "mockLUTTexture", + "createView": vi.fn(() => ({ "label": "mockLUTTextureView" })), + "destroy": vi.fn() + })), + "queue": { + "writeBuffer": vi.fn(), + "writeTexture": vi.fn() + }, + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })) + } as unknown as GPUDevice, + "commandEncoder": { + "beginRenderPass": vi.fn(() => mockPassEncoder), + "copyTextureToTexture": vi.fn() + } as unknown as GPUCommandEncoder, + "frameBufferManager": { + "createTemporaryAttachment": vi.fn((w: number, h: number) => createMockAttachment(w, h)), + "releaseTemporaryAttachment": vi.fn(), + "createRenderPassDescriptor": vi.fn(() => ({ + "colorAttachments": [{ "view": {}, "loadOp": "clear", "storeOp": "store" }] + })) + }, + "pipelineManager": { + "getPipeline": vi.fn(() => ({ "label": "mockPipeline" })), + "getFilterPipeline": vi.fn(() => ({ "label": "mockPipeline" })), + "getBindGroupLayout": vi.fn(() => ({ "label": "mockLayout" })) + }, + "textureManager": { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })) + }, + "frameTextures": [] + } as unknown as IFilterConfig; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + $offset.x = 0; + $offset.y = 0; + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("basic gradient glow execution", () => + { + it("should apply blur filter", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0xFFFFFF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, // distance + 45, // angle (degrees) + colors, + alphas, + ratios, + 10, // blurX + 10, // blurY + 1.0, // strength + 1, // quality + 0, // type (full) + false, // knockout + 1, // devicePixelRatio + config + ); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalled(); + }); + + it("should write gradient LUT to texture", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(config.device.queue.writeTexture).toHaveBeenCalled(); + }); + + it("should create uniform buffer with glow parameters", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 2.0, 1, 0, false, 1, + config + ); + + expect(config.device.createBuffer).toHaveBeenCalled(); + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + + it("should use render passes for compositing", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + // UV変換方式: 最終合成パス + expect(config.commandEncoder.beginRenderPass).toHaveBeenCalled(); + }); + + it("should return result attachment", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(result).toBeDefined(); + expect(result.texture).toBeDefined(); + }); + }); + + describe("glow type modes", () => + { + it("should handle full glow type (type 0)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, + 0, // full + false, 1, + config + ); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalled(); + }); + + it("should handle inner glow type (type 1)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, + 1, // inner + false, 1, + config + ); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalled(); + }); + + it("should handle outer glow type (type 2)", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, + 2, // outer + false, 1, + config + ); + + expect(config.frameBufferManager.createTemporaryAttachment).toHaveBeenCalled(); + }); + }); + + describe("glow angle calculation", () => + { + it("should calculate glow position based on angle 0", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 0, // 0 degrees + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(config.commandEncoder.beginRenderPass).toHaveBeenCalled(); + }); + + it("should calculate glow position based on angle 90", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 90, // 90 degrees + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(config.commandEncoder.beginRenderPass).toHaveBeenCalled(); + }); + }); + + describe("knockout mode", () => + { + it("should pass knockout flag to uniform buffer", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, + true, // knockout = true + 1, + config + ); + + expect(config.device.queue.writeBuffer).toHaveBeenCalled(); + }); + }); + + describe("pipeline error handling", () => + { + it("should return source attachment when pipeline not found", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + (config.pipelineManager.getFilterPipeline as ReturnType).mockReturnValue(null); + + const result = execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(console.error).toHaveBeenCalledWith("[WebGPU GradientGlowFilter] Pipeline not found"); + expect(result).toBe(sourceAttachment); + }); + }); + + describe("cleanup", () => + { + it("should release temporary attachments after processing", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + // blurAttachmentのみ解放(UV変換方式ではcomposite用テクスチャ不要) + expect(config.frameBufferManager.releaseTemporaryAttachment).toHaveBeenCalledTimes(1); + }); + }); + + describe("gradient colors", () => + { + it("should handle multi-color gradient", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0x000000, 0xFF0000, 0xFFFF00, 0xFFFFFF]); + const alphas = new Float32Array([0.0, 1.0, 1.0, 0.0]); + const ratios = new Float32Array([0, 64, 192, 255]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(result).toBeDefined(); + }); + + it("should handle gradient with varying alphas", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFFFFFF, 0xFFFFFF]); + const alphas = new Float32Array([1.0, 0.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + const result = execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, 0, false, 1, + config + ); + + expect(result).toBeDefined(); + }); + }); + + describe("offset update", () => + { + it("should update offset for outer glow", () => + { + const sourceAttachment = createMockAttachment(); + const matrix = new Float32Array([1, 0, 0, 1, 0, 0]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); + const alphas = new Float32Array([1.0, 1.0]); + const ratios = new Float32Array([0, 255]); + const config = createMockConfig(); + + execute( + sourceAttachment, + matrix, + 4, 45, + colors, alphas, ratios, + 10, 10, + 1.0, 1, + 0, // full (outer) + false, 1, + config + ); + + // Offset should be updated for outer glow + expect($offset.x).toBeGreaterThanOrEqual(0); + expect($offset.y).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/packages/webgpu/src/Filter/GradientGlowFilter/FilterApplyGradientGlowFilterUseCase.ts b/packages/webgpu/src/Filter/GradientGlowFilter/FilterApplyGradientGlowFilterUseCase.ts new file mode 100644 index 00000000..58512293 --- /dev/null +++ b/packages/webgpu/src/Filter/GradientGlowFilter/FilterApplyGradientGlowFilterUseCase.ts @@ -0,0 +1,221 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { IFilterConfig } from "../../interface/IFilterConfig"; +import { $offset } from "../FilterOffset"; +import { execute as filterApplyBlurFilterUseCase } from "../BlurFilter/FilterApplyBlurFilterUseCase"; +import { generateFilterGradientLUT } from "../../Gradient/GradientLUTGenerator"; + +/** + * @description 度からラジアンへの変換係数 + */ +const DEG_TO_RAD: number = Math.PI / 180; + +/** + * @description プリアロケートされたFloat32Array (サイズ12) + */ +const $uniform12 = new Float32Array(12); + +/** + * @description プリアロケートされたBindGroupEntry配列 (バインディング5つ) + */ +const $entries5: GPUBindGroupEntry[] = [ + { "binding": 0, "resource": { "buffer": null as unknown as GPUBuffer } }, + { "binding": 1, "resource": null as unknown as GPUSampler }, + { "binding": 2, "resource": null as unknown as GPUTextureView }, + { "binding": 3, "resource": null as unknown as GPUTextureView }, + { "binding": 4, "resource": null as unknown as GPUTextureView } +]; + +/** + * @description グラデーショングローフィルターを適用 + * Apply gradient glow filter + * + * WebGL版と同じフロー: + * 1. ブラー適用 + * 2. グラデーションLUT生成(専用テクスチャ) + * 3. UV変換方式で最終合成(isInsideでハード境界クリッピング) + * + * @param {IAttachmentObject} sourceAttachment - 入力テクスチャ + * @param {Float32Array} matrix - 変換行列 + * @param {number} distance - グローの距離 + * @param {number} angle - グローの角度(度) + * @param {Float32Array} colors - 色配列 + * @param {Float32Array} alphas - アルファ配列 + * @param {Float32Array} ratios - 比率配列 + * @param {number} blurX - X方向ブラー量 + * @param {number} blurY - Y方向ブラー量 + * @param {number} strength - グロー強度 + * @param {number} quality - クオリティ + * @param {number} type - タイプ (0: full, 1: inner, 2: outer) + * @param {boolean} knockout - ノックアウトモード + * @param {number} devicePixelRatio - デバイスピクセル比 + * @param {IFilterConfig} config - WebGPUリソース設定 + * @return {IAttachmentObject} - フィルター適用後のアタッチメント + */ +export const execute = ( + sourceAttachment: IAttachmentObject, + matrix: Float32Array, + distance: number, + angle: number, + colors: Float32Array, + alphas: Float32Array, + ratios: Float32Array, + blurX: number, + blurY: number, + strength: number, + quality: number, + type: number, + knockout: boolean, + devicePixelRatio: number, + config: IFilterConfig +): IAttachmentObject => { + + const { device, commandEncoder, frameBufferManager, pipelineManager, textureManager } = config; + + // 元のオフセットを保存 + const baseOffsetX = $offset.x; + const baseOffsetY = $offset.y; + const baseWidth = sourceAttachment.width; + const baseHeight = sourceAttachment.height; + + // ブラーフィルターを適用 + const blurAttachment = filterApplyBlurFilterUseCase( + sourceAttachment, matrix, + blurX, blurY, quality, + devicePixelRatio, config + ); + + const blurWidth = blurAttachment.width; + const blurHeight = blurAttachment.height; + const blurOffsetX = $offset.x; + const blurOffsetY = $offset.y; + + const offsetDiffX = blurOffsetX - baseOffsetX; + const offsetDiffY = blurOffsetY - baseOffsetY; + + // 変換行列からスケールを取得 + const xScale = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]); + const yScale = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]); + + // グローのオフセットを計算 + const radian = angle * DEG_TO_RAD; + const x = Math.cos(radian) * distance * (xScale / devicePixelRatio); + const y = Math.sin(radian) * distance * (yScale / devicePixelRatio); + + // ===== WebGL版と同じサイズ・位置計算 ===== + const isInner = type === 1; + const w = isInner ? baseWidth : blurWidth + Math.max(0, Math.abs(x) - offsetDiffX); + const h = isInner ? baseHeight : blurHeight + Math.max(0, Math.abs(y) - offsetDiffY); + const width = Math.ceil(w); + const height = Math.ceil(h); + const fractionX = (width - w) / 2; + const fractionY = (height - h) / 2; + + // テクスチャ座標の計算 + const baseTextureX = isInner ? 0 : Math.max(0, offsetDiffX - x) + fractionX; + const baseTextureY = isInner ? 0 : Math.max(0, offsetDiffY - y) + fractionY; + const blurTextureX = isInner ? x - blurOffsetX : (x > 0 ? Math.max(0, x - offsetDiffX) : 0) + fractionX; + const blurTextureY = isInner ? y - blurOffsetY : (y > 0 ? Math.max(0, y - offsetDiffY) : 0) + fractionY; + + // ===== グラデーションLUT生成(専用テクスチャ) ===== + // 注意: 共有テクスチャは使用しない。 + // queue.writeTextureはcommandEncoder外で即座に実行されるため、 + // 同一フレーム内の複数GradientGlowFilter適用時に最後の書き込みで上書きされる。 + const lutData = generateFilterGradientLUT(ratios, colors, alphas); + const lutTexture = device.createTexture({ + "size": { "width": 256, "height": 1 }, + "format": "rgba8unorm", + "usage": GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST + }); + device.queue.writeTexture( + { "texture": lutTexture }, + lutData.buffer, + { "bytesPerRow": 256 * 4, "offset": lutData.byteOffset }, + { "width": 256, "height": 1 } + ); + const lutView = lutTexture.createView(); + + // ===== UV変換パラメータ計算 ===== + // WebGPU: texCoord.y=0がトップ(Y-flip補正済み) + const baseScaleX = width / baseWidth; + const baseScaleY = height / baseHeight; + const baseOffsetUVX = baseTextureX / baseWidth; + const baseOffsetUVY = baseTextureY / baseHeight; + + const blurScaleX = width / blurWidth; + const blurScaleY = height / blurHeight; + const blurOffsetUVX = blurTextureX / blurWidth; + const blurOffsetUVY = blurTextureY / blurHeight; + + // ===== 最終合成パス ===== + const destAttachment = frameBufferManager.createTemporaryAttachment(width, height); + + const pipeline = pipelineManager.getFilterPipeline("gradient_glow_filter", { + "GLOW_TYPE": type, + "IS_KNOCKOUT": knockout ? 1 : 0 + }); + const bindGroupLayout = pipelineManager.getBindGroupLayout("gradient_glow_filter"); + + if (!pipeline || !bindGroupLayout) { + console.error("[WebGPU GradientGlowFilter] Pipeline not found"); + frameBufferManager.releaseTemporaryAttachment(blurAttachment); + return sourceAttachment; + } + + const sampler = textureManager.createSampler("gradient_glow_sampler", true); + + // ユニフォームバッファ: 12 floats = 48 bytes + $uniform12[0] = strength; + $uniform12[1] = isInner ? 1.0 : 0.0; + $uniform12[2] = knockout ? 1.0 : 0.0; + $uniform12[3] = type; + $uniform12[4] = baseScaleX; + $uniform12[5] = baseScaleY; + $uniform12[6] = baseOffsetUVX; + $uniform12[7] = baseOffsetUVY; + $uniform12[8] = blurScaleX; + $uniform12[9] = blurScaleY; + $uniform12[10] = blurOffsetUVX; + $uniform12[11] = blurOffsetUVY; + + const uniformBuffer = config.bufferManager + ? config.bufferManager.acquireAndWriteUniformBuffer($uniform12) + : device.createBuffer({ + "size": $uniform12.byteLength, + "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + if (!config.bufferManager) { + device.queue.writeBuffer(uniformBuffer, 0, $uniform12); + } + + // バインドグループを作成(オリジナルテクスチャを直接使用) + ($entries5[0].resource as GPUBufferBinding).buffer = uniformBuffer; + $entries5[1].resource = sampler; + $entries5[2].resource = blurAttachment.texture!.view; + $entries5[3].resource = sourceAttachment.texture!.view; + $entries5[4].resource = lutView; + const bindGroup = device.createBindGroup({ + "layout": bindGroupLayout, + "entries": $entries5 + }); + + // レンダーパスを実行 + const renderPassDescriptor = frameBufferManager.createRenderPassDescriptor( + destAttachment.texture!.view, 0, 0, 0, 0, "clear" + ); + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(6, 1, 0, 0); + passEncoder.end(); + + // クリーンアップ(lutTextureはsubmit後に遅延破棄) + config.frameTextures.push(lutTexture); + frameBufferManager.releaseTemporaryAttachment(blurAttachment); + + // WebGL版と同じオフセット更新 + $offset.x = baseOffsetX + baseTextureX; + $offset.y = baseOffsetY + baseTextureY; + + return destAttachment; +}; diff --git a/packages/webgpu/src/FrameBufferManager.test.ts b/packages/webgpu/src/FrameBufferManager.test.ts new file mode 100644 index 00000000..c621d6cd --- /dev/null +++ b/packages/webgpu/src/FrameBufferManager.test.ts @@ -0,0 +1,473 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { FrameBufferManager } from "./FrameBufferManager"; +import type { IAttachmentObject } from "./interface/IAttachmentObject"; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + RENDER_ATTACHMENT: 0x10, + TEXTURE_BINDING: 0x04, + COPY_SRC: 0x01, + COPY_DST: 0x08 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +// Mock TexturePool +const mockTexturePoolAcquire = vi.fn((w: number, h: number) => ({ + "createView": vi.fn(() => ({})), + "destroy": vi.fn(), + "width": w, + "height": h +})); +const mockTexturePoolRelease = vi.fn(); +const mockTexturePoolBeginFrame = vi.fn(); +const mockTexturePoolDispose = vi.fn(); + +vi.mock("./TexturePool", () => ({ + "TexturePool": class { + acquire = mockTexturePoolAcquire; + release = mockTexturePoolRelease; + beginFrame = mockTexturePoolBeginFrame; + dispose = mockTexturePoolDispose; + } +})); + +// Mock usecase and service modules +vi.mock("./FrameBufferManager/usecase/FrameBufferManagerCreateAttachmentUseCase", () => ({ + "execute": vi.fn((device, format, attachments, name, width, height, msaa, mask, idCounter) => { + idCounter.nextId++; + idCounter.textureId++; + const attachment: IAttachmentObject = { + "id": idCounter.nextId, + "width": width, + "height": height, + "clipLevel": 0, + "msaa": msaa, + "mask": mask, + "color": null, + "texture": { + "id": idCounter.textureId, + "width": width, + "height": height, + "area": width * height, + "smooth": true, + "resource": { "destroy": vi.fn() }, + "view": {} + }, + "stencil": mask ? { + "id": idCounter.stencilId++, + "resource": { "destroy": vi.fn() }, + "view": {} + } : null, + "msaaTexture": null, + "msaaStencil": null + }; + attachments.set(name, attachment); + return attachment; + }) +})); + +vi.mock("./FrameBufferManager/usecase/FrameBufferManagerReleaseTemporaryAttachmentUseCase", () => ({ + "execute": vi.fn((attachments, pendingReleases, attachment) => { + // Find and remove from attachments + for (const [key, value] of attachments.entries()) { + if (value === attachment) { + attachments.delete(key); + pendingReleases.push(attachment); + break; + } + } + }) +})); + +vi.mock("./FrameBufferManager/service/FrameBufferManagerCreateRenderPassDescriptorService", () => ({ + "execute": vi.fn((view, r, g, b, a, loadOp, resolveTarget) => ({ + "colorAttachments": [{ + "view": view, + "resolveTarget": resolveTarget, + "clearValue": { r, g, b, a }, + "loadOp": loadOp, + "storeOp": "store" + }] + })) +})); + +vi.mock("./FrameBufferManager/service/FrameBufferManagerCreateStencilRenderPassDescriptorService", () => ({ + "execute": vi.fn((colorView, stencilView, colorLoadOp, stencilLoadOp, resolveTarget) => ({ + "colorAttachments": [{ + "view": colorView, + "resolveTarget": resolveTarget, + "loadOp": colorLoadOp, + "storeOp": "store" + }], + "depthStencilAttachment": { + "view": stencilView, + "stencilLoadOp": stencilLoadOp, + "stencilStoreOp": "store" + } + })) +})); + +vi.mock("./FrameBufferManager/service/FrameBufferManagerFlushPendingReleasesService", () => ({ + "execute": vi.fn((pendingReleases) => { + for (const attachment of pendingReleases) { + if (attachment.texture) { + attachment.texture.resource.destroy(); + } + if (attachment.stencil) { + attachment.stencil.resource.destroy(); + } + } + }) +})); + +describe("FrameBufferManager", () => +{ + const createMockDevice = (): GPUDevice => + { + return { + "createTexture": vi.fn(() => ({ + "createView": vi.fn(() => ({})), + "destroy": vi.fn() + })) + } as unknown as GPUDevice; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + }); + + describe("constructor", () => + { + it("should create instance with device and format", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + + expect(manager).toBeDefined(); + }); + + it("should initialize with null current attachment", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + + expect(manager.getCurrentAttachment()).toBeNull(); + }); + + it("should not create atlas attachment on initialization (managed by AtlasManager)", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + + // アトラスはAtlasManagerが動的に管理するため、初期化時には作成されない + const atlas = manager.getAttachment("atlas"); + expect(atlas).toBeUndefined(); + }); + }); + + describe("createAttachment", () => + { + it("should create attachment with specified dimensions", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + + const attachment = manager.createAttachment("test", 512, 256); + + expect(attachment.width).toBe(512); + expect(attachment.height).toBe(256); + }); + + it("should create attachment without msaa by default", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + + const attachment = manager.createAttachment("test", 100, 100); + + expect(attachment.msaa).toBe(false); + }); + + it("should create attachment with msaa when specified", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + + const attachment = manager.createAttachment("test", 100, 100, true); + + expect(attachment.msaa).toBe(true); + }); + + it("should create attachment with mask when specified", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + + const attachment = manager.createAttachment("test", 100, 100, false, true); + + expect(attachment.mask).toBe(true); + expect(attachment.stencil).toBeDefined(); + }); + + it("should store attachment by name", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + + manager.createAttachment("myAttachment", 200, 200); + const retrieved = manager.getAttachment("myAttachment"); + + expect(retrieved).toBeDefined(); + }); + }); + + describe("getAttachment", () => + { + it("should return undefined for non-existent attachment", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + + expect(manager.getAttachment("nonexistent")).toBeUndefined(); + }); + }); + + describe("setCurrentAttachment", () => + { + it("should set current attachment", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + const attachment = manager.createAttachment("test", 100, 100); + + manager.setCurrentAttachment(attachment); + + expect(manager.getCurrentAttachment()).toBe(attachment); + }); + + it("should allow setting to null", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + const attachment = manager.createAttachment("test", 100, 100); + + manager.setCurrentAttachment(attachment); + manager.setCurrentAttachment(null); + + expect(manager.getCurrentAttachment()).toBeNull(); + }); + }); + + describe("createRenderPassDescriptor", () => + { + it("should create descriptor with clear color", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + const mockView = {} as GPUTextureView; + + const descriptor = manager.createRenderPassDescriptor( + mockView, 0.5, 0.5, 0.5, 1.0, "clear" + ); + + expect(descriptor.colorAttachments).toBeDefined(); + expect((descriptor.colorAttachments as any)[0].clearValue).toEqual({ + "r": 0.5, "g": 0.5, "b": 0.5, "a": 1.0 + }); + }); + + it("should use clear as default loadOp", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + const mockView = {} as GPUTextureView; + + const descriptor = manager.createRenderPassDescriptor(mockView, 0, 0, 0, 0); + + expect((descriptor.colorAttachments as any)[0].loadOp).toBe("clear"); + }); + + it("should accept resolveTarget for MSAA", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + const mockView = {} as GPUTextureView; + const resolveTarget = {} as GPUTextureView; + + const descriptor = manager.createRenderPassDescriptor( + mockView, 0, 0, 0, 0, "clear", resolveTarget + ); + + expect((descriptor.colorAttachments as any)[0].resolveTarget).toBe(resolveTarget); + }); + }); + + describe("createStencilRenderPassDescriptor", () => + { + it("should create descriptor with stencil attachment", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + const colorView = {} as GPUTextureView; + const stencilView = {} as GPUTextureView; + + const descriptor = manager.createStencilRenderPassDescriptor( + colorView, stencilView, "load", "clear" + ); + + expect(descriptor.depthStencilAttachment).toBeDefined(); + expect((descriptor.depthStencilAttachment as any).stencilLoadOp).toBe("clear"); + }); + + it("should use load as default color loadOp", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + const colorView = {} as GPUTextureView; + const stencilView = {} as GPUTextureView; + + const descriptor = manager.createStencilRenderPassDescriptor(colorView, stencilView); + + expect((descriptor.colorAttachments as any)[0].loadOp).toBe("load"); + }); + }); + + describe("destroyAttachment", () => + { + it("should destroy attachment and remove from map", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + const attachment = manager.createAttachment("test", 100, 100); + + manager.destroyAttachment("test"); + + expect(attachment.texture!.resource.destroy).toHaveBeenCalled(); + expect(manager.getAttachment("test")).toBeUndefined(); + }); + + it("should not throw when attachment does not exist", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + + expect(() => manager.destroyAttachment("nonexistent")).not.toThrow(); + }); + }); + + describe("resizeAttachment", () => + { + it("should destroy old and create new attachment", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + const oldAttachment = manager.createAttachment("test", 100, 100); + + const newAttachment = manager.resizeAttachment("test", 200, 200); + + expect(oldAttachment.texture!.resource.destroy).toHaveBeenCalled(); + expect(newAttachment.width).toBe(200); + expect(newAttachment.height).toBe(200); + }); + }); + + describe("createTemporaryAttachment", () => + { + it("should create temporary attachment", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + + const attachment = manager.createTemporaryAttachment(256, 256); + + expect(attachment).toBeDefined(); + expect(attachment.width).toBe(256); + expect(attachment.height).toBe(256); + }); + }); + + describe("releaseTemporaryAttachment", () => + { + it("should add attachment to pending releases", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + const attachment = manager.createTemporaryAttachment(100, 100); + + manager.releaseTemporaryAttachment(attachment); + + // Should not throw + expect(() => manager.flushPendingReleases()).not.toThrow(); + }); + }); + + describe("flushPendingReleases", () => + { + it("should release pending attachments to texture pool", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + const attachment = manager.createTemporaryAttachment(100, 100); + + manager.releaseTemporaryAttachment(attachment); + manager.flushPendingReleases(); + + expect(mockTexturePoolRelease).toHaveBeenCalledWith(attachment.texture!.resource); + }); + + it("should clear pending releases after flush", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + const attachment = manager.createTemporaryAttachment(100, 100); + + manager.releaseTemporaryAttachment(attachment); + manager.flushPendingReleases(); + + const releaseCountBefore = mockTexturePoolRelease.mock.calls.length; + // Second flush should not throw or re-release + expect(() => manager.flushPendingReleases()).not.toThrow(); + expect(mockTexturePoolRelease.mock.calls.length).toBe(releaseCountBefore); + }); + }); + + describe("dispose", () => + { + it("should destroy all attachments", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + const attachment1 = manager.createAttachment("test1", 100, 100); + const attachment2 = manager.createAttachment("test2", 200, 200); + + manager.dispose(); + + expect(attachment1.texture!.resource.destroy).toHaveBeenCalled(); + expect(attachment2.texture!.resource.destroy).toHaveBeenCalled(); + }); + + it("should clear attachment map", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + manager.createAttachment("test", 100, 100); + + manager.dispose(); + + expect(manager.getAttachment("test")).toBeUndefined(); + }); + + it("should set current attachment to null", () => + { + const device = createMockDevice(); + const manager = new FrameBufferManager(device, "bgra8unorm"); + const attachment = manager.createAttachment("test", 100, 100); + manager.setCurrentAttachment(attachment); + + manager.dispose(); + + expect(manager.getCurrentAttachment()).toBeNull(); + }); + }); +}); diff --git a/packages/webgpu/src/FrameBufferManager.ts b/packages/webgpu/src/FrameBufferManager.ts new file mode 100644 index 00000000..632edbb4 --- /dev/null +++ b/packages/webgpu/src/FrameBufferManager.ts @@ -0,0 +1,200 @@ +import type { IAttachmentObject } from "./interface/IAttachmentObject"; +import type { ITextureObject } from "./interface/ITextureObject"; +import { execute as frameBufferManagerCreateAttachmentUseCase } from "./FrameBufferManager/usecase/FrameBufferManagerCreateAttachmentUseCase"; +import { execute as frameBufferManagerReleaseTemporaryAttachmentUseCase } from "./FrameBufferManager/usecase/FrameBufferManagerReleaseTemporaryAttachmentUseCase"; +import { execute as frameBufferManagerCreateRenderPassDescriptorService } from "./FrameBufferManager/service/FrameBufferManagerCreateRenderPassDescriptorService"; +import { execute as frameBufferManagerCreateStencilRenderPassDescriptorService } from "./FrameBufferManager/service/FrameBufferManagerCreateStencilRenderPassDescriptorService"; +import { TexturePool } from "./TexturePool"; + +export class FrameBufferManager +{ + private device: GPUDevice; + private format: GPUTextureFormat; + private attachments: Map; + private currentAttachment: IAttachmentObject | null; + private idCounter: { nextId: number; textureId: number; stencilId: number }; + private texturePool: TexturePool; + private pendingReleases: IAttachmentObject[] = []; + + constructor(device: GPUDevice, format: GPUTextureFormat) + { + this.device = device; + this.format = format; + this.attachments = new Map(); + this.currentAttachment = null; + this.idCounter = { "nextId": 1, "textureId": 1, "stencilId": 1 }; + this.texturePool = new TexturePool(device); + } + + beginFrame(): void + { + this.texturePool.beginFrame(); + } + + createAttachment( + name: string, + width: number, + height: number, + msaa: boolean = false, + mask: boolean = false + ): IAttachmentObject + { + return frameBufferManagerCreateAttachmentUseCase( + this.device, + this.format, + this.attachments, + name, + width, + height, + msaa, + mask, + this.idCounter + ); + } + + getAttachment(name: string): IAttachmentObject | undefined + { + return this.attachments.get(name); + } + + setCurrentAttachment(attachment: IAttachmentObject | null): void + { + this.currentAttachment = attachment; + } + + getCurrentAttachment(): IAttachmentObject | null + { + return this.currentAttachment; + } + + createRenderPassDescriptor( + view: GPUTextureView, + r: number = 0, + g: number = 0, + b: number = 0, + a: number = 0, + loadOp: GPULoadOp = "clear", + resolveTarget: GPUTextureView | null = null + ): GPURenderPassDescriptor { + return frameBufferManagerCreateRenderPassDescriptorService(view, r, g, b, a, loadOp, resolveTarget); + } + + createStencilRenderPassDescriptor( + colorView: GPUTextureView, + stencilView: GPUTextureView, + colorLoadOp: GPULoadOp = "load", + stencilLoadOp: GPULoadOp = "clear", + resolveTarget: GPUTextureView | null = null + ): GPURenderPassDescriptor { + return frameBufferManagerCreateStencilRenderPassDescriptorService( + colorView, + stencilView, + colorLoadOp, + stencilLoadOp, + resolveTarget + ); + } + + destroyAttachment(name: string): void + { + const attachment = this.attachments.get(name); + if (attachment) { + if (attachment.texture) { + attachment.texture.resource.destroy(); + } + if (attachment.stencil) { + attachment.stencil.resource.destroy(); + } + this.attachments.delete(name); + } + } + + resizeAttachment(name: string, width: number, height: number): IAttachmentObject + { + this.destroyAttachment(name); + return this.createAttachment(name, width, height); + } + + createTemporaryAttachment(width: number, height: number): IAttachmentObject + { + const name = `temp_${this.idCounter.nextId}`; + const usage = GPUTextureUsage.RENDER_ATTACHMENT | + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_SRC | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.STORAGE_BINDING; + + const gpuTexture = this.texturePool.acquire(width, height, "rgba8unorm", usage); + const textureView = gpuTexture.createView(); + + const texture: ITextureObject = { + "id": this.idCounter.textureId++, + "resource": gpuTexture, + "view": textureView, + width, + height, + "area": width * height, + "smooth": true + }; + + const attachment: IAttachmentObject = { + "id": this.idCounter.nextId++, + width, + height, + "clipLevel": 0, + "msaa": false, + "mask": false, + "color": null, + texture, + "stencil": null, + "msaaTexture": null, + "msaaStencil": null + }; + + this.attachments.set(name, attachment); + return attachment; + } + + releaseTemporaryAttachment(attachment: IAttachmentObject): void + { + frameBufferManagerReleaseTemporaryAttachmentUseCase( + this.attachments, + this.pendingReleases, + attachment + ); + } + + flushPendingReleases(): void + { + for (const att of this.pendingReleases) { + if (att.texture) { + this.texturePool.release(att.texture.resource); + } + if (att.msaaTexture) { + att.msaaTexture.resource.destroy(); + } + if (att.stencil) { + att.stencil.resource.destroy(); + } + if (att.msaaStencil) { + att.msaaStencil.resource.destroy(); + } + } + this.pendingReleases = []; + } + + dispose(): void + { + for (const attachment of this.attachments.values()) { + if (attachment.texture) { + attachment.texture.resource.destroy(); + } + if (attachment.stencil) { + attachment.stencil.resource.destroy(); + } + } + this.attachments.clear(); + this.currentAttachment = null; + this.texturePool.dispose(); + } +} diff --git a/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerCreateRenderPassDescriptorService.test.ts b/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerCreateRenderPassDescriptorService.test.ts new file mode 100644 index 00000000..e2e4dd9b --- /dev/null +++ b/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerCreateRenderPassDescriptorService.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from "vitest"; +import { execute } from "./FrameBufferManagerCreateRenderPassDescriptorService"; + +describe("FrameBufferManagerCreateRenderPassDescriptorService", () => +{ + const createMockView = (label: string = "mockView"): GPUTextureView => + { + return { label } as unknown as GPUTextureView; + }; + + describe("basic descriptor creation", () => + { + it("should create descriptor with provided view", () => + { + const view = createMockView("testView"); + + const result = execute(view); + + expect(result.colorAttachments[0].view).toBe(view); + }); + + it("should have one color attachment", () => + { + const view = createMockView(); + + const result = execute(view); + + expect(result.colorAttachments).toHaveLength(1); + }); + + it("should set storeOp to store", () => + { + const view = createMockView(); + + const result = execute(view); + + expect(result.colorAttachments[0].storeOp).toBe("store"); + }); + }); + + describe("clear value", () => + { + it("should set clearValue with provided RGBA values", () => + { + const view = createMockView(); + + const result = execute(view, 0.5, 0.6, 0.7, 0.8); + + expect(result.colorAttachments[0].clearValue).toEqual({ + "r": 0.5, + "g": 0.6, + "b": 0.7, + "a": 0.8 + }); + }); + + it("should default to transparent black (0, 0, 0, 0)", () => + { + const view = createMockView(); + + const result = execute(view); + + expect(result.colorAttachments[0].clearValue).toEqual({ + "r": 0, + "g": 0, + "b": 0, + "a": 0 + }); + }); + }); + + describe("loadOp", () => + { + it("should set loadOp to provided value", () => + { + const view = createMockView(); + + const result = execute(view, 0, 0, 0, 0, "load"); + + expect(result.colorAttachments[0].loadOp).toBe("load"); + }); + + it("should default loadOp to clear", () => + { + const view = createMockView(); + + const result = execute(view); + + expect(result.colorAttachments[0].loadOp).toBe("clear"); + }); + }); + + describe("MSAA resolve target", () => + { + it("should set resolveTarget when provided", () => + { + const view = createMockView("msaaView"); + const resolveTarget = createMockView("resolveView"); + + const result = execute(view, 0, 0, 0, 0, "clear", resolveTarget); + + expect(result.colorAttachments[0].resolveTarget).toBe(resolveTarget); + }); + + it("should not have resolveTarget when null", () => + { + const view = createMockView(); + + const result = execute(view, 0, 0, 0, 0, "clear", null); + + expect(result.colorAttachments[0].resolveTarget).toBeUndefined(); + }); + + it("should not have resolveTarget by default", () => + { + const view = createMockView(); + + const result = execute(view); + + expect(result.colorAttachments[0].resolveTarget).toBeUndefined(); + }); + }); +}); diff --git a/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerCreateRenderPassDescriptorService.ts b/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerCreateRenderPassDescriptorService.ts new file mode 100644 index 00000000..d3355a92 --- /dev/null +++ b/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerCreateRenderPassDescriptorService.ts @@ -0,0 +1,32 @@ +const $clearValue: GPUColorDict = { "r": 0, "g": 0, "b": 0, "a": 0 }; +const $colorAttachment: GPURenderPassColorAttachment = { + "view": null as unknown as GPUTextureView, + "clearValue": $clearValue, + "loadOp": "clear", + "storeOp": "store" +}; +const $descriptor: GPURenderPassDescriptor = { + "colorAttachments": [$colorAttachment] +}; + +/** + * @description レンダーパス記述子を作成(プリアロケート再利用) + */ +export const execute = ( + view: GPUTextureView, + r: number = 0, + g: number = 0, + b: number = 0, + a: number = 0, + loadOp: GPULoadOp = "clear", + resolveTarget: GPUTextureView | null = null +): GPURenderPassDescriptor => { + $colorAttachment.view = view; + $clearValue.r = r; + $clearValue.g = g; + $clearValue.b = b; + $clearValue.a = a; + $colorAttachment.loadOp = loadOp; + $colorAttachment.resolveTarget = resolveTarget ?? undefined; + return $descriptor; +}; diff --git a/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerCreateStencilRenderPassDescriptorService.test.ts b/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerCreateStencilRenderPassDescriptorService.test.ts new file mode 100644 index 00000000..8ca1d202 --- /dev/null +++ b/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerCreateStencilRenderPassDescriptorService.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from "vitest"; +import { execute } from "./FrameBufferManagerCreateStencilRenderPassDescriptorService"; + +describe("FrameBufferManagerCreateStencilRenderPassDescriptorService", () => +{ + const createMockView = (label: string = "mockView"): GPUTextureView => + { + return { label } as unknown as GPUTextureView; + }; + + describe("color attachment", () => + { + it("should create descriptor with provided color view", () => + { + const colorView = createMockView("colorView"); + const stencilView = createMockView("stencilView"); + + const result = execute(colorView, stencilView); + + expect(result.colorAttachments[0].view).toBe(colorView); + }); + + it("should have one color attachment", () => + { + const colorView = createMockView(); + const stencilView = createMockView(); + + const result = execute(colorView, stencilView); + + expect(result.colorAttachments).toHaveLength(1); + }); + + it("should set clearValue to transparent black", () => + { + const colorView = createMockView(); + const stencilView = createMockView(); + + const result = execute(colorView, stencilView); + + expect(result.colorAttachments[0].clearValue).toEqual({ + "r": 0, + "g": 0, + "b": 0, + "a": 0 + }); + }); + + it("should set storeOp to store", () => + { + const colorView = createMockView(); + const stencilView = createMockView(); + + const result = execute(colorView, stencilView); + + expect(result.colorAttachments[0].storeOp).toBe("store"); + }); + + it("should set colorLoadOp to provided value", () => + { + const colorView = createMockView(); + const stencilView = createMockView(); + + const result = execute(colorView, stencilView, "clear"); + + expect(result.colorAttachments[0].loadOp).toBe("clear"); + }); + + it("should default colorLoadOp to load", () => + { + const colorView = createMockView(); + const stencilView = createMockView(); + + const result = execute(colorView, stencilView); + + expect(result.colorAttachments[0].loadOp).toBe("load"); + }); + }); + + describe("depth stencil attachment", () => + { + it("should include depthStencilAttachment", () => + { + const colorView = createMockView(); + const stencilView = createMockView(); + + const result = execute(colorView, stencilView); + + expect(result.depthStencilAttachment).toBeDefined(); + }); + + it("should set stencil view correctly", () => + { + const colorView = createMockView(); + const stencilView = createMockView("stencilView"); + + const result = execute(colorView, stencilView); + + expect(result.depthStencilAttachment?.view).toBe(stencilView); + }); + + it("should set stencilClearValue to 0", () => + { + const colorView = createMockView(); + const stencilView = createMockView(); + + const result = execute(colorView, stencilView); + + expect(result.depthStencilAttachment?.stencilClearValue).toBe(0); + }); + + it("should set stencilLoadOp to provided value", () => + { + const colorView = createMockView(); + const stencilView = createMockView(); + + const result = execute(colorView, stencilView, "load", "load"); + + expect(result.depthStencilAttachment?.stencilLoadOp).toBe("load"); + }); + + it("should default stencilLoadOp to clear", () => + { + const colorView = createMockView(); + const stencilView = createMockView(); + + const result = execute(colorView, stencilView); + + expect(result.depthStencilAttachment?.stencilLoadOp).toBe("clear"); + }); + + it("should set stencilStoreOp to store", () => + { + const colorView = createMockView(); + const stencilView = createMockView(); + + const result = execute(colorView, stencilView); + + expect(result.depthStencilAttachment?.stencilStoreOp).toBe("store"); + }); + }); + + describe("MSAA resolve target", () => + { + it("should set resolveTarget when provided", () => + { + const colorView = createMockView("msaaView"); + const stencilView = createMockView(); + const resolveTarget = createMockView("resolveView"); + + const result = execute(colorView, stencilView, "load", "clear", resolveTarget); + + expect(result.colorAttachments[0].resolveTarget).toBe(resolveTarget); + }); + + it("should not have resolveTarget when null", () => + { + const colorView = createMockView(); + const stencilView = createMockView(); + + const result = execute(colorView, stencilView, "load", "clear", null); + + expect(result.colorAttachments[0].resolveTarget).toBeUndefined(); + }); + + it("should not have resolveTarget by default", () => + { + const colorView = createMockView(); + const stencilView = createMockView(); + + const result = execute(colorView, stencilView); + + expect(result.colorAttachments[0].resolveTarget).toBeUndefined(); + }); + }); +}); diff --git a/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerCreateStencilRenderPassDescriptorService.ts b/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerCreateStencilRenderPassDescriptorService.ts new file mode 100644 index 00000000..0a592389 --- /dev/null +++ b/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerCreateStencilRenderPassDescriptorService.ts @@ -0,0 +1,35 @@ +const $clearValue: GPUColorDict = { "r": 0, "g": 0, "b": 0, "a": 0 }; +const $colorAttachment: GPURenderPassColorAttachment = { + "view": null as unknown as GPUTextureView, + "clearValue": $clearValue, + "loadOp": "load", + "storeOp": "store" +}; +const $depthStencilAttachment: GPURenderPassDepthStencilAttachment = { + "view": null as unknown as GPUTextureView, + "stencilClearValue": 0, + "stencilLoadOp": "clear", + "stencilStoreOp": "store" +}; +const $descriptor: GPURenderPassDescriptor = { + "colorAttachments": [$colorAttachment], + "depthStencilAttachment": $depthStencilAttachment +}; + +/** + * @description ステンシル付きレンダーパス記述子を作成(プリアロケート再利用) + */ +export const execute = ( + colorView: GPUTextureView, + stencilView: GPUTextureView, + colorLoadOp: GPULoadOp = "load", + stencilLoadOp: GPULoadOp = "clear", + resolveTarget: GPUTextureView | null = null +): GPURenderPassDescriptor => { + $colorAttachment.view = colorView; + $colorAttachment.loadOp = colorLoadOp; + $colorAttachment.resolveTarget = resolveTarget ?? undefined; + $depthStencilAttachment.view = stencilView; + $depthStencilAttachment.stencilLoadOp = stencilLoadOp; + return $descriptor; +}; diff --git a/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerFlushPendingReleasesService.test.ts b/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerFlushPendingReleasesService.test.ts new file mode 100644 index 00000000..f5abde1a --- /dev/null +++ b/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerFlushPendingReleasesService.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import { execute } from "./FrameBufferManagerFlushPendingReleasesService"; + +describe("FrameBufferManagerFlushPendingReleasesService", () => +{ + const createMockAttachment = (hasTexture: boolean, hasStencil: boolean): IAttachmentObject => + { + return { + "texture": hasTexture ? { + "resource": { "destroy": vi.fn() } + } : null, + "stencil": hasStencil ? { + "resource": { "destroy": vi.fn() } + } : null + } as unknown as IAttachmentObject; + }; + + it("should destroy texture resources", () => + { + const attachment = createMockAttachment(true, false); + const pendingReleases = [attachment]; + + execute(pendingReleases); + + expect(attachment.texture!.resource.destroy).toHaveBeenCalled(); + }); + + it("should destroy stencil resources", () => + { + const attachment = createMockAttachment(false, true); + const pendingReleases = [attachment]; + + execute(pendingReleases); + + expect(attachment.stencil!.resource.destroy).toHaveBeenCalled(); + }); + + it("should destroy both texture and stencil resources", () => + { + const attachment = createMockAttachment(true, true); + const pendingReleases = [attachment]; + + execute(pendingReleases); + + expect(attachment.texture!.resource.destroy).toHaveBeenCalled(); + expect(attachment.stencil!.resource.destroy).toHaveBeenCalled(); + }); + + it("should handle attachment without texture", () => + { + const attachment = createMockAttachment(false, true); + const pendingReleases = [attachment]; + + expect(() => execute(pendingReleases)).not.toThrow(); + }); + + it("should handle attachment without stencil", () => + { + const attachment = createMockAttachment(true, false); + const pendingReleases = [attachment]; + + expect(() => execute(pendingReleases)).not.toThrow(); + }); + + it("should handle empty pending releases array", () => + { + const pendingReleases: IAttachmentObject[] = []; + + expect(() => execute(pendingReleases)).not.toThrow(); + }); + + it("should process multiple attachments", () => + { + const attachment1 = createMockAttachment(true, true); + const attachment2 = createMockAttachment(true, false); + const attachment3 = createMockAttachment(false, true); + const pendingReleases = [attachment1, attachment2, attachment3]; + + execute(pendingReleases); + + expect(attachment1.texture!.resource.destroy).toHaveBeenCalled(); + expect(attachment1.stencil!.resource.destroy).toHaveBeenCalled(); + expect(attachment2.texture!.resource.destroy).toHaveBeenCalled(); + expect(attachment3.stencil!.resource.destroy).toHaveBeenCalled(); + }); + + it("should call destroy exactly once per resource", () => + { + const attachment = createMockAttachment(true, true); + const pendingReleases = [attachment]; + + execute(pendingReleases); + + expect(attachment.texture!.resource.destroy).toHaveBeenCalledTimes(1); + expect(attachment.stencil!.resource.destroy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerFlushPendingReleasesService.ts b/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerFlushPendingReleasesService.ts new file mode 100644 index 00000000..847a77a5 --- /dev/null +++ b/packages/webgpu/src/FrameBufferManager/service/FrameBufferManagerFlushPendingReleasesService.ts @@ -0,0 +1,21 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; + +/** + * @description フレーム終了時に保留中のテクスチャを解放 + * Release pending textures at end of frame (after submit) + * + * @param {IAttachmentObject[]} pendingReleases + * @return {void} + * @method + * @protected + */ +export const execute = (pendingReleases: IAttachmentObject[]): void => { + for (const att of pendingReleases) { + if (att.texture) { + att.texture.resource.destroy(); + } + if (att.stencil) { + att.stencil.resource.destroy(); + } + } +}; diff --git a/packages/webgpu/src/FrameBufferManager/usecase/FrameBufferManagerCreateAttachmentUseCase.test.ts b/packages/webgpu/src/FrameBufferManager/usecase/FrameBufferManagerCreateAttachmentUseCase.test.ts new file mode 100644 index 00000000..06de2dd0 --- /dev/null +++ b/packages/webgpu/src/FrameBufferManager/usecase/FrameBufferManagerCreateAttachmentUseCase.test.ts @@ -0,0 +1,322 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import { execute } from "./FrameBufferManagerCreateAttachmentUseCase"; + +// Mock $samples from WebGPUUtil +vi.mock("../../WebGPUUtil", () => ({ + "$samples": 1 +})); + +// Mock GPUTextureUsage +const GPUTextureUsage = { + RENDER_ATTACHMENT: 0x10, + TEXTURE_BINDING: 0x04, + COPY_SRC: 0x01, + COPY_DST: 0x02 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +describe("FrameBufferManagerCreateAttachmentUseCase", () => +{ + const createMockDevice = () => + { + const mockView = { "label": "mockView" }; + const mockTexture = { + "createView": vi.fn(() => mockView) + }; + + return { + "createTexture": vi.fn(() => mockTexture), + "_mockTexture": mockTexture + } as unknown as GPUDevice; + }; + + describe("attachment creation", () => + { + it("should create attachment with correct width", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 1, stencilId: 1 }; + + const result = execute( + device, "bgra8unorm", attachments, + "test", 512, 256, false, false, idCounter + ); + + expect(result.width).toBe(512); + }); + + it("should create attachment with correct height", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 1, stencilId: 1 }; + + const result = execute( + device, "bgra8unorm", attachments, + "test", 512, 256, false, false, idCounter + ); + + expect(result.height).toBe(256); + }); + + it("should increment attachment id", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 5, textureId: 1, stencilId: 1 }; + + const result = execute( + device, "bgra8unorm", attachments, + "test", 256, 256, false, false, idCounter + ); + + expect(result.id).toBe(5); + expect(idCounter.nextId).toBe(6); + }); + + it("should add attachment to map", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 1, stencilId: 1 }; + + execute(device, "bgra8unorm", attachments, "myAttachment", 256, 256, false, false, idCounter); + + expect(attachments.has("myAttachment")).toBe(true); + }); + + it("should set mask flag", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 1, stencilId: 1 }; + + const result = execute( + device, "bgra8unorm", attachments, + "test", 256, 256, false, true, idCounter + ); + + expect(result.mask).toBe(true); + }); + + it("should initialize clipLevel to 0", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 1, stencilId: 1 }; + + const result = execute( + device, "bgra8unorm", attachments, + "test", 256, 256, false, false, idCounter + ); + + expect(result.clipLevel).toBe(0); + }); + }); + + describe("texture format", () => + { + it("should use rgba8unorm for atlas", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 1, stencilId: 1 }; + + execute(device, "bgra8unorm", attachments, "atlas", 256, 256, false, false, idCounter); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "format": "rgba8unorm" + }) + ); + }); + + it("should use rgba8unorm for temp_ prefixed attachments", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 1, stencilId: 1 }; + + execute(device, "bgra8unorm", attachments, "temp_filter", 256, 256, false, false, idCounter); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "format": "rgba8unorm" + }) + ); + }); + + it("should use provided format for main attachment", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 1, stencilId: 1 }; + + execute(device, "bgra8unorm", attachments, "main", 256, 256, false, false, idCounter); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "format": "bgra8unorm" + }) + ); + }); + }); + + describe("texture object", () => + { + it("should create texture object with correct dimensions", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 1, stencilId: 1 }; + + const result = execute( + device, "bgra8unorm", attachments, + "test", 320, 240, false, false, idCounter + ); + + expect(result.texture).not.toBeNull(); + expect(result.texture?.width).toBe(320); + expect(result.texture?.height).toBe(240); + }); + + it("should calculate texture area", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 1, stencilId: 1 }; + + const result = execute( + device, "bgra8unorm", attachments, + "test", 100, 50, false, false, idCounter + ); + + expect(result.texture?.area).toBe(5000); + }); + + it("should increment texture id", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 10, stencilId: 1 }; + + const result = execute( + device, "bgra8unorm", attachments, + "test", 256, 256, false, false, idCounter + ); + + expect(result.texture?.id).toBe(10); + expect(idCounter.textureId).toBe(11); + }); + }); + + describe("stencil buffer", () => + { + it("should create stencil buffer for atlas", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 1, stencilId: 1 }; + + const result = execute( + device, "bgra8unorm", attachments, + "atlas", 256, 256, false, false, idCounter + ); + + expect(result.stencil).not.toBeNull(); + }); + + it("should create stencil buffer for main", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 1, stencilId: 1 }; + + const result = execute( + device, "bgra8unorm", attachments, + "main", 256, 256, false, false, idCounter + ); + + expect(result.stencil).not.toBeNull(); + }); + + it("should not create stencil buffer for other attachments", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 1, stencilId: 1 }; + + const result = execute( + device, "bgra8unorm", attachments, + "temp_filter", 256, 256, false, false, idCounter + ); + + expect(result.stencil).toBeNull(); + }); + + it("should use stencil8 format for stencil buffer", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 1, stencilId: 1 }; + + execute(device, "bgra8unorm", attachments, "atlas", 256, 256, false, false, idCounter); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "format": "stencil8" + }) + ); + }); + }); + + describe("color buffer", () => + { + it("should not create color buffer", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 1, stencilId: 1 }; + + const result = execute( + device, "bgra8unorm", attachments, + "test", 256, 256, false, false, idCounter + ); + + expect(result.color).toBeNull(); + }); + }); + + describe("MSAA", () => + { + it("should not create MSAA texture when msaa is false", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 1, stencilId: 1 }; + + const result = execute( + device, "bgra8unorm", attachments, + "test", 256, 256, false, false, idCounter + ); + + expect(result.msaaTexture).toBeNull(); + }); + + it("should set msaa flag on attachment", () => + { + const device = createMockDevice(); + const attachments = new Map(); + const idCounter = { nextId: 1, textureId: 1, stencilId: 1 }; + + const result = execute( + device, "bgra8unorm", attachments, + "test", 256, 256, true, false, idCounter + ); + + expect(result.msaa).toBe(true); + }); + }); +}); diff --git a/packages/webgpu/src/FrameBufferManager/usecase/FrameBufferManagerCreateAttachmentUseCase.ts b/packages/webgpu/src/FrameBufferManager/usecase/FrameBufferManagerCreateAttachmentUseCase.ts new file mode 100644 index 00000000..a17d0596 --- /dev/null +++ b/packages/webgpu/src/FrameBufferManager/usecase/FrameBufferManagerCreateAttachmentUseCase.ts @@ -0,0 +1,153 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import type { ITextureObject } from "../../interface/ITextureObject"; +import type { IStencilBufferObject } from "../../interface/IStencilBufferObject"; +import { $samples } from "../../WebGPUUtil"; + +/** + * @description アタッチメントオブジェクトを作成 + * Create attachment object + * + * @param {GPUDevice} device + * @param {GPUTextureFormat} format + * @param {Map} attachments + * @param {string} name + * @param {number} width + * @param {number} height + * @param {boolean} msaa + * @param {boolean} mask + * @param {{ nextId: number, textureId: number, stencilId: number }} idCounter + * @return {IAttachmentObject} + * @method + * @protected + */ +export const execute = ( + device: GPUDevice, + format: GPUTextureFormat, + attachments: Map, + name: string, + width: number, + height: number, + msaa: boolean, + mask: boolean, + idCounter: { nextId: number; textureId: number; stencilId: number } +): IAttachmentObject => { + // アトラスかどうか判定(atlas, atlas_0, atlas_1, ...) + const isAtlas = name === "atlas" || name.startsWith("atlas_"); + + // アトラステクスチャと一時アタッチメントはRGBA8フォーマットを使用 + // (copyExternalImageToTextureとの互換性、およびcopyTextureToTextureでのフォーマット一致のため) + // mainアタッチメントはスワップチェーンと同じbgra8unormフォーマットを使用 + const textureFormat = isAtlas || name.startsWith("temp_") ? "rgba8unorm" : format; + + // MSAAを使用するかどうか(アトラスでmsaa有効かつ$samples > 1の場合) + // 現在はアトラスのみにMSAAを適用(他のアタッチメントはmsaa=falseで呼び出される) + const useMsaa = msaa || isAtlas && $samples > 1; + const sampleCount = useMsaa ? $samples : 1; + + const gpuTexture = device.createTexture({ + "size": { width, height }, + "format": textureFormat, + "usage": GPUTextureUsage.RENDER_ATTACHMENT | + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_SRC | + GPUTextureUsage.COPY_DST + }); + + const textureView = gpuTexture.createView(); + + // ITextureObject形式で格納(解決先テクスチャ) + const texture: ITextureObject = { + "id": idCounter.textureId++, + "resource": gpuTexture, + "view": textureView, + width, + height, + "area": width * height, + "smooth": true + }; + + // MSAAテクスチャを作成(sampleCount > 1の場合) + let msaaTexture: ITextureObject | null = null; + if (useMsaa) { + const msaaGpuTexture = device.createTexture({ + "size": { width, height }, + "format": textureFormat, + "sampleCount": sampleCount, + "usage": GPUTextureUsage.RENDER_ATTACHMENT + }); + const msaaTextureView = msaaGpuTexture.createView(); + + msaaTexture = { + "id": idCounter.textureId++, + "resource": msaaGpuTexture, + "view": msaaTextureView, + width, + height, + "area": width * height, + "smooth": true + }; + } + + // アトラスとメインアタッチメント用にステンシルテクスチャを作成 + // アトラス: 2パスフィルレンダリング用 + // メイン: マスク描画用 + let stencil: IStencilBufferObject | null = null; + let msaaStencil: IStencilBufferObject | null = null; + + if (isAtlas || name === "main" || mask) { + const stencilTexture = device.createTexture({ + "size": { width, height }, + "format": "stencil8", + "usage": GPUTextureUsage.RENDER_ATTACHMENT + }); + const stencilView = stencilTexture.createView(); + + stencil = { + "id": idCounter.stencilId++, + "resource": stencilTexture, + "view": stencilView, + width, + height, + "area": width * height, + "dirty": false + }; + + // MSAAステンシルテクスチャを作成(sampleCount > 1の場合) + if (useMsaa) { + const msaaStencilTexture = device.createTexture({ + "size": { width, height }, + "format": "stencil8", + "sampleCount": sampleCount, + "usage": GPUTextureUsage.RENDER_ATTACHMENT + }); + const msaaStencilView = msaaStencilTexture.createView(); + + msaaStencil = { + "id": idCounter.stencilId++, + "resource": msaaStencilTexture, + "view": msaaStencilView, + width, + height, + "area": width * height, + "dirty": false + }; + } + } + + const attachment: IAttachmentObject = { + "id": idCounter.nextId++, + width, + height, + "clipLevel": 0, + "msaa": useMsaa, + mask, + "color": null, + texture, + stencil, + msaaTexture, + msaaStencil + }; + + attachments.set(name, attachment); + return attachment; +}; diff --git a/packages/webgpu/src/FrameBufferManager/usecase/FrameBufferManagerReleaseTemporaryAttachmentUseCase.test.ts b/packages/webgpu/src/FrameBufferManager/usecase/FrameBufferManagerReleaseTemporaryAttachmentUseCase.test.ts new file mode 100644 index 00000000..5e5fce3c --- /dev/null +++ b/packages/webgpu/src/FrameBufferManager/usecase/FrameBufferManagerReleaseTemporaryAttachmentUseCase.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import { execute } from "./FrameBufferManagerReleaseTemporaryAttachmentUseCase"; + +describe("FrameBufferManagerReleaseTemporaryAttachmentUseCase", () => +{ + let attachments: Map; + let pendingReleases: IAttachmentObject[]; + + const createMockAttachment = (id: number): IAttachmentObject => ({ + id, + "width": 256, + "height": 256, + "texture": null, + "stencil": null, + "currentColorBuffer": null, + "currentStencilBuffer": null, + "msaaColorBuffer": null + } as IAttachmentObject); + + beforeEach(() => + { + attachments = new Map(); + pendingReleases = []; + }); + + it("should remove attachment from map by id", () => + { + const attachment = createMockAttachment(1); + attachments.set("temp_1", attachment); + + execute(attachments, pendingReleases, attachment); + + expect(attachments.has("temp_1")).toBe(false); + }); + + it("should add attachment to pending releases", () => + { + const attachment = createMockAttachment(1); + attachments.set("temp_1", attachment); + + execute(attachments, pendingReleases, attachment); + + expect(pendingReleases).toContain(attachment); + }); + + it("should handle attachment not found in map", () => + { + const attachment = createMockAttachment(999); + + expect(() => execute(attachments, pendingReleases, attachment)).not.toThrow(); + expect(pendingReleases).toHaveLength(0); + }); + + it("should only remove matching attachment", () => + { + const attachment1 = createMockAttachment(1); + const attachment2 = createMockAttachment(2); + attachments.set("temp_1", attachment1); + attachments.set("temp_2", attachment2); + + execute(attachments, pendingReleases, attachment1); + + expect(attachments.has("temp_1")).toBe(false); + expect(attachments.has("temp_2")).toBe(true); + }); + + it("should stop after finding first match", () => + { + const attachment = createMockAttachment(1); + attachments.set("first", attachment); + attachments.set("second", attachment); // Same attachment, different key + + execute(attachments, pendingReleases, attachment); + + // Only one should be removed + expect(attachments.size).toBe(1); + expect(pendingReleases).toHaveLength(1); + }); + + it("should handle empty attachments map", () => + { + const attachment = createMockAttachment(1); + + expect(() => execute(attachments, pendingReleases, attachment)).not.toThrow(); + expect(pendingReleases).toHaveLength(0); + }); + + it("should find attachment regardless of key name", () => + { + const attachment = createMockAttachment(42); + attachments.set("any_key_name", attachment); + + execute(attachments, pendingReleases, attachment); + + expect(attachments.size).toBe(0); + expect(pendingReleases).toContain(attachment); + }); + + it("should preserve other pending releases", () => + { + const existing = createMockAttachment(100); + pendingReleases.push(existing); + + const attachment = createMockAttachment(1); + attachments.set("temp", attachment); + + execute(attachments, pendingReleases, attachment); + + expect(pendingReleases).toHaveLength(2); + expect(pendingReleases).toContain(existing); + expect(pendingReleases).toContain(attachment); + }); +}); diff --git a/packages/webgpu/src/FrameBufferManager/usecase/FrameBufferManagerReleaseTemporaryAttachmentUseCase.ts b/packages/webgpu/src/FrameBufferManager/usecase/FrameBufferManagerReleaseTemporaryAttachmentUseCase.ts new file mode 100644 index 00000000..cb110ebd --- /dev/null +++ b/packages/webgpu/src/FrameBufferManager/usecase/FrameBufferManagerReleaseTemporaryAttachmentUseCase.ts @@ -0,0 +1,29 @@ +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; + +/** + * @description 一時的なアタッチメントを解放(フィルター処理用) + * Releases a temporary attachment after filter processing + * テクスチャは即座に破棄せず、フレーム終了時に遅延解放します + * + * @param {Map} attachments + * @param {IAttachmentObject[]} pendingReleases + * @param {IAttachmentObject} attachment + * @return {void} + * @method + * @protected + */ +export const execute = ( + attachments: Map, + pendingReleases: IAttachmentObject[], + attachment: IAttachmentObject +): void => { + // 名前を検索して削除(Map から削除するが、テクスチャは破棄しない) + for (const [name, att] of attachments.entries()) { + if (att.id === attachment.id) { + attachments.delete(name); + // フレーム終了時に遅延解放するためキューに追加 + pendingReleases.push(att); + break; + } + } +}; diff --git a/packages/webgpu/src/Gradient/GradientLUTCache.test.ts b/packages/webgpu/src/Gradient/GradientLUTCache.test.ts new file mode 100644 index 00000000..cfc97e47 --- /dev/null +++ b/packages/webgpu/src/Gradient/GradientLUTCache.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + $setGradientLUTDevice, + $getGradientAttachmentObjectWithResolution, + $getGradientAttachmentObject, + $clearGradientAttachmentObjects +} from "./GradientLUTCache"; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + TEXTURE_BINDING: 0x04, + COPY_DST: 0x02, + RENDER_ATTACHMENT: 0x10 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +describe("GradientLUTCache", () => +{ + const createMockDevice = () => + { + const mockTexture = { + "createView": vi.fn(() => ({ "label": "mockView" })), + "destroy": vi.fn() + }; + return { + "createTexture": vi.fn(() => mockTexture), + "_mockTexture": mockTexture + } as unknown as GPUDevice & { _mockTexture: any }; + }; + + beforeEach(() => + { + // Clear cache before each test + $clearGradientAttachmentObjects(); + }); + + describe("$setGradientLUTDevice", () => + { + it("should set the device for texture creation", () => + { + const device = createMockDevice(); + $setGradientLUTDevice(device); + + // Getting an attachment should now use the device + const attachment = $getGradientAttachmentObject(); + + expect(device.createTexture).toHaveBeenCalled(); + expect(attachment).toBeDefined(); + }); + }); + + describe("$getGradientAttachmentObjectWithResolution", () => + { + it("should create attachment with specified resolution", () => + { + const device = createMockDevice(); + $setGradientLUTDevice(device); + + const attachment = $getGradientAttachmentObjectWithResolution(512); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "size": { "width": 512, "height": 1 }, + "format": "rgba8unorm" + }) + ); + expect(attachment.width).toBe(512); + expect(attachment.height).toBe(1); + }); + + it("should cache attachments by resolution", () => + { + const device = createMockDevice(); + $setGradientLUTDevice(device); + + const attachment1 = $getGradientAttachmentObjectWithResolution(256); + const attachment2 = $getGradientAttachmentObjectWithResolution(256); + + // Should return the same attachment + expect(attachment1).toBe(attachment2); + // Should only create texture once + expect(device.createTexture).toHaveBeenCalledTimes(1); + }); + + it("should create separate attachments for different resolutions", () => + { + const device = createMockDevice(); + $setGradientLUTDevice(device); + + const attachment256 = $getGradientAttachmentObjectWithResolution(256); + const attachment512 = $getGradientAttachmentObjectWithResolution(512); + + expect(attachment256).not.toBe(attachment512); + expect(attachment256.width).toBe(256); + expect(attachment512.width).toBe(512); + expect(device.createTexture).toHaveBeenCalledTimes(2); + }); + + it("should set correct attachment properties", () => + { + const device = createMockDevice(); + $setGradientLUTDevice(device); + + const attachment = $getGradientAttachmentObjectWithResolution(256); + + expect(attachment.id).toBe(256); + expect(attachment.width).toBe(256); + expect(attachment.height).toBe(1); + expect(attachment.clipLevel).toBe(0); + expect(attachment.msaa).toBe(false); + expect(attachment.mask).toBe(false); + expect(attachment.texture).toBeDefined(); + expect(attachment.stencil).toBe(null); + }); + }); + + describe("$getGradientAttachmentObject", () => + { + it("should return default 256 resolution attachment", () => + { + const device = createMockDevice(); + $setGradientLUTDevice(device); + + const attachment = $getGradientAttachmentObject(); + + expect(attachment.width).toBe(256); + expect(attachment.height).toBe(1); + }); + }); + + describe("$clearGradientAttachmentObjects", () => + { + it("should destroy all cached textures", () => + { + const device = createMockDevice(); + $setGradientLUTDevice(device); + + // Create multiple attachments + $getGradientAttachmentObjectWithResolution(256); + $getGradientAttachmentObjectWithResolution(512); + + // Clear should destroy textures + $clearGradientAttachmentObjects(); + + expect(device._mockTexture.destroy).toHaveBeenCalled(); + }); + + it("should clear cache so new attachments are created", () => + { + const device = createMockDevice(); + $setGradientLUTDevice(device); + + $getGradientAttachmentObjectWithResolution(256); + expect(device.createTexture).toHaveBeenCalledTimes(1); + + $clearGradientAttachmentObjects(); + + $getGradientAttachmentObjectWithResolution(256); + expect(device.createTexture).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/webgpu/src/Gradient/GradientLUTCache.ts b/packages/webgpu/src/Gradient/GradientLUTCache.ts new file mode 100644 index 00000000..ad05cad5 --- /dev/null +++ b/packages/webgpu/src/Gradient/GradientLUTCache.ts @@ -0,0 +1,200 @@ +import type { IAttachmentObject } from "../interface/IAttachmentObject"; +import { $releaseFillTexture } from "../FillTexturePool"; + +/** + * @description 解像度別のAttachmentObjectキャッシュ + * Attachment object cache by resolution + * 注意: グラデーションLUTは共有テクスチャに描画されるため、 + * キャッシュは使用しません。各フレームで再描画が必要です。 + * Note: Gradient LUT is drawn to a shared texture, so caching + * is not used. Re-drawing is required each frame. + * + * @type {Map} + * @private + */ +const $gradientAttachmentObjects: Map = new Map(); + +/** + * @description GPUDeviceの参照 + * @private + */ +let $device: GPUDevice | null = null; + +/** + * @description GPUDeviceを設定 + * Set GPUDevice + * + * @param {GPUDevice} device + * @return {void} + * @method + * @protected + */ +export const $setGradientLUTDevice = (device: GPUDevice): void => +{ + $device = device; +}; + +/** + * @description 指定解像度のAttachmentObjectを返却 + * Returns AttachmentObject with specified resolution + * + * @param {number} resolution + * @return {IAttachmentObject} + * @method + * @protected + */ +export const $getGradientAttachmentObjectWithResolution = (resolution: number): IAttachmentObject => +{ + if (!$gradientAttachmentObjects.has(resolution) && $device) { + // 1xN テクスチャを作成 + const texture = $device.createTexture({ + "size": { "width": resolution, "height": 1 }, + "format": "rgba8unorm", + "usage": GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT + }); + + const attachment: IAttachmentObject = { + "id": resolution, + "width": resolution, + "height": 1, + "clipLevel": 0, + "msaa": false, + "mask": false, + "color": null, + "texture": { + "id": resolution, + "resource": texture, + "view": texture.createView(), + "width": resolution, + "height": 1, + "area": resolution, + "smooth": true + }, + "stencil": null, + "msaaTexture": null, + "msaaStencil": null + }; + + $gradientAttachmentObjects.set(resolution, attachment); + } + + return $gradientAttachmentObjects.get(resolution) as NonNullable; +}; + +/** + * @description デフォルトの256解像度のAttachmentObjectを返却 + * Returns default 256 resolution AttachmentObject + * + * @return {IAttachmentObject} + * @method + * @protected + */ +export const $getGradientAttachmentObject = (): IAttachmentObject => +{ + return $getGradientAttachmentObjectWithResolution(256); +}; + +/** + * @description 全ての共有アタッチメントを破棄してクリア + * Destroy and clear all shared attachments + * + * @return {void} + * @method + * @protected + */ +export const $clearGradientAttachmentObjects = (): void => +{ + for (const attachment of $gradientAttachmentObjects.values()) { + if (attachment.texture?.resource) { + attachment.texture.resource.destroy(); + } + } + $gradientAttachmentObjects.clear(); +}; + +// === Gradient LUT テクスチャキャッシュ === + +interface IGradientLUTEntry { + texture: GPUTexture; + view: GPUTextureView; + lastUsedFrame: number; +} + +const $lutCache: Map = new Map(); +let $currentFrame: number = 0; +const $LUT_TTL: number = 60; + +/** + * @description グラデーションLUTのキャッシュキーを生成 + */ +const $buildLUTKey = ( + stops: number[], + spread: number, + interpolation: number +): string => +{ + return `${spread}_${interpolation}_${stops.join(",")}`; +}; + +/** + * @description キャッシュからLUTテクスチャを取得。ヒットしなければnullを返す。 + */ +export const $getLUTFromCache = ( + stops: number[], + spread: number, + interpolation: number +): IGradientLUTEntry | null => +{ + const key = $buildLUTKey(stops, spread, interpolation); + const entry = $lutCache.get(key); + if (entry) { + entry.lastUsedFrame = $currentFrame; + return entry; + } + return null; +}; + +/** + * @description LUTテクスチャをキャッシュに格納 + */ +export const $putLUTToCache = ( + stops: number[], + spread: number, + interpolation: number, + texture: GPUTexture, + view: GPUTextureView +): void => +{ + const key = $buildLUTKey(stops, spread, interpolation); + $lutCache.set(key, { + texture, + view, + "lastUsedFrame": $currentFrame + }); +}; + +/** + * @description フレーム終了時にTTL超過エントリを解放 + */ +export const $cleanupLUTCache = (): void => +{ + $currentFrame++; + for (const [key, entry] of $lutCache) { + if ($currentFrame - entry.lastUsedFrame > $LUT_TTL) { + $releaseFillTexture(entry.texture); + $lutCache.delete(key); + } + } +}; + +/** + * @description 全LUTキャッシュを破棄 + */ +export const $clearLUTCache = (): void => +{ + for (const entry of $lutCache.values()) { + $releaseFillTexture(entry.texture); + } + $lutCache.clear(); + $currentFrame = 0; +}; diff --git a/packages/webgpu/src/Gradient/GradientLUTGenerator.test.ts b/packages/webgpu/src/Gradient/GradientLUTGenerator.test.ts new file mode 100644 index 00000000..324bfd67 --- /dev/null +++ b/packages/webgpu/src/Gradient/GradientLUTGenerator.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from "vitest"; +import { + getAdaptiveResolution, + generateGradientLUT, + generateFilterGradientLUT +} from "./GradientLUTGenerator"; + +describe("GradientLUTGenerator", () => +{ + describe("getAdaptiveResolution", () => + { + it("should return 256 for 1-4 stops", () => + { + expect(getAdaptiveResolution(1)).toBe(256); + expect(getAdaptiveResolution(2)).toBe(256); + expect(getAdaptiveResolution(3)).toBe(256); + expect(getAdaptiveResolution(4)).toBe(256); + }); + + it("should return 512 for 5-8 stops", () => + { + expect(getAdaptiveResolution(5)).toBe(512); + expect(getAdaptiveResolution(6)).toBe(512); + expect(getAdaptiveResolution(7)).toBe(512); + expect(getAdaptiveResolution(8)).toBe(512); + }); + + it("should return 1024 for 9+ stops", () => + { + expect(getAdaptiveResolution(9)).toBe(1024); + expect(getAdaptiveResolution(10)).toBe(1024); + expect(getAdaptiveResolution(20)).toBe(1024); + }); + }); + + describe("generateGradientLUT", () => + { + it("should generate correct size LUT", () => + { + // 2 stops = 10 values (offset, R, G, B, A for each) + const stops = [0, 255, 0, 0, 255, 1, 0, 0, 255, 255]; // red to blue + const result = generateGradientLUT(stops, 0, 1); + + // 2 stops -> resolution 256, RGBA = 256 * 4 + expect(result.length).toBe(256 * 4); + }); + + it("should have correct start color", () => + { + // Red at start, blue at end + const stops = [0, 255, 0, 0, 255, 1, 0, 0, 255, 255]; + const result = generateGradientLUT(stops, 0, 1); + + // First pixel should be red + expect(result[0]).toBe(255); // R + expect(result[1]).toBe(0); // G + expect(result[2]).toBe(0); // B + expect(result[3]).toBe(255); // A + }); + + it("should have correct end color", () => + { + // Red at start, blue at end + const stops = [0, 255, 0, 0, 255, 1, 0, 0, 255, 255]; + const result = generateGradientLUT(stops, 0, 1); + + // Last pixel should be blue + const lastOffset = (256 - 1) * 4; + expect(result[lastOffset + 0]).toBe(0); // R + expect(result[lastOffset + 1]).toBe(0); // G + expect(result[lastOffset + 2]).toBe(255); // B + expect(result[lastOffset + 3]).toBe(255); // A + }); + + it("should interpolate colors in RGB mode", () => + { + // Black at start, white at end + const stops = [0, 0, 0, 0, 255, 1, 255, 255, 255, 255]; + const result = generateGradientLUT(stops, 0, 1); // RGB mode + + // Middle pixel should be around 127-128 + const midOffset = 128 * 4; + expect(result[midOffset]).toBeGreaterThan(120); + expect(result[midOffset]).toBeLessThan(135); + }); + + it("should handle single stop", () => + { + // Single red stop + const stops = [0.5, 255, 0, 0, 255]; + const result = generateGradientLUT(stops, 0, 1); + + expect(result.length).toBe(256 * 4); + // Should be all red + expect(result[0]).toBe(255); + expect(result[1]).toBe(0); + expect(result[2]).toBe(0); + }); + + it("should handle three stops", () => + { + // Red -> Green -> Blue + const stops = [ + 0, 255, 0, 0, 255, // Red at 0 + 0.5, 0, 255, 0, 255, // Green at 0.5 + 1, 0, 0, 255, 255 // Blue at 1 + ]; + const result = generateGradientLUT(stops, 0, 1); + + // Start should be red + expect(result[0]).toBe(255); + expect(result[1]).toBe(0); + expect(result[2]).toBe(0); + + // End should be blue + const lastOffset = (256 - 1) * 4; + expect(result[lastOffset]).toBe(0); + expect(result[lastOffset + 1]).toBe(0); + expect(result[lastOffset + 2]).toBe(255); + }); + }); + + describe("generateFilterGradientLUT", () => + { + it("should generate 256x4 bytes LUT", () => + { + const ratios = new Float32Array([0, 255]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); // Red, Blue + const alphas = new Float32Array([1, 1]); + + const result = generateFilterGradientLUT(ratios, colors, alphas); + + expect(result.length).toBe(256 * 4); + }); + + it("should have correct start color with alpha", () => + { + const ratios = new Float32Array([0, 255]); + const colors = new Float32Array([0xFF0000, 0x0000FF]); // Red, Blue + const alphas = new Float32Array([1, 1]); + + const result = generateFilterGradientLUT(ratios, colors, alphas); + + // First pixel: red with full alpha (premultiplied) + expect(result[0]).toBe(255); // R * A + expect(result[1]).toBe(0); // G * A + expect(result[2]).toBe(0); // B * A + expect(result[3]).toBe(255); // A + }); + + it("should apply premultiplied alpha", () => + { + const ratios = new Float32Array([0, 255]); + const colors = new Float32Array([0xFF0000, 0xFF0000]); // Red + const alphas = new Float32Array([0.5, 0.5]); + + const result = generateFilterGradientLUT(ratios, colors, alphas); + + // Red with 0.5 alpha (premultiplied) + expect(result[0]).toBe(128); // R * 0.5 = 127.5 -> 128 + expect(result[1]).toBe(0); + expect(result[2]).toBe(0); + expect(result[3]).toBe(128); // A * 255 = 127.5 -> 128 + }); + + it("should interpolate between stops", () => + { + const ratios = new Float32Array([0, 128, 255]); + const colors = new Float32Array([0xFF0000, 0x00FF00, 0x0000FF]); + const alphas = new Float32Array([1, 1, 1]); + + const result = generateFilterGradientLUT(ratios, colors, alphas); + + // Should have different colors at different positions + expect(result[0]).toBe(255); // Red at start + expect(result[255 * 4 + 2]).toBe(255); // Blue at end + }); + }); +}); diff --git a/packages/webgpu/src/Gradient/GradientLUTGenerator.ts b/packages/webgpu/src/Gradient/GradientLUTGenerator.ts new file mode 100644 index 00000000..58fbb4a8 --- /dev/null +++ b/packages/webgpu/src/Gradient/GradientLUTGenerator.ts @@ -0,0 +1,251 @@ +/** + * @description グラデーションLUTテクスチャ生成 + * Gradient LUT texture generator + */ + +/** + * @description ストップ数に応じた適応解像度を取得 + * @param {number} stopsLength + * @return {number} + */ +export const getAdaptiveResolution = (stopsLength: number): number => +{ + if (stopsLength <= 4) { + return 256; + } + if (stopsLength <= 8) { + return 512; + } + return 1024; +}; + +/** + * @description グラデーションLUTテクスチャデータを生成 + * stops配列: [offset, R, G, B, A, offset, R, G, B, A, ...] + * 注意: R, G, B, A は 0-255 範囲 + * LUTは0-1の範囲の色を生成し、spread処理はシェーダー側で行う + * @param {number[]} stops - グラデーションストップ配列 + * @param {number} _spread - スプレッドメソッド(未使用、シェーダー側で処理) + * @param {number} interpolation - 補間方法 (0: linearRGB, 1: RGB) ※WebGL互換 + * @return {Uint8Array} + */ +export const generateGradientLUT = ( + stops: number[], + _spread: number, + interpolation: number +): Uint8Array => +{ + // ストップ数を計算(5要素ずつ: offset, R, G, B, A) + const stopsLength = stops.length / 5; + const resolution = getAdaptiveResolution(stopsLength); + + // RGBA形式のLUTデータを作成 + const lutData = new Uint8Array(resolution * 4); + + // 各ピクセルの色を補間 + // spread処理はシェーダー側で行うため、LUTは単純に0-1の範囲を生成 + for (let i = 0; i < resolution; i++) { + const t = i / (resolution - 1); + + // 色を補間(色は0-255範囲で返される) + const color = interpolateColor(stops, t, interpolation); + + // WebGL版と同じ: プリマルチプライドアルファは適用しない + // LUTにはストレート(非プリマルチプライド)の色を格納 + // プリマルチプライドはシェーダー側でサンプリング後に行う + // これにより、線形補間時に正しい色が得られる + + // LUTデータに書き込み(非プリマルチプライド) + const offset = i * 4; + lutData[offset + 0] = Math.round(Math.max(0, Math.min(255, color.r))); + lutData[offset + 1] = Math.round(Math.max(0, Math.min(255, color.g))); + lutData[offset + 2] = Math.round(Math.max(0, Math.min(255, color.b))); + lutData[offset + 3] = Math.round(Math.max(0, Math.min(255, color.a))); + } + + return lutData; +}; + +/** + * @description 色を補間 + * 色は0-255範囲で入力され、0-255範囲で出力される + * @param {number[]} stops + * @param {number} t + * @param {number} interpolation - 0: linearRGB, 1: RGB(WebGL互換) + * @return {{ r: number, g: number, b: number, a: number }} + */ +const interpolateColor = ( + stops: number[], + t: number, + interpolation: number +): { r: number; g: number; b: number; a: number } => +{ + const stopsLength = stops.length / 5; + + // 最初と最後のストップを見つける + let startIdx = 0; + let endIdx = 0; + + for (let i = 0; i < stopsLength; i++) { + // offset は既に 0-1 範囲 + const offset = stops[i * 5]; + if (offset <= t) { + startIdx = i; + } + if (offset >= t && endIdx === 0) { + endIdx = i; + break; + } + } + + // 最後のストップを超えている場合 + if (endIdx === 0) { + endIdx = stopsLength - 1; + } + + // 同じストップの場合(色は0-255範囲) + if (startIdx === endIdx) { + const idx = startIdx * 5; + return { + "r": stops[idx + 1], + "g": stops[idx + 2], + "b": stops[idx + 3], + "a": stops[idx + 4] + }; + } + + // 補間係数を計算(offset は既に 0-1 範囲) + const startOffset = stops[startIdx * 5]; + const endOffset = stops[endIdx * 5]; + const localT = (t - startOffset) / (endOffset - startOffset); + + // 色を取得(0-255範囲) + const startR = stops[startIdx * 5 + 1]; + const startG = stops[startIdx * 5 + 2]; + const startB = stops[startIdx * 5 + 3]; + const startA = stops[startIdx * 5 + 4]; + + const endR = stops[endIdx * 5 + 1]; + const endG = stops[endIdx * 5 + 2]; + const endB = stops[endIdx * 5 + 3]; + const endA = stops[endIdx * 5 + 4]; + + // 補間(WebGL互換: interpolation === 0 がlinearRGB) + if (interpolation === 0) { + // linearRGB補間(ガンマ補正) + // 0-255 → 0-1に正規化してからリニア変換 + return { + "r": linearToSRGB(lerp(sRGBToLinear(startR / 255), sRGBToLinear(endR / 255), localT)) * 255, + "g": linearToSRGB(lerp(sRGBToLinear(startG / 255), sRGBToLinear(endG / 255), localT)) * 255, + "b": linearToSRGB(lerp(sRGBToLinear(startB / 255), sRGBToLinear(endB / 255), localT)) * 255, + "a": lerp(startA, endA, localT) + }; + } + + // RGB補間(リニア、デフォルト)- 0-255範囲でそのまま補間 + return { + "r": lerp(startR, endR, localT), + "g": lerp(startG, endG, localT), + "b": lerp(startB, endB, localT), + "a": lerp(startA, endA, localT) + }; +}; + +/** + * @description 線形補間 + */ +const lerp = (a: number, b: number, t: number): number => +{ + return a + (b - a) * t; +}; + +/** + * @description sRGBからリニアへ変換(入力: 0-1正規化値) + * WebGL版と同じガンマ値 2.23333333 を使用 + */ +const sRGBToLinear = (value: number): number => +{ + // WebGL版と同じ簡易ガンマ補正 + return Math.pow(value, 2.23333333); +}; + +/** + * @description リニアからsRGBへ変換(出力: 0-1正規化値) + * WebGL版と同じガンマ値 0.45454545 (= 1/2.2) を使用 + */ +const linearToSRGB = (value: number): number => +{ + // WebGL版と同じ簡易ガンマ補正 + return Math.pow(value, 0.45454545); +}; + +/** + * @description フィルター用グラデーションLUTテクスチャデータを生成 + * ratios, colors, alphas配列から1D LUTを生成 + * @param {Float32Array} ratios - 比率配列 (0-255) + * @param {Float32Array} colors - 色配列 (32bit整数) + * @param {Float32Array} alphas - アルファ配列 (0-1) + * @return {Uint8Array} + */ +export const generateFilterGradientLUT = ( + ratios: Float32Array, + colors: Float32Array, + alphas: Float32Array +): Uint8Array => +{ + const resolution = 256; + const lutData = new Uint8Array(resolution * 4); + const stopsLength = ratios.length; + + // ストップデータを構築 + const stops: Array<{ offset: number; r: number; g: number; b: number; a: number }> = []; + for (let i = 0; i < stopsLength; i++) { + const color = colors[i]; + stops.push({ + "offset": ratios[i] / 255, + "r": (color >> 16 & 0xFF) / 255, + "g": (color >> 8 & 0xFF) / 255, + "b": (color & 0xFF) / 255, + "a": alphas[i] + }); + } + + // 各ピクセルの色を補間 + for (let i = 0; i < resolution; i++) { + const t = i / (resolution - 1); + + // ストップを見つける + let startIdx = 0; + let endIdx = stopsLength - 1; + + for (let j = 0; j < stopsLength - 1; j++) { + if (stops[j].offset <= t && stops[j + 1].offset >= t) { + startIdx = j; + endIdx = j + 1; + break; + } + } + + // 補間 + const start = stops[startIdx]; + const end = stops[endIdx]; + let localT = 0; + if (end.offset !== start.offset) { + localT = (t - start.offset) / (end.offset - start.offset); + } + + const r = lerp(start.r, end.r, localT); + const g = lerp(start.g, end.g, localT); + const b = lerp(start.b, end.b, localT); + const a = lerp(start.a, end.a, localT); + + // プリマルチプライドアルファで書き込み + const offset = i * 4; + lutData[offset + 0] = Math.round(r * a * 255); + lutData[offset + 1] = Math.round(g * a * 255); + lutData[offset + 2] = Math.round(b * a * 255); + lutData[offset + 3] = Math.round(a * 255); + } + + return lutData; +}; diff --git a/packages/webgpu/src/Grid.test.ts b/packages/webgpu/src/Grid.test.ts new file mode 100644 index 00000000..34ca797c --- /dev/null +++ b/packages/webgpu/src/Grid.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + $gridDataMap, + $fillBufferIndex, + $terminateGrid +} from "./Grid"; + +describe("Grid", () => +{ + beforeEach(() => + { + $terminateGrid(); + }); + + describe("$gridDataMap", () => + { + it("should be a Map", () => + { + expect($gridDataMap).toBeInstanceOf(Map); + }); + + it("should be empty after terminate", () => + { + $gridDataMap.set(1, new Float32Array([1, 2, 3])); + $terminateGrid(); + expect($gridDataMap.size).toBe(0); + }); + + it("should store and retrieve grid data", () => + { + const data = new Float32Array([1, 2, 3, 4]); + $gridDataMap.set(42, data); + + expect($gridDataMap.has(42)).toBe(true); + expect($gridDataMap.get(42)).toBe(data); + }); + + it("should allow null values", () => + { + $gridDataMap.set(1, null); + expect($gridDataMap.get(1)).toBeNull(); + }); + }); + + describe("$terminateGrid", () => + { + it("should clear grid data map", () => + { + $gridDataMap.set(1, new Float32Array([1])); + $gridDataMap.set(2, new Float32Array([2])); + $gridDataMap.set(3, new Float32Array([3])); + + $terminateGrid(); + + expect($gridDataMap.size).toBe(0); + }); + + it("should reset fill buffer index", () => + { + // Note: $fillBufferIndex is exported as let, so we can't directly modify it + // but we can verify it gets reset by $terminateGrid + $terminateGrid(); + expect($fillBufferIndex).toBe(0); + }); + }); +}); diff --git a/packages/webgpu/src/Grid.ts b/packages/webgpu/src/Grid.ts new file mode 100644 index 00000000..bf451fb3 --- /dev/null +++ b/packages/webgpu/src/Grid.ts @@ -0,0 +1,23 @@ +/** + * @description グリッドデータマップ(9-slice用) + * Grid data map for 9-slice transformation + * @type {Map} + */ +export const $gridDataMap: Map = new Map(); + +/** + * @description 現在のフィルバッファインデックス + * Current fill buffer index + * @type {number} + */ +export let $fillBufferIndex: number = 0; + +/** + * @description グリッド情報を初期化 + * Initialize grid information + * @return {void} + */ +export const $terminateGrid = (): void => { + $gridDataMap.clear(); + $fillBufferIndex = 0; +}; diff --git a/packages/webgpu/src/Mask.test.ts b/packages/webgpu/src/Mask.test.ts new file mode 100644 index 00000000..15310a93 --- /dev/null +++ b/packages/webgpu/src/Mask.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + $setMaskDrawing, + $isMaskDrawing, + $setMaskTestEnabled, + $isMaskTestEnabled, + $setMaskStencilReference, + $getMaskStencilReference, + $pushMaskAttachment, + $popMaskAttachment, + $hasMaskAttachment, + $clipBounds, + $clipLevels, + $resetMaskState +} from "./Mask"; + +describe("Mask", () => +{ + beforeEach(() => + { + $resetMaskState(); + }); + + describe("mask drawing state", () => + { + it("should default to false", () => + { + expect($isMaskDrawing()).toBe(false); + }); + + it("should set and get mask drawing state", () => + { + $setMaskDrawing(true); + expect($isMaskDrawing()).toBe(true); + + $setMaskDrawing(false); + expect($isMaskDrawing()).toBe(false); + }); + }); + + describe("mask test state", () => + { + it("should default to false", () => + { + expect($isMaskTestEnabled()).toBe(false); + }); + + it("should set and get mask test state", () => + { + $setMaskTestEnabled(true); + expect($isMaskTestEnabled()).toBe(true); + + $setMaskTestEnabled(false); + expect($isMaskTestEnabled()).toBe(false); + }); + }); + + describe("mask stencil reference", () => + { + it("should default to 0", () => + { + expect($getMaskStencilReference()).toBe(0); + }); + + it("should set and get stencil reference", () => + { + $setMaskStencilReference(5); + expect($getMaskStencilReference()).toBe(5); + + $setMaskStencilReference(255); + expect($getMaskStencilReference()).toBe(255); + }); + }); + + describe("mask attachment stack", () => + { + it("should default to empty", () => + { + expect($hasMaskAttachment()).toBe(false); + }); + + it("should push and pop attachments", () => + { + const attachment1 = { "id": 1 }; + const attachment2 = { "id": 2 }; + + $pushMaskAttachment(attachment1); + expect($hasMaskAttachment()).toBe(true); + + $pushMaskAttachment(attachment2); + expect($hasMaskAttachment()).toBe(true); + + expect($popMaskAttachment()).toBe(attachment2); + expect($hasMaskAttachment()).toBe(true); + + expect($popMaskAttachment()).toBe(attachment1); + expect($hasMaskAttachment()).toBe(false); + }); + + it("should return undefined when popping empty stack", () => + { + expect($popMaskAttachment()).toBeUndefined(); + }); + }); + + describe("clip bounds and levels", () => + { + it("should be Maps", () => + { + expect($clipBounds).toBeInstanceOf(Map); + expect($clipLevels).toBeInstanceOf(Map); + }); + + it("should store and retrieve clip bounds", () => + { + const bounds = new Float32Array([0, 0, 100, 100]); + $clipBounds.set(1, bounds); + + expect($clipBounds.has(1)).toBe(true); + expect($clipBounds.get(1)).toBe(bounds); + }); + + it("should store and retrieve clip levels", () => + { + $clipLevels.set(1, 3); + + expect($clipLevels.has(1)).toBe(true); + expect($clipLevels.get(1)).toBe(3); + }); + }); + + describe("$resetMaskState", () => + { + it("should reset all mask state", () => + { + // Set various states + $setMaskDrawing(true); + $setMaskTestEnabled(true); + $setMaskStencilReference(10); + $pushMaskAttachment({ "id": 1 }); + $clipBounds.set(1, new Float32Array([0, 0, 100, 100])); + $clipLevels.set(1, 5); + + // Reset + $resetMaskState(); + + // Verify all reset + expect($isMaskDrawing()).toBe(false); + expect($isMaskTestEnabled()).toBe(false); + expect($getMaskStencilReference()).toBe(0); + expect($hasMaskAttachment()).toBe(false); + expect($clipBounds.size).toBe(0); + expect($clipLevels.size).toBe(0); + }); + }); +}); diff --git a/packages/webgpu/src/Mask.ts b/packages/webgpu/src/Mask.ts new file mode 100644 index 00000000..5551a197 --- /dev/null +++ b/packages/webgpu/src/Mask.ts @@ -0,0 +1,66 @@ +let $maskDrawingState: boolean = false; + +export const $setMaskDrawing = (state: boolean): void => +{ + $maskDrawingState = state; +}; + +export const $isMaskDrawing = (): boolean => +{ + return $maskDrawingState; +}; + +let $maskTestEnabled: boolean = false; + +let $maskStencilReference: number = 0; + +export const $setMaskTestEnabled = (enabled: boolean): void => +{ + $maskTestEnabled = enabled; +}; + +export const $isMaskTestEnabled = (): boolean => +{ + return $maskTestEnabled; +}; + +export const $setMaskStencilReference = (value: number): void => +{ + $maskStencilReference = value; +}; + +export const $getMaskStencilReference = (): number => +{ + return $maskStencilReference; +}; + +const $maskAttachmentStack: any[] = []; + +export const $pushMaskAttachment = (attachment: any): void => +{ + $maskAttachmentStack.push(attachment); +}; + +export const $popMaskAttachment = (): any => +{ + return $maskAttachmentStack.pop(); +}; + +export const $hasMaskAttachment = (): boolean => +{ + return $maskAttachmentStack.length > 0; +}; + +export const $clipBounds: Map = new Map(); + +export const $clipLevels: Map = new Map(); + +export const $resetMaskState = (): void => +{ + $maskDrawingState = false; + $maskTestEnabled = false; + $maskStencilReference = 0; + $maskAttachmentStack.length = 0; + $clipBounds.clear(); + $clipLevels.clear(); +}; diff --git a/packages/webgpu/src/Mask/service/MaskBeginMaskService.test.ts b/packages/webgpu/src/Mask/service/MaskBeginMaskService.test.ts new file mode 100644 index 00000000..e6e831c3 --- /dev/null +++ b/packages/webgpu/src/Mask/service/MaskBeginMaskService.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { execute } from "./MaskBeginMaskService"; + +// Mock WebGPUUtil +const mockCurrentAttachmentObject = { + "mask": false, + "clipLevel": 0 +}; + +vi.mock("../../WebGPUUtil", () => ({ + "$context": { + get currentAttachmentObject () { + return mockCurrentAttachmentObject; + } + } +})); + +// Mock Mask module +const mockSetMaskDrawing = vi.fn(); +const mockIsMaskDrawing = vi.fn(() => false); + +vi.mock("../../Mask", () => ({ + "$isMaskDrawing": () => mockIsMaskDrawing(), + "$setMaskDrawing": (value: boolean) => mockSetMaskDrawing(value), + "$clipLevels": new Map() +})); + +import { $clipLevels } from "../../Mask"; + +describe("MaskBeginMaskService", () => +{ + beforeEach(() => + { + vi.clearAllMocks(); + mockCurrentAttachmentObject.mask = false; + mockCurrentAttachmentObject.clipLevel = 0; + ($clipLevels as Map).clear(); + mockIsMaskDrawing.mockReturnValue(false); + }); + + describe("basic mask begin", () => + { + it("should set mask to true on attachment", () => + { + execute(); + + expect(mockCurrentAttachmentObject.mask).toBe(true); + }); + + it("should increment clipLevel", () => + { + expect(mockCurrentAttachmentObject.clipLevel).toBe(0); + + execute(); + + expect(mockCurrentAttachmentObject.clipLevel).toBe(1); + }); + + it("should register clipLevel in clipLevels map", () => + { + execute(); + + expect($clipLevels.has(1)).toBe(true); + expect($clipLevels.get(1)).toBe(1); + }); + + it("should set mask drawing to true when not already drawing", () => + { + mockIsMaskDrawing.mockReturnValue(false); + + execute(); + + expect(mockSetMaskDrawing).toHaveBeenCalledWith(true); + }); + + it("should not set mask drawing again when already drawing", () => + { + mockIsMaskDrawing.mockReturnValue(true); + + execute(); + + expect(mockSetMaskDrawing).not.toHaveBeenCalled(); + }); + }); + + describe("nested masks", () => + { + it("should handle multiple mask begins", () => + { + execute(); + expect(mockCurrentAttachmentObject.clipLevel).toBe(1); + expect($clipLevels.has(1)).toBe(true); + + mockIsMaskDrawing.mockReturnValue(true); + execute(); + expect(mockCurrentAttachmentObject.clipLevel).toBe(2); + expect($clipLevels.has(2)).toBe(true); + + execute(); + expect(mockCurrentAttachmentObject.clipLevel).toBe(3); + expect($clipLevels.has(3)).toBe(true); + }); + }); + + describe("no attachment object", () => + { + it("should return early when no current attachment", () => + { + // Temporarily set to null + const originalAttachment = { ...mockCurrentAttachmentObject }; + Object.defineProperty(mockCurrentAttachmentObject, "clipLevel", { + "get": () => { throw new Error("should not access"); }, + "configurable": true + }); + + // Reset to normal object + Object.defineProperty(mockCurrentAttachmentObject, "clipLevel", { + "value": originalAttachment.clipLevel, + "writable": true, + "configurable": true + }); + }); + }); +}); diff --git a/packages/webgpu/src/Mask/service/MaskBeginMaskService.ts b/packages/webgpu/src/Mask/service/MaskBeginMaskService.ts new file mode 100644 index 00000000..a6f296e7 --- /dev/null +++ b/packages/webgpu/src/Mask/service/MaskBeginMaskService.ts @@ -0,0 +1,33 @@ +import { + $isMaskDrawing, + $setMaskDrawing, + $clipLevels +} from "../../Mask"; +import { $context } from "../../WebGPUUtil"; + +/** + * @description マスク描画の開始準備 + * Prepare to start drawing the mask + * + * @return {void} + * @method + * @protected + */ +export const execute = (): void => +{ + const currentAttachmentObject = $context.currentAttachmentObject; + if (!currentAttachmentObject) { + return; + } + + currentAttachmentObject.mask = true; + currentAttachmentObject.clipLevel++; + $clipLevels.set( + currentAttachmentObject.clipLevel, + currentAttachmentObject.clipLevel + ); + + if (!$isMaskDrawing()) { + $setMaskDrawing(true); + } +}; diff --git a/packages/webgpu/src/Mask/service/MaskEndMaskService.test.ts b/packages/webgpu/src/Mask/service/MaskEndMaskService.test.ts new file mode 100644 index 00000000..255e446d --- /dev/null +++ b/packages/webgpu/src/Mask/service/MaskEndMaskService.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { execute } from "./MaskEndMaskService"; + +// Mock WebGPUUtil +const mockCurrentAttachmentObject = { + "clipLevel": 1 +}; + +vi.mock("../../WebGPUUtil", () => ({ + "$context": { + get currentAttachmentObject () { + return mockCurrentAttachmentObject; + } + } +})); + +// Mock Mask module +const mockSetMaskTestEnabled = vi.fn(); +const mockSetMaskStencilReference = vi.fn(); +const mockSetMaskDrawing = vi.fn(); + +vi.mock("../../Mask", () => ({ + "$setMaskTestEnabled": (value: boolean) => mockSetMaskTestEnabled(value), + "$setMaskStencilReference": (value: number) => mockSetMaskStencilReference(value), + "$setMaskDrawing": (value: boolean) => mockSetMaskDrawing(value) +})); + +describe("MaskEndMaskService", () => +{ + beforeEach(() => + { + vi.clearAllMocks(); + mockCurrentAttachmentObject.clipLevel = 1; + }); + + describe("mask value calculation", () => + { + it("should calculate correct mask value for level 1", () => + { + mockCurrentAttachmentObject.clipLevel = 1; + + execute(); + + // mask = (1 << 1) - 1 = 1 + expect(mockSetMaskStencilReference).toHaveBeenCalledWith(1); + }); + + it("should calculate correct mask value for level 2", () => + { + mockCurrentAttachmentObject.clipLevel = 2; + + execute(); + + // mask = (1 << 2) - 1 = 3 + expect(mockSetMaskStencilReference).toHaveBeenCalledWith(3); + }); + + it("should calculate correct mask value for level 3", () => + { + mockCurrentAttachmentObject.clipLevel = 3; + + execute(); + + // mask = (1 << 3) - 1 = 7 + expect(mockSetMaskStencilReference).toHaveBeenCalledWith(7); + }); + + it("should calculate correct mask value for level 8", () => + { + mockCurrentAttachmentObject.clipLevel = 8; + + execute(); + + // mask = (1 << 8) - 1 = 255 & 0xFF = 255 + expect(mockSetMaskStencilReference).toHaveBeenCalledWith(255); + }); + }); + + describe("mask test enabling", () => + { + it("should enable mask test", () => + { + execute(); + + expect(mockSetMaskTestEnabled).toHaveBeenCalledWith(true); + }); + + it("should disable mask drawing", () => + { + execute(); + + expect(mockSetMaskDrawing).toHaveBeenCalledWith(false); + }); + }); + + describe("call order", () => + { + it("should call functions in correct order", () => + { + const callOrder: string[] = []; + mockSetMaskTestEnabled.mockImplementation(() => callOrder.push("testEnabled")); + mockSetMaskStencilReference.mockImplementation(() => callOrder.push("stencilRef")); + mockSetMaskDrawing.mockImplementation(() => callOrder.push("drawing")); + + execute(); + + expect(callOrder).toEqual(["testEnabled", "stencilRef", "drawing"]); + }); + }); +}); diff --git a/packages/webgpu/src/Mask/service/MaskEndMaskService.ts b/packages/webgpu/src/Mask/service/MaskEndMaskService.ts new file mode 100644 index 00000000..0086c6ee --- /dev/null +++ b/packages/webgpu/src/Mask/service/MaskEndMaskService.ts @@ -0,0 +1,46 @@ +import { $context } from "../../WebGPUUtil"; +import { + $setMaskTestEnabled, + $setMaskStencilReference, + $setMaskDrawing +} from "../../Mask"; + +/** + * @description マスクの描画を終了 + * End mask drawing + * + * WebGPU版: ビット単位のマスキングを使用(WebGL版と同様) + * 各レベルに対応するビットが設定されたマスク値を計算し、 + * EQUALテストで累積マスク値と一致する領域のみ描画 + * + * WebGL版: stencilFunc(EQUAL, mask & 0xff, mask) + * + * @return {void} + * @method + * @protected + */ +export const execute = (): void => +{ + const currentAttachmentObject = $context.currentAttachmentObject; + if (!currentAttachmentObject) { + return; + } + + const clipLevel = currentAttachmentObject.clipLevel; + + // 累積マスク値を計算(WebGL版と同じアルゴリズム) + // 簡略化: mask = (1 << clipLevel) - 1 + // level 1: mask = 1 (0x01) + // level 2: mask = 3 (0x03) + // level 3: mask = 7 (0x07) + // ... + const mask = (1 << clipLevel) - 1; + + // マスクテストを有効化 + // EQUALテストで、累積マスク値と一致するピクセルのみ描画を許可 + $setMaskTestEnabled(true); + $setMaskStencilReference(mask & 0xFF); // EQUAL テスト用の参照値 + + // マスク描画フェーズは終了 + $setMaskDrawing(false); +}; diff --git a/packages/webgpu/src/Mask/service/MaskSetMaskBoundsService.test.ts b/packages/webgpu/src/Mask/service/MaskSetMaskBoundsService.test.ts new file mode 100644 index 00000000..52a7e996 --- /dev/null +++ b/packages/webgpu/src/Mask/service/MaskSetMaskBoundsService.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { execute } from "./MaskSetMaskBoundsService"; + +// Mock WebGPUUtil +const mockCurrentAttachmentObject = { + "clipLevel": 1 +}; + +const mockGetFloat32Array4 = vi.fn(() => new Float32Array(4)); + +vi.mock("../../WebGPUUtil", () => ({ + "$context": { + get currentAttachmentObject () { + return mockCurrentAttachmentObject; + } + }, + "$getFloat32Array4": () => mockGetFloat32Array4() +})); + +// Mock Mask module - use actual Map that will be imported +vi.mock("../../Mask", () => ({ + "$clipBounds": new Map() +})); + +import { $clipBounds } from "../../Mask"; + +describe("MaskSetMaskBoundsService", () => +{ + beforeEach(() => + { + vi.clearAllMocks(); + mockCurrentAttachmentObject.clipLevel = 1; + $clipBounds.clear(); + mockGetFloat32Array4.mockReturnValue(new Float32Array(4)); + }); + + describe("initial bounds setting", () => + { + it("should create new bounds when none exist", () => + { + execute(10, 20, 100, 200); + + expect($clipBounds.has(1)).toBe(true); + const bounds = $clipBounds.get(1); + expect(bounds![0]).toBe(10); // x_min + expect(bounds![1]).toBe(20); // y_min + expect(bounds![2]).toBe(100); // x_max + expect(bounds![3]).toBe(200); // y_max + }); + + it("should call $getFloat32Array4 for new bounds", () => + { + execute(0, 0, 50, 50); + + expect(mockGetFloat32Array4).toHaveBeenCalled(); + }); + + it("should handle different clip levels", () => + { + mockCurrentAttachmentObject.clipLevel = 2; + + execute(5, 10, 50, 60); + + expect($clipBounds.has(2)).toBe(true); + const bounds = $clipBounds.get(2); + expect(bounds![0]).toBe(5); + expect(bounds![1]).toBe(10); + expect(bounds![2]).toBe(50); + expect(bounds![3]).toBe(60); + }); + }); + + describe("bounds expansion", () => + { + it("should expand bounds to include smaller x_min", () => + { + const existingBounds = new Float32Array([20, 20, 100, 100]); + $clipBounds.set(1, existingBounds); + + execute(10, 30, 80, 80); + + const bounds = $clipBounds.get(1); + expect(bounds![0]).toBe(10); // expanded x_min + expect(bounds![1]).toBe(20); // unchanged y_min + expect(bounds![2]).toBe(100); // unchanged x_max + expect(bounds![3]).toBe(100); // unchanged y_max + }); + + it("should expand bounds to include smaller y_min", () => + { + const existingBounds = new Float32Array([20, 20, 100, 100]); + $clipBounds.set(1, existingBounds); + + execute(30, 5, 80, 80); + + const bounds = $clipBounds.get(1); + expect(bounds![0]).toBe(20); // unchanged x_min + expect(bounds![1]).toBe(5); // expanded y_min + expect(bounds![2]).toBe(100); // unchanged x_max + expect(bounds![3]).toBe(100); // unchanged y_max + }); + + it("should expand bounds to include larger x_max", () => + { + const existingBounds = new Float32Array([20, 20, 100, 100]); + $clipBounds.set(1, existingBounds); + + execute(30, 30, 150, 80); + + const bounds = $clipBounds.get(1); + expect(bounds![0]).toBe(20); // unchanged x_min + expect(bounds![1]).toBe(20); // unchanged y_min + expect(bounds![2]).toBe(150); // expanded x_max + expect(bounds![3]).toBe(100); // unchanged y_max + }); + + it("should expand bounds to include larger y_max", () => + { + const existingBounds = new Float32Array([20, 20, 100, 100]); + $clipBounds.set(1, existingBounds); + + execute(30, 30, 80, 200); + + const bounds = $clipBounds.get(1); + expect(bounds![0]).toBe(20); // unchanged x_min + expect(bounds![1]).toBe(20); // unchanged y_min + expect(bounds![2]).toBe(100); // unchanged x_max + expect(bounds![3]).toBe(200); // expanded y_max + }); + + it("should expand bounds in all directions", () => + { + const existingBounds = new Float32Array([50, 50, 100, 100]); + $clipBounds.set(1, existingBounds); + + execute(10, 20, 200, 300); + + const bounds = $clipBounds.get(1); + expect(bounds![0]).toBe(10); // expanded x_min + expect(bounds![1]).toBe(20); // expanded y_min + expect(bounds![2]).toBe(200); // expanded x_max + expect(bounds![3]).toBe(300); // expanded y_max + }); + + it("should not shrink existing bounds", () => + { + const existingBounds = new Float32Array([10, 10, 200, 200]); + $clipBounds.set(1, existingBounds); + + execute(50, 50, 100, 100); + + const bounds = $clipBounds.get(1); + expect(bounds![0]).toBe(10); // not shrunk + expect(bounds![1]).toBe(10); // not shrunk + expect(bounds![2]).toBe(200); // not shrunk + expect(bounds![3]).toBe(200); // not shrunk + }); + }); + + describe("negative coordinates", () => + { + it("should handle negative bounds", () => + { + execute(-50, -30, 50, 30); + + const bounds = $clipBounds.get(1); + expect(bounds![0]).toBe(-50); + expect(bounds![1]).toBe(-30); + expect(bounds![2]).toBe(50); + expect(bounds![3]).toBe(30); + }); + }); +}); diff --git a/packages/webgpu/src/Mask/service/MaskSetMaskBoundsService.ts b/packages/webgpu/src/Mask/service/MaskSetMaskBoundsService.ts new file mode 100644 index 00000000..4f2aab61 --- /dev/null +++ b/packages/webgpu/src/Mask/service/MaskSetMaskBoundsService.ts @@ -0,0 +1,48 @@ +import { + $clipBounds +} from "../../Mask"; +import { + $context, + $getFloat32Array4 +} from "../../WebGPUUtil"; + +/** + * @description マスク範囲の設定 + * Set mask bounds + * + * @param {number} x_min + * @param {number} y_min + * @param {number} x_max + * @param {number} y_max + * @return {void} + * @method + * @protected + */ +export const execute = ( + x_min: number, + y_min: number, + x_max: number, + y_max: number +): void => +{ + const currentAttachmentObject = $context.currentAttachmentObject; + if (!currentAttachmentObject) { + return; + } + + const clipLevel = currentAttachmentObject.clipLevel; + let bounds = $clipBounds.get(clipLevel); + if (bounds) { + bounds[0] = Math.min(bounds[0], x_min); + bounds[1] = Math.min(bounds[1], y_min); + bounds[2] = Math.max(bounds[2], x_max); + bounds[3] = Math.max(bounds[3], y_max); + } else { + bounds = $getFloat32Array4(); + bounds[0] = x_min; + bounds[1] = y_min; + bounds[2] = x_max; + bounds[3] = y_max; + $clipBounds.set(clipLevel, bounds); + } +}; diff --git a/packages/webgpu/src/Mask/service/MaskUnionMaskService.test.ts b/packages/webgpu/src/Mask/service/MaskUnionMaskService.test.ts new file mode 100644 index 00000000..13a53e3f --- /dev/null +++ b/packages/webgpu/src/Mask/service/MaskUnionMaskService.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { BufferManager } from "../../BufferManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; +import { execute } from "./MaskUnionMaskService"; + +describe("MaskUnionMaskService", () => +{ + const createMockDevice = () => + { + return { + "queue": { + "writeBuffer": vi.fn() + }, + "createBindGroup": vi.fn(() => ({ "label": "mockBindGroup" })) + } as unknown as GPUDevice; + }; + + const createMockRenderPassEncoder = () => + { + return { + "setPipeline": vi.fn(), + "setStencilReference": vi.fn(), + "setVertexBuffer": vi.fn(), + "setBindGroup": vi.fn(), + "draw": vi.fn() + } as unknown as GPURenderPassEncoder; + }; + + const createMockBufferManager = () => + { + return { + "createVertexBuffer": vi.fn(() => ({ "label": "mockVertexBuffer" })), + "acquireVertexBuffer": vi.fn(() => ({ "label": "mockVertexBuffer" })), + "acquireUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })), + "acquireAndWriteUniformBuffer": vi.fn(() => ({ "label": "mockUniformBuffer" })), + "dynamicUniform": { + "allocate": vi.fn(() => 0), + "getBuffer": vi.fn(() => ({ "label": "mockDynamicBuffer" })) + } + } as unknown as BufferManager; + }; + + const createMockPipelineManager = (hasMergePipeline: boolean = true, hasClearPipeline: boolean = true) => + { + return { + "getPipeline": vi.fn((name: string) => { + if (name.startsWith("mask_union_merge_") && hasMergePipeline) { + return { + "label": `mock_${name}`, + "getBindGroupLayout": vi.fn(() => ({ "label": "mockLayout" })) + }; + } + if (name.startsWith("mask_union_clear_") && hasClearPipeline) { + return { + "label": `mock_${name}`, + "getBindGroupLayout": vi.fn(() => ({ "label": "mockLayout" })) + }; + } + return null; + }), + "getBindGroupLayout": vi.fn(() => ({ "label": "mockDynamicLayout" })) + } as unknown as PipelineManager; + }; + + const createMockAttachment = (clipLevel: number = 3): IAttachmentObject => + { + return { + "id": 1, + "width": 800, + "height": 600, + "clipLevel": clipLevel, + "texture": { + "resource": { "label": "mockTexture" } as unknown as GPUTexture, + "view": { "label": "mockTextureView" } as unknown as GPUTextureView + }, + "stencil": { + "resource": { "label": "mockStencil" } as unknown as GPUTexture, + "view": { "label": "mockStencilView" } as unknown as GPUTextureView + } + }; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + }); + + describe("vertex buffer creation", () => + { + it("should create vertex buffer with fullscreen rectangle data", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const attachment = createMockAttachment(); + + execute(device, renderPassEncoder, bufferManager, pipelineManager, attachment); + + expect(bufferManager.acquireVertexBuffer).toHaveBeenCalled(); + const callArgs = (bufferManager.acquireVertexBuffer as ReturnType).mock.calls[0]; + // Check vertex data size (Float32Array with 6 vertices * 4 floats each = 24 floats = 96 bytes) + expect(callArgs[0]).toBe(96); + const vertexData = callArgs[1] as Float32Array; + expect(vertexData.length).toBe(24); + }); + }); + + describe("uniform buffer creation", () => + { + it("should allocate uniform data via dynamic uniform allocator", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const attachment = createMockAttachment(); + + execute(device, renderPassEncoder, bufferManager, pipelineManager, attachment); + + expect(bufferManager.dynamicUniform.allocate).toHaveBeenCalled(); + }); + }); + + describe("two-pass operation", () => + { + it("should execute merge pass first then clear pass", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const attachment = createMockAttachment(3); + + execute(device, renderPassEncoder, bufferManager, pipelineManager, attachment); + + // Should request both pipelines + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("mask_union_merge_3"); + expect(pipelineManager.getPipeline).toHaveBeenCalledWith("mask_union_clear_3"); + + // Should draw twice (merge + clear) + expect(renderPassEncoder.draw).toHaveBeenCalledTimes(2); + expect(renderPassEncoder.draw).toHaveBeenCalledWith(6, 1, 0, 0); + }); + + it("should set correct stencil references for merge pass", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const attachment = createMockAttachment(3); + + execute(device, renderPassEncoder, bufferManager, pipelineManager, attachment); + + // mask = 1 << (clipLevel - 1) = 1 << 2 = 4 + expect(renderPassEncoder.setStencilReference).toHaveBeenCalledWith(4); + // clear pass uses 0 + expect(renderPassEncoder.setStencilReference).toHaveBeenCalledWith(0); + }); + + it("should calculate correct mask for different clip levels", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const attachment = createMockAttachment(5); + + execute(device, renderPassEncoder, bufferManager, pipelineManager, attachment); + + // mask = 1 << (5 - 1) = 1 << 4 = 16 + expect(renderPassEncoder.setStencilReference).toHaveBeenCalledWith(16); + }); + + it("should set bind groups for both passes", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + const attachment = createMockAttachment(3); + + execute(device, renderPassEncoder, bufferManager, pipelineManager, attachment); + + expect(renderPassEncoder.setBindGroup).toHaveBeenCalledTimes(2); + // 1 createBindGroup for dynamic bind group (shared by both passes) + expect(device.createBindGroup).toHaveBeenCalledTimes(1); + }); + }); + + describe("pipeline not found", () => + { + it("should skip merge pass when merge pipeline not found", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(false, true); + const attachment = createMockAttachment(); + + execute(device, renderPassEncoder, bufferManager, pipelineManager, attachment); + + // Only clear pass should be executed + expect(renderPassEncoder.draw).toHaveBeenCalledTimes(1); + }); + + it("should skip clear pass when clear pipeline not found", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(true, false); + const attachment = createMockAttachment(); + + execute(device, renderPassEncoder, bufferManager, pipelineManager, attachment); + + // Only merge pass should be executed + expect(renderPassEncoder.draw).toHaveBeenCalledTimes(1); + }); + + it("should skip both passes when no pipelines found", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(false, false); + const attachment = createMockAttachment(); + + execute(device, renderPassEncoder, bufferManager, pipelineManager, attachment); + + // No drawing should occur + expect(renderPassEncoder.draw).not.toHaveBeenCalled(); + }); + }); + + describe("null attachment", () => + { + it("should return early when attachment is null", () => + { + const device = createMockDevice(); + const renderPassEncoder = createMockRenderPassEncoder(); + const bufferManager = createMockBufferManager(); + const pipelineManager = createMockPipelineManager(); + + execute(device, renderPassEncoder, bufferManager, pipelineManager, null as unknown as IAttachmentObject); + + expect(bufferManager.acquireVertexBuffer).not.toHaveBeenCalled(); + expect(renderPassEncoder.draw).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/webgpu/src/Mask/service/MaskUnionMaskService.ts b/packages/webgpu/src/Mask/service/MaskUnionMaskService.ts new file mode 100644 index 00000000..eedd97e3 --- /dev/null +++ b/packages/webgpu/src/Mask/service/MaskUnionMaskService.ts @@ -0,0 +1,93 @@ +import type { BufferManager } from "../../BufferManager"; +import type { PipelineManager } from "../../Shader/PipelineManager"; +import type { IAttachmentObject } from "../../interface/IAttachmentObject"; + +/** + * @description マスクの合成処理(ネストされたマスク対応) + * WebGL版と同様に、レベル7を超えたステンシルビットをマージする + * + * @param {GPUDevice} device + * @param {GPURenderPassEncoder} renderPassEncoder + * @param {BufferManager} bufferManager + * @param {PipelineManager} pipelineManager + * @param {IAttachmentObject} currentAttachment + * @return {void} + * @method + * @protected + */ + +// フルスクリーン矩形(4 floats/vertex: position + bezier) +const $rectVertices = new Float32Array([ + // Triangle 1 + -1, -1, 0.5, 0.5, + 1, -1, 0.5, 0.5, + -1, 1, 0.5, 0.5, + // Triangle 2 + -1, 1, 0.5, 0.5, + 1, -1, 0.5, 0.5, + 1, 1, 0.5, 0.5 +]); + +// FillUniforms: identity matrix + white color (NDC座標なので変換不要) +const $uniformData16 = new Float32Array([ + 1, 1, 1, 1, // color: white + 0.5, 0, 0, 0, // matrix0: (0.5, 0, 0, pad) → identity-like for NDC passthrough + 0, 0.5, 0, 0, // matrix1: (0, 0.5, 0, pad) + 0.5, 0.5, 1, 0 // matrix2: (0.5, 0.5, 1, pad) +]); + +export const execute = ( + device: GPUDevice, + renderPassEncoder: GPURenderPassEncoder, + bufferManager: BufferManager, + pipelineManager: PipelineManager, + currentAttachment: IAttachmentObject +): void => { + if (!currentAttachment) { + return; + } + + const clipLevel = currentAttachment.clipLevel; + const mask = 1 << clipLevel - 1; + + const vertexBuffer = bufferManager.acquireVertexBuffer($rectVertices.byteLength, $rectVertices); + + // Dynamic Uniform Bufferにデータを書き込み + const uniformOffset = bufferManager.dynamicUniform.allocate($uniformData16); + + // Dynamic BindGroupを取得 + const layout = pipelineManager.getBindGroupLayout("fill_dynamic"); + if (!layout) { + return; + } + const bindGroup = device.createBindGroup({ + "layout": layout, + "entries": [{ + "binding": 0, + "resource": { + "buffer": bufferManager.dynamicUniform.getBuffer(), + "size": 256 + } + }] + }); + + // === Pass 1: ステンシルビットのマージ === + const mergePipeline = pipelineManager.getPipeline(`mask_union_merge_${clipLevel}`); + if (mergePipeline) { + renderPassEncoder.setPipeline(mergePipeline); + renderPassEncoder.setStencilReference(mask); + renderPassEncoder.setVertexBuffer(0, vertexBuffer); + renderPassEncoder.setBindGroup(0, bindGroup, [uniformOffset]); + renderPassEncoder.draw(6, 1, 0, 0); + } + + // === Pass 2: 上位ビットのクリア === + const clearPipeline = pipelineManager.getPipeline(`mask_union_clear_${clipLevel}`); + if (clearPipeline) { + renderPassEncoder.setPipeline(clearPipeline); + renderPassEncoder.setStencilReference(0); + renderPassEncoder.setVertexBuffer(0, vertexBuffer); + renderPassEncoder.setBindGroup(0, bindGroup, [uniformOffset]); + renderPassEncoder.draw(6, 1, 0, 0); + } +}; diff --git a/packages/webgpu/src/Mask/usecase/MaskBindUseCase.test.ts b/packages/webgpu/src/Mask/usecase/MaskBindUseCase.test.ts new file mode 100644 index 00000000..64ed13e1 --- /dev/null +++ b/packages/webgpu/src/Mask/usecase/MaskBindUseCase.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { execute } from "./MaskBindUseCase"; + +// Mock Mask module +const mockIsMaskDrawing = vi.fn(() => false); +const mockSetMaskDrawing = vi.fn(); + +vi.mock("../../Mask", () => ({ + "$isMaskDrawing": () => mockIsMaskDrawing(), + "$setMaskDrawing": (value: boolean) => mockSetMaskDrawing(value) +})); + +// Mock MaskEndMaskService +const mockMaskEndMaskService = vi.fn(); +vi.mock("../service/MaskEndMaskService", () => ({ + "execute": () => mockMaskEndMaskService() +})); + +describe("MaskBindUseCase", () => +{ + beforeEach(() => + { + vi.clearAllMocks(); + mockIsMaskDrawing.mockReturnValue(false); + }); + + describe("mask binding", () => + { + it("should set mask drawing to true when mask=true and not already drawing", () => + { + mockIsMaskDrawing.mockReturnValue(false); + + execute(true); + + expect(mockSetMaskDrawing).toHaveBeenCalledWith(true); + expect(mockMaskEndMaskService).toHaveBeenCalled(); + }); + + it("should not change state when mask=true and already drawing", () => + { + mockIsMaskDrawing.mockReturnValue(true); + + execute(true); + + expect(mockSetMaskDrawing).not.toHaveBeenCalled(); + expect(mockMaskEndMaskService).not.toHaveBeenCalled(); + }); + }); + + describe("mask unbinding", () => + { + it("should set mask drawing to false when mask=false and currently drawing", () => + { + mockIsMaskDrawing.mockReturnValue(true); + + execute(false); + + expect(mockSetMaskDrawing).toHaveBeenCalledWith(false); + }); + + it("should not change state when mask=false and not drawing", () => + { + mockIsMaskDrawing.mockReturnValue(false); + + execute(false); + + expect(mockSetMaskDrawing).not.toHaveBeenCalled(); + }); + }); + + describe("mask end service", () => + { + it("should call mask end service when transitioning from not drawing to drawing", () => + { + mockIsMaskDrawing.mockReturnValue(false); + + execute(true); + + expect(mockMaskEndMaskService).toHaveBeenCalled(); + }); + + it("should not call mask end service when mask=false", () => + { + mockIsMaskDrawing.mockReturnValue(true); + + execute(false); + + expect(mockMaskEndMaskService).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/webgpu/src/Mask/usecase/MaskBindUseCase.ts b/packages/webgpu/src/Mask/usecase/MaskBindUseCase.ts new file mode 100644 index 00000000..f652fb4f --- /dev/null +++ b/packages/webgpu/src/Mask/usecase/MaskBindUseCase.ts @@ -0,0 +1,24 @@ +import { execute as maskEndMaskService } from "../service/MaskEndMaskService"; +import { + $isMaskDrawing, + $setMaskDrawing +} from "../../Mask"; + +/** + * @description マスクOn/Offに合わせたバインド処理 + * Binding process according to mask On/Off + * + * @param {boolean} mask + * @return {void} + * @method + * @protected + */ +export const execute = (mask: boolean): void => +{ + if (!mask && $isMaskDrawing()) { + $setMaskDrawing(false); + } else if (mask && !$isMaskDrawing()) { + $setMaskDrawing(true); + maskEndMaskService(); + } +}; diff --git a/packages/webgpu/src/Mask/usecase/MaskLeaveMaskUseCase.test.ts b/packages/webgpu/src/Mask/usecase/MaskLeaveMaskUseCase.test.ts new file mode 100644 index 00000000..7dbfcc7b --- /dev/null +++ b/packages/webgpu/src/Mask/usecase/MaskLeaveMaskUseCase.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { execute } from "./MaskLeaveMaskUseCase"; + +// Mock attachment object +const mockCurrentAttachmentObject = { + "clipLevel": 1, + "mask": true, + "needsStencilClear": false, + "pendingStencilClearLevel": 0 +}; + +vi.mock("../../WebGPUUtil", () => ({ + "$context": { + get currentAttachmentObject () { + return mockCurrentAttachmentObject; + } + }, + "$poolFloat32Array4": vi.fn() +})); + +// Mock Mask module - use getters for functions to avoid hoisting issues +let mockSetMaskDrawingFn: ReturnType | null = null; +let mockSetMaskTestEnabledFn: ReturnType | null = null; +let mockSetMaskStencilReferenceFn: ReturnType | null = null; + +vi.mock("../../Mask", () => ({ + "$clipBounds": new Map(), + "$clipLevels": new Map(), + "$setMaskDrawing": (value: boolean) => mockSetMaskDrawingFn?.(value), + "$setMaskTestEnabled": (value: boolean) => mockSetMaskTestEnabledFn?.(value), + "$setMaskStencilReference": (value: number) => mockSetMaskStencilReferenceFn?.(value) +})); + +import { $clipBounds, $clipLevels } from "../../Mask"; + +// Mock MaskEndMaskService +const mockMaskEndMaskService = vi.fn(); +vi.mock("../service/MaskEndMaskService", () => ({ + "execute": () => mockMaskEndMaskService() +})); + +import { $poolFloat32Array4 } from "../../WebGPUUtil"; + +describe("MaskLeaveMaskUseCase", () => +{ + beforeEach(() => + { + vi.clearAllMocks(); + mockCurrentAttachmentObject.clipLevel = 1; + mockCurrentAttachmentObject.mask = true; + mockCurrentAttachmentObject.needsStencilClear = false; + mockCurrentAttachmentObject.pendingStencilClearLevel = 0; + $clipBounds.clear(); + $clipLevels.clear(); + mockSetMaskDrawingFn = vi.fn(); + mockSetMaskTestEnabledFn = vi.fn(); + mockSetMaskStencilReferenceFn = vi.fn(); + }); + + describe("single mask leave", () => + { + it("should decrement clipLevel to 0", () => + { + mockCurrentAttachmentObject.clipLevel = 1; + $clipLevels.set(1, 1); + + execute(); + + expect(mockCurrentAttachmentObject.clipLevel).toBe(0); + }); + + it("should set mask to false when clipLevel becomes 0", () => + { + mockCurrentAttachmentObject.clipLevel = 1; + $clipLevels.set(1, 1); + + execute(); + + expect(mockCurrentAttachmentObject.mask).toBe(false); + }); + + it("should disable mask drawing when clipLevel becomes 0", () => + { + mockCurrentAttachmentObject.clipLevel = 1; + $clipLevels.set(1, 1); + + execute(); + + expect(mockSetMaskDrawingFn).toHaveBeenCalledWith(false); + }); + + it("should disable mask test when clipLevel becomes 0", () => + { + mockCurrentAttachmentObject.clipLevel = 1; + $clipLevels.set(1, 1); + + execute(); + + expect(mockSetMaskTestEnabledFn).toHaveBeenCalledWith(false); + }); + + it("should reset stencil reference to 0 when clipLevel becomes 0", () => + { + mockCurrentAttachmentObject.clipLevel = 1; + $clipLevels.set(1, 1); + + execute(); + + expect(mockSetMaskStencilReferenceFn).toHaveBeenCalledWith(0); + }); + + it("should set needsStencilClear when clipLevel becomes 0", () => + { + mockCurrentAttachmentObject.clipLevel = 1; + $clipLevels.set(1, 1); + + execute(); + + expect(mockCurrentAttachmentObject.needsStencilClear).toBe(true); + }); + + it("should clear clipLevels and clipBounds when clipLevel becomes 0", () => + { + mockCurrentAttachmentObject.clipLevel = 1; + $clipLevels.set(1, 1); + $clipBounds.set(1, new Float32Array([0, 0, 100, 100])); + + execute(); + + expect($clipLevels.size).toBe(0); + expect($clipBounds.size).toBe(0); + }); + }); + + describe("nested mask leave", () => + { + it("should decrement clipLevel but not to 0 for nested masks", () => + { + mockCurrentAttachmentObject.clipLevel = 2; + $clipLevels.set(1, 1); + $clipLevels.set(2, 2); + + execute(); + + expect(mockCurrentAttachmentObject.clipLevel).toBe(1); + expect(mockCurrentAttachmentObject.mask).toBe(true); // still masked + }); + + it("should set pendingStencilClearLevel for nested masks", () => + { + mockCurrentAttachmentObject.clipLevel = 2; + $clipLevels.set(1, 1); + $clipLevels.set(2, 2); + + execute(); + + expect(mockCurrentAttachmentObject.pendingStencilClearLevel).toBe(1); + }); + + it("should call mask end service for nested masks", () => + { + mockCurrentAttachmentObject.clipLevel = 2; + $clipLevels.set(1, 1); + $clipLevels.set(2, 2); + + execute(); + + expect(mockMaskEndMaskService).toHaveBeenCalled(); + }); + + it("should not call mask end service when clipLevel becomes 0", () => + { + mockCurrentAttachmentObject.clipLevel = 1; + $clipLevels.set(1, 1); + + execute(); + + expect(mockMaskEndMaskService).not.toHaveBeenCalled(); + }); + }); + + describe("bounds cleanup", () => + { + it("should delete bounds for current clipLevel", () => + { + mockCurrentAttachmentObject.clipLevel = 1; + const bounds = new Float32Array([0, 0, 100, 100]); + $clipBounds.set(1, bounds); + $clipLevels.set(1, 1); + + execute(); + + // All bounds cleared because clipLevel becomes 0 + expect($clipBounds.has(1)).toBe(false); + }); + + it("should pool bounds Float32Array", () => + { + mockCurrentAttachmentObject.clipLevel = 2; + const bounds = new Float32Array([0, 0, 100, 100]); + $clipBounds.set(2, bounds); + $clipLevels.set(1, 1); + $clipLevels.set(2, 2); + + execute(); + + expect($poolFloat32Array4).toHaveBeenCalledWith(bounds); + }); + + it("should delete clipLevel from clipLevels", () => + { + mockCurrentAttachmentObject.clipLevel = 2; + $clipLevels.set(1, 1); + $clipLevels.set(2, 2); + + execute(); + + expect($clipLevels.has(2)).toBe(false); + expect($clipLevels.has(1)).toBe(true); + }); + }); +}); diff --git a/packages/webgpu/src/Mask/usecase/MaskLeaveMaskUseCase.ts b/packages/webgpu/src/Mask/usecase/MaskLeaveMaskUseCase.ts new file mode 100644 index 00000000..562c5642 --- /dev/null +++ b/packages/webgpu/src/Mask/usecase/MaskLeaveMaskUseCase.ts @@ -0,0 +1,71 @@ +import { execute as maskEndMaskService } from "../service/MaskEndMaskService"; +import { + $setMaskDrawing, + $setMaskTestEnabled, + $setMaskStencilReference, + $clipBounds, + $clipLevels +} from "../../Mask"; +import { + $context, + $poolFloat32Array4 +} from "../../WebGPUUtil"; + +/** + * @description マスクの終了処理 + * End mask processing + * + * WebGL版と同じ: + * - 単体マスク終了時: ステンシルバッファをクリア + * - ネストマスク終了時: 上位レベルのステンシルビットをクリア + * + * @return {void} + * @method + * @protected + */ +export const execute = (): void => +{ + const currentAttachmentObject = $context.currentAttachmentObject; + if (!currentAttachmentObject) { + return; + } + + const clipLevel = currentAttachmentObject.clipLevel; + const bounds = $clipBounds.get(clipLevel); + + if (bounds) { + // レベルと描画範囲を削除 + $clipBounds.delete(clipLevel); + $poolFloat32Array4(bounds); + } + + $clipLevels.delete(clipLevel); + + // 単体のマスクであれば終了 + --currentAttachmentObject.clipLevel; + if (!currentAttachmentObject.clipLevel) { + currentAttachmentObject.mask = false; + $setMaskDrawing(false); + + // マスクテストを無効化 + $setMaskTestEnabled(false); + $setMaskStencilReference(0); + + // WebGL版と同じ: ステンシルバッファをクリア + // WebGPUでは次のレンダーパス開始時にステンシルがクリアされる + // ステンシルクリアフラグを設定 + currentAttachmentObject.needsStencilClear = true; + + $clipLevels.clear(); + $clipBounds.clear(); + return; + } + + // ネストされたマスクの場合、上位レベルのステンシルビットをクリア + // WebGL版と同じ: stencilMask(1 << clipLevel), stencilOp(REPLACE, REPLACE, REPLACE) + // ステンシルクリアフラグを設定(特定のビットのみクリア) + currentAttachmentObject.pendingStencilClearLevel = currentAttachmentObject.clipLevel; + + // 親マスクの設定に戻す + maskEndMaskService(); +}; diff --git a/packages/webgpu/src/Mesh/service/MeshFillGenerateService.test.ts b/packages/webgpu/src/Mesh/service/MeshFillGenerateService.test.ts new file mode 100644 index 00000000..c7bb3fb0 --- /dev/null +++ b/packages/webgpu/src/Mesh/service/MeshFillGenerateService.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from "vitest"; +import type { IPath } from "../../interface/IPath"; +import { execute } from "./MeshFillGenerateService"; + +describe("MeshFillGenerateService", () => +{ + const FLOATS_PER_VERTEX = 4; + + describe("basic vertex generation", () => + { + it("should return updated index after processing", () => + { + // Simple triangle with bezier flag (vertex[idx+2] = true) + // Format: [startX, startY, ?, x1, y1, bezierFlag, x2, y2, ?, ...] + const vertex: IPath = [0, 0, 0, 10, 10, 1, 20, 0, 0] as IPath; + const buffer = new Float32Array(1000); + + const newIndex = execute(vertex, buffer, 0); + + expect(newIndex).toBeGreaterThan(0); + }); + + it("should write 4 floats per vertex", () => + { + // Triangle that triggers one iteration (bezier curve) + const vertex: IPath = [0, 0, 0, 10, 10, 1, 20, 0, 0] as IPath; + const buffer = new Float32Array(100); + + const newIndex = execute(vertex, buffer, 0); + + // Each iteration writes 3 vertices + expect(newIndex).toBe(3); + }); + + it("should write position at correct offset", () => + { + const vertex: IPath = [100, 200, 0, 10, 10, 1, 20, 0, 0] as IPath; + const buffer = new Float32Array(100); + + execute(vertex, buffer, 0); + + // First vertex position comes from vertex[idx-3], vertex[idx-2] + // idx starts at 3, so vertex[0] = 100, vertex[1] = 200 + expect(buffer[0]).toBe(100); + expect(buffer[1]).toBe(200); + }); + }); + + describe("bezier curve handling", () => + { + it("should set bezier coords for curve vertex (0, 0 for first)", () => + { + const vertex: IPath = [0, 0, 0, 10, 10, 1, 20, 0, 0] as IPath; + const buffer = new Float32Array(100); + + execute(vertex, buffer, 0); + + // First vertex bezier coords (u=0, v=0) + expect(buffer[2]).toBe(0); + expect(buffer[3]).toBe(0); + + // Second vertex bezier coords (u=0.5, v=0) + expect(buffer[FLOATS_PER_VERTEX + 2]).toBe(0.5); + expect(buffer[FLOATS_PER_VERTEX + 3]).toBe(0); + + // Third vertex bezier coords (u=1, v=1) + expect(buffer[FLOATS_PER_VERTEX * 2 + 2]).toBe(1); + expect(buffer[FLOATS_PER_VERTEX * 2 + 3]).toBe(1); + }); + }); + + describe("fan triangulation", () => + { + it("should use start point for fan center", () => + { + // Fan vertex (vertex[idx+2]=0, vertex[idx+5]=0) + const vertex: IPath = [50, 50, 0, 10, 10, 0, 20, 20, 0] as IPath; + const buffer = new Float32Array(100); + + execute(vertex, buffer, 0); + + // First vertex should be at start point (50, 50) + expect(buffer[0]).toBe(50); + expect(buffer[1]).toBe(50); + }); + + it("should set bezier coords to (0.5, 0.5) for non-curve vertices", () => + { + const vertex: IPath = [50, 50, 0, 10, 10, 0, 20, 20, 0] as IPath; + const buffer = new Float32Array(100); + + execute(vertex, buffer, 0); + + // All vertices should have (0.5, 0.5) for non-curve + expect(buffer[2]).toBe(0.5); + expect(buffer[3]).toBe(0.5); + }); + }); + + describe("index handling", () => + { + it("should start writing at correct offset based on index", () => + { + const vertex: IPath = [50, 60, 0, 10, 10, 1, 20, 0, 0] as IPath; + const buffer = new Float32Array(200); + + const newIndex = execute( + vertex, buffer, 2 // Start at index 2 + ); + + // Should start at index 2 * 4 = 8 + // Buffer before should be zeros + expect(buffer[0]).toBe(0); + expect(buffer[FLOATS_PER_VERTEX]).toBe(0); + + // Buffer at index 2 should have data (position x=50) + expect(buffer[FLOATS_PER_VERTEX * 2]).toBe(50); + + // Index should increment by 3 from starting index 2 + expect(newIndex).toBe(5); + }); + + it("should increment index by 3 per triangle", () => + { + const vertex: IPath = [0, 0, 0, 10, 10, 1, 20, 0, 0] as IPath; + const buffer = new Float32Array(100); + + const newIndex = execute(vertex, buffer, 0); + + // One triangle = 3 vertices + expect(newIndex % 3).toBe(0); + }); + }); + + describe("empty/minimal input", () => + { + it("should handle minimal vertex array", () => + { + // Too short to trigger any iteration (length - 5 < 3) + const vertex: IPath = [0, 0, 0, 10, 10] as IPath; + const buffer = new Float32Array(100); + + const newIndex = execute(vertex, buffer, 0); + + expect(newIndex).toBe(0); + }); + }); +}); diff --git a/packages/webgpu/src/Mesh/service/MeshFillGenerateService.ts b/packages/webgpu/src/Mesh/service/MeshFillGenerateService.ts new file mode 100644 index 00000000..89bb9515 --- /dev/null +++ b/packages/webgpu/src/Mesh/service/MeshFillGenerateService.ts @@ -0,0 +1,97 @@ +import type { IPath } from "../../interface/IPath"; + +/** + * @description 塗りのメッシュを生成する(Loop-Blinn方式対応) + * Generate a fill mesh with Loop-Blinn method support + * + * 頂点フォーマット(4 floats per vertex): + * - position: x, y (2 floats) + * - bezier: u, v (2 floats) - Loop-Blinn用の暗黙的関数座標 + * + * color/matrixはuniform bufferで供給される + * + * @param {IPath} vertex + * @param {Float32Array} buffer + * @param {number} index - 現在の頂点インデックス + * @return {number} 新しい頂点インデックス + * @method + * @protected + */ +export const execute = ( + vertex: IPath, + buffer: Float32Array, + index: number +): number => { + + const length = vertex.length - 5; + + for (let idx = 3; idx < length; idx += 3) { + + let position = index * 4; + + if (vertex[idx + 2]) { + + // 座標A + buffer[position++] = vertex[idx - 3] as number; + buffer[position++] = vertex[idx - 2] as number; + buffer[position++] = 0; + buffer[position++] = 0; + + // 座標B + buffer[position++] = vertex[idx] as number; + buffer[position++] = vertex[idx + 1] as number; + buffer[position++] = 0.5; + buffer[position++] = 0; + + // 座標C + buffer[position++] = vertex[idx + 3] as number; + buffer[position++] = vertex[idx + 4] as number; + buffer[position++] = 1; + buffer[position++] = 1; + + } else if (vertex[idx + 5]) { + + // 座標A + buffer[position++] = vertex[0] as number; + buffer[position++] = vertex[1] as number; + buffer[position++] = 0.5; + buffer[position++] = 0.5; + + // 座標B + buffer[position++] = vertex[idx] as number; + buffer[position++] = vertex[idx + 1] as number; + buffer[position++] = 0.5; + buffer[position++] = 0.5; + + // 座標C + buffer[position++] = vertex[idx + 6] as number; + buffer[position++] = vertex[idx + 7] as number; + buffer[position++] = 0.5; + buffer[position++] = 0.5; + + } else { + + // 座標A + buffer[position++] = vertex[0] as number; + buffer[position++] = vertex[1] as number; + buffer[position++] = 0.5; + buffer[position++] = 0.5; + + // 座標B + buffer[position++] = vertex[idx] as number; + buffer[position++] = vertex[idx + 1] as number; + buffer[position++] = 0.5; + buffer[position++] = 0.5; + + // 座標C + buffer[position++] = vertex[idx + 3] as number; + buffer[position++] = vertex[idx + 4] as number; + buffer[position++] = 0.5; + buffer[position++] = 0.5; + } + + index += 3; + } + + return index; +}; diff --git a/packages/webgpu/src/Mesh/service/MeshLerpService.test.ts b/packages/webgpu/src/Mesh/service/MeshLerpService.test.ts new file mode 100644 index 00000000..3cf53c46 --- /dev/null +++ b/packages/webgpu/src/Mesh/service/MeshLerpService.test.ts @@ -0,0 +1,49 @@ +import { execute } from "./MeshLerpService"; +import { describe, expect, it } from "vitest"; + +describe("MeshLerpService.ts method test", () => +{ + it("test case - lerp at t=0 returns first point", () => + { + const pointA = { x: 0, y: 0 }; + const pointB = { x: 10, y: 10 }; + + const result = execute(pointA, pointB, 0); + + expect(result.x).toBe(0); + expect(result.y).toBe(0); + }); + + it("test case - lerp at t=1 returns second point", () => + { + const pointA = { x: 0, y: 0 }; + const pointB = { x: 10, y: 10 }; + + const result = execute(pointA, pointB, 1); + + expect(result.x).toBe(10); + expect(result.y).toBe(10); + }); + + it("test case - lerp at t=0.5 returns midpoint", () => + { + const pointA = { x: 0, y: 0 }; + const pointB = { x: 10, y: 20 }; + + const result = execute(pointA, pointB, 0.5); + + expect(result.x).toBe(5); + expect(result.y).toBe(10); + }); + + it("test case - lerp at t=0.25", () => + { + const pointA = { x: 0, y: 0 }; + const pointB = { x: 100, y: 200 }; + + const result = execute(pointA, pointB, 0.25); + + expect(result.x).toBe(25); + expect(result.y).toBe(50); + }); +}); diff --git a/packages/webgpu/src/Mesh/service/MeshLerpService.ts b/packages/webgpu/src/Mesh/service/MeshLerpService.ts new file mode 100644 index 00000000..eb5164aa --- /dev/null +++ b/packages/webgpu/src/Mesh/service/MeshLerpService.ts @@ -0,0 +1,23 @@ +import type { IPoint } from "../../interface/IPoint"; + +/** + * @description 線形補間 + * Linear interpolation + * + * @param {IPoint} pointA + * @param {IPoint} pointB + * @param {number} t + * @return {IPoint} + * @method + * @protected + */ +export const execute = ( + pointA: IPoint, + pointB: IPoint, + t: number +): IPoint => { + return { + "x": pointA.x + (pointB.x - pointA.x) * t, + "y": pointA.y + (pointB.y - pointA.y) * t + }; +}; diff --git a/packages/webgpu/src/Mesh/service/MeshStrokeFillGenerateService.test.ts b/packages/webgpu/src/Mesh/service/MeshStrokeFillGenerateService.test.ts new file mode 100644 index 00000000..1da68418 --- /dev/null +++ b/packages/webgpu/src/Mesh/service/MeshStrokeFillGenerateService.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import type { IPath } from "../../interface/IPath"; +import { execute } from "./MeshStrokeFillGenerateService"; + +describe("MeshStrokeFillGenerateService", () => +{ + const FLOATS_PER_VERTEX = 4; + + describe("bezier coordinates", () => + { + it("should always set bezier coordinates to (0.5, 0.5) for straight lines", () => + { + const vertex: IPath = [ + 0, 0, false, + 100, 0, false, + 100, 100, false, + 0, 0, false + ]; + const buffer = new Float32Array(9 * FLOATS_PER_VERTEX); + + execute(vertex, buffer, 0); + + // First vertex of first triangle: bezier at indices 2, 3 + expect(buffer[2]).toBe(0.5); + expect(buffer[3]).toBe(0.5); + + // Second vertex of first triangle: bezier at indices 4 + 2, 4 + 3 + expect(buffer[FLOATS_PER_VERTEX + 2]).toBe(0.5); + expect(buffer[FLOATS_PER_VERTEX + 3]).toBe(0.5); + + // Third vertex of first triangle: bezier at indices 8 + 2, 8 + 3 + expect(buffer[FLOATS_PER_VERTEX * 2 + 2]).toBe(0.5); + expect(buffer[FLOATS_PER_VERTEX * 2 + 3]).toBe(0.5); + }); + + it("should always set bezier coordinates to (0.5, 0.5) for curves", () => + { + // A curve path: start -> control -> end + const vertex: IPath = [ + 0, 0, false, // start point + 50, 50, true, // control point (curve flag) + 100, 0, false, // end point + 0, 0, false // close + ]; + const buffer = new Float32Array(6 * FLOATS_PER_VERTEX); + + const index = execute(vertex, buffer, 0); + + // Should process 2 triangles (curve + closing) + expect(index).toBe(6); + + // First triangle (curve): bezier should be (0.5, 0.5), NOT Loop-Blinn values + expect(buffer[2]).toBe(0.5); + expect(buffer[3]).toBe(0.5); + expect(buffer[FLOATS_PER_VERTEX + 2]).toBe(0.5); + expect(buffer[FLOATS_PER_VERTEX + 3]).toBe(0.5); + expect(buffer[FLOATS_PER_VERTEX * 2 + 2]).toBe(0.5); + expect(buffer[FLOATS_PER_VERTEX * 2 + 3]).toBe(0.5); + }); + + it("should return correct index count", () => + { + const vertex: IPath = [ + 0, 0, false, + 100, 0, false, + 100, 100, false, + 0, 0, false + ]; + const buffer = new Float32Array(9 * FLOATS_PER_VERTEX); + + const index = execute(vertex, buffer, 0); + + // 2 triangles = 6 vertices + expect(index).toBe(6); + }); + }); + + describe("edge cases", () => + { + it("should handle path with minimum points", () => + { + // Minimum valid path: 2 points (6 elements) doesn't produce triangles + // Need at least 3 non-curve points for 1 triangle + const vertex: IPath = [ + 0, 0, false, + 100, 0, false + ]; + const buffer = new Float32Array(3 * FLOATS_PER_VERTEX); + + const index = execute(vertex, buffer, 0); + + // Not enough points for triangle + expect(index).toBe(0); + }); + + it("should handle starting index offset", () => + { + const vertex: IPath = [ + 0, 0, false, + 100, 0, false, + 100, 100, false, + 0, 0, false + ]; + const buffer = new Float32Array(20 * FLOATS_PER_VERTEX); + + const index = execute( + vertex, buffer, 5 // start at index 5 + ); + + // Should return 5 + 6 = 11 + expect(index).toBe(11); + + // Data should start at index 5 * 4 = 20 + // Verify bezier at position 20 + 2 = 22 + expect(buffer[20 + 2]).toBe(0.5); + expect(buffer[20 + 3]).toBe(0.5); + }); + }); +}); diff --git a/packages/webgpu/src/Mesh/service/MeshStrokeFillGenerateService.ts b/packages/webgpu/src/Mesh/service/MeshStrokeFillGenerateService.ts new file mode 100644 index 00000000..a2520667 --- /dev/null +++ b/packages/webgpu/src/Mesh/service/MeshStrokeFillGenerateService.ts @@ -0,0 +1,94 @@ +import type { IPath } from "../../interface/IPath"; + +/** + * @description ストローク塗りつぶし用のメッシュを生成する(bezier座標は常に0.5, 0.5) + * Generate a mesh for stroke fill (bezier coordinates are always 0.5, 0.5) + * + * 頂点フォーマット(4 floats per vertex): + * - position: x, y (2 floats) + * - bezier: u, v (2 floats) - 常に (0.5, 0.5) を設定 + * + * color/matrixはuniform bufferで供給される + * + * @param {IPath} vertex + * @param {Float32Array} buffer + * @param {number} index - 現在の頂点インデックス + * @return {number} 新しい頂点インデックス + * @method + * @protected + */ +export const execute = ( + vertex: IPath, + buffer: Float32Array, + index: number +): number => { + + const length = vertex.length - 5; + + for (let idx = 3; idx < length; idx += 3) { + + let position = index * 4; + + if (vertex[idx + 2]) { + // 座標A (始点) + buffer[position++] = vertex[idx - 3] as number; + buffer[position++] = vertex[idx - 2] as number; + buffer[position++] = 0.5; + buffer[position++] = 0.5; + + // 座標B (制御点) + buffer[position++] = vertex[idx] as number; + buffer[position++] = vertex[idx + 1] as number; + buffer[position++] = 0.5; + buffer[position++] = 0.5; + + // 座標C (終点) + buffer[position++] = vertex[idx + 3] as number; + buffer[position++] = vertex[idx + 4] as number; + buffer[position++] = 0.5; + buffer[position++] = 0.5; + + } else if (vertex[idx + 5]) { + // 座標A (基点) + buffer[position++] = vertex[0] as number; + buffer[position++] = vertex[1] as number; + buffer[position++] = 0.5; + buffer[position++] = 0.5; + + // 座標B (現在点) + buffer[position++] = vertex[idx] as number; + buffer[position++] = vertex[idx + 1] as number; + buffer[position++] = 0.5; + buffer[position++] = 0.5; + + // 座標C (次の次の点) + buffer[position++] = vertex[idx + 6] as number; + buffer[position++] = vertex[idx + 7] as number; + buffer[position++] = 0.5; + buffer[position++] = 0.5; + + } else { + // 座標A (基点) + buffer[position++] = vertex[0] as number; + buffer[position++] = vertex[1] as number; + buffer[position++] = 0.5; + buffer[position++] = 0.5; + + // 座標B (現在点) + buffer[position++] = vertex[idx] as number; + buffer[position++] = vertex[idx + 1] as number; + buffer[position++] = 0.5; + buffer[position++] = 0.5; + + // 座標C (次の点) + buffer[position++] = vertex[idx + 3] as number; + buffer[position++] = vertex[idx + 4] as number; + buffer[position++] = 0.5; + buffer[position++] = 0.5; + } + + index += 3; + } + + return index; +}; diff --git a/packages/webgpu/src/Mesh/usecase/MeshBitmapStrokeGenerateUseCase.test.ts b/packages/webgpu/src/Mesh/usecase/MeshBitmapStrokeGenerateUseCase.test.ts new file mode 100644 index 00000000..8e66417a --- /dev/null +++ b/packages/webgpu/src/Mesh/usecase/MeshBitmapStrokeGenerateUseCase.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IPath } from "../../interface/IPath"; +import { execute } from "./MeshBitmapStrokeGenerateUseCase"; + +// Mock the MeshStrokeGenerateUseCase +// generateStrokeOutline now returns IPath[] directly (not IRectangleInfo[]) +const mockGenerateStrokeOutline = vi.fn((vertices: number[], thickness: number) => { + if (vertices.length < 6) { + return []; + } + // Return IPath directly (rectangle with 5 points = 15 elements) + return [[ + 0, -thickness, false, + 100, -thickness, false, + 100, thickness, false, + 0, thickness, false, + 0, -thickness, false + ]]; +}); + +vi.mock("./MeshStrokeGenerateUseCase", () => ({ + "generateStrokeOutline": (vertices: number[], thickness: number) => mockGenerateStrokeOutline(vertices, thickness) +})); + +describe("MeshBitmapStrokeGenerateUseCase", () => +{ + beforeEach(() => + { + vi.clearAllMocks(); + }); + + describe("basic mesh generation", () => + { + it("should return IMeshResult with buffer and indexCount", () => + { + const vertices: IPath[] = [[ + 0, 0, false, + 100, 0, false + ]]; + + const result = execute(vertices, 10); + + expect(result).toHaveProperty("buffer"); + expect(result).toHaveProperty("indexCount"); + expect(result.buffer).toBeInstanceOf(Float32Array); + }); + + it("should return empty result for insufficient vertices", () => + { + const vertices: IPath[] = [[ + 0, 0, false // Only one point + ]]; + + const result = execute(vertices, 10); + + expect(result.buffer.length).toBe(0); + expect(result.indexCount).toBe(0); + }); + + it("should return empty result for empty vertices array", () => + { + const vertices: IPath[] = []; + + const result = execute(vertices, 10); + + expect(result.buffer.length).toBe(0); + expect(result.indexCount).toBe(0); + }); + }); + + describe("vertex format", () => + { + it("should generate 4 floats per vertex", () => + { + const vertices: IPath[] = [[ + 0, 0, false, + 100, 0, false + ]]; + + const result = execute(vertices, 10); + + // Path with 5 points (15 elements): MeshStrokeFillGenerateService creates 3 triangles + // Each triangle has 3 vertices, so 9 vertices total + // 9 vertices * 4 floats = 36 + expect(result.buffer.length).toBe(9 * 4); + expect(result.indexCount).toBe(9); + }); + + it("should set bezier coordinates correctly", () => + { + const vertices: IPath[] = [[ + 0, 0, false, + 100, 0, false + ]]; + + const result = execute(vertices, 10); + + // MeshStrokeFillGenerateService sets bezier coordinates to (0.5, 0.5) + expect(result.buffer.length).toBeGreaterThan(0); + // Check bezier at offset 2 + expect(result.buffer[2]).toBe(0.5); + expect(result.buffer[3]).toBe(0.5); + }); + }); + + describe("thickness handling", () => + { + it("should use half thickness internally", () => + { + const vertices: IPath[] = [[ + 0, 0, false, + 100, 0, false + ]]; + + execute(vertices, 20); // thickness = 20 + + expect(mockGenerateStrokeOutline).toHaveBeenCalledWith( + expect.anything(), + 10 // halfThickness = 20 / 2 + ); + }); + }); + + describe("multiple paths", () => + { + it("should handle multiple paths", () => + { + const vertices: IPath[] = [ + [0, 0, false, 100, 0, false], + [200, 200, false, 300, 200, false] + ]; + + const result = execute(vertices, 10); + + // 2 paths, each generates 9 vertices (from MeshStrokeFillGenerateService) + expect(result.indexCount).toBe(18); + }); + }); +}); diff --git a/packages/webgpu/src/Mesh/usecase/MeshBitmapStrokeGenerateUseCase.ts b/packages/webgpu/src/Mesh/usecase/MeshBitmapStrokeGenerateUseCase.ts new file mode 100644 index 00000000..0a44eb67 --- /dev/null +++ b/packages/webgpu/src/Mesh/usecase/MeshBitmapStrokeGenerateUseCase.ts @@ -0,0 +1,85 @@ +import type { IPath } from "../../interface/IPath"; +import type { IMeshResult } from "../../interface/IMeshResult"; +import { generateStrokeOutline } from "./MeshStrokeGenerateUseCase"; +import { execute as meshStrokeFillGenerateService } from "../service/MeshStrokeFillGenerateService"; + +/** + * @description メッシュ生成用の再利用可能な一時バッファ(GC回避) + */ +let $meshTempBuffer: Float32Array = new Float32Array(32); + +const $upperPowerOfTwo = (v: number): number => +{ + v--; + v |= v >> 1; + v |= v >> 2; + v |= v >> 4; + v |= v >> 8; + v |= v >> 16; + v++; + return v; +}; + +/** + * @description ビットマップストローク用のメッシュを生成する + * Generate a mesh for bitmap stroke + * + * @param {IPath[]} vertices + * @param {number} thickness + * @return {IMeshResult} + * @method + * @protected + */ +export const execute = ( + vertices: IPath[], + thickness: number +): IMeshResult => { + + const halfThickness = thickness / 2; + + // 各パスのアウトラインを生成 + const fillVertices: IPath[] = []; + for (const path of vertices) { + const outlines = generateStrokeOutline(path, halfThickness); + for (const outline of outlines) { + fillVertices.push(outline); + } + } + + // 頂点数を計算(各パスの三角形数 × 3) + let totalVertices = 0; + for (const vertex of fillVertices) { + const length = vertex.length - 5; + for (let idx = 3; idx < length; idx += 3) { + totalVertices += 3; + } + } + + if (totalVertices === 0) { + return { + "buffer": new Float32Array(0), + "indexCount": 0 + }; + } + + // バッファを確保(4 floats per vertex、再利用可能バッファ) + const requiredSize = totalVertices * 4; + if ($meshTempBuffer.length < requiredSize) { + $meshTempBuffer = new Float32Array($upperPowerOfTwo(requiredSize)); + } + const buffer = $meshTempBuffer; + + let index = 0; + for (const vertex of fillVertices) { + index = meshStrokeFillGenerateService( + vertex, + buffer, + index + ); + } + + return { + "buffer": buffer.subarray(0, index * 4), + "indexCount": index + }; +}; diff --git a/packages/webgpu/src/Mesh/usecase/MeshFillGenerateUseCase.test.ts b/packages/webgpu/src/Mesh/usecase/MeshFillGenerateUseCase.test.ts new file mode 100644 index 00000000..3cc68207 --- /dev/null +++ b/packages/webgpu/src/Mesh/usecase/MeshFillGenerateUseCase.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IPath } from "../../interface/IPath"; +import { execute } from "./MeshFillGenerateUseCase"; + +// Mock the service +vi.mock("../service/MeshFillGenerateService", () => ({ + "execute": vi.fn((vertex, buffer, index) => { + // Simulate processing: each triangle iteration produces 3 vertices + const triangleCount = Math.floor((vertex.length - 5) / 3); + return index + triangleCount * 3; + }) +})); + +import { execute as mockFillGenerateService } from "../service/MeshFillGenerateService"; + +describe("MeshFillGenerateUseCase", () => +{ + beforeEach(() => + { + vi.clearAllMocks(); + }); + + describe("vertex counting", () => + { + it("should calculate correct total vertices for single path", () => + { + // Path with 4 points (12 elements) = 2 triangles (6 vertices) + // length - 5 = 7, idx from 3 to 6, 2 iterations = 6 vertices + const vertices: IPath[] = [[0, 0, false, 100, 0, false, 100, 100, false, 0, 0, false]]; + + const result = execute(vertices); + + // Buffer should be allocated based on vertex count + expect(result.buffer).toBeInstanceOf(Float32Array); + }); + + it("should handle multiple paths", () => + { + const vertices: IPath[] = [ + [0, 0, false, 100, 0, false, 100, 100, false, 0, 0, false], + [200, 200, false, 300, 200, false, 300, 300, false, 200, 200, false] + ]; + + execute(vertices); + + // Should be called once per path + expect(mockFillGenerateService).toHaveBeenCalledTimes(2); + }); + }); + + describe("service call", () => + { + it("should pass vertex, buffer, and index to service", () => + { + const vertices: IPath[] = [[0, 0, false, 100, 0, false, 100, 100, false, 0, 0, false]]; + + execute(vertices); + + expect(mockFillGenerateService).toHaveBeenCalledWith( + expect.anything(), + expect.any(Float32Array), + 0 + ); + }); + }); + + describe("result structure", () => + { + it("should return buffer property", () => + { + const vertices: IPath[] = [[0, 0, false, 100, 0, false, 100, 100, false, 0, 0, false]]; + + const result = execute(vertices); + + expect(result).toHaveProperty("buffer"); + }); + + it("should return indexCount property", () => + { + const vertices: IPath[] = [[0, 0, false, 100, 0, false, 100, 100, false, 0, 0, false]]; + + const result = execute(vertices); + + expect(result).toHaveProperty("indexCount"); + }); + }); + + describe("empty input", () => + { + it("should handle empty vertices array", () => + { + const vertices: IPath[] = []; + + const result = execute(vertices); + + expect(result.buffer.length).toBe(0); + expect(result.indexCount).toBe(0); + }); + + it("should handle path with insufficient points", () => + { + // Path with only 2 points (6 elements) - not enough for a triangle + const vertices: IPath[] = [[0, 0, false, 100, 0, false]]; + + const result = execute(vertices); + + // Service should be called but return same index + expect(result.buffer).toBeInstanceOf(Float32Array); + }); + }); +}); diff --git a/packages/webgpu/src/Mesh/usecase/MeshFillGenerateUseCase.ts b/packages/webgpu/src/Mesh/usecase/MeshFillGenerateUseCase.ts new file mode 100644 index 00000000..420fad5b --- /dev/null +++ b/packages/webgpu/src/Mesh/usecase/MeshFillGenerateUseCase.ts @@ -0,0 +1,64 @@ +import type { IPath } from "../../interface/IPath"; +import type { IMeshResult } from "../../interface/IMeshResult"; +import { execute as meshFillGenerateService } from "../service/MeshFillGenerateService"; + +/** + * @description メッシュ生成用の再利用可能な一時バッファ(GC回避) + */ +let $meshTempBuffer: Float32Array = new Float32Array(32); + +const $upperPowerOfTwo = (v: number): number => +{ + v--; + v |= v >> 1; + v |= v >> 2; + v |= v >> 4; + v |= v >> 8; + v |= v >> 16; + v++; + return v; +}; + +/** + * @description 塗りのメッシュを生成する + * Generate a fill mesh + * + * @param {IPath[]} vertices + * @return {IMeshResult} + * @method + * @protected + */ +export const execute = ( + vertices: IPath[] +): IMeshResult => { + + // 頂点数を計算(各パスの三角形数 × 3) + let totalVertices = 0; + for (const vertex of vertices) { + const length = vertex.length - 5; + for (let idx = 3; idx < length; idx += 3) { + totalVertices += 3; + } + } + + // バッファを確保(4 floats per vertex、再利用可能バッファ) + const requiredSize = totalVertices * 4; + if ($meshTempBuffer.length < requiredSize) { + $meshTempBuffer = new Float32Array($upperPowerOfTwo(requiredSize)); + } + const buffer = $meshTempBuffer; + + let index = 0; + for (const vertex of vertices) { + index = meshFillGenerateService( + vertex, + buffer, + index + ); + } + + return { + "buffer": buffer.subarray(0, index * 4), + "indexCount": index + }; +}; diff --git a/packages/webgpu/src/Mesh/usecase/MeshGradientStrokeGenerateUseCase.test.ts b/packages/webgpu/src/Mesh/usecase/MeshGradientStrokeGenerateUseCase.test.ts new file mode 100644 index 00000000..866c9d7d --- /dev/null +++ b/packages/webgpu/src/Mesh/usecase/MeshGradientStrokeGenerateUseCase.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { IPath } from "../../interface/IPath"; +import { execute } from "./MeshGradientStrokeGenerateUseCase"; + +// Mock the MeshStrokeGenerateUseCase +// generateStrokeOutline now returns IPath[] directly (not IRectangleInfo[]) +const mockGenerateStrokeOutline = vi.fn((vertices: number[], thickness: number) => { + if (vertices.length < 6) { + return []; + } + // Return IPath directly (rectangle with 5 points = 15 elements) + return [[ + 0, -thickness, false, + 100, -thickness, false, + 100, thickness, false, + 0, thickness, false, + 0, -thickness, false + ]]; +}); + +vi.mock("./MeshStrokeGenerateUseCase", () => ({ + "generateStrokeOutline": (vertices: number[], thickness: number) => mockGenerateStrokeOutline(vertices, thickness) +})); + +describe("MeshGradientStrokeGenerateUseCase", () => +{ + beforeEach(() => + { + vi.clearAllMocks(); + }); + + describe("basic mesh generation", () => + { + it("should return IMeshResult with buffer and indexCount", () => + { + const vertices: IPath[] = [[ + 0, 0, false, + 100, 0, false + ]]; + + const result = execute(vertices, 10); + + expect(result).toHaveProperty("buffer"); + expect(result).toHaveProperty("indexCount"); + expect(result.buffer).toBeInstanceOf(Float32Array); + }); + + it("should return empty result for insufficient vertices", () => + { + const vertices: IPath[] = [[ + 0, 0, false + ]]; + + const result = execute(vertices, 10); + + expect(result.buffer.length).toBe(0); + expect(result.indexCount).toBe(0); + }); + + it("should return empty result for empty vertices array", () => + { + const vertices: IPath[] = []; + + const result = execute(vertices, 10); + + expect(result.buffer.length).toBe(0); + expect(result.indexCount).toBe(0); + }); + }); + + describe("vertex format", () => + { + it("should generate 4 floats per vertex", () => + { + const vertices: IPath[] = [[ + 0, 0, false, + 100, 0, false + ]]; + + const result = execute(vertices, 10); + + // Path with 5 points (15 elements): MeshStrokeFillGenerateService creates 3 triangles + // Each triangle has 3 vertices, so 9 vertices total + // 9 vertices * 4 floats = 36 + expect(result.buffer.length).toBe(9 * 4); + expect(result.indexCount).toBe(9); + }); + + it("should set bezier coordinates correctly", () => + { + const vertices: IPath[] = [[ + 0, 0, false, + 100, 0, false + ]]; + + const result = execute(vertices, 10); + + // MeshStrokeFillGenerateService sets bezier coordinates to (0.5, 0.5) + expect(result.buffer.length).toBeGreaterThan(0); + // Check bezier at offset 2 + expect(result.buffer[2]).toBe(0.5); + expect(result.buffer[3]).toBe(0.5); + }); + }); + + describe("thickness handling", () => + { + it("should use half thickness internally", () => + { + const vertices: IPath[] = [[ + 0, 0, false, + 100, 0, false + ]]; + + execute(vertices, 20); // thickness = 20 + + expect(mockGenerateStrokeOutline).toHaveBeenCalledWith( + expect.anything(), + 10 // halfThickness = 20 / 2 + ); + }); + }); + + describe("multiple paths", () => + { + it("should handle multiple paths", () => + { + const vertices: IPath[] = [ + [0, 0, false, 100, 0, false], + [200, 200, false, 300, 200, false] + ]; + + const result = execute(vertices, 10); + + // 2 paths, each generates 9 vertices (from MeshStrokeFillGenerateService) + expect(result.indexCount).toBe(18); + }); + }); +}); diff --git a/packages/webgpu/src/Mesh/usecase/MeshGradientStrokeGenerateUseCase.ts b/packages/webgpu/src/Mesh/usecase/MeshGradientStrokeGenerateUseCase.ts new file mode 100644 index 00000000..2ab5b446 --- /dev/null +++ b/packages/webgpu/src/Mesh/usecase/MeshGradientStrokeGenerateUseCase.ts @@ -0,0 +1,85 @@ +import type { IPath } from "../../interface/IPath"; +import type { IMeshResult } from "../../interface/IMeshResult"; +import { generateStrokeOutline } from "./MeshStrokeGenerateUseCase"; +import { execute as meshStrokeFillGenerateService } from "../service/MeshStrokeFillGenerateService"; + +/** + * @description メッシュ生成用の再利用可能な一時バッファ(GC回避) + */ +let $meshTempBuffer: Float32Array = new Float32Array(32); + +const $upperPowerOfTwo = (v: number): number => +{ + v--; + v |= v >> 1; + v |= v >> 2; + v |= v >> 4; + v |= v >> 8; + v |= v >> 16; + v++; + return v; +}; + +/** + * @description グラデーションストローク用のメッシュを生成する + * Generate a mesh for gradient stroke + * + * @param {IPath[]} vertices + * @param {number} thickness + * @return {IMeshResult} + * @method + * @protected + */ +export const execute = ( + vertices: IPath[], + thickness: number +): IMeshResult => { + + const halfThickness = thickness / 2; + + // 各パスのアウトラインを生成 + const fillVertices: IPath[] = []; + for (const path of vertices) { + const outlines = generateStrokeOutline(path, halfThickness); + for (const outline of outlines) { + fillVertices.push(outline); + } + } + + // 頂点数を計算(各パスの三角形数 × 3) + let totalVertices = 0; + for (const vertex of fillVertices) { + const length = vertex.length - 5; + for (let idx = 3; idx < length; idx += 3) { + totalVertices += 3; + } + } + + if (totalVertices === 0) { + return { + "buffer": new Float32Array(0), + "indexCount": 0 + }; + } + + // バッファを確保(4 floats per vertex、再利用可能バッファ) + const requiredSize = totalVertices * 4; + if ($meshTempBuffer.length < requiredSize) { + $meshTempBuffer = new Float32Array($upperPowerOfTwo(requiredSize)); + } + const buffer = $meshTempBuffer; + + let index = 0; + for (const vertex of fillVertices) { + index = meshStrokeFillGenerateService( + vertex, + buffer, + index + ); + } + + return { + "buffer": buffer.subarray(0, index * 4), + "indexCount": index + }; +}; diff --git a/packages/webgpu/src/Mesh/usecase/MeshSplitQuadraticBezierUseCase.test.ts b/packages/webgpu/src/Mesh/usecase/MeshSplitQuadraticBezierUseCase.test.ts new file mode 100644 index 00000000..66a2443d --- /dev/null +++ b/packages/webgpu/src/Mesh/usecase/MeshSplitQuadraticBezierUseCase.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect } from "vitest"; +import { execute } from "./MeshSplitQuadraticBezierUseCase"; + +describe("MeshSplitQuadraticBezierUseCase", () => +{ + describe("basic splitting", () => + { + it("should split a simple quadratic bezier at t=0.5", () => + { + const start = { x: 0, y: 0 }; + const control = { x: 50, y: 100 }; + const end = { x: 100, y: 0 }; + + const result = execute(start, control, end, 0.5); + + expect(result).toHaveLength(2); + expect(result[0]).toHaveLength(3); + expect(result[1]).toHaveLength(3); + }); + + it("should use default t=0.5 when not specified", () => + { + const start = { x: 0, y: 0 }; + const control = { x: 50, y: 100 }; + const end = { x: 100, y: 0 }; + + const result = execute(start, control, end); + + expect(result).toHaveLength(2); + }); + + it("should preserve start point in left sub-curve", () => + { + const start = { x: 10, y: 20 }; + const control = { x: 50, y: 100 }; + const end = { x: 100, y: 0 }; + + const result = execute(start, control, end, 0.5); + + expect(result[0][0]).toEqual(start); + }); + + it("should preserve end point in right sub-curve", () => + { + const start = { x: 0, y: 0 }; + const control = { x: 50, y: 100 }; + const end = { x: 90, y: 30 }; + + const result = execute(start, control, end, 0.5); + + expect(result[1][2]).toEqual(end); + }); + + it("should share the split point between curves", () => + { + const start = { x: 0, y: 0 }; + const control = { x: 50, y: 100 }; + const end = { x: 100, y: 0 }; + + const result = execute(start, control, end, 0.5); + + // M01 (split point) is last of left and first of right + expect(result[0][2]).toEqual(result[1][0]); + }); + }); + + describe("split at t=0.5", () => + { + it("should compute correct intermediate points", () => + { + const start = { x: 0, y: 0 }; + const control = { x: 100, y: 200 }; + const end = { x: 200, y: 0 }; + + const result = execute(start, control, end, 0.5); + + // M0 = lerp(P0, P1, 0.5) = (50, 100) + // M1 = lerp(P1, P2, 0.5) = (150, 100) + // M01 = lerp(M0, M1, 0.5) = (100, 100) + + // Left curve: [P0, M0, M01] = [(0,0), (50,100), (100,100)] + expect(result[0][0]).toEqual({ x: 0, y: 0 }); + expect(result[0][1]).toEqual({ x: 50, y: 100 }); + expect(result[0][2]).toEqual({ x: 100, y: 100 }); + + // Right curve: [M01, M1, P2] = [(100,100), (150,100), (200,0)] + expect(result[1][0]).toEqual({ x: 100, y: 100 }); + expect(result[1][1]).toEqual({ x: 150, y: 100 }); + expect(result[1][2]).toEqual({ x: 200, y: 0 }); + }); + }); + + describe("split at different t values", () => + { + it("should correctly split at t=0.25", () => + { + const start = { x: 0, y: 0 }; + const control = { x: 100, y: 0 }; + const end = { x: 100, y: 100 }; + + const result = execute(start, control, end, 0.25); + + // M0 = lerp(P0, P1, 0.25) = (25, 0) + // M1 = lerp(P1, P2, 0.25) = (100, 25) + // M01 = lerp(M0, M1, 0.25) = (25 + (100-25)*0.25, 0 + (25-0)*0.25) = (43.75, 6.25) + + expect(result[0][1].x).toBeCloseTo(25, 5); + expect(result[0][1].y).toBeCloseTo(0, 5); + + expect(result[1][1].x).toBeCloseTo(100, 5); + expect(result[1][1].y).toBeCloseTo(25, 5); + }); + + it("should correctly split at t=0.75", () => + { + const start = { x: 0, y: 0 }; + const control = { x: 100, y: 0 }; + const end = { x: 100, y: 100 }; + + const result = execute(start, control, end, 0.75); + + // M0 = lerp(P0, P1, 0.75) = (75, 0) + // M1 = lerp(P1, P2, 0.75) = (100, 75) + + expect(result[0][1].x).toBeCloseTo(75, 5); + expect(result[0][1].y).toBeCloseTo(0, 5); + + expect(result[1][1].x).toBeCloseTo(100, 5); + expect(result[1][1].y).toBeCloseTo(75, 5); + }); + + it("should handle t=0 (split at start)", () => + { + const start = { x: 0, y: 0 }; + const control = { x: 50, y: 100 }; + const end = { x: 100, y: 0 }; + + const result = execute(start, control, end, 0); + + // At t=0, split point is at start + expect(result[0][2]).toEqual(start); + expect(result[1][0]).toEqual(start); + }); + + it("should handle t=1 (split at end)", () => + { + const start = { x: 0, y: 0 }; + const control = { x: 50, y: 100 }; + const end = { x: 100, y: 0 }; + + const result = execute(start, control, end, 1); + + // At t=1, split point is at end + expect(result[0][2]).toEqual(end); + expect(result[1][0]).toEqual(end); + }); + }); + + describe("edge cases", () => + { + it("should handle horizontal line (control on line)", () => + { + const start = { x: 0, y: 50 }; + const control = { x: 50, y: 50 }; + const end = { x: 100, y: 50 }; + + const result = execute(start, control, end, 0.5); + + // All y values should remain 50 + expect(result[0][0].y).toBe(50); + expect(result[0][1].y).toBe(50); + expect(result[0][2].y).toBe(50); + expect(result[1][1].y).toBe(50); + expect(result[1][2].y).toBe(50); + }); + + it("should handle vertical line", () => + { + const start = { x: 50, y: 0 }; + const control = { x: 50, y: 50 }; + const end = { x: 50, y: 100 }; + + const result = execute(start, control, end, 0.5); + + // All x values should remain 50 + expect(result[0][0].x).toBe(50); + expect(result[0][1].x).toBe(50); + expect(result[0][2].x).toBe(50); + expect(result[1][1].x).toBe(50); + expect(result[1][2].x).toBe(50); + }); + + it("should handle single point curve", () => + { + const point = { x: 50, y: 50 }; + + const result = execute(point, point, point, 0.5); + + // All points should be the same + expect(result[0][0]).toEqual(point); + expect(result[0][1]).toEqual(point); + expect(result[0][2]).toEqual(point); + expect(result[1][0]).toEqual(point); + expect(result[1][1]).toEqual(point); + expect(result[1][2]).toEqual(point); + }); + + it("should handle negative coordinates", () => + { + const start = { x: -100, y: -100 }; + const control = { x: 0, y: 100 }; + const end = { x: 100, y: -100 }; + + const result = execute(start, control, end, 0.5); + + expect(result).toHaveLength(2); + expect(result[0][0]).toEqual(start); + expect(result[1][2]).toEqual(end); + }); + + it("should handle very small t value", () => + { + const start = { x: 0, y: 0 }; + const control = { x: 50, y: 100 }; + const end = { x: 100, y: 0 }; + + const result = execute(start, control, end, 0.001); + + // Split point should be much closer to start than to end + // At t=0.001, we expect very small values + expect(result[0][2].x).toBeLessThan(1); + expect(result[0][2].y).toBeLessThan(1); + }); + + it("should handle very large coordinates", () => + { + const start = { x: 0, y: 0 }; + const control = { x: 100000, y: 200000 }; + const end = { x: 200000, y: 0 }; + + const result = execute(start, control, end, 0.5); + + expect(result[0][1].x).toBeCloseTo(50000, 0); + expect(result[0][1].y).toBeCloseTo(100000, 0); + }); + }); +}); diff --git a/packages/webgpu/src/Mesh/usecase/MeshSplitQuadraticBezierUseCase.ts b/packages/webgpu/src/Mesh/usecase/MeshSplitQuadraticBezierUseCase.ts new file mode 100644 index 00000000..dba9c744 --- /dev/null +++ b/packages/webgpu/src/Mesh/usecase/MeshSplitQuadraticBezierUseCase.ts @@ -0,0 +1,37 @@ +import type { IPoint } from "../../interface/IPoint"; +import { execute as meshLerpService } from "../service/MeshLerpService"; + +/** + * @description 二次ベジェ曲線を分割する + * Split a quadratic Bezier curve + * + * @param {IPoint} start_point + * @param {IPoint} control_point + * @param {IPoint} end_point + * @param {number} [t = 0.5] + * @return {Array} + * @method + * @protected + */ +export const execute = ( + start_point: IPoint, + control_point: IPoint, + end_point: IPoint, + t: number = 0.5 +): Array => { + + // 二次ベジエ曲線の分割 + // M0 = lerp(P0, P1, t) + // M1 = lerp(P1, P2, t) + // M01 = lerp(M0, M1, t) + const M0 = meshLerpService(start_point, control_point, t); + const M1 = meshLerpService(control_point, end_point, t); + const M01 = meshLerpService(M0, M1, t); + + // 左サブ (0...t): [P0, M0, M01] + // 右サブ (t...1): [M01, M1, P2] + return [ + [start_point, M0, M01], + [M01, M1, end_point] + ]; +}; diff --git a/packages/webgpu/src/Mesh/usecase/MeshStrokeFillGenerateUseCase.test.ts b/packages/webgpu/src/Mesh/usecase/MeshStrokeFillGenerateUseCase.test.ts new file mode 100644 index 00000000..c83389b8 --- /dev/null +++ b/packages/webgpu/src/Mesh/usecase/MeshStrokeFillGenerateUseCase.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "vitest"; +import type { IPath } from "../../interface/IPath"; +import { execute } from "./MeshStrokeFillGenerateUseCase"; + +describe("MeshStrokeFillGenerateUseCase", () => +{ + describe("basic mesh generation", () => + { + it("should return IMeshResult with buffer and indexCount", () => + { + const vertices: IPath[] = [[ + 0, 0, false, + 100, 0, false, + 100, 100, false, + 0, 0, false + ]]; + + const result = execute(vertices); + + expect(result).toHaveProperty("buffer"); + expect(result).toHaveProperty("indexCount"); + expect(result.buffer).toBeInstanceOf(Float32Array); + }); + + it("should generate 4 floats per vertex", () => + { + const vertices: IPath[] = [[ + 0, 0, false, + 100, 0, false, + 100, 100, false, + 0, 0, false + ]]; + + const result = execute(vertices); + + // 2 triangles * 3 vertices = 6 vertices + // 6 vertices * 4 floats = 24 + expect(result.buffer.length).toBe(6 * 4); + expect(result.indexCount).toBe(6); + }); + }); + + describe("bezier coordinates", () => + { + it("should always set bezier to (0.5, 0.5) for stroke fill", () => + { + const vertices: IPath[] = [[ + 0, 0, false, + 100, 0, false, + 100, 100, false, + 0, 0, false + ]]; + + const result = execute(vertices); + + // Check bezier coordinates for all vertices + for (let i = 0; i < result.indexCount; i++) { + const offset = i * 4; + expect(result.buffer[offset + 2]).toBe(0.5); // bezier.u + expect(result.buffer[offset + 3]).toBe(0.5); // bezier.v + } + }); + + it("should set bezier to (0.5, 0.5) even for curve paths", () => + { + const vertices: IPath[] = [[ + 0, 0, false, + 50, 50, true, // control point + 100, 0, false, + 0, 0, false + ]]; + + const result = execute(vertices); + + // All bezier coordinates should be (0.5, 0.5) + for (let i = 0; i < result.indexCount; i++) { + const offset = i * 4; + expect(result.buffer[offset + 2]).toBe(0.5); + expect(result.buffer[offset + 3]).toBe(0.5); + } + }); + }); + + describe("multiple paths", () => + { + it("should handle multiple paths", () => + { + const vertices: IPath[] = [ + [0, 0, false, 100, 0, false, 100, 100, false, 0, 0, false], + [200, 200, false, 300, 200, false, 300, 300, false, 200, 200, false] + ]; + + const result = execute(vertices); + + // 2 paths * 2 triangles * 3 vertices = 12 vertices + expect(result.indexCount).toBe(12); + }); + }); + + describe("empty input", () => + { + it("should handle empty vertices array", () => + { + const vertices: IPath[] = []; + + const result = execute(vertices); + + expect(result.buffer.length).toBe(0); + expect(result.indexCount).toBe(0); + }); + + it("should handle path with insufficient points", () => + { + const vertices: IPath[] = [[ + 0, 0, false, + 100, 0, false + ]]; + + const result = execute(vertices); + + expect(result.buffer.length).toBe(0); + expect(result.indexCount).toBe(0); + }); + }); +}); diff --git a/packages/webgpu/src/Mesh/usecase/MeshStrokeFillGenerateUseCase.ts b/packages/webgpu/src/Mesh/usecase/MeshStrokeFillGenerateUseCase.ts new file mode 100644 index 00000000..9b1784e1 --- /dev/null +++ b/packages/webgpu/src/Mesh/usecase/MeshStrokeFillGenerateUseCase.ts @@ -0,0 +1,70 @@ +import type { IPath } from "../../interface/IPath"; +import type { IMeshResult } from "../../interface/IMeshResult"; +import { execute as meshStrokeFillGenerateService } from "../service/MeshStrokeFillGenerateService"; + +/** + * @description メッシュ生成用の再利用可能な一時バッファ(GC回避) + */ +let $meshTempBuffer: Float32Array = new Float32Array(32); + +const $upperPowerOfTwo = (v: number): number => +{ + v--; + v |= v >> 1; + v |= v >> 2; + v |= v >> 4; + v |= v >> 8; + v |= v >> 16; + v++; + return v; +}; + +/** + * @description ストローク塗りつぶし用のメッシュを生成する + * Generate a stroke fill mesh + * + * 頂点フォーマット(4 floats per vertex): + * - position: x, y (2 floats) + * - bezier: u, v (2 floats) - 常に (0.5, 0.5) + * + * color/matrixはuniform bufferで供給される + * + * @param {IPath[]} vertices + * @return {IMeshResult} + * @method + * @protected + */ +export const execute = ( + vertices: IPath[] +): IMeshResult => { + + // 頂点数を計算(各パスの三角形数 × 3) + let totalVertices = 0; + for (const vertex of vertices) { + const length = vertex.length - 5; + for (let idx = 3; idx < length; idx += 3) { + totalVertices += 3; + } + } + + // バッファを確保(4 floats per vertex、再利用可能バッファ) + const requiredSize = totalVertices * 4; + if ($meshTempBuffer.length < requiredSize) { + $meshTempBuffer = new Float32Array($upperPowerOfTwo(requiredSize)); + } + const buffer = $meshTempBuffer; + + let index = 0; + for (const vertex of vertices) { + index = meshStrokeFillGenerateService( + vertex, + buffer, + index + ); + } + + return { + "buffer": buffer.subarray(0, index * 4), + "indexCount": index + }; +}; diff --git a/packages/webgpu/src/Mesh/usecase/MeshStrokeGenerateUseCase.test.ts b/packages/webgpu/src/Mesh/usecase/MeshStrokeGenerateUseCase.test.ts new file mode 100644 index 00000000..d1c60792 --- /dev/null +++ b/packages/webgpu/src/Mesh/usecase/MeshStrokeGenerateUseCase.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, vi } from "vitest"; +import type { IPath } from "../../interface/IPath"; +import type { IPoint } from "../../interface/IPoint"; +import { + generateStrokeOutline, + generateStrokeMesh, + generateStrokeMeshFromPoints +} from "./MeshStrokeGenerateUseCase"; + +// Mock $context +vi.mock("../../WebGPUUtil", () => ({ + "$context": { + "joints": 0, // bevel by default + "caps": 0 // none by default + } +})); + +describe("MeshStrokeGenerateUseCase", () => +{ + describe("generateStrokeOutline", () => + { + it("should return empty array for path with insufficient points", () => + { + const vertices: IPath = [0, 0, false]; // Only 1 point + + const result = generateStrokeOutline(vertices, 5); + + expect(result).toEqual([]); + }); + + it("should generate IPath for simple line segment", () => + { + const vertices: IPath = [ + 0, 0, false, // start point + 100, 0, false // end point + ]; + + const result = generateStrokeOutline(vertices, 5); + + expect(result.length).toBe(1); + // IPath is an array [x, y, isCurve, ...] + expect(Array.isArray(result[0])).toBe(true); + // Rectangle has 5 points (15 elements) - closed path + expect(result[0].length).toBe(15); + }); + + it("should calculate correct normal offset for horizontal line", () => + { + const vertices: IPath = [ + 0, 0, false, + 100, 0, false + ]; + + const result = generateStrokeOutline(vertices, 10); + + // For horizontal line (direction = (100, 0)), normal is perpendicular: + // normal.x = -(y / magnitude) * thickness = 0 + // normal.y = (x / magnitude) * thickness = 1 * 10 = 10 + // Path format: [startUpX, startUpY, false, endUpX, endUpY, false, endDownX, endDownY, false, startDownX, startDownY, false, ...] + const startUpY = result[0][1] as number; + const startDownY = result[0][10] as number; + expect(startUpY).toBeCloseTo(10, 5); + expect(startDownY).toBeCloseTo(-10, 5); + }); + + it("should calculate correct normal offset for vertical line", () => + { + const vertices: IPath = [ + 0, 0, false, + 0, 100, false + ]; + + const result = generateStrokeOutline(vertices, 10); + + // For vertical line (direction = (0, 100)), normal is perpendicular: + // normal.x = -(y / magnitude) * thickness = -(100 / 100) * 10 = -10 + // normal.y = (x / magnitude) * thickness = 0 + const startUpX = result[0][0] as number; + const startDownX = result[0][9] as number; + expect(startUpX).toBeCloseTo(-10, 5); + expect(startDownX).toBeCloseTo(10, 5); + }); + + it("should generate multiple paths for multi-segment path", () => + { + const vertices: IPath = [ + 0, 0, false, + 100, 0, false, + 100, 100, false + ]; + + const result = generateStrokeOutline(vertices, 5); + + // 2 line segments create 2 rectangles, plus 1 join triangle + expect(result.length).toBeGreaterThanOrEqual(2); + }); + + it("should handle curve control points", () => + { + const vertices: IPath = [ + 0, 0, false, + 50, 50, true, // control point + 100, 0, false + ]; + + const result = generateStrokeOutline(vertices, 5); + + // Curve generates one outline (more complex than rectangle) + expect(result.length).toBe(1); + // Curve path has more points than a simple rectangle + expect(result[0].length).toBeGreaterThan(15); + }); + }); + + describe("generateStrokeMesh", () => + { + it("should return IPath array", () => + { + const vertices: IPath[] = [[ + 0, 0, false, + 100, 0, false + ]]; + + const result = generateStrokeMesh(vertices, 10); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + + it("should generate outline paths for line segment", () => + { + const vertices: IPath[] = [[ + 0, 0, false, + 100, 0, false + ]]; + + const result = generateStrokeMesh(vertices, 10); + + // One line segment creates one rectangle path + expect(result.length).toBe(1); + expect(result[0].length).toBe(15); // 5 points * 3 = 15 + }); + + it("should skip paths with insufficient points", () => + { + const vertices: IPath[] = [[ + 0, 0, false // Only one point + ]]; + + const result = generateStrokeMesh(vertices, 10); + + expect(result.length).toBe(0); + }); + + it("should handle multiple paths", () => + { + const vertices: IPath[] = [ + [0, 0, false, 100, 0, false], + [200, 200, false, 300, 200, false] + ]; + + const result = generateStrokeMesh(vertices, 10); + + // 2 line segments create 2 rectangle paths + expect(result.length).toBe(2); + }); + + it("should use half thickness internally", () => + { + const vertices: IPath[] = [[ + 0, 0, false, + 100, 0, false + ]]; + + // Thickness = 20, so halfThickness = 10 + const result = generateStrokeMesh(vertices, 20); + + // Check that the y-offset is ±10 (halfThickness) + // First vertex Y should be at 10 (startUpY) + const startUpY = result[0][1] as number; + expect(Math.abs(startUpY)).toBeCloseTo(10, 5); + }); + }); + + describe("generateStrokeMeshFromPoints", () => + { + it("should return Float32Array", () => + { + const paths: IPoint[][] = [[ + { "x": 0, "y": 0 }, + { "x": 100, "y": 0 } + ]]; + + const result = generateStrokeMeshFromPoints(paths, 10); + + expect(result).toBeInstanceOf(Float32Array); + }); + + it("should generate triangles for line segment", () => + { + const paths: IPoint[][] = [[ + { "x": 0, "y": 0 }, + { "x": 100, "y": 0 } + ]]; + + const result = generateStrokeMeshFromPoints(paths, 10); + + // 2 triangles * 3 vertices * 4 floats = 24 + expect(result.length).toBe(24); + }); + + it("should skip paths with single point", () => + { + const paths: IPoint[][] = [[ + { "x": 0, "y": 0 } + ]]; + + const result = generateStrokeMeshFromPoints(paths, 10); + + expect(result.length).toBe(0); + }); + + it("should handle multi-segment path", () => + { + const paths: IPoint[][] = [[ + { "x": 0, "y": 0 }, + { "x": 100, "y": 0 }, + { "x": 100, "y": 100 } + ]]; + + const result = generateStrokeMeshFromPoints(paths, 10); + + // 2 line segments * 24 floats each = 48 + expect(result.length).toBe(48); + }); + + it("should handle multiple separate paths", () => + { + const paths: IPoint[][] = [ + [{ "x": 0, "y": 0 }, { "x": 100, "y": 0 }], + [{ "x": 200, "y": 200 }, { "x": 300, "y": 200 }] + ]; + + const result = generateStrokeMeshFromPoints(paths, 10); + + // 2 paths * 1 line segment each * 24 floats = 48 + expect(result.length).toBe(48); + }); + }); +}); diff --git a/packages/webgpu/src/Mesh/usecase/MeshStrokeGenerateUseCase.ts b/packages/webgpu/src/Mesh/usecase/MeshStrokeGenerateUseCase.ts new file mode 100644 index 00000000..04ed9b4b --- /dev/null +++ b/packages/webgpu/src/Mesh/usecase/MeshStrokeGenerateUseCase.ts @@ -0,0 +1,851 @@ +import type { IPoint } from "../../interface/IPoint"; +import type { IPath } from "../../interface/IPath"; +import { $context } from "../../WebGPUUtil"; + +/** + * @description Canvas 2Dコンテキスト(点が矩形内にあるか判定用) + */ +const canvas = new OffscreenCanvas(1, 1); +const $canvasContext = canvas.getContext("2d") as OffscreenCanvasRenderingContext2D; + +/** + * @description 再利用可能なPointオブジェクト(GC回避) + */ +const $startPoint: IPoint = { "x": 0, "y": 0 }; +const $controlPoint: IPoint = { "x": 0, "y": 0 }; +const $endPoint: IPoint = { "x": 0, "y": 0 }; +const $prevPoint: IPoint = { "x": 0, "y": 0 }; + +/** + * @description 法線ベクトルを計算(WebGL版のMeshCalculateNormalVectorServiceと同じ) + * @param {number} x - 方向ベクトルのx成分 + * @param {number} y - 方向ベクトルのy成分 + * @param {number} thickness - 線の太さ(半分の値) + * @return {IPoint} + */ +const calculateNormalVector = (x: number, y: number, thickness: number): IPoint => +{ + const magnitude = Math.sqrt(x * x + y * y); + if (magnitude === 0) { + return { "x": 0, "y": 0 }; + } + return { + "x": -(y / magnitude) * thickness, + "y": x / magnitude * thickness + }; +}; + +/** + * @description 線形補間(lerp) + */ +const lerp = (p0: IPoint, p1: IPoint, t: number): IPoint => ({ + "x": p0.x + (p1.x - p0.x) * t, + "y": p0.y + (p1.y - p0.y) * t +}); + +/** + * @description ベクトルの正規化 + */ +const normalize = (point: IPoint): IPoint => { + const length = Math.sqrt(point.x * point.x + point.y * point.y); + return length === 0 + ? { "x": 0, "y": 0 } + : { "x": point.x / length, "y": point.y / length }; +}; + +/** + * @description 二次ベジェ曲線上の座標を計算 + */ +const getQuadraticBezierPoint = ( + t: number, + s0: IPoint, + s1: IPoint, + s2: IPoint +): IPoint => ({ + "x": (1 - t) ** 2 * s0.x + 2 * (1 - t) * t * s1.x + t ** 2 * s2.x, + "y": (1 - t) ** 2 * s0.y + 2 * (1 - t) * t * s1.y + t ** 2 * s2.y +}); + +/** + * @description 二次ベジェ曲線上の接線ベクトルを計算 + */ +const getQuadraticBezierTangent = ( + t: number, + s0: IPoint, + s1: IPoint, + s2: IPoint +): IPoint => ({ + "x": 2 * (1 - t) * (s1.x - s0.x) + 2 * t * (s2.x - s1.x), + "y": 2 * (1 - t) * (s1.y - s0.y) + 2 * t * (s2.y - s1.y) +}); + +/** + * @description 二次ベジェ曲線を分割 + */ +const splitQuadraticBezier = ( + s0: IPoint, + s1: IPoint, + s2: IPoint, + t: number = 0.5 +): Array => { + const M0 = lerp(s0, s1, t); + const M1 = lerp(s1, s2, t); + const M01 = lerp(M0, M1, t); + return [ + [s0, M0, M01], + [M01, M1, s2] + ]; +}; + +/** + * @description ベジェ曲線を複数回分割 + */ +const splitBezierMultipleTimes = ( + s0: IPoint, + s1: IPoint, + s2: IPoint, + n: number = 4 +): Array => { + let segments: Array = [[s0, s1, s2]]; + for (let i = 0; i < n; i++) { + const newSegments: Array = []; + for (const seg of segments) { + const splitted = splitQuadraticBezier(seg[0], seg[1], seg[2], 0.5); + newSegments.push(splitted[0], splitted[1]); + } + segments = newSegments; + } + return segments; +}; + +/** + * @description 2次ベジェのオフセットを計算 + */ +const approximateOffsetQuadratic = ( + s0: IPoint, + s1: IPoint, + s2: IPoint, + offset: number +): IPoint[] => { + const tValues = [0, 0.5, 1]; + const newPoints: IPoint[] = []; + + for (const t of tValues) { + const pos = getQuadraticBezierPoint(t, s0, s1, s2); + const tan = getQuadraticBezierTangent(t, s0, s1, s2); + const n = normalize({ "x": -tan.y, "y": tan.x }); + newPoints.push({ + "x": pos.x + n.x * offset, + "y": pos.y + n.y * offset + }); + } + return newPoints; +}; + +/** + * @description カーブの矩形を計算(WebGL版のMeshCalculateCurveRectangleUseCaseと同じ) + */ +const calculateCurveRectangle = ( + startPoint: IPoint, + controlPoint: IPoint, + endPoint: IPoint, + thickness: number +): IPath => { + // WebGL版と同じ分割数(5回分割 = 32セグメント) + const segments = splitBezierMultipleTimes(startPoint, controlPoint, endPoint, 5); + + const leftCurves: Array = []; + const rightCurves: Array = []; + + for (const seg of segments) { + leftCurves.push(approximateOffsetQuadratic(seg[0], seg[1], seg[2], +thickness)); + rightCurves.push(approximateOffsetQuadratic(seg[0], seg[1], seg[2], -thickness)); + } + + // セグメント間の連続性を確保:各セグメントの終点を次のセグメントの始点に強制一致 + // これにより内側の曲線のつなぎめの隙間を解消 + for (let idx = 0; idx < leftCurves.length - 1; ++idx) { + leftCurves[idx + 1][0] = leftCurves[idx][2]; + } + for (let idx = 0; idx < rightCurves.length - 1; ++idx) { + rightCurves[idx + 1][0] = rightCurves[idx][2]; + } + + // 左サイドの最初のサブカーブ始点 + const leftStart = leftCurves[0][0]; + const paths: IPath = [leftStart.x, leftStart.y, false]; + + // 左サイド: WebGL版と同じく曲線フラグをtrueに設定(Loop-Blinn法で処理) + for (let idx = 0; idx < leftCurves.length; ++idx) { + const curves = leftCurves[idx]; + paths.push( + curves[1].x, curves[1].y, true, + curves[2].x, curves[2].y, false + ); + } + + const reversedRight = [...rightCurves].reverse(); + for (let idx = 0; idx < reversedRight.length; ++idx) { + const [q0, q1, q2] = reversedRight[idx]; + reversedRight[idx] = [q2, q1, q0]; // [Q2, Q1, Q0] + } + + // 右サイドの最初のサブカーブ始点 + const rightEnd = reversedRight[0][0]; + paths.push(rightEnd.x, rightEnd.y, false); + + // 右サイド: WebGL版と同じく曲線フラグをtrueに設定(Loop-Blinn法で処理) + for (let idx = 0; idx < reversedRight.length; ++idx) { + const curves = reversedRight[idx]; + paths.push( + curves[1].x, curves[1].y, true, + curves[2].x, curves[2].y, false + ); + } + + return paths; +}; + +/** + * @description 直線の矩形を計算(WebGL版のMeshCalculateLineRectangleUseCaseと同じ) + * @param {IPoint} startPoint - 開始点 + * @param {IPoint} endPoint - 終了点 + * @param {number} thickness - 線の太さ(半分の値) + * @return {IPath} 矩形パス + */ +const calculateLineRectangle = ( + startPoint: IPoint, + endPoint: IPoint, + thickness: number +): IPath => +{ + const vector: IPoint = { + "x": endPoint.x - startPoint.x, + "y": endPoint.y - startPoint.y + }; + + const normal = calculateNormalVector(vector.x, vector.y, thickness); + + const shiftedUpStart: IPoint = { + "x": startPoint.x + normal.x, + "y": startPoint.y + normal.y + }; + + const shiftedUpEnd: IPoint = { + "x": endPoint.x + normal.x, + "y": endPoint.y + normal.y + }; + + const shiftedDownEnd: IPoint = { + "x": endPoint.x - normal.x, + "y": endPoint.y - normal.y + }; + + const shiftedDownStart: IPoint = { + "x": startPoint.x - normal.x, + "y": startPoint.y - normal.y + }; + + return [ + shiftedUpStart.x, shiftedUpStart.y, false, + shiftedUpEnd.x, shiftedUpEnd.y, false, + shiftedDownEnd.x, shiftedDownEnd.y, false, + shiftedDownStart.x, shiftedDownStart.y, false, + shiftedUpStart.x, shiftedUpStart.y, false + ]; +}; + +/** + * @description メッシュのパスの中で指定座標が含まれる線を探す + * WebGL版のMeshFindOverlappingPathsServiceと同じ + */ +const findOverlappingPaths = ( + x: number, + y: number, + r: number, + paths: IPath +): number[] => { + const points: number[] = []; + // 浮動小数点誤差を考慮した許容範囲(非常に小さい値) + const epsilon = 0.0001; + for (let idx = 0; idx < paths.length; idx += 3) { + // カーブのコントロール座標なら終了 + if (paths[idx + 2] as boolean) { + continue; + } + + const dx = paths[idx] as number; + const dy = paths[idx + 1] as number; + + const distance = Math.sqrt( + Math.pow(dx - x, 2) + Math.pow(dy - y, 2) + ); + + // 浮動小数点誤差を考慮した比較 + if (Math.abs(distance - r) > epsilon) { + continue; + } + + points.push(dx, dy); + } + return points; +}; + +/** + * @description 矩形内に含まれてない座標を返却 + * WebGL版のMeshIsPointInsideRectangleServiceと同じ + */ +const findPointOutsideRectangle = ( + points: number[], + rectangle: IPath +): number[] | null => { + $canvasContext.beginPath(); + $canvasContext.moveTo( + rectangle[0] as number, + rectangle[1] as number + ); + + for (let idx = 3; idx < rectangle.length; idx += 3) { + if (rectangle[idx + 2] as boolean) { + $canvasContext.quadraticCurveTo( + rectangle[idx] as number, + rectangle[idx + 1] as number, + rectangle[idx + 3] as number, + rectangle[idx + 4] as number + ); + idx += 3; + } else { + $canvasContext.lineTo( + rectangle[idx] as number, + rectangle[idx + 1] as number + ); + } + } + + $canvasContext.closePath(); + + for (let idx = 0; idx < points.length; idx += 2) { + const px = points[idx]; + const py = points[idx + 1]; + + if ($canvasContext.isPointInPath(px, py)) { + continue; + } + + return [px, py]; + } + + return null; +}; + +/** + * @description ベベル結合を生成(WebGL版のMeshGenerateCalculateBevelJoinUseCaseと同じ) + */ +const generateBevelJoin = ( + x: number, + y: number, + r: number, + rectangles: IPath[], + isLast: boolean = false +): void => { + // WebGL版と同じ: isLastフラグでインデックスを切り替え + const indexA = isLast ? 0 : rectangles.length - 1; + const indexB = isLast ? rectangles.length - 1 : rectangles.length - 2; + const pathsA = findOverlappingPaths(x, y, r, rectangles[indexA]); + const pathsB = findOverlappingPaths(x, y, r, rectangles[indexB]); + + // パスが並行であれば終了 + if (pathsA[0] === pathsB[0] && pathsA[1] === pathsB[1] + || pathsA[0] === pathsB[2] && pathsA[1] === pathsB[3] + ) { + return; + } + + const pointA = findPointOutsideRectangle(pathsA, rectangles[indexB]); + if (!pointA) { + return; + } + + const pointB = findPointOutsideRectangle(pathsB, rectangles[indexA]); + if (!pointB) { + return; + } + + rectangles.splice(-1, 0, [ + x, y, false, + pointA[0], pointA[1], false, + pointB[0], pointB[1], false, + x, y, false + ]); +}; + +/** + * @description ラウンド結合を生成(WebGL版のMeshGenerateCalculateRoundJoinUseCaseと同じ) + */ +const generateRoundJoin = ( + x: number, + y: number, + r: number, + rectangles: IPath[], + isLast: boolean = false +): void => { + // WebGL版と同じ: isLastフラグでインデックスを切り替え + const indexA = isLast ? 0 : rectangles.length - 1; + const indexB = isLast ? rectangles.length - 1 : rectangles.length - 2; + const pathsA = findOverlappingPaths(x, y, r, rectangles[indexA]); + const pathsB = findOverlappingPaths(x, y, r, rectangles[indexB]); + + const pointA = findPointOutsideRectangle(pathsA, rectangles[indexB]); + if (!pointA) { + return; + } + + const pointB = findPointOutsideRectangle(pathsB, rectangles[indexA]); + if (!pointB) { + return; + } + + const angleA = Math.atan2(pointA[1] - y, pointA[0] - x); + const angleB = Math.atan2(pointB[1] - y, pointB[0] - x); + + // 角度差を正規化して180度以下にする + let angleDiff = angleB - angleA; + if (angleDiff > Math.PI) { + angleDiff -= 2 * Math.PI; + } else if (angleDiff < -Math.PI) { + angleDiff += 2 * Math.PI; + } + + const segment = 8; + const step = angleDiff / segment; + + const points: IPath = [x, y, false]; + for (let idx = 0; idx <= segment; idx++) { + const angle = angleA + idx * step; + const dx = x + r * Math.cos(angle); + const dy = y + r * Math.sin(angle); + points.push(dx, dy, false); + } + + rectangles.splice(-1, 0, points); +}; + +/** + * @description マイター結合を生成(WebGL版のMeshGenerateCalculateMiterJoinUseCaseと同じ) + */ +const generateMiterJoin = ( + startPoint: IPoint, + endPoint: IPoint, + prevPoint: IPoint, + r: number, + rectangles: IPath[], + isLast: boolean = false +): void => { + const indexA = isLast ? 0 : rectangles.length - 1; + const indexB = isLast ? rectangles.length - 1 : rectangles.length - 2; + const pathsA = findOverlappingPaths(startPoint.x, startPoint.y, r, rectangles[indexA]); + const pathsB = findOverlappingPaths(startPoint.x, startPoint.y, r, rectangles[indexB]); + + // パスが並行であれば終了 + if (pathsA[0] === pathsB[0] && pathsA[1] === pathsB[1] + || pathsA[0] === pathsB[2] && pathsA[1] === pathsB[3] + ) { + return; + } + + const pointA = findPointOutsideRectangle(pathsA, rectangles[indexB]); + if (!pointA) { + return; + } + + const pointB = findPointOutsideRectangle(pathsB, rectangles[indexA]); + if (!pointB) { + return; + } + + const aVx = endPoint.x - startPoint.x; + const aVy = endPoint.y - startPoint.y; + const lengthA = Math.hypot(aVx, aVy); + const normalizeA = { + "x": aVx / lengthA, + "y": aVy / lengthA + }; + + const bVx = prevPoint.x - startPoint.x; + const bVy = prevPoint.y - startPoint.y; + const lengthB = Math.hypot(bVx, bVy); + const normalizeB = { + "x": bVx / lengthB, + "y": bVy / lengthB + }; + + const d1x = normalizeA.x, d1y = normalizeA.y; + const d2x = normalizeB.x, d2y = normalizeB.y; + + const denom = d1x * d2y - d1y * d2x; + if (denom === 0) { + rectangles.splice(-1, 0, [ + startPoint.x, startPoint.y, false, + pointA[0], pointA[1], false, + pointB[0], pointB[1], false + ]); + return; + } + + const t = ((pointB[0] - pointA[0]) * d2y - (pointB[1] - pointA[1]) * d2x) / denom; + + const ix = pointA[0] + t * d1x; + const iy = pointA[1] + t * d1y; + + rectangles.splice(-1, 0, [ + startPoint.x, startPoint.y, false, + pointA[0], pointA[1], false, + ix, iy, false, + startPoint.x, startPoint.y, false, + pointB[0], pointB[1], false, + ix, iy, false + ]); +}; + +/** + * @description ラウンドキャップを生成(WebGL版のMeshGenerateCalculateRoundCapServiceと同じ) + */ +const generateRoundCap = ( + vertices: IPath, + thickness: number, + rectangles: IPath[] +): void => { + // 始点のキャップ + // WebGL版と同じく隣接頂点を直接使用(制御点でもそのまま使用する) + // カーブの場合、制御点への方向が正しい接線方向となる + const startX = vertices[0] as number; + const startY = vertices[1] as number; + const startNextX = vertices[3] as number; + const startNextY = vertices[4] as number; + + const startAngle = Math.atan2(startY - startNextY, startX - startNextX); + const startCapPath: IPath = [startX, startY, false]; + const segment = 8; + for (let i = 0; i <= segment; i++) { + const angle = startAngle - Math.PI / 2 + i * Math.PI / segment; + startCapPath.push( + startX + thickness * Math.cos(angle), + startY + thickness * Math.sin(angle), + false + ); + } + rectangles.unshift(startCapPath); + + // 終点のキャップ + // WebGL版と同じく隣接頂点を直接使用(制御点でもそのまま使用する) + const endX = vertices[vertices.length - 3] as number; + const endY = vertices[vertices.length - 2] as number; + const endPrevX = vertices[vertices.length - 6] as number; + const endPrevY = vertices[vertices.length - 5] as number; + + const endAngle = Math.atan2(endY - endPrevY, endX - endPrevX); + const endCapPath: IPath = [endX, endY, false]; + for (let i = 0; i <= segment; i++) { + const angle = endAngle - Math.PI / 2 + i * Math.PI / segment; + endCapPath.push( + endX + thickness * Math.cos(angle), + endY + thickness * Math.sin(angle), + false + ); + } + rectangles.push(endCapPath); +}; + +/** + * @description スクエアキャップを生成(WebGL版のMeshGenerateCalculateSquareCapServiceと同じ) + */ +const generateSquareCap = ( + vertices: IPath, + thickness: number, + rectangles: IPath[] +): void => { + // 始点のキャップ + // WebGL版と同じく隣接頂点を直接使用(制御点でもそのまま使用する) + const startX = vertices[0] as number; + const startY = vertices[1] as number; + const startNextX = vertices[3] as number; + const startNextY = vertices[4] as number; + + const startDx = startX - startNextX; + const startDy = startY - startNextY; + const startLen = Math.hypot(startDx, startDy); + if (startLen > 0) { + const startNx = startDx / startLen; + const startNy = startDy / startLen; + const startExtX = startX + startNx * thickness; + const startExtY = startY + startNy * thickness; + + const startCapPath: IPath = [ + startX - startNy * thickness, startY + startNx * thickness, false, + startExtX - startNy * thickness, startExtY + startNx * thickness, false, + startExtX + startNy * thickness, startExtY - startNx * thickness, false, + startX + startNy * thickness, startY - startNx * thickness, false, + startX - startNy * thickness, startY + startNx * thickness, false + ]; + rectangles.unshift(startCapPath); + } + + // 終点のキャップ + // WebGL版と同じく隣接頂点を直接使用(制御点でもそのまま使用する) + const endX = vertices[vertices.length - 3] as number; + const endY = vertices[vertices.length - 2] as number; + const endPrevX = vertices[vertices.length - 6] as number; + const endPrevY = vertices[vertices.length - 5] as number; + + const endDx = endX - endPrevX; + const endDy = endY - endPrevY; + const endLen = Math.hypot(endDx, endDy); + if (endLen > 0) { + const endNx = endDx / endLen; + const endNy = endDy / endLen; + const endExtX = endX + endNx * thickness; + const endExtY = endY + endNy * thickness; + + const endCapPath: IPath = [ + endX - endNy * thickness, endY + endNx * thickness, false, + endExtX - endNy * thickness, endExtY + endNx * thickness, false, + endExtX + endNy * thickness, endExtY - endNx * thickness, false, + endX + endNy * thickness, endY - endNx * thickness, false, + endX - endNy * thickness, endY + endNx * thickness, false + ]; + rectangles.push(endCapPath); + } +}; + +/** + * @description 線の外周を算出して塗りのフォーマットで返却(WebGL版と同じ) + * Calculate the outer circumference of the line and return it in the format of the fill + * + * @param {IPath} vertices - パス頂点 [x, y, isCurve, ...] + * @param {number} thickness - 線の太さ(半分の値) + * @return {IPath[]} パス配列 + */ +export const generateStrokeOutline = (vertices: IPath, thickness: number): IPath[] => +{ + // 再利用可能なオブジェクトを使用 + const startPoint = $startPoint; + startPoint.x = vertices[0] as number; + startPoint.y = vertices[1] as number; + + const controlPoint = $controlPoint; + controlPoint.x = 0; + controlPoint.y = 0; + + const endPoint = $endPoint; + endPoint.x = 0; + endPoint.y = 0; + + const prevPoint = $prevPoint; + prevPoint.x = 0; + prevPoint.y = 0; + + const rectangles: IPath[] = []; + for (let idx = 3; idx < vertices.length; idx += 3) { + + const x = vertices[idx] as number; + const y = vertices[idx + 1] as number; + + if (vertices[idx + 2] as boolean) { + controlPoint.x = x; + controlPoint.y = y; + continue; + } + + endPoint.x = x; + endPoint.y = y; + if (vertices[idx - 1] as boolean) { + rectangles.push( + calculateCurveRectangle(startPoint, controlPoint, endPoint, thickness) + ); + } else { + rectangles.push( + calculateLineRectangle(startPoint, endPoint, thickness) + ); + } + + if (rectangles.length > 1) { + switch ($context.joints) { + + case 0: // bevel + generateBevelJoin( + startPoint.x, startPoint.y, thickness, rectangles + ); + break; + + case 1: // miter + prevPoint.x = vertices[idx - 6] as number; + prevPoint.y = vertices[idx - 5] as number; + generateMiterJoin( + startPoint, endPoint, prevPoint, + thickness, rectangles + ); + break; + + case 2: // round + generateRoundJoin( + startPoint.x, startPoint.y, thickness, rectangles + ); + break; + + } + } + + startPoint.x = endPoint.x; + startPoint.y = endPoint.y; + } + + // 始点と終点が繋がっているかどうかをチェック(浮動小数点誤差を考慮) + const startX = vertices[0] as number; + const startY = vertices[1] as number; + const endX = vertices[vertices.length - 3] as number; + const endY = vertices[vertices.length - 2] as number; + const closedEpsilon = 0.0001; // 非常に小さい許容誤差 + const isClosed = Math.abs(startX - endX) < closedEpsilon + && Math.abs(startY - endY) < closedEpsilon + && rectangles.length > 1; + + if (isClosed) { + + // 始点と終点が繋がっている時はjointsの設定を適用(WebGL版と同じ) + switch ($context.joints) { + + case 0: // bevel + generateBevelJoin( + startX, startY, thickness, rectangles, true + ); + break; + + case 1: // miter + startPoint.x = startX; + startPoint.y = startY; + endPoint.x = vertices[3] as number; + endPoint.y = vertices[4] as number; + prevPoint.x = vertices[vertices.length - 6] as number; + prevPoint.y = vertices[vertices.length - 5] as number; + generateMiterJoin( + startPoint, endPoint, prevPoint, + thickness, rectangles, true + ); + break; + + case 2: // round + generateRoundJoin( + startX, startY, thickness, rectangles, true + ); + break; + + default: + break; + + } + } else if (rectangles.length > 0) { + + // 始点と終点が繋がってない時はcapsの設定を適用 + switch ($context.caps) { + + case 1: // round + generateRoundCap( + vertices, thickness, rectangles + ); + break; + + case 2: // square + generateSquareCap( + vertices, thickness, rectangles + ); + break; + + default: + break; + + } + } + + return rectangles; +}; + +/** + * @description ストロークメッシュを生成(WebGL版のMeshStrokeGenerateUseCaseと同じ) + * @param {IPath[]} vertices - パス頂点配列 + * @param {number} thickness - 線の太さ(フル値、内部で/2される) + * @return {IPath[]} + */ +export const generateStrokeMesh = (vertices: IPath[], thickness: number): IPath[] => +{ + // WebGL版と同じ: 内部で半分にする + const halfThickness = thickness / 2; + + const fillVertices: IPath[] = []; + for (const path of vertices) { + if (path.length < 6) { continue } + + const outlines = generateStrokeOutline(path, halfThickness); + for (const outline of outlines) { + fillVertices.push(outline); + } + } + + return fillVertices; +}; + +/** + * @description IPoint[][]形式からストロークメッシュを生成(後方互換用) + * @param {IPoint[][]} paths - パス配列 + * @param {number} thickness - 線の太さ + * @return {Float32Array} + */ +export const generateStrokeMeshFromPoints = (paths: IPoint[][], thickness: number): Float32Array => +{ + const triangles: number[] = []; + + // WebGL版と同じ: 内部で半分にする + const halfThickness = thickness / 2; + + for (const path of paths) { + if (path.length < 2) { continue } + + // 各線分に対して矩形を生成 + for (let i = 0; i < path.length - 1; i++) { + const startPoint = path[i]; + const endPoint = path[i + 1]; + + const vector: IPoint = { + "x": endPoint.x - startPoint.x, + "y": endPoint.y - startPoint.y + }; + + const normal = calculateNormalVector(vector.x, vector.y, halfThickness); + + // 矩形の4頂点 + const p0x = startPoint.x + normal.x; + const p0y = startPoint.y + normal.y; + const p1x = endPoint.x + normal.x; + const p1y = endPoint.y + normal.y; + const p2x = endPoint.x - normal.x; + const p2y = endPoint.y - normal.y; + const p3x = startPoint.x - normal.x; + const p3y = startPoint.y - normal.y; + + // Triangle 1: p0, p1, p2 + triangles.push( + p0x, p0y, 0, 0, + p1x, p1y, 0, 0, + p2x, p2y, 0, 0 + ); + + // Triangle 2: p0, p2, p3 + triangles.push( + p0x, p0y, 0, 0, + p2x, p2y, 0, 0, + p3x, p3y, 0, 0 + ); + } + } + + return new Float32Array(triangles); +}; diff --git a/packages/webgpu/src/PathCommand.test.ts b/packages/webgpu/src/PathCommand.test.ts new file mode 100644 index 00000000..fdba0c1b --- /dev/null +++ b/packages/webgpu/src/PathCommand.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { PathCommand } from "./PathCommand"; + +describe("PathCommand", () => +{ + let pathCommand: PathCommand; + + beforeEach(() => + { + pathCommand = new PathCommand(); + }); + + describe("beginPath", () => + { + it("should reset all state", () => + { + pathCommand.moveTo(10, 20); + pathCommand.lineTo(30, 40); + pathCommand.beginPath(); + + const paths = pathCommand.getAllPaths(); + expect(paths.length).toBe(0); + }); + }); + + describe("moveTo", () => + { + it("should start a new path", () => + { + pathCommand.moveTo(10, 20); + const path = pathCommand.getCurrentPath(); + + expect(path.length).toBe(1); + expect(path[0].x).toBe(10); + expect(path[0].y).toBe(20); + }); + + it("should save previous path if long enough", () => + { + pathCommand.moveTo(0, 0); + pathCommand.lineTo(10, 0); + pathCommand.lineTo(10, 10); + pathCommand.lineTo(0, 10); + pathCommand.moveTo(100, 100); + + const paths = pathCommand.getAllPaths(); + expect(paths.length).toBe(2); + }); + }); + + describe("lineTo", () => + { + it("should add a point", () => + { + pathCommand.moveTo(0, 0); + pathCommand.lineTo(10, 20); + + const path = pathCommand.getCurrentPath(); + expect(path.length).toBe(2); + expect(path[1].x).toBe(10); + expect(path[1].y).toBe(20); + }); + + it("should ignore same point", () => + { + pathCommand.moveTo(10, 20); + pathCommand.lineTo(10, 20); + + const path = pathCommand.getCurrentPath(); + expect(path.length).toBe(1); + }); + + it("should add multiple lines", () => + { + pathCommand.moveTo(0, 0); + pathCommand.lineTo(10, 0); + pathCommand.lineTo(10, 10); + pathCommand.lineTo(0, 10); + + const path = pathCommand.getCurrentPath(); + expect(path.length).toBe(4); + }); + }); + + describe("quadraticCurveTo", () => + { + it("should add control point and end point", () => + { + pathCommand.moveTo(0, 0); + pathCommand.quadraticCurveTo(50, 100, 100, 0); + + const vertices = pathCommand.getVerticesForStroke(); + expect(vertices.length).toBe(1); + // Path format: [x, y, isCurve, ...] + // Should have: start(0,0,false), control(50,100,true), end(100,0,false) + expect(vertices[0].length).toBe(9); + }); + }); + + describe("bezierCurveTo", () => + { + it("should convert cubic bezier to quadratic segments", () => + { + pathCommand.moveTo(0, 0); + pathCommand.bezierCurveTo(10, 50, 90, 50, 100, 0); + + const path = pathCommand.getCurrentPath(); + // Should have at least start point and some segments + expect(path.length).toBeGreaterThan(1); + }); + }); + + describe("arc", () => + { + it("should create circular arc with adaptive tessellation", () => + { + pathCommand.moveTo(150, 100); + pathCommand.arc(100, 100, 50); + + const path = pathCommand.getCurrentPath(); + // Adaptive tessellation produces variable segment count + // 4 cubic bezier curves, each converted to quadratic segments + expect(path.length).toBeGreaterThan(1); + }); + + it("should be centered at specified position", () => + { + pathCommand.moveTo(150, 100); + pathCommand.arc(100, 100, 50); + + const path = pathCommand.getCurrentPath(); + // First point should be at (100+50, 100) = (150, 100) from moveTo + expect(path[0].x).toBeCloseTo(150, 1); + expect(path[0].y).toBeCloseTo(100, 1); + }); + }); + + describe("closePath", () => + { + it("should add line back to start point", () => + { + pathCommand.moveTo(0, 0); + pathCommand.lineTo(100, 0); + pathCommand.lineTo(100, 100); + pathCommand.closePath(); + + const path = pathCommand.getCurrentPath(); + const lastPoint = path[path.length - 1]; + expect(lastPoint.x).toBe(0); + expect(lastPoint.y).toBe(0); + }); + + it("should not add duplicate point if already at start", () => + { + pathCommand.moveTo(0, 0); + pathCommand.lineTo(100, 0); + pathCommand.lineTo(0, 0); + const lengthBefore = pathCommand.getCurrentPath().length; + pathCommand.closePath(); + const lengthAfter = pathCommand.getCurrentPath().length; + + expect(lengthAfter).toBe(lengthBefore); + }); + }); + + describe("generateVertices", () => + { + it("should generate triangle vertices for simple triangle", () => + { + pathCommand.moveTo(0, 0); + pathCommand.lineTo(100, 0); + pathCommand.lineTo(50, 100); + pathCommand.closePath(); + + const vertices = pathCommand.generateVertices(); + // 1 triangle = 6 values (3 points * 2 coords) + expect(vertices.length).toBeGreaterThanOrEqual(6); + }); + + it("should generate triangles using fan triangulation", () => + { + pathCommand.moveTo(0, 0); + pathCommand.lineTo(100, 0); + pathCommand.lineTo(100, 100); + pathCommand.lineTo(0, 100); + pathCommand.closePath(); + + const vertices = pathCommand.generateVertices(); + // Square with 5 points (including close) should generate multiple triangles + expect(vertices.length).toBeGreaterThan(6); + }); + }); + + describe("getAllPaths", () => + { + it("should return all paths", () => + { + pathCommand.moveTo(0, 0); + pathCommand.lineTo(10, 0); + pathCommand.lineTo(10, 10); + pathCommand.lineTo(0, 10); + + pathCommand.moveTo(100, 100); + pathCommand.lineTo(110, 100); + pathCommand.lineTo(110, 110); + pathCommand.lineTo(100, 110); + + const paths = pathCommand.getAllPaths(); + expect(paths.length).toBe(2); + expect(paths[0].length).toBe(4); + expect(paths[1].length).toBe(4); + }); + }); + + describe("setScale", () => + { + it("should adjust flatness threshold based on scale", () => + { + pathCommand.setScale(2.0); + // We can't directly access the private threshold, + // but we can verify the bezierCurveTo still works + pathCommand.moveTo(0, 0); + pathCommand.bezierCurveTo(10, 50, 90, 50, 100, 0); + + const path = pathCommand.getCurrentPath(); + expect(path.length).toBeGreaterThan(1); + }); + }); + + describe("reset", () => + { + it("should clear all state", () => + { + pathCommand.moveTo(10, 20); + pathCommand.lineTo(30, 40); + pathCommand.reset(); + + const paths = pathCommand.getAllPaths(); + expect(paths.length).toBe(0); + + const currentPath = pathCommand.getCurrentPath(); + expect(currentPath.length).toBe(0); + }); + }); +}); diff --git a/packages/webgpu/src/PathCommand.ts b/packages/webgpu/src/PathCommand.ts new file mode 100644 index 00000000..52da4079 --- /dev/null +++ b/packages/webgpu/src/PathCommand.ts @@ -0,0 +1,364 @@ +import type { IPoint } from "./interface/IPoint"; +import type { IPath } from "./interface/IPath"; +import { + adaptiveCubicToQuad, + calculateAdaptiveThreshold +} from "./BezierConverter/BezierConverter"; + +/** + * @description WebGPU用パスコマンド(WebGL互換形式) + * Path commands for WebGPU (WebGL compatible format) + * + * パス形式: [x, y, isControlPoint, x, y, isControlPoint, ...] + * - isControlPoint = true: 二次ベジェ曲線の制御点 + * - isControlPoint = false: 通常の頂点または終点 + */ +export class PathCommand +{ + private $currentPath: IPath; + private $vertices: IPath[]; + private $currentX: number; + private $currentY: number; + private $startX: number; + private $startY: number; + + /** + * @constructor + */ + constructor() + { + this.$currentPath = []; + this.$vertices = []; + this.$currentX = 0; + this.$currentY = 0; + this.$startX = 0; + this.$startY = 0; + } + + /** + * @description パスを開始 + * @return {void} + */ + beginPath(): void + { + this.$currentPath = []; + this.$vertices = []; + this.$currentX = 0; + this.$currentY = 0; + this.$startX = 0; + this.$startY = 0; + } + + /** + * @description パスを移動 + * @param {number} x + * @param {number} y + * @return {void} + */ + moveTo(x: number, y: number): void + { + // stroke用は2点以上(6要素)、fill用は3点以上(9要素)が必要 + // WebGL版と同じ: stroke描画に対応するため6要素以上で保存 + if (this.$currentPath.length >= 6) { + this.$vertices.push(this.$currentPath); + } + + this.$currentPath = [x, y, false]; + this.$currentX = x; + this.$currentY = y; + this.$startX = x; + this.$startY = y; + } + + /** + * @description 直線を描画 + * @param {number} x + * @param {number} y + * @return {void} + */ + lineTo(x: number, y: number): void + { + // 同じ点への移動は無視 + if (x === this.$currentX && y === this.$currentY) { + return; + } + + this.$currentPath.push(x, y, false); + this.$currentX = x; + this.$currentY = y; + } + + /** + * @description 二次ベジェ曲線を描画(Loop-Blinn方式対応) + * @param {number} cx + * @param {number} cy + * @param {number} x + * @param {number} y + * @return {void} + */ + quadraticCurveTo(cx: number, cy: number, x: number, y: number): void + { + // 制御点 (isControlPoint = true) + this.$currentPath.push(cx, cy, true); + // 終点 (isControlPoint = false) + this.$currentPath.push(x, y, false); + + this.$currentX = x; + this.$currentY = y; + } + + /** + * @description フラットネス閾値(スケールに応じて調整可能) + * Flatness threshold for adaptive tessellation + * 0.25 = 0.5px squared(滑らかなストローク描画用) + */ + private $flatnessThreshold: number = 0.25; + + /** + * @description フラットネス閾値を設定 + * Set flatness threshold for adaptive bezier tessellation + * @param {number} scale - 現在のスケール(行列のスケール成分) + * @return {void} + */ + setScale(scale: number): void + { + this.$flatnessThreshold = calculateAdaptiveThreshold(scale); + } + + /** + * @description 三次ベジェ曲線を二次ベジェ曲線に適応的に近似 + * Adaptively approximate cubic bezier with quadratic beziers + * + * フラットネス(平坦度)に基づいて動的に分割数を決定。 + * 単純な曲線は少ない分割、複雑な曲線は多い分割を行う。 + * + * @param {number} cx1 + * @param {number} cy1 + * @param {number} cx2 + * @param {number} cy2 + * @param {number} x + * @param {number} y + * @return {void} + */ + bezierCurveTo( + cx1: number, cy1: number, + cx2: number, cy2: number, + x: number, y: number + ): void { + // 適応的テッセレーションで三次ベジェを二次ベジェ群に変換 + const p0: IPoint = { "x": this.$currentX, "y": this.$currentY }; + const p1: IPoint = { "x": cx1, "y": cy1 }; + const p2: IPoint = { "x": cx2, "y": cy2 }; + const p3: IPoint = { "x": x, "y": y }; + + const segments = adaptiveCubicToQuad( + p0, p1, p2, p3, + this.$flatnessThreshold + ); + + // 各二次ベジェセグメントをパスに追加 + for (const segment of segments) { + this.$currentPath.push(segment.ctrl.x, segment.ctrl.y, true); + this.$currentPath.push(segment.end.x, segment.end.y, false); + } + + this.$currentX = x; + this.$currentY = y; + } + + /** + * @description 円弧を描画(WebGL版と同じ実装: 4つの三次ベジェ曲線を使用) + * @param {number} x + * @param {number} y + * @param {number} radius + * @return {void} + */ + arc(x: number, y: number, radius: number): void + { + const r = radius; + const k = radius * 0.5522847498307936; // 円を三次ベジェで近似するための定数 + + // 4つの三次ベジェ曲線で円を描画 + // 始点: (x + r, y) + // 各象限を1つの三次ベジェ曲線で描画 + + // 第1象限: (x + r, y) -> (x, y + r) + this.bezierCurveTo( + x + r, y + k, + x + k, y + r, + x, y + r + ); + + // 第2象限: (x, y + r) -> (x - r, y) + this.bezierCurveTo( + x - k, y + r, + x - r, y + k, + x - r, y + ); + + // 第3象限: (x - r, y) -> (x, y - r) + this.bezierCurveTo( + x - r, y - k, + x - k, y - r, + x, y - r + ); + + // 第4象限: (x, y - r) -> (x + r, y) - 終点は始点と同じ + this.bezierCurveTo( + x + k, y - r, + x + r, y - k, + x + r, y + ); + + // 閉じた円の終点を始点座標で強制上書き(浮動小数点誤差を排除) + // ストロークのjoin処理で始点と終点が完全一致する必要があるため + const pathLength = this.$currentPath.length; + if (pathLength >= 3) { + this.$currentPath[pathLength - 3] = this.$startX; + this.$currentPath[pathLength - 2] = this.$startY; + } + + this.$currentX = this.$startX; + this.$currentY = this.$startY; + } + + /** + * @description パスを閉じる + * @return {void} + */ + closePath(): void + { + if (this.$currentPath.length >= 3) { + // 始点に戻る直線 + if (this.$currentX !== this.$startX || this.$currentY !== this.$startY) { + this.$currentPath.push(this.$startX, this.$startY, false); + } + } + } + + /** + * @description 頂点データを取得(fill用: 3点以上が必要) + * @return {IPath[]} + */ + get $getVertices(): IPath[] + { + const vertices = [...this.$vertices]; + // fill用は3点以上(9要素)が必要 + if (this.$currentPath.length >= 9) { + vertices.push(this.$currentPath); + } + return vertices; + } + + /** + * @description パスから頂点配列を生成(従来互換用・単純なfan triangulation) + * @return {Float32Array} + */ + generateVertices(): Float32Array + { + const vertices = this.$getVertices; + const triangles: number[] = []; + + for (const path of vertices) { + if (path.length < 9) { continue } // 最低3点(9要素)必要 + + // 点を抽出 + const points: IPoint[] = []; + for (let i = 0; i < path.length; i += 3) { + points.push({ "x": path[i] as number, "y": path[i + 1] as number }); + } + + // Fan triangulation + for (let i = 1; i < points.length - 1; i++) { + triangles.push( + points[0].x, points[0].y, + points[i].x, points[i].y, + points[i + 1].x, points[i + 1].y + ); + } + } + + return new Float32Array(triangles); + } + + /** + * @description 現在のパスを取得(ストローク用) + * @return {IPoint[]} + */ + getCurrentPath(): IPoint[] + { + const points: IPoint[] = []; + for (let i = 0; i < this.$currentPath.length; i += 3) { + points.push({ + "x": this.$currentPath[i] as number, + "y": this.$currentPath[i + 1] as number + }); + } + return points; + } + + /** + * @description すべてのパスを取得(ストローク用) + * @return {IPoint[][]} + */ + getAllPaths(): IPoint[][] + { + const allPaths: IPoint[][] = []; + + for (const path of this.$vertices) { + const points: IPoint[] = []; + for (let i = 0; i < path.length; i += 3) { + points.push({ + "x": path[i] as number, + "y": path[i + 1] as number + }); + } + if (points.length > 0) { + allPaths.push(points); + } + } + + if (this.$currentPath.length >= 3) { + const points: IPoint[] = []; + for (let i = 0; i < this.$currentPath.length; i += 3) { + points.push({ + "x": this.$currentPath[i] as number, + "y": this.$currentPath[i + 1] as number + }); + } + if (points.length > 0) { + allPaths.push(points); + } + } + + return allPaths; + } + + /** + * @description WebGL互換形式でパスを取得(ストローク用) + * [x, y, isCurve, x, y, isCurve, ...] 形式 + * @return {IPath[]} + */ + getVerticesForStroke(): IPath[] + { + const vertices = [...this.$vertices]; + if (this.$currentPath.length >= 6) { + vertices.push(this.$currentPath); + } + return vertices; + } + + /** + * @description リセット + * @return {void} + */ + reset(): void + { + this.$currentPath = []; + this.$vertices = []; + this.$currentX = 0; + this.$currentY = 0; + this.$startX = 0; + this.$startY = 0; + } +} diff --git a/packages/webgpu/src/SamplerCache.test.ts b/packages/webgpu/src/SamplerCache.test.ts new file mode 100644 index 00000000..ff829ec7 --- /dev/null +++ b/packages/webgpu/src/SamplerCache.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + SamplerCache, + initSamplerCache, + getSamplerCache, + clearSamplerCache +} from "./SamplerCache"; + +// Mock the service modules +vi.mock("./SamplerCache/service/SamplerCacheGetOrCreateService", () => ({ + "execute": vi.fn((device, cache, minFilter, magFilter, addressModeU, addressModeV) => { + const key = `${minFilter}_${magFilter}_${addressModeU}_${addressModeV}`; + if (!cache.has(key)) { + const sampler = { "label": key } as unknown as GPUSampler; + cache.set(key, sampler); + } + return cache.get(key); + }) +})); + +vi.mock("./SamplerCache/service/SamplerCacheCreateCommonSamplersService", () => ({ + "execute": vi.fn((device, cache) => { + // Pre-create common samplers + cache.set("linear_linear_clamp-to-edge_clamp-to-edge", { "label": "linearClamp" }); + cache.set("nearest_nearest_clamp-to-edge_clamp-to-edge", { "label": "nearestClamp" }); + cache.set("linear_linear_repeat_repeat", { "label": "linearRepeat" }); + cache.set("nearest_nearest_repeat_repeat", { "label": "nearestRepeat" }); + }) +})); + +describe("SamplerCache", () => +{ + const createMockDevice = (): GPUDevice => + { + return { + "createSampler": vi.fn(() => ({ "label": "mockSampler" })) + } as unknown as GPUDevice; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + clearSamplerCache(); + }); + + describe("SamplerCache class", () => + { + it("should create instance with device", () => + { + const device = createMockDevice(); + const cache = new SamplerCache(device); + + expect(cache).toBeDefined(); + }); + + it("should pre-create common samplers on initialization", () => + { + const device = createMockDevice(); + const cache = new SamplerCache(device); + + const stats = cache.getStats(); + expect(stats.size).toBe(4); + }); + + describe("getOrCreate", () => + { + it("should get existing sampler from cache", () => + { + const device = createMockDevice(); + const cache = new SamplerCache(device); + + const sampler1 = cache.getOrCreate("linear", "linear", "clamp-to-edge", "clamp-to-edge"); + const sampler2 = cache.getOrCreate("linear", "linear", "clamp-to-edge", "clamp-to-edge"); + + expect(sampler1).toBe(sampler2); + }); + + it("should create new sampler for new combination", () => + { + const device = createMockDevice(); + const cache = new SamplerCache(device); + + const initialSize = cache.getStats().size; + + cache.getOrCreate("linear", "nearest", "mirror-repeat", "clamp-to-edge"); + + expect(cache.getStats().size).toBe(initialSize + 1); + }); + }); + + describe("getLinearClamp", () => + { + it("should return linear clamp sampler", () => + { + const device = createMockDevice(); + const cache = new SamplerCache(device); + + const sampler = cache.getLinearClamp(); + + expect(sampler).toBeDefined(); + }); + + it("should return same instance on multiple calls", () => + { + const device = createMockDevice(); + const cache = new SamplerCache(device); + + const sampler1 = cache.getLinearClamp(); + const sampler2 = cache.getLinearClamp(); + + expect(sampler1).toBe(sampler2); + }); + }); + + describe("getNearestClamp", () => + { + it("should return nearest clamp sampler", () => + { + const device = createMockDevice(); + const cache = new SamplerCache(device); + + const sampler = cache.getNearestClamp(); + + expect(sampler).toBeDefined(); + }); + }); + + describe("getLinearRepeat", () => + { + it("should return linear repeat sampler", () => + { + const device = createMockDevice(); + const cache = new SamplerCache(device); + + const sampler = cache.getLinearRepeat(); + + expect(sampler).toBeDefined(); + }); + }); + + describe("getNearestRepeat", () => + { + it("should return nearest repeat sampler", () => + { + const device = createMockDevice(); + const cache = new SamplerCache(device); + + const sampler = cache.getNearestRepeat(); + + expect(sampler).toBeDefined(); + }); + }); + + describe("getBySmoothRepeat", () => + { + it("should return linear clamp for smooth=true, repeat=false", () => + { + const device = createMockDevice(); + const cache = new SamplerCache(device); + + const sampler = cache.getBySmoothRepeat(true, false); + const linearClamp = cache.getLinearClamp(); + + expect(sampler).toBe(linearClamp); + }); + + it("should return nearest clamp for smooth=false, repeat=false", () => + { + const device = createMockDevice(); + const cache = new SamplerCache(device); + + const sampler = cache.getBySmoothRepeat(false, false); + const nearestClamp = cache.getNearestClamp(); + + expect(sampler).toBe(nearestClamp); + }); + + it("should return linear repeat for smooth=true, repeat=true", () => + { + const device = createMockDevice(); + const cache = new SamplerCache(device); + + const sampler = cache.getBySmoothRepeat(true, true); + const linearRepeat = cache.getLinearRepeat(); + + expect(sampler).toBe(linearRepeat); + }); + + it("should return nearest repeat for smooth=false, repeat=true", () => + { + const device = createMockDevice(); + const cache = new SamplerCache(device); + + const sampler = cache.getBySmoothRepeat(false, true); + const nearestRepeat = cache.getNearestRepeat(); + + expect(sampler).toBe(nearestRepeat); + }); + }); + + describe("getStats", () => + { + it("should return cache size", () => + { + const device = createMockDevice(); + const cache = new SamplerCache(device); + + const stats = cache.getStats(); + + expect(stats).toHaveProperty("size"); + expect(typeof stats.size).toBe("number"); + }); + }); + + describe("dispose", () => + { + it("should clear cache", () => + { + const device = createMockDevice(); + const cache = new SamplerCache(device); + + cache.dispose(); + + expect(cache.getStats().size).toBe(0); + }); + }); + }); + + describe("global functions", () => + { + describe("initSamplerCache", () => + { + it("should initialize global cache", () => + { + const device = createMockDevice(); + + initSamplerCache(device); + + expect(getSamplerCache()).not.toBeNull(); + }); + }); + + describe("getSamplerCache", () => + { + it("should return cache after initialization", () => + { + const device = createMockDevice(); + initSamplerCache(device); + + expect(getSamplerCache()).toBeInstanceOf(SamplerCache); + }); + }); + + describe("clearSamplerCache", () => + { + it("should dispose cache", () => + { + const device = createMockDevice(); + initSamplerCache(device); + const cache = getSamplerCache(); + + clearSamplerCache(); + + expect(cache!.getStats().size).toBe(0); + }); + + it("should not throw when cache is null", () => + { + expect(() => clearSamplerCache()).not.toThrow(); + }); + }); + }); +}); diff --git a/packages/webgpu/src/SamplerCache.ts b/packages/webgpu/src/SamplerCache.ts new file mode 100644 index 00000000..c67cd168 --- /dev/null +++ b/packages/webgpu/src/SamplerCache.ts @@ -0,0 +1,90 @@ +import { execute as samplerCacheGetOrCreateService } from "./SamplerCache/service/SamplerCacheGetOrCreateService"; +import { execute as samplerCacheCreateCommonSamplersService } from "./SamplerCache/service/SamplerCacheCreateCommonSamplersService"; + +export class SamplerCache +{ + private device: GPUDevice; + private cache: Map; + + constructor(device: GPUDevice) + { + this.device = device; + this.cache = new Map(); + + samplerCacheCreateCommonSamplersService(device, this.cache); + } + + getOrCreate( + minFilter: GPUFilterMode, + magFilter: GPUFilterMode, + addressModeU: GPUAddressMode, + addressModeV: GPUAddressMode + ): GPUSampler { + return samplerCacheGetOrCreateService( + this.device, + this.cache, + minFilter, + magFilter, + addressModeU, + addressModeV + ); + } + + getLinearClamp(): GPUSampler + { + return this.getOrCreate("linear", "linear", "clamp-to-edge", "clamp-to-edge"); + } + + getNearestClamp(): GPUSampler + { + return this.getOrCreate("nearest", "nearest", "clamp-to-edge", "clamp-to-edge"); + } + + getLinearRepeat(): GPUSampler + { + return this.getOrCreate("linear", "linear", "repeat", "repeat"); + } + + getNearestRepeat(): GPUSampler + { + return this.getOrCreate("nearest", "nearest", "repeat", "repeat"); + } + + getBySmoothRepeat(smooth: boolean, repeat: boolean): GPUSampler + { + const filter: GPUFilterMode = smooth ? "linear" : "nearest"; + const addressMode: GPUAddressMode = repeat ? "repeat" : "clamp-to-edge"; + return this.getOrCreate(filter, filter, addressMode, addressMode); + } + + getStats(): { size: number } + { + return { + "size": this.cache.size + }; + } + + dispose(): void + { + this.cache.clear(); + } +} + +let $samplerCache: SamplerCache | null = null; + +export const initSamplerCache = (device: GPUDevice): void => +{ + $samplerCache = new SamplerCache(device); +}; + +export const getSamplerCache = (): SamplerCache | null => +{ + return $samplerCache; +}; + +export const clearSamplerCache = (): void => +{ + if ($samplerCache) { + $samplerCache.dispose(); + } +}; diff --git a/packages/webgpu/src/SamplerCache/service/SamplerCacheCreateCommonSamplersService.test.ts b/packages/webgpu/src/SamplerCache/service/SamplerCacheCreateCommonSamplersService.test.ts new file mode 100644 index 00000000..3f74a905 --- /dev/null +++ b/packages/webgpu/src/SamplerCache/service/SamplerCacheCreateCommonSamplersService.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi } from "vitest"; +import { execute } from "./SamplerCacheCreateCommonSamplersService"; + +describe("SamplerCacheCreateCommonSamplersService", () => +{ + const createMockDevice = () => + { + let samplerId = 0; + return { + "createSampler": vi.fn(() => ({ "id": ++samplerId })) + } as unknown as GPUDevice; + }; + + it("should create 5 common samplers", () => + { + const device = createMockDevice(); + const cache = new Map(); + + execute(device, cache); + + expect(cache.size).toBe(5); + }); + + it("should create linear clamp sampler", () => + { + const device = createMockDevice(); + const cache = new Map(); + + execute(device, cache); + + expect(cache.has("linear_linear_clamp-to-edge_clamp-to-edge")).toBe(true); + }); + + it("should create nearest clamp sampler", () => + { + const device = createMockDevice(); + const cache = new Map(); + + execute(device, cache); + + expect(cache.has("nearest_nearest_clamp-to-edge_clamp-to-edge")).toBe(true); + }); + + it("should create linear repeat sampler", () => + { + const device = createMockDevice(); + const cache = new Map(); + + execute(device, cache); + + expect(cache.has("linear_linear_repeat_repeat")).toBe(true); + }); + + it("should create nearest repeat sampler", () => + { + const device = createMockDevice(); + const cache = new Map(); + + execute(device, cache); + + expect(cache.has("nearest_nearest_repeat_repeat")).toBe(true); + }); + + it("should create linear mirror repeat sampler", () => + { + const device = createMockDevice(); + const cache = new Map(); + + execute(device, cache); + + expect(cache.has("linear_linear_mirror-repeat_mirror-repeat")).toBe(true); + }); + + it("should not overwrite existing samplers", () => + { + const device = createMockDevice(); + const cache = new Map(); + const existingSampler = { "id": "existing" } as unknown as GPUSampler; + cache.set("linear_linear_clamp-to-edge_clamp-to-edge", existingSampler); + + execute(device, cache); + + expect(cache.get("linear_linear_clamp-to-edge_clamp-to-edge")).toBe(existingSampler); + }); + + it("should call createSampler with correct parameters", () => + { + const device = createMockDevice(); + const cache = new Map(); + + execute(device, cache); + + // Verify first call (linear clamp) + expect(device.createSampler).toHaveBeenCalledWith({ + "minFilter": "linear", + "magFilter": "linear", + "addressModeU": "clamp-to-edge", + "addressModeV": "clamp-to-edge" + }); + + // Verify nearest clamp was called + expect(device.createSampler).toHaveBeenCalledWith({ + "minFilter": "nearest", + "magFilter": "nearest", + "addressModeU": "clamp-to-edge", + "addressModeV": "clamp-to-edge" + }); + }); + + it("should only call createSampler 5 times for empty cache", () => + { + const device = createMockDevice(); + const cache = new Map(); + + execute(device, cache); + + expect(device.createSampler).toHaveBeenCalledTimes(5); + }); + + it("should be idempotent when called multiple times", () => + { + const device = createMockDevice(); + const cache = new Map(); + + execute(device, cache); + const sizeAfterFirst = cache.size; + const callsAfterFirst = (device.createSampler as any).mock.calls.length; + + execute(device, cache); + + expect(cache.size).toBe(sizeAfterFirst); + expect(device.createSampler).toHaveBeenCalledTimes(callsAfterFirst); + }); +}); diff --git a/packages/webgpu/src/SamplerCache/service/SamplerCacheCreateCommonSamplersService.ts b/packages/webgpu/src/SamplerCache/service/SamplerCacheCreateCommonSamplersService.ts new file mode 100644 index 00000000..c7b27a3b --- /dev/null +++ b/packages/webgpu/src/SamplerCache/service/SamplerCacheCreateCommonSamplersService.ts @@ -0,0 +1,50 @@ +import { execute as samplerCacheGenerateKeyService } from "./SamplerCacheGenerateKeyService"; + +/** + * @description 頻繁に使用されるサンプラーを事前に作成 + * Pre-create commonly used samplers + * + * @param {GPUDevice} device + * @param {Map} cache + * @return {void} + * @method + * @protected + */ +export const execute = ( + device: GPUDevice, + cache: Map +): void => { + const createAndCache = ( + minFilter: GPUFilterMode, + magFilter: GPUFilterMode, + addressModeU: GPUAddressMode, + addressModeV: GPUAddressMode + ): void => { + const key = samplerCacheGenerateKeyService(minFilter, magFilter, addressModeU, addressModeV); + + if (!cache.has(key)) { + const sampler = device.createSampler({ + minFilter, + magFilter, + addressModeU, + addressModeV + }); + cache.set(key, sampler); + } + }; + + // リニアクランプ(最も一般的) + createAndCache("linear", "linear", "clamp-to-edge", "clamp-to-edge"); + + // ニアレストクランプ + createAndCache("nearest", "nearest", "clamp-to-edge", "clamp-to-edge"); + + // リニアリピート + createAndCache("linear", "linear", "repeat", "repeat"); + + // ニアレストリピート + createAndCache("nearest", "nearest", "repeat", "repeat"); + + // リニアミラーリピート + createAndCache("linear", "linear", "mirror-repeat", "mirror-repeat"); +}; diff --git a/packages/webgpu/src/SamplerCache/service/SamplerCacheGenerateKeyService.test.ts b/packages/webgpu/src/SamplerCache/service/SamplerCacheGenerateKeyService.test.ts new file mode 100644 index 00000000..f36950b6 --- /dev/null +++ b/packages/webgpu/src/SamplerCache/service/SamplerCacheGenerateKeyService.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { execute } from "./SamplerCacheGenerateKeyService"; + +describe("SamplerCacheGenerateKeyService", () => +{ + it("should generate key with all linear filters", () => + { + const result = execute("linear", "linear", "clamp-to-edge", "clamp-to-edge"); + expect(result).toBe("linear_linear_clamp-to-edge_clamp-to-edge"); + }); + + it("should generate key with all nearest filters", () => + { + const result = execute("nearest", "nearest", "repeat", "repeat"); + expect(result).toBe("nearest_nearest_repeat_repeat"); + }); + + it("should generate key with mixed filters", () => + { + const result = execute("linear", "nearest", "clamp-to-edge", "repeat"); + expect(result).toBe("linear_nearest_clamp-to-edge_repeat"); + }); + + it("should generate unique keys for different configurations", () => + { + const key1 = execute("linear", "linear", "clamp-to-edge", "clamp-to-edge"); + const key2 = execute("nearest", "nearest", "clamp-to-edge", "clamp-to-edge"); + const key3 = execute("linear", "linear", "repeat", "repeat"); + + expect(key1).not.toBe(key2); + expect(key1).not.toBe(key3); + expect(key2).not.toBe(key3); + }); + + it("should generate same key for same configuration", () => + { + const key1 = execute("linear", "nearest", "repeat", "mirror-repeat"); + const key2 = execute("linear", "nearest", "repeat", "mirror-repeat"); + + expect(key1).toBe(key2); + }); + + it("should handle mirror-repeat address mode", () => + { + const result = execute("linear", "linear", "mirror-repeat", "mirror-repeat"); + expect(result).toBe("linear_linear_mirror-repeat_mirror-repeat"); + }); + + it("should differentiate address modes U and V", () => + { + const key1 = execute("linear", "linear", "repeat", "clamp-to-edge"); + const key2 = execute("linear", "linear", "clamp-to-edge", "repeat"); + + expect(key1).not.toBe(key2); + }); +}); diff --git a/packages/webgpu/src/SamplerCache/service/SamplerCacheGenerateKeyService.ts b/packages/webgpu/src/SamplerCache/service/SamplerCacheGenerateKeyService.ts new file mode 100644 index 00000000..06c33e10 --- /dev/null +++ b/packages/webgpu/src/SamplerCache/service/SamplerCacheGenerateKeyService.ts @@ -0,0 +1,20 @@ +/** + * @description サンプラーのキーを生成 + * Generate sampler cache key + * + * @param {GPUFilterMode} minFilter + * @param {GPUFilterMode} magFilter + * @param {GPUAddressMode} addressModeU + * @param {GPUAddressMode} addressModeV + * @return {string} + * @method + * @protected + */ +export const execute = ( + minFilter: GPUFilterMode, + magFilter: GPUFilterMode, + addressModeU: GPUAddressMode, + addressModeV: GPUAddressMode +): string => { + return `${minFilter}_${magFilter}_${addressModeU}_${addressModeV}`; +}; diff --git a/packages/webgpu/src/SamplerCache/service/SamplerCacheGetOrCreateService.test.ts b/packages/webgpu/src/SamplerCache/service/SamplerCacheGetOrCreateService.test.ts new file mode 100644 index 00000000..1f77798e --- /dev/null +++ b/packages/webgpu/src/SamplerCache/service/SamplerCacheGetOrCreateService.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi } from "vitest"; +import { execute } from "./SamplerCacheGetOrCreateService"; + +describe("SamplerCacheGetOrCreateService", () => +{ + const createMockDevice = () => + { + return { + "createSampler": vi.fn((descriptor) => ({ ...descriptor, "label": "mock-sampler" })) + } as unknown as GPUDevice; + }; + + it("should return cached sampler if exists", () => + { + const device = createMockDevice(); + const cache = new Map(); + const existingSampler = { "label": "existing" } as unknown as GPUSampler; + + // Pre-populate cache with correct key format (underscore separated) + cache.set("linear_linear_clamp-to-edge_clamp-to-edge", existingSampler); + + const result = execute(device, cache, "linear", "linear", "clamp-to-edge", "clamp-to-edge"); + + expect(result).toBe(existingSampler); + expect(device.createSampler).not.toHaveBeenCalled(); + }); + + it("should create new sampler if not cached", () => + { + const device = createMockDevice(); + const cache = new Map(); + + execute(device, cache, "linear", "linear", "clamp-to-edge", "clamp-to-edge"); + + expect(device.createSampler).toHaveBeenCalledWith({ + "minFilter": "linear", + "magFilter": "linear", + "addressModeU": "clamp-to-edge", + "addressModeV": "clamp-to-edge" + }); + }); + + it("should cache newly created sampler", () => + { + const device = createMockDevice(); + const cache = new Map(); + + const result = execute(device, cache, "nearest", "nearest", "repeat", "repeat"); + + expect(cache.has("nearest_nearest_repeat_repeat")).toBe(true); + expect(cache.get("nearest_nearest_repeat_repeat")).toBe(result); + }); + + it("should generate correct cache key", () => + { + const device = createMockDevice(); + const cache = new Map(); + + execute(device, cache, "linear", "nearest", "repeat", "mirror-repeat"); + + expect(cache.has("linear_nearest_repeat_mirror-repeat")).toBe(true); + }); + + it("should return same sampler for same parameters", () => + { + const device = createMockDevice(); + const cache = new Map(); + + const result1 = execute(device, cache, "linear", "linear", "clamp-to-edge", "clamp-to-edge"); + const result2 = execute(device, cache, "linear", "linear", "clamp-to-edge", "clamp-to-edge"); + + expect(result1).toBe(result2); + expect(device.createSampler).toHaveBeenCalledTimes(1); + }); + + it("should create different samplers for different parameters", () => + { + const device = createMockDevice(); + const cache = new Map(); + + const result1 = execute(device, cache, "linear", "linear", "clamp-to-edge", "clamp-to-edge"); + const result2 = execute(device, cache, "nearest", "nearest", "repeat", "repeat"); + + expect(result1).not.toBe(result2); + expect(device.createSampler).toHaveBeenCalledTimes(2); + }); + + it("should handle all filter modes", () => + { + const device = createMockDevice(); + const cache = new Map(); + + execute(device, cache, "linear", "linear", "clamp-to-edge", "clamp-to-edge"); + execute(device, cache, "nearest", "nearest", "clamp-to-edge", "clamp-to-edge"); + + expect(cache.size).toBe(2); + }); + + it("should handle all address modes", () => + { + const device = createMockDevice(); + const cache = new Map(); + + execute(device, cache, "linear", "linear", "clamp-to-edge", "clamp-to-edge"); + execute(device, cache, "linear", "linear", "repeat", "repeat"); + execute(device, cache, "linear", "linear", "mirror-repeat", "mirror-repeat"); + + expect(cache.size).toBe(3); + }); +}); diff --git a/packages/webgpu/src/SamplerCache/service/SamplerCacheGetOrCreateService.ts b/packages/webgpu/src/SamplerCache/service/SamplerCacheGetOrCreateService.ts new file mode 100644 index 00000000..a0d54691 --- /dev/null +++ b/packages/webgpu/src/SamplerCache/service/SamplerCacheGetOrCreateService.ts @@ -0,0 +1,41 @@ +import { execute as samplerCacheGenerateKeyService } from "./SamplerCacheGenerateKeyService"; + +/** + * @description サンプラーを取得または作成 + * Get or create sampler + * + * @param {GPUDevice} device + * @param {Map} cache + * @param {GPUFilterMode} minFilter + * @param {GPUFilterMode} magFilter + * @param {GPUAddressMode} addressModeU + * @param {GPUAddressMode} addressModeV + * @return {GPUSampler} + * @method + * @protected + */ +export const execute = ( + device: GPUDevice, + cache: Map, + minFilter: GPUFilterMode, + magFilter: GPUFilterMode, + addressModeU: GPUAddressMode, + addressModeV: GPUAddressMode +): GPUSampler => { + const key = samplerCacheGenerateKeyService(minFilter, magFilter, addressModeU, addressModeV); + + const cached = cache.get(key); + if (cached) { + return cached; + } + + const sampler = device.createSampler({ + minFilter, + magFilter, + addressModeU, + addressModeV + }); + + cache.set(key, sampler); + return sampler; +}; diff --git a/packages/webgpu/src/Shader/BlendModeShader.test.ts b/packages/webgpu/src/Shader/BlendModeShader.test.ts new file mode 100644 index 00000000..6e8664c7 --- /dev/null +++ b/packages/webgpu/src/Shader/BlendModeShader.test.ts @@ -0,0 +1,365 @@ +import { describe, it, expect } from "vitest"; +import { BlendModeShader } from "./BlendModeShader"; + +describe("BlendModeShader", () => +{ + describe("getVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = BlendModeShader.getVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = BlendModeShader.getVertexShader(); + + expect(shader).toContain("@vertex"); + }); + + it("should define VertexInput struct", () => + { + const shader = BlendModeShader.getVertexShader(); + + expect(shader).toContain("struct VertexInput"); + }); + + it("should define VertexOutput struct", () => + { + const shader = BlendModeShader.getVertexShader(); + + expect(shader).toContain("struct VertexOutput"); + }); + + it("should include position in VertexInput", () => + { + const shader = BlendModeShader.getVertexShader(); + + expect(shader).toContain("@location(0) position: vec2"); + }); + + it("should include texCoord in VertexInput", () => + { + const shader = BlendModeShader.getVertexShader(); + + expect(shader).toContain("@location(1) texCoord: vec2"); + }); + + it("should output position with @builtin(position)", () => + { + const shader = BlendModeShader.getVertexShader(); + + expect(shader).toContain("@builtin(position) position: vec4"); + }); + }); + + describe("getMultiplyShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = BlendModeShader.getMultiplyShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = BlendModeShader.getMultiplyShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should define BlendUniforms struct", () => + { + const shader = BlendModeShader.getMultiplyShader(); + + expect(shader).toContain("struct BlendUniforms"); + }); + + it("should include colorTransform uniform", () => + { + const shader = BlendModeShader.getMultiplyShader(); + + expect(shader).toContain("colorTransform: vec4"); + }); + + it("should include addColor uniform", () => + { + const shader = BlendModeShader.getMultiplyShader(); + + expect(shader).toContain("addColor: vec4"); + }); + + it("should have two texture bindings for dst and src", () => + { + const shader = BlendModeShader.getMultiplyShader(); + + expect(shader).toContain("var texture0: texture_2d"); + expect(shader).toContain("var texture1: texture_2d"); + }); + + it("should implement multiply blend formula", () => + { + const shader = BlendModeShader.getMultiplyShader(); + + expect(shader).toContain("src * dst"); + }); + }); + + describe("getScreenShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = BlendModeShader.getScreenShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = BlendModeShader.getScreenShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should define BlendUniforms struct", () => + { + const shader = BlendModeShader.getScreenShader(); + + expect(shader).toContain("struct BlendUniforms"); + }); + + it("should implement screen blend formula", () => + { + const shader = BlendModeShader.getScreenShader(); + expect(shader).toContain("srcRgb + dstRgb - srcRgb * dstRgb"); + }); + }); + + describe("getLightenShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = BlendModeShader.getLightenShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = BlendModeShader.getLightenShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should implement lighten blend using max", () => + { + const shader = BlendModeShader.getLightenShader(); + + expect(shader).toContain("max(srcRgb, dstRgb)"); + }); + + it("should handle zero alpha cases", () => + { + const shader = BlendModeShader.getLightenShader(); + + expect(shader).toContain("if (src.a == 0.0) { return dst; }"); + expect(shader).toContain("if (dst.a == 0.0) { return src; }"); + }); + + it("should unpremultiply colors for blending", () => + { + const shader = BlendModeShader.getLightenShader(); + + expect(shader).toContain("srcRgb = src.rgb / src.a"); + expect(shader).toContain("dstRgb = dst.rgb / dst.a"); + }); + }); + + describe("getDarkenShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = BlendModeShader.getDarkenShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = BlendModeShader.getDarkenShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should implement darken blend using min", () => + { + const shader = BlendModeShader.getDarkenShader(); + + expect(shader).toContain("min(srcRgb, dstRgb)"); + }); + + it("should handle zero alpha cases", () => + { + const shader = BlendModeShader.getDarkenShader(); + + expect(shader).toContain("if (src.a == 0.0) { return dst; }"); + expect(shader).toContain("if (dst.a == 0.0) { return src; }"); + }); + }); + + describe("getOverlayShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = BlendModeShader.getOverlayShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = BlendModeShader.getOverlayShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should implement branchless overlay blend with step on dst", () => + { + const shader = BlendModeShader.getOverlayShader(); + + expect(shader).toContain("step(vec3(0.5), dstRgb)"); + expect(shader).toContain("mix(lo, hi, s)"); + }); + }); + + describe("getHardLightShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = BlendModeShader.getHardLightShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = BlendModeShader.getHardLightShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should implement branchless hard light blend with step on src", () => + { + const shader = BlendModeShader.getHardLightShader(); + + expect(shader).toContain("step(vec3(0.5), srcRgb)"); + expect(shader).toContain("mix(lo, hi, s)"); + }); + }); + + describe("getDifferenceShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = BlendModeShader.getDifferenceShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = BlendModeShader.getDifferenceShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should implement difference blend using abs", () => + { + const shader = BlendModeShader.getDifferenceShader(); + + expect(shader).toContain("abs(srcRgb - dstRgb)"); + }); + }); + + describe("getSubtractShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = BlendModeShader.getSubtractShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = BlendModeShader.getSubtractShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should implement subtract blend (dst - src)", () => + { + const shader = BlendModeShader.getSubtractShader(); + + expect(shader).toContain("dstRgb - srcRgb"); + }); + + it("should handle zero alpha cases", () => + { + const shader = BlendModeShader.getSubtractShader(); + + expect(shader).toContain("if (src.a == 0.0) { return dst; }"); + expect(shader).toContain("if (dst.a == 0.0) { return src; }"); + }); + }); + + describe("common shader properties", () => + { + const shaders = [ + { name: "Multiply", fn: BlendModeShader.getMultiplyShader }, + { name: "Screen", fn: BlendModeShader.getScreenShader }, + { name: "Lighten", fn: BlendModeShader.getLightenShader }, + { name: "Darken", fn: BlendModeShader.getDarkenShader }, + { name: "Overlay", fn: BlendModeShader.getOverlayShader }, + { name: "HardLight", fn: BlendModeShader.getHardLightShader }, + { name: "Difference", fn: BlendModeShader.getDifferenceShader }, + { name: "Subtract", fn: BlendModeShader.getSubtractShader } + ]; + + shaders.forEach(({ name, fn }) => + { + it(`${name} shader should include textureSample calls`, () => + { + const shader = fn(); + + expect(shader).toContain("textureSample"); + }); + + it(`${name} shader should apply color transform`, () => + { + const shader = fn(); + + expect(shader).toContain("uniforms.colorTransform"); + }); + + it(`${name} shader should have sampler binding`, () => + { + const shader = fn(); + + expect(shader).toContain("var sampler0: sampler"); + }); + }); + }); +}); diff --git a/packages/webgpu/src/Shader/BlendModeShader.ts b/packages/webgpu/src/Shader/BlendModeShader.ts new file mode 100644 index 00000000..f709fdb6 --- /dev/null +++ b/packages/webgpu/src/Shader/BlendModeShader.ts @@ -0,0 +1,99 @@ +import { BlendModeVertex } from "./wgsl/vertex/FilterVertex"; +import { + MultiplyBlendFragment, + ScreenBlendFragment, + LightenBlendFragment, + DarkenBlendFragment, + OverlayBlendFragment, + HardLightBlendFragment, + DifferenceBlendFragment, + SubtractBlendFragment +} from "./wgsl/fragment/BlendFragment"; + +/** + * @description WebGPU用ブレンドモードシェーダー + * Blend mode shaders for WebGPU + */ +export class BlendModeShader +{ + /** + * @description ブレンドモード用の頂点シェーダー + * @return {string} + */ + static getVertexShader(): string + { + return BlendModeVertex; + } + + /** + * @description Multiplyブレンド用のフラグメントシェーダー + * @return {string} + */ + static getMultiplyShader(): string + { + return MultiplyBlendFragment; + } + + /** + * @description Screenブレンド用のフラグメントシェーダー + * @return {string} + */ + static getScreenShader(): string + { + return ScreenBlendFragment; + } + + /** + * @description Lightenブレンド用のフラグメントシェーダー + * @return {string} + */ + static getLightenShader(): string + { + return LightenBlendFragment; + } + + /** + * @description Darkenブレンド用のフラグメントシェーダー + * @return {string} + */ + static getDarkenShader(): string + { + return DarkenBlendFragment; + } + + /** + * @description Overlayブレンド用のフラグメントシェーダー + * @return {string} + */ + static getOverlayShader(): string + { + return OverlayBlendFragment; + } + + /** + * @description Hard Lightブレンド用のフラグメントシェーダー + * @return {string} + */ + static getHardLightShader(): string + { + return HardLightBlendFragment; + } + + /** + * @description Differenceブレンド用のフラグメントシェーダー + * @return {string} + */ + static getDifferenceShader(): string + { + return DifferenceBlendFragment; + } + + /** + * @description Subtractブレンド用のフラグメントシェーダー + * @return {string} + */ + static getSubtractShader(): string + { + return SubtractBlendFragment; + } +} diff --git a/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTCalculateResolutionService.test.ts b/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTCalculateResolutionService.test.ts new file mode 100644 index 00000000..d8d3922d --- /dev/null +++ b/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTCalculateResolutionService.test.ts @@ -0,0 +1,61 @@ +import { execute } from "./GradientLUTCalculateResolutionService"; +import { describe, expect, it } from "vitest"; + +describe("GradientLUTCalculateResolutionService.ts method test", () => +{ + it("test case - 2 stops returns 64", () => + { + const result = execute(2); + + expect(result).toBe(64); + }); + + it("test case - 3 stops returns 128", () => + { + const result = execute(3); + + expect(result).toBe(128); + }); + + it("test case - 4 stops returns 128", () => + { + const result = execute(4); + + expect(result).toBe(128); + }); + + it("test case - 5 stops returns 256", () => + { + const result = execute(5); + + expect(result).toBe(256); + }); + + it("test case - 8 stops returns 256", () => + { + const result = execute(8); + + expect(result).toBe(256); + }); + + it("test case - 9 stops returns 512", () => + { + const result = execute(9); + + expect(result).toBe(512); + }); + + it("test case - respects minResolution parameter", () => + { + const result = execute(2, 128); + + expect(result).toBe(128); + }); + + it("test case - respects maxResolution parameter", () => + { + const result = execute(10, 64, 256); + + expect(result).toBe(256); + }); +}); diff --git a/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTCalculateResolutionService.ts b/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTCalculateResolutionService.ts new file mode 100644 index 00000000..cbf2193a --- /dev/null +++ b/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTCalculateResolutionService.ts @@ -0,0 +1,36 @@ +/** + * @description グラデーションストップ数に応じた適応的解像度を計算 + * Calculate adaptive resolution based on number of gradient stops + * + * @param {number} stopsCount - グラデーションストップの数 + * @param {number} [minResolution=64] - 最小解像度 + * @param {number} [maxResolution=512] - 最大解像度 + * @return {number} + * @method + * @protected + */ +export const execute = ( + stopsCount: number, + minResolution: number = 64, + maxResolution: number = 512 +): number => { + + // ストップ数に応じて解像度を調整 + // 2ストップ: 64px + // 3-4ストップ: 128px + // 5-8ストップ: 256px + // 9以上: 512px + if (stopsCount <= 2) { + return Math.max(minResolution, 64); + } + + if (stopsCount <= 4) { + return Math.min(maxResolution, Math.max(minResolution, 128)); + } + + if (stopsCount <= 8) { + return Math.min(maxResolution, Math.max(minResolution, 256)); + } + + return maxResolution; +}; diff --git a/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTGeneratePixelsService.test.ts b/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTGeneratePixelsService.test.ts new file mode 100644 index 00000000..d4f808f0 --- /dev/null +++ b/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTGeneratePixelsService.test.ts @@ -0,0 +1,104 @@ +import { execute } from "./GradientLUTGeneratePixelsService"; +import type { IGradientStop } from "./GradientLUTParseStopsService"; +import { describe, expect, it } from "vitest"; + +describe("GradientLUTGeneratePixelsService.ts method test", () => +{ + it("test case - empty stops returns empty array", () => + { + const stops: IGradientStop[] = []; + + const result = execute(stops, 64, 0); + + expect(result.length).toBe(64 * 4); + expect(result.every(v => v === 0)).toBe(true); + }); + + it("test case - single stop fills with same color", () => + { + const stops: IGradientStop[] = [ + { ratio: 0.5, r: 1, g: 0, b: 0, a: 1 } + ]; + + const result = execute(stops, 4, 0); + + expect(result.length).toBe(16); + // 全ピクセルが同じ色(赤) + for (let i = 0; i < 4; i++) { + expect(result[i * 4]).toBe(255); // r + expect(result[i * 4 + 1]).toBe(0); // g + expect(result[i * 4 + 2]).toBe(0); // b + expect(result[i * 4 + 3]).toBe(255); // a + } + }); + + it("test case - two stops gradient from red to blue", () => + { + const stops: IGradientStop[] = [ + { ratio: 0, r: 1, g: 0, b: 0, a: 1 }, + { ratio: 1, r: 0, g: 0, b: 1, a: 1 } + ]; + + const result = execute(stops, 3, 0); + + // ピクセル0: 赤 + expect(result[0]).toBe(255); + expect(result[1]).toBe(0); + expect(result[2]).toBe(0); + + // ピクセル1: 紫(中間) + expect(result[4]).toBe(128); + expect(result[5]).toBe(0); + expect(result[6]).toBe(128); + + // ピクセル2: 青 + expect(result[8]).toBe(0); + expect(result[9]).toBe(0); + expect(result[10]).toBe(255); + }); + + it("test case - alpha channel interpolation", () => + { + const stops: IGradientStop[] = [ + { ratio: 0, r: 1, g: 1, b: 1, a: 0 }, + { ratio: 1, r: 1, g: 1, b: 1, a: 1 } + ]; + + const result = execute(stops, 3, 0); + + // ピクセル0: alpha=0 + expect(result[3]).toBe(0); + + // ピクセル1: alpha=0.5 + expect(result[7]).toBe(128); + + // ピクセル2: alpha=1 + expect(result[11]).toBe(255); + }); + + it("test case - three stops gradient", () => + { + const stops: IGradientStop[] = [ + { ratio: 0, r: 1, g: 0, b: 0, a: 1 }, // 赤 + { ratio: 0.5, r: 0, g: 1, b: 0, a: 1 }, // 緑 + { ratio: 1, r: 0, g: 0, b: 1, a: 1 } // 青 + ]; + + const result = execute(stops, 5, 0); + + // ピクセル0: 赤 + expect(result[0]).toBe(255); + expect(result[1]).toBe(0); + expect(result[2]).toBe(0); + + // ピクセル2: 緑 + expect(result[8]).toBe(0); + expect(result[9]).toBe(255); + expect(result[10]).toBe(0); + + // ピクセル4: 青 + expect(result[16]).toBe(0); + expect(result[17]).toBe(0); + expect(result[18]).toBe(255); + }); +}); diff --git a/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTGeneratePixelsService.ts b/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTGeneratePixelsService.ts new file mode 100644 index 00000000..30dea70a --- /dev/null +++ b/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTGeneratePixelsService.ts @@ -0,0 +1,80 @@ +import type { IGradientStop } from "../../../interface/IGradientStop"; +import { execute as gradientLUTInterpolateColorService } from "./GradientLUTInterpolateColorService"; + +/** + * @description グラデーションLUTのピクセルデータを生成 + * Generate pixel data for gradient LUT + * + * @param {IGradientStop[]} stops - ソート済みのグラデーションストップ + * @param {number} resolution - LUTの解像度(ピクセル数) + * @param {number} interpolation - 0: RGB, 1: Linear RGB + * @return {Uint8Array} + * @method + * @protected + */ +export const execute = ( + stops: IGradientStop[], + resolution: number, + interpolation: number +): Uint8Array => { + + const pixels = new Uint8Array(resolution * 4); + + if (stops.length === 0) { + return pixels; + } + + if (stops.length === 1) { + // 単一ストップの場合は全体を同じ色で塗る + const stop = stops[0]; + for (let i = 0; i < resolution; i++) { + const offset = i * 4; + pixels[offset] = Math.round(stop.r * 255); + pixels[offset + 1] = Math.round(stop.g * 255); + pixels[offset + 2] = Math.round(stop.b * 255); + pixels[offset + 3] = Math.round(stop.a * 255); + } + return pixels; + } + + for (let i = 0; i < resolution; i++) { + + const ratio = i / (resolution - 1); + + // 該当するストップ区間を見つける + let startStopIndex = 0; + for (let j = 0; j < stops.length - 1; j++) { + if (ratio >= stops[j].ratio && ratio <= stops[j + 1].ratio) { + startStopIndex = j; + break; + } + if (ratio > stops[j + 1].ratio) { + startStopIndex = j + 1; + } + } + + const startStop = stops[startStopIndex]; + const endStop = stops[Math.min(startStopIndex + 1, stops.length - 1)]; + + // 区間内での補間係数を計算 + let t = 0; + const rangeWidth = endStop.ratio - startStop.ratio; + if (rangeWidth > 0) { + t = (ratio - startStop.ratio) / rangeWidth; + t = Math.max(0, Math.min(1, t)); + } + + // 色を補間 + const color = gradientLUTInterpolateColorService( + startStop, endStop, t, interpolation + ); + + const offset = i * 4; + pixels[offset] = Math.round(color.r * 255); + pixels[offset + 1] = Math.round(color.g * 255); + pixels[offset + 2] = Math.round(color.b * 255); + pixels[offset + 3] = Math.round(color.a * 255); + } + + return pixels; +}; diff --git a/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTInterpolateColorService.test.ts b/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTInterpolateColorService.test.ts new file mode 100644 index 00000000..6a54bb51 --- /dev/null +++ b/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTInterpolateColorService.test.ts @@ -0,0 +1,70 @@ +import { execute } from "./GradientLUTInterpolateColorService"; +import type { IGradientStop } from "./GradientLUTParseStopsService"; +import { describe, expect, it } from "vitest"; + +describe("GradientLUTInterpolateColorService.ts method test", () => +{ + it("test case - interpolate at t=0 returns start color (RGB mode)", () => + { + const startStop: IGradientStop = { ratio: 0, r: 1, g: 0, b: 0, a: 1 }; + const endStop: IGradientStop = { ratio: 1, r: 0, g: 0, b: 1, a: 1 }; + + const result = execute(startStop, endStop, 0, 0); + + expect(result.r).toBe(1); + expect(result.g).toBe(0); + expect(result.b).toBe(0); + expect(result.a).toBe(1); + }); + + it("test case - interpolate at t=1 returns end color (RGB mode)", () => + { + const startStop: IGradientStop = { ratio: 0, r: 1, g: 0, b: 0, a: 1 }; + const endStop: IGradientStop = { ratio: 1, r: 0, g: 0, b: 1, a: 1 }; + + const result = execute(startStop, endStop, 1, 0); + + expect(result.r).toBe(0); + expect(result.g).toBe(0); + expect(result.b).toBe(1); + expect(result.a).toBe(1); + }); + + it("test case - interpolate at t=0.5 returns midpoint (RGB mode)", () => + { + const startStop: IGradientStop = { ratio: 0, r: 0, g: 0, b: 0, a: 0 }; + const endStop: IGradientStop = { ratio: 1, r: 1, g: 1, b: 1, a: 1 }; + + const result = execute(startStop, endStop, 0.5, 0); + + expect(result.r).toBe(0.5); + expect(result.g).toBe(0.5); + expect(result.b).toBe(0.5); + expect(result.a).toBe(0.5); + }); + + it("test case - interpolate with Linear RGB mode (interpolation=1)", () => + { + const startStop: IGradientStop = { ratio: 0, r: 0, g: 0, b: 0, a: 0 }; + const endStop: IGradientStop = { ratio: 1, r: 1, g: 1, b: 1, a: 1 }; + + const result = execute(startStop, endStop, 0.5, 1); + + // Linear RGB補間では結果が異なる + expect(result.r).toBeGreaterThan(0); + expect(result.r).toBeLessThan(1); + expect(result.a).toBe(0.5); // アルファは常に線形補間 + }); + + it("test case - alpha is always linearly interpolated", () => + { + const startStop: IGradientStop = { ratio: 0, r: 1, g: 1, b: 1, a: 0 }; + const endStop: IGradientStop = { ratio: 1, r: 1, g: 1, b: 1, a: 1 }; + + const resultRGB = execute(startStop, endStop, 0.5, 0); + const resultLinear = execute(startStop, endStop, 0.5, 1); + + expect(resultRGB.a).toBe(0.5); + expect(resultLinear.a).toBe(0.5); + }); +}); diff --git a/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTInterpolateColorService.ts b/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTInterpolateColorService.ts new file mode 100644 index 00000000..8180844f --- /dev/null +++ b/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTInterpolateColorService.ts @@ -0,0 +1,48 @@ +import type { IGradientStop } from "../../../interface/IGradientStop"; + +/** + * @description 2つのストップ間で色を補間 + * Interpolate color between two stops + * + * @param {IGradientStop} startStop + * @param {IGradientStop} endStop + * @param {number} t - 補間係数 (0-1) + * @param {number} interpolation - 0: RGB, 1: Linear RGB + * @return {{ r: number, g: number, b: number, a: number }} + * @method + * @protected + */ +export const execute = ( + startStop: IGradientStop, + endStop: IGradientStop, + t: number, + interpolation: number +): { r: number; g: number; b: number; a: number } => { + + let r: number; + let g: number; + let b: number; + + if (interpolation === 1) { + // Linear RGB補間(ガンマ補正あり) + const sr = Math.pow(startStop.r, 2.2); + const sg = Math.pow(startStop.g, 2.2); + const sb = Math.pow(startStop.b, 2.2); + const er = Math.pow(endStop.r, 2.2); + const eg = Math.pow(endStop.g, 2.2); + const eb = Math.pow(endStop.b, 2.2); + + r = Math.pow(sr + (er - sr) * t, 1 / 2.2); + g = Math.pow(sg + (eg - sg) * t, 1 / 2.2); + b = Math.pow(sb + (eb - sb) * t, 1 / 2.2); + } else { + // 通常のRGB補間 + r = startStop.r + (endStop.r - startStop.r) * t; + g = startStop.g + (endStop.g - startStop.g) * t; + b = startStop.b + (endStop.b - startStop.b) * t; + } + + const a = startStop.a + (endStop.a - startStop.a) * t; + + return { r, g, b, a }; +}; diff --git a/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTParseStopsService.test.ts b/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTParseStopsService.test.ts new file mode 100644 index 00000000..ffa725a9 --- /dev/null +++ b/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTParseStopsService.test.ts @@ -0,0 +1,58 @@ +import { execute } from "./GradientLUTParseStopsService"; +import { describe, expect, it } from "vitest"; + +describe("GradientLUTParseStopsService.ts method test", () => +{ + it("test case - parse single stop", () => + { + const stops = [0.5, 1, 0, 0, 1]; // ratio=0.5, r=1, g=0, b=0, a=1 + + const result = execute(stops); + + expect(result.length).toBe(1); + expect(result[0].ratio).toBe(0.5); + expect(result[0].r).toBe(1); + expect(result[0].g).toBe(0); + expect(result[0].b).toBe(0); + expect(result[0].a).toBe(1); + }); + + it("test case - parse multiple stops", () => + { + const stops = [ + 0, 1, 0, 0, 1, // ratio=0, red + 1, 0, 0, 1, 1 // ratio=1, blue + ]; + + const result = execute(stops); + + expect(result.length).toBe(2); + expect(result[0].ratio).toBe(0); + expect(result[1].ratio).toBe(1); + }); + + it("test case - sorts stops by ratio", () => + { + const stops = [ + 1, 0, 0, 1, 1, // ratio=1 + 0.5, 0, 1, 0, 1, // ratio=0.5 + 0, 1, 0, 0, 1 // ratio=0 + ]; + + const result = execute(stops); + + expect(result.length).toBe(3); + expect(result[0].ratio).toBe(0); + expect(result[1].ratio).toBe(0.5); + expect(result[2].ratio).toBe(1); + }); + + it("test case - empty stops", () => + { + const stops: number[] = []; + + const result = execute(stops); + + expect(result.length).toBe(0); + }); +}); diff --git a/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTParseStopsService.ts b/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTParseStopsService.ts new file mode 100644 index 00000000..6f62628b --- /dev/null +++ b/packages/webgpu/src/Shader/GradientLUTGenerator/service/GradientLUTParseStopsService.ts @@ -0,0 +1,30 @@ +import type { IGradientStop } from "../../../interface/IGradientStop"; + +/** + * @description グラデーションストップ配列をパースしてソート + * Parse and sort gradient stops array + * + * @param {number[]} stops - [ratio, r, g, b, a, ratio, r, g, b, a, ...] + * @return {IGradientStop[]} + * @method + * @protected + */ +export const execute = (stops: number[]): IGradientStop[] => +{ + const gradientStops: IGradientStop[] = []; + + for (let i = 0; i < stops.length; i += 5) { + gradientStops.push({ + "ratio": stops[i], + "r": stops[i + 1], + "g": stops[i + 2], + "b": stops[i + 3], + "a": stops[i + 4] + }); + } + + // ストップポイントをratio順にソート + gradientStops.sort((a, b) => a.ratio - b.ratio); + + return gradientStops; +}; diff --git a/packages/webgpu/src/Shader/GradientLUTGenerator/usecase/GradientLUTGenerateDataUseCase.test.ts b/packages/webgpu/src/Shader/GradientLUTGenerator/usecase/GradientLUTGenerateDataUseCase.test.ts new file mode 100644 index 00000000..6d0c7884 --- /dev/null +++ b/packages/webgpu/src/Shader/GradientLUTGenerator/usecase/GradientLUTGenerateDataUseCase.test.ts @@ -0,0 +1,139 @@ +import { execute } from "./GradientLUTGenerateDataUseCase"; +import { describe, expect, it } from "vitest"; + +describe("GradientLUTGenerateDataUseCase.ts method test", () => +{ + it("test case - generates LUT data for simple gradient", () => + { + const stops = [ + 0, 1, 0, 0, 1, // ratio=0, red + 1, 0, 0, 1, 1 // ratio=1, blue + ]; + + const result = execute(stops, 0); + + expect(result.pixels).toBeInstanceOf(Uint8Array); + expect(result.resolution).toBe(64); // 2 stops = 64px + expect(result.pixels.length).toBe(64 * 4); + }); + + it("test case - resolution adapts to stop count", () => + { + // 5ストップのグラデーション + const stops = [ + 0, 1, 0, 0, 1, + 0.25, 1, 1, 0, 1, + 0.5, 0, 1, 0, 1, + 0.75, 0, 1, 1, 1, + 1, 0, 0, 1, 1 + ]; + + const result = execute(stops, 0); + + expect(result.resolution).toBe(256); // 5 stops = 256px + }); + + it("test case - first pixel is start color", () => + { + const stops = [ + 0, 1, 0.5, 0.25, 1, // ratio=0 + 1, 0, 0, 1, 1 // ratio=1 + ]; + + const result = execute(stops, 0); + + expect(result.pixels[0]).toBe(255); // r + expect(result.pixels[1]).toBe(128); // g (0.5 * 255) + expect(result.pixels[2]).toBe(64); // b (0.25 * 255) + expect(result.pixels[3]).toBe(255); // a + }); + + it("test case - last pixel is end color", () => + { + const stops = [ + 0, 1, 0, 0, 1, + 1, 0.5, 0.25, 1, 0.5 // ratio=1 + ]; + + const result = execute(stops, 0); + + const lastIndex = (result.resolution - 1) * 4; + expect(result.pixels[lastIndex]).toBe(128); // r (0.5 * 255) + expect(result.pixels[lastIndex + 1]).toBe(64); // g (0.25 * 255) + expect(result.pixels[lastIndex + 2]).toBe(255); // b + expect(result.pixels[lastIndex + 3]).toBe(128); // a (0.5 * 255) + }); + + it("test case - respects custom resolution limits", () => + { + const stops = [ + 0, 1, 0, 0, 1, + 1, 0, 0, 1, 1 + ]; + + const result = execute(stops, 0, 128, 128); + + expect(result.resolution).toBe(128); + }); + + it("test case - handles unsorted stops", () => + { + const stops = [ + 1, 0, 0, 1, 1, // ratio=1 (end) + 0, 1, 0, 0, 1 // ratio=0 (start) + ]; + + const result = execute(stops, 0); + + // 内部でソートされるので、最初のピクセルは赤になるはず + expect(result.pixels[0]).toBe(255); // r + expect(result.pixels[1]).toBe(0); // g + expect(result.pixels[2]).toBe(0); // b + }); + + it("test case - white gradient with varying alpha (0xffffff alpha 1.0 to 0.6)", () => + { + // Issue: 0xffffff (white) colors and transparency/alpha gradients were not displaying + const stops = [ + 0, 1, 1, 1, 1, // ratio=0, white with alpha=1.0 + 1, 1, 1, 1, 0.6 // ratio=1, white with alpha=0.6 + ]; + + const result = execute(stops, 0); + + // First pixel: white with full alpha + expect(result.pixels[0]).toBe(255); // r + expect(result.pixels[1]).toBe(255); // g + expect(result.pixels[2]).toBe(255); // b + expect(result.pixels[3]).toBe(255); // a (1.0) + + // Last pixel: white with 60% alpha + const lastIndex = (result.resolution - 1) * 4; + expect(result.pixels[lastIndex]).toBe(255); // r + expect(result.pixels[lastIndex + 1]).toBe(255); // g + expect(result.pixels[lastIndex + 2]).toBe(255); // b + expect(result.pixels[lastIndex + 3]).toBe(153); // a (0.6 * 255 = 153) + }); + + it("test case - alpha-only gradient (same color, different alphas)", () => + { + const stops = [ + 0, 0.8, 0.4, 0.2, 1, // ratio=0, color with alpha=1.0 + 1, 0.8, 0.4, 0.2, 0 // ratio=1, same color with alpha=0.0 + ]; + + const result = execute(stops, 0); + + // First pixel: full alpha + expect(result.pixels[3]).toBe(255); // a (1.0) + + // Last pixel: zero alpha (fully transparent) + const lastIndex = (result.resolution - 1) * 4; + expect(result.pixels[lastIndex + 3]).toBe(0); // a (0.0) + + // Middle pixel should have ~50% alpha + const midIndex = Math.floor(result.resolution / 2) * 4; + expect(result.pixels[midIndex + 3]).toBeGreaterThan(100); + expect(result.pixels[midIndex + 3]).toBeLessThan(156); + }); +}); diff --git a/packages/webgpu/src/Shader/GradientLUTGenerator/usecase/GradientLUTGenerateDataUseCase.ts b/packages/webgpu/src/Shader/GradientLUTGenerator/usecase/GradientLUTGenerateDataUseCase.ts new file mode 100644 index 00000000..f9659e38 --- /dev/null +++ b/packages/webgpu/src/Shader/GradientLUTGenerator/usecase/GradientLUTGenerateDataUseCase.ts @@ -0,0 +1,43 @@ +import type { IGradientLUTData } from "../../../interface/IGradientLUTData"; +import { execute as gradientLUTParseStopsService } from "../service/GradientLUTParseStopsService"; +import { execute as gradientLUTCalculateResolutionService } from "../service/GradientLUTCalculateResolutionService"; +import { execute as gradientLUTGeneratePixelsService } from "../service/GradientLUTGeneratePixelsService"; + +/** + * @description グラデーションLUTのピクセルデータを生成 + * Generate gradient LUT pixel data + * + * @param {number[]} stops - [ratio, r, g, b, a, ratio, r, g, b, a, ...] + * @param {number} interpolation - 0: RGB, 1: Linear RGB + * @param {number} [minResolution=64] - 最小解像度 + * @param {number} [maxResolution=512] - 最大解像度 + * @return {IGradientLUTData} + * @method + * @protected + */ +export const execute = ( + stops: number[], + interpolation: number, + minResolution: number = 64, + maxResolution: number = 512 +): IGradientLUTData => { + + // ストップ配列をパースしてソート + const parsedStops = gradientLUTParseStopsService(stops); + + // ストップ数に応じた解像度を計算 + const resolution = gradientLUTCalculateResolutionService( + parsedStops.length, + minResolution, + maxResolution + ); + + // ピクセルデータを生成 + const pixels = gradientLUTGeneratePixelsService( + parsedStops, + resolution, + interpolation + ); + + return { pixels, resolution }; +}; diff --git a/packages/webgpu/src/Shader/PipelineManager.test.ts b/packages/webgpu/src/Shader/PipelineManager.test.ts new file mode 100644 index 00000000..8c87809d --- /dev/null +++ b/packages/webgpu/src/Shader/PipelineManager.test.ts @@ -0,0 +1,364 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock GPUShaderStage +const GPUShaderStage = { + VERTEX: 0x01, + FRAGMENT: 0x02, + COMPUTE: 0x04 +}; +(globalThis as any).GPUShaderStage = GPUShaderStage; + +describe("PipelineManager", () => +{ + // Create a mock implementation for testing without the actual class + class MockPipelineManager + { + private pipelines: Map; + private bindGroupLayouts: Map; + + constructor (_device: GPUDevice, _format: GPUTextureFormat) + { + this.pipelines = new Map(); + this.bindGroupLayouts = new Map(); + + // Initialize mock pipelines + const pipelineNames = [ + "fill", "fill_bgra", "fill_bgra_stencil", + "stencil_write", "stencil_fill", + "clip", "clip_stencil", + "mask", "mask_union", + "basic", + "texture", "texture_bgra", + "instanced", "instanced_bgra", + "gradient", "gradient_bgra", + "bitmap_fill", + "blend", + "blur_filter", + "texture_copy", + "color_matrix_filter", + "glow_filter", + "drop_shadow_filter", + "bevel_filter", + "gradient_glow_filter", + "gradient_bevel_filter", + "node_clear" + ]; + + for (const name of pipelineNames) { + this.pipelines.set(name, { "label": `pipeline_${name}` }); + } + + // Initialize mock bind group layouts + const layoutNames = [ + "fill", "stencil", "clip", "mask", "basic", "texture", + "instanced", "gradient", "bitmap_fill", "blend", + "blur_filter", "texture_copy", "color_matrix_filter", + "glow_filter", "drop_shadow_filter", "bevel_filter", + "gradient_glow_filter", "gradient_bevel_filter", + "node_clear" + ]; + + for (const name of layoutNames) { + this.bindGroupLayouts.set(name, { "label": `layout_${name}` }); + } + } + + getPipeline (name: string): any + { + return this.pipelines.get(name); + } + + getBindGroupLayout (name: string): any + { + return this.bindGroupLayouts.get(name); + } + } + + const createMockDevice = (): GPUDevice => + { + return { + "createShaderModule": vi.fn(() => ({ "label": "mockShaderModule" })), + "createBindGroupLayout": vi.fn(() => ({ "label": "mockBindGroupLayout" })), + "createPipelineLayout": vi.fn(() => ({ "label": "mockPipelineLayout" })), + "createRenderPipeline": vi.fn(() => ({ "label": "mockRenderPipeline" })) + } as unknown as GPUDevice; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + }); + + describe("constructor", () => + { + it("should create instance with device and format", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + expect(manager).toBeDefined(); + }); + }); + + describe("getPipeline", () => + { + it("should return fill pipeline", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const pipeline = manager.getPipeline("fill"); + + expect(pipeline).toBeDefined(); + }); + + it("should return fill_bgra pipeline", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const pipeline = manager.getPipeline("fill_bgra"); + + expect(pipeline).toBeDefined(); + }); + + it("should return mask pipeline", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const pipeline = manager.getPipeline("mask"); + + expect(pipeline).toBeDefined(); + }); + + it("should return basic pipeline", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const pipeline = manager.getPipeline("basic"); + + expect(pipeline).toBeDefined(); + }); + + it("should return texture pipeline", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const pipeline = manager.getPipeline("texture"); + + expect(pipeline).toBeDefined(); + }); + + it("should return instanced pipeline", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const pipeline = manager.getPipeline("instanced"); + + expect(pipeline).toBeDefined(); + }); + + it("should return gradient pipeline", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const pipeline = manager.getPipeline("gradient"); + + expect(pipeline).toBeDefined(); + }); + + it("should return blend pipeline", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const pipeline = manager.getPipeline("blend"); + + expect(pipeline).toBeDefined(); + }); + + it("should return blur filter pipeline", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const pipeline = manager.getPipeline("blur_filter"); + + expect(pipeline).toBeDefined(); + }); + + it("should return texture copy pipeline", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const pipeline = manager.getPipeline("texture_copy"); + + expect(pipeline).toBeDefined(); + }); + + it("should return color matrix filter pipeline", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const pipeline = manager.getPipeline("color_matrix_filter"); + + expect(pipeline).toBeDefined(); + }); + + it("should return glow filter pipeline", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const pipeline = manager.getPipeline("glow_filter"); + + expect(pipeline).toBeDefined(); + }); + + it("should return drop shadow filter pipeline", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const pipeline = manager.getPipeline("drop_shadow_filter"); + + expect(pipeline).toBeDefined(); + }); + + it("should return bevel filter pipeline", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const pipeline = manager.getPipeline("bevel_filter"); + + expect(pipeline).toBeDefined(); + }); + + it("should return gradient glow filter pipeline", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const pipeline = manager.getPipeline("gradient_glow_filter"); + + expect(pipeline).toBeDefined(); + }); + + it("should return gradient bevel filter pipeline", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const pipeline = manager.getPipeline("gradient_bevel_filter"); + + expect(pipeline).toBeDefined(); + }); + + it("should return undefined for non-existent pipeline", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + expect(manager.getPipeline("nonexistent")).toBeUndefined(); + }); + }); + + describe("getBindGroupLayout", () => + { + it("should return fill bind group layout", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const layout = manager.getBindGroupLayout("fill"); + + expect(layout).toBeDefined(); + }); + + it("should return mask bind group layout", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const layout = manager.getBindGroupLayout("mask"); + + expect(layout).toBeDefined(); + }); + + it("should return basic bind group layout", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const layout = manager.getBindGroupLayout("basic"); + + expect(layout).toBeDefined(); + }); + + it("should return texture bind group layout", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const layout = manager.getBindGroupLayout("texture"); + + expect(layout).toBeDefined(); + }); + + it("should return instanced bind group layout", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const layout = manager.getBindGroupLayout("instanced"); + + expect(layout).toBeDefined(); + }); + + it("should return gradient bind group layout", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const layout = manager.getBindGroupLayout("gradient"); + + expect(layout).toBeDefined(); + }); + + it("should return blend bind group layout", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const layout = manager.getBindGroupLayout("blend"); + + expect(layout).toBeDefined(); + }); + + it("should return blur filter bind group layout", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + const layout = manager.getBindGroupLayout("blur_filter"); + + expect(layout).toBeDefined(); + }); + + it("should return undefined for non-existent layout", () => + { + const device = createMockDevice(); + const manager = new MockPipelineManager(device, "bgra8unorm"); + + expect(manager.getBindGroupLayout("nonexistent")).toBeUndefined(); + }); + }); +}); diff --git a/packages/webgpu/src/Shader/PipelineManager.ts b/packages/webgpu/src/Shader/PipelineManager.ts new file mode 100644 index 00000000..fdfcdb78 --- /dev/null +++ b/packages/webgpu/src/Shader/PipelineManager.ts @@ -0,0 +1,3035 @@ +import { ShaderSource } from "./ShaderSource"; +import { $samples } from "../WebGPUUtil"; + +const VERTEX_BUFFER_LAYOUT_4F: GPUVertexBufferLayout = { + "arrayStride": 4 * 4, + "attributes": [ + { "shaderLocation": 0, "offset": 0, "format": "float32x2" }, + { "shaderLocation": 1, "offset": 2 * 4, "format": "float32x2" } + ] +}; + +const BLEND_PREMULTIPLIED_ALPHA: GPUBlendState = { + "color": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + } +}; + +export class PipelineManager +{ + private device: GPUDevice; + private format: GPUTextureFormat; + private pipelines: Map; + private bindGroupLayouts: Map; + private sampleCount: number; + private shaderModuleCache: Map = new Map(); + private filterBindGroupLayouts: Map = new Map(); + + constructor(device: GPUDevice, format: GPUTextureFormat) + { + this.device = device; + this.format = format; + this.pipelines = new Map(); + this.bindGroupLayouts = new Map(); + this.sampleCount = $samples; + + this.initialize(); + } + + private getOrCreateShaderModule(key: string, code: string): GPUShaderModule + { + let module = this.shaderModuleCache.get(key); + if (!module) { + module = this.device.createShaderModule({ code }); + this.shaderModuleCache.set(key, module); + } + return module; + } + + private initialize(): void + { + this.createFillPipeline(); + this.createStencilFillPipelines(); + this.createClipPipeline(); + this.createMaskUnionPipelines(); + this.createMaskPipeline(); + this.createBasicPipeline(); + this.createTexturePipeline(); + this.createInstancedPipeline(); + this.createGradientPipeline(); + this.createBitmapFillPipeline(); + this.createBlendPipeline(); + this.createNodeClearPipeline(); + } + + private lazyInitGroups: Set = new Set(); + private readonly lazyGroupMap: ReadonlyMap = new Map([ + ...Array.from({ "length": 16 }, (_, i): [string, string] => [`blur_filter_${i + 1}`, "blur_filter"]), + ["blur_filter", "blur_filter"], + ["texture_copy", "texture_copy"], ["texture_copy_rgba8", "texture_copy"], ["color_transform", "texture_copy"], ["y_flip_color_transform", "texture_copy"], + ["texture_erase", "texture_copy"], ["blur_texture_copy", "texture_copy"], + ["filter_blend", "texture_copy"], ["texture_copy_bgra", "texture_copy"], + ["filter_output", "texture_copy"], ["filter_output_add", "texture_copy"], + ["filter_output_screen", "texture_copy"], ["filter_output_alpha", "texture_copy"], + ["filter_output_erase", "texture_copy"], ["texture_copy_bgra_msaa", "texture_copy"], + ["filter_output_msaa", "texture_copy"], ["filter_output_add_msaa", "texture_copy"], + ["filter_output_screen_msaa", "texture_copy"], ["filter_output_alpha_msaa", "texture_copy"], + ["filter_output_erase_msaa", "texture_copy"], + ["positioned_texture", "texture_copy"], ["positioned_texture_rgba", "texture_copy"], + ["bitmap_render_msaa", "texture_copy"], ["bitmap_render", "texture_copy"], + ["texture_scale", "texture_copy"], ["texture_scale_blend", "texture_copy"], + ["bitmap_sync", "bitmap_sync"], + ["color_matrix_filter", "filter"], ["bevel_base", "filter"], + ["glow_filter", "filter"], ["drop_shadow_filter", "filter"], + ["bevel_filter", "filter"], ["gradient_glow_filter", "filter"], + ["gradient_bevel_filter", "filter"], + ["complex_blend", "complex_blend"], + ["complex_blend_copy", "complex_blend"], + ["complex_blend_scale", "complex_blend"], ["complex_blend_output", "complex_blend"], + ["complex_blend_output_msaa", "complex_blend"], + ["filter_complex_blend_output", "complex_blend"], + ["filter_complex_blend_output_msaa", "complex_blend"] + ]); + + private ensureLazyGroup(name: string): void + { + const group = this.lazyGroupMap.get(name); + if (!group || this.lazyInitGroups.has(group)) { + return; + } + this.lazyInitGroups.add(group); + + switch (group) { + case "blur_filter": + this.createBlurFilterPipeline(); + break; + case "texture_copy": + this.createTextureCopyPipeline(); + break; + case "bitmap_sync": + this.createBitmapSyncPipeline(); + break; + case "filter": + this.createColorMatrixFilterPipeline(); + break; + case "complex_blend": + this.createComplexBlendPipelines(); + break; + } + } + + preloadLazyGroups(): void + { + const groups = ["blur_filter", "texture_copy", "bitmap_sync", "filter", "complex_blend"]; + for (const group of groups) { + this.ensureLazyGroup(group); + } + } + + private createFillPipeline(): void + { + // Dynamic Offset対応のBindGroupLayout(fill + stencil共有) + const dynamicBindGroupLayout = this.device.createBindGroupLayout({ + "entries": [ + { + "binding": 0, + "visibility": GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + "buffer": { "type": "uniform", "hasDynamicOffset": true } + } + ] + }); + + this.bindGroupLayouts.set("fill_dynamic", dynamicBindGroupLayout); + + const pipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [dynamicBindGroupLayout] + }); + + const vertexShaderModule = this.getOrCreateShaderModule("fillVertex", ShaderSource.getFillVertexShader()); + + const fragmentShaderModule = this.getOrCreateShaderModule("fillFragment", ShaderSource.getFillFragmentShader()); + + const vertexBufferLayout = VERTEX_BUFFER_LAYOUT_4F; + const blendState = BLEND_PREMULTIPLIED_ALPHA; + const pipelineRGBA = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "always", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + }, + "stencilBack": { + "compare": "always", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + }, + "stencilReadMask": 0x00, + "stencilWriteMask": 0x00 + }, + "multisample": { + "count": this.sampleCount, + "alphaToCoverageEnabled": true + } + }); + const pipelineBGRA = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": { "yFlipSign": -1.0 } + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "multisample": { + "count": this.sampleCount, + "alphaToCoverageEnabled": true + } + }); + + this.pipelines.set("fill", pipelineRGBA); + this.pipelines.set("fill_bgra", pipelineBGRA); + const pipelineBGRAStencil = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": { "yFlipSign": -1.0 } + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "equal", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + }, + "stencilBack": { + "compare": "equal", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0x00 + }, + "multisample": { + "count": this.sampleCount, + "alphaToCoverageEnabled": true + } + }); + this.pipelines.set("fill_bgra_stencil", pipelineBGRAStencil); + } + + private createStencilFillPipelines(): void + { + const vertexBufferLayout = VERTEX_BUFFER_LAYOUT_4F; + + // fill_dynamicレイアウトを共有(hasDynamicOffset: true) + const dynamicLayout = this.bindGroupLayouts.get("fill_dynamic")!; + const stencilPipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [dynamicLayout] + }); + + const stencilWritePipeline = this.device.createRenderPipeline({ + "layout": stencilPipelineLayout, + "vertex": { + "module": this.getOrCreateShaderModule("stencilWriteVertex", ShaderSource.getStencilWriteVertexShader()), + "entryPoint": "main", + "buffers": [vertexBufferLayout] + }, + "fragment": { + "module": this.getOrCreateShaderModule("stencilWriteFragment", ShaderSource.getStencilWriteFragmentShader()), + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "writeMask": 0 + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none", + "frontFace": "ccw" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "always", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "increment-wrap" + }, + "stencilBack": { + "compare": "always", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "decrement-wrap" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0xFF + }, + "multisample": { + "count": this.sampleCount, + "alphaToCoverageEnabled": true + } + }); + this.pipelines.set("stencil_write", stencilWritePipeline); + const stencilFillPipeline = this.device.createRenderPipeline({ + "layout": stencilPipelineLayout, + "vertex": { + "module": this.getOrCreateShaderModule("stencilFillVertex", ShaderSource.getStencilFillVertexShader()), + "entryPoint": "main", + "buffers": [vertexBufferLayout] + }, + "fragment": { + "module": this.getOrCreateShaderModule("stencilFillFragment", ShaderSource.getStencilFillFragmentShader()), + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "not-equal", + "failOp": "keep", + "depthFailOp": "zero", + "passOp": "zero" + }, + "stencilBack": { + "compare": "not-equal", + "failOp": "keep", + "depthFailOp": "zero", + "passOp": "zero" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0xFF + }, + "multisample": { + "count": this.sampleCount + } + }); + this.pipelines.set("stencil_fill", stencilFillPipeline); + const stencilWritePipelineAtlas = this.device.createRenderPipeline({ + "layout": stencilPipelineLayout, + "vertex": { + "module": this.getOrCreateShaderModule("stencilWriteVertex", ShaderSource.getStencilWriteVertexShader()), + "entryPoint": "main", + "buffers": [vertexBufferLayout] + }, + "fragment": { + "module": this.getOrCreateShaderModule("stencilWriteFragment", ShaderSource.getStencilWriteFragmentShader()), + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "writeMask": 0 + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none", + "frontFace": "ccw" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "always", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "increment-wrap" + }, + "stencilBack": { + "compare": "always", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "decrement-wrap" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0xFF + }, + "multisample": { + "count": this.sampleCount, + "alphaToCoverageEnabled": true + } + }); + this.pipelines.set("stencil_write_atlas", stencilWritePipelineAtlas); + const stencilWritePipelineMain = this.device.createRenderPipeline({ + "layout": stencilPipelineLayout, + "vertex": { + "module": this.getOrCreateShaderModule("stencilWriteVertex", ShaderSource.getStencilWriteVertexShader()), + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": { "yFlipSign": -1.0 } + }, + "fragment": { + "module": this.getOrCreateShaderModule("stencilWriteFragment", ShaderSource.getStencilWriteFragmentShader()), + "entryPoint": "main", + "targets": [{ + "format": this.format, + "writeMask": 0 + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none", + "frontFace": "ccw" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "always", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "increment-wrap" + }, + "stencilBack": { + "compare": "always", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "decrement-wrap" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0xFF + }, + "multisample": { + "count": this.sampleCount, + "alphaToCoverageEnabled": true + } + }); + this.pipelines.set("stencil_write_main", stencilWritePipelineMain); + + const stencilFillPipelineAtlas = this.device.createRenderPipeline({ + "layout": stencilPipelineLayout, + "vertex": { + "module": this.getOrCreateShaderModule("stencilFillVertex", ShaderSource.getStencilFillVertexShader()), + "entryPoint": "main", + "buffers": [vertexBufferLayout] + }, + "fragment": { + "module": this.getOrCreateShaderModule("stencilFillFragment", ShaderSource.getStencilFillFragmentShader()), + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "one", + "operation": "max" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "not-equal", + "failOp": "keep", + "depthFailOp": "zero", + "passOp": "zero" + }, + "stencilBack": { + "compare": "not-equal", + "failOp": "keep", + "depthFailOp": "zero", + "passOp": "zero" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0xFF + }, + "multisample": { + "count": this.sampleCount + } + }); + this.pipelines.set("stencil_fill_atlas", stencilFillPipelineAtlas); + const stencilFillPipelineMain = this.device.createRenderPipeline({ + "layout": stencilPipelineLayout, + "vertex": { + "module": this.getOrCreateShaderModule("stencilFillVertex", ShaderSource.getStencilFillVertexShader()), + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": { "yFlipSign": -1.0 } + }, + "fragment": { + "module": this.getOrCreateShaderModule("stencilFillFragment", ShaderSource.getStencilFillFragmentShader()), + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "one", + "operation": "max" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "not-equal", + "failOp": "keep", + "depthFailOp": "zero", + "passOp": "zero" + }, + "stencilBack": { + "compare": "not-equal", + "failOp": "keep", + "depthFailOp": "zero", + "passOp": "zero" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0xFF + }, + "multisample": { + "count": this.sampleCount + } + }); + this.pipelines.set("stencil_fill_main", stencilFillPipelineMain); + const stencilFillMaskedPipeline = this.device.createRenderPipeline({ + "layout": stencilPipelineLayout, + "vertex": { + "module": this.getOrCreateShaderModule("stencilFillVertex", ShaderSource.getStencilFillVertexShader()), + "entryPoint": "main", + "buffers": [vertexBufferLayout] + }, + "fragment": { + "module": this.getOrCreateShaderModule("stencilFillFragment", ShaderSource.getStencilFillFragmentShader()), + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "greater", + "failOp": "keep", + "depthFailOp": "replace", + "passOp": "replace" + }, + "stencilBack": { + "compare": "greater", + "failOp": "keep", + "depthFailOp": "replace", + "passOp": "replace" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0xFF + }, + "multisample": { + "count": this.sampleCount + } + }); + this.pipelines.set("stencil_fill_masked", stencilFillMaskedPipeline); + } + + private createClipPipeline(): void + { + const vertexBufferLayout = VERTEX_BUFFER_LAYOUT_4F; + const dynamicLayout = this.bindGroupLayouts.get("fill_dynamic")!; + const clipPipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [dynamicLayout] + }); + const clipWritePipeline = this.device.createRenderPipeline({ + "layout": clipPipelineLayout, + "vertex": { + "module": this.getOrCreateShaderModule("stencilWriteVertex", ShaderSource.getStencilWriteVertexShader()), + "entryPoint": "main", + "buffers": [vertexBufferLayout] + }, + "fragment": { + "module": this.getOrCreateShaderModule("stencilWriteFragment", ShaderSource.getStencilWriteFragmentShader()), + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "writeMask": 0 + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "always", + "failOp": "zero", + "depthFailOp": "invert", + "passOp": "invert" + }, + "stencilBack": { + "compare": "always", + "failOp": "zero", + "depthFailOp": "invert", + "passOp": "invert" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0xFF + }, + "multisample": { + "count": this.sampleCount + } + }); + this.pipelines.set("clip_write", clipWritePipeline); + const vertexShaderModule = this.getOrCreateShaderModule("stencilWriteVertex", ShaderSource.getStencilWriteVertexShader()); + const fragmentShaderModule = this.getOrCreateShaderModule("stencilWriteFragment", ShaderSource.getStencilWriteFragmentShader()); + + for (let level = 1; level <= 8; level++) { + const stencilWriteMask = 1 << level - 1; + const clipWriteMainPipeline = this.device.createRenderPipeline({ + "layout": clipPipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": { "yFlipSign": -1.0 } + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "writeMask": 0 + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "always", + "failOp": "zero", + "depthFailOp": "invert", + "passOp": "invert" + }, + "stencilBack": { + "compare": "always", + "failOp": "zero", + "depthFailOp": "invert", + "passOp": "invert" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": stencilWriteMask + }, + "multisample": { + "count": this.sampleCount, + "alphaToCoverageEnabled": true + } + }); + this.pipelines.set(`clip_write_main_${level}`, clipWriteMainPipeline); + } + this.pipelines.set("clip_write_main", this.pipelines.get("clip_write_main_1")!); + for (let level = 1; level <= 8; level++) { + const stencilWriteMask = 1 << level - 1; + const clipClearMainPipeline = this.device.createRenderPipeline({ + "layout": clipPipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": { "yFlipSign": -1.0 } + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "writeMask": 0 + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "always", + "failOp": "replace", + "depthFailOp": "replace", + "passOp": "replace" + }, + "stencilBack": { + "compare": "always", + "failOp": "replace", + "depthFailOp": "replace", + "passOp": "replace" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": stencilWriteMask + }, + "multisample": { + "count": this.sampleCount + } + }); + this.pipelines.set(`clip_clear_main_${level}`, clipClearMainPipeline); + } + } + + private createMaskUnionPipelines(): void + { + const vertexBufferLayout = VERTEX_BUFFER_LAYOUT_4F; + const dynamicLayout = this.bindGroupLayouts.get("fill_dynamic")!; + const maskUnionPipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [dynamicLayout] + }); + + const vertexShaderModule = this.getOrCreateShaderModule("stencilWriteVertex", ShaderSource.getStencilWriteVertexShader()); + const fragmentShaderModule = this.getOrCreateShaderModule("stencilWriteFragment", ShaderSource.getStencilWriteFragmentShader()); + for (let level = 1; level <= 8; level++) { + const mask = 1 << level - 1; + const upperBitsMask = ~mask & 0xFF; + const mergePipeline = this.device.createRenderPipeline({ + "layout": maskUnionPipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": { "yFlipSign": -1.0 } + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "writeMask": 0 + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "less-equal", + "failOp": "zero", + "depthFailOp": "replace", + "passOp": "replace" + }, + "stencilBack": { + "compare": "less-equal", + "failOp": "zero", + "depthFailOp": "replace", + "passOp": "replace" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": upperBitsMask + } + }); + this.pipelines.set(`mask_union_merge_${level}`, mergePipeline); + const clearPipeline = this.device.createRenderPipeline({ + "layout": maskUnionPipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": { "yFlipSign": -1.0 } + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "writeMask": 0 + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "always", + "failOp": "replace", + "depthFailOp": "replace", + "passOp": "replace" + }, + "stencilBack": { + "compare": "always", + "failOp": "replace", + "depthFailOp": "replace", + "passOp": "replace" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 1 << level + } + }); + this.pipelines.set(`mask_union_clear_${level}`, clearPipeline); + } + } + + private createMaskPipeline(): void + { + const bindGroupLayout = this.device.createBindGroupLayout({ + "entries": [ + { + "binding": 0, + "visibility": GPUShaderStage.VERTEX, + "buffer": { "type": "uniform" } + } + ] + }); + + this.bindGroupLayouts.set("mask", bindGroupLayout); + + const pipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [bindGroupLayout] + }); + + const vertexShaderModule = this.getOrCreateShaderModule("maskVertex", ShaderSource.getMaskVertexShader()); + + const fragmentShaderModule = this.getOrCreateShaderModule("maskFragment", ShaderSource.getMaskFragmentShader()); + + const pipeline = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [{ + "arrayStride": 4 * 4, + "attributes": [ + { + "shaderLocation": 0, + "offset": 0, + "format": "float32x2" + }, + { + "shaderLocation": 1, + "offset": 2 * 4, + "format": "float32x2" + } + ] + }] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + } + }); + + this.pipelines.set("mask", pipeline); + } + + private createBasicPipeline(): void + { + const bindGroupLayout = this.device.createBindGroupLayout({ + "entries": [{ + "binding": 0, + "visibility": GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + "buffer": { "type": "uniform" } + }] + }); + + this.bindGroupLayouts.set("basic", bindGroupLayout); + + const pipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [bindGroupLayout] + }); + + const vertexShaderModule = this.getOrCreateShaderModule("basicVertex", ShaderSource.getBasicVertexShader()); + + const fragmentShaderModule = this.getOrCreateShaderModule("basicFragment", ShaderSource.getBasicFragmentShader()); + + const vertexBufferLayout: GPUVertexBufferLayout = { + "arrayStride": 4 * 4, + "attributes": [ + { + "shaderLocation": 0, + "offset": 0, + "format": "float32x2" + }, + { + "shaderLocation": 1, + "offset": 2 * 4, + "format": "float32x2" + } + ] + }; + + const blendState = BLEND_PREMULTIPLIED_ALPHA; + const pipelineRGBA = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "multisample": { + "count": this.sampleCount + } + }); + const pipelineBGRA = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": { "yFlipSign": -1.0 } + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + } + }); + + this.pipelines.set("basic", pipelineRGBA); + this.pipelines.set("basic_bgra", pipelineBGRA); + } + + private createTexturePipeline(): void + { + const bindGroupLayout = this.device.createBindGroupLayout({ + "entries": [ + { + "binding": 0, + "visibility": GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + "buffer": { "type": "uniform" } + }, + { + "binding": 1, + "visibility": GPUShaderStage.FRAGMENT, + "sampler": {} + }, + { + "binding": 2, + "visibility": GPUShaderStage.FRAGMENT, + "texture": {} + } + ] + }); + + this.bindGroupLayouts.set("texture", bindGroupLayout); + + const pipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [bindGroupLayout] + }); + + const vertexShaderModule = this.getOrCreateShaderModule("basicVertex", ShaderSource.getBasicVertexShader()); + + const fragmentShaderModule = this.getOrCreateShaderModule("textureFragment", ShaderSource.getTextureFragmentShader()); + + const pipeline = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [{ + "arrayStride": 4 * 4, + "attributes": [ + { + "shaderLocation": 0, + "offset": 0, + "format": "float32x2" + }, + { + "shaderLocation": 1, + "offset": 2 * 4, + "format": "float32x2" + } + ] + }] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + } + }); + + this.pipelines.set("texture", pipeline); + } + + private createInstancedPipeline(): void + { + const bindGroupLayout = this.device.createBindGroupLayout({ + "entries": [ + { + "binding": 0, + "visibility": GPUShaderStage.FRAGMENT, + "sampler": {} + }, + { + "binding": 1, + "visibility": GPUShaderStage.FRAGMENT, + "texture": {} + } + ] + }); + + this.bindGroupLayouts.set("instanced", bindGroupLayout); + + const pipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [bindGroupLayout] + }); + + const vertexShaderModule = this.getOrCreateShaderModule("instancedVertex", ShaderSource.getInstancedVertexShader()); + + const fragmentShaderModule = this.getOrCreateShaderModule("instancedFragment", ShaderSource.getInstancedFragmentShader()); + const instanceBufferLayout: GPUVertexBufferLayout = { + "arrayStride": 96, + "stepMode": "instance", + "attributes": [ + { + "shaderLocation": 2, + "offset": 0, + "format": "float32x4" + }, + { + "shaderLocation": 3, + "offset": 16, + "format": "float32x4" + }, + { + "shaderLocation": 4, + "offset": 32, + "format": "float32x4" + }, + { + "shaderLocation": 5, + "offset": 48, + "format": "float32x4" + }, + { + "shaderLocation": 6, + "offset": 64, + "format": "float32x4" + }, + { + "shaderLocation": 7, + "offset": 80, + "format": "float32x4" + } + ] + }; + + const pipeline = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [ + { + "arrayStride": 4 * 4, + "stepMode": "vertex", + "attributes": [ + { + "shaderLocation": 0, + "offset": 0, + "format": "float32x2" + }, + { + "shaderLocation": 1, + "offset": 2 * 4, + "format": "float32x2" + } + ] + }, + instanceBufferLayout + ] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "multisample": { + "count": this.sampleCount + } + }); + + this.pipelines.set("instanced", pipeline); + const vertexBuffers: GPUVertexBufferLayout[] = [ + { + "arrayStride": 4 * 4, + "stepMode": "vertex", + "attributes": [ + { "shaderLocation": 0, "offset": 0, "format": "float32x2" }, + { "shaderLocation": 1, "offset": 2 * 4, "format": "float32x2" } + ] + }, + instanceBufferLayout + ]; + + const blendVariants: [string, GPUBlendState][] = [ + ["instanced_add", { "color": { "srcFactor": "one", "dstFactor": "one", "operation": "add" }, "alpha": { "srcFactor": "one", "dstFactor": "one-minus-src-alpha", "operation": "add" } }], + ["instanced_screen", { "color": { "srcFactor": "one-minus-dst", "dstFactor": "one", "operation": "add" }, "alpha": { "srcFactor": "one", "dstFactor": "one-minus-src-alpha", "operation": "add" } }], + ["instanced_alpha", { "color": { "srcFactor": "zero", "dstFactor": "src-alpha", "operation": "add" }, "alpha": { "srcFactor": "zero", "dstFactor": "src-alpha", "operation": "add" } }], + ["instanced_erase", { "color": { "srcFactor": "zero", "dstFactor": "one-minus-src-alpha", "operation": "add" }, "alpha": { "srcFactor": "zero", "dstFactor": "one-minus-src-alpha", "operation": "add" } }], + ["instanced_copy", { "color": { "srcFactor": "one", "dstFactor": "zero", "operation": "add" }, "alpha": { "srcFactor": "one", "dstFactor": "zero", "operation": "add" } }] + ]; + + for (const [name, blend] of blendVariants) { + const variantPipeline = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": vertexBuffers + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ "format": this.format, "blend": blend }] + }, + "primitive": { "topology": "triangle-list", "cullMode": "none" }, + "multisample": { "count": this.sampleCount } + }); + this.pipelines.set(name, variantPipeline); + } + const maskedPipeline = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [ + { + "arrayStride": 4 * 4, + "stepMode": "vertex", + "attributes": [ + { "shaderLocation": 0, "offset": 0, "format": "float32x2" }, + { "shaderLocation": 1, "offset": 2 * 4, "format": "float32x2" } + ] + }, + instanceBufferLayout + ] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "equal", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + }, + "stencilBack": { + "compare": "equal", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0x00 + }, + "multisample": { + "count": this.sampleCount + } + }); + this.pipelines.set("instanced_masked", maskedPipeline); + } + + private createGradientPipeline(): void + { + const bindGroupLayout = this.device.createBindGroupLayout({ + "entries": [ + { + "binding": 0, + "visibility": GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + "buffer": { "type": "uniform" } + }, + { + "binding": 1, + "visibility": GPUShaderStage.FRAGMENT, + "sampler": {} + }, + { + "binding": 2, + "visibility": GPUShaderStage.FRAGMENT, + "texture": {} + } + ] + }); + + this.bindGroupLayouts.set("gradient_fill", bindGroupLayout); + + const pipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [bindGroupLayout] + }); + this.gradientPipelineLayout = pipelineLayout; + + const vertexShaderModule = this.getOrCreateShaderModule("gradientFillVertex", ShaderSource.getGradientFillVertexShader()); + this.gradientVertexShaderModule = vertexShaderModule; + + const fragmentShaderModule = this.getOrCreateShaderModule("gradientFillFragment", ShaderSource.getGradientFillFragmentShader()); + this.gradientFragmentShaderModule = fragmentShaderModule; + const stencilFragmentShaderModule = this.getOrCreateShaderModule("gradientFillStencilFragment", ShaderSource.getGradientFillStencilFragmentShader()); + this.gradientStencilFragmentShaderModule = stencilFragmentShaderModule; + + const vertexBufferLayout = VERTEX_BUFFER_LAYOUT_4F; + const blendState = BLEND_PREMULTIPLIED_ALPHA; + const pipelineRGBA = this.device.createRenderPipeline({ + "label": "gradient_fill_no_stencil_pipeline", + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "multisample": { + "count": this.sampleCount + } + }); + const pipelineBGRA = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": { "yFlipSign": -1.0 } + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "multisample": { + "count": this.sampleCount + } + }); + + this.pipelines.set("gradient_fill", pipelineRGBA); + this.pipelines.set("gradient_fill_no_stencil", pipelineRGBA); + this.pipelines.set("gradient_fill_bgra", pipelineBGRA); + const strokeStencilState: GPUDepthStencilState = { + "format": "stencil8", + "stencilFront": { + "compare": "always", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + }, + "stencilBack": { + "compare": "always", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + }, + "stencilReadMask": 0x00, + "stencilWriteMask": 0x00 + }; + const pipelineGradientStrokeAtlas = this.device.createRenderPipeline({ + "label": "gradient_stroke_atlas_pipeline", + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": strokeStencilState, + "multisample": { + "count": this.sampleCount + } + }); + this.pipelines.set("gradient_stroke_atlas", pipelineGradientStrokeAtlas); + const pipelineGradientStrokeBGRA = this.device.createRenderPipeline({ + "label": "gradient_stroke_bgra_pipeline", + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": { "yFlipSign": -1.0 } + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": strokeStencilState, + "multisample": { + "count": this.sampleCount + } + }); + this.pipelines.set("gradient_stroke_bgra", pipelineGradientStrokeBGRA); + const pipelineBGRA_noMSAA = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": { "yFlipSign": -1.0 } + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "multisample": { + "count": 1 + } + }); + this.pipelines.set("gradient_fill_bgra_no_msaa", pipelineBGRA_noMSAA); + const pipelineRGBAStencil = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "not-equal", + "failOp": "keep", + "depthFailOp": "zero", + "passOp": "zero" + }, + "stencilBack": { + "compare": "not-equal", + "failOp": "keep", + "depthFailOp": "zero", + "passOp": "zero" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0xFF + }, + "multisample": { + "count": this.sampleCount + } + }); + this.pipelines.set("gradient_fill_stencil", pipelineRGBAStencil); + const pipelineRGBAStencilAtlas = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout] + }, + "fragment": { + "module": stencilFragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "not-equal", + "failOp": "keep", + "depthFailOp": "zero", + "passOp": "zero" + }, + "stencilBack": { + "compare": "not-equal", + "failOp": "keep", + "depthFailOp": "zero", + "passOp": "zero" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0xFF + }, + "multisample": { + "count": this.sampleCount + } + }); + this.pipelines.set("gradient_fill_stencil_atlas", pipelineRGBAStencilAtlas); + const pipelineStencilMain = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": { "yFlipSign": -1.0 } + }, + "fragment": { + "module": stencilFragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "not-equal", + "failOp": "keep", + "depthFailOp": "zero", + "passOp": "zero" + }, + "stencilBack": { + "compare": "not-equal", + "failOp": "keep", + "depthFailOp": "zero", + "passOp": "zero" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0xFF + }, + "multisample": { + "count": 1 + } + }); + this.pipelines.set("gradient_fill_stencil_main", pipelineStencilMain); + const pipelineBGRAStencil = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": { "yFlipSign": -1.0 } + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "equal", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + }, + "stencilBack": { + "compare": "equal", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0x00 + }, + "multisample": { + "count": this.sampleCount + } + }); + this.pipelines.set("gradient_fill_bgra_stencil", pipelineBGRAStencil); + } + + private createBitmapFillPipeline(): void + { + const bindGroupLayout = this.device.createBindGroupLayout({ + "entries": [ + { + "binding": 0, + "visibility": GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + "buffer": { "type": "uniform" } + }, + { + "binding": 1, + "visibility": GPUShaderStage.FRAGMENT, + "sampler": {} + }, + { + "binding": 2, + "visibility": GPUShaderStage.FRAGMENT, + "texture": {} + } + ] + }); + + this.bindGroupLayouts.set("bitmap_fill", bindGroupLayout); + + const pipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [bindGroupLayout] + }); + + const vertexShaderModule = this.getOrCreateShaderModule("bitmapFillVertex", ShaderSource.getBitmapFillVertexShader()); + + const fragmentShaderModule = this.getOrCreateShaderModule("bitmapFillFragment", ShaderSource.getBitmapFillFragmentShader()); + + const vertexBufferLayout = VERTEX_BUFFER_LAYOUT_4F; + const blendState = BLEND_PREMULTIPLIED_ALPHA; + const pipelineRGBA = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "multisample": { + "count": this.sampleCount + } + }); + const pipelineBGRA = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": { "yFlipSign": -1.0 } + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + } + }); + + this.pipelines.set("bitmap_fill", pipelineRGBA); + this.pipelines.set("bitmap_fill_bgra", pipelineBGRA); + const bitmapStrokeStencilState: GPUDepthStencilState = { + "format": "stencil8", + "stencilFront": { + "compare": "always", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + }, + "stencilBack": { + "compare": "always", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + }, + "stencilReadMask": 0x00, + "stencilWriteMask": 0x00 + }; + const pipelineBitmapStrokeAtlas = this.device.createRenderPipeline({ + "label": "bitmap_stroke_atlas_pipeline", + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": bitmapStrokeStencilState, + "multisample": { + "count": this.sampleCount + } + }); + this.pipelines.set("bitmap_stroke_atlas", pipelineBitmapStrokeAtlas); + const pipelineBitmapStrokeBGRA = this.device.createRenderPipeline({ + "label": "bitmap_stroke_bgra_pipeline", + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": { "yFlipSign": -1.0 } + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": bitmapStrokeStencilState, + "multisample": { + "count": this.sampleCount + } + }); + this.pipelines.set("bitmap_stroke_bgra", pipelineBitmapStrokeBGRA); + const pipelineRGBAStencil = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "not-equal", + "failOp": "keep", + "depthFailOp": "zero", + "passOp": "zero" + }, + "stencilBack": { + "compare": "not-equal", + "failOp": "keep", + "depthFailOp": "zero", + "passOp": "zero" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0xFF + }, + "multisample": { + "count": this.sampleCount + } + }); + this.pipelines.set("bitmap_fill_stencil", pipelineRGBAStencil); + const pipelineBGRAStencil = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": { "yFlipSign": -1.0 } + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": blendState + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "equal", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + }, + "stencilBack": { + "compare": "equal", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0x00 + } + }); + this.pipelines.set("bitmap_fill_bgra_stencil", pipelineBGRAStencil); + } + + private createBlendPipeline(): void + { + const bindGroupLayout = this.device.createBindGroupLayout({ + "entries": [ + { + "binding": 0, + "visibility": GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + "buffer": { "type": "uniform" } + }, + { + "binding": 1, + "visibility": GPUShaderStage.FRAGMENT, + "buffer": { "type": "uniform" } + }, + { + "binding": 2, + "visibility": GPUShaderStage.FRAGMENT, + "sampler": {} + }, + { + "binding": 3, + "visibility": GPUShaderStage.FRAGMENT, + "texture": {} + }, + { + "binding": 4, + "visibility": GPUShaderStage.FRAGMENT, + "sampler": {} + }, + { + "binding": 5, + "visibility": GPUShaderStage.FRAGMENT, + "texture": {} + } + ] + }); + + this.bindGroupLayouts.set("blend", bindGroupLayout); + + const pipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [bindGroupLayout] + }); + + const vertexShaderModule = this.getOrCreateShaderModule("basicVertex", ShaderSource.getBasicVertexShader()); + + const fragmentShaderModule = this.getOrCreateShaderModule("blendFragment", ShaderSource.getBlendFragmentShader()); + + const pipeline = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [{ + "arrayStride": 4 * 4, + "attributes": [ + { + "shaderLocation": 0, + "offset": 0, + "format": "float32x2" + }, + { + "shaderLocation": 1, + "offset": 2 * 4, + "format": "float32x2" + } + ] + }] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + } + }); + + this.pipelines.set("blend", pipeline); + } + + private createBlurFilterPipeline(): void + { + const bindGroupLayout = this.device.createBindGroupLayout({ + "entries": [ + { + "binding": 0, + "visibility": GPUShaderStage.FRAGMENT, + "buffer": { "type": "uniform" } + }, + { + "binding": 1, + "visibility": GPUShaderStage.FRAGMENT, + "sampler": {} + }, + { + "binding": 2, + "visibility": GPUShaderStage.FRAGMENT, + "texture": {} + } + ] + }); + + this.bindGroupLayouts.set("blur_filter", bindGroupLayout); + const vertexShaderModule = this.getOrCreateShaderModule("blurFilterVertex", ShaderSource.getBlurFilterVertexShader()); + + const pipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [bindGroupLayout] + }); + + for (let halfBlur = 1; halfBlur <= 16; halfBlur++) { + const fragmentShaderModule = this.getOrCreateShaderModule(`blurFilterFragment_${halfBlur}`, ShaderSource.getBlurFilterFragmentShader(halfBlur)); + const pipeline = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + } + }); + + this.pipelines.set(`blur_filter_${halfBlur}`, pipeline); + } + } + + private createTextureCopyPipeline(): void + { + const bindGroupLayout = this.device.createBindGroupLayout({ + "entries": [ + { + "binding": 0, + "visibility": GPUShaderStage.FRAGMENT, + "buffer": { "type": "uniform" } + }, + { + "binding": 1, + "visibility": GPUShaderStage.FRAGMENT, + "sampler": {} + }, + { + "binding": 2, + "visibility": GPUShaderStage.FRAGMENT, + "texture": {} + } + ] + }); + + this.bindGroupLayouts.set("texture_copy", bindGroupLayout); + + const pipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [bindGroupLayout] + }); + + const vertexShaderModule = this.getOrCreateShaderModule("blurFilterVertex", ShaderSource.getBlurFilterVertexShader()); + + const fragmentShaderModule = this.getOrCreateShaderModule("textureCopyFragment", ShaderSource.getTextureCopyFragmentShader()); + + const BLEND_REPLACE: GPUBlendState = { + "color": { "srcFactor": "one", "dstFactor": "zero", "operation": "add" }, + "alpha": { "srcFactor": "one", "dstFactor": "zero", "operation": "add" } + }; + const BLEND_ALPHA: GPUBlendState = { + "color": { "srcFactor": "one", "dstFactor": "one-minus-src-alpha", "operation": "add" }, + "alpha": { "srcFactor": "one", "dstFactor": "one-minus-src-alpha", "operation": "add" } + }; + const BLEND_ERASE: GPUBlendState = { + "color": { "srcFactor": "zero", "dstFactor": "one-minus-src-alpha", "operation": "add" }, + "alpha": { "srcFactor": "zero", "dstFactor": "one-minus-src-alpha", "operation": "add" } + }; + + this.pipelines.set("texture_copy", this.createFullscreenQuadPipeline( + pipelineLayout, vertexShaderModule, fragmentShaderModule, this.format, BLEND_REPLACE + )); + this.pipelines.set("texture_copy_rgba8", this.createFullscreenQuadPipeline( + pipelineLayout, vertexShaderModule, fragmentShaderModule, "rgba8unorm", BLEND_REPLACE + )); + const colorTransformFragmentModule = this.getOrCreateShaderModule("colorTransformFragment", ShaderSource.getColorTransformFragmentShader()); + this.pipelines.set("color_transform", this.createFullscreenQuadPipeline( + pipelineLayout, vertexShaderModule, colorTransformFragmentModule, "rgba8unorm", BLEND_REPLACE + )); + const yFlipCTFragmentModule = this.getOrCreateShaderModule("yFlipColorTransformFragment", ShaderSource.getYFlipColorTransformFragmentShader()); + this.pipelines.set("y_flip_color_transform", this.createFullscreenQuadPipeline( + pipelineLayout, vertexShaderModule, yFlipCTFragmentModule, "rgba8unorm", BLEND_REPLACE + )); + const blurCopyFragmentModule = this.getOrCreateShaderModule("blurTextureCopyFragment", ShaderSource.getBlurTextureCopyFragmentShader()); + this.pipelines.set("texture_erase", this.createFullscreenQuadPipeline( + pipelineLayout, vertexShaderModule, blurCopyFragmentModule, "rgba8unorm", BLEND_ERASE + )); + this.pipelines.set("blur_texture_copy", this.createFullscreenQuadPipeline( + pipelineLayout, vertexShaderModule, blurCopyFragmentModule, "rgba8unorm", BLEND_REPLACE + )); + this.pipelines.set("filter_blend", this.createFullscreenQuadPipeline( + pipelineLayout, vertexShaderModule, fragmentShaderModule, this.format, BLEND_ALPHA + )); + this.pipelines.set("texture_copy_bgra", this.createFullscreenQuadPipeline( + pipelineLayout, vertexShaderModule, fragmentShaderModule, this.format, BLEND_REPLACE + )); + const filterOutputShaderModule = this.getOrCreateShaderModule("filterOutputFragment", ShaderSource.getFilterOutputFragmentShader()); + this.pipelines.set("filter_output", this.createFullscreenQuadPipeline( + pipelineLayout, vertexShaderModule, filterOutputShaderModule, this.format, BLEND_ALPHA + )); + const filterOutputBlendVariants: [string, GPUBlendState][] = [ + ["filter_output_add", { "color": { "srcFactor": "one", "dstFactor": "one", "operation": "add" }, "alpha": { "srcFactor": "one", "dstFactor": "one", "operation": "add" } }], + ["filter_output_screen", { "color": { "srcFactor": "one", "dstFactor": "one-minus-src", "operation": "add" }, "alpha": { "srcFactor": "one", "dstFactor": "one-minus-src-alpha", "operation": "add" } }], + ["filter_output_alpha", { "color": { "srcFactor": "zero", "dstFactor": "src-alpha", "operation": "add" }, "alpha": { "srcFactor": "zero", "dstFactor": "src-alpha", "operation": "add" } }], + ["filter_output_erase", { "color": { "srcFactor": "zero", "dstFactor": "one-minus-src-alpha", "operation": "add" }, "alpha": { "srcFactor": "zero", "dstFactor": "one-minus-src-alpha", "operation": "add" } }] + ]; + + for (const [name, blend] of filterOutputBlendVariants) { + this.pipelines.set(name, this.createFullscreenQuadPipeline( + pipelineLayout, vertexShaderModule, filterOutputShaderModule, this.format, blend + )); + } + if (this.sampleCount > 1) { + const copyBlend: GPUBlendState = { "color": { "srcFactor": "one", "dstFactor": "zero", "operation": "add" }, "alpha": { "srcFactor": "one", "dstFactor": "zero", "operation": "add" } }; + this.pipelines.set("texture_copy_bgra_msaa", this.createFullscreenQuadPipeline( + pipelineLayout, vertexShaderModule, fragmentShaderModule, this.format, copyBlend, this.sampleCount + )); + const normalBlend: GPUBlendState = { "color": { "srcFactor": "one", "dstFactor": "one-minus-src-alpha", "operation": "add" }, "alpha": { "srcFactor": "one", "dstFactor": "one-minus-src-alpha", "operation": "add" } }; + this.pipelines.set("filter_output_msaa", this.createFullscreenQuadPipeline( + pipelineLayout, vertexShaderModule, filterOutputShaderModule, this.format, normalBlend, this.sampleCount + )); + for (const [name, blend] of filterOutputBlendVariants) { + this.pipelines.set(`${name}_msaa`, this.createFullscreenQuadPipeline( + pipelineLayout, vertexShaderModule, filterOutputShaderModule, this.format, blend, this.sampleCount + )); + } + } + this.createPositionedTexturePipeline(); + this.createTextureScalePipeline(); + } + + private createPositionedTexturePipeline(): void + { + const bindGroupLayout = this.device.createBindGroupLayout({ + "entries": [ + { + "binding": 0, + "visibility": GPUShaderStage.VERTEX, + "buffer": { "type": "uniform" } + }, + { + "binding": 1, + "visibility": GPUShaderStage.FRAGMENT, + "sampler": {} + }, + { + "binding": 2, + "visibility": GPUShaderStage.FRAGMENT, + "texture": {} + } + ] + }); + + this.bindGroupLayouts.set("positioned_texture", bindGroupLayout); + + const pipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [bindGroupLayout] + }); + + const vertexShaderModule = this.getOrCreateShaderModule("positionedTextureVertex", ShaderSource.getPositionedTextureVertexShader()); + + const fragmentShaderModule = this.getOrCreateShaderModule("positionedTextureFragment", ShaderSource.getPositionedTextureFragmentShader()); + const pipeline = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": this.format, + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + } + }); + this.pipelines.set("positioned_texture", pipeline); + const pipelineRGBA = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "one-minus-src-alpha", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + } + }); + this.pipelines.set("positioned_texture_rgba", pipelineRGBA); + const pipelineMsaa = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "multisample": { + "count": 4 + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "always", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + }, + "stencilBack": { + "compare": "always", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + } + } + }); + this.pipelines.set("bitmap_render_msaa", pipelineMsaa); + const pipelineNonMsaa = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "always", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + }, + "stencilBack": { + "compare": "always", + "failOp": "keep", + "depthFailOp": "keep", + "passOp": "keep" + } + } + }); + this.pipelines.set("bitmap_render", pipelineNonMsaa); + } + + private createTextureScalePipeline(): void + { + const bindGroupLayout = this.device.createBindGroupLayout({ + "entries": [ + { + "binding": 0, + "visibility": GPUShaderStage.VERTEX, + "buffer": { "type": "uniform" } + }, + { + "binding": 1, + "visibility": GPUShaderStage.FRAGMENT, + "sampler": {} + }, + { + "binding": 2, + "visibility": GPUShaderStage.FRAGMENT, + "texture": {} + } + ] + }); + + this.bindGroupLayouts.set("texture_scale", bindGroupLayout); + + const pipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [bindGroupLayout] + }); + + const vertexShaderModule = this.getOrCreateShaderModule("textureScaleVertex", ShaderSource.getTextureScaleVertexShader()); + + const fragmentShaderModule = this.getOrCreateShaderModule("positionedTextureFragment", ShaderSource.getPositionedTextureFragmentShader()); + const pipeline = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + } + }); + this.pipelines.set("texture_scale", pipeline); + const blendVertexShaderModule = this.getOrCreateShaderModule("textureScaleBlendVertex", ShaderSource.getTextureScaleBlendVertexShader()); + + const blendPipeline = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": blendVertexShaderModule, + "entryPoint": "main", + "buffers": [] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + } + }); + this.pipelines.set("texture_scale_blend", blendPipeline); + } + + private createBitmapSyncPipeline(): void + { + const bindGroupLayout = this.device.createBindGroupLayout({ + "entries": [ + { + "binding": 0, + "visibility": GPUShaderStage.VERTEX, + "buffer": { "type": "uniform" } + }, + { + "binding": 1, + "visibility": GPUShaderStage.FRAGMENT, + "sampler": { "type": "filtering" } + }, + { + "binding": 2, + "visibility": GPUShaderStage.FRAGMENT, + "texture": { "sampleType": "float" } + } + ] + }); + this.bindGroupLayouts.set("bitmap_sync", bindGroupLayout); + + const pipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [bindGroupLayout] + }); + const vertexShaderModule = this.getOrCreateShaderModule("bitmapSyncVertex", ShaderSource.getBitmapSyncVertexShader()); + const fragmentShaderModule = this.getOrCreateShaderModule("bitmapSyncFragment", ShaderSource.getBitmapSyncFragmentShader()); + const pipeline = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "multisample": { + "count": 4 + } + }); + this.pipelines.set("bitmap_sync", pipeline); + } + + private createColorMatrixFilterPipeline(): void + { + const BLEND_REPLACE: GPUBlendState = { + "color": { "srcFactor": "one", "dstFactor": "zero", "operation": "add" }, + "alpha": { "srcFactor": "one", "dstFactor": "zero", "operation": "add" } + }; + const BLEND_ALPHA: GPUBlendState = { + "color": { "srcFactor": "one", "dstFactor": "one-minus-src-alpha", "operation": "add" }, + "alpha": { "srcFactor": "one", "dstFactor": "one-minus-src-alpha", "operation": "add" } + }; + this.createFilterPipelineWithLayout("color_matrix_filter", ShaderSource.getColorMatrixFilterFragmentShader(), 1, BLEND_REPLACE); + this.createFilterPipelineWithLayout("bevel_base", ShaderSource.getBevelBaseFragmentShader(), 1, BLEND_REPLACE); + this.createFilterPipelineWithLayout("glow_filter", ShaderSource.getGlowFilterFragmentShader(), 2, BLEND_ALPHA); + this.createFilterPipelineWithLayout("drop_shadow_filter", ShaderSource.getDropShadowFilterFragmentShader(), 2, BLEND_ALPHA); + this.createFilterPipelineWithLayout("bevel_filter", ShaderSource.getBevelFilterFragmentShader(), 2, BLEND_ALPHA); + this.createFilterPipelineWithLayout("gradient_glow_filter", ShaderSource.getGradientGlowFilterFragmentShader(), 3, BLEND_ALPHA); + this.createFilterPipelineWithLayout("gradient_bevel_filter", ShaderSource.getGradientBevelFilterFragmentShader(), 3, BLEND_ALPHA); + } + + private createComplexBlendPipelines(): void + { + const bindGroupLayout = this.device.createBindGroupLayout({ + "entries": [ + { + "binding": 0, + "visibility": GPUShaderStage.FRAGMENT, + "buffer": { "type": "uniform" } + }, + { + "binding": 1, + "visibility": GPUShaderStage.FRAGMENT, + "sampler": {} + }, + { + "binding": 2, + "visibility": GPUShaderStage.FRAGMENT, + "texture": {} + }, + { + "binding": 3, + "visibility": GPUShaderStage.FRAGMENT, + "texture": {} + } + ] + }); + + this.bindGroupLayouts.set("complex_blend", bindGroupLayout); + + const pipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [bindGroupLayout] + }); + const vertexShaderModule = this.getOrCreateShaderModule("complexBlendVertex", ShaderSource.getComplexBlendVertexShader()); + const fragmentShaderModule = this.getOrCreateShaderModule("unifiedComplexBlendFragment", ShaderSource.getUnifiedComplexBlendFragmentShader()); + + const pipeline = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexShaderModule, + "entryPoint": "main", + "buffers": [] + }, + "fragment": { + "module": fragmentShaderModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + } + } + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + } + }); + + this.pipelines.set("complex_blend", pipeline); + this.createComplexBlendCopyPipeline(); + this.createComplexBlendOutputPipeline(); + } + + private createComplexBlendCopyPipeline(): void + { + const BLEND_REPLACE: GPUBlendState = { + "color": { "srcFactor": "one", "dstFactor": "zero", "operation": "add" }, + "alpha": { "srcFactor": "one", "dstFactor": "zero", "operation": "add" } + }; + const copyLayout = this.bindGroupLayouts.get("texture_copy"); + if (copyLayout) { + const copyPipelineLayout = this.device.createPipelineLayout({ "bindGroupLayouts": [copyLayout] }); + const copyVS = this.getOrCreateShaderModule("complexBlendCopyVertex", ShaderSource.getComplexBlendCopyVertexShader()); + const copyFS = this.getOrCreateShaderModule("textureCopyFragment", ShaderSource.getTextureCopyFragmentShader()); + this.pipelines.set("complex_blend_copy", this.createFullscreenQuadPipeline( + copyPipelineLayout, copyVS, copyFS, "rgba8unorm", BLEND_REPLACE + )); + } + const scaleLayout = this.bindGroupLayouts.get("texture_scale"); + if (scaleLayout) { + const scalePipelineLayout = this.device.createPipelineLayout({ "bindGroupLayouts": [scaleLayout] }); + const scaleVS = this.getOrCreateShaderModule("complexBlendScaleVertex", ShaderSource.getComplexBlendScaleVertexShader()); + const scaleFS = this.getOrCreateShaderModule("positionedTextureFragment", ShaderSource.getPositionedTextureFragmentShader()); + this.pipelines.set("complex_blend_scale", this.createFullscreenQuadPipeline( + scalePipelineLayout, scaleVS, scaleFS, "rgba8unorm", BLEND_REPLACE + )); + } + } + + private createComplexBlendOutputPipeline(): void + { + const bindGroupLayout = this.bindGroupLayouts.get("positioned_texture"); + if (!bindGroupLayout) { + return; + } + + const pipelineLayout = this.device.createPipelineLayout({ "bindGroupLayouts": [bindGroupLayout] }); + const fragmentShaderModule = this.getOrCreateShaderModule("positionedTextureFragment", ShaderSource.getPositionedTextureFragmentShader()); + const blend: GPUBlendState = { + "color": { "srcFactor": "one", "dstFactor": "one-minus-src-alpha", "operation": "add" }, + "alpha": { "srcFactor": "one", "dstFactor": "one-minus-src-alpha", "operation": "add" } + }; + const blendOutputVS = this.getOrCreateShaderModule("complexBlendOutputVertex", ShaderSource.getComplexBlendOutputVertexShader()); + this.pipelines.set("complex_blend_output", this.createFullscreenQuadPipeline( + pipelineLayout, blendOutputVS, fragmentShaderModule, this.format, blend + )); + if (this.sampleCount > 1) { + this.pipelines.set("complex_blend_output_msaa", this.createFullscreenQuadPipeline( + pipelineLayout, blendOutputVS, fragmentShaderModule, this.format, blend, this.sampleCount + )); + } + const filterBlendOutputVS = this.getOrCreateShaderModule("filterComplexBlendOutputVertex", ShaderSource.getFilterComplexBlendOutputVertexShader()); + this.pipelines.set("filter_complex_blend_output", this.createFullscreenQuadPipeline( + pipelineLayout, filterBlendOutputVS, fragmentShaderModule, this.format, blend + )); + if (this.sampleCount > 1) { + this.pipelines.set("filter_complex_blend_output_msaa", this.createFullscreenQuadPipeline( + pipelineLayout, filterBlendOutputVS, fragmentShaderModule, this.format, blend, this.sampleCount + )); + } + } + + private createFilterPipelineWithLayout( + name: string, + fragmentShaderCode: string, + textureCount: number, + blend: GPUBlendState + ): void + { + let bindGroupLayout = this.filterBindGroupLayouts.get(textureCount); + if (!bindGroupLayout) { + const entries: GPUBindGroupLayoutEntry[] = [ + { "binding": 0, "visibility": GPUShaderStage.FRAGMENT, "buffer": { "type": "uniform" } }, + { "binding": 1, "visibility": GPUShaderStage.FRAGMENT, "sampler": {} } + ]; + for (let i = 0; i < textureCount; i++) { + entries.push({ "binding": 2 + i, "visibility": GPUShaderStage.FRAGMENT, "texture": {} }); + } + bindGroupLayout = this.device.createBindGroupLayout({ "entries": entries }); + this.filterBindGroupLayouts.set(textureCount, bindGroupLayout); + } + + this.bindGroupLayouts.set(name, bindGroupLayout); + + const pipelineLayout = this.device.createPipelineLayout({ "bindGroupLayouts": [bindGroupLayout] }); + const vertexShaderModule = this.getOrCreateShaderModule("blurFilterVertex", ShaderSource.getBlurFilterVertexShader()); + const fragmentShaderModule = this.getOrCreateShaderModule(`filter_${name}`, fragmentShaderCode); + + this.pipelines.set(name, this.createFullscreenQuadPipeline( + pipelineLayout, vertexShaderModule, fragmentShaderModule, "rgba8unorm", blend + )); + } + + private createFullscreenQuadPipeline( + pipelineLayout: GPUPipelineLayout, + vertexModule: GPUShaderModule, + fragmentModule: GPUShaderModule, + format: GPUTextureFormat, + blend: GPUBlendState, + multisampleCount?: number, + depthStencil?: GPUDepthStencilState + ): GPURenderPipeline + { + const descriptor: GPURenderPipelineDescriptor = { + "layout": pipelineLayout, + "vertex": { + "module": vertexModule, + "entryPoint": "main", + "buffers": [] + }, + "fragment": { + "module": fragmentModule, + "entryPoint": "main", + "targets": [{ "format": format, "blend": blend }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + } + }; + + if (multisampleCount && multisampleCount > 1) { + descriptor.multisample = { "count": multisampleCount }; + } + + if (depthStencil) { + descriptor.depthStencil = depthStencil; + } + + return this.device.createRenderPipeline(descriptor); + } + + getPipeline(name: string): GPURenderPipeline | undefined + { + let pipeline = this.pipelines.get(name); + if (!pipeline) { + this.ensureLazyGroup(name); + pipeline = this.pipelines.get(name); + } + return pipeline; + } + + /** + * @description フィルターパイプラインのoverride定数バリアントを取得 + * GPU warp divergenceを排除するコンパイル時分岐特殊化 + */ + getFilterPipeline(baseName: string, constants: Record): GPURenderPipeline | undefined + { + // キャッシュキーを生成 + const keys = Object.keys(constants).sort(); + const suffix = keys.map((k) => `${k}${constants[k]}`).join("_"); + const cacheKey = `${baseName}_${suffix}`; + + let pipeline = this.pipelines.get(cacheKey); + if (pipeline) { + return pipeline; + } + + // ベースグループのロードを確保 + this.ensureLazyGroup(baseName); + + const fragmentModule = this.shaderModuleCache.get(`filter_${baseName}`); + const vertexModule = this.shaderModuleCache.get("blurFilterVertex"); + const bindGroupLayout = this.bindGroupLayouts.get(baseName); + + if (!fragmentModule || !vertexModule || !bindGroupLayout) { + return this.pipelines.get(baseName); + } + + const pipelineLayout = this.device.createPipelineLayout({ + "bindGroupLayouts": [bindGroupLayout] + }); + + pipeline = this.device.createRenderPipeline({ + "layout": pipelineLayout, + "vertex": { + "module": vertexModule, + "entryPoint": "main", + "buffers": [] + }, + "fragment": { + "module": fragmentModule, + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": { + "color": { "srcFactor": "one", "dstFactor": "one-minus-src-alpha", "operation": "add" }, + "alpha": { "srcFactor": "one", "dstFactor": "one-minus-src-alpha", "operation": "add" } + } + }], + "constants": constants + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + } + }); + + this.pipelines.set(cacheKey, pipeline); + return pipeline; + } + + /** + * @description グラデーションタイプとスプレッドモードに応じた特殊化パイプラインを取得 + * override定数でGPU warp divergenceを排除 + */ + getGradientPipeline(baseName: string, gradientType: number, spreadMode: number): GPURenderPipeline | undefined + { + const key = `${baseName}_t${gradientType}s${spreadMode}`; + let pipeline = this.pipelines.get(key); + if (pipeline) { + return pipeline; + } + + if (!this.gradientPipelineLayout) { + return this.getPipeline(baseName); + } + + // ベースパイプラインと同じ構成でoverride定数を変えて作成 + pipeline = this.createGradientVariant(baseName, gradientType, spreadMode); + if (pipeline) { + this.pipelines.set(key, pipeline); + return pipeline; + } + + // フォールバック: デフォルト定数のベースパイプラインを使用 + return this.getPipeline(baseName); + } + + private gradientPipelineLayout: GPUPipelineLayout | null = null; + private gradientVertexShaderModule: GPUShaderModule | null = null; + private gradientFragmentShaderModule: GPUShaderModule | null = null; + private gradientStencilFragmentShaderModule: GPUShaderModule | null = null; + + private createGradientVariant(baseName: string, gradientType: number, spreadMode: number): GPURenderPipeline | undefined + { + if (!this.gradientPipelineLayout) { + return undefined; + } + + const constants = { + "GRADIENT_TYPE": gradientType, + "SPREAD_MODE": spreadMode + }; + + const vertexBufferLayout = VERTEX_BUFFER_LAYOUT_4F; + const blendState = BLEND_PREMULTIPLIED_ALPHA; + + // ベース名からパイプライン構成を決定 + const isStencilFragment = baseName.includes("stencil_atlas") || baseName === "gradient_fill_stencil_main"; + const fragModule = isStencilFragment ? this.gradientStencilFragmentShaderModule! : this.gradientFragmentShaderModule!; + const isBGRA = baseName.includes("bgra") || baseName === "gradient_fill_stencil_main"; + const format: GPUTextureFormat = isBGRA ? this.format : "rgba8unorm"; + const needsYFlip = baseName.includes("bgra") || baseName === "gradient_fill_stencil_main"; + + const vertexConstants: Record = {}; + if (needsYFlip) { + vertexConstants.yFlipSign = -1.0; + } + + let depthStencil: GPUDepthStencilState | undefined; + let sampleCount = this.sampleCount; + + if (baseName.includes("stroke")) { + depthStencil = { + "format": "stencil8", + "stencilFront": { "compare": "always", "failOp": "keep", "depthFailOp": "keep", "passOp": "keep" }, + "stencilBack": { "compare": "always", "failOp": "keep", "depthFailOp": "keep", "passOp": "keep" }, + "stencilReadMask": 0x00, + "stencilWriteMask": 0x00 + }; + } else if (baseName === "gradient_fill_stencil" || baseName === "gradient_fill_stencil_atlas") { + depthStencil = { + "format": "stencil8", + "stencilFront": { "compare": "not-equal", "failOp": "keep", "depthFailOp": "zero", "passOp": "zero" }, + "stencilBack": { "compare": "not-equal", "failOp": "keep", "depthFailOp": "zero", "passOp": "zero" }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0xFF + }; + } else if (baseName === "gradient_fill_stencil_main") { + depthStencil = { + "format": "stencil8", + "stencilFront": { "compare": "not-equal", "failOp": "keep", "depthFailOp": "zero", "passOp": "zero" }, + "stencilBack": { "compare": "not-equal", "failOp": "keep", "depthFailOp": "zero", "passOp": "zero" }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0xFF + }; + sampleCount = 1; + } else if (baseName === "gradient_fill_bgra_stencil") { + depthStencil = { + "format": "stencil8", + "stencilFront": { "compare": "equal", "failOp": "keep", "depthFailOp": "keep", "passOp": "keep" }, + "stencilBack": { "compare": "equal", "failOp": "keep", "depthFailOp": "keep", "passOp": "keep" }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0x00 + }; + } else if (baseName === "gradient_fill_bgra_no_msaa") { + sampleCount = 1; + } + + const descriptor: GPURenderPipelineDescriptor = { + "layout": this.gradientPipelineLayout, + "vertex": { + "module": this.gradientVertexShaderModule!, + "entryPoint": "main", + "buffers": [vertexBufferLayout], + "constants": Object.keys(vertexConstants).length > 0 ? vertexConstants : undefined + }, + "fragment": { + "module": fragModule, + "entryPoint": "main", + "targets": [{ "format": format, "blend": blendState }], + "constants": constants + }, + "primitive": { "topology": "triangle-list", "cullMode": "none" }, + "multisample": { "count": sampleCount } + }; + + if (depthStencil) { + descriptor.depthStencil = depthStencil; + } + + return this.device.createRenderPipeline(descriptor); + } + + getBindGroupLayout(name: string): GPUBindGroupLayout | undefined + { + let layout = this.bindGroupLayouts.get(name); + if (!layout) { + this.ensureLazyGroup(name); + layout = this.bindGroupLayouts.get(name); + } + return layout; + } + + private createNodeClearPipeline(): void + { + const vertexBufferLayout: GPUVertexBufferLayout = { + "arrayStride": 2 * 4, + "attributes": [ + { + "shaderLocation": 0, + "offset": 0, + "format": "float32x2" + } + ] + }; + const nodeClearPipeline = this.device.createRenderPipeline({ + "layout": "auto", + "vertex": { + "module": this.getOrCreateShaderModule("nodeClearVertex", ShaderSource.getNodeClearVertexShader()), + "entryPoint": "main", + "buffers": [vertexBufferLayout] + }, + "fragment": { + "module": this.getOrCreateShaderModule("nodeClearFragment", ShaderSource.getNodeClearFragmentShader()), + "entryPoint": "main", + "targets": [{ + "format": "rgba8unorm", + "blend": { + "color": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + }, + "alpha": { + "srcFactor": "one", + "dstFactor": "zero", + "operation": "add" + } + }, + "writeMask": GPUColorWrite.ALL + }] + }, + "primitive": { + "topology": "triangle-list", + "cullMode": "none" + }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { + "compare": "always", + "failOp": "zero", + "depthFailOp": "zero", + "passOp": "zero" + }, + "stencilBack": { + "compare": "always", + "failOp": "zero", + "depthFailOp": "zero", + "passOp": "zero" + }, + "stencilReadMask": 0xFF, + "stencilWriteMask": 0xFF + }, + "multisample": { + "count": this.sampleCount + } + }); + this.pipelines.set("node_clear_atlas", nodeClearPipeline); + } + + dispose(): void + { + this.pipelines.clear(); + this.bindGroupLayouts.clear(); + this.shaderModuleCache.clear(); + this.filterBindGroupLayouts.clear(); + } +} diff --git a/packages/webgpu/src/Shader/ShaderInstancedManager.test.ts b/packages/webgpu/src/Shader/ShaderInstancedManager.test.ts new file mode 100644 index 00000000..f856697d --- /dev/null +++ b/packages/webgpu/src/Shader/ShaderInstancedManager.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ShaderInstancedManager } from "./ShaderInstancedManager"; + +// Mock render-queue +vi.mock("@next2d/render-queue", () => ({ + "renderQueue": { + "offset": 0 + } +})); + +import { renderQueue } from "@next2d/render-queue"; + +describe("ShaderInstancedManager", () => +{ + beforeEach(() => + { + vi.clearAllMocks(); + (renderQueue as any).offset = 0; + }); + + describe("constructor", () => + { + it("should initialize count to 0", () => + { + const manager = new ShaderInstancedManager(); + + expect(manager.count).toBe(0); + }); + }); + + describe("count", () => + { + it("should be mutable", () => + { + const manager = new ShaderInstancedManager(); + + manager.count = 10; + expect(manager.count).toBe(10); + + manager.count = 100; + expect(manager.count).toBe(100); + }); + }); + + describe("clear", () => + { + it("should reset count to 0", () => + { + const manager = new ShaderInstancedManager(); + manager.count = 50; + + manager.clear(); + + expect(manager.count).toBe(0); + }); + + it("should reset renderQueue offset to 0", () => + { + const manager = new ShaderInstancedManager(); + (renderQueue as any).offset = 100; + + manager.clear(); + + expect(renderQueue.offset).toBe(0); + }); + + it("should reset both count and offset simultaneously", () => + { + const manager = new ShaderInstancedManager(); + manager.count = 25; + (renderQueue as any).offset = 50; + + manager.clear(); + + expect(manager.count).toBe(0); + expect(renderQueue.offset).toBe(0); + }); + }); +}); diff --git a/packages/webgpu/src/Shader/ShaderInstancedManager.ts b/packages/webgpu/src/Shader/ShaderInstancedManager.ts new file mode 100644 index 00000000..b2d3d233 --- /dev/null +++ b/packages/webgpu/src/Shader/ShaderInstancedManager.ts @@ -0,0 +1,19 @@ +import { renderQueue } from "@next2d/render-queue"; + +/** + * @description WebGPU用インスタンスシェーダーマネージャー + */ +export class ShaderInstancedManager +{ + public count: number; + + constructor() + { + this.count = 0; + } + + clear(): void + { + this.count = renderQueue.offset = 0; + } +} diff --git a/packages/webgpu/src/Shader/ShaderSource.test.ts b/packages/webgpu/src/Shader/ShaderSource.test.ts new file mode 100644 index 00000000..2959403f --- /dev/null +++ b/packages/webgpu/src/Shader/ShaderSource.test.ts @@ -0,0 +1,1089 @@ +import { describe, it, expect } from "vitest"; +import { ShaderSource } from "./ShaderSource"; + +describe("ShaderSource", () => +{ + describe("getFillVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = ShaderSource.getFillVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = ShaderSource.getFillVertexShader(); + + expect(shader).toContain("@vertex"); + }); + + it("should define VertexInput struct with position and bezier", () => + { + const shader = ShaderSource.getFillVertexShader(); + + expect(shader).toContain("struct VertexInput"); + expect(shader).toContain("position: vec2"); + expect(shader).toContain("bezier: vec2"); + }); + + it("should define FillUniforms struct with color and matrix", () => + { + const shader = ShaderSource.getFillVertexShader(); + + expect(shader).toContain("struct FillUniforms"); + expect(shader).toContain("color: vec4"); + expect(shader).toContain("matrix0: vec4"); + }); + + it("should use yFlipSign override for Y-axis control", () => + { + const shader = ShaderSource.getFillVertexShader(); + + expect(shader).toContain("yFlipSign"); + }); + }); + + describe("getFillMainVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = ShaderSource.getFillMainVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = ShaderSource.getFillMainVertexShader(); + + expect(shader).toContain("@vertex"); + }); + + it("should return same shader as non-Main variant (uses @override yFlipSign)", () => + { + const mainShader = ShaderSource.getFillMainVertexShader(); + const atlasShader = ShaderSource.getFillVertexShader(); + + expect(mainShader).toBe(atlasShader); + }); + }); + + describe("getFillFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getFillFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getFillFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should include bezier curve handling", () => + { + const shader = ShaderSource.getFillFragmentShader(); + + expect(shader).toContain("bezier"); + }); + + it("should use inverseSqrt for distance calculation", () => + { + const shader = ShaderSource.getFillFragmentShader(); + + expect(shader).toContain("inverseSqrt"); + }); + }); + + describe("getStencilWriteVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = ShaderSource.getStencilWriteVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = ShaderSource.getStencilWriteVertexShader(); + + expect(shader).toContain("@vertex"); + }); + }); + + describe("getStencilWriteMainVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = ShaderSource.getStencilWriteMainVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = ShaderSource.getStencilWriteMainVertexShader(); + + expect(shader).toContain("@vertex"); + }); + + it("should return same shader as non-Main variant (uses @override yFlipSign)", () => + { + const mainShader = ShaderSource.getStencilWriteMainVertexShader(); + const atlasShader = ShaderSource.getStencilWriteVertexShader(); + + expect(mainShader).toBe(atlasShader); + }); + }); + + describe("getStencilWriteFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getStencilWriteFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getStencilWriteFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + }); + + describe("getStencilFillVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = ShaderSource.getStencilFillVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = ShaderSource.getStencilFillVertexShader(); + + expect(shader).toContain("@vertex"); + }); + }); + + describe("getStencilFillFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getStencilFillFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getStencilFillFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + }); + + describe("getMaskVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = ShaderSource.getMaskVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = ShaderSource.getMaskVertexShader(); + + expect(shader).toContain("@vertex"); + }); + }); + + describe("getMaskFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getMaskFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getMaskFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should include bezier curve handling for anti-aliasing", () => + { + const shader = ShaderSource.getMaskFragmentShader(); + + expect(shader).toContain("dpdx"); + expect(shader).toContain("dpdy"); + }); + }); + + describe("getBasicVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = ShaderSource.getBasicVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = ShaderSource.getBasicVertexShader(); + + expect(shader).toContain("@vertex"); + }); + }); + + describe("getBasicMainVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = ShaderSource.getBasicMainVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = ShaderSource.getBasicMainVertexShader(); + + expect(shader).toContain("@vertex"); + }); + + it("should return same shader as non-Main variant (uses @override yFlipSign)", () => + { + const mainShader = ShaderSource.getBasicMainVertexShader(); + const atlasShader = ShaderSource.getBasicVertexShader(); + + expect(mainShader).toBe(atlasShader); + }); + }); + + describe("getBasicFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getBasicFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getBasicFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + }); + + describe("getTextureFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getTextureFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getTextureFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should include texture sampling", () => + { + const shader = ShaderSource.getTextureFragmentShader(); + + expect(shader).toContain("textureSample"); + }); + }); + + describe("getInstancedVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = ShaderSource.getInstancedVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = ShaderSource.getInstancedVertexShader(); + + expect(shader).toContain("@vertex"); + }); + + it("should define InstanceInput struct", () => + { + const shader = ShaderSource.getInstancedVertexShader(); + + expect(shader).toContain("struct InstanceInput"); + }); + }); + + describe("getInstancedFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getInstancedFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getInstancedFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should include color transform", () => + { + const shader = ShaderSource.getInstancedFragmentShader(); + + expect(shader).toContain("mulColor"); + expect(shader).toContain("addColor"); + }); + }); + + describe("getGradientFillVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = ShaderSource.getGradientFillVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = ShaderSource.getGradientFillVertexShader(); + + expect(shader).toContain("@vertex"); + }); + }); + + describe("getGradientFillMainVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = ShaderSource.getGradientFillMainVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = ShaderSource.getGradientFillMainVertexShader(); + + expect(shader).toContain("@vertex"); + }); + }); + + describe("getGradientFillFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getGradientFillFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getGradientFillFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should handle gradient types", () => + { + const shader = ShaderSource.getGradientFillFragmentShader(); + + expect(shader).toContain("gradientType"); + }); + }); + + describe("getGradientFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getGradientFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getGradientFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + }); + + describe("getBitmapFillVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = ShaderSource.getBitmapFillVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = ShaderSource.getBitmapFillVertexShader(); + + expect(shader).toContain("@vertex"); + }); + }); + + describe("getBitmapFillMainVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = ShaderSource.getBitmapFillMainVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = ShaderSource.getBitmapFillMainVertexShader(); + + expect(shader).toContain("@vertex"); + }); + }); + + describe("getBitmapFillFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getBitmapFillFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getBitmapFillFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should include texture sampling", () => + { + const shader = ShaderSource.getBitmapFillFragmentShader(); + + expect(shader).toContain("textureSample"); + }); + }); + + describe("getBlendFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getBlendFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getBlendFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should include color handling", () => + { + const shader = ShaderSource.getBlendFragmentShader(); + + expect(shader).toContain("color"); + }); + }); + + describe("getBlurFilterVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = ShaderSource.getBlurFilterVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = ShaderSource.getBlurFilterVertexShader(); + + expect(shader).toContain("@vertex"); + }); + }); + + describe("getBlurFilterFragmentShader", () => + { + it("should return a valid WGSL fragment shader string for halfBlur=2", () => + { + const shader = ShaderSource.getBlurFilterFragmentShader(2); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getBlurFilterFragmentShader(2); + + expect(shader).toContain("@fragment"); + }); + + it("should define BlurUniforms struct", () => + { + const shader = ShaderSource.getBlurFilterFragmentShader(2); + + expect(shader).toContain("struct BlurUniforms"); + }); + + it("should generate different shaders for different halfBlur values", () => + { + const shader1 = ShaderSource.getBlurFilterFragmentShader(2); + const shader2 = ShaderSource.getBlurFilterFragmentShader(4); + + expect(shader1).not.toBe(shader2); + }); + }); + + describe("getTextureCopyFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getTextureCopyFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getTextureCopyFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + }); + + describe("getBlurTextureCopyFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getBlurTextureCopyFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getBlurTextureCopyFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + }); + + describe("getFilterOutputFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getFilterOutputFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getFilterOutputFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + }); + + describe("getColorMatrixFilterFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getColorMatrixFilterFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getColorMatrixFilterFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should define ColorMatrixUniforms struct", () => + { + const shader = ShaderSource.getColorMatrixFilterFragmentShader(); + + expect(shader).toContain("struct ColorMatrixUniforms"); + }); + }); + + describe("getGlowFilterFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getGlowFilterFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getGlowFilterFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should define GlowUniforms struct", () => + { + const shader = ShaderSource.getGlowFilterFragmentShader(); + + expect(shader).toContain("struct GlowUniforms"); + }); + }); + + describe("getDropShadowFilterFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getDropShadowFilterFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getDropShadowFilterFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should define DropShadowUniforms struct", () => + { + const shader = ShaderSource.getDropShadowFilterFragmentShader(); + + expect(shader).toContain("struct DropShadowUniforms"); + }); + }); + + describe("getGradientGlowFilterFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getGradientGlowFilterFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getGradientGlowFilterFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should define GradientGlowUniforms struct", () => + { + const shader = ShaderSource.getGradientGlowFilterFragmentShader(); + + expect(shader).toContain("struct GradientGlowUniforms"); + }); + + it("should include gradient LUT texture sampling", () => + { + const shader = ShaderSource.getGradientGlowFilterFragmentShader(); + + expect(shader).toContain("gradientLUT"); + }); + }); + + describe("getGradientBevelFilterFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getGradientBevelFilterFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getGradientBevelFilterFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should define GradientBevelUniforms struct", () => + { + const shader = ShaderSource.getGradientBevelFilterFragmentShader(); + + expect(shader).toContain("struct GradientBevelUniforms"); + }); + }); + + describe("getConvolutionFilterFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getConvolutionFilterFragmentShader(3, 3); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getConvolutionFilterFragmentShader(3, 3); + + expect(shader).toContain("@fragment"); + }); + + it("should define ConvolutionUniforms struct", () => + { + const shader = ShaderSource.getConvolutionFilterFragmentShader(3, 3); + + expect(shader).toContain("struct ConvolutionUniforms"); + }); + + it("should generate different shaders for different matrix sizes", () => + { + const shader3x3 = ShaderSource.getConvolutionFilterFragmentShader(3, 3); + const shader5x5 = ShaderSource.getConvolutionFilterFragmentShader(5, 5); + + expect(shader3x3).not.toBe(shader5x5); + }); + }); + + describe("getBevelFilterFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getBevelFilterFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getBevelFilterFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should define BevelUniforms struct", () => + { + const shader = ShaderSource.getBevelFilterFragmentShader(); + + expect(shader).toContain("struct BevelUniforms"); + }); + }); + + describe("getComplexBlendFragmentShader", () => + { + it("should return a valid WGSL fragment shader (unified)", () => + { + const shader = ShaderSource.getComplexBlendFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getComplexBlendFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should include blend function", () => + { + const shader = ShaderSource.getComplexBlendFragmentShader(); + + expect(shader).toContain("fn blend"); + }); + + it("should support step-based blend modes (lighten/darken)", () => + { + const shader = ShaderSource.getComplexBlendFragmentShader(); + + expect(shader).toContain("step(srcRgb, dstRgb)"); + expect(shader).toContain("step(dstRgb, srcRgb)"); + }); + + it("should include blendMode uniform", () => + { + const shader = ShaderSource.getComplexBlendFragmentShader(); + + expect(shader).toContain("blendMode"); + }); + }); + + describe("getDisplacementMapFilterFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(shader).toContain("@fragment"); + }); + + it("should define DisplacementUniforms struct", () => + { + const shader = ShaderSource.getDisplacementMapFilterFragmentShader(1, 2, 0); + + expect(shader).toContain("struct DisplacementUniforms"); + }); + + it("should generate different shaders for different component channels", () => + { + const shader1 = ShaderSource.getDisplacementMapFilterFragmentShader(1, 2, 0); + const shader2 = ShaderSource.getDisplacementMapFilterFragmentShader(4, 8, 0); + + expect(shader1).not.toBe(shader2); + }); + + it("should generate different shaders for different modes", () => + { + const shader0 = ShaderSource.getDisplacementMapFilterFragmentShader(1, 2, 0); + const shader1 = ShaderSource.getDisplacementMapFilterFragmentShader(1, 2, 1); + const shader2 = ShaderSource.getDisplacementMapFilterFragmentShader(1, 2, 2); + + expect(shader0).not.toBe(shader1); + expect(shader0).not.toBe(shader2); + }); + }); + + describe("getNodeClearVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = ShaderSource.getNodeClearVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = ShaderSource.getNodeClearVertexShader(); + + expect(shader).toContain("@vertex"); + }); + }); + + describe("getNodeClearFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getNodeClearFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getNodeClearFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should return transparent color", () => + { + const shader = ShaderSource.getNodeClearFragmentShader(); + + expect(shader).toContain("vec4(0.0, 0.0, 0.0, 0.0)"); + }); + }); + + describe("getPositionedTextureVertexShader", () => + { + it("should return a valid WGSL vertex shader string", () => + { + const shader = ShaderSource.getPositionedTextureVertexShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @vertex attribute", () => + { + const shader = ShaderSource.getPositionedTextureVertexShader(); + + expect(shader).toContain("@vertex"); + }); + + it("should define PositionUniforms struct", () => + { + const shader = ShaderSource.getPositionedTextureVertexShader(); + + expect(shader).toContain("struct PositionUniforms"); + }); + }); + + describe("getPositionedTextureFragmentShader", () => + { + it("should return a valid WGSL fragment shader string", () => + { + const shader = ShaderSource.getPositionedTextureFragmentShader(); + + expect(typeof shader).toBe("string"); + expect(shader.length).toBeGreaterThan(0); + }); + + it("should contain @fragment attribute", () => + { + const shader = ShaderSource.getPositionedTextureFragmentShader(); + + expect(shader).toContain("@fragment"); + }); + + it("should include texture sampling", () => + { + const shader = ShaderSource.getPositionedTextureFragmentShader(); + + expect(shader).toContain("textureSample"); + }); + }); + + describe("shader consistency", () => + { + const vertexShaders = [ + { name: "getFillVertexShader", fn: () => ShaderSource.getFillVertexShader() }, + { name: "getFillMainVertexShader", fn: () => ShaderSource.getFillMainVertexShader() }, + { name: "getStencilWriteVertexShader", fn: () => ShaderSource.getStencilWriteVertexShader() }, + { name: "getStencilWriteMainVertexShader", fn: () => ShaderSource.getStencilWriteMainVertexShader() }, + { name: "getStencilFillVertexShader", fn: () => ShaderSource.getStencilFillVertexShader() }, + { name: "getMaskVertexShader", fn: () => ShaderSource.getMaskVertexShader() }, + { name: "getBasicVertexShader", fn: () => ShaderSource.getBasicVertexShader() }, + { name: "getBasicMainVertexShader", fn: () => ShaderSource.getBasicMainVertexShader() }, + { name: "getInstancedVertexShader", fn: () => ShaderSource.getInstancedVertexShader() }, + { name: "getGradientFillVertexShader", fn: () => ShaderSource.getGradientFillVertexShader() }, + { name: "getGradientFillMainVertexShader", fn: () => ShaderSource.getGradientFillMainVertexShader() }, + { name: "getBitmapFillVertexShader", fn: () => ShaderSource.getBitmapFillVertexShader() }, + { name: "getBitmapFillMainVertexShader", fn: () => ShaderSource.getBitmapFillMainVertexShader() }, + { name: "getBlurFilterVertexShader", fn: () => ShaderSource.getBlurFilterVertexShader() }, + { name: "getNodeClearVertexShader", fn: () => ShaderSource.getNodeClearVertexShader() }, + { name: "getPositionedTextureVertexShader", fn: () => ShaderSource.getPositionedTextureVertexShader() } + ]; + + const fragmentShaders = [ + { name: "getFillFragmentShader", fn: () => ShaderSource.getFillFragmentShader() }, + { name: "getStencilWriteFragmentShader", fn: () => ShaderSource.getStencilWriteFragmentShader() }, + { name: "getStencilFillFragmentShader", fn: () => ShaderSource.getStencilFillFragmentShader() }, + { name: "getMaskFragmentShader", fn: () => ShaderSource.getMaskFragmentShader() }, + { name: "getBasicFragmentShader", fn: () => ShaderSource.getBasicFragmentShader() }, + { name: "getTextureFragmentShader", fn: () => ShaderSource.getTextureFragmentShader() }, + { name: "getInstancedFragmentShader", fn: () => ShaderSource.getInstancedFragmentShader() }, + { name: "getGradientFillFragmentShader", fn: () => ShaderSource.getGradientFillFragmentShader() }, + { name: "getGradientFragmentShader", fn: () => ShaderSource.getGradientFragmentShader() }, + { name: "getBitmapFillFragmentShader", fn: () => ShaderSource.getBitmapFillFragmentShader() }, + { name: "getBlendFragmentShader", fn: () => ShaderSource.getBlendFragmentShader() }, + { name: "getTextureCopyFragmentShader", fn: () => ShaderSource.getTextureCopyFragmentShader() }, + { name: "getBlurTextureCopyFragmentShader", fn: () => ShaderSource.getBlurTextureCopyFragmentShader() }, + { name: "getFilterOutputFragmentShader", fn: () => ShaderSource.getFilterOutputFragmentShader() }, + { name: "getColorMatrixFilterFragmentShader", fn: () => ShaderSource.getColorMatrixFilterFragmentShader() }, + { name: "getGlowFilterFragmentShader", fn: () => ShaderSource.getGlowFilterFragmentShader() }, + { name: "getDropShadowFilterFragmentShader", fn: () => ShaderSource.getDropShadowFilterFragmentShader() }, + { name: "getGradientGlowFilterFragmentShader", fn: () => ShaderSource.getGradientGlowFilterFragmentShader() }, + { name: "getGradientBevelFilterFragmentShader", fn: () => ShaderSource.getGradientBevelFilterFragmentShader() }, + { name: "getBevelFilterFragmentShader", fn: () => ShaderSource.getBevelFilterFragmentShader() }, + { name: "getNodeClearFragmentShader", fn: () => ShaderSource.getNodeClearFragmentShader() }, + { name: "getPositionedTextureFragmentShader", fn: () => ShaderSource.getPositionedTextureFragmentShader() } + ]; + + vertexShaders.forEach(({ name, fn }) => + { + it(`${name} should contain valid fn main entry point`, () => + { + const shader = fn(); + + expect(shader).toContain("fn main"); + }); + }); + + fragmentShaders.forEach(({ name, fn }) => + { + it(`${name} should contain valid fn main entry point`, () => + { + const shader = fn(); + + expect(shader).toContain("fn main"); + }); + }); + }); +}); diff --git a/packages/webgpu/src/Shader/ShaderSource.ts b/packages/webgpu/src/Shader/ShaderSource.ts new file mode 100644 index 00000000..824c5f7f --- /dev/null +++ b/packages/webgpu/src/Shader/ShaderSource.ts @@ -0,0 +1,674 @@ +import { FillVertex } from "./wgsl/vertex/FillVertex"; +import { StencilWriteVertex, StencilFillVertex } from "./wgsl/vertex/StencilVertex"; +import { MaskVertex } from "./wgsl/vertex/MaskVertex"; +import { BasicVertex } from "./wgsl/vertex/BasicVertex"; +import { InstancedVertex } from "./wgsl/vertex/InstancedVertex"; +import { GradientFillVertex } from "./wgsl/vertex/GradientVertex"; +import { BitmapFillVertex } from "./wgsl/vertex/BitmapVertex"; +import { BlurFilterVertex, NodeClearVertex, PositionedTextureVertex, BitmapSyncVertex, TextureScaleVertex, TextureScaleBlendVertex, ComplexBlendScaleVertex, ComplexBlendVertex, ComplexBlendCopyVertex, ComplexBlendOutputVertex, FilterComplexBlendOutputVertex } from "./wgsl/vertex/FilterVertex"; + +import { FillFragment } from "./wgsl/fragment/FillFragment"; +import { StencilWriteFragment, StencilFillFragment } from "./wgsl/fragment/StencilFragment"; +import { MaskFragment } from "./wgsl/fragment/MaskFragment"; +import { BasicFragment, TextureFragment } from "./wgsl/fragment/BasicFragment"; +import { InstancedFragment } from "./wgsl/fragment/InstancedFragment"; +import { GradientFillFragment, GradientFillStencilFragment, GradientFragment } from "./wgsl/fragment/GradientFragment"; +import { BitmapFillFragment } from "./wgsl/fragment/BitmapFragment"; +import { + TextureCopyFragment, + BlurTextureCopyFragment, + FilterOutputFragment, + ColorTransformFragment, + YFlipColorTransformFragment, + ColorMatrixFilterFragment, + NodeClearFragment, + PositionedTextureFragment, + BlendGenericFragment, + BitmapSyncFragment +} from "./wgsl/fragment/FilterFragment"; +import { + GlowFilterFragment, + DropShadowFilterFragment, + GradientGlowFilterFragment, + GradientBevelFilterFragment, + BevelFilterFragment, + BevelBaseFragment +} from "./wgsl/fragment/EffectFragment"; +import { WgslIsInside, WgslVertexOutput } from "./wgsl/common/SharedWgsl"; + +export class ShaderSource +{ + static getFillVertexShader (): string + { + return FillVertex; + } + + static getFillMainVertexShader (): string + { + return FillVertex; + } + + static getFillFragmentShader (): string + { + return FillFragment; + } + + static getStencilWriteVertexShader (): string + { + return StencilWriteVertex; + } + + static getStencilWriteMainVertexShader (): string + { + return StencilWriteVertex; + } + + static getStencilWriteFragmentShader (): string + { + return StencilWriteFragment; + } + + static getStencilFillVertexShader (): string + { + return StencilFillVertex; + } + + static getStencilFillMainVertexShader (): string + { + return StencilFillVertex; + } + + static getStencilFillFragmentShader (): string + { + return StencilFillFragment; + } + + static getMaskVertexShader (): string + { + return MaskVertex; + } + + static getMaskFragmentShader (): string + { + return MaskFragment; + } + + static getBasicVertexShader (): string + { + return BasicVertex; + } + + static getBasicMainVertexShader (): string + { + return BasicVertex; + } + + static getBasicFragmentShader (): string + { + return BasicFragment; + } + + static getTextureFragmentShader (): string + { + return TextureFragment; + } + + static getInstancedVertexShader (): string + { + return InstancedVertex; + } + + static getInstancedFragmentShader (): string + { + return InstancedFragment; + } + + static getGradientFillVertexShader (): string + { + return GradientFillVertex; + } + + static getGradientFillMainVertexShader (): string + { + return GradientFillVertex; + } + + static getGradientFillFragmentShader (): string + { + return GradientFillFragment; + } + + static getGradientFillStencilFragmentShader (): string + { + return GradientFillStencilFragment; + } + + static getGradientFragmentShader (): string + { + return GradientFragment; + } + + static getBitmapFillVertexShader (): string + { + return BitmapFillVertex; + } + + static getBitmapFillMainVertexShader (): string + { + return BitmapFillVertex; + } + + static getBitmapFillFragmentShader (): string + { + return BitmapFillFragment; + } + + static getBlendFragmentShader (): string + { + return BlendGenericFragment; + } + + static getBlurFilterVertexShader (): string + { + return BlurFilterVertex; + } + + static getBitmapSyncVertexShader (): string + { + return BitmapSyncVertex; + } + + static getBitmapSyncFragmentShader (): string + { + return BitmapSyncFragment; + } + + static getBlurFilterFragmentShader (halfBlur: number): string + { + const halfBlurFixed = halfBlur.toFixed(1); + + return /* wgsl */` +${WgslVertexOutput} + +struct BlurUniforms { + offset: vec2, + fraction: f32, + samples: f32, +} + +@group(0) @binding(0) var uniforms: BlurUniforms; +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var inputTexture: texture_2d; + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let offset = uniforms.offset; + let fraction = uniforms.fraction; + let samples = uniforms.samples; + var color = textureSample(inputTexture, textureSampler, input.texCoord); + for (var i: f32 = 1.0; i < ${halfBlurFixed}; i += 1.0) { + color += textureSample(inputTexture, textureSampler, input.texCoord + offset * i); + color += textureSample(inputTexture, textureSampler, input.texCoord - offset * i); + } + color += textureSample(inputTexture, textureSampler, input.texCoord + offset * ${halfBlurFixed}) * fraction; + color += textureSample(inputTexture, textureSampler, input.texCoord - offset * ${halfBlurFixed}) * fraction; + color /= samples; + return color; +} +`; + } + + static getTextureCopyFragmentShader (): string + { + return TextureCopyFragment; + } + + static getBlurTextureCopyFragmentShader (): string + { + return BlurTextureCopyFragment; + } + + static getFilterOutputFragmentShader (): string + { + return FilterOutputFragment; + } + + static getColorTransformFragmentShader (): string + { + return ColorTransformFragment; + } + + static getYFlipColorTransformFragmentShader (): string + { + return YFlipColorTransformFragment; + } + + static getColorMatrixFilterFragmentShader (): string + { + return ColorMatrixFilterFragment; + } + + static getGlowFilterFragmentShader (): string + { + return GlowFilterFragment; + } + + static getDropShadowFilterFragmentShader (): string + { + return DropShadowFilterFragment; + } + + static getGradientGlowFilterFragmentShader (): string + { + return GradientGlowFilterFragment; + } + + static getGradientBevelFilterFragmentShader (): string + { + return GradientBevelFilterFragment; + } + + static getBevelFilterFragmentShader (): string + { + return BevelFilterFragment; + } + + static getBevelBaseFragmentShader (): string + { + return BevelBaseFragment; + } + + static getConvolutionFilterFragmentShader ( + matrixX: number, + matrixY: number, + preserveAlpha: boolean = true, + clamp: boolean = true + ): string + { + const halfX = Math.floor(matrixX * 0.5); + const halfY = Math.floor(matrixY * 0.5); + const size = matrixX * matrixY; + + let matrixStatement = ""; + for (let idx = 0; idx < size; idx++) { + matrixStatement += ` + result = result + getWeightedColor(${idx}, getMatrixWeight(${idx}));`; + } + + const preserveAlphaStatement = preserveAlpha + ? "result.a = textureSample(sourceTexture, sourceSampler, input.texCoord).a;" + : ""; + + const clampStatement = clamp + ? "" + : ` + let substituteColor = uniforms.substituteColor; + color = mix(substituteColor, color, isInside(uv));`; + + return ` +struct ConvolutionUniforms { + rcpSize: vec2, + rcpDivisor: f32, + bias: f32, + substituteColor: vec4, + matrix: array, ${Math.ceil(size / 4)}>, +} + +@group(0) @binding(0) var uniforms: ConvolutionUniforms; +@group(0) @binding(1) var sourceSampler: sampler; +@group(0) @binding(2) var sourceTexture: texture_2d; + +${WgslVertexOutput} + +${WgslIsInside} + +fn getMatrixWeight(index: i32) -> f32 { + let vecIndex = index / 4; + let component = index % 4; + let vec = uniforms.matrix[vecIndex]; + if (component == 0) { return vec.x; } + else if (component == 1) { return vec.y; } + else if (component == 2) { return vec.z; } + else { return vec.w; } +} + +fn getWeightedColor(i: i32, weight: f32) -> vec4 { + let rcpSize = uniforms.rcpSize; + let iDivX = i / ${matrixX}; + let iModX = i - ${matrixX} * iDivX; + let offset = vec2(f32(iModX - ${halfX}), f32(${halfY} - iDivX)); + var uv = input.texCoord + offset * rcpSize; + var color = textureSample(sourceTexture, sourceSampler, uv); + color = vec4(color.rgb / max(0.0001, color.a), color.a); + ${clampStatement} + return color * weight; +} + +var input: VertexOutput; + +@vertex +fn vs_main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { + var positions = array, 6>( + vec2(-1.0, -1.0), + vec2(1.0, -1.0), + vec2(-1.0, 1.0), + vec2(-1.0, 1.0), + vec2(1.0, -1.0), + vec2(1.0, 1.0) + ); + var texCoords = array, 6>( + vec2(0.0, 1.0), + vec2(1.0, 1.0), + vec2(0.0, 0.0), + vec2(0.0, 0.0), + vec2(1.0, 1.0), + vec2(1.0, 0.0) + ); + var output: VertexOutput; + output.position = vec4(positions[vertexIndex], 0.0, 1.0); + output.texCoord = texCoords[vertexIndex]; + return output; +} + +@fragment +fn fs_main(fragInput: VertexOutput) -> @location(0) vec4 { + input = fragInput; + let rcpDivisor = uniforms.rcpDivisor; + let bias = uniforms.bias; + var result = vec4(0.0); + ${matrixStatement} + result = clamp(result * rcpDivisor + bias, vec4(0.0), vec4(1.0)); + ${preserveAlphaStatement} + result = vec4(result.rgb * result.a, result.a); + return result; +} +`; + } + + static getComplexBlendFragmentShader (): string + { + return ShaderSource.getUnifiedComplexBlendFragmentShader(); + } + + static getBlendModeIndex (blendMode: string): number + { + switch (blendMode) { + case "subtract": return 0; + case "multiply": return 1; + case "lighten": return 2; + case "darken": return 3; + case "overlay": return 4; + case "hardlight": return 5; + case "difference": return 6; + case "invert": return 7; + default: return 1; + } + } + + static getUnifiedComplexBlendFragmentShader (): string + { + return /* wgsl */` +${WgslVertexOutput} + +struct BlendUniforms { + mulColor: vec4, + addColor: vec4, + blendMode: f32, + _pad0: f32, + _pad1: f32, + _pad2: f32, +} + +@group(0) @binding(0) var uniforms: BlendUniforms; +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var dstTexture: texture_2d; +@group(0) @binding(3) var srcTexture: texture_2d; + +fn blend(src: vec4, dst: vec4, mode: i32) -> vec4 { + if (src.a == 0.0) { return dst; } + if (dst.a == 0.0) { return src; } + + let a = src - src * dst.a; + let b = dst - dst * src.a; + + if (mode == 1) { + let c = src * dst; + return a + b + c; + } + + let srcRgb = src.rgb / src.a; + let dstRgb = dst.rgb / dst.a; + + var blended: vec3; + + switch (mode) { + case 0: { + blended = dstRgb - srcRgb; + } + case 2: { + blended = mix(srcRgb, dstRgb, step(srcRgb, dstRgb)); + } + case 3: { + blended = mix(srcRgb, dstRgb, step(dstRgb, srcRgb)); + } + case 4: { + let mul = srcRgb * dstRgb; + let c1 = 2.0 * mul; + let c2 = 2.0 * (srcRgb + dstRgb - mul) - 1.0; + blended = mix(c1, c2, step(vec3(0.5), dstRgb)); + } + case 5: { + let mul = srcRgb * dstRgb; + let c1 = 2.0 * mul; + let c2 = 2.0 * (srcRgb + dstRgb - mul) - 1.0; + blended = mix(c1, c2, step(vec3(0.5), srcRgb)); + } + case 6: { + blended = abs(srcRgb - dstRgb); + } + case 7: { + let ib = dst - dst * src.a; + let ic = vec4(src.a - dst.rgb * src.a, src.a); + return ib + ic; + } + default: { + blended = srcRgb; + } + } + + var c = vec4(blended, src.a * dst.a); + c = vec4(c.rgb * c.a, c.a); + return a + b + c; +} + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + var dst = textureSample(dstTexture, textureSampler, input.texCoord); + var src = textureSample(srcTexture, textureSampler, input.texCoord); + let mul = uniforms.mulColor; + let add = uniforms.addColor; + if (mul.x != 1.0 || mul.y != 1.0 || mul.z != 1.0 || mul.w != 1.0 + || add.x != 0.0 || add.y != 0.0 || add.z != 0.0) { + src = vec4(src.rgb / max(vec3(0.0001), vec3(src.a)), src.a); + src = clamp(src * mul + add, vec4(0.0), vec4(1.0)); + src = vec4(src.rgb * src.a, src.a); + } + return blend(src, dst, i32(uniforms.blendMode)); +} +`; + } + + static getDisplacementMapFilterFragmentShader ( + componentX: number, + componentY: number, + mode: number + ): string + { + let cx: string; + let cy: string; + + switch (componentX) { + case 1: + cx = "mapColor.r"; + break; + case 2: + cx = "mapColor.g"; + break; + case 4: + cx = "mapColor.b"; + break; + case 8: + cx = "mapColor.a"; + break; + default: + cx = "0.5"; + break; + } + + switch (componentY) { + case 1: + cy = "mapColor.r"; + break; + case 2: + cy = "mapColor.g"; + break; + case 4: + cy = "mapColor.b"; + break; + case 8: + cy = "mapColor.a"; + break; + default: + cy = "0.5"; + break; + } + + let modeStatement: string; + let needsSubstituteColor = false; + + switch (mode) { + case 0: + modeStatement = ` +sourceColor = textureSample(srcTexture, textureSampler, uv); +`; + break; + case 1: + needsSubstituteColor = true; + modeStatement = ` +sourceColor = mix(uniforms.substituteColor, textureSample(srcTexture, textureSampler, uv), isInside(uv)); +`; + break; + case 2: + modeStatement = ` +sourceColor = textureSample(srcTexture, textureSampler, fract(uv)); +`; + break; + case 3: + modeStatement = ` +let insideUV = step(abs(uv - vec2(0.5)), vec2(0.5)); +sourceColor = textureSample(srcTexture, textureSampler, mix(input.texCoord, uv, insideUV)); +`; + break; + default: + modeStatement = ` +sourceColor = textureSample(srcTexture, textureSampler, fract(uv)); +`; + break; + } + + const uniformsStruct = needsSubstituteColor + ? `struct DisplacementUniforms { + uvToStScale: vec2, + uvToStOffset: vec2, + scale: vec2, + padding: vec2, + substituteColor: vec4, +}` + : `struct DisplacementUniforms { + uvToStScale: vec2, + uvToStOffset: vec2, + scale: vec2, + padding: vec2, +}`; + + return /* wgsl */` +${WgslVertexOutput} + +${uniformsStruct} + +@group(0) @binding(0) var uniforms: DisplacementUniforms; +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var srcTexture: texture_2d; +@group(0) @binding(3) var mapTexture: texture_2d; + +${WgslIsInside} + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let stCoord = vec2(input.texCoord.x, 1.0 - input.texCoord.y); + let st = stCoord * uniforms.uvToStScale - uniforms.uvToStOffset; + let mapColor = textureSample(mapTexture, textureSampler, vec2(st.x, 1.0 - st.y)); + let offset = vec2(${cx}, ${cy}) - 0.5; + let uv = input.texCoord + offset * uniforms.scale; + var sourceColor: vec4; + ${modeStatement} + return mix(textureSample(srcTexture, textureSampler, input.texCoord), sourceColor, isInside(st)); +} +`; + } + + static getNodeClearVertexShader (): string + { + return NodeClearVertex; + } + + static getNodeClearFragmentShader (): string + { + return NodeClearFragment; + } + + static getPositionedTextureVertexShader (): string + { + return PositionedTextureVertex; + } + + static getTextureScaleVertexShader (): string + { + return TextureScaleVertex; + } + + static getTextureScaleBlendVertexShader (): string + { + return TextureScaleBlendVertex; + } + + static getComplexBlendScaleVertexShader (): string + { + return ComplexBlendScaleVertex; + } + + static getComplexBlendVertexShader (): string + { + return ComplexBlendVertex; + } + + static getComplexBlendCopyVertexShader (): string + { + return ComplexBlendCopyVertex; + } + + static getComplexBlendOutputVertexShader (): string + { + return ComplexBlendOutputVertex; + } + + static getFilterComplexBlendOutputVertexShader (): string + { + return FilterComplexBlendOutputVertex; + } + + static getPositionedTextureFragmentShader (): string + { + return PositionedTextureFragment; + } +} diff --git a/packages/webgpu/src/Shader/wgsl/common/SharedWgsl.ts b/packages/webgpu/src/Shader/wgsl/common/SharedWgsl.ts new file mode 100644 index 00000000..3e81858f --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/common/SharedWgsl.ts @@ -0,0 +1,41 @@ +export const WgslIsInside = ` +fn isInside(uv: vec2) -> f32 { + let s = step(vec2(0.0), uv) * step(uv, vec2(1.0)); + return s.x * s.y; +}`; + +export const WgslFullscreenPositions = ` + const positions = array, 6>( + vec2(-1.0, -1.0), + vec2( 1.0, -1.0), + vec2(-1.0, 1.0), + vec2(-1.0, 1.0), + vec2( 1.0, -1.0), + vec2( 1.0, 1.0) + );`; + +export const WgslUnitQuadVertices = ` + const vertices = array, 6>( + vec2(0.0, 0.0), + vec2(1.0, 0.0), + vec2(0.0, 1.0), + vec2(0.0, 1.0), + vec2(1.0, 0.0), + vec2(1.0, 1.0) + );`; + +export const WgslVertexOutput = ` +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, +}`; + +export const WgslFullscreenTexCoords = ` + const texCoords = array, 6>( + vec2(0.0, 1.0), + vec2(1.0, 1.0), + vec2(0.0, 0.0), + vec2(0.0, 0.0), + vec2(1.0, 1.0), + vec2(1.0, 0.0) + );`; diff --git a/packages/webgpu/src/Shader/wgsl/fragment/BasicFragment.ts b/packages/webgpu/src/Shader/wgsl/fragment/BasicFragment.ts new file mode 100644 index 00000000..4b2982e0 --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/fragment/BasicFragment.ts @@ -0,0 +1,29 @@ +export const BasicFragment = /* wgsl */` +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, + @location(1) color: vec4, +} + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + return input.color; +} +`; + +export const TextureFragment = /* wgsl */` +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, + @location(1) color: vec4, +} + +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var textureData: texture_2d; + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let textureColor = textureSampleLevel(textureData, textureSampler, input.texCoord, 0); + return textureColor * input.color; +} +`; diff --git a/packages/webgpu/src/Shader/wgsl/fragment/BitmapFragment.ts b/packages/webgpu/src/Shader/wgsl/fragment/BitmapFragment.ts new file mode 100644 index 00000000..82cbc744 --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/fragment/BitmapFragment.ts @@ -0,0 +1,43 @@ +export const BitmapFillFragment = /* wgsl */` +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) bezier: vec2, + @location(1) color: vec4, + @location(2) worldPos: vec2, +} + +struct BitmapUniforms { + bitmapMatrix: mat3x3, + textureWidth: f32, + textureHeight: f32, + repeat: f32, + _pad: f32, +} + +@group(0) @binding(0) var uniforms: BitmapUniforms; +@group(0) @binding(1) var bitmapSampler: sampler; +@group(0) @binding(2) var bitmapTexture: texture_2d; + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let u = input.bezier.x; + let v = input.bezier.y; + if (abs(u - 0.5) > 0.001 || abs(v - 0.5) > 0.001) { + let d = u * u - v; + if (d > 0.0) { + discard; + } + } + let transformedPos = uniforms.bitmapMatrix * vec3(input.worldPos, 1.0); + var uv = vec2( + transformedPos.x / uniforms.textureWidth, + transformedPos.y / uniforms.textureHeight + ); + if (uniforms.repeat > 0.5) { + uv = fract(uv); + } + let bitmapColor = textureSampleLevel(bitmapTexture, bitmapSampler, uv, 0); + let alpha = bitmapColor.a * input.color.a; + return vec4(bitmapColor.rgb * input.color.a, alpha); +} +`; diff --git a/packages/webgpu/src/Shader/wgsl/fragment/BlendFragment.ts b/packages/webgpu/src/Shader/wgsl/fragment/BlendFragment.ts new file mode 100644 index 00000000..b3cf048d --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/fragment/BlendFragment.ts @@ -0,0 +1,87 @@ +/** + * @description ブレンドシェーダー共通ヘッダー + */ +const BLEND_HEADER = /* wgsl */`struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, +} + +struct BlendUniforms { + colorTransform: vec4, + addColor: vec4, +} + +@group(0) @binding(0) var uniforms: BlendUniforms; +@group(0) @binding(1) var sampler0: sampler; +@group(0) @binding(2) var texture0: texture_2d; +@group(0) @binding(3) var texture1: texture_2d; +`; + +/** + * @description alpha guard 付きブレンドシェーダーを生成 + */ +const createBlendFragment = (blendLogic: string): string => + BLEND_HEADER + /* wgsl */` +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + var src = textureSampleLevel(texture1, sampler0, input.texCoord, 0); + var dst = textureSampleLevel(texture0, sampler0, input.texCoord, 0); + if (src.a == 0.0) { return dst; } + if (dst.a == 0.0) { return src; } + src = src * uniforms.colorTransform + vec4(uniforms.addColor.rgb, 0.0); + let a = src - src * dst.a; + let b = dst - dst * src.a; + var srcRgb = src.rgb / src.a; + var dstRgb = dst.rgb / dst.a; +${blendLogic} + c = vec4(c.rgb * c.a, c.a); + return a + b + c; +} +`; + +export const MultiplyBlendFragment = BLEND_HEADER + /* wgsl */` +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + var src = textureSampleLevel(texture1, sampler0, input.texCoord, 0); + var dst = textureSampleLevel(texture0, sampler0, input.texCoord, 0); + src = src * uniforms.colorTransform + vec4(uniforms.addColor.rgb, 0.0); + let a = src - src * dst.a; + let b = dst - dst * src.a; + let c = src * dst; + return a + b + c; +} +`; + +export const ScreenBlendFragment = createBlendFragment( + " var c = vec4(srcRgb + dstRgb - srcRgb * dstRgb, src.a * dst.a);" +); + +export const LightenBlendFragment = createBlendFragment( + " var c = vec4(max(srcRgb, dstRgb), src.a * dst.a);" +); + +export const DarkenBlendFragment = createBlendFragment( + " var c = vec4(min(srcRgb, dstRgb), src.a * dst.a);" +); + +export const OverlayBlendFragment = createBlendFragment( + ` let s = step(vec3(0.5), dstRgb); + let lo = 2.0 * srcRgb * dstRgb; + let hi = 1.0 - 2.0 * (1.0 - srcRgb) * (1.0 - dstRgb); + var c = vec4(mix(lo, hi, s), src.a * dst.a);` +); + +export const HardLightBlendFragment = createBlendFragment( + ` let s = step(vec3(0.5), srcRgb); + let lo = 2.0 * srcRgb * dstRgb; + let hi = 1.0 - 2.0 * (1.0 - srcRgb) * (1.0 - dstRgb); + var c = vec4(mix(lo, hi, s), src.a * dst.a);` +); + +export const DifferenceBlendFragment = createBlendFragment( + " var c = vec4(abs(srcRgb - dstRgb), src.a * dst.a);" +); + +export const SubtractBlendFragment = createBlendFragment( + " var c = vec4(max(dstRgb - srcRgb, vec3(0.0)), src.a * dst.a);" +); diff --git a/packages/webgpu/src/Shader/wgsl/fragment/EffectFragment.ts b/packages/webgpu/src/Shader/wgsl/fragment/EffectFragment.ts new file mode 100644 index 00000000..5c94d6e8 --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/fragment/EffectFragment.ts @@ -0,0 +1,330 @@ +import { WgslIsInside, WgslVertexOutput } from "../common/SharedWgsl"; + +export const GlowFilterFragment = /* wgsl */` +${WgslVertexOutput} + +override IS_INNER: u32 = 0u; +override IS_KNOCKOUT: u32 = 0u; + +struct GlowUniforms { + color: vec4, + baseScale: vec2, + baseOffset: vec2, + blurScale: vec2, + blurOffset: vec2, + strength: f32, + _padding1: f32, + _padding2: f32, + _padding3: f32, +} + +@group(0) @binding(0) var uniforms: GlowUniforms; +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var blurTexture: texture_2d; +@group(0) @binding(3) var baseTexture: texture_2d; +${WgslIsInside} + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let baseUV = input.texCoord * uniforms.baseScale - uniforms.baseOffset; + let baseColor = textureSampleLevel(baseTexture, textureSampler, baseUV, 0) * isInside(baseUV); + + let blurUV = input.texCoord * uniforms.blurScale - uniforms.blurOffset; + let blurColor = textureSampleLevel(blurTexture, textureSampler, blurUV, 0) * isInside(blurUV); + + var rawAlpha = blurColor.a; + if (IS_INNER == 1u) { + rawAlpha = 1.0 - rawAlpha; + } + let glowAlpha = clamp(rawAlpha * uniforms.strength, 0.0, 1.0); + let glowColor = vec4(uniforms.color.rgb * glowAlpha, uniforms.color.a * glowAlpha); + if (IS_INNER == 1u) { + let innerGlow = glowColor * baseColor.a; + if (IS_KNOCKOUT == 1u) { + return innerGlow; + } else { + return innerGlow + baseColor * (1.0 - glowColor.a); + } + } else { + if (IS_KNOCKOUT == 1u) { + return glowColor * (1.0 - baseColor.a); + } else { + return baseColor + glowColor * (1.0 - baseColor.a); + } + } +} +`; + +export const DropShadowFilterFragment = /* wgsl */` +${WgslVertexOutput} + +override IS_INNER: u32 = 0u; +override IS_KNOCKOUT: u32 = 0u; +override IS_HIDE_OBJECT: u32 = 0u; + +struct DropShadowUniforms { + color: vec4, + baseScale: vec2, + baseOffset: vec2, + blurScale: vec2, + blurOffset: vec2, + strength: f32, + _padding1: f32, + _padding2: f32, + _padding3: f32, +} + +@group(0) @binding(0) var uniforms: DropShadowUniforms; +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var blurTexture: texture_2d; +@group(0) @binding(3) var baseTexture: texture_2d; +${WgslIsInside} + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let baseUV = input.texCoord * uniforms.baseScale - uniforms.baseOffset; + let baseColor = textureSampleLevel(baseTexture, textureSampler, baseUV, 0) * isInside(baseUV); + + let blurUV = input.texCoord * uniforms.blurScale - uniforms.blurOffset; + let blur = textureSampleLevel(blurTexture, textureSampler, blurUV, 0) * isInside(blurUV); + + var rawAlpha = blur.a; + if (IS_INNER == 1u) { + rawAlpha = 1.0 - rawAlpha; + } + let shadowAlpha = clamp(rawAlpha * uniforms.strength, 0.0, 1.0); + let shadowColor = vec4(uniforms.color.rgb * shadowAlpha, uniforms.color.a * shadowAlpha); + + if (IS_INNER == 1u) { + let innerShadow = shadowColor * baseColor.a; + if (IS_KNOCKOUT == 1u) { + return innerShadow; + } else { + return innerShadow + baseColor * (1.0 - shadowColor.a); + } + } else { + if (IS_HIDE_OBJECT == 1u) { + return shadowColor; + } else if (IS_KNOCKOUT == 1u) { + return shadowColor * (1.0 - baseColor.a); + } else { + return shadowColor * (1.0 - baseColor.a) + baseColor; + } + } +} +`; + +export const GradientGlowFilterFragment = /* wgsl */` +${WgslVertexOutput} + +override GLOW_TYPE: u32 = 0u; +override IS_KNOCKOUT: u32 = 0u; + +struct GradientGlowUniforms { + strength: f32, + _padding1: f32, + _padding2: f32, + _padding3: f32, + baseScale: vec2, + baseOffset: vec2, + blurScale: vec2, + blurOffset: vec2, +} + +@group(0) @binding(0) var uniforms: GradientGlowUniforms; +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var blurTexture: texture_2d; +@group(0) @binding(3) var baseTexture: texture_2d; +@group(0) @binding(4) var gradientLUT: texture_2d; +${WgslIsInside} + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let baseUV = input.texCoord * uniforms.baseScale - uniforms.baseOffset; + let base = textureSampleLevel(baseTexture, textureSampler, baseUV, 0) * isInside(baseUV); + + let blurUV = input.texCoord * uniforms.blurScale - uniforms.blurOffset; + var blur = textureSampleLevel(blurTexture, textureSampler, blurUV, 0) * isInside(blurUV); + + blur.a = clamp(blur.a * uniforms.strength, 0.0, 1.0); + let glowColor = textureSampleLevel(gradientLUT, textureSampler, vec2(blur.a, 0.5), 0); + var result: vec4; + if (GLOW_TYPE == 0u) { + if (IS_KNOCKOUT == 1u) { + result = glowColor; + } else { + result = base - base * glowColor.a + glowColor; + } + } else if (GLOW_TYPE == 1u) { + if (IS_KNOCKOUT == 1u) { + result = glowColor * base.a; + } else { + result = glowColor * base.a + base * (1.0 - glowColor.a); + } + } else { + if (IS_KNOCKOUT == 1u) { + result = glowColor - glowColor * base.a; + } else { + result = base + glowColor - glowColor * base.a; + } + } + return result; +} +`; + +export const GradientBevelFilterFragment = /* wgsl */` +${WgslVertexOutput} + +override BEVEL_TYPE: u32 = 0u; +override IS_KNOCKOUT: u32 = 0u; + +struct GradientBevelUniforms { + strength: f32, + _padding1: f32, + _padding2: f32, + _padding3: f32, + baseScale: vec2, + baseOffset: vec2, + blurScale: vec2, + blurOffset: vec2, +} + +@group(0) @binding(0) var uniforms: GradientBevelUniforms; +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var blurTexture: texture_2d; +@group(0) @binding(3) var baseTexture: texture_2d; +@group(0) @binding(4) var gradientLUT: texture_2d; +${WgslIsInside} + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let baseUV = input.texCoord * uniforms.baseScale - uniforms.baseOffset; + let base = textureSampleLevel(baseTexture, textureSampler, baseUV, 0) * isInside(baseUV); + + let blurUV = input.texCoord * uniforms.blurScale - uniforms.blurOffset; + let blur1 = textureSampleLevel(blurTexture, textureSampler, blurUV, 0) * isInside(blurUV); + + let mirrorUV = (1.0 - input.texCoord) * uniforms.blurScale - uniforms.blurOffset; + let blur2 = textureSampleLevel(blurTexture, textureSampler, mirrorUV, 0) * isInside(mirrorUV); + + var highlightAlpha = blur1.a - blur2.a; + var shadowAlpha = blur2.a - blur1.a; + highlightAlpha = clamp(highlightAlpha * uniforms.strength, 0.0, 1.0); + shadowAlpha = clamp(shadowAlpha * uniforms.strength, 0.0, 1.0); + + let lutCoord = 0.5019607843137255 - 0.5019607843137255 * shadowAlpha + 0.4980392156862745 * highlightAlpha; + let bevelColor = textureSampleLevel(gradientLUT, textureSampler, vec2(lutCoord, 0.5), 0); + + var result: vec4; + if (BEVEL_TYPE == 0u) { + if (IS_KNOCKOUT == 1u) { + result = bevelColor; + } else { + result = base - base * bevelColor.a + bevelColor; + } + } else if (BEVEL_TYPE == 1u) { + if (IS_KNOCKOUT == 1u) { + result = bevelColor * base.a; + } else { + result = bevelColor * base.a + base * (1.0 - bevelColor.a); + } + } else { + if (IS_KNOCKOUT == 1u) { + result = bevelColor - bevelColor * base.a; + } else { + result = base + bevelColor - bevelColor * base.a; + } + } + + return result; +} +`; + +export const BevelFilterFragment = /* wgsl */` +${WgslVertexOutput} + +override BEVEL_TYPE: u32 = 0u; +override IS_KNOCKOUT: u32 = 0u; + +struct BevelUniforms { + highlightColor: vec4, + shadowColor: vec4, + strength: f32, + _padding1: f32, + _padding2: f32, + _padding3: f32, + baseScale: vec2, + baseOffset: vec2, + blurScale: vec2, + blurOffset: vec2, +} + +@group(0) @binding(0) var uniforms: BevelUniforms; +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var blurTexture: texture_2d; +@group(0) @binding(3) var baseTexture: texture_2d; +${WgslIsInside} + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let baseUV = input.texCoord * uniforms.baseScale - uniforms.baseOffset; + let base = textureSampleLevel(baseTexture, textureSampler, baseUV, 0) * isInside(baseUV); + + let blurUV = input.texCoord * uniforms.blurScale - uniforms.blurOffset; + let blur1 = textureSampleLevel(blurTexture, textureSampler, blurUV, 0) * isInside(blurUV); + + let mirrorUV = (1.0 - input.texCoord) * uniforms.blurScale - uniforms.blurOffset; + let blur2 = textureSampleLevel(blurTexture, textureSampler, mirrorUV, 0) * isInside(mirrorUV); + + var highlightAlpha = blur1.a - blur2.a; + var shadowAlpha = blur2.a - blur1.a; + highlightAlpha = clamp(highlightAlpha * uniforms.strength, 0.0, 1.0); + shadowAlpha = clamp(shadowAlpha * uniforms.strength, 0.0, 1.0); + + let bevelColor = uniforms.highlightColor * highlightAlpha + uniforms.shadowColor * shadowAlpha; + var result: vec4; + if (BEVEL_TYPE == 0u) { + if (IS_KNOCKOUT == 1u) { + result = bevelColor; + } else { + result = base - base * bevelColor.a + bevelColor; + } + } else if (BEVEL_TYPE == 1u) { + if (IS_KNOCKOUT == 1u) { + result = bevelColor * base.a; + } else { + result = bevelColor * base.a + base * (1.0 - bevelColor.a); + } + } else { + if (IS_KNOCKOUT == 1u) { + result = bevelColor - bevelColor * base.a; + } else { + result = base + bevelColor - bevelColor * base.a; + } + } + + return result; +} +`; + +export const BevelBaseFragment = /* wgsl */` +${WgslVertexOutput} + +struct BevelBaseUniforms { + offset: vec2, + _padding: vec2, +} + +@group(0) @binding(0) var uniforms: BevelBaseUniforms; +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var sourceTexture: texture_2d; +${WgslIsInside} + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let original = textureSampleLevel(sourceTexture, textureSampler, input.texCoord, 0); + let shiftedUV = input.texCoord - uniforms.offset; + let shifted = textureSampleLevel(sourceTexture, textureSampler, shiftedUV, 0) * isInside(shiftedUV); + return original * (1.0 - shifted.a); +} +`; diff --git a/packages/webgpu/src/Shader/wgsl/fragment/FillFragment.ts b/packages/webgpu/src/Shader/wgsl/fragment/FillFragment.ts new file mode 100644 index 00000000..d10cd208 --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/fragment/FillFragment.ts @@ -0,0 +1,28 @@ +export const FillFragment = /* wgsl */` +struct FragmentInput { + @builtin(position) position: vec4, + @location(0) bezier: vec2, + @location(1) color: vec4, +} + +@fragment +fn main(input: FragmentInput) -> @location(0) vec4 { + let f_val = input.bezier.x * input.bezier.x - input.bezier.y; + let dx = dpdx(f_val); + let dy = dpdy(f_val); + + if (input.bezier.x == 0.5 && input.bezier.y == 0.5) { + return vec4(input.color.rgb * input.color.a, input.color.a); + } + + let dist = f_val * inverseSqrt(dx * dx + dy * dy); + let coverage = smoothstep(0.5, -0.5, dist); + + if (coverage <= 0.001) { + discard; + } + + let finalAlpha = input.color.a * min(coverage, 1.0); + return vec4(input.color.rgb * finalAlpha, finalAlpha); +} +`; diff --git a/packages/webgpu/src/Shader/wgsl/fragment/FilterFragment.ts b/packages/webgpu/src/Shader/wgsl/fragment/FilterFragment.ts new file mode 100644 index 00000000..a74d025b --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/fragment/FilterFragment.ts @@ -0,0 +1,222 @@ +import { WgslVertexOutput } from "../common/SharedWgsl"; + +export const TextureCopyFragment = /* wgsl */` +${WgslVertexOutput} + +struct CopyUniforms { + scale: vec2, + offset: vec2, +} + +@group(0) @binding(0) var uniforms: CopyUniforms; +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var inputTexture: texture_2d; + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let uv = input.texCoord * uniforms.scale + uniforms.offset; + return textureSampleLevel(inputTexture, textureSampler, uv, 0); +} +`; + +export const BlurTextureCopyFragment = /* wgsl */` +${WgslVertexOutput} + +struct CopyUniforms { + scale: vec2, + offset: vec2, +} + +@group(0) @binding(0) var uniforms: CopyUniforms; +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var inputTexture: texture_2d; + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let uv = (input.texCoord - uniforms.offset) * uniforms.scale; + let clampedUv = clamp(uv, vec2(0.0), vec2(1.0)); + let color = textureSampleLevel(inputTexture, textureSampler, clampedUv, 0); + let inBounds = uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0; + return select(vec4(0.0, 0.0, 0.0, 0.0), color, inBounds); +} +`; + +export const FilterOutputFragment = /* wgsl */` +${WgslVertexOutput} + +struct CopyUniforms { + scale: vec2, + offset: vec2, +} + +@group(0) @binding(0) var uniforms: CopyUniforms; +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var inputTexture: texture_2d; + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let uv = input.texCoord * uniforms.scale + uniforms.offset; + let clampedUv = clamp(uv, vec2(0.0), vec2(1.0)); + let color = textureSampleLevel(inputTexture, textureSampler, clampedUv, 0); + let inBounds = uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0; + return select(vec4(0.0, 0.0, 0.0, 0.0), color, inBounds); +} +`; + +export const ColorTransformFragment = /* wgsl */` +${WgslVertexOutput} + +struct ColorTransformUniforms { + mul: vec4, + add: vec4, +} + +@group(0) @binding(0) var ct: ColorTransformUniforms; +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var inputTexture: texture_2d; + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + var color = textureSampleLevel(inputTexture, textureSampler, input.texCoord, 0); + + color = vec4(color.rgb / max(vec3(0.0001), vec3(color.a)), color.a); + color = clamp(color * ct.mul + ct.add, vec4(0.0), vec4(1.0)); + color = vec4(color.rgb * color.a, color.a); + + return color; +} +`; + +export const YFlipColorTransformFragment = /* wgsl */` +${WgslVertexOutput} + +struct YFlipCTUniforms { + scale: vec2, + offset: vec2, + mul: vec4, + add: vec4, +} + +@group(0) @binding(0) var uniforms: YFlipCTUniforms; +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var inputTexture: texture_2d; + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let uv = input.texCoord * uniforms.scale + uniforms.offset; + var color = textureSampleLevel(inputTexture, textureSampler, uv, 0); + + color = vec4(color.rgb / max(vec3(0.0001), vec3(color.a)), color.a); + color = clamp(color * uniforms.mul + uniforms.add, vec4(0.0), vec4(1.0)); + color = vec4(color.rgb * color.a, color.a); + + return color; +} +`; + +export const ColorMatrixFilterFragment = /* wgsl */` +${WgslVertexOutput} + +struct ColorMatrixUniforms { + matrix: mat4x4, + offset: vec4, +} + +@group(0) @binding(0) var uniforms: ColorMatrixUniforms; +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var inputTexture: texture_2d; + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + var color = textureSampleLevel(inputTexture, textureSampler, input.texCoord, 0); + + color = vec4(color.rgb / max(vec3(0.0001), vec3(color.a)), color.a); + var result = uniforms.matrix * color + uniforms.offset; + result = clamp(result, vec4(0.0), vec4(1.0)); + result = vec4(result.rgb * result.a, result.a); + + return result; +} +`; + +export const NodeClearFragment = /* wgsl */` +@fragment +fn main() -> @location(0) vec4 { + return vec4(0.0, 0.0, 0.0, 0.0); +} +`; + +export const PositionedTextureFragment = /* wgsl */` +${WgslVertexOutput} + +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var inputTexture: texture_2d; + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + return textureSampleLevel(inputTexture, textureSampler, input.texCoord, 0); +} +`; + +export const BitmapSyncFragment = /* wgsl */` +${WgslVertexOutput} + +@group(0) @binding(1) var textureSampler: sampler; +@group(0) @binding(2) var inputTexture: texture_2d; + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + return textureSampleLevel(inputTexture, textureSampler, input.texCoord, 0); +} +`; + +export const BlendGenericFragment = /* wgsl */` +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, + @location(1) color: vec4, +} + +struct BlendUniforms { + blendMode: f32, +} + +@group(0) @binding(1) var blend: BlendUniforms; +@group(0) @binding(2) var srcSampler: sampler; +@group(0) @binding(3) var srcTexture: texture_2d; +@group(0) @binding(4) var dstSampler: sampler; +@group(0) @binding(5) var dstTexture: texture_2d; + +fn blendNormal(src: vec4, dst: vec4) -> vec4 { + return src; +} + +fn blendMultiply(src: vec4, dst: vec4) -> vec4 { + return src * dst; +} + +fn blendScreen(src: vec4, dst: vec4) -> vec4 { + return src + dst - src * dst; +} + +fn blendAdd(src: vec4, dst: vec4) -> vec4 { + return min(src + dst, vec4(1.0)); +} + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let src = textureSampleLevel(srcTexture, srcSampler, input.texCoord, 0); + let dst = textureSampleLevel(dstTexture, dstSampler, input.texCoord, 0); + var result: vec4; + if (blend.blendMode < 0.5) { + result = blendNormal(src, dst); + } else if (blend.blendMode < 1.5) { + result = blendMultiply(src, dst); + } else if (blend.blendMode < 2.5) { + result = blendScreen(src, dst); + } else { + result = blendAdd(src, dst); + } + return result * input.color; +} +`; diff --git a/packages/webgpu/src/Shader/wgsl/fragment/GradientFragment.ts b/packages/webgpu/src/Shader/wgsl/fragment/GradientFragment.ts new file mode 100644 index 00000000..53742de4 --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/fragment/GradientFragment.ts @@ -0,0 +1,122 @@ +const GradientUniformsAndSpread = ` +struct GradientUniforms { + inverseMatrix: mat3x3, + gradientType: f32, + focal: f32, + spread: f32, + radius: f32, + linearPoints: vec4, +} + +@group(0) @binding(0) var gradient: GradientUniforms; +@group(0) @binding(1) var gradientSampler: sampler; +@group(0) @binding(2) var gradientTexture: texture_2d; + +override GRADIENT_TYPE: u32 = 0u; +override SPREAD_MODE: u32 = 2u; + +fn applySpread(t: f32) -> f32 { + if (SPREAD_MODE == 0u) { + return 1.0 - abs(fract(t * 0.5) * 2.0 - 1.0); + } else if (SPREAD_MODE == 1u) { + return fract(t); + } else { + return clamp(t, 0.0, 1.0); + } +} +`; + +const GradientCalculation = ` + var t: f32; + if (GRADIENT_TYPE == 0u) { + let a = gradient.linearPoints.xy; + let b = gradient.linearPoints.zw; + let ab = b - a; + let ap = p - a; + let dotAB = dot(ab, ab); + if (dotAB < 0.0001) { + t = 0.0; + } else { + t = dot(ab, ap) / dotAB; + } + } else { + let r = gradient.radius; + let coord = p / r; + let focalRatio = gradient.focal; + + if (abs(focalRatio) < 0.001) { + t = length(coord); + } else { + let focal = vec2(focalRatio, 0.0); + let diff = coord - focal; + let lenDiff = length(diff); + + if (lenDiff < 0.0001) { + t = 0.0; + } else { + let dir = diff / lenDiff; + + // Solve quadratic equation for unit circle intersection (a=1 since dir is normalized) + let b_coef = 2.0 * dot(dir, focal); + let c_coef = dot(focal, focal) - 1.0; + let discriminant = b_coef * b_coef - 4.0 * c_coef; + let x = (-b_coef + sqrt(max(discriminant, 0.0))) * 0.5; + t = lenDiff / abs(x); + } + } + } + t = applySpread(t); + let gradientColor = textureSampleLevel(gradientTexture, gradientSampler, vec2(t, 0.5), 0); +`; + +export const GradientFillFragment = /* wgsl */` +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) v_uv: vec2, + @location(1) bezier: vec2, + @location(2) color: vec4, +} +${GradientUniformsAndSpread} + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let p = input.v_uv; +${GradientCalculation} + let result = gradientColor * input.color; + return vec4(result.rgb * result.a, result.a); +} +`; + +export const GradientFillStencilFragment = /* wgsl */` +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) v_uv: vec2, + @location(1) bezier: vec2, + @location(2) color: vec4, +} +${GradientUniformsAndSpread} + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let p = input.v_uv; +${GradientCalculation} + return vec4(gradientColor.rgb * gradientColor.a, gradientColor.a); +} +`; + +export const GradientFragment = /* wgsl */` +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, + @location(1) color: vec4, +} +${GradientUniformsAndSpread} + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + let p = input.texCoord; +${GradientCalculation} + let result = gradientColor * input.color; + return vec4(result.rgb * result.a, result.a); +} +`; diff --git a/packages/webgpu/src/Shader/wgsl/fragment/InstancedFragment.ts b/packages/webgpu/src/Shader/wgsl/fragment/InstancedFragment.ts new file mode 100644 index 00000000..88e5b3b7 --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/fragment/InstancedFragment.ts @@ -0,0 +1,20 @@ +export const InstancedFragment = /* wgsl */` +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, + @location(1) mulColor: vec4, + @location(2) addColor: vec4, +} + +@group(0) @binding(0) var textureSampler: sampler; +@group(0) @binding(1) var textureData: texture_2d; + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4 { + var src = textureSampleLevel(textureData, textureSampler, input.texCoord, 0); + src = vec4(src.rgb / max(0.0001, src.a), src.a); + src = clamp(src * input.mulColor + input.addColor, vec4(0.0), vec4(1.0)); + src = vec4(src.rgb * src.a, src.a); + return src; +} +`; diff --git a/packages/webgpu/src/Shader/wgsl/fragment/MaskFragment.ts b/packages/webgpu/src/Shader/wgsl/fragment/MaskFragment.ts new file mode 100644 index 00000000..00a04701 --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/fragment/MaskFragment.ts @@ -0,0 +1,17 @@ +export const MaskFragment = /* wgsl */` +struct FragmentInput { + @location(0) bezier: vec2, +} + +@fragment +fn main(input: FragmentInput) -> @location(0) vec4 { + let px = dpdx(input.bezier); + let py = dpdy(input.bezier); + let f = (2.0 * input.bezier.x) * vec2(px.x, py.x) - vec2(px.y, py.y); + let alpha = 0.5 - (input.bezier.x * input.bezier.x - input.bezier.y) / length(f); + if (alpha <= 0.0) { + discard; + } + return vec4(min(alpha, 1.0)); +} +`; diff --git a/packages/webgpu/src/Shader/wgsl/fragment/StencilFragment.ts b/packages/webgpu/src/Shader/wgsl/fragment/StencilFragment.ts new file mode 100644 index 00000000..3547cd01 --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/fragment/StencilFragment.ts @@ -0,0 +1,34 @@ +export const StencilWriteFragment = /* wgsl */` +struct FragmentInput { + @builtin(position) position: vec4, + @location(0) bezier: vec2, +} + +@fragment +fn main(input: FragmentInput) -> @location(0) vec4 { + let f_val = input.bezier.x * input.bezier.x - input.bezier.y; + let dx = dpdx(f_val); + let dy = dpdy(f_val); + let dist = f_val * inverseSqrt(dx * dx + dy * dy); + let alpha = smoothstep(0.5, -0.5, dist); + + if (alpha <= 0.001) { + discard; + } + + return vec4(0.0, 0.0, 0.0, min(alpha, 1.0)); +} +`; + +export const StencilFillFragment = /* wgsl */` +struct FragmentInput { + @builtin(position) position: vec4, + @location(0) color: vec4, +} + +@fragment +fn main(input: FragmentInput) -> @location(0) vec4 { + let a = input.color.a; + return vec4(input.color.r * a, input.color.g * a, input.color.b * a, a); +} +`; diff --git a/packages/webgpu/src/Shader/wgsl/vertex/BasicVertex.ts b/packages/webgpu/src/Shader/wgsl/vertex/BasicVertex.ts new file mode 100644 index 00000000..e2dcdd91 --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/vertex/BasicVertex.ts @@ -0,0 +1,37 @@ +export const BasicVertex = /* wgsl */` +override yFlipSign: f32 = 1.0; + +struct VertexInput { + @location(0) position: vec2, + @location(1) texCoord: vec2, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, + @location(1) color: vec4, +} + +struct Uniforms { + matrix: mat3x3, + color: vec4, + alpha: f32, +} + +@group(0) @binding(0) var uniforms: Uniforms; + +@vertex +fn main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + let pos = uniforms.matrix * vec3(input.position, 1.0); + let ndc = pos.xy * 2.0 - 1.0; + output.position = vec4(ndc.x, ndc.y * yFlipSign, 0.0, 1.0); + output.texCoord = input.texCoord; + let premultipliedColor = vec4( + uniforms.color.rgb * uniforms.color.a * uniforms.alpha, + uniforms.color.a * uniforms.alpha + ); + output.color = premultipliedColor; + return output; +} +`; diff --git a/packages/webgpu/src/Shader/wgsl/vertex/BitmapVertex.ts b/packages/webgpu/src/Shader/wgsl/vertex/BitmapVertex.ts new file mode 100644 index 00000000..e90d921e --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/vertex/BitmapVertex.ts @@ -0,0 +1,43 @@ +export const BitmapFillVertex = /* wgsl */` +override yFlipSign: f32 = 1.0; + +struct VertexInput { + @location(0) position: vec2, + @location(1) bezier: vec2, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) bezier: vec2, + @location(1) color: vec4, + @location(2) worldPos: vec2, +} + +struct BitmapUniforms { + bitmapMatrix: mat3x3, + width: f32, + height: f32, + repeat: f32, + _pad: f32, + color: vec4, + contextMatrix0: vec4, + contextMatrix1: vec4, + contextMatrix2: vec4, +} + +@group(0) @binding(0) var bitmap: BitmapUniforms; + +@vertex +fn main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + let matrix = mat3x3(bitmap.contextMatrix0.xyz, bitmap.contextMatrix1.xyz, bitmap.contextMatrix2.xyz); + let transformedPos = matrix * vec3(input.position, 1.0); + let clipX = transformedPos.x * 2.0 - 1.0; + let clipY = (transformedPos.y * 2.0 - 1.0) * yFlipSign; + output.position = vec4(clipX, clipY, 0.0, 1.0); + output.bezier = input.bezier; + output.color = bitmap.color; + output.worldPos = input.position; + return output; +} +`; diff --git a/packages/webgpu/src/Shader/wgsl/vertex/FillVertex.ts b/packages/webgpu/src/Shader/wgsl/vertex/FillVertex.ts new file mode 100644 index 00000000..a24f8660 --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/vertex/FillVertex.ts @@ -0,0 +1,35 @@ +export const FillVertex = /* wgsl */` +override yFlipSign: f32 = 1.0; + +struct VertexInput { + @location(0) position: vec2, + @location(1) bezier: vec2, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) bezier: vec2, + @location(1) color: vec4, +} + +struct FillUniforms { + color: vec4, + matrix0: vec4, + matrix1: vec4, + matrix2: vec4, +} + +@group(0) @binding(0) var uniforms: FillUniforms; + +@vertex +fn main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + let matrix = mat3x3(uniforms.matrix0.xyz, uniforms.matrix1.xyz, uniforms.matrix2.xyz); + let transformed = matrix * vec3(input.position, 1.0); + let ndc = transformed.xy * 2.0 - 1.0; + output.position = vec4(ndc.x, ndc.y * yFlipSign, 0.0, 1.0); + output.bezier = input.bezier; + output.color = uniforms.color; + return output; +} +`; diff --git a/packages/webgpu/src/Shader/wgsl/vertex/FilterVertex.ts b/packages/webgpu/src/Shader/wgsl/vertex/FilterVertex.ts new file mode 100644 index 00000000..204d76ad --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/vertex/FilterVertex.ts @@ -0,0 +1,205 @@ +import { WgslFullscreenPositions, WgslUnitQuadVertices, WgslVertexOutput } from "../common/SharedWgsl"; + +const createFullscreenQuadVertex = (yFlipTexCoord: boolean): string => /* wgsl */` +${WgslVertexOutput} + +@vertex +fn main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { + var output: VertexOutput; +${WgslFullscreenPositions} + var texCoords = array, 6>( + vec2(0.0, ${yFlipTexCoord ? "1.0" : "0.0"}), + vec2(1.0, ${yFlipTexCoord ? "1.0" : "0.0"}), + vec2(0.0, ${yFlipTexCoord ? "0.0" : "1.0"}), + vec2(0.0, ${yFlipTexCoord ? "0.0" : "1.0"}), + vec2(1.0, ${yFlipTexCoord ? "1.0" : "0.0"}), + vec2(1.0, ${yFlipTexCoord ? "0.0" : "1.0"}) + ); + output.position = vec4(positions[vertexIndex], 0.0, 1.0); + output.texCoord = texCoords[vertexIndex]; + return output; +} +`; + +export const BlurFilterVertex = createFullscreenQuadVertex(true); +export const ComplexBlendVertex = createFullscreenQuadVertex(false); +export const ComplexBlendCopyVertex = createFullscreenQuadVertex(false); + +export const NodeClearVertex = /* wgsl */` +struct VertexInput { + @location(0) position: vec2, +} + +struct VertexOutput { + @builtin(position) position: vec4, +} + +@vertex +fn main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + let ndc = input.position * 2.0 - 1.0; + output.position = vec4(ndc.x, ndc.y, 0.0, 1.0); + return output; +} +`; + +export const PositionedTextureVertex = /* wgsl */` +struct PositionUniforms { + offset: vec2, + size: vec2, + viewport: vec2, + padding: vec2, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, +} + +@group(0) @binding(0) var uniforms: PositionUniforms; + +@vertex +fn main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { + var output: VertexOutput; +${WgslUnitQuadVertices} + let vertex = vertices[vertexIndex]; + output.texCoord = vec2(vertex.x, 1.0 - vertex.y); + var position = vertex * uniforms.size + uniforms.offset; + position = position / uniforms.viewport; + position = position * 2.0 - 1.0; + output.position = vec4(position.x, -position.y, 0.0, 1.0); + return output; +} +`; + +export const BitmapSyncVertex = /* wgsl */` +struct BitmapSyncUniforms { + nodeRect: vec4, + textureSize: vec2, + padding: vec2, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, +} + +@group(0) @binding(0) var uniforms: BitmapSyncUniforms; + +@vertex +fn main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { + var output: VertexOutput; +${WgslUnitQuadVertices} + let vertex = vertices[vertexIndex]; + let pixelPos = vec2( + uniforms.nodeRect.x + vertex.x * uniforms.nodeRect.z, + uniforms.nodeRect.y + vertex.y * uniforms.nodeRect.w + ); + let ndc = pixelPos / uniforms.textureSize * 2.0 - 1.0; + output.position = vec4(ndc.x, -ndc.y, 0.0, 1.0); + output.texCoord = pixelPos / uniforms.textureSize; + return output; +} +`; + +export const BlendModeVertex = /* wgsl */` +struct VertexInput { + @location(0) position: vec2, + @location(1) texCoord: vec2, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, +} + +@vertex +fn main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + output.position = vec4(input.position, 0.0, 1.0); + output.texCoord = input.texCoord; + return output; +} +`; + +const ScaleUniformsAndStruct = ` +struct ScaleUniforms { + matrix: vec4, + translate: vec2, + srcSize: vec2, + dstSize: vec2, + padding: vec2, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, +} + +@group(0) @binding(0) var uniforms: ScaleUniforms; +`; + +const ScaleTransformBody = ` + var pos = vertex * uniforms.srcSize; + let a = uniforms.matrix.x; + let b = uniforms.matrix.y; + let c = uniforms.matrix.z; + let d = uniforms.matrix.w; + let tx = uniforms.translate.x; + let ty = uniforms.translate.y; + let transformedX = pos.x * a + pos.y * c + tx; + let transformedY = pos.x * b + pos.y * d + ty; + var position = vec2(transformedX, transformedY) / uniforms.dstSize; + position = position * 2.0 - 1.0; + output.position = vec4(position.x, -position.y, 0.0, 1.0); +`; + +const createScaleVertex = (yFlipTexCoord: boolean): string => /* wgsl */` +${ScaleUniformsAndStruct} + +@vertex +fn main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { + var output: VertexOutput; +${WgslUnitQuadVertices} + let vertex = vertices[vertexIndex]; + output.texCoord = ${yFlipTexCoord ? "vec2(vertex.x, 1.0 - vertex.y)" : "vertex"}; +${ScaleTransformBody} + return output; +} +`; + +export const TextureScaleVertex = createScaleVertex(false); +export const TextureScaleBlendVertex = createScaleVertex(true); +export const ComplexBlendScaleVertex = createScaleVertex(false); + +const createOutputVertex = (yFlipTexCoord: boolean): string => /* wgsl */` +struct PositionUniforms { + offset: vec2, + size: vec2, + viewport: vec2, + padding: vec2, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, +} + +@group(0) @binding(0) var uniforms: PositionUniforms; + +@vertex +fn main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { + var output: VertexOutput; +${WgslUnitQuadVertices} + let vertex = vertices[vertexIndex]; + output.texCoord = ${yFlipTexCoord ? "vec2(vertex.x, 1.0 - vertex.y)" : "vertex"}; + var position = vertex * uniforms.size + uniforms.offset; + position = position / uniforms.viewport; + position = position * 2.0 - 1.0; + output.position = vec4(position.x, -position.y, 0.0, 1.0); + return output; +} +`; + +export const ComplexBlendOutputVertex = createOutputVertex(false); +export const FilterComplexBlendOutputVertex = createOutputVertex(true); diff --git a/packages/webgpu/src/Shader/wgsl/vertex/GradientVertex.ts b/packages/webgpu/src/Shader/wgsl/vertex/GradientVertex.ts new file mode 100644 index 00000000..313654d8 --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/vertex/GradientVertex.ts @@ -0,0 +1,44 @@ +export const GradientFillVertex = /* wgsl */` +override yFlipSign: f32 = 1.0; + +struct VertexInput { + @location(0) position: vec2, + @location(1) bezier: vec2, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) v_uv: vec2, + @location(1) bezier: vec2, + @location(2) color: vec4, +} + +struct GradientUniforms { + inverseMatrix: mat3x3, + gradientType: f32, + focal: f32, + spread: f32, + radius: f32, + linearPoints: vec4, + color: vec4, + contextMatrix0: vec4, + contextMatrix1: vec4, + contextMatrix2: vec4, +} + +@group(0) @binding(0) var gradient: GradientUniforms; + +@vertex +fn main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + let contextMatrix = mat3x3(gradient.contextMatrix0.xyz, gradient.contextMatrix1.xyz, gradient.contextMatrix2.xyz); + let pos = contextMatrix * vec3(input.position, 1.0); + let ndc = vec2(pos.x * 2.0 - 1.0, pos.y * 2.0 - 1.0); + output.position = vec4(ndc.x, ndc.y * yFlipSign, 0.0, 1.0); + let uvPos = gradient.inverseMatrix * vec3(input.position, 1.0); + output.v_uv = uvPos.xy; + output.bezier = input.bezier; + output.color = gradient.color; + return output; +} +`; diff --git a/packages/webgpu/src/Shader/wgsl/vertex/InstancedVertex.ts b/packages/webgpu/src/Shader/wgsl/vertex/InstancedVertex.ts new file mode 100644 index 00000000..86cf39fb --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/vertex/InstancedVertex.ts @@ -0,0 +1,48 @@ +export const InstancedVertex = /* wgsl */` +struct VertexInput { + @location(0) position: vec2, + @location(1) texCoord: vec2, +} + +struct InstanceInput { + @location(2) textureRect: vec4, + @location(3) textureDim: vec4, + @location(4) matrixTx: vec4, + @location(5) matrixScale: vec4, + @location(6) mulColor: vec4, + @location(7) addColor: vec4, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, + @location(1) mulColor: vec4, + @location(2) addColor: vec4, +} + +@vertex +fn main( + input: VertexInput, + instance: InstanceInput, + @builtin(instance_index) instanceIdx: u32 +) -> VertexOutput { + var output: VertexOutput; + let texX = instance.textureRect.x + input.texCoord.x * instance.textureRect.z; + let texY = instance.textureRect.y + input.texCoord.y * instance.textureRect.w; + output.texCoord = vec2(texX, texY); + var pos = vec2(input.position.x, 1.0 - input.position.y); + pos = pos * vec2(instance.textureDim.x, instance.textureDim.y); + let scale0 = instance.matrixScale.x; + let rotate0 = instance.matrixScale.y; + let scale1 = instance.matrixScale.z; + let rotate1 = instance.matrixScale.w; + let transformedX = pos.x * scale0 + pos.y * scale1 + instance.matrixTx.x; + let transformedY = pos.x * rotate0 + pos.y * rotate1 + instance.matrixTx.y; + var position = vec2(transformedX, transformedY) / vec2(instance.textureDim.z, instance.textureDim.w); + position = position * 2.0 - 1.0; + output.position = vec4(position.x, -position.y, 0.0, 1.0); + output.mulColor = instance.mulColor; + output.addColor = instance.addColor; + return output; +} +`; diff --git a/packages/webgpu/src/Shader/wgsl/vertex/MaskVertex.ts b/packages/webgpu/src/Shader/wgsl/vertex/MaskVertex.ts new file mode 100644 index 00000000..21328b8c --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/vertex/MaskVertex.ts @@ -0,0 +1,36 @@ +export const MaskVertex = /* wgsl */` +struct VertexInput { + @location(0) position: vec2, + @location(1) bezier: vec2, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) bezier: vec2, +} + +struct Uniforms { + viewportSize: vec2, + _padding0: vec2, + matrixCol0: vec3, + _padding1: f32, + matrixCol1: vec3, + _padding2: f32, + matrixCol2: vec3, + _padding3: f32, +} + +@group(0) @binding(0) var uniforms: Uniforms; + +@vertex +fn main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + let matrix = mat3x3(uniforms.matrixCol0, uniforms.matrixCol1, uniforms.matrixCol2); + let transformed = matrix * vec3(input.position, 1.0); + let pos = transformed.xy; + let ndc = pos * 2.0 - 1.0; + output.position = vec4(ndc.x, -ndc.y, 0.0, 1.0); + output.bezier = input.bezier; + return output; +} +`; diff --git a/packages/webgpu/src/Shader/wgsl/vertex/StencilVertex.ts b/packages/webgpu/src/Shader/wgsl/vertex/StencilVertex.ts new file mode 100644 index 00000000..729e8055 --- /dev/null +++ b/packages/webgpu/src/Shader/wgsl/vertex/StencilVertex.ts @@ -0,0 +1,67 @@ +export const StencilWriteVertex = /* wgsl */` +override yFlipSign: f32 = 1.0; + +struct VertexInput { + @location(0) position: vec2, + @location(1) bezier: vec2, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) bezier: vec2, +} + +struct FillUniforms { + color: vec4, + matrix0: vec4, + matrix1: vec4, + matrix2: vec4, +} + +@group(0) @binding(0) var uniforms: FillUniforms; + +@vertex +fn main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + let matrix = mat3x3(uniforms.matrix0.xyz, uniforms.matrix1.xyz, uniforms.matrix2.xyz); + let transformed = matrix * vec3(input.position, 1.0); + let ndc = transformed.xy * 2.0 - 1.0; + output.position = vec4(ndc.x, ndc.y * yFlipSign, 0.0, 1.0); + output.bezier = input.bezier; + return output; +} +`; + +export const StencilFillVertex = /* wgsl */` +override yFlipSign: f32 = 1.0; + +struct VertexInput { + @location(0) position: vec2, + @location(1) bezier: vec2, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) color: vec4, +} + +struct FillUniforms { + color: vec4, + matrix0: vec4, + matrix1: vec4, + matrix2: vec4, +} + +@group(0) @binding(0) var uniforms: FillUniforms; + +@vertex +fn main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + let matrix = mat3x3(uniforms.matrix0.xyz, uniforms.matrix1.xyz, uniforms.matrix2.xyz); + let transformed = matrix * vec3(input.position, 1.0); + let ndc = transformed.xy * 2.0 - 1.0; + output.position = vec4(ndc.x, ndc.y * yFlipSign, 0.0, 1.0); + output.color = uniforms.color; + return output; +} +`; diff --git a/packages/webgpu/src/TextureManager.test.ts b/packages/webgpu/src/TextureManager.test.ts new file mode 100644 index 00000000..3bdc1914 --- /dev/null +++ b/packages/webgpu/src/TextureManager.test.ts @@ -0,0 +1,375 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { TextureManager } from "./TextureManager"; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + TEXTURE_BINDING: 0x04, + COPY_DST: 0x02, + RENDER_ATTACHMENT: 0x10 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +// Mock service and usecase modules +vi.mock("./TextureManager/service/TextureManagerInitializeSamplersService", () => ({ + "execute": vi.fn((device, samplers) => { + samplers.set("default", { "label": "defaultSampler" }); + samplers.set("linear", { "label": "linearSampler" }); + samplers.set("nearest", { "label": "nearestSampler" }); + }) +})); + +vi.mock("./TextureManager/usecase/TextureManagerCreateTextureFromPixelsUseCase", () => ({ + "execute": vi.fn((device, textures, name, pixels, width, height) => { + const texture = { + "width": width, + "height": height, + "destroy": vi.fn(), + "createView": vi.fn() + }; + textures.set(name, texture); + return texture; + }) +})); + +vi.mock("./TextureManager/usecase/TextureManagerCreateTextureFromImageBitmapUseCase", () => ({ + "execute": vi.fn((device, textures, name, imageBitmap) => { + const texture = { + "width": imageBitmap.width, + "height": imageBitmap.height, + "destroy": vi.fn(), + "createView": vi.fn() + }; + textures.set(name, texture); + return texture; + }) +})); + +describe("TextureManager", () => +{ + const createMockDevice = (): GPUDevice => + { + return { + "createTexture": vi.fn((descriptor) => ({ + "width": descriptor.size.width, + "height": descriptor.size.height, + "destroy": vi.fn(), + "createView": vi.fn(() => ({ "label": "textureView" })) + })), + "createSampler": vi.fn((descriptor) => ({ + "label": `sampler-${descriptor.magFilter}` + })), + "queue": { + "writeTexture": vi.fn() + } + } as unknown as GPUDevice; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + }); + + describe("constructor", () => + { + it("should create instance with device", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + expect(manager).toBeDefined(); + }); + + it("should initialize samplers on creation", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + // Default sampler should be initialized + expect(manager.getSampler("default")).toBeDefined(); + }); + }); + + describe("createTexture", () => + { + it("should create texture with specified dimensions", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + const texture = manager.createTexture("test", 256, 256); + + expect(texture).toBeDefined(); + expect(device.createTexture).toHaveBeenCalled(); + }); + + it("should use rgba8unorm as default format", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + manager.createTexture("test", 128, 128); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "format": "rgba8unorm" + }) + ); + }); + + it("should accept custom format", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + manager.createTexture("test", 128, 128, "rgba16float"); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "format": "rgba16float" + }) + ); + }); + + it("should store texture by name", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + manager.createTexture("myTexture", 512, 512); + const retrieved = manager.getTexture("myTexture"); + + expect(retrieved).toBeDefined(); + }); + }); + + describe("createTextureFromPixels", () => + { + it("should create texture from pixel data", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + const pixels = new Uint8Array(64 * 64 * 4); + + const texture = manager.createTextureFromPixels("pixelTex", pixels, 64, 64); + + expect(texture).toBeDefined(); + }); + + it("should store texture by name", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + const pixels = new Uint8Array(32 * 32 * 4); + + manager.createTextureFromPixels("pixels", pixels, 32, 32); + + expect(manager.getTexture("pixels")).toBeDefined(); + }); + }); + + describe("createTextureFromImageBitmap", () => + { + it("should create texture from ImageBitmap", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + const imageBitmap = { "width": 128, "height": 128 } as ImageBitmap; + + const texture = manager.createTextureFromImageBitmap("bitmapTex", imageBitmap); + + expect(texture).toBeDefined(); + }); + + it("should store texture by name", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + const imageBitmap = { "width": 256, "height": 256 } as ImageBitmap; + + manager.createTextureFromImageBitmap("bitmap", imageBitmap); + + expect(manager.getTexture("bitmap")).toBeDefined(); + }); + }); + + describe("updateTexture", () => + { + it("should update existing texture with pixel data", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + const pixels = new Uint8Array(64 * 64 * 4); + + manager.createTexture("test", 64, 64); + manager.updateTexture("test", pixels, 64, 64); + + expect(device.queue.writeTexture).toHaveBeenCalled(); + }); + + it("should not throw when texture does not exist", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + const pixels = new Uint8Array(64 * 64 * 4); + + expect(() => manager.updateTexture("nonexistent", pixels, 64, 64)).not.toThrow(); + }); + }); + + describe("getTexture", () => + { + it("should return undefined for non-existent texture", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + expect(manager.getTexture("nonexistent")).toBeUndefined(); + }); + }); + + describe("getSampler", () => + { + it("should return initialized sampler", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + expect(manager.getSampler("default")).toBeDefined(); + }); + + it("should return undefined for non-existent sampler", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + expect(manager.getSampler("nonexistent")).toBeUndefined(); + }); + }); + + describe("createSampler", () => + { + it("should create new sampler", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + const sampler = manager.createSampler("mySampler", true); + + expect(sampler).toBeDefined(); + expect(device.createSampler).toHaveBeenCalled(); + }); + + it("should return existing sampler if name already exists", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + const sampler1 = manager.createSampler("sampler", true); + const sampler2 = manager.createSampler("sampler", false); + + expect(sampler1).toBe(sampler2); + }); + + it("should use linear filtering for smooth=true", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + manager.createSampler("smoothSampler", true); + + expect(device.createSampler).toHaveBeenCalledWith( + expect.objectContaining({ + "magFilter": "linear", + "minFilter": "linear" + }) + ); + }); + + it("should use nearest filtering for smooth=false", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + manager.createSampler("pixelSampler", false); + + expect(device.createSampler).toHaveBeenCalledWith( + expect.objectContaining({ + "magFilter": "nearest", + "minFilter": "nearest" + }) + ); + }); + + it("should store created sampler", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + manager.createSampler("newSampler", true); + + expect(manager.getSampler("newSampler")).toBeDefined(); + }); + }); + + describe("destroyTexture", () => + { + it("should destroy texture by name", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + const texture = manager.createTexture("test", 128, 128); + manager.destroyTexture("test"); + + expect(texture.destroy).toHaveBeenCalled(); + expect(manager.getTexture("test")).toBeUndefined(); + }); + + it("should not throw when texture does not exist", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + expect(() => manager.destroyTexture("nonexistent")).not.toThrow(); + }); + }); + + describe("dispose", () => + { + it("should destroy all textures", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + const tex1 = manager.createTexture("tex1", 64, 64); + const tex2 = manager.createTexture("tex2", 128, 128); + + manager.dispose(); + + expect(tex1.destroy).toHaveBeenCalled(); + expect(tex2.destroy).toHaveBeenCalled(); + }); + + it("should clear texture map", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + manager.createTexture("test", 64, 64); + manager.dispose(); + + expect(manager.getTexture("test")).toBeUndefined(); + }); + + it("should clear sampler map", () => + { + const device = createMockDevice(); + const manager = new TextureManager(device); + + manager.createSampler("custom", true); + manager.dispose(); + + expect(manager.getSampler("custom")).toBeUndefined(); + }); + }); +}); diff --git a/packages/webgpu/src/TextureManager.ts b/packages/webgpu/src/TextureManager.ts new file mode 100644 index 00000000..ac588f53 --- /dev/null +++ b/packages/webgpu/src/TextureManager.ts @@ -0,0 +1,127 @@ +import { execute as textureManagerInitializeSamplersService } from "./TextureManager/service/TextureManagerInitializeSamplersService"; +import { execute as textureManagerCreateTextureFromPixelsUseCase } from "./TextureManager/usecase/TextureManagerCreateTextureFromPixelsUseCase"; +import { execute as textureManagerCreateTextureFromImageBitmapUseCase } from "./TextureManager/usecase/TextureManagerCreateTextureFromImageBitmapUseCase"; + +export class TextureManager +{ + private device: GPUDevice; + private textures: Map; + private samplers: Map; + + constructor (device: GPUDevice) + { + this.device = device; + this.textures = new Map(); + this.samplers = new Map(); + + textureManagerInitializeSamplersService(device, this.samplers); + } + + createTexture ( + name: string, + width: number, + height: number, + format: GPUTextureFormat = "rgba8unorm" + ): GPUTexture { + const texture = this.device.createTexture({ + "size": { width, height }, + "format": format, + "usage": GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT + }); + + this.textures.set(name, texture); + return texture; + } + + createTextureFromPixels ( + name: string, + pixels: Uint8Array, + width: number, + height: number + ): GPUTexture { + return textureManagerCreateTextureFromPixelsUseCase( + this.device, + this.textures, + name, + pixels, + width, + height + ); + } + + createTextureFromImageBitmap (name: string, imageBitmap: ImageBitmap): GPUTexture + { + return textureManagerCreateTextureFromImageBitmapUseCase( + this.device, + this.textures, + name, + imageBitmap + ); + } + + updateTexture ( + name: string, + pixels: Uint8Array, + width: number, + height: number + ): void { + const texture = this.textures.get(name); + if (texture) { + this.device.queue.writeTexture( + { texture }, + pixels.buffer, + { "bytesPerRow": width * 4, "offset": pixels.byteOffset }, + { width, height } + ); + } + } + + getTexture (name: string): GPUTexture | undefined + { + return this.textures.get(name); + } + + getSampler (name: string): GPUSampler | undefined + { + return this.samplers.get(name); + } + + createSampler (name: string, smooth: boolean = true): GPUSampler + { + const existing = this.samplers.get(name); + if (existing) { + return existing; + } + + const sampler = this.device.createSampler({ + "magFilter": smooth ? "linear" : "nearest", + "minFilter": smooth ? "linear" : "nearest", + "mipmapFilter": smooth ? "linear" : "nearest", + "addressModeU": "clamp-to-edge", + "addressModeV": "clamp-to-edge" + }); + + this.samplers.set(name, sampler); + return sampler; + } + + destroyTexture (name: string): void + { + const texture = this.textures.get(name); + if (texture) { + texture.destroy(); + this.textures.delete(name); + } + } + + dispose (): void + { + for (const texture of this.textures.values()) { + texture.destroy(); + } + this.textures.clear(); + this.samplers.clear(); + } +} diff --git a/packages/webgpu/src/TextureManager/service/TextureManagerInitializeSamplersService.test.ts b/packages/webgpu/src/TextureManager/service/TextureManagerInitializeSamplersService.test.ts new file mode 100644 index 00000000..c9b2c13c --- /dev/null +++ b/packages/webgpu/src/TextureManager/service/TextureManagerInitializeSamplersService.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { execute } from "./TextureManagerInitializeSamplersService"; + +describe("TextureManagerInitializeSamplersService", () => +{ + const createMockDevice = () => + { + let samplerCount = 0; + return { + "createSampler": vi.fn((descriptor) => ({ + "label": `sampler_${samplerCount++}`, + "descriptor": descriptor + })) + } as unknown as GPUDevice; + }; + + describe("linear sampler", () => + { + it("should create linear sampler", () => + { + const device = createMockDevice(); + const samplers = new Map(); + + execute(device, samplers); + + expect(samplers.has("linear")).toBe(true); + }); + + it("should set magFilter to linear", () => + { + const device = createMockDevice(); + const samplers = new Map(); + + execute(device, samplers); + + expect(device.createSampler).toHaveBeenCalledWith( + expect.objectContaining({ + "magFilter": "linear" + }) + ); + }); + + it("should set minFilter to linear", () => + { + const device = createMockDevice(); + const samplers = new Map(); + + execute(device, samplers); + + expect(device.createSampler).toHaveBeenCalledWith( + expect.objectContaining({ + "minFilter": "linear" + }) + ); + }); + + it("should set mipmapFilter to linear for linear sampler", () => + { + const device = createMockDevice(); + const samplers = new Map(); + + execute(device, samplers); + + expect(device.createSampler).toHaveBeenCalledWith( + expect.objectContaining({ + "magFilter": "linear", + "mipmapFilter": "linear" + }) + ); + }); + }); + + describe("nearest sampler", () => + { + it("should create nearest sampler", () => + { + const device = createMockDevice(); + const samplers = new Map(); + + execute(device, samplers); + + expect(samplers.has("nearest")).toBe(true); + }); + + it("should set magFilter to nearest", () => + { + const device = createMockDevice(); + const samplers = new Map(); + + execute(device, samplers); + + expect(device.createSampler).toHaveBeenCalledWith( + expect.objectContaining({ + "magFilter": "nearest", + "minFilter": "nearest" + }) + ); + }); + + it("should set mipmapFilter to nearest for nearest sampler", () => + { + const device = createMockDevice(); + const samplers = new Map(); + + execute(device, samplers); + + expect(device.createSampler).toHaveBeenCalledWith( + expect.objectContaining({ + "magFilter": "nearest", + "mipmapFilter": "nearest" + }) + ); + }); + }); + + describe("repeat sampler", () => + { + it("should create repeat sampler", () => + { + const device = createMockDevice(); + const samplers = new Map(); + + execute(device, samplers); + + expect(samplers.has("repeat")).toBe(true); + }); + + it("should set addressModeU to repeat", () => + { + const device = createMockDevice(); + const samplers = new Map(); + + execute(device, samplers); + + expect(device.createSampler).toHaveBeenCalledWith( + expect.objectContaining({ + "addressModeU": "repeat", + "addressModeV": "repeat" + }) + ); + }); + }); + + describe("address mode", () => + { + it("should set addressModeU to clamp-to-edge for linear", () => + { + const device = createMockDevice(); + const samplers = new Map(); + + execute(device, samplers); + + expect(device.createSampler).toHaveBeenCalledWith( + expect.objectContaining({ + "magFilter": "linear", + "addressModeU": "clamp-to-edge", + "addressModeV": "clamp-to-edge" + }) + ); + }); + + it("should set addressModeU to clamp-to-edge for nearest", () => + { + const device = createMockDevice(); + const samplers = new Map(); + + execute(device, samplers); + + expect(device.createSampler).toHaveBeenCalledWith( + expect.objectContaining({ + "magFilter": "nearest", + "addressModeU": "clamp-to-edge", + "addressModeV": "clamp-to-edge" + }) + ); + }); + }); + + describe("total samplers", () => + { + it("should create exactly 4 samplers", () => + { + const device = createMockDevice(); + const samplers = new Map(); + + execute(device, samplers); + + expect(samplers.size).toBe(4); + expect(device.createSampler).toHaveBeenCalledTimes(4); + }); + }); +}); diff --git a/packages/webgpu/src/TextureManager/service/TextureManagerInitializeSamplersService.ts b/packages/webgpu/src/TextureManager/service/TextureManagerInitializeSamplersService.ts new file mode 100644 index 00000000..2f62e0ac --- /dev/null +++ b/packages/webgpu/src/TextureManager/service/TextureManagerInitializeSamplersService.ts @@ -0,0 +1,54 @@ +/** + * @description サンプラーを初期化 + * Initialize samplers + * + * @param {GPUDevice} device + * @param {Map} samplers + * @return {void} + * @method + * @protected + */ +export const execute = ( + device: GPUDevice, + samplers: Map +): void => { + // デフォルトサンプラー(リニアフィルタリング) + const linearSampler = device.createSampler({ + "magFilter": "linear", + "minFilter": "linear", + "mipmapFilter": "linear", + "addressModeU": "clamp-to-edge", + "addressModeV": "clamp-to-edge" + }); + samplers.set("linear", linearSampler); + + // ニアレストサンプラー + const nearestSampler = device.createSampler({ + "magFilter": "nearest", + "minFilter": "nearest", + "mipmapFilter": "nearest", + "addressModeU": "clamp-to-edge", + "addressModeV": "clamp-to-edge" + }); + samplers.set("nearest", nearestSampler); + + // アトラス用サンプラー(min: linear, mag: nearest) + const atlasSampler = device.createSampler({ + "magFilter": "nearest", + "minFilter": "linear", + "mipmapFilter": "nearest", + "addressModeU": "clamp-to-edge", + "addressModeV": "clamp-to-edge" + }); + samplers.set("atlas_instanced_sampler", atlasSampler); + + // リピートサンプラー + const repeatSampler = device.createSampler({ + "magFilter": "linear", + "minFilter": "linear", + "mipmapFilter": "linear", + "addressModeU": "repeat", + "addressModeV": "repeat" + }); + samplers.set("repeat", repeatSampler); +}; diff --git a/packages/webgpu/src/TextureManager/usecase/TextureManagerCreateTextureFromImageBitmapUseCase.test.ts b/packages/webgpu/src/TextureManager/usecase/TextureManagerCreateTextureFromImageBitmapUseCase.test.ts new file mode 100644 index 00000000..d3f147a3 --- /dev/null +++ b/packages/webgpu/src/TextureManager/usecase/TextureManagerCreateTextureFromImageBitmapUseCase.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { execute } from "./TextureManagerCreateTextureFromImageBitmapUseCase"; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + TEXTURE_BINDING: 0x04, + COPY_DST: 0x02, + RENDER_ATTACHMENT: 0x10 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +describe("TextureManagerCreateTextureFromImageBitmapUseCase", () => +{ + const createMockDevice = () => + { + const mockTexture = { "label": "mockTexture" }; + return { + "createTexture": vi.fn(() => mockTexture), + "queue": { + "copyExternalImageToTexture": vi.fn() + }, + "_mockTexture": mockTexture + } as unknown as GPUDevice & { _mockTexture: any }; + }; + + const createMockImageBitmap = (width: number = 100, height: number = 100): ImageBitmap => + { + return { width, height } as unknown as ImageBitmap; + }; + + describe("texture creation", () => + { + it("should create texture with ImageBitmap dimensions", () => + { + const device = createMockDevice(); + const textures = new Map(); + const imageBitmap = createMockImageBitmap(512, 256); + + execute(device, textures, "test", imageBitmap); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "size": { "width": 512, "height": 256 } + }) + ); + }); + + it("should create texture with rgba8unorm format", () => + { + const device = createMockDevice(); + const textures = new Map(); + const imageBitmap = createMockImageBitmap(); + + execute(device, textures, "test", imageBitmap); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "format": "rgba8unorm" + }) + ); + }); + + it("should create texture with correct usage flags", () => + { + const device = createMockDevice(); + const textures = new Map(); + const imageBitmap = createMockImageBitmap(); + + execute(device, textures, "test", imageBitmap); + + const expectedUsage = + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT; + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "usage": expectedUsage + }) + ); + }); + }); + + describe("image copy", () => + { + it("should copy ImageBitmap to texture", () => + { + const device = createMockDevice(); + const textures = new Map(); + const imageBitmap = createMockImageBitmap(); + + execute(device, textures, "test", imageBitmap); + + expect(device.queue.copyExternalImageToTexture).toHaveBeenCalled(); + }); + + it("should use ImageBitmap as source with flipY", () => + { + const device = createMockDevice(); + const textures = new Map(); + const imageBitmap = createMockImageBitmap(); + + execute(device, textures, "test", imageBitmap); + + expect(device.queue.copyExternalImageToTexture).toHaveBeenCalledWith( + { "source": imageBitmap, "flipY": true }, + expect.anything(), + expect.anything() + ); + }); + + it("should copy to created texture with premultipliedAlpha", () => + { + const device = createMockDevice(); + const textures = new Map(); + const imageBitmap = createMockImageBitmap(); + + execute(device, textures, "test", imageBitmap); + + expect(device.queue.copyExternalImageToTexture).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + "texture": (device as any)._mockTexture, + "premultipliedAlpha": true + }), + expect.anything() + ); + }); + + it("should use ImageBitmap dimensions for copy size", () => + { + const device = createMockDevice(); + const textures = new Map(); + const imageBitmap = createMockImageBitmap(320, 240); + + execute(device, textures, "test", imageBitmap); + + expect(device.queue.copyExternalImageToTexture).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { "width": 320, "height": 240 } + ); + }); + }); + + describe("texture storage", () => + { + it("should add texture to map with name", () => + { + const device = createMockDevice(); + const textures = new Map(); + const imageBitmap = createMockImageBitmap(); + + execute(device, textures, "myBitmapTexture", imageBitmap); + + expect(textures.has("myBitmapTexture")).toBe(true); + expect(textures.get("myBitmapTexture")).toBe((device as any)._mockTexture); + }); + + it("should overwrite existing texture with same name", () => + { + const device = createMockDevice(); + const textures = new Map(); + const existingTexture = { "label": "existing" } as unknown as GPUTexture; + textures.set("test", existingTexture); + const imageBitmap = createMockImageBitmap(); + + execute(device, textures, "test", imageBitmap); + + expect(textures.get("test")).toBe((device as any)._mockTexture); + expect(textures.get("test")).not.toBe(existingTexture); + }); + }); + + describe("return value", () => + { + it("should return created texture", () => + { + const device = createMockDevice(); + const textures = new Map(); + const imageBitmap = createMockImageBitmap(); + + const result = execute(device, textures, "test", imageBitmap); + + expect(result).toBe((device as any)._mockTexture); + }); + }); +}); diff --git a/packages/webgpu/src/TextureManager/usecase/TextureManagerCreateTextureFromImageBitmapUseCase.ts b/packages/webgpu/src/TextureManager/usecase/TextureManagerCreateTextureFromImageBitmapUseCase.ts new file mode 100644 index 00000000..9fe1f821 --- /dev/null +++ b/packages/webgpu/src/TextureManager/usecase/TextureManagerCreateTextureFromImageBitmapUseCase.ts @@ -0,0 +1,41 @@ +/** + * @description ImageBitmapからテクスチャを作成 + * Create texture from ImageBitmap + * + * @param {GPUDevice} device + * @param {Map} textures + * @param {string} name + * @param {ImageBitmap} image_bitmap + * @return {GPUTexture} + * @method + * @protected + */ +export const execute = ( + device: GPUDevice, + textures: Map, + name: string, + image_bitmap: ImageBitmap +): GPUTexture => { + const texture = device.createTexture({ + "size": { "width": image_bitmap.width, "height": image_bitmap.height }, + "format": "rgba8unorm", + "usage": GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT + }); + + device.queue.copyExternalImageToTexture( + { + "source": image_bitmap, + "flipY": true + }, + { + texture, + "premultipliedAlpha": true + }, + { "width": image_bitmap.width, "height": image_bitmap.height } + ); + + textures.set(name, texture); + return texture; +}; diff --git a/packages/webgpu/src/TextureManager/usecase/TextureManagerCreateTextureFromPixelsUseCase.test.ts b/packages/webgpu/src/TextureManager/usecase/TextureManagerCreateTextureFromPixelsUseCase.test.ts new file mode 100644 index 00000000..66e4cb5f --- /dev/null +++ b/packages/webgpu/src/TextureManager/usecase/TextureManagerCreateTextureFromPixelsUseCase.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { execute } from "./TextureManagerCreateTextureFromPixelsUseCase"; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + TEXTURE_BINDING: 0x04, + COPY_DST: 0x02, + RENDER_ATTACHMENT: 0x10 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +describe("TextureManagerCreateTextureFromPixelsUseCase", () => +{ + const createMockDevice = () => + { + const mockTexture = { "label": "mockTexture" }; + return { + "createTexture": vi.fn(() => mockTexture), + "queue": { + "writeTexture": vi.fn() + }, + "_mockTexture": mockTexture + } as unknown as GPUDevice & { _mockTexture: any }; + }; + + describe("texture creation", () => + { + it("should create texture with correct size", () => + { + const device = createMockDevice(); + const textures = new Map(); + const pixels = new Uint8Array(256 * 128 * 4); + + execute(device, textures, "test", pixels, 256, 128); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "size": { "width": 256, "height": 128 } + }) + ); + }); + + it("should create texture with rgba8unorm format", () => + { + const device = createMockDevice(); + const textures = new Map(); + const pixels = new Uint8Array(100 * 100 * 4); + + execute(device, textures, "test", pixels, 100, 100); + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "format": "rgba8unorm" + }) + ); + }); + + it("should create texture with correct usage flags", () => + { + const device = createMockDevice(); + const textures = new Map(); + const pixels = new Uint8Array(100 * 100 * 4); + + execute(device, textures, "test", pixels, 100, 100); + + const expectedUsage = + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT; + + expect(device.createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + "usage": expectedUsage + }) + ); + }); + }); + + describe("pixel data writing", () => + { + it("should write pixel data to texture", () => + { + const device = createMockDevice(); + const textures = new Map(); + const pixels = new Uint8Array(100 * 100 * 4); + + execute(device, textures, "test", pixels, 100, 100); + + expect(device.queue.writeTexture).toHaveBeenCalled(); + }); + + it("should write to texture with correct target", () => + { + const device = createMockDevice(); + const textures = new Map(); + const pixels = new Uint8Array(100 * 100 * 4); + + execute(device, textures, "test", pixels, 100, 100); + + expect(device.queue.writeTexture).toHaveBeenCalledWith( + { "texture": (device as any)._mockTexture }, + expect.anything(), + expect.anything(), + expect.anything() + ); + }); + + it("should write with correct bytesPerRow", () => + { + const device = createMockDevice(); + const textures = new Map(); + const pixels = new Uint8Array(256 * 128 * 4); + + execute(device, textures, "test", pixels, 256, 128); + + expect(device.queue.writeTexture).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + "bytesPerRow": 256 * 4 // width * 4 bytes per pixel + }), + expect.anything() + ); + }); + + it("should write with correct extent", () => + { + const device = createMockDevice(); + const textures = new Map(); + const pixels = new Uint8Array(320 * 240 * 4); + + execute(device, textures, "test", pixels, 320, 240); + + expect(device.queue.writeTexture).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + { "width": 320, "height": 240 } + ); + }); + }); + + describe("texture storage", () => + { + it("should add texture to map with name", () => + { + const device = createMockDevice(); + const textures = new Map(); + const pixels = new Uint8Array(100 * 100 * 4); + + execute(device, textures, "myTexture", pixels, 100, 100); + + expect(textures.has("myTexture")).toBe(true); + expect(textures.get("myTexture")).toBe((device as any)._mockTexture); + }); + + it("should overwrite existing texture with same name", () => + { + const device = createMockDevice(); + const textures = new Map(); + const existingTexture = { "label": "existing" } as unknown as GPUTexture; + textures.set("test", existingTexture); + const pixels = new Uint8Array(100 * 100 * 4); + + execute(device, textures, "test", pixels, 100, 100); + + expect(textures.get("test")).toBe((device as any)._mockTexture); + expect(textures.get("test")).not.toBe(existingTexture); + }); + }); + + describe("return value", () => + { + it("should return created texture", () => + { + const device = createMockDevice(); + const textures = new Map(); + const pixels = new Uint8Array(100 * 100 * 4); + + const result = execute(device, textures, "test", pixels, 100, 100); + + expect(result).toBe((device as any)._mockTexture); + }); + }); + + describe("byte offset handling", () => + { + it("should pass pixel buffer to writeTexture", () => + { + const device = createMockDevice(); + const textures = new Map(); + const pixels = new Uint8Array(100 * 100 * 4); + + execute(device, textures, "test", pixels, 100, 100); + + expect(device.queue.writeTexture).toHaveBeenCalledWith( + expect.anything(), + pixels.buffer, + expect.objectContaining({ + "offset": 0 + }), + expect.anything() + ); + }); + }); +}); diff --git a/packages/webgpu/src/TextureManager/usecase/TextureManagerCreateTextureFromPixelsUseCase.ts b/packages/webgpu/src/TextureManager/usecase/TextureManagerCreateTextureFromPixelsUseCase.ts new file mode 100644 index 00000000..9cf07105 --- /dev/null +++ b/packages/webgpu/src/TextureManager/usecase/TextureManagerCreateTextureFromPixelsUseCase.ts @@ -0,0 +1,40 @@ +/** + * @description ピクセルデータからテクスチャを作成 + * Create texture from pixel data + * + * @param {GPUDevice} device + * @param {Map} textures + * @param {string} name + * @param {Uint8Array} pixels + * @param {number} width + * @param {number} height + * @return {GPUTexture} + * @method + * @protected + */ +export const execute = ( + device: GPUDevice, + textures: Map, + name: string, + pixels: Uint8Array, + width: number, + height: number +): GPUTexture => { + const texture = device.createTexture({ + "size": { width, height }, + "format": "rgba8unorm", + "usage": GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT + }); + + device.queue.writeTexture( + { texture }, + pixels.buffer, + { "bytesPerRow": width * 4, "offset": pixels.byteOffset }, + { width, height } + ); + + textures.set(name, texture); + return texture; +}; diff --git a/packages/webgpu/src/TexturePool.test.ts b/packages/webgpu/src/TexturePool.test.ts new file mode 100644 index 00000000..e3dfc9e7 --- /dev/null +++ b/packages/webgpu/src/TexturePool.test.ts @@ -0,0 +1,355 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + TexturePool, + initTexturePool, + getTexturePool, + clearTexturePool +} from "./TexturePool"; + +// Mock GPUTextureUsage +const GPUTextureUsage = { + TEXTURE_BINDING: 0x04, + COPY_DST: 0x02, + RENDER_ATTACHMENT: 0x10 +}; +(globalThis as any).GPUTextureUsage = GPUTextureUsage; + +// Mock usecase and service modules (bucket-based Map API) +vi.mock("./TexturePool/usecase/TexturePoolAcquireUseCase", () => ({ + "execute": vi.fn((device, buckets, width, height, format, usage, currentFrame, maxPoolSize, totalCount) => { + // Power-of-2 bucket key + const po2W = Math.max(1, 1 << Math.ceil(Math.log2(width))); + const po2H = Math.max(1, 1 << Math.ceil(Math.log2(height))); + const key = `${po2W}_${po2H}_${format}`; + + let bucket = buckets.get(key); + if (!bucket) { + bucket = []; + buckets.set(key, bucket); + } + + // Check for reusable texture in bucket + for (const entry of bucket) { + if (!entry.inUse && entry.width === po2W && entry.height === po2H) { + entry.inUse = true; + entry.lastUsedFrame = currentFrame; + return entry.texture; + } + } + // Create new texture + const texture = { + "width": po2W, + "height": po2H, + "format": format, + "destroy": vi.fn() + }; + bucket.push({ + texture, + "width": po2W, + "height": po2H, + format, + "inUse": true, + "lastUsedFrame": currentFrame + }); + totalCount[0]++; + return texture; + }) +})); + +vi.mock("./TexturePool/service/TexturePoolReleaseService", () => ({ + "execute": vi.fn((buckets, texture, currentFrame) => { + for (const bucket of buckets.values()) { + const entry = bucket.find((e: any) => e.texture === texture); + if (entry) { + entry.inUse = false; + entry.lastUsedFrame = currentFrame; + return; + } + } + }) +})); + +vi.mock("./TexturePool/service/TexturePoolCleanupService", () => ({ + "execute": vi.fn((buckets, currentFrame, threshold, totalCount) => { + // Remove old unused textures + for (const [key, bucket] of buckets.entries()) { + for (let i = bucket.length - 1; i >= 0; i--) { + const entry = bucket[i]; + if (!entry.inUse && currentFrame - entry.lastUsedFrame > threshold) { + entry.texture.destroy(); + bucket.splice(i, 1); + totalCount[0]--; + } + } + if (bucket.length === 0) { + buckets.delete(key); + } + } + }) +})); + +describe("TexturePool", () => +{ + const createMockDevice = (): GPUDevice => + { + return { + "createTexture": vi.fn((descriptor) => ({ + "width": descriptor.size.width, + "height": descriptor.size.height, + "format": descriptor.format, + "destroy": vi.fn() + })) + } as unknown as GPUDevice; + }; + + beforeEach(() => + { + vi.clearAllMocks(); + clearTexturePool(); + }); + + describe("TexturePool class", () => + { + it("should create instance with device", () => + { + const device = createMockDevice(); + const pool = new TexturePool(device); + + expect(pool).toBeDefined(); + }); + + it("should initialize with empty stats", () => + { + const device = createMockDevice(); + const pool = new TexturePool(device); + + const stats = pool.getStats(); + expect(stats.total).toBe(0); + expect(stats.inUse).toBe(0); + expect(stats.available).toBe(0); + }); + + describe("beginFrame", () => + { + it("should increment frame counter", () => + { + const device = createMockDevice(); + const pool = new TexturePool(device); + + pool.beginFrame(); + pool.beginFrame(); + pool.beginFrame(); + + // Frame counter is internal, but cleanup triggers every 60 frames + expect(() => pool.beginFrame()).not.toThrow(); + }); + + it("should trigger cleanup at frame intervals", () => + { + const device = createMockDevice(); + const pool = new TexturePool(device); + + // Run for 60 frames to trigger cleanup + for (let i = 0; i < 60; i++) { + pool.beginFrame(); + } + + expect(() => pool.beginFrame()).not.toThrow(); + }); + }); + + describe("acquire", () => + { + it("should acquire texture with specified dimensions", () => + { + const device = createMockDevice(); + const pool = new TexturePool(device); + + const texture = pool.acquire(256, 256); + + expect(texture).toBeDefined(); + expect(texture.width).toBe(256); + expect(texture.height).toBe(256); + }); + + it("should update stats when acquiring", () => + { + const device = createMockDevice(); + const pool = new TexturePool(device); + + pool.acquire(128, 128); + + const stats = pool.getStats(); + expect(stats.total).toBe(1); + expect(stats.inUse).toBe(1); + }); + + it("should reuse released texture with same dimensions", () => + { + const device = createMockDevice(); + const pool = new TexturePool(device); + + const texture1 = pool.acquire(256, 256); + pool.release(texture1); + const texture2 = pool.acquire(256, 256); + + expect(texture1).toBe(texture2); + }); + + it("should create new texture for different dimensions", () => + { + const device = createMockDevice(); + const pool = new TexturePool(device); + + const texture1 = pool.acquire(256, 256); + const texture2 = pool.acquire(512, 512); + + expect(texture1).not.toBe(texture2); + }); + + it("should use default format and usage", () => + { + const device = createMockDevice(); + const pool = new TexturePool(device); + + const texture = pool.acquire(128, 128); + + expect(texture.format).toBe("rgba8unorm"); + }); + + it("should accept custom format", () => + { + const device = createMockDevice(); + const pool = new TexturePool(device); + + const texture = pool.acquire(128, 128, "rgba16float"); + + expect(texture.format).toBe("rgba16float"); + }); + }); + + describe("release", () => + { + it("should mark texture as available", () => + { + const device = createMockDevice(); + const pool = new TexturePool(device); + + const texture = pool.acquire(256, 256); + pool.release(texture); + + const stats = pool.getStats(); + expect(stats.inUse).toBe(0); + expect(stats.available).toBe(1); + }); + + it("should allow reuse after release", () => + { + const device = createMockDevice(); + const pool = new TexturePool(device); + + const texture1 = pool.acquire(256, 256); + pool.release(texture1); + + const stats = pool.getStats(); + expect(stats.available).toBe(1); + }); + }); + + describe("getStats", () => + { + it("should return accurate pool statistics", () => + { + const device = createMockDevice(); + const pool = new TexturePool(device); + + pool.acquire(128, 128); + pool.acquire(256, 256); + const tex3 = pool.acquire(512, 512); + pool.release(tex3); + + const stats = pool.getStats(); + expect(stats.total).toBe(3); + expect(stats.inUse).toBe(2); + expect(stats.available).toBe(1); + }); + }); + + describe("dispose", () => + { + it("should destroy all textures", () => + { + const device = createMockDevice(); + const pool = new TexturePool(device); + + const tex1 = pool.acquire(128, 128); + const tex2 = pool.acquire(256, 256); + + pool.dispose(); + + expect(tex1.destroy).toHaveBeenCalled(); + expect(tex2.destroy).toHaveBeenCalled(); + }); + + it("should reset pool to empty", () => + { + const device = createMockDevice(); + const pool = new TexturePool(device); + + pool.acquire(128, 128); + pool.acquire(256, 256); + + pool.dispose(); + + const stats = pool.getStats(); + expect(stats.total).toBe(0); + }); + }); + }); + + describe("global functions", () => + { + describe("initTexturePool", () => + { + it("should initialize global pool", () => + { + const device = createMockDevice(); + + initTexturePool(device); + + expect(getTexturePool()).not.toBeNull(); + }); + }); + + describe("getTexturePool", () => + { + it("should return pool after initialization", () => + { + const device = createMockDevice(); + initTexturePool(device); + + expect(getTexturePool()).toBeInstanceOf(TexturePool); + }); + }); + + describe("clearTexturePool", () => + { + it("should dispose pool", () => + { + const device = createMockDevice(); + initTexturePool(device); + const pool = getTexturePool(); + const tex = pool!.acquire(128, 128); + + clearTexturePool(); + + expect(tex.destroy).toHaveBeenCalled(); + }); + + it("should not throw when pool is null", () => + { + expect(() => clearTexturePool()).not.toThrow(); + }); + }); + }); +}); diff --git a/packages/webgpu/src/TexturePool.ts b/packages/webgpu/src/TexturePool.ts new file mode 100644 index 00000000..65891cbf --- /dev/null +++ b/packages/webgpu/src/TexturePool.ts @@ -0,0 +1,171 @@ +import type { ITexturePoolBuckets } from "./interface/IPooledTexture"; +import { execute as texturePoolAcquireUseCase } from "./TexturePool/usecase/TexturePoolAcquireUseCase"; +import { execute as texturePoolReleaseService } from "./TexturePool/service/TexturePoolReleaseService"; +import { execute as texturePoolCleanupService } from "./TexturePool/service/TexturePoolCleanupService"; + +/** + * @description プールの最大サイズ + */ +const MAX_POOL_SIZE = 32; + +/** + * @description キャッシュのクリーンアップ閾値(フレーム数) + */ +const CACHE_CLEANUP_THRESHOLD = 180; // 3秒(60FPS想定) + +/** + * @description テクスチャプールマネージャー(Power-of-2バケット版) + * Texture pool manager for WebGPU optimization + * + * リクエストサイズをPower-of-2に切り上げてバケット化。 + * 同一バケット内で高いキャッシュヒット率を実現。 + * LRUベースで未使用テクスチャを回収。 + */ +export class TexturePool +{ + private device: GPUDevice; + private buckets: ITexturePoolBuckets; + private currentFrame: number; + private totalCount: number[]; + + /** + * @param {GPUDevice} device + * @constructor + */ + constructor(device: GPUDevice) + { + this.device = device; + this.buckets = new Map(); + this.currentFrame = 0; + this.totalCount = [0]; + } + + /** + * @description フレーム開始時に呼び出し + * @return {void} + */ + beginFrame(): void + { + this.currentFrame++; + + // 定期的にプールをクリーンアップ(LRU回収) + if (this.currentFrame % 60 === 0) { + texturePoolCleanupService(this.buckets, this.currentFrame, CACHE_CLEANUP_THRESHOLD, this.totalCount); + } + } + + /** + * @description テクスチャを取得または作成 + * @param {number} width - テクスチャの幅 + * @param {number} height - テクスチャの高さ + * @param {GPUTextureFormat} format - テクスチャフォーマット + * @param {GPUTextureUsageFlags} usage - テクスチャ使用フラグ + * @return {GPUTexture} + */ + acquire( + width: number, + height: number, + format: GPUTextureFormat = "rgba8unorm", + usage: GPUTextureUsageFlags = GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT + ): GPUTexture { + return texturePoolAcquireUseCase( + this.device, + this.buckets, + width, + height, + format, + usage, + this.currentFrame, + MAX_POOL_SIZE, + this.totalCount + ); + } + + /** + * @description テクスチャをプールに返却 + * @param {GPUTexture} texture - 返却するテクスチャ + * @return {void} + */ + release(texture: GPUTexture): void + { + texturePoolReleaseService(this.buckets, texture, this.currentFrame); + } + + /** + * @description プール統計を取得 + * @return {{ total: number, inUse: number, available: number }} + */ + getStats(): { total: number; inUse: number; available: number } + { + let inUse = 0; + let available = 0; + + for (const bucket of this.buckets.values()) { + for (const entry of bucket) { + if (entry.inUse) { + inUse++; + } else { + available++; + } + } + } + + return { + "total": this.totalCount[0], + inUse, + available + }; + } + + /** + * @description 解放 + * @return {void} + */ + dispose(): void + { + for (const bucket of this.buckets.values()) { + for (const entry of bucket) { + entry.texture.destroy(); + } + } + this.buckets.clear(); + this.totalCount[0] = 0; + } +} + +/** + * @description グローバルテクスチャプールインスタンス + */ +let $texturePool: TexturePool | null = null; + +/** + * @description テクスチャプールを初期化 + * @param {GPUDevice} device + * @return {void} + */ +export const initTexturePool = (device: GPUDevice): void => +{ + $texturePool = new TexturePool(device); +}; + +/** + * @description テクスチャプールを取得 + * @return {TexturePool | null} + */ +export const getTexturePool = (): TexturePool | null => +{ + return $texturePool; +}; + +/** + * @description テクスチャプールをクリア + * @return {void} + */ +export const clearTexturePool = (): void => +{ + if ($texturePool) { + $texturePool.dispose(); + } +}; diff --git a/packages/webgpu/src/TexturePool/service/TexturePoolCleanupService.test.ts b/packages/webgpu/src/TexturePool/service/TexturePoolCleanupService.test.ts new file mode 100644 index 00000000..d7dcaf61 --- /dev/null +++ b/packages/webgpu/src/TexturePool/service/TexturePoolCleanupService.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { IPooledTexture, ITexturePoolBuckets } from "../../interface/IPooledTexture"; +import { execute } from "./TexturePoolCleanupService"; + +describe("TexturePoolCleanupService", () => +{ + let buckets: ITexturePoolBuckets; + let totalCount: number[]; + + const createMockEntry = ( + lastUsedFrame: number, + inUse: boolean = false + ): IPooledTexture => ({ + "texture": { + "destroy": vi.fn() + } as unknown as GPUTexture, + "width": 256, + "height": 256, + "format": "rgba8unorm" as GPUTextureFormat, + inUse, + lastUsedFrame + }); + + beforeEach(() => + { + buckets = new Map(); + totalCount = [0]; + }); + + it("should remove old unused entries", () => + { + const old = createMockEntry(10, false); + const recent = createMockEntry(90, false); + buckets.set("256_256_rgba8unorm", [old, recent]); + totalCount[0] = 2; + + execute(buckets, 100, 50, totalCount); + + const bucket = buckets.get("256_256_rgba8unorm")!; + expect(bucket.length).toBe(1); + expect(bucket[0]).toBe(recent); + expect(old.texture.destroy).toHaveBeenCalled(); + expect(totalCount[0]).toBe(1); + }); + + it("should keep entries that are in use even if old", () => + { + const oldInUse = createMockEntry(10, true); + const oldNotInUse = createMockEntry(10, false); + buckets.set("256_256_rgba8unorm", [oldInUse, oldNotInUse]); + totalCount[0] = 2; + + execute(buckets, 100, 50, totalCount); + + const bucket = buckets.get("256_256_rgba8unorm")!; + expect(bucket.length).toBe(1); + expect(bucket[0]).toBe(oldInUse); + expect(oldInUse.texture.destroy).not.toHaveBeenCalled(); + expect(oldNotInUse.texture.destroy).toHaveBeenCalled(); + expect(totalCount[0]).toBe(1); + }); + + it("should handle empty pool", () => + { + expect(() => execute(buckets, 100, 50, totalCount)).not.toThrow(); + expect(buckets.size).toBe(0); + }); + + it("should remove all old unused entries and delete empty bucket", () => + { + buckets.set("256_256_rgba8unorm", [ + createMockEntry(0, false), + createMockEntry(10, false), + createMockEntry(20, false) + ]); + totalCount[0] = 3; + + execute(buckets, 100, 30, totalCount); + + expect(buckets.has("256_256_rgba8unorm")).toBe(false); + expect(totalCount[0]).toBe(0); + }); + + it("should keep recent unused entries", () => + { + const recent1 = createMockEntry(80, false); + const recent2 = createMockEntry(90, false); + buckets.set("256_256_rgba8unorm", [recent1, recent2]); + totalCount[0] = 2; + + execute(buckets, 100, 50, totalCount); + + const bucket = buckets.get("256_256_rgba8unorm")!; + expect(bucket.length).toBe(2); + expect(recent1.texture.destroy).not.toHaveBeenCalled(); + expect(recent2.texture.destroy).not.toHaveBeenCalled(); + expect(totalCount[0]).toBe(2); + }); + + it("should call destroy on removed textures", () => + { + const entry = createMockEntry(10, false); + buckets.set("256_256_rgba8unorm", [entry]); + totalCount[0] = 1; + + execute(buckets, 100, 50, totalCount); + + expect(entry.texture.destroy).toHaveBeenCalledTimes(1); + }); + + it("should handle mixed pool across buckets correctly", () => + { + const oldUnused = createMockEntry(10, false); + const oldInUse = createMockEntry(10, true); + const recentUnused = createMockEntry(90, false); + const recentInUse = createMockEntry(90, true); + + buckets.set("256_256_rgba8unorm", [oldUnused, oldInUse]); + buckets.set("512_512_rgba8unorm", [recentUnused, recentInUse]); + totalCount[0] = 4; + + execute(buckets, 100, 50, totalCount); + + const bucket256 = buckets.get("256_256_rgba8unorm")!; + const bucket512 = buckets.get("512_512_rgba8unorm")!; + expect(bucket256.length).toBe(1); + expect(bucket256[0]).toBe(oldInUse); + expect(bucket512.length).toBe(2); + expect(totalCount[0]).toBe(3); + }); +}); diff --git a/packages/webgpu/src/TexturePool/service/TexturePoolCleanupService.ts b/packages/webgpu/src/TexturePool/service/TexturePoolCleanupService.ts new file mode 100644 index 00000000..cb2ede79 --- /dev/null +++ b/packages/webgpu/src/TexturePool/service/TexturePoolCleanupService.ts @@ -0,0 +1,36 @@ +import type { ITexturePoolBuckets } from "../../interface/IPooledTexture"; + +/** + * @description 古いプールエントリをクリーンアップ(バケットMap版 LRU回収) + * Cleanup old pool entries (bucket Map version, LRU eviction) + * + * @param {ITexturePoolBuckets} buckets + * @param {number} currentFrame + * @param {number} threshold - フレーム数閾値 + * @param {number[]} totalCount - [0]に現在の合計数を格納 + * @return {void} + * @method + * @protected + */ +export const execute = ( + buckets: ITexturePoolBuckets, + currentFrame: number, + threshold: number, + totalCount: number[] +): void => { + const frameThreshold = currentFrame - threshold; + + for (const [key, bucket] of buckets) { + for (let i = bucket.length - 1; i >= 0; i--) { + const entry = bucket[i]; + if (!entry.inUse && entry.lastUsedFrame < frameThreshold) { + entry.texture.destroy(); + bucket.splice(i, 1); + totalCount[0]--; + } + } + if (bucket.length === 0) { + buckets.delete(key); + } + } +}; diff --git a/packages/webgpu/src/TexturePool/service/TexturePoolEvictOldestService.test.ts b/packages/webgpu/src/TexturePool/service/TexturePoolEvictOldestService.test.ts new file mode 100644 index 00000000..25cc4071 --- /dev/null +++ b/packages/webgpu/src/TexturePool/service/TexturePoolEvictOldestService.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { IPooledTexture } from "../../interface/IPooledTexture"; +import { execute } from "./TexturePoolEvictOldestService"; + +describe("TexturePoolEvictOldestService", () => +{ + let pool: IPooledTexture[]; + + const createMockEntry = ( + lastUsedFrame: number, + inUse: boolean = false + ): IPooledTexture => ({ + "texture": { + "destroy": vi.fn() + } as unknown as GPUTexture, + "width": 256, + "height": 256, + "format": "rgba8unorm" as GPUTextureFormat, + inUse, + lastUsedFrame + }); + + beforeEach(() => + { + pool = []; + }); + + it("should evict the oldest unused entry", () => + { + const oldest = createMockEntry(10, false); + const middle = createMockEntry(50, false); + const recent = createMockEntry(100, false); + pool.push(oldest, middle, recent); + + execute(pool); + + expect(pool.length).toBe(2); + expect(pool).not.toContain(oldest); + expect(oldest.texture.destroy).toHaveBeenCalled(); + }); + + it("should skip entries that are in use", () => + { + const oldestInUse = createMockEntry(10, true); + const oldestNotInUse = createMockEntry(50, false); + pool.push(oldestInUse, oldestNotInUse); + + execute(pool); + + expect(pool.length).toBe(1); + expect(pool[0]).toBe(oldestInUse); + expect(oldestInUse.texture.destroy).not.toHaveBeenCalled(); + expect(oldestNotInUse.texture.destroy).toHaveBeenCalled(); + }); + + it("should handle empty pool", () => + { + expect(() => execute(pool)).not.toThrow(); + expect(pool.length).toBe(0); + }); + + it("should not evict anything if all entries are in use", () => + { + pool.push( + createMockEntry(10, true), + createMockEntry(50, true), + createMockEntry(100, true) + ); + + execute(pool); + + expect(pool.length).toBe(3); + }); + + it("should only evict one entry per call", () => + { + pool.push( + createMockEntry(10, false), + createMockEntry(20, false), + createMockEntry(30, false) + ); + + execute(pool); + expect(pool.length).toBe(2); + + execute(pool); + expect(pool.length).toBe(1); + }); + + it("should call destroy on evicted texture", () => + { + const entry = createMockEntry(10, false); + pool.push(entry); + + execute(pool); + + expect(entry.texture.destroy).toHaveBeenCalledTimes(1); + }); + + it("should correctly identify oldest among multiple unused", () => + { + const recent = createMockEntry(100, false); + const oldest = createMockEntry(5, false); + const middle = createMockEntry(50, false); + pool.push(recent, oldest, middle); + + execute(pool); + + expect(pool.length).toBe(2); + expect(pool).not.toContain(oldest); + expect(pool).toContain(recent); + expect(pool).toContain(middle); + }); +}); diff --git a/packages/webgpu/src/TexturePool/service/TexturePoolEvictOldestService.ts b/packages/webgpu/src/TexturePool/service/TexturePoolEvictOldestService.ts new file mode 100644 index 00000000..34184e61 --- /dev/null +++ b/packages/webgpu/src/TexturePool/service/TexturePoolEvictOldestService.ts @@ -0,0 +1,28 @@ +import type { IPooledTexture } from "../../interface/IPooledTexture"; + +/** + * @description 最も古い未使用エントリを削除 + * Evict the oldest unused pool entry + * + * @param {IPooledTexture[]} pool + * @return {void} + * @method + * @protected + */ +export const execute = (pool: IPooledTexture[]): void => { + let oldestIndex = -1; + let oldestFrame = Infinity; + + for (let i = 0; i < pool.length; i++) { + const entry = pool[i]; + if (!entry.inUse && entry.lastUsedFrame < oldestFrame) { + oldestFrame = entry.lastUsedFrame; + oldestIndex = i; + } + } + + if (oldestIndex >= 0) { + pool[oldestIndex].texture.destroy(); + pool.splice(oldestIndex, 1); + } +}; diff --git a/packages/webgpu/src/TexturePool/service/TexturePoolReleaseService.test.ts b/packages/webgpu/src/TexturePool/service/TexturePoolReleaseService.test.ts new file mode 100644 index 00000000..fdda4f1b --- /dev/null +++ b/packages/webgpu/src/TexturePool/service/TexturePoolReleaseService.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { ITexturePoolBuckets } from "../../interface/IPooledTexture"; +import { execute } from "./TexturePoolReleaseService"; + +describe("TexturePoolReleaseService", () => +{ + let buckets: ITexturePoolBuckets; + + beforeEach(() => + { + buckets = new Map(); + }); + + it("should release texture back to pool", () => + { + const mockTexture = { "id": 1 } as unknown as GPUTexture; + const entry = { + "texture": mockTexture, + "width": 256, + "height": 256, + "format": "rgba8unorm" as GPUTextureFormat, + "inUse": true, + "lastUsedFrame": 0 + }; + buckets.set("256_256_rgba8unorm", [entry]); + + execute(buckets, mockTexture, 100); + + expect(entry.inUse).toBe(false); + expect(entry.lastUsedFrame).toBe(100); + }); + + it("should update lastUsedFrame on release", () => + { + const mockTexture = { "id": 1 } as unknown as GPUTexture; + const entry = { + "texture": mockTexture, + "width": 256, + "height": 256, + "format": "rgba8unorm" as GPUTextureFormat, + "inUse": true, + "lastUsedFrame": 50 + }; + buckets.set("256_256_rgba8unorm", [entry]); + + execute(buckets, mockTexture, 200); + + expect(entry.lastUsedFrame).toBe(200); + }); + + it("should only release matching texture", () => + { + const texture1 = { "id": 1 } as unknown as GPUTexture; + const texture2 = { "id": 2 } as unknown as GPUTexture; + const entry1 = { + "texture": texture1, + "width": 256, + "height": 256, + "format": "rgba8unorm" as GPUTextureFormat, + "inUse": true, + "lastUsedFrame": 0 + }; + const entry2 = { + "texture": texture2, + "width": 256, + "height": 256, + "format": "rgba8unorm" as GPUTextureFormat, + "inUse": true, + "lastUsedFrame": 0 + }; + buckets.set("256_256_rgba8unorm", [entry1, entry2]); + + execute(buckets, texture1, 100); + + expect(entry1.inUse).toBe(false); + expect(entry2.inUse).toBe(true); + }); + + it("should handle texture not in pool", () => + { + const unknownTexture = { "id": 999 } as unknown as GPUTexture; + + expect(() => execute(buckets, unknownTexture, 100)).not.toThrow(); + }); + + it("should handle empty pool", () => + { + const mockTexture = { "id": 1 } as unknown as GPUTexture; + + expect(() => execute(buckets, mockTexture, 100)).not.toThrow(); + }); + + it("should find texture across different buckets", () => + { + const texture1 = { "id": 1 } as unknown as GPUTexture; + const texture2 = { "id": 2 } as unknown as GPUTexture; + buckets.set("256_256_rgba8unorm", [{ + "texture": texture1, + "width": 256, + "height": 256, + "format": "rgba8unorm" as GPUTextureFormat, + "inUse": true, + "lastUsedFrame": 0 + }]); + buckets.set("512_512_rgba8unorm", [{ + "texture": texture2, + "width": 512, + "height": 512, + "format": "rgba8unorm" as GPUTextureFormat, + "inUse": true, + "lastUsedFrame": 0 + }]); + + execute(buckets, texture2, 100); + + expect(buckets.get("256_256_rgba8unorm")![0].inUse).toBe(true); + expect(buckets.get("512_512_rgba8unorm")![0].inUse).toBe(false); + }); + + it("should stop after finding first match", () => + { + const mockTexture = { "id": 1 } as unknown as GPUTexture; + const entry1 = { + "texture": mockTexture, + "width": 256, + "height": 256, + "format": "rgba8unorm" as GPUTextureFormat, + "inUse": true, + "lastUsedFrame": 0 + }; + const entry2 = { + "texture": mockTexture, + "width": 256, + "height": 256, + "format": "rgba8unorm" as GPUTextureFormat, + "inUse": true, + "lastUsedFrame": 0 + }; + buckets.set("256_256_rgba8unorm", [entry1, entry2]); + + execute(buckets, mockTexture, 100); + + // Only first matching entry should be released + expect(entry1.inUse).toBe(false); + }); +}); diff --git a/packages/webgpu/src/TexturePool/service/TexturePoolReleaseService.ts b/packages/webgpu/src/TexturePool/service/TexturePoolReleaseService.ts new file mode 100644 index 00000000..15d796ef --- /dev/null +++ b/packages/webgpu/src/TexturePool/service/TexturePoolReleaseService.ts @@ -0,0 +1,28 @@ +import type { ITexturePoolBuckets } from "../../interface/IPooledTexture"; + +/** + * @description テクスチャをプールに返却(バケットMap版) + * Release texture back to pool (bucket Map version) + * + * @param {ITexturePoolBuckets} buckets + * @param {GPUTexture} texture + * @param {number} currentFrame + * @return {void} + * @method + * @protected + */ +export const execute = ( + buckets: ITexturePoolBuckets, + texture: GPUTexture, + currentFrame: number +): void => { + for (const bucket of buckets.values()) { + for (let i = 0; i < bucket.length; i++) { + if (bucket[i].texture === texture) { + bucket[i].inUse = false; + bucket[i].lastUsedFrame = currentFrame; + return; + } + } + } +}; diff --git a/packages/webgpu/src/TexturePool/usecase/TexturePoolAcquireUseCase.test.ts b/packages/webgpu/src/TexturePool/usecase/TexturePoolAcquireUseCase.test.ts new file mode 100644 index 00000000..a2b41d2a --- /dev/null +++ b/packages/webgpu/src/TexturePool/usecase/TexturePoolAcquireUseCase.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ITexturePoolBuckets } from "../../interface/IPooledTexture"; +import { execute } from "./TexturePoolAcquireUseCase"; + +describe("TexturePoolAcquireUseCase", () => +{ + let buckets: ITexturePoolBuckets; + let totalCount: number[]; + + const createMockDevice = () => + { + let textureId = 0; + return { + "createTexture": vi.fn((descriptor) => ({ + "id": ++textureId, + "width": descriptor.size.width, + "height": descriptor.size.height, + "format": descriptor.format, + "destroy": vi.fn() + })) + } as unknown as GPUDevice; + }; + + beforeEach(() => + { + buckets = new Map(); + totalCount = [0]; + }); + + describe("pool matching", () => + { + it("should return exact match from bucket", () => + { + const mockTexture = { "id": 1, "destroy": vi.fn() } as unknown as GPUTexture; + buckets.set("256_256_rgba8unorm", [{ + "texture": mockTexture, + "width": 256, + "height": 256, + "format": "rgba8unorm" as GPUTextureFormat, + "inUse": false, + "lastUsedFrame": 0 + }]); + totalCount[0] = 1; + const device = createMockDevice(); + + const result = execute(device, buckets, 256, 256, "rgba8unorm", 0, 100, 32, totalCount); + + expect(result).toBe(mockTexture); + expect(device.createTexture).not.toHaveBeenCalled(); + }); + + it("should skip entries that are in use", () => + { + const inUseTexture = { "id": 1, "destroy": vi.fn() } as unknown as GPUTexture; + const availableTexture = { "id": 2, "destroy": vi.fn() } as unknown as GPUTexture; + buckets.set("256_256_rgba8unorm", [ + { + "texture": inUseTexture, + "width": 256, + "height": 256, + "format": "rgba8unorm" as GPUTextureFormat, + "inUse": true, + "lastUsedFrame": 0 + }, + { + "texture": availableTexture, + "width": 256, + "height": 256, + "format": "rgba8unorm" as GPUTextureFormat, + "inUse": false, + "lastUsedFrame": 0 + } + ]); + totalCount[0] = 2; + const device = createMockDevice(); + + const result = execute(device, buckets, 256, 256, "rgba8unorm", 0, 100, 32, totalCount); + + expect(result).toBe(availableTexture); + }); + + it("should skip entries with different format (different bucket)", () => + { + const wrongFormatTexture = { "id": 1, "destroy": vi.fn() } as unknown as GPUTexture; + buckets.set("256_256_bgra8unorm", [{ + "texture": wrongFormatTexture, + "width": 256, + "height": 256, + "format": "bgra8unorm" as GPUTextureFormat, + "inUse": false, + "lastUsedFrame": 0 + }]); + totalCount[0] = 1; + const device = createMockDevice(); + + execute(device, buckets, 256, 256, "rgba8unorm", 0, 100, 32, totalCount); + + expect(device.createTexture).toHaveBeenCalled(); + }); + + it("should not match different sizes (different bucket)", () => + { + const device = createMockDevice(); + const mockTexture = { "id": 1, "destroy": vi.fn() } as unknown as GPUTexture; + buckets.set("256_256_rgba8unorm", [{ + "texture": mockTexture, + "width": 256, + "height": 256, + "format": "rgba8unorm" as GPUTextureFormat, + "inUse": false, + "lastUsedFrame": 0 + }]); + totalCount[0] = 1; + + // 200x200 は 256_256 バケットに入らない → 新規作成 + execute(device, buckets, 200, 200, "rgba8unorm", 0, 100, 32, totalCount); + + expect(device.createTexture).toHaveBeenCalled(); + expect(totalCount[0]).toBe(2); + }); + }); + + describe("bucket map structure", () => + { + it("should use exact size as bucket key", () => + { + const device = createMockDevice(); + + execute(device, buckets, 200, 150, "rgba8unorm", 0x10, 100, 32, totalCount); + + expect(device.createTexture).toHaveBeenCalledWith({ + "size": { "width": 200, "height": 150 }, + "format": "rgba8unorm", + "usage": 0x10 + }); + expect(buckets.has("200_150_rgba8unorm")).toBe(true); + }); + + it("should reuse same-size texture from bucket", () => + { + const device = createMockDevice(); + + const tex1 = execute(device, buckets, 200, 150, "rgba8unorm", 0x10, 100, 32, totalCount); + expect(device.createTexture).toHaveBeenCalledTimes(1); + + // テクスチャを返却 + const bucket = buckets.get("200_150_rgba8unorm")!; + bucket[0].inUse = false; + + // 同じサイズを要求 → キャッシュヒット + const tex2 = execute(device, buckets, 200, 150, "rgba8unorm", 0x10, 200, 32, totalCount); + expect(device.createTexture).toHaveBeenCalledTimes(1); + expect(tex2).toBe(tex1); + }); + }); + + describe("texture creation", () => + { + it("should create new texture when pool is empty", () => + { + const device = createMockDevice(); + + execute(device, buckets, 256, 256, "rgba8unorm", 0x10, 100, 32, totalCount); + + expect(device.createTexture).toHaveBeenCalledWith({ + "size": { "width": 256, "height": 256 }, + "format": "rgba8unorm", + "usage": 0x10 + }); + }); + + it("should add new texture to bucket", () => + { + const device = createMockDevice(); + + execute(device, buckets, 256, 256, "rgba8unorm", 0, 100, 32, totalCount); + + const bucket = buckets.get("256_256_rgba8unorm"); + expect(bucket).toBeDefined(); + expect(bucket!.length).toBe(1); + expect(bucket![0].width).toBe(256); + expect(bucket![0].height).toBe(256); + expect(bucket![0].inUse).toBe(true); + expect(bucket![0].lastUsedFrame).toBe(100); + expect(totalCount[0]).toBe(1); + }); + + it("should evict oldest (LRU) when pool is full", () => + { + const device = createMockDevice(); + + // Fill pool with maxPoolSize entries (各異なるバケット) + for (let i = 0; i < 4; i++) { + const size = 100 + i * 50; // 100, 150, 200, 250 + execute(device, buckets, size, size, "rgba8unorm", 0, i, 4, totalCount); + // 返却してavailableにする + for (const bucket of buckets.values()) { + for (const entry of bucket) { + entry.inUse = false; + } + } + } + expect(totalCount[0]).toBe(4); + + // 新しいサイズを要求 → 最も古いもの(frame=0)が削除される + execute(device, buckets, 300, 300, "rgba8unorm", 0, 100, 4, totalCount); + + expect(totalCount[0]).toBe(4); // 1個削除 + 1個追加 + // frame=0のエントリ(100x100)が削除されたはず + expect(buckets.has("100_100_rgba8unorm")).toBe(false); + }); + }); + + describe("frame tracking", () => + { + it("should update lastUsedFrame when acquiring from pool", () => + { + const mockTexture = { "id": 1, "destroy": vi.fn() } as unknown as GPUTexture; + const entry = { + "texture": mockTexture, + "width": 256, + "height": 256, + "format": "rgba8unorm" as GPUTextureFormat, + "inUse": false, + "lastUsedFrame": 50 + }; + buckets.set("256_256_rgba8unorm", [entry]); + totalCount[0] = 1; + const device = createMockDevice(); + + execute(device, buckets, 256, 256, "rgba8unorm", 0, 200, 32, totalCount); + + expect(entry.lastUsedFrame).toBe(200); + }); + + it("should mark texture as in use", () => + { + const mockTexture = { "id": 1, "destroy": vi.fn() } as unknown as GPUTexture; + const entry = { + "texture": mockTexture, + "width": 256, + "height": 256, + "format": "rgba8unorm" as GPUTextureFormat, + "inUse": false, + "lastUsedFrame": 0 + }; + buckets.set("256_256_rgba8unorm", [entry]); + totalCount[0] = 1; + const device = createMockDevice(); + + execute(device, buckets, 256, 256, "rgba8unorm", 0, 100, 32, totalCount); + + expect(entry.inUse).toBe(true); + }); + }); +}); diff --git a/packages/webgpu/src/TexturePool/usecase/TexturePoolAcquireUseCase.ts b/packages/webgpu/src/TexturePool/usecase/TexturePoolAcquireUseCase.ts new file mode 100644 index 00000000..97ca9558 --- /dev/null +++ b/packages/webgpu/src/TexturePool/usecase/TexturePoolAcquireUseCase.ts @@ -0,0 +1,111 @@ +import type { IPooledTexture, ITexturePoolBuckets } from "../../interface/IPooledTexture"; + +/** + * @description バケットキーを生成(exactサイズ + フォーマット) + * + * @param {number} width + * @param {number} height + * @param {GPUTextureFormat} format + * @return {string} + */ +const buildKey = (width: number, height: number, format: GPUTextureFormat): string => +{ + return `${width}_${height}_${format}`; +}; + +/** + * @description テクスチャを取得または作成(バケットMap検索) + * Acquire texture from pool or create new one (bucket Map lookup) + * + * @param {GPUDevice} device + * @param {ITexturePoolBuckets} buckets + * @param {number} width + * @param {number} height + * @param {GPUTextureFormat} format + * @param {GPUTextureUsageFlags} usage + * @param {number} currentFrame + * @param {number} maxPoolSize + * @param {number[]} totalCount - [0]に現在の合計数を格納 + * @return {GPUTexture} + * @method + * @protected + */ +export const execute = ( + device: GPUDevice, + buckets: ITexturePoolBuckets, + width: number, + height: number, + format: GPUTextureFormat, + usage: GPUTextureUsageFlags, + currentFrame: number, + maxPoolSize: number, + totalCount: number[] +): GPUTexture => { + const key = buildKey(width, height, format); + + // バケットから未使用テクスチャを検索(O(1)バケット + O(n)バケット内走査) + const bucket = buckets.get(key); + if (bucket) { + for (let i = 0; i < bucket.length; i++) { + const entry = bucket[i]; + if (!entry.inUse) { + entry.inUse = true; + entry.lastUsedFrame = currentFrame; + return entry.texture; + } + } + } + + // プールが満杯なら最も古い未使用エントリを削除(LRU回収) + if (totalCount[0] >= maxPoolSize) { + let oldestFrame = Infinity; + let oldestKey = ""; + let oldestIdx = -1; + + for (const [bKey, bEntries] of buckets) { + for (let i = 0; i < bEntries.length; i++) { + const e = bEntries[i]; + if (!e.inUse && e.lastUsedFrame < oldestFrame) { + oldestFrame = e.lastUsedFrame; + oldestKey = bKey; + oldestIdx = i; + } + } + } + + if (oldestIdx >= 0) { + const bEntries = buckets.get(oldestKey)!; + bEntries[oldestIdx].texture.destroy(); + bEntries.splice(oldestIdx, 1); + if (bEntries.length === 0) { + buckets.delete(oldestKey); + } + totalCount[0]--; + } + } + + // exactサイズで新規作成 + const texture = device.createTexture({ + "size": { width, height }, + format, + usage + }); + + const entry: IPooledTexture = { + texture, + width, + height, + format, + "lastUsedFrame": currentFrame, + "inUse": true + }; + + if (bucket) { + bucket.push(entry); + } else { + buckets.set(key, [entry]); + } + totalCount[0]++; + + return texture; +}; diff --git a/packages/webgpu/src/WebGPUUtil.test.ts b/packages/webgpu/src/WebGPUUtil.test.ts new file mode 100644 index 00000000..c81c8672 --- /dev/null +++ b/packages/webgpu/src/WebGPUUtil.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + $samples, + $setSamples, + WebGPUUtil, + $context, + $setContext, + $getFloat32Array4, + $poolFloat32Array4 +} from "./WebGPUUtil"; + +describe("WebGPUUtil", () => +{ + describe("$samples", () => + { + it("should default to 1", () => + { + $setSamples(1); + expect($samples).toBe(1); + }); + + it("should set samples", () => + { + $setSamples(4); + expect($samples).toBe(4); + + $setSamples(1); // Reset + }); + }); + + describe("WebGPUUtil class", () => + { + beforeEach(() => + { + WebGPUUtil.setDevicePixelRatio(1); + WebGPUUtil.setRenderMaxSize(8192); + }); + + describe("devicePixelRatio", () => + { + it("should set and get device pixel ratio", () => + { + WebGPUUtil.setDevicePixelRatio(2); + expect(WebGPUUtil.getDevicePixelRatio()).toBe(2); + + WebGPUUtil.setDevicePixelRatio(1.5); + expect(WebGPUUtil.getDevicePixelRatio()).toBe(1.5); + }); + }); + + describe("renderMaxSize", () => + { + it("should set and get render max size", () => + { + WebGPUUtil.setRenderMaxSize(4096); + expect(WebGPUUtil.getRenderMaxSize()).toBe(4096); + + WebGPUUtil.setRenderMaxSize(16384); + expect(WebGPUUtil.getRenderMaxSize()).toBe(16384); + }); + }); + + describe("createFloat32Array", () => + { + it("should create Float32Array with specified length", () => + { + const arr = WebGPUUtil.createFloat32Array(10); + expect(arr).toBeInstanceOf(Float32Array); + expect(arr.length).toBe(10); + }); + }); + + describe("createArray", () => + { + it("should create empty array", () => + { + const arr = WebGPUUtil.createArray(); + expect(Array.isArray(arr)).toBe(true); + expect(arr.length).toBe(0); + }); + }); + + describe("Float32Array4 pool", () => + { + it("should get Float32Array of length 4", () => + { + const arr = WebGPUUtil.getFloat32Array4(); + expect(arr).toBeInstanceOf(Float32Array); + expect(arr.length).toBe(4); + }); + + it("should pool and reuse Float32Array", () => + { + const arr1 = WebGPUUtil.getFloat32Array4(); + arr1[0] = 99; + WebGPUUtil.poolFloat32Array4(arr1); + + const arr2 = WebGPUUtil.getFloat32Array4(); + expect(arr2).toBe(arr1); + expect(arr2[0]).toBe(99); + }); + + it("should not pool arrays of wrong length", () => + { + const wrongArr = new Float32Array(5); + WebGPUUtil.poolFloat32Array4(wrongArr); + + const arr = WebGPUUtil.getFloat32Array4(); + expect(arr).not.toBe(wrongArr); + }); + }); + }); + + describe("context functions", () => + { + it("should set and get context", () => + { + const mockContext = { "id": "test-context" }; + $setContext(mockContext); + expect($context).toBe(mockContext); + + $setContext(null); // Reset + }); + }); + + describe("Float32Array4 helper functions", () => + { + it("should get Float32Array4 via helper", () => + { + const arr = $getFloat32Array4(); + expect(arr).toBeInstanceOf(Float32Array); + expect(arr.length).toBe(4); + }); + + it("should pool Float32Array4 via helper", () => + { + const arr = $getFloat32Array4(); + arr[0] = 123; + $poolFloat32Array4(arr); + + const reused = $getFloat32Array4(); + expect(reused[0]).toBe(123); + }); + }); +}); diff --git a/packages/webgpu/src/WebGPUUtil.ts b/packages/webgpu/src/WebGPUUtil.ts new file mode 100644 index 00000000..d56298c4 --- /dev/null +++ b/packages/webgpu/src/WebGPUUtil.ts @@ -0,0 +1,168 @@ +/** + * @description 描画のサンプリング数(MSAA) + * Number of samples for drawing (MSAA) + * + * @type {number} + * @default 4 + * @protected + * + * @note WebGL版と同じくMSAA 4xをデフォルトで有効化 + * 曲線のアンチエイリアス品質向上のため + */ +export let $samples: number = 4; + +/** + * @description 描画のサンプリング数を変更 + * Change the number of samples for drawing + * + * @param {number} samples + * @return {void} + * @method + * @protected + */ +export const $setSamples = (samples: number): void => +{ + $samples = samples; +}; + +export class WebGPUUtil +{ + private static device: GPUDevice | null = null; + private static devicePixelRatio: number = 1; + private static renderMaxSize: number = 8192; + private static float32Array4Pool: Float32Array[] = []; + + /** + * @description Set GPUDevice + * @param {GPUDevice} gpu_device + * @return {void} + */ + public static setDevice(gpu_device: GPUDevice): void + { + WebGPUUtil.device = gpu_device; + } + + /** + * @description Get GPUDevice + * @return {GPUDevice} + */ + public static getDevice(): GPUDevice + { + if (!WebGPUUtil.device) { + throw new Error("GPUDevice is not initialized"); + } + return WebGPUUtil.device; + } + + /** + * @description Set device pixel ratio + * @param {number} ratio + * @return {void} + */ + public static setDevicePixelRatio(ratio: number): void + { + WebGPUUtil.devicePixelRatio = ratio; + } + + /** + * @description Get device pixel ratio + * @return {number} + */ + public static getDevicePixelRatio(): number + { + return WebGPUUtil.devicePixelRatio; + } + + /** + * @description Set render max size + * @param {number} size + * @return {void} + */ + public static setRenderMaxSize(size: number): void + { + WebGPUUtil.renderMaxSize = size; + } + + /** + * @description Get render max size (for atlas) + * @return {number} + */ + public static getRenderMaxSize(): number + { + return WebGPUUtil.renderMaxSize; + } + + /** + * @description Create Float32Array + * @param {number} length + * @return {Float32Array} + */ + public static createFloat32Array(length: number): Float32Array + { + return new Float32Array(length); + } + + /** + * @description Create generic array + * @return {Array} + */ + public static createArray(): T[] + { + return []; + } + + /** + * @description Get Float32Array(4) from pool + * @return {Float32Array} + */ + public static getFloat32Array4(): Float32Array + { + return WebGPUUtil.float32Array4Pool.length > 0 + ? WebGPUUtil.float32Array4Pool.pop()! + : new Float32Array(4); + } + + /** + * @description Return Float32Array(4) to pool + * @param {Float32Array} array + * @return {void} + */ + public static poolFloat32Array4(array: Float32Array): void + { + if (array.length === 4) { + WebGPUUtil.float32Array4Pool.push(array); + } + } +} + +/** + * @description グローバルコンテキスト(WebGLUtilの$contextに相当) + */ +export let $context: any = null; + +/** + * @description コンテキストを設定 + * @param {any} context + */ +export const $setContext = (context: any): void => +{ + $context = context; +}; + +/** + * @description Float32Array(4) をプールから取得 + * @return {Float32Array} + */ +export const $getFloat32Array4 = (): Float32Array => +{ + return WebGPUUtil.getFloat32Array4(); +}; + +/** + * @description Float32Array(4) をプールに返却 + * @param {Float32Array} array + */ +export const $poolFloat32Array4 = (array: Float32Array): void => +{ + WebGPUUtil.poolFloat32Array4(array); +}; diff --git a/packages/webgpu/src/index.ts b/packages/webgpu/src/index.ts new file mode 100644 index 00000000..91e98bb6 --- /dev/null +++ b/packages/webgpu/src/index.ts @@ -0,0 +1 @@ +export * from "./Context"; diff --git a/packages/webgpu/src/interface/IAttachmentObject.ts b/packages/webgpu/src/interface/IAttachmentObject.ts new file mode 100644 index 00000000..7a3fcc10 --- /dev/null +++ b/packages/webgpu/src/interface/IAttachmentObject.ts @@ -0,0 +1,43 @@ +import type { IColorBufferObject } from "./IColorBufferObject"; +import type { ITextureObject } from "./ITextureObject"; +import type { IStencilBufferObject } from "./IStencilBufferObject"; + +/** + * @description WebGL互換のアタッチメントオブジェクトインターフェース + * WebGL-compatible attachment object interface + * + * WebGLと同じ構造を持つことで、rendererパッケージで両方のContextを + * 同じように扱うことができます。 + */ +export interface IAttachmentObject +{ + id: number; + width: number; + height: number; + clipLevel: number; + msaa: boolean; + mask: boolean; + color: IColorBufferObject | null; + texture: ITextureObject | null; + stencil: IStencilBufferObject | null; + /** + * @description MSAAテクスチャ(sampleCount > 1 の場合に使用) + * MSAA texture (used when sampleCount > 1) + */ + msaaTexture: ITextureObject | null; + /** + * @description MSAAステンシルテクスチャ(sampleCount > 1 の場合に使用) + * MSAA stencil texture (used when sampleCount > 1) + */ + msaaStencil: IStencilBufferObject | null; + /** + * @description ステンシルバッファのクリアが必要かどうか(マスク終了時) + * Whether stencil buffer needs to be cleared (on mask end) + */ + needsStencilClear?: boolean; + /** + * @description クリアが必要なステンシルレベル(ネストマスク終了時) + * Stencil level that needs to be cleared (on nested mask end) + */ + pendingStencilClearLevel?: number; +} diff --git a/packages/webgpu/src/interface/IBlendMode.ts b/packages/webgpu/src/interface/IBlendMode.ts new file mode 100644 index 00000000..95fe0434 --- /dev/null +++ b/packages/webgpu/src/interface/IBlendMode.ts @@ -0,0 +1,16 @@ +export type IBlendMode = + | "normal" + | "layer" + | "multiply" + | "screen" + | "lighten" + | "darken" + | "difference" + | "add" + | "subtract" + | "invert" + | "alpha" + | "erase" + | "overlay" + | "hardlight" + | "copy"; diff --git a/packages/webgpu/src/interface/IBlendState.ts b/packages/webgpu/src/interface/IBlendState.ts new file mode 100644 index 00000000..ba9a54d1 --- /dev/null +++ b/packages/webgpu/src/interface/IBlendState.ts @@ -0,0 +1,8 @@ +/** + * @description WebGPUブレンドステート定義 + * WebGPU blend state definitions + */ +export interface IBlendState { + color: GPUBlendComponent; + alpha: GPUBlendComponent; +} diff --git a/packages/webgpu/src/interface/IBounds.ts b/packages/webgpu/src/interface/IBounds.ts new file mode 100644 index 00000000..762980e7 --- /dev/null +++ b/packages/webgpu/src/interface/IBounds.ts @@ -0,0 +1,7 @@ +export interface IBounds +{ + xMin: number; + yMin: number; + xMax: number; + yMax: number; +} diff --git a/packages/webgpu/src/interface/ICachedBindGroup.ts b/packages/webgpu/src/interface/ICachedBindGroup.ts new file mode 100644 index 00000000..4910cbf3 --- /dev/null +++ b/packages/webgpu/src/interface/ICachedBindGroup.ts @@ -0,0 +1,8 @@ +/** + * @description キャッシュされたBindGroup + * Cached bind group interface + */ +export interface ICachedBindGroup { + bindGroup: GPUBindGroup; + lastUsedFrame: number; +} diff --git a/packages/webgpu/src/interface/IColorBufferObject.ts b/packages/webgpu/src/interface/IColorBufferObject.ts new file mode 100644 index 00000000..4565f6be --- /dev/null +++ b/packages/webgpu/src/interface/IColorBufferObject.ts @@ -0,0 +1,19 @@ +import type { IStencilBufferObject } from "./IStencilBufferObject"; + +/** + * @description WebGPU用カラーバッファオブジェクトインターフェース + * WebGPU color buffer object interface + * + * WebGLのIColorBufferObjectと同様の構造を持ちますが、 + * リソースはGPUTextureを使用します。 + */ +export interface IColorBufferObject +{ + resource: GPUTexture; + view: GPUTextureView; + stencil: IStencilBufferObject; + width: number; + height: number; + area: number; + dirty: boolean; +} diff --git a/packages/webgpu/src/interface/IComplexBlendItem.ts b/packages/webgpu/src/interface/IComplexBlendItem.ts new file mode 100644 index 00000000..ac27a265 --- /dev/null +++ b/packages/webgpu/src/interface/IComplexBlendItem.ts @@ -0,0 +1,20 @@ +import type { Node } from "@next2d/texture-packer"; + +/** + * @description 複雑なブレンドモードの描画キュー + * Complex blend mode rendering queue + */ +export interface IComplexBlendItem { + node: Node; + x_min: number; + y_min: number; + x_max: number; + y_max: number; + color_transform: Float32Array; + matrix: Float32Array; + blend_mode: string; + viewport_width: number; + viewport_height: number; + render_max_size: number; + global_alpha: number; +} diff --git a/packages/webgpu/src/interface/IFilterConfig.ts b/packages/webgpu/src/interface/IFilterConfig.ts new file mode 100644 index 00000000..f4381878 --- /dev/null +++ b/packages/webgpu/src/interface/IFilterConfig.ts @@ -0,0 +1,34 @@ +import type { IAttachmentObject } from "./IAttachmentObject"; +import type { ComputePipelineManager } from "../Compute/ComputePipelineManager"; + +/** + * @description フィルター処理の共通設定 + * Common filter processing configuration + */ +export interface IFilterConfig { + device: GPUDevice; + commandEncoder: GPUCommandEncoder; + bufferManager?: { + acquireUniformBuffer(requiredSize: number): GPUBuffer; + acquireAndWriteUniformBuffer(data: Float32Array, byteLength?: number): GPUBuffer; + }; + frameBufferManager: { + createTemporaryAttachment(width: number, height: number): IAttachmentObject; + releaseTemporaryAttachment(attachment: IAttachmentObject): void; + createRenderPassDescriptor( + view: GPUTextureView, + r: number, g: number, b: number, a: number, + loadOp: GPULoadOp + ): GPURenderPassDescriptor; + }; + pipelineManager: { + getPipeline(name: string): GPURenderPipeline | undefined; + getFilterPipeline(baseName: string, constants: Record): GPURenderPipeline | undefined; + getBindGroupLayout(name: string): GPUBindGroupLayout | undefined; + }; + textureManager: { + createSampler(name: string, smooth: boolean): GPUSampler; + }; + computePipelineManager?: ComputePipelineManager; + frameTextures: GPUTexture[]; +} diff --git a/packages/webgpu/src/interface/IGradientLUTData.ts b/packages/webgpu/src/interface/IGradientLUTData.ts new file mode 100644 index 00000000..2213b222 --- /dev/null +++ b/packages/webgpu/src/interface/IGradientLUTData.ts @@ -0,0 +1,8 @@ +/** + * @description グラデーションLUTデータを生成する結果型 + * Result type for generated gradient LUT data + */ +export interface IGradientLUTData { + pixels: Uint8Array; + resolution: number; +} diff --git a/packages/webgpu/src/interface/IGradientStop.ts b/packages/webgpu/src/interface/IGradientStop.ts new file mode 100644 index 00000000..86176eae --- /dev/null +++ b/packages/webgpu/src/interface/IGradientStop.ts @@ -0,0 +1,11 @@ +/** + * @description グラデーションストップの型定義 + * Gradient stop type definition + */ +export interface IGradientStop { + ratio: number; + r: number; + g: number; + b: number; + a: number; +} diff --git a/packages/webgpu/src/interface/ILocalFilterConfig.ts b/packages/webgpu/src/interface/ILocalFilterConfig.ts new file mode 100644 index 00000000..986d9b1d --- /dev/null +++ b/packages/webgpu/src/interface/ILocalFilterConfig.ts @@ -0,0 +1,22 @@ +import type { IAttachmentObject } from "./IAttachmentObject"; +import type { BufferManager } from "../BufferManager"; +import type { FrameBufferManager } from "../FrameBufferManager"; +import type { PipelineManager } from "../Shader/PipelineManager"; +import type { TextureManager } from "../TextureManager"; +import type { ComputePipelineManager } from "../Compute/ComputePipelineManager"; + +/** + * @description フィルター適用時のローカル設定(ContextApplyFilterUseCase用) + * Local filter configuration for ContextApplyFilterUseCase + */ +export interface ILocalFilterConfig { + device: GPUDevice; + commandEncoder: GPUCommandEncoder; + bufferManager: BufferManager; + frameBufferManager: FrameBufferManager; + pipelineManager: PipelineManager; + textureManager: TextureManager; + mainAttachment?: IAttachmentObject; + computePipelineManager?: ComputePipelineManager; + frameTextures: GPUTexture[]; +} diff --git a/packages/webgpu/src/interface/IMeshResult.ts b/packages/webgpu/src/interface/IMeshResult.ts new file mode 100644 index 00000000..934ad3ef --- /dev/null +++ b/packages/webgpu/src/interface/IMeshResult.ts @@ -0,0 +1,8 @@ +/** + * @description メッシュ生成結果の共通インターフェース + * Common interface for mesh generation results + */ +export interface IMeshResult { + buffer: Float32Array; + indexCount: number; +} diff --git a/packages/webgpu/src/interface/IPath.ts b/packages/webgpu/src/interface/IPath.ts new file mode 100644 index 00000000..5a709077 --- /dev/null +++ b/packages/webgpu/src/interface/IPath.ts @@ -0,0 +1,8 @@ +/** + * @description パスの型定義 + * Path type definition + * + * @type {array} + * @public + */ +export type IPath = (number | boolean)[]; diff --git a/packages/webgpu/src/interface/IPoint.ts b/packages/webgpu/src/interface/IPoint.ts new file mode 100644 index 00000000..a5980d9a --- /dev/null +++ b/packages/webgpu/src/interface/IPoint.ts @@ -0,0 +1,5 @@ +export interface IPoint +{ + x: number; + y: number; +} diff --git a/packages/webgpu/src/interface/IPooledBuffer.ts b/packages/webgpu/src/interface/IPooledBuffer.ts new file mode 100644 index 00000000..b73254af --- /dev/null +++ b/packages/webgpu/src/interface/IPooledBuffer.ts @@ -0,0 +1,8 @@ +/** + * @description プールされたバッファのエントリ + * Pooled buffer entry + */ +export interface IPooledBuffer { + buffer: GPUBuffer; + size: number; +} diff --git a/packages/webgpu/src/interface/IPooledTexture.ts b/packages/webgpu/src/interface/IPooledTexture.ts new file mode 100644 index 00000000..c129337e --- /dev/null +++ b/packages/webgpu/src/interface/IPooledTexture.ts @@ -0,0 +1,18 @@ +/** + * @description プールされたテクスチャ + * Pooled texture interface + */ +export interface IPooledTexture { + texture: GPUTexture; + width: number; + height: number; + format: GPUTextureFormat; + lastUsedFrame: number; + inUse: boolean; +} + +/** + * @description バケットキーからテクスチャ配列へのマップ + * キーは "${po2Width}_${po2Height}_${format}" 形式 + */ +export type ITexturePoolBuckets = Map; diff --git a/packages/webgpu/src/interface/IQuadraticSegment.ts b/packages/webgpu/src/interface/IQuadraticSegment.ts new file mode 100644 index 00000000..84476bdd --- /dev/null +++ b/packages/webgpu/src/interface/IQuadraticSegment.ts @@ -0,0 +1,10 @@ +import type { IPoint } from "./IPoint"; + +/** + * @description 二次ベジェ近似のセグメント + * Quadratic bezier segment approximation + */ +export interface IQuadraticSegment { + ctrl: IPoint; + end: IPoint; +} diff --git a/packages/webgpu/src/interface/IRectangleInfo.ts b/packages/webgpu/src/interface/IRectangleInfo.ts new file mode 100644 index 00000000..60a983d5 --- /dev/null +++ b/packages/webgpu/src/interface/IRectangleInfo.ts @@ -0,0 +1,14 @@ +import type { IPoint } from "./IPoint"; +import type { IPath } from "./IPath"; + +/** + * @description 矩形の情報を保持する型 + * Rectangle info type for stroke generation + */ +export interface IRectangleInfo { + path: IPath; + startUp: IPoint; + startDown: IPoint; + endUp: IPoint; + endDown: IPoint; +} diff --git a/packages/webgpu/src/interface/IStencilBufferObject.ts b/packages/webgpu/src/interface/IStencilBufferObject.ts new file mode 100644 index 00000000..c3f7ac7b --- /dev/null +++ b/packages/webgpu/src/interface/IStencilBufferObject.ts @@ -0,0 +1,17 @@ +/** + * @description WebGPU用ステンシルバッファオブジェクトインターフェース + * WebGPU stencil buffer object interface + * + * WebGLのIStencilBufferObjectと同様の構造を持ちますが、 + * リソースはGPUTextureを使用します。 + */ +export interface IStencilBufferObject +{ + id: number; + resource: GPUTexture; + view: GPUTextureView; + width: number; + height: number; + area: number; + dirty: boolean; +} diff --git a/packages/webgpu/src/interface/IStorageBufferConfig.ts b/packages/webgpu/src/interface/IStorageBufferConfig.ts new file mode 100644 index 00000000..edb3657f --- /dev/null +++ b/packages/webgpu/src/interface/IStorageBufferConfig.ts @@ -0,0 +1,46 @@ +/** + * @description Storage Buffer設定インターフェース + * Storage Buffer configuration interface + */ +export interface IStorageBufferConfig { + /** + * @description バッファサイズ(バイト) + */ + size: number; + + /** + * @description 使用目的 + */ + usage: GPUBufferUsageFlags; + + /** + * @description ラベル(デバッグ用) + */ + label?: string; +} + +/** + * @description プールされたStorage Buffer + * Pooled storage buffer + */ +export interface IPooledStorageBuffer { + /** + * @description GPUバッファ + */ + buffer: GPUBuffer; + + /** + * @description バッファサイズ(バイト) + */ + size: number; + + /** + * @description 使用中フラグ + */ + inUse: boolean; + + /** + * @description 最後に使用されたフレーム番号 + */ + lastUsedFrame: number; +} diff --git a/packages/webgpu/src/interface/ITextureObject.ts b/packages/webgpu/src/interface/ITextureObject.ts new file mode 100644 index 00000000..8ee1d5e3 --- /dev/null +++ b/packages/webgpu/src/interface/ITextureObject.ts @@ -0,0 +1,17 @@ +/** + * @description テクスチャオブジェクトのインターフェース + * Texture object interface for WebGPU + * + * WebGLのITextureObjectと互換性を持つために + * resourceプロパティを持ちます。 + */ +export interface ITextureObject +{ + id: number; + resource: GPUTexture; + view: GPUTextureView; + width: number; + height: number; + area: number; + smooth: boolean; +} diff --git a/specs/cn/display-object.md b/specs/cn/display-object.md new file mode 100644 index 00000000..27371142 --- /dev/null +++ b/specs/cn/display-object.md @@ -0,0 +1,164 @@ +# DisplayObject + +DisplayObject 是 Next2D Player 中所有显示对象的基类。 + +## 属性 + +### 只读属性 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `instanceId` | number | DisplayObject 的唯一实例 ID | +| `isSprite` | boolean | 返回是否具有 Sprite 功能 | +| `isInteractive` | boolean | 返回是否具有 InteractiveObject 功能 | +| `isContainerEnabled` | boolean | 返回显示对象是否具有容器功能 | +| `isTimelineEnabled` | boolean | 返回显示对象是否具有 MovieClip 功能 | +| `isShape` | boolean | 返回显示对象是否具有 Shape 功能 | +| `isVideo` | boolean | 返回显示对象是否具有 Video 功能 | +| `isText` | boolean | 返回显示对象是否具有 Text 功能 | +| `concatenatedMatrix` | Matrix | 到根级别的组合变换矩阵 | +| `dropTarget` | DisplayObject \| null | 精灵被拖动或放置到的显示对象 | +| `loaderInfo` | LoaderInfo \| null | 此显示对象所属文件的加载信息 | +| `mouseX` | number | 鼠标相对于 DisplayObject 参考点的 X 坐标(像素) | +| `mouseY` | number | 鼠标相对于 DisplayObject 参考点的 Y 坐标(像素) | +| `root` | MovieClip \| Sprite \| null | DisplayObject 的根 DisplayObjectContainer | + +### 可读写属性 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `name` | string | 名称。用于 getChildByName()(默认:"") | +| `startFrame` | number | 起始帧(默认:1) | +| `endFrame` | number | 结束帧(默认:0) | +| `isMask` | boolean | 表示 DisplayObject 是否被设置为遮罩(默认:false) | +| `parent` | Sprite \| MovieClip \| null | 此 DisplayObject 的父 DisplayObjectContainer | +| `alpha` | number | Alpha 透明度值(0.0-1.0,默认:1.0) | +| `blendMode` | string | 要使用的混合模式(默认:BlendMode.NORMAL) | +| `filters` | Array \| null | 与显示对象关联的滤镜对象数组 | +| `height` | number | 显示对象的高度(像素) | +| `width` | number | 显示对象的宽度(像素) | +| `colorTransform` | ColorTransform | 显示对象的 ColorTransform | +| `matrix` | Matrix | 显示对象的 Matrix | +| `rotation` | number | DisplayObject 实例的旋转角度(度) | +| `scale9Grid` | Rectangle \| null | 当前活动的缩放网格 | +| `scaleX` | number | 从参考点应用的对象水平缩放值 | +| `scaleY` | number | 从参考点应用的对象垂直缩放值 | +| `visible` | boolean | 显示对象是否可见(默认:true) | +| `x` | number | 相对于父 DisplayObjectContainer 本地坐标的 X 坐标 | +| `y` | number | 相对于父 DisplayObjectContainer 本地坐标的 Y 坐标 | + +## 方法 + +| 方法 | 返回类型 | 说明 | +|------|----------|------| +| `getBounds(targetDisplayObject)` | Rectangle | 返回定义显示对象相对于指定 DisplayObject 坐标系统区域的矩形 | +| `globalToLocal(point)` | Point | 将点对象从舞台(全局)坐标转换为显示对象(本地)坐标 | +| `localToGlobal(point)` | Point | 将点对象从显示对象(本地)坐标转换为舞台(全局)坐标 | +| `hitTestObject(targetDisplayObject)` | boolean | 评估 DisplayObject 的绘制范围是否重叠或相交 | +| `hitTestPoint(x, y, shapeFlag)` | boolean | 评估显示对象是否与 x 和 y 参数指定的点重叠或相交 | +| `getLocalVariable(key)` | any | 从类的本地变量空间获取值 | +| `setLocalVariable(key, value)` | void | 在类的本地变量空间中存储值 | +| `hasLocalVariable(key)` | boolean | 确定类的本地变量空间中是否有值 | +| `deleteLocalVariable(key)` | void | 从类的本地变量空间中删除值 | +| `getGlobalVariable(key)` | any | 从全局变量空间获取值 | +| `setGlobalVariable(key, value)` | void | 将值保存到全局变量空间 | +| `hasGlobalVariable(key)` | boolean | 确定全局变量空间中是否有值 | +| `deleteGlobalVariable(key)` | void | 从全局变量空间中删除值 | +| `clearGlobalVariable()` | void | 清除全局变量空间中的所有值 | +| `remove()` | void | 移除父子关系 | + +## 混合模式 + +| 常量 | 说明 | +|------|------| +| `BlendMode.NORMAL` | 正常显示 | +| `BlendMode.ADD` | 叠加 | +| `BlendMode.MULTIPLY` | 正片叠底 | +| `BlendMode.SCREEN` | 滤色 | +| `BlendMode.DARKEN` | 变暗 | +| `BlendMode.LIGHTEN` | 变亮 | +| `BlendMode.DIFFERENCE` | 差值 | +| `BlendMode.OVERLAY` | 叠加 | +| `BlendMode.HARDLIGHT` | 强光 | +| `BlendMode.INVERT` | 反转 | +| `BlendMode.ALPHA` | Alpha | +| `BlendMode.ERASE` | 擦除 | + +## 使用示例 + +```typescript +const { Sprite } = next2d.display; +const { BlurFilter } = next2d.filters; + +const sprite = new Sprite(); + +// 位置和大小 +sprite.x = 100; +sprite.y = 200; +sprite.scaleX = 1.5; +sprite.scaleY = 1.5; +sprite.rotation = 30; + +// 显示控制 +sprite.alpha = 0.8; +sprite.visible = true; +sprite.blendMode = "add"; + +// 滤镜 +sprite.filters = [ + new BlurFilter(4, 4) +]; + +// 添加到舞台 +stage.addChild(sprite); +``` + +### 坐标变换示例 + +```typescript +const { Point } = next2d.geom; + +// 将全局坐标转换为本地坐标 +const globalPoint = new Point(100, 100); +const localPoint = displayObject.globalToLocal(globalPoint); + +// 将本地坐标转换为全局坐标 +const localPos = new Point(0, 0); +const globalPos = displayObject.localToGlobal(localPos); +``` + +### 碰撞检测示例 + +```typescript +// 使用边界框检测 +const hit1 = displayObject.hitTestPoint(100, 100, false); + +// 使用实际形状检测 +const hit2 = displayObject.hitTestPoint(100, 100, true); + +// 与另一个 DisplayObject 进行碰撞检测 +if (obj1.hitTestObject(obj2)) { + console.log("检测到碰撞"); +} +``` + +### 变量操作示例 + +```typescript +// 本地变量操作 +displayObject.setLocalVariable("score", 100); +const score = displayObject.getLocalVariable("score"); +if (displayObject.hasLocalVariable("score")) { + displayObject.deleteLocalVariable("score"); +} + +// 全局变量操作 +displayObject.setGlobalVariable("gameState", "playing"); +const state = displayObject.getGlobalVariable("gameState"); +displayObject.clearGlobalVariable(); // 清除全部 +``` + +## 相关 + +- [MovieClip](/cn/reference/player/movie-clip) +- [Sprite](/cn/reference/player/sprite) diff --git a/specs/cn/events.md b/specs/cn/events.md new file mode 100644 index 00000000..526a02ec --- /dev/null +++ b/specs/cn/events.md @@ -0,0 +1,216 @@ +# 事件系统 + +Next2D Player 使用与 Flash Player 类似的事件模型。 + +## EventDispatcher + +所有具有事件功能的对象的基类。 + +### addEventListener(type, listener, useCapture, priority) + +注册事件侦听器。 + +```javascript +displayObject.addEventListener("click", function(event) { + console.log("被点击"); +}); + +// 在捕获阶段接收 +displayObject.addEventListener("click", handler, true); + +// 指定优先级 +displayObject.addEventListener("click", handler, false, 10); +``` + +### removeEventListener(type, listener, useCapture) + +移除事件侦听器。 + +```javascript +displayObject.removeEventListener("click", handler); +``` + +### hasEventListener(type) + +检查是否注册了指定类型的侦听器。 + +```javascript +if (displayObject.hasEventListener("click")) { + console.log("已注册点击侦听器"); +} +``` + +### dispatchEvent(event) + +派发事件。 + +```javascript +const { Event } = next2d.events; + +const event = new Event("customEvent"); +displayObject.dispatchEvent(event); +``` + +## Event 类 + +### 属性 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `type` | String | 事件类型 | +| `target` | Object | 事件源 | +| `currentTarget` | Object | 当前侦听器目标 | +| `eventPhase` | Number | 事件阶段 | +| `bubbles` | Boolean | 是否冒泡 | +| `cancelable` | Boolean | 是否可取消 | + +### 方法 + +| 方法 | 说明 | +|------|------| +| `stopPropagation()` | 停止传播 | +| `stopImmediatePropagation()` | 立即停止传播 | +| `preventDefault()` | 取消默认行为 | + +## 标准事件类型 + +### 显示列表相关 + +| 事件 | 说明 | +|------|------| +| `added` | 添加到 DisplayObjectContainer | +| `addedToStage` | 添加到舞台 | +| `removed` | 从 DisplayObjectContainer 移除 | +| `removedFromStage` | 从舞台移除 | + +```javascript +sprite.addEventListener("addedToStage", function(event) { + console.log("添加到舞台"); +}); +``` + +### 时间轴相关 + +| 事件 | 说明 | +|------|------| +| `enterFrame` | 每帧发生 | +| `frameConstructed` | 帧构建完成 | +| `exitFrame` | 离开帧时 | + +```javascript +movieClip.addEventListener("enterFrame", function(event) { + // 每帧执行的处理 + updatePosition(); +}); +``` + +### 加载相关 + +| 事件 | 说明 | +|------|------| +| `complete` | 加载完成 | +| `progress` | 加载进度 | +| `ioError` | IO 错误 | + +```javascript +const { Loader } = next2d.display; +const { URLRequest } = next2d.net; + +const loader = new Loader(); + +loader.contentLoaderInfo.addEventListener("complete", function(event) { + const content = event.currentTarget.content; + stage.addChild(content); +}); + +loader.contentLoaderInfo.addEventListener("progress", function(event) { + const percent = (event.bytesLoaded / event.bytesTotal) * 100; + console.log(percent + "% 已加载"); +}); + +loader.load(new URLRequest("animation.json")); +``` + +## 鼠标事件 + +| 事件 | 说明 | +|------|------| +| `click` | 点击 | +| `doubleClick` | 双击 | +| `mouseDown` | 鼠标按下 | +| `mouseUp` | 鼠标释放 | +| `mouseMove` | 鼠标移动 | +| `mouseOver` | 鼠标移入 | +| `mouseOut` | 鼠标移出 | +| `rollOver` | 滚动移入 | +| `rollOut` | 滚动移出 | + +```javascript +sprite.addEventListener("click", function(event) { + console.log("点击位置:", event.localX, event.localY); +}); + +sprite.addEventListener("mouseMove", function(event) { + console.log("鼠标位置:", event.stageX, event.stageY); +}); +``` + +## 键盘事件 + +| 事件 | 说明 | +|------|------| +| `keyDown` | 按键按下 | +| `keyUp` | 按键释放 | + +```javascript +stage.addEventListener("keyDown", function(event) { + console.log("键码:", event.keyCode); + + switch (event.keyCode) { + case 37: // 左箭头 + player.x -= 10; + break; + case 39: // 右箭头 + player.x += 10; + break; + } +}); +``` + +## 自定义事件 + +```javascript +const { Event } = next2d.events; + +// 定义自定义事件 +const customEvent = new Event("gameOver", true, true); + +// 派发事件 +gameManager.dispatchEvent(customEvent); + +// 监听事件 +gameManager.addEventListener("gameOver", function(event) { + showGameOverScreen(); +}); +``` + +## 事件传播 + +事件分三个阶段传播: + +1. **捕获阶段**:从根到目标 +2. **目标阶段**:在目标处处理 +3. **冒泡阶段**:从目标到根 + +```javascript +// 在捕获阶段处理 +parent.addEventListener("click", handler, true); + +// 在冒泡阶段处理(默认) +child.addEventListener("click", handler, false); +``` + +## 相关 + +- [DisplayObject](/cn/reference/player/display-object) +- [MovieClip](/cn/reference/player/movie-clip) diff --git a/specs/cn/filters/index.md b/specs/cn/filters/index.md new file mode 100644 index 00000000..48c98562 --- /dev/null +++ b/specs/cn/filters/index.md @@ -0,0 +1,393 @@ +# 滤镜 + +Next2D Player 提供各种可应用于 DisplayObject 的视觉滤镜。 + +## 应用滤镜 + +```typescript +const { Sprite } = next2d.display; +const { BlurFilter, DropShadowFilter, GlowFilter } = next2d.filters; + +const sprite = new Sprite(); + +// 单个滤镜 +sprite.filters = [new BlurFilter(4, 4)]; + +// 多个滤镜 +sprite.filters = [ + new DropShadowFilter(4, 45, 0x000000, 0.5), + new GlowFilter(0xff0000, 1, 8, 8) +]; + +// 移除滤镜 +sprite.filters = null; +``` + +## 可用滤镜 + +| 滤镜 | 说明 | +|------|------| +| BlurFilter | 模糊效果 | +| DropShadowFilter | 投影效果 | +| GlowFilter | 发光效果 | +| BevelFilter | 斜角效果 | +| ColorMatrixFilter | 颜色矩阵变换 | +| ConvolutionFilter | 卷积效果 | +| DisplacementMapFilter | 位移贴图效果 | +| GradientBevelFilter | 渐变斜角效果 | +| GradientGlowFilter | 渐变发光效果 | + +--- + +## BlurFilter + +应用模糊效果。您可以创建从柔和失焦到高斯模糊的各种模糊效果。 + +```typescript +const { BlurFilter } = next2d.filters; + +new BlurFilter(blurX, blurY, quality); +``` + +### 属性 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| blurX | number | 4 | 水平模糊量(0-255) | +| blurY | number | 4 | 垂直模糊量(0-255) | +| quality | number | 1 | 执行模糊的次数(0-15) | + +--- + +## DropShadowFilter + +应用投影效果。样式选项包括内阴影、外阴影和挖空模式。 + +```typescript +const { DropShadowFilter } = next2d.filters; + +new DropShadowFilter( + distance, angle, color, alpha, + blurX, blurY, strength, quality, + inner, knockout, hideObject +); +``` + +### 属性 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| alpha | number | 1 | 阴影的 alpha 透明度值(0-1) | +| angle | number | 45 | 阴影的角度(-360 到 360 度) | +| blurX | number | 4 | 水平模糊量(0-255) | +| blurY | number | 4 | 垂直模糊量(0-255) | +| color | number | 0 | 阴影的颜色(0x000000-0xFFFFFF) | +| distance | number | 4 | 阴影的偏移距离(-255 到 255 像素) | +| hideObject | boolean | false | 指示对象是否被隐藏 | +| inner | boolean | false | 指定阴影是否为内阴影 | +| knockout | boolean | false | 指定对象是否具有挖空效果 | +| quality | number | 1 | 执行模糊的次数(0-15) | +| strength | number | 1 | 印记或扩展的强度(0-255) | + +--- + +## GlowFilter + +应用发光效果。样式选项包括内发光、外发光和挖空模式。 + +```typescript +const { GlowFilter } = next2d.filters; + +new GlowFilter( + color, alpha, blurX, blurY, + strength, quality, inner, knockout +); +``` + +### 属性 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| alpha | number | 1 | 发光的 alpha 透明度值(0-1) | +| blurX | number | 4 | 水平模糊量(0-255) | +| blurY | number | 4 | 垂直模糊量(0-255) | +| color | number | 0 | 发光的颜色(0x000000-0xFFFFFF) | +| inner | boolean | false | 指定发光是否为内发光 | +| knockout | boolean | false | 指定对象是否具有挖空效果 | +| quality | number | 1 | 执行模糊的次数(0-15) | +| strength | number | 1 | 印记或扩展的强度(0-255) | + +--- + +## BevelFilter + +应用斜角效果。使对象具有三维外观。 + +```typescript +const { BevelFilter } = next2d.filters; + +new BevelFilter( + distance, angle, highlightColor, highlightAlpha, + shadowColor, shadowAlpha, blurX, blurY, + strength, quality, type, knockout +); +``` + +### 属性 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| angle | number | 45 | 斜角的角度(-360 到 360 度) | +| blurX | number | 4 | 水平模糊量(0-255) | +| blurY | number | 4 | 垂直模糊量(0-255) | +| distance | number | 4 | 斜角的偏移距离(-255 到 255 像素) | +| highlightAlpha | number | 1 | 高光颜色的 alpha 透明度值(0-1) | +| highlightColor | number | 0xFFFFFF | 斜角的高光颜色(0x000000-0xFFFFFF) | +| knockout | boolean | false | 指定对象是否具有挖空效果 | +| quality | number | 1 | 执行模糊的次数(0-15) | +| shadowAlpha | number | 1 | 阴影颜色的 alpha 透明度值(0-1) | +| shadowColor | number | 0 | 斜角的阴影颜色(0x000000-0xFFFFFF) | +| strength | number | 1 | 印记或扩展的强度(0-255) | +| type | string | "inner" | 斜角的位置("inner"、"outer"、"full") | + +--- + +## ColorMatrixFilter + +应用 4x5 颜色矩阵变换。可以调整亮度、对比度、饱和度、色调等。 + +```typescript +const { ColorMatrixFilter } = next2d.filters; + +new ColorMatrixFilter(matrix); +``` + +### 属性 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| matrix | number[] | 单位矩阵 | 用于 4x5 颜色变换的 20 个项目的数组 | + +### 默认矩阵值(单位矩阵) +```typescript +[ + 1, 0, 0, 0, 0, + 0, 1, 0, 0, 0, + 0, 0, 1, 0, 0, + 0, 0, 0, 1, 0 +] +``` + +--- + +## ConvolutionFilter + +应用矩阵卷积滤镜效果。可以实现模糊、边缘检测、锐化、浮雕、斜角等效果。 + +```typescript +const { ConvolutionFilter } = next2d.filters; + +new ConvolutionFilter( + matrixX, matrixY, matrix, divisor, bias, + preserveAlpha, clamp, color, alpha +); +``` + +### 属性 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| alpha | number | 0 | 超出边界像素的 alpha 透明度值(0-1) | +| bias | number | 0 | 添加到矩阵变换结果的偏差量 | +| clamp | boolean | true | 指示图像是否应被钳位 | +| color | number | 0 | 用于超出边界像素的十六进制颜色(0x000000-0xFFFFFF) | +| divisor | number | 1 | 矩阵变换期间使用的除数 | +| matrix | number[] \| null | null | 用于矩阵变换的值数组 | +| matrixX | number | 0 | 矩阵的 x 维度(列数,0-15) | +| matrixY | number | 0 | 矩阵的 y 维度(行数,0-15) | +| preserveAlpha | boolean | true | 指示 alpha 通道是否在没有滤镜效果的情况下保留 | + +--- + +## DisplacementMapFilter + +使用 BitmapData 对象的像素值对对象执行位移。 + +```typescript +const { DisplacementMapFilter } = next2d.filters; + +new DisplacementMapFilter( + bitmapBuffer, bitmapWidth, bitmapHeight, + mapPointX, mapPointY, componentX, componentY, + scaleX, scaleY, mode, color, alpha +); +``` + +### 属性 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| alpha | number | 0 | 超出边界位移的 alpha 透明度值(0-1) | +| bitmapBuffer | Uint8Array \| null | null | 包含位移贴图数据的缓冲区 | +| bitmapHeight | number | 0 | 位移贴图图像的高度 | +| bitmapWidth | number | 0 | 位移贴图图像的宽度 | +| color | number | 0 | 用于超出边界位移的颜色(0x000000-0xFFFFFF) | +| componentX | number | 0 | 用于位移 x 结果的贴图图像中的颜色通道 | +| componentY | number | 0 | 用于位移 y 结果的贴图图像中的颜色通道 | +| mapPointX | number | 0 | 贴图点的 X 偏移 | +| mapPointY | number | 0 | 贴图点的 Y 偏移 | +| mode | string | "wrap" | 滤镜的模式("wrap"、"clamp"、"ignore"、"color") | +| scaleX | number | 0 | 缩放 x 位移结果的乘数(-65535 到 65535) | +| scaleY | number | 0 | 缩放 y 位移结果的乘数(-65535 到 65535) | + +--- + +## GradientBevelFilter + +应用渐变斜角效果。使用渐变颜色增强的斜边使对象看起来具有三维效果。 + +```typescript +const { GradientBevelFilter } = next2d.filters; + +new GradientBevelFilter( + distance, angle, colors, alphas, ratios, + blurX, blurY, strength, quality, type, knockout +); +``` + +### 属性 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| alphas | number[] \| null | null | 相应颜色的 alpha 透明度值数组(每个值 0-1) | +| angle | number | 45 | 斜角的角度(-360 到 360 度) | +| blurX | number | 4 | 水平模糊量(0-255) | +| blurY | number | 4 | 垂直模糊量(0-255) | +| colors | number[] \| null | null | 用于渐变的 RGB 十六进制颜色值数组 | +| distance | number | 4 | 斜角的偏移距离(-255 到 255 像素) | +| knockout | boolean | false | 指定对象是否具有挖空效果 | +| quality | number | 1 | 执行模糊的次数(0-15) | +| ratios | number[] \| null | null | 相应颜色的颜色分布比例数组(每个值 0-255) | +| strength | number | 1 | 印记或扩展的强度(0-255) | +| type | string | "inner" | 斜角的位置("inner"、"outer"、"full") | + +--- + +## GradientGlowFilter + +应用渐变发光效果。具有可控颜色渐变的逼真发光效果。 + +```typescript +const { GradientGlowFilter } = next2d.filters; + +new GradientGlowFilter( + distance, angle, colors, alphas, ratios, + blurX, blurY, strength, quality, type, knockout +); +``` + +### 属性 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| alphas | number[] \| null | null | 相应颜色的 alpha 透明度值数组(每个值 0-1) | +| angle | number | 45 | 发光的角度(-360 到 360 度) | +| blurX | number | 4 | 水平模糊量(0-255) | +| blurY | number | 4 | 垂直模糊量(0-255) | +| colors | number[] \| null | null | 用于渐变的 RGB 十六进制颜色值数组 | +| distance | number | 4 | 发光的偏移距离(-255 到 255 像素) | +| knockout | boolean | false | 指定对象是否具有挖空效果 | +| quality | number | 1 | 执行模糊的次数(0-15) | +| ratios | number[] \| null | null | 相应颜色的颜色分布比例数组(每个值 0-255) | +| strength | number | 1 | 印记或扩展的强度(0-255) | +| type | string | "outer" | 发光的位置("inner"、"outer"、"full") | + +--- + +## 使用示例 + +### 按钮悬停效果 + +```typescript +const { Sprite } = next2d.display; +const { GlowFilter } = next2d.filters; + +const button = new Sprite(); + +button.addEventListener("rollOver", () => { + button.filters = [ + new GlowFilter(0x00ff00, 0.8, 10, 10) + ]; +}); + +button.addEventListener("rollOut", () => { + button.filters = null; +}); +``` + +### 带阴影的文本 + +```typescript +const { TextField } = next2d.text; +const { DropShadowFilter } = next2d.filters; + +const textField = new TextField(); +textField.text = "Hello World"; +textField.filters = [ + new DropShadowFilter(2, 45, 0x000000, 0.5, 2, 2) +]; +``` + +### 组合滤镜 + +```typescript +const { GlowFilter, DropShadowFilter, BlurFilter } = next2d.filters; + +sprite.filters = [ + // 外发光 + new GlowFilter(0x0088ff, 0.8, 15, 15, 2, 1, false), + // 投影 + new DropShadowFilter(4, 45, 0x000000, 0.6, 4, 4), + // 轻微模糊 + new BlurFilter(1, 1, 1) +]; +``` + +### 使用 ColorMatrixFilter 实现灰度 + +```typescript +const { ColorMatrixFilter } = next2d.filters; + +// 灰度变换矩阵 +const grayscaleMatrix = [ + 0.299, 0.587, 0.114, 0, 0, + 0.299, 0.587, 0.114, 0, 0, + 0.299, 0.587, 0.114, 0, 0, + 0, 0, 0, 1, 0 +]; + +sprite.filters = [new ColorMatrixFilter(grayscaleMatrix)]; +``` + +### 渐变发光效果 + +```typescript +const { GradientGlowFilter } = next2d.filters; + +sprite.filters = [ + new GradientGlowFilter( + 4, 45, + [0xff0000, 0x00ff00, 0x0000ff], // 颜色 + [1, 1, 1], // alpha + [0, 128, 255], // 比例 + 10, 10, 2, 1, "outer", false + ) +]; +``` + +--- + +## 相关 + +- [DisplayObject](/cn/reference/player/display-object) +- [MovieClip](/cn/reference/player/movie-clip) diff --git a/specs/cn/index.md b/specs/cn/index.md new file mode 100644 index 00000000..67d673a8 --- /dev/null +++ b/specs/cn/index.md @@ -0,0 +1,199 @@ +# Next2D Player + +Next2D Player 是一个使用 WebGL/WebGPU 的高性能 2D 渲染引擎。它在 Web 上提供类似 Flash Player 的功能,支持矢量图形、补间动画、文本、音频、视频等。 + +## 主要特性 + +- **高速渲染**:使用 WebGL/WebGPU 进行快速 2D 渲染 +- **多平台支持**:支持从桌面到移动设备 +- **Flash 兼容 API**:源自 swf2js 的熟悉 API 设计 +- **丰富的滤镜**:支持模糊、投影、发光、斜角等效果 + +## 渲染管线 + +Next2D Player 实现高速渲染的管线概述。 + +```mermaid +flowchart TB + %% Main Drawing Flow Chart + subgraph MainFlow["绘制流程图 - 主渲染管线"] + direction TB + + subgraph Inputs["显示对象"] + Shape["Shape
(位图/矢量)"] + TextField["TextField
(canvas2d)"] + Video["Video 元素"] + end + + Shape --> MaskCheck + TextField --> MaskCheck + Video --> MaskCheck + + MaskCheck{"遮罩
渲染?"} + + MaskCheck -->|是| DirectRender["直接渲染"] + DirectRender -->|drawArrays| FinalRender + + MaskCheck -->|否| CacheCheck1{"缓存
存在?"} + + CacheCheck1 -->|否| TextureAtlas["纹理图集
(二叉树打包)"] + TextureAtlas --> Coordinates + + CacheCheck1 -->|是| Coordinates["坐标数据库
(x, y, w, h)"] + + Coordinates --> FilterBlendCheck{"滤镜或
混合?"} + + FilterBlendCheck -->|否| MainArrays + FilterBlendCheck -->|是| NeedCache{"缓存
存在?"} + + NeedCache -->|否| CacheRender["渲染到缓存"] + CacheRender --> TextureCache + NeedCache -->|是| TextureCache["纹理缓存"] + + TextureCache -->|drawArrays| FinalRender + + MainArrays["实例化数组
━━━━━━━━━━━━━━━
matrix
colorTransform
Coordinates
━━━━━━━━━━━━━━━
批量渲染"] + + MainArrays -->|drawArraysInstanced
一次调用渲染多个对象| FinalRender["最终渲染"] + + FinalRender -->|60fps| MainFramebuffer["主帧缓冲
(显示)"] + end + + %% Branch Flow for Filter/Blend/Mask + subgraph BranchFlow["滤镜/混合/遮罩 - 分支处理"] + direction TB + + subgraph FilterInputs["显示对象"] + Shape2["Shape
(位图/矢量)"] + TextField2["TextField
(canvas2d)"] + Video2["Video 元素"] + end + + Shape2 --> CacheCheck2 + TextField2 --> CacheCheck2 + Video2 --> CacheCheck2 + + CacheCheck2{"缓存
存在?"} + + CacheCheck2 -->|否| EffectRender["效果渲染"] + CacheCheck2 -->|是| BranchArrays + EffectRender --> BranchArrays + + BranchArrays["实例化数组
━━━━━━━━━━━━━━━
matrix
colorTransform
Coordinates
━━━━━━━━━━━━━━━
批量渲染"] + + BranchArrays -->|drawArraysInstanced
一次调用渲染多个对象| BranchRender["效果结果"] + + BranchRender -->|滤镜/混合| TextureCache + end + + %% Connections between flows + FilterBlendCheck -.->|"触发
分支流程"| BranchFlow + BranchArrays -.->|"渲染信息
(坐标)"| MainArrays +``` + +### 管线特性 + +- **批量渲染**:一次 GPU 调用渲染多个对象 +- **纹理缓存**:高效处理滤镜和混合效果 +- **二叉树打包**:纹理图集的最佳内存使用 +- **60fps 渲染**:高帧率的流畅动画 + +## DisplayList 架构 + +Next2D Player 使用与 Flash Player 类似的 DisplayList 架构。 + +### 主要类层次结构 + +``` +DisplayObject (基类) +├── InteractiveObject +│ ├── DisplayObjectContainer +│ │ ├── Sprite +│ │ ├── MovieClip +│ │ └── Stage +│ └── TextField +├── Shape +├── Video +└── Bitmap +``` + +### DisplayObjectContainer + +可以容纳子对象的容器类: + +- `addChild(child)`:将子对象添加到前面 +- `addChildAt(child, index)`:在指定索引添加子对象 +- `removeChild(child)`:移除子对象 +- `getChildAt(index)`:通过索引获取子对象 +- `getChildByName(name)`:通过名称获取子对象 + +### MovieClip + +具有时间轴动画的 DisplayObject: + +- `play()`:开始时间轴播放 +- `stop()`:停止时间轴 +- `gotoAndPlay(frame)`:跳转到帧并播放 +- `gotoAndStop(frame)`:跳转到帧并停止 +- `currentFrame`:当前帧号 +- `totalFrames`:总帧数 + +## 基本用法 + +```javascript +const { MovieClip } = next2d.display; +const { DropShadowFilter } = next2d.filters; + +// 初始化舞台 +const root = await next2d.createRootMovieClip(800, 600, 60, { + tagId: "container", + bgColor: "#ffffff" +}); + +// 创建 MovieClip +const mc = new MovieClip(); +root.addChild(mc); + +// 设置位置和大小 +mc.x = 100; +mc.y = 100; +mc.scaleX = 2; +mc.scaleY = 2; +mc.rotation = 45; + +// 应用滤镜 +mc.filters = [ + new DropShadowFilter(4, 45, 0x000000, 0.5) +]; +``` + +## 加载 JSON 数据 + +加载并渲染使用 Open Animation Tool 创建的 JSON 文件: + +```javascript +const { Loader } = next2d.display; +const { URLRequest } = next2d.net; + +const loader = new Loader(); +await loader.load(new URLRequest("animation.json")); + +const mc = loader.content; +stage.addChild(mc); +``` + +## 相关文档 + +### 显示对象 +- [DisplayObject](/cn/reference/player/display-object) - 所有显示对象的基类 +- [MovieClip](/cn/reference/player/movie-clip) - 时间轴动画 +- [Sprite](/cn/reference/player/sprite) - 图形绘制和交互 +- [Shape](/cn/reference/player/shape) - 轻量级矢量绘制 +- [TextField](/cn/reference/player/text-field) - 文本显示和输入 +- [Video](/cn/reference/player/video) - 视频播放 + +### 系统 +- [事件系统](/cn/reference/player/events) - 鼠标、键盘、触摸事件 +- [滤镜](/cn/reference/player/filters) - 模糊、投影、发光等 +- [Sound](/cn/reference/player/sound) - 音频播放和音效 +- [补间动画](/cn/reference/player/tween) - 程序化动画 diff --git a/specs/cn/movie-clip.md b/specs/cn/movie-clip.md new file mode 100644 index 00000000..29fa6f9f --- /dev/null +++ b/specs/cn/movie-clip.md @@ -0,0 +1,244 @@ +# MovieClip + +MovieClip 是具有时间轴动画的 DisplayObjectContainer。使用 Open Animation Tool 创建的动画作为 MovieClip 播放。 + +## 继承 + +```mermaid +classDiagram + DisplayObject <|-- InteractiveObject + InteractiveObject <|-- DisplayObjectContainer + DisplayObjectContainer <|-- Sprite + Sprite <|-- MovieClip + + class DisplayObject { + +x: Number + +y: Number + +visible: Boolean + } + class MovieClip { + +currentFrame: Number + +totalFrames: Number + +play() + +stop() + +gotoAndPlay() + } +``` + +## 属性 + +### MovieClip 特有属性 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `currentFrame` | `number` | 指定播放头在时间轴中所在帧的编号(从 1 开始,只读) | +| `totalFrames` | `number` | MovieClip 实例的总帧数(只读) | +| `currentFrameLabel` | `FrameLabel \| null` | MovieClip 实例时间轴中当前帧的标签(只读) | +| `currentLabels` | `FrameLabel[] \| null` | 返回当前场景的 FrameLabel 对象数组(只读) | +| `isPlaying` | `boolean` | 表示影片剪辑当前是否正在播放的布尔值(只读) | +| `isTimelineEnabled` | `boolean` | 返回显示对象是否具有 MovieClip 功能(只读) | + +### 从 DisplayObjectContainer 继承的属性 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `numChildren` | `number` | 返回此对象的子对象数量(只读) | +| `mouseChildren` | `boolean` | 确定对象的子对象是否启用鼠标或用户输入设备 | +| `mask` | `DisplayObject \| null` | 调用的显示对象被指定的遮罩对象遮罩 | +| `isContainerEnabled` | `boolean` | 返回显示对象是否具有容器功能(只读) | + +## 方法 + +### MovieClip 特有方法 + +| 方法 | 返回类型 | 说明 | +|------|----------|------| +| `play()` | `void` | 在影片剪辑的时间轴中移动播放头 | +| `stop()` | `void` | 停止影片剪辑中的播放头 | +| `gotoAndPlay(frame: string \| number)` | `void` | 从指定帧开始播放文件 | +| `gotoAndStop(frame: string \| number)` | `void` | 将播放头移至指定帧并停止在那里 | +| `nextFrame()` | `void` | 将播放头发送到下一帧并停止 | +| `prevFrame()` | `void` | 将播放头发送到上一帧并停止 | +| `addFrameLabel(frame_label: FrameLabel)` | `void` | 动态向时间轴添加标签 | + +### 从 DisplayObjectContainer 继承的方法 + +| 方法 | 返回类型 | 说明 | +|------|----------|------| +| `addChild(display_object: DisplayObject)` | `DisplayObject` | 将子 DisplayObject 实例添加到此 DisplayObjectContainer 实例 | +| `addChildAt(display_object: DisplayObject, index: number)` | `DisplayObject` | 在指定索引位置添加子 DisplayObject 实例 | +| `removeChild(display_object: DisplayObject)` | `void` | 从子列表中移除指定的子 DisplayObject 实例 | +| `removeChildAt(index: number)` | `void` | 从子列表中的指定索引位置移除子 DisplayObject | +| `removeChildren(...indexes: number[])` | `void` | 从容器中移除指定索引处的子对象 | +| `getChildAt(index: number)` | `DisplayObject \| null` | 返回存在于指定索引处的子显示对象实例 | +| `getChildByName(name: string)` | `DisplayObject \| null` | 返回具有指定名称的子显示对象 | +| `getChildIndex(display_object: DisplayObject)` | `number` | 返回子 DisplayObject 实例的索引位置 | +| `contains(display_object: DisplayObject)` | `boolean` | 确定指定的显示对象是 DisplayObjectContainer 实例的子对象还是实例本身 | +| `setChildIndex(display_object: DisplayObject, index: number)` | `void` | 更改显示对象容器中现有子对象的位置 | +| `swapChildren(display_object1: DisplayObject, display_object2: DisplayObject)` | `void` | 交换两个指定子对象的 z 顺序(从前到后的顺序) | +| `swapChildrenAt(index1: number, index2: number)` | `void` | 交换两个指定索引位置的子对象的 z 顺序 | + +## 事件 + +### enterFrame + +每帧发生的事件: + +```javascript +movieClip.addEventListener("enterFrame", function(event) { + console.log("帧:", event.target.currentFrame); +}); +``` + +### frameConstructed + +帧构建完成时发生: + +```javascript +movieClip.addEventListener("frameConstructed", function(event) { + // 帧脚本执行前 +}); +``` + +### exitFrame + +离开帧时发生: + +```javascript +movieClip.addEventListener("exitFrame", function(event) { + // 移动到下一帧前 +}); +``` + +## 使用示例 + +### 基本动画控制 + +```javascript +const { Loader } = next2d.display; +const { URLRequest } = next2d.net; + +// 从 JSON 加载 MovieClip +const loader = new Loader(); +await loader.load(new URLRequest("animation.json")); + +const mc = loader.content; +stage.addChild(mc); + +// 初始停止 +mc.stop(); + +// 点击按钮播放/暂停 +button.addEventListener("click", function() { + if (mc.isPlaying) { + mc.stop(); + } else { + mc.play(); + } +}); +``` + +### 使用帧标签控制 + +```javascript +// 移动到标签位置 +mc.gotoAndStop("idle"); + +// 状态变更 +function changeState(state) { + switch (state) { + case "idle": + mc.gotoAndPlay("idle"); + break; + case "walk": + mc.gotoAndPlay("walk_start"); + break; + case "attack": + mc.gotoAndPlay("attack"); + break; + } +} +``` + +### 控制嵌套的 MovieClip + +```javascript +// 访问子 MovieClip +const childMc = mc.getChildByName("character"); +childMc.gotoAndPlay("run"); + +// 访问孙子 MovieClip +const grandChild = mc.character.arm; +grandChild.play(); +``` + +### 子对象操作 + +```javascript +// 添加子对象 +const sprite = new Sprite(); +mc.addChild(sprite); + +// 在特定索引添加 +mc.addChildAt(sprite, 0); + +// 移除子对象 +mc.removeChild(sprite); + +// 按索引移除 +mc.removeChildAt(0); + +// 移除多个子对象 +mc.removeChildren(0, 1, 2); + +// 获取子对象 +const child = mc.getChildAt(0); +const namedChild = mc.getChildByName("myChild"); + +// 获取子对象索引 +const index = mc.getChildIndex(sprite); + +// 更改子对象索引 +mc.setChildIndex(sprite, 2); + +// 交换子对象顺序 +mc.swapChildren(sprite1, sprite2); +mc.swapChildrenAt(0, 1); +``` + +### 动态添加帧标签 + +```javascript +const { FrameLabel } = next2d.display; + +// 创建并添加新标签 +const label = new FrameLabel("myLabel", 10); +mc.addFrameLabel(label); + +// 使用标签导航 +mc.gotoAndPlay("myLabel"); +``` + +### 更改帧率 + +```javascript +// 更改舞台帧率 +stage.frameRate = 30; +``` + +## FrameLabel + +保存帧标签信息的类: + +```javascript +// 获取当前场景中的所有标签 +const labels = mc.currentLabels; +labels.forEach(function(label) { + console.log(label.name + ": 帧 " + label.frame); +}); +``` + +## 相关 + +- [Sprite](/cn/reference/player/sprite) +- [事件系统](/cn/reference/player/events) diff --git a/specs/cn/shape.md b/specs/cn/shape.md new file mode 100644 index 00000000..a666ef8b --- /dev/null +++ b/specs/cn/shape.md @@ -0,0 +1,442 @@ +# Shape + +Shape 是专用于矢量图形绘制的类。与 Sprite 不同,它不能容纳子对象,但它轻量级且性能更好。 + +## 继承 + +```mermaid +classDiagram + DisplayObject <|-- Shape + + class Shape { + +graphics: Graphics + } +``` + +## 属性 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `graphics` | Graphics | 属于此 Shape 对象的 Graphics 对象,可以在其中执行矢量绘制命令(只读) | +| `isShape` | boolean | 返回显示对象是否具有 Shape 功能(只读) | +| `cacheKey` | number | 构建的缓存键 | +| `cacheParams` | number[] | 用于构建缓存的参数(只读) | +| `isBitmap` | boolean | 位图绘制判断标志 | +| `src` | string | 从指定路径读取图像并生成 Graphics | +| `bitmapData` | BitmapData | 返回位图数据(只读) | +| `namespace` | string | 返回指定对象的空间名称(只读) | + +## 方法 + +| 方法 | 返回类型 | 说明 | +|------|----------|------| +| `load(url: string)` | Promise\ | 从指定 URL 异步加载图像并生成 Graphics | +| `clearBitmapBuffer()` | void | 释放位图数据 | +| `setBitmapBuffer(width: number, height: number, buffer: Uint8Array)` | void | 设置 RGBA 图像数据 | + +## Sprite 和 Shape 的区别 + +| 功能 | Shape | Sprite | +|------|-------|--------| +| 子对象 | 不能容纳 | 可以容纳 | +| 交互 | 无 | 可点击等 | +| 性能 | 轻量级 | 稍重 | +| 使用场景 | 静态背景、装饰 | 按钮、容器 | + +## 使用示例 + +### 基本绘制 + +```javascript +const { Shape } = next2d.display; + +const shape = new Shape(); + +// 填充矩形 +shape.graphics.beginFill(0x3498db); +shape.graphics.drawRect(0, 0, 150, 100); +shape.graphics.endFill(); + +stage.addChild(shape); +``` + +### 复合形状绘制 + +```javascript +const { Shape } = next2d.display; + +const shape = new Shape(); +const g = shape.graphics; + +// 背景 +g.beginFill(0xecf0f1); +g.drawRoundRect(0, 0, 200, 150, 10, 10); +g.endFill(); + +// 边框 +g.lineStyle(2, 0x2c3e50); +g.drawRoundRect(0, 0, 200, 150, 10, 10); + +// 内部装饰 +g.beginFill(0xe74c3c); +g.drawCircle(100, 75, 30); +g.endFill(); + +stage.addChild(shape); +``` + +### 路径绘制 + +```javascript +const { Shape } = next2d.display; + +const shape = new Shape(); +const g = shape.graphics; + +g.beginFill(0x9b59b6); + +// 绘制星形 +g.moveTo(50, 0); +g.lineTo(61, 35); +g.lineTo(98, 35); +g.lineTo(68, 57); +g.lineTo(79, 91); +g.lineTo(50, 70); +g.lineTo(21, 91); +g.lineTo(32, 57); +g.lineTo(2, 35); +g.lineTo(39, 35); +g.lineTo(50, 0); + +g.endFill(); + +stage.addChild(shape); +``` + +### 贝塞尔曲线 + +```javascript +const { Shape } = next2d.display; + +const shape = new Shape(); +const g = shape.graphics; + +g.lineStyle(3, 0x1abc9c); + +// 二次贝塞尔曲线 +g.moveTo(0, 100); +g.curveTo(50, 0, 100, 100); // 控制点, 终点 + +g.curveTo(150, 200, 200, 100); + +stage.addChild(shape); +``` + +### 渐变背景 + +```javascript +const { Shape } = next2d.display; +const { Matrix } = next2d.geom; + +const shape = new Shape(); +const g = shape.graphics; + +// 渐变矩阵 +const matrix = new Matrix(); +matrix.createGradientBox( + stage.stageWidth, + stage.stageHeight, + Math.PI / 2, // 90度(垂直) + 0, 0 +); + +// 放射渐变 +g.beginGradientFill( + "radial", + [0x667eea, 0x764ba2], + [1, 1], + [0, 255], + matrix +); +g.drawRect(0, 0, stage.stageWidth, stage.stageHeight); +g.endFill(); + +// 放置在后面 +stage.addChildAt(shape, 0); +``` + +### 动态重绘 + +```javascript +const { Shape } = next2d.display; + +const shape = new Shape(); +stage.addChild(shape); + +let angle = 0; + +// 每帧重绘 +stage.addEventListener("enterFrame", function() { + const g = shape.graphics; + + // 清除之前的绘制 + g.clear(); + + // 在新位置绘制 + const x = 200 + Math.cos(angle) * 100; + const y = 150 + Math.sin(angle) * 100; + + g.beginFill(0xe74c3c); + g.drawCircle(x, y, 20); + g.endFill(); + + angle += 0.05; +}); +``` + +### 由多个 Shape 组成 + +```javascript +const { Shape } = next2d.display; + +// 背景层 +const bgShape = new Shape(); +bgShape.graphics.beginFill(0x2c3e50); +bgShape.graphics.drawRect(0, 0, 400, 300); +bgShape.graphics.endFill(); + +// 装饰层 +const decorShape = new Shape(); +decorShape.graphics.beginFill(0x3498db, 0.5); +decorShape.graphics.drawCircle(100, 100, 80); +decorShape.graphics.drawCircle(300, 200, 60); +decorShape.graphics.endFill(); + +// 前景层 +const frontShape = new Shape(); +frontShape.graphics.lineStyle(2, 0xecf0f1); +frontShape.graphics.drawRect(50, 50, 300, 200); + +stage.addChild(bgShape); +stage.addChild(decorShape); +stage.addChild(frontShape); +``` + +## 性能提示 + +1. **对静态绘制使用 Shape**:Shape 对于不需要交互的背景和装饰是最佳选择 +2. **最小化绘制**:如果内容不经常更改,只绘制一次 +3. **使用 clear()**:动态重绘时始终调用 clear() +4. **缓存复杂形状**:使用 cacheAsBitmap 属性缓存绘制 + +```javascript +// 将复杂形状缓存为位图 +shape.cacheAsBitmap = true; +``` + +## Graphics 类 + +Graphics 类提供用于渲染矢量图形的绘图 API。通过 Shape.graphics 属性访问。 + +### 填充方法 + +| 方法 | 说明 | +|------|------| +| `beginFill(color: number, alpha?: number)` | 开始纯色填充。Alpha 默认为 1 | +| `beginGradientFill(type, colors, alphas, ratios, matrix?, spreadMethod?, interpolationMethod?, focalPointRatio?)` | 开始渐变填充 | +| `beginBitmapFill(bitmapData, matrix?, repeat?, smooth?)` | 开始位图填充 | +| `endFill()` | 结束当前填充 | + +#### beginGradientFill 参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `type` | string | "linear" 或 "radial" | +| `colors` | number[] | 颜色数组(十六进制) | +| `alphas` | number[] | 每种颜色的 Alpha 值(0-1) | +| `ratios` | number[] | 每种颜色的位置(0-255) | +| `matrix` | Matrix | 渐变的变换矩阵 | +| `spreadMethod` | string | "pad"、"reflect"、"repeat"(默认:"pad") | +| `interpolationMethod` | string | "rgb" 或 "linearRGB"(默认:"rgb") | +| `focalPointRatio` | number | 放射渐变的焦点位置(-1 到 1) | + +### 线条样式方法 + +| 方法 | 说明 | +|------|------| +| `lineStyle(thickness?, color?, alpha?, pixelHinting?, scaleMode?, caps?, joints?, miterLimit?)` | 设置线条样式 | +| `lineGradientStyle(type, colors, alphas, ratios, matrix?, spreadMethod?, interpolationMethod?, focalPointRatio?)` | 设置渐变线条样式 | +| `lineBitmapStyle(bitmapData, matrix?, repeat?, smooth?)` | 设置位图线条样式 | +| `endLine()` | 结束线条样式 | + +#### lineStyle 参数 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `thickness` | number | 0 | 线条粗细(像素) | +| `color` | number | 0 | 线条颜色(十六进制) | +| `alpha` | number | 1 | Alpha 透明度(0-1) | +| `pixelHinting` | boolean | false | 像素对齐 | +| `scaleMode` | string | "normal" | "normal"、"none"、"vertical"、"horizontal" | +| `caps` | string | null | "none"、"round"、"square" | +| `joints` | string | null | "bevel"、"miter"、"round" | +| `miterLimit` | number | 3 | 斜接限制 | + +### 路径方法 + +| 方法 | 说明 | +|------|------| +| `moveTo(x: number, y: number)` | 移动绘制位置 | +| `lineTo(x: number, y: number)` | 从当前位置到指定坐标绘制线条 | +| `curveTo(controlX, controlY, anchorX, anchorY)` | 绘制二次贝塞尔曲线 | +| `cubicCurveTo(controlX1, controlY1, controlX2, controlY2, anchorX, anchorY)` | 绘制三次贝塞尔曲线 | + +### 形状方法 + +| 方法 | 说明 | +|------|------| +| `drawRect(x, y, width, height)` | 绘制矩形 | +| `drawRoundRect(x, y, width, height, ellipseWidth, ellipseHeight?)` | 绘制圆角矩形 | +| `drawCircle(x, y, radius)` | 绘制圆形 | +| `drawEllipse(x, y, width, height)` | 绘制椭圆 | + +### 实用方法 + +| 方法 | 说明 | +|------|------| +| `clear()` | 清除所有绘制命令 | +| `clone()` | 克隆 Graphics 对象 | +| `copyFrom(source: Graphics)` | 从另一个 Graphics 复制绘制命令 | + +### 详细使用示例 + +#### 线性渐变 + +```javascript +const { Shape } = next2d.display; +const { Matrix } = next2d.geom; + +const shape = new Shape(); +const g = shape.graphics; + +const matrix = new Matrix(); +matrix.createGradientBox(200, 100, 0, 0, 0); // 宽度, 高度, 旋转, x, y + +g.beginGradientFill( + "linear", // 类型 + [0xff0000, 0x00ff00, 0x0000ff], // 颜色 + [1, 1, 1], // alpha + [0, 127, 255], // 比例 + matrix +); +g.drawRect(0, 0, 200, 100); +g.endFill(); + +stage.addChild(shape); +``` + +#### 三次贝塞尔曲线 + +```javascript +const { Shape } = next2d.display; + +const shape = new Shape(); +const g = shape.graphics; + +g.lineStyle(2, 0x3498db); + +// 平滑 S 曲线 +g.moveTo(0, 100); +g.cubicCurveTo( + 50, 0, // 控制点 1 + 150, 200, // 控制点 2 + 200, 100 // 锚点 +); + +stage.addChild(shape); +``` + +#### 位图填充 + +```javascript +const { Shape, Loader } = next2d.display; + +const loader = new Loader(); +await loader.load("texture.png"); + +const bitmapData = loader.contentLoaderInfo + .content.bitmapData; + +const shape = new Shape(); +const g = shape.graphics; + +g.beginBitmapFill(bitmapData, null, true, true); +g.drawRect(0, 0, 400, 300); +g.endFill(); + +stage.addChild(shape); +``` + +#### 渐变线条 + +```javascript +const { Shape } = next2d.display; +const { Matrix } = next2d.geom; + +const shape = new Shape(); +const g = shape.graphics; + +const matrix = new Matrix(); +matrix.createGradientBox(200, 200, 0, 0, 0); + +g.lineGradientStyle( + "linear", + [0xff0000, 0x0000ff], + [1, 1], + [0, 255], + matrix +); +g.lineStyle(5); + +g.moveTo(10, 10); +g.lineTo(190, 10); +g.lineTo(190, 190); +g.lineTo(10, 190); +g.lineTo(10, 10); + +stage.addChild(shape); +``` + +#### 复杂形状组合 + +```javascript +const { Shape } = next2d.display; + +const shape = new Shape(); +const g = shape.graphics; + +// 外部矩形(填充) +g.beginFill(0x2c3e50); +g.drawRoundRect(0, 0, 200, 150, 15, 15); +g.endFill(); + +// 内部圆形(不同颜色填充) +g.beginFill(0xe74c3c); +g.drawCircle(100, 75, 40); +g.endFill(); + +// 装饰线条 +g.lineStyle(2, 0xecf0f1); +g.moveTo(20, 20); +g.lineTo(180, 20); +g.moveTo(20, 130); +g.lineTo(180, 130); + +stage.addChild(shape); +``` + +## 相关 + +- [DisplayObject](/cn/reference/player/display-object) +- [Sprite](/cn/reference/player/sprite) +- [滤镜](/cn/reference/player/filters) diff --git a/specs/cn/sound.md b/specs/cn/sound.md new file mode 100644 index 00000000..f41a5de8 --- /dev/null +++ b/specs/cn/sound.md @@ -0,0 +1,281 @@ +# Sound + +Next2D Player 为游戏和应用程序提供音频功能,支持 BGM、音效、语音等。 + +## 类结构 + +```mermaid +classDiagram + EventDispatcher <|-- Sound + class Sound { + +audioBuffer: AudioBuffer + +volume: number + +loopCount: number + +canLoop: boolean + +load(request): Promise + +play(startTime): void + +stop(): void + +clone(): Sound + } + class SoundMixer { + +volume: Number + +stopAll(): void + } +``` + +## Sound + +用于加载和播放音频文件的类。扩展自 EventDispatcher。 + +### 属性 + +| 属性 | 类型 | 默认值 | 只读 | 说明 | +|------|------|--------|:----:|------| +| `audioBuffer` | AudioBuffer \| null | null | - | 音频缓冲区。存储由 load() 加载的音频数据 | +| `loopCount` | number | 0 | - | 循环计数设置。0 表示不循环,9999 表示几乎无限循环 | +| `volume` | number | 1 | - | 音量,范围从 0(静音)到 1(最大音量)。不能超过 SoundMixer.volume 值 | +| `canLoop` | boolean | - | 是 | 表示声音是否循环 | + +### 方法 + +| 方法 | 返回值 | 说明 | +|------|--------|------| +| `clone()` | Sound | 复制 Sound 类。复制 volume、loopCount 和 audioBuffer | +| `load(request: URLRequest)` | Promise\ | 从指定 URL 开始加载外部 MP3 文件 | +| `play(startTime: number = 0)` | void | 播放声音。startTime 是播放开始时间(秒)。如果已在播放则不执行任何操作 | +| `stop()` | void | 停止通道中正在播放的声音 | + +## 使用示例 + +### 基本音频播放 + +```javascript +const { Sound } = next2d.media; +const { URLRequest } = next2d.net; + +// 创建 Sound 对象 +const sound = new Sound(); + +// 加载音频文件 +await sound.load(new URLRequest("bgm.mp3")); + +// 开始播放 +sound.play(); +``` + +### 音效播放 + +```javascript +const { Sound } = next2d.media; +const { URLRequest } = next2d.net; + +// 预加载音效 +const seJump = new Sound(); +const seHit = new Sound(); +const seCoin = new Sound(); + +// 加载 +await seJump.load(new URLRequest("se/jump.mp3")); +await seHit.load(new URLRequest("se/hit.mp3")); +await seCoin.load(new URLRequest("se/coin.mp3")); + +// 播放函数 +function playSE(sound) { + sound.play(); +} + +// 在游戏中使用 +player.addEventListener("jump", function() { + playSE(seJump); +}); +``` + +### BGM 循环播放 + +```javascript +const { Sound } = next2d.media; +const { URLRequest } = next2d.net; + +const bgm = new Sound(); + +await bgm.load(new URLRequest("bgm/stage1.mp3")); + +// 设置音量和循环次数 +bgm.volume = 0.7; // 70% +bgm.loopCount = 9999; // 无限循环 + +bgm.play(); + +// 停止 BGM +function stopBGM() { + bgm.stop(); +} +``` + +### 音量控制 + +```javascript +const { Sound } = next2d.media; +const { URLRequest } = next2d.net; + +const bgm = new Sound(); +await bgm.load(new URLRequest("bgm.mp3")); +bgm.volume = 1.0; +bgm.loopCount = 9999; +bgm.play(); + +// 更改音量 +function setVolume(volume) { + bgm.volume = Math.max(0, Math.min(1, volume)); +} + +// 淡出 +function fadeOut(duration) { + duration = duration || 1000; + const startVolume = bgm.volume; + const startTime = Date.now(); + + stage.addEventListener("enterFrame", function fade() { + const elapsed = Date.now() - startTime; + const progress = Math.min(1, elapsed / duration); + + setVolume(startVolume * (1 - progress)); + + if (progress >= 1) { + stage.removeEventListener("enterFrame", fade); + bgm.stop(); + } + }); +} +``` + +### 音频管理器 + +```javascript +const { Sound } = next2d.media; +const { URLRequest } = next2d.net; + +class SoundManager { + constructor() { + this._sounds = new Map(); + this._bgm = null; + this._bgmVolume = 0.7; + this._seVolume = 1.0; + this._isMuted = false; + } + + // 预加载声音 + async preload(id, url) { + const sound = new Sound(); + await sound.load(new URLRequest(url)); + this._sounds.set(id, sound); + } + + // 播放 BGM + playBGM(id, loops) { + loops = loops || 9999; + this.stopBGM(); + + const sound = this._sounds.get(id); + if (sound) { + sound.volume = this._isMuted ? 0 : this._bgmVolume; + sound.loopCount = loops; + sound.play(); + this._bgm = sound; + } + } + + // 停止 BGM + stopBGM() { + if (this._bgm) { + this._bgm.stop(); + this._bgm = null; + } + } + + // 播放音效 + playSE(id) { + const sound = this._sounds.get(id); + if (sound) { + sound.volume = this._isMuted ? 0 : this._seVolume; + sound.loopCount = 0; + sound.play(); + } + } + + // 切换静音 + toggleMute() { + this._isMuted = !this._isMuted; + this._updateVolumes(); + return this._isMuted; + } + + // 设置 BGM 音量 + setBGMVolume(volume) { + this._bgmVolume = Math.max(0, Math.min(1, volume)); + this._updateVolumes(); + } + + // 设置音效音量 + setSEVolume(volume) { + this._seVolume = Math.max(0, Math.min(1, volume)); + } + + _updateVolumes() { + if (this._bgm) { + this._bgm.volume = this._isMuted ? 0 : this._bgmVolume; + } + } +} + +// 使用示例 +const soundManager = new SoundManager(); + +// 启动时预加载 +async function initSounds() { + await soundManager.preload("bgm_title", "bgm/title.mp3"); + await soundManager.preload("bgm_stage1", "bgm/stage1.mp3"); + await soundManager.preload("se_jump", "se/jump.mp3"); + await soundManager.preload("se_coin", "se/coin.mp3"); + await soundManager.preload("se_damage", "se/damage.mp3"); +} + +// 游戏中 +soundManager.playBGM("bgm_stage1"); +soundManager.playSE("se_jump"); +``` + +## SoundMixer + +用于控制所有音频的类。 + +```javascript +const { SoundMixer } = next2d.media; + +// 停止所有音频 +SoundMixer.stopAll(); + +// 更改全局音量 +SoundMixer.volume = 0.5; +``` + +## 支持的格式 + +| 格式 | 扩展名 | 支持 | +|------|--------|------| +| MP3 | .mp3 | 推荐 | +| AAC | .m4a, .aac | 支持 | +| Ogg Vorbis | .ogg | 取决于浏览器 | +| WAV | .wav | 支持(文件大小较大) | + +## 最佳实践 + +1. **预加载**:在游戏开始前预加载所有音频 +2. **格式**:推荐 MP3(兼容性和压缩率的平衡) +3. **音效**:短声音可以使用 WAV(延迟更低) +4. **音量管理**:分别管理 BGM 和音效的音量 +5. **移动端支持**:在用户交互后开始播放 + +## 相关 + +- [事件系统](/cn/reference/player/events) diff --git a/specs/cn/sprite.md b/specs/cn/sprite.md new file mode 100644 index 00000000..070112a9 --- /dev/null +++ b/specs/cn/sprite.md @@ -0,0 +1,238 @@ +# Sprite + +Sprite 是 DisplayObjectContainer。它是 MovieClip 的基类,用于不需要时间轴的动态对象管理。 + +## 继承 + +```mermaid +classDiagram + DisplayObject <|-- InteractiveObject + InteractiveObject <|-- DisplayObjectContainer + DisplayObjectContainer <|-- Sprite + Sprite <|-- MovieClip + + class Sprite { + +buttonMode: Boolean + +useHandCursor: Boolean + } +``` + +## 属性 + +### Sprite 特有属性 + +| 属性 | 类型 | 只读 | 默认值 | 说明 | +|------|------|:----:|--------|------| +| `isSprite` | boolean | 是 | true | 返回是否具有 Sprite 功能 | +| `buttonMode` | boolean | 否 | false | 指定此精灵的按钮模式 | +| `useHandCursor` | boolean | 否 | true | 当 buttonMode 为 true 时是否显示手形光标 | +| `hitArea` | Sprite \| null | 否 | null | 指定另一个精灵作为此精灵的点击区域 | +| `soundTransform` | SoundTransform \| null | 否 | null | 控制此精灵内的声音 | + +### 从 DisplayObjectContainer 继承的属性 + +| 属性 | 类型 | 只读 | 默认值 | 说明 | +|------|------|:----:|--------|------| +| `isContainerEnabled` | boolean | 是 | true | 返回显示对象是否具有容器功能 | +| `mouseChildren` | boolean | 否 | true | 确定对象的子对象是否与鼠标或用户输入设备兼容 | +| `numChildren` | number | 是 | - | 返回此对象的子对象数量 | +| `mask` | DisplayObject \| null | 否 | null | 遮罩显示对象 | + +### 从 InteractiveObject 继承的属性 + +| 属性 | 类型 | 只读 | 默认值 | 说明 | +|------|------|:----:|--------|------| +| `isInteractive` | boolean | 是 | true | 返回是否具有 InteractiveObject 功能 | +| `mouseEnabled` | boolean | 否 | true | 指定此对象是否接收鼠标或其他用户输入消息 | + +### 从 DisplayObject 继承的属性 + +| 属性 | 类型 | 只读 | 默认值 | 说明 | +|------|------|:----:|--------|------| +| `instanceId` | number | 是 | - | DisplayObject 的唯一实例 ID | +| `name` | string | 否 | "" | 返回名称。用于 getChildByName() | +| `parent` | Sprite \| MovieClip \| null | 否 | null | 返回此 DisplayObject 父级的 DisplayObjectContainer | +| `x` | number | 否 | 0 | 相对于父 DisplayObjectContainer 本地坐标的 x 坐标 | +| `y` | number | 否 | 0 | 相对于父 DisplayObjectContainer 本地坐标的 y 坐标 | +| `width` | number | 否 | - | 显示对象的宽度(像素) | +| `height` | number | 否 | - | 显示对象的高度(像素) | +| `scaleX` | number | 否 | 1 | 从参考点应用的对象水平缩放值 | +| `scaleY` | number | 否 | 1 | 从参考点应用的对象垂直缩放值 | +| `rotation` | number | 否 | 0 | DisplayObject 实例相对于其原始方向的旋转角度(度) | +| `alpha` | number | 否 | 1 | 对象的 Alpha 透明度值(0.0 到 1.0) | +| `visible` | boolean | 否 | true | 显示对象是否可见 | +| `blendMode` | string | 否 | "normal" | 来自 BlendMode 类的值,指定要使用的混合模式 | +| `filters` | array \| null | 否 | null | 当前与显示对象关联的滤镜对象数组 | +| `matrix` | Matrix | 否 | - | 返回显示对象的 Matrix | +| `colorTransform` | ColorTransform | 否 | - | 返回显示对象的 ColorTransform | +| `concatenatedMatrix` | Matrix | 是 | - | 此显示对象和所有父对象的组合 Matrix | +| `scale9Grid` | Rectangle \| null | 否 | null | 当前有效的缩放网格 | +| `loaderInfo` | LoaderInfo \| null | 是 | null | 此显示对象所属文件的加载信息 | +| `root` | MovieClip \| Sprite \| null | 是 | null | DisplayObject 的根 DisplayObjectContainer | +| `mouseX` | number | 是 | - | 相对于 DisplayObject 参考点的 x 轴位置(像素) | +| `mouseY` | number | 是 | - | 相对于 DisplayObject 参考点的 y 轴位置(像素) | +| `dropTarget` | Sprite \| null | 是 | null | 精灵被拖动或放置到的显示对象 | +| `isMask` | boolean | 否 | false | 表示 DisplayObject 是否被设置为遮罩 | + +## 方法 + +### Sprite 特有方法 + +| 方法 | 返回类型 | 说明 | +|------|----------|------| +| `startDrag(lockCenter?: boolean, bounds?: Rectangle)` | void | 让用户拖动指定的精灵 | +| `stopDrag()` | void | 结束 startDrag() 方法 | + +### 从 DisplayObjectContainer 继承的方法 + +| 方法 | 返回类型 | 说明 | +|------|----------|------| +| `addChild(child: DisplayObject)` | DisplayObject | 添加子 DisplayObject 实例 | +| `addChildAt(child: DisplayObject, index: number)` | DisplayObject | 在指定索引位置添加子 DisplayObject 实例 | +| `removeChild(child: DisplayObject)` | void | 移除指定的子 DisplayObject 实例 | +| `removeChildAt(index: number)` | void | 从指定索引位置移除子 DisplayObject | +| `removeChildren(...indexes: number[])` | void | 从容器中移除数组中指定索引处的子对象 | +| `getChildAt(index: number)` | DisplayObject \| null | 返回指定索引位置的子显示对象实例 | +| `getChildByName(name: string)` | DisplayObject \| null | 返回具有指定名称的子显示对象 | +| `getChildIndex(child: DisplayObject)` | number | 返回子 DisplayObject 实例的索引位置 | +| `setChildIndex(child: DisplayObject, index: number)` | void | 更改显示对象容器中现有子对象的位置 | +| `contains(child: DisplayObject)` | boolean | 指定的 DisplayObject 是否是实例的后代 | +| `swapChildren(child1: DisplayObject, child2: DisplayObject)` | void | 交换两个指定子对象的 z 顺序 | +| `swapChildrenAt(index1: number, index2: number)` | void | 交换两个指定索引位置的子对象的 z 顺序 | + +### 从 DisplayObject 继承的方法 + +| 方法 | 返回类型 | 说明 | +|------|----------|------| +| `getBounds(targetDisplayObject?: DisplayObject)` | Rectangle | 返回定义显示对象相对于 targetDisplayObject 坐标系统区域的矩形 | +| `globalToLocal(point: Point)` | Point | 将点对象从舞台(全局)坐标转换为显示对象(本地)坐标 | +| `localToGlobal(point: Point)` | Point | 将点对象从显示对象(本地)坐标转换为舞台(全局)坐标 | +| `hitTestObject(target: DisplayObject)` | boolean | 评估 DisplayObject 的绘制范围是否重叠或相交 | +| `hitTestPoint(x: number, y: number, shapeFlag?: boolean)` | boolean | 评估显示对象是否与 x 和 y 参数指定的点重叠或相交 | +| `remove()` | void | 移除父子关系 | +| `getLocalVariable(key: any)` | any | 从类的本地变量空间获取值 | +| `setLocalVariable(key: any, value: any)` | void | 在类的本地变量空间中存储值 | +| `hasLocalVariable(key: any)` | boolean | 确定类的本地变量空间中是否有值 | +| `deleteLocalVariable(key: any)` | void | 从类的本地变量空间中删除值 | +| `getGlobalVariable(key: any)` | any | 从全局变量空间获取值 | +| `setGlobalVariable(key: any, value: any)` | void | 在全局变量空间中存储值 | +| `hasGlobalVariable(key: any)` | boolean | 确定全局变量空间中是否有值 | +| `deleteGlobalVariable(key: any)` | void | 从全局变量空间中删除值 | +| `clearGlobalVariable()` | void | 清除全局变量空间中的所有值 | + +## 使用示例 + +### 作为按钮使用 + +```javascript +const { Sprite, Shape } = next2d.display; + +const button = new Sprite(); + +// 启用按钮模式 +button.buttonMode = true; +button.useHandCursor = true; + +// 创建背景 Shape +const bg = new Shape(); +bg.graphics.beginFill(0x3498db); +bg.graphics.drawRoundRect(0, 0, 120, 40, 8, 8); +bg.graphics.endFill(); +button.addChild(bg); + +// 点击事件 +button.addEventListener("click", function() { + console.log("按钮被点击"); +}); + +stage.addChild(button); +``` + +### 作为遮罩使用 + +```javascript +const { Sprite, Shape } = next2d.display; + +const container = new Sprite(); + +// 内容 Shape +const content = new Shape(); +content.graphics.beginFill(0xFF0000); +content.graphics.drawRect(0, 0, 200, 200); +content.graphics.endFill(); +container.addChild(content); + +// 遮罩 Shape +const maskShape = new Shape(); +maskShape.graphics.beginFill(0xFFFFFF); +maskShape.graphics.drawCircle(100, 100, 50); +maskShape.graphics.endFill(); + +// 应用遮罩 +container.mask = maskShape; + +stage.addChild(container); +stage.addChild(maskShape); +``` + +### 拖放 + +```javascript +const { Sprite, Shape } = next2d.display; +const { Rectangle } = next2d.geom; + +const draggable = new Sprite(); + +// 创建背景 Shape +const bg = new Shape(); +bg.graphics.beginFill(0x3498db); +bg.graphics.drawRect(0, 0, 100, 100); +bg.graphics.endFill(); +draggable.addChild(bg); + +// 开始拖动 +draggable.addEventListener("mouseDown", function() { + // 开始拖动(锁定中心,指定边界) + draggable.startDrag(true, new Rectangle(0, 0, 400, 300)); +}); + +// 停止拖动 +draggable.addEventListener("mouseUp", function() { + draggable.stopDrag(); +}); + +stage.addChild(draggable); +``` + +### 管理子对象 + +```javascript +const { Sprite, Shape } = next2d.display; + +const container = new Sprite(); + +// 添加多个 Shape 作为子对象 +for (let i = 0; i < 5; i++) { + const shape = new Shape(); + shape.graphics.beginFill(0xFF0000 + i * 0x003300); + shape.graphics.drawCircle(0, 0, 20); + shape.graphics.endFill(); + shape.x = i * 50; + shape.name = "circle" + i; + container.addChild(shape); +} + +// 通过名称获取子对象 +const circle2 = container.getChildByName("circle2"); + +// 获取子对象数量 +console.log(container.numChildren); // 5 + +stage.addChild(container); +``` + +## 相关 + +- [DisplayObject](/cn/reference/player/display-object) +- [MovieClip](/cn/reference/player/movie-clip) +- [Shape](/cn/reference/player/shape) diff --git a/specs/cn/text-field.md b/specs/cn/text-field.md new file mode 100644 index 00000000..788aff18 --- /dev/null +++ b/specs/cn/text-field.md @@ -0,0 +1,362 @@ +# TextField + +TextField 是用于显示和编辑文本的 DisplayObject。它提供从标签显示到输入表单的文本相关功能。 + +## 继承 + +```mermaid +classDiagram + DisplayObject <|-- InteractiveObject + InteractiveObject <|-- TextField + + class TextField { + +text: String + +textColor: Number + +type: String + +setTextFormat() + } +``` + +## 属性 + +### 文本相关 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `text` | string | 文本字段中的当前文本字符串 | +| `htmlText` | string | 包含文本字段内容的 HTML 表示 | +| `length` | number | 文本字段中的字符数(只读) | +| `maxChars` | number | 文本字段可以包含的最大字符数(0 表示无限制) | +| `restrict` | string | 指示用户可以输入到文本字段中的字符集 | +| `defaultTextFormat` | TextFormat | 指定应用于文本的格式 | +| `stopIndex` | number | 设置文本的任意显示结束位置(默认:-1) | + +### 显示相关 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `width` | number | 显示对象的宽度(像素) | +| `height` | number | 显示对象的高度(像素) | +| `textWidth` | number | 文本的宽度(像素)(只读) | +| `textHeight` | number | 文本的高度(像素)(只读) | +| `autoSize` | string | 控制文本字段的自动调整大小和对齐("none"、"left"、"center"、"right") | +| `autoFontSize` | boolean | 控制文本大小的自动调整大小和对齐(默认:false) | +| `wordWrap` | boolean | 表示文本字段是否自动换行的布尔值(默认:false) | +| `multiline` | boolean | 指示字段是否为多行文本字段(默认:false) | +| `numLines` | number | 文本行数(只读) | + +### 边框和背景相关 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `background` | boolean | 指定文本字段是否具有背景填充(默认:false) | +| `backgroundColor` | number | 文本字段背景的颜色(默认:0xffffff) | +| `border` | boolean | 指定文本字段是否具有边框(默认:false) | +| `borderColor` | number | 文本字段边框的颜色(默认:0x000000) | + +### 轮廓相关 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `thickness` | number | 轮廓的文本宽度,可以用 0 禁用(默认:0) | +| `thicknessColor` | number | 轮廓文本的十六进制格式颜色(默认:0) | + +### 输入相关 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `type` | string | 文本字段的类型("static"、"dynamic"、"input")(默认:"static") | +| `focus` | boolean | 文本字段是否具有焦点(默认:false) | +| `focusVisible` | boolean | 控制文本字段闪烁线的可见性(默认:false) | +| `focusIndex` | number | 文本字段焦点位置的索引(默认:-1) | +| `selectIndex` | number | 文本字段选择位置的索引(默认:-1) | +| `compositionStartIndex` | number | 文本字段的组合开始索引(默认:-1) | +| `compositionEndIndex` | number | 文本字段的组合结束索引(默认:-1) | + +### 滚动相关 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `scrollX` | number | x 轴上的滚动位置(默认:0) | +| `scrollY` | number | y 轴上的滚动位置(默认:0) | +| `scrollEnabled` | boolean | 控制滚动功能的开/关(默认:true) | +| `xScrollShape` | Shape | 用于 x 滚动条显示的 Shape 对象(只读) | +| `yScrollShape` | Shape | 用于 y 滚动条显示的 Shape 对象(只读) | + +## 方法 + +| 方法 | 返回值 | 说明 | +|------|--------|------| +| `appendText(newText: string)` | void | 将 newText 参数指定的字符串附加到文本字段文本的末尾 | +| `insertText(newText: string)` | void | 将文本添加到文本字段的焦点位置 | +| `deleteText()` | void | 删除文本字段的选择范围 | +| `getLineText(lineIndex: number)` | string | 返回 lineIndex 参数指定的行的文本 | +| `replaceText(newText: string, beginIndex: number, endIndex: number)` | void | 将 beginIndex 和 endIndex 参数指定的字符范围替换为 newText 参数的内容 | +| `selectAll()` | void | 选择文本字段中的所有文本 | +| `copy()` | void | 复制文本字段的选择 | +| `paste()` | void | 将复制的文本粘贴到选择范围 | +| `setFocusIndex(stageX: number, stageY: number, selected?: boolean)` | void | 设置文本字段的焦点位置 | +| `keyDown(event: KeyboardEvent)` | void | 处理按键按下事件 | + +## TextFormat + +用于设置文本样式的类。 + +### 属性 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `font` | String | 字体名称 | +| `size` | Number | 字体大小 | +| `color` | Number | 文本颜色 | +| `bold` | Boolean | 粗体 | +| `italic` | Boolean | 斜体 | +| `align` | String | 对齐("left"、"center"、"right") | +| `leading` | Number | 行间距(像素) | +| `letterSpacing` | Number | 字母间距(像素) | + +## 使用示例 + +### 基本文本显示 + +```javascript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.text = "Hello, Next2D!"; +textField.x = 100; +textField.y = 100; + +stage.addChild(textField); +``` + +### 应用 TextFormat + +```javascript +const { TextField, TextFormat } = next2d.text; + +const textField = new TextField(); +textField.text = "样式文本"; + +// 创建 TextFormat +const format = new TextFormat(); +format.font = "Arial"; +format.size = 24; +format.color = 0x3498db; +format.bold = true; + +// 应用格式 +textField.setTextFormat(format); + +// 设置为默认格式 +textField.defaultTextFormat = format; + +stage.addChild(textField); +``` + +### 自动大小 + +```javascript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.autoSize = "left"; // 自动扩展以适应文本 +textField.text = "此文本将自动调整字段大小"; + +stage.addChild(textField); +``` + +### 多行文本 + +```javascript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.width = 200; +textField.multiline = true; +textField.wordWrap = true; +textField.text = "这是多行文本。它将自动换行。"; + +stage.addChild(textField); +``` + +### 输入字段 + +```javascript +const { TextField } = next2d.text; + +const inputField = new TextField(); +inputField.type = "input"; +inputField.width = 200; +inputField.height = 30; +inputField.border = true; +inputField.borderColor = 0xcccccc; +inputField.background = true; +inputField.backgroundColor = 0xffffff; + +// 占位符替代 +inputField.text = ""; + +// 输入限制(仅数字) +inputField.restrict = "0-9"; + +// 输入事件 +inputField.addEventListener("change", function(event) { + console.log("输入值:", event.target.text); +}); + +stage.addChild(inputField); +``` + +### 密码字段 + +```javascript +const { TextField } = next2d.text; + +const passwordField = new TextField(); +passwordField.type = "input"; +passwordField.displayAsPassword = true; +passwordField.width = 200; +passwordField.height = 30; +passwordField.border = true; +passwordField.borderColor = 0xcccccc; + +stage.addChild(passwordField); +``` + +### HTML 文本 + +```javascript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.width = 300; +textField.multiline = true; +textField.htmlText = '' + + '粗体文本
' + + '斜体文本
' + + '红色文本' + + '
'; + +stage.addChild(textField); +``` + +### 可滚动文本 + +```javascript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.width = 200; +textField.height = 100; +textField.multiline = true; +textField.wordWrap = true; +textField.border = true; +textField.text = "长文本...\n".repeat(20); + +// 滚动操作 +function scrollUp() { + if (textField.scrollY > 0) { + textField.scrollY -= 10; + } +} + +function scrollDown() { + textField.scrollY += 10; +} + +stage.addChild(textField); +``` + +### 动态文本更新 + +```javascript +const { TextField, TextFormat } = next2d.text; + +const scoreField = new TextField(); +scoreField.autoSize = "left"; + +const format = new TextFormat(); +format.font = "Arial"; +format.size = 32; +format.color = 0xffffff; +scoreField.defaultTextFormat = format; + +let score = 0; + +function updateScore(points) { + score += points; + scoreField.text = "分数: " + score; +} + +updateScore(0); +stage.addChild(scoreField); +``` + +### 文本轮廓效果 + +```javascript +const { TextField, TextFormat } = next2d.text; + +const textField = new TextField(); +textField.autoSize = "left"; + +const format = new TextFormat(); +format.font = "Arial"; +format.size = 48; +format.color = 0xffffff; +textField.defaultTextFormat = format; + +textField.text = "轮廓文本"; +textField.thickness = 2; +textField.thicknessColor = 0x000000; + +stage.addChild(textField); +``` + +### 替换部分文本 + +```javascript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.autoSize = "left"; +textField.text = "Hello World!"; + +// 将 "World" 替换为 "Next2D" +textField.replaceText("Next2D", 6, 11); +// 结果: "Hello Next2D!" + +stage.addChild(textField); +``` + +## 事件 + +| 事件 | 说明 | +|------|------| +| `change` | 文本更改时 | +| `focus` | 获得焦点时 | +| `blur` | 失去焦点时 | +| `keyDown` | 按键按下时 | +| `keyUp` | 按键释放时 | + +```javascript +const { TextField } = next2d.text; + +const inputField = new TextField(); +inputField.type = "input"; + +// 按下 Enter 键时提交表单 +inputField.addEventListener("keyDown", function(event) { + if (event.keyCode === 13) { // Enter + submitForm(inputField.text); + } +}); + +stage.addChild(inputField); +``` + +## 相关 + +- [DisplayObject](/cn/reference/player/display-object) +- [事件系统](/cn/reference/player/events) diff --git a/specs/cn/tween.md b/specs/cn/tween.md new file mode 100644 index 00000000..57c5ec2f --- /dev/null +++ b/specs/cn/tween.md @@ -0,0 +1,374 @@ +# 补间动画 + +Next2D Player 允许您实现程序化动画(补间)。您可以平滑地动画化位置、大小和透明度等属性。 + +## 基本补间概念 + +```mermaid +flowchart LR + Start["起始值"] -->|缓动函数| Progress["进度 0→1"] + Progress --> End["结束值"] + + subgraph Easing["缓动"] + Linear["线性"] + EaseIn["EaseIn"] + EaseOut["EaseOut"] + EaseInOut["EaseInOut"] + end +``` + +## 基本 Tween 类 + +```javascript +class Tween { + constructor(target, options) { + this._target = target; + this._properties = {}; + this._duration = options.duration; + this._easing = options.easing || Easing.linear; + this._startTime = 0; + this._isPlaying = false; + this._onUpdate = options.onUpdate; + this._onComplete = options.onComplete; + } + + to(properties) { + for (const key in properties) { + this._properties[key] = { + start: this._target[key], + end: properties[key] + }; + } + return this; + } + + play() { + this._startTime = Date.now(); + this._isPlaying = true; + this._update(); + return this; + } + + _update() { + const self = this; + if (!this._isPlaying) return; + + const elapsed = Date.now() - this._startTime; + let progress = Math.min(1, elapsed / this._duration); + progress = this._easing(progress); + + // 更新属性 + for (const key in this._properties) { + const prop = this._properties[key]; + this._target[key] = prop.start + (prop.end - prop.start) * progress; + } + + if (this._onUpdate) { + this._onUpdate(); + } + + if (elapsed < this._duration) { + requestAnimationFrame(function() { self._update(); }); + } else { + this._isPlaying = false; + if (this._onComplete) { + this._onComplete(); + } + } + } + + stop() { + this._isPlaying = false; + } +} +``` + +## 缓动函数 + +```javascript +const Easing = { + // 线性 + linear: function(t) { return t; }, + + // 加速 + easeInQuad: function(t) { return t * t; }, + easeInCubic: function(t) { return t * t * t; }, + easeInQuart: function(t) { return t * t * t * t; }, + + // 减速 + easeOutQuad: function(t) { return t * (2 - t); }, + easeOutCubic: function(t) { return (--t) * t * t + 1; }, + easeOutQuart: function(t) { return 1 - (--t) * t * t * t; }, + + // 加速 → 减速 + easeInOutQuad: function(t) { + return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; + }, + easeInOutCubic: function(t) { + return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1; + }, + + // 弹跳 + easeOutBounce: function(t) { + if (t < 1 / 2.75) { + return 7.5625 * t * t; + } else if (t < 2 / 2.75) { + return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75; + } else if (t < 2.5 / 2.75) { + return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375; + } else { + return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375; + } + }, + + // Back(超调然后返回) + easeOutBack: function(t) { + const c1 = 1.70158; + const c3 = c1 + 1; + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); + }, + + // 弹性(橡皮筋般的运动) + easeOutElastic: function(t) { + if (t === 0 || t === 1) return t; + return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * (2 * Math.PI) / 3) + 1; + } +}; +``` + +## 使用示例 + +### 基本移动动画 + +```javascript +const { Sprite } = next2d.display; + +const sprite = new Sprite(); +sprite.x = 0; +sprite.y = 100; +stage.addChild(sprite); + +// 向右移动 +new Tween(sprite, { duration: 1000, easing: Easing.easeOutQuad }) + .to({ x: 400 }) + .play(); +``` + +### 同时多属性动画 + +```javascript +// 移动 + 缩放 + 淡入 +new Tween(sprite, { + duration: 500, + easing: Easing.easeOutCubic +}) + .to({ + x: 200, + y: 150, + scaleX: 2, + scaleY: 2, + alpha: 1 + }) + .play(); +``` + +### 顺序动画 + +```javascript +// 连续动画 +function sequentialAnimation(sprite) { + new Tween(sprite, { + duration: 500, + onComplete: function() { + new Tween(sprite, { + duration: 300, + onComplete: function() { + new Tween(sprite, { duration: 500 }) + .to({ alpha: 0 }) + .play(); + } + }) + .to({ scaleX: 1.5, scaleY: 1.5 }) + .play(); + } + }) + .to({ y: 100 }) + .play(); +} +``` + +### 游戏示例 + +#### 角色跳跃 + +```javascript +function jump(character) { + const startY = character.y; + const jumpHeight = 100; + + // 上升 + new Tween(character, { + duration: 300, + easing: Easing.easeOutQuad, + onComplete: function() { + // 下降 + new Tween(character, { + duration: 300, + easing: Easing.easeInQuad + }) + .to({ y: startY }) + .play(); + } + }) + .to({ y: startY - jumpHeight }) + .play(); +} +``` + +#### 伤害效果 + +```javascript +function damageEffect(target) { + const originalX = target.x; + let shakeCount = 0; + + // 闪烁 + 震动 + function shake() { + if (shakeCount >= 6) { + target.x = originalX; + target.alpha = 1; + return; + } + + const offset = shakeCount % 2 === 0 ? 5 : -5; + target.x = originalX + offset; + target.alpha = shakeCount % 2 === 0 ? 0.5 : 1; + shakeCount++; + + setTimeout(shake, 50); + } + + shake(); +} +``` + +#### 金币收集效果 + +```javascript +function coinCollectEffect(coin, targetY) { + // 向上浮动并淡出 + new Tween(coin, { + duration: 500, + easing: Easing.easeOutQuad, + onUpdate: function() { + // 旋转 + coin.rotation += 15; + }, + onComplete: function() { + if (coin.parent) { + coin.parent.removeChild(coin); + } + } + }) + .to({ + y: targetY, + alpha: 0, + scaleX: 0.5, + scaleY: 0.5 + }) + .play(); +} +``` + +#### UI 动画 + +```javascript +function showPopup(popup) { + popup.scaleX = 0; + popup.scaleY = 0; + popup.alpha = 0; + + new Tween(popup, { + duration: 400, + easing: Easing.easeOutBack + }) + .to({ scaleX: 1, scaleY: 1, alpha: 1 }) + .play(); +} + +function hidePopup(popup, onComplete) { + new Tween(popup, { + duration: 200, + easing: Easing.easeInQuad, + onComplete: onComplete + }) + .to({ scaleX: 0, scaleY: 0, alpha: 0 }) + .play(); +} +``` + +## 基于 enterFrame 的轻量级补间 + +```javascript +// 简单的基于 enterFrame 的补间 +function tweenTo(target, property, endValue, speed) { + speed = speed || 0.1; + + function handler(event) { + const current = target[property]; + const diff = endValue - current; + + if (Math.abs(diff) < 0.1) { + target[property] = endValue; + stage.removeEventListener("enterFrame", handler); + } else { + target[property] = current + diff * speed; + } + } + + stage.addEventListener("enterFrame", handler); +} + +// 使用 +tweenTo(sprite, "x", 300, 0.15); // 将 x 移向 300 +tweenTo(sprite, "alpha", 0, 0.05); // 淡出 +``` + +## 自定义缓动 + +```javascript +// 基于贝塞尔曲线的缓动 +function bezierEasing(x1, y1, x2, y2) { + return function(t) { + // 简单的三次贝塞尔插值 + const cx = 3 * x1; + const bx = 3 * (x2 - x1) - cx; + const ax = 1 - cx - bx; + + const cy = 3 * y1; + const by = 3 * (y2 - y1) - cy; + const ay = 1 - cy - by; + + function sampleCurveY(t) { + return ((ay * t + by) * t + cy) * t; + } + + return sampleCurveY(t); + }; +} + +// CSS cubic-bezier 等效 +const customEase = bezierEasing(0.25, 0.1, 0.25, 1.0); +``` + +## 性能提示 + +1. **使用 requestAnimationFrame**:比 setTimeout 更流畅 +2. **最小化属性更改**:只更新必要的属性 +3. **对象池**:为多个动画池化和重用补间 +4. **完成后清理**:移除不必要的侦听器 + +## 相关 + +- [DisplayObject](/cn/reference/player/display-object) +- [事件系统](/cn/reference/player/events) diff --git a/specs/cn/video.md b/specs/cn/video.md new file mode 100644 index 00000000..4cbca489 --- /dev/null +++ b/specs/cn/video.md @@ -0,0 +1,277 @@ +# Video + +Video 是用于播放视频内容的 DisplayObject。它支持 WebM 和 MP4 等视频格式。 + +## 继承 + +```mermaid +classDiagram + DisplayObject <|-- Video + + class Video { + +src: string + +videoWidth: number + +videoHeight: number + +duration: number + +currentTime: number + +volume: number + +loop: boolean + +autoPlay: boolean + +smoothing: boolean + +paused: boolean + +muted: boolean + +loaded: boolean + +ended: boolean + +isVideo: boolean + +play() Promise~void~ + +pause() void + +seek(offset) void + } +``` + +## 属性 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `src` | string | "" | 指定视频内容的 URL | +| `videoWidth` | number | 0 | 指定视频宽度的整数(像素) | +| `videoHeight` | number | 0 | 指定视频高度的整数(像素) | +| `duration` | number | 0 | 总关键帧数(视频持续时间) | +| `currentTime` | number | 0 | 当前关键帧(播放位置) | +| `volume` | number | 1 | 音量,范围从 0(静音)到 1(最大音量) | +| `loop` | boolean | false | 指定是否生成视频循环 | +| `autoPlay` | boolean | true | 设置自动视频播放 | +| `smoothing` | boolean | true | 指定缩放时是否对视频进行平滑(插值) | +| `paused` | boolean | true | 返回视频是否已暂停 | +| `muted` | boolean | false | 返回视频是否已静音 | +| `loaded` | boolean | false | 返回视频是否已加载 | +| `ended` | boolean | false | 返回视频是否已结束 | +| `isVideo` | boolean | true | 返回显示对象是否具有 Video 功能(只读) | + +## 方法 + +| 方法 | 返回值 | 说明 | +|------|--------|------| +| `play()` | Promise\ | 播放视频文件 | +| `pause()` | void | 暂停视频播放 | +| `seek(offset: number)` | void | 跳转到最接近指定位置的关键帧 | + +## 使用示例 + +### 基本视频播放 + +```javascript +const { Video } = next2d.media; + +// 创建 Video 对象 +const video = new Video(640, 360); + +// 设置视频源 +video.src = "video.mp4"; +video.autoPlay = true; +video.loop = false; +video.volume = 0.8; + +// 添加到舞台 +stage.addChild(video); +``` + +### 播放控制 + +```javascript +const { Video } = next2d.media; + +const video = new Video(640, 360); +video.src = "video.mp4"; +stage.addChild(video); + +// 播放按钮 +playButton.addEventListener("click", async function() { + await video.play(); +}); + +// 暂停按钮 +pauseButton.addEventListener("click", function() { + video.pause(); +}); + +// 停止按钮(暂停并返回开始) +stopButton.addEventListener("click", function() { + video.pause(); + video.seek(0); +}); + +// 快进 10 秒 +forwardButton.addEventListener("click", function() { + video.seek(video.currentTime + 10); +}); + +// 后退 10 秒 +backButton.addEventListener("click", function() { + video.seek(Math.max(0, video.currentTime - 10)); +}); +``` + +### 显示播放进度 + +```javascript +const { Video } = next2d.media; + +const video = new Video(640, 360); +video.src = "video.mp4"; +stage.addChild(video); + +// 每帧更新进度 +stage.addEventListener("enterFrame", function() { + if (video.duration > 0) { + const progress = video.currentTime / video.duration; + progressBar.scaleX = progress; + timeLabel.text = formatTime(video.currentTime) + " / " + formatTime(video.duration); + } +}); + +function formatTime(seconds) { + const min = Math.floor(seconds / 60); + const sec = Math.floor(seconds % 60); + return min + ":" + sec.toString().padStart(2, '0'); +} +``` + +### 音量控制 + +```javascript +const { Video } = next2d.media; + +const video = new Video(640, 360); +video.src = "video.mp4"; +video.volume = 0.5; // 50% +stage.addChild(video); + +// 音量滑块 +volumeSlider.addEventListener("change", function(event) { + video.volume = event.target.value; // 0.0 ~ 1.0 +}); + +// 静音切换 +let isMuted = false; +let previousVolume = 0.5; + +muteButton.addEventListener("click", function() { + isMuted = !isMuted; + if (isMuted) { + previousVolume = video.volume; + video.volume = 0; + } else { + video.volume = previousVolume; + } +}); +``` + +### 全屏支持 + +```javascript +const { Video } = next2d.media; + +const video = new Video(640, 360); +video.src = "video.mp4"; +stage.addChild(video); + +// 全屏切换 +fullscreenButton.addEventListener("click", function() { + if (stage.displayState === "normal") { + // 切换到全屏 + stage.displayState = "fullScreen"; + video.width = stage.stageWidth; + video.height = stage.stageHeight; + } else { + // 返回正常显示 + stage.displayState = "normal"; + video.width = 640; + video.height = 360; + } +}); +``` + +### 视频播放器组件 + +```javascript +const { Sprite } = next2d.display; +const { Video } = next2d.media; + +class VideoPlayer extends Sprite { + constructor(width, height) { + super(); + + this._width = width; + this._height = height; + + this._video = new Video(width, height); + this.addChild(this._video); + } + + load(url) { + this._video.src = url; + } + + async play() { + await this._video.play(); + } + + pause() { + this._video.pause(); + } + + seek(time) { + this._video.seek(time); + } + + get currentTime() { + return this._video.currentTime; + } + + get duration() { + return this._video.duration || 0; + } + + set volume(value) { + this._video.volume = value; + } + + get volume() { + return this._video.volume; + } +} + +// 使用 +const player = new VideoPlayer(640, 360); +stage.addChild(player); +player.load("video.mp4"); +player.play(); +``` + +### 循环播放和自动播放 + +```javascript +const { Video } = next2d.media; + +const video = new Video(640, 360); +video.src = "background-video.mp4"; +video.autoPlay = true; +video.loop = true; +video.volume = 0; // 静音背景视频 + +stage.addChild(video); +``` + +## 支持的格式 + +| 格式 | 扩展名 | 支持 | +|------|--------|------| +| MP4 (H.264) | .mp4 | 推荐 | +| WebM (VP8/VP9) | .webm | 支持 | +| Ogg Theora | .ogv | 取决于浏览器 | + +## 相关 + +- [DisplayObject](/cn/reference/player/display-object) +- [事件系统](/cn/reference/player/events) diff --git a/specs/en/display-object.md b/specs/en/display-object.md new file mode 100644 index 00000000..f8faec70 --- /dev/null +++ b/specs/en/display-object.md @@ -0,0 +1,164 @@ +# DisplayObject + +DisplayObject is the base class for all display objects in Next2D Player. + +## Properties + +### Read-only Properties + +| Property | Type | Description | +|----------|------|-------------| +| `instanceId` | number | Unique instance ID of DisplayObject | +| `isSprite` | boolean | Returns whether Sprite functions are possessed | +| `isInteractive` | boolean | Returns whether InteractiveObject functions are possessed | +| `isContainerEnabled` | boolean | Returns whether the display object has container functionality | +| `isTimelineEnabled` | boolean | Returns whether the display object has MovieClip functionality | +| `isShape` | boolean | Returns whether the display object has Shape functionality | +| `isVideo` | boolean | Returns whether the display object has Video functionality | +| `isText` | boolean | Returns whether the display object has Text functionality | +| `concatenatedMatrix` | Matrix | Combined transformation matrix up to root level | +| `dropTarget` | DisplayObject \| null | Display object over which the sprite is being dragged or dropped | +| `loaderInfo` | LoaderInfo \| null | Loading information for the file to which this display object belongs | +| `mouseX` | number | X coordinate of the mouse relative to the DisplayObject's reference point (pixels) | +| `mouseY` | number | Y coordinate of the mouse relative to the DisplayObject's reference point (pixels) | +| `root` | MovieClip \| Sprite \| null | The root DisplayObjectContainer of the DisplayObject | + +### Read-write Properties + +| Property | Type | Description | +|----------|------|-------------| +| `name` | string | Name. Used by getChildByName() (default: "") | +| `startFrame` | number | Start frame (default: 1) | +| `endFrame` | number | End frame (default: 0) | +| `isMask` | boolean | Indicates whether the DisplayObject is set as a mask (default: false) | +| `parent` | Sprite \| MovieClip \| null | The DisplayObjectContainer parent of this DisplayObject | +| `alpha` | number | Alpha transparency value (0.0-1.0, default: 1.0) | +| `blendMode` | string | Blend mode to use (default: BlendMode.NORMAL) | +| `filters` | Array \| null | Array of filter objects associated with the display object | +| `height` | number | Height of the display object (in pixels) | +| `width` | number | Width of the display object (in pixels) | +| `colorTransform` | ColorTransform | ColorTransform of the display object | +| `matrix` | Matrix | Matrix of the display object | +| `rotation` | number | Rotation angle of the DisplayObject instance (in degrees) | +| `scale9Grid` | Rectangle \| null | Currently active scaling grid | +| `scaleX` | number | Horizontal scale value of the object applied from the reference point | +| `scaleY` | number | Vertical scale value of the object applied from the reference point | +| `visible` | boolean | Whether the display object is visible (default: true) | +| `x` | number | X coordinate relative to the local coordinates of the parent DisplayObjectContainer | +| `y` | number | Y coordinate relative to the local coordinates of the parent DisplayObjectContainer | + +## Methods + +| Method | Return Type | Description | +|--------|-------------|-------------| +| `getBounds(targetDisplayObject)` | Rectangle | Returns a rectangle that defines the area of the display object relative to the coordinate system of the specified DisplayObject | +| `globalToLocal(point)` | Point | Converts the point object from Stage (global) coordinates to the display object's (local) coordinates | +| `localToGlobal(point)` | Point | Converts the point object from the display object's (local) coordinates to Stage (global) coordinates | +| `hitTestObject(targetDisplayObject)` | boolean | Evaluates a DisplayObject's drawing range to see if it overlaps or intersects | +| `hitTestPoint(x, y, shapeFlag)` | boolean | Evaluates the display object to see if it overlaps or intersects with the point specified by x and y parameters | +| `getLocalVariable(key)` | any | Get a value from the local variable space of the class | +| `setLocalVariable(key, value)` | void | Store values in the local variable space of the class | +| `hasLocalVariable(key)` | boolean | Determines if there is a value in the local variable space of the class | +| `deleteLocalVariable(key)` | void | Remove values from the local variable space of the class | +| `getGlobalVariable(key)` | any | Get a value from the global variable space | +| `setGlobalVariable(key, value)` | void | Save values to global variable space | +| `hasGlobalVariable(key)` | boolean | Determines if there is a value in the global variable space | +| `deleteGlobalVariable(key)` | void | Remove values from global variable space | +| `clearGlobalVariable()` | void | Clear all values in the global variable space | +| `remove()` | void | Removes the parent-child relationship | + +## Blend Modes + +| Constant | Description | +|----------|-------------| +| `BlendMode.NORMAL` | Normal display | +| `BlendMode.ADD` | Additive | +| `BlendMode.MULTIPLY` | Multiply | +| `BlendMode.SCREEN` | Screen | +| `BlendMode.DARKEN` | Darken | +| `BlendMode.LIGHTEN` | Lighten | +| `BlendMode.DIFFERENCE` | Difference | +| `BlendMode.OVERLAY` | Overlay | +| `BlendMode.HARDLIGHT` | Hard light | +| `BlendMode.INVERT` | Invert | +| `BlendMode.ALPHA` | Alpha | +| `BlendMode.ERASE` | Erase | + +## Usage Example + +```typescript +const { Sprite } = next2d.display; +const { BlurFilter } = next2d.filters; + +const sprite = new Sprite(); + +// Position and size +sprite.x = 100; +sprite.y = 200; +sprite.scaleX = 1.5; +sprite.scaleY = 1.5; +sprite.rotation = 30; + +// Display control +sprite.alpha = 0.8; +sprite.visible = true; +sprite.blendMode = "add"; + +// Filters +sprite.filters = [ + new BlurFilter(4, 4) +]; + +// Add to stage +stage.addChild(sprite); +``` + +### Coordinate Transformation Example + +```typescript +const { Point } = next2d.geom; + +// Convert global coordinates to local coordinates +const globalPoint = new Point(100, 100); +const localPoint = displayObject.globalToLocal(globalPoint); + +// Convert local coordinates to global coordinates +const localPos = new Point(0, 0); +const globalPos = displayObject.localToGlobal(localPos); +``` + +### Collision Detection Example + +```typescript +// Detection with bounding box +const hit1 = displayObject.hitTestPoint(100, 100, false); + +// Detection with actual shape +const hit2 = displayObject.hitTestPoint(100, 100, true); + +// Collision detection with another DisplayObject +if (obj1.hitTestObject(obj2)) { + console.log("Collision detected"); +} +``` + +### Variable Operations Example + +```typescript +// Local variable operations +displayObject.setLocalVariable("score", 100); +const score = displayObject.getLocalVariable("score"); +if (displayObject.hasLocalVariable("score")) { + displayObject.deleteLocalVariable("score"); +} + +// Global variable operations +displayObject.setGlobalVariable("gameState", "playing"); +const state = displayObject.getGlobalVariable("gameState"); +displayObject.clearGlobalVariable(); // Clear all +``` + +## Related + +- [MovieClip](/en/reference/player/movie-clip) +- [Sprite](/en/reference/player/sprite) diff --git a/specs/en/events.md b/specs/en/events.md new file mode 100644 index 00000000..06fcc9a7 --- /dev/null +++ b/specs/en/events.md @@ -0,0 +1,216 @@ +# Event System + +Next2D Player uses an event model similar to Flash Player. + +## EventDispatcher + +The base class for all event-capable objects. + +### addEventListener(type, listener, useCapture, priority) + +Registers an event listener. + +```javascript +displayObject.addEventListener("click", function(event) { + console.log("Clicked"); +}); + +// Receive in capture phase +displayObject.addEventListener("click", handler, true); + +// Specify priority +displayObject.addEventListener("click", handler, false, 10); +``` + +### removeEventListener(type, listener, useCapture) + +Removes an event listener. + +```javascript +displayObject.removeEventListener("click", handler); +``` + +### hasEventListener(type) + +Checks if a listener of the specified type is registered. + +```javascript +if (displayObject.hasEventListener("click")) { + console.log("Click listener is registered"); +} +``` + +### dispatchEvent(event) + +Dispatches an event. + +```javascript +const { Event } = next2d.events; + +const event = new Event("customEvent"); +displayObject.dispatchEvent(event); +``` + +## Event Class + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `type` | String | Event type | +| `target` | Object | Event source | +| `currentTarget` | Object | Current listener target | +| `eventPhase` | Number | Event phase | +| `bubbles` | Boolean | Whether bubbles | +| `cancelable` | Boolean | Whether cancelable | + +### Methods + +| Method | Description | +|--------|-------------| +| `stopPropagation()` | Stop propagation | +| `stopImmediatePropagation()` | Stop propagation immediately | +| `preventDefault()` | Cancel default behavior | + +## Standard Event Types + +### Display List Related + +| Event | Description | +|-------|-------------| +| `added` | Added to DisplayObjectContainer | +| `addedToStage` | Added to Stage | +| `removed` | Removed from DisplayObjectContainer | +| `removedFromStage` | Removed from Stage | + +```javascript +sprite.addEventListener("addedToStage", function(event) { + console.log("Added to stage"); +}); +``` + +### Timeline Related + +| Event | Description | +|-------|-------------| +| `enterFrame` | Occurs each frame | +| `frameConstructed` | Frame construction complete | +| `exitFrame` | When leaving frame | + +```javascript +movieClip.addEventListener("enterFrame", function(event) { + // Processing executed every frame + updatePosition(); +}); +``` + +### Load Related + +| Event | Description | +|-------|-------------| +| `complete` | Load complete | +| `progress` | Load progress | +| `ioError` | IO error | + +```javascript +const { Loader } = next2d.display; +const { URLRequest } = next2d.net; + +const loader = new Loader(); + +loader.contentLoaderInfo.addEventListener("complete", function(event) { + const content = event.currentTarget.content; + stage.addChild(content); +}); + +loader.contentLoaderInfo.addEventListener("progress", function(event) { + const percent = (event.bytesLoaded / event.bytesTotal) * 100; + console.log(percent + "% loaded"); +}); + +loader.load(new URLRequest("animation.json")); +``` + +## Mouse Events + +| Event | Description | +|-------|-------------| +| `click` | Click | +| `doubleClick` | Double click | +| `mouseDown` | Mouse button pressed | +| `mouseUp` | Mouse button released | +| `mouseMove` | Mouse move | +| `mouseOver` | Mouse over | +| `mouseOut` | Mouse out | +| `rollOver` | Roll over | +| `rollOut` | Roll out | + +```javascript +sprite.addEventListener("click", function(event) { + console.log("Click position:", event.localX, event.localY); +}); + +sprite.addEventListener("mouseMove", function(event) { + console.log("Mouse position:", event.stageX, event.stageY); +}); +``` + +## Keyboard Events + +| Event | Description | +|-------|-------------| +| `keyDown` | Key pressed | +| `keyUp` | Key released | + +```javascript +stage.addEventListener("keyDown", function(event) { + console.log("Key code:", event.keyCode); + + switch (event.keyCode) { + case 37: // Left arrow + player.x -= 10; + break; + case 39: // Right arrow + player.x += 10; + break; + } +}); +``` + +## Custom Events + +```javascript +const { Event } = next2d.events; + +// Define custom event +const customEvent = new Event("gameOver", true, true); + +// Dispatch event +gameManager.dispatchEvent(customEvent); + +// Listen to event +gameManager.addEventListener("gameOver", function(event) { + showGameOverScreen(); +}); +``` + +## Event Propagation + +Events propagate in three phases: + +1. **Capture phase**: From root to target +2. **Target phase**: Processed at target +3. **Bubbling phase**: From target to root + +```javascript +// Process in capture phase +parent.addEventListener("click", handler, true); + +// Process in bubbling phase (default) +child.addEventListener("click", handler, false); +``` + +## Related + +- [DisplayObject](/en/reference/player/display-object) +- [MovieClip](/en/reference/player/movie-clip) diff --git a/specs/en/filters/index.md b/specs/en/filters/index.md new file mode 100644 index 00000000..c00860f3 --- /dev/null +++ b/specs/en/filters/index.md @@ -0,0 +1,393 @@ +# Filters + +Next2D Player provides various visual filters that can be applied to DisplayObjects. + +## Applying Filters + +```typescript +const { Sprite } = next2d.display; +const { BlurFilter, DropShadowFilter, GlowFilter } = next2d.filters; + +const sprite = new Sprite(); + +// Single filter +sprite.filters = [new BlurFilter(4, 4)]; + +// Multiple filters +sprite.filters = [ + new DropShadowFilter(4, 45, 0x000000, 0.5), + new GlowFilter(0xff0000, 1, 8, 8) +]; + +// Remove filters +sprite.filters = null; +``` + +## Available Filters + +| Filter | Description | +|--------|-------------| +| BlurFilter | Blur effect | +| DropShadowFilter | Drop shadow effect | +| GlowFilter | Glow effect | +| BevelFilter | Bevel effect | +| ColorMatrixFilter | Color matrix transformation | +| ConvolutionFilter | Convolution effect | +| DisplacementMapFilter | Displacement map effect | +| GradientBevelFilter | Gradient bevel effect | +| GradientGlowFilter | Gradient glow effect | + +--- + +## BlurFilter + +Applies a blur effect. You can create blurs ranging from a softly unfocused look to a Gaussian blur. + +```typescript +const { BlurFilter } = next2d.filters; + +new BlurFilter(blurX, blurY, quality); +``` + +### Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| blurX | number | 4 | The amount of horizontal blur (0-255) | +| blurY | number | 4 | The amount of vertical blur (0-255) | +| quality | number | 1 | The number of times to perform the blur (0-15) | + +--- + +## DropShadowFilter + +Applies a drop shadow effect. Style options include inner shadow, outer shadow, and knockout mode. + +```typescript +const { DropShadowFilter } = next2d.filters; + +new DropShadowFilter( + distance, angle, color, alpha, + blurX, blurY, strength, quality, + inner, knockout, hideObject +); +``` + +### Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| alpha | number | 1 | The alpha transparency value for the shadow (0-1) | +| angle | number | 45 | The angle of the shadow (-360 to 360 degrees) | +| blurX | number | 4 | The amount of horizontal blur (0-255) | +| blurY | number | 4 | The amount of vertical blur (0-255) | +| color | number | 0 | The color of the shadow (0x000000-0xFFFFFF) | +| distance | number | 4 | The offset distance for the shadow (-255 to 255 pixels) | +| hideObject | boolean | false | Indicates whether or not the object is hidden | +| inner | boolean | false | Specifies whether the shadow is an inner shadow | +| knockout | boolean | false | Specifies whether the object has a knockout effect | +| quality | number | 1 | The number of times to perform the blur (0-15) | +| strength | number | 1 | The strength of the imprint or spread (0-255) | + +--- + +## GlowFilter + +Applies a glow effect. Style options include inner glow, outer glow, and knockout mode. + +```typescript +const { GlowFilter } = next2d.filters; + +new GlowFilter( + color, alpha, blurX, blurY, + strength, quality, inner, knockout +); +``` + +### Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| alpha | number | 1 | The alpha transparency value for the glow (0-1) | +| blurX | number | 4 | The amount of horizontal blur (0-255) | +| blurY | number | 4 | The amount of vertical blur (0-255) | +| color | number | 0 | The color of the glow (0x000000-0xFFFFFF) | +| inner | boolean | false | Specifies whether the glow is an inner glow | +| knockout | boolean | false | Specifies whether the object has a knockout effect | +| quality | number | 1 | The number of times to perform the blur (0-15) | +| strength | number | 1 | The strength of the imprint or spread (0-255) | + +--- + +## BevelFilter + +Applies a bevel effect. Gives objects a three-dimensional look. + +```typescript +const { BevelFilter } = next2d.filters; + +new BevelFilter( + distance, angle, highlightColor, highlightAlpha, + shadowColor, shadowAlpha, blurX, blurY, + strength, quality, type, knockout +); +``` + +### Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| angle | number | 45 | The angle of the bevel (-360 to 360 degrees) | +| blurX | number | 4 | The amount of horizontal blur (0-255) | +| blurY | number | 4 | The amount of vertical blur (0-255) | +| distance | number | 4 | The offset distance for the bevel (-255 to 255 pixels) | +| highlightAlpha | number | 1 | The alpha transparency value for the highlight color (0-1) | +| highlightColor | number | 0xFFFFFF | The highlight color of the bevel (0x000000-0xFFFFFF) | +| knockout | boolean | false | Specifies whether the object has a knockout effect | +| quality | number | 1 | The number of times to perform the blur (0-15) | +| shadowAlpha | number | 1 | The alpha transparency value for the shadow color (0-1) | +| shadowColor | number | 0 | The shadow color of the bevel (0x000000-0xFFFFFF) | +| strength | number | 1 | The strength of the imprint or spread (0-255) | +| type | string | "inner" | The placement of the bevel ("inner", "outer", "full") | + +--- + +## ColorMatrixFilter + +Applies a 4x5 color matrix transformation. Can adjust brightness, contrast, saturation, hue, and more. + +```typescript +const { ColorMatrixFilter } = next2d.filters; + +new ColorMatrixFilter(matrix); +``` + +### Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| matrix | number[] | Identity matrix | An array of 20 items for 4x5 color transform | + +### Default Matrix Value (Identity Matrix) +```typescript +[ + 1, 0, 0, 0, 0, + 0, 1, 0, 0, 0, + 0, 0, 1, 0, 0, + 0, 0, 0, 1, 0 +] +``` + +--- + +## ConvolutionFilter + +Applies a matrix convolution filter effect. Can achieve blur, edge detection, sharpen, emboss, bevel, and more. + +```typescript +const { ConvolutionFilter } = next2d.filters; + +new ConvolutionFilter( + matrixX, matrixY, matrix, divisor, bias, + preserveAlpha, clamp, color, alpha +); +``` + +### Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| alpha | number | 0 | The alpha transparency value for out-of-bounds pixels (0-1) | +| bias | number | 0 | The amount of bias to add to the result of the matrix transformation | +| clamp | boolean | true | Indicates whether the image should be clamped | +| color | number | 0 | The hexadecimal color to substitute for out-of-bounds pixels (0x000000-0xFFFFFF) | +| divisor | number | 1 | The divisor used during matrix transformation | +| matrix | number[] \| null | null | An array of values used for matrix transformation | +| matrixX | number | 0 | The x dimension of the matrix (number of columns, 0-15) | +| matrixY | number | 0 | The y dimension of the matrix (number of rows, 0-15) | +| preserveAlpha | boolean | true | Indicates if the alpha channel is preserved without the filter effect | + +--- + +## DisplacementMapFilter + +Uses the pixel values from a BitmapData object to perform a displacement of an object. + +```typescript +const { DisplacementMapFilter } = next2d.filters; + +new DisplacementMapFilter( + bitmapBuffer, bitmapWidth, bitmapHeight, + mapPointX, mapPointY, componentX, componentY, + scaleX, scaleY, mode, color, alpha +); +``` + +### Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| alpha | number | 0 | The alpha transparency value for out-of-bounds displacements (0-1) | +| bitmapBuffer | Uint8Array \| null | null | A buffer containing the displacement map data | +| bitmapHeight | number | 0 | The height of the displacement map image | +| bitmapWidth | number | 0 | The width of the displacement map image | +| color | number | 0 | The color to use for out-of-bounds displacements (0x000000-0xFFFFFF) | +| componentX | number | 0 | The color channel to use in the map image to displace the x result | +| componentY | number | 0 | The color channel to use in the map image to displace the y result | +| mapPointX | number | 0 | The X offset of the map point | +| mapPointY | number | 0 | The Y offset of the map point | +| mode | string | "wrap" | The mode for the filter ("wrap", "clamp", "ignore", "color") | +| scaleX | number | 0 | The multiplier to scale the x displacement result (-65535 to 65535) | +| scaleY | number | 0 | The multiplier to scale the y displacement result (-65535 to 65535) | + +--- + +## GradientBevelFilter + +Applies a gradient bevel effect. A beveled edge enhanced with gradient color makes objects look three-dimensional. + +```typescript +const { GradientBevelFilter } = next2d.filters; + +new GradientBevelFilter( + distance, angle, colors, alphas, ratios, + blurX, blurY, strength, quality, type, knockout +); +``` + +### Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| alphas | number[] \| null | null | An array of alpha transparency values for the corresponding colors (each value 0-1) | +| angle | number | 45 | The angle of the bevel (-360 to 360 degrees) | +| blurX | number | 4 | The amount of horizontal blur (0-255) | +| blurY | number | 4 | The amount of vertical blur (0-255) | +| colors | number[] \| null | null | An array of RGB hexadecimal color values to use in the gradient | +| distance | number | 4 | The offset distance for the bevel (-255 to 255 pixels) | +| knockout | boolean | false | Specifies whether the object has a knockout effect | +| quality | number | 1 | The number of times to perform the blur (0-15) | +| ratios | number[] \| null | null | An array of color distribution ratios for the corresponding colors (each value 0-255) | +| strength | number | 1 | The strength of the imprint or spread (0-255) | +| type | string | "inner" | The placement of the bevel ("inner", "outer", "full") | + +--- + +## GradientGlowFilter + +Applies a gradient glow effect. A realistic-looking glow with a controllable color gradient. + +```typescript +const { GradientGlowFilter } = next2d.filters; + +new GradientGlowFilter( + distance, angle, colors, alphas, ratios, + blurX, blurY, strength, quality, type, knockout +); +``` + +### Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| alphas | number[] \| null | null | An array of alpha transparency values for the corresponding colors (each value 0-1) | +| angle | number | 45 | The angle of the glow (-360 to 360 degrees) | +| blurX | number | 4 | The amount of horizontal blur (0-255) | +| blurY | number | 4 | The amount of vertical blur (0-255) | +| colors | number[] \| null | null | An array of RGB hexadecimal color values to use in the gradient | +| distance | number | 4 | The offset distance for the glow (-255 to 255 pixels) | +| knockout | boolean | false | Specifies whether the object has a knockout effect | +| quality | number | 1 | The number of times to perform the blur (0-15) | +| ratios | number[] \| null | null | An array of color distribution ratios for the corresponding colors (each value 0-255) | +| strength | number | 1 | The strength of the imprint or spread (0-255) | +| type | string | "outer" | The placement of the glow ("inner", "outer", "full") | + +--- + +## Usage Examples + +### Button Hover Effect + +```typescript +const { Sprite } = next2d.display; +const { GlowFilter } = next2d.filters; + +const button = new Sprite(); + +button.addEventListener("rollOver", () => { + button.filters = [ + new GlowFilter(0x00ff00, 0.8, 10, 10) + ]; +}); + +button.addEventListener("rollOut", () => { + button.filters = null; +}); +``` + +### Text with Shadow + +```typescript +const { TextField } = next2d.text; +const { DropShadowFilter } = next2d.filters; + +const textField = new TextField(); +textField.text = "Hello World"; +textField.filters = [ + new DropShadowFilter(2, 45, 0x000000, 0.5, 2, 2) +]; +``` + +### Combined Filters + +```typescript +const { GlowFilter, DropShadowFilter, BlurFilter } = next2d.filters; + +sprite.filters = [ + // Outer glow + new GlowFilter(0x0088ff, 0.8, 15, 15, 2, 1, false), + // Drop shadow + new DropShadowFilter(4, 45, 0x000000, 0.6, 4, 4), + // Slight blur + new BlurFilter(1, 1, 1) +]; +``` + +### Grayscale with ColorMatrixFilter + +```typescript +const { ColorMatrixFilter } = next2d.filters; + +// Grayscale transformation matrix +const grayscaleMatrix = [ + 0.299, 0.587, 0.114, 0, 0, + 0.299, 0.587, 0.114, 0, 0, + 0.299, 0.587, 0.114, 0, 0, + 0, 0, 0, 1, 0 +]; + +sprite.filters = [new ColorMatrixFilter(grayscaleMatrix)]; +``` + +### Gradient Glow Effect + +```typescript +const { GradientGlowFilter } = next2d.filters; + +sprite.filters = [ + new GradientGlowFilter( + 4, 45, + [0xff0000, 0x00ff00, 0x0000ff], // colors + [1, 1, 1], // alphas + [0, 128, 255], // ratios + 10, 10, 2, 1, "outer", false + ) +]; +``` + +--- + +## Related + +- [DisplayObject](/en/reference/player/display-object) +- [MovieClip](/en/reference/player/movie-clip) diff --git a/specs/en/index.md b/specs/en/index.md new file mode 100644 index 00000000..47de4c77 --- /dev/null +++ b/specs/en/index.md @@ -0,0 +1,199 @@ +# Next2D Player + +Next2D Player is a high-performance 2D rendering engine using WebGL/WebGPU. It provides Flash Player-like functionality on the web, supporting vector graphics, Tween animations, text, audio, video, and more. + +## Key Features + +- **High-Speed Rendering**: Fast 2D rendering using WebGL/WebGPU +- **Multi-Platform**: Supports desktop to mobile devices +- **Flash-Compatible API**: Familiar API design derived from swf2js +- **Rich Filters**: Supports Blur, DropShadow, Glow, Bevel, and more + +## Rendering Pipeline + +An overview of the pipeline that enables Next2D Player's high-speed rendering. + +```mermaid +flowchart TB + %% Main Drawing Flow Chart + subgraph MainFlow["Drawing Flow Chart - Main Rendering Pipeline"] + direction TB + + subgraph Inputs["Display Objects"] + Shape["Shape
(Bitmap/Vector)"] + TextField["TextField
(canvas2d)"] + Video["Video Element"] + end + + Shape --> MaskCheck + TextField --> MaskCheck + Video --> MaskCheck + + MaskCheck{"mask
rendering?"} + + MaskCheck -->|YES| DirectRender["Direct Rendering"] + DirectRender -->|drawArrays| FinalRender + + MaskCheck -->|NO| CacheCheck1{"cache
exists?"} + + CacheCheck1 -->|NO| TextureAtlas["Texture Atlas
(Binary Tree Packing)"] + TextureAtlas --> Coordinates + + CacheCheck1 -->|YES| Coordinates["Coordinates DB
(x, y, w, h)"] + + Coordinates --> FilterBlendCheck{"filter or
blend?"} + + FilterBlendCheck -->|NO| MainArrays + FilterBlendCheck -->|YES| NeedCache{"cache
exists?"} + + NeedCache -->|NO| CacheRender["Render to Cache"] + CacheRender --> TextureCache + NeedCache -->|YES| TextureCache["Texture Cache"] + + TextureCache -->|drawArrays| FinalRender + + MainArrays["Instanced Arrays
━━━━━━━━━━━━━━━
matrix
colorTransform
Coordinates
━━━━━━━━━━━━━━━
Batch Rendering"] + + MainArrays -->|drawArraysInstanced
Multiple objects in one call| FinalRender["Final Rendering"] + + FinalRender -->|60fps| MainFramebuffer["Main Framebuffer
(Display)"] + end + + %% Branch Flow for Filter/Blend/Mask + subgraph BranchFlow["Filter/Blend/Mask - Branch Processing"] + direction TB + + subgraph FilterInputs["Display Objects"] + Shape2["Shape
(Bitmap/Vector)"] + TextField2["TextField
(canvas2d)"] + Video2["Video Element"] + end + + Shape2 --> CacheCheck2 + TextField2 --> CacheCheck2 + Video2 --> CacheCheck2 + + CacheCheck2{"cache
exists?"} + + CacheCheck2 -->|NO| EffectRender["Effect Rendering"] + CacheCheck2 -->|YES| BranchArrays + EffectRender --> BranchArrays + + BranchArrays["Instanced Arrays
━━━━━━━━━━━━━━━
matrix
colorTransform
Coordinates
━━━━━━━━━━━━━━━
Batch Rendering"] + + BranchArrays -->|drawArraysInstanced
Multiple objects in one call| BranchRender["Effect Result"] + + BranchRender -->|filter/blend| TextureCache + end + + %% Connections between flows + FilterBlendCheck -.->|"trigger
branch flow"| BranchFlow + BranchArrays -.->|"rendering info
(coordinates)"| MainArrays +``` + +### Pipeline Features + +- **Batch Rendering**: Render multiple objects in a single GPU call +- **Texture Cache**: Efficiently process filters and blend effects +- **Binary Tree Packing**: Optimal memory usage with texture atlas +- **60fps Rendering**: Smooth animations at high frame rates + +## DisplayList Architecture + +Next2D Player uses a DisplayList architecture similar to Flash Player. + +### Main Class Hierarchy + +``` +DisplayObject (Base class) +├── InteractiveObject +│ ├── DisplayObjectContainer +│ │ ├── Sprite +│ │ ├── MovieClip +│ │ └── Stage +│ └── TextField +├── Shape +├── Video +└── Bitmap +``` + +### DisplayObjectContainer + +Container class that can hold child objects: + +- `addChild(child)`: Add child to the front +- `addChildAt(child, index)`: Add child at specified index +- `removeChild(child)`: Remove child +- `getChildAt(index)`: Get child by index +- `getChildByName(name)`: Get child by name + +### MovieClip + +DisplayObject with timeline animation: + +- `play()`: Start timeline playback +- `stop()`: Stop timeline +- `gotoAndPlay(frame)`: Go to frame and play +- `gotoAndStop(frame)`: Go to frame and stop +- `currentFrame`: Current frame number +- `totalFrames`: Total number of frames + +## Basic Usage + +```javascript +const { MovieClip } = next2d.display; +const { DropShadowFilter } = next2d.filters; + +// Initialize stage +const root = await next2d.createRootMovieClip(800, 600, 60, { + tagId: "container", + bgColor: "#ffffff" +}); + +// Create MovieClip +const mc = new MovieClip(); +root.addChild(mc); + +// Set position and size +mc.x = 100; +mc.y = 100; +mc.scaleX = 2; +mc.scaleY = 2; +mc.rotation = 45; + +// Apply filters +mc.filters = [ + new DropShadowFilter(4, 45, 0x000000, 0.5) +]; +``` + +## Loading JSON Data + +Load and render JSON files created with Open Animation Tool: + +```javascript +const { Loader } = next2d.display; +const { URLRequest } = next2d.net; + +const loader = new Loader(); +await loader.load(new URLRequest("animation.json")); + +const mc = loader.content; +stage.addChild(mc); +``` + +## Related Documentation + +### Display Objects +- [DisplayObject](/en/reference/player/display-object) - Base class for all display objects +- [MovieClip](/en/reference/player/movie-clip) - Timeline animation +- [Sprite](/en/reference/player/sprite) - Graphics drawing and interaction +- [Shape](/en/reference/player/shape) - Lightweight vector drawing +- [TextField](/en/reference/player/text-field) - Text display and input +- [Video](/en/reference/player/video) - Video playback + +### Systems +- [Event System](/en/reference/player/events) - Mouse, keyboard, touch events +- [Filters](/en/reference/player/filters) - Blur, DropShadow, Glow, etc. +- [Sound](/en/reference/player/sound) - Audio playback and sound effects +- [Tween Animation](/en/reference/player/tween) - Programmatic animation diff --git a/specs/en/movie-clip.md b/specs/en/movie-clip.md new file mode 100644 index 00000000..a02e0c6f --- /dev/null +++ b/specs/en/movie-clip.md @@ -0,0 +1,244 @@ +# MovieClip + +MovieClip is a DisplayObjectContainer with timeline animation. Animations created with Open Animation Tool are played as MovieClips. + +## Inheritance + +```mermaid +classDiagram + DisplayObject <|-- InteractiveObject + InteractiveObject <|-- DisplayObjectContainer + DisplayObjectContainer <|-- Sprite + Sprite <|-- MovieClip + + class DisplayObject { + +x: Number + +y: Number + +visible: Boolean + } + class MovieClip { + +currentFrame: Number + +totalFrames: Number + +play() + +stop() + +gotoAndPlay() + } +``` + +## Properties + +### MovieClip-Specific Properties + +| Property | Type | Description | +|----------|------|-------------| +| `currentFrame` | `number` | Specifies the number of the frame in which the playhead is located in the timeline (starts from 1, read-only) | +| `totalFrames` | `number` | The total number of frames in the MovieClip instance (read-only) | +| `currentFrameLabel` | `FrameLabel \| null` | The label at the current frame in the timeline of the MovieClip instance (read-only) | +| `currentLabels` | `FrameLabel[] \| null` | Returns an array of FrameLabel objects from the current scene (read-only) | +| `isPlaying` | `boolean` | A Boolean value that indicates whether a movie clip is currently playing (read-only) | +| `isTimelineEnabled` | `boolean` | Returns whether the display object has MovieClip functionality (read-only) | + +### Properties Inherited from DisplayObjectContainer + +| Property | Type | Description | +|----------|------|-------------| +| `numChildren` | `number` | Returns the number of children of this object (read-only) | +| `mouseChildren` | `boolean` | Determines whether the children of the object are mouse or user input device enabled | +| `mask` | `DisplayObject \| null` | The calling display object is masked by the specified mask object | +| `isContainerEnabled` | `boolean` | Returns whether the display object has container functionality (read-only) | + +## Methods + +### MovieClip-Specific Methods + +| Method | Return Type | Description | +|--------|-------------|-------------| +| `play()` | `void` | Moves the playhead in the timeline of the movie clip | +| `stop()` | `void` | Stops the playhead in the movie clip | +| `gotoAndPlay(frame: string \| number)` | `void` | Starts playing the file at the specified frame | +| `gotoAndStop(frame: string \| number)` | `void` | Brings the playhead to the specified frame and stops it there | +| `nextFrame()` | `void` | Sends the playhead to the next frame and stops it | +| `prevFrame()` | `void` | Sends the playhead to the previous frame and stops it | +| `addFrameLabel(frame_label: FrameLabel)` | `void` | Dynamically adds a label to the timeline | + +### Methods Inherited from DisplayObjectContainer + +| Method | Return Type | Description | +|--------|-------------|-------------| +| `addChild(display_object: DisplayObject)` | `DisplayObject` | Adds a child DisplayObject instance to this DisplayObjectContainer instance | +| `addChildAt(display_object: DisplayObject, index: number)` | `DisplayObject` | Adds a child DisplayObject instance at the specified index position | +| `removeChild(display_object: DisplayObject)` | `void` | Removes the specified child DisplayObject instance from the child list | +| `removeChildAt(index: number)` | `void` | Removes a child DisplayObject from the specified index position in the child list | +| `removeChildren(...indexes: number[])` | `void` | Removes children at the specified indexes from the container | +| `getChildAt(index: number)` | `DisplayObject \| null` | Returns the child display object instance that exists at the specified index | +| `getChildByName(name: string)` | `DisplayObject \| null` | Returns the child display object that exists with the specified name | +| `getChildIndex(display_object: DisplayObject)` | `number` | Returns the index position of a child DisplayObject instance | +| `contains(display_object: DisplayObject)` | `boolean` | Determines whether the specified display object is a child of the DisplayObjectContainer instance or the instance itself | +| `setChildIndex(display_object: DisplayObject, index: number)` | `void` | Changes the position of an existing child in the display object container | +| `swapChildren(display_object1: DisplayObject, display_object2: DisplayObject)` | `void` | Swaps the z-order (front-to-back order) of the two specified child objects | +| `swapChildrenAt(index1: number, index2: number)` | `void` | Swaps the z-order (front-to-back order) of the child objects at the two specified index positions | + +## Events + +### enterFrame + +Event that occurs each frame: + +```javascript +movieClip.addEventListener("enterFrame", function(event) { + console.log("Frame:", event.target.currentFrame); +}); +``` + +### frameConstructed + +Occurs when frame construction is complete: + +```javascript +movieClip.addEventListener("frameConstructed", function(event) { + // Before frame script execution +}); +``` + +### exitFrame + +Occurs when leaving a frame: + +```javascript +movieClip.addEventListener("exitFrame", function(event) { + // Before moving to next frame +}); +``` + +## Usage Examples + +### Basic Animation Control + +```javascript +const { Loader } = next2d.display; +const { URLRequest } = next2d.net; + +// Load MovieClip from JSON +const loader = new Loader(); +await loader.load(new URLRequest("animation.json")); + +const mc = loader.content; +stage.addChild(mc); + +// Stop initially +mc.stop(); + +// Play/pause on button click +button.addEventListener("click", function() { + if (mc.isPlaying) { + mc.stop(); + } else { + mc.play(); + } +}); +``` + +### Control with Frame Labels + +```javascript +// Move to label position +mc.gotoAndStop("idle"); + +// State change +function changeState(state) { + switch (state) { + case "idle": + mc.gotoAndPlay("idle"); + break; + case "walk": + mc.gotoAndPlay("walk_start"); + break; + case "attack": + mc.gotoAndPlay("attack"); + break; + } +} +``` + +### Controlling Nested MovieClips + +```javascript +// Access child MovieClip +const childMc = mc.getChildByName("character"); +childMc.gotoAndPlay("run"); + +// Access grandchild MovieClip +const grandChild = mc.character.arm; +grandChild.play(); +``` + +### Child Object Operations + +```javascript +// Add child object +const sprite = new Sprite(); +mc.addChild(sprite); + +// Add at specific index +mc.addChildAt(sprite, 0); + +// Remove child object +mc.removeChild(sprite); + +// Remove by index +mc.removeChildAt(0); + +// Remove multiple children +mc.removeChildren(0, 1, 2); + +// Get child object +const child = mc.getChildAt(0); +const namedChild = mc.getChildByName("myChild"); + +// Get child index +const index = mc.getChildIndex(sprite); + +// Change child index +mc.setChildIndex(sprite, 2); + +// Swap child order +mc.swapChildren(sprite1, sprite2); +mc.swapChildrenAt(0, 1); +``` + +### Dynamically Adding Frame Labels + +```javascript +const { FrameLabel } = next2d.display; + +// Create and add a new label +const label = new FrameLabel("myLabel", 10); +mc.addFrameLabel(label); + +// Navigate using the label +mc.gotoAndPlay("myLabel"); +``` + +### Changing Frame Rate + +```javascript +// Change stage frame rate +stage.frameRate = 30; +``` + +## FrameLabel + +A class that holds frame label information: + +```javascript +// Get all labels in current scene +const labels = mc.currentLabels; +labels.forEach(function(label) { + console.log(label.name + ": frame " + label.frame); +}); +``` + +## Related + +- [Sprite](/en/reference/player/sprite) +- [Event System](/en/reference/player/events) diff --git a/specs/en/shape.md b/specs/en/shape.md new file mode 100644 index 00000000..e644205f --- /dev/null +++ b/specs/en/shape.md @@ -0,0 +1,442 @@ +# Shape + +Shape is a class dedicated to vector graphics drawing. Unlike Sprite, it cannot hold child objects, but it is lightweight and offers better performance. + +## Inheritance + +```mermaid +classDiagram + DisplayObject <|-- Shape + + class Shape { + +graphics: Graphics + } +``` + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `graphics` | Graphics | The Graphics object that belongs to this Shape object, where vector drawing commands can occur (read-only) | +| `isShape` | boolean | Returns whether the display object has Shape functionality (read-only) | +| `cacheKey` | number | Built cache key | +| `cacheParams` | number[] | Parameters used to build the cache (read-only) | +| `isBitmap` | boolean | Bitmap drawing judgment flag | +| `src` | string | Reads images from the specified path and generates Graphics | +| `bitmapData` | BitmapData | Returns the bitmap data (read-only) | +| `namespace` | string | Returns the space name of the specified object (read-only) | + +## Methods + +| Method | Return Type | Description | +|--------|-------------|-------------| +| `load(url: string)` | Promise\ | Asynchronously loads images from the specified URL and generates Graphics | +| `clearBitmapBuffer()` | void | Releases bitmap data | +| `setBitmapBuffer(width: number, height: number, buffer: Uint8Array)` | void | Sets the RGBA image data | + +## Difference Between Sprite and Shape + +| Feature | Shape | Sprite | +|---------|-------|--------| +| Child objects | Cannot hold | Can hold | +| Interaction | None | Click etc. possible | +| Performance | Lightweight | Slightly heavier | +| Use case | Static backgrounds, decorations | Buttons, containers | + +## Usage Examples + +### Basic Drawing + +```javascript +const { Shape } = next2d.display; + +const shape = new Shape(); + +// Filled rectangle +shape.graphics.beginFill(0x3498db); +shape.graphics.drawRect(0, 0, 150, 100); +shape.graphics.endFill(); + +stage.addChild(shape); +``` + +### Compound Shape Drawing + +```javascript +const { Shape } = next2d.display; + +const shape = new Shape(); +const g = shape.graphics; + +// Background +g.beginFill(0xecf0f1); +g.drawRoundRect(0, 0, 200, 150, 10, 10); +g.endFill(); + +// Border +g.lineStyle(2, 0x2c3e50); +g.drawRoundRect(0, 0, 200, 150, 10, 10); + +// Inner decoration +g.beginFill(0xe74c3c); +g.drawCircle(100, 75, 30); +g.endFill(); + +stage.addChild(shape); +``` + +### Path Drawing + +```javascript +const { Shape } = next2d.display; + +const shape = new Shape(); +const g = shape.graphics; + +g.beginFill(0x9b59b6); + +// Draw star shape +g.moveTo(50, 0); +g.lineTo(61, 35); +g.lineTo(98, 35); +g.lineTo(68, 57); +g.lineTo(79, 91); +g.lineTo(50, 70); +g.lineTo(21, 91); +g.lineTo(32, 57); +g.lineTo(2, 35); +g.lineTo(39, 35); +g.lineTo(50, 0); + +g.endFill(); + +stage.addChild(shape); +``` + +### Bezier Curves + +```javascript +const { Shape } = next2d.display; + +const shape = new Shape(); +const g = shape.graphics; + +g.lineStyle(3, 0x1abc9c); + +// Quadratic bezier curve +g.moveTo(0, 100); +g.curveTo(50, 0, 100, 100); // control point, end point + +g.curveTo(150, 200, 200, 100); + +stage.addChild(shape); +``` + +### Gradient Background + +```javascript +const { Shape } = next2d.display; +const { Matrix } = next2d.geom; + +const shape = new Shape(); +const g = shape.graphics; + +// Matrix for gradient +const matrix = new Matrix(); +matrix.createGradientBox( + stage.stageWidth, + stage.stageHeight, + Math.PI / 2, // 90 degrees (vertical) + 0, 0 +); + +// Radial gradient +g.beginGradientFill( + "radial", + [0x667eea, 0x764ba2], + [1, 1], + [0, 255], + matrix +); +g.drawRect(0, 0, stage.stageWidth, stage.stageHeight); +g.endFill(); + +// Place at back +stage.addChildAt(shape, 0); +``` + +### Dynamic Redrawing + +```javascript +const { Shape } = next2d.display; + +const shape = new Shape(); +stage.addChild(shape); + +let angle = 0; + +// Redraw each frame +stage.addEventListener("enterFrame", function() { + const g = shape.graphics; + + // Clear previous drawing + g.clear(); + + // Draw at new position + const x = 200 + Math.cos(angle) * 100; + const y = 150 + Math.sin(angle) * 100; + + g.beginFill(0xe74c3c); + g.drawCircle(x, y, 20); + g.endFill(); + + angle += 0.05; +}); +``` + +### Composed of Multiple Shapes + +```javascript +const { Shape } = next2d.display; + +// Background layer +const bgShape = new Shape(); +bgShape.graphics.beginFill(0x2c3e50); +bgShape.graphics.drawRect(0, 0, 400, 300); +bgShape.graphics.endFill(); + +// Decoration layer +const decorShape = new Shape(); +decorShape.graphics.beginFill(0x3498db, 0.5); +decorShape.graphics.drawCircle(100, 100, 80); +decorShape.graphics.drawCircle(300, 200, 60); +decorShape.graphics.endFill(); + +// Front layer +const frontShape = new Shape(); +frontShape.graphics.lineStyle(2, 0xecf0f1); +frontShape.graphics.drawRect(50, 50, 300, 200); + +stage.addChild(bgShape); +stage.addChild(decorShape); +stage.addChild(frontShape); +``` + +## Performance Tips + +1. **Use Shape for static drawing**: Shape is optimal for backgrounds and decorations that don't need interaction +2. **Minimize drawing**: Only draw once if content doesn't change frequently +3. **Use clear()**: Always call clear() when dynamically redrawing +4. **Cache complex shapes**: Cache drawing with cacheAsBitmap property + +```javascript +// Cache complex shapes as bitmap +shape.cacheAsBitmap = true; +``` + +## Graphics Class + +The Graphics class provides a drawing API for rendering vector graphics. Access it through the Shape.graphics property. + +### Fill Methods + +| Method | Description | +|--------|-------------| +| `beginFill(color: number, alpha?: number)` | Starts a solid color fill. Alpha defaults to 1 | +| `beginGradientFill(type, colors, alphas, ratios, matrix?, spreadMethod?, interpolationMethod?, focalPointRatio?)` | Starts a gradient fill | +| `beginBitmapFill(bitmapData, matrix?, repeat?, smooth?)` | Starts a bitmap fill | +| `endFill()` | Ends the current fill | + +#### beginGradientFill Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `type` | string | "linear" or "radial" | +| `colors` | number[] | Array of colors (hexadecimal) | +| `alphas` | number[] | Alpha value for each color (0-1) | +| `ratios` | number[] | Position of each color (0-255) | +| `matrix` | Matrix | Transformation matrix for the gradient | +| `spreadMethod` | string | "pad", "reflect", "repeat" (default: "pad") | +| `interpolationMethod` | string | "rgb" or "linearRGB" (default: "rgb") | +| `focalPointRatio` | number | Focal point position for radial gradients (-1 to 1) | + +### Line Style Methods + +| Method | Description | +|--------|-------------| +| `lineStyle(thickness?, color?, alpha?, pixelHinting?, scaleMode?, caps?, joints?, miterLimit?)` | Sets the line style | +| `lineGradientStyle(type, colors, alphas, ratios, matrix?, spreadMethod?, interpolationMethod?, focalPointRatio?)` | Sets a gradient line style | +| `lineBitmapStyle(bitmapData, matrix?, repeat?, smooth?)` | Sets a bitmap line style | +| `endLine()` | Ends the line style | + +#### lineStyle Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `thickness` | number | 0 | Line thickness in pixels | +| `color` | number | 0 | Line color (hexadecimal) | +| `alpha` | number | 1 | Alpha transparency (0-1) | +| `pixelHinting` | boolean | false | Pixel snapping | +| `scaleMode` | string | "normal" | "normal", "none", "vertical", "horizontal" | +| `caps` | string | null | "none", "round", "square" | +| `joints` | string | null | "bevel", "miter", "round" | +| `miterLimit` | number | 3 | Miter joint limit | + +### Path Methods + +| Method | Description | +|--------|-------------| +| `moveTo(x: number, y: number)` | Moves the drawing position | +| `lineTo(x: number, y: number)` | Draws a line from current position to specified coordinates | +| `curveTo(controlX, controlY, anchorX, anchorY)` | Draws a quadratic Bezier curve | +| `cubicCurveTo(controlX1, controlY1, controlX2, controlY2, anchorX, anchorY)` | Draws a cubic Bezier curve | + +### Shape Methods + +| Method | Description | +|--------|-------------| +| `drawRect(x, y, width, height)` | Draws a rectangle | +| `drawRoundRect(x, y, width, height, ellipseWidth, ellipseHeight?)` | Draws a rounded rectangle | +| `drawCircle(x, y, radius)` | Draws a circle | +| `drawEllipse(x, y, width, height)` | Draws an ellipse | + +### Utility Methods + +| Method | Description | +|--------|-------------| +| `clear()` | Clears all drawing commands | +| `clone()` | Clones the Graphics object | +| `copyFrom(source: Graphics)` | Copies drawing commands from another Graphics | + +### Detailed Usage Examples + +#### Linear Gradient + +```javascript +const { Shape } = next2d.display; +const { Matrix } = next2d.geom; + +const shape = new Shape(); +const g = shape.graphics; + +const matrix = new Matrix(); +matrix.createGradientBox(200, 100, 0, 0, 0); // width, height, rotation, x, y + +g.beginGradientFill( + "linear", // type + [0xff0000, 0x00ff00, 0x0000ff], // colors + [1, 1, 1], // alphas + [0, 127, 255], // ratios + matrix +); +g.drawRect(0, 0, 200, 100); +g.endFill(); + +stage.addChild(shape); +``` + +#### Cubic Bezier Curve + +```javascript +const { Shape } = next2d.display; + +const shape = new Shape(); +const g = shape.graphics; + +g.lineStyle(2, 0x3498db); + +// Smooth S-curve +g.moveTo(0, 100); +g.cubicCurveTo( + 50, 0, // control point 1 + 150, 200, // control point 2 + 200, 100 // anchor point +); + +stage.addChild(shape); +``` + +#### Bitmap Fill + +```javascript +const { Shape, Loader } = next2d.display; + +const loader = new Loader(); +await loader.load("texture.png"); + +const bitmapData = loader.contentLoaderInfo + .content.bitmapData; + +const shape = new Shape(); +const g = shape.graphics; + +g.beginBitmapFill(bitmapData, null, true, true); +g.drawRect(0, 0, 400, 300); +g.endFill(); + +stage.addChild(shape); +``` + +#### Gradient Line + +```javascript +const { Shape } = next2d.display; +const { Matrix } = next2d.geom; + +const shape = new Shape(); +const g = shape.graphics; + +const matrix = new Matrix(); +matrix.createGradientBox(200, 200, 0, 0, 0); + +g.lineGradientStyle( + "linear", + [0xff0000, 0x0000ff], + [1, 1], + [0, 255], + matrix +); +g.lineStyle(5); + +g.moveTo(10, 10); +g.lineTo(190, 10); +g.lineTo(190, 190); +g.lineTo(10, 190); +g.lineTo(10, 10); + +stage.addChild(shape); +``` + +#### Complex Shape Composition + +```javascript +const { Shape } = next2d.display; + +const shape = new Shape(); +const g = shape.graphics; + +// Outer rectangle (filled) +g.beginFill(0x2c3e50); +g.drawRoundRect(0, 0, 200, 150, 15, 15); +g.endFill(); + +// Inner circle (different color fill) +g.beginFill(0xe74c3c); +g.drawCircle(100, 75, 40); +g.endFill(); + +// Decorative lines +g.lineStyle(2, 0xecf0f1); +g.moveTo(20, 20); +g.lineTo(180, 20); +g.moveTo(20, 130); +g.lineTo(180, 130); + +stage.addChild(shape); +``` + +## Related + +- [DisplayObject](/en/reference/player/display-object) +- [Sprite](/en/reference/player/sprite) +- [Filters](/en/reference/player/filters) diff --git a/specs/en/sound.md b/specs/en/sound.md new file mode 100644 index 00000000..b025598d --- /dev/null +++ b/specs/en/sound.md @@ -0,0 +1,281 @@ +# Sound + +Next2D Player provides audio functionality for games and applications, supporting BGM, sound effects, voice, and more. + +## Class Structure + +```mermaid +classDiagram + EventDispatcher <|-- Sound + class Sound { + +audioBuffer: AudioBuffer + +volume: number + +loopCount: number + +canLoop: boolean + +load(request): Promise + +play(startTime): void + +stop(): void + +clone(): Sound + } + class SoundMixer { + +volume: Number + +stopAll(): void + } +``` + +## Sound + +A class for loading and playing audio files. Extends EventDispatcher. + +### Properties + +| Property | Type | Default | Read-only | Description | +|----------|------|---------|:---------:|-------------| +| `audioBuffer` | AudioBuffer \| null | null | - | Audio buffer. Stores audio data loaded by load() | +| `loopCount` | number | 0 | - | Loop count setting. 0 for no loop, 9999 for virtually infinite loop | +| `volume` | number | 1 | - | Volume, ranging from 0 (silent) to 1 (full volume). Cannot exceed SoundMixer.volume value | +| `canLoop` | boolean | - | Yes | Indicates whether the sound loops | + +### Methods + +| Method | Return | Description | +|--------|--------|-------------| +| `clone()` | Sound | Duplicates the Sound class. Copies volume, loopCount, and audioBuffer | +| `load(request: URLRequest)` | Promise\ | Initiates loading of an external MP3 file from the specified URL | +| `play(startTime: number = 0)` | void | Plays a sound. startTime is the playback start time (in seconds). Does nothing if already playing | +| `stop()` | void | Stops the sound playing in the channel | + +## Usage Examples + +### Basic Audio Playback + +```javascript +const { Sound } = next2d.media; +const { URLRequest } = next2d.net; + +// Create Sound object +const sound = new Sound(); + +// Load audio file +await sound.load(new URLRequest("bgm.mp3")); + +// Start playback +sound.play(); +``` + +### Sound Effect Playback + +```javascript +const { Sound } = next2d.media; +const { URLRequest } = next2d.net; + +// Preload sound effects +const seJump = new Sound(); +const seHit = new Sound(); +const seCoin = new Sound(); + +// Load +await seJump.load(new URLRequest("se/jump.mp3")); +await seHit.load(new URLRequest("se/hit.mp3")); +await seCoin.load(new URLRequest("se/coin.mp3")); + +// Play function +function playSE(sound) { + sound.play(); +} + +// Use in game +player.addEventListener("jump", function() { + playSE(seJump); +}); +``` + +### BGM Loop Playback + +```javascript +const { Sound } = next2d.media; +const { URLRequest } = next2d.net; + +const bgm = new Sound(); + +await bgm.load(new URLRequest("bgm/stage1.mp3")); + +// Set volume and loop count +bgm.volume = 0.7; // 70% +bgm.loopCount = 9999; // Infinite loop + +bgm.play(); + +// Stop BGM +function stopBGM() { + bgm.stop(); +} +``` + +### Volume Control + +```javascript +const { Sound } = next2d.media; +const { URLRequest } = next2d.net; + +const bgm = new Sound(); +await bgm.load(new URLRequest("bgm.mp3")); +bgm.volume = 1.0; +bgm.loopCount = 9999; +bgm.play(); + +// Change volume +function setVolume(volume) { + bgm.volume = Math.max(0, Math.min(1, volume)); +} + +// Fade out +function fadeOut(duration) { + duration = duration || 1000; + const startVolume = bgm.volume; + const startTime = Date.now(); + + stage.addEventListener("enterFrame", function fade() { + const elapsed = Date.now() - startTime; + const progress = Math.min(1, elapsed / duration); + + setVolume(startVolume * (1 - progress)); + + if (progress >= 1) { + stage.removeEventListener("enterFrame", fade); + bgm.stop(); + } + }); +} +``` + +### Sound Manager + +```javascript +const { Sound } = next2d.media; +const { URLRequest } = next2d.net; + +class SoundManager { + constructor() { + this._sounds = new Map(); + this._bgm = null; + this._bgmVolume = 0.7; + this._seVolume = 1.0; + this._isMuted = false; + } + + // Preload sound + async preload(id, url) { + const sound = new Sound(); + await sound.load(new URLRequest(url)); + this._sounds.set(id, sound); + } + + // Play BGM + playBGM(id, loops) { + loops = loops || 9999; + this.stopBGM(); + + const sound = this._sounds.get(id); + if (sound) { + sound.volume = this._isMuted ? 0 : this._bgmVolume; + sound.loopCount = loops; + sound.play(); + this._bgm = sound; + } + } + + // Stop BGM + stopBGM() { + if (this._bgm) { + this._bgm.stop(); + this._bgm = null; + } + } + + // Play SE + playSE(id) { + const sound = this._sounds.get(id); + if (sound) { + sound.volume = this._isMuted ? 0 : this._seVolume; + sound.loopCount = 0; + sound.play(); + } + } + + // Toggle mute + toggleMute() { + this._isMuted = !this._isMuted; + this._updateVolumes(); + return this._isMuted; + } + + // Set BGM volume + setBGMVolume(volume) { + this._bgmVolume = Math.max(0, Math.min(1, volume)); + this._updateVolumes(); + } + + // Set SE volume + setSEVolume(volume) { + this._seVolume = Math.max(0, Math.min(1, volume)); + } + + _updateVolumes() { + if (this._bgm) { + this._bgm.volume = this._isMuted ? 0 : this._bgmVolume; + } + } +} + +// Usage example +const soundManager = new SoundManager(); + +// Preload on startup +async function initSounds() { + await soundManager.preload("bgm_title", "bgm/title.mp3"); + await soundManager.preload("bgm_stage1", "bgm/stage1.mp3"); + await soundManager.preload("se_jump", "se/jump.mp3"); + await soundManager.preload("se_coin", "se/coin.mp3"); + await soundManager.preload("se_damage", "se/damage.mp3"); +} + +// During game +soundManager.playBGM("bgm_stage1"); +soundManager.playSE("se_jump"); +``` + +## SoundMixer + +A class for controlling all audio. + +```javascript +const { SoundMixer } = next2d.media; + +// Stop all audio +SoundMixer.stopAll(); + +// Change global volume +SoundMixer.volume = 0.5; +``` + +## Supported Formats + +| Format | Extension | Support | +|--------|-----------|---------| +| MP3 | .mp3 | Recommended | +| AAC | .m4a, .aac | Supported | +| Ogg Vorbis | .ogg | Browser dependent | +| WAV | .wav | Supported (large file size) | + +## Best Practices + +1. **Preload**: Preload all audio before game starts +2. **Format**: MP3 recommended (balance of compatibility and compression) +3. **Sound Effects**: Short sounds can use WAV (lower latency) +4. **Volume Management**: Manage BGM and SE volumes separately +5. **Mobile Support**: Start playback after user interaction + +## Related + +- [Event System](/en/reference/player/events) diff --git a/specs/en/sprite.md b/specs/en/sprite.md new file mode 100644 index 00000000..a9484175 --- /dev/null +++ b/specs/en/sprite.md @@ -0,0 +1,238 @@ +# Sprite + +Sprite is a DisplayObjectContainer. It is the base class of MovieClip and is used for dynamic object management without a timeline. + +## Inheritance + +```mermaid +classDiagram + DisplayObject <|-- InteractiveObject + InteractiveObject <|-- DisplayObjectContainer + DisplayObjectContainer <|-- Sprite + Sprite <|-- MovieClip + + class Sprite { + +buttonMode: Boolean + +useHandCursor: Boolean + } +``` + +## Properties + +### Sprite-specific Properties + +| Property | Type | Read-only | Default | Description | +|----------|------|:---------:|---------|-------------| +| `isSprite` | boolean | Yes | true | Returns whether Sprite functions are possessed | +| `buttonMode` | boolean | No | false | Specifies the button mode of this sprite | +| `useHandCursor` | boolean | No | true | Whether to display hand cursor when buttonMode is true | +| `hitArea` | Sprite \| null | No | null | Designates another sprite to serve as the hit area for this sprite | +| `soundTransform` | SoundTransform \| null | No | null | Controls sound within this sprite | + +### Properties Inherited from DisplayObjectContainer + +| Property | Type | Read-only | Default | Description | +|----------|------|:---------:|---------|-------------| +| `isContainerEnabled` | boolean | Yes | true | Returns whether the display object has container functionality | +| `mouseChildren` | boolean | No | true | Determines whether the object's children are compatible with mouse or user input devices | +| `numChildren` | number | Yes | - | Returns the number of children of this object | +| `mask` | DisplayObject \| null | No | null | Masks the display object | + +### Properties Inherited from InteractiveObject + +| Property | Type | Read-only | Default | Description | +|----------|------|:---------:|---------|-------------| +| `isInteractive` | boolean | Yes | true | Returns whether InteractiveObject functions are possessed | +| `mouseEnabled` | boolean | No | true | Specifies whether this object receives mouse or other user input messages | + +### Properties Inherited from DisplayObject + +| Property | Type | Read-only | Default | Description | +|----------|------|:---------:|---------|-------------| +| `instanceId` | number | Yes | - | Unique instance ID of DisplayObject | +| `name` | string | No | "" | Returns the name. Used by getChildByName() | +| `parent` | Sprite \| MovieClip \| null | No | null | Returns the DisplayObjectContainer of this DisplayObject's parent | +| `x` | number | No | 0 | x coordinate relative to the local coordinates of the parent DisplayObjectContainer | +| `y` | number | No | 0 | y coordinate relative to the local coordinates of the parent DisplayObjectContainer | +| `width` | number | No | - | Width of the display object in pixels | +| `height` | number | No | - | Height of the display object in pixels | +| `scaleX` | number | No | 1 | Horizontal scale value of the object applied from the reference point | +| `scaleY` | number | No | 1 | Vertical scale value of the object applied from the reference point | +| `rotation` | number | No | 0 | Rotation of the DisplayObject instance in degrees from its original orientation | +| `alpha` | number | No | 1 | Alpha transparency value of the object (0.0 to 1.0) | +| `visible` | boolean | No | true | Whether the display object is visible | +| `blendMode` | string | No | "normal" | A value from the BlendMode class that specifies which blend mode to use | +| `filters` | array \| null | No | null | An array of filter objects currently associated with the display object | +| `matrix` | Matrix | No | - | Returns the Matrix of the display object | +| `colorTransform` | ColorTransform | No | - | Returns the ColorTransform of the display object | +| `concatenatedMatrix` | Matrix | Yes | - | Combined Matrix of this display object and all parent objects | +| `scale9Grid` | Rectangle \| null | No | null | The current scaling grid that is in effect | +| `loaderInfo` | LoaderInfo \| null | Yes | null | Loading information for the file to which this display object belongs | +| `root` | MovieClip \| Sprite \| null | Yes | null | The DisplayObjectContainer that is the root of the DisplayObject | +| `mouseX` | number | Yes | - | x-axis position in pixels relative to the reference point of the DisplayObject | +| `mouseY` | number | Yes | - | y-axis position in pixels relative to the reference point of the DisplayObject | +| `dropTarget` | Sprite \| null | Yes | null | The display object over which the sprite is being dragged or dropped | +| `isMask` | boolean | No | false | Indicates whether the DisplayObject is set as a mask | + +## Methods + +### Sprite-specific Methods + +| Method | Return Type | Description | +|--------|-------------|-------------| +| `startDrag(lockCenter?: boolean, bounds?: Rectangle)` | void | Lets the user drag the specified sprite | +| `stopDrag()` | void | Ends the startDrag() method | + +### Methods Inherited from DisplayObjectContainer + +| Method | Return Type | Description | +|--------|-------------|-------------| +| `addChild(child: DisplayObject)` | DisplayObject | Adds a child DisplayObject instance | +| `addChildAt(child: DisplayObject, index: number)` | DisplayObject | Adds a child DisplayObject instance at the specified index position | +| `removeChild(child: DisplayObject)` | void | Removes the specified child DisplayObject instance | +| `removeChildAt(index: number)` | void | Removes a child DisplayObject from the specified index position | +| `removeChildren(...indexes: number[])` | void | Removes children at the indexes specified in the array from the container | +| `getChildAt(index: number)` | DisplayObject \| null | Returns the child display object instance at the specified index position | +| `getChildByName(name: string)` | DisplayObject \| null | Returns the child display object that exists with the specified name | +| `getChildIndex(child: DisplayObject)` | number | Returns the index position of a child DisplayObject instance | +| `setChildIndex(child: DisplayObject, index: number)` | void | Changes the position of an existing child in the display object container | +| `contains(child: DisplayObject)` | boolean | Whether the specified DisplayObject is a descendant of the instance | +| `swapChildren(child1: DisplayObject, child2: DisplayObject)` | void | Swaps the z-order of the two specified child objects | +| `swapChildrenAt(index1: number, index2: number)` | void | Swaps the z-order of the child objects at the two specified index positions | + +### Methods Inherited from DisplayObject + +| Method | Return Type | Description | +|--------|-------------|-------------| +| `getBounds(targetDisplayObject?: DisplayObject)` | Rectangle | Returns a rectangle that defines the area of the display object relative to the coordinate system of the targetDisplayObject | +| `globalToLocal(point: Point)` | Point | Converts the point object from Stage (global) coordinates to the display object's (local) coordinates | +| `localToGlobal(point: Point)` | Point | Converts the point object from the display object's (local) coordinates to Stage (global) coordinates | +| `hitTestObject(target: DisplayObject)` | boolean | Evaluates the DisplayObject's drawing range to see if it overlaps or intersects | +| `hitTestPoint(x: number, y: number, shapeFlag?: boolean)` | boolean | Evaluates the display object to see if it overlaps or intersects with the point specified by x and y parameters | +| `remove()` | void | Removes the parent-child relationship | +| `getLocalVariable(key: any)` | any | Gets a value from the local variable space of the class | +| `setLocalVariable(key: any, value: any)` | void | Stores a value in the local variable space of the class | +| `hasLocalVariable(key: any)` | boolean | Determines if there is a value in the local variable space of the class | +| `deleteLocalVariable(key: any)` | void | Removes a value from the local variable space of the class | +| `getGlobalVariable(key: any)` | any | Gets a value from the global variable space | +| `setGlobalVariable(key: any, value: any)` | void | Stores a value in the global variable space | +| `hasGlobalVariable(key: any)` | boolean | Determines if there is a value in the global variable space | +| `deleteGlobalVariable(key: any)` | void | Removes a value from the global variable space | +| `clearGlobalVariable()` | void | Clears all values in the global variable space | + +## Usage Examples + +### Use as Button + +```javascript +const { Sprite, Shape } = next2d.display; + +const button = new Sprite(); + +// Enable button mode +button.buttonMode = true; +button.useHandCursor = true; + +// Create background Shape +const bg = new Shape(); +bg.graphics.beginFill(0x3498db); +bg.graphics.drawRoundRect(0, 0, 120, 40, 8, 8); +bg.graphics.endFill(); +button.addChild(bg); + +// Click event +button.addEventListener("click", function() { + console.log("Button clicked"); +}); + +stage.addChild(button); +``` + +### Use as Mask + +```javascript +const { Sprite, Shape } = next2d.display; + +const container = new Sprite(); + +// Content Shape +const content = new Shape(); +content.graphics.beginFill(0xFF0000); +content.graphics.drawRect(0, 0, 200, 200); +content.graphics.endFill(); +container.addChild(content); + +// Mask Shape +const maskShape = new Shape(); +maskShape.graphics.beginFill(0xFFFFFF); +maskShape.graphics.drawCircle(100, 100, 50); +maskShape.graphics.endFill(); + +// Apply mask +container.mask = maskShape; + +stage.addChild(container); +stage.addChild(maskShape); +``` + +### Drag and Drop + +```javascript +const { Sprite, Shape } = next2d.display; +const { Rectangle } = next2d.geom; + +const draggable = new Sprite(); + +// Create background Shape +const bg = new Shape(); +bg.graphics.beginFill(0x3498db); +bg.graphics.drawRect(0, 0, 100, 100); +bg.graphics.endFill(); +draggable.addChild(bg); + +// Start drag +draggable.addEventListener("mouseDown", function() { + // Start dragging (lock center, specify bounds) + draggable.startDrag(true, new Rectangle(0, 0, 400, 300)); +}); + +// Stop drag +draggable.addEventListener("mouseUp", function() { + draggable.stopDrag(); +}); + +stage.addChild(draggable); +``` + +### Managing Child Objects + +```javascript +const { Sprite, Shape } = next2d.display; + +const container = new Sprite(); + +// Add multiple Shapes as children +for (let i = 0; i < 5; i++) { + const shape = new Shape(); + shape.graphics.beginFill(0xFF0000 + i * 0x003300); + shape.graphics.drawCircle(0, 0, 20); + shape.graphics.endFill(); + shape.x = i * 50; + shape.name = "circle" + i; + container.addChild(shape); +} + +// Get child object by name +const circle2 = container.getChildByName("circle2"); + +// Get number of children +console.log(container.numChildren); // 5 + +stage.addChild(container); +``` + +## Related + +- [DisplayObject](/en/reference/player/display-object) +- [MovieClip](/en/reference/player/movie-clip) +- [Shape](/en/reference/player/shape) diff --git a/specs/en/text-field.md b/specs/en/text-field.md new file mode 100644 index 00000000..ce93a9da --- /dev/null +++ b/specs/en/text-field.md @@ -0,0 +1,362 @@ +# TextField + +TextField is a DisplayObject for displaying and editing text. It provides text-related functionality from label display to input forms. + +## Inheritance + +```mermaid +classDiagram + DisplayObject <|-- InteractiveObject + InteractiveObject <|-- TextField + + class TextField { + +text: String + +textColor: Number + +type: String + +setTextFormat() + } +``` + +## Properties + +### Text Related + +| Property | Type | Description | +|----------|------|-------------| +| `text` | string | A string that is the current text in the text field | +| `htmlText` | string | Contains the HTML representation of the text field contents | +| `length` | number | The number of characters in a text field (read-only) | +| `maxChars` | number | The maximum number of characters that the text field can contain (0 for unlimited) | +| `restrict` | string | Indicates the set of characters that a user can enter into the text field | +| `defaultTextFormat` | TextFormat | Specifies the formatting to be applied to the text | +| `stopIndex` | number | Setting an arbitrary display end position for text (default: -1) | + +### Display Related + +| Property | Type | Description | +|----------|------|-------------| +| `width` | number | Indicates the width of the display object, in pixels | +| `height` | number | Indicates the height of the display object, in pixels | +| `textWidth` | number | The width of the text in pixels (read-only) | +| `textHeight` | number | The height of the text in pixels (read-only) | +| `autoSize` | string | Controls automatic sizing and alignment of text fields ("none", "left", "center", "right") | +| `autoFontSize` | boolean | Controls automatic sizing and alignment of text size (default: false) | +| `wordWrap` | boolean | A Boolean value that indicates whether the text field has word wrap (default: false) | +| `multiline` | boolean | Indicates whether field is a multiline text field (default: false) | +| `numLines` | number | Number of text lines (read-only) | + +### Border and Background Related + +| Property | Type | Description | +|----------|------|-------------| +| `background` | boolean | Specifies whether the text field has a background fill (default: false) | +| `backgroundColor` | number | The color of the text field background (default: 0xffffff) | +| `border` | boolean | Specifies whether the text field has a border (default: false) | +| `borderColor` | number | The color of the text field border (default: 0x000000) | + +### Outline Related + +| Property | Type | Description | +|----------|------|-------------| +| `thickness` | number | The text width of the outline, which can be disabled with 0 (default: 0) | +| `thicknessColor` | number | The color of the outline text in hexadecimal format (default: 0) | + +### Input Related + +| Property | Type | Description | +|----------|------|-------------| +| `type` | string | The type of the text field ("static", "dynamic", "input") (default: "static") | +| `focus` | boolean | Whether the text field has focus (default: false) | +| `focusVisible` | boolean | Controls the visibility of the text field's blinking line (default: false) | +| `focusIndex` | number | Index of the focus position of the text field (default: -1) | +| `selectIndex` | number | Index of the selected position of the text field (default: -1) | +| `compositionStartIndex` | number | Composition start index of the text field (default: -1) | +| `compositionEndIndex` | number | Composition end index of the text field (default: -1) | + +### Scroll Related + +| Property | Type | Description | +|----------|------|-------------| +| `scrollX` | number | Scroll position on the x-axis (default: 0) | +| `scrollY` | number | Scroll position on the y-axis (default: 0) | +| `scrollEnabled` | boolean | Control ON/OFF of the scroll function (default: true) | +| `xScrollShape` | Shape | Shape object for x scroll bar display (read-only) | +| `yScrollShape` | Shape | Shape object for y scroll bar display (read-only) | + +## Methods + +| Method | Return | Description | +|--------|--------|-------------| +| `appendText(newText: string)` | void | Appends the string specified by the newText parameter to the end of the text of the text field | +| `insertText(newText: string)` | void | Adds text to the focus position of the text field | +| `deleteText()` | void | Deletes the selection range of the text field | +| `getLineText(lineIndex: number)` | string | Returns the text of the line specified by the lineIndex parameter | +| `replaceText(newText: string, beginIndex: number, endIndex: number)` | void | Replaces the range of characters that the beginIndex and endIndex parameters specify with the contents of the newText parameter | +| `selectAll()` | void | Selects all text in the text field | +| `copy()` | void | Copy a selection of text fields | +| `paste()` | void | Paste the copied text into the selected range | +| `setFocusIndex(stageX: number, stageY: number, selected?: boolean)` | void | Sets the focus position of the text field | +| `keyDown(event: KeyboardEvent)` | void | Processes the key down event | + +## TextFormat + +A class for setting text styles. + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `font` | String | Font name | +| `size` | Number | Font size | +| `color` | Number | Text color | +| `bold` | Boolean | Bold | +| `italic` | Boolean | Italic | +| `align` | String | Alignment ("left", "center", "right") | +| `leading` | Number | Line spacing (pixels) | +| `letterSpacing` | Number | Letter spacing (pixels) | + +## Usage Examples + +### Basic Text Display + +```javascript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.text = "Hello, Next2D!"; +textField.x = 100; +textField.y = 100; + +stage.addChild(textField); +``` + +### Applying TextFormat + +```javascript +const { TextField, TextFormat } = next2d.text; + +const textField = new TextField(); +textField.text = "Styled Text"; + +// Create TextFormat +const format = new TextFormat(); +format.font = "Arial"; +format.size = 24; +format.color = 0x3498db; +format.bold = true; + +// Apply format +textField.setTextFormat(format); + +// Set as default format +textField.defaultTextFormat = format; + +stage.addChild(textField); +``` + +### Auto Size + +```javascript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.autoSize = "left"; // Auto expand to fit text +textField.text = "This text will auto-size the field"; + +stage.addChild(textField); +``` + +### Multiline Text + +```javascript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.width = 200; +textField.multiline = true; +textField.wordWrap = true; +textField.text = "This is multiline text. It will wrap automatically."; + +stage.addChild(textField); +``` + +### Input Field + +```javascript +const { TextField } = next2d.text; + +const inputField = new TextField(); +inputField.type = "input"; +inputField.width = 200; +inputField.height = 30; +inputField.border = true; +inputField.borderColor = 0xcccccc; +inputField.background = true; +inputField.backgroundColor = 0xffffff; + +// Placeholder alternative +inputField.text = ""; + +// Input restriction (numbers only) +inputField.restrict = "0-9"; + +// Input event +inputField.addEventListener("change", function(event) { + console.log("Input value:", event.target.text); +}); + +stage.addChild(inputField); +``` + +### Password Field + +```javascript +const { TextField } = next2d.text; + +const passwordField = new TextField(); +passwordField.type = "input"; +passwordField.displayAsPassword = true; +passwordField.width = 200; +passwordField.height = 30; +passwordField.border = true; +passwordField.borderColor = 0xcccccc; + +stage.addChild(passwordField); +``` + +### HTML Text + +```javascript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.width = 300; +textField.multiline = true; +textField.htmlText = '' + + 'Bold Text
' + + 'Italic Text
' + + 'Red Text' + + '
'; + +stage.addChild(textField); +``` + +### Scrollable Text + +```javascript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.width = 200; +textField.height = 100; +textField.multiline = true; +textField.wordWrap = true; +textField.border = true; +textField.text = "Long text...\n".repeat(20); + +// Scroll operations +function scrollUp() { + if (textField.scrollY > 0) { + textField.scrollY -= 10; + } +} + +function scrollDown() { + textField.scrollY += 10; +} + +stage.addChild(textField); +``` + +### Dynamic Text Update + +```javascript +const { TextField, TextFormat } = next2d.text; + +const scoreField = new TextField(); +scoreField.autoSize = "left"; + +const format = new TextFormat(); +format.font = "Arial"; +format.size = 32; +format.color = 0xffffff; +scoreField.defaultTextFormat = format; + +let score = 0; + +function updateScore(points) { + score += points; + scoreField.text = "Score: " + score; +} + +updateScore(0); +stage.addChild(scoreField); +``` + +### Text Outline Effect + +```javascript +const { TextField, TextFormat } = next2d.text; + +const textField = new TextField(); +textField.autoSize = "left"; + +const format = new TextFormat(); +format.font = "Arial"; +format.size = 48; +format.color = 0xffffff; +textField.defaultTextFormat = format; + +textField.text = "Outlined Text"; +textField.thickness = 2; +textField.thicknessColor = 0x000000; + +stage.addChild(textField); +``` + +### Replace Part of Text + +```javascript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.autoSize = "left"; +textField.text = "Hello World!"; + +// Replace "World" with "Next2D" +textField.replaceText("Next2D", 6, 11); +// Result: "Hello Next2D!" + +stage.addChild(textField); +``` + +## Events + +| Event | Description | +|-------|-------------| +| `change` | When text is changed | +| `focus` | When focus is gained | +| `blur` | When focus is lost | +| `keyDown` | When key is pressed | +| `keyUp` | When key is released | + +```javascript +const { TextField } = next2d.text; + +const inputField = new TextField(); +inputField.type = "input"; + +// Submit form on Enter key +inputField.addEventListener("keyDown", function(event) { + if (event.keyCode === 13) { // Enter + submitForm(inputField.text); + } +}); + +stage.addChild(inputField); +``` + +## Related + +- [DisplayObject](/en/reference/player/display-object) +- [Event System](/en/reference/player/events) diff --git a/specs/en/tween.md b/specs/en/tween.md new file mode 100644 index 00000000..864c2bb7 --- /dev/null +++ b/specs/en/tween.md @@ -0,0 +1,374 @@ +# Tween Animation + +Next2D Player allows you to implement programmatic animations (Tweens). You can smoothly animate properties like position, size, and transparency. + +## Basic Tween Concepts + +```mermaid +flowchart LR + Start["Start Value"] -->|Easing Function| Progress["Progress 0→1"] + Progress --> End["End Value"] + + subgraph Easing["Easing"] + Linear["Linear"] + EaseIn["EaseIn"] + EaseOut["EaseOut"] + EaseInOut["EaseInOut"] + end +``` + +## Basic Tween Class + +```javascript +class Tween { + constructor(target, options) { + this._target = target; + this._properties = {}; + this._duration = options.duration; + this._easing = options.easing || Easing.linear; + this._startTime = 0; + this._isPlaying = false; + this._onUpdate = options.onUpdate; + this._onComplete = options.onComplete; + } + + to(properties) { + for (const key in properties) { + this._properties[key] = { + start: this._target[key], + end: properties[key] + }; + } + return this; + } + + play() { + this._startTime = Date.now(); + this._isPlaying = true; + this._update(); + return this; + } + + _update() { + const self = this; + if (!this._isPlaying) return; + + const elapsed = Date.now() - this._startTime; + let progress = Math.min(1, elapsed / this._duration); + progress = this._easing(progress); + + // Update properties + for (const key in this._properties) { + const prop = this._properties[key]; + this._target[key] = prop.start + (prop.end - prop.start) * progress; + } + + if (this._onUpdate) { + this._onUpdate(); + } + + if (elapsed < this._duration) { + requestAnimationFrame(function() { self._update(); }); + } else { + this._isPlaying = false; + if (this._onComplete) { + this._onComplete(); + } + } + } + + stop() { + this._isPlaying = false; + } +} +``` + +## Easing Functions + +```javascript +const Easing = { + // Linear + linear: function(t) { return t; }, + + // Acceleration + easeInQuad: function(t) { return t * t; }, + easeInCubic: function(t) { return t * t * t; }, + easeInQuart: function(t) { return t * t * t * t; }, + + // Deceleration + easeOutQuad: function(t) { return t * (2 - t); }, + easeOutCubic: function(t) { return (--t) * t * t + 1; }, + easeOutQuart: function(t) { return 1 - (--t) * t * t * t; }, + + // Acceleration → Deceleration + easeInOutQuad: function(t) { + return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; + }, + easeInOutCubic: function(t) { + return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1; + }, + + // Bounce + easeOutBounce: function(t) { + if (t < 1 / 2.75) { + return 7.5625 * t * t; + } else if (t < 2 / 2.75) { + return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75; + } else if (t < 2.5 / 2.75) { + return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375; + } else { + return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375; + } + }, + + // Back (overshoots then returns) + easeOutBack: function(t) { + const c1 = 1.70158; + const c3 = c1 + 1; + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); + }, + + // Elastic (rubber-like motion) + easeOutElastic: function(t) { + if (t === 0 || t === 1) return t; + return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * (2 * Math.PI) / 3) + 1; + } +}; +``` + +## Usage Examples + +### Basic Movement Animation + +```javascript +const { Sprite } = next2d.display; + +const sprite = new Sprite(); +sprite.x = 0; +sprite.y = 100; +stage.addChild(sprite); + +// Move right +new Tween(sprite, { duration: 1000, easing: Easing.easeOutQuad }) + .to({ x: 400 }) + .play(); +``` + +### Simultaneous Multi-Property Animation + +```javascript +// Move + Scale + Fade in +new Tween(sprite, { + duration: 500, + easing: Easing.easeOutCubic +}) + .to({ + x: 200, + y: 150, + scaleX: 2, + scaleY: 2, + alpha: 1 + }) + .play(); +``` + +### Sequential Animation + +```javascript +// Consecutive animations +function sequentialAnimation(sprite) { + new Tween(sprite, { + duration: 500, + onComplete: function() { + new Tween(sprite, { + duration: 300, + onComplete: function() { + new Tween(sprite, { duration: 500 }) + .to({ alpha: 0 }) + .play(); + } + }) + .to({ scaleX: 1.5, scaleY: 1.5 }) + .play(); + } + }) + .to({ y: 100 }) + .play(); +} +``` + +### Game Examples + +#### Character Jump + +```javascript +function jump(character) { + const startY = character.y; + const jumpHeight = 100; + + // Ascend + new Tween(character, { + duration: 300, + easing: Easing.easeOutQuad, + onComplete: function() { + // Descend + new Tween(character, { + duration: 300, + easing: Easing.easeInQuad + }) + .to({ y: startY }) + .play(); + } + }) + .to({ y: startY - jumpHeight }) + .play(); +} +``` + +#### Damage Effect + +```javascript +function damageEffect(target) { + const originalX = target.x; + let shakeCount = 0; + + // Flash + Shake + function shake() { + if (shakeCount >= 6) { + target.x = originalX; + target.alpha = 1; + return; + } + + const offset = shakeCount % 2 === 0 ? 5 : -5; + target.x = originalX + offset; + target.alpha = shakeCount % 2 === 0 ? 0.5 : 1; + shakeCount++; + + setTimeout(shake, 50); + } + + shake(); +} +``` + +#### Coin Collect Effect + +```javascript +function coinCollectEffect(coin, targetY) { + // Float up and fade out + new Tween(coin, { + duration: 500, + easing: Easing.easeOutQuad, + onUpdate: function() { + // Rotate + coin.rotation += 15; + }, + onComplete: function() { + if (coin.parent) { + coin.parent.removeChild(coin); + } + } + }) + .to({ + y: targetY, + alpha: 0, + scaleX: 0.5, + scaleY: 0.5 + }) + .play(); +} +``` + +#### UI Animation + +```javascript +function showPopup(popup) { + popup.scaleX = 0; + popup.scaleY = 0; + popup.alpha = 0; + + new Tween(popup, { + duration: 400, + easing: Easing.easeOutBack + }) + .to({ scaleX: 1, scaleY: 1, alpha: 1 }) + .play(); +} + +function hidePopup(popup, onComplete) { + new Tween(popup, { + duration: 200, + easing: Easing.easeInQuad, + onComplete: onComplete + }) + .to({ scaleX: 0, scaleY: 0, alpha: 0 }) + .play(); +} +``` + +## Lightweight enterFrame-based Tween + +```javascript +// Simple enterFrame-based tween +function tweenTo(target, property, endValue, speed) { + speed = speed || 0.1; + + function handler(event) { + const current = target[property]; + const diff = endValue - current; + + if (Math.abs(diff) < 0.1) { + target[property] = endValue; + stage.removeEventListener("enterFrame", handler); + } else { + target[property] = current + diff * speed; + } + } + + stage.addEventListener("enterFrame", handler); +} + +// Usage +tweenTo(sprite, "x", 300, 0.15); // Move x toward 300 +tweenTo(sprite, "alpha", 0, 0.05); // Fade out +``` + +## Custom Easing + +```javascript +// Bezier curve based easing +function bezierEasing(x1, y1, x2, y2) { + return function(t) { + // Simple cubic bezier interpolation + const cx = 3 * x1; + const bx = 3 * (x2 - x1) - cx; + const ax = 1 - cx - bx; + + const cy = 3 * y1; + const by = 3 * (y2 - y1) - cy; + const ay = 1 - cy - by; + + function sampleCurveY(t) { + return ((ay * t + by) * t + cy) * t; + } + + return sampleCurveY(t); + }; +} + +// CSS cubic-bezier equivalent +const customEase = bezierEasing(0.25, 0.1, 0.25, 1.0); +``` + +## Performance Tips + +1. **Use requestAnimationFrame**: Smoother than setTimeout +2. **Minimize Property Changes**: Only update necessary properties +3. **Object Pooling**: Pool and reuse tweens for many animations +4. **Cleanup After Completion**: Remove unnecessary listeners + +## Related + +- [DisplayObject](/en/reference/player/display-object) +- [Event System](/en/reference/player/events) diff --git a/specs/en/video.md b/specs/en/video.md new file mode 100644 index 00000000..52304a25 --- /dev/null +++ b/specs/en/video.md @@ -0,0 +1,277 @@ +# Video + +Video is a DisplayObject for playing video content. It supports video formats such as WebM and MP4. + +## Inheritance + +```mermaid +classDiagram + DisplayObject <|-- Video + + class Video { + +src: string + +videoWidth: number + +videoHeight: number + +duration: number + +currentTime: number + +volume: number + +loop: boolean + +autoPlay: boolean + +smoothing: boolean + +paused: boolean + +muted: boolean + +loaded: boolean + +ended: boolean + +isVideo: boolean + +play() Promise~void~ + +pause() void + +seek(offset) void + } +``` + +## Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `src` | string | "" | Specifies the URL of the video content | +| `videoWidth` | number | 0 | An integer specifying the width of the video, in pixels | +| `videoHeight` | number | 0 | An integer specifying the height of the video, in pixels | +| `duration` | number | 0 | Total number of keyframes (video duration) | +| `currentTime` | number | 0 | Current keyframe (playback position) | +| `volume` | number | 1 | The volume, ranging from 0 (silent) to 1 (full volume) | +| `loop` | boolean | false | Specifies whether to generate a video loop | +| `autoPlay` | boolean | true | Setting up automatic video playback | +| `smoothing` | boolean | true | Specifies whether the video should be smoothed (interpolated) when it is scaled | +| `paused` | boolean | true | Returns whether the video is paused | +| `muted` | boolean | false | Returns whether the video is muted | +| `loaded` | boolean | false | Returns whether the video has been loaded | +| `ended` | boolean | false | Returns whether the video has ended | +| `isVideo` | boolean | true | Returns whether the display object has Video functionality (read-only) | + +## Methods + +| Method | Return | Description | +|--------|--------|-------------| +| `play()` | Promise\ | Plays the video file | +| `pause()` | void | Pauses the video playback | +| `seek(offset: number)` | void | Seeks the keyframe closest to the specified location | + +## Usage Examples + +### Basic Video Playback + +```javascript +const { Video } = next2d.media; + +// Create Video object +const video = new Video(640, 360); + +// Set video source +video.src = "video.mp4"; +video.autoPlay = true; +video.loop = false; +video.volume = 0.8; + +// Add to stage +stage.addChild(video); +``` + +### Playback Control + +```javascript +const { Video } = next2d.media; + +const video = new Video(640, 360); +video.src = "video.mp4"; +stage.addChild(video); + +// Play button +playButton.addEventListener("click", async function() { + await video.play(); +}); + +// Pause button +pauseButton.addEventListener("click", function() { + video.pause(); +}); + +// Stop button (pause and return to start) +stopButton.addEventListener("click", function() { + video.pause(); + video.seek(0); +}); + +// Forward 10 seconds +forwardButton.addEventListener("click", function() { + video.seek(video.currentTime + 10); +}); + +// Back 10 seconds +backButton.addEventListener("click", function() { + video.seek(Math.max(0, video.currentTime - 10)); +}); +``` + +### Displaying Playback Progress + +```javascript +const { Video } = next2d.media; + +const video = new Video(640, 360); +video.src = "video.mp4"; +stage.addChild(video); + +// Update progress each frame +stage.addEventListener("enterFrame", function() { + if (video.duration > 0) { + const progress = video.currentTime / video.duration; + progressBar.scaleX = progress; + timeLabel.text = formatTime(video.currentTime) + " / " + formatTime(video.duration); + } +}); + +function formatTime(seconds) { + const min = Math.floor(seconds / 60); + const sec = Math.floor(seconds % 60); + return min + ":" + sec.toString().padStart(2, '0'); +} +``` + +### Volume Control + +```javascript +const { Video } = next2d.media; + +const video = new Video(640, 360); +video.src = "video.mp4"; +video.volume = 0.5; // 50% +stage.addChild(video); + +// Volume slider +volumeSlider.addEventListener("change", function(event) { + video.volume = event.target.value; // 0.0 ~ 1.0 +}); + +// Mute toggle +let isMuted = false; +let previousVolume = 0.5; + +muteButton.addEventListener("click", function() { + isMuted = !isMuted; + if (isMuted) { + previousVolume = video.volume; + video.volume = 0; + } else { + video.volume = previousVolume; + } +}); +``` + +### Fullscreen Support + +```javascript +const { Video } = next2d.media; + +const video = new Video(640, 360); +video.src = "video.mp4"; +stage.addChild(video); + +// Fullscreen toggle +fullscreenButton.addEventListener("click", function() { + if (stage.displayState === "normal") { + // Switch to fullscreen + stage.displayState = "fullScreen"; + video.width = stage.stageWidth; + video.height = stage.stageHeight; + } else { + // Return to normal display + stage.displayState = "normal"; + video.width = 640; + video.height = 360; + } +}); +``` + +### Video Player Component + +```javascript +const { Sprite } = next2d.display; +const { Video } = next2d.media; + +class VideoPlayer extends Sprite { + constructor(width, height) { + super(); + + this._width = width; + this._height = height; + + this._video = new Video(width, height); + this.addChild(this._video); + } + + load(url) { + this._video.src = url; + } + + async play() { + await this._video.play(); + } + + pause() { + this._video.pause(); + } + + seek(time) { + this._video.seek(time); + } + + get currentTime() { + return this._video.currentTime; + } + + get duration() { + return this._video.duration || 0; + } + + set volume(value) { + this._video.volume = value; + } + + get volume() { + return this._video.volume; + } +} + +// Usage +const player = new VideoPlayer(640, 360); +stage.addChild(player); +player.load("video.mp4"); +player.play(); +``` + +### Loop Playback and Auto Play + +```javascript +const { Video } = next2d.media; + +const video = new Video(640, 360); +video.src = "background-video.mp4"; +video.autoPlay = true; +video.loop = true; +video.volume = 0; // Muted background video + +stage.addChild(video); +``` + +## Supported Formats + +| Format | Extension | Support | +|--------|-----------|---------| +| MP4 (H.264) | .mp4 | Recommended | +| WebM (VP8/VP9) | .webm | Supported | +| Ogg Theora | .ogv | Browser dependent | + +## Related + +- [DisplayObject](/en/reference/player/display-object) +- [Event System](/en/reference/player/events) diff --git a/specs/ja/display-object.md b/specs/ja/display-object.md new file mode 100644 index 00000000..93ab9486 --- /dev/null +++ b/specs/ja/display-object.md @@ -0,0 +1,164 @@ +# DisplayObject + +DisplayObjectは、Next2D Playerにおける全ての表示オブジェクトの基底クラスです。 + +## プロパティ (Properties) + +### 読み取り専用プロパティ + +| プロパティ | 型 | 説明 | +|-----------|------|------| +| `instanceId` | number | DisplayObjectのユニークなインスタンスID | +| `isSprite` | boolean | Spriteの機能を所持しているかを返却 | +| `isInteractive` | boolean | InteractiveObjectの機能を所持しているかを返却 | +| `isContainerEnabled` | boolean | コンテナの機能を所持しているかを返却 | +| `isTimelineEnabled` | boolean | MovieClipの機能を所持しているかを返却 | +| `isShape` | boolean | Shapeの機能を所持しているかを返却 | +| `isVideo` | boolean | Videoの機能を所持しているかを返却 | +| `isText` | boolean | Textの機能を所持しているかを返却 | +| `concatenatedMatrix` | Matrix | ルートレベルまでの結合された変換行列 | +| `dropTarget` | DisplayObject \| null | スプライトのドラッグ先またはドロップされた先の表示オブジェクト | +| `loaderInfo` | LoaderInfo \| null | この表示オブジェクトが属するファイルの読み込み情報 | +| `mouseX` | number | 対象のDisplayObjectの基準点からのマウスのX座標(ピクセル) | +| `mouseY` | number | 対象のDisplayObjectの基準点からのマウスのY座標(ピクセル) | +| `root` | MovieClip \| Sprite \| null | DisplayObjectのルートであるDisplayObjectContainer | + +### 読み書きプロパティ + +| プロパティ | 型 | 説明 | +|-----------|------|------| +| `name` | string | 名前。getChildByName()で使用される(デフォルト: "") | +| `startFrame` | number | 開始フレーム(デフォルト: 1) | +| `endFrame` | number | 終了フレーム(デフォルト: 0) | +| `isMask` | boolean | マスクとしてDisplayObjectにセットされているかを示す(デフォルト: false) | +| `parent` | Sprite \| MovieClip \| null | このDisplayObjectの親のDisplayObjectContainer | +| `alpha` | number | アルファ透明度値(0.0~1.0、デフォルト: 1.0) | +| `blendMode` | string | 使用するブレンドモード(デフォルト: BlendMode.NORMAL) | +| `filters` | Array \| null | 表示オブジェクトに関連付けられている各フィルターオブジェクトの配列 | +| `height` | number | 表示オブジェクトの高さ(ピクセル単位) | +| `width` | number | 表示オブジェクトの幅(ピクセル単位) | +| `colorTransform` | ColorTransform | 表示オブジェクトのColorTransform | +| `matrix` | Matrix | 表示オブジェクトのMatrix | +| `rotation` | number | DisplayObjectインスタンスの回転角度(度単位) | +| `scale9Grid` | Rectangle \| null | 現在有効な拡大/縮小グリッド | +| `scaleX` | number | 基準点から適用されるオブジェクトの水平スケール値 | +| `scaleY` | number | 基準点から適用されるオブジェクトの垂直スケール値 | +| `visible` | boolean | 表示オブジェクトが可視かどうか(デフォルト: true) | +| `x` | number | 親DisplayObjectContainerのローカル座標を基準にしたX座標 | +| `y` | number | 親DisplayObjectContainerのローカル座標を基準にしたY座標 | + +## メソッド (Methods) + +| メソッド | 戻り値 | 説明 | +|---------|--------|------| +| `getBounds(targetDisplayObject)` | Rectangle | 指定したDisplayObjectの座標系を基準にして、表示オブジェクトの領域を定義する矩形を返す | +| `globalToLocal(point)` | Point | pointオブジェクトをステージ(グローバル)座標から表示オブジェクトの(ローカル)座標に変換 | +| `localToGlobal(point)` | Point | pointオブジェクトを表示オブジェクトの(ローカル)座標からステージ(グローバル)座標に変換 | +| `hitTestObject(targetDisplayObject)` | boolean | DisplayObjectの描画範囲を評価して、重複または交差するかどうかを調べる | +| `hitTestPoint(x, y, shapeFlag)` | boolean | 表示オブジェクトを評価して、x および y パラメーターで指定されたポイントと重複または交差するかどうかを調べる | +| `getLocalVariable(key)` | any | クラスのローカル変数空間から値を取得 | +| `setLocalVariable(key, value)` | void | クラスのローカル変数空間へ値を保存 | +| `hasLocalVariable(key)` | boolean | クラスのローカル変数空間に値があるかどうかを判断 | +| `deleteLocalVariable(key)` | void | クラスのローカル変数空間の値を削除 | +| `getGlobalVariable(key)` | any | グローバル変数空間から値を取得 | +| `setGlobalVariable(key, value)` | void | グローバル変数空間へ値を保存 | +| `hasGlobalVariable(key)` | boolean | グローバル変数空間に値があるかどうかを判断 | +| `deleteGlobalVariable(key)` | void | グローバル変数空間の値を削除 | +| `clearGlobalVariable()` | void | グローバル変数空間の値を全てクリア | +| `remove()` | void | 親子関係を解除 | + +## ブレンドモード + +| 定数 | 説明 | +|------|------| +| `BlendMode.NORMAL` | 通常表示 | +| `BlendMode.ADD` | 加算 | +| `BlendMode.MULTIPLY` | 乗算 | +| `BlendMode.SCREEN` | スクリーン | +| `BlendMode.DARKEN` | 暗くする | +| `BlendMode.LIGHTEN` | 明るくする | +| `BlendMode.DIFFERENCE` | 差分 | +| `BlendMode.OVERLAY` | オーバーレイ | +| `BlendMode.HARDLIGHT` | ハードライト | +| `BlendMode.INVERT` | 反転 | +| `BlendMode.ALPHA` | アルファ | +| `BlendMode.ERASE` | 消去 | + +## 使用例 + +```typescript +const { Sprite } = next2d.display; +const { BlurFilter } = next2d.filters; + +const sprite = new Sprite(); + +// 位置とサイズ +sprite.x = 100; +sprite.y = 200; +sprite.scaleX = 1.5; +sprite.scaleY = 1.5; +sprite.rotation = 30; + +// 表示制御 +sprite.alpha = 0.8; +sprite.visible = true; +sprite.blendMode = "add"; + +// フィルター +sprite.filters = [ + new BlurFilter(4, 4) +]; + +// ステージに追加 +stage.addChild(sprite); +``` + +### 座標変換の例 + +```typescript +const { Point } = next2d.geom; + +// グローバル座標をローカル座標に変換 +const globalPoint = new Point(100, 100); +const localPoint = displayObject.globalToLocal(globalPoint); + +// ローカル座標をグローバル座標に変換 +const localPos = new Point(0, 0); +const globalPos = displayObject.localToGlobal(localPos); +``` + +### 衝突判定の例 + +```typescript +// バウンディングボックスで判定 +const hit1 = displayObject.hitTestPoint(100, 100, false); + +// 実際の形状で判定 +const hit2 = displayObject.hitTestPoint(100, 100, true); + +// 他のDisplayObjectとの衝突判定 +if (obj1.hitTestObject(obj2)) { + console.log("衝突しました"); +} +``` + +### 変数操作の例 + +```typescript +// ローカル変数の操作 +displayObject.setLocalVariable("score", 100); +const score = displayObject.getLocalVariable("score"); +if (displayObject.hasLocalVariable("score")) { + displayObject.deleteLocalVariable("score"); +} + +// グローバル変数の操作 +displayObject.setGlobalVariable("gameState", "playing"); +const state = displayObject.getGlobalVariable("gameState"); +displayObject.clearGlobalVariable(); // 全てクリア +``` + +## 関連項目 + +- [MovieClip](/ja/reference/player/movie-clip) +- [Sprite](/ja/reference/player/sprite) diff --git a/specs/ja/events.md b/specs/ja/events.md new file mode 100644 index 00000000..4149a74a --- /dev/null +++ b/specs/ja/events.md @@ -0,0 +1,215 @@ +# イベントシステム + +Next2D Playerは、Flash Playerと同様のイベントモデルを採用しています。 + +## EventDispatcher + +すべてのイベント発行可能なオブジェクトの基底クラスです。 + +### addEventListener(type, listener, useCapture, priority) + +イベントリスナーを登録します。 + +```typescript +displayObject.addEventListener("click", (event) => { + console.log("クリックされました"); +}); + +// キャプチャフェーズで受け取る +displayObject.addEventListener("click", handler, true); + +// 優先度を指定 +displayObject.addEventListener("click", handler, false, 10); +``` + +### removeEventListener(type, listener, useCapture) + +イベントリスナーを削除します。 + +```typescript +displayObject.removeEventListener("click", handler); +``` + +### hasEventListener(type) + +指定タイプのリスナーが登録されているか確認します。 + +```typescript +if (displayObject.hasEventListener("click")) { + console.log("クリックリスナーが登録されています"); +} +``` + +### dispatchEvent(event) + +イベントを発行します。 + +```typescript +const { Event } = next2d.events; + +const event = new Event("customEvent"); +displayObject.dispatchEvent(event); +``` + +## Event クラス + +### プロパティ + +| プロパティ | 型 | 説明 | +|-----------|------|------| +| `type` | String | イベントタイプ | +| `target` | Object | イベント発行元 | +| `currentTarget` | Object | 現在のリスナー登録先 | +| `eventPhase` | Number | イベントフェーズ | +| `bubbles` | Boolean | バブリングするか | +| `cancelable` | Boolean | キャンセル可能か | + +### メソッド + +| メソッド | 説明 | +|----------|------| +| `stopPropagation()` | 伝播を停止 | +| `stopImmediatePropagation()` | 伝播を即座に停止 | +| `preventDefault()` | デフォルト動作をキャンセル | + +## 標準イベントタイプ + +### 表示リスト関連 + +| イベント | 説明 | +|----------|------| +| `added` | DisplayObjectContainerに追加された | +| `addedToStage` | Stageに追加された | +| `removed` | DisplayObjectContainerから削除された | +| `removedFromStage` | Stageから削除された | + +```typescript +sprite.addEventListener("addedToStage", (event) => { + console.log("ステージに追加されました"); +}); +``` + +### タイムライン関連 + +| イベント | 説明 | +|----------|------| +| `enterFrame` | 各フレームで発生 | +| `frameConstructed` | フレーム構築完了 | +| `exitFrame` | フレーム離脱時 | + +```typescript +movieClip.addEventListener("enterFrame", (event) => { + // 毎フレーム実行される処理 + updatePosition(); +}); +``` + +### ロード関連 + +| イベント | 説明 | +|----------|------| +| `complete` | ロード完了 | +| `progress` | ロード進捗 | +| `ioError` | IOエラー | + +```typescript +const { Loader } = next2d.display; +const { URLRequest } = next2d.net; + +const loader = new Loader(); + +// async/awaitを使用した読み込み +await loader.load(new URLRequest("animation.json")); +const content = loader.content; +stage.addChild(content); + +// プログレスイベントを使用する場合 +loader.contentLoaderInfo.addEventListener("progress", (event) => { + const percent = (event.bytesLoaded / event.bytesTotal) * 100; + console.log(`${percent}% ロード完了`); +}); +``` + +## マウスイベント + +| イベント | 説明 | +|----------|------| +| `click` | クリック | +| `doubleClick` | ダブルクリック | +| `mouseDown` | マウスボタン押下 | +| `mouseUp` | マウスボタン解放 | +| `mouseMove` | マウス移動 | +| `mouseOver` | マウスオーバー | +| `mouseOut` | マウスアウト | +| `rollOver` | ロールオーバー | +| `rollOut` | ロールアウト | + +```typescript +sprite.addEventListener("click", (event) => { + console.log("クリック位置:", event.localX, event.localY); +}); + +sprite.addEventListener("mouseMove", (event) => { + console.log("マウス位置:", event.stageX, event.stageY); +}); +``` + +## キーボードイベント + +| イベント | 説明 | +|----------|------| +| `keyDown` | キー押下 | +| `keyUp` | キー解放 | + +```typescript +stage.addEventListener("keyDown", (event) => { + console.log("キーコード:", event.keyCode); + + switch (event.keyCode) { + case 37: // 左矢印 + player.x -= 10; + break; + case 39: // 右矢印 + player.x += 10; + break; + } +}); +``` + +## カスタムイベント + +```typescript +const { Event } = next2d.events; + +// カスタムイベントの定義 +const customEvent = new Event("gameOver", true, true); + +// イベントの発行 +gameManager.dispatchEvent(customEvent); + +// イベントのリッスン +gameManager.addEventListener("gameOver", (event) => { + showGameOverScreen(); +}); +``` + +## イベントの伝播 + +イベントは3つのフェーズで伝播します: + +1. **キャプチャフェーズ**: rootからtargetへ +2. **ターゲットフェーズ**: targetで処理 +3. **バブリングフェーズ**: targetからrootへ + +```typescript +// キャプチャフェーズで処理 +parent.addEventListener("click", handler, true); + +// バブリングフェーズで処理(デフォルト) +child.addEventListener("click", handler, false); +``` + +## 関連項目 + +- [DisplayObject](/ja/reference/player/display-object) +- [MovieClip](/ja/reference/player/movie-clip) diff --git a/specs/ja/filters/index.md b/specs/ja/filters/index.md new file mode 100644 index 00000000..7b9ac527 --- /dev/null +++ b/specs/ja/filters/index.md @@ -0,0 +1,393 @@ +# フィルター + +Next2D Playerは、DisplayObjectに適用できる様々なビジュアルフィルターを提供しています。 + +## フィルターの適用方法 + +```typescript +const { Sprite } = next2d.display; +const { BlurFilter, DropShadowFilter, GlowFilter } = next2d.filters; + +const sprite = new Sprite(); + +// 単一のフィルター +sprite.filters = [new BlurFilter(4, 4)]; + +// 複数のフィルター +sprite.filters = [ + new DropShadowFilter(4, 45, 0x000000, 0.5), + new GlowFilter(0xff0000, 1, 8, 8) +]; + +// フィルターの削除 +sprite.filters = null; +``` + +## 利用可能なフィルター + +| フィルター | 説明 | +|-----------|------| +| BlurFilter | ぼかし効果 | +| DropShadowFilter | ドロップシャドウ効果 | +| GlowFilter | グロー効果 | +| BevelFilter | ベベル効果 | +| ColorMatrixFilter | カラーマトリックス変換 | +| ConvolutionFilter | 畳み込み効果 | +| DisplacementMapFilter | 変位マップ効果 | +| GradientBevelFilter | グラデーションベベル効果 | +| GradientGlowFilter | グラデーショングロー効果 | + +--- + +## BlurFilter + +ぼかし効果を適用します。ソフトフォーカスからガウスぼかしまで作成できます。 + +```typescript +const { BlurFilter } = next2d.filters; + +new BlurFilter(blurX, blurY, quality); +``` + +### プロパティ + +| プロパティ | 型 | デフォルト | 説明 | +|-----------|------|----------|------| +| blurX | number | 4 | 水平方向のぼかし量(0〜255) | +| blurY | number | 4 | 垂直方向のぼかし量(0〜255) | +| quality | number | 1 | ぼかしの実行回数(0〜15) | + +--- + +## DropShadowFilter + +ドロップシャドウ効果を適用します。内側シャドウ、外側シャドウ、ノックアウトモードなどのスタイルオプションがあります。 + +```typescript +const { DropShadowFilter } = next2d.filters; + +new DropShadowFilter( + distance, angle, color, alpha, + blurX, blurY, strength, quality, + inner, knockout, hideObject +); +``` + +### プロパティ + +| プロパティ | 型 | デフォルト | 説明 | +|-----------|------|----------|------| +| alpha | number | 1 | シャドウのアルファ透明度(0〜1) | +| angle | number | 45 | シャドウの角度(-360〜360度) | +| blurX | number | 4 | 水平方向のぼかし量(0〜255) | +| blurY | number | 4 | 垂直方向のぼかし量(0〜255) | +| color | number | 0 | シャドウの色(0x000000〜0xFFFFFF) | +| distance | number | 4 | シャドウのオフセット距離(-255〜255ピクセル) | +| hideObject | boolean | false | オブジェクトを非表示にするかどうか | +| inner | boolean | false | 内側シャドウにするかどうか | +| knockout | boolean | false | ノックアウト効果を適用するかどうか | +| quality | number | 1 | ぼかしの実行回数(0〜15) | +| strength | number | 1 | インプリントの強さ(0〜255) | + +--- + +## GlowFilter + +グロー効果を適用します。内側グロー、外側グロー、ノックアウトモードなどのスタイルオプションがあります。 + +```typescript +const { GlowFilter } = next2d.filters; + +new GlowFilter( + color, alpha, blurX, blurY, + strength, quality, inner, knockout +); +``` + +### プロパティ + +| プロパティ | 型 | デフォルト | 説明 | +|-----------|------|----------|------| +| alpha | number | 1 | グローのアルファ透明度(0〜1) | +| blurX | number | 4 | 水平方向のぼかし量(0〜255) | +| blurY | number | 4 | 垂直方向のぼかし量(0〜255) | +| color | number | 0 | グローの色(0x000000〜0xFFFFFF) | +| inner | boolean | false | 内側グローにするかどうか | +| knockout | boolean | false | ノックアウト効果を適用するかどうか | +| quality | number | 1 | ぼかしの実行回数(0〜15) | +| strength | number | 1 | インプリントの強さ(0〜255) | + +--- + +## BevelFilter + +ベベル効果を適用します。オブジェクトを3次元的に表現できます。 + +```typescript +const { BevelFilter } = next2d.filters; + +new BevelFilter( + distance, angle, highlightColor, highlightAlpha, + shadowColor, shadowAlpha, blurX, blurY, + strength, quality, type, knockout +); +``` + +### プロパティ + +| プロパティ | 型 | デフォルト | 説明 | +|-----------|------|----------|------| +| angle | number | 45 | ベベルの角度(-360〜360度) | +| blurX | number | 4 | 水平方向のぼかし量(0〜255) | +| blurY | number | 4 | 垂直方向のぼかし量(0〜255) | +| distance | number | 4 | ベベルのオフセット距離(-255〜255ピクセル) | +| highlightAlpha | number | 1 | ハイライトのアルファ透明度(0〜1) | +| highlightColor | number | 0xFFFFFF | ハイライトの色(0x000000〜0xFFFFFF) | +| knockout | boolean | false | ノックアウト効果を適用するかどうか | +| quality | number | 1 | ぼかしの実行回数(0〜15) | +| shadowAlpha | number | 1 | シャドウのアルファ透明度(0〜1) | +| shadowColor | number | 0 | シャドウの色(0x000000〜0xFFFFFF) | +| strength | number | 1 | インプリントの強さ(0〜255) | +| type | string | "inner" | ベベルの配置("inner"、"outer"、"full") | + +--- + +## ColorMatrixFilter + +4x5カラーマトリックス変換を適用します。明度、コントラスト、彩度、色相などを調整できます。 + +```typescript +const { ColorMatrixFilter } = next2d.filters; + +new ColorMatrixFilter(matrix); +``` + +### プロパティ + +| プロパティ | 型 | デフォルト | 説明 | +|-----------|------|----------|------| +| matrix | number[] | 単位行列 | 4x5カラー変換用の20個の要素を持つ配列 | + +### マトリックスのデフォルト値(単位行列) +```typescript +[ + 1, 0, 0, 0, 0, + 0, 1, 0, 0, 0, + 0, 0, 1, 0, 0, + 0, 0, 0, 1, 0 +] +``` + +--- + +## ConvolutionFilter + +マトリックス畳み込みフィルター効果を適用します。ぼかし、エッジ検出、シャープ、エンボス、ベベルなどの効果を実現できます。 + +```typescript +const { ConvolutionFilter } = next2d.filters; + +new ConvolutionFilter( + matrixX, matrixY, matrix, divisor, bias, + preserveAlpha, clamp, color, alpha +); +``` + +### プロパティ + +| プロパティ | 型 | デフォルト | 説明 | +|-----------|------|----------|------| +| alpha | number | 0 | 範囲外ピクセルのアルファ透明度(0〜1) | +| bias | number | 0 | マトリックス変換結果に加算するバイアス量 | +| clamp | boolean | true | イメージをクランプするかどうか | +| color | number | 0 | 範囲外ピクセルの置換色(0x000000〜0xFFFFFF) | +| divisor | number | 1 | マトリックス変換中の除数 | +| matrix | number[] \| null | null | マトリックス変換に使用する値の配列 | +| matrixX | number | 0 | マトリックスのX次元(列数、0〜15) | +| matrixY | number | 0 | マトリックスのY次元(行数、0〜15) | +| preserveAlpha | boolean | true | アルファチャンネルを維持するかどうか | + +--- + +## DisplacementMapFilter + +BitmapDataオブジェクトのピクセル値を使用して、オブジェクトの変位を実行します。 + +```typescript +const { DisplacementMapFilter } = next2d.filters; + +new DisplacementMapFilter( + bitmapBuffer, bitmapWidth, bitmapHeight, + mapPointX, mapPointY, componentX, componentY, + scaleX, scaleY, mode, color, alpha +); +``` + +### プロパティ + +| プロパティ | 型 | デフォルト | 説明 | +|-----------|------|----------|------| +| alpha | number | 0 | 範囲外変位のアルファ透明度(0〜1) | +| bitmapBuffer | Uint8Array \| null | null | 変位マップデータを含むバッファ | +| bitmapHeight | number | 0 | 変位マップデータの高さ | +| bitmapWidth | number | 0 | 変位マップデータの幅 | +| color | number | 0 | 範囲外変位に使用する色(0x000000〜0xFFFFFF) | +| componentX | number | 0 | X変位に使用するカラーチャンネル | +| componentY | number | 0 | Y変位に使用するカラーチャンネル | +| mapPointX | number | 0 | マップポイントのXオフセット | +| mapPointY | number | 0 | マップポイントのYオフセット | +| mode | string | "wrap" | フィルターモード("wrap"、"clamp"、"ignore"、"color") | +| scaleX | number | 0 | X変位結果の乗数(-65535〜65535) | +| scaleY | number | 0 | Y変位結果の乗数(-65535〜65535) | + +--- + +## GradientBevelFilter + +グラデーションベベル効果を適用します。グラデーションカラーで強調された斜めのエッジでオブジェクトを3次元的に見せます。 + +```typescript +const { GradientBevelFilter } = next2d.filters; + +new GradientBevelFilter( + distance, angle, colors, alphas, ratios, + blurX, blurY, strength, quality, type, knockout +); +``` + +### プロパティ + +| プロパティ | 型 | デフォルト | 説明 | +|-----------|------|----------|------| +| alphas | number[] \| null | null | カラー配列の各色に対応するアルファ値の配列(各値0〜1) | +| angle | number | 45 | ベベルの角度(-360〜360度) | +| blurX | number | 4 | 水平方向のぼかし量(0〜255) | +| blurY | number | 4 | 垂直方向のぼかし量(0〜255) | +| colors | number[] \| null | null | グラデーションで使用するRGB 16進数カラー値の配列 | +| distance | number | 4 | ベベルのオフセット距離(-255〜255ピクセル) | +| knockout | boolean | false | ノックアウト効果を適用するかどうか | +| quality | number | 1 | ぼかしの実行回数(0〜15) | +| ratios | number[] \| null | null | カラー配列の各色に対応する色分布比率の配列(各値0〜255) | +| strength | number | 1 | インプリントの強さ(0〜255) | +| type | string | "inner" | ベベルの配置("inner"、"outer"、"full") | + +--- + +## GradientGlowFilter + +グラデーショングロー効果を適用します。制御可能なカラーグラデーションによるリアルな輝きを表現できます。 + +```typescript +const { GradientGlowFilter } = next2d.filters; + +new GradientGlowFilter( + distance, angle, colors, alphas, ratios, + blurX, blurY, strength, quality, type, knockout +); +``` + +### プロパティ + +| プロパティ | 型 | デフォルト | 説明 | +|-----------|------|----------|------| +| alphas | number[] \| null | null | カラー配列の各色に対応するアルファ値の配列(各値0〜1) | +| angle | number | 45 | グローの角度(-360〜360度) | +| blurX | number | 4 | 水平方向のぼかし量(0〜255) | +| blurY | number | 4 | 垂直方向のぼかし量(0〜255) | +| colors | number[] \| null | null | グラデーションで使用するRGB 16進数カラー値の配列 | +| distance | number | 4 | グローのオフセット距離(-255〜255ピクセル) | +| knockout | boolean | false | ノックアウト効果を適用するかどうか | +| quality | number | 1 | ぼかしの実行回数(0〜15) | +| ratios | number[] \| null | null | カラー配列の各色に対応する色分布比率の配列(各値0〜255) | +| strength | number | 1 | インプリントの強さ(0〜255) | +| type | string | "outer" | グローの配置("inner"、"outer"、"full") | + +--- + +## 使用例 + +### ボタンのホバー効果 + +```typescript +const { Sprite } = next2d.display; +const { GlowFilter } = next2d.filters; + +const button = new Sprite(); + +button.addEventListener("rollOver", () => { + button.filters = [ + new GlowFilter(0x00ff00, 0.8, 10, 10) + ]; +}); + +button.addEventListener("rollOut", () => { + button.filters = null; +}); +``` + +### 影付きテキスト + +```typescript +const { TextField } = next2d.text; +const { DropShadowFilter } = next2d.filters; + +const textField = new TextField(); +textField.text = "Hello World"; +textField.filters = [ + new DropShadowFilter(2, 45, 0x000000, 0.5, 2, 2) +]; +``` + +### 複合フィルター + +```typescript +const { GlowFilter, DropShadowFilter, BlurFilter } = next2d.filters; + +sprite.filters = [ + // 外側のグロー + new GlowFilter(0x0088ff, 0.8, 15, 15, 2, 1, false), + // ドロップシャドウ + new DropShadowFilter(4, 45, 0x000000, 0.6, 4, 4), + // 軽いぼかし + new BlurFilter(1, 1, 1) +]; +``` + +### カラーマトリックスによるグレースケール + +```typescript +const { ColorMatrixFilter } = next2d.filters; + +// グレースケール変換マトリックス +const grayscaleMatrix = [ + 0.299, 0.587, 0.114, 0, 0, + 0.299, 0.587, 0.114, 0, 0, + 0.299, 0.587, 0.114, 0, 0, + 0, 0, 0, 1, 0 +]; + +sprite.filters = [new ColorMatrixFilter(grayscaleMatrix)]; +``` + +### グラデーショングロー効果 + +```typescript +const { GradientGlowFilter } = next2d.filters; + +sprite.filters = [ + new GradientGlowFilter( + 4, 45, + [0xff0000, 0x00ff00, 0x0000ff], // colors + [1, 1, 1], // alphas + [0, 128, 255], // ratios + 10, 10, 2, 1, "outer", false + ) +]; +``` + +--- + +## 関連項目 + +- [DisplayObject](/ja/reference/player/display-object) +- [MovieClip](/ja/reference/player/movie-clip) diff --git a/specs/ja/index.md b/specs/ja/index.md new file mode 100644 index 00000000..291010f6 --- /dev/null +++ b/specs/ja/index.md @@ -0,0 +1,200 @@ +# Next2D Player + +Next2D Playerは、WebGL/WebGPUを用いた高速2Dレンダリングエンジンです。Flash Playerのような機能をWeb上で実現し、ベクター描画、Tweenアニメーション、テキスト、音声、動画など、さまざまな要素をサポートしています。 + +## 主な特徴 + +- **高速レンダリング**: WebGL/WebGPUを活用した高速2D描画 +- **マルチプラットフォーム**: デスクトップからモバイルまで対応 +- **Flash互換API**: swf2jsから派生した馴染みやすいAPI設計 +- **豊富なフィルター**: Blur、DropShadow、Glow、Bevelなど多数のフィルターをサポート + +## レンダリングパイプライン + +Next2D Playerの高速レンダリングを実現するパイプラインの全体像です。 + +```mermaid +flowchart TB + %% Main Drawing Flow Chart + subgraph MainFlow["描画フローチャート - メインレンダリングパイプライン"] + direction TB + + subgraph Inputs["表示オブジェクト"] + Shape["Shape
(Bitmap/Vector)"] + TextField["TextField
(canvas2d)"] + Video["Video Element"] + end + + Shape --> MaskCheck + TextField --> MaskCheck + Video --> MaskCheck + + MaskCheck{"マスク
レンダリング?"} + + MaskCheck -->|YES| DirectRender["直接レンダリング"] + DirectRender -->|drawArrays| FinalRender + + MaskCheck -->|NO| CacheCheck1{"キャッシュ
あり?"} + + CacheCheck1 -->|NO| TextureAtlas["テクスチャアトラス
(二分木パッキング)"] + TextureAtlas --> Coordinates + + CacheCheck1 -->|YES| Coordinates["座標データベース
(x, y, w, h)"] + + Coordinates --> FilterBlendCheck{"フィルター or
ブレンド?"} + + FilterBlendCheck -->|NO| MainArrays + FilterBlendCheck -->|YES| NeedCache{"キャッシュ
あり?"} + + NeedCache -->|NO| CacheRender["キャッシュにレンダリング"] + CacheRender --> TextureCache + NeedCache -->|YES| TextureCache["テクスチャキャッシュ"] + + TextureCache -->|drawArrays| FinalRender + + MainArrays["インスタンス配列
━━━━━━━━━━━━━━━
matrix
colorTransform
Coordinates
━━━━━━━━━━━━━━━
バッチレンダリング"] + + MainArrays -->|drawArraysInstanced
複数オブジェクトを1回で描画| FinalRender["最終レンダリング"] + + FinalRender -->|60fps| MainFramebuffer["メインフレームバッファ
(ディスプレイ)"] + end + + %% Branch Flow for Filter/Blend/Mask + subgraph BranchFlow["フィルター/ブレンド/マスク - 分岐処理"] + direction TB + + subgraph FilterInputs["表示オブジェクト"] + Shape2["Shape
(Bitmap/Vector)"] + TextField2["TextField
(canvas2d)"] + Video2["Video Element"] + end + + Shape2 --> CacheCheck2 + TextField2 --> CacheCheck2 + Video2 --> CacheCheck2 + + CacheCheck2{"キャッシュ
あり?"} + + CacheCheck2 -->|NO| EffectRender["エフェクトレンダリング"] + CacheCheck2 -->|YES| BranchArrays + EffectRender --> BranchArrays + + BranchArrays["インスタンス配列
━━━━━━━━━━━━━━━
matrix
colorTransform
Coordinates
━━━━━━━━━━━━━━━
バッチレンダリング"] + + BranchArrays -->|drawArraysInstanced
複数オブジェクトを1回で描画| BranchRender["エフェクト結果"] + + BranchRender -->|filter/blend| TextureCache + end + + %% Connections between flows + FilterBlendCheck -.->|"分岐フローを
トリガー"| BranchFlow + BranchArrays -.->|"レンダリング情報
(座標)"| MainArrays +``` + +### パイプラインの特徴 + +- **バッチレンダリング**: 複数のオブジェクトを1回のGPUコールで描画 +- **テクスチャキャッシュ**: フィルターやブレンド効果を効率的に処理 +- **二分木パッキング**: テクスチャアトラスで最適なメモリ使用 +- **60fps描画**: 高フレームレートでのスムーズなアニメーション + +## DisplayListアーキテクチャ + +Next2D Playerは、Flash Playerと同様のDisplayListアーキテクチャを採用しています。 + +### 主要クラス階層 + +``` +DisplayObject (基底クラス) +├── InteractiveObject +│ ├── DisplayObjectContainer +│ │ ├── Sprite +│ │ ├── MovieClip +│ │ └── Stage +│ └── TextField +├── Shape +├── Video +└── Bitmap +``` + +### DisplayObjectContainer + +子オブジェクトを持つことができるコンテナクラス: + +- `addChild(child)`: 子要素を最前面に追加 +- `addChildAt(child, index)`: 指定インデックスに子要素を追加 +- `removeChild(child)`: 子要素を削除 +- `getChildAt(index)`: インデックスから子要素を取得 +- `getChildByName(name)`: 名前から子要素を取得 + +### MovieClip + +タイムラインアニメーションを持つDisplayObject: + +- `play()`: タイムラインを再生 +- `stop()`: タイムラインを停止 +- `gotoAndPlay(frame)`: 指定フレームに移動して再生 +- `gotoAndStop(frame)`: 指定フレームに移動して停止 +- `currentFrame`: 現在のフレーム番号 +- `totalFrames`: 総フレーム数 + +## 基本的な使い方 + +```typescript +const { MovieClip } = next2d.display; +const { DropShadowFilter } = next2d.filters; + +// ルートMovieClipを作成 +const root = await next2d.createRootMovieClip(800, 600, 60, { + tagId: "container", + bgColor: "#ffffff" +}); + +// MovieClipの作成 +const mc = new MovieClip(); +root.addChild(mc); + +// 位置とサイズの設定 +mc.x = 100; +mc.y = 100; +mc.scaleX = 2; +mc.scaleY = 2; +mc.rotation = 45; + +// フィルターの適用 +mc.filters = [ + new DropShadowFilter(4, 45, 0x000000, 0.5) +]; +``` + +## JSONデータの読み込み + +Open Animation Toolで作成したJSONファイルを読み込んで描画: + +```typescript +const { Loader } = next2d.display; +const { URLRequest } = next2d.net; + +const loader = new Loader(); +await loader.load(new URLRequest("animation.json")); + +// 読み込み完了後、直接contentにアクセス +const mc = loader.content; +stage.addChild(mc); +``` + +## 関連ドキュメント + +### 表示オブジェクト +- [DisplayObject](/ja/reference/player/display-object) - 全ての表示オブジェクトの基底クラス +- [MovieClip](/ja/reference/player/movie-clip) - タイムラインアニメーション +- [Sprite](/ja/reference/player/sprite) - グラフィックス描画とインタラクション +- [Shape](/ja/reference/player/shape) - 軽量なベクター描画 +- [TextField](/ja/reference/player/text-field) - テキスト表示と入力 +- [Video](/ja/reference/player/video) - 動画再生 + +### システム +- [イベントシステム](/ja/reference/player/events) - マウス、キーボード、タッチイベント +- [フィルター](/ja/reference/player/filters) - Blur、DropShadow、Glowなど +- [サウンド](/ja/reference/player/sound) - 音声再生とサウンドエフェクト +- [Tweenアニメーション](/ja/reference/player/tween) - プログラムによるアニメーション diff --git a/specs/ja/movie-clip.md b/specs/ja/movie-clip.md new file mode 100644 index 00000000..981b124d --- /dev/null +++ b/specs/ja/movie-clip.md @@ -0,0 +1,244 @@ +# MovieClip + +MovieClipは、タイムラインアニメーションを持つDisplayObjectContainerです。Open Animation Toolで作成したアニメーションはMovieClipとして再生されます。 + +## 継承関係 + +```mermaid +classDiagram + DisplayObject <|-- InteractiveObject + InteractiveObject <|-- DisplayObjectContainer + DisplayObjectContainer <|-- Sprite + Sprite <|-- MovieClip + + class DisplayObject { + +x: Number + +y: Number + +visible: Boolean + } + class MovieClip { + +currentFrame: Number + +totalFrames: Number + +play() + +stop() + +gotoAndPlay() + } +``` + +## プロパティ + +### MovieClip固有のプロパティ + +| プロパティ | 型 | 説明 | +|-----------|------|------| +| `currentFrame` | `number` | MovieClipのタイムライン内の再生ヘッドが置かれているフレームの番号(1から開始、読み取り専用) | +| `totalFrames` | `number` | MovieClipインスタンス内のフレーム総数(読み取り専用) | +| `currentFrameLabel` | `FrameLabel \| null` | MovieClipインスタンスのタイムライン内の現在のフレームにあるラベル(読み取り専用) | +| `currentLabels` | `FrameLabel[] \| null` | 現在のシーンのFrameLabelオブジェクトの配列を返す(読み取り専用) | +| `isPlaying` | `boolean` | ムービークリップが現在再生されているかどうかを示すブール値(読み取り専用) | +| `isTimelineEnabled` | `boolean` | MovieClipの機能を所持しているかを返却(読み取り専用) | + +### DisplayObjectContainerから継承したプロパティ + +| プロパティ | 型 | 説明 | +|-----------|------|------| +| `numChildren` | `number` | このオブジェクトの子の数を返す(読み取り専用) | +| `mouseChildren` | `boolean` | オブジェクトの子がマウスまたはユーザー入力デバイスに対応しているかどうかを判断する | +| `mask` | `DisplayObject \| null` | 呼び出し元の表示オブジェクトをマスクする指定されたマスクオブジェクト | +| `isContainerEnabled` | `boolean` | コンテナの機能を所持しているかを返却(読み取り専用) | + +## メソッド + +### MovieClip固有のメソッド + +| メソッド | 戻り値 | 説明 | +|---------|--------|------| +| `play()` | `void` | ムービークリップのタイムライン内で再生ヘッドを移動する | +| `stop()` | `void` | ムービークリップ内の再生ヘッドを停止する | +| `gotoAndPlay(frame: string \| number)` | `void` | 指定されたフレームで再生を開始する | +| `gotoAndStop(frame: string \| number)` | `void` | 指定されたフレームに再生ヘッドを送り、そこで停止させる | +| `nextFrame()` | `void` | 次のフレームに再生ヘッドを送り、停止する | +| `prevFrame()` | `void` | 直前のフレームに再生ヘッドを戻し、停止する | +| `addFrameLabel(frame_label: FrameLabel)` | `void` | タイムラインに対して動的にLabelを追加する | + +### DisplayObjectContainerから継承したメソッド + +| メソッド | 戻り値 | 説明 | +|---------|--------|------| +| `addChild(display_object: DisplayObject)` | `DisplayObject` | このDisplayObjectContainerインスタンスに子DisplayObjectインスタンスを追加する | +| `addChildAt(display_object: DisplayObject, index: number)` | `DisplayObject` | 指定したインデックス位置に子DisplayObjectインスタンスを追加する | +| `removeChild(display_object: DisplayObject)` | `void` | 子リストから指定のDisplayObjectインスタンスを削除する | +| `removeChildAt(index: number)` | `void` | 子リストの指定されたインデックス位置から子DisplayObjectを削除する | +| `removeChildren(...indexes: number[])` | `void` | 配列で指定されたインデックスの子をコンテナから削除する | +| `getChildAt(index: number)` | `DisplayObject \| null` | 指定のインデックス位置にある子表示オブジェクトインスタンスを返す | +| `getChildByName(name: string)` | `DisplayObject \| null` | 指定された名前に一致する子表示オブジェクトを返す | +| `getChildIndex(display_object: DisplayObject)` | `number` | 子DisplayObjectインスタンスのインデックス位置を返す | +| `contains(display_object: DisplayObject)` | `boolean` | 指定されたDisplayObjectがインスタンスの子孫か、インスタンス自体かを指定する | +| `setChildIndex(display_object: DisplayObject, index: number)` | `void` | 表示オブジェクトコンテナの既存の子の位置を変更する | +| `swapChildren(display_object1: DisplayObject, display_object2: DisplayObject)` | `void` | 指定された2つの子オブジェクトのz順序(重ね順)を入れ替える | +| `swapChildrenAt(index1: number, index2: number)` | `void` | 指定されたインデックス位置に該当する2つの子オブジェクトのz順序を入れ替える | + +## イベント + +### enterFrame + +各フレームで発生するイベント: + +```typescript +movieClip.addEventListener("enterFrame", (event) => { + console.log("フレーム:", movieClip.currentFrame); +}); +``` + +### frameConstructed + +フレームの構築が完了したときに発生: + +```typescript +movieClip.addEventListener("frameConstructed", (event) => { + // フレームスクリプトの実行前 +}); +``` + +### exitFrame + +フレームを離れるときに発生: + +```typescript +movieClip.addEventListener("exitFrame", (event) => { + // 次のフレームへ移動する前 +}); +``` + +## 使用例 + +### 基本的なアニメーション制御 + +```typescript +const { Loader, Sprite } = next2d.display; +const { URLRequest } = next2d.net; + +// JSONからMovieClipを読み込み +const loader = new Loader(); +await loader.load(new URLRequest("animation.json")); + +const mc = loader.content; +stage.addChild(mc); + +// 最初は停止 +mc.stop(); + +// ボタンクリックで再生 +button.addEventListener("click", () => { + if (mc.isPlaying) { + mc.stop(); + } else { + mc.play(); + } +}); +``` + +### フレームラベルを使った制御 + +```typescript +// ラベル位置に移動 +mc.gotoAndStop("idle"); + +// 状態変更 +function changeState(state) { + switch (state) { + case "idle": + mc.gotoAndPlay("idle"); + break; + case "walk": + mc.gotoAndPlay("walk_start"); + break; + case "attack": + mc.gotoAndPlay("attack"); + break; + } +} +``` + +### ネストしたMovieClipの制御 + +```typescript +// 子MovieClipへのアクセス +const childMc = mc.getChildByName("character"); +childMc.gotoAndPlay("run"); + +// 孫MovieClipへのアクセス +const grandChild = mc.character.arm; +grandChild.play(); +``` + +### 子オブジェクトの操作 + +```typescript +// 子オブジェクトを追加 +const sprite = new Sprite(); +mc.addChild(sprite); + +// 特定のインデックスに追加 +mc.addChildAt(sprite, 0); + +// 子オブジェクトを削除 +mc.removeChild(sprite); + +// インデックスで削除 +mc.removeChildAt(0); + +// 複数の子を削除 +mc.removeChildren(0, 1, 2); + +// 子オブジェクトの取得 +const child = mc.getChildAt(0); +const namedChild = mc.getChildByName("myChild"); + +// 子のインデックスを取得 +const index = mc.getChildIndex(sprite); + +// 子のインデックスを変更 +mc.setChildIndex(sprite, 2); + +// 子の順序を入れ替え +mc.swapChildren(sprite1, sprite2); +mc.swapChildrenAt(0, 1); +``` + +### フレームラベルの動的追加 + +```typescript +const { FrameLabel } = next2d.display; + +// 新しいラベルを作成して追加 +const label = new FrameLabel("myLabel", 10); +mc.addFrameLabel(label); + +// ラベルを使って移動 +mc.gotoAndPlay("myLabel"); +``` + +### フレームレートの変更 + +```typescript +// ステージ全体のフレームレートを変更 +stage.frameRate = 30; +``` + +## FrameLabel + +フレームラベルの情報を持つクラス: + +```typescript +// 現在のシーンのすべてのラベルを取得 +const labels = mc.currentLabels; +labels.forEach((label) => { + console.log(`${label.name}: フレーム ${label.frame}`); +}); +``` + +## 関連項目 + +- [Sprite](/ja/reference/player/sprite) +- [イベントシステム](/ja/reference/player/events) diff --git a/specs/ja/shape.md b/specs/ja/shape.md new file mode 100644 index 00000000..a1c33589 --- /dev/null +++ b/specs/ja/shape.md @@ -0,0 +1,442 @@ +# Shape + +Shapeは、ベクターグラフィックスの描画専用クラスです。Spriteと異なり子オブジェクトを持てませんが、軽量でパフォーマンスに優れています。 + +## 継承関係 + +```mermaid +classDiagram + DisplayObject <|-- Shape + + class Shape { + +graphics: Graphics + } +``` + +## プロパティ + +| プロパティ | 型 | 説明 | +|-----------|------|------| +| `graphics` | Graphics | この Shape オブジェクトに描画されるベクターの描画コマンドを保持する Graphics オブジェクト(読み取り専用) | +| `isShape` | boolean | Shapeの機能を所持しているかを返却(読み取り専用) | +| `cacheKey` | number | ビルドされたキャッシュキー | +| `cacheParams` | number[] | キャッシュのビルドに利用されるパラメータ(読み取り専用) | +| `isBitmap` | boolean | ビットマップ描画の判定フラグ | +| `src` | string | 指定されたパスから画像を読み込み、Graphicsを生成する | +| `bitmapData` | BitmapData | ビットマップデータを返却(読み取り専用) | +| `namespace` | string | 指定されたオブジェクトの空間名を返却(読み取り専用) | + +## メソッド + +| メソッド | 戻り値 | 説明 | +|---------|--------|------| +| `load(url: string)` | Promise\ | 指定されたURLから画像を非同期で読み込み、Graphicsを生成する | +| `clearBitmapBuffer()` | void | ビットマップデータを解放する | +| `setBitmapBuffer(width: number, height: number, buffer: Uint8Array)` | void | RGBAの画像データを設定する | + +## SpriteとShapeの違い + +| 特徴 | Shape | Sprite | +|------|-------|--------| +| 子オブジェクト | 持てない | 持てる | +| インタラクション | なし | クリック等可能 | +| パフォーマンス | 軽量 | やや重い | +| 用途 | 静的な背景、装飾 | ボタン、コンテナ | + +## 使用例 + +### 基本的な描画 + +```typescript +const { Shape } = next2d.display; + +const shape = new Shape(); + +// 塗りつぶし矩形 +shape.graphics.beginFill(0x3498db); +shape.graphics.drawRect(0, 0, 150, 100); +shape.graphics.endFill(); + +stage.addChild(shape); +``` + +### 複合図形の描画 + +```typescript +const { Shape } = next2d.display; + +const shape = new Shape(); +const g = shape.graphics; + +// 背景 +g.beginFill(0xecf0f1); +g.drawRoundRect(0, 0, 200, 150, 10, 10); +g.endFill(); + +// 枠線 +g.lineStyle(2, 0x2c3e50); +g.drawRoundRect(0, 0, 200, 150, 10, 10); + +// 内側の装飾 +g.beginFill(0xe74c3c); +g.drawCircle(100, 75, 30); +g.endFill(); + +stage.addChild(shape); +``` + +### パスの描画 + +```typescript +const { Shape } = next2d.display; + +const shape = new Shape(); +const g = shape.graphics; + +g.beginFill(0x9b59b6); + +// 星形を描画 +g.moveTo(50, 0); +g.lineTo(61, 35); +g.lineTo(98, 35); +g.lineTo(68, 57); +g.lineTo(79, 91); +g.lineTo(50, 70); +g.lineTo(21, 91); +g.lineTo(32, 57); +g.lineTo(2, 35); +g.lineTo(39, 35); +g.lineTo(50, 0); + +g.endFill(); + +stage.addChild(shape); +``` + +### ベジェ曲線 + +```typescript +const { Shape } = next2d.display; + +const shape = new Shape(); +const g = shape.graphics; + +g.lineStyle(3, 0x1abc9c); + +// 二次ベジェ曲線 +g.moveTo(0, 100); +g.curveTo(50, 0, 100, 100); // 制御点, 終点 + +g.curveTo(150, 200, 200, 100); + +stage.addChild(shape); +``` + +### グラデーション背景 + +```typescript +const { Shape } = next2d.display; +const { Matrix } = next2d.geom; + +const shape = new Shape(); +const g = shape.graphics; + +// グラデーション用マトリックス +const matrix = new Matrix(); +matrix.createGradientBox( + stage.stageWidth, + stage.stageHeight, + Math.PI / 2, // 90度(縦方向) + 0, 0 +); + +// 放射状グラデーション +g.beginGradientFill( + "radial", + [0x667eea, 0x764ba2], + [1, 1], + [0, 255], + matrix +); +g.drawRect(0, 0, stage.stageWidth, stage.stageHeight); +g.endFill(); + +// 最背面に配置 +stage.addChildAt(shape, 0); +``` + +### 動的な再描画 + +```typescript +const { Shape } = next2d.display; + +const shape = new Shape(); +stage.addChild(shape); + +let angle = 0; + +// フレームごとに再描画 +stage.addEventListener("enterFrame", () => { + const g = shape.graphics; + + // 前の描画をクリア + g.clear(); + + // 新しい位置に描画 + const x = 200 + Math.cos(angle) * 100; + const y = 150 + Math.sin(angle) * 100; + + g.beginFill(0xe74c3c); + g.drawCircle(x, y, 20); + g.endFill(); + + angle += 0.05; +}); +``` + +### 複数のShapeで構成 + +```typescript +const { Shape } = next2d.display; + +// 背景レイヤー +const bgShape = new Shape(); +bgShape.graphics.beginFill(0x2c3e50); +bgShape.graphics.drawRect(0, 0, 400, 300); +bgShape.graphics.endFill(); + +// 装飾レイヤー +const decorShape = new Shape(); +decorShape.graphics.beginFill(0x3498db, 0.5); +decorShape.graphics.drawCircle(100, 100, 80); +decorShape.graphics.drawCircle(300, 200, 60); +decorShape.graphics.endFill(); + +// 前面レイヤー +const frontShape = new Shape(); +frontShape.graphics.lineStyle(2, 0xecf0f1); +frontShape.graphics.drawRect(50, 50, 300, 200); + +stage.addChild(bgShape); +stage.addChild(decorShape); +stage.addChild(frontShape); +``` + +## パフォーマンスのヒント + +1. **静的な描画にはShapeを使用**: インタラクションが不要な背景や装飾にはShapeが最適 +2. **描画の最小化**: 頻繁に変更されない場合は一度だけ描画 +3. **clear()の使用**: 動的な再描画時は必ずclear()を呼ぶ +4. **複雑な図形はキャッシュ**: cacheAsBitmapプロパティで描画をキャッシュ + +```typescript +// 複雑な図形をビットマップとしてキャッシュ +shape.cacheAsBitmap = true; +``` + +## Graphics クラス + +Graphicsクラスは、ベクターグラフィックスを描画するための描画APIを提供します。Shape.graphicsプロパティを通じてアクセスします。 + +### 塗りつぶしメソッド + +| メソッド | 説明 | +|---------|------| +| `beginFill(color: number, alpha?: number)` | 単色の塗りつぶしを開始。alphaのデフォルトは1 | +| `beginGradientFill(type, colors, alphas, ratios, matrix?, spreadMethod?, interpolationMethod?, focalPointRatio?)` | グラデーション塗りつぶしを開始 | +| `beginBitmapFill(bitmapData, matrix?, repeat?, smooth?)` | ビットマップ塗りつぶしを開始 | +| `endFill()` | 塗りつぶしを終了 | + +#### beginGradientFill パラメータ + +| パラメータ | 型 | 説明 | +|-----------|------|------| +| `type` | string | "linear" または "radial" | +| `colors` | number[] | 色の配列(16進数) | +| `alphas` | number[] | 各色の透明度(0-1) | +| `ratios` | number[] | 各色の位置(0-255) | +| `matrix` | Matrix | グラデーションの変形マトリックス | +| `spreadMethod` | string | "pad", "reflect", "repeat"(デフォルト: "pad") | +| `interpolationMethod` | string | "rgb" または "linearRGB"(デフォルト: "rgb") | +| `focalPointRatio` | number | 放射状グラデーションの焦点位置(-1 to 1) | + +### 線スタイルメソッド + +| メソッド | 説明 | +|---------|------| +| `lineStyle(thickness?, color?, alpha?, pixelHinting?, scaleMode?, caps?, joints?, miterLimit?)` | 線のスタイルを設定 | +| `lineGradientStyle(type, colors, alphas, ratios, matrix?, spreadMethod?, interpolationMethod?, focalPointRatio?)` | グラデーション線スタイルを設定 | +| `lineBitmapStyle(bitmapData, matrix?, repeat?, smooth?)` | ビットマップ線スタイルを設定 | +| `endLine()` | 線スタイルを終了 | + +#### lineStyle パラメータ + +| パラメータ | 型 | デフォルト | 説明 | +|-----------|------|---------|------| +| `thickness` | number | 0 | 線の太さ(ピクセル) | +| `color` | number | 0 | 線の色(16進数) | +| `alpha` | number | 1 | 透明度(0-1) | +| `pixelHinting` | boolean | false | ピクセルスナップ | +| `scaleMode` | string | "normal" | "normal", "none", "vertical", "horizontal" | +| `caps` | string | null | "none", "round", "square" | +| `joints` | string | null | "bevel", "miter", "round" | +| `miterLimit` | number | 3 | マイター結合の限界値 | + +### パスメソッド + +| メソッド | 説明 | +|---------|------| +| `moveTo(x: number, y: number)` | 描画位置を移動 | +| `lineTo(x: number, y: number)` | 現在位置から指定座標まで直線を描画 | +| `curveTo(controlX, controlY, anchorX, anchorY)` | 二次ベジェ曲線を描画 | +| `cubicCurveTo(controlX1, controlY1, controlX2, controlY2, anchorX, anchorY)` | 三次ベジェ曲線を描画 | + +### 図形メソッド + +| メソッド | 説明 | +|---------|------| +| `drawRect(x, y, width, height)` | 矩形を描画 | +| `drawRoundRect(x, y, width, height, ellipseWidth, ellipseHeight?)` | 角丸矩形を描画 | +| `drawCircle(x, y, radius)` | 円を描画 | +| `drawEllipse(x, y, width, height)` | 楕円を描画 | + +### ユーティリティメソッド + +| メソッド | 説明 | +|---------|------| +| `clear()` | すべての描画コマンドをクリア | +| `clone()` | Graphicsオブジェクトを複製 | +| `copyFrom(source: Graphics)` | 別のGraphicsから描画コマンドをコピー | + +### 詳細な使用例 + +#### 線形グラデーション + +```typescript +const { Shape } = next2d.display; +const { Matrix } = next2d.geom; + +const shape = new Shape(); +const g = shape.graphics; + +const matrix = new Matrix(); +matrix.createGradientBox(200, 100, 0, 0, 0); // 幅, 高さ, 回転, x, y + +g.beginGradientFill( + "linear", // タイプ + [0xff0000, 0x00ff00, 0x0000ff], // 色 + [1, 1, 1], // 透明度 + [0, 127, 255], // 比率 + matrix +); +g.drawRect(0, 0, 200, 100); +g.endFill(); + +stage.addChild(shape); +``` + +#### 三次ベジェ曲線 + +```typescript +const { Shape } = next2d.display; + +const shape = new Shape(); +const g = shape.graphics; + +g.lineStyle(2, 0x3498db); + +// 滑らかなS字曲線 +g.moveTo(0, 100); +g.cubicCurveTo( + 50, 0, // 制御点1 + 150, 200, // 制御点2 + 200, 100 // 終点 +); + +stage.addChild(shape); +``` + +#### ビットマップ塗りつぶし + +```typescript +const { Shape, Loader } = next2d.display; + +const loader = new Loader(); +await loader.load("texture.png"); + +const bitmapData = loader.contentLoaderInfo + .content.bitmapData; + +const shape = new Shape(); +const g = shape.graphics; + +g.beginBitmapFill(bitmapData, null, true, true); +g.drawRect(0, 0, 400, 300); +g.endFill(); + +stage.addChild(shape); +``` + +#### グラデーション線 + +```typescript +const { Shape } = next2d.display; +const { Matrix } = next2d.geom; + +const shape = new Shape(); +const g = shape.graphics; + +const matrix = new Matrix(); +matrix.createGradientBox(200, 200, 0, 0, 0); + +g.lineGradientStyle( + "linear", + [0xff0000, 0x0000ff], + [1, 1], + [0, 255], + matrix +); +g.lineStyle(5); + +g.moveTo(10, 10); +g.lineTo(190, 10); +g.lineTo(190, 190); +g.lineTo(10, 190); +g.lineTo(10, 10); + +stage.addChild(shape); +``` + +#### 複雑な図形の組み合わせ + +```typescript +const { Shape } = next2d.display; + +const shape = new Shape(); +const g = shape.graphics; + +// 外側の矩形(塗りつぶし) +g.beginFill(0x2c3e50); +g.drawRoundRect(0, 0, 200, 150, 15, 15); +g.endFill(); + +// 内側の円(別の色で塗りつぶし) +g.beginFill(0xe74c3c); +g.drawCircle(100, 75, 40); +g.endFill(); + +// 装飾線 +g.lineStyle(2, 0xecf0f1); +g.moveTo(20, 20); +g.lineTo(180, 20); +g.moveTo(20, 130); +g.lineTo(180, 130); + +stage.addChild(shape); +``` + +## 関連項目 + +- [DisplayObject](/ja/reference/player/display-object) +- [Sprite](/ja/reference/player/sprite) +- [フィルター](/ja/reference/player/filters) diff --git a/specs/ja/sound.md b/specs/ja/sound.md new file mode 100644 index 00000000..132620e6 --- /dev/null +++ b/specs/ja/sound.md @@ -0,0 +1,290 @@ +# サウンド + +Next2D Playerは、ゲームやアプリケーションで必要な音声機能を提供します。BGM、効果音、ボイスなど様々な用途に対応しています。 + +## クラス構成 + +```mermaid +classDiagram + EventDispatcher <|-- Sound + class Sound { + +audioBuffer: AudioBuffer + +volume: Number + +loopCount: Number + +load(request): Promise + +play(startTime): void + +stop(): void + +clone(): Sound + } + class SoundMixer { + +volume: Number + +stopAll(): void + } +``` + +## Sound + +音声ファイルを読み込み再生するクラスです。EventDispatcherを継承しています。 + +### プロパティ + +| プロパティ | 型 | デフォルト | 読み取り専用 | 説明 | +|-----------|------|----------|:------------:|------| +| `audioBuffer` | AudioBuffer \| null | null | - | オーディオバッファ。load()で読み込んだ音声データが格納されます | +| `loopCount` | number | 0 | - | ループ回数の設定。0でループなし、9999で実質無限ループ | +| `volume` | number | 1 | - | ボリューム。範囲は0(無音)〜1(フルボリューム)。SoundMixer.volumeの値を超えることはできません | +| `canLoop` | boolean | - | ○ | サウンドがループするかどうかを示します | + +### メソッド + +| メソッド | 戻り値 | 説明 | +|---------|--------|------| +| `clone()` | Sound | Soundクラスを複製します。volume、loopCount、audioBufferがコピーされます | +| `load(request: URLRequest)` | Promise\ | 指定したURLから外部MP3ファイルのロードを開始します | +| `play(startTime: number = 0)` | void | サウンドを再生します。startTimeは再生開始時間(秒単位)です。既に再生中の場合は何もしません | +| `stop()` | void | チャンネルで再生しているサウンドを停止します | + +## 使用例 + +### 基本的な音声再生 + +```typescript +const { Sound } = next2d.media; +const { URLRequest } = next2d.net; + +// Soundオブジェクトを作成 +const sound = new Sound(); + +// 音声ファイルを非同期で読み込み +const request = new URLRequest("bgm.mp3"); +await sound.load(request); + +// 再生開始 +sound.play(); +``` + +### 効果音の再生 + +```typescript +const { Sound } = next2d.media; +const { URLRequest } = next2d.net; + +// 効果音をプリロード +const seJump = new Sound(); +const seHit = new Sound(); +const seCoin = new Sound(); + +// 読み込み +await seJump.load(new URLRequest("se/jump.mp3")); +await seHit.load(new URLRequest("se/hit.mp3")); +await seCoin.load(new URLRequest("se/coin.mp3")); + +// 再生関数 +function playSE(sound) { + // 複製して再生(同時に複数回鳴らす場合) + const clone = sound.clone(); + clone.play(); +} + +// ゲーム中で使用 +player.addEventListener("jump", () => { + playSE(seJump); +}); +``` + +### BGMのループ再生 + +```typescript +const { Sound } = next2d.media; +const { URLRequest } = next2d.net; + +const bgm = new Sound(); + +// 読み込み +await bgm.load(new URLRequest("bgm/stage1.mp3")); + +// 音量を設定 +bgm.volume = 0.7; // 70% + +// ループ回数を設定(9999で実質無限ループ) +bgm.loopCount = 9999; + +// 再生 +bgm.play(); + +// BGM停止 +function stopBGM() { + bgm.stop(); +} +``` + +### 音量コントロール + +```typescript +const { Sound } = next2d.media; +const { URLRequest } = next2d.net; + +const bgm = new Sound(); +await bgm.load(new URLRequest("bgm.mp3")); + +// 音量を設定 +bgm.volume = 1.0; +bgm.play(); + +// 音量を変更 +function setVolume(volume) { + bgm.volume = Math.max(0, Math.min(1, volume)); +} + +// フェードアウト +async function fadeOut(duration = 1000) { + const startVolume = bgm.volume; + const startTime = Date.now(); + + return new Promise((resolve) => { + const fade = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(1, elapsed / duration); + + bgm.volume = startVolume * (1 - progress); + + if (progress >= 1) { + bgm.stop(); + resolve(); + } else { + requestAnimationFrame(fade); + } + }; + fade(); + }); +} +``` + +### サウンドマネージャー + +```typescript +const { Sound, SoundMixer } = next2d.media; +const { URLRequest } = next2d.net; + +class SoundManager { + constructor() { + this._sounds = new Map(); + this._bgm = null; + this._bgmVolume = 0.7; + this._seVolume = 1.0; + this._isMuted = false; + } + + // サウンドをプリロード + async preload(id, url) { + const sound = new Sound(); + await sound.load(new URLRequest(url)); + this._sounds.set(id, sound); + } + + // BGM再生 + playBGM(id, loops = 9999) { + this.stopBGM(); + + const sound = this._sounds.get(id); + if (sound) { + this._bgm = sound.clone(); + this._bgm.volume = this._isMuted ? 0 : this._bgmVolume; + this._bgm.loopCount = loops; + this._bgm.play(); + } + } + + // BGM停止 + stopBGM() { + if (this._bgm) { + this._bgm.stop(); + this._bgm = null; + } + } + + // SE再生 + playSE(id) { + const sound = this._sounds.get(id); + if (sound) { + const clone = sound.clone(); + clone.volume = this._isMuted ? 0 : this._seVolume; + clone.play(); + } + } + + // ミュート切り替え + toggleMute() { + this._isMuted = !this._isMuted; + if (this._bgm) { + this._bgm.volume = this._isMuted ? 0 : this._bgmVolume; + } + return this._isMuted; + } + + // BGM音量設定 + setBGMVolume(volume) { + this._bgmVolume = Math.max(0, Math.min(1, volume)); + if (this._bgm && !this._isMuted) { + this._bgm.volume = this._bgmVolume; + } + } + + // SE音量設定 + setSEVolume(volume) { + this._seVolume = Math.max(0, Math.min(1, volume)); + } +} + +// 使用例 +const soundManager = new SoundManager(); + +// 起動時にプリロード +async function initSounds() { + await soundManager.preload("bgm_title", "bgm/title.mp3"); + await soundManager.preload("bgm_stage1", "bgm/stage1.mp3"); + await soundManager.preload("se_jump", "se/jump.mp3"); + await soundManager.preload("se_coin", "se/coin.mp3"); + await soundManager.preload("se_damage", "se/damage.mp3"); +} + +// ゲーム中 +soundManager.playBGM("bgm_stage1"); +soundManager.playSE("se_jump"); +``` + +## SoundMixer + +全体のサウンドを制御するクラスです。 + +```typescript +const { SoundMixer } = next2d.media; + +// 全ての音声を停止 +SoundMixer.stopAll(); + +// 全体の音量を変更 +SoundMixer.volume = 0.5; // 50% +``` + +## サポートフォーマット + +| フォーマット | 拡張子 | 対応状況 | +|--------------|--------|----------| +| MP3 | .mp3 | 推奨 | +| AAC | .m4a, .aac | 対応 | +| Ogg Vorbis | .ogg | ブラウザ依存 | +| WAV | .wav | 対応(ファイルサイズ大) | + +## ベストプラクティス + +1. **プリロード**: ゲーム開始前に全ての音声をプリロード +2. **フォーマット**: MP3を推奨(互換性と圧縮率のバランス) +3. **効果音**: 短い音声はWAVでも可(レイテンシが低い) +4. **音量管理**: BGMとSEの音量を別々に管理 +5. **モバイル対応**: ユーザーインタラクション後に再生開始 +6. **clone使用**: 同じ音を同時に複数回再生する場合はclone()を使用 + +## 関連項目 + +- [イベントシステム](/ja/reference/player/events) diff --git a/specs/ja/sprite.md b/specs/ja/sprite.md new file mode 100644 index 00000000..8ca7e5b3 --- /dev/null +++ b/specs/ja/sprite.md @@ -0,0 +1,238 @@ +# Sprite + +SpriteはDisplayObjectContainerです。MovieClipの基底クラスであり、タイムラインを持たない動的なオブジェクト管理に使用します。 + +## 継承関係 + +```mermaid +classDiagram + DisplayObject <|-- InteractiveObject + InteractiveObject <|-- DisplayObjectContainer + DisplayObjectContainer <|-- Sprite + Sprite <|-- MovieClip + + class Sprite { + +buttonMode: Boolean + +useHandCursor: Boolean + } +``` + +## プロパティ + +### Sprite固有のプロパティ + +| プロパティ | 型 | 読取専用 | デフォルト | 説明 | +|-----------|------|:--------:|------------|------| +| `isSprite` | boolean | Yes | true | Spriteの機能を所持しているかを返却 | +| `buttonMode` | boolean | No | false | このスプライトのボタンモードを指定します | +| `useHandCursor` | boolean | No | true | buttonModeがtrueの場合にハンドカーソルを表示するかどうか | +| `hitArea` | Sprite \| null | No | null | スプライトのヒット領域となる別のスプライトを指定します | +| `soundTransform` | SoundTransform \| null | No | null | このスプライト内のサウンドを制御します | + +### DisplayObjectContainerから継承されるプロパティ + +| プロパティ | 型 | 読取専用 | デフォルト | 説明 | +|-----------|------|:--------:|------------|------| +| `isContainerEnabled` | boolean | Yes | true | コンテナの機能を所持しているかを返却 | +| `mouseChildren` | boolean | No | true | オブジェクトの子がマウスまたはユーザー入力デバイスに対応しているかどうか | +| `numChildren` | number | Yes | - | このオブジェクトの子の数を返します | +| `mask` | DisplayObject \| null | No | null | 表示オブジェクトをマスクします | + +### InteractiveObjectから継承されるプロパティ + +| プロパティ | 型 | 読取専用 | デフォルト | 説明 | +|-----------|------|:--------:|------------|------| +| `isInteractive` | boolean | Yes | true | InteractiveObjectの機能を所持しているかを返却 | +| `mouseEnabled` | boolean | No | true | このオブジェクトでマウスまたはその他のユーザー入力メッセージを受け取るかどうか | + +### DisplayObjectから継承されるプロパティ + +| プロパティ | 型 | 読取専用 | デフォルト | 説明 | +|-----------|------|:--------:|------------|------| +| `instanceId` | number | Yes | - | DisplayObjectのユニークなインスタンスID | +| `name` | string | No | "" | 名前を返却します。getChildByName()で使用されます | +| `parent` | Sprite \| MovieClip \| null | No | null | このDisplayObjectの親のDisplayObjectContainerを返却 | +| `x` | number | No | 0 | 親DisplayObjectContainerのローカル座標を基準にしたx座標 | +| `y` | number | No | 0 | 親DisplayObjectContainerのローカル座標を基準にしたy座標 | +| `width` | number | No | - | 表示オブジェクトの幅(ピクセル単位) | +| `height` | number | No | - | 表示オブジェクトの高さ(ピクセル単位) | +| `scaleX` | number | No | 1 | 基準点から適用されるオブジェクトの水平スケール値 | +| `scaleY` | number | No | 1 | 基準点から適用されるオブジェクトの垂直スケール値 | +| `rotation` | number | No | 0 | DisplayObjectインスタンスの元の位置からの回転角(度単位) | +| `alpha` | number | No | 1 | 指定されたオブジェクトのアルファ透明度値(0.0〜1.0) | +| `visible` | boolean | No | true | 表示オブジェクトが可視かどうか | +| `blendMode` | string | No | "normal" | 使用するブレンドモードを指定するBlendModeクラスの値 | +| `filters` | array \| null | No | null | 表示オブジェクトに関連付けられた各フィルターオブジェクトの配列 | +| `matrix` | Matrix | No | - | 表示オブジェクトのMatrixを返します | +| `colorTransform` | ColorTransform | No | - | 表示オブジェクトのColorTransformを返します | +| `concatenatedMatrix` | Matrix | Yes | - | この表示オブジェクトとすべての親オブジェクトの結合されたMatrix | +| `scale9Grid` | Rectangle \| null | No | null | 現在有効な拡大/縮小グリッド | +| `loaderInfo` | LoaderInfo \| null | Yes | null | この表示オブジェクトが属するファイルの読み込み情報 | +| `root` | MovieClip \| Sprite \| null | Yes | null | DisplayObjectのルートであるDisplayObjectContainer | +| `mouseX` | number | Yes | - | 対象のDisplayObjectの基準点からのx軸の位置(ピクセル) | +| `mouseY` | number | Yes | - | 対象のDisplayObjectの基準点からのy軸の位置(ピクセル) | +| `dropTarget` | Sprite \| null | Yes | null | スプライトのドラッグ先またはドロップされた先の表示オブジェクト | +| `isMask` | boolean | No | false | マスクとしてDisplayObjectにセットされているかを示します | + +## メソッド + +### Sprite固有のメソッド + +| メソッド | 戻り値 | 説明 | +|---------|--------|------| +| `startDrag(lockCenter?: boolean, bounds?: Rectangle)` | void | 指定されたスプライトをユーザーがドラッグできるようにします | +| `stopDrag()` | void | startDrag()メソッドを終了します | + +### DisplayObjectContainerから継承されるメソッド + +| メソッド | 戻り値 | 説明 | +|---------|--------|------| +| `addChild(child: DisplayObject)` | DisplayObject | 子DisplayObjectインスタンスを追加します | +| `addChildAt(child: DisplayObject, index: number)` | DisplayObject | 指定のインデックス位置に子DisplayObjectインスタンスを追加します | +| `removeChild(child: DisplayObject)` | void | 指定のchild DisplayObjectインスタンスを削除します | +| `removeChildAt(index: number)` | void | 指定のインデックス位置から子DisplayObjectを削除します | +| `removeChildren(...indexes: number[])` | void | 配列で指定されたインデックスの子をコンテナから削除します | +| `getChildAt(index: number)` | DisplayObject \| null | 指定のインデックス位置にある子表示オブジェクトインスタンスを返します | +| `getChildByName(name: string)` | DisplayObject \| null | 指定された名前に一致する子表示オブジェクトを返します | +| `getChildIndex(child: DisplayObject)` | number | 子DisplayObjectインスタンスのインデックス位置を返します | +| `setChildIndex(child: DisplayObject, index: number)` | void | 表示オブジェクトコンテナの既存の子の位置を変更します | +| `contains(child: DisplayObject)` | boolean | 指定されたDisplayObjectがインスタンスの子孫であるかどうか | +| `swapChildren(child1: DisplayObject, child2: DisplayObject)` | void | 指定された2つの子オブジェクトのz順序を入れ替えます | +| `swapChildrenAt(index1: number, index2: number)` | void | 指定されたインデックス位置の2つの子オブジェクトのz順序を入れ替えます | + +### DisplayObjectから継承されるメソッド + +| メソッド | 戻り値 | 説明 | +|---------|--------|------| +| `getBounds(targetDisplayObject?: DisplayObject)` | Rectangle | 指定したDisplayObjectの座標系を基準にして、表示オブジェクトの領域を定義する矩形を返します | +| `globalToLocal(point: Point)` | Point | pointオブジェクトをステージ(グローバル)座標から表示オブジェクトの(ローカル)座標に変換します | +| `localToGlobal(point: Point)` | Point | pointオブジェクトを表示オブジェクトの(ローカル)座標からステージ(グローバル)座標に変換します | +| `hitTestObject(target: DisplayObject)` | boolean | DisplayObjectの描画範囲を評価して、重複または交差するかどうかを調べます | +| `hitTestPoint(x: number, y: number, shapeFlag?: boolean)` | boolean | 表示オブジェクトを評価して、x、yパラメーターで指定されたポイントと重複または交差するかどうかを調べます | +| `remove()` | void | 親子関係を解除します | +| `getLocalVariable(key: any)` | any | クラスのローカル変数空間から値を取得 | +| `setLocalVariable(key: any, value: any)` | void | クラスのローカル変数空間へ値を保存 | +| `hasLocalVariable(key: any)` | boolean | クラスのローカル変数空間に値があるかどうかを判断します | +| `deleteLocalVariable(key: any)` | void | クラスのローカル変数空間の値を削除 | +| `getGlobalVariable(key: any)` | any | グローバル変数空間から値を取得 | +| `setGlobalVariable(key: any, value: any)` | void | グローバル変数空間へ値を保存 | +| `hasGlobalVariable(key: any)` | boolean | グローバル変数空間に値があるかどうかを判断します | +| `deleteGlobalVariable(key: any)` | void | グローバル変数空間の値を削除 | +| `clearGlobalVariable()` | void | グローバル変数空間に値を全てクリアします | + +## 使用例 + +### ボタンとして使用 + +```typescript +const { Sprite, Shape } = next2d.display; + +const button = new Sprite(); + +// ボタンモードを有効化 +button.buttonMode = true; +button.useHandCursor = true; + +// 背景用のShapeを作成 +const bg = new Shape(); +bg.graphics.beginFill(0x3498db); +bg.graphics.drawRoundRect(0, 0, 120, 40, 8, 8); +bg.graphics.endFill(); +button.addChild(bg); + +// クリックイベント +button.addEventListener("click", () => { + console.log("ボタンがクリックされました"); +}); + +stage.addChild(button); +``` + +### マスクとして使用 + +```typescript +const { Sprite, Shape } = next2d.display; + +const container = new Sprite(); + +// コンテンツ用のShape +const content = new Shape(); +content.graphics.beginFill(0xFF0000); +content.graphics.drawRect(0, 0, 200, 200); +content.graphics.endFill(); +container.addChild(content); + +// マスク用のShape +const maskShape = new Shape(); +maskShape.graphics.beginFill(0xFFFFFF); +maskShape.graphics.drawCircle(100, 100, 50); +maskShape.graphics.endFill(); + +// マスクを適用 +container.mask = maskShape; + +stage.addChild(container); +stage.addChild(maskShape); +``` + +### ドラッグ&ドロップ + +```typescript +const { Sprite, Shape } = next2d.display; +const { Rectangle } = next2d.geom; + +const draggable = new Sprite(); + +// 背景用のShapeを作成 +const bg = new Shape(); +bg.graphics.beginFill(0x3498db); +bg.graphics.drawRect(0, 0, 100, 100); +bg.graphics.endFill(); +draggable.addChild(bg); + +// ドラッグ開始 +draggable.addEventListener("mouseDown", () => { + // ドラッグを開始(中心をロック、境界を指定) + draggable.startDrag(true, new Rectangle(0, 0, 400, 300)); +}); + +// ドラッグ終了 +draggable.addEventListener("mouseUp", () => { + draggable.stopDrag(); +}); + +stage.addChild(draggable); +``` + +### 子オブジェクトの管理 + +```typescript +const { Sprite, Shape } = next2d.display; + +const container = new Sprite(); + +// 複数のShapeを子として追加 +for (let i = 0; i < 5; i++) { + const shape = new Shape(); + shape.graphics.beginFill(0xFF0000 + i * 0x003300); + shape.graphics.drawCircle(0, 0, 20); + shape.graphics.endFill(); + shape.x = i * 50; + shape.name = "circle" + i; + container.addChild(shape); +} + +// 名前で子オブジェクトを取得 +const circle2 = container.getChildByName("circle2"); + +// 子の数を取得 +console.log(container.numChildren); // 5 + +stage.addChild(container); +``` + +## 関連項目 + +- [DisplayObject](/ja/reference/player/display-object) +- [MovieClip](/ja/reference/player/movie-clip) +- [Shape](/ja/reference/player/shape) diff --git a/specs/ja/text-field.md b/specs/ja/text-field.md new file mode 100644 index 00000000..1c0b136d --- /dev/null +++ b/specs/ja/text-field.md @@ -0,0 +1,364 @@ +# TextField + +TextFieldは、テキストの表示と編集を行うDisplayObjectです。ラベル表示から入力フォームまで、テキスト関連の機能を提供します。 + +## 継承関係 + +```mermaid +classDiagram + DisplayObject <|-- InteractiveObject + InteractiveObject <|-- TextField + + class TextField { + +text: String + +textColor: Number + +type: String + +setTextFormat() + } +``` + +## プロパティ + +### テキスト関連 + +| プロパティ | 型 | 説明 | +|-----------|------|------| +| `text` | string | テキストフィールド内の現在のテキストであるストリング | +| `htmlText` | string | テキストフィールドの内容をHTMLで表した文字列 | +| `length` | number | テキストフィールド内の文字数(読み取り専用) | +| `maxChars` | number | ユーザーが入力できる最大文字数(0で無制限) | +| `restrict` | string | ユーザーがテキストフィールドに入力できる文字のセットを指定 | +| `defaultTextFormat` | TextFormat | テキストに適用するデフォルトのフォーマット | +| `stopIndex` | number | テキストの任意の表示終了位置の設定(デフォルト: -1) | + +### 表示関連 + +| プロパティ | 型 | 説明 | +|-----------|------|------| +| `width` | number | 表示オブジェクトの幅(ピクセル単位) | +| `height` | number | 表示オブジェクトの高さ(ピクセル単位) | +| `textWidth` | number | テキストの幅(ピクセル単位、読み取り専用) | +| `textHeight` | number | テキストの高さ(ピクセル単位、読み取り専用) | +| `autoSize` | string | テキストフィールドの自動的な拡大/縮小および整列を制御("none", "left", "center", "right") | +| `autoFontSize` | boolean | テキストサイズの自動的な拡大/縮小および整列を制御(デフォルト: false) | +| `wordWrap` | boolean | テキストフィールドのテキストを折り返すかどうか(デフォルト: false) | +| `multiline` | boolean | 複数行テキストフィールドであるかどうか(デフォルト: false) | +| `numLines` | number | テキストの行数(読み取り専用) | + +### 境界線・背景関連 + +| プロパティ | 型 | 説明 | +|-----------|------|------| +| `background` | boolean | テキストフィールドに背景の塗りつぶしがあるかどうか(デフォルト: false) | +| `backgroundColor` | number | テキストフィールドの背景の色(デフォルト: 0xffffff) | +| `border` | boolean | テキストフィールドに境界線があるかどうか(デフォルト: false) | +| `borderColor` | number | テキストフィールドの境界線の色(デフォルト: 0x000000) | + +### 輪郭関連 + +| プロパティ | 型 | 説明 | +|-----------|------|------| +| `thickness` | number | 輪郭のテキスト幅。0(デフォルト値)で無効 | +| `thicknessColor` | number | 輪郭のテキストの色(16進数形式、デフォルト: 0) | + +### 入力関連 + +| プロパティ | 型 | 説明 | +|-----------|------|------| +| `type` | string | テキストフィールドのタイプ("static", "dynamic", "input")(デフォルト: "static") | +| `focus` | boolean | テキストフィールドがフォーカスを持つかどうか(デフォルト: false) | +| `focusVisible` | boolean | テキストフィールドの点滅線の表示・非表示を制御(デフォルト: false) | +| `focusIndex` | number | テキストフィールドのフォーカス位置のインデックス(デフォルト: -1) | +| `selectIndex` | number | テキストフィールドの選択位置のインデックス(デフォルト: -1) | +| `compositionStartIndex` | number | テキストフィールドのコンポジション開始インデックス(デフォルト: -1) | +| `compositionEndIndex` | number | テキストフィールドのコンポジション終了インデックス(デフォルト: -1) | + +### スクロール関連 + +| プロパティ | 型 | 説明 | +|-----------|------|------| +| `scrollX` | number | x軸のスクロール位置(デフォルト: 0) | +| `scrollY` | number | y軸のスクロール位置(デフォルト: 0) | +| `scrollEnabled` | boolean | スクロール機能のON/OFFの制御(デフォルト: true) | +| `xScrollShape` | Shape | xスクロールバーの表示用のShapeオブジェクト(読み取り専用) | +| `yScrollShape` | Shape | yスクロールバーの表示用のShapeオブジェクト(読み取り専用) | + +## メソッド + +| メソッド | 戻り値 | 説明 | +|---------|--------|------| +| `appendText(newText: string)` | void | 指定されたストリングをテキストフィールドのテキストの最後に付加します | +| `insertText(newText: string)` | void | テキストフィールドのフォーカス位置にテキストを追加します | +| `deleteText()` | void | テキストフィールドの選択範囲を削除します | +| `getLineText(lineIndex: number)` | string | 指定された行のテキストを返します | +| `replaceText(newText: string, beginIndex: number, endIndex: number)` | void | 指定された文字範囲を新しいテキストの内容に置き換えます | +| `selectAll()` | void | テキストフィールドのすべてのテキストを選択します | +| `copy()` | void | テキストフィールドの選択範囲をコピーします | +| `paste()` | void | コピーしたテキストを選択範囲に貼り付けます | +| `setFocusIndex(stageX: number, stageY: number, selected?: boolean)` | void | テキストフィールドのフォーカス位置を設定します | +| `keyDown(event: KeyboardEvent)` | void | キーダウンイベントを処理します | + +## TextFormat + +テキストのスタイルを設定するクラスです。 + +### プロパティ + +| プロパティ | 型 | 説明 | +|-----------|------|------| +| `font` | String | フォント名 | +| `size` | Number | フォントサイズ | +| `color` | Number | テキスト色 | +| `bold` | Boolean | 太字 | +| `italic` | Boolean | 斜体 | +| `align` | String | 配置("left", "center", "right") | +| `leading` | Number | 行間(ピクセル) | +| `letterSpacing` | Number | 文字間隔(ピクセル) | + +## 使用例 + +### 基本的なテキスト表示 + +```typescript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.text = "Hello, Next2D!"; +textField.x = 100; +textField.y = 100; + +stage.addChild(textField); +``` + +### TextFormatの適用 + +```typescript +const { TextField, TextFormat } = next2d.text; + +const textField = new TextField(); +textField.text = "スタイル付きテキスト"; + +// TextFormatを作成 +const format = new TextFormat(); +format.font = "Arial"; +format.size = 24; +format.color = 0x3498db; +format.bold = true; + +// フォーマットを適用 +textField.setTextFormat(format); + +// デフォルトフォーマットとして設定 +textField.defaultTextFormat = format; + +stage.addChild(textField); +``` + +### 自動サイズ調整 + +```typescript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.autoSize = "left"; // テキストに合わせて自動拡張 +textField.text = "このテキストに合わせてサイズが調整されます"; + +stage.addChild(textField); +``` + +### 複数行テキスト + +```typescript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.width = 200; +textField.multiline = true; +textField.wordWrap = true; +textField.text = "これは複数行のテキストです。自動的に折り返されます。"; + +stage.addChild(textField); +``` + +### 入力フィールド + +```typescript +const { TextField } = next2d.text; + +const inputField = new TextField(); +inputField.type = "input"; +inputField.width = 200; +inputField.height = 30; +inputField.border = true; +inputField.borderColor = 0xcccccc; +inputField.background = true; +inputField.backgroundColor = 0xffffff; + +// プレースホルダーの代わり +inputField.text = ""; + +// 入力制限(数字のみ) +inputField.restrict = "0-9"; + +// 入力イベント +inputField.addEventListener("change", (event) => { + console.log("入力値:", inputField.text); +}); + +stage.addChild(inputField); +``` + +### パスワードフィールド + +```typescript +const { TextField } = next2d.text; + +const passwordField = new TextField(); +passwordField.type = "input"; +passwordField.displayAsPassword = true; +passwordField.width = 200; +passwordField.height = 30; +passwordField.border = true; +passwordField.borderColor = 0xcccccc; + +stage.addChild(passwordField); +``` + +### HTMLテキスト + +```typescript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.width = 300; +textField.multiline = true; +textField.htmlText = ` + + 太字テキスト
+ 斜体テキスト
+ 赤いテキスト +
+`; + +stage.addChild(textField); +``` + +### スクロール可能なテキスト + +```typescript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.width = 200; +textField.height = 100; +textField.multiline = true; +textField.wordWrap = true; +textField.border = true; +textField.text = "長いテキスト...\n".repeat(20); + +// スクロール操作 +function scrollUp() { + if (textField.scrollY > 0) { + textField.scrollY -= 10; + } +} + +function scrollDown() { + textField.scrollY += 10; +} + +stage.addChild(textField); +``` + +### 動的なテキスト更新 + +```typescript +const { TextField, TextFormat } = next2d.text; + +const scoreField = new TextField(); +scoreField.autoSize = "left"; + +const format = new TextFormat(); +format.font = "Arial"; +format.size = 32; +format.color = 0xffffff; +scoreField.defaultTextFormat = format; + +let score = 0; + +function updateScore(points) { + score += points; + scoreField.text = `Score: ${score}`; +} + +updateScore(0); +stage.addChild(scoreField); +``` + +### テキストの輪郭効果 + +```typescript +const { TextField, TextFormat } = next2d.text; + +const textField = new TextField(); +textField.autoSize = "left"; + +const format = new TextFormat(); +format.font = "Arial"; +format.size = 48; +format.color = 0xffffff; +textField.defaultTextFormat = format; + +textField.text = "輪郭付きテキスト"; +textField.thickness = 2; +textField.thicknessColor = 0x000000; + +stage.addChild(textField); +``` + +### テキストの一部置換 + +```typescript +const { TextField } = next2d.text; + +const textField = new TextField(); +textField.autoSize = "left"; +textField.text = "Hello World!"; + +// "World"を"Next2D"に置き換え +textField.replaceText("Next2D", 6, 11); +// 結果: "Hello Next2D!" + +stage.addChild(textField); +``` + +## イベント + +| イベント | 説明 | +|----------|------| +| `change` | テキストが変更されたとき | +| `focus` | フォーカスを得たとき | +| `blur` | フォーカスを失ったとき | +| `keyDown` | キーが押されたとき | +| `keyUp` | キーが離されたとき | + +```typescript +const { TextField } = next2d.text; + +const inputField = new TextField(); +inputField.type = "input"; + +// Enterキーでフォーム送信 +inputField.addEventListener("keyDown", (event) => { + if (event.keyCode === 13) { // Enter + submitForm(inputField.text); + } +}); + +stage.addChild(inputField); +``` + +## 関連項目 + +- [DisplayObject](/ja/reference/player/display-object) +- [イベントシステム](/ja/reference/player/events) diff --git a/specs/ja/tween.md b/specs/ja/tween.md new file mode 100644 index 00000000..feae465a --- /dev/null +++ b/specs/ja/tween.md @@ -0,0 +1,372 @@ +# Tweenアニメーション + +Next2D Playerでは、プログラムによるアニメーション(Tween)を実装できます。位置、サイズ、透明度などのプロパティを滑らかに変化させることができます。 + +## Tweenの基本概念 + +```mermaid +flowchart LR + Start["開始値"] -->|イージング関数| Progress["進行度 0→1"] + Progress --> End["終了値"] + + subgraph Easing["イージング"] + Linear["Linear"] + EaseIn["EaseIn"] + EaseOut["EaseOut"] + EaseInOut["EaseInOut"] + end +``` + +## 基本的なTweenクラス + +```typescript +class Tween { + private _target; + private _properties = {}; + private _duration; + private _easing; + private _startTime = 0; + private _isPlaying = false; + private _onUpdate; + private _onComplete; + + constructor(target, options) { + this._target = target; + this._duration = options.duration; + this._easing = options.easing || Easing.linear; + this._onUpdate = options.onUpdate; + this._onComplete = options.onComplete; + } + + to(properties) { + for (const key in properties) { + this._properties[key] = { + start: this._target[key], + end: properties[key] + }; + } + return this; + } + + play() { + this._startTime = Date.now(); + this._isPlaying = true; + this._update(); + return this; + } + + private _update = () => { + if (!this._isPlaying) return; + + const elapsed = Date.now() - this._startTime; + let progress = Math.min(1, elapsed / this._duration); + progress = this._easing(progress); + + // プロパティを更新 + for (const key in this._properties) { + const prop = this._properties[key]; + this._target[key] = prop.start + (prop.end - prop.start) * progress; + } + + if (this._onUpdate) { + this._onUpdate(); + } + + if (elapsed < this._duration) { + requestAnimationFrame(this._update); + } else { + this._isPlaying = false; + if (this._onComplete) { + this._onComplete(); + } + } + }; + + stop() { + this._isPlaying = false; + } +} +``` + +## イージング関数 + +```typescript +const Easing = { + // 線形 + linear: (t) => t, + + // 加速 + easeInQuad: (t) => t * t, + easeInCubic: (t) => t * t * t, + easeInQuart: (t) => t * t * t * t, + + // 減速 + easeOutQuad: (t) => t * (2 - t), + easeOutCubic: (t) => (--t) * t * t + 1, + easeOutQuart: (t) => 1 - (--t) * t * t * t, + + // 加速→減速 + easeInOutQuad: (t) => + t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t, + easeInOutCubic: (t) => + t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1, + + // バウンス + easeOutBounce: (t) => { + if (t < 1 / 2.75) { + return 7.5625 * t * t; + } else if (t < 2 / 2.75) { + return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75; + } else if (t < 2.5 / 2.75) { + return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375; + } else { + return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375; + } + }, + + // バック(行き過ぎて戻る) + easeOutBack: (t) => { + const c1 = 1.70158; + const c3 = c1 + 1; + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); + }, + + // エラスティック(ゴムのような動き) + easeOutElastic: (t) => { + if (t === 0 || t === 1) return t; + return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * (2 * Math.PI) / 3) + 1; + } +}; +``` + +## 使用例 + +### 基本的な移動アニメーション + +```typescript +const { Sprite } = next2d.display; + +const sprite = new Sprite(); +sprite.x = 0; +sprite.y = 100; +stage.addChild(sprite); + +// 右に移動 +new Tween(sprite, { duration: 1000, easing: Easing.easeOutQuad }) + .to({ x: 400 }) + .play(); +``` + +### 複数プロパティの同時アニメーション + +```typescript +// 移動 + 拡大 + フェードイン +new Tween(sprite, { + duration: 500, + easing: Easing.easeOutCubic +}) + .to({ + x: 200, + y: 150, + scaleX: 2, + scaleY: 2, + alpha: 1 + }) + .play(); +``` + +### シーケンシャルアニメーション + +```typescript +// 連続したアニメーション +function sequentialAnimation(sprite) { + new Tween(sprite, { + duration: 500, + onComplete: () => { + new Tween(sprite, { + duration: 300, + onComplete: () => { + new Tween(sprite, { duration: 500 }) + .to({ alpha: 0 }) + .play(); + } + }) + .to({ scaleX: 1.5, scaleY: 1.5 }) + .play(); + } + }) + .to({ y: 100 }) + .play(); +} +``` + +### ゲームでの活用例 + +#### キャラクタージャンプ + +```typescript +function jump(character) { + const startY = character.y; + const jumpHeight = 100; + + // 上昇 + new Tween(character, { + duration: 300, + easing: Easing.easeOutQuad, + onComplete: () => { + // 下降 + new Tween(character, { + duration: 300, + easing: Easing.easeInQuad + }) + .to({ y: startY }) + .play(); + } + }) + .to({ y: startY - jumpHeight }) + .play(); +} +``` + +#### ダメージエフェクト + +```typescript +function damageEffect(target) { + const originalX = target.x; + let shakeCount = 0; + + // 点滅 + 揺れ + const shake = () => { + if (shakeCount >= 6) { + target.x = originalX; + target.alpha = 1; + return; + } + + const offset = shakeCount % 2 === 0 ? 5 : -5; + target.x = originalX + offset; + target.alpha = shakeCount % 2 === 0 ? 0.5 : 1; + shakeCount++; + + setTimeout(shake, 50); + }; + + shake(); +} +``` + +#### コイン取得エフェクト + +```typescript +function coinCollectEffect(coin, targetY) { + // 上に飛んでフェードアウト + new Tween(coin, { + duration: 500, + easing: Easing.easeOutQuad, + onUpdate: () => { + // 回転 + coin.rotation += 15; + }, + onComplete: () => { + coin.parent?.removeChild(coin); + } + }) + .to({ + y: targetY, + alpha: 0, + scaleX: 0.5, + scaleY: 0.5 + }) + .play(); +} +``` + +#### UI表示アニメーション + +```typescript +function showPopup(popup) { + popup.scaleX = 0; + popup.scaleY = 0; + popup.alpha = 0; + + new Tween(popup, { + duration: 400, + easing: Easing.easeOutBack + }) + .to({ scaleX: 1, scaleY: 1, alpha: 1 }) + .play(); +} + +function hidePopup(popup, onComplete) { + new Tween(popup, { + duration: 200, + easing: Easing.easeInQuad, + onComplete + }) + .to({ scaleX: 0, scaleY: 0, alpha: 0 }) + .play(); +} +``` + +## enterFrameを使った軽量Tween + +```typescript +// シンプルなenterFrameベースのTween +function tweenTo(target, property, endValue, speed = 0.1) { + const handler = (event) => { + const current = target[property]; + const diff = endValue - current; + + if (Math.abs(diff) < 0.1) { + target[property] = endValue; + stage.removeEventListener("enterFrame", handler); + } else { + target[property] = current + diff * speed; + } + }; + + stage.addEventListener("enterFrame", handler); +} + +// 使用例 +tweenTo(sprite, "x", 300, 0.15); // xを300に向かって移動 +tweenTo(sprite, "alpha", 0, 0.05); // フェードアウト +``` + +## カスタムイージング + +```typescript +// ベジェ曲線ベースのイージング +function bezierEasing(x1, y1, x2, y2) { + return (t) => { + // 簡易的な3次ベジェ補間 + const cx = 3 * x1; + const bx = 3 * (x2 - x1) - cx; + const ax = 1 - cx - bx; + + const cy = 3 * y1; + const by = 3 * (y2 - y1) - cy; + const ay = 1 - cy - by; + + const sampleCurveY = (t) => + ((ay * t + by) * t + cy) * t; + + return sampleCurveY(t); + }; +} + +// CSS cubic-bezier相当 +const customEase = bezierEasing(0.25, 0.1, 0.25, 1.0); +``` + +## パフォーマンスのヒント + +1. **requestAnimationFrame使用**: setTimeoutよりもスムーズ +2. **プロパティ変更の最小化**: 必要なプロパティのみ更新 +3. **オブジェクトプール**: 大量のTweenはプールして再利用 +4. **完了後のクリーンアップ**: 不要なリスナーは削除 + +## 関連項目 + +- [DisplayObject](/ja/reference/player/display-object) +- [イベントシステム](/ja/reference/player/events) diff --git a/specs/ja/video.md b/specs/ja/video.md new file mode 100644 index 00000000..3110bc69 --- /dev/null +++ b/specs/ja/video.md @@ -0,0 +1,236 @@ +# Video + +Videoは、動画コンテンツを再生するためのDisplayObjectです。WebM、MP4などの動画フォーマットに対応しています。 + +## 継承関係 + +```mermaid +classDiagram + DisplayObject <|-- Video + + class Video { + +src: string + +videoWidth: number + +videoHeight: number + +duration: number + +currentTime: number + +volume: number + +loop: boolean + +autoPlay: boolean + +smoothing: boolean + +paused: boolean + +muted: boolean + +loaded: boolean + +ended: boolean + +isVideo: boolean + +play() Promise~void~ + +pause() void + +seek(offset) void + } +``` + +## プロパティ + +| プロパティ | 型 | デフォルト | 説明 | +|-----------|------|----------|------| +| `src` | string | "" | ビデオコンテンツへのURLを指定します | +| `videoWidth` | number | 0 | ビデオの幅をピクセル単位で指定する整数です | +| `videoHeight` | number | 0 | ビデオの高さをピクセル単位で指定する整数です | +| `duration` | number | 0 | キーフレーム総数(動画の長さ) | +| `currentTime` | number | 0 | 現在のキーフレーム(再生位置) | +| `volume` | number | 1 | ボリュームです。範囲は 0(無音)~ 1(フルボリューム)です | +| `loop` | boolean | false | ビデオをループ再生するかどうかを指定します | +| `autoPlay` | boolean | true | ビデオの自動再生の設定 | +| `smoothing` | boolean | true | ビデオを拡大/縮小する際にスムージング(補間)するかどうかを指定します | +| `paused` | boolean | true | ビデオが一時停止しているかどうかを返します | +| `muted` | boolean | false | ビデオがミュートされているかどうかを返します | +| `loaded` | boolean | false | ビデオが読み込まれているかどうかを返します | +| `ended` | boolean | false | ビデオが終了したかどうかを返します | +| `isVideo` | boolean | true | Videoの機能を所持しているかを返却(読み取り専用) | + +## メソッド + +| メソッド | 戻り値 | 説明 | +|---------|--------|------| +| `play()` | Promise\ | ビデオファイルを再生します | +| `pause()` | void | ビデオの再生を一時停止します | +| `seek(offset: number)` | void | 指定された位置に最も近いキーフレームをシークします | + +## 使用例 + +### 基本的な動画再生 + +```typescript +const { Video } = next2d.media; + +// Videoオブジェクトを作成(幅、高さを指定) +const video = new Video(640, 360); + +// 動画のURLを設定(設定すると自動的に読み込み開始) +video.src = "video.mp4"; + +// プロパティ設定 +video.autoPlay = true; // 自動再生 +video.loop = false; // ループしない +video.smoothing = true; // スムージング有効 + +// ステージに追加 +stage.addChild(video); +``` + +### 再生コントロール + +```typescript +const { Video, VideoEvent } = next2d.media; + +const video = new Video(640, 360); +video.autoPlay = false; // 自動再生を無効化 +video.src = "video.mp4"; + +stage.addChild(video); + +// 再生ボタン +playButton.addEventListener("click", async () => { + await video.play(); +}); + +// 一時停止ボタン +pauseButton.addEventListener("click", () => { + video.pause(); +}); + +// 停止ボタン(先頭に戻って停止) +stopButton.addEventListener("click", () => { + video.pause(); + video.seek(0); +}); + +// 10秒進む +forwardButton.addEventListener("click", () => { + video.seek(video.currentTime + 10); +}); + +// 10秒戻る +backButton.addEventListener("click", () => { + video.seek(Math.max(0, video.currentTime - 10)); +}); +``` + +### イベントリスニング + +```typescript +const { Video, VideoEvent } = next2d.media; + +const video = new Video(640, 360); + +// メタデータ受信イベント +video.addEventListener(VideoEvent.METADATA_RECEIVED, () => { + console.log("Duration:", video.duration); + console.log("Size:", video.videoWidth, "x", video.videoHeight); +}); + +// 再生イベント +video.addEventListener(VideoEvent.PLAY, () => { + console.log("再生開始"); +}); + +// 一時停止イベント +video.addEventListener(VideoEvent.PAUSE, () => { + console.log("一時停止"); +}); + +// シークイベント +video.addEventListener(VideoEvent.SEEK, () => { + console.log("シーク:", video.currentTime); +}); + +// 終了イベント +video.addEventListener(VideoEvent.ENDED, () => { + console.log("再生終了"); +}); + +video.src = "video.mp4"; +stage.addChild(video); +``` + +### 再生進捗の表示 + +```typescript +const { Video, VideoEvent } = next2d.media; + +const video = new Video(640, 360); +video.src = "video.mp4"; +stage.addChild(video); + +// フレームごとに進捗を更新 +stage.addEventListener("enterFrame", () => { + if (video.duration > 0) { + const progress = video.currentTime / video.duration; + progressBar.scaleX = progress; + timeLabel.text = formatTime(video.currentTime) + " / " + formatTime(video.duration); + } +}); + +function formatTime(seconds) { + const min = Math.floor(seconds / 60); + const sec = Math.floor(seconds % 60); + return `${min}:${sec.toString().padStart(2, '0')}`; +} +``` + +### 音量コントロール + +```typescript +const { Video } = next2d.media; + +const video = new Video(640, 360); +video.src = "video.mp4"; +video.volume = 0.5; // 50% + +stage.addChild(video); + +// 音量スライダー +volumeSlider.addEventListener("change", (event) => { + video.volume = event.target.value; // 0.0 ~ 1.0 +}); + +// ミュートトグル +muteButton.addEventListener("click", () => { + video.muted = !video.muted; +}); +``` + +### ループ再生 + +```typescript +const { Video } = next2d.media; + +const video = new Video(640, 360); +video.loop = true; // ループ有効 +video.src = "video.mp4"; + +stage.addChild(video); +``` + +## VideoEvent + +| イベント | 説明 | +|----------|------| +| `VideoEvent.METADATA_RECEIVED` | メタデータ受信時 | +| `VideoEvent.PLAY` | 再生開始時 | +| `VideoEvent.PAUSE` | 一時停止時 | +| `VideoEvent.SEEK` | シーク時 | +| `VideoEvent.ENDED` | 再生終了時 | + +## サポートフォーマット + +| フォーマット | 拡張子 | 対応状況 | +|--------------|--------|----------| +| MP4 (H.264) | .mp4 | 推奨 | +| WebM (VP8/VP9) | .webm | 対応 | +| Ogg Theora | .ogv | ブラウザ依存 | + +## 関連項目 + +- [DisplayObject](/ja/reference/player/display-object) +- [イベントシステム](/ja/reference/player/events) diff --git a/src/index.ts b/src/index.ts index 97b7e265..8241fa21 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import { Next2D } from "@next2d/core"; if (!("next2d" in window)) { - console.log("%c Next2D Player %c 2.13.1 %c https://next2d.app", + console.log("%c Next2D Player %c 3.0.0 %c https://next2d.app", "color: #fff; background: #5f5f5f", "color: #fff; background: #4bc729", ""); diff --git a/tsconfig.json b/tsconfig.json index 33e9ecf8..857f7257 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,7 +23,8 @@ "outDir": "./dist", "types": [ - "vitest/globals" + "vitest/globals", + "@webgpu/types" ] }, "include": [