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/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/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); 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/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 [--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 diff --git a/site/scripts/smoke-examples.ts b/site/scripts/smoke-examples.ts index f7b73461e..b6436794d 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', }; @@ -282,11 +280,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) => { @@ -304,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 }[]; @@ -527,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 () => { @@ -584,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 { @@ -606,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; }); @@ -805,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 @@ -859,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'} · ` + @@ -876,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) { @@ -906,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]!; 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'; diff --git a/test/rendering/browser/webgl2-particles.test.ts b/test/rendering/browser/webgl2-particles.test.ts index 9b97e952b..a8a5fbd8c 100644 --- a/test/rendering/browser/webgl2-particles.test.ts +++ b/test/rendering/browser/webgl2-particles.test.ts @@ -18,6 +18,7 @@ import type { Application } from '#core/Application'; import { Color } from '#core/Color'; import { materializeRendererBindings } from '#extensions/materialize'; +import { Rectangle } from '#math/Rectangle'; import { Container } from '#rendering/Container'; import { Geometry } from '#rendering/geometry/Geometry'; import type { RenderNode } from '#rendering/RenderNode'; @@ -401,6 +402,88 @@ describe('WebGL2 ParticleSystem — mesh', () => { }); }); +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();