Skip to content

feat(loader): "shader" asset type — preloaded, precompiled ShaderEffects + clone()#1534

Merged
obiot merged 4 commits into
masterfrom
feat/shader-preload
Jul 2, 2026
Merged

feat(loader): "shader" asset type — preloaded, precompiled ShaderEffects + clone()#1534
obiot merged 4 commits into
masterfrom
feat/shader-preload

Conversation

@obiot

@obiot obiot commented Jul 2, 2026

Copy link
Copy Markdown
Member

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 time

me.loader.preload([
  // from a file (or data: URI)
  { name: "waterRipple", type: "shader", src: "shaders/waterRipple.frag" },
  // or inline GLSL via the `data` field (the inline-TMX convention)
  { name: "flash", type: "shader", data: `
      uniform float uIntensity;
      vec4 apply(vec4 color, vec2 uv) { return mix(color, vec4(1.0), uIntensity); }
  ` },
]);

The source is a GLSL fragment body following the ShaderEffect convention (uniform declarations + vec4 apply(vec4 color, vec2 uv)). It is compiled during preloading against the renderer captured on VIDEO_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 instance

Returns the same, loader-owned ShaderEffect on every call (shared = true, dogfooding #1531):

  • safe to assign to any number of renderables — none of their cleanup paths auto-destroy it;
  • they all share ONE set of uniform values;
  • freed only by loader.unload() / loader.unloadAll(), which destroy the GL program, not just the cache entry.
sprite.shader = me.loader.getShader("waterRipple");

ShaderEffect.clone() — private copies for per-renderable uniforms

Compiles 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 setTexture bindings (each clone lazily uploads + owns its own GL texture/unit reservation) — but never the ownership: the clone's shared flag is always reset to false, so it is auto-destroyed with the renderable it is assigned to, exactly like a hand-constructed effect (set shared = true yourself to opt out). Cloning a destroyed effect throws.

boss.shader = me.loader.getShader("flash").clone(); // caller-owned
boss.shader.setUniform("uIntensity", 0.9);          // doesn't affect other users

Also in this PR

  • unload/unloadAll gain a "shader" case (destroys the shared GL program); setBaseURL supports "shader"; Asset typedef documents the type + inline data.
  • The Water Refraction example now ships its fragment as a preloaded shader asset (getShader + loader.unload on 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 via gl.getUniform) + shared reset + extra-texture copy + destroy independence + destroyed-clone throw.

Full suite 4557 passed / 0 failed; eslint src tests, tsc, examples tsc, biome all clean.

🤖 Generated with Claude Code

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
Copilot AI review requested due to automatic review settings July 2, 2026 06:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, plus loader.getShader() and shader-aware unload/unloadAll behavior.
  • Extend ShaderEffect with clone() (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.

Comment thread packages/melonjs/src/loader/loader.js Outdated
Comment thread packages/melonjs/tests/shader-loader.spec.js
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
@obiot

obiot commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

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: 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" branch was dead complexity. Shaders now always compile at load time — a missing renderer fails the load through the parser's existing asset-named error path — shaderList collapses to a plain name → ShaderEffect map, and getShader is a simple lookup mirroring getGLTF.

Test leaves flash-inline cached (shader-loader.spec.js:50). The test now unloads it at the end and asserts destroyed === true — no GL program left in global loader state across the suite.

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
Copilot AI review requested due to automatic review settings July 2, 2026 07:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Comment thread packages/melonjs/src/video/webgl/shadereffect.js
Comment thread packages/melonjs/src/video/webgl/glshader.js
Comment thread packages/melonjs/src/loader/parsers/shader.js
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
@obiot

obiot commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

Ran a final adversarial review before merge — it confirmed two real bugs, both fixed in 60e5310 with WEBGL_lose_context regression tests:

  1. ShaderEffect.clone() crashed during a lost context — the clone's inner GLShader compiles deferred (uniforms === null) and the replay guard read null[name] → TypeError. Guard is now suspension-aware (defers to the suspended setUniform cache; the restore replay re-validates), matching the "addPostEffect during the suspended window does not throw" bar from the adversarial spec. GLShader.clone() got the same guard for the inverse edge.

  2. Concurrent double-load of one shader name orphaned a live GL program — e.g. the same name listed twice in a preload manifest: both fetches pass the entry check, both compile, the second store overwrites the first — which stays pinned forever 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. (The check-at-entry/store-after-fetch window is the same in every other parser, but their losers are inert GC-able data — a compiled ShaderEffect is not.)

Also from review: clear error for an asset with neither src nor data (was fetch("undefined")); example comment now warns the unload-in-onDestroyEvent pattern is single-stage-teardown only; CHANGELOG states precisely that loader.load() is the form that rejects with the asset name.

New adversarial tests: concurrent-race → one shared effect; clone mid-loss no-throw + post-restore uniform replay (verified via gl.getUniform); preloaded asset survives a full lose/restore cycle (same instance, re-enabled, uniform replayed). Suite: 4564 passed / 0 failed.

Review also flagged pre-existing unloadAll bugs outside this PR's scope (videoList unloaded with type: "json" so never removed; fontList with type: "font" which hits the default throw; a typeof typeof in the fontface case) — worth a small follow-up PR.

@obiot obiot merged commit 4dc1b7a into master Jul 2, 2026
6 checks passed
@obiot obiot deleted the feat/shader-preload branch July 2, 2026 08:55
obiot added a commit that referenced this pull request Jul 2, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants