From a224bdb768f0c3eea638dba4fc23ed8566bb2bc2 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 2 Oct 2026 00:23:37 -0500 Subject: [PATCH 1/8] cvcGL: a WebGL state shim for every cvcGL wasm app webgl_state_shadow.js is an Emscripten --pre-js that answers the GL state queries made around every frame from a client-side shadow instead of a synchronous round trip to the browser's GPU process. The queries come from cvcGL's own code, so every cvcGL ImGui / Ariadne wasm app makes them: - imgui_impl_opengl3 is compiled into libcvcGL, ImGuiOverlay calls ImGui_ImplOpenGL3_RenderDrawData once per frame, and Ariadne's ImGuiBackend draws through ImGuiOverlay. That backend backs up and restores ACTIVE_TEXTURE, VIEWPORT, SCISSOR_BOX, the six BLEND_* values, five isEnabled flags and isProgram around every draw. - VTK re-reads READ_BUFFER after every read-framebuffer bind and MAX_DRAW_BUFFERS on every framebuffer activation, several times a frame. In a browser several of these block until the GPU process drains its command queue. Firefox does not cache ACTIVE_TEXTURE, so there ImGui's first query of each frame waits for all of the frame's queued GPU work. Every setter that can change a shadowed value is wrapped to keep a per-context copy current, and the matching query is answered from it. Anything the shim cannot prove goes to the real call: a rejected setter, a foreign framebuffer, a deleted program, BLEND_COLOR. The copy is dropped on context loss, and in a worker (OffscreenCanvas / PROXY_TO_PTHREAD) the shim does nothing. It patches the WebGL prototypes, so it serves every context on the page. Modes: on (default), 0/off (nothing installed: the A/B baseline), norb (READ_BUFFER goes to the real call) and verify (answer with the real value, compare it to the shadow, count mismatches). The mode comes from the URL (?glshim=), then the page (Module.glStateShadow), then a build-time default (Module.glStateShadowDefault), then on, so a URL can A/B any page. An unrecognized value still means on, but a console.warn names its source and the accepted values, so a mistyped baseline does not silently measure the shim twice. window.__cvcGlShadow.stats records the version, the mode and where it came from, and the served / verified / mismatch counts. src/cvcGL/test/webgl_state_shadow_test.js runs it under node against a mock WebGL with real GL/WebGL semantics: a seeded fuzz of valid and invalid state changes on WebGL1 and WebGL2, clamped and raw viewports, framebuffers and context loss, plus the mode precedence, the version and the typo warning. ctest registers it later in this series. --- src/cvcGL/test/webgl_state_shadow_test.js | 643 ++++++++++++++++++++++ src/cvcGL/wasm/webgl_state_shadow.js | 404 ++++++++++++++ 2 files changed, 1047 insertions(+) create mode 100644 src/cvcGL/test/webgl_state_shadow_test.js create mode 100644 src/cvcGL/wasm/webgl_state_shadow.js diff --git a/src/cvcGL/test/webgl_state_shadow_test.js b/src/cvcGL/test/webgl_state_shadow_test.js new file mode 100644 index 00000000..3bc370f2 --- /dev/null +++ b/src/cvcGL/test/webgl_state_shadow_test.js @@ -0,0 +1,643 @@ +// clang-format off: hand-formatted JS; .clang-format is the C++ style (CI formats only C++). +// webgl_state_shadow_test.js -- node unit test for src/cvcGL/wasm/webgl_state_shadow.js (the +// --pre-js cvcgl_wasm_app() links into every cvcGL WebAssembly app). +// +// A mock WebGL context implements the GL/WebGL semantics the shadow relies on, modelled on +// Chrome (blink + ANGLE) and Firefox (ClientWebGLContext): rejected calls leave state +// unchanged, WebIDL argument conversion, WebGL's constant-colour/constant-alpha rule, link +// status + deferred program deletion, BLEND_COLOR clamped only on WebGL1, viewport clamped to +// MAX_VIEWPORT_DIMS (Chrome) or cached raw (Firefox), the active texture unit's range, WebGL2 +// framebuffers (FRAMEBUFFER / READ_FRAMEBUFFER / DRAW_FRAMEBUFFER bindings, deletion of a bound +// framebuffer, a per-framebuffer READ_BUFFER and readBuffer's default-vs-FBO rules), context +// loss/restore. Each case loads the shim into a FRESH vm context with its own mock classes, then +// checks: +// 1. after seeding, shadowed queries make no call into the (synchronous) implementation; +// 2. a seeded random fuzz of valid AND invalid state changes -- the shadowed answer equals +// the mock's INTERNAL state (read directly, never through the shim) after every step, +// for WebGL1/WebGL2 x clamping/raw viewport x default / ?glshim=norb; the framebuffer ops +// draw from this context's live, deleted and pre-loss framebuffers, another context's and +// null / undefined; +// 3. edge cases the fuzz does not reach (listener ordering on restore, setters while lost, +// WebIDL coercion, argument pass-through, a foreign framebuffer, deleting the bound read +// framebuffer, WebGL1's unshadowed draw-buffer constants), and where the mode comes from: +// URL > Module.glStateShadow (page) > Module.glStateShadowDefault (build) > on, and the +// stats.version that names this copy; +// 4. ?glshim=0 installs nothing; ?glshim=verify answers with real values and counts 0 +// mismatches over the same fuzz (READ_BUFFER audited); no window (a pthread worker) is a +// no-op. +// Usage: node webgl_state_shadow_test.js [path/to/webgl_state_shadow.js] +// (default: ../wasm/webgl_state_shadow.js; registered with ctest as cvcgl_webgl_state_shadow) +'use strict'; +const fs = require('fs'); +const vm = require('vm'); +const assert = require('assert'); + +const SHIM = fs.readFileSync(process.argv[2] || `${__dirname}/../wasm/webgl_state_shadow.js`, 'utf8'); + +// Real WebGL enum values (subset used by the shim + the mock). +const E = { + ZERO: 0, ONE: 1, SRC_COLOR: 0x0300, ONE_MINUS_SRC_COLOR: 0x0301, SRC_ALPHA: 0x0302, + ONE_MINUS_SRC_ALPHA: 0x0303, DST_ALPHA: 0x0304, ONE_MINUS_DST_ALPHA: 0x0305, DST_COLOR: 0x0306, + ONE_MINUS_DST_COLOR: 0x0307, SRC_ALPHA_SATURATE: 0x0308, CONSTANT_COLOR: 0x8001, + ONE_MINUS_CONSTANT_COLOR: 0x8002, CONSTANT_ALPHA: 0x8003, ONE_MINUS_CONSTANT_ALPHA: 0x8004, + FUNC_ADD: 0x8006, FUNC_SUBTRACT: 0x800A, FUNC_REVERSE_SUBTRACT: 0x800B, MIN: 0x8007, MAX: 0x8008, + BLEND_EQUATION_RGB: 0x8009, BLEND_EQUATION_ALPHA: 0x883D, BLEND_DST_RGB: 0x80C8, + BLEND_SRC_RGB: 0x80C9, BLEND_DST_ALPHA: 0x80CA, BLEND_SRC_ALPHA: 0x80CB, BLEND_COLOR: 0x8005, + SCISSOR_BOX: 0x0C10, VIEWPORT: 0x0BA2, MAX_VIEWPORT_DIMS: 0x0D3A, CURRENT_PROGRAM: 0x8B8D, + BLEND: 0x0BE2, CULL_FACE: 0x0B44, DEPTH_TEST: 0x0B71, STENCIL_TEST: 0x0B90, SCISSOR_TEST: 0x0C11, + POLYGON_OFFSET_FILL: 0x8037, SAMPLE_ALPHA_TO_COVERAGE: 0x809E, SAMPLE_COVERAGE: 0x80A0, + DITHER: 0x0BD0, RASTERIZER_DISCARD: 0x8C89, ACTIVE_TEXTURE: 0x84E0, TEXTURE0: 0x84C0, + MAX_COMBINED_TEXTURE_IMAGE_UNITS: 0x8B4D, MAX_DRAW_BUFFERS: 0x8824, MAX_COLOR_ATTACHMENTS: 0x8CDF, + READ_BUFFER: 0x0C02, FRAMEBUFFER: 0x8D40, READ_FRAMEBUFFER: 0x8CA8, DRAW_FRAMEBUFFER: 0x8CA9, + READ_FRAMEBUFFER_BINDING: 0x8CAA, DRAW_FRAMEBUFFER_BINDING: 0x8CA6, BACK: 0x0405, NONE: 0, + COLOR_ATTACHMENT0: 0x8CE0, +}; +const MAXTEX = 32, MAXDB = 8, MAXCA = 4; // texture units, draw buffers, colour attachments +const FACTORS = [E.ZERO, E.ONE, E.SRC_COLOR, E.ONE_MINUS_SRC_COLOR, E.SRC_ALPHA, E.ONE_MINUS_SRC_ALPHA, + E.DST_ALPHA, E.ONE_MINUS_DST_ALPHA, E.DST_COLOR, E.ONE_MINUS_DST_COLOR, E.SRC_ALPHA_SATURATE, + E.CONSTANT_COLOR, E.ONE_MINUS_CONSTANT_COLOR, E.CONSTANT_ALPHA, E.ONE_MINUS_CONSTANT_ALPHA]; +const SHADOWED = ['SCISSOR_BOX', 'VIEWPORT', 'BLEND_SRC_RGB', 'BLEND_DST_RGB', 'BLEND_SRC_ALPHA', + 'BLEND_DST_ALPHA', 'BLEND_EQUATION_RGB', 'BLEND_EQUATION_ALPHA']; +const PARAMS = SHADOWED.concat(['BLEND_COLOR']); // BLEND_COLOR must pass through, exactly +const CAPS1 = ['BLEND', 'CULL_FACE', 'DEPTH_TEST', 'STENCIL_TEST', 'SCISSOR_TEST', 'POLYGON_OFFSET_FILL', + 'SAMPLE_ALPHA_TO_COVERAGE', 'SAMPLE_COVERAGE', 'DITHER']; +const MAXVP = [4096, 2048]; +const u32 = (x) => x >>> 0, i32 = (x) => x | 0; // WebIDL unsigned long / long + +function makeMocks(opts) { + const clampViewport = opts.clampViewport; + class Canvas { + constructor() { this.l = {}; } + addEventListener(t, f) { (this.l[t] = this.l[t] || []).push(f); } + fire(t) { (this.l[t] || []).slice().forEach((f) => f({ type: t })); } + } + class Prog { constructor(gl) { this.ctx = gl; this.gen = gl.gen; this.deleted = false; this.flagged = false; this.linked = false; } } + class Fb { constructor(gl) { this.ctx = gl; this.gen = gl.gen; this.deleted = false; this.rb = E.COLOR_ATTACHMENT0; } } + function defaults(gl) { + gl.s = { + SCISSOR_BOX: [0, 0, 300, 150], VIEWPORT: [0, 0, 300, 150], BLEND_COLOR: [0, 0, 0, 0], + BLEND_SRC_RGB: E.ONE, BLEND_DST_RGB: E.ZERO, BLEND_SRC_ALPHA: E.ONE, BLEND_DST_ALPHA: E.ZERO, + BLEND_EQUATION_RGB: E.FUNC_ADD, BLEND_EQUATION_ALPHA: E.FUNC_ADD, + }; + gl.en = {}; for (const c of gl.caps) gl.en[E[c]] = c === 'DITHER'; + gl.cur = null; gl.gen = (gl.gen || 0) + 1; // programs from an older generation are invalid + gl.activeTex = E.TEXTURE0; + gl.readFb = null; gl.drawFb = null; gl.defRB = E.BACK; // default framebuffer reads BACK + } + function Base(isGL2) { + this.isGL2 = isGL2; this.caps = isGL2 ? CAPS1.concat(['RASTERIZER_DISCARD']) : CAPS1; + this.canvas = new Canvas(); this.lost = false; this.sync = 0; this.errors = 0; defaults(this); + } + const need = (args, n) => { if (args.length < n) throw new TypeError('not enough arguments'); }; + const progArg = (p) => { if (p !== null && p !== undefined && !(p instanceof Prog)) throw new TypeError('not a WebGLProgram'); }; + const fbArg = (f) => { if (f !== null && f !== undefined && !(f instanceof Fb)) throw new TypeError('not a WebGLFramebuffer'); }; + const proto = { + isContextLost() { return this.lost; }, + getParameter(p) { + need(arguments, 1); this.sync++; if (this.lost) return null; p = u32(p); + if (p === E.MAX_VIEWPORT_DIMS) return new Int32Array(MAXVP); + if (p === E.CURRENT_PROGRAM) return this.cur; + if (p === E.ACTIVE_TEXTURE) return this.activeTex; + if (p === E.MAX_COMBINED_TEXTURE_IMAGE_UNITS) return MAXTEX; + if (this.isGL2) { // WebGL1 without WEBGL_draw_buffers: INVALID_ENUM below + if (p === E.MAX_DRAW_BUFFERS) return MAXDB; + if (p === E.MAX_COLOR_ATTACHMENTS) return MAXCA; + if (p === E.READ_BUFFER) return this.readFb ? this.readFb.rb : this.defRB; + if (p === E.READ_FRAMEBUFFER_BINDING) return this.readFb; + if (p === E.DRAW_FRAMEBUFFER_BINDING) return this.drawFb; + } + for (const k of PARAMS) if (E[k] === p) { + const v = this.s[k]; + return Array.isArray(v) ? (k === 'BLEND_COLOR' ? new Float32Array(v) : new Int32Array(v)) : v; + } + this.errors++; return null; + }, + isEnabled(c) { need(arguments, 1); this.sync++; if (this.lost) return false; c = u32(c); if (!(c in this.en)) { this.errors++; return false; } return this.en[c]; }, + createProgram() { if (this.lost) return null; return new Prog(this); }, + linkProgram(p) { progArg(p); if (!this.lost && this._valid(p)) p.linked = true; }, + _valid(p) { return p instanceof Prog && p.ctx === this && p.gen === this.gen && !p.deleted; }, + isProgram(p) { need(arguments, 1); progArg(p); this.sync++; if (this.lost) return false; return !!this._valid(p); }, + deleteProgram(p) { progArg(p); if (this.lost || !this._valid(p)) return; if (p === this.cur) p.flagged = true; else p.deleted = true; }, + useProgram(p) { + progArg(p); if (this.lost) return; + if (p && (!this._valid(p) || !p.linked)) { this.errors++; return; } // INVALID_OPERATION + if (this.cur && this.cur.flagged && this.cur !== p) this.cur.deleted = true; + this.cur = p || null; + }, + scissor(x, y, w, h) { if (this.lost) return; if (i32(w) < 0 || i32(h) < 0) { this.errors++; return; } this.s.SCISSOR_BOX = [i32(x), i32(y), i32(w), i32(h)]; }, + viewport(x, y, w, h) { + if (this.lost) return; w = i32(w); h = i32(h); if (w < 0 || h < 0) { this.errors++; return; } + this.s.VIEWPORT = clampViewport ? [i32(x), i32(y), Math.min(w, MAXVP[0]), Math.min(h, MAXVP[1])] : [i32(x), i32(y), w, h]; + }, + blendColor(r, g, b, a) { // WebGL1 (no float-buffer ext) clamps on store; WebGL2 does not + if (this.lost) return; const c = this.isGL2 ? (x) => Math.fround(+x) : (x) => Math.fround(Math.min(1, Math.max(0, +x || 0))); + this.s.BLEND_COLOR = [c(r), c(g), c(b), c(a)]; + }, + _fOK(sf, df) { + if (!FACTORS.includes(sf) || !FACTORS.includes(df)) return false; + if (!this.isGL2 && df === E.SRC_ALPHA_SATURATE) return false; // ES2: src only (ES3 allows dst) + const cc = (f) => (f === E.CONSTANT_COLOR || f === E.ONE_MINUS_CONSTANT_COLOR ? 1 : (f === E.CONSTANT_ALPHA || f === E.ONE_MINUS_CONSTANT_ALPHA ? 2 : 0)); + return !(cc(sf) && cc(df) && cc(sf) !== cc(df)); + }, + blendFunc(sf, df) { + if (this.lost) return; sf = u32(sf); df = u32(df); if (!this._fOK(sf, df)) { this.errors++; return; } + Object.assign(this.s, { BLEND_SRC_RGB: sf, BLEND_SRC_ALPHA: sf, BLEND_DST_RGB: df, BLEND_DST_ALPHA: df }); + }, + blendFuncSeparate(sr, dr, sa, da) { + if (this.lost) return; sr = u32(sr); dr = u32(dr); sa = u32(sa); da = u32(da); + // WebGL: the constant rule applies to the RGB pair; each factor must be a valid enum. + if (!this._fOK(sr, dr) || !FACTORS.includes(sa) || !FACTORS.includes(da) || (!this.isGL2 && da === E.SRC_ALPHA_SATURATE)) { this.errors++; return; } + Object.assign(this.s, { BLEND_SRC_RGB: sr, BLEND_DST_RGB: dr, BLEND_SRC_ALPHA: sa, BLEND_DST_ALPHA: da }); + }, + _eOK(m) { return [E.FUNC_ADD, E.FUNC_SUBTRACT, E.FUNC_REVERSE_SUBTRACT].includes(m) || (this.isGL2 && (m === E.MIN || m === E.MAX)); }, + blendEquation(m) { if (this.lost) return; m = u32(m); if (!this._eOK(m)) { this.errors++; return; } this.s.BLEND_EQUATION_RGB = this.s.BLEND_EQUATION_ALPHA = m; }, + blendEquationSeparate(a, b) { if (this.lost) return; a = u32(a); b = u32(b); if (!this._eOK(a) || !this._eOK(b)) { this.errors++; return; } this.s.BLEND_EQUATION_RGB = a; this.s.BLEND_EQUATION_ALPHA = b; }, + enable(c) { if (this.lost) return; c = u32(c); if (!(c in this.en)) { this.errors++; return; } this.en[c] = true; }, + disable(c) { if (this.lost) return; c = u32(c); if (!(c in this.en)) { this.errors++; return; } this.en[c] = false; }, + activeTexture(t) { + need(arguments, 1); if (this.lost) return; t = u32(t); + if (t < E.TEXTURE0 || t >= E.TEXTURE0 + MAXTEX) { this.errors++; return; } // INVALID_ENUM + this.activeTex = t; + }, + createFramebuffer() { if (this.lost) return null; return new Fb(this); }, + _fbValid(f) { return f instanceof Fb && f.ctx === this && f.gen === this.gen && !f.deleted; }, + bindFramebuffer(target, f) { + need(arguments, 2); fbArg(f); if (this.lost) return; target = u32(target); + const targets = this.isGL2 ? [E.FRAMEBUFFER, E.READ_FRAMEBUFFER, E.DRAW_FRAMEBUFFER] : [E.FRAMEBUFFER]; + if (!targets.includes(target)) { this.errors++; return; } // INVALID_ENUM + if (f && !this._fbValid(f)) { this.errors++; return; } // deleted / foreign: INVALID_OPERATION + const b = f || null; + if (target !== E.DRAW_FRAMEBUFFER) this.readFb = b; + if (target !== E.READ_FRAMEBUFFER) this.drawFb = b; + }, + deleteFramebuffer(f) { + fbArg(f); if (this.lost || !f) return; + if (!this._fbValid(f)) { if (f.ctx !== this || f.gen !== this.gen) this.errors++; return; } + f.deleted = true; // a bound framebuffer is unbound (as if bound to null) + if (this.readFb === f) this.readFb = null; + if (this.drawFb === f) this.drawFb = null; + }, + // test hooks (not WebGL API) + _lose() { this.lost = true; this.canvas.fire('webglcontextlost'); }, + _restore() { this.lost = false; defaults(this); this.canvas.fire('webglcontextrestored'); }, + }; + function GL1() { Base.call(this, false); } + function GL2() { Base.call(this, true); } + GL1.prototype = Object.assign(Object.create(null), proto); + GL2.prototype = Object.assign(Object.create(null), proto); + // WebGL2 only. The default framebuffer reads BACK or NONE; a framebuffer object NONE or + // COLOR_ATTACHMENTi (i < MAX_COLOR_ATTACHMENTS); anything else is refused, state unchanged. + GL2.prototype.readBuffer = function (src) { + need(arguments, 1); if (this.lost) return; src = u32(src); + if (!this.readFb) { + if (src !== E.BACK && src !== E.NONE) { this.errors++; return; } + this.defRB = src; + } else { + if (src !== E.NONE && !(src >= E.COLOR_ATTACHMENT0 && src < E.COLOR_ATTACHMENT0 + MAXCA)) { this.errors++; return; } + this.readFb.rb = src; + } + }; + for (const k in E) { GL1[k] = E[k]; GL2[k] = E[k]; } + return { GL1, GL2, Fb }; +} + +function load(search, opts = {}) { + const o = Object.assign({ clampViewport: true, window: true, Module: undefined }, opts); + const { GL1, GL2, Fb } = makeMocks(o); + const ctx = { console: o.console || console, URLSearchParams, Math, Object, WeakMap, WeakSet, Int32Array, Float32Array, String, + JSON }; + ctx.WebGLRenderingContext = GL1; ctx.WebGL2RenderingContext = GL2; + if (o.window) ctx.window = { location: { search } }; + if (o.Module !== undefined) ctx.Module = o.Module; + vm.createContext(ctx); + vm.runInContext(SHIM, ctx); + return { ctx, GL1, GL2, Fb }; +} + +// Deterministic PRNG (mulberry32) so failures reproduce. +function rng(seed) { return () => { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } + +function truth(gl, name) { // the mock's INTERNAL state, never read through the shim + const v = gl.s[name]; return Array.isArray(v) ? v.slice() : v; +} +function arr(v) { return v && v.length !== undefined ? Array.from(v) : v; } + +// `other` (optional): a second context of the same kind, whose framebuffers the fuzz also binds and +// deletes on `gl` (refused: INVALID_OPERATION) -- the shim cannot prove those refusals. +function fuzz(gl, steps, seed, check, other) { + const r = rng(seed); const pick = (a) => a[Math.floor(r() * a.length)]; + const progs = []; + // Mostly plain numbers (what emscripten passes), sometimes WebIDL-coercible forms and junk. + const coerce = (v) => { const k = r(); return k < 0.9 ? v : k < 0.94 ? String(v) : k < 0.97 ? v + 2 ** 32 : v + 0.5; }; + const anyEnum = () => coerce(r() < 0.85 ? pick(FACTORS) : pick([0x1234, E.MIN, E.FUNC_ADD, -1])); + const anyEq = () => coerce(r() < 0.85 ? pick([E.FUNC_ADD, E.FUNC_SUBTRACT, E.FUNC_REVERSE_SUBTRACT, E.MIN, E.MAX]) : pick([0x9999, E.ONE])); + const anyCap = () => coerce(r() < 0.9 ? E[pick(gl.caps)] : pick([0x1111, E.RASTERIZER_DISCARD])); + const anyUnit = () => coerce(r() < 0.85 ? E.TEXTURE0 + Math.floor(r() * MAXTEX) : pick([E.TEXTURE0 + MAXTEX, E.TEXTURE0 - 1, 0x1234, 0])); + const anyFbTarget = () => coerce(r() < 0.9 ? pick([E.FRAMEBUFFER, E.READ_FRAMEBUFFER, E.DRAW_FRAMEBUFFER]) : pick([0x1234, E.READ_BUFFER])); + const anyReadBuf = () => coerce(r() < 0.85 + ? pick([E.BACK, E.NONE, E.COLOR_ATTACHMENT0, E.COLOR_ATTACHMENT0 + 1, E.COLOR_ATTACHMENT0 + MAXCA - 1]) + : pick([E.COLOR_ATTACHMENT0 + MAXCA, E.COLOR_ATTACHMENT0 + 15, 0x1234, E.FRAMEBUFFER])); + const fbs = [], foreign = []; + const anyFb = () => { // mostly this context's (live, deleted, or from before a loss) + const k = r(); + if (k < 0.2 || !fbs.length) return k < 0.1 ? null : (k < 0.15 ? undefined : null); + if (k > 0.95 && other) { + if (!foreign.length || r() < 0.2) { const f = other.createFramebuffer(); if (f) foreign.push(f); } + if (foreign.length) return pick(foreign); + } + return pick(fbs); + }; + for (let i = 0; i < steps; ++i) { + switch (Math.floor(r() * 26)) { + case 0: gl.scissor(Math.floor(r() * 500) - 50, Math.floor(r() * 500), Math.floor(r() * 900) - 100, Math.floor(r() * 900) - 100); break; + case 1: gl.viewport(Math.floor(r() * 50), Math.floor(r() * 50), Math.floor(r() * 9000) - 100, Math.floor(r() * 9000) - 100); break; + case 2: gl.blendColor(r() * 2 - 0.5, r(), r() * 3, -r()); break; + case 3: gl.blendFunc(anyEnum(), anyEnum()); break; + case 4: gl.blendFuncSeparate(anyEnum(), anyEnum(), anyEnum(), anyEnum()); break; + case 5: gl.blendEquation(anyEq()); break; + case 6: gl.blendEquationSeparate(anyEq(), anyEq()); break; + case 7: gl.enable(anyCap()); break; + case 8: gl.disable(anyCap()); break; + case 9: { const p = gl.createProgram(); if (p) progs.push(p); break; } + case 10: if (progs.length) gl.linkProgram(pick(progs)); break; + case 11: if (progs.length) gl.deleteProgram(pick(progs)); break; + case 12: gl.useProgram(r() < 0.2 ? null : (progs.length ? pick(progs) : null)); break; + case 13: if (r() < 0.02) gl._lose(); break; + case 14: if (gl.lost && r() < 0.5) gl._restore(); break; + case 15: case 16: gl.activeTexture(anyUnit()); break; + case 17: { const f = gl.createFramebuffer(); if (f) fbs.push(f); break; } + case 18: case 19: case 20: gl.bindFramebuffer(anyFbTarget(), anyFb()); break; + case 21: { // often the bound read framebuffer itself (deleting it rebinds the default one) + const f = gl.readFb && r() < 0.3 ? gl.readFb : anyFb(); + if (f && r() < 0.5) gl.deleteFramebuffer(f); + break; + } + case 22: case 23: if (gl.readBuffer) gl.readBuffer(anyReadBuf()); break; + default: break; // queries only + } + check(gl, progs); + } +} + +function checkExact(gl, progs) { + if (gl.isContextLost()) return; + for (const k of PARAMS) assert.deepStrictEqual(arr(gl.getParameter(E[k])), truth(gl, k), k); + for (const c of gl.caps) assert.strictEqual(gl.isEnabled(E[c]), gl.en[E[c]], c); + for (const p of progs) assert.strictEqual(gl.isProgram(p), !!gl._valid(p), 'isProgram'); + assert.strictEqual(gl.isProgram(null), false); + assert.strictEqual(gl.getParameter(E.ACTIVE_TEXTURE), gl.activeTex, 'ACTIVE_TEXTURE'); + if (gl.isGL2) { + assert.strictEqual(gl.getParameter(E.MAX_DRAW_BUFFERS), MAXDB, 'MAX_DRAW_BUFFERS'); + assert.strictEqual(gl.getParameter(E.MAX_COLOR_ATTACHMENTS), MAXCA, 'MAX_COLOR_ATTACHMENTS'); + assert.strictEqual(gl.getParameter(E.READ_BUFFER), gl.readFb ? gl.readFb.rb : gl.defRB, 'READ_BUFFER'); + } +} + +let passed = 0; +function test(name, fn) { fn(); passed++; console.log('ok -', name); } + +test('shadowed queries make no synchronous call once seeded', () => { + const { GL2 } = load(''); + const gl = new GL2(); + gl.getParameter(E.SCISSOR_BOX); // seeds + const before = gl.sync; + for (let i = 0; i < 1000; ++i) { + for (const k of SHADOWED) gl.getParameter(E[k]); + for (const c of gl.caps) gl.isEnabled(E[c]); + gl.getParameter(E.ACTIVE_TEXTURE); gl.getParameter(E.MAX_DRAW_BUFFERS); + gl.getParameter(E.MAX_COLOR_ATTACHMENTS); + gl.scissor(1, 2, 3, 4); gl.viewport(0, 0, 640, 480); gl.activeTexture(E.TEXTURE0 + (i % MAXTEX)); + gl.blendFuncSeparate(E.SRC_ALPHA, E.ONE_MINUS_SRC_ALPHA, E.ONE, E.ONE_MINUS_SRC_ALPHA); + const p = gl.createProgram(); gl.linkProgram(p); gl.useProgram(p); gl.isProgram(p); + } + assert.strictEqual(gl.sync - before, 0, 'sync calls after seeding'); + assert.strictEqual(gl.getParameter(E.ACTIVE_TEXTURE), E.TEXTURE0 + (999 % MAXTEX)); + gl.getParameter(E.CURRENT_PROGRAM); gl.getParameter(E.BLEND_COLOR); // unshadowed reach the impl + assert.strictEqual(gl.sync - before, 2); +}); + +test('?glshim=norb: READ_BUFFER (only) goes to the real call', () => { + const m = load('?glshim=norb'); const gl = new m.GL2(); + assert.strictEqual(m.ctx.window.__cvcGlShadow.stats.mode, 'norb'); + const f = gl.createFramebuffer(); gl.bindFramebuffer(E.FRAMEBUFFER, f); gl.readBuffer(E.NONE); + gl.getParameter(E.ACTIVE_TEXTURE); // seeds + const before = gl.sync; + for (let i = 0; i < 10; ++i) { + assert.strictEqual(gl.getParameter(E.READ_BUFFER), E.NONE); + gl.getParameter(E.ACTIVE_TEXTURE); gl.getParameter(E.MAX_COLOR_ATTACHMENTS); + } + assert.strictEqual(gl.sync - before, 10, 'one real READ_BUFFER per query, nothing else'); +}); + +test('activeTexture outside the texture units changes nothing (and is re-read, not guessed)', () => { + const { GL2 } = load(''); + const gl = new GL2(); gl.activeTexture(E.TEXTURE0 + 5); + for (const bad of [E.TEXTURE0 + MAXTEX, E.TEXTURE0 - 1, 0x1234, -1]) { + gl.activeTexture(bad); + assert.strictEqual(gl.getParameter(E.ACTIVE_TEXTURE), E.TEXTURE0 + 5, `0x${(bad >>> 0).toString(16)}`); + } + gl.activeTexture(String(E.TEXTURE0 + 7)); // WebIDL coercion + assert.strictEqual(gl.getParameter(E.ACTIVE_TEXTURE), E.TEXTURE0 + 7); +}); + +test('WebGL1: MAX_DRAW_BUFFERS / MAX_COLOR_ATTACHMENTS / READ_BUFFER go to the real call', () => { + for (const q of ['', '?glshim=norb', '?glshim=verify']) { + const { GL1 } = load(q); + const gl = new GL1(); gl.getParameter(E.SCISSOR_BOX); // seeds + const s0 = gl.sync, e0 = gl.errors; + for (const k of ['MAX_DRAW_BUFFERS', 'MAX_COLOR_ATTACHMENTS', 'READ_BUFFER']) + assert.strictEqual(gl.getParameter(E[k]), null, k); // INVALID_ENUM without the extension + assert.strictEqual(gl.sync - s0, 3); assert.strictEqual(gl.errors - e0, 3); + } +}); + +test('READ_BUFFER follows the read binding with no synchronous call once seeded', () => { + const m = load(''); const gl = new m.GL2(); + assert.strictEqual(m.ctx.window.__cvcGlShadow.stats.mode, 'on'); + const a = gl.createFramebuffer(), b = gl.createFramebuffer(); + // seed the three framebuffers' read buffers (the default one and both objects) once each + gl.getParameter(E.READ_BUFFER); + gl.bindFramebuffer(E.READ_FRAMEBUFFER, a); gl.getParameter(E.READ_BUFFER); + gl.bindFramebuffer(E.FRAMEBUFFER, b); gl.getParameter(E.READ_BUFFER); + const before = gl.sync; + for (let i = 0; i < 500; ++i) { + gl.bindFramebuffer(E.READ_FRAMEBUFFER, a); gl.readBuffer(E.COLOR_ATTACHMENT0 + (i % MAXCA)); + assert.strictEqual(gl.getParameter(E.READ_BUFFER), E.COLOR_ATTACHMENT0 + (i % MAXCA)); + gl.bindFramebuffer(E.DRAW_FRAMEBUFFER, null); // the draw binding does not move the read one + assert.strictEqual(gl.getParameter(E.READ_BUFFER), E.COLOR_ATTACHMENT0 + (i % MAXCA)); + gl.bindFramebuffer(E.FRAMEBUFFER, null); gl.readBuffer(i % 2 ? E.NONE : E.BACK); + assert.strictEqual(gl.getParameter(E.READ_BUFFER), i % 2 ? E.NONE : E.BACK); + gl.bindFramebuffer(E.READ_FRAMEBUFFER, b); + assert.strictEqual(gl.getParameter(E.READ_BUFFER), E.COLOR_ATTACHMENT0); // b's own, untouched + } + assert.strictEqual(gl.sync - before, 0, 'sync calls after seeding'); +}); + +test('READ_BUFFER: refused readBuffer / bindFramebuffer leave it as the GL has it', () => { + const m = load(''); const gl = new m.GL2(); + const f = gl.createFramebuffer(); + gl.readBuffer(E.COLOR_ATTACHMENT0); // default framebuffer: INVALID_OPERATION + assert.strictEqual(gl.getParameter(E.READ_BUFFER), E.BACK); + gl.bindFramebuffer(E.READ_FRAMEBUFFER, f); gl.readBuffer(E.COLOR_ATTACHMENT0 + 2); + for (const bad of [E.BACK, E.COLOR_ATTACHMENT0 + MAXCA, 0x1234]) { + gl.readBuffer(bad); + assert.strictEqual(gl.getParameter(E.READ_BUFFER), E.COLOR_ATTACHMENT0 + 2); + } + gl.bindFramebuffer(0x1234, null); // INVALID_ENUM: the read binding stays f + assert.strictEqual(gl.getParameter(E.READ_BUFFER), E.COLOR_ATTACHMENT0 + 2); +}); + +test('READ_BUFFER: deleting the bound read framebuffer falls back to the default one', () => { + const m = load(''); const gl = new m.GL2(); + gl.readBuffer(E.NONE); // default framebuffer reads NONE + const f = gl.createFramebuffer(); gl.bindFramebuffer(E.FRAMEBUFFER, f); gl.readBuffer(E.COLOR_ATTACHMENT0 + 1); + gl.deleteFramebuffer(f); + assert.strictEqual(gl.getParameter(E.READ_BUFFER), E.NONE); + gl.bindFramebuffer(E.READ_FRAMEBUFFER, f); // deleted: INVALID_OPERATION, still the default + assert.strictEqual(gl.getParameter(E.READ_BUFFER), E.NONE); + // only the READ binding of a framebuffer bound for drawing alone survives its deletion + const g = gl.createFramebuffer(), h = gl.createFramebuffer(); + gl.bindFramebuffer(E.READ_FRAMEBUFFER, g); gl.readBuffer(E.COLOR_ATTACHMENT0 + 3); + gl.bindFramebuffer(E.DRAW_FRAMEBUFFER, h); gl.deleteFramebuffer(h); + assert.strictEqual(gl.getParameter(E.READ_BUFFER), E.COLOR_ATTACHMENT0 + 3); + // a re-created framebuffer starts from its own default, not a deleted one's value + gl.deleteFramebuffer(g); + const g2 = gl.createFramebuffer(); gl.bindFramebuffer(E.READ_FRAMEBUFFER, g2); + assert.strictEqual(gl.getParameter(E.READ_BUFFER), E.COLOR_ATTACHMENT0); +}); + +test('READ_BUFFER: a foreign framebuffer makes the binding UNKNOWN until a known one is bound', () => { + const m = load(''); const a = new m.GL2(), b = new m.GL2(); + const fa = a.createFramebuffer(), fb = b.createFramebuffer(); + a.bindFramebuffer(E.READ_FRAMEBUFFER, fa); a.readBuffer(E.COLOR_ATTACHMENT0 + 3); + a.getParameter(E.READ_BUFFER); + a.bindFramebuffer(E.READ_FRAMEBUFFER, fb); // b's: refused, the GL still reads fa + const s0 = a.sync; + assert.strictEqual(a.getParameter(E.READ_BUFFER), E.COLOR_ATTACHMENT0 + 3); + assert.strictEqual(a.sync - s0, 1, 'UNKNOWN binding: answered by the real call'); + a.bindFramebuffer(E.READ_FRAMEBUFFER, null); a.getParameter(E.READ_BUFFER); + const s1 = a.sync; + assert.strictEqual(a.getParameter(E.READ_BUFFER), E.BACK); + assert.strictEqual(a.sync - s1, 0, 'known again: answered from the shadow'); + // a readBuffer under an UNKNOWN binding may have changed whichever framebuffer is really bound + a.bindFramebuffer(E.READ_FRAMEBUFFER, fb); a.readBuffer(E.NONE); // reaches the default one + a.bindFramebuffer(E.READ_FRAMEBUFFER, null); + assert.strictEqual(a.getParameter(E.READ_BUFFER), E.NONE); +}); + +test('READ_BUFFER / ACTIVE_TEXTURE: context loss drops the shadow', () => { + const m = load(''); const gl = new m.GL2(); + const f = gl.createFramebuffer(); gl.bindFramebuffer(E.FRAMEBUFFER, f); gl.readBuffer(E.NONE); + gl.activeTexture(E.TEXTURE0 + 9); + gl._lose(); gl.readBuffer(E.COLOR_ATTACHMENT0); gl.activeTexture(E.TEXTURE0 + 3); gl._restore(); + assert.strictEqual(gl.getParameter(E.READ_BUFFER), E.BACK); + assert.strictEqual(gl.getParameter(E.ACTIVE_TEXTURE), E.TEXTURE0); + gl.bindFramebuffer(E.FRAMEBUFFER, f); // from before the loss: refused + assert.strictEqual(gl.getParameter(E.READ_BUFFER), E.BACK); +}); + +test('returned arrays are copies (caller mutation cannot corrupt the shadow)', () => { + const { GL2 } = load(''); + const gl = new GL2(); gl.scissor(5, 6, 7, 8); + const a = gl.getParameter(E.SCISSOR_BOX); a[0] = 999; + assert.deepStrictEqual(Array.from(gl.getParameter(E.SCISSOR_BOX)), [5, 6, 7, 8]); +}); + +test('deleted programs follow GL deferred-delete rules (via the real call)', () => { + const { GL2 } = load(''); + const gl = new GL2(); const p = gl.createProgram(), q = gl.createProgram(), u = gl.createProgram(); + gl.linkProgram(p); gl.linkProgram(q); + gl.useProgram(p); gl.deleteProgram(p); + assert.strictEqual(gl.isProgram(p), true); // flagged while current + gl.useProgram(u); // unlinked: rejected, p stays current + assert.strictEqual(gl.isProgram(p), true); + gl.useProgram(q); + assert.strictEqual(gl.isProgram(p), false); + assert.strictEqual(gl.isProgram(u), true); // never deleted: served from the shadow +}); + +test('foreign-context and non-program arguments', () => { + const { GL2 } = load(''); + const a = new GL2(), b = new GL2(); const p = a.createProgram(); + assert.strictEqual(b.isProgram(p), false); + assert.strictEqual(a.isProgram(p), true); + assert.throws(() => a.isProgram({}), TypeError); // WebIDL type check still happens + assert.throws(() => a.getParameter(), TypeError); // arity still checked + assert.throws(() => a.isEnabled(), TypeError); +}); + +test('context loss/restore re-seeds; setters while lost change nothing', () => { + const { GL2 } = load(''); + const gl = new GL2(); gl.scissor(9, 9, 9, 9); gl.enable(E.BLEND); const p = gl.createProgram(); + gl._lose(); + assert.strictEqual(gl.isProgram(p), false); + gl.scissor(1, 2, 3, 4); gl.enable(E.CULL_FACE); gl.blendFunc(E.SRC_ALPHA, E.ONE); // ignored while lost + gl._restore(); + assert.deepStrictEqual(Array.from(gl.getParameter(E.SCISSOR_BOX)), [0, 0, 300, 150]); + assert.strictEqual(gl.isEnabled(E.BLEND), false); + assert.strictEqual(gl.isEnabled(E.CULL_FACE), false); + assert.strictEqual(gl.getParameter(E.BLEND_SRC_RGB), E.ONE); + assert.strictEqual(gl.isProgram(p), false); +}); + +test('an app restore listener registered BEFORE the shim keeps its new programs', () => { + const { GL2 } = load(''); + const gl = new GL2(); let fresh = null; + gl.canvas.addEventListener('webglcontextrestored', () => { fresh = gl.createProgram(); gl.scissor(3, 3, 3, 3); }); + gl.getParameter(E.SCISSOR_BOX); // shim registers its own listeners now (after the app's) + gl._lose(); gl._restore(); + assert.ok(fresh); + const s0 = gl.sync; + assert.strictEqual(gl.isProgram(fresh), true); + assert.deepStrictEqual(Array.from(gl.getParameter(E.SCISSOR_BOX)), [3, 3, 3, 3]); + assert.strictEqual(gl.sync, s0, 'still answered from the shadow (no round-trip) after restore'); +}); + +test('WebIDL coercion: string / wrapped / fractional enums match the browser', () => { + const { GL2 } = load(''); + const gl = new GL2(); + gl.enable('3042'); assert.strictEqual(gl.isEnabled(E.BLEND), true); + gl.disable(E.BLEND + 2 ** 32); assert.strictEqual(gl.isEnabled(E.BLEND), false); + gl.enable(E.BLEND + 0.5); assert.strictEqual(gl.isEnabled(E.BLEND), true); + gl.blendFunc('770', '771'); + assert.strictEqual(gl.getParameter(E.BLEND_SRC_RGB), 770); + assert.strictEqual(typeof gl.getParameter(E.BLEND_DST_ALPHA), 'number'); + gl.blendEquation('32778'); assert.strictEqual(gl.getParameter(E.BLEND_EQUATION_RGB), 32778); + gl.scissor('4', 5.9, NaN, 2 ** 32 + 7); assert.deepStrictEqual(Array.from(gl.getParameter(E.SCISSOR_BOX)), [4, 5, 0, 7]); +}); + +test('viewport beyond MAX_VIEWPORT_DIMS is re-read (browsers differ)', () => { + for (const clampViewport of [true, false]) { + const { GL2 } = load('', { clampViewport }); + const gl = new GL2(); gl.viewport(0, 0, 9000, 100); + assert.deepStrictEqual(Array.from(gl.getParameter(E.VIEWPORT)), truth(gl, 'VIEWPORT')); + } +}); + +for (const [label, Cls, query] of [['WebGL2', 'GL2', ''], ['WebGL2 ?glshim=norb', 'GL2', '?glshim=norb'], + ['WebGL1', 'GL1', ''], ['WebGL1 ?glshim=norb', 'GL1', '?glshim=norb']]) { + for (const clampViewport of [true, false]) { + test(`${label} fuzz (viewport ${clampViewport ? 'clamped' : 'raw'}): shadow == real state after every step`, () => { + let served = 0; + for (let seed = 1; seed <= 10; ++seed) { + const m = load(query, { clampViewport }); const gl = new m[Cls](); + fuzz(gl, 4000, seed, checkExact, new m[Cls]()); + assert.ok(gl.errors > 0, 'fuzz must exercise rejected calls'); + served += m.ctx.window.__cvcGlShadow.stats.served; + } + assert.ok(served > 10000, 'the fuzz must be answered from the shadow'); + }); + } +} + +test('?glshim=verify answers with real values and counts zero mismatches (READ_BUFFER audited)', () => { + const m = load('?glshim=verify'); const gl = new m.GL2(); + // Count the READ_BUFFER answers the shadow could vouch for (and so was compared on). + const st = m.ctx.window.__cvcGlShadow.stats; + let rbChecked = 0; + fuzz(gl, 6000, 99, (g, progs) => { + checkExact(g, progs); + if (g.isContextLost()) return; + const v0 = st.verified; g.getParameter(E.READ_BUFFER); rbChecked += st.verified - v0; + }, new m.GL2()); + assert.strictEqual(st.mode, 'verify'); assert.ok(st.verified > 1000); assert.strictEqual(st.mismatches, 0); + assert.deepStrictEqual(Object.keys(st.mismatchBy), []); + assert.ok(rbChecked > 1000, `READ_BUFFER audited ${rbChecked}x`); +}); + +test('?glshim=verify reports, by name, a value the shadow got wrong', () => { + // State changed behind the shim's back (the mock's internals, which the shim never sees) must + // show up as a mismatch under that value's name, not pass silently: a framebuffer's read buffer + // and the active texture unit here. + const m = load('?glshim=verify'); const gl = new m.GL2(); + const f = gl.createFramebuffer(); gl.bindFramebuffer(E.READ_FRAMEBUFFER, f); + assert.strictEqual(gl.getParameter(E.READ_BUFFER), E.COLOR_ATTACHMENT0); // seeds f's entry + f.rb = E.NONE; + gl.activeTexture(E.TEXTURE0 + 2); gl.activeTex = E.TEXTURE0 + 6; + const warn = console.warn, warned = []; + console.warn = (...a) => { warned.push(a[1]); }; + try { + assert.strictEqual(gl.getParameter(E.READ_BUFFER), E.NONE); + assert.strictEqual(gl.getParameter(E.ACTIVE_TEXTURE), E.TEXTURE0 + 6); + } finally { console.warn = warn; } + const st = m.ctx.window.__cvcGlShadow.stats; + assert.strictEqual(st.mismatches, 2); + assert.deepStrictEqual(Object.assign({}, st.mismatchBy), { READ_BUFFER: 1, ACTIVE_TEXTURE: 1 }); + assert.deepStrictEqual(warned, ['READ_BUFFER (framebuffer object)', 'ACTIVE_TEXTURE']); +}); + +test('?glshim=0 / Module.glStateShadow=false|0|"off" install nothing', () => { + for (const [q, mod] of [['?glshim=0', undefined], ['?glshim=off', undefined], ['', false], ['', 0], ['', 'off'], ['', 'false']]) { + const m = load(q, { Module: mod === undefined ? undefined : { glStateShadow: mod } }); + assert.strictEqual(m.ctx.window.__cvcGlShadow.stats.mode, 'off', `${q} ${mod}`); + assert.strictEqual(m.GL2.prototype.__cvcGlShadowed, undefined); + } + assert.strictEqual(load('', { Module: { glStateShadow: true } }).ctx.window.__cvcGlShadow.stats.mode, 'on'); + assert.strictEqual(load('', { Module: { glStateShadow: 'norb' } }).ctx.window.__cvcGlShadow.stats.mode, 'norb'); + assert.strictEqual(load('?glshim=NORB').ctx.window.__cvcGlShadow.stats.mode, 'norb'); +}); + +test('mode precedence: URL > Module.glStateShadow (page) > Module.glStateShadowDefault (build) > on', () => { + const st = (q, Module) => load(q, { Module }).ctx.window.__cvcGlShadow.stats; + const at = (q, Module) => { const s = st(q, Module); return s.mode + '/' + s.modeFrom; }; + assert.strictEqual(at('', undefined), 'on/default'); + assert.strictEqual(at('', {}), 'on/default'); + // the build-time default (cvcgl_wasm_app STATE_SHIM_MODE) applies when nothing else says + assert.strictEqual(at('', { glStateShadowDefault: 'verify' }), 'verify/build'); + assert.strictEqual(at('', { glStateShadowDefault: 'norb' }), 'norb/build'); + // the page beats the build + assert.strictEqual(at('', { glStateShadowDefault: 'verify', glStateShadow: 'norb' }), 'norb/page'); + assert.strictEqual(at('', { glStateShadowDefault: 'verify', glStateShadow: false }), 'off/page'); + // the URL beats both, either way round, so a URL can always A/B any page + assert.strictEqual(at('?glshim=verify', { glStateShadow: 'off' }), 'verify/url'); + assert.strictEqual(at('?glshim=0', { glStateShadow: 'on', glStateShadowDefault: 'verify' }), 'off/url'); + assert.strictEqual(at('?glshim=norb', { glStateShadowDefault: 'verify' }), 'norb/url'); + assert.strictEqual(at('?glshim', { glStateShadow: 'off' }), 'on/url'); // a bare ?glshim means on + assert.strictEqual(at('?other=1', { glStateShadow: 'verify' }), 'verify/page'); + // a URL that turns it off installs nothing, whatever the page and build asked for + const m = load('?glshim=off', { Module: { glStateShadow: 'verify', glStateShadowDefault: 'norb' } }); + assert.strictEqual(m.GL2.prototype.__cvcGlShadowed, undefined); + // a build default of verify is a real verify: real values answered, mismatches counted + const v = load('', { Module: { glStateShadowDefault: 'verify' } }); const gl = new v.GL2(); + gl.scissor(1, 2, 3, 4); gl.getParameter(E.SCISSOR_BOX); + assert.strictEqual(v.ctx.window.__cvcGlShadow.stats.verified, 1); + assert.strictEqual(v.ctx.window.__cvcGlShadow.stats.served, 0); +}); + +test('an unrecognized mode is on, with a console.warn naming the accepted values', () => { + const warned = []; + const con = { warn: (...a) => warned.push(a.join(' ')), log() {}, error() {} }; + const st = (q, Module) => load(q, { Module, console: con }).ctx.window.__cvcGlShadow.stats; + // accepted spellings never warn + for (const [q, mod, want] of [['?glshim', undefined, 'on'], ['?glshim=on', undefined, 'on'], ['?glshim=1', undefined, 'on'], + ['?glshim=TRUE', undefined, 'on'], ['?glshim=Off', undefined, 'off'], ['?glshim=0', undefined, 'off'], + ['?glshim=verify', undefined, 'verify'], ['?glshim=norb', undefined, 'norb'], + ['', { glStateShadow: true }, 'on'], ['', { glStateShadow: false }, 'off'], + ['', { glStateShadow: 0 }, 'off'], ['', { glStateShadowDefault: 'verify' }, 'verify']]) { + assert.strictEqual(st(q, mod).mode, want, `${q} ${JSON.stringify(mod)}`); + } + assert.deepStrictEqual(warned, []); + // typos: still on (the shim is the safe default), but loudly + assert.strictEqual(st('?glshim=of').mode, 'on'); + assert.strictEqual(st('', { glStateShadow: 'no' }).mode, 'on'); + assert.strictEqual(st('', { glStateShadowDefault: 'verfy' }).mode, 'on'); + assert.strictEqual(warned.length, 3, warned.join('\n')); + assert.match(warned[0], /"of".*\?glshim=.*0\|off\|false.*verify.*norb/); + assert.match(warned[1], /"no".*Module\.glStateShadow\b/); + assert.match(warned[2], /"verfy".*Module\.glStateShadowDefault/); +}); + +test('stats.version names this copy of the shim, in every mode', () => { + for (const q of ['', '?glshim=0', '?glshim=verify', '?glshim=norb']) { + const s = load(q).ctx.window.__cvcGlShadow.stats; + assert.strictEqual(typeof s.version, 'string', q); + assert.match(s.version, /^cvcGL-\d+$/, q); + } +}); + +test('no window (pthread worker) is a no-op', () => { + const m = load('', { window: false }); + assert.strictEqual(m.GL2.prototype.__cvcGlShadowed, undefined); +}); + +console.log(`${passed} tests passed`); diff --git a/src/cvcGL/wasm/webgl_state_shadow.js b/src/cvcGL/wasm/webgl_state_shadow.js new file mode 100644 index 00000000..d16c27ca --- /dev/null +++ b/src/cvcGL/wasm/webgl_state_shadow.js @@ -0,0 +1,404 @@ +// clang-format off: hand-formatted JS; .clang-format is the C++ style (CI formats only C++). +// webgl_state_shadow.js -- emscripten --pre-js: answer GL state queries from a client-side +// shadow instead of a synchronous round-trip to the browser's GPU process. +// +// Part of cvcGL. A wasm app links it with cvcgl_wasm_app() (cvcGLWasm.cmake, installed +// beside cvcGLConfig.cmake; the file itself installs to share/cvcGL/wasm/). See +// docs/CVCGL_WASM.md for the app contract. +// +// Why: two per-frame sources of GL state queries in every cvcGL WebAssembly app. +// * Dear ImGui's OpenGL3 backend (imgui_impl_opengl3.cpp, compiled into libcvcGL) backs up and +// restores GL state around every ImGui_ImplOpenGL3_RenderDrawData() call, which +// cvc::gl::ImGuiOverlay makes once per frame -- and Ariadne's ImGuiBackend draws through +// ImGuiOverlay, so every cvcGL ImGui / Ariadne app pays it: ACTIVE_TEXTURE, VIEWPORT, +// SCISSOR_BOX, the six BLEND_* values, isEnabled x5, isProgram. +// * VTK re-reads READ_BUFFER after every read-framebuffer bind and MAX_DRAW_BUFFERS on every +// framebuffer activation (vtkOpenGLState, vtkOpenGLFramebufferObject), several times a frame. +// In a browser, several of those queries are not cached by the WebGL implementation, so each one +// blocks until the GPU process drains its command queue: the first absorbs the frame's queued GPU +// work, and each later one adds another round trip. Every frame pays this, and the more GPU work a +// frame queues (a heavy per-frame overlay, a large scene), the longer that first query waits. The +// sync-call census (devtools/glsync.js) shows which queries still reach the browser: with the shim +// on, none of the shadowed ones should. +// +// Scope: it patches the WebGL prototypes, so it serves EVERY WebGL context on the page, cvcGL's +// canvas or not. The fuzz test (src/cvcGL/test/webgl_state_shadow_test.js) checks every answer +// against a mock WebGL with real GL/WebGL semantics, and ?glshim=verify compares each answer with +// the real value in a live app (the browser check in docs/CVCGL_WASM.md). A page that embeds a +// cvcGL module beside other WebGL code can still leave it out: +// cvcgl_wasm_app( STATE_SHIM OFF) at build time, or ?glshim=0 per page load. +// +// What: every setter that can change a shadowed value is wrapped to keep a per-context +// copy current; the matching query is answered from that copy. The copy is seeded by one +// real query per value the first time a context is used, and dropped on context loss so it +// is re-seeded after restore (every wrapper passes straight through while a context is lost). +// Shadowed: +// getParameter: SCISSOR_BOX, VIEWPORT, BLEND_{SRC,DST}_{RGB,ALPHA}, BLEND_EQUATION_{RGB,ALPHA}, +// ACTIVE_TEXTURE (ImGui's first query of every frame, so in a browser that does +// not cache it -- Firefox -- the one that absorbs the whole frame's GPU work); +// WebGL2 also MAX_DRAW_BUFFERS and MAX_COLOR_ATTACHMENTS, implementation +// constants VTK re-reads per frame (on WebGL1 they depend on whether +// WEBGL_draw_buffers was enabled, so there they go to the real call); +// WebGL2 READ_BUFFER (below) +// isEnabled: BLEND, CULL_FACE, DEPTH_TEST, STENCIL_TEST, SCISSOR_TEST, +// POLYGON_OFFSET_FILL, SAMPLE_ALPHA_TO_COVERAGE, SAMPLE_COVERAGE, DITHER, +// RASTERIZER_DISCARD (WebGL2) +// isProgram: true for a program this context created and nobody has deleted; any program +// that was deleted (deferred-delete rules) or is foreign goes to the real call. +// READ_BUFFER is state of the bound READ framebuffer, so its shadow tracks the read binding +// (bindFramebuffer on FRAMEBUFFER / READ_FRAMEBUFFER, deleteFramebuffer of the bound one) and one +// value per framebuffer (readBuffer); a framebuffer this context did not create through the shim +// makes the binding UNKNOWN, and every READ_BUFFER query then goes to the real call until a known +// one is bound. VTK (vtkOpenGLState::vtkglBindFramebuffer) re-reads it after every read-framebuffer +// bind to seed its own cache, which then decides whether a later readBuffer is issued at all, so a +// wrong answer would blit from the wrong attachment; ?glshim=norb takes it back to the real call +// while keeping the rest, and ?glshim=verify audits it like every other shadowed value. +// Anything else -- including BLEND_COLOR, whose clamping differs by context version and +// extensions -- goes to the real implementation untouched. State changed through extension +// objects (e.g. OES_draw_buffers_indexed) is not tracked; emscripten does not enable those. +// The shim is a no-op when GL lives in a worker (OffscreenCanvas / PROXY_TO_PTHREAD). +// +// Modes: +// on (default) everything above +// 0 / off every call goes straight to WebGL (the A/B baseline); nothing is installed +// norb on, except READ_BUFFER (and its framebuffer tracking): the real call +// verify answer with the REAL value but compare it to the shadow, every shadowed value +// included; mismatches are counted in window.__cvcGlShadow.stats (.mismatches, and per +// value name in .mismatchBy) and the first few logged with that name, so the shadow can +// be checked against the real values in a live app. +// The mode comes from the first of these that is set, so a URL can always A/B any page: +// 1. the URL: ?glshim=0|off|on|verify|norb +// 2. the page: Module.glStateShadow, set by the host page before the module starts +// 3. the build: Module.glStateShadowDefault, written by cvcgl_wasm_app(... STATE_SHIM_MODE m) +// 4. on +// An unrecognized value (?glshim=of, Module.glStateShadow='no') means on, with a console.warn +// naming the accepted values -- so a mistyped A/B baseline does not silently measure the shim twice. +// window.__cvcGlShadow.stats records the mode, where it came from (.modeFrom: url / page / build / +// default) and .version, which names this copy: the first copy installed on a page wins, so a page +// that may load another copy can tell which one is active. +(function () { + 'use strict'; + if (typeof window === 'undefined' || typeof WebGLRenderingContext === 'undefined') return; + if (window.__cvcGlShadow) return; // idempotent (several --pre-js / pages may include it) + + var VERSION = 'cvcGL-1'; + // Accepted: off = 0 / off / false (or boolean false); on = on / 1 / true / empty (a bare + // ?glshim) (or boolean true); verify; norb -- any case. Anything else is still 'on', but says so: + // a mistyped A/B baseline (?glshim=of, ?glshim=no) would otherwise measure the shim twice. + function parseMode(v, from) { + var t = String(v).toLowerCase(); + if (v === false || t === '0' || t === 'off' || t === 'false') return 'off'; + if (t === 'verify') return 'verify'; + if (t === 'norb') return 'norb'; + if (!(v === true || t === '' || t === 'on' || t === '1' || t === 'true')) { + try { + console.warn('webgl_state_shadow: unrecognized mode ' + JSON.stringify(String(v)) + ' from ' + from + + "; using 'on'. Accepted: 0|off|false, on|1|true, verify, norb"); + } catch (e) { /* no console */ } + } + return 'on'; + } + var mode = 'on', modeFrom = 'default'; + var M = typeof Module !== 'undefined' && Module ? Module : null; + // Bracket reads: the property names must survive a Closure-compiled module. + if (M && M['glStateShadowDefault'] !== undefined) { + mode = parseMode(M['glStateShadowDefault'], 'Module.glStateShadowDefault'); modeFrom = 'build'; + } + if (M && M['glStateShadow'] !== undefined) { mode = parseMode(M['glStateShadow'], 'Module.glStateShadow'); modeFrom = 'page'; } + try { + var q = new URLSearchParams(window.location.search).get('glshim'); + if (q !== null) { mode = parseMode(q, '?glshim='); modeFrom = 'url'; } + } catch (e) { /* no location: keep the page / build / default mode */ } + + var stats = { version: VERSION, mode: mode, modeFrom: modeFrom, served: 0, verified: 0, mismatches: 0, + mismatchBy: {} }; + window.__cvcGlShadow = { stats: stats }; + if (mode === 'off') return; + + var G = typeof WebGL2RenderingContext !== 'undefined' ? WebGL2RenderingContext : WebGLRenderingContext; + var PARAMS = [G.SCISSOR_BOX, G.VIEWPORT, G.BLEND_SRC_RGB, G.BLEND_DST_RGB, + G.BLEND_SRC_ALPHA, G.BLEND_DST_ALPHA, G.BLEND_EQUATION_RGB, G.BLEND_EQUATION_ALPHA, + G.ACTIVE_TEXTURE]; + // WebGL2-only enums, as literals: G is WebGLRenderingContext where there is no WebGL2. + var MAX_DRAW_BUFFERS = 0x8824, MAX_COLOR_ATTACHMENTS = 0x8CDF, READ_BUFFER = 0x0C02, + FRAMEBUFFER = 0x8D40, READ_FRAMEBUFFER = 0x8CA8, READ_FRAMEBUFFER_BINDING = 0x8CAA, + BACK = 0x0405, NONE = 0, COLOR_ATTACHMENT0 = 0x8CE0; + var PARAMS2 = [MAX_DRAW_BUFFERS, MAX_COLOR_ATTACHMENTS]; // WebGL2 only: implementation constants + var CAPS = [G.BLEND, G.CULL_FACE, G.DEPTH_TEST, G.STENCIL_TEST, G.SCISSOR_TEST, + G.POLYGON_OFFSET_FILL, G.SAMPLE_ALPHA_TO_COVERAGE, G.SAMPLE_COVERAGE, G.DITHER]; + var CAPS2 = [G.RASTERIZER_DISCARD]; // WebGL2 only + // READ_BUFFER (and the framebuffer tracking behind it) everywhere but ?glshim=norb. + var rbTrack = mode !== 'norb'; + var UNKNOWN = {}; // read-framebuffer binding the shadow cannot vouch for: ask the real call + // Value names for ?glshim=verify's report (stats.mismatchBy and the console), precomputed so the + // served path builds no string. + var NAMES = {}; + ['SCISSOR_BOX', 'VIEWPORT', 'BLEND_SRC_RGB', 'BLEND_DST_RGB', 'BLEND_SRC_ALPHA', 'BLEND_DST_ALPHA', + 'BLEND_EQUATION_RGB', 'BLEND_EQUATION_ALPHA', 'ACTIVE_TEXTURE'].forEach(function (n) { NAMES[G[n]] = n; }); + NAMES[MAX_DRAW_BUFFERS] = 'MAX_DRAW_BUFFERS'; NAMES[MAX_COLOR_ATTACHMENTS] = 'MAX_COLOR_ATTACHMENTS'; + var CAP_NAMES = {}; + ['BLEND', 'CULL_FACE', 'DEPTH_TEST', 'STENCIL_TEST', 'SCISSOR_TEST', 'POLYGON_OFFSET_FILL', + 'SAMPLE_ALPHA_TO_COVERAGE', 'SAMPLE_COVERAGE', 'DITHER', 'RASTERIZER_DISCARD'].forEach(function (n) { + if (G[n] !== undefined) CAP_NAMES[G[n]] = 'isEnabled(' + n + ')'; + }); + // WebIDL GLenum/long conversions, so the shadow keys match what the browser stores. + function u32(x) { return x >>> 0; } + function i32(x) { return x | 0; } + + function install(proto, isGL2) { + if (!proto || proto.__cvcGlShadowed) return; + proto.__cvcGlShadowed = true; + var real = {}; + ['getParameter', 'isEnabled', 'isProgram', 'createProgram', 'deleteProgram', 'scissor', + 'viewport', 'blendFunc', 'blendFuncSeparate', 'blendEquation', 'blendEquationSeparate', + 'enable', 'disable', 'activeTexture', 'createFramebuffer', 'deleteFramebuffer', + 'bindFramebuffer', 'readBuffer'].forEach(function (n) { real[n] = proto[n]; }); + var caps = isGL2 ? CAPS.concat(CAPS2) : CAPS; + var params = isGL2 ? PARAMS.concat(PARAMS2) : PARAMS; + var rb = rbTrack && isGL2 && typeof real.readBuffer === 'function'; + var shadows = new WeakMap(); // context -> shadow + + function shadowOf(gl) { + var s = shadows.get(gl); + if (s) return s; + s = { p: {}, en: {}, live: new WeakSet(), deleted: new WeakSet(), + maxVp: real.getParameter.call(gl, G.MAX_VIEWPORT_DIMS), + maxTex: real.getParameter.call(gl, G.MAX_COMBINED_TEXTURE_IMAGE_UNITS) }; + params.forEach(function (k) { s.p[k] = real.getParameter.call(gl, k); }); + caps.forEach(function (k) { s.en[k] = real.isEnabled.call(gl, k); }); + if (rb) { + // Framebuffers this context created (through the shim) and deleted, the read buffer of + // each (undefined = not known yet: read it on the next query) and of the default + // framebuffer, and the read binding -- seeded from the real binding, which can only be a + // framebuffer the shim saw created if it is not null, else it is UNKNOWN. + s.fbLive = new WeakSet(); s.fbDeleted = new WeakSet(); s.rbOf = new WeakMap(); + s.rbDefault = undefined; + var bound = real.getParameter.call(gl, READ_FRAMEBUFFER_BINDING); + s.readFb = bound === null ? null : UNKNOWN; + } + shadows.set(gl, s); + var c = gl.canvas; + if (c && c.addEventListener && !c.__cvcGlShadowLoss) { + c.__cvcGlShadowLoss = true; + // Drop on LOSS only: every wrapper passes through while lost, so the first use after + // restore re-seeds -- and programs an app's own 'restored' listener creates (which may + // run before any listener of ours) land in that fresh shadow instead of being dropped. + c.addEventListener('webglcontextlost', function () { shadows.delete(gl); }, false); + } + return s; + } + function copy(v) { return (v && v.slice) ? v.slice() : v; } // Int32Array -> fresh copy + function same(a, b) { + if (a && a.length !== undefined && b && b.length !== undefined) { + if (a.length !== b.length) return false; + for (var i = 0; i < a.length; ++i) if (!Object.is(a[i], b[i])) return false; + return true; + } + return Object.is(a, b); + } + // `name` keys stats.mismatchBy; `detail` (optional) only goes to the console. + function answer(gl, shadowVal, realFn, args, name, detail) { + if (mode === 'verify') { + var r = realFn.apply(gl, args); + stats.verified++; + if (!same(r, shadowVal)) { + stats.mismatches++; + stats.mismatchBy[name] = (stats.mismatchBy[name] || 0) + 1; + if (stats.mismatches <= 8) + console.warn('webgl_state_shadow mismatch', name + (detail ? ' (' + detail + ')' : ''), + 'real=', r, 'shadow=', shadowVal); + } + return r; + } + stats.served++; + return copy(shadowVal); + } + var hasParam = {}; params.forEach(function (k) { hasParam[k] = true; }); + var hasCap = {}; caps.forEach(function (k) { hasCap[k] = true; }); + + proto.getParameter = function (pname) { + if (arguments.length === 1 && hasParam[pname] && !this.isContextLost()) { + var s = shadowOf(this), k = u32(pname); + if (s.p[k] === undefined) s.p[k] = real.getParameter.call(this, k); // re-seed after an invalidation + return answer(this, s.p[k], real.getParameter, arguments, NAMES[k]); + } + if (rb && pname === READ_BUFFER && arguments.length === 1 && !this.isContextLost()) { + var t = shadowOf(this), fb = t.readFb; + if (fb !== UNKNOWN) { + var v = fb === null ? t.rbDefault : t.rbOf.get(fb); + if (v === undefined) { // not known yet for this framebuffer: the real answer, kept + v = real.getParameter.call(this, READ_BUFFER); + if (fb === null) t.rbDefault = v; else t.rbOf.set(fb, v); + return v; + } + return answer(this, v, real.getParameter, arguments, 'READ_BUFFER', + fb === null ? 'default framebuffer' : 'framebuffer object'); + } + } + return real.getParameter.apply(this, arguments); + }; + proto.isEnabled = function (cap) { + if (arguments.length === 1 && hasCap[cap] && !this.isContextLost()) { + var k = u32(cap); + return answer(this, shadowOf(this).en[k], real.isEnabled, arguments, CAP_NAMES[k]); + } + return real.isEnabled.apply(this, arguments); + }; + proto.isProgram = function (p) { + if (arguments.length === 1 && p && !this.isContextLost()) { + var s = shadowOf(this); + if (s.live.has(p) && !s.deleted.has(p)) + return answer(this, true, real.isProgram, arguments, 'isProgram'); + } + return real.isProgram.apply(this, arguments); // null, deleted, foreign, lost: the real answer + }; + proto.createProgram = function () { + var p = real.createProgram.apply(this, arguments); + if (p && !this.isContextLost()) shadowOf(this).live.add(p); + return p; + }; + proto.deleteProgram = function (p) { + var r = real.deleteProgram.apply(this, arguments); + if (p && !this.isContextLost()) shadowOf(this).deleted.add(p); // GL's deferred-delete rules + return r; // stay with the real isProgram + }; + + function setter(name, update) { + var f = real[name]; + proto[name] = function () { + var r = f.apply(this, arguments); + if (!this.isContextLost()) update(shadowOf(this), arguments); + return r; + }; + } + // Setters run the real call FIRST (an exception from WebIDL conversion propagates before the + // shadow changes). A call GL rejects (bad enum, negative size, WebGL's constant-colour/ + // constant-alpha pairing rule) leaves GL state unchanged; the shadow cannot see the error + // without a synchronous getError(), so it only commits values it can prove valid and + // otherwise INVALIDATES the affected entries -- the next query re-reads them. + var FACTORS = {}; [G.ZERO, G.ONE, G.SRC_COLOR, G.ONE_MINUS_SRC_COLOR, G.DST_COLOR, G.ONE_MINUS_DST_COLOR, + G.SRC_ALPHA, G.ONE_MINUS_SRC_ALPHA, G.DST_ALPHA, G.ONE_MINUS_DST_ALPHA, G.CONSTANT_COLOR, + G.ONE_MINUS_CONSTANT_COLOR, G.CONSTANT_ALPHA, G.ONE_MINUS_CONSTANT_ALPHA, G.SRC_ALPHA_SATURATE + ].forEach(function (k) { FACTORS[k] = true; }); + var EQS = {}; [G.FUNC_ADD, G.FUNC_SUBTRACT, G.FUNC_REVERSE_SUBTRACT].forEach(function (k) { EQS[k] = true; }); + if (isGL2) { EQS[G.MIN] = true; EQS[G.MAX] = true; } // WebGL1 needs EXT_blend_minmax: not assumed + var CC = {}; CC[G.CONSTANT_COLOR] = CC[G.ONE_MINUS_CONSTANT_COLOR] = 1; + CC[G.CONSTANT_ALPHA] = CC[G.ONE_MINUS_CONSTANT_ALPHA] = 2; + function factorsOk(src, dst) { + if (!FACTORS[src] || !FACTORS[dst]) return false; + if (dst === G.SRC_ALPHA_SATURATE) return false; // src-only in ES2; not relied on either way + return !(CC[src] && CC[dst] && CC[src] !== CC[dst]); // WebGL: INVALID_OPERATION + } + function drop(s, keys) { keys.forEach(function (k) { s.p[k] = undefined; }); } + var BF = [G.BLEND_SRC_RGB, G.BLEND_DST_RGB, G.BLEND_SRC_ALPHA, G.BLEND_DST_ALPHA]; + var BE = [G.BLEND_EQUATION_RGB, G.BLEND_EQUATION_ALPHA]; + + setter('scissor', function (s, a) { + var w = i32(a[2]), h = i32(a[3]); + if (w >= 0 && h >= 0) s.p[G.SCISSOR_BOX] = new Int32Array([i32(a[0]), i32(a[1]), w, h]); + }); + setter('viewport', function (s, a) { + var w = i32(a[2]), h = i32(a[3]); + if (w < 0 || h < 0) return; + // ANGLE clamps to MAX_VIEWPORT_DIMS on store, Firefox caches the raw size: beyond the + // limit the answer is browser-specific, so re-read it instead of guessing. + if (!s.maxVp || w > s.maxVp[0] || h > s.maxVp[1]) return drop(s, [G.VIEWPORT]); + s.p[G.VIEWPORT] = new Int32Array([i32(a[0]), i32(a[1]), w, h]); + }); + setter('blendFunc', function (s, a) { + var sf = u32(a[0]), df = u32(a[1]); + if (!factorsOk(sf, df)) return drop(s, BF); + s.p[G.BLEND_SRC_RGB] = s.p[G.BLEND_SRC_ALPHA] = sf; + s.p[G.BLEND_DST_RGB] = s.p[G.BLEND_DST_ALPHA] = df; + }); + setter('blendFuncSeparate', function (s, a) { + var sr = u32(a[0]), dr = u32(a[1]), sa = u32(a[2]), da = u32(a[3]); + if (!factorsOk(sr, dr) || !factorsOk(sa, da) || + (CC[sr] && CC[da] && CC[sr] !== CC[da]) || (CC[sa] && CC[dr] && CC[sa] !== CC[dr])) + return drop(s, BF); // stricter than the spec (conservative), never more permissive + s.p[G.BLEND_SRC_RGB] = sr; s.p[G.BLEND_DST_RGB] = dr; + s.p[G.BLEND_SRC_ALPHA] = sa; s.p[G.BLEND_DST_ALPHA] = da; + }); + setter('blendEquation', function (s, a) { + var m = u32(a[0]); + if (!EQS[m]) return drop(s, BE); + s.p[G.BLEND_EQUATION_RGB] = s.p[G.BLEND_EQUATION_ALPHA] = m; + }); + setter('blendEquationSeparate', function (s, a) { + var mr = u32(a[0]), ma = u32(a[1]); + if (!EQS[mr] || !EQS[ma]) return drop(s, BE); + s.p[G.BLEND_EQUATION_RGB] = mr; s.p[G.BLEND_EQUATION_ALPHA] = ma; + }); + setter('enable', function (s, a) { var c = u32(a[0]); if (hasCap[c]) s.en[c] = true; }); + setter('disable', function (s, a) { var c = u32(a[0]); if (hasCap[c]) s.en[c] = false; }); + // TEXTURE0 .. TEXTURE0 + MAX_COMBINED_TEXTURE_IMAGE_UNITS - 1, else INVALID_ENUM (no change). + setter('activeTexture', function (s, a) { + var t = u32(a[0]); + if (s.maxTex > 0 && t >= G.TEXTURE0 && t < G.TEXTURE0 + s.maxTex) s.p[G.ACTIVE_TEXTURE] = t; + else drop(s, [G.ACTIVE_TEXTURE]); + }); + + if (!rb) return; + // ---- READ_BUFFER (WebGL2; all modes but norb) ---- + function knownFb(s, f) { return s.fbLive.has(f) && !s.fbDeleted.has(f); } + proto.createFramebuffer = function () { + var f = real.createFramebuffer.apply(this, arguments); + if (f && !this.isContextLost()) shadowOf(this).fbLive.add(f); + return f; + }; + proto.deleteFramebuffer = function (f) { + var r = real.deleteFramebuffer.apply(this, arguments); + if (f && !this.isContextLost()) { + var s = shadowOf(this); + if (knownFb(s, f)) { + s.fbDeleted.add(f); + s.rbOf.delete(f); + if (s.readFb === f) s.readFb = null; // deleting the bound framebuffer binds the default one + } + } + return r; + }; + // FRAMEBUFFER and READ_FRAMEBUFFER move the read binding (DRAW_FRAMEBUFFER does not). A + // deleted framebuffer is refused (INVALID_OPERATION, binding unchanged); one the shim did not + // see created (another context's: refused too, but not provably) makes the binding UNKNOWN. + proto.bindFramebuffer = function (target, f) { + var r = real.bindFramebuffer.apply(this, arguments); + if (!this.isContextLost()) { + var s = shadowOf(this), t = u32(target); + if (t === FRAMEBUFFER || t === READ_FRAMEBUFFER) { + if (f === null || f === undefined) s.readFb = null; + else if (knownFb(s, f)) s.readFb = f; + else if (!s.fbLive.has(f)) s.readFb = UNKNOWN; + } + } + return r; + }; + // The default framebuffer takes BACK or NONE, a framebuffer object NONE or COLOR_ATTACHMENTi + // below MAX_COLOR_ATTACHMENTS; anything else is refused and leaves the value unchanged, which + // the shadow does not try to prove: it forgets the entry and re-reads it on the next query. + // Under an UNKNOWN binding the call may have changed ANY framebuffer's value (a refused + // foreign bind leaves the previous one bound, known or default), so all of them are forgotten. + proto.readBuffer = function (src) { + var r = real.readBuffer.apply(this, arguments); + if (!this.isContextLost()) { + var s = shadowOf(this), m = u32(src), fb = s.readFb; + if (fb === null) { + s.rbDefault = (m === BACK || m === NONE) ? m : undefined; + } else if (fb === UNKNOWN) { + s.rbDefault = undefined; + s.rbOf = new WeakMap(); + } else { + var maxCA = s.p[MAX_COLOR_ATTACHMENTS]; + if (m === NONE || (maxCA > 0 && m >= COLOR_ATTACHMENT0 && m < COLOR_ATTACHMENT0 + maxCA)) + s.rbOf.set(fb, m); + else + s.rbOf.delete(fb); + } + } + return r; + }; + } + + install(WebGLRenderingContext.prototype, false); + if (typeof WebGL2RenderingContext !== 'undefined') install(WebGL2RenderingContext.prototype, true); +})(); From 541ce93ab766fbfc78106a2f192d2758698d5482 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 2 Oct 2026 00:23:37 -0500 Subject: [PATCH 2/8] cvcGL: FrameYield, an explicit switch for VTK's in-render browser yield VTK 9.5's vtkWebAssemblyOpenGLRenderWindow::Frame(), reached from every Render(), calls emscripten_sleep(0) when DoubleBuffer is on and the build has Asyncify. An app whose loop already yields once per frame pays that clamped trip through the event loop twice. An app could only remove it by calling SetDoubleBuffer(0) on the render window itself; this makes it a cvcGL setting: namespace cvc::gl { enum class FrameYield { Vtk, App }; } // FrameYield.h SceneRenderer / ViewportManager: using FrameYield = cvc::gl::FrameYield; void setFrameYield(FrameYield); FrameYield frameYield() const; // the effective mode void lockFrameYield(); // App for the life of the window bool frameYieldLocked() const; #define CVC_GL_HAS_FRAME_YIELD 1 #define CVC_GL_HAS_FRAME_YIELD_LOCK 1 Vtk stays the default. App is the app's promise that its loop calls emscripten_sleep(0) once per frame on every path that renders; cvcGL then turns the window's DoubleBuffer off. That happens only on wasm with Asyncify and VTK < 9.6: VTK 9.6 dropped the sleep, and natively or without Asyncify there is none, so there App is a no-op, DoubleBuffer is left alone and frameYield() reads Vtk. cvcGL changes DoubleBuffer back only if it turned it off, and then restores the value it found rather than forcing 1, so Vtk mode never overrides an app's own setting. Pixel readback is unaffected: writePNG and frameRGB read the back buffer explicitly. ?frameyield=vtk|app on the page URL overrides the app for an A/B, read once when the window is created. Watchdog: a microtask-bumped epoch (EM_JS) can only advance once wasm has returned to the browser. A vtkCommand::StartEvent observer on the window counts every Render(), whichever path made it: render(), writePNG / frameRGB, renderWindow()->Render(), a node's fallback render, the interactor's resize render. If one window renders 8 times in a single epoch, the loop broke the App contract: cvcGL logs once and restores VTK's yield, so the page keeps running instead of freezing. Each trip bumps globalThis.__cvcglFrameYield.trips for browser checks. With VTK >= 9.6 there is nothing to restore and it only warns. The observer is removed before the window can outlive the manager. The lock: under -sASYNCIFY_IGNORE_INDIRECT=1, VTK's in-render sleep is reached through the virtual Render() and traps, so in such an app neither ?frameyield=vtk nor a watchdog trip may bring it back. lockFrameYield(), or Module.cvcglFrameYieldLocked = 1 set before the module starts (read once per window), makes App one-way: ?frameyield=vtk and setFrameYield(Vtk) are refused and logged, and the watchdog only reports, with a loud "cvcGL: ERROR:" line. Natively the lock is only recorded. The enum lives in its own header and is %included in pycvc_gl.i ahead of SceneRenderer.h, because SWIG does not follow #include. New headless test cvcgl_frame_yield pins the native contract: App leaves DoubleBuffer at 1 and reads back Vtk, the facade forwards, Vtk does not reset an app's own DoubleBuffer, the lock is one-way and only recorded, a closed renderer throws, and the requires-detection a consumer writes finds the methods and falls back on a type without them. --- bindings/pycvc/pycvc_gl.i | 3 + inc/cvc/gl/FrameYield.h | 52 ++++++ inc/cvc/gl/SceneRenderer.h | 15 ++ inc/cvc/gl/ViewportManager.h | 42 +++++ src/cvcGL/CMakeLists.txt | 7 + src/cvcGL/SceneRenderer.cpp | 20 ++ src/cvcGL/ViewportManager.cpp | 261 +++++++++++++++++++++++++++ src/cvcGL/test/cvcgl_frame_yield.cpp | 141 +++++++++++++++ 8 files changed, 541 insertions(+) create mode 100644 inc/cvc/gl/FrameYield.h create mode 100644 src/cvcGL/test/cvcgl_frame_yield.cpp diff --git a/bindings/pycvc/pycvc_gl.i b/bindings/pycvc/pycvc_gl.i index 0882c9ee..b6a7d63e 100644 --- a/bindings/pycvc/pycvc_gl.i +++ b/bindings/pycvc/pycvc_gl.i @@ -1694,6 +1694,9 @@ def _typed_node(sg, name): %pythonappend cvc::gl::SceneRenderer::SceneRenderer %{ if args: self._pycvc_scene = args[0] %} +// FrameYield: SceneRenderer / ViewportManager::setFrameYield take it. At namespace +// scope, so it must be seen before either header (SWIG does not follow #include). +%include "cvc/gl/FrameYield.h" %include "cvc/gl/SceneRenderer.h" %extend cvc::gl::SceneRenderer { diff --git a/inc/cvc/gl/FrameYield.h b/inc/cvc/gl/FrameYield.h new file mode 100644 index 00000000..931f28a9 --- /dev/null +++ b/inc/cvc/gl/FrameYield.h @@ -0,0 +1,52 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. +*/ + +#ifndef __CVC_GL_FRAME_YIELD_H__ +#define __CVC_GL_FRAME_YIELD_H__ + +// Feature test for SceneRenderer / ViewportManager::setFrameYield (or, in C++20, +// `requires { v.setFrameYield(V::FrameYield::App); }`). +#define CVC_GL_HAS_FRAME_YIELD 1 +// ... and for lockFrameYield() / frameYieldLocked() (`requires { v.lockFrameYield(); }`). +#define CVC_GL_HAS_FRAME_YIELD_LOCK 1 + +namespace cvc { +namespace gl { + +// Who yields to the browser once per frame in a WebAssembly build -- VTK inside +// every render, or the app's own loop. Namespace scope so SWIG wraps it as-is; +// SceneRenderer and ViewportManager also name it as a nested alias. +// +// Vtk (default) VTK 9.5's vtkWebAssemblyOpenGLRenderWindow::Frame() calls +// emscripten_sleep(0) at the end of every render (when the build +// has Asyncify), so each render() is one trip through the +// browser's event loop -- and a frame that also yields in its own +// loop pays the clamped trip twice. +// App the app's loop calls emscripten_sleep(0) once per frame on EVERY +// path that renders, so cvcGL turns VTK's in-render sleep off +// (SetDoubleBuffer(0) on the render window). Only valid with that +// contract kept: a render loop that never yields would never let +// the browser paint. A watchdog catches that case (see +// ViewportManager::setFrameYield) and restores VTK's yield. +// An app linked with -sASYNCIFY_IGNORE_INDIRECT=1 must LOCK App +// (ViewportManager::lockFrameYield, or cvcgl_wasm_app's +// Module.cvcglFrameYieldLocked): there VTK's yield, reached +// through a virtual call, traps, so neither ?frameyield=vtk nor +// the watchdog may bring it back. +// +// Native, and wasm without Asyncify, have no in-render yield: App is a no-op +// there. VTK 9.6 removed the in-render yield entirely, so with it every wasm loop +// must yield by itself in either mode. +enum class FrameYield { Vtk, App }; + +} // namespace gl +} // namespace cvc + +#endif // __CVC_GL_FRAME_YIELD_H__ diff --git a/inc/cvc/gl/SceneRenderer.h b/inc/cvc/gl/SceneRenderer.h index e518f1b8..c5347526 100644 --- a/inc/cvc/gl/SceneRenderer.h +++ b/inc/cvc/gl/SceneRenderer.h @@ -11,6 +11,7 @@ #ifndef __CVC_GL_SCENE_RENDERER_H__ #define __CVC_GL_SCENE_RENDERER_H__ +#include #include #include #include @@ -144,6 +145,20 @@ class SceneRenderer { // re-point it. Throws if the renderer is closed. ViewportManager &viewportManager() const; + // Who yields to the browser once per frame in a WebAssembly build: VTK inside + // every render (FrameYield::Vtk, the default) or the caller's loop + // (FrameYield::App, which turns VTK 9.5's in-render emscripten_sleep off). Only + // pass App from a loop that calls emscripten_sleep(0) once per frame on every + // path that renders. No-op natively. Forwards to the ViewportManager; see + // ViewportManager::setFrameYield for the URL override and the watchdog, and + // ViewportManager::lockFrameYield for the lock an -sASYNCIFY_IGNORE_INDIRECT=1 + // app needs (App for good: no URL override, no watchdog fallback to Vtk). + using FrameYield = cvc::gl::FrameYield; + void setFrameYield(FrameYield mode); + FrameYield frameYield() const; // the EFFECTIVE mode + void lockFrameYield(); + bool frameYieldLocked() const; + // Detach from the scene and release the GL context. Idempotent; the // destructor calls it. Exposed so a caller can decide WHEN the context dies // rather than leaving it to static teardown at process exit, which segfaults diff --git a/inc/cvc/gl/ViewportManager.h b/inc/cvc/gl/ViewportManager.h index b06e95a9..739ec5a7 100644 --- a/inc/cvc/gl/ViewportManager.h +++ b/inc/cvc/gl/ViewportManager.h @@ -11,6 +11,7 @@ #ifndef __CVC_GL_VIEWPORT_MANAGER_H__ #define __CVC_GL_VIEWPORT_MANAGER_H__ +#include #include #include #include @@ -171,6 +172,47 @@ class ViewportManager { vtkRenderWindow *renderWindow() const; // escape hatch, parity with SceneRenderer + // ---- frame yield (WebAssembly) --------------------------------------------- + // Who yields to the browser once per frame: VTK inside every render (Vtk, the + // default) or the caller's loop (App) -- see cvc/gl/FrameYield.h. Call + // setFrameYield(FrameYield::App) only from a loop that calls + // emscripten_sleep(0) once per frame on EVERY path that renders; cvcGL then + // switches VTK 9.5's in-render sleep off (SetDoubleBuffer(0) on the window), + // which saves a clamped trip through the event loop per frame. + // + // A no-op natively and in a wasm build without Asyncify (there is no in-render + // yield to remove), where frameYield() stays Vtk and DoubleBuffer is untouched. + // A page URL can override the app for an A/B without a rebuild: + // ?frameyield=vtk|app, read once when the window is created (not while locked). + // + // Watchdog (wasm, App): if this window renders 8 times without the page having + // yielded to the browser in between, cvcGL logs once and restores VTK's yield + // (frameYield() is Vtk again), so a loop that broke the contract stays alive + // instead of freezing the page. Calling setFrameYield(App) again re-arms it. + // Every vtkRenderWindow::Render() counts (a StartEvent observer on the window), + // whichever path made it: render(), writePNG / frameRGB, renderWindow()->Render(), + // a resize. When cvcGL turned DoubleBuffer off it restores the value it found + // then, so an app's own SetDoubleBuffer(0) survives the trip if the app made it + // first (before setFrameYield(App), or under ?frameyield=app before the window + // exists); one made after is undone. With VTK >= 9.6 + // (no in-render yield to restore), or while locked, it only reports. + using FrameYield = cvc::gl::FrameYield; + void setFrameYield(FrameYield mode); + FrameYield frameYield() const; // the EFFECTIVE mode + + // Lock FrameYield::App for the life of this window. For an app linked with + // -sASYNCIFY_IGNORE_INDIRECT=1: VTK 9.5's in-render emscripten_sleep is reached + // through the virtual Render(), so under that flag it TRAPS, and App must not be + // undone at runtime. Once locked, ?frameyield=vtk and setFrameYield(Vtk) are + // refused (and logged), and the watchdog reports loudly instead of turning + // VTK's yield back on. One-way: there is no unlock. A page can lock every window + // before the app starts with Module.cvcglFrameYieldLocked = 1, which + // cvcgl_wasm_app() links in automatically when the app's final link options + // carry -sASYNCIFY_IGNORE_INDIRECT=1 (or on FRAME_YIELD_LOCKED ON). Natively it + // only records the lock (frameYield() still reads Vtk: nothing yields in-render). + void lockFrameYield(); + bool frameYieldLocked() const; + private: struct Impl; std::unique_ptr m_impl; diff --git a/src/cvcGL/CMakeLists.txt b/src/cvcGL/CMakeLists.txt index c8b22e21..48652364 100644 --- a/src/cvcGL/CMakeLists.txt +++ b/src/cvcGL/CMakeLists.txt @@ -425,6 +425,13 @@ add_executable(cvcgl_scene_renderer_facade test/cvcgl_scene_renderer_facade.cpp) target_link_libraries(cvcgl_scene_renderer_facade PRIVATE cvcGL) add_test(NAME cvcgl_scene_renderer_facade COMMAND cvcgl_scene_renderer_facade) +# FrameYield: natively App is a no-op (DoubleBuffer stays on, the effective mode +# reads Vtk), SceneRenderer forwards to its ViewportManager, and the nested alias +# + CVC_GL_HAS_FRAME_YIELD consumers feature-detect with exist. Headless. +add_executable(cvcgl_frame_yield test/cvcgl_frame_yield.cpp) +target_link_libraries(cvcgl_frame_yield PRIVATE cvcGL) +add_test(NAME cvcgl_frame_yield COMMAND cvcgl_frame_yield) + add_executable(cvcgl_volren_node test/cvcgl_volren_node.cpp) target_link_libraries(cvcgl_volren_node PRIVATE cvcGL) add_test(NAME cvcgl_volren_node COMMAND cvcgl_volren_node) diff --git a/src/cvcGL/SceneRenderer.cpp b/src/cvcGL/SceneRenderer.cpp index ad4750fb..a3db22d1 100644 --- a/src/cvcGL/SceneRenderer.cpp +++ b/src/cvcGL/SceneRenderer.cpp @@ -183,6 +183,26 @@ vtkRenderWindow *SceneRenderer::renderWindow() const { SceneGraph &SceneRenderer::scene() const { return *m_impl->scene; } const std::string &SceneRenderer::name() const { return m_impl->name; } +void SceneRenderer::setFrameYield(FrameYield mode) { + m_impl->requireOpen(); + m_impl->vm->setFrameYield(mode); +} + +SceneRenderer::FrameYield SceneRenderer::frameYield() const { + m_impl->requireOpen(); + return m_impl->vm->frameYield(); +} + +void SceneRenderer::lockFrameYield() { + m_impl->requireOpen(); + m_impl->vm->lockFrameYield(); +} + +bool SceneRenderer::frameYieldLocked() const { + m_impl->requireOpen(); + return m_impl->vm->frameYieldLocked(); +} + ViewportManager &SceneRenderer::viewportManager() const { m_impl->requireOpen(); return *m_impl->vm; diff --git a/src/cvcGL/ViewportManager.cpp b/src/cvcGL/ViewportManager.cpp index c52ab1f1..31654659 100644 --- a/src/cvcGL/ViewportManager.cpp +++ b/src/cvcGL/ViewportManager.cpp @@ -9,6 +9,7 @@ */ #include +#include #include // active_viewport focus, stored in cvc::state #include #include @@ -20,6 +21,7 @@ #include #include // detect 2-D overlay props to skip in a mirror #include // vtkCollectionSimpleIterator (reentrant prop traversal) +#include // StartEvent: the FrameYield watchdog counts every Render() #include #include #include @@ -34,7 +36,64 @@ #include #include #include +#include // VTK 9.6 dropped the in-render yield FrameYield::App removes #include +#ifdef __EMSCRIPTEN__ +#include +#endif + +// FrameYield (see cvc/gl/FrameYield.h). VTK 9.5's vtkWebAssemblyOpenGLRenderWindow::Frame() -- +// reached from every Render() -- calls emscripten_sleep(0) when DoubleBuffer is on and the build +// has Asyncify; VTK 9.6 removed that sleep. FrameYield::App turns it off with SetDoubleBuffer(0). +#if defined(__EMSCRIPTEN__) && VTK_VERSION_NUMBER < VTK_VERSION_CHECK(9, 6, 0) +#define CVCGL_VTK_FRAME_SLEEPS 1 +#else +#define CVCGL_VTK_FRAME_SLEEPS 0 +#endif + +#ifdef __EMSCRIPTEN__ +// clang-format off: JavaScript bodies +// The page's ?frameyield=vtk|app (1 = vtk, 2 = app, 0 = absent or anything else). +EM_JS(int, cvcgl_frame_yield_url, (), { + try { + if (typeof location === "undefined" || !location.search) return 0; + var v = new URLSearchParams(location.search).get("frameyield"); + if (v === null) return 0; + v = String(v).toLowerCase(); + return v === "app" ? 2 : (v === "vtk" ? 1 : 0); + } catch (e) { + return 0; + } +}); +// Module.cvcglFrameYieldLocked (1 = lock FrameYield::App on every window): set by the page, or by +// the --pre-js cvcgl_wasm_app() links into an app whose final link options carry +// -sASYNCIFY_IGNORE_INDIRECT=1 (cvcGLWasm.cmake). Read once per window. +EM_JS(int, cvcgl_frame_yield_locked_by_page, (), { + try { + return (typeof Module !== "undefined" && Module && Module["cvcglFrameYieldLocked"]) ? 1 : 0; + } catch (e) { + return 0; + } +}); +// A counter bumped by a microtask. A microtask can only run once wasm has returned to the +// browser's event loop (an Asyncify unwind in emscripten_sleep, the end of a main-loop callback), +// so two reads that return the same value had no yield to the browser between them. +EM_JS(int, cvcgl_frame_yield_epoch, (), { + var s = globalThis.__cvcglFrameYield; + if (!s) s = globalThis.__cvcglFrameYield = { epoch: 0, armed: false, trips: 0 }; + if (!s.armed) { + s.armed = true; + queueMicrotask(function () { s.armed = false; s.epoch = (s.epoch + 1) | 0; }); + } + return s.epoch; +}); +// The watchdog fired: count it where a browser check can read it (__cvcglFrameYield.trips). +EM_JS(void, cvcgl_frame_yield_tripped, (), { + var s = globalThis.__cvcglFrameYield; + if (s) s.trips = (s.trips | 0) + 1; +}); +// clang-format on +#endif // File-local interactor style: the ONE interactor-coupled piece of the input // router. Onscreen it is installed on the manager's single interactor; each On* @@ -195,6 +254,155 @@ struct ViewportManager::Impl { // Keep the window's layer count strictly greater than the highest layer index // in use, or VTK silently drops the top layer. void syncLayerCount() { window->SetNumberOfLayers(highestLayer() + 1); } + + // ---- FrameYield (see ViewportManager::setFrameYield / lockFrameYield) ---- + // requestedYield: what the app asked for. urlYield: the page's ?frameyield= (0 absent, 1 vtk, + // 2 app), read once at construction. yieldLocked: App for good (lockFrameYield(), or the page's + // Module.cvcglFrameYieldLocked) -- neither the URL, setFrameYield(Vtk) nor the watchdog may bring + // VTK's in-render sleep back. yieldTripped: the watchdog restored VTK's yield. doubleBufferOff: + // cvcGL switched the window's DoubleBuffer off; savedDoubleBuffer is the value it found then, + // which is what it restores (so an app's own SetDoubleBuffer(0) survives). yieldEpoch / + // rendersThisEpoch: the watchdog's view of the page's yields. yieldObserver: the window's + // StartEvent observer tag (wasm only), removed before the window can outlive this Impl. + static constexpr int kYieldWatchdogRenders = 8; + FrameYield requestedYield = FrameYield::Vtk; + int urlYield = 0; + bool yieldLocked = false; + bool yieldTripped = false; + bool doubleBufferOff = false; + int savedDoubleBuffer = 1; + bool yieldWarned = false; + int yieldEpoch = -1; + int rendersThisEpoch = 0; + unsigned long yieldObserver = 0; + + FrameYield effectiveYield() const { +#ifdef __EMSCRIPTEN__ + if (!emscripten_has_asyncify()) + return FrameYield::Vtk; + if (yieldLocked) + return FrameYield::App; + if (yieldTripped) + return FrameYield::Vtk; + if (urlYield == 2) + return FrameYield::App; + if (urlYield == 1) + return FrameYield::Vtk; + return requestedYield; +#else + return FrameYield::Vtk; // no in-render yield natively: App is a no-op +#endif + } + + // Bring the window's DoubleBuffer in line with the effective mode. Touches it only on a + // change cvcGL made, so Vtk mode leaves VTK's default (and any app's own setting) alone, and + // turning it back on restores what cvcGL found rather than forcing 1. + void applyYield() { +#if CVCGL_VTK_FRAME_SLEEPS + const bool off = effectiveYield() == FrameYield::App; + if (off == doubleBufferOff) + return; + if (off) { + savedDoubleBuffer = window->GetDoubleBuffer(); + window->SetDoubleBuffer(0); + } else { + window->SetDoubleBuffer(savedDoubleBuffer); + } + doubleBufferOff = off; +#endif + } + + // Lock App (lockFrameYield, or the page's Module.cvcglFrameYieldLocked). `by` says who, for + // the log line when it overrules the page's ?frameyield=vtk. + void lockYield(const char *by) { + if (!yieldLocked) { + yieldLocked = true; +#ifdef __EMSCRIPTEN__ + if (urlYield == 1 && emscripten_has_asyncify()) + std::printf("cvcGL: ?frameyield=vtk ignored on window '%s': FrameYield is locked to App " + "(%s). VTK's in-render sleep is reached through a virtual call, which traps " + "under -sASYNCIFY_IGNORE_INDIRECT=1.\n", + name.c_str(), by); +#else + (void)by; +#endif + } + requestedYield = FrameYield::App; + yieldTripped = false; + yieldWarned = false; + yieldEpoch = -1; + rendersThisEpoch = 0; + applyYield(); + } + + // The window's vtkCommand::StartEvent observer (wasm): every vtkRenderWindow::Render() passes + // here before VTK's Frame() -- render(), writePNG / frameRGB, an app's renderWindow()->Render(), + // a node's own fallback render, the interactor's resize render -- so every path is counted. A + // render loop that broke the App contract (a path that renders without emscripten_sleep(0)) + // would otherwise never let the browser paint again; after kYieldWatchdogRenders renders in one + // browser task this restores VTK's in-render yield -- the page keeps running, slower -- and says + // why, once. Locked, it cannot restore anything (that sleep would trap), so it only reports. + void yieldWatchdog() { +#ifdef __EMSCRIPTEN__ + if (!emscripten_has_asyncify()) + return; // a main-loop callback app: every frame is its own browser task +#if CVCGL_VTK_FRAME_SLEEPS + if (effectiveYield() != FrameYield::App) { + yieldEpoch = -1; // VTK yields in every render: nothing to watch + return; + } +#endif + const int e = cvcgl_frame_yield_epoch(); + if (e != yieldEpoch) { + yieldEpoch = e; + rendersThisEpoch = 1; + return; + } + if (++rendersThisEpoch < kYieldWatchdogRenders) + return; + rendersThisEpoch = 0; + cvcgl_frame_yield_tripped(); +#if CVCGL_VTK_FRAME_SLEEPS + if (!yieldLocked) { + yieldTripped = true; + applyYield(); // DoubleBuffer back as found: this very render yields inside Frame() + const bool restored = window->GetDoubleBuffer() != 0; + std::fprintf(stderr, + "cvcGL: FrameYield::App on window '%s', but it rendered %d times without the " + "page yielding to the browser in between. In App mode the app's loop must " + "call emscripten_sleep(0) once per frame on every path that renders. %s See " + "docs/CVCGL_WASM.md.\n", + name.c_str(), kYieldWatchdogRenders, + restored ? "Restored VTK's in-render yield (FrameYield::Vtk) so the page keeps " + "running." + : "VTK's in-render yield stays off: the app had turned DoubleBuffer " + "off itself before cvcGL did."); + return; + } + if (!yieldWarned) { + yieldWarned = true; + std::fprintf(stderr, + "cvcGL: ERROR: FrameYield::App is LOCKED on window '%s' (an " + "-sASYNCIFY_IGNORE_INDIRECT=1 app), and it rendered %d times without the page " + "yielding to the browser in between. cvcGL cannot restore VTK's in-render " + "yield here -- it would trap -- so the page cannot paint until the loop " + "yields. Every path that renders must call emscripten_sleep(0) once per " + "frame. See docs/CVCGL_WASM.md.\n", + name.c_str(), kYieldWatchdogRenders); + } +#else + if (!yieldWarned) { + yieldWarned = true; + std::fprintf(stderr, + "cvcGL: window '%s' rendered %d times without the page yielding to the " + "browser in between. This VTK has no in-render yield, so the app's loop must " + "call emscripten_sleep(0) once per frame or the page cannot paint. See " + "docs/CVCGL_WASM.md.\n", + name.c_str(), kYieldWatchdogRenders); + } +#endif +#endif + } }; ViewportManager::ViewportManager(SceneGraph &mainScene, int width, int height, bool offscreen, @@ -226,6 +434,22 @@ ViewportManager::ViewportManager(SceneGraph &mainScene, int width, int height, b m_impl->window->SetOffScreenRendering(offscreen ? 1 : 0); m_impl->window->SetSize(width, height); m_impl->window->SetNumberOfLayers(1); +#ifdef __EMSCRIPTEN__ + // ?frameyield=vtk|app: A/B the frame yield of any page without a rebuild (wins over the app, + // but never over a lock: Module.cvcglFrameYieldLocked, from cvcgl_wasm_app on an + // -sASYNCIFY_IGNORE_INDIRECT=1 app, or set by the page). + m_impl->urlYield = cvcgl_frame_yield_url(); + if (cvcgl_frame_yield_locked_by_page()) + m_impl->lockYield("Module.cvcglFrameYieldLocked"); + else if (m_impl->urlYield != 0) + std::printf("cvcGL: ?frameyield=%s -- window '%s' uses FrameYield::%s whatever the app asks\n", + m_impl->urlYield == 2 ? "app" : "vtk", name.c_str(), + m_impl->urlYield == 2 ? "App" : "Vtk"); + m_impl->applyYield(); + // The watchdog counts every Render() of this window, whichever path made it. + m_impl->yieldObserver = + m_impl->window->AddObserver(vtkCommand::StartEvent, m_impl.get(), &Impl::yieldWatchdog); +#endif if (!offscreen) { m_impl->window->SetWindowName("cvcGL"); @@ -284,6 +508,11 @@ ViewportManager::~ViewportManager() { return; m_impl->closed = true; try { + // The watchdog observer points at this Impl; an app may still hold the window (the + // renderWindow() escape hatch), so it must not call back into a destroyed manager. + if (m_impl->window && m_impl->yieldObserver) + m_impl->window->RemoveObserver(m_impl->yieldObserver); + m_impl->yieldObserver = 0; // Detach every scene BEFORE the window dies: the scenes hold actors that // belong to these renderers, and tearing the window down under them is how // offscreen backends crash at exit (see SceneRenderer::close). @@ -721,5 +950,37 @@ vtkRenderWindow *ViewportManager::renderWindow() const { return m_impl->window; } +void ViewportManager::setFrameYield(FrameYield mode) { + m_impl->requireOpen(); + if (m_impl->yieldLocked && mode != FrameYield::App) { + std::fprintf(stderr, + "cvcGL: setFrameYield(Vtk) refused on window '%s': FrameYield is locked to App " + "(lockFrameYield / Module.cvcglFrameYieldLocked).\n", + m_impl->name.c_str()); + return; + } + m_impl->requestedYield = mode; + // A fresh request re-arms the watchdog (and drops a trip from an earlier contract). + m_impl->yieldTripped = false; + m_impl->yieldEpoch = -1; + m_impl->rendersThisEpoch = 0; + m_impl->applyYield(); +} + +ViewportManager::FrameYield ViewportManager::frameYield() const { + m_impl->requireOpen(); + return m_impl->effectiveYield(); +} + +void ViewportManager::lockFrameYield() { + m_impl->requireOpen(); + m_impl->lockYield("lockFrameYield()"); +} + +bool ViewportManager::frameYieldLocked() const { + m_impl->requireOpen(); + return m_impl->yieldLocked; +} + } // namespace gl } // namespace cvc diff --git a/src/cvcGL/test/cvcgl_frame_yield.cpp b/src/cvcGL/test/cvcgl_frame_yield.cpp new file mode 100644 index 00000000..0a6afcc5 --- /dev/null +++ b/src/cvcGL/test/cvcgl_frame_yield.cpp @@ -0,0 +1,141 @@ +// FrameYield (cvc/gl/FrameYield.h): who yields to the browser once per frame in a +// WebAssembly build -- VTK inside every render, or the app's loop. Natively there +// is no in-render yield, so App must be a NO-OP: the window's DoubleBuffer stays +// on (native double buffering is real, and turning it off would tear) and the +// effective mode reads back Vtk. Pins that, the facade forwarding, the nested +// aliases + CVC_GL_HAS_FRAME_YIELD(_LOCK) that consumers feature-detect with, the +// one-way lock (lockFrameYield: an -sASYNCIFY_IGNORE_INDIRECT=1 app's App for +// good), and that a closed renderer refuses the calls like every other one. +// +// The wasm side (SetDoubleBuffer(0), ?frameyield=, Module.cvcglFrameYieldLocked, +// the StartEvent watchdog) needs a browser; +// docs/CVCGL_WASM.md lists its acceptance checks. Offscreen and headless: nothing +// here renders. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef CVC_GL_HAS_FRAME_YIELD +#error "cvc/gl/FrameYield.h must define CVC_GL_HAS_FRAME_YIELD" +#endif +#ifndef CVC_GL_HAS_FRAME_YIELD_LOCK +#error "cvc/gl/FrameYield.h must define CVC_GL_HAS_FRAME_YIELD_LOCK" +#endif + +using cvc::gl::FrameYield; +using cvc::gl::SceneGraph; +using cvc::gl::SceneRenderer; +using cvc::gl::ViewportManager; + +static int fails = 0; +static void check(bool ok, const std::string &w) { + std::printf(" %s %s\n", ok ? "PASS" : "FAIL", w.c_str()); + if (!ok) + ++fails; +} + +// The detection a consumer that must build against older cvcGL too writes (the +// docs/CVCGL_WASM.md feature-detection snippet): the nested alias makes the call +// well-formed only where it exists. +template bool app_yields(V &v) { + if constexpr (requires { v.setFrameYield(V::FrameYield::App); }) { + v.setFrameYield(V::FrameYield::App); + return true; + } else { + return false; + } +} +// ... and the lock, for an app linked with -sASYNCIFY_IGNORE_INDIRECT=1. +template bool app_locks(V &v) { + if constexpr (requires { v.lockFrameYield(); }) { + v.lockFrameYield(); + return true; + } else { + return false; + } +} +struct OldRenderer {}; // a cvcGL without FrameYield +static_assert(std::is_same_v); +static_assert(std::is_same_v); + +int main() { + std::printf("cvcgl_frame_yield\n"); + cvc::app app; + + { + SceneGraph sg(app, "fy_sr"); + SceneRenderer view(sg, 64, 48, /*offscreen=*/true, "main"); + vtkRenderWindow *rw = view.renderWindow(); + const int db0 = rw->GetDoubleBuffer(); + check(view.frameYield() == FrameYield::Vtk, "SceneRenderer: default is Vtk"); + check(app_yields(view), "SceneRenderer: requires-detection finds setFrameYield"); + check(rw->GetDoubleBuffer() == db0, + "SceneRenderer: App leaves DoubleBuffer untouched natively"); + check(rw->GetDoubleBuffer() == 1, "SceneRenderer: DoubleBuffer stays 1 natively"); + check(view.frameYield() == FrameYield::Vtk, + "SceneRenderer: effective mode stays Vtk natively (no in-render yield to remove)"); + check(view.viewportManager().frameYield() == view.frameYield(), + "SceneRenderer forwards to its ViewportManager"); + view.setFrameYield(FrameYield::Vtk); + check(rw->GetDoubleBuffer() == db0 && view.frameYield() == FrameYield::Vtk, + "SceneRenderer: back to Vtk changes nothing natively"); + // An app's own SetDoubleBuffer (the escape hatch) is never overridden by Vtk mode. + rw->SetDoubleBuffer(0); + view.setFrameYield(FrameYield::Vtk); + check(rw->GetDoubleBuffer() == 0, + "SceneRenderer: Vtk does not reset an app's own DoubleBuffer"); + rw->SetDoubleBuffer(db0); + // The lock: recorded, one-way, and still a no-op on the window natively. + check(!view.frameYieldLocked(), "SceneRenderer: not locked by default"); + check(app_locks(view), "SceneRenderer: requires-detection finds lockFrameYield"); + check(view.frameYieldLocked() && view.viewportManager().frameYieldLocked(), + "SceneRenderer: lockFrameYield locks (and forwards to its ViewportManager)"); + view.setFrameYield(FrameYield::Vtk); // refused (logged): the lock is one-way + view.lockFrameYield(); // idempotent + check(view.frameYieldLocked(), "SceneRenderer: setFrameYield(Vtk) does not unlock"); + check(rw->GetDoubleBuffer() == db0 && view.frameYield() == FrameYield::Vtk, + "SceneRenderer: locked App leaves DoubleBuffer untouched natively (effective Vtk)"); + view.close(); + bool threw = false; + try { + view.setFrameYield(FrameYield::App); + } catch (const std::runtime_error &) { + threw = true; + } + check(threw, "SceneRenderer: setFrameYield on a closed renderer throws"); + threw = false; + try { + view.lockFrameYield(); + } catch (const std::runtime_error &) { + threw = true; + } + check(threw, "SceneRenderer: lockFrameYield on a closed renderer throws"); + } + + { + SceneGraph sg(app, "fy_vm"); + ViewportManager vm(sg, 64, 48, /*offscreen=*/true, "main"); + vtkRenderWindow *rw = vm.renderWindow(); + check(vm.frameYield() == FrameYield::Vtk, "ViewportManager: default is Vtk"); + check(app_yields(vm), "ViewportManager: requires-detection finds setFrameYield"); + check(rw->GetDoubleBuffer() == 1, "ViewportManager: App leaves DoubleBuffer 1 natively"); + check(vm.frameYield() == FrameYield::Vtk, "ViewportManager: effective mode stays Vtk natively"); + check(!vm.frameYieldLocked(), "ViewportManager: not locked by default"); + check(app_locks(vm) && vm.frameYieldLocked(), "ViewportManager: lockFrameYield locks"); + check(rw->GetDoubleBuffer() == 1, "ViewportManager: locked App leaves DoubleBuffer 1 natively"); + } + + OldRenderer old; + check(!app_yields(old), "requires-detection falls back on a renderer without FrameYield"); + check(!app_locks(old), "requires-detection falls back on a renderer without the lock"); + + std::printf("%s (%d failure%s)\n", fails ? "FAILED" : "OK", fails, fails == 1 ? "" : "s"); + return fails ? 1 : 0; +} From c68f761e8d7d5dcc670209e733389bf3913423ab Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 2 Oct 2026 00:23:37 -0500 Subject: [PATCH 3/8] cvcGL: cvcgl_wasm_app(), the one-line opt-in for wasm apps A wasm app that links cvcGL gets the browser-side speedups with cvcgl_wasm_app( [STATE_SHIM ON|OFF] [STATE_SHIM_MODE on|verify|norb] [MIMALLOC AUTO|ON|OFF] [FRAME_YIELD_LOCKED AUTO|ON|OFF]) defined in src/cvcGL/wasm/cvcGLWasm.cmake, included in-tree by src/cvcGL/CMakeLists.txt and, for installed consumers, by cvcGLConfig.cmake. Outside Emscripten it returns at once, so an app can call it unconditionally. Consumers feature-test it with if(COMMAND cvcgl_wasm_app), and later keywords with CVCGL_WASM_APP_FEATURES. That variable is directory-scoped, so it is set on every include, ahead of include_guard(GLOBAL): a second find_package(cvcGL) from a sibling directory sees it too. - STATE_SHIM (default ON) links webgl_state_shadow.js as a --pre-js and adds it to LINK_DEPENDS. A STATE_SHIM_MODE other than `on` generates _glshim_default.js (Module.glStateShadowDefault) ahead of it. - MIMALLOC AUTO links -sMALLOC=mimalloc iff cvcGL was built -pthread: dlmalloc serialises every malloc/free on one lock, which only threads contend on, and mimalloc is larger and uses more memory. A -sMALLOC=mimalloc the target already links counts as ON; any other allocator of its own is kept, with a warning. Under -fsanitize=address AUTO resolves OFF, because emcc refuses mimalloc with ASan, and an explicit ON warns. - FRAME_YIELD_LOCKED links a generated _frameyield_lock.js that sets Module.cvcglFrameYieldLocked = 1, so every cvcGL window of the app keeps FrameYield::App for good. AUTO locks exactly when the target's own link line carries -sASYNCIFY_IGNORE_INDIRECT=1: its LINK_OPTIONS, LINK_FLAGS[_], the -s items given to target_link_libraries, and the global linker flags. Directory options are not read: a target inherits them only when it is created, so a later add_link_options is not on its link line. ON is for a flag the check cannot see (a dependency's INTERFACE_LINK_OPTIONS, a generator expression). - With CMake >= 3.19 a deferred check on the final link options resolves AUTO and warns when the target links -sASYNCIFY_IGNORE_INDIRECT=1, louder with -sFETCH=1; before 3.19 it runs at the call. That flag is deliberately not an option: it is only correct after an audit of every sleep the app can reach. It is a function, not an INTERFACE --pre-js on cvc::cvcGL. An interface option would reach every static consumer's link: tests, helper tools, and pages that never asked for a page-global WebGL patch. It could not carry -sMALLOC either, because dependency link options come after the target's own and emcc keeps the last -s value. Nothing absolute is exported. Two GLOBAL properties tell the function where the JS is and whether cvcGL is wasm-mt: - in-tree, the source dir and CVC_WASM_PTHREADS; - installed, @PACKAGE_CVCGL_WASM_DATADIR@ (configure_package_config_file PATH_VARS, computed from the installed config's own location) and the -pthread flag the build had. cvcGLConfig.cmake reads that path right after its package init, before any find_dependency: on CMake 3.29 and older every dependency config generated the same way overwrites PACKAGE_PREFIX_DIR with its own prefix, so a later read hands out the dependency prefix's share/cvcGL/wasm whenever cvc or SDL3 lives in another prefix than cvcGL. The JS installs to share/cvcGL/wasm/ and the function to lib/cmake/cvcGL/ on every platform. The cvcgl and cvcgl-cuda recipes pack share/cvcGL/. --- cvcpkg/recipes/cvcgl-cuda/recipe.yaml | 1 + cvcpkg/recipes/cvcgl/recipe.yaml | 4 + src/cvcGL/CMakeLists.txt | 39 ++++ src/cvcGL/cvcGLConfig.cmake.in | 18 ++ src/cvcGL/wasm/cvcGLWasm.cmake | 301 ++++++++++++++++++++++++++ 5 files changed, 363 insertions(+) create mode 100644 src/cvcGL/wasm/cvcGLWasm.cmake diff --git a/cvcpkg/recipes/cvcgl-cuda/recipe.yaml b/cvcpkg/recipes/cvcgl-cuda/recipe.yaml index 71a0a9cb..64f98079 100644 --- a/cvcpkg/recipes/cvcgl-cuda/recipe.yaml +++ b/cvcpkg/recipes/cvcgl-cuda/recipe.yaml @@ -74,6 +74,7 @@ package: - lib/libcvcGL* - lib/cmake/cvcGL/ - include/cvc/gl/ + - share/cvcGL/ cmake_packages: - name: cvcGL targets: diff --git a/cvcpkg/recipes/cvcgl/recipe.yaml b/cvcpkg/recipes/cvcgl/recipe.yaml index 310aaa3d..6e7cc4aa 100644 --- a/cvcpkg/recipes/cvcgl/recipe.yaml +++ b/cvcpkg/recipes/cvcgl/recipe.yaml @@ -153,6 +153,10 @@ package: - lib/cvcGL* - lib/cmake/cvcGL/ - include/cvc/gl/ + # The browser-side half of cvcGL: webgl_state_shadow.js, which cvcgl_wasm_app() (from + # lib/cmake/cvcGL/cvcGLWasm.cmake) links into a wasm app as a --pre-js. Installed on every + # platform so the layout is uniform; only Emscripten consumers use it. + - share/cvcGL/ cmake_packages: - name: cvcGL targets: diff --git a/src/cvcGL/CMakeLists.txt b/src/cvcGL/CMakeLists.txt index 48652364..40cfd0d5 100644 --- a/src/cvcGL/CMakeLists.txt +++ b/src/cvcGL/CMakeLists.txt @@ -188,6 +188,36 @@ if(WIN32) set_target_properties(cvcGL PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON) endif() +# ── WebAssembly app support: cvcgl_wasm_app() ── +# The browser-side half of cvcGL: webgl_state_shadow.js (a --pre-js that answers the GL state +# queries ImGuiOverlay's imgui_impl_opengl3 and VTK make every frame without a synchronous +# GPU-process round-trip) and the CMake function that links it, plus mimalloc, into an app. See +# wasm/cvcGLWasm.cmake and docs/CVCGL_WASM.md. In-tree consumers (examples/) get the function +# from the include below; installed consumers from cvcGLConfig.cmake. Two GLOBAL properties tell +# it where the JS is and whether this cvcGL is -pthread (wasm-mt: Emscripten forbids mixing +# pthread and non-pthread objects, so a consumer of a -pthread libcvcGL.a is -pthread too). +set(CVCGL_WASM_PTHREADS OFF) +if(EMSCRIPTEN) + if(CVC_WASM_PTHREADS) # in-tree (top-level CMakeLists.txt option) + set(CVCGL_WASM_PTHREADS ON) + else() # standalone: whatever made this build -pthread + get_directory_property(_cvcgl_dir_copts COMPILE_OPTIONS) + set(_cvcgl_pt_flags "${_cvcgl_dir_copts};${CMAKE_CXX_FLAGS};${CMAKE_EXE_LINKER_FLAGS}") + foreach(_p INTERFACE_COMPILE_OPTIONS INTERFACE_LINK_OPTIONS) + get_target_property(_v cvc::cvc ${_p}) + if(_v) + string(APPEND _cvcgl_pt_flags ";${_v}") + endif() + endforeach() + if(_cvcgl_pt_flags MATCHES "(^|[; ])-pthread($|[; ])") + set(CVCGL_WASM_PTHREADS ON) + endif() + endif() +endif() +set_property(GLOBAL PROPERTY CVCGL_WASM_DATA_DIR "${CMAKE_CURRENT_SOURCE_DIR}/wasm") +set_property(GLOBAL PROPERTY CVCGL_WASM_PTHREADS ${CVCGL_WASM_PTHREADS}) +include("${CMAKE_CURRENT_SOURCE_DIR}/wasm/cvcGLWasm.cmake") + # ── Install + export a package config so consumers can find_package(cvcGL) ── include(GNUInstallDirs) include(CMakePackageConfigHelpers) @@ -223,10 +253,14 @@ endif() # Substitutes @CVCGL_VTK_COMPONENTS@ (the list cvcGL links) into the config so # find_dependency(VTK COMPONENTS ...) re-finds the same modules for consumers. +# PATH_VARS: @PACKAGE_CVCGL_WASM_DATADIR@ is computed from the INSTALLED config's own location +# (PACKAGE_PREFIX_DIR) when a consumer configures -- relocatable, no builder path baked in. +set(CVCGL_WASM_DATADIR share/cvcGL/wasm) configure_package_config_file( "${CMAKE_CURRENT_SOURCE_DIR}/cvcGLConfig.cmake.in" "${CMAKE_CURRENT_BINARY_DIR}/cvcGLConfig.cmake" INSTALL_DESTINATION lib/cmake/cvcGL + PATH_VARS CVCGL_WASM_DATADIR ) write_basic_package_version_file( "${CMAKE_CURRENT_BINARY_DIR}/cvcGLConfigVersion.cmake" @@ -236,8 +270,13 @@ write_basic_package_version_file( install(FILES "${CMAKE_CURRENT_BINARY_DIR}/cvcGLConfig.cmake" "${CMAKE_CURRENT_BINARY_DIR}/cvcGLConfigVersion.cmake" + "${CMAKE_CURRENT_SOURCE_DIR}/wasm/cvcGLWasm.cmake" DESTINATION lib/cmake/cvcGL ) +# Installed on every platform (the bundle layout stays uniform, and cvcgl_wasm_app is a no-op +# outside Emscripten): the state shim cvcgl_wasm_app() links. +install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/wasm/webgl_state_shadow.js" + DESTINATION ${CVCGL_WASM_DATADIR}) # ── functional smoke test ── enable_testing() diff --git a/src/cvcGL/cvcGLConfig.cmake.in b/src/cvcGL/cvcGLConfig.cmake.in index 186293c8..233c33d0 100644 --- a/src/cvcGL/cvcGLConfig.cmake.in +++ b/src/cvcGL/cvcGLConfig.cmake.in @@ -1,5 +1,11 @@ @PACKAGE_INIT@ +# The WebAssembly JS directory (share/cvcGL/wasm), captured NOW, before any find_dependency: it is +# computed from PACKAGE_PREFIX_DIR, which on CMake <= 3.29 every dependency config generated with +# configure_package_config_file's PACKAGE_INIT (cvc, SDL3, zstd, libxml2, ...) overwrites with ITS +# prefix. Used and unset below. +set(_cvcGL_wasm_data_dir "@PACKAGE_CVCGL_WASM_DATADIR@") + include(CMakeFindDependencyMacro) # cvcGL's public interface links cvc::cvc and the VTK modules below, so both @@ -29,4 +35,16 @@ find_dependency(VTK COMPONENTS @CVCGL_VTK_COMPONENTS@) include("${CMAKE_CURRENT_LIST_DIR}/cvcGLTargets.cmake") +# WebAssembly app support: cvcgl_wasm_app( ...) (cvcGLWasm.cmake, next to this file). +# GLOBAL properties, so the function works whichever directory scope called find_package. The JS +# directory was computed from this file's own location (PACKAGE_PREFIX_DIR, captured at the top), +# so a relocated prefix hands out its own share/cvcGL/wasm. A plain set, not set_and_check: a +# bundle without the JS still passes find_package; cvcgl_wasm_app fails only if asked for the shim +# and the file is missing. +set_property(GLOBAL PROPERTY CVCGL_WASM_DATA_DIR "${_cvcGL_wasm_data_dir}") +unset(_cvcGL_wasm_data_dir) +# Whether this cvcGL was built -pthread (wasm-mt); MIMALLOC AUTO follows it. +set_property(GLOBAL PROPERTY CVCGL_WASM_PTHREADS @CVCGL_WASM_PTHREADS@) +include("${CMAKE_CURRENT_LIST_DIR}/cvcGLWasm.cmake" OPTIONAL) + check_required_components(cvcGL) diff --git a/src/cvcGL/wasm/cvcGLWasm.cmake b/src/cvcGL/wasm/cvcGLWasm.cmake new file mode 100644 index 00000000..335f6b67 --- /dev/null +++ b/src/cvcGL/wasm/cvcGLWasm.cmake @@ -0,0 +1,301 @@ +# cvcGLWasm.cmake -- cvcgl_wasm_app(): opt a cvcGL WebAssembly app into the browser-side speedups. +# +# Included by cvcGLConfig.cmake (installed next to it in lib/cmake/cvcGL/) and, in-tree, by +# src/cvcGL/CMakeLists.txt. One line per app: +# +# cvcgl_wasm_app( +# [STATE_SHIM ON|OFF] # default ON +# [STATE_SHIM_MODE on|verify|norb] # build-time default when the URL / page say nothing; default on +# [MIMALLOC AUTO|ON|OFF] # default AUTO = ON iff cvcGL was built -pthread (wasm-mt) +# [FRAME_YIELD_LOCKED AUTO|ON|OFF]) # default AUTO = ON iff it links -sASYNCIFY_IGNORE_INDIRECT +# +# Outside Emscripten it does nothing, so an app can call it unconditionally. Feature-test it with +# if(COMMAND cvcgl_wasm_app) (and CVCGL_WASM_APP_FEATURES for later keywords). +# +# STATE_SHIM links webgl_state_shadow.js as a --pre-js: GL state queries ImGui's OpenGL3 backend +# (ImGuiOverlay, Ariadne's ImGuiBackend) and VTK make every frame are answered from a +# client-side shadow instead of a synchronous GPU-process round-trip. It patches +# every WebGL context on the page; a page can still turn it off with ?glshim=0. +# STATE_SHIM_MODE the mode when neither the URL (?glshim=) nor the page (Module.glStateShadow) +# chooses one. Not `on`: a generated _glshim_default.js sets +# Module.glStateShadowDefault ahead of the shim. +# MIMALLOC links -sMALLOC=mimalloc. dlmalloc serialises every malloc/free on one global lock, +# which threads contend on; a single-threaded build has no lock to contend on, and +# mimalloc is bigger and uses more memory, so AUTO turns it on for wasm-mt only -- +# and never under -fsanitize=address, which emcc refuses to combine with mimalloc. +# If the target already links -sMALLOC=mimalloc, that counts as ON (nothing added); +# any other -sMALLOC= of its own is kept (a warning says so). One the app adds AFTER +# this call comes later on the link line, and emcc keeps the last -s value, so it +# wins too. +# FRAME_YIELD_LOCKED links a generated _frameyield_lock.js --pre-js that sets +# Module.cvcglFrameYieldLocked = 1: every cvcGL window then keeps FrameYield::App for +# good -- ?frameyield=vtk and setFrameYield(Vtk) are refused, and the watchdog +# reports instead of turning VTK 9.5's in-render emscripten_sleep back on. That sleep +# is reached through a virtual call, so under -sASYNCIFY_IGNORE_INDIRECT=1 it TRAPS. +# AUTO locks exactly when the target's own link line carries that flag -- LINK_OPTIONS, +# LINK_FLAGS[_], -s... items in target_link_libraries, or the global linker flags (checked +# once the calling directory is done, CMake >= 3.19; at the call before that). Pass +# ON when the flag comes from somewhere the check cannot see (a dependency's +# INTERFACE_LINK_OPTIONS, a generator expression). The C++ equivalent is +# SceneRenderer / ViewportManager::lockFrameYield(). +# +# Deliberately NOT options here (docs/CVCGL_WASM.md): +# -sASYNCIFY_IGNORE_INDIRECT -- only safe after an audit of every sleep the app can reach; a +# deferred lint (CMake >= 3.19) warns when a cvcgl_wasm_app target links it (and AUTO locks +# FrameYield::App, above). +# FrameYield (VTK's in-render emscripten_sleep off) -- a C++ setting next to the app's own loop: +# cvc::gl::SceneRenderer / ViewportManager::setFrameYield(FrameYield::App). FRAME_YIELD_LOCKED +# only makes App permanent; it does not switch the loop's contract on. +# +# Where the JS lives: the GLOBAL property CVCGL_WASM_DATA_DIR (src/cvcGL/wasm in-tree, +# /share/cvcGL/wasm installed -- resolved from the installed config's own location when the +# consumer configures, so no absolute path is baked into the package). Whether cvcGL was built +# -pthread: the GLOBAL property CVCGL_WASM_PTHREADS. + +# Keywords cvcgl_wasm_app understands, for consumers that need a newer one: +# if("MIMALLOC" IN_LIST CVCGL_WASM_APP_FEATURES) +# Set on every include, ahead of the guard: it is a directory-scoped variable, so a second +# find_package(cvcGL) from a sibling directory must see it too (the functions are global). +set(CVCGL_WASM_APP_FEATURES STATE_SHIM STATE_SHIM_MODE MIMALLOC FRAME_YIELD_LOCKED) +include_guard(GLOBAL) + +function(cvcgl_wasm_app target) + if(NOT EMSCRIPTEN) + return() + endif() + if(NOT TARGET ${target}) + message(FATAL_ERROR "cvcgl_wasm_app: '${target}' is not a target") + endif() + cmake_parse_arguments(PARSE_ARGV 1 _cwa "" + "STATE_SHIM;STATE_SHIM_MODE;MIMALLOC;FRAME_YIELD_LOCKED" "") + if(_cwa_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "cvcgl_wasm_app(${target}): unknown arguments: ${_cwa_UNPARSED_ARGUMENTS}") + endif() + foreach(_k IN LISTS _cwa_KEYWORDS_MISSING_VALUES) + message(FATAL_ERROR "cvcgl_wasm_app(${target}): ${_k} needs a value") + endforeach() + + # STATE_SHIM: any CMake boolean, default ON. + set(_shim ON) + if(DEFINED _cwa_STATE_SHIM) + if(_cwa_STATE_SHIM) + set(_shim ON) + else() + set(_shim OFF) + endif() + endif() + set(_mode on) + if(DEFINED _cwa_STATE_SHIM_MODE) + string(TOLOWER "${_cwa_STATE_SHIM_MODE}" _mode) + if(NOT _mode MATCHES "^(on|verify|norb)$") + message(FATAL_ERROR "cvcgl_wasm_app(${target}): STATE_SHIM_MODE must be on, verify or norb " + "(got '${_cwa_STATE_SHIM_MODE}'; use STATE_SHIM OFF to leave the shim out)") + endif() + endif() + set(_malloc AUTO) + if(DEFINED _cwa_MIMALLOC) + string(TOUPPER "${_cwa_MIMALLOC}" _malloc) + if(NOT _malloc STREQUAL "AUTO") + if(_cwa_MIMALLOC) + set(_malloc ON) + else() + set(_malloc OFF) + endif() + endif() + endif() + # FRAME_YIELD_LOCKED: AUTO (resolved against the final link options) or any CMake boolean. + set(_lock AUTO) + if(DEFINED _cwa_FRAME_YIELD_LOCKED) + string(TOUPPER "${_cwa_FRAME_YIELD_LOCKED}" _lock) + if(NOT _lock STREQUAL "AUTO") + if(_cwa_FRAME_YIELD_LOCKED) + set(_lock ON) + else() + set(_lock OFF) + endif() + endif() + endif() + + # The flags this target is compiled and linked with that this call can see: the + # target's own and the global ones for the build type. Not the directory's: a target's + # LINK_OPTIONS / COMPILE_OPTIONS start as its directory's when it is created, and a + # directory option added later (or the calling directory's) is not on its link line. + string(TOUPPER "${CMAKE_BUILD_TYPE}" _cfg) + _cvcgl_wasm_app_target_flags(${target} _flags) + get_target_property(_v ${target} COMPILE_OPTIONS) + if(_v) + string(APPEND _flags ";${_v}") + endif() + foreach(_v CMAKE_C_FLAGS CMAKE_CXX_FLAGS CMAKE_EXE_LINKER_FLAGS) + string(APPEND _flags " ${${_v}} ${${_v}_${_cfg}}") + endforeach() + set(_asan OFF) + if(_flags MATCHES "(^|[; ])-fsanitize=[^ ;]*address") + set(_asan ON) + endif() + + if(_malloc STREQUAL "AUTO") + get_property(_pthreads GLOBAL PROPERTY CVCGL_WASM_PTHREADS) + if(_pthreads AND _asan) + message(STATUS "cvcgl_wasm_app(${target}): MIMALLOC AUTO -> OFF: built with " + "-fsanitize=address, which emcc refuses to combine with mimalloc") + set(_malloc OFF) + elseif(_pthreads) + set(_malloc ON) + else() + set(_malloc OFF) + endif() + elseif(_malloc AND _asan) + message(WARNING "cvcgl_wasm_app(${target}): MIMALLOC ON with -fsanitize=address -- emcc " + "refuses mimalloc under ASan and will stop at the link. Pass MIMALLOC AUTO " + "(OFF under ASan) or OFF.") + endif() + + if(_shim) + get_property(_dir GLOBAL PROPERTY CVCGL_WASM_DATA_DIR) + set(_js "${_dir}/webgl_state_shadow.js") + if(NOT _dir OR NOT EXISTS "${_js}") + message(FATAL_ERROR + "cvcgl_wasm_app(${target}): this cvcGL has no webgl_state_shadow.js (looked in " + "'${_dir}'). Install a cvcGL that ships share/cvcGL/wasm/, or pass STATE_SHIM OFF.") + endif() + if(NOT _mode STREQUAL "on") + # Read by the shim when neither the URL nor the page chose a mode. Generated, so it lives in + # the consumer's build tree; file(GENERATE) only rewrites it when the content changes. + set(_def "${CMAKE_CURRENT_BINARY_DIR}/${target}_glshim_default.js") + file(GENERATE OUTPUT "${_def}" CONTENT + "// Generated by cvcgl_wasm_app(${target} STATE_SHIM_MODE ${_mode}): the build-time default\n// mode of webgl_state_shadow.js. ?glshim= and Module.glStateShadow still win.\nModule['glStateShadowDefault'] = '${_mode}';\n") + target_link_options(${target} PRIVATE "SHELL:--pre-js \"${_def}\"") + set_property(TARGET ${target} APPEND PROPERTY LINK_DEPENDS "${_def}") + endif() + target_link_options(${target} PRIVATE "SHELL:--pre-js \"${_js}\"") + set_property(TARGET ${target} APPEND PROPERTY LINK_DEPENDS "${_js}") + endif() + + # An allocator the app already chose (target or global flags) is kept. mimalloc already + # there is simply ON: nothing to add, nothing to warn about. + if(_flags MATCHES "(^|[; :])-s[ ]*MALLOC=([A-Za-z0-9_-]*)") + set(_theirs "${CMAKE_MATCH_2}") + if(_theirs STREQUAL "mimalloc") + set(_malloc ON) + else() + if(_malloc) + message(WARNING "cvcgl_wasm_app(${target}): the target already links -sMALLOC=${_theirs}; " + "keeping it instead of mimalloc (pass MIMALLOC OFF to silence this)") + endif() + set(_malloc OFF) + endif() + elseif(_malloc) + target_link_options(${target} PRIVATE "-sMALLOC=mimalloc") + endif() + + set_target_properties(${target} PROPERTIES + CVCGL_WASM_APP ON + CVCGL_WASM_APP_STATE_SHIM ${_shim} + CVCGL_WASM_APP_STATE_SHIM_MODE ${_mode} + CVCGL_WASM_APP_MIMALLOC ${_malloc} + CVCGL_WASM_APP_FRAME_YIELD_LOCKED ${_lock}) # AUTO until the deferred check resolves it + if(_lock STREQUAL "ON") + _cvcgl_wasm_app_lock_frame_yield(${target} "FRAME_YIELD_LOCKED ON") + endif() + + # Check the FINAL link options once the calling directory is done (the app may add flags after + # this call): -sASYNCIFY_IGNORE_INDIRECT is app-specific and easy to get wrong, and it decides + # FRAME_YIELD_LOCKED AUTO. Before CMake 3.19 there is no DEFER: check what is there now. + if(NOT CMAKE_VERSION VERSION_LESS 3.19) + cmake_language(EVAL CODE "cmake_language(DEFER CALL _cvcgl_wasm_app_finish [[${target}]])") + else() + _cvcgl_wasm_app_finish(${target}) + endif() +endfunction() + +# FrameYield::App for good on every cvcGL window of : a generated --pre-js the +# ViewportManager reads once per window (Module.cvcglFrameYieldLocked). +function(_cvcgl_wasm_app_lock_frame_yield target why) + get_target_property(_done ${target} CVCGL_WASM_APP_FRAME_YIELD_LOCK_JS) + if(_done) + return() + endif() + set(_f "${CMAKE_CURRENT_BINARY_DIR}/${target}_frameyield_lock.js") + file(GENERATE OUTPUT "${_f}" CONTENT + "// Generated by cvcgl_wasm_app(${target}): ${why}.\n// Every cvcGL window keeps FrameYield::App: ?frameyield=vtk, setFrameYield(Vtk) and the watchdog\n// cannot turn VTK 9.5's in-render emscripten_sleep -- reached through a virtual call, so a trap\n// under -sASYNCIFY_IGNORE_INDIRECT=1 -- back on (cvc/gl/ViewportManager.h, lockFrameYield).\nModule['cvcglFrameYieldLocked'] = 1;\n") + target_link_options(${target} PRIVATE "SHELL:--pre-js \"${_f}\"") + set_property(TARGET ${target} APPEND PROPERTY LINK_DEPENDS "${_f}") + set_target_properties(${target} PROPERTIES + CVCGL_WASM_APP_FRAME_YIELD_LOCKED ON + CVCGL_WASM_APP_FRAME_YIELD_LOCK_JS "${_f}") +endfunction() + +# The link flags on ${target}'s own link line: LINK_OPTIONS, LINK_FLAGS[_], and the +# -s... items given to target_link_libraries (a common emscripten idiom). +function(_cvcgl_wasm_app_target_flags target out) + string(TOUPPER "${CMAKE_BUILD_TYPE}" _cfg) + set(_r "") + foreach(_p LINK_OPTIONS LINK_FLAGS LINK_FLAGS_${_cfg}) + get_target_property(_v ${target} ${_p}) + if(_v) + string(APPEND _r ";${_v}") + endif() + endforeach() + get_target_property(_v ${target} LINK_LIBRARIES) + if(_v) + foreach(_i IN LISTS _v) + if(_i MATCHES "^-s") + string(APPEND _r ";${_i}") + endif() + endforeach() + endif() + set(${out} "${_r}" PARENT_SCOPE) +endfunction() + +function(_cvcgl_wasm_app_finish target) + string(TOUPPER "${CMAKE_BUILD_TYPE}" _cfg) + _cvcgl_wasm_app_target_flags(${target} _lo) + # Whole tokens only: -sASYNCIFY_IGNORE_INDIRECT[=1] (also "-s X" and SHELL:), not =0. + set(_all "${_lo};${CMAKE_EXE_LINKER_FLAGS} ${CMAKE_EXE_LINKER_FLAGS_${_cfg}}") + set(_ii OFF) + if(_all MATCHES "(^|[; :])-s[ ]*ASYNCIFY_IGNORE_INDIRECT(=1)?($|[; ])") + set(_ii ON) + endif() + get_target_property(_lock ${target} CVCGL_WASM_APP_FRAME_YIELD_LOCKED) + if(_lock STREQUAL "AUTO") + if(_ii) + _cvcgl_wasm_app_lock_frame_yield(${target} + "it links -sASYNCIFY_IGNORE_INDIRECT=1 (FRAME_YIELD_LOCKED AUTO)") + set(_lock ON) + else() + set_property(TARGET ${target} PROPERTY CVCGL_WASM_APP_FRAME_YIELD_LOCKED OFF) + set(_lock OFF) + endif() + endif() + if(NOT _ii) + return() + endif() + if(_lock) + string(CONCAT _lock_note + "FrameYield::App is locked for it (Module.cvcglFrameYieldLocked, FRAME_YIELD_LOCKED), so " + "neither ?frameyield=vtk nor the watchdog can bring that yield back; the app's loop must " + "still call emscripten_sleep(0) once per frame on every path that renders.") + else() + string(CONCAT _lock_note + "FRAME_YIELD_LOCKED is OFF, so ?frameyield=vtk or a watchdog trip turns that yield back on " + "and the app TRAPS, unless its C++ calls lockFrameYield() on every window: prefer " + "FRAME_YIELD_LOCKED AUTO or ON.") + endif() + message(WARNING + "cvcgl_wasm_app(${target}): links -sASYNCIFY_IGNORE_INDIRECT=1. That is only correct while " + "every emscripten_sleep (and every other async import) the app can reach on the main thread is " + "reached through DIRECT calls -- a sleep reached through a virtual call, function pointer or " + "std::function traps at its unwind. Known indirect sleepers in a cvcGL app: VTK 9.5's " + "in-render yield (reached through the virtual Render(); off only in FrameYield::App). " + "${_lock_note} Also cvc::net's fetch (Ariadne http verbs) and anything an ImGuiOverlay draw " + "callback runs. Work through the checklist in docs/CVCGL_WASM.md " + "(\"-sASYNCIFY_IGNORE_INDIRECT\") before shipping.") + if(_all MATCHES "(^|[; :])-s[ ]*FETCH(=1)?($|[; ])") + message(WARNING + "cvcgl_wasm_app(${target}): -sASYNCIFY_IGNORE_INDIRECT=1 TOGETHER WITH -sFETCH=1. cvc::net's " + "blocking fetch spins on emscripten_sleep and is reached from Ariadne's http verbs through " + "std::function intrinsics -- an indirect call -- so any http(s):// load in this app will trap " + "unless you have proven fetch unreachable (docs/CVCGL_WASM.md, checklist item 2).") + endif() +endfunction() From e144bb6b0dec2b9719b68bc5f7bdc211c09a1a1f Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 2 Oct 2026 00:23:37 -0500 Subject: [PATCH 4/8] cvcGL examples: every wasm demo is a cvcgl_wasm_app with FrameYield::App All 9 gallery demos (_wasm_demos) now call cvcgl_wasm_app() inside the existing link-flags foreach. Each links the WebGL state shim as a --pre-js, and mimalloc as well on the threaded (wasm-mt) build. Each also calls view.setFrameYield(SceneRenderer::FrameYield::App), because each loop yields once per frame (emscripten_sleep(0)) on every path that renders. That takes VTK 9.5's second, in-render yield off each frame. The call is a no-op natively. nav_fog_ghost was the one exception. Its paused path, if (uiPaused) { view.render(); continue; }, skipped the loop's only yield. It had only ever worked because VTK yielded inside render(); in App mode, pausing would freeze the page. That path now flushes the publisher (non-pthread builds) and yields before its continue. ariadne_hello becomes browser-capable as the first wasm Ariadne app. On Emscripten its loop yields (flush + emscripten_sleep(0)) instead of sleep_for, and it gets the same link flags plus cvcgl_wasm_app, so the state shim answers the queries its ImGuiBackend -> ImGuiOverlay draw makes. It sets FrameYield::App right before the interactive loop only: its --png / --offscreen / --frames capture branch renders back to back without yielding, so it keeps VTK's in-render yield (and cannot trip the watchdog). It is a _wasm_extra_demo: it builds only on request (wasm-extra-demos, or by name) into bin/extra/, which build-pages.py does not scan, so the gallery is unchanged. nav_convoy and nav_compute load .ari documents from disk and stay native-only. --- src/cvcGL/examples/CMakeLists.txt | 22 +++++++++++++++++++++- src/cvcGL/examples/ariadne_hello.cpp | 15 +++++++++++++++ src/cvcGL/examples/bunny_shadow.cpp | 4 ++++ src/cvcGL/examples/lsystem_coast.cpp | 4 ++++ src/cvcGL/examples/lsystem_forest.cpp | 4 ++++ src/cvcGL/examples/nav_city_drive.cpp | 4 ++++ src/cvcGL/examples/nav_city_swarm.cpp | 4 ++++ src/cvcGL/examples/nav_fog_ghost.cpp | 12 ++++++++++++ src/cvcGL/examples/terrain_lab.cpp | 4 ++++ src/cvcGL/examples/volren_bunny.cpp | 4 ++++ src/cvcGL/examples/volslice_bunny.cpp | 4 ++++ 11 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/cvcGL/examples/CMakeLists.txt b/src/cvcGL/examples/CMakeLists.txt index e3b75aa7..4cf927fb 100644 --- a/src/cvcGL/examples/CMakeLists.txt +++ b/src/cvcGL/examples/CMakeLists.txt @@ -102,8 +102,19 @@ endif() # imagemagick + a runtime bundle the wasm toolchain lacks). Add a new browser demo # to _wasm_demos and it builds via the `wasm-demos` target and is auto-discovered # into the gallery by wasm/build-pages.py — no host page to hand-write. +# +# _wasm_extra_demos are browser-capable too but stay OUT of the gallery: they build +# only on request (`--target wasm-extra-demos`, or by name) into bin/extra/, which +# build-pages.py does not scan. ariadne_hello is the Ariadne app there (its .ari +# siblings nav_convoy / nav_compute load documents from disk and stay native-only). +# +# Every one of them is a cvcgl_wasm_app (src/cvcGL/wasm/cvcGLWasm.cmake): the WebGL +# state shim as a --pre-js, plus mimalloc on the threaded (wasm-mt) build. Each also +# calls SceneRenderer::setFrameYield(FrameYield::App) in its C++, because its loop +# yields once per frame on every path that renders. if(EMSCRIPTEN) set(_wasm_demos lsystem_forest lsystem_coast terrain_lab bunny_shadow volren_bunny volslice_bunny nav_city_swarm nav_city_drive nav_fog_ghost) + set(_wasm_extra_demos ariadne_hello) # Optional runtime asset bundle for nav_city_swarm (Austin: terrain.json + # buildings.glb + satellite.png). When CVC_WASM_BUNDLE is set to a scene dir, # its contents are preloaded to /bundle and, if the sibling shared/Humvee.glb @@ -129,7 +140,7 @@ if(EMSCRIPTEN) message(STATUS "cvcGL nav_city_swarm wasm: preloading trained weights from ${CVC_WASM_NAV_WEIGHTS}") list(APPEND _nav_swarm_bundle_flags "--preload-file=${CVC_WASM_NAV_WEIGHTS}@/coef_sdf.cvcnav") endif() - foreach(_wt ${_wasm_demos}) + foreach(_wt ${_wasm_demos} ${_wasm_extra_demos}) # Asyncify: each demo keeps its own while-loop and yields via emscripten_sleep. # Growable heap: the forest/sea/sky volumes and city scenes exceed the 16 MB default. # VTK's patched FindOpenGL injects the WebGL2/GLES3 flags; RenderingUI its js-library. @@ -158,10 +169,19 @@ if(EMSCRIPTEN) if(_nav_swarm_bundle_flags AND ("${_wt}" STREQUAL "nav_city_swarm" OR "${_wt}" STREQUAL "nav_city_drive")) target_link_options(${_wt} PRIVATE ${_nav_swarm_bundle_flags}) endif() + # The browser-side speedups: state shim (--pre-js) + mimalloc on wasm-mt. + cvcgl_wasm_app(${_wt}) endforeach() + if(CMAKE_RUNTIME_OUTPUT_DIRECTORY) + set(_wasm_extra_dir "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/extra") + else() + set(_wasm_extra_dir "${CMAKE_CURRENT_BINARY_DIR}/extra") + endif() + set_target_properties(${_wasm_extra_demos} PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${_wasm_extra_dir}") # One target builds them all; wasm/build-wasm-demo.sh drives it, then build-pages.py # turns the built bin/ into the servable gallery (per-demo page + index of cards). add_custom_target(wasm-demos DEPENDS ${_wasm_demos}) + add_custom_target(wasm-extra-demos DEPENDS ${_wasm_extra_demos}) endif() # Install rules so the cvcgl-examples recipe packages the example binaries with no diff --git a/src/cvcGL/examples/ariadne_hello.cpp b/src/cvcGL/examples/ariadne_hello.cpp index 66822214..d9c5a4b1 100644 --- a/src/cvcGL/examples/ariadne_hello.cpp +++ b/src/cvcGL/examples/ariadne_hello.cpp @@ -36,6 +36,9 @@ #include #include #include // a scene background: gradient reaches the renderer directly (§16.1) +#ifdef __EMSCRIPTEN__ +#include +#endif using cvc::gl::CameraController; using cvc::gl::ImGuiBackend; @@ -316,10 +319,22 @@ int main(int argc, char **argv) { png.c_str()); } else { std::puts("[ariadne_hello] running — close the window or use Sim > Quit to exit."); + // This loop yields to the browser once per frame in the WebAssembly build (emscripten_sleep(0) + // at its end), so VTK need not yield again inside every render (FrameYield::App; a no-op + // natively). Only here: the capture branch above renders back to back without yielding, so it + // keeps VTK's in-render yield. + view.setFrameYield(SceneRenderer::FrameYield::App); while (!view.windowClosed() && !quit) { frame_body(1.0 / 120.0); view.render(); // draws the scene + the Ariadne overlay (runs rt.render()) +#ifdef __EMSCRIPTEN__ +#ifndef __EMSCRIPTEN_PTHREADS__ + sg.publisher().flush(); // no worker thread — drain publishes at frame cadence +#endif + emscripten_sleep(0); // yield to the browser event loop once per frame (Asyncify) +#else std::this_thread::sleep_for(std::chrono::milliseconds(8)); // ~120 Hz cap +#endif } } cam.detach(); diff --git a/src/cvcGL/examples/bunny_shadow.cpp b/src/cvcGL/examples/bunny_shadow.cpp index 524ec957..ab6ab747 100644 --- a/src/cvcGL/examples/bunny_shadow.cpp +++ b/src/cvcGL/examples/bunny_shadow.cpp @@ -186,6 +186,10 @@ int main(int argc, char **argv) { rig.apply(); SceneRenderer view(sg, width, height, offscreen, "main"); + // The loop yields to the browser once per frame (emscripten_sleep(0) at its end), so VTK + // need not yield again inside every render (FrameYield::App: VTK 9.5's in-render + // emscripten_sleep off; a no-op natively). + view.setFrameYield(SceneRenderer::FrameYield::App); const bool shadows = !no_shadows && sg.setShadowsEnabled(true); if (shadows) { sg.setShadowResolution(2048); diff --git a/src/cvcGL/examples/lsystem_coast.cpp b/src/cvcGL/examples/lsystem_coast.cpp index a18ad66e..48fc972c 100644 --- a/src/cvcGL/examples/lsystem_coast.cpp +++ b/src/cvcGL/examples/lsystem_coast.cpp @@ -1281,6 +1281,10 @@ int main(int argc, char **argv) { rig.setWarmth(0.45); SceneRenderer view(sg, width, height, offscreen, "main"); + // The loop yields to the browser once per frame (emscripten_sleep(0) at its end), so VTK + // need not yield again inside every render (FrameYield::App: VTK 9.5's in-render + // emscripten_sleep off; a no-op natively). + view.setFrameYield(SceneRenderer::FrameYield::App); // A real sky, not a flat void: a vertical gradient background (hazy horizon at // the bottom, deep blue at the zenith). Done on the renderer rather than as a // sky sphere on purpose — an enclosing sky sphere would occlude the directional diff --git a/src/cvcGL/examples/lsystem_forest.cpp b/src/cvcGL/examples/lsystem_forest.cpp index 0b1868c4..cc515352 100644 --- a/src/cvcGL/examples/lsystem_forest.cpp +++ b/src/cvcGL/examples/lsystem_forest.cpp @@ -1221,6 +1221,10 @@ int main(int argc, char **argv) { rig.setWarmth(0.45); SceneRenderer view(sg, width, height, offscreen, "main"); + // The loop yields to the browser once per frame (emscripten_sleep(0) at its end), so VTK + // need not yield again inside every render (FrameYield::App: VTK 9.5's in-render + // emscripten_sleep off; a no-op natively). + view.setFrameYield(SceneRenderer::FrameYield::App); // A real sky, not a flat void: a vertical gradient background (hazy horizon at // the bottom, deep blue at the zenith). Done on the renderer rather than as a // sky sphere on purpose — an enclosing sky sphere would occlude the directional diff --git a/src/cvcGL/examples/nav_city_drive.cpp b/src/cvcGL/examples/nav_city_drive.cpp index 5ab3602c..42bea403 100644 --- a/src/cvcGL/examples/nav_city_drive.cpp +++ b/src/cvcGL/examples/nav_city_drive.cpp @@ -1424,6 +1424,10 @@ int main(int argc, char **argv) { auto rig = navdemo::make_stage_rig(sg, bounds, wall_h); SceneRenderer view(sg, width, height, offscreen, "main"); + // The loop yields to the browser once per frame (emscripten_sleep(0) at its end), so VTK + // need not yield again inside every render (FrameYield::App: VTK 9.5's in-render + // emscripten_sleep off; a no-op natively). + view.setFrameYield(SceneRenderer::FrameYield::App); // Shadows must be enabled AFTER the renderer exists (they attach to its passes). bool shadows = !no_shadows && sg.setShadowsEnabled(true); if (shadows) { diff --git a/src/cvcGL/examples/nav_city_swarm.cpp b/src/cvcGL/examples/nav_city_swarm.cpp index 1aef0b0e..40cb5232 100644 --- a/src/cvcGL/examples/nav_city_swarm.cpp +++ b/src/cvcGL/examples/nav_city_swarm.cpp @@ -1433,6 +1433,10 @@ int main(int argc, char **argv) { auto rig = navdemo::make_stage_rig(sg, bounds, wall_h); SceneRenderer view(sg, width, height, offscreen, "main"); + // The loop yields to the browser once per frame (emscripten_sleep(0) at its end), so VTK + // need not yield again inside every render (FrameYield::App: VTK 9.5's in-render + // emscripten_sleep off; a no-op natively). + view.setFrameYield(SceneRenderer::FrameYield::App); // Shadows must be enabled AFTER the renderer exists (they attach to its passes). bool shadows = !no_shadows && sg.setShadowsEnabled(true); if (shadows) { diff --git a/src/cvcGL/examples/nav_fog_ghost.cpp b/src/cvcGL/examples/nav_fog_ghost.cpp index 8768f586..131ae4e9 100644 --- a/src/cvcGL/examples/nav_fog_ghost.cpp +++ b/src/cvcGL/examples/nav_fog_ghost.cpp @@ -503,6 +503,10 @@ int main(int argc, char **argv) { sg.addDirectionalLight(150, 34, 0.5, 0.58, 0.72, 0.45); SceneRenderer view(sg, width, height, offscreen, "main"); + // The loop yields to the browser once per frame on both paths that render (the paused one + // and the frame end), so VTK need not yield again inside every render (FrameYield::App: + // VTK 9.5's in-render emscripten_sleep off; a no-op natively). + view.setFrameYield(SceneRenderer::FrameYield::App); const bool shadows = !no_shadows && sg.setShadowsEnabled(true); if (shadows) { sg.setShadowResolution(1024); @@ -726,6 +730,14 @@ int main(int argc, char **argv) { } if (uiPaused) { view.render(); +#ifdef __EMSCRIPTEN__ + // This path renders too, so it yields too: FrameYield::App (above) took VTK's in-render + // yield away, and a `continue` past the frame end's yield would freeze the paused page. +#ifndef __EMSCRIPTEN_PTHREADS__ + sg.publisher().flush(); // no worker thread — drain publishes at frame cadence +#endif + emscripten_sleep(0); +#endif continue; // frozen world, live camera + UI } #endif diff --git a/src/cvcGL/examples/terrain_lab.cpp b/src/cvcGL/examples/terrain_lab.cpp index 0f79af10..78e0fcf0 100644 --- a/src/cvcGL/examples/terrain_lab.cpp +++ b/src/cvcGL/examples/terrain_lab.cpp @@ -1236,6 +1236,10 @@ int main(int argc, char **argv) { rig.setWarmth(0.4); SceneRenderer view(sg, width, height, offscreen, "main"); + // The loop yields to the browser once per frame (emscripten_sleep(0) at its end), so VTK + // need not yield again inside every render (FrameYield::App: VTK 9.5's in-render + // emscripten_sleep off; a no-op natively). + view.setFrameYield(SceneRenderer::FrameYield::App); view.renderer()->GradientBackgroundOn(); view.renderer()->SetBackground(0.66, 0.71, 0.74); view.renderer()->SetBackground2(0.23, 0.44, 0.80); diff --git a/src/cvcGL/examples/volren_bunny.cpp b/src/cvcGL/examples/volren_bunny.cpp index 0ffd93d3..18388a6c 100644 --- a/src/cvcGL/examples/volren_bunny.cpp +++ b/src/cvcGL/examples/volren_bunny.cpp @@ -533,6 +533,10 @@ int main(int argc, char **argv) { int wantRig = -1; SceneRenderer view(sg, width, height, capturing || offscreen, "main"); + // The loop yields to the browser once per frame (emscripten_sleep(0) at its end), so VTK + // need not yield again inside every render (FrameYield::App: VTK 9.5's in-render + // emscripten_sleep off; a no-op natively). + view.setFrameYield(SceneRenderer::FrameYield::App); const bool shadows = !no_shadows && sg.setShadowsEnabled(true); if (shadows) { sg.setShadowResolution(2048); diff --git a/src/cvcGL/examples/volslice_bunny.cpp b/src/cvcGL/examples/volslice_bunny.cpp index f6e770de..bf656950 100644 --- a/src/cvcGL/examples/volslice_bunny.cpp +++ b/src/cvcGL/examples/volslice_bunny.cpp @@ -260,6 +260,10 @@ int main(int argc, char **argv) { rig.apply(); SceneRenderer view(sg, width, height, capturing, "main"); + // The loop yields to the browser once per frame (emscripten_sleep(0) at its end), so VTK + // need not yield again inside every render (FrameYield::App: VTK 9.5's in-render + // emscripten_sleep off; a no-op natively). + view.setFrameYield(SceneRenderer::FrameYield::App); const bool shadows = !no_shadows && sg.setShadowsEnabled(true); if (shadows) { sg.setShadowResolution(2048); From d9625db5ee52717cdb7e470930ea068fa2f55365 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 2 Oct 2026 00:23:37 -0500 Subject: [PATCH 5/8] cvcGL wasm devtools: the glsync census, and serve.py --glsync glsync.js is a Firefox census of synchronous WebGL calls, the tool that shows whether the state shim removes what it should. It wraps every WebGL2RenderingContext method, sorts each call by what Firefox 156 does with it (a synchronous round trip to the GPU process, answered in the content process, queued, or flushed), and models Firefox's async-present flush budget. Once per report window it prints one GLSYNC line. Without ?glsync in the URL it does nothing but that one check. It lives in src/cvcGL/wasm/devtools/ with GLSYNC.md, its node test and a bench, as a developer tool, not part of the SDK install. - A frame clock option, clock=auto|prof|raf. The cvcGL gallery page sends Module.print to a DOM node and the gallery demos print no frame-timing lines, so the census cannot report only after "PROF n=" console lines. raf reports every every=N paints (default 60) of a drawn canvas; f is the number of JS tasks that drew to the canvas, exactly one per frame with FrameYield::App. prof reports after each PROF line, for an app that prints them. auto, the default, uses raf until the first PROF line and prof after it. - The census adds ?prof to the page URL only on request: with ?glsync=prof, or when the server injects window.__glsyncProfParam. A URL that already has prof, profhud or glcount (other switches with which an app may already print PROF lines), or the requested name, is left alone. - The header and GLSYNC.md describe frames and tasks in terms of FrameYield. src/cvcGL/examples/wasm/serve.py gains the injection, behind flags that are all off by default, so the cvcgl-examples-web launcher is unchanged: - --glsync[=PATH] inserts the census before the first + ' +FIRST_SCRIPT = re.compile(rb" bytes: + """Insert the census tag (after the optional __glsyncProfParam script) before the first + \n' + tags + m = FIRST_SCRIPT.search(html) + if m is None: + return html + tags + return html[: m.start()] + tags + html[m.start():] class IsolatedHandler(SimpleHTTPRequestHandler): + # Set per server by make_handler(); the class defaults are the plain isolated server. + glsync_js = None # Path to serve at /glsync.js and inject into index.html, or None + prof_param = None # name for window.__glsyncProfParam, or None + js_profiling = False # send Document-Policy: js-profiling + def end_headers(self): self.send_header("Cross-Origin-Opener-Policy", "same-origin") self.send_header("Cross-Origin-Embedder-Policy", "require-corp") + if self.js_profiling: + self.send_header("Document-Policy", "js-profiling") # wasm/js must never be served stale while iterating on builds self.send_header("Cache-Control", "no-cache") super().end_headers() + def send_head(self): + if self.glsync_js is None: + return super().send_head() + url_path = unquote(urlsplit(self.path).path) + if url_path == GLSYNC_URL: + try: + body = Path(self.glsync_js).read_bytes() + except OSError: + self.send_error(HTTPStatus.NOT_FOUND, "glsync.js not found") + return None + return self._send_bytes(body, "text/javascript; charset=utf-8") + fs_path = self.translate_path(self.path) + if os.path.isdir(fs_path): + if not url_path.endswith("/"): + return super().send_head() # the usual 301 to the trailing-slash URL + fs_path = os.path.join(fs_path, "index.html") + elif os.path.basename(fs_path) != "index.html": + return super().send_head() + if not os.path.isfile(fs_path): + return super().send_head() # index.htm, directory listing or 404, as before + try: + html = Path(fs_path).read_bytes() + except OSError: + self.send_error(HTTPStatus.NOT_FOUND, "File not found") + return None + return self._send_bytes(inject_glsync(html, self.prof_param), "text/html; charset=utf-8") -def main(): - ap = argparse.ArgumentParser(description=__doc__) + def _send_bytes(self, body: bytes, content_type: str): + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + return io.BytesIO(body) + + +def make_handler(directory, glsync_js=None, prof_param=None, js_profiling=False, + base=IsolatedHandler): + """A handler class serving `directory` with the given profiling options.""" + if prof_param is not None and not PROF_PARAM.match(prof_param): + raise ValueError(f"--prof-param must match {PROF_PARAM.pattern}: {prof_param!r}") + if prof_param is not None and glsync_js is None: + raise ValueError("--prof-param needs --glsync") + cls = type("Handler", (base,), { + "glsync_js": None if glsync_js is None else Path(glsync_js), + "prof_param": prof_param, + "js_profiling": bool(js_profiling), + }) + return functools.partial(cls, directory=str(directory)) + + +def parse_args(argv=None): + """Parse the command line: (namespace, glsync Path or None). Exits on a bad combination.""" + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("-d", "--directory", default=".", help="directory to serve") - ap.add_argument("port", nargs="?", type=int, default=8811) - args = ap.parse_args() + ap.add_argument("--glsync", nargs="?", const="", default=None, metavar="PATH", + help="inject the WebGL sync census (default: the source-tree glsync.js)") + ap.add_argument("--prof-param", default=None, metavar="NAME", + help="have the census add ?NAME to the page URL (needs --glsync)") + ap.add_argument("--js-profiling", action="store_true", + help="send Document-Policy: js-profiling (Chromium JS Self-Profiling API)") + ap.add_argument("port", nargs="?", type=int, default=None, help="port (default 8811)") + args = ap.parse_args(argv) + + # `--glsync 8822`: argparse hands the port to --glsync's optional PATH. A bare number that is + # not a file is the port. + if args.glsync and args.glsync.isdigit() and not Path(args.glsync).exists(): + if args.port is not None: + ap.error(f"--glsync {args.glsync}: no such file (use --glsync=PATH)") + args.port, args.glsync = int(args.glsync), "" + if args.port is None: + args.port = 8811 + + glsync = None + if args.glsync is not None: + glsync = Path(args.glsync) if args.glsync else default_glsync() + if glsync is None or not glsync.is_file(): + ap.error("--glsync: glsync.js not found (looked at " + + (str(glsync) if args.glsync else ", ".join(map(str, GLSYNC_CANDIDATES))) + + "); pass --glsync=PATH") + if args.prof_param is not None and glsync is None: + ap.error("--prof-param needs --glsync") + if args.prof_param is not None and not PROF_PARAM.match(args.prof_param): + ap.error(f"--prof-param must match {PROF_PARAM.pattern}: {args.prof_param!r}") + return args, glsync + - handler = functools.partial(IsolatedHandler, directory=args.directory) +def main(argv=None): + args, glsync = parse_args(argv) + handler = make_handler(args.directory, glsync, args.prof_param, args.js_profiling) + extras = [] + if glsync is not None: + extras.append(f"?glsync census from {glsync}") + if args.prof_param: + extras.append(f"census adds ?{args.prof_param}") + if args.js_profiling: + extras.append("js-profiling") with ThreadingHTTPServer(("", args.port), handler) as httpd: print(f"Serving {args.directory} at http://localhost:{args.port} " - "(cross-origin isolated)") + "(cross-origin isolated" + "".join("; " + e for e in extras) + ")") + sys.stdout.flush() httpd.serve_forever() diff --git a/src/cvcGL/wasm/devtools/GLSYNC.md b/src/cvcGL/wasm/devtools/GLSYNC.md new file mode 100644 index 00000000..911b9938 --- /dev/null +++ b/src/cvcGL/wasm/devtools/GLSYNC.md @@ -0,0 +1,146 @@ +# glsync: Firefox WebGL sync census for cvcGL wasm apps + +`glsync.js` wraps every WebGL2 method on the page and sorts each call by what Firefox 156 does with it: + +- **S**: a synchronous round trip to the GPU process +- **C**: answered in the content process +- **A**: queued +- **F**: flushed without waiting + +It also models Firefox's async-present flush budget. Once per report window it prints one `GLSYNC` line to the console, and appends a short version to the page's `#profhud` element (a HUD) when it has one. Without `?glsync` in the URL it does nothing; the only cost is one URL check. + +The model is Firefox 156's. In any other browser, every line it prints says `model is Firefox-only`. + +It is a developer tool, not part of the cvcGL SDK. It is how the WebGL state shim (`../webgl_state_shadow.js`, linked by `cvcgl_wasm_app()`) was found and is checked: with the shim on, the census should show no `ACTIVE_TEXTURE`, `SCISSOR_BOX` or `BLEND_*` sync calls. Compare against `?glshim=0`. + +## Getting the script into the page + +The cvcGL dev server injects it: + +``` +python3 src/cvcGL/examples/wasm/serve.py -d build-wasm-mt/gallery --glsync [--js-profiling] [--prof-param prof] 8822 +``` + +- `--glsync[=PATH]` inserts `` before the first `") + + +class QuietHandler(serve.IsolatedHandler): + def log_message(self, *args): + pass + + +class Server: + def __init__(self, directory, **opts): + handler = serve.make_handler(directory, base=QuietHandler, **opts) + self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler) + self.port = self.httpd.server_address[1] + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + + def get(self, path, method="GET"): + c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10) + c.request(method, path) + r = c.getresponse() + body = r.read() + c.close() + return r, body + + def close(self): + self.httpd.shutdown() + self.httpd.server_close() + + +def make_gallery(root: Path): + (root / "demo").mkdir() + (root / "demo" / "index.html").write_bytes(PAGE) + (root / "demo" / "demo.js").write_bytes(b"// the module\n") + (root / "index.html").write_bytes(b"demo") + (root / "nodir").mkdir() + + +class InjectTest(unittest.TestCase): + def test_before_first_script_case_insensitive(self): + html = b"" + out = serve.inject_glsync(html) + self.assertEqual(out.count(TAG), 1) + self.assertLess(out.index(TAG), out.index(b"") + self.assertLess(out.index(TAG), out.index(b"", "prof") + self.assertEqual(out, b'\n' + + TAG + b"\n") + self.assertEqual(serve.inject_glsync(out, "prof"), out) + + +class NoFlagsTest(unittest.TestCase): + """No flags: exactly the plain isolated server -- no injection, no Document-Policy.""" + + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory() + make_gallery(Path(cls.tmp.name)) + cls.srv = Server(cls.tmp.name) + + @classmethod + def tearDownClass(cls): + cls.srv.close() + cls.tmp.cleanup() + + def test_page_untouched_and_headers(self): + for path in ("/demo/", "/demo/index.html", "/demo/?glsync"): + r, body = self.srv.get(path) + self.assertEqual(r.status, 200, path) + self.assertEqual(body, PAGE, path) + self.assertEqual(r.getheader("Cross-Origin-Opener-Policy"), "same-origin") + self.assertEqual(r.getheader("Cross-Origin-Embedder-Policy"), "require-corp") + self.assertEqual(r.getheader("Cache-Control"), "no-cache") + self.assertIsNone(r.getheader("Document-Policy"), path) + + def test_no_census_url(self): + r, _ = self.srv.get("/glsync.js") + self.assertEqual(r.status, 404) + + +class GlsyncTest(unittest.TestCase): + """--glsync=PATH --js-profiling.""" + + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory() + make_gallery(Path(cls.tmp.name)) + cls.srv = Server(cls.tmp.name, glsync_js=GLSYNC, js_profiling=True) + + @classmethod + def tearDownClass(cls): + cls.srv.close() + cls.tmp.cleanup() + + def assert_headers(self, r): + self.assertEqual(r.getheader("Cross-Origin-Opener-Policy"), "same-origin") + self.assertEqual(r.getheader("Cross-Origin-Embedder-Policy"), "require-corp") + self.assertEqual(r.getheader("Document-Policy"), "js-profiling") + self.assertEqual(r.getheader("Cache-Control"), "no-cache") + + def test_demo_page_injected_before_first_script(self): + for path in ("/demo/", "/demo/index.html", "/demo/?glsync=seq"): + r, body = self.srv.get(path) + self.assertEqual(r.status, 200, path) + self.assert_headers(r) + self.assertEqual(int(r.getheader("Content-Length")), len(PAGE) + len(TAG) + 1) + self.assertTrue(r.getheader("Content-Type").startswith("text/html")) + self.assertEqual(body.count(TAG), 1) + self.assertEqual(body.index(TAG), body.lower().index(b"window.__glsyncProfParam = "prof";\n' + self.assertEqual(body.count(pre), 1) + self.assertLess(body.index(pre), body.index(TAG)) + self.assertEqual(body.index(pre), body.lower().index(b" Date: Fri, 2 Oct 2026 00:23:37 -0500 Subject: [PATCH 6/8] docs + CI: the cvcGL wasm app contract, and checks that it ships docs/CVCGL_WASM.md documents the contract a cvcGL / Ariadne wasm app works under: - cvcgl_wasm_app() and its keywords; - the state shim, its URL modes and their precedence; - FrameYield: the contract, the URL override, the watchdog and what it counts, the lock for -sASYNCIFY_IGNORE_INDIRECT apps, a feature-detection snippet for consumers that also build against an older cvcGL, and the loop audit of the examples; - mimalloc AUTO and why it is threaded-only; - the -sASYNCIFY_IGNORE_INDIRECT checklist (documented, never a default); - glsync and serve.py's profiling flags; - the tests, and what to check in a browser. The JS tests run only under the Emscripten SDK's node, never one from PATH (hermetic toolchain). ctest registers cvcgl_webgl_state_shadow and cvcgl_glsync (label js) with CMAKE_CROSSCOMPILING_EMULATOR under emcmake, and otherwise with CVCGL_EMSDK_NODE, searched only in $CVC_EMSDK_DIR/node/*/bin and /opt/cvc-wasm/emsdk/node/*/bin (NO_DEFAULT_PATH) or given with -D; it looks again when CVC_EMSDK_DIR changes. Without an emsdk node the tests are not registered, and a status line says so. src/cvcGL/wasm/run-js-tests.sh runs the shim, glsync and serve.py tests without CMake, with the emsdk bundle's node (CVC_EMSDK_DIR, or the fleet's /opt/cvc-wasm/emsdk). ci.yml: a new cvcgl-wasm-js job runs it. It does work only when src/cvcGL/wasm/, the shim test, serve.py or ci.yml changed, decided by a git diff against the PR base or the push's before, and provisions node with `cvcpkg install emsdk` (no apt). publish-cvcgl-wasm.yml: - runs run-js-tests.sh once emsdk is provisioned; - fails the SDK bundle when share/cvcGL/wasm/webgl_state_shadow.js is missing, cvcGLWasm.cmake has no cvcgl_wasm_app(), cvcGLConfig.cmake does not record the -pthread build, or cvcGLConfig / cvcGLTargets / cvcGLWasm.cmake name a path on the runner; - fails the cvcgl-examples gallery when nav_city_drive.js carries no __cvcGlShadow. --- .github/workflows/ci.yml | 52 +++ .github/workflows/publish-cvcgl-wasm.yml | 21 ++ docs/CVCGL_WASM.md | 434 +++++++++++++++++++++++ src/cvcGL/CMakeLists.txt | 45 +++ src/cvcGL/wasm/run-js-tests.sh | 45 +++ 5 files changed, 597 insertions(+) create mode 100644 docs/CVCGL_WASM.md create mode 100755 src/cvcGL/wasm/run-js-tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ecbb6b1..5c497a37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,6 +154,58 @@ jobs: echo "$out" exit 1 + # ─────────────── cvcGL browser-side JS (state shim, glsync, serve.py) ─────────────── + # + # webgl_state_shadow.js (the --pre-js cvcgl_wasm_app links into every cvcGL wasm app), the + # glsync census and serve.py's flags are plain JS / Python, tested under node against mock + # WebGL contexts -- no browser and no wasm build. Only when those files changed: the node + # comes from the cvcpkg emsdk bundle (hermetic, no apt), which is a big download. + cvcgl-wasm-js: + name: cvcGL wasm JS tests + runs-on: ubuntu-24.04 + concurrency: + group: ci-${{ github.ref }}-cvcgl-wasm-js + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Did the browser-side files change? + id: paths + shell: bash + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "pull_request" ]; then + base="${{ github.event.pull_request.base.sha }}" + else + base="${{ github.event.before }}" + fi + run=true + if [ -n "$base" ] && [ "$base" != "0000000000000000000000000000000000000000" ] \ + && git cat-file -e "${base}^{commit}" 2>/dev/null; then + if ! git diff --name-only "$base" HEAD -- src/cvcGL/wasm \ + src/cvcGL/test/webgl_state_shadow_test.js src/cvcGL/examples/wasm/serve.py \ + .github/workflows/ci.yml | grep -q .; then + run=false + fi + fi + echo "run=$run" >> "$GITHUB_OUTPUT" + echo "browser-side files changed: $run" + + - name: Provision node from the cvcpkg emsdk bundle + if: steps.paths.outputs.run == 'true' + run: | + curl -fsSL https://cvcpkg.org/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + cvcpkg install emsdk --platform linux --prefix "$RUNNER_TEMP/emsdk" \ + --config release --link shared --no-fallback-to-source + echo "CVC_EMSDK_DIR=$RUNNER_TEMP/emsdk" >> "$GITHUB_ENV" + + - name: Run the JS tests + if: steps.paths.outputs.run == 'true' + run: src/cvcGL/wasm/run-js-tests.sh + # ──────────────────────────── Linux packaging ──────────────────────────── # # Mirrors release.yml's linux job exactly, but injects - into diff --git a/.github/workflows/publish-cvcgl-wasm.yml b/.github/workflows/publish-cvcgl-wasm.yml index 052d643d..21d92f5a 100644 --- a/.github/workflows/publish-cvcgl-wasm.yml +++ b/.github/workflows/publish-cvcgl-wasm.yml @@ -72,6 +72,10 @@ jobs: || { echo "::error::emsdk bundle has no emsdk_env.sh at $EMSDK_DIR"; exit 1; } echo "CVC_EMSDK_DIR=$EMSDK_DIR" >> "$GITHUB_ENV" + - name: cvcGL browser-side JS tests (state shim, glsync, serve.py) + # The shim ships in the bundle built below; test it first, with the emsdk's own node. + run: src/cvcGL/wasm/run-js-tests.sh + - name: Install the wasm-mt dep closure # The same list + flags the nightly installs; libcvc/cvcGL link these. sdl3 backs the # cvcGL input/peripheral seam (CVC_ENABLE_SDL): with it in $WASM_DEPS, cvcGL's @@ -117,6 +121,20 @@ jobs: grep -q "find_dependency(SDL3" "$INST/lib/cmake/cvcGL/cvcGLConfig.cmake" \ || { echo "::error::cvcGL wasm did NOT link SDL3 (find_package(SDL3) failed under emcmake despite sdl3 in \$WASM_DEPS) — the input seam fell back to the VTK interactor"; exit 1; } echo "cvcGL wasm: SDL3 input seam ENABLED (find_dependency(SDL3) present)" + # cvcgl_wasm_app(): the WebGL state shim and the CMake function that links it must ship, + # the config must say this cvcGL is -pthread (MIMALLOC AUTO follows it), and none of the + # three CMake files may name a path on this runner (the JS dir is computed from the + # installed config's own location). + test -f "$INST/share/cvcGL/wasm/webgl_state_shadow.js" \ + || { echo "::error::cvcGL wasm bundle has no share/cvcGL/wasm/webgl_state_shadow.js"; exit 1; } + grep -q "function(cvcgl_wasm_app" "$INST/lib/cmake/cvcGL/cvcGLWasm.cmake" \ + || { echo "::error::cvcGL wasm bundle has no cvcgl_wasm_app() (lib/cmake/cvcGL/cvcGLWasm.cmake)"; exit 1; } + grep -q "CVCGL_WASM_PTHREADS ON" "$INST/lib/cmake/cvcGL/cvcGLConfig.cmake" \ + || { echo "::error::cvcGLConfig.cmake does not record the -pthread build (CVCGL_WASM_PTHREADS)"; exit 1; } + if grep -nF -e "$GITHUB_WORKSPACE" -e "$INST" "$INST"/lib/cmake/cvcGL/cvcGLConfig.cmake \ + "$INST"/lib/cmake/cvcGL/cvcGLTargets.cmake "$INST"/lib/cmake/cvcGL/cvcGLWasm.cmake; then + echo "::error::cvcGL's installed CMake files name a path on this runner (not relocatable)"; exit 1 + fi - name: Pack + publish libcvc + cvcgl wasm-mt env: @@ -186,6 +204,9 @@ jobs: bash cvcpkg/recipes/cvcgl-examples/build-wasm.sh test -f "$EX_INST/share/cvcgl-examples/web/index.html" \ || { echo "::error::cvcgl-examples wasm gallery missing web/index.html"; exit 1; } + # Every gallery demo is a cvcgl_wasm_app: the state shim is in its module JS. + grep -q __cvcGlShadow "$EX_INST/share/cvcgl-examples/web/nav_city_drive/nav_city_drive.js" \ + || { echo "::error::nav_city_drive.js has no WebGL state shim (cvcgl_wasm_app not applied)"; exit 1; } echo "=== gallery web/ contents ==="; ls -la "$EX_INST/share/cvcgl-examples/web" | head -20 cvcpkg pack cvcpkg/recipes/cvcgl-examples --from-prefix "$EX_INST" \ --platform wasm-mt --config release --link static \ diff --git a/docs/CVCGL_WASM.md b/docs/CVCGL_WASM.md new file mode 100644 index 00000000..029f0d30 --- /dev/null +++ b/docs/CVCGL_WASM.md @@ -0,0 +1,434 @@ +# cvcGL in the Browser: the WebAssembly App Contract + +*How a cvcGL / Ariadne app built with Emscripten gets the browser-side speedups, +and what it promises in return. Reference for `cvcgl_wasm_app()` +(`src/cvcGL/wasm/cvcGLWasm.cmake`), the WebGL state shim +(`src/cvcGL/wasm/webgl_state_shadow.js`) and `cvc::gl::FrameYield` +(`inc/cvc/gl/FrameYield.h`).* + +## Table of Contents + +- [Overview](#overview) +- [Quick start](#quick-start) +- [cvcgl_wasm_app()](#cvcgl_wasm_app) +- [The WebGL state shim](#the-webgl-state-shim) +- [FrameYield: who yields to the browser](#frameyield-who-yields-to-the-browser) +- [mimalloc](#mimalloc) +- [-sASYNCIFY_IGNORE_INDIRECT: a checklist, never a default](#-sasyncify_ignore_indirect-a-checklist-never-a-default) +- [Measuring: glsync and serve.py](#measuring-glsync-and-servepy) +- [Testing](#testing) +- [Checking an app in a browser](#checking-an-app-in-a-browser) + +## Overview + +A cvcGL app in a browser pays for things a native build never sees. Two of those costs +come from cvcGL's own code, so every cvcGL app has them: + +- **Synchronous GL state queries.** cvcGL compiles Dear ImGui's `imgui_impl_opengl3` + into `libcvcGL`, and `ImGuiOverlay` calls `ImGui_ImplOpenGL3_RenderDrawData` once per + frame. Ariadne's `ImGuiBackend` draws through `ImGuiOverlay`. That backend backs up + and restores `ACTIVE_TEXTURE`, `VIEWPORT`, `SCISSOR_BOX`, the six `BLEND_*` values, + five `isEnabled` flags and `isProgram` around every draw. VTK also re-reads + `READ_BUFFER` and `MAX_DRAW_BUFFERS` several times a frame. In a browser several of + these block until the GPU process has drained its command queue. Firefox does not cache + `ACTIVE_TEXTURE`, so there ImGui's first query of each frame waits for all of the + frame's queued GPU work. +- **A second yield per frame.** VTK 9.5's `vtkWebAssemblyOpenGLRenderWindow::Frame()` + calls `emscripten_sleep(0)` at the end of every render. An app whose loop already + yields once per frame then pays a clamped trip through the event loop twice. + +A third cost is the allocator. dlmalloc serialises every `malloc`/`free` on one global +lock, and in a threaded (wasm-mt) build the threads contend for it. + +The fixes are opt-in, one per cost: + +| Fix | How an app gets it | Effect | +|---|---|---| +| WebGL state shim | `cvcgl_wasm_app()` (CMake) | the shadowed state queries are answered client-side and never wait for the GPU process (check with glsync) | +| `FrameYield::App` | `view.setFrameYield(SceneRenderer::FrameYield::App)` (C++) | one event-loop trip per frame instead of two | +| mimalloc | `cvcgl_wasm_app` on a wasm-mt build | no global allocator lock for the threads to contend on | + +The CPU-side frame-cost fixes already in cvcGL need no opt-in: the metadata mirror, +the idle `CameraController::update`, and the cheaper bounds walk. `setCastsShadow` +and `texture_modified_rows` / `_rect` need the app to call them. + +## Quick start + +```cmake +find_package(cvcGL CONFIG REQUIRED) # or in-tree: cvcGL's own CMakeLists +add_executable(my_app main.cpp) +target_link_libraries(my_app PRIVATE cvc::cvcGL) +if(EMSCRIPTEN) + target_link_options(my_app PRIVATE -sASYNCIFY=1 -sALLOW_MEMORY_GROWTH=1) +endif() +if(COMMAND cvcgl_wasm_app) # older cvcGL bundles do not ship it + cvcgl_wasm_app(my_app) # no-op outside Emscripten: call it unconditionally +endif() +``` + +```cpp +cvc::gl::SceneRenderer view(sg, w, h, /*offscreen=*/false, "main"); +view.setFrameYield(cvc::gl::SceneRenderer::FrameYield::App); // no-op natively +while (!view.windowClosed()) { + // ... frame work ... + view.render(); +#ifdef __EMSCRIPTEN__ +#ifndef __EMSCRIPTEN_PTHREADS__ + sg.publisher().flush(); // no worker thread: drain publishes at frame cadence +#endif + emscripten_sleep(0); // the ONE yield per frame -- on every path that renders +#endif +} +``` + +Every wasm demo in `src/cvcGL/examples` is built this way: the 9 gallery demos +(`_wasm_demos`) and `ariadne_hello` (`_wasm_extra_demos`, not in the gallery). + +## cvcgl_wasm_app() + +```cmake +cvcgl_wasm_app( + [STATE_SHIM ON|OFF] # default ON + [STATE_SHIM_MODE on|verify|norb] # build-time default mode; default on + [MIMALLOC AUTO|ON|OFF] # default AUTO = ON iff cvcGL was built -pthread + [FRAME_YIELD_LOCKED AUTO|ON|OFF]) # default AUTO = ON iff the app links -sASYNCIFY_IGNORE_INDIRECT +``` + +Defined in `cvcGLWasm.cmake`. `cvcGLConfig.cmake` includes it for installed consumers, +and `src/cvcGL/CMakeLists.txt` includes it in-tree. + +- **Outside Emscripten** it returns at once. +- **STATE_SHIM** adds `target_link_options(PRIVATE "SHELL:--pre-js /webgl_state_shadow.js")` + and puts the file in `LINK_DEPENDS`, so editing the shim relinks the app. +- **STATE_SHIM_MODE** other than `on` generates `/_glshim_default.js`, + which sets `Module.glStateShadowDefault`, and links it as a `--pre-js` ahead of the shim. +- **MIMALLOC** adds `-sMALLOC=mimalloc` (see [mimalloc](#mimalloc) for AUTO). + - A `-sMALLOC=mimalloc` the target already has counts as ON: nothing is added and + nothing is said. + - Any other `-sMALLOC=` on the target, its directory or `CMAKE_EXE_LINKER_FLAGS` is + kept, and a warning says so. + - An `-sMALLOC=` the app adds after this call comes later on the link line, and emcc + keeps the last `-s` value, so it wins too. +- **FRAME_YIELD_LOCKED** links a generated `/_frameyield_lock.js` as a + `--pre-js`. It sets `Module.cvcglFrameYieldLocked = 1`, which locks every cvcGL window + of the app in `FrameYield::App` ([the lock](#the-lock-apps-linked-with--sasyncify_ignore_indirect)). + - `AUTO` locks exactly when the target's final link options carry + `-sASYNCIFY_IGNORE_INDIRECT=1` (the deferred check below; on CMake older than 3.19, the + options present at the call). + - Pass `ON` when the flag comes from somewhere the check cannot see, such as a + dependency's `INTERFACE_LINK_OPTIONS` or a generator expression. + - `OFF` never locks. With `IGNORE_INDIRECT` linked, the lint then warns that the app + traps unless its C++ locks every window itself. +- **Target properties** `CVCGL_WASM_APP`, `CVCGL_WASM_APP_STATE_SHIM`, + `CVCGL_WASM_APP_STATE_SHIM_MODE`, `CVCGL_WASM_APP_MIMALLOC` and + `CVCGL_WASM_APP_FRAME_YIELD_LOCKED` record what was applied. The last reads `AUTO` + until the deferred check resolves it. +- **Deferred check and lint.** With CMake 3.19 or newer, a deferred check runs on the + target's final link options when the calling directory finishes. It resolves + `FRAME_YIELD_LOCKED AUTO`, warns on `-sASYNCIFY_IGNORE_INDIRECT=1`, and warns more + loudly if `-sFETCH=1` is there too. See the [checklist](#-sasyncify_ignore_indirect-a-checklist-never-a-default). + +**Feature tests:** `if(COMMAND cvcgl_wasm_app)`, and for later keywords +`if("FRAME_YIELD_LOCKED" IN_LIST CVCGL_WASM_APP_FEATURES)`. + +**Why a function and not an INTERFACE option on `cvc::cvcGL`?** An interface +`--pre-js` would reach every static consumer's final link: tests, helper tools, and +pages that never asked for a page-global WebGL patch. It also could not carry +`-sMALLOC`. Dependency link options come after the target's own and emcc keeps the +last `-s` value, so cvcGL would silently override the app's allocator. + +**No absolute paths are exported.** The function finds the JS through the GLOBAL +property `CVCGL_WASM_DATA_DIR`: +- in-tree it is `src/cvcGL/wasm`; +- installed it is `@PACKAGE_CVCGL_WASM_DATADIR@`, which + `configure_package_config_file(... PATH_VARS)` computes from the installed config's + own location (`PACKAGE_PREFIX_DIR`). + +`cvcGLConfig.cmake` reads that path right after `@PACKAGE_INIT@`, before any +`find_dependency`. On CMake 3.29 and older, every dependency config +generated with `@PACKAGE_INIT@` (cvc, SDL3, zstd, libxml2 and others) overwrites +`PACKAGE_PREFIX_DIR` with its own prefix. Reading it later would hand out the +dependency prefix's `share/cvcGL/wasm` whenever cvc or SDL3 lives in a different +prefix than cvcGL, as in publish-cvcgl-wasm's `$INST` / `$WASM_DEPS` split. + +So a relocated prefix hands out its own `share/cvcGL/wasm`, and the path only ever +appears in the consumer's build tree. `CVCGL_WASM_PTHREADS` records whether cvcGL +was built `-pthread`. Emscripten forbids mixing pthread and non-pthread objects, so +a consumer of a wasm-mt `libcvcGL.a` is threaded too. A bundle without the JS still +passes `find_package`; the function stops with an error only when it is asked for the +shim and the file is missing. + +**Not CMake?** Pass the installed file yourself: +`--pre-js /share/cvcGL/wasm/webgl_state_shadow.js`, plus `-sMALLOC=mimalloc` on a +wasm-mt (`-pthread`) build. + +**TODO: the pycvc_gl wasm host.** `bindings/pycvc/wasm/link-host.sh` is node-only today, +so it links neither speedup. When it gets a browser page, its link must add the shim +`--pre-js "$INST/share/cvcGL/wasm/webgl_state_shadow.js"` and, on wasm-mt, +`-sMALLOC=mimalloc`. A TODO in the script says the same. + +## The WebGL state shim + +`webgl_state_shadow.js` wraps every WebGL setter that can change a value it shadows, +and keeps a copy per context. The matching query is then answered from that copy +instead of a synchronous call: +- `getParameter` for `SCISSOR_BOX`, `VIEWPORT`, `BLEND_{SRC,DST}_{RGB,ALPHA}`, + `BLEND_EQUATION_{RGB,ALPHA}` and `ACTIVE_TEXTURE`; +- on WebGL2, also `MAX_DRAW_BUFFERS`, `MAX_COLOR_ATTACHMENTS`, and `READ_BUFFER`, + which is tracked per framebuffer; +- `isEnabled` for 9 or 10 capabilities; +- `isProgram` for live programs. + +Anything it cannot prove is passed to the real call: a rejected setter, a foreign +framebuffer, a deleted program. The copy is dropped on context loss. In a worker +(OffscreenCanvas / PROXY_TO_PTHREAD) the shim does nothing. + +**Scope.** It patches the WebGL *prototypes*, so it serves every WebGL context on the +page, cvcGL's or not. A fuzz test checks the answers against a mock WebGL with real +GL/WebGL semantics, and `?glshim=verify` checks them against a live app (see +[Checking an app in a browser](#checking-an-app-in-a-browser)). A page that embeds a +cvcGL module beside other WebGL code can still opt out with `STATE_SHIM OFF`, or +`?glshim=0` per load. + +**Modes and where they come from.** The first of these that is set wins, so a URL can +always A/B any page: + +1. the URL: `?glshim=0|off|on|verify|norb`; +2. the page: `Module.glStateShadow`, set by the host page before the module starts; +3. the build: `Module.glStateShadowDefault`, from `cvcgl_wasm_app(... STATE_SHIM_MODE m)`; +4. `on`. + +| mode | behaviour | +|---|---| +| `on` | everything above | +| `0` / `off` | nothing installed; every call goes straight to WebGL (the A/B baseline) | +| `norb` | on, except `READ_BUFFER`, which goes to the real call | +| `verify` | answers with the REAL value, compares it to the shadow, and counts mismatches | + +`window.__cvcGlShadow.stats` holds: +- `version`: which copy of the shim is active (`cvcGL-1` for this one). The first copy + installed on a page wins, so a page that may load another copy can tell which one runs. +- `mode` and `modeFrom` (`url` / `page` / `build` / `default`); +- `served`, `verified`, `mismatches` and `mismatchBy` (per value name). + +## FrameYield: who yields to the browser + +```cpp +namespace cvc::gl { enum class FrameYield { Vtk, App }; } // cvc/gl/FrameYield.h +// SceneRenderer and ViewportManager: +using FrameYield = cvc::gl::FrameYield; +void setFrameYield(FrameYield); +FrameYield frameYield() const; // the effective mode +void lockFrameYield(); // App for good (the -sASYNCIFY_IGNORE_INDIRECT lock) +bool frameYieldLocked() const; +#define CVC_GL_HAS_FRAME_YIELD 1 +#define CVC_GL_HAS_FRAME_YIELD_LOCK 1 +``` + +- **`Vtk`** (the default) keeps today's behaviour: VTK 9.5 yields inside every + `render()`. +- **`App`** means the app's loop yields once per frame, so cvcGL calls + `SetDoubleBuffer(0)` on the render window. In VTK 9.5 that is the switch for the + in-render sleep. + +**The contract.** In `App` mode the loop calls `emscripten_sleep(0)` on **every** path +that renders a frame. A `continue` that skips the frame-end yield breaks it. That is +the bug `nav_fog_ghost`'s paused path had. Without the in-render yield it would never +let the browser paint again. + +- **Where App does nothing.** It is a no-op natively, and in a wasm build without + Asyncify (`emscripten_has_asyncify() == 0`, e.g. an `emscripten_set_main_loop` app). + There is no in-render yield there to remove, so `DoubleBuffer` is left alone and + `frameYield()` reads `Vtk`. +- **DoubleBuffer.** cvcGL only changes `DoubleBuffer` back if cvcGL turned it off, and + then it restores the value it found at that moment rather than forcing 1. An app's own + `SetDoubleBuffer` survives `Vtk` mode. An app's own `SetDoubleBuffer(0)` survives a + watchdog trip only if the app made it before cvcGL switched to `App` (before + `setFrameYield(App)`, or under `?frameyield=app` before the window exists): one made + after is undone by the trip. An `-sASYNCIFY_IGNORE_INDIRECT` app locks instead. +- **Readback.** `writePNG` and `frameRGB` read the back buffer explicitly (`front=0`, + `ReadFrontBufferOff`), so readback is unaffected. +- **URL override.** `?frameyield=vtk|app` overrides the app's choice for an A/B without + a rebuild. It is read once, when the window is created, and logged. A locked window + refuses `?frameyield=vtk` and logs that instead. +- **Watchdog** (wasm with Asyncify, `App`). An `EM_JS` epoch counter is bumped by a + microtask. A microtask can only run once wasm has returned to the browser. If one + window renders 8 times in a single epoch, the loop broke the contract. cvcGL then + logs once to stderr (the console) and restores the `DoubleBuffer` value it found, so + the page keeps running, slower, instead of freezing. + - It counts **every** `vtkRenderWindow::Render()` of the window through a + `vtkCommand::StartEvent` observer, whichever path made it: `render()`, `writePNG` + (two renders), `frameRGB` (one), `renderWindow()->Render()`, a node's own fallback + render, the interactor's resize render. + - `frameYield()` reads `Vtk` afterwards. Calling `setFrameYield(App)` again re-arms it. + - Each trip also bumps `globalThis.__cvcglFrameYield.trips`, for automated browser + checks. + - The threshold assumes no window renders more than 7 times per browser task. + - A locked window only reports, as a loud `cvcGL: ERROR:` line. See + [the lock](#the-lock-apps-linked-with--sasyncify_ignore_indirect). +- **VTK 9.6** removed the in-render yield (`81a272dd1ee`). With VTK 9.6 every wasm loop + must yield by itself in either mode, so the watchdog there only warns. + +**Feature detection** for a consumer that also builds against an older cvcGL: + +```cpp +template void app_yields(V &v) { + if constexpr (requires { v.setFrameYield(V::FrameYield::App); }) + v.setFrameYield(V::FrameYield::App); + else { + // Older cvcGL: by hand, and only in the browser. Natively, DoubleBuffer off makes + // the window draw to the front buffer or stop presenting. +#ifdef __EMSCRIPTEN__ + if (auto *rw = v.renderWindow()) + rw->SetDoubleBuffer(0); +#endif + } +} +``` + +An app linked with `-sASYNCIFY_IGNORE_INDIRECT` locks instead, so nothing can turn the +trapping sleep back on: `if constexpr (requires { v.lockFrameYield(); }) v.lockFrameYield(); +else` in front of the first branch, or let `cvcgl_wasm_app` do it (`FRAME_YIELD_LOCKED AUTO`). +Any other app must not lock: that turns off the watchdog's rescue and the `?frameyield` A/B. + +**Loop audit of the examples** (why `Vtk` stays the library default): + +| App | Its own per-frame yield | App mode? | +|---|---|---| +| lsystem_forest, lsystem_coast, terrain_lab, bunny_shadow | at the loop end | yes | +| volren_bunny, volslice_bunny | at the loop end (the `break`s are native capture) | yes | +| nav_city_swarm, nav_city_drive | at the loop end (the `continue`s are inside the trail lambda's `for`) | yes | +| nav_fog_ghost | at the loop end, and on the paused path (fixed: it used to `continue` past the only yield) | yes | +| ariadne_hello | at the loop end on Emscripten (`sleep_for` natively); its `--png` / `--offscreen` capture loop renders back to back, so it keeps `Vtk` | yes, interactive loop only | +| nav_convoy, nav_compute | `std::this_thread::sleep_for` only: native-only, not wasm apps | n/a | +| pycvc_gl wasm host | Python-driven, no `-sASYNCIFY` | n/a (VTK's sleep never runs) | + +### The lock: apps linked with -sASYNCIFY_IGNORE_INDIRECT + +Under `-sASYNCIFY_IGNORE_INDIRECT=1`, VTK 9.5's in-render sleep is a trap, not a +fallback, because it is reached through the virtual `Render()`. Two things can bring it +back at runtime on an unlocked window: `?frameyield=vtk`, and a watchdog trip. Such an +app therefore **locks** `App`: + +- **Automatically**, from `cvcgl_wasm_app()`. `FRAME_YIELD_LOCKED AUTO` sees the + flag in the target's final link options and links `_frameyield_lock.js`, which + sets `Module.cvcglFrameYieldLocked = 1`. Every window reads it when it is created. +- **By hand**: `view.lockFrameYield()` in C++, `FRAME_YIELD_LOCKED ON`, or a host page + that sets `Module.cvcglFrameYieldLocked = 1` before the module starts. + +A locked window is in `App` from that moment, whatever the app asked before: +- `?frameyield=vtk` and `setFrameYield(Vtk)` are refused, and each refusal is logged; +- the watchdog never turns VTK's yield back on. It reports once, loudly, and the page + cannot paint until the loop yields; +- there is no unlock, because the reason is a link flag. + +Natively the lock is only recorded: `frameYieldLocked()` reads `true`, and +`frameYield()` still reads `Vtk`. + +## mimalloc + +`MIMALLOC AUTO` turns mimalloc on for wasm-mt builds and leaves single-threaded builds +on dlmalloc. Emscripten's own guidance (`src/settings.js`) recommends mimalloc for +malloc contention, and notes that it is larger and uses more memory. A single-threaded +dlmalloc has no lock to contend on, so it gains nothing there. + +As a result: +- the gh-pages gallery (single-threaded) keeps dlmalloc; +- the cvcgl-examples wasm-mt gallery, like any other wasm-mt app, gets mimalloc. + +Under `-fsanitize=address`, `AUTO` resolves to OFF with a status message, because emcc +refuses to combine mimalloc with ASan. An explicit `MIMALLOC ON` under ASan gets a +warning instead. + +Because mimalloc uses more memory, check a memory-heavy app at its `MAXIMUM_MEMORY` +cap for growth failures or OOM. The gallery's Austin nav demos (`nav_city_swarm`, +`nav_city_drive`) run at 4 GB. + +## -sASYNCIFY_IGNORE_INDIRECT: a checklist, never a default + +`-sASYNCIFY_IGNORE_INDIRECT=1` tells Asyncify that no indirect call leads to an +unwind: no virtual call, function pointer or `std::function`. Asyncify then +instruments far fewer functions, which gives a smaller and faster module. It is only +correct while every sleep the app can reach is reached through direct calls. A sleep +reached indirectly **traps** at its unwind. That depends on the app, so it is not a +`cvcgl_wasm_app` option, and `cvcgl_wasm_app`'s lint warns when a target links it. +Before shipping it: + +1. **Every async import reachable on the main thread is reached only through direct + calls.** These cvcGL apps can reach a sleep indirectly: + - VTK 9.5's `vtkWebAssemblyOpenGLRenderWindow::Frame()` sleep, reached through the + virtual `Render()`. It needs `FrameYield::App`, **locked**, because on an unlocked + window `?frameyield=vtk` or a watchdog trip turns it back on. + `cvcgl_wasm_app`'s `FRAME_YIELD_LOCKED AUTO` locks it when the flag is in the final + link options; otherwise use `FRAME_YIELD_LOCKED ON` or `lockFrameYield()` (see + [the lock](#the-lock-apps-linked-with--sasyncify_ignore_indirect)). + - `cvc::net`'s blocking fetch (`emscripten_sleep` in `http_client_fetch.cpp`), + reached from Ariadne's http verbs through `std::function` intrinsics. + - Anything that runs inside `ImGuiOverlay`'s draw callback. That callback is a + `std::function`, so this covers every Ariadne UI action. + - ImageMagick's `fd_sync`. + - Any other blocking `emscripten_*` call. +2. **`-sFETCH=1`.** Every gallery demo links it (`src/cvcGL/examples/CMakeLists.txt`). + An app that keeps it must prove fetch is unreachable. Otherwise, do not use + `IGNORE_INDIRECT`. +3. **Link once with `-sASYNCIFY_ADVISE=1 -sASSERTIONS=1`**, exercise every UI path, and + confirm there is no Asyncify "unreachable" trap. +4. **Re-run this audit** whenever anything new that can sleep is linked in. + +An app passes it when, for example, its only sleeps are direct calls in `main()`, it +links no fetch, and the VTK sleep is locked off. Once such an app calls +`cvcgl_wasm_app`, `FRAME_YIELD_LOCKED AUTO` does that locking. + +## Measuring: glsync and serve.py + +`src/cvcGL/wasm/devtools/glsync.js` is a Firefox census of synchronous WebGL calls. It +is a developer tool, not installed with the SDK; `devtools/GLSYNC.md` explains how to +read it. Serve a built gallery with it injected: + +``` +python3 src/cvcGL/examples/wasm/serve.py -d build-wasm-mt/gallery --glsync 8822 +# then http://localhost:8822/nav_city_drive/?glsync (shim on) +# http://localhost:8822/nav_city_drive/?glsync&glshim=0 (shim off: the A/B) +``` + +`serve.py` takes three flags, all off by default (`cvcgl-examples-web` passes none): +- `--glsync[=PATH]` injects the census; +- `--prof-param NAME` makes the census add `?NAME` to the URL, for apps that print + `PROF n=` lines with it; +- `--js-profiling` sends `Document-Policy: js-profiling` for Chromium's JS + Self-Profiling API. + +## Testing + +| What | How | +|---|---| +| state shim (fuzz + precedence) | `cvcgl_webgl_state_shadow` (ctest, label `js`) | +| glsync | `cvcgl_glsync` (ctest, label `js`) | +| serve.py flags | `python3 src/cvcGL/wasm/devtools/tests/test_serve.py` | +| all three, no CMake (CI) | `CVC_EMSDK_DIR= src/cvcGL/wasm/run-js-tests.sh` | +| FrameYield native contract | `cvcgl_frame_yield` (ctest) | + +The JS tests run only under the Emscripten SDK's node, never one from `PATH`: +- under `emcmake`, that is `CMAKE_CROSSCOMPILING_EMULATOR`; +- otherwise CMake looks only in `$CVC_EMSDK_DIR/node/*/bin` and + `/opt/cvc-wasm/emsdk/node/*/bin` (`NO_DEFAULT_PATH`; `-DCVCGL_EMSDK_NODE=` by + hand). It looks again when `CVC_EMSDK_DIR` changes. + +Without an emsdk node, ctest does not register them. CI runs them in the dedicated +`cvcgl-wasm-js` job through `run-js-tests.sh`, which installs emsdk from cvcpkg. + +## Checking an app in a browser + +Check in both Firefox and Chromium: + +1. **Exactness.** `?glshim=verify`: interact with the app (menus, panels, camera, + resize), then `__cvcGlShadow.stats.mismatches === 0` in the console. +2. **The win.** Firefox `?glsync`. There should be no `getParameter(ACTIVE_TEXTURE)`, + `SCISSOR_BOX` or `BLEND_*` `[S]` keys; with `&glshim=0` they come back. +3. **Every render path yields.** Pause, resume and use every mode. No `cvcGL: + FrameYield::App ... rendered 8 times` line may appear, and + `__cvcglFrameYield.trips` must stay 0. A deliberately non-yielding debug build must + print it once and keep running. A locked one, with `-sASYNCIFY_IGNORE_INDIRECT=1`, + must print the `cvcGL: ERROR:` line instead and never trap. +4. **Memory with mimalloc** (wasm-mt). Run for a few minutes at the app's + `MAXIMUM_MEMORY` and watch for growth failures. diff --git a/src/cvcGL/CMakeLists.txt b/src/cvcGL/CMakeLists.txt index 40cfd0d5..e832a7b7 100644 --- a/src/cvcGL/CMakeLists.txt +++ b/src/cvcGL/CMakeLists.txt @@ -661,6 +661,51 @@ add_executable(cvcgl_sdl_input test/cvcgl_sdl_input.cpp) target_link_libraries(cvcgl_sdl_input PRIVATE cvcGL) add_test(NAME cvcgl_sdl_input COMMAND cvcgl_sdl_input) +# ── JavaScript tests (node) ── +# The browser-side pieces cvcgl_wasm_app() links into a wasm app are plain JS, tested under node +# against mock WebGL contexts -- no browser and no wasm build needed. node must come from the +# Emscripten SDK (the cvcpkg emsdk bundle ships it), never from PATH (hermetic toolchain): under +# emcmake that is CMAKE_CROSSCOMPILING_EMULATOR; otherwise $CVC_EMSDK_DIR/node/*/bin or the fleet's +# /opt/cvc-wasm/emsdk, searched with NO_DEFAULT_PATH (or -DCVCGL_EMSDK_NODE= by hand). No +# emsdk node: the tests are not registered here, and CI runs them in its own job instead +# (src/cvcGL/wasm/run-js-tests.sh, cvcgl-wasm-js in ci.yml). +if(EMSCRIPTEN AND CMAKE_CROSSCOMPILING_EMULATOR) + set(_cvcgl_node ${CMAKE_CROSSCOMPILING_EMULATOR}) +else() + # A different CVC_EMSDK_DIR than the cached node was found under: look again (a node given + # with -D on the first configure is kept). + if(NOT DEFINED _CVCGL_EMSDK_NODE_FROM) + set(_CVCGL_EMSDK_NODE_FROM "$ENV{CVC_EMSDK_DIR}" CACHE INTERNAL + "CVC_EMSDK_DIR that CVCGL_EMSDK_NODE was looked up under") + elseif(DEFINED ENV{CVC_EMSDK_DIR} AND NOT "$ENV{CVC_EMSDK_DIR}" STREQUAL + "${_CVCGL_EMSDK_NODE_FROM}") + unset(CVCGL_EMSDK_NODE CACHE) + set(_CVCGL_EMSDK_NODE_FROM "$ENV{CVC_EMSDK_DIR}" CACHE INTERNAL + "CVC_EMSDK_DIR that CVCGL_EMSDK_NODE was looked up under") + endif() + file(GLOB _cvcgl_node_hints LIST_DIRECTORIES true "$ENV{CVC_EMSDK_DIR}/node/*/bin" + "/opt/cvc-wasm/emsdk/node/*/bin") + find_program(CVCGL_EMSDK_NODE NAMES node HINTS ${_cvcgl_node_hints} NO_DEFAULT_PATH + DOC "the Emscripten SDK's node, for cvcGL's JS tests (never a PATH node)") + set(_cvcgl_node ${CVCGL_EMSDK_NODE}) +endif() +if(_cvcgl_node) + # webgl_state_shadow.js (the state shim): fuzzed against a mock WebGL with real GL/WebGL + # semantics, plus its mode precedence (URL > page > build > on). + add_test(NAME cvcgl_webgl_state_shadow + COMMAND ${_cvcgl_node} ${CMAKE_CURRENT_SOURCE_DIR}/test/webgl_state_shadow_test.js + ${CMAKE_CURRENT_SOURCE_DIR}/wasm/webgl_state_shadow.js) + # glsync.js (wasm/devtools, the Firefox WebGL sync census dev tool): its flush model, frame + # clocks and URL handling, against a mock WebGL2 + fake rAF. + add_test(NAME cvcgl_glsync + COMMAND ${_cvcgl_node} ${CMAKE_CURRENT_SOURCE_DIR}/wasm/devtools/tests/test_glsync.js) + set_tests_properties(cvcgl_webgl_state_shadow cvcgl_glsync PROPERTIES LABELS js) +else() + message(STATUS "cvcGL: no Emscripten SDK node (CVC_EMSDK_DIR, /opt/cvc-wasm/emsdk) -- the JS " + "tests (cvcgl_webgl_state_shadow, cvcgl_glsync) are NOT registered; " + "src/cvcGL/wasm/run-js-tests.sh runs them (CI: the cvcgl-wasm-js job)") +endif() + # ── examples (opt-in, OFF by default) ── # Standalone C++ programs that drive cvcGL directly — SceneGraph + SceneRenderer # (the onscreen "basic window") + CameraController (orbit / Quake-fly / cinematic diff --git a/src/cvcGL/wasm/run-js-tests.sh b/src/cvcGL/wasm/run-js-tests.sh new file mode 100755 index 00000000..a3d181af --- /dev/null +++ b/src/cvcGL/wasm/run-js-tests.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# run-js-tests.sh — run cvcGL's browser-side tests without CMake (what CI runs): +# - the WebGL state shim's node test (test/webgl_state_shadow_test.js) +# - the glsync census's node test (wasm/devtools/tests/test_glsync.js) +# - serve.py's flag/injection tests (wasm/devtools/tests/test_serve.py, python3) +# +# node comes from the Emscripten SDK (the cvcpkg emsdk bundle ships it), never a system one: +# CVC_EMSDK_DIR= src/cvcGL/wasm/run-js-tests.sh +# NODE= overrides; /opt/cvc-wasm/emsdk (the fleet's) is the fallback. PYTHON= +# overrides the interpreter for test_serve.py (default: python3 on PATH). +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # src/cvcGL/wasm +CVCGL="$(dirname "$HERE")" # src/cvcGL + +if [[ -z "${NODE:-}" ]]; then + for d in "${CVC_EMSDK_DIR:-}" /opt/cvc-wasm/emsdk; do + [[ -n "$d" ]] || continue + for n in "$d"/node/*/bin/node; do + if [[ -x "$n" ]]; then NODE="$n"; break 2; fi + done + done +fi +if [[ -z "${NODE:-}" || ! -x "$NODE" ]]; then + echo "run-js-tests: no emsdk node (set CVC_EMSDK_DIR to the cvcpkg emsdk bundle, or NODE=)" >&2 + exit 2 +fi +PYTHON="${PYTHON:-python3}" +echo "run-js-tests: node $("$NODE" --version) ($NODE); $("$PYTHON" --version 2>&1)" + +fail=0 +run() { + local name="$1"; shift + echo "=== $name" + if "$@"; then echo "--- $name: ok"; else echo "--- $name: FAILED"; fail=1; fi +} +run webgl_state_shadow "$NODE" "$CVCGL/test/webgl_state_shadow_test.js" "$HERE/webgl_state_shadow.js" +run glsync "$NODE" "$HERE/devtools/tests/test_glsync.js" +run serve.py "$PYTHON" "$HERE/devtools/tests/test_serve.py" + +if [[ "$fail" -ne 0 ]]; then + echo "run-js-tests: FAILED" >&2 + exit 1 +fi +echo "run-js-tests: all passed" From a95245fcfaa3fe7a31781cc984bd9f72eebb3be5 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 2 Oct 2026 00:23:37 -0500 Subject: [PATCH 7/8] pycvc wasm host: TODO for the browser page's shim and mimalloc bindings/pycvc/wasm/link-host.sh links neither of cvcGL's browser-side speedups. That is right while the host is node-only, but nothing tracked it where the work will happen. Leave a TODO in link-host.sh (docs/CVCGL_WASM.md says the same) that when the host gets a browser page, its link must add --pre-js "$INST/share/cvcGL/wasm/webgl_state_shadow.js" and, on wasm-mt, -sMALLOC=mimalloc: what cvcgl_wasm_app does for a CMake app. It links no -sASYNCIFY, so FrameYield and -sASYNCIFY_IGNORE_INDIRECT do not apply until it does. --- bindings/pycvc/wasm/link-host.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bindings/pycvc/wasm/link-host.sh b/bindings/pycvc/wasm/link-host.sh index 4ac2b04b..88f85790 100644 --- a/bindings/pycvc/wasm/link-host.sh +++ b/bindings/pycvc/wasm/link-host.sh @@ -81,6 +81,13 @@ fi printf "Module.preRun=Module.preRun||[];Module.preRun.push(function(){ENV.PYTHONHOME='/py';%sENV.PYTHONDONTWRITEBYTECODE='1';});\n" "$VTK_PYPATH" > "$OUT/pre.js" +# TODO(cvcGL wasm app contract, docs/CVCGL_WASM.md): this host is node-only today, so it links +# neither of cvcGL's browser-side speedups. When it gets a browser page, that link must add +# --pre-js "$INST/share/cvcGL/wasm/webgl_state_shadow.js" (the WebGL state shim), and +# -sMALLOC=mimalloc (on a wasm-mt / -pthread build only) +# -- what cvcgl_wasm_app() does for a CMake app. It links no -sASYNCIFY, so FrameYield and +# -sASYNCIFY_IGNORE_INDIRECT do not apply until it does. + "$EMCC" "$SRC/bindings/pycvc/wasm/pycvc_host.cpp" "$GEN" \ -std=c++17 -O1 -DPYCVC_EMBED_NUMPY ${VTK_DEF[@]+"${VTK_DEF[@]}"} -I "$PYINC" \ -Wl,--allow-multiple-definition \ From 057ef49d2564f5316e4a66aaf0e48a2ff4427ed5 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 2 Oct 2026 16:02:41 -0500 Subject: [PATCH 8/8] cvcpkg recipes: bump cvcgl 18, cvcgl-cuda 3, cvcgl-examples 17 for the wasm app contract Next free revisions above master, the low-memory draw PR (#522: cvcgl 17, cvcgl-cuda 2, cvcgl-examples 16) and the highest published on any platform (cvcgl +cvc.16, cvcgl-examples +cvc.15, cvcgl-cuda none), so the publish jobs ship share/cvcGL/, cvcGLWasm.cmake and the examples' devtools instead of skipping an already-published name+version. --- cvcpkg/recipes/cvcgl-cuda/recipe.yaml | 3 ++- cvcpkg/recipes/cvcgl-examples/recipe.yaml | 3 ++- cvcpkg/recipes/cvcgl/recipe.yaml | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/cvcpkg/recipes/cvcgl-cuda/recipe.yaml b/cvcpkg/recipes/cvcgl-cuda/recipe.yaml index 64f98079..3e2375b3 100644 --- a/cvcpkg/recipes/cvcgl-cuda/recipe.yaml +++ b/cvcpkg/recipes/cvcgl-cuda/recipe.yaml @@ -4,7 +4,8 @@ recipe: upstream_version: "3.4.0" # Reset to 1 for the 3.3.0 upstream version: cvc_revision counts # revisions OF an upstream version, so a version bump restarts it. - cvc_revision: 1 + # Bumped 1 -> 3 (2 is #522's): ships share/cvcGL/ (the wasm state shim), as cvcgl 3.4.0+cvc.18. + cvc_revision: 3 maintainer: "cvcpkg group" maintainer_email: "info@cvcpkg.org" maintainer_url: "https://cvcpkg.org" diff --git a/cvcpkg/recipes/cvcgl-examples/recipe.yaml b/cvcpkg/recipes/cvcgl-examples/recipe.yaml index b5b04e9e..ab2484e8 100644 --- a/cvcpkg/recipes/cvcgl-examples/recipe.yaml +++ b/cvcpkg/recipes/cvcgl-examples/recipe.yaml @@ -17,7 +17,8 @@ recipe: # Bumped 2 -> 3: build-wasm.sh no longer forces state_exec OFF (the # CVC_STATE_EXEC option is gone; state_exec is always built), so the wasm # gallery carries the program lanes that Ariadne apps run on. - cvc_revision: 3 + # Bumped 3 -> 17 (15 published, 16 is #522's): every wasm demo is a cvcgl_wasm_app; ships glsync. + cvc_revision: 17 maintainer: "cvcpkg group" maintainer_email: "info@cvcpkg.org" maintainer_url: "https://cvcpkg.org" diff --git a/cvcpkg/recipes/cvcgl/recipe.yaml b/cvcpkg/recipes/cvcgl/recipe.yaml index 07e45eb9..585eacb7 100644 --- a/cvcpkg/recipes/cvcgl/recipe.yaml +++ b/cvcpkg/recipes/cvcgl/recipe.yaml @@ -69,7 +69,8 @@ recipe: # _rect partial texture uploads, caster-aware shadow baking (setCastsShadow), and the cheaper # scene-bounds walk deferred under hidden chrome. ABI-stale: SceneGraph/GraphicsNode/ # NullGraphicNode layouts changed, so the published +cvc.14 must not be mixed with this build. - cvc_revision: 15 + # Bumped 15 -> 18 (16 published, 17 is #522's): cvcgl_wasm_app(), the WebGL state shim, FrameYield. + cvc_revision: 18 maintainer: "cvcpkg group" maintainer_email: "info@cvcpkg.org" maintainer_url: "https://cvcpkg.org"