plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
2c0eaf2
— Peter John
2026-11-02T16:17:17+05:30
Add the gpu web renderer: own WebGPU batcher with a command-buffer decoder
- plum-cli/src/game.rs +16 -0
- plum-cli/src/main.rs +1 -1
- plum-cli/tests/game_web_test.rs +20 -0
- plum-game/web-test/README.md +1 -1
- plum-game/web-test/src/lib.rs +37 -2
- plum-game/web-test/src/main.rs +4 -2
- plum-game/web-test/tests/web_check.rs +17 -0
- plum-game/web/gpu_cmd_check.mjs +93 -0
- plum-game/web/index_gpu.html +17 -0
- plum-game/web/plum_bridge_gpu.js +904 -0
plum-cli/src/game.rs
CHANGED
|
@@ -35,6 +35,8 @@ pub enum Renderer {
|
|
|
35
35
|
Macroquad,
|
|
36
36
|
/// PixiJS, driven straight from the Plum module (experimental)
|
|
37
37
|
Pixi,
|
|
38
|
+
/// our own batching renderer, WebGPU with a WebGL2 fallback, no library (experimental)
|
|
39
|
+
Gpu,
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
pub fn cmdGame(
|
|
@@ -58,6 +60,7 @@ pub fn cmdGame(
|
|
|
58
60
|
return match renderer {
|
|
59
61
|
Renderer::Macroquad => writeWeb(&wasm, &dist, &assets),
|
|
60
62
|
Renderer::Pixi => writeWebPixi(&wasm, &dist, &assets),
|
|
63
|
+
Renderer::Gpu => writeWebGpu(&wasm, &dist, &assets),
|
|
61
64
|
};
|
|
62
65
|
}
|
|
63
66
|
runNative(&wasm, &assets)
|
|
@@ -236,3 +239,16 @@ fn writeWebPixi(wasm: &[u8], dist: &Path, assets: &Path) -> Result<()> {
|
|
|
236
239
|
eprintln!("wrote web build (pixi) to {}", dist.display());
|
|
237
240
|
Ok(())
|
|
238
241
|
}
|
|
242
|
+
|
|
243
|
+
/// The own-batcher build needs no host wasm and no library: the page, the bridge and the Plum module.
|
|
244
|
+
fn writeWebGpu(wasm: &[u8], dist: &Path, assets: &Path) -> Result<()> {
|
|
245
|
+
fs::create_dir_all(dist)?;
|
|
246
|
+
let web = repoRoot().join("plum-game/web");
|
|
247
|
+
fs::copy(web.join("index_gpu.html"), dist.join("index.html"))
|
|
248
|
+
.context("failed to copy index_gpu.html")?;
|
|
249
|
+
fs::copy(web.join("plum_bridge_gpu.js"), dist.join("plum_bridge_gpu.js"))
|
|
250
|
+
.context("failed to copy plum_bridge_gpu.js")?;
|
|
251
|
+
writeWebAssets(wasm, dist, assets)?;
|
|
252
|
+
eprintln!("wrote web build (gpu) to {}", dist.display());
|
|
253
|
+
Ok(())
|
|
254
|
+
}
|
plum-cli/src/main.rs
CHANGED
|
@@ -83,7 +83,7 @@ enum Command {
|
|
|
83
83
|
/// Write a static web build instead of opening a window
|
|
84
84
|
#[arg(long)]
|
|
85
85
|
web: bool,
|
|
86
|
-
/// Browser renderer for --web: macroquad (default) or
|
|
86
|
+
/// Browser renderer for --web: macroquad (default), pixi or gpu (both experimental)
|
|
87
87
|
#[arg(long, value_enum, default_value = "macroquad")]
|
|
88
88
|
renderer: game::Renderer,
|
|
89
89
|
/// Output directory for --web (default: `dist` beside the source file)
|
plum-cli/tests/game_web_test.rs
CHANGED
|
@@ -102,3 +102,23 @@ fn pixiWebBuildNeedsNoHostWasm() {
|
|
|
102
102
|
checkAssets(&dist);
|
|
103
103
|
let _ = std::fs::remove_dir_all(&scratch);
|
|
104
104
|
}
|
|
105
|
+
|
|
106
|
+
#[test]
|
|
107
|
+
fn gpuWebBuildNeedsNoHostWasmOrLibrary() {
|
|
108
|
+
let (dist, scratch) = exportFixture(Some("gpu"));
|
|
109
|
+
for f in ["index.html", "plum_bridge_gpu.js", "plum.wasm", "preload.txt"] {
|
|
110
|
+
assert!(dist.join(f).is_file(), "missing {f}");
|
|
111
|
+
}
|
|
112
|
+
for f in [
|
|
113
|
+
"mq_js_bundle.js",
|
|
114
|
+
"plum-game.wasm",
|
|
115
|
+
"plum_bridge.js",
|
|
116
|
+
"pixi.min.js",
|
|
117
|
+
] {
|
|
118
|
+
assert!(!dist.join(f).exists(), "unexpected {f}");
|
|
119
|
+
}
|
|
120
|
+
let html = std::fs::read_to_string(dist.join("index.html")).unwrap();
|
|
121
|
+
assert!(html.contains("plum_bridge_gpu.js") && !html.contains("pixi"));
|
|
122
|
+
checkAssets(&dist);
|
|
123
|
+
let _ = std::fs::remove_dir_all(&scratch);
|
|
124
|
+
}
|
plum-game/web-test/README.md
CHANGED
|
@@ -21,7 +21,7 @@ Downloads the Chromium build matching the bundled Playwright driver (about 100 M
|
|
|
21
21
|
|
|
22
22
|
cargo run --manifest-path plum-game/web-test/Cargo.toml -- plum-examples/game.plum --screenshot /tmp/orb-run.png
|
|
23
23
|
|
|
24
|
-
Options: `--frames-wait SECS` (default 8), `--screenshot OUT.png`, `--require-audio`, `--renderer pixi|macroquad` (default macroquad), `--headed` (a real GPU window instead of headless software GL), `--measure SECS` (records requestAnimationFrame intervals in the page and prints avg / p50 / p95 / p99 ms, fps and the renderer Pixi chose from `plumRenderer`) and `--uncapped` (lifts vsync so the frame time is the work per frame). `fixtures/stress.plum` is the scene behind [BENCHMARK.md](BENCHMARK.md).
|
|
24
|
+
Options: `--frames-wait SECS` (default 8), `--screenshot OUT.png`, `--require-audio`, `--renderer pixi|macroquad|gpu` (default macroquad), `--json` (adds a one-line JSON object with the renderer, backend and frame stats), `--headed` (a real GPU window instead of headless software GL), `--measure SECS` (records requestAnimationFrame intervals in the page and prints avg / p50 / p95 / p99 ms, fps and the renderer Pixi chose from `plumRenderer`) and `--uncapped` (lifts vsync so the frame time is the work per frame). `fixtures/stress.plum` is the scene behind [BENCHMARK.md](BENCHMARK.md).
|
|
25
25
|
Exit code is non-zero with a message on failure.
|
|
26
26
|
|
|
27
27
|
`cargo test --manifest-path plum-game/web-test/Cargo.toml` runs the same check on
|
plum-game/web-test/src/lib.rs
CHANGED
|
@@ -13,6 +13,7 @@ use std::time::Duration;
|
|
|
13
13
|
pub enum Renderer {
|
|
14
14
|
Macroquad,
|
|
15
15
|
Pixi,
|
|
16
|
+
Gpu,
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
impl Renderer {
|
|
@@ -20,6 +21,7 @@ impl Renderer {
|
|
|
20
21
|
match self {
|
|
21
22
|
Renderer::Macroquad => "macroquad",
|
|
22
23
|
Renderer::Pixi => "pixi",
|
|
24
|
+
Renderer::Gpu => "gpu",
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
27
|
}
|
|
@@ -33,6 +35,8 @@ pub struct Options {
|
|
|
33
35
|
pub headed: bool,
|
|
34
36
|
/// Record requestAnimationFrame intervals for this many seconds and report them.
|
|
35
37
|
pub measure: Option<f64>,
|
|
38
|
+
/// Append a one-line JSON object with the renderer and frame statistics to the report.
|
|
39
|
+
pub json: bool,
|
|
36
40
|
/// Lift Chromium's vsync / frame-rate cap so frame times show the work per frame.
|
|
37
41
|
pub uncapped: bool,
|
|
38
42
|
}
|
|
@@ -46,6 +50,7 @@ impl Default for Options {
|
|
|
46
50
|
renderer: Renderer::Macroquad,
|
|
47
51
|
headed: false,
|
|
48
52
|
measure: None,
|
|
53
|
+
json: false,
|
|
49
54
|
uncapped: false,
|
|
50
55
|
}
|
|
51
56
|
}
|
|
@@ -78,6 +83,29 @@ pub fn frame_stats(stamps: &[f64]) -> Option<FrameStats> {
|
|
|
78
83
|
Some(FrameStats { frames: d.len(), avg: d.iter().sum::<f64>() / d.len() as f64, p50: pct(0.50), p95: pct(0.95), p99: pct(0.99) })
|
|
79
84
|
}
|
|
80
85
|
|
|
86
|
+
fn json_str(s: &str) -> String {
|
|
87
|
+
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// One JSON object: renderer, backend (`plumRenderer`), headed, uncapped and the frame statistics.
|
|
91
|
+
pub fn stats_json(opts: &Options, backend: Option<&str>, st: Option<&FrameStats>) -> String {
|
|
92
|
+
let mut s = format!(
|
|
93
|
+
"{{\"renderer\":{},\"backend\":{},\"headed\":{},\"uncapped\":{}",
|
|
94
|
+
json_str(opts.renderer.name()),
|
|
95
|
+
backend.map(json_str).unwrap_or_else(|| "null".into()),
|
|
96
|
+
opts.headed,
|
|
97
|
+
opts.uncapped
|
|
98
|
+
);
|
|
99
|
+
match st {
|
|
100
|
+
Some(st) => s.push_str(&format!(
|
|
101
|
+
",\"frames\":{},\"avg_ms\":{:.3},\"p50_ms\":{:.3},\"p95_ms\":{:.3},\"p99_ms\":{:.3},\"fps\":{:.2}}}",
|
|
102
|
+
st.frames, st.avg, st.p50, st.p95, st.p99, st.fps()
|
|
103
|
+
)),
|
|
104
|
+
None => s.push('}'),
|
|
105
|
+
}
|
|
106
|
+
s
|
|
107
|
+
}
|
|
108
|
+
|
|
81
109
|
/// Whether `playwright install chromium` has been run (browser cache dir exists).
|
|
82
110
|
pub fn chromium_installed() -> bool {
|
|
83
111
|
let dir = if let Ok(p) = std::env::var("PLAYWRIGHT_BROWSERS_PATH") {
|
|
@@ -269,6 +297,10 @@ async fn check(port: u16, opts: &Options) -> Result<String, String> {
|
|
|
269
297
|
let mut args: Vec<String> = ["--ignore-gpu-blocklist", "--autoplay-policy=no-user-gesture-required"].map(String::from).to_vec();
|
|
270
298
|
if !opts.headed {
|
|
271
299
|
args.extend(["--use-angle=swiftshader", "--enable-unsafe-swiftshader"].map(String::from));
|
|
300
|
+
if opts.renderer == Renderer::Gpu {
|
|
301
|
+
// The gpu renderer is WebGPU only: give headless Chromium a software adapter.
|
|
302
|
+
args.extend(["--enable-unsafe-webgpu", "--enable-features=Vulkan", "--use-webgpu-adapter=swiftshader"].map(String::from));
|
|
303
|
+
}
|
|
272
304
|
}
|
|
273
305
|
if opts.uncapped {
|
|
274
306
|
args.extend(["--disable-gpu-vsync", "--disable-frame-rate-limit"].map(String::from));
|
|
@@ -383,9 +415,12 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
|
|
|
383
415
|
} else if opts.measure.is_some() {
|
|
384
416
|
report.push_str("\nframes: none recorded");
|
|
385
417
|
}
|
|
418
|
+
if opts.json {
|
|
419
|
+
report.push_str(&format!("\n{}", stats_json(opts, renderer.as_deref(), measured.as_ref())));
|
|
420
|
+
}
|
|
386
421
|
let mut failures = vec![];
|
|
387
|
-
if opts.renderer
|
|
422
|
+
if matches!(opts.renderer, Renderer::Pixi | Renderer::Gpu) && renderer.is_none() {
|
|
388
|
-
failures.push("
|
|
423
|
+
failures.push(format!("{} build did not set globalThis.plumRenderer", opts.renderer.name()));
|
|
389
424
|
}
|
|
390
425
|
if colours <= 1 {
|
|
391
426
|
failures.push("canvas is blank (uniformly one colour)".to_string());
|
plum-game/web-test/src/main.rs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
use plum_web_test::{chromium_installed, run, Options, Renderer};
|
|
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 [--renderer pixi|macroquad] [--headed] [--measure SECS] [--uncapped]";
|
|
4
|
+
const USAGE: &str = "usage: plum-web-test <file.plum> [--frames-wait SECS] [--screenshot OUT.png] [--require-audio]\n [--renderer pixi|macroquad|gpu] [--headed] [--measure SECS] [--uncapped] [--json]";
|
|
5
5
|
|
|
6
6
|
#[tokio::main]
|
|
7
7
|
async fn main() {
|
|
@@ -17,12 +17,14 @@ async fn main() {
|
|
|
17
17
|
"--require-audio" => opts.require_audio = true,
|
|
18
18
|
"--headed" => opts.headed = true,
|
|
19
19
|
"--uncapped" => opts.uncapped = true,
|
|
20
|
+
"--json" => opts.json = true,
|
|
20
21
|
"--measure" => opts.measure = Some(args.next().and_then(|v| v.parse().ok()).unwrap_or_else(|| fail("--measure needs a number of seconds"))),
|
|
21
22
|
"--renderer" => {
|
|
22
23
|
opts.renderer = match args.next().as_deref() {
|
|
23
24
|
Some("pixi") => Renderer::Pixi,
|
|
25
|
+
Some("gpu") => Renderer::Gpu,
|
|
24
26
|
Some("macroquad") => Renderer::Macroquad,
|
|
25
|
-
_ => fail("--renderer needs pixi or
|
|
27
|
+
_ => fail("--renderer needs pixi, macroquad or gpu"),
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
30
|
"-h" | "--help" => {
|
plum-game/web-test/tests/web_check.rs
CHANGED
|
@@ -10,6 +10,23 @@ fn orb_run_renders_and_animates_on_pixi() {
|
|
|
10
10
|
orb_run(Renderer::Pixi);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
#[test]
|
|
14
|
+
fn orb_run_renders_and_animates_on_gpu() {
|
|
15
|
+
orb_run(Renderer::Gpu);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#[test]
|
|
19
|
+
fn stats_json_is_one_object() {
|
|
20
|
+
use plum_web_test::{stats_json, FrameStats, Options};
|
|
21
|
+
let st = FrameStats { frames: 3, avg: 2.0, p50: 2.0, p95: 3.0, p99: 3.0 };
|
|
22
|
+
let o = Options { renderer: Renderer::Gpu, headed: true, ..Options::default() };
|
|
23
|
+
assert_eq!(
|
|
24
|
+
stats_json(&o, Some("gpu-webgpu"), Some(&st)),
|
|
25
|
+
r#"{"renderer":"gpu","backend":"gpu-webgpu","headed":true,"uncapped":false,"frames":3,"avg_ms":2.000,"p50_ms":2.000,"p95_ms":3.000,"p99_ms":3.000,"fps":500.00}"#
|
|
26
|
+
);
|
|
27
|
+
assert_eq!(stats_json(&o, None, None), r#"{"renderer":"gpu","backend":null,"headed":true,"uncapped":false}"#);
|
|
28
|
+
}
|
|
29
|
+
|
|
13
30
|
fn orb_run(renderer: Renderer) {
|
|
14
31
|
if !chromium_installed() {
|
|
15
32
|
eprintln!("SKIP: Playwright Chromium not installed (see plum-game/web-test/README.md)");
|
plum-game/web/gpu_cmd_check.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Usage: node gpu_cmd_check.mjs
|
|
2
|
+
//
|
|
3
|
+
// Hand-encodes one command of each opcode into an ArrayBuffer, decodes it with
|
|
4
|
+
// plum_bridge_gpu.js's decoder and checks the batcher's vertex/index/batch counts.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import vm from "node:vm";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
vm.runInThisContext(readFileSync(join(here, "plum_bridge_gpu.js"), "utf8"));
|
|
13
|
+
const { makeBatcher, decodeCommands } = globalThis.PlumGpu;
|
|
14
|
+
|
|
15
|
+
// --- encoder ---------------------------------------------------------------------
|
|
16
|
+
const buf = new ArrayBuffer(4096);
|
|
17
|
+
const dv = new DataView(buf);
|
|
18
|
+
let p = 0;
|
|
19
|
+
const u = (v) => { dv.setUint32(p, v, true); p += 4; };
|
|
20
|
+
const i = (v) => { dv.setInt32(p, v, true); p += 4; };
|
|
21
|
+
const f = (...vs) => { for (const v of vs) { dv.setFloat32(p, v, true); p += 4; } };
|
|
22
|
+
const text = (s) => {
|
|
23
|
+
const bytes = new TextEncoder().encode(s);
|
|
24
|
+
i(bytes.length);
|
|
25
|
+
new Uint8Array(buf, p, bytes.length).set(bytes);
|
|
26
|
+
p += (bytes.length + 3) & ~3;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
u(1); f(0.1, 0.2, 0.3, 1); // clear
|
|
30
|
+
u(2); f(10, 20, 30, 40, 1, 0, 0, 1); // rect : white batch
|
|
31
|
+
u(3); f(0, 0, 100, 0, 4, 0, 1, 0, 1); // line : white batch
|
|
32
|
+
u(4); f(50, 50, 10, 0, 0, 1, 1); // circle (24 segs): white batch
|
|
33
|
+
u(5); f(5, 6, 16, 1, 1, 1, 1); text("héllo"); // text : text batch
|
|
34
|
+
u(6); i(0); f(1, 2, 1, 1, 1, 1); // texture : tex 0 batch
|
|
35
|
+
u(7); i(0); f(0, 0, 8, 8, 10, 10, 16, 16, 0.5); i(1); f(1, 1, 1, 1); // textureEx: same batch, rotated, flipped
|
|
36
|
+
u(8); f(100, 50, 2); // setCamera
|
|
37
|
+
u(2); f(0, 0, 1, 1, 1, 1, 1, 1); // rect after camera: white batch again
|
|
38
|
+
u(9); // resetCamera
|
|
39
|
+
const len = p;
|
|
40
|
+
|
|
41
|
+
const texts = [];
|
|
42
|
+
const b = makeBatcher({
|
|
43
|
+
textTexture: (s, px) => {
|
|
44
|
+
const t = { w: s.length * px, h: px + 2, pad: 1 };
|
|
45
|
+
texts.push([s, px]);
|
|
46
|
+
return t;
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
b.textures.push({ w: 16, h: 16 });
|
|
50
|
+
b.beginFrame(800, 600);
|
|
51
|
+
|
|
52
|
+
const n = decodeCommands(new DataView(buf, 0, len), b);
|
|
53
|
+
assert.equal(n, 10, "command count");
|
|
54
|
+
assert.deepEqual(Array.from(b.clearColor).map((v) => Math.round(v * 10)), [1, 2, 3, 10]);
|
|
55
|
+
assert.deepEqual(texts, [["héllo", 16]], "text decoded as UTF-8 at size 16");
|
|
56
|
+
// rect 4v/6i, line 4/6, circle 25/72, text 4/6, texture 4/6, textureEx 4/6, rect 4/6
|
|
57
|
+
assert.equal(b.vertexCount, 4 + 4 + 25 + 4 + 4 + 4 + 4);
|
|
58
|
+
assert.equal(b.indexCount, 6 + 6 + 72 + 6 + 6 + 6 + 6);
|
|
59
|
+
// batches: white(rect,line,circle) | text | tex0(texture,textureEx) | white(rect)
|
|
60
|
+
assert.equal(b.batchCount, 4);
|
|
61
|
+
assert.deepEqual(Array.from({ length: 4 }, (_, k) => b.batches[k].count), [84, 6, 12, 6]);
|
|
62
|
+
assert.equal(b.batches[0].tex, b.white);
|
|
63
|
+
assert.equal(b.batches[2].tex, b.textures[0]);
|
|
64
|
+
assert.equal(b.batches[3].tex, b.white);
|
|
65
|
+
// every index points at an existing vertex
|
|
66
|
+
for (let k = 0; k < b.indexCount; k++) assert.ok(b.idx[k] < b.vertexCount);
|
|
67
|
+
// the first rect (10,20)-(40,60) in an 800x600 identity camera -> clip coordinates
|
|
68
|
+
assert.ok(Math.abs(b.f32[0] - (10 * 2 / 800 - 1)) < 1e-6);
|
|
69
|
+
assert.ok(Math.abs(b.f32[1] - (1 - 20 * 2 / 600)) < 1e-6);
|
|
70
|
+
|
|
71
|
+
// a clear mid-frame discards earlier geometry
|
|
72
|
+
const b2 = makeBatcher({ textTexture: () => null });
|
|
73
|
+
b2.beginFrame(100, 100);
|
|
74
|
+
b2.drawRect(0, 0, 1, 1, 1, 1, 1, 1);
|
|
75
|
+
b2.clear(0, 0, 0, 1);
|
|
76
|
+
assert.equal(b2.vertexCount, 0);
|
|
77
|
+
assert.equal(b2.batchCount, 0);
|
|
78
|
+
|
|
79
|
+
// growth past the initial capacity keeps earlier vertices
|
|
80
|
+
const b3 = makeBatcher({ textTexture: () => null });
|
|
81
|
+
b3.beginFrame(100, 100);
|
|
82
|
+
for (let k = 0; k < 10000; k++) b3.drawRect(k, 0, 1, 1, 1, 1, 1, 1);
|
|
83
|
+
assert.equal(b3.vertexCount, 40000);
|
|
84
|
+
assert.equal(b3.batchCount, 1);
|
|
85
|
+
assert.equal(b3.batches[0].count, 60000);
|
|
86
|
+
assert.ok(Math.abs(b3.f32[0] - (-1)) < 1e-6);
|
|
87
|
+
|
|
88
|
+
// truncated / unknown input is rejected
|
|
89
|
+
const bad = new DataView(new ArrayBuffer(4));
|
|
90
|
+
bad.setUint32(0, 99, true);
|
|
91
|
+
assert.throws(() => decodeCommands(bad, b2), /unknown opcode 99/);
|
|
92
|
+
|
|
93
|
+
console.log("gpu_cmd_check: ok");
|
plum-game/web/index_gpu.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Plum Game</title>
|
|
7
|
+
<link rel="icon" href="data:,">
|
|
8
|
+
<style>
|
|
9
|
+
html, body, canvas { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #000; }
|
|
10
|
+
canvas:focus { outline: none; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<canvas id="glcanvas" tabindex="1"></canvas>
|
|
15
|
+
<script src="plum_bridge_gpu.js"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
plum-game/web/plum_bridge_gpu.js
ADDED
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
// Own batching renderer for a Plum game: plum.wasm calls this file directly (no
|
|
2
|
+
// second wasm module, no library). Every draw becomes quads or triangle fans
|
|
3
|
+
// written into one CPU vertex/index array (camera applied on the CPU), flushed
|
|
4
|
+
// with one buffer upload per frame and one draw call per run of the same texture.
|
|
5
|
+
// Untextured shapes sample a 1x1 white texture. The backend is WebGPU where the
|
|
6
|
+
// browser has it; there is no fallback (a page without navigator.gpu says so).
|
|
7
|
+
//
|
|
8
|
+
// Two input modes share the batcher: per-call (each Game.plum draw extern appends
|
|
9
|
+
// vertices) and, when the module exports `cmdPtr`, `cmdLen`, `cmdReset` and
|
|
10
|
+
// `memory`, a command buffer decoded once per frame (see decodeCommands).
|
|
11
|
+
// Plum's `Str` is a GC struct JS can't read: it is read through the module's
|
|
12
|
+
// `strLen`/`strByte` exports and built with `strNew`/`strSetByte`.
|
|
13
|
+
(function () {
|
|
14
|
+
"use strict";
|
|
15
|
+
|
|
16
|
+
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
|
17
|
+
const SOUND_EXT = /\.(wav|ogg)$/i;
|
|
18
|
+
// --- paths --------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
// The asset-root-relative form of `p`, or null if it is empty, absolute or escapes the root.
|
|
21
|
+
function normPath(p) {
|
|
22
|
+
if (!p || p.includes("\\") || p.startsWith("/")) return null;
|
|
23
|
+
const parts = [];
|
|
24
|
+
for (const c of p.split("/")) {
|
|
25
|
+
if (c === "" || c === ".") continue;
|
|
26
|
+
if (c === "..") return null;
|
|
27
|
+
parts.push(c);
|
|
28
|
+
}
|
|
29
|
+
return parts.length ? parts.join("/") : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// --- Plum Str -------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
function strToJs(plum, s) {
|
|
35
|
+
const n = plum.strLen(s);
|
|
36
|
+
const bytes = new Uint8Array(n);
|
|
37
|
+
for (let i = 0; i < n; i++) bytes[i] = plum.strByte(s, i);
|
|
38
|
+
return new TextDecoder().decode(bytes);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function bytesToStr(plum, bytes) {
|
|
42
|
+
const out = plum.strNew(bytes.length);
|
|
43
|
+
for (let i = 0; i < bytes.length; i++) plum.strSetByte(out, i, bytes[i]);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- input ----------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
// KeyboardEvent.code -> GLFW key number (the numbering of `Key` in Game.plum).
|
|
50
|
+
function glfwKey(code) {
|
|
51
|
+
if (code.length === 4 && code.startsWith("Key")) return code.charCodeAt(3);
|
|
52
|
+
if (code.length === 6 && code.startsWith("Digit")) return code.charCodeAt(5);
|
|
53
|
+
switch (code) {
|
|
54
|
+
case "Space": return 32;
|
|
55
|
+
case "Escape": return 256;
|
|
56
|
+
case "Enter": case "NumpadEnter": return 257;
|
|
57
|
+
case "Tab": return 258;
|
|
58
|
+
case "Backspace": return 259;
|
|
59
|
+
case "ArrowRight": return 262;
|
|
60
|
+
case "ArrowLeft": return 263;
|
|
61
|
+
case "ArrowDown": return 264;
|
|
62
|
+
case "ArrowUp": return 265;
|
|
63
|
+
case "ShiftLeft": return 340;
|
|
64
|
+
case "ControlLeft": return 341;
|
|
65
|
+
default: return -1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const MOUSE_BUTTONS = [0, 2, 1]; // DOM 0/1/2 = left/middle/right -> Mouse.Left/Middle/Right numbers 0/2/1
|
|
70
|
+
|
|
71
|
+
function makeInput(canvas) {
|
|
72
|
+
const keysDown = new Set();
|
|
73
|
+
let keysPressed = new Set();
|
|
74
|
+
let keysPending = new Set();
|
|
75
|
+
const mouseDown = new Set();
|
|
76
|
+
let mousePressed = new Set();
|
|
77
|
+
let mousePending = new Set();
|
|
78
|
+
let wheelPending = 0;
|
|
79
|
+
let wheel = 0;
|
|
80
|
+
const mouse = { x: 0, y: 0 };
|
|
81
|
+
|
|
82
|
+
window.addEventListener("keydown", (e) => {
|
|
83
|
+
const k = glfwKey(e.code);
|
|
84
|
+
if (k < 0) return;
|
|
85
|
+
if (k === 32 || k === 258 || k === 259 || (k >= 262 && k <= 265)) e.preventDefault();
|
|
86
|
+
if (!e.repeat) keysPending.add(k);
|
|
87
|
+
keysDown.add(k);
|
|
88
|
+
});
|
|
89
|
+
window.addEventListener("keyup", (e) => {
|
|
90
|
+
const k = glfwKey(e.code);
|
|
91
|
+
if (k >= 0) keysDown.delete(k);
|
|
92
|
+
});
|
|
93
|
+
window.addEventListener("blur", () => {
|
|
94
|
+
keysDown.clear();
|
|
95
|
+
mouseDown.clear();
|
|
96
|
+
});
|
|
97
|
+
const track = (e) => {
|
|
98
|
+
const r = canvas.getBoundingClientRect();
|
|
99
|
+
mouse.x = e.clientX - r.left;
|
|
100
|
+
mouse.y = e.clientY - r.top;
|
|
101
|
+
};
|
|
102
|
+
window.addEventListener("pointermove", track);
|
|
103
|
+
canvas.addEventListener("pointerdown", (e) => {
|
|
104
|
+
track(e);
|
|
105
|
+
const b = MOUSE_BUTTONS[e.button];
|
|
106
|
+
if (b === undefined) return;
|
|
107
|
+
mousePending.add(b);
|
|
108
|
+
mouseDown.add(b);
|
|
109
|
+
});
|
|
110
|
+
window.addEventListener("pointerup", (e) => {
|
|
111
|
+
const b = MOUSE_BUTTONS[e.button];
|
|
112
|
+
if (b !== undefined) mouseDown.delete(b);
|
|
113
|
+
});
|
|
114
|
+
canvas.addEventListener(
|
|
115
|
+
"wheel",
|
|
116
|
+
(e) => {
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
wheelPending += Math.sign(-e.deltaY);
|
|
119
|
+
},
|
|
120
|
+
{ passive: false }
|
|
121
|
+
);
|
|
122
|
+
document.addEventListener("contextmenu", (e) => e.preventDefault());
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
mouse,
|
|
126
|
+
// Fixes what isKeyPressed / isMousePressed / mouseWheel report for the frame about to run.
|
|
127
|
+
beginFrame() {
|
|
128
|
+
[keysPressed, keysPending] = [keysPending, keysPressed];
|
|
129
|
+
keysPending.clear();
|
|
130
|
+
[mousePressed, mousePending] = [mousePending, mousePressed];
|
|
131
|
+
mousePending.clear();
|
|
132
|
+
wheel = Math.sign(wheelPending);
|
|
133
|
+
wheelPending = 0;
|
|
134
|
+
},
|
|
135
|
+
isKeyDown: (k) => (keysDown.has(k) ? 1 : 0),
|
|
136
|
+
isKeyPressed: (k) => (keysPressed.has(k) ? 1 : 0),
|
|
137
|
+
isMouseDown: (b) => (mouseDown.has(b) ? 1 : 0),
|
|
138
|
+
isMousePressed: (b) => (mousePressed.has(b) ? 1 : 0),
|
|
139
|
+
mouseWheel: () => wheel,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- sound ----------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
function makeAudio() {
|
|
146
|
+
const AC = globalThis.AudioContext || globalThis.webkitAudioContext;
|
|
147
|
+
const ctx = AC ? new AC() : null;
|
|
148
|
+
const decoded = new Map(); // path -> AudioBuffer
|
|
149
|
+
const handles = []; // handle -> { path, plays: Set<{ src, gain }> }
|
|
150
|
+
if (ctx) {
|
|
151
|
+
const resume = () => {
|
|
152
|
+
if (ctx.state !== "running") ctx.resume();
|
|
153
|
+
};
|
|
154
|
+
for (const ev of ["pointerdown", "keydown", "touchstart"]) window.addEventListener(ev, resume);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const sound = (h) => (h >= 0 && h < handles.length ? handles[h] : null);
|
|
158
|
+
const clampVolume = (v) => (v > 0 ? Math.min(v, 1) : 0);
|
|
159
|
+
function start(h, volume, loop) {
|
|
160
|
+
const s = sound(h);
|
|
161
|
+
if (!s || !ctx) return;
|
|
162
|
+
// A one-shot before the first user gesture is dropped, not queued.
|
|
163
|
+
if (!loop && ctx.state !== "running") return;
|
|
164
|
+
const src = ctx.createBufferSource();
|
|
165
|
+
src.buffer = decoded.get(s.path);
|
|
166
|
+
src.loop = loop;
|
|
167
|
+
const gain = ctx.createGain();
|
|
168
|
+
gain.gain.value = clampVolume(volume);
|
|
169
|
+
src.connect(gain).connect(ctx.destination);
|
|
170
|
+
const play = { src, gain };
|
|
171
|
+
s.plays.add(play);
|
|
172
|
+
src.onended = () => s.plays.delete(play);
|
|
173
|
+
src.start();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
ctx,
|
|
178
|
+
// Decodes `bytes` for later soundLoad(path); false if undecodable.
|
|
179
|
+
async decode(path, bytes) {
|
|
180
|
+
if (!ctx) return false;
|
|
181
|
+
try {
|
|
182
|
+
decoded.set(path, await ctx.decodeAudioData(bytes.slice().buffer));
|
|
183
|
+
return true;
|
|
184
|
+
} catch (e) {
|
|
185
|
+
console.warn(path + ": could not decode sound", e);
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
load(path) {
|
|
190
|
+
if (!decoded.has(path)) return -1;
|
|
191
|
+
let h = handles.findIndex((s) => s.path === path);
|
|
192
|
+
if (h < 0) h = handles.push({ path, plays: new Set() }) - 1;
|
|
193
|
+
return h;
|
|
194
|
+
},
|
|
195
|
+
play: (h, v) => start(h, v, false),
|
|
196
|
+
loop: (h, v) => start(h, v, true),
|
|
197
|
+
stop(h) {
|
|
198
|
+
const s = sound(h);
|
|
199
|
+
if (!s) return;
|
|
200
|
+
for (const p of s.plays) {
|
|
201
|
+
p.src.onended = null;
|
|
202
|
+
try { p.src.stop(); } catch (e) { /* already stopped */ }
|
|
203
|
+
}
|
|
204
|
+
s.plays.clear();
|
|
205
|
+
},
|
|
206
|
+
setVolume(h, v) {
|
|
207
|
+
const s = sound(h);
|
|
208
|
+
if (s) for (const p of s.plays) p.gain.gain.value = clampVolume(v);
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- files ----------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
// Fetches every file listed in preload.txt. Returns path -> Uint8Array.
|
|
216
|
+
async function preload() {
|
|
217
|
+
const list = await (await fetch("preload.txt")).text();
|
|
218
|
+
const files = new Map();
|
|
219
|
+
await Promise.all(
|
|
220
|
+
list.split("\n").map((l) => l.trim()).filter(Boolean).map(async (path) => {
|
|
221
|
+
const r = await fetch("assets/" + path);
|
|
222
|
+
if (!r.ok) throw new Error(path + ": HTTP " + r.status);
|
|
223
|
+
files.set(path, new Uint8Array(await r.arrayBuffer()));
|
|
224
|
+
})
|
|
225
|
+
);
|
|
226
|
+
return files;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- batcher ----------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
// One vertex is 20 bytes: x, y (clip space), u, v (f32) and rgba (u8x4).
|
|
232
|
+
const VERT_WORDS = 5;
|
|
233
|
+
const VERT_BYTES = 20;
|
|
234
|
+
const MAX_SEGMENTS = 96;
|
|
235
|
+
|
|
236
|
+
function clamp01(v) {
|
|
237
|
+
return v > 0 ? (v < 1 ? v : 1) : 0;
|
|
238
|
+
}
|
|
239
|
+
function packColor(r, g, b, a) {
|
|
240
|
+
return (
|
|
241
|
+
((clamp01(r) * 255 + 0.5) | 0) |
|
|
242
|
+
(((clamp01(g) * 255 + 0.5) | 0) << 8) |
|
|
243
|
+
(((clamp01(b) * 255 + 0.5) | 0) << 16) |
|
|
244
|
+
(((clamp01(a) * 255 + 0.5) | 0) << 24)
|
|
245
|
+
) >>> 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const circleTables = new Map(); // segments -> Float32Array [cos0, sin0, cos1, sin1, ...]
|
|
249
|
+
function circleTable(n) {
|
|
250
|
+
let t = circleTables.get(n);
|
|
251
|
+
if (!t) {
|
|
252
|
+
t = new Float32Array(n * 2);
|
|
253
|
+
for (let i = 0; i < n; i++) {
|
|
254
|
+
t[2 * i] = Math.cos((i / n) * 2 * Math.PI);
|
|
255
|
+
t[2 * i + 1] = Math.sin((i / n) * 2 * Math.PI);
|
|
256
|
+
}
|
|
257
|
+
circleTables.set(n, t);
|
|
258
|
+
}
|
|
259
|
+
return t;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// env.textTexture(str, px) -> { w, h, pad } texture object for a text run
|
|
263
|
+
// (white glyphs on transparent, `pad` px of margin on every side), or null.
|
|
264
|
+
function makeBatcher(env) {
|
|
265
|
+
let vcap = 1 << 14;
|
|
266
|
+
let icap = 1 << 15;
|
|
267
|
+
let buf = new ArrayBuffer(vcap * VERT_BYTES);
|
|
268
|
+
let f32 = new Float32Array(buf);
|
|
269
|
+
let u32 = new Uint32Array(buf);
|
|
270
|
+
let idx = new Uint32Array(icap);
|
|
271
|
+
let vn = 0; // vertices used
|
|
272
|
+
let inn = 0; // indices used
|
|
273
|
+
const batches = []; // pooled { tex, start, count }
|
|
274
|
+
let nb = 0;
|
|
275
|
+
let cur = null;
|
|
276
|
+
const white = { w: 1, h: 1, white: true };
|
|
277
|
+
const textures = []; // handle -> texture object { w, h, ... }
|
|
278
|
+
let sw = 1;
|
|
279
|
+
let sh = 1;
|
|
280
|
+
const cam = { x: 0, y: 0, s: 1 };
|
|
281
|
+
// world -> clip: X = x * mx + bx, Y = y * my + by
|
|
282
|
+
let mx = 2;
|
|
283
|
+
let bx = -1;
|
|
284
|
+
let my = -2;
|
|
285
|
+
let by = 1;
|
|
286
|
+
const clearColor = [0, 0, 0, 1];
|
|
287
|
+
|
|
288
|
+
function reserve(nv, ni) {
|
|
289
|
+
if (vn + nv > vcap) {
|
|
290
|
+
while (vn + nv > vcap) vcap *= 2;
|
|
291
|
+
const nbuf = new ArrayBuffer(vcap * VERT_BYTES);
|
|
292
|
+
new Uint8Array(nbuf).set(new Uint8Array(buf, 0, vn * VERT_BYTES));
|
|
293
|
+
buf = nbuf;
|
|
294
|
+
f32 = new Float32Array(buf);
|
|
295
|
+
u32 = new Uint32Array(buf);
|
|
296
|
+
}
|
|
297
|
+
if (inn + ni > icap) {
|
|
298
|
+
while (inn + ni > icap) icap *= 2;
|
|
299
|
+
const nidx = new Uint32Array(icap);
|
|
300
|
+
nidx.set(idx.subarray(0, inn));
|
|
301
|
+
idx = nidx;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Starts a new batch only when the texture changes.
|
|
306
|
+
function useTex(t) {
|
|
307
|
+
if (cur && cur.tex === t) return;
|
|
308
|
+
cur = batches[nb];
|
|
309
|
+
if (!cur) batches.push((cur = { tex: t, start: 0, count: 0 }));
|
|
310
|
+
nb++;
|
|
311
|
+
cur.tex = t;
|
|
312
|
+
cur.start = inn;
|
|
313
|
+
cur.count = 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function vert(x, y, u, v, c) {
|
|
317
|
+
const o = vn * VERT_WORDS;
|
|
318
|
+
f32[o] = x * mx + bx;
|
|
319
|
+
f32[o + 1] = y * my + by;
|
|
320
|
+
f32[o + 2] = u;
|
|
321
|
+
f32[o + 3] = v;
|
|
322
|
+
u32[o + 4] = c;
|
|
323
|
+
vn++;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Corners in order: top-left, top-right, bottom-right, bottom-left.
|
|
327
|
+
function quad(t, ax, ay, bx_, by_, cx, cy, dx, dy, u0, v0, u1, v1, c) {
|
|
328
|
+
reserve(4, 6);
|
|
329
|
+
useTex(t);
|
|
330
|
+
const base = vn;
|
|
331
|
+
vert(ax, ay, u0, v0, c);
|
|
332
|
+
vert(bx_, by_, u1, v0, c);
|
|
333
|
+
vert(cx, cy, u1, v1, c);
|
|
334
|
+
vert(dx, dy, u0, v1, c);
|
|
335
|
+
idx[inn] = base;
|
|
336
|
+
idx[inn + 1] = base + 1;
|
|
337
|
+
idx[inn + 2] = base + 2;
|
|
338
|
+
idx[inn + 3] = base;
|
|
339
|
+
idx[inn + 4] = base + 2;
|
|
340
|
+
idx[inn + 5] = base + 3;
|
|
341
|
+
inn += 6;
|
|
342
|
+
cur.count += 6;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function setCamera(x, y, s) {
|
|
346
|
+
cam.x = x;
|
|
347
|
+
cam.y = y;
|
|
348
|
+
cam.s = s;
|
|
349
|
+
mx = (2 * s) / sw;
|
|
350
|
+
bx = -x * mx - 1;
|
|
351
|
+
my = (-2 * s) / sh;
|
|
352
|
+
by = 1 - y * my;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const self = {
|
|
356
|
+
textures,
|
|
357
|
+
white,
|
|
358
|
+
cam,
|
|
359
|
+
clearColor,
|
|
360
|
+
// Per-frame state; (w, h) is the screen size in pixels.
|
|
361
|
+
beginFrame(w, h) {
|
|
362
|
+
sw = w;
|
|
363
|
+
sh = h;
|
|
364
|
+
vn = 0;
|
|
365
|
+
inn = 0;
|
|
366
|
+
nb = 0;
|
|
367
|
+
cur = null;
|
|
368
|
+
clearColor[0] = clearColor[1] = clearColor[2] = 0;
|
|
369
|
+
clearColor[3] = 1;
|
|
370
|
+
setCamera(0, 0, 1);
|
|
371
|
+
},
|
|
372
|
+
get f32() { return f32; },
|
|
373
|
+
get idx() { return idx; },
|
|
374
|
+
get vertexCount() { return vn; },
|
|
375
|
+
get indexCount() { return inn; },
|
|
376
|
+
get batchCount() { return nb; },
|
|
377
|
+
batches,
|
|
378
|
+
// Everything drawn so far this frame is discarded, as clearing the screen does.
|
|
379
|
+
clear(r, g, b, a) {
|
|
380
|
+
vn = 0;
|
|
381
|
+
inn = 0;
|
|
382
|
+
nb = 0;
|
|
383
|
+
cur = null;
|
|
384
|
+
clearColor[0] = clamp01(r);
|
|
385
|
+
clearColor[1] = clamp01(g);
|
|
386
|
+
clearColor[2] = clamp01(b);
|
|
387
|
+
clearColor[3] = clamp01(a);
|
|
388
|
+
},
|
|
389
|
+
drawRect(x, y, w, h, r, g, b, a) {
|
|
390
|
+
quad(white, x, y, x + w, y, x + w, y + h, x, y + h, 0, 0, 1, 1, packColor(r, g, b, a));
|
|
391
|
+
},
|
|
392
|
+
drawLine(x1, y1, x2, y2, th, r, g, b, a) {
|
|
393
|
+
const dx = x2 - x1;
|
|
394
|
+
const dy = y2 - y1;
|
|
395
|
+
const len = Math.sqrt(dx * dx + dy * dy);
|
|
396
|
+
if (!(len > 0)) return;
|
|
397
|
+
const nx = (-dy / len) * (th / 2);
|
|
398
|
+
const ny = (dx / len) * (th / 2);
|
|
399
|
+
quad(white, x1 + nx, y1 + ny, x2 + nx, y2 + ny, x2 - nx, y2 - ny, x1 - nx, y1 - ny, 0, 0, 1, 1, packColor(r, g, b, a));
|
|
400
|
+
},
|
|
401
|
+
drawCircle(x, y, rad, r, g, b, a) {
|
|
402
|
+
if (!(rad > 0)) return;
|
|
403
|
+
const n = Math.min(MAX_SEGMENTS, Math.max(24, Math.ceil(rad * cam.s * 0.6)));
|
|
404
|
+
reserve(n + 1, n * 3);
|
|
405
|
+
useTex(white);
|
|
406
|
+
const c = packColor(r, g, b, a);
|
|
407
|
+
const base = vn;
|
|
408
|
+
vert(x, y, 0.5, 0.5, c);
|
|
409
|
+
const t = circleTable(n);
|
|
410
|
+
for (let i = 0; i < n; i++) vert(x + rad * t[2 * i], y + rad * t[2 * i + 1], 0.5, 0.5, c);
|
|
411
|
+
for (let i = 0; i < n; i++) {
|
|
412
|
+
idx[inn] = base;
|
|
413
|
+
idx[inn + 1] = base + 1 + i;
|
|
414
|
+
idx[inn + 2] = base + 1 + ((i + 1) % n);
|
|
415
|
+
inn += 3;
|
|
416
|
+
}
|
|
417
|
+
cur.count += n * 3;
|
|
418
|
+
},
|
|
419
|
+
// `x, y` is the text's top-left. The run is rasterised at its on-screen
|
|
420
|
+
// size (size * camera scale), so it is drawn 1:1 in screen pixels.
|
|
421
|
+
drawText(str, x, y, size, r, g, b, a) {
|
|
422
|
+
if (!str) return;
|
|
423
|
+
const px = Math.max(1, Math.round(size * cam.s));
|
|
424
|
+
const t = env.textTexture(str, px);
|
|
425
|
+
if (!t) return;
|
|
426
|
+
const s = cam.s;
|
|
427
|
+
const x0 = x - t.pad / s;
|
|
428
|
+
const y0 = y - t.pad / s;
|
|
429
|
+
const x1 = x0 + t.w / s;
|
|
430
|
+
const y1 = y0 + t.h / s;
|
|
431
|
+
quad(t, x0, y0, x1, y0, x1, y1, x0, y1, 0, 0, 1, 1, packColor(r, g, b, a));
|
|
432
|
+
},
|
|
433
|
+
setCamera,
|
|
434
|
+
resetCamera() {
|
|
435
|
+
setCamera(0, 0, 1);
|
|
436
|
+
},
|
|
437
|
+
textureWidth: (h) => (textures[h] ? textures[h].w : 0),
|
|
438
|
+
textureHeight: (h) => (textures[h] ? textures[h].h : 0),
|
|
439
|
+
drawTexture(h, x, y, r, g, b, a) {
|
|
440
|
+
const t = textures[h];
|
|
441
|
+
if (!t) return;
|
|
442
|
+
quad(t, x, y, x + t.w, y, x + t.w, y + t.h, x, y + t.h, 0, 0, 1, 1, packColor(r, g, b, a));
|
|
443
|
+
},
|
|
444
|
+
drawTextureEx(h, sx, sy, sw_, sh_, x, y, w, ht, rotation, flip, r, g, b, a) {
|
|
445
|
+
const t = textures[h];
|
|
446
|
+
if (!t || sw_ === 0 || sh_ === 0) return;
|
|
447
|
+
let u0 = sx / t.w;
|
|
448
|
+
let u1 = (sx + sw_) / t.w;
|
|
449
|
+
const v0 = sy / t.h;
|
|
450
|
+
const v1 = (sy + sh_) / t.h;
|
|
451
|
+
if (flip) {
|
|
452
|
+
const tmp = u0;
|
|
453
|
+
u0 = u1;
|
|
454
|
+
u1 = tmp;
|
|
455
|
+
}
|
|
456
|
+
const c = packColor(r, g, b, a);
|
|
457
|
+
if (rotation === 0) {
|
|
458
|
+
quad(t, x, y, x + w, y, x + w, y + ht, x, y + ht, u0, v0, u1, v1, c);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
// Rotated clockwise (y points down) about the dest centre.
|
|
462
|
+
const cx = x + w / 2;
|
|
463
|
+
const cy = y + ht / 2;
|
|
464
|
+
const hw = w / 2;
|
|
465
|
+
const hh = ht / 2;
|
|
466
|
+
const co = Math.cos(rotation);
|
|
467
|
+
const si = Math.sin(rotation);
|
|
468
|
+
quad(
|
|
469
|
+
t,
|
|
470
|
+
cx - hw * co + hh * si, cy - hw * si - hh * co,
|
|
471
|
+
cx + hw * co + hh * si, cy + hw * si - hh * co,
|
|
472
|
+
cx + hw * co - hh * si, cy + hw * si + hh * co,
|
|
473
|
+
cx - hw * co - hh * si, cy - hw * si + hh * co,
|
|
474
|
+
u0, v0, u1, v1, c
|
|
475
|
+
);
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
return self;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// --- command buffer ---------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
// Decodes one frame of commands (little-endian, 4-byte fields; see
|
|
484
|
+
// docs/plans/2026-11-02-render-experiments.md) from `view` and feeds `r`,
|
|
485
|
+
// which has the batcher's draw methods. Returns the number of commands.
|
|
486
|
+
function decodeCommands(view, r) {
|
|
487
|
+
const end = view.byteLength;
|
|
488
|
+
const dec = new TextDecoder();
|
|
489
|
+
let p = 0;
|
|
490
|
+
let n = 0;
|
|
491
|
+
const f = () => {
|
|
492
|
+
const v = view.getFloat32(p, true);
|
|
493
|
+
p += 4;
|
|
494
|
+
return v;
|
|
495
|
+
};
|
|
496
|
+
const i = () => {
|
|
497
|
+
const v = view.getInt32(p, true);
|
|
498
|
+
p += 4;
|
|
499
|
+
return v;
|
|
500
|
+
};
|
|
501
|
+
while (p < end) {
|
|
502
|
+
const op = view.getUint32(p, true);
|
|
503
|
+
p += 4;
|
|
504
|
+
n++;
|
|
505
|
+
switch (op) {
|
|
506
|
+
case 1: r.clear(f(), f(), f(), f()); break;
|
|
507
|
+
case 2: r.drawRect(f(), f(), f(), f(), f(), f(), f(), f()); break;
|
|
508
|
+
case 3: r.drawLine(f(), f(), f(), f(), f(), f(), f(), f(), f()); break;
|
|
509
|
+
case 4: r.drawCircle(f(), f(), f(), f(), f(), f(), f()); break;
|
|
510
|
+
case 5: {
|
|
511
|
+
const x = f(), y = f(), size = f(), cr = f(), cg = f(), cb = f(), ca = f();
|
|
512
|
+
const len = i();
|
|
513
|
+
const str = dec.decode(new Uint8Array(view.buffer, view.byteOffset + p, len));
|
|
514
|
+
p += (len + 3) & ~3;
|
|
515
|
+
r.drawText(str, x, y, size, cr, cg, cb, ca);
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
case 6: {
|
|
519
|
+
const h = i();
|
|
520
|
+
r.drawTexture(h, f(), f(), f(), f(), f(), f());
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
case 7: {
|
|
524
|
+
const h = i();
|
|
525
|
+
const sx = f(), sy = f(), sw = f(), sh = f(), x = f(), y = f(), w = f(), ht = f(), rot = f();
|
|
526
|
+
const flip = i();
|
|
527
|
+
r.drawTextureEx(h, sx, sy, sw, sh, x, y, w, ht, rot, flip, f(), f(), f(), f());
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
case 8: r.setCamera(f(), f(), f()); break;
|
|
531
|
+
case 9: r.resetCamera(); break;
|
|
532
|
+
default:
|
|
533
|
+
throw new Error("command buffer: unknown opcode " + op + " at byte " + (p - 4));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return n;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// --- backends -----------------------------------------------------------------------
|
|
540
|
+
// A backend: { name, createTexture(source, w, h, smooth) -> { w, h, ...gpu fields },
|
|
541
|
+
// setSmooth(tex, smooth), destroyTexture(tex), submit(batcher, w, h) }.
|
|
542
|
+
|
|
543
|
+
const WGSL = `
|
|
544
|
+
struct VOut {
|
|
545
|
+
@builtin(position) p: vec4f,
|
|
546
|
+
@location(0) uv: vec2f,
|
|
547
|
+
@location(1) c: vec4f,
|
|
548
|
+
};
|
|
549
|
+
@vertex fn vs(@location(0) p: vec2f, @location(1) uv: vec2f, @location(2) c: vec4f) -> VOut {
|
|
550
|
+
var o: VOut;
|
|
551
|
+
o.p = vec4f(p, 0.0, 1.0);
|
|
552
|
+
o.uv = uv;
|
|
553
|
+
o.c = c;
|
|
554
|
+
return o;
|
|
555
|
+
}
|
|
556
|
+
@group(0) @binding(0) var tex: texture_2d<f32>;
|
|
557
|
+
@group(0) @binding(1) var smp: sampler;
|
|
558
|
+
@fragment fn fs(i: VOut) -> @location(0) vec4f {
|
|
559
|
+
return textureSample(tex, smp, i.uv) * i.c;
|
|
560
|
+
}`;
|
|
561
|
+
|
|
562
|
+
async function makeWebGPU(canvas, white) {
|
|
563
|
+
if (!navigator.gpu) return null;
|
|
564
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
565
|
+
if (!adapter) return null;
|
|
566
|
+
const device = await adapter.requestDevice();
|
|
567
|
+
const ctx = canvas.getContext("webgpu");
|
|
568
|
+
if (!ctx) return null;
|
|
569
|
+
const format = navigator.gpu.getPreferredCanvasFormat();
|
|
570
|
+
ctx.configure({ device, format, alphaMode: "opaque" });
|
|
571
|
+
const module = device.createShaderModule({ code: WGSL });
|
|
572
|
+
const pipeline = device.createRenderPipeline({
|
|
573
|
+
layout: "auto",
|
|
574
|
+
vertex: {
|
|
575
|
+
module,
|
|
576
|
+
entryPoint: "vs",
|
|
577
|
+
buffers: [{
|
|
578
|
+
arrayStride: VERT_BYTES,
|
|
579
|
+
attributes: [
|
|
580
|
+
{ shaderLocation: 0, format: "float32x2", offset: 0 },
|
|
581
|
+
{ shaderLocation: 1, format: "float32x2", offset: 8 },
|
|
582
|
+
{ shaderLocation: 2, format: "unorm8x4", offset: 16 },
|
|
583
|
+
],
|
|
584
|
+
}],
|
|
585
|
+
},
|
|
586
|
+
fragment: {
|
|
587
|
+
module,
|
|
588
|
+
entryPoint: "fs",
|
|
589
|
+
targets: [{
|
|
590
|
+
format,
|
|
591
|
+
blend: {
|
|
592
|
+
color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
593
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
594
|
+
},
|
|
595
|
+
}],
|
|
596
|
+
},
|
|
597
|
+
primitive: { topology: "triangle-list" },
|
|
598
|
+
});
|
|
599
|
+
const layout = pipeline.getBindGroupLayout(0);
|
|
600
|
+
const nearest = device.createSampler({ magFilter: "nearest", minFilter: "nearest" });
|
|
601
|
+
const linear = device.createSampler({ magFilter: "linear", minFilter: "linear" });
|
|
602
|
+
let vbuf = null;
|
|
603
|
+
let vsize = 0;
|
|
604
|
+
let ibuf = null;
|
|
605
|
+
let isize = 0;
|
|
606
|
+
|
|
607
|
+
function bind(t) {
|
|
608
|
+
t.bg = device.createBindGroup({
|
|
609
|
+
layout,
|
|
610
|
+
entries: [
|
|
611
|
+
{ binding: 0, resource: t.view },
|
|
612
|
+
{ binding: 1, resource: t.smooth ? linear : nearest },
|
|
613
|
+
],
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
function createTexture(source, w, h, smooth) {
|
|
617
|
+
const gpu = device.createTexture({
|
|
618
|
+
size: [w, h],
|
|
619
|
+
format: "rgba8unorm",
|
|
620
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
621
|
+
});
|
|
622
|
+
if (source && source.data) device.queue.writeTexture({ texture: gpu }, source.data, { bytesPerRow: w * 4 }, [w, h]);
|
|
623
|
+
else if (source) device.queue.copyExternalImageToTexture({ source }, { texture: gpu }, [w, h]);
|
|
624
|
+
else device.queue.writeTexture({ texture: gpu }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]);
|
|
625
|
+
const t = { w, h, gpu, view: gpu.createView(), smooth: !!smooth, bg: null };
|
|
626
|
+
bind(t);
|
|
627
|
+
return t;
|
|
628
|
+
}
|
|
629
|
+
Object.assign(white, createTexture(null, 1, 1, false));
|
|
630
|
+
|
|
631
|
+
return {
|
|
632
|
+
name: "gpu-webgpu",
|
|
633
|
+
createTexture,
|
|
634
|
+
setSmooth(t, smooth) {
|
|
635
|
+
if (t.smooth === !!smooth) return;
|
|
636
|
+
t.smooth = !!smooth;
|
|
637
|
+
bind(t);
|
|
638
|
+
},
|
|
639
|
+
destroyTexture(t) {
|
|
640
|
+
t.gpu.destroy();
|
|
641
|
+
},
|
|
642
|
+
submit(b) {
|
|
643
|
+
const vbytes = b.vertexCount * VERT_BYTES;
|
|
644
|
+
const ibytes = b.indexCount * 4;
|
|
645
|
+
if (vbytes > vsize) {
|
|
646
|
+
if (vbuf) vbuf.destroy();
|
|
647
|
+
vsize = Math.max(vbytes, vsize * 2, 1 << 16);
|
|
648
|
+
vbuf = device.createBuffer({ size: vsize, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
|
|
649
|
+
}
|
|
650
|
+
if (ibytes > isize) {
|
|
651
|
+
if (ibuf) ibuf.destroy();
|
|
652
|
+
isize = Math.max(ibytes, isize * 2, 1 << 16);
|
|
653
|
+
ibuf = device.createBuffer({ size: isize, usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST });
|
|
654
|
+
}
|
|
655
|
+
if (vbytes) {
|
|
656
|
+
device.queue.writeBuffer(vbuf, 0, b.f32.buffer, 0, vbytes);
|
|
657
|
+
device.queue.writeBuffer(ibuf, 0, b.idx.buffer, 0, ibytes);
|
|
658
|
+
}
|
|
659
|
+
const [cr, cg, cb] = b.clearColor;
|
|
660
|
+
const enc = device.createCommandEncoder();
|
|
661
|
+
const pass = enc.beginRenderPass({
|
|
662
|
+
colorAttachments: [{
|
|
663
|
+
view: ctx.getCurrentTexture().createView(),
|
|
664
|
+
clearValue: { r: cr, g: cg, b: cb, a: 1 },
|
|
665
|
+
loadOp: "clear",
|
|
666
|
+
storeOp: "store",
|
|
667
|
+
}],
|
|
668
|
+
});
|
|
669
|
+
if (vbytes) {
|
|
670
|
+
pass.setPipeline(pipeline);
|
|
671
|
+
pass.setVertexBuffer(0, vbuf);
|
|
672
|
+
pass.setIndexBuffer(ibuf, "uint32");
|
|
673
|
+
for (let i = 0; i < b.batchCount; i++) {
|
|
674
|
+
const bt = b.batches[i];
|
|
675
|
+
pass.setBindGroup(0, bt.tex.bg);
|
|
676
|
+
pass.drawIndexed(bt.count, 1, bt.start);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
pass.end();
|
|
680
|
+
device.queue.submit([enc.finish()]);
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// --- text -------------------------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
const TEXT_PAD = 1;
|
|
688
|
+
const TEXT_CACHE_MAX = 512;
|
|
689
|
+
|
|
690
|
+
// Rasterises (string, px) runs once into backend textures, LRU-evicted.
|
|
691
|
+
function makeTextCache(getBackend) {
|
|
692
|
+
const cache = new Map(); // "px|str" -> { tex, frame }; Map order = LRU order
|
|
693
|
+
let frameNo = 0;
|
|
694
|
+
let scratch = null;
|
|
695
|
+
let ctx2d = null;
|
|
696
|
+
return {
|
|
697
|
+
textTexture(str, px) {
|
|
698
|
+
const key = px + "|" + str;
|
|
699
|
+
let e = cache.get(key);
|
|
700
|
+
if (e) {
|
|
701
|
+
cache.delete(key);
|
|
702
|
+
cache.set(key, e);
|
|
703
|
+
e.frame = frameNo;
|
|
704
|
+
return e.tex;
|
|
705
|
+
}
|
|
706
|
+
if (!scratch) {
|
|
707
|
+
scratch = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(8, 8) : document.createElement("canvas");
|
|
708
|
+
ctx2d = scratch.getContext("2d", { willReadFrequently: false });
|
|
709
|
+
}
|
|
710
|
+
ctx2d.font = px + "px sans-serif";
|
|
711
|
+
const w = Math.min(4096, Math.ceil(ctx2d.measureText(str).width) + 2 * TEXT_PAD);
|
|
712
|
+
const h = Math.min(4096, Math.ceil(px * 1.4) + 2 * TEXT_PAD);
|
|
713
|
+
scratch.width = w;
|
|
714
|
+
scratch.height = h;
|
|
715
|
+
ctx2d.font = px + "px sans-serif";
|
|
716
|
+
ctx2d.textBaseline = "top";
|
|
717
|
+
ctx2d.fillStyle = "#fff";
|
|
718
|
+
ctx2d.fillText(str, TEXT_PAD, TEXT_PAD);
|
|
719
|
+
const tex = getBackend().createTexture(ctx2d.getImageData(0, 0, w, h), w, h, true);
|
|
720
|
+
tex.pad = TEXT_PAD;
|
|
721
|
+
cache.set(key, (e = { tex, frame: frameNo }));
|
|
722
|
+
return tex;
|
|
723
|
+
},
|
|
724
|
+
// Evicts least-recently-used runs not used this frame (their textures may be queued).
|
|
725
|
+
endFrame() {
|
|
726
|
+
frameNo++;
|
|
727
|
+
if (cache.size <= TEXT_CACHE_MAX) return;
|
|
728
|
+
for (const [k, e] of cache) {
|
|
729
|
+
if (cache.size <= TEXT_CACHE_MAX || e.frame >= frameNo - 1) break;
|
|
730
|
+
getBackend().destroyTexture(e.tex);
|
|
731
|
+
cache.delete(k);
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// --- start-up --------------------------------------------------------------------
|
|
738
|
+
|
|
739
|
+
async function main() {
|
|
740
|
+
const canvas = document.getElementById("glcanvas");
|
|
741
|
+
const size = () => {
|
|
742
|
+
const w = Math.max(1, window.innerWidth | 0);
|
|
743
|
+
const h = Math.max(1, window.innerHeight | 0);
|
|
744
|
+
if (canvas.width !== w) canvas.width = w;
|
|
745
|
+
if (canvas.height !== h) canvas.height = h;
|
|
746
|
+
};
|
|
747
|
+
size();
|
|
748
|
+
|
|
749
|
+
let backend = null;
|
|
750
|
+
const text = makeTextCache(() => backend);
|
|
751
|
+
const b = makeBatcher({ textTexture: text.textTexture });
|
|
752
|
+
try {
|
|
753
|
+
backend = await makeWebGPU(canvas, b.white);
|
|
754
|
+
} catch (e) {
|
|
755
|
+
console.error("WebGPU failed to start:", e);
|
|
756
|
+
}
|
|
757
|
+
if (!backend) {
|
|
758
|
+
const msg = "WebGPU is not supported in this browser";
|
|
759
|
+
console.error(msg);
|
|
760
|
+
document.body.append(Object.assign(document.createElement("pre"), { textContent: msg, style: "position:fixed;top:0;left:0;color:#f55;margin:8px;font-size:18px" }));
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
globalThis.plumRenderer = backend.name;
|
|
764
|
+
console.log("plumRenderer: " + backend.name);
|
|
765
|
+
|
|
766
|
+
const files = await preload();
|
|
767
|
+
const images = new Map();
|
|
768
|
+
const audio = makeAudio();
|
|
769
|
+
await Promise.all(
|
|
770
|
+
[...files].map(async ([path, bytes]) => {
|
|
771
|
+
if (IMAGE_EXT.test(path)) {
|
|
772
|
+
try {
|
|
773
|
+
const bitmap = await createImageBitmap(new Blob([bytes]));
|
|
774
|
+
images.set(path, { bitmap, w: bitmap.width, h: bitmap.height });
|
|
775
|
+
} catch (e) {
|
|
776
|
+
console.warn(path + ": could not decode image", e);
|
|
777
|
+
}
|
|
778
|
+
} else if (SOUND_EXT.test(path)) {
|
|
779
|
+
await audio.decode(path, bytes);
|
|
780
|
+
}
|
|
781
|
+
})
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
const input = makeInput(canvas);
|
|
785
|
+
const ctx = { plum: null };
|
|
786
|
+
const fileStrs = new Map(); // path -> Plum Str, built once per path
|
|
787
|
+
let dt = 1 / 60;
|
|
788
|
+
|
|
789
|
+
const host = {
|
|
790
|
+
screenWidth: () => canvas.width,
|
|
791
|
+
screenHeight: () => canvas.height,
|
|
792
|
+
frameTime: () => dt,
|
|
793
|
+
clear: b.clear,
|
|
794
|
+
drawRect: b.drawRect,
|
|
795
|
+
drawCircle: b.drawCircle,
|
|
796
|
+
drawLine: b.drawLine,
|
|
797
|
+
drawText: (s, ...rest) => b.drawText(strToJs(ctx.plum, s), ...rest),
|
|
798
|
+
setCamera: b.setCamera,
|
|
799
|
+
resetCamera: b.resetCamera,
|
|
800
|
+
textureLoad: (s) => {
|
|
801
|
+
const p = normPath(strToJs(ctx.plum, s));
|
|
802
|
+
const img = p && images.get(p);
|
|
803
|
+
if (!img) return -1;
|
|
804
|
+
return b.textures.push(backend.createTexture(img.bitmap, img.w, img.h, false)) - 1;
|
|
805
|
+
},
|
|
806
|
+
textureWidth: b.textureWidth,
|
|
807
|
+
textureHeight: b.textureHeight,
|
|
808
|
+
textureSetSmooth: (h, smooth) => {
|
|
809
|
+
if (b.textures[h]) backend.setSmooth(b.textures[h], smooth !== 0);
|
|
810
|
+
},
|
|
811
|
+
drawTexture: b.drawTexture,
|
|
812
|
+
drawTextureEx: b.drawTextureEx,
|
|
813
|
+
fileExists: (s) => {
|
|
814
|
+
const p = normPath(strToJs(ctx.plum, s));
|
|
815
|
+
return p && files.has(p) ? 1 : 0;
|
|
816
|
+
},
|
|
817
|
+
fileLoad: (s) => {
|
|
818
|
+
const p = normPath(strToJs(ctx.plum, s));
|
|
819
|
+
const key = p && files.has(p) ? p : "";
|
|
820
|
+
let str = fileStrs.get(key);
|
|
821
|
+
if (str === undefined) {
|
|
822
|
+
str = bytesToStr(ctx.plum, key ? files.get(key) : new Uint8Array(0));
|
|
823
|
+
fileStrs.set(key, str);
|
|
824
|
+
}
|
|
825
|
+
return str;
|
|
826
|
+
},
|
|
827
|
+
soundLoad: (s) => {
|
|
828
|
+
const p = normPath(strToJs(ctx.plum, s));
|
|
829
|
+
return p ? audio.load(p) : -1;
|
|
830
|
+
},
|
|
831
|
+
soundPlay: audio.play,
|
|
832
|
+
soundLoop: audio.loop,
|
|
833
|
+
soundStop: audio.stop,
|
|
834
|
+
soundSetVolume: audio.setVolume,
|
|
835
|
+
isKeyDown: input.isKeyDown,
|
|
836
|
+
isKeyPressed: input.isKeyPressed,
|
|
837
|
+
mousePos: () => [input.mouse.x, input.mouse.y],
|
|
838
|
+
isMouseDown: input.isMouseDown,
|
|
839
|
+
isMousePressed: input.isMousePressed,
|
|
840
|
+
mouseWheel: input.mouseWheel,
|
|
841
|
+
printLn: (s) => console.log(strToJs(ctx.plum, s)),
|
|
842
|
+
crash: (s) => {
|
|
843
|
+
throw new Error("crash: " + strToJs(ctx.plum, s));
|
|
844
|
+
},
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
const module = await WebAssembly.compileStreaming(fetch("plum.wasm"));
|
|
848
|
+
const plumImports = {};
|
|
849
|
+
for (const imp of WebAssembly.Module.imports(module)) {
|
|
850
|
+
if (imp.module !== "plum" || imp.kind !== "function") continue;
|
|
851
|
+
plumImports[imp.name] =
|
|
852
|
+
host[imp.name] ||
|
|
853
|
+
(() => {
|
|
854
|
+
throw new Error("host function '" + imp.name + "' is not available here");
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
const instance = await WebAssembly.instantiate(module, { plum: plumImports });
|
|
858
|
+
ctx.plum = instance.exports;
|
|
859
|
+
const ex = ctx.plum;
|
|
860
|
+
const cmdMode = !!(ex.cmdPtr && ex.cmdLen && ex.cmdReset && ex.memory);
|
|
861
|
+
globalThis.plumCommandBuffer = cmdMode;
|
|
862
|
+
console.log("plumCommandBuffer: " + cmdMode);
|
|
863
|
+
const state = ex.init();
|
|
864
|
+
|
|
865
|
+
let last = performance.now();
|
|
866
|
+
function frame(now) {
|
|
867
|
+
try {
|
|
868
|
+
dt = Math.max((now - last) / 1000, 0);
|
|
869
|
+
last = now;
|
|
870
|
+
size();
|
|
871
|
+
input.beginFrame();
|
|
872
|
+
b.beginFrame(canvas.width, canvas.height);
|
|
873
|
+
ex.update(state, dt);
|
|
874
|
+
if (cmdMode) {
|
|
875
|
+
ex.cmdReset();
|
|
876
|
+
ex.draw(state);
|
|
877
|
+
// memory may have grown since last frame: take a fresh view
|
|
878
|
+
decodeCommands(new DataView(ex.memory.buffer, ex.cmdPtr(), ex.cmdLen()), b);
|
|
879
|
+
} else {
|
|
880
|
+
ex.draw(state);
|
|
881
|
+
}
|
|
882
|
+
backend.submit(b, canvas.width, canvas.height);
|
|
883
|
+
text.endFrame();
|
|
884
|
+
} catch (e) {
|
|
885
|
+
console.error("frame failed:", e);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
requestAnimationFrame(frame);
|
|
889
|
+
}
|
|
890
|
+
requestAnimationFrame((now) => {
|
|
891
|
+
last = now - 1000 / 60;
|
|
892
|
+
frame(now);
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
globalThis.PlumGpu = { makeBatcher, decodeCommands, packColor, VERT_BYTES };
|
|
897
|
+
|
|
898
|
+
if (typeof document !== "undefined") {
|
|
899
|
+
main().catch((e) => {
|
|
900
|
+
console.error("plum game failed to start:", e);
|
|
901
|
+
document.body.append(Object.assign(document.createElement("pre"), { textContent: String(e), style: "position:fixed;top:0;left:0;color:#f55;margin:8px" }));
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
})();
|