diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffe797ba9..22b0ed1f8 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 b1d826d51..ac15b5d16 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/bindings/pycvc/pycvc_gl.i b/bindings/pycvc/pycvc_gl.i index 0882c9ee9..b6a7d63e7 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/bindings/pycvc/wasm/link-host.sh b/bindings/pycvc/wasm/link-host.sh index 4ac2b04be..88f857907 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 \ diff --git a/cvcpkg/recipes/cvcgl-cuda/recipe.yaml b/cvcpkg/recipes/cvcgl-cuda/recipe.yaml index 71a0a9cb5..3e2375b35 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" @@ -74,6 +75,7 @@ package: - lib/libcvcGL* - lib/cmake/cvcGL/ - include/cvc/gl/ + - share/cvcGL/ cmake_packages: - name: cvcGL targets: diff --git a/cvcpkg/recipes/cvcgl-examples/build.sh b/cvcpkg/recipes/cvcgl-examples/build.sh index c02086d97..2081eb821 100755 --- a/cvcpkg/recipes/cvcgl-examples/build.sh +++ b/cvcpkg/recipes/cvcgl-examples/build.sh @@ -97,6 +97,12 @@ cmake --install "$CVC_BUILD_DIR" --component cvcgl-examples install -d "$CVC_INSTALL_DIR/share/cvcgl-examples" install -m 755 "$CVC_SOURCE_DIR/src/cvcGL/examples/wasm/serve.py" \ "$CVC_INSTALL_DIR/share/cvcgl-examples/serve.py" +# The Firefox WebGL sync census (a dev tool, off unless serve.py --glsync): serve.py finds it in +# devtools/ next to itself. The launcher below does not pass --glsync, so pages are unchanged. +install -d "$CVC_INSTALL_DIR/share/cvcgl-examples/devtools" +install -m 644 "$CVC_SOURCE_DIR/src/cvcGL/wasm/devtools/glsync.js" \ + "$CVC_SOURCE_DIR/src/cvcGL/wasm/devtools/GLSYNC.md" \ + "$CVC_INSTALL_DIR/share/cvcgl-examples/devtools/" cat > "$CVC_INSTALL_DIR/bin/cvcgl-examples-web" <<'LAUNCHER' #!/usr/bin/env bash diff --git a/cvcpkg/recipes/cvcgl-examples/recipe.yaml b/cvcpkg/recipes/cvcgl-examples/recipe.yaml index b5b04e9eb..ab2484e8e 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 7e17eb38a..585eacb7e 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" @@ -153,6 +154,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/docs/CVCGL_WASM.md b/docs/CVCGL_WASM.md new file mode 100644 index 000000000..029f0d308 --- /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/inc/cvc/gl/FrameYield.h b/inc/cvc/gl/FrameYield.h new file mode 100644 index 000000000..931f28a9f --- /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 e518f1b80..c53475266 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 af2575df7..ff6b5f33c 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 d5e171acc..b408fa8d8 100644 --- a/src/cvcGL/CMakeLists.txt +++ b/src/cvcGL/CMakeLists.txt @@ -210,6 +210,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) @@ -245,10 +275,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" @@ -258,8 +292,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() @@ -474,6 +513,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) @@ -664,6 +710,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() + # ── GL tests: one at a time ── # Every cvcGL test that renders through a GL context takes the CTest resource # lock cvcgl_gl_context, so ctest never runs two of them at once; the headless @@ -682,7 +773,8 @@ list(REMOVE_ITEM _cvcgl_gl_tests cvcgl_node_outlives_scene cvcgl_texture cvcgl_texture_zerocopy cvcgl_stream_texture cvcgl_state_publisher cvcgl_nav_stats_publish cvcgl_camera cvcgl_track_parity cvcgl_fps_hud cvcgl_stage_setters - cvcgl_streaming_races cvcgl_sdl_input) + cvcgl_streaming_races cvcgl_sdl_input + cvcgl_webgl_state_shadow cvcgl_glsync) set_tests_properties(${_cvcgl_gl_tests} PROPERTIES RESOURCE_LOCK cvcgl_gl_context) # ── examples (opt-in, OFF by default) ── diff --git a/src/cvcGL/SceneRenderer.cpp b/src/cvcGL/SceneRenderer.cpp index ad4750fbf..a3db22d1a 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 c52ab1f1b..31654659b 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/cvcGLConfig.cmake.in b/src/cvcGL/cvcGLConfig.cmake.in index ab283308d..16d2fab64 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 @@ -59,4 +65,16 @@ endif() 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/examples/CMakeLists.txt b/src/cvcGL/examples/CMakeLists.txt index 82ee20454..6fdac1a48 100644 --- a/src/cvcGL/examples/CMakeLists.txt +++ b/src/cvcGL/examples/CMakeLists.txt @@ -111,8 +111,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 @@ -138,7 +149,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. @@ -167,10 +178,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 668222140..d9c5a4b19 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 524ec957e..ab6ab7473 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 a18ad66e1..48fc972ca 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 0b1868c4f..cc5153522 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 6e8d3a615..8d8590f4a 100644 --- a/src/cvcGL/examples/nav_city_drive.cpp +++ b/src/cvcGL/examples/nav_city_drive.cpp @@ -1422,6 +1422,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 568fb68d8..dccf1e3d5 100644 --- a/src/cvcGL/examples/nav_city_swarm.cpp +++ b/src/cvcGL/examples/nav_city_swarm.cpp @@ -1431,6 +1431,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 8768f5862..131ae4e9d 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 982e707c0..96dd2e44a 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 0ffd93d32..18388a6c8 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 f6e770de9..bf656950e 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); diff --git a/src/cvcGL/examples/wasm/serve.py b/src/cvcGL/examples/wasm/serve.py index aee240eca..cda21cc24 100755 --- a/src/cvcGL/examples/wasm/serve.py +++ b/src/cvcGL/examples/wasm/serve.py @@ -11,33 +11,185 @@ The headers are harmless for the single-threaded build, so this server is safe to use for either variant. -Usage: serve.py [-d DIRECTORY] [PORT] +Profiling (all off by default; without them the server is exactly the plain +isolated one above): + + --glsync[=PATH] insert 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/test/cvcgl_frame_yield.cpp b/src/cvcGL/test/cvcgl_frame_yield.cpp new file mode 100644 index 000000000..0a6afcc56 --- /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; +} 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 000000000..3bc370f2b --- /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/cvcGLWasm.cmake b/src/cvcGL/wasm/cvcGLWasm.cmake new file mode 100644 index 000000000..335f6b67e --- /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() diff --git a/src/cvcGL/wasm/devtools/GLSYNC.md b/src/cvcGL/wasm/devtools/GLSYNC.md new file mode 100644 index 000000000..911b9938d --- /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" 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" diff --git a/src/cvcGL/wasm/webgl_state_shadow.js b/src/cvcGL/wasm/webgl_state_shadow.js new file mode 100644 index 000000000..d16c27ca7 --- /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); +})();