plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


bd86de6 — Peter John 2026-11-02T17:31:11+05:30
textureCreate, renderTarget, blendMode (Blend) in std/Game, the bridge and the headless runner; rendertarget fixture; web-test --expect-pixel
plum-game/src/api.rs CHANGED
@@ -9,6 +9,13 @@ use std::path::{Component, Path, PathBuf};
9
9
 
10
10
  pub type Color = [f32; 4];
11
11
 
12
+ /// The largest side of a texture made by `texture_create`.
13
+ pub const MAX_TEXTURE_SIZE: i32 = 8192;
14
+ pub const BLEND_NORMAL: i32 = 0;
15
+ pub const BLEND_ADD: i32 = 1;
16
+ pub const BLEND_MULTIPLY: i32 = 2;
17
+ pub const BLEND_SCREEN: i32 = 3;
18
+
12
19
  pub trait Backend: 'static {
13
20
  fn screen_width(&mut self) -> f32;
14
21
  fn screen_height(&mut self) -> f32;
@@ -27,6 +34,14 @@ pub trait Backend: 'static {
27
34
  fn texture_width(&mut self, t: i32) -> i32;
28
35
  fn texture_height(&mut self, t: i32) -> i32;
29
36
  fn texture_set_smooth(&mut self, t: i32, smooth: bool);
37
+ /// A blank texture of `w` x `h` pixels to draw into; `-1` when a side is
38
+ /// below 1 or above [`MAX_TEXTURE_SIZE`].
39
+ fn texture_create(&mut self, w: i32, h: i32) -> i32;
40
+ /// Sends the following draws into the texture `t` made by `texture_create`,
41
+ /// or to the screen for `-1`; any other handle is ignored.
42
+ fn render_target(&mut self, t: i32);
43
+ /// Sets the blend mode (`BLEND_NORMAL`..`BLEND_SCREEN`); other values are ignored.
44
+ fn blend_mode(&mut self, m: i32);
30
45
  fn draw_texture(&mut self, t: i32, x: f32, y: f32, tint: Color);
31
46
  /// Draws the source rect `(sx, sy, sw, sh)` (texture pixels) into the dest
32
47
  /// rect `(x, y, w, h)`, rotated `rotation` radians clockwise about the dest
@@ -82,6 +97,8 @@ struct Tex {
82
97
  width: u32,
83
98
  height: u32,
84
99
  smooth: bool,
100
+ /// Made by `texture_create`, so it can be a render target.
101
+ target: bool,
85
102
  }
86
103
 
87
104
  /// Files, texture sizes and sound handles from an asset root, addressed by
@@ -142,7 +159,7 @@ impl AssetStore {
142
159
  let dims = image::io::Reader::new(std::io::Cursor::new(&bytes)).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok());
143
160
  match dims {
144
161
  Some((width, height)) => {
145
- self.textures.push(Tex { width, height, smooth: false });
162
+ self.textures.push(Tex { width, height, smooth: false, target: false });
146
163
  self.textures.len() as i32 - 1
147
164
  }
148
165
  None => -1,
@@ -177,6 +194,20 @@ impl AssetStore {
177
194
  self.sounds.get(usize::try_from(s).ok()?).map(String::as_str)
178
195
  }
179
196
 
197
+ /// A blank render-target texture; `-1` for a side outside 1..=MAX_TEXTURE_SIZE.
198
+ pub fn texture_create(&mut self, w: i32, h: i32) -> i32 {
199
+ if !(1..=MAX_TEXTURE_SIZE).contains(&w) || !(1..=MAX_TEXTURE_SIZE).contains(&h) {
200
+ return -1;
201
+ }
202
+ self.textures.push(Tex { width: w as u32, height: h as u32, smooth: false, target: true });
203
+ self.textures.len() as i32 - 1
204
+ }
205
+
206
+ /// Whether `t` is a texture made by `texture_create`.
207
+ pub fn is_target(&self, t: i32) -> bool {
208
+ self.tex(t).is_some_and(|t| t.target)
209
+ }
210
+
180
211
  fn tex(&self, t: i32) -> Option<&Tex> {
181
212
  self.textures.get(usize::try_from(t).ok()?)
182
213
  }
@@ -263,6 +294,18 @@ mod tests {
263
294
  let _ = std::fs::remove_dir_all(&dir);
264
295
  }
265
296
 
297
+ #[test]
298
+ fn created_textures_are_targets_with_a_size() {
299
+ let mut s = AssetStore::new(PathBuf::from("/nonexistent-plum-assets"));
300
+ assert_eq!(s.texture_create(0, 4), -1);
301
+ assert_eq!(s.texture_create(4, 8193), -1);
302
+ let t = s.texture_create(32, 16);
303
+ assert_eq!((s.texture_width(t), s.texture_height(t)), (32, 16));
304
+ assert!(s.is_target(t));
305
+ assert!(!s.is_target(-1));
306
+ assert!(!s.is_target(t + 1));
307
+ }
308
+
266
309
  #[test]
267
310
  fn paths_escaping_the_root_are_rejected() {
268
311
  for bad in ["../x", "a/../../x", "/etc/passwd", "a\\b", ""] {
plum-game/src/host.rs CHANGED
@@ -112,6 +112,9 @@ fn dispatch<B: Backend>(name: &str, caller: &mut Caller<'_, HostState<B>>, p: &[
112
112
  "textureWidth" => r[0] = Val::I32(b!().texture_width(int(p, 0))),
113
113
  "textureHeight" => r[0] = Val::I32(b!().texture_height(int(p, 0))),
114
114
  "textureSetSmooth" => b!().texture_set_smooth(int(p, 0), int(p, 1) != 0),
115
+ "textureCreate" => r[0] = Val::I32(b!().texture_create(int(p, 0), int(p, 1))),
116
+ "renderTarget" => b!().render_target(int(p, 0)),
117
+ "blendMode" => b!().blend_mode(int(p, 0)),
115
118
  "drawTexture" => b!().draw_texture(int(p, 0), f(p, 1), f(p, 2), color(p, 3)),
116
119
  "drawTextureEx" => b!().draw_texture_ex(
117
120
  int(p, 0),
plum-game/src/recorder.rs CHANGED
@@ -105,6 +105,17 @@ impl Backend for Recorder {
105
105
  self.rec(format!("textureSetSmooth({t},{smooth})"));
106
106
  self.assets.texture_set_smooth(t, smooth);
107
107
  }
108
+ fn texture_create(&mut self, w: i32, h: i32) -> i32 {
109
+ let t = self.assets.texture_create(w, h);
110
+ self.rec(format!("textureCreate({w},{h}) -> {t}"));
111
+ t
112
+ }
113
+ fn render_target(&mut self, t: i32) {
114
+ self.rec(format!("renderTarget({t})"));
115
+ }
116
+ fn blend_mode(&mut self, m: i32) {
117
+ self.rec(format!("blendMode({m})"));
118
+ }
108
119
  fn draw_texture(&mut self, t: i32, x: f32, y: f32, tint: Color) {
109
120
  self.rec(format!("drawTexture({t},{x},{y},{})", c4(tint)));
110
121
  }
plum-game/tests/fixtures/target_fixture.plum ADDED
@@ -0,0 +1,25 @@
1
+ module target_fixture
2
+ import std/Int
3
+ import std/Float
4
+ import std/Str
5
+ import std/Vec2
6
+ import std/Color
7
+ import std/Game
8
+
9
+ record State(canvas: Int, bad: Int)
10
+
11
+ export fun init() -> State =
12
+ State(canvas: textureCreate(64, 32), bad: textureCreate(0, 5))
13
+
14
+ export fun update(g: State, dt: Float) =
15
+ g.canvas = g.canvas
16
+
17
+ export fun draw(g: State) =
18
+ renderTarget(g.canvas)
19
+ clear(Color.Transparent)
20
+ drawRect(0.0, 0.0, 10.0, 10.0, Color.Red)
21
+ renderTarget(-1)
22
+ blendMode(Blend.Add)
23
+ drawTexture(g.canvas, 20.0, 20.0, Color.White)
24
+ blendMode(Blend.Normal)
25
+ drawText("size {textureWidth(g.canvas)}x{textureHeight(g.canvas)} bad {g.bad}", 0.0, 0.0, 8.0, Color.White)
plum-game/tests/host_test.rs CHANGED
@@ -262,3 +262,29 @@ fn sceneExampleRunsFrames() {
262
262
  g.draw().expect("draw");
263
263
  assert!(g.backend().calls.iter().any(|c| c.starts_with("draw")), "{:?}", g.backend().calls);
264
264
  }
265
+
266
+ #[test]
267
+ fn renderTargetAndBlendCallsAreRecorded() {
268
+ let wasm = compile(&Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/target_fixture.plum"));
269
+ let mut g = PlumGame::load(&wasm, Recorder::new()).unwrap();
270
+ g.init().unwrap();
271
+ g.draw().unwrap();
272
+ let calls = g.backend().calls.clone();
273
+ let from = calls.iter().position(|c| c == "renderTarget(0)").expect("renderTarget(0)");
274
+ assert_eq!(
275
+ calls[from..],
276
+ [
277
+ "renderTarget(0)",
278
+ "clear(0,0,0,0)",
279
+ "drawRect(0,0,10,10,1,0,0,1)",
280
+ "renderTarget(-1)",
281
+ "blendMode(1)",
282
+ "drawTexture(0,20,20,1,1,1,1)",
283
+ "blendMode(0)",
284
+ "textureWidth(0)",
285
+ "textureHeight(0)",
286
+ "drawText(\"size 64x32 bad -1\",0,0,8,1,1,1,1)",
287
+ ]
288
+ );
289
+ assert_eq!(calls[..2], ["textureCreate(64,32) -> 0", "textureCreate(0,5) -> -1"]);
290
+ }
plum-game/web-test/fixtures/rendertarget.plum ADDED
@@ -0,0 +1,49 @@
1
+ # Render targets and blend modes, checked by pixel (see the --expect-pixel flags in
2
+ # web-test/tests/web_check.rs). The background is (51,51,51).
3
+ # 1. a 64x64 target drawn with red and green squares, shown through a camera
4
+ # (10,0) x2: red at (100..164, 100..164), green at (164..228, 164..228), the
5
+ # transparent rest shows the background; the target's own camera is pixels
6
+ # 2. Add (400,100): rgb(0.2,0.4,0.6) over the background gives (102,153,204)
7
+ # 3. Multiply (550,100): rgb(0.5,1,0.5) gives (26,51,26)
8
+ # 4. Screen (700,100): rgb(0.5,0.5,0.5) gives (153,153,153)
9
+ # 5. Normal with alpha 0.5 (850,100): red gives (153,26,26)
10
+ # 6. a second 16x16 blue target drawn at (400,300)
11
+ module rendertarget
12
+ import std/Str
13
+ import std/Int
14
+ import std/Float
15
+ import std/Vec2
16
+ import std/Color
17
+ import std/Game
18
+
19
+ record Targets(big: Int, small: Int, t: Int)
20
+
21
+ export fun init() -> Targets =
22
+ Targets(big: textureCreate(64, 64), small: textureCreate(16, 16), t: 0)
23
+
24
+ export fun update(g: Targets, dt: Float) =
25
+ g.t = g.t + 1
26
+
27
+ export fun draw(g: Targets) =
28
+ setCamera(10.0, 0.0, 2.0)
29
+ renderTarget(g.big)
30
+ clear(Color.Transparent)
31
+ drawRect(0.0, 0.0, 32.0, 32.0, Color.rgb(1.0, 0.0, 0.0))
32
+ drawRect(32.0, 32.0, 32.0, 32.0, Color.rgb(0.0, 1.0, 0.0))
33
+ renderTarget(g.small)
34
+ clear(Color.rgb(0.0, 0.0, 1.0))
35
+ renderTarget(-1)
36
+ clear(Color.rgb(0.2, 0.2, 0.2))
37
+ drawTexture(g.big, 60.0, 50.0, Color.White)
38
+ resetCamera()
39
+ blendMode(Blend.Add)
40
+ drawRect(400.0, 100.0, 100.0, 100.0, Color.rgb(0.2, 0.4, 0.6))
41
+ blendMode(Blend.Multiply)
42
+ drawRect(550.0, 100.0, 100.0, 100.0, Color.rgb(0.5, 1.0, 0.5))
43
+ blendMode(Blend.Screen)
44
+ drawRect(700.0, 100.0, 100.0, 100.0, Color.rgb(0.5, 0.5, 0.5))
45
+ blendMode(Blend.Normal)
46
+ drawRect(850.0, 100.0, 100.0, 100.0, Color(r: 1.0, g: 0.0, b: 0.0, a: 0.5))
47
+ drawTexture(g.small, 400.0, 300.0, Color.White)
48
+ drawText("frame {g.t}", 20.0, 500.0, 16.0, Color.White)
49
+ drawRect(Float(g.t % 600), 600.0, 40.0, 6.0, Color.Yellow)
plum-game/web-test/src/lib.rs CHANGED
@@ -24,6 +24,8 @@ pub struct Options {
24
24
  pub uncapped: bool,
25
25
  /// Browser `devicePixelRatio` (Playwright `deviceScaleFactor`); the page default when None.
26
26
  pub dpr: Option<f64>,
27
+ /// Colours the final screenshot must have at CSS pixel (x, y): each is x, y, [r, g, b], tolerance.
28
+ pub expect_pixels: Vec<ExpectPixel>,
27
29
  /// Destroy the GPU device from the page after a few frames and require rendering to resume.
28
30
  pub lose_device: bool,
29
31
  }
@@ -41,6 +43,45 @@ impl Default for Options {
41
43
  uncapped: false,
42
44
  dpr: None,
43
45
  lose_device: false,
46
+ expect_pixels: Vec::new(),
47
+ }
48
+ }
49
+ }
50
+
51
+ /// `--expect-pixel x,y,r,g,b[,tol]`: the screenshot pixel at CSS (x, y) is within `tol`
52
+ /// (default 2) of (r, g, b) on every channel.
53
+ #[derive(Debug, Clone, PartialEq)]
54
+ pub struct ExpectPixel {
55
+ pub x: u32,
56
+ pub y: u32,
57
+ pub rgb: [u8; 3],
58
+ pub tol: u8,
59
+ }
60
+
61
+ impl ExpectPixel {
62
+ pub fn parse(s: &str) -> Result<ExpectPixel, String> {
63
+ let n: Vec<u32> = s.split(',').map(|p| p.trim().parse::<u32>().map_err(|_| format!("--expect-pixel {s}: '{p}' is not a number"))).collect::<Result<_, _>>()?;
64
+ if n.len() != 5 && n.len() != 6 {
65
+ return Err(format!("--expect-pixel {s}: expected x,y,r,g,b[,tol]"));
66
+ }
67
+ if n[2..5].iter().any(|c| *c > 255) || n.get(5).is_some_and(|t| *t > 255) {
68
+ return Err(format!("--expect-pixel {s}: channels and tolerance are 0..255"));
69
+ }
70
+ Ok(ExpectPixel { x: n[0], y: n[1], rgb: [n[2] as u8, n[3] as u8, n[4] as u8], tol: n.get(5).map_or(2, |t| *t as u8) })
71
+ }
72
+
73
+ /// An error message when `shot` (taken at `dpr` device pixels per CSS pixel) differs.
74
+ pub fn check(&self, shot: &Shot, dpr: f64) -> Option<String> {
75
+ let (px, py) = ((f64::from(self.x) * dpr) as u32, (f64::from(self.y) * dpr) as u32);
76
+ if px >= shot.w || py >= shot.h {
77
+ return Some(format!("--expect-pixel {},{}: outside the {}x{} screenshot", self.x, self.y, shot.w, shot.h));
78
+ }
79
+ let i = ((py * shot.w + px) * 4) as usize;
80
+ let got = [shot.rgba[i], shot.rgba[i + 1], shot.rgba[i + 2]];
81
+ if got.iter().zip(self.rgb).all(|(g, w)| g.abs_diff(w) <= self.tol) {
82
+ None
83
+ } else {
84
+ Some(format!("--expect-pixel {},{}: expected {:?} (tolerance {}), screenshot has {:?}", self.x, self.y, self.rgb, self.tol, got))
44
85
  }
45
86
  }
46
87
  }
@@ -491,6 +532,11 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
491
532
  }
492
533
  let mut failures = failures_pre;
493
534
  failures.extend(loss_failure);
535
+ let dpr = backing.get(2).copied().unwrap_or(1.0);
536
+ failures.extend(opts.expect_pixels.iter().filter_map(|e| e.check(&b, dpr)));
537
+ if !opts.expect_pixels.is_empty() {
538
+ report.push_str(&format!("\npixel checks: {}", opts.expect_pixels.len()));
539
+ }
494
540
  if renderer.is_none() {
495
541
  failures.push("the page did not set globalThis.plumRenderer (WebGPU failed to start?)".to_string());
496
542
  }
plum-game/web-test/src/main.rs CHANGED
@@ -1,8 +1,8 @@
1
- use plum_web_test::{chromium_installed, run, Options};
1
+ use plum_web_test::{chromium_installed, run, ExpectPixel, Options};
2
2
  use std::path::PathBuf;
3
3
 
4
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
+ [--dpr N] [--lose-device] [--expect-pixel x,y,r,g,b[,tol]]...";
6
6
 
7
7
  #[tokio::main]
8
8
  async fn main() {
@@ -19,6 +19,10 @@ async fn main() {
19
19
  "--headed" => opts.headed = true,
20
20
  "--uncapped" => opts.uncapped = true,
21
21
  "--json" => opts.json = true,
22
+ "--expect-pixel" => {
23
+ let v = args.next().unwrap_or_else(|| fail("--expect-pixel needs x,y,r,g,b[,tol]"));
24
+ opts.expect_pixels.push(ExpectPixel::parse(&v).unwrap_or_else(|e| fail(&e)));
25
+ }
22
26
  "--lose-device" => opts.lose_device = true,
23
27
  "--dpr" => opts.dpr = Some(args.next().and_then(|v| v.parse().ok()).unwrap_or_else(|| fail("--dpr needs a number"))),
24
28
  "--measure" => opts.measure = Some(args.next().and_then(|v| v.parse().ok()).unwrap_or_else(|| fail("--measure needs a number of seconds"))),
plum-game/web-test/tests/web_check.rs CHANGED
@@ -74,3 +74,33 @@ fn draws_in_json() {
74
74
  let j = stats_json(&Options::default(), None, None, Some(&ph));
75
75
  assert!(j.ends_with(r#","render_ms":0.300,"draws":2.50}"#), "{j}");
76
76
  }
77
+
78
+ #[test]
79
+ fn expect_pixel_parses_and_checks() {
80
+ use plum_web_test::{ExpectPixel, Shot};
81
+ let e = ExpectPixel::parse("1,0,10,20,30").unwrap();
82
+ assert_eq!((e.x, e.y, e.rgb, e.tol), (1, 0, [10, 20, 30], 2));
83
+ assert_eq!(ExpectPixel::parse("1,0,10,20,30,5").unwrap().tol, 5);
84
+ assert!(ExpectPixel::parse("1,0,10").is_err());
85
+ assert!(ExpectPixel::parse("1,0,10,20,300").is_err());
86
+ let shot = Shot { w: 2, h: 1, rgba: vec![0, 0, 0, 255, 10, 21, 30, 255] };
87
+ assert!(e.check(&shot, 1.0).is_none());
88
+ assert!(ExpectPixel::parse("1,0,10,25,30").unwrap().check(&shot, 1.0).unwrap().contains("screenshot has [10, 21, 30]"));
89
+ assert!(ExpectPixel::parse("1,0,0,0,0").unwrap().check(&shot, 1.0).is_some());
90
+ // CSS (0, 0) at dpr 2 is device pixel (0, 0); CSS (1, 0) would be device (2, 0): outside.
91
+ assert!(e.check(&shot, 2.0).unwrap().contains("outside"));
92
+ }
93
+
94
+ #[test]
95
+ fn render_target_pixels() {
96
+ if !chromium_installed() {
97
+ eprintln!("SKIP: Playwright Chromium not installed (see plum-game/web-test/README.md)");
98
+ return;
99
+ }
100
+ let pixels = ["130,130,255,0,0", "196,196,0,255,0", "130,196,51,51,51", "450,150,102,153,204", "600,150,26,51,26", "750,150,153,153,153", "900,150,153,26,26", "408,308,0,0,255"];
101
+ let opts = Options { expect_pixels: pixels.iter().map(|p| plum_web_test::ExpectPixel::parse(p).unwrap()).collect(), ..Options::default() };
102
+ let rt = tokio::runtime::Runtime::new().unwrap();
103
+ let file = repo_root().join("plum-game/web-test/fixtures/rendertarget.plum");
104
+ let report = rt.block_on(run(&file, &opts)).unwrap_or_else(|e| panic!("{e}"));
105
+ println!("{report}");
106
+ }
plum-game/web/plum_bridge.js CHANGED
@@ -15,6 +15,7 @@
15
15
  "use strict";
16
16
 
17
17
  const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp)$/i;
18
+ const MAX_TEXTURE = 8192; // WebGPU's default maxTextureDimension2D
18
19
  const SOUND_EXT = /\.(wav|ogg)$/i;
19
20
  // --- paths --------------------------------------------------------------------
20
21
 
@@ -284,7 +285,10 @@
284
285
  let idx = new Uint32Array(icap);
285
286
  let vn = 0; // vertices used
286
287
  let inn = 0; // indices used
287
- const batches = []; // pooled { id, slots, n, start, count }
288
+ // Pooled draw/clear items in call order: { id, slots, n, start, count, blend, target, clear }.
289
+ // A draw item holds up to 16 textures; `target` is the render-target texture
290
+ // (null for the screen) and `clear` is null, or [r, g, b, a] for a clear of `target`.
291
+ const batches = [];
288
292
  let nb = 0;
289
293
  let cur = null;
290
294
  let batchSeq = 0;
@@ -301,7 +305,10 @@
301
305
  let bx = -1;
302
306
  let my = -2;
303
307
  let by = 1;
308
+ let curTarget = null; // render-target texture being drawn into; null = the screen
309
+ let curBlend = 0;
310
+ let scr = { w: 1, h: 1, dpr: 1 }; // the screen's size and ratio, kept while a target is selected
304
- const clearColor = [0, 0, 0, 1];
311
+ const screenCam = { x: 0, y: 0, s: 1 };
305
312
 
306
313
  function reserve(nv, ni) {
307
314
  if (vn + nv > vcap) {
@@ -320,17 +327,25 @@
320
327
  }
321
328
  }
322
329
 
330
+ function newItem() {
331
+ let it = batches[nb];
332
+ if (!it) batches.push((it = { id: 0, slots: new Array(SLOTS).fill(null), n: 0, start: 0, count: 0, blend: 0, target: null, clear: null }));
333
+ nb++;
334
+ it.id = ++batchSeq;
335
+ it.start = inn;
336
+ it.blend = curBlend;
337
+ it.target = curTarget;
338
+ it.clear = null;
339
+ it.n = 0;
340
+ it.count = 0;
341
+ return it;
342
+ }
343
+
323
344
  // Puts `t` in a slot of the current batch; starts a new batch when all 16 slots
324
345
  // hold other textures. Sets slotBits for the vertices that follow.
325
346
  function useTex(t) {
326
- if (!cur || (t.bid !== cur.id && cur.n === SLOTS)) {
347
+ if (!cur || cur.target !== curTarget || cur.blend !== curBlend || (t.bid !== cur.id && cur.n === SLOTS)) {
327
- cur = batches[nb];
348
+ cur = newItem();
328
- if (!cur) batches.push((cur = { id: 0, slots: new Array(SLOTS).fill(null), n: 0, start: 0, count: 0 }));
329
- nb++;
330
- cur.id = ++batchSeq;
331
- cur.n = 0;
332
- cur.start = inn;
333
- cur.count = 0;
334
349
  }
335
350
  if (t.bid !== cur.id) {
336
351
  t.bid = cur.id;
@@ -353,6 +368,7 @@
353
368
 
354
369
  // Corners in order: top-left, top-right, bottom-right, bottom-left.
355
370
  function quad(t, ax, ay, bx_, by_, cx, cy, dx, dy, u0, v0, u1, v1, c) {
371
+ if (t === curTarget) return; // a target cannot be drawn into itself
356
372
  reserve(4, 6);
357
373
  useTex(t);
358
374
  const base = vn;
@@ -385,9 +401,10 @@
385
401
  newTextureId: () => texSeq++,
386
402
  white,
387
403
  cam,
388
- clearColor,
389
- // Per-frame state; (w, h) is the screen size in CSS pixels.
404
+ // Per-frame state; (w, h) is the screen size in CSS pixels. Every frame
405
+ // starts on the screen with the normal blend mode and a default camera.
390
406
  beginFrame(w, h, ratio) {
407
+ scr = { w, h, dpr: ratio };
391
408
  sw = w;
392
409
  sh = h;
393
410
  dpr = ratio;
@@ -395,8 +412,8 @@
395
412
  inn = 0;
396
413
  nb = 0;
397
414
  cur = null;
398
- clearColor[0] = clearColor[1] = clearColor[2] = 0;
415
+ curTarget = null;
399
- clearColor[3] = 1;
416
+ curBlend = 0;
400
417
  setCamera(0, 0, 1);
401
418
  },
402
419
  get f32() { return f32; },
@@ -405,16 +422,42 @@
405
422
  get indexCount() { return inn; },
406
423
  get batchCount() { return nb; },
407
424
  batches,
408
- // Everything drawn so far this frame is discarded, as clearing the screen does.
425
+ // Fills the current target (the screen by default); earlier draws into it are overwritten.
409
426
  clear(r, g, b, a) {
410
- vn = 0;
411
- inn = 0;
412
- nb = 0;
413
427
  cur = null;
428
+ newItem().clear = [clamp01(r), clamp01(g), clamp01(b), clamp01(a)];
429
+ },
430
+ // Following draws go into render-target texture `h`, or the screen for -1. A
431
+ // target's camera starts as the identity over its pixels. A handle that is not
432
+ // a texture made by textureCreate is ignored.
433
+ renderTarget(h) {
434
+ let t = null;
435
+ if (h !== -1) {
436
+ t = textures[h];
437
+ if (!t || !t.target) return;
438
+ }
439
+ if (t === curTarget && t === null) return;
440
+ if (curTarget === null) {
414
- clearColor[0] = clamp01(r);
441
+ screenCam.x = cam.x;
415
- clearColor[1] = clamp01(g);
442
+ screenCam.y = cam.y;
416
- clearColor[2] = clamp01(b);
443
+ screenCam.s = cam.s;
444
+ }
445
+ curTarget = t;
446
+ if (t) {
447
+ sw = t.w;
448
+ sh = t.h;
449
+ dpr = 1;
417
- clearColor[3] = clamp01(a);
450
+ setCamera(0, 0, 1);
451
+ } else {
452
+ sw = scr.w;
453
+ sh = scr.h;
454
+ dpr = scr.dpr;
455
+ setCamera(screenCam.x, screenCam.y, screenCam.s);
456
+ }
457
+ },
458
+ // Normal 0, Add 1, Multiply 2, Screen 3; other values are ignored.
459
+ blendMode(m) {
460
+ if (m >= 0 && m <= 3) curBlend = m | 0;
418
461
  },
419
462
  drawRect(x, y, w, h, r, g, b, a) {
420
463
  quad(white, x, y, x + w, y, x + w, y + h, x, y + h, 0, 0, 1, 1, packColor(r, g, b, a));
@@ -550,7 +593,7 @@ fn pick(slot: u32, uv: vec2f) -> vec4f {
550
593
  ${cases} default: { return vec4f(1.0); }
551
594
  }
552
595
  }
553
- @fragment fn fs(i: VOut) -> @location(0) vec4f {
596
+ fn shade(i: VOut) -> vec4f {
554
597
  let fw = max(fwidth(i.uv.x), fwidth(i.uv.y));
555
598
  let texel = pick(i.slot, i.uv);
556
599
  if ((i.slot & ${SDF_BIT}u) != 0u) {
@@ -558,6 +601,19 @@ ${cases} default: { return vec4f(1.0); }
558
601
  return vec4f(i.c.rgb, i.c.a * smoothstep(0.5 - w, 0.5 + w, texel.a));
559
602
  }
560
603
  return texel * i.c;
604
+ }
605
+ @fragment fn fs(i: VOut) -> @location(0) vec4f {
606
+ return shade(i);
607
+ }
608
+ // Multiply: the blend state is src * dst, so a translucent colour fades towards white.
609
+ @fragment fn fsMultiply(i: VOut) -> @location(0) vec4f {
610
+ let c = shade(i);
611
+ return vec4f(mix(vec3f(1.0), c.rgb, c.a), c.a);
612
+ }
613
+ // Screen: the blend state is src * (1 - dst) + dst with a premultiplied src.
614
+ @fragment fn fsScreen(i: VOut) -> @location(0) vec4f {
615
+ let c = shade(i);
616
+ return vec4f(c.rgb * c.a, c.a);
561
617
  }`;
562
618
  }
563
619
  const WGSL = makeWGSL();
@@ -574,8 +630,10 @@ ${cases} default: { return vec4f(1.0); }
574
630
  if (!ctx) return null;
575
631
  const format = navigator.gpu.getPreferredCanvasFormat();
576
632
  let device = null;
577
- let pipeline = null;
633
+ let module = null;
578
634
  let layout = null;
635
+ let pipelineLayout = null;
636
+ const pipelines = new Map(); // blend mode | format -> render pipeline
579
637
  let nearest = null;
580
638
  let linear = null;
581
639
  let vbuf = null;
@@ -590,40 +648,67 @@ ${cases} default: { return vec4f(1.0); }
590
648
  return adapter ? adapter.requestDevice() : null;
591
649
  }
592
650
 
651
+ // Standard WebGPU blend states for Normal, Add, Multiply and Screen (the two
652
+ // last need the matching fragment entry point, which premixes the colour).
653
+ const BLENDS = [
654
+ {
655
+ color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
656
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
657
+ },
658
+ {
659
+ color: { srcFactor: "src-alpha", dstFactor: "one", operation: "add" },
660
+ alpha: { srcFactor: "one", dstFactor: "one", operation: "add" },
661
+ },
662
+ {
663
+ color: { srcFactor: "dst", dstFactor: "zero", operation: "add" },
664
+ alpha: { srcFactor: "zero", dstFactor: "one", operation: "add" },
665
+ },
666
+ {
667
+ color: { srcFactor: "one-minus-dst", dstFactor: "one", operation: "add" },
668
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
669
+ },
670
+ ];
671
+ const ENTRIES = ["fs", "fs", "fsMultiply", "fsScreen"];
672
+
673
+ function pipelineFor(mode, fmt) {
674
+ const key = mode + "|" + fmt;
675
+ let p = pipelines.get(key);
676
+ if (!p) {
677
+ p = device.createRenderPipeline({
678
+ layout: pipelineLayout,
679
+ vertex: {
680
+ module,
681
+ entryPoint: "vs",
682
+ buffers: [{
683
+ arrayStride: VERT_BYTES,
684
+ attributes: [
685
+ { shaderLocation: 0, format: "float32x2", offset: 0 },
686
+ { shaderLocation: 1, format: "float32x2", offset: 8 },
687
+ { shaderLocation: 2, format: "unorm8x4", offset: 16 },
688
+ { shaderLocation: 3, format: "uint32", offset: 20 },
689
+ ],
690
+ }],
691
+ },
692
+ fragment: { module, entryPoint: ENTRIES[mode], targets: [{ format: fmt, blend: BLENDS[mode] }] },
693
+ primitive: { topology: "triangle-list" },
694
+ });
695
+ pipelines.set(key, p);
696
+ }
697
+ return p;
698
+ }
699
+
593
700
  function setup(dev) {
594
701
  device = dev;
595
702
  if (globalThis.__plumExposeDevice) globalThis.__plumDevice = dev; // set by plum-web-test --lose-device
596
703
  ctx.configure({ device, format, alphaMode: "opaque" });
597
- const module = device.createShaderModule({ code: WGSL });
704
+ module = device.createShaderModule({ code: WGSL });
598
- pipeline = device.createRenderPipeline({
705
+ pipelines.clear();
599
- layout: "auto",
600
- vertex: {
601
- module,
602
- entryPoint: "vs",
603
- buffers: [{
604
- arrayStride: VERT_BYTES,
605
- attributes: [
706
+ const entries = [];
606
- { shaderLocation: 0, format: "float32x2", offset: 0 },
707
+ for (let i = 0; i < SLOTS; i++) entries.push({ binding: i, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } });
607
- { shaderLocation: 1, format: "float32x2", offset: 8 },
608
- { shaderLocation: 2, format: "unorm8x4", offset: 16 },
609
- { shaderLocation: 3, format: "uint32", offset: 20 },
610
- ],
611
- }],
612
- },
613
- fragment: {
614
- module,
615
- entryPoint: "fs",
616
- targets: [{
617
- format,
618
- blend: {
619
- color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
708
+ entries.push({ binding: 16, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } });
620
- alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
709
+ entries.push({ binding: 17, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } });
621
- },
622
- }],
623
- },
624
- primitive: { topology: "triangle-list" },
625
- });
626
- layout = pipeline.getBindGroupLayout(0);
710
+ layout = device.createBindGroupLayout({ entries });
711
+ pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [layout] });
627
712
  nearest = device.createSampler({ magFilter: "nearest", minFilter: "nearest" });
628
713
  linear = device.createSampler({ magFilter: "linear", minFilter: "linear" });
629
714
  vbuf = ibuf = null;
@@ -736,30 +821,58 @@ ${cases} default: { return vec4f(1.0); }
736
821
  device.queue.writeBuffer(vbuf, 0, b.f32.buffer, 0, vbytes);
737
822
  device.queue.writeBuffer(ibuf, 0, b.idx.buffer, 0, ibytes);
738
823
  }
824
+ // Items run in call order; a pass lasts while consecutive items share a target.
739
- const [cr, cg, cb] = b.clearColor;
825
+ // The screen is cleared by its first pass (to black unless a clear comes first).
740
826
  const enc = device.createCommandEncoder();
827
+ let pass = null;
828
+ let passTarget;
829
+ let screenCleared = false;
830
+ let draws = 0;
831
+ const begin = (target, clearValue) => {
832
+ if (pass) pass.end();
833
+ const screen = !target;
834
+ const clearing = clearValue || (screen && !screenCleared ? [0, 0, 0, 1] : null);
835
+ if (screen) screenCleared = true;
741
- const pass = enc.beginRenderPass({
836
+ pass = enc.beginRenderPass({
742
- colorAttachments: [{
837
+ colorAttachments: [{
743
- view: ctx.getCurrentTexture().createView(),
838
+ view: screen ? ctx.getCurrentTexture().createView() : target.view,
744
- clearValue: { r: cr, g: cg, b: cb, a: 1 },
839
+ clearValue: clearing ? { r: clearing[0], g: clearing[1], b: clearing[2], a: screen ? 1 : clearing[3] } : undefined,
745
- loadOp: "clear",
840
+ loadOp: clearing ? "clear" : "load",
746
- storeOp: "store",
841
+ storeOp: "store",
747
- }],
842
+ }],
748
- });
843
+ });
844
+ passTarget = target;
749
- if (vbytes) {
845
+ if (vbytes) {
750
- pass.setPipeline(pipeline);
751
- pass.setVertexBuffer(0, vbuf);
846
+ pass.setVertexBuffer(0, vbuf);
752
- pass.setIndexBuffer(ibuf, "uint32");
847
+ pass.setIndexBuffer(ibuf, "uint32");
753
- for (let i = 0; i < b.batchCount; i++) {
754
- const bt = b.batches[i];
755
- pass.setBindGroup(0, bindGroup(bt));
756
- pass.drawIndexed(bt.count, 1, bt.start);
757
848
  }
849
+ };
850
+ let boundBlend = -1;
851
+ for (let i = 0; i < b.batchCount; i++) {
852
+ const bt = b.batches[i];
853
+ if (bt.clear) {
854
+ begin(bt.target, bt.clear);
855
+ boundBlend = -1;
856
+ continue;
857
+ }
858
+ if (bt.count === 0) continue;
859
+ if (!pass || passTarget !== bt.target) {
860
+ begin(bt.target, null);
861
+ boundBlend = -1;
862
+ }
863
+ if (bt.blend !== boundBlend) {
864
+ pass.setPipeline(pipelineFor(bt.blend, bt.target ? "rgba8unorm" : format));
865
+ boundBlend = bt.blend;
866
+ }
867
+ pass.setBindGroup(0, bindGroup(bt));
868
+ pass.drawIndexed(bt.count, 1, bt.start);
869
+ draws++;
758
870
  }
871
+ if (!screenCleared) begin(null, null);
759
872
  pass.end();
760
873
  device.queue.submit([enc.finish()]);
761
874
  globalThis.plumFrames = (globalThis.plumFrames || 0) + 1;
762
- return vbytes ? b.batchCount : 0;
875
+ return draws;
763
876
  },
764
877
  };
765
878
  }
@@ -996,6 +1109,14 @@ ${cases} default: { return vec4f(1.0); }
996
1109
  if (!img) return -1;
997
1110
  return b.textures.push(backend.createTexture(img.bitmap, img.w, img.h, false)) - 1;
998
1111
  },
1112
+ textureCreate: (w, h) => {
1113
+ if (!(w >= 1 && h >= 1 && w <= MAX_TEXTURE && h <= MAX_TEXTURE)) return -1;
1114
+ const t = backend.createTexture(null, w | 0, h | 0, false);
1115
+ t.target = true;
1116
+ return b.textures.push(t) - 1;
1117
+ },
1118
+ renderTarget: b.renderTarget,
1119
+ blendMode: b.blendMode,
999
1120
  textureWidth: b.textureWidth,
1000
1121
  textureHeight: b.textureHeight,
1001
1122
  textureSetSmooth: (h, smooth) => {
plum-std/Game.plum CHANGED
@@ -31,6 +31,18 @@ extern fun textureWidth(t: Int) -> Int
31
31
  extern fun textureHeight(t: Int) -> Int
32
32
  # Linear (true) or nearest (false, the default) filtering.
33
33
  extern fun textureSetSmooth(t: Int, smooth: Bool)
34
+ # A blank, transparent texture of w x h pixels that can be drawn into with
35
+ # `renderTarget`; -1 if a side is below 1 or above 8192.
36
+ extern fun textureCreate(w: Int, h: Int) -> Int
37
+ # Sends the following draws (and `clear`) into texture `t` made by `textureCreate`,
38
+ # or back to the screen with -1; any other handle is ignored. Each target has its
39
+ # own camera: selecting a target resets its camera to its pixels, and returning to
40
+ # the screen restores the screen's camera. The texture's contents persist between
41
+ # frames; draw it like any texture once the screen is selected again.
42
+ extern fun renderTarget(t: Int)
43
+ # The blend mode of the following draws: a `Blend` constant (other values are
44
+ # ignored). Every frame starts with `Blend.Normal`.
45
+ extern fun blendMode(m: Int)
34
46
  # The texture at its natural size, top-left at (x, y).
35
47
  extern fun drawTexture(t: Int, x: Float, y: Float, tint: Color)
36
48
  # The source rect (sx, sy, sw, sh) in texture pixels drawn into the dest rect
@@ -111,6 +123,14 @@ primitive Key =
111
123
  LeftShift = 340
112
124
  LeftControl = 341
113
125
 
126
+ # Blend modes for `blendMode`: alpha blending, additive (brightens), multiply
127
+ # (darkens, white leaves the target unchanged) and screen (lightens).
128
+ primitive Blend =
129
+ Normal = 0
130
+ Add = 1
131
+ Multiply = 2
132
+ Screen = 3
133
+
114
134
  # Mouse button numbers for `isMouseDown` / `isMousePressed`.
115
135
  primitive Mouse =
116
136
  Left = 0
@@ -123,3 +143,7 @@ test "key and mouse constants use GLFW numbering"
123
143
  assert Key.Num0 == 48
124
144
  assert Key.LeftControl == 341
125
145
  assert Mouse.Right == 1
146
+
147
+ test "blend constants"
148
+ assert Blend.Normal == 0
149
+ assert Blend.Screen == 3