feat(loader): "shader" asset type — preloaded, precompiled ShaderEffects + clone()#1534
Conversation
Custom shaders become first-class preloadable assets (the Godot
shader-as-resource model, via the loader's standard parser registry):
- `{ name, type: "shader", src }` loads a GLSL fragment body (the
ShaderEffect convention: uniforms + `vec4 apply(vec4, vec2)`) from a URL
or data: URI; inline GLSL ships via the `data` field (the inline-TMX
convention). Sources are COMPILED AT LOAD TIME against the renderer
captured on VIDEO_INIT (compressed-textures pattern), so the GLSL compile
cost lands in the loading screen and compile errors fail the load carrying
the asset's name. Preloading before video.init falls back to lazy
compilation on first access.
- `loader.getShader(name)` returns the SHARED, loader-owned instance —
the same object on every call, flagged `shared = true` so no renderable
cleanup can auto-destroy it. Freed only by loader.unload()/unloadAll(),
which destroy the GL program (not just the cache entry).
- `ShaderEffect.clone()` compiles an independent, caller-owned copy for
per-renderable uniform values: copies the recipe (fragment source,
precision, uniform values via the context-loss replay cache, setTexture
bindings with their own GL uploads) but never ownership — the clone's
`shared` flag always resets to false, so it is auto-destroyed with the
renderable it is assigned to unless explicitly re-shared.
The Water Refraction example now ships its fragment as a preloaded shader
asset (getShader + unload on teardown). 10 new tests: precompile/shared
identity, data: URI load, compile-error naming, unload-destroys, clone
program independence + uniform copy + shared reset + destroy independence
+ destroyed-clone throw.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A
There was a problem hiding this comment.
Pull request overview
This PR adds first-class support for preloading custom GLSL fragment shaders as loader assets (type: "shader"), returning a shared, loader-owned ShaderEffect via loader.getShader(name), and introduces ShaderEffect.clone() to create independent, caller-owned copies for per-renderable uniforms. It also updates the Water Refraction example to consume a preloaded shader asset and documents the new API in the changelog.
Changes:
- Add a new
"shader"loader parser + cache entry, plusloader.getShader()and shader-awareunload/unloadAllbehavior. - Extend
ShaderEffectwithclone()(copies recipe + uniform cache + extra textures, but not ownership) and store constructor “recipe” fields for cloning. - Add a comprehensive shader loader/clone test suite and migrate the Water Refraction example to preloaded shader usage.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/melonjs/tests/shader-loader.spec.js | Adds tests for shader asset preload/compile/unload semantics and ShaderEffect.clone() behavior. |
| packages/melonjs/src/video/webgl/shadereffect.js | Stores shader “recipe” fields and adds clone() to build independent programs and replay cached uniforms/textures. |
| packages/melonjs/src/loader/parsers/shader.js | Introduces the "shader" asset parser and compile-at-load-time behavior when a renderer is available. |
| packages/melonjs/src/loader/loader.js | Registers the shader parser, adds shader cache unloading, and introduces the public loader.getShader() accessor. |
| packages/melonjs/src/loader/cache.js | Adds shaderList cache storage for { source, effect } entries. |
| packages/melonjs/CHANGELOG.md | Documents the new shader asset type, loader.getShader(), and ShaderEffect.clone(). |
| packages/examples/src/examples/waterRefraction/ExampleWaterRefraction.tsx | Updates the example to preload and retrieve the refraction shader via loader.getShader() and unload it on teardown. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
video.init() is an inherent precondition of the preload flow — the loading screen itself needs the renderer, and a failed init throws and halts before any preload runs. So the "preloaded before video.init → compile lazily on first getShader" fallback was dead complexity: shaders now ALWAYS compile at load time, and a missing renderer fails the load with a clear, asset- named error. shaderList collapses to a plain name → ShaderEffect map like every other loader cache, and getShader becomes a simple lookup (mirroring getGLTF) — which also removes the un-named-error lazy path Copilot flagged. Also per review: the spec's "flash-inline" asset is now unloaded at the end of its test (no GL program left in global loader state across the suite). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A
|
Both points addressed in 0925717 — the first one at the root rather than as suggested: Lazy-compile error loses the asset name (loader.js:1049). Rather than wrapping the error, the lazy-compile path is removed entirely: Test leaves |
GLShader gains its own clone() — ShaderEffect wraps a GLShader by composition (it is NOT a subclass), so each class carries a clone with the same recipe-not-ownership semantics: a new GL program compiled from the stored vertex+fragment sources (kept since 19.6 for context-loss recovery), every cached uniform value replayed onto the copy, and the clone's `shared` flag always reset to false (caller-owned). Cloning a destroyed shader throws. 3 new tests (program independence + gl.getUniform replay readback, shared reset + destroy independence, destroyed-clone throw). Also adds tests/shader-canvas.spec.js proving the Canvas-fallback story for preloaded shader assets end-to-end: loading succeeds (warn only), getShader returns an inert stub born with enabled=false (class field — filtered from every post-effect pass), setUniform/setTime/setTexture no-op safely, clone yields another inert caller-owned stub, and unload destroys cleanly. A video.AUTO game falling back to Canvas keeps running, just unshaded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A
Pre-merge review of #1534 confirmed two real bugs; both fixed with adversarial WEBGL_lose_context regression tests: - ShaderEffect.clone() crashed with a TypeError when called during a lost context: the clone's inner GLShader compiles deferred (uniforms === null) and the replay guard read null.uTime. The guard is now suspension-aware (defer to the suspended setUniform cache; the restore replay re-validates) — matching the bar set by the adversarial spec's "addPostEffect during the suspended window does not throw". GLShader.clone() gets the same guard, closing the inverse edge (a name cached during an earlier suspended window throwing on a live clone's replay). - Concurrent double-load of the same shader name (e.g. the name listed twice in one preload manifest) compiled twice; the second store orphaned a live GL program permanently pinned by its context-loss event subscriptions (never GC-eligible, unreachable by unload, recompiling itself on every restore). The fetch path now re-checks the cache after resolving and skips compilation when a concurrent load already stored. Also from review: a clear error for a shader asset with neither `src` nor inline `data` (was fetch("undefined")); example comment warns the unload- in-onDestroyEvent pattern is for single-stage teardown only (stage switches re-fire it); CHANGELOG states precisely which form rejects with the asset name. New tests: concurrent-load race → one shared effect; clone mid-loss does not throw + replays after restore; preloaded asset survives a full lose/restore cycle (same instance, re-enabled, uniform replayed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A
|
Ran a final adversarial review before merge — it confirmed two real bugs, both fixed in 60e5310 with WEBGL_lose_context regression tests:
Also from review: clear error for an asset with neither New adversarial tests: concurrent-race → one shared effect; clone mid-loss no-throw + post-restore uniform replay (verified via Review also flagged pre-existing |
Three long-latent bugs in the unload path, surfaced by #1534's pre-merge review: - unloadAll's video-cache sweep dispatched entries as type "json" (copy- paste from the loop above), so the jsonList lookup missed and video assets survived every unloadAll. - unloadAll's font-cache sweep used type "font" — an unknown type that hit unload()'s default throw, ABORTING the whole unloadAll mid-way (obj/mtl/ gltf/shader/audio sweeps never ran) whenever any fontface was loaded. (Its comment also said "video resources" — same copy-paste.) - The fontface unload case guarded on `typeof typeof globalThis.document` — always "string", never "undefined" — so the DOM check was inert. Regression test seeds videoList + fontList (real FontFace) and asserts unloadAll doesn't throw and empties both — verified to FAIL against the old code and pass with the fix. Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Custom shaders become first-class preloadable assets — the Godot shader-as-resource model, built on the loader's standard parser registry.
type: "shader"— compile at load timeThe source is a GLSL fragment body following the
ShaderEffectconvention (uniformdeclarations +vec4 apply(vec4 color, vec2 uv)). It is compiled during preloading against the renderer captured onVIDEO_INIT(the compressed-textures pattern) — so the GLSL compile cost lands in the loading screen instead of mid-gameplay, and a compile error fails the load with the asset's name.video.init()is an inherent precondition of the preload flow (the loading screen itself needs the renderer, and a failed init throws and halts) — loading a shader without it fails the load with a clear, asset-named error.loader.getShader(name)— a SHARED instanceReturns the same, loader-owned
ShaderEffecton every call (shared = true, dogfooding #1531):loader.unload()/loader.unloadAll(), which destroy the GL program, not just the cache entry.ShaderEffect.clone()— private copies for per-renderable uniformsCompiles an independent, caller-owned copy. It copies the recipe — fragment source, precision, every uniform value set so far (replayed from the context-loss uniform cache), and
setTexturebindings (each clone lazily uploads + owns its own GL texture/unit reservation) — but never the ownership: the clone'ssharedflag is always reset tofalse, so it is auto-destroyed with the renderable it is assigned to, exactly like a hand-constructed effect (setshared = trueyourself to opt out). Cloning a destroyed effect throws.Also in this PR
unload/unloadAllgain a"shader"case (destroys the shared GL program);setBaseURLsupports"shader"; Asset typedef documents the type + inlinedata.getShader+loader.unloadon stage teardown) — verified live via Playwright, pixel-equivalent to the previous inline construction.Tests
tests/shader-loader.spec.js(10 tests): precompiled+shared identity across calls, data: URI loading, compile-error carries asset name, unload destroys + double-unload false, unknown-name null, clone program independence + uniform-value copy (read back viagl.getUniform) +sharedreset + extra-texture copy + destroy independence + destroyed-clone throw.Full suite 4557 passed / 0 failed;
eslint src tests,tsc, examplestsc, biome all clean.🤖 Generated with Claude Code