@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/src/material.ts CHANGED
@@ -1,5 +1,7 @@
1
- // Materials pair GLSL with pipeline state, deduped hard: one program and
2
- // one render pipeline per material CLASS (unlit color, unlit textured),
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 { VERTEX_LAYOUTS } from "./geometry.ts"
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 (lazily created). */
44
- pipeline(): RenderPipelineId
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?: Record<string, TextureId>
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 layout the pipeline is built for; absent means "standard".
54
- * shaderMaterial sets "colored" when the vertex stage reads `aColor`. A
55
- * mesh whose geometry layout differs is rejected at add() - the strides
56
- * disagree, so a mismatch would render garbage, not just miss a channel. */
57
- layout?: VertexLayout
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 pipelines: Partial<Record<UnlitClass, RenderPipelineId>> = {}
117
+ let programs: Partial<Record<UnlitClass, ProgramId>> = {}
118
+ let pipelines = new Map<string, RenderPipelineId>()
104
119
 
105
- // One pipeline per unlit CLASS: fragment kind x transparency, since blend
106
- // state is pipeline state.
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 pipelineFor(cls: UnlitClass): RenderPipelineId {
110
- let existing = pipelines[cls]
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
- if (sharedVertex === undefined) sharedVertex = compileShader("vertex", VERTEX_SRC, { header: true })
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: VERTEX_LAYOUTS.standard,
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[cls] = pipeline
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
- if (opts.map !== undefined) {
151
- return {
152
- pipeline: () => pipelineFor(transparent ? "map-transparent" : "map"),
153
- params: { uColor },
154
- textures: { uMap: opts.map },
155
- transparent,
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 { pipeline: () => pipelineFor(transparent ? "color-transparent" : "color"), params: { uColor }, transparent }
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
- * layout's `in` attributes (aPos vec3, aNormal vec3, aUV vec2);
220
- * undeclared ones are skipped. Reading `in vec4 aColor` opts the
221
- * material into the "colored" 12-float layout - the per-vertex data
222
- * channel (tint, baked AO, any four scalars); its meshes then need
223
- * withColors() geometry, and a layout mismatch throws at add().
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?: Record<string, TextureId>
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 pipeline: RenderPipelineId | undefined
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
- let pipelineFor = (): RenderPipelineId => {
302
- if (pipeline === undefined) {
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
- pipeline = createRenderPipeline(program, {
309
- attributes: VERTEX_LAYOUTS[layout],
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, layout, transparent, pipeline: pipelineFor, params: inst.params ?? {}, textures: inst.textures }
652
+ return { normalMatrix, attributes, transparent, instanceAttributes, pipeline: pipelineFor, params: inst.params ?? {}, textures: inst.textures }
325
653
  },
326
654
  dispose() {
327
- if (pipeline !== undefined) {
328
- destroyRenderPipeline(pipeline)
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
+ }