plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
2c424c4
— Peter John
2026-11-02T17:25:03+05:30
plum_bridge: HiDPI backing store and GPU device-loss recovery; web-test --dpr and --lose-device
- plum-game/web-test/src/lib.rs +59 -2
- plum-game/web-test/src/main.rs +4 -1
- plum-game/web/plum_bridge.js +148 -67
plum-game/web-test/src/lib.rs
CHANGED
|
@@ -22,6 +22,10 @@ pub struct Options {
|
|
|
22
22
|
pub json: bool,
|
|
23
23
|
/// Lift Chromium's vsync / frame-rate cap so frame times show the work per frame.
|
|
24
24
|
pub uncapped: bool,
|
|
25
|
+
/// Browser `devicePixelRatio` (Playwright `deviceScaleFactor`); the page default when None.
|
|
26
|
+
pub dpr: Option<f64>,
|
|
27
|
+
/// Destroy the GPU device from the page after a few frames and require rendering to resume.
|
|
28
|
+
pub lose_device: bool,
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
impl Default for Options {
|
|
@@ -35,6 +39,8 @@ impl Default for Options {
|
|
|
35
39
|
measure: None,
|
|
36
40
|
json: false,
|
|
37
41
|
uncapped: false,
|
|
42
|
+
dpr: None,
|
|
43
|
+
lose_device: false,
|
|
38
44
|
}
|
|
39
45
|
}
|
|
40
46
|
}
|
|
@@ -319,7 +325,14 @@ async fn check(port: u16, opts: &Options) -> Result<String, String> {
|
|
|
319
325
|
}
|
|
320
326
|
|
|
321
327
|
async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts: &Options) -> Result<String, String> {
|
|
328
|
+
let page = match opts.dpr {
|
|
329
|
+
Some(d) => {
|
|
330
|
+
let ctx_opts = playwright_rs::protocol::BrowserContextOptions::builder().device_scale_factor(d).build();
|
|
331
|
+
let ctx = browser.new_context_with_options(ctx_opts).await.map_err(pe("new context"))?;
|
|
332
|
+
ctx.new_page().await.map_err(pe("new page"))?
|
|
333
|
+
}
|
|
322
|
-
|
|
334
|
+
None => browser.new_page().await.map_err(pe("new page"))?,
|
|
335
|
+
};
|
|
323
336
|
let errors: Arc<Mutex<Vec<String>>> = Arc::default();
|
|
324
337
|
let logs: Arc<Mutex<Vec<String>>> = Arc::default();
|
|
325
338
|
{
|
|
@@ -351,6 +364,9 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
|
|
|
351
364
|
.map_err(pe("on_pageerror"))?;
|
|
352
365
|
}
|
|
353
366
|
page.add_init_script(AUDIO_HOOK).await.map_err(pe("init script"))?;
|
|
367
|
+
if opts.lose_device {
|
|
368
|
+
page.add_init_script("window.__plumExposeDevice = true;").await.map_err(pe("init script"))?;
|
|
369
|
+
}
|
|
354
370
|
page.add_init_script(RAF_PROBE).await.map_err(pe("init script"))?;
|
|
355
371
|
page.goto(&format!("http://127.0.0.1:{port}/"), None).await.map_err(pe("goto"))?;
|
|
356
372
|
|
|
@@ -363,6 +379,31 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
|
|
|
363
379
|
page.mouse().click(size[0] / 2.0, size[1] / 2.0, None).await.map_err(pe("click"))?;
|
|
364
380
|
tokio::time::sleep(Duration::from_secs_f64((opts.frames_wait - (opts.frames_wait / 2.0).min(3.0)).max(0.0))).await;
|
|
365
381
|
|
|
382
|
+
let mut loss_failure = None;
|
|
383
|
+
if opts.lose_device {
|
|
384
|
+
let before: f64 = page.evaluate("() => window.plumFrames || 0", None::<&()>).await.unwrap_or(0.0);
|
|
385
|
+
let destroyed: bool = page
|
|
386
|
+
.evaluate("() => { if (!window.__plumDevice) return false; window.__plumDevice.destroy(); return true; }", None::<&()>)
|
|
387
|
+
.await
|
|
388
|
+
.unwrap_or(false);
|
|
389
|
+
tokio::time::sleep(Duration::from_secs(3)).await;
|
|
390
|
+
let after: f64 = page.evaluate("() => window.plumFrames || 0", None::<&()>).await.unwrap_or(0.0);
|
|
391
|
+
let restored = logs.lock().unwrap().iter().any(|l| l.contains("GPU device lost (") && l.ends_with("; restored"));
|
|
392
|
+
loss_failure = if !destroyed {
|
|
393
|
+
Some("--lose-device: the page exposed no GPU device".to_string())
|
|
394
|
+
} else if !restored {
|
|
395
|
+
Some("--lose-device: no 'GPU device lost (...); restored' log after device.destroy()".to_string())
|
|
396
|
+
} else if after < before + 10.0 {
|
|
397
|
+
Some(format!("--lose-device: rendering did not resume (frames drawn {before} -> {after})"))
|
|
398
|
+
} else {
|
|
399
|
+
None
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
let backing: Vec<f64> = page
|
|
403
|
+
.evaluate("() => { const c = document.getElementById('glcanvas'); return [c.width, c.height, window.devicePixelRatio, c.clientWidth, c.clientHeight]; }", None::<&()>)
|
|
404
|
+
.await
|
|
405
|
+
.unwrap_or_default();
|
|
406
|
+
|
|
366
407
|
let renderer: Option<String> = page.evaluate("() => window.plumRenderer || null", None::<&()>).await.unwrap_or(None);
|
|
367
408
|
let mut measured = None;
|
|
368
409
|
let mut phases = None;
|
|
@@ -396,6 +437,7 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
|
|
|
396
437
|
let colours = distinct_colours(&b, 1000);
|
|
397
438
|
let diff = differing_pixels(&a, &b);
|
|
398
439
|
let errs = errors.lock().unwrap().clone();
|
|
440
|
+
let mut failures_pre: Vec<String> = vec![];
|
|
399
441
|
|
|
400
442
|
let mut report = format!(
|
|
401
443
|
"canvas {}x{}, distinct colours: {colours}{}, pixels changed over 1s: {}\naudio: {}",
|
|
@@ -433,7 +475,22 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
|
|
|
433
475
|
if opts.json {
|
|
434
476
|
report.push_str(&format!("\n{}", stats_json(opts, renderer.as_deref(), measured.as_ref(), phases.as_ref())));
|
|
435
477
|
}
|
|
478
|
+
if let [bw, bh, ratio, cw, ch] = backing[..] {
|
|
479
|
+
report.push_str(&format!("\nbacking store {bw}x{bh} for CSS {cw}x{ch} at devicePixelRatio {ratio}"));
|
|
480
|
+
if (bw - (cw * ratio).round()).abs() > 1.0 || (bh - (ch * ratio).round()).abs() > 1.0 {
|
|
481
|
+
failures_pre.push(format!("backing store {bw}x{bh} is not CSS {cw}x{ch} x devicePixelRatio {ratio}"));
|
|
482
|
+
}
|
|
483
|
+
if let Some(d) = opts.dpr {
|
|
484
|
+
if (ratio - d).abs() > 1e-6 {
|
|
485
|
+
failures_pre.push(format!("--dpr {d}: the page reports devicePixelRatio {ratio}"));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if opts.lose_device {
|
|
490
|
+
report.push_str(if loss_failure.is_none() { "\ndevice loss: destroyed, restored, rendering resumed" } else { "\ndevice loss: FAILED" });
|
|
491
|
+
}
|
|
436
|
-
let mut failures =
|
|
492
|
+
let mut failures = failures_pre;
|
|
493
|
+
failures.extend(loss_failure);
|
|
437
494
|
if renderer.is_none() {
|
|
438
495
|
failures.push("the page did not set globalThis.plumRenderer (WebGPU failed to start?)".to_string());
|
|
439
496
|
}
|
plum-game/web-test/src/main.rs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
use plum_web_test::{chromium_installed, run, Options};
|
|
2
2
|
use std::path::PathBuf;
|
|
3
3
|
|
|
4
|
-
const USAGE: &str = "usage: plum-web-test <file.plum> [--frames-wait SECS] [--screenshot OUT.png] [--require-audio]\n [--assets DIR] [--headed] [--measure SECS] [--uncapped] [--json]
|
|
4
|
+
const USAGE: &str = "usage: plum-web-test <file.plum> [--frames-wait SECS] [--screenshot OUT.png] [--require-audio]\n [--assets DIR] [--headed] [--measure SECS] [--uncapped] [--json]
|
|
5
|
+
[--dpr N] [--lose-device]";
|
|
5
6
|
|
|
6
7
|
#[tokio::main]
|
|
7
8
|
async fn main() {
|
|
@@ -18,6 +19,8 @@ async fn main() {
|
|
|
18
19
|
"--headed" => opts.headed = true,
|
|
19
20
|
"--uncapped" => opts.uncapped = true,
|
|
20
21
|
"--json" => opts.json = true,
|
|
22
|
+
"--lose-device" => opts.lose_device = true,
|
|
23
|
+
"--dpr" => opts.dpr = Some(args.next().and_then(|v| v.parse().ok()).unwrap_or_else(|| fail("--dpr needs a number"))),
|
|
21
24
|
"--measure" => opts.measure = Some(args.next().and_then(|v| v.parse().ok()).unwrap_or_else(|| fail("--measure needs a number of seconds"))),
|
|
22
25
|
"--assets" => opts.assets = Some(args.next().map(PathBuf::from).unwrap_or_else(|| fail("--assets needs a directory"))),
|
|
23
26
|
"-h" | "--help" => {
|
plum-game/web/plum_bridge.js
CHANGED
|
@@ -280,8 +280,9 @@
|
|
|
280
280
|
let texSeq = 0;
|
|
281
281
|
const white = { w: 1, h: 1, white: true, uid: texSeq++ };
|
|
282
282
|
const textures = []; // handle -> texture object { w, h, uid, smooth, ... }
|
|
283
|
-
let sw = 1;
|
|
283
|
+
let sw = 1; // screen size in CSS pixels (the unit of game coordinates)
|
|
284
284
|
let sh = 1;
|
|
285
|
+
let dpr = 1; // device pixels per CSS pixel
|
|
285
286
|
const cam = { x: 0, y: 0, s: 1 };
|
|
286
287
|
// world -> clip: X = x * mx + bx, Y = y * my + by
|
|
287
288
|
let mx = 2;
|
|
@@ -373,10 +374,11 @@
|
|
|
373
374
|
white,
|
|
374
375
|
cam,
|
|
375
376
|
clearColor,
|
|
376
|
-
// Per-frame state; (w, h) is the screen size in pixels.
|
|
377
|
+
// Per-frame state; (w, h) is the screen size in CSS pixels.
|
|
377
|
-
beginFrame(w, h) {
|
|
378
|
+
beginFrame(w, h, ratio) {
|
|
378
379
|
sw = w;
|
|
379
380
|
sh = h;
|
|
381
|
+
dpr = ratio;
|
|
380
382
|
vn = 0;
|
|
381
383
|
inn = 0;
|
|
382
384
|
nb = 0;
|
|
@@ -416,7 +418,7 @@
|
|
|
416
418
|
},
|
|
417
419
|
drawCircle(x, y, rad, r, g, b, a) {
|
|
418
420
|
if (!(rad > 0)) return;
|
|
419
|
-
const n = Math.min(MAX_SEGMENTS, Math.max(24, Math.ceil(rad * cam.s * 0.6)));
|
|
421
|
+
const n = Math.min(MAX_SEGMENTS, Math.max(24, Math.ceil(rad * cam.s * dpr * 0.6)));
|
|
420
422
|
reserve(n + 1, n * 3);
|
|
421
423
|
useTex(white);
|
|
422
424
|
const c = packColor(r, g, b, a);
|
|
@@ -433,13 +435,13 @@
|
|
|
433
435
|
cur.count += n * 3;
|
|
434
436
|
},
|
|
435
437
|
// `x, y` is the text's top-left. The run is rasterised at its on-screen
|
|
436
|
-
// size (size * camera scale), so it is drawn 1:1
|
|
438
|
+
// size in device pixels (size * camera scale * dpr), so it is drawn 1:1.
|
|
437
439
|
drawText(str, x, y, size, r, g, b, a) {
|
|
438
440
|
if (!str) return;
|
|
439
|
-
const px = Math.max(1, Math.round(size * cam.s));
|
|
441
|
+
const px = Math.max(1, Math.round(size * cam.s * dpr));
|
|
440
442
|
const t = env.textTexture(str, px);
|
|
441
443
|
if (!t) return;
|
|
442
|
-
const s = cam.s;
|
|
444
|
+
const s = cam.s * dpr;
|
|
443
445
|
const x0 = x - t.pad / s;
|
|
444
446
|
const y0 = y - t.pad / s;
|
|
445
447
|
const x1 = x0 + t.w / s;
|
|
@@ -538,56 +540,119 @@ ${cases} default: { return vec4f(1.0); }
|
|
|
538
540
|
}
|
|
539
541
|
const WGSL = makeWGSL();
|
|
540
542
|
|
|
543
|
+
// The WebGPU backend. Everything that belongs to the device lives in `setup`, so a
|
|
544
|
+
// lost device is replaced by requesting a new one, building that state again and
|
|
545
|
+
// re-uploading every live texture from its kept source (an ImageBitmap or pixel
|
|
546
|
+
// data; render-target contents are lost and start transparent).
|
|
547
|
+
// `onLost(reason, restored)` reports a loss and whether a new device was obtained.
|
|
541
|
-
async function makeWebGPU(canvas, b) {
|
|
548
|
+
async function makeWebGPU(canvas, b, onLost) {
|
|
542
549
|
const white = b.white;
|
|
543
550
|
if (!navigator.gpu) return null;
|
|
544
|
-
const adapter = await navigator.gpu.requestAdapter();
|
|
545
|
-
if (!adapter) return null;
|
|
546
|
-
const device = await adapter.requestDevice();
|
|
547
551
|
const ctx = canvas.getContext("webgpu");
|
|
548
552
|
if (!ctx) return null;
|
|
549
553
|
const format = navigator.gpu.getPreferredCanvasFormat();
|
|
550
|
-
ctx.configure({ device, format, alphaMode: "opaque" });
|
|
551
|
-
|
|
554
|
+
let device = null;
|
|
552
|
-
|
|
555
|
+
let pipeline = null;
|
|
553
|
-
|
|
556
|
+
let layout = null;
|
|
554
|
-
vertex: {
|
|
555
|
-
module,
|
|
556
|
-
entryPoint: "vs",
|
|
557
|
-
buffers: [{
|
|
558
|
-
arrayStride: VERT_BYTES,
|
|
559
|
-
attributes: [
|
|
560
|
-
{ shaderLocation: 0, format: "float32x2", offset: 0 },
|
|
561
|
-
{ shaderLocation: 1, format: "float32x2", offset: 8 },
|
|
562
|
-
{ shaderLocation: 2, format: "unorm8x4", offset: 16 },
|
|
563
|
-
{ shaderLocation: 3, format: "uint32", offset: 20 },
|
|
564
|
-
],
|
|
565
|
-
}],
|
|
566
|
-
},
|
|
567
|
-
fragment: {
|
|
568
|
-
module,
|
|
569
|
-
entryPoint: "fs",
|
|
570
|
-
|
|
557
|
+
let nearest = null;
|
|
571
|
-
format,
|
|
572
|
-
blend: {
|
|
573
|
-
color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
574
|
-
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
575
|
-
},
|
|
576
|
-
}],
|
|
577
|
-
},
|
|
578
|
-
primitive: { topology: "triangle-list" },
|
|
579
|
-
});
|
|
580
|
-
|
|
558
|
+
let linear = null;
|
|
581
|
-
const nearest = device.createSampler({ magFilter: "nearest", minFilter: "nearest" });
|
|
582
|
-
const linear = device.createSampler({ magFilter: "linear", minFilter: "linear" });
|
|
583
559
|
let vbuf = null;
|
|
584
560
|
let vsize = 0;
|
|
585
561
|
let ibuf = null;
|
|
586
562
|
let isize = 0;
|
|
563
|
+
const groups = new Map(); // bind groups by the textures of a draw's slots
|
|
564
|
+
const live = new Set(); // every texture that has GPU memory
|
|
565
|
+
|
|
566
|
+
async function acquire() {
|
|
567
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
568
|
+
return adapter ? adapter.requestDevice() : null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function setup(dev) {
|
|
572
|
+
device = dev;
|
|
573
|
+
if (globalThis.__plumExposeDevice) globalThis.__plumDevice = dev; // set by plum-web-test --lose-device
|
|
574
|
+
ctx.configure({ device, format, alphaMode: "opaque" });
|
|
575
|
+
const module = device.createShaderModule({ code: WGSL });
|
|
576
|
+
pipeline = device.createRenderPipeline({
|
|
577
|
+
layout: "auto",
|
|
578
|
+
vertex: {
|
|
579
|
+
module,
|
|
580
|
+
entryPoint: "vs",
|
|
581
|
+
buffers: [{
|
|
582
|
+
arrayStride: VERT_BYTES,
|
|
583
|
+
attributes: [
|
|
584
|
+
{ shaderLocation: 0, format: "float32x2", offset: 0 },
|
|
585
|
+
{ shaderLocation: 1, format: "float32x2", offset: 8 },
|
|
586
|
+
{ shaderLocation: 2, format: "unorm8x4", offset: 16 },
|
|
587
|
+
{ shaderLocation: 3, format: "uint32", offset: 20 },
|
|
588
|
+
],
|
|
589
|
+
}],
|
|
590
|
+
},
|
|
591
|
+
fragment: {
|
|
592
|
+
module,
|
|
593
|
+
entryPoint: "fs",
|
|
594
|
+
targets: [{
|
|
595
|
+
format,
|
|
596
|
+
blend: {
|
|
597
|
+
color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
598
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
599
|
+
},
|
|
600
|
+
}],
|
|
601
|
+
},
|
|
602
|
+
primitive: { topology: "triangle-list" },
|
|
603
|
+
});
|
|
604
|
+
layout = pipeline.getBindGroupLayout(0);
|
|
605
|
+
nearest = device.createSampler({ magFilter: "nearest", minFilter: "nearest" });
|
|
606
|
+
linear = device.createSampler({ magFilter: "linear", minFilter: "linear" });
|
|
607
|
+
vbuf = ibuf = null;
|
|
608
|
+
vsize = isize = 0;
|
|
609
|
+
groups.clear();
|
|
610
|
+
for (const t of live) allocate(t);
|
|
611
|
+
device.lost.then((info) => recover(info.reason || "unknown"));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Gives `t` GPU memory on the current device and uploads its source.
|
|
615
|
+
function allocate(t) {
|
|
616
|
+
const gpu = device.createTexture({
|
|
617
|
+
size: [t.w, t.h],
|
|
618
|
+
format: "rgba8unorm",
|
|
619
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
620
|
+
});
|
|
621
|
+
const src = t.source;
|
|
622
|
+
if (t.white) device.queue.writeTexture({ texture: gpu }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]);
|
|
623
|
+
else if (src && src.data) device.queue.writeTexture({ texture: gpu }, src.data, { bytesPerRow: t.w * 4 }, [t.w, t.h]);
|
|
624
|
+
else if (src) device.queue.copyExternalImageToTexture({ source: src }, { texture: gpu }, [t.w, t.h]);
|
|
625
|
+
t.gpu = gpu;
|
|
626
|
+
t.view = gpu.createView();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
let recovering = false;
|
|
630
|
+
async function recover(reason) {
|
|
631
|
+
recovering = true;
|
|
632
|
+
let dev = null;
|
|
633
|
+
try {
|
|
634
|
+
dev = await acquire();
|
|
635
|
+
} catch (e) {
|
|
636
|
+
console.error("GPU device request failed:", e);
|
|
637
|
+
}
|
|
638
|
+
if (!dev) {
|
|
639
|
+
onLost(reason, false);
|
|
640
|
+
return; // stays lost: submit draws nothing
|
|
641
|
+
}
|
|
642
|
+
setup(dev);
|
|
643
|
+
recovering = false;
|
|
644
|
+
onLost(reason, true);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function createTexture(source, w, h, smooth, uid) {
|
|
648
|
+
const t = { w, h, uid, source, smooth: !!smooth, bid: 0, slot: 0 };
|
|
649
|
+
live.add(t);
|
|
650
|
+
if (!recovering) allocate(t);
|
|
651
|
+
return t;
|
|
652
|
+
}
|
|
587
653
|
|
|
588
654
|
// Bind groups by the textures of a draw's slots (unused slots hold the white texture).
|
|
589
|
-
const groups = new Map();
|
|
590
|
-
function bindGroup(batch
|
|
655
|
+
function bindGroup(batch) {
|
|
591
656
|
let key = "";
|
|
592
657
|
for (let i = 0; i < SLOTS; i++) key += (i < batch.n ? batch.slots[i].uid : white.uid) + ",";
|
|
593
658
|
let bg = groups.get(key);
|
|
@@ -600,18 +665,16 @@ ${cases} default: { return vec4f(1.0); }
|
|
|
600
665
|
}
|
|
601
666
|
return bg;
|
|
602
667
|
}
|
|
603
|
-
|
|
668
|
+
|
|
604
|
-
|
|
669
|
+
const first = await acquire();
|
|
670
|
+
if (!first) return null;
|
|
671
|
+
white.white = true;
|
|
605
|
-
|
|
672
|
+
white.w = 1;
|
|
673
|
+
white.h = 1;
|
|
606
|
-
|
|
674
|
+
white.source = null;
|
|
607
|
-
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
608
|
-
});
|
|
609
|
-
if (source && source.data) device.queue.writeTexture({ texture: gpu }, source.data, { bytesPerRow: w * 4 }, [w, h]);
|
|
610
|
-
else if (source) device.queue.copyExternalImageToTexture({ source }, { texture: gpu }, [w, h]);
|
|
611
|
-
else device.queue.writeTexture({ texture: gpu }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]);
|
|
612
|
-
return { w, h, uid, gpu, view: gpu.createView(), smooth: !!smooth, bid: 0, slot: 0 };
|
|
613
|
-
}
|
|
614
|
-
|
|
675
|
+
white.smooth = false;
|
|
676
|
+
live.add(white);
|
|
677
|
+
setup(first);
|
|
615
678
|
|
|
616
679
|
return {
|
|
617
680
|
name: "gpu-webgpu",
|
|
@@ -620,10 +683,13 @@ ${cases} default: { return vec4f(1.0); }
|
|
|
620
683
|
t.smooth = !!smooth;
|
|
621
684
|
},
|
|
622
685
|
destroyTexture(t) {
|
|
686
|
+
live.delete(t);
|
|
623
687
|
groups.clear();
|
|
624
688
|
t.gpu.destroy();
|
|
625
689
|
},
|
|
690
|
+
// Draws the batcher's frame; returns the number of draw calls issued.
|
|
626
691
|
submit(b) {
|
|
692
|
+
if (recovering || !device) return 0;
|
|
627
693
|
const vbytes = b.vertexCount * VERT_BYTES;
|
|
628
694
|
const ibytes = b.indexCount * 4;
|
|
629
695
|
if (vbytes > vsize) {
|
|
@@ -656,12 +722,13 @@ ${cases} default: { return vec4f(1.0); }
|
|
|
656
722
|
pass.setIndexBuffer(ibuf, "uint32");
|
|
657
723
|
for (let i = 0; i < b.batchCount; i++) {
|
|
658
724
|
const bt = b.batches[i];
|
|
659
|
-
pass.setBindGroup(0, bindGroup(bt
|
|
725
|
+
pass.setBindGroup(0, bindGroup(bt));
|
|
660
726
|
pass.drawIndexed(bt.count, 1, bt.start);
|
|
661
727
|
}
|
|
662
728
|
}
|
|
663
729
|
pass.end();
|
|
664
730
|
device.queue.submit([enc.finish()]);
|
|
731
|
+
globalThis.plumFrames = (globalThis.plumFrames || 0) + 1;
|
|
665
732
|
return vbytes ? b.batchCount : 0;
|
|
666
733
|
},
|
|
667
734
|
};
|
|
@@ -723,26 +790,40 @@ ${cases} default: { return vec4f(1.0); }
|
|
|
723
790
|
|
|
724
791
|
async function main() {
|
|
725
792
|
const canvas = document.getElementById("glcanvas");
|
|
793
|
+
// Game coordinates are CSS pixels; the backing store is CSS size x devicePixelRatio.
|
|
794
|
+
const view = { w: 1, h: 1, dpr: 1 };
|
|
726
795
|
const size = () => {
|
|
796
|
+
view.dpr = window.devicePixelRatio || 1;
|
|
727
|
-
|
|
797
|
+
view.w = Math.max(1, canvas.clientWidth || window.innerWidth | 0);
|
|
798
|
+
view.h = Math.max(1, canvas.clientHeight || window.innerHeight | 0);
|
|
799
|
+
const w = Math.max(1, Math.round(view.w * view.dpr));
|
|
728
|
-
const h = Math.max(1,
|
|
800
|
+
const h = Math.max(1, Math.round(view.h * view.dpr));
|
|
729
801
|
if (canvas.width !== w) canvas.width = w;
|
|
730
802
|
if (canvas.height !== h) canvas.height = h;
|
|
731
803
|
};
|
|
732
804
|
size();
|
|
805
|
+
const message = (text, color) =>
|
|
806
|
+
document.body.append(Object.assign(document.createElement("pre"), { textContent: text, style: "position:fixed;top:0;left:0;color:" + color + ";margin:8px;font-size:18px" }));
|
|
733
807
|
|
|
734
808
|
let backend = null;
|
|
735
809
|
const text = makeTextCache(() => backend);
|
|
736
810
|
const b = makeBatcher({ textTexture: text.textTexture });
|
|
737
811
|
try {
|
|
738
|
-
backend = await makeWebGPU(canvas, b)
|
|
812
|
+
backend = await makeWebGPU(canvas, b, (reason, restored) => {
|
|
813
|
+
if (restored) {
|
|
814
|
+
console.log("GPU device lost (" + reason + "); restored");
|
|
815
|
+
} else {
|
|
816
|
+
console.error("GPU device lost (" + reason + "); no new device");
|
|
817
|
+
message("The GPU device was lost (" + reason + ") and could not be restored. Reload the page.", "#f55");
|
|
818
|
+
}
|
|
819
|
+
});
|
|
739
820
|
} catch (e) {
|
|
740
821
|
console.error("WebGPU failed to start:", e);
|
|
741
822
|
}
|
|
742
823
|
if (!backend) {
|
|
743
824
|
const msg = "WebGPU is not supported in this browser";
|
|
744
825
|
console.error(msg);
|
|
745
|
-
|
|
826
|
+
message(msg, "#f55");
|
|
746
827
|
return;
|
|
747
828
|
}
|
|
748
829
|
globalThis.plumRenderer = backend.name;
|
|
@@ -772,8 +853,8 @@ ${cases} default: { return vec4f(1.0); }
|
|
|
772
853
|
let dt = 1 / 60;
|
|
773
854
|
|
|
774
855
|
const host = {
|
|
775
|
-
screenWidth: () =>
|
|
856
|
+
screenWidth: () => view.w,
|
|
776
|
-
screenHeight: () =>
|
|
857
|
+
screenHeight: () => view.h,
|
|
777
858
|
frameTime: () => dt,
|
|
778
859
|
clear: b.clear,
|
|
779
860
|
drawRect: b.drawRect,
|
|
@@ -851,14 +932,14 @@ ${cases} default: { return vec4f(1.0); }
|
|
|
851
932
|
last = now;
|
|
852
933
|
size();
|
|
853
934
|
input.beginFrame();
|
|
854
|
-
b.beginFrame(
|
|
935
|
+
b.beginFrame(view.w, view.h, view.dpr);
|
|
855
936
|
const m = globalThis.__plumMeasure; // set by plum-web-test --measure; null otherwise
|
|
856
937
|
const t0 = m ? performance.now() : 0;
|
|
857
938
|
ex.update(state, dt);
|
|
858
939
|
const t1 = m ? performance.now() : 0;
|
|
859
940
|
ex.draw(state);
|
|
860
941
|
const t2 = m ? performance.now() : 0;
|
|
861
|
-
const draws = backend.submit(b
|
|
942
|
+
const draws = backend.submit(b);
|
|
862
943
|
if (m) { m.n++; m.u += t1 - t0; m.d += t2 - t1; m.r += performance.now() - t2; m.dr += draws; }
|
|
863
944
|
text.endFrame();
|
|
864
945
|
} catch (e) {
|