webvas 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5741995c39bcdce271bdf34cf96cc377d48cfb43abc9705d8beb6f2b49345034
4
- data.tar.gz: 77fee6bec1cafd49ff8fb62b65ca00afeff04e618986b73977b375b13d652ad7
3
+ metadata.gz: 8a2023d01aad96f20ed9938d18ddedf254edd56ba69c2dc0383a84d00c2efd03
4
+ data.tar.gz: 4ad4fbd5919583226a281186a717d59eb038a0d1aac25e79716ccd8cc5775fb9
5
5
  SHA512:
6
- metadata.gz: 7b98cc6279a621f860e0bde96266c11c54913e5331d0a80f4a880ae17caf3b277bd3699dbf5c34913a4376efc792b05b1793ec152933438d4d2a6819789a21cd
7
- data.tar.gz: cf95c627be83f6525fd9a5205ffd253367cb06fc6aed829e9d1f056b1bad192c6a41f983e145f71c18b7d494faf6cc38adf10df0b95752eeb53192e2be6485f5
6
+ metadata.gz: 034c1b98ebf7a2607d8b985c21e63394ccca5b228bf99fc39dc74bb710ea6ae4e72b2ae13968e7b8108f3f3a71280c0119ab025290274d1b8b877b3bba3aeb78
7
+ data.tar.gz: affd813bbbbf4ab437bfa794c5d56dd54187c27e4c6315bec9ad76b40bde5f9fae4dfa6941641e668f56f8a32506c40efe9c0e7983a5b5da32206a03209f1439
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.1] - 2026-09-25
4
+
5
+ ### Fixed
6
+
7
+ - Build the current WebAssembly runtime before browser tests and Pages deployment.
8
+ - Include the CLI executable and runtime build inputs in the gem.
9
+ - Require a Gesso release that supports browser sketches.
10
+
11
+ ## [0.3.0] - 2026-09-25
12
+
13
+ ### Added
14
+
15
+ - Gesso sketches and RLSL WGSL shaders in the browser runtime.
16
+ - An editable playground with example modes, compressed share URLs, and standalone HTML downloads.
17
+ - `webvas new`, `serve`, and `build` commands, including custom WebAssembly runtime builds.
18
+ - Worker reset between runs, Ruby error reporting, and frame performance measurements.
19
+
3
20
  ## [0.1.0] - 2026-09-25
4
21
 
5
22
  Initial release
data/README.md CHANGED
@@ -2,80 +2,107 @@
2
2
 
3
3
  # Webvas
4
4
 
5
- **Run RBGL sketches in a browser with Ruby.**
5
+ **Write Ruby graphics sketches and run them in a browser.**
6
6
 
7
- Webvas connects RBGL's software renderer to an `OffscreenCanvas` and drives one frame at a time with `requestAnimationFrame`.
8
-
9
- [Live example](https://rbgfx.github.io/webvas/) · [Ruby API](#ruby-api) · [Browser requirements](#browser-requirements)
7
+ [Playground](https://rbgfx.github.io/webvas/) · [Ruby API](#ruby-api) · [CLI](#cli) · [Security](#security)
10
8
 
11
9
  </div>
12
10
 
11
+ Webvas connects RBGL's software renderer to an `OffscreenCanvas` and advances one frame at a time with `requestAnimationFrame`. The playground includes RBGL, Gesso, and RLSL examples, shareable source links, and single-file HTML downloads.
12
+
13
13
  ## Features
14
14
 
15
- - RBGL browser backend for RGBA frames and pointer, keyboard, and wheel events
16
- - Frame scheduling through `RBGL::GUI::Window#step`
17
- - Standalone loader for `<script type="text/ruby" data-webvas>`
18
- - Runtime published with the example site; no local WebAssembly build is needed to use it
15
+ - RBGL canvas backend with pointer, keyboard, and wheel input
16
+ - Gesso sketch runner and RLSL WGSL compute shaders
17
+ - Fresh Ruby worker for each playground run, with visible errors and frame measurements
18
+ - Deflate-compressed source in URL fragments; source is not sent to a Webvas service
19
+ - `webvas new`, `serve`, and `build` for standalone sketch projects
20
+ - Browser runtime built with Webvas, RBGL, Tessel, Gesso, Glyphic, and RLSL
19
21
 
20
- ## Install
22
+ ## Quick start
21
23
 
22
- Add Webvas to the Ruby bundle used to prepare your WebAssembly runtime:
24
+ Add the loader and a Ruby source file to an HTML page:
25
+
26
+ ~~~html
27
+ <canvas id="screen" width="320" height="240"></canvas>
28
+ <script type="text/ruby" data-webvas data-canvas="#screen" src="app.rb"></script>
29
+ <script src="https://rbgfx.github.io/webvas/loader.js"
30
+ data-runtime="https://rbgfx.github.io/webvas/assets/webvas.wasm"></script>
31
+ ~~~
32
+
33
+ In `app.rb`:
23
34
 
24
35
  ~~~ruby
25
- gem "webvas"
36
+ require "gesso"
37
+
38
+ Gesso.run(width: 320, height: 240, runner: :web) do
39
+ draw do
40
+ background "#101827"
41
+ no_stroke
42
+ fill "#f07850"
43
+ circle width / 2, height / 2, 36
44
+ end
45
+ end
26
46
  ~~~
27
47
 
28
- The browser runtime must also include `rbgl` and its dependencies. When using the runtime distributed with the Webvas example, no local build is required.
48
+ The official runtime is built with these gems already included. A runtime URL can be set with `data-runtime`; use the same URL for sketches that bundle their own dependencies.
29
49
 
30
- ## Quick start
50
+ ## Ruby API
31
51
 
32
- Add a canvas, a Ruby script, and the loader to an HTML page:
52
+ Create an `RBGL::GUI::Window` with `Webvas::Backend.new`, then call `Webvas.run(window)` with a frame block. The block receives the RBGL context and delta time. `Webvas.run` reuses one animation callback, closes the window on stop or error, and reports exceptions through the bridge.
33
53
 
34
- ~~~html
35
- <canvas id="screen" width="320" height="240"></canvas>
36
- <script type="text/ruby" data-webvas>
37
- require "rbgl"
38
- require "webvas"
39
-
40
- window = RBGL::GUI::Window.new(
41
- width: 320,
42
- height: 240,
43
- backend: Webvas::Backend.new(width: 320, height: 240)
44
- )
45
-
46
- Webvas.run(window) do |context, _delta_time|
47
- context.clear
48
- # Bind an RBGL pipeline and draw here.
54
+ `Webvas::Backend` accepts `width`, `height`, `canvas`, `title`, and `pixelated`. Canvas drawing dimensions set render resolution; CSS scales its display. Pointer coordinates are mapped from CSS pixels to render pixels. Focus the canvas to send keyboard input.
55
+
56
+ RLSL can generate the WGSL source for WebGPU:
57
+
58
+ ~~~ruby
59
+ require "rlsl"
60
+ require "webvas"
61
+
62
+ wgsl = RLSL.to_wgsl(:plasma) do
63
+ uniforms { float :time }
64
+ fragment do |frag_coord, resolution, uniforms|
65
+ uv = frag_coord / resolution.y
66
+ vec3(sin(uv.x + uniforms.time), uv.y, 0.6)
49
67
  end
50
- </script>
51
- <script src="https://rbgfx.github.io/webvas/loader.js"></script>
68
+ end
69
+ Webvas.run_shader(Webvas::Shader.new(wgsl))
52
70
  ~~~
53
71
 
54
- The loader reads `assets/webvas.wasm` beside itself by default. Set `data-runtime` on the loader script to use another HTTPS or same-origin runtime URL. Set `data-canvas` on the Ruby script and use the same selector in `Webvas::Backend` when the canvas is not `#screen`.
72
+ WebGPU requires a supported browser, secure context, and GPU adapter. The shader runner updates the `resolution` and `time` uniforms automatically.
55
73
 
56
- ## Ruby API
57
-
58
- Create an `RBGL::GUI::Window` with a `Webvas::Backend`, then pass it to `Webvas.run`. The callback receives the RBGL context and elapsed seconds. `Webvas.run` reuses one callback for each animation frame and closes the window when it stops or raises an exception.
74
+ ## CLI
59
75
 
60
- `Webvas::Backend` accepts `width`, `height`, `canvas`, `title`, and `pixelated`. The canvas size sets the rendering resolution; CSS can scale its display size. With `pixelated: true`, scaled output uses nearest-neighbor display.
76
+ ~~~sh
77
+ gem install webvas
78
+ webvas new my-sketch
79
+ cd my-sketch
80
+ webvas serve
81
+ webvas build -o dist
82
+ ~~~
61
83
 
62
- Pointer coordinates are mapped from the canvas display bounds to rendering pixels. Pointer buttons, keyboard keys, modifier keys, and wheel deltas are converted to RBGL events. Focus the canvas to send keyboard events.
84
+ `serve` binds to `127.0.0.1:8000`, serves `.wasm` as `application/wasm`, and reloads when project files change. Use `--host`, `--port`, or `--root` to change its defaults. `build` writes a static site that loads the official runtime. To bundle a custom runtime, add `ruby_wasm` and `js` to the project's `Gemfile`, install it, then run `webvas build --runtime custom`; the project's bundle is passed to `rbwasm build`.
63
85
 
64
- ## Browser requirements
86
+ ## Playground
65
87
 
66
- - WebAssembly, module workers, and `OffscreenCanvas`
67
- - A secure context when loading the page over the network
68
- - A Ruby runtime built with Webvas, RBGL, and RBGL's dependencies
88
+ Open the [Webvas playground](https://rbgfx.github.io/webvas/). Choose a mode and example, edit the Ruby, then press **Run** or **Ctrl/Cmd + Enter**. Each run starts a fresh worker and canvas. **Share** compresses the source with `CompressionStream` into the URL fragment; **Download HTML** embeds the source as escaped JSON and keeps the runtime on its CDN URL.
69
89
 
70
- Ruby runs in a worker without access to the page DOM. The `js` gem exposes APIs available to that worker, so this worker should not be treated as a security sandbox for hostile code. The page's Content Security Policy must allow the worker, runtime URL, and WebAssembly compilation.
90
+ ## Security and limitations
71
91
 
72
- Blocking frame loops and `sleep` cannot drive animation in the browser. Use `Webvas.run` to advance the window one frame at a time. WebAssembly threads and compiling native extensions in the browser are not supported.
92
+ - Ruby runs in a worker, but the worker is not a security sandbox. Sketches can call browser APIs and make network requests allowed by the page's Content Security Policy. Inspect shared code before running it.
93
+ - Webvas does not set cookies, store source, or send code to a Webvas service. The GitHub Pages project URL shares the `rbgfx.github.io` origin; a dedicated hostname requires a separate hosting or domain decision.
94
+ - Runtime downloads omit browser credentials. Pages need to allow the chosen loader, worker, and runtime in their Content Security Policy.
95
+ - The `js` gem evaluates Ruby-to-JavaScript bridge code, so the playground policy requires `unsafe-eval` and `wasm-unsafe-eval`. This weakens script restrictions; use a dedicated origin for untrusted sketches.
96
+ - The 0.3.0 candidate was verified locally in Chromium 153 on macOS arm64. Firefox, Safari, and mobile browsers are not verified; the software renderer needs `OffscreenCanvas` and module workers, and RLSL also needs WebGPU.
97
+ - Blocking loops and `sleep` stop browser frame progress. WebAssembly threads and compiling native extensions in the browser are unsupported.
98
+ - Ruby source is limited to 32 KiB. RGBA frames use base64 transfer; a 320×240 frame is 307,200 bytes before encoding.
99
+ - RLSL mode requires WebGPU; RBGL and Gesso modes use the software renderer.
73
100
 
74
- Ruby source is limited to 32 KiB. Frame pixels use base64 between Ruby and JavaScript; a 320×240 RGBA frame is 307,200 bytes before encoding and 409,600 bytes after encoding.
101
+ See [browser security notes](docs/security.md) and [runtime/performance records](docs/performance.md).
75
102
 
76
103
  ## Development
77
104
 
78
- The repository expects adjacent checkouts of `larb`, `rbgl`, and `tessel`.
105
+ The repository expects adjacent checkouts of Larb, RBGL, Tessel, Gesso, Glyphic, and RLSL. Ruby unit tests run under CRuby; the browser integration tests use Chromium and the locally built runtime.
79
106
 
80
107
  ~~~sh
81
108
  bundle install
@@ -90,8 +117,6 @@ npx playwright install chromium
90
117
  npm run test:browser
91
118
  ~~~
92
119
 
93
- The Pages workflow builds the WebAssembly runtime, checks the browser loader in Chromium, and deploys the static example.
94
-
95
120
  ## License
96
121
 
97
122
  MIT. See [LICENSE.txt](LICENSE.txt).
data/exe/webvas ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "webvas"
5
+ require_relative "../lib/webvas/cli"
6
+
7
+ exit Webvas::CLI.run
data/js/bridge.js CHANGED
@@ -4,6 +4,107 @@
4
4
  const pendingEvents = [];
5
5
  const eventLimit = 2048;
6
6
 
7
+ function uniformLayout(wgsl) {
8
+ const source = wgsl.match(/struct\s+Uniforms\s*\{([\s\S]*?)\}/)?.[1];
9
+ if (!source) throw new Error("WGSL must define a Uniforms struct");
10
+ const fields = [...source.matchAll(/\b(\w+)\s*:\s*(f32|i32|u32|vec[234]<f32>)\s*,/g)];
11
+ let offset = 0;
12
+ const layout = fields.map(([, name, type]) => {
13
+ const components = Number(type.match(/^vec(\d)/)?.[1] || 1);
14
+ const align = components === 1 ? 4 : components === 2 ? 8 : 16;
15
+ const size = components === 3 ? 12 : components * 4;
16
+ offset = Math.ceil(offset / align) * align;
17
+ const field = { name, type, offset, components };
18
+ offset += size;
19
+ return field;
20
+ });
21
+ if (!layout.length) throw new Error("WGSL Uniforms must contain supported numeric fields");
22
+ return { fields: layout, size: Math.ceil(offset / 16) * 16 };
23
+ }
24
+
25
+ function startShader(selector, wgsl, initialUniforms) {
26
+ const canvas = globalThis.WebvasCanvases?.[selector];
27
+ if (!(canvas instanceof OffscreenCanvas)) throw new Error("Canvas not found: " + selector);
28
+ if (!navigator.gpu) throw new Error("WebGPU is unavailable. Try a browser with WebGPU enabled.");
29
+ if (!canvas.width || !canvas.height) throw new Error("Shader canvas size must be positive");
30
+
31
+ const startTime = performance.now();
32
+ let metricFrames = 0;
33
+ let metricStart = startTime;
34
+ let metricDispatchMs = 0;
35
+ const uniforms = JSON.parse(initialUniforms);
36
+ const layout = uniformLayout(wgsl);
37
+ const frame = async () => {
38
+ try {
39
+ const adapter = await navigator.gpu.requestAdapter();
40
+ if (!adapter) throw new Error("WebGPU could not find an adapter.");
41
+ const device = await adapter.requestDevice();
42
+ const context = canvas.getContext("webgpu");
43
+ if (!context) throw new Error("WebGPU canvas is unavailable.");
44
+ context.configure({ device, format: "rgba8unorm", usage: GPUTextureUsage.STORAGE_BINDING, alphaMode: "opaque" });
45
+ const module = device.createShaderModule({ code: wgsl });
46
+ const info = await module.getCompilationInfo();
47
+ const errors = info.messages.filter(message => message.type === "error");
48
+ if (errors.length) throw new Error(errors.map(error => error.message).join("\n"));
49
+ const pipeline = await device.createComputePipelineAsync({
50
+ layout: "auto", compute: { module, entryPoint: "main" }
51
+ });
52
+ const buffer = device.createBuffer({ size: layout.size, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
53
+ const draw = () => {
54
+ try {
55
+ const started = performance.now();
56
+ const data = new DataView(new ArrayBuffer(layout.size));
57
+ for (const field of layout.fields) {
58
+ let value = uniforms[field.name];
59
+ if (field.name === "resolution") value = [canvas.width, canvas.height];
60
+ if (field.name === "time" && value === undefined) value = (performance.now() - startTime) / 1000;
61
+ const values = field.components === 1 ? [value ?? 0] : Array.isArray(value) ? value : [];
62
+ for (let index = 0; index < field.components; index += 1) {
63
+ const at = field.offset + index * 4;
64
+ if (field.type === "i32") data.setInt32(at, Number(values[index] || 0), true);
65
+ else if (field.type === "u32") data.setUint32(at, Number(values[index] || 0), true);
66
+ else data.setFloat32(at, Number(values[index] || 0), true);
67
+ }
68
+ }
69
+ device.queue.writeBuffer(buffer, 0, data);
70
+ const bindGroup = device.createBindGroup({
71
+ layout: pipeline.getBindGroupLayout(0),
72
+ entries: [
73
+ { binding: 0, resource: { buffer } },
74
+ { binding: 1, resource: context.getCurrentTexture().createView() }
75
+ ]
76
+ });
77
+ const encoder = device.createCommandEncoder();
78
+ const pass = encoder.beginComputePass();
79
+ pass.setPipeline(pipeline);
80
+ pass.setBindGroup(0, bindGroup);
81
+ pass.dispatchWorkgroups(Math.ceil(canvas.width / 8), Math.ceil(canvas.height / 8));
82
+ pass.end();
83
+ device.queue.submit([encoder.finish()]);
84
+ metricDispatchMs += performance.now() - started;
85
+ metricFrames += 1;
86
+ if (metricFrames === 60) {
87
+ const elapsed = performance.now() - metricStart;
88
+ globalThis.postMessage({ type: "webvas:metrics", frames: metricFrames, elapsedMs: elapsed,
89
+ fps: metricFrames * 1000 / elapsed, averagePresentMs: metricDispatchMs / metricFrames });
90
+ metricFrames = 0;
91
+ metricStart = performance.now();
92
+ metricDispatchMs = 0;
93
+ }
94
+ globalThis.window.requestAnimationFrame(draw);
95
+ } catch (error) {
96
+ globalThis.WebvasBridge.showError(error.message || String(error), "");
97
+ }
98
+ };
99
+ globalThis.window.requestAnimationFrame(draw);
100
+ } catch (error) {
101
+ globalThis.WebvasBridge.showError(error.message || String(error), "");
102
+ }
103
+ };
104
+ frame();
105
+ return true;
106
+ }
107
+
7
108
  function queue(handle, event) {
8
109
  if (handle.events.length === eventLimit) handle.events.shift();
9
110
  handle.events.push(event);
@@ -17,7 +118,7 @@
17
118
  canvas.height = height;
18
119
  const context = canvas.getContext("2d", { alpha: false });
19
120
  if (!context) throw new Error("Canvas 2D is unavailable");
20
- const handle = { id: nextId++, canvas, context, events: pendingEvents.splice(0) };
121
+ const handle = { id: nextId++, canvas, context, events: pendingEvents.splice(0), metricStart: performance.now(), metricFrames: 0, metricPresentMs: 0 };
21
122
  globalThis.postMessage({ type: "webvas:pixelated", selector, enabled: pixelated });
22
123
  canvases.set(handle.id, handle);
23
124
  return handle.id;
@@ -26,6 +127,7 @@
26
127
  present(id, encoded, width, height) {
27
128
  const handle = canvases.get(id);
28
129
  if (!handle) throw new Error("Canvas backend is closed");
130
+ const started = performance.now();
29
131
  const { canvas, context } = handle;
30
132
  if (canvas.width !== width || canvas.height !== height) {
31
133
  canvas.width = width;
@@ -36,9 +138,23 @@
36
138
  const pixels = new Uint8ClampedArray(binary.length);
37
139
  for (let index = 0; index < binary.length; index += 1) pixels[index] = binary.charCodeAt(index);
38
140
  context.putImageData(new ImageData(pixels, width, height), 0, 0);
141
+ handle.metricFrames += 1;
142
+ handle.metricPresentMs += performance.now() - started;
143
+ if (handle.metricFrames === 60) {
144
+ const elapsed = performance.now() - handle.metricStart;
145
+ globalThis.postMessage({ type: "webvas:metrics", frames: handle.metricFrames, elapsedMs: elapsed,
146
+ fps: handle.metricFrames * 1000 / elapsed, averagePresentMs: handle.metricPresentMs / handle.metricFrames });
147
+ handle.metricStart = performance.now();
148
+ handle.metricFrames = 0;
149
+ handle.metricPresentMs = 0;
150
+ }
39
151
  return true;
40
152
  },
41
153
 
154
+ runShader(selector, wgsl, uniforms) {
155
+ return startShader(selector, wgsl, uniforms);
156
+ },
157
+
42
158
  events(id) {
43
159
  const handle = canvases.get(id);
44
160
  return JSON.stringify(handle ? handle.events.splice(0) : []);
data/js/loader.js CHANGED
@@ -3,44 +3,92 @@
3
3
  if (!loader) return;
4
4
 
5
5
  const base = new URL(".", loader.src);
6
- const runtimeUrl = loader.dataset.runtime || new URL("./assets/webvas.wasm", base).href;
7
- const workerUrl = new URL("./worker.js", base).href;
8
- const bridgeUrl = new URL("./bridge.js", base).href;
6
+ const defaultRuntime = loader.dataset.runtime || new URL("./assets/webvas.wasm", base).href;
7
+ const sessions = new Map();
8
+ const sourceLimit = 32768;
9
+
10
+ function textSource(element) {
11
+ if (element.matches('script[type="application/json"][data-webvas-source]')) {
12
+ const payload = JSON.parse(element.textContent);
13
+ if (typeof payload.source !== "string") throw new Error("Embedded Ruby source is missing.");
14
+ return payload.source;
15
+ }
16
+ return element.textContent;
17
+ }
18
+
19
+ async function readSource(element) {
20
+ if (!element.src) return textSource(element);
21
+ const response = await fetch(element.src, { credentials: "omit" });
22
+ if (!response.ok) throw new Error(`Ruby source download failed: ${response.status}`);
23
+ return response.text();
24
+ }
25
+
26
+ function dispose(selector, replaceCanvas) {
27
+ const old = sessions.get(selector);
28
+ if (!old) return;
29
+ old.worker.terminate();
30
+ URL.revokeObjectURL(old.workerBlobUrl);
31
+ old.listeners.forEach(([target, type, listener, options]) => target.removeEventListener(type, listener, options));
32
+ if (replaceCanvas && old.canvas.isConnected) {
33
+ const canvas = old.canvas.cloneNode(false);
34
+ canvas.style.imageRendering = "";
35
+ old.canvas.replaceWith(canvas);
36
+ }
37
+ sessions.delete(selector);
38
+ }
39
+
40
+ async function start(source, { canvas: selector = "#screen", runtime, statusElement, onStatus } = {}) {
41
+ if (typeof source !== "string") throw new TypeError("Ruby source must be a string.");
42
+ if (new TextEncoder().encode(source).length > sourceLimit) throw new Error("Ruby source exceeds 32 KB.");
43
+ dispose(selector, true);
9
44
 
10
- async function run(sourceElement) {
11
- const selector = sourceElement.dataset.canvas || "#screen";
12
45
  const canvas = document.querySelector(selector);
13
46
  if (!(canvas instanceof HTMLCanvasElement)) throw new Error(`Canvas not found: ${selector}`);
14
- if (new TextEncoder().encode(sourceElement.textContent).length > 32768) throw new Error("Ruby source exceeds 32 KB.");
15
-
16
- const status = document.createElement("output");
17
- status.setAttribute("role", "status");
18
- status.setAttribute("aria-live", "polite");
19
- canvas.insertAdjacentElement("afterend", status);
20
- const report = message => { status.textContent = message; };
21
- report("Loading Ruby runtime…");
47
+ const status = statusElement || document.createElement("output");
48
+ if (!statusElement) {
49
+ status.setAttribute("role", "status");
50
+ status.setAttribute("aria-live", "polite");
51
+ canvas.insertAdjacentElement("afterend", status);
52
+ }
53
+ const report = detail => {
54
+ if (onStatus) onStatus(detail);
55
+ else status.textContent = detail.message || "";
56
+ };
57
+ report({ state: "loading", message: "Loading Ruby runtime…" });
22
58
  if (canvas.tabIndex < 0) canvas.tabIndex = 0;
23
59
 
24
- const response = await fetch(workerUrl, { credentials: "omit" });
60
+ const response = await fetch(new URL("./worker.js", base), { credentials: "omit" });
25
61
  if (!response.ok) throw new Error(`Worker download failed: ${response.status}`);
26
62
  const workerBlobUrl = URL.createObjectURL(new Blob([await response.text()], { type: "text/javascript" }));
27
63
  const worker = new Worker(workerBlobUrl, { type: "module", name: "webvas" });
28
- worker.addEventListener("message", event => {
64
+ const listeners = [];
65
+ const session = { canvas, worker, workerBlobUrl, listeners };
66
+ sessions.set(selector, session);
67
+
68
+ const listen = (target, type, callback, options) => {
69
+ target.addEventListener(type, callback, options);
70
+ listeners.push([target, type, callback, options]);
71
+ };
72
+ listen(worker, "message", event => {
29
73
  const message = event.data || {};
30
- if (message.type === "webvas:loading") report(message.message || "Loading Ruby runtime…");
74
+ if (message.type === "webvas:loading") report({ state: "loading", message: message.message || "Loading Ruby runtime…" });
31
75
  else if (message.type === "webvas:ready") {
32
76
  URL.revokeObjectURL(workerBlobUrl);
33
- worker.postMessage({ type: "webvas:run", source: sourceElement.textContent });
34
- } else if (message.type === "webvas:started") report("Ruby sketch is running");
35
- else if (message.type === "webvas:error") report([message.message, message.backtrace].filter(Boolean).join("\n"));
77
+ worker.postMessage({ type: "webvas:run", source });
78
+ } else if (message.type === "webvas:started") report({ state: "running", message: "Ruby sketch is running" });
79
+ else if (message.type === "webvas:error") report({ state: "error", message: message.message || "Ruby sketch failed", backtrace: message.backtrace || "" });
80
+ else if (message.type === "webvas:metrics") report({ state: "metrics", ...message });
36
81
  else if (message.type === "webvas:pixelated" && message.selector === selector) {
37
- canvas.style.imageRendering = message.enabled ? "pixelated" : "auto";
82
+ const enabled = message.enabled === true || message.enabled === "true";
83
+ canvas.dataset.pixelated = String(enabled);
84
+ canvas.style.imageRendering = enabled ? "pixelated" : "auto";
38
85
  }
39
86
  });
40
- worker.addEventListener("error", event => {
87
+ listen(worker, "error", event => {
41
88
  URL.revokeObjectURL(workerBlobUrl);
42
- report(event.message || "Webvas worker failed");
89
+ report({ state: "error", message: event.message || "Webvas worker failed" });
43
90
  });
91
+ listen(worker, "messageerror", () => report({ state: "error", message: "Webvas worker sent an unreadable message." }));
44
92
 
45
93
  const forward = event => {
46
94
  if (event.type === "wheel") event.preventDefault();
@@ -54,7 +102,7 @@
54
102
  type: "webvas:input",
55
103
  event: {
56
104
  type: event.type === "pointercancel" ? "pointerup" : event.type,
57
- clientX: event.clientX || 0, clientY: event.clientY || 0,
105
+ clientX: event.clientX ?? 0, clientY: event.clientY ?? 0,
58
106
  left: rect.left, top: rect.top, rectWidth: rect.width, rectHeight: rect.height,
59
107
  button: event.button, deltaX: event.deltaX, deltaY: event.deltaY,
60
108
  code: event.code, key: event.key, shiftKey: event.shiftKey,
@@ -63,24 +111,52 @@
63
111
  });
64
112
  };
65
113
  for (const type of ["pointerdown", "pointerup", "pointermove", "pointercancel", "keydown", "keyup", "wheel"]) {
66
- canvas.addEventListener(type, forward, type === "wheel" ? { passive: false } : undefined);
114
+ listen(canvas, type, forward, type === "wheel" ? { passive: false } : undefined);
67
115
  }
68
116
 
117
+ if (typeof canvas.transferControlToOffscreen !== "function") throw new Error("OffscreenCanvas is unavailable in this browser.");
69
118
  const offscreen = canvas.transferControlToOffscreen();
70
119
  worker.postMessage({
71
120
  type: "webvas:init", canvas: offscreen, selector,
72
- bridgeUrl, wasmUrl: new URL(runtimeUrl, document.baseURI).href
121
+ bridgeUrl: new URL("./bridge.js", base).href,
122
+ wasmUrl: new URL(runtime || defaultRuntime, document.baseURI).href
73
123
  }, [offscreen]);
124
+ return session;
125
+ }
126
+
127
+ async function run(source, options = {}) {
128
+ try {
129
+ return await start(source, options);
130
+ } catch (error) {
131
+ const detail = { state: "error", message: error.message || String(error) };
132
+ dispose(options.canvas || "#screen", true);
133
+ if (options.onStatus) options.onStatus(detail);
134
+ else if (options.statusElement) options.statusElement.textContent = detail.message;
135
+ else throw error;
136
+ }
74
137
  }
75
138
 
76
- const start = () => document.querySelectorAll('script[type="text/ruby"][data-webvas]').forEach(element => {
77
- run(element).catch(error => {
139
+ globalThis.WebvasPlayground = {
140
+ run,
141
+ stop(canvas = "#screen") { dispose(canvas, true); }
142
+ };
143
+
144
+ const startEmbedded = () => {
145
+ for (const element of document.querySelectorAll('script[type="text/ruby"][data-webvas],script[type="application/json"][data-webvas-source]')) {
78
146
  const status = document.createElement("output");
79
- status.setAttribute("role", "alert");
80
- status.textContent = error.message || String(error);
147
+ status.setAttribute("role", "status");
148
+ status.setAttribute("aria-live", "polite");
81
149
  element.insertAdjacentElement("afterend", status);
82
- });
83
- });
84
- if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start, { once: true });
85
- else start();
150
+ readSource(element).then(source => run(source, {
151
+ canvas: element.dataset.canvas || "#screen",
152
+ runtime: element.dataset.runtime,
153
+ statusElement: status
154
+ })).catch(error => {
155
+ status.setAttribute("role", "alert");
156
+ status.textContent = error.message || String(error);
157
+ });
158
+ }
159
+ };
160
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", startEmbedded, { once: true });
161
+ else startEmbedded();
86
162
  })();
@@ -19,7 +19,14 @@ module Webvas
19
19
  def set_pixels(buffer, width, height)
20
20
  width = Integer(width)
21
21
  height = Integer(height)
22
- bytes = validate_rgba_buffer(buffer, width, height)
22
+ bytes = String.try_convert(buffer)
23
+ raise ArgumentError, "Pixel buffer must be a String" unless bytes
24
+
25
+ expected_size = width * height * 4
26
+ unless width.positive? && height.positive? && bytes.bytesize == expected_size
27
+ raise ArgumentError, "Pixel buffer size mismatch: expected #{expected_size}, got #{bytes.bytesize}"
28
+ end
29
+
23
30
  resize(width, height) if [width, height] != [@width, @height]
24
31
  @pending_pixels = [bytes, width, height]
25
32
  true
data/lib/webvas/bridge.rb CHANGED
@@ -18,6 +18,10 @@ module Webvas
18
18
  @api.present(handle, Base64.strict_encode64(bytes), width, height)
19
19
  end
20
20
 
21
+ def run_shader(selector, wgsl, uniforms)
22
+ @api.runShader(selector, wgsl, JSON.generate(uniforms))
23
+ end
24
+
21
25
  def events(handle)
22
26
  JSON.parse(@api.events(handle).to_s)
23
27
  end
data/lib/webvas/cli.rb ADDED
@@ -0,0 +1,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "optparse"
5
+ require "socket"
6
+ require "uri"
7
+
8
+ module Webvas
9
+ class CLI
10
+ ROOT = File.expand_path("../..", __dir__)
11
+ CDN = "https://rbgfx.github.io/webvas"
12
+ LIVE_SCRIPT = '(()=>{const s=new EventSource("/__webvas/events");s.onmessage=()=>location.reload()})();'
13
+ CONTENT_TYPES = {
14
+ ".css" => "text/css; charset=utf-8", ".html" => "text/html; charset=utf-8",
15
+ ".js" => "text/javascript; charset=utf-8", ".json" => "application/json",
16
+ ".png" => "image/png", ".svg" => "image/svg+xml", ".wasm" => "application/wasm",
17
+ ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".gif" => "image/gif",
18
+ ".rb" => "text/plain; charset=utf-8", ".txt" => "text/plain; charset=utf-8"
19
+ }.freeze
20
+
21
+ def self.run(arguments = ARGV, out: $stdout, err: $stderr)
22
+ command = arguments.shift
23
+ case command
24
+ when "new" then new_project(arguments, out:)
25
+ when "serve" then serve(arguments, out:)
26
+ when "build" then build(arguments, out:)
27
+ when "--help", "-h", nil then out.puts help; 0
28
+ else raise Error, "unknown command: #{command}\n#{help}"
29
+ end
30
+ rescue Error, OptionParser::ParseError, SystemCallError, Interrupt => error
31
+ err.puts(error.message) unless error.is_a?(Interrupt)
32
+ error.is_a?(Interrupt) ? 0 : 1
33
+ end
34
+
35
+ def self.help
36
+ <<~HELP
37
+ Usage: webvas <command>
38
+
39
+ Commands:
40
+ new NAME Create a browser sketch project
41
+ serve [--root DIR] Serve the current project and reload on changes
42
+ build [-o DIR] Build a static site using the official runtime
43
+ [--runtime custom] Build a runtime from the project's Gemfile
44
+ HELP
45
+ end
46
+
47
+ def self.new_project(arguments, out:)
48
+ name = arguments.shift
49
+ raise Error, "Usage: webvas new NAME" unless name && arguments.empty?
50
+ raise Error, "Project name must be one path component." unless name.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]*\z/) && !%w[. ..].include?(name)
51
+
52
+ destination = File.expand_path(name)
53
+ raise Error, "Directory already exists and is not empty: #{destination}" if File.directory?(destination) && !Dir.empty?(destination)
54
+ raise Error, "Path already exists: #{destination}" if File.exist?(destination) && !File.directory?(destination)
55
+
56
+ FileUtils.mkdir_p(destination)
57
+ html = project_html.gsub("__WEBVAS_LOADER__", "#{CDN}/loader.js")
58
+ .gsub("__WEBVAS_RUNTIME__", "#{CDN}/assets/webvas.wasm")
59
+ files = {
60
+ "index.html" => html,
61
+ "app.rb" => project_source,
62
+ "Gemfile" => project_gemfile
63
+ }
64
+ existing = files.keys.select { |file| File.exist?(File.join(destination, file)) }
65
+ raise Error, "Refusing to overwrite: #{existing.join(', ')}" unless existing.empty?
66
+ files.each { |file, content| File.write(File.join(destination, file), content) }
67
+ out.puts "Created #{destination}"
68
+ 0
69
+ end
70
+
71
+ def self.serve(arguments, out:)
72
+ options = { host: "127.0.0.1", port: 8000, root: Dir.pwd }
73
+ OptionParser.new do |parser|
74
+ parser.on("--host HOST") { |value| options[:host] = value }
75
+ parser.on("-p", "--port PORT", Integer) { |value| options[:port] = value }
76
+ parser.on("--root DIR") { |value| options[:root] = value }
77
+ end.parse!(arguments)
78
+ raise Error, "Unexpected arguments: #{arguments.join(' ')}" unless arguments.empty?
79
+ root = File.realpath(options[:root])
80
+ raise Error, "Project root must be a directory: #{root}" unless File.directory?(root)
81
+ raise Error, "Port must be between 0 and 65535." unless (0..65_535).cover?(options[:port])
82
+
83
+ server = Server.new(root, host: options[:host], port: options[:port])
84
+ out.puts "Serving #{root} at http://#{options[:host]}:#{server.port}"
85
+ server.start
86
+ 0
87
+ ensure
88
+ server&.close
89
+ end
90
+
91
+ def self.build(arguments, out:)
92
+ options = { output: "dist", runtime: "official", root: Dir.pwd }
93
+ OptionParser.new do |parser|
94
+ parser.on("-o", "--output DIR") { |value| options[:output] = value }
95
+ parser.on("--runtime RUNTIME") { |value| options[:runtime] = value }
96
+ parser.on("--root DIR") { |value| options[:root] = value }
97
+ end.parse!(arguments)
98
+ raise Error, "Unexpected arguments: #{arguments.join(' ')}" unless arguments.empty?
99
+ raise Error, "Runtime must be official or custom." unless %w[official custom].include?(options[:runtime])
100
+
101
+ source = File.realpath(options[:root])
102
+ destination = File.expand_path(options[:output], source)
103
+ raise Error, "Build output cannot overwrite the project." if destination == source
104
+ if destination == File.join(source, "assets") || destination.start_with?(File.join(source, "assets") + File::SEPARATOR)
105
+ raise Error, "Build output cannot be inside the project's assets directory."
106
+ end
107
+ raise Error, "Build output cannot contain the project." if source.start_with?(destination + File::SEPARATOR)
108
+ raise Error, "Project is missing index.html or app.rb." unless %w[index.html app.rb].all? { |file| File.file?(File.join(source, file)) }
109
+
110
+ FileUtils.mkdir_p(destination)
111
+ %w[index.html app.rb].each { |file| FileUtils.cp(File.join(source, file), destination) }
112
+ assets = File.join(source, "assets")
113
+ copy_assets(assets, File.join(destination, "assets")) if File.directory?(assets)
114
+ index = File.read(File.join(destination, "index.html"))
115
+ if options[:runtime] == "custom"
116
+ build_custom_runtime(source, File.join(destination, "assets"))
117
+ webvas_assets = File.join(destination, "assets", "webvas")
118
+ FileUtils.mkdir_p(webvas_assets)
119
+ %w[bridge.js loader.js worker.js].each do |file|
120
+ FileUtils.cp(File.join(ROOT, "js", file), webvas_assets)
121
+ end
122
+ index = index.gsub("__WEBVAS_LOADER__", "./assets/webvas/loader.js")
123
+ .gsub("__WEBVAS_RUNTIME__", "./assets/webvas.wasm")
124
+ else
125
+ index = index.gsub("__WEBVAS_LOADER__", "#{CDN}/loader.js")
126
+ .gsub("__WEBVAS_RUNTIME__", "#{CDN}/assets/webvas.wasm")
127
+ end
128
+ index = index.gsub("<!-- webvas:dev -->", "")
129
+ File.write(File.join(destination, "index.html"), index)
130
+ out.puts "Built #{destination} (#{options[:runtime]} runtime)"
131
+ 0
132
+ end
133
+
134
+ def self.copy_assets(source, destination)
135
+ FileUtils.mkdir_p(destination)
136
+ Dir.children(source).each do |name|
137
+ path = File.join(source, name)
138
+ next if File.symlink?(path)
139
+
140
+ FileUtils.cp_r(path, destination)
141
+ end
142
+ end
143
+ private_class_method :copy_assets
144
+
145
+ def self.build_custom_runtime(source, assets)
146
+ gemfile = File.join(source, "Gemfile")
147
+ raise Error, "Custom runtime requires a project Gemfile." unless File.file?(gemfile)
148
+ FileUtils.mkdir_p(assets)
149
+ command = ["bundle", "exec", "rbwasm", "build", "--ruby-version", "4.0",
150
+ "--target", "wasm32-unknown-wasip1", "--build-profile", "full"]
151
+ patch = File.join(ROOT, "patches", "psych-wasi.patch")
152
+ command.concat(["--patch", patch]) if File.file?(patch)
153
+ command.concat(["-o", File.join(assets, "webvas.wasm")])
154
+ success = Dir.chdir(source) { system({ "BUNDLE_GEMFILE" => gemfile }, *command) }
155
+ raise Error, "Custom runtime build failed. Install the project's bundle and check rbwasm." unless success
156
+ end
157
+ private_class_method :build_custom_runtime
158
+
159
+ def self.project_html
160
+ <<~HTML
161
+ <!doctype html>
162
+ <html lang="en">
163
+ <head>
164
+ <meta charset="utf-8">
165
+ <meta name="viewport" content="width=device-width, initial-scale=1">
166
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://rbgfx.github.io https://cdn.jsdelivr.net 'unsafe-eval' 'wasm-unsafe-eval'; worker-src 'self' blob:; connect-src 'self' https://rbgfx.github.io https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'">
167
+ <title>My Webvas sketch</title>
168
+ <style>body{margin:2rem auto;max-width:760px;padding:0 1rem;background:#101312;color:#e7e9e2;font:16px/1.5 system-ui}canvas{display:block;width:min(100%,640px);height:auto;aspect-ratio:4/3;background:#101827;image-rendering:pixelated}output{display:block;white-space:pre-wrap;overflow-wrap:anywhere}</style>
169
+ </head>
170
+ <body>
171
+ <h1>My Webvas sketch</h1>
172
+ <canvas id="screen" width="320" height="240" aria-label="Ruby sketch"></canvas>
173
+ <script type="text/ruby" data-webvas data-canvas="#screen" src="./app.rb"></script>
174
+ <!-- webvas:dev -->
175
+ <script data-webvas-loader src="__WEBVAS_LOADER__" data-runtime="__WEBVAS_RUNTIME__"></script>
176
+ </body>
177
+ </html>
178
+ HTML
179
+ end
180
+ private_class_method :project_html
181
+
182
+ def self.project_source
183
+ <<~RUBY
184
+ require "gesso"
185
+
186
+ Gesso.run(width: 320, height: 240, runner: :web, pixelated: true) do
187
+ draw do
188
+ background "#101827"
189
+ no_stroke
190
+ fill "#f07850"
191
+ circle width / 2 + Math.sin(frame_count * 0.05) * 60, height / 2, 28
192
+ end
193
+ end
194
+ RUBY
195
+ end
196
+ private_class_method :project_source
197
+
198
+ def self.project_gemfile
199
+ <<~RUBY
200
+ source "https://rubygems.org"
201
+
202
+ gem "ruby_wasm", "~> 2.10.1"
203
+ gem "js", "~> 2.10.1"
204
+ gem "webvas", "~> #{Webvas::VERSION}"
205
+ gem "rbgl"
206
+ gem "gesso"
207
+ gem "rlsl"
208
+ gem "glyphic"
209
+ RUBY
210
+ end
211
+ private_class_method :project_gemfile
212
+
213
+ class Server
214
+ attr_reader :port
215
+
216
+ def initialize(root, host:, port:)
217
+ @root = File.realpath(root)
218
+ @listener = TCPServer.new(host, port)
219
+ @port = @listener.addr[1]
220
+ @threads = []
221
+ end
222
+
223
+ def start
224
+ loop do
225
+ socket = @listener.accept
226
+ @threads << Thread.new(socket) { |client| handle(client) }
227
+ end
228
+ rescue IOError, Errno::EBADF
229
+ nil
230
+ end
231
+
232
+ def close
233
+ @listener.close unless @listener.closed?
234
+ @threads.each { |thread| thread.kill if thread.alive? }
235
+ end
236
+
237
+ private
238
+
239
+ def handle(socket)
240
+ request = socket.gets("\r\n", 8192)
241
+ return unless request
242
+ method, target = request.split(" ", 3)
243
+ header_bytes = 0
244
+ while (line = socket.gets("\r\n")) && line != "\r\n"
245
+ header_bytes += line.bytesize
246
+ return response(socket, 400, "text/plain; charset=utf-8", "Headers too large") if header_bytes > 16_384
247
+ end
248
+ return response(socket, 405, "text/plain", "GET only") unless method == "GET"
249
+ return response(socket, 400, "text/plain; charset=utf-8", "Bad request") unless target
250
+
251
+ uri = URI.parse(target)
252
+ return events(socket) if uri.path == "/__webvas/events"
253
+ return response(socket, 200, "text/javascript; charset=utf-8", LIVE_SCRIPT) if uri.path == "/__webvas/live.js"
254
+
255
+ file = safe_file(uri.path)
256
+ return response(socket, 404, "text/plain; charset=utf-8", "Not found") unless file
257
+ body = File.binread(file)
258
+ if File.basename(file) == "index.html" && body.include?("<!-- webvas:dev -->")
259
+ body = body.sub("<!-- webvas:dev -->", '<script src="/__webvas/live.js"></script>')
260
+ end
261
+ response(socket, 200, CONTENT_TYPES.fetch(File.extname(file), "application/octet-stream"), body)
262
+ rescue URI::InvalidURIError, ArgumentError
263
+ response(socket, 400, "text/plain; charset=utf-8", "Bad request")
264
+ rescue IOError, SystemCallError
265
+ nil
266
+ ensure
267
+ socket.close unless socket.closed?
268
+ end
269
+
270
+ def safe_file(path)
271
+ decoded = URI::RFC2396_PARSER.unescape(path)
272
+ return if decoded.include?("\0")
273
+ parts = decoded.split("/")
274
+ return if parts.any? { |part| part.start_with?(".") }
275
+ return if %w[.bundle build dist node_modules tmp vendor].include?(parts.first)
276
+ candidate = File.expand_path(decoded.delete_prefix("/"), @root)
277
+ return unless candidate.start_with?("#{@root}#{File::SEPARATOR}") || candidate == @root
278
+
279
+ candidate = File.join(candidate, "index.html") if File.directory?(candidate)
280
+ real = File.realpath(candidate)
281
+ return if real != @root && !real.start_with?("#{@root}#{File::SEPARATOR}")
282
+ return unless File.file?(real)
283
+
284
+ real
285
+ rescue Errno::ENOENT, Errno::EACCES
286
+ nil
287
+ end
288
+
289
+ def signature
290
+ hidden = %w[.bundle build dist node_modules tmp vendor]
291
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: @root).filter_map do |name|
292
+ next if name == "." || name == ".." || name.split("/").any? { |part| hidden.include?(part) || part.start_with?(".") }
293
+ path = File.join(@root, name)
294
+ stat = File.stat(path) rescue next
295
+ [name, stat.mtime.to_f, stat.size] if stat.file?
296
+ end.sort
297
+ end
298
+
299
+ def events(socket)
300
+ previous = signature
301
+ socket.write("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\nX-Content-Type-Options: nosniff\r\n\r\n: connected\n\n")
302
+ loop do
303
+ sleep 0.5
304
+ current = signature
305
+ if current != previous
306
+ socket.write("data: reload\n\n")
307
+ previous = current
308
+ end
309
+ end
310
+ end
311
+
312
+ def response(socket, status, type, body)
313
+ label = { 200 => "OK", 400 => "Bad Request", 404 => "Not Found", 405 => "Method Not Allowed" }.fetch(status)
314
+ socket.write("HTTP/1.1 #{status} #{label}\r\nContent-Type: #{type}\r\nContent-Length: #{body.bytesize}\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n")
315
+ socket.write(body)
316
+ end
317
+ end
318
+ end
319
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webvas
4
+ class Shader
5
+ attr_reader :wgsl, :canvas
6
+
7
+ def initialize(wgsl, canvas: "#screen")
8
+ @wgsl = String(wgsl)
9
+ @canvas = String(canvas)
10
+ raise ArgumentError, "WGSL source cannot be empty" if @wgsl.empty?
11
+ end
12
+ end
13
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Webvas
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/webvas.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "webvas/backend"
7
7
  require_relative "webvas/input"
8
8
  require_relative "webvas/key_map"
9
9
  require_relative "webvas/runner"
10
+ require_relative "webvas/shader"
10
11
 
11
12
  module Webvas
12
13
  class Error < StandardError; end
@@ -36,4 +37,10 @@ module Webvas
36
37
  Bridge.new.show_error(error.message, error.backtrace || [])
37
38
  end
38
39
 
40
+ def self.run_shader(shader, uniforms: {})
41
+ raise TypeError, "expected a Webvas::Shader" unless shader.is_a?(Shader)
42
+
43
+ Bridge.new.run_shader(shader.canvas, shader.wgsl, uniforms)
44
+ end
45
+
39
46
  end
@@ -0,0 +1,9 @@
1
+ --- a/ext/psych/extconf.rb
2
+ +++ b/ext/psych/extconf.rb
3
+ @@ -40,3 +40,3 @@
4
+ # default to pre-installed libyaml
5
+ -elsif pkg_config('yaml-0.1')
6
+ +elsif !CROSS_COMPILING && pkg_config('yaml-0.1')
7
+ # found with pkg-config
8
+ else
9
+ dir_config('libyaml')
data/sig/webvas.rbs CHANGED
@@ -4,10 +4,17 @@ module Webvas
4
4
  class Error < StandardError
5
5
  end
6
6
 
7
+ class Shader
8
+ attr_reader wgsl: String
9
+ attr_reader canvas: String
10
+ def initialize: (String wgsl, ?canvas: String) -> void
11
+ end
12
+
7
13
  class Bridge
8
14
  def initialize: (?api: untyped) -> void
9
15
  def attach: (String selector, Integer width, Integer height, pixelated: bool) -> untyped
10
16
  def present: (untyped handle, String bytes, Integer width, Integer height) -> untyped
17
+ def run_shader: (String selector, String wgsl, Hash[String, untyped] uniforms) -> untyped
11
18
  def events: (untyped handle) -> Array[Hash[String, untyped]]
12
19
  def resize: (untyped handle, Integer width, Integer height) -> untyped
13
20
  def close: (untyped handle) -> untyped
@@ -45,4 +52,5 @@ module Webvas
45
52
 
46
53
  def self.run: (RBGL::GUI::Window window, ?scheduler: Scheduler, ?on_error: Proc) { (RBGL::Engine::Context, Float) -> void } -> Proc
47
54
  def self.report_error: (StandardError error) -> untyped
55
+ def self.run_shader: (Shader shader, ?uniforms: Hash[Symbol | String, untyped]) -> untyped
48
56
  end
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: webvas
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -116,23 +116,28 @@ dependencies:
116
116
  description: An RBGL canvas backend and browser loader powered by ruby.wasm.
117
117
  email:
118
118
  - t.yudai92@gmail.com
119
- executables: []
119
+ executables:
120
+ - webvas
120
121
  extensions: []
121
122
  extra_rdoc_files: []
122
123
  files:
123
124
  - CHANGELOG.md
124
125
  - LICENSE.txt
125
126
  - README.md
127
+ - exe/webvas
126
128
  - js/bridge.js
127
129
  - js/loader.js
128
130
  - js/worker.js
129
131
  - lib/webvas.rb
130
132
  - lib/webvas/backend.rb
131
133
  - lib/webvas/bridge.rb
134
+ - lib/webvas/cli.rb
132
135
  - lib/webvas/input.rb
133
136
  - lib/webvas/key_map.rb
134
137
  - lib/webvas/runner.rb
138
+ - lib/webvas/shader.rb
135
139
  - lib/webvas/version.rb
140
+ - patches/psych-wasi.patch
136
141
  - sig/rbgl.rbs
137
142
  - sig/webvas.rbs
138
143
  homepage: https://github.com/rbgfx/webvas
@@ -156,7 +161,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
156
161
  - !ruby/object:Gem::Version
157
162
  version: '0'
158
163
  requirements: []
159
- rubygems_version: 4.0.16
164
+ rubygems_version: 4.0.20
160
165
  specification_version: 4
161
166
  summary: Run Ruby graphics in the browser
162
167
  test_files: []