plum
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 +44 -1
- plum-game/src/host.rs +3 -0
- plum-game/src/recorder.rs +11 -0
- plum-game/tests/fixtures/target_fixture.plum +25 -0
- plum-game/tests/host_test.rs +26 -0
- plum-game/web-test/fixtures/rendertarget.plum +49 -0
- plum-game/web-test/src/lib.rs +46 -0
- plum-game/web-test/src/main.rs +6 -2
- plum-game/web-test/tests/web_check.rs +30 -0
- plum-game/web/plum_bridge.js +193 -72
- plum-std/Game.plum +24 -0
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
415
|
+
curTarget = null;
|
|
399
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
441
|
+
screenCam.x = cam.x;
|
|
415
|
-
|
|
442
|
+
screenCam.y = cam.y;
|
|
416
|
-
|
|
443
|
+
screenCam.s = cam.s;
|
|
444
|
+
}
|
|
445
|
+
curTarget = t;
|
|
446
|
+
if (t) {
|
|
447
|
+
sw = t.w;
|
|
448
|
+
sh = t.h;
|
|
449
|
+
dpr = 1;
|
|
417
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
704
|
+
module = device.createShaderModule({ code: WGSL });
|
|
598
|
-
|
|
705
|
+
pipelines.clear();
|
|
599
|
-
layout: "auto",
|
|
600
|
-
vertex: {
|
|
601
|
-
module,
|
|
602
|
-
entryPoint: "vs",
|
|
603
|
-
buffers: [{
|
|
604
|
-
arrayStride: VERT_BYTES,
|
|
605
|
-
|
|
706
|
+
const entries = [];
|
|
606
|
-
|
|
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
|
-
|
|
708
|
+
entries.push({ binding: 16, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } });
|
|
620
|
-
|
|
709
|
+
entries.push({ binding: 17, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } });
|
|
621
|
-
},
|
|
622
|
-
}],
|
|
623
|
-
},
|
|
624
|
-
primitive: { topology: "triangle-list" },
|
|
625
|
-
});
|
|
626
|
-
layout =
|
|
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
|
-
|
|
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
|
-
|
|
836
|
+
pass = enc.beginRenderPass({
|
|
742
|
-
|
|
837
|
+
colorAttachments: [{
|
|
743
|
-
|
|
838
|
+
view: screen ? ctx.getCurrentTexture().createView() : target.view,
|
|
744
|
-
|
|
839
|
+
clearValue: clearing ? { r: clearing[0], g: clearing[1], b: clearing[2], a: screen ? 1 : clearing[3] } : undefined,
|
|
745
|
-
|
|
840
|
+
loadOp: clearing ? "clear" : "load",
|
|
746
|
-
|
|
841
|
+
storeOp: "store",
|
|
747
|
-
|
|
842
|
+
}],
|
|
748
|
-
|
|
843
|
+
});
|
|
844
|
+
passTarget = target;
|
|
749
|
-
|
|
845
|
+
if (vbytes) {
|
|
750
|
-
pass.setPipeline(pipeline);
|
|
751
|
-
|
|
846
|
+
pass.setVertexBuffer(0, vbuf);
|
|
752
|
-
|
|
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
|
|
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
|