phoria2 1.3.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.
- package/README.md +9 -6
- package/dist/bloom.js +4 -4
- package/dist/bloom.js.map +1 -1
- package/dist/charts.js +436 -436
- package/dist/charts.js.map +1 -1
- package/dist/core.js +1223 -862
- package/dist/core.js.map +1 -1
- package/dist/debug.js +297 -0
- package/dist/debug.js.map +1 -0
- package/dist/edit.js +40 -40
- package/dist/edit.js.map +1 -1
- package/dist/explain.js +32 -13
- package/dist/explain.js.map +1 -1
- package/dist/gltf.js +128 -128
- package/dist/gltf.js.map +1 -1
- package/dist/keyboard.js +19 -19
- package/dist/keyboard.js.map +1 -1
- package/dist/motion.js +35 -35
- package/dist/motion.js.map +1 -1
- package/dist/particles.js +36 -36
- package/dist/particles.js.map +1 -1
- package/dist/passes.js +116 -104
- package/dist/passes.js.map +1 -1
- package/dist/phoria2.js +1 -1
- package/dist/picking.js +88 -71
- package/dist/picking.js.map +1 -1
- package/dist/snapshot.js +1 -1
- package/dist/tooltip.js +1 -1
- package/dist/types/View.d.ts +16 -0
- package/dist/types/debug.d.ts +74 -0
- package/dist/types/explain.d.ts +4 -0
- package/dist/types/frame.d.ts +5 -0
- package/dist/types/material/Material.d.ts +52 -0
- package/dist/types/watch.d.ts +8 -1
- package/dist/types/webgl/hooks.d.ts +48 -0
- package/dist/types/webgl/passes.d.ts +4 -0
- package/dist/types/webgl/programs.d.ts +15 -5
- package/dist/types/webgl/shaders.d.ts +36 -5
- package/llms-full.txt +227 -0
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/schema/scene.schema.json +27 -0
- package/src/View.js +105 -7
- package/src/debug.js +283 -0
- package/src/explain.js +19 -0
- package/src/frame.js +7 -2
- package/src/material/Material.js +121 -2
- package/src/scene/drawables.js +21 -4
- package/src/watch.js +18 -2
- package/src/webgl/WebGLRenderer.js +19 -2
- package/src/webgl/hooks.js +121 -0
- package/src/webgl/passes.js +17 -2
- package/src/webgl/picking.js +23 -2
- package/src/webgl/programs.js +20 -9
- package/src/webgl/resources.js +1 -1
- 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
|
-
|
|
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) {
|
package/src/material/Material.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/src/scene/drawables.js
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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 */
|