phoria2 1.0.0 → 1.1.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 (46) hide show
  1. package/README.md +6 -6
  2. package/dist/bloom.js +4 -4
  3. package/dist/bloom.js.map +1 -1
  4. package/dist/charts.js +377 -377
  5. package/dist/charts.js.map +1 -1
  6. package/dist/core.js +1052 -558
  7. package/dist/core.js.map +1 -1
  8. package/dist/edit.js +48 -48
  9. package/dist/edit.js.map +1 -1
  10. package/dist/explain.js +1 -1
  11. package/dist/gltf.js +207 -162
  12. package/dist/gltf.js.map +1 -1
  13. package/dist/motion.js +27 -27
  14. package/dist/motion.js.map +1 -1
  15. package/dist/particles.js +50 -50
  16. package/dist/particles.js.map +1 -1
  17. package/dist/passes.js +199 -97
  18. package/dist/passes.js.map +1 -1
  19. package/dist/phoria2.js +1 -1
  20. package/dist/picking.js +16 -21
  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/View.d.ts +5 -0
  26. package/dist/types/geometry/Geometry.d.ts +6 -0
  27. package/dist/types/material/Material.d.ts +125 -0
  28. package/dist/types/material/Texture.d.ts +37 -5
  29. package/dist/types/webgl/WebGLRenderer.d.ts +6 -0
  30. package/dist/types/webgl/passes.d.ts +37 -13
  31. package/dist/types/webgl/resources.d.ts +24 -2
  32. package/dist/types/webgl/shaders.d.ts +4 -2
  33. package/llms-full.txt +96 -7
  34. package/package.json +1 -1
  35. package/schema/scene.schema.json +127 -0
  36. package/src/View.js +4 -2
  37. package/src/geometry/Geometry.js +8 -1
  38. package/src/gltf/load.js +50 -6
  39. package/src/material/Material.js +104 -19
  40. package/src/material/Texture.js +116 -11
  41. package/src/watch.js +19 -0
  42. package/src/webgl/WebGLRenderer.js +51 -12
  43. package/src/webgl/passes.js +190 -32
  44. package/src/webgl/picking.js +0 -1
  45. package/src/webgl/resources.js +89 -8
  46. package/src/webgl/shaders.js +350 -48
@@ -5,6 +5,7 @@ import * as mat4 from '../math/mat4.js'
5
5
  import { irradianceSH } from '../irradiance.js'
6
6
  import { POINT_SHADOW_SIZE, SHADOW_MAP_SIZE, SPOT_ATLAS_SIZE } from '../shadow.js'
7
7
  import { CLIP, CLIP_ONLY_FRAGMENT, COMMON, EQUIRECT, FULLSCREEN_VERTEX, MESH_VERTEX, TONE_MAP } from './shaders.js'
8
+ import { uvMatrix } from './resources.js'
8
9
 
9
10
  /** @import { Programs } from './programs.js' */
10
11
  /** @import { Resources } from './resources.js' */
@@ -154,9 +155,98 @@ void main() {
154
155
  fragColor = vec4(linearToSrgb(ring.rgb), 1.0);
155
156
  }
156
157
  `
158
+ // Antialiased outlines. The mask is drawn multisampled, with a second target saying how much of
159
+ // each pixel it covers; once resolved, the mask holds colour and width times that coverage. The
160
+ // ring is then measured from the shape's edge, not from masked pixels' middles - a pixel's edge is
161
+ // its coverage less a half beyond its middle - and in true distance, so corners come out round. Its
162
+ // outer edge fades over a pixel, and its inner edge leaves the shape's share of each pixel alone.
163
+ const OUTLINE_COVER_FRAGMENT = /* glsl */ `
164
+ precision highp float;
165
+ in vec3 vWorldPosition;
166
+ uniform vec4 uMask;
167
+ layout(location = 0) out vec4 fragColor;
168
+ layout(location = 1) out vec4 fragCoverage;
169
+ ${CLIP}
170
+ void main() {
171
+ clipAway(vWorldPosition);
172
+ fragColor = uMask;
173
+ fragCoverage = vec4(1.0);
174
+ }
175
+ `
176
+ const SMOOTH = /* glsl */ `
177
+ uniform sampler2D uMask;
178
+ uniform sampler2D uCoverage;
179
+ uniform vec2 uTexel;
180
+ uniform int uRadius;
181
+ // a masked pixel's colour and width, from the resolved mask and its coverage
182
+ vec4 masked(vec2 uv, float coverage) {
183
+ vec4 m = texture(uMask, uv) / coverage;
184
+ return vec4(m.rgb, m.a * ${OUTLINE_MAX}.0);
185
+ }
186
+ `
187
+ // across: each pixel notes which masked pixel along its row has the nearest edge, as an offset
188
+ const OUTLINE_SMOOTH_WIDEN_FRAGMENT = /* glsl */ `
189
+ precision highp float;
190
+ in vec2 vUv;
191
+ out vec4 fragColor;
192
+ ${SMOOTH}
193
+ void main() {
194
+ float nearest = 1e9;
195
+ int at = 0;
196
+ bool found = false;
197
+ for (int i = -${OUTLINE_MAX + 1}; i <= ${OUTLINE_MAX + 1}; i++) {
198
+ if (abs(i) > uRadius + 1) continue;
199
+ vec2 uv = vUv + vec2(float(i) * uTexel.x, 0.0);
200
+ float c = texture(uCoverage, uv).r;
201
+ if (c <= 0.0) continue;
202
+ float d = abs(float(i)) - (c - 0.5);
203
+ if (d < masked(uv, c).a + 0.5 && d < nearest) {
204
+ nearest = d;
205
+ at = i;
206
+ found = true;
207
+ }
208
+ }
209
+ fragColor = found ? vec4(float(at + 32) / 255.0, 0.0, 0.0, 1.0) : vec4(0.0);
210
+ }
211
+ `
212
+ // down, over the scene: the nearest of the rows' nearest, in true distance, faded at both edges
213
+ const OUTLINE_SMOOTH_COMPOSITE_FRAGMENT = /* glsl */ `
214
+ precision highp float;
215
+ in vec2 vUv;
216
+ uniform sampler2D uNearest;
217
+ out vec4 fragColor;
218
+ ${COMMON}
219
+ ${SMOOTH}
220
+ void main() {
221
+ float here = texture(uCoverage, vUv).r;
222
+ if (here >= 1.0) discard;
223
+ float nearest = 1e9;
224
+ vec4 ring = vec4(0.0);
225
+ for (int j = -${OUTLINE_MAX + 1}; j <= ${OUTLINE_MAX + 1}; j++) {
226
+ if (abs(j) > uRadius + 1) continue;
227
+ vec2 row = vUv + vec2(0.0, float(j) * uTexel.y);
228
+ vec4 n = texture(uNearest, row);
229
+ if (n.a <= 0.0) continue;
230
+ int i = int(n.r * 255.0 + 0.5) - 32;
231
+ vec2 uv = row + vec2(float(i) * uTexel.x, 0.0);
232
+ float c = texture(uCoverage, uv).r;
233
+ float d = length(vec2(float(i), float(j))) - (c - 0.5);
234
+ if (d < nearest) {
235
+ nearest = d;
236
+ ring = masked(uv, c);
237
+ }
238
+ }
239
+ float alpha = clamp(ring.a - nearest + 0.5, 0.0, 1.0) * (1.0 - here);
240
+ if (alpha <= 0.0) discard;
241
+ fragColor = vec4(linearToSrgb(ring.rgb) * alpha, alpha);
242
+ }
243
+ `
157
244
  const OUTLINE_MASK = /** @type {[string, string]} */ ([MESH_VERTEX, OUTLINE_MASK_FRAGMENT])
158
245
  const OUTLINE_WIDEN = /** @type {[string, string]} */ ([FULLSCREEN_VERTEX, OUTLINE_WIDEN_FRAGMENT])
159
246
  const OUTLINE_COMPOSITE = /** @type {[string, string]} */ ([FULLSCREEN_VERTEX, OUTLINE_COMPOSITE_FRAGMENT])
247
+ const OUTLINE_COVER = /** @type {[string, string]} */ ([MESH_VERTEX, OUTLINE_COVER_FRAGMENT])
248
+ const OUTLINE_SMOOTH_WIDEN = /** @type {[string, string]} */ ([FULLSCREEN_VERTEX, OUTLINE_SMOOTH_WIDEN_FRAGMENT])
249
+ const OUTLINE_SMOOTH_COMPOSITE = /** @type {[string, string]} */ ([FULLSCREEN_VERTEX, OUTLINE_SMOOTH_COMPOSITE_FRAGMENT])
160
250
 
161
251
  // shadow maps only need depth - of what isn't cut away
162
252
  const DEPTH = /** @type {[string, string]} */ ([MESH_VERTEX, CLIP_ONLY_FRAGMENT])
@@ -183,12 +273,19 @@ const ENV_LEVELS = 6
183
273
  * @typedef {{ define: string, uniforms: Record<string, unknown>, sampler: string, texture: WebGLTexture }} ShadowMap
184
274
  */
185
275
 
276
+ /**
277
+ * @typedef {{ texture: WebGLTexture, framebuffer: WebGLFramebuffer }} Target
278
+ * @typedef {{ width: number, height: number, smooth: boolean, mask: Target, wide: Target, coverage: Target | null,
279
+ * msaa: { framebuffer: WebGLFramebuffer, renderbuffers: WebGLRenderbuffer[] } | null }} OutlineTargets
280
+ * @typedef {{ mask: WebGLTexture, wide: WebGLTexture, coverage: WebGLTexture | null, radius: number, width: number, height: number }} OutlineMask
281
+ */
282
+
186
283
  export class Passes {
187
284
  /** @type {WeakMap<Texture, Environment>} */ #environments = new WeakMap()
188
285
  /** @type {{ texture: WebGLTexture, framebuffer: WebGLFramebuffer } | null} */ #shadow = null
189
286
  /** Spot lights' shadows, a tile each. @type {{ texture: WebGLTexture, framebuffer: WebGLFramebuffer } | null} */ #spotShadow = null
190
287
  /** A point light's shadow: a depth cube, a framebuffer a face. @type {{ texture: WebGLTexture, framebuffers: WebGLFramebuffer[] } | null} */ #pointShadow = null
191
- /** Outlines' mask and its widened copy. @type {{ width: number, height: number, mask: { texture: WebGLTexture, framebuffer: WebGLFramebuffer }, wide: { texture: WebGLTexture, framebuffer: WebGLFramebuffer } } | null} */ #outline = null
288
+ /** Outlines' mask and its widened copy. @type {OutlineTargets | null} */ #outline = null
192
289
  /** Rendering to float textures, which environments need. */
193
290
  floatTargets = false
194
291
 
@@ -413,6 +510,8 @@ export class Passes {
413
510
  // read the map as the drawn surface reads it - projected, for a triplanar one
414
511
  const triplanar = cutout && !!material.map && material.triplanar > 0
415
512
  if (cutout) defines.push('ALPHA_TEST', ...(material.map ? ['MAP'] : []), ...(triplanar ? ['TRIPLANAR'] : []))
513
+ // a map on the second UV set reads it here too
514
+ if (cutout && material.map?.uvSet === 1 && item.geometry.uvs2) defines.push('UV2', 'uMapSet vUv2')
416
515
  const program = this.programs.ready('depth', defines, DEPTH)
417
516
  gl.useProgram(program.program)
418
517
  program.set('uViewProjection', viewProjection)
@@ -422,8 +521,10 @@ export class Passes {
422
521
  program.set('uOpacity', material.opacity)
423
522
  program.set('uAlphaTest', material.alphaTest)
424
523
  const map = material.map
425
- program.set('uUvTransform', map ? [map.repeat[0], map.repeat[1], map.offset[0], map.offset[1]] : [1, 1, 0, 0])
426
- if (map) program.set('uMap', this.resources.texture(map))
524
+ if (map) {
525
+ program.set('uMapUv', uvMatrix(map))
526
+ program.set('uMap', this.resources.texture(map))
527
+ }
427
528
  if (triplanar) program.set('uTriplanar', material.triplanar)
428
529
  }
429
530
  this.resources.drawTriangles(item.geometry, instances)
@@ -434,15 +535,19 @@ export class Passes {
434
535
  * The first half of drawing outlines, before the scene: a mask of the outlined subtrees - each
435
536
  * pixel they cover in their colour, with the line's width in alpha - then that mask widened
436
537
  * sideways. outlineComposite() widens it up and down and draws the ring left round each mask.
538
+ * Smooth outlines draw the mask multisampled, with how much of each pixel it covers, and measure
539
+ * the ring from the shape's edge; otherwise each pixel is in or out.
437
540
  * @param {FrameOutline[]} outlines @param {FrameDescription} frame
438
541
  * @param {number} width @param {number} height drawing buffer pixels
439
542
  * @param {number} pixelRatio
440
- * @returns {{ mask: WebGLTexture, wide: WebGLTexture, radius: number }}
543
+ * @param {boolean} smooth antialiased: soft edges and round corners
544
+ * @returns {OutlineMask}
441
545
  */
442
- outlineMask(outlines, frame, width, height, pixelRatio) {
546
+ outlineMask(outlines, frame, width, height, pixelRatio, smooth) {
443
547
  const gl = this.gl
444
- const targets = this.#outlineTargets(width, height)
445
- gl.bindFramebuffer(gl.FRAMEBUFFER, targets.mask.framebuffer)
548
+ const targets = this.#outlineTargets(width, height, smooth)
549
+ gl.bindFramebuffer(gl.FRAMEBUFFER, targets.msaa ? targets.msaa.framebuffer : targets.mask.framebuffer)
550
+ if (targets.msaa) gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1])
446
551
  gl.viewport(0, 0, width, height)
447
552
  gl.clearColor(0, 0, 0, 0)
448
553
  gl.clear(gl.COLOR_BUFFER_BIT)
@@ -457,7 +562,7 @@ export class Passes {
457
562
  for (const item of outline.items) {
458
563
  const defines = item.instances ? ['INSTANCED'] : []
459
564
  if (item.clip) defines.push('CLIP')
460
- const program = this.programs.ready('outline-mask', defines, OUTLINE_MASK)
565
+ const program = smooth ? this.programs.ready('outline-cover', defines, OUTLINE_COVER) : this.programs.ready('outline-mask', defines, OUTLINE_MASK)
461
566
  gl.useProgram(program.program)
462
567
  program.set('uViewProjection', frame.camera.viewProjection)
463
568
  program.set('uModel', item.worldMatrix)
@@ -466,35 +571,62 @@ export class Passes {
466
571
  this.resources.drawTriangles(item.geometry, item.instances)
467
572
  }
468
573
  }
574
+ // the samples resolved: the mask into its texture, the coverage into its own
575
+ if (targets.msaa && targets.coverage) {
576
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, targets.msaa.framebuffer)
577
+ for (const [attachment, target] of /** @type {[number, Target][]} */ ([[gl.COLOR_ATTACHMENT0, targets.mask], [gl.COLOR_ATTACHMENT1, targets.coverage]])) {
578
+ gl.readBuffer(attachment)
579
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, target.framebuffer)
580
+ gl.blitFramebuffer(0, 0, width, height, 0, 0, width, height, gl.COLOR_BUFFER_BIT, gl.NEAREST)
581
+ }
582
+ }
469
583
  // widened sideways
470
584
  gl.bindFramebuffer(gl.FRAMEBUFFER, targets.wide.framebuffer)
471
585
  gl.clear(gl.COLOR_BUFFER_BIT)
472
- const widen = this.programs.ready('outline-widen', [], OUTLINE_WIDEN)
473
- gl.useProgram(widen.program)
474
- widen.set('uSource', targets.mask.texture)
475
- widen.set('uStep', [1 / width, 0])
476
- widen.set('uRadius', radius)
586
+ if (targets.coverage) {
587
+ const widen = this.programs.ready('outline-smooth-widen', [], OUTLINE_SMOOTH_WIDEN)
588
+ gl.useProgram(widen.program)
589
+ widen.set('uMask', targets.mask.texture)
590
+ widen.set('uCoverage', targets.coverage.texture)
591
+ widen.set('uTexel', [1 / width, 1 / height])
592
+ widen.set('uRadius', radius)
593
+ } else {
594
+ const widen = this.programs.ready('outline-widen', [], OUTLINE_WIDEN)
595
+ gl.useProgram(widen.program)
596
+ widen.set('uSource', targets.mask.texture)
597
+ widen.set('uStep', [1 / width, 0])
598
+ widen.set('uRadius', radius)
599
+ }
477
600
  this.programs.drawFullscreen()
478
601
  gl.depthMask(true)
479
602
  gl.enable(gl.DEPTH_TEST)
480
603
  gl.bindFramebuffer(gl.FRAMEBUFFER, null)
481
- return { mask: targets.mask.texture, wide: targets.wide.texture, radius }
604
+ return { mask: targets.mask.texture, wide: targets.wide.texture, coverage: targets.coverage?.texture ?? null, radius, width, height }
482
605
  }
483
606
 
484
607
  /**
485
608
  * Draw the outlines over the finished scene, into whatever is bound: the sideways-widened mask
486
609
  * widened up and down too, less the mask itself, so a ring is left round each subtree.
487
- * @param {{ mask: WebGLTexture, wide: WebGLTexture, radius: number }} outline
488
- * @param {number} height drawing buffer pixels
610
+ * @param {OutlineMask} outline
489
611
  */
490
- outlineComposite(outline, height) {
612
+ outlineComposite(outline) {
491
613
  const gl = this.gl
492
- const program = this.programs.ready('outline-composite', [], OUTLINE_COMPOSITE)
493
- gl.useProgram(program.program)
494
- program.set('uSource', outline.wide)
495
- program.set('uMask', outline.mask)
496
- program.set('uStep', [0, 1 / height])
497
- program.set('uRadius', outline.radius)
614
+ if (outline.coverage) {
615
+ const program = this.programs.ready('outline-smooth-composite', [], OUTLINE_SMOOTH_COMPOSITE)
616
+ gl.useProgram(program.program)
617
+ program.set('uNearest', outline.wide)
618
+ program.set('uMask', outline.mask)
619
+ program.set('uCoverage', outline.coverage)
620
+ program.set('uTexel', [1 / outline.width, 1 / outline.height])
621
+ program.set('uRadius', outline.radius)
622
+ } else {
623
+ const program = this.programs.ready('outline-composite', [], OUTLINE_COMPOSITE)
624
+ gl.useProgram(program.program)
625
+ program.set('uSource', outline.wide)
626
+ program.set('uMask', outline.mask)
627
+ program.set('uStep', [0, 1 / outline.height])
628
+ program.set('uRadius', outline.radius)
629
+ }
498
630
  gl.disable(gl.DEPTH_TEST)
499
631
  gl.disable(gl.CULL_FACE)
500
632
  gl.depthMask(false)
@@ -505,22 +637,32 @@ export class Passes {
505
637
  gl.enable(gl.DEPTH_TEST)
506
638
  }
507
639
 
508
- /** The mask and its widened copy, made again when the size changes. @param {number} width @param {number} height */
509
- #outlineTargets(width, height) {
640
+ /**
641
+ * The mask and its widened copy - and, for smooth outlines, the multisampled buffers drawn into
642
+ * and the resolved coverage - made again when the size or the kind changes.
643
+ * @param {number} width @param {number} height @param {boolean} smooth
644
+ * @returns {OutlineTargets}
645
+ */
646
+ #outlineTargets(width, height, smooth) {
510
647
  const t = this.#outline
511
- if (t && t.width === width && t.height === height) return t
648
+ if (t && t.width === width && t.height === height && t.smooth === smooth) return t
512
649
  const gl = this.gl
513
650
  if (t) {
514
- for (const { texture, framebuffer } of [t.mask, t.wide]) {
515
- gl.deleteTexture(texture)
516
- gl.deleteFramebuffer(framebuffer)
651
+ for (const target of [t.mask, t.wide, t.coverage]) {
652
+ if (!target) continue
653
+ gl.deleteTexture(target.texture)
654
+ gl.deleteFramebuffer(target.framebuffer)
655
+ }
656
+ if (t.msaa) {
657
+ gl.deleteFramebuffer(t.msaa.framebuffer)
658
+ for (const buffer of t.msaa.renderbuffers) gl.deleteRenderbuffer(buffer)
517
659
  }
518
660
  }
519
- const make = () => {
661
+ const make = (/** @type {number} */ format = gl.RGBA8) => {
520
662
  const texture = /** @type {WebGLTexture} */ (gl.createTexture())
521
663
  gl.activeTexture(gl.TEXTURE15)
522
664
  gl.bindTexture(gl.TEXTURE_2D, texture)
523
- gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, width, height)
665
+ gl.texStorage2D(gl.TEXTURE_2D, 1, format, width, height)
524
666
  // read texel for texel: no blending between neighbours
525
667
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
526
668
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
@@ -531,7 +673,23 @@ export class Passes {
531
673
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0)
532
674
  return { texture, framebuffer }
533
675
  }
534
- this.#outline = { width, height, mask: make(), wide: make() }
676
+ /** @type {OutlineTargets['msaa']} */
677
+ let msaa = null
678
+ if (smooth) {
679
+ // two multisampled targets drawn at once: the mask, and its coverage
680
+ const samples = Math.min(4, gl.getParameter(gl.MAX_SAMPLES))
681
+ const framebuffer = /** @type {WebGLFramebuffer} */ (gl.createFramebuffer())
682
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer)
683
+ const renderbuffers = [gl.RGBA8, gl.R8].map((format, i) => {
684
+ const buffer = /** @type {WebGLRenderbuffer} */ (gl.createRenderbuffer())
685
+ gl.bindRenderbuffer(gl.RENDERBUFFER, buffer)
686
+ gl.renderbufferStorageMultisample(gl.RENDERBUFFER, samples, format, width, height)
687
+ gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.RENDERBUFFER, buffer)
688
+ return buffer
689
+ })
690
+ msaa = { framebuffer, renderbuffers }
691
+ }
692
+ this.#outline = { width, height, smooth, mask: make(), wide: make(), coverage: smooth ? make(gl.R8) : null, msaa }
535
693
  gl.bindFramebuffer(gl.FRAMEBUFFER, null)
536
694
  return this.#outline
537
695
  }
@@ -232,7 +232,6 @@ export class Picker {
232
232
  program.set('uModel', worldMatrix)
233
233
  program.set('uPickId', id)
234
234
  program.set('uHighlightIndex', -2)
235
- program.set('uUvTransform', [1, 1, 0, 0])
236
235
  if (item.clip) program.set('uClipPlane', item.clip.plane)
237
236
 
238
237
  if (kind === 'lines') {
@@ -2,6 +2,7 @@
2
2
  // CPU objects through their `version` counters. Everything is keyed by object in WeakMaps, so a
3
3
  // dropped geometry's entry goes with it; dispose() frees the GL objects straight away.
4
4
  import { ATTRIBUTES } from './shaders.js'
5
+ import { isVideo } from '../material/Texture.js'
5
6
 
6
7
  /** @import { Geometry } from '../geometry/Geometry.js' */
7
8
  /** @import { Texture, DataImage } from '../material/Texture.js' */
@@ -31,9 +32,79 @@ const UPLOAD_UNIT = 15
31
32
  * @param {{ instanceMatrices: Float32Array, instanceColors: Float32Array | null } | null} instances
32
33
  * @param {boolean} withNormals
33
34
  */
34
- function layoutOf(g, instances, withNormals) {
35
- return [g.positions.length, withNormals, g.uvs?.length, g.colors?.length, g.indices?.length,
36
- instances?.instanceMatrices.length, instances?.instanceColors?.length].join()
35
+ function layoutOf(g, instances, withNormals, withTangents) {
36
+ return [g.positions.length, withNormals, g.uvs?.length, g.uvs2?.length, g.colors?.length, g.indices?.length,
37
+ instances?.instanceMatrices.length, instances?.instanceColors?.length, withTangents].join()
38
+ }
39
+
40
+ /**
41
+ * Per-vertex tangents along the UVs' u, with the way v runs in w (Lengyel's method): smooth where
42
+ * the normals are. Without UVs, around the mesh's up axis.
43
+ * @param {Geometry} g
44
+ * @returns {Float32Array}
45
+ */
46
+ export function tangentsOf(g) {
47
+ const p = g.positions, n = g.normals, uv = g.uvs, count = p.length / 3
48
+ const out = new Float32Array(count * 4)
49
+ const t = new Float32Array(count * 3), b = new Float32Array(count * 3)
50
+ if (uv) {
51
+ const idx = g.indices, triangles = (idx ? idx.length : count) / 3
52
+ for (let k = 0; k < triangles; k++) {
53
+ const i0 = idx ? idx[k * 3] : k * 3, i1 = idx ? idx[k * 3 + 1] : k * 3 + 1, i2 = idx ? idx[k * 3 + 2] : k * 3 + 2
54
+ const e1x = p[i1 * 3] - p[i0 * 3], e1y = p[i1 * 3 + 1] - p[i0 * 3 + 1], e1z = p[i1 * 3 + 2] - p[i0 * 3 + 2]
55
+ const e2x = p[i2 * 3] - p[i0 * 3], e2y = p[i2 * 3 + 1] - p[i0 * 3 + 1], e2z = p[i2 * 3 + 2] - p[i0 * 3 + 2]
56
+ const du1 = uv[i1 * 2] - uv[i0 * 2], dv1 = uv[i1 * 2 + 1] - uv[i0 * 2 + 1]
57
+ const du2 = uv[i2 * 2] - uv[i0 * 2], dv2 = uv[i2 * 2 + 1] - uv[i0 * 2 + 1]
58
+ const det = du1 * dv2 - du2 * dv1
59
+ if (Math.abs(det) < 1e-12) continue
60
+ const r = 1 / det
61
+ const tx = (e1x * dv2 - e2x * dv1) * r, ty = (e1y * dv2 - e2y * dv1) * r, tz = (e1z * dv2 - e2z * dv1) * r
62
+ const bx = (e2x * du1 - e1x * du2) * r, by = (e2y * du1 - e1y * du2) * r, bz = (e2z * du1 - e1z * du2) * r
63
+ for (const i of [i0, i1, i2]) {
64
+ t[i * 3] += tx; t[i * 3 + 1] += ty; t[i * 3 + 2] += tz
65
+ b[i * 3] += bx; b[i * 3 + 1] += by; b[i * 3 + 2] += bz
66
+ }
67
+ }
68
+ }
69
+ for (let i = 0; i < count; i++) {
70
+ const nx = n[i * 3], ny = n[i * 3 + 1], nz = n[i * 3 + 2]
71
+ let tx = t[i * 3], ty = t[i * 3 + 1], tz = t[i * 3 + 2]
72
+ // onto the surface, square to the normal
73
+ const d = nx * tx + ny * ty + nz * tz
74
+ tx -= nx * d; ty -= ny * d; tz -= nz * d
75
+ let len = Math.hypot(tx, ty, tz)
76
+ if (len < 1e-9) {
77
+ // no UVs here: around the up axis, or the x axis for a surface facing up or down
78
+ if (Math.abs(ny) < 0.999) { tx = nz; ty = 0; tz = -nx } else { tx = 1; ty = 0; tz = 0 }
79
+ const e = nx * tx + ny * ty + nz * tz
80
+ tx -= nx * e; ty -= ny * e; tz -= nz * e
81
+ len = Math.hypot(tx, ty, tz) || 1
82
+ }
83
+ out[i * 4] = tx / len; out[i * 4 + 1] = ty / len; out[i * 4 + 2] = tz / len
84
+ // which way v runs against n x t: mirrored UVs turn the other way
85
+ const cx = ny * tz - nz * ty, cy = nz * tx - nx * tz, cz = nx * ty - ny * tx
86
+ out[i * 4 + 3] = cx * b[i * 3] + cy * b[i * 3 + 1] + cz * b[i * 3 + 2] < 0 ? -1 : 1
87
+ }
88
+ return out
89
+ }
90
+
91
+ const UV_MATRIX = new Float32Array(9)
92
+
93
+ /**
94
+ * A map's UV transform as a 3x3 (column-major): repeat, then rotation about its centre, then offset -
95
+ * glTF's KHR_texture_transform order, turning the way three.js and glTF viewers do. Written into one
96
+ * shared array, so set it as a uniform straight away.
97
+ * @param {{ repeat: number[], offset: number[], rotation: number, center: number[] }} t
98
+ * @returns {Float32Array}
99
+ */
100
+ export function uvMatrix(t) {
101
+ const c = Math.cos(t.rotation), s = Math.sin(t.rotation)
102
+ const [sx, sy] = t.repeat, [cx, cy] = t.center
103
+ const m = UV_MATRIX
104
+ m[0] = c * sx; m[1] = -s * sx; m[2] = 0
105
+ m[3] = s * sy; m[4] = c * sy; m[5] = 0
106
+ m[6] = cx - c * cx - s * cy + t.offset[0]; m[7] = cy + s * cx - c * cy + t.offset[1]; m[8] = 1
107
+ return m
37
108
  }
38
109
 
39
110
  // a quad as a triangle strip of [along, across] corners, shared by every Lines draw
@@ -78,17 +149,20 @@ export class Resources {
78
149
  * @param {Geometry} geometry
79
150
  * @param {{ instanceMatrices: Float32Array, instanceColors: Float32Array | null, version: number } | null} instances
80
151
  * @param {boolean} withNormals
152
+ * @param {boolean} [withTangents] add tangents, for brushed metal - kept once added
81
153
  * @returns {MeshBuffers}
82
154
  */
83
- mesh(geometry, instances, withNormals) {
155
+ mesh(geometry, instances, withNormals, withTangents = false) {
84
156
  const key = instances ?? geometry
85
157
  this.#mark(key)
86
158
  const cache = withNormals ? this.#triangles : this.#points
87
159
  let entry = cache.get(key)
88
160
  // instanced buffers are kept by the node, so check it still has the same geometry
89
161
  const same = entry?.geometry === geometry
90
- if (entry && same && entry.version === geometry.version && (!instances || entry.instanceVersion === instances.version)) return entry
91
- const layout = layoutOf(geometry, instances, withNormals)
162
+ withTangents = withNormals && (withTangents || (same && !!entry?.attributes?.tangent))
163
+ if (entry && same && entry.version === geometry.version && (!instances || entry.instanceVersion === instances.version) &&
164
+ withTangents === !!entry.attributes?.tangent) return entry
165
+ const layout = layoutOf(geometry, instances, withNormals, withTangents)
92
166
  if (entry && same && entry.layout === layout) {
93
167
  // the data changed but kept its shape (moving vertices, particles, instances): refill in place
94
168
  if (entry.version !== geometry.version) {
@@ -96,7 +170,9 @@ export class Resources {
96
170
  this.#refill(a.position, geometry.positions)
97
171
  if (withNormals) this.#refill(a.normal, geometry.normals)
98
172
  if (geometry.uvs) this.#refill(a.uv, geometry.uvs)
173
+ if (geometry.uvs2) this.#refill(a.uv2, geometry.uvs2)
99
174
  if (geometry.colors) this.#refill(a.color, geometry.colors)
175
+ if (withTangents) this.#refill(a.tangent, tangentsOf(geometry))
100
176
  if (geometry.indices) {
101
177
  // the element binding belongs to the vertex array, so bind that first
102
178
  this.gl.bindVertexArray(entry.vao)
@@ -142,8 +218,10 @@ export class Resources {
142
218
  if (withNormals) attributes.normal = attribute(ATTRIBUTES.normal, geometry.normals, 3)
143
219
  if (geometry.uvs) attributes.uv = attribute(ATTRIBUTES.uv, geometry.uvs, 2)
144
220
  else gl.vertexAttrib2f(ATTRIBUTES.uv, 0, 0)
221
+ if (geometry.uvs2) attributes.uv2 = attribute(ATTRIBUTES.uv2, geometry.uvs2, 2)
145
222
  if (geometry.colors) attributes.color = attribute(ATTRIBUTES.color, geometry.colors, 3)
146
223
  else gl.vertexAttrib3f(ATTRIBUTES.color, 1, 1, 1)
224
+ if (withTangents) attributes.tangent = attribute(ATTRIBUTES.tangent, tangentsOf(geometry), 4)
147
225
 
148
226
  let matrixBuffer = null, colorBuffer = null
149
227
  if (instances) {
@@ -181,11 +259,12 @@ export class Resources {
181
259
  * Draw a mesh's triangles - instanced or not, indexed or not - with the program in use, as far as
182
260
  * its drawn count goes. Every pass that draws meshes draws them this way.
183
261
  * @param {Geometry} geometry @param {{ instanceMatrices: Float32Array, instanceColors: Float32Array | null, version: number, count: number } | null} instances
262
+ * @param {boolean} [withTangents] for brushed metal
184
263
  * @returns {number} triangles drawn
185
264
  */
186
- drawTriangles(geometry, instances) {
265
+ drawTriangles(geometry, instances, withTangents = false) {
187
266
  const gl = this.gl
188
- const buffers = this.mesh(geometry, instances, true)
267
+ const buffers = this.mesh(geometry, instances, true, withTangents)
189
268
  gl.bindVertexArray(buffers.vao)
190
269
  const elements = geometry.drawnCount - geometry.drawnCount % 3
191
270
  const count = instances ? instances.count : 1
@@ -272,6 +351,8 @@ export class Resources {
272
351
  const gl = this.gl
273
352
  let entry = this.#textures.get(texture)
274
353
  if (entry && entry.version === texture.version) return entry.texture
354
+ // a video with no frame yet: what it showed before, or white - and try again next frame
355
+ if (isVideo(texture.image) && /** @type {HTMLVideoElement} */ (texture.image).readyState < 2) return entry?.texture ?? this.#whiteTexture()
275
356
  if (!entry) {
276
357
  entry = { texture: /** @type {WebGLTexture} */ (gl.createTexture()), version: -1 }
277
358
  this.#textures.set(texture, entry)