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.
- package/README.md +11 -9
- package/dist/bloom.js +4 -4
- package/dist/bloom.js.map +1 -1
- package/dist/charts.js +527 -510
- package/dist/charts.js.map +1 -1
- package/dist/core.js +1760 -1170
- package/dist/core.js.map +1 -1
- package/dist/debug.js +297 -0
- package/dist/debug.js.map +1 -0
- package/dist/edit.js +60 -60
- package/dist/edit.js.map +1 -1
- package/dist/explain.js +32 -13
- package/dist/explain.js.map +1 -1
- package/dist/gltf.js +155 -155
- package/dist/gltf.js.map +1 -1
- package/dist/keyboard.js +188 -0
- package/dist/keyboard.js.map +1 -0
- package/dist/motion.js +35 -35
- package/dist/motion.js.map +1 -1
- package/dist/particles.js +187 -114
- 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 +95 -62
- 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 +27 -0
- package/dist/types/area.d.ts +18 -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/geometry/Geometry.d.ts +16 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/material/Material.d.ts +67 -0
- package/dist/types/material/Texture.d.ts +18 -2
- package/dist/types/particles/Emitter.d.ts +39 -2
- package/dist/types/scene/drawables.d.ts +23 -2
- 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/resources.d.ts +12 -2
- package/dist/types/webgl/shaders.d.ts +41 -5
- package/llms-full.txt +275 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/schema/scene.schema.json +75 -0
- package/src/View.js +121 -7
- package/src/area.js +131 -0
- package/src/charts/marks.js +29 -2
- package/src/debug.js +283 -0
- package/src/explain.js +19 -0
- package/src/frame.js +7 -2
- package/src/geometry/Geometry.js +16 -1
- package/src/index.js +1 -0
- package/src/material/Material.js +130 -4
- package/src/material/Texture.js +55 -2
- package/src/particles/Emitter.js +121 -10
- package/src/scene/drawables.js +89 -8
- package/src/watch.js +26 -3
- package/src/webgl/WebGLRenderer.js +55 -7
- package/src/webgl/hooks.js +121 -0
- package/src/webgl/passes.js +17 -2
- package/src/webgl/picking.js +45 -3
- package/src/webgl/programs.js +20 -9
- package/src/webgl/resources.js +169 -15
- 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
|
-
|
|
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/geometry/Geometry.js
CHANGED
|
@@ -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 */
|
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,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.
|
|
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
|
+
}
|