From 4a6ff7b624b1b394a860ee78d394f51088b65261 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 3 Sep 2026 04:39:45 +0200 Subject: [PATCH 1/7] ci(release): admit a tag by the trust check instead of re-verifying locally The pre-push hook's tag path ran the full verify:release suite, which repeated every lane CI had already run on the push that brought the commit to main and could still disagree with what the Release workflow accepts. scripts/ci/trust.ts now holds that decision once: the tag matches package.json, sits on origin/main and carries a green verdict. The workflow's trust job and the hook both run it, and verify:release, whose only caller was the hook, is gone. Claude-Session: https://claude.ai/code/session_01YRLzcQ9ZasLdUWCtD9fDpM --- .github/workflows/release.yml | 30 ++++++------------- .husky/pre-push | 47 ++++++++++++++++++------------ package.json | 1 - scripts/ci/trust.ts | 55 +++++++++++++++++++++++++++++++++++ scripts/release/RELEASING.md | 7 +++-- 5 files changed, 98 insertions(+), 42 deletions(-) create mode 100644 scripts/ci/trust.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1732a53d4..a7e5b09e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,31 +47,19 @@ jobs: fetch-depth: 0 ref: ${{ env.TARGET_TAG }} + # No install: the trust check is dependency-free TypeScript that node + # strips - the same script the pre-push hook runs before a tag leaves + # the developer's machine. + - uses: actions/setup-node@v6 + with: + node-version: '24.x' + - id: resolve env: GH_TOKEN: ${{ github.token }} run: | - sha="$(git rev-parse "${TARGET_TAG}^{commit}")" - echo "sha=$sha" >> "$GITHUB_OUTPUT" - - pkg="v$(node -p "require('./package.json').version")" - if [ "$TARGET_TAG" != "$pkg" ]; then - echo "::error::Tag '$TARGET_TAG' does not match package.json version '$pkg'." - exit 1 - fi - - if ! git merge-base --is-ancestor "$sha" origin/main; then - echo "::error::$TARGET_TAG ($sha) is not on main. Releases are cut from main only." - exit 1 - fi - - verdict="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${sha}/check-runs?check_name=verdict&per_page=100" \ - --jq '[.check_runs[] | select(.conclusion == "success")] | length')" - if [ "$verdict" = "0" ]; then - echo "::error::No successful 'verdict' check run on $sha. Let CI finish on main before tagging." - exit 1 - fi - echo "$TARGET_TAG = $sha, on main, verdict green." + echo "sha=$(git rev-parse "${TARGET_TAG}^{commit}")" >> "$GITHUB_OUTPUT" + node scripts/ci/trust.ts "$TARGET_TAG" # Build every lockstep package exactly once, then pack/hash/attw/consumers/ # full-zip without rebuilding, and upload the result. Any failure here means diff --git a/.husky/pre-push b/.husky/pre-push index 0a3faecdb..4ab22c8f2 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -17,10 +17,12 @@ # does NOT run is a lane the change cannot affect — a src-only # change never waits for the audio or tilemap-worker browser # lanes. -# Tag pushes: full `verify:release` (mirrors CI's verify job, including -# verify:exports + npm pack --dry-run) so a release tag is -# never published until the same checks CI runs have all -# passed locally. +# Tag pushes: `scripts/ci/trust.ts` - the release workflow's own admission +# check: the tagged commit carries the package.json version, +# sits on origin/main and has a green `verdict`. Nothing is +# re-verified locally because CI already ran every lane on the +# push that brought the commit to main; a tag that would be +# rejected remotely is rejected here first. # # Also, on branch pushes only: a path-gated `@codexo/exojs-bench` typecheck # (see the block below verify:quick — it is intentionally NOT part of @@ -30,6 +32,7 @@ # is_tag_push=0 is_branch_push=0 +pushed_tags="" push_head_sha="" push_base_sha="" null_sha="0000000000000000000000000000000000000000" @@ -38,7 +41,10 @@ while read local_ref local_sha remote_ref remote_sha; do # Deletions carry the null SHA — nothing to verify when removing a ref. [ "$local_sha" = "$null_sha" ] && continue case "$remote_ref" in - refs/tags/*) is_tag_push=1 ;; + refs/tags/*) + is_tag_push=1 + pushed_tags="$pushed_tags ${remote_ref#refs/tags/}" + ;; *) is_branch_push=1 # Only the last pushed branch ref wins when a push carries several @@ -51,16 +57,16 @@ while read local_ref local_sha remote_ref remote_sha; do esac done -# Both verify:quick and verify:release include `typecheck:site`, which -# type-checks the site/examples package against the PUBLISHED entry points -# (package.json `exports` → dist/esm/*.d.ts) rather than against src/. Without -# a build those declaration files don't exist and tsc reports a wall of -# ts(2307) "Cannot find module 'exojs'" — a failure mode that reads like a -# broken import in the pushed change, not like a missing prerequisite. Checking -# for the root entry point's .d.ts up front turns that into one actionable -# line. Deliberately not running the build here: it is slow enough that a hook -# doing it silently would be the thing people bypass. -if [ "$is_tag_push" = "1" ] || [ "$is_branch_push" = "1" ]; then +# verify:quick includes `typecheck:site`, which type-checks the site/examples +# package against the PUBLISHED entry points (package.json `exports` → +# dist/esm/*.d.ts) rather than against src/. Without a build those declaration +# files don't exist and tsc reports a wall of ts(2307) "Cannot find module +# 'exojs'" — a failure mode that reads like a broken import in the pushed +# change, not like a missing prerequisite. Checking for the root entry point's +# .d.ts up front turns that into one actionable line. Deliberately not running +# the build here: it is slow enough that a hook doing it silently would be the +# thing people bypass. +if [ "$is_branch_push" = "1" ]; then if [ ! -e dist/esm/index.d.ts ]; then echo "[pre-push] dist/ is missing — 'typecheck:site' would fail with ts(2307) 'Cannot find module exojs'." echo "[pre-push] It type-checks site/examples against the published entry points, which only exist after a build." @@ -70,9 +76,14 @@ if [ "$is_tag_push" = "1" ] || [ "$is_branch_push" = "1" ]; then fi if [ "$is_tag_push" = "1" ]; then - echo "[pre-push] tag push detected — running full release verification" - npm run verify:release || exit 1 -elif [ "$is_branch_push" = "1" ]; then + echo "[pre-push] tag push detected - checking the tagged commit is releasable" + git fetch --quiet origin main || exit 1 + for tag in $pushed_tags; do + node scripts/ci/trust.ts "$tag" || exit 1 + done +fi + +if [ "$is_branch_push" = "1" ]; then echo "[pre-push] running verify:quick (static CI-parity gates)" npm run verify:quick || exit 1 diff --git a/package.json b/package.json index 2391d583a..67e73de82 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,6 @@ "verify:create-exo-app": "tsx ./scripts/verify-create-exo-app.ts", "sync:example-capabilities": "tsx ./scripts/sync-example-capabilities.ts", "create:package": "tsx scripts/create-package.ts", - "verify:release": "pnpm verify:lockstep && pnpm typecheck && pnpm typecheck:guides && pnpm typecheck:examples && pnpm lint:all && pnpm format:check && pnpm test && pnpm verify:package && pnpm verify:create-exo-app && pnpm site:build", "gate:bench:structural": "pnpm --filter @codexo/exojs-bench gate:structural", "gates": "tsx ./scripts/ci/gates.ts", "lanes": "tsx ./scripts/lanes.ts", diff --git a/scripts/ci/trust.ts b/scripts/ci/trust.ts new file mode 100644 index 000000000..7117cc969 --- /dev/null +++ b/scripts/ci/trust.ts @@ -0,0 +1,55 @@ +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +/** + * Decides whether a release tag may be published without re-running CI: the + * tagged commit must carry the package.json version, sit on `main`, and have a + * green `verdict` check run - the required check that only passes when every + * lane the plan asked for succeeded on the push that brought the commit there. + * + * The Release workflow's `trust` job and the pre-push hook's tag path both run + * this, so what a developer's push accepts is exactly what the workflow + * accepts. Dependency-free and type-strippable like `lanes.ts`: plain `node` + * runs it before any install. + * + * Usage: node scripts/ci/trust.ts [--main ] + * --main defaults to `origin/main`; the caller fetches it first. + */ + +const args = process.argv.slice(2); +const tag = args.find(arg => !arg.startsWith('--')); +const mainIndex = args.indexOf('--main'); +const mainRef = mainIndex === -1 ? 'origin/main' : (args[mainIndex + 1] ?? 'origin/main'); + +const fail: (message: string) => never = message => { + process.stdout.write(`${process.env['GITHUB_ACTIONS'] ? '::error::' : '[trust] '}${message}\n`); + process.exit(1); +}; + +const capture = (command: string, commandArgs: readonly string[]): string | null => { + const result = spawnSync(command, commandArgs, { encoding: 'utf8' }); + return result.status === 0 ? result.stdout.trim() : null; +}; + +if (!tag) fail('usage: node scripts/ci/trust.ts [--main ]'); + +const sha = capture('git', ['rev-parse', `${tag}^{commit}`]); +if (!sha) fail(`Tag '${tag}' does not exist locally.`); + +const version = `v${(JSON.parse(readFileSync('package.json', 'utf8')) as { version: string }).version}`; +if (tag !== version) fail(`Tag '${tag}' does not match package.json version '${version}'.`); + +const onMain = spawnSync('git', ['merge-base', '--is-ancestor', sha, mainRef], { stdio: 'ignore' }); +if (onMain.status !== 0) fail(`${tag} (${sha}) is not on ${mainRef}. Releases are cut from main only.`); + +const repository = process.env['GITHUB_REPOSITORY'] ?? '{owner}/{repo}'; +const greenVerdicts = capture('gh', [ + 'api', + `repos/${repository}/commits/${sha}/check-runs?check_name=verdict&per_page=100`, + '--jq', + '[.check_runs[] | select(.conclusion == "success")] | length', +]); +if (greenVerdicts === null) fail("Could not read the tag commit's check runs - is `gh` installed and authenticated?"); +if (greenVerdicts === '0') fail(`No successful 'verdict' check run on ${sha}. Let CI finish on main before tagging.`); + +process.stdout.write(`${tag} = ${sha}, on ${mainRef}, verdict green.\n`); diff --git a/scripts/release/RELEASING.md b/scripts/release/RELEASING.md index 22fc88633..f35230ea6 100644 --- a/scripts/release/RELEASING.md +++ b/scripts/release/RELEASING.md @@ -59,8 +59,11 @@ version in the tree indefinitely). git push && git push origin refs/tags/vx.y.z ``` -7. **Watch the CI.** The `Release` workflow checks out the **tag commit**, runs the - full CI gate, builds once, packs/hashes/attw/consumer-tests the tarballs, and +7. **Watch the CI.** The pre-push hook already ran `scripts/ci/trust.ts` on the + tag: the tagged commit carries the package.json version, sits on `main` and + has a green `verdict` check. The `Release` workflow repeats that check in its + `trust` job instead of re-running CI, then checks out the **tag commit**, + builds once, packs/hashes/attw/consumer-tests the tarballs, and publishes them directly to the `latest` dist-tag via OIDC in lockstep order (Core first, then the extensions). Every tarball is `attw`-checked; the offline consumer smoke covers all packages **except `@codexo/exojs-react`** (its From 4a8bf519b194e5cb4bc95e397f94300de97587fe Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 3 Sep 2026 04:55:07 +0200 Subject: [PATCH 2/7] fix(site): keep the playground from booting two TypeScript language services The editor mounted before the selected example was known, creating a throwaway model under a placeholder path in the pre-load default language (JavaScript); switching `path` afterwards left that model alive, so Monaco kept a JavaScript and a TypeScript language service and downloaded the 6.9 MB ts.worker chunk twice. With cacheable responses the second request failed with ERR_CACHE_WRITE_FAILURE against the entry the first was still writing, and the resulting Worker `error` event reached Monaco's unexpected error handler as a bare `Event` - the `Uncaught [object Event]` seen on a playground page's first load. Claude-Session: https://claude.ai/code/session_01YRLzcQ9ZasLdUWCtD9fDpM --- site/src/components/EditorCode.tsx | 72 ++++++++++++++++-------------- 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/site/src/components/EditorCode.tsx b/site/src/components/EditorCode.tsx index 371a254e8..661f7ef27 100644 --- a/site/src/components/EditorCode.tsx +++ b/site/src/components/EditorCode.tsx @@ -420,45 +420,51 @@ export const EditorCode = ({
- setEditorValue(value ?? '')} - onMount={onMount} - options={{ - automaticLayout: true, - fixedOverflowWidgets: true, - fontFamily: MONACO_FONT_FAMILY, - fontSize: 14, - glyphMargin: false, - hover: { delay: 250, enabled: true, sticky: true, hidingDelay: 300 }, - lineDecorationsWidth: 8, - lineHeight: 21, - lineNumbersMinChars: 4, - minimap: { enabled: false }, - overviewRulerLanes: 0, - readOnly, - renderValidationDecorations: 'on', - scrollBeyondLastLine: false, - tabSize: 4, - }} - path={getModelUrl(sourcePath, language)} - theme="vs-dark" - value={editorValue} - /> + {/* + Withheld until the selected example is known. Mounting earlier would + create a throwaway model under a placeholder path in whatever + language the not-yet-loaded example defaults to; switching `path` + afterwards leaves that model alive, and Monaco keeps one TypeScript + language service - one 6.9 MB `ts.worker` download - per language + that owns a live model. + */} + {sourcePath === null ? null : ( + setEditorValue(value ?? '')} + onMount={onMount} + options={{ + automaticLayout: true, + fixedOverflowWidgets: true, + fontFamily: MONACO_FONT_FAMILY, + fontSize: 14, + glyphMargin: false, + hover: { delay: 250, enabled: true, sticky: true, hidingDelay: 300 }, + lineDecorationsWidth: 8, + lineHeight: 21, + lineNumbersMinChars: 4, + minimap: { enabled: false }, + overviewRulerLanes: 0, + readOnly, + renderValidationDecorations: 'on', + scrollBeyondLastLine: false, + tabSize: 4, + }} + path={getModelUrl(sourcePath)} + theme="vs-dark" + value={editorValue} + /> + )}
); }; -const getModelUrl = (sourcePath: string | null, language: 'javascript' | 'typescript'): string => { - const defaultExt = language === 'typescript' ? '.ts' : '.js'; - const normalizedPath = (sourcePath ?? `examples/active-example${defaultExt}`).replace(/^\/+/, ''); - return `file:///${normalizedPath}`; -}; +const getModelUrl = (sourcePath: string): string => `file:///${sourcePath.replace(/^\/+/, '')}`; const monacoSeverityToString = (severity: number): EditorDiagnosticSeverity => { if (severity >= 8) return 'error'; From 236d8c01a76d771fdac4b3d6014643721f1b8ae7 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 3 Sep 2026 04:56:32 +0200 Subject: [PATCH 3/7] test(site): serve the example smoke over cacheable responses `no-store` was covering for the playground booting two TypeScript language services and fetching the 6.9 MB ts.worker chunk twice, whose second request failed against the cache entry the first was still writing. That is fixed, so the harness can serve what a static host serves and exercise the browser cache visitors actually get. Claude-Session: https://claude.ai/code/session_01YRLzcQ9ZasLdUWCtD9fDpM --- site/scripts/smoke-examples.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/site/scripts/smoke-examples.ts b/site/scripts/smoke-examples.ts index f7b73461e..41298c7ea 100644 --- a/site/scripts/smoke-examples.ts +++ b/site/scripts/smoke-examples.ts @@ -282,11 +282,11 @@ const startServer = (root: string): Promise<{ port: number; server: Server }> => return; } - // Uncacheable on purpose: with cacheable responses the playground shell - // raised a bare `Event` on its very first load - Monaco fetches its - // TypeScript worker twice in quick succession, and the second load - // fails against the entry the first is still writing. - res.writeHead(200, { 'Content-Type': file.type, 'Cache-Control': 'no-store' }); + // Cacheable on purpose, matching what a static host serves: a browser + // cache is part of what the shell has to survive, and serving + // `no-store` here would hide any regression that only shows up once + // responses can be cached. + res.writeHead(200, { 'Content-Type': file.type, 'Cache-Control': 'public, max-age=0, must-revalidate' }); res.end(file.body); }) .catch((error: unknown) => { From 02b9f0e243af2fc16afd9f1490423550a0e8ded7 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 3 Sep 2026 05:13:43 +0200 Subject: [PATCH 4/7] fix(particles): rebind the system texture on every WebGL2 draw WebGl2ParticleRenderer bound the system texture only when its identity changed, which for a scene with a single particle system meant exactly once - while the handle from `loader.get(...)` was still an empty placeholder. The image landed a few frames later and bumped the texture version, but nothing ever asked the backend to look again, so the system kept drawing its quads against blank pixels for the rest of its life. Scenes with two systems on different textures alternated the identity every frame and were healed by accident, which is why some particle examples rendered and others stayed black. The same memo could also hold a stale blend mode after another renderer changed it. Both are now offered to the backend on every system: it owns the live GL state, already collapses a redundant bind, and is the only place that sees a texture whose payload changed under a stable identity. Claude-Session: https://claude.ai/code/session_01YRLzcQ9ZasLdUWCtD9fDpM --- CHANGELOG.md | 12 +++ .../src/renderers/WebGl2ParticleRenderer.ts | 25 ++---- .../browser/webgl2-particles.test.ts | 83 +++++++++++++++++++ 3 files changed, 103 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce280f9c8..30189be70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ## [Unreleased] +### Fixed + +- **Particle systems on WebGL2 pick up a texture whose payload arrives after + the first draw.** `WebGl2ParticleRenderer` bound the system's texture only + when its identity changed, which for a single-system scene meant exactly + once - while the handle from `loader.get(...)` was still empty. The image + landed a few frames later and never reached the GPU, so the system simulated + and drew its quads against blank pixels for the rest of its life. The same + memo could hold a stale blend mode after another renderer changed it. Both + are now offered to the backend on every system, which already collapses a + redundant bind and is the only holder of the live GL state. + ## [0.16.1] - 2026-09-02 ### Fixed diff --git a/packages/exojs-particles/src/renderers/WebGl2ParticleRenderer.ts b/packages/exojs-particles/src/renderers/WebGl2ParticleRenderer.ts index ce1d41d7b..9624f5456 100644 --- a/packages/exojs-particles/src/renderers/WebGl2ParticleRenderer.ts +++ b/packages/exojs-particles/src/renderers/WebGl2ParticleRenderer.ts @@ -1,6 +1,4 @@ import type { AttributeType, GeometryUsage, Material, Topology } from '@codexo/exojs'; -import type { BlendModes } from '@codexo/exojs/renderer-sdk'; -import type { Texture } from '@codexo/exojs/renderer-sdk'; import type { View } from '@codexo/exojs/renderer-sdk'; import type { WebGl2Backend } from '@codexo/exojs/renderer-sdk'; import { BufferTypes, BufferUsage, RenderingPrimitives } from '@codexo/exojs/renderer-sdk'; @@ -132,8 +130,6 @@ export class WebGl2ParticleRenderer extends AbstractWebGl2Renderer { }); }); +describe('WebGL2 ParticleSystem — texture mutation', () => { + test('a payload that arrives after the first draw reaches the GPU on the next one', async () => { + const backend = await createBackend(); + // What `loader.get(...)` hands a system: an empty handle whose pixels are + // installed once the download finishes, which is usually after the system + // has already been drawn at least once. + const texture = new Texture(); + const root = new Container(); + const system = new ParticleSystem(texture, { capacity: 4 }); + + try { + // The quad's extent comes from the frame, which an empty handle cannot + // supply yet; setting it explicitly keeps this about the upload alone. + system.setTextureFrame(new Rectangle(0, 0, 16, 16)); + system.emit(); + system.setPosition(32, 32); + root.addChild(system); + + render(backend, root); + + const payload = document.createElement('canvas'); + + payload.width = 16; + payload.height = 16; + + const ctx = payload.getContext('2d')!; + + ctx.fillStyle = '#ff0000'; + ctx.fillRect(0, 0, 16, 16); + + texture.setSource(payload); + + render(backend, root); + + expectPixelNear(readWebGl2Pixel(backend, 32, 32), [255, 0, 0, 255]); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('an in-place edit to the texture reaches the GPU on the next draw', async () => { + const backend = await createBackend(); + const source = document.createElement('canvas'); + + source.width = 16; + source.height = 16; + + const ctx = source.getContext('2d')!; + + ctx.fillStyle = '#0000ff'; + ctx.fillRect(0, 0, 16, 16); + + const texture = new Texture(source); + const root = new Container(); + const system = new ParticleSystem(texture, { capacity: 4 }); + + try { + system.emit(); + system.setPosition(32, 32); + root.addChild(system); + + render(backend, root); + + expectPixelNear(readWebGl2Pixel(backend, 32, 32), [0, 0, 255, 255]); + + ctx.fillStyle = '#ff0000'; + ctx.fillRect(0, 0, 16, 16); + texture.updateSource(); + + render(backend, root); + + expectPixelNear(readWebGl2Pixel(backend, 32, 32), [255, 0, 0, 255]); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); +}); + describe('WebGL2 ParticleSystem — mesh mutation', () => { test('an in-place edit to the mesh reaches the GPU on the next draw', async () => { const backend = await createBackend(); From 6b28b5d25a2c45afa0fae0353ef2ac977b3277e1 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 3 Sep 2026 05:14:19 +0200 Subject: [PATCH 5/7] fix(examples): budget the GPU particles example from the backend that came up `app.backend` before `start()` is the requested backend, not the one that initialised: a WebGPU request that finds no adapter falls back to WebGL2 during startup. Read at module scope, the example therefore budgeted 320 000 particles at 75 000 per second onto the CPU integrator whenever WebGPU was requested and unavailable - sixteen times the fallback budget its own note describes. The choice moves into `init()`, which the scene director runs after the backend is settled. Claude-Session: https://claude.ai/code/session_01YRLzcQ9ZasLdUWCtD9fDpM --- examples/particles/gpu-particles.js | 29 +++++++++++++++++---------- examples/particles/gpu-particles.ts | 31 +++++++++++++++++++---------- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/examples/particles/gpu-particles.js b/examples/particles/gpu-particles.js index 7a290e57f..513203cfe 100644 --- a/examples/particles/gpu-particles.js +++ b/examples/particles/gpu-particles.js @@ -2,18 +2,34 @@ import { Application, Color, FixedResolutionCanvasSizing, RenderBackendType, Scene, Vector } from '@codexo/exojs'; import { AlphaFadeOverLifetime, ApplyForce, ConeDirection, Constant, particlesExtension, ParticleSystem, Range, RateSpawn } from '@codexo/exojs-particles'; import { mountControls } from '@examples/runtime'; +// WebGPU runs the whole simulation on a compute shader, so it sustains hundreds +// of thousands of particles smoothly; WebGL2 falls back to a CPU integrator, so +// it uses a much smaller budget to stay at a comfortable frame rate. Both stay +// well within what a modern machine handles without lag. +const budgets = { + webgpu: { capacity: 320_000, rate: 75_000 }, + webgl2: { capacity: 20_000, rate: 3_000 }, +}; class GpuParticlesScene extends Scene { system; hud; + capacity = 0; init() { const app = this.app; const { width, height } = app; - this.system = new ParticleSystem(this.loader.get('image/particle-light.png'), { capacity: CAPACITY }); + // Read here rather than beside the Application: a WebGPU request that finds + // no adapter falls back to WebGL2 during start(), so before the scene is + // activated the backend can still be the requested one rather than the one + // that came up - and these two budgets differ sixteenfold. + const isWebGpu = app.backend.backendType === RenderBackendType.WebGpu; + const { capacity, rate } = isWebGpu ? budgets.webgpu : budgets.webgl2; + this.capacity = capacity; + this.system = new ParticleSystem(this.loader.get('image/particle-light.png'), { capacity }); this.systems.add(this.system); this.system.setPosition(width / 2, height - 80); this.system.addSpawnModule( new RateSpawn({ - rate: new Constant(RATE), + rate: new Constant(rate), lifetime: new Range(2.6, 3.8), velocity: new ConeDirection(-Math.PI / 2, Math.PI / 4, 120, 340), scale: new Constant(new Vector(0.22, 0.22)), @@ -30,7 +46,7 @@ class GpuParticlesScene extends Scene { } update(_delta) { const backend = this.system.gpuMode ? 'WebGPU (GPU compute)' : 'WebGL2 (CPU fallback)'; - this.hud.setStatus(`${this.system.aliveCount.toLocaleString()} live / ${CAPACITY.toLocaleString()} cap · ${backend}`); + this.hud.setStatus(`${this.system.aliveCount.toLocaleString()} live / ${this.capacity.toLocaleString()} cap · ${backend}`); } draw(context) { context.render(this.system); @@ -50,11 +66,4 @@ const app = new Application({ }, extensions: [particlesExtension], }); -// WebGPU runs the whole simulation on a compute shader, so it sustains hundreds -// of thousands of particles smoothly; WebGL2 falls back to a CPU integrator, so -// it uses a much smaller budget to stay at a comfortable frame rate. Both stay -// well within what a modern machine handles without lag. -const isWebGpu = app.backend.backendType === RenderBackendType.WebGpu; -const CAPACITY = isWebGpu ? 320_000 : 20_000; -const RATE = isWebGpu ? 75_000 : 3_000; await app.start(GpuParticlesScene); diff --git a/examples/particles/gpu-particles.ts b/examples/particles/gpu-particles.ts index 7994f628d..b1df61bb2 100644 --- a/examples/particles/gpu-particles.ts +++ b/examples/particles/gpu-particles.ts @@ -2,20 +2,37 @@ import { Application, Color, FixedResolutionCanvasSizing, RenderBackendType, typ import { AlphaFadeOverLifetime, ApplyForce, ConeDirection, Constant, particlesExtension, ParticleSystem, Range, RateSpawn } from '@codexo/exojs-particles'; import { mountControls } from '@examples/runtime'; +// WebGPU runs the whole simulation on a compute shader, so it sustains hundreds +// of thousands of particles smoothly; WebGL2 falls back to a CPU integrator, so +// it uses a much smaller budget to stay at a comfortable frame rate. Both stay +// well within what a modern machine handles without lag. +const budgets = { + webgpu: { capacity: 320_000, rate: 75_000 }, + webgl2: { capacity: 20_000, rate: 3_000 }, +}; + class GpuParticlesScene extends Scene { private system!: ParticleSystem; private hud!: ReturnType; + private capacity = 0; override init(): void { const app = this.app; const { width, height } = app; + // Read here rather than beside the Application: a WebGPU request that finds + // no adapter falls back to WebGL2 during start(), so before the scene is + // activated the backend can still be the requested one rather than the one + // that came up - and these two budgets differ sixteenfold. + const isWebGpu = app.backend.backendType === RenderBackendType.WebGpu; + const { capacity, rate } = isWebGpu ? budgets.webgpu : budgets.webgl2; - this.system = new ParticleSystem(this.loader.get('image/particle-light.png'), { capacity: CAPACITY }); + this.capacity = capacity; + this.system = new ParticleSystem(this.loader.get('image/particle-light.png'), { capacity }); this.systems.add(this.system); this.system.setPosition(width / 2, height - 80); this.system.addSpawnModule( new RateSpawn({ - rate: new Constant(RATE), + rate: new Constant(rate), lifetime: new Range(2.6, 3.8), velocity: new ConeDirection(-Math.PI / 2, Math.PI / 4, 120, 340), scale: new Constant(new Vector(0.22, 0.22)), @@ -35,7 +52,7 @@ class GpuParticlesScene extends Scene { override update(_delta: Seconds): void { const backend = this.system.gpuMode ? 'WebGPU (GPU compute)' : 'WebGL2 (CPU fallback)'; - this.hud.setStatus(`${this.system.aliveCount.toLocaleString()} live / ${CAPACITY.toLocaleString()} cap · ${backend}`); + this.hud.setStatus(`${this.system.aliveCount.toLocaleString()} live / ${this.capacity.toLocaleString()} cap · ${backend}`); } override draw(context: RenderingContext): void { @@ -58,12 +75,4 @@ const app = new Application({ extensions: [particlesExtension], }); -// WebGPU runs the whole simulation on a compute shader, so it sustains hundreds -// of thousands of particles smoothly; WebGL2 falls back to a CPU integrator, so -// it uses a much smaller budget to stay at a comfortable frame rate. Both stay -// well within what a modern machine handles without lag. -const isWebGpu = app.backend.backendType === RenderBackendType.WebGpu; -const CAPACITY = isWebGpu ? 320_000 : 20_000; -const RATE = isWebGpu ? 75_000 : 3_000; - await app.start(GpuParticlesScene); From 23ed1a3e2fefbf081260a0a18d7153c60b94e801 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 3 Sep 2026 05:14:36 +0200 Subject: [PATCH 6/7] test(site): stop skipping the particle examples on a software rasteriser Both entries recorded the symptom of a renderer defect, not a limit of the environment: the WebGL2 particle renderer never re-bound a texture whose payload arrived after the first draw, which left every scene with a single particle system black on any WebGL2 path, software rasteriser or not. With that fixed, all six particle examples pass the harness under `--use-angle=swiftshader`, so the skips would only hide the next regression. The backend-comparison entry stays: that one is genuinely a throughput limit. Claude-Session: https://claude.ai/code/session_01YRLzcQ9ZasLdUWCtD9fDpM --- site/scripts/smoke-examples.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/site/scripts/smoke-examples.ts b/site/scripts/smoke-examples.ts index 41298c7ea..e9afbff5a 100644 --- a/site/scripts/smoke-examples.ts +++ b/site/scripts/smoke-examples.ts @@ -82,8 +82,6 @@ const BLANK_FAILURE = 'canvas rendered but appears blank - one uniform color, no * WebGPU-adapter skip: the environment's limit, not the example's. */ const SOFTWARE_RASTERISER_LIMITED: Readonly> = { - 'particles/gpu-particles.js': 'the CPU particle fallback paints nothing through SwiftShader (reproduced locally with --use-angle=swiftshader)', - 'particles/custom-wgsl-module.js': 'the CPU particle fallback paints nothing through SwiftShader (reproduced locally with --use-angle=swiftshader)', 'performance/backend-comparison.js': '2200 moving sprites plus the debug overlay saturate a software-rasterised main thread; the harness cannot reach the page', }; From beec588335896f793ec48944ef711f028f777fce Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 3 Sep 2026 05:20:45 +0200 Subject: [PATCH 7/7] test(site): withhold navigator.gpu itself for the smoke's WebGL2 lane Leaving out --enable-unsafe-webgpu no longer suppresses the adapter on current Chromium, so the WebGL2 lane ran with WebGPU present and never exercised the renderer path it exists for - which is how a WebGL2-only particle defect survived it. The lane now overrides navigator.gpu in every frame before any page script runs, for the probe and the pool. Claude-Session: https://claude.ai/code/session_01YRLzcQ9ZasLdUWCtD9fDpM --- site/scripts/smoke-examples.ts | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/site/scripts/smoke-examples.ts b/site/scripts/smoke-examples.ts index e9afbff5a..b6436794d 100644 --- a/site/scripts/smoke-examples.ts +++ b/site/scripts/smoke-examples.ts @@ -302,6 +302,16 @@ const startServer = (root: string): Promise<{ port: number; server: Server }> => }); }; +// Runs in every frame before any page script. Overriding the prototype rather +// than leaving out `--enable-unsafe-webgpu`: current Chromium exposes the +// adapter without that flag, so only the page-level property is a reliable way +// to make the playground see no WebGPU at all. A data property, not a getter: +// the function is serialised into the page by source, where a nested arrow +// would drop its transpiler helpers. +const withholdWebGpu = (): void => { + Object.defineProperty(Navigator.prototype, 'gpu', { configurable: true, value: undefined }); +}; + const captureErrors = (): void => { interface SmokeWindow { __SMOKE_ERRORS__?: { message: string }[]; @@ -525,11 +535,15 @@ interface Graphics { softwareRasteriser: boolean; } -const probeGraphics = async (browser: Browser, baseUrl: string): Promise => { +const probeGraphics = async (browser: Browser, baseUrl: string, forceWebGl2: boolean): Promise => { // Navigate to a real origin rather than about:blank - some Chromium builds // refuse to expose navigator.gpu on opaque origins. const page = await browser.newPage(); try { + if (forceWebGl2) { + await page.addInitScript(withholdWebGpu); + } + await page.goto(`${baseUrl}/preview.html`, { waitUntil: 'domcontentloaded', timeout: 10_000 }); return await page.evaluate(async () => { const webgpu = await (async () => { @@ -582,7 +596,7 @@ interface ContextPool { * single context share a renderer process, so a shared pool would put every * concurrent example on one main thread. */ -const createContextPool = (browser: Browser, colorScheme: 'light' | 'dark'): ContextPool => { +const createContextPool = (browser: Browser, colorScheme: 'light' | 'dark', forceWebGl2: boolean): ContextPool => { const contexts = new Map>(); return { @@ -604,6 +618,9 @@ const createContextPool = (browser: Browser, colorScheme: 'light' | 'dark'): Con // green, so every example that does not ask for touch keeps exactly the // context it had. pending = browser.newContext({ viewport: { width: 1600, height: 900 }, colorScheme, hasTouch }).then(async context => { + if (forceWebGl2) { + await context.addInitScript(withholdWebGpu); + } await context.addInitScript(captureErrors); return context; }); @@ -803,7 +820,7 @@ const main = async (): Promise => { } const browserName = values.browser === 'firefox' ? 'firefox' : 'chromium'; - // Withholding the WebGPU flag rather than passing a backend preference: the + // Withholding `navigator.gpu` rather than passing a backend preference: the // playground picks its renderer from what the browser actually reports, so // an adapter that is never offered is the only way to make that choice from // outside the page. Examples that declare `webgpu` as a required capability @@ -857,11 +874,11 @@ const main = async (): Promise => { browser = await chromium.launch({ channel: 'chromium', headless, - args: forceWebGl2 ? ['--enable-webgl', '--ignore-gpu-blocklist'] : ['--enable-webgl', '--enable-unsafe-webgpu', '--ignore-gpu-blocklist'], + args: ['--enable-webgl', '--enable-unsafe-webgpu', '--ignore-gpu-blocklist'], }); } - const graphics = await probeGraphics(browser, baseUrl); + const graphics = await probeGraphics(browser, baseUrl, forceWebGl2); const webgpuAvailable = graphics.webgpu; console.log( `[smoke] ${entries.length} example(s) · ${browserName} · ${headless ? 'headless' : 'headed'} · ` + @@ -874,7 +891,7 @@ const main = async (): Promise => { let cursor = 0; const worker = async (): Promise => { - const pool = createContextPool(browser, colorScheme); + const pool = createContextPool(browser, colorScheme, forceWebGl2); try { while (true) { @@ -904,7 +921,7 @@ const main = async (): Promise => { // blank remains a failure; thrown errors and missing canvases are never // softened by this retry. const blankFailureIndexes = results.flatMap((result, index) => (result.status === 'failed' && result.note === BLANK_FAILURE ? [index] : [])); - const retryPool = createContextPool(browser, colorScheme); + const retryPool = createContextPool(browser, colorScheme, forceWebGl2); for (const index of blankFailureIndexes) { const entry = entries[index]!;