@playcanvas/web-components 0.10.1 → 0.11.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.
package/src/app.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  CameraComponent,
5
5
  createGraphicsDevice,
6
6
  ElementInput,
7
+ Entity,
7
8
  FILLMODE_FILL_WINDOW,
8
9
  GraphNode,
9
10
  Keyboard,
@@ -72,7 +73,7 @@ import { EntityElement } from './entity';
72
73
  import { LoadingBar } from './loading-bar';
73
74
  import { MaterialElement } from './material';
74
75
  import { ModuleElement } from './module';
75
- import { parseBool, parseEnum } from './parse';
76
+ import { parseBool, parseEnum, parseNumber } from './parse';
76
77
 
77
78
  /**
78
79
  * The AppElement interface provides properties and methods for manipulating
@@ -97,18 +98,32 @@ class AppElement extends AsyncElement {
97
98
 
98
99
  private _antialias = true;
99
100
 
100
- private _depth = true;
101
+ private _depthBuffer = true;
101
102
 
102
- private _stencil = true;
103
+ private _stencilBuffer = true;
103
104
 
104
- private _highResolution = true;
105
+ private _maxPixelRatio = Infinity;
105
106
 
106
107
  private _loadingBar = true;
107
108
 
109
+ /**
110
+ * Set once the graphics options above have been handed to `createGraphicsDevice`, after which
111
+ * writing any of them changes nothing. Guards the warning in {@link _warnIfBooted}, and is
112
+ * cleared on disconnect so a re-connected element boots from its current attributes.
113
+ */
114
+ private _optionsLocked = false;
115
+
108
116
  private _bar: LoadingBar | null = null;
109
117
 
110
118
  private _hierarchyReady = false;
111
119
 
120
+ /**
121
+ * The elements backing this application's entities, keyed by the entity itself. Registered
122
+ * by EntityElement at creation and removed when an entity is destroyed, this joins engine
123
+ * scene nodes back to their owning elements by identity - never by name.
124
+ */
125
+ private _entityElements = new Map<GraphNode, EntityElement>();
126
+
112
127
  private _picker: Picker | null = null;
113
128
 
114
129
  private _hasPointerListeners: { [key: string]: boolean } = {
@@ -192,15 +207,21 @@ class AppElement extends AsyncElement {
192
207
  };
193
208
  const deviceTypes = backendToDeviceTypes[this._backend] || [];
194
209
 
210
+ this._optionsLocked = true;
211
+
195
212
  const device = await createGraphicsDevice(this._canvas, {
196
213
  // @ts-ignore - alpha needs to be documented
197
214
  alpha: this._alpha,
198
215
  antialias: this._antialias,
199
- depth: this._depth,
216
+ depth: this._depthBuffer,
200
217
  deviceTypes: deviceTypes,
201
- stencil: this._stencil
218
+ stencil: this._stencilBuffer
202
219
  });
203
- device.maxPixelRatio = this._highResolution ? window.devicePixelRatio : 1;
220
+
221
+ // Assigned rather than resolved to a number here: the engine caps against the live
222
+ // window.devicePixelRatio on every resize, so an uncapped Infinity keeps following the
223
+ // display when a window moves between monitors of differing density.
224
+ device.maxPixelRatio = this._maxPixelRatio;
204
225
 
205
226
  const createOptions = new AppOptions();
206
227
  createOptions.graphicsDevice = device;
@@ -344,13 +365,16 @@ class AppElement extends AsyncElement {
344
365
  }
345
366
 
346
367
  disconnectedCallback() {
368
+ this._optionsLocked = false;
347
369
  this._pickerDestroy();
348
370
 
349
- // Clean up the application
371
+ // Clean up the application. Destroying it destroys every entity, whose destroy hooks
372
+ // unregister them - clear() covers any entity the engine no longer reached.
350
373
  if (this._app) {
351
374
  this._app.destroy();
352
375
  this._app = null;
353
376
  }
377
+ this._entityElements.clear();
354
378
  this._loadProgress = 0;
355
379
  this._bar?.destroy();
356
380
  this._bar = null;
@@ -427,6 +451,79 @@ class AppElement extends AsyncElement {
427
451
  };
428
452
  }
429
453
 
454
+ /**
455
+ * Registers the element that created an entity. Called by EntityElement when it creates its
456
+ * entity.
457
+ *
458
+ * @param entity - The entity.
459
+ * @param element - The element that created it.
460
+ * @ignore
461
+ */
462
+ _registerEntityElement(entity: Entity, element: EntityElement) {
463
+ this._entityElements.set(entity, element);
464
+ }
465
+
466
+ /**
467
+ * Removes the registration for a destroyed entity. Called by EntityElement.
468
+ *
469
+ * @param entity - The entity.
470
+ * @ignore
471
+ */
472
+ _unregisterEntityElement(entity: Entity) {
473
+ this._entityElements.delete(entity);
474
+ }
475
+
476
+ /**
477
+ * Returns the `<pc-entity>` element whose backing entity is `entity`, or `null` if the
478
+ * entity was not created by an element of this application - for example, a node inside a
479
+ * model's instantiated hierarchy, or an entity created through the engine API.
480
+ *
481
+ * @param entity - The entity to look up.
482
+ * @returns The element backing the entity, or `null`.
483
+ */
484
+ elementFromEntity(entity: Entity): EntityElement | null {
485
+ return this._entityElements.get(entity) ?? null;
486
+ }
487
+
488
+ /**
489
+ * Resolves the element that owns a picked node: the nearest node up the parent chain -
490
+ * starting with the node itself - that was created by a `<pc-entity>` of this application.
491
+ * A hit inside a model's instantiated hierarchy therefore resolves to the element hosting
492
+ * the model.
493
+ *
494
+ * @param node - The picked node, or `null`.
495
+ * @returns The owning element, or `null`.
496
+ */
497
+ private _elementFromNode(node: GraphNode | null): EntityElement | null {
498
+ while (node !== null) {
499
+ const element = this._entityElements.get(node);
500
+ if (element) {
501
+ return element;
502
+ }
503
+ node = node.parent;
504
+ }
505
+ return null;
506
+ }
507
+
508
+ /**
509
+ * Like {@link _elementFromNode}, but skips elements without a listener for `type`, so a hit
510
+ * on an unlistened child still reaches a listening ancestor.
511
+ *
512
+ * @param node - The picked node, or `null`.
513
+ * @param type - The pointer event type a listener is required for.
514
+ * @returns The nearest listening element, or `null`.
515
+ */
516
+ private _elementWithListener(node: GraphNode | null, type: string): EntityElement | null {
517
+ while (node !== null) {
518
+ const element = this._entityElements.get(node);
519
+ if (element?.hasListeners(type)) {
520
+ return element;
521
+ }
522
+ node = node.parent;
523
+ }
524
+ return null;
525
+ }
526
+
430
527
  // New helper to convert CSS coordinates to canvas (picker) coordinates
431
528
  private _getPickerCoordinates(event: PointerEvent): { x: number, y: number } {
432
529
  // Get the canvas' bounding rectangle in CSS pixels.
@@ -475,17 +572,9 @@ class AppElement extends AsyncElement {
475
572
  const node = await this._pickNode(event);
476
573
  if (token !== this._pickToken || !this._picker) return;
477
574
 
478
- // Get the currently hovered entity by walking up the hierarchy
479
- let newHoverEntity: EntityElement | null = null;
480
- let currentNode = node;
481
- while (currentNode !== null) {
482
- const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`) as EntityElement;
483
- if (entityElement) {
484
- newHoverEntity = entityElement;
485
- break;
486
- }
487
- currentNode = currentNode.parent;
488
- }
575
+ // The hovered element is the nearest one up the node's parent chain, listening or not -
576
+ // dispatch is gated per event type below
577
+ const newHoverEntity = this._elementFromNode(node);
489
578
 
490
579
  // Handle enter/leave events
491
580
  if (this._hoveredEntity !== newHoverEntity) {
@@ -509,16 +598,12 @@ class AppElement extends AsyncElement {
509
598
  async _onPointerDown(event: PointerEvent) {
510
599
  if (!this._picker || !this.app) return;
511
600
 
512
- let currentNode = await this._pickNode(event);
601
+ const node = await this._pickNode(event);
513
602
  if (!this._picker) return; // the element disconnected while the pick was in flight
514
603
 
515
- while (currentNode !== null) {
516
- const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`) as EntityElement;
517
- if (entityElement && entityElement.hasListeners('pointerdown')) {
518
- entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
519
- break;
520
- }
521
- currentNode = currentNode.parent;
604
+ const entityElement = this._elementWithListener(node, 'pointerdown');
605
+ if (entityElement) {
606
+ entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
522
607
  }
523
608
  }
524
609
 
@@ -526,10 +611,10 @@ class AppElement extends AsyncElement {
526
611
  if (!this._picker || !this.app) return;
527
612
 
528
613
  const node = await this._pickNode(event);
529
- if (!node || !this._picker) return;
614
+ if (!this._picker) return; // the element disconnected while the pick was in flight
530
615
 
531
- const entityElement = this.querySelector(`pc-entity[name="${node.name}"]`) as EntityElement;
532
- if (entityElement && entityElement.hasListeners('pointerup')) {
616
+ const entityElement = this._elementWithListener(node, 'pointerup');
617
+ if (entityElement) {
533
618
  entityElement.dispatchEvent(new PointerEvent('pointerup', event));
534
619
  }
535
620
  }
@@ -567,15 +652,30 @@ class AppElement extends AsyncElement {
567
652
  }
568
653
 
569
654
  /**
570
- * Sets the alpha flag.
655
+ * Warns that a graphics option was written too late to have any effect. These options are read
656
+ * once, when the element connects and creates its graphics device, so a later write updates
657
+ * only the element's own property - silently, without this.
658
+ *
659
+ * @param name - The name of the option, as its attribute.
660
+ */
661
+ private _warnIfBooted(name: string) {
662
+ if (this._optionsLocked) {
663
+ console.warn(`Attribute '${name}' on <pc-app> is only read when the application boots, so this change has no effect. Set it before the element is connected, or remove and re-insert the element to reboot with the new value.`);
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Sets whether the frame buffer has an alpha channel, which is what lets the page show through
669
+ * wherever the scene has not drawn. Read only when the application boots.
571
670
  * @param value - The alpha flag.
572
671
  */
573
672
  set alpha(value: boolean) {
673
+ this._warnIfBooted('alpha');
574
674
  this._alpha = value;
575
675
  }
576
676
 
577
677
  /**
578
- * Gets the alpha flag.
678
+ * Gets whether the frame buffer has an alpha channel.
579
679
  * @returns The alpha flag.
580
680
  */
581
681
  get alpha() {
@@ -583,15 +683,16 @@ class AppElement extends AsyncElement {
583
683
  }
584
684
 
585
685
  /**
586
- * Sets the antialias flag.
686
+ * Sets whether the frame buffer is anti-aliased. Read only when the application boots.
587
687
  * @param value - The antialias flag.
588
688
  */
589
689
  set antialias(value: boolean) {
690
+ this._warnIfBooted('antialias');
590
691
  this._antialias = value;
591
692
  }
592
693
 
593
694
  /**
594
- * Gets the antialias flag.
695
+ * Gets whether the frame buffer is anti-aliased.
595
696
  * @returns The antialias flag.
596
697
  */
597
698
  get antialias() {
@@ -600,10 +701,11 @@ class AppElement extends AsyncElement {
600
701
 
601
702
  /**
602
703
  * Sets the graphics backend. Defaults to 'webgpu', which falls back to 'webgl2' if WebGPU
603
- * is not supported by the browser.
704
+ * is not supported by the browser. Read only when the application boots.
604
705
  * @param value - The graphics backend ('webgpu', 'webgl2', or 'null').
605
706
  */
606
707
  set backend(value: 'webgpu' | 'webgl2' | 'null') {
708
+ this._warnIfBooted('backend');
607
709
  this._backend = value;
608
710
  }
609
711
 
@@ -616,19 +718,21 @@ class AppElement extends AsyncElement {
616
718
  }
617
719
 
618
720
  /**
619
- * Sets the depth flag.
620
- * @param value - The depth flag.
721
+ * Sets whether the frame buffer has a depth buffer, which the renderer needs to resolve which
722
+ * surface is nearest the camera. Read only when the application boots.
723
+ * @param value - The depth buffer flag.
621
724
  */
622
- set depth(value: boolean) {
623
- this._depth = value;
725
+ set depthBuffer(value: boolean) {
726
+ this._warnIfBooted('depth-buffer');
727
+ this._depthBuffer = value;
624
728
  }
625
729
 
626
730
  /**
627
- * Gets the depth flag.
628
- * @returns The depth flag.
731
+ * Gets whether the frame buffer has a depth buffer.
732
+ * @returns The depth buffer flag.
629
733
  */
630
- get depth() {
631
- return this._depth;
734
+ get depthBuffer() {
735
+ return this._depthBuffer;
632
736
  }
633
737
 
634
738
  /**
@@ -640,26 +744,6 @@ class AppElement extends AsyncElement {
640
744
  return this._hierarchyReady;
641
745
  }
642
746
 
643
- /**
644
- * Sets the high resolution flag. When true, the application will render at the device's
645
- * physical resolution. When false, the application will render at CSS resolution.
646
- * @param value - The high resolution flag.
647
- */
648
- set highResolution(value: boolean) {
649
- this._highResolution = value;
650
- if (this.app) {
651
- this.app.graphicsDevice.maxPixelRatio = value ? window.devicePixelRatio : 1;
652
- }
653
- }
654
-
655
- /**
656
- * Gets the high resolution flag.
657
- * @returns The high resolution flag.
658
- */
659
- get highResolution() {
660
- return this._highResolution;
661
- }
662
-
663
747
  /**
664
748
  * Sets whether the application shows its built-in loading bar while it boots and preloads its
665
749
  * assets. Enabled by default; setting `false` removes the bar immediately, while setting
@@ -686,23 +770,49 @@ class AppElement extends AsyncElement {
686
770
  }
687
771
 
688
772
  /**
689
- * Sets the stencil flag.
690
- * @param value - The stencil flag.
773
+ * Sets the cap on the pixel ratio the application renders at. The canvas is sized by the
774
+ * smaller of this value and the display's own device pixel ratio, so the default of `Infinity`
775
+ * renders at full physical resolution, `1` renders at CSS resolution, and an intermediate
776
+ * value such as `2` keeps a dense display sharp without paying for every one of its pixels.
777
+ * Must be greater than 0. Unlike the other graphics options, this applies immediately.
778
+ * @param value - The maximum pixel ratio.
779
+ */
780
+ set maxPixelRatio(value: number) {
781
+ this._maxPixelRatio = value;
782
+ if (this.app) {
783
+ this.app.graphicsDevice.maxPixelRatio = value;
784
+ this.app.resizeCanvas();
785
+ }
786
+ }
787
+
788
+ /**
789
+ * Gets the cap on the pixel ratio the application renders at.
790
+ * @returns The maximum pixel ratio.
691
791
  */
692
- set stencil(value: boolean) {
693
- this._stencil = value;
792
+ get maxPixelRatio() {
793
+ return this._maxPixelRatio;
694
794
  }
695
795
 
696
796
  /**
697
- * Gets the stencil flag.
698
- * @returns The stencil flag.
797
+ * Sets whether the frame buffer has a stencil buffer, which stencil-based effects and UI
798
+ * masking need. Read only when the application boots.
799
+ * @param value - The stencil buffer flag.
699
800
  */
700
- get stencil() {
701
- return this._stencil;
801
+ set stencilBuffer(value: boolean) {
802
+ this._warnIfBooted('stencil-buffer');
803
+ this._stencilBuffer = value;
804
+ }
805
+
806
+ /**
807
+ * Gets whether the frame buffer has a stencil buffer.
808
+ * @returns The stencil buffer flag.
809
+ */
810
+ get stencilBuffer() {
811
+ return this._stencilBuffer;
702
812
  }
703
813
 
704
814
  static get observedAttributes() {
705
- return ['alpha', 'antialias', 'backend', 'depth', 'stencil', 'high-resolution', 'loading-bar'];
815
+ return ['alpha', 'antialias', 'backend', 'depth-buffer', 'loading-bar', 'max-pixel-ratio', 'stencil-buffer'];
706
816
  }
707
817
 
708
818
  attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
@@ -716,17 +826,17 @@ class AppElement extends AsyncElement {
716
826
  case 'backend':
717
827
  this.backend = parseEnum(newValue, ['webgpu', 'webgl2', 'null'], 'webgpu', name);
718
828
  break;
719
- case 'depth':
720
- this.depth = parseBool(newValue, true);
721
- break;
722
- case 'high-resolution':
723
- this.highResolution = parseBool(newValue, true);
829
+ case 'depth-buffer':
830
+ this.depthBuffer = parseBool(newValue, true);
724
831
  break;
725
832
  case 'loading-bar':
726
833
  this.loadingBar = parseBool(newValue, true);
727
834
  break;
728
- case 'stencil':
729
- this.stencil = parseBool(newValue, true);
835
+ case 'max-pixel-ratio':
836
+ this.maxPixelRatio = parseNumber(newValue, Infinity, name);
837
+ break;
838
+ case 'stencil-buffer':
839
+ this.stencilBuffer = parseBool(newValue, true);
730
840
  break;
731
841
  }
732
842
  }
@@ -3,6 +3,11 @@ import { CameraComponent, Color, Vec4, GAMMA_NONE, GAMMA_SRGB, PROJECTION_ORTHOG
3
3
  import { ComponentElement } from './component';
4
4
  import { parseBool, parseColor, parseEnum, parseNumber, parseVec4 } from '../parse';
5
5
 
6
+ const projections = new Map<'perspective' | 'orthographic', number>([
7
+ ['perspective', PROJECTION_PERSPECTIVE],
8
+ ['orthographic', PROJECTION_ORTHOGRAPHIC]
9
+ ]);
10
+
6
11
  const tonemaps = new Map<'none' | 'linear' | 'filmic' | 'hejl' | 'aces' | 'aces2' | 'neutral', number>([
7
12
  ['none', TONEMAP_NONE],
8
13
  ['linear', TONEMAP_LINEAR],
@@ -46,7 +51,7 @@ class CameraComponentElement extends ComponentElement {
46
51
 
47
52
  private _nearClip = 0.1;
48
53
 
49
- private _orthographic = false;
54
+ private _projection: 'perspective' | 'orthographic' = 'perspective';
50
55
 
51
56
  private _orthoHeight = 10;
52
57
 
@@ -77,12 +82,12 @@ class CameraComponentElement extends ComponentElement {
77
82
  gammaCorrection: this._gamma === 'srgb' ? GAMMA_SRGB : GAMMA_NONE,
78
83
  horizontalFov: this._horizontalFov,
79
84
  nearClip: this._nearClip,
80
- projection: this._orthographic ? PROJECTION_ORTHOGRAPHIC : PROJECTION_PERSPECTIVE,
85
+ projection: projections.get(this._projection) ?? PROJECTION_PERSPECTIVE,
81
86
  orthoHeight: this._orthoHeight,
82
87
  priority: this._priority,
83
88
  rect: this._rect,
84
89
  scissorRect: this._scissorRect,
85
- toneMapping: tonemaps.get(this._tonemap)
90
+ toneMapping: tonemaps.get(this._tonemap) ?? TONEMAP_NONE
86
91
  };
87
92
  }
88
93
 
@@ -352,25 +357,6 @@ class CameraComponentElement extends ComponentElement {
352
357
  return this._nearClip;
353
358
  }
354
359
 
355
- /**
356
- * Sets the orthographic projection of the camera.
357
- * @param value - The orthographic projection.
358
- */
359
- set orthographic(value) {
360
- this._orthographic = value;
361
- if (this.component) {
362
- this.component.projection = value ? PROJECTION_ORTHOGRAPHIC : PROJECTION_PERSPECTIVE;
363
- }
364
- }
365
-
366
- /**
367
- * Gets the orthographic projection of the camera.
368
- * @returns The orthographic projection.
369
- */
370
- get orthographic(): boolean {
371
- return this._orthographic;
372
- }
373
-
374
360
  /**
375
361
  * Sets the orthographic height of the camera.
376
362
  * @param value - The orthographic height.
@@ -409,6 +395,25 @@ class CameraComponentElement extends ComponentElement {
409
395
  return this._priority;
410
396
  }
411
397
 
398
+ /**
399
+ * Sets the projection of the camera. Use `orthoHeight` to size an orthographic projection.
400
+ * @param value - The projection ('perspective' or 'orthographic').
401
+ */
402
+ set projection(value: 'perspective' | 'orthographic') {
403
+ this._projection = value;
404
+ if (this.component) {
405
+ this.component.projection = projections.get(value) ?? PROJECTION_PERSPECTIVE;
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Gets the projection of the camera.
411
+ * @returns The projection.
412
+ */
413
+ get projection() {
414
+ return this._projection;
415
+ }
416
+
412
417
  /**
413
418
  * Sets the rect of the camera.
414
419
  * @param value - The rect.
@@ -481,9 +486,9 @@ class CameraComponentElement extends ComponentElement {
481
486
  'gamma',
482
487
  'horizontal-fov',
483
488
  'near-clip',
484
- 'orthographic',
485
489
  'ortho-height',
486
490
  'priority',
491
+ 'projection',
487
492
  'rect',
488
493
  'scissor-rect',
489
494
  'tonemap'
@@ -530,15 +535,15 @@ class CameraComponentElement extends ComponentElement {
530
535
  case 'near-clip':
531
536
  this.nearClip = parseNumber(newValue, 0.1, name);
532
537
  break;
533
- case 'orthographic':
534
- this.orthographic = parseBool(newValue, false);
535
- break;
536
538
  case 'ortho-height':
537
539
  this.orthoHeight = parseNumber(newValue, 10, name);
538
540
  break;
539
541
  case 'priority':
540
542
  this.priority = parseNumber(newValue, 0, name);
541
543
  break;
544
+ case 'projection':
545
+ this.projection = parseEnum(newValue, projections, 'perspective', name);
546
+ break;
542
547
  case 'rect':
543
548
  this.rect = parseVec4(newValue, new Vec4(0, 0, 1, 1), name);
544
549
  break;
@@ -1,7 +1,16 @@
1
1
  import { SCALEMODE_BLEND, SCALEMODE_NONE, ScreenComponent, Vec2 } from 'playcanvas';
2
2
 
3
3
  import { ComponentElement } from './component';
4
- import { parseBool, parseNumber, parseVec2 } from '../parse';
4
+ import { parseBool, parseEnum, parseNumber, parseVec2 } from '../parse';
5
+
6
+ // The engine's SCALEMODE_* constants are the strings 'none' and 'blend', so this map happens to be
7
+ // an identity. It is still the right shape: it supplies parseEnum's valid-name list, it is what the
8
+ // manifest generator reads the enum values from, and it keeps the attribute vocabulary independent
9
+ // of constants the engine is free to change.
10
+ const scaleModes = new Map<'none' | 'blend', string>([
11
+ ['none', SCALEMODE_NONE],
12
+ ['blend', SCALEMODE_BLEND]
13
+ ]);
5
14
 
6
15
  /**
7
16
  * The ScreenComponentElement interface provides properties and methods for manipulating
@@ -20,7 +29,7 @@ class ScreenComponentElement extends ComponentElement {
20
29
 
21
30
  private _priority = 0;
22
31
 
23
- private _blend = false;
32
+ private _scaleMode: 'none' | 'blend' = 'none';
24
33
 
25
34
  private _scaleBlend = 0.5;
26
35
 
@@ -35,7 +44,7 @@ class ScreenComponentElement extends ComponentElement {
35
44
  referenceResolution: this._referenceResolution,
36
45
  resolution: this._resolution,
37
46
  scaleBlend: this._scaleBlend,
38
- scaleMode: this._blend ? SCALEMODE_BLEND : SCALEMODE_NONE,
47
+ scaleMode: scaleModes.get(this._scaleMode) ?? SCALEMODE_NONE,
39
48
  screenSpace: this._screenSpace
40
49
  };
41
50
  }
@@ -81,6 +90,12 @@ class ScreenComponentElement extends ComponentElement {
81
90
  return this._resolution;
82
91
  }
83
92
 
93
+ /**
94
+ * Sets how the screen's `resolution` and `referenceResolution` are weighted against each other
95
+ * when `scaleMode` is `blend`, from 0 (follow the resolution) to 1 (follow the reference
96
+ * resolution). Ignored while `scaleMode` is `none`.
97
+ * @param value - The scale blend factor.
98
+ */
84
99
  set scaleBlend(value: number) {
85
100
  this._scaleBlend = value;
86
101
  if (this.component) {
@@ -88,19 +103,34 @@ class ScreenComponentElement extends ComponentElement {
88
103
  }
89
104
  }
90
105
 
106
+ /**
107
+ * Gets how the screen's resolutions are weighted against each other.
108
+ * @returns The scale blend factor.
109
+ */
91
110
  get scaleBlend() {
92
111
  return this._scaleBlend;
93
112
  }
94
113
 
95
- set blend(value: boolean) {
96
- this._blend = value;
114
+ /**
115
+ * Sets how the screen scales its contents. `none` renders at `resolution` and ignores
116
+ * `referenceResolution`; `blend` scales between the two, weighted by `scaleBlend`, which is what
117
+ * keeps a UI laid out at one resolution usable at another. Requires `screenSpace` - the engine
118
+ * forces `none` on a world-space screen, which does not support scaling.
119
+ * @param value - The scale mode ('none' or 'blend').
120
+ */
121
+ set scaleMode(value: 'none' | 'blend') {
122
+ this._scaleMode = value;
97
123
  if (this.component) {
98
- this.component.scaleMode = this._blend ? SCALEMODE_BLEND : SCALEMODE_NONE;
124
+ this.component.scaleMode = scaleModes.get(value) ?? SCALEMODE_NONE;
99
125
  }
100
126
  }
101
127
 
102
- get blend() {
103
- return this._blend;
128
+ /**
129
+ * Gets how the screen scales its contents.
130
+ * @returns The scale mode.
131
+ */
132
+ get scaleMode() {
133
+ return this._scaleMode;
104
134
  }
105
135
 
106
136
  set screenSpace(value: boolean) {
@@ -117,12 +147,12 @@ class ScreenComponentElement extends ComponentElement {
117
147
  static get observedAttributes() {
118
148
  return [
119
149
  ...super.observedAttributes,
120
- 'blend',
121
150
  'screen-space',
122
151
  'resolution',
123
152
  'reference-resolution',
124
153
  'priority',
125
- 'scale-blend'
154
+ 'scale-blend',
155
+ 'scale-mode'
126
156
  ];
127
157
  }
128
158
 
@@ -142,8 +172,8 @@ class ScreenComponentElement extends ComponentElement {
142
172
  case 'scale-blend':
143
173
  this.scaleBlend = parseNumber(newValue, 0.5, name);
144
174
  break;
145
- case 'blend':
146
- this.blend = parseBool(newValue, false);
175
+ case 'scale-mode':
176
+ this.scaleMode = parseEnum(newValue, scaleModes, 'none', name);
147
177
  break;
148
178
  case 'screen-space':
149
179
  this.screenSpace = parseBool(newValue, false);