diff --git a/.github/workflows/build-v8.yml b/.github/workflows/build-v8.yml index 9dd093e..3976cd9 100644 --- a/.github/workflows/build-v8.yml +++ b/.github/workflows/build-v8.yml @@ -17,7 +17,7 @@ on: v8_version: description: "V8 version tag (https://github.com/v8/v8/tags)" required: true - default: "13.6.233.17" + default: "13.7.152.19" permissions: contents: write # Releases への発行に必要 @@ -80,14 +80,19 @@ jobs: rem v8_enable_sandbox=false : sandbox は libc++ ハードニング必須のため rem use_custom_libcxx=false と両立しない (BUILD.gn の assert)。 rem ホストは自明に信頼済みのゲームJSのみ実行するため無効で問題ない。 - rem v8_jitless=true + turbofan/wasm 無効 : ホストは実行時 --jitless (Xbox は JIT 禁止)。 - rem BUILD.gn の assert により jitless では turbofan/sparkplug/maglev/wasm を - rem 無効にする必要がある (sparkplug/maglev は既定値が連動して自動で無効)。 - rem MSVC STL と相性の悪い wasm 系ソースもビルド対象から外れる。 + rem WebAssembly 対応 (r3): ゲームが wasm を使うため wasm 有効 + DrumBrake + rem (V8 の wasm インタープリタ。JIT 禁止のコンソール向け機能) を組み込む。 + rem - wasm はビルド時に turbofan を要求するため v8_jitless ビルドは使えない + rem (JS の JIT 無効化は実行時の --jitless フラグで行う) + rem - 実行時は --jitless --wasm-jitless で JS=Ignition / wasm=DrumBrake 解釈実行 + rem - v8_drumbrake_bounds_checks=true (明示境界チェック): 既定の guard page + + rem trap handler + 特殊呼出規約 builtin 方式は MSVC 環境 (r2) で + rem InterpreterHandle 構築時に fail-fast したため明示チェック方式を採用。 + rem コンソール実機 (VEH 制約) とも整合する構成。 rem icu_use_data_file=false : icudtl.dat の同梱を不要にする rem treat_warnings_as_errors=false : use_custom_libcxx=false では MSVC STL の rem ヘッダ警告が -Werror で停止するため無効化 - call gn gen out\x64.release --args="v8_monolithic=true v8_use_external_startup_data=false is_component_build=false use_custom_libcxx=false icu_use_data_file=false treat_warnings_as_errors=false v8_jitless=true v8_enable_turbofan=false v8_enable_webassembly=false target_cpu=\"x64\" is_debug=false v8_enable_pointer_compression=true v8_enable_sandbox=false" + call gn gen out\x64.release --args="v8_monolithic=true v8_use_external_startup_data=false is_component_build=false use_custom_libcxx=false icu_use_data_file=false treat_warnings_as_errors=false v8_enable_drumbrake=true v8_drumbrake_bounds_checks=true target_cpu=\"x64\" is_debug=false v8_enable_pointer_compression=true v8_enable_sandbox=false" if errorlevel 1 exit /b 1 call ninja -C out\x64.release v8_monolith if errorlevel 1 ( @@ -116,17 +121,17 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - $tag = "v8-${{ inputs.v8_version }}-windows-x64" + $tag = "v8-${{ inputs.v8_version }}-windows-x64-r3" $zip = "v8-monolith-${{ inputs.v8_version }}-windows-x64.zip" $notes = @" Prebuilt V8 monolith for the Next2D Xbox host (windows-x64). - - V8 ``${{ inputs.v8_version }}`` / VS2022 (windows-latest runner) + - V8 ``${{ inputs.v8_version }}`` / VS2022 (windows-2022 runner) / r3 - gn args: monolithic, no external startup data, use_custom_libcxx=false, icu_use_data_file=false, pointer compression enabled, sandbox disabled (requires the bundled hardened libc++, conflicts with MSVC linking), - jitless (turbofan/sparkplug/maglev/wasm disabled — the host runs V8 with - --jitless because JIT is prohibited on Xbox) + **WebAssembly + DrumBrake enabled** (the wasm interpreter for JIT-prohibited + consoles, explicit bounds checks; the host runs V8 with --jitless --wasm-jitless) - Layout: ``include/`` + ``lib/v8_monolith.lib`` (pass the extracted dir to ``--v8-root``) ``npx @next2d/builder --platform xbox`` downloads this automatically when diff --git a/.github/workflows/xbox-host-ci.yml b/.github/workflows/xbox-host-ci.yml index 656629d..e9c03af 100644 --- a/.github/workflows/xbox-host-ci.yml +++ b/.github/workflows/xbox-host-ci.yml @@ -23,7 +23,7 @@ on: env: # コンパイル検証に使う V8 ヘッダのバージョン (README「V8 の用意」と揃える) - V8_TAG: 13.6.233.17 + V8_TAG: 13.7.152.19 jobs: raster-tests-linux: diff --git a/package-lock.json b/package-lock.json index 93bbc45..4cba83a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "@next2d/builder", - "version": "0.1.6", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@next2d/builder", - "version": "0.1.6", + "version": "0.1.8", "license": "MIT", "dependencies": { "@electron-forge/core": "^7.11.2", "@types/node": "^26.1.0", "picocolors": "^1.1.1", - "vite": "^8.1.2" + "vite": "^8.1.3" }, "bin": { "builder": "dist/index.js" @@ -487,9 +487,9 @@ } }, "node_modules/@electron/packager/node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -556,9 +556,9 @@ } }, "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -604,9 +604,9 @@ } }, "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -703,9 +703,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -1912,9 +1912,9 @@ "license": "ISC" }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -2132,9 +2132,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.34", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", - "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", + "version": "2.10.41", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz", + "integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -2191,9 +2191,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", "funding": [ { "type": "opencollective", @@ -2210,10 +2210,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -2370,9 +2370,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001797", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", - "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", "funding": [ { "type": "opencollective", @@ -2786,9 +2786,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.368", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", - "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", + "version": "1.5.386", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.386.tgz", + "integrity": "sha512-f9yJE+9dJy+B4QC1iTu+mVP/KWLZviG5u/qtRwdBF0zPt3etWa1HSY1lMuWw0Nt8/KpLCY6ok+XlKHOs6ZhxSA==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -2817,9 +2817,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.23.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", - "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", + "version": "5.24.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.1.tgz", + "integrity": "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -2873,9 +2873,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", "license": "MIT" }, "node_modules/es6-error": { @@ -3025,9 +3025,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -3358,9 +3358,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "funding": [ { "type": "github", @@ -3649,12 +3649,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause" - }, "node_modules/global-agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", @@ -4141,9 +4135,9 @@ } }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -4757,6 +4751,66 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, "node_modules/minipass": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", @@ -4912,9 +4966,9 @@ "license": "MIT" }, "node_modules/node-abi": { - "version": "3.92.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", - "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", "license": "MIT", "dependencies": { "semver": "^7.3.5" @@ -4953,9 +5007,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "license": "MIT", "engines": { "node": ">=18" @@ -5447,9 +5501,9 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -5969,9 +6023,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", - "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6375,66 +6429,6 @@ "node": ">=10" } }, - "node_modules/terser-webpack-plugin": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", - "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@minify-html/node": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "@swc/css": { - "optional": true - }, - "@swc/html": { - "optional": true - }, - "clean-css": { - "optional": true - }, - "cssnano": { - "optional": true - }, - "csso": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "html-minifier-terser": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "postcss": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -6475,9 +6469,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" @@ -6693,9 +6687,9 @@ } }, "node_modules/vite": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz", - "integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", @@ -6770,9 +6764,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" @@ -6782,12 +6776,11 @@ } }, "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "license": "MIT", "dependencies": { - "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" }, "engines": { @@ -6810,9 +6803,9 @@ "license": "BSD-2-Clause" }, "node_modules/webpack": { - "version": "5.107.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz", - "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", + "version": "5.108.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.108.3.tgz", + "integrity": "sha512-hOpaCHmQVVY66IVTjofnH14IgSdmod2aquSGHGuYig/OIdWge01Hk2Wt988DZcwXumFUT4+FvJY5N+ikl8o/ww==", "license": "MIT", "dependencies": { "@types/estree": "^1.0.8", @@ -6824,19 +6817,18 @@ "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.22.0", + "enhanced-resolve": "^5.22.2", "es-module-lexer": "^2.1.0", "eslint-scope": "5.1.1", "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "loader-runner": "^4.3.2", "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.6.1", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.5.0", - "watchpack": "^2.5.1", + "watchpack": "^2.5.2", "webpack-sources": "^3.5.0" }, "bin": { @@ -6981,9 +6973,9 @@ "license": "ISC" }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "license": "MIT", "dependencies": { "cliui": "^8.0.1", diff --git a/package.json b/package.json index 2eb8b24..491b49c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@next2d/builder", - "version": "0.1.7", + "version": "0.1.8", "description": "Multi-platform builder for Next2d Framework, supporting export to various platforms such as macOS, Windows, iOS, Android and Web(HTML)", "author": "Toshiyuki Ienaga ", "license": "MIT", @@ -22,6 +22,7 @@ ], "scripts": { "lint": "eslint src/index.ts", + "prepare": "tsc", "release": "tsc" }, "files": [ @@ -39,7 +40,7 @@ "@electron-forge/core": "^7.11.2", "@types/node": "^26.1.0", "picocolors": "^1.1.1", - "vite": "^8.1.2" + "vite": "^8.1.3" }, "devDependencies": { "@eslint/eslintrc": "^3.3.5", diff --git a/src/index.ts b/src/index.ts index f33a87b..2cfc8a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -220,7 +220,7 @@ const buildWeb = (): Promise => { return new Promise((resolve, reject): void => { - const stream = cp.spawn("npx", [ + const stream = $spawn("npx", [ "@next2d/vite-plugin-next2d-auto-loader" ], { "stdio": "inherit" }); @@ -230,7 +230,7 @@ const buildWeb = (): Promise => reject("vite plugin command failed."); } - const stream = cp.spawn("npx", [ + const stream = $spawn("npx", [ "vite", "--outDir", $buildDir, @@ -267,7 +267,7 @@ const installElectron = (): Promise => return resolve(); } - const stream = cp.spawn("npm", [ + const stream = $spawn("npm", [ "--prefix", `${process.cwd()}/electron`, "install", @@ -363,7 +363,7 @@ const buildSteam = async (): Promise => if (preview) { - cp.spawn("npx", [ + $spawn("npx", [ "electron", `${process.cwd()}/electron/index.js` ], { "stdio": "inherit" }); @@ -437,7 +437,7 @@ const generateNativeProject = (): Promise => return resolve(); } - const stream = cp.spawn("npx", [ + const stream = $spawn("npx", [ "cap", "add", platform @@ -484,7 +484,7 @@ const runNative = async (): Promise => JSON.stringify(config, null, 2) ); - cp.spawn("npx", [ + $spawn("npx", [ "cap", "run", platform @@ -518,7 +518,7 @@ const openNative = async (): Promise => JSON.stringify(config, null, 2) ); - const stream = cp.spawn("npx", [ + const stream = $spawn("npx", [ "cap", "sync", platform @@ -531,7 +531,7 @@ const openNative = async (): Promise => return; } - cp.spawn("npx", [ + $spawn("npx", [ "cap", "open", platform @@ -566,7 +566,7 @@ const buildNative = async (): Promise => JSON.stringify(config, null, 2) ); - const stream = cp.spawn("npx", [ + const stream = $spawn("npx", [ "cap", "sync", platform @@ -579,7 +579,7 @@ const buildNative = async (): Promise => return; } - cp.spawn("npx", [ + $spawn("npx", [ "cap", "build", platform @@ -615,6 +615,30 @@ const getTemplateDir = (name: string): string => */ const XBOX_CONFIG_NAME: string = "MicrosoftGame.config"; +/** + * @description npx / npm を子プロセスとして起動する (クロスプラットフォーム)。 + * Windows では npx/npm が .cmd (バッチファイル) のため、shell 経由で + * ないと起動できない (spawn ENOENT / Node の CVE-2024-27980 対応により + * .cmd の直接 spawn も不可)。スペースを含む引数は二重引用符で保護する。 + * Spawn npx/npm cross-platform. On Windows they are .cmd batch files, + * which require a shell to spawn. + * + * @param {string} command + * @param {array} args + * @param {object} options + * @return {object} + * @method + * @public + */ +const $spawn = (command: string, args: string[], options: object = {}): cp.ChildProcess => +{ + if (process.platform === "win32" && (command === "npx" || command === "npm")) { + const quoted: string[] = args.map((a: string): string => /\s/.test(a) ? `"${a}"` : a); + return cp.spawn(command, quoted, { ...options, "shell": true }); + } + return cp.spawn(command, args, options); +}; + /** * @description 自動ダウンロードする prebuilt V8 のバージョン。 * `.github/workflows/build-v8.yml` で発行した Releases のタグと、 @@ -625,7 +649,19 @@ const XBOX_CONFIG_NAME: string = "MicrosoftGame.config"; * @type {string} * @constant */ -const XBOX_V8_VERSION: string = "13.6.233.17"; +const XBOX_V8_VERSION: string = "13.7.152.19"; + +/** + * @description prebuilt V8 のアセットリビジョン。同一 V8 バージョンでも + * gn フラグ変更 (例: r2 = WebAssembly + DrumBrake 有効化) で + * バイナリが変わる場合に上げ、Release タグとローカルキャッシュを分離する。 + * Asset revision. Bump when gn flags change for the same V8 version + * (r2 = WebAssembly + DrumBrake enabled). + * + * @type {string} + * @constant + */ +const XBOX_V8_REVISION: string = "r3"; /** * @description ゲーム側の `xbox/` へスキャフォールドしないテンプレート内ファイル。 @@ -779,14 +815,14 @@ const resolveXboxV8Root = async (): Promise => const cacheBase: string = process.env.LOCALAPPDATA ? `${process.env.LOCALAPPDATA}/next2d` : `${os.homedir()}/.cache/next2d`; - const cacheDir: string = `${cacheBase}/v8/${XBOX_V8_VERSION}`; + const cacheDir: string = `${cacheBase}/v8/${XBOX_V8_VERSION}-${XBOX_V8_REVISION}`; if (fs.existsSync(`${cacheDir}/include/v8.h`)) { return cacheDir; } // 4. GitHub Releases から自動ダウンロード (マシンごとに初回のみ) const assetName: string = `v8-monolith-${XBOX_V8_VERSION}-windows-x64.zip`; - const url: string = `https://github.com/Next2D/builder/releases/download/v8-${XBOX_V8_VERSION}-windows-x64/${assetName}`; + const url: string = `https://github.com/Next2D/builder/releases/download/v8-${XBOX_V8_VERSION}-windows-x64-${XBOX_V8_REVISION}/${assetName}`; console.log(pc.green(`Downloading prebuilt V8 ${XBOX_V8_VERSION} (first time only) ...`)); console.log(url); @@ -908,21 +944,25 @@ const buildXbox = async (): Promise => return; } - // フルビルド (Release パッケージまで) - const stream = cp.spawn("cmake", [ - "--build", cmakeBuildDir, - "--config", "Release" - ], { "stdio": "inherit" }); - - stream.on("close", (code: number): void => + // フルビルド (Release パッケージまで)。失敗時は reject して非ゼロ終了させる + // (以前は表示のみで exit code 0 のまま成功扱いになっていた) + await new Promise((resolve, reject): void => { - if (code !== 0) { - console.log(pc.red("Export of the Xbox (GDK) package failed.")); - return; - } - console.log(); - console.log(pc.green(`Finished building the Xbox (GDK) package for ${gdkArch}.`)); - console.log(); + const stream = cp.spawn("cmake", [ + "--build", cmakeBuildDir, + "--config", "Release" + ], { "stdio": "inherit" }); + + stream.on("close", (code: number): void => + { + if (code !== 0) { + return reject("Export of the Xbox (GDK) package failed."); + } + console.log(); + console.log(pc.green(`Finished building the Xbox (GDK) package for ${gdkArch}.`)); + console.log(); + resolve(); + }); }); }; diff --git a/templates/xbox/CMakeLists.txt b/templates/xbox/CMakeLists.txt index 1785db2..d7aff88 100644 --- a/templates/xbox/CMakeLists.txt +++ b/templates/xbox/CMakeLists.txt @@ -23,6 +23,18 @@ set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) +if(MSVC) + # MSVC は /Zc:__cplusplus を渡さないと __cplusplus を 199711L のまま報告し + # (/std:c++20 を指定していても)、v8config.h の C++20 チェックで停止する。 + add_compile_options(/Zc:__cplusplus) +endif() + +# CRT は静的 (/MT, Debug は /MTd) に統一する。 +# prebuilt V8 monolith は GN の既定で静的 CRT ビルドのため、ホスト側が既定の /MD だと +# libcpmt.lib と msvcprt.lib が衝突して LNK2005/LNK2038 でリンクに失敗する。 +# ここで設定することで FetchContent の Dawn も同じ CRT でビルドされる。 +set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + # GDK target platform (Gaming.Xbox.* / Gaming.Desktop.*) の判定 if(NOT DEFINED CMAKE_GENERATOR_PLATFORM) message(WARNING "CMAKE_GENERATOR_PLATFORM が未設定です。builder は -A Gaming.Xbox.Scarlett.x64 等を渡します。") @@ -35,6 +47,28 @@ else() set(NEXT2D_IS_CONSOLE OFF) endif() +# ----------------------------------------------------------------------------- +# GDK の解決 (2通り) +# A) インストール済み GDK: -A Gaming.Desktop.x64 / Gaming.Xbox.* でビルド。 +# VS 統合が include/lib を自動供給するため追加設定は不要。 +# B) 展開レイアウト (インストーラ不要・CI 向け): NuGet の Microsoft.GDK.Windows を +# 展開し、-A x64 + GDK_ROOT (cmake 変数 or 環境変数) で指定する。 +# GDK_ROOT は /native//windows を指す (include/, lib/x64/, bin/x64/)。 +# ----------------------------------------------------------------------------- +string(FIND "${CMAKE_GENERATOR_PLATFORM}" "Gaming." _is_gaming_platform) +if(_is_gaming_platform LESS 0) + if(NOT DEFINED GDK_ROOT) + set(GDK_ROOT "$ENV{GDK_ROOT}") + endif() + if(NOT GDK_ROOT OR NOT EXISTS "${GDK_ROOT}/include/XGameRuntime.h") + message(FATAL_ERROR + "GDK が見つかりません。次のいずれかで指定してください:\n" + " 1) GDK をインストールして -A Gaming.Desktop.x64 でビルド\n" + " 2) NuGet の Microsoft.GDK.Windows を展開し、-A x64 で\n" + " -D GDK_ROOT=/native//windows (または環境変数 GDK_ROOT) を指定") + endif() +endif() + # ----------------------------------------------------------------------------- # 依存: Dawn (WebGPU 実装 / D3D12 バックエンド) # ----------------------------------------------------------------------------- @@ -61,11 +95,46 @@ target_include_directories(Next2DXboxHost PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src" ) +# 展開レイアウト (GDK_ROOT) 使用時: include/lib を明示指定し、実行時 DLL を並べる +if(_is_gaming_platform LESS 0) + target_include_directories(Next2DXboxHost PRIVATE "${GDK_ROOT}/include") + target_link_directories(Next2DXboxHost PRIVATE "${GDK_ROOT}/lib/x64") + add_custom_command(TARGET Next2DXboxHost POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${GDK_ROOT}/bin/x64/xgameruntime.dll" + "$" + COMMENT "Staging xgameruntime.dll next to the executable") +endif() + target_link_libraries(Next2DXboxHost PRIVATE v8::v8 dawn::webgpu_dawn ) +if(MSVC) + # CRT は静的 (/MT) に統一している (V8 monolith が静的 CRT 固定のため)。 + # 一方 GDK の xgameruntime.lib 等は /MD ビルドで /DEFAULTLIB:msvcprt を + # 持ち込み LNK2005 (多重定義) を起こすので、動的 C++ ランタイムを除外して + # 静的側 (libcpmt) に一本化する。 + target_link_options(Next2DXboxHost PRIVATE + /NODEFAULTLIB:msvcprt.lib + /NODEFAULTLIB:msvcprtd.lib + ) + + # Release でも PDB を生成する (クラッシュダンプ解析でスタックを名前解決するため) + target_compile_options(Next2DXboxHost PRIVATE /Zi) + target_link_options(Next2DXboxHost PRIVATE /DEBUG /OPT:REF /OPT:ICF) +endif() + +# webgpu_dawn は SHARED (DLL)。実行に必要なため exe の隣へステージする +if(TARGET webgpu_dawn) + add_custom_command(TARGET Next2DXboxHost POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" + "$" + COMMENT "Staging webgpu_dawn.dll next to the executable") +endif() + # WebGPU / D3D12 / GameInput / WIC / XAudio2 / GDK target_link_libraries(Next2DXboxHost PRIVATE d3d12 diff --git a/templates/xbox/README.md b/templates/xbox/README.md index aaf200d..0be7b33 100644 --- a/templates/xbox/README.md +++ b/templates/xbox/README.md @@ -51,7 +51,7 @@ C++ 実行ファイルをビルドします。Electron や WebView は使用し ### ランタイム環境の要点 - **jitless V8**: Xbox/Switch のリテールは JIT(動的コード生成)を禁止するため、V8 は - `--jitless`(Ignition インタプリタのみ)で起動する。WebAssembly も無効。 + `--jitless --wasm-jitless` で起動する (JS=Ignition インタプリタ、wasm=DrumBrake インタープリタ)。 - **Worker / OffscreenCanvas**: player はレンダラを Worker + `transferControlToOffscreen` で動かす。本ホストは **「同一 Isolate 内の別 Context を協調スケジューリングする単一スレッド Worker モデル」** を採用(`src/worker/WorkerRuntime`)。`postMessage` は V8 の @@ -73,7 +73,7 @@ C++ 実行ファイルをビルドします。Electron や WebView は使用し |------|------| | OS | Windows 11 | | IDE | Visual Studio 2022 (v143, C++ Game development workload) | -| GDK | Microsoft GDK (June 2024 以降) — ID@Xbox 登録が必要 | +| GDK | 公開版 GDK をインストール (`-A Gaming.Desktop.x64`)、**または** NuGet `Microsoft.GDK.Windows` を展開して `-A x64` + 環境変数/`-D` `GDK_ROOT=/native//windows`(インストーラ不要・CI 向け)。コンソール向け GDKX は ID@Xbox 登録が必要 | | CMake | 3.26 以降 | | V8 | 不要 (builder が prebuilt を自動ダウンロード)。自前ビルドは `--v8-root` で指定可 | | Dawn | CMake の FetchContent で自動取得(ネットワーク必須) | @@ -120,9 +120,9 @@ set DEPOT_TOOLS_WIN_TOOLCHAIN=0 fetch v8 cd v8 :: CI のコンパイル検証と同じバージョンに固定 (xbox-host-ci.yml の V8_TAG) -git checkout 13.6.233.17 +git checkout 13.7.152.19 gclient sync -gn gen out\x64.release --args="v8_monolithic=true v8_use_external_startup_data=false is_component_build=false use_custom_libcxx=false icu_use_data_file=false treat_warnings_as_errors=false v8_jitless=true v8_enable_turbofan=false v8_enable_webassembly=false target_cpu=\"x64\" is_debug=false v8_enable_pointer_compression=true v8_enable_sandbox=false" +gn gen out\x64.release --args="v8_monolithic=true v8_use_external_startup_data=false is_component_build=false use_custom_libcxx=false icu_use_data_file=false treat_warnings_as_errors=false v8_enable_drumbrake=true target_cpu=\"x64\" is_debug=false v8_enable_pointer_compression=true v8_enable_sandbox=false" ninja -C out\x64.release v8_monolith ``` @@ -142,9 +142,12 @@ ninja -C out\x64.release v8_monolith > - `treat_warnings_as_errors=false` を推奨。`use_custom_libcxx=false` では MSVC STL の > ヘッダ警告が -Werror で停止することがあります。**VS のバージョンは VS2022 (v143) を推奨** > (新しすぎる STL は V8 の clang が警告/エラーを出しやすい)。 -> - `v8_jitless=true` + `v8_enable_turbofan=false` + `v8_enable_webassembly=false` を推奨。 -> ホストは実行時 `--jitless` で動かすため (Xbox は JIT 禁止)、JIT/wasm をビルドから -> 外すのが正しい構成です (BUILD.gn の assert もこの組み合わせを要求)。ビルドも大幅に速くなります。 +> - `v8_enable_drumbrake=true` は必須 (r2)。**DrumBrake = V8 の wasm インタープリタ**で、 +> JIT 禁止環境 (コンソール) でも wasm を解釈実行できます。wasm はビルド時に turbofan を +> 要求するため `v8_jitless` ビルドは使えません — JIT の無効化はホストが実行時フラグ +> `--jitless --wasm-jitless` で行います (JS=Ignition / wasm=DrumBrake)。 +> 注意: DrumBrake は既定ビルド対象外のためタグによりコンパイル不能な場合があります +> (13.6.233.17 は不可 → 13.7.152.19 で修正済み)。 > - pointer compression は `CMakeLists.txt` の `V8_COMPRESS_POINTERS` と一致させてください > (sandbox 無効に合わせ `V8_ENABLE_SANDBOX` は定義しません)。不一致は ABI 崩れの原因になります。 > - 上記は PC (`Gaming.Desktop.x64`) 用。コンソール実機 (`Gaming.Xbox.*`) 向けは @@ -210,7 +213,7 @@ macOS / Linux でそのまま実行できます: tests/check_local.sh ``` -構文チェックは実 V8 ヘッダ (pin: `13.6.233.17`) に対して、Windows API 非依存の +構文チェックは実 V8 ヘッダ (pin: `13.7.152.19`) に対して、Windows API 非依存の V8 バインディング 12 ファイルをコンパイル検証します (API 削除・型不整合を検出)。 ### 2. GitHub Actions (windows-latest) — `.github/workflows/xbox-host-ci.yml` @@ -302,6 +305,11 @@ player の実 API 使用を全面調査し、以下を実装済み。**この環 | ポインタ/キーボード/ホイール入力 | ⚠️ 実装(要検証) | `WndProc`→`DispatchEvent`。配送先canvasの特定は実機検証 | | 動画 (HTMLVideoElement) | ⚠️ 実装(要検証) | `Video.cpp`(Media Foundation)。フレーム精度/音声同期は実機検証 | | クリップボード (`navigator.clipboard`) | ✅ 実装 | Win32 クリップボード (readText/writeText) | +| **JS エンジン/グローバル機能** (player 全数調査) | ✅ 実装 | Proxy=V8コア。`crypto.randomUUID`/`getRandomValues`(BCrypt)、`TextEncoder`/`TextDecoder`/`queueMicrotask`/`location`/`atob`/`btoa` は worker 含む全コンテキストへ注入。`createImageBitmap` は ImageData/ImageBitmap/canvas/Blob を受理 | +| **WebAssembly** | ✅ 実装 (要 V8 r2) | prebuilt V8 r2 = wasm + **DrumBrake**(V8 の wasm インタープリタ、JIT 禁止コンソール向け)。実行時 `--jitless --wasm-jitless` で JS=Ignition / wasm=DrumBrake 解釈実行 | +| **indexedDB** (最小実装) | ✅ 実装 | open/createObjectStore/transaction/get/put/delete/clear/getAll/count/keyPath。値は JSON 化可能なもの + ArrayBuffer/TypedArray(base64 永続化)。カーソル/インデックスは未対応。保存先 `%LOCALAPPDATA%\Next2D\idb_.json` | +| **localStorage / sessionStorage** | ✅ 実装 | JS セマンティクス + ファイル永続化 (`%LOCALAPPDATA%\Next2D\`)。コンソール実機では XGameSave 化が必要 («EXTEND») | +| **Audio 要素** (`new Audio(url)`) | ✅ 実装 | play/pause/paused/loop/volume/currentTime/preload (pause→play は先頭から) | > player が使用する API はコード上すべて実装済み。残る唯一の障壁は > **GDK/devkit を要するコンパイル・実機検証**(この環境では物理的に不可)。 diff --git a/templates/xbox/cmake/FetchDawn.cmake b/templates/xbox/cmake/FetchDawn.cmake index 9b3a8b0..0d79dde 100644 --- a/templates/xbox/cmake/FetchDawn.cmake +++ b/templates/xbox/cmake/FetchDawn.cmake @@ -3,13 +3,18 @@ # D3D12 バックエンドのみを有効化し、Xbox(GDK)/PC(GDK) で利用する。 # # 注意: -# - Dawn のビルドには depot_tools 由来の一部が不要になる CMake パスを使用する。 +# - submodule はクローンしない (GIT_SUBMODULES "")。SwiftShader→LLVM まで再帰して +# 数 GB のクローン + Windows の MAX_PATH 超過 ("Filename too long") で失敗するため。 +# 依存は Dawn 公式の DAWN_FETCH_DEPENDENCIES (python スクリプト) が必要分だけ取得する。 +# - DAWN_FETCH_DEPENDENCIES には python3 が PATH に必要。 # - 初回構成はネットワークとビルド時間を要する。CI ではキャッシュ推奨。 -# - DAWN_TAG は動作確認済みタグに固定すること (chromium ブランチに追従)。 +# - DAWN_TAG はバインディング (bindings/webgpu/*) が使う API 世代と一致させること: +# wgpu::Limits / ShaderSourceWGSL / SurfaceSourceWindowsHWND / OptionalBool +# (いずれも 2025 以降の命名。古いタグでは SupportedLimits 等になりビルド不可) # ============================================================================= include(FetchContent) -set(DAWN_TAG "chromium/6478" CACHE STRING "Dawn のチェックアウトタグ") +set(DAWN_TAG "v20260402.171122" CACHE STRING "Dawn のチェックアウトタグ (github.com/google/dawn)") # Dawn のビルドオプション: 必要なものだけを有効化 set(DAWN_FETCH_DEPENDENCIES ON CACHE BOOL "" FORCE) @@ -24,14 +29,24 @@ set(DAWN_BUILD_SAMPLES OFF CACHE BOOL "" FORCE) set(TINT_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(TINT_BUILD_CMD_TOOLS OFF CACHE BOOL "" FORCE) -# webgpu_dawn: webgpu.h C-API を提供する統合ライブラリ (バインディング層はこれを叩く) -set(DAWN_BUILD_MONOLITHIC_LIBRARY ON CACHE BOOL "" FORCE) +# webgpu_dawn: webgpu.h C-API を提供する統合ライブラリ (バインディング層はこれを叩く)。 +# 2025 以降の Dawn では SHARED / STATIC / OFF の3値。 +# SHARED (DLL) を選ぶ理由: V8 monolith と Dawn は双方が abseil を静的に内蔵しており、 +# STATIC だと raw_hash_set 等の同名シンボルが LNK2005 で衝突する (しかも absl の +# バージョンが異なるため /FORCE:MULTIPLE は実行時破壊のリスクがある)。 +# DLL にすれば公開されるのは WebGPU C API のみで、absl は DLL 内部に閉じる。 +set(DAWN_BUILD_MONOLITHIC_LIBRARY SHARED CACHE STRING "" FORCE) +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) FetchContent_Declare( dawn - GIT_REPOSITORY https://dawn.googlesource.com/dawn + # GitHub ミラーの方が CI から高速・安定 (googlesource はレート制限がきつい) + GIT_REPOSITORY https://github.com/google/dawn.git GIT_TAG ${DAWN_TAG} GIT_SHALLOW TRUE + # submodule は取得しない (DAWN_FETCH_DEPENDENCIES が必要分を取得する) + GIT_SUBMODULES "" + GIT_SUBMODULES_RECURSE FALSE ) FetchContent_MakeAvailable(dawn) @@ -40,3 +55,29 @@ FetchContent_MakeAvailable(dawn) if(NOT TARGET dawn::webgpu_dawn AND TARGET webgpu_dawn) add_library(dawn::webgpu_dawn ALIAS webgpu_dawn) endif() + +# ----------------------------------------------------------------------------- +# CRT を静的 (/MT) へ強制統一する。 +# CMAKE_MSVC_RUNTIME_LIBRARY 変数だけでは Dawn の third_party (fetch_dawn_dependencies +# が取得する abseil/protobuf 等) の一部に伝播せず /MD が混ざり、静的 CRT 固定の +# prebuilt V8 とのリンクで LNK2005/LNK2019 になる。全ターゲットへ再帰的に +# プロパティを直接設定して確実に揃える。 +# ----------------------------------------------------------------------------- +function(next2d_force_static_crt dir) + get_property(_targets DIRECTORY "${dir}" PROPERTY BUILDSYSTEM_TARGETS) + foreach(_t IN LISTS _targets) + get_target_property(_type ${_t} TYPE) + if(NOT _type STREQUAL "INTERFACE_LIBRARY") + set_property(TARGET ${_t} PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + endif() + endforeach() + get_property(_subdirs DIRECTORY "${dir}" PROPERTY SUBDIRECTORIES) + foreach(_sd IN LISTS _subdirs) + next2d_force_static_crt("${_sd}") + endforeach() +endfunction() + +if(MSVC) + next2d_force_static_crt("${dawn_SOURCE_DIR}") +endif() diff --git a/templates/xbox/cmake/FindV8.cmake b/templates/xbox/cmake/FindV8.cmake index bada275..22e306d 100644 --- a/templates/xbox/cmake/FindV8.cmake +++ b/templates/xbox/cmake/FindV8.cmake @@ -40,6 +40,21 @@ if(NOT V8_ROOT OR NOT EXISTS "${V8_ROOT}") "詳細は README.md の『V8 の用意』を参照。") endif() +# find_path/find_library の結果は CMakeCache に永続化されるため、 +# V8_ROOT の変更 (バージョン更新等) や参照先の消滅に追随しない。 +# 古い/無効な検出結果はここで破棄して再探索させる。 +if(DEFINED CACHE{NEXT2D_V8_ROOT_LAST} AND NOT "$CACHE{NEXT2D_V8_ROOT_LAST}" STREQUAL "${V8_ROOT}") + unset(V8_INCLUDE_DIR CACHE) + unset(V8_MONOLITH_LIB CACHE) +endif() +if(V8_INCLUDE_DIR AND NOT EXISTS "${V8_INCLUDE_DIR}/v8.h") + unset(V8_INCLUDE_DIR CACHE) +endif() +if(V8_MONOLITH_LIB AND NOT EXISTS "${V8_MONOLITH_LIB}") + unset(V8_MONOLITH_LIB CACHE) +endif() +set(NEXT2D_V8_ROOT_LAST "${V8_ROOT}" CACHE INTERNAL "直近の構成で使った V8_ROOT") + find_path(V8_INCLUDE_DIR NAMES v8.h PATHS "${V8_ROOT}/include" diff --git a/templates/xbox/js/bootstrap.js b/templates/xbox/js/bootstrap.js index 1e23404..476652c 100644 --- a/templates/xbox/js/bootstrap.js +++ b/templates/xbox/js/bootstrap.js @@ -25,7 +25,7 @@ // V8 単体には Web の Text* が含まれないため最小実装を提供する。 if (typeof global.TextEncoder === "undefined") { global.TextEncoder = class TextEncoder { - get encoding() { return "utf-8"; } + get encoding() { return "utf-8" } encode(str) { str = String(str); const out = []; @@ -34,23 +34,23 @@ if (code < 0x80) { out.push(code); } else if (code < 0x800) { - out.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f)); + out.push(0xc0 | code >> 6, 0x80 | code & 0x3f); } else if (code >= 0xd800 && code <= 0xdbff) { // サロゲートペア const hi = code; const lo = str.charCodeAt(++i); - code = 0x10000 + ((hi - 0xd800) << 10) + (lo - 0xdc00); + code = 0x10000 + (hi - 0xd800 << 10) + (lo - 0xdc00); out.push( - 0xf0 | (code >> 18), - 0x80 | ((code >> 12) & 0x3f), - 0x80 | ((code >> 6) & 0x3f), - 0x80 | (code & 0x3f) + 0xf0 | code >> 18, + 0x80 | code >> 12 & 0x3f, + 0x80 | code >> 6 & 0x3f, + 0x80 | code & 0x3f ); } else { out.push( - 0xe0 | (code >> 12), - 0x80 | ((code >> 6) & 0x3f), - 0x80 | (code & 0x3f) + 0xe0 | code >> 12, + 0x80 | code >> 6 & 0x3f, + 0x80 | code & 0x3f ); } } @@ -61,10 +61,10 @@ if (typeof global.TextDecoder === "undefined") { global.TextDecoder = class TextDecoder { - constructor(label) { this._label = label || "utf-8"; } - get encoding() { return "utf-8"; } + constructor(label) { this._label = label || "utf-8" } + get encoding() { return "utf-8" } decode(input) { - if (!input) return ""; + if (!input) { return "" } const bytes = input instanceof Uint8Array ? input : new Uint8Array(input.buffer || input); @@ -75,14 +75,14 @@ if (c < 0x80) { out += String.fromCharCode(c); } else if (c < 0xe0) { - out += String.fromCharCode(((c & 0x1f) << 6) | (bytes[i++] & 0x3f)); + out += String.fromCharCode((c & 0x1f) << 6 | bytes[i++] & 0x3f); } else if (c < 0xf0) { out += String.fromCharCode( - ((c & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f) + (c & 0x0f) << 12 | (bytes[i++] & 0x3f) << 6 | bytes[i++] & 0x3f ); } else { - let code = ((c & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | - ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f); + let code = (c & 0x07) << 18 | (bytes[i++] & 0x3f) << 12 | + (bytes[i++] & 0x3f) << 6 | bytes[i++] & 0x3f; code -= 0x10000; out += String.fromCharCode(0xd800 + (code >> 10), 0xdc00 + (code & 0x3ff)); } @@ -97,28 +97,31 @@ if (typeof global.URL === "undefined") { global.URL = class URL { constructor(url, base) { - this.href = base ? (String(base).replace(/\/?$/, "/") + url) : String(url); + this.href = base ? String(base).replace(/\/?$/, "/") + url : String(url); this.pathname = this.href.replace(/^[a-z]+:\/\/[^/]*/i, ""); this.search = ""; this.hash = ""; } - toString() { return this.href; } + toString() { return this.href } }; - global.URL.createObjectURL = function () { return ""; }; + global.URL.createObjectURL = function () { return "" }; + // 意図的な no-op スタブ (ローカル実行では解放処理は不要) + // eslint-disable-next-line no-empty-function global.URL.revokeObjectURL = function () {}; } // --- location (Next2D が参照する場合の最小値) --------------------------- if (typeof global.location === "undefined") { global.location = { - href: "app://local/", - origin: "app://local", - protocol: "app:", - host: "local", - hostname: "local", - pathname: "/", - search: "", - hash: "", + "href": "app://local/", + "origin": "app://local", + "protocol": "app:", + "host": "local", + "hostname": "local", + "pathname": "/", + "search": "", + "hash": "", + // eslint-disable-next-line no-empty-function reload() {} }; } diff --git a/templates/xbox/js/selftest.js b/templates/xbox/js/selftest.js index 0cf4aad..ca74dfa 100644 --- a/templates/xbox/js/selftest.js +++ b/templates/xbox/js/selftest.js @@ -15,7 +15,7 @@ */ (function () { - const results = { pass: 0, fail: 0, skip: 0 }; + const results = { "pass": 0, "fail": 0, "skip": 0 }; const failures = []; function tap(status, name, detail) { @@ -27,16 +27,16 @@ console.log("[selftest] " + line); } - function pass(name) { results.pass++; tap("ok", name); } + function pass(name) { results.pass++; tap("ok", name) } function fail(name, e) { results.fail++; const msg = e && e.message ? e.message : String(e); failures.push(name + ": " + msg); tap("fail", name, msg); } - function skip(name, why) { results.skip++; tap("skip", name, why); } + function skip(name, why) { results.skip++; tap("skip", name, why) } - function assert(cond, msg) { if (!cond) throw new Error(msg || "assertion failed"); } + function assert(cond, msg) { if (!cond) { throw new Error(msg || "assertion failed") } } function withTimeout(promise, ms, what) { return Promise.race([ @@ -109,18 +109,18 @@ const order = []; await new Promise((resolve) => { setTimeout(() => order.push("t10"), 10); - setTimeout(() => { order.push("t30"); resolve(); }, 30); + setTimeout(() => { order.push("t30"); resolve() }, 30); Promise.resolve().then(() => order.push("micro")); order.push("sync"); }); assert(order[0] === "sync" && order[1] === "micro", - "microtask order: " + order.join(",")); + "microtask order: " + order.join(",")); assert(order.indexOf("t10") < order.indexOf("t30"), "timer order"); let ticks = 0; await new Promise((resolve) => { const id = setInterval(() => { - if (++ticks >= 2) { clearInterval(id); resolve(); } + if (++ticks >= 2) { clearInterval(id); resolve() } }, 5); }); assert(ticks === 2, "interval stopped after clearInterval"); @@ -135,22 +135,101 @@ await test("EventTarget: addEventListener / dispatchEvent", () => { let got = null; - const handler = (e) => { got = e; }; + const handler = (e) => { got = e }; window.addEventListener("selftest-event", handler); - window.dispatchEvent({ type: "selftest-event", value: 42 }); + window.dispatchEvent({ "type": "selftest-event", "value": 42 }); assert(got && got.value === 42, "listener called"); window.removeEventListener("selftest-event", handler); got = null; - window.dispatchEvent({ type: "selftest-event" }); + window.dispatchEvent({ "type": "selftest-event" }); assert(got === null, "listener removed"); }); + await test("DOM: player boot パス (body/parentElement/getElementById)", () => { + // @next2d/player の起動シーケンスが要求する DOM API を検証する + const div = document.createElement("div"); + div.id = "selftest-main"; + div.tabIndex = -1; + document.body.appendChild(div); + assert(div.parentElement === document.body, "parentElement === body"); + assert(div.parentElement.tagName === "BODY", "body.tagName === BODY"); + div.setAttribute("style", "display:flex;width:640px;height:480px;"); + assert(div.clientWidth === 640 && div.clientHeight === 480, + "clientWidth/Height from style attr"); + div.innerHTML = "
"; + while (div.firstChild) { div.removeChild(div.firstChild) } + + const canvas = document.createElement("canvas"); + div.appendChild(canvas); + assert(div.children[0] === canvas, "children[0] === canvas"); + assert(canvas.localName === "canvas", "canvas.localName"); + assert(canvas.parentElement === div, "canvas.parentElement"); + + assert(document.getElementById("selftest-main") === div, "getElementById"); + assert(typeof Event === "function", "Event constructor"); + let fired = false; + div.addEventListener("selftest-dom", () => { fired = true }); + div.dispatchEvent(new Event("selftest-dom")); + assert(fired, "element dispatchEvent"); + div.remove(); + assert(document.getElementById("selftest-main") === null, "removed from tree"); + }); + + await test("DOM: history / location (framework gotoView パス)", () => { + // framework の gotoView は SPA 設定時に history.pushState を呼ぶ + assert(typeof history === "object" && typeof history.pushState === "function", + "history.pushState"); + const beforePath = location.pathname; + history.pushState("", "", location.origin + "/selftest-view?q=1"); + assert(location.pathname === "/selftest-view", "pushState -> location.pathname"); + assert(location.search === "?q=1", "pushState -> location.search"); + history.replaceState("", "", beforePath); + assert(location.pathname === beforePath, "replaceState で復元"); + assert(typeof location.origin === "string" && location.origin.length > 0, + "location.origin"); + }); + + await test("DOM: 未処理 Promise 拒否が握り潰されない (レポータ設置済み)", async () => { + // SetPromiseRejectCallback の存在自体は JS から観測できないため、 + // 拒否→後付けハンドラの一連が例外なく動くことのみ確認する + const p = Promise.reject(new Error("selftest-rejection (expected log)")); + await new Promise((resolve) => setTimeout(resolve, 10)); + let caught = false; + await p.catch(() => { caught = true }); + assert(caught, "late handler attached"); + }); + // ==================== 画像 ==================== let bitmap = null; await test("createImageBitmap: PNG デコード (WIC)", async () => { bitmap = await createImageBitmap(TEST_PNG.buffer); assert(bitmap.width === 2 && bitmap.height === 2, - "2x2 png -> " + bitmap.width + "x" + bitmap.height); + "2x2 png -> " + bitmap.width + "x" + bitmap.height); + }); + + await test("Image 要素: new Image + src(data:) -> load -> drawImage", async () => { + // player の ShapeLoadSrcUseCase と同じ利用パターン + const img = new Image(); + img.crossOrigin = "anonymous"; + const loaded = new Promise((resolve, reject) => { + img.addEventListener("load", resolve); + img.addEventListener("error", () => reject(new Error("image load error"))); + setTimeout(() => reject(new Error("image load timeout")), 3000); + }); + img.src = "data:image/png;base64," + btoa(String.fromCharCode.apply(null, TEST_PNG)); + await loaded; + assert(img.complete === true, "complete = true"); + assert(img.width === 2 && img.height === 2, + "size: " + img.width + "x" + img.height); + assert(img.naturalWidth === 2, "naturalWidth"); + + const c = new OffscreenCanvas(2, 2); + const ctx = c.getContext("2d", { "willReadFrequently": true }); + ctx.drawImage(img, 0, 0, 2, 2); + const d = ctx.getImageData(0, 0, 2, 2).data; + assert(d[0] === 255 && d[1] === 0 && d[2] === 0 && d[3] === 255, + "(0,0) red: " + d[0] + "," + d[1] + "," + d[2] + "," + d[3]); + assert(d[5] === 255, "(1,0) green"); }); // ==================== Canvas2D ==================== @@ -207,7 +286,7 @@ ctx.fillText("A", 4, 24); const d = ctx.getImageData(0, 0, 64, 32).data; let opaque = 0; - for (let i = 3; i < d.length; i += 4) if (d[i] > 128) opaque++; + for (let i = 3; i < d.length; i += 4) { if (d[i] > 128) { opaque++ } } assert(opaque > 5, "glyph pixels=" + opaque); }); @@ -221,13 +300,103 @@ assert(d[4] === 0 && d[5] === 255, "(1,0) green"); }); + // ==================== エンジン/グローバル (player の V8 機能全数調査ぶん) ==================== + await test("engine: crypto / ImageData→ImageBitmap / Proxy", async () => { + // crypto.randomUUID (player の WebGLUtil が ID 生成に使用) + const uuid = crypto.randomUUID(); + assert(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(uuid), + "uuid=" + uuid); + const rnd = crypto.getRandomValues(new Uint8Array(16)); + assert(rnd.length === 16 && rnd.some((b) => b !== 0), "getRandomValues"); + + // createImageBitmap(ImageData) — webgpu レンダラのキャプチャ/フィルタ経路 + const data = new Uint8ClampedArray(2 * 2 * 4); + data[0] = 255; data[3] = 255; // (0,0) = 赤 + const bmp = await createImageBitmap(new ImageData(data, 2, 2)); + assert(bmp.width === 2 && bmp.height === 2, "ImageData bitmap " + bmp.width + "x" + bmp.height); + const c = new OffscreenCanvas(2, 2); + const ctx = c.getContext("2d"); + ctx.drawImage(bmp, 0, 0); + const px = ctx.getImageData(0, 0, 1, 1).data; + assert(px[0] === 255, "ImageData pixel roundtrip"); + + // Proxy (player が4ファイルで使用。jitless V8 のコア機能) + const p = new Proxy({}, { "get": (_, k) => k === "x" ? 42 : undefined }); + assert(p.x === 42, "Proxy"); + + // ゲームコードが使うグローバル (UserSettings=localStorage, boot=readyState, confirm) + localStorage.setItem("__selftest", "1"); + assert(localStorage.getItem("__selftest") === "1", "localStorage roundtrip"); + localStorage.removeItem("__selftest"); + assert(localStorage.getItem("__selftest") === null, "localStorage remove"); + assert(document.readyState === "complete", "document.readyState"); + assert(typeof confirm("?") === "boolean", "confirm"); + }); + + await test("engine: WebAssembly (DrumBrake インタープリタ実行)", async () => { + // (module (func $add (param i32 i32) (result i32) ...) (export "add")) + // prebuilt V8 r2 (wasm + DrumBrake) が必要。実行時は --wasm-jitless で解釈実行。 + assert(typeof WebAssembly === "object", "WebAssembly global (V8 r2 required)"); + const wasmBytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7f, + 0x03, 0x02, 0x01, 0x00, + 0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, + 0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b + ]); + const { instance } = await WebAssembly.instantiate(wasmBytes); + assert(instance.exports.add(19, 23) === 42, "wasm add(19,23)"); + }); + + await test("engine: indexedDB / atob / btoa", async () => { + assert(atob(btoa("Next2D!")) === "Next2D!", "atob/btoa roundtrip"); + + const openReq = indexedDB.open("selftest-db", 1); + const db = await new Promise((resolve, reject) => { + openReq.onupgradeneeded = (e) => e.target.result.createObjectStore("kv"); + openReq.onsuccess = (e) => resolve(e.target.result); + openReq.onerror = () => reject(new Error("open failed")); + }); + const store = db.transaction(["kv"], "readwrite").objectStore("kv"); + await new Promise((resolve, reject) => { + const r = store.put({ "n": 1, "bin": new Uint8Array([7, 8]) }, "k"); + r.onsuccess = resolve; + r.onerror = () => reject(new Error("put failed")); + }); + const v = await new Promise((resolve, reject) => { + const r = db.transaction(["kv"]).objectStore("kv").get("k"); + r.onsuccess = (e) => resolve(e.target.result); + r.onerror = () => reject(new Error("get failed")); + }); + assert(v && v.n === 1 && v.bin instanceof Uint8Array && v.bin[1] === 8, + "indexedDB roundtrip (binary 値含む)"); + }); + + await test("engine: Worker 内の TextDecoder / location / crypto", async () => { + const src = "self.onmessage = function() { " + + "self.postMessage({ " + + " td: typeof TextDecoder, loc: typeof location, cr: typeof crypto, " + + " dec: new TextDecoder().decode(new TextEncoder().encode('abc')) " + + "}); };"; + const url = URL.createObjectURL(new Blob([src], { "type": "text/javascript" })); + const worker = new Worker(url); + const r = await new Promise((resolve) => { + worker.onmessage = (e) => resolve(e.data); + worker.postMessage(0); + }); + assert(r.td === "function" && r.loc === "object" && r.cr === "object", + JSON.stringify(r)); + assert(r.dec === "abc", "worker TextDecoder roundtrip"); + if (worker.terminate) { worker.terminate() } + }); + // ==================== ネットワーク (Blob / URL / XHR) ==================== let blobUrl = null; await test("Blob / URL.createObjectURL / revokeObjectURL", () => { - const blob = new Blob(["self.__marker = 1;"], { type: "text/javascript" }); + const blob = new Blob(["self.__marker = 1;"], { "type": "text/javascript" }); blobUrl = URL.createObjectURL(blob); assert(typeof blobUrl === "string" && blobUrl.indexOf("blob:") === 0, - "url=" + blobUrl); + "url=" + blobUrl); }); await softTest("XMLHttpRequest: アセット読込 (assets/app)", async () => { @@ -245,17 +414,49 @@ const src = "self.onmessage = function(e) { " + " self.postMessage({ echo: e.data.value * 2, arr: e.data.arr }); " + "};"; - const url = URL.createObjectURL(new Blob([src], { type: "text/javascript" })); + const url = URL.createObjectURL(new Blob([src], { "type": "text/javascript" })); const worker = new Worker(url); const reply = await new Promise((resolve, reject) => { worker.onmessage = (e) => resolve(e.data); worker.onerror = (e) => reject(new Error("worker error: " + (e && e.message))); - worker.postMessage({ value: 21, arr: new Float32Array([1.5, 2.5]) }); + worker.postMessage({ "value": 21, "arr": new Float32Array([1.5, 2.5]) }); }); assert(reply.echo === 42, "echo=" + reply.echo); assert(reply.arr instanceof Float32Array && reply.arr[1] === 2.5, - "structured clone TypedArray"); - if (worker.terminate) worker.terminate(); + "structured clone TypedArray"); + if (worker.terminate) { worker.terminate() } + }); + + await test("Worker: ArrayBuffer transfer + ImageBitmap clone (render 経路)", async () => { + // player の render キューと同じく、大きな buffer は transfer で移譲し、 + // ImageBitmap はホストオブジェクトとして複製して往復させる + const src = "self.onmessage = function(e) { " + + " var view = new Float32Array(e.data.buffer); " + + " self.postMessage({ " + + " sum: view[0] + view[1], " + + " len: e.data.buffer.byteLength, " + + " bmp: e.data.bmp, " + + " buffer: e.data.buffer " + + " }, [e.data.buffer]); " + + "};"; + const url = URL.createObjectURL(new Blob([src], { "type": "text/javascript" })); + const worker = new Worker(url); + const view = new Float32Array(1024); + view[0] = 1.5; + view[1] = 2.25; + const reply = await new Promise((resolve, reject) => { + worker.onmessage = (e) => resolve(e.data); + worker.onerror = (e) => reject(new Error("worker error: " + (e && e.message))); + worker.postMessage({ "buffer": view.buffer, "bmp": bitmap }, [view.buffer]); + assert(view.buffer.byteLength === 0, "送信側は detach される (transferable)"); + }); + assert(reply.sum === 3.75, "worker 側で中身が読める: " + reply.sum); + assert(reply.len === 4096, "worker 側 byteLength: " + reply.len); + assert(reply.buffer instanceof ArrayBuffer && reply.buffer.byteLength === 4096, + "worker→main への transfer 返却"); + assert(reply.bmp && reply.bmp.width === 2 && reply.bmp.height === 2, + "ImageBitmap クローン往復"); + if (worker.terminate) { worker.terminate() } }); await test("OffscreenCanvas: transferControlToOffscreen + Worker 転送", async () => { @@ -267,20 +468,22 @@ " var c = e.data.canvas; " + " self.postMessage({ w: c ? c.width : -1 }); " + "};"; - const url = URL.createObjectURL(new Blob([src], { type: "text/javascript" })); + const url = URL.createObjectURL(new Blob([src], { "type": "text/javascript" })); const worker = new Worker(url); const reply = await new Promise((resolve, reject) => { worker.onmessage = (e) => resolve(e.data); - worker.onerror = (e) => reject(new Error("worker error")); - worker.postMessage({ canvas: off }, [off]); + worker.onerror = (_e) => reject(new Error("worker error")); + worker.postMessage({ "canvas": off }, [off]); }); assert(reply.w === 8, "transferred canvas width=" + reply.w); - if (worker.terminate) worker.terminate(); + if (worker.terminate) { worker.terminate() } }); // ==================== WebGPU ==================== + // GPU の無い環境 (CI ランナー等) では adapter が取れず skip になる (softTest)。 + // 実機・GPU あり PC では全て実行される。 let device = null; - await test("WebGPU: requestAdapter / requestDevice / limits", async () => { + await softTest("WebGPU: requestAdapter / requestDevice / limits", async () => { assert(navigator.gpu, "navigator.gpu"); const format = navigator.gpu.getPreferredCanvasFormat(); assert(typeof format === "string" && format.length > 0, "preferred format=" + format); @@ -289,13 +492,13 @@ device = await adapter.requestDevice(); assert(device, "device"); assert(device.limits && device.limits.maxTextureDimension2D >= 4096, - "maxTextureDimension2D=" + (device.limits && device.limits.maxTextureDimension2D)); + "maxTextureDimension2D=" + (device.limits && device.limits.maxTextureDimension2D)); }); - await test("WebGPU: writeBuffer -> copyBufferToBuffer -> mapAsync 読み戻し", async () => { + await softTest("WebGPU: writeBuffer -> copyBufferToBuffer -> mapAsync 読み戻し", async () => { assert(device, "needs device"); - const src = device.createBuffer({ size: 16, usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST }); - const dst = device.createBuffer({ size: 16, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + const src = device.createBuffer({ "size": 16, "usage": GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST }); + const dst = device.createBuffer({ "size": 16, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); device.queue.writeBuffer(src, 0, new Uint32Array([1, 2, 3, 4])); const enc = device.createCommandEncoder(); enc.copyBufferToBuffer(src, 0, dst, 0, 16); @@ -306,20 +509,20 @@ assert(view[0] === 1 && view[3] === 4, "readback=" + Array.from(view).join(",")); }); - await test("WebGPU: copyExternalImageToTexture -> copyTextureToBuffer 読み戻し", async () => { + await softTest("WebGPU: copyExternalImageToTexture -> copyTextureToBuffer 読み戻し", async () => { assert(device && bitmap, "needs device+bitmap"); const tex = device.createTexture({ - size: { width: 2, height: 2 }, - format: "rgba8unorm", - usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC | GPUTextureUsage.RENDER_ATTACHMENT + "size": { "width": 2, "height": 2 }, + "format": "rgba8unorm", + "usage": GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC | GPUTextureUsage.RENDER_ATTACHMENT }); device.queue.copyExternalImageToTexture( - { source: bitmap }, { texture: tex }, { width: 2, height: 2 }); + { "source": bitmap }, { "texture": tex }, { "width": 2, "height": 2 }); // bytesPerRow は 256 アライン必須 - const buf = device.createBuffer({ size: 256 * 2, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + const buf = device.createBuffer({ "size": 256 * 2, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); const enc = device.createCommandEncoder(); enc.copyTextureToBuffer( - { texture: tex }, { buffer: buf, bytesPerRow: 256 }, { width: 2, height: 2 }); + { "texture": tex }, { "buffer": buf, "bytesPerRow": 256 }, { "width": 2, "height": 2 }); device.queue.submit([enc.finish()]); await buf.mapAsync(GPUMapMode.READ); const px = new Uint8Array(buf.getMappedRange().slice(0)); @@ -328,9 +531,9 @@ assert(px[4] === 0 && px[5] === 255, "(1,0) green"); }); - await test("WebGPU: dynamic offset 付き uniform で描画 (塗りの中核経路)", async () => { + await softTest("WebGPU: dynamic offset 付き uniform で描画 (塗りの中核経路)", async () => { assert(device, "needs device"); - const shader = device.createShaderModule({ code: ` + const shader = device.createShaderModule({ "code": ` struct U { color: vec4f }; @group(0) @binding(0) var u: U; @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { @@ -339,96 +542,251 @@ } @fragment fn fs() -> @location(0) vec4f { return u.color; } ` }); - const bgl = device.createBindGroupLayout({ entries: [{ - binding: 0, visibility: GPUShaderStage.FRAGMENT, - buffer: { type: "uniform", hasDynamicOffset: true } + const bgl = device.createBindGroupLayout({ "entries": [{ + "binding": 0, "visibility": GPUShaderStage.FRAGMENT, + "buffer": { "type": "uniform", "hasDynamicOffset": true } }] }); const pipeline = device.createRenderPipeline({ - layout: device.createPipelineLayout({ bindGroupLayouts: [bgl] }), - vertex: { module: shader, entryPoint: "vs" }, - fragment: { module: shader, entryPoint: "fs", - targets: [{ format: "rgba8unorm" }] }, - primitive: { topology: "triangle-list" } + "layout": device.createPipelineLayout({ "bindGroupLayouts": [bgl] }), + "vertex": { "module": shader, "entryPoint": "vs" }, + "fragment": { "module": shader, "entryPoint": "fs", + "targets": [{ "format": "rgba8unorm" }] }, + "primitive": { "topology": "triangle-list" } }); // offset 0 = 赤 / offset 256 = 緑。dynamic offset 256 で緑が描かれるべき。 - const ubo = device.createBuffer({ size: 512, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + const ubo = device.createBuffer({ "size": 512, "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); device.queue.writeBuffer(ubo, 0, new Float32Array([1, 0, 0, 1])); device.queue.writeBuffer(ubo, 256, new Float32Array([0, 1, 0, 1])); - const bindGroup = device.createBindGroup({ layout: bgl, entries: [{ - binding: 0, resource: { buffer: ubo, offset: 0, size: 16 } + const bindGroup = device.createBindGroup({ "layout": bgl, "entries": [{ + "binding": 0, "resource": { "buffer": ubo, "offset": 0, "size": 16 } }] }); const target = device.createTexture({ - size: { width: 4, height: 4 }, format: "rgba8unorm", - usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC + "size": { "width": 4, "height": 4 }, "format": "rgba8unorm", + "usage": GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC }); const enc = device.createCommandEncoder(); - const pass = enc.beginRenderPass({ colorAttachments: [{ - view: target.createView(), loadOp: "clear", storeOp: "store", - clearValue: [0, 0, 0, 0] + const pass = enc.beginRenderPass({ "colorAttachments": [{ + "view": target.createView(), "loadOp": "clear", "storeOp": "store", + "clearValue": [0, 0, 0, 0] }] }); pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup, [256]); // ← dynamic offset pass.draw(3); pass.end(); - const buf = device.createBuffer({ size: 256 * 4, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + const buf = device.createBuffer({ "size": 256 * 4, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); const enc2 = device.createCommandEncoder(); - enc2.copyTextureToBuffer({ texture: target }, { buffer: buf, bytesPerRow: 256 }, { width: 4, height: 4 }); + enc2.copyTextureToBuffer({ "texture": target }, { "buffer": buf, "bytesPerRow": 256 }, { "width": 4, "height": 4 }); device.queue.submit([enc.finish(), enc2.finish()]); await buf.mapAsync(GPUMapMode.READ); const px = new Uint8Array(buf.getMappedRange().slice(0)); buf.unmap(); assert(px[1] === 255 && px[0] === 0, - "dynamic offset 256 -> green, got rgba=" + px[0] + "," + px[1] + "," + px[2] + "," + px[3]); + "dynamic offset 256 -> green, got rgba=" + px[0] + "," + px[1] + "," + px[2] + "," + px[3]); }); - await test("WebGPU: stencil8 パイプライン + setStencilReference (マスク経路)", async () => { + await softTest("WebGPU: MSAA (sampleCount=4) + resolveTarget 解決 (player 全描画経路)", async () => { assert(device, "needs device"); - const shader = device.createShaderModule({ code: ` + // player は全描画を MSAA テクスチャへ行い resolveTarget で本体へ解決する。 + // resolveTarget が無視されると描画結果がどこにも現れない (黒画面の実原因だった) + const shader = device.createShaderModule({ "code": ` @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { var p = array(vec2f(-1,-3), vec2f(3,1), vec2f(-1,1)); return vec4f(p[i], 0, 1); } @fragment fn fs() -> @location(0) vec4f { return vec4f(0, 0, 1, 1); } ` }); - const layout = device.createPipelineLayout({ bindGroupLayouts: [] }); + const pipeline = device.createRenderPipeline({ + "layout": "auto", + "vertex": { "module": shader, "entryPoint": "vs" }, + "fragment": { "module": shader, "entryPoint": "fs", + "targets": [{ "format": "rgba8unorm" }] }, + "primitive": { "topology": "triangle-list" }, + "multisample": { "count": 4 } + }); + const msaa = device.createTexture({ + "size": { "width": 4, "height": 4 }, "format": "rgba8unorm", + "sampleCount": 4, + "usage": GPUTextureUsage.RENDER_ATTACHMENT + }); + const resolve = device.createTexture({ + "size": { "width": 4, "height": 4 }, "format": "rgba8unorm", + "usage": GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC + }); + const enc = device.createCommandEncoder(); + const pass = enc.beginRenderPass({ "colorAttachments": [{ + "view": msaa.createView(), + "resolveTarget": resolve.createView(), + "loadOp": "clear", "storeOp": "store", + "clearValue": [0, 0, 0, 0] + }] }); + pass.setPipeline(pipeline); + pass.draw(3); + pass.end(); + const buf = device.createBuffer({ "size": 256 * 4, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + const enc2 = device.createCommandEncoder(); + enc2.copyTextureToBuffer({ "texture": resolve }, { "buffer": buf, "bytesPerRow": 256 }, { "width": 4, "height": 4 }); + device.queue.submit([enc.finish(), enc2.finish()]); + await buf.mapAsync(GPUMapMode.READ); + const px = new Uint8Array(buf.getMappedRange().slice(0)); + buf.unmap(); + assert(px[2] === 255 && px[3] === 255, + "resolve target has blue, got rgba=" + px[0] + "," + px[1] + "," + px[2] + "," + px[3]); + }); + + await softTest("WebGPU: MSAA ステンシルマスク (player clip 経路の忠実再現)", async () => { + assert(device, "needs device"); + // player の mask: (1) writeMask=0 でステンシルへ形状を書く (resolveTarget なし) + // (2) stencil compare=equal で内容を描く (resolveTarget あり) + // これが崩れるとマスクが効かず、隠されるべきレイヤーが全面に描かれる + const W = 8, H = 8; + const shader = device.createShaderModule({ "code": ` + @vertex fn vsLeft(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { + // 左半分を覆う矩形 (2 三角形) + var p = array( + vec2f(-1,-1), vec2f(0,-1), vec2f(-1,1), + vec2f(-1,1), vec2f(0,-1), vec2f(0,1)); + return vec4f(p[i], 0, 1); + } + @vertex fn vsFull(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { + var p = array(vec2f(-1,-3), vec2f(3,1), vec2f(-1,1)); + return vec4f(p[i], 0, 1); + } + @fragment fn fs() -> @location(0) vec4f { return vec4f(1, 0, 0, 1); } + @fragment fn fsBlue() -> @location(0) vec4f { return vec4f(0, 0, 1, 1); } + ` }); + const msaaColor = device.createTexture({ + "size": { "width": W, "height": H }, "format": "rgba8unorm", + "sampleCount": 4, "usage": GPUTextureUsage.RENDER_ATTACHMENT + }); + const resolve = device.createTexture({ + "size": { "width": W, "height": H }, "format": "rgba8unorm", + "usage": GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC + }); + const msaaStencil = device.createTexture({ + "size": { "width": W, "height": H }, "format": "stencil8", + "sampleCount": 4, "usage": GPUTextureUsage.RENDER_ATTACHMENT + }); + const clipPipeline = device.createRenderPipeline({ + "layout": "auto", + "vertex": { "module": shader, "entryPoint": "vsLeft" }, + "fragment": { "module": shader, "entryPoint": "fs", + "targets": [{ "format": "rgba8unorm", "writeMask": 0 }] }, + "primitive": { "topology": "triangle-list" }, + "multisample": { "count": 4 }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { "compare": "always", "passOp": "replace" }, + "stencilBack": { "compare": "always", "passOp": "replace" } + } + }); + const drawPipeline = device.createRenderPipeline({ + "layout": "auto", + "vertex": { "module": shader, "entryPoint": "vsFull" }, + "fragment": { "module": shader, "entryPoint": "fsBlue", + "targets": [{ "format": "rgba8unorm" }] }, + "primitive": { "topology": "triangle-list" }, + "multisample": { "count": 4 }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { "compare": "equal", "passOp": "keep" }, + "stencilBack": { "compare": "equal", "passOp": "keep" } + } + }); + const enc = device.createCommandEncoder(); + // pass 1: マスク形状 (左半分) をステンシルに書く。色は writeMask=0 で不変 + const p1 = enc.beginRenderPass({ + "colorAttachments": [{ + "view": msaaColor.createView(), + "loadOp": "clear", "storeOp": "store", "clearValue": [0, 0, 0, 0] + }], + "depthStencilAttachment": { + "view": msaaStencil.createView(), + "stencilLoadOp": "clear", "stencilStoreOp": "store", + "stencilClearValue": 0 + } + }); + p1.setPipeline(clipPipeline); + p1.setStencilReference(1); + p1.draw(6); + p1.end(); + // pass 2: 全面に青を stencil==1 の場所だけ描き、resolve へ解決 + const p2 = enc.beginRenderPass({ + "colorAttachments": [{ + "view": msaaColor.createView(), + "resolveTarget": resolve.createView(), + "loadOp": "load", "storeOp": "store" + }], + "depthStencilAttachment": { + "view": msaaStencil.createView(), + "stencilLoadOp": "load", "stencilStoreOp": "store" + } + }); + p2.setPipeline(drawPipeline); + p2.setStencilReference(1); + p2.draw(3); + p2.end(); + const buf = device.createBuffer({ "size": 256 * H, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + enc.copyTextureToBuffer({ "texture": resolve }, { "buffer": buf, "bytesPerRow": 256 }, { "width": W, "height": H }); + device.queue.submit([enc.finish()]); + await buf.mapAsync(GPUMapMode.READ); + const px = new Uint8Array(buf.getMappedRange().slice(0)); + buf.unmap(); + const at = (x, y) => y * 256 + x * 4; + const l = at(2, 4), r = at(6, 4); + assert(px[l + 2] === 255 && px[l + 3] === 255, + "マスク内 (左) は青: rgba=" + px[l] + "," + px[l + 1] + "," + px[l + 2] + "," + px[l + 3]); + assert(px[r + 3] === 0, + "マスク外 (右) は透明のまま: rgba=" + px[r] + "," + px[r + 1] + "," + px[r + 2] + "," + px[r + 3]); + }); + + await softTest("WebGPU: stencil8 パイプライン + setStencilReference (マスク経路)", async () => { + assert(device, "needs device"); + const shader = device.createShaderModule({ "code": ` + @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { + var p = array(vec2f(-1,-3), vec2f(3,1), vec2f(-1,1)); + return vec4f(p[i], 0, 1); + } + @fragment fn fs() -> @location(0) vec4f { return vec4f(0, 0, 1, 1); } + ` }); + const layout = device.createPipelineLayout({ "bindGroupLayouts": [] }); // 1st: ステンシルへ replace 書き込み (色は書かない設定でも良いが簡略化) const writePipe = device.createRenderPipeline({ - layout, vertex: { module: shader, entryPoint: "vs" }, - fragment: { module: shader, entryPoint: "fs", targets: [{ format: "rgba8unorm" }] }, - primitive: { topology: "triangle-list" }, - depthStencil: { - format: "stencil8", - stencilFront: { compare: "always", failOp: "keep", depthFailOp: "keep", passOp: "replace" }, - stencilBack: { compare: "always", failOp: "keep", depthFailOp: "keep", passOp: "replace" }, - stencilReadMask: 0xff, stencilWriteMask: 0xff + layout, "vertex": { "module": shader, "entryPoint": "vs" }, + "fragment": { "module": shader, "entryPoint": "fs", "targets": [{ "format": "rgba8unorm" }] }, + "primitive": { "topology": "triangle-list" }, + "depthStencil": { + "format": "stencil8", + "stencilFront": { "compare": "always", "failOp": "keep", "depthFailOp": "keep", "passOp": "replace" }, + "stencilBack": { "compare": "always", "failOp": "keep", "depthFailOp": "keep", "passOp": "replace" }, + "stencilReadMask": 0xff, "stencilWriteMask": 0xff } }); // 2nd: equal 比較で描画 (マスク内のみ) const testPipe = device.createRenderPipeline({ - layout, vertex: { module: shader, entryPoint: "vs" }, - fragment: { module: shader, entryPoint: "fs", targets: [{ format: "rgba8unorm" }] }, - primitive: { topology: "triangle-list" }, - 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 + layout, "vertex": { "module": shader, "entryPoint": "vs" }, + "fragment": { "module": shader, "entryPoint": "fs", "targets": [{ "format": "rgba8unorm" }] }, + "primitive": { "topology": "triangle-list" }, + "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 } }); const color = device.createTexture({ - size: { width: 4, height: 4 }, format: "rgba8unorm", - usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC + "size": { "width": 4, "height": 4 }, "format": "rgba8unorm", + "usage": GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC }); const stencil = device.createTexture({ - size: { width: 4, height: 4 }, format: "stencil8", - usage: GPUTextureUsage.RENDER_ATTACHMENT + "size": { "width": 4, "height": 4 }, "format": "stencil8", + "usage": GPUTextureUsage.RENDER_ATTACHMENT }); const enc = device.createCommandEncoder(); const p1 = enc.beginRenderPass({ - colorAttachments: [{ view: color.createView(), loadOp: "clear", storeOp: "store", clearValue: [0,0,0,0] }], - depthStencilAttachment: { - view: stencil.createView(), - stencilLoadOp: "clear", stencilStoreOp: "store", stencilClearValue: 0 + "colorAttachments": [{ "view": color.createView(), "loadOp": "clear", "storeOp": "store", "clearValue": [0,0,0,0] }], + "depthStencilAttachment": { + "view": stencil.createView(), + "stencilLoadOp": "clear", "stencilStoreOp": "store", "stencilClearValue": 0 } }); p1.setPipeline(writePipe); @@ -436,18 +794,18 @@ p1.draw(3); p1.end(); const p2 = enc.beginRenderPass({ - colorAttachments: [{ view: color.createView(), loadOp: "load", storeOp: "store" }], - depthStencilAttachment: { - view: stencil.createView(), - stencilLoadOp: "load", stencilStoreOp: "store" + "colorAttachments": [{ "view": color.createView(), "loadOp": "load", "storeOp": "store" }], + "depthStencilAttachment": { + "view": stencil.createView(), + "stencilLoadOp": "load", "stencilStoreOp": "store" } }); p2.setPipeline(testPipe); p2.setStencilReference(1); // ref==stencil(1) -> 描画される p2.draw(3); p2.end(); - const buf = device.createBuffer({ size: 256 * 4, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); - enc.copyTextureToBuffer({ texture: color }, { buffer: buf, bytesPerRow: 256 }, { width: 4, height: 4 }); + const buf = device.createBuffer({ "size": 256 * 4, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + enc.copyTextureToBuffer({ "texture": color }, { "buffer": buf, "bytesPerRow": 256 }, { "width": 4, "height": 4 }); device.queue.submit([enc.finish()]); await buf.mapAsync(GPUMapMode.READ); const px = new Uint8Array(buf.getMappedRange().slice(0)); @@ -455,6 +813,284 @@ assert(px[2] === 255, "stencil equal pass drew blue, got rgba=" + px[0] + "," + px[1] + "," + px[2]); }); + await softTest("WebGPU: pipeline-overridable constants (override がパイプラインへ反映)", async () => { + assert(device, "needs device"); + // player は override 定数 (GRADIENT_TYPE=linear/radial, SPREAD_MODE, フィルタ種別, + // yFlipSign) でシェーダを特殊化する。ホストが fragment/vertex.constants を読まないと + // 全パイプラインが WGSL 既定値でコンパイルされ、放射状グラデが線形に潰れる等になる。 + // ここで override が実際に効くか (既定値でなく指定値でコンパイルされるか) を検証する。 + const shader = device.createShaderModule({ "code": ` + override SELECT : u32 = 0u; + override TINT : f32 = 0.25; + @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { + var p = array(vec2f(-1,-3), vec2f(3,1), vec2f(-1,1)); + return vec4f(p[i], 0, 1); + } + @fragment fn fs() -> @location(0) vec4f { + if (SELECT == 1u) { return vec4f(0, TINT, 0, 1); } + return vec4f(TINT, 0, 0, 1); + } + ` }); + const mk = (constants) => device.createRenderPipeline({ + "layout": "auto", + "vertex": { "module": shader, "entryPoint": "vs" }, + "fragment": { "module": shader, "entryPoint": "fs", + "constants": constants, + "targets": [{ "format": "rgba8unorm" }] }, + "primitive": { "topology": "triangle-list" } + }); + const draw = async (pipeline) => { + const target = device.createTexture({ + "size": { "width": 2, "height": 2 }, "format": "rgba8unorm", + "usage": GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC }); + const enc = device.createCommandEncoder(); + const pass = enc.beginRenderPass({ "colorAttachments": [{ + "view": target.createView(), "loadOp": "clear", "storeOp": "store", + "clearValue": [0, 0, 0, 1] }] }); + pass.setPipeline(pipeline); pass.draw(3); pass.end(); + // 2 行分: copyTextureToBuffer は size >= bytesPerRow*(h-1)+w*4 = 264 を要求する + const buf = device.createBuffer({ "size": 256 * 2, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + enc.copyTextureToBuffer({ "texture": target }, { "buffer": buf, "bytesPerRow": 256 }, { "width": 2, "height": 2 }); + device.queue.submit([enc.finish()]); + await buf.mapAsync(GPUMapMode.READ); + const px = new Uint8Array(buf.getMappedRange().slice(0)); buf.unmap(); + return [px[0], px[1], px[2]]; + }; + // constants が無視されると両方 SELECT=0/TINT=0.25 (既定) の赤~64 になり検証に落ちる。 + const green = await draw(mk({ "SELECT": 1, "TINT": 0.75 })); + const red = await draw(mk({ "SELECT": 0, "TINT": 1.0 })); + assert(green[1] > 150 && green[0] < 60, + "override SELECT=1/TINT=0.75 -> 緑: rgb=" + green.join(",")); + assert(red[0] > 240 && red[1] < 40, + "override SELECT=0/TINT=1.0 -> 赤最大: rgb=" + red.join(",")); + }); + + await softTest("WebGPU: multisample.alphaToCoverageEnabled (ベクタ縁 AA)", async () => { + assert(device, "needs device"); + // Next2D はベクタ図形の境界を塗り alpha に頼り、MSAA カバレッジで AA する。 + // このフィールドが無視されると縁がジャギる。alpha=0.5 で部分被覆になるかを検証。 + const shader = device.createShaderModule({ "code": ` + @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { + var p = array(vec2f(-1,-3), vec2f(3,1), vec2f(-1,1)); + return vec4f(p[i], 0, 1); + } + @fragment fn fs() -> @location(0) vec4f { return vec4f(1, 0, 0, 0.5); } + ` }); + const pipeline = device.createRenderPipeline({ + "layout": "auto", + "vertex": { "module": shader, "entryPoint": "vs" }, + "fragment": { "module": shader, "entryPoint": "fs", "targets": [{ "format": "rgba8unorm" }] }, + "primitive": { "topology": "triangle-list" }, + "multisample": { "count": 4, "alphaToCoverageEnabled": true } + }); + const msaa = device.createTexture({ "size": { "width": 4, "height": 4 }, "format": "rgba8unorm", + "sampleCount": 4, "usage": GPUTextureUsage.RENDER_ATTACHMENT }); + const resolve = device.createTexture({ "size": { "width": 4, "height": 4 }, "format": "rgba8unorm", + "usage": GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC }); + const enc = device.createCommandEncoder(); + const pass = enc.beginRenderPass({ "colorAttachments": [{ + "view": msaa.createView(), "resolveTarget": resolve.createView(), + "loadOp": "clear", "storeOp": "store", "clearValue": [0, 0, 0, 1] }] }); + pass.setPipeline(pipeline); pass.draw(3); pass.end(); + const buf = device.createBuffer({ "size": 256 * 4, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + enc.copyTextureToBuffer({ "texture": resolve }, { "buffer": buf, "bytesPerRow": 256 }, { "width": 4, "height": 4 }); + device.queue.submit([enc.finish()]); + await buf.mapAsync(GPUMapMode.READ); + const px = new Uint8Array(buf.getMappedRange().slice(0)); buf.unmap(); + // alpha 0.5 + alphaToCoverage -> ~2/4 サンプル被覆 -> resolve は中間の赤。 + // フィールドが無視されると全サンプル被覆で赤=255 になり検証に落ちる。 + assert(px[0] > 20 && px[0] < 235, + "alpha-to-coverage で部分被覆 (中間の赤): rgba=" + px[0] + "," + px[1] + "," + px[2] + "," + px[3]); + }); + + await softTest("WebGPU: writeTexture 宛先 origin (アトラスのセル位置)", async () => { + assert(device, "needs device"); + // player はアトラスの (node.x, node.y) へ writeTexture する。origin が無視されると + // 常に (0,0) に書かれてアトラスが破損する。origin(2,2) の書込みが右下に出るか検証。 + const tex = device.createTexture({ "size": { "width": 4, "height": 4 }, "format": "rgba8unorm", + "usage": GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC }); + const red = new Uint8Array(2 * 2 * 4); + for (let i = 0; i < red.length; i += 4) { red[i] = 255; red[i + 3] = 255; } + device.queue.writeTexture({ "texture": tex, "origin": { "x": 2, "y": 2, "z": 0 } }, + red, { "bytesPerRow": 2 * 4, "rowsPerImage": 2 }, { "width": 2, "height": 2 }); + const buf = device.createBuffer({ "size": 256 * 4, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + const enc = device.createCommandEncoder(); + enc.copyTextureToBuffer({ "texture": tex }, { "buffer": buf, "bytesPerRow": 256 }, { "width": 4, "height": 4 }); + device.queue.submit([enc.finish()]); + await buf.mapAsync(GPUMapMode.READ); + const px = new Uint8Array(buf.getMappedRange().slice(0)); buf.unmap(); + const at = (x, y) => y * 256 + x * 4; + assert(px[at(3, 3)] === 255, "origin(2,2) の赤が右下(3,3)に存在: got=" + px[at(3, 3)]); + assert(px[at(0, 0)] === 0, "左上(0,0)は空 (origin が (0,0) に潰れていない): got=" + px[at(0, 0)]); + }); + + await softTest("WebGPU: compute pipeline + storage buffer 書き戻し (dispatchWorkgroups)", async () => { + assert(device, "needs device"); + // Next2D の 2D ラスタライザは compute を使わないが、WebGPU の機能を特定用途に + // 絞らず全面的に利用可能にするため compute 経路を検証する。storage buffer への + // 書込みと compute.constants (override) の反映を 1 テストで確認する。 + const shader = device.createShaderModule({ "code": ` + override MUL : u32 = 1u; + @group(0) @binding(0) var outBuf : array; + @compute @workgroup_size(4) fn main(@builtin(global_invocation_id) gid : vec3u) { + outBuf[gid.x] = gid.x * MUL; + } + ` }); + const pipeline = device.createComputePipeline({ + "layout": "auto", + "compute": { "module": shader, "entryPoint": "main", "constants": { "MUL": 3 } } + }); + const N = 4; + const storage = device.createBuffer({ "size": N * 4, + "usage": GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC }); + const bindGroup = device.createBindGroup({ + "layout": pipeline.getBindGroupLayout(0), + "entries": [{ "binding": 0, "resource": { "buffer": storage } }] + }); + const enc = device.createCommandEncoder(); + const pass = enc.beginComputePass(); + pass.setPipeline(pipeline); + pass.setBindGroup(0, bindGroup); + pass.dispatchWorkgroups(1); + pass.end(); + const readback = device.createBuffer({ "size": N * 4, + "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + enc.copyBufferToBuffer(storage, 0, readback, 0, N * 4); + device.queue.submit([enc.finish()]); + await readback.mapAsync(GPUMapMode.READ); + const out = new Uint32Array(readback.getMappedRange().slice(0)); readback.unmap(); + // MUL=3 override が効けば [0,3,6,9]。無視 (既定 1) なら [0,1,2,3] で落ちる。 + assert(out[0] === 0 && out[1] === 3 && out[2] === 6 && out[3] === 9, + "compute storage 書込み + override MUL=3: got=" + Array.from(out).join(",")); + }); + + await softTest("WebGPU: error scope (pushErrorScope/popErrorScope)", async () => { + assert(device, "needs device"); + // 正常操作 → popErrorScope は null。不正操作 → validation error を捕捉。 + device.pushErrorScope("validation"); + device.createBuffer({ "size": 16, "usage": GPUBufferUsage.COPY_DST }); + const noErr = await device.popErrorScope(); + assert(noErr === null, "正常操作では error なし: got=" + noErr); + device.pushErrorScope("validation"); + // MAP_READ | MAP_WRITE の同時指定は不正 (validation error) + device.createBuffer({ "size": 16, "usage": GPUBufferUsage.MAP_READ | GPUBufferUsage.MAP_WRITE }); + const err = await device.popErrorScope(); + assert(err !== null, "不正操作で validation error を捕捉 (null でない)"); + }); + + await softTest("WebGPU: render bundle (createRenderBundleEncoder/executeBundles)", async () => { + assert(device, "needs device"); + const shader = device.createShaderModule({ "code": ` + @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { + var p = array(vec2f(-1,-3), vec2f(3,1), vec2f(-1,1)); + return vec4f(p[i], 0, 1); + } + @fragment fn fs() -> @location(0) vec4f { return vec4f(0, 1, 0, 1); } + ` }); + const pipeline = device.createRenderPipeline({ + "layout": "auto", + "vertex": { "module": shader, "entryPoint": "vs" }, + "fragment": { "module": shader, "entryPoint": "fs", "targets": [{ "format": "rgba8unorm" }] }, + "primitive": { "topology": "triangle-list" } + }); + const bundleEnc = device.createRenderBundleEncoder({ "colorFormats": ["rgba8unorm"] }); + bundleEnc.setPipeline(pipeline); bundleEnc.draw(3); + const bundle = bundleEnc.finish(); + const target = device.createTexture({ "size": { "width": 2, "height": 2 }, "format": "rgba8unorm", + "usage": GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC }); + const enc = device.createCommandEncoder(); + const pass = enc.beginRenderPass({ "colorAttachments": [{ + "view": target.createView(), "loadOp": "clear", "storeOp": "store", "clearValue": [0, 0, 0, 1] }] }); + pass.executeBundles([bundle]); pass.end(); + const buf = device.createBuffer({ "size": 256 * 2, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + enc.copyTextureToBuffer({ "texture": target }, { "buffer": buf, "bytesPerRow": 256 }, { "width": 2, "height": 2 }); + device.queue.submit([enc.finish()]); + await buf.mapAsync(GPUMapMode.READ); + const px = new Uint8Array(buf.getMappedRange().slice(0)); buf.unmap(); + // bundle 経由の draw が実行されないと clearValue の黒のまま。 + assert(px[1] > 240 && px[0] < 40, "render bundle 経由の緑描画: rgb=" + px[0] + "," + px[1] + "," + px[2]); + }); + + await softTest("WebGPU: occlusion query (createQuerySet/beginOcclusionQuery/resolveQuerySet)", async () => { + assert(device, "needs device"); + const shader = device.createShaderModule({ "code": ` + @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { + var p = array(vec2f(-1,-3), vec2f(3,1), vec2f(-1,1)); + return vec4f(p[i], 0, 1); + } + @fragment fn fs() -> @location(0) vec4f { return vec4f(1, 0, 0, 1); } + ` }); + const pipeline = device.createRenderPipeline({ + "layout": "auto", + "vertex": { "module": shader, "entryPoint": "vs" }, + "fragment": { "module": shader, "entryPoint": "fs", "targets": [{ "format": "rgba8unorm" }] }, + "primitive": { "topology": "triangle-list" } + }); + const querySet = device.createQuerySet({ "type": "occlusion", "count": 1 }); + assert(querySet.count === 1 && querySet.type === "occlusion", + "querySet メタ: count=" + querySet.count + " type=" + querySet.type); + const target = device.createTexture({ "size": { "width": 2, "height": 2 }, "format": "rgba8unorm", + "usage": GPUTextureUsage.RENDER_ATTACHMENT }); + const enc = device.createCommandEncoder(); + const pass = enc.beginRenderPass({ "colorAttachments": [{ + "view": target.createView(), "loadOp": "clear", "storeOp": "store", "clearValue": [0, 0, 0, 1] }], + "occlusionQuerySet": querySet }); + pass.setPipeline(pipeline); + pass.beginOcclusionQuery(0); pass.draw(3); pass.endOcclusionQuery(); + pass.end(); + const resolveBuf = device.createBuffer({ "size": 8, "usage": GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC }); + enc.resolveQuerySet(querySet, 0, 1, resolveBuf, 0); + const readBuf = device.createBuffer({ "size": 8, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + enc.copyBufferToBuffer(resolveBuf, 0, readBuf, 0, 8); + device.queue.submit([enc.finish()]); + await readBuf.mapAsync(GPUMapMode.READ); + const counts = new Uint32Array(readBuf.getMappedRange().slice(0)); readBuf.unmap(); + // 全画面三角形を描いたので被覆サンプル数 > 0 (query 未対応なら 0 のまま)。 + assert(counts[0] > 0, "occlusion query 被覆サンプル数 > 0: got=" + counts[0]); + }); + + await softTest("WebGPU: async pipeline + onSubmittedWorkDone", async () => { + assert(device, "needs device"); + const shader = device.createShaderModule({ "code": ` + @vertex fn vs() -> @builtin(position) vec4f { return vec4f(0, 0, 0, 1); } + @fragment fn fs() -> @location(0) vec4f { return vec4f(1, 0, 0, 1); } + ` }); + const rp = await device.createRenderPipelineAsync({ + "layout": "auto", + "vertex": { "module": shader, "entryPoint": "vs" }, + "fragment": { "module": shader, "entryPoint": "fs", "targets": [{ "format": "rgba8unorm" }] }, + "primitive": { "topology": "point-list" } + }); + assert(rp && typeof rp.getBindGroupLayout === "function", "createRenderPipelineAsync が pipeline を返す"); + const cs = device.createShaderModule({ "code": ` + @group(0) @binding(0) var o : array; + @compute @workgroup_size(1) fn main() { o[0] = 1u; } + ` }); + const cp = await device.createComputePipelineAsync({ + "layout": "auto", "compute": { "module": cs, "entryPoint": "main" } }); + assert(cp && typeof cp.getBindGroupLayout === "function", "createComputePipelineAsync が pipeline を返す"); + device.queue.submit([]); + await device.queue.onSubmittedWorkDone(); // resolve すれば成功 + assert(true, "onSubmittedWorkDone が resolve"); + }); + + await softTest("WebGPU: debug marker / device.lost / device.destroy", async () => { + assert(device, "needs device"); + // debug marker は結果に影響しないが呼べること (no-throw) を確認する。 + const enc = device.createCommandEncoder(); + enc.pushDebugGroup("group"); enc.insertDebugMarker("marker"); enc.popDebugGroup(); + const target = device.createTexture({ "size": { "width": 2, "height": 2 }, "format": "rgba8unorm", + "usage": GPUTextureUsage.RENDER_ATTACHMENT }); + const pass = enc.beginRenderPass({ "colorAttachments": [{ + "view": target.createView(), "loadOp": "clear", "storeOp": "store", "clearValue": [0, 0, 0, 1] }] }); + pass.pushDebugGroup("pass"); pass.insertDebugMarker("pm"); pass.popDebugGroup(); + pass.end(); + device.queue.submit([enc.finish()]); + // device.lost は Promise、destroy は関数として存在すること (device 共有中なので実際には呼ばない)。 + assert(device.lost && typeof device.lost.then === "function", "device.lost は Promise"); + assert(typeof device.destroy === "function", "device.destroy が存在"); + }); + // ==================== Audio ==================== let audioCtx = null; await test("AudioContext: decodeAudioData (Media Foundation)", async () => { @@ -465,6 +1101,20 @@ globalThis.__selftestAudioBuffer = buffer; }); + await test("Audio 要素 (new Audio / play / pause)", async () => { + // ゲームコードが効果音再生に使う HTMLAudioElement 最小実装 + const a = new Audio(); // URL なし (ボイス無しでも API は成立する) + a.volume = 0; + a.loop = false; + a.preload = "auto"; + a.currentTime = 0; + assert(a.paused === true, "initial paused"); + await a.play(); + assert(a.paused === false, "playing"); + a.pause(); + assert(a.paused === true, "paused after pause()"); + }); + await softTest("Audio: gain 接続 + 再生 + ended 発火", async () => { assert(audioCtx && globalThis.__selftestAudioBuffer, "needs decoded buffer"); const gainNode = audioCtx.createGain(); @@ -481,8 +1131,8 @@ // ==================== 入力 / その他 ==================== await test("Gamepad: navigator.getGamepads()", () => { const pads = navigator.getGamepads(); - assert(Array.isArray(pads) || (pads && typeof pads.length === "number"), - "returns array-like"); + assert(Array.isArray(pads) || pads && typeof pads.length === "number", + "returns array-like"); }); await softTest("clipboard: writeText / readText 往復", async () => { diff --git a/templates/xbox/src/EventLoop.cpp b/templates/xbox/src/EventLoop.cpp index f13616d..10030f6 100644 --- a/templates/xbox/src/EventLoop.cpp +++ b/templates/xbox/src/EventLoop.cpp @@ -63,6 +63,22 @@ void EventLoop::ClearTimer(uint32_t id) uint32_t EventLoop::RequestAnimationFrame(v8::Local callback) { const uint32_t id = next_raf_id_++; + + // 診断: 誰が rAF を登録しているか (関数名 + minify 後スクリプト位置)。 + // 最初の 40 件 + 以後 300 件毎にサンプルし、凍結後も生きている登録元を特定する。 + static uint64_t reg_count = 0; + ++reg_count; + if (reg_count <= 40 || reg_count % 300 == 0) { + const std::string name = + v8util::ToStdString(isolate_, callback->GetDebugName()); + v8::Local res = callback->GetScriptOrigin().ResourceName(); + std::cerr << "[Loop] rAF register #" << reg_count + << " fn=" << (name.empty() ? "(anon)" : name) + << " at " << v8util::ToStdString(isolate_, res) + << ":" << callback->GetScriptLineNumber() + 1 + << ":" << callback->GetScriptColumnNumber() + 1 << std::endl; + } + v8::Global global; global.Reset(isolate_, callback); raf_callbacks_.emplace_back(id, std::move(global)); @@ -71,6 +87,16 @@ uint32_t EventLoop::RequestAnimationFrame(v8::Local callback) void EventLoop::CancelAnimationFrame(uint32_t id) { + // 未実行分は即座に取り除く。raf_cancelled_ だけに頼ると、RunAnimationFrame の + // 実行中に登録されたコールバックへのキャンセルが同フレーム末尾の clear で失われ、 + // キャンセル済みのはずの rAF が翌フレームに実行されてしまう (ticker/Tween の多重化)。 + for (auto it = raf_callbacks_.begin(); it != raf_callbacks_.end(); ++it) { + if (it->first == id) { + raf_callbacks_.erase(it); + return; + } + } + // 実行中バッチ (RunAnimationFrame の current) 内のものはフラグで無効化する raf_cancelled_.push_back(id); } @@ -104,7 +130,7 @@ void EventLoop::PumpTimers() v8::TryCatch try_catch(isolate_); (void) fn->Call(ctx, global, 0, nullptr); - // 例外はコンソールに委譲 (V8Runtime のメッセージハンドラが拾う) + v8util::ReportCaught(isolate_, &try_catch, "setTimeout/setInterval callback"); if (!it->second.repeat) { // 単発は実行後に破棄 @@ -149,6 +175,7 @@ void EventLoop::RunAnimationFrame(double timestamp_ms) v8::TryCatch try_catch(isolate_); v8::Local args[1] = { arg }; (void) fn->Call(ctx, global, 1, args); + v8util::ReportCaught(isolate_, &try_catch, "requestAnimationFrame callback"); } raf_cancelled_.clear(); diff --git a/templates/xbox/src/EventLoop.h b/templates/xbox/src/EventLoop.h index c18bb6d..915ceda 100644 --- a/templates/xbox/src/EventLoop.h +++ b/templates/xbox/src/EventLoop.h @@ -50,6 +50,18 @@ class EventLoop { // 保留タスクが残っているか (終了判定用) bool HasPendingWork() const; + // 診断用: 登録中の rAF コールバック数 / タイマー数 + size_t PendingAnimationFrameCount() const { return raf_callbacks_.size(); } + size_t PendingTimerCount() const { return timers_.size(); } + + // 全タイマー/rAF コールバック (v8::Global) を解放する。Isolate::Dispose より + // 前に必ず呼ぶこと (スタック巻き戻しでの破棄は Isolate 破棄後になり fail-fast)。 + void Shutdown() { + timers_.clear(); + raf_callbacks_.clear(); + raf_cancelled_.clear(); + } + private: struct TimerEntry { v8::Global callback; diff --git a/templates/xbox/src/bindings/Audio.cpp b/templates/xbox/src/bindings/Audio.cpp index 0f83172..82fb990 100644 --- a/templates/xbox/src/bindings/Audio.cpp +++ b/templates/xbox/src/bindings/Audio.cpp @@ -5,6 +5,7 @@ #include "EventTarget.h" #include "platform/AudioEngine.h" #include "v8/V8Util.h" +#include "v8/WeakHandle.h" #include #include @@ -22,11 +23,6 @@ struct GainParam { std::shared_ptr voice; // source.connect(gain) 時にリンクされる }; -void ReleaseGainParam(const v8::WeakCallbackInfo& info) -{ - delete info.GetParameter(); -} - // 再生中(非ループ)の source ノード。毎フレーム IsFinished を見て "ended" を発火する。 std::vector>& PlayingSources() { @@ -40,19 +36,12 @@ struct Holder { std::shared_ptr ptr; }; -template -void ReleaseHolder(const v8::WeakCallbackInfo>& info) -{ - delete info.GetParameter(); -} - template void Attach(v8::Isolate* isolate, v8::Local obj, std::shared_ptr ptr) { auto* holder = new Holder{std::move(ptr)}; obj->SetInternalField(0, v8::External::New(isolate, holder)); - auto* handle = new v8::Global(isolate, obj); - handle->SetWeak(holder, ReleaseHolder, v8::WeakCallbackType::kParameter); + v8util::AttachWeak(isolate, obj, holder); } template @@ -198,8 +187,7 @@ void CreateGain(const v8::FunctionCallbackInfo& args) v8::Local gain = InternalTemplate(isolate)->NewInstance(ctx).ToLocalChecked(); auto* param = new GainParam(); gain->SetInternalField(0, v8::External::New(isolate, param)); - auto* handle = new v8::Global(isolate, gain); - handle->SetWeak(param, ReleaseGainParam, v8::WeakCallbackType::kParameter); + v8util::AttachWeak(isolate, gain, param); gain->SetNativeDataProperty(ctx, Str(isolate, "value"), [](v8::Local, const v8::PropertyCallbackInfo& info) { @@ -226,6 +214,16 @@ void CreateGain(const v8::FunctionCallbackInfo& args) // 毎フレーム呼ばれ、再生終了した source ノードへ "ended" を発火する (main.cpp のループから)。 } // namespace +// V8 破棄前に呼ぶ。再生追跡リストの v8::Global を解放する。 +// (放置すると static デストラクタが V8 破棄後に走りアクセス違反になる) +void ShutdownAudioEvents() +{ + for (auto& g : PlayingSources()) { + g.Reset(); + } + PlayingSources().clear(); +} + void PumpAudioEvents(v8::Isolate* isolate) { auto& sources = PlayingSources(); @@ -285,6 +283,83 @@ void DecodeAudioData(const v8::FunctionCallbackInfo& args) resolver->Resolve(ctx, buffer).Check(); } +// --- Audio (HTMLAudioElement 最小実装) ------------------------------------- +// ゲームコードが `new Audio(url)` で効果音/BGM を再生する経路。 +// play/pause/paused/loop/volume/currentTime/preload をサポートする。 +// pause→play は先頭からの再生になる (位置レジュームは未対応の近似)。 + +void AudioElementPlay(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + v8::Local ctx = isolate->GetCurrentContext(); + v8::Local self = args.This(); + + auto resolver = v8::Promise::Resolver::New(ctx).ToLocalChecked(); + args.GetReturnValue().Set(resolver->GetPromise()); + + auto voice = Get(self); + if (voice) { + bool loop = false; + double volume = 1.0; + v8::Local v; + if (self->Get(ctx, Str(isolate, "loop")).ToLocal(&v)) { + loop = v->BooleanValue(isolate); + } + if (self->Get(ctx, Str(isolate, "volume")).ToLocal(&v) && v->IsNumber()) { + volume = v.As()->Value(); + } + voice->SetVolume(static_cast(volume)); + voice->Start(loop); + } + v8util::SetValue(isolate, self, "paused", v8::Boolean::New(isolate, false)); + resolver->Resolve(ctx, v8::Undefined(isolate)).Check(); +} + +void AudioElementPause(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + auto voice = Get(args.This()); + if (voice) { + voice->Stop(); + } + v8util::SetValue(isolate, args.This(), "paused", v8::Boolean::New(isolate, true)); +} + +void AudioElementConstructor(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + if (!args.IsConstructCall()) { + v8util::ThrowTypeError(isolate, "Audio must be called with new"); + return; + } + v8::Local self = args.This(); + Attach(isolate, self, nullptr); + + // URL 指定があれば同期でロード+デコードして voice を作る (失敗時は無音の no-op) + if (args.Length() > 0 && args[0]->IsString()) { + const std::string src = ToStdString(isolate, args[0]); + v8util::SetValue(isolate, self, "src", args[0]); + HostContext* host = HostContext::From(isolate); + auto bytes = host->assets->ReadBinary(src); + if (bytes) { + auto pcm = std::make_shared(); + if (AudioEngine::Decode(*bytes, *pcm)) { + Attach(isolate, self, host->audio->CreateVoice(pcm)); + } + } + } + + v8util::SetValue(isolate, self, "loop", v8::Boolean::New(isolate, false)); + v8util::SetValue(isolate, self, "volume", v8::Number::New(isolate, 1.0)); + v8util::SetValue(isolate, self, "paused", v8::Boolean::New(isolate, true)); + v8util::SetValue(isolate, self, "preload", Str(isolate, "auto")); + v8util::SetValue(isolate, self, "currentTime", v8::Number::New(isolate, 0)); + v8util::SetMethod(isolate, self, "play", AudioElementPlay); + v8util::SetMethod(isolate, self, "pause", AudioElementPause); + InstallEventTarget(isolate, self); + args.GetReturnValue().Set(self); +} + // AudioContext コンストラクタ void AudioContextConstructor(const v8::FunctionCallbackInfo& args) { @@ -315,6 +390,15 @@ void InstallAudio(v8::Isolate* isolate, v8::Local global, HostContex global->Set(ctx, Str(isolate, "AudioContext"), fn).Check(); global->Set(ctx, Str(isolate, "webkitAudioContext"), fn).Check(); + + // Audio (HTMLAudioElement 最小): ゲームコードの `new Audio(url)` 用。 + // インスタンスは内部フィールドに AudioVoice ホルダを持つ。 + v8::Local audio_tmpl = + v8::FunctionTemplate::New(isolate, AudioElementConstructor); + audio_tmpl->SetClassName(Str(isolate, "Audio")); + audio_tmpl->InstanceTemplate()->SetInternalFieldCount(1); + global->Set(ctx, Str(isolate, "Audio"), + audio_tmpl->GetFunction(ctx).ToLocalChecked()).Check(); } } // namespace next2d diff --git a/templates/xbox/src/bindings/Bindings.h b/templates/xbox/src/bindings/Bindings.h index 24b0f10..48ad869 100644 --- a/templates/xbox/src/bindings/Bindings.h +++ b/templates/xbox/src/bindings/Bindings.h @@ -41,6 +41,12 @@ void InstallAudio(v8::Isolate* isolate, v8::Local global, HostContex // 再生終了した AudioBufferSourceNode へ "ended" を発火する。main.cpp のループから毎フレーム呼ぶ。 void PumpAudioEvents(v8::Isolate* isolate); +// V8 破棄前に呼ぶ。音声の再生追跡リスト (v8::Global) を解放する。 +void ShutdownAudioEvents(); + +// V8 破棄前に呼ぶ。WebGPU テンプレートの v8::Global (static) を解放する。 +void ShutdownWebGPU(); + // window / document / navigator / screen 等の DOM 相当スタブと canvas 生成 void InstallDomShims(v8::Isolate* isolate, v8::Local global, HostContext* host); @@ -69,6 +75,10 @@ bool GetVideoFramePixels(v8::Isolate* isolate, v8::Local obj, // blob: URL のバイト列をテキストとして取得する (Network.cpp)。 bool ResolveObjectURL(const std::string& url, std::string* out_text); +// TextEncoder/TextDecoder/queueMicrotask 等の JS ポリフィルを評価する (Polyfills.cpp)。 +// bootstrap.js はメインのみのため、worker を含む全コンテキストにはこちらで注入する。 +void InstallPolyfills(v8::Isolate* isolate, v8::Local context); + // 上記をまとめてインストールする void InstallGlobalBindings(v8::Isolate* isolate, v8::Local global, HostContext* host); diff --git a/templates/xbox/src/bindings/Canvas.cpp b/templates/xbox/src/bindings/Canvas.cpp index 6cb870a..30e5911 100644 --- a/templates/xbox/src/bindings/Canvas.cpp +++ b/templates/xbox/src/bindings/Canvas.cpp @@ -135,6 +135,9 @@ v8::Local CreateCanvasElement(v8::Isolate* isolate, HostContext* hos int width, int height) { v8::Local canvas = v8::Object::New(isolate); + // player の canvasSetPositionService は children[0].localName === "canvas" を確認する + SetValue(isolate, canvas, "tagName", Str(isolate, "CANVAS")); + SetValue(isolate, canvas, "localName", Str(isolate, "canvas")); SetValue(isolate, canvas, "width", v8::Integer::New(isolate, width ? width : host->viewport_width)); SetValue(isolate, canvas, "height", @@ -146,6 +149,48 @@ v8::Local CreateCanvasElement(v8::Isolate* isolate, HostContext* hos SetMethod(isolate, canvas, "transferControlToOffscreen", TransferControlToOffscreen); InstallEventTarget(isolate, canvas); // pointer/wheel リスナを保持 SetMethod(isolate, canvas, "getBoundingClientRect", GetBoundingClientRect); + + // player の CanvasInitializeService が canvas.setAttribute("style", ...) を呼ぶ。 + // 属性は __attrs に保存する (見た目の CSS はネイティブ描画では不要)。 + SetMethod(isolate, canvas, "setAttribute", + [](const v8::FunctionCallbackInfo& a) { + if (a.Length() < 2) return; + v8::Isolate* iso = a.GetIsolate(); + v8::Local c = iso->GetCurrentContext(); + v8::Local attrs; + v8::Local store; + if (a.This()->Get(c, v8util::Str(iso, "__attrs")).ToLocal(&attrs) && attrs->IsObject()) { + store = attrs.As(); + } else { + store = v8::Object::New(iso); + v8util::SetValue(iso, a.This(), "__attrs", store); + } + store->Set(c, a[0], a[1]).Check(); + }); + SetMethod(isolate, canvas, "getAttribute", + [](const v8::FunctionCallbackInfo& a) { + a.GetReturnValue().SetNull(); + if (a.Length() < 1) return; + v8::Isolate* iso = a.GetIsolate(); + v8::Local c = iso->GetCurrentContext(); + v8::Local attrs; + if (a.This()->Get(c, v8util::Str(iso, "__attrs")).ToLocal(&attrs) && attrs->IsObject()) { + v8::Local v; + if (attrs.As()->Get(c, a[0]).ToLocal(&v) && !v->IsUndefined()) { + a.GetReturnValue().Set(v); + } + } + }); + SetMethod(isolate, canvas, "removeAttribute", + [](const v8::FunctionCallbackInfo& a) { + if (a.Length() < 1) return; + v8::Isolate* iso = a.GetIsolate(); + v8::Local c = iso->GetCurrentContext(); + v8::Local attrs; + if (a.This()->Get(c, v8util::Str(iso, "__attrs")).ToLocal(&attrs) && attrs->IsObject()) { + (void) attrs.As()->Delete(c, a[0]); + } + }); // 主要 canvas を HostContext から参照できるよう記録 (WndProc の入力配送先) HostContext::From(isolate)->main_canvas.Reset(isolate, canvas); return canvas; diff --git a/templates/xbox/src/bindings/Canvas2D.cpp b/templates/xbox/src/bindings/Canvas2D.cpp index 72dadec..791d91b 100644 --- a/templates/xbox/src/bindings/Canvas2D.cpp +++ b/templates/xbox/src/bindings/Canvas2D.cpp @@ -11,6 +11,7 @@ #include "RasterCore.h" #include "platform/TextRasterizer.h" #include "v8/V8Util.h" +#include "v8/WeakHandle.h" #include @@ -352,9 +353,6 @@ void GetImageData(const v8::FunctionCallbackInfo& a) a.GetReturnValue().Set(image_data); } -// GC 時に Canvas2D 実体を解放 -void ReleaseCanvas2D(const v8::WeakCallbackInfo& info) { delete info.GetParameter(); } - // fillStyle / strokeStyle / lineWidth / globalAlpha / font アクセサ void InstallAccessors(v8::Isolate* isolate, v8::Local obj) { @@ -424,8 +422,7 @@ v8::Local CreateCanvas2DContext(v8::Isolate* isolate, HostContext* / auto* impl = new Canvas2D(width > 0 ? width : 1, height > 0 ? height : 1); obj->SetInternalField(0, v8::External::New(isolate, impl)); - auto* handle = new v8::Global(isolate, obj); - handle->SetWeak(impl, ReleaseCanvas2D, v8::WeakCallbackType::kParameter); + v8util::AttachWeak(isolate, obj, impl); SetMethod(isolate, obj, "beginPath", BeginPath); SetMethod(isolate, obj, "moveTo", MoveTo); diff --git a/templates/xbox/src/bindings/DomShims.cpp b/templates/xbox/src/bindings/DomShims.cpp index 81442be..0c30c43 100644 --- a/templates/xbox/src/bindings/DomShims.cpp +++ b/templates/xbox/src/bindings/DomShims.cpp @@ -5,6 +5,10 @@ #include "v8/V8Util.h" #include +#include +#include + +#pragma comment(lib, "bcrypt.lib") namespace next2d { @@ -107,6 +111,109 @@ void ClipboardWriteText(const v8::FunctionCallbackInfo& args) resolver->Resolve(ctx, v8::Undefined(isolate)).Check(); } +// crypto.getRandomValues(typedArray): OS 乱数で埋めて同じ view を返す +void CryptoGetRandomValues(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + if (args.Length() < 1 || !args[0]->IsArrayBufferView()) { + v8util::ThrowTypeError(isolate, "getRandomValues requires a TypedArray"); + return; + } + auto view = args[0].As(); + std::vector bytes(view->ByteLength()); + if (!bytes.empty()) { + BCryptGenRandom(nullptr, bytes.data(), static_cast(bytes.size()), + BCRYPT_USE_SYSTEM_PREFERRED_RNG); + // view のバッキングへ書き戻す + auto ab = view->Buffer(); + auto* dst = static_cast(ab->GetBackingStore()->Data()) + view->ByteOffset(); + memcpy(dst, bytes.data(), bytes.size()); + } + args.GetReturnValue().Set(args[0]); +} + +// crypto.randomUUID(): RFC 4122 v4 +void CryptoRandomUUID(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + uint8_t b[16] = {}; + BCryptGenRandom(nullptr, b, sizeof(b), BCRYPT_USE_SYSTEM_PREFERRED_RNG); + b[6] = static_cast((b[6] & 0x0f) | 0x40); // version 4 + b[8] = static_cast((b[8] & 0x3f) | 0x80); // variant + char buf[37]; + std::snprintf(buf, sizeof(buf), + "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]); + args.GetReturnValue().Set(Str(isolate, buf)); +} + +// --- 名前付きストレージの永続化バックエンド --------------------------------- +// localStorage / indexedDB のセマンティクスは Polyfills.cpp の JS 側が実装し、 +// ここは名前ごとのファイル I/O のみを担う。 +// 保存先: %LOCALAPPDATA%\Next2D\.json +// «EXTEND» コンソール実機では XGameSave への置き換えが必要 (devkit 後の作業)。 +std::wstring StorageFilePath(const std::string& name) +{ + // ファイル名として安全な文字だけを残す + std::wstring safe; + for (char c : name) { + const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '-'; + safe.push_back(ok ? static_cast(c) : L'_'); + } + if (safe.empty()) { + safe = L"storage"; + } + wchar_t base[MAX_PATH] = {}; + if (!GetEnvironmentVariableW(L"LOCALAPPDATA", base, MAX_PATH)) { + return safe + L".json"; // フォールバック: カレント + } + std::wstring dir = std::wstring(base) + L"\\Next2D"; + CreateDirectoryW(dir.c_str(), nullptr); + return dir + L"\\" + safe + L".json"; +} + +// __next2d_storage_load(name) -> string +void StorageLoad(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + const std::string name = args.Length() > 0 + ? v8util::ToStdString(isolate, args[0]) : "localStorage"; + HANDLE h = CreateFileW(StorageFilePath(name).c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) { + args.GetReturnValue().Set(Str(isolate, "")); + return; + } + DWORD size = GetFileSize(h, nullptr); + std::string data(size, '\0'); + DWORD read = 0; + ReadFile(h, data.data(), size, &read, nullptr); + CloseHandle(h); + data.resize(read); + args.GetReturnValue().Set(Str(isolate, data)); +} + +// __next2d_storage_save(name, data) +void StorageSave(const v8::FunctionCallbackInfo& args) +{ + if (args.Length() < 2) { + return; + } + v8::Isolate* isolate = args.GetIsolate(); + const std::string name = v8util::ToStdString(isolate, args[0]); + const std::string data = v8util::ToStdString(isolate, args[1]); + HANDLE h = CreateFileW(StorageFilePath(name).c_str(), GENERIC_WRITE, 0, + nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) { + return; + } + DWORD written = 0; + WriteFile(h, data.data(), static_cast(data.size()), &written, nullptr); + CloseHandle(h); +} + // new OffscreenCanvas(width, height) void OffscreenCanvasConstructor(const v8::FunctionCallbackInfo& args) { @@ -144,6 +251,19 @@ void InstallDomShims(v8::Isolate* isolate, v8::Local global, HostCon SetValue(isolate, global, "self", global); NoopEventTarget(isolate, global); + // crypto (getRandomValues / randomUUID — OS 乱数)。 + // player の WebGLUtil が ID 生成に crypto.randomUUID を使う。 + { + v8::Local crypto = v8::Object::New(isolate); + SetMethod(isolate, crypto, "getRandomValues", CryptoGetRandomValues); + SetMethod(isolate, crypto, "randomUUID", CryptoRandomUUID); + SetValue(isolate, global, "crypto", crypto); + } + + // localStorage のファイル I/O バックエンド (Polyfills.cpp の JS が利用) + SetMethod(isolate, global, "__next2d_storage_load", StorageLoad); + SetMethod(isolate, global, "__next2d_storage_save", StorageSave); + // devicePixelRatio SetValue(isolate, global, "devicePixelRatio", v8::Number::New(isolate, host->device_pixel_ratio)); @@ -176,6 +296,8 @@ void InstallDomShims(v8::Isolate* isolate, v8::Local global, HostCon // document v8::Local document = v8::Object::New(isolate); SetMethod(isolate, document, "createElement", CreateElement); + // 起動時点で読み込み完了扱い (ゲームの boot 分岐が readyState === "loading" で待機しないように) + SetValue(isolate, document, "readyState", Str(isolate, "complete")); SetMethod(isolate, document, "getElementById", [](const v8::FunctionCallbackInfo& a) { a.GetReturnValue().SetNull(); }); SetMethod(isolate, document, "querySelector", @@ -216,6 +338,11 @@ void InstallGlobalBindings(v8::Isolate* isolate, v8::Local global, H InstallAudio(isolate, global, host); InstallWebGPU(isolate, global, host); // navigator.gpu InstallGamepad(isolate, global, host); // navigator.getGamepads + + // TextEncoder/TextDecoder/queueMicrotask。bootstrap.js はメインコンテキスト + // でのみ実行されるため、worker を含む全コンテキストにはここで注入する + // (Vite バンドルの worker は評価時に TextDecoder を要求する)。 + InstallPolyfills(isolate, isolate->GetCurrentContext()); } } // namespace next2d diff --git a/templates/xbox/src/bindings/EventTarget.cpp b/templates/xbox/src/bindings/EventTarget.cpp index 41b1d3f..3df9f99 100644 --- a/templates/xbox/src/bindings/EventTarget.cpp +++ b/templates/xbox/src/bindings/EventTarget.cpp @@ -104,6 +104,7 @@ void DispatchEvent(v8::Isolate* isolate, v8::Local target, if (target->Get(ctx, Str(isolate, onname.c_str())).ToLocal(&on) && on->IsFunction()) { v8::TryCatch tc(isolate); (void) on.As()->Call(ctx, target, 1, args1); + v8util::ReportCaught(isolate, &tc, onname.c_str()); } // __listeners[type] @@ -118,6 +119,7 @@ void DispatchEvent(v8::Isolate* isolate, v8::Local target, if (arr->Get(ctx, i).ToLocal(&fn) && fn->IsFunction()) { v8::TryCatch tc(isolate); (void) fn.As()->Call(ctx, target, 1, args1); + v8util::ReportCaught(isolate, &tc, ("listener:" + type).c_str()); } } } diff --git a/templates/xbox/src/bindings/Fetch.cpp b/templates/xbox/src/bindings/Fetch.cpp index 2c875cb..1c73311 100644 --- a/templates/xbox/src/bindings/Fetch.cpp +++ b/templates/xbox/src/bindings/Fetch.cpp @@ -3,6 +3,7 @@ #include "HostContext.h" #include "AssetLoader.h" #include "v8/V8Util.h" +#include "v8/WeakHandle.h" #include #include @@ -84,12 +85,6 @@ void ResponseJson(const v8::FunctionCallbackInfo& args) args.GetReturnValue().Set(resolver->GetPromise()); } -// External に持たせた vector を解放するための弱参照コールバック -void ReleaseResponseData(const v8::WeakCallbackInfo>& info) -{ - delete info.GetParameter(); -} - v8::Local MakeResponse(v8::Isolate* isolate, v8::Local ctx, std::vector&& bytes, bool ok, int status) { @@ -101,8 +96,7 @@ v8::Local MakeResponse(v8::Isolate* isolate, v8::Local response->SetInternalField(0, v8::External::New(isolate, data)); // GC 時に解放 - auto* handle = new v8::Global(isolate, response); - handle->SetWeak(data, ReleaseResponseData, v8::WeakCallbackType::kParameter); + v8util::AttachWeak(isolate, response, data); v8util::SetValue(isolate, response, "ok", v8::Boolean::New(isolate, ok)); v8util::SetValue(isolate, response, "status", v8::Integer::New(isolate, status)); diff --git a/templates/xbox/src/bindings/Image.cpp b/templates/xbox/src/bindings/Image.cpp index 7e44fc4..b8b5057 100644 --- a/templates/xbox/src/bindings/Image.cpp +++ b/templates/xbox/src/bindings/Image.cpp @@ -2,13 +2,16 @@ #include "HostContext.h" #include "AssetLoader.h" +#include "EventTarget.h" #include "ImageSource.h" #include "platform/WicDecoder.h" #include "v8/V8Util.h" +#include "v8/WeakHandle.h" #include #include +#include #include namespace next2d { @@ -18,12 +21,110 @@ using v8util::ToStdString; namespace { -void ReleaseDecoded(const v8::WeakCallbackInfo& info) +// base64 デコード (data: URL 用。Vite は小さい画像をインライン化する) +bool ImageDecodeBase64(const std::string& in, std::vector* out) { - delete info.GetParameter(); + auto val = [](char c) -> int { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; + }; + out->clear(); + out->reserve(in.size() / 4 * 3); + int buf = 0, bits = 0; + for (char c : in) { + if (c == '=' || c == '\r' || c == '\n') continue; + const int v = val(c); + if (v < 0) return false; + buf = buf << 6 | v; + bits += 6; + if (bits >= 8) { + bits -= 8; + out->push_back(static_cast(buf >> bits & 0xFF)); + } + } + return true; } -// Image コンストラクタ: src セッターで読み込み+デコードし onload を発火する。 +// 遅延発火する load/error イベント (img.src = url; img.onload = fn の順でも拾えるよう +// microtask で発火する。メインループの PerformMicrotaskCheckpoint が実行する) +struct PendingImageEvent { + v8::Isolate* isolate; + v8::Global image; + bool ok; +}; + +void FireImageEvent(void* data) +{ + auto* p = static_cast(data); + v8::Isolate* isolate = p->isolate; + v8::HandleScope hs(isolate); + v8::Local img = p->image.Get(isolate); + v8::Local ctx = img->GetCreationContextChecked(); + v8::Context::Scope cs(ctx); + + v8::Local ev = v8::Object::New(isolate); + v8util::SetValue(isolate, ev, "type", Str(isolate, p->ok ? "load" : "error")); + v8util::SetValue(isolate, ev, "target", img); + DispatchEvent(isolate, img, ev); + + p->image.Reset(); + delete p; +} + +// src に指定された URL (assets 相対 / data:) を読み込み WIC でデコードする。 +// 成否イベントは microtask で発火する。 +void LoadImageFromSrc(v8::Isolate* isolate, v8::Local self, const std::string& url) +{ + std::vector input; + if (url.rfind("data:", 0) == 0) { + const auto comma = url.find(','); + if (comma != std::string::npos) { + const std::string meta = url.substr(5, comma - 5); + const std::string payload = url.substr(comma + 1); + if (meta.find(";base64") != std::string::npos) { + ImageDecodeBase64(payload, &input); + } + } + } else { + AssetLoader* assets = HostContext::From(isolate)->assets; + auto bytes = assets->ReadBinary(url); + if (bytes) { + input = std::move(*bytes); + } + } + + auto* img = new DecodedImage(); + const bool ok = !input.empty() && DecodeImageWithWIC(input, *img); + if (ok) { + self->SetInternalField(0, v8::External::New(isolate, img)); + v8util::AttachWeak(isolate, self, img); + v8util::SetValue(isolate, self, "width", + v8::Integer::NewFromUnsigned(isolate, img->width)); + v8util::SetValue(isolate, self, "height", + v8::Integer::NewFromUnsigned(isolate, img->height)); + v8util::SetValue(isolate, self, "naturalWidth", + v8::Integer::NewFromUnsigned(isolate, img->width)); + v8util::SetValue(isolate, self, "naturalHeight", + v8::Integer::NewFromUnsigned(isolate, img->height)); + v8util::SetValue(isolate, self, "complete", v8::Boolean::New(isolate, true)); + } else { + delete img; + std::cerr << "[Image] load failed: " + << (url.size() > 96 ? url.substr(0, 96) + "..." : url) << std::endl; + } + + auto* pending = new PendingImageEvent(); + pending->isolate = isolate; + pending->image.Reset(isolate, self); + pending->ok = ok; + isolate->EnqueueMicrotask(FireImageEvent, pending); +} + +// Image コンストラクタ: src セッターで読み込み+デコードし load イベントを発火する。 void ImageConstructor(const v8::FunctionCallbackInfo& args) { v8::Isolate* isolate = args.GetIsolate(); @@ -31,11 +132,46 @@ void ImageConstructor(const v8::FunctionCallbackInfo& args) v8util::ThrowTypeError(isolate, "Image must be called with new"); return; } - // 実体は通常オブジェクト。src セッターで decode をトリガする。 - args.This()->Set(isolate->GetCurrentContext(), - Str(isolate, "complete"), - v8::Boolean::New(isolate, false)).Check(); - args.GetReturnValue().Set(args.This()); + v8::Local ctx = isolate->GetCurrentContext(); + v8::Local self = args.This(); + + v8util::SetValue(isolate, self, "__isImageElement", v8::Boolean::New(isolate, true)); + v8util::SetValue(isolate, self, "tagName", Str(isolate, "IMG")); + v8util::SetValue(isolate, self, "localName", Str(isolate, "img")); + v8util::SetValue(isolate, self, "complete", v8::Boolean::New(isolate, false)); + v8util::SetValue(isolate, self, "width", v8::Integer::New(isolate, 0)); + v8util::SetValue(isolate, self, "height", v8::Integer::New(isolate, 0)); + v8util::SetValue(isolate, self, "naturalWidth", v8::Integer::New(isolate, 0)); + v8util::SetValue(isolate, self, "naturalHeight", v8::Integer::New(isolate, 0)); + InstallEventTarget(isolate, self); // addEventListener("load"/"error") + + self->SetNativeDataProperty(ctx, Str(isolate, "src"), + [](v8::Local, const v8::PropertyCallbackInfo& info) { + v8::Local v; + if (info.This()->Get(info.GetIsolate()->GetCurrentContext(), + Str(info.GetIsolate(), "__src")).ToLocal(&v) && !v->IsUndefined()) { + info.GetReturnValue().Set(v); + } else { + info.GetReturnValue().Set(Str(info.GetIsolate(), "")); + } + }, + [](v8::Local, v8::Local value, + const v8::PropertyCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8util::SetValue(iso, info.This(), "__src", value); + LoadImageFromSrc(iso, info.This(), ToStdString(iso, value)); + }).Check(); + + // decode(): 読み込み済みなら resolve、未読み込みでも resolve (簡易) + v8util::SetMethod(isolate, self, "decode", + [](const v8::FunctionCallbackInfo& a) { + v8::Isolate* iso = a.GetIsolate(); + auto r = v8::Promise::Resolver::New(iso->GetCurrentContext()).ToLocalChecked(); + r->Resolve(iso->GetCurrentContext(), v8::Undefined(iso)).Check(); + a.GetReturnValue().Set(r->GetPromise()); + }); + + args.GetReturnValue().Set(self); } // createImageBitmap(source) -> Promise @@ -71,6 +207,52 @@ void CreateImageBitmap(const v8::FunctionCallbackInfo& args) if (bytes) { input = std::move(*bytes); } + } else if (args[0]->IsObject()) { + v8::Local obj = args[0].As(); + + // 1) ImageData ({ data: Uint8ClampedArray, width, height }): + // デコード不要でそのまま RGBA として取り込む + // (webgpu レンダラのキャプチャ/フィルタ→ImageBitmap 化の経路)。 + v8::Local data_v, w_v, h_v; + if (obj->Get(ctx, Str(isolate, "data")).ToLocal(&data_v) && data_v->IsArrayBufferView() && + obj->Get(ctx, Str(isolate, "width")).ToLocal(&w_v) && w_v->IsNumber() && + obj->Get(ctx, Str(isolate, "height")).ToLocal(&h_v) && h_v->IsNumber()) { + const auto w = static_cast(w_v.As()->Value()); + const auto h = static_cast(h_v.As()->Value()); + auto view = data_v.As(); + if (view->ByteLength() < static_cast(w) * h * 4) { + resolver->Reject(ctx, v8::Exception::TypeError( + Str(isolate, "ImageData size mismatch"))).Check(); + return; + } + auto* raw = new DecodedImage(); + raw->width = w; + raw->height = h; + raw->rgba.resize(static_cast(w) * h * 4); + view->CopyContents(raw->rgba.data(), raw->rgba.size()); + resolver->Resolve(ctx, WrapImageBitmap(isolate, raw)).Check(); + return; + } + + // 2) ImageBitmap / 2D 済み canvas / video: ピクセルを複製して新規 ImageBitmap + const uint8_t* px = nullptr; + uint32_t sw = 0, sh = 0; + if (GetImageSourcePixels(isolate, obj, &px, &sw, &sh) && px) { + auto* raw = new DecodedImage(); + raw->width = sw; + raw->height = sh; + raw->rgba.assign(px, px + static_cast(sw) * sh * 4); + resolver->Resolve(ctx, WrapImageBitmap(isolate, raw)).Check(); + return; + } + + // 3) Blob: 内包バイト列 (エンコード済み画像) を WIC デコードへ回す + v8::Local blob_v; + if (obj->Get(ctx, Str(isolate, "__blobData")).ToLocal(&blob_v) && blob_v->IsArrayBuffer()) { + auto store = blob_v.As()->GetBackingStore(); + input.assign(static_cast(store->Data()), + static_cast(store->Data()) + store->ByteLength()); + } } auto* img = new DecodedImage(); @@ -99,8 +281,7 @@ v8::Local WrapImageBitmap(v8::Isolate* isolate, DecodedImage* image) v8util::SetValue(isolate, obj, "height", v8::Integer::NewFromUnsigned(isolate, image->height)); v8util::SetMethod(isolate, obj, "close", [](const v8::FunctionCallbackInfo&) {}); - auto* handle = new v8::Global(isolate, obj); - handle->SetWeak(image, ReleaseDecoded, v8::WeakCallbackType::kParameter); + v8util::AttachWeak(isolate, obj, image); return obj; } @@ -126,6 +307,25 @@ bool GetImageSourcePixels(v8::Isolate* isolate, v8::Local source, return true; } + // Image 要素 (new Image() + src): 読み込み完了後は内部フィールドに DecodedImage + v8::Local is_image; + if (obj->Get(ctx, Str(isolate, "__isImageElement")).ToLocal(&is_image) && + is_image->IsBoolean() && is_image.As()->Value()) { + if (obj->InternalFieldCount() < 1) { + return false; + } + v8::Local field = obj->GetInternalField(0); + if (!field->IsValue() || !field.As()->IsExternal()) { + return false; // 未読み込み + } + auto* img = static_cast( + field.As().As()->Value()); + *out_rgba = img->rgba.data(); + *out_width = img->width; + *out_height = img->height; + return true; + } + // video 要素: 現在フレームを返す v8::Local is_video; if (obj->Get(ctx, Str(isolate, "__isVideoElement")).ToLocal(&is_video) && @@ -151,6 +351,8 @@ void InstallImage(v8::Isolate* isolate, v8::Local global, HostContex v8::Local image_tmpl = v8::FunctionTemplate::New(isolate, ImageConstructor); image_tmpl->SetClassName(Str(isolate, "Image")); + // 内部フィールド0: デコード済み DecodedImage* (src セッターが設定) + image_tmpl->InstanceTemplate()->SetInternalFieldCount(1); global->Set(ctx, Str(isolate, "Image"), image_tmpl->GetFunction(ctx).ToLocalChecked()).Check(); diff --git a/templates/xbox/src/bindings/Polyfills.cpp b/templates/xbox/src/bindings/Polyfills.cpp new file mode 100644 index 0000000..3546e40 --- /dev/null +++ b/templates/xbox/src/bindings/Polyfills.cpp @@ -0,0 +1,791 @@ +// 全コンテキスト (メイン + 各 worker) に必要な JS ポリフィルを評価する。 +// +// bootstrap.js はメインコンテキストでのみ実行されるため、worker コンテキストには +// TextEncoder/TextDecoder 等が存在せず、Vite がバンドルした worker スクリプトが +// 評価時に ReferenceError で落ちる。ここで C++ から全コンテキストに注入する。 +// (bootstrap.js 側は typeof ガード付きなので二重定義にはならない) +// +// NOTE: MSVC は 1 つの文字列リテラルが約 16KB までのため (C2026)、 +// ポリフィルは独立した IIFE のセクション配列に分割して順に評価する。 +#include "Bindings.h" + +#include "v8/V8Util.h" + +namespace next2d { + +namespace { + +// section 1: base (queueMicrotask) +const char kPolyfills1[] = R"JS( +(function (global) { + "use strict"; + + + if (typeof global.queueMicrotask !== "function") { + global.queueMicrotask = function (callback) { + Promise.resolve().then(callback); + }; + } +})(globalThis); +)JS"; + +// section 2: TextEncoder / location +const char kPolyfills2[] = R"JS( +(function (global) { + "use strict"; + if (typeof global.TextEncoder === "undefined") { + global.TextEncoder = class TextEncoder { + get encoding() { return "utf-8"; } + encode(str) { + str = String(str); + const out = []; + for (let i = 0; i < str.length; i++) { + let code = str.charCodeAt(i); + if (code < 0x80) { + out.push(code); + } else if (code < 0x800) { + out.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f)); + } else if (code >= 0xd800 && code <= 0xdbff) { + const hi = code; + const lo = str.charCodeAt(++i); + code = 0x10000 + ((hi - 0xd800) << 10) + (lo - 0xdc00); + out.push( + 0xf0 | (code >> 18), + 0x80 | ((code >> 12) & 0x3f), + 0x80 | ((code >> 6) & 0x3f), + 0x80 | (code & 0x3f) + ); + } else { + out.push( + 0xe0 | (code >> 12), + 0x80 | ((code >> 6) & 0x3f), + 0x80 | (code & 0x3f) + ); + } + } + return new Uint8Array(out); + } + }; + } + + // location (worker を含む全コンテキストで参照可能にする。 + // player の LoadUseCase が location.origin で URL を解決する) + if (typeof global.location === "undefined") { + global.location = { + "href": "app://local/", + "origin": "app://local", + "protocol": "app:", + "host": "local", + "hostname": "local", + "pathname": "/", + "search": "", + "hash": "", + reload() { /* no-op */ } + }; + } + + // history (framework の gotoView が SPA 設定時に history.pushState を呼ぶ)。 + // pushState/replaceState は location の pathname/search/hash にも反映する + if (typeof global.history === "undefined") { + const applyUrl = (url) => { + if (typeof url !== "string" || url === "") { + return; + } + let rest = url; + const origin = global.location.origin; + if (rest.indexOf(origin) === 0) { + rest = rest.slice(origin.length); + } + if (rest === "" || rest[0] !== "/") { + rest = "/" + rest; + } + let hash = ""; + const hi = rest.indexOf("#"); + if (hi >= 0) { + hash = rest.slice(hi); + rest = rest.slice(0, hi); + } + let search = ""; + const qi = rest.indexOf("?"); + if (qi >= 0) { + search = rest.slice(qi); + rest = rest.slice(0, qi); + } + global.location.pathname = rest; + global.location.search = search; + global.location.hash = hash; + global.location.href = origin + rest + search + hash; + }; + global.history = { + "state": null, + "length": 1, + pushState(state, _title, url) { + this.state = state; + this.length++; + applyUrl(url); + }, + replaceState(state, _title, url) { + this.state = state; + applyUrl(url); + }, + back() { /* no-op */ }, + forward() { /* no-op */ }, + go() { /* no-op */ } + }; + } +})(globalThis); +)JS"; + +// section 3: atob / btoa +const char kPolyfills3[] = R"JS( +(function (global) { + "use strict"; + // atob / btoa (indexedDB のバイナリ値永続化にも使用) + const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + if (typeof global.btoa !== "function") { + global.btoa = function (bin) { + bin = String(bin); + let out = ""; + for (let i = 0; i < bin.length; i += 3) { + const a = bin.charCodeAt(i); + const b = i + 1 < bin.length ? bin.charCodeAt(i + 1) : NaN; + const c = i + 2 < bin.length ? bin.charCodeAt(i + 2) : NaN; + if (a > 255 || b > 255 || c > 255) { + throw new Error("btoa: character out of range"); + } + out += B64[a >> 2]; + out += B64[((a & 3) << 4) | (isNaN(b) ? 0 : b >> 4)]; + out += isNaN(b) ? "=" : B64[((b & 15) << 2) | (isNaN(c) ? 0 : c >> 6)]; + out += isNaN(c) ? "=" : B64[c & 63]; + } + return out; + }; + } + if (typeof global.atob !== "function") { + global.atob = function (b64) { + b64 = String(b64).replace(/[=\s]+$/, ""); + let out = ""; + let buffer = 0; + let bits = 0; + for (let i = 0; i < b64.length; i++) { + const v = B64.indexOf(b64[i]); + if (v < 0) { continue; } + buffer = (buffer << 6) | v; + bits += 6; + if (bits >= 8) { + bits -= 8; + out += String.fromCharCode((buffer >> bits) & 0xff); + } + } + return out; + }; + } +})(globalThis); +)JS"; + +// section 4: localStorage / sessionStorage / alert / confirm +const char kPolyfills4[] = R"JS( +(function (global) { + "use strict"; + // localStorage / sessionStorage。 + // 永続化はホストの __next2d_storage_load/save (名前付きファイル I/O) に委ねる。 + if (typeof global.localStorage === "undefined") { + const makeStorage = function (persistent) { + let data = {}; + if (persistent && typeof global.__next2d_storage_load === "function") { + try { + const raw = global.__next2d_storage_load("localStorage"); + if (raw) { data = JSON.parse(raw) || {}; } + } catch (e) { data = {}; } + } + const persist = function () { + if (persistent && typeof global.__next2d_storage_save === "function") { + try { global.__next2d_storage_save("localStorage", JSON.stringify(data)); } catch (e) { /* 保存失敗は無視 */ } + } + }; + return { + getItem(k) { + k = String(k); + return Object.prototype.hasOwnProperty.call(data, k) ? data[k] : null; + }, + setItem(k, v) { data[String(k)] = String(v); persist(); }, + removeItem(k) { delete data[String(k)]; persist(); }, + clear() { data = {}; persist(); }, + key(i) { + const ks = Object.keys(data); + return i >= 0 && i < ks.length ? ks[i] : null; + }, + get length() { return Object.keys(data).length; } + }; + }; + global.localStorage = makeStorage(true); + global.sessionStorage = makeStorage(false); + } + + // alert / confirm (ダイアログの無い環境向け。confirm は肯定を返す) + if (typeof global.alert !== "function") { + global.alert = function () { /* no-op */ }; + } + if (typeof global.confirm !== "function") { + global.confirm = function () { return true; }; + } +})(globalThis); +)JS"; + +// section 5: indexedDB / TextDecoder +const char kPolyfills5[] = R"JS( +(function (global) { + "use strict"; + // indexedDB (最小実装・ファイル永続化)。 + // ゲームの典型用途 (open → objectStore → get/put/delete/getAll) をカバーする。 + // 値は JSON 化可能なもの + ArrayBuffer/TypedArray (base64 でタグ付け永続化)。 + // カーソル/インデックス/複合キーは未対応 («EXTEND»)。 + if (typeof global.indexedDB === "undefined" && + typeof global.__next2d_storage_load === "function") { + + const TYPED = { + "Int8Array": Int8Array, "Uint8Array": Uint8Array, + "Uint8ClampedArray": Uint8ClampedArray, + "Int16Array": Int16Array, "Uint16Array": Uint16Array, + "Int32Array": Int32Array, "Uint32Array": Uint32Array, + "Float32Array": Float32Array, "Float64Array": Float64Array + }; + + const bytesToB64 = function (u8) { + let bin = ""; + for (let i = 0; i < u8.length; i += 0x8000) { + bin += String.fromCharCode.apply(null, u8.subarray(i, i + 0x8000)); + } + return global.btoa(bin); + }; + const b64ToBytes = function (b64) { + const bin = global.atob(b64); + const u8 = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) { u8[i] = bin.charCodeAt(i); } + return u8; + }; + + const encodeValue = function (v) { + if (v instanceof ArrayBuffer) { + return { "__n2d_bin": bytesToB64(new Uint8Array(v)), "__n2d_type": "ArrayBuffer" }; + } + if (ArrayBuffer.isView(v)) { + const name = v.constructor && v.constructor.name; + if (TYPED[name]) { + return { + "__n2d_bin": bytesToB64(new Uint8Array(v.buffer, v.byteOffset, v.byteLength)), + "__n2d_type": name + }; + } + } + if (Array.isArray(v)) { return v.map(encodeValue); } + if (v && typeof v === "object") { + const out = {}; + for (const k of Object.keys(v)) { out[k] = encodeValue(v[k]); } + return out; + } + return v; + }; + const decodeValue = function (v) { + if (v && typeof v === "object") { + if (typeof v.__n2d_bin === "string") { + const u8 = b64ToBytes(v.__n2d_bin); + if (v.__n2d_type === "ArrayBuffer") { return u8.buffer; } + const Ctor = TYPED[v.__n2d_type] || Uint8Array; + return new Ctor(u8.buffer, 0, u8.byteLength / Ctor.BYTES_PER_ELEMENT); + } + if (Array.isArray(v)) { return v.map(decodeValue); } + const out = {}; + for (const k of Object.keys(v)) { out[k] = decodeValue(v[k]); } + return out; + } + return v; + }; + + const loadDb = function (name) { + try { + const raw = global.__next2d_storage_load("idb_" + name); + if (raw) { return JSON.parse(raw); } + } catch (e) { /* 破損時は初期化 */ } + return { "version": 0, "stores": {} }; + }; + const saveDb = function (name, db) { + try { global.__next2d_storage_save("idb_" + name, JSON.stringify(db)); } catch (e) { /* 無視 */ } + }; + + const makeRequest = function () { + return { "onsuccess": null, "onerror": null, "result": undefined, "error": null }; + }; + const fireSuccess = function (r) { + global.queueMicrotask(function () { + if (typeof r.onsuccess === "function") { r.onsuccess({ "target": r }); } + }); + }; + const fireError = function (r, e) { + r.error = e; + global.queueMicrotask(function () { + if (typeof r.onerror === "function") { r.onerror({ "target": r }); } + }); + }; + + global.indexedDB = { + open(name, version) { + const req = makeRequest(); + req.onupgradeneeded = null; + req.onblocked = null; + global.queueMicrotask(function () { + const data = loadDb(name); + + const storeApi = function (storeName) { + const entry = data.stores[storeName] || + (data.stores[storeName] = { "keyPath": null, "data": {} }); + const run = function (fn) { + const r = makeRequest(); + global.queueMicrotask(function () { + try { r.result = fn(); fireSuccess(r); } + catch (e) { fireError(r, e); } + }); + return r; + }; + return { + "keyPath": entry.keyPath, + get(key) { + return run(function () { + const raw = entry.data[String(key)]; + return raw === undefined ? undefined : decodeValue(raw); + }); + }, + put(value, key) { + return run(function () { + let k = key; + if (k === undefined && entry.keyPath && value && + typeof value === "object") { + k = value[entry.keyPath]; + } + if (k === undefined) { throw new Error("key required"); } + entry.data[String(k)] = encodeValue(value); + saveDb(name, data); + return k; + }); + }, + add(value, key) { return this.put(value, key); }, + "delete": function (key) { + return run(function () { + delete entry.data[String(key)]; + saveDb(name, data); + }); + }, + clear() { + return run(function () { + entry.data = {}; + saveDb(name, data); + }); + }, + count() { return run(function () { return Object.keys(entry.data).length; }); }, + getAll() { + return run(function () { + return Object.keys(entry.data).map(function (k) { + return decodeValue(entry.data[k]); + }); + }); + }, + getAllKeys() { return run(function () { return Object.keys(entry.data); }); } + }; + }; + + const dbObj = { + "name": name, + "objectStoreNames": { + contains(s) { return Object.prototype.hasOwnProperty.call(data.stores, s); }, + get length() { return Object.keys(data.stores).length; } + }, + createObjectStore(storeName, options) { + if (!data.stores[storeName]) { + data.stores[storeName] = { + "keyPath": options && options.keyPath ? options.keyPath : null, + "data": {} + }; + saveDb(name, data); + } + return storeApi(storeName); + }, + deleteObjectStore(storeName) { + delete data.stores[storeName]; + saveDb(name, data); + }, + transaction(_names, _mode) { + const tx = { + "oncomplete": null, "onerror": null, "onabort": null, + objectStore(s) { return storeApi(s); }, + abort() { /* 未対応 */ }, + commit() { /* 自動コミット */ } + }; + // 全リクエストのマイクロタスク消化後に complete を発火 + global.queueMicrotask(function () { + global.queueMicrotask(function () { + global.queueMicrotask(function () { + if (typeof tx.oncomplete === "function") { + tx.oncomplete({ "target": tx }); + } + }); + }); + }); + return tx; + }, + close() { /* no-op */ }, + "onversionchange": null + }; + + const oldVersion = data.version || 0; + const newVersion = typeof version === "number" ? version : Math.max(1, oldVersion); + req.result = dbObj; + if (newVersion > oldVersion) { + data.version = newVersion; + if (typeof req.onupgradeneeded === "function") { + req.onupgradeneeded({ + "target": req, + "oldVersion": oldVersion, + "newVersion": newVersion + }); + } + saveDb(name, data); + } + if (typeof req.onsuccess === "function") { + req.onsuccess({ "target": req }); + } + }); + return req; + }, + deleteDatabase(name) { + const req = makeRequest(); + global.queueMicrotask(function () { + try { + global.__next2d_storage_save("idb_" + name, ""); + fireSuccess(req); + } catch (e) { + fireError(req, e); + } + }); + return req; + } + }; + } + + if (typeof global.TextDecoder === "undefined") { + global.TextDecoder = class TextDecoder { + constructor(label) { this._label = label || "utf-8"; } + get encoding() { return "utf-8"; } + decode(input) { + if (!input) { return ""; } + const bytes = input instanceof Uint8Array + ? input + : new Uint8Array(input.buffer || input); + let out = ""; + let i = 0; + while (i < bytes.length) { + const c = bytes[i++]; + if (c < 0x80) { + out += String.fromCharCode(c); + } else if (c < 0xe0) { + out += String.fromCharCode(((c & 0x1f) << 6) | (bytes[i++] & 0x3f)); + } else if (c < 0xf0) { + out += String.fromCharCode( + ((c & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f) + ); + } else { + let code = ((c & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | + ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f); + code -= 0x10000; + out += String.fromCharCode(0xd800 + (code >> 10), 0xdc00 + (code & 0x3ff)); + } + } + return out; + } + }; + } +})(globalThis); +)JS"; + +// section 6: DOM 要素ツリー (parentElement / children / getElementById / Event)。 +// player の boot は document.body.appendChild(div) 後に div.parentElement.tagName を +// 参照し、無いと throw する (async 起動フロー内のため無報告で停止していた)。 +// canvas/video はネイティブ実装 (C++) をそのまま使い、他タグを JS で本格化する。 +const char kPolyfills6[] = R"JS( +(function (global) { + "use strict"; + var doc = global.document; + if (!doc || doc.__domUpgraded) { + return; + } + doc.__domUpgraded = true; + + if (typeof global.Event !== "function") { + var Ev = function (type, init) { + init = init || {}; + this.type = String(type); + this.bubbles = !!init.bubbles; + this.cancelable = !!init.cancelable; + this.defaultPrevented = false; + this.target = null; + this.currentTarget = null; + }; + Ev.prototype.preventDefault = function () { + if (this.cancelable) { this.defaultPrevented = true; } + }; + Ev.prototype.stopPropagation = function () {}; + Ev.prototype.stopImmediatePropagation = function () {}; + global.Event = Ev; + } + if (typeof global.CustomEvent !== "function") { + global.CustomEvent = function (type, init) { + var e = new global.Event(type, init); + e.detail = init && init.detail !== undefined ? init.detail : null; + return e; + }; + } + + function styleSize(el, key) { + var v = el.style && el.style[key]; + var n = parseFloat(v); + if (isFinite(n) && n > 0) { return n; } + var attrs = el.__attrs; + if (attrs && typeof attrs.style === "string") { + var m = attrs.style.match( + new RegExp("(?:^|;)\\s*" + key + "\\s*:\\s*([0-9.]+)px") + ); + if (m) { return parseFloat(m[1]); } + } + if (el.tagName === "BODY" || el.tagName === "HTML") { + return key === "width" ? global.innerWidth : global.innerHeight; + } + return 0; + } + + // el を DOM 要素相当へ拡張する。addEventListener はネイティブ実装 + // (__listeners: ホストの入力配送先) が既にある場合は温存する。 + function upgrade(el, tag) { + tag = String(tag || "div").toLowerCase(); + el.tagName = tag.toUpperCase(); + el.localName = tag; + if (el.id === undefined) { el.id = ""; } + if (!el.style) { el.style = {}; } + if (!el.__attrs) { el.__attrs = {}; } + el.children = []; + el.childNodes = el.children; + el.parentElement = null; + el.parentNode = null; + el.dataset = {}; + + Object.defineProperty(el, "firstChild", { + get: function () { return this.children.length ? this.children[0] : null; }, + configurable: true + }); + Object.defineProperty(el, "lastChild", { + get: function () { + var c = this.children; + return c.length ? c[c.length - 1] : null; + }, + configurable: true + }); + Object.defineProperty(el, "clientWidth", { + get: function () { return styleSize(this, "width"); }, + configurable: true + }); + Object.defineProperty(el, "clientHeight", { + get: function () { return styleSize(this, "height"); }, + configurable: true + }); + + var html = ""; + Object.defineProperty(el, "innerHTML", { + get: function () { return html; }, + set: function (v) { html = String(v); this.children.length = 0; }, + configurable: true + }); + + el.appendChild = function (child) { + if (child.parentElement && child.parentElement.children) { + var cs = child.parentElement.children; + var i = cs.indexOf(child); + if (i >= 0) { cs.splice(i, 1); } + } + this.children.push(child); + child.parentElement = this; + child.parentNode = this; + return child; + }; + el.removeChild = function (child) { + var i = this.children.indexOf(child); + if (i >= 0) { this.children.splice(i, 1); } + child.parentElement = null; + child.parentNode = null; + return child; + }; + el.insertBefore = function (child, ref) { + var i = ref ? this.children.indexOf(ref) : -1; + if (i < 0) { return this.appendChild(child); } + this.children.splice(i, 0, child); + child.parentElement = this; + child.parentNode = this; + return child; + }; + el.contains = function (node) { + if (node === this) { return true; } + for (var i = 0; i < this.children.length; i++) { + var c = this.children[i]; + if (c === node || (c.contains && c.contains(node))) { return true; } + } + return false; + }; + el.remove = function () { + if (this.parentElement) { this.parentElement.removeChild(this); } + }; + el.setAttribute = function (name, value) { + this.__attrs[name] = String(value); + if (name === "id") { this.id = String(value); } + }; + el.getAttribute = function (name) { + return this.__attrs[name] !== undefined ? this.__attrs[name] : null; + }; + el.removeAttribute = function (name) { delete this.__attrs[name]; }; + el.getBoundingClientRect = function () { + var w = this.clientWidth; + var h = this.clientHeight; + return { x: 0, y: 0, top: 0, left: 0, right: w, bottom: h, width: w, height: h }; + }; + el.focus = function () {}; + el.blur = function () {}; + + if (typeof el.addEventListener !== "function") { + var listeners = {}; + el.addEventListener = function (type, fn) { + (listeners[type] || (listeners[type] = [])).push(fn); + }; + el.removeEventListener = function (type, fn) { + var a = listeners[type]; + if (!a) { return; } + var i = a.indexOf(fn); + if (i >= 0) { a.splice(i, 1); } + }; + el.dispatchEvent = function (ev) { + ev.target = ev.target || this; + ev.currentTarget = this; + var a = listeners[ev.type]; + if (a) { + for (var i = 0; i < a.length; i++) { + try { a[i].call(this, ev); } catch (e) { + if (global.console) { global.console.error(e); } + } + } + } + var on = this["on" + ev.type]; + if (typeof on === "function") { + try { on.call(this, ev); } catch (e2) { + if (global.console) { global.console.error(e2); } + } + } + return !ev.defaultPrevented; + }; + } + return el; + } + + var nativeCreate = doc.createElement.bind(doc); + doc.createElement = function (tag) { + var t = String(tag).toLowerCase(); + if (t === "canvas" || t === "video") { + var n = nativeCreate(t); + if (!n.localName) { + n.localName = t; + n.tagName = t.toUpperCase(); + } + if (n.parentElement === undefined) { + n.parentElement = null; + n.parentNode = null; + } + return n; + } + return upgrade({}, t); + }; + + // body / documentElement (同一オブジェクト)。ネイティブの addEventListener + // は upgrade 内で温存される。 + var body = doc.body; + if (body) { + upgrade(body, "body"); + Object.defineProperty(body, "clientWidth", { + get: function () { return global.innerWidth; }, + configurable: true + }); + Object.defineProperty(body, "clientHeight", { + get: function () { return global.innerHeight; }, + configurable: true + }); + } + + doc.getElementById = function (id) { + id = String(id); + var find = function (el) { + if (el.id === id) { return el; } + var cs = el.children || []; + for (var i = 0; i < cs.length; i++) { + var r = find(cs[i]); + if (r) { return r; } + } + return null; + }; + return body ? find(body) : null; + }; + + // window.addEventListener("resize", ...) 用 (main コンテキストの global) + if (typeof global.addEventListener !== "function") { + var winListeners = {}; + global.addEventListener = function (type, fn) { + (winListeners[type] || (winListeners[type] = [])).push(fn); + }; + global.removeEventListener = function (type, fn) { + var a = winListeners[type]; + if (!a) { return; } + var i = a.indexOf(fn); + if (i >= 0) { a.splice(i, 1); } + }; + global.dispatchEvent = function (ev) { + var a = winListeners[ev.type]; + if (a) { + for (var i = 0; i < a.length; i++) { + try { a[i].call(global, ev); } catch (e) { + if (global.console) { global.console.error(e); } + } + } + } + return true; + }; + } +})(globalThis); +)JS"; + +const char* const kPolyfillSections[] = { + kPolyfills1, + kPolyfills2, + kPolyfills3, + kPolyfills4, + kPolyfills5, + kPolyfills6, +}; + +} // namespace + +void InstallPolyfills(v8::Isolate* isolate, v8::Local context) +{ + v8::Context::Scope cs(context); + for (const char* source : kPolyfillSections) { + v8::TryCatch tc(isolate); + v8::Local src = v8util::Str(isolate, source); + v8::Local script; + if (v8::Script::Compile(context, src).ToLocal(&script)) { + (void) script->Run(context); + } + } +} + +} // namespace next2d diff --git a/templates/xbox/src/bindings/Video.cpp b/templates/xbox/src/bindings/Video.cpp index 7537946..9e694ed 100644 --- a/templates/xbox/src/bindings/Video.cpp +++ b/templates/xbox/src/bindings/Video.cpp @@ -13,6 +13,7 @@ #include "ImageSource.h" #include "EventTarget.h" #include "v8/V8Util.h" +#include "v8/WeakHandle.h" #include #include @@ -47,6 +48,24 @@ struct VideoDecoder { bool playing = false; bool ended = false; double duration = 0.0; + double position = 0.0; // 現在位置 (秒, サンプルタイムスタンプ由来) + + // 先頭 (または指定秒) へシークする。loop 再生と currentTime 設定で使用。 + bool Seek(double seconds) + { + if (!reader) return false; + PROPVARIANT var; + PropVariantInit(&var); + var.vt = VT_I8; + var.hVal.QuadPart = static_cast(seconds * 1e7); // s -> 100ns + const bool ok = SUCCEEDED(reader->SetCurrentPosition(GUID_NULL, var)); + PropVariantClear(&var); + if (ok) { + ended = false; + position = seconds; + } + return ok; + } bool Open(const std::vector& bytes) { @@ -87,13 +106,15 @@ struct VideoDecoder { { if (!reader) return false; DWORD flags = 0; + LONGLONG timestamp = 0; ComPtr sample; if (FAILED(reader->ReadSample(MF_SOURCE_READER_FIRST_VIDEO_STREAM, 0, - nullptr, &flags, nullptr, &sample))) { + nullptr, &flags, ×tamp, &sample))) { return false; } if (flags & MF_SOURCE_READERF_ENDOFSTREAM) { ended = true; return false; } if (!sample) return false; + position = static_cast(timestamp) / 1e7; // 100ns -> s ComPtr buffer; sample->ConvertToContiguousBuffer(&buffer); @@ -112,8 +133,6 @@ struct VideoDecoder { } }; -void ReleaseVideo(const v8::WeakCallbackInfo& info) { delete info.GetParameter(); } - VideoDecoder* Decoder(v8::Local obj) { return static_cast(obj->GetInternalField(0).As()->Value()); @@ -144,7 +163,10 @@ void LoadVideo(v8::Isolate* isolate, v8::Local self, const std::stri SetValue(isolate, self, "videoHeight", v8::Integer::NewFromUnsigned(isolate, dec->height)); SetValue(isolate, self, "duration", v8::Number::New(isolate, dec->duration)); SetValue(isolate, self, "readyState", v8::Integer::New(isolate, 4)); + // player は loadedmetadata / progress / canplaythrough を購読する + // (VideoRegisterEventUseCase)。ローカル読み込みは即完了のため順に発火する。 FireVideoEvent(isolate, self, "loadedmetadata"); + FireVideoEvent(isolate, self, "progress"); FireVideoEvent(isolate, self, "canplaythrough"); } else { FireVideoEvent(isolate, self, "error"); @@ -162,7 +184,20 @@ bool GetVideoFramePixels(v8::Isolate* isolate, v8::Local obj, if (dec->playing && !dec->ended) { dec->ReadNextFrame(); // 描画のたびに次フレームへ進める(簡易フレーム進行) if (dec->ended) { - FireVideoEvent(isolate, obj, "ended"); + // loop 指定時は先頭へ戻して継続、そうでなければ ended を通知 + v8::Local loop_v; + const bool loop = + obj->Get(isolate->GetCurrentContext(), + v8util::Str(isolate, "loop")).ToLocal(&loop_v) && + loop_v->BooleanValue(isolate); + if (loop && dec->Seek(0.0)) { + dec->ReadNextFrame(); + } else { + dec->playing = false; + SetValue(isolate, obj, "ended", v8::Boolean::New(isolate, true)); + SetValue(isolate, obj, "paused", v8::Boolean::New(isolate, true)); + FireVideoEvent(isolate, obj, "ended"); + } } } *out_rgba = dec->frame.data(); @@ -180,19 +215,69 @@ v8::Local CreateVideoElement(v8::Isolate* isolate, HostContext* /*ho auto* dec = new VideoDecoder(); self->SetInternalField(0, v8::External::New(isolate, dec)); - auto* handle = new v8::Global(isolate, self); - handle->SetWeak(dec, ReleaseVideo, v8::WeakCallbackType::kParameter); + v8util::AttachWeak(isolate, self, dec); SetValue(isolate, self, "__isVideoElement", v8::Boolean::New(isolate, true)); SetValue(isolate, self, "tagName", Str(isolate, "VIDEO")); SetValue(isolate, self, "videoWidth", v8::Integer::New(isolate, 0)); SetValue(isolate, self, "videoHeight", v8::Integer::New(isolate, 0)); - SetValue(isolate, self, "currentTime", v8::Number::New(isolate, 0)); SetValue(isolate, self, "readyState", v8::Integer::New(isolate, 0)); SetValue(isolate, self, "loop", v8::Boolean::New(isolate, false)); + SetValue(isolate, self, "paused", v8::Boolean::New(isolate, true)); + SetValue(isolate, self, "ended", v8::Boolean::New(isolate, false)); + SetValue(isolate, self, "muted", v8::Boolean::New(isolate, false)); + SetValue(isolate, self, "volume", v8::Number::New(isolate, 1.0)); + SetValue(isolate, self, "autoplay", v8::Boolean::New(isolate, false)); SetValue(isolate, self, "style", v8::Object::New(isolate)); InstallEventTarget(isolate, self); + // currentTime: 取得はデコーダの現在位置、設定はシーク + self->SetNativeDataProperty(ctx, Str(isolate, "currentTime"), + [](v8::Local, const v8::PropertyCallbackInfo& info) { + VideoDecoder* d = Decoder(info.This()); + info.GetReturnValue().Set( + v8::Number::New(info.GetIsolate(), d ? d->position : 0.0)); + }, + [](v8::Local, v8::Local value, + const v8::PropertyCallbackInfo& info) { + if (!value->IsNumber()) return; + VideoDecoder* d = Decoder(info.This()); + if (d) { + d->Seek(value.As()->Value()); + } + }).Check(); + + // setAttribute / getAttribute (player は playsinline を設定する) + SetMethod(isolate, self, "setAttribute", + [](const v8::FunctionCallbackInfo& a) { + if (a.Length() < 2) return; + v8::Isolate* iso = a.GetIsolate(); + v8::Local c = iso->GetCurrentContext(); + v8::Local attrs; + v8::Local store; + if (a.This()->Get(c, v8util::Str(iso, "__attrs")).ToLocal(&attrs) && attrs->IsObject()) { + store = attrs.As(); + } else { + store = v8::Object::New(iso); + SetValue(iso, a.This(), "__attrs", store); + } + store->Set(c, a[0], a[1]).Check(); + }); + SetMethod(isolate, self, "getAttribute", + [](const v8::FunctionCallbackInfo& a) { + a.GetReturnValue().SetNull(); + if (a.Length() < 1) return; + v8::Isolate* iso = a.GetIsolate(); + v8::Local c = iso->GetCurrentContext(); + v8::Local attrs; + if (a.This()->Get(c, v8util::Str(iso, "__attrs")).ToLocal(&attrs) && attrs->IsObject()) { + v8::Local v; + if (attrs.As()->Get(c, a[0]).ToLocal(&v) && !v->IsUndefined()) { + a.GetReturnValue().Set(v); + } + } + }); + // src セッター: 代入で読み込み開始 self->SetNativeDataProperty(ctx, Str(isolate, "src"), [](v8::Local, const v8::PropertyCallbackInfo& info) { @@ -218,12 +303,16 @@ v8::Local CreateVideoElement(v8::Isolate* isolate, HostContext* /*ho SetMethod(isolate, self, "play", [](const v8::FunctionCallbackInfo& a) { v8::Isolate* iso = a.GetIsolate(); Decoder(a.This())->playing = true; + SetValue(iso, a.This(), "paused", v8::Boolean::New(iso, false)); + SetValue(iso, a.This(), "ended", v8::Boolean::New(iso, false)); auto r = v8::Promise::Resolver::New(iso->GetCurrentContext()).ToLocalChecked(); r->Resolve(iso->GetCurrentContext(), v8::Undefined(iso)).Check(); a.GetReturnValue().Set(r->GetPromise()); }); SetMethod(isolate, self, "pause", [](const v8::FunctionCallbackInfo& a) { + v8::Isolate* iso = a.GetIsolate(); Decoder(a.This())->playing = false; + SetValue(iso, a.This(), "paused", v8::Boolean::New(iso, true)); }); SetMethod(isolate, self, "canPlayType", [](const v8::FunctionCallbackInfo& a) { a.GetReturnValue().Set(Str(a.GetIsolate(), "maybe")); diff --git a/templates/xbox/src/bindings/webgpu/WebGPU.cpp b/templates/xbox/src/bindings/webgpu/WebGPU.cpp index a7a8c17..cac3f3d 100644 --- a/templates/xbox/src/bindings/webgpu/WebGPU.cpp +++ b/templates/xbox/src/bindings/webgpu/WebGPU.cpp @@ -9,6 +9,7 @@ #include "bindings/ImageSource.h" #include +#include #include #include @@ -24,7 +25,8 @@ struct Templates { v8::Global adapter, device, queue, buffer, texture, textureView, sampler, shaderModule, bindGroupLayout, bindGroup, pipelineLayout, renderPipeline, commandEncoder, renderPass, - commandBuffer, canvasContext; + commandBuffer, canvasContext, computePipeline, computePass, + querySet, renderBundle, renderBundleEncoder; }; static std::map g_templates; @@ -47,6 +49,9 @@ static v8::Local WrapTexture(v8::Isolate*, wgpu::Texture); static v8::Local WrapTextureView(v8::Isolate*, wgpu::TextureView); static v8::Local WrapEncoder(v8::Isolate*, wgpu::CommandEncoder); static v8::Local WrapPipeline(v8::Isolate*, wgpu::RenderPipeline); +static v8::Local WrapComputePipeline(v8::Isolate*, wgpu::ComputePipeline); +static v8::Local WrapQuerySet(v8::Isolate*, wgpu::QuerySet); +static v8::Local WrapRenderBundle(v8::Isolate*, wgpu::RenderBundle); // --------------------------------------------------------------------------- // GPUBuffer @@ -63,15 +68,25 @@ static void Buffer_GetMappedRange(const v8::FunctionCallbackInfo& arg : (buffer.GetSize() - offset); void* ptr = buffer.GetMappedRange(offset, static_cast(size)); - if (!ptr) { + if (ptr) { + // 書き込み可能マップ: マップ領域を指す非所有 ArrayBuffer (unmap まで有効) + std::unique_ptr store = v8::ArrayBuffer::NewBackingStore( + ptr, static_cast(size), + [](void*, size_t, void*) {}, nullptr); + args.GetReturnValue().Set(v8::ArrayBuffer::New(isolate, std::move(store))); + return; + } + + // MapMode::Read でマップされたバッファは非 const 版が nullptr を返す仕様。 + // GetConstMappedRange から所有コピーを返す (読み戻し copyTextureToBuffer 等の経路)。 + const void* cptr = buffer.GetConstMappedRange(offset, static_cast(size)); + if (!cptr) { args.GetReturnValue().Set(v8::ArrayBuffer::New(isolate, 0)); return; } - // マップ領域を指す非所有 ArrayBuffer (unmap まで有効) - std::unique_ptr store = v8::ArrayBuffer::NewBackingStore( - ptr, static_cast(size), - [](void*, size_t, void*) {}, nullptr); - args.GetReturnValue().Set(v8::ArrayBuffer::New(isolate, std::move(store))); + v8::Local ab = v8::ArrayBuffer::New(isolate, static_cast(size)); + memcpy(ab->GetBackingStore()->Data(), cptr, static_cast(size)); + args.GetReturnValue().Set(ab); } static void Buffer_Unmap(const v8::FunctionCallbackInfo& args) @@ -219,6 +234,55 @@ static wgpu::Extent3D ParseExtent(v8::Isolate* isolate, v8::Local val return extent; } +// GPUOrigin3D は {x,y,z} オブジェクト or [x,y,z] 配列。ParseExtent とは +// キー名が違う (width/height ではなく x/y/z、既定は 1 ではなく 0) ため専用に扱う。 +static wgpu::Origin3D ParseOrigin(v8::Isolate* isolate, v8::Local value) +{ + wgpu::Origin3D origin = {0, 0, 0}; + if (value->IsArray()) { + auto arr = value.As(); + v8::Local ctx = isolate->GetCurrentContext(); + auto get = [&](uint32_t i) -> uint32_t { + v8::Local v; + return (i < arr->Length() && arr->Get(ctx, i).ToLocal(&v) && v->IsNumber()) + ? static_cast(v.As()->Value()) : 0; + }; + origin.x = get(0); + origin.y = get(1); + origin.z = get(2); + } else if (value->IsObject()) { + auto o = value.As(); + origin.x = U32(isolate, o, "x", 0); + origin.y = U32(isolate, o, "y", 0); + origin.z = U32(isolate, o, "z", 0); + } + return origin; +} + +static wgpu::ErrorFilter ToErrorFilter(std::string_view s) +{ + if (s == "out-of-memory") return wgpu::ErrorFilter::OutOfMemory; + if (s == "internal") return wgpu::ErrorFilter::Internal; + return wgpu::ErrorFilter::Validation; +} + +static wgpu::QueryType ToQueryType(std::string_view s) +{ + if (s == "timestamp") return wgpu::QueryType::Timestamp; + return wgpu::QueryType::Occlusion; +} + +// wgpu::ErrorType -> WebGPU の GPUError 種別文字列 +static const char* ErrorTypeName(wgpu::ErrorType t) +{ + switch (t) { + case wgpu::ErrorType::Validation: return "validation"; + case wgpu::ErrorType::OutOfMemory: return "out-of-memory"; + case wgpu::ErrorType::Internal: return "internal"; + default: return "unknown"; + } +} + static void Queue_WriteTexture(const v8::FunctionCallbackInfo& args) { v8::Isolate* isolate = args.GetIsolate(); @@ -229,6 +293,11 @@ static void Queue_WriteTexture(const v8::FunctionCallbackInfo& args) wgpu::TexelCopyTextureInfo copy = {}; copy.texture = Unwrap(Prop(isolate, dest, "texture").As()); copy.mipLevel = U32(isolate, dest, "mipLevel", 0); + // 宛先 origin (アトラスのセル位置など)。未指定なら (0,0,0)。 + v8::Local dst_origin = Prop(isolate, dest, "origin"); + if (dst_origin->IsObject() || dst_origin->IsArray()) { + copy.origin = ParseOrigin(isolate, dst_origin); + } const void* data = nullptr; size_t length = 0; @@ -313,6 +382,12 @@ static void Queue_Submit(const v8::FunctionCallbackInfo& args) } } } + static uint64_t count = 0; + ++count; + if (count <= 10 || count % 300 == 0) { + std::cerr << "[GPU] queue.submit #" << count + << " (" << buffers.size() << " cmd)" << std::endl; + } queue.Submit(buffers.size(), buffers.data()); } @@ -460,6 +535,58 @@ static void Pass_End(const v8::FunctionCallbackInfo& args) Unwrap(args.This()).End(); } +static void Pass_BeginOcclusionQuery(const v8::FunctionCallbackInfo& args) +{ + auto& pass = Unwrap(args.This()); + uint32_t index = args.Length() > 0 && args[0]->IsNumber() + ? static_cast(args[0].As()->Value()) : 0; + pass.BeginOcclusionQuery(index); +} + +static void Pass_EndOcclusionQuery(const v8::FunctionCallbackInfo& args) +{ + Unwrap(args.This()).EndOcclusionQuery(); +} + +static void Pass_ExecuteBundles(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + auto& pass = Unwrap(args.This()); + std::vector bundles; + if (args.Length() > 0 && args[0]->IsArray()) { + auto arr = args[0].As(); + v8::Local ctx = isolate->GetCurrentContext(); + for (uint32_t i = 0; i < arr->Length(); ++i) { + v8::Local v; + if (arr->Get(ctx, i).ToLocal(&v) && v->IsObject()) + bundles.push_back(Unwrap(v.As())); + } + } + pass.ExecuteBundles(bundles.size(), bundles.data()); +} + +// pushDebugGroup / popDebugGroup / insertDebugMarker (render pass) +static void Pass_PushDebugGroup(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + std::string label = args.Length() > 0 ? v8util::ToStdString(isolate, args[0]) : ""; + Unwrap(args.This()).PushDebugGroup( + wgpu::StringView(label.c_str(), label.size())); +} + +static void Pass_PopDebugGroup(const v8::FunctionCallbackInfo& args) +{ + Unwrap(args.This()).PopDebugGroup(); +} + +static void Pass_InsertDebugMarker(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + std::string label = args.Length() > 0 ? v8util::ToStdString(isolate, args[0]) : ""; + Unwrap(args.This()).InsertDebugMarker( + wgpu::StringView(label.c_str(), label.size())); +} + static v8::Local WrapRenderPass(v8::Isolate* isolate, wgpu::RenderPassEncoder pass) { v8::Local obj = WrapWith(isolate, Tmpl(isolate).renderPass, std::move(pass)); @@ -474,10 +601,120 @@ static v8::Local WrapRenderPass(v8::Isolate* isolate, wgpu::RenderPa SetMethod(isolate, obj, "draw", Pass_Draw); SetMethod(isolate, obj, "drawIndexed", Pass_DrawIndexed); SetMethod(isolate, obj, "drawIndirect", Pass_DrawIndirect); + SetMethod(isolate, obj, "beginOcclusionQuery", Pass_BeginOcclusionQuery); + SetMethod(isolate, obj, "endOcclusionQuery", Pass_EndOcclusionQuery); + SetMethod(isolate, obj, "executeBundles", Pass_ExecuteBundles); + SetMethod(isolate, obj, "pushDebugGroup", Pass_PushDebugGroup); + SetMethod(isolate, obj, "popDebugGroup", Pass_PopDebugGroup); + SetMethod(isolate, obj, "insertDebugMarker", Pass_InsertDebugMarker); SetMethod(isolate, obj, "end", Pass_End); return obj; } +// --------------------------------------------------------------------------- +// GPUComputePassEncoder +// compute シェーダ経路 (createComputePipeline / beginComputePass / dispatch)。 +// Next2D の 2D ラスタライザは未使用だが、WebGPU の機能を特定用途に絞らず +// 全面的に利用可能にするため実装する。汎用 GPGPU / storage buffer 出力に対応。 +// --------------------------------------------------------------------------- +static void CPass_SetPipeline(const v8::FunctionCallbackInfo& args) +{ + Unwrap(args.This()).SetPipeline( + Unwrap(args[0].As())); +} + +static void CPass_SetBindGroup(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + auto& pass = Unwrap(args.This()); + uint32_t index = static_cast(args[0].As()->Value()); + if (args.Length() < 2 || !args[1]->IsObject()) { + pass.SetBindGroup(index, nullptr); + return; + } + auto& group = Unwrap(args[1].As()); + + // dynamicOffsets (render pass と同じく Array / Uint32Array) + std::vector offsets; + if (args.Length() > 2 && !args[2]->IsNullOrUndefined()) { + v8::Local ctx = isolate->GetCurrentContext(); + if (args[2]->IsArray()) { + auto arr = args[2].As(); + offsets.reserve(arr->Length()); + for (uint32_t i = 0; i < arr->Length(); ++i) { + v8::Local v; + if (arr->Get(ctx, i).ToLocal(&v) && v->IsNumber()) + offsets.push_back(static_cast(v.As()->Value())); + } + } else if (args[2]->IsUint32Array()) { + auto ta = args[2].As(); + offsets.resize(ta->Length()); + ta->CopyContents(offsets.data(), offsets.size() * sizeof(uint32_t)); + } + } + pass.SetBindGroup(index, group, + static_cast(offsets.size()), + offsets.empty() ? nullptr : offsets.data()); +} + +static void CPass_DispatchWorkgroups(const v8::FunctionCallbackInfo& args) +{ + auto& pass = Unwrap(args.This()); + auto n = [&](int i, uint32_t fb) { + return (i < args.Length() && args[i]->IsNumber()) + ? static_cast(args[i].As()->Value()) : fb; }; + pass.DispatchWorkgroups(n(0, 1), n(1, 1), n(2, 1)); +} + +static void CPass_DispatchWorkgroupsIndirect(const v8::FunctionCallbackInfo& args) +{ + auto& pass = Unwrap(args.This()); + auto& buffer = Unwrap(args[0].As()); + uint64_t offset = args.Length() > 1 && args[1]->IsNumber() + ? static_cast(args[1].As()->Value()) : 0; + pass.DispatchWorkgroupsIndirect(buffer, offset); +} + +static void CPass_End(const v8::FunctionCallbackInfo& args) +{ + Unwrap(args.This()).End(); +} + +static void CPass_PushDebugGroup(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + std::string label = args.Length() > 0 ? v8util::ToStdString(isolate, args[0]) : ""; + Unwrap(args.This()).PushDebugGroup( + wgpu::StringView(label.c_str(), label.size())); +} + +static void CPass_PopDebugGroup(const v8::FunctionCallbackInfo& args) +{ + Unwrap(args.This()).PopDebugGroup(); +} + +static void CPass_InsertDebugMarker(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + std::string label = args.Length() > 0 ? v8util::ToStdString(isolate, args[0]) : ""; + Unwrap(args.This()).InsertDebugMarker( + wgpu::StringView(label.c_str(), label.size())); +} + +static v8::Local WrapComputePass(v8::Isolate* isolate, wgpu::ComputePassEncoder pass) +{ + v8::Local obj = WrapWith(isolate, Tmpl(isolate).computePass, std::move(pass)); + SetMethod(isolate, obj, "setPipeline", CPass_SetPipeline); + SetMethod(isolate, obj, "setBindGroup", CPass_SetBindGroup); + SetMethod(isolate, obj, "dispatchWorkgroups", CPass_DispatchWorkgroups); + SetMethod(isolate, obj, "dispatchWorkgroupsIndirect", CPass_DispatchWorkgroupsIndirect); + SetMethod(isolate, obj, "pushDebugGroup", CPass_PushDebugGroup); + SetMethod(isolate, obj, "popDebugGroup", CPass_PopDebugGroup); + SetMethod(isolate, obj, "insertDebugMarker", CPass_InsertDebugMarker); + SetMethod(isolate, obj, "end", CPass_End); + return obj; +} + // --------------------------------------------------------------------------- // GPUCommandEncoder // --------------------------------------------------------------------------- @@ -501,6 +738,13 @@ static void Encoder_BeginRenderPass(const v8::FunctionCallbackInfo& a att.loadOp = ToLoadOp(webgpu::Str(isolate, o, "loadOp")); att.storeOp = ToStoreOp(webgpu::Str(isolate, o, "storeOp")); att.depthSlice = wgpu::kDepthSliceUndefined; + // MSAA 解決先。player は全描画を MSAA テクスチャへ行い resolveTarget で + // 本体テクスチャに解決する。未対応だと描画結果がどこにも現れない (黒画面)。 + v8::Local rt = Prop(isolate, o, "resolveTarget"); + if (rt->IsObject()) { + att.resolveTarget = + Unwrap(rt.As()); + } v8::Local cv = Prop(isolate, o, "clearValue"); if (cv->IsArray()) { auto c = cv.As(); @@ -544,10 +788,38 @@ static void Encoder_BeginRenderPass(const v8::FunctionCallbackInfo& a pass_desc.depthStencilAttachment = &depth; } + // occlusionQuerySet: beginOcclusionQuery/endOcclusionQuery で被覆サンプル数を計測する + v8::Local oqs = Prop(isolate, desc, "occlusionQuerySet"); + if (oqs->IsObject()) { + pass_desc.occlusionQuerySet = Unwrap(oqs.As()); + } + + // timestampWrites: { querySet, beginningOfPassWriteIndex?, endOfPassWriteIndex? } + // (timestamp-query フィーチャ有効時のみ有効。未有効なら player/検証側で未使用) + wgpu::PassTimestampWrites ts = {}; + v8::Local tsv = Prop(isolate, desc, "timestampWrites"); + if (tsv->IsObject()) { + auto t = tsv.As(); + ts.querySet = Unwrap(Prop(isolate, t, "querySet").As()); + ts.beginningOfPassWriteIndex = U32(isolate, t, "beginningOfPassWriteIndex", wgpu::kQuerySetIndexUndefined); + ts.endOfPassWriteIndex = U32(isolate, t, "endOfPassWriteIndex", wgpu::kQuerySetIndexUndefined); + pass_desc.timestampWrites = &ts; + } + args.GetReturnValue().Set( WrapRenderPass(isolate, encoder.BeginRenderPass(&pass_desc))); } +static void Encoder_BeginComputePass(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + auto& encoder = Unwrap(args.This()); + // descriptor は任意 (timestampWrites 等は未使用)。既定で開始する。 + wgpu::ComputePassDescriptor desc = {}; + args.GetReturnValue().Set( + WrapComputePass(isolate, encoder.BeginComputePass(&desc))); +} + static void Encoder_Finish(const v8::FunctionCallbackInfo& args) { v8::Isolate* isolate = args.GetIsolate(); @@ -575,8 +847,7 @@ static wgpu::TexelCopyTextureInfo ParseTexelCopyTexture(v8::Isolate* isolate, v8 info.mipLevel = U32(isolate, o, "mipLevel", 0); v8::Local origin = Prop(isolate, o, "origin"); if (origin->IsObject() || origin->IsArray()) { - wgpu::Extent3D e = ParseExtent(isolate, origin); - info.origin = { e.width, e.height, e.depthOrArrayLayers }; + info.origin = ParseOrigin(isolate, origin); } return info; } @@ -608,6 +879,187 @@ static void Encoder_CopyTextureToBuffer(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + auto& encoder = Unwrap(args.This()); + auto& qs = Unwrap(args[0].As()); + uint32_t first = static_cast(args[1].As()->Value()); + uint32_t count = static_cast(args[2].As()->Value()); + auto& dst = Unwrap(args[3].As()); + uint64_t dst_off = args.Length() > 4 && args[4]->IsNumber() + ? static_cast(args[4].As()->Value()) : 0; + (void) isolate; + encoder.ResolveQuerySet(qs, first, count, dst, dst_off); +} + +static void Encoder_WriteTimestamp(const v8::FunctionCallbackInfo& args) +{ + auto& encoder = Unwrap(args.This()); + auto& qs = Unwrap(args[0].As()); + uint32_t index = static_cast(args[1].As()->Value()); + encoder.WriteTimestamp(qs, index); +} + +static void Encoder_PushDebugGroup(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + std::string label = args.Length() > 0 ? v8util::ToStdString(isolate, args[0]) : ""; + Unwrap(args.This()).PushDebugGroup( + wgpu::StringView(label.c_str(), label.size())); +} + +static void Encoder_PopDebugGroup(const v8::FunctionCallbackInfo& args) +{ + Unwrap(args.This()).PopDebugGroup(); +} + +static void Encoder_InsertDebugMarker(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + std::string label = args.Length() > 0 ? v8util::ToStdString(isolate, args[0]) : ""; + Unwrap(args.This()).InsertDebugMarker( + wgpu::StringView(label.c_str(), label.size())); +} + +// --------------------------------------------------------------------------- +// GPURenderBundleEncoder (createRenderBundleEncoder -> finish -> GPURenderBundle) +// --------------------------------------------------------------------------- +static void RBEncoder_SetPipeline(const v8::FunctionCallbackInfo& args) +{ + Unwrap(args.This()).SetPipeline( + Unwrap(args[0].As())); +} + +static void RBEncoder_SetBindGroup(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + auto& enc = Unwrap(args.This()); + uint32_t index = static_cast(args[0].As()->Value()); + if (args.Length() < 2 || !args[1]->IsObject()) { + enc.SetBindGroup(index, nullptr); + return; + } + auto& group = Unwrap(args[1].As()); + std::vector offsets; + if (args.Length() > 2 && !args[2]->IsNullOrUndefined()) { + v8::Local ctx = isolate->GetCurrentContext(); + if (args[2]->IsArray()) { + auto arr = args[2].As(); + offsets.reserve(arr->Length()); + for (uint32_t i = 0; i < arr->Length(); ++i) { + v8::Local v; + if (arr->Get(ctx, i).ToLocal(&v) && v->IsNumber()) + offsets.push_back(static_cast(v.As()->Value())); + } + } else if (args[2]->IsUint32Array()) { + auto ta = args[2].As(); + offsets.resize(ta->Length()); + ta->CopyContents(offsets.data(), offsets.size() * sizeof(uint32_t)); + } + } + enc.SetBindGroup(index, group, static_cast(offsets.size()), + offsets.empty() ? nullptr : offsets.data()); +} + +static void RBEncoder_SetVertexBuffer(const v8::FunctionCallbackInfo& args) +{ + auto& enc = Unwrap(args.This()); + uint32_t slot = static_cast(args[0].As()->Value()); + auto& buffer = Unwrap(args[1].As()); + uint64_t offset = args.Length() > 2 && args[2]->IsNumber() + ? static_cast(args[2].As()->Value()) : 0; + uint64_t size = args.Length() > 3 && args[3]->IsNumber() + ? static_cast(args[3].As()->Value()) : wgpu::kWholeSize; + enc.SetVertexBuffer(slot, buffer, offset, size); +} + +static void RBEncoder_SetIndexBuffer(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + auto& enc = Unwrap(args.This()); + auto& buffer = Unwrap(args[0].As()); + wgpu::IndexFormat format = ToIndexFormat(v8util::ToStdString(isolate, args[1])); + uint64_t offset = args.Length() > 2 && args[2]->IsNumber() + ? static_cast(args[2].As()->Value()) : 0; + uint64_t size = args.Length() > 3 && args[3]->IsNumber() + ? static_cast(args[3].As()->Value()) : wgpu::kWholeSize; + enc.SetIndexBuffer(buffer, format, offset, size); +} + +static void RBEncoder_Draw(const v8::FunctionCallbackInfo& args) +{ + auto& enc = Unwrap(args.This()); + auto n = [&](int i, uint32_t fb) { + return (i < args.Length() && args[i]->IsNumber()) + ? static_cast(args[i].As()->Value()) : fb; }; + enc.Draw(n(0, 0), n(1, 1), n(2, 0), n(3, 0)); +} + +static void RBEncoder_DrawIndexed(const v8::FunctionCallbackInfo& args) +{ + auto& enc = Unwrap(args.This()); + auto n = [&](int i, uint32_t fb) { + return (i < args.Length() && args[i]->IsNumber()) + ? static_cast(args[i].As()->Value()) : fb; }; + enc.DrawIndexed(n(0, 0), n(1, 1), n(2, 0), n(3, 0), n(4, 0)); +} + +static void RBEncoder_DrawIndirect(const v8::FunctionCallbackInfo& args) +{ + auto& enc = Unwrap(args.This()); + auto& buffer = Unwrap(args[0].As()); + uint64_t offset = args.Length() > 1 && args[1]->IsNumber() + ? static_cast(args[1].As()->Value()) : 0; + enc.DrawIndirect(buffer, offset); +} + +static void RBEncoder_Finish(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + auto& enc = Unwrap(args.This()); + wgpu::RenderBundleDescriptor desc = {}; + args.GetReturnValue().Set(WrapRenderBundle(isolate, enc.Finish(&desc))); +} + +static v8::Local WrapRenderBundleEncoder(v8::Isolate* isolate, wgpu::RenderBundleEncoder enc) +{ + auto obj = WrapWith(isolate, Tmpl(isolate).renderBundleEncoder, std::move(enc)); + SetMethod(isolate, obj, "setPipeline", RBEncoder_SetPipeline); + SetMethod(isolate, obj, "setBindGroup", RBEncoder_SetBindGroup); + SetMethod(isolate, obj, "setVertexBuffer", RBEncoder_SetVertexBuffer); + SetMethod(isolate, obj, "setIndexBuffer", RBEncoder_SetIndexBuffer); + SetMethod(isolate, obj, "draw", RBEncoder_Draw); + SetMethod(isolate, obj, "drawIndexed", RBEncoder_DrawIndexed); + SetMethod(isolate, obj, "drawIndirect", RBEncoder_DrawIndirect); + SetMethod(isolate, obj, "finish", RBEncoder_Finish); + return obj; +} + +static v8::Local WrapRenderBundle(v8::Isolate* isolate, wgpu::RenderBundle bundle) +{ + return WrapWith(isolate, Tmpl(isolate).renderBundle, std::move(bundle)); +} + +// --------------------------------------------------------------------------- +// GPUQuerySet +// --------------------------------------------------------------------------- +static void QuerySet_Destroy(const v8::FunctionCallbackInfo& args) +{ + Unwrap(args.This()).Destroy(); +} + +static v8::Local WrapQuerySet(v8::Isolate* isolate, wgpu::QuerySet qs) +{ + auto obj = WrapWith(isolate, Tmpl(isolate).querySet, std::move(qs)); + auto& q = Unwrap(obj); + SetValue(isolate, obj, "count", v8::Integer::NewFromUnsigned(isolate, q.GetCount())); + SetValue(isolate, obj, "type", + Str(isolate, q.GetType() == wgpu::QueryType::Timestamp ? "timestamp" : "occlusion")); + SetMethod(isolate, obj, "destroy", QuerySet_Destroy); + return obj; +} + // --------------------------------------------------------------------------- // GPUCanvasContext // --------------------------------------------------------------------------- @@ -617,6 +1069,11 @@ static void Ctx_Configure(const v8::FunctionCallbackInfo& args) DawnContext* gpu = HostContext::From(isolate)->gpu; // device は既に DawnContext が保持。フォーマット/サイズのみ反映する。 if (args.Length() > 0 && args[0]->IsObject()) { + static uint64_t count = 0; + ++count; + if (count <= 10 || count % 300 == 0) { + std::cerr << "[GPU] context.configure #" << count << std::endl; + } // width/height は canvas サイズを使用 (configure の size は任意) gpu->Configure(gpu->width(), gpu->height()); } @@ -626,6 +1083,11 @@ static void Ctx_GetCurrentTexture(const v8::FunctionCallbackInfo& arg { v8::Isolate* isolate = args.GetIsolate(); DawnContext* gpu = HostContext::From(isolate)->gpu; + static uint64_t count = 0; + ++count; + if (count <= 10 || count % 300 == 0) { + std::cerr << "[GPU] getCurrentTexture #" << count << std::endl; + } args.GetReturnValue().Set(WrapTexture(isolate, gpu->GetCurrentTexture())); } @@ -881,17 +1343,62 @@ struct RenderPipelineScratch { std::vector targets; std::vector blends; std::string vs_entry, fs_entry; + // pipeline-overridable constants (WGSL の override 定数)。key 文字列は + // ConstantEntry.key が指すため CreateRenderPipeline 完了まで生存させる。 + std::vector vs_const_keys, fs_const_keys; + std::vector vs_constants, fs_constants; }; -static void Device_CreateRenderPipeline(const v8::FunctionCallbackInfo& args) +// { "KEY": number|bool, ... } を wgpu::ConstantEntry 配列へ変換する。 +// player は WGSL の override(GRADIENT_TYPE / SPREAD_MODE / yFlipSign / フィルタ種別など) +// を constants で指定してパイプラインを特殊化する。ここを読まないと全パイプラインが +// WGSL の既定値でコンパイルされ、放射状グラデが線形に、spread が pad に、 +// フィルタ種別が既定分岐に潰れる (エラーは出ない)。 +static void ParsePipelineConstants( + v8::Isolate* isolate, v8::Local stage, + std::vector& keys, std::vector& entries) { - v8::Isolate* isolate = args.GetIsolate(); v8::Local ctx = isolate->GetCurrentContext(); - auto& device = Unwrap(args.This()); - v8::Local d = args[0].As(); + v8::Local cv = Prop(isolate, stage, "constants"); + if (!cv->IsObject()) { + return; + } + auto co = cv.As(); + v8::Local names; + if (!co->GetOwnPropertyNames(ctx).ToLocal(&names)) { + return; + } + // 再確保で ConstantEntry.key の指す c_str が無効化されないよう上限で確保する。 + keys.reserve(keys.size() + names->Length()); + entries.reserve(entries.size() + names->Length()); + for (uint32_t i = 0; i < names->Length(); ++i) { + v8::Local k, val; + if (!names->Get(ctx, i).ToLocal(&k)) continue; + if (!co->Get(ctx, k).ToLocal(&val)) continue; + double num; + if (val->IsBoolean()) { + num = val.As()->Value() ? 1.0 : 0.0; + } else if (val->IsNumber()) { + num = val.As()->Value(); + } else { + continue; + } + v8::String::Utf8Value ks(isolate, k); + if (!*ks) continue; + keys.emplace_back(*ks, static_cast(ks.length())); + wgpu::ConstantEntry e = {}; + e.key = wgpu::StringView(keys.back().c_str(), keys.back().size()); + e.value = num; + entries.push_back(e); + } +} - RenderPipelineScratch scratch; - wgpu::RenderPipelineDescriptor desc = {}; +// ディスクリプタ構築 (sync/async 双方から使う。desc/scratch は呼び出し側が保持し、 +// CreateRenderPipeline[Async] が同期コピーするまで生存させる)。 +static void FillRenderPipelineDescriptor(v8::Isolate* isolate, v8::Local d, + RenderPipelineScratch& scratch, wgpu::RenderPipelineDescriptor& desc) +{ + v8::Local ctx = isolate->GetCurrentContext(); // layout: 'auto' or GPUPipelineLayout v8::Local layout = Prop(isolate, d, "layout"); @@ -944,6 +1451,13 @@ static void Device_CreateRenderPipeline(const v8::FunctionCallbackInfo prim = Prop(isolate, d, "primitive"); if (prim->IsObject()) { @@ -1008,6 +1522,13 @@ static void Device_CreateRenderPipeline(const v8::FunctionCallbackInfoIsObject()) { auto m = ms.As(); desc.multisample.count = U32(isolate, m, "count", 1); + // alphaToCoverageEnabled: フラグメント alpha を MSAA カバレッジへ変換する。 + // Next2D はベクタ図形の境界を塗り alpha に頼るため、これが無いと全図形の + // アンチエイリアスが失われて縁がジャギる (静かな品質劣化)。 + desc.multisample.alphaToCoverageEnabled = Bool(isolate, m, "alphaToCoverageEnabled", false); + if (HasProp(isolate, m, "mask")) { + desc.multisample.mask = U32(isolate, m, "mask", 0xFFFFFFFF); + } } // fragment @@ -1053,12 +1574,223 @@ static void Device_CreateRenderPipeline(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + auto& device = Unwrap(args.This()); + RenderPipelineScratch scratch; + wgpu::RenderPipelineDescriptor desc = {}; + FillRenderPipelineDescriptor(isolate, args[0].As(), scratch, desc); args.GetReturnValue().Set(WrapPipeline(isolate, device.CreateRenderPipeline(&desc))); } +// --------------------------------------------------------------------------- +// GPUDevice.createComputePipeline +// { layout: 'auto'|GPUPipelineLayout, compute: {module, entryPoint?, constants?} } +// --------------------------------------------------------------------------- +// compute ディスクリプタ構築 (sync/async 共有)。entryPoint/constants の文字列寿命は +// 呼び出し側が保持する keys/entry_strs で管理する。 +struct ComputePipelineScratch { + std::string cs_entry; + std::vector cs_const_keys; + std::vector cs_constants; +}; + +static void FillComputePipelineDescriptor(v8::Isolate* isolate, v8::Local d, + ComputePipelineScratch& scratch, wgpu::ComputePipelineDescriptor& desc) +{ + v8::Local layout = Prop(isolate, d, "layout"); + if (layout->IsObject()) { + desc.layout = Unwrap(layout.As()); + } + v8::Local compute = Prop(isolate, d, "compute").As(); + desc.compute.module = Unwrap(Prop(isolate, compute, "module").As()); + scratch.cs_entry = webgpu::Str(isolate, compute, "entryPoint"); + if (!scratch.cs_entry.empty()) { + desc.compute.entryPoint = wgpu::StringView(scratch.cs_entry.c_str(), scratch.cs_entry.size()); + } + ParsePipelineConstants(isolate, compute, scratch.cs_const_keys, scratch.cs_constants); + if (!scratch.cs_constants.empty()) { + desc.compute.constantCount = scratch.cs_constants.size(); + desc.compute.constants = scratch.cs_constants.data(); + } +} + +static void Device_CreateComputePipeline(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + auto& device = Unwrap(args.This()); + ComputePipelineScratch scratch; + wgpu::ComputePipelineDescriptor desc = {}; + FillComputePipelineDescriptor(isolate, args[0].As(), scratch, desc); + args.GetReturnValue().Set( + WrapComputePipeline(isolate, device.CreateComputePipeline(&desc))); +} + +// createRenderPipelineAsync / createComputePipelineAsync -> Promise +// Dawn C++ の Future 版 (CallbackMode::AllowProcessEvents で event loop が処理)。 +// ディスクリプタは呼び出し時に同期コピーされるため scratch はスタックで足りる。 +static void Device_CreateRenderPipelineAsync(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + v8::Local ctx = isolate->GetCurrentContext(); + auto& device = Unwrap(args.This()); + RenderPipelineScratch scratch; + wgpu::RenderPipelineDescriptor desc = {}; + FillRenderPipelineDescriptor(isolate, args[0].As(), scratch, desc); + + auto resolver = v8::Promise::Resolver::New(ctx).ToLocalChecked(); + auto* persistent = new v8::Global(isolate, resolver); + args.GetReturnValue().Set(resolver->GetPromise()); + + device.CreateRenderPipelineAsync(&desc, wgpu::CallbackMode::AllowProcessEvents, + [isolate, persistent](wgpu::CreatePipelineAsyncStatus status, + wgpu::RenderPipeline pipeline, wgpu::StringView) { + v8::HandleScope scope(isolate); + v8::Local c = isolate->GetCurrentContext(); + auto r = persistent->Get(isolate); + if (status == wgpu::CreatePipelineAsyncStatus::Success) { + r->Resolve(c, WrapPipeline(isolate, pipeline)).Check(); + } else { + r->Reject(c, v8::Exception::Error( + v8util::Str(isolate, "createRenderPipelineAsync failed"))).Check(); + } + persistent->Reset(); delete persistent; + }); +} + +static void Device_CreateComputePipelineAsync(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + v8::Local ctx = isolate->GetCurrentContext(); + auto& device = Unwrap(args.This()); + ComputePipelineScratch scratch; + wgpu::ComputePipelineDescriptor desc = {}; + FillComputePipelineDescriptor(isolate, args[0].As(), scratch, desc); + + auto resolver = v8::Promise::Resolver::New(ctx).ToLocalChecked(); + auto* persistent = new v8::Global(isolate, resolver); + args.GetReturnValue().Set(resolver->GetPromise()); + + device.CreateComputePipelineAsync(&desc, wgpu::CallbackMode::AllowProcessEvents, + [isolate, persistent](wgpu::CreatePipelineAsyncStatus status, + wgpu::ComputePipeline pipeline, wgpu::StringView) { + v8::HandleScope scope(isolate); + v8::Local c = isolate->GetCurrentContext(); + auto r = persistent->Get(isolate); + if (status == wgpu::CreatePipelineAsyncStatus::Success) { + r->Resolve(c, WrapComputePipeline(isolate, pipeline)).Check(); + } else { + r->Reject(c, v8::Exception::Error( + v8util::Str(isolate, "createComputePipelineAsync failed"))).Check(); + } + persistent->Reset(); delete persistent; + }); +} + +// createQuerySet({ type: 'occlusion'|'timestamp', count }) +static void Device_CreateQuerySet(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + auto& device = Unwrap(args.This()); + v8::Local d = args[0].As(); + wgpu::QuerySetDescriptor desc = {}; + desc.type = ToQueryType(webgpu::Str(isolate, d, "type")); + desc.count = U32(isolate, d, "count", 0); + args.GetReturnValue().Set(WrapQuerySet(isolate, device.CreateQuerySet(&desc))); +} + +// createRenderBundleEncoder({ colorFormats[], depthStencilFormat?, sampleCount?, ... }) +static void Device_CreateRenderBundleEncoder(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + v8::Local ctx = isolate->GetCurrentContext(); + auto& device = Unwrap(args.This()); + v8::Local d = args[0].As(); + + std::vector formats; + v8::Local cf = Prop(isolate, d, "colorFormats"); + if (cf->IsArray()) { + auto arr = cf.As(); + for (uint32_t i = 0; i < arr->Length(); ++i) { + v8::Local v; + if (arr->Get(ctx, i).ToLocal(&v) && v->IsString()) { + formats.push_back(ToTextureFormat(v8util::ToStdString(isolate, v))); + } else { + formats.push_back(wgpu::TextureFormat::Undefined); // ギャップ (null) + } + } + } + + wgpu::RenderBundleEncoderDescriptor desc = {}; + desc.colorFormatCount = formats.size(); + desc.colorFormats = formats.data(); + if (HasProp(isolate, d, "depthStencilFormat")) { + desc.depthStencilFormat = ToTextureFormat(webgpu::Str(isolate, d, "depthStencilFormat")); + } + desc.sampleCount = U32(isolate, d, "sampleCount", 1); + desc.depthReadOnly = Bool(isolate, d, "depthReadOnly", false); + desc.stencilReadOnly = Bool(isolate, d, "stencilReadOnly", false); + + args.GetReturnValue().Set( + WrapRenderBundleEncoder(isolate, device.CreateRenderBundleEncoder(&desc))); +} + +// pushErrorScope(filter) / popErrorScope() -> Promise +static void Device_PushErrorScope(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + wgpu::ErrorFilter filter = ToErrorFilter( + args.Length() > 0 ? v8util::ToStdString(isolate, args[0]) : "validation"); + Unwrap(args.This()).PushErrorScope(filter); +} + +static void Device_PopErrorScope(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + v8::Local ctx = isolate->GetCurrentContext(); + auto& device = Unwrap(args.This()); + + auto resolver = v8::Promise::Resolver::New(ctx).ToLocalChecked(); + auto* persistent = new v8::Global(isolate, resolver); + args.GetReturnValue().Set(resolver->GetPromise()); + + device.PopErrorScope(wgpu::CallbackMode::AllowProcessEvents, + [isolate, persistent](wgpu::PopErrorScopeStatus, wgpu::ErrorType type, wgpu::StringView message) { + v8::HandleScope scope(isolate); + v8::Local c = isolate->GetCurrentContext(); + auto r = persistent->Get(isolate); + if (type == wgpu::ErrorType::NoError) { + r->Resolve(c, v8::Null(isolate)).Check(); + } else { + // GPUError 相当のオブジェクト ({ message, __errorType }) + v8::Local err = v8::Object::New(isolate); + std::string msg(message.data ? message.data : "", message.length); + SetValue(isolate, err, "message", v8util::Str(isolate, msg.c_str())); + SetValue(isolate, err, "__errorType", v8util::Str(isolate, ErrorTypeName(type))); + r->Resolve(c, err).Check(); + } + persistent->Reset(); delete persistent; + }); +} + +static void Device_Destroy(const v8::FunctionCallbackInfo& args) +{ + Unwrap(args.This()).Destroy(); +} + static void Device_CreateCommandEncoder(const v8::FunctionCallbackInfo& args) { v8::Isolate* isolate = args.GetIsolate(); @@ -1106,10 +1838,16 @@ static v8::Local WrapEncoder(v8::Isolate* isolate, wgpu::CommandEnco { auto obj = WrapWith(isolate, Tmpl(isolate).commandEncoder, std::move(encoder)); SetMethod(isolate, obj, "beginRenderPass", Encoder_BeginRenderPass); + SetMethod(isolate, obj, "beginComputePass", Encoder_BeginComputePass); SetMethod(isolate, obj, "finish", Encoder_Finish); SetMethod(isolate, obj, "copyBufferToBuffer", Encoder_CopyBufferToBuffer); SetMethod(isolate, obj, "copyTextureToTexture", Encoder_CopyTextureToTexture); SetMethod(isolate, obj, "copyTextureToBuffer", Encoder_CopyTextureToBuffer); + SetMethod(isolate, obj, "resolveQuerySet", Encoder_ResolveQuerySet); + SetMethod(isolate, obj, "writeTimestamp", Encoder_WriteTimestamp); + SetMethod(isolate, obj, "pushDebugGroup", Encoder_PushDebugGroup); + SetMethod(isolate, obj, "popDebugGroup", Encoder_PopDebugGroup); + SetMethod(isolate, obj, "insertDebugMarker", Encoder_InsertDebugMarker); return obj; } @@ -1120,9 +1858,45 @@ static v8::Local WrapPipeline(v8::Isolate* isolate, wgpu::RenderPipe return obj; } +static void ComputePipeline_GetBindGroupLayout(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + auto& pipeline = Unwrap(args.This()); + uint32_t index = args.Length() > 0 && args[0]->IsNumber() + ? static_cast(args[0].As()->Value()) : 0; + args.GetReturnValue().Set( + WrapWith(isolate, Tmpl(isolate).bindGroupLayout, pipeline.GetBindGroupLayout(index))); +} + +static v8::Local WrapComputePipeline(v8::Isolate* isolate, wgpu::ComputePipeline pipeline) +{ + auto obj = WrapWith(isolate, Tmpl(isolate).computePipeline, std::move(pipeline)); + SetMethod(isolate, obj, "getBindGroupLayout", ComputePipeline_GetBindGroupLayout); + return obj; +} + // --------------------------------------------------------------------------- // GPUDevice / GPUQueue / GPUAdapter のラップ // --------------------------------------------------------------------------- +static void Queue_OnSubmittedWorkDone(const v8::FunctionCallbackInfo& args) +{ + v8::Isolate* isolate = args.GetIsolate(); + v8::Local ctx = isolate->GetCurrentContext(); + auto& queue = Unwrap(args.This()); + + auto resolver = v8::Promise::Resolver::New(ctx).ToLocalChecked(); + auto* persistent = new v8::Global(isolate, resolver); + args.GetReturnValue().Set(resolver->GetPromise()); + + queue.OnSubmittedWorkDone(wgpu::CallbackMode::AllowProcessEvents, + [isolate, persistent](wgpu::QueueWorkDoneStatus, wgpu::StringView) { + v8::HandleScope scope(isolate); + v8::Local c = isolate->GetCurrentContext(); + persistent->Get(isolate)->Resolve(c, v8::Undefined(isolate)).Check(); + persistent->Reset(); delete persistent; + }); +} + static v8::Local WrapQueue(v8::Isolate* isolate, wgpu::Queue queue) { auto obj = WrapWith(isolate, Tmpl(isolate).queue, std::move(queue)); @@ -1130,6 +1904,7 @@ static v8::Local WrapQueue(v8::Isolate* isolate, wgpu::Queue queue) SetMethod(isolate, obj, "writeBuffer", Queue_WriteBuffer); SetMethod(isolate, obj, "writeTexture", Queue_WriteTexture); SetMethod(isolate, obj, "copyExternalImageToTexture", Queue_CopyExternalImageToTexture); + SetMethod(isolate, obj, "onSubmittedWorkDone", Queue_OnSubmittedWorkDone); return obj; } @@ -1144,10 +1919,26 @@ static v8::Local WrapDevice(v8::Isolate* isolate, wgpu::Device devic SetMethod(isolate, obj, "createBindGroup", Device_CreateBindGroup); SetMethod(isolate, obj, "createPipelineLayout", Device_CreatePipelineLayout); SetMethod(isolate, obj, "createRenderPipeline", Device_CreateRenderPipeline); + SetMethod(isolate, obj, "createComputePipeline", Device_CreateComputePipeline); + SetMethod(isolate, obj, "createRenderPipelineAsync", Device_CreateRenderPipelineAsync); + SetMethod(isolate, obj, "createComputePipelineAsync", Device_CreateComputePipelineAsync); SetMethod(isolate, obj, "createCommandEncoder", Device_CreateCommandEncoder); + SetMethod(isolate, obj, "createQuerySet", Device_CreateQuerySet); + SetMethod(isolate, obj, "createRenderBundleEncoder", Device_CreateRenderBundleEncoder); + SetMethod(isolate, obj, "pushErrorScope", Device_PushErrorScope); + SetMethod(isolate, obj, "popErrorScope", Device_PopErrorScope); + SetMethod(isolate, obj, "destroy", Device_Destroy); SetValue(isolate, obj, "queue", WrapQueue(isolate, device.GetQueue())); SetValue(isolate, obj, "features", v8::Object::New(isolate)); + // device.lost: Promise。デバイス生存中は pending (spec 準拠)。 + // 実際のロストは Dawn の deviceLost コールバック経路 (DawnContext 側) に委ねる。 + { + v8::Local lctx = isolate->GetCurrentContext(); + auto lost = v8::Promise::Resolver::New(lctx).ToLocalChecked(); + SetValue(isolate, obj, "lost", lost->GetPromise()); + } + // limits: player は maxTextureDimension2D で描画最大サイズを決めるため実値を返す。 // GetLimits の戻り値型は Dawn バージョン差があるため値でガードする(0 なら保証最小値)。 v8::Local limits = v8::Object::New(isolate); @@ -1224,6 +2015,8 @@ static void InitTemplates(v8::Isolate* isolate) make(t.textureView); make(t.sampler); make(t.shaderModule); make(t.bindGroupLayout); make(t.bindGroup); make(t.pipelineLayout); make(t.renderPipeline); make(t.commandEncoder); make(t.renderPass); make(t.commandBuffer); make(t.canvasContext); + make(t.computePipeline); make(t.computePass); + make(t.querySet); make(t.renderBundle); make(t.renderBundleEncoder); } static void SetFlag(v8::Isolate* isolate, v8::Local obj, const char* k, uint32_t v) @@ -1234,6 +2027,13 @@ static void SetFlag(v8::Isolate* isolate, v8::Local obj, const char* } // namespace webgpu // Bindings.h から呼ばれる公開関数 +// V8 破棄前に呼ぶ。テンプレートの v8::Global (static map) を解放する。 +// (放置すると static デストラクタが V8 破棄後に走りアクセス違反になる) +void ShutdownWebGPU() +{ + webgpu::g_templates.clear(); +} + void InstallWebGPU(v8::Isolate* isolate, v8::Local global, HostContext* host) { using namespace next2d::webgpu; diff --git a/templates/xbox/src/bindings/webgpu/WebGPUCommon.h b/templates/xbox/src/bindings/webgpu/WebGPUCommon.h index 80c0661..56c6e60 100644 --- a/templates/xbox/src/bindings/webgpu/WebGPUCommon.h +++ b/templates/xbox/src/bindings/webgpu/WebGPUCommon.h @@ -15,6 +15,7 @@ #include #include "v8/V8Util.h" +#include "v8/WeakHandle.h" #include #include @@ -27,13 +28,8 @@ struct Holder { T handle; }; -template -void ReleaseHolder(const v8::WeakCallbackInfo>& info) -{ - delete info.GetParameter(); -} - // wgpu ハンドル T を、指定 ObjectTemplate から生成したオブジェクトにラップする。 +// GC 時の解放は AttachWeak (第一パスで Global を Reset する規約に準拠) に委ねる。 template v8::Local Wrap(v8::Isolate* isolate, v8::Local tmpl, @@ -43,8 +39,7 @@ v8::Local Wrap(v8::Isolate* isolate, v8::Local obj = tmpl->NewInstance(ctx).ToLocalChecked(); auto* holder = new Holder{std::move(handle)}; obj->SetInternalField(0, v8::External::New(isolate, holder)); - auto* global = new v8::Global(isolate, obj); - global->SetWeak(holder, ReleaseHolder, v8::WeakCallbackType::kParameter); + v8util::AttachWeak(isolate, obj, holder); return obj; } diff --git a/templates/xbox/src/gpu/DawnContext.cpp b/templates/xbox/src/gpu/DawnContext.cpp index 87f0a10..bb683e5 100644 --- a/templates/xbox/src/gpu/DawnContext.cpp +++ b/templates/xbox/src/gpu/DawnContext.cpp @@ -1,7 +1,6 @@ #include "DawnContext.h" #include -#include #include namespace next2d { @@ -114,12 +113,13 @@ bool DawnContext::AcquireDevice() return false; } - // surface の優先フォーマットを取得 + // surface の優先フォーマットと CopySrc 可否 (デバッグ読み戻し用) を取得 wgpu::SurfaceCapabilities caps = {}; surface_.GetCapabilities(adapter_, &caps); if (caps.formatCount > 0) { format_ = caps.formats[0]; } + can_copy_src_ = (caps.usages & wgpu::TextureUsage::CopySrc) == wgpu::TextureUsage::CopySrc; Configure(width_, height_); return true; @@ -138,26 +138,113 @@ void DawnContext::Configure(uint32_t width, uint32_t height) config.device = device_; config.format = format_; config.usage = wgpu::TextureUsage::RenderAttachment; + if (can_copy_src_) { + // 黒画面デバッグ用の surface 読み戻し (DebugProbeSurface) を可能にする + config.usage |= wgpu::TextureUsage::CopySrc; + } config.width = width_; config.height = height_; config.presentMode = wgpu::PresentMode::Fifo; config.alphaMode = wgpu::CompositeAlphaMode::Opaque; surface_.Configure(&config); + configured_ = true; } wgpu::Texture DawnContext::GetCurrentTexture() { + if (!configured_) { + return nullptr; + } wgpu::SurfaceTexture surface_texture = {}; surface_.GetCurrentTexture(&surface_texture); + frame_texture_acquired_ = true; + current_texture_ = surface_texture.texture; return surface_texture.texture; } +// 提示直前の surface 中央行を読み戻し、非黒ピクセル数をログする (黒画面調査用)。 +// CI では目視できないため、これが「画面に何が出ているか」の唯一の証拠になる。 +void DawnContext::DebugProbeSurface() +{ + if (!can_copy_src_ || !current_texture_ || width_ == 0) { + std::cerr << "[GPU] surface probe unavailable (CopySrc=" + << (can_copy_src_ ? "yes" : "no") << ")" << std::endl; + return; + } + + const uint32_t row_bytes = width_ * 4; + wgpu::BufferDescriptor buf_desc = {}; + buf_desc.size = (row_bytes + 3) & ~3u; + buf_desc.usage = wgpu::BufferUsage::CopyDst | wgpu::BufferUsage::MapRead; + wgpu::Buffer buffer = device_.CreateBuffer(&buf_desc); + + wgpu::TexelCopyTextureInfo src = {}; + src.texture = current_texture_; + src.origin = { 0, height_ / 2, 0 }; + + wgpu::TexelCopyBufferInfo dst = {}; + dst.buffer = buffer; + dst.layout.offset = 0; + dst.layout.bytesPerRow = wgpu::kCopyStrideUndefined; // 1 行コピーでは不要 + + wgpu::Extent3D extent = { width_, 1, 1 }; + + wgpu::CommandEncoder encoder = device_.CreateCommandEncoder(); + encoder.CopyTextureToBuffer(&src, &dst, &extent); + wgpu::CommandBuffer commands = encoder.Finish(); + device_.GetQueue().Submit(1, &commands); + + bool done = false; + wgpu::Future future = buffer.MapAsync(wgpu::MapMode::Read, 0, buf_desc.size, + wgpu::CallbackMode::AllowProcessEvents, + [&done](wgpu::MapAsyncStatus, wgpu::StringView) { done = true; }); + // GPU 完了待ちは TimedWaitAny で行う (ProcessEvents の空スピンでは + // GPU がフレーム描画中のとき完了前にループ上限へ達する) + instance_.WaitAny(future, 2'000'000'000); // 2s + if (!done) { + std::cerr << "[GPU] surface probe: map timeout" << std::endl; + return; + } + + const auto* px = static_cast(buffer.GetConstMappedRange(0, buf_desc.size)); + uint32_t non_black = 0; + for (uint32_t x = 0; px && x < width_; ++x) { + const uint8_t* p = px + static_cast(x) * 4; + if (p[0] || p[1] || p[2]) { + ++non_black; + } + } + std::cerr << "[GPU] surface probe (present #" << present_count_ + << "): non-black " << non_black << "/" << width_ << " px"; + if (px && width_ > 1) { + const uint8_t* c = px + static_cast(width_ / 2) * 4; + std::cerr << ", center=(" << +c[0] << "," << +c[1] << "," << +c[2] << "," << +c[3] << ")"; + } + std::cerr << std::endl; + buffer.Unmap(); +} + void DawnContext::Present() { - if (surface_) { + // 提示するのは「未構成でない」かつ「今フレーム GetCurrentTexture 済み」のときのみ。 + // 取得なしで Present すると Dawn の検証エラーになる (描画しないフレームは提示しない)。 + if (surface_ && configured_ && frame_texture_acquired_) { + ++present_count_; + if (present_count_ <= 3 || present_count_ == 60 || present_count_ % 300 == 0) { + DebugProbeSurface(); + } surface_.Present(); + } else { + ++present_skipped_; + if (present_skipped_ <= 3 || present_skipped_ % 300 == 0) { + std::cerr << "[GPU] present skipped #" << present_skipped_ + << " (configured=" << configured_ + << " acquired=" << frame_texture_acquired_ << ")" << std::endl; + } } + frame_texture_acquired_ = false; + current_texture_ = nullptr; } void DawnContext::Tick() diff --git a/templates/xbox/src/gpu/DawnContext.h b/templates/xbox/src/gpu/DawnContext.h index 60467ac..873fde7 100644 --- a/templates/xbox/src/gpu/DawnContext.h +++ b/templates/xbox/src/gpu/DawnContext.h @@ -36,6 +36,9 @@ class DawnContext { // 提示する。 void Present(); + // 提示直前の surface 中央行を読み戻して非黒ピクセル数をログする (黒画面調査用)。 + void DebugProbeSurface(); + // Dawn の非同期イベント(コールバック)を進める。毎フレーム呼ぶ。 void Tick(); @@ -59,6 +62,21 @@ class DawnContext { HWND hwnd_ = nullptr; uint32_t width_ = 0; uint32_t height_ = 0; + + // surface が Configure 済みか。未構成のまま Present すると Dawn の + // 検証エラーが毎フレーム出るため、Present/GetCurrentTexture をゲートする。 + bool configured_ = false; + + // 今フレーム GetCurrentTexture が呼ばれたか。呼ばれていないフレームで + // Present すると Dawn の検証エラーになるため、Present をゲートする + // (アプリが描画しないフレームは提示しない)。 + bool frame_texture_acquired_ = false; + + // デバッグ用: surface の CopySrc 可否 / 今フレームのテクスチャ / 提示統計 + bool can_copy_src_ = false; + wgpu::Texture current_texture_ = nullptr; + uint64_t present_count_ = 0; + uint64_t present_skipped_ = 0; }; } // namespace next2d diff --git a/templates/xbox/src/main.cpp b/templates/xbox/src/main.cpp index 2ffa437..51752dc 100644 --- a/templates/xbox/src/main.cpp +++ b/templates/xbox/src/main.cpp @@ -4,8 +4,11 @@ // bootstrap.js とアプリ本体(ESM)の読み込み -> ゲームループ、という流れ。 #include #include +#include #include +#pragma comment(lib, "winmm.lib") + #include "HostContext.h" #include "AssetLoader.h" #include "EventLoop.h" @@ -190,6 +193,18 @@ HWND CreateHostWindow(HINSTANCE instance, int width, int height) wc.lpszClassName = L"Next2DXboxHostWindow"; RegisterClassExW(&wc); + // ウィンドウが画面(作業領域)より大きいと画面外にはみ出し、提示が + // 見切れる/非等倍でスケールされて歪む (CI の低解像度ディスプレイで + // 1920x1080 ウィンドウ → 描画が縦長になっていた)。作業領域に収まるよう + // クランプする。実機コンソールでは作業領域=全画面のため 1920x1080 のまま。 + RECT wa = {}; + if (SystemParametersInfoW(SPI_GETWORKAREA, 0, &wa, 0)) { + const int maxW = wa.right - wa.left; + const int maxH = wa.bottom - wa.top; + if (maxW > 0 && width > maxW) { width = maxW; } + if (maxH > 0 && height > maxH) { height = maxH; } + } + // コンソールでは全画面相当。PC(GDK) 検証ではウィンドウ。 const DWORD style = WS_OVERLAPPEDWINDOW | WS_VISIBLE; return CreateWindowExW( @@ -202,14 +217,27 @@ HWND CreateHostWindow(HINSTANCE instance, int width, int height) int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR cmd_line, int) { + // Sleep() の分解能を 1ms に上げる (フレームペーシング用。既定 15.6ms では + // 60Hz を刻めない) + timeBeginPeriod(1); + // --selftest: アプリの代わりに js/selftest.js を実行し、全バインディングを // 実機/PC(GDK) 上で検証して終了する (テスト完了で自動終了)。 const bool selftest = cmd_line && wcsstr(cmd_line, L"--selftest") != nullptr; - // 1. GDK ランタイム初期化 - if (FAILED(XGameRuntimeInitialize())) { + // 1. GDK ランタイム初期化。 + // デスクトップでは Gaming Services 未導入の環境 (CI ランナー等) でも + // 起動できるよう、失敗を警告に留めて続行する (本ホストは XUser 等を未使用)。 + // コンソールでは必須のため失敗したら終了する。 + bool xgame_initialized = SUCCEEDED(XGameRuntimeInitialize()); + if (!xgame_initialized) { +#if NEXT2D_XBOX_CONSOLE std::cerr << "XGameRuntimeInitialize failed" << std::endl; return 1; +#else + std::cerr << "warning: XGameRuntimeInitialize failed (Gaming Services not installed?)" + << " - continuing on desktop" << std::endl; +#endif } const fs::path exe_dir = ExeDir(); @@ -230,6 +258,22 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR cmd_line, int) return 1; } + // ウィンドウは要求サイズより小さく作られ得る (作業領域クランプ / DPI / + // CI の低解像度ディスプレイ)。surface・canvas・screen は全て viewport_* から + // 派生するため、ここで「実クライアント矩形」を採用して一致させる。これを + // 怠ると surface(1920x1080) を実ウィンドウ(例 1024x768)へ非等倍提示して + // 画面が縦長に歪む。V8 バインディング設置(screen/innerWidth/dpr)と + // Dawn 初期化の前に確定させる必要がある。 + { + RECT rc = {}; + if (GetClientRect(hwnd, &rc) && rc.right > 0 && rc.bottom > 0) { + host.viewport_width = rc.right; + host.viewport_height = rc.bottom; + } + std::cerr << "[Win] client rect: " << host.viewport_width + << "x" << host.viewport_height << std::endl; + } + // 4. V8 プロセス初期化 + Isolate/Context + バインディング V8Runtime::InitializeProcess(nullptr); V8Runtime runtime; @@ -255,7 +299,11 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR cmd_line, int) host.workers = &workers; g_dawn = &dawn; - dawn.Initialize(hwnd, host.viewport_width, host.viewport_height); + // GPU 無し環境 (CI 等) では Dawn 初期化に失敗し得るが、CPU 側の機能と + // selftest は動かせるため続行する (Present 等は内部でゲートされる) + if (!dawn.Initialize(hwnd, host.viewport_width, host.viewport_height)) { + std::cerr << "warning: Dawn initialization failed - continuing without GPU" << std::endl; + } gamepad.Initialize(); audio.Initialize(); @@ -300,9 +348,12 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR cmd_line, int) } // 7. ゲームループ - v8::Isolate::Scope isolate_scope(runtime.isolate()); MSG msg = {}; int exit_code = 0; + // Isolate::Scope はループ内に限定する。Enter されたままの isolate を + // Dispose すると V8 の fatal (Disposing the isolate that is entered) になる。 + { + v8::Isolate::Scope isolate_scope(runtime.isolate()); while (g_running) { // Win32 メッセージ while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) { @@ -347,6 +398,27 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR cmd_line, int) // 提示 dawn.Present(); + // フレームペーシング: ブラウザの rAF 同様 ~60Hz に揃える。 + // Present はスキップ時にブロックしないため、これが無いとループが + // 数万回転/秒で空回りし、rAF 前提のゲーム/Tween のタイミングが崩れる。 + { + const double frame_ms = event_loop.Now() - now; + if (frame_ms < 15.0) { + Sleep(static_cast(15.0 - frame_ms)); + } + } + + // 診断: ループ統計 (5 秒毎) + { + static uint64_t iteration = 0; + ++iteration; + if (iteration % 300 == 0) { + std::cerr << "[Loop] it=" << iteration + << " rAF=" << event_loop.PendingAnimationFrameCount() + << " timers=" << event_loop.PendingTimerCount() << std::endl; + } + } + // selftest 完了検知: selftest.js が globalThis.__selftestExitCode を設定したら終了 if (selftest) { v8::Local code; @@ -359,10 +431,20 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR cmd_line, int) } } - // 8. 後始末 + } + + // 8. 後始末: v8::Global を保持するものは Isolate 破棄前に必ず明示解放する。 + // (スタック変数はスコープ終了 = runtime.Dispose() の後に巻き戻されるため、 + // デストラクタ任せにすると破棄済み Isolate への Global::Reset で fail-fast する) + workers.Shutdown(); // WorkerInstance の Global / EventLoop + event_loop.Shutdown(); // main の setTimeout/rAF コールバック (Global) + ShutdownAudioEvents(); + ShutdownWebGPU(); host.main_canvas.Reset(); runtime.Dispose(); V8Runtime::ShutdownProcess(); - XGameRuntimeUninitialize(); + if (xgame_initialized) { + XGameRuntimeUninitialize(); + } return exit_code; } diff --git a/templates/xbox/src/platform/AudioEngine.cpp b/templates/xbox/src/platform/AudioEngine.cpp index ff21c65..15b9a3d 100644 --- a/templates/xbox/src/platform/AudioEngine.cpp +++ b/templates/xbox/src/platform/AudioEngine.cpp @@ -41,6 +41,10 @@ AudioVoice::~AudioVoice() void AudioVoice::Start(bool loop) { + // 音声デバイスが無い環境 (CI 等) でも「再生開始→即終了」として扱えるよう、 + // voice_ の有無に関わらず開始状態は記録する (IsFinished が ended 発火を導く)。 + started_ = true; + looping_ = loop; if (!voice_) { return; } @@ -51,8 +55,6 @@ void AudioVoice::Start(bool loop) buf.LoopCount = loop ? XAUDIO2_LOOP_INFINITE : 0; voice_->SubmitSourceBuffer(&buf); voice_->Start(0); - started_ = true; - looping_ = loop; } void AudioVoice::Stop() @@ -66,9 +68,13 @@ void AudioVoice::Stop() bool AudioVoice::IsFinished() const { - if (!voice_ || !started_ || looping_) { + if (!started_ || looping_) { return false; } + if (!voice_) { + // 実ボイスが無い (音声デバイス無し) 場合は開始と同時に終了扱い。 + return true; + } XAUDIO2_VOICE_STATE state = {}; voice_->GetState(&state); return state.BuffersQueued == 0; diff --git a/templates/xbox/src/v8/V8Runtime.cpp b/templates/xbox/src/v8/V8Runtime.cpp index 75bd998..df5429b 100644 --- a/templates/xbox/src/v8/V8Runtime.cpp +++ b/templates/xbox/src/v8/V8Runtime.cpp @@ -43,16 +43,26 @@ V8Runtime::~V8Runtime() void V8Runtime::InitializeProcess(const char* exec_path) { // Xbox(GDK) / Nintendo Switch のリテール環境は動的コード生成(JIT)を禁止する。 - // V8 を jitless (Ignition インタプリタのみ・TurboFan/Sparkplug/RWXページ無し) で動かす。 - // prebuilt V8 (build-v8.yml) は v8_jitless=true + turbofan/wasm 無効でビルドされる。 - // wasm/opt 系フラグはそのビルドに存在しないため渡さない (未知フラグ警告を避ける)。 - v8::V8::SetFlagsFromString("--jitless"); + // - JS: --jitless (Ignition インタプリタのみ・RWX ページ無し) + // - WebAssembly: --wasm-jitless (DrumBrake = V8 の wasm インタープリタ。 + // prebuilt V8 r2 は v8_enable_drumbrake=true でビルドされている) + v8::V8::SetFlagsFromString("--jitless --wasm-jitless"); v8::V8::InitializeICUDefaultLocation(exec_path); v8::V8::InitializeExternalStartupData(exec_path); platform_ = v8::platform::NewDefaultPlatform(); v8::V8::InitializePlatform(platform_.get()); v8::V8::Initialize(); + + // WebAssembly (DrumBrake) はメモリ境界検査を guard page + trap handler で行うため、 + // embedder が trap handler を有効化する必要がある (d8/Chrome も起動時に行う)。 + // 未設定のまま wasm を実行するとインタープリタ初期化で fail-fast する。 + // コンソール実機で VEH が使えない場合は、V8 を v8_drumbrake_bounds_checks=true + // (明示境界チェック) でビルドし直すこと。 + if (!v8::V8::EnableWebAssemblyTrapHandler(true)) { + std::cerr << "warning: EnableWebAssemblyTrapHandler failed" + << " - WebAssembly may not work" << std::endl; + } } void V8Runtime::ShutdownProcess() @@ -76,6 +86,35 @@ bool V8Runtime::Initialize(HostContext* host) // 例外時に未処理を潰さないよう、明示的に MicrotasksPolicy を制御する isolate_->SetMicrotasksPolicy(v8::MicrotasksPolicy::kExplicit); + // 未処理の Promise 拒否を stderr へ報告する。framework の boot は async のため、 + // この報告が無いと例外がどこにも出ずゲームが無音で停止する (実際に発生した)。 + isolate_->SetPromiseRejectCallback([](v8::PromiseRejectMessage message) { + if (message.GetEvent() != v8::kPromiseRejectWithNoHandler) { + return; // 後からハンドラが付く分などは報告しない + } + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + v8::HandleScope hs(isolate); + v8::Local value = message.GetValue(); + std::string text = "(no value)"; + std::string stack; + if (!value.IsEmpty()) { + text = v8util::ToStdString(isolate, value); + v8::Local ctx = isolate->GetCurrentContext(); + if (!ctx.IsEmpty() && value->IsObject()) { + v8::Local s; + if (value.As() + ->Get(ctx, v8util::Str(isolate, "stack")).ToLocal(&s) && + s->IsString()) { + stack = v8util::ToStdString(isolate, s); + } + } + } + std::cerr << "[V8] Unhandled promise rejection: " << text << std::endl; + if (!stack.empty() && stack != text) { + std::cerr << stack << std::endl; + } + }); + v8::Isolate::Scope isolate_scope(isolate_); v8::HandleScope handle_scope(isolate_); @@ -149,12 +188,37 @@ v8::MaybeLocal V8Runtime::LoadModule(const std::string& path, return cached->second.Get(isolate_); } - const std::string source = ReadTextFile(abs); + std::string source = ReadTextFile(abs); if (source.empty() && !fs::exists(abs)) { v8util::ThrowTypeError(isolate_, "Module not found: " + abs); return v8::MaybeLocal(); } + // «診断» Tween(Job) の凍結調査: app.js の Job 更新コードへトレースを注入する。 + // rAF が 2 本 (ticker + 1 Job) のまま描画が止まる現象の currentTime/duration の + // 実値を観測する。パターン不一致なら無変更 (ログのみ)。 + if (source.find("(e.currentTime=(t-e.startTime)/1e3,") != std::string::npos) { + const auto patch = [&source](const std::string& from, const std::string& to) { + const auto pos = source.find(from); + if (pos != std::string::npos) { + source.replace(pos, from.size(), to); + } else { + std::cerr << "[V8] job trace patch not applied: " + << from.substr(0, 40) << std::endl; + } + }; + patch("(e.currentTime=(t-e.startTime)/1e3,", + "(e.currentTime=(t-e.startTime)/1e3," + "((globalThis.__jt=(globalThis.__jt||0)+1)<=60||globalThis.__jt%600===0)&&" + "console.info(\"[jobtrace] #\"+globalThis.__jt," + "\"ct=\"+e.currentTime.toFixed(3),\"dur=\"+e.duration," + "\"t=\"+t.toFixed(1),\"st=\"+e.startTime.toFixed(1)),"); + patch("e.currentTime>=e.duration?(", + "e.currentTime>=e.duration?(" + "console.info(\"[jobtrace] COMPLETE dur=\"+e.duration),"); + std::cerr << "[V8] job trace patch applied to " << abs << std::endl; + } + v8::Local src = v8util::Str(isolate_, source); v8::ScriptOrigin origin( diff --git a/templates/xbox/src/v8/V8Util.h b/templates/xbox/src/v8/V8Util.h index 49a7145..4544283 100644 --- a/templates/xbox/src/v8/V8Util.h +++ b/templates/xbox/src/v8/V8Util.h @@ -2,6 +2,7 @@ #pragma once #include +#include #include #include @@ -53,4 +54,32 @@ inline void ThrowTypeError(v8::Isolate* isolate, std::string_view message) isolate->ThrowException(v8::Exception::TypeError(Str(isolate, message))); } +// TryCatch が例外を保持していれば stderr へ報告する。 +// rAF / タイマー / onmessage / イベントリスナーのコールバックは TryCatch で +// 保護されるが、報告しないと例外が無音で消えて原因究明が不可能になる +// (レンダラ worker が毎フレーム throw していても黒画面にしか見えない)。 +inline void ReportCaught(v8::Isolate* isolate, v8::TryCatch* tc, const char* where) +{ + if (!tc->HasCaught()) { + return; + } + v8::HandleScope hs(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + const std::string text = ToStdString(isolate, tc->Exception()); + std::cerr << "[JS] exception in " << where << ": " << text << std::endl; + v8::Local stack; + if (!ctx.IsEmpty() && tc->StackTrace(ctx).ToLocal(&stack)) { + const std::string s = ToStdString(isolate, stack); + if (!s.empty() && s != text) { + std::cerr << s << std::endl; + } + } + v8::Local message = tc->Message(); + if (!message.IsEmpty() && !ctx.IsEmpty()) { + std::cerr << " at " + << ToStdString(isolate, message->GetScriptOrigin().ResourceName()) + << ":" << message->GetLineNumber(ctx).FromMaybe(0) << std::endl; + } +} + } // namespace next2d::v8util diff --git a/templates/xbox/src/v8/WeakHandle.h b/templates/xbox/src/v8/WeakHandle.h new file mode 100644 index 0000000..082cf75 --- /dev/null +++ b/templates/xbox/src/v8/WeakHandle.h @@ -0,0 +1,45 @@ +// SetWeak (GC 連動の解放) の正しい運用ヘルパー。 +// +// V8 の規約では、第一パスの weak callback 内で対応する v8::Global を必ず +// Reset しなければならない。違反すると GC 時に fatal +// 「Check failed: Handle not reset in first callback.」で落ちる。 +// payload と Global を束ねて所有し、GC で Reset + delete の両方を行う。 +#pragma once + +#include + +namespace next2d::v8util { + +template +class WeakHandle { +public: + static void Attach(v8::Isolate* isolate, v8::Local obj, T* payload) + { + auto* w = new WeakHandle(payload); + w->handle_.Reset(isolate, obj); + w->handle_.SetWeak(w, &WeakHandle::OnGC, v8::WeakCallbackType::kParameter); + } + +private: + explicit WeakHandle(T* payload) : payload_(payload) {} + + static void OnGC(const v8::WeakCallbackInfo>& info) + { + WeakHandle* w = info.GetParameter(); + w->handle_.Reset(); // 第一パスでの Reset (V8 の必須要件) + delete w->payload_; + delete w; + } + + v8::Global handle_; + T* payload_; +}; + +// オブジェクトが GC されたときに `delete payload` する弱参照を張る。 +template +inline void AttachWeak(v8::Isolate* isolate, v8::Local obj, T* payload) +{ + WeakHandle::Attach(isolate, obj, payload); +} + +} // namespace next2d::v8util diff --git a/templates/xbox/src/worker/WorkerRuntime.cpp b/templates/xbox/src/worker/WorkerRuntime.cpp index 299b6f1..e3fbb9c 100644 --- a/templates/xbox/src/worker/WorkerRuntime.cpp +++ b/templates/xbox/src/worker/WorkerRuntime.cpp @@ -4,6 +4,8 @@ #include "EventLoop.h" #include "AssetLoader.h" #include "bindings/Bindings.h" +#include "bindings/ImageSource.h" +#include "platform/ImageTypes.h" #include "v8/V8Util.h" #include @@ -22,14 +24,108 @@ using v8util::ToStdString; // =========================================================================== namespace { -bool IsOffscreen(v8::Isolate* isolate, v8::Local obj) +// ログ用: data:/blob: URL は長大になるため先頭だけに切り詰める +std::string ShortUrl(const std::string& url) +{ + if (url.size() <= 64) { + return url; + } + return url.substr(0, 64) + "... (" + std::to_string(url.size()) + " chars)"; +} + +// base64 デコード (data: URL 用)。不正文字があれば false。 +bool DecodeBase64(const std::string& in, std::string* out) +{ + auto val6 = [](unsigned char c) -> int { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; + }; + out->clear(); + out->reserve(in.size() * 3 / 4); + int val = 0; + int bits = -8; + for (unsigned char c : in) { + if (c == '=') { + break; + } + if (c == '\r' || c == '\n' || c == ' ') { + continue; + } + const int d = val6(c); + if (d < 0) { + return false; + } + val = (val << 6) | d; + bits += 6; + if (bits >= 0) { + out->push_back(static_cast((val >> bits) & 0xFF)); + bits -= 8; + } + } + return true; +} + +// %xx パーセントデコード (非 base64 の data: URL 用) +std::string PercentDecode(const std::string& s) +{ + std::string out; + out.reserve(s.size()); + for (size_t i = 0; i < s.size(); ++i) { + if (s[i] == '%' && i + 2 < s.size()) { + const auto hex = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + const int hi = hex(s[i + 1]); + const int lo = hex(s[i + 2]); + if (hi >= 0 && lo >= 0) { + out.push_back(static_cast((hi << 4) | lo)); + i += 2; + continue; + } + } + out.push_back(s[i]); + } + return out; +} + +bool HasBoolMarker(v8::Isolate* isolate, v8::Local obj, const char* name) { v8::Local ctx = isolate->GetCurrentContext(); v8::Local v; - return obj->Get(ctx, Str(isolate, "__isOffscreenCanvas")).ToLocal(&v) && + return obj->Get(ctx, Str(isolate, name)).ToLocal(&v) && v->IsBoolean() && v.As()->Value(); } +bool IsOffscreen(v8::Isolate* isolate, v8::Local obj) +{ + return HasBoolMarker(isolate, obj, "__isOffscreenCanvas"); +} + +bool IsImageBitmapObject(v8::Isolate* isolate, v8::Local obj) +{ + return HasBoolMarker(isolate, obj, "__isImageBitmap"); +} + +// WriteHostObject / ReadHostObject のタグ +constexpr uint32_t kHostObjectOffscreenCanvas = 1; +constexpr uint32_t kHostObjectImageBitmap = 2; + +// メッセージトレース: 定常時のログ洪水を防ぐため、最初の 120 件のあとは +// 200 件ごとに 1 回だけ出力する (render は 60fps で双方向に流れる)。 +bool TraceMessage() +{ + static uint64_t count = 0; + ++count; + return count <= 120 || count % 200 == 0; +} + class SerializerDelegate : public v8::ValueSerializer::Delegate { public: explicit SerializerDelegate(v8::Isolate* isolate) : isolate_(isolate) {} @@ -39,18 +135,46 @@ class SerializerDelegate : public v8::ValueSerializer::Delegate { isolate_->ThrowException(v8::Exception::Error(message)); } - // OffscreenCanvas をホストオブジェクトとして扱い WriteHostObject に回す。 + // すべてのオブジェクトで IsHostObject を照会させる。 + // OffscreenCanvas は ObjectTemplate 由来ではない plain object のため、 + // これが無いと V8 は IsHostObject を呼ばず、getContext 等の関数プロパティで + // 「could not be cloned」になる。 + bool HasCustomHostObject(v8::Isolate*) override { return true; } + + // OffscreenCanvas / ImageBitmap をホストオブジェクトとして WriteHostObject に回す。 v8::Maybe IsHostObject(v8::Isolate*, v8::Local object) override { - return v8::Just(IsOffscreen(isolate_, object)); + return v8::Just(IsOffscreen(isolate_, object) || + IsImageBitmapObject(isolate_, object)); } v8::Maybe WriteHostObject(v8::Isolate*, v8::Local object) override { v8::Local ctx = isolate_->GetCurrentContext(); - v8::Local w, h; - object->Get(ctx, Str(isolate_, "width")).ToLocal(&w); - object->Get(ctx, Str(isolate_, "height")).ToLocal(&h); + + // ImageBitmap: ピクセルごと複製する (render メッセージの imageBitmaps 用) + if (IsImageBitmapObject(isolate_, object)) { + const uint8_t* px = nullptr; + uint32_t w = 0, h = 0; + if (!GetImageSourcePixels(isolate_, object, &px, &w, &h) || !px) { + isolate_->ThrowException(v8::Exception::Error( + Str(isolate_, "ImageBitmap clone failed"))); + return v8::Nothing(); + } + serializer->WriteUint32(kHostObjectImageBitmap); + serializer->WriteUint32(w); + serializer->WriteUint32(h); + serializer->WriteRawBytes(px, static_cast(w) * h * 4); + return v8::Just(true); + } + + // OffscreenCanvas + // Get 失敗時に空 Local を触らないようフォールバックを入れる + v8::Local w = v8::Undefined(isolate_); + v8::Local h = v8::Undefined(isolate_); + (void) object->Get(ctx, Str(isolate_, "width")).ToLocal(&w); + (void) object->Get(ctx, Str(isolate_, "height")).ToLocal(&h); + serializer->WriteUint32(kHostObjectOffscreenCanvas); serializer->WriteUint32(w->IsNumber() ? static_cast(w.As()->Value()) : 0); serializer->WriteUint32(h->IsNumber() ? static_cast(h.As()->Value()) : 0); return v8::Just(true); @@ -69,6 +193,26 @@ class DeserializerDelegate : public v8::ValueDeserializer::Delegate { v8::MaybeLocal ReadHostObject(v8::Isolate*) override { + uint32_t tag = 0; + if (!deserializer->ReadUint32(&tag)) { + return v8::MaybeLocal(); + } + + if (tag == kHostObjectImageBitmap) { + uint32_t w = 0, h = 0; + const void* px = nullptr; + if (!deserializer->ReadUint32(&w) || !deserializer->ReadUint32(&h) || + !deserializer->ReadRawBytes(static_cast(w) * h * 4, &px)) { + return v8::MaybeLocal(); + } + auto* img = new DecodedImage(); + img->width = w; + img->height = h; + img->rgba.assign(static_cast(px), + static_cast(px) + static_cast(w) * h * 4); + return WrapImageBitmap(isolate_, img); + } + uint32_t w = 0, h = 0; deserializer->ReadUint32(&w); deserializer->ReadUint32(&h); @@ -84,36 +228,69 @@ class DeserializerDelegate : public v8::ValueDeserializer::Delegate { } // namespace -std::vector SerializeMessage(v8::Isolate* isolate, v8::Local source_ctx, - v8::Local message, - v8::Local /*transfer_list*/) +WorkerMessage SerializeMessage(v8::Isolate* isolate, v8::Local source_ctx, + v8::Local message, + v8::Local transfer_list) { v8::Context::Scope scope(source_ctx); SerializerDelegate delegate(isolate); v8::ValueSerializer serializer(isolate, &delegate); delegate.serializer = &serializer; + // transfer_list の ArrayBuffer は中身を埋め込まず所有権を移譲する + // (player の render キューは数十 MB — 毎フレームのコピーは不可)。 + std::vector> transfer_abs; + if (!transfer_list.IsEmpty() && transfer_list->IsArray()) { + auto arr = transfer_list.As(); + for (uint32_t i = 0; i < arr->Length(); ++i) { + v8::Local item; + if (arr->Get(source_ctx, i).ToLocal(&item) && item->IsArrayBuffer()) { + auto ab = item.As(); + if (ab->IsDetachable()) { + serializer.TransferArrayBuffer( + static_cast(transfer_abs.size()), ab); + transfer_abs.push_back(ab); + } + } + } + } + serializer.WriteHeader(); - // 通常の ArrayBuffer は値としてシリアライズされる (transfer は将来最適化)。 if (serializer.WriteValue(source_ctx, message).IsNothing()) { return {}; } + + WorkerMessage result; std::pair buffer = serializer.Release(); - std::vector bytes(buffer.first, buffer.first + buffer.second); + result.data.assign(buffer.first, buffer.first + buffer.second); free(buffer.first); - return bytes; + + // 成功後に detach (ブラウザの transferable と同じく送信側では使えなくなる) + for (auto& ab : transfer_abs) { + result.transfers.push_back(ab->GetBackingStore()); + (void) ab->Detach(v8::Local()); + } + return result; } v8::MaybeLocal DeserializeMessage(v8::Isolate* isolate, v8::Local target_ctx, - const std::vector& bytes) + const WorkerMessage& message) { v8::Context::Scope scope(target_ctx); HostContext* host = HostContext::From(isolate); DeserializerDelegate delegate(isolate, host); - v8::ValueDeserializer deserializer(isolate, bytes.data(), bytes.size(), &delegate); + v8::ValueDeserializer deserializer(isolate, message.data.data(), + message.data.size(), &delegate); delegate.deserializer = &deserializer; + // transfer された ArrayBuffer を同じ backing store で再装着する (ゼロコピー) + for (size_t i = 0; i < message.transfers.size(); ++i) { + v8::Local ab = + v8::ArrayBuffer::New(isolate, message.transfers[i]); + deserializer.TransferArrayBuffer(static_cast(i), ab); + } + if (deserializer.ReadHeader(target_ctx).IsNothing()) { return v8::MaybeLocal(); } @@ -140,6 +317,7 @@ void DispatchMessage(v8::Isolate* isolate, v8::Local ctx, if (target->Get(ctx, Str(isolate, "onmessage")).ToLocal(&on) && on->IsFunction()) { v8::TryCatch tc(isolate); (void) on.As()->Call(ctx, target, 1, args); + v8util::ReportCaught(isolate, &tc, "onmessage"); } // __messageListeners 配列 v8::Local listeners; @@ -151,6 +329,7 @@ void DispatchMessage(v8::Isolate* isolate, v8::Local ctx, if (arr->Get(ctx, i).ToLocal(&fn) && fn->IsFunction()) { v8::TryCatch tc(isolate); (void) fn.As()->Call(ctx, target, 1, args); + v8util::ReportCaught(isolate, &tc, "message listener"); } } } @@ -203,9 +382,13 @@ void WorkerSelfPostMessage(const v8::FunctionCallbackInfo& args) auto* instance = static_cast(args.Data().As()->Value()); v8::Local msg = args.Length() > 0 ? args[0] : v8::Undefined(isolate).As(); v8::Local transfer = args.Length() > 1 ? args[1] : v8::Undefined(isolate).As(); - auto bytes = SerializeMessage(isolate, isolate->GetCurrentContext(), msg, transfer); - if (!bytes.empty()) { - instance->PostToMain(std::move(bytes)); + auto message = SerializeMessage(isolate, isolate->GetCurrentContext(), msg, transfer); + if (TraceMessage()) { + std::cerr << "[Worker] worker->main postMessage: " << message.data.size() + << " bytes (+" << message.transfers.size() << " transfers)" << std::endl; + } + if (!message.data.empty()) { + instance->PostToMain(std::move(message)); } } @@ -236,9 +419,13 @@ void MainWorkerPostMessage(const v8::FunctionCallbackInfo& args) auto* instance = static_cast(args.Data().As()->Value()); v8::Local msg = args.Length() > 0 ? args[0] : v8::Undefined(isolate).As(); v8::Local transfer = args.Length() > 1 ? args[1] : v8::Undefined(isolate).As(); - auto bytes = SerializeMessage(isolate, isolate->GetCurrentContext(), msg, transfer); - if (!bytes.empty()) { - instance->PostToWorker(std::move(bytes)); + auto message = SerializeMessage(isolate, isolate->GetCurrentContext(), msg, transfer); + if (TraceMessage()) { + std::cerr << "[Worker] main->worker postMessage: " << message.data.size() + << " bytes (+" << message.transfers.size() << " transfers)" << std::endl; + } + if (!message.data.empty()) { + instance->PostToWorker(std::move(message)); } } @@ -246,10 +433,12 @@ void MainWorkerPostMessage(const v8::FunctionCallbackInfo& args) bool WorkerInstance::Start() { + std::cerr << "[Worker] start: " << ShortUrl(url_) << std::endl; v8::HandleScope handle_scope(isolate_); v8::Local context = v8::Context::New(isolate_); context_.Reset(isolate_, context); + std::cerr << "[Worker] context created" << std::endl; // worker 専用 EventLoop を Context の embedder slot に登録 loop_ = std::make_unique(isolate_); @@ -260,6 +449,7 @@ bool WorkerInstance::Start() // ブラウザ相当環境一式 (console/timers/fetch/image/audio/webgpu/canvas/dom) InstallGlobalBindings(isolate_, global, HostContext::From(isolate_)); + std::cerr << "[Worker] bindings installed" << std::endl; // worker スコープには document/window を出さない (ライブラリの環境判定対策)。 global->Delete(context, Str(isolate_, "document")).Check(); @@ -278,46 +468,112 @@ bool WorkerInstance::Start() runtime_->InstallOnGlobal(global); // ワーカースクリプトを読み込んで評価 (クラシック。ESM worker は «EXTEND»)。 - // Vite の `?worker&inline` は blob: URL(= URL.createObjectURL(new Blob([code]))) を渡すため、 - // まず object URL レジストリ、次に assets を参照する。 + // Vite の `?worker&inline` は data:application/javascript;base64,... を渡す + // (バージョンによっては blob: URL)。data: / blob: / assets の順で解決する。 HostContext* host = HostContext::From(isolate_); std::string source; - if (url_.rfind("blob:", 0) == 0) { + if (url_.rfind("data:", 0) == 0) { + const auto comma = url_.find(','); + if (comma == std::string::npos) { + std::cerr << "[Worker] malformed data url: " << ShortUrl(url_) << std::endl; + return false; + } + const std::string meta = url_.substr(5, comma - 5); // 例: application/javascript;base64 + const std::string payload = url_.substr(comma + 1); + if (meta.find(";base64") != std::string::npos) { + if (!DecodeBase64(payload, &source)) { + std::cerr << "[Worker] base64 decode failed: " << ShortUrl(url_) << std::endl; + return false; + } + } else { + source = PercentDecode(payload); + } + } else if (url_.rfind("blob:", 0) == 0) { if (!ResolveObjectURL(url_, &source)) { - std::cerr << "[Worker] blob url not found: " << url_ << std::endl; + std::cerr << "[Worker] blob url not found: " << ShortUrl(url_) << std::endl; return false; } } else { auto src = host->assets->ReadText(url_); if (!src) { - std::cerr << "[Worker] script not found: " << url_ << std::endl; + std::cerr << "[Worker] script not found: " << ShortUrl(url_) << std::endl; return false; } source = std::move(*src); } + std::cerr << "[Worker] source resolved (" << source.size() << " bytes)" << std::endl; + + // «診断» レンダラ worker のフレームサイクル (frameStarted / mainTexture) を追跡する。 + // 黒画面問題: getCurrentTexture がフレーム 3 回目以降呼ばれなくなる現象の原因特定用。 + // minify 後も this.プロパティ名は保持されるためパターンは安定。見つからなければ無変更。 + if (source.find("getCurrentTexture") != std::string::npos) { + const auto patch = [&source](const std::string& from, const std::string& to) { + const auto pos = source.find(from); + if (pos != std::string::npos) { + source.replace(pos, from.size(), to); + } else { + std::cerr << "[Worker] trace patch not applied: " << from.substr(0, 40) << std::endl; + } + }; + source.insert(0, + "var __n2dT={};function __n2dTrace(k,s){var n=(__n2dT[k]=(__n2dT[k]||0)+1);" + "if(n<=30||n%300===0)console.info(\"[trace]\",k,\"#\"+n,s||\"\")}\n"); + patch("clearTransferBounds(){this.beginFrame()}", + "clearTransferBounds(){__n2dTrace(\"CTB\",\"fs=\"+this.frameStarted+\" mt=\"+!!this.mainTexture);this.beginFrame()}"); + patch("ensureMainTexture(){this.mainTexture||", + "ensureMainTexture(){__n2dTrace(\"EMT\",\"mt=\"+!!this.mainTexture+\" rc=\"+this.$needsReconfigure);this.mainTexture||"); + patch("endFrame(){if(this.frameStarted){", + "endFrame(){__n2dTrace(\"EF\",\"fs=\"+this.frameStarted+\" enc=\"+!!this.commandEncoder);if(this.frameStarted){"); + patch("transferMainCanvas(){if(!this.$mainAttachmentObject||!this.$mainAttachmentObject.texture)", + "transferMainCanvas(){__n2dTrace(\"TMC\",\"att=\"+!!(this.$mainAttachmentObject&&this.$mainAttachmentObject.texture));if(!this.$mainAttachmentObject||!this.$mainAttachmentObject.texture)"); + // «診断/自己修復» CommandController.execute() は try/catch を持たないため、 + // 描画中の例外が 1 度起きると this.state が "active" のまま固まり、 + // 以後 onmessage は queue に積むだけで execute を再開せず worker が永久停止する + // (画面が ~15 フレームで凍結する現象の直接原因)。元の execute をリネームし、 + // 例外を捕捉して message+stack を出力しつつ state を戻すラッパを被せる。 + // これで「実際に何が throw しているか」を CI ログで確定でき、かつ凍結も防げる。 + patch("async execute(){for(this.state=\"active\"", + "async execute(){try{await this.__n2dExec()}catch(__ee){" + "var __n=(globalThis.__rerr=(globalThis.__rerr||0)+1);" + "if(__n<=8)console.error(\"[rendererr] #\"+__n+\" \"+(__ee&&__ee.message||__ee)+" + "\"\\n\"+(__ee&&__ee.stack||\"\"));this.state=\"deactivate\"}}" + "async __n2dExec(){for(this.state=\"active\""); + } v8::TryCatch tc(isolate_); v8::ScriptOrigin origin(Str(isolate_, url_)); v8::Local code = Str(isolate_, source); v8::Local script; if (!v8::Script::Compile(context, code, &origin).ToLocal(&script) || script->Run(context).IsEmpty()) { - std::cerr << "[Worker] script eval failed: " << url_ << std::endl; + std::cerr << "[Worker] script eval failed: " << ShortUrl(url_) << std::endl; + if (tc.HasCaught()) { + v8::String::Utf8Value exception(isolate_, tc.Exception()); + std::cerr << "[Worker] exception: " << (*exception ? *exception : "?") << std::endl; + v8::Local message = tc.Message(); + if (!message.IsEmpty()) { + std::cerr << "[Worker] at line " + << message->GetLineNumber(context).FromMaybe(0) << std::endl; + } + } return false; } - isolate_->PerformMicrotaskCheckpoint(); + // NOTE: ここで PerformMicrotaskCheckpoint は呼ばない。Start は JS コールバック + // (new Worker) の最中に呼ばれるため、JS 実行中のチェックポイントは V8 の規約違反。 + // マイクロタスクはメインループの PumpMicrotasks で処理される。 + std::cerr << "[Worker] script evaluated" << std::endl; return true; } void WorkerInstance::Terminate() { terminated_ = true; } -void WorkerInstance::PostToWorker(std::vector bytes) +void WorkerInstance::PostToWorker(WorkerMessage message) { - to_worker_.push_back(WorkerMessage{std::move(bytes)}); + to_worker_.push_back(std::move(message)); } -void WorkerInstance::PostToMain(std::vector bytes) +void WorkerInstance::PostToMain(WorkerMessage message) { - to_main_.push_back(WorkerMessage{std::move(bytes)}); + to_main_.push_back(std::move(message)); } void WorkerInstance::DeliverToWorker(double now_ms) @@ -329,9 +585,19 @@ void WorkerInstance::DeliverToWorker(double now_ms) while (!to_worker_.empty()) { WorkerMessage m = std::move(to_worker_.front()); to_worker_.pop_front(); + const bool trace = TraceMessage(); + if (trace) { + std::cerr << "[Worker] deliver main->worker (" << m.data.size() << " bytes)" << std::endl; + } v8::Local data; - if (DeserializeMessage(isolate_, ctx, m.data).ToLocal(&data)) { + if (DeserializeMessage(isolate_, ctx, m).ToLocal(&data)) { DispatchMessage(isolate_, ctx, ctx->Global(), data); + if (trace) { + std::cerr << "[Worker] worker onmessage done" << std::endl; + } + } else { + std::cerr << "[Worker] main->worker deserialize failed (" + << m.data.size() << " bytes)" << std::endl; } } @@ -353,9 +619,19 @@ void WorkerInstance::DeliverToMain() while (!to_main_.empty()) { WorkerMessage m = std::move(to_main_.front()); to_main_.pop_front(); + const bool trace = TraceMessage(); + if (trace) { + std::cerr << "[Worker] deliver worker->main (" << m.data.size() << " bytes)" << std::endl; + } v8::Local data; - if (DeserializeMessage(isolate_, main_ctx, m.data).ToLocal(&data)) { + if (DeserializeMessage(isolate_, main_ctx, m).ToLocal(&data)) { DispatchMessage(isolate_, main_ctx, worker_obj, data); + if (trace) { + std::cerr << "[Worker] main onmessage done" << std::endl; + } + } else { + std::cerr << "[Worker] worker->main deserialize failed (" + << m.data.size() << " bytes)" << std::endl; } } } @@ -389,11 +665,17 @@ v8::Local WorkerRuntime::CreateWorker(const std::string& url) worker->Set(ctx, Str(isolate_, "postMessage"), v8::Function::New(ctx, MainWorkerPostMessage, ext).ToLocalChecked()).Check(); SetMethod(isolate_, worker, "addEventListener", AddEventListener); - SetMethod(isolate_, worker, "terminate", - [](const v8::FunctionCallbackInfo& a) { - auto* w = static_cast(a.Data().As()->Value()); - w->Terminate(); - }); + // terminate は WorkerInstance* を data (External) で受け取る必要があるため + // SetMethod (data なし) ではなく Function::New で設置する。 + worker->Set(ctx, Str(isolate_, "terminate"), + v8::Function::New(ctx, + [](const v8::FunctionCallbackInfo& a) { + if (!a.Data()->IsExternal()) { + return; + } + auto* w = static_cast(a.Data().As()->Value()); + w->Terminate(); + }, ext).ToLocalChecked()).Check(); ptr->main_worker_object.Reset(isolate_, worker); @@ -437,8 +719,10 @@ void WorkerRuntime::InstallOnGlobal(v8::Local global) void WorkerRuntime::Pump(double now_ms) { - for (auto& w : workers_) { - w->Deliver(now_ms); + // 配送中の JS が new Worker() を呼ぶと workers_ に push_back され + // vector が再割当されるため、イテレータではなく添字で回す。 + for (size_t i = 0; i < workers_.size(); ++i) { + workers_[i]->Deliver(now_ms); } } diff --git a/templates/xbox/src/worker/WorkerRuntime.h b/templates/xbox/src/worker/WorkerRuntime.h index 3a8e3df..1b912ee 100644 --- a/templates/xbox/src/worker/WorkerRuntime.h +++ b/templates/xbox/src/worker/WorkerRuntime.h @@ -25,9 +25,12 @@ class HostContext; class EventLoop; class WorkerRuntime; -// 1 メッセージ = ValueSerializer 出力バイト列。 +// 1 メッセージ = ValueSerializer 出力バイト列 + transfer された ArrayBuffer の実体。 +// transfer_list の ArrayBuffer は中身を bytes に埋め込まず所有権ごと移す +// (render キューは数十 MB になるため、コピーするとフレームレートが崩壊する)。 struct WorkerMessage { std::vector data; + std::vector> transfers; }; class WorkerInstance { @@ -39,8 +42,8 @@ class WorkerInstance { void Terminate(); // main→worker / worker→main の投函 - void PostToWorker(std::vector bytes); - void PostToMain(std::vector bytes); + void PostToWorker(WorkerMessage message); + void PostToMain(WorkerMessage message); // 配送 + タイマー/rAF 前進 (WorkerRuntime::Pump から呼ぶ) void Deliver(double now_ms); @@ -88,21 +91,28 @@ class WorkerRuntime { bool HasWorkers() const { return !workers_.empty(); } + // 全 WorkerInstance を破棄する。WorkerInstance は v8::Global (context_ / + // main_worker_object 等) を保持するため、Isolate::Dispose より前に必ず呼ぶこと + // (呼ばずにスタック巻き戻しで破棄されると Isolate 破棄後の Global::Reset で fail-fast)。 + void Shutdown() { workers_.clear(); } + private: v8::Isolate* isolate_; HostContext* host_; std::vector> workers_; }; -// ValueSerializer/Deserializer による structured clone (OffscreenCanvas transfer 対応)。 -// 失敗時は空を返す。source_ctx で直列化し、bytes を得る。 -std::vector SerializeMessage(v8::Isolate* isolate, v8::Local source_ctx, - v8::Local message, - v8::Local transfer_list); +// ValueSerializer/Deserializer による structured clone +// (OffscreenCanvas / ImageBitmap のホストオブジェクト対応 + ArrayBuffer transfer)。 +// 失敗時は data が空の WorkerMessage を返す。transfer_list 内の ArrayBuffer は +// detach され、backing store が message.transfers へ移る。 +WorkerMessage SerializeMessage(v8::Isolate* isolate, v8::Local source_ctx, + v8::Local message, + v8::Local transfer_list); -// bytes を target_ctx で復元する。 +// message を target_ctx で復元する (transfer された ArrayBuffer は同一実体を再装着)。 v8::MaybeLocal DeserializeMessage(v8::Isolate* isolate, v8::Local target_ctx, - const std::vector& bytes); + const WorkerMessage& message); } // namespace next2d diff --git a/templates/xbox/tests/check_local.sh b/templates/xbox/tests/check_local.sh index 86ddcb2..645ed14 100755 --- a/templates/xbox/tests/check_local.sh +++ b/templates/xbox/tests/check_local.sh @@ -10,7 +10,7 @@ set -euo pipefail cd "$(dirname "$0")/.." -V8_TAG="13.6.233.17" +V8_TAG="13.7.152.19" V8INC="${1:-}" echo "== 1/3 RasterCore unit tests ==" @@ -41,6 +41,7 @@ FILES=( src/bindings/Console.cpp src/bindings/Timers.cpp src/bindings/EventTarget.cpp + src/bindings/Polyfills.cpp src/bindings/Network.cpp src/bindings/Fetch.cpp src/bindings/Canvas.cpp