@playcanvas/web-components 0.12.0 → 0.13.1

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 (95) hide show
  1. package/README.md +1 -1
  2. package/dist/app.d.cts +281 -0
  3. package/dist/app.d.ts +61 -3
  4. package/dist/asset.d.cts +224 -0
  5. package/dist/asset.d.ts +8 -2
  6. package/dist/async-element.d.cts +103 -0
  7. package/dist/async-element.d.ts +6 -4
  8. package/dist/colors.d.cts +1 -0
  9. package/dist/components/button-component.d.cts +186 -0
  10. package/dist/components/button-component.d.ts +1 -1
  11. package/dist/components/camera-component.d.cts +253 -0
  12. package/dist/components/camera-component.d.ts +1 -1
  13. package/dist/components/collision-component.d.cts +73 -0
  14. package/dist/components/collision-component.d.ts +1 -1
  15. package/dist/components/component.d.cts +82 -0
  16. package/dist/components/component.d.ts +1 -1
  17. package/dist/components/element-component.d.cts +316 -0
  18. package/dist/components/element-component.d.ts +1 -1
  19. package/dist/components/gsplat-component.d.cts +108 -0
  20. package/dist/components/gsplat-component.d.ts +1 -1
  21. package/dist/components/layoutchild-component.d.cts +110 -0
  22. package/dist/components/layoutchild-component.d.ts +1 -1
  23. package/dist/components/layoutgroup-component.d.cts +136 -0
  24. package/dist/components/layoutgroup-component.d.ts +1 -1
  25. package/dist/components/light-component.d.cts +264 -0
  26. package/dist/components/light-component.d.ts +1 -1
  27. package/dist/components/listener-component.d.cts +20 -0
  28. package/dist/components/listener-component.d.ts +1 -1
  29. package/dist/components/particlesystem-component.d.cts +52 -0
  30. package/dist/components/particlesystem-component.d.ts +1 -1
  31. package/dist/components/render-component.d.cts +76 -0
  32. package/dist/components/render-component.d.ts +1 -1
  33. package/dist/components/rigidbody-component.d.cts +88 -0
  34. package/dist/components/rigidbody-component.d.ts +1 -1
  35. package/dist/components/screen-component.d.cts +70 -0
  36. package/dist/components/screen-component.d.ts +1 -1
  37. package/dist/components/script-component.d.cts +163 -0
  38. package/dist/components/script-component.d.ts +1 -1
  39. package/dist/components/script.d.cts +94 -0
  40. package/dist/components/script.d.ts +1 -1
  41. package/dist/components/scrollbar-component.d.cts +69 -0
  42. package/dist/components/scrollbar-component.d.ts +1 -1
  43. package/dist/components/scrollview-component.d.cts +178 -0
  44. package/dist/components/scrollview-component.d.ts +1 -1
  45. package/dist/components/sound-component.d.cts +108 -0
  46. package/dist/components/sound-component.d.ts +1 -1
  47. package/dist/components/sound-slot.d.cts +134 -0
  48. package/dist/components/sound-slot.d.ts +2 -2
  49. package/dist/custom-elements.json +80 -22
  50. package/dist/entity-base.d.cts +67 -0
  51. package/dist/entity-base.d.ts +2 -2
  52. package/dist/entity.d.cts +131 -0
  53. package/dist/entity.d.ts +1 -1
  54. package/dist/index.d.cts +79 -0
  55. package/dist/index.d.ts +33 -33
  56. package/dist/loading-bar.d.cts +35 -0
  57. package/dist/material.d.cts +1011 -0
  58. package/dist/model.d.cts +72 -0
  59. package/dist/model.d.ts +1 -1
  60. package/dist/module.d.cts +29 -0
  61. package/dist/module.d.ts +16 -10
  62. package/dist/node.d.cts +253 -0
  63. package/dist/node.d.ts +1 -1
  64. package/dist/parse.d.cts +147 -0
  65. package/dist/pwc.cjs +285 -92
  66. package/dist/pwc.cjs.map +1 -1
  67. package/dist/pwc.js +286 -93
  68. package/dist/pwc.js.map +1 -1
  69. package/dist/pwc.min.js +1 -1
  70. package/dist/pwc.min.js.map +1 -1
  71. package/dist/pwc.min.mjs +1 -1
  72. package/dist/pwc.min.mjs.map +1 -1
  73. package/dist/pwc.mjs +286 -93
  74. package/dist/pwc.mjs.map +1 -1
  75. package/dist/scene.d.cts +117 -0
  76. package/dist/scene.d.ts +1 -1
  77. package/dist/sky.d.cts +121 -0
  78. package/dist/sky.d.ts +1 -1
  79. package/dist/vscode.html-custom-data.json +3 -3
  80. package/dist/web-types.json +42 -6
  81. package/package.json +16 -7
  82. package/src/app.ts +211 -49
  83. package/src/asset.ts +34 -2
  84. package/src/async-element.ts +4 -2
  85. package/src/components/button-component.ts +7 -7
  86. package/src/components/element-component.ts +7 -7
  87. package/src/components/gsplat-component.ts +3 -3
  88. package/src/components/particlesystem-component.ts +7 -8
  89. package/src/components/script-component.ts +2 -2
  90. package/src/components/sound-slot.ts +2 -2
  91. package/src/loading-bar.ts +2 -2
  92. package/src/material.ts +2 -2
  93. package/src/model.ts +2 -5
  94. package/src/module.ts +39 -20
  95. package/src/sky.ts +2 -3
package/dist/pwc.cjs CHANGED
@@ -110,12 +110,18 @@ async function whenReady(target) {
110
110
 
111
111
  /**
112
112
  * The ModuleElement interface provides properties and methods for manipulating
113
- * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-module/ | `<pc-module>`} elements.
114
- * The ModuleElement interface also inherits the properties and methods of the
115
- * {@link HTMLElement} interface.
113
+ * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-module/ | `<pc-module>`}
114
+ * elements. The ModuleElement interface also inherits the properties and methods of the
115
+ * {@link AsyncElement} interface.
116
+ *
117
+ * The attributes are read once, when the module starts loading - on the element's first
118
+ * connection, or earlier if a containing `<pc-app>` boots first and collects it - so changing
119
+ * them later has no effect. The element becomes ready once the module has loaded. WebAssembly
120
+ * modules configure engine-global state that never unloads, so readiness is not re-armed by
121
+ * removing the element, and a re-inserted element does not load again.
116
122
  *
117
- * Note that these attributes are read once when the element is created, so changing them later
118
- * has no effect.
123
+ * A `<pc-module>` without a `name` warns and never becomes ready; a containing `<pc-app>` still
124
+ * boots.
119
125
  *
120
126
  * @attribute {string} name - The name of the WebAssembly module to configure, e.g. `Basis` or
121
127
  * `Ammo`.
@@ -124,19 +130,22 @@ async function whenReady(target) {
124
130
  * @attribute {string} fallback - The URL of the module's asm.js fallback script, used when
125
131
  * WebAssembly is unavailable.
126
132
  */
127
- class ModuleElement extends HTMLElement {
128
- loadPromise;
129
- /** @ignore */
130
- constructor() {
131
- super();
132
- this.loadPromise = this.loadModule();
133
+ class ModuleElement extends AsyncElement {
134
+ _loadPromise = null;
135
+ connectedCallback() {
136
+ this._getLoadPromise();
133
137
  }
134
- async loadModule() {
138
+ async _loadModule() {
135
139
  const name = this.getAttribute('name');
136
- const glueUrl = this.getAttribute('glue');
137
- const wasmUrl = this.getAttribute('wasm');
138
- const fallbackUrl = this.getAttribute('fallback');
139
- const config = { glueUrl, wasmUrl, fallbackUrl };
140
+ if (!name) {
141
+ console.warn("pc-module requires a 'name' attribute - no module was configured");
142
+ return;
143
+ }
144
+ const config = {
145
+ glueUrl: this.getAttribute('glue') ?? undefined,
146
+ wasmUrl: this.getAttribute('wasm') ?? undefined,
147
+ fallbackUrl: this.getAttribute('fallback') ?? undefined
148
+ };
140
149
  if (name === 'Basis') {
141
150
  playcanvas.basisInitialize(config);
142
151
  }
@@ -146,16 +155,22 @@ class ModuleElement extends HTMLElement {
146
155
  playcanvas.WasmModule.getInstance(name, () => resolve());
147
156
  });
148
157
  }
158
+ this._onReady();
149
159
  }
150
160
  /**
151
- * Returns the promise that settles when the module has loaded. Awaited by the containing
152
- * `<pc-app>` element before it creates its graphics device.
161
+ * Returns the promise that settles when the module has loaded, starting the load if it has
162
+ * not already started - a containing `<pc-app>` boots in document order, so it may collect
163
+ * this element before the element's own connectedCallback has run. A missing `name` resolves
164
+ * the promise without configuring anything, so a misconfigured module never blocks the app.
153
165
  *
154
166
  * @returns The load promise.
155
167
  * @internal
156
168
  */
157
169
  _getLoadPromise() {
158
- return this.loadPromise;
170
+ if (!this._loadPromise) {
171
+ this._loadPromise = this._loadModule();
172
+ }
173
+ return this._loadPromise;
159
174
  }
160
175
  }
161
176
  customElements.define('pc-module', ModuleElement);
@@ -185,9 +200,9 @@ class LoadingBar {
185
200
  this._track.setAttribute('aria-label', 'Loading');
186
201
  this._track.setAttribute('aria-valuemin', '0');
187
202
  this._track.setAttribute('aria-valuemax', '100');
188
- // Fixed positioning matches the canvas, which always fills the window (FILLMODE_FILL_WINDOW)
203
+ // Anchored to the pc-app element, which the library's base styles make a positioned box
189
204
  this._track.style.cssText = [
190
- 'position: fixed',
205
+ 'position: absolute',
191
206
  'top: 0',
192
207
  'left: 0',
193
208
  'width: 100%',
@@ -699,16 +714,44 @@ const getEntity = (ref) => {
699
714
 
700
715
  /** The pointer event types the application synthesizes on `<pc-entity>` elements via picking. */
701
716
  const pointerEventTypes = ['pointermove', 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave'];
717
+ /**
718
+ * Gives `pc-app` the sizing contract of a replaced element (`<video>`, `<img>`): a block-level
719
+ * box that the page's CSS sizes, defaulting to the canvas's own 300x150 intrinsic size, with the
720
+ * canvas and loading bar anchored to it. `:where()` keeps every declaration at zero specificity,
721
+ * so any page rule - however plain - overrides these defaults.
722
+ */
723
+ const ensureBaseStyles = () => {
724
+ const id = 'pc-app-styles';
725
+ if (document.getElementById(id)) {
726
+ return;
727
+ }
728
+ const style = document.createElement('style');
729
+ style.id = id;
730
+ style.textContent = ':where(pc-app) { display: block; position: relative; width: 300px; height: 150px; }';
731
+ document.head.appendChild(style);
732
+ };
702
733
  /**
703
734
  * The AppElement interface provides properties and methods for manipulating
704
735
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-app/ | `<pc-app>`} elements.
705
736
  * The AppElement interface also inherits the properties and methods of the
706
737
  * {@link HTMLElement} interface.
707
738
  *
739
+ * The element is sized like a replaced element such as `<video>`: a block-level box that the
740
+ * page's CSS controls, 300x150 by default. The application's canvas always fills the element,
741
+ * and the drawing buffer resolution follows the element's size (capped by `max-pixel-ratio`),
742
+ * tracked live via a ResizeObserver — so the element can be embedded at any size, resized by
743
+ * its container, or made fullscreen with ordinary CSS such as `width: 100vw; height: 100dvh`.
744
+ *
708
745
  * @fires {ProgressEvent} progress - Fired while the application preloads its assets. `loaded` and
709
746
  * `total` are asset counts, not bytes, and an asset that fails to load still counts as loaded.
710
747
  * Fired at least once per boot, and the final event always has `loaded` equal to `total`. Does
711
748
  * not bubble.
749
+ *
750
+ * @fires {ErrorEvent} error - Fired when the application cannot boot because no graphics device
751
+ * could be created (for example, a browser with WebGL disabled). `message` names the requested
752
+ * backends and `error` holds the underlying failure. The element never becomes ready
753
+ * and `app` stays `null` — listen for this event to show a fallback UI. Removing the element and
754
+ * re-inserting it retries the boot with its current attributes. Does not bubble.
712
755
  */
713
756
  class AppElement extends AsyncElement {
714
757
  /**
@@ -768,6 +811,12 @@ class AppElement extends AsyncElement {
768
811
  };
769
812
  _app = null;
770
813
  _loadProgress = 0;
814
+ /**
815
+ * Tracks the element's box so the drawing buffer and picker follow it. Created per boot once
816
+ * the application exists, and disconnected on teardown. `null` where ResizeObserver is
817
+ * unavailable (jsdom), where the boot-time resolution set is the only sizing that happens.
818
+ */
819
+ _resizeObserver = null;
771
820
  /**
772
821
  * The PlayCanvas application instance. `null` until the element is ready, and again once it
773
822
  * has been removed from the document — await {@link whenReady} or the element's `ready()`
@@ -794,8 +843,6 @@ class AppElement extends AsyncElement {
794
843
  */
795
844
  constructor() {
796
845
  super();
797
- // Bind methods to maintain 'this' context
798
- this._onWindowResize = this._onWindowResize.bind(this);
799
846
  // Track pointer listeners being added to and removed from descendant entities.
800
847
  // Registered once here rather than on every boot - the handlers no-op while there is no
801
848
  // canvas, and a re-booted element must not stack a second set.
@@ -806,6 +853,9 @@ class AppElement extends AsyncElement {
806
853
  }
807
854
  async connectedCallback() {
808
855
  const generation = ++this._bootGeneration;
856
+ // Installed before the loading bar is created: the bar anchors to this element, which
857
+ // these styles make a positioned block box
858
+ ensureBaseStyles();
809
859
  // Created before the first await, so the bar is visible while modules and the graphics
810
860
  // device are created, and exists before any disconnect could need to clean it up
811
861
  if (this._loadingBar && !this._bar) {
@@ -820,8 +870,11 @@ class AppElement extends AsyncElement {
820
870
  if (generation !== this._bootGeneration) {
821
871
  return;
822
872
  }
823
- // Create and append the canvas to the element
873
+ // Create and append the canvas, filling the element's content box - the page sizes the
874
+ // element, and everything else follows. touch-action: none keeps touch drags driving the
875
+ // engine's input handlers instead of scrolling the page.
824
876
  this._canvas = document.createElement('canvas');
877
+ this._canvas.style.cssText = 'display: block; width: 100%; height: 100%; touch-action: none;';
825
878
  this.appendChild(this._canvas);
826
879
  // Configure device types based on backend selection
827
880
  const backendToDeviceTypes = {
@@ -831,14 +884,44 @@ class AppElement extends AsyncElement {
831
884
  };
832
885
  const deviceTypes = backendToDeviceTypes[this._backend] || [];
833
886
  this._optionsLocked = true;
834
- const device = await playcanvas.createGraphicsDevice(this._canvas, {
835
- // @ts-ignore - alpha needs to be documented
836
- alpha: this._alpha,
837
- antialias: this._antialias,
838
- depth: this._depthBuffer,
839
- deviceTypes: deviceTypes,
840
- stencil: this._stencilBuffer
841
- });
887
+ // createGraphicsDevice appends its final null-device fallback to the array in place, so
888
+ // the requested list is captured now for the failure message.
889
+ const requested = deviceTypes.join(', ');
890
+ let device;
891
+ try {
892
+ device = await playcanvas.createGraphicsDevice(this._canvas, {
893
+ // @ts-ignore - alpha needs to be documented
894
+ alpha: this._alpha,
895
+ antialias: this._antialias,
896
+ depth: this._depthBuffer,
897
+ deviceTypes: deviceTypes,
898
+ stencil: this._stencilBuffer
899
+ });
900
+ }
901
+ catch (error) {
902
+ // The element may have been removed while device creation was failing. The teardown
903
+ // has already cleaned up, and the failure belongs to a boot that no longer owns the
904
+ // element.
905
+ if (generation !== this._bootGeneration) {
906
+ return;
907
+ }
908
+ // Return the element to its pre-boot state - no dead canvas, no loading bar stuck at
909
+ // zero - before announcing the failure. Readiness deliberately stays pending: nothing
910
+ // it would announce (the app, the entity hierarchy) exists, so a device-less element
911
+ // joins the documented never-ready cases and the failure surfaces through the error
912
+ // event instead.
913
+ if (this._canvas && this.contains(this._canvas)) {
914
+ this.removeChild(this._canvas);
915
+ }
916
+ this._canvas = null;
917
+ this._bar?.destroy();
918
+ this._bar = null;
919
+ const reason = error instanceof Error ? error.message : String(error);
920
+ const message = `pc-app failed to create a graphics device (${requested}) - ${reason}`;
921
+ console.error(message, error);
922
+ this.dispatchEvent(new ErrorEvent('error', { message, error }));
923
+ return;
924
+ }
842
925
  // The element may have been removed while the device was created. disconnectedCallback
843
926
  // has already cleaned up the canvas; the device was created inside the await, so it is
844
927
  // this boot's to release.
@@ -917,9 +1000,19 @@ class AppElement extends AsyncElement {
917
1000
  const app = new playcanvas.AppBase(this._canvas);
918
1001
  this._app = app;
919
1002
  app.init(createOptions);
920
- app.setCanvasFillMode(playcanvas.FILLMODE_FILL_WINDOW);
1003
+ // FILLMODE_NONE leaves the canvas's CSS sizing alone (the engine's other fill modes
1004
+ // stamp window-derived pixel sizes onto it); RESOLUTION_AUTO sizes the drawing buffer
1005
+ // from the canvas's client size
1006
+ app.setCanvasFillMode(playcanvas.FILLMODE_NONE);
921
1007
  app.setCanvasResolution(playcanvas.RESOLUTION_AUTO);
922
1008
  this._pickerCreate();
1009
+ // Track the element's box rather than the window: containers resize without any window
1010
+ // event (splitter drags, flex reflow, animations). Guarded because jsdom has no
1011
+ // ResizeObserver - there, the resolution set above is the only sizing that happens.
1012
+ if (typeof ResizeObserver !== 'undefined') {
1013
+ this._resizeObserver = new ResizeObserver(() => this._syncCanvasSize());
1014
+ this._resizeObserver.observe(this);
1015
+ }
923
1016
  // Get all pc-asset elements that are direct children of the pc-app element
924
1017
  const assetElements = this.querySelectorAll(':scope > pc-asset');
925
1018
  for (const assetElement of Array.from(assetElements)) {
@@ -995,8 +1088,6 @@ class AppElement extends AsyncElement {
995
1088
  // Dismiss the bar only once a frame has actually rendered; ready fires before the
996
1089
  // first rAF tick
997
1090
  app.once('frameend', () => this._bar?.complete());
998
- // Handle window resize to keep the canvas responsive
999
- window.addEventListener('resize', this._onWindowResize);
1000
1091
  this._onReady();
1001
1092
  });
1002
1093
  }
@@ -1021,18 +1112,27 @@ class AppElement extends AsyncElement {
1021
1112
  // no longer holds.
1022
1113
  this._hierarchyReady = false;
1023
1114
  this._resetReady();
1024
- // Remove event listeners
1025
- window.removeEventListener('resize', this._onWindowResize);
1115
+ // Stop tracking the element's size
1116
+ this._resizeObserver?.disconnect();
1117
+ this._resizeObserver = null;
1026
1118
  // Remove the canvas
1027
1119
  if (this._canvas && this.contains(this._canvas)) {
1028
1120
  this.removeChild(this._canvas);
1029
1121
  this._canvas = null;
1030
1122
  }
1031
1123
  }
1032
- _onWindowResize() {
1033
- if (this.app) {
1034
- this.app.resizeCanvas();
1124
+ /**
1125
+ * Syncs the drawing buffer and the picker to the canvas's current CSS size. The picker must
1126
+ * track the buffer, or picks would land at stale coordinates after a resize. Skipped while
1127
+ * an XR session presents - the session owns the buffer size.
1128
+ */
1129
+ _syncCanvasSize() {
1130
+ if (!this.app || this.app.xr?.active) {
1131
+ return;
1035
1132
  }
1133
+ this.app.updateCanvasSize();
1134
+ const { width, height } = this.app.graphicsDevice;
1135
+ this._picker?.resize(width, height);
1036
1136
  }
1037
1137
  _pickerCreate() {
1038
1138
  const { width, height } = this.app.graphicsDevice;
@@ -1150,21 +1250,63 @@ class AppElement extends AsyncElement {
1150
1250
  }
1151
1251
  return null;
1152
1252
  }
1153
- // New helper to convert CSS coordinates to canvas (picker) coordinates
1154
- _getPickerCoordinates(event) {
1155
- // Get the canvas' bounding rectangle in CSS pixels.
1156
- const canvasRect = this._canvas.getBoundingClientRect();
1157
- // Compute scale factors based on canvas actual resolution vs. its CSS display size.
1158
- const scaleX = this._canvas.width / canvasRect.width;
1159
- const scaleY = this._canvas.height / canvasRect.height;
1160
- // Convert the client coordinates accordingly.
1161
- const x = (event.clientX - canvasRect.left) * scaleX;
1162
- const y = (event.clientY - canvasRect.top) * scaleY;
1163
- return { x, y };
1253
+ /**
1254
+ * Converts a pointer event's client coordinates into drawing-buffer coordinates - the space
1255
+ * the pick buffer and the camera viewports are laid out in. When the canvas has no CSS box
1256
+ * to map through (jsdom; a hidden canvas receives no pointer events in a browser), the
1257
+ * client coordinates are passed through unmapped and `mapped` is false, so callers know the
1258
+ * coordinates correspond to no real geometry.
1259
+ *
1260
+ * @param event - The pointer event to convert.
1261
+ * @param canvas - The canvas the event was dispatched on.
1262
+ * @returns The buffer-space coordinates, and whether they were actually mapped.
1263
+ */
1264
+ _getPickerCoordinates(event, canvas) {
1265
+ const canvasRect = canvas.getBoundingClientRect();
1266
+ if (canvasRect.width === 0 || canvasRect.height === 0) {
1267
+ return { x: event.clientX, y: event.clientY, mapped: false };
1268
+ }
1269
+ const scaleX = canvas.width / canvasRect.width;
1270
+ const scaleY = canvas.height / canvasRect.height;
1271
+ return {
1272
+ x: (event.clientX - canvasRect.left) * scaleX,
1273
+ y: (event.clientY - canvasRect.top) * scaleY,
1274
+ mapped: true
1275
+ };
1276
+ }
1277
+ /**
1278
+ * Whether a camera's viewport contains the point. A camera renders into its normalized
1279
+ * `rect`, whose origin is the bottom-left of the canvas while buffer coordinates run from
1280
+ * the top-left - so the vertical test flips, as the engine's ElementInput flips it for UI
1281
+ * input. The right and bottom edges are exclusive: a viewport rasterizes the half-open
1282
+ * pixel range [left, right) x [top, bottom), so a coordinate on a shared edge belongs to
1283
+ * the viewport whose first pixel it is - never to the one it just left, whose pick buffer
1284
+ * holds nothing there.
1285
+ *
1286
+ * @param camera - The camera to test.
1287
+ * @param x - The x coordinate, in buffer space.
1288
+ * @param y - The y coordinate, in buffer space.
1289
+ * @param canvas - The canvas the coordinates are relative to.
1290
+ * @returns Whether the camera's viewport contains the point.
1291
+ */
1292
+ _cameraContains(camera, x, y, canvas) {
1293
+ const rect = camera.rect;
1294
+ const left = rect.x * canvas.width;
1295
+ const bottom = (1 - rect.y) * canvas.height;
1296
+ const top = bottom - rect.w * canvas.height;
1297
+ return x >= left && x < left + rect.z * canvas.width && y >= top && y < bottom;
1164
1298
  }
1165
1299
  /**
1166
1300
  * Picks the scene under the pointer and returns the graph node that was hit, or `null`.
1167
1301
  *
1302
+ * The camera is resolved the way the engine's ElementInput resolves it for UI input:
1303
+ * enabled cameras are tried topmost-first (they render in ascending `priority` order),
1304
+ * skipping cameras that render to a texture and cameras whose viewport `rect` does not
1305
+ * contain the pointer. A camera that picks nothing ends the search if it clears the color
1306
+ * buffer - its background visually owns the pixel - and otherwise cedes to the cameras
1307
+ * beneath it, so an overlay camera only intercepts picks where it actually drew something.
1308
+ * The pick buffer is prepared per camera, so each camera picks from its own layers.
1309
+ *
1168
1310
  * The read back is asynchronous because the synchronous {@link Picker.getSelection} is not
1169
1311
  * supported on WebGPU, where it returns an empty selection rather than failing - which
1170
1312
  * silently disabled every `onpointer*` handler once WebGPU became the resolved backend. The
@@ -1174,16 +1316,40 @@ class AppElement extends AsyncElement {
1174
1316
  * @returns The graph node under the pointer, or `null` if nothing was hit.
1175
1317
  */
1176
1318
  async _pickNode(event) {
1177
- const camera = this.app.root.findComponent('camera');
1178
- if (!camera)
1179
- return null;
1180
- const { x, y } = this._getPickerCoordinates(event);
1181
- this._picker.prepare(camera, this.app.scene);
1182
- const selection = await this._picker.getSelectionAsync(x, y);
1183
- if (selection.length === 0)
1319
+ const app = this.app;
1320
+ const picker = this._picker;
1321
+ const canvas = this._canvas;
1322
+ if (!app || !picker || !canvas)
1184
1323
  return null;
1185
- const item = selection[0];
1186
- return item instanceof playcanvas.MeshInstance ? item.node : item.entity;
1324
+ const { x, y, mapped } = this._getPickerCoordinates(event, canvas);
1325
+ // Walked from the end: the array is sorted by ascending priority, so the last camera
1326
+ // renders last and sits on top. Read through .at() because a pick handler may remove
1327
+ // cameras while an earlier iteration's read back is in flight.
1328
+ const cameras = app.systems.camera?.cameras ?? [];
1329
+ for (let i = cameras.length - 1; i >= 0; i--) {
1330
+ const camera = cameras.at(i);
1331
+ // A camera rendering to a texture is not on the canvas.
1332
+ if (!camera || camera.renderTarget)
1333
+ continue;
1334
+ // Coordinates that could not be mapped cannot be tested for containment.
1335
+ if (mapped && !this._cameraContains(camera, x, y, canvas))
1336
+ continue;
1337
+ picker.prepare(camera, app.scene);
1338
+ const selection = await picker.getSelectionAsync(x, y);
1339
+ // The element may have disconnected while the read back was in flight.
1340
+ if (!this._picker || !this.app)
1341
+ return null;
1342
+ if (selection.length > 0) {
1343
+ const item = selection[0];
1344
+ return item instanceof playcanvas.MeshInstance ? item.node : item.entity;
1345
+ }
1346
+ // Nothing hit. A camera that clears the color buffer paints its background over
1347
+ // everything beneath it, so the miss is final; one that does not is an overlay
1348
+ // that the cameras beneath show through, so they get their turn.
1349
+ if (camera.clearColorBuffer)
1350
+ return null;
1351
+ }
1352
+ return null;
1187
1353
  }
1188
1354
  async _onPointerMove(event) {
1189
1355
  if (!this._picker || !this.app)
@@ -1370,7 +1536,7 @@ class AppElement extends AsyncElement {
1370
1536
  this._maxPixelRatio = value;
1371
1537
  if (this.app) {
1372
1538
  this.app.graphicsDevice.maxPixelRatio = value;
1373
- this.app.resizeCanvas();
1539
+ this._syncCanvasSize();
1374
1540
  }
1375
1541
  }
1376
1542
  /**
@@ -2127,6 +2293,10 @@ const processBufferView = (gltfBuffer, buffers, continuation) => {
2127
2293
  * immediately unless `lazy`. A `pc-asset` must be a direct child of `pc-app` — elements placed
2128
2294
  * elsewhere, or with an unsupported asset type, never become ready.
2129
2295
  *
2296
+ * A `lazy` asset loads on first use: the first time any element resolves it by `id` — a model,
2297
+ * a material map, a sky, a script `asset:` reference — or when the `lazy` attribute is removed,
2298
+ * whichever comes first. Until then it stays registered and unloaded.
2299
+ *
2130
2300
  * For `texture` and `textureatlas` assets, the texture options (`address-u`, `address-v`,
2131
2301
  * `min-filter`, `mag-filter`, `anisotropy`, `mipmaps`, `srgb`, `flip-y`) apply when the texture is
2132
2302
  * created and — like `lazy` — are observed: changing one updates a texture that has already
@@ -2326,7 +2496,9 @@ class AssetElement extends AsyncElement {
2326
2496
  if (type === 'sprite') {
2327
2497
  data = data ?? {};
2328
2498
  // Resolve the referenced texture atlas to its (numeric) asset id. The atlas must be
2329
- // declared before the sprite so its asset already exists in the registry.
2499
+ // declared before the sprite so its asset already exists in the registry. Resolved
2500
+ // with get, not useAsset: creation-time wiring is not a use, and the engine's
2501
+ // sprite handler loads the atlas when the sprite itself loads.
2330
2502
  const atlas = this.getAttribute('atlas') ?? data.textureAtlasAsset;
2331
2503
  if (typeof atlas === 'string') {
2332
2504
  const atlasAsset = AssetElement.get(atlas);
@@ -2487,13 +2659,18 @@ class AssetElement extends AsyncElement {
2487
2659
  return this._flipY;
2488
2660
  }
2489
2661
  /**
2490
- * Sets whether the asset should be loaded lazily.
2662
+ * Sets whether the asset should be loaded lazily. A lazy asset is registered without being
2663
+ * loaded; it loads on first use - the first time any element resolves it by `id` - or when
2664
+ * this flag is cleared on a registered asset, whichever comes first.
2491
2665
  * @param value - The lazy loading flag.
2492
2666
  */
2493
2667
  set lazy(value) {
2494
2668
  this._lazy = value;
2495
2669
  if (this.asset) {
2496
2670
  this.asset.preload = !value;
2671
+ if (!value) {
2672
+ this.asset.registry?.load(this.asset);
2673
+ }
2497
2674
  }
2498
2675
  }
2499
2676
  /**
@@ -2677,6 +2854,26 @@ class AssetElement extends AsyncElement {
2677
2854
  }
2678
2855
  }
2679
2856
  customElements.define('pc-asset', AssetElement);
2857
+ /**
2858
+ * Resolves an asset reference for use: {@link AssetElement.get}, plus starting the load of a
2859
+ * registered asset that has not begun one - a `lazy` asset. Every element that consumes assets
2860
+ * resolves its references here, which is what makes `lazy` mean load on first use without any
2861
+ * consumer having to remember the load. The load is asynchronous - callers observe the asset's
2862
+ * `load` event for the resource.
2863
+ *
2864
+ * @param id - The `id` of the `<pc-asset>` element.
2865
+ * @returns The asset, or `undefined`.
2866
+ * @internal
2867
+ */
2868
+ const useAsset = (id) => {
2869
+ const asset = AssetElement.get(id);
2870
+ // load() ignores an asset that is already loaded or loading, so repeated resolution
2871
+ // costs nothing.
2872
+ if (asset) {
2873
+ asset.registry?.load(asset);
2874
+ }
2875
+ return asset;
2876
+ };
2680
2877
 
2681
2878
  /**
2682
2879
  * Represents a component in the PlayCanvas engine.
@@ -2960,15 +3157,15 @@ class ButtonComponentElement extends ComponentElement {
2960
3157
  if (imageEntity) {
2961
3158
  data.imageEntity = imageEntity;
2962
3159
  }
2963
- const hoverSpriteAsset = AssetElement.get(this._hoverSpriteAsset);
3160
+ const hoverSpriteAsset = useAsset(this._hoverSpriteAsset);
2964
3161
  if (hoverSpriteAsset) {
2965
3162
  data.hoverSpriteAsset = hoverSpriteAsset.id;
2966
3163
  }
2967
- const pressedSpriteAsset = AssetElement.get(this._pressedSpriteAsset);
3164
+ const pressedSpriteAsset = useAsset(this._pressedSpriteAsset);
2968
3165
  if (pressedSpriteAsset) {
2969
3166
  data.pressedSpriteAsset = pressedSpriteAsset.id;
2970
3167
  }
2971
- const inactiveSpriteAsset = AssetElement.get(this._inactiveSpriteAsset);
3168
+ const inactiveSpriteAsset = useAsset(this._inactiveSpriteAsset);
2972
3169
  if (inactiveSpriteAsset) {
2973
3170
  data.inactiveSpriteAsset = inactiveSpriteAsset.id;
2974
3171
  }
@@ -3130,7 +3327,7 @@ class ButtonComponentElement extends ComponentElement {
3130
3327
  */
3131
3328
  set hoverSpriteAsset(value) {
3132
3329
  this._hoverSpriteAsset = value;
3133
- const asset = AssetElement.get(value);
3330
+ const asset = useAsset(value);
3134
3331
  if (this.component && asset) {
3135
3332
  this.component.hoverSpriteAsset = asset.id;
3136
3333
  }
@@ -3166,7 +3363,7 @@ class ButtonComponentElement extends ComponentElement {
3166
3363
  */
3167
3364
  set pressedSpriteAsset(value) {
3168
3365
  this._pressedSpriteAsset = value;
3169
- const asset = AssetElement.get(value);
3366
+ const asset = useAsset(value);
3170
3367
  if (this.component && asset) {
3171
3368
  this.component.pressedSpriteAsset = asset.id;
3172
3369
  }
@@ -3202,7 +3399,7 @@ class ButtonComponentElement extends ComponentElement {
3202
3399
  */
3203
3400
  set inactiveSpriteAsset(value) {
3204
3401
  this._inactiveSpriteAsset = value;
3205
- const asset = AssetElement.get(value);
3402
+ const asset = useAsset(value);
3206
3403
  if (this.component && asset) {
3207
3404
  this.component.inactiveSpriteAsset = asset.id;
3208
3405
  }
@@ -4060,15 +4257,15 @@ class ElementComponentElement extends ComponentElement {
4060
4257
  };
4061
4258
  // Asset references are resolved from `<pc-asset>` element ids to engine asset ids. They are
4062
4259
  // only included when they resolve, so image/group elements (with no font) don't error.
4063
- const fontAsset = AssetElement.get(this._fontAsset);
4260
+ const fontAsset = useAsset(this._fontAsset);
4064
4261
  if (fontAsset) {
4065
4262
  data.fontAsset = fontAsset.id;
4066
4263
  }
4067
- const spriteAsset = AssetElement.get(this._spriteAsset);
4264
+ const spriteAsset = useAsset(this._spriteAsset);
4068
4265
  if (spriteAsset) {
4069
4266
  data.spriteAsset = spriteAsset.id;
4070
4267
  }
4071
- const textureAsset = AssetElement.get(this._textureAsset);
4268
+ const textureAsset = useAsset(this._textureAsset);
4072
4269
  if (textureAsset) {
4073
4270
  data.textureAsset = textureAsset.id;
4074
4271
  }
@@ -4182,7 +4379,7 @@ class ElementComponentElement extends ComponentElement {
4182
4379
  */
4183
4380
  set fontAsset(value) {
4184
4381
  this._fontAsset = value;
4185
- const asset = AssetElement.get(value);
4382
+ const asset = useAsset(value);
4186
4383
  if (this.component && asset) {
4187
4384
  this.component.fontAsset = asset.id;
4188
4385
  }
@@ -4337,7 +4534,7 @@ class ElementComponentElement extends ComponentElement {
4337
4534
  */
4338
4535
  set spriteAsset(value) {
4339
4536
  this._spriteAsset = value;
4340
- const asset = AssetElement.get(value);
4537
+ const asset = useAsset(value);
4341
4538
  if (this.component && asset) {
4342
4539
  this.component.spriteAsset = asset.id;
4343
4540
  }
@@ -4389,7 +4586,7 @@ class ElementComponentElement extends ComponentElement {
4389
4586
  */
4390
4587
  set textureAsset(value) {
4391
4588
  this._textureAsset = value;
4392
- const asset = AssetElement.get(value);
4589
+ const asset = useAsset(value);
4393
4590
  if (this.component && asset) {
4394
4591
  this.component.textureAsset = asset.id;
4395
4592
  }
@@ -5624,13 +5821,14 @@ class ParticleSystemComponentElement extends ComponentElement {
5624
5821
  super('particlesystem');
5625
5822
  }
5626
5823
  getInitialComponentData() {
5627
- const asset = AssetElement.get(this._asset);
5628
- if (!asset) {
5824
+ const asset = useAsset(this._asset);
5825
+ // A lazy config has no resource yet - _loadAsset applies it once the load completes
5826
+ if (!asset || !asset.resource) {
5629
5827
  return {};
5630
5828
  }
5631
5829
  if (asset.resource.colorMapAsset) {
5632
5830
  const id = asset.resource.colorMapAsset;
5633
- const colorMapAsset = AssetElement.get(id)?.id;
5831
+ const colorMapAsset = useAsset(id)?.id;
5634
5832
  if (colorMapAsset) {
5635
5833
  asset.resource.colorMapAsset = colorMapAsset;
5636
5834
  }
@@ -5656,9 +5854,8 @@ class ParticleSystemComponentElement extends ComponentElement {
5656
5854
  }
5657
5855
  }
5658
5856
  async _loadAsset() {
5659
- const appElement = await this.closestApp?.ready();
5660
- const app = appElement?.app;
5661
- const asset = AssetElement.get(this._asset);
5857
+ await this.closestApp?.ready();
5858
+ const asset = useAsset(this._asset);
5662
5859
  if (!asset) {
5663
5860
  return;
5664
5861
  }
@@ -5669,7 +5866,6 @@ class ParticleSystemComponentElement extends ComponentElement {
5669
5866
  asset.once('load', () => {
5670
5867
  this.applyConfig(asset.resource);
5671
5868
  });
5672
- app.assets.load(asset);
5673
5869
  }
5674
5870
  }
5675
5871
  /**
@@ -6085,7 +6281,7 @@ class MaterialElement extends HTMLElement {
6085
6281
  this._scheduleUpdate();
6086
6282
  return;
6087
6283
  }
6088
- const asset = AssetElement.get(id);
6284
+ const asset = useAsset(id);
6089
6285
  if (!asset)
6090
6286
  return;
6091
6287
  if (asset.loaded) {
@@ -9186,7 +9382,7 @@ const camelToKebab = (name) => {
9186
9382
  * @returns The asset, or `raw`.
9187
9383
  */
9188
9384
  const assetConversion = (rest, raw) => {
9189
- const asset = AssetElement.get(rest);
9385
+ const asset = useAsset(rest);
9190
9386
  if (asset) {
9191
9387
  return asset;
9192
9388
  }
@@ -10006,7 +10202,7 @@ class SoundSlotElement extends AsyncElement {
10006
10202
  set asset(value) {
10007
10203
  this._asset = value;
10008
10204
  if (this.soundSlot) {
10009
- const id = AssetElement.get(value)?.id;
10205
+ const id = useAsset(value)?.id;
10010
10206
  if (id) {
10011
10207
  this.soundSlot.asset = id;
10012
10208
  }
@@ -10213,7 +10409,7 @@ class GSplatComponentElement extends ComponentElement {
10213
10409
  }
10214
10410
  getInitialComponentData() {
10215
10411
  return {
10216
- asset: AssetElement.get(this._asset),
10412
+ asset: useAsset(this._asset),
10217
10413
  castShadows: this._castShadows,
10218
10414
  lodBaseDistance: this._lodBaseDistance,
10219
10415
  lodMultiplier: this._lodMultiplier,
@@ -10234,7 +10430,7 @@ class GSplatComponentElement extends ComponentElement {
10234
10430
  */
10235
10431
  set asset(value) {
10236
10432
  this._asset = value;
10237
- const asset = AssetElement.get(value);
10433
+ const asset = useAsset(value);
10238
10434
  if (this.component && asset) {
10239
10435
  this.component.asset = asset;
10240
10436
  }
@@ -10512,8 +10708,7 @@ class ModelElement extends AsyncElement {
10512
10708
  if (generation !== this._loadGeneration) {
10513
10709
  return;
10514
10710
  }
10515
- const app = appElement.app;
10516
- const asset = AssetElement.get(this._asset);
10711
+ const asset = useAsset(this._asset);
10517
10712
  if (!asset) {
10518
10713
  // An empty id is a legitimate transient (the asset may be assigned later); a
10519
10714
  // non-empty one that resolves to nothing is a dead end - say so rather than staying
@@ -10549,7 +10744,6 @@ class ModelElement extends AsyncElement {
10549
10744
  }));
10550
10745
  this._onReady();
10551
10746
  });
10552
- app.assets.load(asset);
10553
10747
  }
10554
10748
  }
10555
10749
  _unloadModel() {
@@ -11514,7 +11708,7 @@ class SkyElement extends AsyncElement {
11514
11708
  return;
11515
11709
  }
11516
11710
  this._appElement = appElement;
11517
- const asset = AssetElement.get(this._asset);
11711
+ const asset = useAsset(this._asset);
11518
11712
  if (!asset) {
11519
11713
  return;
11520
11714
  }
@@ -11533,7 +11727,6 @@ class SkyElement extends AsyncElement {
11533
11727
  }
11534
11728
  this._generateSkybox(asset);
11535
11729
  });
11536
- app.assets.load(asset);
11537
11730
  }
11538
11731
  }
11539
11732
  _unloadSkybox() {