@next2d/builder 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +5 -1
  2. package/dist/index.js +140 -36
  3. package/package.json +3 -2
  4. package/templates/xbox/CMakeLists.txt +74 -2
  5. package/templates/xbox/README.md +73 -12
  6. package/templates/xbox/cmake/FetchDawn.cmake +47 -6
  7. package/templates/xbox/cmake/FindV8.cmake +22 -1
  8. package/templates/xbox/js/bootstrap.js +31 -28
  9. package/templates/xbox/js/selftest.js +740 -90
  10. package/templates/xbox/src/EventLoop.cpp +28 -1
  11. package/templates/xbox/src/EventLoop.h +12 -0
  12. package/templates/xbox/src/bindings/Audio.cpp +99 -15
  13. package/templates/xbox/src/bindings/Bindings.h +10 -0
  14. package/templates/xbox/src/bindings/Canvas.cpp +45 -0
  15. package/templates/xbox/src/bindings/Canvas2D.cpp +2 -5
  16. package/templates/xbox/src/bindings/DomShims.cpp +127 -0
  17. package/templates/xbox/src/bindings/EventTarget.cpp +2 -0
  18. package/templates/xbox/src/bindings/Fetch.cpp +2 -8
  19. package/templates/xbox/src/bindings/Image.cpp +212 -10
  20. package/templates/xbox/src/bindings/Polyfills.cpp +791 -0
  21. package/templates/xbox/src/bindings/Video.cpp +96 -7
  22. package/templates/xbox/src/bindings/webgpu/WebGPU.cpp +815 -15
  23. package/templates/xbox/src/bindings/webgpu/WebGPUCommon.h +3 -8
  24. package/templates/xbox/src/gpu/DawnContext.cpp +90 -3
  25. package/templates/xbox/src/gpu/DawnContext.h +18 -0
  26. package/templates/xbox/src/main.cpp +88 -6
  27. package/templates/xbox/src/platform/AudioEngine.cpp +9 -3
  28. package/templates/xbox/src/v8/V8Runtime.cpp +69 -4
  29. package/templates/xbox/src/v8/V8Util.h +29 -0
  30. package/templates/xbox/src/v8/WeakHandle.h +45 -0
  31. package/templates/xbox/src/worker/WorkerRuntime.cpp +325 -41
  32. package/templates/xbox/src/worker/WorkerRuntime.h +20 -10
  33. package/templates/xbox/tests/check_local.sh +3 -2
  34. package/templates/xbox/tests/raster_test.bin +0 -0
@@ -15,7 +15,7 @@
15
15
  */
16
16
  (function () {
17
17
 
18
- const results = { pass: 0, fail: 0, skip: 0 };
18
+ const results = { "pass": 0, "fail": 0, "skip": 0 };
19
19
  const failures = [];
20
20
 
21
21
  function tap(status, name, detail) {
@@ -27,16 +27,16 @@
27
27
  console.log("[selftest] " + line);
28
28
  }
29
29
 
30
- function pass(name) { results.pass++; tap("ok", name); }
30
+ function pass(name) { results.pass++; tap("ok", name) }
31
31
  function fail(name, e) {
32
32
  results.fail++;
33
33
  const msg = e && e.message ? e.message : String(e);
34
34
  failures.push(name + ": " + msg);
35
35
  tap("fail", name, msg);
36
36
  }
37
- function skip(name, why) { results.skip++; tap("skip", name, why); }
37
+ function skip(name, why) { results.skip++; tap("skip", name, why) }
38
38
 
39
- function assert(cond, msg) { if (!cond) throw new Error(msg || "assertion failed"); }
39
+ function assert(cond, msg) { if (!cond) { throw new Error(msg || "assertion failed") } }
40
40
 
41
41
  function withTimeout(promise, ms, what) {
42
42
  return Promise.race([
@@ -109,18 +109,18 @@
109
109
  const order = [];
110
110
  await new Promise((resolve) => {
111
111
  setTimeout(() => order.push("t10"), 10);
112
- setTimeout(() => { order.push("t30"); resolve(); }, 30);
112
+ setTimeout(() => { order.push("t30"); resolve() }, 30);
113
113
  Promise.resolve().then(() => order.push("micro"));
114
114
  order.push("sync");
115
115
  });
116
116
  assert(order[0] === "sync" && order[1] === "micro",
117
- "microtask order: " + order.join(","));
117
+ "microtask order: " + order.join(","));
118
118
  assert(order.indexOf("t10") < order.indexOf("t30"), "timer order");
119
119
 
120
120
  let ticks = 0;
121
121
  await new Promise((resolve) => {
122
122
  const id = setInterval(() => {
123
- if (++ticks >= 2) { clearInterval(id); resolve(); }
123
+ if (++ticks >= 2) { clearInterval(id); resolve() }
124
124
  }, 5);
125
125
  });
126
126
  assert(ticks === 2, "interval stopped after clearInterval");
@@ -135,22 +135,101 @@
135
135
 
136
136
  await test("EventTarget: addEventListener / dispatchEvent", () => {
137
137
  let got = null;
138
- const handler = (e) => { got = e; };
138
+ const handler = (e) => { got = e };
139
139
  window.addEventListener("selftest-event", handler);
140
- window.dispatchEvent({ type: "selftest-event", value: 42 });
140
+ window.dispatchEvent({ "type": "selftest-event", "value": 42 });
141
141
  assert(got && got.value === 42, "listener called");
142
142
  window.removeEventListener("selftest-event", handler);
143
143
  got = null;
144
- window.dispatchEvent({ type: "selftest-event" });
144
+ window.dispatchEvent({ "type": "selftest-event" });
145
145
  assert(got === null, "listener removed");
146
146
  });
147
147
 
148
+ await test("DOM: player boot パス (body/parentElement/getElementById)", () => {
149
+ // @next2d/player の起動シーケンスが要求する DOM API を検証する
150
+ const div = document.createElement("div");
151
+ div.id = "selftest-main";
152
+ div.tabIndex = -1;
153
+ document.body.appendChild(div);
154
+ assert(div.parentElement === document.body, "parentElement === body");
155
+ assert(div.parentElement.tagName === "BODY", "body.tagName === BODY");
156
+ div.setAttribute("style", "display:flex;width:640px;height:480px;");
157
+ assert(div.clientWidth === 640 && div.clientHeight === 480,
158
+ "clientWidth/Height from style attr");
159
+ div.innerHTML = "<style></style><div id=\"loading\"></div>";
160
+ while (div.firstChild) { div.removeChild(div.firstChild) }
161
+
162
+ const canvas = document.createElement("canvas");
163
+ div.appendChild(canvas);
164
+ assert(div.children[0] === canvas, "children[0] === canvas");
165
+ assert(canvas.localName === "canvas", "canvas.localName");
166
+ assert(canvas.parentElement === div, "canvas.parentElement");
167
+
168
+ assert(document.getElementById("selftest-main") === div, "getElementById");
169
+ assert(typeof Event === "function", "Event constructor");
170
+ let fired = false;
171
+ div.addEventListener("selftest-dom", () => { fired = true });
172
+ div.dispatchEvent(new Event("selftest-dom"));
173
+ assert(fired, "element dispatchEvent");
174
+ div.remove();
175
+ assert(document.getElementById("selftest-main") === null, "removed from tree");
176
+ });
177
+
178
+ await test("DOM: history / location (framework gotoView パス)", () => {
179
+ // framework の gotoView は SPA 設定時に history.pushState を呼ぶ
180
+ assert(typeof history === "object" && typeof history.pushState === "function",
181
+ "history.pushState");
182
+ const beforePath = location.pathname;
183
+ history.pushState("", "", location.origin + "/selftest-view?q=1");
184
+ assert(location.pathname === "/selftest-view", "pushState -> location.pathname");
185
+ assert(location.search === "?q=1", "pushState -> location.search");
186
+ history.replaceState("", "", beforePath);
187
+ assert(location.pathname === beforePath, "replaceState で復元");
188
+ assert(typeof location.origin === "string" && location.origin.length > 0,
189
+ "location.origin");
190
+ });
191
+
192
+ await test("DOM: 未処理 Promise 拒否が握り潰されない (レポータ設置済み)", async () => {
193
+ // SetPromiseRejectCallback の存在自体は JS から観測できないため、
194
+ // 拒否→後付けハンドラの一連が例外なく動くことのみ確認する
195
+ const p = Promise.reject(new Error("selftest-rejection (expected log)"));
196
+ await new Promise((resolve) => setTimeout(resolve, 10));
197
+ let caught = false;
198
+ await p.catch(() => { caught = true });
199
+ assert(caught, "late handler attached");
200
+ });
201
+
148
202
  // ==================== 画像 ====================
149
203
  let bitmap = null;
150
204
  await test("createImageBitmap: PNG デコード (WIC)", async () => {
151
205
  bitmap = await createImageBitmap(TEST_PNG.buffer);
152
206
  assert(bitmap.width === 2 && bitmap.height === 2,
153
- "2x2 png -> " + bitmap.width + "x" + bitmap.height);
207
+ "2x2 png -> " + bitmap.width + "x" + bitmap.height);
208
+ });
209
+
210
+ await test("Image 要素: new Image + src(data:) -> load -> drawImage", async () => {
211
+ // player の ShapeLoadSrcUseCase と同じ利用パターン
212
+ const img = new Image();
213
+ img.crossOrigin = "anonymous";
214
+ const loaded = new Promise((resolve, reject) => {
215
+ img.addEventListener("load", resolve);
216
+ img.addEventListener("error", () => reject(new Error("image load error")));
217
+ setTimeout(() => reject(new Error("image load timeout")), 3000);
218
+ });
219
+ img.src = "data:image/png;base64," + btoa(String.fromCharCode.apply(null, TEST_PNG));
220
+ await loaded;
221
+ assert(img.complete === true, "complete = true");
222
+ assert(img.width === 2 && img.height === 2,
223
+ "size: " + img.width + "x" + img.height);
224
+ assert(img.naturalWidth === 2, "naturalWidth");
225
+
226
+ const c = new OffscreenCanvas(2, 2);
227
+ const ctx = c.getContext("2d", { "willReadFrequently": true });
228
+ ctx.drawImage(img, 0, 0, 2, 2);
229
+ const d = ctx.getImageData(0, 0, 2, 2).data;
230
+ assert(d[0] === 255 && d[1] === 0 && d[2] === 0 && d[3] === 255,
231
+ "(0,0) red: " + d[0] + "," + d[1] + "," + d[2] + "," + d[3]);
232
+ assert(d[5] === 255, "(1,0) green");
154
233
  });
155
234
 
156
235
  // ==================== Canvas2D ====================
@@ -207,7 +286,7 @@
207
286
  ctx.fillText("A", 4, 24);
208
287
  const d = ctx.getImageData(0, 0, 64, 32).data;
209
288
  let opaque = 0;
210
- for (let i = 3; i < d.length; i += 4) if (d[i] > 128) opaque++;
289
+ for (let i = 3; i < d.length; i += 4) { if (d[i] > 128) { opaque++ } }
211
290
  assert(opaque > 5, "glyph pixels=" + opaque);
212
291
  });
213
292
 
@@ -221,13 +300,103 @@
221
300
  assert(d[4] === 0 && d[5] === 255, "(1,0) green");
222
301
  });
223
302
 
303
+ // ==================== エンジン/グローバル (player の V8 機能全数調査ぶん) ====================
304
+ await test("engine: crypto / ImageData→ImageBitmap / Proxy", async () => {
305
+ // crypto.randomUUID (player の WebGLUtil が ID 生成に使用)
306
+ const uuid = crypto.randomUUID();
307
+ 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),
308
+ "uuid=" + uuid);
309
+ const rnd = crypto.getRandomValues(new Uint8Array(16));
310
+ assert(rnd.length === 16 && rnd.some((b) => b !== 0), "getRandomValues");
311
+
312
+ // createImageBitmap(ImageData) — webgpu レンダラのキャプチャ/フィルタ経路
313
+ const data = new Uint8ClampedArray(2 * 2 * 4);
314
+ data[0] = 255; data[3] = 255; // (0,0) = 赤
315
+ const bmp = await createImageBitmap(new ImageData(data, 2, 2));
316
+ assert(bmp.width === 2 && bmp.height === 2, "ImageData bitmap " + bmp.width + "x" + bmp.height);
317
+ const c = new OffscreenCanvas(2, 2);
318
+ const ctx = c.getContext("2d");
319
+ ctx.drawImage(bmp, 0, 0);
320
+ const px = ctx.getImageData(0, 0, 1, 1).data;
321
+ assert(px[0] === 255, "ImageData pixel roundtrip");
322
+
323
+ // Proxy (player が4ファイルで使用。jitless V8 のコア機能)
324
+ const p = new Proxy({}, { "get": (_, k) => k === "x" ? 42 : undefined });
325
+ assert(p.x === 42, "Proxy");
326
+
327
+ // ゲームコードが使うグローバル (UserSettings=localStorage, boot=readyState, confirm)
328
+ localStorage.setItem("__selftest", "1");
329
+ assert(localStorage.getItem("__selftest") === "1", "localStorage roundtrip");
330
+ localStorage.removeItem("__selftest");
331
+ assert(localStorage.getItem("__selftest") === null, "localStorage remove");
332
+ assert(document.readyState === "complete", "document.readyState");
333
+ assert(typeof confirm("?") === "boolean", "confirm");
334
+ });
335
+
336
+ await test("engine: WebAssembly (DrumBrake インタープリタ実行)", async () => {
337
+ // (module (func $add (param i32 i32) (result i32) ...) (export "add"))
338
+ // prebuilt V8 r2 (wasm + DrumBrake) が必要。実行時は --wasm-jitless で解釈実行。
339
+ assert(typeof WebAssembly === "object", "WebAssembly global (V8 r2 required)");
340
+ const wasmBytes = new Uint8Array([
341
+ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
342
+ 0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7f,
343
+ 0x03, 0x02, 0x01, 0x00,
344
+ 0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00,
345
+ 0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b
346
+ ]);
347
+ const { instance } = await WebAssembly.instantiate(wasmBytes);
348
+ assert(instance.exports.add(19, 23) === 42, "wasm add(19,23)");
349
+ });
350
+
351
+ await test("engine: indexedDB / atob / btoa", async () => {
352
+ assert(atob(btoa("Next2D!")) === "Next2D!", "atob/btoa roundtrip");
353
+
354
+ const openReq = indexedDB.open("selftest-db", 1);
355
+ const db = await new Promise((resolve, reject) => {
356
+ openReq.onupgradeneeded = (e) => e.target.result.createObjectStore("kv");
357
+ openReq.onsuccess = (e) => resolve(e.target.result);
358
+ openReq.onerror = () => reject(new Error("open failed"));
359
+ });
360
+ const store = db.transaction(["kv"], "readwrite").objectStore("kv");
361
+ await new Promise((resolve, reject) => {
362
+ const r = store.put({ "n": 1, "bin": new Uint8Array([7, 8]) }, "k");
363
+ r.onsuccess = resolve;
364
+ r.onerror = () => reject(new Error("put failed"));
365
+ });
366
+ const v = await new Promise((resolve, reject) => {
367
+ const r = db.transaction(["kv"]).objectStore("kv").get("k");
368
+ r.onsuccess = (e) => resolve(e.target.result);
369
+ r.onerror = () => reject(new Error("get failed"));
370
+ });
371
+ assert(v && v.n === 1 && v.bin instanceof Uint8Array && v.bin[1] === 8,
372
+ "indexedDB roundtrip (binary 値含む)");
373
+ });
374
+
375
+ await test("engine: Worker 内の TextDecoder / location / crypto", async () => {
376
+ const src = "self.onmessage = function() { " +
377
+ "self.postMessage({ " +
378
+ " td: typeof TextDecoder, loc: typeof location, cr: typeof crypto, " +
379
+ " dec: new TextDecoder().decode(new TextEncoder().encode('abc')) " +
380
+ "}); };";
381
+ const url = URL.createObjectURL(new Blob([src], { "type": "text/javascript" }));
382
+ const worker = new Worker(url);
383
+ const r = await new Promise((resolve) => {
384
+ worker.onmessage = (e) => resolve(e.data);
385
+ worker.postMessage(0);
386
+ });
387
+ assert(r.td === "function" && r.loc === "object" && r.cr === "object",
388
+ JSON.stringify(r));
389
+ assert(r.dec === "abc", "worker TextDecoder roundtrip");
390
+ if (worker.terminate) { worker.terminate() }
391
+ });
392
+
224
393
  // ==================== ネットワーク (Blob / URL / XHR) ====================
225
394
  let blobUrl = null;
226
395
  await test("Blob / URL.createObjectURL / revokeObjectURL", () => {
227
- const blob = new Blob(["self.__marker = 1;"], { type: "text/javascript" });
396
+ const blob = new Blob(["self.__marker = 1;"], { "type": "text/javascript" });
228
397
  blobUrl = URL.createObjectURL(blob);
229
398
  assert(typeof blobUrl === "string" && blobUrl.indexOf("blob:") === 0,
230
- "url=" + blobUrl);
399
+ "url=" + blobUrl);
231
400
  });
232
401
 
233
402
  await softTest("XMLHttpRequest: アセット読込 (assets/app)", async () => {
@@ -245,17 +414,49 @@
245
414
  const src = "self.onmessage = function(e) { " +
246
415
  " self.postMessage({ echo: e.data.value * 2, arr: e.data.arr }); " +
247
416
  "};";
248
- const url = URL.createObjectURL(new Blob([src], { type: "text/javascript" }));
417
+ const url = URL.createObjectURL(new Blob([src], { "type": "text/javascript" }));
249
418
  const worker = new Worker(url);
250
419
  const reply = await new Promise((resolve, reject) => {
251
420
  worker.onmessage = (e) => resolve(e.data);
252
421
  worker.onerror = (e) => reject(new Error("worker error: " + (e && e.message)));
253
- worker.postMessage({ value: 21, arr: new Float32Array([1.5, 2.5]) });
422
+ worker.postMessage({ "value": 21, "arr": new Float32Array([1.5, 2.5]) });
254
423
  });
255
424
  assert(reply.echo === 42, "echo=" + reply.echo);
256
425
  assert(reply.arr instanceof Float32Array && reply.arr[1] === 2.5,
257
- "structured clone TypedArray");
258
- if (worker.terminate) worker.terminate();
426
+ "structured clone TypedArray");
427
+ if (worker.terminate) { worker.terminate() }
428
+ });
429
+
430
+ await test("Worker: ArrayBuffer transfer + ImageBitmap clone (render 経路)", async () => {
431
+ // player の render キューと同じく、大きな buffer は transfer で移譲し、
432
+ // ImageBitmap はホストオブジェクトとして複製して往復させる
433
+ const src = "self.onmessage = function(e) { " +
434
+ " var view = new Float32Array(e.data.buffer); " +
435
+ " self.postMessage({ " +
436
+ " sum: view[0] + view[1], " +
437
+ " len: e.data.buffer.byteLength, " +
438
+ " bmp: e.data.bmp, " +
439
+ " buffer: e.data.buffer " +
440
+ " }, [e.data.buffer]); " +
441
+ "};";
442
+ const url = URL.createObjectURL(new Blob([src], { "type": "text/javascript" }));
443
+ const worker = new Worker(url);
444
+ const view = new Float32Array(1024);
445
+ view[0] = 1.5;
446
+ view[1] = 2.25;
447
+ const reply = await new Promise((resolve, reject) => {
448
+ worker.onmessage = (e) => resolve(e.data);
449
+ worker.onerror = (e) => reject(new Error("worker error: " + (e && e.message)));
450
+ worker.postMessage({ "buffer": view.buffer, "bmp": bitmap }, [view.buffer]);
451
+ assert(view.buffer.byteLength === 0, "送信側は detach される (transferable)");
452
+ });
453
+ assert(reply.sum === 3.75, "worker 側で中身が読める: " + reply.sum);
454
+ assert(reply.len === 4096, "worker 側 byteLength: " + reply.len);
455
+ assert(reply.buffer instanceof ArrayBuffer && reply.buffer.byteLength === 4096,
456
+ "worker→main への transfer 返却");
457
+ assert(reply.bmp && reply.bmp.width === 2 && reply.bmp.height === 2,
458
+ "ImageBitmap クローン往復");
459
+ if (worker.terminate) { worker.terminate() }
259
460
  });
260
461
 
261
462
  await test("OffscreenCanvas: transferControlToOffscreen + Worker 転送", async () => {
@@ -267,20 +468,22 @@
267
468
  " var c = e.data.canvas; " +
268
469
  " self.postMessage({ w: c ? c.width : -1 }); " +
269
470
  "};";
270
- const url = URL.createObjectURL(new Blob([src], { type: "text/javascript" }));
471
+ const url = URL.createObjectURL(new Blob([src], { "type": "text/javascript" }));
271
472
  const worker = new Worker(url);
272
473
  const reply = await new Promise((resolve, reject) => {
273
474
  worker.onmessage = (e) => resolve(e.data);
274
- worker.onerror = (e) => reject(new Error("worker error"));
275
- worker.postMessage({ canvas: off }, [off]);
475
+ worker.onerror = (_e) => reject(new Error("worker error"));
476
+ worker.postMessage({ "canvas": off }, [off]);
276
477
  });
277
478
  assert(reply.w === 8, "transferred canvas width=" + reply.w);
278
- if (worker.terminate) worker.terminate();
479
+ if (worker.terminate) { worker.terminate() }
279
480
  });
280
481
 
281
482
  // ==================== WebGPU ====================
483
+ // GPU の無い環境 (CI ランナー等) では adapter が取れず skip になる (softTest)。
484
+ // 実機・GPU あり PC では全て実行される。
282
485
  let device = null;
283
- await test("WebGPU: requestAdapter / requestDevice / limits", async () => {
486
+ await softTest("WebGPU: requestAdapter / requestDevice / limits", async () => {
284
487
  assert(navigator.gpu, "navigator.gpu");
285
488
  const format = navigator.gpu.getPreferredCanvasFormat();
286
489
  assert(typeof format === "string" && format.length > 0, "preferred format=" + format);
@@ -289,13 +492,13 @@
289
492
  device = await adapter.requestDevice();
290
493
  assert(device, "device");
291
494
  assert(device.limits && device.limits.maxTextureDimension2D >= 4096,
292
- "maxTextureDimension2D=" + (device.limits && device.limits.maxTextureDimension2D));
495
+ "maxTextureDimension2D=" + (device.limits && device.limits.maxTextureDimension2D));
293
496
  });
294
497
 
295
- await test("WebGPU: writeBuffer -> copyBufferToBuffer -> mapAsync 読み戻し", async () => {
498
+ await softTest("WebGPU: writeBuffer -> copyBufferToBuffer -> mapAsync 読み戻し", async () => {
296
499
  assert(device, "needs device");
297
- const src = device.createBuffer({ size: 16, usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST });
298
- const dst = device.createBuffer({ size: 16, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
500
+ const src = device.createBuffer({ "size": 16, "usage": GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST });
501
+ const dst = device.createBuffer({ "size": 16, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
299
502
  device.queue.writeBuffer(src, 0, new Uint32Array([1, 2, 3, 4]));
300
503
  const enc = device.createCommandEncoder();
301
504
  enc.copyBufferToBuffer(src, 0, dst, 0, 16);
@@ -306,20 +509,20 @@
306
509
  assert(view[0] === 1 && view[3] === 4, "readback=" + Array.from(view).join(","));
307
510
  });
308
511
 
309
- await test("WebGPU: copyExternalImageToTexture -> copyTextureToBuffer 読み戻し", async () => {
512
+ await softTest("WebGPU: copyExternalImageToTexture -> copyTextureToBuffer 読み戻し", async () => {
310
513
  assert(device && bitmap, "needs device+bitmap");
311
514
  const tex = device.createTexture({
312
- size: { width: 2, height: 2 },
313
- format: "rgba8unorm",
314
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC | GPUTextureUsage.RENDER_ATTACHMENT
515
+ "size": { "width": 2, "height": 2 },
516
+ "format": "rgba8unorm",
517
+ "usage": GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC | GPUTextureUsage.RENDER_ATTACHMENT
315
518
  });
316
519
  device.queue.copyExternalImageToTexture(
317
- { source: bitmap }, { texture: tex }, { width: 2, height: 2 });
520
+ { "source": bitmap }, { "texture": tex }, { "width": 2, "height": 2 });
318
521
  // bytesPerRow は 256 アライン必須
319
- const buf = device.createBuffer({ size: 256 * 2, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
522
+ const buf = device.createBuffer({ "size": 256 * 2, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
320
523
  const enc = device.createCommandEncoder();
321
524
  enc.copyTextureToBuffer(
322
- { texture: tex }, { buffer: buf, bytesPerRow: 256 }, { width: 2, height: 2 });
525
+ { "texture": tex }, { "buffer": buf, "bytesPerRow": 256 }, { "width": 2, "height": 2 });
323
526
  device.queue.submit([enc.finish()]);
324
527
  await buf.mapAsync(GPUMapMode.READ);
325
528
  const px = new Uint8Array(buf.getMappedRange().slice(0));
@@ -328,9 +531,9 @@
328
531
  assert(px[4] === 0 && px[5] === 255, "(1,0) green");
329
532
  });
330
533
 
331
- await test("WebGPU: dynamic offset 付き uniform で描画 (塗りの中核経路)", async () => {
534
+ await softTest("WebGPU: dynamic offset 付き uniform で描画 (塗りの中核経路)", async () => {
332
535
  assert(device, "needs device");
333
- const shader = device.createShaderModule({ code: `
536
+ const shader = device.createShaderModule({ "code": `
334
537
  struct U { color: vec4f };
335
538
  @group(0) @binding(0) var<uniform> u: U;
336
539
  @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
@@ -339,96 +542,251 @@
339
542
  }
340
543
  @fragment fn fs() -> @location(0) vec4f { return u.color; }
341
544
  ` });
342
- const bgl = device.createBindGroupLayout({ entries: [{
343
- binding: 0, visibility: GPUShaderStage.FRAGMENT,
344
- buffer: { type: "uniform", hasDynamicOffset: true }
545
+ const bgl = device.createBindGroupLayout({ "entries": [{
546
+ "binding": 0, "visibility": GPUShaderStage.FRAGMENT,
547
+ "buffer": { "type": "uniform", "hasDynamicOffset": true }
345
548
  }] });
346
549
  const pipeline = device.createRenderPipeline({
347
- layout: device.createPipelineLayout({ bindGroupLayouts: [bgl] }),
348
- vertex: { module: shader, entryPoint: "vs" },
349
- fragment: { module: shader, entryPoint: "fs",
350
- targets: [{ format: "rgba8unorm" }] },
351
- primitive: { topology: "triangle-list" }
550
+ "layout": device.createPipelineLayout({ "bindGroupLayouts": [bgl] }),
551
+ "vertex": { "module": shader, "entryPoint": "vs" },
552
+ "fragment": { "module": shader, "entryPoint": "fs",
553
+ "targets": [{ "format": "rgba8unorm" }] },
554
+ "primitive": { "topology": "triangle-list" }
352
555
  });
353
556
  // offset 0 = 赤 / offset 256 = 緑。dynamic offset 256 で緑が描かれるべき。
354
- const ubo = device.createBuffer({ size: 512, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
557
+ const ubo = device.createBuffer({ "size": 512, "usage": GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
355
558
  device.queue.writeBuffer(ubo, 0, new Float32Array([1, 0, 0, 1]));
356
559
  device.queue.writeBuffer(ubo, 256, new Float32Array([0, 1, 0, 1]));
357
- const bindGroup = device.createBindGroup({ layout: bgl, entries: [{
358
- binding: 0, resource: { buffer: ubo, offset: 0, size: 16 }
560
+ const bindGroup = device.createBindGroup({ "layout": bgl, "entries": [{
561
+ "binding": 0, "resource": { "buffer": ubo, "offset": 0, "size": 16 }
359
562
  }] });
360
563
  const target = device.createTexture({
361
- size: { width: 4, height: 4 }, format: "rgba8unorm",
362
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
564
+ "size": { "width": 4, "height": 4 }, "format": "rgba8unorm",
565
+ "usage": GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
363
566
  });
364
567
  const enc = device.createCommandEncoder();
365
- const pass = enc.beginRenderPass({ colorAttachments: [{
366
- view: target.createView(), loadOp: "clear", storeOp: "store",
367
- clearValue: [0, 0, 0, 0]
568
+ const pass = enc.beginRenderPass({ "colorAttachments": [{
569
+ "view": target.createView(), "loadOp": "clear", "storeOp": "store",
570
+ "clearValue": [0, 0, 0, 0]
368
571
  }] });
369
572
  pass.setPipeline(pipeline);
370
573
  pass.setBindGroup(0, bindGroup, [256]); // ← dynamic offset
371
574
  pass.draw(3);
372
575
  pass.end();
373
- const buf = device.createBuffer({ size: 256 * 4, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
576
+ const buf = device.createBuffer({ "size": 256 * 4, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
374
577
  const enc2 = device.createCommandEncoder();
375
- enc2.copyTextureToBuffer({ texture: target }, { buffer: buf, bytesPerRow: 256 }, { width: 4, height: 4 });
578
+ enc2.copyTextureToBuffer({ "texture": target }, { "buffer": buf, "bytesPerRow": 256 }, { "width": 4, "height": 4 });
376
579
  device.queue.submit([enc.finish(), enc2.finish()]);
377
580
  await buf.mapAsync(GPUMapMode.READ);
378
581
  const px = new Uint8Array(buf.getMappedRange().slice(0));
379
582
  buf.unmap();
380
583
  assert(px[1] === 255 && px[0] === 0,
381
- "dynamic offset 256 -> green, got rgba=" + px[0] + "," + px[1] + "," + px[2] + "," + px[3]);
584
+ "dynamic offset 256 -> green, got rgba=" + px[0] + "," + px[1] + "," + px[2] + "," + px[3]);
382
585
  });
383
586
 
384
- await test("WebGPU: stencil8 パイプライン + setStencilReference (マスク経路)", async () => {
587
+ await softTest("WebGPU: MSAA (sampleCount=4) + resolveTarget 解決 (player 全描画経路)", async () => {
385
588
  assert(device, "needs device");
386
- const shader = device.createShaderModule({ code: `
589
+ // player は全描画を MSAA テクスチャへ行い resolveTarget で本体へ解決する。
590
+ // resolveTarget が無視されると描画結果がどこにも現れない (黒画面の実原因だった)
591
+ const shader = device.createShaderModule({ "code": `
387
592
  @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
388
593
  var p = array<vec2f, 3>(vec2f(-1,-3), vec2f(3,1), vec2f(-1,1));
389
594
  return vec4f(p[i], 0, 1);
390
595
  }
391
596
  @fragment fn fs() -> @location(0) vec4f { return vec4f(0, 0, 1, 1); }
392
597
  ` });
393
- const layout = device.createPipelineLayout({ bindGroupLayouts: [] });
598
+ const pipeline = device.createRenderPipeline({
599
+ "layout": "auto",
600
+ "vertex": { "module": shader, "entryPoint": "vs" },
601
+ "fragment": { "module": shader, "entryPoint": "fs",
602
+ "targets": [{ "format": "rgba8unorm" }] },
603
+ "primitive": { "topology": "triangle-list" },
604
+ "multisample": { "count": 4 }
605
+ });
606
+ const msaa = device.createTexture({
607
+ "size": { "width": 4, "height": 4 }, "format": "rgba8unorm",
608
+ "sampleCount": 4,
609
+ "usage": GPUTextureUsage.RENDER_ATTACHMENT
610
+ });
611
+ const resolve = device.createTexture({
612
+ "size": { "width": 4, "height": 4 }, "format": "rgba8unorm",
613
+ "usage": GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
614
+ });
615
+ const enc = device.createCommandEncoder();
616
+ const pass = enc.beginRenderPass({ "colorAttachments": [{
617
+ "view": msaa.createView(),
618
+ "resolveTarget": resolve.createView(),
619
+ "loadOp": "clear", "storeOp": "store",
620
+ "clearValue": [0, 0, 0, 0]
621
+ }] });
622
+ pass.setPipeline(pipeline);
623
+ pass.draw(3);
624
+ pass.end();
625
+ const buf = device.createBuffer({ "size": 256 * 4, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
626
+ const enc2 = device.createCommandEncoder();
627
+ enc2.copyTextureToBuffer({ "texture": resolve }, { "buffer": buf, "bytesPerRow": 256 }, { "width": 4, "height": 4 });
628
+ device.queue.submit([enc.finish(), enc2.finish()]);
629
+ await buf.mapAsync(GPUMapMode.READ);
630
+ const px = new Uint8Array(buf.getMappedRange().slice(0));
631
+ buf.unmap();
632
+ assert(px[2] === 255 && px[3] === 255,
633
+ "resolve target has blue, got rgba=" + px[0] + "," + px[1] + "," + px[2] + "," + px[3]);
634
+ });
635
+
636
+ await softTest("WebGPU: MSAA ステンシルマスク (player clip 経路の忠実再現)", async () => {
637
+ assert(device, "needs device");
638
+ // player の mask: (1) writeMask=0 でステンシルへ形状を書く (resolveTarget なし)
639
+ // (2) stencil compare=equal で内容を描く (resolveTarget あり)
640
+ // これが崩れるとマスクが効かず、隠されるべきレイヤーが全面に描かれる
641
+ const W = 8, H = 8;
642
+ const shader = device.createShaderModule({ "code": `
643
+ @vertex fn vsLeft(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
644
+ // 左半分を覆う矩形 (2 三角形)
645
+ var p = array<vec2f, 6>(
646
+ vec2f(-1,-1), vec2f(0,-1), vec2f(-1,1),
647
+ vec2f(-1,1), vec2f(0,-1), vec2f(0,1));
648
+ return vec4f(p[i], 0, 1);
649
+ }
650
+ @vertex fn vsFull(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
651
+ var p = array<vec2f, 3>(vec2f(-1,-3), vec2f(3,1), vec2f(-1,1));
652
+ return vec4f(p[i], 0, 1);
653
+ }
654
+ @fragment fn fs() -> @location(0) vec4f { return vec4f(1, 0, 0, 1); }
655
+ @fragment fn fsBlue() -> @location(0) vec4f { return vec4f(0, 0, 1, 1); }
656
+ ` });
657
+ const msaaColor = device.createTexture({
658
+ "size": { "width": W, "height": H }, "format": "rgba8unorm",
659
+ "sampleCount": 4, "usage": GPUTextureUsage.RENDER_ATTACHMENT
660
+ });
661
+ const resolve = device.createTexture({
662
+ "size": { "width": W, "height": H }, "format": "rgba8unorm",
663
+ "usage": GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
664
+ });
665
+ const msaaStencil = device.createTexture({
666
+ "size": { "width": W, "height": H }, "format": "stencil8",
667
+ "sampleCount": 4, "usage": GPUTextureUsage.RENDER_ATTACHMENT
668
+ });
669
+ const clipPipeline = device.createRenderPipeline({
670
+ "layout": "auto",
671
+ "vertex": { "module": shader, "entryPoint": "vsLeft" },
672
+ "fragment": { "module": shader, "entryPoint": "fs",
673
+ "targets": [{ "format": "rgba8unorm", "writeMask": 0 }] },
674
+ "primitive": { "topology": "triangle-list" },
675
+ "multisample": { "count": 4 },
676
+ "depthStencil": {
677
+ "format": "stencil8",
678
+ "stencilFront": { "compare": "always", "passOp": "replace" },
679
+ "stencilBack": { "compare": "always", "passOp": "replace" }
680
+ }
681
+ });
682
+ const drawPipeline = device.createRenderPipeline({
683
+ "layout": "auto",
684
+ "vertex": { "module": shader, "entryPoint": "vsFull" },
685
+ "fragment": { "module": shader, "entryPoint": "fsBlue",
686
+ "targets": [{ "format": "rgba8unorm" }] },
687
+ "primitive": { "topology": "triangle-list" },
688
+ "multisample": { "count": 4 },
689
+ "depthStencil": {
690
+ "format": "stencil8",
691
+ "stencilFront": { "compare": "equal", "passOp": "keep" },
692
+ "stencilBack": { "compare": "equal", "passOp": "keep" }
693
+ }
694
+ });
695
+ const enc = device.createCommandEncoder();
696
+ // pass 1: マスク形状 (左半分) をステンシルに書く。色は writeMask=0 で不変
697
+ const p1 = enc.beginRenderPass({
698
+ "colorAttachments": [{
699
+ "view": msaaColor.createView(),
700
+ "loadOp": "clear", "storeOp": "store", "clearValue": [0, 0, 0, 0]
701
+ }],
702
+ "depthStencilAttachment": {
703
+ "view": msaaStencil.createView(),
704
+ "stencilLoadOp": "clear", "stencilStoreOp": "store",
705
+ "stencilClearValue": 0
706
+ }
707
+ });
708
+ p1.setPipeline(clipPipeline);
709
+ p1.setStencilReference(1);
710
+ p1.draw(6);
711
+ p1.end();
712
+ // pass 2: 全面に青を stencil==1 の場所だけ描き、resolve へ解決
713
+ const p2 = enc.beginRenderPass({
714
+ "colorAttachments": [{
715
+ "view": msaaColor.createView(),
716
+ "resolveTarget": resolve.createView(),
717
+ "loadOp": "load", "storeOp": "store"
718
+ }],
719
+ "depthStencilAttachment": {
720
+ "view": msaaStencil.createView(),
721
+ "stencilLoadOp": "load", "stencilStoreOp": "store"
722
+ }
723
+ });
724
+ p2.setPipeline(drawPipeline);
725
+ p2.setStencilReference(1);
726
+ p2.draw(3);
727
+ p2.end();
728
+ const buf = device.createBuffer({ "size": 256 * H, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
729
+ enc.copyTextureToBuffer({ "texture": resolve }, { "buffer": buf, "bytesPerRow": 256 }, { "width": W, "height": H });
730
+ device.queue.submit([enc.finish()]);
731
+ await buf.mapAsync(GPUMapMode.READ);
732
+ const px = new Uint8Array(buf.getMappedRange().slice(0));
733
+ buf.unmap();
734
+ const at = (x, y) => y * 256 + x * 4;
735
+ const l = at(2, 4), r = at(6, 4);
736
+ assert(px[l + 2] === 255 && px[l + 3] === 255,
737
+ "マスク内 (左) は青: rgba=" + px[l] + "," + px[l + 1] + "," + px[l + 2] + "," + px[l + 3]);
738
+ assert(px[r + 3] === 0,
739
+ "マスク外 (右) は透明のまま: rgba=" + px[r] + "," + px[r + 1] + "," + px[r + 2] + "," + px[r + 3]);
740
+ });
741
+
742
+ await softTest("WebGPU: stencil8 パイプライン + setStencilReference (マスク経路)", async () => {
743
+ assert(device, "needs device");
744
+ const shader = device.createShaderModule({ "code": `
745
+ @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
746
+ var p = array<vec2f, 3>(vec2f(-1,-3), vec2f(3,1), vec2f(-1,1));
747
+ return vec4f(p[i], 0, 1);
748
+ }
749
+ @fragment fn fs() -> @location(0) vec4f { return vec4f(0, 0, 1, 1); }
750
+ ` });
751
+ const layout = device.createPipelineLayout({ "bindGroupLayouts": [] });
394
752
  // 1st: ステンシルへ replace 書き込み (色は書かない設定でも良いが簡略化)
395
753
  const writePipe = device.createRenderPipeline({
396
- layout, vertex: { module: shader, entryPoint: "vs" },
397
- fragment: { module: shader, entryPoint: "fs", targets: [{ format: "rgba8unorm" }] },
398
- primitive: { topology: "triangle-list" },
399
- depthStencil: {
400
- format: "stencil8",
401
- stencilFront: { compare: "always", failOp: "keep", depthFailOp: "keep", passOp: "replace" },
402
- stencilBack: { compare: "always", failOp: "keep", depthFailOp: "keep", passOp: "replace" },
403
- stencilReadMask: 0xff, stencilWriteMask: 0xff
754
+ layout, "vertex": { "module": shader, "entryPoint": "vs" },
755
+ "fragment": { "module": shader, "entryPoint": "fs", "targets": [{ "format": "rgba8unorm" }] },
756
+ "primitive": { "topology": "triangle-list" },
757
+ "depthStencil": {
758
+ "format": "stencil8",
759
+ "stencilFront": { "compare": "always", "failOp": "keep", "depthFailOp": "keep", "passOp": "replace" },
760
+ "stencilBack": { "compare": "always", "failOp": "keep", "depthFailOp": "keep", "passOp": "replace" },
761
+ "stencilReadMask": 0xff, "stencilWriteMask": 0xff
404
762
  }
405
763
  });
406
764
  // 2nd: equal 比較で描画 (マスク内のみ)
407
765
  const testPipe = device.createRenderPipeline({
408
- layout, vertex: { module: shader, entryPoint: "vs" },
409
- fragment: { module: shader, entryPoint: "fs", targets: [{ format: "rgba8unorm" }] },
410
- primitive: { topology: "triangle-list" },
411
- depthStencil: {
412
- format: "stencil8",
413
- stencilFront: { compare: "equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
414
- stencilBack: { compare: "equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
415
- stencilReadMask: 0xff, stencilWriteMask: 0x00
766
+ layout, "vertex": { "module": shader, "entryPoint": "vs" },
767
+ "fragment": { "module": shader, "entryPoint": "fs", "targets": [{ "format": "rgba8unorm" }] },
768
+ "primitive": { "topology": "triangle-list" },
769
+ "depthStencil": {
770
+ "format": "stencil8",
771
+ "stencilFront": { "compare": "equal", "failOp": "keep", "depthFailOp": "keep", "passOp": "keep" },
772
+ "stencilBack": { "compare": "equal", "failOp": "keep", "depthFailOp": "keep", "passOp": "keep" },
773
+ "stencilReadMask": 0xff, "stencilWriteMask": 0x00
416
774
  }
417
775
  });
418
776
  const color = device.createTexture({
419
- size: { width: 4, height: 4 }, format: "rgba8unorm",
420
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
777
+ "size": { "width": 4, "height": 4 }, "format": "rgba8unorm",
778
+ "usage": GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
421
779
  });
422
780
  const stencil = device.createTexture({
423
- size: { width: 4, height: 4 }, format: "stencil8",
424
- usage: GPUTextureUsage.RENDER_ATTACHMENT
781
+ "size": { "width": 4, "height": 4 }, "format": "stencil8",
782
+ "usage": GPUTextureUsage.RENDER_ATTACHMENT
425
783
  });
426
784
  const enc = device.createCommandEncoder();
427
785
  const p1 = enc.beginRenderPass({
428
- colorAttachments: [{ view: color.createView(), loadOp: "clear", storeOp: "store", clearValue: [0,0,0,0] }],
429
- depthStencilAttachment: {
430
- view: stencil.createView(),
431
- stencilLoadOp: "clear", stencilStoreOp: "store", stencilClearValue: 0
786
+ "colorAttachments": [{ "view": color.createView(), "loadOp": "clear", "storeOp": "store", "clearValue": [0,0,0,0] }],
787
+ "depthStencilAttachment": {
788
+ "view": stencil.createView(),
789
+ "stencilLoadOp": "clear", "stencilStoreOp": "store", "stencilClearValue": 0
432
790
  }
433
791
  });
434
792
  p1.setPipeline(writePipe);
@@ -436,18 +794,18 @@
436
794
  p1.draw(3);
437
795
  p1.end();
438
796
  const p2 = enc.beginRenderPass({
439
- colorAttachments: [{ view: color.createView(), loadOp: "load", storeOp: "store" }],
440
- depthStencilAttachment: {
441
- view: stencil.createView(),
442
- stencilLoadOp: "load", stencilStoreOp: "store"
797
+ "colorAttachments": [{ "view": color.createView(), "loadOp": "load", "storeOp": "store" }],
798
+ "depthStencilAttachment": {
799
+ "view": stencil.createView(),
800
+ "stencilLoadOp": "load", "stencilStoreOp": "store"
443
801
  }
444
802
  });
445
803
  p2.setPipeline(testPipe);
446
804
  p2.setStencilReference(1); // ref==stencil(1) -> 描画される
447
805
  p2.draw(3);
448
806
  p2.end();
449
- const buf = device.createBuffer({ size: 256 * 4, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
450
- enc.copyTextureToBuffer({ texture: color }, { buffer: buf, bytesPerRow: 256 }, { width: 4, height: 4 });
807
+ const buf = device.createBuffer({ "size": 256 * 4, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
808
+ enc.copyTextureToBuffer({ "texture": color }, { "buffer": buf, "bytesPerRow": 256 }, { "width": 4, "height": 4 });
451
809
  device.queue.submit([enc.finish()]);
452
810
  await buf.mapAsync(GPUMapMode.READ);
453
811
  const px = new Uint8Array(buf.getMappedRange().slice(0));
@@ -455,6 +813,284 @@
455
813
  assert(px[2] === 255, "stencil equal pass drew blue, got rgba=" + px[0] + "," + px[1] + "," + px[2]);
456
814
  });
457
815
 
816
+ await softTest("WebGPU: pipeline-overridable constants (override がパイプラインへ反映)", async () => {
817
+ assert(device, "needs device");
818
+ // player は override 定数 (GRADIENT_TYPE=linear/radial, SPREAD_MODE, フィルタ種別,
819
+ // yFlipSign) でシェーダを特殊化する。ホストが fragment/vertex.constants を読まないと
820
+ // 全パイプラインが WGSL 既定値でコンパイルされ、放射状グラデが線形に潰れる等になる。
821
+ // ここで override が実際に効くか (既定値でなく指定値でコンパイルされるか) を検証する。
822
+ const shader = device.createShaderModule({ "code": `
823
+ override SELECT : u32 = 0u;
824
+ override TINT : f32 = 0.25;
825
+ @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
826
+ var p = array<vec2f, 3>(vec2f(-1,-3), vec2f(3,1), vec2f(-1,1));
827
+ return vec4f(p[i], 0, 1);
828
+ }
829
+ @fragment fn fs() -> @location(0) vec4f {
830
+ if (SELECT == 1u) { return vec4f(0, TINT, 0, 1); }
831
+ return vec4f(TINT, 0, 0, 1);
832
+ }
833
+ ` });
834
+ const mk = (constants) => device.createRenderPipeline({
835
+ "layout": "auto",
836
+ "vertex": { "module": shader, "entryPoint": "vs" },
837
+ "fragment": { "module": shader, "entryPoint": "fs",
838
+ "constants": constants,
839
+ "targets": [{ "format": "rgba8unorm" }] },
840
+ "primitive": { "topology": "triangle-list" }
841
+ });
842
+ const draw = async (pipeline) => {
843
+ const target = device.createTexture({
844
+ "size": { "width": 2, "height": 2 }, "format": "rgba8unorm",
845
+ "usage": GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC });
846
+ const enc = device.createCommandEncoder();
847
+ const pass = enc.beginRenderPass({ "colorAttachments": [{
848
+ "view": target.createView(), "loadOp": "clear", "storeOp": "store",
849
+ "clearValue": [0, 0, 0, 1] }] });
850
+ pass.setPipeline(pipeline); pass.draw(3); pass.end();
851
+ // 2 行分: copyTextureToBuffer は size >= bytesPerRow*(h-1)+w*4 = 264 を要求する
852
+ const buf = device.createBuffer({ "size": 256 * 2, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
853
+ enc.copyTextureToBuffer({ "texture": target }, { "buffer": buf, "bytesPerRow": 256 }, { "width": 2, "height": 2 });
854
+ device.queue.submit([enc.finish()]);
855
+ await buf.mapAsync(GPUMapMode.READ);
856
+ const px = new Uint8Array(buf.getMappedRange().slice(0)); buf.unmap();
857
+ return [px[0], px[1], px[2]];
858
+ };
859
+ // constants が無視されると両方 SELECT=0/TINT=0.25 (既定) の赤~64 になり検証に落ちる。
860
+ const green = await draw(mk({ "SELECT": 1, "TINT": 0.75 }));
861
+ const red = await draw(mk({ "SELECT": 0, "TINT": 1.0 }));
862
+ assert(green[1] > 150 && green[0] < 60,
863
+ "override SELECT=1/TINT=0.75 -> 緑: rgb=" + green.join(","));
864
+ assert(red[0] > 240 && red[1] < 40,
865
+ "override SELECT=0/TINT=1.0 -> 赤最大: rgb=" + red.join(","));
866
+ });
867
+
868
+ await softTest("WebGPU: multisample.alphaToCoverageEnabled (ベクタ縁 AA)", async () => {
869
+ assert(device, "needs device");
870
+ // Next2D はベクタ図形の境界を塗り alpha に頼り、MSAA カバレッジで AA する。
871
+ // このフィールドが無視されると縁がジャギる。alpha=0.5 で部分被覆になるかを検証。
872
+ const shader = device.createShaderModule({ "code": `
873
+ @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
874
+ var p = array<vec2f, 3>(vec2f(-1,-3), vec2f(3,1), vec2f(-1,1));
875
+ return vec4f(p[i], 0, 1);
876
+ }
877
+ @fragment fn fs() -> @location(0) vec4f { return vec4f(1, 0, 0, 0.5); }
878
+ ` });
879
+ const pipeline = device.createRenderPipeline({
880
+ "layout": "auto",
881
+ "vertex": { "module": shader, "entryPoint": "vs" },
882
+ "fragment": { "module": shader, "entryPoint": "fs", "targets": [{ "format": "rgba8unorm" }] },
883
+ "primitive": { "topology": "triangle-list" },
884
+ "multisample": { "count": 4, "alphaToCoverageEnabled": true }
885
+ });
886
+ const msaa = device.createTexture({ "size": { "width": 4, "height": 4 }, "format": "rgba8unorm",
887
+ "sampleCount": 4, "usage": GPUTextureUsage.RENDER_ATTACHMENT });
888
+ const resolve = device.createTexture({ "size": { "width": 4, "height": 4 }, "format": "rgba8unorm",
889
+ "usage": GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC });
890
+ const enc = device.createCommandEncoder();
891
+ const pass = enc.beginRenderPass({ "colorAttachments": [{
892
+ "view": msaa.createView(), "resolveTarget": resolve.createView(),
893
+ "loadOp": "clear", "storeOp": "store", "clearValue": [0, 0, 0, 1] }] });
894
+ pass.setPipeline(pipeline); pass.draw(3); pass.end();
895
+ const buf = device.createBuffer({ "size": 256 * 4, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
896
+ enc.copyTextureToBuffer({ "texture": resolve }, { "buffer": buf, "bytesPerRow": 256 }, { "width": 4, "height": 4 });
897
+ device.queue.submit([enc.finish()]);
898
+ await buf.mapAsync(GPUMapMode.READ);
899
+ const px = new Uint8Array(buf.getMappedRange().slice(0)); buf.unmap();
900
+ // alpha 0.5 + alphaToCoverage -> ~2/4 サンプル被覆 -> resolve は中間の赤。
901
+ // フィールドが無視されると全サンプル被覆で赤=255 になり検証に落ちる。
902
+ assert(px[0] > 20 && px[0] < 235,
903
+ "alpha-to-coverage で部分被覆 (中間の赤): rgba=" + px[0] + "," + px[1] + "," + px[2] + "," + px[3]);
904
+ });
905
+
906
+ await softTest("WebGPU: writeTexture 宛先 origin (アトラスのセル位置)", async () => {
907
+ assert(device, "needs device");
908
+ // player はアトラスの (node.x, node.y) へ writeTexture する。origin が無視されると
909
+ // 常に (0,0) に書かれてアトラスが破損する。origin(2,2) の書込みが右下に出るか検証。
910
+ const tex = device.createTexture({ "size": { "width": 4, "height": 4 }, "format": "rgba8unorm",
911
+ "usage": GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC });
912
+ const red = new Uint8Array(2 * 2 * 4);
913
+ for (let i = 0; i < red.length; i += 4) { red[i] = 255; red[i + 3] = 255; }
914
+ device.queue.writeTexture({ "texture": tex, "origin": { "x": 2, "y": 2, "z": 0 } },
915
+ red, { "bytesPerRow": 2 * 4, "rowsPerImage": 2 }, { "width": 2, "height": 2 });
916
+ const buf = device.createBuffer({ "size": 256 * 4, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
917
+ const enc = device.createCommandEncoder();
918
+ enc.copyTextureToBuffer({ "texture": tex }, { "buffer": buf, "bytesPerRow": 256 }, { "width": 4, "height": 4 });
919
+ device.queue.submit([enc.finish()]);
920
+ await buf.mapAsync(GPUMapMode.READ);
921
+ const px = new Uint8Array(buf.getMappedRange().slice(0)); buf.unmap();
922
+ const at = (x, y) => y * 256 + x * 4;
923
+ assert(px[at(3, 3)] === 255, "origin(2,2) の赤が右下(3,3)に存在: got=" + px[at(3, 3)]);
924
+ assert(px[at(0, 0)] === 0, "左上(0,0)は空 (origin が (0,0) に潰れていない): got=" + px[at(0, 0)]);
925
+ });
926
+
927
+ await softTest("WebGPU: compute pipeline + storage buffer 書き戻し (dispatchWorkgroups)", async () => {
928
+ assert(device, "needs device");
929
+ // Next2D の 2D ラスタライザは compute を使わないが、WebGPU の機能を特定用途に
930
+ // 絞らず全面的に利用可能にするため compute 経路を検証する。storage buffer への
931
+ // 書込みと compute.constants (override) の反映を 1 テストで確認する。
932
+ const shader = device.createShaderModule({ "code": `
933
+ override MUL : u32 = 1u;
934
+ @group(0) @binding(0) var<storage, read_write> outBuf : array<u32>;
935
+ @compute @workgroup_size(4) fn main(@builtin(global_invocation_id) gid : vec3u) {
936
+ outBuf[gid.x] = gid.x * MUL;
937
+ }
938
+ ` });
939
+ const pipeline = device.createComputePipeline({
940
+ "layout": "auto",
941
+ "compute": { "module": shader, "entryPoint": "main", "constants": { "MUL": 3 } }
942
+ });
943
+ const N = 4;
944
+ const storage = device.createBuffer({ "size": N * 4,
945
+ "usage": GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
946
+ const bindGroup = device.createBindGroup({
947
+ "layout": pipeline.getBindGroupLayout(0),
948
+ "entries": [{ "binding": 0, "resource": { "buffer": storage } }]
949
+ });
950
+ const enc = device.createCommandEncoder();
951
+ const pass = enc.beginComputePass();
952
+ pass.setPipeline(pipeline);
953
+ pass.setBindGroup(0, bindGroup);
954
+ pass.dispatchWorkgroups(1);
955
+ pass.end();
956
+ const readback = device.createBuffer({ "size": N * 4,
957
+ "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
958
+ enc.copyBufferToBuffer(storage, 0, readback, 0, N * 4);
959
+ device.queue.submit([enc.finish()]);
960
+ await readback.mapAsync(GPUMapMode.READ);
961
+ const out = new Uint32Array(readback.getMappedRange().slice(0)); readback.unmap();
962
+ // MUL=3 override が効けば [0,3,6,9]。無視 (既定 1) なら [0,1,2,3] で落ちる。
963
+ assert(out[0] === 0 && out[1] === 3 && out[2] === 6 && out[3] === 9,
964
+ "compute storage 書込み + override MUL=3: got=" + Array.from(out).join(","));
965
+ });
966
+
967
+ await softTest("WebGPU: error scope (pushErrorScope/popErrorScope)", async () => {
968
+ assert(device, "needs device");
969
+ // 正常操作 → popErrorScope は null。不正操作 → validation error を捕捉。
970
+ device.pushErrorScope("validation");
971
+ device.createBuffer({ "size": 16, "usage": GPUBufferUsage.COPY_DST });
972
+ const noErr = await device.popErrorScope();
973
+ assert(noErr === null, "正常操作では error なし: got=" + noErr);
974
+ device.pushErrorScope("validation");
975
+ // MAP_READ | MAP_WRITE の同時指定は不正 (validation error)
976
+ device.createBuffer({ "size": 16, "usage": GPUBufferUsage.MAP_READ | GPUBufferUsage.MAP_WRITE });
977
+ const err = await device.popErrorScope();
978
+ assert(err !== null, "不正操作で validation error を捕捉 (null でない)");
979
+ });
980
+
981
+ await softTest("WebGPU: render bundle (createRenderBundleEncoder/executeBundles)", async () => {
982
+ assert(device, "needs device");
983
+ const shader = device.createShaderModule({ "code": `
984
+ @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
985
+ var p = array<vec2f, 3>(vec2f(-1,-3), vec2f(3,1), vec2f(-1,1));
986
+ return vec4f(p[i], 0, 1);
987
+ }
988
+ @fragment fn fs() -> @location(0) vec4f { return vec4f(0, 1, 0, 1); }
989
+ ` });
990
+ const pipeline = device.createRenderPipeline({
991
+ "layout": "auto",
992
+ "vertex": { "module": shader, "entryPoint": "vs" },
993
+ "fragment": { "module": shader, "entryPoint": "fs", "targets": [{ "format": "rgba8unorm" }] },
994
+ "primitive": { "topology": "triangle-list" }
995
+ });
996
+ const bundleEnc = device.createRenderBundleEncoder({ "colorFormats": ["rgba8unorm"] });
997
+ bundleEnc.setPipeline(pipeline); bundleEnc.draw(3);
998
+ const bundle = bundleEnc.finish();
999
+ const target = device.createTexture({ "size": { "width": 2, "height": 2 }, "format": "rgba8unorm",
1000
+ "usage": GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC });
1001
+ const enc = device.createCommandEncoder();
1002
+ const pass = enc.beginRenderPass({ "colorAttachments": [{
1003
+ "view": target.createView(), "loadOp": "clear", "storeOp": "store", "clearValue": [0, 0, 0, 1] }] });
1004
+ pass.executeBundles([bundle]); pass.end();
1005
+ const buf = device.createBuffer({ "size": 256 * 2, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
1006
+ enc.copyTextureToBuffer({ "texture": target }, { "buffer": buf, "bytesPerRow": 256 }, { "width": 2, "height": 2 });
1007
+ device.queue.submit([enc.finish()]);
1008
+ await buf.mapAsync(GPUMapMode.READ);
1009
+ const px = new Uint8Array(buf.getMappedRange().slice(0)); buf.unmap();
1010
+ // bundle 経由の draw が実行されないと clearValue の黒のまま。
1011
+ assert(px[1] > 240 && px[0] < 40, "render bundle 経由の緑描画: rgb=" + px[0] + "," + px[1] + "," + px[2]);
1012
+ });
1013
+
1014
+ await softTest("WebGPU: occlusion query (createQuerySet/beginOcclusionQuery/resolveQuerySet)", async () => {
1015
+ assert(device, "needs device");
1016
+ const shader = device.createShaderModule({ "code": `
1017
+ @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
1018
+ var p = array<vec2f, 3>(vec2f(-1,-3), vec2f(3,1), vec2f(-1,1));
1019
+ return vec4f(p[i], 0, 1);
1020
+ }
1021
+ @fragment fn fs() -> @location(0) vec4f { return vec4f(1, 0, 0, 1); }
1022
+ ` });
1023
+ const pipeline = device.createRenderPipeline({
1024
+ "layout": "auto",
1025
+ "vertex": { "module": shader, "entryPoint": "vs" },
1026
+ "fragment": { "module": shader, "entryPoint": "fs", "targets": [{ "format": "rgba8unorm" }] },
1027
+ "primitive": { "topology": "triangle-list" }
1028
+ });
1029
+ const querySet = device.createQuerySet({ "type": "occlusion", "count": 1 });
1030
+ assert(querySet.count === 1 && querySet.type === "occlusion",
1031
+ "querySet メタ: count=" + querySet.count + " type=" + querySet.type);
1032
+ const target = device.createTexture({ "size": { "width": 2, "height": 2 }, "format": "rgba8unorm",
1033
+ "usage": GPUTextureUsage.RENDER_ATTACHMENT });
1034
+ const enc = device.createCommandEncoder();
1035
+ const pass = enc.beginRenderPass({ "colorAttachments": [{
1036
+ "view": target.createView(), "loadOp": "clear", "storeOp": "store", "clearValue": [0, 0, 0, 1] }],
1037
+ "occlusionQuerySet": querySet });
1038
+ pass.setPipeline(pipeline);
1039
+ pass.beginOcclusionQuery(0); pass.draw(3); pass.endOcclusionQuery();
1040
+ pass.end();
1041
+ const resolveBuf = device.createBuffer({ "size": 8, "usage": GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC });
1042
+ enc.resolveQuerySet(querySet, 0, 1, resolveBuf, 0);
1043
+ const readBuf = device.createBuffer({ "size": 8, "usage": GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
1044
+ enc.copyBufferToBuffer(resolveBuf, 0, readBuf, 0, 8);
1045
+ device.queue.submit([enc.finish()]);
1046
+ await readBuf.mapAsync(GPUMapMode.READ);
1047
+ const counts = new Uint32Array(readBuf.getMappedRange().slice(0)); readBuf.unmap();
1048
+ // 全画面三角形を描いたので被覆サンプル数 > 0 (query 未対応なら 0 のまま)。
1049
+ assert(counts[0] > 0, "occlusion query 被覆サンプル数 > 0: got=" + counts[0]);
1050
+ });
1051
+
1052
+ await softTest("WebGPU: async pipeline + onSubmittedWorkDone", async () => {
1053
+ assert(device, "needs device");
1054
+ const shader = device.createShaderModule({ "code": `
1055
+ @vertex fn vs() -> @builtin(position) vec4f { return vec4f(0, 0, 0, 1); }
1056
+ @fragment fn fs() -> @location(0) vec4f { return vec4f(1, 0, 0, 1); }
1057
+ ` });
1058
+ const rp = await device.createRenderPipelineAsync({
1059
+ "layout": "auto",
1060
+ "vertex": { "module": shader, "entryPoint": "vs" },
1061
+ "fragment": { "module": shader, "entryPoint": "fs", "targets": [{ "format": "rgba8unorm" }] },
1062
+ "primitive": { "topology": "point-list" }
1063
+ });
1064
+ assert(rp && typeof rp.getBindGroupLayout === "function", "createRenderPipelineAsync が pipeline を返す");
1065
+ const cs = device.createShaderModule({ "code": `
1066
+ @group(0) @binding(0) var<storage, read_write> o : array<u32>;
1067
+ @compute @workgroup_size(1) fn main() { o[0] = 1u; }
1068
+ ` });
1069
+ const cp = await device.createComputePipelineAsync({
1070
+ "layout": "auto", "compute": { "module": cs, "entryPoint": "main" } });
1071
+ assert(cp && typeof cp.getBindGroupLayout === "function", "createComputePipelineAsync が pipeline を返す");
1072
+ device.queue.submit([]);
1073
+ await device.queue.onSubmittedWorkDone(); // resolve すれば成功
1074
+ assert(true, "onSubmittedWorkDone が resolve");
1075
+ });
1076
+
1077
+ await softTest("WebGPU: debug marker / device.lost / device.destroy", async () => {
1078
+ assert(device, "needs device");
1079
+ // debug marker は結果に影響しないが呼べること (no-throw) を確認する。
1080
+ const enc = device.createCommandEncoder();
1081
+ enc.pushDebugGroup("group"); enc.insertDebugMarker("marker"); enc.popDebugGroup();
1082
+ const target = device.createTexture({ "size": { "width": 2, "height": 2 }, "format": "rgba8unorm",
1083
+ "usage": GPUTextureUsage.RENDER_ATTACHMENT });
1084
+ const pass = enc.beginRenderPass({ "colorAttachments": [{
1085
+ "view": target.createView(), "loadOp": "clear", "storeOp": "store", "clearValue": [0, 0, 0, 1] }] });
1086
+ pass.pushDebugGroup("pass"); pass.insertDebugMarker("pm"); pass.popDebugGroup();
1087
+ pass.end();
1088
+ device.queue.submit([enc.finish()]);
1089
+ // device.lost は Promise、destroy は関数として存在すること (device 共有中なので実際には呼ばない)。
1090
+ assert(device.lost && typeof device.lost.then === "function", "device.lost は Promise");
1091
+ assert(typeof device.destroy === "function", "device.destroy が存在");
1092
+ });
1093
+
458
1094
  // ==================== Audio ====================
459
1095
  let audioCtx = null;
460
1096
  await test("AudioContext: decodeAudioData (Media Foundation)", async () => {
@@ -465,6 +1101,20 @@
465
1101
  globalThis.__selftestAudioBuffer = buffer;
466
1102
  });
467
1103
 
1104
+ await test("Audio 要素 (new Audio / play / pause)", async () => {
1105
+ // ゲームコードが効果音再生に使う HTMLAudioElement 最小実装
1106
+ const a = new Audio(); // URL なし (ボイス無しでも API は成立する)
1107
+ a.volume = 0;
1108
+ a.loop = false;
1109
+ a.preload = "auto";
1110
+ a.currentTime = 0;
1111
+ assert(a.paused === true, "initial paused");
1112
+ await a.play();
1113
+ assert(a.paused === false, "playing");
1114
+ a.pause();
1115
+ assert(a.paused === true, "paused after pause()");
1116
+ });
1117
+
468
1118
  await softTest("Audio: gain 接続 + 再生 + ended 発火", async () => {
469
1119
  assert(audioCtx && globalThis.__selftestAudioBuffer, "needs decoded buffer");
470
1120
  const gainNode = audioCtx.createGain();
@@ -481,8 +1131,8 @@
481
1131
  // ==================== 入力 / その他 ====================
482
1132
  await test("Gamepad: navigator.getGamepads()", () => {
483
1133
  const pads = navigator.getGamepads();
484
- assert(Array.isArray(pads) || (pads && typeof pads.length === "number"),
485
- "returns array-like");
1134
+ assert(Array.isArray(pads) || pads && typeof pads.length === "number",
1135
+ "returns array-like");
486
1136
  });
487
1137
 
488
1138
  await softTest("clipboard: writeText / readText 往復", async () => {