phoria2 1.0.0 → 1.2.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 (76) hide show
  1. package/README.md +71 -13
  2. package/dist/bloom.js +1 -1
  3. package/dist/charts.js +397 -384
  4. package/dist/charts.js.map +1 -1
  5. package/dist/core.js +4451 -3568
  6. package/dist/core.js.map +1 -1
  7. package/dist/edit.js +67 -67
  8. package/dist/edit.js.map +1 -1
  9. package/dist/explain.js +6 -2
  10. package/dist/explain.js.map +1 -1
  11. package/dist/gltf.js +211 -166
  12. package/dist/gltf.js.map +1 -1
  13. package/dist/motion.js +26 -26
  14. package/dist/motion.js.map +1 -1
  15. package/dist/particles.js +49 -49
  16. package/dist/particles.js.map +1 -1
  17. package/dist/passes.js +188 -86
  18. package/dist/passes.js.map +1 -1
  19. package/dist/phoria2.js +1 -1
  20. package/dist/picking.js +8 -13
  21. package/dist/picking.js.map +1 -1
  22. package/dist/snapshot.js +1 -1
  23. package/dist/tooltip.js +3 -3
  24. package/dist/tooltip.js.map +1 -1
  25. package/dist/types/OrbitControl.d.ts +6 -2
  26. package/dist/types/Raycaster.d.ts +10 -0
  27. package/dist/types/View.d.ts +64 -1
  28. package/dist/types/charts/Chart.d.ts +7 -0
  29. package/dist/types/explain.d.ts +3 -0
  30. package/dist/types/frame.d.ts +7 -0
  31. package/dist/types/geometry/Geometry.d.ts +14 -2
  32. package/dist/types/keyboard.d.ts +48 -0
  33. package/dist/types/labels.d.ts +4 -0
  34. package/dist/types/material/Material.d.ts +125 -0
  35. package/dist/types/material/Texture.d.ts +37 -5
  36. package/dist/types/scene/Node.d.ts +19 -14
  37. package/dist/types/scene/drawables.d.ts +32 -14
  38. package/dist/types/scene/lights.d.ts +16 -2
  39. package/dist/types/shadow.d.ts +2 -1
  40. package/dist/types/types.d.ts +14 -0
  41. package/dist/types/value/Color.d.ts +0 -4
  42. package/dist/types/value/Quat.d.ts +4 -2
  43. package/dist/types/webgl/WebGLRenderer.d.ts +6 -0
  44. package/dist/types/webgl/passes.d.ts +37 -13
  45. package/dist/types/webgl/resources.d.ts +24 -2
  46. package/dist/types/webgl/shaders.d.ts +4 -2
  47. package/llms-full.txt +153 -15
  48. package/package.json +4 -1
  49. package/schema/scene.schema.json +146 -1
  50. package/src/OrbitControl.js +7 -3
  51. package/src/Raycaster.js +58 -0
  52. package/src/View.js +207 -42
  53. package/src/charts/Chart.js +20 -2
  54. package/src/explain.js +6 -2
  55. package/src/frame.js +29 -9
  56. package/src/geometry/Geometry.js +24 -4
  57. package/src/gltf/load.js +50 -6
  58. package/src/keyboard.js +298 -0
  59. package/src/labels.js +4 -1
  60. package/src/material/Material.js +104 -19
  61. package/src/material/Texture.js +116 -11
  62. package/src/scene/Node.js +20 -9
  63. package/src/scene/Scene.js +2 -0
  64. package/src/scene/cameras.js +4 -1
  65. package/src/scene/drawables.js +48 -12
  66. package/src/scene/lights.js +40 -6
  67. package/src/shadow.js +27 -4
  68. package/src/types.js +6 -0
  69. package/src/value/Color.js +21 -0
  70. package/src/value/Quat.js +15 -6
  71. package/src/watch.js +25 -1
  72. package/src/webgl/WebGLRenderer.js +51 -12
  73. package/src/webgl/passes.js +190 -32
  74. package/src/webgl/picking.js +0 -1
  75. package/src/webgl/resources.js +89 -8
  76. package/src/webgl/shaders.js +350 -48
@@ -12,7 +12,7 @@ import { NODE_OPTIONS, Node } from './Node.js'
12
12
  /** @import { Material, MaterialOptions } from '../material/Material.js' */
13
13
  /** @import { GeometryOptions } from '../geometry/Geometry.js' */
14
14
  /** @import { NodeOptions } from './Node.js' */
15
- /** @import { ColorInput, Vec3Input } from '../types.js' */
15
+ /** @import { ColorInput, QuatInput, Vec3Input } from '../types.js' */
16
16
 
17
17
  /**
18
18
  * @typedef {NodeOptions & {
@@ -45,11 +45,14 @@ import { NODE_OPTIONS, Node } from './Node.js'
45
45
  * @property {'left' | 'right' | null} [edge] set the label at the view's left or right edge, level with its
46
46
  * point - callouts in a column down the side of a diagram, with leader: true. offset then moves it in from
47
47
  * the edge and up or down (default null: by its point)
48
+ * @property {number} [minScale] show the label only when zoomed in this far: at least this many CSS pixels to
49
+ * one world unit, at the label's point - details that appear as you come closer (default 0: always)
48
50
  */
49
51
  /** @typedef {'center' | 'above' | 'below' | 'left' | 'right'} LabelPlace */
50
52
 
51
53
  /**
52
- * One label per instance or point, from its row of data.
54
+ * One label per instance or point, from its row of data - or a list of these, for several each (a symbol
55
+ * always, its details when zoomed in).
53
56
  * @typedef {Omit<LabelPlacement, 'priority'> & {
54
57
  * text: (datum: any, index: number) => string | number | null | undefined,
55
58
  * priority?: number | ((datum: any, index: number) => number),
@@ -61,7 +64,7 @@ import { NODE_OPTIONS, Node } from './Node.js'
61
64
  */
62
65
 
63
66
  export const LABEL_PLACES = /** @type {LabelPlace[]} */ (['center', 'above', 'below', 'left', 'right'])
64
- const PLACEMENT_OPTIONS = ['place', 'offset', 'className', 'priority', 'overlap', 'occlude', 'leader', 'edge']
67
+ const PLACEMENT_OPTIONS = ['place', 'offset', 'className', 'priority', 'overlap', 'occlude', 'leader', 'edge', 'minScale']
65
68
  const LABEL_HINTS = {
66
69
  line: 'use leader: true - a line from the point to the label',
67
70
  callout: "use leader: true, with edge: 'left' | 'right' to line the labels up down the side",
@@ -70,6 +73,8 @@ const LABEL_HINTS = {
70
73
  pointer: 'use leader: true - a line from the point to the label',
71
74
  anchor: "use place: 'center' | 'above' | 'below' | 'left' | 'right' (where the label goes)",
72
75
  zIndex: "use 'priority': higher stays when labels overlap",
76
+ minZoom: "use 'minScale': CSS pixels to one world unit at the label's point",
77
+ lod: "use 'minScale': the label shows when zoomed in at least this far (CSS pixels to one world unit)",
73
78
  collision: "use overlap: 'hide' | 'dim' | 'show'",
74
79
  }
75
80
  // labels per instance or point take one more: they can't be moved anywhere but by offset
@@ -94,12 +99,26 @@ function placement(owner, o) {
94
99
  occlude: !!o.occlude,
95
100
  leader: !!o.leader,
96
101
  edge: o.edge == null ? null : /** @type {'left' | 'right'} */ (checkOneOf(owner, 'edge', o.edge, ['left', 'right'])),
102
+ minScale: o.minScale === undefined ? 0 : minScale(owner, o.minScale),
97
103
  }
98
104
  }
99
105
 
100
- /** @param {string} owner @param {unknown} v @returns {(InstanceLabels & Required<Omit<LabelPlacement, 'priority'>> & { priority: number | ((datum: any, index: number) => number) }) | null} */
106
+ /** @param {string} owner @param {unknown} v @returns {number} */
107
+ function minScale(owner, v) {
108
+ const n = checkNumber(owner, 'minScale', v)
109
+ if (!(n >= 0)) fail(owner, `'minScale' is CSS pixels to one world unit, 0 or more, got ${n}`)
110
+ return n
111
+ }
112
+
113
+ /** @typedef {InstanceLabels & Required<Omit<LabelPlacement, 'priority'>> & { priority: number | ((datum: any, index: number) => number) }} CheckedLabels */
114
+
115
+ /** @param {string} owner @param {unknown} v @returns {CheckedLabels | CheckedLabels[] | null} */
101
116
  function instanceLabels(owner, v) {
102
117
  if (v == null) return null
118
+ if (Array.isArray(v)) {
119
+ if (!v.length) fail(owner, "'labels' as a list needs at least one { text: (datum, index) => string, ... }")
120
+ return v.map((one) => /** @type {CheckedLabels} */ (instanceLabels(owner, one)))
121
+ }
103
122
  if (typeof v !== 'object' || typeof (/** @type {any} */ (v)).text !== 'function') {
104
123
  fail(owner, `'labels' must be { text: (datum, index) => string, ... }, got ${describe(v)}`)
105
124
  }
@@ -253,7 +272,7 @@ export class Mesh extends Drawable {
253
272
  * @typedef {object} InstanceOptions
254
273
  * @property {Vec3Input} [position]
255
274
  * @property {Vec3Input | Euler} [rotation] Euler angles in radians, XYZ order
256
- * @property {ArrayLike<number>} [quaternion]
275
+ * @property {QuatInput} [quaternion] [x, y, z, w] or { x, y, z, w } - as physics engines give them
257
276
  * @property {Vec3Input | number} [scale]
258
277
  * @property {ColorInput} [color] multiplies the material colour
259
278
  */
@@ -261,11 +280,23 @@ export class Mesh extends Drawable {
261
280
  const INSTANCE_OPTIONS = new Set(['position', 'rotation', 'quaternion', 'scale', 'color'])
262
281
  // reused by setInstance, so updating every instance of a large mesh each frame allocates nothing
263
282
  const scratch = { position: new Vec3(), scale: new Vec3(), quaternion: new Quat(), rotation: new Euler() }
283
+ const SCRATCH_COLOR = new Color()
264
284
  const scratchMatrix = new Float32Array(16)
265
285
 
286
+ /**
287
+ * True when an instance is scaled to nothing - hidden: it draws nothing, takes no room in its mesh's
288
+ * bounds and shows no label.
289
+ * @internal @param {ArrayLike<number>} m instance matrices @param {number} i
290
+ */
291
+ export function hiddenInstance(m, i) {
292
+ const o = i * 16
293
+ return !m[o] && !m[o + 1] && !m[o + 2] && !m[o + 4] && !m[o + 5] && !m[o + 6] && !m[o + 8] && !m[o + 9] && !m[o + 10]
294
+ }
295
+
266
296
  /**
267
297
  * Many copies of one geometry and material, drawn together. Set each copy with setInstance().
268
- * Picking reports which instance was hit.
298
+ * Picking reports which instance was hit. An instance scaled to 0 is hidden - it draws nothing, and
299
+ * framing, culling, shadows and labels leave it out - so a pool of instances can keep spares.
269
300
  */
270
301
  export class InstancedMesh extends Mesh {
271
302
  static type = 'instanced-mesh'
@@ -279,12 +310,12 @@ export class InstancedMesh extends Mesh {
279
310
  instanceColors = null
280
311
  /** @internal Bumped when instance data changes, so renderers re-upload. */
281
312
  version = 0
282
- /** A label per instance, from its row of data. @type {ReturnType<typeof instanceLabels>} */
313
+ /** A label per instance from its row of data - or a list of them, several per instance. @type {ReturnType<typeof instanceLabels>} */
283
314
  labels = null
284
315
 
285
316
  /**
286
317
  * @param {DrawableOptions & { count?: number, instances?: InstanceOptions[], instanceMatrices?: ArrayLike<number>,
287
- * instanceColors?: ArrayLike<number>, labels?: InstanceLabels | null }} options count defaults to the number
318
+ * instanceColors?: ArrayLike<number>, labels?: InstanceLabels | InstanceLabels[] | null }} options count defaults to the number
288
319
  * of instances or rows of data; instanceMatrices/instanceColors are raw data, as toJSON() writes
289
320
  */
290
321
  constructor(options) {
@@ -375,7 +406,7 @@ export class InstancedMesh extends Mesh {
375
406
  /** @param {number} i @param {ColorInput} color */
376
407
  #setColor(i, color) {
377
408
  if (!this.instanceColors) this.instanceColors = new Float32Array(this.instanceMatrices.length / 16 * 3).fill(1)
378
- this.instanceColors.set(Color.fromInput(color, 'InstancedMesh'), i * 3)
409
+ this.instanceColors.set(SCRATCH_COLOR.set(color, 'InstancedMesh'), i * 3)
379
410
  }
380
411
 
381
412
  /** @internal @param {Box3} box */
@@ -401,6 +432,7 @@ export class InstancedMesh extends Mesh {
401
432
  const ex = (local.max[0] - local.min[0]) / 2, ey = (local.max[1] - local.min[1]) / 2, ez = (local.max[2] - local.min[2]) / 2
402
433
  const w = this.worldMatrix, im = this.instanceMatrices, m = scratchMatrix
403
434
  for (let i = 0; i < this.count; i++) {
435
+ if (hiddenInstance(im, i)) continue
404
436
  mat4.multiply(m, w, im.subarray(i * 16, i * 16 + 16))
405
437
  const x = m[0] * cx + m[4] * cy + m[8] * cz + m[12]
406
438
  const y = m[1] * cx + m[5] * cy + m[9] * cz + m[13]
@@ -436,10 +468,10 @@ export class Points extends Drawable {
436
468
  static type = 'points'
437
469
  static get OPTIONS() { return [...DRAWABLE_OPTIONS, 'labels'] }
438
470
 
439
- /** A label per point, from its row of data. @type {ReturnType<typeof instanceLabels>} */
471
+ /** A label per point from its row of data - or a list of them, several per point. @type {ReturnType<typeof instanceLabels>} */
440
472
  labels = null
441
473
 
442
- /** @param {DrawableOptions & { labels?: InstanceLabels | null }} options */
474
+ /** @param {DrawableOptions & { labels?: InstanceLabels | InstanceLabels[] | null }} options */
443
475
  constructor(options) {
444
476
  super(options)
445
477
  this.labels = instanceLabels(this.constructor.name, options?.labels)
@@ -505,6 +537,8 @@ export class Label extends Node {
505
537
  leader = false
506
538
  /** Set at the view's left or right edge, level with its point, or null for by its point. @type {'left' | 'right' | null} */
507
539
  edge = null
540
+ /** Shown only when zoomed in this far: CSS pixels to one world unit at its point; 0 for always. */
541
+ minScale = 0
508
542
 
509
543
  /** @param {LabelOptions} options */
510
544
  constructor(options) {
@@ -529,7 +563,8 @@ export class Label extends Node {
529
563
  /** @internal @returns {Record<string, unknown>} */
530
564
  cloneOptions() {
531
565
  return { ...super.cloneOptions(), text: this.text, className: this.className, place: this.place,
532
- offset: [...this.offset], priority: this.priority, overlap: this.overlap, occlude: this.occlude, leader: this.leader, edge: this.edge }
566
+ offset: [...this.offset], priority: this.priority, overlap: this.overlap, occlude: this.occlude, leader: this.leader, edge: this.edge,
567
+ minScale: this.minScale }
533
568
  }
534
569
 
535
570
  /** @internal @returns {Record<string, unknown>} */
@@ -544,6 +579,7 @@ export class Label extends Node {
544
579
  if (this.occlude) json.occlude = true
545
580
  if (this.leader) json.leader = true
546
581
  if (this.edge) json.edge = this.edge
582
+ if (this.minScale) json.minScale = this.minScale
547
583
  return json
548
584
  }
549
585
  }
@@ -1,10 +1,11 @@
1
1
  import { Color } from '../value/Color.js'
2
2
  import { Vec3 } from '../value/Vec3.js'
3
- import { checkNumber, fail } from '../validate.js'
3
+ import { Box3 } from '../value/Box3.js'
4
+ import { checkNumber, describe, fail } from '../validate.js'
4
5
  import { NODE_OPTIONS, Node, inParentSpace, setPoint } from './Node.js'
5
6
 
6
7
  /** @import { NodeOptions } from './Node.js' */
7
- /** @import { ColorInput, Vec3Input } from '../types.js' */
8
+ /** @import { Box3Input, ColorInput, Vec3Input } from '../types.js' */
8
9
 
9
10
  /** @typedef {NodeOptions & { color?: ColorInput, intensity?: number }} LightOptions */
10
11
  /** @typedef {'ambient' | 'hemisphere' | 'directional' | 'point' | 'spot'} LightKind */
@@ -189,24 +190,57 @@ export class DirectionalLight extends AimedLight {
189
190
  static type = 'directional-light'
190
191
  /** @internal @type {LightKind} */
191
192
  static kind = 'directional'
192
- static get OPTIONS() { return [...LIGHT_OPTIONS, 'direction', 'target', 'castShadow'] }
193
+ static get OPTIONS() { return [...LIGHT_OPTIONS, 'direction', 'target', 'castShadow', 'shadowBounds'] }
193
194
 
194
195
  castShadow = false
196
+ /** @type {Box3 | null} */
197
+ #shadowBounds = null
195
198
 
196
- /** @param {AimedLightOptions & { castShadow?: boolean }} [options] */
199
+ /** @param {AimedLightOptions & { castShadow?: boolean, shadowBounds?: Box3Input | null }} [options] */
197
200
  constructor(options = {}) {
198
201
  super(options)
199
202
  if (options.castShadow !== undefined) this.castShadow = !!options.castShadow
203
+ if (options.shadowBounds !== undefined) this.#setShadowBounds(options.shadowBounds)
204
+ }
205
+
206
+ /**
207
+ * The part of the world, in world units, that has shadows - or null (the default) to follow
208
+ * whatever casts them. The shadow map is fitted to what casts every frame, so one caster flung far
209
+ * off spreads its texels over everything and every shadow blurs; set the area where things happen
210
+ * and its shadows stay sharp. Casters outside it still shadow it, from wherever they stand.
211
+ * Assigning copies.
212
+ * @returns {Box3 | null}
213
+ */
214
+ get shadowBounds() { return this.#shadowBounds }
215
+
216
+ /** @param {Box3Input | null} v */
217
+ set shadowBounds(v) { this.#setShadowBounds(v) }
218
+
219
+ /** @param {Box3Input | null} v */
220
+ #setShadowBounds(v) {
221
+ if (v === null) {
222
+ this.#shadowBounds = null
223
+ return
224
+ }
225
+ const b = /** @type {any} */ (v)
226
+ if (!b || typeof b !== 'object' || !b.min || !b.max) {
227
+ fail(this.constructor.name, `'shadowBounds' is a box - { min: [x, y, z], max: [x, y, z] } or a Box3 - or null, got ${describe(v)}`)
228
+ }
229
+ const box = new Box3(Vec3.fromInput(b.min, this.constructor.name), Vec3.fromInput(b.max, this.constructor.name))
230
+ if (box.isEmpty()) fail(this.constructor.name, `'shadowBounds' min must not be above max on any axis, got ${describe(v)}`)
231
+ this.#shadowBounds = box
200
232
  }
201
233
 
202
234
  /** @internal @returns {Record<string, unknown>} */
203
235
  cloneOptions() {
204
- return { ...super.cloneOptions(), castShadow: this.castShadow }
236
+ return { ...super.cloneOptions(), castShadow: this.castShadow, shadowBounds: this.#shadowBounds }
205
237
  }
206
238
 
207
239
  /** @internal @returns {Record<string, unknown>} */
208
240
  jsonOptions() {
209
- return { ...super.jsonOptions(), ...(this.castShadow ? { castShadow: true } : {}) }
241
+ const b = this.#shadowBounds
242
+ return { ...super.jsonOptions(), ...(this.castShadow ? { castShadow: true } : {}),
243
+ ...(b ? { shadowBounds: { min: Array.from(b.min), max: Array.from(b.max) } } : {}) }
210
244
  }
211
245
  }
212
246
 
package/src/shadow.js CHANGED
@@ -23,9 +23,10 @@ export const SHADOW_MAP_SIZE = 2048
23
23
  * @param {Box3[]} casters world bounds
24
24
  * @param {Box3[]} receivers world bounds
25
25
  * @param {number} [size] map size in texels
26
+ * @param {Box3 | null} [area] the light's shadowBounds: the map covers this across, whatever casts
26
27
  * @returns {ShadowFit | null}
27
28
  */
28
- export function fitShadow(direction, casters, receivers, size = SHADOW_MAP_SIZE) {
29
+ export function fitShadow(direction, casters, receivers, size = SHADOW_MAP_SIZE, area = null) {
29
30
  const boxes = casters.filter((b) => !b.isEmpty())
30
31
  if (!boxes.length) return null
31
32
  const d = normalized(direction)
@@ -50,7 +51,10 @@ export function fitShadow(direction, casters, receivers, size = SHADOW_MAP_SIZE)
50
51
  if (z > z1) z1 = z
51
52
  }
52
53
  }
53
- for (const b of boxes) corners(b, true)
54
+ // across the light's view: the area it was given, or else the casters; in depth, everything that
55
+ // casts - so a caster outside the area, between it and the light, still shadows it
56
+ if (area) corners(area, true)
57
+ for (const b of boxes) corners(b, !area)
54
58
  // receivers only stretch the depth (view z is negative in front, so the far end is the minimum)
55
59
  for (const b of receivers) if (!b.isEmpty()) corners(b, false)
56
60
 
@@ -58,10 +62,29 @@ export function fitShadow(direction, casters, receivers, size = SHADOW_MAP_SIZE)
58
62
  const margin = Math.max(x1 - x0, y1 - y0, 1e-3) * 0.02
59
63
  x0 -= margin; x1 += margin; y0 -= margin; y1 += margin
60
64
  const depthMargin = Math.max(z1 - z0, 1e-3) * 0.01
61
- const projection = mat4.ortho(mat4.create(), x0, x1, y0, y1, -z1 - depthMargin, -z0 + depthMargin)
65
+ z0 -= depthMargin; z1 += depthMargin
66
+
67
+ // steady while things move: the map grows and shrinks in small steps rather than with every change
68
+ // in the casters' extent, and its edges sit on the texel grid, so a shadow that isn't moving falls
69
+ // on the same texels every frame, instead of shimmering as the fit slides about under it
70
+ const w = steps(x1 - x0), h = steps(y1 - y0)
71
+ const tx = w / size, ty = h / size
72
+ x0 = Math.floor((x0 + x1 - w) / 2 / tx) * tx
73
+ y0 = Math.floor((y0 + y1 - h) / 2 / ty) * ty
74
+ // the depth range too, which sets what each stored depth means
75
+ const dz = steps(z1 - z0) / 64
76
+ z0 = Math.floor(z0 / dz) * dz
77
+ z1 = Math.ceil(z1 / dz) * dz
78
+
79
+ const projection = mat4.ortho(mat4.create(), x0, x0 + w, y0, y0 + h, -z1, -z0)
62
80
  const viewProjection = new Mat4()
63
81
  mat4.multiply(viewProjection, projection, view)
64
- return { viewProjection, texelSize: Math.max(x1 - x0, y1 - y0) / size }
82
+ return { viewProjection, texelSize: Math.max(w, h) / size }
83
+ }
84
+
85
+ /** A length rounded up to the next of a ladder of sizes, sixteen to each doubling. @param {number} v */
86
+ function steps(v) {
87
+ return 2 ** (Math.ceil(Math.log2(Math.max(v, 1e-3)) * 16) / 16)
65
88
  }
66
89
 
67
90
  /** Spot shadows' map: four tiles of half its size, one a light. */
package/src/types.js CHANGED
@@ -9,6 +9,12 @@
9
9
 
10
10
  /** A vector as accepted on input: [x, y, z], { x, y, z } or a Vec3. @typedef {Vec3Like | XYZ} Vec3Input */
11
11
 
12
+ /** A quaternion as an object literal - as physics engines such as Rapier return them. @typedef {{ x: number, y: number, z: number, w: number }} XYZW */
13
+
14
+ /** A rotation quaternion as accepted on input: [x, y, z, w], { x, y, z, w } or a Quat. @typedef {ArrayLike<number> | XYZW} QuatInput */
15
+
16
+ /** A box as accepted on input: a Box3, or its lowest and highest corners. @typedef {import('./value/Box3.js').Box3 | { min: Vec3Input, max: Vec3Input }} Box3Input */
17
+
12
18
  /**
13
19
  * A colour as accepted on input, in sRGB: '#ff8800', '#f80', 0xff8800, 'rgb(255 136 0)',
14
20
  * 'hsl(32 100% 50%)', a CSS colour name, or [r, g, b] with components 0-1.
@@ -16,6 +16,11 @@ const HELP = "use '#rrggbb', a CSS name, 'rgb(...)', 'hsl(...)', 0xrrggbb or [r,
16
16
  * An RGB colour. Input is sRGB, as everywhere in phoria2; the components are stored **linear**,
17
17
  * ready for lighting maths. `toJSON()` gives sRGB '#rrggbb' back.
18
18
  */
19
+ // colour strings already worked out, as linear rgb - emptied when full, as a palette never fills it
20
+ /** @type {Map<string, number[]>} */
21
+ const PARSED = new Map()
22
+ const PARSED_MAX = 1024
23
+
19
24
  export class Color extends Float32Array {
20
25
  static get [Symbol.species]() { return Float32Array }
21
26
 
@@ -154,6 +159,22 @@ export class Color extends Float32Array {
154
159
 
155
160
  /** @param {string} s @param {string} owner */
156
161
  #setString(s, owner) {
162
+ // the same few strings come again and again - an instance's colour set every frame - so each is
163
+ // worked out once
164
+ const known = PARSED.get(s)
165
+ if (known) {
166
+ this[0] = known[0]
167
+ this[1] = known[1]
168
+ this[2] = known[2]
169
+ return
170
+ }
171
+ this.#parse(s, owner)
172
+ if (PARSED.size >= PARSED_MAX) PARSED.clear()
173
+ PARSED.set(s, [this[0], this[1], this[2]])
174
+ }
175
+
176
+ /** @param {string} s @param {string} owner */
177
+ #parse(s, owner) {
157
178
  const lower = s.toLowerCase()
158
179
  if (lower.startsWith('#')) {
159
180
  let hex = lower.slice(1)
package/src/value/Quat.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import * as quat from '../math/quat.js'
2
2
  import { describe, fail } from '../validate.js'
3
3
 
4
+ /** @import { QuatInput } from '../types.js' */
5
+
4
6
  /**
5
7
  * A rotation quaternion [x, y, z, w]: a Float32Array(4) with accessors and chainable methods.
6
8
  * A node's quaternion and its Euler `rotation` keep each other in sync through these setters;
@@ -61,13 +63,20 @@ export class Quat extends Float32Array {
61
63
  return this.changed()
62
64
  }
63
65
 
64
- /** @param {ArrayLike<number>} q [x, y, z, w] @param {string} [owner] @returns {this} */
66
+ /** @param {QuatInput} q [x, y, z, w], or { x, y, z, w } as physics engines give them @param {string} [owner] @returns {this} */
65
67
  copy(q, owner = 'Quat') {
66
- if (!q || typeof q !== 'object' || !(q.length >= 4)) fail(owner, `expected a quaternion [x, y, z, w], got ${describe(q)}`)
67
- this[0] = q[0]
68
- this[1] = q[1]
69
- this[2] = q[2]
70
- this[3] = q[3]
68
+ const o = /** @type {any} */ (q)
69
+ if (o && typeof o === 'object' && o.length >= 4) {
70
+ this[0] = o[0]
71
+ this[1] = o[1]
72
+ this[2] = o[2]
73
+ this[3] = o[3]
74
+ } else if (o && typeof o === 'object' && 'x' in o && 'y' in o && 'z' in o && 'w' in o) {
75
+ this[0] = o.x
76
+ this[1] = o.y
77
+ this[2] = o.z
78
+ this[3] = o.w
79
+ } else fail(owner, `expected a quaternion [x, y, z, w] or { x, y, z, w }, got ${describe(q)}`)
71
80
  return this.changed()
72
81
  }
73
82
 
package/src/watch.js CHANGED
@@ -123,7 +123,7 @@ export class ChangeWatch {
123
123
  this.#push((node.handlersFor('click').length ? 1 : 0) | (node.handlersFor('hover').length ? 2 : 0) | (node.tooltip ? 4 : 0) | (node.onDrag ? 8 : 0) | (node.highlightWhole ? 16 : 0))
124
124
  // what hovering tints it with - a different colour shows straight away on what is hovered
125
125
  if (node.highlight) this.#pushAll(node.highlight)
126
- else this.#push(-4)
126
+ else this.#push(node.highlight === false ? -5 : -4)
127
127
  const outline = node.outline
128
128
  if (outline) {
129
129
  this.#pushAll(outline.color)
@@ -169,6 +169,22 @@ export class ChangeWatch {
169
169
  this.#push(m.aoMapIntensity)
170
170
  this.#push(m.triplanar)
171
171
  this.#push(m.bumpScale)
172
+ this.#push(m.transmission)
173
+ this.#push(m.clearcoat)
174
+ this.#push(m.clearcoatRoughness)
175
+ this.#push(m.sheen)
176
+ this.#pushAll(m.sheenColor)
177
+ this.#push(m.sheenRoughness)
178
+ this.#push(m.ior)
179
+ this.#push(m.specularIntensity)
180
+ this.#pushAll(m.specularColor)
181
+ this.#push(m.anisotropy)
182
+ this.#push(m.anisotropyRotation)
183
+ this.#push(m.translucency)
184
+ this.#push(m.toon)
185
+ this.#push(m.lightMapIntensity)
186
+ this.#push(m.environmentIntensity)
187
+ this.#pushAll(m.translucencyColor)
172
188
  this.#push((m.unlit ? 1 : 0) | (m.flatShading ? 2 : 0) | (m.doubleSided ? 4 : 0) | (m.vertexColors ? 8 : 0) |
173
189
  (m.depthWrite ? 16 : 0) | (m.depthTest ? 32 : 0) | (m.sizeAttenuation ? 64 : 0) | (m.blending === 'additive' ? 128 : m.blending === 'multiply' ? 1024 : 0) | (m.transparent ? 256 : 0) | (m.fog ? 512 : 0))
174
190
  for (const [, texture] of m.textures) this.#texture(texture)
@@ -179,6 +195,9 @@ export class ChangeWatch {
179
195
  this.#push(idOf(texture))
180
196
  this.#push(texture.version)
181
197
  this.#pushAll(texture.repeat)
198
+ this.#push(texture.rotation)
199
+ this.#pushAll(texture.center)
200
+ this.#push(texture.uvSet)
182
201
  this.#pushAll(texture.offset)
183
202
  }
184
203
 
@@ -189,6 +208,7 @@ export class ChangeWatch {
189
208
  this.#push(label.priority)
190
209
  this.#push(label.occlude ? 1 : 0)
191
210
  this.#push(label.leader ? 1 : 0)
211
+ this.#push(label.minScale)
192
212
  }
193
213
 
194
214
  /** @param {any} light */
@@ -199,6 +219,10 @@ export class ChangeWatch {
199
219
  if (light.direction) this.#pushAll(light.direction)
200
220
  if (light.target) this.#pushAll(light.target)
201
221
  if (light.castShadow) this.#push(1)
222
+ if (light.shadowBounds) {
223
+ this.#pushAll(light.shadowBounds.min)
224
+ this.#pushAll(light.shadowBounds.max)
225
+ }
202
226
  if ('range' in light) this.#push(light.range)
203
227
  if ('angle' in light) {
204
228
  this.#push(light.angle)
@@ -6,6 +6,7 @@ import { CLIP_ONLY_FRAGMENT, MESH_VERTEX } from './shaders.js'
6
6
  import { collect } from '../frame.js'
7
7
  import { checkNumber, checkOneOf, checkOptions, checkRange, describe, fail, warnOnce } from '../validate.js'
8
8
  import { GLContext } from './context.js'
9
+ import { uvMatrix } from './resources.js'
9
10
 
10
11
  /** @import { FrameDescription, DrawItem, FrameLight, FrameCap } from '../frame.js' */
11
12
  /** @import { Scene } from '../scene/Scene.js' */
@@ -32,6 +33,7 @@ import { GLContext } from './context.js'
32
33
  * this canvas, instead of giving the canvas a context of its own - for pages with many views
33
34
  * (browsers allow about 16 live contexts). The View decides this; see its 'context' option.
34
35
  * @property {boolean} [antialias] multisampling (default true; a shared context always has it)
36
+ * @property {boolean} [outlineAntialias] soft-edged outlines with round corners (default: as antialias)
35
37
  * @property {ToneMapping} [toneMapping] how lit colours are mapped to the screen: 'neutral' keeps colours
36
38
  * true (Khronos PBR Neutral, the default), 'aces' is filmic, 'none' clips
37
39
  * @property {number} [exposure] (default 1)
@@ -39,7 +41,7 @@ import { GLContext } from './context.js'
39
41
  * @property {boolean | BloomOptions} [bloom] glow round bright parts (default off); loads its code the first time
40
42
  */
41
43
 
42
- const OPTIONS = ['canvas', 'shared', 'antialias', 'toneMapping', 'exposure', 'pixelRatio', 'bloom']
44
+ const OPTIONS = ['canvas', 'shared', 'antialias', 'outlineAntialias', 'toneMapping', 'exposure', 'pixelRatio', 'bloom']
43
45
  const BLOOM = { strength: 1, radius: 0.6, threshold: 0.6 }
44
46
  /** What bloom: { ... } takes, and hints for names people reach for instead. */
45
47
  export const BLOOM_OPTIONS = Object.keys(BLOOM)
@@ -66,10 +68,10 @@ const TONE_MAPPING = { none: 0, aces: 1, neutral: 2 }
66
68
  // material maps and the shader feature each one switches on (all but emissive need lighting)
67
69
  /** @type {import('./passes.js').ShadowMap[]} */
68
70
  const NO_SHADOWS = []
69
- const MAP_DEFINES = { roughnessMap: 'ROUGHNESS_MAP', metalnessMap: 'METALNESS_MAP', normalMap: 'NORMAL_MAP', bumpMap: 'BUMP_MAP', aoMap: 'AO_MAP' }
71
+ const MAP_DEFINES = { roughnessMap: 'ROUGHNESS_MAP', metalnessMap: 'METALNESS_MAP', normalMap: 'NORMAL_MAP', bumpMap: 'BUMP_MAP', aoMap: 'AO_MAP', lightMap: 'LIGHT_MAP' }
70
72
  // every map a mesh can sample - triplanar projection is only worth its varyings when there is one
71
73
  const MAP_SLOTS = ['map', 'emissiveMap', ...Object.keys(MAP_DEFINES)]
72
- const MAP_UNIFORMS = { map: 'uMap', roughnessMap: 'uRoughnessMap', metalnessMap: 'uMetalnessMap', normalMap: 'uNormalMap', bumpMap: 'uBumpMap', aoMap: 'uAoMap', emissiveMap: 'uEmissiveMap' }
74
+ const MAP_UNIFORMS = { matcap: 'uMatcap', map: 'uMap', roughnessMap: 'uRoughnessMap', metalnessMap: 'uMetalnessMap', normalMap: 'uNormalMap', bumpMap: 'uBumpMap', aoMap: 'uAoMap', emissiveMap: 'uEmissiveMap', lightMap: 'uLightMap' }
73
75
  // counting a cap's faces into the stencil: the solids' surfaces, colour and depth left alone
74
76
  const CLIP_MASK = /** @type {[string, string]} */ ([MESH_VERTEX, CLIP_ONLY_FRAGMENT])
75
77
  // a cap: a square on the plane, two units across, facing +Y until it is placed
@@ -96,6 +98,8 @@ export class WebGLRenderer {
96
98
  /** @type {ToneMapping} */ toneMapping = 'neutral'
97
99
  exposure = 1
98
100
  pixelRatio = 1
101
+ /** Outlines with soft edges and round corners, or hard pixel steps; takes effect on the next frame. */
102
+ outlineAntialias = true
99
103
  /** @type {Required<BloomOptions> | null} */ #bloom = null
100
104
  /** Counts from the last frame and the GPU caches. */
101
105
  info = { drawCalls: 0, triangles: 0, programs: 0, geometries: 0, textures: 0, compiling: 0 }
@@ -129,6 +133,8 @@ export class WebGLRenderer {
129
133
  if (options.exposure !== undefined) this.exposure = checkRange('WebGLRenderer', 'exposure', options.exposure, 0)
130
134
  if (options.pixelRatio !== undefined) this.pixelRatio = checkRange('WebGLRenderer', 'pixelRatio', options.pixelRatio, 0, Infinity, { above: true })
131
135
  this.#bloom = bloomSettings('WebGLRenderer', options.bloom)
136
+ // outlines follow the picture's antialiasing unless told otherwise (a shared context always has it)
137
+ this.outlineAntialias = options.outlineAntialias !== undefined ? !!options.outlineAntialias : options.shared || options.antialias !== false
132
138
 
133
139
  if (options.shared) {
134
140
  this.#gpu = GLContext.shared()
@@ -230,7 +236,7 @@ export class WebGLRenderer {
230
236
  frame.pointShadow && passes.pointShadowMap(frame.pointShadow),
231
237
  ].filter((s) => !!s)
232
238
  // outlines' mask, drawn before the scene, so the scene's own target need not be left
233
- const outline = passes && frame.outlines.length ? passes.outlineMask(frame.outlines, frame, width, height, this.pixelRatio) : null
239
+ const outline = passes && frame.outlines.length ? passes.outlineMask(frame.outlines, frame, width, height, this.pixelRatio, this.outlineAntialias) : null
234
240
 
235
241
  // with bloom, the scene is drawn into a buffer of its own first (drawn plainly while its code loads)
236
242
  const bloom = this.#bloom ? gpu.loadBloom() : null
@@ -268,7 +274,7 @@ export class WebGLRenderer {
268
274
  gl.enable(gl.BLEND)
269
275
  for (const item of frame.transparent) this.#draw(item, frame, true)
270
276
  // outlines over everything
271
- if (outline && passes) passes.outlineComposite(outline, height)
277
+ if (outline && passes) passes.outlineComposite(outline)
272
278
  gl.depthMask(true)
273
279
  gl.disable(gl.BLEND)
274
280
  gl.bindVertexArray(null)
@@ -400,7 +406,7 @@ export class WebGLRenderer {
400
406
  const shadows = receives ? this.#shadows : NO_SHADOWS
401
407
 
402
408
  const clip = kind === 'mesh' ? item.clip : null
403
- const defines = this.#defines(kind, material, geometry.colors !== null, !!instances)
409
+ const defines = this.#defines(kind, material, geometry.colors !== null, !!instances, geometry.uvs2 !== null)
404
410
  for (const shadow of shadows) defines.push(shadow.define)
405
411
  const fog = material.fog ? frame.fog : null
406
412
  if (fog) {
@@ -477,15 +483,38 @@ export class WebGLRenderer {
477
483
  if (material.bumpMap) program.set('uBumpScale', material.bumpScale)
478
484
  if (material.triplanar > 0) program.set('uTriplanar', material.triplanar)
479
485
  program.set('uAoMapIntensity', material.aoMapIntensity)
486
+ if (material.transmission > 0) program.set('uTransmission', material.transmission)
487
+ if (material.clearcoat > 0) {
488
+ program.set('uClearcoat', material.clearcoat)
489
+ program.set('uClearcoatRoughness', material.clearcoatRoughness)
490
+ }
491
+ // a non-metal's reflectance face on, from its ior and specular settings, and at grazing angles
492
+ const ior = (material.ior - 1) / (material.ior + 1), sc = material.specularColor, si = material.specularIntensity
493
+ program.set('uSpecular', [Math.min(ior * ior * sc[0], 1) * si, Math.min(ior * ior * sc[1], 1) * si, Math.min(ior * ior * sc[2], 1) * si, si])
494
+ if (material.sheen > 0) {
495
+ const c = material.sheenColor, k = material.sheen
496
+ program.set('uSheenColor', [c[0] * k, c[1] * k, c[2] * k])
497
+ program.set('uSheenRoughness', material.sheenRoughness)
498
+ }
499
+ if (material.anisotropy > 0) {
500
+ program.set('uAnisotropy', material.anisotropy)
501
+ program.set('uAnisotropyDirection', [Math.cos(material.anisotropyRotation), Math.sin(material.anisotropyRotation)])
502
+ }
503
+ if (material.translucency > 0) {
504
+ const c = material.translucencyColor, k = material.translucency
505
+ program.set('uTranslucency', [c[0] * k, c[1] * k, c[2] * k, k])
506
+ }
507
+ if (material.toon > 0) program.set('uToon', Math.max(2, Math.round(material.toon)))
480
508
  program.set('uAlphaTest', material.alphaTest)
481
509
  /** @type {[string, Texture][]} */
482
510
  const maps = kind === 'mesh' ? material.textures : material.map ? [['map', material.map]] : []
483
511
  // GL textures first (uploads use a unit of their own), then bind them to the samplers
484
512
  const bound = maps.map(([slot, texture]) => [MAP_UNIFORMS[slot], this.#gpu.resources.texture(texture)])
485
513
  for (const [name, texture] of bound) program.set(name, texture)
486
- // one UV transform for every map: the first map's (glTF models share theirs)
487
- const first = maps[0]?.[1]
488
- program.set('uUvTransform', first ? [first.repeat[0], first.repeat[1], first.offset[0], first.offset[1]] : [1, 1, 0, 0])
514
+ // each map's own repeat, rotation and offset (a matcap takes no UVs)
515
+ for (const [slot, texture] of maps) if (slot !== 'matcap') program.set(`${MAP_UNIFORMS[slot]}Uv`, uvMatrix(texture))
516
+ if (material.lightMap) program.set('uLightMapIntensity', material.lightMapIntensity)
517
+ program.set('uEnvStrength', material.environmentIntensity)
489
518
 
490
519
  // render state
491
520
  gl.depthMask(!transparent && material.depthWrite)
@@ -535,7 +564,7 @@ export class WebGLRenderer {
535
564
  // drawing's outline - shows whole rather than fighting it pixel by pixel
536
565
  gl.enable(gl.POLYGON_OFFSET_FILL)
537
566
  gl.polygonOffset(1, 2)
538
- this.info.triangles += this.#gpu.resources.drawTriangles(geometry, instances)
567
+ this.info.triangles += this.#gpu.resources.drawTriangles(geometry, instances, material.anisotropy > 0 && !material.unlit)
539
568
  gl.disable(gl.POLYGON_OFFSET_FILL)
540
569
  this.info.drawCalls++
541
570
  }
@@ -613,10 +642,10 @@ export class WebGLRenderer {
613
642
 
614
643
  /**
615
644
  * @param {'mesh' | 'points' | 'lines'} kind @param {Material} material
616
- * @param {boolean} hasColors @param {boolean} instanced
645
+ * @param {boolean} hasColors @param {boolean} instanced @param {boolean} hasUvs2
617
646
  * @returns {string[]}
618
647
  */
619
- #defines(kind, material, hasColors, instanced) {
648
+ #defines(kind, material, hasColors, instanced, hasUvs2) {
620
649
  const d = []
621
650
  if (material.vertexColors && hasColors) d.push('VERTEX_COLORS')
622
651
  if (kind === 'mesh') {
@@ -626,6 +655,13 @@ export class WebGLRenderer {
626
655
  d.push(...this.#lightDefines)
627
656
  if (this.#environment) d.push('ENV_MAP')
628
657
  else if (this.#studio) d.push('STUDIO')
658
+ if (material.transmission > 0) d.push('TRANSMISSION')
659
+ if (material.clearcoat > 0) d.push('CLEARCOAT')
660
+ if (material.sheen > 0) d.push('SHEEN')
661
+ if (material.anisotropy > 0) d.push('ANISOTROPY')
662
+ if (material.translucency > 0) d.push('TRANSLUCENCY')
663
+ if (material.matcap) d.push('MATCAP')
664
+ if (material.toon > 0) d.push('TOON')
629
665
  }
630
666
  if (material.map) d.push('MAP')
631
667
  if (material.emissiveMap) d.push('EMISSIVE_MAP')
@@ -633,6 +669,9 @@ export class WebGLRenderer {
633
669
  if (material.flatShading) d.push('FLAT_SHADING')
634
670
  if (material.doubleSided) d.push('DOUBLE_SIDED')
635
671
  if (material.triplanar > 0 && MAP_SLOTS.some((slot) => material[slot])) d.push('TRIPLANAR')
672
+ // maps on the second UV set read it - where the geometry has one
673
+ const second = hasUvs2 ? material.textures.filter(([slot, t]) => t.uvSet === 1 && slot !== 'matcap') : []
674
+ if (second.length) d.push('UV2', ...second.map(([slot]) => `${MAP_UNIFORMS[slot]}Set vUv2`))
636
675
  if (instanced) d.push('INSTANCED')
637
676
  } else if (kind === 'points') {
638
677
  if (material.map) d.push('MAP')