@solidrt/3d 0.0.51 → 0.0.53
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/AGENTS.md +354 -91
- package/README.md +13 -9
- package/examples/README.md +37 -2
- package/examples/aim.tsx +5 -5
- package/examples/cascades.tsx +121 -0
- package/examples/instanced.tsx +4 -4
- package/examples/lit.tsx +66 -0
- package/examples/model.glb +0 -0
- package/examples/model.tsx +51 -0
- package/examples/pick.tsx +5 -5
- package/examples/scene-background.tsx +3 -3
- package/examples/scene-basic.tsx +3 -3
- package/examples/scene-post-effect.tsx +3 -3
- package/examples/scene-views.tsx +95 -0
- package/examples/shadows.tsx +86 -0
- package/examples/sprites.tsx +95 -0
- package/examples/sweep-paths.tsx +11 -27
- package/package.json +4 -3
- package/src/components.tsx +110 -14
- package/src/geometry-gpu.ts +13 -2
- package/src/geometry.ts +331 -127
- package/src/glsl.ts +138 -3
- package/src/gltf.ts +437 -0
- package/src/index.ts +17 -12
- package/src/material.ts +353 -47
- package/src/math.ts +145 -0
- package/src/model-file.ts +122 -0
- package/src/model.ts +105 -0
- package/src/orbit.ts +13 -9
- package/src/order.ts +12 -5
- package/src/profile.ts +4 -8
- package/src/scene.ts +1294 -283
- package/src/sweep.ts +21 -36
- package/tools/model.ts +52 -0
- package/src/bvh.ts +0 -258
package/src/material.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
// Materials pair GLSL with pipeline state, deduped hard: one program
|
|
2
|
-
//
|
|
1
|
+
// Materials pair GLSL with pipeline state, deduped hard: one program per
|
|
2
|
+
// material CLASS (unlit color, unlit textured), and one render pipeline
|
|
3
|
+
// per vertex layout the class meets (a pipeline is program + attribute
|
|
4
|
+
// list, so the program never recompiles for a wider geometry),
|
|
3
5
|
// created lazily at first use and kept for the app's lifetime. A material
|
|
4
6
|
// INSTANCE is just the per-entry uniform values (and sampler bindings) it
|
|
5
7
|
// contributes when a mesh becomes a draw entry - so a thousand meshes with
|
|
@@ -25,6 +27,7 @@ import {
|
|
|
25
27
|
destroyShader,
|
|
26
28
|
glsl,
|
|
27
29
|
linkProgram,
|
|
30
|
+
programAttributes,
|
|
28
31
|
} from "@solidrt/core/gpu"
|
|
29
32
|
import type {
|
|
30
33
|
BlendMode,
|
|
@@ -33,29 +36,34 @@ import type {
|
|
|
33
36
|
RenderPipelineId,
|
|
34
37
|
ShaderParams,
|
|
35
38
|
ShaderStageId,
|
|
39
|
+
TextureBindings,
|
|
36
40
|
TextureId,
|
|
37
41
|
Topology,
|
|
38
42
|
VertexAttribute,
|
|
39
43
|
} from "@solidrt/core/gpu"
|
|
40
|
-
import {
|
|
44
|
+
import { layoutAttributes, layoutKey, layoutSlot } from "./geometry.ts"
|
|
41
45
|
import type { VertexLayout } from "./geometry.ts"
|
|
46
|
+
import { BLINN_SPECULAR, HEMISPHERE, LAMBERT, LIT_VERTEX, LIT_VERTEX_COLORED, MAX_LIGHTS, SHADOW, SHADOW_LOOKUP, SHADOW_SLOTS } from "./glsl.ts"
|
|
42
47
|
|
|
43
48
|
export type Material = {
|
|
44
|
-
/** The pipeline this material draws with
|
|
45
|
-
|
|
49
|
+
/** The pipeline this material draws with for geometry of `layout`
|
|
50
|
+
* (lazily created, one per layout met). */
|
|
51
|
+
pipeline(layout: VertexLayout | undefined): RenderPipelineId
|
|
46
52
|
/** Per-entry uniform values this material contributes at addDraw. */
|
|
47
53
|
params: ShaderParams
|
|
48
54
|
/** Per-entry sampler bindings, when the material samples textures. */
|
|
49
|
-
textures?:
|
|
55
|
+
textures?: TextureBindings
|
|
50
56
|
/** True when the vertex stage declares `uNormal`: the scene then writes
|
|
51
57
|
* the world matrix's inverse-transpose alongside uModel for meshes using
|
|
52
58
|
* this material (set automatically by shaderMaterial). */
|
|
53
59
|
normalMatrix?: boolean
|
|
54
|
-
/** The vertex
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
60
|
+
/** The vertex attributes the linked program reads from the geometry
|
|
61
|
+
* (name and format, per the engine's reflection of the compiled program,
|
|
62
|
+
* instance attributes excluded). Links the program on first call. A mesh
|
|
63
|
+
* whose geometry layout lacks any of them is rejected at add(); extra
|
|
64
|
+
* channels in the geometry are fine (inactive attributes keep the
|
|
65
|
+
* stride). */
|
|
66
|
+
attributes(): VertexAttribute[]
|
|
59
67
|
/** True when the pipeline blends over (blend "alpha", depthWrite off):
|
|
60
68
|
* the scene draws this material's meshes after every opaque one, sorted
|
|
61
69
|
* back-to-front by mesh origin, and re-sorts them when the camera moves. */
|
|
@@ -106,30 +114,42 @@ const FRAGMENT_MAP_SRC = glsl`
|
|
|
106
114
|
`
|
|
107
115
|
|
|
108
116
|
let sharedVertex: ShaderStageId | undefined
|
|
109
|
-
let
|
|
117
|
+
let programs: Partial<Record<UnlitClass, ProgramId>> = {}
|
|
118
|
+
let pipelines = new Map<string, RenderPipelineId>()
|
|
110
119
|
|
|
111
|
-
// One
|
|
112
|
-
// state is pipeline
|
|
120
|
+
// One program per unlit CLASS: fragment kind x transparency. Blend state is
|
|
121
|
+
// pipeline state and so is the attribute list, so the pipeline is keyed by
|
|
122
|
+
// class and vertex layout.
|
|
113
123
|
type UnlitClass = "color" | "map" | "color-transparent" | "map-transparent"
|
|
114
124
|
|
|
115
|
-
function
|
|
116
|
-
let
|
|
125
|
+
function programFor(cls: UnlitClass): ProgramId {
|
|
126
|
+
let program = programs[cls]
|
|
127
|
+
if (program === undefined) {
|
|
128
|
+
if (sharedVertex === undefined) sharedVertex = compileShader("vertex", VERTEX_SRC, { header: true })
|
|
129
|
+
let fragment = compileShader("fragment", cls.startsWith("color") ? FRAGMENT_COLOR_SRC : FRAGMENT_MAP_SRC, {
|
|
130
|
+
header: true,
|
|
131
|
+
})
|
|
132
|
+
program = linkProgram(sharedVertex, fragment, { label: "scene-unlit-" + cls })
|
|
133
|
+
programs[cls] = program
|
|
134
|
+
}
|
|
135
|
+
return program
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function pipelineFor(cls: UnlitClass, layout: VertexLayout | undefined): RenderPipelineId {
|
|
139
|
+
let key = cls + "|" + layoutKey(layout)
|
|
140
|
+
let existing = pipelines.get(key)
|
|
117
141
|
if (existing !== undefined) return existing
|
|
118
|
-
|
|
142
|
+
let program = programFor(cls)
|
|
119
143
|
let transparent = cls.endsWith("-transparent")
|
|
120
|
-
let fragment = compileShader("fragment", cls.startsWith("color") ? FRAGMENT_COLOR_SRC : FRAGMENT_MAP_SRC, {
|
|
121
|
-
header: true,
|
|
122
|
-
})
|
|
123
|
-
let program = linkProgram(sharedVertex, fragment, { label: "scene-unlit-" + cls })
|
|
124
144
|
let pipeline = createRenderPipeline(program, {
|
|
125
|
-
attributes:
|
|
145
|
+
attributes: layoutAttributes(layout),
|
|
126
146
|
depth: true,
|
|
127
147
|
depthWrite: transparent ? false : undefined,
|
|
128
148
|
blend: transparent ? "alpha" : undefined,
|
|
129
149
|
cull: "back",
|
|
130
150
|
label: "scene-unlit-" + cls,
|
|
131
151
|
})
|
|
132
|
-
pipelines
|
|
152
|
+
pipelines.set(key, pipeline)
|
|
133
153
|
return pipeline
|
|
134
154
|
}
|
|
135
155
|
|
|
@@ -153,15 +173,288 @@ export function unlit(opts: UnlitOptions = {}): Material {
|
|
|
153
173
|
let a = color.length === 4 ? color[3] : 1
|
|
154
174
|
let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
|
|
155
175
|
let transparent = opts.transparent === true
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
176
|
+
let cls: UnlitClass = opts.map !== undefined ? (transparent ? "map-transparent" : "map") : transparent ? "color-transparent" : "color"
|
|
177
|
+
return {
|
|
178
|
+
pipeline: layout => pipelineFor(cls, layout),
|
|
179
|
+
attributes: () => programAttributes(programFor(cls)),
|
|
180
|
+
params: { uColor },
|
|
181
|
+
textures: opts.map !== undefined ? { uMap: opts.map } : undefined,
|
|
182
|
+
transparent,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export type LitOptions = UnlitOptions & {
|
|
187
|
+
/** Multiply the base by the geometry's per-vertex aColor (withColors
|
|
188
|
+
* geometry; add() throws without it). */
|
|
189
|
+
vertexColors?: boolean
|
|
190
|
+
/** Blinn-Phong highlight strength, 0..1 (default 0: pure diffuse). */
|
|
191
|
+
specular?: number
|
|
192
|
+
/** Highlight tightness, wide sheen (~8) to mirror dot (~150); default 30. */
|
|
193
|
+
shininess?: number
|
|
194
|
+
/** Sample `map` by WORLD position instead of UV - the value is the
|
|
195
|
+
* texture repeats per world unit - blended across the three axis planes
|
|
196
|
+
* by the normal. Tiles generated geometry at one density regardless of
|
|
197
|
+
* each part's size or UVs; the map must be created with
|
|
198
|
+
* `wrap: "repeat"`. */
|
|
199
|
+
triplanar?: number
|
|
200
|
+
/**
|
|
201
|
+
* Receive the scene's directional shadows (default true, like Godot and
|
|
202
|
+
* Three): each casting light's term is multiplied by its shadow-map
|
|
203
|
+
* factor (SHADOW in `@solidrt/3d/glsl`). `false` opts out - a material
|
|
204
|
+
* that must never darken (an emissive surface, a far skybox) - and
|
|
205
|
+
* drops the map sample from its program. A material option, not a
|
|
206
|
+
* node flag as in Three, because the material picks the program (like
|
|
207
|
+
* vertexColors and triplanar; Godot's `disable_receive_shadows`); in a
|
|
208
|
+
* scene with no `castShadow` light the receiving variant draws exactly
|
|
209
|
+
* like the opted-out one. Custom materials receive by declaring the
|
|
210
|
+
* scene's shadow set (see SHADOW's doc) and composing `shadow` per light.
|
|
211
|
+
*/
|
|
212
|
+
receiveShadow?: boolean
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The lit fragment is composed from the same exported pieces an app
|
|
216
|
+
// composes by hand, per flag: map x vertexColors x triplanar x shadow x
|
|
217
|
+
// transparent. Lights arrive through the scene's shared params
|
|
218
|
+
// (light nodes); the base color, map and highlight are per entry. The
|
|
219
|
+
// shadow set is shared too and indexed like the lights: one atlas sampler,
|
|
220
|
+
// directional light i's maps (one, or its cascades) as map slots
|
|
221
|
+
// uShadowFirst[i] .. + uShadowCount[i] with a tile rect and a matrix
|
|
222
|
+
// each, and its biases (target-level, bound by the scene); uShadowCount
|
|
223
|
+
// 0 means it does not cast; SHADOW_LOOKUP turns the index into the factor.
|
|
224
|
+
function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, shadow: boolean): string {
|
|
225
|
+
return glsl`
|
|
226
|
+
in vec3 vWorldPos;
|
|
227
|
+
in vec3 vNormal;
|
|
228
|
+
in vec2 vUv;
|
|
229
|
+
${vertexColors ? "in vec4 vColor;" : ""}
|
|
230
|
+
uniform vec4 uColor;
|
|
231
|
+
${map ? "uniform sampler2D uMap;" : ""}
|
|
232
|
+
uniform float uSpecular;
|
|
233
|
+
uniform float uShininess;
|
|
234
|
+
${triplanar ? "uniform float uTriplanar;" : ""}
|
|
235
|
+
uniform vec3 uCamPos;
|
|
236
|
+
uniform vec3 uHemiSky;
|
|
237
|
+
uniform vec3 uHemiGround;
|
|
238
|
+
uniform int uLightCount;
|
|
239
|
+
uniform vec3 uLightDir[${MAX_LIGHTS}];
|
|
240
|
+
uniform vec3 uLightColor[${MAX_LIGHTS}];
|
|
241
|
+
${
|
|
242
|
+
shadow
|
|
243
|
+
? `${SHADOW_SLOTS}
|
|
244
|
+
${SHADOW}
|
|
245
|
+
${SHADOW_LOOKUP}`
|
|
246
|
+
: ""
|
|
247
|
+
}
|
|
248
|
+
${HEMISPHERE}
|
|
249
|
+
${LAMBERT}
|
|
250
|
+
${BLINN_SPECULAR}
|
|
251
|
+
|
|
252
|
+
void main() {
|
|
253
|
+
vec3 n = normalize(vNormal);
|
|
254
|
+
vec4 base = uColor;
|
|
255
|
+
${
|
|
256
|
+
map
|
|
257
|
+
? triplanar
|
|
258
|
+
? `vec3 w = pow(abs(n), vec3(4.0));
|
|
259
|
+
w /= w.x + w.y + w.z;
|
|
260
|
+
vec3 p = vWorldPos * uTriplanar;
|
|
261
|
+
base *= texture(uMap, p.yz) * w.x + texture(uMap, p.xz) * w.y + texture(uMap, p.xy) * w.z;`
|
|
262
|
+
: "base *= texture(uMap, vUv);"
|
|
263
|
+
: ""
|
|
264
|
+
}
|
|
265
|
+
${vertexColors ? "base *= vColor;" : ""}
|
|
266
|
+
vec3 v = normalize(uCamPos - vWorldPos);
|
|
267
|
+
vec3 light = hemisphere(n, uHemiSky, uHemiGround);
|
|
268
|
+
vec3 spec = vec3(0.0);
|
|
269
|
+
for (int i = 0; i < ${MAX_LIGHTS}; i++) {
|
|
270
|
+
if (i >= uLightCount) break;
|
|
271
|
+
vec3 l = uLightDir[i];
|
|
272
|
+
${
|
|
273
|
+
shadow ? "float s = lightShadow(i, vWorldPos, n);" : "float s = 1.0;"
|
|
274
|
+
}
|
|
275
|
+
light += uLightColor[i] * lambert(n, l) * s;
|
|
276
|
+
spec += uLightColor[i] * blinnSpecular(n, v, l, uShininess) * s;
|
|
277
|
+
}
|
|
278
|
+
fragColor = vec4(base.rgb * light + spec * uSpecular * base.a, base.a);
|
|
162
279
|
}
|
|
280
|
+
`
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let litClasses = new Map<string, ShaderMaterialClass>()
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* A lit material: hemisphere ambient plus the scene's directional lights
|
|
287
|
+
* (DirectionalLight nodes), Lambert diffuse, optional
|
|
288
|
+
* Blinn-Phong highlight. Same options as unlit (color, map, transparent)
|
|
289
|
+
* plus vertexColors, specular/shininess and triplanar mapping. One program
|
|
290
|
+
* per option combination, one pipeline per vertex layout met, shared by
|
|
291
|
+
* every instance - a thousand lit meshes still share one pipeline. No
|
|
292
|
+
* lights set means black except for the hemisphere term, which also
|
|
293
|
+
* starts at zero: set at least one of the two.
|
|
294
|
+
*/
|
|
295
|
+
export function lit(opts: LitOptions = {}): Material {
|
|
296
|
+
let color = opts.color ?? [1, 1, 1]
|
|
297
|
+
let a = color.length === 4 ? color[3] : 1
|
|
298
|
+
let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
|
|
299
|
+
let map = opts.map !== undefined
|
|
300
|
+
let vertexColors = opts.vertexColors === true
|
|
301
|
+
let triplanar = map && opts.triplanar !== undefined
|
|
302
|
+
let transparent = opts.transparent === true
|
|
303
|
+
let shadow = opts.receiveShadow !== false
|
|
304
|
+
let key = [map, vertexColors, triplanar, transparent, shadow].join("|")
|
|
305
|
+
let cls = litClasses.get(key)
|
|
306
|
+
if (cls === undefined) {
|
|
307
|
+
cls = shaderMaterialClass({
|
|
308
|
+
vertex: vertexColors ? LIT_VERTEX_COLORED : LIT_VERTEX,
|
|
309
|
+
fragment: litFragment(map, vertexColors, triplanar, shadow),
|
|
310
|
+
transparent,
|
|
311
|
+
label: "scene-lit-" + key,
|
|
312
|
+
})
|
|
313
|
+
litClasses.set(key, cls)
|
|
163
314
|
}
|
|
164
|
-
|
|
315
|
+
let material = cls.instance({
|
|
316
|
+
params: triplanar
|
|
317
|
+
? { uColor, uSpecular: opts.specular ?? 0, uShininess: opts.shininess ?? 30, uTriplanar: opts.triplanar! }
|
|
318
|
+
: { uColor, uSpecular: opts.specular ?? 0, uShininess: opts.shininess ?? 30 },
|
|
319
|
+
textures: map ? { uMap: opts.map! } : undefined,
|
|
320
|
+
})
|
|
321
|
+
return material
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// The shadow depth pass: position only, no color of interest (the target's
|
|
325
|
+
// depth texture is the output; the color write is the pipeline's minimum).
|
|
326
|
+
// Front faces culled, Three's shadowSide default: the map holds each
|
|
327
|
+
// caster's BACK surface, so a receiving front face at the same depth
|
|
328
|
+
// compares lit without a bias and acne needs no fighting on closed meshes.
|
|
329
|
+
const SHADOW_DEPTH_VERTEX = glsl`
|
|
330
|
+
in vec3 aPos;
|
|
331
|
+
uniform mat4 uModel;
|
|
332
|
+
uniform mat4 uViewProj;
|
|
333
|
+
void main() {
|
|
334
|
+
gl_Position = uViewProj * uModel * vec4(aPos, 1.0);
|
|
335
|
+
}
|
|
336
|
+
`
|
|
337
|
+
|
|
338
|
+
const SHADOW_DEPTH_FRAGMENT = glsl`
|
|
339
|
+
void main() {
|
|
340
|
+
fragColor = vec4(1.0);
|
|
341
|
+
}
|
|
342
|
+
`
|
|
343
|
+
|
|
344
|
+
let shadowDepth: Material | undefined
|
|
345
|
+
|
|
346
|
+
/** The override material of a scene's shadow view (internal): one class
|
|
347
|
+
* for the app, built on first use. */
|
|
348
|
+
export function shadowDepthMaterial(): Material {
|
|
349
|
+
if (shadowDepth === undefined) {
|
|
350
|
+
shadowDepth = shaderMaterialClass({
|
|
351
|
+
vertex: SHADOW_DEPTH_VERTEX,
|
|
352
|
+
fragment: SHADOW_DEPTH_FRAGMENT,
|
|
353
|
+
cull: "front",
|
|
354
|
+
label: "scene-shadow-depth",
|
|
355
|
+
}).instance()
|
|
356
|
+
}
|
|
357
|
+
return shadowDepth
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export type SpriteOptions = UnlitOptions & {
|
|
361
|
+
/** Which way the quad turns to face the camera. `"full"` (default,
|
|
362
|
+
* Three's Sprite): both axes follow the view, the quad is always flat
|
|
363
|
+
* to the screen. `"fixed-y"` (Godot's BILLBOARD_FIXED_Y): only the yaw
|
|
364
|
+
* follows the camera, the quad stays upright on world y - trees and
|
|
365
|
+
* standing characters, the classic sprite. */
|
|
366
|
+
billboard?: "full" | "fixed-y"
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// The billboard vertex stages: the unit quad's corners placed along the
|
|
370
|
+
// camera axes at the mesh's world position, with the quad's size read
|
|
371
|
+
// off uModel's column lengths so `scale` sizes the sprite like any mesh.
|
|
372
|
+
// The rotation part of uModel is otherwise ignored (the camera decides
|
|
373
|
+
// the facing). Fixed-y takes the yaw from the camera-to-center direction
|
|
374
|
+
// flattened onto XZ; straight above or below there is no yaw to take, so
|
|
375
|
+
// the quad falls back to facing +z rather than dividing by zero.
|
|
376
|
+
const SPRITE_VERTEX_SRC = glsl`
|
|
377
|
+
in vec3 aPos;
|
|
378
|
+
in vec2 aUV;
|
|
379
|
+
out vec2 vUv;
|
|
380
|
+
uniform mat4 uModel;
|
|
381
|
+
uniform mat4 uViewProj;
|
|
382
|
+
uniform vec3 uCamRight;
|
|
383
|
+
uniform vec3 uCamUp;
|
|
384
|
+
|
|
385
|
+
void main() {
|
|
386
|
+
vec3 center = uModel[3].xyz;
|
|
387
|
+
vec2 size = vec2(length(uModel[0].xyz), length(uModel[1].xyz));
|
|
388
|
+
vec3 world = center + uCamRight * (aPos.x * size.x) + uCamUp * (aPos.y * size.y);
|
|
389
|
+
gl_Position = uViewProj * vec4(world, 1.0);
|
|
390
|
+
vUv = aUV;
|
|
391
|
+
}
|
|
392
|
+
`
|
|
393
|
+
|
|
394
|
+
const SPRITE_FIXED_Y_VERTEX_SRC = glsl`
|
|
395
|
+
in vec3 aPos;
|
|
396
|
+
in vec2 aUV;
|
|
397
|
+
out vec2 vUv;
|
|
398
|
+
uniform mat4 uModel;
|
|
399
|
+
uniform mat4 uViewProj;
|
|
400
|
+
uniform vec3 uCamPos;
|
|
401
|
+
|
|
402
|
+
void main() {
|
|
403
|
+
vec3 center = uModel[3].xyz;
|
|
404
|
+
vec2 size = vec2(length(uModel[0].xyz), length(uModel[1].xyz));
|
|
405
|
+
vec3 toCam = uCamPos - center;
|
|
406
|
+
toCam.y = 0.0;
|
|
407
|
+
float len = length(toCam);
|
|
408
|
+
vec3 right = len > 1e-6 ? vec3(toCam.z, 0.0, -toCam.x) / len : vec3(1.0, 0.0, 0.0);
|
|
409
|
+
vec3 world = center + right * (aPos.x * size.x) + vec3(0.0, aPos.y * size.y, 0.0);
|
|
410
|
+
gl_Position = uViewProj * vec4(world, 1.0);
|
|
411
|
+
vUv = aUV;
|
|
412
|
+
}
|
|
413
|
+
`
|
|
414
|
+
|
|
415
|
+
let spriteClasses = new Map<string, ShaderMaterialClass>()
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* A sprite material: unlit color/map on a quad that turns to face the
|
|
419
|
+
* camera in the vertex stage (the shared uCamRight/uCamUp basis, or
|
|
420
|
+
* uCamPos for fixed-y), so a thousand sprites cost no per-frame JS. Draw
|
|
421
|
+
* it with createSprite / `<Sprite>`, which supply the unit quad; on other
|
|
422
|
+
* geometry the vertex stage still flattens every vertex onto the camera
|
|
423
|
+
* plane. Unlike unlit, `transparent` defaults to TRUE - sprites are cutouts
|
|
424
|
+
* far more often than not (Three's SpriteMaterial default) - pass false
|
|
425
|
+
* for an opaque one. Culling is off: a camera-facing quad has no back.
|
|
426
|
+
*/
|
|
427
|
+
export function sprite(opts: SpriteOptions = {}): Material {
|
|
428
|
+
let color = opts.color ?? [1, 1, 1]
|
|
429
|
+
let a = color.length === 4 ? color[3] : 1
|
|
430
|
+
let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
|
|
431
|
+
let map = opts.map !== undefined
|
|
432
|
+
let transparent = opts.transparent !== false
|
|
433
|
+
let fixedY = opts.billboard === "fixed-y"
|
|
434
|
+
let key = [map, transparent, fixedY].join("|")
|
|
435
|
+
let cls = spriteClasses.get(key)
|
|
436
|
+
if (cls === undefined) {
|
|
437
|
+
cls = shaderMaterialClass({
|
|
438
|
+
vertex: fixedY ? SPRITE_FIXED_Y_VERTEX_SRC : SPRITE_VERTEX_SRC,
|
|
439
|
+
fragment: map ? FRAGMENT_MAP_SRC : FRAGMENT_COLOR_SRC,
|
|
440
|
+
transparent,
|
|
441
|
+
cull: "none",
|
|
442
|
+
label: "scene-sprite-" + key,
|
|
443
|
+
})
|
|
444
|
+
spriteClasses.set(key, cls)
|
|
445
|
+
}
|
|
446
|
+
return cls.instance({ params: { uColor }, textures: map ? { uMap: opts.map! } : undefined })
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The attributes `material` reads that `layout` does not carry (name and
|
|
450
|
+
* format) - empty when the pair is drawable. */
|
|
451
|
+
export function missingAttributes(material: Material, layout: VertexLayout | undefined): VertexAttribute[] {
|
|
452
|
+
let missing: VertexAttribute[] = []
|
|
453
|
+
for (let attr of material.attributes()) {
|
|
454
|
+
let slot = layoutSlot(layout, attr.name)
|
|
455
|
+
if (slot === null || slot.format !== attr.format) missing.push(attr)
|
|
456
|
+
}
|
|
457
|
+
return missing
|
|
165
458
|
}
|
|
166
459
|
|
|
167
460
|
// Mirrors the engine's own preamble rule: a source carrying its own
|
|
@@ -222,11 +515,14 @@ export type ShaderMaterialClassOptions = {
|
|
|
222
515
|
* normals, correct under non-uniform scale - and `uniform vec3 uCamPos`
|
|
223
516
|
* the camera's world position, shared like uViewProj (the specular /
|
|
224
517
|
* fresnel view vector: `uCamPos - worldPos`). Declare any of the
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
518
|
+
* geometry's `in` attributes by name (the standard aPos vec3, aNormal
|
|
519
|
+
* vec3, aUV vec2, or any channel appended with withAttribute);
|
|
520
|
+
* undeclared ones are skipped. What the program READS is the engine's
|
|
521
|
+
* word (reflected from the linked program, so an `in` the compiler
|
|
522
|
+
* dropped does not count); one the mesh's geometry layout does not
|
|
523
|
+
* carry (name and format) throws at add() - so `in vec4 aColor` needs
|
|
524
|
+
* withColors() geometry. The class builds one pipeline per layout its
|
|
525
|
+
* meshes bring, the program compiles once.
|
|
230
526
|
* `@solidrt/3d/glsl` exports a standard
|
|
231
527
|
* vertex stage and lighting pieces built on exactly this contract.
|
|
232
528
|
*/
|
|
@@ -267,7 +563,7 @@ export type ShaderMaterialInstanceOptions = {
|
|
|
267
563
|
/** Uniform seeds beyond the standard set; update per mesh later with
|
|
268
564
|
* setMeshParams. */
|
|
269
565
|
params?: ShaderParams
|
|
270
|
-
textures?:
|
|
566
|
+
textures?: TextureBindings
|
|
271
567
|
}
|
|
272
568
|
|
|
273
569
|
export type ShaderMaterialOptions = ShaderMaterialClassOptions & ShaderMaterialInstanceOptions
|
|
@@ -308,25 +604,36 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
|
|
|
308
604
|
}
|
|
309
605
|
}
|
|
310
606
|
let program: ProgramId | undefined
|
|
311
|
-
let
|
|
607
|
+
let pipelines = new Map<string, RenderPipelineId>()
|
|
312
608
|
// Attributes live in the vertex stage only, so unlike the uNormal scan
|
|
313
609
|
// there is nothing to look for in the fragment source.
|
|
314
|
-
let layout: VertexLayout = /\baColor\b/.test(opts.vertex) ? "colored" : "standard"
|
|
315
610
|
let normalMatrix = /\buNormal\b/.test(opts.vertex) || /\buNormal\b/.test(opts.fragment)
|
|
316
611
|
let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
|
|
317
612
|
let depth = opts.depth ?? true
|
|
318
613
|
// An empty list declares nothing - same as absent (the engine requires an
|
|
319
614
|
// instance buffer exactly when attributes are declared).
|
|
320
615
|
let instanceAttributes = opts.instanceAttributes?.length ? opts.instanceAttributes.map(a => ({ ...a })) : undefined
|
|
321
|
-
let
|
|
322
|
-
if (
|
|
616
|
+
let programFor = (): ProgramId => {
|
|
617
|
+
if (program === undefined) {
|
|
323
618
|
let vs = compileShader("vertex", opts.vertex, { header: needsHeader(opts.vertex) })
|
|
324
619
|
let fs = compileShader("fragment", opts.fragment, { header: needsHeader(opts.fragment) })
|
|
325
620
|
program = linkProgram(vs, fs, { label: opts.label })
|
|
326
621
|
destroyShader(vs)
|
|
327
622
|
destroyShader(fs)
|
|
328
|
-
|
|
329
|
-
|
|
623
|
+
}
|
|
624
|
+
return program
|
|
625
|
+
}
|
|
626
|
+
// What the program reads from the GEOMETRY: the engine's reflection of
|
|
627
|
+
// the linked program minus the per-instance names (those come from the
|
|
628
|
+
// record buffer, declared on the pipeline beside the layout).
|
|
629
|
+
let attributes = (): VertexAttribute[] =>
|
|
630
|
+
programAttributes(programFor()).filter(a => !instanceAttributes?.some(i => i.name === a.name))
|
|
631
|
+
let pipelineFor = (layout: VertexLayout | undefined): RenderPipelineId => {
|
|
632
|
+
let key = layoutKey(layout)
|
|
633
|
+
let pipeline = pipelines.get(key)
|
|
634
|
+
if (pipeline === undefined) {
|
|
635
|
+
pipeline = createRenderPipeline(programFor(), {
|
|
636
|
+
attributes: layoutAttributes(layout),
|
|
330
637
|
instanceAttributes,
|
|
331
638
|
depth,
|
|
332
639
|
// depthWrite needs a depth buffer, so the transparent default
|
|
@@ -337,18 +644,17 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
|
|
|
337
644
|
topology: opts.topology,
|
|
338
645
|
label: opts.label,
|
|
339
646
|
})
|
|
647
|
+
pipelines.set(key, pipeline)
|
|
340
648
|
}
|
|
341
649
|
return pipeline
|
|
342
650
|
}
|
|
343
651
|
return {
|
|
344
652
|
instance(inst = {}) {
|
|
345
|
-
return { normalMatrix,
|
|
653
|
+
return { normalMatrix, attributes, transparent, instanceAttributes, pipeline: pipelineFor, params: inst.params ?? {}, textures: inst.textures }
|
|
346
654
|
},
|
|
347
655
|
dispose() {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
pipeline = undefined
|
|
351
|
-
}
|
|
656
|
+
for (let pipeline of pipelines.values()) destroyRenderPipeline(pipeline)
|
|
657
|
+
pipelines.clear()
|
|
352
658
|
if (program !== undefined) {
|
|
353
659
|
destroyProgram(program)
|
|
354
660
|
program = undefined
|
package/src/math.ts
CHANGED
|
@@ -191,6 +191,24 @@ export function perspective(out: Mat4, fovy: number, aspect: number, near: numbe
|
|
|
191
191
|
return out
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Orthographic projection with the same y-down clip flip BAKED IN as
|
|
196
|
+
* perspective() (row two negated): view-space x in [left, right] and y in
|
|
197
|
+
* [bottom, top] fill the target at any depth, [near, far] maps to depth
|
|
198
|
+
* like perspective. The camera's `ortho` option; the flip lives here for
|
|
199
|
+
* the reason given at perspective().
|
|
200
|
+
*/
|
|
201
|
+
export function orthographic(out: Mat4, left: number, right: number, top: number, bottom: number, near: number, far: number): Mat4 {
|
|
202
|
+
let lr = 1 / (left - right)
|
|
203
|
+
let bt = 1 / (bottom - top)
|
|
204
|
+
let nf = 1 / (near - far)
|
|
205
|
+
out[0] = -2 * lr; out[1] = 0; out[2] = 0; out[3] = 0
|
|
206
|
+
out[4] = 0; out[5] = 2 * bt; out[6] = 0; out[7] = 0
|
|
207
|
+
out[8] = 0; out[9] = 0; out[10] = 2 * nf; out[11] = 0
|
|
208
|
+
out[12] = (left + right) * lr; out[13] = -(top + bottom) * bt; out[14] = (far + near) * nf; out[15] = 1
|
|
209
|
+
return out
|
|
210
|
+
}
|
|
211
|
+
|
|
194
212
|
// Quaternions, the rotation the scene actually stores. Euler triples are a
|
|
195
213
|
// boundary format only - authoring (setTransform's `rotation`, the
|
|
196
214
|
// components' `rotation` prop) and reading back (getRotation) - so the order
|
|
@@ -579,3 +597,130 @@ export function lookAt(out: Mat4, eye: Vec3, target: Vec3, up: Vec3): Mat4 {
|
|
|
579
597
|
out[15] = 1
|
|
580
598
|
return out
|
|
581
599
|
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Entry distance of a ray against a box: the smallest t >= 0 with
|
|
603
|
+
* origin + t * direction inside [min, max] (0 when the origin starts
|
|
604
|
+
* inside), or -1 for a miss. The direction need not be normalized - t is
|
|
605
|
+
* in units of its length, which is what keeps a ray transformed into a
|
|
606
|
+
* mesh's local space reporting world distances.
|
|
607
|
+
*/
|
|
608
|
+
export function rayBoxDistance(
|
|
609
|
+
ox: number, oy: number, oz: number,
|
|
610
|
+
dx: number, dy: number, dz: number,
|
|
611
|
+
minX: number, minY: number, minZ: number,
|
|
612
|
+
maxX: number, maxY: number, maxZ: number,
|
|
613
|
+
): number {
|
|
614
|
+
let tNear = 0
|
|
615
|
+
let tFar = Infinity
|
|
616
|
+
// Per axis: a zero direction component never crosses the slab, so the
|
|
617
|
+
// origin must already be inside it (the multiply-by-inverse shortcut
|
|
618
|
+
// turns that case into NaN, hence the explicit branch).
|
|
619
|
+
if (dx === 0) {
|
|
620
|
+
if (ox < minX || ox > maxX) return -1
|
|
621
|
+
} else {
|
|
622
|
+
let inv = 1 / dx
|
|
623
|
+
let t1 = (minX - ox) * inv
|
|
624
|
+
let t2 = (maxX - ox) * inv
|
|
625
|
+
if (t1 > t2) { let t = t1; t1 = t2; t2 = t }
|
|
626
|
+
if (t1 > tNear) tNear = t1
|
|
627
|
+
if (t2 < tFar) tFar = t2
|
|
628
|
+
}
|
|
629
|
+
if (dy === 0) {
|
|
630
|
+
if (oy < minY || oy > maxY) return -1
|
|
631
|
+
} else {
|
|
632
|
+
let inv = 1 / dy
|
|
633
|
+
let t1 = (minY - oy) * inv
|
|
634
|
+
let t2 = (maxY - oy) * inv
|
|
635
|
+
if (t1 > t2) { let t = t1; t1 = t2; t2 = t }
|
|
636
|
+
if (t1 > tNear) tNear = t1
|
|
637
|
+
if (t2 < tFar) tFar = t2
|
|
638
|
+
}
|
|
639
|
+
if (dz === 0) {
|
|
640
|
+
if (oz < minZ || oz > maxZ) return -1
|
|
641
|
+
} else {
|
|
642
|
+
let inv = 1 / dz
|
|
643
|
+
let t1 = (minZ - oz) * inv
|
|
644
|
+
let t2 = (maxZ - oz) * inv
|
|
645
|
+
if (t1 > t2) { let t = t1; t1 = t2; t2 = t }
|
|
646
|
+
if (t1 > tNear) tNear = t1
|
|
647
|
+
if (t2 < tFar) tFar = t2
|
|
648
|
+
}
|
|
649
|
+
return tFar >= tNear ? tNear : -1
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* The far bound of slice `index` of `count` when a range near..far is
|
|
654
|
+
* split for shadow cascades: `lambda` 0 slices it uniformly, 1
|
|
655
|
+
* logarithmically (equal texel density per unit of view depth, which
|
|
656
|
+
* starves the far slices), between the two in between. The last slice
|
|
657
|
+
* ends at `far`; a near of 0 has no logarithm and slices uniformly.
|
|
658
|
+
*/
|
|
659
|
+
export function cascadeSplit(near: number, far: number, index: number, count: number, lambda: number): number {
|
|
660
|
+
if (index >= count - 1) return far
|
|
661
|
+
let t = (index + 1) / count
|
|
662
|
+
let uniform = near + (far - near) * t
|
|
663
|
+
if (!(near > 0)) return uniform
|
|
664
|
+
let log = near * Math.pow(far / near, t)
|
|
665
|
+
return uniform + (log - uniform) * lambda
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** The camera facts a frustum slice depends on: its view matrix (rows are
|
|
669
|
+
* its right, up and back axes), eye, vertical fov in degrees and, for an
|
|
670
|
+
* orthographic camera, the extents (fov ignored then). */
|
|
671
|
+
export type FrustumSpec = { view: Mat4; eye: Vec3; fov: number; ortho: { left: number; right: number; top: number; bottom: number } | null }
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* The bounding sphere of the slice zn..zf of a camera's view frustum
|
|
675
|
+
* (`aspect` = width / height): writes the centre to `out`, returns the
|
|
676
|
+
* radius. Perspective: the centre sits on the view axis where the near
|
|
677
|
+
* and far corner rings are equidistant, clamped into the slice, so it
|
|
678
|
+
* is the tightest sphere on the axis; orthographic: the slice box's
|
|
679
|
+
* centre and half-diagonal. A sphere rather than the slice's own corners
|
|
680
|
+
* so a shadow box fitted to it keeps its size while the camera turns.
|
|
681
|
+
*/
|
|
682
|
+
export function frustumSliceSphere(out: Vec3, cam: FrustumSpec, aspect: number, zn: number, zf: number): number {
|
|
683
|
+
let v = cam.view
|
|
684
|
+
let fx = -v[2]
|
|
685
|
+
let fy = -v[6]
|
|
686
|
+
let fz = -v[10]
|
|
687
|
+
let o = cam.ortho
|
|
688
|
+
if (o === null) {
|
|
689
|
+
// Corner distance from the axis per unit of depth.
|
|
690
|
+
let k = Math.tan((cam.fov * Math.PI) / 360) * Math.hypot(1, aspect)
|
|
691
|
+
let rn = zn * k
|
|
692
|
+
let rf = zf * k
|
|
693
|
+
let zc = zf > zn ? Math.min(zf, Math.max(zn, (zf * zf + rf * rf - zn * zn - rn * rn) / (2 * (zf - zn)))) : zn
|
|
694
|
+
out[0] = cam.eye[0] + fx * zc
|
|
695
|
+
out[1] = cam.eye[1] + fy * zc
|
|
696
|
+
out[2] = cam.eye[2] + fz * zc
|
|
697
|
+
return Math.hypot(zf - zc, rf)
|
|
698
|
+
}
|
|
699
|
+
let zc = 0.5 * (zn + zf)
|
|
700
|
+
let cx = 0.5 * (o.left + o.right)
|
|
701
|
+
let cy = 0.5 * (o.top + o.bottom)
|
|
702
|
+
out[0] = cam.eye[0] + fx * zc + v[0] * cx + v[1] * cy
|
|
703
|
+
out[1] = cam.eye[1] + fy * zc + v[4] * cx + v[5] * cy
|
|
704
|
+
out[2] = cam.eye[2] + fz * zc + v[8] * cx + v[9] * cy
|
|
705
|
+
return Math.hypot(0.5 * (o.right - o.left), 0.5 * (o.top - o.bottom), 0.5 * (zf - zn))
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Snap `p`'s coordinates along the first two axes of `basis` (a rotation
|
|
710
|
+
* matrix whose rows are the frame's axes - `lookAt([0, 0, 0], dir, up)`
|
|
711
|
+
* for a light) to multiples of `step`, leaving the third as it is;
|
|
712
|
+
* writes to `out`, which may be `p`. A shadow box centred on the result
|
|
713
|
+
* moves by whole texels only, so its shadows do not swim as the camera
|
|
714
|
+
* creeps.
|
|
715
|
+
*/
|
|
716
|
+
export function snapToGrid(out: Vec3, p: Vec3, basis: Mat4, step: number): Vec3 {
|
|
717
|
+
let x = basis[0] * p[0] + basis[4] * p[1] + basis[8] * p[2]
|
|
718
|
+
let y = basis[1] * p[0] + basis[5] * p[1] + basis[9] * p[2]
|
|
719
|
+
let z = basis[2] * p[0] + basis[6] * p[1] + basis[10] * p[2]
|
|
720
|
+
x = Math.round(x / step) * step
|
|
721
|
+
y = Math.round(y / step) * step
|
|
722
|
+
out[0] = basis[0] * x + basis[1] * y + basis[2] * z
|
|
723
|
+
out[1] = basis[4] * x + basis[5] * y + basis[6] * z
|
|
724
|
+
out[2] = basis[8] * x + basis[9] * y + basis[10] * z
|
|
725
|
+
return out
|
|
726
|
+
}
|