phoria2 1.2.0 → 1.4.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 (69) hide show
  1. package/README.md +11 -9
  2. package/dist/bloom.js +4 -4
  3. package/dist/bloom.js.map +1 -1
  4. package/dist/charts.js +527 -510
  5. package/dist/charts.js.map +1 -1
  6. package/dist/core.js +1760 -1170
  7. package/dist/core.js.map +1 -1
  8. package/dist/debug.js +297 -0
  9. package/dist/debug.js.map +1 -0
  10. package/dist/edit.js +60 -60
  11. package/dist/edit.js.map +1 -1
  12. package/dist/explain.js +32 -13
  13. package/dist/explain.js.map +1 -1
  14. package/dist/gltf.js +155 -155
  15. package/dist/gltf.js.map +1 -1
  16. package/dist/keyboard.js +188 -0
  17. package/dist/keyboard.js.map +1 -0
  18. package/dist/motion.js +35 -35
  19. package/dist/motion.js.map +1 -1
  20. package/dist/particles.js +187 -114
  21. package/dist/particles.js.map +1 -1
  22. package/dist/passes.js +116 -104
  23. package/dist/passes.js.map +1 -1
  24. package/dist/phoria2.js +1 -1
  25. package/dist/picking.js +95 -62
  26. package/dist/picking.js.map +1 -1
  27. package/dist/snapshot.js +1 -1
  28. package/dist/tooltip.js +1 -1
  29. package/dist/types/View.d.ts +27 -0
  30. package/dist/types/area.d.ts +18 -0
  31. package/dist/types/debug.d.ts +74 -0
  32. package/dist/types/explain.d.ts +4 -0
  33. package/dist/types/frame.d.ts +5 -0
  34. package/dist/types/geometry/Geometry.d.ts +16 -0
  35. package/dist/types/index.d.ts +2 -0
  36. package/dist/types/material/Material.d.ts +67 -0
  37. package/dist/types/material/Texture.d.ts +18 -2
  38. package/dist/types/particles/Emitter.d.ts +39 -2
  39. package/dist/types/scene/drawables.d.ts +23 -2
  40. package/dist/types/watch.d.ts +8 -1
  41. package/dist/types/webgl/hooks.d.ts +48 -0
  42. package/dist/types/webgl/passes.d.ts +4 -0
  43. package/dist/types/webgl/programs.d.ts +15 -5
  44. package/dist/types/webgl/resources.d.ts +12 -2
  45. package/dist/types/webgl/shaders.d.ts +41 -5
  46. package/llms-full.txt +275 -1
  47. package/llms.txt +1 -1
  48. package/package.json +1 -1
  49. package/schema/scene.schema.json +75 -0
  50. package/src/View.js +121 -7
  51. package/src/area.js +131 -0
  52. package/src/charts/marks.js +29 -2
  53. package/src/debug.js +283 -0
  54. package/src/explain.js +19 -0
  55. package/src/frame.js +7 -2
  56. package/src/geometry/Geometry.js +16 -1
  57. package/src/index.js +1 -0
  58. package/src/material/Material.js +130 -4
  59. package/src/material/Texture.js +55 -2
  60. package/src/particles/Emitter.js +121 -10
  61. package/src/scene/drawables.js +89 -8
  62. package/src/watch.js +26 -3
  63. package/src/webgl/WebGLRenderer.js +55 -7
  64. package/src/webgl/hooks.js +121 -0
  65. package/src/webgl/passes.js +17 -2
  66. package/src/webgl/picking.js +45 -3
  67. package/src/webgl/programs.js +20 -9
  68. package/src/webgl/resources.js +169 -15
  69. package/src/webgl/shaders.js +545 -38
package/src/debug.js ADDED
@@ -0,0 +1,283 @@
1
+ // view.debug: pictures for a person checking a scene - each light and the way it shines, the shadow
2
+ // cameras, bounding boxes, normals, the axes, the orbit target - and a corner readout of what a frame
3
+ // costs. view.explain() is the text an agent reads; this is what a person looks at. Loaded only when
4
+ // debug is turned on. The helpers are drawn by the View after the scene, not added to it, so they
5
+ // are never picked, saved, framed or counted: lines in the scene's depth, and faintly through
6
+ // whatever is in front of them, so a light inside a lamp can still be found.
7
+ import { collect } from './frame.js'
8
+ import { Geometry } from './geometry/Geometry.js'
9
+ import { Lines } from './scene/drawables.js'
10
+ import { Scene } from './scene/Scene.js'
11
+ import { Box3 } from './value/Box3.js'
12
+ import { Mat4 } from './value/Mat4.js'
13
+ import { Vec3 } from './value/Vec3.js'
14
+
15
+ /** @import { DrawItem, FrameDescription } from './frame.js' */
16
+ /** @import { Hit } from './Raycaster.js' */
17
+ /** @import { Camera } from './scene/cameras.js' */
18
+
19
+ /**
20
+ * What debug draws. `true` for a helper shows it for everything; `'hovered'` (bounds and normals) only
21
+ * for what is under the pointer.
22
+ * @typedef {object} DebugOptions
23
+ * @property {boolean} [lights] each light: an arrow for directional ones, a small sphere and its range
24
+ * for point lights, a cone for spot lights - in the light's colour
25
+ * @property {boolean} [shadows] the box or cone each shadow is drawn from - the usual reason a shadow
26
+ * is cut off or soft
27
+ * @property {boolean | 'hovered'} [bounds] world bounding boxes - what culling and view.frame() see
28
+ * @property {boolean | 'hovered'} [normals] short lines along vertex normals (meshes up to 5,000
29
+ * vertices each when all are shown)
30
+ * @property {boolean} [axes] x, y and z at the origin, red, green and blue
31
+ * @property {boolean} [camera] the point the camera orbits round
32
+ * @property {boolean | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'} [stats] a readout of
33
+ * frame time, draw calls, triangles, programs, textures and geometries, in a corner of the canvas
34
+ */
35
+
36
+ const COLORS = { shadow: [1, 0.8, 0], bounds: [0, 0.9, 1], normal: [1, 0.31, 0.85], camera: [1, 1, 1] }
37
+ // normals, when shown for everything: past these a model would be a hedgehog
38
+ const NORMALS_PER_MESH = 5000, NORMALS_IN_ALL = 40000
39
+
40
+ export class DebugDraw {
41
+ #scene = new Scene()
42
+ #geometry = new Geometry({ positions: [0, 0, 0, 0, 0, 0] })
43
+ // the same lines twice: in the scene's depth, and faintly through what is in front of them
44
+ #solid = /** @type {Lines} */ (this.#scene.add(new Lines({ geometry: this.#geometry, pickable: false, frustumCulled: false,
45
+ material: { vertexColors: true, width: 1.5, fog: false } })))
46
+ #faint = /** @type {Lines} */ (this.#scene.add(new Lines({ geometry: this.#geometry, pickable: false, frustumCulled: false, renderOrder: 1e9,
47
+ material: { vertexColors: true, width: 1, opacity: 0.22, depthTest: false, depthWrite: false, fog: false } })))
48
+ /** @type {HTMLDivElement | null} */ #stats = null
49
+ #statsAt = 0
50
+ /** @type {number[]} */ #positions = []
51
+ /** @type {number[]} */ #colors = []
52
+
53
+ /** @param {HTMLElement | null} overlay where the stats readout goes - the layer over the canvas */
54
+ constructor(overlay) {
55
+ this.overlay = overlay
56
+ }
57
+
58
+ /**
59
+ * Add the helpers to a frame about to be drawn, after everything in it. Returns how many items were
60
+ * added to each list, to take out again once it is drawn.
61
+ * @param {FrameDescription} frame @param {DebugOptions} o
62
+ * @param {{ camera: Camera, hovered: Hit | null, bounds: Box3 }} at
63
+ * @returns {{ opaque: number, transparent: number }}
64
+ */
65
+ add(frame, o, at) {
66
+ this.#positions = []
67
+ this.#colors = []
68
+ const items = [...frame.opaque, ...frame.transparent]
69
+ const size = at.bounds.isEmpty() ? 1 : Math.max(at.bounds.getRadius(), 1e-3)
70
+ const middle = at.bounds.isEmpty() ? new Vec3() : at.bounds.getCenter()
71
+ if (o.lights) this.#lights(frame, size, middle)
72
+ if (o.shadows) this.#shadows(frame)
73
+ if (o.bounds) this.#bounds(items, o.bounds === 'hovered' ? at.hovered : null, o.bounds === 'hovered')
74
+ if (o.normals) this.#normals(items, o.normals === 'hovered' ? at.hovered : null, o.normals === 'hovered', size * 0.025)
75
+ if (o.axes) {
76
+ this.#segment([0, 0, 0], [size * 0.5, 0, 0], [1, 0.2, 0.2])
77
+ this.#segment([0, 0, 0], [0, size * 0.5, 0], [0.3, 1, 0.3])
78
+ this.#segment([0, 0, 0], [0, 0, size * 0.5], [0.3, 0.5, 1])
79
+ }
80
+ const target = /** @type {any} */ (at.camera).target
81
+ if (o.camera && target) this.#cross(target, size * 0.04, COLORS.camera)
82
+ if (!this.#positions.length) return { opaque: 0, transparent: 0 }
83
+
84
+ // one buffer for all of it, made again only when it changes length
85
+ const n = this.#positions.length
86
+ const colors = this.#geometry.colors
87
+ if (this.#geometry.positions.length === n && colors) {
88
+ this.#geometry.positions.set(this.#positions)
89
+ colors.set(this.#colors)
90
+ this.#geometry.needsUpdate()
91
+ } else {
92
+ this.#geometry.dispose()
93
+ this.#geometry = new Geometry({ positions: this.#positions, colors: this.#colors })
94
+ this.#solid.geometry = this.#faint.geometry = this.#geometry
95
+ }
96
+ this.#scene.updateWorldTree()
97
+ const drawn = collect(this.#scene, at.camera)
98
+ frame.opaque.push(...drawn.opaque)
99
+ frame.transparent.push(...drawn.transparent)
100
+ return { opaque: drawn.opaque.length, transparent: drawn.transparent.length }
101
+ }
102
+
103
+ /**
104
+ * The corner readout, refreshed a few times a second.
105
+ * @param {DebugOptions['stats']} where
106
+ * @param {{ drawCalls: number, triangles: number, programs: number, textures: number, geometries: number, compiling: number }} info
107
+ * @param {number} frameMs time between drawn frames @param {number} cpuMs time the last frame took to draw
108
+ */
109
+ stats(where, info, frameMs, cpuMs) {
110
+ if (!where || !this.overlay || typeof document === 'undefined') {
111
+ this.#stats?.remove()
112
+ this.#stats = null
113
+ return
114
+ }
115
+ const corner = where === true ? 'top-left' : where
116
+ if (!this.#stats) {
117
+ this.#stats = document.createElement('div')
118
+ this.#stats.className = 'phoria-debug-stats'
119
+ Object.assign(this.#stats.style, { position: 'absolute', zIndex: '3', pointerEvents: 'none', padding: '6px 8px', borderRadius: '6px',
120
+ background: 'rgba(10, 12, 16, 0.72)', color: '#e8ecf2', font: '11px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace', whiteSpace: 'pre' })
121
+ this.overlay.append(this.#stats)
122
+ }
123
+ const [v, h] = corner.split('-')
124
+ Object.assign(this.#stats.style, { top: v === 'top' ? '8px' : '', bottom: v === 'bottom' ? '8px' : '', left: h === 'left' ? '8px' : '', right: h === 'right' ? '8px' : '' })
125
+ const now = performance.now()
126
+ if (now - this.#statsAt < 250) return
127
+ this.#statsAt = now
128
+ const k = (x) => (x >= 1e6 ? `${(x / 1e6).toFixed(1)}M` : x >= 1e4 ? `${Math.round(x / 1e3)}k` : String(x))
129
+ const fps = frameMs > 0 ? Math.round(1000 / frameMs) : 0
130
+ this.#stats.textContent = [
131
+ `${fps ? `${fps} fps ${frameMs.toFixed(1)} ms` : 'idle'} cpu ${cpuMs.toFixed(1)} ms`,
132
+ `${info.drawCalls} draws ${k(info.triangles)} triangles`,
133
+ `${info.programs} programs${info.compiling ? ` (${info.compiling} compiling)` : ''} ${info.textures} textures ${info.geometries} geometries`,
134
+ ].join('\n')
135
+ }
136
+
137
+ dispose() {
138
+ this.#stats?.remove()
139
+ this.#stats = null
140
+ this.#geometry.dispose()
141
+ }
142
+
143
+ // ---- the helpers, as line segments in world space, colours sRGB
144
+
145
+ /** @param {ArrayLike<number>} a @param {ArrayLike<number>} b @param {ArrayLike<number>} c */
146
+ #segment(a, b, c) {
147
+ this.#positions.push(a[0], a[1], a[2], b[0], b[1], b[2])
148
+ this.#colors.push(c[0], c[1], c[2], c[0], c[1], c[2])
149
+ }
150
+
151
+ /** @param {ArrayLike<number>} p @param {number} r @param {ArrayLike<number>} c */
152
+ #cross(p, r, c) {
153
+ for (let k = 0; k < 3; k++) {
154
+ const a = [p[0], p[1], p[2]], b = [p[0], p[1], p[2]]
155
+ a[k] -= r
156
+ b[k] += r
157
+ this.#segment(a, b, c)
158
+ }
159
+ }
160
+
161
+ /** A circle round a centre, in the plane of two unit axes. @param {Vec3} centre @param {Vec3} u @param {Vec3} v @param {number} r @param {ArrayLike<number>} c */
162
+ #circle(centre, u, v, r, c, segments = 32) {
163
+ const at = (i) => {
164
+ const a = i / segments * Math.PI * 2, x = Math.cos(a) * r, y = Math.sin(a) * r
165
+ return [centre[0] + u[0] * x + v[0] * y, centre[1] + u[1] * x + v[1] * y, centre[2] + u[2] * x + v[2] * y]
166
+ }
167
+ for (let i = 0; i < segments; i++) this.#segment(at(i), at(i + 1), c)
168
+ }
169
+
170
+ /** The twelve edges of a box. @param {Box3} box @param {ArrayLike<number>} c */
171
+ #box(box, c) {
172
+ const { min: a, max: b } = box
173
+ const corner = (i) => [i & 1 ? b[0] : a[0], i & 2 ? b[1] : a[1], i & 4 ? b[2] : a[2]]
174
+ for (const [i, j] of EDGES) this.#segment(corner(i), corner(j), c)
175
+ }
176
+
177
+ /** The edges of what a view-projection matrix sees: a box for an orthographic one, a frustum for a perspective one. @param {ArrayLike<number>} viewProjection @param {ArrayLike<number>} c */
178
+ #frustum(viewProjection, c) {
179
+ const inverse = new Mat4().copy(viewProjection).invert()
180
+ const corner = (i) => new Vec3(i & 1 ? 1 : -1, i & 2 ? 1 : -1, i & 4 ? 1 : -1).applyMatrix4(inverse)
181
+ for (const [i, j] of EDGES) this.#segment(corner(i), corner(j), c)
182
+ }
183
+
184
+ /** @param {FrameDescription} frame @param {number} size @param {Vec3} middle */
185
+ #lights(frame, size, middle) {
186
+ for (const l of frame.lights) {
187
+ const c = l.color.toSRGB().map((x) => Math.min(1, x))
188
+ if (l.kind === 'directional' && l.direction) {
189
+ // an arrow coming in over the scene's middle, the way the light travels
190
+ const d = new Vec3().copy(l.direction).normalize()
191
+ const from = moved(middle, d, -size * 1.1), to = moved(middle, d, -size * 0.45)
192
+ this.#segment(from, to, c)
193
+ const [u, v] = across(d)
194
+ for (const s of [u, v, u.clone().negate(), v.clone().negate()]) {
195
+ this.#segment(to, moved(moved(to, d, -size * 0.08), s, size * 0.04), c)
196
+ }
197
+ } else if (l.kind === 'point' && l.position) {
198
+ const p = new Vec3().copy(l.position), r = size * 0.06
199
+ this.#circle(p, X, Y, r, c, 16)
200
+ this.#circle(p, X, Z, r, c, 16)
201
+ this.#circle(p, Y, Z, r, c, 16)
202
+ if (l.range) this.#circle(p, X, Z, l.range, c, 48)
203
+ } else if (l.kind === 'spot' && l.position && l.direction) {
204
+ // the cone out to its range (or the scene's size), with a ring where its edge is
205
+ const p = new Vec3().copy(l.position), d = new Vec3().copy(l.direction).normalize()
206
+ const length = l.range ? Math.min(l.range, size * 2) : size, r = length * Math.tan(l.angle ?? Math.PI / 4)
207
+ const end = moved(p, d, length)
208
+ const [u, v] = across(d)
209
+ this.#circle(end, u, v, r, c, 32)
210
+ for (const s of [u, v, u.clone().negate(), v.clone().negate()]) this.#segment(p, moved(end, s, r), c)
211
+ this.#segment(p, end, c)
212
+ }
213
+ }
214
+ }
215
+
216
+ /** @param {FrameDescription} frame */
217
+ #shadows(frame) {
218
+ if (frame.shadow) this.#frustum(frame.shadow.viewProjection, COLORS.shadow)
219
+ for (const s of frame.spotShadows) this.#frustum(s.viewProjection, COLORS.shadow)
220
+ }
221
+
222
+ /** @param {DrawItem[]} items @param {Hit | null} hovered @param {boolean} onlyHovered */
223
+ #bounds(items, hovered, onlyHovered) {
224
+ if (onlyHovered) {
225
+ if (!hovered) return
226
+ const node = /** @type {any} */ (hovered.node)
227
+ // an instance's own box, or the whole node's
228
+ if (hovered.instance !== null && typeof node.eachInstanceBox === 'function') {
229
+ let i = 0
230
+ node.eachInstanceBox((x0, y0, z0, x1, y1, z1) => {
231
+ if (i++ === hovered.instance) this.#box(new Box3([x0, y0, z0], [x1, y1, z1]), COLORS.bounds)
232
+ })
233
+ } else this.#box(node.expandBounds(new Box3()), COLORS.bounds)
234
+ return
235
+ }
236
+ const seen = new Set()
237
+ for (const item of items) {
238
+ if (seen.has(item.node)) continue
239
+ seen.add(item.node)
240
+ const box = /** @type {any} */ (item.node).expandBounds(new Box3())
241
+ if (!box.isEmpty()) this.#box(box, COLORS.bounds)
242
+ }
243
+ }
244
+
245
+ /** @param {DrawItem[]} items @param {Hit | null} hovered @param {boolean} onlyHovered @param {number} length */
246
+ #normals(items, hovered, onlyHovered, length) {
247
+ const meshes = onlyHovered
248
+ ? (hovered && /** @type {any} */ (hovered.node).drawMode === 'triangles' ? [/** @type {any} */ (hovered.node)] : [])
249
+ : [...new Set(items.map((i) => i.node))].filter((n) => /** @type {any} */ (n).drawMode === 'triangles')
250
+ let budget = NORMALS_IN_ALL
251
+ for (const node of /** @type {any[]} */ (meshes)) {
252
+ if (node.instanceMatrices) continue
253
+ const g = node.geometry, count = g.vertexCount
254
+ if (!g.normals || (!onlyHovered && (count > NORMALS_PER_MESH || count > budget))) continue
255
+ budget -= count
256
+ const m = node.worldMatrix, p = g.positions, n = g.normals
257
+ const a = new Vec3(), d = new Vec3()
258
+ for (let i = 0; i < count; i++) {
259
+ a.set(p[i * 3], p[i * 3 + 1], p[i * 3 + 2]).applyMatrix4(m)
260
+ // the normal turned with the node (the upper 3x3: right for rotation and even scale)
261
+ d.set(m[0] * n[i * 3] + m[4] * n[i * 3 + 1] + m[8] * n[i * 3 + 2], m[1] * n[i * 3] + m[5] * n[i * 3 + 1] + m[9] * n[i * 3 + 2],
262
+ m[2] * n[i * 3] + m[6] * n[i * 3 + 1] + m[10] * n[i * 3 + 2]).normalize()
263
+ this.#segment(a, [a[0] + d[0] * length, a[1] + d[1] * length, a[2] + d[2] * length], COLORS.normal)
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ const X = new Vec3(1, 0, 0), Y = new Vec3(0, 1, 0), Z = new Vec3(0, 0, 1)
270
+ // a box's corners by bit (x, y, z), and the twelve edges between them
271
+ const EDGES = [[0, 1], [2, 3], [4, 5], [6, 7], [0, 2], [1, 3], [4, 6], [5, 7], [0, 4], [1, 5], [2, 6], [3, 7]]
272
+
273
+ /** Two unit axes across a direction. @param {Vec3} d @returns {[Vec3, Vec3]} */
274
+ function across(d) {
275
+ const up = Math.abs(d[1]) < 0.9 ? Y : X
276
+ const u = d.clone().cross(up).normalize()
277
+ return [u, d.clone().cross(u).normalize()]
278
+ }
279
+
280
+ /** p moved s along d, as a new point. @param {ArrayLike<number>} p @param {ArrayLike<number>} d @param {number} s */
281
+ function moved(p, d, s) {
282
+ return new Vec3(p[0] + d[0] * s, p[1] + d[1] * s, p[2] + d[2] * s)
283
+ }
package/src/explain.js CHANGED
@@ -24,6 +24,7 @@ import { Box3 } from './value/Box3.js'
24
24
  * @property {boolean} [sharedContext] drawn with the page's shared WebGL context
25
25
  * @property {{ hover: number, click: number, tooltip: boolean, highlight: boolean, keyboard?: boolean, reducedMotion?: boolean, description?: string | null }} interaction
26
26
  * @property {string[]} [notes] lines from nodes that describe themselves, such as charts
27
+ * @property {Record<string, unknown> | null} [debug] what debug drawing shows, when it is on
27
28
  */
28
29
 
29
30
  const fmt = (n) => (Math.abs(n) >= 1e4 || (n !== 0 && Math.abs(n) < 1e-3) ? n.toExponential(2) : String(+n.toFixed(2)))
@@ -113,6 +114,19 @@ export function explain(info) {
113
114
  const invisible = items.filter((i) => i.material.opacity === 0).length
114
115
  if (invisible) warnings.push(`${plural(invisible, 'object')} with material opacity 0 - drawn but invisible`)
115
116
 
117
+ // shader hooks: which drawn things have GLSL of their own, and what they read
118
+ const hooked = items.filter((i) => i.material.shader)
119
+ if (hooked.length) {
120
+ const parts = (/** @type {any} */ s) => ['vertex', 'fragment', 'lit'].filter((p) => s[p]).join(', ')
121
+ lines.push(`Shader hooks: ${hooked.map((i) => {
122
+ const s = /** @type {any} */ (i.material.shader), n = /** @type {any} */ (i.node)
123
+ const reads = [...(/\buTime\b/.test(s.vertex + s.fragment + s.lit) ? ['uTime'] : []), ...Object.keys(s.uniforms)]
124
+ return `${n.constructor.name}${n.name ? ` '${n.name}'` : ''} (${parts(s)}${reads.length ? `; ${reads.join(', ')}` : ''})`
125
+ }).join(', ')}`)
126
+ const unculled = hooked.filter((i) => i.material.shader?.vertex && i.material.shader.bounds === null).length
127
+ if (unculled) lines.push(` ${plural(unculled, 'object')} moved by a vertex hook without shader.bounds - not frustum culled, and picked on the GPU`)
128
+ }
129
+
116
130
  if (info.labels && info.labels.total) {
117
131
  const s = info.labels
118
132
  lines.push(`Labels: ${s.shown} shown, ${s.dimmed ? `${s.dimmed} dimmed (covered by labels or geometry), ` : ''}${s.overlapped} hidden by overlap, ${s.occluded} hidden behind geometry, ${s.offscreen} off screen${s.zoomedOut ? `, ${s.zoomedOut} waiting to be zoomed in to (minScale)` : ''}`)
@@ -137,6 +151,11 @@ export function explain(info) {
137
151
  warnings.unshift(`nothing is in view: ${plural(frame.stats.culled, 'object')} are outside the camera's view - call view.frame() to fit the camera to the scene`)
138
152
  }
139
153
 
154
+ // debug drawing on: said, so a picture full of lines isn't a mystery
155
+ if (info.debug) {
156
+ const shown = Object.entries(info.debug).filter(([, v]) => v).map(([k, v]) => (v === true ? k : `${k} (${v})`))
157
+ lines.push(`Debug drawing: ${shown.join(', ') || 'nothing'} - helpers aren't part of the scene, picked or counted above`)
158
+ }
140
159
  lines.push(warnings.length ? `Warnings:\n${warnings.map((w) => `- ${w}`).join('\n')}` : 'No warnings.')
141
160
  return lines.join('\n')
142
161
  }
package/src/frame.js CHANGED
@@ -151,6 +151,7 @@ export const LIGHT_LIMITS = { punctual: 4, directional: 2 }
151
151
  * @property {PointShadowFrame | null} pointShadow a point light's shadow, all round it
152
152
  * @property {FrameCap[]} caps section planes' caps to fill, after the opaque pass
153
153
  * @property {{ drawn: number, culled: number }} stats
154
+ * @property {number} [time] the View's clock in seconds, for shader hooks that read uTime
154
155
  * @property {FrameHighlight | null} highlight set by the View for hover highlighting
155
156
  * @property {any[]} overlays HTML elements carried by the nodes drawn (node.overlay)
156
157
  * @property {boolean} interactive some node has a tooltip, highlight or pointer handler
@@ -191,6 +192,7 @@ export function collect(scene, camera) {
191
192
  pointShadow: null,
192
193
  caps: [],
193
194
  stats: { drawn: 0, culled: 0 },
195
+ time: 0,
194
196
  highlight: null,
195
197
  overlays: [],
196
198
  interactive: false,
@@ -359,8 +361,11 @@ function drawItem(node, view, planes, clip) {
359
361
  const center = box.getCenter().applyMatrix4(worldMatrix)
360
362
  const scale = Math.max(Math.hypot(worldMatrix[0], worldMatrix[1], worldMatrix[2]),
361
363
  Math.hypot(worldMatrix[4], worldMatrix[5], worldMatrix[6]), Math.hypot(worldMatrix[8], worldMatrix[9], worldMatrix[10]))
362
- // instanced meshes spread their copies anywhere, so they aren't culled as a whole
363
- if (planes && node.frustumCulled && !instances && !sphereInFrustum(planes, center, box.getRadius() * scale)) return null
364
+ // instanced meshes spread their copies anywhere, so they aren't culled as a whole; a shader hook
365
+ // that moves vertices is culled by its padded bounds, or - without them - not at all
366
+ const hooks = material.shader?.vertex ? material.shader : null
367
+ const pad = hooks ? hooks.bounds : 0
368
+ if (planes && node.frustumCulled && !instances && pad !== null && !sphereInFrustum(planes, center, box.getRadius() * scale + pad)) return null
364
369
  // all of it on the cut-away side of a clip - points and lines aren't cut, so they stay
365
370
  const cuts = clip && node.drawMode === 'triangles' ? clip : null
366
371
  if (cuts && !instances) {
@@ -9,10 +9,16 @@ import { checkOptions, describe, fail } from '../validate.js'
9
9
  * @property {ArrayLike<number>} [uvs2] a second uv per vertex, for maps that need every surface on its own
10
10
  * patch of texture - baked occlusion and light maps (a texture's uvSet: 1 reads it)
11
11
  * @property {ArrayLike<number>} [colors] rgb per vertex, sRGB 0-1 like every colour input
12
+ * @property {ArrayLike<number>} [sizes] points: one per vertex, a multiple of the material's size
13
+ * @property {ArrayLike<number>} [opacities] points: one per vertex, 0-1, multiplying the material's opacity
14
+ * @property {ArrayLike<number>} [angles] points: one per vertex, radians anticlockwise on screen - turns
15
+ * each point's texture
12
16
  * @property {ArrayLike<number>} [indices] vertex indices; triangles for meshes, pairs for lines
13
17
  */
14
18
 
15
- const OPTIONS = ['positions', 'normals', 'uvs', 'uvs2', 'colors', 'indices']
19
+ const OPTIONS = ['positions', 'normals', 'uvs', 'uvs2', 'colors', 'sizes', 'opacities', 'angles', 'indices']
20
+ // the per-point arrays: Points with any of them draw each point as its own square, sized, faded and turned
21
+ const POINT_ARRAYS = /** @type {const} */ (['sizes', 'opacities', 'angles'])
16
22
 
17
23
  /**
18
24
  * Vertex data: flat typed arrays plus an optional index. Generators such as box() and sphere()
@@ -25,6 +31,9 @@ export class Geometry {
25
31
  /** @type {Float32Array | null} */ uvs = null
26
32
  /** A second UV set, for baked occlusion and light maps. @type {Float32Array | null} */ uvs2 = null
27
33
  /** @type {Float32Array | null} */ colors = null
34
+ /** Points: each one's size, as a multiple of the material's. @type {Float32Array | null} */ sizes = null
35
+ /** Points: each one's opacity, 0-1, times the material's. @type {Float32Array | null} */ opacities = null
36
+ /** Points: each one's turn on screen, in radians anticlockwise. @type {Float32Array | null} */ angles = null
28
37
  /** @type {Uint16Array | Uint32Array | null} */ indices = null
29
38
 
30
39
  /** @internal Bumped by needsUpdate() so renderers know to re-upload. */
@@ -62,6 +71,7 @@ export class Geometry {
62
71
  if (options.uvs) this.uvs = toFloat32('uvs', options.uvs, 2, n)
63
72
  if (options.uvs2) this.uvs2 = toFloat32('uvs2', options.uvs2, 2, n)
64
73
  if (options.colors) this.colors = toFloat32('colors', options.colors, 3, n)
74
+ for (const name of POINT_ARRAYS) if (options[name]) this[name] = toFloat32(name, options[name], 1, n)
65
75
  if (options.indices) {
66
76
  // -1: no indices at all refer to no vertex, so an empty geometry can say so
67
77
  let max = -1
@@ -191,6 +201,9 @@ export class Geometry {
191
201
  uvs: this.uvs ?? undefined,
192
202
  uvs2: this.uvs2 ?? undefined,
193
203
  colors: this.colors ?? undefined,
204
+ sizes: this.sizes ?? undefined,
205
+ opacities: this.opacities ?? undefined,
206
+ angles: this.angles ?? undefined,
194
207
  indices: this.indices ?? undefined,
195
208
  })
196
209
  g.source = this.source && { ...this.source }
@@ -218,6 +231,7 @@ export class Geometry {
218
231
  if (this.uvs) json.uvs = Array.from(this.uvs)
219
232
  if (this.uvs2) json.uvs2 = Array.from(this.uvs2)
220
233
  if (this.colors) json.colors = Array.from(this.colors)
234
+ for (const name of POINT_ARRAYS) if (this[name]) json[name] = Array.from(/** @type {Float32Array} */ (this[name]))
221
235
  if (this.indices) json.indices = Array.from(this.indices)
222
236
  return json
223
237
  }
@@ -242,6 +256,7 @@ export class Geometry {
242
256
  this.uvs = expand(this.uvs, 2)
243
257
  this.uvs2 = expand(this.uvs2, 2)
244
258
  this.colors = expand(this.colors, 3)
259
+ for (const name of POINT_ARRAYS) this[name] = expand(this[name], 1)
245
260
  this.indices = null
246
261
  this.source = null
247
262
  }
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 */
@@ -7,6 +7,40 @@ import { Texture } from './Texture.js'
7
7
 
8
8
  /** A map: a Texture, its options, or just a url. @typedef {Texture | TextureOptions | string} MapInput */
9
9
 
10
+ /**
11
+ * A shader uniform's value: a number (float), two to four numbers (vec2-vec4), a colour - a colour
12
+ * string or Color, sent linear as the shader works (vec3) - or a Texture (sampler2D).
13
+ * @typedef {number | number[] | ColorInput | Texture} ShaderUniform
14
+ */
15
+
16
+ /**
17
+ * Small pieces of GLSL run inside phoria2's own shader, so lighting, shadows, fog, clipping,
18
+ * picking and outlines keep working. Every hook can read `uTime` (the View's clock, in seconds)
19
+ * and the uniforms given, and call - p a vec2 or vec3, every result 0-1 - `hash(x)` (the same on every
20
+ * GPU for the same bits), `vnoise(p)` (value noise), `snoise(p)` (simplex noise; `snoised(p)` with its
21
+ * gradient), and `vfbm(p)` / `sfbm(p)` / `sfbmd(p)` (octaves of either, five unless given:
22
+ * `sfbm(p, 8)`), `voronoi(p)` (cellular noise: vec3 of the nearest and second nearest distances and
23
+ * a random 0-1 for the nearest cell); and `srgb(c)` (a colour as picked to linear), `hsv2rgb`, `palette` (cosine
24
+ * palettes, linear), `fresnel(normal, position, power)` (rim light - `cameraPosition` is there too),
25
+ * `rotate2(a)` / `rotate3(axis, a)` matrices, `remap` and `saturate`.
26
+ * @typedef {object} ShaderHooks
27
+ * @property {string} [vertex] runs on each vertex before it is placed: change `local` (vec3, the
28
+ * geometry's own space) - and for meshes `n`, its normal; reads `uv` (meshes), `index` (the
29
+ * instance, point or segment), `size` (points: change it too) and `along` (lines: 0 at a
30
+ * segment's start, 1 at its end)
31
+ * @property {string} [fragment] runs on each pixel before lighting: change `base` (vec4: linear
32
+ * colour and alpha) and, on meshes, `emissive` and `normal` (the shading normal, world space - bend it for
33
+ * relief lit per pixel); `discard` cuts the pixel away - shadows and
34
+ * picking too. Reads `vUv`, `vWorldPosition`, `vNormal` (meshes), `pointUv` and `index`
35
+ * (points), `distance` along the line and `index` the segment (lines)
36
+ * @property {string} [lit] runs after lighting: change `color` (vec3, linear) and `base.a` - glows,
37
+ * scanlines and fades the light doesn't touch
38
+ * @property {Record<string, ShaderUniform>} [uniforms] values the hooks read, by name; change them on
39
+ * `material.shader.uniforms` and the View redraws
40
+ * @property {number} [bounds] how far, in world units, the vertex hook can move a vertex: bounds are
41
+ * padded by it for culling, framing and shadows. Without it a vertex hook turns frustum culling off
42
+ */
43
+
10
44
  /**
11
45
  * @typedef {object} MaterialOptions
12
46
  * @property {string} [name]
@@ -71,7 +105,12 @@ import { Texture } from './Texture.js'
71
105
  * @property {boolean} [depthTest] (default true)
72
106
  * @property {number} [size] Points: diameter in world units, or pixels when sizeAttenuation is off (default 1)
73
107
  * @property {boolean} [sizeAttenuation] Points: sized in the world, so smaller with distance (default true)
108
+ * @property {ShaderHooks | null} [shader] GLSL of your own, run at fixed points in the shader - waves,
109
+ * dissolves, glows (default none)
74
110
  * @property {number} [width] Lines: width in pixels (default 1)
111
+ * @property {number} [dash] Lines: dashed, with dashes this long in the model's units; 0 is solid (default 0)
112
+ * @property {number} [gap] Lines: the gaps between dashes; 0 is as long as the dashes (default 0)
113
+ * @property {number} [dashOffset] Lines: moves the dashes along the lines - animate it for flow (default 0)
75
114
  */
76
115
 
77
116
  // the matcap last: the first map's repeat and offset are every map's, and a matcap has none
@@ -83,7 +122,8 @@ const NUMBERS = { opacity: [0, 1], roughness: [0, 1], metalness: [0, 1], emissiv
83
122
  alphaTest: [0, 1], normalScale: [-Infinity, Infinity], aoMapIntensity: [0, 1], triplanar: [0, Infinity], bumpScale: [-Infinity, Infinity],
84
123
  transmission: [0, 1], clearcoat: [0, 1], clearcoatRoughness: [0, 1], sheen: [0, 1], sheenRoughness: [0, 1], ior: [1, Infinity],
85
124
  specularIntensity: [0, 1], anisotropy: [0, 1], anisotropyRotation: [-Infinity, Infinity], translucency: [0, 1], toon: [0, 16],
86
- lightMapIntensity: [0, Infinity], environmentIntensity: [0, Infinity] }
125
+ lightMapIntensity: [0, Infinity], environmentIntensity: [0, Infinity], dash: [0, Infinity], gap: [0, Infinity],
126
+ dashOffset: [-Infinity, Infinity] }
87
127
  // colours, and their defaults
88
128
  const COLORS = { color: 0xffffff, emissive: 0x000000, sheenColor: 0xffffff, specularColor: 0xffffff, translucencyColor: 0xffffff }
89
129
  const DEFAULTS = {
@@ -91,9 +131,14 @@ const DEFAULTS = {
91
131
  transmission: 0, clearcoat: 0, clearcoatRoughness: 0, sheen: 0, sheenRoughness: 0.5, ior: 1.5, specularIntensity: 1, anisotropy: 0,
92
132
  anisotropyRotation: 0, translucency: 0, toon: 0, lightMapIntensity: 1, environmentIntensity: 1, unlit: false, flatShading: false,
93
133
  doubleSided: false, vertexColors: false, blending: 'normal', depthWrite: true, depthTest: true, fog: true,
94
- size: 1, sizeAttenuation: true, width: 1,
134
+ size: 1, sizeAttenuation: true, width: 1, dash: 0, gap: 0, dashOffset: 0,
95
135
  }
96
- const OPTIONS = ['name', ...Object.keys(COLORS), ...MAPS, ...FLAGS, ...Object.keys(NUMBERS), 'blending']
136
+ const OPTIONS = ['name', ...Object.keys(COLORS), ...MAPS, ...FLAGS, ...Object.keys(NUMBERS), 'blending', 'shader']
137
+ const HOOKS = ['vertex', 'fragment', 'lit', 'uniforms', 'bounds']
138
+ // names the shader keeps for itself
139
+ // the functions hooks can call
140
+ const HELPERS = ['hash', 'vnoise', 'snoise', 'snoised', 'vfbm', 'sfbm', 'sfbmd', 'voronoi', 'srgb', 'hsv2rgb', 'palette', 'fresnel', 'rotate2', 'rotate3', 'remap', 'saturate']
141
+ const RESERVED = new RegExp(`^(gl_|phoria)|^(uTime|cameraPosition|${HELPERS.join('|')})$`)
97
142
 
98
143
  // what agents (and three.js habits) will reach for
99
144
  const HINTS = {
@@ -115,6 +160,10 @@ const HINTS = {
115
160
  colour: "use 'color'",
116
161
  emissiveColor: "use 'emissive'",
117
162
  alpha: "use 'opacity'",
163
+ onBeforeCompile: "use 'shader': { vertex, fragment, lit, uniforms } - GLSL run inside phoria2's own shader",
164
+ vertexShader: "use 'shader': { vertex: 'local.y += sin(local.x + uTime);' } - a hook, not a whole shader",
165
+ fragmentShader: "use 'shader': { fragment: 'base.rgb *= 0.5;' } - a hook, not a whole shader",
166
+ uniforms: "use 'shader': { uniforms: { uAmount: 1 } }",
118
167
  }
119
168
 
120
169
  /**
@@ -161,6 +210,9 @@ export class Material {
161
210
  /** @type {number} */ size
162
211
  /** @type {boolean} */ sizeAttenuation
163
212
  /** @type {number} */ width
213
+ /** Lines: dash length in the model's units; 0 is solid. @type {number} */ dash
214
+ /** Lines: gap length; 0 is the dash's. @type {number} */ gap
215
+ /** Lines: how far the dashes have moved along. @type {number} */ dashOffset
164
216
 
165
217
  /** @type {Record<string, Color>} */
166
218
  #colors = Object.fromEntries(Object.entries(COLORS).map(([key, hex]) => [key, new Color(hex)]))
@@ -169,6 +221,8 @@ export class Material {
169
221
  #maps = Object.fromEntries(MAPS.map((m) => [m, null]))
170
222
  /** @type {Set<() => void>} */
171
223
  #onDispose = new Set()
224
+ /** @type {Required<Omit<ShaderHooks, 'bounds'>> & { bounds: number | null } | null} */
225
+ #shader = null
172
226
 
173
227
  /** @param {MaterialOptions} [options] */
174
228
  constructor(options = {}) {
@@ -182,9 +236,27 @@ export class Material {
182
236
  else if (key in NUMBERS) this[key] = range(key, value, NUMBERS[key])
183
237
  else if (key === 'blending') this.blending = checkOneOf('Material', 'blending', value, ['normal', 'additive', 'multiply'])
184
238
  else if (key === 'name') this.name = String(value)
239
+ else if (key === 'shader') this.shader = value
185
240
  }
186
241
  }
187
242
 
243
+ /**
244
+ * GLSL hooks run inside the shader, or null. Change its uniforms in place
245
+ * (`material.shader.uniforms.uAmount = 2`); assign a new object to change the code.
246
+ * @returns {Required<Omit<ShaderHooks, 'bounds'>> & { bounds: number | null } | null}
247
+ */
248
+ get shader() { return this.#shader }
249
+ /** @param {ShaderHooks | null} v */
250
+ set shader(v) { this.#shader = shaderHooks(v) }
251
+
252
+ /** @internal Every texture the material reads: its maps, then its shader's uniform textures. @returns {[string, Texture][]} */
253
+ get allTextures() {
254
+ const maps = this.textures
255
+ const u = this.#shader?.uniforms
256
+ if (!u) return maps
257
+ return [...maps, ...Object.entries(u).filter(([, v]) => v instanceof Texture).map(([k, v]) => /** @type {[string, Texture]} */ ([`shader.${k}`, v]))]
258
+ }
259
+
188
260
  /** @internal
189
261
  * A Material from a Material (returned as is) or options.
190
262
  * @param {Material | MaterialOptions} input
@@ -315,12 +387,13 @@ export class Material {
315
387
  if (this.toon > 0) parts.push('toon')
316
388
  if (this.flatShading) parts.push('flat')
317
389
  if (this.vertexColors) parts.push('vcolor')
390
+ if (this.#shader) parts.push('hooks')
318
391
  return parts.join('+')
319
392
  }
320
393
 
321
394
  /** Resolves when every texture has loaded. @returns {Promise<void>} */
322
395
  async ready() {
323
- await Promise.all(this.textures.map(([, t]) => t.ready))
396
+ await Promise.all(this.allTextures.map(([, t]) => t.ready))
324
397
  }
325
398
 
326
399
  /** @returns {Material} */
@@ -359,6 +432,17 @@ export class Material {
359
432
  const desc = keepTextures ? t : t.toJSON()
360
433
  if (desc) json[slot] = desc
361
434
  }
435
+ const s = this.#shader
436
+ if (s) {
437
+ /** @type {Record<string, unknown>} */
438
+ const hooks = {}
439
+ for (const part of ['vertex', 'fragment', 'lit']) if (s[part]) hooks[part] = s[part]
440
+ const uniforms = Object.entries(s.uniforms).map(([k, v]) => [k, v instanceof Texture ? (keepTextures ? v : v.toJSON())
441
+ : v instanceof Color ? v.getStyle() : Array.isArray(v) || ArrayBuffer.isView(v) ? Array.from(/** @type {ArrayLike<number>} */ (v)) : v])
442
+ if (uniforms.length) hooks.uniforms = Object.fromEntries(uniforms.filter(([, v]) => v != null))
443
+ if (s.bounds !== null) hooks.bounds = s.bounds
444
+ json.shader = hooks
445
+ }
362
446
  return json
363
447
  }
364
448
  }
@@ -376,3 +460,45 @@ function range(name, v, [min, max]) {
376
460
  if (n < min || n > max) fail('Material', `'${name}' must be ${max === Infinity ? `${min} or more` : `${min}-${max}`}, got ${n}`)
377
461
  return n
378
462
  }
463
+
464
+ /**
465
+ * Shader hooks checked and copied: each piece of code a string, the uniforms by valid names, their
466
+ * values numbers, two to four numbers, colours or textures.
467
+ * @param {ShaderHooks | null | undefined} v
468
+ */
469
+ function shaderHooks(v) {
470
+ if (v == null) return null
471
+ if (typeof v !== 'object' || Array.isArray(v)) fail('Material', `'shader' is { vertex, fragment, lit, uniforms, bounds } - GLSL hooks - got ${describe(v)}`)
472
+ checkOptions('Material shader', v, HOOKS, {
473
+ vertexShader: "use 'vertex' - a few lines run on each vertex, not a whole shader",
474
+ fragmentShader: "use 'fragment' (before lighting) or 'lit' (after) - a few lines, not a whole shader",
475
+ })
476
+ for (const part of ['vertex', 'fragment', 'lit']) {
477
+ if (v[part] !== undefined && typeof v[part] !== 'string') fail('Material', `shader '${part}' is GLSL as a string, got ${describe(v[part])}`)
478
+ }
479
+ if (!v.vertex && !v.fragment && !v.lit) fail('Material', "'shader' needs at least one of 'vertex', 'fragment' or 'lit'")
480
+ const uniforms = v.uniforms ?? {}
481
+ if (typeof uniforms !== 'object' || Array.isArray(uniforms)) fail('Material', `shader 'uniforms' is an object of name: value, got ${describe(uniforms)}`)
482
+ /** @type {Record<string, ShaderUniform>} */
483
+ const own = {}
484
+ for (const [name, value] of Object.entries(uniforms)) {
485
+ if (!/^[A-Za-z_]\w*$/.test(name)) fail('Material', `shader uniform '${name}' isn't a GLSL name - letters, digits and _, not starting with a digit`)
486
+ if (RESERVED.test(name)) fail('Material', `shader uniform '${name}' is a name phoria2 keeps${name === 'uTime' ? " - uTime is there already, the View's clock in seconds" : name === 'cameraPosition' ? " - cameraPosition is there already, the camera's world position" : HELPERS.includes(name) ? ` - ${name}() is a function every hook can call` : ''}`)
487
+ own[name] = uniformValue(name, value)
488
+ }
489
+ const bounds = v.bounds === undefined || v.bounds === null ? null : Math.max(0, checkNumber('Material', 'shader bounds', v.bounds))
490
+ return { vertex: v.vertex ?? '', fragment: v.fragment ?? '', lit: v.lit ?? '', uniforms: own, bounds }
491
+ }
492
+
493
+ /** A uniform's value as kept: numbers and arrays as given, colours as Colors, textures as Textures. @param {string} name @param {unknown} v @returns {ShaderUniform} */
494
+ function uniformValue(name, v) {
495
+ if (typeof v === 'number' && Number.isFinite(v)) return v
496
+ if ((Array.isArray(v) || ArrayBuffer.isView(v)) && /** @type {ArrayLike<unknown>} */ (v).length >= 2 && /** @type {ArrayLike<unknown>} */ (v).length <= 4 && Array.from(/** @type {ArrayLike<unknown>} */ (v)).every((n) => typeof n === 'number' && Number.isFinite(n))) {
497
+ return Array.from(/** @type {ArrayLike<number>} */ (v))
498
+ }
499
+ if (v instanceof Texture) return v
500
+ if (v instanceof Color) return v
501
+ if (v && typeof v === 'object' && 'url' in v) return new Texture(/** @type {any} */ (v))
502
+ if (typeof v === 'string') return Color.fromInput(v, 'Material')
503
+ return fail('Material', `shader uniform '${name}' is a number, two to four numbers, a colour or a Texture, got ${describe(v)}`)
504
+ }