@solidrt/3d 0.0.52 → 0.0.54

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/gltf.ts CHANGED
@@ -30,11 +30,16 @@ export type ModelMaterial = {
30
30
  color: [number, number, number, number]
31
31
  /** Index into ModelData.images (the base color texture), or null. */
32
32
  map: number | null
33
- /** glTF doubleSided. Reported, not applied: the standard materials cull
34
- * back faces. */
33
+ /** glTF doubleSided; createModel's default material draws it with
34
+ * `cull: "none"`. */
35
35
  doubleSided: boolean
36
- /** alphaMode BLEND. MASK draws opaque (no alpha test). */
36
+ /** alphaMode BLEND; createModel's default material blends it. */
37
37
  transparent: boolean
38
+ /** glTF alphaMode as written (default OPAQUE). MASK is a cutout:
39
+ * createModel's default material draws it with `alphaTest: alphaCutoff`. */
40
+ alphaMode: "OPAQUE" | "MASK" | "BLEND"
41
+ /** glTF alphaCutoff (default 0.5); meaningful for MASK only. */
42
+ alphaCutoff: number
38
43
  }
39
44
 
40
45
  /** One drawable: a mesh node's primitive, vertices in WORLD space. */
@@ -67,10 +72,21 @@ const CHUNK_JSON = 0x4e4f534a
67
72
  const CHUNK_BIN = 0x004e4942
68
73
  const MODE_TRIANGLES = 4
69
74
 
75
+ // The spec's alphaCutoff when a MASK material leaves it out.
76
+ const GLTF_ALPHA_CUTOFF = 0.5
77
+
70
78
  const COMPONENT_BYTES: Record<number, number> = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }
71
79
  const TYPE_ELEMENTS: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 }
72
80
 
73
- const DEFAULT_MATERIAL: ModelMaterial = { name: "default", color: [1, 1, 1, 1], map: null, doubleSided: false, transparent: false }
81
+ const DEFAULT_MATERIAL: ModelMaterial = {
82
+ name: "default",
83
+ color: [1, 1, 1, 1],
84
+ map: null,
85
+ doubleSided: false,
86
+ transparent: false,
87
+ alphaMode: "OPAQUE",
88
+ alphaCutoff: GLTF_ALPHA_CUTOFF,
89
+ }
74
90
 
75
91
  /** True when the bytes are a .glb container (the "glTF" magic). */
76
92
  export function isGlb(bytes: Uint8Array): boolean {
@@ -171,6 +187,8 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
171
187
  map,
172
188
  doubleSided: m.doubleSided === true,
173
189
  transparent: m.alphaMode === "BLEND",
190
+ alphaMode: m.alphaMode === "MASK" || m.alphaMode === "BLEND" ? m.alphaMode : "OPAQUE",
191
+ alphaCutoff: typeof m.alphaCutoff === "number" ? m.alphaCutoff : GLTF_ALPHA_CUTOFF,
174
192
  }
175
193
  })
176
194
  // Primitives without a material draw the spec's default; it is appended
package/src/material.ts CHANGED
@@ -35,7 +35,6 @@ import type {
35
35
  ProgramId,
36
36
  RenderPipelineId,
37
37
  ShaderParams,
38
- ShaderStageId,
39
38
  TextureBindings,
40
39
  TextureId,
41
40
  Topology,
@@ -73,18 +72,27 @@ export type Material = {
73
72
  * instanced meshes only - createInstancedMesh supplies the record buffer,
74
73
  * and createMesh meshes are rejected at add(). */
75
74
  instanceAttributes?: VertexAttribute[]
75
+ /** What a shadow view draws this material's meshes with instead of its
76
+ * default depth override: the depth pass culling the side this material
77
+ * culls (Three's shadowSide rule, Godot's shadow pass), so a `cull:
78
+ * "none"` caster casts from both faces, and for a cutout (lit
79
+ * alphaTest with a map) the same discard, so a plant casts leaves and
80
+ * not rectangles. Absent = the default (a back-culling material casts
81
+ * from its back faces). A shaderMaterial supplies its own through the
82
+ * instance option of the same name. */
83
+ shadow?: Material
76
84
  /** Present on materials that own their pipeline (shaderMaterial). */
77
85
  dispose?(): void
78
86
  }
79
87
 
80
- // One vertex stage serves every unlit class: model then view-projection,
81
- // plus the UV varying. uModel is per-entry (the scene writes it when the
82
- // mesh moves), uViewProj is target-shared (one write per camera move) - the
83
- // split is what keeps camera motion O(1) instead of O(meshes), and the
84
- // extra per-vertex mat4 multiply is free on the GPU. aNormal from the
85
- // shared layout is deliberately not declared - inactive attributes are
86
- // skipped and only the stride accounts for them.
87
- const VERTEX_SRC = glsl`
88
+ // The unlit vertex stage: model then view-projection, plus the UV
89
+ // varying. uModel is per-entry (the scene writes it when the mesh moves),
90
+ // uViewProj is target-shared (one write per camera move) - the split is
91
+ // what keeps camera motion O(1) instead of O(meshes), and the extra
92
+ // per-vertex mat4 multiply is free on the GPU. aNormal from the shared
93
+ // layout is deliberately not declared - inactive attributes are skipped
94
+ // and only the stride accounts for them.
95
+ const UNLIT_VERTEX = glsl`
88
96
  in vec3 aPos;
89
97
  in vec2 aUV;
90
98
  out vec2 vUv;
@@ -97,61 +105,28 @@ const VERTEX_SRC = glsl`
97
105
  }
98
106
  `
99
107
 
100
- const FRAGMENT_COLOR_SRC = glsl`
101
- uniform vec4 uColor;
102
- void main() {
103
- fragColor = uColor;
104
- }
105
- `
106
-
107
- const FRAGMENT_MAP_SRC = glsl`
108
- in vec2 vUv;
109
- uniform sampler2D uMap;
110
- uniform vec4 uColor;
111
- void main() {
112
- fragColor = texture(uMap, vUv) * uColor;
113
- }
114
- `
115
-
116
- let sharedVertex: ShaderStageId | undefined
117
- let programs: Partial<Record<UnlitClass, ProgramId>> = {}
118
- let pipelines = new Map<string, RenderPipelineId>()
119
-
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.
123
- type UnlitClass = "color" | "map" | "color-transparent" | "map-transparent"
124
-
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
108
+ // The unlit fragment (sprites share it): the color, times the map when
109
+ // there is one, with the alphaTest discard when asked for. An opaque
110
+ // class writes alpha 1: the scene target is composited premultiplied, so
111
+ // a leaked texel alpha would punch a hole through an opaque draw.
112
+ function unlitFragment(map: boolean, alphaTest: boolean, transparent: boolean): string {
113
+ return glsl`
114
+ ${map ? "in vec2 vUv;" : ""}
115
+ ${map ? "uniform sampler2D uMap;" : ""}
116
+ uniform vec4 uColor;
117
+ ${alphaTest ? "uniform float uAlphaTest;" : ""}
118
+ void main() {
119
+ vec4 base = ${map ? "texture(uMap, vUv) * uColor" : "uColor"};
120
+ ${alphaTest ? "if (base.a < uAlphaTest) discard;" : ""}
121
+ fragColor = ${transparent ? "base" : "vec4(base.rgb, 1.0)"};
122
+ }
123
+ `
136
124
  }
137
125
 
138
- function pipelineFor(cls: UnlitClass, layout: VertexLayout | undefined): RenderPipelineId {
139
- let key = cls + "|" + layoutKey(layout)
140
- let existing = pipelines.get(key)
141
- if (existing !== undefined) return existing
142
- let program = programFor(cls)
143
- let transparent = cls.endsWith("-transparent")
144
- let pipeline = createRenderPipeline(program, {
145
- attributes: layoutAttributes(layout),
146
- depth: true,
147
- depthWrite: transparent ? false : undefined,
148
- blend: transparent ? "alpha" : undefined,
149
- cull: "back",
150
- label: "scene-unlit-" + cls,
151
- })
152
- pipelines.set(key, pipeline)
153
- return pipeline
154
- }
126
+ // One shaderMaterialClass per unlit option combination (map x transparent
127
+ // x cull x alphaTest), cached for the app's lifetime like lit's; one
128
+ // pipeline per vertex layout inside each.
129
+ let unlitClasses = new Map<string, ShaderMaterialClass>()
155
130
 
156
131
  export type UnlitOptions = {
157
132
  /** Straight [r, g, b] or [r, g, b, a], 0..1. Default white. */
@@ -161,6 +136,18 @@ export type UnlitOptions = {
161
136
  /** Blend over what is behind (color alpha and map alpha both count).
162
137
  * Without it an alpha below 1 still draws opaque. See Material.transparent. */
163
138
  transparent?: boolean
139
+ /** Which faces to drop; default "back". "none" draws both sides of
140
+ * single-layer geometry (foliage cards, glass, a mirrored part), and
141
+ * lit materials then light a back face with its normal flipped, as
142
+ * Three's DoubleSide and Godot's CULL_DISABLED do. */
143
+ cull?: CullMode
144
+ /** Cutout: drop a fragment whose final alpha (color x map, and for lit
145
+ * the vertex color too) is below this, 0..1 (Three's alphaTest, glTF
146
+ * alphaMode MASK with its alphaCutoff). Opaque otherwise:
147
+ * depth-written, not sorted, unlike `transparent`. Foliage cards and
148
+ * fences want it with `cull: "none"`; a mapped cutout casts its cutout
149
+ * (Material.shadow). */
150
+ alphaTest?: number
164
151
  }
165
152
 
166
153
  /**
@@ -172,15 +159,29 @@ export function unlit(opts: UnlitOptions = {}): Material {
172
159
  let color = opts.color ?? [1, 1, 1]
173
160
  let a = color.length === 4 ? color[3] : 1
174
161
  let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
162
+ let map = opts.map !== undefined
175
163
  let transparent = opts.transparent === true
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,
164
+ let cull = opts.cull ?? "back"
165
+ let alphaTest = opts.alphaTest !== undefined
166
+ let key = [map, transparent, cull, alphaTest].join("|")
167
+ let cls = unlitClasses.get(key)
168
+ if (cls === undefined) {
169
+ cls = shaderMaterialClass({
170
+ vertex: UNLIT_VERTEX,
171
+ fragment: unlitFragment(map, alphaTest, transparent),
172
+ transparent,
173
+ cull,
174
+ label: "scene-unlit-" + key,
175
+ })
176
+ unlitClasses.set(key, cls)
183
177
  }
178
+ let params: ShaderParams = { uColor }
179
+ if (alphaTest) params.uAlphaTest = opts.alphaTest!
180
+ return cls.instance({
181
+ params,
182
+ textures: map ? { uMap: opts.map! } : undefined,
183
+ shadow: alphaTest && map ? shadowCutoutMaterial(shadowCull(cull), uColor, opts.alphaTest!, opts.map!) : undefined,
184
+ })
184
185
  }
185
186
 
186
187
  export type LitOptions = UnlitOptions & {
@@ -214,13 +215,37 @@ export type LitOptions = UnlitOptions & {
214
215
 
215
216
  // The lit fragment is composed from the same exported pieces an app
216
217
  // 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 {
218
+ // transparent x cull (a class that shows back faces lights them with the
219
+ // normal flipped, else a double-sided leaf's back is black) x alphaTest
220
+ // (the cutoff itself is a per-entry uniform, one class for every value).
221
+ // An opaque class writes alpha 1 (see unlitFragment). Lights arrive
222
+ // through the scene's shared params (light nodes); the base color, map
223
+ // and highlight are per entry. The
224
+ // shadow set is shared too and indexed like the lights: one atlas sampler,
225
+ // directional light i's maps (one, or its cascades) as map slots
226
+ // uShadowFirst[i] .. + uShadowCount[i] with a tile rect and a matrix
227
+ // each, and its biases (target-level, bound by the scene); uShadowCount
228
+ // 0 means it does not cast; SHADOW_LOOKUP turns the index into the factor.
229
+ // The option combination that picks a lit class: the class-cache key is
230
+ // its values in this order, and the fragment builder reads the same
231
+ // object, so the two cannot drift apart.
232
+ type LitClass = {
233
+ map: boolean
234
+ vertexColors: boolean
235
+ triplanar: boolean
236
+ transparent: boolean
237
+ shadow: boolean
238
+ cull: CullMode
239
+ alphaTest: boolean
240
+ }
241
+
242
+ function litClassKey(c: LitClass): string {
243
+ return Object.values(c).join("|")
244
+ }
245
+
246
+ function litFragment(c: LitClass): string {
247
+ let { map, vertexColors, triplanar, shadow, alphaTest } = c
248
+ let backFaces = c.cull !== "back"
224
249
  return glsl`
225
250
  in vec3 vWorldPos;
226
251
  in vec3 vNormal;
@@ -231,6 +256,7 @@ function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, sh
231
256
  uniform float uSpecular;
232
257
  uniform float uShininess;
233
258
  ${triplanar ? "uniform float uTriplanar;" : ""}
259
+ ${alphaTest ? "uniform float uAlphaTest;" : ""}
234
260
  uniform vec3 uCamPos;
235
261
  uniform vec3 uHemiSky;
236
262
  uniform vec3 uHemiGround;
@@ -250,6 +276,7 @@ function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, sh
250
276
 
251
277
  void main() {
252
278
  vec3 n = normalize(vNormal);
279
+ ${backFaces ? "if (!gl_FrontFacing) n = -n;" : ""}
253
280
  vec4 base = uColor;
254
281
  ${
255
282
  map
@@ -262,6 +289,7 @@ function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, sh
262
289
  : ""
263
290
  }
264
291
  ${vertexColors ? "base *= vColor;" : ""}
292
+ ${alphaTest ? "if (base.a < uAlphaTest) discard;" : ""}
265
293
  vec3 v = normalize(uCamPos - vWorldPos);
266
294
  vec3 light = hemisphere(n, uHemiSky, uHemiGround);
267
295
  vec3 spec = vec3(0.0);
@@ -274,7 +302,7 @@ function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, sh
274
302
  light += uLightColor[i] * lambert(n, l) * s;
275
303
  spec += uLightColor[i] * blinnSpecular(n, v, l, uShininess) * s;
276
304
  }
277
- fragColor = vec4(base.rgb * light + spec * uSpecular * base.a, base.a);
305
+ fragColor = vec4(base.rgb * light + spec * uSpecular * base.a, ${c.transparent ? "base.a" : "1.0"});
278
306
  }
279
307
  `
280
308
  }
@@ -284,8 +312,8 @@ let litClasses = new Map<string, ShaderMaterialClass>()
284
312
  /**
285
313
  * A lit material: hemisphere ambient plus the scene's directional lights
286
314
  * (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
315
+ * Blinn-Phong highlight. Same options as unlit (color, map, transparent,
316
+ * cull, alphaTest) plus vertexColors, specular/shininess and triplanar mapping. One program
289
317
  * per option combination, one pipeline per vertex layout met, shared by
290
318
  * every instance - a thousand lit meshes still share one pipeline. No
291
319
  * lights set means black except for the hemisphere term, which also
@@ -296,26 +324,40 @@ export function lit(opts: LitOptions = {}): Material {
296
324
  let a = color.length === 4 ? color[3] : 1
297
325
  let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
298
326
  let map = opts.map !== undefined
299
- let vertexColors = opts.vertexColors === true
300
327
  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("|")
328
+ let alphaTest = opts.alphaTest !== undefined
329
+ let cull = opts.cull ?? "back"
330
+ let flags: LitClass = {
331
+ map,
332
+ vertexColors: opts.vertexColors === true,
333
+ triplanar,
334
+ transparent: opts.transparent === true,
335
+ shadow: opts.receiveShadow !== false,
336
+ cull,
337
+ alphaTest,
338
+ }
339
+ let key = litClassKey(flags)
304
340
  let cls = litClasses.get(key)
305
341
  if (cls === undefined) {
306
342
  cls = shaderMaterialClass({
307
- vertex: vertexColors ? LIT_VERTEX_COLORED : LIT_VERTEX,
308
- fragment: litFragment(map, vertexColors, triplanar, shadow),
309
- transparent,
343
+ vertex: flags.vertexColors ? LIT_VERTEX_COLORED : LIT_VERTEX,
344
+ fragment: litFragment(flags),
345
+ transparent: flags.transparent,
346
+ cull,
310
347
  label: "scene-lit-" + key,
311
348
  })
312
349
  litClasses.set(key, cls)
313
350
  }
351
+ let params: ShaderParams = { uColor, uSpecular: opts.specular ?? 0, uShininess: opts.shininess ?? 30 }
352
+ if (triplanar) params.uTriplanar = opts.triplanar!
353
+ if (alphaTest) params.uAlphaTest = opts.alphaTest!
354
+ // A UV-mapped cutout casts its cutout; a color-only or triplanar
355
+ // alphaTest keeps the plain (cull-only) variant.
356
+ let cutout = alphaTest && map && !triplanar
314
357
  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 },
358
+ params,
318
359
  textures: map ? { uMap: opts.map! } : undefined,
360
+ shadow: cutout ? shadowCutoutMaterial(shadowCull(cull), uColor, opts.alphaTest!, opts.map!) : undefined,
319
361
  })
320
362
  return material
321
363
  }
@@ -340,20 +382,79 @@ const SHADOW_DEPTH_FRAGMENT = glsl`
340
382
  }
341
383
  `
342
384
 
343
- let shadowDepth: Material | undefined
385
+ let shadowDepth = new Map<CullMode, Material>()
344
386
 
345
387
  /** 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({
388
+ * per cull mode for the app, built on first use. The default, "front",
389
+ * is the caster's back surface (see above); a material culling
390
+ * otherwise carries its own variant as Material.shadow. */
391
+ export function shadowDepthMaterial(cull: CullMode = "front"): Material {
392
+ let material = shadowDepth.get(cull)
393
+ if (material === undefined) {
394
+ material = shaderMaterialClass({
350
395
  vertex: SHADOW_DEPTH_VERTEX,
351
396
  fragment: SHADOW_DEPTH_FRAGMENT,
352
- cull: "front",
353
- label: "scene-shadow-depth",
397
+ cull,
398
+ label: "scene-shadow-depth-" + cull,
354
399
  }).instance()
400
+ shadowDepth.set(cull, material)
355
401
  }
356
- return shadowDepth
402
+ return material
403
+ }
404
+
405
+ /** The shadow pass's cull for a material's cull: the opposite side
406
+ * (Three's shadowSide default), none stays none. */
407
+ function shadowCull(cull: CullMode): CullMode {
408
+ return cull === "none" ? "none" : cull === "back" ? "front" : "back"
409
+ }
410
+
411
+ /** "back" maps to the default depth material, so its variant is
412
+ * undefined. */
413
+ function shadowVariant(cull: CullMode): Material | undefined {
414
+ return cull === "back" ? undefined : shadowDepthMaterial(shadowCull(cull))
415
+ }
416
+
417
+ // The cutout depth pass: the caster's map alpha (times its color alpha)
418
+ // against its alphaTest, the lit fragment's test minus everything else.
419
+ const SHADOW_CUTOUT_VERTEX = glsl`
420
+ in vec3 aPos;
421
+ in vec2 aUV;
422
+ out vec2 vUv;
423
+ uniform mat4 uModel;
424
+ uniform mat4 uViewProj;
425
+ void main() {
426
+ gl_Position = uViewProj * uModel * vec4(aPos, 1.0);
427
+ vUv = aUV;
428
+ }
429
+ `
430
+
431
+ const SHADOW_CUTOUT_FRAGMENT = glsl`
432
+ in vec2 vUv;
433
+ uniform sampler2D uMap;
434
+ uniform vec4 uColor;
435
+ uniform float uAlphaTest;
436
+ void main() {
437
+ if (texture(uMap, vUv).a * uColor.a < uAlphaTest) discard;
438
+ fragColor = vec4(1.0);
439
+ }
440
+ `
441
+
442
+ let shadowCutout = new Map<CullMode, ShaderMaterialClass>()
443
+
444
+ /** The shadow variant of a UV-mapped cutout material: one class per
445
+ * shadow cull mode, an instance per material (its map, color, cutoff). */
446
+ function shadowCutoutMaterial(cull: CullMode, uColor: number[], uAlphaTest: number, uMap: TextureId): Material {
447
+ let cls = shadowCutout.get(cull)
448
+ if (cls === undefined) {
449
+ cls = shaderMaterialClass({
450
+ vertex: SHADOW_CUTOUT_VERTEX,
451
+ fragment: SHADOW_CUTOUT_FRAGMENT,
452
+ cull,
453
+ label: "scene-shadow-cutout-" + cull,
454
+ })
455
+ shadowCutout.set(cull, cls)
456
+ }
457
+ return cls.instance({ params: { uColor, uAlphaTest }, textures: { uMap } })
357
458
  }
358
459
 
359
460
  export type SpriteOptions = UnlitOptions & {
@@ -435,7 +536,7 @@ export function sprite(opts: SpriteOptions = {}): Material {
435
536
  if (cls === undefined) {
436
537
  cls = shaderMaterialClass({
437
538
  vertex: fixedY ? SPRITE_FIXED_Y_VERTEX_SRC : SPRITE_VERTEX_SRC,
438
- fragment: map ? FRAGMENT_MAP_SRC : FRAGMENT_COLOR_SRC,
539
+ fragment: unlitFragment(map, false, transparent),
439
540
  transparent,
440
541
  cull: "none",
441
542
  label: "scene-sprite-" + key,
@@ -563,6 +664,10 @@ export type ShaderMaterialInstanceOptions = {
563
664
  * setMeshParams. */
564
665
  params?: ShaderParams
565
666
  textures?: TextureBindings
667
+ /** The depth variant a shadow view draws this instance with (see
668
+ * Material.shadow): a cutout's discard, an instanced class's vertex
669
+ * placement. Default: the depth pass with this class's cull side. */
670
+ shadow?: Material
566
671
  }
567
672
 
568
673
  export type ShaderMaterialOptions = ShaderMaterialClassOptions & ShaderMaterialInstanceOptions
@@ -609,6 +714,7 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
609
714
  let normalMatrix = /\buNormal\b/.test(opts.vertex) || /\buNormal\b/.test(opts.fragment)
610
715
  let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
611
716
  let depth = opts.depth ?? true
717
+ let cull = opts.cull ?? "back"
612
718
  // An empty list declares nothing - same as absent (the engine requires an
613
719
  // instance buffer exactly when attributes are declared).
614
720
  let instanceAttributes = opts.instanceAttributes?.length ? opts.instanceAttributes.map(a => ({ ...a })) : undefined
@@ -639,7 +745,7 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
639
745
  // only applies when there is one.
640
746
  depthWrite: opts.depthWrite ?? (transparent && depth ? false : undefined),
641
747
  blend: opts.blend ?? (transparent ? "alpha" : undefined),
642
- cull: opts.cull ?? "back",
748
+ cull,
643
749
  topology: opts.topology,
644
750
  label: opts.label,
645
751
  })
@@ -649,7 +755,20 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
649
755
  }
650
756
  return {
651
757
  instance(inst = {}) {
652
- return { normalMatrix, attributes, transparent, instanceAttributes, pipeline: pipelineFor, params: inst.params ?? {}, textures: inst.textures }
758
+ return {
759
+ normalMatrix,
760
+ attributes,
761
+ transparent,
762
+ instanceAttributes,
763
+ pipeline: pipelineFor,
764
+ params: inst.params ?? {},
765
+ textures: inst.textures,
766
+ // Lazy: the depth materials are shaderMaterialClass instances
767
+ // themselves, so an eager variant would recurse into its own cache.
768
+ get shadow() {
769
+ return inst.shadow ?? shadowVariant(cull)
770
+ },
771
+ }
653
772
  },
654
773
  dispose() {
655
774
  for (let pipeline of pipelines.values()) destroyRenderPipeline(pipeline)
package/src/math.ts CHANGED
@@ -648,3 +648,79 @@ export function rayBoxDistance(
648
648
  }
649
649
  return tFar >= tNear ? tNear : -1
650
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
+ }
package/src/model.ts CHANGED
@@ -58,7 +58,13 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
58
58
  label: label ? label + "-image" + i : undefined,
59
59
  })
60
60
  })
61
- let make = opts.material ?? ((m: ModelMaterial, map: TextureId | null): Material => lit({ color: m.color, map: map ?? undefined, transparent: m.transparent }))
61
+ let make = opts.material ?? ((m: ModelMaterial, map: TextureId | null): Material => lit({
62
+ color: m.color,
63
+ map: map ?? undefined,
64
+ transparent: m.transparent,
65
+ cull: m.doubleSided ? "none" : "back",
66
+ alphaTest: m.alphaMode === "MASK" ? m.alphaCutoff : undefined,
67
+ }))
62
68
  let materials = data.materials.map((m) => make(m, m.map === null ? null : textures[m.map]!))
63
69
 
64
70
  let model = createGroup() as Model