@solidrt/3d 0.0.50 → 0.0.52
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 +393 -87
- package/README.md +20 -10
- package/demos/README.md +15 -0
- package/demos/assets/icon.svg +23 -0
- package/demos/package.json +9 -0
- package/demos/src/the-third-dimension.tsx +866 -0
- package/demos/tsconfig.json +15 -0
- package/examples/README.md +34 -2
- package/examples/aim.tsx +5 -5
- package/examples/instanced.tsx +158 -0
- 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 +5 -3
- package/src/components.tsx +171 -3
- package/src/geometry-gpu.ts +97 -0
- package/src/geometry.ts +413 -162
- package/src/glsl.ts +83 -3
- package/src/gltf.ts +437 -0
- package/src/index.ts +18 -11
- package/src/material.ts +373 -47
- package/src/math.ts +114 -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 +1270 -281
- package/src/sweep.ts +21 -36
- 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,32 +36,43 @@ import type {
|
|
|
33
36
|
RenderPipelineId,
|
|
34
37
|
ShaderParams,
|
|
35
38
|
ShaderStageId,
|
|
39
|
+
TextureBindings,
|
|
36
40
|
TextureId,
|
|
37
41
|
Topology,
|
|
42
|
+
VertexAttribute,
|
|
38
43
|
} from "@solidrt/core/gpu"
|
|
39
|
-
import {
|
|
44
|
+
import { layoutAttributes, layoutKey, layoutSlot } from "./geometry.ts"
|
|
40
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"
|
|
41
47
|
|
|
42
48
|
export type Material = {
|
|
43
|
-
/** The pipeline this material draws with
|
|
44
|
-
|
|
49
|
+
/** The pipeline this material draws with for geometry of `layout`
|
|
50
|
+
* (lazily created, one per layout met). */
|
|
51
|
+
pipeline(layout: VertexLayout | undefined): RenderPipelineId
|
|
45
52
|
/** Per-entry uniform values this material contributes at addDraw. */
|
|
46
53
|
params: ShaderParams
|
|
47
54
|
/** Per-entry sampler bindings, when the material samples textures. */
|
|
48
|
-
textures?:
|
|
55
|
+
textures?: TextureBindings
|
|
49
56
|
/** True when the vertex stage declares `uNormal`: the scene then writes
|
|
50
57
|
* the world matrix's inverse-transpose alongside uModel for meshes using
|
|
51
58
|
* this material (set automatically by shaderMaterial). */
|
|
52
59
|
normalMatrix?: boolean
|
|
53
|
-
/** The vertex
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
|
|
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[]
|
|
58
67
|
/** True when the pipeline blends over (blend "alpha", depthWrite off):
|
|
59
68
|
* the scene draws this material's meshes after every opaque one, sorted
|
|
60
69
|
* back-to-front by mesh origin, and re-sorts them when the camera moves. */
|
|
61
70
|
transparent?: boolean
|
|
71
|
+
/** Per-instance attributes, when the material's pipeline declares them
|
|
72
|
+
* (shaderMaterialClass's `instanceAttributes`). Such a material draws
|
|
73
|
+
* instanced meshes only - createInstancedMesh supplies the record buffer,
|
|
74
|
+
* and createMesh meshes are rejected at add(). */
|
|
75
|
+
instanceAttributes?: VertexAttribute[]
|
|
62
76
|
/** Present on materials that own their pipeline (shaderMaterial). */
|
|
63
77
|
dispose?(): void
|
|
64
78
|
}
|
|
@@ -100,30 +114,42 @@ const FRAGMENT_MAP_SRC = glsl`
|
|
|
100
114
|
`
|
|
101
115
|
|
|
102
116
|
let sharedVertex: ShaderStageId | undefined
|
|
103
|
-
let
|
|
117
|
+
let programs: Partial<Record<UnlitClass, ProgramId>> = {}
|
|
118
|
+
let pipelines = new Map<string, RenderPipelineId>()
|
|
104
119
|
|
|
105
|
-
// One
|
|
106
|
-
// 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.
|
|
107
123
|
type UnlitClass = "color" | "map" | "color-transparent" | "map-transparent"
|
|
108
124
|
|
|
109
|
-
function
|
|
110
|
-
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)
|
|
111
141
|
if (existing !== undefined) return existing
|
|
112
|
-
|
|
142
|
+
let program = programFor(cls)
|
|
113
143
|
let transparent = cls.endsWith("-transparent")
|
|
114
|
-
let fragment = compileShader("fragment", cls.startsWith("color") ? FRAGMENT_COLOR_SRC : FRAGMENT_MAP_SRC, {
|
|
115
|
-
header: true,
|
|
116
|
-
})
|
|
117
|
-
let program = linkProgram(sharedVertex, fragment, { label: "scene-unlit-" + cls })
|
|
118
144
|
let pipeline = createRenderPipeline(program, {
|
|
119
|
-
attributes:
|
|
145
|
+
attributes: layoutAttributes(layout),
|
|
120
146
|
depth: true,
|
|
121
147
|
depthWrite: transparent ? false : undefined,
|
|
122
148
|
blend: transparent ? "alpha" : undefined,
|
|
123
149
|
cull: "back",
|
|
124
150
|
label: "scene-unlit-" + cls,
|
|
125
151
|
})
|
|
126
|
-
pipelines
|
|
152
|
+
pipelines.set(key, pipeline)
|
|
127
153
|
return pipeline
|
|
128
154
|
}
|
|
129
155
|
|
|
@@ -147,15 +173,287 @@ export function unlit(opts: UnlitOptions = {}): Material {
|
|
|
147
173
|
let a = color.length === 4 ? color[3] : 1
|
|
148
174
|
let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
|
|
149
175
|
let transparent = opts.transparent === true
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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: slot i is
|
|
220
|
+
// directional light i's map, matrix and biases (target-level, bound by
|
|
221
|
+
// the scene), uShadowCast[i] says whether it casts; SHADOW_LOOKUP turns
|
|
222
|
+
// the index into the factor.
|
|
223
|
+
function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, shadow: boolean): string {
|
|
224
|
+
return glsl`
|
|
225
|
+
in vec3 vWorldPos;
|
|
226
|
+
in vec3 vNormal;
|
|
227
|
+
in vec2 vUv;
|
|
228
|
+
${vertexColors ? "in vec4 vColor;" : ""}
|
|
229
|
+
uniform vec4 uColor;
|
|
230
|
+
${map ? "uniform sampler2D uMap;" : ""}
|
|
231
|
+
uniform float uSpecular;
|
|
232
|
+
uniform float uShininess;
|
|
233
|
+
${triplanar ? "uniform float uTriplanar;" : ""}
|
|
234
|
+
uniform vec3 uCamPos;
|
|
235
|
+
uniform vec3 uHemiSky;
|
|
236
|
+
uniform vec3 uHemiGround;
|
|
237
|
+
uniform int uLightCount;
|
|
238
|
+
uniform vec3 uLightDir[${MAX_LIGHTS}];
|
|
239
|
+
uniform vec3 uLightColor[${MAX_LIGHTS}];
|
|
240
|
+
${
|
|
241
|
+
shadow
|
|
242
|
+
? `${SHADOW_SLOTS}
|
|
243
|
+
${SHADOW}
|
|
244
|
+
${SHADOW_LOOKUP}`
|
|
245
|
+
: ""
|
|
246
|
+
}
|
|
247
|
+
${HEMISPHERE}
|
|
248
|
+
${LAMBERT}
|
|
249
|
+
${BLINN_SPECULAR}
|
|
250
|
+
|
|
251
|
+
void main() {
|
|
252
|
+
vec3 n = normalize(vNormal);
|
|
253
|
+
vec4 base = uColor;
|
|
254
|
+
${
|
|
255
|
+
map
|
|
256
|
+
? triplanar
|
|
257
|
+
? `vec3 w = pow(abs(n), vec3(4.0));
|
|
258
|
+
w /= w.x + w.y + w.z;
|
|
259
|
+
vec3 p = vWorldPos * uTriplanar;
|
|
260
|
+
base *= texture(uMap, p.yz) * w.x + texture(uMap, p.xz) * w.y + texture(uMap, p.xy) * w.z;`
|
|
261
|
+
: "base *= texture(uMap, vUv);"
|
|
262
|
+
: ""
|
|
263
|
+
}
|
|
264
|
+
${vertexColors ? "base *= vColor;" : ""}
|
|
265
|
+
vec3 v = normalize(uCamPos - vWorldPos);
|
|
266
|
+
vec3 light = hemisphere(n, uHemiSky, uHemiGround);
|
|
267
|
+
vec3 spec = vec3(0.0);
|
|
268
|
+
for (int i = 0; i < ${MAX_LIGHTS}; i++) {
|
|
269
|
+
if (i >= uLightCount) break;
|
|
270
|
+
vec3 l = uLightDir[i];
|
|
271
|
+
${
|
|
272
|
+
shadow ? "float s = lightShadow(i, vWorldPos, n);" : "float s = 1.0;"
|
|
273
|
+
}
|
|
274
|
+
light += uLightColor[i] * lambert(n, l) * s;
|
|
275
|
+
spec += uLightColor[i] * blinnSpecular(n, v, l, uShininess) * s;
|
|
276
|
+
}
|
|
277
|
+
fragColor = vec4(base.rgb * light + spec * uSpecular * base.a, base.a);
|
|
156
278
|
}
|
|
279
|
+
`
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let litClasses = new Map<string, ShaderMaterialClass>()
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* A lit material: hemisphere ambient plus the scene's directional lights
|
|
286
|
+
* (DirectionalLight nodes), Lambert diffuse, optional
|
|
287
|
+
* Blinn-Phong highlight. Same options as unlit (color, map, transparent)
|
|
288
|
+
* plus vertexColors, specular/shininess and triplanar mapping. One program
|
|
289
|
+
* per option combination, one pipeline per vertex layout met, shared by
|
|
290
|
+
* every instance - a thousand lit meshes still share one pipeline. No
|
|
291
|
+
* lights set means black except for the hemisphere term, which also
|
|
292
|
+
* starts at zero: set at least one of the two.
|
|
293
|
+
*/
|
|
294
|
+
export function lit(opts: LitOptions = {}): Material {
|
|
295
|
+
let color = opts.color ?? [1, 1, 1]
|
|
296
|
+
let a = color.length === 4 ? color[3] : 1
|
|
297
|
+
let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
|
|
298
|
+
let map = opts.map !== undefined
|
|
299
|
+
let vertexColors = opts.vertexColors === true
|
|
300
|
+
let triplanar = map && opts.triplanar !== undefined
|
|
301
|
+
let transparent = opts.transparent === true
|
|
302
|
+
let shadow = opts.receiveShadow !== false
|
|
303
|
+
let key = [map, vertexColors, triplanar, transparent, shadow].join("|")
|
|
304
|
+
let cls = litClasses.get(key)
|
|
305
|
+
if (cls === undefined) {
|
|
306
|
+
cls = shaderMaterialClass({
|
|
307
|
+
vertex: vertexColors ? LIT_VERTEX_COLORED : LIT_VERTEX,
|
|
308
|
+
fragment: litFragment(map, vertexColors, triplanar, shadow),
|
|
309
|
+
transparent,
|
|
310
|
+
label: "scene-lit-" + key,
|
|
311
|
+
})
|
|
312
|
+
litClasses.set(key, cls)
|
|
313
|
+
}
|
|
314
|
+
let material = cls.instance({
|
|
315
|
+
params: triplanar
|
|
316
|
+
? { uColor, uSpecular: opts.specular ?? 0, uShininess: opts.shininess ?? 30, uTriplanar: opts.triplanar! }
|
|
317
|
+
: { uColor, uSpecular: opts.specular ?? 0, uShininess: opts.shininess ?? 30 },
|
|
318
|
+
textures: map ? { uMap: opts.map! } : undefined,
|
|
319
|
+
})
|
|
320
|
+
return material
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// The shadow depth pass: position only, no color of interest (the target's
|
|
324
|
+
// depth texture is the output; the color write is the pipeline's minimum).
|
|
325
|
+
// Front faces culled, Three's shadowSide default: the map holds each
|
|
326
|
+
// caster's BACK surface, so a receiving front face at the same depth
|
|
327
|
+
// compares lit without a bias and acne needs no fighting on closed meshes.
|
|
328
|
+
const SHADOW_DEPTH_VERTEX = glsl`
|
|
329
|
+
in vec3 aPos;
|
|
330
|
+
uniform mat4 uModel;
|
|
331
|
+
uniform mat4 uViewProj;
|
|
332
|
+
void main() {
|
|
333
|
+
gl_Position = uViewProj * uModel * vec4(aPos, 1.0);
|
|
334
|
+
}
|
|
335
|
+
`
|
|
336
|
+
|
|
337
|
+
const SHADOW_DEPTH_FRAGMENT = glsl`
|
|
338
|
+
void main() {
|
|
339
|
+
fragColor = vec4(1.0);
|
|
340
|
+
}
|
|
341
|
+
`
|
|
342
|
+
|
|
343
|
+
let shadowDepth: Material | undefined
|
|
344
|
+
|
|
345
|
+
/** The override material of a scene's shadow view (internal): one class
|
|
346
|
+
* for the app, built on first use. */
|
|
347
|
+
export function shadowDepthMaterial(): Material {
|
|
348
|
+
if (shadowDepth === undefined) {
|
|
349
|
+
shadowDepth = shaderMaterialClass({
|
|
350
|
+
vertex: SHADOW_DEPTH_VERTEX,
|
|
351
|
+
fragment: SHADOW_DEPTH_FRAGMENT,
|
|
352
|
+
cull: "front",
|
|
353
|
+
label: "scene-shadow-depth",
|
|
354
|
+
}).instance()
|
|
157
355
|
}
|
|
158
|
-
return
|
|
356
|
+
return shadowDepth
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export type SpriteOptions = UnlitOptions & {
|
|
360
|
+
/** Which way the quad turns to face the camera. `"full"` (default,
|
|
361
|
+
* Three's Sprite): both axes follow the view, the quad is always flat
|
|
362
|
+
* to the screen. `"fixed-y"` (Godot's BILLBOARD_FIXED_Y): only the yaw
|
|
363
|
+
* follows the camera, the quad stays upright on world y - trees and
|
|
364
|
+
* standing characters, the classic sprite. */
|
|
365
|
+
billboard?: "full" | "fixed-y"
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// The billboard vertex stages: the unit quad's corners placed along the
|
|
369
|
+
// camera axes at the mesh's world position, with the quad's size read
|
|
370
|
+
// off uModel's column lengths so `scale` sizes the sprite like any mesh.
|
|
371
|
+
// The rotation part of uModel is otherwise ignored (the camera decides
|
|
372
|
+
// the facing). Fixed-y takes the yaw from the camera-to-center direction
|
|
373
|
+
// flattened onto XZ; straight above or below there is no yaw to take, so
|
|
374
|
+
// the quad falls back to facing +z rather than dividing by zero.
|
|
375
|
+
const SPRITE_VERTEX_SRC = glsl`
|
|
376
|
+
in vec3 aPos;
|
|
377
|
+
in vec2 aUV;
|
|
378
|
+
out vec2 vUv;
|
|
379
|
+
uniform mat4 uModel;
|
|
380
|
+
uniform mat4 uViewProj;
|
|
381
|
+
uniform vec3 uCamRight;
|
|
382
|
+
uniform vec3 uCamUp;
|
|
383
|
+
|
|
384
|
+
void main() {
|
|
385
|
+
vec3 center = uModel[3].xyz;
|
|
386
|
+
vec2 size = vec2(length(uModel[0].xyz), length(uModel[1].xyz));
|
|
387
|
+
vec3 world = center + uCamRight * (aPos.x * size.x) + uCamUp * (aPos.y * size.y);
|
|
388
|
+
gl_Position = uViewProj * vec4(world, 1.0);
|
|
389
|
+
vUv = aUV;
|
|
390
|
+
}
|
|
391
|
+
`
|
|
392
|
+
|
|
393
|
+
const SPRITE_FIXED_Y_VERTEX_SRC = glsl`
|
|
394
|
+
in vec3 aPos;
|
|
395
|
+
in vec2 aUV;
|
|
396
|
+
out vec2 vUv;
|
|
397
|
+
uniform mat4 uModel;
|
|
398
|
+
uniform mat4 uViewProj;
|
|
399
|
+
uniform vec3 uCamPos;
|
|
400
|
+
|
|
401
|
+
void main() {
|
|
402
|
+
vec3 center = uModel[3].xyz;
|
|
403
|
+
vec2 size = vec2(length(uModel[0].xyz), length(uModel[1].xyz));
|
|
404
|
+
vec3 toCam = uCamPos - center;
|
|
405
|
+
toCam.y = 0.0;
|
|
406
|
+
float len = length(toCam);
|
|
407
|
+
vec3 right = len > 1e-6 ? vec3(toCam.z, 0.0, -toCam.x) / len : vec3(1.0, 0.0, 0.0);
|
|
408
|
+
vec3 world = center + right * (aPos.x * size.x) + vec3(0.0, aPos.y * size.y, 0.0);
|
|
409
|
+
gl_Position = uViewProj * vec4(world, 1.0);
|
|
410
|
+
vUv = aUV;
|
|
411
|
+
}
|
|
412
|
+
`
|
|
413
|
+
|
|
414
|
+
let spriteClasses = new Map<string, ShaderMaterialClass>()
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* A sprite material: unlit color/map on a quad that turns to face the
|
|
418
|
+
* camera in the vertex stage (the shared uCamRight/uCamUp basis, or
|
|
419
|
+
* uCamPos for fixed-y), so a thousand sprites cost no per-frame JS. Draw
|
|
420
|
+
* it with createSprite / `<Sprite>`, which supply the unit quad; on other
|
|
421
|
+
* geometry the vertex stage still flattens every vertex onto the camera
|
|
422
|
+
* plane. Unlike unlit, `transparent` defaults to TRUE - sprites are cutouts
|
|
423
|
+
* far more often than not (Three's SpriteMaterial default) - pass false
|
|
424
|
+
* for an opaque one. Culling is off: a camera-facing quad has no back.
|
|
425
|
+
*/
|
|
426
|
+
export function sprite(opts: SpriteOptions = {}): Material {
|
|
427
|
+
let color = opts.color ?? [1, 1, 1]
|
|
428
|
+
let a = color.length === 4 ? color[3] : 1
|
|
429
|
+
let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
|
|
430
|
+
let map = opts.map !== undefined
|
|
431
|
+
let transparent = opts.transparent !== false
|
|
432
|
+
let fixedY = opts.billboard === "fixed-y"
|
|
433
|
+
let key = [map, transparent, fixedY].join("|")
|
|
434
|
+
let cls = spriteClasses.get(key)
|
|
435
|
+
if (cls === undefined) {
|
|
436
|
+
cls = shaderMaterialClass({
|
|
437
|
+
vertex: fixedY ? SPRITE_FIXED_Y_VERTEX_SRC : SPRITE_VERTEX_SRC,
|
|
438
|
+
fragment: map ? FRAGMENT_MAP_SRC : FRAGMENT_COLOR_SRC,
|
|
439
|
+
transparent,
|
|
440
|
+
cull: "none",
|
|
441
|
+
label: "scene-sprite-" + key,
|
|
442
|
+
})
|
|
443
|
+
spriteClasses.set(key, cls)
|
|
444
|
+
}
|
|
445
|
+
return cls.instance({ params: { uColor }, textures: map ? { uMap: opts.map! } : undefined })
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** The attributes `material` reads that `layout` does not carry (name and
|
|
449
|
+
* format) - empty when the pair is drawable. */
|
|
450
|
+
export function missingAttributes(material: Material, layout: VertexLayout | undefined): VertexAttribute[] {
|
|
451
|
+
let missing: VertexAttribute[] = []
|
|
452
|
+
for (let attr of material.attributes()) {
|
|
453
|
+
let slot = layoutSlot(layout, attr.name)
|
|
454
|
+
if (slot === null || slot.format !== attr.format) missing.push(attr)
|
|
455
|
+
}
|
|
456
|
+
return missing
|
|
159
457
|
}
|
|
160
458
|
|
|
161
459
|
// Mirrors the engine's own preamble rule: a source carrying its own
|
|
@@ -216,16 +514,30 @@ export type ShaderMaterialClassOptions = {
|
|
|
216
514
|
* normals, correct under non-uniform scale - and `uniform vec3 uCamPos`
|
|
217
515
|
* the camera's world position, shared like uViewProj (the specular /
|
|
218
516
|
* fresnel view vector: `uCamPos - worldPos`). Declare any of the
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
517
|
+
* geometry's `in` attributes by name (the standard aPos vec3, aNormal
|
|
518
|
+
* vec3, aUV vec2, or any channel appended with withAttribute);
|
|
519
|
+
* undeclared ones are skipped. What the program READS is the engine's
|
|
520
|
+
* word (reflected from the linked program, so an `in` the compiler
|
|
521
|
+
* dropped does not count); one the mesh's geometry layout does not
|
|
522
|
+
* carry (name and format) throws at add() - so `in vec4 aColor` needs
|
|
523
|
+
* withColors() geometry. The class builds one pipeline per layout its
|
|
524
|
+
* meshes bring, the program compiles once.
|
|
224
525
|
* `@solidrt/3d/glsl` exports a standard
|
|
225
526
|
* vertex stage and lighting pieces built on exactly this contract.
|
|
226
527
|
*/
|
|
227
528
|
vertex: string
|
|
228
529
|
fragment: string
|
|
530
|
+
/**
|
|
531
|
+
* Per-instance attributes: the vertex stage reads these as `in` variables
|
|
532
|
+
* beside the layout's own, and each drawn instance gets one record from
|
|
533
|
+
* the mesh's instance buffer (interleaved floats in this order). A class
|
|
534
|
+
* with instance attributes makes INSTANCED materials: attach their meshes
|
|
535
|
+
* with createInstancedMesh, which carries the records - a createMesh mesh
|
|
536
|
+
* is rejected at add(). A per-instance transform is data, not a matrix:
|
|
537
|
+
* a position/yaw/scale record beats four vec4 columns for most fleets,
|
|
538
|
+
* and the composed uModel still places the whole population.
|
|
539
|
+
*/
|
|
540
|
+
instanceAttributes?: VertexAttribute[]
|
|
229
541
|
/** Blend over what is behind, with the scene sorting this material's
|
|
230
542
|
* meshes back-to-front after the opaque ones (see Material.transparent).
|
|
231
543
|
* Sets the pipeline defaults blend "alpha" and depthWrite false; the
|
|
@@ -250,7 +562,7 @@ export type ShaderMaterialInstanceOptions = {
|
|
|
250
562
|
/** Uniform seeds beyond the standard set; update per mesh later with
|
|
251
563
|
* setMeshParams. */
|
|
252
564
|
params?: ShaderParams
|
|
253
|
-
textures?:
|
|
565
|
+
textures?: TextureBindings
|
|
254
566
|
}
|
|
255
567
|
|
|
256
568
|
export type ShaderMaterialOptions = ShaderMaterialClassOptions & ShaderMaterialInstanceOptions
|
|
@@ -291,22 +603,37 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
|
|
|
291
603
|
}
|
|
292
604
|
}
|
|
293
605
|
let program: ProgramId | undefined
|
|
294
|
-
let
|
|
606
|
+
let pipelines = new Map<string, RenderPipelineId>()
|
|
295
607
|
// Attributes live in the vertex stage only, so unlike the uNormal scan
|
|
296
608
|
// there is nothing to look for in the fragment source.
|
|
297
|
-
let layout: VertexLayout = /\baColor\b/.test(opts.vertex) ? "colored" : "standard"
|
|
298
609
|
let normalMatrix = /\buNormal\b/.test(opts.vertex) || /\buNormal\b/.test(opts.fragment)
|
|
299
610
|
let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
|
|
300
611
|
let depth = opts.depth ?? true
|
|
301
|
-
|
|
302
|
-
|
|
612
|
+
// An empty list declares nothing - same as absent (the engine requires an
|
|
613
|
+
// instance buffer exactly when attributes are declared).
|
|
614
|
+
let instanceAttributes = opts.instanceAttributes?.length ? opts.instanceAttributes.map(a => ({ ...a })) : undefined
|
|
615
|
+
let programFor = (): ProgramId => {
|
|
616
|
+
if (program === undefined) {
|
|
303
617
|
let vs = compileShader("vertex", opts.vertex, { header: needsHeader(opts.vertex) })
|
|
304
618
|
let fs = compileShader("fragment", opts.fragment, { header: needsHeader(opts.fragment) })
|
|
305
619
|
program = linkProgram(vs, fs, { label: opts.label })
|
|
306
620
|
destroyShader(vs)
|
|
307
621
|
destroyShader(fs)
|
|
308
|
-
|
|
309
|
-
|
|
622
|
+
}
|
|
623
|
+
return program
|
|
624
|
+
}
|
|
625
|
+
// What the program reads from the GEOMETRY: the engine's reflection of
|
|
626
|
+
// the linked program minus the per-instance names (those come from the
|
|
627
|
+
// record buffer, declared on the pipeline beside the layout).
|
|
628
|
+
let attributes = (): VertexAttribute[] =>
|
|
629
|
+
programAttributes(programFor()).filter(a => !instanceAttributes?.some(i => i.name === a.name))
|
|
630
|
+
let pipelineFor = (layout: VertexLayout | undefined): RenderPipelineId => {
|
|
631
|
+
let key = layoutKey(layout)
|
|
632
|
+
let pipeline = pipelines.get(key)
|
|
633
|
+
if (pipeline === undefined) {
|
|
634
|
+
pipeline = createRenderPipeline(programFor(), {
|
|
635
|
+
attributes: layoutAttributes(layout),
|
|
636
|
+
instanceAttributes,
|
|
310
637
|
depth,
|
|
311
638
|
// depthWrite needs a depth buffer, so the transparent default
|
|
312
639
|
// only applies when there is one.
|
|
@@ -316,18 +643,17 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
|
|
|
316
643
|
topology: opts.topology,
|
|
317
644
|
label: opts.label,
|
|
318
645
|
})
|
|
646
|
+
pipelines.set(key, pipeline)
|
|
319
647
|
}
|
|
320
648
|
return pipeline
|
|
321
649
|
}
|
|
322
650
|
return {
|
|
323
651
|
instance(inst = {}) {
|
|
324
|
-
return { normalMatrix,
|
|
652
|
+
return { normalMatrix, attributes, transparent, instanceAttributes, pipeline: pipelineFor, params: inst.params ?? {}, textures: inst.textures }
|
|
325
653
|
},
|
|
326
654
|
dispose() {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
pipeline = undefined
|
|
330
|
-
}
|
|
655
|
+
for (let pipeline of pipelines.values()) destroyRenderPipeline(pipeline)
|
|
656
|
+
pipelines.clear()
|
|
331
657
|
if (program !== undefined) {
|
|
332
658
|
destroyProgram(program)
|
|
333
659
|
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
|
|
@@ -213,6 +231,51 @@ export function quatNormalize(out: Quat, q: Quat): Quat {
|
|
|
213
231
|
return out
|
|
214
232
|
}
|
|
215
233
|
|
|
234
|
+
/**
|
|
235
|
+
* A transform update - the shape setTransform writes and transformGeometry
|
|
236
|
+
* bakes. Absent keys mean "keep" (nodes) or identity (geometry).
|
|
237
|
+
*/
|
|
238
|
+
export type TransformUpdate = {
|
|
239
|
+
position?: Vec3
|
|
240
|
+
/** Euler radians in XYZ order (x first), Three's `Euler` default -
|
|
241
|
+
* converted to a quaternion on use. */
|
|
242
|
+
rotation?: Vec3
|
|
243
|
+
/** The rotation itself. Normalized on use, so a hand-built or drifted
|
|
244
|
+
* quaternion cannot silently scale the geometry. Passing this together
|
|
245
|
+
* with `rotation` is an error, not a precedence question. */
|
|
246
|
+
quaternion?: Quat
|
|
247
|
+
/** A number is uniform scale. */
|
|
248
|
+
scale?: Vec3 | number
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Resolve an update's rotation into `out`: euler converted, quaternion
|
|
253
|
+
* normalized. Returns false (out untouched) when the update carries
|
|
254
|
+
* neither; throws when it carries both. `caller` names the verb in the
|
|
255
|
+
* error.
|
|
256
|
+
*/
|
|
257
|
+
export function updateRotation(out: Quat, update: TransformUpdate, caller: string): boolean {
|
|
258
|
+
let r = update.rotation
|
|
259
|
+
let q = update.quaternion
|
|
260
|
+
if (r !== undefined && q !== undefined) {
|
|
261
|
+
throw new Error("Pass rotation or quaternion to " + caller + ", not both")
|
|
262
|
+
}
|
|
263
|
+
if (r !== undefined) quatFromEuler(out, r)
|
|
264
|
+
else if (q !== undefined) quatNormalize(out, q)
|
|
265
|
+
else return false
|
|
266
|
+
return true
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Expand an update's scale (number = uniform) into `out`. */
|
|
270
|
+
export function updateScale(out: Vec3, scale: Vec3 | number): Vec3 {
|
|
271
|
+
if (typeof scale === "number") {
|
|
272
|
+
out[0] = scale; out[1] = scale; out[2] = scale
|
|
273
|
+
} else {
|
|
274
|
+
out[0] = scale[0]; out[1] = scale[1]; out[2] = scale[2]
|
|
275
|
+
}
|
|
276
|
+
return out
|
|
277
|
+
}
|
|
278
|
+
|
|
216
279
|
/**
|
|
217
280
|
* Euler radians to a quaternion, in XYZ order: x applied first, then y,
|
|
218
281
|
* then z (R = Rx * Ry * Rz on column vectors), Three's `Euler` default - a
|
|
@@ -534,3 +597,54 @@ export function lookAt(out: Mat4, eye: Vec3, target: Vec3, up: Vec3): Mat4 {
|
|
|
534
597
|
out[15] = 1
|
|
535
598
|
return out
|
|
536
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
|
+
}
|