phoria2 1.2.0 → 1.3.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 (50) hide show
  1. package/README.md +2 -3
  2. package/dist/charts.js +476 -459
  3. package/dist/charts.js.map +1 -1
  4. package/dist/core.js +1192 -963
  5. package/dist/core.js.map +1 -1
  6. package/dist/edit.js +75 -75
  7. package/dist/edit.js.map +1 -1
  8. package/dist/explain.js +1 -1
  9. package/dist/gltf.js +84 -84
  10. package/dist/gltf.js.map +1 -1
  11. package/dist/keyboard.js +188 -0
  12. package/dist/keyboard.js.map +1 -0
  13. package/dist/motion.js +35 -35
  14. package/dist/motion.js.map +1 -1
  15. package/dist/particles.js +191 -118
  16. package/dist/particles.js.map +1 -1
  17. package/dist/passes.js +16 -16
  18. package/dist/passes.js.map +1 -1
  19. package/dist/phoria2.js +1 -1
  20. package/dist/picking.js +30 -14
  21. package/dist/picking.js.map +1 -1
  22. package/dist/snapshot.js +1 -1
  23. package/dist/tooltip.js +1 -1
  24. package/dist/types/View.d.ts +11 -0
  25. package/dist/types/area.d.ts +18 -0
  26. package/dist/types/geometry/Geometry.d.ts +16 -0
  27. package/dist/types/index.d.ts +2 -0
  28. package/dist/types/material/Material.d.ts +15 -0
  29. package/dist/types/material/Texture.d.ts +18 -2
  30. package/dist/types/particles/Emitter.d.ts +39 -2
  31. package/dist/types/scene/drawables.d.ts +23 -2
  32. package/dist/types/webgl/resources.d.ts +12 -2
  33. package/dist/types/webgl/shaders.d.ts +7 -2
  34. package/llms-full.txt +48 -1
  35. package/package.json +1 -1
  36. package/schema/scene.schema.json +48 -0
  37. package/src/View.js +16 -0
  38. package/src/area.js +131 -0
  39. package/src/charts/marks.js +29 -2
  40. package/src/geometry/Geometry.js +16 -1
  41. package/src/index.js +1 -0
  42. package/src/material/Material.js +9 -2
  43. package/src/material/Texture.js +55 -2
  44. package/src/particles/Emitter.js +121 -10
  45. package/src/scene/drawables.js +68 -4
  46. package/src/watch.js +8 -1
  47. package/src/webgl/WebGLRenderer.js +36 -5
  48. package/src/webgl/picking.js +22 -1
  49. package/src/webgl/resources.js +168 -14
  50. package/src/webgl/shaders.js +110 -11
package/src/index.js CHANGED
@@ -50,6 +50,7 @@ export { PhoriaError } from './validate.js'
50
50
  /** @typedef {import('./presets.js').CameraPreset} CameraPreset */
51
51
  /** @typedef {import('./tooltip.js').TooltipContent} TooltipContent */
52
52
  /** @typedef {import('./Raycaster.js').Hit} Hit */
53
+ /** @typedef {import('./area.js').PickArea} PickArea */
53
54
  /** @typedef {import('./scene/drawables.js').LabelPlacement} LabelPlacement */
54
55
  /** @typedef {import('./scene/drawables.js').LabelPlace} LabelPlace */
55
56
  /** @typedef {import('./scene/drawables.js').InstanceLabels} InstanceLabels */
@@ -72,6 +72,9 @@ import { Texture } from './Texture.js'
72
72
  * @property {number} [size] Points: diameter in world units, or pixels when sizeAttenuation is off (default 1)
73
73
  * @property {boolean} [sizeAttenuation] Points: sized in the world, so smaller with distance (default true)
74
74
  * @property {number} [width] Lines: width in pixels (default 1)
75
+ * @property {number} [dash] Lines: dashed, with dashes this long in the model's units; 0 is solid (default 0)
76
+ * @property {number} [gap] Lines: the gaps between dashes; 0 is as long as the dashes (default 0)
77
+ * @property {number} [dashOffset] Lines: moves the dashes along the lines - animate it for flow (default 0)
75
78
  */
76
79
 
77
80
  // the matcap last: the first map's repeat and offset are every map's, and a matcap has none
@@ -83,7 +86,8 @@ const NUMBERS = { opacity: [0, 1], roughness: [0, 1], metalness: [0, 1], emissiv
83
86
  alphaTest: [0, 1], normalScale: [-Infinity, Infinity], aoMapIntensity: [0, 1], triplanar: [0, Infinity], bumpScale: [-Infinity, Infinity],
84
87
  transmission: [0, 1], clearcoat: [0, 1], clearcoatRoughness: [0, 1], sheen: [0, 1], sheenRoughness: [0, 1], ior: [1, Infinity],
85
88
  specularIntensity: [0, 1], anisotropy: [0, 1], anisotropyRotation: [-Infinity, Infinity], translucency: [0, 1], toon: [0, 16],
86
- lightMapIntensity: [0, Infinity], environmentIntensity: [0, Infinity] }
89
+ lightMapIntensity: [0, Infinity], environmentIntensity: [0, Infinity], dash: [0, Infinity], gap: [0, Infinity],
90
+ dashOffset: [-Infinity, Infinity] }
87
91
  // colours, and their defaults
88
92
  const COLORS = { color: 0xffffff, emissive: 0x000000, sheenColor: 0xffffff, specularColor: 0xffffff, translucencyColor: 0xffffff }
89
93
  const DEFAULTS = {
@@ -91,7 +95,7 @@ const DEFAULTS = {
91
95
  transmission: 0, clearcoat: 0, clearcoatRoughness: 0, sheen: 0, sheenRoughness: 0.5, ior: 1.5, specularIntensity: 1, anisotropy: 0,
92
96
  anisotropyRotation: 0, translucency: 0, toon: 0, lightMapIntensity: 1, environmentIntensity: 1, unlit: false, flatShading: false,
93
97
  doubleSided: false, vertexColors: false, blending: 'normal', depthWrite: true, depthTest: true, fog: true,
94
- size: 1, sizeAttenuation: true, width: 1,
98
+ size: 1, sizeAttenuation: true, width: 1, dash: 0, gap: 0, dashOffset: 0,
95
99
  }
96
100
  const OPTIONS = ['name', ...Object.keys(COLORS), ...MAPS, ...FLAGS, ...Object.keys(NUMBERS), 'blending']
97
101
 
@@ -161,6 +165,9 @@ export class Material {
161
165
  /** @type {number} */ size
162
166
  /** @type {boolean} */ sizeAttenuation
163
167
  /** @type {number} */ width
168
+ /** Lines: dash length in the model's units; 0 is solid. @type {number} */ dash
169
+ /** Lines: gap length; 0 is the dash's. @type {number} */ gap
170
+ /** Lines: how far the dashes have moved along. @type {number} */ dashOffset
164
171
 
165
172
  /** @type {Record<string, Color>} */
166
173
  #colors = Object.fromEntries(Object.entries(COLORS).map(([key, hex]) => [key, new Color(hex)]))
@@ -43,9 +43,18 @@ const HINTS = {
43
43
  scale: "use 'repeat' - [u, v] times across",
44
44
  }
45
45
 
46
+ /**
47
+ * Part of an image, in its pixels from the top left, as needsUpdate() takes it.
48
+ * @typedef {{ x: number, y: number, width: number, height: number }} TextureRegion
49
+ */
50
+
51
+ // how many updates a texture remembers the region of, for a renderer catching up on several - a
52
+ // frame of paint can make a hundred or so
53
+ const CHANGES_KEPT = 256
54
+
46
55
  /**
47
56
  * An image for a material map. A video's frames upload by themselves as it plays; after drawing on
48
- * a canvas, call `needsUpdate()`.
57
+ * a canvas, call `needsUpdate()` - with the rectangle drawn in, to send only that.
49
58
  */
50
59
  export class Texture {
51
60
  /** The options it takes, and hints for names people reach for instead. */
@@ -77,6 +86,8 @@ export class Texture {
77
86
  flipY = false
78
87
  /** @internal Bumped by needsUpdate() so renderers re-upload. */
79
88
  version = 0
89
+ /** What each recent update changed - its region, or null for all of it. @type {{ version: number, region: TextureRegion | null }[]} */
90
+ #changes = []
80
91
  /** Resolves when the image is available (immediately when one was given). @type {Promise<Texture>} */
81
92
  ready
82
93
 
@@ -180,11 +191,53 @@ export class Texture {
180
191
  /** @internal Whether the app chose the colour space, rather than leaving the default. @returns {boolean} */
181
192
  get colorSpaceSet() { return this.#colorSpaceSet }
182
193
 
183
- needsUpdate() {
194
+ /**
195
+ * Upload the image again, after drawing on it (a canvas) or changing its pixels. Give the
196
+ * rectangle that changed - in the image's pixels, from its top left - and only that is sent: for a
197
+ * big canvas drawn on a little at a time, far less to send each frame.
198
+ *
199
+ * ctx.fillRect(x, y, 20, 20)
200
+ * texture.needsUpdate({ x, y, width: 20, height: 20 })
201
+ * @param {TextureRegion} [region]
202
+ * @returns {this}
203
+ */
204
+ needsUpdate(region) {
205
+ /** @type {TextureRegion | null} */
206
+ let changed = null
207
+ if (region !== undefined) {
208
+ const { x, y, width, height } = region ?? /** @type {any} */ ({})
209
+ if (![x, y, width, height].every((v) => typeof v === 'number' && Number.isFinite(v)) || width < 0 || height < 0) {
210
+ fail('Texture', `needsUpdate() takes the region changed, { x, y, width, height } in the image's pixels, got ${describe(region)}`)
211
+ }
212
+ // whole pixels, covering every one the region touches
213
+ const x0 = Math.floor(x), y0 = Math.floor(y)
214
+ changed = { x: x0, y: y0, width: Math.ceil(x + width) - x0, height: Math.ceil(y + height) - y0 }
215
+ }
184
216
  this.version++
217
+ this.#changes.push({ version: this.version, region: changed })
218
+ if (this.#changes.length > CHANGES_KEPT) this.#changes.shift()
185
219
  return this
186
220
  }
187
221
 
222
+ /**
223
+ * @internal What changed since a version a renderer uploaded: each update's region since, or null
224
+ * when that's all of it - an update without a region, or one too long ago to know.
225
+ * @param {number} version
226
+ * @returns {TextureRegion[] | null}
227
+ */
228
+ changedSince(version) {
229
+ const first = this.#changes[0]
230
+ if (!first || first.version > version + 1) return null
231
+ /** @type {TextureRegion[]} */
232
+ const regions = []
233
+ for (const { version: v, region } of this.#changes) {
234
+ if (v <= version) continue
235
+ if (!region) return null
236
+ regions.push(region)
237
+ }
238
+ return regions.length ? regions : null
239
+ }
240
+
188
241
  /** Release the GPU copy. @returns {void} */
189
242
  dispose() {
190
243
  for (const fn of this.#onDispose) fn()
@@ -1,7 +1,8 @@
1
1
  // A particle system as one Points node: particles are born at the emitter's origin, move under
2
2
  // velocity, gravity and drag, fade through a list of colours and die. The simulation runs in the
3
3
  // emitter's own update handler, so it follows the scene clock (frame-rate independent) and its
4
- // arrays go to the GPU as one buffer refill per frame.
4
+ // arrays go to the GPU as one buffer refill per frame. Particles that grow, fade or turn give the
5
+ // geometry per-point sizes, opacities or angles, and are drawn as squares.
5
6
  import * as mat4 from '../math/mat4.js'
6
7
  import { Geometry } from '../geometry/Geometry.js'
7
8
  import { Material } from '../material/Material.js'
@@ -48,17 +49,32 @@ import { checkNumber, describe, fail } from '../validate.js'
48
49
  * frustumCulled?: boolean,
49
50
  * renderOrder?: number,
50
51
  * onParticle?: ((p: Particle) => boolean | void) | null,
51
- * }} EmitterOptions
52
+ * sort?: boolean,
53
+ * } & LookOptions} EmitterOptions
52
54
  * max: most live particles at once (default 1000). rate: particles born per second (default 100).
53
55
  * gravity: acceleration, e.g. [0, -9.8, 0] (default none). drag: share of velocity lost per second
54
56
  * (default 0). follow: a node whose position becomes the origin each frame - a trail that stays
55
57
  * behind it. onParticle: called for every live particle each frame after it moves; return false to
56
- * remove it (a particle hitting something).
58
+ * remove it (a particle hitting something). sort: draw furthest first, for soft particles with
59
+ * blending 'normal' (smoke) that would otherwise layer wrongly.
60
+ */
61
+
62
+ /**
63
+ * How particles look over their lives, beyond colour.
64
+ * @typedef {object} LookOptions
65
+ * @property {number | number[]} [size] a multiple of the material's size, or several each particle goes
66
+ * through over its life - [0.5, 3] for smoke that billows out (default 1)
67
+ * @property {number} [sizeSpread] each particle's size varies by up to this share either way, 0-1 (default 0)
68
+ * @property {number | number[]} [opacity] 0-1, or several each particle goes through - [0, 1, 0] fades in
69
+ * and out (default 1)
70
+ * @property {number | [number, number]} [spin] radians per second: a number turns each particle up to that
71
+ * fast either way, [min, max] a range. Spinning particles start at a random angle (default 0)
57
72
  */
58
73
 
59
74
  const SPAWN_OPTIONS = ['origin', 'originSpread', 'velocity', 'velocitySpread', 'lifetime', 'color']
60
- const OPTIONS = [...Points.OPTIONS, ...SPAWN_OPTIONS,
61
- 'max', 'rate', 'gravity', 'drag', 'follow', 'onParticle']
75
+ const LOOK_OPTIONS = ['size', 'sizeSpread', 'opacity', 'spin']
76
+ const BEHAVIOUR_OPTIONS = ['rate', 'gravity', 'drag', 'follow', 'onParticle', ...LOOK_OPTIONS]
77
+ const OPTIONS = [...Points.OPTIONS, ...SPAWN_OPTIONS, 'max', ...BEHAVIOUR_OPTIONS]
62
78
  const HINTS = {
63
79
  count: "use 'max' for the most particles alive at once, 'rate' for how many are born per second",
64
80
  lifetimeRnd: "give 'lifetime' a [min, max] range",
@@ -111,6 +127,13 @@ export class Emitter extends Points {
111
127
  /** @type {Map<string, number>} */ #paletteIds = new Map()
112
128
  /** @type {Uint8Array} */ #palette
113
129
  #spawnPalette = 0
130
+ // the looks: stops over each particle's life, and each one's own size factor and turn rate
131
+ /** @type {number[]} */ #size = [1]
132
+ #sizeSpread = 0
133
+ /** @type {number[]} */ #opacity = [1]
134
+ /** @type {[number, number]} */ #spin = [0, 0]
135
+ /** @type {Float32Array | null} */ #sizeFactors = null
136
+ /** @type {Float32Array | null} */ #spins = null
114
137
  /** Last frame's origin, so a moving source leaves an even trail rather than clumps. */
115
138
  #lastOrigin = /** @type {Vec3 | null} */ (null)
116
139
  #particle = /** @type {Particle} */ ({ position: new Vec3(), velocity: new Vec3(), age: 0, lifetime: 0 })
@@ -144,12 +167,12 @@ export class Emitter extends Points {
144
167
  /**
145
168
  * Change several settings at once - a preset, say. Vectors are copied into the emitter's own,
146
169
  * so anything bound to `emitter.gravity` and friends stays bound. Live particles carry on.
147
- * @param {SpawnOptions & { rate?: number, gravity?: Vec3Input, drag?: number, follow?: Node | null,
170
+ * @param {SpawnOptions & LookOptions & { rate?: number, gravity?: Vec3Input, drag?: number, follow?: Node | null,
148
171
  * onParticle?: ((p: Particle) => boolean | void) | null }} options
149
172
  * @returns {this}
150
173
  */
151
174
  set(options) {
152
- const known = [...SPAWN_OPTIONS, 'rate', 'gravity', 'drag', 'follow', 'onParticle']
175
+ const known = [...SPAWN_OPTIONS, ...BEHAVIOUR_OPTIONS]
153
176
  for (const key of Object.keys(options ?? {})) {
154
177
  if (!known.includes(key)) fail('Emitter', `set() doesn't take '${key}' - it takes ${known.join(', ')}`)
155
178
  }
@@ -169,6 +192,22 @@ export class Emitter extends Points {
169
192
  /** @returns {[number, number, number][]} */
170
193
  get color() { return /** @type {[number, number, number][]} */ (this.#palettes[0].map((s) => [...s])) }
171
194
 
195
+ /** Each particle's size over its life, as multiples of the material's. @param {number | number[]} v */
196
+ set size(v) { this.#setLooks({ size: v }) }
197
+ /** @returns {number[]} */
198
+ get size() { return [...this.#size] }
199
+ /** How much each particle's size varies, 0-1 either way. @param {number} v */
200
+ set sizeSpread(v) { this.#setLooks({ sizeSpread: v }) }
201
+ get sizeSpread() { return this.#sizeSpread }
202
+ /** Each particle's opacity over its life. @param {number | number[]} v */
203
+ set opacity(v) { this.#setLooks({ opacity: v }) }
204
+ /** @returns {number[]} */
205
+ get opacity() { return [...this.#opacity] }
206
+ /** How fast particles turn, radians per second, as a [min, max] range. @param {number | [number, number]} v */
207
+ set spin(v) { this.#setLooks({ spin: v }) }
208
+ /** @returns {[number, number]} */
209
+ get spin() { return [...this.#spin] }
210
+
172
211
  /**
173
212
  * Birth `count` particles at once - explosions, splashes. Options override the emitter's own
174
213
  * spawn settings for this burst only.
@@ -211,6 +250,7 @@ export class Emitter extends Points {
211
250
  const v = this.#velocities, ages = this.#ages, lives = this.#lifetimes
212
251
  const g = this.gravity, keep = Math.exp(-this.drag * dt)
213
252
  const hook = this.onParticle, particle = this.#particle
253
+ const angles = this.geometry.angles, spins = this.#spins
214
254
 
215
255
  for (let i = 0; i < this.#alive;) {
216
256
  const age = ages[i] += dt
@@ -223,6 +263,7 @@ export class Emitter extends Points {
223
263
  v[i3 + k] = (v[i3 + k] + g[k] * dt) * keep
224
264
  p[i3 + k] += v[i3 + k] * dt
225
265
  }
266
+ if (angles && spins) angles[i] += spins[i] * dt
226
267
  this.#paint(i, age / lives[i])
227
268
  if (hook) {
228
269
  particle.position.set(p[i3], p[i3 + 1], p[i3 + 2])
@@ -256,7 +297,7 @@ export class Emitter extends Points {
256
297
  const rest = super.cloneOptions()
257
298
  delete rest.geometry
258
299
  return { ...rest, ...this.#saveSpawn(), max: this.#max, rate: this.rate, gravity: this.gravity.clone(),
259
- drag: this.drag, follow: this.follow, onParticle: this.onParticle, color: this.color }
300
+ drag: this.drag, follow: this.follow, onParticle: this.onParticle, color: this.color, ...this.#looks() }
260
301
  }
261
302
 
262
303
  /** @internal @returns {Record<string, unknown>} */
@@ -266,7 +307,8 @@ export class Emitter extends Points {
266
307
  return { ...rest, max: this.#max, rate: this.rate, gravity: this.gravity.toArray(), drag: this.drag,
267
308
  origin: this.origin.toArray(), originSpread: this.originSpread, velocity: this.velocity.toArray(),
268
309
  velocitySpread: typeof this.velocitySpread === 'number' ? this.velocitySpread : this.velocitySpread.toArray(),
269
- lifetime: [...this.lifetime], color: this.#palettes[0].map((s) => new Color().setSRGB(s[0], s[1], s[2]).toJSON()) }
310
+ lifetime: [...this.lifetime], color: this.#palettes[0].map((s) => new Color().setSRGB(s[0], s[1], s[2]).toJSON()),
311
+ ...this.#looks() }
270
312
  }
271
313
 
272
314
  // birth one particle somewhere between a and b, already `head` seconds into its life
@@ -287,6 +329,12 @@ export class Emitter extends Points {
287
329
  this.#lifetimes[i] = min + Math.random() * (max - min)
288
330
  this.#ages[i] = head
289
331
  this.#palette[i] = this.#spawnPalette
332
+ if (this.#sizeFactors) this.#sizeFactors[i] = 1 + (Math.random() * 2 - 1) * this.#sizeSpread
333
+ const angles = this.geometry.angles, spins = this.#spins
334
+ if (angles && spins) {
335
+ spins[i] = this.#spin[0] + Math.random() * (this.#spin[1] - this.#spin[0])
336
+ angles[i] = Math.random() * Math.PI * 2 + spins[i] * head
337
+ }
290
338
  this.#paint(i, head / this.#lifetimes[i])
291
339
  }
292
340
 
@@ -302,10 +350,16 @@ export class Emitter extends Points {
302
350
  this.#ages[i] = this.#ages[last]
303
351
  this.#lifetimes[i] = this.#lifetimes[last]
304
352
  this.#palette[i] = this.#palette[last]
353
+ for (const a of [this.geometry.sizes, this.geometry.opacities, this.geometry.angles, this.#sizeFactors, this.#spins]) {
354
+ if (a) a[i] = a[last]
355
+ }
305
356
  }
306
357
 
307
358
  /** @param {number} i @param {number} t 0-1 through its life */
308
359
  #paint(i, t) {
360
+ const { sizes, opacities } = this.geometry
361
+ if (sizes) sizes[i] = along(this.#size, t) * (this.#sizeFactors ? this.#sizeFactors[i] : 1)
362
+ if (opacities) opacities[i] = along(this.#opacity, t)
309
363
  const c = /** @type {Float32Array} */ (this.geometry.colors), stops = this.#palettes[this.#palette[i]]
310
364
  if (stops.length === 1) {
311
365
  c.set(stops[0], i * 3)
@@ -349,8 +403,9 @@ export class Emitter extends Points {
349
403
  if (o.color !== undefined) this.#palettes[0] = colorStops(o.color)
350
404
  }
351
405
 
352
- /** @param {{ rate?: number, gravity?: Vec3Input, drag?: number, follow?: Node | null, onParticle?: ((p: Particle) => boolean | void) | null }} o */
406
+ /** @param {LookOptions & { rate?: number, gravity?: Vec3Input, drag?: number, follow?: Node | null, onParticle?: ((p: Particle) => boolean | void) | null }} o */
353
407
  #setBehaviour(o) {
408
+ this.#setLooks(o)
354
409
  const owner = 'Emitter'
355
410
  if (o.rate !== undefined) this.rate = Math.max(0, checkNumber(owner, 'rate', o.rate))
356
411
  if (o.gravity !== undefined) this.gravity.copy(o.gravity, owner)
@@ -359,6 +414,31 @@ export class Emitter extends Points {
359
414
  if (o.onParticle !== undefined) this.onParticle = o.onParticle
360
415
  }
361
416
 
417
+ // Sizes, opacities and angles go on the geometry when first wanted (the points are then drawn as
418
+ // squares) and stay; live particles get the plain values until they are next painted.
419
+ /** @param {LookOptions} o */
420
+ #setLooks(o) {
421
+ const owner = 'Emitter', g = this.geometry, max = this.#max
422
+ if (o.size !== undefined) this.#size = lookStops('size', o.size, Infinity)
423
+ if (o.sizeSpread !== undefined) this.#sizeSpread = Math.min(Math.max(checkNumber(owner, 'sizeSpread', o.sizeSpread), 0), 1)
424
+ if (o.opacity !== undefined) this.#opacity = lookStops('opacity', o.opacity, 1)
425
+ if (o.spin !== undefined) this.#spin = spinRange(o.spin)
426
+ const plain = (/** @type {number[]} */ stops) => stops.length === 1 && stops[0] === 1
427
+ if (!g.sizes && (!plain(this.#size) || this.#sizeSpread)) g.sizes = new Float32Array(max).fill(1)
428
+ if (!this.#sizeFactors && this.#sizeSpread) this.#sizeFactors = new Float32Array(max).fill(1)
429
+ if (!g.opacities && !plain(this.#opacity)) g.opacities = new Float32Array(max).fill(1)
430
+ if (!g.angles && (this.#spin[0] || this.#spin[1])) {
431
+ g.angles = new Float32Array(max)
432
+ this.#spins = new Float32Array(max)
433
+ }
434
+ }
435
+
436
+ /** The looks, as options. @returns {LookOptions} */
437
+ #looks() {
438
+ return { size: unstop(this.#size), sizeSpread: this.#sizeSpread, opacity: unstop(this.#opacity),
439
+ spin: /** @type {[number, number]} */ ([...this.#spin]) }
440
+ }
441
+
362
442
  /** The spawn settings other than colour, as options. @returns {SpawnOptions} */
363
443
  #saveSpawn() {
364
444
  return { origin: this.origin.clone(), originSpread: this.originSpread, velocity: this.velocity.clone(),
@@ -397,6 +477,37 @@ function lifetimeRange(v) {
397
477
  return [Math.min(range[0], range[1]), Math.max(range[0], range[1])]
398
478
  }
399
479
 
480
+ /** @param {string} name @param {number | number[]} v @param {number} most @returns {number[]} */
481
+ function lookStops(name, v, most) {
482
+ const list = Array.isArray(v) ? v : [v]
483
+ if (!list.length || !list.every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)) {
484
+ fail('Emitter', `'${name}' must be a number of 0 or more, or a list of them a particle goes through, got ${describe(v)}`)
485
+ }
486
+ return list.map((n) => Math.min(n, most))
487
+ }
488
+
489
+ /** One stop as a number, several as a list. @param {number[]} stops @returns {number | number[]} */
490
+ function unstop(stops) {
491
+ return stops.length === 1 ? stops[0] : [...stops]
492
+ }
493
+
494
+ /** The value t (0-1) of the way through evenly spaced stops. @param {number[]} stops @param {number} t */
495
+ function along(stops, t) {
496
+ if (stops.length === 1) return stops[0]
497
+ const x = Math.min(Math.max(t, 0), 1) * (stops.length - 1)
498
+ const s = Math.min(Math.floor(x), stops.length - 2)
499
+ return stops[s] + (stops[s + 1] - stops[s]) * (x - s)
500
+ }
501
+
502
+ /** @param {number | [number, number]} v @returns {[number, number]} */
503
+ function spinRange(v) {
504
+ const range = typeof v === 'number' ? [-Math.abs(v), Math.abs(v)] : v
505
+ if (!Array.isArray(range) || range.length !== 2 || !range.every((n) => typeof n === 'number' && Number.isFinite(n))) {
506
+ fail('Emitter', `'spin' must be radians per second, or a [min, max] range, got ${describe(v)}`)
507
+ }
508
+ return [Math.min(range[0], range[1]), Math.max(range[0], range[1])]
509
+ }
510
+
400
511
  /** @param {ColorInput | ColorInput[]} v @returns {number[][]} */
401
512
  function colorStops(v) {
402
513
  // a single [r, g, b] array is one colour, not three
@@ -466,20 +466,44 @@ export class InstancedMesh extends Mesh {
466
466
  /** Each vertex drawn as a square point sprite: star fields, particles, scatter plots. */
467
467
  export class Points extends Drawable {
468
468
  static type = 'points'
469
- static get OPTIONS() { return [...DRAWABLE_OPTIONS, 'labels'] }
469
+ static get OPTIONS() { return [...DRAWABLE_OPTIONS, 'labels', 'sort'] }
470
470
 
471
471
  /** A label per point from its row of data - or a list of them, several per point. @type {ReturnType<typeof instanceLabels>} */
472
472
  labels = null
473
+ /**
474
+ * Draw the points back to front, furthest first, each frame - for soft points that cover what is
475
+ * behind them (smoke, with blending 'normal'), which then layer the right way round. Drawn as squares.
476
+ */
477
+ sort = false
473
478
 
474
- /** @param {DrawableOptions & { labels?: InstanceLabels | InstanceLabels[] | null }} options */
479
+ /** @param {DrawableOptions & { labels?: InstanceLabels | InstanceLabels[] | null, sort?: boolean }} options */
475
480
  constructor(options) {
476
481
  super(options)
477
482
  this.labels = instanceLabels(this.constructor.name, options?.labels)
483
+ if (options?.sort !== undefined) {
484
+ if (typeof options.sort !== 'boolean') fail(this.constructor.name, `'sort' is true or false, got ${describe(options.sort)}`)
485
+ this.sort = options.sort
486
+ }
487
+ }
488
+
489
+ /**
490
+ * @internal Drawn as squares, one a point, rather than as GL points: when the geometry gives each
491
+ * point its own size, opacity or angle, or the points are sorted. Squares can be any size and stay
492
+ * whole at the edge of the view.
493
+ */
494
+ get drawsSprites() {
495
+ const g = this.geometry
496
+ return this.sort || !!(g.sizes || g.opacities || g.angles)
478
497
  }
479
498
 
480
499
  /** @internal @returns {Record<string, unknown>} */
481
500
  cloneOptions() {
482
- return { ...super.cloneOptions(), labels: this.labels }
501
+ return { ...super.cloneOptions(), labels: this.labels, sort: this.sort }
502
+ }
503
+
504
+ /** @internal @returns {Record<string, unknown>} */
505
+ jsonOptions() {
506
+ return { ...super.jsonOptions(), ...(this.sort ? { sort: true } : {}) }
483
507
  }
484
508
 
485
509
  /** @returns {'points'} */
@@ -488,12 +512,52 @@ export class Points extends Drawable {
488
512
  }
489
513
  }
490
514
 
515
+ const ARROWS = ['none', 'start', 'end', 'both']
516
+
517
+ /**
518
+ * @typedef {DrawableOptions & { arrows?: 'none' | 'start' | 'end' | 'both', arrowSize?: number }} LinesOptions
519
+ */
520
+
491
521
  /**
492
522
  * Line segments: indices (or consecutive vertices) in pairs. Width is in pixels and works on
493
- * every platform. edges(geometry) makes a wireframe of any geometry.
523
+ * every platform. edges(geometry) makes a wireframe of any geometry. Segments each starting where
524
+ * the one before ended make a chain - a polyline - which dashes run on along and arrowheads finish.
494
525
  */
495
526
  export class Lines extends Drawable {
496
527
  static type = 'lines'
528
+ static get OPTIONS() { return [...DRAWABLE_OPTIONS, 'arrows', 'arrowSize'] }
529
+
530
+ /** Arrowheads at the ends of each chain: at its 'start', its 'end', 'both' or 'none'. @type {'none' | 'start' | 'end' | 'both'} */
531
+ arrows = 'none'
532
+ /** Arrowhead length in pixels; 0 sizes them from the material's width. */
533
+ arrowSize = 0
534
+
535
+ /** @param {LinesOptions} options */
536
+ constructor(options) {
537
+ super(options)
538
+ if (options?.arrows !== undefined) this.arrows = /** @type {any} */ (checkOneOf('Lines', 'arrows', options.arrows, ARROWS))
539
+ if (options?.arrowSize !== undefined) this.arrowSize = Math.max(0, checkNumber('Lines', 'arrowSize', options.arrowSize))
540
+ }
541
+
542
+ /** @internal Which ends get arrowheads, for the shader: 1 the start, 2 the end. */
543
+ get arrowBits() {
544
+ return ARROWS.indexOf(this.arrows)
545
+ }
546
+
547
+ /** @internal An arrowhead's length in pixels, before the pixel ratio. @param {number} width */
548
+ arrowPixels(width) {
549
+ return this.arrowSize || 6 + width * 3
550
+ }
551
+
552
+ /** @internal @returns {Record<string, unknown>} */
553
+ cloneOptions() {
554
+ return { ...super.cloneOptions(), arrows: this.arrows, arrowSize: this.arrowSize }
555
+ }
556
+
557
+ /** @internal @returns {Record<string, unknown>} */
558
+ jsonOptions() {
559
+ return { ...super.jsonOptions(), ...(this.arrows !== 'none' ? { arrows: this.arrows } : {}), ...(this.arrowSize ? { arrowSize: this.arrowSize } : {}) }
560
+ }
497
561
 
498
562
  /** @returns {'lines'} */
499
563
  get drawMode() {
package/src/watch.js CHANGED
@@ -147,7 +147,11 @@ export class ChangeWatch {
147
147
  this.#push(g.version)
148
148
  this.#push(g.drawCount ?? -1)
149
149
  this.#push(node.renderOrder)
150
- this.#push((node.castShadow ? 1 : 0) | (node.receiveShadow ? 2 : 0))
150
+ this.#push((node.castShadow ? 1 : 0) | (node.receiveShadow ? 2 : 0) | (node.sort ? 4 : 0))
151
+ if ('arrowBits' in node) {
152
+ this.#push(node.arrowBits)
153
+ this.#push(node.arrowSize)
154
+ }
151
155
  // per-instance labels come from these, so swapping either redraws
152
156
  this.#push(node.labels ? idOf(node.labels) : 0)
153
157
  this.#push(node.data && typeof node.data === 'object' ? idOf(node.data) : 0)
@@ -164,6 +168,9 @@ export class ChangeWatch {
164
168
  this.#push(m.metalness)
165
169
  this.#push(m.size)
166
170
  this.#push(m.width)
171
+ this.#push(m.dash)
172
+ this.#push(m.gap)
173
+ this.#push(m.dashOffset)
167
174
  this.#push(m.alphaTest)
168
175
  this.#push(m.normalScale)
169
176
  this.#push(m.aoMapIntensity)
@@ -117,6 +117,8 @@ export class WebGLRenderer {
117
117
  /** This frame's prefiltered environment, when it is an HDR image. @type {Environment | null} */ #environment = null
118
118
  /** This frame's shadows - directional, spot, point - for the meshes that receive them. @type {import('./passes.js').ShadowMap[]} */ #shadows = []
119
119
  /** @type {Float32Array} */ #normalMatrix = new Float32Array(9)
120
+ /** Model-view for sorting points back to front. */
121
+ #modelView = new Float32Array(16)
120
122
  /** @type {Record<string, any>} */ #lights = {}
121
123
  /** This frame waits for shaders to compile rather than leaving things out. */
122
124
  #wait = false
@@ -407,6 +409,7 @@ export class WebGLRenderer {
407
409
 
408
410
  const clip = kind === 'mesh' ? item.clip : null
409
411
  const defines = this.#defines(kind, material, geometry.colors !== null, !!instances, geometry.uvs2 !== null)
412
+ if (kind === 'points' && /** @type {any} */ (node).drawsSprites) defines.push('SPRITES')
410
413
  for (const shadow of shadows) defines.push(shadow.define)
411
414
  const fog = material.fog ? frame.fog : null
412
415
  if (fog) {
@@ -531,9 +534,18 @@ export class WebGLRenderer {
531
534
  if (kind === 'lines') {
532
535
  gl.disable(gl.CULL_FACE)
533
536
  program.set('uWidth', material.width * this.pixelRatio)
537
+ const arrows = /** @type {any} */ (node).arrowBits ?? 0
538
+ program.set('uArrows', arrows)
539
+ program.set('uArrowSize', /** @type {any} */ (node).arrowPixels?.(material.width) * this.pixelRatio || 0)
540
+ if (material.dash > 0) {
541
+ program.set('uDash', material.dash)
542
+ program.set('uGap', material.gap || material.dash)
543
+ program.set('uDashOffset', material.dashOffset)
544
+ }
534
545
  const buffers = this.#gpu.resources.lines(geometry)
535
546
  gl.bindVertexArray(buffers.vao)
536
- gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, Math.min(buffers.count, Math.floor(geometry.drawnCount / 2)))
547
+ // a quad a segment, and two arrowheads where there are any
548
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, arrows ? 12 : 6, Math.min(buffers.count, Math.floor(geometry.drawnCount / 2)))
537
549
  this.info.drawCalls++
538
550
  return
539
551
  }
@@ -541,9 +553,27 @@ export class WebGLRenderer {
541
553
  if (kind === 'points') {
542
554
  gl.disable(gl.CULL_FACE)
543
555
  program.set('uSize', material.sizeAttenuation ? material.size : material.size * this.pixelRatio)
544
- const buffers = this.#gpu.resources.mesh(geometry, null, false)
545
- gl.bindVertexArray(buffers.vao)
546
- gl.drawArrays(gl.POINTS, 0, geometry.drawnVertices)
556
+ // round points are solid, but their edges are soft: blended over what is behind even in the
557
+ // opaque pass, or the edge fades to black - a dark ring round every dot
558
+ const round = !transparent && !material.map
559
+ if (round) {
560
+ gl.enable(gl.BLEND)
561
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
562
+ }
563
+ if (/** @type {any} */ (node).drawsSprites) {
564
+ // each point a square, any size and turned - drawn back to front when the points ask, for
565
+ // smoke and other soft things that hide what is behind them
566
+ program.set('uResolution', [this.canvas.width, this.canvas.height])
567
+ const modelView = /** @type {any} */ (node).sort ? mat4.multiply(this.#modelView, frame.camera.view, worldMatrix) : null
568
+ const buffers = this.#gpu.resources.sprites(geometry, modelView)
569
+ gl.bindVertexArray(buffers.vao)
570
+ gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, buffers.count)
571
+ } else {
572
+ const buffers = this.#gpu.resources.mesh(geometry, null, false)
573
+ gl.bindVertexArray(buffers.vao)
574
+ gl.drawArrays(gl.POINTS, 0, geometry.drawnVertices)
575
+ }
576
+ if (round) gl.disable(gl.BLEND)
547
577
  this.info.drawCalls++
548
578
  return
549
579
  }
@@ -676,7 +706,8 @@ export class WebGLRenderer {
676
706
  } else if (kind === 'points') {
677
707
  if (material.map) d.push('MAP')
678
708
  if (material.sizeAttenuation) d.push('SIZE_ATTENUATION')
679
- }
709
+ } else if (material.dash > 0) d.push('DASHED')
710
+ // (points drawn as squares add SPRITES - see #draw, which knows the node)
680
711
  return d
681
712
  }
682
713
  }
@@ -18,6 +18,9 @@ const PICK_FRAGMENT = /* glsl */ `
18
18
  precision highp float;
19
19
  precision highp int;
20
20
  flat in uint vPickElement;
21
+ #ifdef SPRITES
22
+ in vec2 vUv;
23
+ #endif
21
24
  #ifdef CLIP
22
25
  in vec3 vWorldPosition;
23
26
  #endif
@@ -30,7 +33,11 @@ void main() {
30
33
  clipAway(vWorldPosition);
31
34
  #endif
32
35
  #ifdef ROUND
36
+ #ifdef SPRITES
37
+ vec2 p = vUv * 2.0 - 1.0;
38
+ #else
33
39
  vec2 p = gl_PointCoord * 2.0 - 1.0;
40
+ #endif
34
41
  if (dot(p, p) > 1.0) discard;
35
42
  #endif
36
43
  pickOut = uvec4(uPickId, vPickElement, floatBitsToUint(gl_FragCoord.z), 1u);
@@ -219,9 +226,11 @@ export class Picker {
219
226
  const kind = mode === 'points' ? 'points' : mode === 'lines' ? 'lines' : 'mesh'
220
227
  const defines = ['PICK']
221
228
  if (instances) defines.push('INSTANCED')
229
+ const sprites = kind === 'points' && /** @type {any} */ (node).drawsSprites
222
230
  if (kind === 'points') {
223
231
  if (material.sizeAttenuation) defines.push('SIZE_ATTENUATION')
224
232
  if (!material.map) defines.push('ROUND')
233
+ if (sprites) defines.push('SPRITES')
225
234
  }
226
235
  if (kind === 'mesh' && item.clip) defines.push('CLIP')
227
236
  const program = this.programs.ready(`pick-${kind}`, defines, SOURCES[kind])
@@ -240,9 +249,13 @@ export class Picker {
240
249
  program.set('uResolution', [REGION, REGION])
241
250
  program.set('uWidth', material.width * pixelRatio)
242
251
  program.set('uNear', frame.camera.near)
252
+ // arrowheads pick as their segment; dashes' gaps do too, so a dashed link is as easy to hit
253
+ const arrows = /** @type {any} */ (node).arrowBits ?? 0
254
+ program.set('uArrows', arrows)
255
+ program.set('uArrowSize', /** @type {any} */ (node).arrowPixels?.(material.width) * pixelRatio || 0)
243
256
  const buffers = this.resources.lines(geometry)
244
257
  gl.bindVertexArray(buffers.vao)
245
- gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, Math.min(buffers.count, Math.floor(geometry.drawnCount / 2)))
258
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, arrows ? 12 : 6, Math.min(buffers.count, Math.floor(geometry.drawnCount / 2)))
246
259
  return
247
260
  }
248
261
  if (kind === 'points') {
@@ -250,6 +263,14 @@ export class Picker {
250
263
  // sizes as drawn: world sizes scale by the real projection, not the zoomed one
251
264
  program.set('uSize', material.sizeAttenuation ? material.size : material.size * pixelRatio)
252
265
  program.set('uScale', frame.camera.projection[5] * height / 2)
266
+ if (sprites) {
267
+ // squares, in whatever order they were last drawn - each says which point it is
268
+ program.set('uResolution', [REGION, REGION])
269
+ const buffers = this.resources.sprites(geometry)
270
+ gl.bindVertexArray(buffers.vao)
271
+ gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, buffers.count)
272
+ return
273
+ }
253
274
  const buffers = this.resources.mesh(geometry, null, false)
254
275
  gl.bindVertexArray(buffers.vao)
255
276
  gl.drawArrays(gl.POINTS, 0, geometry.drawnVertices)