melonjs 20.4.0 → 20.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/build/application/defaultApplicationSettings.d.ts +1 -0
  2. package/build/application/defaultApplicationSettings.d.ts.map +1 -1
  3. package/build/application/settings.d.ts +37 -0
  4. package/build/application/settings.d.ts.map +1 -1
  5. package/build/audio/backend/sound.d.ts.map +1 -1
  6. package/build/audio/procedural.d.ts +7 -2
  7. package/build/audio/procedural.d.ts.map +1 -1
  8. package/build/audio/types.d.ts +22 -0
  9. package/build/audio/types.d.ts.map +1 -1
  10. package/build/camera/camera2d.d.ts +28 -0
  11. package/build/camera/camera2d.d.ts.map +1 -1
  12. package/build/camera/camera3d.d.ts +24 -3
  13. package/build/camera/camera3d.d.ts.map +1 -1
  14. package/build/geometries/polygon.d.ts +7 -1
  15. package/build/geometries/polygon.d.ts.map +1 -1
  16. package/build/index.d.ts +4 -0
  17. package/build/index.d.ts.map +1 -1
  18. package/build/index.js +1379 -226
  19. package/build/index.js.map +4 -4
  20. package/build/level/gltf/GLTFModel.d.ts +50 -7
  21. package/build/level/gltf/GLTFModel.d.ts.map +1 -1
  22. package/build/lighting/light3d.d.ts +20 -47
  23. package/build/lighting/light3d.d.ts.map +1 -1
  24. package/build/loader/loader.d.ts +106 -6
  25. package/build/loader/loader.d.ts.map +1 -1
  26. package/build/loader/loadingscreen.d.ts.map +1 -1
  27. package/build/renderable/animation.d.ts +21 -6
  28. package/build/renderable/animation.d.ts.map +1 -1
  29. package/build/renderable/container.d.ts +27 -5
  30. package/build/renderable/container.d.ts.map +1 -1
  31. package/build/renderable/instanced_mesh.d.ts +34 -9
  32. package/build/renderable/instanced_mesh.d.ts.map +1 -1
  33. package/build/renderable/mesh.d.ts +131 -94
  34. package/build/renderable/mesh.d.ts.map +1 -1
  35. package/build/renderable/renderable.d.ts +17 -0
  36. package/build/renderable/renderable.d.ts.map +1 -1
  37. package/build/renderable/sprite3d.d.ts +12 -2
  38. package/build/renderable/sprite3d.d.ts.map +1 -1
  39. package/build/renderable/text/bitmaptext.d.ts +32 -7
  40. package/build/renderable/text/bitmaptext.d.ts.map +1 -1
  41. package/build/renderable/text/text.d.ts +78 -3
  42. package/build/renderable/text/text.d.ts.map +1 -1
  43. package/build/renderable/text/textmetrics.d.ts +2 -0
  44. package/build/renderable/text/textmetrics.d.ts.map +1 -1
  45. package/build/renderable/text/textstyle.d.ts.map +1 -1
  46. package/build/state/stage.d.ts +21 -0
  47. package/build/state/stage.d.ts.map +1 -1
  48. package/build/system/device.d.ts +1 -1
  49. package/build/system/device.d.ts.map +1 -1
  50. package/build/system/save.d.ts +44 -1
  51. package/build/system/save.d.ts.map +1 -1
  52. package/build/video/effects/glsl_realization.d.ts.map +1 -1
  53. package/build/video/effects/mesh_splice.d.ts +2 -0
  54. package/build/video/effects/mesh_splice.d.ts.map +1 -0
  55. package/build/video/effects/shadereffect.d.ts +12 -128
  56. package/build/video/effects/shadereffect.d.ts.map +1 -1
  57. package/build/video/effects/wgsl_realization.d.ts.map +1 -1
  58. package/build/video/gradient.d.ts +19 -0
  59. package/build/video/gradient.d.ts.map +1 -1
  60. package/build/video/renderer.d.ts +44 -0
  61. package/build/video/renderer.d.ts.map +1 -1
  62. package/build/video/texture/noise_texture2d.d.ts +101 -34
  63. package/build/video/texture/noise_texture2d.d.ts.map +1 -1
  64. package/build/video/webgl/batchers/batcher.d.ts.map +1 -1
  65. package/build/video/webgl/batchers/mesh_batcher.d.ts.map +1 -1
  66. package/build/video/webgl/glshader.d.ts +7 -2
  67. package/build/video/webgl/glshader.d.ts.map +1 -1
  68. package/build/video/webgl/webgl_renderer.d.ts.map +1 -1
  69. package/build/video/webgpu/batchers/lit_mesh_batcher.d.ts.map +1 -1
  70. package/build/video/webgpu/batchers/mesh_batcher.d.ts +6 -1
  71. package/build/video/webgpu/batchers/mesh_batcher.d.ts.map +1 -1
  72. package/build/video/webgpu/effect_binding.d.ts.map +1 -1
  73. package/build/video/webgpu/pipeline/cache.d.ts.map +1 -1
  74. package/build/video/webgpu/webgpu_renderer.d.ts +1 -0
  75. package/build/video/webgpu/webgpu_renderer.d.ts.map +1 -1
  76. package/package.json +1 -1
  77. package/skills/melonjs-3d/SKILL.md +152 -3
  78. package/skills/melonjs-3d-assets/SKILL.md +139 -0
  79. package/skills/melonjs-audio/SKILL.md +17 -0
  80. package/skills/melonjs-camera-and-drawing/SKILL.md +45 -0
  81. package/skills/melonjs-effects-and-shaders/SKILL.md +130 -1
  82. package/skills/melonjs-loading-assets/SKILL.md +131 -1
  83. package/skills/melonjs-performance/SKILL.md +83 -1
  84. package/skills/melonjs-physics/SKILL.md +78 -0
  85. package/skills/melonjs-renderables/SKILL.md +63 -1
  86. package/skills/melonjs-renderer-backends/SKILL.md +19 -0
  87. package/skills/melonjs-scenes-and-state/SKILL.md +93 -0
  88. package/skills/melonjs-ui-and-text/SKILL.md +124 -0
package/build/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * melonJS Game Engine - 20.4.0
2
+ * melonJS Game Engine - 20.6.0
3
3
  * http://www.melonjs.org
4
4
  * melonjs is licensed under the MIT License.
5
5
  * http://www.opensource.org/licenses/mit-license
@@ -1843,17 +1843,33 @@ var Polygon = class {
1843
1843
  */
1844
1844
  edges;
1845
1845
  /**
1846
- * a list of indices for all vertices composing this polygon
1846
+ * a list of indices for all vertices composing this polygon, filled in by
1847
+ * {@link Polygon#getIndices} and invalidated by {@link Polygon#recalc}.
1848
+ *
1849
+ * A subclass may repurpose this name for geometry of its own: a
1850
+ * {@link Mesh} holds a GPU index buffer here, a typed array rather than a
1851
+ * plain one. `recalc` therefore only clears the slot when it owns it —
1852
+ * anything else reading it should check what it got.
1847
1853
  */
1848
1854
  indices;
1849
1855
  /**
1850
- * The normals here are the direction of the normal for the `n`th edge of the polygon, relative
1851
- * to the position of the `n`th point. If you want to draw an edge normal, you must first
1852
- * translate to the position of the starting point.
1856
+ * The normals here are the direction of the normal for the `n`th EDGE of the
1857
+ * polygon, relative to the position of the `n`th point. If you want to draw
1858
+ * an edge normal, you must first translate to the position of the starting
1859
+ * point.
1860
+ *
1861
+ * Named for the edges rather than just `normals` because a subclass may
1862
+ * carry normals of its own with a different meaning and a different type:
1863
+ * a {@link Mesh} holds per-VERTEX normals as a `Float32Array`, and when
1864
+ * that shadowed this one, {@link Polygon#recalc} walked it expecting
1865
+ * `Vector2d`s. It allocates only into empty slots, and a typed array's
1866
+ * slots are `0` rather than `undefined` — so it took the reuse branch and
1867
+ * threw `Cannot create property 'x' on number '0'`, breaking `recalc`,
1868
+ * `resize` and the `width`/`height` setters on every mesh.
1853
1869
  * @ignore
1854
1870
  * @internal
1855
1871
  */
1856
- normals;
1872
+ edgeNormals;
1857
1873
  /**
1858
1874
  * The bounding rectangle for this shape
1859
1875
  * @ignore
@@ -1874,7 +1890,7 @@ var Polygon = class {
1874
1890
  this.points = [];
1875
1891
  this.edges = [];
1876
1892
  this.indices = [];
1877
- this.normals = [];
1893
+ this.edgeNormals = [];
1878
1894
  this._bounds = boundsPool.get();
1879
1895
  this.setVertices(vertices);
1880
1896
  }
@@ -2016,7 +2032,7 @@ var Polygon = class {
2016
2032
  */
2017
2033
  recalc() {
2018
2034
  const edges = this.edges;
2019
- const normals = this.normals;
2035
+ const normals = this.edgeNormals;
2020
2036
  const indices = this.indices;
2021
2037
  const points = this.points;
2022
2038
  const len = points.length;
@@ -2047,7 +2063,9 @@ var Polygon = class {
2047
2063
  }
2048
2064
  edges.length = len;
2049
2065
  normals.length = len;
2050
- indices.length = 0;
2066
+ if (Array.isArray(indices)) {
2067
+ indices.length = 0;
2068
+ }
2051
2069
  return this;
2052
2070
  }
2053
2071
  /**
@@ -3097,6 +3115,7 @@ var save = {
3097
3115
  if (hasLocalStorage) {
3098
3116
  localStorage.setItem("me.save", JSON.stringify(Object.keys(data)));
3099
3117
  }
3118
+ return save;
3100
3119
  },
3101
3120
  /**
3102
3121
  * Remove a key from localStorage
@@ -4255,11 +4274,22 @@ var Gradient = class {
4255
4274
  return this;
4256
4275
  }
4257
4276
  /**
4258
- * Get or create a native CanvasGradient for use with a 2D context.
4277
+ * Get or create a native `CanvasGradient` for use with a 2D context.
4278
+ *
4279
+ * This is the one to reach for when baking a gradient into your own
4280
+ * canvas — it hands back a value for `ctx.fillStyle` and touches no
4281
+ * shared state, so two callers cannot clobber each other.
4282
+ *
4283
+ * The result is cached and reused until a stop changes.
4259
4284
  * @param {CanvasRenderingContext2D} context - the 2D context to create the gradient on
4260
- * @returns {CanvasGradient}
4261
- * @ignore
4262
- * @internal
4285
+ * @returns {CanvasGradient} a native gradient bound to that context
4286
+ * @example
4287
+ * // bake a gradient into a standalone canvas
4288
+ * const c = document.createElement("canvas");
4289
+ * c.width = 64; c.height = 64;
4290
+ * const ctx = c.getContext("2d");
4291
+ * ctx.fillStyle = myGradient.toCanvasGradient(ctx);
4292
+ * ctx.fillRect(0, 0, 64, 64);
4263
4293
  */
4264
4294
  toCanvasGradient(context) {
4265
4295
  if (this._canvasGradient && !this._dirty) {
@@ -4297,6 +4327,10 @@ var Gradient = class {
4297
4327
  * which is visually equivalent (linear stop interpolation × linear
4298
4328
  * texture filtering). The returned `width`/`height` describe the region
4299
4329
  * of the canvas the caller must use as the drawImage SOURCE rect.
4330
+ * Internal: the bake target is SHARED across every gradient and valid only
4331
+ * until the next call, which is a renderer implementation detail rather
4332
+ * than something a caller should reason about. The public way to rasterise
4333
+ * a gradient is {@link Gradient#toCanvasGradient}.
4300
4334
  * @param {CanvasRenderer|WebGLRenderer} renderer - the active renderer (used to invalidate the GPU texture)
4301
4335
  * @param {number} x - draw rect x
4302
4336
  * @param {number} y - draw rect y
@@ -6857,6 +6891,52 @@ var Renderer = class _Renderer {
6857
6891
  */
6858
6892
  flush() {
6859
6893
  }
6894
+ /**
6895
+ * Compile the backend's built-in shader programs up front, so the first
6896
+ * frame that draws them does not stall while the driver builds them.
6897
+ *
6898
+ * A GPU backend does not finish a shader when you hand it the source. It
6899
+ * finishes it the first time something is actually drawn with it, which
6900
+ * puts the whole cost on the frame a scene first appears — the moment a
6901
+ * level or stage comes up and its geometry arrives a beat late. Calling
6902
+ * this while a loading screen is still on screen moves that cost to where
6903
+ * nobody is waiting on a frame.
6904
+ *
6905
+ * Resolves immediately on a backend with nothing to compile (Canvas).
6906
+ * What "ready" means is backend-specific and worth knowing: WebGL's link
6907
+ * blocks, so a resolved promise there means the programs are genuinely
6908
+ * built; WebGPU's `createShaderModule` returns before its compilation
6909
+ * finishes, so a resolved promise there means the modules have been
6910
+ * requested and validation is under way.
6911
+ *
6912
+ * **You normally do not call this.** `loader.preload()` calls it for you,
6913
+ * once the assets are in and before `LOADER_COMPLETE` fires — so it runs
6914
+ * while the loading screen is still up — whenever the application's
6915
+ * `prewarm` setting is on, which it is by default. Every `preload()` warms,
6916
+ * not just the first, so a per-level preload warms whatever that level
6917
+ * brought with it.
6918
+ *
6919
+ * Two consequences worth knowing:
6920
+ *
6921
+ * - **A game that never calls `loader.preload()` is never warmed.** There
6922
+ * is no other hook. Call this yourself if you load assets some other way
6923
+ * and still want the warm-up.
6924
+ * - It is deliberately NOT called from `Application.init()`. That runs
6925
+ * before anything is on screen, so warming there would only lengthen a
6926
+ * blank page instead of hiding behind a progress bar.
6927
+ * @returns {Promise<void>} settles when the built-in programs are ready
6928
+ * @example
6929
+ * // the usual case: nothing to write. `preload()` warms on the way through
6930
+ * await loader.preload(resources);
6931
+ * @example
6932
+ * // only needed if you do not use the preloader, or want a second warm-up
6933
+ * // at a point of your own choosing
6934
+ * await app.renderer.prewarm();
6935
+ * state.change(state.PLAY);
6936
+ */
6937
+ prewarm() {
6938
+ return Promise.resolve();
6939
+ }
6860
6940
  /**
6861
6941
  * Mark the start of a screen-space (`floating`) draw, during which the
6862
6942
  * camera's screen projection is installed and world-space geometry cannot
@@ -6956,9 +7036,18 @@ var Renderer = class _Renderer {
6956
7036
  entry2.shader = this.customShader ?? null;
6957
7037
  const v = this.currentTransform.val;
6958
7038
  const m = modelMatrix.val;
6959
- const mx = m[12];
6960
- const my = m[13];
6961
- const mz = m[14];
7039
+ let cx = 0;
7040
+ let cy = 0;
7041
+ let cz = 0;
7042
+ if (typeof mesh?._updateLocalCenter === "function") {
7043
+ mesh._updateLocalCenter();
7044
+ cx = mesh._lcx;
7045
+ cy = mesh._lcy;
7046
+ cz = mesh._lcz;
7047
+ }
7048
+ const mx = m[0] * cx + m[4] * cy + m[8] * cz + m[12];
7049
+ const my = m[1] * cx + m[5] * cy + m[9] * cz + m[13];
7050
+ const mz = m[2] * cx + m[6] * cy + m[10] * cz + m[14];
6962
7051
  const dx = v[0] * mx + v[4] * my + v[8] * mz + v[12];
6963
7052
  const dy = v[1] * mx + v[5] * my + v[9] * mz + v[13];
6964
7053
  const dz = v[2] * mx + v[6] * my + v[10] * mz + v[14];
@@ -7761,6 +7850,31 @@ var Renderer = class _Renderer {
7761
7850
  );
7762
7851
  }
7763
7852
  break;
7853
+ // Box3d — a 3D box has no 2D outline of its own, so draw the XY
7854
+ // FOOTPRINT it already hands every 2D consumer through its own
7855
+ // `getBounds()` (the body bounds, the broadphase pre-gate, and
7856
+ // this). Without a case here it fell through to the `default`
7857
+ // below, so switching on the debug panel's hitbox overlay threw
7858
+ // "Invalid geometry for fill/stroke" on any body carrying one.
7859
+ case "Box3d": {
7860
+ const footprint = shape.getBounds();
7861
+ if (fill) {
7862
+ this.fillRect(
7863
+ footprint.left,
7864
+ footprint.top,
7865
+ footprint.width,
7866
+ footprint.height
7867
+ );
7868
+ } else {
7869
+ this.strokeRect(
7870
+ footprint.left,
7871
+ footprint.top,
7872
+ footprint.width,
7873
+ footprint.height
7874
+ );
7875
+ }
7876
+ break;
7877
+ }
7764
7878
  // Rect or Bounds
7765
7879
  case "Rectangle":
7766
7880
  case "Bounds":
@@ -10411,14 +10525,16 @@ var Sound = class {
10411
10525
  sound2._node.gain.linearRampToValueAtTime(to, end);
10412
10526
  }
10413
10527
  }
10414
- this._startFadeInterval(
10415
- sound2,
10416
- from,
10417
- to,
10418
- len,
10419
- ids[i],
10420
- typeof id === "undefined"
10421
- );
10528
+ const isGroup = typeof id === "undefined";
10529
+ if (!(len > 0) || to === from) {
10530
+ if (isGroup) {
10531
+ this._volume = to;
10532
+ }
10533
+ this.volume(to, ids[i]);
10534
+ this._emit("fade", ids[i]);
10535
+ continue;
10536
+ }
10537
+ this._startFadeInterval(sound2, from, to, len, ids[i], isGroup);
10422
10538
  }
10423
10539
  }
10424
10540
  return this;
@@ -11269,13 +11385,14 @@ function tone(opts) {
11269
11385
  gain = 0.1,
11270
11386
  attack = 5e-3,
11271
11387
  pan = 0,
11272
- pitchSlide = 1
11388
+ pitchSlide = 1,
11389
+ delay = 0
11273
11390
  } = opts;
11274
11391
  const freqs = Array.isArray(freq) ? freq : [freq];
11275
11392
  if (freqs.length === 0) return;
11276
11393
  _resumeIfSuspended(ctx);
11277
11394
  const dur = Math.max(1e-3, duration2);
11278
- const t0 = ctx.currentTime;
11395
+ const t0 = ctx.currentTime + Math.max(0, delay);
11279
11396
  const t1 = t0 + dur;
11280
11397
  const env = _buildGainEnvelope(ctx, t0, t1, attack, dur, gain);
11281
11398
  const panner2 = _connectToOutput(ctx, env, pan, t0);
@@ -11348,11 +11465,12 @@ function noise(opts) {
11348
11465
  attack = 5e-3,
11349
11466
  pan = 0,
11350
11467
  filter,
11351
- filterSweep = 1
11468
+ filterSweep = 1,
11469
+ delay = 0
11352
11470
  } = opts;
11353
11471
  _resumeIfSuspended(ctx);
11354
11472
  const dur = Math.max(1e-3, duration2);
11355
- const t0 = ctx.currentTime;
11473
+ const t0 = ctx.currentTime + Math.max(0, delay);
11356
11474
  const t1 = t0 + dur;
11357
11475
  const env = _buildGainEnvelope(ctx, t0, t1, attack, dur, gain);
11358
11476
  const sampleCount = Math.max(1, Math.ceil(dur * ctx.sampleRate));
@@ -13357,7 +13475,7 @@ var input_exports = {};
13357
13475
  __export(input_exports, {
13358
13476
  GAMEPAD: () => GAMEPAD,
13359
13477
  KEY: () => KEY,
13360
- _app: () => _app2,
13478
+ _app: () => _app3,
13361
13479
  bindGamepad: () => bindGamepad,
13362
13480
  bindKey: () => bindKey,
13363
13481
  bindPointer: () => bindPointer,
@@ -15216,7 +15334,7 @@ var Line = class _Line extends Polygon {
15216
15334
  */
15217
15335
  recalc() {
15218
15336
  const edges = this.edges;
15219
- const normals = this.normals;
15337
+ const normals = this.edgeNormals;
15220
15338
  const indices = this.indices;
15221
15339
  const points = this.points;
15222
15340
  if (points.length !== 2) {
@@ -17271,6 +17389,9 @@ var Renderable = class _Renderable extends Rect {
17271
17389
  }
17272
17390
  /**
17273
17391
  * get the renderable alpha channel value<br>
17392
+ *
17393
+ * This is the renderable's OWN value. What it finally draws at is this
17394
+ * multiplied by every ancestor's — see {@link Renderable#alpha}.
17274
17395
  * @returns {number} current opacity value between 0 and 1
17275
17396
  */
17276
17397
  getOpacity() {
@@ -17668,7 +17789,10 @@ var Renderable = class _Renderable extends Rect {
17668
17789
  if (this.applyAnchorTransform !== false) {
17669
17790
  renderer.translate(-ax, -ay);
17670
17791
  }
17671
- renderer.setTint(this.tint, this.getOpacity());
17792
+ renderer.setTint(
17793
+ this.tint,
17794
+ this.getOpacity() * (renderer.currentTint?.alpha ?? 1)
17795
+ );
17672
17796
  renderer.setDepth(this.depth);
17673
17797
  if (this.blendMode !== renderer.getBlendMode()) {
17674
17798
  renderer.setBlendMode(this.blendMode);
@@ -18260,9 +18384,14 @@ var GLShader = class _GLShader {
18260
18384
  /**
18261
18385
  * Set the uniform to the given value
18262
18386
  * @param {string} name - the uniform name
18263
- * @param {object|Float32Array} value - the value to assign to that uniform
18387
+ * @param {number|boolean|number[]|Float32Array|object} value - the value to assign to that
18388
+ * uniform. Scalars (`float`, `int`, `bool`) take a number or a boolean;
18389
+ * vectors and matrices take an array, a `Float32Array`, or any object
18390
+ * exposing `toArray()` — which is every {@link Vector2d},
18391
+ * {@link Vector3d}, {@link Color} and {@link Matrix3d}.
18264
18392
  * @example
18265
18393
  * myShader.setUniform("uProjectionMatrix", this.projectionMatrix);
18394
+ * myShader.setUniform("uStrength", 0.5); // a scalar is a plain number
18266
18395
  */
18267
18396
  setUniform(name, value) {
18268
18397
  if (this.destroyed) {
@@ -18547,11 +18676,78 @@ function buildGLSLProgram(fragmentBody) {
18547
18676
  return {
18548
18677
  vertex: buildEffectVertex(builtins),
18549
18678
  fragment: fragment2,
18679
+ // the annotation-stripped body, so a mesh-hosted effect can be
18680
+ // spliced into the engine's own mesh shader (#1658) without
18681
+ // re-parsing. Additive: the quad `vertex`/`fragment` above are
18682
+ // byte-identical to what they always were.
18683
+ body: builtins.body,
18684
+ builtins,
18550
18685
  screenTextures: builtins.screenTextures,
18551
18686
  noiseUV: builtins.noiseUV
18552
18687
  };
18553
18688
  }
18554
18689
 
18690
+ // src/video/effects/mesh_splice.js
18691
+ var HOOKS = [
18692
+ {
18693
+ pattern: /vec4\s+ME_effect\s*\(\s*vec4\s+c\s*,\s*vec2\s+uv\s*\)\s*\{[^}]*\}/,
18694
+ replacement: "vec4 ME_effect(vec4 c, vec2 uv) { return apply(c, uv); }"
18695
+ },
18696
+ {
18697
+ pattern: /fn\s+ME_effect\s*\(\s*c\s*:\s*vec4f\s*,\s*uv\s*:\s*vec2f\s*\)\s*->\s*vec4f\s*\{[^}]*\}/,
18698
+ replacement: "fn ME_effect(c : vec4f, uv : vec2f) -> vec4f { return apply(c, uv); }"
18699
+ }
18700
+ ];
18701
+ var MESH_UNSUPPORTED = {
18702
+ noiseUV: "`noise_uv` (derived from a quad's image and object size)",
18703
+ screenUV: "`screen_uv` (needs the screen capture the quad tier takes)",
18704
+ screenTexture: "`screen_texture` (needs the screen capture)"
18705
+ };
18706
+ function meshHostingBlocker(builtins, textures, language2) {
18707
+ if (language2 === "wgsl" && builtins?.vColor === true) {
18708
+ return "it reads `vColor`, which only the 2D quad tier exposes to an effect body";
18709
+ }
18710
+ if (Array.isArray(textures) && textures.length > 0) {
18711
+ return "it declares its own texture samplers, which a mesh-hosted effect cannot bind yet \u2014 uniforms are supported, textures are not";
18712
+ }
18713
+ if (typeof builtins !== "object" || builtins === null) {
18714
+ return null;
18715
+ }
18716
+ const blocked = Object.keys(MESH_UNSUPPORTED).filter((flag) => {
18717
+ return builtins[flag] === true;
18718
+ }).map((flag) => {
18719
+ return MESH_UNSUPPORTED[flag];
18720
+ });
18721
+ return blocked.length === 0 ? null : `it uses ${blocked.join(" and ")}, which only the 2D quad tier provides`;
18722
+ }
18723
+ function hasSpliceMarkers(source) {
18724
+ return typeof source === "string" && HOOKS.some((hook) => {
18725
+ return hook.pattern.test(source);
18726
+ });
18727
+ }
18728
+ function spliceEffect(source, body) {
18729
+ if (typeof source !== "string" || typeof body !== "string") {
18730
+ return null;
18731
+ }
18732
+ for (const hook of HOOKS) {
18733
+ if (hook.pattern.test(source)) {
18734
+ return source.replace(hook.pattern, () => {
18735
+ return `${body}
18736
+ ${hook.replacement}`;
18737
+ });
18738
+ }
18739
+ }
18740
+ return null;
18741
+ }
18742
+ function shiftGroup3(body, shift) {
18743
+ return body.replace(
18744
+ /@group\(\s*3\s*\)\s*@binding\(\s*(\d+)\s*\)/g,
18745
+ (_match, binding) => {
18746
+ return `@group(3) @binding(${Number(binding) + shift})`;
18747
+ }
18748
+ );
18749
+ }
18750
+
18555
18751
  // src/video/effects/wgsl/layout.js
18556
18752
  var TYPE_LAYOUT = {
18557
18753
  f32: { align: 4, size: 4 },
@@ -18923,6 +19119,8 @@ var WGSLEffectRealization = class {
18923
19119
  if (!this.valid) {
18924
19120
  return;
18925
19121
  }
19122
+ this.body = body;
19123
+ this.parsed = parsed;
18926
19124
  this.layout = parsed.layout;
18927
19125
  this.structSize = parsed.structSize;
18928
19126
  this.textures = parsed.textures;
@@ -18930,6 +19128,7 @@ var WGSLEffectRealization = class {
18930
19128
  const module = buildWGSLModule(body, parsed);
18931
19129
  this.code = module.code;
18932
19130
  this.builtinBindings = module.bindings;
19131
+ this.meshModules = /* @__PURE__ */ new Map();
18933
19132
  this.cpu = new ArrayBuffer(Math.max(this.structSize, 0));
18934
19133
  this.f32 = new Float32Array(this.cpu);
18935
19134
  this.i32 = new Int32Array(this.cpu);
@@ -19006,9 +19205,30 @@ var WGSLEffectRealization = class {
19006
19205
  releaseGPU() {
19007
19206
  this.gpu = null;
19008
19207
  }
19208
+ /**
19209
+ * This effect realized against the MESH contract: the engine's own mesh
19210
+ * module with the body spliced in at its `ME_effect` hook, and the
19211
+ * body's group-3 bindings shifted up one so `uMesh` keeps binding 0.
19212
+ *
19213
+ * The quad module (`this.code`) is untouched — a `Sprite` still gets the
19214
+ * exact text it always did.
19215
+ * @param {string} meshSource - the host mesh shader's WGSL text
19216
+ * @returns {string} the spliced module
19217
+ * @ignore
19218
+ * @internal
19219
+ */
19220
+ meshModule(meshSource) {
19221
+ let code = this.meshModules.get(meshSource);
19222
+ if (typeof code === "undefined") {
19223
+ code = spliceEffect(meshSource, shiftGroup3(this.body, 1));
19224
+ this.meshModules.set(meshSource, code);
19225
+ }
19226
+ return code;
19227
+ }
19009
19228
  };
19010
19229
 
19011
19230
  // src/video/effects/shadereffect.js
19231
+ var _nextEffectId = 0;
19012
19232
  var ShaderEffect = class _ShaderEffect {
19013
19233
  /**
19014
19234
  * whether this effect is active (false in Canvas mode, false after
@@ -19065,10 +19285,14 @@ var ShaderEffect = class _ShaderEffect {
19065
19285
  return;
19066
19286
  }
19067
19287
  this._extraTextures = /* @__PURE__ */ new Map();
19288
+ this._uniformValues = /* @__PURE__ */ new Map();
19289
+ this._effectId = _nextEffectId++;
19068
19290
  if (language2 === "glsl") {
19069
19291
  const program = buildGLSLProgram(source);
19070
19292
  this._screenTextureUniforms = program.screenTextures;
19071
19293
  this._hasNoiseUV = program.noiseUV;
19294
+ this._meshBody = program.body;
19295
+ this._meshBuiltins = program.builtins;
19072
19296
  this._shader = new GLShader(
19073
19297
  renderer.gl,
19074
19298
  program.vertex,
@@ -19138,12 +19362,23 @@ var ShaderEffect = class _ShaderEffect {
19138
19362
  /**
19139
19363
  * Set the uniform to the given value
19140
19364
  * @param {string} name - the uniform name
19141
- * @param {object|Float32Array} value - the value to assign to that uniform
19365
+ * @param {number|boolean|number[]|Float32Array|object} value - the value to assign to that
19366
+ * uniform. Scalars (`float`, `int`, `bool`) take a number or a boolean;
19367
+ * vectors and matrices take an array, a `Float32Array`, or any object
19368
+ * exposing `toArray()` — which is every {@link Vector2d},
19369
+ * {@link Vector3d}, {@link Color} and {@link Matrix3d}.
19370
+ * @example
19371
+ * // a scalar the body declares as `uniform float uStrength;`
19372
+ * fx.setUniform("uStrength", 0.5);
19373
+ * // a vec3 — an array, or anything with toArray()
19374
+ * fx.setUniform("uTint", [1.0, 0.82, 0.55]);
19375
+ * fx.setUniform("uOrigin", new me.Vector2d(0.5, 0.5));
19142
19376
  */
19143
19377
  setUniform(name, value) {
19144
19378
  if (this.destroyed === true) {
19145
19379
  return;
19146
19380
  }
19381
+ this._uniformValues?.set(name, value);
19147
19382
  if (typeof this._shader !== "undefined") {
19148
19383
  this._shader.setUniform(name, value);
19149
19384
  } else if (typeof this.wgslRealization !== "undefined") {
@@ -19174,7 +19409,12 @@ var ShaderEffect = class _ShaderEffect {
19174
19409
  * flow.setTime(me.timer.getTime() / 1000);
19175
19410
  */
19176
19411
  setTime(seconds) {
19177
- if (typeof this._shader !== "undefined" && this.destroyed !== true && !this._shader.suspended && typeof this._shader.uniforms.uTime !== "undefined") {
19412
+ if (this.destroyed !== true) {
19413
+ this._uniformValues?.set("uTime", seconds);
19414
+ }
19415
+ if (typeof this._shader !== "undefined" && this.destroyed !== true && !this._shader.suspended && // optional: the map is null on a shader with no live context
19416
+ // behind it, which `suspended` alone does not cover
19417
+ typeof this._shader.uniforms?.uTime !== "undefined") {
19178
19418
  this._shader.setUniform("uTime", seconds);
19179
19419
  } else if (typeof this.wgslRealization !== "undefined" && this.destroyed !== true && this.wgslRealization.hasUniform("uTime")) {
19180
19420
  this.wgslRealization.setUniform("uTime", seconds);
@@ -19862,6 +20102,8 @@ var Camera2d = class extends Renderable {
19862
20102
  * @internal
19863
20103
  */
19864
20104
  _zoom;
20105
+ /** backing store for {@link Camera2d#roll} — see the accessor */
20106
+ _roll;
19865
20107
  /**
19866
20108
  * the world-space projection matrix for non-default cameras (offset/zoomed).
19867
20109
  * Maps world coordinates to the camera's screen viewport.
@@ -19954,6 +20196,7 @@ var Camera2d = class extends Renderable {
19954
20196
  this.screenX = 0;
19955
20197
  this.screenY = 0;
19956
20198
  this.zoom = 1;
20199
+ this._roll = 0;
19957
20200
  this.worldProjection = new Matrix3d();
19958
20201
  this.screenProjection = new Matrix3d();
19959
20202
  this._worldView = new Bounds();
@@ -20029,6 +20272,48 @@ var Camera2d = class extends Renderable {
20029
20272
  * // zoom out to show the full level in a 180x100 minimap
20030
20273
  * camera.zoom = Math.min(180 / levelWidth, 100 / levelHeight);
20031
20274
  */
20275
+ /**
20276
+ * Roll this camera in the screen plane, in radians — the 2D spelling of
20277
+ * {@link Camera3d#roll}, so a helper that banks "a camera" works on either.
20278
+ *
20279
+ * Absolute and idempotent: assigning the same angle twice leaves the view
20280
+ * where it was. It is a thin accessor over the camera's own
20281
+ * `currentTransform`, which is what {@link Renderable#rotate} writes and
20282
+ * what `worldToLocal` / `localToWorld` already undo — so picking under a
20283
+ * rolled 2D camera stays correct for free.
20284
+ *
20285
+ * A 2D camera has no pitch or yaw, so a screen-plane rotation is its only
20286
+ * rotation; on a `Camera3d` the roll is a real view angle instead, because
20287
+ * a 3D view is built from its three angles and never reads
20288
+ * `currentTransform`.
20289
+ *
20290
+ * This OWNS the camera's `currentTransform`: the setter rebuilds it rather
20291
+ * than composing onto it, so assigning every frame cannot drift and
20292
+ * `roll = 0` lands back on an exact identity. (A delta-based version does
20293
+ * neither — a banking camera would accumulate float error across thousands
20294
+ * of frames.) The cost is that a manual `rotate()` / `scale()` on the
20295
+ * camera itself is discarded by the next assignment; a camera has `zoom`
20296
+ * and `shake` for those jobs, and neither touches this matrix.
20297
+ * @default 0
20298
+ * @example
20299
+ * camera.roll = Math.PI / 32; // a slight dutch angle
20300
+ */
20301
+ get roll() {
20302
+ return this._roll;
20303
+ }
20304
+ set roll(value) {
20305
+ if (value === this._roll) {
20306
+ return;
20307
+ }
20308
+ this._roll = value;
20309
+ this.currentTransform.identity();
20310
+ if (value !== 0) {
20311
+ this.currentTransform.rotate(value);
20312
+ }
20313
+ this.invCurrentTransform.copy(this.currentTransform).invert();
20314
+ this.updateBounds();
20315
+ this.isDirty = true;
20316
+ }
20032
20317
  get zoom() {
20033
20318
  return this._zoom;
20034
20319
  }
@@ -20086,6 +20371,7 @@ var Camera2d = class extends Renderable {
20086
20371
  this.smoothFollow = true;
20087
20372
  this.damping = 1;
20088
20373
  this.currentTransform.identity();
20374
+ this._roll = 0;
20089
20375
  this.invCurrentTransform.identity().invert();
20090
20376
  this.worldProjection.identity();
20091
20377
  this.screenProjection.identity();
@@ -20558,7 +20844,16 @@ var Camera2d = class extends Renderable {
20558
20844
  container.draw(r, this);
20559
20845
  }
20560
20846
  renderer.flushTransparent();
20847
+ const hasCameraEffects = this.cameraEffects.length > 0;
20848
+ if (hasCameraEffects) {
20849
+ renderer.setProjection(this.screenProjection);
20850
+ }
20561
20851
  this.drawFX(renderer);
20852
+ if (hasCameraEffects) {
20853
+ renderer.setProjection(
20854
+ isNonDefault ? this.worldProjection : this.projectionMatrix
20855
+ );
20856
+ }
20562
20857
  container.postDraw(r);
20563
20858
  this.postDraw(r);
20564
20859
  if (stage) {
@@ -21855,9 +22150,8 @@ var Stage = class {
21855
22150
  }
21856
22151
  }
21857
22152
  /**
21858
- * update function
21859
- * @ignore
21860
- * @internal
22153
+ * Update the stage. Override it to run your own per-frame logic, and call
22154
+ * `super.update(dt)` so the world and cameras still advance.
21861
22155
  * @param dt - time since the last update in milliseconds.
21862
22156
  * @returns true if the stage needs to be redrawn
21863
22157
  */
@@ -21876,8 +22170,9 @@ var Stage = class {
21876
22170
  * Lights are rendered as part of the world tree (they're now first-class
21877
22171
  * Renderables) and the ambient overlay pass runs inside each Camera's
21878
22172
  * post-effect FBO bracket via {@link Stage#drawLighting}.
21879
- * @ignore
21880
- * @internal
22173
+ *
22174
+ * Override it to draw under or over the world, calling `super.draw(...)`
22175
+ * where the world itself belongs in that order.
21881
22176
  * @param renderer - the renderer object to draw with
21882
22177
  * @param world - the world object to draw
21883
22178
  */
@@ -22051,6 +22346,13 @@ var DefaultLoadingScreen = class extends Stage {
22051
22346
  * @internal
22052
22347
  */
22053
22348
  #cleanedUp = false;
22349
+ /**
22350
+ * whether the preloader has reported done. Latched on LOADER_COMPLETE
22351
+ * and read by the logo's own load callback — NOT a teardown signal.
22352
+ * @ignore
22353
+ * @internal
22354
+ */
22355
+ #loadingComplete = false;
22054
22356
  /**
22055
22357
  * Pin the loading screen to a Camera2d regardless of the
22056
22358
  * application's `cameraClass` setting. The loader must render
@@ -22070,13 +22372,14 @@ var DefaultLoadingScreen = class extends Stage {
22070
22372
  const barHeight = 8;
22071
22373
  this.#app = app;
22072
22374
  this.#cleanedUp = false;
22375
+ this.#loadingComplete = false;
22073
22376
  app.world.backgroundColor.parseCSS("#202020");
22074
22377
  const { width, height } = app.renderer;
22075
22378
  this.progressBar = new ProgressBar(0, height / 2, width, barHeight);
22076
22379
  app.world.addChild(this.progressBar, 1);
22077
- once(LOADER_COMPLETE, this.#cleanup, this);
22078
- load2({ name: "melonjs_logo", type: "image", src: melonjs_logo_default }, () => {
22079
- if (this.#cleanedUp) {
22380
+ once(LOADER_COMPLETE, this.#onLoaderComplete, this);
22381
+ load2({ name: "melonjs_logo", type: "image", src: melonjs_logo_default }).then(() => {
22382
+ if (this.#loadingComplete || this.#cleanedUp) {
22080
22383
  return;
22081
22384
  }
22082
22385
  this.logoSprite = new Sprite(width / 2, height / 2, {
@@ -22085,8 +22388,18 @@ var DefaultLoadingScreen = class extends Stage {
22085
22388
  frameheight: 256
22086
22389
  });
22087
22390
  app.world.addChild(this.logoSprite, 2);
22391
+ }).catch(() => {
22088
22392
  });
22089
22393
  }
22394
+ /**
22395
+ * The preloader is done. Latched so a late-arriving logo is not added
22396
+ * to a world that is about to be replaced; nothing is torn down here.
22397
+ * @ignore
22398
+ * @internal
22399
+ */
22400
+ #onLoaderComplete() {
22401
+ this.#loadingComplete = true;
22402
+ }
22090
22403
  /**
22091
22404
  * Remove loading screen children and unload the logo
22092
22405
  * @ignore
@@ -22114,8 +22427,8 @@ var DefaultLoadingScreen = class extends Stage {
22114
22427
  * @internal
22115
22428
  */
22116
22429
  onDestroyEvent() {
22117
- if (!this.#cleanedUp) {
22118
- off(LOADER_COMPLETE, this.#cleanup, this);
22430
+ if (!this.#loadingComplete) {
22431
+ off(LOADER_COMPLETE, this.#onLoaderComplete, this);
22119
22432
  }
22120
22433
  this.#cleanup();
22121
22434
  }
@@ -23080,6 +23393,9 @@ function preloadFontFace(data2, onload2, onerror) {
23080
23393
  }
23081
23394
 
23082
23395
  // src/lighting/light3d.ts
23396
+ function readVector(out, value) {
23397
+ return Array.isArray(value) ? out.set(value[0], value[1], value[2]) : out.set(value.x, value.y, value.z);
23398
+ }
23083
23399
  var Light3d = class extends Renderable {
23084
23400
  /** `"directional"`, `"ambient"`, `"point"` or `"spot"`. */
23085
23401
  type;
@@ -23111,20 +23427,12 @@ var Light3d = class extends Renderable {
23111
23427
  this.type = options.type ?? "directional";
23112
23428
  this.direction = new Vector3d(0, 1, 0);
23113
23429
  if (options.direction) {
23114
- this.direction.set(
23115
- options.direction[0],
23116
- options.direction[1],
23117
- options.direction[2]
23118
- );
23430
+ readVector(this.direction, options.direction);
23119
23431
  }
23120
23432
  this.direction.normalize();
23121
23433
  this.position = new Vector3d(0, 0, 0);
23122
23434
  if (options.position) {
23123
- this.position.set(
23124
- options.position[0],
23125
- options.position[1],
23126
- options.position[2]
23127
- );
23435
+ readVector(this.position, options.position);
23128
23436
  }
23129
23437
  if (options.color instanceof Color) {
23130
23438
  this.color = options.color;
@@ -23505,6 +23813,7 @@ var Frustum = class {
23505
23813
  // src/camera/camera3d.ts
23506
23814
  var AXIS_X = new Vector3d(1, 0, 0);
23507
23815
  var AXIS_Y = new Vector3d(0, 1, 0);
23816
+ var AXIS_Z = new Vector3d(0, 0, 1);
23508
23817
  var _viewMatrix = new Matrix3d();
23509
23818
  var _viewProjection = new Matrix3d();
23510
23819
  var _wsPoint = new Vector3d();
@@ -23610,6 +23919,29 @@ var Camera3d = class extends Camera2d {
23610
23919
  * @default 0
23611
23920
  */
23612
23921
  yaw;
23922
+ /**
23923
+ * Z-axis rotation in radians (bank the horizon). Positive values
23924
+ * roll the camera clockwise, so the world tilts anticlockwise —
23925
+ * the view from a cockpit banking right.
23926
+ *
23927
+ * Completes the `pitch` / `yaw` / `roll` trio. Note this is NOT the
23928
+ * inherited {@link Renderable#rotation}: a 3D camera builds its view
23929
+ * from these three angles and never reads `currentTransform`, which
23930
+ * is why `camera.rotate()` on a `Camera3d` is silently inert. A
23931
+ * `Camera2d` is the other way round — it has no `roll` because a
23932
+ * screen-plane rotation IS its only rotation, and `rotate()` already
23933
+ * does it through `currentTransform`.
23934
+ * @default 0
23935
+ * @example
23936
+ * // bank with the player's steering, as a flight game would
23937
+ * camera.roll = (player.pos.x / PLAY_BOUND_X) * MAX_BANK;
23938
+ */
23939
+ get roll() {
23940
+ return this._roll;
23941
+ }
23942
+ set roll(value) {
23943
+ this._roll = value;
23944
+ }
23613
23945
  /**
23614
23946
  * World-space offset from the followed target. When `target` is
23615
23947
  * set via {@link Camera2d#follow}, the camera position resolves to
@@ -23650,6 +23982,7 @@ var Camera3d = class extends Camera2d {
23650
23982
  });
23651
23983
  this.pitch = 0;
23652
23984
  this.yaw = 0;
23985
+ this.roll = 0;
23653
23986
  this.followOffset = new Vector3d(0, 0, 0);
23654
23987
  this.lookAhead = new Vector3d(0, 0, 1);
23655
23988
  this.near = this.frustum.near;
@@ -23925,6 +24258,7 @@ var Camera3d = class extends Camera2d {
23925
24258
  _basis.identity();
23926
24259
  _basis.rotate(this.yaw, AXIS_Y);
23927
24260
  _basis.rotate(this.pitch, AXIS_X);
24261
+ _basis.rotate(this.roll, AXIS_Z);
23928
24262
  const b = _basis.val;
23929
24263
  state4.heightAxis[0] = k * b[1];
23930
24264
  state4.heightAxis[1] = k * b[5];
@@ -23952,6 +24286,7 @@ var Camera3d = class extends Camera2d {
23952
24286
  _basis.identity();
23953
24287
  _basis.rotate(this.yaw, AXIS_Y);
23954
24288
  _basis.rotate(this.pitch, AXIS_X);
24289
+ _basis.rotate(this.roll, AXIS_Z);
23955
24290
  const v = _basis.val;
23956
24291
  right.set(v[0], v[1], v[2]);
23957
24292
  up.set(v[4], v[5], v[6]);
@@ -24050,6 +24385,9 @@ var Camera3d = class extends Camera2d {
24050
24385
  * @internal
24051
24386
  */
24052
24387
  _applyContainerViewTransform(container, translateX, translateY) {
24388
+ if (this.roll !== 0) {
24389
+ container.rotate(-this.roll, AXIS_Z);
24390
+ }
24053
24391
  if (this.pitch !== 0) {
24054
24392
  container.rotate(-this.pitch, AXIS_X);
24055
24393
  }
@@ -24076,6 +24414,9 @@ var Camera3d = class extends Camera2d {
24076
24414
  if (this.pitch !== 0) {
24077
24415
  container.rotate(this.pitch, AXIS_X);
24078
24416
  }
24417
+ if (this.roll !== 0) {
24418
+ container.rotate(this.roll, AXIS_Z);
24419
+ }
24079
24420
  }
24080
24421
  /**
24081
24422
  * Point the camera at a world-space target by deriving pitch and
@@ -24276,6 +24617,9 @@ var Camera3d = class extends Camera2d {
24276
24617
  */
24277
24618
  worldToScreen(world, out = new Vector2d()) {
24278
24619
  _viewMatrix.identity();
24620
+ if (this.roll !== 0) {
24621
+ _viewMatrix.rotate(-this.roll, AXIS_Z);
24622
+ }
24279
24623
  if (this.pitch !== 0) {
24280
24624
  _viewMatrix.rotate(-this.pitch, AXIS_X);
24281
24625
  }
@@ -24308,6 +24652,9 @@ var Camera3d = class extends Camera2d {
24308
24652
  */
24309
24653
  _rebuildFrustumPlanes() {
24310
24654
  _viewMatrix.identity();
24655
+ if (this.roll !== 0) {
24656
+ _viewMatrix.rotate(-this.roll, AXIS_Z);
24657
+ }
24311
24658
  if (this.pitch !== 0) {
24312
24659
  _viewMatrix.rotate(-this.pitch, AXIS_X);
24313
24660
  }
@@ -24614,38 +24961,44 @@ function buildTextureGroups(groups, materials, shared, framewidth, frameheight)
24614
24961
  }
24615
24962
  return slices.length === 1 && slices[0].texture !== shared ? slices : void 0;
24616
24963
  }
24964
+ function declaredSize(settings) {
24965
+ const width = settings.width;
24966
+ const height = settings.height;
24967
+ if (settings.normalize !== false || typeof settings.scale !== "number" || width > 0 && height > 0) {
24968
+ return [width, height];
24969
+ }
24970
+ const vertices = typeof settings.model === "string" ? getOBJ(settings.model)?.vertices : settings.vertices;
24971
+ if (!vertices || vertices.length < 3) {
24972
+ return [width, height];
24973
+ }
24974
+ let minX = Infinity;
24975
+ let maxX = -Infinity;
24976
+ let minY = Infinity;
24977
+ let maxY = -Infinity;
24978
+ for (let i = 0; i < vertices.length; i += 3) {
24979
+ if (vertices[i] < minX) {
24980
+ minX = vertices[i];
24981
+ }
24982
+ if (vertices[i] > maxX) {
24983
+ maxX = vertices[i];
24984
+ }
24985
+ if (vertices[i + 1] < minY) {
24986
+ minY = vertices[i + 1];
24987
+ }
24988
+ if (vertices[i + 1] > maxY) {
24989
+ maxY = vertices[i + 1];
24990
+ }
24991
+ }
24992
+ return [
24993
+ width > 0 ? width : (maxX - minX) * settings.scale,
24994
+ height > 0 ? height : (maxY - minY) * settings.scale
24995
+ ];
24996
+ }
24617
24997
  var Mesh = class _Mesh extends Renderable {
24618
24998
  /**
24619
24999
  * @param {number} x - the x screen position of the mesh object
24620
25000
  * @param {number} y - the y screen position of the mesh object
24621
- * @param {object} settings - Configuration parameters for the Mesh object
24622
- * @param {string} [settings.model] - name of a preloaded OBJ model (via loader.preload with type "obj"). Vertex normals come with it — authored `vn` when the file has them, generated from face geometry when it does not — so an OBJ model can be `lit`.
24623
- * @param {Float32Array|number[]} [settings.vertices] - vertex positions as x,y,z triplets (alternative to settings.model)
24624
- * @param {Float32Array|number[]} [settings.uvs] - texture coordinates as u,v pairs (alternative to settings.model)
24625
- * @param {Uint16Array|number[]} [settings.indices] - triangle vertex indices (alternative to settings.model)
24626
- * @param {HTMLImageElement|TextureAtlas|string} [settings.texture] - the texture to apply (image name, HTMLImageElement, or TextureAtlas). If omitted and settings.material is provided, the texture is resolved from the MTL material's map_Kd. Passing this pins ONE binding over the whole model, which on a multi-material model suppresses the per-material texture split — see {@link Mesh#textureGroups}.
24627
- * @param {string} [settings.material] - name of a preloaded MTL material (via loader.preload with type "mtl"). When provided, the diffuse texture (map_Kd), tint color (Kd), and opacity (d) are automatically applied. On a multi-material model each material's own `map_Kd` is bound for its own slice of the geometry (#1573) and each `Kd` is baked per-vertex, so one `Mesh` renders the whole model.
24628
- * @param {number} settings.width - display width in pixels. With normalization on (the default) the model is scaled to fit this size; with `normalize: false` this is the uniform pixels-per-unit scale applied to the raw geometry.
24629
- * @param {number} [settings.height] - display height in pixels (normalized models only; ignored when `normalize: false`)
24630
- * @param {boolean} [settings.cullBackFaces=true] - enable backface culling
24631
- * @param {boolean} [settings.normalize=true] - fit the source geometry into a `[-0.5, 0.5]` unit cube before scaling, so `width`/`height` behave like a Sprite. Set `false` to keep the geometry's real-world coordinates — required when several meshes share one coordinate space (e.g. nodes of an imported glTF scene) so their relative scale and layout are preserved.
24632
- * @param {number} [settings.scale] - world-space scale (pixels per source unit) for the Camera3d path; defaults to `width`. Set this when `width`/`height` describe the renderable's world bounds (frustum culling) rather than the geometry scale — see {@link Mesh#meshScale}.
24633
- * @param {boolean} [settings.rightHanded=false] - treat the source as right-handed (Y-up, e.g. glTF) under the `Camera3d` world path. The default Y-up→Y-down bridge negates Y only (a reflection, which mirrors the scene left/right); `true` negates Y **and** Z (a rotation) so chirality is preserved and the result matches the authoring tool. See {@link Mesh#rightHanded}.
24634
- * @param {string} [settings.textureRepeat] - texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"` / `"no-repeat"`) this mesh samples its texture with (per-mesh — it does not modify the shared texture, so other meshes/sprites using the same image are unaffected). Use `"repeat"` when the geometry's UVs fall outside the `[0, 1]` range and rely on the texture tiling (e.g. glTF assets, whose default sampler wrap is REPEAT) — otherwise the texture clamps to its edge texels and looks flat. Ignored for the white-pixel fallback. Note: REPEAT on a non-power-of-two texture requires WebGL 2.
24635
- * @param {string} [settings.textureFilter] - texture magnification filter (`"nearest"` for crisp pixel-art upscaling, `"linear"` for smooth) applied to the resolved texture. Omit to keep the renderer's global `antiAlias` default. On the mesh path, linear filtering also samples a generated mip chain with trilinear minification and 4× anisotropy (distant geometry stops shimmering) — `"nearest"` opts out, keeping crisp pixel-art models on hard level-0 sampling. GPU backends only (ignored by the Canvas renderer).
24636
- * @param {number} [settings.alphaCutoff=0] - alpha cutout threshold. Fragments whose final alpha is below this value are discarded (hard-edged cutout — foliage, fences, decals — with no blending or sorting). `0` disables the cutout. Set automatically by the glTF loader from a material's `alphaMode: "MASK"`. GPU mesh path only (WebGL and WebGPU; the Canvas renderer ignores it).
24637
- * @param {number[]|Float32Array} [settings.emissive] - emissive (self-illumination) color `[r, g, b]` (0..1, may exceed 1 for HDR glow) added on top of the lit/unlit color so the surface glows regardless of scene lights (neon, lava, screens). Omit / all-zero for no emission. Set automatically by the glTF loader (`emissiveFactor`) and OBJ loader (MTL `Ke`). GPU mesh path only (WebGL and WebGPU; the Canvas renderer ignores it).
24638
- * @param {boolean} [settings.lit=false] - shade this mesh with the scene's {@link Light3d} lights (the lit mesh pipeline) instead of rendering fullbright. Set automatically by the glTF importer when the scene carries a directional, point or spot light. With `lit` on and no lights present the batcher uploads a white ambient, so the result is indistinguishable from unlit.
24639
- * @param {Uint32Array|Color[]|number[]} [settings.vertexColors] - per-vertex colour, one entry per vertex, multiplied into {@link Mesh#tint}. Either packed RGBA8 (`Uint32Array`, the form the batchers read — no conversion) or one {@link Color} per vertex. Omit for plain white. Lets a single mesh carry a gradient — fading a terrain toward the sky with distance, darkening a crease — which a per-object `tint` cannot express. An explicit value wins over the colours a multi-material OBJ bakes from its MTL.
24640
- * @param {number[]|Float32Array} [settings.normals] - per-vertex normals for the lit path. An explicit value wins over the ones an OBJ or glTF source supplies; omit it and they are taken from the model, or generated from the geometry when the mesh is `lit`. Generated normals average per vertex where faces share vertices (smooth shading) and equal the face normal where they do not (flat shading) — the geometry decides, not a flag.
24641
- * @param {number[]|Float32Array} [settings.specular] - specular color `[r, g, b]` (0..1) for the lit path. Set by the OBJ loader from MTL `Ks`, and derived from glTF metallic/roughness.
24642
- * @param {number} [settings.shininess=0] - specular exponent for the lit path (MTL `Ns`). `0` for a fully diffuse surface.
24643
- * @param {string|TextureAtlas|HTMLImageElement} [settings.alphaMap] - per-texel opacity map, sampled in addition to the diffuse texture (MTL `map_d`).
24644
- * @param {boolean} [settings.castGroundShadow] - give this mesh a blob ground shadow, overriding the application's `castGroundShadow` setting in both directions. Omit to inherit. Needs a GPU backend and a `Camera3d`.
24645
- * @param {boolean} [settings.transparent] - draw in the transparent pass (blended, back-to-front, no depth write). Omit and a mesh goes transparent whenever its draw alpha is fractional; `true` for soft-alpha textures; `false` to stay opaque however faded
24646
- * @param {boolean} [settings.fog] - set `false` to exempt this mesh from the camera's distance fog ({@link Camera3d#setFog}); omit to fog whenever the camera does
24647
- * @param {number} [settings.shadowGroundY] - world Y of the floor the shadow lands on. Omit and the blob sits at the object's own base at full strength; set it and the blob shrinks and fades as the object rises. Render space is Y-down, so the floor is a **greater** Y than the object above it.
24648
- * @param {number} [settings.shadowOpacity=0.45] - opacity of the shadow directly beneath the object, before any height fade.
25001
+ * @param {MeshSettings} settings - Configuration parameters for the Mesh object
24649
25002
  * @example
24650
25003
  * // create from OBJ + MTL (texture auto-resolved from material)
24651
25004
  * let mesh = new me.Mesh(0, 0, {
@@ -24692,7 +25045,7 @@ var Mesh = class _Mesh extends Renderable {
24692
25045
  * mesh.rotate(Math.PI / 4);
24693
25046
  */
24694
25047
  constructor(x, y, settings) {
24695
- super(x, y, settings.width, settings.height);
25048
+ super(x, y, ...declaredSize(settings));
24696
25049
  let objGroups = null;
24697
25050
  let sourceNormals = settings.normals;
24698
25051
  if (typeof settings.model === "string") {
@@ -25175,6 +25528,66 @@ var Mesh = class _Mesh extends Renderable {
25175
25528
  renderer?.deleteMeshGeometry?.(this);
25176
25529
  super.onDeactivateEvent(...args);
25177
25530
  }
25531
+ /**
25532
+ * The centre of this mesh's own geometry, in MODEL space, written into
25533
+ * `_lcx` / `_lcy` / `_lcz`.
25534
+ *
25535
+ * The transparent pass needs a point that represents where a mesh
25536
+ * actually is. Its origin does not: a mesh whose vertices run away from
25537
+ * the origin — a ground plane authored from one corner, a river that
25538
+ * trails behind the boat it is parented to — sits nowhere near it. Keyed
25539
+ * on that origin, such a mesh sorts by an authoring choice rather than by
25540
+ * its position.
25541
+ *
25542
+ * Cached against `_geometryVersion`, so the vertex walk happens once per
25543
+ * geometry edit rather than once per queued draw. An origin-centred mesh
25544
+ * — which is most of them — resolves to (0, 0, 0) and keys exactly as it
25545
+ * did before.
25546
+ * @ignore
25547
+ * @internal
25548
+ */
25549
+ _updateLocalCenter() {
25550
+ if (this._lcVersion === this._geometryVersion) {
25551
+ return;
25552
+ }
25553
+ const v = this.originalVertices;
25554
+ const n = this.vertexCount;
25555
+ let minX = Infinity;
25556
+ let minY = Infinity;
25557
+ let minZ = Infinity;
25558
+ let maxX = -Infinity;
25559
+ let maxY = -Infinity;
25560
+ let maxZ = -Infinity;
25561
+ if (v !== void 0 && n > 0) {
25562
+ for (let i = 0; i < n; i++) {
25563
+ const x = v[i * 3];
25564
+ const y = v[i * 3 + 1];
25565
+ const z = v[i * 3 + 2];
25566
+ if (x < minX) {
25567
+ minX = x;
25568
+ }
25569
+ if (x > maxX) {
25570
+ maxX = x;
25571
+ }
25572
+ if (y < minY) {
25573
+ minY = y;
25574
+ }
25575
+ if (y > maxY) {
25576
+ maxY = y;
25577
+ }
25578
+ if (z < minZ) {
25579
+ minZ = z;
25580
+ }
25581
+ if (z > maxZ) {
25582
+ maxZ = z;
25583
+ }
25584
+ }
25585
+ }
25586
+ this._lcx = Number.isFinite(minX) ? (minX + maxX) * 0.5 : 0;
25587
+ this._lcy = Number.isFinite(minY) ? (minY + maxY) * 0.5 : 0;
25588
+ this._lcz = Number.isFinite(minZ) ? (minZ + maxZ) * 0.5 : 0;
25589
+ this._lcVersion = this._geometryVersion;
25590
+ }
25178
25591
  /**
25179
25592
  * The mesh's world-space 3D axis-aligned bounding box. This is the 3D analog
25180
25593
  * of {@link Renderable#getBounds} (which returns a flat 2D box from
@@ -25587,10 +26000,7 @@ var InstancedMesh = class extends Mesh {
25587
26000
  /**
25588
26001
  * @param {number} x - the x coordinate of the group origin
25589
26002
  * @param {number} y - the y coordinate of the group origin
25590
- * @param {object} settings - every {@link Mesh} setting, plus those below
25591
- * @param {number} [settings.instanceCount=0] - number of instances to pre-allocate. Instances start at the group origin (identity transform) until placed; `addInstance` grows past this.
25592
- * @param {boolean} [settings.instanceColors=false] - give each instance its own colour (16 bytes per instance), multiplied into the mesh tint
25593
- * @param {boolean} [settings.instanceData=false] - give each instance an opaque `vec4` (16 bytes per instance). Read as emissive by the built-in lit shading, or as anything at all by a custom mesh shader.
26003
+ * @param {InstancedMeshSettings} settings - every {@link Mesh} setting, plus the instancing ones
25594
26004
  */
25595
26005
  constructor(x, y, settings) {
25596
26006
  super(x, y, settings);
@@ -26652,9 +27062,16 @@ var Container = class _Container extends Renderable {
26652
27062
  }
26653
27063
  /**
26654
27064
  * Automatically set the specified property of all childs to the given value
27065
+ *
27066
+ * This ASSIGNS to the children — after it, their own property carries the
27067
+ * value. To fade a subtree without touching what the children are, set
27068
+ * {@link Renderable#alpha} on the container instead: opacity cascades at
27069
+ * draw time and leaves every child's own value alone.
26655
27070
  * @param {string} prop - property name
26656
- * @param {object} value - property value
27071
+ * @param {*} value - property value; whatever the named property holds, so a
27072
+ * flag is a boolean and a colour is a string, not only an object
26657
27073
  * @param {boolean} [recursive=false] - recursively apply the value to child containers if true
27074
+ * @see Renderable#alpha
26658
27075
  */
26659
27076
  setChildsProperty(prop, value, recursive) {
26660
27077
  this.forEach((child) => {
@@ -27053,6 +27470,13 @@ function linearToSrgb8(c) {
27053
27470
 
27054
27471
  // src/level/gltf/GLTFModel.js
27055
27472
  var IDENTITY16 = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
27473
+ var _rootScratch = new Array(16);
27474
+ function partAbsolutePosition() {
27475
+ if (this._absPos === void 0) {
27476
+ this._absPos = vector3dPool.get();
27477
+ }
27478
+ return this._absPos.set(this.pos.x, this.pos.y, this.depth);
27479
+ }
27056
27480
  var _t = [0, 0, 0];
27057
27481
  var _r = [0, 0, 0, 1];
27058
27482
  var _s = [1, 1, 1];
@@ -27060,7 +27484,7 @@ var _val = [0, 0, 0, 0];
27060
27484
  var _localScratch = new Array(16);
27061
27485
  var GLTFModel = class extends Container {
27062
27486
  /**
27063
- * @param {object} data - the parsed glTF descriptor (`{ graph, animations, bounds, ... }`)
27487
+ * @param {import("../../loader/loader.js").GLTFData} data - the parsed glTF descriptor, as returned by {@link loader.getGLTF}
27064
27488
  * @param {object} [options]
27065
27489
  * @param {number} [options.scale=1] - pixels per glTF unit (uniform scene scale)
27066
27490
  * @param {boolean} [options.rightHanded=true] - glTF Y-up → engine Y-down via a rotation (no mirror)
@@ -27073,6 +27497,7 @@ var GLTFModel = class extends Container {
27073
27497
  this.scale = options.scale ?? 1;
27074
27498
  this._zSign = options.rightHanded !== false ? -1 : 1;
27075
27499
  this.autoDepth = false;
27500
+ this.autoTransform = false;
27076
27501
  this.applyAnchorTransform = false;
27077
27502
  this._nodes = data2.graph.nodes;
27078
27503
  this._roots = data2.graph.roots;
@@ -27085,6 +27510,9 @@ var GLTFModel = class extends Container {
27085
27510
  const dz = b.max[2] - b.min[2];
27086
27511
  const radius = Math.hypot(dx, dy, dz) / 2 * this.scale;
27087
27512
  const boxSize = Math.max(radius, 1) * Math.SQRT2;
27513
+ this._extent = Math.max(radius, 1);
27514
+ this._extentX = (b.min[0] + b.max[0]) / 2 * this.scale;
27515
+ this._extentY = -((b.min[1] + b.max[1]) / 2) * this.scale;
27088
27516
  const lit = options.lit === true;
27089
27517
  const rightHanded = options.rightHanded !== false;
27090
27518
  const castGroundShadow = typeof options.castGroundShadow === "boolean" ? options.castGroundShadow : void 0;
@@ -27142,6 +27570,7 @@ var GLTFModel = class extends Container {
27142
27570
  mesh.vertexColors = prim.colors;
27143
27571
  }
27144
27572
  mesh.name = node.name;
27573
+ mesh.getAbsolutePosition = partAbsolutePosition;
27145
27574
  (this._meshByNode[idx] ??= []).push(mesh);
27146
27575
  this.addChild(mesh);
27147
27576
  }
@@ -27192,7 +27621,7 @@ var GLTFModel = class extends Container {
27192
27621
  * another clip when this one ends, a `function` legacy completion callback
27193
27622
  * (return `false` to hold the final pose), or an options object.
27194
27623
  * @param {string} name - animation clip id (see {@link GLTFModel#getAnimationNames})
27195
- * @param {string|Function|AnimationOptions} [options] - loop / chain / completion behavior
27624
+ * @param {AnimationOptionsInput} [options] - loop / chain / completion behavior
27196
27625
  * @param {boolean} [preserveTime=false] - keep the current playback time instead of restarting at 0
27197
27626
  * @returns {GLTFModel} this, for chaining
27198
27627
  * @example
@@ -27259,7 +27688,7 @@ var GLTFModel = class extends Container {
27259
27688
  * (and start) it, or with no argument to resume after {@link GLTFModel#pause}.
27260
27689
  * Always clears the paused state.
27261
27690
  * @param {string} [name] - clip id to play; omit to just resume
27262
- * @param {string|Function|AnimationOptions} [options] - loop / chain / completion behavior (see {@link GLTFModel#setCurrentAnimation})
27691
+ * @param {AnimationOptionsInput} [options] - loop / chain / completion behavior (see {@link GLTFModel#setCurrentAnimation})
27263
27692
  * @returns {GLTFModel} this, for chaining
27264
27693
  * @example
27265
27694
  * model.play("walk"); // switch to + play "walk"
@@ -27362,6 +27791,52 @@ var GLTFModel = class extends Container {
27362
27791
  _isLive() {
27363
27792
  return this.pos !== void 0;
27364
27793
  }
27794
+ /**
27795
+ * {@link Container#draw} translates the renderer by `pos` so children draw
27796
+ * in container-local space. This rig's children are already in world space,
27797
+ * so that translation is undone here rather than doubling the placement.
27798
+ * Paired with `autoTransform = false`, which suppresses the matching
27799
+ * rotation fold.
27800
+ * @param {CanvasRenderer|WebGLRenderer} renderer - a renderer instance
27801
+ * @param {Camera2d} [viewport] - the camera rendering this frame
27802
+ */
27803
+ draw(renderer, viewport) {
27804
+ renderer.translate(-this.pos.x, -this.pos.y);
27805
+ super.draw(renderer, viewport);
27806
+ }
27807
+ /**
27808
+ * Where this model is, as a 2D box — what culling, picking and the physics
27809
+ * broadphase all read.
27810
+ *
27811
+ * A `Container` has no dimensions of its own, and one left that way reports
27812
+ * an EMPTY bounds: a model claiming to be nowhere, which the broadphase
27813
+ * cannot place. A rig's size is known at load from the glTF scene AABB, so
27814
+ * this is a placement of a measurement taken once in the constructor rather
27815
+ * than a walk of the parts — `Container#updateBounds` with
27816
+ * `enableChildBoundsUpdate` would re-measure the whole rig on every call,
27817
+ * and that flag is for a group whose extent really is its members' union.
27818
+ *
27819
+ * The box is the model's bounding SPHERE squared off, so a turned model
27820
+ * still reports something that contains it. Generous bounds only cost
27821
+ * broadphase pruning; tight ones lose contacts.
27822
+ * @param {boolean} [absolute=true] - in world rather than local coordinates
27823
+ * @returns {Bounds} this model's bounding box
27824
+ */
27825
+ updateBounds(absolute = true) {
27826
+ const bounds = this.getBounds();
27827
+ const e = this._extent;
27828
+ bounds.setMinMax(
27829
+ this._extentX - e,
27830
+ this._extentY - e,
27831
+ this._extentX + e,
27832
+ this._extentY + e
27833
+ );
27834
+ if (absolute === true) {
27835
+ const absPos = this.getAbsolutePosition();
27836
+ bounds.translate(absPos.x, absPos.y);
27837
+ }
27838
+ return bounds;
27839
+ }
27365
27840
  /**
27366
27841
  * @ignore
27367
27842
  * @internal
@@ -27369,9 +27844,47 @@ var GLTFModel = class extends Container {
27369
27844
  _pose() {
27370
27845
  const clip = this.current.name ? this.anim[this.current.name] : null;
27371
27846
  const t = this.current.time;
27847
+ const parent = this._rootMatrix();
27372
27848
  for (const root of this._roots) {
27373
- this._visit(root, IDENTITY16, clip, t);
27849
+ this._visit(root, parent, clip, t);
27850
+ }
27851
+ }
27852
+ /**
27853
+ * The model's own placement, as the parent transform every root node hangs
27854
+ * from — so `pos`, `depth` and `currentTransform` move, turn and scale the
27855
+ * whole rig the way they do for any other renderable.
27856
+ *
27857
+ * It has to be handed to the DFS in **glTF space**, because that is where
27858
+ * the node matrices live and `_applyWorldToMesh` only bridges to engine
27859
+ * space afterwards. The bridge is `D = diag(1, -1, zSign)` and a uniform
27860
+ * `scale`, so the inverse trip is a division by `scale` for the translation
27861
+ * and a conjugation `D · R · D` for the rotation/scale — `D` is its own
27862
+ * inverse, which is what makes the conjugation this cheap.
27863
+ * @returns {number[]} 16-element column-major matrix
27864
+ * @ignore
27865
+ * @internal
27866
+ */
27867
+ _rootMatrix() {
27868
+ const { x, y, z } = this.pos;
27869
+ const t = this.currentTransform.val;
27870
+ const s = this.scale;
27871
+ if (x === 0 && y === 0 && z === 0 && this.currentTransform.isIdentity()) {
27872
+ return IDENTITY16;
27873
+ }
27874
+ const out = _rootScratch;
27875
+ const zs = this._zSign;
27876
+ for (let col = 0; col < 3; col++) {
27877
+ const dc = col === 0 ? 1 : col === 1 ? -1 : zs;
27878
+ out[col * 4] = t[col * 4] * dc;
27879
+ out[col * 4 + 1] = -t[col * 4 + 1] * dc;
27880
+ out[col * 4 + 2] = zs * t[col * 4 + 2] * dc;
27881
+ out[col * 4 + 3] = 0;
27374
27882
  }
27883
+ out[12] = x / s;
27884
+ out[13] = -y / s;
27885
+ out[14] = zs * z / s;
27886
+ out[15] = 1;
27887
+ return out;
27375
27888
  }
27376
27889
  /**
27377
27890
  * DFS one node: compose its local matrix, multiply by the parent world,
@@ -30203,13 +30716,47 @@ function createShapeObject(settings) {
30203
30716
  }
30204
30717
 
30205
30718
  // src/renderable/text/textstyle.js
30719
+ var VERTICAL_TOLERANCE = 0.1;
30720
+ function resolveFill(context, style) {
30721
+ const gradient = style.fillGradient;
30722
+ if (gradient === void 0) {
30723
+ return style.fillStyle.toRGBA();
30724
+ }
30725
+ const lines = style._text?.length ?? 1;
30726
+ if (style.gradientPerLine !== true || lines <= 1 || gradient.type !== "linear") {
30727
+ return gradient.toCanvasGradient(context);
30728
+ }
30729
+ const [x0, y0, x1, y1] = gradient.coords;
30730
+ const span = y1 - y0;
30731
+ if (span === 0 || Math.abs(x1 - x0) > Math.abs(span) * VERTICAL_TOLERANCE) {
30732
+ return gradient.toCanvasGradient(context);
30733
+ }
30734
+ const lineHeight = style.metrics.lineHeight();
30735
+ const blockHeight = lines * lineHeight;
30736
+ if (blockHeight <= 0) {
30737
+ return gradient.toCanvasGradient(context);
30738
+ }
30739
+ const repeated = context.createLinearGradient(x0, 0, x1, blockHeight);
30740
+ for (let line = 0; line < lines; line++) {
30741
+ for (const stop2 of gradient.colorStops) {
30742
+ const y = line * lineHeight + y0 + stop2.offset * span;
30743
+ repeated.addColorStop(
30744
+ Math.min(1, Math.max(0, y / blockHeight)),
30745
+ stop2.color
30746
+ );
30747
+ }
30748
+ }
30749
+ return repeated;
30750
+ }
30206
30751
  function setContextStyle(context, style) {
30752
+ const fill = resolveFill(context, style);
30207
30753
  context.font = style.font;
30208
- context.fillStyle = style.fillStyle.toRGBA();
30754
+ context.fillStyle = fill;
30209
30755
  context.strokeStyle = style.strokeStyle.toRGBA();
30210
30756
  context.lineWidth = style.lineWidth;
30211
30757
  context.textAlign = style.textAlign;
30212
30758
  context.textBaseline = style.textBaseline;
30759
+ return fill;
30213
30760
  }
30214
30761
 
30215
30762
  // src/renderable/text/text.js
@@ -30237,7 +30784,8 @@ var Text = class extends Renderable {
30237
30784
  * @param {object} settings - the text configuration
30238
30785
  * @param {string} settings.font - a CSS font family: a specific name (`"Arial"`), a generic keyword (`"sans-serif"`, `"monospace"`, …), or a web font loaded via the `fontface` loader (referenced by its family name)
30239
30786
  * @param {number|string} settings.size - the font size: a number in pixels, or a CSS size string with a unit (`"24px"` / `"1.5em"` / `"18pt"`)
30240
- * @param {Color|string} [settings.fillStyle="#000000"] - a CSS color value used to fill the glyphs
30787
+ * @param {Color|Gradient|string} [settings.fillStyle="#000000"] - a CSS color value used to fill the glyphs, or a {@link Gradient} to ramp them
30788
+ * @param {boolean} [settings.gradientPerLine=true] - restart a gradient fill on every line; `false` spans one ramp across the whole block, as a plain canvas does
30241
30789
  * @param {Color|string} [settings.strokeStyle="#000000"] - a CSS color value used for the glyph outline (drawn when `lineWidth` > 0)
30242
30790
  * @param {number} [settings.lineWidth=0] - outline width in pixels (0 = no stroke)
30243
30791
  * @param {string} [settings.textAlign="left"] - horizontal text alignment ("left", "center", "right")
@@ -30245,6 +30793,8 @@ var Text = class extends Renderable {
30245
30793
  * @param {number} [settings.lineHeight=1.0] - line spacing height
30246
30794
  * @param {string|Vector2d|{x:number,y:number}} [settings.anchorPoint={x:0.0, y:0.0}] - anchor point to draw the text at. Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`.
30247
30795
  * @param {number} [settings.wordWrapWidth] - the maximum length in CSS pixels of a line before it wraps
30796
+ * @param {boolean} [settings.bold=false] - render the face bold, as {@link Text#bold} does
30797
+ * @param {boolean} [settings.italic=false] - render the face italic, as {@link Text#italic} does
30248
30798
  * @param {(string|string[])} [settings.text=""] - a string, or an array of strings
30249
30799
  * @example
30250
30800
  * // a styled, word-wrapped, multi-line label using a generic system font
@@ -30263,6 +30813,23 @@ var Text = class extends Renderable {
30263
30813
  * label.setOpacity(0.8); // per-object transparency
30264
30814
  * app.world.addChild(label);
30265
30815
  * @example
30816
+ * // a gradient fill: `fillStyle` takes a Gradient as well as a colour.
30817
+ * // Its coordinates are the label's OWN bake, so (0, 0) is the top-left of
30818
+ * // the render box and the ramp below runs down exactly one line.
30819
+ * const ramp = app.renderer.createLinearGradient(0, 0, 0, 32);
30820
+ * ramp.addColorStop(0, "#fffdf0");
30821
+ * ramp.addColorStop(1, "#ffa71d");
30822
+ *
30823
+ * app.world.addChild(new Text(8, 8, {
30824
+ * font: "sans-serif",
30825
+ * size: 32,
30826
+ * fillStyle: ramp, // ramps the glyphs...
30827
+ * strokeStyle: "#000000", // ...while the outline keeps its own colour
30828
+ * lineWidth: 1,
30829
+ * text: "GAME\nOVER", // every line restarts the ramp by default
30830
+ * // gradientPerLine: false, // ...or span ONE ramp across both lines
30831
+ * }));
30832
+ * @example
30266
30833
  * // a web font (loaded via the fontface loader) is used by its family name
30267
30834
  * loader.preload(
30268
30835
  * [{ name: "kenpixel", type: "fontface", src: "data/font/kenvector.woff2" }],
@@ -30294,11 +30861,16 @@ var Text = class extends Renderable {
30294
30861
  if (typeof this.fillStyle === "undefined") {
30295
30862
  this.fillStyle = colorPool.get(0, 0, 0);
30296
30863
  }
30864
+ this.fillGradient = void 0;
30865
+ this.gradientPerLine = true;
30297
30866
  if (typeof this.strokeStyle === "undefined") {
30298
30867
  this.strokeStyle = colorPool.get(0, 0, 0);
30299
30868
  }
30869
+ this.fillGradient = void 0;
30300
30870
  if (typeof settings.fillStyle !== "undefined") {
30301
- if (settings.fillStyle instanceof Color) {
30871
+ if (settings.fillStyle instanceof Gradient) {
30872
+ this.fillGradient = settings.fillStyle;
30873
+ } else if (settings.fillStyle instanceof Color) {
30302
30874
  this.fillStyle.copy(settings.fillStyle);
30303
30875
  } else {
30304
30876
  this.fillStyle.parseCSS(settings.fillStyle);
@@ -30311,6 +30883,9 @@ var Text = class extends Renderable {
30311
30883
  this.strokeStyle.parseCSS(settings.strokeStyle);
30312
30884
  }
30313
30885
  }
30886
+ if (typeof settings.gradientPerLine === "boolean") {
30887
+ this.gradientPerLine = settings.gradientPerLine;
30888
+ }
30314
30889
  this.lineWidth = settings.lineWidth || 0;
30315
30890
  this.textAlign = settings.textAlign || "left";
30316
30891
  this.textBaseline = settings.textBaseline || "top";
@@ -30425,9 +31000,10 @@ var Text = class extends Renderable {
30425
31000
  true
30426
31001
  );
30427
31002
  const width = Math.ceil(this.metrics.width / 32) * 32;
30428
- const height = Math.ceil(this.metrics.height / 32) * 32;
31003
+ const height = Math.ceil(
31004
+ (this.metrics.height + this.metrics.inkPadTop + this.metrics.inkPadBottom) / 32
31005
+ ) * 32;
30429
31006
  const renderer = this.parentApp?.renderer ?? game.renderer;
30430
- this.canvasTexture.invalidate(renderer);
30431
31007
  if (this.canvasTexture.width < width || this.canvasTexture.height < height) {
30432
31008
  this.canvasTexture.resize(width, height);
30433
31009
  }
@@ -30438,6 +31014,7 @@ var Text = class extends Renderable {
30438
31014
  this.pos.x - this.metrics.x,
30439
31015
  this.pos.y - this.metrics.y
30440
31016
  );
31017
+ this.canvasTexture.invalidate(renderer);
30441
31018
  this.isDirty = true;
30442
31019
  return this;
30443
31020
  }
@@ -30464,9 +31041,18 @@ var Text = class extends Renderable {
30464
31041
  }
30465
31042
  /**
30466
31043
  * the ratio of visible characters (0.0 to 1.0).
30467
- * Setting this automatically updates {@link visibleCharacters}.
31044
+ * Setting this automatically updates {@link Text#visibleCharacters}.
31045
+ *
31046
+ * This is the one to tween: it is independent of how many characters the
31047
+ * label holds, so a reveal takes the same time whatever the string is.
30468
31048
  * @public
30469
31049
  * @type {number}
31050
+ * @default 1.0
31051
+ * @see Text#visibleCharacters
31052
+ * @example
31053
+ * // reveal over two seconds, regardless of length
31054
+ * label.visibleRatio = 0;
31055
+ * new Tween(label).to({ visibleRatio: 1.0 }, { duration: 2000 }).start();
30470
31056
  */
30471
31057
  get visibleRatio() {
30472
31058
  if (this._visibleCharacters === -1) {
@@ -30535,6 +31121,14 @@ var Text = class extends Renderable {
30535
31121
  * measure the given text size in pixels
30536
31122
  * @param {string} [text] - the text to be measured
30537
31123
  * @returns {TextMetrics} a TextMetrics object defining the dimensions of the given piece of text
31124
+ * @example
31125
+ * // size a panel around a label
31126
+ * const size = label.measureText();
31127
+ * panel.resize(size.width + 16, size.height + 16);
31128
+ *
31129
+ * // or measure a string the label does not currently hold, to reserve
31130
+ * // room for the widest state a counter will reach
31131
+ * const widest = label.measureText("00:00").width;
30538
31132
  */
30539
31133
  measureText(text = this._text) {
30540
31134
  return this.metrics.measureText(text, this.canvasTexture.context);
@@ -30544,8 +31138,8 @@ var Text = class extends Renderable {
30544
31138
  * @param {CanvasRenderer|WebGLRenderer} renderer - Reference to the destination renderer instance
30545
31139
  */
30546
31140
  draw(renderer) {
31141
+ this.metrics.updateOrigin();
30547
31142
  if (this.isDirty) {
30548
- this.canvasTexture.invalidate(renderer);
30549
31143
  this.canvasTexture.clear();
30550
31144
  this._drawFont(
30551
31145
  this.canvasTexture.context,
@@ -30553,9 +31147,10 @@ var Text = class extends Renderable {
30553
31147
  this.pos.x - this.metrics.x,
30554
31148
  this.pos.y - this.metrics.y
30555
31149
  );
31150
+ this.canvasTexture.invalidate(renderer);
30556
31151
  }
30557
31152
  let x = this.metrics.x;
30558
- let y = this.metrics.y;
31153
+ let y = this.metrics.y - this.metrics.inkPadTop;
30559
31154
  if (renderer.settings.subPixel === false) {
30560
31155
  x = ~~x;
30561
31156
  y = ~~y;
@@ -30567,25 +31162,54 @@ var Text = class extends Renderable {
30567
31162
  * @internal
30568
31163
  */
30569
31164
  _drawFont(context, text, x, y) {
30570
- setContextStyle(context, this);
30571
- let remaining = this.visibleCharacters;
30572
- for (let i = 0; i < text.length; i++) {
30573
- let string = text[i].trimEnd();
30574
- if (remaining !== -1) {
30575
- if (remaining <= 0) {
30576
- break;
31165
+ context.save();
31166
+ context.translate(0, this.metrics.inkPadTop);
31167
+ const fill = setContextStyle(context, this);
31168
+ const fills = this.fillStyle.alpha > 0;
31169
+ const strokes = this.lineWidth > 0 && this.strokeStyle.alpha > 0;
31170
+ const masked = fills && this.fillGradient !== void 0;
31171
+ const eachLine = (paint) => {
31172
+ let remaining = this.visibleCharacters;
31173
+ let lineY = y;
31174
+ for (let i = 0; i < text.length; i++) {
31175
+ let string = text[i].trimEnd();
31176
+ if (remaining !== -1) {
31177
+ if (remaining <= 0) {
31178
+ break;
31179
+ }
31180
+ string = string.substring(0, remaining);
31181
+ remaining -= string.length;
30577
31182
  }
30578
- string = string.substring(0, remaining);
30579
- remaining -= string.length;
31183
+ paint(string, x, lineY);
31184
+ lineY += this.metrics.lineHeight();
30580
31185
  }
30581
- if (this.fillStyle.alpha > 0) {
30582
- context.fillText(string, x, y);
30583
- }
30584
- if (this.lineWidth > 0 && this.strokeStyle.alpha > 0) {
30585
- context.strokeText(string, x, y);
31186
+ };
31187
+ if (fills) {
31188
+ if (masked === true) {
31189
+ context.fillStyle = "#000000";
30586
31190
  }
30587
- y += this.metrics.lineHeight();
31191
+ eachLine((line, lineX, lineY) => {
31192
+ context.fillText(line, lineX, lineY);
31193
+ });
31194
+ }
31195
+ if (masked === true) {
31196
+ const canvas = context.canvas;
31197
+ context.globalCompositeOperation = "source-in";
31198
+ context.fillStyle = fill;
31199
+ context.fillRect(
31200
+ -1,
31201
+ -this.metrics.inkPadTop - 1,
31202
+ canvas.width + 2,
31203
+ canvas.height + 2
31204
+ );
31205
+ context.globalCompositeOperation = "source-over";
31206
+ }
31207
+ if (strokes) {
31208
+ eachLine((line, lineX, lineY) => {
31209
+ context.strokeText(line, lineX, lineY);
31210
+ });
30588
31211
  }
31212
+ context.restore();
30589
31213
  return this.metrics;
30590
31214
  }
30591
31215
  /**
@@ -30597,6 +31221,7 @@ var Text = class extends Renderable {
30597
31221
  const renderer = this.parentApp?.renderer ?? game.renderer;
30598
31222
  this.canvasTexture.destroy(renderer);
30599
31223
  this.canvasTexture = void 0;
31224
+ this.fillGradient = void 0;
30600
31225
  colorPool.release(this.fillStyle);
30601
31226
  colorPool.release(this.strokeStyle);
30602
31227
  this.fillStyle = this.strokeStyle = void 0;
@@ -30607,6 +31232,8 @@ var Text = class extends Renderable {
30607
31232
  };
30608
31233
 
30609
31234
  // src/renderable/text/textmetrics.js
31235
+ var INK_SLACK = 1;
31236
+ var INK_GUESS = 0.25;
30610
31237
  var TextMetrics = class extends Bounds {
30611
31238
  /**
30612
31239
  * @param {Text|BitmapText} ancestor - the parent object that contains this TextMetrics object
@@ -30690,17 +31317,55 @@ var TextMetrics = class extends Bounds {
30690
31317
  this.glyphYOffset = fontData.glyphMinTop * scaleY;
30691
31318
  this.glyphMaxBottom = fontData.glyphMaxBottom * scaleY;
30692
31319
  }
31320
+ this.inkPadTop = 0;
31321
+ this.inkPadBottom = 0;
31322
+ if (!isBitmapText && strings.length > 0 && typeof context !== "undefined") {
31323
+ const style = this.ancestor;
31324
+ const stroke = style.lineWidth > 0 && style.strokeStyle.alpha > 0 ? style.lineWidth / 2 : 0;
31325
+ const first = context.measureText(strings[0].trimEnd());
31326
+ const last = strings.length === 1 ? first : context.measureText(strings[strings.length - 1].trimEnd());
31327
+ const above = first.actualBoundingBoxAscent;
31328
+ const below = last.actualBoundingBoxDescent;
31329
+ const measured = Number.isFinite(above) && Number.isFinite(below);
31330
+ const top = measured ? above : style.fontSize * INK_GUESS;
31331
+ const bottom = measured ? below : style.fontSize * INK_GUESS;
31332
+ this.inkPadTop = Math.max(0, Math.ceil(top + stroke) + INK_SLACK);
31333
+ this.inkPadBottom = Math.max(
31334
+ 0,
31335
+ Math.ceil(this.inkPadTop + bottom + stroke - this.lineHeight()) + INK_SLACK
31336
+ );
31337
+ }
30693
31338
  this.width = Math.ceil(this.width);
30694
31339
  this.height = Math.ceil(this.height);
31340
+ this.updateOrigin();
31341
+ if (typeof context !== "undefined") {
31342
+ context.restore();
31343
+ }
31344
+ return this;
31345
+ }
31346
+ /**
31347
+ * Recompute the box's position from the ancestor's CURRENT `pos`.
31348
+ *
31349
+ * Split out of `measureText` because it depends on `pos` while everything
31350
+ * else there depends only on the string and the font. A label that moves
31351
+ * needs this refreshed — and nothing else — so the draw path can call it
31352
+ * per frame without re-measuring a single glyph.
31353
+ *
31354
+ * The `Math.floor` is what puts the canvas on a whole pixel. The remainder
31355
+ * it drops is not lost: `Text` bakes its glyphs at `pos - metrics`, so the
31356
+ * sub-pixel part lands in the rasterization, where the font rasterizer can
31357
+ * antialias it, instead of resampling the whole texture.
31358
+ * @returns {TextMetrics} this instance for chaining
31359
+ * @ignore
31360
+ * @internal
31361
+ */
31362
+ updateOrigin() {
30695
31363
  this.x = Math.floor(
30696
31364
  this.ancestor.textAlign === "right" ? this.ancestor.pos.x - this.width : this.ancestor.textAlign === "center" ? this.ancestor.pos.x - this.width / 2 : this.ancestor.pos.x
30697
31365
  );
30698
31366
  this.y = Math.floor(
30699
31367
  this.ancestor.textBaseline.search(/^(top|hanging)$/) === 0 ? this.ancestor.pos.y : this.ancestor.textBaseline === "middle" ? this.ancestor.pos.y - this.lineHeight() / 2 : this.ancestor.pos.y - this.lineHeight()
30700
31368
  );
30701
- if (typeof context !== "undefined") {
30702
- context.restore();
30703
- }
30704
31369
  return this;
30705
31370
  }
30706
31371
  /**
@@ -30748,9 +31413,8 @@ var BitmapText = class extends Renderable {
30748
31413
  * @param {object} settings - the text configuration
30749
31414
  * @param {string|Image} settings.font - a font name to identify the corresponding source image
30750
31415
  * @param {string} [settings.fontData=settings.font] - the bitmap font data corresponding name, or the bitmap font data itself (AngelCode BMFont, `.fnt` text or `.xml`)
30751
- * @param {number} [settings.size] - size a scaling ratio
30752
- * @param {Color|string} [settings.fillStyle] - a CSS color value used to tint the bitmapText (@see BitmapText.tint)
30753
- * @param {number} [settings.lineWidth=1] - line width, in pixels, when drawing stroke
31416
+ * @param {number} [settings.size=1.0] - a scaling RATIO applied to the font's authored size, not a pixel size: `2` draws it at double. ({@link Text} takes pixels here; this one does not.)
31417
+ * @param {Color|string} [settings.fillStyle] - a CSS color value used to tint the glyphs, see {@link Renderable#tint}
30754
31418
  * @param {string} [settings.textAlign="left"] - horizontal text alignment
30755
31419
  * @param {string} [settings.textBaseline="top"] - the text baseline
30756
31420
  * @param {number} [settings.lineHeight=1.0] - line spacing height
@@ -30816,7 +31480,7 @@ var BitmapText = class extends Renderable {
30816
31480
  /**
30817
31481
  * change the font settings
30818
31482
  * @param {string} textAlign - ("left", "center", "right")
30819
- * @param {number} [scale]
31483
+ * @param {number} [scale] - a scaling ratio, applied through {@link BitmapText#resize} when given
30820
31484
  * @returns {BitmapText} this object for chaining
30821
31485
  */
30822
31486
  set(textAlign, scale2) {
@@ -30941,10 +31605,27 @@ var BitmapText = class extends Renderable {
30941
31605
  return bounds;
30942
31606
  }
30943
31607
  /**
30944
- * defines the color used to tint the bitmap text
31608
+ * defines the color used to tint the bitmap text.
31609
+ *
31610
+ * This is {@link Renderable#tint} under another name, so the same rule
31611
+ * applies: white — `(255, 255, 255)` — is the absence of a tint, and any
31612
+ * other colour tints away from there. A page image authored in white
31613
+ * therefore keeps every colour available to it.
30945
31614
  * @public
30946
31615
  * @type {Color}
30947
31616
  * @see Renderable#tint
31617
+ * @example
31618
+ * // tint at construction...
31619
+ * const score = new BitmapText(8, 8, {
31620
+ * font: "arial",
31621
+ * text: "1000",
31622
+ * fillStyle: "#ffd700", // gold
31623
+ * });
31624
+ * app.world.addChild(score);
31625
+ *
31626
+ * // ...or at any point after it, from a CSS string or a Color
31627
+ * score.fillStyle = "#ff4040"; // flash red on damage
31628
+ * score.fillStyle = new Color(255, 255, 255); // back to untinted
30948
31629
  */
30949
31630
  get fillStyle() {
30950
31631
  return this.tint;
@@ -30958,8 +31639,14 @@ var BitmapText = class extends Renderable {
30958
31639
  }
30959
31640
  /**
30960
31641
  * change the font display size
30961
- * @param {number} scale - ratio
31642
+ * @param {number} scale - a ratio against the font's authored size, NOT a
31643
+ * pixel size: `1` is the page image at its native scale, `2` is double
30962
31644
  * @returns {BitmapText} this object for chaining
31645
+ * @example
31646
+ * // a bitmap font is pixel art — whole-number ratios stay crisp, and
31647
+ * // fractional ones resample the page image
31648
+ * title.resize(3); // three times its authored size
31649
+ * title.set("center", 2); // align and rescale in one call
30963
31650
  */
30964
31651
  resize(scale2) {
30965
31652
  this.fontScale.set(scale2, scale2);
@@ -30972,6 +31659,10 @@ var BitmapText = class extends Renderable {
30972
31659
  * measure the given text size in pixels
30973
31660
  * @param {string} [text]
30974
31661
  * @returns {TextMetrics} a TextMetrics object with two properties: `width` and `height`, defining the output dimensions
31662
+ * @example
31663
+ * // size a panel around a label, at the label's CURRENT scale
31664
+ * const size = label.measureText();
31665
+ * panel.resize(size.width + 16, size.height + 16);
30975
31666
  */
30976
31667
  measureText(text = this._text) {
30977
31668
  return this.metrics.measureText(text);
@@ -34510,6 +35201,21 @@ function initParsers() {
34510
35201
  setParser("shader", preloadShader);
34511
35202
  parserInitialized = true;
34512
35203
  }
35204
+ var _app2;
35205
+ on(GAME_INIT, (app) => {
35206
+ _app2 = app;
35207
+ });
35208
+ function runShaderPrewarm() {
35209
+ if (_app2 === void 0 || _app2.settings?.prewarm !== true) {
35210
+ return Promise.resolve();
35211
+ }
35212
+ try {
35213
+ return Promise.resolve(_app2.renderer?.prewarm?.()).catch(() => {
35214
+ });
35215
+ } catch {
35216
+ return Promise.resolve();
35217
+ }
35218
+ }
34513
35219
  function completeLoading(onloadcb) {
34514
35220
  const callback = onloadcb || onload;
34515
35221
  if (typeof callback === "function") {
@@ -34553,7 +35259,7 @@ function preload(assets, onloadcb, switchToLoadState = true) {
34553
35259
  if (switchToLoadState === true) {
34554
35260
  state_default.change(state_default.LOADING);
34555
35261
  }
34556
- return Promise.all(promises).then(() => {
35262
+ return Promise.all(promises).then(runShaderPrewarm).then(() => {
34557
35263
  completeLoading(onload);
34558
35264
  });
34559
35265
  }
@@ -38399,6 +39105,18 @@ var WebGLBatcher = class extends Batcher {
38399
39105
  vertex.clear();
38400
39106
  }
38401
39107
  }
39108
+ /**
39109
+ * Build any shader variant this batcher compiles lazily.
39110
+ *
39111
+ * A batcher whose only program is the one built in its constructor has
39112
+ * nothing to do here — which is every batcher but the mesh one.
39113
+ * @returns {Promise<void>} settles once this batcher is warm
39114
+ * @ignore
39115
+ * @internal
39116
+ */
39117
+ prewarm() {
39118
+ return Promise.resolve();
39119
+ }
38402
39120
  };
38403
39121
 
38404
39122
  // src/video/gpu/primitives.ts
@@ -40544,7 +41262,7 @@ function writeLight3dBlock(out, packed) {
40544
41262
  }
40545
41263
 
40546
41264
  // src/video/webgl/shaders/mesh-lit.frag
40547
- var mesh_lit_default = "#version 300 es\n// Lit mesh fragment shader: half-Lambert diffuse from directional lights\n// plus an ambient floor.\n//\n// GLSL ES 3.00 because the light data arrives in a uniform block, which does\n// not exist in ES 1.00. That is what lifted the light cap: uniform arrays are\n// charged against MAX_FRAGMENT_UNIFORM_VECTORS, a small driver-reported\n// budget, while a block is charged against MAX_UNIFORM_BLOCK_SIZE.\n//\n// `__MAX_LIGHTS__` is replaced with the MAX_LIGHTS constant\n// (src/video/webgl/lighting/constants.ts) by LitMeshBatcher._shaderSources at\n// load time, so the array size can't drift from the uniform packer. (Used\n// directly rather than via a #define so it survives the GLSL preprocessor\n// regardless of how it handles macros.)\n//\n// The block's layout is std140 and is written by\n// src/video/webgl/lighting/std140.ts. The two must agree byte for byte \u2014 a\n// mismatch does not fail to link, it silently shifts every light.\n//\n// No `precision` line here on purpose: `setPrecision` injects float and int\n// precision after `#version`, using whatever the device reports and the\n// application asked for, so shader files don't hardcode it.\n\nuniform sampler2D uSampler;\nuniform float uAlphaCutoff; // alpha cutout threshold (0 = disabled)\nuniform vec3 uEmissive; // self-illumination color (0 = none)\nuniform vec3 uSpecular; // specular color (0 = purely diffuse)\nuniform float uShininess; // specular exponent (0 = no highlight)\nuniform vec3 uEyePosition; // camera position, world space\nuniform sampler2D uAlphaMap; // per-texel opacity (MTL map_d)\nuniform float uHasAlphaMap; // 0 = uAlphaMap is filler, ignore it\n\n// One light, type inferred from sentinels (see std140.ts):\n// posRange.w < 0 -> directional (dirCone.xyz = surface->light, normalized);\n// otherwise positional at posRange.xyz with quadratic falloff over\n// posRange.w, plus a spot cone when dirCone.w > -1 (dirCone.xyz = the cone\n// axis / travel direction, w = cos(outer), colorInner.w = cos(inner)).\n// colorInner.rgb is premultiplied by intensity.\nstruct Light3dData {\n vec4 posRange;\n vec4 dirCone;\n vec4 colorInner;\n};\n\nlayout(std140) uniform Light3dBlock {\n // `count` is a float: mixing int and float members would stop the staging\n // buffer being a single Float32Array view, and the cast below is free. The\n // vec3 that follows aligns to 16 bytes, so the three floats after `count`\n // are padding either way.\n float uLightCount;\n vec3 uAmbient;\n Light3dData uLights[__MAX_LIGHTS__];\n};\n\nin vec4 vColor;\nin vec2 vRegion;\nin vec3 vNormal;\nin vec3 vWorldPos;\n#ifdef INSTANCE_DATA\n// per-instance custom slot \u2014 read as emissive by this built-in shading\nin vec4 vInstanceData;\n#endif\n\nout vec4 fragColor;\n\n// Distance fog is a COMPILED VARIANT, not a runtime branch: the batcher\n// injects `#define FOG` only while a camera has fog enabled. A software\n// rasterizer predicates both sides of a branch, so an `exp()` guarded at\n// runtime still costs every fragment of every scene \u2014 including the scenes\n// that never asked for fog.\n#ifdef FOG\nuniform vec3 uFogColor; // straight (unpremultiplied) fog colour\nuniform vec4 uFogParams; // x = mode (0 off / 1 linear / 2 exp2),\n // y = near, z = 1/(far - near), w = density\nin float vFogDepth; // radial view-space distance, interpolated\n\n// Fold distance fog into a PREMULTIPLIED colour. Twin of the block in\n// mesh.frag \u2014 keep the two in step; they are duplicated rather than shared\n// because the production build loads each shader as raw text (no preprocessor\n// include), and this shader is ES 3.00 while its twin is ES 1.00.\n//\n// `rgb` is already multiplied by `a`, so the fog colour has to be scaled by\n// the fragment's own coverage. A plain mix toward uFogColor would paint\n// full-strength fog onto near-transparent fragments \u2014 grey halos around every\n// alpha-cutout leaf.\n//\n// Mode 0 returns the input untouched, so a scene with no fog is bit-identical\n// to one built before fog existed.\nvec3 applyFog(vec3 rgb, float a) {\n float mode = uFogParams.x;\n if (mode < 0.5) {\n return rgb;\n }\n float f; // fraction of the scene colour that survives\n if (mode < 1.5) {\n f = 1.0 - clamp((vFogDepth - uFogParams.y) * uFogParams.z, 0.0, 1.0);\n } else {\n float dd = vFogDepth * uFogParams.w;\n f = exp(-dd * dd);\n }\n return mix(uFogColor * a, rgb, f);\n}\n#endif\n\n\n\nvoid main(void) {\n vec4 base = texture(uSampler, vRegion);\n\n // per-texel opacity (MTL map_d) multiplies in BEFORE the cutout, so one\n // material can cut out to the shape of a leaf rather than at a single\n // threshold across the whole surface. Red channel: the format stores a\n // greyscale map, and every channel carries the same value.\n // Sampled unconditionally and WEIGHTED rather than branched: when no map\n // is bound the second sampler is filler (the diffuse texture), so the\n // value is thrown away \u2014 and both backends run the identical expression\n // instead of one branching and the other not.\n base.a *= mix(1.0, texture(uAlphaMap, vRegion).r, uHasAlphaMap);\n\n // hard alpha cutout (glTF alphaMode MASK) \u2014 discard before any shading\n // so cut-away texels cost nothing and never write depth.\n // Thresholded on the MATERIAL's own alpha, deliberately BEFORE the tint\n // multiply. The cutout describes the shape of the surface, and that shape\n // does not change when the object fades: testing the drawn alpha instead\n // makes a cutout mesh vanish completely the moment its opacity crosses its\n // own threshold \u2014 and `Sprite3d` defaults that threshold to 0.5, so half a\n // fade-out was a hard pop.\n if (base.a < uAlphaCutoff) {\n discard;\n }\n base *= vColor;\n\n // A mesh marked `lit` with no usable normals \u2014 the 2D-camera path,\n // which leaves world normals unwritten, or geometry that supplied none \u2014\n // would normalize a zero vector to NaN and render BLACK. Degrade to\n // unlit instead: wrong, but recognisably the model rather than a hole.\n float nLength = length(vNormal);\n if (nLength < 1e-6) {\n // the emissive term is built exactly as the lit path below builds\n // it, per-instance slot included \u2014 degrading to unlit must not also\n // drop an instance's glow\n vec3 unlitEmissive = uEmissive;\n#ifdef INSTANCE_DATA\n unlitEmissive += vInstanceData.rgb;\n#endif\n#ifdef FOG\n fragColor = vec4(applyFog(base.rgb + unlitEmissive, base.a), base.a);\n#else\n fragColor = vec4(base.rgb + unlitEmissive, base.a);\n#endif\n return;\n }\n vec3 N = vNormal / nLength;\n vec3 lit = uAmbient;\n // Blinn-Phong specular, accumulated alongside the diffuse term. Gated on\n // the exponent rather than the colour: `Ns` of 0 is the format's \"no\n // highlight\", and exporters happily write a non-black `Ks` next to it.\n // The half-vector form (rather than reflect()) is the cheaper of the two\n // and is what the fixed-function pipeline this format predates used.\n vec3 specular = vec3(0.0);\n bool hasSpecular = uShininess > 0.0;\n vec3 V = hasSpecular ? normalize(uEyePosition - vWorldPos) : vec3(0.0);\n // ES 3.00 allows a non-constant loop bound, so this runs exactly as many\n // iterations as there are live lights \u2014 unused capacity costs nothing,\n // which is what makes a larger cap safe.\n // clamped to the array size, not just taken on trust: if the block ever\n // read as something other than what the writer put there, an unbounded\n // trip count would also index `uLights` out of range\n int count = min(int(uLightCount), __MAX_LIGHTS__);\n for (int i = 0; i < count; i++) {\n vec4 pr = uLights[i].posRange;\n vec4 dc = uLights[i].dirCone;\n vec3 L;\n float atten = 1.0;\n if (pr.w < 0.0) {\n // directional: dirCone.xyz is already the surface->light vector\n L = dc.xyz;\n } else {\n // positional: quadratic falloff over range (the Light2d model \u2014\n // stylized, not physical inverse-square, which is unusable in\n // pixel-unit worlds with unit intensities)\n vec3 toLight = pr.xyz - vWorldPos;\n float dist = max(length(toLight), 1e-4);\n L = toLight / dist;\n float linearAtt = max(0.0, 1.0 - dist / pr.w);\n atten = linearAtt * linearAtt;\n if (dc.w > -1.0) {\n // spot cone: -L is the light->surface direction; fade from\n // the inner cone cosine to the outer\n float cd = dot(-L, dc.xyz);\n atten *= smoothstep(dc.w, uLights[i].colorInner.w, cd);\n }\n }\n // Half-Lambert (\"wrap\") diffuse: dot * 0.5 + 0.5. Softens the\n // terminator and lifts the shadowed side, for a gentler, more\n // diffuse look than hard Lambert (which reads as harsh noon).\n float ndl = dot(N, L) * 0.5 + 0.5;\n lit += uLights[i].colorInner.rgb * (ndl * ndl * atten);\n if (hasSpecular) {\n // masked by the UNWRAPPED Lambert term: half-Lambert lifts the\n // shadowed side, and a highlight on a surface facing away from\n // the light reads as a rendering error\n float facing = max(dot(N, L), 0.0);\n vec3 H = normalize(L + V);\n float spec = pow(max(dot(N, H), 0.0), uShininess);\n specular += uLights[i].colorInner.rgb * (spec * facing * atten);\n }\n }\n\n // emissive self-illuminates: added AFTER lighting so it glows at full\n // strength regardless of the scene lights (neon, lava, glowing eyes).\n vec3 emissive = uEmissive;\n#ifdef INSTANCE_DATA\n emissive += vInstanceData.rgb;\n#endif\n#ifdef FOG\n fragColor = vec4(\n applyFog(base.rgb * lit + specular * uSpecular + emissive, base.a),\n base.a\n );\n#else\n fragColor = vec4(base.rgb * lit + specular * uSpecular + emissive, base.a);\n#endif\n}\n";
41265
+ var mesh_lit_default = "#version 300 es\n// Lit mesh fragment shader: half-Lambert diffuse from directional lights\n// plus an ambient floor.\n//\n// GLSL ES 3.00 because the light data arrives in a uniform block, which does\n// not exist in ES 1.00. That is what lifted the light cap: uniform arrays are\n// charged against MAX_FRAGMENT_UNIFORM_VECTORS, a small driver-reported\n// budget, while a block is charged against MAX_UNIFORM_BLOCK_SIZE.\n//\n// `__MAX_LIGHTS__` is replaced with the MAX_LIGHTS constant\n// (src/video/webgl/lighting/constants.ts) by LitMeshBatcher._shaderSources at\n// load time, so the array size can't drift from the uniform packer. (Used\n// directly rather than via a #define so it survives the GLSL preprocessor\n// regardless of how it handles macros.)\n//\n// The block's layout is std140 and is written by\n// src/video/webgl/lighting/std140.ts. The two must agree byte for byte \u2014 a\n// mismatch does not fail to link, it silently shifts every light.\n//\n// No `precision` line here on purpose: `setPrecision` injects float and int\n// precision after `#version`, using whatever the device reports and the\n// application asked for, so shader files don't hardcode it.\n\nuniform sampler2D uSampler;\nuniform float uAlphaCutoff; // alpha cutout threshold (0 = disabled)\nuniform vec3 uEmissive; // self-illumination color (0 = none)\nuniform vec3 uSpecular; // specular color (0 = purely diffuse)\nuniform float uShininess; // specular exponent (0 = no highlight)\nuniform vec3 uEyePosition; // camera position, world space\nuniform sampler2D uAlphaMap; // per-texel opacity (MTL map_d)\nuniform float uHasAlphaMap; // 0 = uAlphaMap is filler, ignore it\n\n// One light, type inferred from sentinels (see std140.ts):\n// posRange.w < 0 -> directional (dirCone.xyz = surface->light, normalized);\n// otherwise positional at posRange.xyz with quadratic falloff over\n// posRange.w, plus a spot cone when dirCone.w > -1 (dirCone.xyz = the cone\n// axis / travel direction, w = cos(outer), colorInner.w = cos(inner)).\n// colorInner.rgb is premultiplied by intensity.\nstruct Light3dData {\n vec4 posRange;\n vec4 dirCone;\n vec4 colorInner;\n};\n\nlayout(std140) uniform Light3dBlock {\n // `count` is a float: mixing int and float members would stop the staging\n // buffer being a single Float32Array view, and the cast below is free. The\n // vec3 that follows aligns to 16 bytes, so the three floats after `count`\n // are padding either way.\n float uLightCount;\n vec3 uAmbient;\n Light3dData uLights[__MAX_LIGHTS__];\n};\n\nin vec4 vColor;\nin vec2 vRegion;\nin vec3 vNormal;\nin vec3 vWorldPos;\n#ifdef INSTANCE_DATA\n// per-instance custom slot \u2014 read as emissive by this built-in shading\nin vec4 vInstanceData;\n#endif\n\nout vec4 fragColor;\n\n// Distance fog is a COMPILED VARIANT, not a runtime branch: the batcher\n// injects `#define FOG` only while a camera has fog enabled. A software\n// rasterizer predicates both sides of a branch, so an `exp()` guarded at\n// runtime still costs every fragment of every scene \u2014 including the scenes\n// that never asked for fog.\n#ifdef FOG\nuniform vec3 uFogColor; // straight (unpremultiplied) fog colour\nuniform vec4 uFogParams; // x = mode (0 off / 1 linear / 2 exp2),\n // y = near, z = 1/(far - near), w = density\nin float vFogDepth; // radial view-space distance, interpolated\n\n// Fold distance fog into a PREMULTIPLIED colour. Twin of the block in\n// mesh.frag \u2014 keep the two in step; they are duplicated rather than shared\n// because the production build loads each shader as raw text (no preprocessor\n// include), and this shader is ES 3.00 while its twin is ES 1.00.\n//\n// `rgb` is already multiplied by `a`, so the fog colour has to be scaled by\n// the fragment's own coverage. A plain mix toward uFogColor would paint\n// full-strength fog onto near-transparent fragments \u2014 grey halos around every\n// alpha-cutout leaf.\n//\n// Mode 0 returns the input untouched, so a scene with no fog is bit-identical\n// to one built before fog existed.\nvec3 applyFog(vec3 rgb, float a) {\n float mode = uFogParams.x;\n if (mode < 0.5) {\n return rgb;\n }\n float f; // fraction of the scene colour that survives\n if (mode < 1.5) {\n f = 1.0 - clamp((vFogDepth - uFogParams.y) * uFogParams.z, 0.0, 1.0);\n } else {\n float dd = vFogDepth * uFogParams.w;\n f = exp(-dd * dd);\n }\n return mix(uFogColor * a, rgb, f);\n}\n#endif\n\n\n\n// The mesh-hosted ShaderEffect hook (#1658). Identity by default \u2014 every\n// compiler inlines it away, so an un-effected mesh pays nothing. When an\n// effect is hosted, `spliceEffect` replaces this definition with one that\n// calls the effect's `apply()`, and pastes the effect body above it.\n//\n// Deliberately a FUNCTION and not a marker comment: the shader pipeline\n// strips comments, so a comment cannot be relied on to reach the splicer.\nvec4 ME_effect(vec4 c, vec2 uv) { return c; }\n\nvoid main(void) {\n vec4 base = texture(uSampler, vRegion);\n\n // per-texel opacity (MTL map_d) multiplies in BEFORE the cutout, so one\n // material can cut out to the shape of a leaf rather than at a single\n // threshold across the whole surface. Red channel: the format stores a\n // greyscale map, and every channel carries the same value.\n // Sampled unconditionally and WEIGHTED rather than branched: when no map\n // is bound the second sampler is filler (the diffuse texture), so the\n // value is thrown away \u2014 and both backends run the identical expression\n // instead of one branching and the other not.\n base.a *= mix(1.0, texture(uAlphaMap, vRegion).r, uHasAlphaMap);\n\n // hard alpha cutout (glTF alphaMode MASK) \u2014 discard before any shading\n // so cut-away texels cost nothing and never write depth.\n // Thresholded on the MATERIAL's own alpha, deliberately BEFORE the tint\n // multiply. The cutout describes the shape of the surface, and that shape\n // does not change when the object fades: testing the drawn alpha instead\n // makes a cutout mesh vanish completely the moment its opacity crosses its\n // own threshold \u2014 and `Sprite3d` defaults that threshold to 0.5, so half a\n // fade-out was a hard pop.\n if (base.a < uAlphaCutoff) {\n discard;\n }\n base *= vColor;\n base = ME_effect(base, vRegion);\n\n // A mesh marked `lit` with no usable normals \u2014 the 2D-camera path,\n // which leaves world normals unwritten, or geometry that supplied none \u2014\n // would normalize a zero vector to NaN and render BLACK. Degrade to\n // unlit instead: wrong, but recognisably the model rather than a hole.\n float nLength = length(vNormal);\n if (nLength < 1e-6) {\n // the emissive term is built exactly as the lit path below builds\n // it, per-instance slot included \u2014 degrading to unlit must not also\n // drop an instance's glow\n vec3 unlitEmissive = uEmissive;\n#ifdef INSTANCE_DATA\n unlitEmissive += vInstanceData.rgb;\n#endif\n#ifdef FOG\n fragColor = vec4(applyFog(base.rgb + unlitEmissive, base.a), base.a);\n#else\n fragColor = vec4(base.rgb + unlitEmissive, base.a);\n#endif\n return;\n }\n vec3 N = vNormal / nLength;\n vec3 lit = uAmbient;\n // Blinn-Phong specular, accumulated alongside the diffuse term. Gated on\n // the exponent rather than the colour: `Ns` of 0 is the format's \"no\n // highlight\", and exporters happily write a non-black `Ks` next to it.\n // The half-vector form (rather than reflect()) is the cheaper of the two\n // and is what the fixed-function pipeline this format predates used.\n vec3 specular = vec3(0.0);\n bool hasSpecular = uShininess > 0.0;\n vec3 V = hasSpecular ? normalize(uEyePosition - vWorldPos) : vec3(0.0);\n // ES 3.00 allows a non-constant loop bound, so this runs exactly as many\n // iterations as there are live lights \u2014 unused capacity costs nothing,\n // which is what makes a larger cap safe.\n // clamped to the array size, not just taken on trust: if the block ever\n // read as something other than what the writer put there, an unbounded\n // trip count would also index `uLights` out of range\n int count = min(int(uLightCount), __MAX_LIGHTS__);\n for (int i = 0; i < count; i++) {\n vec4 pr = uLights[i].posRange;\n vec4 dc = uLights[i].dirCone;\n vec3 L;\n float atten = 1.0;\n if (pr.w < 0.0) {\n // directional: dirCone.xyz is already the surface->light vector\n L = dc.xyz;\n } else {\n // positional: quadratic falloff over range (the Light2d model \u2014\n // stylized, not physical inverse-square, which is unusable in\n // pixel-unit worlds with unit intensities)\n vec3 toLight = pr.xyz - vWorldPos;\n float dist = max(length(toLight), 1e-4);\n L = toLight / dist;\n float linearAtt = max(0.0, 1.0 - dist / pr.w);\n atten = linearAtt * linearAtt;\n if (dc.w > -1.0) {\n // spot cone: -L is the light->surface direction; fade from\n // the inner cone cosine to the outer\n float cd = dot(-L, dc.xyz);\n atten *= smoothstep(dc.w, uLights[i].colorInner.w, cd);\n }\n }\n // Half-Lambert (\"wrap\") diffuse: dot * 0.5 + 0.5. Softens the\n // terminator and lifts the shadowed side, for a gentler, more\n // diffuse look than hard Lambert (which reads as harsh noon).\n float ndl = dot(N, L) * 0.5 + 0.5;\n lit += uLights[i].colorInner.rgb * (ndl * ndl * atten);\n if (hasSpecular) {\n // masked by the UNWRAPPED Lambert term: half-Lambert lifts the\n // shadowed side, and a highlight on a surface facing away from\n // the light reads as a rendering error\n float facing = max(dot(N, L), 0.0);\n vec3 H = normalize(L + V);\n float spec = pow(max(dot(N, H), 0.0), uShininess);\n specular += uLights[i].colorInner.rgb * (spec * facing * atten);\n }\n }\n\n // emissive self-illuminates: added AFTER lighting so it glows at full\n // strength regardless of the scene lights (neon, lava, glowing eyes).\n vec3 emissive = uEmissive;\n#ifdef INSTANCE_DATA\n emissive += vInstanceData.rgb;\n#endif\n#ifdef FOG\n fragColor = vec4(\n applyFog(base.rgb * lit + specular * uSpecular + emissive, base.a),\n base.a\n );\n#else\n fragColor = vec4(base.rgb * lit + specular * uSpecular + emissive, base.a);\n#endif\n}\n";
40548
41266
 
40549
41267
  // src/video/webgl/shaders/mesh-lit.vert
40550
41268
  var mesh_lit_default2 = "#version 300 es\n// Lit mesh vertex shader (Camera3d + Light3d) \u2014 retained-mode.\n//\n// GLSL ES 3.00 because the fragment stage reads light data from a uniform\n// block, which does not exist in ES 1.00. Both stages of a program must share\n// a dialect, so the vertex shader follows.\n//\n// Same placement as mesh.vert (clip = projection \xD7 view \xD7 model \xD7 vertex,\n// vertices in MODEL space) plus a normal carried to the fragment shader in\n// world space for diffuse shading.\nin vec3 aVertex;\nin vec2 aRegion;\nin vec4 aColor;\n// Model-space normal, rotated into world space below.\nin vec3 aNormal;\n\nuniform mat4 uProjectionMatrix;\nuniform mat4 uViewMatrix;\nuniform mat4 uModelMatrix;\nuniform vec4 uTint;\n\nout vec2 vRegion;\nout vec4 vColor;\nout vec3 vNormal;\n// world-space fragment position \u2014 positional lights (point / spot) fall\n// off with distance, so the fragment stage needs where the surface IS\nout vec3 vWorldPos;\n#ifdef FOG\nout float vFogDepth;\n#endif\n\n#ifdef FOG\nuniform vec4 uFogHeight; // xyz = falloff x world-up axis, in VIEW space;\n // w = exp(k * (cameraY - fogHeight)), pre-baked\n\n// How much the height falloff scales the fog along this view ray.\n//\n// Density falls off exponentially with altitude, and an exponential integrates\n// analytically along a straight segment, so the whole ray costs one `exp` and\n// no marching. The result multiplies the distance, which leaves both fog\n// curves exactly as they are.\n//\n// Takes the VIEW-space position, not a height. The only position this stage\n// has is pre-view \u2014 `uModelMatrix * position` \u2014 and that is the mesh's parent\n// space rather than the world, because `Container.draw` folds every ancestor\n// into the view matrix. Reading a height straight off it put the fog floor at\n// the wrong altitude for anything under a scaled container. Dotting the\n// view-space position against the world-up axis expressed in view space gives\n// the height above the camera whatever those ancestors did, since they are\n// inside the very matrix that produced this position. `uFogHeight.xyz`\n// carries the falloff too, so the exponent is one dot product.\n//\n// Render space is Y-DOWN: density rises as height INCREASES, the opposite\n// sign to every published form of this. The axis is signed that way round,\n// and there is a test that catches it being flipped.\n//\n// A falloff of 0 gives exactly 1: the axis is all zero, so `kdy` is 0, the\n// series limit is taken, and the pre-baked term is exp(0) \u2014 uniform fog is\n// not a special case, it is this with the dial at zero.\nfloat fogHeightFactor(vec3 viewPos) {\n float kdy = dot(uFogHeight.xyz, viewPos);\n // (exp(x) - 1) / x is 0/0 at x = 0, and a horizontal view ray \u2014 looking\n // straight across a valley \u2014 is exactly that case. Take the limit rather\n // than guarding, or the fog steps as the ray approaches horizontal.\n float t = abs(kdy) < 1e-4 ? 1.0 : (exp(kdy) - 1.0) / kdy;\n return uFogHeight.w * t;\n}\n#endif\n\nvoid main(void) {\n vec4 worldPos = uModelMatrix * vec4(aVertex, 1.0);\n gl_Position = uProjectionMatrix * uViewMatrix * worldPos;\n vWorldPos = worldPos.xyz;\n#ifdef FOG\n // Radial view-space distance for distance fog. Radial rather than view-space\n // z, so fog holds steady as the camera turns instead of sliding across the\n // scene. The clip position above keeps its own product: re-associating it\n // could shift vertices by an ulp, and a scene without fog must be unchanged.\n vec3 viewPos = (uViewMatrix * worldPos).xyz;\n vFogDepth = length(viewPos) * fogHeightFactor(viewPos);\n#endif\n vec4 tinted = aColor * uTint;\n vColor = vec4(tinted.rgb * tinted.a, tinted.a);\n vRegion = aRegion;\n // Rotate the normal into world space with the model matrix's upper 3\xD73.\n // Lighting is evaluated in world space, so the view transform is\n // deliberately excluded. Uniform scale cancels when the fragment shader\n // renormalizes; non-uniform scale is approximated \u2014 unchanged behaviour,\n // an exact result would need the inverse-transpose.\n vNormal = mat3(uModelMatrix) * aNormal;\n}\n";
@@ -40744,7 +41462,7 @@ function vertexDataExceedsUint16(indices) {
40744
41462
  }
40745
41463
 
40746
41464
  // src/video/webgl/shaders/mesh.frag
40747
- var mesh_default = "uniform sampler2D uSampler;\nuniform float uAlphaCutoff; // alpha cutout threshold (0 = disabled)\nuniform vec3 uEmissive; // self-illumination color added on top (0 = none)\nuniform sampler2D uAlphaMap; // per-texel opacity (MTL map_d)\nuniform float uHasAlphaMap; // 0 = uAlphaMap is filler, ignore it\nvarying vec4 vColor;\nvarying vec2 vRegion;\n// Distance fog is a COMPILED VARIANT, not a runtime branch: the batcher\n// injects `#define FOG` only while a camera has fog enabled. A software\n// rasterizer predicates both sides of a branch, so an `exp()` guarded at\n// runtime still costs every fragment of every scene \u2014 including the scenes\n// that never asked for fog.\n#ifdef FOG\nuniform vec3 uFogColor; // straight (unpremultiplied) fog colour\nuniform vec4 uFogParams; // x = mode (0 off / 1 linear / 2 exp2),\n // y = near, z = 1/(far - near), w = density\nvarying float vFogDepth; // radial view-space distance, interpolated\n\n// Fold distance fog into a PREMULTIPLIED colour.\n//\n// `rgb` here is already multiplied by `a` (the vertex stage premultiplies\n// vColor), so the fog colour has to be scaled by the fragment's own coverage.\n// A plain mix toward uFogColor would paint full-strength fog onto\n// near-transparent fragments \u2014 grey halos around every alpha-cutout leaf, and\n// unattenuated fog added on the blended shadow pass.\n//\n// Mode 0 returns the input untouched, so a scene with no fog is bit-identical\n// to one built before fog existed.\nvec3 applyFog(vec3 rgb, float a) {\n float mode = uFogParams.x;\n if (mode < 0.5) {\n return rgb;\n }\n float f; // fraction of the scene colour that survives\n if (mode < 1.5) {\n // linear: 1 at `near`, reaching 0 at `far`\n f = 1.0 - clamp((vFogDepth - uFogParams.y) * uFogParams.z, 0.0, 1.0);\n } else {\n // exponential squared: survival = exp(-(density * d)^2)\n float dd = vFogDepth * uFogParams.w;\n f = exp(-dd * dd);\n }\n return mix(uFogColor * a, rgb, f);\n}\n#endif\n\n\n#ifdef INSTANCE_DATA\n// per-instance custom slot. The built-in shading reads its rgb as emissive,\n// so a forest can glow per tree without a uniform per instance; a CUSTOM mesh\n// shader is free to read the same slot as anything else entirely.\nvarying vec4 vInstanceData;\n#endif\n\nvoid main(void) {\n vec4 texel = texture2D(uSampler, vRegion);\n // per-texel opacity (MTL map_d) multiplies in BEFORE the cutout, so one\n // material can cut out to the shape of a leaf rather than at a single\n // threshold across the whole surface. Red channel: the format stores a\n // greyscale map, and every channel carries the same value.\n // Sampled unconditionally and WEIGHTED rather than branched: when no map\n // is bound the second sampler is filler (the diffuse texture), so the\n // value is thrown away \u2014 and both backends run the identical expression\n // instead of one branching and the other not.\n texel.a *= mix(1.0, texture2D(uAlphaMap, vRegion).r, uHasAlphaMap);\n // hard alpha cutout (glTF alphaMode MASK): drop fully-transparent texels\n // so foliage / fences / decals read crisp without blending or sorting.\n // Thresholded on the MATERIAL's own alpha, deliberately BEFORE the tint\n // multiply. The cutout describes the shape of the surface, and that shape\n // does not change when the object fades: testing the drawn alpha instead\n // makes a cutout mesh vanish completely the moment its opacity crosses its\n // own threshold \u2014 and `Sprite3d` defaults that threshold to 0.5, so half a\n // fade-out was a hard pop.\n if (texel.a < uAlphaCutoff) {\n discard;\n }\n vec4 color = texel * vColor;\n // emissive adds a self-lit color on top (neon, lava, screens); the unlit\n // path has no lighting, so it's simply added to the base color.\n vec3 emissive = uEmissive;\n#ifdef INSTANCE_DATA\n emissive += vInstanceData.rgb;\n#endif\n#ifdef FOG\n gl_FragColor = vec4(applyFog(color.rgb + emissive, color.a), color.a);\n#else\n gl_FragColor = vec4(color.rgb + emissive, color.a);\n#endif\n}\n";
41465
+ var mesh_default = "uniform sampler2D uSampler;\nuniform float uAlphaCutoff; // alpha cutout threshold (0 = disabled)\nuniform vec3 uEmissive; // self-illumination color added on top (0 = none)\nuniform sampler2D uAlphaMap; // per-texel opacity (MTL map_d)\nuniform float uHasAlphaMap; // 0 = uAlphaMap is filler, ignore it\nvarying vec4 vColor;\nvarying vec2 vRegion;\n// Distance fog is a COMPILED VARIANT, not a runtime branch: the batcher\n// injects `#define FOG` only while a camera has fog enabled. A software\n// rasterizer predicates both sides of a branch, so an `exp()` guarded at\n// runtime still costs every fragment of every scene \u2014 including the scenes\n// that never asked for fog.\n#ifdef FOG\nuniform vec3 uFogColor; // straight (unpremultiplied) fog colour\nuniform vec4 uFogParams; // x = mode (0 off / 1 linear / 2 exp2),\n // y = near, z = 1/(far - near), w = density\nvarying float vFogDepth; // radial view-space distance, interpolated\n\n// Fold distance fog into a PREMULTIPLIED colour.\n//\n// `rgb` here is already multiplied by `a` (the vertex stage premultiplies\n// vColor), so the fog colour has to be scaled by the fragment's own coverage.\n// A plain mix toward uFogColor would paint full-strength fog onto\n// near-transparent fragments \u2014 grey halos around every alpha-cutout leaf, and\n// unattenuated fog added on the blended shadow pass.\n//\n// Mode 0 returns the input untouched, so a scene with no fog is bit-identical\n// to one built before fog existed.\nvec3 applyFog(vec3 rgb, float a) {\n float mode = uFogParams.x;\n if (mode < 0.5) {\n return rgb;\n }\n float f; // fraction of the scene colour that survives\n if (mode < 1.5) {\n // linear: 1 at `near`, reaching 0 at `far`\n f = 1.0 - clamp((vFogDepth - uFogParams.y) * uFogParams.z, 0.0, 1.0);\n } else {\n // exponential squared: survival = exp(-(density * d)^2)\n float dd = vFogDepth * uFogParams.w;\n f = exp(-dd * dd);\n }\n return mix(uFogColor * a, rgb, f);\n}\n#endif\n\n\n#ifdef INSTANCE_DATA\n// per-instance custom slot. The built-in shading reads its rgb as emissive,\n// so a forest can glow per tree without a uniform per instance; a CUSTOM mesh\n// shader is free to read the same slot as anything else entirely.\nvarying vec4 vInstanceData;\n#endif\n\n// The mesh-hosted ShaderEffect hook (#1658). Identity by default \u2014 every\n// compiler inlines it away, so an un-effected mesh pays nothing. When an\n// effect is hosted, `spliceEffect` replaces this definition with one that\n// calls the effect's `apply()`, and pastes the effect body above it.\n//\n// Deliberately a FUNCTION and not a marker comment: the shader pipeline\n// strips comments, so a comment cannot be relied on to reach the splicer.\nvec4 ME_effect(vec4 c, vec2 uv) { return c; }\n\nvoid main(void) {\n vec4 texel = texture2D(uSampler, vRegion);\n // per-texel opacity (MTL map_d) multiplies in BEFORE the cutout, so one\n // material can cut out to the shape of a leaf rather than at a single\n // threshold across the whole surface. Red channel: the format stores a\n // greyscale map, and every channel carries the same value.\n // Sampled unconditionally and WEIGHTED rather than branched: when no map\n // is bound the second sampler is filler (the diffuse texture), so the\n // value is thrown away \u2014 and both backends run the identical expression\n // instead of one branching and the other not.\n texel.a *= mix(1.0, texture2D(uAlphaMap, vRegion).r, uHasAlphaMap);\n // hard alpha cutout (glTF alphaMode MASK): drop fully-transparent texels\n // so foliage / fences / decals read crisp without blending or sorting.\n // Thresholded on the MATERIAL's own alpha, deliberately BEFORE the tint\n // multiply. The cutout describes the shape of the surface, and that shape\n // does not change when the object fades: testing the drawn alpha instead\n // makes a cutout mesh vanish completely the moment its opacity crosses its\n // own threshold \u2014 and `Sprite3d` defaults that threshold to 0.5, so half a\n // fade-out was a hard pop.\n if (texel.a < uAlphaCutoff) {\n discard;\n }\n vec4 color = texel * vColor;\n color = ME_effect(color, vRegion);\n // emissive adds a self-lit color on top (neon, lava, screens); the unlit\n // path has no lighting, so it's simply added to the base color.\n vec3 emissive = uEmissive;\n#ifdef INSTANCE_DATA\n emissive += vInstanceData.rgb;\n#endif\n#ifdef FOG\n gl_FragColor = vec4(applyFog(color.rgb + emissive, color.a), color.a);\n#else\n gl_FragColor = vec4(color.rgb + emissive, color.a);\n#endif\n}\n";
40748
41466
 
40749
41467
  // src/video/webgl/shaders/mesh.vert
40750
41468
  var mesh_default2 = "// Mesh vertex shader (retained-mode).\n//\n// Vertices arrive in MODEL space and are placed by uniforms rather than by\n// CPU-side transformation, so moving/rotating/scaling a mesh costs a uniform\n// update instead of re-uploading its geometry:\n//\n// clip = projection \xD7 view \xD7 model \xD7 vertex\n//\n// `uModelMatrix` carries the mesh's own transform (including the Y/Z axis\n// bridge and mesh scale); `uViewMatrix` carries the camera view plus any\n// ancestor container transform. Both are always written before a draw \u2014 the\n// accumulation path (2D camera, vertices pre-projected on the CPU) pushes\n// identity explicitly. Note GLSL uniforms default to ZERO, not identity, so a\n// path that reached this shader without setting them would collapse every\n// vertex onto the origin rather than drawing untransformed.\nattribute vec3 aVertex;\nattribute vec2 aRegion;\nattribute vec4 aColor;\n\nuniform mat4 uProjectionMatrix;\nuniform mat4 uViewMatrix;\nuniform mat4 uModelMatrix;\n// Per-draw tint \xD7 global alpha. Kept out of the vertex data so that changing\n// a mesh's tint never invalidates its retained geometry.\nuniform vec4 uTint;\n\nvarying vec2 vRegion;\nvarying vec4 vColor;\n#ifdef FOG\nvarying float vFogDepth;\n#endif\n\n#ifdef FOG\nuniform vec4 uFogHeight; // xyz = falloff x world-up axis, in VIEW space;\n // w = exp(k * (cameraY - fogHeight)), pre-baked\n\n// How much the height falloff scales the fog along this view ray.\n//\n// Density falls off exponentially with altitude, and an exponential integrates\n// analytically along a straight segment, so the whole ray costs one `exp` and\n// no marching. The result multiplies the distance, which leaves both fog\n// curves exactly as they are.\n//\n// Takes the VIEW-space position, not a height. The only position this stage\n// has is pre-view \u2014 `uModelMatrix * position` \u2014 and that is the mesh's parent\n// space rather than the world, because `Container.draw` folds every ancestor\n// into the view matrix. Reading a height straight off it put the fog floor at\n// the wrong altitude for anything under a scaled container. Dotting the\n// view-space position against the world-up axis expressed in view space gives\n// the height above the camera whatever those ancestors did, since they are\n// inside the very matrix that produced this position. `uFogHeight.xyz`\n// carries the falloff too, so the exponent is one dot product.\n//\n// Render space is Y-DOWN: density rises as height INCREASES, the opposite\n// sign to every published form of this. The axis is signed that way round,\n// and there is a test that catches it being flipped.\n//\n// A falloff of 0 gives exactly 1: the axis is all zero, so `kdy` is 0, the\n// series limit is taken, and the pre-baked term is exp(0) \u2014 uniform fog is\n// not a special case, it is this with the dial at zero.\nfloat fogHeightFactor(vec3 viewPos) {\n float kdy = dot(uFogHeight.xyz, viewPos);\n // (exp(x) - 1) / x is 0/0 at x = 0, and a horizontal view ray \u2014 looking\n // straight across a valley \u2014 is exactly that case. Take the limit rather\n // than guarding, or the fog steps as the ray approaches horizontal.\n float t = abs(kdy) < 1e-4 ? 1.0 : (exp(kdy) - 1.0) / kdy;\n return uFogHeight.w * t;\n}\n#endif\n\nvoid main(void) {\n gl_Position =\n uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aVertex, 1.0);\n#ifdef FOG\n // Radial view-space distance for distance fog. Radial rather than view-space\n // z, so fog holds steady as the camera turns instead of sliding across the\n // scene. The clip position above keeps its own product: re-associating it\n // could shift vertices by an ulp, and a scene without fog must be unchanged.\n vec4 viewPos = uViewMatrix * uModelMatrix * vec4(aVertex, 1.0);\n vFogDepth = length(viewPos.xyz) * fogHeightFactor(viewPos.xyz);\n#endif\n // tint first, then premultiply \u2014 matches the fragment shader's expectation\n vec4 tinted = aColor * uTint;\n vColor = vec4(tinted.rgb * tinted.a, tinted.a);\n vRegion = aRegion;\n}\n";
@@ -41314,6 +42032,99 @@ var MeshBatcher = class extends MaterialBatcher {
41314
42032
  fragmentDefines
41315
42033
  );
41316
42034
  }
42035
+ /**
42036
+ * The program that hosts a `ShaderEffect` on a mesh (#1658).
42037
+ *
42038
+ * A ShaderEffect is realized against the QUAD contract — its vertex stage
42039
+ * projects straight from `uProjectionMatrix` and declares neither
42040
+ * `uModelMatrix` nor `uViewMatrix` — so it cannot be bound to a mesh as-is:
42041
+ * the geometry arrives in model space and would draw unplaced and without
42042
+ * the camera (the very case `validateShaderLocations` warns about). Instead
42043
+ * the body is spliced into THIS batcher's own mesh shader, so the mesh keeps
42044
+ * its placement, alpha cutout, lighting and fog and the effect becomes a
42045
+ * colour hook over the result.
42046
+ *
42047
+ * Keyed through the same variant map as every other permutation, so it is
42048
+ * released on context loss and destroy along with the rest — and keyed on
42049
+ * fog too, since the spliced source inherits the fog `#define`.
42050
+ * @param {object} effect - the hosted ShaderEffect
42051
+ * @returns {GLShader} the spliced program
42052
+ * @ignore
42053
+ * @internal
42054
+ */
42055
+ effectShaderFor(effect) {
42056
+ const fog = this._fogDefine();
42057
+ const sources = this._shaderSources();
42058
+ const fragment2 = spliceEffect(sources.fragment, effect._meshBody);
42059
+ if (fragment2 === null) {
42060
+ return null;
42061
+ }
42062
+ const compat = /^\s*#version\s+300\s+es/m.test(sources.fragment) ? "#define texture2D texture\n" : "";
42063
+ try {
42064
+ return this.shaderVariant(
42065
+ `effect|${effect._effectId}|${fog !== "" ? 1 : 0}`,
42066
+ { vertex: sources.vertex, fragment: fragment2 },
42067
+ fog,
42068
+ fog + compat
42069
+ );
42070
+ } catch (error) {
42071
+ if (this._meshEffectCompileWarned !== true) {
42072
+ this._meshEffectCompileWarned = true;
42073
+ console.warn(
42074
+ `melonJS: this ShaderEffect failed to compile against the mesh shader \u2014 ${error instanceof Error ? error.message : String(error)}. The mesh draws with the built-in shading.`
42075
+ );
42076
+ }
42077
+ return null;
42078
+ }
42079
+ }
42080
+ /**
42081
+ * Bring a spliced program up to date with the effect's uniforms.
42082
+ *
42083
+ * `ShaderEffect.setUniform` writes into the effect's OWN quad program;
42084
+ * this one is a different program, so every value the effect has ever been
42085
+ * given is replayed onto it. Unknown names are skipped rather than passed
42086
+ * on: `GLShader.setUniform` throws on one, and a body is free to declare
42087
+ * uniforms the spliced mesh source optimised away.
42088
+ * @param {object} effect - the hosted ShaderEffect
42089
+ * @param {GLShader} shader - the spliced program
42090
+ * @ignore
42091
+ * @internal
42092
+ */
42093
+ replayEffectUniforms(effect, shader) {
42094
+ for (const [name, value] of effect._uniformValues) {
42095
+ if (shader.uniforms[name] !== void 0) {
42096
+ shader.setUniform(name, value);
42097
+ }
42098
+ }
42099
+ }
42100
+ /**
42101
+ * Whether this batcher can host `effect`, warning once when it cannot.
42102
+ * @param {object} effect - the candidate ShaderEffect
42103
+ * @returns {boolean} true when the effect can be spliced in
42104
+ * @ignore
42105
+ * @internal
42106
+ */
42107
+ canHostEffect(effect) {
42108
+ if (typeof effect._meshBody !== "string") {
42109
+ return false;
42110
+ }
42111
+ if (!hasSpliceMarkers(this._shaderSources().fragment)) {
42112
+ return false;
42113
+ }
42114
+ const blocker = meshHostingBlocker(effect._meshBuiltins, [
42115
+ ...effect._extraTextures?.keys() ?? []
42116
+ ]);
42117
+ if (blocker === null) {
42118
+ return true;
42119
+ }
42120
+ if (this._meshEffectBlockedWarned !== true) {
42121
+ this._meshEffectBlockedWarned = true;
42122
+ console.warn(
42123
+ `melonJS: this ShaderEffect cannot be hosted on a Mesh \u2014 ${blocker}. The mesh draws with the built-in shading.`
42124
+ );
42125
+ }
42126
+ return false;
42127
+ }
41317
42128
  /**
41318
42129
  * `"#define FOG\n"` while the camera drawing has fog, `""` otherwise.
41319
42130
  *
@@ -41359,6 +42170,71 @@ var MeshBatcher = class extends MaterialBatcher {
41359
42170
  }
41360
42171
  return shader;
41361
42172
  }
42173
+ /**
42174
+ * Build every shader variant this batcher can reach, so none of them is
42175
+ * compiled on the frame a scene first draws.
42176
+ *
42177
+ * The set is enumerable because each variant is a source permutation, not
42178
+ * a runtime branch: fog on or off, and the three instance-record slots.
42179
+ * That is 1 fogged mesh + 8 instanced + 2 ground-shadow programs; the
42180
+ * plain mesh program is built in the constructor and is already here.
42181
+ *
42182
+ * Yields between programs. Linking is synchronous in WebGL and a dozen
42183
+ * links in one task is a dropped frame — which on a loading screen is a
42184
+ * stuttering progress bar, trading one visible hitch for another.
42185
+ * @returns {Promise<void>} settles once every variant is linked
42186
+ * @ignore
42187
+ * @internal
42188
+ */
42189
+ async prewarm() {
42190
+ const yieldToFrame = () => {
42191
+ if (globalThis.document?.hidden === true) {
42192
+ return Promise.resolve();
42193
+ }
42194
+ return new Promise((resolve) => {
42195
+ setTimeout(resolve, 0);
42196
+ });
42197
+ };
42198
+ const alive = () => {
42199
+ return this.gl !== null && this.gl?.isContextLost?.() !== true;
42200
+ };
42201
+ const sources = this._shaderSources();
42202
+ const instanced = this._instancedShaderSources();
42203
+ for (const fogDefine of ["", "#define FOG\n"]) {
42204
+ if (fogDefine !== "") {
42205
+ this.shaderVariant("mesh|fog", sources, fogDefine, fogDefine);
42206
+ await yieldToFrame();
42207
+ if (!alive()) {
42208
+ return;
42209
+ }
42210
+ }
42211
+ for (const hasColor of [false, true]) {
42212
+ for (const hasData of [false, true]) {
42213
+ const key = (hasColor ? 1 : 0) | (hasData ? 2 : 0) | (fogDefine !== "" ? 4 : 0);
42214
+ this.shaderVariant(
42215
+ `instanced|${key}`,
42216
+ instanced,
42217
+ (hasColor ? "#define INSTANCE_COLORS\n" : "") + (hasData ? "#define INSTANCE_DATA\n" : "") + fogDefine,
42218
+ (hasData ? "#define INSTANCE_DATA\n" : "") + fogDefine
42219
+ );
42220
+ await yieldToFrame();
42221
+ if (!alive()) {
42222
+ return;
42223
+ }
42224
+ }
42225
+ }
42226
+ this.shaderVariant(
42227
+ fogDefine === "" ? "shadow" : "shadow|fog",
42228
+ { vertex: mesh_shadow_instanced_default, fragment: mesh_default },
42229
+ fogDefine,
42230
+ fogDefine
42231
+ );
42232
+ await yieldToFrame();
42233
+ if (!alive()) {
42234
+ return;
42235
+ }
42236
+ }
42237
+ }
41362
42238
  /**
41363
42239
  * Whether the bound program is one this batcher would pick for a plain
41364
42240
  * mesh — its own, or the fog variant of its own.
@@ -43632,6 +44508,7 @@ var WebGLRenderer = class _WebGLRenderer extends Renderer {
43632
44508
  this.onContextRestoredInvalidate = (renderer) => {
43633
44509
  if (renderer === this) {
43634
44510
  this.currentProgram = void 0;
44511
+ this._prewarmed = void 0;
43635
44512
  }
43636
44513
  };
43637
44514
  this.onCanvasResize = (width, height) => {
@@ -43715,6 +44592,29 @@ var WebGLRenderer = class _WebGLRenderer extends Renderer {
43715
44592
  batcher.releaseRetained?.(mesh);
43716
44593
  });
43717
44594
  }
44595
+ /**
44596
+ * Compile every shader variant the built-in batchers can reach.
44597
+ *
44598
+ * A WebGL batcher builds its own program in its constructor, so the base
44599
+ * programs are already linked by the time `init()` resolves. What is not
44600
+ * is the mesh batcher's permutations — fog, and the three instance-record
44601
+ * slots — which are built on first use and so land on the frame a 3D
44602
+ * scene first draws.
44603
+ * @returns {Promise<void>} settles once every variant is linked
44604
+ * @see {@link Renderer#prewarm}
44605
+ */
44606
+ prewarm() {
44607
+ if (this._prewarmed === void 0) {
44608
+ this._prewarmed = Promise.all(
44609
+ [...this.batchers.values()].map((batcher) => {
44610
+ return batcher.prewarm();
44611
+ })
44612
+ ).then(() => {
44613
+ this.setBatcher("quad");
44614
+ });
44615
+ }
44616
+ return this._prewarmed;
44617
+ }
43718
44618
  destroy() {
43719
44619
  off(GAME_RESET, this.onGameReset);
43720
44620
  off(ONCONTEXT_RESTORED, this.onContextRestoredInvalidate);
@@ -44841,8 +45741,14 @@ var WebGLRenderer = class _WebGLRenderer extends Renderer {
44841
45741
  return;
44842
45742
  }
44843
45743
  this.setBatcher(mesh.lit === true ? "litMesh" : "mesh");
44844
- const hostedShader = this.customShader != null && this.customShader.program != null;
45744
+ const effectHosted = this.customShader != null && this.currentBatcher.canHostEffect?.(this.customShader) === true;
45745
+ const spliced = effectHosted ? this.currentBatcher.effectShaderFor(this.customShader) : null;
45746
+ let hostedShader = spliced !== null && spliced !== void 0;
44845
45747
  if (hostedShader) {
45748
+ this.currentBatcher.useShader(spliced);
45749
+ this.currentBatcher.replayEffectUniforms(this.customShader, spliced);
45750
+ } else if (this.customShader != null && this.customShader.program != null) {
45751
+ hostedShader = true;
44846
45752
  this.currentBatcher.useShader(this.customShader);
44847
45753
  } else if (this.customShader != null && this.customShader.isWebGL !== true && this._meshShaderWarned !== true) {
44848
45754
  this._meshShaderWarned = true;
@@ -46598,8 +47504,8 @@ var Pointer = class extends Bounds {
46598
47504
  this.isPrimary = typeof event.isPrimary !== "undefined" ? event.isPrimary : true;
46599
47505
  this.button = event.button || 0;
46600
47506
  this.type = event.type;
46601
- if (typeof _app2?.viewport !== "undefined") {
46602
- _app2.viewport.localToWorld(this.gameScreenX, this.gameScreenY, tmpVec);
47507
+ if (typeof _app3?.viewport !== "undefined") {
47508
+ _app3.viewport.localToWorld(this.gameScreenX, this.gameScreenY, tmpVec);
46603
47509
  }
46604
47510
  this.gameWorldX = tmpVec.x;
46605
47511
  this.gameWorldY = tmpVec.y;
@@ -46620,9 +47526,9 @@ var pointer_default = Pointer;
46620
47526
  var T_POINTERS = [];
46621
47527
  var eventHandlers = /* @__PURE__ */ new Map();
46622
47528
  var currentPointer;
46623
- var _app2;
47529
+ var _app3;
46624
47530
  on(GAME_INIT, (app) => {
46625
- _app2 = app;
47531
+ _app3 = app;
46626
47532
  });
46627
47533
  var pointerInitialized = false;
46628
47534
  var lastTimeStamp = 0;
@@ -46686,7 +47592,7 @@ function registerEventListener(eventList, callback) {
46686
47592
  }
46687
47593
  function enablePointerEvent() {
46688
47594
  if (!pointerInitialized) {
46689
- if (!_app2) {
47595
+ if (!_app3) {
46690
47596
  throw new Error("Pointer events require an initialized Application");
46691
47597
  }
46692
47598
  currentPointer = new Rect(0, 0, 1, 1);
@@ -46694,7 +47600,7 @@ function enablePointerEvent() {
46694
47600
  T_POINTERS.push(new pointer_default());
46695
47601
  }
46696
47602
  if (pointerEventTarget === null || pointerEventTarget === void 0) {
46697
- pointerEventTarget = _app2.renderer.getCanvas();
47603
+ pointerEventTarget = _app3.renderer.getCanvas();
46698
47604
  }
46699
47605
  if (pointerEvent) {
46700
47606
  activeEventList = pointerEventList;
@@ -46748,7 +47654,7 @@ function enablePointerEvent() {
46748
47654
  globalThis.document.addEventListener(
46749
47655
  POINTERLOCKCHANGE,
46750
47656
  () => {
46751
- locked = globalThis.document.pointerLockElement === _app2.getParentElement();
47657
+ locked = globalThis.document.pointerLockElement === _app3.getParentElement();
46752
47658
  emit(POINTERLOCKCHANGE, locked);
46753
47659
  },
46754
47660
  true
@@ -46798,7 +47704,7 @@ function dispatchEvent(normalizedEvents2) {
46798
47704
  }
46799
47705
  lastTimeStamp = pointer2.event.timeStamp;
46800
47706
  }
46801
- const worldOffset = _app2.world.pos;
47707
+ const worldOffset = _app3.world.pos;
46802
47708
  const absoluteWorldX = pointer2.gameWorldX + worldOffset.x;
46803
47709
  const absoluteWorldY = pointer2.gameWorldY + worldOffset.y;
46804
47710
  currentPointer.pos.set(pointer2.gameWorldX, pointer2.gameWorldY);
@@ -46808,16 +47714,16 @@ function dispatchEvent(normalizedEvents2) {
46808
47714
  pointer2.gameY = pointer2.gameLocalY = pointer2.gameScreenY;
46809
47715
  emit(POINTERMOVE, pointer2);
46810
47716
  }
46811
- let candidates = _app2.world.broadphase.retrieve(
47717
+ let candidates = _app3.world.broadphase.retrieve(
46812
47718
  currentPointer,
46813
- (a, b) => _app2.world._sortReverseZ(a, b),
47719
+ (a, b) => _app3.world._sortReverseZ(a, b),
46814
47720
  void 0
46815
47721
  );
46816
47722
  if (worldOffset.x !== 0 || worldOffset.y !== 0) {
46817
47723
  currentPointer.pos.set(absoluteWorldX, absoluteWorldY);
46818
- const absoluteCandidates = _app2.world.broadphase.retrieve(
47724
+ const absoluteCandidates = _app3.world.broadphase.retrieve(
46819
47725
  currentPointer,
46820
- (a, b) => _app2.world._sortReverseZ(a, b),
47726
+ (a, b) => _app3.world._sortReverseZ(a, b),
46821
47727
  void 0
46822
47728
  );
46823
47729
  for (const candidate of candidates) {
@@ -46826,10 +47732,10 @@ function dispatchEvent(normalizedEvents2) {
46826
47732
  }
46827
47733
  }
46828
47734
  candidates = absoluteCandidates.sort(
46829
- (a, b) => _app2.world._sortReverseZ(a, b)
47735
+ (a, b) => _app3.world._sortReverseZ(a, b)
46830
47736
  );
46831
47737
  }
46832
- candidates = candidates.concat([_app2.viewport]);
47738
+ candidates = candidates.concat([_app3.viewport]);
46833
47739
  for (let c = candidates.length, candidate; c--, candidate = candidates[c]; ) {
46834
47740
  if (eventHandlers.has(candidate) && candidate.isKinematic !== true) {
46835
47741
  const handlers = eventHandlers.get(candidate);
@@ -46992,11 +47898,11 @@ function hasRegisteredEvents() {
46992
47898
  }
46993
47899
  function globalToLocal(x, y, v) {
46994
47900
  v = v || vector2dPool.get();
46995
- const rect = getElementBounds(_app2.renderer.getCanvas());
47901
+ const rect = getElementBounds(_app3.renderer.getCanvas());
46996
47902
  const pixelRatio = globalThis.devicePixelRatio || 1;
46997
47903
  x -= rect.left + (globalThis.pageXOffset || 0);
46998
47904
  y -= rect.top + (globalThis.pageYOffset || 0);
46999
- const scale2 = _app2.renderer.scaleRatio;
47905
+ const scale2 = _app3.renderer.scaleRatio;
47000
47906
  if (scale2.x !== 1 || scale2.y !== 1) {
47001
47907
  x /= scale2.x;
47002
47908
  y /= scale2.y;
@@ -47088,7 +47994,7 @@ function releaseAllPointerEvents(region) {
47088
47994
  }
47089
47995
  function requestPointerLock() {
47090
47996
  if (hasPointerLockSupport) {
47091
- const element = _app2.getParentElement();
47997
+ const element = _app3.getParentElement();
47092
47998
  void element.requestPointerLock();
47093
47999
  return true;
47094
48000
  }
@@ -48408,7 +49314,7 @@ function _computeShapeAbsPos(renderable, shape, outPos) {
48408
49314
  var _shapeAbs = new Vector2d(0, 0);
48409
49315
  function _raycastPolygon(fromX, fromY, dx, dy, shape, shapeAbsX, shapeAbsY) {
48410
49316
  const points = shape.points;
48411
- const normals = shape.normals;
49317
+ const normals = shape.edgeNormals;
48412
49318
  const len = points.length;
48413
49319
  let bestT = Infinity;
48414
49320
  let bestNormalIdx = -1;
@@ -48649,10 +49555,10 @@ function vornoiRegion(line, point) {
48649
49555
  }
48650
49556
  function testPolygonPolygon(a, polyA, b, polyB, response) {
48651
49557
  const aPoints = polyA.points;
48652
- const aNormals = polyA.normals;
49558
+ const aNormals = polyA.edgeNormals;
48653
49559
  const aLen = aNormals.length;
48654
49560
  const bPoints = polyB.points;
48655
- const bNormals = polyB.normals;
49561
+ const bNormals = polyB.edgeNormals;
48656
49562
  const bLen = bNormals.length;
48657
49563
  const posA = T_VECTORS[--T_VECTORS_IDX].copy(a.pos).add(a.ancestor.getAbsolutePosition()).add(polyA.pos);
48658
49564
  const posB = T_VECTORS[--T_VECTORS_IDX].copy(b.pos).add(b.ancestor.getAbsolutePosition()).add(polyB.pos);
@@ -48792,7 +49698,7 @@ function testPolygonEllipse(a, polyA, b, ellipseB, response) {
48792
49698
  }
48793
49699
  }
48794
49700
  } else {
48795
- normal.copy(polyA.normals[i]);
49701
+ normal.copy(polyA.edgeNormals[i]);
48796
49702
  dist = point.dot(normal);
48797
49703
  const distAbs = Math.abs(dist);
48798
49704
  if ((len === 1 || dist > 0) && distAbs > radius) {
@@ -51772,7 +52678,7 @@ var Trigger = class extends Renderable {
51772
52678
  };
51773
52679
 
51774
52680
  // src/version.ts
51775
- var version = "20.4.0";
52681
+ var version = "20.6.0";
51776
52682
 
51777
52683
  // src/system/bootstrap.ts
51778
52684
  var initialized = false;
@@ -52222,7 +53128,7 @@ var LIT_INSTANCED = {
52222
53128
  };
52223
53129
 
52224
53130
  // src/video/webgpu/shaders/mesh-lit.wgsl
52225
- var mesh_lit_default3 = "// melonJS WebGPU lit mesh shader \u2014 the WGSL port of mesh-lit.vert /\n// mesh-lit.frag: half-Lambert diffuse from directional Light3d lights plus\n// an ambient floor, over the mesh tier's uniform-driven placement.\n//\n// Vertex layout (48-byte stride, shared with the WebGL backend): the\n// 36-byte unlit layout plus\n// @location(3) aNormal float32x3 offset 36\n// aNormal is MODEL-space on the retained path (rotated below by the model\n// matrix's upper 3\xD73) and world-space on the accumulated path \u2014 which\n// supplies an identity model matrix, so one shader serves both.\n//\n// The std140 Light3dBlock binds at group 2 with a dynamic offset \u2014 one\n// snapshot per light change per frame, per the queue-write ordering law.\n// Its layout is written by src/video/webgl/lighting/std140.ts and the two\n// must agree byte for byte \u2014 a mismatch does not fail validation, it\n// silently shifts every light.\n\nstruct FrameUniforms {\n projection : mat4x4<f32>,\n // unused by this shader; part of the shared frame-globals block\n lineWidth : f32,\n};\n\nstruct MeshUniforms {\n model : mat4x4<f32>,\n view : mat4x4<f32>,\n tint : vec4f,\n // x = alpha cutout threshold (0 = disabled), y = 1 when an opacity map\n // is bound (0 = the second sampler is filler); z, w reserved\n params : vec4f,\n // self-illumination added AFTER lighting (r, g, b; w reserved)\n emissive : vec4f,\n // specular color (rgb) and exponent (w); w = 0 means no highlight\n specular : vec4f,\n // the camera's world position (xyz; w reserved), for the specular\n // half-vector \u2014 nothing else in this shader needs it\n eye : vec4f,\n // straight (unpremultiplied) fog colour (rgb; w reserved)\n fogColor : vec4f,\n // x = mode (0 off / 1 linear / 2 exp2), y = near, z = 1/(far - near),\n // w = density\n fogParams : vec4f,\n // xyz = height falloff x the world-up axis, in VIEW space;\n // w = exp(k * (cameraY - fogHeight)), pre-baked on the CPU\n fogHeight : vec4f,\n};\n\n// One light, type inferred from sentinels (see std140.ts):\n// posRange.w < 0 \u2192 directional (dirCone.xyz = surface\u2192light, normalized);\n// otherwise positional at posRange.xyz with quadratic falloff over\n// posRange.w, plus a spot cone when dirCone.w > -1 (dirCone.xyz = the\n// cone axis / travel direction, w = cos(outer), colorInner.w =\n// cos(inner)). colorInner.rgb is premultiplied by intensity.\nstruct Light3dData {\n posRange : vec4f,\n dirCone : vec4f,\n colorInner : vec4f,\n};\n\n// header = count + pad (vec4), ambient rgb + pad (vec4), then 32 lights\n// of 3 vec4 each \u2014 1568 bytes total (BLOCK3D_BYTES)\nstruct Light3dBlock {\n countPad : vec4f,\n ambient : vec4f,\n lights : array<Light3dData, 32>,\n};\n\n@group(0) @binding(0) var<uniform> uFrame : FrameUniforms;\n@group(1) @binding(0) var uTexture : texture_2d<f32>;\n@group(1) @binding(1) var uSampler : sampler;\n// per-texel opacity (MTL map_d). When no map is bound this pair is filler \u2014\n// the diffuse texture again \u2014 and `params.y` is 0, so its sample is weighted\n// away rather than branched around.\n@group(1) @binding(2) var uAlphaMap : texture_2d<f32>;\n@group(1) @binding(3) var uAlphaSampler : sampler;\n@group(2) @binding(0) var<uniform> uLights : Light3dBlock;\n@group(3) @binding(0) var<uniform> uMesh : MeshUniforms;\n\nstruct VSOut {\n @builtin(position) position : vec4f,\n @location(0) vRegion : vec2f,\n @location(1) vColor : vec4f,\n @location(2) vNormal : vec3f,\n // world-space fragment position \u2014 positional lights (point / spot)\n // fall off with distance, so the fragment stage needs where the\n // surface IS\n @location(3) vWorldPos : vec3f,\n // radial view-space distance for distance fog\n @location(4) vFogDepth : f32,\n};\n\n// Distance fog is behind a PIPELINE-OVERRIDABLE CONSTANT rather than a plain\n// runtime test. `enable_fog` is fixed when the pipeline is created, so the\n// implementation can fold the branch and drop the dead side \u2014 the same result\n// the WebGL backend gets from `#define FOG`, without WGSL needing a\n// preprocessor or the engine deriving a second module. A scene that never\n// enables fog pays for none of the work below.\noverride enable_fog : bool = false;\n\n// How much the height falloff scales the fog along this view ray. Twin of\n// `fogHeightFactor` in the GLSL mesh shaders \u2014 keep them in step.\n//\n// Density falls off exponentially with altitude, and an exponential integrates\n// analytically along a straight segment, so the ray costs one `exp` and no\n// marching. The result multiplies the distance, leaving both curves untouched.\n//\n// Takes the VIEW-space position, not a height. The only position this stage\n// has is pre-view \u2014 `model * position` \u2014 and that is the mesh's parent space\n// rather than the world, because `Container.draw` folds every ancestor into\n// the view matrix. Reading a height straight off it put the fog floor at the\n// wrong altitude for anything under a scaled container. Dotting the view-space\n// position against the world-up axis expressed in view space gives the height\n// above the camera whatever those ancestors did, since they are inside the\n// very matrix that produced this position. `fogHeight.xyz` carries the falloff\n// too, so the exponent is one dot product.\n//\n// Render space is Y-DOWN: density rises as height INCREASES, the opposite sign\n// to every published form of this.\n//\n// A falloff of 0 gives exactly 1, so uniform fog is this with the dial at zero\n// rather than a special case.\nfn fog_height_factor(viewPos : vec3f) -> f32 {\n let kdy = dot(uMesh.fogHeight.xyz, viewPos);\n // (exp(x) - 1) / x is 0/0 at x = 0, which is a horizontal view ray \u2014 take\n // the limit rather than guarding, or the fog steps as the ray flattens\n var t = 1.0;\n if (abs(kdy) >= 1e-4) {\n t = (exp(kdy) - 1.0) / kdy;\n }\n return uMesh.fogHeight.w * t;\n}\n\n\n// Fold distance fog into a PREMULTIPLIED colour. Twin of `apply_fog` in\n// mesh.wgsl and `applyFog` in the GLSL mesh shaders \u2014 keep them in step. WGSL\n// has no preprocessor and the build loads shaders as raw text, so the block is\n// duplicated rather than shared.\n//\n// `rgb` is already multiplied by `a`, so the fog colour must be scaled by the\n// fragment's own coverage \u2014 a plain mix would paint fog onto near-transparent\n// fragments and halo every alpha-cutout leaf. Mode 0 returns the input\n// untouched, so a scene with no fog is bit-identical.\nfn apply_fog(rgb : vec3f, a : f32, fogDepth : f32) -> vec3f {\n if (!enable_fog) {\n return rgb;\n }\n let mode = uMesh.fogParams.x;\n if (mode < 0.5) {\n return rgb;\n }\n var f : f32;\n if (mode < 1.5) {\n f = 1.0 - clamp((fogDepth - uMesh.fogParams.y) * uMesh.fogParams.z, 0.0, 1.0);\n } else {\n let dd = fogDepth * uMesh.fogParams.w;\n f = exp(-dd * dd);\n }\n return mix(uMesh.fogColor.rgb * a, rgb, f);\n}\n\n@vertex\nfn vertex_main(\n @location(0) aVertex : vec3f,\n @location(1) aRegion : vec2f,\n @location(2) aColor : vec4f,\n @location(3) aNormal : vec3f,\n) -> VSOut {\n var out : VSOut;\n let worldPos = uMesh.model * vec4f(aVertex, 1.0);\n let clip = uFrame.projection * uMesh.view * worldPos;\n // GL-convention clip z in [-w, w] remapped to WebGPU's [0, w]\n out.position = vec4f(clip.xy, (clip.z + clip.w) * 0.5, clip.w);\n let tinted = aColor * uMesh.tint;\n out.vColor = vec4f(tinted.rgb * tinted.a, tinted.a);\n out.vRegion = aRegion;\n out.vWorldPos = worldPos.xyz;\n if (enable_fog) {\n let viewPos = (uMesh.view * worldPos).xyz;\n out.vFogDepth = length(viewPos) * fog_height_factor(viewPos);\n } else {\n out.vFogDepth = 0.0;\n }\n // Rotate the normal into world space with the model matrix's upper\n // 3\xD73. Lighting is evaluated in world space, so the view transform is\n // deliberately excluded. Uniform scale cancels when the fragment\n // renormalizes; non-uniform scale is approximated \u2014 an exact result\n // would need the inverse-transpose.\n let m = uMesh.model;\n out.vNormal = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * aNormal;\n return out;\n}\n\n@fragment\nfn fragment_main(in : VSOut) -> @location(0) vec4f {\n // sampled unconditionally, before the discard (uniform control flow)\n var base = textureSample(uTexture, uSampler, in.vRegion);\n // Per-texel opacity (MTL map_d), applied BEFORE the cutout so one\n // material can cut out to the shape of a leaf rather than at a single\n // threshold across the whole surface. Sampled unconditionally and\n // WEIGHTED: with no map bound the second pair is filler, and this keeps\n // the sample in uniform control flow (and identical to the GLSL twin).\n base.a = base.a * mix(1.0, textureSample(uAlphaMap, uAlphaSampler, in.vRegion).r, uMesh.params.y);\n // hard alpha cutout (glTF alphaMode MASK) \u2014 discard before any shading\n // so cut-away texels cost nothing and never write depth\n // Thresholded on the MATERIAL's own alpha, deliberately BEFORE the tint\n // multiply \u2014 see mesh.frag: the cutout is the surface's shape, and a\n // fading object must keep it rather than pop out at its own threshold.\n if (base.a < uMesh.params.x) {\n discard;\n }\n base = base * in.vColor;\n\n // see mesh-lit.frag: a `lit` mesh with no usable normals must degrade to\n // unlit rather than normalize a zero vector to NaN and render black\n let nLength = length(in.vNormal);\n if (nLength < 1e-6) {\n return vec4f(\n apply_fog(base.rgb + uMesh.emissive.rgb, base.a, in.vFogDepth),\n base.a\n );\n }\n let n = in.vNormal / nLength;\n var lit = uLights.ambient.rgb;\n // Blinn-Phong specular, accumulated alongside the diffuse term. Gated on\n // the exponent rather than the colour: `Ns` of 0 is the MTL format's \"no\n // highlight\", and exporters happily write a non-black `Ks` next to it.\n var specular = vec3f(0.0);\n let shininess = uMesh.specular.w;\n let hasSpecular = shininess > 0.0;\n var viewDir = vec3f(0.0);\n if (hasSpecular) {\n viewDir = normalize(uMesh.eye.xyz - in.vWorldPos);\n }\n // clamped to the array size, not just taken on trust: if the block\n // ever read as something other than what the writer put there, an\n // unbounded trip count would also index out of range\n let count = min(i32(uLights.countPad.x), 32);\n for (var i = 0; i < count; i = i + 1) {\n let pr = uLights.lights[i].posRange;\n let dc = uLights.lights[i].dirCone;\n var lightDir : vec3f;\n var atten = 1.0;\n if (pr.w < 0.0) {\n // directional: dirCone.xyz is already the surface\u2192light vector\n lightDir = dc.xyz;\n } else {\n // positional: quadratic falloff over range (the Light2d model \u2014\n // stylized, not physical inverse-square, which is unusable in\n // pixel-unit worlds with unit intensities)\n let toLight = pr.xyz - in.vWorldPos;\n let dist = max(length(toLight), 1e-4);\n lightDir = toLight / dist;\n let linearAtt = max(0.0, 1.0 - dist / pr.w);\n atten = linearAtt * linearAtt;\n if (dc.w > -1.0) {\n // spot cone: -lightDir is the light\u2192surface direction; fade\n // from the inner cone cosine to the outer\n let cd = dot(-lightDir, dc.xyz);\n atten = atten * smoothstep(dc.w, uLights.lights[i].colorInner.w, cd);\n }\n }\n // Half-Lambert (\"wrap\") diffuse: dot * 0.5 + 0.5, squared. Softens\n // the terminator and lifts the shadowed side \u2014 gentler than hard\n // Lambert, which reads as harsh noon.\n let ndl = dot(n, lightDir) * 0.5 + 0.5;\n lit = lit + uLights.lights[i].colorInner.rgb * (ndl * ndl * atten);\n if (hasSpecular) {\n // masked by the UNWRAPPED Lambert term: half-Lambert lifts the\n // shadowed side, and a highlight on a surface facing away from\n // the light reads as a rendering error\n let facing = max(dot(n, lightDir), 0.0);\n let h = normalize(lightDir + viewDir);\n let spec = pow(max(dot(n, h), 0.0), shininess);\n specular = specular\n + uLights.lights[i].colorInner.rgb * (spec * facing * atten);\n }\n }\n\n // emissive self-illuminates: added AFTER lighting so it glows at full\n // strength regardless of the scene lights (neon, lava, glowing eyes)\n return vec4f(\n apply_fog(\n base.rgb * lit + specular * uMesh.specular.rgb + uMesh.emissive.rgb,\n base.a,\n in.vFogDepth\n ),\n base.a\n );\n}\n";
53131
+ var mesh_lit_default3 = "// melonJS WebGPU lit mesh shader \u2014 the WGSL port of mesh-lit.vert /\n// mesh-lit.frag: half-Lambert diffuse from directional Light3d lights plus\n// an ambient floor, over the mesh tier's uniform-driven placement.\n//\n// Vertex layout (48-byte stride, shared with the WebGL backend): the\n// 36-byte unlit layout plus\n// @location(3) aNormal float32x3 offset 36\n// aNormal is MODEL-space on the retained path (rotated below by the model\n// matrix's upper 3\xD73) and world-space on the accumulated path \u2014 which\n// supplies an identity model matrix, so one shader serves both.\n//\n// The std140 Light3dBlock binds at group 2 with a dynamic offset \u2014 one\n// snapshot per light change per frame, per the queue-write ordering law.\n// Its layout is written by src/video/webgl/lighting/std140.ts and the two\n// must agree byte for byte \u2014 a mismatch does not fail validation, it\n// silently shifts every light.\n\nstruct FrameUniforms {\n projection : mat4x4<f32>,\n // unused by this shader; part of the shared frame-globals block\n lineWidth : f32,\n};\n\nstruct MeshUniforms {\n model : mat4x4<f32>,\n view : mat4x4<f32>,\n tint : vec4f,\n // x = alpha cutout threshold (0 = disabled), y = 1 when an opacity map\n // is bound (0 = the second sampler is filler); z, w reserved\n params : vec4f,\n // self-illumination added AFTER lighting (r, g, b; w reserved)\n emissive : vec4f,\n // specular color (rgb) and exponent (w); w = 0 means no highlight\n specular : vec4f,\n // the camera's world position (xyz; w reserved), for the specular\n // half-vector \u2014 nothing else in this shader needs it\n eye : vec4f,\n // straight (unpremultiplied) fog colour (rgb; w reserved)\n fogColor : vec4f,\n // x = mode (0 off / 1 linear / 2 exp2), y = near, z = 1/(far - near),\n // w = density\n fogParams : vec4f,\n // xyz = height falloff x the world-up axis, in VIEW space;\n // w = exp(k * (cameraY - fogHeight)), pre-baked on the CPU\n fogHeight : vec4f,\n};\n\n// One light, type inferred from sentinels (see std140.ts):\n// posRange.w < 0 \u2192 directional (dirCone.xyz = surface\u2192light, normalized);\n// otherwise positional at posRange.xyz with quadratic falloff over\n// posRange.w, plus a spot cone when dirCone.w > -1 (dirCone.xyz = the\n// cone axis / travel direction, w = cos(outer), colorInner.w =\n// cos(inner)). colorInner.rgb is premultiplied by intensity.\nstruct Light3dData {\n posRange : vec4f,\n dirCone : vec4f,\n colorInner : vec4f,\n};\n\n// header = count + pad (vec4), ambient rgb + pad (vec4), then 32 lights\n// of 3 vec4 each \u2014 1568 bytes total (BLOCK3D_BYTES)\nstruct Light3dBlock {\n countPad : vec4f,\n ambient : vec4f,\n lights : array<Light3dData, 32>,\n};\n\n@group(0) @binding(0) var<uniform> uFrame : FrameUniforms;\n@group(1) @binding(0) var uTexture : texture_2d<f32>;\n@group(1) @binding(1) var uSampler : sampler;\n// per-texel opacity (MTL map_d). When no map is bound this pair is filler \u2014\n// the diffuse texture again \u2014 and `params.y` is 0, so its sample is weighted\n// away rather than branched around.\n@group(1) @binding(2) var uAlphaMap : texture_2d<f32>;\n@group(1) @binding(3) var uAlphaSampler : sampler;\n@group(2) @binding(0) var<uniform> uLights : Light3dBlock;\n@group(3) @binding(0) var<uniform> uMesh : MeshUniforms;\n\nstruct VSOut {\n @builtin(position) position : vec4f,\n @location(0) vRegion : vec2f,\n @location(1) vColor : vec4f,\n @location(2) vNormal : vec3f,\n // world-space fragment position \u2014 positional lights (point / spot)\n // fall off with distance, so the fragment stage needs where the\n // surface IS\n @location(3) vWorldPos : vec3f,\n // radial view-space distance for distance fog\n @location(4) vFogDepth : f32,\n};\n\n// Distance fog is behind a PIPELINE-OVERRIDABLE CONSTANT rather than a plain\n// runtime test. `enable_fog` is fixed when the pipeline is created, so the\n// implementation can fold the branch and drop the dead side \u2014 the same result\n// the WebGL backend gets from `#define FOG`, without WGSL needing a\n// preprocessor or the engine deriving a second module. A scene that never\n// enables fog pays for none of the work below.\noverride enable_fog : bool = false;\n\n// How much the height falloff scales the fog along this view ray. Twin of\n// `fogHeightFactor` in the GLSL mesh shaders \u2014 keep them in step.\n//\n// Density falls off exponentially with altitude, and an exponential integrates\n// analytically along a straight segment, so the ray costs one `exp` and no\n// marching. The result multiplies the distance, leaving both curves untouched.\n//\n// Takes the VIEW-space position, not a height. The only position this stage\n// has is pre-view \u2014 `model * position` \u2014 and that is the mesh's parent space\n// rather than the world, because `Container.draw` folds every ancestor into\n// the view matrix. Reading a height straight off it put the fog floor at the\n// wrong altitude for anything under a scaled container. Dotting the view-space\n// position against the world-up axis expressed in view space gives the height\n// above the camera whatever those ancestors did, since they are inside the\n// very matrix that produced this position. `fogHeight.xyz` carries the falloff\n// too, so the exponent is one dot product.\n//\n// Render space is Y-DOWN: density rises as height INCREASES, the opposite sign\n// to every published form of this.\n//\n// A falloff of 0 gives exactly 1, so uniform fog is this with the dial at zero\n// rather than a special case.\nfn fog_height_factor(viewPos : vec3f) -> f32 {\n let kdy = dot(uMesh.fogHeight.xyz, viewPos);\n // (exp(x) - 1) / x is 0/0 at x = 0, which is a horizontal view ray \u2014 take\n // the limit rather than guarding, or the fog steps as the ray flattens\n var t = 1.0;\n if (abs(kdy) >= 1e-4) {\n t = (exp(kdy) - 1.0) / kdy;\n }\n return uMesh.fogHeight.w * t;\n}\n\n\n// Fold distance fog into a PREMULTIPLIED colour. Twin of `apply_fog` in\n// mesh.wgsl and `applyFog` in the GLSL mesh shaders \u2014 keep them in step. WGSL\n// has no preprocessor and the build loads shaders as raw text, so the block is\n// duplicated rather than shared.\n//\n// `rgb` is already multiplied by `a`, so the fog colour must be scaled by the\n// fragment's own coverage \u2014 a plain mix would paint fog onto near-transparent\n// fragments and halo every alpha-cutout leaf. Mode 0 returns the input\n// untouched, so a scene with no fog is bit-identical.\nfn apply_fog(rgb : vec3f, a : f32, fogDepth : f32) -> vec3f {\n if (!enable_fog) {\n return rgb;\n }\n let mode = uMesh.fogParams.x;\n if (mode < 0.5) {\n return rgb;\n }\n var f : f32;\n if (mode < 1.5) {\n f = 1.0 - clamp((fogDepth - uMesh.fogParams.y) * uMesh.fogParams.z, 0.0, 1.0);\n } else {\n let dd = fogDepth * uMesh.fogParams.w;\n f = exp(-dd * dd);\n }\n return mix(uMesh.fogColor.rgb * a, rgb, f);\n}\n\n// The mesh-hosted ShaderEffect hook (#1658). Identity by default \u2014 every\n// compiler inlines it away, so an un-effected mesh pays nothing. When an\n// effect is hosted, `spliceEffect` replaces this definition with one that\n// calls the effect's `apply()`, and pastes the effect body above it.\n//\n// Deliberately a FUNCTION and not a marker comment: the shader pipeline\n// strips comments, so a comment cannot be relied on to reach the splicer.\n//\n// Declared ABOVE `@vertex` on purpose: `buildInstancedMeshWGSL` assembles the\n// instanced variant as `source.slice(0, vertexAt)` + a generated vertex stage\n// + `source.slice(fragmentAt)`, so anything BETWEEN the two stages is dropped.\n// Defined there, the instanced module kept the call and lost the definition,\n// and every frame drawing an instanced lit mesh failed to compile.\nfn ME_effect(c : vec4f, uv : vec2f) -> vec4f { return c; }\n\n@vertex\nfn vertex_main(\n @location(0) aVertex : vec3f,\n @location(1) aRegion : vec2f,\n @location(2) aColor : vec4f,\n @location(3) aNormal : vec3f,\n) -> VSOut {\n var out : VSOut;\n let worldPos = uMesh.model * vec4f(aVertex, 1.0);\n let clip = uFrame.projection * uMesh.view * worldPos;\n // GL-convention clip z in [-w, w] remapped to WebGPU's [0, w]\n out.position = vec4f(clip.xy, (clip.z + clip.w) * 0.5, clip.w);\n let tinted = aColor * uMesh.tint;\n out.vColor = vec4f(tinted.rgb * tinted.a, tinted.a);\n out.vRegion = aRegion;\n out.vWorldPos = worldPos.xyz;\n if (enable_fog) {\n let viewPos = (uMesh.view * worldPos).xyz;\n out.vFogDepth = length(viewPos) * fog_height_factor(viewPos);\n } else {\n out.vFogDepth = 0.0;\n }\n // Rotate the normal into world space with the model matrix's upper\n // 3\xD73. Lighting is evaluated in world space, so the view transform is\n // deliberately excluded. Uniform scale cancels when the fragment\n // renormalizes; non-uniform scale is approximated \u2014 an exact result\n // would need the inverse-transpose.\n let m = uMesh.model;\n out.vNormal = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * aNormal;\n return out;\n}\n\n\n@fragment\nfn fragment_main(in : VSOut) -> @location(0) vec4f {\n // sampled unconditionally, before the discard (uniform control flow)\n var base = textureSample(uTexture, uSampler, in.vRegion);\n // Per-texel opacity (MTL map_d), applied BEFORE the cutout so one\n // material can cut out to the shape of a leaf rather than at a single\n // threshold across the whole surface. Sampled unconditionally and\n // WEIGHTED: with no map bound the second pair is filler, and this keeps\n // the sample in uniform control flow (and identical to the GLSL twin).\n base.a = base.a * mix(1.0, textureSample(uAlphaMap, uAlphaSampler, in.vRegion).r, uMesh.params.y);\n // hard alpha cutout (glTF alphaMode MASK) \u2014 discard before any shading\n // so cut-away texels cost nothing and never write depth\n // Thresholded on the MATERIAL's own alpha, deliberately BEFORE the tint\n // multiply \u2014 see mesh.frag: the cutout is the surface's shape, and a\n // fading object must keep it rather than pop out at its own threshold.\n if (base.a < uMesh.params.x) {\n discard;\n }\n base = base * in.vColor;\n base = ME_effect(base, in.vRegion);\n\n // see mesh-lit.frag: a `lit` mesh with no usable normals must degrade to\n // unlit rather than normalize a zero vector to NaN and render black\n let nLength = length(in.vNormal);\n if (nLength < 1e-6) {\n return vec4f(\n apply_fog(base.rgb + uMesh.emissive.rgb, base.a, in.vFogDepth),\n base.a\n );\n }\n let n = in.vNormal / nLength;\n var lit = uLights.ambient.rgb;\n // Blinn-Phong specular, accumulated alongside the diffuse term. Gated on\n // the exponent rather than the colour: `Ns` of 0 is the MTL format's \"no\n // highlight\", and exporters happily write a non-black `Ks` next to it.\n var specular = vec3f(0.0);\n let shininess = uMesh.specular.w;\n let hasSpecular = shininess > 0.0;\n var viewDir = vec3f(0.0);\n if (hasSpecular) {\n viewDir = normalize(uMesh.eye.xyz - in.vWorldPos);\n }\n // clamped to the array size, not just taken on trust: if the block\n // ever read as something other than what the writer put there, an\n // unbounded trip count would also index out of range\n let count = min(i32(uLights.countPad.x), 32);\n for (var i = 0; i < count; i = i + 1) {\n let pr = uLights.lights[i].posRange;\n let dc = uLights.lights[i].dirCone;\n var lightDir : vec3f;\n var atten = 1.0;\n if (pr.w < 0.0) {\n // directional: dirCone.xyz is already the surface\u2192light vector\n lightDir = dc.xyz;\n } else {\n // positional: quadratic falloff over range (the Light2d model \u2014\n // stylized, not physical inverse-square, which is unusable in\n // pixel-unit worlds with unit intensities)\n let toLight = pr.xyz - in.vWorldPos;\n let dist = max(length(toLight), 1e-4);\n lightDir = toLight / dist;\n let linearAtt = max(0.0, 1.0 - dist / pr.w);\n atten = linearAtt * linearAtt;\n if (dc.w > -1.0) {\n // spot cone: -lightDir is the light\u2192surface direction; fade\n // from the inner cone cosine to the outer\n let cd = dot(-lightDir, dc.xyz);\n atten = atten * smoothstep(dc.w, uLights.lights[i].colorInner.w, cd);\n }\n }\n // Half-Lambert (\"wrap\") diffuse: dot * 0.5 + 0.5, squared. Softens\n // the terminator and lifts the shadowed side \u2014 gentler than hard\n // Lambert, which reads as harsh noon.\n let ndl = dot(n, lightDir) * 0.5 + 0.5;\n lit = lit + uLights.lights[i].colorInner.rgb * (ndl * ndl * atten);\n if (hasSpecular) {\n // masked by the UNWRAPPED Lambert term: half-Lambert lifts the\n // shadowed side, and a highlight on a surface facing away from\n // the light reads as a rendering error\n let facing = max(dot(n, lightDir), 0.0);\n let h = normalize(lightDir + viewDir);\n let spec = pow(max(dot(n, h), 0.0), shininess);\n specular = specular\n + uLights.lights[i].colorInner.rgb * (spec * facing * atten);\n }\n }\n\n // emissive self-illuminates: added AFTER lighting so it glows at full\n // strength regardless of the scene lights (neon, lava, glowing eyes)\n return vec4f(\n apply_fog(\n base.rgb * lit + specular * uMesh.specular.rgb + uMesh.emissive.rgb,\n base.a,\n in.vFogDepth\n ),\n base.a\n );\n}\n";
52226
53132
 
52227
53133
  // src/video/webgpu/buffer/instance_buffer.js
52228
53134
  var WebGPUInstanceBuffer = class {
@@ -52416,7 +53322,7 @@ var WebGPURetainedGeometry = class {
52416
53322
  };
52417
53323
 
52418
53324
  // src/video/webgpu/shaders/mesh.wgsl
52419
- var mesh_default3 = "// melonJS WebGPU mesh shader \u2014 the WGSL port of mesh.vert / mesh.frag.\n//\n// Vertex layout (36-byte stride, shared with the WebGL backend):\n// @location(0) aVertex float32x3 offset 0\n// @location(1) aRegion float32x2 offset 12\n// @location(2) aColor float32x4 offset 20\n//\n// aColor is four straight floats (R, G, B, A in [0, 1]) \u2014 NOT the quad\n// tier's packed unorm8x4 \u2014 so no BGRA swizzle applies. The float layout is\n// shared with the GL backend, where it exists to dodge NaN-pattern\n// canonicalization on Metal-backed drivers.\n//\n// Placement is uniform-driven so retained geometry uploads once and reuses:\n// clip = projection \xD7 view \xD7 model \xD7 vertex\n// One MeshUniforms snapshot binds per draw (group 3, dynamic offset \u2014 the\n// queue-write ordering law: a shared region would be retroactively\n// clobbered for draws already recorded). The accumulated path (2D camera,\n// CPU-projected vertices) supplies an identity model matrix.\n\nstruct FrameUniforms {\n projection : mat4x4<f32>,\n // unused by this shader; part of the shared frame-globals block\n lineWidth : f32,\n};\n\nstruct MeshUniforms {\n // the mesh's own placement (axis bridge + mesh scale included)\n model : mat4x4<f32>,\n // the camera view plus any ancestor container transform\n view : mat4x4<f32>,\n // per-draw tint \xD7 global alpha (r, g, b, a) \u2014 kept out of the vertex\n // data so re-tinting never invalidates retained geometry\n tint : vec4f,\n // x = alpha cutout threshold (0 = disabled), y = 1 when an opacity map\n // is bound (0 = the second sampler is filler); z, w reserved\n params : vec4f,\n // self-illumination added on top (r, g, b; w reserved)\n emissive : vec4f,\n // specular color (rgb) and exponent (w); w = 0 means no highlight.\n // Unused by the unlit tier, present so both tiers share one block size.\n specular : vec4f,\n // the camera's world position (xyz; w reserved)\n eye : vec4f,\n // straight (unpremultiplied) fog colour (rgb; w reserved)\n fogColor : vec4f,\n // x = mode (0 off / 1 linear / 2 exp2), y = near, z = 1/(far - near),\n // w = density\n fogParams : vec4f,\n // xyz = height falloff x the world-up axis, in VIEW space;\n // w = exp(k * (cameraY - fogHeight)), pre-baked on the CPU\n fogHeight : vec4f,\n};\n\n@group(0) @binding(0) var<uniform> uFrame : FrameUniforms;\n@group(1) @binding(0) var uTexture : texture_2d<f32>;\n@group(1) @binding(1) var uSampler : sampler;\n// per-texel opacity (MTL map_d). When no map is bound this pair is filler \u2014\n// the diffuse texture again \u2014 and `params.y` is 0, so its sample is weighted\n// away rather than branched around.\n@group(1) @binding(2) var uAlphaMap : texture_2d<f32>;\n@group(1) @binding(3) var uAlphaSampler : sampler;\n@group(3) @binding(0) var<uniform> uMesh : MeshUniforms;\n\nstruct VSOut {\n @builtin(position) position : vec4f,\n @location(0) vRegion : vec2f,\n @location(1) vColor : vec4f,\n @location(2) vFogDepth : f32,\n};\n\n// Distance fog is behind a PIPELINE-OVERRIDABLE CONSTANT rather than a plain\n// runtime test. `enable_fog` is fixed when the pipeline is created, so the\n// implementation can fold the branch and drop the dead side \u2014 the same result\n// the WebGL backend gets from `#define FOG`, without WGSL needing a\n// preprocessor or the engine deriving a second module. A scene that never\n// enables fog pays for none of the work below.\noverride enable_fog : bool = false;\n\n// How much the height falloff scales the fog along this view ray. Twin of\n// `fogHeightFactor` in the GLSL mesh shaders \u2014 keep them in step.\n//\n// Density falls off exponentially with altitude, and an exponential integrates\n// analytically along a straight segment, so the ray costs one `exp` and no\n// marching. The result multiplies the distance, leaving both curves untouched.\n//\n// Takes the VIEW-space position, not a height. The only position this stage\n// has is pre-view \u2014 `model * position` \u2014 and that is the mesh's parent space\n// rather than the world, because `Container.draw` folds every ancestor into\n// the view matrix. Reading a height straight off it put the fog floor at the\n// wrong altitude for anything under a scaled container. Dotting the view-space\n// position against the world-up axis expressed in view space gives the height\n// above the camera whatever those ancestors did, since they are inside the\n// very matrix that produced this position. `fogHeight.xyz` carries the falloff\n// too, so the exponent is one dot product.\n//\n// Render space is Y-DOWN: density rises as height INCREASES, the opposite sign\n// to every published form of this.\n//\n// A falloff of 0 gives exactly 1, so uniform fog is this with the dial at zero\n// rather than a special case.\nfn fog_height_factor(viewPos : vec3f) -> f32 {\n let kdy = dot(uMesh.fogHeight.xyz, viewPos);\n // (exp(x) - 1) / x is 0/0 at x = 0, which is a horizontal view ray \u2014 take\n // the limit rather than guarding, or the fog steps as the ray flattens\n var t = 1.0;\n if (abs(kdy) >= 1e-4) {\n t = (exp(kdy) - 1.0) / kdy;\n }\n return uMesh.fogHeight.w * t;\n}\n\n\n// Fold distance fog into a PREMULTIPLIED colour. Twin of `applyFog` in the\n// GLSL mesh shaders \u2014 keep the two in step. They are duplicated rather than\n// shared because WGSL has no preprocessor and the production build loads each\n// shader as raw text.\n//\n// `rgb` is already multiplied by `a`, so the fog colour must be scaled by the\n// fragment's own coverage: a plain mix toward the fog colour would paint\n// full-strength fog onto near-transparent fragments \u2014 grey halos around every\n// alpha-cutout leaf.\n//\n// Mode 0 returns the input untouched, so a scene with no fog is bit-identical\n// to one built before fog existed.\nfn apply_fog(rgb : vec3f, a : f32, fogDepth : f32) -> vec3f {\n if (!enable_fog) {\n return rgb;\n }\n let mode = uMesh.fogParams.x;\n if (mode < 0.5) {\n return rgb;\n }\n var f : f32;\n if (mode < 1.5) {\n // linear: 1 at `near`, reaching 0 at `far`\n f = 1.0 - clamp((fogDepth - uMesh.fogParams.y) * uMesh.fogParams.z, 0.0, 1.0);\n } else {\n // exponential squared: survival = exp(-(density * d)^2)\n let dd = fogDepth * uMesh.fogParams.w;\n f = exp(-dd * dd);\n }\n return mix(uMesh.fogColor.rgb * a, rgb, f);\n}\n\n@vertex\nfn vertex_main(\n @location(0) aVertex : vec3f,\n @location(1) aRegion : vec2f,\n @location(2) aColor : vec4f,\n) -> VSOut {\n var out : VSOut;\n let clip =\n uFrame.projection * uMesh.view * uMesh.model * vec4f(aVertex, 1.0);\n // GL-convention clip z in [-w, w] remapped to WebGPU's [0, w]\n out.position = vec4f(clip.xy, (clip.z + clip.w) * 0.5, clip.w);\n // tint first, then premultiply \u2014 matches the fragment's expectation\n let tinted = aColor * uMesh.tint;\n out.vColor = vec4f(tinted.rgb * tinted.a, tinted.a);\n // Radial view-space distance for distance fog. Radial rather than view-space\n // z, so fog holds steady as the camera turns. The clip position above keeps\n // its own product: re-associating it could shift vertices by an ulp, and\n // fog-off output must stay bit-identical.\n // the slot is always declared \u2014 an override cannot remove an inter-stage\n // variable \u2014 but the work behind it folds away with the constant\n if (enable_fog) {\n let viewPos = uMesh.view * uMesh.model * vec4f(aVertex, 1.0);\n out.vFogDepth = length(viewPos.xyz)\n * fog_height_factor(viewPos.xyz);\n } else {\n out.vFogDepth = 0.0;\n }\n out.vRegion = aRegion;\n return out;\n}\n\n@fragment\nfn fragment_main(in : VSOut) -> @location(0) vec4f {\n // sampled unconditionally, before the discard (uniform control flow)\n var color = textureSample(uTexture, uSampler, in.vRegion);\n // Per-texel opacity (MTL map_d), applied BEFORE the cutout so one\n // material can cut out to the shape of a leaf rather than at a single\n // threshold across the whole surface. Sampled unconditionally and\n // WEIGHTED: with no map bound the second pair is filler, and this keeps\n // the sample in uniform control flow (and identical to the GLSL twin).\n color.a = color.a * mix(1.0, textureSample(uAlphaMap, uAlphaSampler, in.vRegion).r, uMesh.params.y);\n // hard alpha cutout (glTF alphaMode MASK): drop cut texels so foliage /\n // fences / decals read crisp without blending or sorting\n // Thresholded on the MATERIAL's own alpha, deliberately BEFORE the tint\n // multiply \u2014 see mesh.frag: the cutout is the surface's shape, and a\n // fading object must keep it rather than pop out at its own threshold.\n if (color.a < uMesh.params.x) {\n discard;\n }\n color = color * in.vColor;\n // emissive adds a self-lit color on top (neon, lava, screens); the\n // unlit path has no lighting, so it is simply added to the base color\n return vec4f(\n apply_fog(color.rgb + uMesh.emissive.rgb, color.a, in.vFogDepth),\n color.a\n );\n}\n";
53325
+ var mesh_default3 = "// melonJS WebGPU mesh shader \u2014 the WGSL port of mesh.vert / mesh.frag.\n//\n// Vertex layout (36-byte stride, shared with the WebGL backend):\n// @location(0) aVertex float32x3 offset 0\n// @location(1) aRegion float32x2 offset 12\n// @location(2) aColor float32x4 offset 20\n//\n// aColor is four straight floats (R, G, B, A in [0, 1]) \u2014 NOT the quad\n// tier's packed unorm8x4 \u2014 so no BGRA swizzle applies. The float layout is\n// shared with the GL backend, where it exists to dodge NaN-pattern\n// canonicalization on Metal-backed drivers.\n//\n// Placement is uniform-driven so retained geometry uploads once and reuses:\n// clip = projection \xD7 view \xD7 model \xD7 vertex\n// One MeshUniforms snapshot binds per draw (group 3, dynamic offset \u2014 the\n// queue-write ordering law: a shared region would be retroactively\n// clobbered for draws already recorded). The accumulated path (2D camera,\n// CPU-projected vertices) supplies an identity model matrix.\n\nstruct FrameUniforms {\n projection : mat4x4<f32>,\n // unused by this shader; part of the shared frame-globals block\n lineWidth : f32,\n};\n\nstruct MeshUniforms {\n // the mesh's own placement (axis bridge + mesh scale included)\n model : mat4x4<f32>,\n // the camera view plus any ancestor container transform\n view : mat4x4<f32>,\n // per-draw tint \xD7 global alpha (r, g, b, a) \u2014 kept out of the vertex\n // data so re-tinting never invalidates retained geometry\n tint : vec4f,\n // x = alpha cutout threshold (0 = disabled), y = 1 when an opacity map\n // is bound (0 = the second sampler is filler); z, w reserved\n params : vec4f,\n // self-illumination added on top (r, g, b; w reserved)\n emissive : vec4f,\n // specular color (rgb) and exponent (w); w = 0 means no highlight.\n // Unused by the unlit tier, present so both tiers share one block size.\n specular : vec4f,\n // the camera's world position (xyz; w reserved)\n eye : vec4f,\n // straight (unpremultiplied) fog colour (rgb; w reserved)\n fogColor : vec4f,\n // x = mode (0 off / 1 linear / 2 exp2), y = near, z = 1/(far - near),\n // w = density\n fogParams : vec4f,\n // xyz = height falloff x the world-up axis, in VIEW space;\n // w = exp(k * (cameraY - fogHeight)), pre-baked on the CPU\n fogHeight : vec4f,\n};\n\n@group(0) @binding(0) var<uniform> uFrame : FrameUniforms;\n@group(1) @binding(0) var uTexture : texture_2d<f32>;\n@group(1) @binding(1) var uSampler : sampler;\n// per-texel opacity (MTL map_d). When no map is bound this pair is filler \u2014\n// the diffuse texture again \u2014 and `params.y` is 0, so its sample is weighted\n// away rather than branched around.\n@group(1) @binding(2) var uAlphaMap : texture_2d<f32>;\n@group(1) @binding(3) var uAlphaSampler : sampler;\n@group(3) @binding(0) var<uniform> uMesh : MeshUniforms;\n\nstruct VSOut {\n @builtin(position) position : vec4f,\n @location(0) vRegion : vec2f,\n @location(1) vColor : vec4f,\n @location(2) vFogDepth : f32,\n};\n\n// Distance fog is behind a PIPELINE-OVERRIDABLE CONSTANT rather than a plain\n// runtime test. `enable_fog` is fixed when the pipeline is created, so the\n// implementation can fold the branch and drop the dead side \u2014 the same result\n// the WebGL backend gets from `#define FOG`, without WGSL needing a\n// preprocessor or the engine deriving a second module. A scene that never\n// enables fog pays for none of the work below.\noverride enable_fog : bool = false;\n\n// How much the height falloff scales the fog along this view ray. Twin of\n// `fogHeightFactor` in the GLSL mesh shaders \u2014 keep them in step.\n//\n// Density falls off exponentially with altitude, and an exponential integrates\n// analytically along a straight segment, so the ray costs one `exp` and no\n// marching. The result multiplies the distance, leaving both curves untouched.\n//\n// Takes the VIEW-space position, not a height. The only position this stage\n// has is pre-view \u2014 `model * position` \u2014 and that is the mesh's parent space\n// rather than the world, because `Container.draw` folds every ancestor into\n// the view matrix. Reading a height straight off it put the fog floor at the\n// wrong altitude for anything under a scaled container. Dotting the view-space\n// position against the world-up axis expressed in view space gives the height\n// above the camera whatever those ancestors did, since they are inside the\n// very matrix that produced this position. `fogHeight.xyz` carries the falloff\n// too, so the exponent is one dot product.\n//\n// Render space is Y-DOWN: density rises as height INCREASES, the opposite sign\n// to every published form of this.\n//\n// A falloff of 0 gives exactly 1, so uniform fog is this with the dial at zero\n// rather than a special case.\nfn fog_height_factor(viewPos : vec3f) -> f32 {\n let kdy = dot(uMesh.fogHeight.xyz, viewPos);\n // (exp(x) - 1) / x is 0/0 at x = 0, which is a horizontal view ray \u2014 take\n // the limit rather than guarding, or the fog steps as the ray flattens\n var t = 1.0;\n if (abs(kdy) >= 1e-4) {\n t = (exp(kdy) - 1.0) / kdy;\n }\n return uMesh.fogHeight.w * t;\n}\n\n\n// Fold distance fog into a PREMULTIPLIED colour. Twin of `applyFog` in the\n// GLSL mesh shaders \u2014 keep the two in step. They are duplicated rather than\n// shared because WGSL has no preprocessor and the production build loads each\n// shader as raw text.\n//\n// `rgb` is already multiplied by `a`, so the fog colour must be scaled by the\n// fragment's own coverage: a plain mix toward the fog colour would paint\n// full-strength fog onto near-transparent fragments \u2014 grey halos around every\n// alpha-cutout leaf.\n//\n// Mode 0 returns the input untouched, so a scene with no fog is bit-identical\n// to one built before fog existed.\nfn apply_fog(rgb : vec3f, a : f32, fogDepth : f32) -> vec3f {\n if (!enable_fog) {\n return rgb;\n }\n let mode = uMesh.fogParams.x;\n if (mode < 0.5) {\n return rgb;\n }\n var f : f32;\n if (mode < 1.5) {\n // linear: 1 at `near`, reaching 0 at `far`\n f = 1.0 - clamp((fogDepth - uMesh.fogParams.y) * uMesh.fogParams.z, 0.0, 1.0);\n } else {\n // exponential squared: survival = exp(-(density * d)^2)\n let dd = fogDepth * uMesh.fogParams.w;\n f = exp(-dd * dd);\n }\n return mix(uMesh.fogColor.rgb * a, rgb, f);\n}\n\n// The mesh-hosted ShaderEffect hook (#1658). Identity by default \u2014 every\n// compiler inlines it away, so an un-effected mesh pays nothing. When an\n// effect is hosted, `spliceEffect` replaces this definition with one that\n// calls the effect's `apply()`, and pastes the effect body above it.\n//\n// Deliberately a FUNCTION and not a marker comment: the shader pipeline\n// strips comments, so a comment cannot be relied on to reach the splicer.\n//\n// Declared ABOVE `@vertex` on purpose: `buildInstancedMeshWGSL` assembles the\n// instanced variant as `source.slice(0, vertexAt)` + a generated vertex stage\n// + `source.slice(fragmentAt)`, so anything BETWEEN the two stages is dropped.\n// Defined there, the instanced module kept the call and lost the definition,\n// and every frame drawing an instanced lit mesh failed to compile.\nfn ME_effect(c : vec4f, uv : vec2f) -> vec4f { return c; }\n\n@vertex\nfn vertex_main(\n @location(0) aVertex : vec3f,\n @location(1) aRegion : vec2f,\n @location(2) aColor : vec4f,\n) -> VSOut {\n var out : VSOut;\n let clip =\n uFrame.projection * uMesh.view * uMesh.model * vec4f(aVertex, 1.0);\n // GL-convention clip z in [-w, w] remapped to WebGPU's [0, w]\n out.position = vec4f(clip.xy, (clip.z + clip.w) * 0.5, clip.w);\n // tint first, then premultiply \u2014 matches the fragment's expectation\n let tinted = aColor * uMesh.tint;\n out.vColor = vec4f(tinted.rgb * tinted.a, tinted.a);\n // Radial view-space distance for distance fog. Radial rather than view-space\n // z, so fog holds steady as the camera turns. The clip position above keeps\n // its own product: re-associating it could shift vertices by an ulp, and\n // fog-off output must stay bit-identical.\n // the slot is always declared \u2014 an override cannot remove an inter-stage\n // variable \u2014 but the work behind it folds away with the constant\n if (enable_fog) {\n let viewPos = uMesh.view * uMesh.model * vec4f(aVertex, 1.0);\n out.vFogDepth = length(viewPos.xyz)\n * fog_height_factor(viewPos.xyz);\n } else {\n out.vFogDepth = 0.0;\n }\n out.vRegion = aRegion;\n return out;\n}\n\n\n@fragment\nfn fragment_main(in : VSOut) -> @location(0) vec4f {\n // sampled unconditionally, before the discard (uniform control flow)\n var color = textureSample(uTexture, uSampler, in.vRegion);\n // Per-texel opacity (MTL map_d), applied BEFORE the cutout so one\n // material can cut out to the shape of a leaf rather than at a single\n // threshold across the whole surface. Sampled unconditionally and\n // WEIGHTED: with no map bound the second pair is filler, and this keeps\n // the sample in uniform control flow (and identical to the GLSL twin).\n color.a = color.a * mix(1.0, textureSample(uAlphaMap, uAlphaSampler, in.vRegion).r, uMesh.params.y);\n // hard alpha cutout (glTF alphaMode MASK): drop cut texels so foliage /\n // fences / decals read crisp without blending or sorting\n // Thresholded on the MATERIAL's own alpha, deliberately BEFORE the tint\n // multiply \u2014 see mesh.frag: the cutout is the surface's shape, and a\n // fading object must keep it rather than pop out at its own threshold.\n if (color.a < uMesh.params.x) {\n discard;\n }\n color = color * in.vColor;\n color = ME_effect(color, in.vRegion);\n // emissive adds a self-lit color on top (neon, lava, screens); the\n // unlit path has no lighting, so it is simply added to the base color\n return vec4f(\n apply_fog(color.rgb + uMesh.emissive.rgb, color.a, in.vFogDepth),\n color.a\n );\n}\n";
52420
53326
 
52421
53327
  // src/video/webgpu/shaders/mesh-shadow-instanced.wgsl
52422
53328
  var mesh_shadow_instanced_default2 = "// Instanced ground-shadow module (#1515) \u2014 the WGSL twin of\n// `webgl/shaders/mesh-shadow-instanced.vert`.\n//\n// One flat blob per instance, drawn from the SAME instance buffer the mesh\n// itself uses, so a forest of a hundred thousand trees costs one extra draw\n// rather than a hundred thousand.\n//\n// Hand-written and STANDALONE, rather than derived from the mesh module by\n// `buildInstancedMeshWGSL`. Deriving it would inherit three things it must\n// not have: the `hasColor`/`hasData` variant matrix (this reads neither slot,\n// so one module serves every record layout), the per-instance tint multiply\n// and emissive substitution (which would give a forest coloured, glowing\n// shadows), and the VSOut member-written guard, which on the lit tier\n// requires a body that writes `vWorldPos` and `vNormal` \u2014 neither of which a\n// flat unlit blob has any use for.\n//\n// The instance's rotation and vertical scale are DISCARDED: a blob lies flat\n// however its tree is turned, and only the horizontal footprint sets its\n// width. Translation and horizontal scale come straight out of the record's\n// three rows with no matrix built. Flattening onto the ground is\n// `uMesh.model`'s job \u2014 the caller passes the group matrix with its Y basis\n// column zeroed and its translation Y set to the ground height.\n\nstruct FrameUniforms {\n projection : mat4x4<f32>,\n // unused by this shader; part of the shared frame-globals block. Its size\n // has to match the block the layout declares EXACTLY \u2014 a struct that reads\n // past `minBindingSize` is a pipeline-creation error, not a silent\n // over-read, and takes the whole frame's command buffer down with it.\n lineWidth : f32,\n};\n\nstruct MeshUniforms {\n model : mat4x4<f32>,\n view : mat4x4<f32>,\n tint : vec4f,\n params : vec4f,\n emissive : vec4f,\n specular : vec4f,\n eye : vec4f,\n // straight (unpremultiplied) fog colour (rgb; w reserved)\n fogColor : vec4f,\n // x = mode (0 off / 1 linear / 2 exp2), y = near, z = 1/(far - near),\n // w = density\n fogParams : vec4f,\n // xyz = height falloff x the world-up axis, in VIEW space;\n // w = exp(k * (cameraY - fogHeight)), pre-baked on the CPU\n fogHeight : vec4f,\n};\n\n@group(0) @binding(0) var<uniform> uFrame : FrameUniforms;\n@group(1) @binding(0) var uTexture : texture_2d<f32>;\n@group(1) @binding(1) var uSampler : sampler;\n@group(1) @binding(2) var uAlphaMap : texture_2d<f32>;\n@group(1) @binding(3) var uAlphaSampler : sampler;\n@group(3) @binding(0) var<uniform> uMesh : MeshUniforms;\n\n// Distance fog is behind a PIPELINE-OVERRIDABLE CONSTANT rather than a plain\n// runtime test. `enable_fog` is fixed when the pipeline is created, so the\n// implementation can fold the branch and drop the dead side \u2014 the same result\n// the WebGL backend gets from `#define FOG`, without WGSL needing a\n// preprocessor or the engine deriving a second module. A scene that never\n// enables fog pays for none of the work below.\noverride enable_fog : bool = false;\n\n// How much the height falloff scales the fog along this view ray. Twin of\n// `fogHeightFactor` in the GLSL mesh shaders \u2014 keep them in step.\n//\n// Density falls off exponentially with altitude, and an exponential integrates\n// analytically along a straight segment, so the ray costs one `exp` and no\n// marching. The result multiplies the distance, leaving both curves untouched.\n//\n// Takes the VIEW-space position, not a height. The only position this stage\n// has is pre-view \u2014 `model * position` \u2014 and that is the mesh's parent space\n// rather than the world, because `Container.draw` folds every ancestor into\n// the view matrix. Reading a height straight off it put the fog floor at the\n// wrong altitude for anything under a scaled container. Dotting the view-space\n// position against the world-up axis expressed in view space gives the height\n// above the camera whatever those ancestors did, since they are inside the\n// very matrix that produced this position. `fogHeight.xyz` carries the falloff\n// too, so the exponent is one dot product.\n//\n// Render space is Y-DOWN: density rises as height INCREASES, the opposite sign\n// to every published form of this.\n//\n// A falloff of 0 gives exactly 1, so uniform fog is this with the dial at zero\n// rather than a special case.\nfn fog_height_factor(viewPos : vec3f) -> f32 {\n let kdy = dot(uMesh.fogHeight.xyz, viewPos);\n // (exp(x) - 1) / x is 0/0 at x = 0, which is a horizontal view ray \u2014 take\n // the limit rather than guarding, or the fog steps as the ray flattens\n var t = 1.0;\n if (abs(kdy) >= 1e-4) {\n t = (exp(kdy) - 1.0) / kdy;\n }\n return uMesh.fogHeight.w * t;\n}\n\n\nstruct VSOut {\n @builtin(position) position : vec4f,\n @location(0) vRegion : vec2f,\n @location(1) vColor : vec4f,\n // radial view-space distance for distance fog\n @location(2) vFogDepth : f32,\n};\n\n@vertex\nfn vertex_main(\n @location(0) aVertex : vec3f,\n @location(1) aRegion : vec2f,\n @location(2) aColor : vec4f,\n @location(3) aInstanceRow0 : vec4f,\n @location(4) aInstanceRow1 : vec4f,\n @location(5) aInstanceRow2 : vec4f,\n) -> VSOut {\n var out : VSOut;\n\n let instancePos = vec3f(aInstanceRow0.w, aInstanceRow1.w, aInstanceRow2.w);\n // column 0 of the instance basis is the X axis times its scale; a scatter\n // is rotated about the vertical axis, so its length is the horizontal\n // footprint whatever the rotation\n let footprint = length(\n vec3f(aInstanceRow0.x, aInstanceRow1.x, aInstanceRow2.x));\n\n // the quad is a unit square in the ground plane (XZ); its own Y is\n // irrelevant, because uMesh.model flattens it onto the floor\n let local = instancePos + vec3f(aVertex.x, 0.0, aVertex.z) * footprint;\n\n let clip = uFrame.projection * uMesh.view * uMesh.model * vec4f(local, 1.0);\n // WebGPU clip space is [0, w] in Z where GL is [-w, w]\n out.position = vec4f(clip.xy, (clip.z + clip.w) * 0.5, clip.w);\n\n let tinted = aColor * uMesh.tint;\n out.vColor = vec4f(tinted.rgb * tinted.a, tinted.a);\n // a blob fades with distance like the ground it lies on\n if (enable_fog) {\n let viewPos = uMesh.view * uMesh.model * vec4f(local, 1.0);\n out.vFogDepth = length(viewPos.xyz)\n * fog_height_factor(viewPos.xyz);\n } else {\n out.vFogDepth = 0.0;\n }\n out.vRegion = aRegion;\n return out;\n}\n\n// Fold distance fog into a PREMULTIPLIED colour. Twin of `apply_fog` in\n// mesh.wgsl \u2014 kept in step by hand (WGSL has no preprocessor, and the build\n// loads shaders as raw text). Present here because on WebGL this vertex\n// shader pairs with mesh.frag, which fogs; without this the two backends would\n// disagree on whether distant shadows fade.\nfn apply_fog(rgb : vec3f, a : f32, fogDepth : f32) -> vec3f {\n if (!enable_fog) {\n return rgb;\n }\n let mode = uMesh.fogParams.x;\n if (mode < 0.5) {\n return rgb;\n }\n var f : f32;\n if (mode < 1.5) {\n f = 1.0 - clamp((fogDepth - uMesh.fogParams.y) * uMesh.fogParams.z, 0.0, 1.0);\n } else {\n let dd = fogDepth * uMesh.fogParams.w;\n f = exp(-dd * dd);\n }\n return mix(uMesh.fogColor.rgb * a, rgb, f);\n}\n\n@fragment\nfn fragment_main(in : VSOut) -> @location(0) vec4f {\n let texel = textureSample(uTexture, uSampler, in.vRegion);\n let color = texel * in.vColor;\n return vec4f(apply_fog(color.rgb, color.a, in.vFogDepth), color.a);\n}\n";
@@ -52596,11 +53502,13 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
52596
53502
  }
52597
53503
  this.vertexLayoutKey = vertexLayoutKey;
52598
53504
  this.customShader = null;
53505
+ this.hostedEffect = null;
52599
53506
  this.indexData = new Uint32Array(this.vertexData.maxVertex * 6);
52600
53507
  this.indexCount = 0;
52601
53508
  this.currentMaterial = null;
52602
53509
  this.uniformBinding = null;
52603
53510
  this.uniformBindGroups = /* @__PURE__ */ new Map();
53511
+ this.effectBindGroups = /* @__PURE__ */ new Map();
52604
53512
  this.meshState = { cullMode: "back", frontFace: "ccw" };
52605
53513
  if (this.retained !== void 0) {
52606
53514
  this.releaseAllRetained();
@@ -52745,9 +53653,11 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
52745
53653
  const frame = renderer.currentFrameBinding;
52746
53654
  pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]);
52747
53655
  this.bindLights(pass);
52748
- pass.setBindGroup(3, this.uniformBinding.bindGroup, [
52749
- this.uniformBinding.dynamicOffset
52750
- ]);
53656
+ pass.setBindGroup(
53657
+ 3,
53658
+ this.uniformBinding.bindGroup,
53659
+ this.uniformBinding.offsets
53660
+ );
52751
53661
  pass.setVertexBuffer(0, geometry.vertexBuffer);
52752
53662
  pass.setVertexBuffer(1, instances.buffer);
52753
53663
  pass.setIndexBuffer(geometry.indexBuffer, geometry.indexFormat);
@@ -52856,9 +53766,11 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
52856
53766
  pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]);
52857
53767
  pass.setBindGroup(1, this.currentMaterial);
52858
53768
  this.bindLights(pass);
52859
- pass.setBindGroup(3, this.uniformBinding.bindGroup, [
52860
- this.uniformBinding.dynamicOffset
52861
- ]);
53769
+ pass.setBindGroup(
53770
+ 3,
53771
+ this.uniformBinding.bindGroup,
53772
+ this.uniformBinding.offsets
53773
+ );
52862
53774
  pass.setVertexBuffer(0, quadGeometry.vertexBuffer);
52863
53775
  pass.setVertexBuffer(1, instances.buffer);
52864
53776
  pass.setIndexBuffer(quadGeometry.indexBuffer, quadGeometry.indexFormat);
@@ -52946,6 +53858,108 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
52946
53858
  uniformSignature() {
52947
53859
  return `mesh:u${MESH_UNIFORM_SIZE}`;
52948
53860
  }
53861
+ /**
53862
+ * The group-3 layout when a `ShaderEffect` is hosted on the mesh (#1658).
53863
+ *
53864
+ * `uMesh` keeps binding 0 — the spliced module is the engine's own mesh
53865
+ * shader and still declares it there — and the effect's own uniform block
53866
+ * takes binding 1, its texture/sampler pairs following at their declared
53867
+ * bindings shifted by the same one. That shift is why the body is rewritten
53868
+ * rather than pasted: a quad effect declares its block at
53869
+ * `@group(3) @binding(0)`, which is exactly where `uMesh` lives.
53870
+ * @param {object} effect - the hosted effect
53871
+ * @returns {GPUBindGroupLayout} the combined layout
53872
+ * @ignore
53873
+ * @internal
53874
+ */
53875
+ hostedEffectLayout(effect) {
53876
+ const realization = effect.wgslRealization;
53877
+ const entries = [
53878
+ {
53879
+ binding: 0,
53880
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
53881
+ buffer: {
53882
+ type: "uniform",
53883
+ hasDynamicOffset: true,
53884
+ minBindingSize: MESH_UNIFORM_SIZE
53885
+ }
53886
+ }
53887
+ ];
53888
+ let signature = `${this.uniformSignature()}|fx${realization.structSize}`;
53889
+ if (realization.structSize > 0) {
53890
+ entries.push({
53891
+ binding: 1,
53892
+ visibility: GPUShaderStage.FRAGMENT,
53893
+ buffer: {
53894
+ type: "uniform",
53895
+ hasDynamicOffset: true,
53896
+ minBindingSize: realization.structSize
53897
+ }
53898
+ });
53899
+ }
53900
+ return this.renderer.pipelineCache.getEffectLayout(signature, entries);
53901
+ }
53902
+ /**
53903
+ * Build the group-3 binding for a draw that hosts an effect: `uMesh`
53904
+ * plus a fresh snapshot of the effect's uniform mirror, plus its
53905
+ * textures.
53906
+ * @param {GPUDevice} device - the device
53907
+ * @param {object} renderer - the owning renderer
53908
+ * @param {object} effect - the hosted effect
53909
+ * @param {object} meshRegion - the arena region already holding `uMesh`
53910
+ * @returns {{bindGroup: GPUBindGroup, offsets: number[]}} the binding
53911
+ * @ignore
53912
+ * @internal
53913
+ */
53914
+ bindHostedEffect(device, renderer, effect, meshRegion) {
53915
+ const realization = effect.wgslRealization;
53916
+ const entries = [
53917
+ {
53918
+ binding: 0,
53919
+ resource: { buffer: meshRegion.buffer, size: MESH_UNIFORM_SIZE }
53920
+ }
53921
+ ];
53922
+ const offsets = [meshRegion.offset];
53923
+ if (realization.structSize > 0) {
53924
+ const region = renderer.effectUniformArena.alloc(
53925
+ realization.structSize,
53926
+ device.limits.minUniformBufferOffsetAlignment
53927
+ );
53928
+ device.queue.writeBuffer(
53929
+ region.buffer,
53930
+ region.offset,
53931
+ realization.cpu,
53932
+ 0,
53933
+ realization.structSize
53934
+ );
53935
+ entries.push({
53936
+ binding: 1,
53937
+ resource: { buffer: region.buffer, size: realization.structSize }
53938
+ });
53939
+ offsets.push(region.offset);
53940
+ }
53941
+ const effectBuffer = offsets.length > 1 ? entries[1].resource.buffer : null;
53942
+ let byEffectPage = this.effectBindGroups.get(meshRegion.buffer);
53943
+ if (byEffectPage === void 0) {
53944
+ byEffectPage = /* @__PURE__ */ new Map();
53945
+ this.effectBindGroups.set(meshRegion.buffer, byEffectPage);
53946
+ }
53947
+ let byEffect = byEffectPage.get(effectBuffer);
53948
+ if (byEffect === void 0) {
53949
+ byEffect = /* @__PURE__ */ new Map();
53950
+ byEffectPage.set(effectBuffer, byEffect);
53951
+ }
53952
+ let bindGroup = byEffect.get(effect._effectId);
53953
+ if (bindGroup === void 0) {
53954
+ bindGroup = device.createBindGroup({
53955
+ label: "melonJS mesh uniforms + hosted effect",
53956
+ layout: this.hostedEffectLayout(effect),
53957
+ entries
53958
+ });
53959
+ byEffect.set(effect._effectId, bindGroup);
53960
+ }
53961
+ return { bindGroup, offsets };
53962
+ }
52949
53963
  /**
52950
53964
  * The shader family for the next recorded draw: the built-in family,
52951
53965
  * or the hosted custom module when `renderer.drawMesh` routed a
@@ -52960,6 +53974,27 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
52960
53974
  * @internal
52961
53975
  */
52962
53976
  activeShaderKey() {
53977
+ const effect = this.hostedEffect;
53978
+ if (effect != null) {
53979
+ const cache4 = this.renderer.pipelineCache;
53980
+ if (this._effectKeys?.epoch !== cache4.epoch) {
53981
+ this._effectKeys = { epoch: cache4.epoch, keys: /* @__PURE__ */ new Map() };
53982
+ }
53983
+ const host = `${this.vertexLayoutKey}|fx${effect._effectId}`;
53984
+ let key2 = this._effectKeys.keys.get(host);
53985
+ if (typeof key2 === "undefined") {
53986
+ key2 = cache4.registerShader(
53987
+ effect.wgslRealization.meshModule(this.shaderSource()),
53988
+ {
53989
+ bindGroupLayouts: this.bindGroupLayoutList(cache4),
53990
+ vertexLayoutKey: this.vertexLayoutKey,
53991
+ label: `melonJS ${this.vertexLayoutKey} + hosted effect`
53992
+ }
53993
+ );
53994
+ this._effectKeys.keys.set(host, key2);
53995
+ }
53996
+ return key2;
53997
+ }
52963
53998
  const custom = this.customShader;
52964
53999
  if (custom === null) {
52965
54000
  return this.shaderKey;
@@ -52984,9 +54019,27 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
52984
54019
  cache3.frameLayout,
52985
54020
  cache3.meshMaterialLayout,
52986
54021
  cache3.emptyLayout,
52987
- this.meshLayout
54022
+ this.perDrawLayout()
52988
54023
  ];
52989
54024
  }
54025
+ /**
54026
+ * The group-3 layout for the draw being recorded: `uMesh` alone, or
54027
+ * `uMesh` plus a hosted effect's own block.
54028
+ *
54029
+ * A single accessor on purpose. Every subclass builds its own positional
54030
+ * layout list — the lit tier swaps group 2 for its light block — and when
54031
+ * this choice was inlined into the base list only, the lit subclass kept
54032
+ * handing over the plain layout while the module declared the effect's
54033
+ * binding. The pipeline then failed to validate with "Binding doesn't
54034
+ * exist", every frame drawing that mesh was dropped, and the scene
54035
+ * flickered. Subclasses override the list; they must not re-decide this.
54036
+ * @returns {GPUBindGroupLayout} the group-3 layout
54037
+ * @ignore
54038
+ * @internal
54039
+ */
54040
+ perDrawLayout() {
54041
+ return this.hostedEffect != null ? this.hostedEffectLayout(this.hostedEffect) : this.meshLayout;
54042
+ }
52990
54043
  /**
52991
54044
  * Resolve the group-1 material for a mesh: its texture through the
52992
54045
  * texture store, honouring the mesh's own `textureRepeat` wrap override
@@ -53103,6 +54156,16 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
53103
54156
  0,
53104
54157
  MESH_UNIFORM_SIZE
53105
54158
  );
54159
+ const effect = this.hostedEffect;
54160
+ if (effect !== null && effect !== void 0) {
54161
+ this.uniformBinding = this.bindHostedEffect(
54162
+ device,
54163
+ renderer,
54164
+ effect,
54165
+ region
54166
+ );
54167
+ return;
54168
+ }
53106
54169
  let bindGroup = this.uniformBindGroups.get(region.buffer);
53107
54170
  if (typeof bindGroup === "undefined") {
53108
54171
  bindGroup = device.createBindGroup({
@@ -53117,7 +54180,7 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
53117
54180
  });
53118
54181
  this.uniformBindGroups.set(region.buffer, bindGroup);
53119
54182
  }
53120
- this.uniformBinding = { bindGroup, dynamicOffset: region.offset };
54183
+ this.uniformBinding = { bindGroup, offsets: [region.offset] };
53121
54184
  }
53122
54185
  /**
53123
54186
  * Write one vertex into the staging buffer. The base (unlit) layout is
@@ -53273,9 +54336,11 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
53273
54336
  pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]);
53274
54337
  pass.setBindGroup(1, this.currentMaterial);
53275
54338
  this.bindLights(pass);
53276
- pass.setBindGroup(3, this.uniformBinding.bindGroup, [
53277
- this.uniformBinding.dynamicOffset
53278
- ]);
54339
+ pass.setBindGroup(
54340
+ 3,
54341
+ this.uniformBinding.bindGroup,
54342
+ this.uniformBinding.offsets
54343
+ );
53279
54344
  pass.setVertexBuffer(
53280
54345
  0,
53281
54346
  vertexRegion.buffer,
@@ -53386,9 +54451,11 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
53386
54451
  const frame = renderer.currentFrameBinding;
53387
54452
  pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]);
53388
54453
  this.bindLights(pass);
53389
- pass.setBindGroup(3, this.uniformBinding.bindGroup, [
53390
- this.uniformBinding.dynamicOffset
53391
- ]);
54454
+ pass.setBindGroup(
54455
+ 3,
54456
+ this.uniformBinding.bindGroup,
54457
+ this.uniformBinding.offsets
54458
+ );
53392
54459
  pass.setVertexBuffer(0, geometry.vertexBuffer);
53393
54460
  pass.setIndexBuffer(geometry.indexBuffer, geometry.indexFormat);
53394
54461
  const slices = mesh.textureGroups;
@@ -53438,8 +54505,10 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
53438
54505
  this.indexCount = 0;
53439
54506
  this.currentMaterial = null;
53440
54507
  this.customShader = null;
54508
+ this.hostedEffect = null;
53441
54509
  this.uniformBinding = null;
53442
54510
  this.uniformBindGroups.clear();
54511
+ this.effectBindGroups.clear();
53443
54512
  this.releaseAllRetained();
53444
54513
  }
53445
54514
  /**
@@ -53547,7 +54616,9 @@ var WebGPULitMeshBatcher = class extends WebGPUMeshBatcher {
53547
54616
  cache3.frameLayout,
53548
54617
  cache3.meshMaterialLayout,
53549
54618
  this.ensureLightsLayout(),
53550
- this.meshLayout
54619
+ // NOT `this.meshLayout`: a hosted effect extends group 3, and the
54620
+ // base decides that for every tier (see `perDrawLayout`)
54621
+ this.perDrawLayout()
53551
54622
  ];
53552
54623
  }
53553
54624
  /**
@@ -54334,46 +55405,75 @@ var WebGPUPipelineCache = class _WebGPUPipelineCache {
54334
55405
  primitive.cullMode = meshState.cullMode;
54335
55406
  primitive.frontFace = meshState.frontFace;
54336
55407
  }
54337
- pipeline = this.device.createRenderPipeline({
54338
- label: `melonJS ${key}`,
54339
- layout: this.pipelineLayouts[shaderKey],
54340
- vertex: {
54341
- module: this.modules[shaderKey],
54342
- entryPoint: "vertex_main",
54343
- buffers: vertexLayout ?? [],
54344
- ...meshState ? { constants: { enable_fog: fogFlag } } : {}
54345
- },
54346
- fragment: {
54347
- module: this.modules[shaderKey],
54348
- entryPoint: "fragment_main",
54349
- ...meshState ? { constants: { enable_fog: fogFlag } } : {},
54350
- targets: [
54351
- {
54352
- format: this.format,
54353
- blend: shaderKey === "clear" ? void 0 : gpuBlendState(blend, pma),
54354
- writeMask: stencil.colorWriteMask
54355
- }
54356
- ]
54357
- },
54358
- primitive,
54359
- depthStencil: {
54360
- format: DEPTH_STENCIL_FORMAT,
54361
- // `!== false`, not `!!`: mesh draws that never set the field (and
54362
- // every bare `{cullMode, frontFace}` literal) must keep writing
54363
- // depth exactly as they did before the axis existed
54364
- depthWriteEnabled: meshState ? meshState.depthWrite !== false : false,
54365
- depthCompare: meshState ? "less-equal" : "always",
54366
- stencilFront: stencil.stencil,
54367
- stencilBack: stencil.stencil,
54368
- stencilReadMask: stencil.readMask,
54369
- stencilWriteMask: stencil.writeMask
54370
- },
54371
- multisample: { count: this.sampleCount }
54372
- });
55408
+ pipeline = this.device.createRenderPipeline(
55409
+ this._descriptor(key, shaderKey, blend, pma, stencil, primitive, {
55410
+ vertexLayout,
55411
+ meshState,
55412
+ fogFlag
55413
+ })
55414
+ );
54373
55415
  this.pipelines.set(key, pipeline);
54374
55416
  }
54375
55417
  return pipeline;
54376
55418
  }
55419
+ /**
55420
+ * Assemble a pipeline descriptor.
55421
+ *
55422
+ * Split out of `get()` so the descriptor can be read on its own — it is
55423
+ * long, and every field in it is load-bearing for cache correctness: a
55424
+ * pipeline built from a descriptor that differs from the one a draw would
55425
+ * have produced is worse than no cache entry at all, because the draw
55426
+ * silently builds a second one under a key nothing will hit again.
55427
+ * @param {string} key - the cache key, used as the label
55428
+ * @param {string} shaderKey - the module family
55429
+ * @param {string} blend - normalized blend mode
55430
+ * @param {boolean} pma - premultiplied alpha
55431
+ * @param {object} stencil - the resolved stencil state
55432
+ * @param {object} primitive - the resolved primitive state
55433
+ * @param {object} opts - `{vertexLayout, meshState, fogFlag}`
55434
+ * @returns {GPURenderPipelineDescriptor} the descriptor
55435
+ * @ignore
55436
+ * @internal
55437
+ */
55438
+ _descriptor(key, shaderKey, blend, pma, stencil, primitive, opts) {
55439
+ const { vertexLayout, meshState, fogFlag } = opts;
55440
+ return {
55441
+ label: `melonJS ${key}`,
55442
+ layout: this.pipelineLayouts[shaderKey],
55443
+ vertex: {
55444
+ module: this.modules[shaderKey],
55445
+ entryPoint: "vertex_main",
55446
+ buffers: vertexLayout ?? [],
55447
+ ...meshState ? { constants: { enable_fog: fogFlag } } : {}
55448
+ },
55449
+ fragment: {
55450
+ module: this.modules[shaderKey],
55451
+ entryPoint: "fragment_main",
55452
+ ...meshState ? { constants: { enable_fog: fogFlag } } : {},
55453
+ targets: [
55454
+ {
55455
+ format: this.format,
55456
+ blend: shaderKey === "clear" ? void 0 : gpuBlendState(blend, pma),
55457
+ writeMask: stencil.colorWriteMask
55458
+ }
55459
+ ]
55460
+ },
55461
+ primitive,
55462
+ depthStencil: {
55463
+ format: DEPTH_STENCIL_FORMAT,
55464
+ // `!== false`, not `!!`: mesh draws that never set the field (and
55465
+ // every bare `{cullMode, frontFace}` literal) must keep writing
55466
+ // depth exactly as they did before the axis existed
55467
+ depthWriteEnabled: meshState ? meshState.depthWrite !== false : false,
55468
+ depthCompare: meshState ? "less-equal" : "always",
55469
+ stencilFront: stencil.stencil,
55470
+ stencilBack: stencil.stencil,
55471
+ stencilReadMask: stencil.readMask,
55472
+ stencilWriteMask: stencil.writeMask
55473
+ },
55474
+ multisample: { count: this.sampleCount }
55475
+ };
55476
+ }
54377
55477
  /**
54378
55478
  * drop every cached pipeline (device loss — the whole cache object is
54379
55479
  * rebuilt by the renderer's restore path, this exists for symmetry)
@@ -57568,6 +58668,45 @@ var WebGPURenderer = class _WebGPURenderer extends Renderer {
57568
58668
  }
57569
58669
  return this.renderPass;
57570
58670
  }
58671
+ /**
58672
+ * Compile the WGSL module of every shader asset the loader has, ahead of
58673
+ * the frame that first draws it.
58674
+ *
58675
+ * WebGPU splits what WebGL fuses into one program. The **modules** for
58676
+ * the engine's own families — quad, blit, primitive, clear, and the mesh
58677
+ * tiers — are already created when the pipeline cache and the batchers
58678
+ * are constructed, so there is nothing lazy there to warm. A **pipeline**
58679
+ * is lazy, but it is keyed by draw state (blend, cull, depth-write) as
58680
+ * well as by shader, so the set a scene will ask for cannot be known from
58681
+ * here — and creating one costs almost nothing anyway, because WebGPU
58682
+ * records the intent and leaves the work to first use.
58683
+ *
58684
+ * What IS both lazy and knowable is a `ShaderEffect`'s module:
58685
+ * `prepareEffectBinding` builds it, and it is only ever called from
58686
+ * inside the draw path. Every effect the loader compiled from a
58687
+ * `{type: "shader"}` asset is already in `shaderList`, so those are
58688
+ * exactly the ones that can be brought forward — which is what makes
58689
+ * declaring a level's effects alongside that level's assets worth doing.
58690
+ *
58691
+ * Effects constructed inline when a stage builds are not here yet and
58692
+ * cannot be warmed; that is a property of when they come into existence,
58693
+ * not a limitation of this method.
58694
+ * @returns {Promise<void>} settles once those modules are built
58695
+ * @see {@link Renderer#prewarm}
58696
+ */
58697
+ prewarm() {
58698
+ return Promise.resolve().then(() => {
58699
+ for (const effect of Object.values(shaderList)) {
58700
+ const realization = effect?.wgslRealization;
58701
+ if (realization === void 0 || realization.valid !== true) {
58702
+ continue;
58703
+ }
58704
+ if (realization.gpu === null || realization.gpu.epoch !== this.pipelineCache.epoch) {
58705
+ buildEffectGPU(this, effect, realization);
58706
+ }
58707
+ }
58708
+ });
58709
+ }
57571
58710
  /**
57572
58711
  * end the open pass and submit the frame's command buffer
57573
58712
  * @override
@@ -57841,23 +58980,46 @@ var WebGPURenderer = class _WebGPURenderer extends Renderer {
57841
58980
  this.pendingDepthClear = true;
57842
58981
  }
57843
58982
  const customShader = this.customShader != null && this.customShader.isWebGPU === true ? this.customShader : null;
57844
- if (this.customShader != null && customShader === null && this.meshEffectWarned !== true) {
58983
+ const hostedEffect = customShader === null && this.customShader != null && typeof this.customShader.wgslRealization !== "undefined" && meshHostingBlocker(
58984
+ this.customShader.wgslRealization.builtins,
58985
+ this.customShader.wgslRealization.textures,
58986
+ "wgsl"
58987
+ ) === null && this.customShader.wgslRealization.meshModule(
58988
+ this.batchers.get(
58989
+ mesh.lit === true && this.batchers.has("litMesh") ? "litMesh" : "mesh"
58990
+ )?.shaderSource?.() ?? ""
58991
+ ) !== null ? this.customShader : null;
58992
+ if (this.customShader != null && customShader === null && hostedEffect === null && this.meshEffectWarned !== true) {
57845
58993
  this.meshEffectWarned = true;
58994
+ const realization = this.customShader.wgslRealization;
58995
+ const blocker = typeof realization !== "undefined" ? meshHostingBlocker(
58996
+ realization.builtins,
58997
+ realization.textures,
58998
+ "wgsl"
58999
+ ) : null;
57846
59000
  console.warn(
57847
- "melonJS: this custom shader cannot be hosted on a Mesh by the WebGPU renderer (it carries no `wgsl` module) \u2014 the mesh draws with the built-in shading"
59001
+ blocker !== null ? `melonJS: this ShaderEffect cannot be hosted on a Mesh \u2014 ${blocker}. The mesh draws with the built-in shading.` : "melonJS: this custom shader cannot be hosted on a Mesh by the WebGPU renderer (it carries no `wgsl` module) \u2014 the mesh draws with the built-in shading"
57848
59002
  );
57849
59003
  }
57850
59004
  const batcher = this.setBatcher(
57851
59005
  mesh.lit === true && this.batchers.has("litMesh") ? "litMesh" : "mesh"
57852
59006
  );
57853
59007
  batcher.customShader = customShader;
59008
+ const instanced = mesh.instanceLayout !== void 0 && retained && this.supportsInstancing === true;
59009
+ if (instanced && hostedEffect !== null && this.instancedEffectWarned !== true) {
59010
+ this.instancedEffectWarned = true;
59011
+ console.warn(
59012
+ "melonJS: a ShaderEffect cannot be hosted on an InstancedMesh \u2014 the instanced shading is a separate shader family. The mesh draws with the built-in shading."
59013
+ );
59014
+ }
59015
+ batcher.hostedEffect = instanced ? null : hostedEffect;
57854
59016
  const culling = mesh.cullBackFaces === true;
57855
59017
  const state4 = batcher.meshState;
57856
59018
  state4.cullMode = culling ? "back" : "none";
57857
59019
  state4.frontFace = culling && retained && mesh.rightHanded !== true ? "cw" : "ccw";
57858
59020
  try {
57859
59021
  const tint = this.currentTint.toUint32(this.getGlobalAlpha());
57860
- if (mesh.instanceLayout !== void 0 && retained && this.supportsInstancing === true) {
59022
+ if (instanced) {
57861
59023
  batcher.drawInstancedMesh(mesh, modelMatrix, tint);
57862
59024
  } else if (retained) {
57863
59025
  batcher.drawRetainedMesh(mesh, modelMatrix, tint);
@@ -57867,6 +59029,7 @@ var WebGPURenderer = class _WebGPURenderer extends Renderer {
57867
59029
  }
57868
59030
  } finally {
57869
59031
  batcher.customShader = null;
59032
+ batcher.hostedEffect = null;
57870
59033
  }
57871
59034
  }
57872
59035
  /**
@@ -59349,6 +60512,7 @@ var defaultApplicationSettings = {
59349
60512
  powerPreference: "default",
59350
60513
  transparent: false,
59351
60514
  antiAlias: false,
60515
+ prewarm: true,
59352
60516
  textureFilter: "auto",
59353
60517
  maxTextures: "auto",
59354
60518
  castGroundShadow: true,
@@ -60914,7 +62078,7 @@ var Sprite3d = class extends Mesh {
60914
62078
  * @param {number} x - world x position
60915
62079
  * @param {number} y - world y position
60916
62080
  * @param {object} settings - configuration
60917
- * @param {HTMLImageElement|Texture2d|string} [settings.image] - the sprite texture (image name, image, or a {@link Texture2d} asset such as a {@link TextureAtlas}). Alias: `settings.texture`.
62081
+ * @param {HTMLImageElement|HTMLCanvasElement|Texture2d|string} [settings.image] - the sprite texture (image name, image, or a {@link Texture2d} asset such as a {@link TextureAtlas}). Alias: `settings.texture`.
60918
62082
  * @param {number} [settings.width=settings.framewidth] - quad width in world units (pixels)
60919
62083
  * @param {number} [settings.height=settings.width] - quad height in world units
60920
62084
  * @param {number} [settings.framewidth] - width of a single frame within a spritesheet (enables frame animation)
@@ -60928,6 +62092,11 @@ var Sprite3d = class extends Mesh {
60928
62092
  * @param {boolean} [settings.flipY=false] - mirror the sprite vertically (see {@link Sprite3d#flipY})
60929
62093
  * @param {boolean} [settings.lit=false] - shade through the lit mesh batcher (see {@link Mesh})
60930
62094
  * @param {number[]|Float32Array} [settings.emissive] - emissive color (see {@link Mesh})
62095
+ * @param {number} [settings.shadowGroundY] - world Y of the floor the blob shadow lands on. Omit and it falls back to the sprite's own base — which for a billboard moves with the camera, so a scene that knows where its floor is should say so.
62096
+ * @param {number} [settings.shadowOpacity=0.45] - opacity of the shadow directly beneath the sprite, before any height fade
62097
+ * @param {boolean} [settings.castGroundShadow] - give this sprite a blob ground shadow, overriding the application's `castGroundShadow` setting in both directions. Omit to inherit. Needs a GPU backend and a {@link Camera3d}.
62098
+ * @param {boolean} [settings.fog] - set `false` to exempt this sprite from the camera's distance fog ({@link Camera3d#setFog}); omit to fog whenever the camera does. A sun or a moon wants this — everything else at that distance dissolves into the haze, and so would it.
62099
+ * @param {boolean} [settings.transparent] - draw in the transparent pass (blended, back-to-front, no depth write) instead of the opaque one. Omit and the sprite goes transparent whenever its draw alpha is fractional; `true` for a soft-alpha sprite such as an additive glow; `false` to stay opaque however faded.
60931
62100
  * @param {number} [settings.alphaCutoff=0.5] - alpha cutout threshold (see {@link Mesh}). The mesh pass is opaque (no alpha blending), so this defaults to `0.5` to discard a sprite's transparent background (clean cutout silhouette, correct depth, no sorting). Set `0` for a fully-opaque quad, or tune the threshold.
60932
62101
  */
60933
62102
  constructor(x, y, settings) {
@@ -61010,6 +62179,7 @@ var Sprite3d = class extends Mesh {
61010
62179
  // flatten it to an explicit false, silently opting every sprite out
61011
62180
  // of a scene-wide default
61012
62181
  castGroundShadow: settings.castGroundShadow,
62182
+ fog: settings.fog,
61013
62183
  // raw for the same reason as above: `undefined` means "decide from
61014
62184
  // the draw's alpha", and coercing it would pin every sprite opaque
61015
62185
  transparent: settings.transparent,
@@ -64247,25 +65417,8 @@ var lerp3 = (a, b, t) => {
64247
65417
  };
64248
65418
  var NoiseTexture2d = class extends Texture2d {
64249
65419
  /**
64250
- * @param {object} [settings] - configuration; any {@link Noise} setting
64251
- * (`type`, `seed`, `frequency`/`scale`, `octaves`, `gain`/`persistence`,
64252
- * `lacunarity`, `fractalType`, domain-warp settings) is forwarded when no
64253
- * `noise` instance is given.
64254
- * @param {number} [settings.width=256] - baked texture width in pixels
64255
- * @param {number} [settings.height=256] - baked texture height in pixels
64256
- * @param {Noise} [settings.noise] - an existing {@link Noise} to bake; when
64257
- * omitted one is built from the forwarded settings
64258
- * @param {boolean} [settings.seamless=false] - tile cleanly in both axes
64259
- * @param {number} [settings.seamlessBlendSkirt=0.1] - edge blend band width
64260
- * as a fraction (0..1) of the smaller dimension, when `seamless`
64261
- * @param {boolean} [settings.invert=false] - invert the noise value (`1 - v`)
64262
- * @param {boolean} [settings.asNormalMap=false] - encode as a normal map
64263
- * @param {number} [settings.bumpStrength=1] - normal steepness when `asNormalMap`
64264
- * @param {Gradient} [settings.colorRamp] - map the noise value to a color
64265
- * @param {boolean} [settings.animated=false] - sample in 3D (`getNoise3d`)
64266
- * using an internal `time` as the third axis, advanced by {@link NoiseTexture2d#update}
64267
- * @param {number} [settings.speed=1] - animation speed in noise z-units per
64268
- * second (only used while `animated`)
65420
+ * @param {NoiseTexture2dSettings} [settings] - bake settings, plus any
65421
+ * {@link NoiseSettings} for the field itself
64269
65422
  */
64270
65423
  constructor(settings = {}) {
64271
65424
  super();