@solidrt/3d 0.0.51 → 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,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 { VERTEX_LAYOUTS } from "./geometry.ts"
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 (lazily created). */
45
- 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
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?: Record<string, TextureId>
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 layout the pipeline is built for; absent means "standard".
55
- * shaderMaterial sets "colored" when the vertex stage reads `aColor`. A
56
- * mesh whose geometry layout differs is rejected at add() - the strides
57
- * disagree, so a mismatch would render garbage, not just miss a channel. */
58
- 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[]
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 pipelines: Partial<Record<UnlitClass, RenderPipelineId>> = {}
117
+ let programs: Partial<Record<UnlitClass, ProgramId>> = {}
118
+ let pipelines = new Map<string, RenderPipelineId>()
110
119
 
111
- // One pipeline per unlit CLASS: fragment kind x transparency, since blend
112
- // 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.
113
123
  type UnlitClass = "color" | "map" | "color-transparent" | "map-transparent"
114
124
 
115
- function pipelineFor(cls: UnlitClass): RenderPipelineId {
116
- 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)
117
141
  if (existing !== undefined) return existing
118
- if (sharedVertex === undefined) sharedVertex = compileShader("vertex", VERTEX_SRC, { header: true })
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: VERTEX_LAYOUTS.standard,
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[cls] = pipeline
152
+ pipelines.set(key, pipeline)
133
153
  return pipeline
134
154
  }
135
155
 
@@ -153,15 +173,287 @@ 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
- if (opts.map !== undefined) {
157
- return {
158
- pipeline: () => pipelineFor(transparent ? "map-transparent" : "map"),
159
- params: { uColor },
160
- textures: { uMap: opts.map },
161
- 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);
162
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)
163
313
  }
164
- return { pipeline: () => pipelineFor(transparent ? "color-transparent" : "color"), params: { uColor }, transparent }
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()
355
+ }
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
165
457
  }
166
458
 
167
459
  // Mirrors the engine's own preamble rule: a source carrying its own
@@ -222,11 +514,14 @@ export type ShaderMaterialClassOptions = {
222
514
  * normals, correct under non-uniform scale - and `uniform vec3 uCamPos`
223
515
  * the camera's world position, shared like uViewProj (the specular /
224
516
  * fresnel view vector: `uCamPos - worldPos`). Declare any of the
225
- * layout's `in` attributes (aPos vec3, aNormal vec3, aUV vec2);
226
- * undeclared ones are skipped. Reading `in vec4 aColor` opts the
227
- * material into the "colored" 12-float layout - the per-vertex data
228
- * channel (tint, baked AO, any four scalars); its meshes then need
229
- * 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.
230
525
  * `@solidrt/3d/glsl` exports a standard
231
526
  * vertex stage and lighting pieces built on exactly this contract.
232
527
  */
@@ -267,7 +562,7 @@ export type ShaderMaterialInstanceOptions = {
267
562
  /** Uniform seeds beyond the standard set; update per mesh later with
268
563
  * setMeshParams. */
269
564
  params?: ShaderParams
270
- textures?: Record<string, TextureId>
565
+ textures?: TextureBindings
271
566
  }
272
567
 
273
568
  export type ShaderMaterialOptions = ShaderMaterialClassOptions & ShaderMaterialInstanceOptions
@@ -308,25 +603,36 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
308
603
  }
309
604
  }
310
605
  let program: ProgramId | undefined
311
- let pipeline: RenderPipelineId | undefined
606
+ let pipelines = new Map<string, RenderPipelineId>()
312
607
  // Attributes live in the vertex stage only, so unlike the uNormal scan
313
608
  // there is nothing to look for in the fragment source.
314
- let layout: VertexLayout = /\baColor\b/.test(opts.vertex) ? "colored" : "standard"
315
609
  let normalMatrix = /\buNormal\b/.test(opts.vertex) || /\buNormal\b/.test(opts.fragment)
316
610
  let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
317
611
  let depth = opts.depth ?? true
318
612
  // An empty list declares nothing - same as absent (the engine requires an
319
613
  // instance buffer exactly when attributes are declared).
320
614
  let instanceAttributes = opts.instanceAttributes?.length ? opts.instanceAttributes.map(a => ({ ...a })) : undefined
321
- let pipelineFor = (): RenderPipelineId => {
322
- if (pipeline === undefined) {
615
+ let programFor = (): ProgramId => {
616
+ if (program === undefined) {
323
617
  let vs = compileShader("vertex", opts.vertex, { header: needsHeader(opts.vertex) })
324
618
  let fs = compileShader("fragment", opts.fragment, { header: needsHeader(opts.fragment) })
325
619
  program = linkProgram(vs, fs, { label: opts.label })
326
620
  destroyShader(vs)
327
621
  destroyShader(fs)
328
- pipeline = createRenderPipeline(program, {
329
- 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),
330
636
  instanceAttributes,
331
637
  depth,
332
638
  // depthWrite needs a depth buffer, so the transparent default
@@ -337,18 +643,17 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
337
643
  topology: opts.topology,
338
644
  label: opts.label,
339
645
  })
646
+ pipelines.set(key, pipeline)
340
647
  }
341
648
  return pipeline
342
649
  }
343
650
  return {
344
651
  instance(inst = {}) {
345
- return { normalMatrix, layout, transparent, instanceAttributes, pipeline: pipelineFor, params: inst.params ?? {}, textures: inst.textures }
652
+ return { normalMatrix, attributes, transparent, instanceAttributes, pipeline: pipelineFor, params: inst.params ?? {}, textures: inst.textures }
346
653
  },
347
654
  dispose() {
348
- if (pipeline !== undefined) {
349
- destroyRenderPipeline(pipeline)
350
- pipeline = undefined
351
- }
655
+ for (let pipeline of pipelines.values()) destroyRenderPipeline(pipeline)
656
+ pipelines.clear()
352
657
  if (program !== undefined) {
353
658
  destroyProgram(program)
354
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
@@ -579,3 +597,54 @@ 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
+ }