@solidrt/3d 0.0.54 → 0.0.56

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 (54) hide show
  1. package/AGENTS.md +1121 -177
  2. package/README.md +15 -7
  3. package/examples/README.md +86 -5
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +20 -5
  6. package/examples/collision.tsx +250 -0
  7. package/examples/first-person.tsx +119 -0
  8. package/examples/fleet.tsx +194 -0
  9. package/examples/fog.tsx +173 -0
  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 +32 -6
  17. package/examples/pick.tsx +2 -4
  18. package/examples/probe.tsx +101 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +32 -3
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +86 -0
  24. package/examples/skybox.tsx +128 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +132 -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 +199 -0
  32. package/src/color.ts +32 -0
  33. package/src/components.tsx +773 -82
  34. package/src/environment-bake.ts +492 -0
  35. package/src/environment.ts +325 -0
  36. package/src/first-person.ts +306 -0
  37. package/src/geometry.ts +6 -2
  38. package/src/glsl.ts +1390 -82
  39. package/src/gltf.ts +432 -112
  40. package/src/index.ts +57 -9
  41. package/src/light.ts +383 -0
  42. package/src/material.ts +682 -216
  43. package/src/math.ts +110 -0
  44. package/src/mesh.ts +728 -0
  45. package/src/mixer.ts +476 -0
  46. package/src/model-file.ts +80 -15
  47. package/src/model.ts +211 -34
  48. package/src/node.ts +506 -0
  49. package/src/orbit.ts +97 -48
  50. package/src/scene-pointer.ts +166 -0
  51. package/src/scene-shadows.ts +471 -0
  52. package/src/scene.ts +1304 -1532
  53. package/tools/environment.ts +69 -0
  54. package/tools/model.ts +4 -3
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, 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,70 +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.
95
- const UNLIT_VERTEX = glsl`
96
- in vec3 aPos;
97
- in vec2 aUV;
98
- out vec2 vUv;
99
- uniform mat4 uModel;
100
- uniform mat4 uViewProj;
101
-
102
- void main() {
103
- gl_Position = uViewProj * uModel * vec4(aPos, 1.0);
104
- vUv = aUV;
105
- }
106
- `
107
-
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
- `
124
- }
125
-
126
121
  // One shaderMaterialClass per unlit option combination (map x transparent
127
122
  // x cull x alphaTest), cached for the app's lifetime like lit's; one
128
123
  // pipeline per vertex layout inside each.
129
124
  let unlitClasses = new Map<string, ShaderMaterialClass>()
130
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
+
131
134
  export type UnlitOptions = {
132
- /** 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. */
133
140
  color?: [number, number, number] | [number, number, number, number]
134
- /** 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. */
135
145
  map?: TextureId
136
146
  /** Blend over what is behind (color alpha and map alpha both count).
137
147
  * Without it an alpha below 1 still draws opaque. See Material.transparent. */
@@ -148,6 +158,64 @@ export type UnlitOptions = {
148
158
  * fences want it with `cull: "none"`; a mapped cutout casts its cutout
149
159
  * (Material.shadow). */
150
160
  alphaTest?: number
161
+ /** Take the scene's fog (default true, Three's `material.fog`): the
162
+ * fragment fades toward the fog color with its distance from the
163
+ * camera once `scene.setFog` is set. `false` drops the fog code from
164
+ * the program - a sky sphere or a far backdrop that must keep its
165
+ * color, an emissive marker. */
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]]
151
219
  }
152
220
 
153
221
  /**
@@ -156,19 +224,26 @@ export type UnlitOptions = {
156
224
  * light list); see the scene-graph research note.
157
225
  */
158
226
  export function unlit(opts: UnlitOptions = {}): Material {
159
- let color = opts.color ?? [1, 1, 1]
160
- let a = color.length === 4 ? color[3] : 1
161
- let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
227
+ let uColor = colorParam(opts.color ?? [1, 1, 1])
162
228
  let map = opts.map !== undefined
163
229
  let transparent = opts.transparent === true
164
230
  let cull = opts.cull ?? "back"
165
231
  let alphaTest = opts.alphaTest !== undefined
166
- let key = [map, transparent, cull, alphaTest].join("|")
232
+ let fog = opts.fog !== false
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("|")
167
239
  let cls = unlitClasses.get(key)
168
240
  if (cls === undefined) {
169
241
  cls = shaderMaterialClass({
170
- vertex: UNLIT_VERTEX,
171
- fragment: unlitFragment(map, alphaTest, transparent),
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,
172
247
  transparent,
173
248
  cull,
174
249
  label: "scene-unlit-" + key,
@@ -177,10 +252,14 @@ export function unlit(opts: UnlitOptions = {}): Material {
177
252
  }
178
253
  let params: ShaderParams = { uColor }
179
254
  if (alphaTest) params.uAlphaTest = opts.alphaTest!
255
+ if (mapTransform) params.uMapTransform = mapTransformParam(opts.mapTransform!)
180
256
  return cls.instance({
181
257
  params,
182
258
  textures: map ? { uMap: opts.map! } : undefined,
183
- 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,
184
263
  })
185
264
  }
186
265
 
@@ -198,6 +277,58 @@ export type LitOptions = UnlitOptions & {
198
277
  * each part's size or UVs; the map must be created with
199
278
  * `wrap: "repeat"`. */
200
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
201
332
  /**
202
333
  * Receive the scene's directional shadows (default true, like Godot and
203
334
  * Three): each casting light's term is multiplied by its shadow-map
@@ -213,100 +344,54 @@ export type LitOptions = UnlitOptions & {
213
344
  receiveShadow?: boolean
214
345
  }
215
346
 
216
- // The lit fragment is composed from the same exported pieces an app
217
- // composes by hand, per flag: map x vertexColors x triplanar x shadow x
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,
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,
225
363
  // directional light i's maps (one, or its cascades) as map slots
226
364
  // uShadowFirst[i] .. + uShadowCount[i] with a tile rect and a matrix
227
365
  // each, and its biases (target-level, bound by the scene); uShadowCount
228
366
  // 0 means it does not cast; SHADOW_LOOKUP turns the index into the factor.
229
367
  // 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.
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.
232
371
  type LitClass = {
233
372
  map: boolean
234
373
  vertexColors: boolean
235
374
  triplanar: boolean
236
375
  transparent: boolean
237
- shadow: boolean
376
+ receiveShadow: boolean
238
377
  cull: CullMode
239
378
  alphaTest: boolean
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
240
390
  }
241
391
 
242
392
  function litClassKey(c: LitClass): string {
243
393
  return Object.values(c).join("|")
244
394
  }
245
-
246
- function litFragment(c: LitClass): string {
247
- let { map, vertexColors, triplanar, shadow, alphaTest } = c
248
- let backFaces = c.cull !== "back"
249
- return glsl`
250
- in vec3 vWorldPos;
251
- in vec3 vNormal;
252
- in vec2 vUv;
253
- ${vertexColors ? "in vec4 vColor;" : ""}
254
- uniform vec4 uColor;
255
- ${map ? "uniform sampler2D uMap;" : ""}
256
- uniform float uSpecular;
257
- uniform float uShininess;
258
- ${triplanar ? "uniform float uTriplanar;" : ""}
259
- ${alphaTest ? "uniform float uAlphaTest;" : ""}
260
- uniform vec3 uCamPos;
261
- uniform vec3 uHemiSky;
262
- uniform vec3 uHemiGround;
263
- uniform int uLightCount;
264
- uniform vec3 uLightDir[${MAX_LIGHTS}];
265
- uniform vec3 uLightColor[${MAX_LIGHTS}];
266
- ${
267
- shadow
268
- ? `${SHADOW_SLOTS}
269
- ${SHADOW}
270
- ${SHADOW_LOOKUP}`
271
- : ""
272
- }
273
- ${HEMISPHERE}
274
- ${LAMBERT}
275
- ${BLINN_SPECULAR}
276
-
277
- void main() {
278
- vec3 n = normalize(vNormal);
279
- ${backFaces ? "if (!gl_FrontFacing) n = -n;" : ""}
280
- vec4 base = uColor;
281
- ${
282
- map
283
- ? triplanar
284
- ? `vec3 w = pow(abs(n), vec3(4.0));
285
- w /= w.x + w.y + w.z;
286
- vec3 p = vWorldPos * uTriplanar;
287
- base *= texture(uMap, p.yz) * w.x + texture(uMap, p.xz) * w.y + texture(uMap, p.xy) * w.z;`
288
- : "base *= texture(uMap, vUv);"
289
- : ""
290
- }
291
- ${vertexColors ? "base *= vColor;" : ""}
292
- ${alphaTest ? "if (base.a < uAlphaTest) discard;" : ""}
293
- vec3 v = normalize(uCamPos - vWorldPos);
294
- vec3 light = hemisphere(n, uHemiSky, uHemiGround);
295
- vec3 spec = vec3(0.0);
296
- for (int i = 0; i < ${MAX_LIGHTS}; i++) {
297
- if (i >= uLightCount) break;
298
- vec3 l = uLightDir[i];
299
- ${
300
- shadow ? "float s = lightShadow(i, vWorldPos, n);" : "float s = 1.0;"
301
- }
302
- light += uLightColor[i] * lambert(n, l) * s;
303
- spec += uLightColor[i] * blinnSpecular(n, v, l, uShininess) * s;
304
- }
305
- fragColor = vec4(base.rgb * light + spec * uSpecular * base.a, ${c.transparent ? "base.a" : "1.0"});
306
- }
307
- `
308
- }
309
-
310
395
  let litClasses = new Map<string, ShaderMaterialClass>()
311
396
 
312
397
  /**
@@ -320,61 +405,328 @@ let litClasses = new Map<string, ShaderMaterialClass>()
320
405
  * starts at zero: set at least one of the two.
321
406
  */
322
407
  export function lit(opts: LitOptions = {}): Material {
323
- let color = opts.color ?? [1, 1, 1]
324
- let a = color.length === 4 ? color[3] : 1
325
- let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
408
+ let uColor = colorParam(opts.color ?? [1, 1, 1])
326
409
  let map = opts.map !== undefined
327
410
  let triplanar = map && opts.triplanar !== undefined
328
411
  let alphaTest = opts.alphaTest !== undefined
329
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
+ }
330
428
  let flags: LitClass = {
331
429
  map,
332
430
  vertexColors: opts.vertexColors === true,
333
431
  triplanar,
334
432
  transparent: opts.transparent === true,
335
- shadow: opts.receiveShadow !== false,
433
+ receiveShadow: opts.receiveShadow !== false,
336
434
  cull,
337
435
  alphaTest,
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,
338
447
  }
339
448
  let key = litClassKey(flags)
340
449
  let cls = litClasses.get(key)
341
450
  if (cls === undefined) {
342
451
  cls = shaderMaterialClass({
343
- vertex: flags.vertexColors ? LIT_VERTEX_COLORED : LIT_VERTEX,
452
+ vertex: litVertex(flags),
344
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,
345
457
  transparent: flags.transparent,
346
458
  cull,
347
459
  label: "scene-lit-" + key,
348
460
  })
349
461
  litClasses.set(key, cls)
350
462
  }
351
- 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
+ }
352
468
  if (triplanar) params.uTriplanar = opts.triplanar!
353
469
  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
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.
357
491
  let material = cls.instance({
358
492
  params,
359
- textures: map ? { uMap: opts.map! } : undefined,
360
- 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,
361
498
  })
362
499
  return material
363
500
  }
364
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
+
365
706
  // The shadow depth pass: position only, no color of interest (the target's
366
707
  // depth texture is the output; the color write is the pipeline's minimum).
367
708
  // Front faces culled, Three's shadowSide default: the map holds each
368
709
  // caster's BACK surface, so a receiving front face at the same depth
369
710
  // compares lit without a bias and acne needs no fighting on closed meshes.
370
- 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`
371
719
  in vec3 aPos;
720
+ ${skinned ? SKIN_DECLS : ""}
721
+ ${instanced ? INSTANCE_MATRIX : ""}
372
722
  uniform mat4 uModel;
373
723
  uniform mat4 uViewProj;
374
724
  void main() {
375
- gl_Position = uViewProj * uModel * vec4(aPos, 1.0);
725
+ ${skinned ? SKIN_MATRIX : ""}
726
+ gl_Position = uViewProj * uModel * ${instanced ? `(instanceMatrix() * ${local})` : local};
376
727
  }
377
728
  `
729
+ }
378
730
 
379
731
  const SHADOW_DEPTH_FRAGMENT = glsl`
380
732
  void main() {
@@ -382,22 +734,23 @@ const SHADOW_DEPTH_FRAGMENT = glsl`
382
734
  }
383
735
  `
384
736
 
385
- let shadowDepth = new Map<CullMode, Material>()
737
+ let shadowDepth = new Map<string, Material>()
386
738
 
387
739
  /** The override material of a scene's shadow view (internal): one class
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)
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)
393
746
  if (material === undefined) {
394
747
  material = shaderMaterialClass({
395
- vertex: SHADOW_DEPTH_VERTEX,
748
+ vertex: shadowDepthVertex(skinned, false),
396
749
  fragment: SHADOW_DEPTH_FRAGMENT,
397
750
  cull,
398
- label: "scene-shadow-depth-" + cull,
751
+ label: "scene-shadow-depth-" + key,
399
752
  }).instance()
400
- shadowDepth.set(cull, material)
753
+ shadowDepth.set(key, material)
401
754
  }
402
755
  return material
403
756
  }
@@ -408,56 +761,56 @@ function shadowCull(cull: CullMode): CullMode {
408
761
  return cull === "none" ? "none" : cull === "back" ? "front" : "back"
409
762
  }
410
763
 
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))
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)
415
770
  }
416
771
 
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
- `
772
+ let unlitShadowClasses = new Map<string, ShaderMaterialClass>()
441
773
 
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)
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)
448
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")
449
796
  cls = shaderMaterialClass({
450
- vertex: SHADOW_CUTOUT_VERTEX,
451
- fragment: SHADOW_CUTOUT_FRAGMENT,
797
+ vertex: unlitVertex({ skinned, instanced, instanceColors }),
798
+ fragment,
452
799
  cull,
453
- label: "scene-shadow-cutout-" + cull,
800
+ instanceAttributes: stockInstanceAttributes(instanced, instanceColors),
801
+ label: "scene-unlit-shadow-" + key,
454
802
  })
455
- shadowCutout.set(cull, cls)
803
+ unlitShadowClasses.set(key, cls)
456
804
  }
457
- 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 } })
458
808
  }
459
809
 
460
- 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"> & {
461
814
  /** Which way the quad turns to face the camera. `"full"` (default,
462
815
  * Three's Sprite): both axes follow the view, the quad is always flat
463
816
  * to the screen. `"fixed-y"` (Godot's BILLBOARD_FIXED_Y): only the yaw
@@ -477,6 +830,7 @@ const SPRITE_VERTEX_SRC = glsl`
477
830
  in vec3 aPos;
478
831
  in vec2 aUV;
479
832
  out vec2 vUv;
833
+ out vec3 vWorldPos;
480
834
  uniform mat4 uModel;
481
835
  uniform mat4 uViewProj;
482
836
  uniform vec3 uCamRight;
@@ -486,6 +840,7 @@ const SPRITE_VERTEX_SRC = glsl`
486
840
  vec3 center = uModel[3].xyz;
487
841
  vec2 size = vec2(length(uModel[0].xyz), length(uModel[1].xyz));
488
842
  vec3 world = center + uCamRight * (aPos.x * size.x) + uCamUp * (aPos.y * size.y);
843
+ vWorldPos = world;
489
844
  gl_Position = uViewProj * vec4(world, 1.0);
490
845
  vUv = aUV;
491
846
  }
@@ -495,6 +850,7 @@ const SPRITE_FIXED_Y_VERTEX_SRC = glsl`
495
850
  in vec3 aPos;
496
851
  in vec2 aUV;
497
852
  out vec2 vUv;
853
+ out vec3 vWorldPos;
498
854
  uniform mat4 uModel;
499
855
  uniform mat4 uViewProj;
500
856
  uniform vec3 uCamPos;
@@ -507,6 +863,7 @@ const SPRITE_FIXED_Y_VERTEX_SRC = glsl`
507
863
  float len = length(toCam);
508
864
  vec3 right = len > 1e-6 ? vec3(toCam.z, 0.0, -toCam.x) / len : vec3(1.0, 0.0, 0.0);
509
865
  vec3 world = center + right * (aPos.x * size.x) + vec3(0.0, aPos.y * size.y, 0.0);
866
+ vWorldPos = world;
510
867
  gl_Position = uViewProj * vec4(world, 1.0);
511
868
  vUv = aUV;
512
869
  }
@@ -525,18 +882,22 @@ let spriteClasses = new Map<string, ShaderMaterialClass>()
525
882
  * for an opaque one. Culling is off: a camera-facing quad has no back.
526
883
  */
527
884
  export function sprite(opts: SpriteOptions = {}): Material {
528
- let color = opts.color ?? [1, 1, 1]
529
- let a = color.length === 4 ? color[3] : 1
530
- 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])
531
891
  let map = opts.map !== undefined
532
892
  let transparent = opts.transparent !== false
533
893
  let fixedY = opts.billboard === "fixed-y"
534
- let key = [map, transparent, fixedY].join("|")
894
+ let fog = opts.fog !== false
895
+ let key = [map, transparent, fixedY, fog].join("|")
535
896
  let cls = spriteClasses.get(key)
536
897
  if (cls === undefined) {
537
898
  cls = shaderMaterialClass({
538
899
  vertex: fixedY ? SPRITE_FIXED_Y_VERTEX_SRC : SPRITE_VERTEX_SRC,
539
- fragment: unlitFragment(map, false, transparent),
900
+ fragment: unlitFragment({ map, transparent, fog }),
540
901
  transparent,
541
902
  cull: "none",
542
903
  label: "scene-sprite-" + key,
@@ -567,21 +928,51 @@ function needsHeader(source: string): boolean {
567
928
  // engine's own attributeless fullscreen triangle (gl_VertexID, no vertex
568
929
  // buffer), emitting the SAME vUV the shader-target contract provides: 0..1
569
930
  // with origin at the displayed top-left - so a backdrop fragment written
570
- // 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.
571
938
  const BACKGROUND_VERTEX = glsl`
939
+ uniform mat4 uInvViewProj;
572
940
  out vec2 vUV;
941
+ out vec3 vRay;
573
942
  void main() {
574
943
  vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
575
944
  vUV = p;
576
- 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);
577
950
  }
578
951
  `
579
952
 
580
953
  // Pipeline fragments get no vUV from the engine preamble (a pipeline's
581
954
  // varyings are its own), so the background slot injects the full
582
- // 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.
583
958
  const BACKGROUND_FRAGMENT_PREAMBLE =
584
- "#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
+ `
585
976
 
586
977
  /** The scene's background pipeline (internal - reached via
587
978
  * scene.setBackground): depth-free, attributeless, drawn as entry zero of
@@ -633,12 +1024,40 @@ export type ShaderMaterialClassOptions = {
633
1024
  * beside the layout's own, and each drawn instance gets one record from
634
1025
  * the mesh's instance buffer (interleaved floats in this order). A class
635
1026
  * with instance attributes makes INSTANCED materials: attach their meshes
636
- * with createInstancedMesh, which carries the records - a createMesh mesh
637
- * is rejected at add(). A per-instance transform is data, not a matrix:
638
- * a position/yaw/scale record beats four vec4 columns for most fleets,
639
- * 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.
640
1059
  */
641
- instanceAttributes?: VertexAttribute[]
1060
+ shadowVertex?: string
642
1061
  /** Blend over what is behind, with the scene sorting this material's
643
1062
  * meshes back-to-front after the opaque ones (see Material.transparent).
644
1063
  * Sets the pipeline defaults blend "alpha" and depthWrite false; the
@@ -665,8 +1084,11 @@ export type ShaderMaterialInstanceOptions = {
665
1084
  params?: ShaderParams
666
1085
  textures?: TextureBindings
667
1086
  /** 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. */
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. */
670
1092
  shadow?: Material
671
1093
  }
672
1094
 
@@ -701,10 +1123,12 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
701
1123
  // shared params skipping undeclared names the omission would otherwise
702
1124
  // surface as a silently untransformed render, not an error.
703
1125
  for (let name of ["uModel", "uViewProj"]) {
704
- if (!new RegExp("\\b" + name + "\\b").test(opts.vertex)) {
705
- throw new Error(
706
- "shaderMaterial vertex stage must declare and use '" + name + "' (see the standard uniform set in AGENTS.md)",
707
- )
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
+ }
708
1132
  }
709
1133
  }
710
1134
  let program: ProgramId | undefined
@@ -712,12 +1136,47 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
712
1136
  // Attributes live in the vertex stage only, so unlike the uNormal scan
713
1137
  // there is nothing to look for in the fragment source.
714
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)
715
1142
  let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
716
1143
  let depth = opts.depth ?? true
717
1144
  let cull = opts.cull ?? "back"
718
1145
  // An empty list declares nothing - same as absent (the engine requires an
719
1146
  // instance buffer exactly when attributes are declared).
720
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
+ }
721
1180
  let programFor = (): ProgramId => {
722
1181
  if (program === undefined) {
723
1182
  let vs = compileShader("vertex", opts.vertex, { header: needsHeader(opts.vertex) })
@@ -757,16 +1216,18 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
757
1216
  instance(inst = {}) {
758
1217
  return {
759
1218
  normalMatrix,
1219
+ skinned,
760
1220
  attributes,
761
1221
  transparent,
762
1222
  instanceAttributes,
1223
+ instanceStyle: opts.instanceStyle,
763
1224
  pipeline: pipelineFor,
764
1225
  params: inst.params ?? {},
765
1226
  textures: inst.textures,
766
1227
  // Lazy: the depth materials are shaderMaterialClass instances
767
1228
  // themselves, so an eager variant would recurse into its own cache.
768
1229
  get shadow() {
769
- return inst.shadow ?? shadowVariant(cull)
1230
+ return inst.shadow ?? shadowFor() ?? shadowVariant(cull, skinned)
770
1231
  },
771
1232
  }
772
1233
  },
@@ -777,6 +1238,11 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
777
1238
  destroyProgram(program)
778
1239
  program = undefined
779
1240
  }
1241
+ if (shadowClass !== undefined) {
1242
+ shadowClass.dispose()
1243
+ shadowClass = undefined
1244
+ classShadow = undefined
1245
+ }
780
1246
  },
781
1247
  }
782
1248
  }