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
@@ -30,8 +30,36 @@ import { Texture } from './Texture.js'
30
30
  * @property {MapInput | null} [aoMap] ambient occlusion in its red channel; darkens environment and ambient light
31
31
  * @property {number} [aoMapIntensity] 0-1 (default 1)
32
32
  * @property {MapInput | null} [emissiveMap] multiplies the emissive colour
33
+ * @property {MapInput | null} [lightMap] light baked into a texture, added as light from all round - usually on
34
+ * the second UV set (uvSet: 1)
35
+ * @property {number} [lightMapIntensity] (default 1)
36
+ * @property {number} [environmentIntensity] this material's share of the environment's light and reflections:
37
+ * lower for a matte floor in a shiny showroom, higher to make one thing catch the light (default 1)
33
38
  * @property {ColorInput} [emissive] glow colour (default black)
34
39
  * @property {number} [emissiveIntensity] (default 1)
40
+ * @property {number} [transmission] 0-1: how much light passes through, as through glass (default 0). Unlike
41
+ * opacity, which fades the whole surface, glass keeps its reflections, and shows less of what is behind it
42
+ * towards its edges. Its colour shows in proportion to 1 - transmission
43
+ * @property {number} [clearcoat] 0-1: a clear glossy layer over the surface - car paint, lacquer, varnish (default 0)
44
+ * @property {number} [clearcoatRoughness] 0-1: how glossy the coat is, 0 a mirror (default 0)
45
+ * @property {number} [sheen] 0-1: the soft bright rim of cloth at grazing angles - velvet, felt, upholstery (default 0)
46
+ * @property {ColorInput} [sheenColor] the sheen's colour (default white)
47
+ * @property {number} [sheenRoughness] 0-1: a thin bright rim at 0, a broad soft glow at 1 (default 0.5)
48
+ * @property {number} [ior] index of refraction, 1 or more: how strongly a non-metal reflects - water 1.33, glass and
49
+ * plastic 1.5 (the default), diamond 2.4
50
+ * @property {number} [specularIntensity] 0-1: scales a non-metal's reflections, 0 for none - chalk, clay (default 1)
51
+ * @property {ColorInput} [specularColor] tints a non-metal's reflections face on (default white)
52
+ * @property {number} [anisotropy] 0-1: brushed metal - highlights drawn out along the brushing, which runs along
53
+ * the UVs' u (around the mesh where it has no UVs) (default 0)
54
+ * @property {number} [anisotropyRotation] turns the brushing from the UVs' u, in radians (default 0)
55
+ * @property {number} [translucency] 0-1: light through thin surfaces, lighting them from behind - leaves, paper,
56
+ * lampshades, curtains (default 0)
57
+ * @property {ColorInput} [translucencyColor] tints the light that comes through (default white)
58
+ * @property {MapInput | null} [matcap] a picture of a lit sphere that gives the whole look - clay, wax, chrome,
59
+ * a painted ball - picked by which way the surface faces the camera. Lights, shadows and the environment are
60
+ * ignored; the colour and colour map multiply it
61
+ * @property {number} [toon] cartoon shading in this many flat tones (2 or more) with a hard-edged highlight;
62
+ * 0 (the default) is smooth
35
63
  * @property {boolean} [unlit] flat colour that ignores lights (default false)
36
64
  * @property {boolean} [flatShading] faceted look from face normals (default false)
37
65
  * @property {boolean} [doubleSided] draw back faces too (default false)
@@ -46,18 +74,26 @@ import { Texture } from './Texture.js'
46
74
  * @property {number} [width] Lines: width in pixels (default 1)
47
75
  */
48
76
 
49
- const MAPS = ['map', 'roughnessMap', 'metalnessMap', 'normalMap', 'bumpMap', 'aoMap', 'emissiveMap']
77
+ // the matcap last: the first map's repeat and offset are every map's, and a matcap has none
78
+ const MAPS = ['map', 'roughnessMap', 'metalnessMap', 'normalMap', 'bumpMap', 'aoMap', 'emissiveMap', 'lightMap', 'matcap']
50
79
  // data maps hold numbers, not colours
51
80
  const LINEAR_MAPS = new Set(['roughnessMap', 'metalnessMap', 'normalMap', 'bumpMap', 'aoMap'])
52
81
  const FLAGS = ['unlit', 'flatShading', 'doubleSided', 'vertexColors', 'depthWrite', 'depthTest', 'sizeAttenuation', 'transparent', 'fog']
53
82
  const NUMBERS = { opacity: [0, 1], roughness: [0, 1], metalness: [0, 1], emissiveIntensity: [0, Infinity], size: [0, Infinity], width: [0, Infinity],
54
- alphaTest: [0, 1], normalScale: [-Infinity, Infinity], aoMapIntensity: [0, 1], triplanar: [0, Infinity], bumpScale: [-Infinity, Infinity] }
83
+ alphaTest: [0, 1], normalScale: [-Infinity, Infinity], aoMapIntensity: [0, 1], triplanar: [0, Infinity], bumpScale: [-Infinity, Infinity],
84
+ transmission: [0, 1], clearcoat: [0, 1], clearcoatRoughness: [0, 1], sheen: [0, 1], sheenRoughness: [0, 1], ior: [1, Infinity],
85
+ specularIntensity: [0, 1], anisotropy: [0, 1], anisotropyRotation: [-Infinity, Infinity], translucency: [0, 1], toon: [0, 16],
86
+ lightMapIntensity: [0, Infinity], environmentIntensity: [0, Infinity] }
87
+ // colours, and their defaults
88
+ const COLORS = { color: 0xffffff, emissive: 0x000000, sheenColor: 0xffffff, specularColor: 0xffffff, translucencyColor: 0xffffff }
55
89
  const DEFAULTS = {
56
- name: '', opacity: 1, alphaTest: 0, roughness: 0.5, metalness: 0, normalScale: 1, bumpScale: 1, aoMapIntensity: 1, triplanar: 0, emissiveIntensity: 1, unlit: false, flatShading: false,
90
+ name: '', opacity: 1, alphaTest: 0, roughness: 0.5, metalness: 0, normalScale: 1, bumpScale: 1, aoMapIntensity: 1, triplanar: 0, emissiveIntensity: 1,
91
+ transmission: 0, clearcoat: 0, clearcoatRoughness: 0, sheen: 0, sheenRoughness: 0.5, ior: 1.5, specularIntensity: 1, anisotropy: 0,
92
+ anisotropyRotation: 0, translucency: 0, toon: 0, lightMapIntensity: 1, environmentIntensity: 1, unlit: false, flatShading: false,
57
93
  doubleSided: false, vertexColors: false, blending: 'normal', depthWrite: true, depthTest: true, fog: true,
58
94
  size: 1, sizeAttenuation: true, width: 1,
59
95
  }
60
- const OPTIONS = ['name', 'color', 'emissive', ...MAPS, ...FLAGS, ...Object.keys(NUMBERS), 'blending']
96
+ const OPTIONS = ['name', ...Object.keys(COLORS), ...MAPS, ...FLAGS, ...Object.keys(NUMBERS), 'blending']
61
97
 
62
98
  // what agents (and three.js habits) will reach for
63
99
  const HINTS = {
@@ -65,7 +101,17 @@ const HINTS = {
65
101
  side: "use 'doubleSided: true'",
66
102
  wireframe: 'use new Lines({ geometry: edges(geometry) }) for a wireframe',
67
103
  shininess: "use 'roughness' (0 = mirror, 1 = matte)",
68
- specular: "use 'roughness' and 'metalness'",
104
+ envMapIntensity: "use 'environmentIntensity'",
105
+ reflectivityScale: "use 'environmentIntensity'",
106
+ matcapMap: "use 'matcap'",
107
+ gradientMap: "use 'toon' - the number of flat tones",
108
+ cel: "use 'toon' - the number of flat tones",
109
+ celShading: "use 'toon' - the number of flat tones",
110
+ specular: "use 'specularIntensity' and 'specularColor' for a non-metal's reflections, or 'roughness' and 'metalness'",
111
+ reflectivity: "use 'ior' (1.5 by default) or 'specularIntensity'",
112
+ anisotropyStrength: "use 'anisotropy'",
113
+ diffuseTransmission: "use 'translucency'",
114
+ subsurface: "use 'translucency' for light through thin surfaces",
69
115
  colour: "use 'color'",
70
116
  emissiveColor: "use 'emissive'",
71
117
  alpha: "use 'opacity'",
@@ -91,6 +137,19 @@ export class Material {
91
137
  /** @type {number} */ roughness
92
138
  /** @type {number} */ metalness
93
139
  /** @type {number} */ emissiveIntensity
140
+ /** How much light passes through, as through glass: 0-1. @type {number} */ transmission
141
+ /** A clear glossy layer over the surface: 0-1. @type {number} */ clearcoat
142
+ /** How glossy the coat is, 0 a mirror. @type {number} */ clearcoatRoughness
143
+ /** The soft bright rim of cloth: 0-1. @type {number} */ sheen
144
+ /** A thin bright rim at 0, a broad soft glow at 1. @type {number} */ sheenRoughness
145
+ /** Index of refraction: how strongly a non-metal reflects (1.5). @type {number} */ ior
146
+ /** Scales a non-metal's reflections, 0-1. @type {number} */ specularIntensity
147
+ /** Brushed metal: 0-1. @type {number} */ anisotropy
148
+ /** Turns the brushing from the UVs' u, in radians. @type {number} */ anisotropyRotation
149
+ /** Light through thin surfaces: 0-1. @type {number} */ translucency
150
+ /** Cartoon shading in this many flat tones; 0 is smooth. @type {number} */ toon
151
+ /** @type {number} */ lightMapIntensity
152
+ /** This material's share of the environment's light and reflections. @type {number} */ environmentIntensity
94
153
  /** @type {boolean} */ unlit
95
154
  /** @type {boolean} */ flatShading
96
155
  /** @type {boolean} */ doubleSided
@@ -103,8 +162,8 @@ export class Material {
103
162
  /** @type {boolean} */ sizeAttenuation
104
163
  /** @type {number} */ width
105
164
 
106
- #color = new Color(0xffffff)
107
- #emissive = new Color(0x000000)
165
+ /** @type {Record<string, Color>} */
166
+ #colors = Object.fromEntries(Object.entries(COLORS).map(([key, hex]) => [key, new Color(hex)]))
108
167
  #transparent = false
109
168
  /** @type {Record<string, Texture | null>} */
110
169
  #maps = Object.fromEntries(MAPS.map((m) => [m, null]))
@@ -117,8 +176,7 @@ export class Material {
117
176
  Object.assign(this, DEFAULTS)
118
177
  for (const [key, value] of /** @type {[string, any][]} */ (Object.entries(options))) {
119
178
  if (value === undefined) continue
120
- if (key === 'color') this.color = value
121
- else if (key === 'emissive') this.emissive = value
179
+ if (key in COLORS) this.#colors[key].set(value, 'Material')
122
180
  else if (MAPS.includes(key)) this.setMap(key, value)
123
181
  else if (FLAGS.includes(key)) this[key] = bool(key, value)
124
182
  else if (key in NUMBERS) this[key] = range(key, value, NUMBERS[key])
@@ -141,14 +199,29 @@ export class Material {
141
199
  }
142
200
 
143
201
  /** Base colour; assign any colour input. @returns {Color} */
144
- get color() { return this.#color }
202
+ get color() { return this.#colors.color }
145
203
  /** @param {ColorInput} v */
146
- set color(v) { this.#color.set(v, 'Material') }
204
+ set color(v) { this.#colors.color.set(v, 'Material') }
147
205
 
148
206
  /** Glow colour; assign any colour input. @returns {Color} */
149
- get emissive() { return this.#emissive }
207
+ get emissive() { return this.#colors.emissive }
150
208
  /** @param {ColorInput} v */
151
- set emissive(v) { this.#emissive.set(v, 'Material') }
209
+ set emissive(v) { this.#colors.emissive.set(v, 'Material') }
210
+
211
+ /** The sheen's colour; assign any colour input. @returns {Color} */
212
+ get sheenColor() { return this.#colors.sheenColor }
213
+ /** @param {ColorInput} v */
214
+ set sheenColor(v) { this.#colors.sheenColor.set(v, 'Material') }
215
+
216
+ /** Tints a non-metal's reflections face on; assign any colour input. @returns {Color} */
217
+ get specularColor() { return this.#colors.specularColor }
218
+ /** @param {ColorInput} v */
219
+ set specularColor(v) { this.#colors.specularColor.set(v, 'Material') }
220
+
221
+ /** Tints the light that comes through a translucent surface; assign any colour input. @returns {Color} */
222
+ get translucencyColor() { return this.#colors.translucencyColor }
223
+ /** @param {ColorInput} v */
224
+ set translucencyColor(v) { this.#colors.translucencyColor.set(v, 'Material') }
152
225
 
153
226
  /** @returns {Texture | null} */
154
227
  get map() { return this.#maps.map }
@@ -178,9 +251,17 @@ export class Material {
178
251
  get emissiveMap() { return this.#maps.emissiveMap }
179
252
  /** @param {MapInput | null} v */
180
253
  set emissiveMap(v) { this.setMap('emissiveMap', v) }
254
+ /** Light baked into a texture. @returns {Texture | null} */
255
+ get lightMap() { return this.#maps.lightMap }
256
+ /** @param {MapInput | null} v */
257
+ set lightMap(v) { this.setMap('lightMap', v) }
258
+ /** A picture of a lit sphere that gives the whole look. @returns {Texture | null} */
259
+ get matcap() { return this.#maps.matcap }
260
+ /** @param {MapInput | null} v */
261
+ set matcap(v) { this.setMap('matcap', v) }
181
262
 
182
263
  /** @internal
183
- * @param {string} slot one of map, roughnessMap, metalnessMap, normalMap, aoMap, emissiveMap
264
+ * @param {string} slot one of map, roughnessMap, metalnessMap, normalMap, bumpMap, aoMap, emissiveMap, lightMap, matcap
184
265
  * @param {MapInput | null} value
185
266
  */
186
267
  setMap(slot, value) {
@@ -213,7 +294,7 @@ export class Material {
213
294
 
214
295
  /** Rendered in the transparent pass: an opacity below 1, additive or multiply blending, or set for maps with soft alpha. @returns {boolean} */
215
296
  get transparent() {
216
- return this.#transparent || this.opacity < 1 || this.blending !== 'normal'
297
+ return this.#transparent || this.opacity < 1 || this.transmission > 0 || this.blending !== 'normal'
217
298
  }
218
299
  /** @param {boolean} v */
219
300
  set transparent(v) { this.#transparent = !!v }
@@ -226,6 +307,12 @@ export class Material {
226
307
  const parts = [this.unlit ? 'unlit' : 'lit']
227
308
  for (const [slot] of this.textures) parts.push(slot)
228
309
  if (this.alphaTest > 0) parts.push('cutout')
310
+ if (this.transmission > 0) parts.push('glass')
311
+ if (this.clearcoat > 0) parts.push('clearcoat')
312
+ if (this.sheen > 0) parts.push('sheen')
313
+ if (this.anisotropy > 0) parts.push('brushed')
314
+ if (this.translucency > 0) parts.push('translucent')
315
+ if (this.toon > 0) parts.push('toon')
229
316
  if (this.flatShading) parts.push('flat')
230
317
  if (this.vertexColors) parts.push('vcolor')
231
318
  return parts.join('+')
@@ -240,8 +327,7 @@ export class Material {
240
327
  clone() {
241
328
  const copy = new Material(this.toJSON(true))
242
329
  // the colours exactly: a description rounds them to 8-bit sRGB and can't hold values past 1
243
- copy.color.set(this.#color)
244
- copy.emissive.set(this.#emissive)
330
+ for (const [key, c] of Object.entries(this.#colors)) copy.#colors[key].set(c)
245
331
  return copy
246
332
  }
247
333
 
@@ -268,8 +354,7 @@ export class Material {
268
354
  if (this[key] !== value) json[key] = this[key]
269
355
  }
270
356
  if (this.#transparent) json.transparent = true
271
- if (this.#color.getHex() !== 0xffffff) json.color = this.#color.getStyle()
272
- if (this.#emissive.getHex() !== 0) json.emissive = this.#emissive.getStyle()
357
+ for (const [key, c] of Object.entries(this.#colors)) if (c.getHex() !== COLORS[key]) json[key] = c.getStyle()
273
358
  for (const [slot, t] of this.textures) {
274
359
  const desc = keepTextures ? t : t.toJSON()
275
360
  if (desc) json[slot] = desc
@@ -10,36 +10,49 @@ import { checkNumber, checkOneOf, checkOptions, describe, fail } from '../valida
10
10
  * @typedef {object} TextureOptions
11
11
  * @property {TextureImage} [image] the pixels; omit and give a url to load them
12
12
  * @property {string} [url] image to load (starts loading immediately in a browser); a .hdr file loads as
13
- * linear HDR radiance, for scene.environment
13
+ * linear HDR radiance, for scene.environment; a video (.mp4, .webm, .ogv, .mov, .m4v) plays muted and on a
14
+ * loop, and texture.image is its <video> to pause, seek or unmute
14
15
  * @property {'srgb' | 'linear'} [colorSpace] 'srgb' for colour maps (default), 'linear' for data
15
16
  * maps such as normal and roughness maps
16
17
  * @property {'repeat' | 'clamp' | 'mirror'} [wrap] (default 'repeat')
17
18
  * @property {number[]} [repeat] [u, v] (default [1, 1])
18
19
  * @property {number[]} [offset] [u, v] (default [0, 0])
20
+ * @property {number} [rotation] turns the image on the surface, in radians, about `center` (default 0)
21
+ * @property {number[]} [center] [u, v] the point rotation turns about, in repeated UVs (default [0, 0], the
22
+ * corner, as glTF has it; [0.5, 0.5] for the middle of an unrepeated image)
23
+ * @property {0 | 1} [uvSet] which UV set to read: 0 the first, 1 the second (Geometry uvs2) - baked occlusion
24
+ * and light maps usually come on the second (default 0)
19
25
  * @property {'linear' | 'nearest'} [filter] (default 'linear')
20
- * @property {boolean} [mipmaps] (default true)
26
+ * @property {boolean} [mipmaps] (default true; false for a video, which would make them every frame)
21
27
  * @property {number} [anisotropy] samples along a surface seen at a glancing angle - floors and roads
22
28
  * stay sharp into the distance. Up to what the GPU allows; 1 is off (default 8)
23
29
  * @property {boolean} [flipY] flip on upload; false for glTF-style UVs (default false)
24
30
  */
25
31
 
26
- const OPTIONS = ['image', 'url', 'colorSpace', 'wrap', 'repeat', 'offset', 'filter', 'mipmaps', 'anisotropy', 'flipY']
27
- const DEFAULTS = { colorSpace: 'srgb', wrap: 'repeat', repeat: [1, 1], offset: [0, 0], filter: 'linear', mipmaps: true, anisotropy: 8, flipY: false }
32
+ const OPTIONS = ['image', 'url', 'colorSpace', 'wrap', 'repeat', 'offset', 'rotation', 'center', 'uvSet', 'filter', 'mipmaps', 'anisotropy', 'flipY']
33
+ const DEFAULTS = { colorSpace: 'srgb', wrap: 'repeat', repeat: [1, 1], offset: [0, 0], rotation: 0, center: [0, 0], uvSet: 0, filter: 'linear',
34
+ mipmaps: true, anisotropy: 8, flipY: false }
28
35
 
29
36
  const HINTS = {
30
37
  src: "use 'url' for the image address",
31
38
  encoding: "use colorSpace: 'srgb' | 'linear'",
32
39
  wrapS: "use wrap: 'repeat' | 'clamp' | 'mirror' (applies to both directions)",
33
40
  maxAnisotropy: "use 'anisotropy' - samples along glancing angles, 1 for off",
41
+ channel: "use 'uvSet' - 0 for the first UV set, 1 for the second",
42
+ texCoord: "use 'uvSet' - 0 for the first UV set, 1 for the second",
43
+ scale: "use 'repeat' - [u, v] times across",
34
44
  }
35
45
 
36
- /** An image for a material map. Canvas and video textures: set `needsUpdate()` after drawing. */
46
+ /**
47
+ * An image for a material map. A video's frames upload by themselves as it plays; after drawing on
48
+ * a canvas, call `needsUpdate()`.
49
+ */
37
50
  export class Texture {
38
51
  /** The options it takes, and hints for names people reach for instead. */
39
52
  static OPTIONS = OPTIONS
40
53
  static HINTS = HINTS
41
- /** @type {TextureImage | null} null until loaded */
42
- image = null
54
+ /** @type {TextureImage | null} */
55
+ #image = null
43
56
  /** @type {string | null} */
44
57
  url = null
45
58
  /** @type {'srgb' | 'linear'} */
@@ -50,6 +63,12 @@ export class Texture {
50
63
  wrap = 'repeat'
51
64
  repeat = [1, 1]
52
65
  offset = [0, 0]
66
+ /** Turns the image on the surface, in radians, about `center`. */
67
+ rotation = 0
68
+ /** [u, v] the point rotation turns about, in repeated UVs. */
69
+ center = [0, 0]
70
+ /** Which UV set it reads: 0 the first, 1 the second (Geometry uvs2). @type {0 | 1} */
71
+ uvSet = 0
53
72
  /** @type {'linear' | 'nearest'} */
54
73
  filter = 'linear'
55
74
  mipmaps = true
@@ -75,7 +94,12 @@ export class Texture {
75
94
  this.filter = checkOneOf('Texture', 'filter', o.filter, ['linear', 'nearest'])
76
95
  this.repeat = pair('repeat', o.repeat)
77
96
  this.offset = pair('offset', o.offset)
78
- this.mipmaps = !!o.mipmaps
97
+ this.rotation = checkNumber('Texture', 'rotation', o.rotation)
98
+ this.center = pair('center', o.center)
99
+ if (o.uvSet !== 0 && o.uvSet !== 1) fail('Texture', `'uvSet' is 0 (the first UV set) or 1 (the second), got ${describe(o.uvSet)}`)
100
+ this.uvSet = o.uvSet
101
+ // a video would make mipmaps every frame: off unless asked for
102
+ this.mipmaps = options.mipmaps !== undefined ? !!options.mipmaps : !(isVideo(o.image) || (!o.image && VIDEO.test(o.url ?? '')))
79
103
  this.anisotropy = checkNumber('Texture', 'anisotropy', o.anisotropy)
80
104
  if (!(this.anisotropy >= 1)) fail('Texture', `'anisotropy' is how many samples along a glancing angle - 1 (off) or more, got ${this.anisotropy}`)
81
105
  this.flipY = !!o.flipY
@@ -83,7 +107,8 @@ export class Texture {
83
107
 
84
108
  if (o.image) {
85
109
  this.image = o.image
86
- this.ready = Promise.resolve(this)
110
+ this.ready = isVideo(o.image) ? loaded(/** @type {HTMLVideoElement} */ (o.image)).then(() => this) : Promise.resolve(this)
111
+ this.ready.catch(() => {})
87
112
  } else if (this.url) {
88
113
  this.ready = this.#load(this.url)
89
114
  // a failed load is reported by whoever awaits `ready`; don't also raise an unhandled rejection
@@ -94,6 +119,57 @@ export class Texture {
94
119
  }
95
120
 
96
121
  /** Call after redrawing a canvas or advancing a video. @returns {this} */
122
+ /**
123
+ * The pixels: null until loaded. A <video>'s frames upload by themselves while it plays.
124
+ * @returns {TextureImage | null}
125
+ */
126
+ get image() { return this.#image }
127
+ /** @param {TextureImage | null} v */
128
+ set image(v) {
129
+ this.#image = v
130
+ this.needsUpdate()
131
+ if (isVideo(v)) this.#follow(/** @type {HTMLVideoElement} */ (v))
132
+ }
133
+
134
+ // Each new frame of a playing video marks the texture changed, so views redraw with it: the
135
+ // browser's own per-frame callback where there is one, otherwise a check every animation frame
136
+ // while it plays. Following stops when the image is replaced.
137
+ /** @param {HTMLVideoElement} video */
138
+ #follow(video) {
139
+ if (typeof video.requestVideoFrameCallback === 'function') {
140
+ const frame = () => {
141
+ if (this.#image !== video) return
142
+ this.needsUpdate()
143
+ video.requestVideoFrameCallback(frame)
144
+ }
145
+ video.requestVideoFrameCallback(frame)
146
+ return
147
+ }
148
+ let last = -1, running = false
149
+ const tick = () => {
150
+ if (this.#image !== video || video.paused || video.ended) {
151
+ running = false
152
+ return
153
+ }
154
+ if (video.currentTime !== last) {
155
+ last = video.currentTime
156
+ this.needsUpdate()
157
+ }
158
+ requestAnimationFrame(tick)
159
+ }
160
+ const start = () => {
161
+ if (running || this.#image !== video) return
162
+ running = true
163
+ requestAnimationFrame(tick)
164
+ }
165
+ video.addEventListener('play', start)
166
+ // a seek while paused shows a new frame too
167
+ video.addEventListener('seeked', () => {
168
+ if (this.#image === video) this.needsUpdate()
169
+ })
170
+ if (!video.paused) start()
171
+ }
172
+
97
173
  /** 'srgb' for colour (decoded to linear by the GPU), 'linear' for data such as normals and roughness. Call needsUpdate() after changing it. @returns {'srgb' | 'linear'} */
98
174
  get colorSpace() { return this.#colorSpace }
99
175
  set colorSpace(v) {
@@ -139,12 +215,26 @@ export class Texture {
139
215
 
140
216
  /** @param {string} url @returns {Promise<Texture>} */
141
217
  async #load(url) {
218
+ if (VIDEO.test(url)) {
219
+ if (typeof document === 'undefined') throw new Error(`Texture: can't load '${url}' - no video support in this environment`)
220
+ const video = document.createElement('video')
221
+ // muted, so browsers let it play by itself; inline, so phones don't take it full screen
222
+ Object.assign(video, { crossOrigin: 'anonymous', muted: true, loop: true, playsInline: true, autoplay: true, src: url })
223
+ try {
224
+ await loaded(video)
225
+ } catch {
226
+ throw new Error(`Texture: failed to load '${url}'`)
227
+ }
228
+ // a browser that won't autoplay leaves it paused on its first frame: video.play() on a click starts it
229
+ video.play()?.catch(() => {})
230
+ this.image = video
231
+ return this
232
+ }
142
233
  if (/\.hdr($|[?#])/i.test(url)) {
143
234
  const [{ parseHDR }, response] = await Promise.all([import('../hdr.js'), fetch(url)])
144
235
  if (!response.ok) throw new Error(`Texture: failed to load '${url}' (${response.status})`)
145
236
  this.image = parseHDR(await response.arrayBuffer())
146
237
  this.colorSpace = 'linear'
147
- this.needsUpdate()
148
238
  return this
149
239
  }
150
240
  if (typeof Image === 'undefined') {
@@ -159,11 +249,26 @@ export class Texture {
159
249
  throw new Error(`Texture: failed to load '${url}'`)
160
250
  }
161
251
  this.image = img
162
- this.needsUpdate()
163
252
  return this
164
253
  }
165
254
  }
166
255
 
256
+ const VIDEO = /\.(mp4|webm|ogv|mov|m4v)($|[?#])/i
257
+
258
+ /** @internal A <video>, whose frames change as it plays. @param {unknown} image @returns {boolean} */
259
+ export function isVideo(image) {
260
+ return typeof HTMLVideoElement !== 'undefined' && image instanceof HTMLVideoElement
261
+ }
262
+
263
+ /** Resolves once a video has a frame to show. @param {HTMLVideoElement} video @returns {Promise<void>} */
264
+ function loaded(video) {
265
+ if (video.readyState >= 2) return Promise.resolve()
266
+ return new Promise((resolve, reject) => {
267
+ video.addEventListener('loadeddata', () => resolve(), { once: true })
268
+ video.addEventListener('error', () => reject(new Error('Texture: the video failed to load')), { once: true })
269
+ })
270
+ }
271
+
167
272
  /**
168
273
  * Load an image as a texture; resolves once it has decoded.
169
274
  * @param {string} url
package/src/watch.js CHANGED
@@ -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
 
@@ -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')