webvas 0.3.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 +4 -4
- data/CHANGELOG.md +16 -5
- data/LICENSE.txt +1 -1
- data/README.md +68 -77
- data/exe/webvas +3 -267
- data/js/bridge.js +117 -19
- data/js/loader.js +114 -52
- data/{site → js}/worker.js +2 -30
- data/lib/webvas/backend.rb +8 -1
- data/lib/webvas/bridge.rb +4 -5
- data/lib/webvas/cli.rb +319 -0
- data/lib/webvas/shader.rb +4 -27
- data/lib/webvas/version.rb +1 -1
- data/lib/webvas.rb +4 -3
- data/sig/webvas.rbs +8 -11
- metadata +5 -35
- data/docs/performance.md +0 -12
- data/docs/security.md +0 -9
- data/docs/spikes.md +0 -18
- data/runtime/Gemfile +0 -22
- data/runtime/Gemfile.lock +0 -101
- data/script/build_site +0 -52
- data/site/app.js +0 -466
- data/site/index.html +0 -56
- data/site/loader-example.html +0 -26
- data/site/style.css +0 -89
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
|
|
7
|
-
const
|
|
8
|
-
const
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
if (!
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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(
|
|
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
|
|
38
|
-
worker
|
|
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
|
|
44
|
-
}
|
|
45
|
-
else if (message.type === "webvas:
|
|
46
|
-
else if (message.type === "webvas:
|
|
47
|
-
else if (message.type === "webvas:pixelated" && message.selector ===
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
canvas.
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
114
|
+
listen(canvas, type, forward, type === "wheel" ? { passive: false } : undefined);
|
|
80
115
|
}
|
|
81
116
|
|
|
82
|
-
|
|
83
|
-
const
|
|
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",
|
|
86
|
-
bridgeUrl
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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", "
|
|
94
|
-
status.
|
|
147
|
+
status.setAttribute("role", "status");
|
|
148
|
+
status.setAttribute("aria-live", "polite");
|
|
95
149
|
element.insertAdjacentElement("afterend", status);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
})();
|
data/{site → js}/worker.js
RENAMED
|
@@ -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
|
-
|
|
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");
|
data/lib/webvas/backend.rb
CHANGED
|
@@ -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 =
|
|
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
|