@solidrt/3d 0.0.55 → 0.0.57

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.
Files changed (72) hide show
  1. package/AGENTS.md +1206 -224
  2. package/README.md +16 -7
  3. package/examples/README.md +88 -9
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +1 -4
  6. package/examples/collision.tsx +263 -0
  7. package/examples/first-person.tsx +129 -0
  8. package/examples/fleet.tsx +200 -0
  9. package/examples/fog.tsx +2 -3
  10. package/examples/instanced.tsx +72 -32
  11. package/examples/lamps.tsx +92 -0
  12. package/examples/lit.tsx +3 -5
  13. package/examples/materials.tsx +150 -0
  14. package/examples/model-load.tsx +95 -0
  15. package/examples/model.glb +0 -0
  16. package/examples/model.tsx +35 -9
  17. package/examples/pick.tsx +145 -37
  18. package/examples/probe.tsx +107 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +68 -48
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +92 -0
  24. package/examples/skybox.tsx +134 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +138 -0
  27. package/examples/sweep-paths.tsx +2 -4
  28. package/package.json +3 -3
  29. package/src/camera.ts +148 -0
  30. package/src/clip.ts +96 -0
  31. package/src/collision.ts +20 -0
  32. package/src/color.ts +32 -0
  33. package/src/components/context.tsx +35 -0
  34. package/src/components/directional-light.tsx +48 -0
  35. package/src/components/first-person-camera.tsx +72 -0
  36. package/src/components/group.tsx +18 -0
  37. package/src/components/hemisphere-light.tsx +24 -0
  38. package/src/components/index.ts +46 -0
  39. package/src/components/instanced-mesh.tsx +105 -0
  40. package/src/components/mesh.tsx +92 -0
  41. package/src/components/node-props.ts +90 -0
  42. package/src/components/orbit-camera.tsx +75 -0
  43. package/src/components/perspective-camera.tsx +32 -0
  44. package/src/components/point-light.tsx +52 -0
  45. package/src/components/record-mesh.tsx +68 -0
  46. package/src/components/scene.tsx +281 -0
  47. package/src/components/spot-light.tsx +65 -0
  48. package/src/components/sprite.tsx +38 -0
  49. package/src/components/view3d.tsx +152 -0
  50. package/src/environment-bake.ts +492 -0
  51. package/src/environment.ts +325 -0
  52. package/src/first-person.ts +258 -0
  53. package/src/geometry.ts +6 -2
  54. package/src/glsl.ts +1343 -82
  55. package/src/gltf.ts +432 -112
  56. package/src/index.ts +61 -9
  57. package/src/input.ts +70 -0
  58. package/src/light.ts +383 -0
  59. package/src/material.ts +667 -232
  60. package/src/math.ts +110 -0
  61. package/src/mesh.ts +728 -0
  62. package/src/mixer.ts +476 -0
  63. package/src/model-file.ts +80 -15
  64. package/src/model.ts +204 -35
  65. package/src/node.ts +579 -0
  66. package/src/orbit.ts +292 -212
  67. package/src/scene-pointer.ts +294 -0
  68. package/src/scene-shadows.ts +471 -0
  69. package/src/scene.ts +1435 -1576
  70. package/tools/environment.ts +69 -0
  71. package/tools/model.ts +4 -3
  72. package/src/components.tsx +0 -420
package/src/material.ts CHANGED
@@ -33,6 +33,7 @@ import type {
33
33
  BlendMode,
34
34
  CullMode,
35
35
  ProgramId,
36
+ InstanceAttribute,
36
37
  RenderPipelineId,
37
38
  ShaderParams,
38
39
  TextureBindings,
@@ -42,7 +43,24 @@ import type {
42
43
  } from "@solidrt/core/gpu"
43
44
  import { layoutAttributes, layoutKey, layoutSlot } from "./geometry.ts"
44
45
  import type { VertexLayout } from "./geometry.ts"
45
- import { BLINN_SPECULAR, FOG, HEMISPHERE, LAMBERT, LIT_VERTEX, LIT_VERTEX_COLORED, MAX_LIGHTS, SHADOW, SHADOW_LOOKUP, SHADOW_SLOTS } from "./glsl.ts"
46
+ import { linearColor } from "./color.ts"
47
+ import {
48
+ INSTANCE_COLOR_ATTRIBUTES,
49
+ INSTANCE_MATRIX,
50
+ INSTANCE_MATRIX_ATTRIBUTES,
51
+ litFragment,
52
+ litShadowFragment,
53
+ litVertex,
54
+ SKIN_DECLS,
55
+ SKIN_MATRIX,
56
+ standardFragment,
57
+ SURFACE_DEFAULTS,
58
+ UNLIT_VERTEX,
59
+ unlitFragment,
60
+ unlitShadowFragment,
61
+ unlitVertex,
62
+ OUTPUT,
63
+ } from "./glsl.ts"
46
64
 
47
65
  export type Material = {
48
66
  /** The pipeline this material draws with for geometry of `layout`
@@ -68,82 +86,62 @@ export type Material = {
68
86
  * back-to-front by mesh origin, and re-sorts them when the camera moves. */
69
87
  transparent?: boolean
70
88
  /** Per-instance attributes, when the material's pipeline declares them
71
- * (shaderMaterialClass's `instanceAttributes`). Such a material draws
72
- * instanced meshes only - createInstancedMesh supplies the record buffer,
73
- * and createMesh meshes are rejected at add(). */
74
- instanceAttributes?: VertexAttribute[]
89
+ * (shaderMaterialClass's `instanceAttributes`; the stock materials'
90
+ * `instanced`). Such a material draws populated meshes only - an
91
+ * instanced mesh binds slot 0 to the core-written matrix records and
92
+ * slot 1 to its app-written style records, a record mesh binds slot 0
93
+ * to its records - and createMesh meshes are rejected at add(). */
94
+ instanceAttributes?: InstanceAttribute[]
95
+ /** The style record (the slot-1 attributes' floats, in order) every
96
+ * fresh instance of an instanced mesh starts with; absent = zeros.
97
+ * White for the stock materials' `instanceColors`. */
98
+ instanceStyle?: ArrayLike<number>
75
99
  /** What a shadow view draws this material's meshes with instead of its
76
100
  * default depth override: the depth pass culling the side this material
77
101
  * culls (Three's shadowSide rule, Godot's shadow pass), so a `cull:
78
- * "none"` caster casts from both faces, and for a cutout (lit
102
+ * "none"` caster casts from both faces, for a cutout (lit
79
103
  * 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. */
104
+ * not rectangles, for a skinned material the same skinning, so a
105
+ * posed caster casts its pose, and for an instanced class with
106
+ * `shadowVertex` the same instance placement, so a fleet casts where
107
+ * its records stand. Absent = the default (a back-culling material
108
+ * casts from its back faces); an instanced material without one is
109
+ * SKIPPED by shadow views. A shaderMaterial supplies its own through
110
+ * the instance option of the same name. */
83
111
  shadow?: Material
112
+ /** True when the vertex stage declares `uBones` (read from the source,
113
+ * like normalMatrix): the program skins by the mesh's palette texture,
114
+ * so a shadow or override entry for a skinned mesh merges the mesh's
115
+ * own uBones binding even though this material is not the mesh's. */
116
+ skinned?: boolean
84
117
  /** Present on materials that own their pipeline (shaderMaterial). */
85
118
  dispose?(): void
86
119
  }
87
120
 
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`
98
- in vec3 aPos;
99
- in vec2 aUV;
100
- out vec2 vUv;
101
- out vec3 vWorldPos;
102
- uniform mat4 uModel;
103
- uniform mat4 uViewProj;
104
-
105
- void main() {
106
- vec4 world = uModel * vec4(aPos, 1.0);
107
- vWorldPos = world.xyz;
108
- gl_Position = uViewProj * world;
109
- vUv = aUV;
110
- }
111
- `
112
-
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
- }
137
-
138
121
  // One shaderMaterialClass per unlit option combination (map x transparent
139
122
  // x cull x alphaTest), cached for the app's lifetime like lit's; one
140
123
  // pipeline per vertex layout inside each.
141
124
  let unlitClasses = new Map<string, ShaderMaterialClass>()
142
125
 
126
+ // The uColor a color option compiles to: sRGB decoded to linear light,
127
+ // then premultiplied by its alpha (the target's contract).
128
+ function colorParam(color: readonly number[]): number[] {
129
+ let a = color.length === 4 ? color[3]! : 1
130
+ let c = linearColor(color)
131
+ return [c[0]! * a, c[1]! * a, c[2]! * a, a]
132
+ }
133
+
143
134
  export type UnlitOptions = {
144
- /** Straight [r, g, b] or [r, g, b, a], 0..1. Default white. */
135
+ /** Straight [r, g, b] or [r, g, b, a], 0..1, sRGB - a color picker's
136
+ * values. The library decodes it to linear light for shading and the
137
+ * output stage encodes the result back (see the color contract in
138
+ * AGENTS.md); an unlit surface with no tone mapping shows the value
139
+ * as given. Default white. */
145
140
  color?: [number, number, number] | [number, number, number, number]
146
- /** A texture id to sample (tinted by `color` when both are given). */
141
+ /** A texture id to sample (tinted by `color` when both are given).
142
+ * Create a color image with `format: "rgba8-srgb"` so it decodes to
143
+ * linear light like `color`; a plain rgba8 map reads as linear data and
144
+ * shows washed out. */
147
145
  map?: TextureId
148
146
  /** Blend over what is behind (color alpha and map alpha both count).
149
147
  * Without it an alpha below 1 still draws opaque. See Material.transparent. */
@@ -166,6 +164,58 @@ export type UnlitOptions = {
166
164
  * the program - a sky sphere or a far backdrop that must keep its
167
165
  * color, an emissive marker. */
168
166
  fog?: boolean
167
+ /** Offset and repeat for the uv every map of this material samples:
168
+ * `uv * repeat + offset` (defaults `[1, 1]` / `[0, 0]`). ONE transform
169
+ * per MATERIAL (Godot's uv1_offset/uv1_scale, Unity's Tiling/Offset) -
170
+ * deliberately not Three's per-texture transform, since a TextureId is
171
+ * a shared value whose sampling is creation-time state. A cutout's
172
+ * shadow transforms the same way; lit's lightMap (aUV2) is exempt.
173
+ * Scrolling surfaces (water, a conveyor) drive it per frame with
174
+ * `setMeshParams(mesh, { uMapTransform: [ru, rv, ou, ov] })`. Needs a
175
+ * map; not with triplanar (whose repeat is uTriplanar). */
176
+ mapTransform?: { offset?: [number, number]; repeat?: [number, number] }
177
+ /** Skin positions (and lit normals) by the "skinned" layout's aJoints/
178
+ * aWeights against the `uBones` palette texture - the rigged-model
179
+ * variant createModel picks for skinned parts. The material then
180
+ * requires "skinned" geometry, and something must bind `uBones`
181
+ * (createModel binds each skin's palette texture to its meshes and
182
+ * the spatial flush writes it from the joints). The shadow variants
183
+ * (depth and cutout) skin the same way, so a caster casts its pose. */
184
+ skinned?: boolean
185
+ /** Draw instanced meshes (createInstancedMesh / `<InstancedMesh>`):
186
+ * the vertex stage places each copy by its instance matrix under the
187
+ * mesh's uModel, and the material declares INSTANCE_MATRIX_ATTRIBUTES,
188
+ * so it draws populated meshes ONLY (a createMesh mesh is rejected at
189
+ * add()). The shadow variants place the same way, so a fleet casts.
190
+ * Unity's per-material "Enable GPU Instancing"; Three's built-ins do
191
+ * it implicitly on an InstancedMesh. */
192
+ instanced?: boolean
193
+ /** Multiply the base by a per-instance color (implies `instanced`):
194
+ * the instance's style record is one premultiplied linear [r, g, b, a]
195
+ * (INSTANCE_COLOR_ATTRIBUTES), starting white, written with
196
+ * setInstanceStyle or `<Instance style>`. Three's setColorAt, Godot's
197
+ * MultiMesh instance color. A cutout's shadow multiplies by the same
198
+ * color, so alpha below `alphaTest` cuts the shadow with the pixels. */
199
+ instanceColors?: boolean
200
+ }
201
+
202
+ // The instance attributes a stock instanced material declares: the
203
+ // matrix always, the color record beside it under instanceColors.
204
+ function stockInstanceAttributes(instanced: boolean, instanceColors: boolean): InstanceAttribute[] | undefined {
205
+ if (!instanced) return undefined
206
+ return instanceColors ? [...INSTANCE_MATRIX_ATTRIBUTES, ...INSTANCE_COLOR_ATTRIBUTES] : INSTANCE_MATRIX_ATTRIBUTES
207
+ }
208
+ // A fresh instance's color: white, the multiplicative identity, so an
209
+ // instance never written shows the material's own color (Godot's default;
210
+ // Three's zero-filled instanceColor is the trap avoided here).
211
+ const INSTANCE_COLOR_DEFAULT = [1, 1, 1, 1]
212
+
213
+ /** The uMapTransform vec4 for a mapTransform option: [repeatU, repeatV,
214
+ * offsetU, offsetV]. */
215
+ function mapTransformParam(t: { offset?: [number, number]; repeat?: [number, number] }): number[] {
216
+ let repeat = t.repeat ?? [1, 1]
217
+ let offset = t.offset ?? [0, 0]
218
+ return [repeat[0], repeat[1], offset[0], offset[1]]
169
219
  }
170
220
 
171
221
  /**
@@ -174,20 +224,26 @@ export type UnlitOptions = {
174
224
  * light list); see the scene-graph research note.
175
225
  */
176
226
  export function unlit(opts: UnlitOptions = {}): Material {
177
- let color = opts.color ?? [1, 1, 1]
178
- let a = color.length === 4 ? color[3] : 1
179
- let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
227
+ let uColor = colorParam(opts.color ?? [1, 1, 1])
180
228
  let map = opts.map !== undefined
181
229
  let transparent = opts.transparent === true
182
230
  let cull = opts.cull ?? "back"
183
231
  let alphaTest = opts.alphaTest !== undefined
184
232
  let fog = opts.fog !== false
185
- let key = [map, transparent, cull, alphaTest, fog].join("|")
233
+ let mapTransform = opts.mapTransform !== undefined
234
+ let skinned = opts.skinned === true
235
+ let instanceColors = opts.instanceColors === true
236
+ let instanced = opts.instanced === true || instanceColors
237
+ if (mapTransform && !map) throw new Error("unlit: mapTransform without a map to transform")
238
+ let key = [map, transparent, cull, alphaTest, fog, mapTransform, skinned, instanced, instanceColors].join("|")
186
239
  let cls = unlitClasses.get(key)
187
240
  if (cls === undefined) {
188
241
  cls = shaderMaterialClass({
189
- vertex: UNLIT_VERTEX,
190
- fragment: unlitFragment(map, alphaTest, transparent, fog),
242
+ vertex: unlitVertex({ skinned, instanced, instanceColors }),
243
+ fragment: unlitFragment({ map, alphaTest, transparent, fog, mapTransform, instanceColors }),
244
+ shadowVertex: instanced ? shadowDepthVertex(skinned, true) : undefined,
245
+ instanceAttributes: stockInstanceAttributes(instanced, instanceColors),
246
+ instanceStyle: instanceColors ? INSTANCE_COLOR_DEFAULT : undefined,
191
247
  transparent,
192
248
  cull,
193
249
  label: "scene-unlit-" + key,
@@ -196,10 +252,14 @@ export function unlit(opts: UnlitOptions = {}): Material {
196
252
  }
197
253
  let params: ShaderParams = { uColor }
198
254
  if (alphaTest) params.uAlphaTest = opts.alphaTest!
255
+ if (mapTransform) params.uMapTransform = mapTransformParam(opts.mapTransform!)
199
256
  return cls.instance({
200
257
  params,
201
258
  textures: map ? { uMap: opts.map! } : undefined,
202
- shadow: alphaTest && map ? shadowCutoutMaterial(shadowCull(cull), uColor, opts.alphaTest!, opts.map!) : undefined,
259
+ shadow:
260
+ alphaTest && map
261
+ ? unlitShadowMaterial(shadowCull(cull), skinned, instanced, instanceColors, uColor, opts.alphaTest!, opts.map!, mapTransform ? params.uMapTransform as number[] : undefined)
262
+ : undefined,
203
263
  })
204
264
  }
205
265
 
@@ -217,6 +277,58 @@ export type LitOptions = UnlitOptions & {
217
277
  * each part's size or UVs; the map must be created with
218
278
  * `wrap: "repeat"`. */
219
279
  triplanar?: number
280
+ /** A tangent-space normal map (OpenGL-style +Y, as glTF mandates),
281
+ * sampled at the same uv as `map` and bending the lit normal - track
282
+ * relief, kart panel lines, without triangles. The tangent frame is
283
+ * built per fragment from screen-space derivatives (NORMAL_MAP in
284
+ * `@solidrt/3d/glsl` - Three's untangented path), so ANY UV-mapped
285
+ * geometry works with no tangent channel; the trade is mild seams on
286
+ * mirrored UVs. Not with `triplanar` (which samples by world position).
287
+ * Wants `mipmap: true` at creation like any map seen at distance. */
288
+ normalMap?: TextureId
289
+ /** How strongly the normal map bends the surface, default 1 (0 flattens
290
+ * it). One float, as in Unity (_BumpScale) and Godot (normal_scale) -
291
+ * not Three's Vector2, whose second component exists to flip
292
+ * DirectX-style green channels. */
293
+ normalScale?: number
294
+ /** Light the surface emits, [r, g, b] 0..1, sRGB like `color` - added
295
+ * after the lighting terms, unaffected by lights and shadows, fogged
296
+ * like everything else. Defaults to WHITE when `emissiveMap` is given
297
+ * (the map is the emission - fixing Three's black-default gotcha, where
298
+ * an emissiveMap alone shows nothing) and to off otherwise. */
299
+ emissive?: [number, number, number]
300
+ /** Scales `emissive` in linear light, default 1: values above 1 for a
301
+ * glow that tone mapping can bloom (Three's emissiveIntensity, Godot's
302
+ * emission_energy_multiplier, glTF's KHR_materials_emissive_strength). */
303
+ emissiveIntensity?: number
304
+ /** A texture multiplying `emissive` per fragment - lamps, screens,
305
+ * nitro glow baked into one map. Sampled at the same uv as `map`;
306
+ * create it with `format: "rgba8-srgb"` like `map`. */
307
+ emissiveMap?: TextureId
308
+ /** A texture whose RED channel scales `specular` per fragment (Three's
309
+ * specularMap) - chrome and rubber on one mesh. With it, `specular`
310
+ * defaults to 1 (the map is the strength). */
311
+ specularMap?: TextureId
312
+ /** Mirror the scene's environment (scene.setEnvironment), 0..1: the
313
+ * face-on reflection weight, rising to 1 at grazing angles (Schlick) -
314
+ * 1 is chrome, ~0.05 a glossy dielectric with rim reflections. The
315
+ * reflection blurs with `shininess` (a wide sheen reflects a blurred
316
+ * sky, a mirror dot a sharp one; the environment cube needs mipmaps
317
+ * for the blur) and `specularMap`'s red scales it like `specular`.
318
+ * Three's Phong `reflectivity` with MixOperation and a fresnel weight
319
+ * (Three's default MultiplyOperation tints instead; not offered). Off
320
+ * while the scene has no environment. Undefined declares no
321
+ * environment sampler at all. */
322
+ reflectivity?: number
323
+ /** A baked-light texture (an offline render, an AO+GI bake), sampled by
324
+ * the geometry's aUV2 channel and ADDED to the light sum like the
325
+ * hemisphere term - a fully baked scene runs with no lights at all
326
+ * (Three's lightMap; Unity and Godot bake at scene level, but here the
327
+ * material picks the program). The geometry must carry aUV2
328
+ * (withAttribute) or add() throws. */
329
+ lightMap?: TextureId
330
+ /** Scales the lightMap, default 1. */
331
+ lightMapIntensity?: number
220
332
  /**
221
333
  * Receive the scene's directional shadows (default true, like Godot and
222
334
  * Three): each casting light's term is multiplied by its shadow-map
@@ -232,106 +344,54 @@ export type LitOptions = UnlitOptions & {
232
344
  receiveShadow?: boolean
233
345
  }
234
346
 
235
- // The lit fragment is composed from the same exported pieces an app
236
- // composes by hand, per flag: map x vertexColors x triplanar x shadow x
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
244
- // shadow set is shared too and indexed like the lights: one atlas sampler,
347
+ // The lit program is built by litFragment in ./glsl - the same builder an
348
+ // app calls to get a lit material with its own GLSL in it, composed from
349
+ // the same exported constants. What varies per flag: map x vertexColors x
350
+ // triplanar x receiveShadow x transparent x cull (a class that shows back
351
+ // faces lights them with the normal flipped, else a double-sided leaf's
352
+ // back is black) x alphaTest (the cutoff itself is a per-entry uniform,
353
+ // one class for every value) x fog (the scene's fog composed last, or
354
+ // left out of the program) x the surface maps (normalMap, emissive /
355
+ // emissiveMap, specularMap, lightMap) x mapTransform. The key's
356
+ // dimensionality costs nothing by itself: classes are created lazily per
357
+ // combination USED, so the program count is the app's distinct material
358
+ // configurations, bounded by its material count, never by this tuple's
359
+ // width. An opaque class writes alpha 1 (see
360
+ // unlitFragment). Lights arrive through the scene's shared params (light
361
+ // nodes); the base color, map and highlight are per entry. The shadow set
362
+ // is shared too and indexed like the lights: one atlas sampler,
245
363
  // directional light i's maps (one, or its cascades) as map slots
246
364
  // uShadowFirst[i] .. + uShadowCount[i] with a tile rect and a matrix
247
365
  // each, and its biases (target-level, bound by the scene); uShadowCount
248
366
  // 0 means it does not cast; SHADOW_LOOKUP turns the index into the factor.
249
367
  // 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.
368
+ // its values in this order, and the same object builds the program, so
369
+ // the two cannot drift apart. `lit` fills no slot - an app that does
370
+ // reaches for litFragment directly, and owns the class it builds.
252
371
  type LitClass = {
253
372
  map: boolean
254
373
  vertexColors: boolean
255
374
  triplanar: boolean
256
375
  transparent: boolean
257
- shadow: boolean
376
+ receiveShadow: boolean
258
377
  cull: CullMode
259
378
  alphaTest: boolean
260
379
  fog: boolean
380
+ normalMap: boolean
381
+ emissive: boolean
382
+ emissiveMap: boolean
383
+ specularMap: boolean
384
+ env: boolean
385
+ lightMap: boolean
386
+ mapTransform: boolean
387
+ skinned: boolean
388
+ instanced: boolean
389
+ instanceColors: boolean
261
390
  }
262
391
 
263
392
  function litClassKey(c: LitClass): string {
264
393
  return Object.values(c).join("|")
265
394
  }
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"
271
- return glsl`
272
- in vec3 vWorldPos;
273
- in vec3 vNormal;
274
- in vec2 vUv;
275
- ${vertexColors ? "in vec4 vColor;" : ""}
276
- uniform vec4 uColor;
277
- ${map ? "uniform sampler2D uMap;" : ""}
278
- uniform float uSpecular;
279
- uniform float uShininess;
280
- ${triplanar ? "uniform float uTriplanar;" : ""}
281
- ${alphaTest ? "uniform float uAlphaTest;" : ""}
282
- uniform vec3 uCamPos;
283
- uniform vec3 uHemiSky;
284
- uniform vec3 uHemiGround;
285
- uniform int uLightCount;
286
- uniform vec3 uLightDir[${MAX_LIGHTS}];
287
- uniform vec3 uLightColor[${MAX_LIGHTS}];
288
- ${
289
- shadow
290
- ? `${SHADOW_SLOTS}
291
- ${SHADOW}
292
- ${SHADOW_LOOKUP}`
293
- : ""
294
- }
295
- ${HEMISPHERE}
296
- ${LAMBERT}
297
- ${BLINN_SPECULAR}
298
- ${fog ? FOG : ""}
299
-
300
- void main() {
301
- vec3 n = normalize(vNormal);
302
- ${backFaces ? "if (!gl_FrontFacing) n = -n;" : ""}
303
- vec4 base = uColor;
304
- ${
305
- map
306
- ? triplanar
307
- ? `vec3 w = pow(abs(n), vec3(4.0));
308
- w /= w.x + w.y + w.z;
309
- vec3 p = vWorldPos * uTriplanar;
310
- base *= texture(uMap, p.yz) * w.x + texture(uMap, p.xz) * w.y + texture(uMap, p.xy) * w.z;`
311
- : "base *= texture(uMap, vUv);"
312
- : ""
313
- }
314
- ${vertexColors ? "base *= vColor;" : ""}
315
- ${alphaTest ? "if (base.a < uAlphaTest) discard;" : ""}
316
- vec3 v = normalize(uCamPos - vWorldPos);
317
- vec3 light = hemisphere(n, uHemiSky, uHemiGround);
318
- vec3 spec = vec3(0.0);
319
- for (int i = 0; i < ${MAX_LIGHTS}; i++) {
320
- if (i >= uLightCount) break;
321
- vec3 l = uLightDir[i];
322
- ${
323
- shadow ? "float s = lightShadow(i, vWorldPos, n);" : "float s = 1.0;"
324
- }
325
- light += uLightColor[i] * lambert(n, l) * s;
326
- spec += uLightColor[i] * blinnSpecular(n, v, l, uShininess) * s;
327
- }
328
- vec3 rgb = base.rgb * light + spec * uSpecular * base.a;
329
- ${fog ? `rgb = fog(rgb, ${alpha}, vWorldPos, uCamPos);` : ""}
330
- fragColor = vec4(rgb, ${alpha});
331
- }
332
- `
333
- }
334
-
335
395
  let litClasses = new Map<string, ShaderMaterialClass>()
336
396
 
337
397
  /**
@@ -345,62 +405,328 @@ let litClasses = new Map<string, ShaderMaterialClass>()
345
405
  * starts at zero: set at least one of the two.
346
406
  */
347
407
  export function lit(opts: LitOptions = {}): Material {
348
- let color = opts.color ?? [1, 1, 1]
349
- let a = color.length === 4 ? color[3] : 1
350
- let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
408
+ let uColor = colorParam(opts.color ?? [1, 1, 1])
351
409
  let map = opts.map !== undefined
352
410
  let triplanar = map && opts.triplanar !== undefined
353
411
  let alphaTest = opts.alphaTest !== undefined
354
412
  let cull = opts.cull ?? "back"
413
+ let normalMap = opts.normalMap !== undefined
414
+ let emissiveMap = opts.emissiveMap !== undefined
415
+ let emissive = opts.emissive !== undefined || emissiveMap
416
+ let specularMap = opts.specularMap !== undefined
417
+ let env = opts.reflectivity !== undefined
418
+ let lightMap = opts.lightMap !== undefined
419
+ let mapTransform = opts.mapTransform !== undefined
420
+ if (env && !(Number.isFinite(opts.reflectivity) && opts.reflectivity! >= 0 && opts.reflectivity! <= 1)) {
421
+ throw new Error("lit: reflectivity must be a number in 0..1, got " + opts.reflectivity)
422
+ }
423
+ if (triplanar && normalMap) throw new Error("lit: normalMap cannot combine with triplanar (normal maps sample by uv)")
424
+ if (triplanar && mapTransform) throw new Error("lit: mapTransform cannot combine with triplanar (its repeat is the triplanar value)")
425
+ if (mapTransform && !map && !normalMap && !emissiveMap && !specularMap) {
426
+ throw new Error("lit: mapTransform without a map to transform")
427
+ }
355
428
  let flags: LitClass = {
356
429
  map,
357
430
  vertexColors: opts.vertexColors === true,
358
431
  triplanar,
359
432
  transparent: opts.transparent === true,
360
- shadow: opts.receiveShadow !== false,
433
+ receiveShadow: opts.receiveShadow !== false,
361
434
  cull,
362
435
  alphaTest,
363
436
  fog: opts.fog !== false,
437
+ normalMap,
438
+ emissive,
439
+ emissiveMap,
440
+ specularMap,
441
+ env,
442
+ lightMap,
443
+ mapTransform,
444
+ skinned: opts.skinned === true,
445
+ instanced: opts.instanced === true || opts.instanceColors === true,
446
+ instanceColors: opts.instanceColors === true,
364
447
  }
365
448
  let key = litClassKey(flags)
366
449
  let cls = litClasses.get(key)
367
450
  if (cls === undefined) {
368
451
  cls = shaderMaterialClass({
369
- vertex: flags.vertexColors ? LIT_VERTEX_COLORED : LIT_VERTEX,
452
+ vertex: litVertex(flags),
370
453
  fragment: litFragment(flags),
454
+ shadowVertex: flags.instanced ? shadowDepthVertex(flags.skinned, true) : undefined,
455
+ instanceAttributes: stockInstanceAttributes(flags.instanced, flags.instanceColors),
456
+ instanceStyle: flags.instanceColors ? INSTANCE_COLOR_DEFAULT : undefined,
371
457
  transparent: flags.transparent,
372
458
  cull,
373
459
  label: "scene-lit-" + key,
374
460
  })
375
461
  litClasses.set(key, cls)
376
462
  }
377
- let params: ShaderParams = { uColor, uSpecular: opts.specular ?? 0, uShininess: opts.shininess ?? 30 }
463
+ let params: ShaderParams = {
464
+ uColor,
465
+ uSpecular: opts.specular ?? (specularMap ? 1 : SURFACE_DEFAULTS.specular),
466
+ uShininess: opts.shininess ?? SURFACE_DEFAULTS.shininess,
467
+ }
378
468
  if (triplanar) params.uTriplanar = opts.triplanar!
379
469
  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
470
+ if (normalMap) params.uNormalScale = opts.normalScale ?? 1
471
+ if (env) params.uReflectivity = opts.reflectivity!
472
+ if (emissive) {
473
+ let k = opts.emissiveIntensity ?? 1
474
+ params.uEmissive = linearColor(opts.emissive ?? [1, 1, 1]).map(c => c * k)
475
+ }
476
+ if (lightMap) params.uLightMapIntensity = opts.lightMapIntensity ?? 1
477
+ if (mapTransform) params.uMapTransform = mapTransformParam(opts.mapTransform!)
478
+ let textures: TextureBindings | undefined
479
+ if (map || normalMap || emissiveMap || specularMap || lightMap) {
480
+ textures = {}
481
+ if (map) textures.uMap = opts.map!
482
+ if (normalMap) textures.uNormalMap = opts.normalMap!
483
+ if (emissiveMap) textures.uEmissiveMap = opts.emissiveMap!
484
+ if (specularMap) textures.uSpecularMap = opts.specularMap!
485
+ if (lightMap) textures.uLightMap = opts.lightMap!
486
+ }
487
+ // A mapped cutout casts its cutout, triplanar included (the shadow
488
+ // source resolves the base exactly as the main one does). A color-only
489
+ // alphaTest is a constant over the mesh - all or nothing, and nothing
490
+ // needs no program - so it keeps the plain cull-only variant.
383
491
  let material = cls.instance({
384
492
  params,
385
- textures: map ? { uMap: opts.map! } : undefined,
386
- shadow: cutout ? shadowCutoutMaterial(shadowCull(cull), uColor, opts.alphaTest!, opts.map!) : undefined,
493
+ textures,
494
+ shadow:
495
+ alphaTest && map
496
+ ? litShadowMaterial(flags, uColor, opts.alphaTest!, opts.triplanar, opts.map!, mapTransform ? params.uMapTransform as number[] : undefined)
497
+ : undefined,
387
498
  })
388
499
  return material
389
500
  }
390
501
 
502
+ export type StandardOptions = Omit<LitOptions, "specular" | "shininess" | "specularMap" | "reflectivity"> & {
503
+ /** How metallic the surface is, 0..1 (default 0; 1 when `metalnessMap`
504
+ * is given - the map is the value): a metal has no diffuse and
505
+ * reflects tinted by `color`, a dielectric reflects 4% face-on.
506
+ * Three's metalness; metallic in Godot, Unity and glTF. */
507
+ metalness?: number
508
+ /** Perceptual roughness, 0..1 (default 1): 0 a mirror, 1 fully matte;
509
+ * it widens the highlight and blurs the environment reflection alike.
510
+ * Unity's smoothness is 1 - roughness. */
511
+ roughness?: number
512
+ /** A texture whose BLUE channel scales `metalness` per fragment, and
513
+ * `roughnessMap` one whose GREEN channel scales `roughness`: Three's
514
+ * two options over glTF's ONE packed metallicRoughnessTexture, so
515
+ * pass the same texture to both (createModel's `maps` does). Data
516
+ * maps, created as plain rgba8; sampled at the same uv as `map`. */
517
+ metalnessMap?: TextureId
518
+ roughnessMap?: TextureId
519
+ }
520
+
521
+ // The `standard` class key: lit's minus the Blinn-Phong slots (env is
522
+ // always on, the environment being intrinsic to the model) plus the two
523
+ // packed maps. Its cutout shadow is lit's own, keyed on the shared subset
524
+ // in lit's order, so the two materials share shadow programs.
525
+ type StandardClass = Omit<LitClass, "specularMap" | "env"> & { metalnessMap: boolean; roughnessMap: boolean }
526
+
527
+ function standardClassKey(c: StandardClass): string {
528
+ return Object.values(c).join("|")
529
+ }
530
+ let standardClasses = new Map<string, ShaderMaterialClass>()
531
+
532
+ function standardShadowFlags(c: StandardClass): LitClass {
533
+ return {
534
+ map: c.map,
535
+ vertexColors: c.vertexColors,
536
+ triplanar: c.triplanar,
537
+ transparent: c.transparent,
538
+ receiveShadow: c.receiveShadow,
539
+ cull: c.cull,
540
+ alphaTest: c.alphaTest,
541
+ fog: c.fog,
542
+ normalMap: c.normalMap,
543
+ emissive: c.emissive,
544
+ emissiveMap: c.emissiveMap,
545
+ specularMap: false,
546
+ env: false,
547
+ lightMap: c.lightMap,
548
+ mapTransform: c.mapTransform,
549
+ skinned: c.skinned,
550
+ instanced: c.instanced,
551
+ instanceColors: c.instanceColors,
552
+ }
553
+ }
554
+
555
+ /**
556
+ * The metalness/roughness material, the look authored assets expect
557
+ * (Three's MeshStandardMaterial, Godot's StandardMaterial3D, Unity's
558
+ * Standard): the same base, maps, cutout, shadows, emissive, fog and
559
+ * lights as `lit`, shaded with GGX specular (PBR in `@solidrt/3d/glsl`)
560
+ * and the scene's environment sampled ALWAYS as the split sum over its
561
+ * mip chain - no `reflectivity` switch, the environment is intrinsic to
562
+ * the model. Light intensities read as lit's (1 lights a white matte
563
+ * surface to 1). Without an environment set a metal shows only its
564
+ * highlights - its diffuse is zero and there is nothing to reflect, as
565
+ * in Three and Godot - so give the scene one. One program per option
566
+ * combination, shared by every instance, like lit.
567
+ */
568
+ export function standard(opts: StandardOptions = {}): Material {
569
+ let uColor = colorParam(opts.color ?? [1, 1, 1])
570
+ let map = opts.map !== undefined
571
+ let triplanar = map && opts.triplanar !== undefined
572
+ let alphaTest = opts.alphaTest !== undefined
573
+ let cull = opts.cull ?? "back"
574
+ let normalMap = opts.normalMap !== undefined
575
+ let emissiveMap = opts.emissiveMap !== undefined
576
+ let emissive = opts.emissive !== undefined || emissiveMap
577
+ let metalnessMap = opts.metalnessMap !== undefined
578
+ let roughnessMap = opts.roughnessMap !== undefined
579
+ let lightMap = opts.lightMap !== undefined
580
+ let mapTransform = opts.mapTransform !== undefined
581
+ let metalness = opts.metalness ?? (metalnessMap ? 1 : SURFACE_DEFAULTS.metalness)
582
+ let roughness = opts.roughness ?? SURFACE_DEFAULTS.roughness
583
+ if (!(Number.isFinite(metalness) && metalness >= 0 && metalness <= 1)) {
584
+ throw new Error("standard: metalness must be a number in 0..1, got " + opts.metalness)
585
+ }
586
+ if (!(Number.isFinite(roughness) && roughness >= 0 && roughness <= 1)) {
587
+ throw new Error("standard: roughness must be a number in 0..1, got " + opts.roughness)
588
+ }
589
+ if (triplanar && normalMap) throw new Error("standard: normalMap cannot combine with triplanar (normal maps sample by uv)")
590
+ if (triplanar && (metalnessMap || roughnessMap)) {
591
+ throw new Error("standard: metalnessMap and roughnessMap cannot combine with triplanar (they sample by uv)")
592
+ }
593
+ if (triplanar && mapTransform) throw new Error("standard: mapTransform cannot combine with triplanar (its repeat is the triplanar value)")
594
+ if (mapTransform && !map && !normalMap && !emissiveMap && !metalnessMap && !roughnessMap) {
595
+ throw new Error("standard: mapTransform without a map to transform")
596
+ }
597
+ let flags: StandardClass = {
598
+ map,
599
+ vertexColors: opts.vertexColors === true,
600
+ triplanar,
601
+ transparent: opts.transparent === true,
602
+ receiveShadow: opts.receiveShadow !== false,
603
+ cull,
604
+ alphaTest,
605
+ fog: opts.fog !== false,
606
+ normalMap,
607
+ emissive,
608
+ emissiveMap,
609
+ lightMap,
610
+ mapTransform,
611
+ skinned: opts.skinned === true,
612
+ instanced: opts.instanced === true || opts.instanceColors === true,
613
+ instanceColors: opts.instanceColors === true,
614
+ metalnessMap,
615
+ roughnessMap,
616
+ }
617
+ let key = standardClassKey(flags)
618
+ let cls = standardClasses.get(key)
619
+ if (cls === undefined) {
620
+ cls = shaderMaterialClass({
621
+ vertex: litVertex(flags),
622
+ fragment: standardFragment(flags),
623
+ shadowVertex: flags.instanced ? shadowDepthVertex(flags.skinned, true) : undefined,
624
+ instanceAttributes: stockInstanceAttributes(flags.instanced, flags.instanceColors),
625
+ instanceStyle: flags.instanceColors ? INSTANCE_COLOR_DEFAULT : undefined,
626
+ transparent: flags.transparent,
627
+ cull,
628
+ label: "scene-standard-" + key,
629
+ })
630
+ standardClasses.set(key, cls)
631
+ }
632
+ let params: ShaderParams = { uColor, uMetalness: metalness, uRoughness: roughness }
633
+ if (triplanar) params.uTriplanar = opts.triplanar!
634
+ if (alphaTest) params.uAlphaTest = opts.alphaTest!
635
+ if (normalMap) params.uNormalScale = opts.normalScale ?? 1
636
+ if (emissive) {
637
+ let k = opts.emissiveIntensity ?? 1
638
+ params.uEmissive = linearColor(opts.emissive ?? [1, 1, 1]).map(c => c * k)
639
+ }
640
+ if (lightMap) params.uLightMapIntensity = opts.lightMapIntensity ?? 1
641
+ if (mapTransform) params.uMapTransform = mapTransformParam(opts.mapTransform!)
642
+ let textures: TextureBindings | undefined
643
+ if (map || normalMap || emissiveMap || metalnessMap || roughnessMap || lightMap) {
644
+ textures = {}
645
+ if (map) textures.uMap = opts.map!
646
+ if (normalMap) textures.uNormalMap = opts.normalMap!
647
+ if (emissiveMap) textures.uEmissiveMap = opts.emissiveMap!
648
+ if (metalnessMap) textures.uMetalnessMap = opts.metalnessMap!
649
+ if (roughnessMap) textures.uRoughnessMap = opts.roughnessMap!
650
+ if (lightMap) textures.uLightMap = opts.lightMap!
651
+ }
652
+ return cls.instance({
653
+ params,
654
+ textures,
655
+ shadow:
656
+ alphaTest && map
657
+ ? litShadowMaterial(standardShadowFlags(flags), uColor, opts.alphaTest!, opts.triplanar, opts.map!, mapTransform ? params.uMapTransform as number[] : undefined)
658
+ : undefined,
659
+ })
660
+ }
661
+
662
+ let litShadowClasses = new Map<string, ShaderMaterialClass>()
663
+
664
+ /** The shadow variant of a discarding lit material: litShadowFragment on
665
+ * the same vertex stage, culling the side the material's own pipeline
666
+ * keeps (Three's shadowSide rule), instanced with the same values so the
667
+ * cutout it casts is the one it draws. Its params carry only the uniforms
668
+ * its program declares - per-entry params reject unknown names. One class
669
+ * per lit class, an instance per material. */
670
+ function litShadowMaterial(
671
+ flags: LitClass,
672
+ uColor: number[],
673
+ uAlphaTest: number,
674
+ triplanar: number | undefined,
675
+ uMap: TextureId,
676
+ uMapTransform?: number[],
677
+ ): Material {
678
+ // A skinned cutout keeps its skinning (flags.skinned rides into the
679
+ // key and the vertex stage), so the shadow it casts is posed: the
680
+ // shadow entry merges the mesh's uBones binding (Material.skinned).
681
+ let key = litClassKey(flags)
682
+ let cls = litShadowClasses.get(key)
683
+ if (cls === undefined) {
684
+ let fragment = litShadowFragment(flags)
685
+ if (fragment === undefined) throw new Error("litShadowMaterial: " + key + " cannot discard")
686
+ cls = shaderMaterialClass({
687
+ vertex: litVertex(flags),
688
+ fragment,
689
+ cull: shadowCull(flags.cull),
690
+ instanceAttributes: stockInstanceAttributes(flags.instanced, flags.instanceColors),
691
+ label: "scene-lit-shadow-" + key,
692
+ })
693
+ litShadowClasses.set(key, cls)
694
+ }
695
+ let params: ShaderParams = { uColor, uAlphaTest }
696
+ if (flags.triplanar) params.uTriplanar = triplanar!
697
+ if (uMapTransform !== undefined) params.uMapTransform = uMapTransform
698
+ let material = cls.instance({ params, textures: { uMap } })
699
+ // Only triplanar sampling reads the normal here: everywhere else the
700
+ // linker drops vNormal and uNormal reflects inactive, so writing it per
701
+ // move would warn every time. The value cannot matter, skip the write.
702
+ if (!flags.triplanar) material.normalMatrix = false
703
+ return material
704
+ }
705
+
391
706
  // The shadow depth pass: position only, no color of interest (the target's
392
707
  // depth texture is the output; the color write is the pipeline's minimum).
393
708
  // Front faces culled, Three's shadowSide default: the map holds each
394
709
  // caster's BACK surface, so a receiving front face at the same depth
395
710
  // compares lit without a bias and acne needs no fighting on closed meshes.
396
- const SHADOW_DEPTH_VERTEX = glsl`
711
+ // The skinned form applies the skin matrix first, so a posed caster's
712
+ // depth is its pose (the entry's uBones binding comes from the mesh, see
713
+ // Material.skinned); the instanced form places each copy by its instance
714
+ // matrix, so a fleet casts where its records stand - the stock instanced
715
+ // materials pass it as their class's shadowVertex.
716
+ function shadowDepthVertex(skinned: boolean, instanced: boolean): string {
717
+ let local = skinned ? "(skin * vec4(aPos, 1.0))" : "vec4(aPos, 1.0)"
718
+ return glsl`
397
719
  in vec3 aPos;
720
+ ${skinned ? SKIN_DECLS : ""}
721
+ ${instanced ? INSTANCE_MATRIX : ""}
398
722
  uniform mat4 uModel;
399
723
  uniform mat4 uViewProj;
400
724
  void main() {
401
- gl_Position = uViewProj * uModel * vec4(aPos, 1.0);
725
+ ${skinned ? SKIN_MATRIX : ""}
726
+ gl_Position = uViewProj * uModel * ${instanced ? `(instanceMatrix() * ${local})` : local};
402
727
  }
403
728
  `
729
+ }
404
730
 
405
731
  const SHADOW_DEPTH_FRAGMENT = glsl`
406
732
  void main() {
@@ -408,22 +734,23 @@ const SHADOW_DEPTH_FRAGMENT = glsl`
408
734
  }
409
735
  `
410
736
 
411
- let shadowDepth = new Map<CullMode, Material>()
737
+ let shadowDepth = new Map<string, Material>()
412
738
 
413
739
  /** The override material of a scene's shadow view (internal): one class
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)
740
+ * per cull mode x skinned for the app, built on first use. The default,
741
+ * "front", is the caster's back surface (see above); a material culling
742
+ * or skinning otherwise carries its own variant as Material.shadow. */
743
+ export function shadowDepthMaterial(cull: CullMode = "front", skinned = false): Material {
744
+ let key = cull + (skinned ? "|skinned" : "")
745
+ let material = shadowDepth.get(key)
419
746
  if (material === undefined) {
420
747
  material = shaderMaterialClass({
421
- vertex: SHADOW_DEPTH_VERTEX,
748
+ vertex: shadowDepthVertex(skinned, false),
422
749
  fragment: SHADOW_DEPTH_FRAGMENT,
423
750
  cull,
424
- label: "scene-shadow-depth-" + cull,
751
+ label: "scene-shadow-depth-" + key,
425
752
  }).instance()
426
- shadowDepth.set(cull, material)
753
+ shadowDepth.set(key, material)
427
754
  }
428
755
  return material
429
756
  }
@@ -434,56 +761,56 @@ function shadowCull(cull: CullMode): CullMode {
434
761
  return cull === "none" ? "none" : cull === "back" ? "front" : "back"
435
762
  }
436
763
 
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))
764
+ /** "back" unskinned maps to the default depth material, so its variant
765
+ * is undefined; a skinned class always needs its own (the default does
766
+ * not skin). */
767
+ function shadowVariant(cull: CullMode, skinned: boolean): Material | undefined {
768
+ if (cull === "back" && !skinned) return undefined
769
+ return shadowDepthMaterial(shadowCull(cull), skinned)
441
770
  }
442
771
 
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);
465
- }
466
- `
772
+ let unlitShadowClasses = new Map<string, ShaderMaterialClass>()
467
773
 
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)
774
+ /** The shadow variant of a discarding unlit material: unlitShadowFragment
775
+ * on the same vertex stage (skinned when the material skins, so the
776
+ * cutout casts its pose), litShadowMaterial's unlit twin. unlit()'s
777
+ * only discard is the mapped cutout, so what varies is the shadow cull,
778
+ * skinning and whether the cutout's uv is transformed - one class per
779
+ * combination. An instance per material (its map, color, cutoff,
780
+ * transform). */
781
+ function unlitShadowMaterial(
782
+ cull: CullMode,
783
+ skinned: boolean,
784
+ instanced: boolean,
785
+ instanceColors: boolean,
786
+ uColor: number[],
787
+ uAlphaTest: number,
788
+ uMap: TextureId,
789
+ uMapTransform?: number[],
790
+ ): Material {
791
+ let key = [cull, uMapTransform !== undefined, skinned, instanced, instanceColors].join("|")
792
+ let cls = unlitShadowClasses.get(key)
474
793
  if (cls === undefined) {
794
+ let fragment = unlitShadowFragment({ map: true, alphaTest: true, mapTransform: uMapTransform !== undefined, instanceColors })
795
+ if (fragment === undefined) throw new Error("unlitShadowMaterial: the cutout options cannot discard")
475
796
  cls = shaderMaterialClass({
476
- vertex: SHADOW_CUTOUT_VERTEX,
477
- fragment: SHADOW_CUTOUT_FRAGMENT,
797
+ vertex: unlitVertex({ skinned, instanced, instanceColors }),
798
+ fragment,
478
799
  cull,
479
- label: "scene-shadow-cutout-" + cull,
800
+ instanceAttributes: stockInstanceAttributes(instanced, instanceColors),
801
+ label: "scene-unlit-shadow-" + key,
480
802
  })
481
- shadowCutout.set(cull, cls)
803
+ unlitShadowClasses.set(key, cls)
482
804
  }
483
- return cls.instance({ params: { uColor, uAlphaTest }, textures: { uMap } })
805
+ let params: ShaderParams = { uColor, uAlphaTest }
806
+ if (uMapTransform !== undefined) params.uMapTransform = uMapTransform
807
+ return cls.instance({ params, textures: { uMap } })
484
808
  }
485
809
 
486
- export type SpriteOptions = UnlitOptions & {
810
+ /** A sprite's options: unlit's, minus instancing (the billboard stage
811
+ * places one quad per mesh; an instanced billboard field is a custom
812
+ * class's vertex stage). */
813
+ export type SpriteOptions = Omit<UnlitOptions, "instanced" | "instanceColors"> & {
487
814
  /** Which way the quad turns to face the camera. `"full"` (default,
488
815
  * Three's Sprite): both axes follow the view, the quad is always flat
489
816
  * to the screen. `"fixed-y"` (Godot's BILLBOARD_FIXED_Y): only the yaw
@@ -555,9 +882,12 @@ let spriteClasses = new Map<string, ShaderMaterialClass>()
555
882
  * for an opaque one. Culling is off: a camera-facing quad has no back.
556
883
  */
557
884
  export function sprite(opts: SpriteOptions = {}): Material {
558
- let color = opts.color ?? [1, 1, 1]
559
- let a = color.length === 4 ? color[3] : 1
560
- let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
885
+ // Excluded by the type and checked for a JS caller: the billboard stage
886
+ // has no instance placement, so the flags would otherwise be ignored.
887
+ if ((opts as UnlitOptions).instanced || (opts as UnlitOptions).instanceColors) {
888
+ throw new Error("sprite: instanced and instanceColors are not sprite options (one billboard per mesh)")
889
+ }
890
+ let uColor = colorParam(opts.color ?? [1, 1, 1])
561
891
  let map = opts.map !== undefined
562
892
  let transparent = opts.transparent !== false
563
893
  let fixedY = opts.billboard === "fixed-y"
@@ -567,7 +897,7 @@ export function sprite(opts: SpriteOptions = {}): Material {
567
897
  if (cls === undefined) {
568
898
  cls = shaderMaterialClass({
569
899
  vertex: fixedY ? SPRITE_FIXED_Y_VERTEX_SRC : SPRITE_VERTEX_SRC,
570
- fragment: unlitFragment(map, false, transparent, fog),
900
+ fragment: unlitFragment({ map, transparent, fog }),
571
901
  transparent,
572
902
  cull: "none",
573
903
  label: "scene-sprite-" + key,
@@ -598,21 +928,51 @@ function needsHeader(source: string): boolean {
598
928
  // engine's own attributeless fullscreen triangle (gl_VertexID, no vertex
599
929
  // buffer), emitting the SAME vUV the shader-target contract provides: 0..1
600
930
  // with origin at the displayed top-left - so a backdrop fragment written
601
- // for createShaderTexture ports verbatim.
931
+ // for createShaderTexture ports verbatim - plus vRay, the world-space
932
+ // view ray through the pixel: the vertex's own clip position carried back
933
+ // to world at the near and far planes through the camera's shared
934
+ // uInvViewProj, far minus near. Linear interpolation is exact for it
935
+ // (at a fixed clip depth the unprojected point is affine in clip x, y
936
+ // under either projection), and under an orthographic camera every ray is
937
+ // the camera's forward. Unnormalized: a sky fragment normalizes it.
602
938
  const BACKGROUND_VERTEX = glsl`
939
+ uniform mat4 uInvViewProj;
603
940
  out vec2 vUV;
941
+ out vec3 vRay;
604
942
  void main() {
605
943
  vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
606
944
  vUV = p;
607
- gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
945
+ vec2 ndc = p * 2.0 - 1.0;
946
+ vec4 near = uInvViewProj * vec4(ndc, -1.0, 1.0);
947
+ vec4 far = uInvViewProj * vec4(ndc, 1.0, 1.0);
948
+ vRay = far.xyz / far.w - near.xyz / near.w;
949
+ gl_Position = vec4(ndc, 0.0, 1.0);
608
950
  }
609
951
  `
610
952
 
611
953
  // Pipeline fragments get no vUV from the engine preamble (a pipeline's
612
954
  // varyings are its own), so the background slot injects the full
613
- // shader-target fragment contract itself: vUV, fragColor, iResolution.
955
+ // shader-target fragment contract itself: vUV, fragColor, iResolution,
956
+ // its own vRay, and the OUTPUT set, so a sky can end with outputColor
957
+ // and take the scene's exposure and tone mapping.
614
958
  const BACKGROUND_FRAGMENT_PREAMBLE =
615
- "#version 300 es\nprecision highp float;\nin vec2 vUV;\nout vec4 fragColor;\nuniform vec2 iResolution;\n"
959
+ "#version 300 es\nprecision highp float;\nin vec2 vUV;\nin vec3 vRay;\nout vec4 fragColor;\nuniform vec2 iResolution;\n" + OUTPUT + "\n"
960
+
961
+ // The skybox fragment behind setBackground({ cube }): the view ray through
962
+ // the sky's rotation (the INVERSE turn, written by skyboxParams, so the
963
+ // sky itself turns by +rotation like a node would), then the cube lookup
964
+ // times the intensity, through the output
965
+ // stage (the preamble declares it) like every lit pixel. Opaque: the
966
+ // skybox replaces the clearColor exactly as a GLSL background does.
967
+ export const SKYBOX_FRAGMENT = glsl`
968
+ uniform samplerCube uSky;
969
+ uniform float uSkyIntensity;
970
+ uniform mat4 uSkyRotation;
971
+ void main() {
972
+ vec3 dir = mat3(uSkyRotation) * normalize(vRay);
973
+ fragColor = outputColor(texture(uSky, dir).rgb * uSkyIntensity, 1.0);
974
+ }
975
+ `
616
976
 
617
977
  /** The scene's background pipeline (internal - reached via
618
978
  * scene.setBackground): depth-free, attributeless, drawn as entry zero of
@@ -664,12 +1024,40 @@ export type ShaderMaterialClassOptions = {
664
1024
  * beside the layout's own, and each drawn instance gets one record from
665
1025
  * the mesh's instance buffer (interleaved floats in this order). A class
666
1026
  * with instance attributes makes INSTANCED materials: attach their meshes
667
- * with createInstancedMesh, which carries the records - a createMesh mesh
668
- * is rejected at add(). A per-instance transform is data, not a matrix:
669
- * a position/yaw/scale record beats four vec4 columns for most fleets,
670
- * and the composed uModel still places the whole population.
1027
+ * with createInstancedMesh or createRecordMesh, which carry the records
1028
+ * - a createMesh mesh is rejected at add(). Each attribute's `slot`
1029
+ * (default 0) picks the buffer: an instanced mesh binds the core's
1030
+ * matrix records (INSTANCE_MATRIX_ATTRIBUTES, exactly) to slot 0 and
1031
+ * the app's style records - any layout, written per instance with
1032
+ * setInstanceStyle - to slot 1; a record mesh is slot 0 only, its
1033
+ * records whatever the stage reads (a position/yaw/scale record beats
1034
+ * four vec4 columns for a JS-stepped fleet, and the composed uModel
1035
+ * still places the whole population).
1036
+ */
1037
+ instanceAttributes?: InstanceAttribute[]
1038
+ /** The style record a fresh instance starts with (the slot-1
1039
+ * attributes' floats, in order); default zeros. The identity of
1040
+ * whatever the stage does with the record - white for a multiplied
1041
+ * tint. */
1042
+ instanceStyle?: ArrayLike<number>
1043
+ /**
1044
+ * The class's vertex stage reduced to POSITION - the instance
1045
+ * placement, a displacement, whatever moves vertices, minus every
1046
+ * color/normal/uv output - for the shadow pass. With it the class's
1047
+ * instances cast: a shadow view draws their meshes with a depth
1048
+ * pipeline built from this stage and the shared depth fragment
1049
+ * (culling the side the class culls, binding the instance slots this
1050
+ * stage reads - the records always, a style slot only when it names
1051
+ * one of its attributes), so
1052
+ * an instanced fleet's shadows land where its records put the
1053
+ * vertices. Same standard-uniform contract as `vertex` (uModel and
1054
+ * uViewProj required); one extra program per class, built at first
1055
+ * cast. Without it, instanced meshes are SKIPPED by shadow views
1056
+ * (`castShadow` does nothing for them) - a non-instanced class needs
1057
+ * it only when its vertex stage moves vertices. Depth-only: a cutout's
1058
+ * discard still goes through the `shadow` instance option.
671
1059
  */
672
- instanceAttributes?: VertexAttribute[]
1060
+ shadowVertex?: string
673
1061
  /** Blend over what is behind, with the scene sorting this material's
674
1062
  * meshes back-to-front after the opaque ones (see Material.transparent).
675
1063
  * Sets the pipeline defaults blend "alpha" and depthWrite false; the
@@ -696,8 +1084,11 @@ export type ShaderMaterialInstanceOptions = {
696
1084
  params?: ShaderParams
697
1085
  textures?: TextureBindings
698
1086
  /** 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. */
1087
+ * Material.shadow): a cutout's discard, a placement the class-level
1088
+ * `shadowVertex` cannot express. Default: the class's shadowVertex
1089
+ * depth material when one is declared, else the depth pass with this
1090
+ * class's cull side, skinned like the class when its vertex skins by
1091
+ * uBones. */
701
1092
  shadow?: Material
702
1093
  }
703
1094
 
@@ -732,10 +1123,12 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
732
1123
  // shared params skipping undeclared names the omission would otherwise
733
1124
  // surface as a silently untransformed render, not an error.
734
1125
  for (let name of ["uModel", "uViewProj"]) {
735
- if (!new RegExp("\\b" + name + "\\b").test(opts.vertex)) {
736
- throw new Error(
737
- "shaderMaterial vertex stage must declare and use '" + name + "' (see the standard uniform set in AGENTS.md)",
738
- )
1126
+ for (let source of opts.shadowVertex === undefined ? [opts.vertex] : [opts.vertex, opts.shadowVertex]) {
1127
+ if (!new RegExp("\\b" + name + "\\b").test(source)) {
1128
+ throw new Error(
1129
+ "shaderMaterial vertex stage must declare and use '" + name + "' (see the standard uniform set in AGENTS.md)",
1130
+ )
1131
+ }
739
1132
  }
740
1133
  }
741
1134
  let program: ProgramId | undefined
@@ -743,12 +1136,47 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
743
1136
  // Attributes live in the vertex stage only, so unlike the uNormal scan
744
1137
  // there is nothing to look for in the fragment source.
745
1138
  let normalMatrix = /\buNormal\b/.test(opts.vertex) || /\buNormal\b/.test(opts.fragment)
1139
+ // Skinning is a vertex-stage affair like the attributes; a source that
1140
+ // mentions uBones skins by the mesh's palette texture (Material.skinned).
1141
+ let skinned = /\buBones\b/.test(opts.vertex)
746
1142
  let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
747
1143
  let depth = opts.depth ?? true
748
1144
  let cull = opts.cull ?? "back"
749
1145
  // An empty list declares nothing - same as absent (the engine requires an
750
1146
  // instance buffer exactly when attributes are declared).
751
1147
  let instanceAttributes = opts.instanceAttributes?.length ? opts.instanceAttributes.map(a => ({ ...a })) : undefined
1148
+ // The shadowVertex depth pass binds the instance slots its stage reads
1149
+ // from - the record slot always (a stage reading none of it cannot
1150
+ // place the instances, an error here), a style slot only when the
1151
+ // stage names one of its attributes - and declares each bound slot's
1152
+ // whole layout, since a slot's stride is the record's (an unread
1153
+ // attribute in a bound slot is inactive, like an extra geometry channel).
1154
+ let shadowAttributes: InstanceAttribute[] | undefined
1155
+ if (opts.shadowVertex !== undefined && instanceAttributes !== undefined) {
1156
+ let source = opts.shadowVertex
1157
+ let named = (a: InstanceAttribute) => new RegExp("\\b" + a.name + "\\b").test(source)
1158
+ let slots = new Set(instanceAttributes.filter(named).map(a => a.slot ?? 0))
1159
+ if (!slots.has(0)) throw new Error("shaderMaterial shadowVertex must read the slot-0 instance attributes it places instances by")
1160
+ shadowAttributes = instanceAttributes.filter(a => slots.has(a.slot ?? 0))
1161
+ }
1162
+ // The shadowVertex depth material: one class and one shared instance,
1163
+ // built the first time a caster of this class meets a shadow view.
1164
+ let shadowClass: ShaderMaterialClass | undefined
1165
+ let classShadow: Material | undefined
1166
+ let shadowFor = (): Material | undefined => {
1167
+ if (opts.shadowVertex === undefined) return undefined
1168
+ if (classShadow === undefined) {
1169
+ shadowClass = shaderMaterialClass({
1170
+ vertex: opts.shadowVertex,
1171
+ fragment: SHADOW_DEPTH_FRAGMENT,
1172
+ cull: shadowCull(cull),
1173
+ instanceAttributes: shadowAttributes,
1174
+ label: (opts.label ?? "shader-material") + "-shadow",
1175
+ })
1176
+ classShadow = shadowClass.instance()
1177
+ }
1178
+ return classShadow
1179
+ }
752
1180
  let programFor = (): ProgramId => {
753
1181
  if (program === undefined) {
754
1182
  let vs = compileShader("vertex", opts.vertex, { header: needsHeader(opts.vertex) })
@@ -788,16 +1216,18 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
788
1216
  instance(inst = {}) {
789
1217
  return {
790
1218
  normalMatrix,
1219
+ skinned,
791
1220
  attributes,
792
1221
  transparent,
793
1222
  instanceAttributes,
1223
+ instanceStyle: opts.instanceStyle,
794
1224
  pipeline: pipelineFor,
795
1225
  params: inst.params ?? {},
796
1226
  textures: inst.textures,
797
1227
  // Lazy: the depth materials are shaderMaterialClass instances
798
1228
  // themselves, so an eager variant would recurse into its own cache.
799
1229
  get shadow() {
800
- return inst.shadow ?? shadowVariant(cull)
1230
+ return inst.shadow ?? shadowFor() ?? shadowVariant(cull, skinned)
801
1231
  },
802
1232
  }
803
1233
  },
@@ -808,6 +1238,11 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
808
1238
  destroyProgram(program)
809
1239
  program = undefined
810
1240
  }
1241
+ if (shadowClass !== undefined) {
1242
+ shadowClass.dispose()
1243
+ shadowClass = undefined
1244
+ classShadow = undefined
1245
+ }
811
1246
  },
812
1247
  }
813
1248
  }