phoria2 1.3.0 → 1.4.1

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 (56) hide show
  1. package/README.md +9 -6
  2. package/dist/bloom.js +4 -4
  3. package/dist/bloom.js.map +1 -1
  4. package/dist/charts.js +436 -436
  5. package/dist/charts.js.map +1 -1
  6. package/dist/core.js +1240 -862
  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 +40 -40
  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 +128 -128
  15. package/dist/gltf.js.map +1 -1
  16. package/dist/keyboard.js +19 -19
  17. package/dist/keyboard.js.map +1 -1
  18. package/dist/motion.js +35 -35
  19. package/dist/motion.js.map +1 -1
  20. package/dist/particles.js +36 -36
  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 +90 -71
  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 +16 -0
  30. package/dist/types/debug.d.ts +74 -0
  31. package/dist/types/explain.d.ts +4 -0
  32. package/dist/types/frame.d.ts +5 -0
  33. package/dist/types/material/Material.d.ts +52 -0
  34. package/dist/types/watch.d.ts +8 -1
  35. package/dist/types/webgl/hooks.d.ts +48 -0
  36. package/dist/types/webgl/passes.d.ts +4 -0
  37. package/dist/types/webgl/programs.d.ts +15 -5
  38. package/dist/types/webgl/shaders.d.ts +36 -5
  39. package/llms-full.txt +227 -0
  40. package/llms.txt +1 -1
  41. package/package.json +1 -1
  42. package/schema/scene.schema.json +27 -0
  43. package/src/View.js +105 -7
  44. package/src/debug.js +283 -0
  45. package/src/explain.js +19 -0
  46. package/src/frame.js +7 -2
  47. package/src/material/Material.js +121 -2
  48. package/src/scene/drawables.js +21 -4
  49. package/src/watch.js +18 -2
  50. package/src/webgl/WebGLRenderer.js +19 -2
  51. package/src/webgl/hooks.js +121 -0
  52. package/src/webgl/passes.js +17 -2
  53. package/src/webgl/picking.js +23 -2
  54. package/src/webgl/programs.js +20 -9
  55. package/src/webgl/resources.js +1 -1
  56. package/src/webgl/shaders.js +443 -35
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) {
@@ -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,6 +105,8 @@ 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)
75
111
  * @property {number} [dash] Lines: dashed, with dashes this long in the model's units; 0 is solid (default 0)
76
112
  * @property {number} [gap] Lines: the gaps between dashes; 0 is as long as the dashes (default 0)
@@ -97,7 +133,12 @@ const DEFAULTS = {
97
133
  doubleSided: false, vertexColors: false, blending: 'normal', depthWrite: true, depthTest: true, fog: true,
98
134
  size: 1, sizeAttenuation: true, width: 1, dash: 0, gap: 0, dashOffset: 0,
99
135
  }
100
- 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('|')})$`)
101
142
 
102
143
  // what agents (and three.js habits) will reach for
103
144
  const HINTS = {
@@ -119,6 +160,10 @@ const HINTS = {
119
160
  colour: "use 'color'",
120
161
  emissiveColor: "use 'emissive'",
121
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 } }",
122
167
  }
123
168
 
124
169
  /**
@@ -176,6 +221,8 @@ export class Material {
176
221
  #maps = Object.fromEntries(MAPS.map((m) => [m, null]))
177
222
  /** @type {Set<() => void>} */
178
223
  #onDispose = new Set()
224
+ /** @type {Required<Omit<ShaderHooks, 'bounds'>> & { bounds: number | null } | null} */
225
+ #shader = null
179
226
 
180
227
  /** @param {MaterialOptions} [options] */
181
228
  constructor(options = {}) {
@@ -189,9 +236,27 @@ export class Material {
189
236
  else if (key in NUMBERS) this[key] = range(key, value, NUMBERS[key])
190
237
  else if (key === 'blending') this.blending = checkOneOf('Material', 'blending', value, ['normal', 'additive', 'multiply'])
191
238
  else if (key === 'name') this.name = String(value)
239
+ else if (key === 'shader') this.shader = value
192
240
  }
193
241
  }
194
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
+
195
260
  /** @internal
196
261
  * A Material from a Material (returned as is) or options.
197
262
  * @param {Material | MaterialOptions} input
@@ -322,12 +387,13 @@ export class Material {
322
387
  if (this.toon > 0) parts.push('toon')
323
388
  if (this.flatShading) parts.push('flat')
324
389
  if (this.vertexColors) parts.push('vcolor')
390
+ if (this.#shader) parts.push('hooks')
325
391
  return parts.join('+')
326
392
  }
327
393
 
328
394
  /** Resolves when every texture has loaded. @returns {Promise<void>} */
329
395
  async ready() {
330
- await Promise.all(this.textures.map(([, t]) => t.ready))
396
+ await Promise.all(this.allTextures.map(([, t]) => t.ready))
331
397
  }
332
398
 
333
399
  /** @returns {Material} */
@@ -366,6 +432,17 @@ export class Material {
366
432
  const desc = keepTextures ? t : t.toJSON()
367
433
  if (desc) json[slot] = desc
368
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
+ }
369
446
  return json
370
447
  }
371
448
  }
@@ -383,3 +460,45 @@ function range(name, v, [min, max]) {
383
460
  if (n < min || n > max) fail('Material', `'${name}' must be ${max === Infinity ? `${min} or more` : `${min}-${max}`}, got ${n}`)
384
461
  return n
385
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
+ }
@@ -215,7 +215,9 @@ export class Drawable extends Node {
215
215
 
216
216
  /** @internal @param {Box3} box */
217
217
  expandBounds(box) {
218
- if (this.geometry.vertexCount) box.union(this.geometry.bounds.clone().applyMatrix4(this.worldMatrix))
218
+ if (!this.geometry.vertexCount) return box
219
+ const own = this.geometry.bounds.clone().applyMatrix4(this.worldMatrix)
220
+ box.union(padForHooks(own, this.material))
219
221
  return box
220
222
  }
221
223
 
@@ -249,7 +251,7 @@ export class Drawable extends Node {
249
251
  dispose() {
250
252
  this.geometry.dispose()
251
253
  this.material.dispose()
252
- for (const [, t] of this.material.textures) t.dispose()
254
+ for (const [, t] of this.material.allTextures) t.dispose()
253
255
  super.dispose()
254
256
  }
255
257
  }
@@ -411,9 +413,11 @@ export class InstancedMesh extends Mesh {
411
413
 
412
414
  /** @internal @param {Box3} box */
413
415
  expandBounds(box) {
416
+ // as far as a shader hook can move a vertex, round each copy
417
+ const pad = this.material.shader?.vertex ? this.material.shader.bounds ?? 0 : 0
414
418
  this.eachInstanceBox((x0, y0, z0, x1, y1, z1) => {
415
- box.expandByXYZ(x0, y0, z0)
416
- box.expandByXYZ(x1, y1, z1)
419
+ box.expandByXYZ(x0 - pad, y0 - pad, z0 - pad)
420
+ box.expandByXYZ(x1 + pad, y1 + pad, z1 + pad)
417
421
  })
418
422
  return box
419
423
  }
@@ -647,3 +651,16 @@ export class Label extends Node {
647
651
  return json
648
652
  }
649
653
  }
654
+
655
+ /**
656
+ * A drawable's world box, grown by as far as its shader's vertex hook says it can move a vertex.
657
+ * @param {import('../value/Box3.js').Box3} box @param {import('../material/Material.js').Material} material
658
+ */
659
+ function padForHooks(box, material) {
660
+ const pad = material.shader?.vertex ? material.shader.bounds ?? 0 : 0
661
+ if (pad > 0 && !box.isEmpty()) {
662
+ box.min.set(box.min[0] - pad, box.min[1] - pad, box.min[2] - pad)
663
+ box.max.set(box.max[0] + pad, box.max[1] + pad, box.max[2] + pad)
664
+ }
665
+ return box
666
+ }
package/src/watch.js CHANGED
@@ -10,6 +10,8 @@
10
10
  /** @type {WeakMap<object, number>} */
11
11
  const ids = new WeakMap()
12
12
  let nextId = 1
13
+ /** Does a material's shader hook read the clock? @param {{ vertex: string, fragment: string, lit: string }} h */
14
+ export const usesTime = (h) => /\buTime\b/.test(h.vertex + h.fragment + h.lit)
13
15
  /** @param {object} o */
14
16
  const idOf = (o) => {
15
17
  let id = ids.get(o)
@@ -24,14 +26,17 @@ export class ChangeWatch {
24
26
  #previousCount = -1
25
27
  /** @type {string[]} */ #strings = []
26
28
  /** @type {string[]} */ #previousStrings = []
29
+ #time = 0
27
30
 
28
31
  /**
29
32
  * Has anything that affects the picture changed since the last call? The first call says yes.
30
33
  * @param {Scene} scene
31
34
  * @param {Camera} camera
35
+ * @param {number} [time] the View's clock, which shader hooks reading uTime follow
32
36
  * @returns {boolean}
33
37
  */
34
- changed(scene, camera) {
38
+ changed(scene, camera, time = 0) {
39
+ this.#time = time
35
40
  this.#count = 0
36
41
  this.#strings = []
37
42
  this.#camera(camera)
@@ -194,7 +199,18 @@ export class ChangeWatch {
194
199
  this.#pushAll(m.translucencyColor)
195
200
  this.#push((m.unlit ? 1 : 0) | (m.flatShading ? 2 : 0) | (m.doubleSided ? 4 : 0) | (m.vertexColors ? 8 : 0) |
196
201
  (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))
197
- for (const [, texture] of m.textures) this.#texture(texture)
202
+ for (const [, texture] of m.allTextures) this.#texture(texture)
203
+ const hooks = m.shader
204
+ if (hooks) {
205
+ this.#push(idOf(hooks))
206
+ for (const v of Object.values(hooks.uniforms)) {
207
+ if (typeof v === 'number') this.#push(v)
208
+ // numbers and colours - textures count through their versions, above
209
+ else if (Array.isArray(v) || ArrayBuffer.isView(v)) this.#pushAll(/** @type {ArrayLike<number>} */ (v))
210
+ }
211
+ // a hook that reads the clock moves on its own while time runs
212
+ if (usesTime(hooks)) this.#push(this.#time)
213
+ }
198
214
  }
199
215
 
200
216
  /** @param {any} texture */