@solidrt/3d 0.0.53 → 0.0.55

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/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // PerspectiveCamera) on top. See AGENTS.md for the model and the traps.
7
7
 
8
8
  export { add, createDirectionalLight, createGroup, createHemisphereLight, createInstancedMesh, createMesh, createScene, createSprite, setLight, disposeInstances, getRotation, lookAt, remove, setCastShadow, setGeometry, setInstanceCount, setInstances, setMaterial, setMeshParams, setRenderOrder, setTransform, setTransition, setVisible, worldPosition, MAX_SHADOWS } from "./scene.ts"
9
- export type { CameraUpdate, DirectionalLight as DirectionalLightNode, DirectionalLightOptions, HemisphereLight as HemisphereLightNode, HemisphereLightOptions, Hit, Light, InstancedMesh as InstancedMeshNode, InstancedMeshOptions, Mesh as MeshNode, MeshInstances, OrthoExtent, Scene as SceneHandle, SceneHandlers, SceneNode, SceneOptions, ScenePointerEvent, ShadowCamera, ShadowOptions, TransformUpdate, TransitionEndEvent, View, ViewOptions } from "./scene.ts"
9
+ export type { CameraUpdate, DirectionalLight as DirectionalLightNode, DirectionalLightOptions, FogOptions, HemisphereLight as HemisphereLightNode, HemisphereLightOptions, Hit, Light, InstancedMesh as InstancedMeshNode, InstancedMeshOptions, Mesh as MeshNode, MeshInstances, OrthoExtent, Scene as SceneHandle, SceneHandlers, SceneNode, SceneOptions, ScenePointerEvent, ShadowCamera, ShadowOptions, TransformUpdate, TransitionEndEvent, View, ViewOptions } from "./scene.ts"
10
10
  export type { NodeTransition, NodeTransitionSpec } from "flux:spatial"
11
11
  export { disposeGeometry } from "./geometry-gpu.ts"
12
12
  export { box, circle, cone, cylinder, fillAttribute, fillColors, geometryBounds, layoutAttributes, layoutKey, layoutSlot, layoutStride, mergeGeometries, packGeometry, plane, ring, sphere, torus, torusKnot, transformGeometry, validateGeometry, withAttribute, withColors, STANDARD_FLOATS, VERTEX_LAYOUTS } from "./geometry.ts"
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,
@@ -43,7 +42,7 @@ import type {
43
42
  } from "@solidrt/core/gpu"
44
43
  import { layoutAttributes, layoutKey, layoutSlot } from "./geometry.ts"
45
44
  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"
45
+ import { BLINN_SPECULAR, FOG, HEMISPHERE, LAMBERT, LIT_VERTEX, LIT_VERTEX_COLORED, MAX_LIGHTS, SHADOW, SHADOW_LOOKUP, SHADOW_SLOTS } from "./glsl.ts"
47
46
 
48
47
  export type Material = {
49
48
  /** The pipeline this material draws with for geometry of `layout`
@@ -73,85 +72,73 @@ 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. vWorldPos is the fog distance
95
+ // input; a fragment that does not read it (fog: false) leaves the out
96
+ // unmatched, which links fine.
97
+ const UNLIT_VERTEX = glsl`
88
98
  in vec3 aPos;
89
99
  in vec2 aUV;
90
100
  out vec2 vUv;
101
+ out vec3 vWorldPos;
91
102
  uniform mat4 uModel;
92
103
  uniform mat4 uViewProj;
93
104
 
94
105
  void main() {
95
- gl_Position = uViewProj * uModel * vec4(aPos, 1.0);
106
+ vec4 world = uModel * vec4(aPos, 1.0);
107
+ vWorldPos = world.xyz;
108
+ gl_Position = uViewProj * world;
96
109
  vUv = aUV;
97
110
  }
98
111
  `
99
112
 
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
113
+ // The unlit fragment (sprites share it): the color, times the map when
114
+ // there is one, with the alphaTest discard when asked for, then the
115
+ // scene's fog (FOG from ./glsl, mixed at the alpha about to be written)
116
+ // unless the material opted out. An opaque
117
+ // class writes alpha 1: the scene target is composited premultiplied, so
118
+ // a leaked texel alpha would punch a hole through an opaque draw.
119
+ function unlitFragment(map: boolean, alphaTest: boolean, transparent: boolean, fog: boolean): string {
120
+ let alpha = transparent ? "base.a" : "1.0"
121
+ return glsl`
122
+ ${map ? "in vec2 vUv;" : ""}
123
+ ${fog ? "in vec3 vWorldPos;" : ""}
124
+ ${map ? "uniform sampler2D uMap;" : ""}
125
+ uniform vec4 uColor;
126
+ ${alphaTest ? "uniform float uAlphaTest;" : ""}
127
+ ${fog ? "uniform vec3 uCamPos;" : ""}
128
+ ${fog ? FOG : ""}
129
+ void main() {
130
+ vec4 base = ${map ? "texture(uMap, vUv) * uColor" : "uColor"};
131
+ ${alphaTest ? "if (base.a < uAlphaTest) discard;" : ""}
132
+ ${fog ? `base.rgb = fog(base.rgb, ${alpha}, vWorldPos, uCamPos);` : ""}
133
+ fragColor = vec4(base.rgb, ${alpha});
134
+ }
135
+ `
136
136
  }
137
137
 
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
- }
138
+ // One shaderMaterialClass per unlit option combination (map x transparent
139
+ // x cull x alphaTest), cached for the app's lifetime like lit's; one
140
+ // pipeline per vertex layout inside each.
141
+ let unlitClasses = new Map<string, ShaderMaterialClass>()
155
142
 
156
143
  export type UnlitOptions = {
157
144
  /** Straight [r, g, b] or [r, g, b, a], 0..1. Default white. */
@@ -161,6 +148,24 @@ export type UnlitOptions = {
161
148
  /** Blend over what is behind (color alpha and map alpha both count).
162
149
  * Without it an alpha below 1 still draws opaque. See Material.transparent. */
163
150
  transparent?: boolean
151
+ /** Which faces to drop; default "back". "none" draws both sides of
152
+ * single-layer geometry (foliage cards, glass, a mirrored part), and
153
+ * lit materials then light a back face with its normal flipped, as
154
+ * Three's DoubleSide and Godot's CULL_DISABLED do. */
155
+ cull?: CullMode
156
+ /** Cutout: drop a fragment whose final alpha (color x map, and for lit
157
+ * the vertex color too) is below this, 0..1 (Three's alphaTest, glTF
158
+ * alphaMode MASK with its alphaCutoff). Opaque otherwise:
159
+ * depth-written, not sorted, unlike `transparent`. Foliage cards and
160
+ * fences want it with `cull: "none"`; a mapped cutout casts its cutout
161
+ * (Material.shadow). */
162
+ alphaTest?: number
163
+ /** Take the scene's fog (default true, Three's `material.fog`): the
164
+ * fragment fades toward the fog color with its distance from the
165
+ * camera once `scene.setFog` is set. `false` drops the fog code from
166
+ * the program - a sky sphere or a far backdrop that must keep its
167
+ * color, an emissive marker. */
168
+ fog?: boolean
164
169
  }
165
170
 
166
171
  /**
@@ -172,15 +177,30 @@ export function unlit(opts: UnlitOptions = {}): Material {
172
177
  let color = opts.color ?? [1, 1, 1]
173
178
  let a = color.length === 4 ? color[3] : 1
174
179
  let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
180
+ let map = opts.map !== undefined
175
181
  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,
182
+ let cull = opts.cull ?? "back"
183
+ let alphaTest = opts.alphaTest !== undefined
184
+ let fog = opts.fog !== false
185
+ let key = [map, transparent, cull, alphaTest, fog].join("|")
186
+ let cls = unlitClasses.get(key)
187
+ if (cls === undefined) {
188
+ cls = shaderMaterialClass({
189
+ vertex: UNLIT_VERTEX,
190
+ fragment: unlitFragment(map, alphaTest, transparent, fog),
191
+ transparent,
192
+ cull,
193
+ label: "scene-unlit-" + key,
194
+ })
195
+ unlitClasses.set(key, cls)
183
196
  }
197
+ let params: ShaderParams = { uColor }
198
+ if (alphaTest) params.uAlphaTest = opts.alphaTest!
199
+ return cls.instance({
200
+ params,
201
+ textures: map ? { uMap: opts.map! } : undefined,
202
+ shadow: alphaTest && map ? shadowCutoutMaterial(shadowCull(cull), uColor, opts.alphaTest!, opts.map!) : undefined,
203
+ })
184
204
  }
185
205
 
186
206
  export type LitOptions = UnlitOptions & {
@@ -214,14 +234,40 @@ export type LitOptions = UnlitOptions & {
214
234
 
215
235
  // The lit fragment is composed from the same exported pieces an app
216
236
  // 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
237
+ // transparent x cull (a class that shows back faces lights them with the
238
+ // normal flipped, else a double-sided leaf's back is black) x alphaTest
239
+ // (the cutoff itself is a per-entry uniform, one class for every value)
240
+ // x fog (the scene's fog composed last, or left out of the program).
241
+ // An opaque class writes alpha 1 (see unlitFragment). Lights arrive
242
+ // through the scene's shared params (light nodes); the base color, map
243
+ // and highlight are per entry. The
219
244
  // shadow set is shared too and indexed like the lights: one atlas sampler,
220
245
  // directional light i's maps (one, or its cascades) as map slots
221
246
  // uShadowFirst[i] .. + uShadowCount[i] with a tile rect and a matrix
222
247
  // each, and its biases (target-level, bound by the scene); uShadowCount
223
248
  // 0 means it does not cast; SHADOW_LOOKUP turns the index into the factor.
224
- function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, shadow: boolean): string {
249
+ // The option combination that picks a lit class: the class-cache key is
250
+ // its values in this order, and the fragment builder reads the same
251
+ // object, so the two cannot drift apart.
252
+ type LitClass = {
253
+ map: boolean
254
+ vertexColors: boolean
255
+ triplanar: boolean
256
+ transparent: boolean
257
+ shadow: boolean
258
+ cull: CullMode
259
+ alphaTest: boolean
260
+ fog: boolean
261
+ }
262
+
263
+ function litClassKey(c: LitClass): string {
264
+ return Object.values(c).join("|")
265
+ }
266
+
267
+ function litFragment(c: LitClass): string {
268
+ let { map, vertexColors, triplanar, shadow, alphaTest, fog } = c
269
+ let backFaces = c.cull !== "back"
270
+ let alpha = c.transparent ? "base.a" : "1.0"
225
271
  return glsl`
226
272
  in vec3 vWorldPos;
227
273
  in vec3 vNormal;
@@ -232,6 +278,7 @@ function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, sh
232
278
  uniform float uSpecular;
233
279
  uniform float uShininess;
234
280
  ${triplanar ? "uniform float uTriplanar;" : ""}
281
+ ${alphaTest ? "uniform float uAlphaTest;" : ""}
235
282
  uniform vec3 uCamPos;
236
283
  uniform vec3 uHemiSky;
237
284
  uniform vec3 uHemiGround;
@@ -248,9 +295,11 @@ function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, sh
248
295
  ${HEMISPHERE}
249
296
  ${LAMBERT}
250
297
  ${BLINN_SPECULAR}
298
+ ${fog ? FOG : ""}
251
299
 
252
300
  void main() {
253
301
  vec3 n = normalize(vNormal);
302
+ ${backFaces ? "if (!gl_FrontFacing) n = -n;" : ""}
254
303
  vec4 base = uColor;
255
304
  ${
256
305
  map
@@ -263,6 +312,7 @@ function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, sh
263
312
  : ""
264
313
  }
265
314
  ${vertexColors ? "base *= vColor;" : ""}
315
+ ${alphaTest ? "if (base.a < uAlphaTest) discard;" : ""}
266
316
  vec3 v = normalize(uCamPos - vWorldPos);
267
317
  vec3 light = hemisphere(n, uHemiSky, uHemiGround);
268
318
  vec3 spec = vec3(0.0);
@@ -275,7 +325,9 @@ function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, sh
275
325
  light += uLightColor[i] * lambert(n, l) * s;
276
326
  spec += uLightColor[i] * blinnSpecular(n, v, l, uShininess) * s;
277
327
  }
278
- fragColor = vec4(base.rgb * light + spec * uSpecular * base.a, base.a);
328
+ vec3 rgb = base.rgb * light + spec * uSpecular * base.a;
329
+ ${fog ? `rgb = fog(rgb, ${alpha}, vWorldPos, uCamPos);` : ""}
330
+ fragColor = vec4(rgb, ${alpha});
279
331
  }
280
332
  `
281
333
  }
@@ -285,8 +337,8 @@ let litClasses = new Map<string, ShaderMaterialClass>()
285
337
  /**
286
338
  * A lit material: hemisphere ambient plus the scene's directional lights
287
339
  * (DirectionalLight nodes), Lambert diffuse, optional
288
- * Blinn-Phong highlight. Same options as unlit (color, map, transparent)
289
- * plus vertexColors, specular/shininess and triplanar mapping. One program
340
+ * Blinn-Phong highlight. Same options as unlit (color, map, transparent,
341
+ * cull, alphaTest) plus vertexColors, specular/shininess and triplanar mapping. One program
290
342
  * per option combination, one pipeline per vertex layout met, shared by
291
343
  * every instance - a thousand lit meshes still share one pipeline. No
292
344
  * lights set means black except for the hemisphere term, which also
@@ -297,26 +349,41 @@ export function lit(opts: LitOptions = {}): Material {
297
349
  let a = color.length === 4 ? color[3] : 1
298
350
  let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
299
351
  let map = opts.map !== undefined
300
- let vertexColors = opts.vertexColors === true
301
352
  let triplanar = map && opts.triplanar !== undefined
302
- let transparent = opts.transparent === true
303
- let shadow = opts.receiveShadow !== false
304
- let key = [map, vertexColors, triplanar, transparent, shadow].join("|")
353
+ let alphaTest = opts.alphaTest !== undefined
354
+ let cull = opts.cull ?? "back"
355
+ let flags: LitClass = {
356
+ map,
357
+ vertexColors: opts.vertexColors === true,
358
+ triplanar,
359
+ transparent: opts.transparent === true,
360
+ shadow: opts.receiveShadow !== false,
361
+ cull,
362
+ alphaTest,
363
+ fog: opts.fog !== false,
364
+ }
365
+ let key = litClassKey(flags)
305
366
  let cls = litClasses.get(key)
306
367
  if (cls === undefined) {
307
368
  cls = shaderMaterialClass({
308
- vertex: vertexColors ? LIT_VERTEX_COLORED : LIT_VERTEX,
309
- fragment: litFragment(map, vertexColors, triplanar, shadow),
310
- transparent,
369
+ vertex: flags.vertexColors ? LIT_VERTEX_COLORED : LIT_VERTEX,
370
+ fragment: litFragment(flags),
371
+ transparent: flags.transparent,
372
+ cull,
311
373
  label: "scene-lit-" + key,
312
374
  })
313
375
  litClasses.set(key, cls)
314
376
  }
377
+ let params: ShaderParams = { uColor, uSpecular: opts.specular ?? 0, uShininess: opts.shininess ?? 30 }
378
+ if (triplanar) params.uTriplanar = opts.triplanar!
379
+ if (alphaTest) params.uAlphaTest = opts.alphaTest!
380
+ // A UV-mapped cutout casts its cutout; a color-only or triplanar
381
+ // alphaTest keeps the plain (cull-only) variant.
382
+ let cutout = alphaTest && map && !triplanar
315
383
  let material = cls.instance({
316
- params: triplanar
317
- ? { uColor, uSpecular: opts.specular ?? 0, uShininess: opts.shininess ?? 30, uTriplanar: opts.triplanar! }
318
- : { uColor, uSpecular: opts.specular ?? 0, uShininess: opts.shininess ?? 30 },
384
+ params,
319
385
  textures: map ? { uMap: opts.map! } : undefined,
386
+ shadow: cutout ? shadowCutoutMaterial(shadowCull(cull), uColor, opts.alphaTest!, opts.map!) : undefined,
320
387
  })
321
388
  return material
322
389
  }
@@ -341,20 +408,79 @@ const SHADOW_DEPTH_FRAGMENT = glsl`
341
408
  }
342
409
  `
343
410
 
344
- let shadowDepth: Material | undefined
411
+ let shadowDepth = new Map<CullMode, Material>()
345
412
 
346
413
  /** The override material of a scene's shadow view (internal): one class
347
- * for the app, built on first use. */
348
- export function shadowDepthMaterial(): Material {
349
- if (shadowDepth === undefined) {
350
- shadowDepth = shaderMaterialClass({
414
+ * per cull mode for the app, built on first use. The default, "front",
415
+ * is the caster's back surface (see above); a material culling
416
+ * otherwise carries its own variant as Material.shadow. */
417
+ export function shadowDepthMaterial(cull: CullMode = "front"): Material {
418
+ let material = shadowDepth.get(cull)
419
+ if (material === undefined) {
420
+ material = shaderMaterialClass({
351
421
  vertex: SHADOW_DEPTH_VERTEX,
352
422
  fragment: SHADOW_DEPTH_FRAGMENT,
353
- cull: "front",
354
- label: "scene-shadow-depth",
423
+ cull,
424
+ label: "scene-shadow-depth-" + cull,
355
425
  }).instance()
426
+ shadowDepth.set(cull, material)
427
+ }
428
+ return material
429
+ }
430
+
431
+ /** The shadow pass's cull for a material's cull: the opposite side
432
+ * (Three's shadowSide default), none stays none. */
433
+ function shadowCull(cull: CullMode): CullMode {
434
+ return cull === "none" ? "none" : cull === "back" ? "front" : "back"
435
+ }
436
+
437
+ /** "back" maps to the default depth material, so its variant is
438
+ * undefined. */
439
+ function shadowVariant(cull: CullMode): Material | undefined {
440
+ return cull === "back" ? undefined : shadowDepthMaterial(shadowCull(cull))
441
+ }
442
+
443
+ // The cutout depth pass: the caster's map alpha (times its color alpha)
444
+ // against its alphaTest, the lit fragment's test minus everything else.
445
+ const SHADOW_CUTOUT_VERTEX = glsl`
446
+ in vec3 aPos;
447
+ in vec2 aUV;
448
+ out vec2 vUv;
449
+ uniform mat4 uModel;
450
+ uniform mat4 uViewProj;
451
+ void main() {
452
+ gl_Position = uViewProj * uModel * vec4(aPos, 1.0);
453
+ vUv = aUV;
454
+ }
455
+ `
456
+
457
+ const SHADOW_CUTOUT_FRAGMENT = glsl`
458
+ in vec2 vUv;
459
+ uniform sampler2D uMap;
460
+ uniform vec4 uColor;
461
+ uniform float uAlphaTest;
462
+ void main() {
463
+ if (texture(uMap, vUv).a * uColor.a < uAlphaTest) discard;
464
+ fragColor = vec4(1.0);
356
465
  }
357
- return shadowDepth
466
+ `
467
+
468
+ let shadowCutout = new Map<CullMode, ShaderMaterialClass>()
469
+
470
+ /** The shadow variant of a UV-mapped cutout material: one class per
471
+ * shadow cull mode, an instance per material (its map, color, cutoff). */
472
+ function shadowCutoutMaterial(cull: CullMode, uColor: number[], uAlphaTest: number, uMap: TextureId): Material {
473
+ let cls = shadowCutout.get(cull)
474
+ if (cls === undefined) {
475
+ cls = shaderMaterialClass({
476
+ vertex: SHADOW_CUTOUT_VERTEX,
477
+ fragment: SHADOW_CUTOUT_FRAGMENT,
478
+ cull,
479
+ label: "scene-shadow-cutout-" + cull,
480
+ })
481
+ shadowCutout.set(cull, cls)
482
+ }
483
+ return cls.instance({ params: { uColor, uAlphaTest }, textures: { uMap } })
358
484
  }
359
485
 
360
486
  export type SpriteOptions = UnlitOptions & {
@@ -377,6 +503,7 @@ const SPRITE_VERTEX_SRC = glsl`
377
503
  in vec3 aPos;
378
504
  in vec2 aUV;
379
505
  out vec2 vUv;
506
+ out vec3 vWorldPos;
380
507
  uniform mat4 uModel;
381
508
  uniform mat4 uViewProj;
382
509
  uniform vec3 uCamRight;
@@ -386,6 +513,7 @@ const SPRITE_VERTEX_SRC = glsl`
386
513
  vec3 center = uModel[3].xyz;
387
514
  vec2 size = vec2(length(uModel[0].xyz), length(uModel[1].xyz));
388
515
  vec3 world = center + uCamRight * (aPos.x * size.x) + uCamUp * (aPos.y * size.y);
516
+ vWorldPos = world;
389
517
  gl_Position = uViewProj * vec4(world, 1.0);
390
518
  vUv = aUV;
391
519
  }
@@ -395,6 +523,7 @@ const SPRITE_FIXED_Y_VERTEX_SRC = glsl`
395
523
  in vec3 aPos;
396
524
  in vec2 aUV;
397
525
  out vec2 vUv;
526
+ out vec3 vWorldPos;
398
527
  uniform mat4 uModel;
399
528
  uniform mat4 uViewProj;
400
529
  uniform vec3 uCamPos;
@@ -407,6 +536,7 @@ const SPRITE_FIXED_Y_VERTEX_SRC = glsl`
407
536
  float len = length(toCam);
408
537
  vec3 right = len > 1e-6 ? vec3(toCam.z, 0.0, -toCam.x) / len : vec3(1.0, 0.0, 0.0);
409
538
  vec3 world = center + right * (aPos.x * size.x) + vec3(0.0, aPos.y * size.y, 0.0);
539
+ vWorldPos = world;
410
540
  gl_Position = uViewProj * vec4(world, 1.0);
411
541
  vUv = aUV;
412
542
  }
@@ -431,12 +561,13 @@ export function sprite(opts: SpriteOptions = {}): Material {
431
561
  let map = opts.map !== undefined
432
562
  let transparent = opts.transparent !== false
433
563
  let fixedY = opts.billboard === "fixed-y"
434
- let key = [map, transparent, fixedY].join("|")
564
+ let fog = opts.fog !== false
565
+ let key = [map, transparent, fixedY, fog].join("|")
435
566
  let cls = spriteClasses.get(key)
436
567
  if (cls === undefined) {
437
568
  cls = shaderMaterialClass({
438
569
  vertex: fixedY ? SPRITE_FIXED_Y_VERTEX_SRC : SPRITE_VERTEX_SRC,
439
- fragment: map ? FRAGMENT_MAP_SRC : FRAGMENT_COLOR_SRC,
570
+ fragment: unlitFragment(map, false, transparent, fog),
440
571
  transparent,
441
572
  cull: "none",
442
573
  label: "scene-sprite-" + key,
@@ -564,6 +695,10 @@ export type ShaderMaterialInstanceOptions = {
564
695
  * setMeshParams. */
565
696
  params?: ShaderParams
566
697
  textures?: TextureBindings
698
+ /** The depth variant a shadow view draws this instance with (see
699
+ * Material.shadow): a cutout's discard, an instanced class's vertex
700
+ * placement. Default: the depth pass with this class's cull side. */
701
+ shadow?: Material
567
702
  }
568
703
 
569
704
  export type ShaderMaterialOptions = ShaderMaterialClassOptions & ShaderMaterialInstanceOptions
@@ -610,6 +745,7 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
610
745
  let normalMatrix = /\buNormal\b/.test(opts.vertex) || /\buNormal\b/.test(opts.fragment)
611
746
  let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
612
747
  let depth = opts.depth ?? true
748
+ let cull = opts.cull ?? "back"
613
749
  // An empty list declares nothing - same as absent (the engine requires an
614
750
  // instance buffer exactly when attributes are declared).
615
751
  let instanceAttributes = opts.instanceAttributes?.length ? opts.instanceAttributes.map(a => ({ ...a })) : undefined
@@ -640,7 +776,7 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
640
776
  // only applies when there is one.
641
777
  depthWrite: opts.depthWrite ?? (transparent && depth ? false : undefined),
642
778
  blend: opts.blend ?? (transparent ? "alpha" : undefined),
643
- cull: opts.cull ?? "back",
779
+ cull,
644
780
  topology: opts.topology,
645
781
  label: opts.label,
646
782
  })
@@ -650,7 +786,20 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
650
786
  }
651
787
  return {
652
788
  instance(inst = {}) {
653
- return { normalMatrix, attributes, transparent, instanceAttributes, pipeline: pipelineFor, params: inst.params ?? {}, textures: inst.textures }
789
+ return {
790
+ normalMatrix,
791
+ attributes,
792
+ transparent,
793
+ instanceAttributes,
794
+ pipeline: pipelineFor,
795
+ params: inst.params ?? {},
796
+ textures: inst.textures,
797
+ // Lazy: the depth materials are shaderMaterialClass instances
798
+ // themselves, so an eager variant would recurse into its own cache.
799
+ get shadow() {
800
+ return inst.shadow ?? shadowVariant(cull)
801
+ },
802
+ }
654
803
  },
655
804
  dispose() {
656
805
  for (let pipeline of pipelines.values()) destroyRenderPipeline(pipeline)
package/src/model.ts CHANGED
@@ -20,6 +20,12 @@ import type { Material } from "./material.ts"
20
20
  import { add, createGroup, createMesh, remove } from "./scene.ts"
21
21
  import type { Mesh, SceneNode } from "./scene.ts"
22
22
 
23
+ /** Anisotropic filtering level for a model's textures: the engines' usual
24
+ * default (Godot ships 2x, Unity's quality presets 2-8x) - enough to keep a
25
+ * tiled surface legible at a grazing angle, cheap on every GPU. Clamped to
26
+ * the device by the runtime. */
27
+ const MODEL_ANISOTROPY = 4
28
+
23
29
  export type ModelOptions = {
24
30
  /** The material for each glTF material (default: `lit` with its color,
25
31
  * map and transparency). `map` is the uploaded base color texture, or
@@ -44,8 +50,9 @@ export type Model = SceneNode & {
44
50
 
45
51
  /**
46
52
  * Build the scene object for parsed model data: upload its images (repeat
47
- * wrap, mipmapped), make a material per glTF material, a mesh per part,
48
- * all under one Group. Synchronous - the data is already in memory.
53
+ * wrap, mipmapped, MODEL_ANISOTROPY), make a material per glTF material, a
54
+ * mesh per part, all under one Group. Synchronous - the data is already in
55
+ * memory.
49
56
  */
50
57
  export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
51
58
  let label = opts.label
@@ -54,11 +61,18 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
54
61
  return createTexture(image.data, image.width, image.height, {
55
62
  wrap: "repeat",
56
63
  mipmap: true,
64
+ anisotropy: MODEL_ANISOTROPY,
57
65
  autoFree: false,
58
66
  label: label ? label + "-image" + i : undefined,
59
67
  })
60
68
  })
61
- let make = opts.material ?? ((m: ModelMaterial, map: TextureId | null): Material => lit({ color: m.color, map: map ?? undefined, transparent: m.transparent }))
69
+ let make = opts.material ?? ((m: ModelMaterial, map: TextureId | null): Material => lit({
70
+ color: m.color,
71
+ map: map ?? undefined,
72
+ transparent: m.transparent,
73
+ cull: m.doubleSided ? "none" : "back",
74
+ alphaTest: m.alphaMode === "MASK" ? m.alphaCutoff : undefined,
75
+ }))
62
76
  let materials = data.materials.map((m) => make(m, m.map === null ? null : textures[m.map]!))
63
77
 
64
78
  let model = createGroup() as Model