webvas 0.3.0 → 0.3.2

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.
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,16 +118,16 @@
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
- globalThis.postMessage({ type: "webvas:size", width, height });
24
124
  return handle.id;
25
125
  },
26
126
 
27
127
  present(id, encoded, width, height) {
28
128
  const handle = canvases.get(id);
29
129
  if (!handle) throw new Error("Canvas backend is closed");
130
+ const started = performance.now();
30
131
  const { canvas, context } = handle;
31
132
  if (canvas.width !== width || canvas.height !== height) {
32
133
  canvas.width = width;
@@ -37,28 +138,29 @@
37
138
  const pixels = new Uint8ClampedArray(binary.length);
38
139
  for (let index = 0; index < binary.length; index += 1) pixels[index] = binary.charCodeAt(index);
39
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
+ }
40
151
  return true;
41
152
  },
42
153
 
154
+ runShader(selector, wgsl, uniforms) {
155
+ return startShader(selector, wgsl, uniforms);
156
+ },
157
+
43
158
  events(id) {
44
159
  const handle = canvases.get(id);
45
160
  return JSON.stringify(handle ? handle.events.splice(0) : []);
46
161
  },
47
162
 
48
163
  pushEvent(event) {
49
- const canvas = canvases.values().next().value?.canvas || globalThis.WebvasCanvases?.["#screen"];
50
- if (canvas && event.clientX !== undefined) {
51
- const width = Number(event.rectWidth);
52
- const height = Number(event.rectHeight);
53
- const x = width > 0 ? Math.floor((event.clientX - event.left) * canvas.width / width) : 0;
54
- const y = height > 0 ? Math.floor((event.clientY - event.top) * canvas.height / height) : 0;
55
- const mouse = globalThis.WebvasMouse || [0, 0, 0, 0];
56
- mouse[0] = Math.max(0, Math.min(canvas.width - 1, x));
57
- mouse[1] = canvas.height - 1 - Math.max(0, Math.min(canvas.height - 1, y));
58
- if (event.type === "pointerdown") [mouse[2], mouse[3]] = [mouse[0], mouse[1]];
59
- if (event.type === "pointerup") [mouse[2], mouse[3]] = [-Math.abs(mouse[2]), -Math.abs(mouse[3])];
60
- globalThis.WebvasMouse = mouse;
61
- }
62
164
  if (!canvases.size) {
63
165
  if (pendingEvents.length === eventLimit) pendingEvents.shift();
64
166
  pendingEvents.push(event);
@@ -79,10 +181,6 @@
79
181
  canvases.delete(id);
80
182
  },
81
183
 
82
- shaderMode(canvas) {
83
- globalThis.postMessage({ type: "webvas:mode", canvas });
84
- },
85
-
86
184
  showError(message, backtrace) {
87
185
  globalThis.postMessage({ type: "webvas:error", message, backtrace });
88
186
  }
data/js/loader.js CHANGED
@@ -3,71 +3,106 @@
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;
9
- const shaderUrl = new URL("./wgsl-runner.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;
10
9
 
11
- async function run(sourceElement) {
12
- const selector = sourceElement.dataset.canvas || "#screen";
13
- const canvas = document.querySelector(selector);
14
- if (!(canvas instanceof HTMLCanvasElement)) throw new Error(`Canvas not found: ${selector}`);
15
- if (new TextEncoder().encode(sourceElement.textContent).length > 32768) throw new Error("Ruby source exceeds 32 KB.");
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
+ }
16
18
 
17
- const status = document.createElement("output");
18
- status.setAttribute("role", "status");
19
- status.setAttribute("aria-live", "polite");
20
- canvas.insertAdjacentElement("afterend", status);
21
- const report = message => { status.textContent = message; };
22
- report("Loading Ruby runtime…");
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
+ }
23
25
 
24
- const gpu = document.querySelector("#gpu") || document.createElement("canvas");
25
- gpu.hidden = true;
26
- if (!gpu.isConnected) {
27
- gpu.id = "gpu";
28
- gpu.hidden = true;
29
- canvas.insertAdjacentElement("afterend", gpu);
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);
44
+
45
+ const canvas = document.querySelector(selector);
46
+ if (!(canvas instanceof HTMLCanvasElement)) throw new Error(`Canvas not found: ${selector}`);
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);
30
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…" });
31
58
  if (canvas.tabIndex < 0) canvas.tabIndex = 0;
32
59
 
33
- const response = await fetch(workerUrl, { credentials: "omit" });
60
+ const response = await fetch(new URL("./worker.js", base), { credentials: "omit" });
34
61
  if (!response.ok) throw new Error(`Worker download failed: ${response.status}`);
35
62
  const workerBlobUrl = URL.createObjectURL(new Blob([await response.text()], { type: "text/javascript" }));
36
63
  const worker = new Worker(workerBlobUrl, { type: "module", name: "webvas" });
37
- const screenSelector = selector;
38
- 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 => {
39
73
  const message = event.data || {};
40
- 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…" });
41
75
  else if (message.type === "webvas:ready") {
42
76
  URL.revokeObjectURL(workerBlobUrl);
43
- worker.postMessage({ type: "webvas:run", source: sourceElement.textContent });
44
- }
45
- else if (message.type === "webvas:started") report("Ruby sketch is running");
46
- else if (message.type === "webvas:error") report([message.message, message.backtrace].filter(Boolean).join("\n"));
47
- else if (message.type === "webvas:pixelated" && message.selector === screenSelector) {
48
- canvas.style.imageRendering = message.enabled ? "pixelated" : "auto";
49
- } else if (message.type === "webvas:mode") {
50
- canvas.hidden = message.canvas !== screenSelector;
51
- gpu.hidden = message.canvas !== "#gpu";
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 });
81
+ else if (message.type === "webvas:pixelated" && message.selector === selector) {
82
+ const enabled = message.enabled === true || message.enabled === "true";
83
+ canvas.dataset.pixelated = String(enabled);
84
+ canvas.style.imageRendering = enabled ? "pixelated" : "auto";
52
85
  }
53
86
  });
54
- worker.addEventListener("error", event => {
87
+ listen(worker, "error", event => {
55
88
  URL.revokeObjectURL(workerBlobUrl);
56
- report(event.message || "Webvas worker failed");
89
+ report({ state: "error", message: event.message || "Webvas worker failed" });
57
90
  });
91
+ listen(worker, "messageerror", () => report({ state: "error", message: "Webvas worker sent an unreadable message." }));
58
92
 
59
93
  const forward = event => {
60
94
  if (event.type === "wheel") event.preventDefault();
95
+ if (event.type === "keydown" && event.code === "Space") event.preventDefault();
61
96
  if (event.type === "pointerdown") {
62
97
  canvas.focus({ preventScroll: true });
63
98
  canvas.setPointerCapture(event.pointerId);
64
99
  }
65
- const rect = event.currentTarget.getBoundingClientRect();
100
+ const rect = canvas.getBoundingClientRect();
66
101
  worker.postMessage({
67
102
  type: "webvas:input",
68
103
  event: {
69
104
  type: event.type === "pointercancel" ? "pointerup" : event.type,
70
- clientX: event.clientX || 0, clientY: event.clientY || 0,
105
+ clientX: event.clientX ?? 0, clientY: event.clientY ?? 0,
71
106
  left: rect.left, top: rect.top, rectWidth: rect.width, rectHeight: rect.height,
72
107
  button: event.button, deltaX: event.deltaX, deltaY: event.deltaY,
73
108
  code: event.code, key: event.key, shiftKey: event.shiftKey,
@@ -76,25 +111,52 @@
76
111
  });
77
112
  };
78
113
  for (const type of ["pointerdown", "pointerup", "pointermove", "pointercancel", "keydown", "keyup", "wheel"]) {
79
- canvas.addEventListener(type, forward, type === "wheel" ? { passive: false } : undefined);
114
+ listen(canvas, type, forward, type === "wheel" ? { passive: false } : undefined);
80
115
  }
81
116
 
82
- const screen = canvas.transferControlToOffscreen();
83
- const gpuCanvas = gpu.transferControlToOffscreen();
117
+ if (typeof canvas.transferControlToOffscreen !== "function") throw new Error("OffscreenCanvas is unavailable in this browser.");
118
+ const offscreen = canvas.transferControlToOffscreen();
84
119
  worker.postMessage({
85
- type: "webvas:init", screen, gpu: gpuCanvas, screenSelector, gpuSelector: "#gpu",
86
- bridgeUrl, shaderUrl, wasmUrl: new URL(runtimeUrl, document.baseURI).href
87
- }, [screen, gpuCanvas]);
120
+ type: "webvas:init", canvas: offscreen, selector,
121
+ bridgeUrl: new URL("./bridge.js", base).href,
122
+ wasmUrl: new URL(runtime || defaultRuntime, document.baseURI).href
123
+ }, [offscreen]);
124
+ return session;
88
125
  }
89
126
 
90
- const start = () => document.querySelectorAll('script[type="text/ruby"][data-webvas]').forEach(element => {
91
- run(element).catch(error => {
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
+ }
137
+ }
138
+
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]')) {
92
146
  const status = document.createElement("output");
93
- status.setAttribute("role", "alert");
94
- status.textContent = error.message || String(error);
147
+ status.setAttribute("role", "status");
148
+ status.setAttribute("aria-live", "polite");
95
149
  element.insertAdjacentElement("afterend", status);
96
- });
97
- });
98
- if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start, { once: true });
99
- 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();
100
162
  })();
@@ -4,35 +4,11 @@ const pendingEvents = [];
4
4
 
5
5
  globalThis.window = {
6
6
  requestAnimationFrame(callback) {
7
- return setTimeout(() => {
8
- const now = performance.now();
9
- const started = now;
10
- callback(now);
11
- frameMetrics(now, performance.now() - started);
12
- }, 1000 / 60);
7
+ return setTimeout(() => callback(performance.now()), 1000 / 60);
13
8
  }
14
9
  };
15
10
  globalThis.requestAnimationFrame ||= callback => globalThis.window.requestAnimationFrame(callback);
16
11
 
17
- let metricFrames = 0;
18
- let metricStarted;
19
- let callbackTime = 0;
20
-
21
- function frameMetrics(now, elapsed) {
22
- metricStarted ??= now;
23
- metricFrames += 1;
24
- callbackTime += elapsed;
25
- if (metricFrames < 60) return;
26
- const duration = now - metricStarted;
27
- send("metrics", {
28
- fps: (metricFrames - 1) * 1000 / duration,
29
- callbackMs: callbackTime / metricFrames
30
- });
31
- metricFrames = 0;
32
- callbackTime = 0;
33
- metricStarted = now;
34
- }
35
-
36
12
  function send(type, payload = {}) {
37
13
  globalThis.postMessage({ type: "webvas:" + type, ...payload });
38
14
  }
@@ -42,11 +18,7 @@ globalThis.addEventListener("message", async event => {
42
18
  try {
43
19
  if (message?.type === "webvas:init") {
44
20
  await import(message.bridgeUrl);
45
- await import(message.shaderUrl);
46
- globalThis.WebvasCanvases = {
47
- [message.screenSelector || "#screen"]: message.screen,
48
- [message.gpuSelector || "#gpu"]: message.gpu
49
- };
21
+ globalThis.WebvasCanvases = { [message.selector]: message.canvas };
50
22
  pendingEvents.splice(0).forEach(input => globalThis.WebvasBridge.pushEvent(input));
51
23
  send("loading", { message: "Downloading the Ruby drawing machine…" });
52
24
  const { DefaultRubyVM } = await import("https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.10.1/dist/browser/+esm");
@@ -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
@@ -37,10 +41,5 @@ module Webvas
37
41
  def show_error(message, backtrace)
38
42
  @api.showError(String(message), Array(backtrace).join("\n"))
39
43
  end
40
-
41
- def run_shader(canvas, source, layout, uniforms)
42
- @api.shaderMode(canvas)
43
- JS.global[:GlazeWGSL].run(canvas, source, layout, uniforms)
44
- end
45
44
  end
46
45
  end