melonjs 20.4.0 → 20.5.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 (79) hide show
  1. package/build/audio/backend/sound.d.ts.map +1 -1
  2. package/build/audio/procedural.d.ts +7 -2
  3. package/build/audio/procedural.d.ts.map +1 -1
  4. package/build/audio/types.d.ts +22 -0
  5. package/build/audio/types.d.ts.map +1 -1
  6. package/build/camera/camera2d.d.ts +28 -0
  7. package/build/camera/camera2d.d.ts.map +1 -1
  8. package/build/camera/camera3d.d.ts +24 -3
  9. package/build/camera/camera3d.d.ts.map +1 -1
  10. package/build/geometries/polygon.d.ts +7 -1
  11. package/build/geometries/polygon.d.ts.map +1 -1
  12. package/build/index.d.ts +4 -0
  13. package/build/index.d.ts.map +1 -1
  14. package/build/index.js +1061 -166
  15. package/build/index.js.map +4 -4
  16. package/build/level/gltf/GLTFModel.d.ts +50 -7
  17. package/build/level/gltf/GLTFModel.d.ts.map +1 -1
  18. package/build/lighting/light3d.d.ts +20 -47
  19. package/build/lighting/light3d.d.ts.map +1 -1
  20. package/build/loader/loader.d.ts +106 -6
  21. package/build/loader/loader.d.ts.map +1 -1
  22. package/build/renderable/animation.d.ts +21 -6
  23. package/build/renderable/animation.d.ts.map +1 -1
  24. package/build/renderable/container.d.ts +27 -5
  25. package/build/renderable/container.d.ts.map +1 -1
  26. package/build/renderable/instanced_mesh.d.ts +34 -9
  27. package/build/renderable/instanced_mesh.d.ts.map +1 -1
  28. package/build/renderable/mesh.d.ts +131 -94
  29. package/build/renderable/mesh.d.ts.map +1 -1
  30. package/build/renderable/renderable.d.ts +17 -0
  31. package/build/renderable/renderable.d.ts.map +1 -1
  32. package/build/renderable/sprite3d.d.ts +12 -2
  33. package/build/renderable/sprite3d.d.ts.map +1 -1
  34. package/build/renderable/text/bitmaptext.d.ts +32 -7
  35. package/build/renderable/text/bitmaptext.d.ts.map +1 -1
  36. package/build/renderable/text/text.d.ts +78 -3
  37. package/build/renderable/text/text.d.ts.map +1 -1
  38. package/build/renderable/text/textmetrics.d.ts +2 -0
  39. package/build/renderable/text/textmetrics.d.ts.map +1 -1
  40. package/build/renderable/text/textstyle.d.ts.map +1 -1
  41. package/build/state/stage.d.ts +21 -0
  42. package/build/state/stage.d.ts.map +1 -1
  43. package/build/system/device.d.ts +1 -1
  44. package/build/system/device.d.ts.map +1 -1
  45. package/build/system/save.d.ts +44 -1
  46. package/build/system/save.d.ts.map +1 -1
  47. package/build/video/effects/glsl_realization.d.ts.map +1 -1
  48. package/build/video/effects/mesh_splice.d.ts +2 -0
  49. package/build/video/effects/mesh_splice.d.ts.map +1 -0
  50. package/build/video/effects/shadereffect.d.ts +12 -128
  51. package/build/video/effects/shadereffect.d.ts.map +1 -1
  52. package/build/video/effects/wgsl_realization.d.ts.map +1 -1
  53. package/build/video/gradient.d.ts +19 -0
  54. package/build/video/gradient.d.ts.map +1 -1
  55. package/build/video/renderer.d.ts.map +1 -1
  56. package/build/video/texture/noise_texture2d.d.ts +101 -34
  57. package/build/video/texture/noise_texture2d.d.ts.map +1 -1
  58. package/build/video/webgl/batchers/mesh_batcher.d.ts.map +1 -1
  59. package/build/video/webgl/glshader.d.ts +7 -2
  60. package/build/video/webgl/glshader.d.ts.map +1 -1
  61. package/build/video/webgl/webgl_renderer.d.ts.map +1 -1
  62. package/build/video/webgpu/batchers/lit_mesh_batcher.d.ts.map +1 -1
  63. package/build/video/webgpu/batchers/mesh_batcher.d.ts +6 -1
  64. package/build/video/webgpu/batchers/mesh_batcher.d.ts.map +1 -1
  65. package/build/video/webgpu/webgpu_renderer.d.ts +1 -0
  66. package/build/video/webgpu/webgpu_renderer.d.ts.map +1 -1
  67. package/package.json +1 -1
  68. package/skills/melonjs-3d/SKILL.md +152 -3
  69. package/skills/melonjs-3d-assets/SKILL.md +139 -0
  70. package/skills/melonjs-audio/SKILL.md +17 -0
  71. package/skills/melonjs-camera-and-drawing/SKILL.md +45 -0
  72. package/skills/melonjs-effects-and-shaders/SKILL.md +93 -1
  73. package/skills/melonjs-loading-assets/SKILL.md +97 -1
  74. package/skills/melonjs-performance/SKILL.md +55 -1
  75. package/skills/melonjs-physics/SKILL.md +78 -0
  76. package/skills/melonjs-renderables/SKILL.md +63 -1
  77. package/skills/melonjs-renderer-backends/SKILL.md +19 -0
  78. package/skills/melonjs-scenes-and-state/SKILL.md +56 -0
  79. 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.5.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
@@ -6956,9 +6990,18 @@ var Renderer = class _Renderer {
6956
6990
  entry2.shader = this.customShader ?? null;
6957
6991
  const v = this.currentTransform.val;
6958
6992
  const m = modelMatrix.val;
6959
- const mx = m[12];
6960
- const my = m[13];
6961
- const mz = m[14];
6993
+ let cx = 0;
6994
+ let cy = 0;
6995
+ let cz = 0;
6996
+ if (typeof mesh?._updateLocalCenter === "function") {
6997
+ mesh._updateLocalCenter();
6998
+ cx = mesh._lcx;
6999
+ cy = mesh._lcy;
7000
+ cz = mesh._lcz;
7001
+ }
7002
+ const mx = m[0] * cx + m[4] * cy + m[8] * cz + m[12];
7003
+ const my = m[1] * cx + m[5] * cy + m[9] * cz + m[13];
7004
+ const mz = m[2] * cx + m[6] * cy + m[10] * cz + m[14];
6962
7005
  const dx = v[0] * mx + v[4] * my + v[8] * mz + v[12];
6963
7006
  const dy = v[1] * mx + v[5] * my + v[9] * mz + v[13];
6964
7007
  const dz = v[2] * mx + v[6] * my + v[10] * mz + v[14];
@@ -7761,6 +7804,31 @@ var Renderer = class _Renderer {
7761
7804
  );
7762
7805
  }
7763
7806
  break;
7807
+ // Box3d — a 3D box has no 2D outline of its own, so draw the XY
7808
+ // FOOTPRINT it already hands every 2D consumer through its own
7809
+ // `getBounds()` (the body bounds, the broadphase pre-gate, and
7810
+ // this). Without a case here it fell through to the `default`
7811
+ // below, so switching on the debug panel's hitbox overlay threw
7812
+ // "Invalid geometry for fill/stroke" on any body carrying one.
7813
+ case "Box3d": {
7814
+ const footprint = shape.getBounds();
7815
+ if (fill) {
7816
+ this.fillRect(
7817
+ footprint.left,
7818
+ footprint.top,
7819
+ footprint.width,
7820
+ footprint.height
7821
+ );
7822
+ } else {
7823
+ this.strokeRect(
7824
+ footprint.left,
7825
+ footprint.top,
7826
+ footprint.width,
7827
+ footprint.height
7828
+ );
7829
+ }
7830
+ break;
7831
+ }
7764
7832
  // Rect or Bounds
7765
7833
  case "Rectangle":
7766
7834
  case "Bounds":
@@ -10411,14 +10479,16 @@ var Sound = class {
10411
10479
  sound2._node.gain.linearRampToValueAtTime(to, end);
10412
10480
  }
10413
10481
  }
10414
- this._startFadeInterval(
10415
- sound2,
10416
- from,
10417
- to,
10418
- len,
10419
- ids[i],
10420
- typeof id === "undefined"
10421
- );
10482
+ const isGroup = typeof id === "undefined";
10483
+ if (!(len > 0) || to === from) {
10484
+ if (isGroup) {
10485
+ this._volume = to;
10486
+ }
10487
+ this.volume(to, ids[i]);
10488
+ this._emit("fade", ids[i]);
10489
+ continue;
10490
+ }
10491
+ this._startFadeInterval(sound2, from, to, len, ids[i], isGroup);
10422
10492
  }
10423
10493
  }
10424
10494
  return this;
@@ -11269,13 +11339,14 @@ function tone(opts) {
11269
11339
  gain = 0.1,
11270
11340
  attack = 5e-3,
11271
11341
  pan = 0,
11272
- pitchSlide = 1
11342
+ pitchSlide = 1,
11343
+ delay = 0
11273
11344
  } = opts;
11274
11345
  const freqs = Array.isArray(freq) ? freq : [freq];
11275
11346
  if (freqs.length === 0) return;
11276
11347
  _resumeIfSuspended(ctx);
11277
11348
  const dur = Math.max(1e-3, duration2);
11278
- const t0 = ctx.currentTime;
11349
+ const t0 = ctx.currentTime + Math.max(0, delay);
11279
11350
  const t1 = t0 + dur;
11280
11351
  const env = _buildGainEnvelope(ctx, t0, t1, attack, dur, gain);
11281
11352
  const panner2 = _connectToOutput(ctx, env, pan, t0);
@@ -11348,11 +11419,12 @@ function noise(opts) {
11348
11419
  attack = 5e-3,
11349
11420
  pan = 0,
11350
11421
  filter,
11351
- filterSweep = 1
11422
+ filterSweep = 1,
11423
+ delay = 0
11352
11424
  } = opts;
11353
11425
  _resumeIfSuspended(ctx);
11354
11426
  const dur = Math.max(1e-3, duration2);
11355
- const t0 = ctx.currentTime;
11427
+ const t0 = ctx.currentTime + Math.max(0, delay);
11356
11428
  const t1 = t0 + dur;
11357
11429
  const env = _buildGainEnvelope(ctx, t0, t1, attack, dur, gain);
11358
11430
  const sampleCount = Math.max(1, Math.ceil(dur * ctx.sampleRate));
@@ -15216,7 +15288,7 @@ var Line = class _Line extends Polygon {
15216
15288
  */
15217
15289
  recalc() {
15218
15290
  const edges = this.edges;
15219
- const normals = this.normals;
15291
+ const normals = this.edgeNormals;
15220
15292
  const indices = this.indices;
15221
15293
  const points = this.points;
15222
15294
  if (points.length !== 2) {
@@ -17271,6 +17343,9 @@ var Renderable = class _Renderable extends Rect {
17271
17343
  }
17272
17344
  /**
17273
17345
  * get the renderable alpha channel value<br>
17346
+ *
17347
+ * This is the renderable's OWN value. What it finally draws at is this
17348
+ * multiplied by every ancestor's — see {@link Renderable#alpha}.
17274
17349
  * @returns {number} current opacity value between 0 and 1
17275
17350
  */
17276
17351
  getOpacity() {
@@ -17668,7 +17743,10 @@ var Renderable = class _Renderable extends Rect {
17668
17743
  if (this.applyAnchorTransform !== false) {
17669
17744
  renderer.translate(-ax, -ay);
17670
17745
  }
17671
- renderer.setTint(this.tint, this.getOpacity());
17746
+ renderer.setTint(
17747
+ this.tint,
17748
+ this.getOpacity() * (renderer.currentTint?.alpha ?? 1)
17749
+ );
17672
17750
  renderer.setDepth(this.depth);
17673
17751
  if (this.blendMode !== renderer.getBlendMode()) {
17674
17752
  renderer.setBlendMode(this.blendMode);
@@ -18260,9 +18338,14 @@ var GLShader = class _GLShader {
18260
18338
  /**
18261
18339
  * Set the uniform to the given value
18262
18340
  * @param {string} name - the uniform name
18263
- * @param {object|Float32Array} value - the value to assign to that uniform
18341
+ * @param {number|boolean|number[]|Float32Array|object} value - the value to assign to that
18342
+ * uniform. Scalars (`float`, `int`, `bool`) take a number or a boolean;
18343
+ * vectors and matrices take an array, a `Float32Array`, or any object
18344
+ * exposing `toArray()` — which is every {@link Vector2d},
18345
+ * {@link Vector3d}, {@link Color} and {@link Matrix3d}.
18264
18346
  * @example
18265
18347
  * myShader.setUniform("uProjectionMatrix", this.projectionMatrix);
18348
+ * myShader.setUniform("uStrength", 0.5); // a scalar is a plain number
18266
18349
  */
18267
18350
  setUniform(name, value) {
18268
18351
  if (this.destroyed) {
@@ -18547,11 +18630,78 @@ function buildGLSLProgram(fragmentBody) {
18547
18630
  return {
18548
18631
  vertex: buildEffectVertex(builtins),
18549
18632
  fragment: fragment2,
18633
+ // the annotation-stripped body, so a mesh-hosted effect can be
18634
+ // spliced into the engine's own mesh shader (#1658) without
18635
+ // re-parsing. Additive: the quad `vertex`/`fragment` above are
18636
+ // byte-identical to what they always were.
18637
+ body: builtins.body,
18638
+ builtins,
18550
18639
  screenTextures: builtins.screenTextures,
18551
18640
  noiseUV: builtins.noiseUV
18552
18641
  };
18553
18642
  }
18554
18643
 
18644
+ // src/video/effects/mesh_splice.js
18645
+ var HOOKS = [
18646
+ {
18647
+ pattern: /vec4\s+ME_effect\s*\(\s*vec4\s+c\s*,\s*vec2\s+uv\s*\)\s*\{[^}]*\}/,
18648
+ replacement: "vec4 ME_effect(vec4 c, vec2 uv) { return apply(c, uv); }"
18649
+ },
18650
+ {
18651
+ pattern: /fn\s+ME_effect\s*\(\s*c\s*:\s*vec4f\s*,\s*uv\s*:\s*vec2f\s*\)\s*->\s*vec4f\s*\{[^}]*\}/,
18652
+ replacement: "fn ME_effect(c : vec4f, uv : vec2f) -> vec4f { return apply(c, uv); }"
18653
+ }
18654
+ ];
18655
+ var MESH_UNSUPPORTED = {
18656
+ noiseUV: "`noise_uv` (derived from a quad's image and object size)",
18657
+ screenUV: "`screen_uv` (needs the screen capture the quad tier takes)",
18658
+ screenTexture: "`screen_texture` (needs the screen capture)"
18659
+ };
18660
+ function meshHostingBlocker(builtins, textures, language2) {
18661
+ if (language2 === "wgsl" && builtins?.vColor === true) {
18662
+ return "it reads `vColor`, which only the 2D quad tier exposes to an effect body";
18663
+ }
18664
+ if (Array.isArray(textures) && textures.length > 0) {
18665
+ return "it declares its own texture samplers, which a mesh-hosted effect cannot bind yet \u2014 uniforms are supported, textures are not";
18666
+ }
18667
+ if (typeof builtins !== "object" || builtins === null) {
18668
+ return null;
18669
+ }
18670
+ const blocked = Object.keys(MESH_UNSUPPORTED).filter((flag) => {
18671
+ return builtins[flag] === true;
18672
+ }).map((flag) => {
18673
+ return MESH_UNSUPPORTED[flag];
18674
+ });
18675
+ return blocked.length === 0 ? null : `it uses ${blocked.join(" and ")}, which only the 2D quad tier provides`;
18676
+ }
18677
+ function hasSpliceMarkers(source) {
18678
+ return typeof source === "string" && HOOKS.some((hook) => {
18679
+ return hook.pattern.test(source);
18680
+ });
18681
+ }
18682
+ function spliceEffect(source, body) {
18683
+ if (typeof source !== "string" || typeof body !== "string") {
18684
+ return null;
18685
+ }
18686
+ for (const hook of HOOKS) {
18687
+ if (hook.pattern.test(source)) {
18688
+ return source.replace(hook.pattern, () => {
18689
+ return `${body}
18690
+ ${hook.replacement}`;
18691
+ });
18692
+ }
18693
+ }
18694
+ return null;
18695
+ }
18696
+ function shiftGroup3(body, shift) {
18697
+ return body.replace(
18698
+ /@group\(\s*3\s*\)\s*@binding\(\s*(\d+)\s*\)/g,
18699
+ (_match, binding) => {
18700
+ return `@group(3) @binding(${Number(binding) + shift})`;
18701
+ }
18702
+ );
18703
+ }
18704
+
18555
18705
  // src/video/effects/wgsl/layout.js
18556
18706
  var TYPE_LAYOUT = {
18557
18707
  f32: { align: 4, size: 4 },
@@ -18923,6 +19073,8 @@ var WGSLEffectRealization = class {
18923
19073
  if (!this.valid) {
18924
19074
  return;
18925
19075
  }
19076
+ this.body = body;
19077
+ this.parsed = parsed;
18926
19078
  this.layout = parsed.layout;
18927
19079
  this.structSize = parsed.structSize;
18928
19080
  this.textures = parsed.textures;
@@ -18930,6 +19082,7 @@ var WGSLEffectRealization = class {
18930
19082
  const module = buildWGSLModule(body, parsed);
18931
19083
  this.code = module.code;
18932
19084
  this.builtinBindings = module.bindings;
19085
+ this.meshModules = /* @__PURE__ */ new Map();
18933
19086
  this.cpu = new ArrayBuffer(Math.max(this.structSize, 0));
18934
19087
  this.f32 = new Float32Array(this.cpu);
18935
19088
  this.i32 = new Int32Array(this.cpu);
@@ -19006,9 +19159,30 @@ var WGSLEffectRealization = class {
19006
19159
  releaseGPU() {
19007
19160
  this.gpu = null;
19008
19161
  }
19162
+ /**
19163
+ * This effect realized against the MESH contract: the engine's own mesh
19164
+ * module with the body spliced in at its `ME_effect` hook, and the
19165
+ * body's group-3 bindings shifted up one so `uMesh` keeps binding 0.
19166
+ *
19167
+ * The quad module (`this.code`) is untouched — a `Sprite` still gets the
19168
+ * exact text it always did.
19169
+ * @param {string} meshSource - the host mesh shader's WGSL text
19170
+ * @returns {string} the spliced module
19171
+ * @ignore
19172
+ * @internal
19173
+ */
19174
+ meshModule(meshSource) {
19175
+ let code = this.meshModules.get(meshSource);
19176
+ if (typeof code === "undefined") {
19177
+ code = spliceEffect(meshSource, shiftGroup3(this.body, 1));
19178
+ this.meshModules.set(meshSource, code);
19179
+ }
19180
+ return code;
19181
+ }
19009
19182
  };
19010
19183
 
19011
19184
  // src/video/effects/shadereffect.js
19185
+ var _nextEffectId = 0;
19012
19186
  var ShaderEffect = class _ShaderEffect {
19013
19187
  /**
19014
19188
  * whether this effect is active (false in Canvas mode, false after
@@ -19065,10 +19239,14 @@ var ShaderEffect = class _ShaderEffect {
19065
19239
  return;
19066
19240
  }
19067
19241
  this._extraTextures = /* @__PURE__ */ new Map();
19242
+ this._uniformValues = /* @__PURE__ */ new Map();
19243
+ this._effectId = _nextEffectId++;
19068
19244
  if (language2 === "glsl") {
19069
19245
  const program = buildGLSLProgram(source);
19070
19246
  this._screenTextureUniforms = program.screenTextures;
19071
19247
  this._hasNoiseUV = program.noiseUV;
19248
+ this._meshBody = program.body;
19249
+ this._meshBuiltins = program.builtins;
19072
19250
  this._shader = new GLShader(
19073
19251
  renderer.gl,
19074
19252
  program.vertex,
@@ -19138,12 +19316,23 @@ var ShaderEffect = class _ShaderEffect {
19138
19316
  /**
19139
19317
  * Set the uniform to the given value
19140
19318
  * @param {string} name - the uniform name
19141
- * @param {object|Float32Array} value - the value to assign to that uniform
19319
+ * @param {number|boolean|number[]|Float32Array|object} value - the value to assign to that
19320
+ * uniform. Scalars (`float`, `int`, `bool`) take a number or a boolean;
19321
+ * vectors and matrices take an array, a `Float32Array`, or any object
19322
+ * exposing `toArray()` — which is every {@link Vector2d},
19323
+ * {@link Vector3d}, {@link Color} and {@link Matrix3d}.
19324
+ * @example
19325
+ * // a scalar the body declares as `uniform float uStrength;`
19326
+ * fx.setUniform("uStrength", 0.5);
19327
+ * // a vec3 — an array, or anything with toArray()
19328
+ * fx.setUniform("uTint", [1.0, 0.82, 0.55]);
19329
+ * fx.setUniform("uOrigin", new me.Vector2d(0.5, 0.5));
19142
19330
  */
19143
19331
  setUniform(name, value) {
19144
19332
  if (this.destroyed === true) {
19145
19333
  return;
19146
19334
  }
19335
+ this._uniformValues?.set(name, value);
19147
19336
  if (typeof this._shader !== "undefined") {
19148
19337
  this._shader.setUniform(name, value);
19149
19338
  } else if (typeof this.wgslRealization !== "undefined") {
@@ -19174,7 +19363,12 @@ var ShaderEffect = class _ShaderEffect {
19174
19363
  * flow.setTime(me.timer.getTime() / 1000);
19175
19364
  */
19176
19365
  setTime(seconds) {
19177
- if (typeof this._shader !== "undefined" && this.destroyed !== true && !this._shader.suspended && typeof this._shader.uniforms.uTime !== "undefined") {
19366
+ if (this.destroyed !== true) {
19367
+ this._uniformValues?.set("uTime", seconds);
19368
+ }
19369
+ if (typeof this._shader !== "undefined" && this.destroyed !== true && !this._shader.suspended && // optional: the map is null on a shader with no live context
19370
+ // behind it, which `suspended` alone does not cover
19371
+ typeof this._shader.uniforms?.uTime !== "undefined") {
19178
19372
  this._shader.setUniform("uTime", seconds);
19179
19373
  } else if (typeof this.wgslRealization !== "undefined" && this.destroyed !== true && this.wgslRealization.hasUniform("uTime")) {
19180
19374
  this.wgslRealization.setUniform("uTime", seconds);
@@ -19862,6 +20056,8 @@ var Camera2d = class extends Renderable {
19862
20056
  * @internal
19863
20057
  */
19864
20058
  _zoom;
20059
+ /** backing store for {@link Camera2d#roll} — see the accessor */
20060
+ _roll;
19865
20061
  /**
19866
20062
  * the world-space projection matrix for non-default cameras (offset/zoomed).
19867
20063
  * Maps world coordinates to the camera's screen viewport.
@@ -19954,6 +20150,7 @@ var Camera2d = class extends Renderable {
19954
20150
  this.screenX = 0;
19955
20151
  this.screenY = 0;
19956
20152
  this.zoom = 1;
20153
+ this._roll = 0;
19957
20154
  this.worldProjection = new Matrix3d();
19958
20155
  this.screenProjection = new Matrix3d();
19959
20156
  this._worldView = new Bounds();
@@ -20029,6 +20226,48 @@ var Camera2d = class extends Renderable {
20029
20226
  * // zoom out to show the full level in a 180x100 minimap
20030
20227
  * camera.zoom = Math.min(180 / levelWidth, 100 / levelHeight);
20031
20228
  */
20229
+ /**
20230
+ * Roll this camera in the screen plane, in radians — the 2D spelling of
20231
+ * {@link Camera3d#roll}, so a helper that banks "a camera" works on either.
20232
+ *
20233
+ * Absolute and idempotent: assigning the same angle twice leaves the view
20234
+ * where it was. It is a thin accessor over the camera's own
20235
+ * `currentTransform`, which is what {@link Renderable#rotate} writes and
20236
+ * what `worldToLocal` / `localToWorld` already undo — so picking under a
20237
+ * rolled 2D camera stays correct for free.
20238
+ *
20239
+ * A 2D camera has no pitch or yaw, so a screen-plane rotation is its only
20240
+ * rotation; on a `Camera3d` the roll is a real view angle instead, because
20241
+ * a 3D view is built from its three angles and never reads
20242
+ * `currentTransform`.
20243
+ *
20244
+ * This OWNS the camera's `currentTransform`: the setter rebuilds it rather
20245
+ * than composing onto it, so assigning every frame cannot drift and
20246
+ * `roll = 0` lands back on an exact identity. (A delta-based version does
20247
+ * neither — a banking camera would accumulate float error across thousands
20248
+ * of frames.) The cost is that a manual `rotate()` / `scale()` on the
20249
+ * camera itself is discarded by the next assignment; a camera has `zoom`
20250
+ * and `shake` for those jobs, and neither touches this matrix.
20251
+ * @default 0
20252
+ * @example
20253
+ * camera.roll = Math.PI / 32; // a slight dutch angle
20254
+ */
20255
+ get roll() {
20256
+ return this._roll;
20257
+ }
20258
+ set roll(value) {
20259
+ if (value === this._roll) {
20260
+ return;
20261
+ }
20262
+ this._roll = value;
20263
+ this.currentTransform.identity();
20264
+ if (value !== 0) {
20265
+ this.currentTransform.rotate(value);
20266
+ }
20267
+ this.invCurrentTransform.copy(this.currentTransform).invert();
20268
+ this.updateBounds();
20269
+ this.isDirty = true;
20270
+ }
20032
20271
  get zoom() {
20033
20272
  return this._zoom;
20034
20273
  }
@@ -20086,6 +20325,7 @@ var Camera2d = class extends Renderable {
20086
20325
  this.smoothFollow = true;
20087
20326
  this.damping = 1;
20088
20327
  this.currentTransform.identity();
20328
+ this._roll = 0;
20089
20329
  this.invCurrentTransform.identity().invert();
20090
20330
  this.worldProjection.identity();
20091
20331
  this.screenProjection.identity();
@@ -21855,9 +22095,8 @@ var Stage = class {
21855
22095
  }
21856
22096
  }
21857
22097
  /**
21858
- * update function
21859
- * @ignore
21860
- * @internal
22098
+ * Update the stage. Override it to run your own per-frame logic, and call
22099
+ * `super.update(dt)` so the world and cameras still advance.
21861
22100
  * @param dt - time since the last update in milliseconds.
21862
22101
  * @returns true if the stage needs to be redrawn
21863
22102
  */
@@ -21876,8 +22115,9 @@ var Stage = class {
21876
22115
  * Lights are rendered as part of the world tree (they're now first-class
21877
22116
  * Renderables) and the ambient overlay pass runs inside each Camera's
21878
22117
  * post-effect FBO bracket via {@link Stage#drawLighting}.
21879
- * @ignore
21880
- * @internal
22118
+ *
22119
+ * Override it to draw under or over the world, calling `super.draw(...)`
22120
+ * where the world itself belongs in that order.
21881
22121
  * @param renderer - the renderer object to draw with
21882
22122
  * @param world - the world object to draw
21883
22123
  */
@@ -23080,6 +23320,9 @@ function preloadFontFace(data2, onload2, onerror) {
23080
23320
  }
23081
23321
 
23082
23322
  // src/lighting/light3d.ts
23323
+ function readVector(out, value) {
23324
+ return Array.isArray(value) ? out.set(value[0], value[1], value[2]) : out.set(value.x, value.y, value.z);
23325
+ }
23083
23326
  var Light3d = class extends Renderable {
23084
23327
  /** `"directional"`, `"ambient"`, `"point"` or `"spot"`. */
23085
23328
  type;
@@ -23111,20 +23354,12 @@ var Light3d = class extends Renderable {
23111
23354
  this.type = options.type ?? "directional";
23112
23355
  this.direction = new Vector3d(0, 1, 0);
23113
23356
  if (options.direction) {
23114
- this.direction.set(
23115
- options.direction[0],
23116
- options.direction[1],
23117
- options.direction[2]
23118
- );
23357
+ readVector(this.direction, options.direction);
23119
23358
  }
23120
23359
  this.direction.normalize();
23121
23360
  this.position = new Vector3d(0, 0, 0);
23122
23361
  if (options.position) {
23123
- this.position.set(
23124
- options.position[0],
23125
- options.position[1],
23126
- options.position[2]
23127
- );
23362
+ readVector(this.position, options.position);
23128
23363
  }
23129
23364
  if (options.color instanceof Color) {
23130
23365
  this.color = options.color;
@@ -23505,6 +23740,7 @@ var Frustum = class {
23505
23740
  // src/camera/camera3d.ts
23506
23741
  var AXIS_X = new Vector3d(1, 0, 0);
23507
23742
  var AXIS_Y = new Vector3d(0, 1, 0);
23743
+ var AXIS_Z = new Vector3d(0, 0, 1);
23508
23744
  var _viewMatrix = new Matrix3d();
23509
23745
  var _viewProjection = new Matrix3d();
23510
23746
  var _wsPoint = new Vector3d();
@@ -23610,6 +23846,29 @@ var Camera3d = class extends Camera2d {
23610
23846
  * @default 0
23611
23847
  */
23612
23848
  yaw;
23849
+ /**
23850
+ * Z-axis rotation in radians (bank the horizon). Positive values
23851
+ * roll the camera clockwise, so the world tilts anticlockwise —
23852
+ * the view from a cockpit banking right.
23853
+ *
23854
+ * Completes the `pitch` / `yaw` / `roll` trio. Note this is NOT the
23855
+ * inherited {@link Renderable#rotation}: a 3D camera builds its view
23856
+ * from these three angles and never reads `currentTransform`, which
23857
+ * is why `camera.rotate()` on a `Camera3d` is silently inert. A
23858
+ * `Camera2d` is the other way round — it has no `roll` because a
23859
+ * screen-plane rotation IS its only rotation, and `rotate()` already
23860
+ * does it through `currentTransform`.
23861
+ * @default 0
23862
+ * @example
23863
+ * // bank with the player's steering, as a flight game would
23864
+ * camera.roll = (player.pos.x / PLAY_BOUND_X) * MAX_BANK;
23865
+ */
23866
+ get roll() {
23867
+ return this._roll;
23868
+ }
23869
+ set roll(value) {
23870
+ this._roll = value;
23871
+ }
23613
23872
  /**
23614
23873
  * World-space offset from the followed target. When `target` is
23615
23874
  * set via {@link Camera2d#follow}, the camera position resolves to
@@ -23650,6 +23909,7 @@ var Camera3d = class extends Camera2d {
23650
23909
  });
23651
23910
  this.pitch = 0;
23652
23911
  this.yaw = 0;
23912
+ this.roll = 0;
23653
23913
  this.followOffset = new Vector3d(0, 0, 0);
23654
23914
  this.lookAhead = new Vector3d(0, 0, 1);
23655
23915
  this.near = this.frustum.near;
@@ -23925,6 +24185,7 @@ var Camera3d = class extends Camera2d {
23925
24185
  _basis.identity();
23926
24186
  _basis.rotate(this.yaw, AXIS_Y);
23927
24187
  _basis.rotate(this.pitch, AXIS_X);
24188
+ _basis.rotate(this.roll, AXIS_Z);
23928
24189
  const b = _basis.val;
23929
24190
  state4.heightAxis[0] = k * b[1];
23930
24191
  state4.heightAxis[1] = k * b[5];
@@ -23952,6 +24213,7 @@ var Camera3d = class extends Camera2d {
23952
24213
  _basis.identity();
23953
24214
  _basis.rotate(this.yaw, AXIS_Y);
23954
24215
  _basis.rotate(this.pitch, AXIS_X);
24216
+ _basis.rotate(this.roll, AXIS_Z);
23955
24217
  const v = _basis.val;
23956
24218
  right.set(v[0], v[1], v[2]);
23957
24219
  up.set(v[4], v[5], v[6]);
@@ -24050,6 +24312,9 @@ var Camera3d = class extends Camera2d {
24050
24312
  * @internal
24051
24313
  */
24052
24314
  _applyContainerViewTransform(container, translateX, translateY) {
24315
+ if (this.roll !== 0) {
24316
+ container.rotate(-this.roll, AXIS_Z);
24317
+ }
24053
24318
  if (this.pitch !== 0) {
24054
24319
  container.rotate(-this.pitch, AXIS_X);
24055
24320
  }
@@ -24076,6 +24341,9 @@ var Camera3d = class extends Camera2d {
24076
24341
  if (this.pitch !== 0) {
24077
24342
  container.rotate(this.pitch, AXIS_X);
24078
24343
  }
24344
+ if (this.roll !== 0) {
24345
+ container.rotate(this.roll, AXIS_Z);
24346
+ }
24079
24347
  }
24080
24348
  /**
24081
24349
  * Point the camera at a world-space target by deriving pitch and
@@ -24276,6 +24544,9 @@ var Camera3d = class extends Camera2d {
24276
24544
  */
24277
24545
  worldToScreen(world, out = new Vector2d()) {
24278
24546
  _viewMatrix.identity();
24547
+ if (this.roll !== 0) {
24548
+ _viewMatrix.rotate(-this.roll, AXIS_Z);
24549
+ }
24279
24550
  if (this.pitch !== 0) {
24280
24551
  _viewMatrix.rotate(-this.pitch, AXIS_X);
24281
24552
  }
@@ -24308,6 +24579,9 @@ var Camera3d = class extends Camera2d {
24308
24579
  */
24309
24580
  _rebuildFrustumPlanes() {
24310
24581
  _viewMatrix.identity();
24582
+ if (this.roll !== 0) {
24583
+ _viewMatrix.rotate(-this.roll, AXIS_Z);
24584
+ }
24311
24585
  if (this.pitch !== 0) {
24312
24586
  _viewMatrix.rotate(-this.pitch, AXIS_X);
24313
24587
  }
@@ -24614,38 +24888,44 @@ function buildTextureGroups(groups, materials, shared, framewidth, frameheight)
24614
24888
  }
24615
24889
  return slices.length === 1 && slices[0].texture !== shared ? slices : void 0;
24616
24890
  }
24891
+ function declaredSize(settings) {
24892
+ const width = settings.width;
24893
+ const height = settings.height;
24894
+ if (settings.normalize !== false || typeof settings.scale !== "number" || width > 0 && height > 0) {
24895
+ return [width, height];
24896
+ }
24897
+ const vertices = typeof settings.model === "string" ? getOBJ(settings.model)?.vertices : settings.vertices;
24898
+ if (!vertices || vertices.length < 3) {
24899
+ return [width, height];
24900
+ }
24901
+ let minX = Infinity;
24902
+ let maxX = -Infinity;
24903
+ let minY = Infinity;
24904
+ let maxY = -Infinity;
24905
+ for (let i = 0; i < vertices.length; i += 3) {
24906
+ if (vertices[i] < minX) {
24907
+ minX = vertices[i];
24908
+ }
24909
+ if (vertices[i] > maxX) {
24910
+ maxX = vertices[i];
24911
+ }
24912
+ if (vertices[i + 1] < minY) {
24913
+ minY = vertices[i + 1];
24914
+ }
24915
+ if (vertices[i + 1] > maxY) {
24916
+ maxY = vertices[i + 1];
24917
+ }
24918
+ }
24919
+ return [
24920
+ width > 0 ? width : (maxX - minX) * settings.scale,
24921
+ height > 0 ? height : (maxY - minY) * settings.scale
24922
+ ];
24923
+ }
24617
24924
  var Mesh = class _Mesh extends Renderable {
24618
24925
  /**
24619
24926
  * @param {number} x - the x screen position of the mesh object
24620
24927
  * @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.
24928
+ * @param {MeshSettings} settings - Configuration parameters for the Mesh object
24649
24929
  * @example
24650
24930
  * // create from OBJ + MTL (texture auto-resolved from material)
24651
24931
  * let mesh = new me.Mesh(0, 0, {
@@ -24692,7 +24972,7 @@ var Mesh = class _Mesh extends Renderable {
24692
24972
  * mesh.rotate(Math.PI / 4);
24693
24973
  */
24694
24974
  constructor(x, y, settings) {
24695
- super(x, y, settings.width, settings.height);
24975
+ super(x, y, ...declaredSize(settings));
24696
24976
  let objGroups = null;
24697
24977
  let sourceNormals = settings.normals;
24698
24978
  if (typeof settings.model === "string") {
@@ -25175,6 +25455,66 @@ var Mesh = class _Mesh extends Renderable {
25175
25455
  renderer?.deleteMeshGeometry?.(this);
25176
25456
  super.onDeactivateEvent(...args);
25177
25457
  }
25458
+ /**
25459
+ * The centre of this mesh's own geometry, in MODEL space, written into
25460
+ * `_lcx` / `_lcy` / `_lcz`.
25461
+ *
25462
+ * The transparent pass needs a point that represents where a mesh
25463
+ * actually is. Its origin does not: a mesh whose vertices run away from
25464
+ * the origin — a ground plane authored from one corner, a river that
25465
+ * trails behind the boat it is parented to — sits nowhere near it. Keyed
25466
+ * on that origin, such a mesh sorts by an authoring choice rather than by
25467
+ * its position.
25468
+ *
25469
+ * Cached against `_geometryVersion`, so the vertex walk happens once per
25470
+ * geometry edit rather than once per queued draw. An origin-centred mesh
25471
+ * — which is most of them — resolves to (0, 0, 0) and keys exactly as it
25472
+ * did before.
25473
+ * @ignore
25474
+ * @internal
25475
+ */
25476
+ _updateLocalCenter() {
25477
+ if (this._lcVersion === this._geometryVersion) {
25478
+ return;
25479
+ }
25480
+ const v = this.originalVertices;
25481
+ const n = this.vertexCount;
25482
+ let minX = Infinity;
25483
+ let minY = Infinity;
25484
+ let minZ = Infinity;
25485
+ let maxX = -Infinity;
25486
+ let maxY = -Infinity;
25487
+ let maxZ = -Infinity;
25488
+ if (v !== void 0 && n > 0) {
25489
+ for (let i = 0; i < n; i++) {
25490
+ const x = v[i * 3];
25491
+ const y = v[i * 3 + 1];
25492
+ const z = v[i * 3 + 2];
25493
+ if (x < minX) {
25494
+ minX = x;
25495
+ }
25496
+ if (x > maxX) {
25497
+ maxX = x;
25498
+ }
25499
+ if (y < minY) {
25500
+ minY = y;
25501
+ }
25502
+ if (y > maxY) {
25503
+ maxY = y;
25504
+ }
25505
+ if (z < minZ) {
25506
+ minZ = z;
25507
+ }
25508
+ if (z > maxZ) {
25509
+ maxZ = z;
25510
+ }
25511
+ }
25512
+ }
25513
+ this._lcx = Number.isFinite(minX) ? (minX + maxX) * 0.5 : 0;
25514
+ this._lcy = Number.isFinite(minY) ? (minY + maxY) * 0.5 : 0;
25515
+ this._lcz = Number.isFinite(minZ) ? (minZ + maxZ) * 0.5 : 0;
25516
+ this._lcVersion = this._geometryVersion;
25517
+ }
25178
25518
  /**
25179
25519
  * The mesh's world-space 3D axis-aligned bounding box. This is the 3D analog
25180
25520
  * of {@link Renderable#getBounds} (which returns a flat 2D box from
@@ -25587,10 +25927,7 @@ var InstancedMesh = class extends Mesh {
25587
25927
  /**
25588
25928
  * @param {number} x - the x coordinate of the group origin
25589
25929
  * @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.
25930
+ * @param {InstancedMeshSettings} settings - every {@link Mesh} setting, plus the instancing ones
25594
25931
  */
25595
25932
  constructor(x, y, settings) {
25596
25933
  super(x, y, settings);
@@ -26652,9 +26989,16 @@ var Container = class _Container extends Renderable {
26652
26989
  }
26653
26990
  /**
26654
26991
  * Automatically set the specified property of all childs to the given value
26992
+ *
26993
+ * This ASSIGNS to the children — after it, their own property carries the
26994
+ * value. To fade a subtree without touching what the children are, set
26995
+ * {@link Renderable#alpha} on the container instead: opacity cascades at
26996
+ * draw time and leaves every child's own value alone.
26655
26997
  * @param {string} prop - property name
26656
- * @param {object} value - property value
26998
+ * @param {*} value - property value; whatever the named property holds, so a
26999
+ * flag is a boolean and a colour is a string, not only an object
26657
27000
  * @param {boolean} [recursive=false] - recursively apply the value to child containers if true
27001
+ * @see Renderable#alpha
26658
27002
  */
26659
27003
  setChildsProperty(prop, value, recursive) {
26660
27004
  this.forEach((child) => {
@@ -27053,6 +27397,13 @@ function linearToSrgb8(c) {
27053
27397
 
27054
27398
  // src/level/gltf/GLTFModel.js
27055
27399
  var IDENTITY16 = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
27400
+ var _rootScratch = new Array(16);
27401
+ function partAbsolutePosition() {
27402
+ if (this._absPos === void 0) {
27403
+ this._absPos = vector3dPool.get();
27404
+ }
27405
+ return this._absPos.set(this.pos.x, this.pos.y, this.depth);
27406
+ }
27056
27407
  var _t = [0, 0, 0];
27057
27408
  var _r = [0, 0, 0, 1];
27058
27409
  var _s = [1, 1, 1];
@@ -27060,7 +27411,7 @@ var _val = [0, 0, 0, 0];
27060
27411
  var _localScratch = new Array(16);
27061
27412
  var GLTFModel = class extends Container {
27062
27413
  /**
27063
- * @param {object} data - the parsed glTF descriptor (`{ graph, animations, bounds, ... }`)
27414
+ * @param {import("../../loader/loader.js").GLTFData} data - the parsed glTF descriptor, as returned by {@link loader.getGLTF}
27064
27415
  * @param {object} [options]
27065
27416
  * @param {number} [options.scale=1] - pixels per glTF unit (uniform scene scale)
27066
27417
  * @param {boolean} [options.rightHanded=true] - glTF Y-up → engine Y-down via a rotation (no mirror)
@@ -27073,6 +27424,7 @@ var GLTFModel = class extends Container {
27073
27424
  this.scale = options.scale ?? 1;
27074
27425
  this._zSign = options.rightHanded !== false ? -1 : 1;
27075
27426
  this.autoDepth = false;
27427
+ this.autoTransform = false;
27076
27428
  this.applyAnchorTransform = false;
27077
27429
  this._nodes = data2.graph.nodes;
27078
27430
  this._roots = data2.graph.roots;
@@ -27085,6 +27437,9 @@ var GLTFModel = class extends Container {
27085
27437
  const dz = b.max[2] - b.min[2];
27086
27438
  const radius = Math.hypot(dx, dy, dz) / 2 * this.scale;
27087
27439
  const boxSize = Math.max(radius, 1) * Math.SQRT2;
27440
+ this._extent = Math.max(radius, 1);
27441
+ this._extentX = (b.min[0] + b.max[0]) / 2 * this.scale;
27442
+ this._extentY = -((b.min[1] + b.max[1]) / 2) * this.scale;
27088
27443
  const lit = options.lit === true;
27089
27444
  const rightHanded = options.rightHanded !== false;
27090
27445
  const castGroundShadow = typeof options.castGroundShadow === "boolean" ? options.castGroundShadow : void 0;
@@ -27142,6 +27497,7 @@ var GLTFModel = class extends Container {
27142
27497
  mesh.vertexColors = prim.colors;
27143
27498
  }
27144
27499
  mesh.name = node.name;
27500
+ mesh.getAbsolutePosition = partAbsolutePosition;
27145
27501
  (this._meshByNode[idx] ??= []).push(mesh);
27146
27502
  this.addChild(mesh);
27147
27503
  }
@@ -27192,7 +27548,7 @@ var GLTFModel = class extends Container {
27192
27548
  * another clip when this one ends, a `function` legacy completion callback
27193
27549
  * (return `false` to hold the final pose), or an options object.
27194
27550
  * @param {string} name - animation clip id (see {@link GLTFModel#getAnimationNames})
27195
- * @param {string|Function|AnimationOptions} [options] - loop / chain / completion behavior
27551
+ * @param {AnimationOptionsInput} [options] - loop / chain / completion behavior
27196
27552
  * @param {boolean} [preserveTime=false] - keep the current playback time instead of restarting at 0
27197
27553
  * @returns {GLTFModel} this, for chaining
27198
27554
  * @example
@@ -27259,7 +27615,7 @@ var GLTFModel = class extends Container {
27259
27615
  * (and start) it, or with no argument to resume after {@link GLTFModel#pause}.
27260
27616
  * Always clears the paused state.
27261
27617
  * @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})
27618
+ * @param {AnimationOptionsInput} [options] - loop / chain / completion behavior (see {@link GLTFModel#setCurrentAnimation})
27263
27619
  * @returns {GLTFModel} this, for chaining
27264
27620
  * @example
27265
27621
  * model.play("walk"); // switch to + play "walk"
@@ -27362,6 +27718,52 @@ var GLTFModel = class extends Container {
27362
27718
  _isLive() {
27363
27719
  return this.pos !== void 0;
27364
27720
  }
27721
+ /**
27722
+ * {@link Container#draw} translates the renderer by `pos` so children draw
27723
+ * in container-local space. This rig's children are already in world space,
27724
+ * so that translation is undone here rather than doubling the placement.
27725
+ * Paired with `autoTransform = false`, which suppresses the matching
27726
+ * rotation fold.
27727
+ * @param {CanvasRenderer|WebGLRenderer} renderer - a renderer instance
27728
+ * @param {Camera2d} [viewport] - the camera rendering this frame
27729
+ */
27730
+ draw(renderer, viewport) {
27731
+ renderer.translate(-this.pos.x, -this.pos.y);
27732
+ super.draw(renderer, viewport);
27733
+ }
27734
+ /**
27735
+ * Where this model is, as a 2D box — what culling, picking and the physics
27736
+ * broadphase all read.
27737
+ *
27738
+ * A `Container` has no dimensions of its own, and one left that way reports
27739
+ * an EMPTY bounds: a model claiming to be nowhere, which the broadphase
27740
+ * cannot place. A rig's size is known at load from the glTF scene AABB, so
27741
+ * this is a placement of a measurement taken once in the constructor rather
27742
+ * than a walk of the parts — `Container#updateBounds` with
27743
+ * `enableChildBoundsUpdate` would re-measure the whole rig on every call,
27744
+ * and that flag is for a group whose extent really is its members' union.
27745
+ *
27746
+ * The box is the model's bounding SPHERE squared off, so a turned model
27747
+ * still reports something that contains it. Generous bounds only cost
27748
+ * broadphase pruning; tight ones lose contacts.
27749
+ * @param {boolean} [absolute=true] - in world rather than local coordinates
27750
+ * @returns {Bounds} this model's bounding box
27751
+ */
27752
+ updateBounds(absolute = true) {
27753
+ const bounds = this.getBounds();
27754
+ const e = this._extent;
27755
+ bounds.setMinMax(
27756
+ this._extentX - e,
27757
+ this._extentY - e,
27758
+ this._extentX + e,
27759
+ this._extentY + e
27760
+ );
27761
+ if (absolute === true) {
27762
+ const absPos = this.getAbsolutePosition();
27763
+ bounds.translate(absPos.x, absPos.y);
27764
+ }
27765
+ return bounds;
27766
+ }
27365
27767
  /**
27366
27768
  * @ignore
27367
27769
  * @internal
@@ -27369,10 +27771,48 @@ var GLTFModel = class extends Container {
27369
27771
  _pose() {
27370
27772
  const clip = this.current.name ? this.anim[this.current.name] : null;
27371
27773
  const t = this.current.time;
27774
+ const parent = this._rootMatrix();
27372
27775
  for (const root of this._roots) {
27373
- this._visit(root, IDENTITY16, clip, t);
27776
+ this._visit(root, parent, clip, t);
27374
27777
  }
27375
27778
  }
27779
+ /**
27780
+ * The model's own placement, as the parent transform every root node hangs
27781
+ * from — so `pos`, `depth` and `currentTransform` move, turn and scale the
27782
+ * whole rig the way they do for any other renderable.
27783
+ *
27784
+ * It has to be handed to the DFS in **glTF space**, because that is where
27785
+ * the node matrices live and `_applyWorldToMesh` only bridges to engine
27786
+ * space afterwards. The bridge is `D = diag(1, -1, zSign)` and a uniform
27787
+ * `scale`, so the inverse trip is a division by `scale` for the translation
27788
+ * and a conjugation `D · R · D` for the rotation/scale — `D` is its own
27789
+ * inverse, which is what makes the conjugation this cheap.
27790
+ * @returns {number[]} 16-element column-major matrix
27791
+ * @ignore
27792
+ * @internal
27793
+ */
27794
+ _rootMatrix() {
27795
+ const { x, y, z } = this.pos;
27796
+ const t = this.currentTransform.val;
27797
+ const s = this.scale;
27798
+ if (x === 0 && y === 0 && z === 0 && this.currentTransform.isIdentity()) {
27799
+ return IDENTITY16;
27800
+ }
27801
+ const out = _rootScratch;
27802
+ const zs = this._zSign;
27803
+ for (let col = 0; col < 3; col++) {
27804
+ const dc = col === 0 ? 1 : col === 1 ? -1 : zs;
27805
+ out[col * 4] = t[col * 4] * dc;
27806
+ out[col * 4 + 1] = -t[col * 4 + 1] * dc;
27807
+ out[col * 4 + 2] = zs * t[col * 4 + 2] * dc;
27808
+ out[col * 4 + 3] = 0;
27809
+ }
27810
+ out[12] = x / s;
27811
+ out[13] = -y / s;
27812
+ out[14] = zs * z / s;
27813
+ out[15] = 1;
27814
+ return out;
27815
+ }
27376
27816
  /**
27377
27817
  * DFS one node: compose its local matrix, multiply by the parent world,
27378
27818
  * apply to its meshes, recurse into children.
@@ -30203,13 +30643,47 @@ function createShapeObject(settings) {
30203
30643
  }
30204
30644
 
30205
30645
  // src/renderable/text/textstyle.js
30646
+ var VERTICAL_TOLERANCE = 0.1;
30647
+ function resolveFill(context, style) {
30648
+ const gradient = style.fillGradient;
30649
+ if (gradient === void 0) {
30650
+ return style.fillStyle.toRGBA();
30651
+ }
30652
+ const lines = style._text?.length ?? 1;
30653
+ if (style.gradientPerLine !== true || lines <= 1 || gradient.type !== "linear") {
30654
+ return gradient.toCanvasGradient(context);
30655
+ }
30656
+ const [x0, y0, x1, y1] = gradient.coords;
30657
+ const span = y1 - y0;
30658
+ if (span === 0 || Math.abs(x1 - x0) > Math.abs(span) * VERTICAL_TOLERANCE) {
30659
+ return gradient.toCanvasGradient(context);
30660
+ }
30661
+ const lineHeight = style.metrics.lineHeight();
30662
+ const blockHeight = lines * lineHeight;
30663
+ if (blockHeight <= 0) {
30664
+ return gradient.toCanvasGradient(context);
30665
+ }
30666
+ const repeated = context.createLinearGradient(x0, 0, x1, blockHeight);
30667
+ for (let line = 0; line < lines; line++) {
30668
+ for (const stop2 of gradient.colorStops) {
30669
+ const y = line * lineHeight + y0 + stop2.offset * span;
30670
+ repeated.addColorStop(
30671
+ Math.min(1, Math.max(0, y / blockHeight)),
30672
+ stop2.color
30673
+ );
30674
+ }
30675
+ }
30676
+ return repeated;
30677
+ }
30206
30678
  function setContextStyle(context, style) {
30679
+ const fill = resolveFill(context, style);
30207
30680
  context.font = style.font;
30208
- context.fillStyle = style.fillStyle.toRGBA();
30681
+ context.fillStyle = fill;
30209
30682
  context.strokeStyle = style.strokeStyle.toRGBA();
30210
30683
  context.lineWidth = style.lineWidth;
30211
30684
  context.textAlign = style.textAlign;
30212
30685
  context.textBaseline = style.textBaseline;
30686
+ return fill;
30213
30687
  }
30214
30688
 
30215
30689
  // src/renderable/text/text.js
@@ -30237,7 +30711,8 @@ var Text = class extends Renderable {
30237
30711
  * @param {object} settings - the text configuration
30238
30712
  * @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
30713
  * @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
30714
+ * @param {Color|Gradient|string} [settings.fillStyle="#000000"] - a CSS color value used to fill the glyphs, or a {@link Gradient} to ramp them
30715
+ * @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
30716
  * @param {Color|string} [settings.strokeStyle="#000000"] - a CSS color value used for the glyph outline (drawn when `lineWidth` > 0)
30242
30717
  * @param {number} [settings.lineWidth=0] - outline width in pixels (0 = no stroke)
30243
30718
  * @param {string} [settings.textAlign="left"] - horizontal text alignment ("left", "center", "right")
@@ -30245,6 +30720,8 @@ var Text = class extends Renderable {
30245
30720
  * @param {number} [settings.lineHeight=1.0] - line spacing height
30246
30721
  * @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
30722
  * @param {number} [settings.wordWrapWidth] - the maximum length in CSS pixels of a line before it wraps
30723
+ * @param {boolean} [settings.bold=false] - render the face bold, as {@link Text#bold} does
30724
+ * @param {boolean} [settings.italic=false] - render the face italic, as {@link Text#italic} does
30248
30725
  * @param {(string|string[])} [settings.text=""] - a string, or an array of strings
30249
30726
  * @example
30250
30727
  * // a styled, word-wrapped, multi-line label using a generic system font
@@ -30263,6 +30740,23 @@ var Text = class extends Renderable {
30263
30740
  * label.setOpacity(0.8); // per-object transparency
30264
30741
  * app.world.addChild(label);
30265
30742
  * @example
30743
+ * // a gradient fill: `fillStyle` takes a Gradient as well as a colour.
30744
+ * // Its coordinates are the label's OWN bake, so (0, 0) is the top-left of
30745
+ * // the render box and the ramp below runs down exactly one line.
30746
+ * const ramp = app.renderer.createLinearGradient(0, 0, 0, 32);
30747
+ * ramp.addColorStop(0, "#fffdf0");
30748
+ * ramp.addColorStop(1, "#ffa71d");
30749
+ *
30750
+ * app.world.addChild(new Text(8, 8, {
30751
+ * font: "sans-serif",
30752
+ * size: 32,
30753
+ * fillStyle: ramp, // ramps the glyphs...
30754
+ * strokeStyle: "#000000", // ...while the outline keeps its own colour
30755
+ * lineWidth: 1,
30756
+ * text: "GAME\nOVER", // every line restarts the ramp by default
30757
+ * // gradientPerLine: false, // ...or span ONE ramp across both lines
30758
+ * }));
30759
+ * @example
30266
30760
  * // a web font (loaded via the fontface loader) is used by its family name
30267
30761
  * loader.preload(
30268
30762
  * [{ name: "kenpixel", type: "fontface", src: "data/font/kenvector.woff2" }],
@@ -30294,11 +30788,16 @@ var Text = class extends Renderable {
30294
30788
  if (typeof this.fillStyle === "undefined") {
30295
30789
  this.fillStyle = colorPool.get(0, 0, 0);
30296
30790
  }
30791
+ this.fillGradient = void 0;
30792
+ this.gradientPerLine = true;
30297
30793
  if (typeof this.strokeStyle === "undefined") {
30298
30794
  this.strokeStyle = colorPool.get(0, 0, 0);
30299
30795
  }
30796
+ this.fillGradient = void 0;
30300
30797
  if (typeof settings.fillStyle !== "undefined") {
30301
- if (settings.fillStyle instanceof Color) {
30798
+ if (settings.fillStyle instanceof Gradient) {
30799
+ this.fillGradient = settings.fillStyle;
30800
+ } else if (settings.fillStyle instanceof Color) {
30302
30801
  this.fillStyle.copy(settings.fillStyle);
30303
30802
  } else {
30304
30803
  this.fillStyle.parseCSS(settings.fillStyle);
@@ -30311,6 +30810,9 @@ var Text = class extends Renderable {
30311
30810
  this.strokeStyle.parseCSS(settings.strokeStyle);
30312
30811
  }
30313
30812
  }
30813
+ if (typeof settings.gradientPerLine === "boolean") {
30814
+ this.gradientPerLine = settings.gradientPerLine;
30815
+ }
30314
30816
  this.lineWidth = settings.lineWidth || 0;
30315
30817
  this.textAlign = settings.textAlign || "left";
30316
30818
  this.textBaseline = settings.textBaseline || "top";
@@ -30425,9 +30927,10 @@ var Text = class extends Renderable {
30425
30927
  true
30426
30928
  );
30427
30929
  const width = Math.ceil(this.metrics.width / 32) * 32;
30428
- const height = Math.ceil(this.metrics.height / 32) * 32;
30930
+ const height = Math.ceil(
30931
+ (this.metrics.height + this.metrics.inkPadTop + this.metrics.inkPadBottom) / 32
30932
+ ) * 32;
30429
30933
  const renderer = this.parentApp?.renderer ?? game.renderer;
30430
- this.canvasTexture.invalidate(renderer);
30431
30934
  if (this.canvasTexture.width < width || this.canvasTexture.height < height) {
30432
30935
  this.canvasTexture.resize(width, height);
30433
30936
  }
@@ -30438,6 +30941,7 @@ var Text = class extends Renderable {
30438
30941
  this.pos.x - this.metrics.x,
30439
30942
  this.pos.y - this.metrics.y
30440
30943
  );
30944
+ this.canvasTexture.invalidate(renderer);
30441
30945
  this.isDirty = true;
30442
30946
  return this;
30443
30947
  }
@@ -30464,9 +30968,18 @@ var Text = class extends Renderable {
30464
30968
  }
30465
30969
  /**
30466
30970
  * the ratio of visible characters (0.0 to 1.0).
30467
- * Setting this automatically updates {@link visibleCharacters}.
30971
+ * Setting this automatically updates {@link Text#visibleCharacters}.
30972
+ *
30973
+ * This is the one to tween: it is independent of how many characters the
30974
+ * label holds, so a reveal takes the same time whatever the string is.
30468
30975
  * @public
30469
30976
  * @type {number}
30977
+ * @default 1.0
30978
+ * @see Text#visibleCharacters
30979
+ * @example
30980
+ * // reveal over two seconds, regardless of length
30981
+ * label.visibleRatio = 0;
30982
+ * new Tween(label).to({ visibleRatio: 1.0 }, { duration: 2000 }).start();
30470
30983
  */
30471
30984
  get visibleRatio() {
30472
30985
  if (this._visibleCharacters === -1) {
@@ -30535,6 +31048,14 @@ var Text = class extends Renderable {
30535
31048
  * measure the given text size in pixels
30536
31049
  * @param {string} [text] - the text to be measured
30537
31050
  * @returns {TextMetrics} a TextMetrics object defining the dimensions of the given piece of text
31051
+ * @example
31052
+ * // size a panel around a label
31053
+ * const size = label.measureText();
31054
+ * panel.resize(size.width + 16, size.height + 16);
31055
+ *
31056
+ * // or measure a string the label does not currently hold, to reserve
31057
+ * // room for the widest state a counter will reach
31058
+ * const widest = label.measureText("00:00").width;
30538
31059
  */
30539
31060
  measureText(text = this._text) {
30540
31061
  return this.metrics.measureText(text, this.canvasTexture.context);
@@ -30544,8 +31065,8 @@ var Text = class extends Renderable {
30544
31065
  * @param {CanvasRenderer|WebGLRenderer} renderer - Reference to the destination renderer instance
30545
31066
  */
30546
31067
  draw(renderer) {
31068
+ this.metrics.updateOrigin();
30547
31069
  if (this.isDirty) {
30548
- this.canvasTexture.invalidate(renderer);
30549
31070
  this.canvasTexture.clear();
30550
31071
  this._drawFont(
30551
31072
  this.canvasTexture.context,
@@ -30553,9 +31074,10 @@ var Text = class extends Renderable {
30553
31074
  this.pos.x - this.metrics.x,
30554
31075
  this.pos.y - this.metrics.y
30555
31076
  );
31077
+ this.canvasTexture.invalidate(renderer);
30556
31078
  }
30557
31079
  let x = this.metrics.x;
30558
- let y = this.metrics.y;
31080
+ let y = this.metrics.y - this.metrics.inkPadTop;
30559
31081
  if (renderer.settings.subPixel === false) {
30560
31082
  x = ~~x;
30561
31083
  y = ~~y;
@@ -30567,25 +31089,54 @@ var Text = class extends Renderable {
30567
31089
  * @internal
30568
31090
  */
30569
31091
  _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;
31092
+ context.save();
31093
+ context.translate(0, this.metrics.inkPadTop);
31094
+ const fill = setContextStyle(context, this);
31095
+ const fills = this.fillStyle.alpha > 0;
31096
+ const strokes = this.lineWidth > 0 && this.strokeStyle.alpha > 0;
31097
+ const masked = fills && this.fillGradient !== void 0;
31098
+ const eachLine = (paint) => {
31099
+ let remaining = this.visibleCharacters;
31100
+ let lineY = y;
31101
+ for (let i = 0; i < text.length; i++) {
31102
+ let string = text[i].trimEnd();
31103
+ if (remaining !== -1) {
31104
+ if (remaining <= 0) {
31105
+ break;
31106
+ }
31107
+ string = string.substring(0, remaining);
31108
+ remaining -= string.length;
30577
31109
  }
30578
- string = string.substring(0, remaining);
30579
- remaining -= string.length;
30580
- }
30581
- if (this.fillStyle.alpha > 0) {
30582
- context.fillText(string, x, y);
31110
+ paint(string, x, lineY);
31111
+ lineY += this.metrics.lineHeight();
30583
31112
  }
30584
- if (this.lineWidth > 0 && this.strokeStyle.alpha > 0) {
30585
- context.strokeText(string, x, y);
31113
+ };
31114
+ if (fills) {
31115
+ if (masked === true) {
31116
+ context.fillStyle = "#000000";
30586
31117
  }
30587
- y += this.metrics.lineHeight();
31118
+ eachLine((line, lineX, lineY) => {
31119
+ context.fillText(line, lineX, lineY);
31120
+ });
30588
31121
  }
31122
+ if (masked === true) {
31123
+ const canvas = context.canvas;
31124
+ context.globalCompositeOperation = "source-in";
31125
+ context.fillStyle = fill;
31126
+ context.fillRect(
31127
+ -1,
31128
+ -this.metrics.inkPadTop - 1,
31129
+ canvas.width + 2,
31130
+ canvas.height + 2
31131
+ );
31132
+ context.globalCompositeOperation = "source-over";
31133
+ }
31134
+ if (strokes) {
31135
+ eachLine((line, lineX, lineY) => {
31136
+ context.strokeText(line, lineX, lineY);
31137
+ });
31138
+ }
31139
+ context.restore();
30589
31140
  return this.metrics;
30590
31141
  }
30591
31142
  /**
@@ -30597,6 +31148,7 @@ var Text = class extends Renderable {
30597
31148
  const renderer = this.parentApp?.renderer ?? game.renderer;
30598
31149
  this.canvasTexture.destroy(renderer);
30599
31150
  this.canvasTexture = void 0;
31151
+ this.fillGradient = void 0;
30600
31152
  colorPool.release(this.fillStyle);
30601
31153
  colorPool.release(this.strokeStyle);
30602
31154
  this.fillStyle = this.strokeStyle = void 0;
@@ -30607,6 +31159,8 @@ var Text = class extends Renderable {
30607
31159
  };
30608
31160
 
30609
31161
  // src/renderable/text/textmetrics.js
31162
+ var INK_SLACK = 1;
31163
+ var INK_GUESS = 0.25;
30610
31164
  var TextMetrics = class extends Bounds {
30611
31165
  /**
30612
31166
  * @param {Text|BitmapText} ancestor - the parent object that contains this TextMetrics object
@@ -30690,17 +31244,55 @@ var TextMetrics = class extends Bounds {
30690
31244
  this.glyphYOffset = fontData.glyphMinTop * scaleY;
30691
31245
  this.glyphMaxBottom = fontData.glyphMaxBottom * scaleY;
30692
31246
  }
31247
+ this.inkPadTop = 0;
31248
+ this.inkPadBottom = 0;
31249
+ if (!isBitmapText && strings.length > 0 && typeof context !== "undefined") {
31250
+ const style = this.ancestor;
31251
+ const stroke = style.lineWidth > 0 && style.strokeStyle.alpha > 0 ? style.lineWidth / 2 : 0;
31252
+ const first = context.measureText(strings[0].trimEnd());
31253
+ const last = strings.length === 1 ? first : context.measureText(strings[strings.length - 1].trimEnd());
31254
+ const above = first.actualBoundingBoxAscent;
31255
+ const below = last.actualBoundingBoxDescent;
31256
+ const measured = Number.isFinite(above) && Number.isFinite(below);
31257
+ const top = measured ? above : style.fontSize * INK_GUESS;
31258
+ const bottom = measured ? below : style.fontSize * INK_GUESS;
31259
+ this.inkPadTop = Math.max(0, Math.ceil(top + stroke) + INK_SLACK);
31260
+ this.inkPadBottom = Math.max(
31261
+ 0,
31262
+ Math.ceil(this.inkPadTop + bottom + stroke - this.lineHeight()) + INK_SLACK
31263
+ );
31264
+ }
30693
31265
  this.width = Math.ceil(this.width);
30694
31266
  this.height = Math.ceil(this.height);
31267
+ this.updateOrigin();
31268
+ if (typeof context !== "undefined") {
31269
+ context.restore();
31270
+ }
31271
+ return this;
31272
+ }
31273
+ /**
31274
+ * Recompute the box's position from the ancestor's CURRENT `pos`.
31275
+ *
31276
+ * Split out of `measureText` because it depends on `pos` while everything
31277
+ * else there depends only on the string and the font. A label that moves
31278
+ * needs this refreshed — and nothing else — so the draw path can call it
31279
+ * per frame without re-measuring a single glyph.
31280
+ *
31281
+ * The `Math.floor` is what puts the canvas on a whole pixel. The remainder
31282
+ * it drops is not lost: `Text` bakes its glyphs at `pos - metrics`, so the
31283
+ * sub-pixel part lands in the rasterization, where the font rasterizer can
31284
+ * antialias it, instead of resampling the whole texture.
31285
+ * @returns {TextMetrics} this instance for chaining
31286
+ * @ignore
31287
+ * @internal
31288
+ */
31289
+ updateOrigin() {
30695
31290
  this.x = Math.floor(
30696
31291
  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
31292
  );
30698
31293
  this.y = Math.floor(
30699
31294
  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
31295
  );
30701
- if (typeof context !== "undefined") {
30702
- context.restore();
30703
- }
30704
31296
  return this;
30705
31297
  }
30706
31298
  /**
@@ -30748,9 +31340,8 @@ var BitmapText = class extends Renderable {
30748
31340
  * @param {object} settings - the text configuration
30749
31341
  * @param {string|Image} settings.font - a font name to identify the corresponding source image
30750
31342
  * @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
31343
+ * @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.)
31344
+ * @param {Color|string} [settings.fillStyle] - a CSS color value used to tint the glyphs, see {@link Renderable#tint}
30754
31345
  * @param {string} [settings.textAlign="left"] - horizontal text alignment
30755
31346
  * @param {string} [settings.textBaseline="top"] - the text baseline
30756
31347
  * @param {number} [settings.lineHeight=1.0] - line spacing height
@@ -30816,7 +31407,7 @@ var BitmapText = class extends Renderable {
30816
31407
  /**
30817
31408
  * change the font settings
30818
31409
  * @param {string} textAlign - ("left", "center", "right")
30819
- * @param {number} [scale]
31410
+ * @param {number} [scale] - a scaling ratio, applied through {@link BitmapText#resize} when given
30820
31411
  * @returns {BitmapText} this object for chaining
30821
31412
  */
30822
31413
  set(textAlign, scale2) {
@@ -30941,10 +31532,27 @@ var BitmapText = class extends Renderable {
30941
31532
  return bounds;
30942
31533
  }
30943
31534
  /**
30944
- * defines the color used to tint the bitmap text
31535
+ * defines the color used to tint the bitmap text.
31536
+ *
31537
+ * This is {@link Renderable#tint} under another name, so the same rule
31538
+ * applies: white — `(255, 255, 255)` — is the absence of a tint, and any
31539
+ * other colour tints away from there. A page image authored in white
31540
+ * therefore keeps every colour available to it.
30945
31541
  * @public
30946
31542
  * @type {Color}
30947
31543
  * @see Renderable#tint
31544
+ * @example
31545
+ * // tint at construction...
31546
+ * const score = new BitmapText(8, 8, {
31547
+ * font: "arial",
31548
+ * text: "1000",
31549
+ * fillStyle: "#ffd700", // gold
31550
+ * });
31551
+ * app.world.addChild(score);
31552
+ *
31553
+ * // ...or at any point after it, from a CSS string or a Color
31554
+ * score.fillStyle = "#ff4040"; // flash red on damage
31555
+ * score.fillStyle = new Color(255, 255, 255); // back to untinted
30948
31556
  */
30949
31557
  get fillStyle() {
30950
31558
  return this.tint;
@@ -30958,8 +31566,14 @@ var BitmapText = class extends Renderable {
30958
31566
  }
30959
31567
  /**
30960
31568
  * change the font display size
30961
- * @param {number} scale - ratio
31569
+ * @param {number} scale - a ratio against the font's authored size, NOT a
31570
+ * pixel size: `1` is the page image at its native scale, `2` is double
30962
31571
  * @returns {BitmapText} this object for chaining
31572
+ * @example
31573
+ * // a bitmap font is pixel art — whole-number ratios stay crisp, and
31574
+ * // fractional ones resample the page image
31575
+ * title.resize(3); // three times its authored size
31576
+ * title.set("center", 2); // align and rescale in one call
30963
31577
  */
30964
31578
  resize(scale2) {
30965
31579
  this.fontScale.set(scale2, scale2);
@@ -30972,6 +31586,10 @@ var BitmapText = class extends Renderable {
30972
31586
  * measure the given text size in pixels
30973
31587
  * @param {string} [text]
30974
31588
  * @returns {TextMetrics} a TextMetrics object with two properties: `width` and `height`, defining the output dimensions
31589
+ * @example
31590
+ * // size a panel around a label, at the label's CURRENT scale
31591
+ * const size = label.measureText();
31592
+ * panel.resize(size.width + 16, size.height + 16);
30975
31593
  */
30976
31594
  measureText(text = this._text) {
30977
31595
  return this.metrics.measureText(text);
@@ -40544,7 +41162,7 @@ function writeLight3dBlock(out, packed) {
40544
41162
  }
40545
41163
 
40546
41164
  // 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";
41165
+ 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
41166
 
40549
41167
  // src/video/webgl/shaders/mesh-lit.vert
40550
41168
  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 +41362,7 @@ function vertexDataExceedsUint16(indices) {
40744
41362
  }
40745
41363
 
40746
41364
  // 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";
41365
+ 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
41366
 
40749
41367
  // src/video/webgl/shaders/mesh.vert
40750
41368
  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 +41932,99 @@ var MeshBatcher = class extends MaterialBatcher {
41314
41932
  fragmentDefines
41315
41933
  );
41316
41934
  }
41935
+ /**
41936
+ * The program that hosts a `ShaderEffect` on a mesh (#1658).
41937
+ *
41938
+ * A ShaderEffect is realized against the QUAD contract — its vertex stage
41939
+ * projects straight from `uProjectionMatrix` and declares neither
41940
+ * `uModelMatrix` nor `uViewMatrix` — so it cannot be bound to a mesh as-is:
41941
+ * the geometry arrives in model space and would draw unplaced and without
41942
+ * the camera (the very case `validateShaderLocations` warns about). Instead
41943
+ * the body is spliced into THIS batcher's own mesh shader, so the mesh keeps
41944
+ * its placement, alpha cutout, lighting and fog and the effect becomes a
41945
+ * colour hook over the result.
41946
+ *
41947
+ * Keyed through the same variant map as every other permutation, so it is
41948
+ * released on context loss and destroy along with the rest — and keyed on
41949
+ * fog too, since the spliced source inherits the fog `#define`.
41950
+ * @param {object} effect - the hosted ShaderEffect
41951
+ * @returns {GLShader} the spliced program
41952
+ * @ignore
41953
+ * @internal
41954
+ */
41955
+ effectShaderFor(effect) {
41956
+ const fog = this._fogDefine();
41957
+ const sources = this._shaderSources();
41958
+ const fragment2 = spliceEffect(sources.fragment, effect._meshBody);
41959
+ if (fragment2 === null) {
41960
+ return null;
41961
+ }
41962
+ const compat = /^\s*#version\s+300\s+es/m.test(sources.fragment) ? "#define texture2D texture\n" : "";
41963
+ try {
41964
+ return this.shaderVariant(
41965
+ `effect|${effect._effectId}|${fog !== "" ? 1 : 0}`,
41966
+ { vertex: sources.vertex, fragment: fragment2 },
41967
+ fog,
41968
+ fog + compat
41969
+ );
41970
+ } catch (error) {
41971
+ if (this._meshEffectCompileWarned !== true) {
41972
+ this._meshEffectCompileWarned = true;
41973
+ console.warn(
41974
+ `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.`
41975
+ );
41976
+ }
41977
+ return null;
41978
+ }
41979
+ }
41980
+ /**
41981
+ * Bring a spliced program up to date with the effect's uniforms.
41982
+ *
41983
+ * `ShaderEffect.setUniform` writes into the effect's OWN quad program;
41984
+ * this one is a different program, so every value the effect has ever been
41985
+ * given is replayed onto it. Unknown names are skipped rather than passed
41986
+ * on: `GLShader.setUniform` throws on one, and a body is free to declare
41987
+ * uniforms the spliced mesh source optimised away.
41988
+ * @param {object} effect - the hosted ShaderEffect
41989
+ * @param {GLShader} shader - the spliced program
41990
+ * @ignore
41991
+ * @internal
41992
+ */
41993
+ replayEffectUniforms(effect, shader) {
41994
+ for (const [name, value] of effect._uniformValues) {
41995
+ if (shader.uniforms[name] !== void 0) {
41996
+ shader.setUniform(name, value);
41997
+ }
41998
+ }
41999
+ }
42000
+ /**
42001
+ * Whether this batcher can host `effect`, warning once when it cannot.
42002
+ * @param {object} effect - the candidate ShaderEffect
42003
+ * @returns {boolean} true when the effect can be spliced in
42004
+ * @ignore
42005
+ * @internal
42006
+ */
42007
+ canHostEffect(effect) {
42008
+ if (typeof effect._meshBody !== "string") {
42009
+ return false;
42010
+ }
42011
+ if (!hasSpliceMarkers(this._shaderSources().fragment)) {
42012
+ return false;
42013
+ }
42014
+ const blocker = meshHostingBlocker(effect._meshBuiltins, [
42015
+ ...effect._extraTextures?.keys() ?? []
42016
+ ]);
42017
+ if (blocker === null) {
42018
+ return true;
42019
+ }
42020
+ if (this._meshEffectBlockedWarned !== true) {
42021
+ this._meshEffectBlockedWarned = true;
42022
+ console.warn(
42023
+ `melonJS: this ShaderEffect cannot be hosted on a Mesh \u2014 ${blocker}. The mesh draws with the built-in shading.`
42024
+ );
42025
+ }
42026
+ return false;
42027
+ }
41317
42028
  /**
41318
42029
  * `"#define FOG\n"` while the camera drawing has fog, `""` otherwise.
41319
42030
  *
@@ -44841,8 +45552,14 @@ var WebGLRenderer = class _WebGLRenderer extends Renderer {
44841
45552
  return;
44842
45553
  }
44843
45554
  this.setBatcher(mesh.lit === true ? "litMesh" : "mesh");
44844
- const hostedShader = this.customShader != null && this.customShader.program != null;
45555
+ const effectHosted = this.customShader != null && this.currentBatcher.canHostEffect?.(this.customShader) === true;
45556
+ const spliced = effectHosted ? this.currentBatcher.effectShaderFor(this.customShader) : null;
45557
+ let hostedShader = spliced !== null && spliced !== void 0;
44845
45558
  if (hostedShader) {
45559
+ this.currentBatcher.useShader(spliced);
45560
+ this.currentBatcher.replayEffectUniforms(this.customShader, spliced);
45561
+ } else if (this.customShader != null && this.customShader.program != null) {
45562
+ hostedShader = true;
44846
45563
  this.currentBatcher.useShader(this.customShader);
44847
45564
  } else if (this.customShader != null && this.customShader.isWebGL !== true && this._meshShaderWarned !== true) {
44848
45565
  this._meshShaderWarned = true;
@@ -48408,7 +49125,7 @@ function _computeShapeAbsPos(renderable, shape, outPos) {
48408
49125
  var _shapeAbs = new Vector2d(0, 0);
48409
49126
  function _raycastPolygon(fromX, fromY, dx, dy, shape, shapeAbsX, shapeAbsY) {
48410
49127
  const points = shape.points;
48411
- const normals = shape.normals;
49128
+ const normals = shape.edgeNormals;
48412
49129
  const len = points.length;
48413
49130
  let bestT = Infinity;
48414
49131
  let bestNormalIdx = -1;
@@ -48649,10 +49366,10 @@ function vornoiRegion(line, point) {
48649
49366
  }
48650
49367
  function testPolygonPolygon(a, polyA, b, polyB, response) {
48651
49368
  const aPoints = polyA.points;
48652
- const aNormals = polyA.normals;
49369
+ const aNormals = polyA.edgeNormals;
48653
49370
  const aLen = aNormals.length;
48654
49371
  const bPoints = polyB.points;
48655
- const bNormals = polyB.normals;
49372
+ const bNormals = polyB.edgeNormals;
48656
49373
  const bLen = bNormals.length;
48657
49374
  const posA = T_VECTORS[--T_VECTORS_IDX].copy(a.pos).add(a.ancestor.getAbsolutePosition()).add(polyA.pos);
48658
49375
  const posB = T_VECTORS[--T_VECTORS_IDX].copy(b.pos).add(b.ancestor.getAbsolutePosition()).add(polyB.pos);
@@ -48792,7 +49509,7 @@ function testPolygonEllipse(a, polyA, b, ellipseB, response) {
48792
49509
  }
48793
49510
  }
48794
49511
  } else {
48795
- normal.copy(polyA.normals[i]);
49512
+ normal.copy(polyA.edgeNormals[i]);
48796
49513
  dist = point.dot(normal);
48797
49514
  const distAbs = Math.abs(dist);
48798
49515
  if ((len === 1 || dist > 0) && distAbs > radius) {
@@ -51772,7 +52489,7 @@ var Trigger = class extends Renderable {
51772
52489
  };
51773
52490
 
51774
52491
  // src/version.ts
51775
- var version = "20.4.0";
52492
+ var version = "20.5.0";
51776
52493
 
51777
52494
  // src/system/bootstrap.ts
51778
52495
  var initialized = false;
@@ -52222,7 +52939,7 @@ var LIT_INSTANCED = {
52222
52939
  };
52223
52940
 
52224
52941
  // 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";
52942
+ 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
52943
 
52227
52944
  // src/video/webgpu/buffer/instance_buffer.js
52228
52945
  var WebGPUInstanceBuffer = class {
@@ -52416,7 +53133,7 @@ var WebGPURetainedGeometry = class {
52416
53133
  };
52417
53134
 
52418
53135
  // 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";
53136
+ 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
53137
 
52421
53138
  // src/video/webgpu/shaders/mesh-shadow-instanced.wgsl
52422
53139
  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 +53313,13 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
52596
53313
  }
52597
53314
  this.vertexLayoutKey = vertexLayoutKey;
52598
53315
  this.customShader = null;
53316
+ this.hostedEffect = null;
52599
53317
  this.indexData = new Uint32Array(this.vertexData.maxVertex * 6);
52600
53318
  this.indexCount = 0;
52601
53319
  this.currentMaterial = null;
52602
53320
  this.uniformBinding = null;
52603
53321
  this.uniformBindGroups = /* @__PURE__ */ new Map();
53322
+ this.effectBindGroups = /* @__PURE__ */ new Map();
52604
53323
  this.meshState = { cullMode: "back", frontFace: "ccw" };
52605
53324
  if (this.retained !== void 0) {
52606
53325
  this.releaseAllRetained();
@@ -52745,9 +53464,11 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
52745
53464
  const frame = renderer.currentFrameBinding;
52746
53465
  pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]);
52747
53466
  this.bindLights(pass);
52748
- pass.setBindGroup(3, this.uniformBinding.bindGroup, [
52749
- this.uniformBinding.dynamicOffset
52750
- ]);
53467
+ pass.setBindGroup(
53468
+ 3,
53469
+ this.uniformBinding.bindGroup,
53470
+ this.uniformBinding.offsets
53471
+ );
52751
53472
  pass.setVertexBuffer(0, geometry.vertexBuffer);
52752
53473
  pass.setVertexBuffer(1, instances.buffer);
52753
53474
  pass.setIndexBuffer(geometry.indexBuffer, geometry.indexFormat);
@@ -52856,9 +53577,11 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
52856
53577
  pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]);
52857
53578
  pass.setBindGroup(1, this.currentMaterial);
52858
53579
  this.bindLights(pass);
52859
- pass.setBindGroup(3, this.uniformBinding.bindGroup, [
52860
- this.uniformBinding.dynamicOffset
52861
- ]);
53580
+ pass.setBindGroup(
53581
+ 3,
53582
+ this.uniformBinding.bindGroup,
53583
+ this.uniformBinding.offsets
53584
+ );
52862
53585
  pass.setVertexBuffer(0, quadGeometry.vertexBuffer);
52863
53586
  pass.setVertexBuffer(1, instances.buffer);
52864
53587
  pass.setIndexBuffer(quadGeometry.indexBuffer, quadGeometry.indexFormat);
@@ -52946,6 +53669,108 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
52946
53669
  uniformSignature() {
52947
53670
  return `mesh:u${MESH_UNIFORM_SIZE}`;
52948
53671
  }
53672
+ /**
53673
+ * The group-3 layout when a `ShaderEffect` is hosted on the mesh (#1658).
53674
+ *
53675
+ * `uMesh` keeps binding 0 — the spliced module is the engine's own mesh
53676
+ * shader and still declares it there — and the effect's own uniform block
53677
+ * takes binding 1, its texture/sampler pairs following at their declared
53678
+ * bindings shifted by the same one. That shift is why the body is rewritten
53679
+ * rather than pasted: a quad effect declares its block at
53680
+ * `@group(3) @binding(0)`, which is exactly where `uMesh` lives.
53681
+ * @param {object} effect - the hosted effect
53682
+ * @returns {GPUBindGroupLayout} the combined layout
53683
+ * @ignore
53684
+ * @internal
53685
+ */
53686
+ hostedEffectLayout(effect) {
53687
+ const realization = effect.wgslRealization;
53688
+ const entries = [
53689
+ {
53690
+ binding: 0,
53691
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
53692
+ buffer: {
53693
+ type: "uniform",
53694
+ hasDynamicOffset: true,
53695
+ minBindingSize: MESH_UNIFORM_SIZE
53696
+ }
53697
+ }
53698
+ ];
53699
+ let signature = `${this.uniformSignature()}|fx${realization.structSize}`;
53700
+ if (realization.structSize > 0) {
53701
+ entries.push({
53702
+ binding: 1,
53703
+ visibility: GPUShaderStage.FRAGMENT,
53704
+ buffer: {
53705
+ type: "uniform",
53706
+ hasDynamicOffset: true,
53707
+ minBindingSize: realization.structSize
53708
+ }
53709
+ });
53710
+ }
53711
+ return this.renderer.pipelineCache.getEffectLayout(signature, entries);
53712
+ }
53713
+ /**
53714
+ * Build the group-3 binding for a draw that hosts an effect: `uMesh`
53715
+ * plus a fresh snapshot of the effect's uniform mirror, plus its
53716
+ * textures.
53717
+ * @param {GPUDevice} device - the device
53718
+ * @param {object} renderer - the owning renderer
53719
+ * @param {object} effect - the hosted effect
53720
+ * @param {object} meshRegion - the arena region already holding `uMesh`
53721
+ * @returns {{bindGroup: GPUBindGroup, offsets: number[]}} the binding
53722
+ * @ignore
53723
+ * @internal
53724
+ */
53725
+ bindHostedEffect(device, renderer, effect, meshRegion) {
53726
+ const realization = effect.wgslRealization;
53727
+ const entries = [
53728
+ {
53729
+ binding: 0,
53730
+ resource: { buffer: meshRegion.buffer, size: MESH_UNIFORM_SIZE }
53731
+ }
53732
+ ];
53733
+ const offsets = [meshRegion.offset];
53734
+ if (realization.structSize > 0) {
53735
+ const region = renderer.effectUniformArena.alloc(
53736
+ realization.structSize,
53737
+ device.limits.minUniformBufferOffsetAlignment
53738
+ );
53739
+ device.queue.writeBuffer(
53740
+ region.buffer,
53741
+ region.offset,
53742
+ realization.cpu,
53743
+ 0,
53744
+ realization.structSize
53745
+ );
53746
+ entries.push({
53747
+ binding: 1,
53748
+ resource: { buffer: region.buffer, size: realization.structSize }
53749
+ });
53750
+ offsets.push(region.offset);
53751
+ }
53752
+ const effectBuffer = offsets.length > 1 ? entries[1].resource.buffer : null;
53753
+ let byEffectPage = this.effectBindGroups.get(meshRegion.buffer);
53754
+ if (byEffectPage === void 0) {
53755
+ byEffectPage = /* @__PURE__ */ new Map();
53756
+ this.effectBindGroups.set(meshRegion.buffer, byEffectPage);
53757
+ }
53758
+ let byEffect = byEffectPage.get(effectBuffer);
53759
+ if (byEffect === void 0) {
53760
+ byEffect = /* @__PURE__ */ new Map();
53761
+ byEffectPage.set(effectBuffer, byEffect);
53762
+ }
53763
+ let bindGroup = byEffect.get(effect._effectId);
53764
+ if (bindGroup === void 0) {
53765
+ bindGroup = device.createBindGroup({
53766
+ label: "melonJS mesh uniforms + hosted effect",
53767
+ layout: this.hostedEffectLayout(effect),
53768
+ entries
53769
+ });
53770
+ byEffect.set(effect._effectId, bindGroup);
53771
+ }
53772
+ return { bindGroup, offsets };
53773
+ }
52949
53774
  /**
52950
53775
  * The shader family for the next recorded draw: the built-in family,
52951
53776
  * or the hosted custom module when `renderer.drawMesh` routed a
@@ -52960,6 +53785,27 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
52960
53785
  * @internal
52961
53786
  */
52962
53787
  activeShaderKey() {
53788
+ const effect = this.hostedEffect;
53789
+ if (effect != null) {
53790
+ const cache4 = this.renderer.pipelineCache;
53791
+ if (this._effectKeys?.epoch !== cache4.epoch) {
53792
+ this._effectKeys = { epoch: cache4.epoch, keys: /* @__PURE__ */ new Map() };
53793
+ }
53794
+ const host = `${this.vertexLayoutKey}|fx${effect._effectId}`;
53795
+ let key2 = this._effectKeys.keys.get(host);
53796
+ if (typeof key2 === "undefined") {
53797
+ key2 = cache4.registerShader(
53798
+ effect.wgslRealization.meshModule(this.shaderSource()),
53799
+ {
53800
+ bindGroupLayouts: this.bindGroupLayoutList(cache4),
53801
+ vertexLayoutKey: this.vertexLayoutKey,
53802
+ label: `melonJS ${this.vertexLayoutKey} + hosted effect`
53803
+ }
53804
+ );
53805
+ this._effectKeys.keys.set(host, key2);
53806
+ }
53807
+ return key2;
53808
+ }
52963
53809
  const custom = this.customShader;
52964
53810
  if (custom === null) {
52965
53811
  return this.shaderKey;
@@ -52984,9 +53830,27 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
52984
53830
  cache3.frameLayout,
52985
53831
  cache3.meshMaterialLayout,
52986
53832
  cache3.emptyLayout,
52987
- this.meshLayout
53833
+ this.perDrawLayout()
52988
53834
  ];
52989
53835
  }
53836
+ /**
53837
+ * The group-3 layout for the draw being recorded: `uMesh` alone, or
53838
+ * `uMesh` plus a hosted effect's own block.
53839
+ *
53840
+ * A single accessor on purpose. Every subclass builds its own positional
53841
+ * layout list — the lit tier swaps group 2 for its light block — and when
53842
+ * this choice was inlined into the base list only, the lit subclass kept
53843
+ * handing over the plain layout while the module declared the effect's
53844
+ * binding. The pipeline then failed to validate with "Binding doesn't
53845
+ * exist", every frame drawing that mesh was dropped, and the scene
53846
+ * flickered. Subclasses override the list; they must not re-decide this.
53847
+ * @returns {GPUBindGroupLayout} the group-3 layout
53848
+ * @ignore
53849
+ * @internal
53850
+ */
53851
+ perDrawLayout() {
53852
+ return this.hostedEffect != null ? this.hostedEffectLayout(this.hostedEffect) : this.meshLayout;
53853
+ }
52990
53854
  /**
52991
53855
  * Resolve the group-1 material for a mesh: its texture through the
52992
53856
  * texture store, honouring the mesh's own `textureRepeat` wrap override
@@ -53103,6 +53967,16 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
53103
53967
  0,
53104
53968
  MESH_UNIFORM_SIZE
53105
53969
  );
53970
+ const effect = this.hostedEffect;
53971
+ if (effect !== null && effect !== void 0) {
53972
+ this.uniformBinding = this.bindHostedEffect(
53973
+ device,
53974
+ renderer,
53975
+ effect,
53976
+ region
53977
+ );
53978
+ return;
53979
+ }
53106
53980
  let bindGroup = this.uniformBindGroups.get(region.buffer);
53107
53981
  if (typeof bindGroup === "undefined") {
53108
53982
  bindGroup = device.createBindGroup({
@@ -53117,7 +53991,7 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
53117
53991
  });
53118
53992
  this.uniformBindGroups.set(region.buffer, bindGroup);
53119
53993
  }
53120
- this.uniformBinding = { bindGroup, dynamicOffset: region.offset };
53994
+ this.uniformBinding = { bindGroup, offsets: [region.offset] };
53121
53995
  }
53122
53996
  /**
53123
53997
  * Write one vertex into the staging buffer. The base (unlit) layout is
@@ -53273,9 +54147,11 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
53273
54147
  pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]);
53274
54148
  pass.setBindGroup(1, this.currentMaterial);
53275
54149
  this.bindLights(pass);
53276
- pass.setBindGroup(3, this.uniformBinding.bindGroup, [
53277
- this.uniformBinding.dynamicOffset
53278
- ]);
54150
+ pass.setBindGroup(
54151
+ 3,
54152
+ this.uniformBinding.bindGroup,
54153
+ this.uniformBinding.offsets
54154
+ );
53279
54155
  pass.setVertexBuffer(
53280
54156
  0,
53281
54157
  vertexRegion.buffer,
@@ -53386,9 +54262,11 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
53386
54262
  const frame = renderer.currentFrameBinding;
53387
54263
  pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]);
53388
54264
  this.bindLights(pass);
53389
- pass.setBindGroup(3, this.uniformBinding.bindGroup, [
53390
- this.uniformBinding.dynamicOffset
53391
- ]);
54265
+ pass.setBindGroup(
54266
+ 3,
54267
+ this.uniformBinding.bindGroup,
54268
+ this.uniformBinding.offsets
54269
+ );
53392
54270
  pass.setVertexBuffer(0, geometry.vertexBuffer);
53393
54271
  pass.setIndexBuffer(geometry.indexBuffer, geometry.indexFormat);
53394
54272
  const slices = mesh.textureGroups;
@@ -53438,8 +54316,10 @@ var WebGPUMeshBatcher = class extends WebGPUBatcher {
53438
54316
  this.indexCount = 0;
53439
54317
  this.currentMaterial = null;
53440
54318
  this.customShader = null;
54319
+ this.hostedEffect = null;
53441
54320
  this.uniformBinding = null;
53442
54321
  this.uniformBindGroups.clear();
54322
+ this.effectBindGroups.clear();
53443
54323
  this.releaseAllRetained();
53444
54324
  }
53445
54325
  /**
@@ -53547,7 +54427,9 @@ var WebGPULitMeshBatcher = class extends WebGPUMeshBatcher {
53547
54427
  cache3.frameLayout,
53548
54428
  cache3.meshMaterialLayout,
53549
54429
  this.ensureLightsLayout(),
53550
- this.meshLayout
54430
+ // NOT `this.meshLayout`: a hosted effect extends group 3, and the
54431
+ // base decides that for every tier (see `perDrawLayout`)
54432
+ this.perDrawLayout()
53551
54433
  ];
53552
54434
  }
53553
54435
  /**
@@ -57841,23 +58723,46 @@ var WebGPURenderer = class _WebGPURenderer extends Renderer {
57841
58723
  this.pendingDepthClear = true;
57842
58724
  }
57843
58725
  const customShader = this.customShader != null && this.customShader.isWebGPU === true ? this.customShader : null;
57844
- if (this.customShader != null && customShader === null && this.meshEffectWarned !== true) {
58726
+ const hostedEffect = customShader === null && this.customShader != null && typeof this.customShader.wgslRealization !== "undefined" && meshHostingBlocker(
58727
+ this.customShader.wgslRealization.builtins,
58728
+ this.customShader.wgslRealization.textures,
58729
+ "wgsl"
58730
+ ) === null && this.customShader.wgslRealization.meshModule(
58731
+ this.batchers.get(
58732
+ mesh.lit === true && this.batchers.has("litMesh") ? "litMesh" : "mesh"
58733
+ )?.shaderSource?.() ?? ""
58734
+ ) !== null ? this.customShader : null;
58735
+ if (this.customShader != null && customShader === null && hostedEffect === null && this.meshEffectWarned !== true) {
57845
58736
  this.meshEffectWarned = true;
58737
+ const realization = this.customShader.wgslRealization;
58738
+ const blocker = typeof realization !== "undefined" ? meshHostingBlocker(
58739
+ realization.builtins,
58740
+ realization.textures,
58741
+ "wgsl"
58742
+ ) : null;
57846
58743
  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"
58744
+ 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
58745
  );
57849
58746
  }
57850
58747
  const batcher = this.setBatcher(
57851
58748
  mesh.lit === true && this.batchers.has("litMesh") ? "litMesh" : "mesh"
57852
58749
  );
57853
58750
  batcher.customShader = customShader;
58751
+ const instanced = mesh.instanceLayout !== void 0 && retained && this.supportsInstancing === true;
58752
+ if (instanced && hostedEffect !== null && this.instancedEffectWarned !== true) {
58753
+ this.instancedEffectWarned = true;
58754
+ console.warn(
58755
+ "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."
58756
+ );
58757
+ }
58758
+ batcher.hostedEffect = instanced ? null : hostedEffect;
57854
58759
  const culling = mesh.cullBackFaces === true;
57855
58760
  const state4 = batcher.meshState;
57856
58761
  state4.cullMode = culling ? "back" : "none";
57857
58762
  state4.frontFace = culling && retained && mesh.rightHanded !== true ? "cw" : "ccw";
57858
58763
  try {
57859
58764
  const tint = this.currentTint.toUint32(this.getGlobalAlpha());
57860
- if (mesh.instanceLayout !== void 0 && retained && this.supportsInstancing === true) {
58765
+ if (instanced) {
57861
58766
  batcher.drawInstancedMesh(mesh, modelMatrix, tint);
57862
58767
  } else if (retained) {
57863
58768
  batcher.drawRetainedMesh(mesh, modelMatrix, tint);
@@ -57867,6 +58772,7 @@ var WebGPURenderer = class _WebGPURenderer extends Renderer {
57867
58772
  }
57868
58773
  } finally {
57869
58774
  batcher.customShader = null;
58775
+ batcher.hostedEffect = null;
57870
58776
  }
57871
58777
  }
57872
58778
  /**
@@ -60914,7 +61820,7 @@ var Sprite3d = class extends Mesh {
60914
61820
  * @param {number} x - world x position
60915
61821
  * @param {number} y - world y position
60916
61822
  * @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`.
61823
+ * @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
61824
  * @param {number} [settings.width=settings.framewidth] - quad width in world units (pixels)
60919
61825
  * @param {number} [settings.height=settings.width] - quad height in world units
60920
61826
  * @param {number} [settings.framewidth] - width of a single frame within a spritesheet (enables frame animation)
@@ -60928,6 +61834,11 @@ var Sprite3d = class extends Mesh {
60928
61834
  * @param {boolean} [settings.flipY=false] - mirror the sprite vertically (see {@link Sprite3d#flipY})
60929
61835
  * @param {boolean} [settings.lit=false] - shade through the lit mesh batcher (see {@link Mesh})
60930
61836
  * @param {number[]|Float32Array} [settings.emissive] - emissive color (see {@link Mesh})
61837
+ * @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.
61838
+ * @param {number} [settings.shadowOpacity=0.45] - opacity of the shadow directly beneath the sprite, before any height fade
61839
+ * @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}.
61840
+ * @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.
61841
+ * @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
61842
  * @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
61843
  */
60933
61844
  constructor(x, y, settings) {
@@ -61010,6 +61921,7 @@ var Sprite3d = class extends Mesh {
61010
61921
  // flatten it to an explicit false, silently opting every sprite out
61011
61922
  // of a scene-wide default
61012
61923
  castGroundShadow: settings.castGroundShadow,
61924
+ fog: settings.fog,
61013
61925
  // raw for the same reason as above: `undefined` means "decide from
61014
61926
  // the draw's alpha", and coercing it would pin every sprite opaque
61015
61927
  transparent: settings.transparent,
@@ -64247,25 +65159,8 @@ var lerp3 = (a, b, t) => {
64247
65159
  };
64248
65160
  var NoiseTexture2d = class extends Texture2d {
64249
65161
  /**
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`)
65162
+ * @param {NoiseTexture2dSettings} [settings] - bake settings, plus any
65163
+ * {@link NoiseSettings} for the field itself
64269
65164
  */
64270
65165
  constructor(settings = {}) {
64271
65166
  super();